408 · DATA STRUCTURE · 王道 / 严蔚敏 完整版
30 节课,覆盖王道教材 8 章全部考点。从 ADT 定义、伪代码、复杂度,到典型算法、手算实例、出题陷阱,一处不漏。
// agenda
01 · 绪论
渐进复杂度只关心增长形状,机器无关。
T(n) = a·T(n/b) + f(n)
02 · 线性表
| 访问 a[i] | O(1) |
|---|---|
| 查找元素值 | O(n) |
| 插入第 i 位 | O(n) 平均 (n-i+1) |
| 删除第 i 位 | O(n) 平均 (n-i) |
| 空间 | 预分配 / 一次分配;浪费或溢出 |
✅ 选它:访问多、增删少、表长稳定(如静态查找表)。
| 访问第 i 个 | O(n) |
|---|---|
| 查找元素值 | O(n) |
| 已知节点插入 | O(1) |
| 已知节点删除 | O(1)(双链)/ O(n)(单链) |
| 空间 | 动态分配;每节点多一指针 |
✅ 选它:增删频繁、表长不定(如 LRU、邻接表、基数排序桶)。
顺序表在第 i 位插入需移动 n−i+1 个元素;等概率插入第 1~n+1 位的平均移动次数 = n/2。删除平均 = (n−1)/2。
02 · 顺序表实现
// 动态顺序表定义 #define InitSize 100 typedef struct { ElemType *data; int MaxSize, length; } SeqList; // 初始化 void InitList(SeqList &L) { L.data = (ElemType*)malloc( sizeof(ElemType)*InitSize); L.length = 0; L.MaxSize = InitSize; } // 扩容 (倍增策略) void IncreaseSize(SeqList &L, int len) { ElemType *p = L.data; L.data = (ElemType*)malloc( sizeof(ElemType)*(L.MaxSize+len)); for(int i=0;i<L.length;i++) L.data[i] = p[i]; L.MaxSize += len; free(p); }
// 第 i 位插入元素 e (1 ≤ i ≤ length+1) bool ListInsert(SeqList &L, int i, ElemType e) { if(i<1 || i>L.length+1) return false; if(L.length >= L.MaxSize) return false; for(int j=L.length; j>=i; j--) L.data[j] = L.data[j-1]; // 后移 L.data[i-1] = e; L.length++; return true; } // 删除第 i 位 bool ListDelete(SeqList &L, int i, ElemType &e) { if(i<1 || i>L.length) return false; e = L.data[i-1]; for(int j=i; j<L.length; j++) L.data[j-1] = L.data[j]; L.length--; return true; } // 按值查找 → 下标 (失败返回 0) int LocateElem(SeqList L, ElemType e) { for(int i=0;i<L.length;i++) if(L.data[i] == e) return i+1; return 0; }
02 · 单链表实现
// 单链表结点 typedef struct LNode { ElemType data; struct LNode *next; } LNode, *LinkList; // 头插法 (逆序建立) LinkList List_HeadInsert(LinkList &L) { L = (LNode*)malloc(sizeof(LNode)); L->next = NULL; ElemType x; scanf("%d", &x); while(x != 9999) { LNode *s = (LNode*)malloc(sizeof(LNode)); s->data = x; s->next = L->next; // 新节点指向第一个 L->next = s; // 头指向新节点 scanf("%d", &x); } return L; }
头插法:每次插在表头之后,时间 O(1),结果逆序。
// 尾插法 (顺序建立) LinkList List_TailInsert(LinkList &L) { L = (LNode*)malloc(sizeof(LNode)); LNode *r = L; // 尾指针 ElemType x; scanf("%d", &x); while(x != 9999) { LNode *s = (LNode*)malloc(sizeof(LNode)); s->data = x; r->next = s; r = s; scanf("%d", &x); } r->next = NULL; return L; } // 按位查找第 i 个 (i >= 0, 0 即头结点) LNode* GetElem(LinkList L, int i) { int j = 0; LNode *p = L; while(p && j<i) { p = p->next; j++; } return p; // 失败返回 NULL } // 后插:在 p 之后插入 s (O(1)) bool InsertNextNode(LNode *p, ElemType e) { LNode *s = (LNode*)malloc(sizeof(LNode)); s->data = e; s->next = p->next; p->next = s; return true; }
02 · 链表变体
typedef struct DNode { ElemType data; struct DNode *prior, *next; } DNode, *DLinkList; // 在 p 后插入 s s->next = p->next; if(p->next) p->next->prior = s; s->prior = p; p->next = s; // 删除 p 后继 q p->next = q->next; if(q->next) q->next->prior = p; free(q);
已知节点删除自身 = O(1);单链表必须知前驱。
尾结点的 next 指向头(带头结点)或第一个数据节点(不带)。
用数组模拟链表,data + cursor 取代指针。
02 · 链表 5 题
LNode *p = L->next, *q, *prev = NULL; L->next = NULL; while(p) { q = p->next; p->next = L->next; L->next = p; // 头插法 p = q; }
slow = fast = L->next; while(fast && fast->next) { slow = slow->next; fast = fast->next->next; if(slow == fast) break; } if(!fast) return null; // 求环入口:从头与相遇点同速前进 LNode *p = L->next; while(p != slow) { p = p->next; slow = slow->next; } return p;
LNode *p = L1->next, *q = L2->next; LNode *r = L1; // 复用 L1 头 while(p && q) { if(p->data <= q->data) { r->next = p; r = p; p = p->next; } else { r->next = q; r = q; q = q->next; } } r->next = p ? p : q; free(L2);
LNode *fast = L, *slow = L; for(int i=0; i<k; i++) fast = fast->next; while(fast) { fast = fast->next; slow = slow->next; } return slow->next; // 即倒数第 k 个
快慢指针定位中点,反转后半段,再与前半段逐节点比较。O(n) 时间、O(1) 空间。
03 · 栈
| 顺序栈 | 链栈 | |
|---|---|---|
| 判空 | top==-1 | top==NULL |
| 入栈 | top++; S[top]=x | 头插 |
| 出栈 | x=S[top--] | 头删 |
| 满栈 | top==MaxSize-1 | 不会满 |
n 个不同元素入栈,可能的出栈序列数:
C(2n,n)/(n+1)(卡特兰数 1,2,5,14,42…)。
遇数字进栈;遇运算符弹两个数 (注意顺序: 后弹的在前),运算结果再入栈。栈底剩一即结果。
03 · 队列
| 方案 | 判空 | 判满 | 队长 |
|---|---|---|---|
| 牺牲一格 | front==rear | (rear+1)%n==front | (rear-front+n)%n |
| 设 size | size==0 | size==n | size |
| 设 tag | tag==0 且 == | tag==1 且 == | — |
// 牺牲一格法 bool EnQueue(Q &q, ElemType x) { if((q.rear+1)%n == q.front) return false; q.data[q.rear] = x; q.rear = (q.rear+1) % n; return true; } bool DeQueue(Q &q, ElemType &x) { if(q.rear == q.front) return false; x = q.data[q.front]; q.front = (q.front+1) % n; return true; }
需要 front + rear 两个指针;带头节点更方便(出空时不用特判)。
不会"满",只可能 malloc 失败。
// 双栈队列 push: 入 stack_in pop: stack_out 空时, 把 in 全部倒入 out; 否则直接出 out 栈顶
03 · 队列扩展
两端都可以入出。受限的两类:
问"给定输入 1,2,3,4,输出 4,1,3,2 是否可能"是典型题。逐步模拟即可。
每次取出"优先级最高 / 最低"的元素。底层常用二叉堆。
从最后一个非叶 ⌊n/2⌋ 开始,逐个向下调整(siftDown)。每次插入 / 删除堆顶为 O(log n)。
应用:Top-K、Dijkstra、合并 K 个有序链表、任务调度。
04 · 字符串
朴素匹配:每次失配,主串指针 i 回退到本次起点 + 1,模式串指针 j 回到 0,最坏 O(nm)。
KMP 关键洞察:失配时主串不回退,只把 j 跳到合适位置,利用已知"前缀 == 后缀"的部分。
next[j] = P[0..j-1] 的"最长真前缀 = 真后缀"长度
特别:next[0] = -1 (惯例)
P = "ababaca"
| j | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| P[j] | a | b | a | b | a | c | a |
| next | -1 | 0 | 0 | 1 | 2 | 3 | 0 |
int KMP(char *S, char *P, int *next) { int i=0, j=0; while(i<strlen(S) && j<(int)strlen(P)) { if(j == -1 || S[i] == P[j]) { i++; j++; } else { j = next[j]; // 主串 i 不回退! } } if(j == strlen(P)) return i - j; return -1; }
复杂度:构造 next O(m) + 匹配 O(n) = O(n+m)。
04 · KMP 优化
问题:若 next[j] = k 而 P[j] == P[k],则 j 跳到 k 后必然又失配,浪费一次比较。
if(P[j] != P[next[j]]) nextval[j] = next[j]; else nextval[j] = nextval[next[j]]; // 继续向上跳
P = "aaaab"
| j | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| P[j] | a | a | a | a | b |
| next | -1 | 0 | 1 | 2 | 3 |
| nextval | -1 | -1 | -1 | -1 | 3 |
若失配于 j=3 (P[3]='a'),next 会让 j 跳到 2 又比 'a',再到 1 比 'a',再到 0 比 'a',再到 -1。
nextval 直接跳到 -1。
05 · 数组
m×n 数组 A,行优先元素 a[i][j] 的地址为:
LOC(a[i][j]) = LOC(a[0][0]) + (i·n + j)·k
// k 为每个元素字节数
C 语言、Python NumPy 默认行优先;Fortran、MATLAB 列优先。
n 阶对称矩阵 a[i][j] = a[j][i],仅存上 / 下三角,n(n+1)/2 个元素。
下三角行优先:k = i(i+1)/2 + j (i ≥ j)
非零元素远少于总元素:用三元组 (i, j, value) 存储。
06 · 树
每节点存 data + parent 下标。
✅ 找父亲 O(1)
❌ 找儿子 O(n)
数组 + 每节点一条孩子链表。
✅ 找儿子方便
❌ 找父亲 O(n)
每节点两个指针:第一个孩子 + 下一个兄弟。
本质:把普通树转为二叉树!
✅ 复用二叉树算法
所有操作 O(度)
森林 → 二叉树:每棵树先用孩子兄弟法变二叉树;将后一棵树整体作为前一棵的右子树。反之亦然。
森林的先序遍历 = 对应二叉树的先序;森林的中序遍历 = 对应二叉树的中序(注意:这里中序是"先左子树、再根、再右兄弟")。
06 · 二叉树
每层都满,叶子全在最底
只有最后一层右部可缺;按层序编号与满二叉树一致
补 NULL 节点为叶
节点 i (从 1 开始):
· 父亲 = ⌊i/2⌋
· 左孩子 = 2i
· 右孩子 = 2i+1
· 所在层 = ⌊log₂i⌋ + 1
总边数 = 总节点 - 1 = n - 1;又 = 0·n₀ + 1·n₁ + 2·n₂。
整理:n₀ + n₁ + n₂ - 1 = n₁ + 2n₂ → n₀ = n₂ + 1。
06 · 遍历
void preOrder(TreeNode *p) { if(!p) return; visit(p); preOrder(p->left); preOrder(p->right); } // 中序:换 visit 位置到中间 // 后序:换 visit 位置到最后
stack<TreeNode*> st; st.push(root); while(!st.empty()) { p = st.top(); st.pop(); visit(p); if(p->right) st.push(p->right); if(p->left) st.push(p->left); // 后压先出 }
stack<TreeNode*> st; TreeNode *p = root, *r = NULL; // r 记上次访问 while(p || !st.empty()) { while(p) { st.push(p); p = p->left; } p = st.top(); if(p->right && p->right != r) { p = p->right; } else { visit(p); r = p; st.pop(); p = NULL; } }
queue<TreeNode*> q; q.push(root); while(!q.empty()) { p = q.front(); q.pop(); visit(p); if(p->left) q.push(p->left); if(p->right) q.push(p->right); }
06 · 反推树
前/后/层都只告诉你"根在哪儿",但中序是唯一把根的左、右子树分开的序列。两者结合才能递归切分。
07 · 线索
n 个节点共有 n+1 个空指针。利用它们存放"中序"或"前/后序"下的前驱后继,就能不用栈做遍历。
typedef struct ThreadNode { ElemType data; struct ThreadNode *left, *right; int ltag, rtag; // 0=孩子, 1=线索 } ThreadNode;
ThreadNode *pre = NULL; void InThread(ThreadNode *p) { if(!p) return; InThread(p->left); if(!p->left) { p->left = pre; p->ltag = 1; } if(pre && !pre->right) { pre->right = p; pre->rtag = 1; } pre = p; InThread(p->right); }
// 中序后继: // rtag=1 → right 直接是后继 // rtag=0 → 后继是右子树最左节点 ThreadNode* Next(ThreadNode *p) { if(p->rtag == 1) return p->right; p = p->right; while(p->ltag == 0) p = p->left; return p; } // 完整遍历 p = first(root); while(p) { visit(p); p = Next(p); }
前序线索:left 空指前驱、right 空指后继;前驱的查找需父指针或额外栈。
后序线索:找后继较复杂(如果是右子树最末则需父指针)。
08 · 哈夫曼
权值 {7, 5, 4, 2}
从根向左 = 0,向右 = 1。例:A=7→"0"、B=5→"10"、C=2→"110"、D=4→"111"。
前缀码:任何编码不是另一个的前缀,可唯一解码。
09 · 二叉搜索树
bool BST_Insert(BSTNode *&T, ElemType e) { if(!T) { T = (BSTNode*)malloc(sizeof(BSTNode)); T->data = e; T->left = T->right = NULL; return true; } if(e == T->data) return false; if(e < T->data) return BST_Insert(T->left, e); return BST_Insert(T->right, e); }
逐次插入 {45, 24, 53, 12, 28} 得到的 BST,中序为 12, 24, 28, 45, 53。顺序不同形态不同。
随机插入:树高 O(log n),操作 O(log n)。
有序插入:退化为单链表,O(n)。
→ 引出 AVL / 红黑树。
10 · AVL
BF(node) = h(左子树) - h(右子树) ∈ {-1, 0, 1}。插入后某节点 BF 变 ±2 即失衡。
| 类型 | 失衡发生 | 旋转 |
|---|---|---|
| LL | 左孩子的左子树插入 | 右单旋 |
| RR | 右孩子的右子树插入 | 左单旋 |
| LR | 左孩子的右子树插入 | 先左孩左旋 → 再右旋 |
| RL | 右孩子的左子树插入 | 先右孩右旋 → 再左旋 |
⚠️ 总是从最低失衡节点开始调整。调整后该子树高度不变 → 不会向上传播。
10 · 红黑树(理解性)
n 个内节点的红黑树,
高度 h ≤ 2·log₂(n + 1)
→ 查找 / 插入 / 删除 O(log n)
| AVL | 红黑树 | |
|---|---|---|
| 平衡严格度 | 左右高差 ≤ 1 | 路径长之比 ≤ 2 |
| 查找 | 更快 | 稍慢 |
| 插入 | 最多 2 次旋转 | 最多 2 次旋转 + 颜色翻转 |
| 删除 | O(log n) 次旋转 | 最多 3 次旋转 |
| 适合 | 查找远多于增删 | 增删频繁,如 STL map、Linux 进程调度 |
大纲只要求"理解概念与性质",不要求手画旋转或插入删除细节。看到红黑题,记住"性质 + 高度结论"即可。
11 · 多叉平衡树
插入总是在叶层。若关键字数 = m,从中间分裂:左半留下、右半新建节点、中间关键字上提到父亲。父亲再满则递归上传。
非叶节点关键字 → 用中序后继顶上(化为叶子删除)。叶节点关键字数不足 → 先向兄弟借(旋转),不能借则与兄弟合并,可能引起父亲递归不足。
| B 树 | B+ 树 | |
|---|---|---|
| 数据存放 | 非叶+叶 | 只在叶 |
| 关键字数 | 孩子-1 | 孩子数 |
| 查找路径 | 可能中途命中 | 必到叶 |
| 区间查询 | 差 | 沿链表很快 |
| 叶节点链 | 无 | 有 |
12 · 图
typedef struct { VertexType vex[MaxN]; EdgeType edge[MaxN][MaxN]; int vexnum, arcnum; } MGraph;
空间 O(V²);判 (i,j) 有边 O(1);列邻接 O(V)。
稠密图首选;不便处理稀疏。
typedef struct ArcNode { int adjvex; struct ArcNode *next; } ArcNode; typedef struct VNode { VertexType data; ArcNode *first; } VNode, AdjList[MaxN];
空间 O(V+E);列邻接 O(度);稀疏图首选。
有向图:出弧链 / 逆邻接表存入弧。
bool visited[MaxN] = {false}; void DFS(Graph G, int v) { visit(v); visited[v] = true; for(w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) if(!visited[w]) DFS(G, w); } // 处理非连通图: for(int i=0; i<G.vexnum; i++) if(!visited[i]) DFS(G, i);
void BFS(Graph G, int v) { visit(v); visited[v] = true; Q.push(v); while(!Q.empty()) { v = Q.pop(); for(w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w)) if(!visited[w]) { visit(w); visited[w] = true; Q.push(w); } } }
邻接表:O(V+E);邻接矩阵:O(V²)。
BFS 在无权图中即最短路径算法(层数即距离)。
13 · Dijkstra & Floyd
O(V²) 朴素;O((V+E)log V) 二叉堆
O(V log V + E) 斐波那契堆
int D[V][V]; // 初始为权值 for k = 0..V-1: for i = 0..V-1: for j = 0..V-1: if(D[i][k]+D[k][j] < D[i][j]) D[i][j] = D[i][k]+D[k][j]; path[i][j] = k;
本质:动态规划。D⁽ᵏ⁾[i][j] = 只经过前 k 个顶点的最短路径。
O(V³) 时间、O(V²) 空间。能处理负权但不能有负环。
13 · Prim & Kruskal
O(V²) 朴素 / 适合稠密图
O(E log V) 用堆
O(E log E) 排序主导 / 适合稀疏图
int parent[N]; void init() { for(int i=0;i<N;i++) parent[i] = i; } int find(int x) { if(parent[x] == x) return x; return parent[x] = find(parent[x]); // 路径压缩 } void unite(int a, int b) { parent[find(a)] = find(b); }
13 · 有向图
O(V+E)。拓扑序不唯一。
DFS 中按"退栈"次序输出即得逆拓扑;其反过来即拓扑序。
14 · 查找
块内无序、块间有序。用索引表(每块的最大值 + 起始下标)。
ASL = L_I + L_S,L_I=⌈log₂(b+1)⌉ 用折半找块,L_S=(s+1)/2 块内顺序。
15 · 排序
| 算法 | 平均 | 最坏 | 空间 | 稳定 | 核心思想 |
|---|---|---|---|---|---|
| 直接插入 | O(n²) | O(n²) | O(1) | ✅ | 哨兵 + 逐个比较后移 |
| 希尔 | ~O(n^1.3) | O(n²) | O(1) | ❌ | 分组插入,缩小增量 |
| 冒泡 | O(n²) | O(n²) | O(1) | ✅ | 相邻交换 |
| 快速 | O(n log n) | O(n²) | O(log n) | ❌ | 分治:枢轴划分 |
| 直接选择 | O(n²) | O(n²) | O(1) | ❌ | 每轮选最小 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | ❌ | 大根堆 + 顶尾交换 |
| 归并 | O(n log n) | O(n log n) | O(n) | ✅ | 分治:合并有序段 |
| 基数 | O(d(n+r)) | O(d(n+r)) | O(n+r) | ✅ | 位排序 + 桶 |
int partition(int *a, int l, int r) { int pivot = a[l]; while(l < r) { while(l < r && a[r] >= pivot) r--; a[l] = a[r]; while(l < r && a[l] <= pivot) l++; a[r] = a[l]; } a[l] = pivot; return l; } void quickSort(int *a, int l, int r) { if(l >= r) return; int p = partition(a, l, r); quickSort(a, l, p-1); quickSort(a, p+1, r); }
归并段 + k 路败者树。读取磁盘块 → 内排序 → 归并写回。性能瓶颈:IO 次数。优化:增大归并段长度、增加归并路数。