最近在看DP有关的问题,还是感觉得总结总结才行,古人云:温故而知新嘛。
一、什么是DP
DP常用于求解最优问题,类似与分治法,基本思想是:
将待求问题分解为若干子问题,从子问题中寻找原问题的解。
但是不同的是,DP分解得到的子问题往往较多,而且有些子问题会被多次重复计算。
DP法将已解决的子问题的答案保存下来,避免了重复运算,提高效率。
而DP算法一般有三种:
1.记忆化搜索
对于一些递归形式的解答,由于递归深度搜索时,可能会多次计算重复值,使用DP记录已经计算的值,能大大减少时间复杂度。
举例,最常见的0/1背包问题
有n个价值为vi,重量为wi的不同物品,从其中挑选重量不超过W的物体,求最大的总价值。
这个问题可以用递归解:
//递归解 0 / 1背包
int n,W;
int v[MAX_N],w[MAX_N]
//挑选到第i个物品,剩余重量为j
int solve(int i,int j){
int res;
if(i == n)
res = 0; //没有物品了
if(j < w[i]){ //无法装下第i个物品
res = solve(i + 1,j);
}
else { //能装下,尝试一下装或者不装,取大的
res = max(res(i + 1,j),res(i + 1,j - w[i]) + v[i]);
}
return res;
}
//尽管递归的思想很棒,但是每次搜索都会有两次分支,而且搜索深度为n,时间复杂度最坏为o(2^n)
//一旦n较大,妥妥地TLE
那么如何优化递归呢?
看下图,我们发现solve(3,2)递归计算了多次,第二次计算时,明明已经知道结果了还白白计算,造成时间上的消耗,于是DP的记忆化搜索的特点就体现了:记录第一次计算的值。
修改一下我们之前的递归:
//记忆化搜索 0 / 1背包
int n,W;
int v[MAX_N],w[MAX_N]
int dp[MAX_N+1][MAX_W+1];
// -1代表没计算过
memset(dp,-1,sizeof(dp));
//从第i个物品开始挑选,总重量小于j
int solve(int i,int j){
if(dp[i][j] >= 0){
return dp[i][j]; //计算过的直接返回
}
int res;
if(i == n)
res = 0; //没有物品了
if(j < w[i]){ //无法装下第i个物品
res = solve(i + 1,j);
}
else { //能装下,尝试一下装或者不装,取大的
res = max(res(i + 1,j),res(i + 1,j - w[i]) + v[i]);
}
return dp[i][j] = res; //这里存储第一次计算结果
}
//简单的记录了计算结果,复杂度为o(nW)(因为组合数不过nW种),第二次执行相同递归都会直接返回
2.递推关系式下的DP
仔细观察之前的记忆搜索过程,我们发现,
记 dp[i][j] 为 从第i个物品开始挑选,总重量小于j, 有如下递推式:
借助递推式,我们可以抛弃递归函数,用for循环将dp全部计算出来:
//递推式 0 / 1背包
int n,W;
int v[MAX_N],w[MAX_N]
int dp[MAX_N+1][MAX_W+1];
// 初始化,其实全局变量一般默认int 为0
memset(dp,0,sizeof(dp));
//从第i个物品开始挑选,总重量小于j
int solve(int i,int j){
for(int i = n - 1;i >= 0;--i){ //从第n个物品开始
for(int j = 0,j <= W;++j){ //从总重量小的开始
if(j < w[i]){ //无法装下第i个物品
dp[i][j] = dp[i+1][j];
}
else { //能装下,尝试一下装或者不装,取大的
dp[i][j] = max(dp[i+1][j]),dp[i+1][j - w[i]] + v[i]);
}
}
}
printf("%d\n",dp[0][W]);
}
//复杂度为o(nW)(因为组合数不过nW种),较递归形式简洁了许多
对于同一题,dp [i][j] 的含义也多种多样,比如 0 / 1 背包中还可以这样定义dp:
根据不同的递推式,计算时开始的循环方向也不一致,上面的就是i从0到n-1的方向…
二、经典的DP问题
尽管DP的思想,我们看起来、理解起来不是太难,但是不去体会一些经典的例题,对于初学DP的人来,说想要一下子得到递推式,实在还是有点困难,以下根据一些资料整理了一些比较经典的DP
1. LCS(Longest Common Subsequence) ——两字符串的最长公共子序列
2. 完全背包问题
3. 多重部分和问题
4. LIS (Longest Incresing Subsequence) ——某字符串的最长上升子序列
5. 划分数
6. 多重集组合数
让我们一个一个来:
1.LCS(Longest Common Subsequence) ——两字符串的最长公共子序列
事先说明:子序列是指字符串 s1 s2 s3 … sn 得到的保持原元素前后关系,但是可以跳过某些元素的子序列,举例: abced 与 bacd 它们都有子序列 bcd.
现在的问题是,给我们两个字符串,两字符串的最长公共子序列的长度是多少?
这显然可以分解为很多个子问题,不妨设:
不难得出递推式为:
其中下式实际上就等于 dp [ i ] [ j ] + 1,因为 dp[i][j+1] 和 dp[i+1][j] 不可能大于 dp[i][j] 部分:
代码实现:
int n,m;
char s1[MAX_N],s2[MAX_M];
int dp[MAX_N + 1][MAX_M + 1];//默认为0
void solve(){
for(int i = 0;i < n;++i){
for(int j = 0;j < m;++j){
if(s1[i] == s2[j]){
dp[i+1][j+1] = dp[i][j] + 1;
}else{
dp[i+1][j+1] = max(dp[i+1][j],dp[i][j+1]);
}
}
}
printf("%d\n",dp[n][m]);
}
2.完全背包问题
在0 / 1背包的基础上,每件物品可以选多件!
我们很容易在0 / 1 背包的基础上得到:
这个图片的递推式少了一个i…
尝试实现以下:
int dp[MAX_N+1][MAX_M+1];
void solve(){
for(int i = 0;i < n;++i){
for(int j = 0;j <= W;++j){
for(int k = 0;k * w[i] <= j;++k){
dp[i+1][j] = max(dp[i+1][j],dp[i][j - k * w[i]] + v[i] * k);
}
}
}
}
//最坏算法复杂度为O(nW * W),不够友好
实际上我们发现:
在dp [ i+1 ][ j ] 中 计算 dp[ i ] [ j - k * w[i] ] + v[i] * k (k >=1 )
实际上跟在 dp [ i+1 ] [ j - w[i] ] 中 计算 dp[ i ] [ j - w[i] - (k-1) * w[i] ] + v[i] * (k-1) (k >=1 )是一样的,
那么我们进行一些变形有:
这样我们就省去了k的循环,复杂度又变为O(nW)
实现一下:
int dp[MAX_N+1][MAX_M+1];
void solve(){
for(int i = 0;i < n;++i){
for(int j = 0;j <= W;++j){
if(j < w[i]){
dp[i+1][j] = dp[i][j];
}
else dp[i+1][j] = max(dp[i][j],dp[i+1][j - w[i]] + v[i]);
}
}
}
其实我们可以用一个一维数组来维护dp,节省空间:
对于 0 / 1背包:
// dp[i][j] = max( dp[i-1][j],dp[i-1][j-w[i]] + v[i]) 发现dp[i][j]只跟上一行有关
int dp[MAX_W+1];
void solve(){
for(int i = 0;i < n;++i){ //一件一件来
for(int j = W;j >= w[i];--j){
//逆序是确保状态正确转移,即求dp[j]前dp[j - w[i]]还没更新,停留在前i-1个物体的状态
dp[j] = max(dp[j],dp[j - w[i]] + v[i]); //不装和装
}
}
printf("%d\n",dp[W]);
}
对完全背包:
// dp[i][j] = max( dp[i-1][j],dp[i][j-w[i]] + v[i]) 发现dp[i][j]只跟上下两行有关
// 完全跟0/1的区别就是 j-w[i]时完全还停在i而0/1则转移到前i-1了
int dp[MAX_W+1];
void solve(){
for(int i = 0;i < n;++i){
for(int j = w[i];j <= W;++j){
//正序是确保状态正确转移,即求dp[j]前dp[j - w[i]]已经更新,停留在前i个物体的状态
dp[j] = max(dp[j],dp[j - w[i]] + v[i]); //不装和装
}
}
printf("%d\n",dp[W]);
}
两者差异只有循环方向上的差异·····虽然一维看起来简单,但是用不好就容易产生bug,需要谨慎使用
多重背包
这里还需要说明背包问题的另一种,叫做多重背包,它介于0/1背包和完全背包之前,差别在于:
它的每个背包有确定的数量,不都是1,也不能任意取。
我们解决多重背包一般有两种思路:
第一种就是二进制优化,转换成01背包:
例如:一件物品有26件,每一件的权值是w,26可以写成(1+2+4+8)+11,所以就把这种物品分解成权重为w,2w,4w,8w,11w的五种物品,这五种物品组合,一定能组成小于等于26的任意一个数,这样就把有26件的一种物品换成了五种各有一件的物品,多重背包用0/1背包就能解决了。
实现代码:
int dp[MAX_W+1];
struct Wooden{
int h,a,c; //价值,重量,数量
};
Wooden wooden[MAX_K];
Wooden wooden_01[MAX_K * N];
void solve(){
for(int i = 0;i < K;++i){
int num = wooden[i].c; // 背包的数量
for(int j = 1;j <= num;j *= 2){ //2进制拆分
wooden_01[number01].a = j * wooden[i].a; //重量组合
wooden_01[number01++].h = j * wooden[i].h; //价值组合
num -= j;
}
if(num > 0){ //1 2 4 6 8 ..... 最后 num = num - 2^c + 1
wooden_01[number01].a = j * wooden[i].a;
wooden_01[number01++].h = num * wooden[i].h; //长度组合
}
}
fill(dp,dp+MAX_A,0);
int max_highest = 0;
for(int i = 0;i < number01;++i){ //对0/1的每个背包
for(int j = W;j >= wooden_01[i].a;--j){
dp[j] = max(dp[j],dp[j - wooden_01[i].a] + wooden_01[i].h); //不装和装
}
}
printf("%d\n",dp[W]);
}
另一种方法是维护一个数组num, 类似完全背包
num [ i ] [ j ] 表示在往背包里试着装第i件物品时,背包容量使用了j时,装了多少件i物品。
w [ i ] 为物品 i 的重量,则有
num [ i ] [ j ] = num [ i ] [ j - w [ i ] ] +1;
可以看到 num [i][j] 只跟这一行有关系,可以用一维数组优化
实现代码:
int wi[MAX_N]; //重量
int vi[MAX_N]; //价值
int ni[MAX_N]; //数量
int dp[MAX_W];
int num[MAX_W];
void solve(){
for(int i = 0;i < n; ++i)
{
memset(num,0,sizeof(num));
memset(dp,0,sizeof(dp));
for(int j = wi[i];j <= W;++j)
//注意状态转移num [ i ] [ j ] = num [ i ] [ j - w [ i ] ] +1;,正向更新
{
if(dp[j-wi[i]] + vi[i] > dp[j] && num[j-wi[i]] < ni[i]) //装且有剩的能装装
{
dp[j] = dp[j-wi[i]] + vi[i];
num[j] = num[j-wi[i]] + 1;
}
}
}
printf("%d\n",dp[W]);
}
3.多重部分和问题
有n个不同物品,分别有不同的价值和数量,判断是否可以从这些物体中选出组合恰好让其价值和为K
我们很容易想到:
但是这样求解太耗时了,而且效率上也很差,只是得到了一个bool数组。
我们可以更改一下dp定义:
有递推式:
最终看dp[n][K]>=0就是是否有解了
实现:
int dp[MAX_K + 1];
void solve(){
memset(dp,-1,sizeof(dp));
dp[0] = 0;
for(int i = 0; i < n;++i){
for(int j = 0;j <= K; ++j){
if(dp[j] >= 0){
dp[j] = m[i];
}else if(j < a[i] || dp[j - a[i]] <= 0){
dp[j] = -1;
}else {
dp[j] = dp[j - a[i]] - 1;
}
}
}
if(dp[K] >= 0) printf("Yes\n);
}
4.LIS (Longest Incresing Subsequence) ——某字符串的最长上升子序列
给定某字符串s1 s2 s3…sn,求其最长的上升子序列
这个dp定义我们可能想类似于LCS的做法:
很容易得到:
写出递推式就是:
实现代码:
int n;
char a[MAX_N];
int dp[MAX_N];
//O(n^2)
void solve(){
int res = 0;
for(int i = 0;i < n;++i){
dp[i] = 1;//初始化为只包含ai
for(int j = 0;j < i;++j){
if(a[i] > a[j]){
dp[i] = max(dp[i],dp[j] + 1);
}
}
res = max(res,dp[i]);
}
printf("%d\n",res);
}
可能有人觉得上面的dp时间上还是比较复杂,我们还有一种新鲜的优化:
我们设dp为:
对同等长度的LIS来说,末尾元素越小越有优势;
那么我们的递推关系为:
从前往后考虑元素,对每个aj, 当 i == 0 || dp[i - 1] < aj,有dp[i] = min(dp[i],aj)
最终我们只需要找出使得:dp[i] < INF的最大的i 就得出结果了,至于更新和查找,用二分搜索即可。
实现代码:
#include<algorithm>
//时间复杂度为O(nlogn)
int n,INF;
int dp[MAX_N];
void solve(){
fill(dp,dp+n,INF);
for(int i = 0;i < n;++i){
*lower_bound(dp,dp+n,a[i]) = a[i]; //对同等长度的LIS来说,末尾元素越小越有优势
//*lower_bound(dp,dp+n,a[i]) 从dp到dp+n已经排好序的序列中找到第一个大于等于a[i]的指针
}
printf("%d\n",lower_bound(dp,dp+n,INF)-dp);//取index
}
5.划分数
将n个相同的物品 分为 不超过 m组,总共有多少中分法?
一般根据题目的两个已知变量,我们常常会定义子问题为:
然后大家容易想到先取出k个,剩余j-k个分为i- 1组,有:
但是这样不正确!Why?
因为这样的话, 1 + 1 + 2 会认为 跟 1 + 2 + 1不一样,导致重复计算!!!
我们改良递推式为如下形式:
dp[ i ] [ j - i ] 代表:i 组每组都少放一个
dp[ i - 1 ] [ j ] 代表:让某一组为空
理由是:对于dp[ i ] [ j ] 其分组,分组组值为 ai,
若任意 ai > 0,ai -1 对应 dp[ i ] [ j - i ] ,真正意义上的划分i组(不为空,每组最小为1)
若存在 ai == 0 ,对应dp[ i - 1 ] [ j ] ( j 的 i -1划分),某种程度上的划分小于 i组(至少有一组为空)
实现:
int n,m;
int dp[MAX_M+1][MAX_N+1];
void solve(){
dp[0][0] = 1;
for(int i = 1; i <= m;++i){
for(int j = 0;j <= n;++j){
if(j - i >= 0 ){
dp[i][j] = dp[i - 1][j] + dp[i][j - i];
}else {
dp[i][j] = dp[i - 1][j];
}
}
}
printf("%d\n",dp[m][n]);
}
6.多重集组合数
有n种不同物品,每种物品有数量Ai个,同类不可区分,不同类可区分,从其中取m个物品有多少种取法?
定义子问题为:
递推关系为:
可以直接用上式计算,时间复杂度最坏为O(n * m^2) ;
不过由于我们有:
dp[i+1][j] = dp[i][j] + dp[i][j-1]+…+dp[i][j−ai] ,
dp[i+1][j−1] = dp[i][j−1] +… + dp[i][j−ai−1]
所以dp[i+1][j]=dp[i+1][j-1] + dp[i][j] - dp[i][j-ai-1]
实现:
int n,m;
int a[MAX_N];
int dp[MAX_N+1][MAX_M+1];
void solve(){
//一个都不取
for(int i = 0;i <= n; ++i){
dp[i][0] = 1;
}
for(int i = 0;i < n; ++i){
for(int j = 1;j <= m;++j){
if(j - 1 - a[i] >= 0){
dp[i+1][j] = dp[i+1][j-1] + dp[i][j] - dp[i][j - 1 - a[i]];
}else {
dp[i+1][j] = dp[i+1][j-1] + dp[i][j];
}
}
}
printf("%d\n",dp[n][m]);
}
以上就是我初步对DP的一些总结,参考了《挑战程序设计竞赛》的部分资料,如果文章有误,还请指出~