字符串无穷幂比较的简单应用
stripe_python · · 算法·理论
1. 前置约定
-
- 字符串下标从
0 开始,S[i] 表示S 的第i 个字符,其中0 \le i < |S| 。 -
- 字符串的实际内容使用
texttt字体,如S=\tt{hello world} 。 -
- $A^n$ 表示 $A$ 自身拼接 $n$ 次。如 $A=\tt{ab}$,则 $A^2=\tt{abab}$。 - $A^{\infty}$ 表示 $A$ 自身拼接无穷次。如 $A=\tt{ba}$,则 $A^\infty=\tt{bababa\cdots} -
2. 周期引理
2.1 周期定义
对于字符串
- 例如对于字符串
S=\tt{ababa} ,其长度为5 。- 其周期可以为
2 ,因为S[0]=S[2], S[1]=S[3] 。 - 注意,字符串的周期并不需要整除字符串的长度。
- 其周期可以为
根据周期的定义,
2.2 引理内容
Fine-Wilf 定理:
对于字符串
S ,其同时拥有两个不同周期p,q 。
若|S| \ge p+q-\gcd(p,q) ,则\gcd(p,q) 也是S 的周期。
先来感性理解一下,考虑
该定理说明,当
-
事实上,当
|S|=11 时,可以构造反例S=\tt{abaababaaba} 。读者可以自行验证。 -
当
|S|=12 时,周期性给出新的约束条件:- 根据
p=5 得S[11]=S[6] 。 - 根据
p=8 得S[11]=S[3] 。 - 此时,
S[3]=S[6] ,上述反例自然不可行。
- 根据
-
通过上面的例子,我们确实感受到
|S| 具有一个下界。
本文中使用 Fine-Wilf 定理的弱化版本:
引理 1:
对于字符串
S ,其同时拥有两个不同周期p,q 。
若|S| \ge p+q ,则\gcd(p,q) 也是S 的周期。
这一结论称作弱周期定理。
:::::success[引理 1 的证明]
不妨设
即证
- 当
i \ge q 时:- 由
q 是周期,S[i]=S[i-q] 。 - 由
p 是周期,S[i-q]=S[i-q+p] 。 - 得
S[i]=S[i+p-q] 。
- 由
- 当
i<q 时:- 由
|S| \ge p+q 且i<q ,i+p <p+q \le |S| 。 - 由
p 是周期,S[i]=S[i+p] 。 - 由
q 是周期,S[i]=S[i+p-q] 。
- 由
下证
因为
- 令
p \gets p-q 。 - 若
p<q ,则交换p,q ,否则不操作。 - 上述过程所得的
p,q 均为S 的周期。
当
:::::
3. 无穷次幂比较
引理 2:
比较
A^{\infty} 与B^{\infty} ,等价于比较AB 与BA 。
还是先直观感受一下。我们声称,
考虑
-
-
B^{\infty}=\tt{aaaaaa\cdots}。 - 其发生“首次不同”的位置为索引
1 。
对于
-
-
- 其发生“首次不同”的位置为索引
1 。
对于引理 2,给出一个感性理解:
对于字典序比较,平凡的方法是用一个指针顺序扫描
- 对于
AB :- 如果还没有扫描完
A ,则其原本就是A 的前缀。 - 如果已经完成
A 的扫描,此时指针“借用”了B 的字符。但是由于A 的扫描已经完成,说明A 本身就是 LCP,“借用”B 的字符等效于使用A 的字符。
- 如果还没有扫描完
- 对于
BA 同理。
:::::success[引理 2 的证明]
若
下面假定
设
不妨设
下证
假设
因为
而
这说明
此时
下证
即
- 当
i < |A| 时,(AB)[i]=A[i] 且A^{\infty}[i]=A[i] ,显然相等。 - 当
|A| \le i \le k 时:- 此时
(AB)[i]=B\left[i-|A|\right]=B^{\infty} \left[i-|A|\right] 。 - 因为
i \le k ,所以i-|A|<k 。根据性质 1,B^{\infty}[i-|A|]=A^{\infty} [i-|A|] 。 -
- 传递得
(AB)[i]=B^{\infty} \left[i-|A|\right]=A^{\infty} [i-|A|]=A^{\infty}[i] 。
- 此时
同理可得
至此,得到结论:
:::::
引理 3:
若
A^{\infty} \ne B^{\infty} ,则\operatorname{LCP}(A^{\infty},B^{\infty})=\operatorname{LCP}(AB,BA) 。
可以直接由引理 2 的证明过程得到。
引理 4:
比较
A 与B^{\infty} ,等价于比较A 与BA 。
:::::success[引理 4 的证明]
显然
- 若
A 是B^{\infty} 的前缀:- 此时
A<B^{\infty} 。 - 设
B^{\infty}=AC ,则B^{\infty}=BB^{\infty}=BAC ,得BA 是B^{\infty} 的前缀。 - 因为
|A|<|BA| ,所以A<BA 。
- 此时
- 若
A 不是B^{\infty} 的前缀:- 设
\operatorname{LCP}(A,B^{\infty})=k ,根据定义其满足如下两个性质: -
-
- 因为
\operatorname{LCP}(A,B^{\infty})=k ,所以\operatorname{LCP}(BA,B^{\infty})=|B|+k 。 - 因为
|B| \ge 1 ,所以|B|+k \ge k+1 ,所以BA 的前k+1 个字符一定与B^{\infty} 相同。即\operatorname{LCP}(A,BA)=k 。 - 因此
A<B^{\infty} \iff A[k]<BA[k] \iff A<BA 。 :::::
- 设
4. 应用
4.1 直接应用
如果要比较
根据引理 2,我们可以直接比较
类似地,在某些模拟题中,我们可能需要比较两个循环小数的大小。对于循环节,可以使用这种方法比较。
4.2 拼数
题面见 P1012 [NOIP 1998 提高组] 拼数。
定义字符串集合上的二元关系
下证
设字符串
根据引理 2,
因此,
4.3 树的最小表示
给出一棵有根树,点有点权,求字典序最小的 DFS 序列。
考虑对于点
这里我们需要按照 4.2 中定义的
使用链表来存储序列,瓶颈仅在于排序。直观感受是复杂度为
:::::success[
假定采用随机快速排序,最终排序完成后排名为
因此,节点
将代价拆到
对于一个固定的
所以
考虑树中的节点
设
由于每个节点的贡献都不超过
:::::info[
const int N = 1e6 + 5;
int n, a[N];
vector<int> e[N];
struct node {
int val;
node* nxt;
node(int v) : val(v), nxt(nullptr) {}
};
node *h[N], *t[N];
void dfs(int u, int fa) {
vector<int> vec;
for (int v : e[u]) {
if (v != fa) dfs(v, u), vec.emplace_back(v);
}
sort(vec.begin(), vec.end(), [&](int x, int y) -> bool {
node *a = h[x], *b = h[y];
bool fx = 0, fy = 0;
while (a && b) {
if (a->val != b->val) return a->val < b->val;
a = a->nxt, b = b->nxt;
if (!a && !fx) a = h[y], fx = 1;
if (!b && !fy) b = h[x], fy = 1;
}
return false;
});
h[u] = t[u] = new node(a[u]);
for (int v : vec) t[u]->nxt = h[v], t[u] = t[v];
}
void _main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1, u, v; i < n; i++) {
cin >> u >> v;
e[u].emplace_back(v), e[v].emplace_back(u);
}
dfs(1, -1);
for (node *i = h[1]; i; i = i->nxt) cout << i->val << ' ';
}
:::::
树的最小表示的一个应用是判断树同构。注意到对于同构的树,其括号序最小表示也是相同的,可以得到一个
通过压位可以做到
4.4 ARC175F
题面见 AT_arc175_f [ARC175F] Append Same Characters。
:::::success[题解]
两个操作的顺序显然没有什么关系,不妨先进行完所有操作一再进行操作二。而操作二就是冒泡排序,其操作次数等于逆序对数。因此本题表述为:
给出
下面分析复杂度时,视
直接对着逆序对数分析很难,考虑
- 若
|\operatorname{LCP}(A,B)|<\min(|A|,|B|) ,显然AX<AY 。 - 若
|\operatorname{LCP}(A,B)|=\min(|A|,|B|) ,此时A 是B 的前缀。设B=AC 。则AX > BX \iff AX > ACX \iff X>CX 。
下面先对
根据引理 4,我们有
现在我们需要找出所有的
依次枚举
讨论
- 对于
i<j 的所有A ,X 跨过C^{\infty} 使得逆序对数 +1。 - 对于
i>j 的所有A ,X 跨过C^{\infty} 使得逆序对数 -1。
这个做法也说明本质不同的
因此,上述两种情形可以统一成一种。在
将所有
对所有
现在整个问题只差最后一步:求满足
记
- 若
|X|\le l ,为了让X>Y_k^{\infty} ,我们只能将前l 个字符中的一些改大,但是这样会导致X>Y_{k+1}^{\infty} ,不符合题意。 - 若
|X|>l :- 因为
Y_k^{\infty} < Y_{k+1}^{\infty} ,所以Y_k^{\infty}[l]<Y_{k+1}^{\infty}[l] \le \tt{z} \implies Y_k^{\infty}[l] \ne \tt{z} 。 - 可以将前
l 个字符复制下来,并且将第l+1 位后移一位。这个构造取到了|X|_{\min}=l+1 。
- 因为
在扫描线的最后,当
- 如果其形如
Y^{\infty}_{\max}=\tt{zzz\cdots a \cdots } ,可以构造X=\tt{zzz\cdots z b} ,即求出前缀\tt{z} 的长度后加一。 - 如果其形如
Y^{\infty}_{\max}=\tt{zzz\cdots } ,此时\nexists X, X>Y^{\infty}_{\max} ,故直接忽略此情况。
代码用到的唯一字符串算法是哈希,复杂度
理清所有思路后码量虽然大,但并不难写。 :::::
:::::info[代码]
const int N = 3e5 + 5;
const u64 base = 13331;
int n, id[N];
u64 b[N];
string s[N];
vector<u64> h[N];
umap<u64, vector<int>> mp;
u64 sub(int i, int l, int r) {return h[i][r] - h[i][l - 1] * b[r - l + 1];}
i64 merge(int l, int r) { // 求原逆序对数
if (l == r) return 0;
int mid = (l + r) >> 1;
i64 res = merge(l, mid) + merge(mid + 1, r);
vector<int> vec;
auto cmp = [&](int x, int y) -> bool {
int n = s[x].size() - 1, m = s[y].size() - 1;
int l = 1, r = min(n, m), lcp = 0;
while (l <= r) {
int mid = (l + r) >> 1;
if (h[x][mid] == h[y][mid]) l = mid + 1, lcp = mid;
else r = mid - 1;
}
if (lcp == min(n, m)) return n <= m;
return s[x][lcp + 1] <= s[y][lcp + 1];
};
int i = l, j = mid + 1;
while (i <= mid && j <= r) {
if (cmp(id[i], id[j])) vec.emplace_back(id[i++]);
else vec.emplace_back(id[j++]), res += mid - i + 1;
}
while (i <= mid) vec.emplace_back(id[i++]);
while (j <= r) vec.emplace_back(id[j++]);
copy(vec.begin(), vec.end(), id + l);
return res;
}
struct node {
int id, st, len; i64 val;
node(int a = 0, int b = 0, int c = 0, i64 d = 0) : id(a), st(b), len(c), val(d) {}
};
int lcp(const node& u, const node& v) {
int n = u.len, m = v.len, l = 1, r = n + m, lcp = 0;
while (l <= r) {
int mid = (l + r) >> 1;
u64 x = mid <= n ? sub(u.id, u.st, u.st + mid - 1) : sub(u.id, u.st, u.st + n - 1) * b[mid - n] + sub(v.id, v.st, v.st + mid - n - 1);
u64 y = mid <= m ? sub(v.id, v.st, v.st + mid - 1) : sub(v.id, v.st, v.st + m - 1) * b[mid - m] + sub(u.id, u.st, u.st + mid - m - 1);
if (x == y) l = mid + 1, lcp = mid;
else r = mid - 1;
} return lcp;
}
void _main() {
b[0] = 1;
for (int i = 1; i < N; i++) b[i] = b[i - 1] * base;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> s[i], s[i] = ' ' + s[i];
int m = s[i].size() - 1;
h[i].resize(m + 5, 0);
for (int j = 1; j <= m; j++) h[i][j] = h[i][j - 1] * b[1] + s[i][j];
mp[h[i][m]].emplace_back(i);
}
iota(id + 1, id + n + 1, 1);
i64 inv = merge(1, n);
// 处理出所有 C^inf 排序后去重
vector<node> inf, vec;
for (int i = 1; i <= n; i++) {
int m = s[i].size() - 1;
for (int k = 1; k < m; k++) {
if (mp.find(h[i][k]) == mp.end()) continue;
auto& v = mp[h[i][k]];
int a = lower_bound(v.begin(), v.end(), i) - v.begin();
int b = upper_bound(v.begin(), v.end(), i) - v.begin();
inf.emplace_back(i, k + 1, m - k, a - (v.size() - b));
}
}
stable_sort(inf.begin(), inf.end(), [&](const node& u, const node& v) -> bool {
int n = u.len, m = v.len, p = lcp(u, v);
if (p == n + m) return false;
char x = p < n ? s[u.id][u.st + p] : s[v.id][v.st + p - n];
char y = p < m ? s[v.id][v.st + p] : s[u.id][u.st + p - m];
return x < y;
});
for (auto& u : inf) {
if (vec.empty()) vec.emplace_back(u);
else if (lcp(vec.back(), u) == vec.back().len + u.len) vec.back().val += u.val;
else vec.emplace_back(u);
}
// 扫描线
int len = vec.size();
i64 res = 0, cur = 0;
for (int k = 0; k < len - 1; k++) {
cur += vec[k].val;
res = min(res, cur + lcp(vec[k], vec[k + 1]) + 1);
}
auto corner = [&]() -> void {
if (vec.empty()) return;
auto [id, st, len, val] = vec.back(); cur += val;
int pre = 0;
while (pre < len && s[id][st + pre] == 'z') pre++;
if (pre >= len) return;
res = min(res, cur + pre + 1);
};
corner(), cout << inv + res;
}
:::::
参考资料
- 4.2 题解:P1012 [NOIP 1998 提高组] 拼数——_Vector_。
- 4.3 复杂度分析:O(n log n)以及O(n)的最小表示法——hqztrue。