第4章:基础数据结构(Fundamental Data Structures)
一、导读
4.1 本章学习目标
如果说前三章教给你的是编程的"语法",那么从本章开始,你正式进入算法的"词汇"世界。数据结构是算法的基石——没有合适的数据结构,再精巧的算法也无从谈起。本章介绍四种最基础也最重要的数据结构:栈、队列、链表和树,以及 C++ STL 中对应的容器。
本章的核心学习目标包括:
- 理解栈的原理和应用:后进先出(LIFO)的特性,以及在括号匹配、表达式求值、DFS 中的关键作用。
- 掌握队列的原理和应用:先进先出(FIFO)的特性,以及在 BFS、拓扑排序中的核心地位。
- 了解链表的基本操作:动态数据结构的插入、删除、遍历,以及与数组的对比。
- 认识树的结构和遍历:二叉树、二叉搜索树的概念,前序、中序、后序遍历。
- 熟练使用 STL 容器:
stack、queue、priority_queue、vector、set、map的用法和复杂度。 - 学会选择合适的数据结构:根据问题的特点,选择最高效的数据结构来建模和求解。
4.2 为什么本章如此重要
数据结构的选择往往决定了一个算法的成败。同一个问题,用不同的数据结构来实现,时间复杂度可能相差几个数量级。例如:
- 用数组维护有序集合,插入 O(n);用
set(红黑树),插入 O(log n)。 - 用数组模拟队列,需要手动管理头尾指针;用 STL
queue,代码简洁且不易出错。 - 用邻接矩阵存图,空间 O(V²);用邻接表,空间 O(V+E)。
在算法竞赛中,STL 是你的好朋友。它提供了经过高度优化的数据结构实现,使用它们不仅能节省编码时间,还能获得接近最优的性能。熟练掌握 STL 容器,是竞赛选手的基本素养。
栈和队列看似简单,但它们的应用范围之广可能超出你的想象。从括号匹配到表达式求值,从 BFS 到拓扑排序,从单调栈到滑动窗口,栈和队列的变体贯穿了整个竞赛算法体系。
4.3 核心问题引导
在阅读本章时,请带着以下问题思考:
栈的"后进先出"特性在哪些问题中是优势?在哪些问题中是劣势?
队列和栈的核心区别是什么?BFS 为什么必须用队列而不能用栈?
链表相比数组的优势和劣势分别是什么?在竞赛中什么时候该用链表?
二叉搜索树的中序遍历有什么特殊性质?为什么它如此重要?
STL 的 priority_queue 默认是大顶堆还是小顶堆?如何改变优先级?
set 和 map 的底层实现是什么?它们的操作复杂度是多少?
二、核心概念详解
4.1 栈(Stack)
4.1.1 栈的原理
栈是一种后进先出(Last In, First Out, LIFO)的数据结构。就像一摞盘子:最后放上去的盘子最先被取走。
栈只允许在一端(栈顶)进行插入(push)和删除(pop)操作。核心操作:
| 操作 | 功能 | 时间复杂度 |
|---|---|---|
push(x) | 将 x 压入栈顶 | O(1) |
pop() | 弹出栈顶元素 | O(1) |
top() | 查看栈顶元素 | O(1) |
empty() | 判断栈是否为空 | O(1) |
size() | 返回栈中元素个数 | O(1) |
4.1.2 STL stack 的使用
#include <stack>
stack<int> st;
st.push(1); // 栈:[1]
st.push(2); // 栈:[1, 2]
st.push(3); // 栈:[1, 2, 3]
printf("%d\n", st.top()); // 3(栈顶)
st.pop(); // 弹出 3,栈:[1, 2]
printf("%d\n", st.top()); // 2
printf("%d\n", st.size()); // 24.1.3 栈的应用:括号匹配
括号匹配是栈最经典的应用之一。遇到左括号入栈,遇到右括号检查栈顶是否匹配:
#include <cstdio>
#include <stack>
#include <cstring>
bool isValid(const char *s) {
stack<char> st;
for (int i = 0; s[i]; i++) {
if (s[i] == '(' || s[i] == '[' || s[i] == '{') {
st.push(s[i]);
} else {
if (st.empty()) return false;
if (s[i] == ')' && st.top() != '(') return false;
if (s[i] == ']' && st.top() != '[') return false;
if (s[i] == '}' && st.top() != '{') return false;
st.pop();
}
}
return st.empty();
}
int main() {
char s[1005];
scanf("%s", s);
printf("%s\n", isValid(s) ? "YES" : "NO");
return 0;
}解析:如果括号序列合法,最终栈一定为空。如果任何时候栈为空却遇到了右括号,或者最后栈不为空,都说明括号不匹配。
4.1.4 单调栈
单调栈是栈的一个重要变体:栈中元素保持单调递增或递减。它常用于解决"下一个更大元素"问题:
#include <cstdio>
#include <stack>
using namespace std;
int a[100005], ans[100005];
int n;
int main() {
scanf("%d", &n);
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
stack<int> st; // 存下标,栈中下标对应的值单调递减
for (int i = n - 1; i >= 0; i--) {
while (!st.empty() && a[st.top()] <= a[i])
st.pop();
ans[i] = st.empty() ? -1 : a[st.top()];
st.push(i);
}
for (int i = 0; i < n; i++)
printf("%d%c", ans[i], " \n"[i == n - 1]);
return 0;
}4.2 队列(Queue)
4.2.1 队列的原理
队列是一种先进先出(First In, First Out, FIFO)的数据结构。就像排队买票:先来的先服务。
| 操作 | 功能 | 时间复杂度 |
|---|---|---|
push(x) | 将 x 加入队尾 | O(1) |
pop() | 弹出队首元素 | O(1) |
front() | 查看队首元素 | O(1) |
empty() | 判断队列是否为空 | O(1) |
size() | 返回队列中元素个数 | O(1) |
4.2.2 队列的应用:BFS
广度优先搜索(BFS)是队列最经典的应用。BFS 按层次遍历图或树,天然需要 FIFO 的顺序:
#include <cstdio>
#include <queue>
#include <vector>
#include <cstring>
using namespace std;
vector<int> adj[1005];
int dist[1005];
int n;
void bfs(int start) {
memset(dist, -1, sizeof(dist));
queue<int> q;
dist[start] = 0;
q.push(start);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
}4.2.3 双端队列(Deque)
双端队列允许在两端进行插入和删除操作。STL 的 deque 以及 priority_queue 都是基于它:
#include <deque>
deque<int> dq;
dq.push_back(1); // 尾部插入
dq.push_front(0); // 头部插入
dq.pop_back(); // 尾部弹出
dq.pop_front(); // 头部弹出双端队列在滑动窗口问题中有重要应用,可以在 O(n) 时间内求出每个窗口内的最大值/最小值。
4.3 优先队列(Priority Queue)
4.3.1 原理
优先队列不是按 FIFO 顺序出队,而是按优先级出队——优先级最高的元素最先出队。默认实现是堆(heap)。
| 操作 | 功能 | 时间复杂度 |
|---|---|---|
push(x) | 插入元素 | O(log n) |
pop() | 弹出最高优先级元素 | O(log n) |
top() | 查看最高优先级元素 | O(1) |
4.3.2 STL priority_queue
#include <queue>
#include <vector>
// 默认大顶堆(最大值在顶部)
priority_queue<int> pq1;
pq1.push(3); pq1.push(1); pq1.push(4);
printf("%d\n", pq1.top()); // 4
// 小顶堆(最小值在顶部)
priority_queue<int, vector<int>, greater<int>> pq2;
pq2.push(3); pq2.push(1); pq2.push(4);
printf("%d\n", pq2.top()); // 14.3.3 应用:Top-K 问题
从 n 个数中找出最大的 k 个,使用小顶堆维护当前最大的 k 个元素:
#include <cstdio>
#include <queue>
#include <vector>
using namespace std;
int main() {
int n, k;
scanf("%d%d", &n, &k);
priority_queue<int, vector<int>, greater<int>> pq; // 小顶堆
for (int i = 0; i < n; i++) {
int x;
scanf("%d", &x);
pq.push(x);
if ((int)pq.size() > k) pq.pop(); // 维护大小为 k
}
printf("Top %d:\n", k);
while (!pq.empty()) {
printf("%d\n", pq.top());
pq.pop();
}
return 0;
}复杂度分析:每次 push/pop 是 O(log k),总共 n 次操作,总时间复杂度 O(n log k),远优于排序的 O(n log n)。
4.4 链表
4.4.1 链表的原理
链表是一种动态数据结构,每个节点包含数据和指向下一个节点的指针。与数组不同,链表的元素在内存中不必连续存储:
struct Node {
int data;
Node *next;
Node(int d) : data(d), next(nullptr) {}
};| 操作 | 数组 | 链表 |
|---|---|---|
| 按位置访问 | O(1) | O(n) |
| 在头部插入 | O(n) | O(1) |
| 在尾部插入 | O(1) | O(1)(有尾指针) |
| 在中间插入 | O(n) | O(n)(需找到位置) |
| 删除元素 | O(n) | O(1)(已知位置) |
4.4.2 竞赛中的链表
在竞赛中,链表的使用频率不如数组和 STL 容器高。但在某些场景下,链表不可替代:
- 需要频繁在中间插入/删除,且已有指向该位置的指针
- 实现邻接表存储图(用数组模拟的"链表"更常见)
// 数组模拟链表(竞赛中更常用,避免指针)
int head[1005], nxt[1005], val[1005], cnt = 0;
void insert(int head_idx, int v) {
val[++cnt] = v;
nxt[cnt] = head[head_idx];
head[head_idx] = cnt;
}
void traverse(int head_idx) {
for (int i = head[head_idx]; i; i = nxt[i])
printf("%d ", val[i]);
printf("\n");
}4.5 树
4.5.1 二叉树
树是层次化的数据结构。二叉树是每个节点最多有两个子节点的树:
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};4.5.2 二叉搜索树(BST)
二叉搜索树满足:对于每个节点,左子树中所有值 < 该节点值 < 右子树中所有值。
中序遍历 BST 会得到一个有序序列——这是 BST 最重要的性质。
| 操作 | 平均复杂度 | 最坏复杂度 |
|---|---|---|
| 查找 | O(log n) | O(n) |
| 插入 | O(log n) | O(n) |
| 删除 | O(log n) | O(n) |
4.5.3 STL 中的树
STL 的 set 和 map 底层都是红黑树(一种平衡 BST),保证操作在 O(log n) 内完成:
#include <set>
#include <map>
set<int> s;
s.insert(3); s.insert(1); s.insert(4); s.insert(1); // 重复元素被忽略
for (int x : s) printf("%d ", x); // 1 3 4(自动有序)
map<string, int> m;
m["alice"] = 90;
m["bob"] = 85;
printf("%d\n", m["alice"]); // 90三、实战案例
3.1 案例一:用栈实现表达式求值
问题描述:计算只包含 +、-、*、/ 和括号的表达式。
#include <cstdio>
#include <stack>
#include <cctype>
#include <cstring>
using namespace std;
int precedence(char op) {
if (op == '+' || op == '-') return 1;
if (op == '*' || op == '/') return 2;
return 0;
}
double apply(double a, double b, char op) {
if (op == '+') return a + b;
if (op == '-') return a - b;
if (op == '*') return a * b;
if (op == '/') return a / b;
return 0;
}
double evaluate(const char *s) {
stack<double> nums;
stack<char> ops;
int i = 0;
while (s[i]) {
if (isdigit(s[i])) {
double num = 0;
while (isdigit(s[i])) {
num = num * 10 + (s[i] - '0');
i++;
}
nums.push(num);
} else if (s[i] == '(') {
ops.push(s[i]);
i++;
} else if (s[i] == ')') {
while (ops.top() != '(') {
double b = nums.top(); nums.pop();
double a = nums.top(); nums.pop();
char op = ops.top(); ops.pop();
nums.push(apply(a, b, op));
}
ops.pop();
i++;
} else {
while (!ops.empty() && precedence(ops.top()) >= precedence(s[i])) {
double b = nums.top(); nums.pop();
double a = nums.top(); nums.pop();
char op = ops.top(); ops.pop();
nums.push(apply(a, b, op));
}
ops.push(s[i]);
i++;
}
}
while (!ops.empty()) {
double b = nums.top(); nums.pop();
double a = nums.top(); nums.pop();
char op = ops.top(); ops.pop();
nums.push(apply(a, b, op));
}
return nums.top();
}
int main() {
char s[1005];
scanf("%s", s);
printf("%.2f\n", evaluate(s));
return 0;
}3.2 案例二:BFS 求无权图最短路
#include <cstdio>
#include <vector>
#include <queue>
#include <cstring>
using namespace std;
vector<int> adj[1005];
int dist[1005], prev_node[1005];
int n, m;
void bfs(int s) {
memset(dist, -1, sizeof(dist));
memset(prev_node, -1, sizeof(prev_node));
queue<int> q;
dist[s] = 0;
q.push(s);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
prev_node[v] = u;
q.push(v);
}
}
}
}
void print_path(int t) {
if (t == -1) return;
print_path(prev_node[t]);
printf("%d ", t);
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < m; i++) {
int u, v;
scanf("%d%d", &u, &v);
adj[u].push_back(v);
adj[v].push_back(u);
}
bfs(1);
printf("Distance to %d: %d\n", n, dist[n]);
printf("Path: ");
print_path(n);
printf("\n");
return 0;
}四、本章小结
本章介绍了四种基础数据结构及其 STL 实现:
栈(LIFO):括号匹配、表达式求值、单调栈、DFS 的模拟。
队列(FIFO):BFS、拓扑排序、滑动窗口。
优先队列:按优先级出队,用于 Top-K 问题、Dijkstra 最短路、Huffman 编码。
链表:动态插入删除,数组模拟链表是竞赛中的常用技巧。
树:层次化结构,BST 的中序遍历有序,STL 的 set/map 基于红黑树。
选择合适的数据结构是解决问题的第一步。在竞赛中,STL 容器是你的利器——熟练掌握它们,才能在算法实现时游刃有余。
五、思考与练习
思考题
如何用两个栈实现一个队列?如何用两个队列实现一个栈?
为什么 priority_queue 默认是大顶堆?这与堆的实现有什么关系?
vector 和 list(双向链表)各自的优缺点是什么?在竞赛中如何选择?
为什么 BST 在最坏情况下会退化为 O(n)?如何避免?
编程练习
最小栈:设计一个支持 push、pop、top 和 getMin(获取最小值)操作的栈,所有操作 O(1)。
用队列实现栈:仅使用队列的基本操作实现栈的所有功能。
合并 K 个有序链表:使用优先队列高效合并 k 个有序链表。
二叉树的层序遍历:使用队列实现二叉树的按层遍历。