一、时间复杂度入门
| 复杂度 | 常见叫法 | 例子 |
|---|---|---|
O(1) |
常数 | 读取列表指定位置 nums[0] |
O(n) |
线性 | 遍历长度为 n 的列表 |
O(n²) |
平方 | 两层循环都遍历 n 次 |
O(log n) |
对数 | 每次把搜索范围减半 |
怎么看循环?
遍历一次列表:
for x in nums:
print(x)
如果列表有 n 个元素,循环约执行 n 次,是 O(n)。
两层循环都遍历同一列表:
for x in nums:
for y in nums:
print(x, y)
总次数约为 n × n,是 O(n²)。
每轮把 i 加倍:
i = 1
while i < n:
i *= 2
大约执行 log₂(n) 次,是 O(log n)。
nums[i]:列表按下标读取,通常是O(1)x in nums:列表查找,通常是O(n)x in some_set:集合查找,平均通常是O(1)- 排序:常见排序算法通常是
O(n log n)
因此,如果需要反复检查元素是否出现,把元素放进集合后再查,通常比反复扫描列表高效。
二、哈希表、栈与队列
1. 哈希表:字典和集合的底层思路
字典和集合通常用哈希表实现。哈希函数会把键转换成位置,帮助快速查找。
user = {"id": 101, "name": "小林"}
- 字典的键不能重复;重复赋值会覆盖旧值。
- 字典键和集合元素通常需要是可哈希的,例如整数、字符串、不可变元组。
- 不同键可能得到相同位置,这叫哈希冲突;实现会处理冲突,所以查找通常快,但并非任何情况下都保证
O(1)。
2. 栈:后进先出
栈的英文是 Stack,特点是 LIFO:最后放进去的,最先取出来。
stack = []
stack.append("A") # 入栈
stack.append("B")
top = stack.pop() # 出栈,得到 "B"
常见用途:
- 括号匹配
- 撤销操作
- 深度优先搜索(DFS)
- 函数调用过程
3. 队列:先进先出
队列的英文是 Queue,特点是 FIFO:先放进去的,先取出来。
Python 中可以使用 deque:
from collections import deque
queue = deque()
queue.append("A") # 入队
queue.append("B")
first = queue.popleft() # 出队,得到 "A"
不建议频繁用 list.pop(0) 做出队操作,因为它需要移动后面的元素,通常是 O(n);deque.popleft() 通常是 O(1)。
队列常用于广度优先搜索(BFS)。
4. 列表与链表的直观区别
- 数组 / Python 列表:按下标读取快,通常
O(1);在开头插入或删除通常较慢,O(n)。 - 链表:节点之间通过引用连接;已知插入位置时,插入操作可以很快,但要先按位置查找时通常需要
O(n)。
数组随机访问快;链表插入删除灵活,但查找指定位置较慢。
三、树、堆与图搜索
1. 树的基本概念
树是一种分层结构,常见术语:
- 根节点:树的起点
- 叶子节点:没有子节点的节点
- 深度:从根到该节点经过的边数
- 二叉树:每个节点最多有两个子节点
二叉搜索树(BST)满足:左子树的值小于节点,右子树的值大于节点。查找平均较快,但树严重偏斜时会退化到 O(n)。
2. 树的遍历
对节点 A,左子树为 B、右子树为 C:
- 前序:根 → 左 → 右:
A, B, C - 中序:左 → 根 → 右:
B, A, C - 后序:左 → 右 → 根:
B, C, A - 层序:从上到下、从左到右,通常用队列
几种树的区别:
先按用途分组,容易记:
- 内存中的有序查找:二叉搜索树、AVL 树、红黑树
- 磁盘 / 数据库索引:B 树、B+ 树
- 数据完整性校验:哈希树(通常指 Merkle Tree)
- 其他常见结构:堆、Trie(字典树)
| 结构 | 核心特点 | 常见用途 |
|---|---|---|
| 二叉搜索树(BST) | 左子树值较小,右子树值较大;可能退化成单链 | 有序查找的基础结构 |
| AVL 树 | 严格控制左右子树高度差,保持平衡 | 查找频繁、希望树较平衡 |
| 红黑树 | 用颜色规则维持近似平衡,查找、插入、删除都是 O(log n) |
内存中的有序 Map / Set |
| B 树 | 多路平衡搜索树,一个节点可存多个键;所有叶子深度相同 | 数据库、文件系统索引 |
| B+ 树 | 内部节点主要存索引键,数据集中在叶子;叶子通常按序链接 | 数据库范围查询、磁盘索引 |
| 哈希树 / Merkle Tree | 叶子存数据块的哈希,父节点由子节点哈希计算得出 | * 校验数据完整性、快速证明数据属于某集合 |
红黑树:记住"近似平衡"
红黑树通过节点颜色规则限制树高。重点性质包括:
- 节点为红色或黑色,根节点为黑色
- 红节点不能有红色子节点
- 从任一节点到后代叶子的路径,黑节点数量相同
因此它能保证查找、插入和删除为 O(log n)。与 AVL 树相比,红黑树的平衡约束较宽松,插入删除时通常更灵活;两者都是平衡二叉搜索树。
B 树与 B+ 树:记住"减少磁盘读取"
它们都让一个节点容纳多个键,降低树的高度,适合以页为单位读写数据的存储设备。
- B 树:键和对应数据可以分布在内部节点与叶子节点。
- B+ 树:数据记录集中在叶子节点,内部节点负责导航;叶子节点通常连成有序链表,因此范围查询效率高。
为什么数据库索引常用 B+ 树?因为扇出大、树高较低,能减少磁盘访问;叶子有序相连,也便于范围扫描。
哈希树:注意它不是哈希表
这里的哈希树通常指 Merkle Tree :把数据块分别做哈希,再逐层合并哈希,最终得到根哈希。数据一旦变化,根哈希也会变化,所以可用于检查数据是否被篡改。它主要用于完整性验证,不是为了按键查找。
3. 堆
堆是一种便于取出最大值或最小值的结构:
- 最小堆:堆顶是最小值
- 最大堆:堆顶是最大值
- 查看堆顶通常是
O(1);插入、删除堆顶通常是O(log n)
常用于优先队列和 Top-K 问题。例如,从大量数字中找最大的 10 个,通常可以用堆维护候选值。
4. 图与 BFS、DFS
图由节点(顶点)和边组成,可以是有向或无向的。
BFS(广度优先搜索)一层层向外访问,常用队列;在无权图中,可以用来找最少经过几条边的路径。
DFS(深度优先搜索)沿一条路线尽可能深入,常用递归或栈;常用于遍历连通区域、检查路径等。
简单记忆:
- BFS:队列,先发现的先处理
- DFS:栈或递归,沿一条路先走到底
(1)递归:函数调用自己
递归需要两部分:
-
终止条件:什么时候停止调用自己。
-
递归步骤:问题如何缩小,再调用自己。
def factorial(n):
if n <= 1: # 终止条件
return 1
return n * factorial(n - 1) # 递归步骤
factorial(4) 会依次计算 4 × 3 × 2 × 1,结果是 24。
易错点:没有终止条件,或问题没有逐步缩小,递归就可能不断调用,最终触发递归深度错误。
(2)回溯
回溯可以理解为:尝试一个选择,继续搜索;如果这条路走完,就撤销选择,再试下一种。它通常通过递归实现。
典型结构:
- 记录当前结果
- 逐个尝试可选项
- 做选择并递归
- 撤销选择
例如,生成 [1, 2] 的所有子集:
def subsets(nums):
result = []
path = []
def dfs(start):
result.append(path.copy())
for i in range(start, len(nums)):
path.append(nums[i]) # 做选择
dfs(i + 1) # 继续搜索
path.pop() # 撤销选择
dfs(0)
return result
结果是:
[[], [1], [1, 2], [2]]
path.copy() 很重要:如果直接把 path 放进结果,之后回溯时修改 path,之前保存的结果也会跟着变化。
有 n 个元素时,子集总数是 2ⁿ。因为还要复制并保存每个子集,时间复杂度通常写作 O(n×2ⁿ)。
(3)DFS:沿一条路尽量走深
DFS 可以用递归实现。图可能有环,所以要用 visited 防止重复访问:
def dfs(graph, node, visited=None):
if visited is None:
visited = set()
if node in visited:
return
visited.add(node)
print(node)
for neighbor in graph[node]:
dfs(graph, neighbor, visited)
这个写法用 None 作为默认参数,避免不同调用意外共享同一个集合。
(4)BFS:一层一层访问
BFS 用队列。把节点加入队列时就标记为已访问,可以避免重复入队:
from collections import deque
def bfs(graph, start):
visited = {start}
queue = deque([start])
while queue:
node = queue.popleft()
print(node)
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
以邻居顺序为准,示例图如下:
graph = {
"A": ["B", "C"],
"B": ["D"],
"C": ["D"],
"D": []
}
从 A 开始:
- BFS 顺序:
A → B → C → D - DFS 顺序:
A → B → D → C
DFS 的遍历顺序会受邻居排列顺序影响,因此不同邻居顺序可能得到不同的有效遍历结果。
(5)复杂度与用途
用邻接表表示图时,完整遍历 BFS 或 DFS 的时间复杂度通常是 O(V + E) ,其中 V 是节点数,E 是边数。
- 找无权图的最短路径:常用 BFS
- 检查能否到达、遍历连通区域:可用 DFS 或 BFS
- 需要探索所有可能路径:常用 DFS 配合回溯
四、排序与二分查找
1. 常见排序算法
先记住复杂度和特点:
| 排序 | 核心思路 | 平均时间 | 最坏时间 | 额外空间 | 稳定 |
|---|---|---|---|---|---|
| 冒泡排序 | 比较相邻元素,把较大值逐步移到后面 | O(n²) |
O(n²) |
O(1) |
是 |
| 选择排序 | 每轮找未排序部分的最小值,放到前面 | O(n²) |
O(n²) |
O(1) |
否 |
| 插入排序 | 把新元素插入前面已排好序的部分 | O(n²) |
O(n²) |
O(1) |
是 |
| 希尔排序 | 按间隔分组,先粗略排序,再逐步缩小间隔 | 取决于间隔序列 | 常见实现 O(n²) |
O(1) |
否 |
| 归并排序 | 分治:拆成小段排序,再合并 | O(n log n) |
O(n log n) |
O(n) |
是 |
| 快速排序 | 选基准值,把数据分到基准两侧,再递归排序 | O(n log n) |
O(n²) |
平均 O(log n) |
否 |
| 堆排序 | 建堆,反复取出堆顶元素 | O(n log n) |
O(n log n) |
O(1) |
否 |
| 基数排序 | 按个位、十位等位数依次分配和收集 | O(d(n+k)) |
O(d(n+k)) |
O(n+k) |
是 |
n 是元素数量;基数排序中的 d 是处理的位数,k 是每一位可能的取值范围。基数排序适合整数、固定长度字符串等能按位处理的数据,不适合所有类型的元素。
稳定"是指:两个相等元素排序后,原来的先后顺序仍然保留。基数排序要通过稳定的分配过程实现稳定性。
快速记忆
- 简单直接但通常慢:冒泡、选择、插入,平均
O(n²) - 平均效率较高:希尔、归并、快速、堆
- 不是基于元素两两比较:基数排序
- 稳定排序:冒泡、插入、归并、基数
- 原地排序且额外空间较少:冒泡、选择、插入、希尔、堆;快速排序还需要递归栈空间
2. 二分查找
二分查找每次排除一半候选范围,因此时间复杂度是 O(log n)。前提是数据已经排好序。
def binary_search(nums, target):
left = 0
right = len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
例如:
binary_search([1, 3, 5, 7, 9], 7) # 返回 3
binary_search([1, 3, 5, 7, 9], 4) # 返回 -1
常见易错点:
- 输入必须有序。
right = len(nums) - 1,因为下标从0开始。while left <= right才能检查左右边界重合的情况。- 没找到时通常返回
-1。