算法:动态规划

一、概述

动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。

动态规划常常适用于有重叠子问题和最优子结构性质的问题,并且记录所有子问题的结果,因此动态规划方法所耗时间往往远少于朴素解法。

动态规划有自底向上和自顶向下两种解决问题的方式。自顶向下即记忆化递归,自底向上就是递推。

使用动态规划解决的问题有个明显的特点,一旦一个子问题的求解得到结果,以后的计算过程就不会修改它,这样的特点叫做无后效性,求解问题的过程形成了一张有向无环图。动态规划只解决每个子问题一次,具有天然剪枝的功能,从而减少计算量。

二、常见算法

1. 汉诺塔问题

LeetCode 汉诺塔问题

在经典汉诺塔问题中,有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。一开始,所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制:
(1) 每次只能移动一个盘子;
(2) 盘子只能从柱子顶端滑出移到下一根柱子;
(3) 盘子只能叠在比它大的盘子上。
请编写程序,用栈将所有盘子从第一根柱子移到最后一根柱子。
你需要原地修改栈。

示例:

输入:A = [2, 1, 0], B = [], C = []
输出:C = [2, 1, 0]

在这里插入图片描述

解题思路:

将问题进行拆解:

  1. 将n-1个盘子从移动到auxiliary
    move(n-1,start,auxiliary,target)
  2. 将第n个盘子添加到target
    target.add(start.remove(start.size()-1));
  3. 将n-1个盘子放到target
    move(n-1,auxiliary,start,target);
public void hanota(List<Integer> A, List<Integer> B, List<Integer> C) {
    move(A.size(), A, B, C);
}

public void move(int size, List<Integer> A, List<Integer> B, List<Integer> C) {
	// 剩余1个的时候,退出递归。
    if (size == 0) {
    	// 这里需要使用A.size()重新计算size,而不能使用参数size。
        C.add(A.remove(A.size()-1));
        return;
    }
    move(size-1, A, C, B);
    C.add(A.remove(A.size()-1));
    move(size-1, B, A, C);
}

2. 爬楼梯

LeetCode 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例:

输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。

  1. 1 阶 + 1 阶
  2. 2 阶

解题思路:

动态规划的状态转移方程为:dp[i] = dp[i-1] + dp[i-2],i>=2。

public int climbStairs(int n) {
    if (n <= 2) {
        return n;
    }
    int[] dp = new int[n];
    dp[0] = 1;
    dp[1] = 2;
    for (int i=2; i<n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n-1];
}

3. 跳跃游戏

LeetCode 跳跃游戏

给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标。

示例 :

输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。

解题思路:

这一题相对于上一题来说,有了一些变化。主要区别在于每一次走的步数为数组中的元素值。
状态转移方程为:rightMost = Math.max(i+nums[i], rightMost)。

public boolean canJump(int[] nums) {
	int rightMost = 0;
	int len = nums.length;
	for (int i=0; i<len; i++) {
		if (rightMost < i) {
			return false;
		}
		rightMost = Math.max(i+nums[i], rightMost);
	}
	return true;
}

4. 跳跃游戏 II

LeetCode 跳跃游戏II

给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。

  • 每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:
  • 0 <= j <= nums[i]
  • i + j < n

返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]。

示例:

输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。

解题思路:

上一题主要是判断能否跳到最后一个位置,这里是判断跳到最后一个位置最少的步数。
计算当前可以跳到的最远距离的方式与上一题一致。下面我们就需要对步数进行定义,我们在数组的第 i 到 i+nums[i] 位置之间做一次决策,每次决策算执行一步。

public int jump(int[] nums) {
    int minStep = 0;
    int end = 0;
    int maxPosition = 0;
    int len = nums.length;
    // 这里要跳到最后一个位置,所以不需要取到最后一个值。
    for (int i=0; i<len-1; i++) {
        maxPosition = Math.max(i+nums[i], maxPosition);
        // 在第i到i+nums[i]位置做一次决策,判断可以达到的最远距离,以便决定下一次决策的范围。
        if (end == i) {
            end = maxPosition;
            minStep++;
        }
    }
    return minStep;
}

5. 不同路径

LeetCode 不同路径

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?

在这里插入图片描述
解题思路:

  • 状态转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  • 第1行全为1,因为只有1种走法(即只能往右走)。
  • 第1列全为1,因为只有1种走法(即只能往下走)。
public int uniquePaths(int m, int n) {
    int[][] dp = new int[m][n];
    // 第1列全为1,因为只有1种走法(即只能往下走)。
    for(int i=0; i<m; i++) {
        dp[i][0] = 1;
    }
    // 第1行全为1,因为只有1种走法(即只能往右走)。
    for(int i=0; i<n; i++) {
        dp[0][i] = 1;
    }
    // 填充剩余格子的可能性走法。
    for (int i=1; i<m; i++) {
        for (int j=1; j<n; j++) {
            dp[i][j] = dp[i-1][j] + dp[i][j-1];
        }
    }
    return dp[m-1][n-1];
}

6. 不同路径 II

LeetCode 不同路径II

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。

在这里插入图片描述

解题思路:

这一题与上一题类似,区别在于在 mxn 的网格中存在障碍物,也就是说在障碍物所在的位置,可达性为0。同时,如果障碍物出现在边界上,则障碍物后续的边界位置可达性全为0。

  • 状态转移方程:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  • 当有障碍物时,且在左边界时:dp[i][0] 到 dp[m][0] 都为 0。
  • 当有障碍物时,且在上边界时:dp[0][j] 到 dp[0][n] 都为 0。
  • 当有障碍物时,且不在边界时:dp[i][j] = 0
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
	// 获取网格范围:m,n
    int m = obstacleGrid.length;
    if (m <= 0) {
        return 0;
    }
    int n = obstacleGrid[0].length;
	// 设定dp表。
    int[][] dp = new int[m][n];
    // 第1列全为1,如果边界出现障碍物时,后序的边界可达性全为0。
    boolean lost = false;
    for(int i=0; i<m; i++) {
        if (obstacleGrid[i][0] == 1 || lost) {
            dp[i][0] = 0;
            // 设置个标记,让障碍物后续全为0;
            lost = true;
        } else {
            dp[i][0] = 1;
        }   
    }
    // 第1行全为1,如果边界出现障碍物时,后序的边界可达性全为0。
    lost = false;
    for(int i=0; i<n; i++) {
        if (obstacleGrid[0][i] == 1 || lost) {
            dp[0][i] = 0;
            // 设置个标记,让障碍物后续全为0;
            lost = true;
        } else {
            dp[0][i] = 1;
        } 
    }
    // 填充剩余格子的可能性走法。
    for (int i=1; i<m; i++) {
        for (int j=1; j<n; j++) {
        	// 障碍物不在边界上。
            if (obstacleGrid[i][j] == 1) {
                dp[i][j] = 0;
            } else {
                dp[i][j] = dp[i-1][j] + dp[i][j-1];
            }
        }
    }
    return dp[m-1][n-1];
}

7. 最小路径和

LeetCode 最小路径和

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。

在这里插入图片描述

解题思路:

上面两题我们计算到达目标点的路径种类,这里我们需要计算的是到达目标点的路径最小和。因为路径方向就两种,从上到下,从左到右。所以我们只要求出每一步的局部最小和就可以得出最终的最小和。

  • 状态转移方程:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
  • 左边界时:dp[i][0] = grid[i][0] + dp[i-1][0]
  • 上边界时:dp[0][i] = grid[0][i] + dp[0][i-1]
public int minPathSum(int[][] grid) {
    int m = grid.length;
    int n = grid[0].length;

    // 走到当前网格的最小路径和。
    int[][] dp = new int[m][n];
    dp[0][0] = grid[0][0];
    for(int i=1; i<m; i++) {
    	// 左边界累加
        dp[i][0] = grid[i][0] + dp[i-1][0];
    }
    for(int i=1; i<n; i++) {
    	// 上边界累加
        dp[0][i] = grid[0][i] + dp[0][i-1];
    }
    for (int i=1; i<m; i++) {
        for (int j=1; j<n; j++) {
            dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
        }
    }
    return dp[m-1][n-1];
}

8. 买卖股票的最佳时机

LeetCode 买卖股票的最佳时机

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。

示例:

输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

解题思路:

状态转移方程:dp[i] = Math.max(prices[i] - minPrice, dp[i-1])

public int maxProfit(int[] prices) {
    int len = prices.length;
    // 假设dp存放的是可以获取的最大利润。
    int[] dp = new int[len];
    int minPrice = prices[0];
    dp[0] = 0;
    for (int i=1; i<len; i++) {
        dp[i] = Math.max(prices[i] - minPrice, dp[i-1]);
        if (minPrice > prices[i]) {
            minPrice = prices[i];
        }
    }
    return dp[len-1];
}

9. 买卖股票的最佳时机 II

LeetCode 买卖股票的最佳时机

给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。

示例:

输入:prices = [7,1,5,3,6,4]
输出:7
解释:在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6 - 3 = 3 。
总利润为 4 + 3 = 7 。

解题思路:

这一题的解题思路与“最小路径和”类似,就是求局部正向收益的累加和。而上一题“买卖股票的最佳时机”求的是全局最大收益。
状态转移方程:dp[i] = Math.max(prices[i] - prePrice, 0) + Math.max(dp[i-1], 0),即当前的正向收益+之前的正向收益。

public int maxProfit(int[] prices) {
    int len = prices.length;
    int[] dp = new int[len];
    int prePrice = prices[0];
    dp[0] = 0;
    for (int i=1; i<len; i++) {
    	// 方法1:
        // if (dp[i-1] > 0) {
        //     int value = Math.max(prices[i] - prePrice, 0);
        //     dp[i] = value + dp[i-1];
        // } else {
        //     dp[i] = Math.max(prices[i] - prePrice, 0);
        // }
        // prePrice = prices[i];
        
        // 方法2(等效):
        int value = Math.max(prices[i] - prePrice, 0);
        dp[i] = Math.max(dp[i-1], 0) + value;
        prePrice = prices[i];
    }
    return dp[len-1];
}

10. 二叉树中的最大路径和

LeeoCode 二叉树中的最大路径和

路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。

  • 路径和 是路径中各节点值的总和。
  • 给你一个二叉树的根节点 root ,返回其 最大路径和 。

在这里插入图片描述

解题思路:

根据上图可以知道,最大路径和不一定是根节点+左侧,或者根节点+右侧,还有可能是根节点+左侧+右侧。所以可以记录局部的最大值(根节点+左侧+右侧),并返回该 根节点 + max(左节点,右节点)的值。为了比较左右节点的值大小,所以可以采取后序遍历的模板写法。

public int maxPathSum(TreeNode root) {
    traversal(root);
    return max;
}
// 最大路径和
// 注意点:这里需要设置默认值为最小值。
int max = Integer.MIN_VALUE;

public int traversal(TreeNode root) {
    if (root == null) {
        return 0;
    }
    // 先获取左侧的最大值,如果是负数则返回0,表示丢弃。
    int left = Math.max(traversal(root.left), 0);
    // 先获取右侧的最大值,如果是负数则返回0,表示丢弃。
    int right = Math.max(traversal(root.right), 0);
    // 返回左右值较大的节点 + 根节点值;
    // 如果左右节点如果都为负数,则丢弃;直接将当前根节点作为局部最大值返回。
    int localMax = Math.max(left, right) + root.val;
	// 更新最大值。
    max = Math.max(left+right+root.val, max);
    // 返回上一层节点继续遍历。
    return localMax;
}

11. 零钱兑换

LeetCode 零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。

示例:

输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

解题思路:

这道题可以利用动态规划来实现,dp[i] 定义为要拼出面额为 i 的数最少需要的硬币数。
状态转移方程:dp[i] = Math.min(dp[i], 1+dp[i-coin])

public int coinChange(int[] coins, int amount) {
    if (coins.length == 0) return -1;
    // 1.先定义dp数组,长度为amount+1,主要是多出了一个0面额的初始值。
    int[] dp = new int[amount+1];
    // 2.需要求出最小值,因此这里默认初始值设置为一个不小于amount+1的值即可,因为就算面额全为1也无法达到amount+1这个值。
    for (int i=0; i< dp.length; i++) {
        dp[i] = amount+1;
    }
    // 3.初始化dp[0]的值。
    dp[0] = 0;
	// 4.开始遍历并更新dp表。
    for (int i=1; i<dp.length; i++) {
    	// 5.每一个面值的dp表都要遍历一次coins数组,以便获取到最小硬币数。
        for (int coin : coins) {
        	// 6.如果硬币面额大于我们的目标面额,说明无法拼出来,保留默认值amount+1。
            if (i < coin) continue;
            // 7. 遍历更新获取dp[i]的最小值,在dp[i-coin]的次数上再加1次就可以拼出来。
            dp[i] = Math.min(dp[i], 1+dp[i-coin]);
        }
    }
    // 获取dp[dp.length-1]的值。如果值为amount+1,则这个面额无法使用硬币拼凑出来。
    return dp[dp.length-1] == amount+1 ? -1 : dp[dp.length-1];
}

12. 最长递增子序列

LeetCode 最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

解题思路:

解题思路与上一题凑硬币类似,凑硬币时由于是求最小个数,所以初始化dp表时需要设置一个大于目标面额的数字作为是否能凑出的初始值。然后开始遍历更新整个dp表,在更新每一个dp[i]时都需要遍历硬币面额数组,以便获取到最小的硬币数量。

本题也使用dp表来简化重复操作,由于求的是最大子序列,所以初始化dp表时,默认都是长度为1。然后遍历更新每一个dp[i]的值。在更新每一个dp[i]时,都需要重新遍历第0到i位置的数字,获取小于nums[i]的个数并更新到dp[i]上。

public int lengthOfLIS(int[] nums) {
    int len = nums.length;
    // dp[i]表示i位置之前小于nums[i]的个数。
    int[] dp = new int[nums.length];
    // 设置默认值为1,表示默认每一个数字单独为1个子序列。
    Arrays.fill(dp, 1);
	// 更新每一个dp[i]的值
    for (int i=0; i<len; i++) {
    	// 比较0-(i-1)处的值与第i处的值的大小,并更新dp[i]。
        for (int j=0; j<i; j++) {
            if (nums[i] > nums[j]) {
            	// 更细dp[i]。
                dp[i] = Math.max(dp[i], dp[j]+1);
            }
        }
    }
    // 查询dp表,找到dp表中最大的值即为最大子序列。
    int maxLen = 0;
    for (int i=0; i<dp.length; i++) {
        maxLen = Math.max(maxLen, dp[i]);
    }

    return maxLen;
}

13. 最长回文串

LeetCode 最长回文串

给定一个包含大写字母和小写字母的字符串 s ,返回 通过这些字母构造成的 最长的回文串 。

在构造过程中,请注意 区分大小写 。比如 “Aa” 不能当做一个回文字符串。

示例:

输入:s = “abccccdd”
输出:7
解释:
我们可以构造的最长的回文串是"dccaccd", 它的长度是 7。

解题思路:

  • 利用HashMap,从给定的字符串中统计各个字符出现的次数。
  • 如果某个字符出现了偶数次,则它本身的最大回文长度即为该字符出现的次数。
  • 如果某个字符出现了奇数次,则它本身的最大回文长度即为该字符出现的次数-1。
  • 上述两步统计出来的回文总长度为偶数。所以如果上述两步统计出来的回文长度小于字符串长度,则在计算出来的回文总长度基础上+1,此时才是最大的回文长度。
public int longestPalindrome(String s) {
    if (s == null) {
        return 0;
    }
    if (s.length() <= 1) {
        return s.length();
    }
	// 统计各个字符出现的次数。
    char[] arr = s.toCharArray();
    HashMap<Character, Integer> map = new HashMap();
    for (int i=0; i<arr.length; i++) {
        Integer count = map.get(arr[i]);
        if (count == null) {
            map.put(arr[i], 1);
        } else {
            map.put(arr[i], ++count);
        }
    }
    // 遍历map集合。
    int sum = 0;
    for (Map.Entry<Character, Integer> kv : map.entrySet()) {
    	// 统计回文长度
        int count = kv.getValue();
        if (count % 2 == 0) {//如果该字符出现偶数次,则直接相加。
            sum += count;
        } else {//如果该字符出现奇数次,则相加后再减1,表示偶数次。
            sum += (count-1);
        }
    }
    // 如果回文总长度比字符串小,说明存在个别字符出现奇数次,则最长回文长度需要加1。
    if (sum < arr.length) {
        sum += 1;
    }
    return sum;
}

14. 最长回文子串

LeetCode 最长回文子串

给你一个字符串 s,找到 s 中最长的回文子串。

如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。

示例:

输入:s = “babad”
输出:“bab”
解释:“aba” 同样是符合题意的答案。

解题思路:

  1. 中心扩散法。
  2. 动态规划(二维数组)。
// 中心扩散法:非动态规划
public String longestPalindrome(String s) {
    if (s == null || s.length() == 1) {
        return s;
    }

    int start = 0;
    int end = 0;
    for (int i=0; i<s.length(); i++) {
        int len1 = expend(i, i, s);
        int len2 = expend(i, i+1, s);
        int len = Math.max(len1, len2);
        if (len > end - start + 1) {
            start = i - (len - 1)/2;
            end = i + (len)/2;
        }
    }
    return s.substring(start, end+1);
}


public int expend(int l, int r, String s) {

    int length = s.length();
    int left = l;
    int right = r;
    while (left >= 0 && right < length && s.charAt(left) == s.charAt(right)) {
        left--;
        right++;
    }
    return right - left -1;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值