求解最长递增子序列(LIS) | 动态规划(DP)+ 二分法

时间:2023-03-08 17:16:17

1、题目描述

    给定数组arr,返回arr的最长递增子序列。

2、举例

    arr={2,1,5,3,6,4,8,9,7},返回的最长递增子序列为{1,3,4,8,9}。

3、解答

    本期主要从动态规划和二分法两个方向来求解最长递增子序列问题。

3.1 动态规划求解最长递增子序列

    先介绍时间复杂度为O(N^2^)的方法,具体过程如下:

  1. 生成数组dp,dp[i]表示在以arr[i]这个数结尾的情况下,arr[0…i]中的最大递增子序列长度。
  2. 对第一个数arr[0]来说,令dp[0]=1,接下来从左到右(i=1,2,…,N-1)依次算出以每个位置的数结尾的情况下,最长递增子序列长度。
  3. 假设计算到位置i,求以arr[i]结尾情况下的最长递增子序列长度,即dp[i]。如果最长递增子序列以arr[i]结尾,那么在arr[0…i-1]中所有比arr[i]小的数都可以作为倒数第二个数。在这么多倒数第二个数的选择中,以哪个数结尾的最大递增子序列更大,就选那个数作为倒数第二个数,所以dp[i]=max{dp[j]+1(0≤j<i,arr[j]<arr[i])}。如果arr[0…i-1]中所有的数都不比arr[i]小,令dp[i]=1即可,说明以arr[i]结尾情况下的最长递增子序列只包含arr[i]。

    按照步骤1~3可以计算出dp数组,具体过程请参看如下代码中的方法,参考代码如下:

#include<stdio.h>

#define MAXN 1000
int arr[MAXN + 10];
int dp[MAXN + 10]; int main() {
int N, i, j;
scanf("%d", &N);
for (i = 0; i < N; ++i) {
scanf("%d", &arr[i]);
}
dp[0] = 1;
for (i = 1; i < N; ++i) {
/*
* 每次求以第i个数为终点的最长上升子序列的长度
*/
int tmp = 0;/* 记录满足条件的、第i个数左边的上升子序列的最大长度 */
for (j = 0; j < i; ++j) {
/* 查看以第j个数为终点的最长上升子序列 */
if (arr[i] > arr[j]) {
if (tmp < dp[j])
tmp = dp[j];
}
}
dp[i] = tmp + 1;
}
int ans = -1;
for (i = 0; i < N; ++i) {
if (ans < dp[i])
ans = dp[i];
}
printf("%d\n", ans);
return 0;
}

    程序执行完后,数组arr[]和状态数组dp[]如下:

求解最长递增子序列(LIS) | 动态规划(DP)+ 二分法

    最长上升子序列有6个:(1,5,6,8,9)、(2,5,6,8,9)、(2,3,6,8,9)、(2,3,4,8,9)、(1,3,4,8,9)和(1,3,6,8,9),长度都是5。

        

    问题:如果还要输出最长的子序列呢?例如,除了输出5之外,还要输出(1,3,4,8,9)这个序列。

    接下来解释如何根据求出的dp数组得到最长递增子序列。以题目的例子来说明,arr={2,1,5,3,6,4,8,9,7},求出的数组dp={1,1,2,2,3,3,4,5,4}。具体求解步骤如下:

  1. 遍历dp数组,找到最大值以及位置。在本例中最大值为5,位置为7,说明最终的最长递增子序列的长度为5,并且应该以arr[7]这个数(arr[7]=9)结尾。
  2. 从arr数组的位置7开始从右向左遍历。如果对某一个位置i,既有arr[i]<arr[7],又有dp[i]=dp[7]-1,说明arr[i]可以作为最长递增子序列的倒数第二个数。在本例中,arr[6]<arr[7],并且dp[6]=dp[7]-1,所以8应该作为最长递增子序列的倒数第二个数。
  3. 从arr数组的位置6开始继续向左遍历,按照同样的过程找到倒数第三个数。在本例中,位置5满足arr[5]<arr[6],并且dp[5]=dp[6]-1,同时位置4也满足。选arr[5]或者arr[4]作为倒数第三个数都可以。
  4. 重复这样的过程,直到所有的数都找出来。

    dp数组包含每一步决策的信息,其实根据dp数组找出最长递增子序列的过程就是从某一个位置开始逆序还原出决策路径的过程。具体过程请参看如下代码:

#include<stdio.h>
#include<stdlib.h> /* 动态内存分配 */ #define MAXN 1000
int arr[MAXN + 10];
int dp[MAXN + 10]; int main() {
int N, i, j;
scanf("%d", &N);
for (i = 0; i < N; ++i) {
scanf("%d", &arr[i]);
}
dp[0] = 1;
for (i = 1; i < N; ++i) {
/*
* 每次求以第i个数为终点的最长上升子序列的长度
*/
int tmp = 0;/* 记录满足条件的、第i个数左边的上升子序列的最大长度 */
for (j = 0; j < i; ++j) {
/* 查看以第j个数为终点的最长上升子序列 */
if (arr[i] > arr[j]) {
if (tmp < dp[j])
tmp = dp[j];
}
}
dp[i] = tmp + 1;
}
int ans = -1;
for (i = 0; i < N; ++i) {
if (ans < dp[i])
ans = dp[i];
}
printf("%d\n", ans); /* 输出最长递增子序列的长度 */ /*
* 下面根据dp数组还原出最长递增子序列。
* len中记录了最长递增子序列的长度,当然有len=ans。
* index记录最长递增子序列中最后一个数在arr数组中的位置。
*/
int len = 0;
int index = 0;
for (i = 0; i < N; ++i) {
if (dp[i] > len) {
len = dp[i];
index = i;
}
} /*
* lis数组用来存放最长递增子序列。
*/
int* lis = (int*)malloc(sizeof(int) * len);
lis[--len] = arr[index]; /* 最长递增子序列中最后一个数为arr[index] */
for (i = index; i >= 0; i--) { /* 从index位置开始从右往左扫描数组arr */
if (arr[i] < arr[index] && dp[i] == dp[index] - 1) {
lis[--len] = arr[i];
index = i;
}
}
/* 打印最长递增子序列 */
for (i = 0; i < ans; ++i) {
printf("%d", lis[i]);
if (i < ans - 1)printf(" ");
}
printf("\n"); free(lis); return 0;
}

输入:

9

2 1 5 3 6 4 8 9 7

输出:

5

1 3 4 8 9

运行结果:

求解最长递增子序列(LIS) | 动态规划(DP)+ 二分法

    计算dp数组过程的时间复杂度为O(N^2^),根据dp数组得到最长递增子序列过程的时间复杂度为O(N),所以整个过程的时间复杂度为O(N^2^)。

    问题:如果把序列的长度增加到N=10^4^,10^5^,10^6^ 呢?如何将计算dp数组的时间复杂度降到O(Nlog N)?

3.2 二分法求解最长递增子序列

    时间复杂度O(Nlog N)生成dp数组的过程是利用二分查找来进行的优化。先生成一个长度为N的数组ends,初始时ends[0]=arr[0],其他位置上的值为0。生成整型变量right, 初始时right=0。在从左到右遍历arr数组的过程中,求解dp[i]的过程需要使用ends数组和 right变量,所以这里解释一下其含义。遍历的过程中,ends[0..right]为有效区, ends[right+1..N-1]为无效区。对有效区上的位置b如果有ends[b]=c,则表示遍历到目前为止,在所有长度为b+1的递增序列中,最小的结尾数是c。无效区的位置则没有意义。

    比如,arr=[2,1,5,3,6,4,8,9,7],初始时 dp[0]=1,ends[0]=2, right=0。ends[0..0]为有效区, ends[0]=2的含义是,在遍历过arr[0]之后,所有长度为1的递增序列中(此时只有[2]),最小的结尾数是2。之后的遍历继续用这个例子来说明求解过程。

  1. 遍历到arr[1]==1。ends有效区=ends[0..0]=[2],在有效区中找到最左边的大于或等于arr[1]的数。发现是ends[0],表示以arr[1]结尾的最长递增序列只有arr[1],所以令dp[1]=1。然后令ends[0]=1,因为遍历到目前为止,在所有长度为1的递增序列中,最小的结尾数是1,而不再是2。
  2. 遍历到arr[2]==5。ends有效区=ends[0..0]=[1],在有效区中找到最左边大于或等于arr[2]的数。发现没有这样的数,表示以arr[2]结尾的最长递增序列长度=ends有效区长度+1, 所以令dp[2]=2。ends整个有效区都没有比arr[2]更大的数,说明发现了比ends有效区长度更长的递增序列,于是把有效区扩大,ends有效区=ends[0..1]=[1,5]。
  3. 遍历到arr[3]==3。ends有效区=ends[0..1]=[1,5],在有效区中用二分法找到最左边大于或等于arr[3]的数。发现是ends[1],表示以arr[3]结尾的最长递增序列长度为2,所以令dp[3]=2。然后令ends[1]=3,因为遍历到目前为止,在所有长度为2的递增序列中,最小的结尾数是3,而不再是5。
  4. 遍历到arr[4]==6。ends有效区=ends[0..1]=[1,3],在有效区中用二分法找到最左边,大于或等于arr[4]的数。发现没有这样的数,表示以arr[4]结尾的最长递增序列长度=ends 有效区长度+1,所以令dp[4]=3。ends整个有效区都没有比arr[4]更大的数,说明发现了比 ends有效区长度更长的递增序列,于是把有效区扩大,ends有效区=ends[0..2]=[1,3,6]。
  5. 遍历到arr[5]==4。ends有效区=ends[0..2]=[1,3,6],在有效区中用二分法找到最左边大于或等于arr[5]的数。发现是ends[2],表示以arr[5]结尾的最长递增序列长度为3,所以令dp[5]=3。然后令ends[2]=4,表示在所有长度为3的递增序列中,最小的结尾数变为4。
  6. 遍历到arr[6]==8。ends有效区=ends[0..2]=[1,3,4],在有效区中用二分法找到最左边大于或等于arr[6]的数。发现没有这样的数,表示以arr[6]结尾的最长递增序列长度=ends有效区长度+1,所以令dp[6]=4。ends整个有效区都没有比arr[6]更大的数,说明发现了比 ends有效区长度更长的递增序列,于是把有效区扩大,ends有效区=ends[0..3]=[1,3,4,8]。
  7. 遍历到arr[7]==9。ends有效区=ends[0..3]=[1,3,4,8],在有效区中用二分法找到最左边大于或等于arr[7]的数。发现没有这样的数,表示以arr[7]结尾的最长递增序列长度=ends 有效区长度+1,所以令dp[7]=5。ends整个有效区都没有比arr[7]更大的数,于是把有效区 扩大,ends 有效区=ends[0..5]=[1,3,4,8,9]。
  8. 遍历到arr[8]==7。ends有效区=ends[0..5]=[1,3,4,8,9],在有效区中用二分法找到最左边大于或等于arr[8]的数。发现是ends[3],表示以arr[8]结尾的最长递增序列长度为4, 所以令dp[8]=4。然后令ends[3]=7,表示在所有长度为4的递增序列中,最小的结尾数变为7。

    具体过程请参看如下代码:

#include<stdio.h>
#include<stdlib.h> /* 动态内存分配 */ #define MAXN 100000
int arr[MAXN + 10];
int dp[MAXN + 10];
int ends[MAXN + 10]; int max(int x, int y) {
return x > y ? x : y;
} int main() {
int N, i;
scanf("%d", &N);
for (i = 0; i < N; ++i) {
scanf("%d", &arr[i]);
}
dp[0] = 1;
ends[0] = arr[0];
int right = 0;
int ll = 0;
int rr = 0;
int mm = 0;
for (i = 1; i < N; ++i) {
ll = 0;
rr = right;
while (ll <= rr) {
mm = (ll + rr) / 2;
if (arr[i] > ends[mm]) {
ll = mm + 1;
} else {
rr = mm - 1;
}
}
right = max(right, ll);
ends[ll] = arr[i];
dp[i] = ll + 1;
} int ans = -1;
for (i = 0; i < N; ++i) {
if (ans < dp[i])
ans = dp[i];
}
printf("%d\n", ans); /* 输出最长递增子序列的长度 */ /*
* 下面根据dp数组还原出最长递增子序列。
* len中记录了最长递增子序列的长度,当然有len=ans。
* index记录最长递增子序列中最后一个数在arr数组中的位置。
*/
int len = 0;
int index = 0;
for (i = 0; i < N; ++i) {
if (dp[i] > len) {
len = dp[i];
index = i;
}
} /*
* lis数组用来存放最长递增子序列。
*/
int* lis = (int*) malloc(sizeof(int) * len);
lis[--len] = arr[index]; /* 最长递增子序列中最后一个数为arr[index] */
for (i = index; i >= 0; i--) { /* 从index位置开始从右往左扫描数组arr */
if (arr[i] < arr[index] && dp[i] == dp[index] - 1) {
lis[--len] = arr[i];
index = i;
}
}
/* 打印最长递增子序列 */
for (i = 0; i < ans; ++i) {
printf("%d", lis[i]);
if (i < ans - 1)
printf(" ");
}
printf("\n"); free(lis); return 0;
}

运行结果:

求解最长递增子序列(LIS) | 动态规划(DP)+ 二分法

4、文章推荐

推荐一:《用x种方式求第n项斐波那契数,99%的人只会第一种》,文章内容:斐波那契数列及其求法,动态规划,数组的巧妙使用--滚动数组。

推荐二:《深入浅出理解动态规划(二) | 最优子结构》,文章内容:经典例题---数字三角形求解。

推荐三:《深入浅出理解动态规划(一) | 交叠子问题》,文章内容:记忆化搜索算法、打表法求解第n个斐波那契数。