本文是个人 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 数组其实和这里“记录表”的做用是同样的。性能
敬请期待个人新书。
使用递归函数的优势是逻辑简单清晰,缺点是过深的调用会致使栈溢出。这里我列举了几道算法题目,这几道算法题目均可以用递归轻松写出来:
当你已经适应了递归的时候,那就让咱们继续学习动态规划吧!
若是你已经熟悉了递归的技巧,那么使用递归解决问题很是符合人的直觉,代码写起来也比较简单。这个时候咱们来关注另外一个问题 - 重复计算 。咱们能够经过分析(能够尝试画一个递归树),能够看出递归在缩小问题规模的同时是否可能会重复计算。 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)); }
咱们用递归树来直观地看一下。
这种作法自己没有问题,可是每次执行一个函数都有必定的开销,拿 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); }
咱们继续用一个递归树来直观感觉如下:
红色表示重复的计算
能够看出这里面有不少重复计算,咱们可使用一个 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) 对比看,这样是否是有点理解了呢? 只不过递归用调用栈枚举状态, 而动态规划使用迭代枚举状态。
动态规划的查表过程若是画成图,就是这样的:
虚线表明的是查表过程
这道题目是动态规划中最简单的问题了,由于设计到单个因素的变化,若是涉及到多个因素,就比较复杂了,好比著名的背包问题,挖金矿问题等。
对于单个因素的,咱们最多只须要一个一维数组便可,对于如背包问题咱们须要二维数组等更高纬度。
爬楼梯咱们并无必要使用一维数组,而是借助两个变量来实现的,空间复杂度是 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; }
之因此能这么作,是由于爬楼梯问题的状态转移方程中当前状态只和前两个状态有关,所以只须要存储这两个便可。 动态规划问题有不少这种讨巧的方式,这个技巧叫作滚动数组。
再次强调一下:
能够看出,用递归解决也是同样的思路
在上面讲解的爬楼梯问题中,若是咱们用 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 中进行互动式学习。