数据结构算法学习: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. 实践价值:动态规划是算法学习的核心技能,通过经典题目积累经验,提升解决实际问题的能力。
相关推荐
vortex59 分钟前
几种 dump hash 方式对比分析
算法·哈希算法
2501_9418649621 分钟前
科学方法论破解学习时间堆砌误区
学习
堕27424 分钟前
java数据结构当中的《排序》(一 )
java·数据结构·排序算法
2302_813806221 小时前
【嵌入式修炼:数据结构篇】——数据结构总结
数据结构
Wei&Yan1 小时前
数据结构——顺序表(静/动态代码实现)
数据结构·c++·算法·visual studio code
1024小神1 小时前
SVG标签中path路径参数学习
学习
浅念-2 小时前
C++入门(2)
开发语言·c++·经验分享·笔记·学习
ZH15455891312 小时前
Flutter for OpenHarmony Python学习助手实战:面向对象编程实战的实现
python·学习·flutter
团子的二进制世界2 小时前
G1垃圾收集器是如何工作的?
java·jvm·算法
简佐义的博客2 小时前
生信入门进阶指南:学习顶级实验室多组学整合方案,构建肾脏细胞空间分子图谱
人工智能·学习