如对时间复杂度的概念不熟悉可参考:复杂度分析
1、线性表概述
数据排成一条线一样的结构,每个线性表上的数据最多只有前后两个方向,包括 数组,链表,队列,栈。
非线性表:
数据之间并不是简单的前后关系,树、图等。
2、数组
用一组连续的内存空间来存储具有相同数据类型的数据。
随机访问特性。
a[i]_address = base_address + i * data_type_size
为什么大多数编程语言数组都是从0开始编号?
寻址公式 i 不需要 -1 操作。
根据下标随机访问的时间复杂度为O(1)
插入
有一个长度为n的数组,将一个数据插入到数组中的第K个位置。
最好情况从末尾插入,时间复杂度为O(1)
最坏情况从头部插入,时间复杂度为O(n)
平均时间复杂度为 O(n)
如果数组中的数据没有规律,如何能将时间复杂度降到O(1)?
先插入到末尾,然后再交换位置
删除
最好情况删除数组末尾数据,时间复杂度为O(1)
最坏情况删除头部数据,时间复杂度为O(n)
平均时间复杂度为O(n)
某些情况下不追求数组中数据的连续性,如何提高删除效率?
先与末尾元素交换位置,再删除
3、链表
通过“指针”将一组零散的内存块串联起来。
①单链表
针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)。
插入:
x.next = b.next;
b.next = x;
删除:
a.next = a.next.next;
链表要想随机访问第 k 个元素,就没有数组那么高效了。
②循环链表
当要处理的数据具有环型结构特点时,就特别适合采用循环链表,如约瑟夫问题。
③双向链表
双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点。
在实际的软件开发中,从链表中删除一个数据无外乎这两种情况,这两种情况使用单链表、双向链表的差异。
删除结点中“值等于某个给定值”的结点;
删除给定指针指向的结点。
4、数组和链表比较
数组连续的内存空间,对CPU缓存友好,链表内存不连续,CPU没办法预读。
数组大小固定,回因为声明的数组过大,系统找不到连续的内存导致内存不足(OOM)。动态扩容耗时。 链表相对而言不会。
相对而言数组占用内存空间更小,因为链表每个节点需要额外的存储空间去存储一份指向下一个节点的指针,内存消耗会翻倍。
如果有大量数据要插入,链表的数据插入方式没什么差别,而数组既要考虑减少元素的移动,还要考虑数组容量。
5、链表代码的编写
写链表代码考验逻辑思维能力,指针操作、边界条件处理等很容易产生bug。
技巧:画图、哨兵、练习(求链表中间节点、单链表反转、回文字符串)。
6、栈
先进者后出,后进者先出;
只允许在一端插入和删除数据;
栈主要包含两个操作,入栈和出栈,用数组实现的栈,叫作顺序栈,用链表实现的栈叫做链式栈;入栈出栈的时间复杂度都为O(1);
栈的实际应用:
1、浏览器的前进、后退功能.
使用两个栈(X,Y)来解决:
把首次浏览的页面一次压入栈X
点击后退按钮时,从栈X出栈,并将其压入栈Y
点击前进时,从Y栈中取出数据,放入栈X中
当栈X中没有数据,说明没有页面可以后退了
当Y栈中没有数据,说明没有页面可以前进
2、函数调用栈
操作系统给每个线程分配一个独立的内存空间用来存储函数调用时的临时变量。
int main() {
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf("%d", res);
reuturn 0;
}
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
3、在表达式求值中的应用
编译器是如何计算表达式的:3+5*8-6
通过两个栈来实现,一个用来保存操作数的栈,一个用来保存运算符的栈;从左往右遍历表达式,遇到数字压入操作数栈;
遇到运算符就与运算符栈顶的元素进行比较,优先级高就压入栈;优先级低或相同,就取出运算符栈栈顶运算符,从操作数栈中取两个数进行运算,将计算结果压入操作数栈,继续比较。
思考:如何计算带括号的表达式:9+(3-1)*3 + 10/2 ?
先将中缀表达式转换成后缀表达式:9 3 1-3 * + 10 2 / +,然后运算。
计算机喜欢的是后缀表达式(所有的符号都是在运算数字的后面出现),只使用一个栈,从左至右遍历后缀表达式,遇到运算符就从栈中取两个数进行运算,按照这个规则就可求得计算结果:
思考:我们平时标准的表达式叫中缀表达式,如何将中缀表达式转换成后缀表达式?
4、栈检查括号书写是否合法
圆括号()、方括号[]、花括号{}, 如何检测他们的任意嵌套是否合法,如 {[] ()[{}]}或[{()}([])]为合法格式,{[}()]或[({)]为不合法的格式。
可以用栈来解决:
从左到右依次扫描表达式字符串,当扫描到左括号时,将其压入栈
当扫描到右括号时,从栈顶取出一个左括号;
如果能匹配,继续扫描,如果不能匹配,则说明为非法格式;
扫描完所有字符串后,如果栈为空,则说明表达式合法,否则有左括号未匹配到,为非法格式。
7、队列
排队买票,先来的先买,后来的人只能站在队尾,不允许插队。先进先出。
队列也是操作受限的线性表数据结构,只有入队和出队两个基本操作。
队列的实现需要两个指针:head指针 指向队头,tail指针指向队尾。
顺序队列:用数组实现的队列。
链式队列:用链表实现的队列。
顺序队列:
// 用数组实现的队列
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;
}
// 入队
public boolean enqueue(String item) {
// 如果tail == n 表示队列已经满了
if (tail == n) return false;
items[tail] = item;
++tail;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
// 为了让其他语言的同学看的更加明确,把--操作放到单独一行来写了
String ret = items[head];
++head;
return ret;
}
}
当a、b、c、d依次入队之后,如下:
调用两次出队操作:
随着不停地进行入队、出队操作,head 与 tail 会持续往后移。
当tail = n, 队列满了,即使数组中还有空闲空间,也无法继续往队列中添加数据了。
如何解决这个问题:
方法1,每一次出队都进行一次数据搬移,入队操作时间复杂度O(1),出队时间复杂度O(n)。
方法2,如果没有空闲空间了,在入队的时候集中触发一次数据的搬移。
代码如下:
//入队操作,将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;
}
出队时间复杂度为O(1)
计算所得执行次数的期望值为: n / (n-m)+1
得出入队平均时间复杂度也为O(n)
8、链式队列
入队: tail -> next = new_node
出队:head = head - > next
出队入队的时间复杂度都为 O(1)
基于链表的实现方式,可以实现一个支持无限排队的无界队列,但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现是不合适的。
而基于数组实现的有界队列,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源、发挥最大性能。
上面用数组实现队列的时候,当tail = n 时,会有数据搬移。 那有没有办法避免数据搬移呢?
9、循环队列
如下图:
图中这个队列的大小为 8,当前 head=4,tail=7。当有一个新的元素 a 入队时,我们放入下标为 7 的位置。但这个时候,我们并不把 tail 更新为 8,而是将其在环中后移一位,到下标为 0 的位置。当再有一个元素 b 入队时,我们将 b 放入下标为 0 的位置,然后 tail 加 1 更新为 1。所以,在 a,b 依次入队之后,循环队列中的元素就变成了下面的样子:
队空情况:head == tail
队满情况:(tail + 1)% n = head
代码如下:
public class CircularQueue {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head表示队头下标,tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 队列满了
if ((tail + 1) % n == head) return false;
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
平时业务开发中不大可能需要从零实现一个队列,但有一些特殊队列还是比较常见。
阻塞队列:
就是在队列的基础上加上阻塞操作。 队列为空的时候,从队头取数据会被阻塞,如果队列满了,插入数据的操作就会被阻塞。
利用阻塞队列实现的,生产者 – 消费者模式:
这种基于阻塞队列实现的“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。
还可以通过协调“生产者”和“消费者”的个数,来提高数据的处理效率。
在多线程的情况下,会有多个线程同时操作队列。这个时候就需要实现一个线程安全的队列。
并发队列:
实现并发队列最简单直接的方式就是在。入队和出队方法上加锁。 锁的粒度大并发度会比较低。基于数组的循环队列,利用CAS,可以实现非常高效的并发队列。