每日算法 — 使用java实现LIS:动态规划与二分优化求最长递增子序列

引言

最长递增子序列(Longest Increasing Subsequence,LIS)是算法竞赛和面试中的经典问题。给定一个整数序列,找出其中最长的严格递增子序列的长度。本文将用Java实现两种解法:基础动态规划(O(n²))和基于二分查找的贪心优化(O(n log n)),帮助读者深入理解状态转移与单调性优化技巧。

问题定义

输入:一个长度为 n 的整数数组 nums。
输出:nums 的最长严格递增子序列的长度。

示例:
– 输入:nums = [10, 9, 2, 5, 3, 7, 101, 18]
– 输出:4
– 解释:最长递增子序列为 [2, 3, 7, 101]

方法一:动态规划(O(n²))

核心思想

设 dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。对于每个位置 i,遍历所有前面的位置 j(j < i),如果 nums[j] < nums[i],则 dp[i] 可以从 dp[j] 转移而来。

状态转移方程:

dp[i] = max(dp[j] + 1)  对于所有 j < i 且 nums[j] < nums[i]

初始状态:dp[i] = 1(每个元素本身构成长度为1的递增子序列)

Java实现

public class LIS_DynamicProgramming {

    /**
     * 动态规划解法,时间复杂度 O(n²),空间复杂度 O(n)
     * @param nums 输入数组
     * @return 最长递增子序列长度
     */
    public static int lengthOfLIS_DP(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }

        int n = nums.length;
        // dp[i] 表示以 nums[i] 结尾的最长递增子序列长度
        int[] dp = new int[n];

        // 初始化:每个元素自身构成长度为1的子序列
        for (int i = 0; i < n; i++) {
            dp[i] = 1;
        }

        int maxLength = 1;

        // 遍历每个位置作为子序列结尾
        for (int i = 1; i < n; i++) {
            // 检查所有前面的位置,寻找可以接在后面的递增序列
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]) {
                    // 如果 nums[j] < nums[i],则可以将 nums[i] 接到以 nums[j] 结尾的序列后面
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            // 更新全局最大值
            maxLength = Math.max(maxLength, dp[i]);
        }

        return maxLength;
    }

    /**
     * 同时返回具体的LIS序列(用于调试和理解)
     */
    public static int[] getLIS_Sequence(int[] nums) {
        if (nums == null || nums.length == 0) {
            return new int[0];
        }

        int n = nums.length;
        int[] dp = new int[n];
        int[] prev = new int[n]; // 记录前驱节点,用于重构序列

        for (int i = 0; i < n; i++) {
            dp[i] = 1;
            prev[i] = -1;
        }

        int maxLength = 1;
        int maxIndex = 0;

        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i] && dp[j] + 1 > dp[i]) {
                    dp[i] = dp[j] + 1;
                    prev[i] = j; // 记录是从哪个位置转移来的
                }
            }
            if (dp[i] > maxLength) {
                maxLength = dp[i];
                maxIndex = i;
            }
        }

        // 重构LIS序列
        int[] lis = new int[maxLength];
        int idx = maxLength - 1;
        int cur = maxIndex;
        while (cur != -1) {
            lis[idx--] = nums[cur];
            cur = prev[cur];
        }

        return lis;
    }

    public static void main(String[] args) {
        int[] nums = {10, 9, 2, 5, 3, 7, 101, 18};

        System.out.println("输入数组: [10, 9, 2, 5, 3, 7, 101, 18]");
        System.out.println("DP解法LIS长度: " + lengthOfLIS_DP(nums));

        int[] seq = getLIS_Sequence(nums);
        System.out.print("LIS序列: ");
        for (int x : seq) {
            System.out.print(x + " ");
        }
        System.out.println();
    }
}

方法二:二分优化(O(n log n))

核心思想

维护一个数组 tails,其中 tails[i] 表示长度为 i+1 的所有递增子序列中,结尾元素的最小值。这个数组一定是严格递增的,因此可以用二分查找来优化查找过程。

关键观察:
– 如果当前元素 x 大于 tails 的最后一个元素,直接追加,LIS长度+1
– 否则,在 tails 中找到第一个大于等于 x 的位置,用 x 替换它

这样 tails 数组始终保持有序,可以用 Arrays.binarySearch 或自定义二分查找。

Java实现

import java.util.Arrays;

public class LIS_BinarySearch {

    /**
     * 二分优化解法,时间复杂度 O(n log n),空间复杂度 O(n)
     * @param nums 输入数组
     * @return 最长递增子序列长度
     */
    public static int lengthOfLIS_Binary(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }

        int n = nums.length;
        // tails[i] 表示长度为 i+1 的递增子序列的最小结尾元素
        int[] tails = new int[n];
        int size = 0; // 当前LIS的长度

        for (int num : nums) {
            // 在 tails[0..size) 中找到第一个 >= 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; // 在左半部分继续查找(包括mid)
                }
            }

            // left 就是 num 应该放置的位置
            tails[left] = num;

            // 如果 num 比所有已存在的结尾元素都大,则扩展LIS长度
            if (left == size) {
                size++;
            }
        }

        return size;
    }

    /**
     * 使用 Java 内置二分查找的简化版本
     */
    public static int lengthOfLIS_BuiltIn(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }

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

        for (int num : nums) {
            int idx = Arrays.binarySearch(tails, 0, size, num);
            // binarySearch 返回非负表示找到,返回负数表示应该插入的位置
            if (idx < 0) {
                idx = -(idx + 1);
            }
            tails[idx] = num;
            if (idx == size) {
                size++;
            }
        }

        return size;
    }

    /**
     * 二分优化同时获取具体的LIS序列
     */
    public static int[] getLIS_Binary(int[] nums) {
        if (nums == null || nums.length == 0) {
            return new int[0];
        }

        int n = nums.length;
        int[] tails = new int[n];    // tails值
        int[] tailIndices = new int[n]; // tails中每个位置对应的原数组下标
        int[] prev = new int[n];     // 每个元素的前驱
        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 (tails[mid] < nums[i]) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            }

            tails[left] = nums[i];
            tailIndices[left] = i;

            // 记录前驱:如果 left > 0,则前驱是 tails[left-1] 对应的原数组下标
            if (left > 0) {
                prev[i] = tailIndices[left - 1];
            }

            if (left == size) {
                size++;
            }
        }

        // 重构序列
        int[] lis = new int[size];
        int cur = tailIndices[size - 1];
        for (int i = size - 1; i >= 0; i--) {
            lis[i] = nums[cur];
            cur = prev[cur];
        }

        return lis;
    }

    public static void main(String[] args) {
        int[] nums1 = {10, 9, 2, 5, 3, 7, 101, 18};
        int[] nums2 = {0, 1, 0, 3, 2, 3};
        int[] nums3 = {7, 7, 7, 7, 7, 7, 7};

        System.out.println("数组 [10,9,2,5,3,7,101,18] LIS长度: " + lengthOfLIS_Binary(nums1));
        System.out.println("数组 [0,1,0,3,2,3] LIS长度: " + lengthOfLIS_Binary(nums2));
        System.out.println("数组 [7,7,7,7,7,7,7] LIS长度: " + lengthOfLIS_Binary(nums3));

        int[] seq = getLIS_Binary(nums1);
        System.out.print("LIS序列: ");
        for (int x : seq) {
            System.out.print(x + " ");
        }
        System.out.println();
    }
}

两种方法对比

方法 时间复杂度 空间复杂度 适用场景
动态规划 O(n²) O(n) 需要获取具体序列,n ≤ 10⁴
二分优化 O(n log n) O(n) 只需长度,或 n 很大(可达 10⁵ ~ 10⁶)

复杂度分析

动态规划法:
– 时间复杂度:O(n²),双重循环遍历所有 (j, i) 对
– 空间复杂度:O(n),dp 数组和 prev 数组

二分优化法:
– 时间复杂度:O(n log n),每个元素进行一次二分查找
– 空间复杂度:O(n),tails 数组

扩展与变体

  1. 最长递减子序列(LDS):将数组取反或改变比较方向即可
  2. 最长非递减子序列:将严格小于 < 改为 <=
  3. 二维LIS:如信封嵌套问题,先按一维排序,再对另一维求LIS
  4. LIS数量:统计最长递增子序列的个数,需要额外维护 count 数组

总结

最长递增子序列问题是理解动态规划与贪心二分优化的绝佳切入点。O(n²) 的DP解法直观易懂,而 O(n log n) 的二分优化则展示了如何利用问题的单调性来加速求解。在实际应用中,根据数据规模选择合适的算法至关重要。掌握这两种方法,也为后续学习更复杂的动态规划优化技巧(如线段树优化DP、树状数组优化DP)打下了坚实基础。