Skip to content
Published on

算法与数据结构完全精通:从复杂度分析到 AI/ML 算法

分享
Authors

目录

  1. 复杂度分析 (Complexity Analysis)
  2. 核心数据结构
  3. 图算法
  4. 动态规划
  5. 高级算法
  6. AI/ML 相关算法
  7. 编程面试核心模式
  8. 测验:检验实力

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() < O() < O(2) < O(n!)

递推式与主定理

分治算法的复杂度用递推式表示,可用主定理(Master Theorem)求解。

T(n) = aT(n/b) + f(n)
  • a = 子问题数量,b = 分割比例,f(n) = 分割/合并成本

主定理的 3 种情形:

  1. f(n) = O(n^(log_b(a) - ε)) → T(n) = Θ(n^log_b(a))
  2. f(n) = Θ(n^log_b(a)) → T(n) = Θ(n^log_b(a) · log n)
  3. 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. 核心数据结构

数据结构复杂度汇总

数据结构访问查找插入删除空间
ArrayO(1)O(n)O(n)O(n)O(n)
Linked ListO(n)O(n)O(1)O(1)O(n)
StackO(n)O(n)O(1)O(1)O(n)
QueueO(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)
HeapO(1)(max)O(n)O(log n)O(log n)O(n)

*平均情况

Red-Black Tree

自动维持平衡的自平衡二叉搜索树。

5 个性质:

  1. 每个节点非红即黑
  2. 根节点为黑色
  3. 所有叶子(NIL)为黑色
  4. 红色节点的子节点必为黑色
  5. 从根到任意叶子的路径上,黑色节点数量相同
# 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 比较

特性BFSDFS
数据结构队列(Queue)栈(Stack) / 递归
最短路径O(无权图)X
空间复杂度O(V + E)O(V)
使用场景最短路径、层序遍历环检测、拓扑排序、连通分量

最短路径算法比较

算法负权边检测负环时间复杂度适用范围
DijkstraXXO((V+E) log V)单源
Bellman-FordOOO(VE)单源
Floyd-WarshallOOO(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 TreeO(log n)O(log n)O(log n)O(4n)
Fenwick TreeO(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 系统高效推理成为可能的核心算法。

坚持刷题与动手实现,理论和实战两手都要抓。