线性DP
基本知识
线性DP,就是 按一条直线顺序递推 的动态规划。
简单来说:我们把问题拆成 1、2、3...n 依次排列的子问题,后面的答案,只由前面已经算好的答案推导得出,严格遵循从左到右、从前到后的线性顺序,有三个核心特征:
- 线性阶段:子问题严格按一维顺序排列,阶段清晰、有序无乱
- 无后效性 :当前状态只依赖前置状态,过去的结果固定,不受未来操作影响
- 最优子结构:全局最优解,一定包含各个子问题的最优解
线性DP万能解题四步模板
Step1:状态定义
定义dp[i] 的含义:dp[i] 表示处理前 i 个元素 / 到第 i 个位置的最优解(最大值/最小值/方案数)。
部分二维线性DP可定义dp[i][j],依旧遵循线性递推逻辑。
Step2:状态转移方程
推导当前状态与前置状态的关系,通用形式:
dp[i] = F(dp[i-1], dp[i-2]...)
根据题目决策(选/不选、走左/走右、取最大/最小),推导对应的递推公式。
Step3:初始化
设置初始边界值,一般是 dp[0]、dp[1],防止递推过程出现逻辑错误、空值、越界问题。
Step4:递推顺序
严格从左到右 遍历,i 从 2 到 n 依次计算,保证求解当前状态时,所有前置状态已经计算完成。
题目练习
B3637 最长上升子序列 - 洛谷
朴素写法
1.状态定义
dp[i]:以数组第 i 个元素 结尾 的最长上升子序列长度。
默认初始化:每个元素自身单独构成子序列,所以 dp[i] = 1。
- 状态转移原理
想要让 a[i] 接在前面的元素后面形成更长的上升子序列:
遍历所有 j < i(前面所有元素),如果 a[j] < a[i](满足上升),说明可以把 a[i] 接在以 j 结尾的子序列后面。
转移方程:dp[i] = max(dp[i], dp[j] + 1)
3.初始化
所有的数自己可以组成一个子序列,所以所有的dp数组初始全都是1
#include<bits/stdc++.h>
using namespace std;
int main(){
int n;
cin>>n;
int a[n+3];
int dp[n+3];
for(int i=0;i<n;i++){
cin>>a[i];
dp[i]=1;
}
for(int i=0;i<n;i++){
for(int j=0;j<i;j++){
if(a[j]<a[i]){
dp[i]=max(dp[j]+1,dp[i]);
}
}
}
int ans=1;
for(int i=0;i<n;i++){
if(dp[i]>ans){
ans=dp[i];
}
}
cout<<ans;
}
二分优化
朴素 DP (O(n2)):两层循环,n 到 (104) 就会 TLE。 贪心 + 二分把内层 (O(n)) 查找,替换成二分 (
O(log k)),总复杂度 (O(nlog n)),可以处理 (n=105)。
注意:该算法直接求出最长上升子序列的长度,tails 数组本身不一定是真实子序列。
定义tails[i]:长度为 i+1 的严格上升子序列,所能得到的最小末尾元素。
关键贪心思想: 同样长度的上升子序列,末尾数字越小越好。末尾越小,后面新来的数字越容易接在它后面,更容易生成更长子序列。
举例:同样是长度为 3 的子序列,[1,2,3](末尾 3)就比[1,2,4](末尾 4)更优。
初始状态:tails为空。
遍历每一个元素 x = a [i],两套分支逻辑
1、如果 x > tails.back()
- x 比 tails 中所有末尾都大,可以接在当前最长子序列后面。
push_back(x),tails 数组长度 + 1,代表找到了更长的子序列。
2、如果 x ≤ tails.back()
- 不能直接延长最长序列。
- 在 tails 数组(本身是严格递增)里,二分查找第一个 ≥ x 的位置 (
lower_bound)。 - 将该位置的值替换成 x。
替换不改变 tails 长度!只更新「该长度子序列的最小末尾」,为后面元素做准备。
tails 数组永远保持严格递增,所以可以二分。
- 严格上升(a j < a i) :
lower_bound找第一个 ≥ x - 允许相等(非严格 a j ≤ a i) :
upper_bound找第一个 > x
为什么 tails.size() 就是答案?
- 每一次
push_back,代表我们成功构造出更长一档长度的上升子序列。 - 替换操作只优化末尾,不会创造更长子序列。
- tails 数组的元素个数 = 我们能实现的最大子序列长度。
重要:
tails[k]代表一定存在原数组中某条长度 k+1 的子序列,末尾等于 tails k; 但是把 tails 全部元素连起来,不一定是原数组真实出现的子序列。
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+3;
int a[N];
int tails[N];
int main(){
int n;
cin>>n;
for(int i=0;i<n;i++){
cin>>a[i];
}
tails[0]=a[0];
int len=1;
for(int i=1;i<n;i++){
if(a[i]>tails[len-1]){
tails[len]=a[i];
len++;
}
else{
int t=lower_bound(tails,tails+len,a[i])-tails;
tails[t]=a[i];
}
}
cout<<len;
return 0;
}
最长连续上升子序列
dp[i]:以 a [i] 结尾的「最长连续上升子数组」的长度。
初始条件: 每个元素自己单独作为一段,长度最少是 1,所以全部 dp[i]=1。
状态转移逻辑
if(a[i-1] < a[i]){
dp[i] = max(dp[i‑1]+1, dp[i]);
}
- 如果前一个数字
a [i‑1]< 当前数字a [i]:连续上升,可以接在前面一段后面。以 i 结尾的连续长度 = 以 i‑1 结尾的长度 +1。 - 如果不满足
a[i‑1]<a[i]:不能接上,dp[i]保持等于 1,代表重新开始一段。
ans 全程记录 dp 数组最大值,就是答案。
#include<bits/stdc++.h>
using namespace std;
int main(){
int n;
cin>>n;
int a[n+3];
int dp[n+3];
for(int i=0;i<n;i++){
cin>>a[i];
dp[i]=1;
}
int ans=1;
for(int i=1;i<n;i++){
if(a[i-1]<a[i]){
dp[i]=max(dp[i-1]+1,dp[i]);
}
ans=max(ans,dp[i]);
}
cout<<ans;
}
P1091 [NOIP 2004 提高组 合唱队形 - 洛谷](https://www.luogu.com.cn/problem/P1091)
合唱队形:序列先严格上升,到山顶点 i 之后严格下降。 求最少删除多少人,使得剩下队伍满足合唱队形。
最少删除人数 = 总人数 n − 满足条件的最长合唱子序列长度。
DP 状态定义
1.up[i]:以 i 位置作为结尾的最长上升子序列长度 ,初始up[i]=1,每个元素自己构成长度 1 的子序列。 转移:
u p i = m a x ( u p i , u p j + 1 ) upi=max(upi,upj+1) upi=max(upi,upj+1)
2.down[i]:以 i 位置作为开头的最长下降子序列长度 从后往前 DP。
d o w n i = m a x ( d o w n i , d o w n j + 1 ) downi=max(downi,downj+1) downi=max(downi,downj+1)
把
i当作峰顶: 左边上升到i,右边从i开始下降;i被up、down各算一次,所以up[i]+down[i]-1就是以i为山顶的完整合唱序列最大长度。 遍历所有i,求出全局最大长度maxx。 答案:ans = n‑maxx。
#include<bits/stdc++.h>
using namespace std;
int up[103],down[103];
int a[103];
int main(){
int n;
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
up[i]=1;
down[i]=1;
}
for(int i=1;i<=n;i++){
for(int j=1;j<i;j++){
if(a[i]>a[j]){
up[i]=max(up[i],up[j]+1);
}
}
}
for(int i=n;i>=1;i--){
for(int j=n;j>i;j--){
if(a[i]>a[j]){
down[i]=max(down[i],down[j]+1);
}
}
}
int maxx=-1;
for(int i=1;i<=n;i++){
maxx=max(maxx,up[i]+down[i]-1);
}
int ans=n-maxx;
cout<<ans;
return 0;
}
91. 解码方法 - 力扣(LeetCode)
题目:数字字符串,
1~26映射 A‑Z,求一共有多少种解码方案。例如"12"可以解码成"AB"、"L",答案为 2。
DP 状态定义
dp[i]:字符串前 i 个字符的解码总方案数。
dp[0] = 1:空串,虚拟边界,代表 "什么都不选,1 种方案",方便做加法。dp[n]就是答案:整个字符串前 n 位全部解码的方案。
两种转移来源
- 单独拿最后 1 位解码 如果当前字符
s[i‑1]不是'0',可以单独作为一个编码。 前面前i‑1位有dp[i‑1]种方案,直接继承:dp[i] += dp[i‑1] - 拿末尾两个字符合并解码 需要满足 3 个条件: 1)至少有两位字符
i>12)第一位不能是 0(不能出现06这种)s[i‑2]!='0'3)组成的数字 ≤26 满足条件,则末尾两位看成整体,前面前i‑2位所有方案都可以接上来:dp[i] += dp[i‑2]
最终:
dp[i] = 情况1的方案数 + 情况2的方案数
class Solution {
public:
int numDecodings(string s) {
int n=s.size();
int dp[n+2];
for(int i=1;i<=n;i++){
dp[i]=0;
}
dp[0]=1;
for(int i=1;i<=n;i++){
if(s[i-1]!='0'){
dp[i]+=dp[i-1];
}
if(i>1&&s[i-2]!='0'&&(s[i-2]-'0')*10+s[i-1]-'0'<=26){
dp[i]+=dp[i-2];
}
}
return dp[n];
}
};
639. 解码方法 II - 力扣(LeetCode)

- 状态:
dp[i]表示字符串前i个字符解码方案数,dp[0]=1虚拟空串边界。- 线性 DP,
i从 1 到l顺序遍历,dp [i]只依赖dp [i‑1]、dp [i‑2]前面的状态,无后效性。- 转移来源两种(两种取其和 ):
- 当前字符单独解码,根据是否是
*乘对应系数加到dp [i‑1];- 末尾两个字符合并解码,分 4 种字符组合,统计合法组合数量,乘以
dp[i‑2]累加到答案。
class Solution {
public:
int numDecodings(string s) {
int l=s.size();
const int N=1e5+3;
vector<long long>dp(l+1,0);
dp[0]=1;
const int mod=1e9+7;
if(s[0]=='0'){
return 0;
}
for(int i=1;i<=l;i++){
if(s[i-1]!='0'){
if(s[i-1]=='*'){
dp[i]+=9*dp[i-1]%mod;
dp[i]%=mod;
}
else{
dp[i]+=dp[i-1]%mod;
dp[i]%=mod;
}
}
if(i>1&&s[i-2]!='0'){
if(s[i-1]=='*'&&s[i-2]=='*'){
dp[i]+=dp[i-2]*15%mod;
dp[i]%=mod;
}
else if(s[i-2]=='*'){
if(s[i-1]-'0'<=6&&s[i-1]-'0'>=0){
dp[i]+=2*dp[i-2]%mod;
dp[i]%=mod;
}
else{
dp[i]+=dp[i-2]%mod;
dp[i]%=mod;
}
}
else if(s[i-1]=='*'){
if(s[i-2]-'0'==1){
dp[i]+=dp[i-2]*9%mod;
dp[i]%=mod;
}
else if(s[i-2]-'0'==2){
dp[i]+=dp[i-2]*6%mod;
dp[i]%=mod;
}
else{
dp[i]+=0;
}
}
else{
if((s[i-2]-'0')*10+s[i-1]-'0'<=26){
dp[i]+=dp[i-2]%mod;
dp[i]%=mod;
}
}
}
}
return dp[l];
}
};