暴力递归到动态规划(一)
练习
练习一: Fibancci 数列
查找斐波纳契数列中第 N 个数。(N 从 0 开始),所谓的斐波纳契数列是必须满足下面两个条件:
- 前 2 个数是 0 和 1 。
- 第 i 个数是第 i-1 个数和第 i-2 个数的和。
【示例】斐波纳契数列的前 10 个数字是: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
根据上面的描述,我们先写一个未优化的最简单版本,代码如下:
function Fibancci(n: number): number {
if (n < 2) {
return n;
}
return Fibancci(n - 1) + Fibancci(n - 2);
}
console.log(Fibancci(20));
//output:
//6765
把这种暴力递归的解法画一张图出来: 
是不是有重复的计算?像这样的二叉树的节点总数是指数级的,所以所有子问题的个数为 (2^n) 噢,这么做会导致时间复杂度爆炸,因为时间复杂度也变成了 O(2^n)
缓存剪枝策略
function Fibancci(n: number, cache: Map<number, number>): number {
if (cache.has(n)) {
return cache.get(n);
}
let ans = 0;
if (n < 2) {
ans = n;
} else {
ans = Fibancci(n - 1, cache) + Fibancci(n - 2, cache);
}
cache.set(n, ans);
return ans;
}
function main(num: number) {
const cache = new Map();
return Fibancci(num, cache);
}
console.log(main(20));
//output:
//6765
这种带缓存的递归解法是针对暴力递归的解法做的优化,优化的操作其实就是对那颗指数级的二叉树进行剪枝,剪枝会减少重复的树节点(减少了重复的计算)。 
红色实线部分圈着的是被剪枝的节点,是不是剪掉了所有的重复树节点?没错是的,所有重复的子问题都被干掉了,时间复杂度骤减。 它时间复杂度也从 O(n^2)降低到了 O(n)
练习二:机器人必须走 K 步,最终能来到 P 位置的方法有多少种
假设有排成一行的 N 个位置,记为 1~N,N 一定大于或等于 2 开始时机器人在其中的 start 位置上(M 一定是 1~N 中的一个)
如果机器人来到 1 位置,那么下一步只能往右来到 2 位置;
如果机器人来到 N 位置,那么下一步只能往左来到 N-1 位置;
如果机器人来到中间位置,那么下一步可以往左走或者往右走;
规定机器人必须走 K 步,最终能来到 aim 位置(aim 也是 1~N 中的一个)的方法有多少种 ?
给定四个参数 N、start、K、aim,返回方法数。

/**
* @description: 从curPos位置出发,还剩restStep要走,返回可以走到aim的方法数
* @param {number} range 机器人可以走的范围 1——range(固定参数)
* @param {number} curPos 当前所在的位置
* @param {number} restStep 还剩restStep步可以走
* @param {number} aim 终点位置 固定参数
* @return {*} 方法数
*/
function robotWalk(
range: number,
curPos: number,
restStep: number,
aim: number
): number {
if (restStep == 0) {
//剩余步数为0的时候如果当前位置是重点位置返回一种方法数
return curPos == aim ? 1 : 0;
}
let ways = 0;
if (curPos == 1) {
//在1位置只能向右走
ways = robotWalk(range, curPos + 1, restStep - 1, aim);
} else if (curPos == range) {
//在最后位置只能往左走
ways = robotWalk(range, curPos - 1, restStep - 1, aim);
} else {
//其余位置可以左右两个方向走
//方法1 向右走
let p1 = robotWalk(range, curPos + 1, restStep - 1, aim);
//方法2 向左走
let p2 = robotWalk(range, curPos - 1, restStep - 1, aim);
ways = p1 + p2;
}
return ways;
}
function main(N: number, start: number, K: number, aim: number) {
return robotWalk(N, start, K, aim);
}
console.log(main(7, 2, 4, 4));
//output:
//4
以上方式是纯暴力,上面方式的可变参数就两个,当前所在的位置(curPos)和剩余步数(restStep),假设当前位置在 7,剩余步数为 10,我们根据下图会发现后续有重复的过程,而且会有很多,所以我们可以用缓存剪枝策略来进行优化,后续也称记忆化搜索

记忆化搜索方式
/**
* @description: 从curPos位置出发,还剩restStep要走,返回可以走到aim的方法数
* @param {number} range 机器人可以走的范围 1——range(固定参数)
* @param {number} curPos 当前所在的位置
* @param {number} restStep 还剩restStep步可以走
* @param {number} aim 终点位置 固定参数
* @param Map<string,number> cache 缓存已经算过方法数的表格
* @return {*} 方法数
*/
function robotWalk(
range: number,
curPos: number,
restStep: number,
aim: number,
cache: Map<string, number>
): number {
const cacheKey = `${curPos}-${restStep}`;
//如果缓存中存在,直接获取该值
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
if (restStep == 0) {
//剩余步数为0的时候如果当前位置是重点位置返回一种方法数
return curPos == aim ? 1 : 0;
}
let ways = 0;
if (curPos == 1) {
//在1位置只能向右走
ways = robotWalk(range, curPos + 1, restStep - 1, aim, cache);
} else if (curPos == range) {
//在最后位置只能往左走
ways = robotWalk(range, curPos - 1, restStep - 1, aim, cache);
} else {
//其余位置可以左右两个方向走
//方法1 向右走
let p1 = robotWalk(range, curPos + 1, restStep - 1, aim, cache);
//方法2 向左走
let p2 = robotWalk(range, curPos - 1, restStep - 1, aim, cache);
ways = p1 + p2;
}
//设置改制到缓存表中
cache.set(cacheKey, ways);
return ways;
}
function main(N: number, start: number, K: number, aim: number) {
let cache = new Map();
return robotWalk(N, start, K, aim, cache);
}
console.log(main(7, 2, 4, 4));
//output:
//4
动态规划方式
通过纯暴力的方式后,我们可知可变参数就两个,当前所在的位置(curPos)和剩余步数(restStep),curPos 的变化范围是 1-N,restStep 的变化范围是 0-K,根据两个可变参数我们绘制一个表格
根据 baseCase 我们可以知道当 restStep 为 0 的时候,只有 curPos==aim 时方法数才是 1
然后让我们分析普遍位置依赖,当 curPos==1 的时候,他的依赖如下图
当 curPos==N 的时候,他的依赖如下图
其余位置的依赖如下
而我们最终要获取的位置是(4,2)这个位置
根据依赖关系我们可以把第二行的值求出来
从而我们可以求出所有的值 
最终得到的结果是 4,详细代码如下:
function main(N: number, start: number, K: number, aim: number) {
//创建一个(K+1)*(N+1)的二位数组,并且每个位置的值都是0
let dp = Array.from(new Array(K + 1), () => new Array(N + 1).fill(0));
//根据baseCase可得
dp[0][aim] = 1;
for (let i = 1; i <= K; i++) {
for (let j = 1; j <= N; j++) {
if (j == 1) {
dp[i][j] = dp[i - 1][j + 1]; //依赖右上角位置
} else if (j == N) {
dp[i][j] = dp[i - 1][j - 1]; //依赖左上角位置
} else {
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j + 1]; //依赖右上角+左上角
}
}
}
return dp[K][start];
}
console.log(main(7, 2, 4, 4));
//output:
//4
练习三: A,B 玩家从左右两边拿纸牌,返回最后获胜者的分数
给定一个整型数组 arr,代表数值不同的纸牌排成一条线,玩家 A 和玩家 B 依次拿走每张纸牌,规定玩家 A 先拿,玩家 B 后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家 A 和玩家 B 都绝顶聪明。请返回最后获胜者的分数
【示例1】 arr=[1,2,100,4]
开始时,玩家 A 只能拿走 1 或 4。如果开始时玩家 A 拿走 1,则排列变为[2,100,4],接下来玩家 B 可以拿走 2 或 4,然后继续轮到玩家 A...
如果开始时玩家 A 拿走 4,则排列变为[1,2,100],接下来玩家 B 可以拿走 1 或 100,然后继续轮到玩家 A...
玩家 A 作为绝顶聪明的人不会先拿 4,因为拿 4 之后,玩家 B 将拿走 100。所以玩家 A 会先拿 1,让排列变为[2,100,4],接下来玩家 B 不管怎么选,100 都会被玩家 A 拿走。玩家 A 会获胜,分数为 101。所以返回 101。
【示例2】 arr=[1,100,2]
开始时,玩家 A 不管拿 1 还是 2,玩家 B 作为绝顶聪明的人,都会把 100 拿走。玩家 B 会获胜,分数为 100。所以返回 100。
暴力递归分析过程:假设我先拿牌,则我可以拿左侧的牌或者拿右侧的牌,这时我会有两种方案,因为我绝顶聪明,我肯定获取两种方案中最大的那个,然后轮到对手拿牌,对手也可以选择拿左侧或者右侧的牌,不管对手拿哪个牌,只要牌数大于 1,则后续都会有我拿牌得到的分数,对手也决定聪明,肯定给我最小的值,分析代码如下:
/**
* @description: 我的拿牌时刻,从[L...R]范围上获取的最好成绩
* @param {number} pokers 所有的牌 (固定参数)
* @param {number} L 左侧可以抽的位置
* @param {number} R 右侧可以抽的位置
* @return {*} 我获取的最好成绩
*/
function meMoment(pokers: number[], L: number, R: number) {
//如果只剩一张牌,由于是我拿牌时刻,所以直接拿走
if (L == R) {
return pokers[L];
}
/**如果剩余牌数大于1张*/
//我拿左侧的牌
let p1 = pokers[L] + opponentMoment(pokers, L + 1, R);
//我拿右侧的牌
let p2 = pokers[R] + opponentMoment(pokers, L, R - 1);
return Math.max(p1, p2);
}
/**
* @description: 对手拿牌时刻,在[L...R]对手让我获取的成绩,对手绝顶聪明,让我获取对他有利的
* @param {number} pokers 所有的牌 (固定参数)
* @param {number} L 左侧可以抽的位置
* @param {number} R 右侧可以抽的位置
* @return {*} 对手拿牌后,我获取的成绩
*/
function opponentMoment(pokers: number[], L: number, R: number): number {
//如果只剩一张牌,由于是对手拿牌时刻,我得到的成绩是0
if (L == R) return 0;
//对手拿左侧的牌,后续我获取的最好成绩
let p1 = meMoment(pokers, L + 1, R);
//对手拿右侧的牌,后续我获取的最好成绩
let p2 = meMoment(pokers, L, R - 1);
return Math.min(p1, p2);
}
function main(pokers: number[]) {
//我先拿牌
let p1 = meMoment(pokers, 0, pokers.length - 1);
//对手先拿牌
let p2 = opponentMoment(pokers, 0, pokers.length - 1);
return Math.max(p1, p2);
}
console.log(main([1, 5, 233, 7]));
//output:
//234
记忆化搜索方式
我们画图分析下具体过程 
会发现 5,233 会有重复的部分,这时候我们就可以用记忆化搜索方式进行剪枝来提升速度了,代码如下
/**
* @description: 我的拿牌时刻,从[L...R]范围上获取的最好成绩
* @param {number} pokers 所有的牌 (固定参数)
* @param {number} L 左侧可以抽的位置
* @param {number} R 右侧可以抽的位置
* @param {number} meCache 针对于我的数据的缓存
* @param {number} opponentCache 针对于对手的数据的缓存
* @return {*} 我获取的最好成绩
*/
function meMoment(
pokers: number[],
L: number,
R: number,
meCache: Map<string, number>,
opponentCache: Map<string, number>
) {
const cacheKey = `${L}-${R}`;
if (meCache.has(cacheKey)) {
return meCache.get(cacheKey);
}
//如果只剩一张牌,由于是我拿牌时刻,所以直接拿走
if (L == R) {
return pokers[L];
}
/**如果剩余牌数大于1张*/
//我拿左侧的牌
let p1 = pokers[L] + opponentMoment(pokers, L + 1, R, meCache, opponentCache);
//我拿右侧的牌
let p2 = pokers[R] + opponentMoment(pokers, L, R - 1, meCache, opponentCache);
let res = Math.max(p1, p2);
meCache.set(cacheKey, res);
return res;
}
/**
* @description: 对手拿牌时刻,在[L...R]对手让我获取的成绩,对手绝顶聪明,让我获取对他有利的
* @param {number} pokers 所有的牌 (固定参数)
* @param {number} L 左侧可以抽的位置
* @param {number} R 右侧可以抽的位置
* @param {number} meCache 针对于我的数据的缓存
* @param {number} opponentCache 针对于对手的数据的缓存
* @return {*} 对手拿牌后,我获取的成绩
*/
function opponentMoment(
pokers: number[],
L: number,
R: number,
meCache: Map<string, number>,
opponentCache: Map<string, number>
): number {
const cacheKey = `${L}-${R}`;
if (opponentCache.has(cacheKey)) {
return opponentCache.get(cacheKey);
}
//如果只剩一张牌,由于是对手拿牌时刻,我得到的成绩是0
if (L == R) return 0;
//对手拿左侧的牌,后续我获取的最好成绩
let p1 = meMoment(pokers, L + 1, R, meCache, opponentCache);
//对手拿右侧的牌,后续我获取的最好成绩
let p2 = meMoment(pokers, L, R - 1, meCache, opponentCache);
let res = Math.min(p1, p2);
opponentCache.set(cacheKey, res);
return res;
}
function main(pokers: number[]) {
let meCache = new Map();
let meCache1 = new Map();
let opponentCache = new Map();
let opponentCache1 = new Map();
//我先拿牌
let p1 = meMoment(pokers, 0, pokers.length - 1, meCache, opponentCache);
//对手先拿牌
let p2 = opponentMoment(
pokers,
0,
pokers.length - 1,
meCache1,
opponentCache1
);
return Math.max(p1, p2);
}
console.log(main([1, 5, 233, 7]));
//output:
//234
动态规划方式
通过暴力递归的方式,我们可知一共就两个可变参数 L,R L 的变化范围是 0-(len-1),R 的变化范围也是 0-(len-1),所以我们构建动态规划表如下,由于 R>L,所以画 x 的位置都是无效位置 
根据 baseCase 我们可以得到 L==R 上的两个动态规划表的值如下: 
两张表的 0,3 位置的最大值即是我们要求得的位置 
接下来我们分析普遍位置依赖,依赖关系如下所示 

根据依赖关系,我们求得第一个斜角的值 
根据依赖关系,我们求得所有的位置关系,最终结果为 12 
分析代码如下:
function main(pokers: number[]) {
let len = pokers.length;
//构建len*len的动态规划表格
let meDp = Array.from(new Array(len), () => new Array(len));
let opponentDp = Array.from(new Array(len), () => new Array(len));
//根据baseCase 设定基础条件
for (let i = 0; i < len; i++) {
meDp[i][i] = pokers[i];
opponentDp[i][i] = 0;
}
//根据普遍依赖关系斜角求每个位置的值
for (let start = 1; start < len; start++) {
let L = 0;
let R = start;
while (R < len) {
meDp[L][R] = Math.max(
pokers[L] + opponentDp[L + 1][R],
pokers[R] + opponentDp[L][R - 1]
);
opponentDp[L][R] = Math.min(meDp[L + 1][R], meDp[L][R - 1]);
L++;
R++;
}
}
return Math.max(meDp[0][len - 1], opponentDp[0][len - 1]);
}
console.log(main([1, 5, 233, 7]));
//output:
//234
