练习题一:切割回文
给出一个字符串S,问对字符串S最少切几刀,使得分成的每一部分都是一个回文串(注意:单一字符是回文串)
- 状态定义:
d p ( i ) : dp(i) : dp(i): 以i位置为结尾,经过最少的切割,可以出现多少回文串? - 状态转移:
d p [ i ] = min ( d p [ j ] ) + 1 ∥ s [ j + 1 ] − > s [ i ] 为 回 文 dp[i] = \min (dp[j]) + 1 \| s[j + 1] -> s[i] 为回文 dp[i]=min(dp[j])+1∥s[j+1]−>s[i]为回文- 根据状态转移,算法时间复杂度为 O ( n 2 ) O(n^2) O(n2)
- 所以,我们需要对转移阶段进行优化
- 动态规划优化章节时,重点解决
- 代码演示
//O(n^2)
#include<iostream>
#include <string>
using namespace std;
#define MAX_N 500000
int dp[MAX_N + 5];//取前i位字符串,g构成的回文串数
bool is_palindrome(string &s, int i, int j) {
while(i < j) {
if (s[i++] - s[j--]) return false;
}
return true;
}
int main() {
string s;
cin >> s;
dp[0] = 0;
for (int i = 1; i <= s.size(); i++) {
dp[i] = dp[i - 1] + 1;
for (int j = 0; j < i; j++) {
if (is_palindrome(s, j, i - 1)) {
dp[i] = min(dp[i], dp[j] + 1);//如果j -> i - 1构成回文回文数 + 1
}
}
}
cout << dp[s.size()] - 1<< endl;//回文串数 = 切的数 - 1
return 0;
}
练习题二:0/1背包
给一个能承重 V V V 的背包,和n件物品,我们用重量和价值的二元组来表示一个物品,第i件物品表示为 ( V i , W i ) (Vi,Wi) (Vi,Wi),问:在背包不超重的情况下,得到物品的最大价值是多少
- 状态定义:
d p [ i ] [ j ] dp[i][j] dp[i][j] 前i件物品,背包上限为j的情况下,能装最大价值
d p [ i ] [ j ] = max { d p [ i − 1 ] [ j ] 没 选 第 i 件 物 品 d p [ i − 1 ] [ j − v [ i ] ] + w [ i ] 选 了 第 i 件 物 品 dp[i][j] = \max\left\{\begin{aligned}&dp[i - 1][j] &没选第i件物品 \\ &dp[i - 1][j - v[i]] + w[i] &选了第i件物品\\ \end{aligned}\right. dp[i][j]=max{ dp[i−1][j]dp[i−1][j−v[i]]+w[i]没选第i件物品选了第i件物品
程序实现一:状态如何定义,程序就如何实现
#include<iostream>
using namespace std;
#define MAX_N 100
#define MAX_V 10000
int v[MAX_N + 5], w[MAX_N + 5];
int dp[MAX_N + 5][MAX_V + 5];
int main() {
int V, n;
cin >> V >> n;
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= V; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= v[i]) {
dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
}
}
}
cout << dp[n][V] << endl;
return 0;
}
优化1:使用滚动数组,对代码进行空间优化
因为代码中,我们一直用到i 和 i - 1,所以我们只需要两行值,所以可以用到滚动数组
#include<iostream>
using namespace std;
#define MAX_N 100
#define MAX_V 10000
int v[MAX_N + 5], w[MAX_N + 5];
int dp[2][MAX_V + 5];
int main() {
int V, n;
cin >> V >> n;
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= V; j++) {
dp[i % 2][j] = dp[(i - 1) - % 2][j];
if (j >= v[i]) {
dp[i % 2][j] = max(dp[i % 2][j], dp[(i - 1) % 2][j - v[i]] + w[i]);
}
}
}
cout << dp[n % 2][V] << endl;
return 0;
}
优化2;将程序中的dp数组变成一维的,并且修改了更新顺序
#include<iostream>
using namespace std;
#define MAX_N 100
#define MAX_V 10000
int dp[MAX_V + 5];
int main() {
int V, n;
cin >> V >> n;
for (int i = 0; i < n; i++) {
int v, w;
cin >> v >> w;
for (int j = V ; j >= v; i--) {
dp[j] = max(dp[j], dp[j - v] + w);
}
}
cout << dp[V] << endl;
return 0;
}
思考:
- j为什么逆序
- 为什么不需要v, w数组
- dp 数组第一维度为什么没了?
1.dp[i][j] 需要参考上一行前面的值dp[i - 1][j - v[i]],如果顺序更新,到这一步的时候,dp[i][j - v[i]]已经被更新了,所以逆序。
2. 商品的空间和价值,没有重复使用,每一轮在观察该物品的v和w的适配
3.这里的状态定义没有改变,他还是二维的,但是在具体代码实现中,不需要二维
练习题三:完全背包
有 N N N种物品和一个容量为 V V V 的背包,每种物品都有无限件可用。
第 i i i 种物品的体积是 C i C_i Ci,价值是 W i W_i Wi。求解在不超过背包容量的情况下,能够获得的最大价值。
- 状态定义:
d p [ i ] [ j ] dp[i][j] dp[i][j] 前i件物品,背包上限为j的情况下,能装最大价值
d p [ i ] [ j ] = max { d p [ i − 1 ] [ j ] 没 选 第 i 件 物 品 d p [ i ] [ j − v [ i ] ] + w [ i ] 选 了 若 干 件 第 i 件 物 品 dp[i][j] = \max\left\{\begin{aligned}&dp[i - 1][j] &没选第i件物品& \\ &dp[i ][j - v[i]] + w[i] &选了若干件第i件物品&\\ \end{aligned}\right. dp[i][j]=max{ dp[i−1][j]dp[i][j−v[i]]+w[i]没选第i件物品选了若干件第i件物品
- 代码实现 : 参考0/1背包程序,将逆向刷表改成正向刷表
#include<iostream>
using namespace std;
#define MAX_V 10000
int dp[MAX_V + 5];
int main() {
int V, n, v, w;
cin >> n >> V;
for (int i = 1; i <= n; i++) {
cin >> v >> w;
for (int j = v; j <= V; j++) {
dp[j] = max(dp[j], dp[j - v] + w);
}
}
cout << dp[V] << endl;
return 0;
}
为什么改成正向?
因为dp[i][j] = dp[i][j - v[i]] + w 依靠的是本行,所以需要提前更新
而0/1背包dp[i][j] 依靠上一行,如果正序,会清理掉上一行的数据
练习四:多重背包
给有一个能承重 V V V 的背包,和 n n n种物品,每种物品的数量有限多,我们用重量、价值和数量的三元组来表示一个物品,第 i i i 件物品表示为 ( V i , W i , S i ) (Vi,Wi,Si) (Vi,Wi,Si),问在背包不超重的情况下,得到物品的最大价值是多少?
问题模型转换:
- 多重背包,每类物品多了一个数量限制
- 01背包,每种物品只有一个
- 将多重背包中的数量限制,当成多个单一物品来处理
- 至此就将多重背包,转成0/1背包问题
#include<iostream>
using namespace std;
#define MAX_V 100000
int dp[MAX_V + 5];
int main() {
int V, n, v, w, s;
cin >> V >> n;
for (int i = 1; i <= n; i++) {
cin >> v >> w >> s;
while(s--) {
for (int j = V ; j >= v ; j--) {
dp[j] = max(dp[j], dp[j - v] + w);
}
}
}
cout << dp[V] << endl;
return 0;
}
练习题五:扔鸡蛋
定义鸡蛋的硬度为 k,则代表鸡蛋最高从 k 楼扔下来不会碎掉,现在给你 n 个硬度相同的鸡蛋,楼高为 m,问最坏情况下最少测多少次,可以测出鸡蛋的硬度。
题目分析: 先假设两个鸡蛋最多第一次从多高的楼层扔下鸡蛋, 假设第一次从k层楼高扔下去,如果碎了,则需要从1楼到 k - 1 层依次仍鸡蛋,题目是最坏情况,则设在 k - 1 层的时候鸡蛋碎开,则一共需要仍 k 次,如果没碎,则下一次在 k + k - 1 层往下扔鸡蛋,如果碎了则在k + 1 到 k + k - 1 层依次扔鸡蛋,需要的最多次数,仍然是 k 次,所以得出,扔鸡蛋的楼层为
- k
- k + k - 1
- k + k - 1 + k - 2
- …
- ( k + 0 ) ∗ k / 2 (k + 0) * k / 2 (k+0)∗k/2 : 等差数列求和的结果需要大于楼层的高度
直到最后一层的层数大于楼层的高度,得出当有两个鸡蛋的时候,最多需要k次- 而当有多个鸡蛋的时候设当有n个鸡蛋和m楼层高度时候
当在k层该鸡蛋碎了,最坏次数是我需要用 n - 1 个鸡蛋来测量剩下的 k - 1 层楼
当在k层该鸡蛋没碎,最坏次数是我需要用n 个鸡蛋来测量剩下的楼层
与此同时我需要找一个最小的k 因为 k 等于测量次数
- 状态定义
dp[n][m] 用n个鸡蛋,测m层楼,最坏情况下最少测多少次 - 状态转移
d p [ n ] [ m ] = min ( max { d p [ n − 1 ] [ k − 1 ] 鸡 蛋 碎 了 d p [ n ] [ m − k ] 鸡 蛋 没 碎 ) dp[n][m] = \min( \max\left\{\begin{aligned}&dp[n - 1][k - 1] &鸡蛋碎了 \\ &dp[n][m - k] &鸡蛋没碎\\ \end{aligned}\right.) dp[n][m]=min(max{ dp[n−1][k−1]dp[n][m−k]鸡蛋碎了鸡蛋没碎) - 代码演示
存在问题:- 程序所使用的存储空间,与楼层数量强相关
- 楼层数量达到了 2 3 1 2^31 231次方,所以在这种状态下一定不可行
- 状态定义不可行,我们就需要优化状态定义
- 时间复杂度是 O ( n × m 2 ) O(n \times m^2) O(n×m2) 当m过大,无法通过时间限制
#include<iostream>
using namespace std;
#define MAX_N 32
#define MAX_M 1000000
int dp[MAX_N + 5][MAX_M + 5];
int main() {
int n, m;
cin >> n >> m;
for (int i = 0; i <= m; i++) dp[1][i] = i;
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= m; j++) {
dp[i][j] = j;
for (int k = 1; k <= j; k++) {
dp[i][j] = min(dp[i][j], max(dp[i - 1][k - 1], dp[i][j - k]) + 1);
}
}
}
cout << dp[n][m] << endl;
return 0;
}
动态规划的优化
一、动态规划优化的分类
- 状态转移过程的优化,不改变状态定义使用一些特殊的数据结构或者算法专门优化转移过程
- 程序实现的优化,例如:01背包问题。状态定义没有变,转移过程也没变
- 状态定义的优化,大量训练,才能培养出来的能力,从源头进行优化
- 状态定义->源头,转移过程->过程,程序实现->结果
程序优化:01背包,钱币问题:滚动数组
一、扔鸡蛋问题的优化
转移过程的优化
d p [ n ] [ m ] = min ( max { d p [ n − 1 ] [ k − 1 ] 鸡 蛋 碎 了 d p [ n ] [ m − k ] 鸡 蛋 没 碎 ) dp[n][m] = \min( \max\left\{\begin{aligned}&dp[n - 1][k - 1] &鸡蛋碎了 \\ &dp[n][m - k] &鸡蛋没碎\\ \end{aligned}\right.) dp[n][m]=min(max{
dp[n−1][k−1]dp[n][m−k]鸡蛋碎了鸡蛋没碎)
转移过程意味着在程序实现中k 从 1开始遍历到 m 层,当确定k时,确定dp[n - 1][k - 1] 随着k值得增加 dp[n][m - k]在减少
思考:
- 在大V型中选最小值,取max值得min值得到dp[n][m] 的值,所以可以直接去掉第三层k循环
拐点: d p [ n − 1 ] [ k − 1 ] ≤ d p [ n ] [ m − k ] dp[n - 1][k - 1] \le dp[n][m - k] dp[n−1][k−1]≤dp[n][m−k] 的最最后一个值 - 在
d p [ n ] [ m 1 − k ] , d p [ n − 1 ] [ k − 1 ] dp[n][m_1 - k], dp[n - 1][k - 1] dp[n][m1−k],dp[n−1][k−1]
d p [ n ] [ m 2 − k ] , d p [ n − 1 ] [ k − 1 ] dp[n][m_2 - k], dp[n - 1][k - 1] dp[n][m2−k],dp[n−1][k−1]
两个式子,若 m 1 < m 2 m_1 < m_2 m1<m2 则在上图中,绿色线条位置不变, m m m 值增大,相当于 m − k m - k m−k 整体减小,相当于 k k k 值增大,则相当于红线整体往右移,则相交点 k 1 ≤ k 2 k_1 \le k_2 k1≤k2
注:楼层是超过一定范围 k k k 值才会变化 - 在 d p [ n 1 ] [ m 1 ] dp[n_1][m_1] dp[n1][m1] 与 d p [ n 1 ] [ m 2 ] dp[n_1][m_2] dp[n1][m2] 两者中,当 m 2 > m 1 m_2 \gt m_1 m2>m1 d p [ n 1 ] [ m 2 ] ≤ d p [ n 1 ] [ m 1 ] dp[n_1][m_2] \le dp[n_1][m_1] dp[n1][m2]≤dp[n1][m1] m m m 增大 ,整体减小
- 通过1 2 3 三条思考 可以推出
若 d p [ n − 1 ] [ k − 1 ] ≤ d p [ n ] [ m − k ] dp[n - 1][k - 1] \le dp[n][m - k] dp[n−1][k−1]≤dp[n][m−k]
则 d p [ n − 1 ] [ k ] ≤ d p [ n ] [ m − k ] dp[n - 1][k] \le dp[n][m - k] dp[n−1][k]≤dp[n][m−k]
- 优化一:代码演示
通过观察 k 与 d p [ n − 1 ] [ k − 1 ] 与 d p [ n ] [ m − k ] dp[n - 1][k - 1] 与 dp[n][m - k] dp[n−1][k−1]与dp[n][m−k] 之间的关系,最优的转移k值,一定发生在两个函数的交点处,优化掉min以后,总体时间复杂度变成了 O ( n × m ) O(n \times m) O(n×m)
#include<iostream>
using namespace std;
#define MAX_N 32
#define MAX_M 1000000
int dp[MAX_N + 5][MAX_M + 5];
int main() {
int n, m;
cin >> n >> m;
for (int i = 0; i <= m; i++) dp[1][i] = i;
for (int i = 2; i <= n; i++) {
int k = 2;
dp[i][1] = 1;
for (int j = 2; j <= m; j++) {
while(k < j && dp[i - 1][k] <= dp[i][j - k]) ++k;
dp[i][j] = max(dp[i - 1][k - 1], dp[i][j - k]) + 1;
}
}
cout << dp[n][m] << endl;
return 0;
}
状态定义的优化
特点:
1. 原状态定义所需存储空间与 m 相关, m 值域大,所以存不下
2. 当发现某个自变量与因变量之间存在相关性的时候,两者即可对调
3. d p [ n ] [ m ] = k dp[n][m] = k dp[n][m]=k 重定义为 d p [ n ] [ k ] = m dp[n][k] = m dp[n][k]=m 代表 n 个几点扔 k 次 ,最多可以测多少层楼
4. k 的值域小, 当 n = 2 时, k ≤ 2 m k \le \sqrt{2m} k≤2m
状态转移方程: d p [ n ] [ k ] = d p [ n − 1 ] [ k − 1 ] + d p [ n ] [ k − 1 ] + 1 dp[n][k] = dp[n - 1][k - 1] + dp[n][k - 1] + 1 dp[n][k]=dp[n−1][k−1]+dp[n][k−1]+1
本质上已经不是一个动态规划题目了,实际上变成了一个递推问题
二、多重背包的优化
转移过程优化
二进制拆分法
- 本质上,对于某一类物品,我们具体要选择多少件,才是最优答案
- 普通的单一拆分法,实际上,只是想枚举某个物品选择1–s 件的所有情况
- 二进制拆分发可以达到相同的效果,拆分出来的物品数量会更少
时间复杂度: O ( n × m × ∑ i = 1 i = n l o g s i ) O(n \times m \times\ \sum_{i =1}^{i = n}{logs_i}) O(n×m× ∑i=1i=nlogsi)
最优时间复杂度: O ( n × m ) O(n \times m) O(n×m) ,借助单调队列,后续讲
01背包时间复杂度: O ( n × m ) O(n \times m) O(n×m)
完全背包时间复杂度: O ( n × m ) O(n \times m) O(n×m)
#include<iostream>
using namespace std;
#define MAX_V 100000
int dp[MAX_V + 5];
int main() {
int V, n, v, w, s;
cin >> V >> n;
for (int i = 1; i <= n; i++) {
cin >> v >> w >> s;
for (int k = 1; s; k *= 2) {
if (k > s) k = s;
s -= k;
for (int j = V ; j >= k * v ; j--) {
dp[j] = max(dp[j], dp[j - k * v] + k * w);
}
}
}
cout << dp[V] << endl;
return 0;
}
三、最长上升子序列的优化
状态定义
- d p [ i ] dp[i] dp[i], 代表以i位做为阶位的最长上升子序列的长度
- 状态转移
d p [ i ] = m a x ( d p [ j ] ) + 1 ∣ v a l j < v a l i dp[i] = max(dp[j]) + 1 | val_j < val_i dp[i]=max(dp[j])+1∣valj<vali
优化方法:
3. 维护一个单调数组len len[i] 代表长度为i 的序列,结尾最小值
4. dp[i] 在转移的时候,在len数组中查找第一个 l e n [ k ] > = v a l i len[k] >= val_i len[k]>=vali 的位置,dp[i] = k
5. 更新 l e n [ k ] = v a l i len[k] = val_i len[k]=vali
6. 需要明确,len数组为什么是单调的
7. 证明过程,假设,更新前是单调的,更新以后,一定是单调的
8. 在len数组中查找位置k,实际上就是二分算法搞定
时间复杂度 : O ( n l o g l ) O(nlogl) O(nlogl)
#include<iostream>
#include <string.h>
using namespace std;
#define MAX_N 1000000
int len[MAX_N + 5];
int dp[MAX_N + 5];
int binary_search(int *arr, int n, int x) {
int head = 0, tail = n, mid;
while(head < tail) {
mid = (head + tail) >> 1;
if (arr[mid] < x) head = mid + 1;
else tail = mid;
}
return head;
}
int main() {
int n, ans = 0;
cin >> n;
memset(len, 0x3f, sizeof(len));
len[0] = 0;
for (int i = 1; i <= n; i++) {
int a;
cin >> a;
dp[i] = binary_search(len, ans + 1, a);
len[dp[i]] = a;
ans = max(dp[i], ans);
}
cout << ans << endl;
return 0;
}
四、切割回文
提前处理得到mark数组,mark[i] 存储的是所有以i位置作为结尾的而会问串的起始坐标,在转移过程中,利用mark数组就可以避免掉大量的无用循环遍历过程
时间复杂度: O ( n + m ) O(n + m) O(n+m) m 是字符串中的回文串的数量
代码演示:
在这里插入代码片#include<iostream>
#include <string>
#include <vector>
using namespace std;
#define MAX_N 500000
vector<int> mark[MAX_N + 5];
int dp[MAX_N + 5];
int expand(string &s, int i, int j) {
while(s[i] == s[j]) {
mark[j + 1].push_back(i + 1);
--i, ++j;
if (i < 0 || j >= s.size()) break;
}
return 1;
}
int main() {
string s;
cin >> s;
for (int i = 0; s[i]; i++) {
expand(s, i, i);
i + 1 < s.size() && expand(s, i, i + 1);
}
for (int i = 1; i <= s.size(); i++) {
dp[i] = i;
for (int j = 0; j < mark[i].size(); j++) {
dp[i] = min(dp[i], dp[mark[i][j] - 1] + 1);
}
}
cout << dp[s.size()] - 1 << endl;
return 0;
}