前一篇博客总结了动态规划,可是对于我这初学者,仍是不少地方不能理解,因此我就在网上找到了一个大神的讲解,确实很棒。转载过来。html
原文连接在下面参考资料。java
下面经过对斐波那契数列和这道凑零钱问题详解动态规划。若是只想看本题的答案,请直接翻到最后查看。python
动态规划算法彷佛是一种很高深莫测的算法,你会在一些面试或算法书籍的高级技巧部分看到相关内容,什么状态转移方程,重叠子问题,最优子结构等高大上的词汇也可能让你望而却步。面试
并且,当你去看用动态规划解决某个问题的代码时,你会以为这样解决问题居然如此巧妙,但却难以理解,你可能惊讶于人家是怎么想到这种解法的。算法
实际上,动态规划是一种常见的“算法设计技巧”,并无什么高深莫测,至于各类高大上的术语,那是吓唬别人用的,只要你亲自体验几把,这些名词的含义其实显而易见,再简单不过了。编程
至于为何最终的解法看起来如此精妙,是由于动态规划遵循一套固定的流程:递归的暴力解法 -> 带备忘录的递归解法 -> 非递归的动态规划解法。这个过程是层层递进的解决问题的过程,你若是没有前面的铺垫,直接看最终的非递归动态规划解法,固然会以为牛逼而不可及了。数组
固然,见的多了,思考多了,是能够一步写出非递归的动态规划解法的。任何技巧都须要练习,咱们先遵循这个流程走,算法设计也就这些套路,除此以外,真的没啥高深的。框架
如下,先经过两个个比较简单的例子:斐波那契和凑零钱问题,揭开动态规划的神秘面纱,描述上述三个流程。后续还会写几篇文章探讨如何使用动态规划技巧解决比较复杂的经典问题。函数
首先,第一个快被举烂了的例子,斐波那契数列。请读者不要嫌弃这个例子简单,由于简单的例子才能让你把精力充分集中在算法背后的通用思想和技巧上,而不会被那些隐晦的细节问题搞的莫名其妙。后续,困难的例子有的是。学习
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
复制代码
这个不用多说了,学校老师讲递归的时候彷佛都是拿这个举例。咱们也知道这样写代码虽然简洁易懂,可是十分低效,低效在哪里?假设 n = 20,请画出递归树。
PS:但凡遇到须要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的缘由都有巨大帮助。
这个递归树怎么理解?就是说想要计算原问题 f(20),我就得先计算出子问题 f(19) 和 f(18),而后要计算 f(19),我就要先算出子问题 f(18) 和 f(17),以此类推。最后遇到 f(1) 或者 f(2) 的时候,结果已知,就能直接返回结果,递归树再也不向下生长了。
递归算法的时间复杂度怎么计算?子问题个数乘以解决一个子问题须要的时间。
子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,因此子问题个数为 O(2^n)。
解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操做,时间为 O(1)。
因此,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。
观察递归树,很明显发现了算法低效的缘由:存在大量重复计算,好比 f(18) 被计算了两次,并且你能够看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更况且,还不止 f(18) 这一个节点被重复计算,因此这个算法及其低效。
这就是动态规划问题的第一个性质:重叠子问题。下面,咱们想办法解决这个问题。
明确了问题,其实就已经把问题解决了一半。即然耗时的缘由是重复计算,那么咱们能够造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,若是发现以前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
通常使用一个数组充当这个「备忘录」,固然你也可使用哈希表(字典),思想都是同样的。
int fib(int N) {
if (N < 1) return 0;
// 备忘录全初始化为 0
vector<int> memo(N + 1, 0);
return helper(memo, N);
}
int helper(vector<int>& memo, int n) {
if (n == 1 || n == 2) return 1;
if (memo[n] != 0) return memo[n];
// 未被计算过
memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
return memo[n];
}
复制代码
如今,画出递归树,你就知道「备忘录」到底作了什么。
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树经过「剪枝」,改形成了一幅不存在冗余的递归图,极大减小了子问题(即递归图中节点)的个数。
递归算法的时间复杂度怎么算?子问题个数乘以解决一个子问题须要的时间。
子问题个数,即图中节点的总数,因为本算法不存在冗余计算,子问题就是 f(1), f(2), f(3) ... f(20),数量和输入规模 n = 20 成正比,因此子问题个数为 O(n)。
解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。
因此,本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击。
至此,带备忘录的递归解法的效率已经和动态规划同样了。实际上,这种解法和动态规划的思想已经差很少了,只不过这种方法叫作「自顶向下」,动态规划叫作「自底向上」。
啥叫「自顶向下」?注意咱们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题好比说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,而后逐层返回答案,这就叫「自顶向下」。
啥叫「自底向上」?反过来,咱们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到咱们想要的答案 f(20),这就是动态规划的思路,这也是为何动态规划通常都脱离了递归,而是由循环迭代完成计算。
有了上一步「备忘录」的启发,咱们能够把这个「备忘录」独立出来成为一张表,就叫作 DP table 吧,在这张表上完成「自底向上」的推算岂不美哉!
int fib(int N) {
vector<int> dp(N + 1, 0);
dp[1] = dp[2] = 1;
for (int i = 3; i <= N; i++)
dp[i] = dp[i - 1] + dp[i - 2];
return dp[N];
}
复制代码
画个图就很好理解了,并且你发现这个 DP table 特别像以前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,因此说这两种解法实际上是差很少的,大部分状况下,效率也基本相同。
这里,引出 「动态转移方程」 这个名词,实际上就是描述问题结构的数学形式:
为啥叫「状态转移方程」?为了听起来高端。你把 f(n) 想作一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移,仅此而已。
你会发现,上面的几种解法中的全部操做,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及对备忘录或 DP table 的初始化操做,都是围绕这个方程式的不一样表现形式。可见列出「状态转移方程」的重要性,它是解决问题的核心。很容易发现,其实状态转移方程直接表明着暴力解法。
千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即这个暴力解。优化方法无非是用备忘录或者 DP table,再无奥妙可言。
这个例子的最后,讲一个细节优化。细心的读者会发现,根据斐波那契数列的状态转移方程,当前状态只和以前的两个状态有关,其实并不须要那么长的一个 DP table 来存储全部的状态,只要想办法存储以前的两个状态就好了。因此,能够进一步优化,把空间复杂度降为 O(1):
int fib(int n) {
if (n < 2) return n;
int prev = 0, curr = 1;
for (int i = 0; i < n - 1; i++) {
int sum = prev + curr;
prev = curr;
curr = sum;
}
return curr;
}
复制代码
有人会问,动态规划的另外一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来讲不算动态规划,以上旨在演示算法设计螺旋上升的过程。当问题中要求求一个最优解或在代码中看到循环和 max、min 等函数时,十有八九,须要动态规划大显身手。
下面,看第二个例子,凑零钱问题,有了上面的详细铺垫,这个问题会很快解决。
题目:给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,再给一个总金额 n,问你最少须要几枚硬币凑出这个金额,若是不可能凑出,则回答 -1 。
好比说,k = 3,面值分别为 1,2,5,总金额 n = 11,那么最少须要 3 枚硬币,即 11 = 5 + 5 + 1 。下面走流程。
1、暴力解法 首先是最困难的一步,写出状态转移方程,这个问题比较好写:
其实,这个方程就用到了「最优子结构」性质:原问题的解由子问题的最优解构成。即 f(11) 由 f(10), f(9), f(6) 的最优解转移而来。
记住,要符合「最优子结构」,子问题间必须互相独立。啥叫相互独立?你确定不想看数学证实,我用一个直观的例子来说解。
好比说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高...... 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高...... 固然,最终就是你每门课都是满分,这就是最高的总成绩。
获得了正确的结果:最高的总成绩就是总分。由于这个过程符合最优子结构,“每门科目考到最高”这些子问题是互相独立,互不干扰的。
可是,若是加一个条件:你的语文成绩和数学成绩会互相制约,此消彼长。这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会获得错误的结果。由于子问题并不独立,语文数学成绩没法同时最优,因此最优子结构被破坏。关注个人众公号 labuladong 看更多精彩算法文章
回到凑零钱问题,显然子问题之间没有相互制约,而是互相独立的。因此这个状态转移方程是能够获得正确答案的。
以后就没啥难点了,按照方程写暴力递归算法便可。
int coinChange(vector<int>& coins, int amount) {
if (amount == 0) return 0;
int ans = INT_MAX;
for (int coin : coins) {
// 金额不可达
if (amount - coin < 0) continue;
int subProb = coinChange(coins, amount - coin);
// 子问题无解
if (subProb == -1) continue;
ans = min(ans, subProb + 1);
}
return ans == INT_MAX ? -1 : ans;
}
复制代码
画出递归树:
时间复杂度分析:子问题总数 x 每一个子问题的时间。子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每一个子问题中含有一个 for 循环,复杂度为 O(k)。因此总时间复杂度为 O(k*n^k),指数级别。
2、带备忘录的递归算法
int coinChange(vector<int>& coins, int amount) {
// 备忘录初始化为 -2
vector<int> memo(amount + 1, -2);
return helper(coins, amount, memo);
}
int helper(vector<int>& coins, int amount, vector<int>& memo) {
if (amount == 0) return 0;
if (memo[amount] != -2) return memo[amount];
int ans = INT_MAX;
for (int coin : coins) {
// 金额不可达
if (amount - coin < 0) continue;
int subProb = helper(coins, amount - coin, memo);
// 子问题无解
if (subProb == -1) continue;
ans = min(ans, subProb + 1);
}
// 记录本轮答案
memo[amount] = (ans == INT_MAX) ? -1 : ans;
return memo[amount];
}
复制代码
不画图了,很显然「备忘录」大大减少了子问题数目,彻底消除了子问题的冗余,因此子问题总数不会超过金额数 n,即子问题数目为 O(n)。处理一个子问题的时间不变,还是 O(k),因此总的时间复杂度是 O(kn)。
3、动态规划
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int coin : coins)
if (coin <= i)
dp[i] = min(dp[i], dp[i - coin] + 1);
}
return dp[amount] > amount ? -1 : dp[amount];
}
复制代码
最后总结 若是你不太了解动态规划,还能看到这里,真得给你鼓掌,相信你已经掌握了这个算法的设计技巧。
计算机解决问题其实没有任何奇技淫巧,它惟一的解决办法就是穷举,穷举全部可能性。算法设计无非就是先思考“如何穷举”,而后再追求“如何聪明地穷举”。
列出动态转移方程,就是在解决“如何穷举”的问题。之因此说它难,一是由于不少穷举须要递归实现,二是由于有的问题自己的解空间复杂,不那么容易穷举完整。
备忘录、DP table 就是在追求“如何聪明地穷举”。用空间换时间的思路,是下降时间复杂度的不二法门,除此以外,试问,还能玩出啥花活?
可是智力题终究是智力题,真正的算法问题确定不会是投机取巧能搞定的。因此,本文就借石头游戏来说讲「假设两我的都足够聪明,最后谁会获胜」这一类问题该如何用动态规划算法解决。
博弈类问题的套路都差很少,下文举例讲解,其核心思路是在二维 dp 的基础上使用元组分别存储两我的的博弈结果。掌握了这个技巧之后,别人再问你什么俩海盗分宝石,俩人拿硬币的问题,你就告诉别人:我懒得想,直接给你写个算法算一下得了。
咱们「石头游戏」改的更具备通常性:
你和你的朋友面前有一排石头堆,用一个数组 piles 表示,piles[i] 表示第 i 堆石子有多少个。大家轮流拿石头,一次拿一堆,可是只能拿走最左边或者最右边的石头堆。全部石头被拿完后,谁拥有的石头多,谁获胜。
石头的堆数能够是任意正整数,石头的总数也能够是任意正整数,这样就能打破先手必胜的局面了。好比有三堆石头 piles = [1, 100, 3],先手无论拿 1 仍是 3,可以决定胜负的 100 都会被后手拿走,后手会获胜。
假设两人都很聪明,请你设计一个算法,返回先手和后手的最后得分(石头总数)之差。好比上面那个例子,先手能得到 4 分,后手会得到 100 分,你的算法应该返回 -96。
这样推广以后,这个问题算是一道 Hard 的动态规划问题了。博弈问题的难点在于,两我的要轮流进行选择,并且都贼精明,应该如何编程表示这个过程呢?
仍是强调屡次的套路,首先明确 dp 数组的含义,而后和股票买卖系列问题相似,只要找到「状态」和「选择」,一切就水到渠成了。
定义 dp 数组的含义是颇有技术含量的,同一问题可能有多种定义方法,不一样的定义会引出不一样的状态转移方程,不过只要逻辑没有问题,最终都能获得相同的答案。
我建议不要迷恋那些看起来很牛逼,代码很短小的奇技淫巧,最好是稳一点,采起可解释性最好,最容易推广的设计思路。本文就给出一种博弈问题的通用设计框架。
介绍 dp 数组的含义以前,咱们先看一下 dp 数组最终的样子:
下文讲解时,认为元组是包含 first 和 second 属性的一个类,并且为了节省篇幅,将这两个属性简写为 fir 和 sec。好比按上图的数据,咱们说 dp[1][3].fir = 10,dp[0][1].sec = 3。
先回答几个读者可能提出的问题:
这个二维 dp table 中存储的是元组,怎么编程表示呢?这个 dp table 有一半根本没用上,怎么优化?很简单,都不要管,先把解题的思路想明白了再谈也不迟。
如下是对 dp 数组含义的解释:
dp[i][j].fir 表示,对于 piles[i...j] 这部分石头堆,先手能得到的最高分数。
dp[i][j].sec 表示,对于 piles[i...j] 这部分石头堆,后手能得到的最高分数。
举例理解一下,假设 piles = [3, 9, 1, 2],索引从 0 开始
dp[0][1].fir = 9 意味着:面对石头堆 [3, 9],先手最终可以得到 9 分。
dp[1][3].sec = 2 意味着:面对石头堆 [9, 1, 2],后手最终可以得到 2 分。
复制代码
咱们想求的答案是先手和后手最终分数之差,按照这个定义也就是 dp[0][n-1].fir - dp[0][n-1].secdp[0][n−1].fir−dp[0][n−1].sec,即面对整个 piles,先手的最优得分和后手的最优得分之差。
写状态转移方程很简单,首先要找到全部「状态」和每一个状态能够作的「选择」,而后择优。
根据前面对 dp 数组的定义,状态显然有三个:开始的索引 i,结束的索引 j,当前轮到的人。
dp[i][j][fir or sec]
其中:
0 <= i < piles.length
i <= j < piles.length
复制代码
对于这个问题的每一个状态,能够作的选择有两个:选择最左边的那堆石头,或者选择最右边的那堆石头。 咱们能够这样穷举全部状态:
n = piles.length
for 0 <= i < n:
for j <= i < n:
for who in {fir, sec}:
dp[i][j][who] = max(left, right)
复制代码
上面的伪码是动态规划的一个大体的框架,股票系列问题中也有相似的伪码。这道题的难点在于,两人是交替进行选择的,也就是说先手的选择会对后手有影响,这怎么表达出来呢?
根据咱们对 dp 数组的定义,很容易解决这个难点,写出状态转移方程:
dp[i][j].fir = max(piles[i] + dp[i+1][j].sec, piles[j] + dp[i][j-1].sec)
dp[i][j].fir = max(选择最左边的石头堆, 选择最右边的石头堆)
# 解释:我做为先手,面对 piles[i...j] 时,有两种选择:
# 要么我选择最左边的那一堆石头,而后面对 piles[i+1...j]
# 可是此时轮到对方,至关于我变成了后手;
# 要么我选择最右边的那一堆石头,而后面对 piles[i...j-1]
# 可是此时轮到对方,至关于我变成了后手。
if 先手选择左边:
dp[i][j].sec = dp[i+1][j].fir
if 先手选择右边:
dp[i][j].sec = dp[i][j-1].fir
# 解释:我做为后手,要等先手先选择,有两种状况:
# 若是先手选择了最左边那堆,给我剩下了 piles[i+1...j]
# 此时轮到我,我变成了先手;
# 若是先手选择了最右边那堆,给我剩下了 piles[i...j-1]
# 此时轮到我,我变成了先手。
复制代码
根据 dp 数组的定义,咱们也能够找出 base case,也就是最简单的状况:
dp[i][j].fir = piles[i]
dp[i][j].sec = 0
其中 0 <= i == j < n
# 解释:i 和 j 相等就是说面前只有一堆石头 piles[i]
# 那么显然先手的得分为 piles[i]
# 后手没有石头拿了,得分为 0
复制代码
这里须要注意一点,咱们发现 base case 是斜着的,并且咱们推算 dp[i][j] 时须要用到 dp[i+1][j] 和 dp[i][j-1]:
因此说算法不能简单的一行一行遍历 dp 数组,而要斜着遍历数组:
说实话,斜着遍历二维数组提及来容易,你还真不必定能想出来怎么实现,不信你思考一下?这么巧妙的状态转移方程都列出来了,要是不会写代码实现,那真的很尴尬了。。。
如何实现这个 fir 和 sec 元组呢,你能够用 python,自带元组类型;或者使用 C++ 的 pair 容器;或者用一个三维数组 dp[n][n][2],最后一个维度就至关于元组;或者咱们本身写一个 Pair 类:
class Pair {
int fir, sec;
Pair(int fir, int sec) {
this.fir = fir;
this.sec = sec;
}
}
复制代码
而后直接把咱们的状态转移方程翻译成代码便可,能够注意一下斜着遍历数组的技巧:
/* 返回游戏最后先手和后手的得分之差 */
int stoneGame(int[] piles) {
int n = piles.length;
// 初始化 dp 数组
Pair[][] dp = new Pair[n][n];
for (int i = 0; i < n; i++)
for (int j = i; j < n; j++)
dp[i][j] = new Pair(0, 0);
// 填入 base case
for (int i = 0; i < n; i++) {
dp[i][i].fir = piles[i];
dp[i][i].sec = 0;
}
// 斜着遍历数组
for (int l = 2; l <= n; l++) {
for (int i = 0; i <= n - l; i++) {
int j = l + i - 1;
// 先手选择最左边或最右边的分数
int left = piles[i] + dp[i+1][j].sec;
int right = piles[j] + dp[i][j-1].sec;
// 套用状态转移方程
if (left > right) {
dp[i][j].fir = left;
dp[i][j].sec = dp[i+1][j].fir;
} else {
dp[i][j].fir = right;
dp[i][j].sec = dp[i][j-1].fir;
}
}
}
Pair res = dp[0][n-1];
return res.fir - res.sec;
}
复制代码
动态规划解法,若是没有状态转移方程指导,绝对是一头雾水,可是根据前面的详细解释,读者应该能够清晰理解这一大段代码的含义。
并且,注意到计算 dp[i][j] 只依赖其左边和下边的元素,因此说确定有优化空间,转换成一维 dp,想象一下把二维平面压扁,也就是投影到一维。可是,一维 dp 比较复杂,可解释性不好,你们就没必要浪费这个时间去理解了。
本文给出了解决博弈问题的动态规划解法。博弈问题的前提通常都是在两个聪明人之间进行,编程描述这种游戏的通常方法是二维 dp 数组,数组中经过元组分别表示两人的最优决策。
之因此这样设计,是由于先手在作出选择以后,就成了后手,后手在对方作完选择后,就变成了先手。这种角色转换使得咱们能够重用以前的结果,典型的动态规划标志。
读到这里的朋友应该能理解算法解决博弈问题的套路了。学习算法,必定要注重算法的模板框架,而不是一些看起来牛逼的思路,也不要奢求上来就写一个最优的解法。不要舍不得多用空间,不要过早尝试优化,不要害怕多维数组。dp 数组就是存储信息避免重复计算的,随便用,直到咱满意为止。
了解了动态规划的套路,也不会写状态转移方程,没有思路,怎么办?本文就借助「最长递增子序列」来说一种设计动态规划的通用技巧:数学概括思想。
最长递增子序列(Longest Increasing Subsequence,简写 LIS)是比较经典的一个问题,比较容易想到的是动态规划解法,时间复杂度 O(N^2),咱们借这个问题来由浅入深讲解如何写动态规划。比较难想到的是利用二分查找,时间复杂度是 O(NlogN),咱们经过一种简单的纸牌游戏来辅助理解这种巧妙的解法。
先看一下题目,很容易理解:
注意「子序列」和「子串」这两个名词的区别,子串必定是连续的,而子序列不必定是连续的。下面先来一步一步设计动态规划算法解决这个问题。
动态规划解法 动态规划的核心设计思想是数学概括法。
相信你们对数学概括法都不陌生,高中就学过,并且思路很简单。好比咱们想证实一个数学结论,那么咱们先假设这个结论在 k<nk<n 时成立,而后想办法证实 k=nk=n 的时候此结论也成立。若是可以证实出来,那么就说明这个结论对于 k 等于任何数都成立。
相似的,咱们设计动态规划算法,不是须要一个 dp 数组吗?咱们能够假设 dp[0...i-1]dp[0...i−1] 都已经被算出来了,而后问本身:怎么经过这些结果算出 dp[i]?
直接拿最长递增子序列这个问题举例你就明白了。不过,首先要定义清楚 dp 数组的含义,即 dp[i] 的值到底表明着什么?
咱们的定义是这样的:dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。
举两个例子:
算法演进的过程是这样的,:
根据这个定义,咱们的最终结果(子序列的最大长度)应该是 dp 数组中的最大值。
int res = 0;
for (int i = 0; i < dp.size(); i++) {
res = Math.max(res, dp[i]);
}
return res;
复制代码
读者也许会问,刚才这个过程当中每一个 dp[i] 的结果是咱们肉眼看出来的,咱们应该怎么设计算法逻辑来正确计算每一个 dp[i] 呢?
这就是动态规划的重头戏了,要思考如何进行状态转移,这里就可使用数学概括的思想:
咱们已经知道了 dp[0...4]dp[0...4] 的全部结果,咱们如何经过这些已知结果推出 dp[5]dp[5] 呢?
根据刚才咱们对 dp 数组的定义,如今想求 dp[5] 的值,也就是想求以 nums[5] 为结尾的最长递增子序列。
nums[5] = 3,既然是递增子序列,咱们只要找到前面那些结尾比 3 小的子序列,而后把 3 接到最后,就能够造成一个新的递增子序列,并且这个新的子序列长度加一。
固然,可能造成不少种新的子序列,可是咱们只要最长的,把最长子序列的长度做为 dp[5] 的值便可。
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
复制代码
这段代码的逻辑就能够算出 dp[5]。到这里,这道算法题咱们就基本作完了。读者也许会问,咱们刚才只是算了 dp[5] 呀,dp[4], dp[3] 这些怎么算呢?
相似数学概括法,你已经能够算出 dp[5] 了,其余的就均可以算出来:
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
复制代码
还有一个细节问题,dp 数组应该所有初始化为 1,由于子序列最少也要包含本身,因此长度最小为 1。下面咱们看一下完整代码:
public int lengthOfLIS(int[] nums) {
int[] dp = new int[nums.length];
// dp 数组全都初始化为 1
Arrays.fill(dp, 1);
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j])
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
int res = 0;
for (int i = 0; i < dp.length; i++) {
res = Math.max(res, dp[i]);
}
return res;
}
复制代码
至此,这道题就解决了,时间复杂度 O(N^2)。总结一下动态规划的设计流程:
首先明确 dp 数组所存数据的含义。这步很重要,若是不得当或者不够清晰,会阻碍以后的步骤。
而后根据 dp 数组的定义,运用数学概括法的思想,假设 dp[0...i-1]dp[0...i−1] 都已知,想办法求出 dp[i]dp[i],一旦这一步完成,整个题目基本就解决了。
但若是没法完成这一步,极可能就是 dp 数组的定义不够恰当,须要从新定义 dp 数组的含义;或者多是 dp 数组存储的信息还不够,不足以推出下一步的答案,须要把 dp 数组扩大成二维数组甚至三维数组。
最后想想问题的 base case 是什么,以此来初始化 dp 数组,以保证算法正确运行。
此篇博客为转载总结,为了更加深刻理解动态规划。 感谢原做者,如下为连接。