中级农民
- 积分
- 105
- 大米
- 颗
- 鳄梨
- 个
- 水井
- 尺
- 蓝莓
- 颗
- 萝卜
- 根
- 小米
- 粒
- 学分
- 个
- 注册时间
- 2018-8-13
- 最后登录
- 1970-1-1
|
如果只是 DP 把 LZ 整的这么闹心的话,LZ 不如停下来想一想,dp 的本质是什么。不知道 LZ 有没有看过/课上用过 CLRS,DP 那一张开篇词中对于一般 dp 问题和 divide-and-conquer 问题进行了一番对比:
“Dynamic programming, like the divide-and-conquer method, solves problems by combining the solutions to subproblems......divide-and-conquer algorithms partition the problem into disjoint subproblems, solve the subproblems recursively.....”
停下来想一想,我们是如何解决divide-and-conquer问题的?对每个子问题递归求解
“.....In this context, a divide-and-conquer algorithm does more work than necessary, repeatedly solving the common subsubproblems.”
这里很关键,对于 dp 问题,用 divide-and-conquer 的老办法行不行?行!但是慢,存在重复求解的情况。
好了,到这里猜也能猜到了,dp 做了什么?dp 对递归过程进行了优化,我们将重复子问题的解存在一个“table”里面,来减少重复求解的次数。
在这里我认为有很重要的一条主线:dp 是对递归过程的优化。我觉得对于我们这样的非天赋异禀得选手,我觉得在思考一道 dp 问题的时候,至少从暴力递归到 dp 这个过程是不应当跳过的
与之对应,CLRS 紧接着给了解决一般 dp 问题的三个 step:
1. Characterize the structure of an optimal solution.
2. Recursively define the value of an optimal solution.
3. Compute the value of an optimal solution, typically in a bottom-up fashion.
很抽象,但紧接着 CLRS 在第一个 rod cutting 问题里面就给出了从暴力递归到记忆化搜索再到 dp 的完整过程,建议 LZ 好好读一下。
下面我用一个例子过一遍这个过程,例子是 LC.322 Coin Change 经典背包问题。
我先给出 recursive 版本,这个应该是都能写得出来:- class Solution {
- public int coinChange(int[] coins, int amount) {
- int len = coins.length;
- if(amount == 0) {
- return 0;
- }
-
- return recursive(amount, len, coins);
- }
-
- private int recursive(int remain, int len, int [] coins) {
- if(remain == 0) {
- return 0;
- }
-
- int minCnt = Integer.MAX_VALUE;
-
- for(int coin : coins) {
-
- if(remain - coin < 0) continue;
-
- int subRes = recursive(remain - coin, len, coins);
-
- if(subRes >= 0 && subRes < minCnt) {
- minCnt = subRes + 1;
- }
- }
-
- return minCnt == Integer.MAX_VALUE ? -1 : minCnt;
- }
- }
复制代码 作为一个超时解,如何优化成 dp ?这里就涉及到 dp 中的“状态”和“状态转移方程”
状态怎么找?回看我们的 recursive body,有什么是能够区分两个不同的 recursive call 的呢?只有 function signature 里面的argument remain,remain 的值不一样,那么我们所处的状态就不一样。
dp 数组要多大?往下看 recursive body,一上来递归结束条件说- if(remain == 0) return 0;
复制代码 在最初调用 recursive call 的时候,我们是这样写的- return recursive(amount, len, coins);
复制代码 那么,remain 的范围可以从 0 到 amount,左闭右闭区间。好了我们由此定义我们的 dp 数组- int [] dp = new int [amount + 1];
复制代码 dp 问题一般需要我们给一个 initial state,作为解决后续子问题的基础,这个其实也出现在 recursive body 当中了,就是 recursive 的结束条件- if(remain == 0) return 0;
复制代码 上面告诉我们,dp[0] = 0。为什么? 再次强调 remain 就是我们的“状态”
状态转移方程怎么找?其实不用找,我们都已经写在 recursive body 里面了,只需要把对应的 recursive call 换成 array indexing 就可以。原来 recursive body 当中最后的 return value 其实就是我们要的 dp的值。把 recursive body 改一下- int minCnt = Integer.MAX_VALUE;
-
- for(int coin : coins) {
- if(remain - coin < 0) continue;
-
- int subRes = dp[remain - coin]; // recursive call 改成 array indexing
-
- if(subRes >= 0 && subRes < minCnt) {
- minCnt = subRes + 1;
- }
- }
复制代码 至此,我们可以丢掉我们的 recursive body 了,把上面的内容合在一起就是这样- class Solution {
- public int coinChange(int[] coins, int amount) {
- int len = coins.length;
- if(amount == 0) {
- return 0;
- }
-
- int [] dp = new int [amount + 1];
-
- dp[0] = 0;
-
- for(int remain = 1; remain <= amount; remain ++) {
-
- int minCnt = Integer.MAX_VALUE;
-
- for(int coin : coins) {
- if(remain - coin < 0) continue;
-
- int subRes = dp[remain - coin];
-
- if(subRes >= 0 && subRes < minCnt) {
- minCnt = subRes + 1;
- }
- }
-
- dp[remain] = minCnt == Integer.MAX_VALUE ? -1 : minCnt;
- }
-
- return dp[amount];
- }
- }
复制代码 dp 版本算是完成了(瘫。。。)
回顾一下,看看能不能找到一些通用的解法:
1. 先写出 recursive 版本
2. “状态” 是根据 recursive function signature 中会变化的 arguments 找到的,可能有一个、两个、三个,对应一维、二维、三维 dp
3. dp 数组定义多大要去看“状态” 的变化范围,主要就是 看第一次 recursive call 和 recursive call 的 termination condition
4. dp 数组初始状态是从 recursive call 的 termination condition 找到的
5. “状态转移方程” 是根据 recursive body 改写出来的,基本上就是把 recursive function call 换成 array indexing
6. 计算 dp 数组的时候是从左往右算还是从右往左 要看最后 return statement 中我们 return 的是0位置上的值还是最后一个值。也会有一些问题要我们再次遍历一遍 dp 数组找出满足条件的值
其他:
1. 这个办法能所有 dp 问题吗?不能,但是能解决很大一部分。
2. 为啥别人写出来的和这样写出来的不一样?bottom-up dp 和 top-down dp 思路不同代码也可能不同/有的问题可以进行“状态压缩”/也有的问题确实 tricky
3. 这样也太慢了吧。没错一开始是很慢,多练就快了,只要能写出 recursive 版本,我觉得改成 dp 可能用不了几分钟。练得多了其实不一定要完完整整写出 recursive 版本,大概脑子里过一下 recursive 版本是什么样,状态/状态转移方程什么的也就出来了。
|
|