leetcode_day9_筑基期_《绝境求生》

28 阅读8分钟

目录


前言

碎碎念:不要问,猜到了,不要说。

本系列《绝境求生》记录转码算法筑基过程,以代码随想录为纲学习,leetcode_hot_100练手,在此记录思考过程,方便过后复现。内容比较粗糙仅便于笔者厘清思路,复盘总结。

刷过的还得再刷3遍 不然白干


提示:以下是本篇文章正文内容

动态规划

将复杂的问题拆解成若干个重叠子问题,通过求解子问题的最优解,逐步推导出原问题的解。可以通过 记忆化 处理避免重复计算子问题来提升效率

状态转移方程,说白了就是找规律,高级一点说法 就是给混沌以秩序。 369 12, dp(n) = dp(n - 1) + 3 。

实战:

把原问题转化为dp问题
状态定义

一、416分割等和子集

1、题目描述

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

  1. 每个元素必须恰好属于其中一个子集(不能不选,也不能重复选);
  2. 子集不要求连续,只需元素和相等。

示例

  • 示例 1:输入 nums = [1,5,11,5] → 输出 True

    • 解释:数组和为 1+5+11+5=22,可分割为 [1,5,5](和 11)和 [11](和 11),满足条件。
  • 示例 2:输入 nums = [1,2,3,5] → 输出 False

    • 解释:数组和为 1+2+3+5=11,是奇数,无法分割为两个和相等的子集;即使和为偶数,也需验证是否能凑出目标和。

提示

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

2、简单理解?

两个子集的元素和相等,为数组总和的一半

问题转化为target=sum(nums)//2 看能否从nums中选出若干元素使得和为target。

!!每个元素恰好属于其中一个子集,不能不选,也不能重复选

子集不要求连续。

不适合用双指针,因为元素和没有单调的规律

3、暴力法

暴力法一般都会超时,这题暴力法用递归难想的一批是默写出来的,根本想不到

3.1、能不能用图示意?

nums = [1,5,11,5],target=11 为例     枚举选/不选的状态

递归树(dfs(index, remain):index为当前遍历索引,remain为剩余目标和):
dfs(0,11) → 选1(remain=10)/ 不选1(remain=11)
  ├─ dfs(1,10) → 选5(remain=5)/ 不选5(remain=10)
  │   ├─ dfs(2,5) → 选11(remain=-6,无效)/ 不选11(remain=5)
  │   │   └─ dfs(3,5) → 选5(remain=0,返回True)/ 不选5(remain=5,返回False)
  │   └─ ...(不选5的分支最终返回False)
  └─ ...(不选1的分支也能找到True)
最终返回True

3.2、初始化条件?

记忆缓存字典 key=(index, remain),value=true/false状态

3.3、边界条件?

判断数组和是否为奇数,是返回False

如果某个单一元素直接大于数组和的一半,直接返回False

3.4、代码逻辑?

问题转化为target=sum(nums)//2 看能否从nums中选出若干元素使得和为target

选当前元素,剩余目标-=当前元素,递归下一个

不选,剩余目标和不变,递归下一个

终止条件。当剩余目标和为0。返回true,如果遍历完所有元素没凑出和为0 返回false

3.5、之前见过但没注意到的?

选和不选这两个状态怎么用代码写出来? 就像打家劫舍一样偷喝不偷

3.6、疑惑点/新知识 ?

递归函数dfs就想到终止条件

3.7、python 代码

from typing import List

class Solution:
    def canPartition(self, nums: List[int]) -> int:
        total = sum(nums)
        # 前置判断1:总和为奇数,直接返回False
        if total % 2 != 0:
            return False
        target = total // 2
        # 前置判断2:存在元素大于target,直接返回False
        if max(nums) > target:
            return False
        
        # 记忆化缓存:key=(index, remain),value=该状态的结果(True/False)
        memo = {}
        
        # 定义递归函数:遍历到index位置,剩余目标和为remain,返回能否凑出
        def dfs(index, remain):
            # 终止条件1:剩余目标和为0,凑出,返回True
            if remain == 0:
                return True
            # 终止条件2:遍历完所有元素 或 剩余目标和<0,未凑出,返回False
            if index == len(nums) or remain < 0:
                return False
            # 查记忆化缓存,已有结果直接返回
            if (index, remain) in memo:
                return memo[(index, remain)]
            
            # 递归分支1:选当前元素,剩余目标和减少nums[index]
            choose = dfs(index + 1, remain - nums[index])
            # 递归分支2:不选当前元素,剩余目标和不变
            not_choose = dfs(index + 1, remain)
            
            # 两种分支有一个为True,当前状态就为True
            res = choose or not_choose
            # 存入缓存,供后续复用
            memo[(index, remain)] = res
            return res
        
        # 从索引0、剩余目标和target开始递归
        return dfs(0, target)

4、优化法

 4.1、能不能用图示意?

nums = [1,5,11,5],target=11 为例

# 初始化dp数组(长度12,索引0~11)
dp = [True, False, False, False, False, False, False, False, False, False, False, False]

# 遍历第一个元素num=1:
逆序遍历j=111j=1: dp[1] = dp[1] or dp[1-1] = False or TrueTrue
dp变为:[T, T, F, F, F, F, F, F, F, F, F, F]

# 遍历第二个元素num=5:
逆序遍历j=115j=5: dp[5] = F or dp[0] → T
j=6: dp[6] = F or dp[1] → T
dp变为:[T, T, F, F, F, T, T, F, F, F, F, F]

# 遍历第三个元素num=11:
逆序遍历j=1111j=11: dp[11] = F or dp[0] → T
dp变为:[T, T, F, F, F, T, T, F, F, F, F, T](已找到True,后续可提前终止)

# 遍历第四个元素num=5(无需遍历,结果已确定)
最终dp[11] = True

4.2、初始化条件?

dp[0] 。空子集的时候必满足

4.3、边界条件?

奇数情况,还有某个元素已经大于target的情况

4.4、代码逻辑?

dp[j ]  j 代表的是nums中的元素,从数组里挑元素,dp[ j ]表示能否凑出 j 的子集  

为什么有时候理解不了? dp数组是从dp[0]推导出来的,先看dp[1],dp[2] ,dp[ 3 ]

j in range(target, num-1,-1) 逆序遍历速度更快,我们的目的是更新dp数组的状态,所以怎么方便推出dp[1],dp[2] .......且不重复 就可以用逆序。 

状态转移

  • dp[j] = dp[j] or dp[j - num]

    • dp[j]:不选当前 num,保持原有状态;
    • dp[j - num]:选当前 num,若 j-num 能凑出,则 j 也能凑出。
  • 外层循环 for num in nums:遍历每个元素,每个元素只能处理一次(0-1 背包特性);

  • 内层循环 range(target, num-1, -1)

    • 逆序遍历是关键!正序遍历会导致同一元素被多次选中(比如 num=1,j=1 更新为 True 后,j=2 会用 j=1 的结果,相当于选了两次 1);
    • 遍历下限是num:j < num 时,j-num 为负(看状态转移方程有dp[j-num]),无意义,无需遍历。

4.5、之前见过但没注意到的?

1、首先要清楚状态定义,即状态方程的索引还有值对应啥不然会乱

2、dp方程,你就想前一个是啥 依赖dp[i-1]还是dp[i-2],还是都依赖,还是运算法的依赖,然后想初始化

2、把示意图画出来  

4.6、疑惑点/新知识 ?

分割等和子集即为0-1背包问题,就是把分割数组转化为凑目标和背包问题,。需要区分0-1背包元素选一次,还有无限背包元素取多次的遍历顺序差异

牢记!!! 当前状态是基于之前的状态做决策得到的结果

4.7、python 代码

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        total = sum(nums)
        # 前置判断1:总和为奇数,无法分割
        if total % 2 != 0:
            return False
        target = total // 2
        # 前置判断2:存在元素大于目标和,无法分割
        if max(nums) > target:
            return False
        
        # 步骤1:初始化dp数组
        # dp[j]表示能否凑出和为j的子集,长度=target+1(索引0~target)
        # 边界条件:dp[0] = True(和为0的空集存在),其余初始为False
        dp = [False] * (target + 1)
        dp[0] = True
        
        # 步骤2:遍历每个元素(0-1背包,每个元素只能选一次)
        for num in nums:
            # 步骤3:逆序遍历j(从target到num),避免重复选同一元素
            # 若正序遍历,会导致同一元素被多次选中(比如num=1,j=1更新后,j=2会复用j=1的结果,相当于选了两次1)
            for j in range(target, num - 1, -1):
                # 状态转移:不选num(dp[j]) 或 选num(dp[j-num])
                dp[j] = dp[j] or dp[j - num]
                # 提前终止:若已找到能凑出target的情况,直接返回True
                if dp[target]:
                    return True
        
        # 步骤4:返回最终结果
        return dp[target]


顶级命格的强大之处在于**“非线性的爆发力”**