SAM 压缩空间

本文采用的是使用 SAM 求 SA。

若您对 SAM 感兴趣,这里安利这篇文章

虽然该题题解中确实有几篇提到过如何使用 SAM 求 SA,但是均不能在 \(128MB\) 的限制下通过此题。

所以本文着重介绍两点:

\(1\)° 使用 SAM 求后缀排序

\(2\)° 空 间 压 缩

其中,第二点无论是在 SA 的题解中,还是在 SAM 的题解中,均没有详细地讲过空间压缩这一问题。

Part 1


根据 SAM 的性质,我们建出来的自动机的 fa 边构成一颗 parents 树,这棵树的叶子节点即为字符串前缀。

所以很自然地,我们可以倒着插入字符串,这样可构造 parents 树即为后缀树。

所以求后缀排序已经被转化为了在后缀树上求按字典序遍历顺序后叶子节点访问顺序。

但是我们又发现一个问题,我们在后缀树上遍历节点的时候,最多有 \(26\) 个子节点连向它,那么我们如何知道走哪条边才是按字典序遍历呢

我们可以在建 SAM 时对每个节点记一个 Right,即在原串的位置。那么通过 Right 与其父节点的 maxlen 就能求出该节点由父亲指向自己的首字符。

然后按照类似拓扑序那样的套路,开个桶然后丢进去排序即可。

贴一下求首字符的函数

for(int i=1;i<=cnt;i++) g[i] = Get(c[n + 1 - Right[i] + node[node[i].fa].len]);

然后在 SAM 上 dfs 时,对叶子的遍历顺序即为后缀排序。

Part 2


从理论上来说

SAM 的状态数最多有 \(2n -1\) 个, 而转移数则有 \(3n-4\)

均是 \(O(n)\) 级别

但实际空间真有这么优吗?按上面的思路交上去 MLE。

仔细算一波空间

\[2\times \left \vert \sum \right \vert \times10^6 \approx 473MB \]

很明显爆空间了。

于是我们现在有 2 种解决方案

\(1\)° 使用 STL

\(2\)° 不使用 STL


对于使用STL的方案,有三种容器供我们挑选。

map、unordered_map、set

其中经过我的实验发现:

map、unordered_map 都不能通过该题,在空间与时间方面均被卡死。另外,unordered_map 常数大的离谱……

set 我并没有试过,但想到其与 map 内部实现本质相同,应该也难以通过此题。

所以,如果在空间较紧的情况下,不推荐使用 STL。


对于不使用 STL 的方案,我们又有 2 种子方案

第一种是手写一棵支持插入、删除、查询、查询是存在等操作的平衡树(应该不会有人真的手写一棵平衡树吧

时间复杂度 \(O(n\log\left\vert\sum\right\vert)\)

第二种是手写哈希表

相比第一种来说哈希更加好写,时间上更有保证(受不同哈希方法的影响

这里展示两种哈希表,一种是借鉴duyi的随机探测哈希表,另一种是我手写的哈希挂链。

时间上,前者明显优于后者。从空间上,后者比前者更优。

namespace Hash{//随机探测
//不过这个方法好像要调用__builtin_ctz()这个函数来卡常 , 双下划线函数
	const unsigned MOD = 19260817;
	const unsigned STP = 9000011;
	struct Node{
		unsigned val;
		unsigned rea;
	}hs[MOD + 10];
	inline unsigned &mk(int x,int y)
	{
		register unsigned real(26 * x + y), i(x) , stp(((real%STP) * 2) ^ 1);
		for(;hs[i].rea&&hs[i].rea!=real;i = i + stp, i -= (i >= MOD) ? MOD : 0)
		if(hs[i].rea == real) return hs[i].val;
		hs[i].rea = real;
		return hs[i].val;
	} 
}

单次查询接近 \(O(1)\)

namespace Hash{//挂链法
	unsigned head[np * 2];
	unsigned Nxt[np*3];
	unsigned val[np*3];
	unsigned rea[np*3];
	int Tit;
	
	inline unsigned &mk(unsigned x,unsigned y)
	{
		for(int i=head[x];i;i = Nxt[i])
		{
			if(rea[i] == y)
			{
				return val[i];
			 } 
		}
		Tit++;
		Nxt[Tit] = head[x];
		head[x] = Tit;
		rea[Tit] = y;
		return val[Tit];		
	}
	
	inline void Memcpy(int x,int y)//这样复制常数更小
	{
		for(int i=head[x] ;i ;i=Nxt[i])
		{
			++Tit;
			Nxt[Tit] = head[y];
			head[y] = Tit;
			val[Tit] = val[i];
			rea[Tit] = rea[i];
		}
	}
}

单次查询为 \(O(\left\vert\sum\right\vert)\)

综上所述,若空间压得紧,推荐使用哈希挂链法。

这里还有个卡常小技巧,对于在 SAM 上跳 fa 边检查 \(son[x]\) 是否存在时,没必要每次都访问你所维护的数据结构,而是在该节点开一个 \(64\) 位变量,用二进制压缩的方法用位运算查看每种转移边是否存在。

Code

#include<bits/stdc++.h>

using namespace std;

#define INF 1ll<<30
#define ill long long
#define sto set<node>::iterator
#define ri register int

template<typename _T>
inline void read(_T &x)
{
	x=0;char s=getchar();int f=1;
	while(s<'0'||s>'9') {f=1;if(s=='-')f=-1;s=getchar();}
	while('0'<=s&&s<='9'){x=(x<<3)+(x<<1)+s-'0';s=getchar();}
	x*=f;
}

int Get(char ch)
{
    if (ch<='9' && ch>='0') return ch-'0';
    if (ch<='Z' && ch>='A') return ch-'A'+10;
    if (ch<='z' && ch>='a') return ch-'a'+36;
    return ch;
}
const int np = 1e6+5;

namespace Hash{ /* 这里有一个卡常小技巧 , 链表的访问内存不连续,对Cache极不友好,常数会变得巨大
如果TLE了请试试vector,可能更快
*/
	unsigned head[np * 2];
	unsigned Nxt[np*3];
	unsigned val[np*3];
	unsigned rea[np*3];
	int Tit;
	
	inline unsigned &mk(unsigned x,unsigned y) // 传址函数 , 在函数外可直接修改该地址上的值
	{
		for(int i=head[x];i;i = Nxt[i])
		{
			if(rea[i] == y)
			{
				return val[i];
			 } 
		}
		Tit++;
		Nxt[Tit] = head[x];
		head[x] = Tit;
		rea[Tit] = y;
		return val[Tit];		
	}
	
	inline void Memcpy(int x,int y)
	{
		for(int i=head[x] ;i ;i=Nxt[i])
		{
			++Tit;
			Nxt[Tit] = head[y];
			head[y] = Tit;
			val[Tit] = val[i];
			rea[Tit] = rea[i];
		}
	}
}

struct SAM{
	int fa;
	int len;
	int pos;
}node[np * 2];

int cnt = 1 , la = 1,n;
int Right[np << 1];
ill g[np << 1];//压缩是否存在

inline void add(int x,int i)
{
	ri k,p,q,now;
	p = la , now = la = ++cnt ;
	Right[now] = node[now].len = node[p].len + 1,node[now].pos = i;
	for( ;p &&!((g[p]>>x)&1ll);g[p] += (1ll<<x),Hash::mk(p,x)=now, p = node[p].fa );
	if(!p) return (void)(node[now].fa = 1);
	if(node[p].len + 1 == node[q = Hash::mk(p,x)].len) return (void)(node[now].fa = q);
	node[k = ++cnt].len = node[p].len + 1, node[k].fa = node[q].fa , node[q].fa = node[now].fa = k ,g[k] = g[q] ;
	Right[k] = Right[q];
	Hash::Memcpy(q,k);
	for(;p && !(Hash::mk(p,x)^q) ;Hash::mk(p,x) = k , p = node[p].fa);	
}

int bac[256 + 10];
int tit;
char c[np];

inline void Add(int a,int b)
{
	Hash::rea[++tit] = b;
	Hash::Nxt[tit] = Hash::head[a];
	Hash::head[a] = tit;
}

inline void solve()
{
	for(int i=1;i<=cnt;i++) g[i] = Get(c[n + 1 - Right[i] + node[node[i].fa].len]) , bac[g[i]]++;
	for(int i=1;i<=256;i++) bac[i]+= bac[i-1];
	for(int i=1;i<=cnt;i++) Right[bac[g[i]]--] = i;
	for(int i=cnt;i>=1;i--) Add(node[Right[i]].fa , Right[i]);
}

inline void dfs(int x)
{
	if(node[x].pos)	printf("%d " , node[x].pos);
	for(int i=Hash::head[x];i;i=Hash::Nxt[i])
	dfs(Hash::rea[i]);
}

signed  main()
{
	scanf("%s",c+1);
	n = strlen(c+1);
	for(int i=n;i>=1;i--) add(Get(c[i]) , i);
	for(int i=1;i<=cnt;i++) Hash::head[i] = 0;
	solve();
	dfs(1);
	
 }

可以看见即使是写了哈希表,还是需要利用重复数组才勉强卡过 \(128MB\),并且在苟过 \(128MB\) 之后,可扩展性几乎为 \(0\)

所以在 \(128MB\) 下,请慎用 SAM 。当然了,空间为 \(256MB\) 时还是可以随便用的。

时间复杂度 \(O(n\left\vert\sum\right\vert)\)

\(End\)

虽然这是 SA 板子的题解,但为了展示压缩空间的重要性和优越性,我还是选择了在这里讲解 SAM 压缩空间技巧。

在 SAM 题解中,几乎所有题解都认为开 map 是可以将 SAM 大量冗余空间优化掉的,所以并没有人认真讲有关空间压缩的问题……

所以才有了这篇题解。

参考资料:

远航之曲博客

duyi的 AC 自动机(二次加强版)提交记录(不贴链接了,如果没过的话也看不了

posted @ 2021-09-28 21:55  ·Iris  阅读(188)  评论(0编辑  收藏  举报