算法总结—【动态规划一维、二维、状态压缩】

十五 DP【重中之重】

1 动态规划知识总结

基础问题、背包问题、打家劫舍、股票问题、子序列问题

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,

1.1 dp的解题过程

对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!

  1. 确定dp数组(dp table)以及下标的含义,比如dp[i],dp的含义和i的含义都要弄清楚
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?因为一些情况是递推公式决定了dp数组要如何初始化!

1.2 dp如何debug?

找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!

1.3 01背包问题

leetcode上没有纯01背包的问题,都是01背包应用方面的题目,也就是需要转化为01背包问题。

所以我先通过纯01背包问题,把01背包原理讲清楚,后续再讲解leetcode题目的时候,重点就是讲解如何转化为01背包问题了

问题定义:有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!

1.4 完全背包问题

算法类型 数组维度 遍历顺序 原因
0-1 背包 二维 dp[i][j] 正序/逆序均可 依赖上一行独立数据。
0-1 背包 一维 dp[j] 必须逆序 防止本轮更新后的新值影响后续计算。
完全背包 一维 dp[j] 必须正序 故意利用本轮更新后的新值实现重复利用。
LIS (递增序列) 一维 dp[i] 必须正序 dp[i]dp[i]dp[i] 依赖已完成的前驱状态 dp[j]dp[j]dp[j]。

题目1------爬楼梯【126】

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:

复制代码
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2:

复制代码
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

提示:

  • 1 <= n <= 45

考虑重叠子问题:到n的不同方式,应该是到n-1的不同方式+n-2的不同方式

dp[i]就是到第i阶楼梯的不同方式

初始化:dp[0]=0,dp[1]=1,dp[2]=2,

java 复制代码
/**
 * @author YinHang
 * @description 爬楼梯
 * @create 2026-03-05 10:06
 */
public class Main {
    public int climbStairs(int n) {
        if (n<=2){
            return n;
        }
        int[] dp = new int[n+1];
        dp[0] = 0;
        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];
    }


    public static void main(String[] args) {
        Main main = new Main();
        System.out.println(main.climbStairs(4));
    }
}

题目2------杨辉三角【】

给定一个非负整数 *numRows,*生成「杨辉三角」的前 numRows 行。

在**「杨辉三角」**中,每个数是它左上方和右上方的数的和。


示例 1:

复制代码
输入: numRows = 5
输出: [[1],[1,1],[1,2,1],[1,3,3,1],[1,4,6,4,1]]

示例 2:

复制代码
输入: numRows = 1
输出: [[1]]

提示:

  • 1 <= numRows <= 30

思路:

java 复制代码
public List<List<Integer>> generate(int numRows){
        List<List<Integer>> ans = new ArrayList<>();
        ans.add(Arrays.asList(1));
        if (numRows == 1) {
            return ans;
        }
        ans.add(Arrays.asList(1, 1));
        if (numRows == 2) {
            return ans;
        }
        for (int i = 2; i < numRows; i++) {
            List<Integer> layer = new ArrayList<>();
            layer.add(1);
            for (int j = 1; j < i; j++) {
                layer.add(ans.get(i - 1).get(j) + ans.get(i - 1).get(j - 1));
            }
            layer.add(1);
            ans.add(layer);
        }
        return ans;
    }

题目3------打家劫舍【71】

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:

复制代码
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。

示例 2:

复制代码
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。

提示:

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 400

思路:

不能相邻的进行偷窃,考虑子问题,需要最高金额,

dp[i]到第i间房子的最高金额

dp[0] = nums[0], dp[1] = max(nums[0], nums[1])

dp[i] = max(dp[i-2]+nums[i], dp[i-1])

最后答案最大的dp[n-1],dp[n-2]

java 复制代码
 public int rob(int[] nums) {
        int n = nums.length;
        if (n == 1) {
            return nums[0];
        } else if (n == 2) {
            return Math.max(nums[0], nums[1]);
        }
        int[] dp = new int[n];
        dp[0] = nums[0];
        dp[1] = Math.max(nums[0], nums[1]);
        for (int i = 2; i < n; i++) {
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
        }
        return Math.max(dp[n - 1], dp[n - 2]);
    }

题目4------完全平方数【23】

给你一个整数 n ,返回 和为 n 的完全平方数的最少数量

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,14916 都是完全平方数,而 311 不是。

示例 1:

复制代码
输入:n = 12
输出:3 
解释:12 = 4 + 4 + 4

示例 2:

复制代码
输入:n = 13
输出:2
解释:13 = 4 + 9

提示:

  • 1 <= n <= 104

思路:

判断完全平方数的函数,

求最少数量dp[i],到数字i的完全最少数量

dp[i] = min(dp[i-j*J]+1) j*j<=i

java 复制代码
public int numSquares(int num) {
        if (num == 1){
            return 1;
        }
        int[] dp = new int[num + 1];
        Arrays.fill(dp, 10001);
        dp[0] = 0;

        for (int i = 1; i < num+1; i++) {
            if (isSquares(i)) {
                dp[i] = 1;
                continue;
            }
            for (int j = 1; j*j <= i; j++) {
                dp[i] = Math.min(dp[i], dp[i - j*j] + 1);
            }
        }
        return dp[num];
    }

     public boolean isSquares(int num) {
        int x = (int) Math.sqrt(num);
        return x * x == num;
    }

题目5------零钱兑换【127】

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1:

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

示例 2:

复制代码
输入:coins = [2], amount = 3
输出:-1

示例 3:

复制代码
输入:coins = [1], amount = 0
输出:0

提示:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 231 - 1
  • 0 <= amount <= 104

思路:

dp[i]:金额i所需的最少金币个数

遍历金币种类i:coins[i]

状态转移方程:dp[j] = min(dp[j], dp[j-coins[i]]+1)

dp[0]=0

java 复制代码
public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, amount+1);
        dp[0] = 0;
        for (int coin : coins) {
            for (int j = 1; j < amount + 1; j++) {
                if (j - coin >= 0) {
                    dp[j] = Math.min(dp[j], dp[j - coin] + 1);
                }
            }
        }
        return dp[amount] == amount+1 ? -1 : dp[amount];
    }

题目6------单词拆分【66】

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true

**注意:**不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

示例 1:

复制代码
输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet" 和 "code" 拼接成。

示例 2:

复制代码
输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。
注意,你可以重复使用字典中的单词。

示例 3:

复制代码
输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

提示:

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • swordDict[i] 仅由小写英文字母组成
  • wordDict 中的所有字符串 互不相同

思路:

dp[i]:前i个字符组成的字符串能不能被拆分

状态转移:dp [i ]=dp [j ] && check (s [j ...i −1]),其中 check (s [j ...i −1]) 表示子串 s [j ...i−1] 是否出现在字典中。

j=0-i

初始化dp[0]为空串,

java 复制代码
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+1; i++) {
            for (int j = 0; j < i; j++) {
                if (dp[j] && wordDict.contains(s.substring(j, i))) {
                    dp[i] = true;
                    break;
                }
            }
        }
        return dp[n];
    }

但是达不到最优

题目7------最长递增子序列【246】

高频

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

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

示例 1:

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

示例 2:

复制代码
输入:nums = [0,1,0,3,2,3]
输出:4

示例 3:

复制代码
输入:nums = [7,7,7,7,7,7,7]
输出:1

提示:

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

进阶:

  • 你能将算法的时间复杂度降低到 O(n log(n)) 吗?

思路:

定义一个 DP 数组,其中 dp[i]dp[i]dp[i] 表示:以第 iii 个元素 nums[i]nums[i]nums[i] 结尾的最长递增子序列的长度

  1. 状态转移方程

对于当前处理的元素 nums[i]nums[i]nums[i],我们需要回头看它之前的所有元素 nums[j]nums[j]nums[j](其中 0≤j<i0 \le j < i0≤j<i):

  • 如果 nums[i]>nums[j]nums[i] > nums[j]nums[i]>nums[j] :说明 nums[i]nums[i]nums[i] 可以接在以 nums[j]nums[j]nums[j] 结尾的递增子序列后面,形成一个更长的子序列。此时长度为 dp[j]+1dp[j] + 1dp[j]+1。
  • 我们要从所有能接上去的前驱状态中,选出最大的那一个

公式表达:

dp[i]=max⁡(dp[j])+1其中 0≤j<i 且 nums[i]>nums[j]dp[i] = \max(dp[j]) + 1 \quad \text{其中 } 0 \le j < i \text{ 且 } nums[i] > nums[j]dp[i]=max(dp[j])+1其中 0≤j<i 且 nums[i]>nums[j]


  1. 初始状态与最终结果
  • 初始状态 :将 dpdpdp 数组的所有元素初始化为 1。因为每个单独的数字本身就是一个长度为 1 的递增子序列。
  • 最终结果 :由于最长递增子序列不一定以最后一个元素结尾,所以结果是 dpdpdp 数组中的最大值 ,即 max⁡(dp)\max(dp)max(dp)。
java 复制代码
public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        if (n == 1) {
            return 1;
        }
        int[] dp = new int[n];
        Arrays.fill(dp, 1);
        int maxlength = 1;
        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]) {
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
                maxlength = Math.max(dp[i], maxlength);
            }
        }
        return maxlength;
    }

要求输出子序列

java 复制代码
public List<Integer> getLIS(int[] nums) {
    int n = nums.length;
    if (n == 0) return new ArrayList<>();

    int[] dp = new int[n];
    int[] pre = new int[n]; // 记录前驱索引
    Arrays.fill(dp, 1);
    Arrays.fill(pre, -1); // 初始为 -1 表示没有前驱

    int maxLen = 0;
    int maxIdx = 0;

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[i] > nums[j]) {
                if (dp[j] + 1 > dp[i]) {
                    dp[i] = dp[j] + 1;
                    pre[i] = j; // 记录是从哪个 j 转移来的
                }
            }
        }
        // 更新全局最大长度和对应的索引
        if (dp[i] > maxLen) {
            maxLen = dp[i];
            maxIdx = i;
        }
    }

    // 回溯路径
    List<Integer> result = new ArrayList<>();
    while (maxIdx != -1) {
        result.add(nums[maxIdx]);
        maxIdx = pre[maxIdx]; // 移动到前驱节点
    }
    Collections.reverse(result); // 反转得到正确顺序
    return result;
}

题目8------乘积最大子数组【77】

给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续 子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。

测试用例的答案是一个 32-位 整数。

请注意,一个只包含一个元素的数组的乘积是这个元素的值。

示例 1:

复制代码
输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。

示例 2:

复制代码
输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。

提示:

  • 1 <= nums.length <= 2 * 104
  • -10 <= nums[i] <= 10
  • nums 的任何子数组的乘积都 保证 是一个 32-位 整数

考虑到正数负数需要两个dp

maxdp[i]:以i结尾子数组最大乘积

mindp[i]:以i结尾子数组最小乘积

if:num[i]>0:maxdp[i] = maxdp[i-1]*nums[i],mindp[i] = nums[i]

else:

java 复制代码
public int maxProduct(int[] nums) {
    int n = nums.length;
    if (n == 1) {
        return nums[0];
    }
    // 需要两个dp数组分别记录最大和最小
    int[] maxdp = new int[n];
    int[] mindp = new int[n];
    Arrays.fill(maxdp, Integer.MIN_VALUE);
    Arrays.fill(mindp, Integer.MAX_VALUE);
    maxdp[0] = nums[0];
    mindp[0] = nums[0];
    int maxProduct = nums[0];
    for (int i = 1; i < n; i++) {
        if (nums[i] > 0) {
            maxdp[i] = Math.max(nums[i],maxdp[i - 1] * nums[i]);
            mindp[i] = Math.min(nums[i], mindp[i - 1] * nums[i]);
        } else {
            maxdp[i] = Math.max(nums[i],mindp[i - 1] * nums[i]);
            mindp[i] = Math.min(nums[i],maxdp[i - 1] * nums[i]);
        }
        maxProduct = Math.max(maxProduct, maxdp[i]);
    }
    return maxProduct;
}

优化:因为每一次只关注前一个状态的最大,

java 复制代码
 public int maxProduct(int[] nums) {
        int n = nums.length;
        if (n == 1) {
            return nums[0];
        }
        // 需要两个dp数组分别记录最大和最小
        int maxProduct = nums[0];
        int minProduct = nums[0];
        int res = nums[0];
        for (int i = 1; i < n; i++) {
            int c1 = nums[i];
            int c2 = nums[i] * maxProduct;
            int c3 = nums[i] * minProduct;
            maxProduct = Math.max(c1, Math.max(c2, c3));
            minProduct = Math.min(c1, Math.min(c2, c3));
            res = Math.max(res, maxProduct);
        }
        return res;
    }
}

输出子数组:

java 复制代码

题目9------分割等和子集【26】

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

示例 1:

复制代码
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。

示例 2:

复制代码
输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100

求和sum,能不能装满sum/2,本质上是01背包问题,就说每个数字只能使用1次,,要么使用二维数组正向遍历,要么使用一维数组反向遍历。

01背包使用1维数组,就逆序遍历容量

dp[i]:容量i能不能被物品填充

状态转移方程就是dp[i] = dp[i]|dp[i-num]

java 复制代码
public boolean canPartition(int[] nums) {
        if (nums.length < 2) {
            return false;
        }
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        if (sum % 2 != 0) {
            return false;
        }
        int target = sum / 2;
        boolean[] dp = new boolean[target+1];
        dp[0] = true;
        for (int num : nums) {
            for (int i = target; i >= num; i--) {
                if (dp[i - num]) {
                    dp[i] = true;
                }
            }
        }
        return dp[target];
    }

二维数组正常

dp[i][j]:使用0-i物品能不能组合出j

状态转移:dp[i][j] = dp[i][j]|dp[i-1][j-num]

java 复制代码
public boolean canPartition(int[] nums) {
        if (nums.length < 2) {
            return false;
        }
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        if (sum % 2 != 0) {
            return false;
        }
        int target = sum / 2;
        boolean[][] dp = new boolean[nums.length][target + 1];
        // 初始化
        for (int i = 0; i < nums.length; i++) {
            dp[i][0] = true;
        }
        dp[0][nums[0]] = true;
        for (int i = 0; i < nums.length; i++) {
            for (int j = 0; j < target + 1; j++) {
                if (j > nums[i]) {
                    dp[i][j] = dp[i - 1][j] | dp[i - 1][j - nums[i]];
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        return dp[nums.length-1][target];
    }

题目10------最长有效括号【127】

高频[背诵]

给你一个只包含 '('')' 的字符串,找出最长有效(格式正确且连续)括号 子串 的长度。

左右括号匹配,即每个左括号都有对应的右括号将其闭合的字符串是格式正确的,比如 "(()())"

示例 1:

复制代码
输入:s = "(()"
输出:2
解释:最长有效括号子串是 "()"

示例 2:

复制代码
输入:s = ")()())"
输出:4
解释:最长有效括号子串是 "()()"

示例 3:

复制代码
输入:s = ""
输出:0

提示:

  • 0 <= s.length <= 3 * 104
  • s[i]'('')'

栈:

但是通过栈,我们可以在遍历给定字符串的过程中去判断到目前为止扫描的子串的有效性,同时能得到最长有效括号的长度。

具体做法是我们始终保持栈底元素为当前已经遍历过的元素中「最后一个没有被匹配的右括号的下标」,这样的做法主要是考虑了边界条件的处理,栈里其他元素维护左括号的下标:

对于遇到的每个 '(' ,我们将它的下标放入栈中

对于遇到的每个 ')' ,我们先弹出栈顶元素表示匹配了当前右括号:

如果栈为空,说明当前的右括号为没有被匹配的右括号,我们将其下标放入栈中来更新我们之前提到的「最后一个没有被匹配的右括号的下标」

如果栈不为空,当前右括号的下标减去栈顶元素即为「以该右括号为结尾的最长有效括号的长度」

我们从前往后遍历字符串并更新答案即可。

需要注意的是,如果一开始栈为空,第一个字符为左括号的时候我们会将其放入栈中,这样就不满足提及的「最后一个没有被匹配的右括号的下标」,为了保持统一,我们在一开始的时候往栈中放入一个值为 −1 的元素。

java 复制代码
public int longestValidParentheses(String s) {
        if (s.length() < 2) {
            return 0;
        }
        // 存储字符的下标,并且保持栈底是未被匹配的右括号,开始的时候是-1-
        Deque<Integer> stack = new ArrayDeque<>();
        stack.push(-1);
        int maxlen = 0;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == '(') {
                // 左括号直接入栈
                stack.push(i);
            } else {
                // 右括号就先出栈
                stack.pop();
                if (stack.isEmpty()) {
                    // 遇见了右括号并且没有任何匹配的就入栈
                    stack.push(i);
                    continue;
                }
                int len = i - stack.peek();
                maxlen = Math.max(maxlen, len);
            }
        }
        return maxlen;
    }

    public static void main(String[] args) {
        Main main = new Main();
        System.out.println(main.longestValidParentheses(")()())"));
    }

动态规划:

dp[i]:以下标 i 字符结尾的最长有效括号的长度。我们将 dp 数组全部初始化为 0 。显然有效的子串一定以 ')' 结尾,因此我们可以知道以 '(' 结尾的子串对应的 dp 值必定为 0 ,我们只需要求解 ')' 在 dp 数组中对应位置的值。

状态转移方程:

我们从前往后遍历字符串求解 dp 值,我们每两个字符检查一次:

s[i]=')' 且 s[i−1]='(',也就是字符串形如 "......()",我们可以推出:

dp[i]=dp[i−2]+2

我们可以进行这样的转移,是因为结束部分的 "()" 是一个有效子字符串,并且将之前有效子字符串的长度增加了 2 。

s[i]=')' 且 s[i−1]=')',也就是字符串形如 "......))",我们可以推出:

如果 s[i−dp[i−1]−1]='(',那么

dp[i]=dp[i−1]+dp[i−dp[i−1]−2]+2

第一个好理解介绍连续的()接到前面去

第2个:

第一步:跨过中间的"套娃"

既然 s[i−1]s[i-1]s[i−1] 是个右括号,那么以 s[i−1]s[i-1]s[i−1] 结尾的地方,可能已经存在一个成型的、被完全包裹的有效括号子串了

  • 这个中间已经成型的子串长度是多少?答案是 dp[i−1]dp[i-1]dp[i−1]
  • 既然中间有一坨完整的有效括号,当前的 s[i]s[i]s[i] 想要找到匹配的 (,就不能在这一坨里面找(里面已经内部消化完了)。
  • 它必须跨过这整个完整的子串,去它的前边找!

第二步:定位那个目标左括号 (

我们来算一下,跨过那个"套娃"后,目标左括号的下标应该是多少?

  1. 当前字符位置是:iii
  2. 要跨过的有效长度是:dp[i−1]dp[i-1]dp[i−1]
  3. 要找的目标位置就是:当前位置往前倒推 dp[i−1]dp[i-1]dp[i−1] 个长度,再往前走一格。
  4. 目标下标公式i−dp[i−1]−1i - dp[i-1] - 1i−dp[i−1]−1

如果在这个目标位置上的字符刚好是 (,恭喜你,当前的 s[i]s[i]s[i] 找到对象了!这就形成了一个大大的外层包裹:( [中间一坨有效括号] )

第三步:拼装最终长度(终极公式拆解)

既然配对成功了,以当前 iii 结尾的最长有效长度该怎么算?它由三部分拼成!

公式:dp[i]=dp[i−1]⏟中间的套娃+2⏟新配对的最外层+dp[i−dp[i−1]−2]⏟前边的贪吃蛇dp[i] = \underbrace{dp[i-1]}{\text{中间的套娃}} + \underbrace{2}{\text{新配对的最外层}} + \underbrace{dp[i - dp[i-1] - 2]}_{\text{前边的贪吃蛇}}dp[i]=中间的套娃 dp[i−1]+新配对的最外层 2+前边的贪吃蛇 dp[i−dp[i−1]−2]

我们逐个击破:

  1. dp[i−1]dp[i-1]dp[i−1]:这是被我们跨过去的那坨内部有效括号的长度。
  2. 222 :这是我们刚刚配对成功的这一头一尾两个括号 () 贡献的长度。
  3. dp[i−dp[i−1]−2]dp[i - dp[i-1] - 2]dp[i−dp[i−1]−2](最容易漏掉的一项):
  • 想象一下这种情况:() ( (中间一坨) )
  • 我们的外层大括号刚匹配完,但在这个大括号的紧挨着的左边 ,可能还有一段已经成型的有效括号(比如最前面的 ())!
  • 大括号左边的那个位置索引是多少?是配对的左括号下标再往前一格:(i−dp[i−1]−1)−1(i - dp[i-1] - 1) - 1(i−dp[i−1]−1)−1,即 i−dp[i−1]−2i - dp[i-1] - 2i−dp[i−1]−2
  • 所以我们要把左边这段现成的长度也像贪吃蛇一样"接"过来!

拿个具体例子跑一遍:()(())

下标:0 1 2 3 4 5

字符:( ) ( ( ) )

假设我们现在算到了最后一步,i=5i = 5i=5

  • s[5]s[5]s[5] 是 ),s[4]s[4]s[4] 也是 )。符合 )) 的情况。
  • 第一步看内部套娃 :dp[4]dp[4]dp[4] 记录的是以位置 4 结尾的有效括号长度(即 ()),所以 dp[4]=2dp[4] = 2dp[4]=2。
  • 第二步找匹配左括号 :位置是 5−dp[4]−1=5−2−1=25 - dp[4] - 1 = 5 - 2 - 1 = 25−dp[4]−1=5−2−1=2。看看 s[2]s[2]s[2] 是什么?哇,刚好是 (!配对成功。
  • 第三步算总长度
  • 中间长度:dp[4]=2dp[4] = 2dp[4]=2
  • 外层新配对:222
  • 检查前边贪吃蛇:位置 5−dp[4]−2=5−2−2=15 - dp[4] - 2 = 5 - 2 - 2 = 15−dp[4]−2=5−2−2=1。看看 dp[1]dp[1]dp[1] 也就是最开头那个 () 的长度,是 222。
  • 总长度 dp[5]=2+2+dp[1]=2+2+2=6dp[5] = 2 + 2 + dp[1] = 2 + 2 + 2 = 6dp[5]=2+2+dp[1]=2+2+2=6!

总结一下:

这个转移方程的本质就是:大括号包着小括号,包完之后再去看看左边还有没有能接上的邻居。 这下是不是把那个长长的下标算式看顺眼多了?

java 复制代码
    public int longestValidParentheses(String s) {
        if (s.length() < 2) {
            return 0;
        }
        int[] dp = new int[s.length()];
        int maxlength = 0;
        for (int i = 1; i < s.length(); i++) {
            if (s.charAt(i) == ')') {
                if (s.charAt(i - 1) == '(') {
                    dp[i] = i >= 2 ? dp[i - 2] + 2 : 2;
                } else if(i-dp[i-1]>0 && s.charAt(i-dp[i-1]-1)=='('){
                    dp[i] = dp[i - 1] + 2 + (i-dp[i-1]>=2?dp[i - dp[i - 1] - 2]:0);
                }
                maxlength = Math.max(maxlength, dp[i]);
            }
        }

        return maxlength;
    }

十六 MDP【重中之重】

题目1------不同路径【73】

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish" )。

问总共有多少条不同的路径?

示例 1:

复制代码
输入:m = 3, n = 7
输出:28

示例 2:

复制代码
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下

示例 3:

复制代码
输入:m = 7, n = 3
输出:28

示例 4:

复制代码
输入:m = 3, n = 3
输出:6

提示:

  • 1 <= m, n <= 100
  • 题目数据保证答案小于等于 2 * 109

dp[i][j]:走到到位置i,j的不同路径数

dp[i][j] = dp[i][j-1]+dp[i-1][j]

初始化:i==0:dp[0][j]=1;dp[i][0]=1

java 复制代码
public int pathSum(int m, int n) {
        int[][] dp = new int[m][n];
        for (int i = 0; i < m; i++) {
            dp[i][0] = 1;
        }
        for (int j = 0; j < n; j++) {
            dp[0][j] = 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];
    }

    public static void main(String[] args) {
        Main main = new Main();
        System.out.println(main.pathSum(3, 7));
    }

题目2------最小路径和【92】

给定一个包含非负整数的 *m* x *n* 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

**说明:**每次只能向下或者向右移动一步。

示例 1:

复制代码
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。

示例 2:

复制代码
输入:grid = [[1,2,3],[4,5,6]]
输出:12

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 200
  • 0 <= grid[i][j] <= 200

求的是总和最小

dp[i][j]:表示走到i,j时最小的路径和

min(dp[i-1][j], dp[i][j-1])

初始化:因为只能向下向右走,所以最上边一行和最左边一列可以初始化

java 复制代码
public int minPathSum(int[][] grid) {
    int m = grid.length, 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] = dp[i - 1][0] + grid[i][0];
    }
    for (int j = 1; j < n; j++) {
        dp[0][j] = dp[0][j - 1] + grid[0][j];
    }
    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];
}

题目3------最长回文子串【358】

必考

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

示例 1:

复制代码
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

示例 2:

复制代码
输入:s = "cbbd"
输出:"bb"

提示:

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成

动态规划:

dp[i][j]:记录i-j是否为回文

java 复制代码
public String longestPalindrome(String s) {
        int n = s.length();
        if (n == 0) {
            return "";
        }
        int maxlen = 1;
        int maxi = 0;
        boolean[][] dp = new boolean[n][n];
        // 长度为1
        for (int i = 0; i < n; i++) {
            dp[i][i] = true;
        }
        // 长度为2
        for (int i = 0; i < n - 1; i++) {
            if (s.charAt(i) == s.charAt(i + 1)) {
                dp[i][i + 1] = true;
                maxi = i;
                maxlen = 2;
            }
        }


        for (int i = n-1; i >= 0; i--) {
            for (int j = i+2; j < n; j++) {
                if (s.charAt(i) == s.charAt(j)&&dp[i+1][j-1]) {
                    dp[i][j] = true;
                    if (maxlen < j - i + 1) {
                        maxi = i;
                        maxlen = j - i + 1;
                    }
                }
            }
        }
        return s.substring(maxi, maxi + maxlen);
    }

方案 A:按子串长度(L)遍历(最直观)

这是最稳妥的写法。我们先算出所有长度为 3 的,再算长度为 4 的......这样能保证 dp[i+1][j-1](长度更小的子串)永远先被算出来。

Java

复制代码
// 长度为 1 和 2 的逻辑保持不变...

// 长度 L 从 3 开始增加
for (int L = 3; L <= n; L++) {
for (int i = 0; i <= n - L; i++) {
  int j = i + L - 1; // 子串终点
  if (s.charAt(i) == s.charAt(j) && dp[i+1][j-1]) {
      dp[i][j] = true;
      if (L > maxlen) {
          maxi = i;
          maxlen = L;
      }
  }
}
}

方案 B:逆序遍历 i

如果你不想改变 ij 的嵌套结构,只需让 i 从大到小 遍历。这样在计算第 i 行时,第 i+1 行已经算好了。

Java

复制代码
// 从下往上,从左往右填表
for (int i = n - 1; i >= 0; i--) {
for (int j = i + 2; j < n; j++) {
  if (s.charAt(i) == s.charAt(j) && dp[i+1][j-1]) {
      dp[i][j] = true;
      if (j - i + 1 > maxlen) {
          maxi = i;
          maxlen = j - i + 1;
      }
  }
}
}

中心扩散

遍历每一个每一个字符让长度为1和长度为2的分别进行中心扩散计算长度。

java 复制代码
public String longestPalindrome(String s) {
    int n = s.length();
    if (n == 0) {
        return "";
    }
    int start = 0, end = 0;
    for (int i = 0; i < n; i++) {
        int len1 = expandTwoSide(s, i, i);
        int len2 = expandTwoSide(s, i, i + 1);
        int len = Math.max(len1, len2);
        if (len > end - start) {
            start = i - (len - 1) / 2;
            end = i + len / 2;
        }
    }
    return s.substring(start, end + 1);
}

public int expandTwoSide(String s, int left, int right) {
    if (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
        left++;
        right++;
    }
    return right - left + 1;
}


public static void main(String[] args) {
    Main main = new Main();
    System.out.println(main.longestPalindrome("babad"));
}

题目4------最长公共子序列【178】

给定两个字符串 text1text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

  • 例如,"ace""abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。

两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

示例 1:

复制代码
输入:text1 = "abcde", text2 = "ace" 
输出:3  
解释:最长公共子序列是 "ace" ,它的长度为 3 。

示例 2:

复制代码
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。

示例 3:

复制代码
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0 。

提示:

  • 1 <= text1.length, text2.length <= 1000
  • text1text2 仅由小写英文字符组成。

dp[i][j]:text1以第i个字符结尾,text2以第j个字符结尾,这两个最长公共子序列长度

状态转移方程:

if t1[i]==t2[j]: dp[i][j]=dp[i-1][j-1]+1

else: dp[i][j] = max(dp[i][j-1], dp[i-1][j])

初始化:

dp[m+1][n+1],把没有任何字符的0算上

答案dp[m][n]

java 复制代码
public int longestCommonSubsequence(String text1, String text2){
    int m = text1.length(), n = text2.length();
    int[][] dp = new int[m+1][n+1];

    for (int i = 1; i < m+1; i++) {
        for (int j = 1; j < n+1; j++) {
            if (text1.charAt(i-1) == text2.charAt(j-1)) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[m][n];
}

题目5------编辑距离【190】

给你两个单词 word1word2请返回将 word1 转换成 word2 所使用的最少操作数

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

示例 1:

复制代码
输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

示例 2:

复制代码
输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')

提示:

  • 0 <= word1.length, word2.length <= 500
  • word1word2 由小写英文字母组成

dp[i][j]:从word1前i个字符转换到word2前j个字符的最少操作数

dp[i][j] = min(dp[i-1][j-1], dp[i][j-1], dp[i-1][j])+1

dp[0][0]都是0无字符

dp[0][j]需要增加j个字符

dp[i][0]需要删除i个字符

java 复制代码
public int minDistance(String word1, String word2) {
    int m = word1.length(), n = word2.length();
    int[][] dp = new int[m + 1][n + 1];
    // 初始化
    dp[0][0] = 0;
    for (int i = 1; i < m+1; i++) {
        dp[i][0] = i;
    }
    for (int j = 1; j < n+1; j++) {
        dp[0][j] = j;
    }

    for (int i = 1; i < m+1; i++) {
        for (int j = 1; j < n+1; j++) {
            if (Objects.equals(word1.charAt(i - 1), word2.charAt(j - 1))) {
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i-1][j], dp[i][j-1]))+1;
            }
        }
    }
    return dp[m][n];
}
相关推荐
重生之后端学习2 小时前
152. 乘积最大子数组
数据结构·算法·leetcode·职场和发展·动态规划
weixin_649555672 小时前
C语言程序设计第四版(何钦铭、颜晖)第八章之字符串压缩
c语言·数据结构·算法
单车少年ing2 小时前
一个编码BUG
算法·bug
努力中的编程者2 小时前
哈希表(C语言底层实现)
c语言·数据结构·c++·算法·哈希算法·散列表
CoovallyAIHub2 小时前
ICLR 2026 | MRAD:不拟合直接查表,零样本工业缺陷检测新范式,16 数据集均值最优
深度学习·算法·计算机视觉
摆烂小白敲代码2 小时前
【数据结构与算法】汉诺塔问题(C++)
c语言·开发语言·数据结构·c++·算法·hanoi·汉诺塔问题
Trouvaille ~2 小时前
【递归、搜索与回溯】专题(八):记忆化搜索——从暴力递归到动态规划的桥梁
c++·算法·leetcode·青少年编程·面试·蓝桥杯·动态规划
飞Link3 小时前
降维打击聚类难题:高斯混合模型 (GMM) 深度解析与实战
人工智能·算法·机器学习·数据挖掘·聚类
无尽的罚坐人生3 小时前
hot 100 543. 二叉树的直径
数据结构·算法·leetcode