学习目标
- 了解 FIFO 和 LIFO 处理顺序的原理;
- 实现这两个数据结构;
- 熟系内置的队列和栈结构;
- 解决基本的队列相关问题,尤其是 BFS;
- 解决基本的栈相关问题;
- 理解在使用 DFS 和其他 递归 算法来解决问题时,系统栈是如何发挥作用的。
1.1 队列:先入先出的数据结构
队列(queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。
队列是一种先进先出(First Input First Output)的线性表,简称 FIFO结构。
允许插入的一端称为队尾,允许删除的一端称为队头。
在 FIFO 数据结构中,将 首先处理添加到队列中的第一个元素。
如上图所示,队列是典型的 FIFO数据结构。插入(insert)操作也称作入队(enqueue),新元素始终被添加在 队列的末尾。删除(delete)操作也被称为出队(dequeue)。你只能移除 第一个元素。
示例 - 队列
入队与出队
1.2 队列 - 实现
为了实现队列,我们可以使用动态数组和指向队列头部的索引。
如上所述,队列应支持两种操作:入队和出队。入队会向队列追加一个新元素,而出队
class MyQueue {
// 存储元素
private List<Integer> data;
// 指示起始位置的指针
private int p_start;
public MyQueue() {
data = new ArrayList<Integer>();
p_start = 0;
}
/** 在队列中插入元素,如果成功,则返回true */
public boolean enQueue(int x) {
data.add(x);
return true;
};
/** 从队列中删除元素。如果操作成功,则返回true */
public boolean deQueue() {
if (isEmpty() == true) {
return false;
}
p_start++;
return true;
}
/** 从队列中获取前面的项 */
public int Front() {
return data.get(p_start);
}
/** 检查队列是否为空 */
public boolean isEmpty() {
return p_start >= data.size();
}
};
public class Main {
public static void main(String[] args) {
MyQueue q = new MyQueue();
q.enQueue(5);
q.enQueue(3);
if (q.isEmpty() == false) {
System.out.println(q.Front());
}
q.deQueue();
if (q.isEmpty() == false) {
System.out.println(q.Front());
}
q.deQueue();
if (q.isEmpty() == false) {
System.out.println(q.Front());
}
}
}
缺点
上面的实现很简单,但在某些情况下效率很低。随着起始指针的移动,浪费了越来越多的空间。当我们有空间限制时,这将是难以接受的。
让我们考虑一种情况,即我们只能分配一个最大长度为5的数组。当我们只添加少于 5 个元素时,我们的解决方案很有效。 例如,如果我们只调用入队函数四次后还想要将元素 10 入队,那么我们可以成功。
但是我们不能接受更多的入队请求,这是合理的,因为现在队列已经满了。但是如果我们将一个元素出队呢?
实际上,在这种情况下,我们应该能够再接受一个元素。
1.3 循环队列
头尾相接的循环,我们把队列的这种头尾相接的顺序存储结构称为循环队列。
具体来说,我们可以使用 固定大小的数组 和 两个指针 来指示起始位置和结束位置。目的是 重用 我们之前提到的 被浪费的存储 。
1.4 设计循环队列
循环队列是一种线性数据结构,其操作表现基于 FIFO(先进先出)原则并且队尾被连接在队首之后已形成一个循环。它也被称为"环形缓冲器"。
循环队列的一个好处是我们可以利用这个队列之前用过的空间。在一个普通队列里,一旦一个队列满了,我们就不能插入下一个元素,即使在队列前面仍有空间。但是使用循环队列,我们能使用这些空间去存储新的值。
你的实现应该支持如下操作:
- MyCircularQueue(k): 构造器,设置队列长度为 k 。
- Front: 从队首获取元素。如果队列为空,返回 -1 。
- Rear: 获取队尾元素。如果队列为空,返回 -1 。
- enQueue(value): 向循环队列插入一个元素。如果成功插入则返回真。
- deQueue(): 从循环队列中删除一个元素。如果成功删除则返回真。
- isEmpty(): 检查循环队列是否为空。
- isFull(): 检查循环队列是否已满。
示例:
MyCircularQueue circularQueue = new MyCircularQueue(3); // 设置长度为 3
circularQueue.enQueue(1); // 返回 true
circularQueue.enQueue(2); // 返回 true
circularQueue.enQueue(3); // 返回 true
circularQueue.enQueue(4); // 返回 false,队列已满
circularQueue.Rear(); // 返回 3
circularQueue.isFull(); // 返回 true
circularQueue.deQueue(); // 返回 true
circularQueue.enQueue(4); // 返回 true
circularQueue.Rear(); // 返回 4
1.5 循环队列 - 实现
/**
* @Author 庭前云落
* @Date 2021/6/28 15:42
* @Description
*/
public class MyCircularQueue {
//一个固定大小的数组,用于保存循环队列的元素
private int[] queue;
//就是一个整数,用于表示头的索引
private int headIndex;
//表示当前队列已含有的元素数量
private int count;
//表示此循环队列的容量,队列中最多可以容纳的元素数量
private int capacity;
/**
* 初始化数据结构。将队列的大小设置为K
*/
public MyCircularQueue(int k) {
this.capacity = k;
this.queue = new int[k];
this.headIndex = 0;
this.count = 0;
}
/**
* 在循环队列中插入元素,如果操作成功,返回true
*/
public boolean enQueue(int value) {
if (this.count == this.capacity)//如果当前元素大小已经达到了循环队列的极限值,也就是容量.
//那么应该返回false
return false;
this.queue[(this.headIndex + this.count) % this.capacity] = value;
this.count += 1;
return true;
}
/**
* 删除循环队列中元素,如果操作成功返回true
*/
public boolean deQueue() {
if (this.count == 0)
return false;
this.headIndex = (this.headIndex + 1) % this.capacity;
this.count -= 1;
return true;
}
/**
* 从队列中获取前面的项
*/
public int Front() {
if (this.count == 0)
return -1;
return this.queue[this.headIndex];
}
/**
* 从队列中获取最后一项
*/
public int Rear() {
if (this.count == 0)
return -1;
int tailIndex = (this.headIndex + this.count - 1) % this.capacity;
return this.queue[tailIndex];
}
/**
* 检查循环队列是否为空。
*/
public boolean isEmpty() {
return (this.count == 0);
}
/**
* 检查循环队列是否已满
*/
public boolean isFull() {
return (this.count == this.capacity);
}
}
1.6 队列 - 用法
大多数流行语言都提供内置的队列库,因此您无需重新发明轮子。
如前所述,队列有两个重要的操作,入队 enqueue 和 出队 dequeue。此外,我们应该能够 获得队列中的第一个元素,因为应该首先处理它。
下面是使用内置队列库及其常见操作的一些示例:
// "static void main" must be defined in a public class.
public class Main {
public static void main(String[] args) {
// 1. Initialize a queue.
Queue<Integer> q = new LinkedList();
// 2. Get the first element - return null if queue is empty.
System.out.println("The first element is: " + q.peek());
// 3. Push new element.
q.offer(5);
q.offer(13);
q.offer(8);
q.offer(6);
// 4. Pop an element.
q.poll();
// 5. Get the first element.
System.out.println("The first element is: " + q.peek());
// 7. Get the size of the queue.
System.out.println("The size is: " + q.size());
}
}
2.1 栈:后入先出的数据结构
栈(stack)是限定仅在表尾进行插入和删除操作的线性表。
栈是一种后进先出(Last Input First Output)的线性表,简称 LIFO结构。
允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom),不含任何数据元素的栈称为空栈。
在 LIFO 数据结构中,将 首先处理添加到队列 中的 最新元素。
与队列不同,栈是一个 LIFO 数据结构。通常,插入操作在栈中被称作入栈 push。与队列类似,总是 在堆栈的末尾添加一个新元素 。但是,删除操作,退栈 pop,将始终 删除 队列中相对于它的 最后一个元素。
示例 - 栈
入栈与出栈
栈的插入操作,叫作进栈,也称压栈、入栈。类似子弹进入弹夹。
栈的删除操作,也叫出栈,也有的叫作弹栈。如同弹夹中的子弹出夹。
2.2 栈 - 实现
栈的实现比队列容易。动态数组 足以实现堆栈结构。这里我们提供了一个简单的实现供大家参考。
class MyStack {
//存储元素
private List<Integer> data;
public MyStack() {
data = new ArrayList<>();
}
/** 将元素插入堆栈 */
public void push(int x) {
data.add(x);
}
/** 检查元素是否为空 */
public boolean isEmpty() {
return data.isEmpty();
}
/** 获取栈顶/
public int top() {
return data.get(data.size() - 1);
}
/** 从队列中删除元素。如果操作成功,则返回true */
public boolean pop() {
if (isEmpty()) {
return false;
}
data.remove(data.size() - 1);
return true;
}
};
public class Main {
public static void main(String[] args) {
MyStack s = new MyStack();
s.push(1);
s.push(2);
s.push(3);
for (int i = 0; i < 4; ++i) {
if (!s.isEmpty()) {
System.out.println(s.top());
}
System.out.println(s.pop());
}
}
}
2.3 栈 - 用法
大多数流行的语言都提供了内置的栈库,因此你不必重新发明轮子。除了初始化,我们还需要知道如何使用两个最重要的操作:入栈和退栈。除此之外,你应该能够从栈中获得顶部元素。下面是一些供你参考的代码示例:
public class Main {
public static void main(String[] args) {
// 1. Initialize a stack.
Stack<Integer> s = new Stack<>();
// 2. Push new element.
s.push(5);
s.push(13);
s.push(8);
s.push(6);
// 3. Check if stack is empty.
if (s.empty() == true) {
System.out.println("Stack is empty!");
return;
}
// 4. Pop an element.
s.pop();
// 5. Get the top element.
System.out.println("The top element is: " + s.peek());
// 6. Get the size of the stack.
System.out.println("The size is: " + s.size());
}
}
2.3.1 最小栈
设计一个支持push,pop,top 操作,并能在常数时间内检索到最小元素的栈。
- push(x) —— 将元素 x 推入栈中。
- pop() —— 删除栈顶的元素。
- top() —— 获取栈顶元素。
- getMin() —— 检索栈中的最小元素。
示例:
输入:
["MinStack","push","push","push","getMin","pop","top","getMin"]
[[],[-2],[0],[-3],[],[],[],[]]
输出:
[null,null,null,null,-3,null,0,-2]
解释:
MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.getMin(); --> 返回 -3.
minStack.pop();
minStack.top(); --> 返回 0.
minStack.getMin(); --> 返回 -2.
思路:
对于栈来说,如果一个元素 a 在入栈时,栈里有其它的元素 b, c, d,那么无论这个栈在之后经历了什么操作,只要 a 在栈中,b, c, d 就一定在栈中,因为在 a 被弹出之前,b, c, d 不会被弹出。
因此,在操作过程中的任意一个时刻,只要栈顶的元素是 a,那么我们就可以确定栈里面现在的元素一定是 a, b, c, d。
那么,我们可以在每个元素 a 入栈时把当前栈的最小值 m 存储起来。在这之后无论何时,如果栈顶元素是 a,我们就可以直接返回存储的最小值 m。
算法
按照上面的思路,我们只需要设计一个数据结构,使得每个元素 a 与其相应的最小值 m 时刻保持一一对应。因此我们可以使用一个辅助栈,与元素栈同步插入与删除,用于存储与每个元素对应的最小值。
当一个元素要入栈时,我们取当前辅助栈的栈顶存储的最小值,与当前元素比较得出最小值,将这个最小值插入辅助栈中;
当一个元素要出栈时,我们把辅助栈的栈顶元素也一并弹出;
在任意一个时刻,栈内元素的最小值就存储在辅助栈的栈顶元素中。
class MinStack {
Deque<Integer> xStack;
Deque<Integer> minStack;
public MinStack() {
xStack = new LinkedList<Integer>();
minStack = new LinkedList<Integer>();
minStack.push(Integer.MAX_VALUE);
}
public void push(int x) {
xStack.push(x);
minStack.push(Math.min(minStack.peek(), x));
}
public void pop() {
xStack.pop();
minStack.pop();
}
public int top() {
return xStack.peek();
}
public int getMin() {
return minStack.peek();
}
}
复杂度分析
- 时间复杂度:对于题目中的所有操作,时间复杂度均为 O(1)O(1)。因为栈的插入、删除与读取操作都是 O(1)O(1),我们定义的每个操作最多调用栈操作两次。
- 空间复杂度:O(n)O(n),其中 nn 为总操作数。最坏情况下,我们会连续插入 nn 个元素,此时两个栈占用的空间为 O(n)O(n)。
2.3.2 有效的括号
给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
判断括号的有效性可以使用「栈」这一数据结构来解决。
思路:
我们遍历给定的字符串 ss。当我们遇到一个左括号时,我们会期望在后续的遍历中,有一个相同类型的右括号将其闭合。由于后遇到的左括号要先闭合,因此我们可以将这个左括号放入栈顶。
当我们遇到一个右括号时,我们需要将一个相同类型的左括号闭合。此时,我们可以取出栈顶的左括号并判断它们是否是相同类型的括号。如果不是相同的类型,或者栈中并没有左括号,那么字符串 ss 无效,返回 False。为了快速判断括号的类型,我们可以使用哈希表存储每一种括号。哈希表的键为右括号,值为相同类型的左括号。
在遍历结束后,如果栈中没有左括号,说明我们将字符串 ss 中的所有左括号闭合,返回 True,否则返回False。
注意到有效字符串的长度一定为偶数,因此如果字符串的长度为奇数,我们可以直接返回False,省去后续的遍历判断过程。
class Solution {
public boolean isValid(String s) {
int n = s.length();
if (n % 2 == 1) {
return false;
}
Map<Character, Character> pairs = new HashMap<Character, Character>() {{
put(')', '(');
put(']', '[');
put('}', '{');
}};
Deque<Character> stack = new LinkedList<Character>();
for (int i = 0; i < n; i++) {
char ch = s.charAt(i);
if (pairs.containsKey(ch)) {
if (stack.isEmpty() || stack.peek() != pairs.get(ch)) {
return false;
}
stack.pop();
} else {
stack.push(ch);
}
}
return stack.isEmpty();
}
}
2.3.3 每日温度
请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0 来代替。
例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
提示:气温 列表长度的范围是 [1, 30000]。每个气温的值的均为华氏度,都是在 [30, 100] 范围内的整数。
思路:
方法一:暴力 对于温度列表中的每个元素 temperatures[i],需要找到最小的下标 j,使得 i < j 且 temperatures[i] < temperatures[j]。
由于温度范围在 [30, 100] 之内,因此可以维护一个数组 next 记录每个温度第一次出现的下标。数组 next 中的元素初始化为无穷大,在遍历温度列表的过程中更新 next 的值。
反向遍历温度列表。对于每个元素 temperatures[i],在数组 next 中找到从 temperatures[i] + 1 到 100 中每个温度第一次出现的下标,将其中的最小下标记为 warmerIndex,则 warmerIndex 为下一次温度比当天高的下标。如果 warmerIndex 不为无穷大,则 warmerIndex - i 即为下一次温度比当天高的等待天数,最后令 next[temperatures[i]] = i。
为什么上述做法可以保证正确呢?因为遍历温度列表的方向是反向,当遍历到元素 temperatures[i] 时,只有 temperatures[i] 后面的元素被访问过,即对于任意 t,当 next[t] 不为无穷大时,一定存在 j 使得 temperatures[j] == t 且 i < j。又由于遍历到温度列表中的每个元素时都会更新数组 next 中的对应温度的元素值,因此对于任意 t,当 next[t] 不为无穷大时,令 j = next[t],则 j 是满足 temperatures[j] == t 且 i < j 的最小下标。
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int length = temperatures.length;
int[] ans = new int[length];
int[] next = new int[101];
Arrays.fill(next, Integer.MAX_VALUE);
for (int i = length - 1; i >= 0; --i) {
int warmerIndex = Integer.MAX_VALUE;
for (int t = temperatures[i] + 1; t <= 100; ++t) {
if (next[t] < warmerIndex) {
warmerIndex = next[t];
}
}
if (warmerIndex < Integer.MAX_VALUE) {
ans[i] = warmerIndex - i;
}
next[temperatures[i]] = i;
}
return ans;
}
}
复杂度分析
时间复杂度:O(nm)O(nm),其中 nn 是温度列表的长度,mm 是数组 next 的长度,在本题中温度不超过 100100,所以 mm 的值为 100100。反向遍历温度列表一遍,对于温度列表中的每个值,都要遍历数组 next 一遍。
空间复杂度:O(m)O(m),其中 mm 是数组 next 的长度。除了返回值以外,需要维护长度为 mm 的数组 next 记录每个温度第一次出现的下标位置。
方法二:单调栈 可以维护一个存储下标的单调栈,从栈底到栈顶的下标对应的温度列表中的温度依次递减。如果一个下标在单调栈里,则表示尚未找到下一次温度更高的下标。
正向遍历温度列表。对于温度列表中的每个元素 temperatures[i]
,如果栈为空,则直接将 i
进栈,如果栈不为空,则比较栈顶元素 prevIndex
对应的温度 temperatures[prevIndex]
和当前温度 temperatures[i]
,如果 temperatures[i] > temperatures[prevIndex]
,则将 prevIndex
移除,并将 prevIndex
对应的等待天数赋为 i - prevIndex
,重复上述操作直到栈为空或者栈顶元素对应的温度小于等于当前温度,然后将 i
进栈。
为什么可以在弹栈的时候更新 ans[prevIndex]
呢?因为在这种情况下,即将进栈的 i
对应的 temperatures[i]
一定是 temperatures[prevIndex]
右边第一个比它大的元素,试想如果 prevIndex
和 i
有比它大的元素,假设下标为 j,那么 prevIndex
一定会在下标 j 的那一轮被弹掉。
由于单调栈满足从栈底到栈顶元素对应的温度递减,因此每次有元素进栈时,会将温度更低的元素全部移除,并更新出栈元素对应的等待天数,这样可以确保等待天数一定是最小的。
以下用一个具体的例子帮助读者理解单调栈。对于温度列表 [73,74,75,71,69,72,76,73],单调栈 stack 的初始状态为空,答案 ans 的初始状态是 [0,0,0,0,0,0,0,0],按照以下步骤更新单调栈和答案,其中单调栈内的元素都是下标,括号内的数字表示下标在温度列表中对应的温度。
-
当 i = 0 时,单调栈为空,因此将 0 进栈。
- stack = [0(73)]
- ans=[0,0,0,0,0,0,0,0]
-
当 i = 1 时,由于 74 大于 73,因此移除栈顶元素 0,赋值 ans[0]:=1-0,将 1 进栈。
- stack = [1(74)]
- ans=[1,0,0,0,0,0,0,0]
-
当 i = 2 时,由于 75 大于 74,因此移除栈顶元素 1,赋值 ans[1]:=2-1,将 2 进栈。
- stack = [2(75)]
- ans=[1,1,0,0,0,0,0,0]
-
当 i = 3 时,由于 71 小于 75,因此将 3 进栈。
- stack=[2(75),3(71)]
- ans=[1,1,0,0,0,0,0,0]
-
当 i=4 时,由于 69 小于 71,因此将 4 进栈。
- stack=[2(75),3(71),4(69)]
- ans=[1,1,0,0,0,0,0,0]
-
当 i=5 时,由于 72 大于 69 和 71,因此依次移除栈顶元素 4 和 3,赋值 ans[4]:=5-4 和 ans[3]:=5-3,将 5 进栈。
- stack=[2(75),5(72)]
- ans=[1,1,0,2,1,0,0,0]
-
当 i=6 时,由于 76 大于 72 和 75,因此依次移除栈顶元素 5 和 2,赋值 ans[5]:=6-5 和 ans[2]:=6-2,将 6 进栈。
- stack=[6(76)]
- ans=[1,1,4,2,1,1,0,0]
-
当 i = 7 时,由于73小于76,因此将 7 进栈。
- stack=[6(76),7(73)]
- ans=[1,1,4,2,1,1,0,0]
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int length = temperatures.length;
int[] ans = new int[length];
Deque<Integer> stack = new LinkedList<Integer>();
for (int i = 0; i < length; i++) {
int temperature = temperatures[i];
while (!stack.isEmpty() && temperature > temperatures[stack.peek()]) {
int prevIndex = stack.pop();
ans[prevIndex] = i - prevIndex;
}
stack.push(i);
}
return ans;
}
}
复杂度分析
时间复杂度:O(n)O(n),其中 nn 是温度列表的长度。正向遍历温度列表一遍,对于温度列表中的每个下标,最多有一次进栈和出栈的操作。
空间复杂度:O(n)O(n),其中 nn 是温度列表的长度。需要维护一个单调栈存储温度列表中的下标。
3. 小结
这一章讲的是栈和队列,它们都是特殊的线性表,只不过对插入和删除操作做了限制。
-
栈(stack)是限定仅在表尾进行插入和删除操作的线性表。
-
队列(queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。
它们均可用用线性表的顺序存储结构来实现,但都存在顺序存储的一些弊端。因此它们各自有各自的技巧来解决这个问题。
对于栈来说,如果是两个相同数据类型的栈,则可用用数组的两端作栈底的方法来让两个栈共享数据,这就可以最大化利用数组的空间。
对于队列来说,为了避免数组插入和删除时需要移动数据,于是就引入了循环队列,使得队头和队尾可以在数组中循环变化。解决了移动数据的时间损耗,使得本来插入和删除时O(n)的时间复杂度变成了O(1)。
它们也都可以通过链式存储结构来实现,实现原则上与线性表基本相同。
栈 | 队列 |
---|---|
顺序栈 | 顺序队列 |
两栈共享空间 | 循环队列 |
链栈 | 链队列 |