错题整理与解析(计算机 · 编程题 · 进阶)
1. 题目信息与错因还原
- 题目:给定整数数组 nums,返回其最长严格递增子序列(LIS)的长度,要求时间复杂度 O(n log n)。
- 示例:nums = [10, 9, 2, 5, 3, 7, 101, 18],输出 4(LIS 之一为 [2, 3, 7, 101])。
- 约束:1 ≤ n ≤ 2×10^5,-10^9 ≤ nums[i] ≤ 10^9
- 题目类型:编程题
- 学科类型:计算机
- 难度等级:进阶
错误原因(还原):
- 概念混淆:将“最长严格递增子序列”误解为“最长连续递增子数组”,用双指针统计连续段。
- 复杂度不达标:改用 O(n^2) DP 在大数据下超时。
- O(n log n) 解法实现细节错误:
- 二分维护 tails 时对相等元素使用 upper_bound,导致把“非严格递增”计入。
- 初始化与边界处理不当(如未用空数组起步、返回了 tails 的值而非长度),输出与样例不一致。
2. 正确解法(O(n log n))—“耐心排序/tails 数组 + 二分”
核心思想:
- 维护一个递增数组 tails,其中 tails[k] 表示“长度为 k+1 的所有严格递增子序列中,可能的最小末尾值”。
- 对每个 x:
- 用 lower_bound 在 tails 中找到第一个 >= x 的位置 i。
- 若 i 等于 tails 长度,追加 x(LIS 长度 +1)。
- 否则更新 tails[i] = x(更优的末尾,更利于后续延长)。
- 由于是“严格递增”,必须用 lower_bound(第一个 >= x)。这样确保相等元素不会延长序列长度。
关键不变量:
- tails 始终递增。
- len(tails) = 当前已知的 LIS 长度。
- tails 不是具体的 LIS,但其长度正确。
步骤:
- 初始化 tails = []。
- 遍历 nums 的每个 x:
- i = lower_bound(tails, x)(第一个 >= x 的位置)。
- 若 i == len(tails):tails.append(x)。
- 否则:tails[i] = x。
- 返回 len(tails)。
时间复杂度:O(n log n)(n 次二分)
空间复杂度:O(n)
示例演算(nums = [10, 9, 2, 5, 3, 7, 101, 18]):
- [] → [10]
- [10] → [9]
- [9] → [2]
- [2] → [2, 5]
- [2, 5] → [2, 3]
- [2, 3] → [2, 3, 7]
- [2, 3, 7] → [2, 3, 7, 101]
- [2, 3, 7, 101] → [2, 3, 7, 18]
- 返回长度 4
代码(Python 版):
from bisect import bisect_left
def lengthOfLIS(nums):
tails = []
for x in nums:
i = bisect_left(tails, x) # lower_bound: 第一个 >= x 的位置
if i == len(tails):
tails.append(x)
else:
tails[i] = x
return len(tails)
代码(C++ 版):
#include <vector>
#include <algorithm>
using namespace std;
int lengthOfLIS(vector<int>& nums) {
vector<int> tails;
for (int x : nums) {
auto it = lower_bound(tails.begin(), tails.end(), x); // 第一个 >= x
if (it == tails.end()) tails.push_back(x);
else *it = x;
}
return (int)tails.size();
}
3. 常见边界与易错点对照修正
- 严格 vs 非严格:
- 严格递增 LIS:用 lower_bound(第一个 >= x),相等元素不会延长长度。
- 非严格(不下降)LIS:用 upper_bound(第一个 > x)。
- 初始化与返回:
- tails 从空开始,不需要预填充无穷大。
- 返回 tails 的长度,而不是 tails 的最后一个值。
- 连续 vs 非连续:
- 子序列不要求连续,下标可跳跃;子数组/子串才要求连续。
- 典型测试用例可自检:
- 全相等:[2,2,2] → 严格 LIS 长度应为 1(upper_bound 会错误给出 3)。
- 严格递减:[5,4,3] → 1。
- 全递增:[1,2,3] → 3。
- 含负数与大值:算法对值域不敏感,只依赖比较。
4. O(n^2) DP 解法(用于理解,对大数据会超时)
- 定义:dp[i] = 以 nums[i] 作为结尾的严格递增子序列的最长长度。
- 转移:dp[i] = 1 + max{ dp[j] | j < i 且 nums[j] < nums[i] },若无满足则为 1。
- 答案:max(dp)。
- 复杂度:O(n^2);n=2×10^5 时不可取。
5. 学习薄弱点分析与改进建议
薄弱点:
- 概念区分不清:将“子序列”误作“连续子数组”。
- 算法复杂度把控不足:已明确 O(n log n) 要求却仍用 O(n^2)。
- 二分边界与“严格/非严格”关系不牢:lower_bound vs upper_bound 混用。
- 实现细节疏漏:初始化方式、返回值、边界样例缺少自测。
改进建议:
- 建立清晰概念框架:
- 子序列:可跳过元素;子数组:连续;子串:连续且字符序列。
- 严格递增 vs 非严格递增,对应 lower_bound vs upper_bound。
- 面向约束设计解法:
- 见到 n 可达 2×10^5,应首选 O(n log n) 或更优;先写出核心不变量再编码。
- 固化不变量与套路:
- 记忆:tails[k] = “长度为 k+1 的最小可能结尾”,len(tails) 即答案。
- 严格递增用 lower_bound(>=),非严格用 upper_bound(>)。
- 增强用例自测覆盖边界:
- [2,2,2]、严格递减、全递增、含负数、值域极大/极小混合。
- 编码清单(提交前自检):
- 初始化 tails=[];每次二分找 >= x;更新或追加;返回 len(tails)。
需要我基于你常用语言与框架(如 Java/Go)给出等价实现和单元测试样例吗?我也可以根据你的代码草稿逐行帮你做边界与复杂度审核。