线性DP(入门)

线性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

  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 ilower_bound 找第一个 ≥ x
  • 允许相等(非严格 a j ≤ a iupper_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 开始下降;iup、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. 单独拿最后 1 位解码 如果当前字符s[i‑1]不是'0',可以单独作为一个编码。 前面前i‑1位有dp[i‑1]种方案,直接继承:dp[i] += dp[i‑1]
  2. 拿末尾两个字符合并解码 需要满足 3 个条件: 1)至少有两位字符 i>1 2)第一位不能是 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)

  1. 状态:dp[i]表示字符串前i个字符解码方案数,dp[0]=1虚拟空串边界。
  2. 线性 DP,i 从 1 到 l 顺序遍历,dp [i] 只依赖 dp [i‑1]、dp [i‑2] 前面的状态,无后效性
  3. 转移来源两种(两种取其和 ):
    • 当前字符单独解码,根据是否是*乘对应系数加到 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];
    }
};
相关推荐
彷徨而立22 分钟前
【C/C++】多线程读写普通 int 变量的一些问题
java·c语言·c++
feilieren26 分钟前
leetcode - 389. 找不同
算法·leetcode
挽星安30 分钟前
2026/8/29
数据结构·算法
positive_zpc42 分钟前
进阶数据结构图——最短路径(二)
数据结构·算法·图论·最短路径
蒸蒸yyyyzwd43 分钟前
cpp web server 面试可能问题总结
c++·笔记·八股
啊啊啊啊啊!!!!1 小时前
【c++】map和set的使用
开发语言·c++
positive_zpc1 小时前
进阶数据结构图——最小生成树(一)
数据结构·算法·图论
小灰灰搞电子1 小时前
分享自己写的一个通信协议源码,支持C++和C,双包头+不定长
c语言·开发语言·c++
码完就睡1 小时前
数据结构——遍历二叉树
数据结构·算法