数据结构与算法之美 课程笔记四 数组

问题:为什么很多编程语言中数组都从0开始编号?

一、如何实现随机访问?

数组是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。几个关键词:

1、线性表(Linear List)

线性表是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。除了数组,链表、队列、栈等也是线性表结构。

与它相对立的是非线性表,比如二叉树、堆、图等。在非线性表中,数据之间并不是简单的前后关系。

2、连续的存储空间和相同类型的数据

因为这两个限制,数组有一个“杀手锏”的特性:“随机访问”。但这两个限制也让数组的很多操作变得非常低效,如删除、插入一个数据时,为保证数据的连续性,就需要大量的数据搬移。

数组是如何实现根据下标随机访问数组元素的?

在上图中,为长度为10的int类型数组a[10]分配了一块连续内存空间1000~1039,内存块的首地址为base_address=1000。

当需要根据下标随机访问数组中的某个元素时,会通过下面的寻址公式,计算出该元素存储的内存地址:

a[i]_address = base_address + i * data_type_size

其中data_type_size表示数组中每个元素的大小。

对于m*n的二维数组,a[i][j](i < m, j < n)的内存地址为:

a[i][j]_address = base_address + ( i * n + j ) * data_type_size

纠正误区:

在问到数组和链表的区别时,答案常常是“链表适合插入、删除,时间复杂度为O(1);数组适合查找,查找时间复杂度为O(1)“。

上面的表述是不准确的。数组是适合查找,但查找的时间复杂度并不为O(1),就算是排好序的数组,用二分查找,时间复杂度也是O(logn)。正确的表述是,数组支持随机访问,根据下标随机访问的时间复杂度为O(1)。

二、低效的“插入”和“删除”

1、插入操作

假设数组的长度为n,现在要将一个数据插入到数组的第k个位置,需要将第k~n这部分的元素都顺序地后挪一位。

如果在数组的末尾插入元素,就无需移动数据,最好情况时间复杂度为O(1),但如果在数组的开头插入元素,则所有元素都需要后移一位,最坏情况时间复杂度为O(n)。而在每个位置插入的概率是一样的,所以平均情况时间复杂度为(1+2+...+n)/n=O(n)。

如果数组中的数据是有序的,在某个位置插入数据时,数据搬移将无法避免。但如果数组中存储的数据没有任何规律,在将数据插入到第k个位置时,为了避免数据搬移,一个简单的方法就是直接将第k位的数据搬移到数组元素的最后,把新的元素直接放入第k个位置。这样在第k个位置插入一个元素的时间复杂度就会降为O(1)。

2、删除操作

如果要删除第k个位置的数据,为了内存的连续性,也需要搬移数据,否则中间就会出现空洞,内存就不连续了。

如果删除数组末尾的数据,则最好情况时间复杂度为O(1);如果删除开头的数据,则最坏情况时间复杂度为O(n);平均情况时间复杂度也为O(n)。

在某些特殊情况下,并不一定非得追求数组中数据的连续性。可以先记录下要删除的数据,当数组没有更多空间存储数据时,再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

这就是JVM标记清除垃圾回收算法的核心思想。

三、警惕数组的访问越界问题

int main(int argc, char* argv[]) {
    int i = 0;
    int arr[3] = {0};
    for (; i <=3; i++) {
        arr[i] = 0;
        printf("hello world\n");
    }
    return 0;
}

这段代码运行后会无限打印“hello world”,这是因为数组越界引起的,当i=3时,a[3]访问数组越界。而在C语言中,所有的内存都是可以自由访问的,a[3]访问的正好是存储变量i的内存地址。(为什么?这跟内存布局有关。在C语言中,函数体内的局部变量存在栈上,且是连续压栈。在Linux进行的内存布局中,占区是高地址空间,从高向低增长。变量i和arr在相邻地址,且i比arr的地址大,所以arr越界正好访问到i。前提是i和arr元素同类型,否则代码仍是未决行为。)那么a[3]=0就相当于i=0,所以就会导致无限循环。

数组越界在C语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。

但并非所有的语言都像C一样,像Java就会做越界检查。

四、容器能否完全替代数组?

针对数组类型,很多语言都提供了容器类,如Java中的ArrayList,C++ STL中的verctor。

ArrayList与数组相比,又哪些优势?

(1)ArrayList可以将很多数组操作的细节封装起来,如前面提到的数据搬移等

(2)支持动态扩展。数组在定义的时候需要预先指定大小,因为需要分配连续的内存空间。当存储空间不足时,需要重新分配一块更大的空间,并将原来的数据复制过去,再插入新的数据。而ArrayList在空间不足时,会将空间自动扩容为1.5倍大小。

注意:因为扩容操作设计内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建ArrayList时指定数据大小。

数组更合适的场景:

1、Java ArrayList无法存储基本类型,如int需要封装成Integer类,而Autoboxing、Unboxing则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。

2、如果数据大小事先已知,并且对数据的操作非常简单,用不到ArrayList提供的大部分方法,也可以直接使用数组。

3、当要表示多维数组时,用数组会更加直观。

对于业务开发,直接使用容器就足够了,省事省力。但如果做一些非常底层的开发,如开发网络框架,性能的优化需要做到极致,这时数组就会是首选。

五、解答开篇

从数组存储的内存模型上看,“下标”确切的定义应该是“偏移(offset)”。a[0]就是偏移为0的位置,a[k]就是偏移k个type_size的位置,所以计算a[k]的内存地址为:a[k]_address = base_address + k * type_size。

但是,如果数组从1开始计数,那计算数组元素a[k]的内存地址就变成:a[k]_address = base_address + (k-1) * type_size。

这样,如果从1开始编号,每次随机访问数组元素都多了一次减法运算,对于CPU来说,就多了一次减法指令。

为什么很多编程语言中数组都从0开始编号, 而不是从1开始呢?

1、数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的操作,效率的优化就要尽可能地做到极致。所以,为了减少一次减法操作,数组选择了从0开始编号,而不是从1开始。

2、最主要的可能是历史原因。C语言设计者用0开始计数数组下标,之后的其他高级语言都效仿了C语言,或者为了减少学习成本,就继续沿用了从0开始的习惯。

极客时间版权所有: https://time.geekbang.org/column/article/41013

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值