封面解释:你看那一口口剑,像不像一个个子序列【狗头】
一。前置条件
阅读本文之前,建议先看一下上一篇文章:
基于二分查找的动态规划 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