数据结构与算法(二)--数组

数据结构与算法(二)---数组

  • 数组的定义以及特点
  • 数组怎样插入元素,怎样简单优化?
  • 数组怎样删除元素的,怎样简单优化?
  • 数组的访问越界;
  • 数组和容器的优劣;
  • 数组的下标为什么不是从1开始?
一、数组的定义
  1. 数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

    • 线性表:数据排成像一条线一样的结构,每个线性表上的数据结构最多只有向前和向后两个方向;数组、链表、队列,栈等都是线性表结构;与线性表相对的是非线性表,如二叉树、堆、图;

      img

    • 连续的内存空间和相同类型的数据,这个是数组支持随机访问的必要条件;

      下面试一个 int[] a = new int[10] 数组的内存示意图,定义内存的首地址base_address = 1000。

      img

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

      a[i]_address = base_address + i * data_type_size
      
    • 知识误区

      我在面试的时候,常常会问数组和链表的区别,很多人都回答说,“链表适合插入、删除,时间复杂度 O(1);数组适合查找,查找时间复杂度为 O(1)”。

      实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。

二、插入元素

  1. 数组的k位置上插入一个元素,为了保持原数据的有序性,需要在k~n 的元素一次往后挪一个位置,然后将k插入到挪出的空位;当k=n时,时间复杂度为O(1),最坏为O(n),其平均时间复杂度为:(1+2+…n)/n=O(n)。

  2. 上述是数据有序的,如果数据不要求是有序的,则每次插入数据时,将k位置的数据挪到最后一个非空位置后面的空位,将新元素插入k中,这样可以减少插入的时间复杂度:

    img

三、删除元素

  1. 跟插入数据类似,如果我们要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了;和插入类似,如果删除数组末尾的数据,则最好情况时间复杂度为 O(1);如果删除开头的数据,则最坏情况时间复杂度为 O(n);平均情况时间复杂度也为 O(n);

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

    数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素

    img

    为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移;

    如果你了解 JVM,你会发现,这不就是 JVM 标记清除垃圾回收算法的核心思想吗?没错,数据结构和算法的魅力就在于此,很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的。如果你细心留意,不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子。

四、数组访问越界

  1. 在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的,根据我们前面讲的数组寻址公式,a[3] 也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。

    
    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;
    }
    
  2. 并非所有的语言都像 C 一样,把数组越界检查的工作丢给程序员来做,像 Java 本身就会做越界检查,比如下面这几行 Java 代码,就会抛出 java.lang.ArrayIndexOutOfBoundsException。

五、数据与容器

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

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

  3. 还有一个是我个人的喜好,当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList<ArrayList > array。

    总结一下,对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

六、为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢?

  1. 便于寻址公式的计算;

    • 从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。前面也讲到,如果用 a 来表示数组的首地址,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
      

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

  2. 因为C语言的设计者使用0开始,很多其他语言也纷纷效仿;

课后思考

1、前面我基于数组的原理引出 JVM 的标记清除垃圾回收算法的核心理念。我不知道你是否使用 Java 语言,理解 JVM,如果你熟悉,可以在评论区回顾下你理解的标记清除垃圾回收算法。
2、前面我们讲到一维数组的内存寻址公式,那你可以思考一下,类比一下,二维数组的内存寻址公式是怎样的呢?
jvm
  • 大多数主流虚拟机采用可达性分析算法来判断对象是否存活,在标记阶段,会遍历所有 GC ROOTS,将所有 GC ROOTS 可达的对象标记为存活。只有当标记工作完成后,清理工作才会开始。

不足:1.效率问题。标记和清理效率都不高,但是当知道只有少量垃圾产生时会很高效。2.空间问题。会产生不连续的内存空间碎片。

二维数组的内存寻址公式
 int arr = new int[m][n];
 //一个int为type_size,所以总的占用内存为m*n*type_size
 //假设首地址为base_address,我们来推算一下:
 a[0][0] = base_address
  //也就是第一行最后一个元素的位置 
 a[0][n] = base_address + n*type_size;  

 a[m][n] = base_address + m*n*bit +n*btype_sizeit ;
 
posted @ 2019-11-28 17:33  李维维(levi)  阅读(342)  评论(0编辑  收藏  举报