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

1、什么是数组?

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

概念解析:

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

      连续的内存空间和相同类型的数据:所以数组根据下标具有随机访问特性,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。

 

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

  举例:长度为 10 的 int 类型的数组 int[] a = new int[10]来举例。在我画的这个图中,计算机给数组分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。

   计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,首先通过下面的寻址公式,计算出该元素存储的内存地址:

1 a[i]_address = base_address + i * data_type_size
2 
3 data_type_size 表示数组中每个元素的大小。我们举的这个例子里,数组中存储的是 int 类型数据,所以 data_type_size 就为 4 个字节。

  

面试常见问题:数组和链表的区别?

  “链表适合插入、删除,时间复杂度 O(1);数组的查找操作时间复杂度并不是O(1)。即便是排好的数组,用二分查找,时间复杂度也是O(logn)。所以应该这么说:数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。

 

3、数组低效的“插入”和“删除”

低效原因:

数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效,因为需要搬移数据。
插入:
假设数组长度为n,要将一个数据插入到第 k 个位置,为了把第 k 个位置腾出来,我们需要将k~n这部分元素顺序的向后移动一位。
插入的时间复杂度:
最好情况:在数组的末尾插入元素,不需要移动数据了,时间复杂度为O(1)。
最坏情况:在开头插入元素,那就需要把所有的数据向后移动一位,时间复杂度为O(n)。
平均时间复杂度:(1+2+…+n)/n = O(n)。

 删除:和插入类似,如果删除数组末尾的数据,则最好情况时间复杂度为O(1),如果要删除开头数据,则最坏情况时间复杂度为O(n),平均时间复杂度为O(n)。

 改进方法:

  插入: 如果数组中的元素没有任何规律,数组只是被当作一个数据集合,在这种情况下,如果要将某个数据插入到第k个位置,为了避免大规模的数据迁移,一个简单的办法就是直接将现在第k个元素放到最后,把新元素放进来。

  例如:arr[10] = {a,b,c,d,e}要将x插入第三个位置arr[10]={a,b,x,d,e,c} 这样插入的时间复杂度为O(1),这种方法在快速排序中也会用到。

  删除: 在某些特殊情况下,我们并不一定非得追求数组中数据的连续性,如果我们将多次删除操作放在一起执行,效率会高很多。

  举个例子:a[10]={a,b,c,d,e,f,g,h} 如果我们要依次删除abc三个元素,需要搬移三次后面的数据,为了避免这个重复的搬移工作,可以先记录下来已经删除的数据,每次的删除操作并不是真正的搬移数据,只是记录数据已经被删除,当数组中没有更多的空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了搬移工作,这也是标记清除垃圾回收算法的核心思想。

4、 数组越界问题

首先看一段C语言代码:

1 int main(int argc, char* argv[]){
2     int i = 0;
3     int arr[3] = {0};
4     for(; i<=3; i++){
5         arr[i] = 0;
6         printf("hello world\n");
7     }
8     return 0;
9 }

  这段代码的结果并不是打印三行hello world,而是无限打印hello world。为啥呢?

  因为在C语言中,除了受限制的内存,其他所有内存空间都是可以自由访问的。a[3]也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。

  那为什么会无限打印呢?

  根据我所学和百度的知识解释下:函数体内的局部变量存在栈区,在Linux内存布局中,栈区在高地址空间,从高到低增长,先int i = 0;再int arr[3]={0};变量i和arr地址相邻,并且i地址比arr地址大,首先压栈的i,a[2],a[1],a[0],循环中arr访问越界正好到i,而此时i变量的地址是数组当前进程的,所以进行修改的时候,操作系统并不会终止进程。当然这只是32位操作系统下,64位操作系统下 默认会进行8字节对齐 变量i的地址就不紧跟着数组后面了。另外这个还和编译环境有关,对于不同的编译器,在内存分配时,会按照内存地址递增或递减的方式进行分配。如果是内存地址递减的方式,就会造成无限循环。

5、容器和数组

  相比于数组,Java中的ArrayList封装了数组的很多操作,并支持动态扩容,每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍大小。但是一旦超过存储容量,扩容时比较耗时,因为涉及到内存申请和数据搬移。

  所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。比如我们要从数据库中取出 10000 条数据放入 ArrayList。我们看下面这几行代码,你会发现,相比之下,事先指定数据大小可以省掉很多次内存申请和数据搬移操作。

ArrayList<User> users = new ArrayList(10000);
for (int i = 0; i < 10000; ++i) {
  users.add(xxx);
}

  

数组适合的场景:

  1、Java ArrayList 的使用涉及装箱拆箱,有一定的性能损耗,如果特别关注性能,可以考虑数组;
  2、若数据大小事先已知,并且涉及的数据操作非常简单,可以使用数组;
  3、表示多维数组时,数组往往更加直观;
  4、业务开发使用容器即可。若涉及底层开发,如网络框架,性能优化等,则最好选择数组。

 

6、解答开篇问题

   1、 效率原因:从内存模型来看,“下标”也称为“偏移”。我们知道在C语言中数组名代表首地址(第一个元素的地址),a[0]就是偏移为 0 的位置。a[k]就表示偏移 k 个元素类型大小的位置。      

  得出计算公式:a[k]_address = base_address + k * type_size

  但是钥匙从 1 开始计数,那这个公式就会变为:a[k]_address = base_address + (k-1) * type_size

  对比两个公式,如果从 1 开始编号,每次随机访问数组元素就多了一次减法运算,对于CPU来说就是多了一次减法指令。

  数组作为非常基础的数据结构,通过下标访问数组元素又是数组上的基础操作,效率优化应做的很好,所以为了减少一次减法操作,数组选择了从 0 开始编号。

 

 2、历史原因:
  C语言的设计者用 0 开始计数下标,之后的Java、C++等高级语言都效仿C语言,沿用了从0开始计数的习惯。
  还有一些语言并不是从0开始计数的,如:Matlab。 甚至Python还支持负数下标。

 

posted @ 2020-02-23 13:17  甘劭  阅读(465)  评论(0编辑  收藏  举报