数据结构之数组

581 阅读7分钟

什么是数组

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

上面数组的概念涉及两个关键点:

  1. 线性表.

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

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

  1. 连续的内存空间存储相同类型的数据

正是因为有了上面两个限制, 所以才有数组的一个堪称"杀手锏"的特性:"随机访问"

但也正因为这两个限制,让数组的插入和删除操作需要做大量的数据搬移工作

数组如何实现随机访问

举例: 一个长度为10的int类型数组int[] a = new int[10],我们知道int类型会占用四个字节,假设计算机给数组分配的连续内存空间为1000~1039,首地址base_address为1000

计算机是通过地址来访问内存中的数据,当需要随机访问数组中的某个元素时,可以通过下面公式得到该元素的内存地址:

a[i]_address = base_address + i * data_type_size

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

为什么数组下标是从0开始编号而不是从1开始?

从数组存储的内存模型来看,"下标"最确切的定义应该是"偏移(offset)".前面说到计算a[i]的地址的公式为

a[i]_address = base_address + i * data_type_size

如果从1开始编号的话,那么计算a[i]的地址的公式为

a[i]_address = base_address + (i-1) * data_type_size

对比不难发现,从1开始编号的话,每次随机访问数组数据时就多了一次减法运算,对CPU来说就多了一个减法指令

二维数组如何寻址?

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

address = base_address + ( i * n + j) * data_type_size

低效的插入和删除操作

插入操作

  1. 如果在数组末尾插入元素,则不需要移动数据,时间复杂度为O(1)
  2. 如果在数组头部插入元素,那么所有的数据都需要向后移动一位
  3. 在每个位置插入数据的概率是一样的,那么平均情况的时间复杂度为(1+2+...n)/2=O(n)

如果数组存储的数据没有规律,数组只是一个存储元素的集合,那么如果要在数据的第k个位置插入数据的话,为了避免大规模移动数据,可以把第k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。

这种方法就能把插入元素的时间复杂度降为O(1)

删除操作

  1. 如果删除数组末尾的元素,则不需要移动数据,时间复杂度为O(1)
  2. 如果删除数组头部的元素,那么所有的数据都需要向前移动一位
  3. 在每个位置删除数据的概率是一样的,那么平均情况的时间复杂度为(1+2+...n)/2=O(n)

实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?

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

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

这种思想可以类比一下JVM的标记清除垃圾回收算法.

数组越界问题

下面一段c语言代码输出结果是什么呢?

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吗?非也,而是循环打印hello world,为什么呢?

因为,数组大小为 3,a[0],a[1],a[2],而我们的代码因为书写错误,导致 for 循环的结束条件错写为了 i<=3 而非 i<3,所以当 i=3 时,数组 a[3] 访问越界。

  1. 这段如果是java代码的话,会抛出异常java.lang.ArrayIndexOutOfBoundsException。
  2. 在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。根据我们前面讲的数组寻址公式,a[3] 也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环。
  3. 数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误。

Q:那么为什么a[3] 刚好就是i的内存地址呢???

A:这个跟编译器分配内存和字节对齐有关

  1. 函数体内的局部变量存在栈上,且是连续压栈。

  2. 在Linux进程的内存布局中,栈区在高地址空间,从高向低增长。

  3. 变量i和arr在相邻地址,且i比arr的地址大,所以arr越界正好访问到i。当然,前提是i和arr元素同类型,否则那段代码仍是未决行为。

  4. 数组3个元素 加上一个变量a 。4个整数刚好能满足8字节对齐 所以i的地址恰好跟着a2后面 导致死循环。

  5. 如果数组本身有4个元素 则这里不会出现死循环。因为编译器64位操作系统下 默认会进行8字节对齐 变量i的地址就不紧跟着数组后面了。

何时使用数组何时使用容器?

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

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

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

    一个小误区

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

    实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。

    所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。