闫氏DP分析法 学习笔记

求解的过程

DP问题 == 有限集上的最值问题

DP的两个阶段:

  1. 化零为整,寻找共性——状态表示
    • 集合
      所谓 化零为整 是指我们对于零星的情况,不是一个一个去枚举,而是每次根据这些零星的情况的某些特性去枚举一类情况(即一个子集)

f(i)需要考虑的问题:

    • 属性
      表示的是一个怎样的集合?
      f(i)保存的值是什么意思(max/min/count/…)?与集合有什么关系?
  1. 化整为零,寻找不同——状态计算

如何去求f(i)呢?
我们要把它们划分成若干个不同的子集来求,每一个子集分别去求。需要满足的条件有:

      • 不重复(可以不满足,例如求min,max。但是求count的时候,就不能重复)
      • 不遗漏
        例如,我们假设f(i)表示的是某个集合的最大值,那么可以每个子集的最大值的最大值。
        那么问题来了,进行集合f(i)的划分的依据?
        寻找最后一个不同点。

01背包

问题:
有N件物品和一个容量是V的背包,每件物品只能使用一次

第i件物品的体积是\(w[i]\),价值是\(v[i]\)

求将这些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大
分析:
是一个有限集合的最值问题

  1. 状态表示:f(i,j)
    • 集合:只考虑前i个物品,且总体积不超过j的选择方案集合
    • 属性:max f(N,V)
  1. 状态计算:
    • 集合1,不选第i个物品的方案,\(max f(i-1,j)\)
    • 集合2,选第i个物品的方案,\(max f(i-1,j-w_i)+v_i\)
      两个集合满足:不重复和不遗漏
    • 将集合1和集合2取最大值就是所求答案 \(f(i,j)=max( f(i-1,j), f(i-1,j-w_i)+v_i)\)
  1. 优化
    分析状态计算可知,f(i,j)只记录f(i-1)即可。

朴素代码

概述: 严格按照状态转移方程 \(f[i][j] = \max(f[i-1][j], f[i-1][j-w[i]]+v[i])\) 实现。
缺点: 使用二维数组,空间复杂度 \(O(N \times V)\)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int v[N],w[N];
int f[N][N];
   int n,m;
int main()
{
 
    cin>>n>>m;
    for(int i=1;i<=n;i++)
     cin >> v[i] >> w[i]; // 读入 体积 价值
    for(int i=1;i<=n;i++){//i表示只考虑前i个物品
        for(int j=0;j<=m;j++){//j表示当前背包容量
            f[i][j] = f[i-1][j]; //该行必须有,保证“放不下w[i]”和“放得下但不选w[i]”时初始化当前状态
            if(j>=w[i]) f[i][j]=max(f[i-1][j] , f[i-1][ j-w[i] ] + v[i]); //j>=w[i] 是为了保证背包能够放下,且防止数组越界
        }
    }
    cout<<f[n][m];
    return 0;
}

一维优化

DP问题的优化是对代码进行等价替换,和题目无关。

优化内容: 空间优化。将二维数组压缩为一维数组。
原理:

  1. 观察发现 \(f[i][j]\) 只依赖于上一层 \(f[i-1][<j]\) 的数据
  2. 因此我们倒序更新就能保证 更新\(f[i][j]\) 时, 依赖的上一层 \(f[i-1][<j]\) 的数据存在。

关键点: 内层循环必须倒序jmw[i])。这是为了保证在计算 f[j] 时,用到的 f[j-w[i]]上一层(即 \(i-1\) 时)的状态,而不是本层已经被更新过的状态(防止一个物品被重复放入)。


#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int v[N], w[N];
int f[N];
int n, m;
int main()
{

    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        cin >> v[i] >> w[i]; // 读入 体积 价值
    for (int i = 1; i <= n; i++)
    {
        for (int j = m; j >= 0; j--)  // 必须倒序
        {
            if (j >= w[i])
                f[j] = max(f[j], f[ j- w[i] ]  + v[i]);
        }
        /*
    等价于for (int j = m; j >= w[i]; j--)
        {
                f[j] = max(f[j], f[ j-w[i] ]  + v[i]);
        }

        */
    }
    cout << f[m];

    return 0;
}

完全背包

完全背包的每个物品可以用无限次,其他条件与01背包一致。

  1. 状态表示:
    • 集合:只考虑前i个物品,且总体积不超过j的选择方案集合
    • 属性:max
  1. 状态计算:
    • 集合0:选0个第i个物品,\(f(i-1,j)\)
    • 集合1:选1个第i个物品,\(f(i-1,j-w_i)+v_i\)
    • 集合 …
    • 集合K:选k个第i个物品,\(f(i-1,j-kw_i)+kv_i\)
    • 集合 …
      每个集合具有排他性

推导\(f(i-1,j-k w_i)+k v_i\)
我们容易得到
\(f(i,j) = max(f(i-1,j),f(i-1,j-V_i)+W_i,f(i-1,j-2V_i)+2W_i,…,f(i-1,j-kV_i)+kW_i)\)

但是对于\(f(i,j-v) = max(f(i-1,j-V_i),f(i-1,j-2V_i)+W_i,…,f(i-1,j-kV_i)+kW_i)\)

立即推:\(f(i,j) = max( f(i-1,j),f(i,j-V_i)+W_i)\)

三重循环朴素代码

概述: 01背包朴素代码+完全背包定义的直接实现。
逻辑: 枚举第 \(i\) 个物品,枚举体积 \(j\),再枚举该物品选 \(k\) 个。
缺点: 效率最低。时间复杂度接近 \(O(N \times V^2)\),空间复杂度 \(O(N \times V)\)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second

const int N = 1010;
const int INF = 0x3f3f3f3f;

int v[N], w[N];
int f[N][N];
int n, m;

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
       cin >> v[i] >> w[i]; // 读入 体积 价值

    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= m; j++) {
            f[i][j] = f[i - 1][j];  // 不选第 i 种物品
            for (int k = 1; k * w[i] <= j; k++) {
                f[i][j] = max( f[i][j], f[i - 1][ j - k * w[i] ] + k * v[i]) ;
            }
        }
    }

    cout << f[n][m];
    return 0;
}

三重循环+一维数组

优化内容: 空间优化
原理: 01背包的一维优化+完全背包定义的直接实现。
注意: 虽然空间降到了 \(O(V)\),但依然保留了三层循环,时间复杂度还是 \(O(N \times V^2)\)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second

const int N = 1010;

int v[N], w[N];
int dp[N];   // 一维数组
int n, m;

int main()
{
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
       cin >> v[i] >> w[i]; // 读入 体积 价值

    // 初始化
    for (int j = 0; j <= m; j++)
        dp[j] = 0;

    // 三重循环版完全背包(1维数组)
    for (int i = 1; i <= n; i++) {          // 物品
        for (int j = m; j >= 0; j--) {      // 背包容量  ,必须倒序
          //*****************只多这些
            for (int k = 1; k * w[i] <= j; k++) {   // 选 k 个
          //只多这些*****************
                dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
            }
        }
    }

    cout << dp[m] << endl;
    return 0;
}

二重循环+二维数组

优化内容: 时间优化
原理: 利用数学推导 \(f(i,j) = \max(f(i-1,j), f(i,j-w_i)+v_i)\)
通过观察递推式发现,我们不需要显式地枚举 \(k\),而是可以通过“拿一个物品 \(i\) 后,状态转移到当前层 \(f[i][j-w_i]\)”来等价替换。
结果: 去掉了最内层的 \(k\) 循环,将时间复杂度从立方级降低到 \(O(N \times V)\)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int v[N],w[N];
int f[N][N];
   int n,m;
int main()
{
 
    cin>>n>>m;
    for(int i=1;i<=n;i++)
      cin>>v[i]>>w[i];
    for(int i=1;i<=n;i++){
        for(int j=0;j<=m;j++){
            f[i][j]=f[i-1][j];
            if(j>=w[i]) f[i][j]=max( f[i][j], f[i][ j-w[i] ] + v[i]);  //注意这里是 f[i]  ,不是 f[i-1] ,该变化决定了能不能重复拿当前物品
        }
    }
    cout<<f[n][m];
    return 0;

}

二重循环+一维数组

优化内容: 空间+代码的最优解
原理: 01背包的一维优化 + 数学推导去除 \(k\) 循环

  1. 对比01背包: 01背包需要倒序遍历以读取“上一层”状态。
  2. 完全背包逻辑: 完全背包的状态转移 \(f[j] = \max(f[j], f[j-w[i]]+v[i])\) 中,我们需要的是当前层(即已经放入过物品 \(i\))的状态。

关键点: 内层循环改为正序jw[i]m)。这意味着在计算 f[j] 时,f[j-w[i]] 已经是本轮更新过的值(可能已经装入了 \(k\) 个物品 \(i\)),从而实现了“物品可以使用无限次”的效果。


#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int v[N], w[N];
int f[N];
int n, m;
int main()
{

    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];
    for (int i = 1; i <= n; i++)
    {
        for (int j = w[i]; j <= m; j++)
        {
            f[j] = max(f[j], f[ j-w[i] ] + v[i]);
        }
    }
    cout << f[m];
    return 0;
}

01背包 vs 完全背包 对比表

对比朴素01背包 和 二重循环优化完全背包

对比维度 01背包 (0/1 Knapsack) 完全背包 (Unbounded Knapsack)
物品限制 每件物品只能用 1 次 每件物品可以用无限次
二维状态方程 \(f[i][j] = \max(f[i-1][j], f[\mathbf{i-1}][j-v] + w)\)
依赖上一行的状态
\(f[i][j] = \max(f[i-1][j], f[\mathbf{i}][j-v] + w)\)
依赖当前行已更新的状态
一维遍历顺序 倒序 (jm \(\to\) v[i]) 正序 (jv[i] \(\to\) m)
为什么这样遍历? 倒序是为了防止覆盖
计算 \(f[j]\) 时需要用到 \(f[j-v]\),倒序保证 \(f[j-v]\) 还是上一层(i-1) 的旧数据(即还没选过i)。
正序是为了利用覆盖
计算 \(f[j]\) 时,我们需要 \(f[j-v]\) 已经是这一层(i) 更新过的数据(即可能已经选过i了,再选一个)。
时间复杂度 \(O(N \times V)\) \(O(N \times V)\) (优化后)
空间复杂度 \(O(V)\) \(O(V)\)

代码核心逻辑对比: 只需改变内层循环的方向:

// 假设 v[i] 是体积,w[i] 是价值,m 是背包容量

// 【01背包】:必须倒序
for (int i = 1; i <= n; i++) {
    for (int j = m; j >= v[i]; j--) {  // <--- 关键区别:倒序
        f[j] = max(f[j], f[j - v[i]] + w[i]);
    }
}

// 【完全背包】:必须正序
for (int i = 1; i <= n; i++) {
    for (int j = v[i]; j <= m; j++) {  // <--- 关键区别:正序
        f[j] = max(f[j], f[j - v[i]] + w[i]);
    }
}

助记口诀

01倒,完全正。
(01背包怕重复,所以倒着走;完全背包求重复,所以顺着走。)

区间DP

合并石子问题

每次只能合并相邻的两堆,合并的代价为这两堆石子的质量和,合并后与这两堆石子相邻的石子和新堆相邻,合并时由于选择的顺序不同,合并的代价也不同

比如有4堆 1 3 5 2 ,我们可以先合并1、2堆,代价为4,得到4,5,2,又合并1,2堆代价为9,得到9,2,再合并得到11,总代价为4+9+11

找出一种合适的方法,使总合并代价最小

  1. 状态表示:
    • 集合:所有将[i,j]合并成一堆的方案的集合
    • 属性:min
  1. 状态计算:
    • f[i,j] = f[i,k]+f[k+1,j]+s[j]-s[i-1];(s为本步骤代价)
      分析:
      对于区间[i,j],可以划分为左边和右边,问题的每一步都是左边和右边进行合并
      我们把左半边的最后一堆设为分界点 k ,
      因此我们问题变成,如何合并[i,k]和[k+1,j](
      容易想到两部分是完全独立的,可以分开计算,
      对于求f(i,k)和f(k+1,j)就是在各个子集中求出最小值,
      然后再对它们取个 min 就是f(i,j)。

代码:


#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int n;
int s[N];		//前n项和
int f[N][N];

int main(){
    cin>>n;
    for(int i=1;i<=n;i++) cin>>s[i],s[i]+=s[i-1];

    for(int len=2;len<=n;len++){
        for(int i=1;i+len-1<=n;i++){
            int j=i+len-1;
            f[i][j]=1e8;
            for(int k=i;k<j;k++)
                f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+s[j]-s[i-1]);
        }
    }
    cout<<f[1][n]<<endl;
    return 0;
}

线性dp

求最长公共子序列
给定两个长度分别为N和M的字符串A,B,求即是A的子序列又是B的子序列的字符串长度是多少。

  1. 状态表示:
    • 集合:所有A[1,i]与B[1,j]的公共子序列的集合
    • 属性:max
  1. 状态计算:
    • f[i,j] = max(f[i-1,j],f[i,j-1],f[i-1,j-1]+1)
      对最后一个不同点可以划分四类:

公共子序列不包含A [i] 也不包含 B [j]
公共子序列不包含A [i] 包含 B [j]
公共子序列包含A [i] 不包含 B [j]
公共子序列包含A [i] 也包含 B [j]
四种状态不重复不遗漏具有排他性
四种状态分别用00,01,10,11表示。

    • 当情况为11时
       当情况为11时,其必满足A [ i ] = B [ j ](字符相等)
      将这一子集根据变与不变划分为两个部分:
      一是前面 A 的 i - 1个字符与 B 的 j - 1个字符的情况(变的部分),就是 f(i-1,j-1)
      二是A [ i ] = b [ j ](不变的部分),就是1
      此时f(i,j) =f(i-1,j-1)+1
    • 当情况是00时
      易得f(i,j) = f(i-1,j-1)
    • 当情况是01时
      由于f(i-1,j) 包含两种情况,一种是B [ j ] 在公共子序列中,一种是B [ j ] 不在公共子序列中 。
      但本题重复对于求最大值没有影响,所以可以直接使用
      即f(i,j) = f(i-1,j)
    • 当情况是10时
      f(i,j) = f(i,j-1) 。原因同上

由上可以看出,01子集已包含00情况下的式子,
所以我们在代码构造中只需要计算 01、10、11三个部分即可。

代码:


#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;

int n,m;
char a[N],b[N];
int f[N][N];

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

posted @ 2022-01-21 17:27  kingwzun  阅读(395)  评论(0)    收藏  举报