hello,大家好,说是要连跟来着,哎,又忙了起来,但再忙也要坚持更。今天我重新打了2020年ICPC昆明区域赛的题,笑道当年做L题(原题附上,有兴趣的同学可以看看)以为是归并排序逆序对加图的最小染色问题,现在想想可能要笑出猪叫。这道经典板子题,小编今天给大家解释解释这个板子。
前言知识
对于一个字符串而言,比如:abcdef
字串是在字符串中,取出一块(连续的),如:abc, bcd, def等
子序列指的是从字符串中,顺序取出字符,但是可以不连续:如:abd, bdf, acf等
最长上升子序列(LIS)普通方法(时间O(N^2))
我先来给上一个例子吧
例:
7
3 1 2 8 4 5 7
很明显我们用线性dp去思考很容易得到结果
解释: 当我们以第i个数结尾时,dp[i]所代表的就是以第i个数结尾时,能构成的最长上升子序列的长度,请你仔细理解这句话,因为他是我们构建代码的核心。当我们要构建dp[i]这个数的时候,我们要遍历他前面的所有数(假设为第j个数),当他前面的数有小于他的时候,我们将当前的dp[i]与dp[j]+1比较取最大,遍历完所有数后此时的dp[i]便是以第i个数结尾时,能构成的最长上升子序列的长度
#include <algorithm>
#include <iostream>
#include<cmath>
using namespace std;
const int MAXN = 1000005;
int a[MAXN];
int dp[MAXN];
int main(void) {
int n; cin >> n;
for (int i = 0; i < n; ++i) {
cin >> a[i];
}
int ans = 0;
for (int i = 0; i < n; ++i) {
dp[i] = 1; //最开始自身也是长度为一的子序列
for (int j = 0; j < i; ++j) {
if (a[j] < a[i]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
ans = max(ans, dp[i]);
}
//打印
cout << ans << endl;
for (int i = 0; i < n; ++i) {
cout << dp[i] << " ";
}cout << endl;
return 0;
}
当然这很显然的时间为O(N^2),如果数据为10的5次方时,是要超时的。具体如何优化我马上就会讲到。
最长下降子序列(LCS)普通方法(时间O(N^2))
很显然,刚才我们让第i个数前面的数只要小于i,则就进行dp[i]的更新,相反如果求下降子序列,我们直接将小于号换成大于号就可以了,你可以停下来再想想这个问题,以便更好的理解。
int ans = 0;
for (int i = 0; i < n; ++i) {
dp[i] = 1; //最开始自身也是长度为一的子序列
for (int j = 0; j < i; ++j) {
if (a[j] > a[i]) { //当前面的数大于第i个数时更新dp[i]
dp[i] = max(dp[i], dp[j] + 1);
}
}
ans = max(ans, dp[i]);
}
最长上升子序列(LIS)二分方法(时间O(NlogN))
因为上一个方法的时间复杂度为O(N^2) ,我们想要优化方法,这会使我们想到使用二分,可是我们难道要对刚才的方法中第二层循环做二分吗?这显然是不合适的,因为显然第i个数前面的数是无序的,那么我们应该如何处理呢?这里我建议你要尽量摒弃掉刚才dp的想法,因为我们将从一个新的角度去看待当前已知的数组,并另辟蹊径的处理数组。
通过观察我们已经知道的dp数组,我们会发现:
已知数组 a[i]: 3 1 2 8 4 5 7
以i个数结尾的
最长上升子序列dp[i] : 1 1 2 3 3 4 5
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|---|
a[i] | 3 | 1 | 2 | 8 | 4 | 5 | 7 |
dp[i] | 1 | 1 | 2 | 3 | 3 | 4 | 5 |
通过观察我们发现(注意我要强调一点,dp[i]的含义是以第i个数结尾的最长上升子序列的长度)
1.长度为1的最长上升子序列是以a[i]=3和a[i]=1结尾的子序列,而长度为1的最长上升子序列结尾的最小值为1.
2.长度为2的最长上升子序列是以a[i]=2结尾的子序列,而长度为2的最长上升子序列结尾的最小值为2.
3.长度为3的最长上升子序列是以a[i]=8和a[i]=4结尾的子序列,而长度为3的最长上升子序列结尾的最小值为4.
4.长度为4的最长上升子序列是以a[i]=5结尾的子序列,而长度为4的最长上升子序列结尾的最小值为5。
请你仔细思考上述过程,你会发现当我们每遍历到一个数,他都可能使最长子序列的长度增加或者使某一长度的最长上升子序列结尾的最小值更新。
如果是这样,我们可以定义这样一个数组
r[i]:i表示长度为i 的最长上升子序列,r[i]是记录长度为i 的最长上升子序列的结尾的最小值。
解释1:我们为什么要放最小值?
因为我们在当前长度上放最小值,在后面被遍历到的数大于该数,最长子序列的长度就更好的更新。
如果我们在当前长度上不放最小值,在后面被遍历到的数就可能很难大于该数,最长子序列的长度就不好的更新。
解释2:这个数组可以二分吗?
我们可以发现当我们每遍历一个数,将这个数拿到r数组中去找比该数小的最大的数,找到的数是在长度为i的最长上升子序列结尾的最小的值,那当前的数刚好比这个数大,则该数就是r[i+1]的值。
现在来解释r数组其实是有序的,因为当我们每遍历一个数,将这个数拿到r数组中去找比该数小的最大的数,找到的数是在长度为i的最长上升子序列结尾的最小的值。那么意味着 假如长度为2的最长上升子序列是以a[i]=2结尾的子序列,那么长度为3的最长上升子序列就不可能是以2或1结尾的子序列,因为当我们拿着2或1去r数组里找的时候就不可能找到长度为3这个位置,2或1只可能比长度为1的最长上升子序列最小值大,从而更新长度2,他不可能去更新或放在长度3处。
我希望你可以仔细思考这个过程,因为他是我们这个思路的重要规律,并且这个数组有序是可以二分的,
总结一下就是最小值以便我们可以取到更长的上升子序列,因为它取最小值的阈值更大(你可以这样理解),并且我们证明了r数组数单调的,可二分,那么很直观的可以发现r数组的长度便是最长的上升子序列的长度。
dp数组怎么求呢?
同样直观,我们对每个数都要更新r数组,有些数更新了某一长度的最小值,有些数更新了r数组的长度,不管如何将这个数更新入r数组的某个位置时的位置r,就是序列以该数结尾时的最长上升子序列长度,与dp[i]的含义一致。即就是dp[i]的值。
#include<bits/stdc++.h>
#include<algorithm>
#include <iostream>
#include<cmath>
using namespace std;
const int MAXN = 1000005;
int a[MAXN ];
int d[MAXN ];
int R[MAXN ];
int main(void) {
int n; scanf("%d", &n);
int len = 0;
for (int i = 0; i <= n; ++i) {
R[i] = INT_MIN;
}
for (int i = 0; i < n; ++i) {
scanf("%d", &a[i]);
int l = 0, r = len;
while (l < r) { //二分
int mid = (l + r + 1) >> 1;
if (R[mid] < a[i]) { //找小于该数的最大值的位置
l = mid;
}
else {
r = mid - 1;
}
}
len = max(len, r + 1); //更新r数组长度
R[r + 1] = a[i]; //更新r数组
d[i] = r + 1; //d数组赋值
}
printf("%d\n", len);
for (int i = 0; i < n; ++i) {
printf("%d ", d[i]);
}printf("\n");
return 0;
}
说实话一个好的二分并不好写,看看代码多思考吧!!
最长下降子序列(LCS)二分方法(时间O(NlogN))
如果你理解了刚才的上升,那么下降也就好理解了吧,我们要尽量让r[i]更大,这样才能给下降留足够大的阈值。所以二分时找大于的最小值,将
while (l < r) { //二分
int mid = (l + r + 1) >> 1;
if (R[mid] > a[i]) { //找大于该数的最小值的位置
l = mid;
}
else {
r = mid - 1;
}
}
希望你可以仔细思考这个过程,我将开头昆明那道题的答案附在下面,因为那个题就是一个标准最长下降子序列(LCS)二分方法板子题,如果看透的话。
#include<bits/stdc++.h>
#include<algorithm>
#include <iostream>
#include<cmath>
using namespace std;
const int MAXN = 1000005;
int a[MAXN];
int d[MAXN];
int R[MAXN];
int main(void) {
int t; scanf("%d", &t);
for (int z = 1; z <= t; ++z) {
int n; scanf("%d", &n);
int len = 0;
for (int i = 0; i <= n; ++i) {
R[i] = INT_MIN;
}
for (int i = 0; i < n; ++i) {
scanf("%d", &a[i]);
int l = 0, r = len;
while (l < r) { //二分
int mid = (l + r + 1) >> 1;
if (R[mid] > a[i]) { //找大于该数的最小值的位置
l = mid;
}
else {
r = mid - 1;
}
}
len = max(len, r + 1); //更新r数组长度
R[r + 1] = a[i]; //更新r数组
d[i] = r + 1; //d数组赋值
}
printf("%d\n", len);
for (int i = 0; i < n; ++i) {
printf("%d ", d[i]);
}printf("\n");
}
return 0;
}
当然我同样的建议你可以思考一下最长不下降子序列(LCS)二分方法和最长不上升子序列(LIS)二分方法,哈哈哈,其实换个符号就好了,希望你可以想的透彻。
结语
好了,今天的分享就到这里,希望你能有所收获,我们下期再见!!