做了三遍才懂的动态规划之线性DP---LeetCode 300. 最长递增子序列

QA模块关键

原题链接:300. 最长递增子序列 - 力扣(LeetCode)

做了三遍才懂的动态规划之线性DP---LeetCode 300. 最长递增子序列_第1张图片


解题思路

        为了构造尽可能长的上升子序列,我们采取的策略是让子序列的增长尽可能慢,即在相同长度的子序列中,选择末尾数最小的一个。这种方法的核心在于维护一个数组 tails,其中 tails[i] 表示所有长度为 i+1 的上升子序列中末尾元素的最小值。这样,tails 数组保持单调递增,使得我们可以用二分查找来优化搜索过程。

关键性质

  • 性质一:在所有长度相同的递增子序列中,末尾元素越小,为后续元素提供加入递增子序列的可能性越大,从而可能形成更长的递增子序列。

  • 性质二:由于子序列是递增的,对于任意一个元素 a[i],如果它能接在某个递增子序列的后面形成更长的递增子序列,那么这个递增子序列的末尾元素必定小于 a[i]

基于上述性质,我们维护一个数组 tails,其中 tails[i] 表示所有长度为 i+1 的递增子序列中末尾元素的最小值。tails 数组具有单调递增的特性,这使得我们可以使用二分查找来优化查找过程。

解题步骤

  1. 初始化 tails 数组,并将 tails[0] 设置为 nums[0]。设置变量 len 为 1,表示当前最长上升子序列的长度。

  2. nums[1] 开始遍历输入数组 nums

    • 使用二分查找在 tails 数组中找到第一个大于等于 nums[i] 的元素的位置。

    • 如果找到这样的位置,用 nums[i] 更新相应的 tails 元素;如果 nums[i] 大于 tails 数组中所有元素,将其添加到 tails 末尾,并更新 len

  3. 遍历结束后,len 即为最长上升子序列的长度。

题解①

class Solution {
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        if (n == 0) return 0; // 空数组特判

        int[] tails = new int[n];
        tails[0] = nums[0]; // 初始化tails数组
        int len = 1; // 初始化最长上升子序列长度为1

        for (int i = 1; i < n; i++) {
            int left = 0, right = len - 1; // 使用二分查找的左右边界
            // 二分查找,找到第一个大于等于nums[i]的元素的位置
            while (left <= right) {
                int mid = left + (right - left) / 2;
                if (nums[i] > tails[mid]) left = mid + 1;
                else right = mid - 1;
            }
            tails[left] = nums[i]; // 更新tails数组
            if (left == len) len++; // 如果nums[i]添加到了tails的末尾,更新len
        }

        return len; // 返回最长上升子序列的长度
    }
}
注释说明
  • tails[left] = nums[i]:这一步是算法的关键,它保证了 tails 数组的定义不变,即 tails[i] 仍然表示所有长度为 i+1 的递增子序列中末尾元素的最小值。

  • 二分查找:left <= right 确保了查找范围包括所有可能的位置,left = mid + 1right = mid - 1 用于缩小查找范围,最终 left 指向了 nums[i] 应该插入的位置。

题解②   直接写在main中

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int N = 100010; // 定义最大数组长度
        int[] nums = new int[N]; // 输入数组
        int[] tails = new int[N]; // tails数组,用于存储当前找到的最长递增子序列的最小末尾元素
        int n = scanner.nextInt(); // 读取数组长度
        for (int i = 0; i < n; i++) {
            nums[i] = scanner.nextInt(); // 读取数组元素
        }

        int len = 0; // 初始化最长递增子序列长度为0
        tails[0] = Integer.MIN_VALUE; // 初始化tails数组的第一个元素为最小值,以便任何nums[i]都大于它

        for (int i = 0; i < n; i++) {
            int left = 0, right = len;
            // 二分查找在tails数组中找到插入nums[i]的位置
            while (left <= right) {
                int mid = (left + right) >>> 1; // 使用无符号右移来防止溢出
                if (tails[mid] < nums[i]) {
                    left = mid + 1;
                } else {
                    right = mid - 1;
                }
            }   
            len = Math.max(len, right + 1);

            // 更新tails数组,并可能增加len
            tails[right + 1] = nums[i];
        }

        System.out.println(len); // 输出最长递增子序列的长度
    }
}
代码解释:
  • 这个版本使用了一个 tails 数组,其含义与之前题解中的一致:tails[i] 存储的是所有长度为 i+1 的递增子序列中末尾元素的最小值。

  • 初始化 tails[0]Integer.MIN_VALUE 是为了确保任何正整数都大于它,从而使得算法可以正确地处理第一个元素。

  • 在遍历 nums 数组的过程中,对于每个元素 nums[i],使用二分查找确定它在 tails 数组中的位置,然后根据情况更新 tails 数组和最长递增子序列的长度 len

  • 最终,变量 len 存储了最长递增子序列的长度,最后打印出来作为结果。

代码解释:

官方题解:

class Solution {
    public int lengthOfLIS(int[] nums) {
        int len = 1, n = nums.length;
        if (n == 0) {
            return 0;
        }
        int[] d = new int[n + 1];
        d[len] = nums[0];
        for (int i = 1; i < n; ++i) {
            if (nums[i] > d[len]) {
                d[++len] = nums[i];
            } else {
                int l = 1, r = len, pos = 0;
 // 如果找不到说明所有的数都比 nums[i] 大,此时要更新 d[1],所以这里将 pos 设为 0
                while (l <= r) {
                    int mid = (l + r) >> 1;
                    if (d[mid] < nums[i]) {
                        pos = mid;
                        l = mid + 1;
                    } else {
                        r = mid - 1;
                    }
                }
                d[pos + 1] = nums[i];
            }
        }
        return len;
    }
}

算法复杂度

通过维护 tails 数组和利用二分查找,该算法有效地解决了最长上升子序列问题,时间复杂度为 O(n log n),其中 n 是输入数组 nums 的长度。

QA

这个方法区别于官方题解稍微难懂一些。官方题解在理解和实现上更加直观和简单

理解贪心策略

整个算法的贪心策略是尽可能地延长上升子序列。通过在 tails 数组中尽可能地使用较小的值,我们为后续的元素留出了更多的上升空间,从而使得整个上升子序列尽可能地长。

理解 tails 数组的作用

首先,理解 tails 数组的作用是关键。tails[i] 存储的是所有长度为 i+1 的上升子序列中末尾元素的最小值。这个定义是非常重要的,因为它保证了 tails 数组是单调递增的,这使得我们可以使用二分查找来优化搜索过程。

更新 tails 数组

在二分查找后,我们用 nums[i] 更新 tails[left],这里 left 是二分查找结束后的索引。这一步的直观理解是:我们要么在 tails 数组的末尾添加一个新元素(从而增加上升子序列的长度),要么用一个较小的值替换 tails 数组中的某个元素(以便为后续可能出现的更大元素腾出空间)。

二分查找的目的

二分查找在这个解法中的目的是找到 nums[i] 应该插入 tails 数组的位置。如果 nums[i] 大于 tails 数组中的所有元素,这意味着我们找到了一个更长的上升子序列,因此我们需要扩展 tails 数组。如果 nums[i] 小于或等于 tails 数组中的一些元素,这意味着我们需要在 tails 数组中找到第一个大于 nums[i] 的元素,并用 nums[i] 替换它,以保持 tails 数组的定义不变。

        理解二分查找中 leftright 范围的更新逻辑是理解整个解法的关键。二分查找的目标是找到一个位置,这个位置指示了新元素 nums[i] 应该插入 tails 数组的哪里。这里有几个关键点需要注意:

leftright 的初始值
  • left 初始化为 0,因为 tails 数组中可能需要更新的位置可以从数组的最开始即索引 0 处开始。
  • right 初始化为 len - 1,因为在当前最长上升子序列中,nums[i] 可能会替换的位置最远只能到达当前序列的末尾,即 len - 1。这里 len 是到目前为止发现的最长上升子序列的长度。
更新 leftright

在二分查找的每一步中,我们计算中点 mid = left + (right - left) / 2,然后根据 nums[i]tails[mid] 的比较结果更新 leftright

  • 如果 nums[i] > tails[mid],这意味着 nums[i] 可以放在 mid 之后而不破坏上升序列的性质。因此,我们应该在 mid 右侧继续查找,所以更新 left = mid + 1
  • 如果 nums[i] <= tails[mid],这意味着 nums[i] 应该替换 mid 位置或 mid 位置之前的某个元素(为了使末尾元素尽可能小),因此我们在 mid 左侧继续查找,所以更新 right = mid - 1
为什么 left = mid + 1

当我们发现 nums[i] 大于 tails[mid] 时,mid 位置不是 nums[i] 应该插入的位置,因为 nums[i] 需要放在一个更大的索引处以保持上升序列的性质。这就是为什么我们设置 left = mid + 1,即我们排除了 mid 及其左侧的所有位置,将搜索范围缩小到 mid 的右侧。

为什么 right = mid - 1

nums[i] 小于或等于 tails[mid] 时,mid 位置或其左侧可能是 nums[i] 的合适位置。因此,我们需要在 mid 的左侧继续搜索。通过设置 right = mid - 1,我们排除了 mid 及其右侧的所有位置,将搜索范围缩小到 mid 的左侧。

循环结束条件

循环继续执行直到 left > right,此时 left 指示了 nums[i] 应该插入的位置。循环结束时,left 指向的是 tails 数组中第一个大于或等于 nums[i] 的元素的位置(如果所有元素都小于 nums[i],则 left 指向 len,即 nums[i] 应该添加到 tails 的末尾)。

通过这种方式,二分查找帮助我们高效地找到了 nums[i]tails 数组中的正确位置,从而可以通过尽可能小的更新来延长上升子序列的长度。

你可能感兴趣的:(数据结构与算法分析,力扣,动态规划,leetcode,算法,排序算法,二分搜索,java,数据结构)