动态规划不再难:一步一步教你攻克经典问题 (1)

目录

1. 动态规划算法简介

2. 动态规划的基本思想

3. 动态规划的三大关键

3.1. 重叠子问题

3.2. 最优子结构

3.3. 状态转移方程

4. 动态规划的应用

4.1. 斐波那契数列

4.2. 0/1 背包问题

5. 总结


1. 动态规划算法简介

动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解成更小的子问题来求解的算法设计方法。它适用于求解具有重叠子问题最优子结构性质的问题。动态规划通过记录已经计算过的子问题的解,避免了重复计算,从而大大提高了计算效率。

2. 动态规划的基本思想

这里以经典的爬楼梯问题为例子,来阐述动态规划的基本思想。我们给出一个数字 n(这里假设 n = 10),表示楼梯的总阶数,我们每次可以选择爬 1 层或者 2 层台阶,然后需要计算出有多少种方法可以从楼梯的底部走到顶部 (第10层台阶)。具体步骤如下:

  1. 拆解问题: 首先来定义整个问题(爬到第10层台阶)的子问题,把问题拆解。【爬到第10阶的走法】 = 【爬到第9阶的走法 + 1步】 + 【爬到第8阶的走法 + 2步】 。同理,【爬到第9阶的走法】 = 【爬到第8阶的走法 + 1步】 + 【爬到第7阶的走法 + 2步】

  2. 定义状态:然后我们定义一个状态来表示子问题的解。例如,dp[5] = 8 表示爬到第5阶的走法有8种。同理:
    dp [ 1 ] = 1, 爬一次1层台阶
    dp [ 2 ] = 2, 爬两次1层台阶 或者 爬一次2层台阶
    dp [ 3 ] = 3, 爬一次1层台阶和一次2层台阶,爬一次2层台阶和一次1层台阶,爬三次1层台阶
    dp [ 4 ] = 5, ...

  3. 递推关系:通过已知的子问题的解,推导出更大问题的解。即,通过找到当前问题与其子问题之间的关系,利用已经解决的子问题来求解更大的问题。针对爬楼梯问题,我们可知递推关系为 dp [ n ] = dp [ n - 1 ] + dp [ n - 2 ]。

  4. 边界条件: 明确递推关系的起始点,通常是一些最简单的子问题,它们的解是直接已知的。比如爬楼梯问题的起点是 dp [1] = 1 和 dp [ 2 ] = 2。

  5. 返回结果:根据定义的目标状态(从 dp[1] 和 dp[2] 开始),递推计算并返回最终的解,通常是 dp[n]dp[target]。

3. 动态规划的三大关键

3.1. 重叠子问题

问题可以拆解成多个重复的小问题,比如”爬到第10阶“可以拆解为”爬到第9阶 + 1步“或者“爬到第8阶 + 2步”。重叠子问题是使用动态规划(DP)算法的基础。动态规划通过“记忆化”避免重复计算 (比如“爬到第5阶”的方法被记录在 dp [ 5 ] ,避免重复多次计算)。这里需要和"分治"算法进行对比一下,分治问题中的子问题是互不重叠,直接用递归解决。

3.2. 最优子结构

大问题的最优解依赖小问题的最优解(比如“第10阶的走法数”由第8、9阶决定)。通过小问题的解组合出大问题的解。所以子问题有最优解,是使用动态规划的另一个关键。

3.3. 状态转移方程

明确如何从小问题推导出大问题(递推公式)。比如爬楼梯问题:dp[i] = dp[i - 1] + dp[i - 2]。状态转移方程,或者说递推公式,其实是动态规划算法的难点,这里给出的例子比较简单,但有时候状态转移方程是很难推导的。找到了状态转移方程,算法已经基本完成了。没有捷径,只能多多练习,培养对算法的敏锐度。

4. 动态规划的应用

这里我们先讲解两个最长见的动态规划的问题,斐波那契数列和 0/1 背包问题。其它的常见问题,我们留在下一篇博文讲解。

4.1. 斐波那契数列

斐波那契数列(Fibonacci Sequence)是一个在数学和计算机科学中非常经典的数列,它的定义非常简单:从第3项开始,每一项都是前两项的和。具体公式为:

F(0)=0,F(1)=1,F(n)=F(n−1)+F(n−2)(for n≥2)

斐波那契数列的前几项 0,1,1,2,3,5,8,13,21,34,55,89,144, ... 。计算斐波那契数列的动态规划代码如下:

def fibonacci_dp(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]    # 递推公式
    return dp[n]

# 示例
print(fibonacci_dp(10))  

# 输出 
55

我们可以看到,斐波那契数列是以递推形式给出的,所以递推公式可以直接用于动态规划的程序实现,只要注意 dp[ 0 ] = 0 和 dp[ 1 ] = 1 这两个起始条件就好。程序时间复杂度为O(n),空间复杂度为O(n)。空间复杂度可以通过优化进一步减少,我们之后再讨论。

4.2. 0/1 背包问题

我再看一个稍微复杂点的情况,也就是 0/1 背包问题。给定一组物品和一个背包,每个物品都有一个重量和价值,背包有一个最大承载重量。要求选择若干个物品,每个物品只能选择一次,使得它们的总价值最大,且总重量不超过背包容量。

  • 状态定义dp[i][w] 表示前 i 个物品,背包容量为 w 时能够获得的最大价值。

  • 状态转移方程:dp [ i ][ w ] = max(dp [ i−1 ][ w ], dp [ i−1 ][ w − weight [ i ] ] + value [ i ])

  • 初始化dp[0][w] = 0(没有物品时价值为 0)。

解释

  1. 不选择当前物品(即 dp[i-1][w]):如果我们不选择当前的第 i 个物品,背包的容量 w 就保持不变,那么最优解就应该是之前考虑的前 i-1 个物品在背包容量为 w 时的最优解,即 dp[i-1][w]

  2. 选择当前物品(即 dp[i-1][w - weight[i]] + value[i]):如果我们选择当前的第 i 个物品,那么背包的容量 w 将减少 weight[i]。此时,问题就变成了前 i-1 个物品,背包容量为 w - weight[i] 时的最优解,再加上当前物品的价值 value[i]

  3. 进行比较 (即 max(dp [ i-1 ][ w ], dp [ i-1 ][ w − weight [ i ] ] + value [ i ])):如果我们选择当前的第 i 个物品,我们需要比较,看选择的当前物品是否能够为我们获得更大的 value.

例子

假设我们有 3 个物品,背包容量为 4:

物品重量价值
134
223
312

计算 0/1 背包问题的动态规划代码如下:

def knapsack(weights, values, capacity):
    # 获取物品的数量
    n = len(weights)
    
    # 创建 DP 表,初始化为 0
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    
    # 填充 DP 表
    # Loop through the item 【0, 1, 2】
    for i in range(1, n + 1): 
        # Loop through the capacity 【0, 1, 2, 3, 4】       
        for c in range(1, capacity + 1):
            # 如果当前背包容量足够装下第 i 个物品
            if weights[i - 1] <= c:
                # 选择是否放入当前物品,取放与不放的最大价值
                dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - weights[i - 1]] + values[i - 1])
            else:
                # 如果当前背包容量不足以放入第 i 个物品
                dp[i][c] = dp[i - 1][c]

    # dp[n][capacity] 存储着最大价值
    return dp[n][capacity]

# 示例数据
weights = [3, 2, 1]  # 物品的重量
values = [4, 3, 2]   # 物品的价值
capacity = 4         # 背包的容量

# 调用 knapsack 函数
max_value = knapsack(weights, values, capacity)
print(f"最大价值是: {max_value}")

运行结果如下:

最大价值是: 6

这里给出具体的填表/递推过程供参考:

  1. dp[0][w] = 0:没有物品时,最大价值为 0。

  2. 对于第 1 个物品,考虑每个背包容量:

    • dp[1][0] = 0:背包容量为 0 时,不能放物品。

    • dp[1][1] = 0:背包容量为 1 时,无法放第一个物品。

    • dp[1][2] = 0:背包容量为 2 时,无法放第一个物品。

    • dp[1][3] = 4:背包容量为 3 时,可以放第一个物品,价值为 4。

    • dp[1][4] = 4:背包容量为 4 时,可以放第一个物品,价值为 4。

  3. 对于第 2 个物品,考虑每个背包容量:

    • dp[2][0] = 0:背包容量为 0 时,最大价值为 0。

    • dp[2][1] = 0:背包容量为 1 时,无法放第 二 个物品。

    • dp[2][2] = 3:背包容量为 2 时,可以放第 二 个物品,价值为 3。

    • dp[2][3] = 4:背包容量为 3 时,选择第一个物品,价值为 4。

    • dp[2][4] = 6:背包容量为 4 时,选择第一个物品,价值为 4。

  4. 对于第 3 个物品,考虑每个背包容量:

    • dp[3][0] = 0:背包容量为 0 时,最大价值为 0。

    • dp[3][1] = 2:背包容量为 1 时,可以放第 三 个物品,价值为 2。

    • dp[3][2] = 3:背包容量为 2 时,选择第 二 个物品,价值为 3。

    • dp[3][3] = 4:背包容量为 3 时,选择第 一 个物品,价值为 4。

    • dp[3][4] = 6:背包容量为 4 时,选择第 一 和第 三 个物品,价值为 6。

最终结果是 dp[3][4] = 6,表示最大价值为 6,背包容量为 4 时,可以选择第 一 个和第 三 个物品。

5. 总结

本文给出了动态规划算法的基本思想与过程,并给出了两个非常常用的例子。我们可以看到,动态规划的关键是找出子问题并列出递推关系,然后根据起始条件和递推关系来进行计算得出最终的结果。但是并不是每个问题的递推关系都很好找出,还是需要多加练习。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

方博士AI机器人

您的鼓励将是我创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值