概念
动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
解题思路(具体步骤)
步骤一:定义状态。将原问题划分为若干个子问题,定义状态表示子问题的解,通常使用一个数组或者矩阵来表示。
步骤二:确定状态转移方程(一般都写在循环中)。在计算子问题的基础上,逐步构建出原问题的解;这个过程通常使用“状态转移方程”来描述,表示从一个状态转移到另一个状态时的转移规则。
步骤三:初始化状态。
步骤四:计算原问题的解(最终答案)。通过计算状态之间的转移,最终计算出原问题的解;通常使用递归或者迭代的方式计算。
动态规划初体验
斐波那契数列是一个特殊的数列,它的定义如下:
F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
那么现在要求第n个斐波那契数列的数值,我们该怎么做呢?
递归
递归应该是我们第一个能想到的方法,因为使用递归非常简单:
function fib(n: number): number {
if (n === 0) return 0
if (n === 1) return 1
return fib(n - 1) + fib(n - 2);
};
递归虽然简单,但是它的效率是非常低的,因为同一个子问题会被重复计算很多次。
记忆化搜索
既然同一个子问题会被重复计算,那么我们就使用一个数组存一下已经被处理的子问题结果:
function fib(n: number, memo: number[] = []): number {
if (n <= 1) {
return n;
}
// 如果有结果直接返回
if (memo[n]) {
return memo[n];
} else {
memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
}
return memo[n]
}
虽然记忆化搜索避免了一个子问题被重复计算,但是对应的递归函数还是会被压入执行栈,这也是不必要的性能消耗。
动态规划
为了避免大量不必要入栈,我们直接放弃递归,直接在函数内声明一个数组用来存储子问题值,然后使用for循环依次计算保存:
function fibDP(n: number): number {
const dp: number[] = [];
for (let i = 0; i <= n; i++) {
if (i <= 1) {
dp[i] = i;
continue;
}
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
这种实现方式就可以称之为动态规划。回顾一下动态规划的定义:把原问题分解为相对简单的子问题的方式求解复杂问题的方法。这里的子问题就是计算出前两个位置的值,然后相加得到当前位置的值也就是原问题的解。
再套用一下思路中的四个步骤:
步骤一:定义状态。我们使用dp数组来保存子问题的解。
步骤二:状态转移方程。dp[i] = dp[i - 1] + dp[i - 2]。
步骤三:初始化状态。这里的初始化状态就是dp[0]=0,dp[1]=1。
步骤四:计算原问题的解。我们这里就是dp[n]。
到这里相信对于动态规划你已经有了一个基本的了解,它并不是洪水猛兽,几乎所有的动态规划题目都可以用上述的四步来解决。
状态压缩
对于上述的代码,其实还有优化空间:我们并不需要保存所有的子问题的状态,因为我们的最终解只需要前两个子问题的状态,所以我们可以使用两个变量来保存前两个子问题的状态:
function fibDP(n: number): number {
if (n <= 1) return n;
// 定义状态和初始化状态(步骤一和步骤三)
let prev = 0;
let cur = 1;
for (let i = 2; i <= n; i++) {
// 状态转移方程(步骤二)
cur = prev + cur;
prev = cur - prev;
}
// 返回最终结果(步骤四)
return cur;
}
这种将状态的存储空间从数组压缩为一个常数的方法叫做动态压缩,这也是动态规划算法中一种常见的优化策略。
爬楼梯
leetcode70题:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
刚看完这道题目你可能有点懵,甚至一点思路都没有,但是当你分析一下就会发现,这不跟斐波那契数列一样吗?根据题中条件,每次只能跳1或2个台阶,所以到第n个台阶只能从第n-1或者第n-2个台阶跳上去,所以只需要计算出到n-1个台阶的方法数加上到第n-2个台阶的方法数就能得出答案。这道题与斐波拉契的区别就是没有明确的告诉我们状态转移方程,需要我们自己去总结出来。代码实现与斐波拉契基本一致,这里不再给出。
买卖股票的最佳时机
leetcode121题:给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择某一天买入这只股票,并选择在未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回0。
我们直接套动态规划的步骤:
- 定义状态:dp[i]为第i天能获得的最大利润
- 状态转移方程:dp[i] = prices[i] - preMinPrice(i天前的最低价格)
- 初始状态:dp[0] = 0
- 最大利润就是状态数组中的最大值,为了提升性能,可以使用状态压缩,使用一个变量max实时记录最大利润
代码实现:
function maxProfit(prices: number[]): number {
let min = prices[0], // 初始最低价格
len = prices.length,
max = 0; // 初始状态
for(let i = 1; i < len; i++) {
let curPrice = prices[i]
// 只有当天价格大于前面最低价格时才用计算
if (curPrice > min) {
// 状态转移方程 实时更新max
max = Math.max(max, curPrice - min)
} else {
// 更新最低价格
min = curPrice
}
}
return max;
};
最大子数组和
leetcode53题:给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
步骤:
- 定义状态:dp[i]为以i位置元素结尾的连续最大子数组和
- 状态转移方程:dp[i] = Math.max(nums[i], dp[i - 1])
- 初始状态:dp[0] = nums[0]
- 结果:dp数组中的最大值
function maxSubArray(nums: number[]): number {
let dp: number[] = [nums[0]], // 定义状态和初始化状态
len = nums.length
for(let i = 1; i < len; i++) {
// 状态转移方程
dp[i] = Math.max(nums[i], nums[i] + dp[i - 1])
}
// 求最大值
return Math.max(...dp);
};
由于dp[i]只与dp[i - 1]有关,所以我们可以进行状态压缩使用一个变量存储dp[i - 1]即可:
function maxSubArray(nums: number[]): number {
let preMax = nums[0], len = nums.length, max = preMax
for(let i = 1; i < len; i++) {
preMax = Math.max(nums[i], nums[i] + preMax);
max = Math.max(preMax, max)
}
return max;
};
总结
看到这里相信你对动态规划已经有了一定的认识,并且掌握了一定的解题思路。但是你还是会发现定义状态和状态转移方程常常是我们想不到的,这也是动态规划的核心与难点,但也不要因此畏惧它,只要我们见识更多的动态规划题目,多积累,一定是可以熟练地掌握常见的动态规划问题的。后面我也会带来更多的动态规划题目来跟大家一起积累相关解题经验。