题解 SP12076 【LCS0 - Longest Common Subsequence】
最长公共子序列的优化
0.前言:
或许大家不知道除了
需要读者的前置技能:
n^{2} 的dp求LCS的做法;- 二进制运算的一些小技巧;
看完本篇文章,你会学会的技能:
给定两个字符串
请注意:要做到这一点需要毒瘤的代码实现技巧和恶魔般的内存优化,如果你是手残+脑残,只能解决
1. 首先,我们来看看一种可能被卡的玄学优化:
我们先不看dp方程,想一想能否有不是二维dp的做法(时间复杂度可以大于
显然有,因为洛谷的LCS模板就是这样的,虽然它有字符集的限制。
假设现在有两个字符串a,b;它们的长度分别是
对于a中的每个字符
然后从
1.1 让我们快乐的举个例子:
设a:
然后将
我们将拼起来的数组中所有空元素删除,对其求最长严格上升子序列,就是
1.2 简述该方法的正确性:
因为是严格上升子序列,每个数最多只会出现一次,所以最长上升子序列中出现的数两两不同;
由定义可知:b[c[x][y]]=a[x];
为了得到最长的公共子序列,我们需要找到最多的
这不就是最长上升子序列吗,而至于最长上升子序列LIS,我们可以通过树状数组做到
1.3 时间复杂度论证:
至于时间复杂度和空间复杂度嘛,是个玄学;
1.上限:时间复杂度上限是
比如说a串是
但时间复杂度下限是
比如说a串是一个排列,b串也是一个排列,那么c数组会很小很小,时间复杂度产生了巨大的优化;
对于随机数据,设它的字典集(即所有出现过的字符的种类数目)大小为
因此求两个排列的LCS便是严格的
重要的事情说三遍:
1.如果
2.如果
3.如果
所以如果数据是用脚造的随机出的(这很重要),并且字典集比较大,这种方法就会跑得比较快;
好了,完结撒花~
Q:欸?不对不对,这糊弄三岁小孩纸呢?这这不是在字符集有限制的情况下才能产生正优化吗?举报举报!出现标题党!与标题严重不符!
A:别着急,接着向下看,因为简单的优化应该放在前面不是吗?
2. 二进制优化:
刚才的算法仅仅是在随机数据下字典集比较大的情况下使用的方法,那么如果字典集比较小(更准确的说是不那么大)呢?怎么办?难道只能是严格
众所周知,二进制的位运算是很快的,可以考虑从这方面结合dp方程入手。
让我们先来看一看dp方程:
特别注意:我的方程的定义:是
我们发现,这个
那么我们可以构造一个01矩阵
显然的,答案就是
2.1 得到M 数组的方法:
2.1.1.首先,我们将a串反转(下标也反转过来)。然后我们建立一个bitset类型的数组t ,对于每个在b 中出现过的字符,利用二进制表示(可以使用bitset)出在a中所有的出现位置;
比如说:对于a串:
将a串反转得到
接下来我们观察一下得到的
其中:红框框圈起来的部分便是
2.1.2.对与每一行,我们可以将其看成一个二进制bitset类型。
设第i行的二进制表示是s[i],我们来说一下s[i]的求法。
用第s[1]和s[2]的转化为例:
然后对于第2行的字符b[2]:
我们将
对于每块,我们选取最右侧的1的位置,把
也就是说:
2.1.3.对于每块(块的定义在上面的例子中提到了),我们选取最右侧的1的位置,把s[2] 的这一位赋值成1;
2.2 这种做法的含义:
我们这样做的意义便是尽可能的找到一个1的个数大于等于
我们发现,只有当
2.3 利用二进制运算加速递推M 数组:
然而怎么找每一块中最靠右的1呢?暴力的话复杂度还不如
这时候想到了我们的好朋友:二进制运算;
首先我们通过
然后我们可以想办法把每一块内最右侧的1(包含1)以及后面的0都变成1,块内其余的数都变成0,得到一个bitset类型的tmp。接着将tmp和
那么怎样把每一块内最右侧的1及其后面的0都变成1,块内其余的数都变成0呢?
我们将
对于
对于与
综上所述,得到式子:
时间复杂度是
那么我们可以轻松地写出一个求LCS的题解(没有滚动数组):
#include <bits/stdc++.h>
#define inc(i,a,b) for(register int i=a;i<=b;i++)
#define dec(i,a,b) for(register int i=a;i>=b;i--)
using namespace std;
bitset<20011> s[20001];
bitset<20011> t[26];
char a[20010],b[20010];
int n,m;
void operator-=(bitset<20011> &a,bitset<20011> b) {
unsigned int last = 0;
for (int i=0;i<7000;i++)
if (a[i]>=b[i]+last)
a[i]=a[i]-(b[i]+last),last=0;
else
a[i]=a[i]-(b[i]+last),last=1;
}
int main()
{
scanf("%s %s",a,b);
n=strlen(a); m=strlen(b);
inc(i,0,25){
t[i].reset();
inc(j,0,min(n-1,20000)){
if(a[j]-'a'==i) t[i].set(j,1);
}
}
//cout<<t['G'-'A']
bitset<20011> tmp=t[b[0]-'a'],tmp2,tmp3=tmp,tmp4;tmp2.set(0,1); tmp4.set(0,1);
tmp3-=tmp2;
s[0]=tmp&(tmp3^tmp);
inc(i,1,m-1){
tmp=s[i-1]|t[b[i]-'a'];
tmp3=tmp;
tmp2=(s[i-1]<<1)|tmp4;
tmp3-=tmp2;
s[i]=tmp&(tmp3^tmp);
}
dec(i,m-1,0){
dec(j,n-1,0){
cout<<s[i][j]<<" ";
}
cout<<b[i]<<" "<<i;
cout<<endl;
}
dec(i,n-1,0) cout<<a[i]<<" ";
cout<<endl;
dec(i,n-1,0) cout<<i<<" ";
// cout<<(s[2]|t[1])<<endl;
cout<<(int)(s[m-1].count());
}
/*
acabcabdb
abaabb
*/
(早期码风若毒瘤请见谅);
2.4利用指令集 思想 来优化二进制运算
但是我们发现它不香,因为bitset类型不存在减法(如果你直接写减法编译会错的),所以需要自己写减法;
而且用bitset常数巨大,这比普通的
那怎么办呢?我们想到了指令集(指令集是不让用的啦~,但是可以借鉴它的思想,就是那个把几个数封装成一块的思想);
首先自己定义bitset类型(反正都要自己定义减法再多定义几个运算也无所谓的吧);
我们把若干位封装在一起(不足补0),得到一个不超过long long的二进制数,将其看作十进制正整数进行二进制运算(and,or,xor,<< 等);
让我们来举个例子:
假设bitset类型是:10001011001010。按照3位封装的话,就是010,001,011,001,010,那么新数组就是:2,1,3,1,2。完全可以
然后优化就结束啦~我们成功的优化掉了一个巨大的常数(64);
有人或许觉得二进制常数优化优化不了什么,但这个优化与直接用bitset运算的快慢就如同FFT迭代版与FFT递归版的区别,如同吸了氧气的毒瘤二进制版莫队与普通版莫队的区别;
经实践可知:这种做法完全可以通过
A:又在逗小孩纸了,时间是降下来了,但内存咋办啊?
Q:内存限制不成问题,观察式子发现,
下面放上本人蒟蒻的代码(没有滚动等内存优化,在
#include <bits/stdc++.h>
using namespace std;
const int N = 50010, M = 2200;
int n, m;
char a[N], b[N];
struct BitSet {
unsigned int a[M];
} Ans, X, Y, A[N];
BitSet operator&(BitSet a, BitSet b) {
for (int i = 0; i < M; i++) a.a[i] &= b.a[i];
return a;
}
BitSet operator^(BitSet a, BitSet b) {
for (int i = 0; i < M; i++) a.a[i] ^= b.a[i];
return a;
}
BitSet operator|(BitSet a, BitSet b) {
for (int i = 0; i < M; i++) a.a[i] |= b.a[i];
return a;
}
void operator-=(BitSet &a, BitSet b) {
unsigned int last = 0;
for (int i = 0; i < M; i++)
if (a.a[i] >= b.a[i] + last)
a.a[i] -= b.a[i] + last, last = 0;
else
a.a[i] -= b.a[i] + last, last = 1;
}
void operator<<=(BitSet &a, BitSet b) // a=b*2+1
{
unsigned int last = 1;
for (int i = 0; i < M; i++) {
unsigned int x = b.a[i] >> 31;
a.a[i] = (b.a[i] << 1 | last);
last = x;
}
}
void Insert(BitSet &A, int x) { A.a[x >> 5] |= 1u << (x & 31); }
int count(BitSet A) {
int ans = 0;
for (int i = 0; i < M; i++) ans += __builtin_popcount(A.a[i]);
return ans;
}
int main() {
scanf("%s %s",a+1,b+1);
n=strlen(a+1); m=strlen(b+1);
for (int i = 1; i <= n; i++) {
Insert(A[a[i]-'a'], i);
}
for (int i = 1; i <= m; i++) {
Y <<= Ans;
Ans = Ans | A[b[i]-'a'];
X = Ans;
X -= Y;
Ans = Ans & (Ans ^ X);
for(int j=0;j<=n;j++){
cout<<Ans.a[j]<<" ";
}
cout<<endl;
}
printf("%d\n", count(Ans));
return 0;
}
总结:
或许在省选中乃至NOI、IOI中不会考察LCS这种基础算法,但谁又说得准呢。之前有一年的省选不是还考四边形不等式优化都过不去的石子合并了吗?需要用什么加西亚瓦克斯算法(这是什么鬼???),详见洛谷P5569 [SDOI2008]石子合并
其实更主要的还是这种二进制优化的思想,它可以被应用到很多意想不到的地方。