动态规划到底是什么鬼?dp数组到底是什么?

Posted 日常学习杂谈

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了动态规划到底是什么鬼?dp数组到底是什么?相关的知识,希望对你有一定的参考价值。


今天在leetcode上面做了一道很有意思的题目,虽然挂的是动态规划的标签,但是我做完这道题之后却觉得它其实很“不”动态规划。做完之后,拿它与别的题目比较了一下,有了一点个人体会,在这里记录一下,分享一下。



1. Leetcode838 ---《推多米诺骨牌》

题目描述:


解题思路:

一开始,我们当然还是按照以往的思路,找子问题 -> 状态定义 -> 状态转移

首先,让我们看看这道题是如何把大问题拆分成小问题的。其实,这道题这一步个人觉得是整个步骤最繁琐的,如果你能想清楚这个过程,那这道题其实就很简单。

整个题目有一个很重要的地方需要强调: 每过一秒,倒向一边的骨牌会推倒相应一侧相邻的骨牌。时间的限制,导致了这道题整体的复杂性。但我们要想解决一道所谓动态规划的题目,无非就是穷举所有状态,再想办法记录一些已经保存的状态从而达到时间性能上的优化。

我们先从一个最"简单"的情况入手:

假设我们一开始多米诺骨牌的状态是这样的
动态规划到底是什么鬼?dp数组到底是什么?
你看最左边和最右边都是 '.' ,那我们可以把这两个地方直接去掉吗?其实是不行的,因为你如果去掉最右边的多米诺骨牌,从上帝视角看,倒数第二个骨牌其实是会把最后一个骨牌推倒的,所以不能就这么直接去掉。

其实这道题就是这里很棘手,你不能随意地把某个位置的骨牌给切掉, 因为别的位置的骨牌可能会影响到你当前切掉的这个。这就是所谓的 子问题之间不相互独立。如果你现在返回去看之前我们做过的所有题,你会发现各个子问题之间必然是独立的,也就是说, 各个子问题之间是不可能相互影响的,这又是一个动态规划的大前提

那这道题究竟如何定义状态,才能使各个子问题之间相互独立呢?

给予我灵感的,是下面这种情况:
动态规划到底是什么鬼?dp数组到底是什么?
最左边是 R,最右边是 L,这样我们可以很简单地从左右两边同时往中边走,走一步推一步,一直到:
动态规划到底是什么鬼?dp数组到底是什么?
所以我想,能不能在原数组中,把所有这样的情况给提取出来(即 R....L)?你看, 如果是这种情况的话,那么从(R....L)这个区间上无论怎么推怎么倒,就都不会影响到两边了不是吗?这不就把原先的大问题切割出来,变成一个个独立的子问题了吗?

把这个思路转化成代码,也很简单,看下面的伪码:
  
    
    
  
end = dominoes.length left = 0 for i in range(0, end): if dominoes[i] == '.': continue elif dominoes[i] == 'R': left = i elif dominoes[i] == 'L': push_domino(left, i)
其中,pushDomino() 函数表示我们推倒 (R...L)这个区间上骨牌的过程,在 R的时候开始记录,遇到 '.' 我们就直接跳过,直到遇到了 L ,表示我们找到了一个这样的区间,这个时候就可以开始推了。

当然,并不是只有这种情况,我们还需要把其它可能会出现的问题解释一下:

你可能已经注意到了,遇到 R 的时候,我们会直接把当前区间的最左边更新为当前的位置,那我们如果连续遇到好几个 R 之后才遇到 L 怎么办呢?

动态规划到底是什么鬼?dp数组到底是什么?

很明显,这时候如果不去处理一下第2个 R之前的多米诺骨牌,就会产生错误,因为从上帝视角看,最后第二个和第三个骨牌必然也是倒向右边的。怎么处理呢?其实也很简单,只需要在更新 left之前,把前面的骨牌推一下就可以了, (R....R)这样一个区间内的骨牌怎么倒,它左边和右边部分的骨牌是影响不了的,这也是一个独立的子问题

更新一下代码,就变成下面这样:

  
    
    
  
end = dominoes.length left = 0 for i in range(0, end): if dominoes[i] == '.': continue elif dominoes[i] == 'R': push_domino(left, i) left = i elif dominoes[i] == 'L': push_domino(left, i)

还有一种情况是,在遇到L之前,我们没有遇到任何一个 R,其实这时候,也跟处理第二个 R的情况一样,只需要把 (....L)这个区间单独处理一下就可以了,在上面的代码已经隐含了这种情况,这里也不再赘述。

到此为止,我们已经把这个循环该处理的都处理了。但是还有最后一个小问题,需要我们注意一下。

动态规划到底是什么鬼?dp数组到底是什么?
如果我们压根就遇不到 L呢?其实也很容易分析:那整个循环就会直接走到完,我们只需要在循环结束之后,重新对当前的 (R....)区间进行一次 pushDomino() 就可以了。最后的伪码是这样的:

  
    
    
  
end = dominoes.length left = 0 for i in range(0, end): if dominoes[i] == '.': continue elif dominoes[i] == 'R': push_domino(left, i) left = i elif dominoes[i] == 'L': push_domino(left, i)push_domino(left, push_domino(left, end - 1)

到此为止,如何将问题转化为多个独立子问题,我们已经讲清楚了。那接下来要干什么,状态定义?状态转移方程?仔细想想,有必要吗?在我们这种定义方式下, 有可能出现重叠子问题吗

其实并不会出现重叠子问题:
在转化过程中,我一直都在强调,各个子问题之间是相互独立的,并且我们 只从左向右扫描了一遍数组,这个过程怎么会产生重叠子问题呢?

这也是我说这个问题很"不"动态规划的原因,因为它其实跟一般的动态规划题目很不一样, 我们不需要使用额外的数据结构记忆一些什么,就可以把所有情况无重复的一次性列举出来。虽说你硬要把这个过程叫做动态规划,也不是不行吧,毕竟 Dynamic Programming,按个人理解就是动态编程,将大问题转化为多个子问题来解决,这个过程其实也是动态的。

pushDomino()的实现

最后,我们来实现一下 pushDomino的过程。

其实也很简单,回顾上一部分,我们要处理的情况无非也就那几种:

  • R .... L
  • R .... R / R .......
  • ........ L

使用双指针的技巧, i指向头, j指向尾,不断往中间边走边推,就可以更新完最后的状态了。

解题完整的Java代码:
  
    
    
  
public class PushDomino {
private char[] cdominoes; public String pushDominoes(String dominoes) { int n = dominoes.length();
cdominoes = dominoes.toCharArray();
int curLeft = 0; for(int i = 0; i < n; i ++) if(cdominoes[i] != '.'){ pushDominoes(curLeft, i); curLeft = (cdominoes[i] == 'R' ? i : i + 1); }
//注解A: 最后的结果可能是curLeft == 'R', // 但是从curLeft一直到最后都没有Left;
// 注解B:并且这里的curLeft有可能为n pushDominoes(curLeft, n - 1);
return String.valueOf(cdominoes); }
private void pushDominoes(int left, int right){ //为什么需要 > ?, 对应上面的注解B if(left >= right) return; if(cdominoes[left] == '.' && cdominoes[right] == '.') return;
if(cdominoes[left] == 'R' && cdominoes[right] == 'L'){ int i = left + 1; int j = right - 1; while(i < j){ cdominoes[i ++] = 'R'; cdominoes[j --] = 'L'; } if(i == j) cdominoes[i] = '.'; }else if(cdominoes[left] == 'R') for(int i = left; i <= right; i ++) cdominoes[i] = 'R'; else if(cdominoes[right] == 'L') for(int i = right; i >= left; i --) cdominoes[i] = 'L'; } }

到此为止,整道题目已经讲解完了,但是我下面还想再说一个跟这道题情境很相像的一个算法, 就是大家都非常熟悉的归并排序。

2. 归并排序的情境

归并排序这个算法, 相信大家都很熟悉,下面我直接给出归并排序分解过程的树,我们来看看归并排序与这道题之间到底有什么相似的地方


归并排序的分解过程,相比我们上面做的这道题,其实容易很多。我们上面还要逐个去分析 (R .... L)的存在性,才能放心地将这个区间从整个区间切出来从而形成独立子问题。

而归并排序不用,直接对半分,对数组两边进行排序,就是两个独立的子问题。这也就是所谓的分治思想。

其实你仔细地回想以前做过的动态规划题目,你会发现无非是在分治的思想基础上,加入一步记忆化罢了。自底向上的分治 (由循环迭代实现),就是我们常说的动态规划。而自顶向上 (由递归实现)的分治,就是所谓的回溯记忆化搜索。

归并排序是单纯的分治,快速排序其实也是分治,只不过快速排序要分解出独立子问题稍微有些麻烦,需要经过一步额外的partition罢了,跟我们这道题类似。所以,我更倾向于称这道《推多米诺骨牌》为一道纯粹分治的题目而不是动态规划。
ps:其实这题也可以用dp数组记录状态做,但是那个做法其实挺难想到的,而且从时间性能上讲,这篇文章给出的解法并不会比那个算法差。有兴趣的朋友可以去题目相应位置看题解,这里也顺便给出我看的一个解释得比较清楚的题解。https://leetcode-cn.com/problems/push-dominoes/solution/xiang-xi-jie-shi-dong-tai-gui-hua-jie-fa-by-ripple/



好了,今天的分享就到这里。希望读者们如果有什么问题或意见,可以直接在下面回复提出来。第一次做公众号,排版方面有一些朋友可能看着不舒服,还请各位多多见谅。内容方面个人绝对是精心考虑过的,如果读者们看完觉得还可以,可以转给身边对算法或者java有兴趣的朋友看看。本公众号虽然是从算法开始,但是之后也打算写一些java方面的文章。希望大家多多包涵。

以上是关于动态规划到底是什么鬼?dp数组到底是什么?的主要内容,如果未能解决你的问题,请参考以下文章

linux下的动态链接库和静态链接库到底是个什么鬼?动态链接库的编译与使用

动态规划答疑篇

动态规划答疑篇

肝!动态规划

Java虚拟机到底是什么鬼,小白看这里!

算法面试课程笔记001 算法面试到底是什么鬼