动态规划DP

​

 此博客参考Partitioning by Palindromes - UVa 11584 - EdwinAze

动态规划(Dynamic Programming, DP)‌。

‌核心思想:‌

动态规划是一种解决‌复杂问题‌的高效算法设计范式。它的核心思想是‌将问题分解成相互重叠的子问题,并将子问题的解存储起来(记忆化),避免重复计算,从而大幅提升效率。‌

你可以把它看作一种‌用空间换时间‌的优化策略。

‌为什么需要动态规划?‌

许多看似复杂的问题(尤其是优化问题,如求最大值、最小值、方案数等)可以被分解成更小的子问题。‌如果这些子问题不是独立的,而是会被重复计算多次‌,那么使用普通的递归(自顶向下)会导致指数级的时间复杂度。

1.入门动态规划DP

例题:

1.一维DP

题目描述

今有面值为 1、5、11 元的硬币各无限枚。

想要凑出 n 元,问需要的最少硬币数量。

输入格式

仅一行,一个正整数 n。

输出格式

仅一行,一个正整数,表示需要的硬币个数。

输入输出样例

输入

15

输出

3

说明/提示

样例解释

对于样例数据 1,最佳方案是 15=5+5+5,使用到 3 枚硬币。

对于样例数据 2,最佳方案是 12=11+1,使用到 2 枚硬币。

数据规模与约定

对于 100% 的数据,保证 n≤106。

问题分析

这是一个典型的‌动态规划问题‌,属于‌最优化问题‌。我们需要用最少数量的硬币凑出给定的金额n,硬币面值为1、5、11元且数量无限。

动态规划思路

  1. ‌定义状态‌:

    • dp[i]表示凑出金额i所需的最少硬币数量
  2. ‌状态转移方程‌:

    • 对于每个金额i,考虑使用三种硬币中的一种:
      • 如果用1元硬币:dp[i] = dp[i-1] + 1
      • 如果用5元硬币:dp[i] = dp[i-5] + 1 (当i≥5时)
      • 如果用11元硬币:dp[i] = dp[i-11] + 1 (当i≥11时)
    • 取这三种情况的最小值:
      dp[i] = min(dp[i-1], dp[i-5], dp[i-11]) + 1
  3. ‌初始化‌:

    • dp[0] = 0(凑出0元需要0个硬币)
    • 其他初始值可以设为一个大数(如INT_MAX),表示暂时无法凑出
  4. ‌计算顺序‌:

    • 从小到大计算,从1计算到n,确保计算dp[i]时所需的子问题都已解决

参考代码:

#include <iostream>
#include <climits>
#include <algorithm>
using namespace std;
const int MAX_N = 1e6 + 5;  // 根据题目n≤1e6设定
int dp[MAX_N];
int main() {
    int n;
    cin >> n;
    fill(dp, dp + n + 1, INT_MAX);  // 初始化所有值为不可达
    dp[0] = 0;  // 初始化
    for (int i = 1; i <= n; ++i) {
        if (i >= 1) dp[i] = min(dp[i], dp[i-1] + 1);
        if (i >= 5) dp[i] = min(dp[i], dp[i-5] + 1);
        if (i >= 11) dp[i] = min(dp[i], dp[i-11] + 1);
    }
    cout << dp[n] << endl;
    return 0;
}

二维DP

题目描述

有一个机器人的位于一个 m × n 个网格左上角。

机器人每一时刻只能向下或者向右移动一步。机器人试图达到网格的右下角。

问有多少条不同的路径?

n和m均不超过100

输入格式

n 和 m

输出格式

路径条数

样例

样例输入
1 3
样例输出
1

 

核心思路

这是一个典型的‌网格路径计数问题‌,可以通过动态规划高效解决‌。机器人只能向右或向下移动,因此每个位置的路径数等于上方和左方位置路径数之和‌。

关键步骤

  1. ‌状态定义‌

    • 使用二维数组dp[i][j]表示从起点到网格(i,j)的路径数‌35。
  2. ‌状态转移方程‌

    • 对于非边界位置:dp[i][j] = dp[i-1][j] + dp[i][j-1]
    • 边界条件(第一行/列):路径数恒为1(只能单向移动)‌。
  3. ‌初始化‌

    • dp[j] = 1(第一行)
    • dp[i] = 1(第一列)‌。

参考代码: 

#include <iostream>
using namespace std;
const int MAX_SIZE = 100;
int dp[MAX_SIZE][MAX_SIZE];
int uniquePaths(int m, int n) {
    // 初始化第一列
    for(int i = 0; i < m; ++i) dp[i][0] = 1;
    // 初始化第一行
    for(int j = 0; j < n; ++j) dp[0][j] = 1;
    // 动态规划填充
    for (int i = 1; i < m; ++i) {
        for (int j = 1; j < n; ++j) {
            dp[i][j] = dp[i-1][j] + dp[i][j-1];
        }
    }
    return dp[m-1][n-1];
}
int main() {
    int m, n;
    cin >> m >> n;
    cout << uniquePaths(m, n) << endl;
    return 0;
}

2.基本线性DP

例题:

题目描述

给定一个序列,从中选取若干个数,使得这一组数组成的序列a满足 i < j 且 a[i] < a[j],求这个序列的最长长度

输入格式

第一行一个整数n

接下来一行n个整数表示序列

输出格式

一行一个整数表示最长序列的长度

样例

样例输入

5
1 5 1 2 4

样例输出

3

数据范围与提示

组成的数列为1 2 4

对于30%的数据, n <= 2000

对于100%的数据,n <= 100000

问题理解

题目要求找出给定序列中最长的严格递增子序列的长度。子序列不需要连续,但必须保持原序列中的相对顺序‌。

动态规划解法(O(n²))

  1. ‌状态定义‌

    • dp[i]表示以第i个元素结尾的最长递增子序列长度‌。
  2. ‌状态转移‌

    • 对于每个元素,检查前面所有比它小的元素,取最大dp值加1:

      dp[i] = max(dp[i], dp[j] + 1) // 对所有j < i且nums[j] < nums[i]

  3. ‌边界条件‌

    • 初始时每个元素的dp值至少为1(自身构成子序列)‌。

优化解法(O(n logn))

  1. ‌贪心+二分查找‌

    • 维护一个动态数组tail,tail[i]表示长度为i+1的子序列的最小末尾元素‌。
    • 遍历时用二分查找确定当前元素的插入位置,保持数组有序‌。
  2. ‌关键操作‌

    • 若当前元素大于tail末尾元素,直接追加‌。
    • 否则替换第一个大于等于它的元素‌。
#include<bits/stdc++.h>
using namespace std;
int main(){
    int n, a[100010], dp[100010] = {0};  // dp数组存储不同长度LIS的最小末尾
    cin >> n;
    for(int i=0; i<n; i++) cin >> a[i];
    int len = 0;  // 当前LIS长度
    for(int i=0; i<n; i++){
        // 二分查找插入位置
        int pos = lower_bound(dp, dp+len, a[i]) - dp;
        dp[pos] = a[i];  // 更新或扩展
        if(pos == len) len++;
    }
    cout << len;
    return 0;
}

思考1:如果要输出最靠前的一条路径怎么办?

 思考2:如果是环形的怎么办?

(需要参考代码评论)


3.划分型动态规划

例题:

题目描述

我们说一个字符序列是回文的,如果它正向和反向写都是一样的。例如,“racecar”就是一个回文,但是“fastcar”不是。

一个字符序列的划分是指一个由一个或多个不相交的、非空的、连续字符组成的列表,这些字符组拼接起来可以得到原始序列。例如,(‘race’, ‘car’)是“racecar”的划分成两组的一个例子。

给定一个字符序列,我们总是可以创建这些字符的一个划分,使得划分中的每一组都是回文的!基于这个观察,很自然会问:

给定一个字符串,使得每一组都是回文的,需要的最小组数是多少?

例如:

• “racecar”已经是一个回文,因此它可以被划分成一组。

• “fastcar”不包含任何非平凡的回文,所以它必须被划分为(‘f’, ‘a’, ‘s’, ‘t’, ‘c’, ‘a’, ‘r’)。

• “aaadbccb”可以被划分为(‘aaa’, ‘d’, ‘bccb’)。

输入格式

输入开始于测试案例的数目n。

每个测试案例由一行由1到1000个小写字母组成,中间没有空白。

输出格式

对于每个测试案例,在一行中输出将输入划分成回文组所需要的最小组数。

样例

输入样例

3
racecar
fastcar
aaadbccb

输出样例

1
7
3

思路

状态表示: f[i] 是 1~i 中回文串个数
状态计算: f[i] = min(f[i], f[j] + 1 ) if i~j 是一个回文串

所以先用n^2把这个串中的所有回文字串标识出来。

bool is_palindrome(int i, int j)
{
	if(i >= j) return true;
	if(s[i] != s[j]) return false;

	if(st[i][j] == kase) return p[i][j];
	st[i][j] = kase;
	p[i][j] = is_palindrome(i+1,j-1);
	return p[i][j];
}

这里的 st 只是为了确保p数组中的值是当前数据组的情况, 正常的判断回文数也可以给st去掉然后每组数据重置一下p数组就行。建议记下来以后判断回文串除了双指针就用递归整。

代码

const int N = 1e3 + 10;
char s[N];
bool p[N][N];
int st[N][N];
int f[N];
int n, kase = 1;

int ispalind(int i, int j)
{
    if (i >= j)
        return 1;
    if (s[i] != s[j])
        return 0;

    if (st[i][j] == kase)
        return p[i][j];
    st[i][j] = kase;
    p[i][j] = ispalind(i + 1, j - 1);
    return p[i][j];
}

int main()
{
    int T;
    cin >> T;
    while (T--)
    {
        scanf("%s", s + 1);
        n = strlen(s + 1);
        f[0] = 0;
        for (int i = 1; i <= n; i++)
        {
            f[i] = i + 1;
            for (int j = 0; j < i; j++)
                if (ispalind(j + 1, i))
                    f[i] = min(f[i], f[j] + 1);
        }
        cout << f[n] << endl;
        kase++;
    }
    return 0;
}

 4.背包问题

 4.1 01背包

题目描述

有一个最多能装m(1<m<=30)千克的背包,有n(1<n<=15)块魔法石,它们的重量分别是W1,W2,...,Wn,它们的价值分别是C1,C2,...,Cn。若每种魔法石只有一件,问能装入的最大总价值。

输入格式

第一行为两个整数m和n,以下n行中,每行两个整数Wi,Ci,分别代表第i件物品的重量和价值。

输出格式

输出一个整数,即最大价值。

样例

样例输入

8 3
2 3
5 4
5 5

样例输出

8

这个问题是经典的01背包问题,我们需要在不超过背包承重的情况下,选择若干魔法石使得总价值最大。采用动态规划解法,思路如下:

核心思路

  1. ‌状态定义‌:dp[j] 表示背包容量为 j 时能装入的最大价值
  2. ‌状态转移‌:考虑每个物品时,有两种选择:
    • 不放入:dp[j] 保持不变
    • 放入:dp[j] = dp[j - w] + c(需满足 j >= w)
  3. ‌状态更新‌:dp[j] = max(dp[j], dp[j - w] + c)

算法特点

  • 时间复杂度:O(n*m)
  • 空间复杂度:O(m)
  • 使用一维数组优化空间复杂度
  • 内层循环倒序更新(确保物品只使用一次)

参考代码: 

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

int main() {
    int m, n;
    cin >> m >> n;
    
    // 初始化DP数组
    int dp[31] = {0};  // m最大为30,数组大小31
    
    // 处理每块魔法石
    for (int i = 0; i < n; i++) {
        int w, c;
        cin >> w >> c;
        
        // 倒序更新DP数组(确保物品只使用一次)
        for (int j = m; j >= w; j--) {
            dp[j] = max(dp[j], dp[j - w] + c);
        }
    }
    
    cout << dp[m] << endl;
    return 0;
}

4.2 完全背包

题目描述

话说张琪曼和李旭琳又发现了一处魔法石矿(运气怎么这么好?各种嫉妒羡慕恨啊),她们有一个最多能装m公斤的背包,现在有n种魔法石,每种的重量分别是W1,W2,…,Wn,每种的价值分别为C1,C2,…,Cn。若每种魔法石的个数足够多,求她们能获得的最大总价值。

输入格式

第一行为两个整数,即m,n。

以后每行为两个整数,表示每块魔法石的重量和价值。

输出格式

获得的最大总价值。

样例

样例输入

5 5
1 1
2 2
3 3
4 4
5 5

样例输出

5

这个问题是典型的完全背包问题,与01背包问题不同,每种物品可以取无限多个。我们需要在背包容量限制下最大化总价值。

核心思路

  1. ‌状态定义‌:dp[j] 表示背包容量为 j 时能装入的最大价值
  2. ‌状态转移‌:对于每种物品(重量 w,价值 c)
    • dp[j] = max(dp[j], dp[j - w] + c)
  3. ‌关键点‌:内层循环正序更新(确保物品可重复使用)

参考代码: 

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

int main() {
    int m, n;
    cin >> m >> n;
    
    int dp[100001] = {0};  // 背包容量为j时的最大价值
    
    // 处理每种魔法石
    for (int i = 0; i < n; i++) {
        int w, c;
        cin >> w >> c;
        
        // 正序更新(关键点:使物品可重复使用)
        for (int j = w; j <= m; j++) {
            dp[j] = max(dp[j], dp[j - w] + c);
        }
    }
    
    cout << dp[m] << endl;
    return 0;
}

4.3 多重背包

​编辑

​

这个问题是多重背包问题的变种,需要处理物品数量限制的情况。与完全背包不同,每种物品有数量上限si。我们采用动态规划解法,通过二进制优化将多重背包转化为01背包问题‌。

核心思路

  1. ‌状态定义‌:dp[j]表示背包容量为j时的最大价值
  2. ‌二进制优化‌:将si个物品拆分为1,2,4...2^k的组合,转化为01背包问题‌
  3. ‌状态转移‌:
    • 不选:dp[j]保持不变
    • 选:dp[j] = max(dp[j], dp[j - w] + v)

算法特点

  • ‌时间复杂度‌:O(N×V×logS)
  • ‌空间优化‌:使用一维数组逆序更新
  • ‌关键点‌:二进制拆分减少物品数量‌

参考代码: 

#include <iostream>
#include <algorithm>
using namespace std;
const int MAXN = 101, MAXV = 101;
int dp[MAXV], v[MAXN], w[MAXN], s[MAXN];
int main() {
    int N, V;
    cin >> N >> V;
    // 二进制拆分预处理
    int cnt = 0;
    for (int i = 1; i <= N; i++) {
        int a, b, c;
        cin >> a >> b >> c;
        int k = 1;
        while (k <= c) {
            v[++cnt] = a * k;
            w[cnt] = b * k;
            c -= k;
            k *= 2;
        }
        if (c > 0) {
            v[++cnt] = a * c;
            w[cnt] = b * c;
        }
    }
    // 01背包处理
    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;
}

4.4 多重背包的二进制优化

例题:

​编辑

  1. ‌问题理解‌:这是一个多重背包问题,要求在给定容量的背包中选择物品,使得总价值最大。每种物品有数量限制。

  2. ‌动态规划定义‌:使用二维数组dp,其中dp[i][j]表示前i种物品在容量为j时的最大价值。

  3. ‌状态转移‌:对于每种物品,遍历其所有可能的数量,更新dp数组。具体为dp[i][j] = max(dp[i][j], dp[i-1][j-v[i]*k] + w[i]*k),其中k表示当前物品的数量。

  4. ‌初始化‌:dp[0][j] = 0,表示没有物品时背包的价值为0。

参考代码 :

#include <iostream>
#include <algorithm>
using namespace std;
const int MAX_N = 1005;  // 最大物品数
const int MAX_V = 1005;  // 最大背包容量
int main() {
    int N, V;  // N-物品数量 V-背包容量
    cin >> N >> V;
    // 静态数组声明
    int v[MAX_N];  // 物品体积数组
    int w[MAX_N];  // 物品价值数组
    int dp[MAX_N][MAX_V] = {0};  // DP状态数组
    // 输入物品数据
    for(int i = 1; i <= N; i++) {
        cin >> v[i] >> w[i];  // 输入第i件物品的体积和价值
    }
    // 动态规划处理
    for(int i = 1; i <= N; i++) {  // 遍历每个物品
        for(int j = 0; j <= V; j++) {  // 遍历每种背包容量
            if(j < v[i]) {
                // 当前物品体积超过背包容量,不能放入
                dp[i][j] = dp[i-1][j];  // 继承前i-1个物品的最优解
            } else {
                // 可以放入当前物品,比较放入和不放入的价值
                dp[i][j] = max(
                    dp[i-1][j],  // 不放入当前物品
                    dp[i-1][j-v[i]] + w[i]  // 放入当前物品
                );
            }
        }
    }
    cout << dp[N][V] << endl;  // 输出最优解
    return 0;
}

 课后思考:在二进制优化的基础上使用单调队列优化

​编辑

4.5 变式

 

 ​编辑

  1. ‌定义变量‌:

    • 使用静态数组 dp 来存储动态规划的结果,其中 dp[i][j] 表示前 i 个物品在容量为 j 的背包中能获得的最大价值。
  2. ‌输入读取‌:

    • 读取物品数量 N、背包容量(虽题目给出但此问题中未使用)V 和最大重量 M。
    • 读取每个物品的体积 v、重量 m 和价值 w,但注意此问题中仅重量 m 和价值 w 被用于动态规划。
  3. ‌初始化DP数组‌:

    • 初始化 dp 数组为 (N+1) x (M+1) 的大小,所有元素初始值为0。
  4. ‌动态规划转移‌:

    • 遍历每个物品,对于每个物品,再遍历所有可能的重量 j。
    • 如果当前物品的重量 m[i-1] 大于当前背包重量 j,则不放入背包,dp[i][j] = dp[i-1][j]。
    • 否则,选择放入或不放入当前物品,取两者中的较大值:dp[i][j] = max(dp[i-1][j], dp[i-1][j-m[i-1]] + w[i-1])。
  5. ‌输出结果‌:

    • 输出 dp[N][M],即表示在容量为 M 的背包中能获得的最大价值。

家庭作业

​编辑

5.博弈类动态规划

例题:

​编辑

这道题可以用数学思路做(只需要判断其是否能整除3),但是以下我们还是使用动态规划DP解决‌

‌思路讲解‌:

  1. ‌问题定义‌:给定n个硬币排成一条线,两个玩家轮流从右边拿走1或2个硬币,拿到最后一枚硬币的玩家获胜。判断先手玩家是否必胜。

  2. ‌动态规划‌:定义一个布尔类型的静态数组dp,其中dp[i]表示当有i个硬币时,先手玩家是否必胜。

  3. ‌状态转移‌:对于dp[i],如果先手玩家可以通过拿走1或2个硬币,使得剩下的硬币数量i-1或i-2时后手玩家无法获胜(即dp[i-1]或dp[i-2]为false),则先手玩家必胜,即dp[i] = true。反之,如果后手玩家在i-1或i-2时都能找到必胜策略,则先手玩家必败,即dp[i] = false。这可以简化为dp[i] = !dp[i-1] || !dp[i-2]。

  4. ‌边界条件‌:

    • 当没有硬币时(i=0),先手玩家无法获胜,即dp[0] = false。
    • 当有1个硬币时(i=1),先手玩家直接拿走,必胜,即dp[1] = true。
    • 当有2个硬币时(i=2),先手玩家拿走1个,后手玩家无论拿1个还是2个(实际上只能拿1个),先手玩家都能拿走最后一个,必胜,即dp[2] = true。

 参考代码:

#include <iostream>
using namespace std;
bool canWin(int n) {
    bool dp[n + 1]; // 定义一个静态数组dp
    dp[0] = false;
    dp[1] = true;
    dp[2] = true;
    for (int i = 3; i <= n; ++i) {
        dp[i] = !dp[i - 1] || !dp[i - 2]; // 状态转移方程
    }
    return dp[n];
}
int main() {
    int n;
    cin >> n;
    cout << (canWin(n) ? "true" : "false") << endl;
    return 0;
}

6.双序列问题

​编辑
  1. ‌问题理解‌:

    • 给定两个字符串序列X和Y,要求找出它们的最长公共子序列(LCS)。
    • 子序列是指从原序列中删除若干元素(不改变顺序)后得到的序列。
  2. ‌动态规划思想‌:

    • 使用一个二维数组dp,其中dp[i][j]表示X的前i个字符和Y的前j个字符的最长公共子序列长度。
    • 初始化dp[0][j]和dp[i][0]为0,因为空序列与任何序列的最长公共子序列长度为0。
  3. ‌状态转移‌:

    • 若X[i-1] == Y[j-1],则dp[i][j] = dp[i-1][j-1] + 1,因为当前字符匹配,长度加1。
    • 若不匹配,则dp[i][j] = max(dp[i-1][j], dp[i][j-1]),取不包含当前字符的两种情况中的最大值。

 参考代码:谪自

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+1;
int n,a[N],b[N],pos[N],s[N],t[N],ans;
//pos 记录数字在第二个排列中的位置(数字范围 1~n)
//t 数组:t[i] 表示长度为 i+1 的递增子序列末尾最小的值
//ans 为最长递增子序列的当前长度
int main(){
    cin>>n;
    for(int i=0;i<n;i++) cin>>a[i];
    for(int i=0;i<n;i++){
        cin>>b[i];
        pos[b[i]]=i;//建立映射
    }
    for(int i=0;i<n;i++) s[i]=pos[a[i]];
    for(int i=0;i<n;i++){
        if(!ans||s[i]>t[ans-1]) t[ans++] = s[i];//当前元素比所有尾部元素都大,直接追加
        else{
            //使用二分查找在 t 中寻找第一个大于等于 s[i] 的位置
            int l=0,r=ans-1;
            while(l<=r){
                int mid=l+(r-l)/2;
                if(t[mid]>=s[i]) r=mid-1;
                else l=mid+1;
            }
            t[l]=s[i];//更新找到的位置
        }
    }
    //输出最长公共子序列的长度,即为 LIS 的长度
    cout<<ans;
}

7.区间DP

​编辑

 

  1. ‌问题定义‌:给定一个字符串S,长度是N,目标是找到它最长的回文子序列的长度。

  2. ‌动态规划方法‌:

    • 使用二维数组dp,其中dp[i][j]表示字符串S从位置i到位置j的子串是否为回文串。
    • 初始化:所有长度为1的子串都是回文串,即dp[i][i] = true。
  3. ‌状态转移‌:

    • 如果S[i] == S[j]且j - i <= 2(即子串长度为1或2,且两端字符相等),则dp[i][j] = true。
    • 否则,dp[i][j] = dp[i+1][j-1],即检查去掉两端字符后的子串是否为回文串。
  4. ‌计算最长回文子序列长度‌:

    • 在状态转移过程中,如果dp[i][j] = true且子串长度len = j - i + 1大于当前记录的最大长度maxLength,则更新maxLength。

 参考代码

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

int longestPalindrome(string s) {
    int n = s.length();
    bool dp[n][n];
    int maxLength = 1;
    
    // 初始化
    for (int i = 0; i < n; ++i) {
        dp[i][i] = true;
    }
    
    // 动态规划
    for (int len = 2; len <= n; ++len) {
        for (int i = 0; i <= n - len; ++i) {
            int j = i + len - 1;
            if (s[i] == s[j]) {
                if (len == 2 || dp[i+1][j-1]) {
                    dp[i][j] = true;
                    maxLength = max(maxLength, len);
                }
            }
        }
    }
    
    return maxLength;
}

int main() {
    string s;
    cin >> s;
    cout << longestPalindrome(s) << endl;
    return 0;
}

总结: 

核心思想‌

  1. ‌分解子问题‌
    将原问题划分为多个重叠的子问题,通过解决子问题逐步求解原问题‌。
  2. ‌存储子问题解‌
    通过数组或表格存储子问题的解(称为“记忆化”),避免重复计算,提升效率‌。
  3. ‌状态转移方程‌
    定义状态变量(如 dp[i]),建立状态间的递推关系(如 dp[i] = dp[i-1] + dp[i-2])‌。

 

​
posted @ 2025-09-06 16:12  一只小小的芙厨  阅读(134)  评论(0)    收藏  举报