1. 最长上升子序列问题概述
最长上升子序列(Longest Increasing Subsequence, LIS)是动态规划中的经典问题。给定一个长度为n的序列,我们需要找到其中最长的严格递增子序列的长度。例如,对于序列[10,9,2,5,3,7,101,18],最长上升子序列是[2,3,7,101],长度为4。
传统解法使用动态规划,时间复杂度为O(n²)。这种方法对于小规模数据(n≤1000)是可行的,但当n达到10⁵级别时,O(n²)的复杂度就无法接受了。本文将详细介绍一种基于贪心策略和二分查找的优化算法,将时间复杂度降低到O(n log n)。
2. 传统动态规划解法回顾
2.1 基本思路
传统DP解法定义dp[i]为以第i个元素结尾的最长上升子序列长度。状态转移方程为:
dp[i] = max(dp[j]) + 1, 其中0 ≤ j < i且a[j] < a[i]最终结果是所有dp[i]中的最大值。
2.2 复杂度分析
这种方法需要两层循环:外层遍历每个元素,内层遍历之前所有元素寻找最大值。因此时间复杂度为O(n²),空间复杂度为O(n)。
注意:虽然这种方法思路简单,但当n=1e5时,1e10次操作在现代计算机上需要数秒甚至更长时间,远超一般算法竞赛的时间限制(通常为1-2秒)。
3. 贪心+二分的优化思路
3.1 贪心策略的核心思想
优化算法的关键在于维护一个数组q,其中q[k]表示长度为k的上升子序列的最小末尾元素。这个定义基于以下观察:
对于相同长度的上升子序列,末尾元素越小,后续扩展的可能性越大。例如:
- [1,3,5]和[1,2,4]都是长度为3的上升子序列
- 但后者末尾4比5小,更容易接上更大的数(如5,6等)
3.2 算法流程详解
- 初始化q数组为空,len=0
- 遍历每个元素a[i]: a. 在q数组中找到最大的k,使得q[k] < a[i](使用二分查找) b. 更新q[k+1] = a[i] c. 如果k+1 > len,则更新len = k+1
- 最终len就是最长上升子序列的长度
3.3 为什么q数组是单调递增的
这是算法的关键性质。假设存在i < j且q[i] ≥ q[j],那么:
- 长度为j的子序列的前i个元素构成一个长度为i的子序列
- 且这个子序列的末尾元素一定小于q[j](因为是子序列)
- 这与q[i]是最小末尾元素的定义矛盾
因此,q数组必然是严格单调递增的,这使得二分查找成为可能。
4. 代码实现与细节解析
4.1 完整代码实现
#include <iostream> #include <algorithm> using namespace std; const int N = 100010; int a[N]; int q[N]; // q[k]存储长度为k的上升子序列的最小末尾元素 int main() { int n; cin >> n; for(int i = 0; i < n; i++) cin >> a[i]; int len = 0; // 当前已知的最长上升子序列长度 for(int i = 0; i < n; i++) { // 二分查找:在q[1..len]中找到最大的k使得q[k] < a[i] int l = 0, r = len; while(l < r) { int mid = l + r + 1 >> 1; // 向上取整防止死循环 if(q[mid] < a[i]) l = mid; else r = mid - 1; } // 更新q[l+1] = a[i] q[l + 1] = a[i]; // 如果l+1 > len,说明找到了更长的子序列 if(l + 1 > len) len = l + 1; } cout << len << endl; return 0; }4.2 关键代码解析
二分查找的实现:
- 使用
l + r + 1 >> 1而不是l + r >> 1是为了避免死循环 - 查找区间是[0, len],因为q数组从1开始存储长度1的子序列
- 使用
q数组的更新:
q[l + 1] = a[i]体现了贪心策略:总是用更小的a[i]替换原来的q[l+1]- 这样能保证q数组始终存储最小可能的末尾元素
len的更新:
- 当
l + 1 > len时,说明发现了更长的子序列 - 此时需要扩展q数组的有效范围
- 当
4.3 边界条件处理
- 初始时len=0,表示还没有任何子序列
- 第一个元素a[0]会使得len变为1(因为l初始为0)
- 对于每个a[i],如果它比所有q[k]都大,则会扩展最长子序列
5. 复杂度分析与正确性证明
5.1 时间复杂度
- 遍历每个元素:O(n)
- 对每个元素进行二分查找:O(log n)
- 总时间复杂度:O(n log n)
5.2 空间复杂度
- 需要O(n)空间存储q数组
- 实际使用中,q数组最多增长到n,因此空间复杂度为O(n)
5.3 算法正确性证明
算法的正确性基于以下两个关键点:
q数组的单调性:通过始终维护最小末尾元素,保证了q数组严格递增,这使得二分查找可行。
贪心选择性质:对于相同长度的子序列,选择末尾更小的不会使结果变差,反而提供了更多扩展机会。
通过数学归纳法可以严格证明该算法能得到最长上升子序列的长度。
6. 实际应用与变种问题
6.1 实际问题中的应用
LIS问题在多个领域有重要应用:
- 生物信息学中的DNA序列比对
- 金融领域中的最长增长趋势分析
- 数据压缩和模式识别
6.2 常见变种问题
最长不下降子序列:允许相邻元素相等
- 解法:将二分查找条件改为q[k] ≤ a[i]
输出具体的LIS:不仅求长度,还要输出一个具体的LIS
- 解法:额外维护一个parent数组记录转移路径
二维LIS问题:如信封嵌套问题
- 解法:先按一维排序,再对另一维求LIS
7. 常见错误与调试技巧
7.1 常见错误
二分查找边界错误:
- 忘记处理空序列情况
- 上下界初始化不正确
q数组更新错误:
- 错误地更新了q[l]而不是q[l+1]
- 没有维护q数组的单调性
输入处理错误:
- 数组下标从0还是1开始不一致
- 没有正确处理输入数据范围
7.2 调试技巧
小数据测试:
- 手动构造小例子验证基本逻辑
- 例如:[2,1,3,4]应该输出3
打印中间结果:
- 在每次迭代后打印q数组和len值
- 观察q数组是否保持单调递增
边界测试:
- 测试空序列、单元素序列
- 测试完全递增和完全递减序列
8. 算法优化与扩展
8.1 进一步优化
使用lower_bound:
- C++中可以直接使用lower_bound实现二分查找
- 代码更简洁但可能稍慢
空间优化:
- 如果只需要长度,可以复用输入数组
- 但通常q数组已经是最优空间
8.2 扩展到其他问题
最长公共子序列(LCS):
- 可以转化为LIS问题求解
- 适用于某些特殊情况的优化
带权LIS:
- 每个元素有权重,求权重和最大的上升子序列
- 需要结合线段树等数据结构
多维LIS:
- 处理多个约束条件的上升序列
- 通常需要更复杂的数据结构
在实际编码比赛中,掌握这种O(n log n)的LIS解法至关重要。它不仅效率高,而且思路巧妙,体现了贪心算法与二分查找的完美结合。我在多次比赛中遇到需要此算法的问题,正确实现后都能获得满分。建议读者充分理解其原理后,尝试解决一些变种问题来巩固学习效果。