算法笔记——动态规划:最长递增子序列LIS、二维LIS问题

最长递增子序列LIS

LIS(Longest Increasing Subsequence)问题是一个经典的动态规划问题

LeetCode 300. 最长递增子序列
给出一个长为n的序列s,求其中最长递增子序列的长度
例如,n=6,s=172548,则长递增子序列为1258,长度为4

思路:

  1. 状态:dp[i]表示以s[i]结尾的最长递增序列的长度

这样定义dp[i],是子序列问题中,常用的dp数组定义方法

  1. 选择:对于一个字符,可以选择将其拼接到其他递增序列上
    ①对于在s[i]前面出现,且比s[i]小的字符s[j],可以把s[i]拼接到s[j]后面,(则dp[i]=dp[j]+1,1即为dp[i]所占的长度)
    ②或者选择不拼接(则dp[i]=dp[i],这可能是因为拼接到其他的s[j]的后面是更好的选择)
  2. 找状态转移方程
    数学归纳思想,假定已知dp[0]...dp[i-1],怎么求dp[i]:对于字符s[i],可以将其拼接到其他递增序列上,求其中的最大值
    转移方程dp[i] = max(dp[i] , dp[j]+1) ,尝试所有的s[j],但前提是:字符s[j]<s[i],才可拼接
    含义:以si结尾的最长递增序列长度=max[以sj结尾的最长递增序列长度]+1

实现:

ps. 如果需要输出这个最长的子序列,可以用列表father[i]记录以s[i]结尾的最长递增序列中,s[i]的上一个元素的下标位置

只求解长度的代码:

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        """求最长递增子序列"""
        maxLen = 1
        # dp[i]代表以nums[i]结尾的最长递增子序列长度
        dp = [1 for _ in range(len(nums))]  # base case
        for i in range(1, len(nums)):
            for j in range(0, i):
                if nums[j] < nums[i]:
                    dp[i] = max(dp[i], dp[j] + 1)  # 状态转移
            maxLen = max(maxLen, dp[i])  # 更新最长长度
        return maxLen

能够打印具体最长递增子序列的代码:

n = int(input())
s = list(input())
# dp[i]表示以s[i]结尾的最长递增序列的长度,初值为1
dp = [1 for _ in range(n)]
# father[i]记录以s[i]结尾的最长递增序列中,s[i]的上一个数字的下标,初值为s[i]本身
father = [idx for idx in range(n)]
maxLen=0

for i in range(n):
    # 对于所有比s[i]小的s[j],考虑是否要把s[i]拼接到s[j]后面
    for j in range(i):
        if s[j]<s[i]:

            if dp[j]+1>dp[i]:
                # 有更好的选择
                dp[i]=dp[j]+1
                father[i]=j
            else:
                #保持原来的选择,dp[i]=dp[i]
                pass
            #若不关心最长序列的内容,上面的代码可直接写作dp[i] = max(dp[j]+1,dp[i])
    # 更新最大长度
    if dp[i]>maxLen:
        maxLen = dp[i] 
print('maxLen=',maxLen)

ans_seq=[]
while True:
    ans_seq.append(s[i])
    if i==father[i]:
        #上一个元素是它本身,则结束
        break
    i=father[i]
    
#反向读入,正向输出
ans_seq=ans_seq[::-1]
print('seq= ',ans_seq)
=================== RESTART: C:\Users\13272\Desktop\最长递增子序列.py ==================
6
172548
maxLen= 4
seq=  ['1', '2', '5', '8']
=================== RESTART: C:\Users\13272\Desktop\最长递增子序列.py ==================
# 此程序也适用于字符串
6
efabcd
maxLen= 4
seq=  ['a', 'b', 'c', 'd']

二分查找优化效率

上面方法的性能问题在于每次需要O(n)复杂度扫描字符s[i]之前小于它的哪些s[k]字符,联想到有序数组用二分查找提高查找效率的特点,我们可以改变dp的含义

如果不关心序列的内容,只需要求最长递增子序列的长度,
那么,另外一种思路是:

  • dp[i]表示目前长度为i的最长递增子序列的末尾字符(最终seq的长度就等于最长递增子序列的长度)
  • 我们从前往后遍历所有字符,尽可能让每个dp[i]中存储的字符尽量小,后面的字符更多机会能拼接元素到dp尾部,就有更大可能组成更长的递增子序列(类似贪心的思想)

注意,这里可能会把s序列中后面的元素放到seq序列的中部,所以最终seq的内容不一定是那个最长递增子序列

实现:

  • 从前往后遍历每个s[i],查看s[i]和dp[-1]的关系:
    如果s[i]>dp[-1],那么将s[i]添加到seq的末尾
    如果s[i]<=dp[-1],那么s[i]替换掉seq序列中第一个大于或等于s[i]的元素(ps.由于dp单调递增,此处可用二分查找提高效率)
    (这里不是“大于”而是“大于等于”,因为要保证序列严格单调递增)

ps. 若还需要输出这个最长递增子序列,可以另外用seqLen[i]记录:将s[i]添加到seq的末尾/替换seq中部的某个元素时,以s[i]结尾的递增子序列的长度;
最后i从[最长子序列长度]到1遍历,从后往前找出seqLen中第一个seqLen[idx]==i的下标值idx,并入队ans,最终逆序输出ans即可

只求解长度的代码:

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        """求最长递增子序列"""
        def left_bound(ch):
            """在dp中二分查找第一个大于等于ch的元素(一定存在)"""
            l, r = 0, len(dp) - 1  # 闭区间
            while l <= r:
                mid = (l + r) // 2
                if dp[mid] == ch:
                    r = mid - 1
                elif dp[mid] < ch:
                    l = mid + 1
                elif dp[mid] > ch:
                    r = mid - 1
            return l
        dp = []  # dp[i]表示目前长度为i的最长递增子序列的末尾字符
        for num in nums:
            if len(dp) == 0 or dp[-1] < num:
                # 拼接到末尾
                dp.append(num)
            else:
                idx = left_bound(num)
                dp[idx] = num
        return len(dp)

能够打印具体最长递增子序列的代码:

def lower_bound(arr, target,low, high):
    """传入非递减序列arr,返回arr中第一个>=target的值的下标
        其中,搜索范围为[low, high(包含)],若找不到返回-1"""
    pos = -1
    while low<high:
        mid = (low+high)//2
        if arr[mid] < target:
            low = mid+1
        else:#>=
            high = mid
            #pos = high
    if arr[low]>=target:
        pos = low
    return pos

n = int(input())
s = list(map(int,input().split()))

seq = [s[0]]# 初始化
length=1

#若不输出最长递增子序列的内容,可不使用seqLen和ans

#将s[i]添加到seq的末尾/替换seq中部的某个元素时,以s[i]结尾的递增子序列的长度
#每个s[i]都对应一个seqLen值
seqLen=[1]#一开始s[0]对应的序列长度为1
ans=[]#记录最长序列的内容

for i in range(1,n):
    # s[0]已经初始化到seq中,从s[1]开始
    # 遍历每个s[i],查看s[i]和seq[-1]的关系

    if s[i]>seq[-1]:
        length+=1
        seq.append(s[i])
        seqLen.append(length)#当前的上升序列的长度是length

    else:# s[i]<=seq[-1]:
        #二分查找第一个大于或等于s[i]的元素,并替换
        #这里不是“大于”而是“大于等于”,因为要防止seq中出现相同的元素
        last_idx=lower_bound(seq,s[i],0, len(seq)-1)
        seq[last_idx]=s[i]#last_idx可能是seq中的任何位置
        seqLen.append(last_idx+1)#当前的上升序列的长度就是pos+1

# 最大长度就是seq序列的长度
print(length)

length_countDown=length
for i in range(len(seqLen)-1,-1,-1):
    if length_countDown<=0:
        break
    if seqLen[i]==length_countDown:
        ans.append(s[i])
        length_countDown-=1
        
# 逆序输出这个最长递增序列
ans=ans[::-1]
print(ans)
=============== RESTART: C:\Users\13272\Desktop\二分查找 - 副本 - 副本.py ==============
5
5 2 3 3 4
3
[2, 3, 4]

变种:二维LIS问题

LeetCode 354. 俄罗斯套娃信封问题
给出一堆信封的长、宽二元组,一个信封若长、宽都大于另一信封,就可被装入,求最多可以“套娃”装入多少信封

预处理并转化问题:最终需要求长、宽都单调递增的最长子序列,是二维LIS问题,可以先将长度递增排列,再求出宽度的最长递增子序列即可(转化为一维LIS问题)
注意细节:多个长度相同的信封,只能选一个,因此排序时首先按照长升序,长度相同应该按宽度降序排列,保证长度相同的信封中只有一个会被选上(至于选哪个,留给一维LIS解决,它会找出能构成最长递增子序列的答案)

class Solution:
    def maxEnvelopes(self, envelopes: List[List[int]]) -> int:
        """二维最长递增子序列LIS问题"""
        # 首先按照长升序,长度相同应该按宽度降序排列
        arr = sorted(envelopes, key=lambda x: (x[0], -x[1]))
        h = [weightHeight[1] for weightHeight in arr]  # 取出宽度

        def lengthOfLIS(nums) -> int:
            """求一维LIS"""
            ...

        # 套用一维LIS解法即可
        ans = lengthOfLIS(h)
        return ans

对于三维的LIS,即箱子的“套娃”问题,就不能再按照这种思路(先按照前两个维度排序,第三维求LIS),这类问题叫“偏序问题”,需要借助树状数组解决

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值