Skip to content

6.3 状态设计——从一维到多维的平衡

问题情境:状态爆炸的困境

面试现场的内存溢出

小明在面试时遇到一道 DP 题:

问题:给定字符串 X 和 Y,求最长公共子序列(LCS)。

小明很快写出了二维 DP:

python
def lcs(X, Y):
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i-1] == Y[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

面试官点点头:"字符串长度 1000,你的空间复杂度是多少?"

小明:"dp 表是 (m+1) × (n+1),空间 O(mn)。"

面试官继续:"如果 m = n = 10000,dp 表需要多少内存?"

小明计算:10001 × 10001 × 4 字节(int) ≈ 400 MB。

面试官:"你的程序内存限制是 100 MB,怎么办?"

小明愣住了:"空间不够……怎么优化?"


第一次困惑:状态维度与空间的关系

小明发现:状态维度越高,空间越大。

状态维度空间复杂度示例问题
一维O(n)Fibonacci
二维O(n²)LCS、编辑距离
三维O(n³)某些区间 DP
n 维O(2^n)TSP

困惑:如何在高维状态中压缩空间?


第二次困惑:维度太少无法刻画子问题

小明尝试把 LCS 优化到一维:

python
# 错误尝试:只保留一行
def lcs_wrong(X, Y):
    dp = [0] * (len(Y) + 1)
    for i in range(1, len(X) + 1):
        for j in range(1, len(Y) + 1):
            if X[i-1] == Y[j-1]:
                dp[j] = dp[j-1] + 1  # 错误!dp[j-1] 是当前行的值
            else:
                dp[j] = max(dp[j], dp[j-1])
    return dp[len(Y)]

运行后,结果错误。

问题:dp[j-1] 在更新时已经是当前行的值,不是上一行的值。

小明困惑:"维度太少,无法正确刻画子问题依赖关系。怎么平衡?"


第三次改进:滚动数组

小明查阅资料,发现滚动数组技术:

python
def lcs_rolling(X, Y):
    m, n = len(X), len(Y)
    # 只保留两行:prev(上一行)和 curr(当前行)
    prev = [0] * (n + 1)
    curr = [0] * (n + 1)
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i-1] == Y[j-1]:
                curr[j] = prev[j-1] + 1
            else:
                curr[j] = max(prev[j], curr[j-1])
        prev, curr = curr, prev  # 滚动交换
    
    return prev[n]

测试:m = n = 10000,空间 2 × 10001 × 4 ≈ 80 KB。

效率提升:400 MB → 80 KB。5000 倍压缩


直观思路:状态是什么?

状态的本质

定义:状态是描述子问题的参数组合

示例

  • Fibonacci:状态 fib(i) 由参数 i 确定 → 一维
  • LCS:状态 dp[i][j] 由参数 i 和 j 确定 → 二维
  • 矩阵链乘:状态 m[i][j] 由参数 i 和 j 确定(区间)→ 二维

状态设计的三原则

  1. 唯一性:每个状态唯一确定一个子问题
  2. 完备性:所有子问题都能被状态覆盖
  3. 依赖性:状态之间有清晰的依赖关系

状态维度与空间的关系

状态维度参数数量状态空间大小
一维1O(n)
二维2O(n²)
三维3O(n³)
...

空间爆炸:维度增加,空间指数增长。


规范定义:状态空间与状态转移

状态空间的定义

定义:状态空间是所有可能状态组成的集合。

大小:|状态空间| = 各参数取值范围的乘积。

示例

  • Fibonacci:参数 i ∈ [0, n],状态空间大小 n+1
  • LCS:参数 i ∈ [0, m],j ∈ [0, n],状态空间大小 (m+1)(n+1)

状态转移方程

定义:描述如何从已知状态计算新状态的方程。

形式:dp[新状态] = f(dp[依赖状态])

示例

  • Fibonacci:dp[i] = dp[i-1] + dp[i-2]
  • LCS:dp[i][j] = dp[i-1][j-1] + 1(若 X[i] = Y[j])

初始条件与边界

初始条件:状态空间中已知的初始值。

边界条件:状态转移中需要处理的特殊情况。

示例

  • Fibonacci:dp[0] = 0, dp[1] = 1(初始条件)
  • LCS:dp[i][0] = 0, dp[0][j] = 0(边界条件)

算法实现:从一维到三维

一维状态:Fibonacci

python
def fib(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[0] = 0
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

状态:dp[i],一维。 空间优化:只保留前两个值,O(1) 空间。


二维状态:LCS

python
def lcs(X, Y):
    m, n = len(X), len(Y)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i-1] == Y[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

状态:dp[i][j],二维。 空间优化:滚动数组,只保留两行,O(n) 空间。


三维状态:区间 DP(矩阵链乘)

python
def matrix_chain(p):
    n = len(p) - 1
    dp = [[0] * n for _ in range(n)]
    
    # l 是链长度
    for l in range(2, n + 1):
        for i in range(n - l + 1):
            j = i + l - 1
            dp[i][j] = float('inf')
            for k in range(i, j):
                cost = dp[i][k] + dp[k+1][j] + p[i] * p[k+1] * p[j+1]
                dp[i][j] = min(dp[i][j], cost)
    
    return dp[0][n-1]

状态:dp[i][j],二维(但隐含长度 l,实际上是三维)。 空间:O(n²)。


高维状态:TSP(状态压缩)

python
def tsp_dp(dist):
    n = len(dist)
    # dp[S][i] = 已访问集合 S,当前在 i 的最短路径
    dp = [[float('inf')] * n for _ in range(2**n)]
    dp[1][0] = 0  # 起点
    
    for S in range(1, 2**n):
        for i in range(n):
            if S & (1 << i):  # i 在集合 S 中
                for j in range(n):
                    if not (S & (1 << j)):  # j 未访问
                        dp[S | (1 << j)][j] = min(
                            dp[S | (1 << j)][j],
                            dp[S][i] + dist[i][j]
                        )
    
    # 回到起点
    full = 2**n - 1
    return min(dp[full][i] + dist[i][0] for i in range(n))

状态:dp[S][i],其中 S 是集合(状态压缩为整数)。 状态空间大小:2^n × n。 空间:O(n × 2^n)。


状态压缩技术

滚动数组

适用场景:递推式只依赖前几行/列。

原理:只保留必要的行/列,不保留整个表。

示例:LCS 的滚动数组(只保留两行)。


状态压缩(位运算)

适用场景:状态中包含集合。

原理:用整数表示集合,每个 bit 表示元素是否在集合中。

示例:TSP 的 dp[S],其中 S 是访问过的城市集合。


空间换时间的权衡

技术空间优化适用条件
滚动数组O(n²) → O(n)递推式只依赖前几行
状态压缩O(n! × n) → O(2^n × n)状态包含集合
一维优化O(n) → O(1)只需前一状态

正确性分析:状态设计是否完备?

状态完备性验证

原则:每个子问题都能被某个状态覆盖。

检验方法

  1. 列出所有可能的子问题
  2. 检查是否每个子问题都有对应状态
  3. 若有遗漏,补充状态

示例:LCS 的子问题是"前缀 X[0:i] 和 Y[0:j] 的 LCS",状态 dp[i][j] 完备覆盖。


状态唯一性验证

原则:同一个子问题不能被多个状态描述(否则冗余)。

检验方法:检查参数组合是否有重复。


复杂度分析

时间复杂度的状态视角

公式:时间复杂度 = O(状态数 × 每个状态的转移代价)。

示例

  • Fibonacci:状态数 n,转移代价 O(1),时间 O(n)
  • LCS:状态数 mn,转移代价 O(1),时间 O(mn)
  • 矩阵链乘:状态数 n²,转移代价 O(n),时间 O(n³)
  • TSP:状态数 2^n × n,转移代价 O(n),时间 O(n² × 2^n)

空间复杂度的状态视角

公式:空间复杂度 = O(状态数)(若不优化)。

优化后:空间复杂度 = O(必需状态数)。

示例

  • LCS:状态数 mn,优化后 O(n)
  • Fibonacci:状态数 n,优化后 O(1)

反例与变式

反例 1:状态维度太少

问题:最长公共子串(必须是连续的)。

错误设计:用 LCS 的二维状态。

正确设计:需要额外标记"当前匹配长度",可能需要三维状态。


反例 2:状态维度太多

问题:某问题设计了 5 维状态,空间 O(n^5)。

教训:n=10 时,空间 10^5 = 100000,还好。n=100 时,空间 10^10 = 10 billion,爆炸。

改进:寻找冗余维度,或用滚动数组。


变式:带权 LCS

问题:字符有权重,求权重之和最大的 LCS。

状态设计:dp[i][j] = 前缀 X[0:i] 和 Y[0:j] 的带权 LCS 最大权重。

递推式:类似 LCS,但 +1 改为 +weight[X[i]]。


常见错误诊断

错误 1:状态定义不唯一

python
# 错误示例:同一子问题有多个状态
dp[i][j][k] = ...  # 但实际上 k 由 i 和 j 决定,冗余

后果:空间浪费,甚至可能导致计算顺序混乱。


错误 2:滚动数组方向错误

python
# 错误示例:LCS 滚动数组方向错误
for j in range(1, n + 1):
    if X[i-1] == Y[j-1]:
        dp[j] = dp[j-1] + 1  # 错误!dp[j-1] 是当前行,不是上一行

正确做法:需要两个数组 prev 和 curr,避免混淆。


错误 3:状态压缩范围错误

python
# 错误示例:状态压缩的范围不够
dp = [[...] for _ in range(2**n)]  # 但某些状态不需要,浪费空间

改进:只存储可达状态。


LLM 连接:Prompt 设计与状态设计

Prompt 的状态维度

类比

  • DP 状态:参数组合,决定子问题
  • LLM Prompt:输入组合,决定输出

相似问题

  • Prompt 太长 → LLM 处理慢(类似状态维度太多)
  • Prompt 不完备 → LLM 无法正确回答(类似状态不完备)

如何迁移 DP 思维到 Prompt 设计?

  1. 最小必要信息:类似状态的最小维度,只保留必要信息
  2. 避免冗余:类似状态的唯一性,避免重复描述
  3. 分层 Prompt:类似滚动数组,分阶段处理

练习

基本理解

  1. 概念解释:什么是"状态"?如何判断状态维度?

  2. 状态分析:分析 LCS 的状态为什么需要二维,不能压缩到一维?

  3. 画图验证:画出 LCS 的状态转移图,标注依赖关系。

方法应用

  1. 滚动数组实现:实现 LCS 的滚动数组版本,对比空间复杂度。

  2. 状态压缩实现:实现 TSP 的状态压缩 DP,分析状态空间大小。

  3. 新问题状态设计:为"最长公共子串"设计状态。

错误诊断

  1. 诊断错误:给定一个"状态定义不唯一"的 DP,分析问题。

  2. 滚动数组错误:如果 LCS 滚动数组只用一个数组,会发生什么错误?

  3. 状态压缩误判:有人用状态压缩解非集合问题,分析错误。

方案比较

  1. 一维 vs 二维:对比 Fibonacci 和 LCS 的状态维度差异。

  2. 滚动 vs 状态压缩:对比两种空间优化技术的适用场景。

  3. 状态设计权衡:如何在维度与空间之间权衡?

开放设计

  1. 新问题状态设计:为"编辑距离"设计状态,写出递推式。

  2. 空间优化创新:设计一个新问题的空间优化方案。

  3. LLM 连接:分析 Prompt 的"状态维度",类比 DP 状态设计。

综合应用

  1. 矩阵链乘状态分析:分析矩阵链乘的状态为什么是二维(隐含三维)。

  2. 区间 DP 设计:为"石子合并"设计状态。

  3. 真实问题:用 DP 解决实际问题:最优股票买卖(带冷却期)。


思考题

状态维度与空间的关系?

状态维度越多,空间越大(指数增长)。但维度太少,无法正确刻画子问题依赖。需要平衡。

何时用滚动数组?何时用状态压缩?

  • 滚动数组:递推式只依赖前几行/列
  • 状态压缩:状态包含集合(如 TSP)

状态设计的三原则?

  1. 唯一性:每个状态唯一确定一个子问题
  2. 完备性:所有子问题都能被状态覆盖
  3. 依赖性:状态之间有清晰的依赖关系

过渡:掌握了状态设计,就可以解决经典 DP 模型。下一节:一个框架解决一族问题——可插拔桩函数的威力。


参考文献

  • Cormen T H, et al. Introduction to Algorithms[M]. 4th ed. MIT Press, 2022. Chapter 15.
  • Skiena S S. The Algorithm Design Manual[M]. 3rd ed. Springer, 2020. Chapter 10.

新时代的算法课程