浅析后缀数组

引入

在多模式匹配时,之前提到的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\; ik个二元组的第二个元素,所以可以直接根据上一次的排序结果得到本次排序每个元素的位置。

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[i1]的最长公共前缀,这样在对于任意两个后缀的最长公共前缀,就是这一段    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\; t1个字符一定还是相等的,所以就不必要再回溯重新比较。

实现如下:

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;
	}
}

完结撒花!!!

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值