比后缀数组还短的后缀平衡树!
zzwdsj
·
2026-10-03 09:18:10
·
算法·理论
:::info[AI使用声明]{open}
本文使用 GPT 辅助文献检索、算法与代码改进、复杂度证明推导及文章检查。
:::
标题中的后缀数组指用倍增和基数排序实现的 O(n\log n) 的算法。
下文默认读者已经掌握普通的重量平衡树维护后缀平衡树。
前言
只想学算法的人可以跳到算法流程部分。
后缀平衡树的一般实现较为复杂的原因是需要写一棵平衡树。
如果能想办法把平衡树换成 std::set/__gnu_pbds::tree,代码会短很多。
替换难点在于底层的平衡树逻辑被封装在内部,无法进行传统的标号。所以我们需要一个全新的标号方式。
显然我是不会的,于是我把这个问题丢给了 GPT。
GPT 找到了这篇论文:
Paul F. Dietz and Daniel D. Sleator. “Two Algorithms for Maintaining Order in a List.” Proceedings of the 19th Annual ACM Symposium on Theory of Computing (STOC) , 1987, pp. 365–372. PDF。
里面提到的一种算法正好满足要求。
GPT 没找到这个算法的标准译名,暂且叫它平方重标号算法吧。
算法流程
每个节点维护这个节点表示的后缀长度和标号。
先加入一个哨兵节点,长度为 0 ,标号为 0 (实际上哨兵的初始标号是任意的)。
在前面加入一个字符,设新加入的节点为 x 。
设 tag_x 表示 x 的标号,next(x,k) 为平衡树上排在 x 后 k 名的节点。
先把 x 的标号 tag_x 设为 x 的前驱的标号,设前驱的标号为 o 。
因为要处理标号冲突,所以需要给一部分节点重标号。
找到满足 k\ge 1,tag_{next(x,k)}-o> k^2 的最小的 k 。
特别地,如果 k 扫描到末尾仍不满足条件,直接设为 x 与末尾的排名差 +1 。
把 next(x,1),next(x,2),\dots,next(x,k-1) 依次重标号为 o+k,o+2k,\dots,o+(k-1)k 。
把 x 重标号为 o+1 。
正确性证明
只需要证明重标号后标号仍然满足递增。
此时标号是不冲突的。因为当 k>1 时 o+1<o+k ,当 k=1 且后继时 tag_{next(x,k)}-o> k^2=1\iff o+1<tag_{next(x,k)} ,如无后继则无需考虑冲突。
重标号的节点内部显然仍然满足标号递增。
若 k>1 且右边界存在,next(x,k-1) 满足 o+(k-1)k<o+k^2<tag_{next(x,k)} ,k=1 的情况在上面已经讨论。
因此整个序列仍然严格递增。
时间复杂度证明
相邻节点间的正整数间隔 v=tag_{next(x,1)}-tag_x ,定义 \phi(v)=\max(0,\log \frac{n+1}{v}) ,n 为整个操作序列中平衡树中元素个数的最大值。
定义整体势能 \Phi=\sum \phi(g) ,显然非负。
一个引理
先证明一个引理:若正数 g_1,\ldots,g_r 满足
则令 $\rho=\sqrt3/2<1$,有 $\prod_{i=1}^{r}g_i\le \rho^r(r+1)^r$。
:::info[引理的证明]{open}
先考虑 $r$ 为偶数的情况,设 $r=2h$。
$\prod_{i=1}^{r}g_i=\prod_{i=1}^{h}g_i\cdot \prod_{i=h+1}^{2h}g_i$。
对 $\prod_{i=1}^{h}g_i$ 和 $\prod_{i=h+1}^{2h}g_i$ 分别应用均值不等式:
设 $A=\sum_{i=1}^h g_i$,$B=\sum_{i=h+1}^{2h}g_i$。
根据 $\sum_{j=1}^{i}g_j\le i^2(1\le i\le r)$ 有 $A=\sum_{i=1}^h g_i\le h^2$,$A+B=\sum_{i=1}^{2h}g_i\le (2h)^2=4h^2\iff B\le 4h^2-A$。
$\prod_{i=1}^{h}g_i\cdot \prod_{i=h+1}^{2h}g_i\le (\frac{A}{h})^h\cdot(\frac{B}{h})^h\le (\frac{A}{h})^h\cdot(\frac{4h^2-A}{h})^h$。
$(\frac{A}{h})^h\cdot(\frac{4h^2-A}{h})^h=(\frac{A(4h^2-A)}{h^2})^h=(-\frac{A^2}{h^2}+4A)^h$。
二次函数 $f(A)=-\frac{A^2}{h^2}+4A$ 的对称轴为 $2h^2$,故 $f(A)$ 在 $0<A\le h^2$ 单调递增,$-\frac{A^2}{h^2}+4A\le -\frac{h^4}{h^2}+4h^2=3h^2$。
于是 $\prod_{i=1}^{r}g_i\le (3h^2)^h=(\frac{3}{4})^h\cdot (4h^2)^h=(\frac{\sqrt{3}}{2})^{2h}\cdot(2h)^{2h}=\rho^rr^r$。
然后是奇数的情况,设 $r=2h+1$,在序列末尾补上一个 $2r+1$。
$\sum_{i=1}^{r}g_i\le r^2\iff 2r+1+\sum_{i=1}^{r}g_i\le r^2+2r+1=(r+1)^2$,满足条件。
应用偶数的情况得到 $(2r+1)\prod_{i=1}^{r}g_i\le \rho^{r+1}(r+1)^{(r+1)}$。
两边同时除以 $2r+1$:$\prod_{i=1}^{r}g_i\le \rho^r(r+1)^r\cdot\frac{\rho(r+1)}{2r+1}$。因为 $\rho<1$ 且 $r+1\le2r+1$,所以 $\frac{\rho(r+1)}{2r+1}<1$,得到 $\prod_{i=1}^{r}g_i\le \rho^r(r+1)^r\cdot\frac{\rho(r+1)}{2r+1}\le \rho^r(r+1)^r$。
偶数的情况 $\prod_{i=1}^{r}g_i\le \rho^rr^r$,同样满足 $\le \rho^r(r+1)^r$。
:::
#### 势能变化
下面先只讨论 $k>1$ 且右边界存在的情况,其他边界情况需要额外讨论。
设重标号前 $x$ 往后的 $k$ 个间隔为 $g_1,g_2,\dots,g_k$。其中 $g_1$ 是前驱到第一个旧后继的间隙,后续 $g_i$ 是相邻旧后继之间的间隙。如果扫描到末尾则 $g_k$ 不存在。
势能仅在插入前与重标号完成后计算,不在临时标签重复时计算。
根据间隔的定义和算法流程有 $\sum_{j=1}^i g_j=tag_{next(x,i)}-o\le i^2(1\le i <k)$。
根据引理,$\prod_{i=1}^{k-1} g_i \le \rho^{k-1}k^{k-1}$。
##### 内部势能
记 $\Phi_{I}=\sum_{i=1}^{k-1}\phi(g_i)\ge\sum_{i=1}^{k-1}\log\frac{n+1}{g_i}=(k-1)\log (n+1)-\log(\prod_{i=1}^{k-1}g_i)$,这是被重标号的节点内部的旧势能。
重标号后,节点间的间隔为:
$$
\underbrace{1,\ k-1,\ \overbrace{k,\ldots,k}^{k-2\text{ 个} k}}_{k\text{ 个内部间隙}},
$$
根据上面的推导被重标号的节点内部的新势能为 $\Phi'_{I}=k\log(n+1)-\log(\prod_{i=1}^{k}g'_i)=k\log(n+1)-\log(1\cdot(k-1)\cdot k^{k-2})$(因为所有新内部间隙都不超过 $k\le n+1$,所以它们的势能表达式中的 max 可以去掉。)。
相减得到势能变化
$$
\begin{aligned}
\Delta \Phi_I &= \Phi'_I-\Phi_I\\
&\le k\log(n+1)-\log(1\cdot(k-1)\cdot k^{k-2})-(k-1)\log (n+1)+\log\left(\prod_{i=1}^{k-1}g_i\right)\\
&= \log(n+1)+\log\frac{\prod_{i=1}^{k-1}g_i}{(k-1)k^{k-2}}\\
&\le \log(n+1)+\log\frac{\rho^{k-1}k^{k-1}}{(k-1)k^{k-2}}\\
&= \log(n+1)+(k-1)\log \rho+(k-1)\log k-\log(k-1)-(k-2)\log k\\
&= \log(n+1)+(k-1)\log \rho+\log k-\log(k-1)
\end{aligned}
$$
##### 边界势能
接下来计算被重标号的节点与左右边界的势能变化。设 $d=tag_{next(x,k)}-o$。
左边的间隔在上面算过了,旧、新右边界间隙分别为 $R=d-\sum_{i=1}^{k-1}g_i$ 和 $R'=d-(k-1)k$。
$R=d-\sum_{i=1}^{k-1}g_i\ge d-(k-1)^2\ge d-(k-1)k=R'$。
$\Delta \Phi_E=\max(0,\log\frac{n+1}{R'})-\max(0,\log\frac{n+1}{R})\le\log\frac{R}{R'}$(在 $R'\le R$ 时,$\max$ 要么把 $\log\frac{n+1}{R}$ 从负数变为 $0$,要么两个都变为 $0$,不会让差变大)。
$R'-\frac{R}{k}\ge R'-\frac{d}{k}=d-(k-1)k-\frac{d}{k}=(k-1)(\frac{d}{k}-k)\ge 0$。
因此 $R'\ge \frac{R}{k}\implies \frac{R}{R'}\le k$。
故 $\Delta \Phi_E\le \log\frac{R}{R'}\le \log k$。
##### 合并两部分势能
令 $c=-\log\rho=\log\frac2{\sqrt3}>0$。
总势能变化 $\Delta\Phi\le\log(n+1)-c(k-1)+\log k-\log(k-1)+\log k=\log(n+1)-c(k-1)+\log\frac{k}{k-1}+\log k$。
$k>1$ 时,$\frac{k}{k-1}\le 2$,所以 $\log(n+1)-c(k-1)+\log\frac{k}{k-1}+\log k\le \log(n+1)-c(k-1)+\log2+\log k=\log(n+1)-c(k-1)+\log2k$。
因为 $\log(2k)=o(k)$,存在固定常数 $K$,使得当 $k\ge K$ 时,$\log(2k)\le ck/2$。因此 $-c(k-1)+\log(2k)\le-\frac c2k+c$。
对于 $2\le k<K$ 的有限种情况,差额也可以由一个固定常数吸收。因此 $\Delta\Phi\le\log(n+1)-\frac{c}{2}k+O(1)$。
#### 边界情况的势能变化
若 $k=1$ 且没有扫描到末尾(后继存在),$\Delta\Phi=\phi(1)+\phi(d-1)-\phi(d)\le \log(n+1)+\log\frac{d}{d-1}\le \log(n+1)+\log 2$。
若 $k=1$ 且扫描到末尾,势能变化为 $\phi(1)=\log(n+1)$。
若 $k>1$ 且扫描到末尾,没有右边界间隙,只计算内部部分即可,上界仍成立。
#### 删除的势能变化
如果有后继,设要被删除的节点删除前左右间隙的长度为 $g_1,g_2$。
势能变化为 $\phi(g_1+g_2)-\phi(g_1)-\phi(g_2)$。
$\phi$ 非负且单调递减,所以 $\phi(g_1+g_2)\le \phi(g_1)\le \phi(g_1)+\phi(g_2)$。
如果没有后继,设删除的节点删除前左边的间隙为 $g_1$,产生的势能变化为 $-\phi(g_1)\le 0$。
势能不增加,不会产生额外代价,直接按照平衡树删除的 $O(\log n)$ 计算。
#### 时间复杂度
若实际扫描和重标号费用不超过 $Ak$,把势能乘上足够大的常数 $B$,使 $Bc/2\ge A$,有
\text{实际费用}+B\Delta\Phi\le Ak+B\left(\log(n+1)+O(1)-\frac c2k\right)\le B\bigl(\log(n+1)+O(1)\bigr)=O(\log(n+1))
只有哨兵的空集合势能为零。对 $m$ 次操作的摊还费用求和,由最终势能非负,得到扫描和重标号总费用为 $O(m\log(n+1))$。再加上平衡树每次 $O(\log(n+1))$ 的插入、删除费用,总复杂度仍为 $O(m\log(n+1))$。
### 标号范围
容易看出,最大标号的增长是比较快的,因此有必要分析一下标号的范围。
设整个操作序列中平衡树中元素个数的最大值为 $n$,总插入次数为 $m$。
每次产生的标号最大为 $\max(o+1,o+(k-1)k)$,而 $k\le n$,所以每次插入最大标号的增加量不超过 $n^2$。
累计 $m$ 次插入后最大标号增加量不超过 $mn^2$。
最大标号不超过哨兵的初始标号加 $mn^2$,哨兵的初始标号取 $0$ 时,最大标号不超过 $mn^2$。
对于一般 $n,m\le10^6$ 的数据来说 `long long` 足够,保险一点可以开 `__int128`。
### 代码
:::info[P3809 【模板】后缀排序]
```cpp
#include<bits/stdc++.h>
using namespace std;
const int MAXN=1000005;
typedef long long ll;
string s;
ll tag[MAXN];
bool cmp(int x,int y){return s[x]!=s[y]?s[x]<s[y]:tag[x+1]<tag[y+1];}
set<int,decltype(cmp)*>t({0},cmp);
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>s;
int n=s.size();s="*"+s;
for(int i=n;i>=1;i--)
{
auto x=t.insert(i).first,y=next(x);
ll o=tag[*prev(x)];int k=1;
while(y!=t.end()&&tag[*y]-o<=(ll)k*k)y++,k++;
tag[i]=o+1;
for(auto j=next(x);j!=y;j++)o+=k,tag[*j]=o;
}
for(int i:t)if(i)cout<<i<<" ";//if(i)用于跳过哨兵
return 0;
}
```
:::
:::info[P6164 【模板】后缀平衡树]
用一个虚拟的 $-1$ 节点表示查询串。
```cpp
#include<bits/stdc++.h>
#include<ext/pb_ds/assoc_container.hpp>
#include<ext/pb_ds/tree_policy.hpp>
using namespace std;
using namespace __gnu_pbds;
const int MAXN=800005;
typedef long long ll;
string s="*",v;
ll tag[MAXN];
bool cmp(int x,int y)
{
if(x==y)return false;
if(x>=0&&y>=0)return s[x]!=s[y]?s[x]<s[y]:tag[x-1]<tag[y-1];
int i=max(x,y),j{};
while(i&&s[i]==v[j])i--,j++;//后缀平衡树维护的是原串的反转
return x<0?v[j]<s[i]:s[i]<v[j];
}
tree<int,null_type,function<bool(int,int)>,rb_tree_tag,tree_order_statistics_node_update>t(cmp);
void add(char c)
{
int i=s.size();s+=c;
auto x=t.insert(i).first,y=next(x);
ll o=tag[*prev(x)];int k=1;
while(y!=t.end()&&tag[*y]-o<=(ll)k*k)y++,k++;
tag[i]=o+1;
for(auto j=next(x);j!=y;j++)o+=k,tag[*j]=o;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int q{},msk{};cin>>q;
cin>>v,t.insert(0);
for(char i:v)add(i);
while(q--)
{
string op{};cin>>op;
if(op[0]=='D')
{
int x{};cin>>x;
while(x--)t.erase(s.size()-1),s.pop_back();
}
else
{
cin>>v;
for(int i=0,j=msk;i<v.size();i++)j=(j*131+i)%v.size(),swap(v[i],v[j]);
if(op[0]=='A')for(char i:v)add(i);
if(op[0]=='Q')
{
reverse(v.begin(),v.end());
int ans=t.order_of_key(-1);
v+='[';
ans=t.order_of_key(-1)-ans;
cout<<ans<<'\n',msk^=ans;
}
}
}
return 0;
}
```
:::