算法与LeetCode - 动态规划
前言
大家好,又是你们帅气的小编,最近2个周末在团结,嘻嘻,美滋滋。这几天我在想,我们稳扎稳打的一题题的按顺序讲,应该是有点傻乎乎的,所以小编拓展一下专题练习,会根据分类来讲解,本篇介绍的是动态规划,希望对大家能有所帮助。
正文
首先简介一下什么是动态规划算法。首先动态规划(Dynamic Programming,DP)是运筹学的一个分支,是求解决策过程最优化的过程。通常这个活动过程可以分为若干个互相联系的阶段,在每一个阶段都需作出决策(采取措施),一个阶段的决策确定以后,常常影响到下一个阶段的决策。比如,我们下一次循环的结果需要上一次循环得到的解。比如,不能选择相邻的数。
首先我们先不急于开始进入正题,让我们梦回到三年高考,五年模拟,来道高数题目。
熟悉的高中数列问题,让我们飞速在答卷上写出答案。
为什么小编会讲这个呢,嗯,卖给关子,且看下文,进入LeetCode刷题环节。
01 斐波那契数列
斐波那契数,通常用 F(n) 表示,形成的序列称为斐波那契数列。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和,关系如下。求第N个斐波那契数。
F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
给定 N,计算 F(N)。
来源:力扣(LeetCode)
https://leetcode-cn.com/problems/fibonacci-number/
既然,题目已经给出我们前后关系了,那么我们直接根据F(N)的表达式求解。解答如下:
var fib2 = function (n) {
if (n <= 2) return 1;
return fib(n - 1) + fib(n - 2);
};
第N个的斐波那契数,需要依赖第N - 1个和第N - 2个斐波那契数的求解,也就动态规划中所描述的子问题或子过程,通过递归可以得出答案。细心的小伙伴可以发现,在求解fib(n - 1), fib(n - 2)的过程中,会有重复的子问题,所以,我们可以优化一下,如下。
var fib = function (n) {
if (n <= 2) return 1;
const cache = {
0: 0,
1: 1,
2: 1
};
function _fib(n) {
if (cache[n] != null) return cache[n];
let res = _fib(n - 1) + _fib(n - 2);
cache[n] = res;
return res;
}
return _fib(n);
};
我们通过一个变量缓存,子过程的结果,通常我们称他为备忘录,可以加快计算。既然有递归,那么,我们也可以用循环求解,解答如下:
var fib = function (n) {
if (n <= 2) return 1;
let fibList = [0, 1];
for (let i = 2; i <= n; i++) {
fibList[i] = fibList[i - 1] + fibList[i - 2];
}
return fibList.pop();
};
同样的也是有重复的子问题,但是这里我们不需要使用备忘录,我们发现他只和前面两个子问题有关,所以我们只需要保存前两次的结果即可。优化后如下:
var fib = function (n) {
if (n <= 1) return n;
let pre = 0,
p = 1,
i = 1,
res;
while (i++ < n) {
res = pre + p;
pre = p;
p = res;
}
return res;
};
(如果有面试官问你动态规划的算法题,你能给他这样的流程:解题-优化,那么,这将给你加分。)
上面两次方式都可以求出正确答案,那么,他们又有什么不同呢?在动态规划中,可以通过有两种不同的算法策略求解,他们分别是:
自顶而下的算法策略,通常的形式为递归。问题依赖于子问题函数的结果,只有子问题完全求出,也就是子问题的递归返回结果,原问题才能求解。
自底而上的算法策略,通常为迭代。通常,将子问题按规模排序,按照规模从小到大的子问题求解,最终求出答案。
一个是抽象到具体的过程,另一个是具体到抽象的过程。两者还需分别考虑栈溢出和辅助空间超过限制的问题。
让我们在回去看看高中的数列问题,发现,其实和我们的动态算法求解的形式差不多,都是以小解大,在数学中叫通项公式,在动态规划中叫状态方程(即F(N) = F(N - 1) + F(N - 2)),求出这样的方程,我们就可以快速得到答案。所以说,学算法如有良好的数学基础,那必然可以举一反三。如果你还体会不深,让我们继续往下解题。
02 "打家劫舍"
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
0 <= nums.length <= 100
0 <= nums[i] <= 400
力扣(LeetCode)
https://leetcode-cn.com/problems/house-robber/
首先,偷窃是违法行为,要坚决抵制。根据上面几道题的思路,想想,我们先求他个通项公式(状态方程),那么,怎么得到这个公式呢?题意中说道不能偷窃相邻的房屋,所以面对一个房屋的时候,我们有两种状态可以选择,要么偷,要么跳过,如果这次偷了,那么下一次就得跳过。根据分析,我们假设 f(n) 为第n个房屋偷窃的最大金额,我们可以得到一个方程:
f(n) = max(f(n-2) + n[i], f(n-1))
方程在手,解答我有,直接给出两种策略的解答。
// 带备忘录的自顶而下的解法
var rob = function (nums) {
const cache = {};
function _rob(nums, start) {
if (start >= nums.length) return 0;
if (cache[start] != null) return cache[start];
let res = Math.max(_rob(nums, start + 1), nums[start] + _rob(nums, start + 2));
cache[start] = res;
return res;
}
return _rob(nums, 0);
};
// 自底而上的解法
var rob = function (nums) {
const length = nums.length;
if (!length) return 0;
let pre = nums[0];
if (length === 1) return pre;
let max = Math.max(nums[0], nums[1]);
let i = 1,
tmp;
while (i++ < length - 1) {
tmp = max;
max = Math.max(pre + nums[i], max);
pre = tmp;
}
return max;
};
03 再战斐波那契
分析上面的题目,我们可以得出动态规划解题的一般思路:
分析最优解的性质和解构
列出状态方程
根据方程使用自顶而下或自底而上的策略求解
其中最关键和核心的当然就属列方程,列方程求解,就和我们解数学题一样。让我们再战一题。
如果序列 X_1, X_2, ..., X_n 满足下列条件,就说它是 斐波那契式 的:
n >= 3
对于所有 i + 2 <= n,都有 X_i + X_{i+1} = X_{i+2}
给定一个严格递增的正整数数组形成序列,找到 A 中最长的斐波那契式的子序列的长度。如果一个不存在,返回 0 。
示例 1:
输入: [1,2,3,4,5,6,7,8]
输出: 5
解释:
最长的斐波那契式子序列为:[1,2,3,5,8] 。
示例 2:
输入: [1,3,7,11,12,14,18]
输出: 3
解释:
最长的斐波那契式子序列有:
[1,11,12],[3,11,14] 以及 [7,11,18] 。
来源:力扣(LeetCode)
https://leetcode-cn.com/problems/length-of-longest-fibonacci-subsequence/
嗯。嗯。嗯。我左想想,右想想,数组前后好像没有关系?X_i + X_{i+1} = X_{i+2} 应该如何使用呢?突然,灵光一闪,既然是求长度,那么就设f(n) = len 类型的表达式,又有两个值,那么,我就设 dp[i][j] = 长度,表示以arr[i], arr[j] 为结尾的斐波那契数列。那么,数列的最后面三个数,下标分别为k, i, j的数有以下关系:
dp[i][j] = dp[k][i] + 1
arr[k] + arr[i] = arr[j]
var lenLongestFibSubseq = function (arr) {
// 建立值和下标的对应关系,快速查找
let map = arr.reduce((pre, cur, index) => {
pre[cur] = index;
return pre;
}, {});
const length = arr.length;
// 辅助空间数组
let dp = Array.from(new Array(length), () => new Array(length).fill(2));
let max = 2;
for (let i = 0; i < length; i++) {
for (let j = i + 1; j < length; j++) {
// arr[j] - arr[i] = arr[k]
k = map[arr[j] - arr[i]];
if (k != null && k < i) dp[i][j] = dp[k][i] + 1;
// 找到最长的
max = Math.max(max, dp[i][j]);
}
}
return max === 2 ? 0 : max;
};
一顿操作后终于解出了答案,现在仔细回想,我们是如何运用题目所求的长度和数组值的关系来求出我们的状态方程的?这本身就是动态规划的难点。
结束语
动态规划算法是一个求最优解的过程,问题的解答往往需要子问题的求解或是决策。他有递归形式的自顶而下的算法策略,也有迭代形式的自底而上的算法策略。在求解过程最重要的就是列出状态方程,这次方程与最优解的性质和结构相关,常见是相邻数据的等式关系(包括等号.max,min),如何更好的找到规律,就需要我们奋笔疾书,多多练习了。
最后,希望文章中的内容可以对大家有所帮助,感谢支持~