在动态规划(DP)问题中,有一类高频场景格外“友好”——它们基于线性序列展开,状态仅依赖最近1-2步的结果,不仅逻辑清晰,还能将空间复杂度从O(n)优化到O(1)。本文将通过线性打家劫舍、股票交易II、爬楼梯三个典型例题,拆解这类“近步依赖DP”的共性,并提炼通用解题思路,帮你快速掌握这类问题的核心解法。
一、典型例题解析:从具体到抽象
1. 线性打家劫舍:不偷相邻的“二选一”决策
题目背景
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素是:相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
完整代码(滚动变量优化版)
def rob(nums):
# 定义滚动变量:prev记录前2间的最大金额,curr记录前1间的最大金额
prev, curr = 0, 0
for num in nums:
# 状态转移:不偷当前房(沿用curr) vs 偷当前房(prev + 当前金额)
prev, curr = curr, max(curr, prev + num)
return curr
# 测试案例
print(rob([1,2,3,1])) # 输出4(偷1和3)
print(rob([2,7,9,3,1]))# 输出12(偷2、9、1)
核心逻辑拆解
- 状态定义:没有用传统的
dp[i](前i间房的最大金额),而是用两个变量滚动记录关键状态:prev:遍历到当前房屋时,前2间房屋能偷到的最大金额(避免与当前房屋相邻);curr:遍历到当前房屋时,前1间房屋能偷到的最大金额。
- 状态转移:每个房屋只有两种选择,取最大值即可:
- 不偷当前房:最大金额 = 前1间的最大金额(
curr); - 偷当前房:最大金额 = 前2间的最大金额 + 当前房屋金额(
prev + num),因为偷了当前房就不能偷前1间。
- 不偷当前房:最大金额 = 前1间的最大金额(
- 空间优化:无需存储整个DP数组,仅用两个变量,空间复杂度从O(n)降至O(1)。
2. 股票交易II:无限次交易的“持有/不持有”状态
题目背景
给定一个数组prices,其中prices[i]表示一支给定股票第i天的价格。你可以无限次地完成交易(多次买入和卖出一支股票),但你必须在再次购买前出售掉之前的股票。设计一个算法计算你所能获取的最大利润。
完整代码(一维数组优化版)
def maxProfit(prices):
if not prices:
return 0
# 一维数组:dp[0]不持有股票的最大利润,dp[1]持有股票的最大利润
dp = [0, -float('inf')]
for price in prices:
# 先更新不持有状态:前一天不持有 或 前一天持有今天卖出(+当前价格)
dp[0] = max(dp[0], dp[1] + price)
# 再更新持有状态:前一天持有 或 前一天不持有今天买入(-当前价格)
dp[1] = max(dp[1], dp[0] - price)
# 最后必须不持有股票(持有会浪费利润)
return dp[0]
# 测试案例
print(maxProfit([7,1,5,3,6,4])) # 输出7(1买5卖、3买6卖)
print(maxProfit([1,2,3,4,5])) # 输出4(1买5卖,或多次小交易)
核心逻辑拆解
- 状态定义:同样放弃传统
dp[i][0/1](第i天的持有状态),用长度为2的一维数组记录最近状态:dp[0]:遍历到当前天,不持有股票的最大利润;dp[1]:遍历到当前天,持有股票的最大利润(初始设为负无穷,因为第一天不买就不能持有)。
- 状态转移:每天的决策围绕“持有”和“不持有”切换,且仅依赖前一天的状态:
- 不持有股票:要么前一天就不持有(
dp[0]),要么前一天持有、今天卖出(dp[1] + price,卖出赚钱); - 持有股票:要么前一天就持有(
dp[1]),要么前一天不持有、今天买入(dp[0] - price,买入花钱)。
- 不持有股票:要么前一天就不持有(
- 关键顺序:必须先更新
dp[0]再更新dp[1],避免dp[0]的新值覆盖旧值,导致dp[1]计算错误。
3. 爬楼梯:步数依赖的“累加”计算
题目背景
假设你正在爬楼梯,需要n阶才能到达楼顶。每次你可以爬1或2个台阶,请问有多少种不同的方法可以爬到楼顶?(注意:给定n是一个正整数)
完整代码(滚动变量优化版)
def climbStairs(n):
# 边界条件:1阶1种方法,2阶2种方法
if n <= 2:
return n
# 滚动变量:a记录n-2阶的方法数,b记录n-1阶的方法数
a, b = 1, 2
for _ in range(3, n + 1):
# 状态转移:n阶方法数 = n-1阶 + n-2阶(最后一步走1阶或2阶)
a, b = b, a + b
return b
# 测试案例
print(climbStairs(2)) # 输出2(1+1、2)
print(climbStairs(5)) # 输出8(1+1+1+1+1、1+1+1+2、1+1+2+1等)
核心逻辑拆解
- 状态定义:用两个变量记录“前两步”的方法数,因为第
n阶的方法数仅依赖前两阶:a:第n-2阶的方法数;b:第n-1阶的方法数。
- 状态转移:每阶的方法数是前两阶之和(最后一步要么走1阶,要么走2阶),即
b = a + b,同时更新a为原来的b(滚动向前)。 - 边界处理:直接处理
n<=2的情况,避免循环中的初始值问题,逻辑更简洁。
二、核心共性提炼:这类问题的3个关键特征
通过上述三个例题,我们可以发现“线性序列近步依赖DP”的本质共性,这也是它们能被优化到O(1)空间的根本原因:
| 共性特征 | 线性打家劫舍 | 股票交易II | 爬楼梯 |
|---|---|---|---|
| 1. 线性序列结构 | 房屋数组是有序线性结构,按顺序决策 | 价格数组是时间线性序列,按天决策 | 台阶是有序线性结构,按阶计算 |
| 2. 有限决策选择 | 每个房屋“偷”或“不偷”(2选1) | 每天“持有”或“不持有”(2选1) | 每阶“走1步”或“走2步”(2选1) |
| 3. 近步状态依赖 | 依赖前2步(prev、curr)的状态 | 依赖前1步(dp[0]、dp[1])的状态 | 依赖前2步(a、b)的状态 |
三、通用解题思路:4步搞定近步依赖DP
掌握以下4个步骤,遇到这类问题就能快速切入,无需从头推导:
步骤1:识别问题特征,确认是否属于“近步依赖”
先问自己3个问题,满足则大概率是这类问题:
- 问题是否基于有序线性结构(数组、时间序列、台阶等)?
- 每个节点的决策是否只有2种或固定少数几种互斥选择(偷/不偷、持有/不持有、走1/2步)?
- 每个选择的结果是否仅依赖最近1-2步的状态,无需追溯更早的历史?
步骤2:定义“滚动状态”,放弃完整DP数组
无需定义dp[i](前i个节点的状态),直接用2个变量或长度为2的一维数组,记录“最近1-2步”的关键状态。核心原则:
- 依赖前2步 → 用2个独立变量(如打家劫舍的
prev/curr、爬楼梯的a/b); - 依赖前1步且有2种状态 → 用长度为2的一维数组(如股票II的
dp[0]/dp[1])。
步骤3:推导状态转移方程,聚焦“二选一”逻辑
针对每个节点的两种选择,用max(求最大收益/金额)或+(求方法数/累加值)连接历史状态与当前节点值,形成转移方程:
- 求最大值场景(打家劫舍、股票交易):
新状态 = max(选择1结果, 选择2结果); - 求累加值场景(爬楼梯):
新状态 = 选择1结果 + 选择2结果。
步骤4:处理边界条件,初始化滚动变量
根据问题的“初始状态”(序列的前1-2个节点),给滚动变量赋合法初始值,避免遍历开始时状态异常:
- 打家劫舍:初始
prev=0、curr=0(无房屋时金额为0); - 股票II:初始
dp[0]=0(无交易利润为0)、dp[1]=-inf(未买入不能持有); - 爬楼梯:初始
a=1、b=2(对应1阶和2阶的方法数)。
四、拓展思考:如何应对更复杂的近步依赖问题?
如果遇到“3种选择”或“依赖前3步”的问题(如“每次可以走1、2、3阶”的爬楼梯),只需微调思路:
- 选择数增加:若有3种选择(如偷/不偷/观望),可增加状态变量(如
dp[0]/dp[1]/dp[2]); - 依赖步数增加:若依赖前3步,可增加滚动变量数量(如用
a/b/c记录前3步状态)。
核心不变:只保留“关键的最近几步状态”,放弃完整DP数组,始终让空间复杂度保持在O(1)。
总结
线性序列近步依赖DP是DP问题中的“性价比之王”——逻辑简单、代码精简、空间高效。记住“线性结构+有限选择+近步依赖”的核心特征,套用“识别特征→定义滚动状态→推转移方程→处理边界”的四步思路,你就能轻松搞定这类高频题目。
浙公网安备 33010602011771号