CC++ & Algorithm

Python动态规划初步

困难5
语言版本:C++Python
概述:动态规划就像一个聪明的记账员,把重复算过的结果记下来,避免重复劳动,从而快速解决大问题。

用“记账本”思路学动态规划——从爬楼梯开始

你有没有遇到过这样的问题:要完成一件事,有很多种不同的做法,而且每一步的选择会影响到后续的结果?比如,你想用1元和2元的零花钱凑出10元钱,有多少种凑法?或者,你每天可以背5个单词或10个单词,一周能背完多少个?这些问题如果一步步硬算,会非常麻烦,而且很多重复。动态规划(Dynamic Programming,简称DP)就像一本聪明的记账本,它会把已经算过的结果记下来,下次需要时直接拿来用,避免重复劳动,从而快速高效地解决问题。

今天,我们就用最常见的“爬楼梯”问题来学习动态规划的基础。学完之后,你会发现自己也能像小侦探一样,轻松解决很多看似复杂的问题。


什么是动态规划?——从爬楼梯说起

想象一下,你要爬一个10级的楼梯,每次可以走1级或2级。问:有多少种不同的爬法?

如果你从第1级开始数,比如:1→2→3→... 或者 1→3→... 你会发现,相同的小段路径会重复出现很多次。比如,爬到第8级的方法,用递归硬算的话,可能被重复计算几十遍。而动态规划的做法是:先算出爬到第1级、第2级的走法,然后一步步算出第3级、第4级……直到第10级。每算出一个结果,就记在本子上,后面要用直接翻本子。

生活类比:假设老师让你统计全班同学一周内一共拿到了多少颗星星。如果每个同学单独报数,然后你一个一个累加,容易加错。动态规划的思路是:先算第一个同学的星星数,记下;然后第二个同学的加上第一个的,记下;第三个加上第二个的……这样你只需要记住一个累加数,就能快速得到最后的总数。动态规划就是这种“边算边记,用已知推未知”的方法


核心思想:拆解、记忆、递推

动态规划有三个关键步骤,我们用一个表格来理解:

  1. 把大问题拆成小问题
    问题:爬到第10级楼梯有多少种方法?
    拆解:最后一步要么是从第9级走1步,要么是从第8级走2步。
    所以:爬到第10级的方法数 = 爬到第9级的方法数 + 爬到第8级的方法数。
    同样,爬到第9级的方法数 = 爬到第8级的方法数 + 爬到第7级的方法数……不断拆下去。

  2. 记住小问题的答案
    用列表(数组)把每个台阶的方法数存下来,比如dp[1]表示爬到第1级的方法数,dp[2]表示爬到第2级的方法数。这样,当需要用到dp[8]时,直接从列表里取,不用再算一遍。

  3. 从小到大计算(递推)
    先算出最基础的台阶:

    • 第0级(还没爬):只有一种方法——不动,所以dp[0]=1
    • 第1级:只能走1步(1种),所以dp[1]=1
    • 第2级:可以1+1或直接2(2种),所以dp[2]=2
      然后从第3级开始,用公式dp[i] = dp[i-1] + dp[i-2]依次算出所有直到第n级。

为什么叫“动态规划”?
“规划”是指我们像制定计划一样,一步步推导出答案;“动态”是因为每一步的决策(走1级还是2级)会影响后续结果,但我们的记账本不会变,所以叫“动态”。(记住这个名字就好,不用深究)


代码示例:爬楼梯问题(详细版)

下面这个Python程序,输入楼梯级数n,输出爬法总数。我们给每行代码都加上中文注释,方便理解。

def climb_stairs(n):
    # 如果楼梯级数少于等于1,直接返回1(0级1种,1级1种)
    if n <= 1:
        return 1
    
    # 创建列表dp,长度为n+1,用来存储爬到每级台阶的方法数
    # dp[i]表示爬到第i级台阶的方法数
    dp = [0] * (n + 1)
    
    # 初始化基础台阶
    dp[0] = 1  # 第0级(起点):一种方法(不动)
    dp[1] = 1  # 第1级:一种方法(直接跨1步)
    
    # 从第2级开始,逐个计算到第n级
    for i in range(2, n + 1):
        # 公式:爬到第i级的方法数 = 爬到第i-1级的方法数 + 爬到第i-2级的方法数
        dp[i] = dp[i-1] + dp[i-2]
    
    # 返回第n级的方法数
    return dp[n]

# 测试:爬10级楼梯有多少种方法?
print(climb_stairs(10))  # 输出结果:89

运行结果:当输入10时,输出89。这意味着爬10级楼梯,每次走1或2级,共有89种不同的爬法。

代码图解(简单理解):

  • dp = [0, 0, 0, ...] 就像一个记账格子,从0号格子到n号格子。
  • 我们先填好dp[0]=1dp[1]=1
  • 然后for循环依次填dp[2]dp[3]……直到dp[n]
  • 每次填的时候,只需要看前面两个格子的值,把它们加起来即可(因为最后一步只可能来自前1级或前2级)。

常见错误(新手最容易犯的坑)

  1. 索引越界
    比如忘记给dp分配足够的长度(n+1),或者循环时range(2, n+1)写成了range(2, n),导致最后一个dp[n]没有被计算。
    ✅ 记住:列表长度一定要是n+1,循环到n+1(不包括n+1,所以循环到n)。

  2. 初始条件搞错

    • 有人认为dp[0]应该为0,但为了公式统一(比如dp[2]=dp[1]+dp[0]),dp[0]应该为1,表示“不动”也是一种方法。如果你把dp[0]设为0,那么dp[2]就会变成1+0=1,但实际有2种爬法(1+1和2),就错了。
      ✅ 记住:0级台阶有1种方法(站着不动),1级有1种方法(直接走1步)。
  3. 忘记处理边界条件
    n=0n=1时,程序直接返回1。如果不加if n <= 1: return 1,那么执行dp[0]=1, dp[1]=1后,如果n=0,循环for i in range(2, 1)根本不会执行,但最后返回dp[0]也是正确的。但为了代码清晰和安全,显式处理边界更好。

  4. 混淆“第几级”和“列表索引”

    • 如果楼梯有n级,我们需要的列表长度是n+1(因为要包含0级)。新手可能把dp大小设为n,然后dp[i]对应第i级,但第0级就放不下了。
      ✅ 统一:索引i代表第i级,所以列表大小至少n+1

完整可运行示例(带输入提示)

下面是一个可以直接复制运行的完整程序,用户输入楼梯级数,程序输出爬法总数。

def climb_stairs(n):
    # 如果楼梯级数少于等于1,直接返回1
    if n <= 1:
        return 1
    
    # 创建dp列表,长度为n+1,初始化为0
    dp = [0] * (n + 1)          # dp[i]表示爬到第i级的方法数
    dp[0] = 1                    # 第0级(起点):1种方法
    dp[1] = 1                    # 第1级:1种方法(跨1步)
    
    # 从第2级开始递推
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]   # 最后一步来自i-1或i-2
    
    return dp[n]

# 主程序:让用户输入楼梯级数
if __name__ == "__main__":
    try:
        n = int(input("请输入楼梯级数(例如10):"))
        if n < 0:
            print("楼梯级数不能为负数!")
        else:
            result = climb_stairs(n)
            print(f"爬 {n} 级楼梯,每次爬1级或2级,共有 {result} 种方法。")
    except ValueError:
        print("请输入一个整数。")

运行示例

请输入楼梯级数(例如10):10
爬 10 级楼梯,每次爬1级或2级,共有 89 种方法。

生活中的其他动态规划问题

动态规划不仅能爬楼梯,还能解决很多和生活相关的问题。这里举两个例子:

1. 零花钱凑数问题
你有1元和2元的硬币,想凑出10元钱,问有多少种凑法?这和爬楼梯一模一样!每次你选择加1元或2元,凑到10元的方法数,就是从0元开始,每次加1或2,最终到达10元的路径数。所以代码几乎不用改,只需要把楼梯级数改成总金额即可。

2. 考试得分问题
一次考试有10道判断题,每道题答对得1分,答错得0分。放学后你可以选择复习哪些题目。如果你每天只能复习1道题或2道题,问期末前一共有多少种复习进度安排?其实也是爬楼梯的变种:把“第i天”看作“第i级台阶”,每天选择复习1题或2题,问复习完10道题有多少种顺序。结果同样是89种。

3. 排队买冰淇淋
你和朋友排队买冰淇淋,队伍长度是n。每次你可以一个人先买,也可以两个人一起买(假设两个人一起买速度一样)。问有多少种排队顺序?这本质上就是爬楼梯。


相关指引

  • 递归:动态规划和递归都能解决这类问题,但递归会重复计算,效率低。你可以试试用递归写爬楼梯(斐波那契数列),然后对比一下运行时间。
  • 斐波那契数列:爬楼梯的方法数正好是斐波那契数列(1,1,2,3,5,8,13……),只不过从dp[0]=1, dp[1]=1开始。
  • 背包问题:这是一种更复杂的动态规划,比如你有不同重量的物品,要放入容量有限的背包,怎么装价值最大。这是动态规划的进阶题型。
  • 二维动态规划:比如计算从一个网格的左上角走到右下角的路径数(只能向右或向下),就需要二维数组来记录。

学完动态规划基础后,你可以试试这些题

  • 72. 编辑距离(LeetCode)
    1. 不同路径
    1. 打家劫舍

记住:动态规划的关键就是拆、记、推。先找到小问题之间的联系,然后用数组或列表把答案记下来,从最小的开始逐步推到最终答案。下次遇到问题,先问问自己:“能不能用记账本的方法解决?” 你一定会越来越熟练的!

例题精讲

1单选题

动态规划的核心思想是什么?

A将问题分解为互相独立的子问题,分别求解
B通过记忆已解决的子问题的结果,避免重复计算
C使用递归层层调用,无需额外存储
D将所有可能解全部枚举,找出最优
2判断题

动态规划只能用于求最优化问题,不能用于计数类问题。

3填空题
下面是用动态规划(自底向上)实现斐波那契数列的函数,请补全代码。

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-2]
    return dp[n]
4单选题

关于动态规划的状态转移方程,下列说法正确的是:

A状态转移方程必须是一元一次方程
B状态转移方程描述了当前状态与之前某些状态之间的关系
C状态转移方程只能由当前状态直接推导出下一个状态
D状态转移方程与递归函数没有区别
5填空题
爬楼梯问题:每次可以爬1级或2级台阶,求爬到第n级台阶有多少种不同的方法。请补全以下动态规划代码。

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