《算法与数据结构》教材学习笔记:一张地图看懂全书,附避坑技巧与典型题
- 约 20 分钟读完 - 全文 7761 字《算法与数据结构》教材学习笔记
目标读者:正在学(或补)大学《算法与数据结构》这门课的新人。 阅读目标:看完能对全书有一个整体地图;知道每种结构"什么时候用、怎么实现、容易踩什么坑";能用简单代码解决典型问题。 代码说明:实现采用 Python(简洁易懂、贴近伪代码),但思路与语言无关。
0. 开篇:这门课到底在讲什么
《算法与数据结构》整本书其实只回答一个问题:如何把"数据"组织起来,让"操作"更快。
教材里的任何一章,都可以拆成三句话:
- 逻辑结构:数据之间的关系(线性、树形、图状、集合)。
- 存储结构:这些关系在内存里怎么放(顺序存储 / 链式存储 / 散列存储)。
- 运算(算法):在这种组织方式上怎么做增删改查,效率如何。
flowchart TD
A[现实世界的问题] --> B[抽象出逻辑结构]
B --> C{选存储结构}
C --> C1[顺序存储<br/>连续内存, 随机访问快]
C --> C2[链式存储<br/>指针串联, 增删快]
C --> C3[散列存储<br/>哈希函数, 查找接近O1]
C --> D[设计运算/算法]
D --> E[用复杂度评估好坏]
E --> F[得到可执行的程序]
新人最容易犯的错误,是上来就背代码。正确顺序是:先想清楚逻辑关系 → 再选存储方式 → 最后才是写算法。
评判算法好坏的尺子只有两个:
- 时间复杂度:操作次数随输入规模 n 增长的趋势(O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ))。
- 空间复杂度:额外占用了多少内存。
避坑 #0:复杂度看的是"趋势"不是"精确次数"。O(2n+100) 就是 O(n),常数和低阶项全部扔掉。
1. 全书知识地图
flowchart LR
subgraph 基础["数据结构部分"]
L1[线性表<br/>顺序表/链表]
L2[栈与队列<br/>受限线性表]
L3[串与数组<br/>KMP/特殊矩阵]
L4[树<br/>二叉树/BST/赫夫曼/B树]
L5[图<br/>遍历/最短路/生成树]
end
subgraph 算法["算法部分"]
A1[查找<br/>顺序/二分/哈希]
A2[排序<br/>插入/交换/选择/归并]
A3[算法思想<br/>递归/分治/贪心/动态规划]
end
基础 --> 算法
L1 --> L2 --> L3 --> L4 --> L5
L4 --> A1
L3 --> A2
学习主线一句话:线性表是地基,栈队列是线性表的特例,树和图是推广,查找和排序是应用,递归分治等思想贯穿始终。
2. 线性表:一切的起点
2.1 什么时候用哪种
| 顺序表(Python 的 list) | 链表 | |
|---|---|---|
| 随机访问第 i 个 | O(1) | O(n) |
| 中间插入/删除 | O(n)(要搬数据) | O(1)(找到位置后) |
| 内存 | 连续,按需自动扩容 | 分散,逐个结点申请 |
| 典型场景 | 频繁按下标读、长度稳定 | 频繁增删、长度不确定 |
一句话场景判断:“读多写少用数组,增删多用链表”。(Python 的 list 是动态数组,自带扩容;教材里的链表则要自己定义结点类。)
2.2 单链表结构与操作图解
flowchart LR
head[头指针] --> H((头结点))
H --> A1[a₁] --> A2[a₂] --> A3[a₃] --> NULL[∅ None]
在结点 p 之后插入新结点 s,顺序不能反:
sequenceDiagram
participant s as 新结点s
participant p as 结点p
participant n as p.next
Note over s,n: 第一步: s.next = p.next (先接上后面)
s->>n: s 指向 p 的后继
Note over s,p: 第二步: p.next = s (再让 p 指向 s)
p->>s: p 指向 s
避坑 #1:插入时如果先写
p.next = s,原来p.next指向的结点就丢了(断链)。永远先接后继,再改前驱。
2.3 简单实现:单链表反转
链表反转是理解指针(引用)操作的"Hello World",也是面试必考:
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def reverse_list(head: ListNode) -> ListNode:
"""原地反转单链表,返回新的头结点"""
prev, cur = None, head
while cur:
nxt = cur.next # 1. 先记住下一个,防止断链
cur.next = prev # 2. 掉头
prev = cur # 3. prev 前进
cur = nxt # 4. cur 前进
return prev # prev 停在原尾结点,即新头避坑 #2:所有链表题,先建一个"虚拟头结点"(dummy node),可以让"删除头结点"和"删除中间结点"用同一套代码,不用特判,错误率直线下降。 避坑 #3:引用操作完一定要检查边界:对
None取.next会报AttributeError,循环里每一步都问自己"这个引用可能是None吗"。
3. 栈与队列:加了规矩的线性表
flowchart LR
subgraph 栈Stack["栈:后进先出 LIFO"]
direction TB
in1[入栈 push] --> top
top[栈顶 top] --> out1[出栈 pop]
end
subgraph 队列Queue["队列:先进先出 FIFO"]
direction LR
rear[队尾 rear<br/>入队] --> q1[...] --> front[队首 front<br/>出队]
end
3.1 场景速查
- 栈:括号匹配、表达式求值、函数调用栈、DFS、撤销(Undo)、浏览器后退。Python 里直接用
list,append()入栈、pop()出栈。 - 队列:BFS、消息队列、缓冲区、按层处理(如二叉树层序遍历)。Python 里用
collections.deque,append()入队、popleft()出队(不要用list.pop(0),那是 O(n))。
判断口诀:“后进先出想栈,排队公平想队列”。
3.2 简单实现:括号匹配
def is_match(s: str) -> bool:
"""判断字符串中的 () [] {} 是否匹配"""
pairs = {')': '(', ']': '[', '}': '{'}
stack = []
for c in s:
if c in '([{':
stack.append(c) # 左括号入栈
elif c in ')]}':
if not stack: # 坑:栈空还来右括号
return False
if stack.pop() != pairs[c]:
return False
return not stack # 坑:别忘了栈里可能剩左括号避坑 #4:括号匹配的两个经典漏判——栈空时遇到右括号(如
")(")和遍历完栈非空(如"(((")。考试和面试最爱在这两个地方埋雷。 避坑 #5:顺序队列用定长数组实现时会有"假溢出"(rear 到顶但前面有空位),解法是把数组首尾相接做成循环队列,用(rear+1) % MAXSIZE == front判满。(Python 的deque内部已处理好这个问题,但考试按教材的循环队列答。)
4. 串与 KMP:让字符串匹配不回退
朴素的字符串匹配:主串指针 i 每次失配都要回退,最坏 O(n·m)。
KMP 的核心思想一句话:失配时利用"已匹配部分的前后缀相同"信息,让主串指针 i 永不回退,只移动模式串指针 j,复杂度降到 O(n+m)。
flowchart TD
A[i 指向主串, j 指向模式串] --> B{"s[i] == p[j] ?"}
B -- 相等 --> C[i++, j++]
B -- 不等且 j>0 --> D["j = next[j]<br/>(模式串自己回退, i 不动)"]
B -- 不等且 j==0 --> E[i++]
C --> F{j 到达模式串末尾?}
D --> B
E --> B
F -- 是 --> G[匹配成功]
F -- 否 --> B
避坑 #6:
next数组的定义是"模式串前 j 个字符中,最长的相等真前后缀长度"。很多人背了代码却没理解这句话,换道题就不会推。手工推 next 数组是教材必考题,务必练几次。
5. 树:最重要的非线性结构
5.1 二叉树长什么样
graph TD
A((A 根)) --> B((B))
A --> C((C))
B --> D((D))
B --> E((E))
C --> F((F))
三种遍历对同一棵树的不同访问顺序:
| 遍历 | 顺序 | 上图结果 | 典型用途 |
|---|---|---|---|
| 前序 | 根→左→右 | A B D E C F | 复制树、表达式前缀形式 |
| 中序 | 左→根→右 | D B E A F C | BST 中序 = 有序序列 |
| 后序 | 左→右→根 | D E B F C A | 释放树、计算目录大小 |
5.2 简单实现:递归遍历
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def preorder(root: TreeNode):
"""前序遍历;把三行的顺序换成 LVR / LRV 就是中序/后序"""
if root is None: # 递归出口
return
visit(root) # V:访问根
preorder(root.left) # L:遍历左子树
preorder(root.right) # R:遍历右子树避坑 #7:树的题 90% 能用递归解决,写递归时先写递归出口(
root is None),再写"我对当前结点做什么、把什么交给子树"。想不清就画一棵三层的树手动模拟。另外注意 Python 默认递归深度约 1000,树很深时要改迭代写法或调sys.setrecursionlimit。 避坑 #8:**“二叉搜索树(BST)中序遍历结果是升序”**这条性质是大量题目的钥匙——验证 BST、找第 K 小、找两结点最近公共祖先都靠它。
5.3 教材里的三种重要树
- 赫夫曼树:带权路径长度最短的树,用于赫夫曼编码(压缩)。构造方法:每次取权值最小的两棵合并(Python 里用
heapq实现非常顺手)。 - 二叉搜索树(BST):左子树所有值 < 根 < 右子树所有值。查找/插入/删除平均 O(log n),但退化成链表时最坏 O(n)。
- 平衡二叉树(AVL)与 B 树:为了解决 BST 退化问题。AVL 靠旋转保持左右子树高度差 ≤ 1;B 树一个结点存多个关键字,矮而胖,是数据库索引和文件系统的基石。
避坑 #9:BST 删除有两个孩子的结点时,标准做法是用中序直接前驱(左子树最大)或中序直接后继(右子树最小)顶替,再删除那个顶替者。直接删会把子树搞丢。
6. 图:关系最一般的结构
6.1 怎么存
- 邻接矩阵:
g[i][j]表示 i 到 j 是否有边。适合稠密图,查边 O(1),空间 O(n²)。 - 邻接表:每个顶点挂一个邻居列表。适合稀疏图,空间 O(n+e),默认首选。Python 里用
dict[int, list[int]]即可。
6.2 两种遍历
flowchart LR
subgraph DFS["DFS 深度优先"]
d1[访问v] --> d2[选一个未访问邻居<br/>递归深入] --> d3[无路可走才回溯]
end
subgraph BFS["BFS 广度优先"]
b1[v 入队] --> b2[出队并访问] --> b3[所有未访问邻居入队] --> b2
end
from collections import deque
def dfs(g: dict, v, visited: set):
"""DFS:递归 + visited 集合防环"""
visited.add(v)
visit(v)
for w in g[v]:
if w not in visited:
dfs(g, w, visited)
def bfs(g: dict, s):
"""BFS:队列 + visited 集合"""
visited = {s}
q = deque([s])
while q:
v = q.popleft()
visit(v)
for w in g[v]:
if w not in visited:
visited.add(w) # 坑:入队时就标记,不是出队时
q.append(w)避坑 #10:图遍历必须配 visited,漏了它遇到环就死循环。而且要在入队时(不是出队时)标记 visited,否则同一结点会被多次入队。
6.3 图的经典应用速查
| 问题 | 算法 | 一句话说明 |
|---|---|---|
| 无权图最短路径 | BFS | 按层扩展,第一次到达即最短 |
| 带权(非负)最短路 | Dijkstra | 每次挑当前距离最小的点"定稿" |
| 最小生成树 | Prim / Kruskal | 连通所有点的最小代价(布线、修路) |
| 课程先修顺序 | 拓扑排序 | 反复找入度为 0 的点输出 |
| 关键路径 | AOE 网 | 工程中最长路径决定工期 |
6.4 简单实现:Dijkstra 最短路径
思想一句话:每次从未定稿的点里挑距离最小的点"定稿",再用它去松弛所有出边(好比涟漪一圈圈扩散,最近的点最先确定)。用最小堆(heapq)把"挑最小"从 O(n) 降到 O(log n),总复杂度 O((n+e) log n)。
import heapq
def dijkstra(g: dict, s) -> dict:
"""g[u] = [(v, w), ...] 表示 u->v 有一条权值为 w 的边(要求 w >= 0)
返回 s 到各点的最短距离,不可达的点不在结果中"""
dist = {s: 0}
pq = [(0, s)] # (当前距离, 顶点),最小堆
done = set() # 已"定稿"的点
while pq:
d, u = heapq.heappop(pq) # 挑出距离最小的点
if u in done: # 坑:堆里可能有同一点的过期记录
continue
done.add(u) # 定稿:d 就是 s 到 u 的最短距离
for v, w in g.get(u, []):
if v not in done and d + w < dist.get(v, float('inf')):
dist[v] = d + w # 松弛:找到更短的路
heapq.heappush(pq, (dist[v], v))
return dist
# 示例:g = {'A': [('B', 5), ('C', 2)], 'B': [('C', 1)], 'C': [('B', 3)]}
# dijkstra(g, 'A') -> {'A': 0, 'C': 2, 'B': 5}要点解释:
- 松弛:如果"经过 u 再到 v"比已知的 v 距离更短,就更新 v 的距离——
dist[v] = d + w那一行。 - 懒惰删除:堆里的记录可能已被更新(过期),出堆时用
done集合跳过,避免入堆记录删不掉的麻烦。 - 想要具体路径而不只是距离?再维护一个
prev[v] = u前驱字典,最后从终点倒推即可。
避坑 #11:Dijkstra 不能处理负权边(“定稿"假设一旦成立就被负边打破),有负权要用 Bellman-Ford。这是考研和面试的经典判断题。 避坑 #12:拓扑排序排不完(输出点数 < 总点数)说明图中有环,工程上意味着任务依赖出现了死锁。
7. 查找
7.1 三种基本查找
| 方法 | 前提 | 平均复杂度 | 场景 |
|---|---|---|---|
| 顺序查找 | 无 | O(n) | 数据少、无序 |
| 二分查找 | 有序 + 顺序存储 | O(log n) | 静态有序数据 |
| 散列(哈希)查找 | 建哈希表 | 接近 O(1) | 海量数据快速查询 |
7.2 简单实现:二分查找(正确写法)
def binary_search(a: list, key) -> int:
"""在有序数组 a 中找 key,找到返回下标,否则返回 -1"""
l, r = 0, len(a) - 1
while l <= r:
mid = l + (r - l) // 2 # 通用写法,见避坑 #13
if a[mid] == key:
return mid
elif a[mid] < key:
l = mid + 1
else:
r = mid - 1
return -1避坑 #13:Python 的整数不会溢出,但 C/Java 里
mid = (l + r) / 2在 l、r 很大时会整数溢出;统一写成l + (r - l) // 2是跨语言都安全的好习惯。(Python 工程代码直接用标准库bisect模块,但手写二分是基本功。) 避坑 #14:二分的边界要全程统一——while l <= r配l = mid + 1 / r = mid - 1(闭区间写法),不要中途混用左闭右开的条件,否则差一错误(off-by-one)必现。
7.3 哈希的冲突处理
哈希查找快的前提是处理好冲突(两个关键字映射到同一位置):
flowchart TD
K[关键字 key] --> H[哈希函数 h key]
H --> C{位置被占?}
C -- 否 --> S[直接存放/命中]
C -- 是 --> M1[开放定址法<br/>向后探测空位]
C -- 是 --> M2[链地址法<br/>同位置挂链表]
- 开放定址法:冲突了就顺延找下一个空位(教材重点,删除要设"墓碑"标记)。
- 链地址法:同位置元素串成链表(工程主流,Python 的
dict和 Java 的HashMap都用类似思路)。
避坑 #15:哈希表的效率取决于装填因子 α = 元素数 / 表长。α 越大冲突越多。工程上 α 超过 0.7 左右就该扩容了。
8. 排序:教材的重头戏
8.1 分类地图
flowchart TD
S[内部排序] --> I[插入类]
S --> X[交换类]
S --> C[选择类]
S --> M[归并类]
I --> I1[直接插入 O n²]
I --> I2[希尔排序 O n^1.3]
X --> X1[冒泡 O n²]
X --> X2[快速排序 O n log n<br/>平均最快]
C --> C1[简单选择 O n²]
C --> C2[堆排序 O n log n]
M --> M1[归并排序 O n log n<br/>稳定]
8.2 一张表记住所有考点
| 排序 | 平均 | 最坏 | 空间 | 稳定? | 一句话记忆 |
|---|---|---|---|---|---|
| 直接插入 | 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) | ✅ | 先分后合,合并两个有序段 |
避坑 #16:“稳定"指相等元素的相对顺序排序后不变。常见坑:快排、堆排、简单选择、希尔都不稳定;插入、冒泡、归并稳定。需要稳定性时(比如先按成绩排再按学号排),选归并。(Python 内置的
sorted()是 Timsort,稳定,工程直接用它。) 避坑 #17:快速排序在输入已基本有序 + 选第一个元素做枢轴时退化成 O(n²),实际工程中要随机选枢轴或三数取中。
8.3 简单实现:快速排序
def quick_sort(a: list, l: int, r: int):
"""快排:分治思想。partition 把枢轴放到最终位置,左边都比它小,右边都比它大"""
if l >= r:
return
p = partition(a, l, r)
quick_sort(a, l, p - 1) # 递归左半
quick_sort(a, p + 1, r) # 递归右半
def partition(a: list, l: int, r: int) -> int:
pivot = a[l] # 选首元素做枢轴(教学版)
while l < r:
while l < r and a[r] >= pivot:
r -= 1
a[l] = a[r] # 小的补到左边
while l < r and a[l] <= pivot:
l += 1
a[r] = a[l] # 大的补到右边
a[l] = pivot # 枢轴归位
return l9. 算法思想:递归、分治、贪心、动态规划
教材后半部分的灵魂。四者关系一句话:递归是手段,分治是"拆开再合并”,贪心是"每步选当前最好”,动态规划是"记住算过的子问题别重算"。
flowchart TD
P[大问题] --> D{能否拆成<br/>同类子问题?}
D -- 能, 子问题独立 --> DC[分治<br/>如归并排序/快排]
D -- 能, 子问题重叠 --> DP[动态规划<br/>存表避免重算]
P --> G{每步局部最优<br/>能推出全局最优?}
G -- 能 --> GR[贪心<br/>如赫夫曼/Kruskal]
G -- 不能 --> DP
9.1 简单实现:爬楼梯(DP 入门第一题)
def climb_stairs(n: int) -> int:
"""一次爬 1 或 2 级台阶,n 级有多少种爬法?"""
if n <= 2:
return n
a, b = 1, 2 # dp[1]=1, dp[2]=2
for _ in range(3, n + 1):
a, b = b, a + b # dp[i] = dp[i-1] + dp[i-2]
return b避坑 #18:写动态规划先别动手写代码,先用一句中文说清"dp[i] 表示什么"(状态定义),再写状态转移方程。这两步想清楚了,代码是机械翻译。直接上手写代码的,十有八九死在边界初始化上。(递归版 DP 记得加
@functools.lru_cache,否则指数级重复计算。)
10. 典型问题速查表
| 典型问题 | 所属章节 | 解题钥匙 |
|---|---|---|
| 单链表反转 / 找倒数第 K 个结点 | 线性表 | 三指针原地反转 / 快慢指针 |
| 括号匹配、表达式求值 | 栈 | 左括号入栈,右括号出栈比对 |
| 约瑟夫问题 | 线性表/队列 | 循环链表模拟,或队列反复出队入队 |
| 汉诺塔 | 递归 | 把 n-1 个盘移到辅助柱 → 移大盘 → 再移回 |
| 判断/还原二叉树 | 树 | 前序+中序可唯一确定一棵树 |
| 求树高、叶子数 | 树 | 递归:1 + max(左高, 右高) |
| 赫夫曼编码 | 树 | 每次合并最小两个权值(heapq) |
| 迷宫问题 | 栈/DFS | 栈记录路径,走不通就回溯 |
| 无权最短路 | 图/BFS | BFS 按层扩展 |
| 课程表能否修完 | 图/拓扑 | 拓扑排序 + 判环 |
| 前 K 大元素 | 堆 | 维护大小为 K 的小顶堆(heapq) |
| 合并 K 个有序数组 | 归并 | 两两归并或堆 |
11. 全书避坑清单(考前/面试前过一遍)
- 复杂度只看最高阶,扔掉常数。
- 链表插入:先接后继,再改前驱;万能技巧:虚拟头结点。
- 括号匹配:查"栈空遇右括号"和"遍历完栈非空"两种漏判。
- 循环队列判满:
(rear+1) % MAX == front;队列出队用deque.popleft()。 - KMP 的 next 数组要会手工推。
- 树的递归:先写递归出口;注意 Python 递归深度限制。
- 记住:BST 中序遍历 = 升序。
- 图遍历必须 visited,BFS 在入队时标记。
- Dijkstra 不支持负权边;拓扑排不完 = 有环。
- 二分:
mid = l + (r - l) // 2,区间定义全程统一。 - 哈希关注装填因子;开放定址法删除要打标记不能真删空。
- 快排/堆排/简单选择/希尔不稳定;快排怕有序输入。
- 动态规划:先说清 dp[i] 的含义,再写转移方程,最后处理初始化。
12. 学习路线建议
flowchart LR
W1[第1-2周<br/>复杂度+线性表] --> W2[第3-4周<br/>栈/队列/串]
W2 --> W3[第5-6周<br/>树与二叉树]
W3 --> W4[第7-8周<br/>图]
W4 --> W5[第9-10周<br/>查找+排序综合]
三点经验之谈:
- 每个结构都亲手实现一遍。看懂和会写之间隔着一条鸿沟,哪怕写得慢,也要自己敲出链表、栈、二叉树遍历、快排这"四大件"。
- 画图比读代码快。指针题、树题、图题,先在纸上画出结构变化,再翻译成代码,出错率低一半。
- 题做不出来别死磕。20 分钟没思路就看解析,重点搞懂"人家为什么想到用这个结构",第二天独立重写一遍。吃透一类题,胜过囫囵刷一百道。
结语
《算法与数据结构》这本书看起来章节多,其实主线非常清晰:数据怎么组织(结构)→ 操作怎么实现(算法)→ 效率怎么评估(复杂度)。把这三问套到每一章,全书就从"一堆要背的知识"变成"一张可以推理的地图"。
希望这份笔记能帮你把地图先装进脑子里,剩下的,就是动手写、动手画、动手刷。