1. 写在前面的话

  • 之前写了一篇不像总结的动态规划总结,感觉更像是一个成长历程,所以就打算重写一篇。

2. 对DP简单的总结

  1. dp的题目特点

    • 求最大或者最小值(如背包:价值最大、凑硬币:数量最少……)
    • 计数(如:爬楼梯:计算上到n台阶的方法数)
    • 求存在性或者胜负(如:能否胜利……)
  2. dp的使用条件
    • 拥有子问题,子问题最优解(即拥有最优子结构),对于一个原问题解最优,其子问题必定也是最优,同时原问题的最优解依赖于其子问题的最优解
    • 子问题重复性,一个子问题可能会影响多个不同的下一阶段的原问题
    • 无后效性,即此时的之前状态无法直接影响未来的决策,换句话说就是之前的每个状态如何得来并不影响未来对此时(当前)状态的利用或者查找,因为我们最后对此时(当前)状态的利用只考虑结果不考虑过程。
  3. dp的思考方式及注意事项
    • 若问题有dp的味道,应当优先从主问题出发来思考,即从末尾(结果)开始思考(例如爬楼梯问题)
    • 接下来,对于一个主问题,应当思考此问题的结果由什么得来(由什么决定、怎么决策等)和有什么因素影响
    • 或者思考此问题类似什么DP题(如:背包、LIS……)
    • 实在想不出可以思考如果是用dfs(或普通递归),应当如何解决问题(个人觉得有时候道理是相同的,记得吗:递归 + 记忆化 = 递推
    • 注意1:在思考一个状态的得来时,目光应当只集中在此时的状态(无后效性),而不要多想之前的状态变化和未来的状态影响
    • 注意2:状态的定义很重要,要结合题目需求和状态影响因素来定义
  4. 关于dp三步走
    • 1.状态定义 → 2.列状态转移方程 → 3.验证方程
    • 对于第一步和第二步主要可以利用上述的思考方式或者是闫氏DP分析法来解决
    • 第三步虽然不难,但是很重要,因为第三步包括:验证状态推理是否合理或者是否是答案最优,并且思考状态是否满足题目条件需求,前者均没问题后再思考边界是什么。对于一般验证发现有问题通常的解决办法有:1、修改状态定义;2、给状态增加维度;3、优化转移方程
  5. 补充的话
    • 读了紫书上的动规篇才开始补这篇博客的坑
    • 学到了很多东西,包括用记忆化搜索的优势刷表法DAG模型等等
    • 所以有些代码写的不是递推的方式是为了练习记忆化式的递归
    • 阅读了紫书动规篇后彻底抛弃了递推就一定比记忆化递归还快的观念,也使我再一次对自己当年总结的dp思考方式(递推 = 递归 + 记忆化)感到肯定
    • 先来说说记忆化的优势
      • 便于思考,有时完全都不用所谓的状态转移方程,只需要考虑当选状态下如何选择下一步的策略来进行码代码,在比赛的时候这个优势是非常大的
      • ②有些题无法用递推的方式来写,甚至状态都可能是无限的,这时候记忆化的优势将会进一步地放大
      • 用记忆化不一定就比递推慢,仔细思考一下,有时候有些状态是不用计算的,而记忆化在这方面会比全部计算出来的递推要好,固速度不必递推差
    • 记忆化的技巧
      • 多开一个数组来做记忆化的操作比将状态数组定义成特殊值来判定是否又有算过好一些,不仅能够增强可读性,还更方便调试操作
      • 如果状态做记忆化用另开数组的方式还是麻烦,不妨试试用map来存被算过的状态
      • 用引用来调用状态数组可以简化代码
    • 刷表法
      • 刷表法相对的是填表法,所谓填表法我的理解就是传统的利用之前选好的状态来计算当前的状态,书中是这么说的“对于每个状态i,计算f(i),这需要对于每个状态i都找到f(i)的依赖的所有状态
      • 正如书中的一句话“在某些时候并不方便”,即找f(i)的所有依赖状态不好找,于是就诞生了刷表法
      • 所谓刷表法,我的理解就是用计算好的状态更新它所影响到的状态,书中原话“对于每个状态i,更新f(i)所影响到的状态,但需要注意的是,只有当每个状态所依赖的状态对它的影响相互独立时才能用刷表法”,后半句话我也不是很理解,以后遇到了能理解的对应的题再说
    • DAG模型
      • 动态规划能够概括出DAG模型是紫书动态规划篇给我最大的惊喜,巧妙地把我之前总结的那繁多的模型分类大大地再次概括了一遍
      • DAG模型有点像刷搜索题时一般,将题目抽象成一个有向无环图,然后求解最大距离或者是最小距离
      • 个人感觉由于有时太过抽象,所有很难把它的图给抽象出来

3. 几个重要的模型

  • 上图中有很多模型都可以归类为DAG模型(习得紫书后才了解到的),但是有些是完全可以单拿出来作为经典模型的
  • 借用陈峰老师的一句话:子结构状态形成的如果是一棵,不就是搜索了吗,如果是一个,不就是动态规划了吗
  • 原话是这个意思,感触很深,确实,有些题遇到了把他抽象出来如果是一颗树,基本应该优先考虑dfs、bfs等搜索操作,如果是一个图,即有重复的子结构,就可以考虑动规

数塔模型

  • 这个模型是许多人动规入门的题,变种不多,但是却很经典

【例题】HDU 2084 数塔

  • 题意:有一个树形的塔,每个节点都有权值,让你求出从根节点到最后一层叶子节点的的最大权值和

  • 简单的分析

    • 这题是不能贪心的,即不能只选出当前最大的叶子权值
    • 假设已经算好了第2层的节点的最大权值和,则第1层的节点(根节点)的最大权值就为第二层的两个节点的最大权值和加上第一层的权值
    • 其余节点也可假设成第一层和第二层来思考
    • 所以设 d p [ i ] [ j ] dp[ i][ j] dp[i][j]为第i层的第j个节点的最大权值和,边界条件就是最后一层的最大权值和就是其本身
    • 状态转移方程如下

    d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j + 1 ] ) + t r e e [ i ] [ j ] dp[i][j] = max(dp[i - 1][j],\ \ dp[i - 1][j + 1]) + tree[i][j] dp[i][j]=max(dp[i−1][j],  dp[i−1][j+1])+tree[i][j]

    • 发现空间还是可以优化的,因为当前层数的dp值利用的只有下一层的dp值,而数组的的第二维计算时利用的是后面的(j + 1)的值,所以第二维是下标小的先更新,下标大的后更新,所以完全可以把第一维抛弃掉,但这样的话dp下标循环起来必须是从小到大循环
    • 状态转移方程如下

    d p [ j ] = m a x ( d p [ j ] , d p [ j + 1 ] ) + t r e e [ i ] [ j ] dp[j] = max(dp[j], dp[j + 1]) + tree[i][j] dp[j]=max(dp[j],dp[j+1])+tree[i][j]

    • 代码如下
    #include <algorithm>
    #include <cstdio>
    #include <vector>using namespace std;int c, n;int main() {scanf("%d", &c);while (c--) {scanf("%d", &n);int tree[110][110] = {0};for (int i = 0; i < n; i++)for (int j = 0; j <= i; j++)scanf("%d", &tree[i][j]);int *dp = new int[n];for (int i = 0; i < n; i++) {dp[i] = tree[n - 1][i];}for (int i = n - 2; i >= 0; i--)for (int j = 0; j <= i; j++)dp[j] = max(dp[j], dp[j + 1]) + tree[i][j];printf("%d\n", dp[0]);}return 0;
    }
    

凑硬币模型

  • 凑硬币模型是一个经典的模型,题目大概就是给你一个目标,你有许多不同的结构,你要用这些结构来组成这个目标,让你求最大组成量、最小组成量或者是组成方法数(组成方法数可以说是爬楼梯模型)

【例题1】leetcode 322 零钱兑换

  • 大致题意:给你不同面额的硬币,让你输出凑成价值n的最小使用数量,若无法凑成则返回-1

  • 这题就是一个凑硬币的板子题

  • 简单分析:

    • 假如你有1 2 5的硬币,假设你已经求出 x x x的最小使用数量 y y y,则就能算出 x + 1 x + 1 x+1 、 x + 2 x + 2 x+2 、 x + 5 x + 5 x+5 的最小使用数量,即都为 y + 1 y + 1 y+1
    • 设 d p [ x ] dp[ x] dp[x] 表示价值为x的最小凑的数量,边界的话显然, x = 0 x = 0 x=0 时最小要凑的数量为 0 0 0,所以边界就是 d p [ 0 ] = 0 dp[ 0] = 0 dp[0]=0
    • 所以不难得出状态转移方程

    d p [ x ] = m i n { d p [ x − c o i n s [ i ] ] + 1 } , x ≥ c o i n s [ i ] , i = 0 , 1 , 2 ⋯ dp[ x] = min \lbrace dp[ x - coins[ i]] + 1 \rbrace ,\ \ x \ge coins[ i],\ \ i = 0, 1, 2 \cdots dp[x]=min{dp[x−coins[i]]+1},  x≥coins[i],  i=0,1,2⋯

    • 代码如下
    class Solution {
    public:int coinChange(vector<int>& coins, int amount) {vector<int> dp(amount + 1, 0x3f3f3f3f);dp[0] = 0;for (int i = 0; i <= amount; i++) {for (auto c : coins) {if (i >= c) dp[i] = min(dp[i - c] + 1, dp[i]);}}if (dp[amount] == 0x3f3f3f3f) return -1;return dp[amount];}
    };
    

【例题2】leetcode 279 完全平方数

  • 大致题意:平方数{1, 4, 9, 16 ……},给你一个数n,让你用平方数组成该数,求最小组成数量

  • 简单的分析:

    • 把平方数看成硬币,然后就是硬币题了
    • 状态转移方程一样的,直接上代码
    class Solution {
    public:int numSquares(int n) {vector<int> dp(n + 1, 0);dp[0] = 0;for (int i = 1; i <= n; i++) {dp[i] = dp[i - 1] + 1;for (int j = 2; i - j * j >= 0; j++) {dp[i] = dp[i] > (dp[i - j * j] + 1) ? dp[i - j * j] + 1 : dp[i];}}return dp[n];}
    };
    

LIS模型

  • LIS就是所谓的最长不下降子序列问题,变种不多,但很经典,所以直接看例题

【例题1】leetcode 300 最长上升子序列

  • 题意:给定一个无序的整数数组,找到其中最长上升子序列的长度,注意严格上升

  • 简单的分析:

    • 分享一下一开始我学动规的时候LIS定义的状态是dp[i]表示下标为i的数组之前的最长上升子序列(即可以不选 n u m s [ i ] nums[i] nums[i]),但是这样来定义我发现很难进一步地对状态进行转移,后来还是妥协了书中的状态定义方式
    • 这件事现在看来也是有点感慨,因为现在的我明白:状态定义的不同,转移的方式可能也会完全不一样,所以有时候不同的状态定义会影响转移的难易程度,在思考一道动规题时,如果发现状态难以转移,不妨试试换个状态定义
    • 设dp[i]表示选择下标为i的数为结尾的最长子序列,这样做的好处就是方便后面的转移,方便利用当前下标数和之前算好的dp状态的下标数进行对比大小,可以想象成当前状态是和之前算好的状态进行拼接
    • 则转移方程为
      d p [ i ] = m a x { 1 , d p [ j ] + 1 ∣ 0 ≤ j < i , n u m s [ j ] < n u m s [ i ] } dp[i] = max\lbrace 1,\ \ dp[j] + 1 \ \ | \ \ 0 \le j < i, nums[j] < nums[i] \rbrace dp[i]=max{1,  dp[j]+1  ∣  0≤j<i,nums[j]<nums[i]}
    • 不难发现,状态转移需要利用到下标为j的原数组的数,所以这个说明了状态定义就很重要了
    • 最终的答案就是取其中的最大值
    • 这种做法的时间复杂度是O( n 2 n^2 n2),当然还有更快的速度可以达到O( n l o g n nlogn nlogn),这里不再赘述了
    • dp代码如下
    class Solution {
    public:int lengthOfLIS(vector<int>& nums) {int len = nums.size(), Max = 0;vector<int> dp(len, 1);for (int i = 0; i < len; i++) {for (int j = 0; j < i; j++) {if (dp[i] < dp[j] + 1 && nums[j] < nums[i]) dp[i] = dp[j] + 1;}Max = max(Max, dp[i]);}return Max;}
    };
    

LCS模型

  • LCS就是最长公共子序列,也很经典

【例题1】AcWing 897. 最长公共子序列

  • 题意:给你两个长度分别为n、m的字符串,让你求最长的公共子序列有多长

  • 简单的分析:

    • 设a序列的字符分别为 a 1 , a 2 , a 3 … … a n a_1, a_2,a_3……a_n a1​,a2​,a3​……an​,b序列的字符分别为 b 1 , b 2 , b 3 … … b m b_1, b_2, b_3……b_m b1​,b2​,b3​……bm​
    • 假如序列a为ab,b为a,则他们的LCS就是1,注意到,如果b序列后面加个b变为ab,则LCS就是2,但如果加的是c变为ac,则LCS则还是1
    • 设 d p [ i ] [ j ] dp[i][j] dp[i][j]表示a序列长度为i,b序列长度为j时的LCS,假设已经求出了之前的状态,则当 a i = b j a_i = b_j ai​=bj​时,则 d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 dp[i][j] = dp[i - 1][j - 1] + 1 dp[i][j]=dp[i−1][j−1]+1,即LCS长度加一,但如果不相等,则 d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) dp[i][j]=max(dp[i−1][j],dp[i][j−1]),不难看出这个转移方程的意义,下面上一个图更好理解
    • 其中红色是a序列的各个字符,橙黄色是b序列的各个字符,表格中的数字是相对应的LCS
    • 代码如下
    #include <cstdio>
    #include <algorithm>using namespace std;int n, m, dp[1010][1010];
    char a[1010], b[1010];int main() {scanf("%d%d%s%s", &n, &m, &a[1], &b[1]);for (int i = 1; i <= n; i++) {for (int j = 1; j <= m; j++) {if (a[i] == b[j]) dp[i][j] = dp[i - 1][j - 1] + 1;else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);}}printf("%d", dp[n][m]);return 0;
    }
    

背包模型

  • 背包问题是经典的一类动规问题
  • 篇幅过长已搬出(传送门)

DAG模型

  • DAG模型是一个概括非常广的模型,它包括凑硬币模型、背包模型等等,但还是要写写,因为我觉得DAG更多的是一种思想,把DP问题抽象成一个DAG的图,便于思考

【例题1】UVA 1025 A Spy in the Metro

  • 大致题意:一个线性的地铁,有 n ( 2 ≤ n ≤ 50 ) n(2 \le n \le 50) n(2≤n≤50)个站,目标是在 T ( 0 ≤ T ≤ 200 ) T(0 \le T \le 200) T(0≤T≤200)时刻从1号站到n号站,注是规定刚好T时刻,不是在T时刻之前也不是在T时刻之后,从1号点出发,可以在途中转乘,问你最少的中途等车时间

    • 第i站到第i + 1站需要花的时间为 t i t_i ti​
    • 有 M 1 ( M 1 ≤ 50 ) M1(M1 \le 50) M1(M1≤50)个车从1号站出发,出发时间分别为 d 1 , d 2 , d 3 … … , d M 1 ( d i < d i + 1 , d i ≤ 250 ) d_1, d_2, d_3……, d_{M1}\ \ (d_i < d_{i + 1}, d_i \le 250) d1​,d2​,d3​……,dM1​  (di​<di+1​,di​≤250),同样有M2个车从n号站出发,时间格式同上
    • 其余细节看题
  • 简单分析:

    • 就看当先的状态,假设现在的时间是tim,正在第p个站有三种策略

      • 等1分钟,剩下的下一分钟再说
      • 如果有向1号站行的车,乘坐上去
      • 如果有向n号站行的车,乘坐上去
    • 那么这个是怎么建立DAG图的呢,对于每个点,定义它包括的属性有时间和站点,如果当前时间当前站点有开往下个一或者上一个站的车,则将到站的时间和对应的站点连一条有向的边,例如当前时间是15,站点是3,并且有开向第2站点的车,10单位的时间后到达,但没有向下一个站开的车,则在属性为(15,3)的节点连一条有向边到(25,2)的节点,
    • 当然千万别忘了一点就是下1单位时间的同一站点也有一条边,即(15,3)到(16,3)也有一条有向边
    • 然后就可以利用这个DAG图的节点属性来定义状态了,即设dp[p][tim]为在p站点tim时刻的状态,既然是求最小路,则状态就是到当前节点的最短路
    • 对应三种策略的状态转移方式
      • 当前的状态等于下一单位时间的状态加1的等待时间
      • 当前状态等于到站后的时间和站点的状态
      • 同上
    • 最后取一个最小值
    • 边界条件就是如果在T时刻和n站点刚好到达,则返回0,因为不用等了,如果超过了T时间,则说明是从某个站点到另一个站点后时间过了,则返回无穷大表示不用等了,如果刚好到T时刻且没到达n站点,则说明后面再怎么坐车时间也过了,也返回无穷大
    • 说了这么多,却没写状态转移方程是因为打算使用记忆化来做,所有不用转移方程,直接考虑当前递归的来自哪些结果就好了
    • 代码如下
    #include <algorithm>
    #include <cstdio>
    #include <cstring>using namespace std;const int INF = 0x3f3f3f3f;int n, T, t[100], kase = 0, dp[110][210];
    bool has_train[110][210][2], vis[110][210];bool read() {int m, x;scanf("%d", &n);if(!n) return false;scanf("%d", &T);for (int i = 1; i < n; i++) scanf("%d", t + i);memset(has_train, 0, sizeof has_train);memset(vis, 0, sizeof vis);scanf("%d", &m);for (int i = 0; i < m; i++) {scanf("%d", &x);has_train[1][x][0] = true;for (int j = 2; j <= n; j++) {has_train[j][x + t[j - 1]][0] = true;x += t[j - 1];}}scanf("%d", &m);for (int i = 0; i < m; i++) {scanf("%d", &x);has_train[n][x][1] = true;for (int j = n - 1; j >= 1; j--) {has_train[j][x + t[j]][1] = true;x += t[j];}}return true;
    }int DP(int p, int tim) {int &d = dp[p][tim];if (tim > T) return INF;if (tim == T) return p == n ? 0 : INF;if (vis[p][tim]) return d;vis[p][tim] = true, d = INF;d = DP(p, tim + 1) + 1;if (p < n && has_train[p][tim][0]) d = min(d, DP(p + 1, tim + t[p]));if (p > 1 && has_train[p][tim][1]) d = min(d, DP(p - 1, tim + t[p - 1]));return d;
    }int main() {while (read()) {int ans = DP(1, 0);if (ans >= INF) printf("Case Number %d: impossible\n", ++kase);else printf("Case Number %d: %d\n", ++kase, ans);}return 0;
    }
    

【例子2】UVA 437 The Tower of Babylon

  • 题意:给你n种有无数个的立方体,现在让你用这些立方体堆一个塔,每个立方体的底面长宽都要严格小于下面立方体的底面长宽,问你最高能堆多高

  • 简单的分析

    • 能看得出来是一个矩形嵌套的变种问题,用DAG模型建立来做会非常好码代码
    • 一种立方体有三种摆放方式,假设一个立方体的长宽高为a、b、c,则三种摆放方式分别是以a、b、c为高的摆放方式,固一种立方体可以看成三个立方体
    • 下面就是建立DAG了,如果一个立方体的底面长宽严格大于另一个底面长宽的立方体则连一条有向边过去
    • 最后就DP就是求从某个节点出发的最大距离就是答案
    • 注意到,无论是思考还是码代码都不需要再去想状态方程来,直接当成一个图求最远距离来做,为了加速,我用了邻接表来建立图
    • 下面是代码
    #include <cstring>
    #include <cstdio>
    #include <algorithm>using namespace std;int n, kase = 0, head[100], cnt = 0, dp[500], vis[500];struct rect{int a, b, c;rect(){}rect(int a, int b, int c) : a(a), b(b), c(c) {}
    }r[100];struct edges{int to, next;edges(int to = 0, int next = -1) : to(to), next(next) {}
    }edge[10010];bool ok(const rect& x, const rect& y) {return (x.a > y.a && x.b > y.b) || (x.b > y.a && x.a > y.b);
    }void add_edge(int u, int v) { edge[++cnt] = edges(v, head[u]); head[u] = cnt; }bool read() {scanf("%d", &n);if (!n) return false;int x[3];for (int i = 0; i < 3 * n;) {for (auto &j : x) scanf("%d", &j);r[i++] = rect(x[0], x[1], x[2]);r[i++] = rect(x[0], x[2], x[1]);r[i++] = rect(x[1], x[2], x[0]);}memset(head, -1, sizeof head);memset(edge, 0, sizeof edge);memset(dp, 0, sizeof dp);memset(vis, 0, sizeof vis);cnt = 0;for (int u = 0; u < 3 * n; u++) {for (int v = 0; v < 3 * n; v++) {if (u == v) continue;if (!ok(r[u], r[v])) continue;add_edge(u, v);//邻接表建图}}return true;
    }int DP(int u) {if (u >= 3 * n) return 0;if (vis[u]) return dp[u];int &res = dp[u], Max = 0;res = r[u].c, vis[u] = true;for (int v = head[u]; ~v; v = edge[v].next) {//它的下一个节点Max = max(Max, DP(edge[v].to));}return res += Max;
    }int main() {while (read()) {int ans = 0;for (int i = 0; i < 3 * n; i++) ans = max(ans, DP(i));printf("Case %d: maximum height = %d\n", ++kase, ans);}return 0;
    }
    

【例题3】UVA 116 Unidirectional TSP

  • 大致题意:有个 m × n m \times n m×n矩阵,对于每个点你可以向直接向右、右上,右下走,第1行的上一行是第m行,第m行的下一行是第1行,问你从第一列的某一行出发,到达最后一列所经历的点的和最小是多少,并且打印出每一列的行号,如果有多解,输出字典序最小的

  • 简单的分析:

    • 可以看出是一个数塔问题的变种
    • 书中说这种问题叫做多阶段决策问题中的一类——多阶段图的最短路问题,所谓多阶段图按书中说法就是图中结点可以划分成若干个阶段
    • 在递归过程中即将完成的决策被称为阶段,回忆解答树中星号之前的第一个数字,应该就是书中所说的阶段吧,或者说每个序就是一个阶段(因为在01背包中,一个物品有选和不选两种决策,每个物品序号可以看成一个阶段)
    • 这题我的理解就是原数塔问题也可以说是一个DAG,而这题相当于多个数塔重合的版本,固每一列都是对应着一个的阶段,而每个阶段都有许多状态,每个状态都由上一个阶段能影响它的状态推得
    • 引用《算法笔记》的话是这样的:“它可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关
    • 计算最短路就是简单的数塔解法了
    • 真正让人头疼的是最小字典序这里,一开始我的做法是写一大堆if判断,最后对比了一下刘老师的代码,妙不可言,用了大小为3的数组存下一列的决策行数
    • 代码如下
    #include <cstring>
    #include <cstdio>
    #include <algorithm>using namespace std;const int INF = 0x3f3f3f3f;
    int m, n, g[15][110], ans[15][110], Min, dp[15][110], f;
    bool vis[15][110];int DP(int r, int c) {if (c == n) return 0;if (vis[r][c]) return dp[r][c];int &res = dp[r][c], M = INF, &i = ans[r][c + 1], row[] = {r - 1, r, r + 1};vis[r][c] = true, res = g[r][c];if (r == 0) row[0] = m - 1;if (r == m - 1) row[2] = 0;sort(row, row + 3);for (int j = 0; j < 3; j++)if (M > DP(row[j], c + 1)) M = DP(row[j], c + 1), i = row[j];res += M;return res;
    }int main() {while (~scanf("%d%d", &m, &n)) {for (int i = 0; i < m; i++)for (int j = 0; j < n; j++)scanf("%d", &g[i][j]);Min = INF;memset(vis, 0, sizeof vis);for (int i = 0; i < m; i++){int tmp = DP(i, 0);if (Min > tmp) Min = tmp, f = i;}printf("%d", f + 1);for (int i = 1, j = ans[f][i]; i < n; j = ans[j][++i])printf(" %d", j + 1);printf("\n%d\n", Min);}return 0;
    }
    

【例题4】UVA 12563 Jin Ge Jin Qu hao

  • 大致题意:在KTV里,如果还剩下1秒的时间,则可以点一首更长的歌,因为他会播放完最后一首歌才停止,现在给你剩下的时间 m m m和 n ( n ≤ 50 ) n(n \le 50) n(n≤50)首歌,每首歌的时长 t 1 , t 2 , t 3 … … t n t_1, t_2, t_3 ……t_n t1​,t2​,t3​……tn​,现让你算出在剩余的时间内能唱的歌的最大数量,然后利用空出来的时间最后再点一首长为678秒的歌,输出能唱的最大数量,对应的时间

  • 简单的分析:

    • 题目中说最后会点一首678秒的歌来延长时间,则策略就是计算在不超过 m − 1 m - 1 m−1时间里选最多的歌
    • 读一读上面的一句话,是不是感觉很想01背包?是的就是01背包问题,每个物品的价值默认是1了(一首歌嘛)
    • 题目中说 m ≤ 1 0 9 m \le 10^9 m≤109,其实并没有这么大,我一开始也很苦恼这怎么建数组,后来发现其实并没有这么大,他说n + 1首歌的时长严格大于剩余的时长,并且每一首歌不会超过3分钟,这么算的话 180 × 50 + 678 = 9678 180 \times 50 + 678 = 9678 180×50+678=9678,固数组完全够开
    • 值得注意的是这题计算在选歌的数量多的前提下最后尽量晚地结束KTV,这就需要讨论了,一开始被这个搞得十分地晕,冷静下来后发现一个if是不够的,于是就特判3次
      • 首先如果当前抉择的歌曲数量还没之前算的多,直接跳过
      • 如果当前抉择的歌曲数量严格比之前算的还多,则更新歌曲数量和时间总长度
      • 如果当前抉择的歌曲数量和之前算的一样多,则还要判断当前抉择的时长是严格比上次算还多,则更新时间总长度
    • 具体看代码
    #include <cstring>
    #include <cstdio>
    #include <algorithm>using namespace std;int n, t, dp[10010], m, kase = 0, sum[10010];void solve() {scanf("%d%d", &n, &m);memset(dp, 0, sizeof dp);memset(sum, 0, sizeof sum);for (int i = 0; i < n; i++) {scanf("%d", &t);for (int j = m; j > t; j--) {if (dp[j] > dp[j - t] + 1) continue;//特判1if (dp[j] < dp[j - t] + 1) {        //特判2dp[j] = dp[j - t] + 1;sum[j] = sum[j - t] + t;} else if (sum[j] < sum[j - t] + t){//特判3sum[j] = sum[j - t] + t;}}}printf("Case %d: %d %d\n", ++kase, dp[m] + 1, sum[m] + 678);
    }int main() {int T;scanf("%d", &T);while (T--) {solve();}return 0;
    }
    

4. 分类

① 线性DP

② 区间DP

③ 树型DP

  • 来自2021.3.22的更新,回来看了一下,以前的写博客也太蠢了吧,不想改了,直接看下面几篇博客点这里

  • 所谓树状DP(树形DP)就是说一个动态规划的问题他的数据(或者子问题)之间是建立在树的基础上提问的,即父节点的最优是由其子节点的最优来推出来的(大部分是这样)。

【例题1】HDU 1520 Anniversary party