每日算法 — 使用java实现最长递增子序列:patience sorting与二分查找优化

最长递增子序列(Longest Increasing Subsequence, LIS)是算法面试中的高频题目:给定一个整数数组,找到其中最长的严格递增子序列的长度。经典的动态规划解法是 O(n²),但通过patience sorting(耐心排序)思想结合二分查找,可以将复杂度优化到 O(n log n)。本文将用Java实现两种解法,并讲解如何还原实际的子序列路径。

一、问题定义

输入: [10, 9, 2, 5, 3, 7, 101, 18]
输出: 4
解释: 最长递增子序列是 [2, 3, 7, 101]

二、O(n²) 动态规划解法

2.1 状态定义

dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。

2.2 状态转移

对于每个位置 i,遍历所有 j < i:
– 若 nums[j] < nums[i],则 dp[i] = max(dp[i], dp[j] + 1)

2.3 Java 实现

public class LIS {

    // O(n²) 解法
    public static int lengthOfLIS_DP(int[] nums) {
        if (nums == null || nums.length == 0) return 0;

        int n = nums.length;
        int[] dp = new int[n];
        Arrays.fill(dp, 1); // 每个元素本身构成长度为1的LIS

        int maxLen = 1;

        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]) {
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            maxLen = Math.max(maxLen, dp[i]);
        }

        return maxLen;
    }
}

三、O(n log n) Patience Sorting 解法

3.1 核心思想

想象在玩纸牌游戏”耐心排序”:将数字视为纸牌,从左到右依次处理。维护若干堆(pile),规则是:
– 每张新牌放在最左边牌顶 ≥ 该牌的堆上
– 如果没有这样的堆,新建一堆放在最右边

关键性质:堆的数量等于 LIS 的长度。通过二分查找可以在 O(log n) 时间内确定每张牌放入哪个堆。

3.2 优化数组的设计

我们用一个数组 tails 记录每堆的顶部牌面,tails[i] 表示长度为 i+1 的递增子序列的最小结尾元素。

// O(n log n) 解法
public static int lengthOfLIS_BinarySearch(int[] nums) {
    if (nums == null || nums.length == 0) return 0;

    int n = nums.length;
    int[] tails = new int[n];
    int size = 0;

    for (int num : nums) {
        // 二分查找:找到 tails 中第一个 >= num 的位置
        int left = 0, right = size;

        while (left < right) {
            int mid = left + (right - left) / 2;
            if (tails[mid] < num) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }

        tails[left] = num;
        if (left == size) {
            size++; // 新建一堆
        }
    }

    return size;
}

3.3 算法执行 trace

以 [10, 9, 2, 5, 3, 7, 101, 18] 为例:

步骤 当前数字 tails 数组 操作
1 10 [10] 新建堆
2 9 [9] 替换堆顶
3 2 [2] 替换堆顶
4 5 [2, 5] 新建堆
5 3 [2, 3] 替换第2堆
6 7 [2, 3, 7] 新建堆
7 101 [2, 3, 7, 101] 新建堆
8 18 [2, 3, 7, 18] 替换第4堆

最终 size = 4,即 LIS 长度。

四、还原实际子序列路径

上述方法只求出了长度,如何还原具体的子序列?需要额外记录前驱信息和每个长度对应的结尾元素位置。

public static List<Integer> getLIS(int[] nums) {
    if (nums == null || nums.length == 0) return Collections.emptyList();

    int n = nums.length;
    int[] tails = new int[n];      // tails[i] = 长度为i+1的LIS的最小结尾元素的索引
    int[] prev = new int[n];       // prev[i] = nums[i]在LIS中的前一个元素的索引
    Arrays.fill(prev, -1);

    int size = 0;

    for (int i = 0; i < n; i++) {
        // 二分查找
        int left = 0, right = size;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (nums[tails[mid]] < nums[i]) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }

        // 记录前驱
        if (left > 0) {
            prev[i] = tails[left - 1];
        }

        tails[left] = i;
        if (left == size) {
            size++;
        }
    }

    // 从最后一个元素反向回溯
    List<Integer> lis = new ArrayList<>();
    int k = tails[size - 1];
    while (k >= 0) {
        lis.add(nums[k]);
        k = prev[k];
    }
    Collections.reverse(lis);

    return lis;
}

五、最长递减子序列与变形

通过简单修改比较符号,可以求解:
– 最长递减子序列:将 < 改为 >
– 最长非递减子序列:将 < 改为 <=
– 最长连续递增子序列:去掉二分查找,只比较相邻元素

// 最长递减子序列(通过反转比较)
public static int lengthOfLDS(int[] nums) {
    int[] tails = new int[nums.length];
    int size = 0;

    for (int num : nums) {
        int left = 0, right = size;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (tails[mid] > num) { // 注意这里是 >
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        tails[left] = num;
        if (left == size) size++;
    }

    return size;
}

六、应用场景

LIS 及其变种在多个领域有重要应用:
– 俄罗斯套娃信封问题:二维LIS
– 最长摆动子序列:交替增减
– 最大不相交区间集:按结束时间排序后求LIS
– ** patience sorting 与卡牌游戏**: piles 数量与 LIS 的对偶关系

七、复杂度总结

方法 时间复杂度 空间复杂度 能否还原路径
DP O(n²) O(n) 可以
Patience Sorting + 二分 O(n log n) O(n) 可以

八、总结

最长递增子序列问题完美展示了算法优化的经典路径:从直观的 O(n²) 动态规划,到利用二分查找将内层循环优化为对数时间,最终达到 O(n log n)。patience sorting 的视角不仅提供了优雅的实现方式,更揭示了组合数学中偏序集与Dilworth定理的深刻联系——最大反链的大小等于最小链覆盖数。