跳转至

第 102 章 线性 DP

本章配套 24 道例题,见 §例题。

「线性 DP」指状态沿一个(或两个)序列维度推进的 DP。它是笔试出现最多的 DP 形态, 四个经典模型:最大子段和、LIS、LCS、编辑距离。


1 最大子段和(Kadane)

选一个非空连续子数组使元素和最大。

设 \(f_i\) = 「以 \(i\) 结尾」的最大子段和。枚举最后一步:这一段要么把前面接上,要么从 \(i\) 重开。

\[f_i = a_i + \max(f_{i-1},\ 0)\]

答案 \(= \max_i f_i\)。

等价视角:前缀和

\[\text{答案} = \max_i \Big(S_i - \min_{j < i} S_j\Big)\]

一边扫一边记录历史最小前缀和。两种写法完全等价,前缀和视角在带长度限制 (如「长度不超过 \(k\) 的最大子段和」)时更容易扩展成单调队列,见 DP优化。

BISHI134 最大子段和(简单)

\(n \le 2\times10^5\),\(a_i\) 可为负。

import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    a = list(map(int, data[1:1 + n]))
    # cur = 以当前元素结尾的最大子段和;best = 扫到目前为止的全局最优
    best = cur = a[0]                  # ★ 初值必须是 a[0],不能是 0
    for i in range(1, n):
        x = a[i]
        # cur > 0 才值得把前面那段接上;否则从 x 处重开一段
        cur = x + cur if cur > 0 else x
        if cur > best:                 # 每个位置都可能是答案的右端点,逐个比一遍
            best = cur
    print(best)


main()

坑 1(最重要):题目要求非空,所以答案初值必须是 \(a_1\)(或 \(-\infty\)),不能是 0。 全负数组时答案是最大的那个负数;初值取 0 会错误地输出 0。

坑 2:max(f, 0) 里的 0 是「放弃前面这段」,不是「和为 0 的空段」—— 因为 \(f_i\) 里必定含 \(a_i\),非空性有保证。

题解:solutions/nowcoder/BISHI134/sol.py(已通过官方样例验证)


2 最长上升子序列(LIS)

\(O(n^2)\) 朴素 DP

\(f_i\) = 以 \(i\) 结尾的最长上升子序列长度:

\[f_i = 1 + \max\{f_j \mid j < i,\ a_j < a_i\}\]

\(n \le 1000\) 时可用;\(n = 5000\) 时 \(2.5\times10^7\) 次 Python 迭代就危险了。

\(O(n\log n)\) 贪心 + 二分

维护数组 tails,tails[i] = 长度为 \(i+1\) 的上升子序列结尾元素的最小可能值。

关键性质:tails 天然单调不减。新元素 \(x\) 来时:

  • 在 tails 里找第一个「不该被 \(x\) 挤掉」的位置 \(p\),用 \(x\) 替换它 (让同长度的结尾更小、更有潜力);
  • 若不存在(\(x\) 比所有元素都大)⇒ append,答案长度 \(+1\)。

等号位置:四个变体的完整对照

这是 LIS 唯一会写错的地方,把四种情况列全:

目标 找第一个 函数 记忆法
最长严格上升 \(\ge x\) 的位置 bisect_left 相等的元素应该被挤掉 ⇒ left
最长不下降(允许相等) \(> x\) 的位置 bisect_right 相等的元素不该被挤掉,越过它们 ⇒ right
最长严格下降 对 \(-x\) 求严格上升 bisect_left on [-x] 取负翻转方向
最长不上升 对 \(-x\) 求不下降 bisect_right on [-x] 同上
# [片段]
from bisect import bisect_left, bisect_right

tails = []                            # tails[i] = 长度为 i+1 的上升子序列的最小结尾值
for x in a:
    p = bisect_right(tails, x)        # 不下降;严格上升改 bisect_left
    if p == len(tails):               # x 比现有全部结尾都大 ⇒ 能接出更长的一条
        tails.append(x)
    else:
        tails[p] = x                  # 同长度换个更小的结尾,后面更容易接上新元素
answer = len(tails)                   # tails 的长度就是最长长度(内容不是答案序列)

tails 不是答案序列本身,只是「每个长度的最优结尾值」,不能拿它去还原方案。 要还原方案需要额外记录每个元素被放到了哪个位置,再回溯。

Python 3.9 的 bisect 不支持 key 参数(3.10 才加)。 对元组或对象排序时要么预先构造键数组,要么取负。

BISHI133 最长不下降子序列(简单)

\(n \le 5\times10^3\)。

\(n\) 只有 5000,\(O(n^2) = 2.5\times10^7\) 在 2 秒里也悬,而 \(O(n\log n)\) 只有 \(6\times10^4\) 次操作,稳。

import sys
from bisect import bisect_right


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    tails = []                         # 始终单调不减,所以可以直接二分
    for i in range(1, n + 1):          # 数据从下标 1 开始,data[0] 是 n
        x = int(data[i])
        p = bisect_right(tails, x)     # 「不下降」用 right:跳过与 x 相等的那些结尾
        if p == len(tails):            # 找不到可替换的位置 ⇒ 答案长度加一
            tails.append(x)
        else:
            tails[p] = x               # 把长度 p+1 的结尾压得更小
    print(len(tails))


main()

题解:solutions/nowcoder/BISHI133/sol.py(已通过官方样例验证)

与 Dilworth 定理的联系

「最少用几个不上升子序列覆盖整个序列」的答案 等于 「最长上升子序列的长度」。 这是 Dilworth 定理的直接推论,也是 NOIP 导弹拦截问题的第二问。 证明见 偏序集与Dilworth定理。


3 最长公共子序列(LCS)

两个序列 \(a\)(长 \(n\))、\(b\)(长 \(m\)):

\[ f[i][j] = \begin{cases} f[i-1][j-1] + 1 & a_i = b_j \\ \max(f[i-1][j],\ f[i][j-1]) & a_i \ne b_j \end{cases} \]

\(O(nm)\)。Python 下 \(n = m = 1000\) 是 \(10^6\) 次迭代,勉强;\(n = m = 5000\) 就不行了。

Python 优化:滚动成一维,并把内层用 map 批处理。但 LCS 的转移有串行依赖 (\(f[i][j]\) 依赖 \(f[i][j-1]\)),不能直接 map。可行的做法:

# [片段]
# 只求长度时,按 b 的字符位置分桶,转成 LIS 问题:
# LCS(a, b) = LIS(把 a 的每个字符替换成它在 b 中的所有下标,逆序排列后拼接)
# 当 b 中字符互不相同时,退化成 O(n log n)

这个转化只在「\(b\) 中元素互不相同」时才把复杂度降到 \(O(n\log n)\); 一般情况仍是 \(O(nm)\)。

最长公共子串(注意区别)

子串要求连续,是另一个模型:

\[ f[i][j] = \begin{cases} f[i-1][j-1] + 1 & a_i = b_j \\ 0 & a_i \ne b_j \end{cases} \]

答案 \(= \max f[i][j]\)(不是 \(f[n][m]\))。

更快的做法是后缀自动机或二分 + 哈希,见 字符串哈希。


4 编辑距离

把 \(a\) 变成 \(b\) 的最少操作数(插入 / 删除 / 替换各 1 次代价):

\[ f[i][j] = \begin{cases} j & i = 0 \\ i & j = 0 \\ f[i-1][j-1] & a_i = b_j \\ 1 + \min\big(f[i-1][j],\ f[i][j-1],\ f[i-1][j-1]\big) & \text{否则} \end{cases} \]

三个候选分别对应删除 \(a_i\)、插入 \(b_j\)、替换。

变式 改动
只允许插入删除 去掉 \(f[i-1][j-1]\) 那一项,答案 \(= n + m - 2\cdot\text{LCS}\)
操作代价不同 把 1 换成对应代价
允许相邻交换 Damerau–Levenshtein,多一个 \(f[i-2][j-2]+1\) 的候选

5 数字三角形与坐标 DP

从顶点走到底边,每步走左下或右下,求路径和最大:

\[f[i][j] = a[i][j] + \max\big(f[i-1][j-1],\ f[i-1][j]\big)\]

坐标 DP 是同一族:网格里只能向右/向下走,\(f[i][j] = a[i][j] + \max(f[i-1][j], f[i][j-1])\)。 经典的「过河卒」就是加了障碍的版本。

BISHI135 三角形取数(Hard Version)(中等)

数字三角形(居中摆放,第 \(i\) 行有 \(2i-1\) 个数),每步走左下/正下/右下, 限制 \(|\text{左下次数} - \text{右下次数}| \le k\),求路径和最大。\(n \le 300\)。

这题最大的价值是它示范了「换坐标系消掉一维状态」。

朴素想法:把 \((l - r)\) 当成 DP 的第三维,\(O(n^3) = 2.7\times10^7\)。能过,但完全没必要。

观察:给每个数一个「绝对列号」\(c\)。第 \(i\) 行的第 \(j\) 个数(\(j = 1..2i-1\))的绝对列号是

\[c = j + (n - i)\]

这样一来:正下方 = \(c\) 不变,左下 = \(c-1\),右下 = \(c+1\),起点在 \(c = n\)。于是终点列号满足

\[c_{\text{end}} = n + (r - l) \quad\Longrightarrow\quad l - r = n - c_{\text{end}}\]

约束 \(|l-r| \le k\) 等价于「终点列号落在 \([n-k,\ n+k]\) 内」—— 根本不需要把 \((l-r)\) 当成状态!

剩下就是最朴素的数字三角形 DP:

\[f[i][j] = a[i][j] + \max\big(f[i-1][j-2],\ f[i-1][j-1],\ f[i-1][j]\big)\]

(下标是「行内序号」,上一行序号比本行小 0/1/2,正好对应右下/正下/左下三种来法。)

坑: 1. 行内序号与绝对列号的换算是本题的全部难点,画个 \(n=3\) 的图对一遍再动手; 2. 边界:上一行的序号必须落在 \([1, 2i-3]\) 内,越界的候选要跳过; 3. \(a_{i,j}\) 可以是 \(-2\times10^9\),累加 300 行会到 \(-6\times10^{11}\),C++ 必须 long long(Python 无此虑); 4. 答案只在最后一行的合法列号范围内取最大值,不是全局最大。

方法论:看到「两个计数之差有界」这类约束,先问一句—— 换个坐标系能不能把它变成「位置的范围约束」? 能的话就省掉一整维状态。这比硬加一维再优化划算得多。

题解:solutions/nowcoder/BISHI135/sol.py(已通过官方样例验证)


6 Python 下的规模判据

线性 DP 的状态数直接决定可行性:

状态数 Python 现实性 应对
\(\le 10^6\) ✅ 安全 直接写
\(10^6 \sim 10^7\) ⚠️ 必须把内层下沉 C 层 map / 切片 / accumulate
\(\ge 10^7\) ❌ 基本无望 换算法降维,或接受 TLE 并标注

降维的常见手段:

手段 例子
换坐标系消掉一维 BISHI135
用数据结构维护转移的最优值 单调队列、树状数组(见DP 优化)
发现状态间的单调性 决策单调性(见DP 优化)
用组合数学替代 DP BISHI72 用 0/1 转化 + 组合计数替代分治

7 例题

题号 题名 难度 题解
BISHI133 最长不下降子序列 简单 题解
BISHI134 最大子段和 简单 题解
BISHI135 三角形取数(Hard Version) 中等 题解
BM65 最长公共子序列(二) 中等 题解
BM66 最长公共子串 中等 题解
BM67 不同路径的数目(一) 简单 题解
BM68 矩阵的最小路径和 中等 题解
BM69 把数字翻译成字符串 中等 题解
BM71 最长上升子序列(一) 中等 题解
BM72 连续子数组的最大和 简单 题解
BM75 编辑距离(一) 较难 题解
BM76 正则表达式匹配 较难 题解
BM78 打家劫舍(一) 中等 题解
BM79 打家劫舍(二) 中等 题解
BM80 买卖股票的最好时机(一) 简单 题解
BM81 买卖股票的最好时机(二) 中等 题解
BM82 买卖股票的最好时机(三) 较难 题解
LC53 最大子数组和 中等 题解
LC72 编辑距离 中等 题解
LC121 买卖股票的最佳时机 简单 题解
LC152 乘积最大子数组 中等 题解
LC198 打家劫舍 中等 题解
LC300 最长递增子序列 中等 题解
LC1143 最长公共子序列 中等 题解

面试题单里的十四道线性 DP 题

本章前五节的每个模型,题单里都有对应的题,正好逐节对照。

§1 最大子段和。 连续子数组的最大和就是 Kadane 的原样形态: 维护「以当前位置结尾的最大和」,非正就丢弃重开。 题面要求至少选一个元素,所以初值取 \(a_0\) 而不是 0——这个边界一旦取 0,全负数组会返回 0。

§2 最长上升子序列。 最长上升子序列(一)只要长度, 数据规模允许 \(O(n^2)\) 的朴素写法,但 bisect 那版 \(O(n\log n)\) 代码更短: 维护「长度为 \(k\) 的上升子序列的最小结尾」,新元素用 bisect_left 定位后原地替换。 那个数组本身不是任何一个合法的子序列,只是各长度的最优结尾,这一点 §2 强调过。

§3 最长公共子序列,以及它最容易被混淆的邻居。 最长公共子序列(二)要求输出具体方案,所以不能只滚动一维, 要么保留整张 \(n \times m\) 表回溯,要么另记转移方向。 最长公共子串则是连续版本:转移从「三选一取最大」变成 「字符相同才由左上角加一,不同就归零」,且答案不在 \(f_{n,m}\) 而在全表最大值处。 子序列与子串差一个字,转移方程与取答案的位置都不同,这两题放在一起做最能记住。

§4 编辑距离。 编辑距离(一)是三种操作等代价的标准版, 按 §4 的方程直接写。正则表达式匹配是它的近亲——同样是两个串下标做二维状态, 只是转移由 . 与 * 两个通配符决定。* 那一支要同时考虑「匹配零次」与「多匹配一次」, 写成 \(f_{i,j} = f_{i,j-2} \lor (f_{i-1,j} \land \text{可匹配})\); * 与它前面那个字符是一个整体,跳过时要一次跳两格,这是这题最集中的错法。

§5 坐标 DP。 不同路径的数目(一)与矩阵的最小路径和是同一张网格上的两问: 前者求方案数(转移是加法),后者求最小代价(转移是 \(\min\) 再加上本格代价)。 两题都只依赖上一行,按 100.5 滚成一维即可。 BM67 还有一条不用动态规划的路:答案就是组合数 \(\binom{n+m-2}{n-1}\), 见 组合数学。

没有直接对应小节的五道。 把数字翻译成字符串是「爬楼梯 + 合法性判断」: \(f_i = f_{i-1}\cdot[\text{单字符合法}] + f_{i-2}\cdot[\text{两字符合法}]\), 合法性判断里最容易漏的是前导零——06 不能当作 6 来翻译。

打家劫舍(一)是最基本的「选或不选且不能相邻」:\(f_i = \max(f_{i-1},\ f_{i-2} + a_i)\)。 打家劫舍(二)把房子排成环,处理办法是拆成两条链各做一遍: 一条强制不选第一间、一条强制不选最后一间,取较大者。 环形问题拆成若干条链是通用手法,比给状态加一维「首元素选没选」更省事。

买卖股票的最好时机(一)、买卖股票的最好时机(二)、买卖股票的最好时机(三) 是同一族的状态机动态规划,难度阶梯很整齐: BM80 只许交易一次,记住「历史最低价」一趟扫完; BM81 不限次数,每段上涨都可以吃下,等价于把所有正差值相加; BM82 最多两次交易,要开 \(f[i][j][0/1]\) 三维(第 \(i\) 天、已用 \(j\) 次、手上有没有股票), 按天推进。第三题的状态设计能写对,这一族就都会了—— 状态里必须同时含「已用交易次数」与「持仓与否」,少一维就补不出转移。

另一处题库里的七道线性动态规划题

四道对应本章已有的模板节,只记差异。 LC 300. 最长递增子序列就是最长不下降子序列那一节的原题,\(O(n^2)\) 与 \(O(n \log n)\) 两版都在那里; LC 1143. 最长公共子序列对应同名的那一节,两串长度都不超过 \(1000\),\(O(nm)\) 的二维表直接可行; LC 72. 编辑距离对应编辑距离那一节,三种操作对应三个转移来源, 初始化的那一行与那一列分别是「全删」与「全插」的代价。 LC 121. 买卖股票的最佳时机是 BM80 那一组里的第一问: 只允许做一次交易时连状态机都不用,扫一遍维护「到目前为止的最低价」即可,\(O(n)\)、\(O(1)\) 空间。

LC 53. 最大子数组和与 LC 152. 乘积最大子数组:状态要带几条,取决于运算的符号性质。 LC53 一条就够:\(f_i\) 表示以 \(i\) 结尾的最大子段和, 转移是 \(f_i = \max(a_i,\ f_{i-1} + a_i)\),答案取所有 \(f_i\) 的最大值,\(O(n)\)。 换成乘积之后一条不够——负负得正,当前的最小值乘上一个负数就成了最大值, 所以 LC152 要同时维护「以 \(i\) 结尾的最大积」与「最小积」两条, 每步用 \(a_i\)、\(\max \cdot a_i\)、\(\min \cdot a_i\) 三者分别取大、取小。 只维护最大值的写法在 \([-2, 3, -4]\) 上会给出 \(3\) 而不是 \(24\)。

LC 198. 打家劫舍:约束要写进状态,不能写进循环。 相邻两间不能同时取,状态取「前 \(i\) 间的最大收益」, 转移是「不取第 \(i\) 间」与「取第 \(i\) 间再加上前 \(i-2\) 间」二者取大, \(O(n)\)、滚动两个变量即可。 把「不能相邻」当成遍历时的临时判断是写不出来的—— 第 \(i\) 间该不该取取决于前面整段的取法,它不是一个局部条件。

8 本章速查

模型 转移 关键点
最大子段和 \(f_i = a_i + \max(f_{i-1}, 0)\) 非空 ⇒ 初值取 \(a_1\) 不是 0
LIS \(O(n^2)\) \(f_i = 1 + \max\{f_j\}\) \(n \le 1000\) 可用
LIS \(O(n\log n)\) tails + bisect 严格上升 left、不下降 right
LCS 相等则 \(+1\),否则取 max \(O(nm)\),串行依赖难批处理
最长公共子串 不等则清 0 答案是全局 max 不是 \(f[n][m]\)
编辑距离 三候选 \(+1\) 只增删时可由 LCS 推出
数字三角形 / 坐标 DP 从上/左转移 障碍格设为不可达
陷阱 正解
最大子段和初值取 0 全负数组会输出 0,必须取 \(a_1\)
LIS 等号写错 不下降用 bisect_right,严格上升用 bisect_left
拿 tails 当答案序列 它只是最优结尾值,不是子序列
3.9 用 bisect(key=) 不支持,预先构造键数组
「两计数之差有界」硬加一维 先试换坐标系(BISHI135)