- 栈操作的时间复杂度,对于顺序栈,入栈时如果栈的空间不够涉及到数据搬移,此时使用摊还分析法,将数据搬移的耗时均摊到不需要搬移数据的入栈操作中,均摊时间复杂度等于最好情况时间复杂度 O(1)
- 栈在函数调用中的应用,内存给每一个线程都分配了一块独立的内存空间,这些内存空间被组织成“栈”的形式,用来存储函数调用时的临时变量,当进入一个函数时,会将这个函数作为一个栈帧入栈,当函数执行完毕时,会将对应的栈帧出栈。
- 栈在表达式中的应用,对于加减乘除等等数学表达式的运算,计算机理解起来是很困难的,需要使用栈来辅助。需要一个运算符栈和一个操作数栈,遍历表达式,当遇到操作数,就压入操作数栈,当遇到运算符,则需要和运算符栈的栈顶运算符比较优先级,如果栈顶元素优先级高,如果当前运算符优先级更高,就压入运算运算符栈,继续下次对比;
- 如何使用栈实现浏览器的前进后退功能?和计算表达式的值有点类似,也是需要两个栈,当访问新页面的时候,把页面压入栈A;当点击后退时,取出栈A的栈顶元素,压入栈B;当点击前进时,从栈B中取出栈顶元素,压入栈A;如果要访问新页面,就需要清除栈B
这一章继续来学习队列
队列比栈复杂那么一丢丢
(一)队列基本概念
队列的基本概念很好理解,就类似于买票排队,先来的先买,后来的排到队尾,也就是咱们经常听到的“先进先出”
队列与栈类似,也只支持两种操作:“入队” 和 “出队”。“入队” 就是新增一个元素到队列的末尾,“出队” 就是从队列的头部取出一个元素。
所以队列和栈一样,是一种操作受限的线性表数据结构。
(二)顺序队列和链式队列
跟栈一样,队列也可以用数组和链表实现。用数组实现的叫顺序队列,用链表实现的叫链式队列。
下面是使用 Java 语言写的队列的数组实现
// 用数组实现的队列
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;
}
}
队列的数组实现比栈的数组实现复杂了那么一丢丢。
对于栈来说,只需要一个栈顶指针,因为入栈出栈都是在栈顶进行操作。而队列需要一个队头指针 head 和一个队尾指针 tail 。可以结合下面这张图来理解
当 a b c d 依次入队之后,队列的 head 指针就指向索引为0的位置,tail 指针就指向索引为4的位置。
当我们调用了两次出队操作之后,head指针往后移动两位,指向索引为2的位置,tail指针还是指向索引为4的位置
随着不停地入队、出队操作,head 指针和 tail 指针都会往后移动,如果这两个指针重合,即使数组中还有位置,也没办法再进行入队操作了。
回想一下数组那一章节,删除一个元素,造成数组空间不连续,后续再往数组中增加元素的时候,这个空出来的空间是没有办法利用的。此时我们怎么解决数据不连续的问题呢?对,就是使用数据搬移。
每次的出队操作都相当于删除了索引为0的元素。但是我们并不需要在每次出队的时候都进行数据搬移,这样子会导致出队的时间复杂度变为 O(n),只需要在入队时,发现没有空闲空间的时候,进行数据搬移。
这样子的话,队列的出队函数并不需要修改,入队函数需要修改一下下
// 入队操作,将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;
}
从代码中可以看到,当队列的 tail 指针移动到最右边后,再往队列中添加元素时,就会将 head 指针到 tail 指针之间的数据往前搬移,从索引为0开始,到 tail - head 结束
在这种实现思路下,出队操作的时间复杂度仍然是 O(1)。入队操作,我们之前在数组那一章节分析过,均摊时间复杂度就等于最好情况时间复杂度,也是 O(1)。
接下来我们再来看一下基于链表的队列实现方式。
基于链表的实现,我们同样需要两个指针,head 指针指向链表的第一个结点,tail 指针指向链表的最后一个结点。如下图所示,
入队时,tail.next = newNode;tail = tail.next
出队时,head = head.next
(三)循环队列
循环队列,顾名思义,就是原来的拉直的队列首尾相连成一个圈圈
我们可以发现,图中的队列大小为8,head指针指向 4 的位置,tail 指针指向 7 的位置。
当有一个新元素入队的时候,新元素会放在 7 的位置,tail 指针并不需要更新为 8 ,而是需要到 0 的位置,同样的,再次新增时,tail 指针继续往前移动,到 1 的位置。新增两个元素的状态如下图所示:
通过这种方式,在队列空间满之前,都不需要进行数据搬移操作。
但是循环队列的实现,相比于非循环队列,会复杂一些。关键在于两个状态的确定,一个是队列为空的状态,一个是队列满员的状态。
在非循环的队列中,队列为空的判断标准是 head == tail
,队列满员的判断标准是 tail == n
。
在循环队列中,队列为空的判断条件依然是 head == tail
,队列满员的状态如下图所示
队列满员的判断条件,可以通过多🖼几次图总结出来,是 (tail+1)%n=head
。当队列满的时候,tail 的位置是没有存储数据的,这会造成一丢丢内存的浪费。
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 = (head + 1) % n;
return ret;
}
}
(四)阻塞队列
阻塞队列其实就是在队列的基础上增加了阻塞操作。当队列为空的时候,从对头取数据的操作就会被阻塞,等到队列中有数据的时候,在从队头取出数据并返回;入队操作也一样,当队列满的时候,入队操作会被阻塞,等到队列中有空位的时候才会执行插入操作,并返回。