数据结构算法学习:LeetCode热题100-动态规划篇(下)(单词拆分、最长递增子序列、乘积最大子数组、分割等和子集、最长有效括号)

文章目录

  • 简介
  • [139. 单词拆分](#139. 单词拆分)
  • [300. 最长递增子序列](#300. 最长递增子序列)
  • [152. 乘积最大子数组](#152. 乘积最大子数组)
  • [416. 分割等和子集](#416. 分割等和子集)
  • [32. 最长有效括号](#32. 最长有效括号)
  • 个人学习总结

简介

本篇博客聚焦LeetCode热题100中的动态规划经典题目,涵盖单词拆分、最长递增子序列、乘积最大子数组、分割等和子集、最长有效括号五类问题。通过分析解题思想、状态转移逻辑及复杂度,深入理解动态规划在字符串处理、数组子序列、背包问题等场景的应用,掌握状态定义与转移的核心技巧。

139. 单词拆分

问题描述

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

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

示例:

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

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

标签提示: 字典树、记忆化搜索、哈希表、字符串、动态规划

解题思想

采用动态规划解决,定义状态 dp[i] 表示字符串 s 的前 i 个字符(即 s[0...i-1])是否可以被字典中的单词拆分。状态转移的核心逻辑是:若存在 j < i,使得前 j 个字符可拆分(dp[j] = true)且 s[j...i-1] 是字典中的单词,则前 i 个字符可拆分。状态转移方程为:

  • dp[i]=dp[j] ∧ wordDict.contains(s[j...i−1])

其中 j 遍历 0 到 i-1,寻找满足条件的分割点。

解题步骤

  1. 数据预处理:将字典列表转换为 HashSet,以便快速判断子串是否存在于字典中。
  2. 状态初始化:创建长度为 s.length() + 1 的布尔数组 dp,初始化 dp[0] = true(空字符串视为可拆分)。
  3. 状态转移:外层循环遍历 i 从 1 到 s.length(),内层循环遍历 j 从 0 到 i-1,检查 dp[j] 是否为 true 且 s.substring(j, i) 是否在字典中。若满足,则 dp[i] = true 并跳出内层循环(无需继续检查更小的 j)。
  4. 结果返回:返回 dp[s.length()],表示整个字符串是否可拆分。

实现代码

java 复制代码
class Solution {
    // 状态转移方程:dp[i]=dp[j] && check(s[j..i−1])
    public boolean wordBreak(String s, List<String> wordDict) {
        // 利用HashSet存储单词表
        Set<String> wordDictSet = new HashSet<>(wordDict);
        // 初始化状态转移数组
        boolean[] dp = new boolean[s.length() + 1];
        dp[0] = true;
        for(int i = 1; i < s.length() + 1; i ++){
            // 判断能否有单词构成
            for(int j = 0; j < i; j ++){
                if(dp[j] && wordDictSet.contains(s.substring(j, i))){
                    dp[i] = true;       // 能够由单词构成
                    break;
                }
            }
        }
        return dp[s.length()];
    }
}

复杂度分析

  • 时间复杂度: O ( n 2 ) O(n^2 ) O(n2)
    外层循环执行 n 次(n 为字符串 s 的长度),内层循环对于每个 i 最多执行 i 次,总次数为 1 + 2 + ⋯ + n = n ( n + 1 ) 2 1+2+⋯+n = \frac{n(n+1)}{2} 1+2+⋯+n=2n(n+1),因此时间复杂度为 O ( n 2 ) O(n^2 ) O(n2)。
  • 空间复杂度: O ( n ) O(n) O(n)
    需要使用长度为 n+1 的数组存储中间状态。

300. 最长递增子序列

问题描述

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

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

示例:

java 复制代码
示例 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

标签提示: 数组、二分查找、动态规划

解题思想

采用动态规划解决,定义状态 dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。对于每个元素 nums[i],需遍历其之前的所有元素 nums[j](j < i),若 nums[i] > nums[j],则 nums[i] 可接在 nums[j] 后面形成更长的递增子序列,此时 dp[i] 可更新为 dp[j] + 1。最终结果为所有 dp[i] 中的最大值。

解题步骤

  1. 状态初始化:创建长度为 n(n = nums.length)的数组 dp,初始化所有元素为 1(每个元素自身可构成长度为1的递增子序列)。
  2. 状态转移:遍历数组 nums,对于每个位置 i,再遍历 j 从 0 到 i-1:
    若 nums[i] > nums[j],则更新 dp[i] = max(dp[i], dp[j] + 1)。
  3. 结果计算:遍历 dp 数组,取其中的最大值作为最终答案。

实现代码

java 复制代码
class Solution {
    // 贪心+二分查找
    // 利用一个数组tails[i]来存储当前长度为i+1的递增子序列的最小尾部元素(这样就能更好的让子序列增长)
    // 当num大于tails中所有元素,那么可以添加其尾部(长度加1);否则替换掉当前第一个大于num的元素
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        int[] dp = new int[n];
        Arrays.fill(dp, 1);
        int ans = 0;
        for(int i = 0; i < n; i ++){
            for(int j = 0; j < i; j ++){
                if(nums[i] > nums[j]){
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            ans = Math.max(ans, dp[i]);
        }
        return ans;
    }
}

复杂度分析

  • 时间复杂度: O ( n 2 ) O(n^2 ) O(n2)
    外层循环执行 n 次,内层循环对于每个 i 最多执行 i 次,总次数为 1 + 2 + ⋯ + n = n ( n + 1 ) 2 1+2+⋯+n = \frac{n(n+1)}{2} 1+2+⋯+n=2n(n+1),因此时间复杂度为 O ( n 2 ) O(n^2 ) O(n2)。
  • 空间复杂度: O ( n ) O(n) O(n)
    需要使用长度为 n 的数组 dp 存储每个位置的最长递增子序列长度。

152. 乘积最大子数组

问题描述

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

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

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

示例:

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

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

标签提示: 数组、动态规划

解题思想

由于乘法运算中负数的影响(负数乘以负数会变为正数,可能成为新的最大乘积 ),需同时维护以当前元素结尾的最大乘积最小乘积。若仅记录最大值,当遇到负数时可能遗漏由最小负数转化而来的最大正数,因此需双状态转移。

解题步骤

  1. 状态定义:maxDp[i]:以 nums[i] 结尾的子数组的最大乘积;minDp[i]:以 nums[i] 结尾的子数组的最小乘积
  2. 状态初始化:maxDp[0] = minDp[0] = nums[0],初始化第一个元素的最大/最小乘积为自身。
  3. 状态转移:对于每个元素 nums[i](i ≥ 1),需考虑三种情况:
    • nums[i] 自身作为子数组
    • nums[i] 与前一个最大乘积相乘(可能为正数或负数)
    • nums[i] 与前一个最小乘积相乘(负数乘负数可能转化为最大正数)
      因此状态转移方程:
    • maxDp[i] = max(num, max(num * maxDp[i - 1], num * minDp[i - 1]));
    • minDp[i] = min(num, min(num * minDp[i - 1], num * maxDp[i - 1]));
  4. 结果更新:遍历过程中维护全局最大值 ans = max(ans, maxDp[i]),最终返回 ans。

实现代码

java 复制代码
class Solution {
    // 最小的为自己本身,状态转移方程dp[i] = max(nums[i], nums[i] * dp[i - 1])
    // 但是目前无法处理负元素(乘上一个负数,直接变最小积,但是后面再乘以一个负数又能变最大)
    // 因此需要记录最大和最小(分别记录)
    public int maxProduct(int[] nums) {
        int n = nums.length;
        int[] maxDp = new int[n];
        int[] minDp = new int[n];
        maxDp[0] = nums[0];
        minDp[0] = nums[0];
        int ans = nums[0];
        for(int i = 1; i < n; i ++){
            int num = nums[i];
            int tmpMax = Math.max(num, Math.max(num * maxDp[i - 1], num * minDp[i - 1]));
            int tmpMin = Math.min(num, Math.min(num * minDp[i - 1], num * maxDp[i - 1]));
            maxDp[i] = tmpMax;
            minDp[i] = tmpMin;
            ans = Math.max(ans, maxDp[i]);
        }
        return ans;
    }
}

复杂度分析

  • 时间复杂度:O(n)
    遍历数组一次,每次状态转移为常数时间操作,总时间复杂度为线性。
  • 空间复杂度:O(n)
    使用两个长度为 n 的数组 maxDp 和 minDp 存储中间状态。

416. 分割等和子集

问题描述

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

示例:

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

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

标签提示: 数组、动态规划

解题思想

将分割等和子集问题转化为子集和问题:若数组总和为sum,需判断是否存在子集和为sum/2(此时另一子集和也为sum/2,实现等和分割)。该问题等价于0/1背包问题,即从数组中选择若干元素,使其和为目标值target = sum/2。

解题步骤

  1. 总和判断:计算数组总和sum,若sum为奇数,直接返回false(无法分成两个等和子集)。
  2. 目标确定:令target = sum / 2,需判断是否存在子集和为target。
  3. 状态定义:用布尔数组dp[j]表示"能否用数组元素凑出和为j",初始化dp[0] = true(和为0时,不选元素即可满足)。
  4. 状态转移:遍历每个数字num,对每个j从target倒序遍历到num(倒序避免重复使用同一元素),更新状态:dp[j]=dp[j] ∣∣ dp[j−num](dp[j]表示不选num时能否凑出j,dp[j - num]表示选num时能否凑出j - num,两者取或)。
  5. 结果判断:最终返回dp[target],若为true则存在等和子集,否则不存在。

实现代码

java 复制代码
class Solution {
    // 分割等和,如果考虑两个子集怎么组合的话,会考虑因素有点多
    // 可以转换一下为凑成总和一半的子集
    // 这样就变成了0、1背包问题
    public boolean canPartition(int[] nums) {
        int sum = 0;
        for(int num : nums){
            sum += num;
        }
        if(sum % 2 == 1){
            return false;
        }
        boolean[] dp = new boolean[sum/2 + 1];
        dp[0] = true;
        int target = sum / 2;
        for(int num : nums){
            for(int j = target; j >= num; j --){
                dp[j] = dp[j] || dp[j - num];
            }
            if(dp[target]){
                return true;
            }
        }
        return dp[target];
    }
}

复杂度分析

  • 时间复杂度: O ( n × t a r g e t ) O(n×target) O(n×target)
    外层循环遍历n个元素,内层循环遍历target(即sum/2)次,总操作次数为 n × t a r g e t n × target n×target。
  • 空间复杂度: O ( t a r g e t ) O(target) O(target)
    使用长度为target + 1的dp数组存储中间状态。

32. 最长有效括号

问题描述

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

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

示例:

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

示例 2:
输入:s = ")()())"
输出:4
解释:最长有效括号子串是 "()()"

标签提示: 栈、字符串、动态规划

解题思想

采用动态规划,定义状态 dp[i] 表示以字符串第 i 个字符(索引从0开始)结尾的最长有效括号子串的长度。由于有效括号必须以 ')' 结尾,因此仅当 s[i] == ')' 时才可能更新 dp[i]。通过分情况讨论前一个字符的类型,推导状态转移方程。

解题步骤

  1. 状态定义:dp[i] 表示以 s[i] 结尾的最长有效括号子串的长度。
  2. 初始化:创建长度为 n(n = s.length())的数组 dp,初始值为 0(默认无有效子串)。
  3. 状态转移:遍历字符串,对每个位置 i(从1开始):
    • 若 s[i] == ')' 且 s[i-1] == '('(直接匹配): d p [ i ] = ( i ≥ 2 ? d p [ i − 2 ] : 0 ) + 2 dp[i]=(i≥2?dp[i−2]:0)+2 dp[i]=(i≥2?dp[i−2]:0)+2
    • 若 s[i] == ')' 且 s[i-1] == ')'(需检查前一个有效子串的前一个字符): 设 j = i - dp[i-1] - 1(前一个有效子串的前一个位置),若 j >= 0 且 s[j] == '(',则: d p [ i ] = d p [ i − 1 ] + 2 + ( j ≥ 1 ? d p [ j − 1 ] : 0 ) dp[i]=dp[i−1]+2+(j≥1?dp[j−1]:0) dp[i]=dp[i−1]+2+(j≥1?dp[j−1]:0)
  4. 结果提取:遍历 dp 数组,取最大值作为最终答案。

实现代码

java 复制代码
class Solution {
    // 动态规划解法(关键找准数组存什么信息,以及状态转移方程)
    // dp[i]:存储当前i结尾的最长有效子串长度(不一定最长子串是以i结尾)
    // 状态转移方程, 想要变长,首先当前位置得是')'
    // 当i - 1 为'(', 那么匹配上了,dp[i] = dp[i - 2] + 2
    // 当i - 1 为')',那么就需要去判断dp[i - 1]前面的字符(j),子串前面是否为'('了,是那么 dp[i] = dp[i - dp[i - 1] -2] + 2 + dp[i - 1];
    public int longestValidParentheses(String s) {
        int n = s.length();
        if(n == 0){
            return 0;
        }
        int[] dp = new int[n];
        int ans = 0;
        dp[0] = 0;
        for(int i = 1; i < n; i ++){
            if(s.charAt(i) == ')'){
                if(s.charAt(i - 1) == '('){
                    dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
                }else{
                    int j = i - dp[i - 1] - 1;
                    if(j >= 0 && s.charAt(j) == '('){
                        dp[i] = dp[i - 1] + 2;
                        if(j >= 1){
                            dp[i] += dp[j - 1];
                        }
                    }
                }
            }
            ans = Math.max(ans, dp[i]);
        }
        return ans;
    }
}

复杂度分析

  • 时间复杂度:O(n)
    遍历字符串一次,每个位置的状态转移为常数时间操作,总时间复杂度为线性。
  • 空间复杂度:O(n)
    使用长度为 n 的数组 dp 存储中间状态。

个人学习总结

  1. 动态规划核心:明确状态定义(如dp[i]表示以i结尾的最优解)和状态转移方程,是解决问题的关键。不同题目需灵活调整状态含义(如最长递增子序列的dp[i]、乘积最大子数组的双状态maxDp/minDp)。
  2. 问题转化思维:部分题目需通过转化简化(如分割等和子集转化为0/1背包问题),将复杂问题映射为已知模型,降低解题难度。
  3. 复杂度平衡:时间与空间复杂度需权衡(如最长有效括号的O(n)时间O(n)空间),优先选择高效算法(如贪心+二分查找优化最长递增子序列)。
  4. 边界与细节:注意特殊情况(如负数乘积、奇数总和、空字符串),确保状态转移覆盖所有场景,避免遗漏。
  5. 实践价值:动态规划是算法学习的核心技能,通过经典题目积累经验,提升解决实际问题的能力。
相关推荐
北京地铁1号线2 小时前
2.3 相似度算法详解:Cosine Similarity 与 Euclidean Distance
算法·余弦相似度
Remember_9932 小时前
【LeetCode精选算法】滑动窗口专题一
java·数据结构·算法·leetcode·哈希算法
Lueeee.3 小时前
v4l2驱动开发
数据结构·驱动开发·b树
窗边鸟3 小时前
小白日记之java方法(java复习)
java·学习
小饼干超人3 小时前
详解向量数据库中的PQ算法(Product Quantization)
人工智能·算法·机器学习
你撅嘴真丑3 小时前
第四章 函数与递归
算法·uva
漫随流水3 小时前
leetcode回溯算法(77.组合)
数据结构·算法·leetcode·回溯算法
魔芋红茶3 小时前
Spring Security 学习笔记 4:用户/密码认证
笔记·学习·spring
玄冥剑尊4 小时前
动态规划入门
算法·动态规划·代理模式