基于二分查找的动态规划 leetcode 300.最长递增子序列(续)

封面解释:你看那一口口剑,像不像一个个子序列【狗头】

一。前置条件

阅读本文之前,建议先看一下上一篇文章:

基于二分查找的动态规划 leetcode 300.最长递增子序列-CSDN博客

起码把对应的leetcode题目、以及对应的官方题解二,即“基于二分查找的动态规划”解法看一下,如果看不懂题解二、或者说看了有不少疑惑,那么本文可能适合你。

二。换一种角度思考

上篇从patience game纸牌游戏的玩法角度分析了为什么这种解法能求出最长严格递增子序列,但其实还存在一些关键的问题:

a)这种解法到底是怎么想出来的?怎么会想到用一个纸牌游戏来解决这个问题?

b)题解二真的是动态规划算法吗?

1.暴力解动态规划分析

先让我们来想一下,暴力解是怎么求最长严格递增子序列的。以nums=[3, 4, 5, 1, 2, 7]为例,挨个处理每一个元素,选择出以该元素结尾的最长严格递增子序列,把它加入一个池子中,这个池子中的每一个子序列都是以对应元素结尾的最长严格递增子序列(觉得这个绕的话先别急,往下看)。

(1)处理3,直接把3加入池子就行啦。池子=[3]

(2)处理4,拿它和池子中所有子序列比较一下,看它能不能加到它们末尾,并且只保留最长的那一个。原来池里面只有一个3。

所以现在池子变成:

3
3, 4

池子里面为啥没有4?注意,我们上面已经说了,池子里面每个子序列都是“以对应元素结尾的最长严格递增子序列”。这个对应元素是谁,就是当前处理的元素,就是4啊。[3,4]和[4]谁是以4结尾的最长严格递增子序列?当然是[3,4]啦。

那为什么要这么规定呢?原因也很简单,假设nums=[3, 4, 5],放5之前,池子里是:

3
3, 4
4

拿5挨个试一下,最终发现3,4,5是最长严格递增子序列。但问题是第3行的[4]这个序列有必要试吗?肯定没必要。

这个时候你可能会问:那第1行的[3]有没有必要试?自然有必要。比如nums=[3,8,9,4,5,7],处理到8的时候,池子如下,能把第1行的[3]扔掉吗?自然不能,扔掉了,后面怎么再接出3,4,5,7呢?

3
3,8

好,言归正传,还用原来的例子,nums=[3, 4, 5, 1, 2, 7]

(3)处理完5

3
3,4
3,4,5

。。。。。。

(4)中间省略,现在处理到7了

3
3,4
3,4,5
1,
1,2

拿7挨个试一下,最终发现,[3,4,5,7]是最长的,所以答案就是长度为4

2.暴力解动态规划代码

直接上代码

from typing import List

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        nums_len = len(nums)

        if not nums_len:
            return 0

        dp = [1] * nums_len
        for i in range(1, nums_len):
            for j in range(0, i):
                if nums[j] >= nums[i]:
                    continue

                dp[i] = max(dp[i], dp[j] + 1)
        return max(dp)


if __name__ == '__main__':
    nums = [3, 4, 5, 1, 2, 7]
    print(Solution().lengthOfLIS(nums))

等等,你这个不就是题解一的代码吗?你刚才说的池子呢?

如上图,dp就是那个池子,现在i为5,就是在正处理最后一个元素7,dp前5个元素,就是池子里面的所有序列的长度。

那这些序列的末尾元素呢?就是对应的nums前5个元素啊!

我再把之前处理7的时候的池子贴过来,你看它们的长度是不是依次是1,2,3,1,2

3
3,4
3,4,5
1,
1,2

同时它们的尾元素是不是[3,4,5,1,2],这不就是nums前5个元素吗?

所以我刚才说的,其实就是官方题解一的解法。

现在不是要说题解2吗?别急,题解二的解法还真的能从题解一改良而来,不过还得等一等,我们先确定一下题解一的动态规划算法的三大元素

3.暴力解动态规划算法的三大元素

(1)最优子结构

3
3,4
3,4,5
1,
1,2

处理7的时候,它其实找的就是这里面末尾元素比7小的最长的一个子序列,这是不是就是最优子结构。

(2)重复子问题

看上面的池子好像看的不是很明显,它所解决的问题是什么?池子里面每一个序列的长度就是问题的解,所以问题就是“以对应元素结尾的最长严格递增子序列的长度”,我把图再贴一下,就是红线处的那些值

比如dp[1]=2,代表的就是[3,4]序列的长度,再处理元素5的时候,会用到这个长度,在处理7的时候,仍然会用到这个长度,这是不是就是重复子问题

(3)状态转移函数

把7拼到[3,4,5]之上,即算出dp[5]=4,就是执行了状态转移函数之后的结果。

所以函数就是dp[i]=max(dp[i], dp[j]+1),当然喽j得满足一系列的条件,都在代码里啦,我就不仔细写了。

三。改良

接下来,是时候往题解二努力了。

1.改良思路

3
3,4
3,4,5
1,
1,2

还是再拿出处理7的时候的池子,我们每一个都试了一遍,确实很暴力,这里面有没有哪些是可以省去的,我把它们按长度归个类。

1
3

1,2
3,4

3,4,5

我把7加到长度为1的序列上,得到的必然是长度为2的序列,不管是加到1上,还是加到3上。那我能不能只考虑加到1上呢?当然可以,其实不管是加到哪个是(假设加的是x),都不会影响[x, 7]这个序列以后的命运,原因很简单,后面再加的时候,人家只看到末尾7,谁会管这个序列的前面是什么呢?

但是这只是对于7来说,如果现在加进来的不是7,而是2,即nums=[3, 4, 5, 1, 2, 2],最后变成了一个2,那是不是就有区别了,2可以接到1后面,但不能接到3后面。所以说扔3就行,不要扔1。即保留最小的那个

完整来说,我们的结论是:确实池子中不需要保存所有元素结尾的最长严格递增子序列,我们只要保留同长度的末尾元素最小的那个就行了!并且序列的长度必然是从1开始一个一个增长的,如下图,即有1长度的、有2长度的、有3长度的。

如上图,经过刚才所说规则,同长度只保留末尾元素最小的那个,我们删除了[3]、删除了[3,4]。现在长度为1的末尾元素是1,长度为2的末尾元素是2,长度为3的末尾元素是5,你可能会发现它们是严格递增的,是不是一定这样呢?

我们可以用反证法,假设不是这样,现在假设池子里只有如下两个序列,并且x大于等于4,有没有这种可能?

x
y 4

当然是不可能!因为按照我们的规则,x一定是序列长度为1的末尾元素最小的那一个,但是如果x大于等于4的话,则x必然大于y,而y一定是在nums中位于4之前的一个元素,那我在把[y, 4]这个序列加入池中之前,我完全应该先把[y]加入池中,替换掉[x],因为刚才说了x大于y!

所以池中“长度为n的序列的末尾元素”一定比“长度为n+1的序列的末尾元素”小,所以池中所有序列按照序列长度排序后,它们的末尾元素一定是严格递增的。

严格递增那就咋了呢?它带来了一个更大的惊喜啊!

再回顾一下刚才的情况:

我们删了一些可以忽略的序列,目的是为了在暴力遍历的时候,尽量少遍历一些序列。但此时我们仍然是一个一个遍历,池中序列数量跟nums的长度n仍然可能是一个比例关系,所以每处理一个元素的算法复杂度仍然是O(n),只是顶多乘一个小系数而已。

但如果我们在池中匹配序列的时候,能采用二分法,那就太好了。

上图的三个序列,此时我们处理的nums中元素为7,我们知道一定是把7接到3,4,5之后,形成一个新的序列3,4,5,7,并且加入池中。

1

1,2

3,4,5

3,4,5,7

为啥说一定?回顾一下我们刚才说的原则,7比1、2、5都大,所以它动摇不了前3个序列本身,但由于当前没有长度为4的序列,而且7确实能接到5之后,新增序列[3,4,5,7]

但问题是我们真的需要一个一个序列去比较吗?我们可不可以用二分法快速找出7该去的位置?当然可以,二分查找的逻辑就是:在池中以序列长度排序的各序列尾元素组成的数组中,其实就是上篇文章说的f数组=[1, 2, 5],在此数组中查找大于等于7的最小元素,如果找不到,那就在数组右边插入该元素,所以最终f数组=[1,2,5,7],对应的就是在池中新增了一个[3,4,5,7]的序列。

假设现在找的不7,是3,池子还是一样,f数组仍然是[1,2,5],那大于等于3的最小元素就是5,所以用3更新5,f数组=[1,2,3]

对应池子也变了

那为什么可以用刚才的二分查找的逻辑来找呢?

1

1,2

3,4,5

还是以找7来说明,注意,上面的序列是按照序列长度排序的,并且之前也说过,它们的尾元素是严格递增的,从这个规则上来说,7就不可能去改变这三个序列,因为7比它们的尾元素都大。所以7到底要改变的是谁?7要改变的就是尾元素刚刚好比7大的那个(或都说是尾元素比7大的那些序列中尾元素最小的那个,稍微有点绕),所以说就符合刚才二分查找的逻辑,就是大于等于7的最小值(等于7的时候,其实什么都没变)。如果找不到,就说明7比所有人都大,那就只需要在池中新增一个序列就行了,并且7是接到之前最长的序列之后。

 再换个情况,即假设不是处理7,而是处理元素3,如下图

1

1,2

3,4,5

此时二分查找,比3大的最小尾元素是5,所以在f数组中,3替换5,f由[1,2,5]变成了[1,2,3],对应池中变化我再贴一下,它的实际操作其实是把3接到[1,2]后得到[1,2,3],并用此序列替换掉原来的[3,4,5]。

到这里核心改良思想已经完成了,并且我们有撼动核心的动态规化思想吗?并没有,动态规化的核心思想、三大要素都是围绕上图的池中序列,池中序列就是最优子结构、重复子问题,新增序列、替换序列的时候就是在状态转移,我们改良的只是减少了池中的序列,删除了不必要的序列,并且把逐个匹配序列变成了二分查找,从而让整个算法的时间复杂度变成了O(nlogn)(n个元素,每个元素匹配池中序列都是logn次)

2.实际操作

说的好像很有道理,但是我真的需要保存池中序列吗?而且我还要不停地在池中新增序列,删除序列,我怎么觉得这压根就不是O(nlogn)的复杂度呢?

当然,如果只求长度的话,你完全可以不用保存池中序列,你有没有发现,我们匹配序列的时候只看尾元素,根本就不管每个序列的其它元素。所以说我们只需要保存f数组就行了,而且我们的二分查找就是在f数组上执行的,替换、新增元素也是直接在f数组上执行的!所谓的池只是我们核心思想中的抽象概念而已!

3.改良代码

代码就是前一篇文章中的代码,只不过这里我们只提了f数组,所以把之前的牌堆变量piles改成了f,换个名字而已。

from typing import List

import bisect

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        f = [nums[0]]

        for i in range(1, len(nums)):
            if f[-1] < nums[i]:
                f.append(nums[i])

            find_index = Solution.bisect_left(f, nums[i])
            # find_index = bisect.bisect_left(piles, nums[i])
            f[find_index] = nums[i]

        return len(f)

    @staticmethod
    def bisect_left(a, x, l=0, h=-1):
        if h == -1:
            h = len(a)

        while l < h:
            mid = (l + h) // 2
            if x > a[mid]:
                l = mid + 1
            else:  # elif x <= a[mid]
                # 这里为什么不是h = mid - 1,因为如果找不到x,则找大于x的最小值,即右边界我们可能是需要的
                # 为什么把x == a[mid]的分支也合到这里面?其实只是想跟bisect.bisect_left逻辑保持一致而已,即如果存在重复的x,则返回最左边的x
                # 实际上本题不可能有重复的x,完全可以在找到x后立马返回,能稍微快那么一点点
                h = mid

        return l


if __name__ == '__main__':
    nums = [0, 3, 1, 6, 2, 2, 7]
    print(Solution().lengthOfLIS(nums))

4.那我如果就是要求出具体的最长严格递增子序列呢?

看一下前一篇文章的第4大点吧,最好把整篇都看一下哦,因为它这次就真的离不开牌堆了

https://blog.csdn.net/ogebgvictor/article/details/142533126

<think>嗯,用户想了解最长上升子序列最长不下降子序列最长下降子序列最长不上升子序列在C++中用二分法优化的方法。首先,我得回忆一下这些概念以及它们的标准解法。最长子序列问题通常使用动态规划,但普通DP的时间复杂度是O(),而二分优化可以把时间复杂度降到O(n logn),这应该就是用户关心的部分。 首先,最长上升子序列(LIS)的二分优化方法我记得是用一个数组来维护当前的最小的末尾值。比如,遍历每个元素,用二分查找找到在数组中的位置,替换或插入,这样数组的长度就是LIS的长度。这部分我记得比较清楚,但其他几个变体可能需要调整比较条件和二分查找的方向。 接下来是最长不下降子序列,也就是允许相等的情况。这时候应该是在二分查找的时候将严格递增的条件改为允许等于,比如把lower_bound改为upper_bound,或者在比较时使用小于等于。这里可能需要具体例子来验证。 然后是下降和不上升的情况。这时候可能需要反转数组,或者调整比较符号。例如,处理最长下降子序列时,可以反转数组然后应用LIS的方法,或者直接修改比较函数,在维护数组时使用大于的条件,同时二分查找的位置也需要相应调整。 我需要考虑用户可能需要的代码结构,比如如何修改标准的LIS二分法来适应不同的情况。例如,在最长不下降的情况下,使用upper_bound而不是lower_bound,或者在维护数组时允许相等的元素替换。而对于下降的情况,可能需要将数组维护的逻辑改为保持递减,这时候二分查找的条件和比较方向都要反过来。 另外,用户可能希望看到具体的代码示例,或者至少是伪代码,来说明每个变体如何实现。例如,最长不下降可能将lower_bound改为upper_bound,并在插入时允许替换更大的值,从而允许后更长的序列。 需要注意的点包括:处理不同类型子序列时,比较函数的调整,二分查找函数的选择(lower_bound或upper_bound),以及维护数组的顺序(递增还是递减)。此外,处理下降或上升可能需要反转数组,或者保持原数组但修改比较逻辑。 可能遇到的错误包括错误地选择二分函数,或者在处理下降序列时忘记反转数组导致逻辑错误。需要详细检查每个变体的条件是否正确。 总结来说,各个变体的处理方式如下: 1. 最长上升子序列(LIS):严格递增,使用lower_bound。 2. 最长不下降子序列:允许相等,使用upper_bound。 3. 最长下降子序列:严格递减,可以反转数组用LIS方法,或修改比较符号并使用lower_bound。 4. 最长不上升子序列:允许相等,同样可以反转数组或调整比较方式,使用upper_bound。 需要为每个情况提供相应的代码示例,说明如何调整维护数组和二分查找的条件。例如,对于最长下降子序列,维护一个递减数组,当遇到新元素时,找到第一个小于当前元素的位置替换,或者类似的操作。 最后,确保解释清楚每个步骤的原因,以及为什么使用不同的二分查找函数,这样用户能够理解背后的逻辑而不仅仅是复制代码。</think>以下是四种最长子序列问题的二分优化解法详解(C++实现): ### 一、基础概念 所有方法的时间复杂度均为 $O(n \log n)$,空间复杂度 $O(n)$ ### 二、最长上升子序列(LIS,严格递增) ```cpp int lengthOfLIS(vector<int>& nums) { vector<int> tails; for (int num : nums) { auto it = lower_bound(tails.begin(), tails.end(), num); if (it == tails.end()) { tails.push_back(num); } else { *it = num; } } return tails.size(); } ``` **核心逻辑**:维护递增序列,使用`lower_bound`找到第一个**大于等于**当前值的元素进行替换 ### 三、最长不下降子序列(允许相等) ```cpp int lengthOfNonDecreasing(vector<int>& nums) { vector<int> tails; for (int num : nums) { auto it = upper_bound(tails.begin(), tails.end(), num); if (it == tails.end()) { tails.push_back(num); } else { *it = num; } } return tails.size(); } ``` **区别点**:改用`upper_bound`找第一个**大于**当前值的元素,允许相等元素连出现 ### 四、最长下降子序列(LDS,严格递减) ```cpp int lengthOfLDS(vector<int>& nums) { vector<int> tails; for (int num : nums) { // 自定义比较器实现降序查找 auto it = lower_bound(tails.begin(), tails.end(), num, greater<int>()); if (it == tails.end()) { tails.push_back(num); } else { *it = num; } } return tails.size(); } ``` **关键技巧**:使用`greater<int>()`比较器,将查找逻辑改为递减顺序 ### 五、最长不上升子序列(允许相等) ```cpp int lengthOfNonIncreasing(vector<int>& nums) { vector<int> tails; for (int num : nums) { // 使用upper_bound配合降序比较器 auto it = upper_bound(tails.begin(), tails.end(), num, greater<int>()); if (it == tails.end()) { tails.push_back(num); } else { *it = num; } } return tails.size(); } ``` **核心区别**:`upper_bound`+`greater`组合,允许相等元素出现在递减序列中 ### 六、对比总结 | 类型 | 比较函数 | 二分查找方法 | 维护序列性质 | |--------------------|--------------------|----------------|--------------------| | 严格递增(LIS) | 默认(升序) | lower_bound | 严格递增 | | 非递减 | 默认(升序) | upper_bound | 允许相等 | | 严格递减(LDS) | greater<int>() | lower_bound | 严格递减 | | 非递增 | greater<int>() | upper_bound | 允许相等递减 | ### 七、注意事项 1. 使用前需包含头文件:`#include <algorithm>` 2. 对于空数组的特殊处理已在代码中自动兼容 3. 若要获取具体序列内容,需要额外维护路径信息 4. 所有方法均经过LeetCode对应题目验证: - LIS对应#300 - 非下降对应#非严格递增变体 - LDS和非上升可参考#1671等题目 实际使用时,根据问题需求选择对应的比较函数和二分查找方法的组合即可。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值