本文旨在阐述数组,以及数组的相关操作和时间复杂度分析。

概述

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

关键词:

  • 线性表(Linear List):顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。而与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
  • 连续的内存空间,相同类型的数据:数组的特性,使数组数据可以“随机访问”,但同时,为了保证连续性,插入和删除操作可能需要大量的数据迁移操作。

线性表

非线性表

操作

数组和链表的区别?

链表适合插入、删除,时间复杂度 O(1);数组适合查找,查找时间复杂度为 O(1)

数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。

1
2
3
4
5
6
//定义整型数据data保存数据
public int data[];
//定义数组长度
private int n;
//定义中实际个数
private int count;

插入

概述

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//插入元素:头部插入,尾部插入
public boolean insert(int index, int value){
// 数组空间已满
if (count == n) {
System.out.println("没有可插入的位置");
return false;
}
// 如果count还没满,那么就可以插入数据到数组中
// 位置不合法
if (index < 0||index > count ) {
System.out.println("位置不合法");
return false;
}
// 位置合法
for( int i = count; i > index; --i){
data[i] = data[i - 1];
}
data[index] = value;
++count;
return true;
}

优化

正常的插入操作是为了确保数组的有序性。如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合,则可以将要插入位置的元素挪到最后,然后将元素插入。

删除

概述

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

1
2
3
4
5
6
7
8
9
10
//根据索引,删除数组中元素
public boolean delete(int index){
if (index<0 || index >=count) return false;
//从删除位置开始,将后面的元素向前移动一位
for (int i=index+1; i<count; ++i){
data[i-1] = data[i];
}
--count;
return true;
}

优化

如果不需要确保数组中数据的连续性,可以将删除操作集中处理,提高删除的效率。如将需要删除的数据进行标记,当数组没有更多空间存储数据时,触发真正的删除操作,减少数据的搬移,提高性能。这其实就是JVM 标记清除垃圾回收算法的核心思想

越界问题

在 C 语言中,数组越界需要程序员警惕,否则可能出现无限循环的情况。而在 Java 语言中,数组会做越界检查,若出现越界的情况,会抛出java.lang.ArrayIndexOutOfBoundsException异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//C:循环打印 hello world
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;
}

//Java:抛出ArrayIndexOutOfBoundsException异常
int[] a = new int[3];
a[3] = 10;

容器

在 Java 中,经常使用 ArrayList 容器类来操作集合。

那么,数组与容器相比,各有什么优势?

数组的优势

  1. ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
  2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。

容器的优势

  1. 封装了数组的操作细节。
  2. 支持动态扩容。注意在使用时,最好先根据需要指定初始化的集合容量。

对比总结

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

还有点啥

为什么大多数编程语言中,数组要从 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]_address = base_address + (k-1)*type_size。对比发现,若从1开始,寻址需要做多一次减法运算,对于最基础的数据结构数组来说,无法达到极致的效率。
  2. 历史原因。C 语言设计者用 0 开始计数数组下标,因此大多数语言进行效仿。同时,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。

二维数组内存寻址?

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

1
address = base_address + ( i * n + j) * type_size