Posted in

动态规划太难?用Go实现这7道经典题,彻底搞懂DP核心逻辑

第一章:动态规划的核心思想与学习路径

动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解为更小的子问题来求解的算法设计思想。其核心在于状态定义状态转移,通过记录已解决的子问题结果,避免重复计算,从而显著提升效率。它适用于具有最优子结构重叠子问题性质的问题,如斐波那契数列、背包问题、最长公共子序列等。

理解状态与转移方程

在动态规划中,“状态”是对问题某一阶段的描述,“转移方程”则是如何从已知状态推导出新状态的规则。例如,在计算斐波那契数列时,可以定义状态 dp[i] 表示第 i 个斐波那契数,其转移方程为:

dp[0] = 0
dp[1] = 1
for i in range(2, n + 1):
    dp[i] = dp[i - 1] + dp[i - 2]  # 当前状态由前两个状态决定

该代码通过自底向上的方式填充数组,避免了递归中的重复调用,时间复杂度从指数级降至 O(n)。

学习路径建议

掌握动态规划需要循序渐进地训练思维模式。以下是推荐的学习路径:

  • 基础阶段:理解记忆化搜索与递推的区别,熟练实现经典问题(如爬楼梯、打家劫舍)
  • 进阶阶段:掌握多维DP(如二维背包)、区间DP(如石子合并)
  • 高阶阶段:学习状态压缩、树形DP、数位DP等高级技巧
阶段 典型问题 关键技能
基础 斐波那契、最大子序和 状态定义、一维转移
中级 0-1背包、编辑距离 二维DP、边界处理
高级 状压DP、环形石子合并 状态优化、环形处理

持续练习并归纳题型模式,是掌握动态规划的关键。

第二章:基础DP模型与Go实现

2.1 理解状态转移方程的设计原理

状态转移方程是动态规划算法的核心,它描述了从一个状态到另一个状态的演化规则。设计合理的转移方程,关键在于准确识别问题的状态表示与子问题之间的依赖关系。

状态分解与递推逻辑

通常,状态可表示为 dp[i]dp[i][j],其含义需清晰定义,例如“前 i 个元素的最大收益”。转移时,需考虑所有可能的决策路径。

dp[i] = max(dp[i-1], dp[i-2] + value[i])  # 选择是否取第i项

上述代码实现打家劫舍问题的状态转移。dp[i-1] 表示不取第 i 项,继承前序最大值;dp[i-2] + value[i] 表示取第 i 项,则不能取 i-1 项。该设计体现了状态间的互斥决策。

转移方程构建步骤

  • 明确状态含义
  • 枚举合法决策
  • 建立当前状态与子状态的数学关系

决策依赖可视化

graph TD
    A[状态 dp[i]] --> B[不选第i项 → dp[i-1]]
    A --> C[选第i项 → dp[i-2]+val]
    B --> D[最终取最大值]
    C --> D

2.2 斐波那契数列与记忆化搜索实践

斐波那契数列是递归算法的经典示例,其定义为:F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2)。直接递归实现会导致大量重复计算,时间复杂度高达 O(2^n)。

朴素递归的性能瓶颈

def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

该实现未缓存子问题结果,导致同一状态被多次求解,效率极低。

引入记忆化搜索优化

使用字典缓存已计算结果,避免重复递归:

def fib_memo(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]

memo 字典存储中间状态,将时间复杂度降至 O(n),空间复杂度为 O(n)。

性能对比表

方法 时间复杂度 空间复杂度 是否可行
朴素递归 O(2^n) O(n) n
记忆化搜索 O(n) O(n)

优化思路可视化

graph TD
    A[fib(5)] --> B[fib(4)]
    A --> C[fib(3)]
    B --> D[fib(3)]
    D --> E[fib(2)]
    C --> F[fib(2)]
    C --> G[fib(1)]
    style D stroke:#f66,stroke-width:2px
    style C stroke:#6f6,stroke-width:2px

图中 fib(3) 被重复计算,记忆化可消除此类冗余分支。

2.3 爬楼梯问题中的递推关系剖析

爬楼梯问题是动态规划中的经典入门案例,其核心在于发现状态之间的递推关系。假设每次可走1阶或2阶,到达第 $n$ 阶的方法数等于到达第 $n-1$ 阶和第 $n-2$ 阶方法数之和。

递推公式的建立

该问题满足斐波那契数列的递推关系: $$ f(n) = f(n-1) + f(n-2) $$ 初始条件为 $f(0)=1$、$f(1)=1$,分别表示站在地面算一种方式,以及第一阶只有一种走法。

动态规划实现

def climbStairs(n):
    if n <= 1:
        return 1
    a, b = 1, 1
    for i in range(2, n + 1):
        a, b = b, a + b  # 滚动更新,节省空间
    return b

上述代码使用滚动变量将空间复杂度优化至 $O(1)$,时间复杂度为 $O(n)$。ab 分别代表前两个状态的解,通过迭代避免重复计算。

n f(n)
0 1
1 1
2 2
3 3

状态转移图示

graph TD
    A[开始] --> B[f(0)=1]
    A --> C[f(1)=1]
    C --> D[f(2)=2]
    D --> E[f(3)=3]
    E --> F[f(4)=5]

2.4 打家劫舍问题的状态定义技巧

动态规划的核心在于状态的合理定义。在“打家劫舍”问题中,若直接以房屋索引作为状态,容易忽略相邻限制带来的约束。

状态设计的关键洞察

应将状态拆分为两种情形:

  • dp[i][0]:第 i 间房未被抢劫时的最大收益
  • dp[i][1]:第 i 间房被抢劫时的最大收益
# 状态转移方程
dp[i][0] = max(dp[i-1][0], dp[i-1][1])  # 不抢当前房,前一间可抢可不抢
dp[i][1] = dp[i-1][0] + nums[i]         # 抢当前房,前一间必须不抢

上述代码中,nums[i] 表示第 i 间房的金额。状态 dp[i][1] 的来源受限于 dp[i-1][0],体现了相邻不能同时抢劫的约束。

状态压缩优化

由于只依赖前一状态,可用两个变量替代数组:

变量 含义
rob_prev 前一间房被抢时的最大收益
not_rob_prev 前一间房未被抢时的最大收益

通过逐步更新,最终返回两者最大值即可完成求解。

2.5 最长递增子序列的O(n²)解法实战

动态规划是解决最长递增子序列(LIS)问题的经典方法。我们定义 dp[i] 表示以 nums[i] 结尾的最长递增子序列长度。

状态转移逻辑

对于每个位置 i,遍历其之前的所有元素 jj < i),若 nums[j] < nums[i],则可将 nums[i] 接在 dp[j] 后形成更长递增序列:

def lengthOfLIS(nums):
    if not nums: return 0
    dp = [1] * len(nums)
    for i in range(1, len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)
  • 时间复杂度:O(n²),双层循环嵌套
  • 空间复杂度:O(n),dp 数组存储各位置最优解

状态演化示例

i nums[i] dp数组变化过程
0 10 [1,1,1,1,1]
1 9 [1,1,1,1,1]
2 2 [1,1,1,1,1]
3 5 [1,1,1,2,1]
4 3 [1,1,1,2,2]

上述流程展示了状态逐步更新的过程,最终结果为 2

第三章:经典线性DP问题精讲

3.1 最长公共子序列(LCS)的二维DP构建

最长公共子序列(LCS)是动态规划中的经典问题,用于找出两个序列中最长的公共子序列。其核心思想是利用二维DP表 dp[i][j] 表示字符串 A[0..i-1]B[0..j-1] 的LCS长度。

状态转移逻辑

当字符匹配时,状态递增;否则继承前值:

if A[i-1] == B[j-1]:
    dp[i][j] = dp[i-1][j-1] + 1
else:
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])

代码说明:dp[i][j] 依赖左、上、左上三个方向的值。若当前字符相同,则从对角线加1;否则取上方或左侧较大值,保证最优子结构。

DP表构建过程

i\j “” a b c d
“” 0 0 0 0 0
a 0 1 1 1 1
c 0 1 1 2 2

状态依赖关系图

graph TD
    A[dp[i-1][j-1]] --> C[dp[i][j]]
    B[dp[i-1][j]] --> C
    D[dp[i][j-1]] --> C

该结构确保每一步都基于已解决的子问题,实现全局最优。

3.2 编辑距离问题的状态转移推导与Go编码

编辑距离(Levenshtein Distance)用于衡量两个字符串之间的相似度,定义为将一个字符串转换为另一个所需的最少单字符编辑操作次数(插入、删除、替换)。

状态定义与转移方程

dp[i][j] 表示将字符串 s1[0..i-1] 转换为 s2[0..j-1] 所需的最小操作数。状态转移如下:

  • 若字符相等:dp[i][j] = dp[i-1][j-1]
  • 否则取三种操作的最小值加1:
    • 插入:dp[i][j-1] + 1
    • 删除:dp[i-1][j] + 1
    • 替换:dp[i-1][j-1] + 1
func minDistance(s1, s2 string) int {
    m, n := len(s1), len(s2)
    dp := make([][]int, m+1)
    for i := range dp {
        dp[i] = make([]int, n+1)
    }

    // 初始化边界:转为空串的操作数
    for i := 1; i <= m; i++ {
        dp[i][0] = i
    }
    for j := 1; j <= n; j++ {
        dp[0][j] = j
    }

    // 填表
    for i := 1; i <= m; i++ {
        for j := 1; j <= n; j++ {
            if s1[i-1] == s2[j-1] {
                dp[i][j] = dp[i-1][j-1]
            } else {
                dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
            }
        }
    }
    return dp[m][n]
}

func min(a, b, c int) int {
    if a < b {
        a, b = b, a
    }
    if b < c {
        return b
    }
    return c
}

逻辑分析:该实现采用二维动态规划,dp[i][j] 依赖左、上、左上三个方向的状态。初始化阶段处理空字符串转换,内层循环根据字符匹配情况选择最优转移路径。时间复杂度 O(mn),空间复杂度 O(mn)。

操作类型 对应状态转移
插入 dp[i][j-1] + 1
删除 dp[i-1][j] + 1
替换 dp[i-1][j-1] + 1

优化方向示意

可通过滚动数组将空间优化至 O(min(m,n)),适用于长文本对比场景。

3.3 股票买卖系列问题的统一DP视角

股票买卖问题看似多样,实则可通过动态规划(DP)建立统一模型。核心思想是将状态定义为持有(hold)与不持有(sold)两种情形,并引入交易次数或冷却期等约束进行扩展。

状态定义的通用形式

  • dp[i][k][0]:第 i 天结束时,最多进行 k 次交易且不持有股票的最大利润
  • dp[i][k][1]:第 i 天结束时,最多进行 k 次交易且持有股票的最大利润

经典问题映射

问题类型 k 的限制 是否允许手续费/冷却期
无限次交易 k = +∞
最多两次交易 k = 2
含冷冻期 k = +∞ 是(1天)
# 以“最多k次交易”为例的DP实现
def maxProfit(prices, k):
    n = len(prices)
    if not prices or k == 0:
        return 0
    dp = [[[0]*2 for _ in range(k+1)] for _ in range(n)]
    # 初始化第一天持有状态
    for j in range(1, k+1):
        dp[0][j][1] = -prices[0]

    for i in range(1, n):
        for j in range(1, k+1):
            dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1] + prices[i])  # 卖出
            dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0] - prices[i]) # 买入
    return dp[n-1][k][0]

上述代码中,状态转移清晰体现“买入消耗一次交易额度”,而dp[i-1][j-1][0]确保买入前已完成上一轮卖出。该框架可灵活适配各类变种问题。

第四章:背包模型与高级DP优化

4.1 0-1背包问题的二维与一维数组实现

动态规划基础思路

0-1背包问题是典型的动态规划应用场景。给定物品重量和价值,求在容量限制下能获得的最大价值。状态定义为 dp[i][j]:前i个物品在容量j下的最大价值。

二维数组实现

def knapsack_2d(w, v, C):
    n = len(w)
    dp = [[0] * (C + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(C + 1):
            if w[i-1] > j:
                dp[i][j] = dp[i-1][j]
            else:
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i-1]] + v[i-1])
    return dp[n][C]

逻辑分析:外层遍历物品,内层逆向遍历容量。若当前物品超重,则继承上一行结果;否则取“不放”与“放入”的较大值。时间复杂度 O(nC),空间 O(nC)。

空间优化:一维数组实现

使用滚动数组压缩空间:

def knapsack_1d(w, v, C):
    dp = [0] * (C + 1)
    for i in range(len(w)):
        for j in range(C, w[i] - 1, -1):
            dp[j] = max(dp[j], dp[j - w[i]] + v[i])
    return dp[C]

参数说明j 从大到小遍历,避免同一物品重复放入。dp[j] 表示当前容量下的最优解。空间复杂度降为 O(C)。

对比维度 二维实现 一维实现
空间复杂度 O(nC) O(C)
可读性
适用场景 教学理解 实际应用

4.2 完全背包问题的滚动数组优化技巧

在动态规划求解完全背包问题时,状态转移方程为:dp[i][w] = max(dp[i-1][w], dp[i][w-wt[i-1]] + val[i-1])。观察发现,当前行仅依赖于上一行或本行左侧的状态,因此可将二维 dp 数组压缩为一维。

滚动数组实现方式

使用一维数组 dp[w] 表示容量为 w 时的最大价值,遍历物品时正向更新容量:

def unbounded_knapsack(weights, values, W):
    dp = [0] * (W + 1)
    for i in range(len(weights)):
        for w in range(weights[i], W + 1):  # 正向遍历
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    return dp[W]

逻辑分析:内层循环正向遍历,确保 dp[w - weights[i]] 使用的是已更新(即包含当前物品)的状态,体现“物品可重复选取”的特性。若反向则退化为0-1背包。

空间复杂度对比

方法 时间复杂度 空间复杂度
二维DP O(nW) O(nW)
滚动数组优化 O(nW) O(W)

通过一维数组复用,显著降低内存占用,适用于大规模数据场景。

4.3 多重背包问题的二进制拆分策略

多重背包问题中,每种物品有有限的可用数量。当直接枚举每件物品的所有副本会导致时间复杂度急剧上升时,二进制拆分策略提供了一种高效的优化方法。

核心思想:将数量拆分为二进制组合

通过将数量为 $ c_i $ 的物品拆分为若干组,每组数量为 $ 1, 2, 4, …, 2^k $ 和剩余部分,可将原问题转化为0-1背包问题,且保证状态覆盖完整。

例如,若某物品有13个,则拆分为:1、2、4、6(剩余)四组,每组视为独立物品。

拆分过程示例

def binary_decompose(count, weight, value):
    items = []
    power = 1
    while power <= count:
        items.append((power * weight, power * value))
        count -= power
        power *= 2
    if count > 0:
        items.append((count * weight, count * value))
    return items

上述函数将一个数量为 count 的物品拆分为多个等效组合。每个组合的重量和价值按幂次累加,确保总和不变。拆分后总组数由 $ \log_2(c_i) $ 控制,显著降低输入规模。

原数量 拆分组合 组数
10 1, 2, 4, 3 4
15 1, 2, 4, 8 4
7 1, 2, 4 3

策略优势

该方法在保持正确性的同时,将时间复杂度从 $ O(V \sum c_i) $ 优化至 $ O(V \sum \log c_i) $,适用于大数量场景。

4.4 背包问题变种在面试中的高频变形

多重背包:物品数量有限制

当每类物品有指定数量限制时,转化为多重背包问题。常见优化是将数量拆分为二进制组合,降低时间复杂度。

分组背包:每组仅选其一

每一组内有多个物品,但只能选择一个放入背包。状态转移需逆序遍历容量,确保每组只贡献一次决策。

# dp[j] 表示容量为 j 时的最大价值
dp = [0] * (W + 1)
for group in groups:
    for j in range(W, 0, -1):  # 逆序遍历
        for w, v in group:
            if j >= w:
                dp[j] = max(dp[j], dp[j - w] + v)

代码逻辑:外层遍历物品组,中层逆序枚举背包容量,内层尝试组内每个物品。逆序防止同一组物品重复选取。

变形类型 约束条件 典型应用场景
0-1背包 每物仅用一次 投资项目选择
完全背包 物品无限可重复 硬币找零
多重背包 每物使用次数受限 库存商品最优搭配
分组背包 每组至多选一个 预算内功能模块选型

第五章:从理解到精通——动态规划的思维跃迁

动态规划(Dynamic Programming, DP)作为算法设计中的核心范式之一,其真正的掌握不在于记忆模板,而在于思维方式的根本转变。许多开发者在初学阶段能顺利解决斐波那契、背包问题,但在面对状态转移不明显的实际场景时仍感束手无策。这种困境的本质,是停留在“套公式”的层面,尚未完成向“建模思维”的跃迁。

状态定义的艺术

在真实项目中,状态的设计往往需要结合业务语义进行抽象。例如,在电商平台的优惠券组合推荐系统中,若需计算用户使用多张满减券的最大优惠金额,传统的0-1背包模型不再适用。此时可将状态定义为 dp[i][j] 表示前 i 张券在累计订单金额为 j 时的最大减免值。但更进一步,若券之间存在互斥规则(如不可叠加使用),则状态需扩展为三维:dp[i][j][k],其中 k 表示当前是否已使用特定类型的券。

# 三维DP处理互斥优惠券
def max_discount(coupons, total):
    n = len(coupons)
    dp = [[[0]*2 for _ in range(total+1)] for __ in range(n+1)]

    for i in range(1, n+1):
        for j in range(total+1):
            dp[i][j][0] = dp[i-1][j][0]
            if j >= coupons[i-1].threshold:
                # 使用当前券,标记状态为已用
                dp[i][j][1] = max(dp[i-1][j - coupons[i-1].threshold][0] + coupons[i-1].amount,
                                  dp[i-1][j][1])
    return max(dp[n][total][0], dp[n][total][1])

转移方程的逆向推导

在高并发订单超卖控制场景中,需动态计算库存分配方案。此时可通过逆向思维构建DP:设 f[stock][order_count] 为剩余库存下满足订单的概率最大值。通过从最终状态反推,可避免重复计算无效路径。

订单数量 剩余库存 最优分配概率
5 3 0.82
4 2 0.76
3 1 0.68

空间优化的工程实践

在实时风控系统中,每毫秒需评估数千笔交易的风险累积路径。此时采用滚动数组技术,将二维DP压缩至一维,显著降低内存占用:

# 滚动数组优化
dp = [0] * (max_risk + 1)
for transaction in transactions:
    for r in range(max_risk, transaction.risk - 1, -1):
        dp[r] = max(dp[r], dp[r - transaction.risk] + transaction.value)

复杂状态机的建模

使用 mermaid 流程图描述多阶段决策过程:

stateDiagram-v2
    [*] --> Idle
    Idle --> Evaluating: 开始评估
    Evaluating --> Approved: 风控通过
    Evaluating --> Rejected: 触发规则
    Approved --> Settled: 完成结算
    Rejected --> ManualReview: 进入人工
    ManualReview --> Approved
    ManualReview --> [*]

用代码写诗,用逻辑构建美,追求优雅与简洁的极致平衡。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注