#cc1. 1

1

当前没有测试数据。

一、动态规划基础概念

1. 什么是动态规划

动态规划(Dynamic Programming,简称 DP)是一种通过把原问题分解为相对简单的子问题,利用子问题的解推导原问题解的算法思想。核心是记住已经算过的结果,避免重复计算,从而提升效率,是 CSP-J 入门组复赛的核心高频考点。

2. DP 问题三要素

  • 状态定义:用 dp[i] 或 dp[i][j] 表示某种含义,明确数组每个位置对应的实际意义。
  • 状态转移方程:从已知子问题的结果,推导出当前问题结果的计算公式。
  • 初始化与边界:最基础子问题的答案,是递推的起点。

3. 线性 DP 特点

状态转移呈线性顺序推导,通常按数组下标从小到大依次计算,是 DP 中最基础、最常考的类型,背包问题本质上也是“选/不选”决策的线性 DP。


二、经典线性 DP 模型

模型 1:数字三角形

问题描述

给定一个 n 行的数字三角形,从顶部出发,每次可以走到下一行相邻的左或右节点,求从顶部到底部的最大路径和。

状态定义

dp[i][j]:从顶部走到第 i 行第 j 列时,能获得的最大路径和。

状态转移方程

dp[i][j] = max(dp[i-1][j-1], dp[i-1][j]) + a[i][j]

含义:当前位置只能从左上方或正上方走来,取两者中的较大值,加上当前位置的数字。

边界初始化

dp[1][1] = a[1][1](起点只有一个数字)

模板代码

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1005;
int a[N][N], dp[N][N];

int main() {
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= i; j++)
            cin >> a[i][j];
    
    dp[1][1] = a[1][1];
    for (int i = 2; i <= n; i++) {
        for (int j = 1; j <= i; j++) {
            dp[i][j] = max(dp[i-1][j-1], dp[i-1][j]) + a[i][j];
        }
    }
    
    int ans = 0;
    for (int j = 1; j <= n; j++)
        ans = max(ans, dp[n][j]);
    cout << ans << endl;
    return 0;
}

📌 真题演练


模型 2:最长上升子序列(LIS)

问题描述

给定一个长度为 n 的序列,求其中最长的严格上升子序列的长度(子序列元素不要求连续)。

状态定义

dp[i]:以第 i 个元素结尾的最长上升子序列的长度。

状态转移方程

dp[i] = 1  (初始值,只选自己)
dp[i] = max(dp[i], dp[j] + 1)  当 j < i 且 a[j] < a[i]

含义:枚举 i 前面所有比 a[i]小的元素 j,尝试接在 j 后面形成更长的子序列。

模板代码(O(n²) 入门版)

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1005;
int a[N], dp[N];

int main() {
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    
    int ans = 0;
    for (int i = 1; i <= n; i++) {
        dp[i] = 1;
        for (int j = 1; j < i; j++) {
            if (a[j] < a[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        ans = max(ans, dp[i]);
    }
    cout << ans << endl;
    return 0;
}

📌 真题演练


模型 3:最长公共子序列(LCS)

问题描述

给定两个字符串 A 和 B,求它们最长的公共子序列的长度。

状态定义

dp[i][j]:字符串 A 前 i 个字符、字符串 B 前 j 个字符的最长公共子序列长度。

状态转移方程

若 A[i] == B[j]:dp[i][j] = dp[i-1][j-1] + 1
若 A[i] != B[j]:dp[i][j] = max(dp[i-1][j], dp[i][j-1])

含义:字符相等时,公共长度加 1;不等时,取去掉 A 末尾或去掉 B 末尾中的较大值。

模板代码

  • 字符数组存字符串,设置让实际下标从 1 开始
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;

const int N = 1005;
char a[N], b[N];
int dp[N][N];

int main() {
    cin >> a + 1 >> b + 1;
    int n = strlen(a + 1);
    int m = strlen(b + 1);
    
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (a[i] == b[j]) {
                dp[i][j] = dp[i-1][j-1] + 1;
            } else {
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
            }
        }
    }
    cout << dp[n][m] << endl;
    return 0;
}
  • string 存字符串
#include <iostream>
#include <algorithm>
#include <string>
using namespace std;

const int N = 1005;
int dp[N][N];

int main() {
    string a, b;
    cin >> a >> b;
    int n = a.size();
    int m = b.size();
    
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            // string 原生0基,第i个字符对应索引 i-1
            if (a[i-1] == b[j-1]) {
                dp[i][j] = dp[i-1][j-1] + 1;
            } else {
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]);//要么舍弃i 要么舍弃j
            }
        }
    }
    cout << dp[n][m] << endl;
    return 0;
}

最长公共子序列-2种选择的含义

📌 真题演练


模型 4:编辑距离(进阶线性 DP)

问题描述

给定两个字符串,每次可以选择删除、插入、替换一个字符,求将第一个字符串转为第二个字符串的最少操作次数。

状态定义

dp[i][j]:将第一个字符串前 i 个字符转为第二个字符串前 j 个字符的最少操作数。

状态转移方程

若 str1[i-1] == str2[j-1]:dp[i][j] = dp[i-1][j-1]
否则:dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
在(删掉str1的第i个字符,在第i个后面增加1个字符和str2的第j个字符相等,替换第i个和第j个)

📌 真题演练


三、背包问题(线性 DP 重要分支)

背包问题是入门组 DP 核心考点,本质是“选与不选”的线性决策。

模型 1:01 背包(每件物品只能选 1 次)

问题描述

有 n 件物品,每件物品有重量 w[i] 和价值 v[i],背包容量为 m,求最多能装下的最大总价值。

二维基础解法

状态定义:dp[i][j] 前 i 件物品,背包容量为 j 时的最大价值。 转移方程:

dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])

含义:两种选择——不选第 i 件(继承前 i-1 件结果);选第 i 件(占用容量,加上价值)。

一维滚动数组优化(重点掌握)

核心技巧:逆序遍历容量,保证每件物品只被选一次。

模板代码

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1005;
int w[N], v[N], dp[N];

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        cin >> w[i] >> v[i];
    
    for (int i = 1; i <= n; i++) {
        // 逆序遍历容量,01背包核心
        for (int j = m; j >= w[i]; j--) {
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    cout << dp[m] << endl;
    return 0;
}

📌 真题演练


模型 2:完全背包(每件物品可以选无限次)

问题描述

物品数量无限,其余条件同 01 背包。

核心区别

将 01 背包的逆序遍历容量改为顺序遍历容量,即可实现物品重复选取。

模板代码

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1005;
int w[N], v[N], dp[N];

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        cin >> w[i] >> v[i];
    
    for (int i = 1; i <= n; i++) {
        // 顺序遍历容量,完全背包核心
        for (int j = w[i]; j <= m; j++) {
            dp[j] = (dp[j], dp[j - w[i]] + v[i]);
        }
    }
    cout << dp[m] << endl;
    return 0;
}

📌 真题演练


模型 3:多重背包(每件物品有固定数量)

问题描述

第 i 件物品最多有 c[i] 件,其余条件同 01 背包。

进阶解法:二进制优化

将数量 c 拆分为若干个 2 的幂次,转化为 01 背包求解。 多重背包二进制优化

📌 真题演练

#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1010, M = 2010;
int v[N], w[N]; // 原始物品体积、价值
int dp[M];      // dp数组:dp[j]表示容量j的背包最大价值
int n, V;       // 物品种类数、背包总容量

int main()
{
    cin >> n >> V;
    int cnt = 0; // 拆分后的新物品总数
    
    // 1. 二进制拆分所有物品
    for(int i = 1; i <= n; i ++)
    {
        int vi, wi, si;
        cin >> vi >> wi >> si;
        // 二进制拆分
        int k = 1;
        while(si > 0)
        {
            int t = min(k, si); // 取出当前分组数量
            cnt ++;
            v[cnt] = t * vi;
            w[cnt] = t * wi;
            si -= t;
            k *= 2;
        }
    }

    // 2. 标准01背包DP
    for(int i = 1; i <= cnt; i ++)
        for(int j = V; j >= v[i]; j --)
            dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
    
    cout << dp[V] << endl;
    return 0;
}

四、区间 DP

1. 区间 DP 基础概念

定义

区间 DP 的状态以一段连续区间 [l, r] 作为划分依据,先求解长度更短的小区间,再合并推导更长的大区间。 适用场景:石子合并、括号匹配、字符串区间操作等。

三大核心特点

  1. 状态二维:dp[l][r] 代表区间 l ~ r 的最优解;
  2. 遍历顺序:先枚举区间长度 len(从小到大),再枚举左端点 l,右端点 r = l + len - 1;
  3. 分割点转移:大区间拆成两段小区间 [l, k] 和 [k+1, r],枚举分割点 k 更新答案。

区间 DP 通用解题步骤

  1. 状态定义:dp[l][r] 区间 l 到 r 的最值/方案数;
  2. 初始化:长度 len=1(单个元素)的区间,dp[i][i] 赋初始值;
  3. 循环:外层区间长度 len 从 2~n;中层枚举左端点 l;计算 r=l+len-1;
  4. 枚举分割点 k ∈ [l, r-1],状态转移;
  5. 答案为 dp[1][n](整个大区间)。

2. 模型 1:石子合并(区间 DP 入门模板)

问题描述

一排 n 堆石子,每次合并相邻两堆,代价是两堆石子总数,求把所有石子合并成一堆的最小总代价。

预处理前缀和

sum[i]:前 i 堆石子总和,区间 [l, r] 合并代价增量 = sum[r] - sum[l-1]。

状态定义

dp[l][r]:合并第 l 堆到第 r 堆石子的最小总代价。

转移方程

dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + sum[r] - sum[l-1])
  • dp[l][k]:左半区间合并代价
  • dp[k+1][r]:右半区间合并代价
  • sum[r]-sum[l-1]:当前左右两段合并的一次性代价

边界初始化

所有长度为 1 的区间:dp[i][i] = 0,单堆石子无需合并,代价为 0。

完整模板代码

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;

const int N = 305;
const int INF = 0x3f3f3f3f;
int a[N], sum[N];
int dp[N][N];

int main() {
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        sum[i] = sum[i-1] + a[i];
    }
    // 初始化
    memset(dp, 0x3f, sizeof(dp));
    for (int i = 1; i <= n; i++) dp[i][i] = 0;

    // len 区间长度,从2开始
    for (int len = 2; len <= n; len++) {
        // 左端点
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            // 枚举分割点
            for (int k = l; k < r; k++) {
                dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + sum[r] - sum[l-1]);
            }
        }
    }
    cout << dp[1][n] << endl;
    return 0;
}

3. 模型 2:括号匹配(最长合法括号序列)

问题描述

给定由 () 组成的字符串,求最长合法括号子序列长度。

状态定义

dp[l][r]:字符串 l~r 区间内最长合法括号长度。

转移规则

  1. 若 s[l] 和 s[r] 配对 ():dp[l][r] = dp[l+1][r-1] + 2
  2. 枚举分割点 k:dp[l][r] = max(dp[l][r], dp[l][k] + dp[k+1][r])

核心代码片段

string s;
cin >> s;
int n = s.size();
for (int len = 2; len <= n; len++) {
    for (int l = 0; l + len - 1 < n; l++) {
        int r = l + len - 1;
        if (s[l] == '(' && s[r] == ')') { //看首尾配对的情况 比如: (A) 
            dp[l][r] = dp[l+1][r-1] + 2;
        }
        for (int k = l; k < r; k++) {//分割是为了包含拼接的情况 比如()()
            dp[l][r] = max(dp[l][r], dp[l][k] + dp[k+1][r]);
        }
    }
}

修改成求子串(要求连续)

// 求最长合法括号子串(必须连续)
for (int len = 2; len <= n; len++) {
    for (int l = 0; l + len - 1 < n; l++) {
        int r = l + len - 1;
        // 只有当内部完全合法(长度等于 len-2)时,才能配对
        if (s[l] == '(' && s[r] == ')' && dp[l+1][r-1] == len - 2) {
            dp[l][r] = dp[l+1][r-1] + 2;
        }
        // 拼接依然需要,用于处理 "()()" 这种连续并列
        for (int k = l; k < r; k++) {
            dp[l][r] = max(dp[l][r], dp[l][k] + dp[k+1][r]);
        }
    }
}

4. 区间 DP 易错点总结

  1. 初始化无穷值:求最小值时 dp 数组初始化为 INF,求最大值初始化为 0;
  2. 遍历顺序不能颠倒:必须先短区间,后长区间;不能直接 l 从 1~n 循环;
  3. 分割点范围:k ∈ [l, r-1],不能取到 r,否则右区间为空;
  4. 环形区间处理(石子合并环形):数组复制一倍 a[n+i]=a[i],区间长度不超过 n。

五、DP 解题通用步骤

  1. 读题抓核心:明确问题是求最大值、最小值还是方案数。
  2. 定义状态:用数组下标对应题目中的变量,清晰写出 dp 数组的含义。
  3. 推导转移:思考“最后一步怎么来的”,找到子问题到原问题的关系。
  4. 处理边界:确定初始值和数组下标范围,防止越界。
  5. 确定遍历顺序:
    • 线性 DP:从小到大递推;
    • 01 背包:容量逆序;完全背包:容量顺序;
    • 区间 DP:先枚举区间长度,再枚举左右端点。
  6. 输出答案:根据状态定义,找到最终结果对应的 dp 数组位置。