第一章、为什么数组的下标一般从0开始编号
提到数组,读者肯定不陌生,甚至还会很自信地说,数组很简单。编程语言中一般会有数组这种数据类型。不过,它不仅是编程语言中的一种数据类型,还是基础的数据结构。尽管数组看起来非常基础,简单,但是深究起来,数组还有很多值得思考的地方。
例如,在大部分编程语言中,数组的下标是从0开始编号的。读者是否想过,为什么数组的下标要从0开始编号,而不是从1开始尼?从1开始编号不是更符合人类的思维习惯吗?读者可以带着这些问题学习本章的内容。
一、数组的定义
什么是数组?
数组是一种线性表数据结构,它用一组连续的内存空间存储一组具有相同类型的数据。
数组的定义中的第一个关键词是“线性表”(linear list)。顾名思义,线性表指的是数据排列成一条线一样的结构。线性表中的数据只有前、后两个方向。其实,除数组之外,本章要讲到的链表,栈和队列都是线性表结构,如图所示:
与线性表相对立的概念是非线性表,如树,图等,如图所示。之所以称为非线性表,是因为数据之间并不是简单的前后关系。
数组的定义中的第二个关键词和第三个关键词是“连续的内存空间”和“相同类型的数据”。正是因为这两个限制,数组才有了一个重要的特性:随机访问。不过,有利就有弊,这两个限制也让数组的很多操作变得非常低效。例如,要想在数组中插入或者删除一个数据,为了保证数组中存储数据的连续性,我们需要做大量的数据搬移工作。
二、寻址公式和随机访问特性
“随机访问”具体指的是:支持在O(1)时间复杂度内按照下标快速访问数组中的元素。我们用一个长度为10,int类型的数组a[10](代码实现为 int[] a = new int[10];)来举例。假设计算机给数组a[10]分配了一块连续内存空间,其中,内存空间的首地址 base_address = 1000。数组的内存存储模型如图所示:
我们知道,计算机会给每个内存单元分配一个地址,目的是方便计算机通过地址来访问内存中的数据。当计算机想要访问下标为 i 的数组元素时,它首先通过下面的寻址公式:
a[i]_address = base_address + i x data_type_size
其中,data_type_size表示数组中每个元素的大小。由于数组中存储的是int类型的数据(int类型占4字节的存储空间),因此 data_type_size就等于4。
在这里,作者要纠正一个“错误”。作者在面试应聘者的时候,常常会向应聘者询问数组和链表的区别,很多应聘者回答:“链表适合插入,删除,对应的时间复杂度为O(1);数组适合查找,查找的时间复杂度为O(1)”。
实际上,这种表述是不准确的,因为在数组中查找数据的时间复杂度并不为O(1)。即便是排好序的数组,用二分查找,时间复杂度也只能达到O(logn)。因此,正确的表述应该是:数组支持随机访问,根据下标访问元素的时间复杂度为O(1)。
三、低效的插入和删除操作
在上文中,我们提到,为了保持内存数据的连续性,数组的插入,删除操作会比较低效。现在我们就来解释一下为什么这两种操作会低效,同时探讨一下有哪些改进方法。
我们先来看插入操作。
假设数组的长度为n。现在,假设我们需要将一个数据插入到数组中的第k个位置。为了把第k个位置腾出来给新来的数据,我们需要将第k~n这部分元素顺序地往后移动一位。在这种情况下,插入操作的时间复杂度是多少尼?读者可以自己先试着分析一下。
如果在数组的末尾插入元素,那么不需要移动数据,最好情况时间复杂度为O(1)。但如果在数组的开头插入元素,那么所有的数据都需要依次往后移动一位,最坏情况时间复杂度为O(n)。
因为在每个位置插入元素的概率是相同的,一共有 n + 1种情况,所以平均情况时间复杂度为 (1 + 2 +...+n)/(n + 1) = O(n)。
如果数组中的数据是有序的,在某个位置(假设下标为k的位置)插入一个新的数据时。就必须按照刚才的方法,搬移下标k之后的数据。但是,如果数组中存储的数据并没有任何规律,那么数组只是被当成一个存储数据的集合。在这种情况下,为了避免大规模的数据搬移,我们可以将第k位的数据搬移到数组的最后,然后把新数据直接放到第k个位置即可。为了更好地理解这段描述,我们通过一个例子来进一步解释一下。
假设数组a中存储了5个元素:a、b、c、d、e。现在,需要将元素x插入到第3个位置。按照上面的处理思路,只需要将原来在第3个位置的c放入到a[5] 这个位置,然后将 a[2] 赋值为x。最后,数组中的元素为 a、b、x、d、e、c,如下图所示:
利用这种处理技巧,在特定场景下,在第k个位置插入数据的时间复杂度就变成了O(1)。这种处理思路在快速排序中也会用到。
下面再看一下删除操作。
与插入操作类似,如果我们要删除第k个位置的数据,为了存储数据的连续性,那么也需要搬移数据,不然中间就会存在已经删除的数据,数组中的数据就不连续了。因此,如果删除数组末尾的数据,则最好时间复杂度为O(1);如果删除数组开头的数据,则最坏时间复杂度为O(n);如果删除任意位置的数据,则平均时间复杂度为O(n)。
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率就会提高很多。我们还是通过例子来解释。
假设数组a[10] 中存储了8个元素:a、b、c、d、e、f、g、和h。现在,我们要依次删除a、b和c,如图所示:
为了避免d、e、f、g和h这几个元素被搬移3次,每次的删除操作并不真正地搬移数据,而只是标记数据已被删除。当数组中没有更多的存储空间时,我们再集中触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移次数。
如果读者了解JVM(java虚拟机),就会发现,这不就是JVM标记清除“垃圾”回收算法的核心思想吗?没错。数据结构和算法的魅力就在于此。很多时候我们并不需要“死记硬背”某个数据结构或算法,而是要学习其背后的思想和处理技巧。这些东西才是最有价值的。如果读者细心留意,就会发现,无论是在软件开发还是架构设计中,总能找到数据结构和算法的影子。
四、警惕数组访问越界问题
在C语言中,数组访问越界是一种未决行为,换句话说,C语言规范并没有规定数组访问越界时编译器应该如何处理。访问数组的本质就是访问一段连续内存,只要通过偏移计算得到的内存地址是可用的,即便数组访问越界,程序就有可能不会报出任何错误。
数组访问越界一般会导致程序出现莫名其妙的运行错误,调试的难度非常大。除此之外,很多计算机病毒也正是利用了数组越界可以访问非法地址的漏洞来攻击系统的。因此,在写代码的时候,我们一定要警惕数组访问越界问题。
但并非所有的语言都像C语言一样,把数组越界检查的工作“交”给程序员来做。例如Java语言,它本身就会进行越界检查,如下面这两行Java代码,数组访问越界,运行时就会抛出java.lang.ArrayIndexOutOfBoundsException 异常。
public static void main(String[] args) {
int[] ints = new int[3];
System.out.println(ints[1]); //0
// System.out.println(ints[3]); //java.lang.ArrayIndexOutOfBoundsException
ints[3] = 10; //java.lang.ArrayIndexOutOfBoundsException
}
数组访问越界异常,python会抛出IndexError异常
my_list = [i for i in range(3)]
print(my_list[3]) # IndexError: list index out of range
五、容器能否完全替代数组
针对数组类型,很多编程语言提供了容器类,如Java中的ArrayList,Python中的list集合。在项目开发中,什么时候适合用高数组?什么时候适合用容器?
这里作者用Java语言来举例。如果读者是Java工程师,应该很熟悉ArrayList,那么它与数组相比,到底有哪些优势尼?
ArrayList最大优势是:可以将很多数组操作的细节封装起来,如上文提到的数组插入,删除数据时的搬移操作。除此之外,他还有一个优势,就是支持动态扩容。
因为数组需要连续的内存存储空间,所以在定义的时候,需要预先指定内存空间大小。如果我们申请了一个大小为10的数组,当第11个数组需要存储到数组中,就需要重新分配一块更大的内存空间,将原来的数据复制过去,然后将新的数据插入。
如果使用ArrayList,我们就完全不需要关系底层的扩容逻辑,刚才提到的这些细节会封装在ArrayList中。
这里需要注意一点,由于扩容操作涉及内存申请和数据搬移,是比较耗时的,因此,如果事先能确定需要存储的数据的大小,最好在创建ArrayList的时候,事先指定容器的大小,这样就能避免在插入数据的过程中出现频繁的扩容操作。举例如下:
ArrayList<Object> objects = new ArrayList<>(10000); //实现指定容器大小
对于使用高级语言编程的读者,有了容器,数组是不是就无用武之地了尼?当然不是,有些时候,用数组会更加合适,如下面集中情况。
- Java ArrayList无法存储基本类型,如int,long,需要封装为Integer类和Long类,而自动装箱(autoboxing),拆箱(unboxing)有一定的性能消耗,因此,如果特别关注性能,或者希望使用基本类型,就可以选用数组。
- 如果数据大小事先已知,并且对数据的操作非常简单,用不到ArrayList提供的大部分方法,那么可以直接使用数组。
- 还有一个算是作者的个人喜好:当需要表示多维数组时,使用数组往往会更加直观,如Object[] array。而如果使用容器的话,那么需要这样定义:ArrayList<ArrayList<Object>> array。这样编写比较麻烦,可读性也不如Object[][] array强。
总结一下,对于业务开发,直接使用容器就足够了,省时又省力。毕竟损耗一些性能,不会影响系统整体的性能。但如果我们进行的是一些底层的开发,如开发网络框架,性能的优化需要做到极致,这个时候,数组就会优于容器,成为首选。
六、解答本章开篇问题
现在我们来看一下开篇的问题:为什么在大多数编程语言中,数组的下标从0开始编号,而不是从1开始编号尼?
从数组存储的内存模型来看,“下标”确切的定义应该是“偏移”(offset)。a[0]就是相对于首地址偏移为0的内存地址,a[k]就是相对于首地址偏移 k 个type_size的内存地址。从0开始编号,计算a[k] 的内存地址只需要用下式:
a[k]_address = base_address + k x data_type_size
但是,如果从1开始编号,计算a[k]的内存地址的公式就会变成如下:
a[k]_address = base_address + (k-1) x data_type_size
对比上面的两个公式,我们不难发现,如果数组下标从1开始编号,每次按照下标访问数组元素,会多一次减法运算。数组是基础的数据结构,通过下标访问数组元素又是其基础的操作,效率的优化就要尽可能做到极致。因此,为了减少一次减法操作,数组的下标选择了从0开始编号,而不是从1开始编号。
不过,这个理由可能还不够充分。作者认为,数组的下标从0开始编号还是有其历史原因的。最初,C语言设计者用0作为数组的起始下标,目的是在一定程度上减少C语言程序员学习其他编程语言的成本,之后的Java,JavaScript等效仿了C语言,继续沿用了数组下标从0开始编号的方式。
当然,也并不是所有的编程语言中的数组下标都是从0开始编号,如MATLAB。甚至,一些语言支持负数下标,如Python。
七、内容小结
数组是简单的数据结构,是很多数据结构的实现基础。数组用一块连续的内存空间存储相同类型的一组数据。数组最大的特点是:
支持在O(1)的时间复杂度内按照下标快速访问元素,但插入操作,删除操作也因此变得比较低效,平均时间复杂度为O(n)。
在平时的业务开发中,我们可以直接使用编程语言提供的数组类容器,如Java ArrayList,但是,对于偏底层的开发,考虑到性能,直接使用数组可能会更加合适。
八、思考题
本章讲到了一维数组的内存寻址公式,类比一下,二维数组的内存寻址公式是怎么的?
类比一维数组的寻址公式,对于m x n的二维数组,其寻址公式分下列两种情况。
第一种情况:如果数组是按行存储的(先存储第一行,在存储第二行,依次类推),那么二维数组的寻址公式为:
a[i][j]_address = base_address + (n x i + j) x data_type_size
第二种情况:如果数组是按列存储的(先存储第一列,在存储第二列,依次类推),那么二维数组的寻址公式为:
a[i][j]_address = base_address + (m x j + i) x data_type_size
除此之外,有很多编程语言对数组重新进行了定义和改造,它们的二维数组的寻址公式无法满足前面给出的标准形式,如Java中的多维数组的内存空间是不连续的。
第二章、数据结构中的数组和编程语言中的数组的区别
在上一章中,我们提到了数组是存储相同数据类型的一块连续的存储空间。解读一下:数组中的数据必须是相同类型的,数组中的数据必须是连续存储的。只有这样,数组才能实现根据下标快速地访问数据。
有些读者可能会发现,在有些编程语言中,“数组”这种数据类型并不一定完全符合上面的定义。例如,在JavaScript中,数组中的数据不一定是连续存储的,也不一定是相同类型的,甚至,数组还可以是变长的。
var arr = new array(4, "hello", new Date());
在大部分关于数据结构和算法的图书中,在提到二维数组或者多维数组中数据的存储方法的时候,一般会这样介绍:二维数组中的数据已“先按行,再按列”(或者“先按列,再按行”)的方式依次存储在连续的存储空间中。如果二维数组定义为 a[n][m],那么a[i][j]的寻址公式如式所示:(以“先按行,再按列”的方式存储)。
address_a[i][j] = base_address + (i x m + j) x data_type_size
但是,在有些编程语言中,二维数组并不满足上面的定义,寻址公式也并非如此。例如Java中的二维数组的第二维可以是不同长度的,如下所示,而且第二维的3个数组(arr[0],arr[1],arr[2])在内存中也并非连续存储。
public static void main(String[] args) {
int arr[][] = new int[3][];
arr[0] = new int[1];
arr[1] = new int[2];
arr[2] = new int[3];
System.out.println(Arrays.toString(arr)); // [[I@1b6d3586, [I@4554617c, [I@74a14482]
}
难道有些关于数据结构和算法的图书里的讲解脱离实践?难道编程语言中的数组没有完全按照数组的定义来设计?哪个对尼?
实际上,这两种讲法都没错。编程语言中的“数组”并不完全等同于我们在介绍数据结构和算法的时候提到的“数组”。编程语言在实现自己的“数组”类型的时候,并不是完全遵循数据结构中的“数组”的定义,而是针对编程语言自身的特点,进行了相应的调整。
在不同的编程语言中,数组这种数据类型的实现方法不大相同。本章利用几种比较经典的编程语言,如C语言,Java,JavaScript,向读者介绍一下几种比较有代表性的数组实现方式。