数组
数组是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
高效的随机访问
因为数组是连续的内存空间和相同类型的数据。造就了它高效的“随机访问”。可以根据寻址公式,可以快速的计算出该元素存储的内存地址。时间复杂度为O(1)。1
2
3
4
5// 一维数组的寻址公式,base_address 为内存的首地址,data_type_size 为单个元素的大小
a[i]_address = base_address + i * data_type_size
// 二维数组寻址公式,对于 m * n 的数组
a[i][j]_address = base_address + ( i * n + j) * data_type_size
低效的插入和删除
数组为了保持内存数据的连续性,在插入和删除的时候会导致其他数据的移动。
- 尾部插入或删除,其他数据无需移动。时间复杂度为O(1)。
- 头部插入或删除,后面所有数据都得移位。时间复杂度为O(n)。
- 任意位置插入或删除,平均时间复杂度为O(n)。
容器ArrayList VS 数组
- ArrayList 封装了很多数组操作的细节。
- ArrayList 支持动态扩容,最好在创建时事先指定大小 int newCapacity = oldCapacity + (oldCapacity >> 1)。
- ArrayList 无法存储基本数据类型。
链表
链表是一种通过“指针”将一组零散的内存块串联起来的线性表数据结构。三种常见的链表结构:单链表、双向链表和循环链表。
低效的随机访问
因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。时间复杂度为O(n)。
高效的插入和删除
在链表中插入或删除一个数据时,因为链表的存储空间本身就不是连续的,所以不需要为了保持内存的连续性而搬移结点,只需要考虑相邻结点的指针改变。时间复杂度为O(1)。
链表 VS 数组
- 数据插入、删除、随机访问操作的时间复杂度正好相反。
- 数组大小固定,一经声明就占用整块连续内存空间;链表本身没有大小的限制,天然支持动态扩容。
- 链表每个结点都需要消耗额外的存储空间去存储一份指向下个结点的指针,内存消耗会翻倍。对链表的频繁插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片。
栈
栈是一种后进先出,先进后出,“操作受限”的线性表。只允许在一端插入和删除数据。
从功能上来看,数组或链表可以替代栈。但是,特定的数据结构是对特定业务场景的抽象。当某个数据集合只涉及在一端插入和删除数据,满足后进先出、先进后出的特性,就应该首选“栈”这种数据结构。而且,数组或链表暴露了太多的操作接口,容易出错。
如何实现一个“栈”
栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,叫作顺序栈,用链表实现的栈,叫作链式栈。
以下是一个用数组实现的顺序栈1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 基于数组实现的顺序栈
public class ArrayStack {
private String[] items; // 数组
private int count; // 栈中元素个数
private int n; //栈的大小
// 初始化数组,申请一个大小为n的数组空间
public ArrayStack(int n) {
this.items = new String[n];
this.n = n;
this.count = 0;
}
// 入栈操作
public boolean push(String item) {
// 数组空间不够了,直接返回false,入栈失败。
if (count == n) return false;
// 将item放到下标为count的位置,并且count加一
items[count] = item;
++count;
return true;
}
// 出栈操作
public String pop() {
// 栈为空,则直接返回null
if (count == 0) return null;
// 返回下标为count-1的数组元素,并且栈中元素个数count减一
String tmp = items[count-1];
--count;
return tmp;
}
}
空间复杂度:入栈、出栈只需要一两个临时变量存储空间,所以空间复杂度是 O(1)。
时间复杂度:入栈、出栈只涉及栈顶个别数据的操作,所以时间复杂度都是 O(1)。
栈的应用:栈在函数调用中的应用,栈在表达式求值中的应用(比如:34 + 13 * 9 + 44 - 12 / 3),栈在括号匹配中的应用(比如:{}{([])()})。
队列
队列是一种先进先出(FIFO),“操作受限”的线性表。只能从队头出队,队尾入队。常见的循环队列、阻塞队列、并发队列等。
如何实现一个队列
跟栈一样,队列既可以用数组来实现,也可以用链表来实现。用数组实现的队列,叫作顺序队列,用链表实现的队列,叫作链式队列。
以下是一个用数组实现的队列1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44// 用数组实现的队列
public class ArrayQueue {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head表示队头下标,tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public ArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队操作,将item放入队尾
public boolean enqueue(String item) {
// tail == n表示队列末尾没有空间了
if (tail == n) {
// tail ==n && head==0,表示整个队列都占满了
if (head == 0) return false;
// 数据搬移
for (int i = head; i < tail; ++i) {
items[i-head] = items[i];
}
// 搬移完之后重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
String ret = items[head];
++head;
return ret;
}
}
空间复杂度:入队、出队空间复杂度是O(1).
时间复杂度:入队、出队时间复杂度是O(1),在入队时虽然有数据搬移,时间复杂度为O(n),但均摊时间复杂度依然为O(1)。
常见队列
- 循环队列:原本数组实现的队列是有头有尾的,是一条直线。循环队列就是把它首尾相连,扳成了一个环。不需再进行数据搬移。
- 阻塞队列:在队列基础上增加了阻塞操作。在队列为空的时候,从队头取数据会被阻塞,直到队列中有了数据才能返回;如果队列已经满了,那插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。
- 并发队列:并发队列简单的实现就是在enqueue()、dequeue()方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或取操作。实际上,基于数组的循环队列利用CAS原子操作,可以实现非常高效的并发队列。