CC++ & Algorithm

最长上升子序列(LIS)——在乱序队伍中找出最长的递增队列

困难3
语言版本:C++Python
概述:从一串数字中,挑出一些数字(可以不连续)保持原顺序,使这些数字严格递增,找到最长的那个序列长度。

最长上升子序列(LIS)——如何在乱序中找出最长的递增“尾巴”

你有没有在体育课上遇到过这种情形:老师让同学们按身高从矮到高排成一队,但队伍已经乱成一团。老师不想让大家全部重新排,而是让一部分同学站出来,按照原来站在队伍里的顺序(从左到右),并且身高要严格递增。你能选出来多少个同学,让他们的身高满足要求?这个问题就是编程里经典的“最长上升子序列”(Longest Increasing Subsequence,简称 LIS)。

简单来说,LIS 就是:给你一串数字,你可以跳着选,但不能改变这些数字的原始顺序,挑出来的数字必须一个比一个大(严格递增),最后找出最长的那个序列有多长。比如数列 [3, 1, 4, 2, 5],最长上升子序列可以是 [1, 2, 5][3, 4, 5],长度都是 3。

下面我们一步步来学习如何用 Python 解决这个问题。


第一个问题:怎么想?“dp” 是什么?

动态规划(Dynamic Programming,简称 DP)是一种把大问题拆成小问题,再把小问题的答案存起来重复使用的技巧。对于 LIS,我们定义一个数组 dp,其中 dp[i] 表示 以第 i 个数字结尾的最长上升子序列的长度。比如对于 nums = [3, 1, 4, 2, 5]dp[0] 就是以数字 3 结尾的序列长度(就是它自己,长度为 1),dp[1] 是以 1 结尾的(也是 1),dp[2] 是以 4 结尾的(可以接在 3 或 1 后面,最长为 2),等等。

为什么叫“以……结尾”?

因为我们要保证子序列是递增的,所以只能把当前数字接到前面比它小的数字后面。如果不知道前面哪些数字小,就没法确定长度。所以每个位置只关心“以我结尾”的序列最长能有多长。


第二个问题:如何计算出 dp[i]?

对于每个位置 i(从 0 开始),我们都要回头看它前面的所有位置 j(从 0 到 i-1)。只要 nums[j] < nums[i](前面的数字比当前数字小),那么当前数字就可以接在它后面,形成一个新的更长的序列。这个新序列的长度就是 dp[j] + 1。我们用 max 函数在所有可能的 j 中挑出最大的那个,作为 dp[i] 的候选值。但注意,即使前面没有比它小的数字,它自己也是一个长度为 1 的序列,所以 dp[i] 至少是 1。

一个简单例子:玩身高卡片

假设我们有一排同学的身高(数字):

[2, 5, 3, 7, 101, 18]

我们记下每个同学的位置(0 到 5)。

  • 第 0 个同学身高 2,前面没人,dp[0] = 1
  • 第 1 个同学身高 5,前面有 2(比 5 小),所以可以接在后面,dp[1] = max(1, dp[0]+1=2) = 2
  • 第 2 个同学身高 3,前面有 2(比 3 小),dp[2] = max(1, dp[0]+1=2) = 2
  • 第 3 个同学身高 7,前面有 2、5、3 都比 7 小,分别能得 2、3、3,最大是 3,所以 dp[3] = 3
  • 第 4 个同学身高 101,前面有 2、5、3、7 都小,最大是 dp[3]+1=4,所以 dp[4] = 4
  • 第 5 个同学身高 18,前面比它小的有 2、5、3、7(101 太大不算),最大是 dp[3]+1=4,所以 dp[5] = 4

最后 dp 数组为 [1, 2, 2, 3, 4, 4],最大值是 4。所以最长上升子序列长度是 4(比如 [2, 5, 7, 101][2, 3, 7, 18] 等)。


第三个问题:用代码怎么写?

我们用两层循环来实现:外层循环枚举每个位置 i,内层循环枚举 i 前面的所有位置 j。代码非常清晰:

def length_of_lis(nums):
    # 如果列表为空,直接返回 0
    if not nums:
        return 0
    
    n = len(nums)                     # 数字的个数
    dp = [1] * n                      # 每个数字本身就是一个长度为1的序列,所以先全部赋值为1
    
    for i in range(1, n):             # 从第二个数字开始,因为第一个已经初始化了
        for j in range(0, i):         # 看前面所有的数字
            if nums[j] < nums[i]:     # 如果前面的数字比当前数字小,可以接在后面
                dp[i] = max(dp[i], dp[j] + 1)   # 取最大的那个
    
    return max(dp)                    # dp数组的最大值就是答案

# 测试一下
print(length_of_lis([10, 9, 2, 5, 3, 7, 101, 18]))  # 输出4

代码解释(逐行看)

  • n = len(nums):得到数字的总数。
  • dp = [1] * n:初始化时每个数字单独作为一个序列,长度为 1。
  • 外层 for i 从 1 到 n-1,因为第 0 个已经不用再判断前面的了。
  • 内层 for j 从 0 到 i-1,检查所有前面的数字。
  • if nums[j] < nums[i] 成立,说明 nums[i] 可以接在 nums[j] 后面,形成更长的序列。
  • dp[i] = max(dp[i], dp[j] + 1):取当前 dp[i] 和新的候选值中较大的,保证存储的是最大长度。
  • 最后用 max(dp) 找出所有 dp[i] 中的最大值,那就是整个序列的 LIS 长度。

常见错误(新手容易掉坑)

  1. 忘记初始化dp[0] 必须为 1,不要设成 0。因为每个数字自己就是一个长度为 1 的上升子序列。
  2. 条件写反:应该是 nums[j] < nums[i](前面的比当前小)才能接,如果写成 > 就变成递减了。
  3. 忘记取 max:只写 dp[i] = dp[j] + 1 可能会被后面更小的 j 覆盖,必须用 max 保证 dp[i] 保持最大值。
  4. 直接用变量名与内置函数冲突:比如用 max 作为变量名会导致后面无法调用内置 max()。建议用 max_len 等。
  5. 返回最后一个 dp 值:有的人以为答案就是 dp[n-1],实际上最长子序列不一定以最后一个数字结尾,所以必须返回整个 dp 数组的最大值。

完整可运行示例(加上测试和打印)

下面是一个完整的 Python 程序,你可以直接复制运行,看看结果:

def length_of_lis(nums):
    """返回列表nums的最长上升子序列长度"""
    if not nums:
        return 0
    
    n = len(nums)                     # 数字个数
    dp = [1] * n                      # 初始化每个位置至少为1
    
    for i in range(1, n):
        for j in range(0, i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    
    return max(dp)

# 测试几个例子
test1 = [3, 1, 4, 2, 5]
print(f"{test1} 的 LIS 长度是 {length_of_lis(test1)}")   # 输出3

test2 = [1, 2, 3, 4, 5]
print(f"{test2} 的 LIS 长度是 {length_of_lis(test2)}")   # 输出5

test3 = [5, 4, 3, 2, 1]
print(f"{test3} 的 LIS 长度是 {length_of_lis(test3)}")   # 输出1

test4 = []
print(f"空列表的 LIS 长度是 {length_of_lis(test4)}")     # 输出0

运行结果:

[3, 1, 4, 2, 5] 的 LIS 长度是 3
[1, 2, 3, 4, 5] 的 LIS 长度是 5
[5, 4, 3, 2, 1] 的 LIS 长度是 1
空列表的 LIS 长度是 0

进阶一点:除了长度,还能得到具体的序列吗?

当然可以!我们只需要额外记录每个位置是从哪里转移来的(比如用一个 parent 数组记录前一个位置),最后倒推回去就能得到具体的子序列。不过那是更进阶的技巧,小同学们先掌握求长度就很棒了。

另外,这个动态规划版本的时间复杂度是 O(n²)(因为两层循环),如果数字很多(比如几万个)可能会慢。更快的办法是使用“贪心 + 二分查找”,可以把复杂度降到 O(n log n)。有兴趣的同学可以去查一下“耐心排序”或者“patience sorting”算法。


相关知识点指引

  • 动态规划入门:学习斐波那契数列、爬楼梯、背包问题,这些都是 DP 的基础。
  • 最长公共子序列(LCS):和 LIS 很像,不过是在两个字符串里找共同子序列。
  • 一维 DP vs 二维 DP:LIS 只用了一维数组,很多问题需要二维甚至三维数组。
  • 二分查找:如果想学 LIS 的 O(n log n) 解法,需要先掌握二分查找的写法。

现在你可以自己试试看:用身高排队、考试成绩排名或者零食价格的变化来编一个 LIS 问题,然后用代码计算最大递增人数。动动手,你会发现自己已经能解决一个看似复杂的问题啦!

例题精讲

1单选题

给定序列 [3, 1, 2, 6, 4, 5],其最长上升子序列的长度是?

A3
B4
C5
D6
2单选题

在动态规划求解LIS(O(n²))时,状态dp[i]通常表示什么?

A以第i个元素结尾的最长上升子序列长度
B前i个元素中能构成的最长上升子序列长度
C第i个元素在最长上升子序列中的位置
D包含前i个元素的最长递增子序列个数
3判断题

在求解最长上升子序列时,使用贪心策略(每次选择能接上的最小末尾值)可以直接得到正确长度。

4填空题
以下是用动态规划(O(n²))求最长上升子序列长度的Python代码,请填写空白处。

def lengthOfLIS(nums):
    n = len(nums)
    if n == 0: return 0
    dp = [1] * n
    res = 1
    for i in range(1, n):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], ___)
        res = max(res, dp[i])
    return res
5填空题
阅读下面利用二分查找优化(O(n log n))求LIS长度的Python代码,请在空白处填入正确代码。

def lengthOfLIS(nums):
    import bisect
    d = []
    for x in nums:
        pos = bisect.bisect_left(d, x)
        if pos == len(d):
            d.append(x)
        else:
            d[pos] = x
    return ___