动态规划
简介
很多问题可以使用递归来解决,但是,递归代码虽然简介,对于机器而言,效率却不高。本质上说,是因为那些指令式编程语言和面向对象编程语言对递归的实现不够完善,因为它们没有将递归作为高级编程的特性。
许多可以用递归解决的问题,可以重写为使用动态规划的技术去解决。
动态规划通常会使用一个数组来建立一张表,用于存放被分解成众多子问题的解,当算法执行完成后,最终的解会在这个表(数组)中找到。
动态规划有几个特征
- 最优子结构
- 状态转移方程
- 边界
- 重叠子问题
动态规划的解决思路
使用场景
如果一个问题,可以把所有可能的答案穷举出来,并且穷举后,发现存在重叠子问题,就可以考虑使用动态规划。
动态规划五部曲
动态规划的核心思想就是拆分子问题,记住过往,减少重复计算。 并且动态规划一般都是自底向上的。
- 穷举分析各个子问题;
- 确定边界;
- 确定dp数组(通常是二维数组,有可能是一维,不限)及其下标的含义、初始化dp数组;;
- 找出规律,确定最优子结构,确定递推公式;
- 确定遍历顺序;
- 举例推倒dp数组(列出状态转移方程);
案例
LeetCode.509斐波那契数
递归计算时,代码虽然简介,但是效率很低。
function fib(n: number): number {
if (n === 0) return 0;
if (n === 1 || n === 2) return 1;
return fib(n - 1) + fib(n - 2);
}
使用动态规划,声明一个数组来储存各个子结果,数组的最后一个数,就是需要的那个最终结果,这个效率特别高:
/** 找到关系
* fib(10) = fib(9) + fib(8)
* fib(9) = fib(8) + fib(7)
* 此时,fib(8)就是【重叠子问题】
*/
function fib(n: number): number {
if (n === 0) return 0;
/** fib(1)和fib(2)即为【最优子结构】 */
/** n为1或2时,即为【边界】 */
if (n === 1 || n === 2) return 1;
// n大于2才迭代
const dp: number[] = new Array(n).fill(0);
for (let i = 0; i <= n; i++) {
if (i === 1 || i === 2) {
dp[i] = 1;
} else {
/** 这个关系式,即为【状态转移方程】 */
dp[i] = dp[i - 1] + dp[i - 2];
}
}
return dp[n];
}
当然,除了动态规划,还可以用迭代来解决斐波那契数列问题:
function fib(n: number): number {
if (n === 0) return 0;
if (n === 1 || n === 2) return 1;
/** 大于等于3 */
// 当前的斐波那契数列计算值
let curr = 0;
// 前一个数,初始为1,因为数列为0,1,1,2,.....
let prev_1 = 1;
// 前前一个数
let prev_2 = 1;
for (let i = 3; i <= n; i++) {
curr = prev_1 + prev_2;
prev_2 = prev_1;
prev_1 = curr;
}
return curr;
}
PS:上例的迭代计算,效率和动态规划一样!
LeetCode.300最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
function lengthOfLIS(nums: number[]) {
const len = nums.length;
if (len < 2) return len;
// 如果数组长度超过1,那么至少有1个递增子序列,所以初始为1;
const dp: number[] = new Array(len).fill(1);
let max = 1;
for (let i = 1; i < len; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
if (dp[i] > max) {
max = dp[i];
}
}
}
return max;
}
LeetCode.674最长连续递增序列
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
function findLengthOfLCIS(nums: number[]) {
const len = nums.length;
if (len < 2) return len;
// 如果数组不为空,那最短至少就是1,所以dp数组初始是1
const dp: number[] = new Array(len).fill(1);
let max = 1;
for (let i = 1; i < len; i++) {
// 遍历外层时,相当于每次遍历,在前一个子数组的末尾添加一个元素
// 例如[1,3,5,4,7]
// 判断[1,3] -> [1,3,5] -> [1,3,5,4] -> [1,3,5,4,7]
for (let j = i - 1; j >= 0; j--) {
// 从i前一个开始判断
// 如果这个数比nums[i]小,说明递增长度+1
if (nums[i] > nums[j]) {
dp[i] = dp[i - 1] + 1;
if (dp[i] > max) {
// 保存最大值
max = dp[i];
}
} else {
// 一旦遇到nums[j]大于等于nums[i]的,说明不递增了
// 不递增,则退出判断
break;
}
}
}
return max;
}
LeetCode.718最长重复子数组
给两个整数数组 nums1 和 nums2 ,返回 两个数组中 公共的 、长度最长的子数组的长度 。
function findLength(nums1: number[], nums2: number[]) {
// 记录遍历过的最大的长度
let max = 0;
// 默认各个位置的匹配情况都为false
const dp: number[][] = new Array(nums1.length).fill(0).map(v => new Array(nums2.length).fill(0));
for (let i = 0; i < nums1.length; i++) {
for (let j = 0; j < nums2.length; j++) {
if (nums1[i] === nums2[j]) {
// 当前位相等
if (i === 0 || j === 0) {
// 如果当前位的匹配情况为0,则无法拿到前一个值,此时直接赋值1
dp[i][j] = 1;
} else {
// 下标非0时,可以拿到前一个值,进行递推
dp[i][j] = dp[i - 1][j - 1] + 1;
}
if (dp[i][j] > max) {
max = dp[i][j];
}
}
}
}
return max;
}
LeetCode.1143最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
PS:注意区分“子序列”和“子数组/子串”的区别。
/**
* 以abcdef, ace为例子
* 初始化dp数组后进行遍历,可以得到dp数组
* 1 0 0
* 1 0 0
* 1 0 0
* 1 0 0
* 1 0 0
* 先外层遍历str1,然后内层遍历str2
* 如果相等,判断当前遍历到的值是否相等
* 如果相等,注意区分下标
*
*/
function longestCommonSubsequence(str1: string, str2: string) {
const len1 = str1.length;
const len2 = str2.length;
if (len1 === 0 || len2 === 0) {
return 0;
}
// 初始化dp数组,有可能最长公共子序列为0,以0为初始
const dp: number[][] = new Array(len1).fill(0).map(n => new Array(len2).fill(0));
for (let i = 0; i < len1; i++) {
for (let j = 0; j < len2; j++) {
if (str1[i] === str2[j]) {
if (i === 0 || j === 0) {
dp[i][j] = 1;
} else {
// 匹配到相等,则该位置的匹配值为【前一个位置匹配值+1】
dp[i][j] = dp[i - 1][j - 1] + 1;
}
} else {
// 不相等的时候,要把上一次匹配的较长者传下来存起
if (i !== 0 && j !== 0) {
// 如果不等于,这时候要判断该位置的上方、左侧的大小,取较大值
dp[i][j] = Math.max(dp[i - 1][j], dp[i][ j - 1]);
}
if (i === 0 && j !== 0) {
// 如果是位于第一列的匹配,不相等时,取上方的那个匹配值
dp[i][j] = dp[i][j - 1];
}
if (i !== 0 && j === 0) {
// 如果是位于第一行的匹配,不相等时,取左侧的那个匹配值
dp[i][j] = dp[i - 1][j];
}
}
}
}
return dp[len1 - 1][len2 - 1];
}
LeetCode.53最大子数组和
function maxSubArray(nums: number[]) {
const len = nums.length;
if (len === 1) return nums[0];
const dp = new Array(len).fill(0);
// 定义数组首个值
dp[0] = nums[0];
let max = dp[0];
for (let i = 1; i < len; i++) {
// 累加值
const sum = dp[i - 1] + nums[i];
dp[i] = Math.max(sum, nums[i]);
max = Math.max(max, dp[i]);
}
return max;
}
LeetCode.392判断子序列
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
"ace"是"abcde"的一个子序列,而"aec"不是。
function isSubsequence(s: string, t: string): boolean {
// 先确定边界
if (s.length === 0) return true;
// 确定dp数组
const dp: number[] = new Array(s.length).fill(0);
// 确定dp[i]的含义:表示s[i]是否找到了匹配
// prev_j表示在t的遍历中应从哪个下标开始遍历
let prev_j = 0;
for (let i = 0; i < s.length; i++) {
for (let j = prev_j; j < t.length; j++) {
if (s[i] === t[j]) {
// 找到匹配后,判断边界值
if (i === 0) {
dp[i] = 1;
} else {
// 此时说明匹配到了,那么匹配的长度加1表示当前连续匹配到的s子串长度
dp[i] = dp[i - 1] + 1;
}
// 缓存下一次t应该开始遍历的位置,因为之前的已经遍历了
prev_j = j + 1;
// 匹配到了之后马上匹配s[i + 1]
break;
}
}
}
// 如果s完全匹配,那dp最后一个值必定为s的长度
return dp[s.length - 1] === s.length;
};
LeetCode.115不同的子序列
给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。
function numDistinct(s: string, t: string) {
const len_s = s.length;
const len_t = t.length;
// 确定边界条件,t的长度为0,那肯定匹配为0了
if (len_t === 0) return 0;
// 在匹配到相等时,会有两种情况:
// 把当前s[i]、t[j]包含在内去匹配
// 不包含s[i]在内去匹配,只匹配s[i-1]、t[j-1]
// 总的匹配结果,就是两者相加
// 所以递推公式:dp[i][j] = dp[i - 1][j] + dp[i - 1][j - 1];
// 确定dp数组,dp[i][j]表示在s[i - 1]、t[j - 1]位置的个数
const dp: number[][] = new Array(len_s + 1).fill(0).map((v) => new Array(len_t + 1).fill(0));
// 因为递推公式dp[i][j] = dp[i - 1][j] + dp[i - 1][j - 1],所以需要初始dp数组的第一列
// 第一列不会被采用,只可能会在递推公式用到
for (let k = 0; k <= len_s; k++) {
dp[k][0] = 1;
}
for (let i = 1; i <= len_s; i++) {
for (let j = 1; j <= len_t; j++) {
if (s[i - 1] === t[j - 1]) {
dp[i][j] = dp[i - 1][j] + dp[i - 1][j - 1];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[len_s][len_t];
}
LeetCode.746使用最小花费爬楼梯
给你一个整数数组 cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
// 动态规划解决
function minCostClimbingStairs(cost: number[]): number {
const len = cost.length;
// 先确定边界条件:长度为2时,直接根据这两个花费的较小值即可
if (len === 2) return Math.min(cost[0], cost[1]);
// 确定递推公式:
// 因为每次只能跳1或2级
// 所以跳到下标为i的方式,有两种:从i-2跳上来;从i-1跳上来,最小花费就是求min(dp[i-2]+cost[i-2], dp[i-1]+cost[i-1])
// 确定dp数组及其含义:dp[i]表示爬到cost[i]位置的花费,PS:i > 2
// 因为需要爬到cost[len]的位置才算爬完,所以dp数组长度要比cost的长度+1
const dp: number[] = new Array(len + 1).fill(0);
// 直接从2开始遍历
for (let i = 2; i <= len; i++) {
dp[i] = Math.min(dp[i - 2] + cost[2], dp[i - 1] + cost[i - 1]);
}
return dp[len];
}