1. 最长递增子序列问题概述
最长递增子序列(Longest Increasing Subsequence, LIS)是算法领域的一个经典问题。给定一个整数数组,我们需要找到其中最长的严格递增子序列的长度。这里的"子序列"不要求连续,但必须保持原始顺序。
举个例子,对于数组 [10,9,2,5,3,7,101,18],最长的递增子序列是 [2,3,7,101],因此长度为4。这个问题看似简单,但蕴含着丰富的算法思想,是理解动态规划和贪心算法的绝佳案例。
2. 动态规划解法详解
2.1 基本思路与状态定义
动态规划解法的核心在于定义合适的状态和状态转移方程。我们定义dp[i]表示以nums[i]结尾的最长递增子序列的长度。这样定义的好处是,我们可以利用之前计算的结果来推导当前状态。
初始化时,每个元素本身就是一个长度为1的子序列,因此所有dp[i]初始值都为1。然后,对于每个元素nums[i],我们检查它之前的所有元素nums[j](j < i),如果nums[i] > nums[j],说明nums[i]可以接在nums[j]后面形成更长的子序列。
2.2 完整代码实现与解析
java复制class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // 每个元素至少可以单独构成一个子序列
int maxLen = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
}
2.3 时间复杂度与空间复杂度分析
这个解法的时间复杂度是O(n²),因为对于每个元素,我们需要检查它之前的所有元素。空间复杂度是O(n),用于存储dp数组。
注意:虽然这个解法不是最优的,但它思路直观,容易理解,适合作为入门解法。在实际面试中,如果时间有限,可以先给出这个解法,再考虑优化。
3. 贪心+二分查找优化解法
3.1 贪心思想的核心
贪心算法的核心思想是:我们希望递增子序列增长得尽可能慢,这样后面的元素有更大的机会接在序列后面。为此,我们维护一个数组d,其中d[i]表示长度为i的递增子序列的最小末尾元素。
3.2 算法流程详解
- 初始化d数组,d[1] = nums[0],len = 1
- 遍历数组中的每个元素nums[i]:
- 如果nums[i] > d[len],直接将其加入d数组末尾,len++
- 否则,在d数组中找到第一个大于等于nums[i]的位置,用nums[i]替换该位置的元素
3.3 二分查找的巧妙应用
java复制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 left = 1, right = len, pos = 0;
while (left <= right) {
int mid = (left + right) >> 1;
if (d[mid] < nums[i]) {
pos = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
d[pos + 1] = nums[i];
}
}
return len;
}
}
3.4 为什么这个解法是正确的?
这个解法的关键在于:虽然我们修改了d数组中的元素,但这并不影响最终结果。因为我们只关心最长子序列的长度,而不需要知道具体的子序列是什么。通过保持d数组中的元素尽可能小,我们为后续的元素提供了更大的增长空间。
4. 两种解法的对比与选择
4.1 时间复杂度对比
- 动态规划:O(n²)
- 贪心+二分:O(nlogn)
对于n=2500的情况,动态规划解法需要进行约6百万次操作,而贪心解法只需要约3万次操作,效率差异显著。
4.2 适用场景分析
-
动态规划解法:
- 优点:思路直观,易于理解和实现
- 缺点:时间复杂度较高
- 适用:小规模数据或对时间复杂度要求不高的场景
-
贪心+二分解法:
- 优点:时间复杂度最优
- 缺点:思路较难理解,实现稍复杂
- 适用:大规模数据或对性能要求高的场景
4.3 实际应用中的选择建议
在面试或竞赛中,如果时间允许,建议先实现动态规划解法,确保正确性,然后再考虑优化为贪心+二分解法。在实际工程应用中,如果数据规模较大,应优先选择贪心+二分解法。
5. 常见问题与调试技巧
5.1 边界条件处理
- 空数组输入:应返回0
- 所有元素相同:应返回1
- 严格递增:注意题目要求的是严格递增(不能有相等)
5.2 调试技巧
- 打印dp数组或d数组的中间状态,观察其变化过程
- 对于贪心解法,可以记录每次替换的位置,验证替换是否合理
- 使用小规模测试用例手动计算预期结果,与程序输出对比
5.3 常见错误
- 初始化错误:忘记将dp数组初始化为1
- 状态转移条件错误:混淆了大于和大于等于
- 二分查找实现错误:边界条件处理不当导致死循环或错误结果
- 数组越界:特别是在处理d数组时,注意索引从1开始
提示:在实现贪心解法时,可以先用线性查找代替二分查找,确保逻辑正确后再优化为二分查找,这样可以降低调试难度。
6. 算法扩展与变种问题
6.1 输出具体的最长子序列
如果需要输出具体的最长子序列而不仅仅是长度,可以在动态规划解法中额外维护一个prev数组,记录每个元素的前驱节点,最后反向追踪即可。
6.2 最长非递减子序列
如果题目改为允许相等(非递减),只需将状态转移条件中的">"改为">="即可。
6.3 二维LIS问题
有些问题可以转化为LIS问题,如俄罗斯套娃信封问题(LeetCode 354),需要先对一维排序,然后在另一维上求LIS。
7. 个人实践心得
在实际编码中,我发现贪心+二分解法虽然效率高,但确实不容易一次写对。有几个关键点需要注意:
- d数组的索引从1开始,这样len可以直接表示当前最大长度
- 二分查找时,要明确查找的是第一个大于等于当前元素的位置
- 替换操作要确保不会破坏数组的单调性
我建议在理解这个算法时,可以用纸笔模拟几个例子,观察d数组的变化过程。例如对于输入[3,5,6,2,5,4,7],d数组的变化如下:
初始:d = [ ,3]
处理5:d = [ ,3,5]
处理6:d = [ ,3,5,6]
处理2:d = [ ,2,5,6]
处理5:d不变
处理4:d = [ ,2,4,6]
处理7:d = [ ,2,4,6,7]
通过这样的模拟,可以更直观地理解算法的运作原理。