动态规划

265 阅读9分钟

修改过程

注意:动态规划就是暴力尝试减少重复计算的技巧,是一个大套路。

  1. 先尝试写出来递归的版本(最难)

  2. 再改成利用缓存得出的记忆化搜索的版本(某些问题时间复杂度和3.中是一样的)

  3. 最后改成严格表结构的动态规划

    1. 分析可变参数的变化范围,几个可变参数就是几维表

    2. 标出计算的终止位置

    3. 依据暴力尝试版本的base case,标记出不用计算就可以得出的位置。

    4. 看依赖,推其他的位置。

    5. 确定依次计算的顺序。

  4. 进一步优化

相关题目一

假设有排成一行的 N 个位置,记为 1 ~ N,N一定大于或等于2。开始时机器人在其中的 M 位置上(M 一定是 1~N 中的一个)

机器人可以往左走或者往右走,如果机器人来到 1 位置, 那 么下一步只能往右来到 2 位置;如果机器人来到 N 位置,那么下一步只能往左来到 N-1 位置。 规定机器人必须走 K 步,最终能来到 P 位置(P 也一定是 1~N 中的一个)的方法有多少种。给定四个参数 N、M、K、P,返回方法数。

举例: N=5,M=2,K=3,P=3

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

1)从2到1,从1到2,从2到3

2)从2到3,从3到2,从2到3

3)从2到3,从3到4,从4到3

所以返回方法数 3。

暴力尝试版本

	// N表示一共几步,M表示当前的位置, K表示一共需要走多少步,P是最终需要到达的位置
	public static int ways1(int N, int M, int K, int P){
		// 参数无效直接返回0
		if(N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N){
			return 0;
		}
		// 总共N个位置,从M点出发,还剩K步,返回最终能达到P的方法数
		return walk(N, M, K, P);
	}

	// N : 位置为1 ~ N,固定参数
	// cur : 当前在cur位置,可变参数
	// rest : 还剩res步没有走,可变参数
	// P : 最终目标位置是P,固定参数
	// 该函数的含义:只能在1~N这些位置上移动,当前在cur位置,走完rest步之后,停在P位置的方法数作为返回值返回
	public static int walk(int N, int cur, int rest, int P) {
		// base case
		// 如果没有剩余步数了,当前的cur位置就是最后的位置
		// 如果最后的位置停在P上,那么之前做的移动是有效的
		// 如果最后的位置没在P上,那么之前做的移动是无效的
		if(rest == 0){
			return cur == P ? 1 : 0;
		}
		// 如果还有rest步要走,而当前的cur位置在1位置上,那么当前这步只能从1走向2
		// 后续的过程就是,来到2位置上,还剩rest-1步要走
		if(cur == 1){
			return walk(N, 2, rest - 1, P);
		}
		// 如果还有rest步要走,而当前的cur位置在N位置上,那么当前这步只能从N走向N-1
		// 后续的过程就是,来到N-1位置上,还剩rest-1步要走
		if (cur == N){
			return walk(N, N - 1, rest - 1, P);
		}
		// 如果还有rest步要走,而当前的cur位置在中间位置上,那么当前这步可以走向左,也可以走向右
		// 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走
		// 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走
		// 走向左、走向右是截然不同的方法,所以总方法数要都算上
		return walk(N, cur + 1, rest -1, P) + walk(N, cur - 1, rest - 1, P);
	}

复杂度分析:因为整个过程相当于树,树的深度是K,所以时间复杂度是O(2^K)。

记忆化搜索版本

当写出来暴力尝试的版本之后,之后就都是套路了。因为在依次递归的过程中,会出现重复的算之前已经由另一个递归操作已经算过的一部分。所以可以对计算的过程进行缓存。

	public static int ways2(int N, int M, int K, int P) {
		//参数无效直接返回0
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		//保证一个足够大的空间,这里其实[K][N]就已经足够,但是还是加了多余的一个空间
		//横行表示的是剩余的步数,列行表示的是cur当前的位置
		int[][] dp = new int[K + 1][N + 1];
		//将所有位置都标记成-1,表示之前没有到达过
		for (int i = 0; i <= K; i++){
			for (int j = 0; j < N; j++){
				dp[i][j] = -1;
			}
		}
		// 总共N个位置,从M点出发,还剩K步,返回最终能达到P的方法数
		return walk2(N, M, K, P, dp);
	}

	public static int walk2(int N, int cur, int rest, int P, int[][] dp) {
		//之前算过,直接返回之前算过的值
		if(dp[rest][cur] != -1){
			return dp[rest][cur];
		}
		//缓存没命中的情况,建立缓存
		if(rest == 0){
			dp[rest][cur] =  cur == P ? 1 : 0;
			return dp[rest][cur];
		}
                
		if(cur == 1){
			dp[rest][cur] = walk(N, 2, rest - 1, P);
			return dp[rest][cur];
		}
                
		if (cur == N){
			dp[rest][cur] = walk(N, N - 1, rest - 1, P);
			return dp[rest][cur];
		}
                
		dp[rest][cur] = walk(N, cur + 1, rest -1, P) + walk(N, cur - 1, rest - 1, P);
		return dp[rest][cur];
	}

时间复杂度:因为最多每个位置会算一次,所以是O(N * M)。

严格表结构的动态规划版本

这一步需要整理位置依赖。

image.png

	public static int ways3(int N, int M, int K, int P) {
		//参数无效直接返回0
		if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N) {
			return 0;
		}
		//保证一个足够大的空间,这里其实[K][N]就已经足够,但是还是加了多余的一个空间
		//横行表示的是剩余的步数,列行表示的是cur当前的位置
		int[][] dp = new int[K + 1][N + 1];
		dp[0][P] = 1;
		for (int i = 1; i <= K; i++){
			//从1开始是因为有效位就是从1开始的
			for (int j = 1; j <= N; j++){
				if(j == 1){
					dp[i][j] = dp[i - 1][2];
				}else if(j == N){
					dp[i][j] = dp[i - 1][N - 1];
				}else{
					dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j + 1];
				}
			}
		}
		return dp[K][M];
	}

时间复杂度:O(K * N)。

相关题目二

给定数组 arr,arr 中所有的值都为正数且可能重复。每个值代表一枚面值的货币,每种面值的货币可以使用任意张,再给定一个整数 aim,代表要找的钱数,求组成 aim 的最少货币数。

举例: arr=[5,2,3],aim=20。

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

暴力尝试版本

	// 当前考虑的面值是arr[i],还剩rest的钱需要找零
	// 如果返回-1说明自由使用arr[i..N-1]面值的情况下,无论如何也无法找零rest
	// 如果返回不是-1,代表自由使用arr[i..N-1]面值的情况下,找零rest需要的最少张数
	public static int process(int[] arr, int i, int rest) {
		// base case
		// 已经没有面值能够考虑了
		// 如果此时剩余的钱为0,返回0张
		// 如果此时剩余的钱不是0,返回-1
		if (i == arr.length){
			return rest == 0 ? 0 : -1;
		}
		// 最少张数,初始时为-1,因为还没找到有效解
		int res = -1;
		// 依次尝试使用当前面值(arr[i])0张、1张、k张,但不能超过rest
		for(int k = 0; k * arr[i] <= rest; k++){
			// 使用了k张arr[i],剩下的钱为rest - k * arr[i]
			// 交给剩下的面值去搞定(arr[i+1..N-1])
			int next = process(arr, i + 1, rest - k * arr[i]);
			if (next != -1){
				//只有刚开始的时候是res == -1res = res == -1 ? next + k : Math.min(res, next + k);
			}
		}
		return res;
	}

严格表结构的动态规划版本

记忆化搜索就不写了,因为我们直接可以通过暴力尝试改写出严格表结构动态规划的版本。

    //直接在暴力尝试的基础上改就行
    public static int minCoins1(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return -1;
        }
        //建立表格
        int N = arr.length;
        int[][] dp = new int[arr.length + 1][aim + 1];
        for(int j = 1; j < aim + 1; j++){
            dp[N][j] = -1;
        }
        //因为毕竟是表格,所以两层循环
        for(int i = N - 1; i >= 0; i--) {
            for (int rest = 0; rest <= aim; rest++) {
                //原来的res变成了dp[i][rest]
                //返回的是当是要到达到多少钱时,最少使用的硬币数
                dp[i][rest] = -1;
                for (int k = 0; k * arr[i] <= rest; k++) {
                    int next = dp[i + 1][rest - k * arr[i]];
                    if (next != -1) {
                        dp[i][rest] = dp[i][rest] == -1 ? next + k : Math.min(dp[i][rest], next + k);
                    }
                }
            }
        }
        return dp[0][aim];
    }

相关题目三

给定一个整型数组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。

暴力尝试版本

	//法一:整体思路是分为先手函数和后手函数,先手函数是我先拿,
	//后手函数是我后拿,先手和后手相互嵌套拿到
	public static int win1(int[] arr) {
		if (arr == null || arr.length == 0) {
			return 0;
		}
		//返回A先拿,和B后拿,看谁大返回谁
		return Math.max(f(arr, 0, arr.length - 1), s(arr, 0, arr.length - 1));
	}
	//因为是先手函数,所以返回的是取了这个值之后+后手函数是最大的
	public static int f(int[] arr, int i, int j) {
		//base case,只剩一个值的话,只能拿了
		if (i == j) {
			return arr[i];
		}
		return Math.max(arr[i] + s(arr, i + 1, j), arr[j] + s(arr, i, j - 1));
	}
	//因为是后手函数,所以轮到我i + 1 ~ j 或者 i ~ j-1上我先手了,
	//但是我在那个地方先手,是对方决定的,对方导致了我取min,毕竟我后手
	public static int s(int[] arr, int i, int j) {
		//base case只剩一个值,因为是后手所以没法拿
		if (i == j) {
			return 0;
		}
		return Math.min(f(arr, i + 1, j), f(arr, i, j - 1));
	}

严格表结构的动态规划版本

image.png

	public static int win2(int[] arr) {
		if (arr == null || arr.length == 0) {
			return 0;
		}
		int[][] f = new int[arr.length][arr.length];
		int[][] s = new int[arr.length][arr.length];
		for (int j = 0; j < arr.length; j++) {
                        //依据暴力尝试的base case得出的
                        //由于s[][]这些部分都是0,所以只需要f[][]
			f[j][j] = arr[j];
                        //因为整个空间i <= j 所以只有右上部分有效
                        //最后一步,确定填写的顺序,这里是按图上的方式
                        //来的,有很多种方式,都可以
			for (int i = j - 1; i >= 0; i--) {
				f[i][j] = Math.max(arr[i] + s[i + 1][j], arr[j] + s[i][j - 1]);
				s[i][j] = Math.min(f[i + 1][j], f[i][j - 1]);
			}
		}
		return Math.max(f[0][arr.length - 1], s[0][arr.length - 1]);
	}

	public static void main(String[] args) {
		int[] arr = { 1, 9, 1 };
		System.out.println(win1(arr));
		System.out.println(win2(arr));

	}

相关题目四(三维表)

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

暴力尝试版本

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

	public static int process(int x, int y, int step) {
		//中间走出去的方法,都直接返回0
		if (x < 0 || x > 8 || y < 0 || y > 9) {
			return 0;
		}
		if (step == 0) {
			//最开始应该是从(0,0)开始的,所以这个时候应该是1,其他不是从(0,0)开始的,就是0
			return (x == 0 && y == 0) ? 1 : 0;
		}
		//因为马走日,所以可以到(x,y)的点,就只有8个点
		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 + 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);
	}

严格表结构的动态规划版本

	public static int dpWays(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];
		dp[0][0][0] = 1;
		for (int h = 1; h <= step; h++) {
			for (int r = 0; r < 9; r++) {
				for (int c = 0; c < 10; c++) {
					dp[r][c][h] += getValue(dp, r - 1, c + 2, h - 1);
					dp[r][c][h] += getValue(dp, r + 1, c + 2, h - 1);
					dp[r][c][h] += getValue(dp, r + 2, c + 1, h - 1);
					dp[r][c][h] += getValue(dp, r + 2, c - 1, h - 1);
					dp[r][c][h] += getValue(dp, r + 1, c - 2, h - 1);
					dp[r][c][h] += getValue(dp, r - 1, c - 2, h - 1);
					dp[r][c][h] += getValue(dp, r - 2, c - 1, h - 1);
					dp[r][c][h] += getValue(dp, r - 2, c + 1, h - 1);
				}
			}
		}
		return dp[x][y][step];
	}

	public static 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];
	}

相关题目五

给定数组 arr,arr 中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数 aim,代表要找的钱数,求组成 aim 的方法数。

暴力尝试版本

    //aim是最终要找的零钱数
    public static int way1(int[] arr, int aim){
        //从arr[]中的0位置开始算
        return process(arr, 0, aim);
    }

    public static int process(int[] arr, int index, int rest){
        //base case 所有种类的面值都尝试完后,如果达到aim,这种方法就成立
        if(index == arr.length){
            return rest == 0 ? 1 : 0;
        }
        // arr[index] 0张、1张...不要超过rest的钱数
        int ways = 0;
        for(int zhang = 0; arr[index] * zhang <= rest; zhang++){
            ways += process(arr, index + 1, rest - arr[index] * zhang);
        }
        return ways;
    }

严格表结构的动态规划版本

    //aim是最终要找的零钱数
    public static int way2(int[] arr, int aim){
        if(arr == null || arr.length == 0){
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        for (int index = N - 1; index >= 0; index--){
            for (int rest = 0; rest <= aim; rest++){
                int ways = 0;
                for(int zhang = 0; arr[index] * zhang <= rest; zhang++){
                    ways += dp[index + 1][rest - arr[index] * zhang];
                }
                dp[index][rest] = ways;
            }
        }
        return dp[0][aim];
    }

严格表结构优化版本

image.png

image.png

    public static int way3(int[] arr, int aim){
        if(arr == null || arr.length == 0){
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        for (int index = N - 1; index >= 0; index--){
            for (int rest = 0; rest <= aim; rest++){
                //进一步优化:斜率优化
                //每一个位置的值是(当前行 - 一个arr[index]的格子值) + 它下面格子的值
                dp[index][rest] = dp[index + 1][rest];
                if (rest - arr[index] >= 0){
                    dp[index][rest] += dp[index][rest - arr[index]];
                }
            }
        }
        return dp[0][aim];
    }

总结

image.png