
1. 这不是“背公式”而是重建你对状态的直觉“线性DP”这四个字在刷题群里常被当成通关暗号——有人一看到就头皮发紧有人张口就是“状态定义转移方程初始化填表顺序”但真让你手写一个从零开始的“最长上升子序列”十个人里有七个会卡在“为什么f[i]要定义成‘以第i个元素结尾的最长长度’而不是‘前i个元素里的最长长度’”。这不是记不住模板是没真正理解“线性”二字背后那个不可见的约束状态只能依赖于它前面有限个、且位置连续的前驱状态不能跳、不能绕、不能回头。我带过三十多个算法集训营学员发现一个铁律所有把线性DP学瘸的人都曾试图用“全局最优”去定义状态。比如解“最大子数组和”有人定义f[i]为“前i个数的最大子数组和”结果转移时发现f[i]可能来自f[i-1]也可能来自a[i]本身但f[i-1]里存的是“前i-1个数里的最优解”这个解未必包含第i-1个数——而你要接上第i个数必须知道上一段是否“连着”你。这就是为什么标准解法必须定义f[i]为“以第i个数结尾的最大子数组和”它强制把“连续性”编码进状态本身让转移变成确定性的“要么续上要么重开”。关键词“动态规划”“线性DP”不是标签是两道安检门。“动态规划”筛掉贪心能解的问题“线性DP”再筛掉状态依赖非邻近位置的问题比如区间DP依赖左右端点树形DP依赖子树根节点。你看到“车辆动态规划问题”核心其实是“车辆在时间轴上的状态演化”——位置、速度、电量这些变量随时间线性推进每个时刻的状态只由前一时刻决定“最少硬币”看似离散但硬币面额是固定集合金额n的状态只依赖n-coin[i]这些确定的前驱位置本质上仍是沿金额轴的一维推进。所以别被“车辆”“硬币”这些业务词晃花眼先问自己这个问题的“线”在哪里是时间是空间坐标是金额数值还是字符串下标找到这条线你就拿到了打开线性DP的钥匙。这篇内容不讲“01背包动态规划python”的完整代码也不列十种变体题型。我要带你重新触摸状态定义的温度为什么f[i][j]有时是二维有时能压成一维为什么“滚动数组”不是炫技而是内存必然为什么有些题必须正向遍历有些必须逆向这些选择背后全是状态依赖关系在说话。如果你正在准备校招笔试或者刚啃完《算法导论》第15章却还是写不出状态转移那接下来的内容就是为你拆掉那些被过度简化的“套路”外壳露出里面真实的力学结构。2. 线性DP的本质状态链与依赖图的降维打击2.1 为什么叫“线性”——从状态依赖图说起所有DP问题都可以画出一张“状态依赖图”每个节点是一个状态比如f[i]或f[i][j]有向边表示“计算该状态需要先算出哪个状态”。对于线性DP这张图有个致命特征所有边都指向当前节点的左侧或上侧且跨度不超过某个固定值。比如最长上升子序列中f[i]依赖所有f[j]ji且a[j]a[i]虽然j可以跳着选但j必须小于i——所有依赖都在i的左边形成一条单向链而01背包中f[i][w]依赖f[i-1][w]和f[i-1][w-weight[i]]依赖全部来自上一行且列坐标w和w-weight[i]都在当前w的左侧或同一列。提示判断一个问题是否适合线性DP第一反应不是想“能不能用DP”而是画出它的最小粒度状态单元再快速草拟几个小规模实例比如n3,4手动推导状态间依赖关系。如果发现某个状态需要引用“右边”或“下面”的状态才能算出那它大概率不属于线性DP范畴——可能是区间DP依赖左右端点、树形DP依赖子节点或状压DP依赖位掩码组合。我们拿“最少硬币找零”给定硬币面额coins[1,3,4]求凑出金额amount6的最少硬币数来实操验证。定义f[i]为凑出金额i所需的最少硬币数。当i6时f[6] min( f[6-1]1, f[6-3]1, f[6-4]1 ) min(f[5], f[3], f[2]) 1。这三个依赖项f[5]、f[3]、f[2]全部小于6且都在金额轴的左侧。再看i1f[1] f[1-1]1 f[0]1而f[0]是边界状态凑0元需0枚硬币。整个依赖链条像一条从左向右流淌的溪流没有回旋没有分叉到右侧这就是“线性”的物理本质——状态空间被压缩成一维直线计算方向天然确定。2.2 状态定义的三原则可转移、可记录、可终止很多初学者卡在第一步怎么定义f[i]网上教程常说“以i结尾”“前i个”但这只是经验总结背后有硬性逻辑。我总结出三条铁律缺一不可可转移性f[i]的值必须能通过已知的f[j]ji和当前输入元素a[i]直接计算出来。比如最长上升子序列中若定义f[i]为“前i个数的LIS长度”则f[i]无法仅由f[i-1]推出——因为新加入的a[i]可能延长LIS也可能不延长而f[i-1]里没存“以第i-1个数结尾的长度”你无从判断是否能接上。但定义f[i]为“以a[i]结尾的LIS长度”则f[i] max{ f[j]1 | ji and a[j]a[i] }完全可算。可记录性状态必须携带足够信息让后续状态能“读懂”它的意图。比如“最大子数组和”中f[i]若定义为“前i个数的最大和”它丢失了“是否包含a[i]”这个关键信息而定义为“以a[i]结尾的最大和”就把“连续性”这个业务约束编码进了状态名后续f[i1]自然知道要么接上f[i]要么从a[i1]重开。可终止性必须存在明确的终止条件且最终答案能从状态数组中直接提取或简单合并。f[n]本身就要么是答案如最少硬币要么是答案的一部分如LIS需取max(f[1..n])。如果定义f[i]为“处理完前i个后剩余的最小余额”那最终答案还得额外判断违背了DP“答案内生”的设计哲学。我见过最典型的反例是“股票买卖含冷冻期”。有人定义f[i]为“第i天结束后的最大利润”结果发现第i天的状态依赖第i-2天因为冷冻期但f[i-1]里没存“第i-1天是否卖出”的标记导致无法区分状态。正确解法必须定义三维状态f[i][0]持股票f[i][1]不持股票且在冷冻期f[i][2]不持股票且不在冷冻期——三个状态共同构成一个完备的、可转移的、可终止的系统。线性DP的“线性”不等于“一维状态”而是指状态维度虽可增加如二维f[i][j]但每个维度的依赖都严格单向、局部。2.3 为什么滚动数组有效——内存视角下的状态生命周期当你写出f[i][j] f[i-1][j] f[i-1][j-w[i]]这样的转移式直觉告诉你算第i行时只用到第i-1行的数据第i-2行及更早的行全作废。这就是滚动数组的物理基础状态在时间轴i维上的生命周期只有1个单位。但很多人忽略了一个关键细节j维的依赖方向决定了滚动方向。以01背包为例原始二维写法for i in range(1, n1): for w in range(W, -1, -1): # 注意这里必须倒序 if w weight[i]: dp[i][w] max(dp[i-1][w], dp[i-1][w-weight[i]] value[i]) else: dp[i][w] dp[i-1][w]若改成正向遍历for w in range(0, W1)会发生什么假设weight[i]2当w2时dp[i][2] max(dp[i-1][2], dp[i-1][0]val[i])接着w4时dp[i][4]可能用到dp[i][2]因为w-weight[i]2而dp[i][2]已是本轮更新过的值——这实际变成了完全背包物品可重复使用彻底破坏01背包语义。所以j维必须倒序确保每次用的都是i-1行的旧值。一维滚动数组正是利用了这个特性dp [0] * (W1) for i in range(1, n1): for w in range(W, weight[i]-1, -1): # 倒序且下界为weight[i] dp[w] max(dp[w], dp[w-weight[i]] value[i])这里dp[w]在更新前存的是f[i-1][w]更新后变成f[i][w]。整个过程就像一条传送带旧数据从右向左被新数据覆盖而覆盖发生的位置永远在依赖位置的右侧保证了数据不被提前污染。这不是编程技巧是状态依赖图在内存布局上的投影——当你理解了这点就不会再死记“背包要倒序”而是看到任何状态转移式立刻能判断维度是否可滚动、滚动方向如何。3. 核心题型拆解从状态定义到代码落地的完整链路3.1 最长上升子序列LISO(n²)与O(n log n)的思维跃迁LIS是线性DP的“试金石”它暴露了状态定义与算法效率的深层关联。先看经典O(n²)解法状态定义f[i] 以a[i]结尾的最长上升子序列长度转移方程f[i] max{ f[j] 1 | 0≤ji and a[j] a[i] }初始化f[i] 1每个元素自身构成长度为1的序列答案max(f[0..n-1])这段代码的物理意义非常清晰对每个位置i扫描它左边所有比它小的元素j取其中f[j]最大的那个接上a[i]。时间复杂度O(n²)源于内层循环的全量扫描。但O(n log n)解法颠覆了状态定义新状态定义g[len] 长度为len的所有上升子序列中末尾元素的最小可能值维护方式g数组严格递增证明若len1len2g[len1]g[len2]否则长度为len2的序列去掉末尾元素就能得到更小的len1末尾值矛盾转移操作对每个a[i]在g中二分查找第一个≥a[i]的位置pos令g[pos] a[i]答案g数组的长度即最大len为什么这个g数组能工作关键在于“末尾最小化”蕴含了贪心思想对于相同长度的LIS末尾越小后续越容易接上新数字。比如序列[1,3,6,7,9,4,10]当处理到4时g[1,3,4]原g[1,3,6]被更新这样后续遇到5就能接上而保留6的话5就接不上。g数组不存具体序列只存“潜力”把O(n)的线性扫描压缩成O(log n)的二分查找。实操心得O(n log n)解法的难点不在代码而在理解g[len]的含义。我让学生画出g数组随a[i]变化的过程初始g[]a[0]1→g[1]a[1]3→g[1,3]a[2]6→g[1,3,6]a[3]7→g[1,3,6,7]a[4]9→g[1,3,6,7,9]a[5]4→二分找到6的位置g[1,3,4,7,9]a[6]10→g[1,3,4,7,9,10]。亲眼看到g如何“优化”末尾值比背一百遍公式都管用。3.2 01背包问题容量维度的依赖陷阱与空间优化01背包是线性DP的“教科书案例”但它的坑比想象中深。先看标准二维解法# dp[i][w] 前i个物品在容量w下的最大价值 dp [[0]*(W1) for _ in range(n1)] for i in range(1, n1): for w in range(W1): if w weight[i]: dp[i][w] dp[i-1][w] else: dp[i][w] max(dp[i-1][w], dp[i-1][w-weight[i]] value[i])这里的关键洞察是w维度的依赖方向决定了空间优化的可行性。dp[i][w]只依赖dp[i-1][w]和dp[i-1][w-weight[i]]两者都在上一行且w-weight[i] ≤ w所以只要保证计算w时w-weight[i]位置还没被覆盖就能复用空间。一维滚动数组的实现细节值得深究dp [0] * (W1) for i in range(1, n1): # 必须倒序否则w-weight[i]会被提前更新 for w in range(W, weight[i]-1, -1): dp[w] max(dp[w], dp[w-weight[i]] value[i])为什么倒序假设weight[i]3W10。正向遍历时w3先更新dp[3]接着w6用到dp[3]此时已是新值相当于把第i个物品用了两次倒序时w10先算用的是旧dp[7]w7再算用旧dp[4]直到w3才更新全程用的都是i-1行的值。注意事项初学者常犯的错误是忘记循环下界。range(W, weight[i]-1, -1)中的weight[i]-1是关键——当w weight[i]时物品放不下dp[w]保持不变无需计算。漏掉这个下界会导致w从W一路减到0做大量无意义的max比较拖慢速度。3.3 最大子数组和Kadane算法状态压缩的极致这个问题常被误认为是“贪心”但它本质是线性DP的极简形态状态定义f[i] 以a[i]结尾的最大子数组和转移方程f[i] max(a[i], f[i-1] a[i])初始化f[0] a[0]答案max(f[0..n-1])这个转移方程的精妙在于它把“是否延续前面的子数组”这个决策压缩成一个max操作。f[i-1] a[i]代表延续a[i]代表重开。而f[i-1]本身已经是最优的延续方案所以无需考虑更早的状态。空间优化到极致max_ending_here a[0] max_so_far a[0] for i in range(1, n): max_ending_here max(a[i], max_ending_here a[i]) max_so_far max(max_so_far, max_ending_here)这里max_ending_here就是f[i]max_so_far是答案。没有数组没有索引只有两个变量在流动。这种“状态即变量”的写法是线性DP最纯粹的体现——状态生命周期短到只需一个变量承载。3.4 编辑距离二维线性DP的典型范式编辑距离Levenshtein Distance是字符串DP的基石它展示了线性DP如何扩展到二维状态定义f[i][j] word1前i个字符变为word2前j个字符的最少操作数转移方程若word1[i-1] word2[j-1]f[i][j] f[i-1][j-1]无需操作否则f[i][j] min( f[i-1][j]1, f[i][j-1]1, f[i-1][j-1]1 )分别对应删除、插入、替换初始化f[i][0] i删光word1前i个f[0][j] j插入j个字符答案f[m][n]这个二维表的依赖关系非常干净f[i][j]只依赖左、上、左上三个邻居全部在(i,j)的左上方。因此可以按行或按列顺序填充也可用滚动数组将空间降到O(min(m,n))。实操心得编辑距离的初始化常被忽略。f[0][j]j不是凭空来的它对应“对空字符串插入j个字符”的操作数这是业务语义决定的边界条件。我在面试中见过候选人把f[0][0]设为0后直接从i1,j1开始循环结果f[1][0]和f[0][1]未初始化导致后续计算全错。记住DP的边界条件不是数学技巧是问题本身的起点。4. 实战避坑指南那些调试三天才发现的隐性错误4.1 索引越界从0开始还是从1开始几乎所有线性DP都面临索引偏移问题。以01背包为例物品编号通常从1到n重量数组weight[1..n]但Python列表从0开始。常见错误写法# 错误weight[i]在i0时访问weight[0]但weight[0]可能是无效值 for i in range(n): # i从0到n-1 for w in range(W, weight[i]-1, -1): dp[w] max(dp[w], dp[w-weight[i]] value[i])正确做法是让物品索引与数组索引对齐# 正确weight[0]存第一个物品重量i从0开始 weight [w1, w2, ..., wn] # 长度n value [v1, v2, ..., vn] dp [0] * (W1) for i in range(n): # i0,1,...,n-1 for w in range(W, weight[i]-1, -1): if w weight[i]: # 双重保险 dp[w] max(dp[w], dp[w-weight[i]] value[i])或者采用“哨兵”风格让数组长度为n1weight[1..n]有效# 哨兵风格weight[0]不用weight[1]到weight[n]有效 weight [0] [w1, w2, ..., wn] # 长度n1 value [0] [v1, v2, ..., vn] for i in range(1, n1): # i从1到n for w in range(W, weight[i]-1, -1): dp[w] max(dp[w], dp[w-weight[i]] value[i])选择哪种取决于团队规范。我倾向哨兵风格因为状态定义f[i]天然对应“前i个物品”与题目描述一致减少脑内转换。4.2 初始化陷阱边界值不是随便设的0DP数组的初始化不是仪式感而是业务逻辑的起点。常见错误错误1全初始化为0在“最少硬币”问题中f[0]0凑0元需0枚但f[i]i0应初始化为无穷大float(inf)表示“暂时无法凑出”。若全设0则f[1] min(f[1-1]1, ...) 01 1看似正确但当coins[2]amount1时f[1]本应为inf却因初始化为0而算出1答案错误。错误2混淆“不可达”与“0”在“目标和”问题给数组nums求加减号使和为target的方案数中f[i][s]表示前i个数达到和s的方案数。s的范围是[-sum, sum]需用偏移量映射到非负索引。若初始化f[0][offset] 1空数组和为0其余为0这是正确的若把f[0][*]全设1则逻辑崩溃。我的初始化检查清单找出所有边界状态如i0, j0, w0逐个问“这个状态的业务含义是什么值应该是多少”对于“方案数”类问题不可达状态初始化为0对于“最值”类问题不可达状态初始化为±inf写完初始化后手动代入小样例如n1,2验证边界状态是否符合预期。4.3 滚动数组的维度混淆一维不够时强行压缩当状态依赖跨越多行时强行一维滚动会出错。例如“股票买卖II”无限次交易f[i][0] 第i天不持股的最大利润f[i][1] 第i天持股的最大利润转移f[i][0] max(f[i-1][0], f[i-1][1] prices[i])f[i][1] max(f[i-1][1], f[i-1][0] - prices[i])这里f[i][0]依赖f[i-1][0]和f[i-1][1]f[i][1]也依赖两者。若用一维数组dp[0], dp[1]正向更新时# 错误先更新dp[0]再用新dp[0]更新dp[1] dp[0] max(dp[0], dp[1] price) dp[1] max(dp[1], dp[0] - price) # 这里dp[0]已是新值正确做法是用临时变量保存旧值# 正确先存旧值再同时更新 old_0, old_1 dp[0], dp[1] dp[0] max(old_0, old_1 price) dp[1] max(old_1, old_0 - price)或者直接用两个变量更清晰hold, sold -prices[0], 0 # 第0天持股/不持股 for i in range(1, n): new_sold max(sold, hold prices[i]) new_hold max(hold, sold - prices[i]) sold, hold new_sold, new_hold4.4 答案提取误区状态定义与答案的错位最隐蔽的bug是状态定义和答案提取不匹配。例如“打家劫舍”问题状态定义f[i] 偷前i个房子的最大金额转移f[i] max(f[i-1], f[i-2] nums[i-1])答案f[n]但若定义f[i] 以第i个房子结尾的最大金额则答案是max(f[0..n-1])而非f[n-1]。我见过学员写f [0] * n f[0] nums[0] f[1] max(nums[0], nums[1]) for i in range(2, n): f[i] max(f[i-1], f[i-2] nums[i]) return f[n-1] # 错这是“以最后一个房子结尾”的值不是全局最大正确答案是max(f)因为最优解不一定包含最后一个房子。排查技巧对任意DP问题做完后立即问自己“最终答案在哪个状态里是f[n]还是max(f)还是f[n][target]”然后用n1,2,3的手算样例验证。比如n1时答案应为nums[0]n2时应为max(nums[0], nums[1])。如果代码在n2时返回f[1]即nums[1]而nums[3,1]则返回1明显错误。5. 进阶思考线性DP的边界与延伸5.1 当“线性”开始弯曲从线性到区间DP的过渡线性DP的“线”一旦弯曲就滑向区间DP。典型例子是“石子合并”n堆石子围成一圈每次合并相邻两堆代价为两堆重量和求最小总代价。若石子是线性的排成一行则f[i][j] 合并第i到j堆的最小代价转移时枚举分割点kf[i][j] min{ f[i][k] f[k1][j] sum(i,j) }。这里f[i][j]依赖所有f[i][k]和f[k1][j]k在i和j之间——依赖不再是单向而是“区间内所有子区间”状态空间从一维线变成二维矩形。但线性DP和区间DP并非割裂。很多区间DP问题可通过“破环为链”技巧转为线性将环形石子复制一份接在后面变成长度2n的链然后在长度为n的窗口内求解。这时状态f[i][j]中j-i的跨度被限制在n内计算顺序按区间长度从小到大本质上仍是线性推进——只是“线”的维度从下标变成了区间长度。5.2 动态规划与贪心的模糊地带有些问题表面是DP实则贪心可解比如“跳跃游戏II”求到达最后索引的最少跳跃次数。DP解法f[i] 到达i的最少跳跃数f[i] min{ f[j]1 | ji and jnums[j]i }O(n²)。但贪心解法维护当前能跳到的最远位置farthest以及上一次跳跃能覆盖的边界end当i越过end时必须跳一次更新endfarthest。O(n)。两者的区别在于DP的f[i]存储了所有可能路径的代价贪心的变量只存当前最优策略的边界。当问题满足“最优子结构”且“贪心选择性质”每一步的局部最优导致全局最优时贪心是DP的特例。识别这一点需要经验如果状态转移中min/max操作总是能被一个确定性规则替代如“取最远可达位置”那很可能存在贪心解。5.3 工程实践中的DP缓存与重构的权衡在真实项目中DP很少以教科书形式出现。比如推荐系统中的“序列建模”用户行为序列长度不定直接开f[n][d]数组会爆内存。工程解法是用字典缓存lru_cache替代数组只存实际访问过的状态将状态压缩为tuplei, context_hashcontext_hash是前k个行为的哈希值设置缓存大小上限超限时LRU淘汰。这时“线性”体现在计算顺序上按时间戳t1,2,3...顺序处理事件每个t的状态只依赖t-1的状态。但内存布局已非连续数组而是哈希表。DP的本质——状态、转移、记忆化——依然存在只是载体变了。我参与过一个物流路径规划项目需求是“给定车辆续航和充电站位置求从A到B的最少充电次数”。教科书解法是f[i] 到达第i个充电站的最少次数O(n²)。但实际路网中站点数万O(n²)超时。最终方案是将站点按距离排序用BFS优先队列状态为当前站点, 剩余电量转移时只考虑电量够到达的下一个站点。这看起来像图搜索但状态定义和转移逻辑与线性DP一脉相承——只是“线”从下标变成了能量维度。最后分享一个小技巧当你面对一个新问题不确定是否适用线性DP时先尝试用“状态机”思维建模。画出所有可能的状态如“空闲”、“充电中”、“行驶中”标出状态间的转移条件如“电量0 → 行驶中”再看这些状态能否用一维或二维数组索引。如果状态图是DAG有向无环图且边只指向“未来”那线性DP大概率可行。DP不是魔法它是把复杂决策分解成可枚举状态的艺术——而线性是这种艺术最简洁的表达。