前一篇我们仔细聊了聊递归,如果大伙都仔细敲了相关代码,没有收获那是不可能的。同时敏锐的伙伴可能也发现了,接下来我们要说啥了——动态规划,对,终于终于我们聊到动态规划了。
提到动态规划,不知道你们第一反应是啥,难?别人的帖子看不懂?这些都不是事儿,跟着我的步伐一步一步往下走,等更完几篇文章之后,我们再来总结的时候,那时,面对动态规划就不会再觉得难了。
以下正文:(本文篇幅依旧较长,请耐心品味)
一、暴力递归是啥
说直白点:暴力递归就是尝试
1、把问题转化为规模缩小的同类问题的子问题
2、有明确的不需要继续进行递归的条件(base case)
3、有当得到了子问题的结果之后的决策过程
4、不记录每一个子问题的解
二、动态规划是啥
在递归调用过程中,动态规划会把曾经的调用过程保存下来,下次遇到相同的过程直接调用就行(空间换时间)。也就是遇到有重复调用的递归过程,就可以使用动态规划进行优化。
拿我们熟知的斐波那契数列举例。
斐波那契数列规定第一项是1,第二项是1,以后的每一项F(n) = F(n-1) + F(n-2)。
我们展开求解第7项的过程如下:
我们将求第7项的值展开后会发现,整个递归过程会有很多重复的过程,这时候我们就可以使用动态规划进行优化求解。
在我们求解过一次F(5)后,将其值存起来,下次再遇到需要求F(5)时直接取就可以了,减少了重复求解的过程,同理,对于每一个求解过的值都这样做,这就是动态规划。
以上概念性的东西都太抽象了,OK,来看两个实际的题目,仔细感受感受从暴力递归到动态规划的魅力。
三、机器人移动问题
1、题目描述
对于N个格子(从1~N标号),机器人最开始在Start(1<=Start<=N)位置,要求在走过K(K>=1)步后(一次一格),来到aim位置(1<=aim<=N),问机器人有多少种走法?
(注:在两端的格子只能往中间走,在中间的任意一个格子,可以选择左走或右走)
2、思路1 从尝试开始
/**
* 1.从尝试开始
*
* @author Java和算法学习:周一
*/
public static int way1(int n, int start, int aim, int k) {
if (n < 2 || start < 1 || start > n || aim < 1 || aim > n || k < 1) {
return -1;
}
return process1(start, k, aim, n);
}
/**
* 计算机器人满足条件的走法有多少种
*
* @param current 当前位置
* @param remaining 剩余步数
* @param aim 目标位置
* @param n 格子数
*/
private static int process1(int current, int remaining, int aim, int n) {
// base case,不需要走时
if (remaining == 0) {
// 剩余步数为0时当前正好在aim位置,则这是一种走法
return current == aim ? 1 : 0;
}
// 还有步数要走
if (current == 1) {
// 在最左侧,只能往右走
return process1(2, remaining - 1, aim, n);
} else if (current == n) {
// 在最右侧,只能往左走
return process1(n - 1, remaining - 1, aim, n);
} else {
// 在中间位置,左走或右走都可以,所以是两种情况产生的结果之和
return process1(current - 1, remaining - 1, aim, n)
+ process1(current + 1, remaining - 1, aim, n);
}
}
以上代码是按照题目要求写出的最符合自然智慧的代码,也是最容易理解的代码。
3、思路2 傻缓存法优化
首先分析这个调用过程是否有重复的步骤。
在递归调用的过程中,目标位置和格子数一直都是不变的,只有当前位置和剩余步数一直在变,对于某次在格子6,剩余步数为8的情况下分析调用过程,如下:
会发现有重复的调用过程,所以这是可以用动态规划优化的,如果没有重复的调用过程,动态规划是优化不了的,也无法优化。
定义一个缓存表的二维数组dp,机器人当前位置范围是1\~n,剩余步数范围是0\~k,dp表(n + 1)*(k + 1)肯定是能够将所有的情况都包含的。
最开始dp表均为-1,后面在递归调用时都先判断缓存表中是否有值,如果有(!=-1)直接返回值,没有才走递归过程,同时在每一次递归返回结果前都要更新缓存表dp。
/**
* 2.傻缓存法
*
* @author Java和算法学习:周一
*/
public static int way2(int n, int start, int aim, int k) {
if (n < 2 || start < 1 || start > n || aim < 1 || aim > n || k < 1) {
return -1;
}
// 机器人当前位置范围是1~n,剩余步数范围是0~k,dp表(n + 1)*(k + 1)肯定是能够将所有的情况都包含的
int[][] dp = new int[n + 1][k + 1];
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= k; j++) {
dp[i][j] = -1;
}
}
// dp[current][remaining] == -1,表示没算过
// dp[current][remaining] != -1,表示算过,保存的是产生的结果
return process2(start, k, aim, n, dp);
}
/**
* 计算机器人满足条件的走法有多少种,傻缓存法
*
* @param current 当前位置
* @param remaining 剩余步数
* @param aim 目标位置
* @param n 格子数
*/
private static int process2(int current, int remaining, int aim, int n, int[][] dp) {
// 缓存表已经有值了,直接返回
if (dp[current][remaining] != -1) {
return dp[current][remaining];
}
// 之前没算过
int answer;
if (remaining == 0) {
answer = current == aim ? 1 : 0;
} else if (current == 1) {
answer = process2(2, remaining - 1, aim, n, dp);
} else if (current == n) {
answer = process2(n - 1, remaining - 1, aim, n, dp);
} else {
answer = process2(current - 1, remaining - 1, aim, n, dp)
+ process2(current + 1, remaining - 1, aim, n, dp);
}
// 返回前更新缓存
dp[current][remaining] = answer;
return dp[current][remaining];
}
因为此方法中递归调用过程是从顶向下的,所以此方法又叫从顶向下的动态规划。
同时,dp表中存在数据就直接返回,类似记忆,所以也叫记忆化搜索。
傻缓存法是在暴力递归的过程上增加了一个dp数组,用于存放之前产生的结果,所以把重复调用的过程优化了。但是,就结束了吗?肯定不是。
4、最终版的动态规划
我们画出第2种方法中的dp表。假设格子数n=5,start=2,aim=4,步数k=6。
从第1种方法的递归过程分析如何填dp表
(1)因为格子数n=5,所以当前位置的范围为1\~5,第0行使用不到,用“×”表示;因为步数k=6,剩余步数范围为0\~6;
public static int way1(int n, int start, int aim, int k) {
if (n < 2 || start < 1 || start > n || aim < 1 || aim > n || k < 1) {
return -1;
}
return process1(start, k, aim, n);
}
主函数调用递归函数时,求的是start=2、k=6的值,即(2, 6)的值,可得初始表如下
(2)剩余步数为0时,当前位置为aim=4时为1,其余均为0;
// base case,不需要走时
if (remaining == 0) {
// 剩余步数为0时当前正好在aim位置,则这是一种走法
return current == aim ? 1 : 0;
}
当前位置为1时,只能往第2格走,同时走一格剩余步数减1(即依赖左下方格子的值);当前位置为n=5时,只能往第n-1=4格走(即依赖左上方的值);对于任意一个非边上的位置,可以往n-1和n+1格走(即依赖左下方和左上方的值)。可得dp表如下:
(3)据此,我们可以根据从上往下从左往右的顺序将整个表填完,如下所示
最后可得(2, 6)的值为13,即最后的结果是13。
(4)根据以上分析,可以写出动态规划代码如下:
/**
* 3.最终版的动态规划
*
* @author Java和算法学习:周一
*/
public static int way3(int n, int start, int aim, int k) {
if (n < 2 || start < 1 || start > n || aim < 1 || aim > n || k < 1) {
return -1;
}
// 机器人当前位置范围是1~n,剩余步数范围是0~k,dp表(n + 1)*(k + 1)肯定是能够将所有的情况都包含的
int[][] dp = new int[n + 1][k + 1];
// 剩余步数为0时,当前位置为aim时为1
dp[aim][0] = 1;
for (int remaining = 1; remaining <= k; remaining++) {
// 第一行,依赖左下方的值
dp[1][remaining] = dp[2][remaining - 1];
// 第一行和第n行单独算后,此处就不用判断越界问题了
for (int current = 2; current <= n - 1; current++) {
// 非边上的行,依赖左下方和左上方的值
dp[current][remaining] = dp[current - 1][remaining - 1] + dp[current + 1][remaining - 1];
}
// 第n行,依赖左上方的值
dp[n][remaining] = dp[n - 1][remaining - 1];
}
return dp[start][k];
}
看完整个过程后,是不是豁然开朗,要是直接给你最后的代码,肯定是不知所云,同时也不建议去背这个动态规划表,动态规划表只是结果,不是原因。我们从最开始的尝试开始,一步一步的往下推导,这就是状态转移的过程,想要一步直接写出最后的动态规划代码来是需要不断的练习和总结的。看到这里,相信大家都有收获了。
趁热打铁,再来一题。
四、纸牌问题
1、题目描述
给定一个正数整型数组arr,代表数值不同的纸牌排成一条线。玩家A和玩家B依次拿走每张纸牌(可以看见所有的牌),规定玩家A先拿,玩家B后拿。但是每个玩家每次只能拿走最左或最右的纸牌。玩家A和玩家B都绝顶聪明,请返回最后获胜者的分数。
2、思路1 从尝试开始
(1)分析先手的情况
定义函数 f(arr, L, R),表示先手在数组arr[L...R]上获得的最大分数。L、R是下标。
1)如果L==R,表明现在只剩下一张牌了,返回arr[L]即可;
2)否则,表明还剩不止一张牌,分两种情况
取L位置的,分数 = arr[L] + g[arr, L+1, R](函数g是后手产生的最大分数)
取R位置的,分数 = arr[R] + g[arr, L, R-1]
因为我是先手,所以最后的分数是以上两种的最大值。
(2)分析后手的情况
1)如果L==R,表明现在只剩下一张牌了,而此时我是后手,剩的一张被先手选了,我没得选,只能返回0;
2)否则,表明还剩不止一张牌,分两种情况
先手取L位置的,我后手分数 = f[arr, L+1, R],即我以先手状态取[L+1, R]的最好分数;
先手取R位置的,我后手分数 = f[arr, L, R-1],即我以先手状态取[L, R-1]的最好分数。
因为我是后手,大家都是绝顶聪明,所以先手肯定只会留给我分数更少的结果,所以最后的分数是以上两种的最小值。
(3)代码
/**
* 1.从尝试开始
*
* @author Java和算法学习:周一
*/
public static int win1(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int before = f1(arr, 0, arr.length - 1);
int after = g1(arr, 0, arr.length - 1);
return Math.max(before, after);
}
/**
* 以先手状态获得的最大分数
*/
private static int f1(int[] arr, int L, int R) {
if (L == R) {
return arr[L];
}
// 还剩不止一张牌
int p1 = arr[L] + g1(arr, L + 1, R);
int p2 = arr[R] + g1(arr, L, R - 1);
return Math.max(p1, p2);
}
/**
* 以后手状态获得的最大分数
*/
private static int g1(int[] arr, int L, int R) {
if (L == R) {
return 0;
}
// 对手拿走L位置的数
int p1 = f1(arr, L + 1, R);
// 对手拿走R位置的数
int p2 = f1(arr, L, R - 1);
return Math.min(p1, p2);
}
3、思路2 傻缓存法优化
首先分析是否有重复调用的过程
有重复调用过程,那么可以使用动态规划来优化。不断变化的是L和R的值,而在f函数里面又依赖g函数,g函数里面又依赖f函数,那么我们不妨设计两个表。
/**
* 2.傻缓存法
*
* @author Java和算法学习:周一
*/
public static int win2(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int N = arr.length;
int[][] fMap = new int[N][N];
int[][] gMap = new int[N][N];
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
fMap[i][j] = -1;
gMap[i][j] = -1;
}
}
int before = f2(arr, 0, arr.length - 1, fMap, gMap);
int after = g2(arr, 0, arr.length - 1, fMap, gMap);
return Math.max(before, after);
}
/**
* 以先手状态获得的最大分数
*/
private static int f2(int[] arr, int L, int R, int[][] fMap, int[][] gMap) {
if (fMap[L][R] != -1) {
return fMap[L][R];
}
int ans;
if (L == R) {
ans = arr[L];
} else {
// 还剩不止一张牌
int p1 = arr[L] + g2(arr, L + 1, R, fMap, gMap);
int p2 = arr[R] + g2(arr, L, R - 1, fMap, gMap);
ans = Math.max(p1, p2);
}
// 更新缓存
fMap[L][R] = ans;
return fMap[L][R];
}
/**
* 以后手状态获得的最大分数
*/
private static int g2(int[] arr, int L, int R, int[][] fMap, int[][] gMap) {
if (gMap[L][R] != -1) {
return gMap[L][R];
}
int ans;
if (L == R) {
ans = 0;
} else {
// 对手拿走L位置的数
int p1 = f2(arr, L + 1, R, fMap, gMap);
// 对手拿走R位置的数
int p2 = f2(arr, L, R - 1, fMap, gMap);
ans = Math.min(p1, p2);
}
// 更新缓存
gMap[L][R] = ans;
return gMap[L][R];
}
4、最终版的动态规划
首先分析两个表的数据咋填的,假设arr = { 7, 4, 16, 15, 1 };
(1)因为L<=R,所以表的左下方都不会用到,用“×”表示;同时fMap表,L==R时,值为arr[L],即对角线依次为数组的值;gMap表,L==R时,值为0。可得如下表
(2)分析值的依赖情况
对于fMap(L, R),依赖arr[L] + gMap(L + 1, R)和arr[R] + gMap(L, R - 1)的最大值;
对于gMap(L, R),依赖fMap(L + 1, R)和fMap(L, R - 1)的最小值
如下图所示
(3)根据递归调用过程,可得最后的fMap和gMap如下图所示
看递归调用的起始传入的值,需要fMap[0, 4]和gMap[0, 4]的最大值即24。
(4)代码
/**
* 3.最终动态规划
*
* @author Java和算法学习:周一
*/
public static int win3(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
int N = arr.length;
int[][] fMap = new int[N][N];
int[][] gMap = new int[N][N];
for (int i = 0; i < N; i++) {
fMap[i][i] = arr[i];
}
for (int startColumn = 1; startColumn < N; startColumn++) {
// 行
int L = 0;
// 列
int R = startColumn;
while (R < N) {
fMap[L][R] = Math.max(arr[L] + gMap[L + 1][R], arr[R] + gMap[L][R - 1]);
gMap[L][R] = Math.min(fMap[L + 1][R], fMap[L][R - 1]);
L++;
R++;
}
}
return Math.max(fMap[0][N - 1], gMap[0][N - 1]);
}
从头仔细看完的伙伴,此刻对动态规划应该不那么害怕了吧,加油!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。