按照数据结构来划分的话,最长上升子序列(LIS)是属于线性DP,其作为动态规划经典入门模型,重要性也是不言而喻的。而LIS问题也有很多种变型,对于这些问题有的超过了动态规划的适用范围,有的需要对转移方程进行更改,需要具体问题具体分析。
前置知识:
- 子序列的概念
- 动态规划一般步骤
- 二分查找
问题模型:给定一个长度为N的数列A,求数值单调递增的子序列长度最长是多少。
解题思路:
状态表示:我们设F[i]表示以A[i]为结尾的“最长上升子序列”的长度。
阶段划分:子序列的结尾位置(数列A中的位置,从前到后)。
转移方程:F[i] = max{ f[j] + 1} (0 <= j < i 且 A[j] < A[i])。
边界:F[0] = 0。
目标:max{F[i]} ( 0 <= i < = N)。
如此设计目标函数与转移方程,很显然可以得到正确结果,其效率是O(N^2),代码实现也很简单。
优化:
首先考虑一下上述算法最耗时的地方在哪,显然我们每次都要用O(N)的时间遍历前i-1个位置上的元素来更新F[i],而能更新F[i]的元素A[j]需要满足两个条件:A[j] < A[i]且F[j] >= F[i],那么F[i]就可以被更新为F[j]+1。在这个过程中,A[j]也可以描述为“小于A[i]的所有元素中F的值最大的那个”,通过这个描述来找A[j]的效率是O(N)。
考虑另一种方式,我们新增一个辅助数组d,d[i] 表示“长度为i的最长上升子序列的最小结尾是多少”,那么很显然,d数组中的元素是严格递增的。且每当我们读取到A序列中的一个元素A[i]时,都有两种情况:
1. A[i] > d[len],此时 d[++len] = A[i]。
2. A[i] <= d[len],此时说明d数组中某个元素可以被A[i]更新,我们采用二分查找的方式更新d数组即可。
其中len代表当前最长上升子序列的长度。
上述方法中,如果遇到情况1,那么效率是O(1),情况2,效率就是O(log N),总体来讲,对于大量数据,该优化效果还是很喜人的。
优化的正确性说明:
上述优化可以用堆栈思想来解释,也可以将其看作一个小技巧。借助于d数组,对于序列A中的每个元素A[i],我们都可以快速找到“小于A[i]的所有元素中F的值最大的那个”,**因为d[len]存放的就是最长上升子序列长度为len时的最小元素** 。如果A[i] <= d[len],那么很显然当前的最长上升子序列长度len不能再增加了(因为A[i] 不能添加在A的子序列d[1] ~d[len] 的后面),且d数组可以被更新(再不济也可以将d[len] 更新为 A[i],因为 A[i] < d[len])。于是借助于d数组的特性,完成了优化,当然也有所牺牲。
代码示例:
int d[100005];
int b_search(int x,int s,int e){
while(s < e){
int mid = s+(e-s)/2;
if(d[mid] >= x) e = mid;
else s = mid+1;
}
return s;
}
int LIS(int A[],int n){
memset(d,0,sizeof d);
int len = 1;
d[1] = A[0];
for(int i = 1;i < n;i++){
if(A[i] >= d[len]) d[++len] = A[i];
else{
int p = b_search(A[i],1,len);
d[p] = A[i];
}
}
// for(int i = 0;i < len;i++) cout << d[i+1]<<" ";
return len;
}
##### 参考资料
- 《算法竞赛进阶指南》,李煜东,P237.
- [博客](https://www.cnblogs.com/wxjor/p/5524447.html)