LeetCode - 动态规划

一. 动态规划

  • 动态规划适用于最优子结构问题,即全局最优解由局部最优解决定;
  • 它将原问题拆成多个子问题求解,与深度优先广度优先的区别是它需要保存子问题的解避免重复计算;
  • 动态规划的关键是要找到状态转移方程;
  • 动态规划还能进行空间压缩以节省空间。

1. 一维动态规划

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

n阶台阶的走法等于走到第n-1阶台阶的走法 + 走到第n-2阶台阶的走法,因此状态转移方程:dp[i] = dp[i-1] + dp[i-2]。

/**
    动态规划,n阶台阶的走法等于走到第n-1阶台阶的走法 + 走到第n-2阶台阶的走法
    d(0) = 0
    d(1) = 1
    d(2) = 2
     */
    public int climbStairs(int n) {
        if (n <= 2) {
            return n;
        }
        int[] dp = new int[n+1];
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i < n+1; i++) {
            dp[i] = dp[i-1] + dp[i-2];
        }
        return dp[n]; 
    }

空间压缩:由于第n阶台阶的走法只与第 n-1 阶台阶的走法与 n-2 阶台阶的走法有关,因此只需要保存两个状态即可。

public int climbStairs(int n) {
       if (n <= 2) {
           return n;
       }
       int n1 = 1;
       int n2 = 2;
       int res = 0;
       for (int i = 3; i < n+1; i++) {
           res = n1 + n2;
           n1 = n2;
           n2 = res;
       }
       return res; 
   }

例题198. 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

  • dp[i]中存储当前能偷到的最高金额,dp[i] = max(dp[i-1], dp[i-2] + nums[i]);
  • 当然也可以压缩空间,仅用两个变量存储。
  • 例题213中,首尾房屋相连,其实只需要将其分解为偷(0,m-1) 和 (1,m)中更大的那个,m为数组长度。
/**
    dp[i]中存储当前能偷到的最高金额
    dp[i] = max(dp[i-1], dp[i-2] + nums[i])
     */
    public int rob(int[] nums) {
        if (nums.length == 1) {
            return nums[0];
        }
        int[] dp = new int[nums.length];
        dp[0] = nums[0];
        dp[1] = nums[0] > nums[1] ? nums[0] : nums[1];
        for (int i = 2; i < nums.length; i++) {
            dp[i] = dp[i-1] > dp[i-2] + nums[i] ? dp[i-1] : dp[i-2] + nums[i];
        }
        return dp[nums.length-1];
    }

例题 413. 如果一个数列 至少有三个元素 ,并且任意两个相邻元素之差相同,则称该数列为等差数列。例如,[1,3,5,7,9]、[7,7,7,7] 和 [3,-1,-5,-9] 都是等差数列。给你一个整数数组 nums ,返回数组 nums 中所有为等差数组的 子数组 个数。子数组 是数组中的一个连续序列。

  • 注意这道题说子数组是数组中的一个连续序列!!!
  • 重点是要让dp[i]表示什么。这里将dp[i]表示以nums[i]为最后元素的等差数组数,如果nums[i]与前面的元素能构成等差数列,那么dp[i] = dp[i-1] + 1;
  • 最后的结果需要累加。
public int numberOfArithmeticSlices(int[] nums) {
        if(nums.length < 3) {
            return 0;
        }
        int[] dp = new int[nums.length];
        int res = 0;
        for (int i = 2; i < nums.length; i++) {
            if (nums[i] - nums[i-1] == nums[i-1] - nums[i-2]) {
                dp[i] = dp[i-1] + 1;
                res += dp[i];
            }
        }
        return res;
        
    }

2. 二维动态规划

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

/**
    dp[i][j]表示到 (i,j) 时的最小数字总和,dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
    */
    public int minPathSum(int[][] grid) {
        int[][] dp = new int[grid.length][grid[0].length];
        for (int i = 0; i < grid.length; i++) {
            for (int j = 0; j < grid[0].length; j++) {
                if (i == 0 && j == 0) {
                    dp[i][j] = grid[0][0];
                } else if (i == 0) {
                    dp[i][j] = dp[i][j-1] + grid[i][j];
                } else if (j == 0) {
                    dp[i][j] = dp[i-1][j] + grid[i][j];
                } else {
                    dp[i][j] = grid[i][j] + (dp[i-1][j] < dp[i][j-1] ? dp[i-1][j] : dp[i][j-1]);
                }
            }
        }
        return dp[grid.length-1][grid[0].length-1];
    }

空间压缩。由于当前 dp[i,j] 只与其上方和左方的值有关,可以将其压缩成一维数组,对于第 i 行,由于 dp[j-1] 已经更新过,因此它代表 dp[i][j-1],但dp[j] 还没更新,因此代表 dp[i-1][j]。

public int minPathSum(int[][] grid) {
        //空间压缩
        int[] dp = new int[grid[0].length];
        for (int i = 0; i < grid.length; i++) {
            for (int j = 0; j < grid[0].length; j++) {
                if (i == 0 && j == 0) {
                    dp[j] = grid[i][j];
                } else if (i == 0) {
                    dp[j] = dp[j-1] + grid[i][j];
                } else if (j == 0) {
                    dp[j] = dp[j] + grid[i][j];
                } else {
                    dp[j] = grid[i][j] + (dp[j] < dp[j-1] ? dp[j] : dp[j-1]);
                }
            }
        }
        return dp[grid[0].length - 1];
    }

例题 542. 01 矩阵。给定一个由 0 和 1 组成的矩阵 mat ,请输出一个大小相同的矩阵,其中每一个格子是 mat 中对应位置元素到最近的 0 的距离。两个相邻元素间的距离为 1 ,mat 中至少有一个 0。

  • 这种最短距离的题目首先想到是用广度优先,而这种题算是图的广度优先,需要先找到图中多个源点再开始搜索。用一个dp数组存储遍历过的元素的距离;
  • 第一层是0所在的位置,再由此一层一层扩展;
  • 注意这里有数组记录距离,就不需要通过计算每层的size,层数增加1距离+1;也不需要定义 isVisited 的数组记录是否遍历过,直接更改原数组 1 的值 -1 表示没遍历过。
public int[][] updateMatrix(int[][] mat) {
	int n = mat[0].length;
        int m = mat.length;
        //首先找到所有0
        List<int[]> queue = new LinkedList<>();
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (mat[i][j] == 0) {
                    queue.add(new int[]{i, j});
                } else {
                    //这里变成-1表示没遍历过
                    mat[i][j] = -1;
                }
            }
        }
        
        while(!queue.isEmpty()) {
            int[] tmp = queue.remove(0);
            int[] dx = new int[]{1, -1, 0, 0};
            int[] dy = new int[]{0, 0, 1, -1};
            for (int i = 0; i < 4; i++) {
                int newX = tmp[0]+dx[i];
                int newY =  tmp[1]+dy[i];
                if (newX < m && newX >= 0 && newY<n && newY>=0 && mat[newX][newY] == -1) {
                    mat[newX][newY] = mat[tmp[0]][tmp[1]] + 1;
                    queue.add(new int[]{newX, newY});
                }
            }
        }
        return mat;
    }
}

例题 221. 最大正方形。在一个由 ‘0’ 和 ‘1’ 组成的二维矩阵内,找到只包含 ‘1’ 的最大正方形,并返回其面积。

  • 这道题的突破口是要找出以 [i,j] 为右下角的最大正方形是与其左上、上边、左边三个位置的最大正方形有关。如果以 dp[i][j] 表示以[i,j] 为右下角的最大正方形的面积,则其边长等于左上、上、左三个位置正方形面积最小的那个的边长 + 1。
  • 为了避免在计算过程中一直做开方运算,也可以直接设置 dp[i][j] 为以 [i,j] 为右下角的最大正方形的边长。
/**
    dp[i][j]表示以[i, j]为右下角的最大正方形的边长
    如果matrix[i][j]==1,则dp[i, j]等于min(dp[i-1][j-1], dp[i-1][0], dp[i], [j-1]) + 1;
     */
    public int maximalSquare(char[][] matrix) {
        int[][] dp = new int[matrix.length][matrix[0].length];
        int max = 0;
        for (int i = 0; i < matrix.length; i++) {
            for (int j = 0; j < matrix[0].length; j++) {
                if (matrix[i][j] == '1') {
                    if (i >= 1 && j>=1) {
                        dp[i][j] = 1 + Math.min(Math.min(dp[i-1][j-1], dp[i-1][j]), dp[i][j-1]);
                    } else {
                        dp[i][j] = 1;
                    }
                    if (max < dp[i][j]) {
                        max = dp[i][j];
                    }
                }
            }
        }
        return max * max;
    }

3. 分割类动态规划

此类型的动态规划题,dp[i] 不与 dp[i-1] 有关,而是与满足分割条件的位置有关。

例题 279. 给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

/**
    dp[i] 表示数字 i 最少可以由几个完全平方数相加构成;
    但是 dp[i] 不是与其相邻位置的值有关,而是与 dp[i-k^2] 有关,dp[i] = 1 + dp[i-k^2], k可以取值1 2 3……
     */
    public int numSquares(int n) {
        int[] dp = new int[n+1];
        for (int i = 1; i <= n; i++) {
            dp[i] = i; //最差的情况全是1
            for (int k = 1; k*k <= i; k++) {
                dp[i] = Math.min(dp[i], dp[i-k*k] + 1);
            }
        }
        return dp[n];
    }

例题91,解码方法

这道题难度不大,标准dp,但是需要耐心考虑很多情况。

/**
    dp[i] 表示长度为 i 的字符串的解码总数
     */
    public int numDecodings(String s) {
        //以0开头是0
        if (s.charAt(0) == '0') {
            return 0;
        }
        int[] dp = new int[s.length() + 1];
        dp[0] = 1; //这样设置是为了计算dp[2]
        dp[1] = 1;
        for (int i = 2; i <= s.length(); i++) {
            char prev = s.charAt(i-2);
            char cur = s.charAt(i-1);
            //如果当前数字是0
            if (cur == '0' && (prev > '2' || prev == '0')) {
                return 0;
            } else if (prev == '1' || (prev == '2' && cur <= '6')) {
                if (cur == '0') {
                    dp[i] = dp[i-2]; // 0 只能和前一个数组队
                } else {
                    dp[i] = dp[i-2] + dp[i-1]; //类似于上楼梯的问题
                }
            } else {
                dp[i] = dp[i-1];
            }
        }
        return dp[s.length()];
    }

例题 139。单词拆分,给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s,字典中的单词可以重复使用。

  • 即判断字符串 s 中位置 i 是否可被分割。
/**
    dp[i] 表示从0-i的字符串是否能从字典里找到单词进行拼接,即该位置是否可分割
     */
    public boolean wordBreak(String s, List<String> wordDict) {
        int n = s.length();
        boolean[] dp = new boolean[n+1];
        dp[0] = true;
        for (int i = 1; i <= n; i++) {
            for (String word : wordDict) {
                int len = word.length();
                if (i >= len && s.substring(i-len, i).equals(word)) {
                    dp[i] = dp[i] || dp[i-len];
                }
            }
        }
        return dp[n];
    }

4. 子序列问题

对于子序列问题,第一种动态规划方法是,定义一个 dp 数组,其中 dp[i] 表示以 i 结尾的子序列的性质。在处理好每个位置后,统计一遍各个位置的结果即可得到题目要求的结果。(如例题300 最长递增子序列,例题718 最长重复子数组)

对于子序列问题,第二种动态规划方法是,定义一个 dp 数组,其中 dp[i] 表示到位置 i 为止的子序列的性质,并不必须以 i 结尾。这样 dp 数组的最后一位结果即为题目所求,不需要再对每个位置进行统计。(如例题 1143 最长公共子序列)

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

 /**
    dp[i] 表示以 i 结尾的最长递增子序列的长度
     */
    public int lengthOfLIS(int[] nums) {
        int[] dp = new int[nums.length];
        //初始化dp值为1
        for (int i = 0; i < nums.length; i++) {
            dp[i] = 1;
        }
        int max = 1;
        for (int i = 1; i < nums.length; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[i] > nums[j] && dp[i] < dp[j] + 1) {
                    dp[i] = dp[j] + 1;
                }
            }
            if (max < dp[i]) {
                max = dp[i];
            }
        }
        return max;

    }

这道题还可以通过二分查找提高查找效率,有点难理解。

/**
    dp[k] 表示长度为 k+1 的序列的尾数,如果nums[i] > dp[k],则排列在后面;否则nums[i]替换dp数组中大于等于它的元素中最小的那一个,始终维护dp为一个递增序列。
     */
    public int lengthOfLIS(int[] nums) {
        int[] dp = new int[nums.length];
        dp[0] = nums[0];
        int res = 1; //表示递增序列的长度
        for (int i = 1; i < nums.length; i++) {
            int left = 0;
            int right = res;
            boolean flag = true;
            while (left < right) {
                int key = (left + right - 1) / 2;
                if (nums[i] > dp[key]) {
                    left = key + 1;
                } else {
                    right = key;
                    flag = false;
                }
            }
            if (flag) { //表示当前数大于序列中最大的数,则res++
                dp[res] = nums[i];
                res ++;
            } else { //表示需要替换,res不变
                dp[right] = nums[i];
            }
        }
        return res;
    }

例题 718,最长重复子数组。给两个整数数组 nums1 和 nums2 ,返回两个数组中 公共的 、长度最长的子数组的长度 。子数组必须连续。

  • 两个数组因此定义一个二维数组,dp[i][j] 表示以nums1[i],nums2[j]结尾的最长子数组长度;
  • 分两种情况,如果nums1[i] == nums2[j],则dp[i][j] = dp[i-1][j-1] + 1;如果不相等,则dp[i][j] = 0 (因为不连续了,所以以nums1[i]、nums2[j] 结尾的子数组长度为 0 );
  • 必须维护一个最大值,统计最大的 dp。
/**
    dp[i][j] 表示以nums1[i],nums2[j]结尾的最长子数组长度
     */
    public int findLength(int[] nums1, int[] nums2) {
        int m = nums1.length;
        int n = nums2.length;
        int[][] dp = new int[m+1][n+1];
        int max = 0;
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (nums1[i-1] == nums2[j-1]) {
                    dp[i][j] = dp[i-1][j-1] + 1;
                } else {
                    //说明不连续,只能为0
                    dp[i][j] = 0;
                }
                max = dp[i][j] > max ? dp[i][j] : max;
            }
        }
        return max;
    }

例题 1143,最长公共子序列。给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。子序列的字符无需连续,但是相对顺序不变。

  • 由于有两个序列,因此定义一个二维数组dp[i][j], 表示到字符串text1的位置 i,到字符串text2的位置 j,两者的公共子序列长度;
  • 分两种情况,如果最后一个字符相等,即 text1[i] == text2[j],则dp[i][j] = dp[i-1][j-1] + 1;如果不相等,则dp[i][j] = max(dp[i-1][j], dp[i][j-1]) 。
  • 与上题必须连续的区别是不需要一定以当前数字结尾,遍历完毕即可得到结果,无需统计一次dp的值。
public int longestCommonSubsequence(String text1, String text2) {
        int m = text1.length();
        int n = text2.length();
        int[][] dp = new int[m+1][n+1];
        //i j 从1开始是为了不用考虑边界情况
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (text1.charAt(i-1) == text2.charAt(j-1)) {
                    dp[i][j] = dp[i-1][j-1] + 1;
                } else {
                    dp[i][j] = dp[i][j-1] > dp[i-1][j] ? dp[i][j-1] : dp[i-1][j];
                }
            }
        }
        return dp[m][n];
    }

5. 背包问题

背包问题是一种组合优化的 NP 完全问题:有 N 个物品和容量为 W 的背包,每个物品都有自己的体积 w 和价值 v,求拿哪些物品可以使得背包所装下物品的总价值最大。如果限定每种物品只能选择 0 个或 1 个,则问题称为 0-1 背包问题;如果不限定每种物品的数量,则问题称为无界背包问题或完全背包问题。

  • 对于 0-1背包问题,以 dp[i][j] 表示到第 i 个物品,体积不超过 j 的情况下能获取到的最大价值;则有两种情况,要么取第 i 件物品,要么不取,选择两种情况下价值更大的那种。dp[i][j] = max(dp[i-1][j], dp[i-1][j-w] + v)。另外可以进行空间压缩,dp[j] = max(dp[j], dp[j-w] + v),由于 j-w 必须是 i - 1 对应的值,因此内层循环必须倒序
  • 对于完全背包问题,也以 dp[i][j] 表示到第 i 个物品,体积不超过 j 的情况下能获取到的最大价值,但由于不限定每种物品的数量,其实可选的物品个数接近无穷;直接上状态转移方程:dp[i][j] = max(dp[i-1][j], dp[i][j-w] + v),与0-1背包问题仅在于 i 和 i-1。空间压缩后,dp[j] = max(dp[j], dp[j-w] + v),但内层循环必须正序

例题 416,分割等和子集。给你一个只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

  • 即从当前数组中挑选一些数,使得这些数之和恰好等于所给数组元素和的一半;
  • 这道题与 0-1 背包问题的差别在于,背包是使得价值尽可能大,而本题是恰好等于某个数;
  • 以dp[i][j] 表示选到第 i 个数时,总和是否可能恰好等于 j.
public boolean canPartition(int[] nums) {
        int s = 0;
        for (int i = 0; i < nums.length; i++) {
            s += nums[i];
        }
        if (s % 2 != 0) {
            return false;
        }
        s = s / 2;
        boolean[][] dp = new boolean[nums.length+1][s+1];
        //初始化
        for (int i = 0; i <= nums.length; i++) {
            dp[i][0] = true;
        }
        //i 从 1开始是为了不用考虑nums的边界
        for (int i = 1; i <= nums.length; i++) {
            for (int j = 1; j <= s; j++) {
                if (j >= nums[i-1]) {
                	//要么不选,要么选
                    dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i-1]];
                } else {
                    dp[i][j] = dp[i-1][j];
                }   
            }
        }
        return dp[nums.length][s];
    }

空间优化,第二层倒序:

public boolean canPartition(int[] nums) {
        int s = 0;
        for (int i = 0; i < nums.length; i++) {
            s += nums[i];
        }
        if (s % 2 != 0) {
            return false;
        }
        s = s / 2;
        boolean[] dp = new boolean[s+1];
        dp[0] = true;
        //i 从 1开始是为了不用考虑nums的边界
        for (int i = 1; i <= nums.length; i++) {
            for (int j = s; j >= 1; j--) {
                if (j >= nums[i-1]) {
                    dp[j] = dp[j] || dp[j-nums[i-1]];
                } else {
                    dp[j] = dp[j];
                }   
            }
        }
        return dp[s];
    }

例题 474,一和零。给你一个二进制字符串数组 strs 和两个整数 m 和 n 。请你找出并返回 strs 的最大子集的长度,该子集中最多 有 m 个 0 和 n 个 1 。

  • 这道题是一道典型的 0-1 背包问题,但是有一点不一样的是这道题是二维背包问题,因为 0 和 1 都需要不超过一定的值。
/**
    dp[k][i][j]表示到第k个字符串时,0不超过i, 1不超过j的最大子集个数
    dp[k][i][j] = dp[k][i-s[0]][j-s[1]]
     */
    public int[] countNum(String str) {
        int[] ret = new int[2];
        for (char arr : str.toCharArray()) {
            if (arr == '0') {
                ret[0]++;
            } else {
                ret[1]++;
            }
        }
        return ret;
    }
    public int findMaxForm(String[] strs, int m, int n) {
        int[][][] dp = new int[strs.length+1][m+1][n+1];
        for (int k = 1; k <= strs.length; k++) {
            int[] s = countNum(strs[k-1]);
            for (int i = 0; i <= m; i++) {
                for (int j = 0; j <= n; j++) {
                    if (i >= s[0] && j >= s[1]) {
                        dp[k][i][j] = Math.max(dp[k-1][i][j], 1 + dp[k-1][i - s[0]][j - s[1]]);
                    } else {
                        dp[k][i][j] = dp[k-1][i][j];
                    }
                }
            }
        }
        return dp[strs.length][m][n];
    }

空间压缩后:

public int findMaxForm(String[] strs, int m, int n) {
        int[][] dp = new int[m+1][n+1];
        for (int k = 1; k <= strs.length; k++) {
            int[] s = countNum(strs[k-1]);
            for (int i = m; i >= s[0]; i--) {
                for (int j = n; j >= s[1]; j--) {
                    dp[i][j] = Math.max(dp[i][j], 1 + dp[i - s[0]][j - s[1]]);
                }
            }
        }
        return dp[m][n];
    }

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

/**
    完全背包问题,dp[i][j]表示到第 i个硬币时,总金额不超过 j 的最少硬币个数。
     */
    public int coinChange(int[] coins, int amount) {
        int[][] dp = new int[coins.length+1][amount+1];
        //初始化,由于要求最少个数,因此初始化为最大值
        for (int i = 0; i <= coins.length; i++) {
            //j 从 1 开始,表示 dp[i][0] = 0;
            for (int j = 1; j <= amount; j++) {
                dp[i][j] = amount+1;
            }
        }
        for (int i = 1; i <= coins.length; i++) {
            for (int j = 1; j <= amount; j++) {
                if (j >= coins[i-1]) {
                    dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1);
                } else {
                    dp[i][j] = dp[i-1][j];
                }
            }
        }
        return dp[coins.length][amount] == amount+1 ? -1 : dp[coins.length][amount];
    }

空间压缩:

public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount+1];
        //初始化,由于要求最少个数,因此初始化为最大值
        //j 从 1 开始,表示 dp[0] = 0;
        for (int j = 1; j <= amount; j++) {
            dp[j] = amount+1;
        }
        for (int i = 1; i <= coins.length; i++) {
        	//完全背包是正序
            for (int j = coins[i-1]; j <= amount; j++) {
                dp[j] = Math.min(dp[j], dp[j-coins[i-1]] + 1);
            }
        }
        return dp[amount] == amount+1 ? -1 : dp[amount];
    }

6. 字符串编辑

例题 72,编辑距离。给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数。你可以对一个单词进行如下三种操作:插入一个字符、删除一个字符、替换一个字符。(简单版编辑距离见例题583)

  • dp[i][j] 表示从 0-i 变到 0-j 所需的最少操作数;
  • 如果 word1[i] == word2[j], 则dp[i][j] = dp[i-1][j-1];
  • 如果word1[i] != word2[j], 则dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);分别表示三种操作,删除word1[i], 插入word2[j], 替换word1[i]。
    public int minDistance(String word1, String word2) {
        int m = word1.length();
        int n = word2.length();
        int[][] dp = new int[m+1][n+1];
        for (int i = 0; i <= m; i++) {
            dp[i][0] = i;
        }
        for (int j = 0; j <= n; j++) {
            dp[0][j] = j;
        }
        for (int i = 1; i <= m; i++) {
            for(int j = 1; j <= n; j++) {
                if (word1.charAt(i-1) == word2.charAt(j-1)) {
                    dp[i][j] = dp[i-1][j-1];
                } else {
                    //将word1变为word2,可以对word1执行下列三种情况操作:删除word1[i], 插入word2[j], 替换word1[i]
                    dp[i][j] = 1 + Math.min(Math.min(dp[i-1][j], dp[i][j-1]), dp[i-1][j-1]);
                }
            }
        }
        return dp[m][n];
    }

例题 650,只有两个键的键盘。最初记事本上只有一个字符 ‘A’ 。你每次可以对这个记事本进行两种操作:Copy All(复制全部):复制这个记事本中的所有字符(不允许仅复制部分字符)。Paste(粘贴):粘贴 上一次 复制的字符。给你一个数字 n ,你需要使用最少的操作次数,在记事本上输出 恰好 n 个 ‘A’ 。返回能够打印出 n 个 ‘A’ 的最少操作次数。

  • 这道题是通过乘除而不是加减确定dp的值;
  • 还可以不用dp,用分解质因数达到更快的效果。
/**
    dp[i] 表示输出i个A所需的最少次数,dp[1] = 0,dp[2] = 2, dp[3] = 3, dp[4] = 4, dp[5]=5, dp[6]=5
    dp[i] = dp[j] + i/j (i可以被j整除)
    dp[i] = i (i为质数)
     */
    public int minSteps(int n) {
        int[] dp = new int[n+1];
        dp[1] = 0; 
        for (int i = 2; i <= n; i++) {
            dp[i] = i;
            for (int j = i / 2; j >= 2; j--) {
                if (i % j == 0) {
                    dp[i] = dp[j] + i/j;
                    break;
                }
            }
        }
        return dp[n];
    }

例题 10,正则表达式。给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。‘.’ 匹配任意单个字符。‘*’ 匹配零个或多个前面的那一个元素。所谓匹配,是要涵盖整个字符串 s的,而不是部分字符串。

  • 这道题dp表示的含义很容易想,难得是当p[i] = ‘*’ 时如何考虑到匹配0个、一个还是多个前面的字符;匹配0个前面的字符表示这两位相当于都没了,匹配一个则表示当前位没了。
  • 还有如何进行 dp 的初始化。
/**
    dp[i][j]表示0-j是否能匹配0-i的字符
    如果p[j] == '.' 或者 s[i] == p[j]: dp[i][j] = dp[i-1][j-1];
    如果p[j] == '*': 三种情况考虑;
    上述都不满足,则返回false
     */
    public boolean isMatch(String s, String p) {
        int m = s.length();
        int n = p.length();
        boolean[][] dp = new boolean[m+1][n+1];
        char[] sarr = s.toCharArray();
        char[] parr = p.toCharArray();
        dp[0][0] = true;
        //dp[0][i]表示匹配空串s,如果匹配零个前一个元素,那么dp[0][i]=dp[0][i-2];
        for (int i = 1; i < n + 1; ++i) {
            if (parr[i-1] == '*') {
                dp[0][i] = dp[0][i-2];
            }
        }
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (parr[j-1] == '.' || sarr[i-1] == parr[j-1]) {
                    dp[i][j] = dp[i-1][j-1];
                } else if (parr[j-1] == '*') {
                    //题目说了*前面一定能匹配到有效字符,因此不考虑j<2的情况
                    dp[i][j] = dp[i][j-2]; //匹配0个前面的字符
                    if (sarr[i-1] == parr[j-2] || parr[j-2] == '.') {
                        dp[i][j] = dp[i-1][j] || dp[i][j-1] || dp[i][j-2]; //分别表示匹配多个前面的字符、匹配一个前面的字符、匹配0个前面的字符
                    }
                } else {
                    dp[i][j] = false;
                }
            }
        }
        return dp[m][n];
    }

7. 股票买卖

一个思路搞定所有股票交易题:

  • 我们要跳出固有的思维模式,并不是要考虑买还是卖,而是要最大化手里持有的钱。
  • 买股票手里的钱减少,卖股票手里的钱增加,无论什么时刻,我们要保证手里的钱最多。
  • 我们这一次买还是卖只跟上一次我们卖还是买的状态有关。
  • buy 表示在买入后手里的钱;
  • sell 表示卖出后手里的钱。
  • 这种思路其实是动态规划优化空间复杂度后的结果,一般动态规划的解法是初始化一个 dp[i][j] 数组,i 表示第 i 天,j 取值 0 和 1,分别表示第 i 天不持有股票(sell)手中的钱,和第 i 天持有股票(buy)手中的钱,最后返回 dp[n][0].

例题 121. 买卖股票的最佳时机(只允许交易一次)。给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

  • 注意 sell = Math.max(sell, buy + prices[i]) 这一句之前一直不理解为什么是今天的 buy 而不是前一天的 buy,后来尝试用一个变量存储前一天的buy,sell = Math.max(sell, preBuy + prices[i]) 发现也能通过。其实可以这样理解,如果今天没买入,则相当于前一天的 buy,如果今天买入了,那 buy + prices[i] 则是今天又卖出了,则相当于今天没有进行交易,与前一天的 sell 相等。如果不进行空间复杂度的优化这里就比较好理解了,需要写成 dp[i-1][1] 。
public int maxProfit(int[] prices) {
        int buy = Integer.MIN_VALUE;
        int sell = 0;
        for (int i = 0; i < prices.length; i++) {
            //因为只交易一次,因此初始化上一次卖后的钱为0
            buy = Math.max(buy, 0 - prices[i]);
            sell = Math.max(sell, buy + prices[i]);
        }
        return sell;
    }

例题 122,买卖股票的最佳时机 II(不限定买卖次数)。

    public int maxProfit(int[] prices) {
        int buy = Integer.MIN_VALUE;
        int sell = 0;
        for (int i = 0; i < prices.length; i++) {
            //交易一次和无数次的区别仅在于是 0 - prices[i] 还是上一次 sell 后减prices[i];
            buy = Math.max(buy, sell - prices[i]);
            sell = Math.max(sell, buy + prices[i]);
        }
        return sell;
    }

例题 123、188,买卖股票的最佳时机 IV(只允许买卖两次、k次).

public int maxProfit(int k, int[] prices) {
        int m = prices.length;
        int[] buy = new int[k+1];
        int[] sell = new int[k+1];
        for (int i = 0; i <= k; i++) {
            buy[i] = Integer.MIN_VALUE;
        }
        for (int i = 0; i < m; i ++) {
            for (int j = 1; j <= k; j++) {
                buy[j] = Math.max(buy[j], sell[j-1] - prices[i]);
                sell[j] = Math.max(sell[j], buy[j] + prices[i]);
            }
        }
        return sell[k];
    }

例题 309,最佳买卖股票时机含冷冻期。

  • 三种状态、买入、卖出、冷冻期。则多记录一个冷冻期的状态,买入必须在冷冻期之后。
  • 但这种复杂的状态转换的题目,最正统的方法是用状态机来解决。画状态转移转移过程,然后建立状态转移方程。
public int maxProfit(int[] prices) {
        int m = prices.length;
        int buy = Integer.MIN_VALUE;
        int rest = 0;
        int sell = 0;
        for (int i = 0; i < m; i++) {
            buy = Math.max(buy, rest - prices[i]);
            rest = sell;
            sell = Math.max(sell, buy + prices[i]);
        }
        return sell;
    }

例题 714,买卖股票的最佳时机含手续费。

  • 在买入或者卖出的时候减去手续费即可。
public int maxProfit(int[] prices, int fee) {
        int buy = Integer.MIN_VALUE;
        int sell = 0;
        for (int i = 0; i < prices.length; i++) {
            buy = Math.max(buy, sell - prices[i]);
            sell = Math.max(sell, buy + prices[i] - fee);
        }
        return sell;
    }

8. 其他例题

例题 343,整数拆分。给定一个正整数 n ,将其拆分为 k 个 正整数 的和( k >= 2 ),并使这些整数的乘积最大化。返回 你可以获得的最大乘积 。

  • 可以用dp来做, dp[i]表示数i能被拆分成的最大乘积,dp[i] = dp[i-k] * k, k表示被拆分出来的数。由于k不知道是多少,因此得遍历寻找
  • 也可以直接找规律,把n拆分成仅包含2和3时,乘积最大。
public int integerBreak(int n) {
        int[] dp = new int[n+1];
        int max = 1;
        for (int i = 2; i <= n; i++) {
            for (int k = 2; k < i; k++) {
                //有两种情况,最后要么不再拆分,要么继续拆分
                dp[i] = Math.max((i-k) * k, dp[i-k] * k);
                max = max > dp[i] ? max : dp[i];
            }    
            dp[i] = max;
        }
        return dp[n];
    }

例题 376,摆动序列。如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为 摆动序列 。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。给你一个整数数组 nums ,返回 nums 中作为 摆动序列的最长子序列的长度 。

  • 这道题不同之处在于需要维护两个dp数组,因为第一个差有两种情况,因此当前差会有两种情况;
  • up必然由down而来,down必然由up而来。
/**
    考虑第一个差有正和为负两种情况,需要建立两个dp数组
    up[i]表示nums[i]-nums[i-1]是正时的最长子序列,down[i]表示最后两个数的差是负的情况;
    up必然由down而来,down必然由up而来
    由于只与最后的up 和 down有关,因此可以优化成常数数组
     */
    public int wiggleMaxLength(int[] nums) {
        int up = 1;
        int down = 1;
        for (int i = 1; i < nums.length; i++) {
            if (nums[i] > nums[i-1]) {
                up = down + 1;
            } else if (nums[i] < nums[i-1]) {
                down = up + 1;
            }
        }
        return up > down ? up : down;
    }

例题 494,目标和。给你一个整数数组 nums 和一个整数 target 。向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个 表达式 。返回可以通过上述方法构造的、运算结果等于 target 的不同表达式的数目。

  • 该问题可以用回溯法,也可以转化为0 1 背包问题;
原问题可以转化为找nums的一个正子集s(P)和一个负子集s(N),使得总和等于target;
s(P) - s(N) = target;
s(P) + s(N) + s(P) - s(N) = target + s(P) + s(N);
2 * s(P) = target + s(all);
s(p) = (target + s(all)) / 2;
则原问题转化为从数组中找一些数,使得总和等于(target + s(all)) / 2。
dp[i][j] 表示到第i个数字,结果等于j的表达式数目。
可以进行空间优化,第二层倒序遍历。
public int findTargetSumWays(int[] nums, int target) {
        int m = nums.length;
        int s = 0;
        for (int i = 0; i < m; i++) {
            s += nums[i];
        }
        if ((s + target) % 2 != 0 || s + target < 0) {
            return 0;
        }
        s = (s + target) / 2;
        int[][] dp = new int[m+1][s+1];
        dp[0][0] = 1;
        for(int i = 1; i <= m; i++) {
            for (int j = 0; j <= s; j++) {
                if (j >= nums[i-1]) {
                    //对于当前数,有取和不取两种选择
                    dp[i][j] = dp[i-1][j-nums[i-1]] + dp[i-1][j];
                } else {
                    dp[i][j] = dp[i-1][j];
                }   
            }
        }
        return dp[m][s];
    }
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值