动态规划问题为何要画表格?

本文是个人 91 算法第一期的部分讲义内容。 91 算法第一期已经接近尾声,二期的具体时间关注个人公众号便可,一旦开放,会第一时间在公众号《力扣加加》通知你们。

动态规划能够理解为是查表的递归(记忆化)。那么什么是递归?什么是查表(记忆化)?git

递归

定义: 递归是指在函数的定义中使用函数自身的方法。github

算法中使用递归能够很简单地完成一些用循环实现的功能,好比二叉树的左中右序遍历。递归在算法中有很是普遍的使用,包括如今日趋流行的函数式编程。算法

纯粹的函数式编程中没有循环,只有递归。

有意义的递归算法会把问题分解成规模缩小的同类子问题,当子问题缩写到寻常的时候,咱们能够知道它的解。而后咱们创建递归函数之间的联系便可解决原问题,这也是咱们使用递归的意义。准确来讲, 递归并非算法,它是和迭代对应的一种编程方法。只不过,咱们一般借助递归去分解问题而已。编程

一个问题要使用递归来解决必须有递归终止条件(算法的有穷性),也就是顺递归会逐步缩小规模到寻常。数组

虽然如下代码也是递归,但因为其没法结束,所以不是一个有效的算法:浏览器

def f(n):
  return n + f(n - 1)

更多的状况应该是:缓存

def f(n):
  if n == 1: return 1
  return n + f(n - 1)

练习递归

一个简单练习递归的方式是将你写的迭代所有改为递归形式。好比你写了一个程序,功能是“将一个字符串逆序输出”,那么使用迭代将其写出来会很是容易,那么你是否可使用递归写出来呢?经过这样的练习,可让你逐步适应使用递归来写程序。函数式编程

若是你已经对递归比较熟悉了,那么咱们继续往下看。函数

递归中的重复计算

递归中可能存在这么多的重复计算,为了消除这种重复计算,一种简单的方式就是记忆化递归。即一边递归一边使用“记录表”(好比哈希表或者数组)记录咱们已经计算过的状况,当下次再次碰到的时候,若是以前已经计算了,那么直接返回便可,这样就避免了重复计算。而动态规划中 DP 数组其实和这里“记录表”的做用是同样的性能

递归的时间复杂度分析

敬请期待个人新书。

小结

使用递归函数的优势是逻辑简单清晰,缺点是过深的调用会致使栈溢出。这里我列举了几道算法题目,这几道算法题目均可以用递归轻松写出来:

  • 递归实现 sum
  • 二叉树的遍历
  • 走楼梯问题
  • 汉诺塔问题
  • 杨辉三角

当你已经适应了递归的时候,那就让咱们继续学习动态规划吧!

动态规划

若是你已经熟悉了递归的技巧,那么使用递归解决问题很是符合人的直觉,代码写起来也比较简单。这个时候咱们来关注另外一个问题 - 重复计算 。咱们能够经过分析(能够尝试画一个递归树),能够看出递归在缩小问题规模的同时是否可能会重复计算279.perfect-squares 中 我经过递归的方式来解决这个问题,同时内部维护了一个缓存来存储计算过的运算,这么作能够减小不少运算。 这其实和动态规划有着殊途同归的地方。

小提示:若是你发现并无重复计算,那么就没有必要用记忆化递归或者动态规划了。

所以动态规划就是枚举因此可能。不过相比暴力枚举,动态规划不会有重复计算。所以如何保证枚举时不重不漏是关键点之一。 递归因为使用了函数调用栈来存储数据,所以若是栈变得很大,那么会容易爆栈。

爆栈

咱们结合求和问题来说解一下,题目是给定一个数组,求出数组中全部项的和,要求使用递归实现。

代码:

function sum(nums) {
  if (nums.length === 0) return 0;
  if (nums.length === 1) return nums[0];

  return nums[0] + sum(nums.slice(1));
}

咱们用递归树来直观地看一下。

dynamic-programming-1

这种作法自己没有问题,可是每次执行一个函数都有必定的开销,拿 JS 引擎执行 JS 来讲,每次函数执行都会进行入栈操做,并进行预处理和执行过程,因此内存会有额外的开销,数据量大的时候很容易形成爆栈。

浏览器中的 JS 引擎对于代码执行栈的长度是有限制的,超过会爆栈,抛出异常。

重复计算

咱们再举一个重复计算的例子,问题描述:

一我的爬楼梯,每次只能爬 1 个或 2 个台阶,假设有 n 个台阶,那么这我的有多少种不一样的爬楼梯方法?

因为上第 n 级台阶必定是从 n - 1 或者 n - 2 来的,所以 上第 n 级台阶的数目就是 上 n - 1 级台阶的数目加上 n - 1 级台阶的数目

递归代码:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return climbStairs(n - 1) + climbStairs(n - 2);
}

咱们继续用一个递归树来直观感觉如下:

dynamic-programming-2

红色表示重复的计算

能够看出这里面有不少重复计算,咱们可使用一个 hashtable 去缓存中间计算结果,从而省去没必要要的计算。

那么动态规划是怎么解决这个问题呢? 答案也是“查表”,不过区别于递归使用函数调用栈,动态规划一般使用的是 dp 数组,数组的索引一般是问题规模,值一般是递归函数的返回值。递归是从问题的结果倒推,直到问题的规模缩小到寻常。 动态规划是从寻常入手, 逐步扩大规模到最优子结构。

若是上面的爬楼梯问题,使用动态规划,代码是这样的:

function climbStairs(n) {
  if (n == 1) return 1;
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;

  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

不会也不要紧,咱们将递归的代码稍微改造一下。其实就是将函数的名字改一下:

function dp(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return dp(n - 1) + dp(n - 2);
}
dp[n] 和 dp(n) 对比看,这样是否是有点理解了呢? 只不过递归用调用栈枚举状态, 而动态规划使用迭代枚举状态。

动态规划的查表过程若是画成图,就是这样的:

dynamic-programming-3

虚线表明的是查表过程

这道题目是动态规划中最简单的问题了,由于设计到单个因素的变化,若是涉及到多个因素,就比较复杂了,好比著名的背包问题,挖金矿问题等。

对于单个因素的,咱们最多只须要一个一维数组便可,对于如背包问题咱们须要二维数组等更高纬度。

爬楼梯咱们并无必要使用一维数组,而是借助两个变量来实现的,空间复杂度是 O(1)。代码:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;

  let a = 1;
  let b = 2;
  let temp;

  for (let i = 3; i <= n; i++) {
    temp = a + b;
    a = b;
    b = temp;
  }

  return temp;
}

之因此能这么作,是由于爬楼梯问题的状态转移方程中当前状态只和前两个状态有关,所以只须要存储这两个便可。 动态规划问题有不少这种讨巧的方式,这个技巧叫作滚动数组。

再次强调一下:

  • 若是说递归是从问题的结果倒推,直到问题的规模缩小到寻常。 那么动态规划就是从寻常入手, 逐步扩大规模到最优子结构。
  • 记忆化递归和动态规划没有本质不一样。都是枚举状态,并根据状态直接的联系逐步推导求解。
  • 动态规划性能一般更好。 一方面是递归的栈开销,一方面是滚动数组的技巧。

动态规划的三个要素

  1. 状态转移方程
  2. 临界条件
  3. 枚举状态
能够看出,用递归解决也是同样的思路

在上面讲解的爬楼梯问题中,若是咱们用 f(n) 表示爬 n 级台阶有多少种方法的话,那么:

f(1) 与 f(2) 就是【边界】
f(n) = f(n-1) + f(n-2) 就是【状态转移公式】

我用动态规划的形式表示一下:

dp[0] 与 dp[1] 就是【边界】
dp[n] = dp[n - 1] + dp[n - 2] 就是【状态转移方程】

能够看出二者是多么的类似。

实际上临界条件相对简单,你们只有多刷几道题,里面就有感受。困难的是找到状态转移方程和枚举状态。这两个核心点的都创建在已经抽象好了状态的基础上。好比爬楼梯的问题,若是咱们用 f(n) 表示爬 n 级台阶有多少种方法的话,那么 f(1), f(2), ... 就是各个独立的状态

不过状态的定义都有特色的套路。 好比一个字符串的状态,一般是 dp[i] 表示字符串 s 以 i 结尾的 ....。 好比两个字符串的状态,一般是 dpi 表示字符串 s1 以 i 结尾,s2 以 j 结尾的 ....。

固然状态转移方程可能不止一个, 不一样的转移方程对应的效率也可能截然不同,这个就是比较玄学的话题了,须要你们在作题的过程当中领悟。

搞定了状态的定义,那么咱们来看下状态转移方程。

状态转移方程

爬楼梯问题因为上第 n 级台阶必定是从 n - 1 或者 n - 2 来的,所以 上第 n 级台阶的数目就是 上 n - 1 级台阶的数目加上 n - 1 级台阶的数目

上面的这个理解是核心, 它就是咱们的状态转移方程,用代码表示就是 f(n) = f(n - 1) + f(n - 2)

实际操做的过程,有可能题目和爬楼梯同样直观,咱们不难想到。也可能隐藏很深或者维度太高。 若是你实在想不到,能够尝试画图打开思路,这也是我刚学习动态规划时候的方法。当你作题量上去了,你的题感就会来,那个时候就能够不用画图了。

状态转移方程实在是没有什么灵丹妙药,不一样的题目有不一样的解法。状态转移方程同时也是解决动态规划问题中最最困难和关键的点,你们必定要多多练习,提升题感。接下来,咱们来看下不那么困难,可是新手疑问比较多的问题 - 如何枚举状态

如何枚举状态

前面说了如何枚举状态,才能不重不漏是枚举状态的关键所在。

  • 若是是一维状态,那么咱们使用一层循环能够搞定。
  • 若是是两维状态,那么咱们使用两层循环能够搞定。
  • 。。。

这样能够保证不重不漏。

可是实际操做的过程有不少细节好比:

  • 一维状态我是先枚举左边的仍是右边的?(从左到右遍历仍是从右到左遍历)
  • 二维状态我是先枚举左上边的仍是右上的,仍是左下的仍是右下的?
  • 里层循环和外层循环的位置关系(能够互换么)
  • 。。。

其实这个东西和不少因素有关,很难总结出一个规律,并且我认为也彻底没有必要去总结规律。不过这里我仍是总结了一个关键点,那就是:

  • 若是你没有使用滚动数组的技巧,那么遍历顺序取决于状态转移方程。好比:
for i in range(1, n + 1):
  dp[i] = dp[i - 1] + 1;

那么咱们就须要从左到右遍历,缘由很简单,由于 dp[i] 依赖于 dp[i - 1],所以计算 dp[i] 的时候, dp[i - 1] 须要已经计算好了。

二维的也是同样的,你们能够试试。
  • 若是你使用了滚动数组的技巧,则怎么遍历均可以,可是不一样的遍历意义一般不不一样的。好比我将二维的压缩到了一维:
for i in range(1, n + 1):
  for j in range(1, n + 1):
    dp[j] = dp[j - 1] + 1;

这样是能够的。 dp[j - 1] 实际上指的是压缩前的 dpi

而:

for i in range(1, n + 1):
  #  倒着遍历
  for j in range(n, 0, -1):
    dp[j] = dp[j - 1] + 1;

这样也是能够的。 可是 dp[j - 1] 实际上指的是压缩前的 dpi - 1。所以实际中采用怎么样的遍历手段取决于题目。我特地写了一个 【彻底背包问题】套路题(1449. 数位成本和为目标值的最大数字 文章,经过一个具体的例子告诉你们不一样的遍历有什么实际不一样,强烈建议你们看看,并顺手给个三连。

  • 关于里外循环的问题,其实和上面原理相似。

这个比较微妙,你们能够参考这篇文章理解一下 0518.coin-change-2

小结

关于如何肯定临界条件一般是比较简单的,多作几个题就能够快速掌握。

关于如何肯定状态转移方程,这个其实比较困难。 不过所幸的是,这些套路性比较强, 好比一个字符串的状态,一般是 dp[i] 表示字符串 s 以 i 结尾的 ....。 好比两个字符串的状态,一般是 dpi 表示字符串 s1 以 i 结尾,s2 以 j 结尾的 ....。 这样遇到新的题目能够往上套, 实在套不出那就先老实画图,不断观察,提升题感。

关于如何枚举状态,若是没有滚动数组, 那么根据转移方程决定如何枚举便可。 若是用了滚动数组,那么要注意压缩后和压缩前的 dp 对应关系便可。

动态规划为何要画表格

动态规划问题要画表格,可是有的人不知道为何要画,就以为这个是必然的,必要要画表格才是动态规划。

其实动态规划本质上是将大问题转化为小问题,而后大问题的解是和小问题有关联的,换句话说大问题能够由小问题进行计算获得。这一点是和用递归解决同样的, 可是动态规划是一种相似查表的方法来缩短期复杂度和空间复杂度。

画表格的目的就是去不断推导,完成状态转移, 表格中的每个 cell 都是一个小问题, 咱们填表的过程其实就是在解决问题的过程,

咱们先解决规模为寻常的状况,而后根据这个结果逐步推导,一般状况下,表格的右下角是问题的最大的规模,也就是咱们想要求解的规模。

好比咱们用动态规划解决背包问题, 其实就是在不断根据以前的小问题A[i - 1][j] A[i -1][w - wj]来询问:

  • 应该选择它
  • 仍是不选择它

至于判断的标准很简单,就是价值最大,所以咱们要作的就是对于选择和不选择两种状况分别求价值,而后取最大,最后更新 cell 便可。

其实大部分的动态规划问题套路都是“选择”或者“不选择”,也就是说是一种“选择题”。 而且大多数动态规划题目还伴随着空间的优化(滚动数组),这是动态规划相对于传统的记忆化递归优点的地方。除了这点优点,就是上文提到的使用动态规划能够减小递归产生的函数调用栈,所以性能上更好。

相关问题

总结

本篇文章总结了算法中比较经常使用的两个方法 - 递归和动态规划。递归的话能够拿树的题目练手,动态规划的话则将我上面推荐的刷完,再考虑去刷力扣的动态规划标签便可。

你们前期学习动态规划的时候,能够先尝试使用记忆化递归解决。而后将其改造为动态规划,这样多练习几回就会有感受。以后你们能够练习一下滚动数组,这个技巧颇有用,而且相对来讲比较简单。 比较动态规划的难点在于枚举因此状态(无重复)寻找状态转移方程

若是你只能记住一句话,那么请记住:递归是从问题的结果倒推,直到问题的规模缩小到寻常。 动态规划是从寻常入手, 逐步扩大规模到最优子结构。

另外,你们能够去 LeetCode 探索中的 递归 I 中进行互动式学习。

相关文章
相关标签/搜索