[算法入门笔记] 18. 动态规划

本文精讲动态规划的基础概念和常用技巧,通过多个实例演示如何从暴力递归逐步优化至高效动态规划解决方案。涵盖机器人行走、货币兑换、纸牌游戏等多种经典问题。
摘要由CSDN通过智能技术生成

动态规划往往是有套路的,但套路是建立在熟练的基础上的~

0 建议

动态规划流程

  1. 尝试使用暴力递归求出答案
  2. 将暴力递归求出的答案缓存下来,改写记忆化搜索
  3. 在记忆化搜索的基础上打表,形成表结构,即dp

缓存结构如何优化成表结构

  1. 确定递归函数参数和返回值,清楚可变参数代表的递归状态
  2. 将可变参数映射成表格结构(单参数映射成一维表,双参数映射成二维表…)
  3. 标出最终答案在表中的位置
  4. 标出递归的base case和最简单、不需要依赖其他位置的答案
  5. 分析普遍位置如何依赖其他位置
  6. 确定计算顺序,求出最终答案

1 机器人达到指定位置的方法数

[问题]
假设有排成一行的N个位置,记为 [ 1 , N ] ( N ≥ 2 ) [1,N](N\ge2) [1,N](N2)。开始时机器人在其中的M位置上(M一定是 [ 1 , N ] [1,N] [1,N]中的一个),机器人可以往左走或者往右走
如果机器人来到1位置,那么下一步只能往右来到2位置;
如果机器人来到N位置,那么下一步只能往左来到N-1位置。
规定机器人必须走K步,最终能来到Р位置(P也一定是 [ 1 , N ] [1,N] [1,N]中的一个)的方法有多少种。给定四个参数NMKP,返回方法数。
[示例]

N=5,M=2,K=3,P=3

上面的参数代表所有位置为1 2 3 4 5。机器人最开始在2位置上,必须经过3步,最后到达3位置。走的方法只有如下3种:

  • 从2到1,从1到2,从2到3
  • 从2到3,从3到2,从2到3
  • 从2到3,从3到4,从4到3

所以返回方法数3

N=3。M=1,K=3,P=3

上面的参数代表所有位置为1 2 3。机器人最开始在1位置上,必须经过3步,最后到达3位置。怎么走也不可能,所以返回方法数0。

题目特点分析

本题是经典的从左向右尝试模型,考虑base case,然后有两种状态,向左走和向右走

1.1 暴力递归

  1. 确定递归函数参数和返回值
    递归函数参数有cur,表示当前处在的位置和rest,表示剩余步数,返回多少走法
  2. 确定递归终止条件
    如果走完所有步数,最终位置停在P位置,说明答案有效,返回一种答案;如果最终位置不在P位置,说明答案无效,返回0种答案
  3. 确定单次遍历逻辑
    dfs表示如果当前来到cur位置,还剩下rest步要走,下一步的走法
    • 如果 c u r = = 1 cur==1 cur==1,下一步只能走2位置,后续剩下rest-1步数
    • 如果 c u r = = N cur==N cur==N,下一步只能走N-1位置,后续剩下rest-1步数
    • 如果 c u r ∈ ( 1 , N ) cur\in(1,N) cur(1,N),下一步可以走cur-1位置或者cur+1位置,后续剩下rest-1步数
      在这里插入图片描述
/**
 * 机器人在1-N位置上移动,当前在cur位置,走完rest步后停在p位置的方法数
 * @param N 位置为1~N,固定参数
 * @param cur 当前所在位置,可变参数
 * @param rest 剩余步数,可变参数
 * @param p 最终目标位置,固定参数
 * @return 停在p位置的方法数
 */
public int dfs(int N, int P, int cur, int rest) {
	// 递归终止条件
	if (rest == 0) {
		// 如果剩余步数为0,并且来到P位置,答案有效
		return cur == P ? 1 : 0;
	}

	// 单次遍历逻辑
	if (cur == 1) { // 来到1位置只能向右走
		return dfs(N, P, cur + 1, rest - 1);
	}
	if (cur == N) { // 来到N位置只能向左走
		return dfs(N, P, cur - 1, rest - 1);
	}
	// 来到一般位置可以向左或者向右走
	return dfs(N, P, cur - 1, rest -1) + dfs(N, P, cur + 1, rest -1);
}

/**
 * 主函数调用dfs
 * @param N N个位置
 * @param M 当前在M位置
 * @param K 只能走K步
 * @param P 目标位置P
 * @return 返回从M位置出发,只走K步,最终到达P位置的方法数
 */
public int ways(int N, int M, int K, int P) {
	// 非法条件
	if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
		return 0;
	}
	return dfs(N, P, M, K);
}

1.2 记忆化搜索

由于在暴力递归过程存在大量重复计算,复杂度是指数级别,因此设计缓存结构存储计算过的结果。

public int dfs(int N, int P, int cur, int rest, int[][] cache) {
	// 查看缓存中是否具有答案
	if (cache[rest][cur] != -1) {
		return cache[rest][cur];
	}
	// 递归终止条件修改成缓存结构
	if (rest == 0) {
		// 如果剩余步数为0,并且来到P位置,答案有效
		cache[rest][cur] = cur == P ? 1 : 0;
		return cache[rest][cur];
	}

	// 单次遍历逻辑
	if (cur == 1) { // 来到1位置只能向右走
		cache[rest][cur] = dfs(N, P, cur + 1, rest - 1);
		return cache[rest][cur];
	}
	if (cur == N) { // 来到N位置只能向左走
		cache[rest][cur] = dfs(N, P, cur - 1, rest - 1);
		return cache[rest][cur];
	}
	// 来到一般位置可以向左或者向右走
	cache[rest][cur] = dfs(N, P, cur - 1, rest -1) + dfs(N, P, cur + 1, rest -1);
	return cache[rest][cur];
}

/**
 * 主函数调用dfs
 * @param N N个位置
 * @param M 当前在M位置
 * @param K 只能走K步
 * @param P 目标位置P
 * @return 返回从M位置出发,只走K步,最终到达P位置的方法数
 */
public int ways(int N, int M, int K, int P) {
	// 非法条件
	if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
		return 0;
	}
	// 确定可变参数范围 rest范围[0,K],cur范围[1,N]
    int[][] cache= new int[K + 1][N + 1];
    // 初始化缓存结构
     for (int i = 0; i <= K; ++i) {
        for (int j = 0; j <= N; j++) {
           cache[i][j] = -1;
        }
    }
    // 调用dfs函数
    return dfs(N, P, M, K, cache);
}

1.3 动态规划

使用该方法优化成dp前提是所求问题具有无后效性,即一个递归状态的返回值与怎么到达这个状态的路径无关

分析是否具有后效性

  • dfs两个固定参数NP,任何时候都不变,说明NP与具体递归状态无关,关注其他两个可变参数restcur
  • dfs(5,5)出现了两次,不管从dfs(4,6)来到dfs(5,5),还是从dfs(6,6)来到dfs(5,5),只要是当前来到5位置,还剩5步,返回值都是不变的,所以是一个无后效性问题

优化步骤

  1. 确定递归函数参数和返回值,清楚可变参数代表的递归状态
    • 可变参数为currest
  2. 将可变参数映射成表格结构
    • rest作为行,cur作为列,映射成二维表,返回值为dp[rest][cur]
  3. 标出最终答案在表中的位置
    • 对于N=7,P=5,M=4,K=9,最终答案在dp[9][4]
      在这里插入图片描述
  4. 标出递归的base case和最简单、不需要依赖其他位置的答案
    • 填写base case位置
    if (rest == 0) {
    	return cur == p ? 1 : 0;
    }
    

在这里插入图片描述

  1. 分析普遍位置如何依赖其他位置

    if (cur == 1) {
        dfs(N, P, 2, rest - 1);
    }
    if (cur == N) {
        dfs(N, P, N - 1, rest - 1);
    }
    return dfs(N, P, cur - 1, rest - 1) + dfs(N, P, cur + 1, rest - 1);
    
    • 如果cur在1位置,最终返回值 d p [ r e s t ] [ c u r ] = d p [ r e s t − 1 ] [ 2 ] dp[rest][cur]=dp[rest-1][2] dp[rest][cur]=dp[rest1][2]A点依赖B
    • 如果cur在N位置,最终返回值 d p [ r e s t ] [ c u r ] = d p [ r e s t − 1 ] [ N − 1 ] dp[rest][cur]=dp[rest-1][N-1] dp[rest][cur]=dp[rest1][N1] C点依赖D
    • 如果cur在中间位置,最终返回值 d p [ r e s t ] [ c u r ] = d p [ r e s t − 1 ] [ c u r − 1 ] + d p [ r e s t − 1 ] [ c u r + 1 ] dp[rest][cur]=dp[rest-1][cur-1]+dp[rest-1][cur+1] dp[rest][cur]=dp[rest1][cur1]+dp[rest1][cur+1] E点依赖F、G
      在这里插入图片描述
  2. 确定计算顺序,求出最终答案
    说明每一行的值依赖上一行的值
    在这里插入图片描述

本题动态规划解法就是把 N × K N×K N×K规模的表填好,填写每个位置的复杂度是 O ( 1 ) O(1) O(1),整个时间复杂度是 O ( N × K ) O(N×K) O(N×K)

/**
 * 动态规划版本
 * @param N N个位置
 * @param M 当前位置
 * @param K 只能走K步
 * @param P 目标位置
 * @return
 */
public int ways(int N, int P, int M, int K) {
	// 非法条件
	if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
        return 0;
    }
    // 定义dp数组
    int[][] dp = new int[K + 1][N + 1];
    // 填写简单位置的答案
    dp[0][P] = 1;
    // 填写普遍位置
	for (int rest = 1; rest <= K; rest++) {
		for (int cur = 1; cur <= N; cur++) {
			// 如果来到位置1,下一步只能走2位置
			if (cur == 1) {
				dp[rest][cur] = dp[rest - 1][2];
			} else if (cur == N) { // 如果来到位置N,下一步只能走N-1位置
				dp[rest][cur] = dp[rest - 1][N - 1];
			} else {
				dp[rest][cur] = dp[rest - 1][cur - 1] + dp[rest - 1][cur + 1];
			}
		}
	}	
	// 返回当剩余步数K,初始位置M的答案
	return dp[K][M];		
}

2 换钱的最少货币数

[问题]
给定数组arrarr 中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim,代表要找的钱数,求组成aim最少货币数。
[示例]

arr=[5,2,3],aim=20。

4张5元可以组成20元,其他的找钱方案都要使用更多张的货币,所以返回4。

arr=[5,2,3],aim=0

不用任何货币就可以组成0元,返回0。

arr=[3,5],aim=2

根本无法组成2元,钱不能找开的情况下默认返回-1。

2.1 暴力递归

  1. 确定递归函数参数和返回值
    递归函数参数有i,表示当前处在的位置和rest,表示剩余面值,返回组合数
  2. 确定递归终止条件
    如果走完所有步数i==arr.length,如果剩余面值为0,表示不需要货币了,返回0;如果剩余面值不为0,表示无法组成目标面值,返回-1
  3. 确定单次遍历逻辑
    dfs表示如果当前来到i位置,还剩下rest面值,下一步的组合方式
    • cur位置前表示已经做出的选择,i位置之后表示后续将要做出的选择
    • cur位置出发选择任意 [ 0 , K ] [0,K] [0,K]当前货币组成面值
    • cur位置状态具有选和不选两种状态
/**
 * 从左向右尝试
 * @param arr 面值数组
 * @param i 当前来到i位置尝试
 * @param rest 还剩多少钱才能组合成aim
 * @return 返回-1,说明i位置后续情况下,怎么都组合不出aim;返回不是-1,代表i位置后续,组合出rest最少的货币数
 */
 public int dfs(int[] arr, int cur, int rest) {
 	// 递归终止条件
 	if (i == arr.length) {
 		return rest == 0 ? 0 : -1;
 	}
 	// 保存答案
 	int ans = -1;
 	// 尝试当前货币0...K张的情况,但不能超过rest
 	for (int k = 0; k <= rest; ++k) {
 		// 使用k张arr[i],剩下的面值是rest-k*arr[i]
 		// next表示i位置向后剩下的面值 arr[i+1..N-1]
 		int next = dfs(arr, i+ 1. rest - k * arr[i]);
 		if (next != -1) { // 当i后续选择合理时
			// 在使用k张当前货币并且后续位置合法的情况下返回较少的货币数
            // next+k表示整个选择过程使用的货币
            ans = ans == -1 ? next + k : Math.min(ans, next + k);
 		}
 	}
 	return ans;
 }
 
 public int minCoins(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return -1;
    }
    // 从0位置开始尝试
    return dfs(arr, 0, aim);
}

2.2 记忆化搜索

致命错误:数组无效状态没有初始化,这里-2表示未填写状态,-1表示无效状态,非basecase后面有个判断是否无效,该状态没有初始化,还有注意初始化顺序

public int minCoins(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return -1;
    }
    int[][] cache = new int[arr.length + 1][aim + 1];
    for (int i = 0; i <= arr.length; i++) {
        for (int j = 0; j <= aim; j++) {
            cache[i][j] = -2;
        }
    }
    return dfs(arr, 0, aim, dp);
}

public int dfs(int[] arr, int i, int rest, int[][] cache) {
    for (int row = 0; row < dp.length; row++) {
        for (int col = 0; col < cache[0].length; col++) {
            cache[row][col] = -1;
        }
    }
    if (cur == arr.length) {
        cache[i][rest] = rest == 0 ? 0 : -1;
        return cache[i][rest];
    }

    // 尝试当前货币0...k张情况,但不能超过rest
    for (int k = 0; k <= rest; k++) {
        // 使用k张arr[i],剩下的面值是rest-k*arr[i]
        // next表示决策i位置向后剩下的面值(arr[i+1...N-1])
        int next = dfs(arr, i+ 1, rest - k * arr[i]);
        if (next != -1) { // 当i后续决策合法时
            // 在使用k张当前货币并且后续位置合法的情况下返回较少的货币数
            // next+k表示整个决策过程使用的货币
            cache[i][rest] = cache[i][rest] == -1 ? next + k : Math.min(cache[i][rest], next + k);
        }
    }
    return cache[i][rest];
}

2.3 动态规划

优化步骤

  1. 确定递归函数参数和返回值,清楚可变参数代表的递归状态
    • 可变参数为irest
  2. 将可变参数映射成表格结构,i表示当前位置,允许cur来到终止位置
    • rest作为行,i作为列,映射成二维表,剩余面值数不超过aim,因此 r e s t ∈ [ 0 , a i m ] rest\in[0,aim] rest[0,aim]
  3. 标出最终答案在表中的位置
    • 确定最终答案dfs(arr,0,aim) d p [ 0 ] [ a i m ] dp[0][aim] dp[0][aim]
  4. 填写base case
if (cur == arr.length) {
    return rest == 0 ? 0 : -1;
}

在这里插入图片描述
5. 填写普遍位置依赖

// 保存最少货币数
int ans = -1;
// 尝试当前货币0...k张情况,但不能超过rest
for (int k = 0; k <= rest; k++) {
    // 使用k张arr[i],剩下的面值是rest-k*arr[i]
    // next表示决策i位置向后剩下的面值(arr[i+1...N-1])
    int next = dfs(arr, i + 1, rest - k * arr[i]);
    if (next != -1) { //当i后续决策合法时
        // 在使用k张当前货币并且后续位置合法的情况下返回较少的货币数
        // next+k表示整个决策过程使用的货币
        ans = ans == -1 ? next + k : Math.min(ans, next + k);
    }
}
return ans;

dfs(arr,i,rest)返回值就是 d p [ i ] [ r e s t ] dp[i][rest] dp[i][rest]
在这里插入图片描述
表中右上角位置时d p [ i ] [ r e s t ] p[i] [rest] p[i][rest],根据dfs(arr,i,rest)

d p [ i ] [ r e s t ] = min ⁡ { d p [ i + 1 ] [ r e s t − 0 ∗ a r r [ i ] ] + 0 dp[i] [rest] = \min\{dp[i+1] [rest - 0*arr[i]] + 0 dp[i][rest]=min{dp[i+1][rest0arr[i]]+0,

d p [ i + 1 ] [ r e s t − 1 ∗ a r r [ i ] ] + 1 \quad \quad dp[i+1] [rest - 1*arr[i]] + 1 dp[i+1][rest1arr[i]]+1,

. . . \quad\quad\quad\quad\quad\quad\quad... ...

d p [ i + 1 ] [ r e s t − k ∗ a r r [ i ] ] + k } dp[i+1] [rest - k*arr[i]] + k\} dp[i+1][restkarr[i]]+k}

要想得到 a r r [ i ] [ r e s t ] arr[i] [rest] arr[i][rest],必须得到i+1行的值

d p [ i ] [ r e s t ] dp[i] [rest] dp[i][rest] 前, d p [ i ] [ r e s t − a r r [ i ] ] dp[i] [rest-arr[i]] dp[i][restarr[i]]已经计算过

d p [ i ] [ r e s t − a r r [ i ] ] = min ⁡ { dp[i] [rest-arr[i]] = \min\{ dp[i][restarr[i]]=min{

d p [ i + 1 ] [ r e s t − 1 ∗ a r r [ i ] ] + 0 \quad \quad\quad dp[i+1] [rest - 1 * arr[i]] + 0 dp[i+1][rest1arr[i]]+0,

d p [ i + 1 ] [ r e s t − 2 ∗ a r r [ i ] ] + 1 , \quad \quad\quad dp[i+1] [rest - 2*arr[i]] + 1, dp[i+1][rest2arr[i]]+1,

. . . \quad \quad\quad\quad \quad\quad\quad \quad\quad... ...

d p [ i + 1 ] [ r e s t − k ∗ a r r [ i ] ] + k − 1 } dp[i+1] [rest - k*arr[i]] + k-1\} dp[i+1][restkarr[i]]+k1}

图解
在这里插入图片描述
因此, d p [ i ] [ r e s t ] = m i n ( d p [ i ] [ r e s t − a r r [ i ] + 1 , d p [ i + 1 ] [ r e s t ] ) dp[i] [rest] = min (dp[i] [rest-arr[i] + 1, dp[i+1] [rest]) dp[i][rest]=min(dp[i][restarr[i]+1,dp[i+1][rest]),就是说dp[i] [rest]依赖下面一个位置和左边一个位置

最后一排值已经确定,剩下的位置只依赖下面和左边的位置,只要求从左到右求倒数第二排,从左到右求倒数第三排…从做到到右求第一排即可
在这里插入图片描述

public int minCoins(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return -1;
    }
    int N = arr.length;
    int[][] dp = new int[N + 1][aim + 1];
    // base case
    // 设置最后一排的值,除了dp[N][0]为0之外,其他都是-1
    for (int col = 1; col <= aim; col++) {
        dp[N][col] = -1;
    }
    // 计算顺序从下到上
    for (int i = N - 1; i >= 0; i--) {
        // 从左到右计算
        for (int rest = 0; rest <= aim; rest++) {
            // 初始时先设置dp[i][rest]的值无效
            dp[i][rest] = -1;
            // 下面的值如果有效
            if (dp[i + 1][rest] != -1) {
                // 先保存起来
                dp[i][rest] = dp[i + 1][rest];
            }
            // 如果左边位置不越界并且有效
            if (rest - arr[i] >= 0 && dp[i][rest - arr[i]] != -1) {
                if (dp[i][rest] == -1) { // 如果下面位置无效
                    dp[i][rest] = dp[i][rest - arr[i]] + 1;
                } else {
                    dp[i][rest] = Math.min(dp[i][rest - arr[i]] + 1, dp[i][rest]);
                }
            }
        }
    }
    return dp[0][aim];
}

3 纸牌博弈问题

[问题]
给定一个整型数组arr,代表数值不同的纸牌排成一条线。玩家A和玩家B依次拿走每张纸牌,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家A和玩家B都绝顶聪明。请返回最后获胜者的分数。
[示例]

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。

arr=[1,100,2]

开始时,玩家A不管拿1还是2,玩家B作为绝顶聪明的人,都会把100拿走。玩家B会获胜,分数为100。所以返回100。

3.1 暴力递归

  • 定义递归函数first(i,j),表示arr[i…j]这个排列上的纸牌被绝顶聪明的人先拿,最终返回的分数
  • 定义递归函数second(i,j),表示arr[i…j]这个排列上的纸牌被绝顶聪明的人后拿,最终返回的分数

分析先手first(i,j)

  • i==j,即只剩一张牌。当然会被先拿纸牌的人拿走,返回arr[i]
  • i!=j,当前拿纸牌的人,要么拿arr[i],要么拿arr[j]。
  • 如果拿arr[i],那么排列只剩下arr[i+1…j]。对当前玩家,面对arr[i+1…j],他将成为后手,后续获得分数是second(i+1,j)
  • 如果拿arr[j],那么排列只剩下arr[i…j-1]。对当前玩家,面对arr[i…j-1],他将成为后手,后续获得分数是second(i,j-1)
  • 作为绝顶聪明的人,两种决策都是最优的,返回max{arr[i]+second(i+1,j), arr[j]+second(i,j-1)}

分析后手second(i,j)

  • i==j,即只剩下一张牌。作为后手,什么都拿不到,得分0
  • i!=j,该玩家对手先拿纸牌。对手要么拿走arr[i],要么拿走arr[j]
  • 如果对手拿走arr[i],排列剩下arr[i+1…j],然后轮到该玩家先拿
  • 如果对手拿走arr[j],排列剩下arr[i…j-1],然后轮到该玩家先拿
  • 对手也是绝顶聪明的人,返回Min{first(i+1,j), first(i,j-1)}

先手函数

public int first(int[] arr, int i, int j) {
    if (i == j) {
        return arr[i];
    }
    return Math.max(
      arr[i] + second(arr, i + 1, j),
      arr[j] + second(arr, i, j - 1)
    );
}

后手函数

public int second(int[] arr, int i, int j) {
    if (i == j) {
        return 0;
    }
    return Math.min(
            first(arr, i + 1, j),
            first(arr, i, j - 1)
    );
}

主函数调用

public int win(int[] arr) {
    if (arr == null || arr.length == 0) {
        return 0;
    }
    return Math.max(first(arr, 0, arr.length - 1), second(arr, 0, arr.length - 1));
}

3.2 动态规划

经典的范围尝试模型

1.分析first(i,j)可变i,j参数范围

在这里插入图片描述

2.标出计算的终止位置

在这里插入图片描述

3.标出basecase

  • i不会超过j,即下三角部分无效

  • first:对角线basecase

    if (i == j) {
        return arr[i];
    }
    
  • second 对角线basecase

    if (i == j) {
        return 0;
    }
    

在这里插入图片描述

3.标出非basecase的普遍位置依赖

  • first

    return Math.max(
      arr[i] + second(arr, i + 1, j),
      arr[j] + second(arr, i, j - 1)
    );
    
  • second

    return Math.min(
        first(arr, i + 1, j),
        first(arr, i, j - 1)
    );
    

在这里插入图片描述

5.确定计算次序

public int win(int[] arr) {
    if (arr == null || arr.length == 0) {
        return 0;
    }
    int[][] first = new int[arr.length][arr.length];
    int[][] second = new int[arr.length][arr.length];
    for (int col = 0; col < arr.length; col++) {
        first[col][col] = arr[col];
        for (int row = col - 1; row >= 0; row--) {
            first[row][col] = Math.max(arr[row] + second[row + 1][col], arr[col] + second[row][col - 1]);
            second[row][col] = Math.min(first[row + 1][col], first[row][col - 1]);
        }
    }
    return Math.max(first[0][arr.length - 1], second[0][arr.length - 1]);
}

4 高维动态规划

4.1 中国象棋马的跳法

[问题]
把棋盘放入第一象限,棋盘的最左下角是 ( 0 , 0 ) (0,0) (0,0)位置。那么整个棋盘就是横坐标上9条线、纵坐标上10条线的一个区域。给你三个参数,x,y,k,返回如果“马”从(0,0)位置出发,必须走k步,最后落在(x, y)上的方法数有多少种?

在这里插入图片描述
在这里插入图片描述

2.1.1 暴力递归

public int getWays(int x, int y, int step) {
    return process(x, y, step);
}

public int process(int x, int y, int step) {
    if (x < 0 || x > 8 || y < 0 || y > 9) {
        return 0;
    }
    if (step == 0) {
        return (x == 0 && y == 0) ? 1 : 0;
    }
    return process(x - 1, y - 2, step - 1) +
           process(x + 1, y - 2, step - 1) +
           process(x - 2, y - 1, step - 1) +
           process(x + 2, y - 1, step - 1) +
           process(x - 2, y + 1, step - 1) +
           process(x + 2, y + 1, step - 1) +
           process(x - 1, y + 2, step - 1) +
           process(x + 1, y + 2, step - 1);
}

2.1.2 动态规划

1.分析可变x,y,step参数范围

2.标出计算终止位置

在这里插入图片描述

3.标出basecase

if (step == 0) {
    return (x == 0 && y == 0) ? 1 : 0;
}

在这里插入图片描述

public int getWays(int x, int y, int step) {
    if (x < 0 || x > 8 || y < 0 || y > 9 || step < 0) {
        return 0;
    }
    int[][][] dp = new int[9][10][step + 1];
    // base case
    dp[0][0][0] = 1;
    // 从底层往上计算
    for (int height = 1; height <= step; height++) {
        for (int row = 0; row < 9; row++) {
            for (int col = 0; col < 10; col++) {
                dp[row][col][height] += getValue(dp, row - 1, col - 2, height - 1);
                dp[row][col][height] += getValue(dp, row + 1, col - 2, height - 1);
                dp[row][col][height] += getValue(dp, row - 2, col - 1, height - 1);
                dp[row][col][height] += getValue(dp, row + 2, col - 1, height - 1);
                dp[row][col][height] += getValue(dp, row - 2, col + 1, height - 1);
                dp[row][col][height] += getValue(dp, row + 2, col + 1, height - 1);
                dp[row][col][height] += getValue(dp, row - 1, col + 2, height - 1);
                dp[row][col][height] += getValue(dp, row + 1, col + 2, height - 1);
            }
        }
    }
    return dp[x][y][step];
}

// 防止出现越界,同时返回数组值
public int getValue(int[][][] dp, int row, int col, int step) {
    if (row < 0 || row > 8 || col < 0 || col > 9) {
        return 0;
    }
    return dp[row][col][step];
}

2.2 生存问题

[问题]
给定五个参数n, m,i, j, k。表示在一个 N × M N×M N×M的区域,Bob处在 ( i , j ) (i,j) (i,j)点,每次Bob等概率的向上、下、左、右四个方向移动一步,Bob必须走K步。如果走完之后,Bob还停留在这个区域上,就算Bob存活,否则就算Bob死亡。请求解Bob的生存概率,返回字符串表示分数的方式。

2.2.1 暴力递归

public String bob(int N, int M, int i, int j, int k) {
    // 总步数4^k
    long allStep = (long)Math.pow(4,k);
    long live = process(N, M, i, j, k);
    long gcd = gcd(allStep, live);
    return String.valueOf((live / gcd) + "/" + (allStep / gcd));
}

/**
 * N*M区域内,Bob从(row,col)位置出发,走rest步,获得生存点数
 * @param N 矩阵长度
 * @param M 矩阵宽度
 * @param row 出发位置的横坐标
 * @param col 出发位置的纵坐标
 * @param rest 剩余步数
 * @return 生存点数
 */
public long process(int N, int M, int row, int col, int rest) {
    //违规条件
    if(row < 0 || row == N || col < 0 || col == M) {
        return 0;
    }
    if (rest == 0) { //剩余步数0,说明走完
        return 1;
    }

    long live =
            //往上走
            process(N, M, row - 1, col, rest - 1) +
            //往下走
            process(N, M, row + 1, col, rest - 1) +
            //往左走
            process(N, M, row,  col - 1, rest - 1) +
            //往右走
            process(N, M, row,  col + 1, rest - 1);
    return live;
}

// 最大公约数
public long gcd(long m, long n) {
    return n == 0 ? m : gcd(n, m % n);
}

4.2.2 动态规划

public String bob(int N, int M, int i, int j, int K) {
    int[][][] dp = new int[N + 2][M + 2][K + 1];
    // base case
    for (int row = 1; row <= N; row++) {
        for (int col = 1; col <= M; col++) {
            dp[row][col][0] = 1;
        }
    }
    // 从底层往高层计算
    for (int rest = 1; rest <= K; rest++) {
        for (int row = 1; row <= N; row++) {
            for (int col = 1; col <= M; col++) {
                dp[row][col][rest] = dp[row - 1][col][rest - 1];
                dp[row][col][rest] += dp[row + 1][col][rest - 1];
                dp[row][col][rest] += dp[row][col - 1][rest - 1];
                dp[row][col][rest] += dp[row][col + 1][rest - 1];
            }
        }
    }
    long all = (long) Math.pow(4, K);
    long live = dp[i + 1][j + 1][K];
    long gcd = gcd(all, live);
    return String.valueOf((live / gcd) + "/" + (all / gcd));
}

5 空间压缩技巧

压缩技巧1

在这里插入图片描述

将二维数组压缩成一维数组

在这里插入图片描述

压缩技巧2

在这里插入图片描述

压缩技巧3

在这里插入图片描述

压缩技巧4

在这里插入图片描述

压缩技巧5

在这里插入图片描述

其他技巧

在这里插入图片描述

矩阵的最小路径和

在这里插入图片描述

暴力递归

1.考虑走到边界,怎么处理 2.写对返回值

1. 分析basecase,终止位置—2.分析边界条件和违规条件,返回值怎么处理—3.普遍位置时如何处理返回值

  • 要得到a(i,j)到b路径和,先求a右边点到b的路径和right,以及a下面点到点b的路径和down,最后a到b路径和为 min ⁡ { r i g h t , d o w n } + a r r [ i ] [ j ] \min\{right,down\}+arr[i] [j] min{right,down}+arr[i][j]
  • a(i,j)到达
  • 只能向右移动,其路径和是a点+右边到b的路径和
  • a(i,j)到达最后一列,他只能向下移动,其路径和是a点+下边到b的路径和
public int minpathSum(int[][] m) {
    if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
        return 0;
    }
    return process(m, 0, 0, m.length, m[0].length);
}

public int process(int[][] m, int i, int j, int row, int col) {
    // base case a来到右下角
    if (i == row - 1 && j == col - 1) { //递归结束条件
        return m[i][j];
    }
    // a来到最后一行
    if (i == row - 1) { // 只能向右走
        return m[i][j] + process(m, i, j + 1, row, col);
    }
    // a来到最后一列
    if (j == col - 1) { // 只能向下走
        return m[i][j] + process(m, i + 1, j, row, col);
    }
    // 选择1 向右走的路径和
    int rightPath = process(m, i + 1, j, row, col);
    // 选择2 向下走的路径和
    int downPath = process(m, i, j + 1, row, col);
    return m[i][j] + Math.min(rightPath, downPath);
}

动态规划

  • 对于第一行所有的位置(0,j)来说,从(0,0)位置到(0,j)位置只能向右走,所以(0,0)到(0,j)位置的路径和是m[0] [0…j]累加的结果

  • 对于m的第一列的所有位置来说,即(i,0)从(0,0)位置走到(i,0)位置只能向下走,所以(0,0)位置到(i,0)位置的路径和就是m[0…i] [0]累加的结果

  • 除了第一行和第一列的位置外,都有左边位置(i-1,j)和上边位置(i,j-1)

  • 从(0,0)到(i,j)位置的路径必然经过位置(i-1,j)或位置(i,j-1)

  • 所以 d p [ i ] [ j ] = min ⁡ { d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] } + m [ i ] [ j ] dp[i] [j] = \min\{dp[i-1] [j],dp[i] [j-1]\}+m[i] [j] dp[i][j]=min{dp[i1][j],dp[i][j1]}+m[i][j]

  • 含义是比较从(0,0)位置开始,经过(i-1,j)位置最终到到达(i,j)的最小路径和经过(i,j-1)位置最终到达(i,j)的最小路径,谁最小

  • 除第一行和第一列位置外,每一个位置考虑从左边到达自己的路径和更小还是从上边到达自己的路径和更小,最右下角位置就是整个问题的答案

public int minPathSum(int[][] m) {
    if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
        return 0;
    }
    int row = m.length;
    int col = m[0].length;
    int[][] dp = new int[row][col];
    // 起始位置
    dp[0][0] = m[0][0];
    // 初始化第一行
    for (int i = 1; i < row; i++) {
        dp[i][0] = dp[i - 1][0] + m[i][0];
    }
    // 初始化第一列
    for (int j = 1; j < col; j++) {
        dp[0][j] = dp[0][j - 1] + m[0][j];
    }
    for (int i = 1; i < row; i++) {
        for (int j = 1; j < col; j++) {
            dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + m[i][j];
        }
    }
    return dp[row - 1][col - 1];
}

动态规划+空间压缩技巧

1 3 5 9 8 1 3 4 5 0 6 1 8 8 4 0 \begin{matrix} 1 & 3 & 5 & 9\\ 8 & 1 & 3 & 4\\ 5 & 0 & 6 & 1\\ 8 & 8 & 4 & 0\\ \end{matrix} 1858310853649410

    1. 生成大小为 min ⁡ { M , N } \min\{M,N\} min{M,N}的一维数组,本测试用例长度为4,初始 a r r = [ 0 , 0 , 0 , 0 ] arr=[0,0,0,0] arr=[0,0,0,0],从 ( 0 , 0 ) (0,0) (0,0)位置出发到达m第一行的每个位置,最小路径和时从 ( 0 , 0 ) (0,0) (0,0)位置开始依次累加的结果, a r r = [ 1 , 4 , 9 , 18 ] arr=[1,4,9,18] arr=[1,4,9,18],此时arr[j]代表从 ( 0 , 0 ) (0,0) (0,0)位置到 ( 0 , j ) (0,j) (0,j)位置的最小路径和

    在这里插入图片描述

  • 准备把arr[j]的值更新成 ( i , j ) (i,j) (i,j)位置上的和

  • 更新arr[0] a r r [ 0 ] = a r r [ 0 ] + m [ 1 ] [ 0 ] arr[0]=arr[0]+m[1] [0] arr[0]=arr[0]+m[1][0]

在这里插入图片描述

  • 更新arr[1]
    • [ 1 ] [ 0 ] [1] [0] [1][0]位置到达 [ 1 ] [ 1 ] [1] [1] [1][1]位置 d p [ 1 ] [ 0 ] + m [ 1 ] [ 1 ] dp[1] [0]+m[1] [1] dp[1][0]+m[1][1]
    • [ 0 ] [ 1 ] [0] [1] [0][1]位置到达 [ 1 ] [ 1 ] [1] [1] [1][1]位置 d p [ 0 ] [ 1 ] + m [ 1 ] [ 1 ] dp[0] [1]+m[1] [1] dp[0][1]+m[1][1]

在这里插入图片描述

最终arr更新成 [ 9 , 5 , 8 , 2 ] [9, 5, 8, 2] [9,5,8,2]

  • 整个过程不断滚动更新arr[],让arr[]依次变成个dp矩阵的每一行,最终变成dp矩阵最后一行的值

NOTICE

  • 给定矩阵列数小于行数(N<M),可以进行空间压缩
  • 给定矩阵列数大于行数(M<N),就生成长度为M的arr,令arr更新成dp的每一列的值,从左向右滚动
/**
 * 动态规划+空间压缩
 * @param m
 * @return
 */
public int minPathSum(int[][] m) {
    if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
        return 0;
    }
    // 行数与列数较大的为more
    int more = Math.max(m.length, m[0].length);
    // 行数与列数较小的为less
    int less = Math.min(m.length, m[0].length);
    // 行数是否大于等于列数
    boolean rowmore = more == m.length;
    // 辅助数组长度是行数或列数的较小值
    int[] arr = new int[less];
    // 出发位置
    arr[0] = m[0][0];

    for (int i = 1; i < less; i++) {
        // rowmore为true代表行数较大,更新列位置,否则更新行位置
        arr[i] = arr[i - 1] + (rowmore ? m[0][i] : m[i][0]);
    }
    for (int i = 1; i < more; i++) {
        arr[0] = arr[0] + (rowmore ? m[i][0] : m[0][i]);
        for (int j = 1; j < less; j++) {
            arr[j] = Math.min(arr[j - 1], arr[j])
                    + (rowmore ? m[i][j] : m[j][i]);
        }
    }
    return arr[less - 1];
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Cyan Chau

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值