动态规划

DP 问题的一般思路

  • DP 定义 ——有时 DP 的更新很难严格遵循定义,需要额外变量保存全局最优结果

  • 初始化 ——初始值可以通过一个简单的特例来确定

  • 递推公式 + 边界条件

  • DP 优化 (可选)

Reference

Index

背包问题

【注】关于“恰好装满”

  • 如果要求恰好装满背包,可以在初始化时将 dp[0] / dp[i][0] 初始化 0,其他初始化为 -INF。这样即可保证最终得到的 dp[N] / dp[N][M] 是一种恰好装满背包的解;

  • 如果不要求恰好装满,则全部初始化为 0 即可。

  • 可以这样理解:初始化的 dp 数组实际上就是在没有任何物品可以放入背包时的合法状态。

    • 如果要求背包恰好装满,那么此时只有容量为 0 的背包可能被价值为 0 的物品“恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,它们的值就都应该是 -INF

    • 如果背包并非必须被装满,那么任何容量的背包都有一个合法解,即“什么都不装”,这个解的价值为0,所以初始时状态的值也全部为 0 。

01 背包

HDOJ - 2602

问题描述

二维 DP(无优化)

  • 定义dp[i][j] := 从前 i 个物品中选取总重量不超过 j 的物品时总价值的最大值

    i 从 1 开始计,包括第 i 个物品

  • 初始化

  • 状态转移

    ```C++ // HDOJ 地址:http://acm.hdu.edu.cn/showproblem.php?pid=2602 int solve(int N, int V, vector& v, vector& w) {

    vector > dp(N + 1, vector(V + 1, 0)); // 不要求装满,初始化为 0 即可

    // 核心代码 for (int i = 1; i <= N; i++) { for (int j = 0; j <= V; j++) { // 可能存在重量为 0,但有价值的物品 if (w[i] > j) // 如果当前物品的重量大于剩余容量 dp[i][j] = dp[i - 1][j]; else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]); } } return dp[N][V]; }

int main() { int T; // 用例数 scanf("%d", &T); while (T--) { int N, V; // N: 物品数量;V: 背包容量 scanf("%d%d", &N, &V); vector v(N + 1, 0); // 保存每个物品的价值 vector w(N + 1, 0); // 保存每个物品的重量 for (int i = 1; i <= N; i++) scanf("%d", &v[i]); for (int i = 1; i <= N; i++) scanf("%d", &w[i]);

}

一维 DP

  • 定义dp[j] := 重量不超过 j 公斤的最大价值

  • 递推公式

完全背包

NYOJ - 311

问题描述

二维 DP(无优化)

  • 直观思路:在 01 背包的基础上在加一层循环

  • 递推关系

    • 关于 k 的循环最坏可能从 0 到 V,因此时间复杂度为 O(N*V^2)

  • 注意到

  • 完整代码

    • 注意,这里要求的是恰好装满时的情况,所以需要将 dp[i][0] 全部初始化为 0,其他初始化为 -INF

      以下代码因超内存无法通过 NYOJ 311;

      可以 AC 的代码,请参考 完全背包(一维 DP)完全背包(滚动数组)

      ```C++ // NYOJ 311 会报超内存,所以无法测试

      include

      include

      include

      using namespace std;

      const int inf = 0x80000000;

      void solve() { int T; scanf("%d", &T); while (T--) { int N, V; // N 表示物品种类的数目,V 表示背包的总容量 scanf("%d%d", &N, &V); vector w(N + 1), v(N + 1); // w 表示重量,v 表示价值 for (int i = 1; i <= N; i++) scanf("%d%d", &w[i], &v[i]);

      }

      int main() { solve(); return 0; }

      ```

二维 DP(滚动数组)

一维 DP

  • 核心代码与 01 背包一致,只有第二层循环的递推方向不同

  • 完整代码

多重背包 TODO

硬币问题

硬币找零

LeetCode - 322. 零钱兑换

问题描述

思路

  • 定义dp[i] := 组成总金额 i 时的最少硬币数

  • 初始化

  • 状态转移

C++

硬币组合

LeetCode - 518. 零钱兑换 II

C++

最长公共子序列(LCS)

最长公共子序列_牛客网

  • 求两个序列的最长公共字序列

    • 示例:s1: "BDCABA" 与 s2:"ABCBDAB" 的一个最长公共字序列为 "BCBA"

    • 最长公共子序列不唯一,但是它们的长度是一致的

    • 子序列不要求连续

思路

  • DP 定义

    • s[0:i] := s 长度为 i 的**前缀**

    • 定义 dp[i][j] := s1[0:i] 和 s2[0:j] 最长公共子序列的长度

  • DP 初始化

  • DP 更新

    • s1[i] == s2[j]

    • s1[i] != s2[j]

  • 完整递推公式

  • Code - C++

最长公共子串

最长公共子串_牛客网

题目描述

思路 - 暴力求解

Longest common substring problem - Wikipedia

暴力求解思路:每当找到一对元素相同时就斜向比较

  • 注意:如果两个串完全相同的话,时间复杂度将退化为 O(N^3)

思路 - DP

  • DP 定义

    • s[0:i] := s 长度为 i 的**前缀**

    • 定义 dp[i][j] := s1[0:i] 和 s2[0:j] 最长公共子串的长度

    • dp[i][j] 只有当 s1[i] == s2[j] 的情况下才是 s1[0:i] 和 s2[0:j] 最长公共子串的长度

  • DP 初始化

  • DP 更新

  • Code

  • DP 优化:空间复杂度 O(N)

    • 好不容易找到的优化为 O(N) 的代码;多数优化直接优化到了 O(1)

    • 因为内层循环是逆序的,所以有点不好理解,可以画一个矩阵手推 DP 的更新过程,很巧妙

  • DP 优化:空间复杂度 O(1)

    • 两个字符串的比较总是按一行一行或一列一列来比较,因此至少要保存一行的数据

    • 而如果是按照斜向遍历,其实只要保存一个数据即可

    斜向遍历的策略很多,下面的代码是从右上角(row=0, col=m-1)开始遍历

    上述代码其实就是把下面的两段循环合并了

最长递增子序列(LIS)

最长递增子序列_牛客网

最长上升子序列 - LeetCode

牛客假设给定的数组中不存在重复元素,LeetCode 可能存在重复元素

问题描述

思路0 - O(N^2)

  • LIS 可以转化成 LCS (最长公共子序列) 问题

  • 用另一个序列保存给定序列的排序结果 - O(NlogN)

  • 则问题转化为求这两个序列的 LCS 问题 - O(N^2)

思路1 - O(N^2)解法

  • DP 定义

    • nums[0:i] := 序列 nums 的前 i 个元素构成的子序列

    • 定义 dp[i] := nums[0:i] 中 LIS 的长度

    • 实际并没有严格按照这个定义,中间使用一个变量记录当前全局最长的 LIS

  • DP 初始化

  • DP 更新 - O(N^2)的解法

    如果只看这个递推公式,很可能会写出如下的错误代码

    错误代码(点击展开) ```C++ // 牛客网 class AscentSequence { public: int findLongest(vector nums, int n) { vector dp(n, 1); for (int i = 1; i nums[j]) dp[i] = max(dp[i], dp[j] + 1); else dp[i] = max(dp[i], dp[j]); } return dp[n-1]; } }; ``` - 这段代码的问题在于 `dp[i]` 应该等于 `max{dp[j]}` 对应的那个 `dp[j]+1`,且**只增加一次** - 这么写可能会导致 `dp[i]` 被增加多次 > [动态规划求解最长递增子序列的长度 - hapjin](https://www.cnblogs.com/hapjin/p/5597658.html) - 博客园

  • 下面是网上比较流行的一种递推公式

    • 注意:此时并没有严格按照定义处理 dp,它只记录了当 nums[i] > nums[j] && dp[i] < dp[j] + 1 时的 LIS;不满足该条件的情况跳过了;所以需要额外一个变量记录当前已知全局的 LIS

  • Code

思路2 - O(NlogN)

  • 该解法的思想是:长度为 i 的 LIS 的尾元素应该大于长度为 i-1 的尾元素

  • DP 定义

    • 定义 dp[i] := 长度为 i 的 LIS 的最小尾元素

  • DP 更新

    • 二分查找 nums[j] 在 dp 中的 upper_bound 位置 lower_bound 位置

      • upper_bound 位置指的是序列中第一个大于 nums[j] 的元素所在的位置

      • lower_bound 位置指的是序列中第一个大于等于 nums[j] 的元素所在的位置

      • C++ 中分别实现了 upper_bound 和 lower_bound,定义在 <algorithm>

      • 如果在末尾,则插入;反之则替换

    • upper_bound 只能用于不存在重复元素的情况;而 lower_bound 可以兼容两种情况

  • Code

最长回文子序列

最长回文子序列 - LeetCode

问题描述

思路

  • 相比最长回文子串,最长回文子序列更像最长公共子序列,只是改变了循环方向

  • DP 定义

    • s[i:j] := 字符串 s 在区间 [i:j] 上的子串

    • 定义 dp[i][j] := s[i:j] 上回文序列的长度

  • DP 初始化

  • DP 更新

  • Code

最长回文子串

最长回文子串_牛客网

最长回文子串 - LeetCode

牛客网只需要输出长度;LeetCode 还需要输出一个具体的回文串

问题描述

思路 - O(N^2)

  • DP 定义

    • s[i:j] := 字符串 s 在区间 [i:j] 上的子串

    • 定义 dp[i][j] := s[i:j] 是否是一个回文串

  • DP 初始化

  • DP 更新

  • Code

Manacher 算法 - O(N)

算法-最长回文子串(Manacher算法) - 琼珶和予 - 博客园

最大连续子序列和

最大连续子序列_牛客网

牛客网要求同时输出最大子序列的首尾元素

思路 - 基本问题:只输出最大连续子序列和

  • DP 定义

    • a[0:i] := 序列 a 在区间 [0:i] 上的子序列

    • 定义 dp[i] := a[0:i] 上的最大子序列和

    • 实际并没有严格按照上面的定义,中间使用一个变量记录当前全局的最大连续子序列和

  • DP 初始化

  • DP 更新

    直观实现-无优化-空间复杂度`O(N)`(点击展开) ```C++ void foo() { int n; while (cin >> n) { vector a(n); for (int i = 0; i> a[i]; vector dp(n); dp[0] = a[0]; int ret = a[0]; for (int i = 1; i 0) dp[i] = dp[i - 1] + a[i]; else dp[i] = a[i]; ret = max(ret, dp[i]); } cout<< ret << endl; } }/* 输入 5 1 5 -3 2 4 6 1 -2 3 4 -10 6 4 -3 -1 -2 -5 输出 9 7 -1 */```

  • DP 优化

    注意到每次递归实际只用到了 dp[i-1],实际只要用到一个变量,空间复杂度 O(1)

思路 - 输出区间/首尾

  • 增加两个变量即可

  • 注意:题目要求,如果序列中全是负数,则输出 0,以及整个序列的首尾元素

编辑距离

LeetCode-编辑距离

问题描述

  • 注意:编辑距离指的是将 word1 转换成 word2

思路

  • 用一个 dp 数组维护两个字符串的前缀编辑距离

  • DP 定义

    • word[0:i] := word 长度为 i 的**前缀子串**

    • 定义 dp[i][j] := 将 word1[0:i] 转换为 word2[0:j] 的操作数

  • 初始化

  • 递推公式

    • word1[i] == word1[j]

    • word1[i] != word1[j] 时,有三种更新方式,取最小

  • C++

  • DP 优化

    • 注意到每次更新 dp[i][j] 只需要用到 dp[i - 1][j - 1], dp[i][j - 1], dp[i - 1][j]。因此实际上不需要用到二维 DP

    • 具体见下方代码

    Code - 优化为一维 DP(点击展开) ```C++ class Solution { public: int minDistance(string word1, string word2) { int m = word1.length(), n = word2.length(); vector cur(m + 1, 0); for (int i = 1; i

矩阵中的最大正方形

LeetCode-221. 最大正方形

问题描述

思路

  • DP 定义dp[i][j] := 以 M[i][j] 为正方形**右下角**所能找到的最大正方形的边长

    • 注意保存的是边长

    • 因为 dp 保存的不是全局最大值,所以需要用一个额外变量更新结果

  • 初始化

  • 递推公式

    注意到,本题的递推公式与 编辑距离 完全一致

C++

鹰蛋问题

Power Eggs http://acm.zcmu.edu.cn/JudgeOnline/problem.php?id=1894

问题描述

分析

  • 如果只有 M=1 个蛋,那么只能从第一层开始一层一层往上尝试,最坏情况下的最少次数为 N

  • 如果蛋的数量足够多,那么问题转变为二分查找,最坏情况下的最少次数为 logN 上取整

思路

  • DP 定义dp[i][j] := i 个蛋比较 j 次所能确定的最高楼层

  • DP 初始化

  • DP 更新

    说明:TODO(不理解是如何得到这个递推式的)

  • C++

Reference

矩阵链乘法 TODO

有代价的最短路径 TODO

瓷砖覆盖(状态压缩DP) TODO

工作量划分 TODO

三路取苹果 TODO

最后更新于

这有帮助吗?