CC++ & Algorithm

Python一维动态规划

困难6
语言版本:C++Python
概述:一维动态规划用一条直线上的状态来解决问题,就像在一条路上记录每个位置的最优结果,然后传到下一个位置。

一维动态规划:像记流水账一样解决最优化问题

你有没有想过,如果每天只能做一次选择,而且后面的决定会受到前面选择的影响,怎样才能做出最好的安排?一维动态规划就是专门解决这类问题的工具。它用一个一维数组(像一条线)来记录每一步的最优结果,然后一步步往后推,最终得到全局最优答案。

什么是一维动态规划?

简单来说,一维动态规划是“把大问题拆成小问题,每个小问题只依赖前面的一两个小问题”。就像你在操场上跑步,每一步踩到的位置(状态)只和上一步的位置有关,你只需要记住每一步踩哪里最好,然后传到下一步。

核心要素:

  • 一维数组 dpdp[i] 表示“当问题规模为 i 时”或者“以第 i 个位置为结尾时”的最优解。
  • 状态转移方程:从 dp[i-1]dp[i-2] 等已知状态,通过一个公式算出 dp[i]
  • 初始条件dp[0]dp[1] 是多少需要先定好。

生活中的例子:买糖果(再详细一点)

假设你有一张零花钱计划表,每天可以买糖果,但妈妈定了一个规矩:

  • 每天最多买 3 颗糖;
  • 相邻两天不能都买(如果今天买了,明天就不能买);
  • 你想让一个月(30天)里买到的糖果总数最大。

这个问题就是一个典型的一维动态规划:

  • 定义 dp[i] 表示前 i 天(从第1天到第i天)能得到的最大糖果数。
  • 那么对于第 i 天,有两种选择:
    1. 今天不买:那么 dp[i] = dp[i-1](继承前一天的成果)。
    2. 今天买(并且前一天没买):那今天买的数量可以是 1、2 或 3,但要保证前一天没买,所以 dp[i] = dp[i-2] + 今天买的糖数
  • 取两种选择中更大的值作为 dp[i]
  • 最后 dp[30] 就是答案。

这种“从前面一天或两天推出当前”的思路,就是一维动态规划的精髓。

特征总结(记住三点)

  1. 状态是一维的:用数组下标 i 表示不同的阶段(位置、天数、长度等)。
  2. 转移是线性的:当前状态只依赖前面一个或两个状态,不会跳来跳去。
  3. 常见题型:最大子段和、最长递增子序列、打家劫舍、爬楼梯、零钱兑换等。

经典问题详解:最大子段和(带详细推理)

题目:给你一个整数列表,例如 [-2, 1, -3, 4, -1, 2, 1, -5, 4],找出连续的一段(子数组),使其和最大。
比如 [4, -1, 2, 1] 的和是 6,就是答案。

为什么用一维动态规划?

因为“以某个位置结尾的最大子段和”只和“前一个位置结尾的最大子段和”有关。如果你已经知道了“以第 i-1 个数结尾的最大子段和”,那么要算“以第 i 个数结尾的最大子段和”时,只需要决定:是把第 i 个数接上去,还是单独重新开始。

状态定义

  • dp[i] 表示以列表第 i 个元素(下标从0开始)为结尾的连续子数组的最大和。
  • 注意:dp[i] 不一定是全局最大,它只是“必须包含第 i 个元素”的最大值。

状态转移方程

dp[i] = max( nums[i], dp[i-1] + nums[i] )

解释:

  • 如果 dp[i-1] 是负数,加上它反而会变小,不如重新从 nums[i] 开始。
  • 如果 dp[i-1] 是正数,加上它能让总和更大。

边界条件

dp[0] = nums[0],因为只有一个元素时,最大子段和就是它自己。

代码实现

def max_subarray_sum(nums):
    n = len(nums)                     # 列表长度
    if n == 0:                        # 空列表直接返回0
        return 0
    # dp[i] 表示以 nums[i] 结尾的最大子段和
    dp = [0] * n                      # 初始化dp数组
    dp[0] = nums[0]                   # 第一个元素作为起点
    max_sum = dp[0]                   # 记录全局最大值
    for i in range(1, n):             # 从第二个元素开始遍历
        # 关键转移:要么接上前面的子段,要么重新开始
        dp[i] = max(nums[i], dp[i-1] + nums[i])
        # 更新全局最大值
        if dp[i] > max_sum:
            max_sum = dp[i]
    return max_sum

# 测试
arr = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
print(max_subarray_sum(arr))  # 输出6,对应子段 [4, -1, 2, 1]

手工推演一遍

arr = [-2, 1, -3, 4, -1, 2, 1, -5, 4]

inums[i]dp[i] 计算过程dp[i]
0-2初始值-2
11max(1, -2+1=-1) = 11
2-3max(-3, 1+(-3)=-2) = -2? 注意:1是dp[1],-2 > -3,所以取-2-2
34max(4, -2+4=2) = 44
4-1max(-1, 4+(-1)=3) = 33
52max(2, 3+2=5) = 55
61max(1, 5+1=6) = 66
7-5max(-5, 6+(-5)=1) = 11
84max(4, 1+4=5) = 55

全局最大值在 i=6 时出现,为 6。对应子段从 i=3 到 i=6:[4, -1, 2, 1]

另一个经典问题:打家劫舍(贴近生活)

题目:你是一个小偷,计划偷一条街上的房子。每间房子有不同金额的现金,但相邻的两间房子不能同时偷(会触发警报)。求你能偷到的最大金额。

比如金额列表 [2, 7, 9, 3, 1],最优是偷第1、3、5家:2+9+1=12。

思考

  • 定义 dp[i] 表示前 i 间房子(0到i-1)能偷到的最大金额。
  • 对于第 i 间房子(下标i-1),有两种选择:
    1. 不偷:dp[i] = dp[i-1]
    2. 偷:那么前一家(i-1)不能偷,所以 dp[i] = dp[i-2] + 当前房子的钱
  • 取较大值。

代码

def rob(nums):
    n = len(nums)                   # 房子数量
    if n == 0:
        return 0
    if n == 1:
        return nums[0]              # 只有一间直接偷
    dp = [0] * n                    # dp[i] 表示偷前 i+1 间房的最大值
    dp[0] = nums[0]                 # 只有一间房,偷它
    dp[1] = max(nums[0], nums[1])   # 两间房,偷金额大的那间
    for i in range(2, n):           # 从第三间开始
        # 偷当前这间 vs 不偷(继承前一间的最优)
        dp[i] = max(dp[i-1], dp[i-2] + nums[i])
    return dp[n-1]                  # 最后一项就是答案

# 测试
houses = [2, 7, 9, 3, 1]
print(rob(houses))  # 输出12

新手常犯的错误

  1. 忘记初始化 dp[0] 和 dp[1]
    很多一维DP问题需要明确前两个状态,比如打家劫舍中 dp[0]dp[1] 都是手动设置的。如果只设了 dp[0],循环从 1 开始会出错。

  2. 混淆 dp[i] 的含义
    最大子段和的 dp[i] 是“以 i 结尾的”,不是“前 i 个元素的最大值”。如果混淆,会导致转移公式写错。

  3. 没有考虑空列表或单个元素的情况
    输入 [][5] 时,程序应该能正确处理,否则会索引越界。

  4. 忘记更新全局最优值
    在最大子段和中,dp[i] 只是局部最优,需要额外变量记录全局最大值,而不是最后取 dp[n-1]

完整可运行示例(含测试)

下面是一个完整的脚本,包含两个经典问题的实现和测试。

# 最大子段和
def max_subarray(nums):
    n = len(nums)                      # 数组长度
    if n == 0:
        return 0
    dp = [0] * n                       # dp[i] 表示以 nums[i] 结尾的最大子段和
    dp[0] = nums[0]                    # 第一个元素
    best = dp[0]                       # 全局最大子段和
    for i in range(1, n):
        dp[i] = max(nums[i], dp[i-1] + nums[i])
        best = max(best, dp[i])        # 更新全局最优
    return best

# 打家劫舍
def rob(nums):
    n = len(nums)
    if n == 0:
        return 0
    if n == 1:
        return nums[0]
    dp = [0] * n
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])
    for i in range(2, n):
        dp[i] = max(dp[i-1], dp[i-2] + nums[i])
    return dp[n-1]

# 测试
if __name__ == "__main__":
    # 最大子段和测试
    test_arr = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
    print("最大子段和:", max_subarray(test_arr))  # 6

    # 打家劫舍测试
    test_houses = [2, 7, 9, 3, 1]
    print("打家劫舍最大金额:", rob(test_houses))  # 12

相关知识点延伸

学会一维动态规划后,你可以继续探索:

  • 二维动态规划:类似一维,但用二维数组记录状态,比如走迷宫、背包问题。
  • 最长递增子序列:一维DP的经典变种,需要两层循环。
  • 状态压缩:如果只依赖前两个状态,可以用两个变量代替数组,节省空间(比如用 prevcurr)。
  • 贪心算法:部分DP问题可以用贪心更快解决(比如最大子段和也有贪心解法),但DP更通用。

一维动态规划就像“搭积木”,一块一块垒起来,每一步都踩在前一块的肩膀上。只要抓住“状态定义”和“转移方程”,你会发现很多看似复杂的问题其实很简单。加油!

例题精讲

1单选题

用一维动态规划求解爬楼梯问题(每次可以爬1阶或2阶,到达第n阶有多少种方法)。假设dp[i]表示到达第i阶的方法数,则正确的递推公式是?

Adp[i] = dp[i-1] + dp[i-2]
Bdp[i] = dp[i-1] * dp[i-2]
Cdp[i] = dp[i-1] + 2*dp[i-2]
Ddp[i] = dp[i-1] + dp[i-2] + 1
2单选题

在求解“最小花费爬楼梯”问题时,数组cost长度为n,dp[i]表示爬到第i阶(从0开始)的最小花费。通常初始化dp[0]和dp[1]为0(假设可以从起点免费到第0或第1阶),则正确的状态转移方程是?

Adp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
Bdp[i] = min(dp[i-1], dp[i-2]) + cost[i]
Cdp[i] = dp[i-1] + cost[i-1]
Ddp[i] = min(dp[i-1], dp[i-2] + cost[i-2])
3判断题

一维动态规划的状态转移方向一定是从左到右(即从小索引到大索引)的。

4填空题
下面是用一维动态规划计算斐波那契数列第n项(假设n>=1)的代码,请填写缺失部分。
def fib(n):
    if n <= 2:
        return 1
    dp = [0] * (n+1)
    dp[1] = dp[2] = 1
    for i in range(3, n+1):
        dp[i] = ___
    return dp[n]
5填空题
下列代码使用一维动态规划(Kadane算法)求数组nums的最大子数组和,请填写缺失部分。
def maxSubArray(nums):
    n = len(nums)
    dp = [0] * n
    dp[0] = nums[0]
    max_sum = dp[0]
    for i in range(1, n):
        dp[i] = max(nums[i], ___)  
        if dp[i] > max_sum:
            max_sum = dp[i]
    return max_sum