Queue and Stack

今天我们一起学习2种重要的数据结构:队列 和 栈。
本文根据LeetCode上的Explore教程 Introduction to Data Structure - Queue & Stack 整理而成。

Introduction

最常用的Collection是数组(Array),其最常使用的获取数据的操作是随机获取(Random access), 在C++中一般称作 subscribe。
但是有时,我们想要限制处理数据的顺序。最常见的限制是:先进先出(First in first out), 后进先出(Last in first out)。分别对应2种数据结构 队列(Queue) 和 栈(Stack)。

我们从 定义、实现 和 每种数据结构的内置操作 分别学习 队列 和 栈。
学习目标:

  1. 理解数据处理顺序FIFO和LIFO的原则;
  2. 手动实现数据结构;
  3. 熟悉语言内置的Queue和Stack;
  4. 解决基础的Queue-related问题,尤其是BFS;
  5. 解决基础的Stack-related问题;
  6. 理解系统的栈如何帮助你,在解决dfs和其他递归问题的时候。

Queue: First-in-first-out Data Structure

定义

先进先出 最普遍的比喻是排队(也就是队列), 最早进入队列的人最早被服务到。
所以队列总共只有2个modify 方法:

  • enqueue
  • dequeue

实现

Queue并不是基础的数据结构,我们可以用内置的数组来实现它。在C++中,Queue是container adapter, 并不是真正的container,其内部其实是deque。

实现实例:

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
45
46
47
48
49
#include <iostream>

class MyQueue {
private:
// store elements
vector<int> data;
// a pointer to indicate the start position
int p_start;
public:
MyQueue() {p_start = 0;}
/** Insert an element into the queue. Return true if the operation is successful. */
bool enQueue(int x) {
data.push_back(x);
return true;
}
/** Delete an element from the queue. Return true if the operation is successful. */
bool deQueue() {
if (isEmpty()) {
return false;
}
p_start++;
return true;
};
/** Get the front item from the queue. */
int Front() {
return data[p_start];
};
/** Checks whether the queue is empty or not. */
bool isEmpty() {
return p_start >= data.size();
}
};

int main() {
MyQueue q;
q.enQueue(5);
q.enQueue(3);
if (!q.isEmpty()) {
cout << q.Front() << endl;
}
q.deQueue();
if (!q.isEmpty()) {
cout << q.Front() << endl;
}
q.deQueue();
if (!q.isEmpty()) {
cout << q.Front() << endl;
}
}

循环队列 Circular Queue

在之前的实现中,p_start之前的内存空间是被浪费掉的。为了充分利用,我们可以在内部使用循环利用array。

Circular Queue也叫做"Ring Buffer"。

Ring Buffer的实现:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class MyCircularQueue {
vector<int> data;
int head;
int size;
public:
/** Initialize your data structure here. Set the size of the queue to be k. */
MyCircularQueue(int k) {
data.insert(data.begin(), k, 0);
head = 0;
size = 0;
}

/** Insert an element into the circular queue. Return true if the operation is successful. */
bool enQueue(int value) {
if (isFull())
return false;

data[(head + size) % data.size()] = value;
size++;
return true;
}

/** Delete an element from the circular queue. Return true if the operation is successful. */
bool deQueue() {
if (isEmpty())
return false;

head = (head + 1) % data.size();
size--;
return true;
}

/** Get the front item from the queue. */
int Front() {
if (isEmpty()) return -1;
return data[head];
}

/** Get the last item from the queue. */
int Rear() {
if (isEmpty()) return -1;
return data[(head + size - 1) % data.size()];
}

/** Checks whether the circular queue is empty or not. */
bool isEmpty() {
return size == 0;
}

/** Checks whether the circular queue is full or not. */
bool isFull() {
return size == data.size();
}
};

/**
* Your MyCircularQueue object will be instantiated and called as such:
* MyCircularQueue* obj = new MyCircularQueue(k);
* bool param_1 = obj->enQueue(value);
* bool param_2 = obj->deQueue();
* int param_3 = obj->Front();
* int param_4 = obj->Rear();
* bool param_5 = obj->isEmpty();
* bool param_6 = obj->isFull();
*/

Queue的应用

最典型的应用,BFS。
BFS(Breadth-first Search)一般用来发现从根节点到目标节点的最短距离。

应用BFS的场景:

  • do traversal
  • find the shortest path

场景中常使用的数据结构:

在具体应用中,BFS里的节点可能是真正的节点状态,边可能是真正的边状态间的转移

BFS的模版,一定要背下来,以后面试或做题提高速度和bug-free的可能。

template 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Return the length of the shortest path between root and target node.
*/
int BFS(Node root, Node target) {
Queue<Node> queue; // store all nodes which are waiting to be processed
int step = 0; // number of steps neeeded from root to current node
// initialize
add root to queue;
// BFS
while (queue is not empty) {
step = step + 1;
// iterate the nodes which are already in the queue
int size = queue.size();
for (int i = 0; i < size; ++i) {
Node cur = the first node in queue;
return step if cur is target;
for (Node next : the neighbors of cur) {
add next to queue;
}
remove the first node from queue;
}
}
return -1; // there is no path from root to target
}

template 2

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
/**
* Return the length of the shortest path between root and target node.
*/
int BFS(Node root, Node target) {
Queue<Node> queue; // store all nodes which are waiting to be processed
Set<Node> visited; // store all the nodes that we've visited
int step = 0; // number of steps neeeded from root to current node
// initialize
add root to queue;
add root to visited;
// BFS
while (queue is not empty) {
step = step + 1;
// iterate the nodes which are already in the queue
int size = queue.size();
for (int i = 0; i < size; ++i) {
Node cur = the first node in queue;
return step if cur is target;
for (Node next : the neighbors of cur) {
if (next is not in used) {
add next to queue;
add next to visited;
}
remove the first node from queue;
}
}
}
return -1; // there is no path from root to target
}
  1. 在每一轮中,队列中的节点都是等待被处理的。
  2. 没经历一次外层的while循环,都离root更远一步, step++。

template 2

在图中,确保每个节点不被多次访问很重要。否则BFS会陷入无限循环。此时,我们增加一个hashset用来标注是否节点已经被访问到。

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
/**
* Return the length of the shortest path between root and target node.
*/
int BFS(Node root, Node target) {
Queue<Node> queue; // store all nodes which are waiting to be processed
Set<Node> visited; // store all the nodes that we've visited
int step = 0; // number of steps neeeded from root to current node
// initialize
add root to queue;
add root to visited;
// BFS
while (queue is not empty) {
step = step + 1;
// iterate the nodes which are already in the queue
int size = queue.size();
for (int i = 0; i < size; ++i) {
Node cur = the first node in queue;
return step if cur is target;
for (Node next : the neighbors of cur) {
if (next is not in used) {
add next to queue;
add next to visited;
}
}
remove the first node from queue;
}
}
return -1; // there is no path from root to target
}

什么情况下可以不使用visited呢?

  1. 你确定不会出现重复访问的情况。比如,遍历树的时候。
  2. 你确实想要把一个节点加入队列多次。

栈 Stack

提到栈,我就想起周杰伦的《七里香》。歌中唱到,

雨下整夜,我的栈溢出就像雨水。

在LIFO数据结构中,最新被添加的元素最早被处理。在栈中,添加元素的操作叫做push(压栈),移除元素的操作叫做pop(弹出)。虽然在C++中,queue的操作也是同样的名字,但在大多数语言中,push 和 pop是stack专有的。

像队列一样,绝大多数语言提供了built-in的stack库,你不需要重复造轮子,只需要熟悉stack的常用操作,包括 push, pop, top(获取栈顶元素)。

单调栈的应用

https://leetcode.com/explore/featured/card/queue-stack/230/usage-stack/1363/

Intution: 维护一个单调递减的栈,遍历一遍数组T,如果将其放入栈中,把栈中比它小的元素都pop出来,并计算出相应的间隔。

时间复杂度:O(n),
空间复杂度: O(n).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& T) {
vector<int> result(T.size(), 0);
stack<pair<int, int>> s; // temperature, day
for (int i = 0; i < T.size(); ++i) {
while (!s.empty() && s.top().first < T[i]) {
pair<int, int> current = s.top();
result[current.second] = i - current.second;
s.pop();
}
s.push({T[i], i});
}

return result;
}
};

stack 和 DFS

DFS是stack的重要应用之一,可以用来寻找从根节点到目标节点的路径(注意不一定是最短)。DFS是回溯的一种算法,只有到达最深的节点才进行回溯,尝试其他路径。

DFS 模版 1,递归版本

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* Return true if there is a path from cur to target.
*/
boolean DFS(Node cur, Node target, Set<Node> visited) {
return true if cur is target;
for (next : each neighbor of cur) {
if (next is not in visited) {
add next to visted;
return true if DFS(next, target, visited) == true;
}
}
return false;
}

DFS 模版 2,迭代版本

递归版本的优点是实现起来更加简单。缺点是,如果递归深度太深,会stack overflow。
这时,你可能会想要用 BFS 或者 用显式的栈实现DFS。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
* Return true if there is a path from cur to target.
*/
boolean DFS(int root, int target) {
Set<Node> visited;
Stack<Node> stack;
add root to stack;
while (s is not empty) {
Node cur = the top element in stack;
remove the cur from the stack;
return true if cur is target;
for (Node next : the neighbors of cur) {
if (next is not in visited) {
add next to visited;
add next to stack;
}
}
}
return false;
}

实现的逻辑和递归解法相同。只不过我们使用while循环和显式的stack来模仿系统栈。

Implement Queue using Stacks

用栈来实现队列。
我记得在程序员面试金典上遇到过一样的题目,感觉是一道很经典的题目。需要面试者对队列和栈都非常熟悉才行。

Intuition: 队列是FIFO,栈是LIFO,所以我们可以用2个栈来实现一个队列。定义将一个栈装入另一个栈的操作为颠倒。我们可以通过颠倒, 将LIFO变为FIFO,而且只有在需要dequeue的时候,才需要做颠倒的操作。

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
45
46
47
48
49
50
51
class MyQueue {
stack<int> a, b;
public:
/** Initialize your data structure here. */
MyQueue() {

}

/** Push element x to the back of queue. */
void push(int x) {
a.push(x);
}

/** Removes the element from in front of queue and returns that element. */
int pop() {
if (b.empty()) {
while (!a.empty()) {
b.push(a.top());
a.pop();
}
}
int ret = b.top();
b.pop();
return ret;
}

/** Get the front element. */
int peek() {
if (b.empty()) {
while (!a.empty()) {
b.push(a.top());
a.pop();
}
}
return b.top();
}

/** Returns whether the queue is empty. */
bool empty() {
return a.empty() && b.empty();
}
};

/**
* Your MyQueue object will be instantiated and called as such:
* MyQueue obj = new MyQueue();
* obj.push(x);
* int param_2 = obj.pop();
* int param_3 = obj.peek();
* bool param_4 = obj.empty();
*/

Implement Stack using Queues

那么如何用FIFO实现LIFO呢?解法可能不是那么明显,但却十分简单。每次push时,都把队列里的元素重新入对一遍,就把最后入对的放到第一个位置了。

总结

dfs 和 bfs 属于想法简单,但实现起来不容易,尤其是bug-free并且快速地实现更属不易。
由于考察的编程基础涉及广泛,面试官也特别喜欢考类似的题目。几个模版必须熟练背会才行。

队 和 栈 更是基础的数据结构。在计算机科学中随处可见,虽然很多时候不会直接考察,但很多算法都要用到。熟练手写stack 和 queue 和使用built-in的stack 和 queue是每个合格程序员的基础。