• 为什么很多编程语言中数组都是从 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
    ");
    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还支持负数下标。
  • 相关阅读:
    【RQNOJ 5201】数字组合【DP】
    【POJ 2279】Mr. Young's Picture Permutations【DP】
    【POJ 2279】Mr. Young's Picture Permutations【DP】
    【POJ 2279】Mr. Young's Picture Permutations【DP】
    【CODEVS 2185】最长公共上升子序列【DP】
    【CODEVS 2185】最长公共上升子序列【DP】
    【CODEVS 2185】最长公共上升子序列【DP】
    【洛谷P1368】工艺【最小表示法】
    【洛谷P1368】工艺【最小表示法】
    【洛谷P1368】工艺【最小表示法】
  • 原文地址:https://www.cnblogs.com/gshao/p/12349522.html
Copyright © 2020-2023  润新知