引入
在多模式匹配时,之前提到的AC自动机能够以很高的效率进行匹配,但是匹配的前提是要知道所有的模式串后建立自动机,再和文本串进行匹配,所以利用自动机的前提是必须知道所有模式串,而在例如搜索引擎中,模式串是未知的,所以这时候就需要对文本串进行处理,从而有了后缀数组。
原理
首先显而易见模式串一定是文本串某个后缀的前缀,也可能就是这个后缀(这里假设模式串是可以匹配到的)。所以对一个文本串,可以将其分解为
s
l
e
n
g
t
h
\;s_{length}
slength个后缀,对每个后缀进行字典序排序。我们作如下处理:
首先定义
s
a
[
i
]
\;sa[i]\;
sa[i]表示排名为
i
\;i\;
i的字符串是从下标为
s
a
[
i
]
\;sa[i]\;
sa[i]开始的后缀。
根据上图不难理解构造出的后缀数组,现在考虑如何实现。
实现
1.朴素算法
首先最容易考虑到的就是将所有后缀一一比对进行排序,这种算法实现会达到
O
(
n
2
)
\;O(n^2)\;
O(n2)的复杂度,所以自然不会考虑。要是可行就没后缀数组什么事了
2.倍增算法
前置姿势:基数排序
首先将所有单个字符排序,得到每个字符的排名,也就是每个后缀的首字符,然后开始倍增,再比较每个后缀的前两个字符、前四个字符
.
.
.
.
.
.
......
......直到倍增的长度大于等于文本串的长度后终止,即如下图所示:
在每次倍增的过程中都会得到一系列的二元组,对这个二元组进行排序,反复下去就可以得到最终的结果。
实现思想并没有什么太多值得推敲的地方,具体如何实现呢,分以下几个步骤:实现比思想难的多啊
1.参数 m \;m\; m表示字符串中所有字符的种类个数,这里设定为所有小写字母。
2.最开始的四个循环就是对每个字符的排序,即初始化工作,同时这里用到了一个辅助数组 t 1 \;t1\; t1来记录 s \;s\; s。
void init(int m)
{
int i;
for (i = 0; i < len; i++)
{
c[t1[i] = s[i] - 'a' + 1]++;
}
for (i = 1; i < m; i++)
{
c[i] += c[i - 1];
}
for (i = len - 1; i >= 0; i--)
{
sa[--c[t1[i]]] = i;
}
}
3.在每次倍增过程中都会形成一系列二元组,根据基数排序的思想,首先要将第二个元素排序,再对第一个元素排序,先实现第二元素排序:
int p = 0;
for (i = len - k; i < len; i++)
{
t2[p++] = i;
}
for (i = 0; i < len; i++)
{
if (sa[i] >= k)
{
t2[p++] = sa[i] - k;
}
}
来分析一下以上代码,首先很明显在倍增的过程中,最后的几个位置在倍增时后面已经没有元素了,这时候要添加
0
\;0\;
0来凑成二元组,那么在排序时这些
0
\;0\;
0自然就会排到前面,这时候用一个辅助数组
t
2
\;t2\;
t2来存储排序的结果,然后将所有补0的二元组排好序后,考虑剩下的如何排序,还是观察图:
不难发现,在倍增后二元组的第二个元素,正是上一个序列中对应位置后的第
k
\;k\;
k个位置,也就是说,第一行中的第
i
\;i\;
i个数字即第二行中第
i
−
k
\;i-k\;
i−k个二元组的第二个元素,所以可以直接根据上一次的排序结果得到本次排序每个元素的位置。
4.在对第二关键字排序结束后就要对第一关键字进行排序,具体实现如下:
for (i = 0; i < m; i++)
{
c[i] = 0;
}
for (i = 0; i < len; i++)
{
c[t1[t2[i]]]++;
}
for (i = 1; i < m; i++)
{
c[i] += c[i - 1];
}
for (i = len - 1; i >= 0; i--)
{
sa[--c[t1[t2[i]]]] = t2[i];
}
重点理解最后一个循环:
首先在前面已经比较出了第二关键字的顺序,存储在 t 2 \;t2\; t2中,记住这里 t 2 [ i ] \;t2[i]\; t2[i]表示的是第二关键字排第 i \;i\; i位的位置,那么 t 2 \;t2\; t2一定是包含 l e n \;len\; len个元素的(即使值相同,排名也有先后),所以第二个循环遍历过程中可以将 t 1 [ t 2 [ i ] ] \;t1[t2[i]]\; t1[t2[i]]其实就是每个位置的第一关键字的个数,将其放在桶中然后累加。
最后一个循环则是难点所在,首先倒序是因为观察等式右边为 t 2 [ i ] \;t2[i]\; t2[i],表示排名为 i \;i\; i的位置,既然是倒序,那么等式其实也就是每次按照二元组第二关键字递减的顺序取的(把握 t 2 \;t2\; t2的含义,是排名为 i \;i\; i的位置,倒序那么就是排名靠后,自然对应的第二关键字就大)。
然后再观察等式左边,逐层来分解,首先 t 1 [ t 2 [ i ] ] \;t1[t2[i]]\; t1[t2[i]]表示的是排名为 i \;i\; i的第二关键字对应的第一关键字,如何理解呢,观察下图其实不难得出,每个二元组的第一关键字正是上一次排序是对应位置所对应的值,那么也就是说 t 2 [ i ] \;t2[i]\; t2[i]所表示的位置和上一次的位置二者的值是一样的,上一个循环中已经将所有元素进行了计数,那么在这里就可以根据 c [ i ] \;c[i]\; c[i]所得到的当前第一关键字的最大位置插入元素(之所以是最大位置前文已经提及,是因为第二关键字是递减的)。
5.接下来是一个优化过程,在每次倍增后,如果所得的名次两两都不相同,那么之后倍增的结果都不会发生变化,这是因为倍增后的二元组第一元素就是上一次的名次,两两不同那么排序后肯定不存在相同的元素。
最终实现代码如下:
void init(int m)
{
int i;
for (i = 0; i < len; i++)
{
c[t1[i] = s[i] - 'a' + 1]++;
}
for (i = 1; i < m; i++)
{
c[i] += c[i - 1];
}
for (i = len - 1; i >= 0; i--)
{
sa[--c[t1[i]]] = i;
}
}
void get_sa(int m)
{
init(m);
int i;
for (int k = 1; k <= len; k <<= 1)
{
int p = 0;
//根据之前的sa数组对第二关键字排序
for (i = len - k; i < len; i++)
{
//在倍增的过程中,最后几个元素倍增时末尾已经没有元素了,所以会补0,这时0一定是排在前面的,所以先将这k-1个元素排序
t2[p++] = i;
}
for (i = 0; i < len; i++)
{
if (sa[i] >= k)
{
//sa[i]-k对应了之前排序的结果
t2[p++] = sa[i] - k;
}
}
//排序第一关键字
for (i = 0; i < m; i++)
{
c[i] = 0;
}
for (i = 0; i < len; i++)
{
c[t1[t2[i]]]++;
}
for (i = 1; i < m; i++)
{
c[i] += c[i - 1];
}
for (i = len - 1; i >= 0; i--)
{
sa[--c[t1[t2[i]]]] = t2[i];
}
swap(t1, t2);
p = 1;
t1[sa[0]] = 0;
//统计不同的二元组有多少个
for (i = 1; i < len; i++)
{
t1[sa[i]] = (t2[sa[i - 1]] == t2[sa[i]] && t2[sa[i - 1] + k] == t2[sa[i] + k]) ? p - 1 : p++;
}
if (p >= len) //如果二元组全部不同之后的排序就没有必要了
{
break;
}
m = p; //更新二元组的种类数
}
}
有了后缀数组后就可以利用二分进行模式匹配了,实现代码如下:
int cmp_suffix(char* p, int x, int l)
{
return strncmp(p, s + sa[x], l);
}
int find(char* p)
{
int Length = strlen(p);
if (cmp_suffix(p, 0, Length) < 0) //如果比字典序最小的还小,则一定找不到
{
return -1;
}
if (cmp_suffix(p, len - 1, Length) > 0) //如果比字典序最大的还大,则一定找不到
{
return -1;
}
int l = 0, r = len - 1;
while (l <= r)
{
int mid = l + (r - l) / 2;
int res = cmp_suffix(p, mid, Length);
if (!res)
{
return mid;
}
if (res < 0)
{
r = mid - 1;
}
else
{
l = mid + 1;
}
}
return -1;
}
然而仅仅有 s a \;sa\; sa来处理问题还是比较困难的,需要引入另外两个数组—— r a n k \;rank\; rank和 h e i g h t \;height\; height,其中 r a n k [ i ] \;rank[i]\; rank[i]表示第 i \;i\; i个字符开始的后缀的排名,即它和 s a [ i ] \;sa[i]\; sa[i]是互逆的运算; h e i g h t [ i ] \;height[i]\; height[i]表示 s a [ i ] \;sa[i]\; sa[i]和 s a [ i − 1 ] \;sa[i-1]\; sa[i−1]的最长公共前缀,这样在对于任意两个后缀的最长公共前缀,就是这一段 h e i g h t \;height\; height的最小值。
那么如何计算 h e i g h t \;height\; height呢,如果只是朴素地枚举去比对,同样需要 O ( n 2 ) \;O(n^2)\; O(n2),这一复杂度都高于构建后缀数组所需的复杂度,所以可以进行如下优化:考虑两个排名相邻的后缀,例如第 k \;k\; k个后缀 a b b . . . x . . . \;abb...x...\; abb...x...和第 i \;i\; i个后缀 a b b . . . y . . . \;abb...y...\; abb...y...,假设在在 x \;x\; x之前的 t \;t\; t个字符都是相同的,那么我们将两个字符的首字母去掉,即可得到第 k + 1 \;k+1\; k+1个后缀 b b . . . x . . . \;bb...x...\; bb...x...和第 i + 1 \;i+1\; i+1个后缀 b b . . . y . . . \;bb...y...\; bb...y...,这时能肯定前 t − 1 \;t-1\; t−1个字符一定还是相等的,所以就不必要再回溯重新比较。
实现如下:
void get_rank()
{
for (int i = 0; i < len; i++)
{
rnk[sa[i]] = i;
}
}
void get_height()
{
get_rank();
int k = 0;
for (int i = 0; i < len; i++)
{
if (k) //比上一次结果少1
{
k--;
}
int j = sa[rnk[i] - 1];
while (s[i + k] == s[j + k])
{
k++;
}
height[rnk[i]] = k;
}
}
完结撒花!!!