目录
1. 复杂度分析
Big-O / Omega / Theta 记号
算法分析的核心,是用数学方式表达时间、空间资源如何随输入规模 n 增长。
| 记号 | 含义 | 说明 |
|---|---|---|
| O(f(n)) | 上界(Upper Bound) | 最坏情况下增长速度不超过 f(n) |
| Ω(f(n)) | 下界(Lower Bound) | 最好情况下增长速度不低于 f(n) |
| Θ(f(n)) | 紧确界(Tight Bound) | 增长速度恰好等于 f(n) |
主要复杂度比较(从慢到快排列):
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
递推式与主定理
分治算法的复杂度用递推式表示,可用主定理(Master Theorem)求解。
T(n) = aT(n/b) + f(n)
a= 子问题数量,b= 分割比例,f(n)= 分割/合并成本
主定理的 3 种情形:
- f(n) = O(n^(log_b(a) - ε)) → T(n) = Θ(n^log_b(a))
- f(n) = Θ(n^log_b(a)) → T(n) = Θ(n^log_b(a) · log n)
- f(n) = Ω(n^(log_b(a) + ε)) → T(n) = Θ(f(n))
示例: 归并排序 T(n) = 2T(n/2) + O(n) → 情形 2 → Θ(n log n)
Amortized Analysis(分摊分析)
即使单次操作代价较高,也要分析整个操作序列的平均成本。
动态数组(Dynamic Array)示例:
- 容量不足时扩容 2 倍 → 该次 push 为 O(n)
- n 次 push 的总成本:O(1) + O(1) + ... + O(n) ≈ O(2n) = O(n)
- 分摊成本:每次操作 O(1)
2. 核心数据结构
数据结构复杂度汇总
| 数据结构 | 访问 | 查找 | 插入 | 删除 | 空间 |
|---|---|---|---|---|---|
| Array | O(1) | O(n) | O(n) | O(n) | O(n) |
| Linked List | O(n) | O(n) | O(1) | O(1) | O(n) |
| Stack | O(n) | O(n) | O(1) | O(1) | O(n) |
| Queue | O(n) | O(n) | O(1) | O(1) | O(n) |
| Hash Table | - | O(1)* | O(1)* | O(1)* | O(n) |
| BST(平衡) | O(log n) | O(log n) | O(log n) | O(log n) | O(n) |
| Heap | O(1)(max) | O(n) | O(log n) | O(log n) | O(n) |
*平均情况
Red-Black Tree
自动维持平衡的自平衡二叉搜索树。
5 个性质:
- 每个节点非红即黑
- 根节点为黑色
- 所有叶子(NIL)为黑色
- 红色节点的子节点必为黑色
- 从根到任意叶子的路径上,黑色节点数量相同
# Red-Black Tree 旋转 - 左旋(Left Rotation)概念
#
# x y
# / \ → / \
# a y x c
# / \ / \
# b c a b
#
def left_rotate(tree, x):
y = x.right # y 是 x 的右子节点
x.right = y.left # 把 y 的左子树接到 x 的右边
if y.left != tree.NIL:
y.left.parent = x
y.parent = x.parent # 把 x 的父节点接给 y
if x.parent == tree.NIL:
tree.root = y
elif x == x.parent.left:
x.parent.left = y
else:
x.parent.right = y
y.left = x # 把 x 接为 y 的左子节点
x.parent = y
Heap 与优先队列
堆以完全二叉树的形式存储在数组中。
import heapq
# 用最小堆实现 Dijkstra
def dijkstra(graph, start):
distances = {node: float('inf') for node in graph}
distances[start] = 0
# 以 (距离, 节点) 的形式存入堆
heap = [(0, start)]
visited = set()
while heap:
dist, node = heapq.heappop(heap)
if node in visited:
continue
visited.add(node)
for neighbor, weight in graph[node]:
new_dist = dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
heapq.heappush(heap, (new_dist, neighbor))
return distances
# 使用示例
graph = {
'A': [('B', 1), ('C', 4)],
'B': [('C', 2), ('D', 5)],
'C': [('D', 1)],
'D': []
}
print(dijkstra(graph, 'A'))
# {'A': 0, 'B': 1, 'C': 3, 'D': 4}
3. 图算法
BFS vs DFS 比较
| 特性 | BFS | DFS |
|---|---|---|
| 数据结构 | 队列(Queue) | 栈(Stack) / 递归 |
| 最短路径 | O(无权图) | X |
| 空间复杂度 | O(V + E) | O(V) |
| 使用场景 | 最短路径、层序遍历 | 环检测、拓扑排序、连通分量 |
最短路径算法比较
| 算法 | 负权边 | 检测负环 | 时间复杂度 | 适用范围 |
|---|---|---|---|---|
| Dijkstra | X | X | O((V+E) log V) | 单源 |
| Bellman-Ford | O | O | O(VE) | 单源 |
| Floyd-Warshall | O | O | O(V³) | 全源 |
Floyd-Warshall 实现
def floyd_warshall(n, edges):
INF = float('inf')
# dist[i][j] = i 到 j 的最短距离
dist = [[INF] * n for _ in range(n)]
for i in range(n):
dist[i][i] = 0
for u, v, w in edges:
dist[u][v] = w
for k in range(n): # 中转节点
for i in range(n): # 起点
for j in range(n): # 终点
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
return dist
MST:Kruskal vs Prim
Kruskal(基于边,使用 Union-Find):
- 将所有边按权重升序排序,依次选取不形成环的边
- 时间复杂度:O(E log E)
- 适合稀疏图(Sparse Graph)
Prim(基于节点,使用最小堆):
- 从起始节点出发,反复选取相邻的最小权重边
- 时间复杂度:O(E log V)
- 适合稠密图(Dense Graph)
拓扑排序(Topological Sort)
在 DAG(有向无环图)中确定依赖关系的顺序。
from collections import deque
def topological_sort(n, edges):
indegree = [0] * n
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
indegree[v] += 1
queue = deque([i for i in range(n) if indegree[i] == 0])
result = []
while queue:
node = queue.popleft()
result.append(node)
for neighbor in graph[node]:
indegree[neighbor] -= 1
if indegree[neighbor] == 0:
queue.append(neighbor)
return result if len(result) == n else [] # 存在环时返回空数组
4. 动态规划
DP 的两种方式
最优子结构(Optimal Substructure): 问题的最优解可以由子问题的最优解构成。 重叠子问题(Overlapping Subproblems): 相同的子问题反复出现。
| 方式 | 方向 | 优点 | 缺点 |
|---|---|---|---|
| Memoization(Top-Down) | 递归 + 缓存 | 只计算需要的部分 | 递归栈开销 |
| Tabulation(Bottom-Up) | 循环 | 无栈溢出风险,缓存效率高 | 可能包含不必要的计算 |
LCS(最长公共子序列)
def lcs(s1, s2):
m, n = len(s1), len(s2)
# dp[i][j] = s1[:i] 与 s2[:j] 的 LCS 长度
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
# 2D 表可视化示例(s1="ABCD", s2="ACBD"):
# "" A C B D
# "" 0 0 0 0 0
# A 0 1 1 1 1
# B 0 1 1 2 2
# C 0 1 2 2 2
# D 0 1 2 2 3 ← LCS 长度 = 3("ABD" 或 "ACD")
return dp[m][n]
0/1 Knapsack
def knapsack(weights, values, capacity):
n = len(weights)
# dp[i][w] = 考虑到第 i 个物品、容量为 w 时的最大价值
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for w in range(capacity + 1):
# 不选第 i 个物品
dp[i][w] = dp[i-1][w]
# 选第 i 个物品
if weights[i-1] <= w:
dp[i][w] = max(dp[i][w],
dp[i-1][w - weights[i-1]] + values[i-1])
return dp[n][capacity]
5. 高级算法
Segment Tree(区间树)
以 O(log n) 处理数组的区间查询(和、最小值、最大值)。
class SegmentTree:
def __init__(self, arr):
self.n = len(arr)
self.tree = [0] * (4 * self.n)
self.build(arr, 0, 0, self.n - 1)
def build(self, arr, node, start, end):
if start == end:
self.tree[node] = arr[start]
else:
mid = (start + end) // 2
self.build(arr, 2*node+1, start, mid)
self.build(arr, 2*node+2, mid+1, end)
self.tree[node] = self.tree[2*node+1] + self.tree[2*node+2]
def query(self, node, start, end, l, r):
if r < start or end < l:
return 0 # 超出范围
if l <= start and end <= r:
return self.tree[node] # 完全包含
mid = (start + end) // 2
left = self.query(2*node+1, start, mid, l, r)
right = self.query(2*node+2, mid+1, end, l, r)
return left + right
def update(self, node, start, end, idx, val):
if start == end:
self.tree[node] = val
else:
mid = (start + end) // 2
if idx <= mid:
self.update(2*node+1, start, mid, idx, val)
else:
self.update(2*node+2, mid+1, end, idx, val)
self.tree[node] = self.tree[2*node+1] + self.tree[2*node+2]
Fenwick Tree(树状数组)
比 Segment Tree 实现更简单、内存效率更好。
class FenwickTree:
def __init__(self, n):
self.n = n
self.tree = [0] * (n + 1)
def update(self, i, delta):
while i <= self.n:
self.tree[i] += delta
i += i & (-i) # 加上 LSB(最低有效位)
def query(self, i):
total = 0
while i > 0:
total += self.tree[i]
i -= i & (-i) # 减去 LSB(最低有效位)
return total
def range_query(self, l, r):
return self.query(r) - self.query(l - 1)
| 数据结构 | 实现复杂度 | 区间查询 | 单点更新 | 区间更新 | 内存 |
|---|---|---|---|---|---|
| Segment Tree | 高 | O(log n) | O(log n) | O(log n) | O(4n) |
| Fenwick Tree | 低 | O(log n) | O(log n) | 有限 | O(n) |
Union-Find(路径压缩 + 按秩合并)
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
# 路径压缩(Path Compression)
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False # 已属于同一集合
# 按秩合并(Union by Rank)
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
return True
# 路径压缩 + 按秩合并 → 实际上 O(α(n)) ≈ O(1)(反阿克曼函数)
KMP 字符串匹配
def kmp_search(text, pattern):
def build_lps(pattern):
lps = [0] * len(pattern)
length, i = 0, 1
while i < len(pattern):
if pattern[i] == pattern[length]:
length += 1
lps[i] = length
i += 1
elif length:
length = lps[length - 1]
else:
lps[i] = 0
i += 1
return lps
lps = build_lps(pattern)
i = j = 0
results = []
while i < len(text):
if text[i] == pattern[j]:
i += 1; j += 1
if j == len(pattern):
results.append(i - j)
j = lps[j - 1]
elif i < len(text) and text[i] != pattern[j]:
j = lps[j - 1] if j else (i := i + 1) and 0
return results
6. AI/ML 相关算法
k-d Tree(k-NN 搜索)
k-d tree 能在 k 维空间中高效搜索最近邻(k-NN)。
class KDNode:
def __init__(self, point, left=None, right=None):
self.point = point
self.left = left
self.right = right
def build_kd_tree(points, depth=0):
if not points:
return None
k = len(points[0])
axis = depth % k
sorted_points = sorted(points, key=lambda p: p[axis])
mid = len(sorted_points) // 2
return KDNode(
point=sorted_points[mid],
left=build_kd_tree(sorted_points[:mid], depth + 1),
right=build_kd_tree(sorted_points[mid+1:], depth + 1)
)
def nearest_neighbor(root, query, depth=0, best=None):
if root is None:
return best
k = len(query)
axis = depth % k
dist = sum((q - p) ** 2 for q, p in zip(query, root.point)) ** 0.5
if best is None or dist < best[0]:
best = (dist, root.point)
# 比较查询点与分割超平面
diff = query[axis] - root.point[axis]
close, away = (root.left, root.right) if diff <= 0 else (root.right, root.left)
best = nearest_neighbor(close, query, depth + 1, best)
# 判断是否需要搜索另一侧子树
if abs(diff) < best[0]:
best = nearest_neighbor(away, query, depth + 1, best)
return best
k-d tree 平均复杂度:O(log n),最坏情况:O(n)(高维时性能下降 → "维度灾难")
LSH(局部敏感哈希)
在高维数据(嵌入向量)中快速查找相似数据的近似最近邻(ANN)方法。
- 核心思路: 相似向量以高概率落入同一哈希桶
- 余弦相似度 LSH: 用随机超平面生成哈希
- 应用: 大规模嵌入搜索(Faiss、ScaNN)、重复文档检测
- 时间复杂度: 查询 O(1) ~ O(log n),索引构建 O(nk)
Beam Search(NLP 解码)
Greedy 搜索的改进版:每一步保留前 k 个(beam width)候选。
beam width k=2,每个 token 保留前 2 个:
第 1 步:[A(0.6), B(0.4)]
第 2 步:[AA(0.36), AB(0.24), BA(0.28), BB(0.16)] → 保留前 2:[AA, BA]
第 3 步:[AAX, AAY, BAX, BAY] → 继续保留前 2...
权衡: beam width 增大 → 质量提升 但内存/时间随 O(k·n) 增长
A* 算法(机器人/路径规划)
结合 Dijkstra 与启发式函数 h(n) 的最优路径搜索算法。
f(n) = g(n) + h(n)
g(n) = 从起点到当前节点的实际成本
h(n) = 从当前节点到目标的估计成本(可采纳启发式)
- 可采纳(Admissible)条件: h(n) ≤ 实际成本 → 保证最优解
- 机器人应用: 网格地图移动规划、ROS Navigation Stack
- 时间复杂度: O(E log V)(与 Dijkstra 相同,但实际搜索空间大幅减少)
7. 编程面试核心模式
Two Pointers
# 在有序数组中找出和为 target 的两个数
def two_sum_sorted(arr, target):
left, right = 0, len(arr) - 1
while left < right:
current = arr[left] + arr[right]
if current == target:
return (left, right)
elif current < target:
left += 1
else:
right -= 1
return None
# 时间 O(n),空间 O(1)
Sliding Window
# 长度为 k 的子数组的最大和
def max_subarray_sum(arr, k):
window_sum = sum(arr[:k])
max_sum = window_sum
for i in range(k, len(arr)):
window_sum += arr[i] - arr[i - k]
max_sum = max(max_sum, window_sum)
return max_sum
# 时间 O(n),空间 O(1)
Backtracking
# N-Queens 问题
def solve_n_queens(n):
results = []
board = [-1] * n
def is_valid(row, col):
for r in range(row):
if board[r] == col:
return False
if abs(board[r] - col) == abs(r - row):
return False
return True
def backtrack(row):
if row == n:
results.append(board[:])
return
for col in range(n):
if is_valid(row, col):
board[row] = col
backtrack(row + 1)
board[row] = -1 # 回溯
backtrack(0)
return results
模式选择指南
| 问题类型 | 推荐模式 |
|---|---|
| 有序数组,两数/三数组合 | Two Pointers |
| 连续子数组/子字符串 | Sliding Window |
| 组合/排列/计数问题 | Backtracking |
| 最短路径/层序遍历 | BFS |
| 环/拓扑/连通性 | DFS |
| 区间查询/更新 | Segment Tree / Fenwick Tree |
| 集合合并/连通性判断 | Union-Find |
| 最优化问题、重叠子问题 | Dynamic Programming |
8. 测验:检验实力
Q1. 哈希表的平均 O(1) 查找在最坏情况下变为 O(n) 的情况是什么?
答案:所有键都哈希到同一个桶(Hash Collision)时
说明:哈希表用哈希函数把键映射到桶。理想情况下没有冲突,为 O(1);但如果哈希函数不佳或输入是恶意构造的,导致全部 n 个键都落入同一个桶,那么在链式(Chaining)方式下就必须线性遍历该桶的链表,退化为 O(n)。为防止这种情况,可以在负载因子(Load Factor)超过阈值时进行再哈希(Rehashing)、使用随机哈希函数(Universal Hashing),或采用 Robin Hood Hashing 等方法。
Q2. 为什么 Dijkstra 算法在存在负权边时无法正常工作?
答案:Dijkstra 基于"已访问节点的距离即为最短"这一贪心(Greedy)假设,而负权边会破坏这一假设。
说明:Dijkstra 会把从最小堆中取出的节点标记为"已确定"。如果存在负权边,即使没有负环,经过已确定节点 A 再到 B 的路径 A → B → A 也可能缩短 A 的距离。也就是说,后访问的节点可能会改进之前已确定节点的距离,使算法的前提不再成立。对于含负权边的图,应使用 Bellman-Ford 算法。
Q3. DP 中记忆化和表格法在空间复杂度上有什么区别?
答案:记忆化只存储被调用过的子问题(在稀疏时更有利);表格法要填满整张表,因此始终至少为 O(n)。不过表格法可以通过滑动窗口进行空间优化。
说明:记忆化采用 Top-Down 方式,在递归调用时存入缓存,只计算实际需要的子问题。即使子问题总数为 n,只要实际调用数为 k,就只使用 O(k) 空间。相反,表格法采用 Bottom-Up 方式填满整张表,始终使用 O(n) 空间。但像斐波那契数列那样只需要前两个值的情况,可以用 dp[i] = dp[i-1] + dp[i-2] 优化为 O(1) 空间。
Q4. k-d tree 在 k-NN 搜索中平均高效的原因和局限是什么?
答案:通过每次将空间对半分割来缩小搜索范围,平均为 O(log n)。但在高维(d 较大)时会因"维度灾难"导致性能趋近于 O(n)。
说明:k-d tree 在每一层以一个坐标轴为基准把数据一分为二。搜索最近邻时,通过比较查询点与分割超平面的距离来剪枝(pruning)不必要的子树。在低维(d ≤ 20)时平均为 O(log n),效率很高;但维度越高,与分割超平面的距离就越小,剪枝效果随之减弱,最终会访问几乎所有点。在高维场景下,LSH 或 HNSW(Hierarchical Navigable Small World)等近似最近邻(ANN)算法效率更高。
Q5. Segment Tree 与 Fenwick Tree(BIT)在实现复杂度和使用场景上有什么区别?
答案:Segment Tree 实现复杂但通用性强(区间更新、多种聚合);Fenwick Tree 实现简单、速度快,但仅适用于前缀和等有限的运算。
说明:Fenwick Tree(BIT)通过位运算(LSB)实现,代码非常简洁、缓存效率高,主要用于 prefix sum(区间和)查询与单点更新。Segment Tree 实现较复杂,但支持区间最小值/最大值、通过 Lazy Propagation 实现的区间更新,以及复杂的聚合函数(GCD、XOR 等)。在竞赛编程中,"只需要区间和就用 BIT,其余情况用 Segment Tree" 是常见的经验法则。
总结
算法与数据结构是 AI 工程的核心基础。
- 复杂度分析对于在系统设计中预测瓶颈、做出扩展决策是必不可少的。
- 图算法直接应用于知识图谱、推荐系统、路径优化。
- DP 与序列建模、强化学习中的贝尔曼方程紧密相连。
- k-d tree、LSH、Beam Search 是让现代 ML 系统高效推理成为可能的核心算法。
坚持刷题与动手实现,理论和实战两手都要抓。
현재 단락 (1/375)
1. [复杂度分析 (Complexity Analysis)](#1-复杂度分析)