在上一篇文章动态规划的文章中,咱们先由 Fibonacci 例子引入到了动态规划中,而后借助兑换零钱的例子,分析了动态规划最主要的三个性质,即:java
可是动态规划远不止这么简单。算法
今天这篇文章,让咱们深刻动态规划,一窥动态规划的本质。数组
咱们既然要完全搞清楚动态规划,那么一个不可避免的问题就是:学习
递归,贪心,记忆化搜索和动态规划之间到底有什么不一样?code
那么,动态规划的核心究竟是什么?递归
要回答这个问题,咱们不妨先回答下面这个问题:游戏
到底哪些问题适合用动态规划即?怎么鉴定 DP 可解问题?内存
相信当咱们认识到哪些问题能够用 DP 解决,咱们也就天然找到了 DP 和其它算法思想的区别,也就是动态规划的核心。ci
首先咱们要搞清楚,动态规划只适用于某一类问题,只是某一类问题的解决方法。get
那么这“某一类问题”是什么问题呢?
聊这个以前咱们有必要稍微了解下计算机的本质。
基于冯诺依曼体系结构的计算机本质上是一个状态机,为何这么说呢?由于 CPU 要进行计算就必须和内存打交道。
由于数据存储在内存当中(寄存器和外盘性质也同样),没有数据 CPU 计算个空气啊?因此内存就是用来保存状态(数据)的,内存中当前存储的全部数据构成了当前的状态,CPU 只能利用当前的状态计算下一个状态。
咱们用计算机处理问题,无非就是在思考:如何用变量来储存状态,以及如何在状态之间转移:由一些变量计算出另外一些变量,由当前状态计算出下一状态。
基于这些,咱们也就获得了评判算法的优劣最主要的两个指标:
若是上述表述还不是很清楚,那咱们仍是举以前 Fibonacci 的例子来讲:
即:
也就是说当前状态只与前两个状态有关,因此对于空间复杂度:咱们只需保存前两个状态便可。
这也就很好的解释了为何动态规划并非单纯的空间换时间,由于它其实只跟状态有关。
由一个状态转移到另外一状态所需的计算时间也是常数,故线性增长的状态,其总的时间复杂度也是线性的。
以上即是动态规划的核心,即:
状态的定义及状态之间的转移(状态方程的定义)。
那么如何定义所谓的“状态”和“状态之间的转移”呢?
咱们引入维基百科的定义:
dynamic programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems.
那就是经过拆分问题,定义问题状态和状态之间的关系,使得问题可以以递推(或者说分治)的方式去解决。
纸上谈来终觉浅,下边咱们再来看一道一样很是经典的例题。
这是 LeetCode 第 300 题。
给定一个数列,长度为 N,求这个数列的最长上升(递增)子数列(LIS)的长度.
示例 1:
输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],所以长度为4示例 2:
输入:nums = [0,1,0,3,2,3] 输出:4 解释:最长递增序列是 [0,1,2,3],所以长度为4
咱们如何进行状态的定义及状态间转移的定义呢?
首先咱们应该进行问题的拆分,即进行这个问题子问题的定义。
因此,咱们从新定义一下这个问题:
给定一个数列,长度为 N,
设 F~k~为:给定数列中第 k 项结尾的最长递增子序列的长度
求 F~1~到 F~N~的最大值
是否是上边这个定义与原问题同样?
显然两者等价,不过明显第二种定义的方式,咱们找到了子问题。
对于 F~k~来说,F~1~到 F~k-1~都是 F~k~的子问题。
上述新问题的 F~k~ 就叫作 状态。
F~k~为数列中第 k 项结尾的 LIS 的长度 即为状态的定义。
状态定义好以后,状态与状态之间的关系式,就叫状态转移方程。
此题以 F~k~的定义来讲:
设 F~k~为:给定数列中第 k 项结尾的最长递增子序列的长
思考,状态之间应该怎么转移呢?
还记得咱们以前说的拆分问题不,在这里一样咱们能够沿用这一招,即拆分数据。
若是数列只有一个数呢?那咱们应该返回 1(咱们找到了状态边界状况)。
那么咱们能够写出如下状态转移方程:
F~1~ = 1
F~k~ = max ( F~i~ + 1 | i ∈(1,k-1))(k > 1)
即:以第 k 项结尾的 LIS 的长度是:max { 以第 i 项结尾的 LIS 长度 + 1 }, 第 i 项比第 k 项小
你们理解下,是否是这么回事~
回忆一下咱们是怎么作的?
写出了状态转移方程,能够说到此,动态规划算法核心的思想咱们已经表达出来了。
剩下的只不过是用记忆化地求解递推式的方法来解决就好了。
下面咱们尝试写出代码。
首先咱们定义 dp 数组:
int[] dp = new int[nums.length];
(注意这里 dp 数组的大小跟上一篇文章兑换零钱的例子有一丢丢不一样,即这里没有+1,你们能够再点击这里看下上一篇文章仔细理解一下。)
那么这里 dp 数组的含义就是:
dp[i] 保存的值便是给定数组 i 位以前最长递增子序列的长度。
那么咱们的初始状态是什么呢?
咱们知道状态的边界状况为:
F~1~ = 1
因此,初始状态咱们给 dp 数组每一个位置都赋为 1.
Arrays.fill(dp, 1);
而后,咱们从给定数组的第一个元素开始遍历,即写出外层的 for 循环:
for(int i = 0; i < nums.length;i++){ ...... }
当咱们外层遍历到某元素时,咱们怎么作呢?
咱们得找一下,在这个外层元素以前,存不存在比它小的数,若是存在,那么咱们就更新此外层元素的 dp[i]
若是某元素以前有比它小的数,那么这不就构成了递增子序列了吗?
所以咱们能够写出内层 for 循环:
for (int j = 0; j < i; j++) { //若是前面有小于当前外层nums[i]的数,那么就令当前dp[i] = dp[j] + 1 if (nums[j] < nums[i]) { //由于当前外层nums[i]前边可能有多个小于它的数,即存在多种组合,咱们取最大的一组放到dp[i]里 dp[i] = Math.max(dp[i], dp[j] + 1); } }
两层循环结束时,dp[] 数组里存储的就是相应元素位置以前的最大递增子序列长度,咱们只需遍历 dp[] 数组寻找出最大值,便可求得整个数组的最大递增子序列长度:
int res = 0; for(int k = 0; k < dp.length; k++){ res = Math.max(res, dp[k]); }
此题代码也就写完了,下面贴出完整代码:
class Solution { public int lengthOfLIS(int[] nums) { if(nums.length < 2) return 1; int[] dp = new int[nums.length]; Arrays.fill(dp,1); for(int i = 0;i < nums.length;i++){ for(int j = 0;j < i;j++){ if(nums[j] < nums[i]){ dp[i] = Math.max(dp[i],dp[j] + 1); } } } int res = 0; for(int k = 0;k < dp.length;k++){ res = Math.max(res,dp[k]); } return res; } }
这个题两层 for 循环跟以前兑换零钱的代码基本上差很少,你们能够结合上一篇文章再一块儿对比理解。
不一样之处只是内层 for 循环的判断条件和状态转移方程的表达(如何更新 dp[]),这也是动态规划的本质所在。
关于动态规划有不少误区和误解,好比最多见的可能就是说它是空间换时间,以及搞不清楚它和贪心的区别。
但愿这两篇动态规划的文章能帮你消除这些误区,而且更好的理解到动态规划的本质,理解状态和状态方程。
固然,仅仅这两篇文章想说透动态规划是远远不够的,因此接下来会具体的讲解一些典型问题,好比背包问题、石子游戏、股票问题等等,但愿能帮你在学习算法的道路上少走一些弯路。
若是你们有什么想了解的算法和题目类型,很是欢迎在评论区留言告诉我,咱们下期见!