浅谈一些基于规模翻倍的对数复杂度算法
前言
你是否有过一些类似的疑惑:
- 为什么并查集按照大小合并后能优化到
O(\log(n)) ? - 重链剖分为什么复杂度是
O(\log(n)) ?或者说为什么按照重儿子剖分就能比随便找个儿子优化这么多呢? - DSU on tree 感觉非常的暴力,轻儿子加起来这么多,不断地暴力它们为什么还能有
O(n\log(n)) 的复杂度呢?
那么在此篇文章,我将介绍这些算法的时间复杂度分析的共通之处以及它们的扩展应用。
启发式合并(小并大)
提到启发式合并,它可能会代表两类算法:
- DSU on tree;
- 并查集的按大小 / 按秩合并。
此处我们讨论类似并查集按大小合并的“小并大(Small-to-Large Merge)”合并方式。顾名思义,它的做法是合并两个集合的时候,让小的集合合并到大的集合。这个算法描述起来很简单,但是复杂度却能直接从暴力降到对数级别,而且用途极广,我将使用几个例子来展示它的复杂度分析以及用途。
顺带一提:其实我个人认为“小并大”这个名字比“启发式合并”更好一点。因为前者展示出了具体的合并方式,而后者又经常用来指代 DSU on tree,导致会比较乱。
并查集合并
众所周知,如果并查集不做任何优化,合并后最差的情况是一条链,这样的高度会导致查询祖先退化为
此处讨论记录每个连通块的节点大小,合并的时候把小的连通块合并到大的连通块。就这么一个优化,即可将树高和复杂度降到
此处我们把视角从连通块的高度换成一个节点的深度来分析。对于一个节点
答案是不会。这就是小并大的威力:因为一次合并操作后,由于我们把小的集合挂在大的身上,所以小连通块中点所在的新集合至少会变成原来的两倍。因为一次合并会翻倍,而集合最大的上限就是
因此,由于深度增加的次数是有限的,深度显然也是在这个对数级别。
可并堆?
例题:P3377 【模板】可并堆 1
这题的做法绝对是百花齐放。数据结构选择可以选配对堆、红黑树、斜堆甚至斐波那契堆。如果你啥也不会也能直接启动平板电视的 __gnu_pbds::priority_queue。但是如果你除了 std::priority_queue 啥也不会呢?
没错!有了小并大这个技巧,你完全不需要学以上任何一个东西。我们的解答就一句话:合并的时候把小的堆元素全部 pop 出来然后 push 进大的堆即可,代码也是很直观的:
#include <bits/stdc++.h>
using namespace std;
#define int long long
int dsu[100005],d[100005];
priority_queue<pair<int,int>,vector<pair<int,int>>,greater<>> pq[100005];
int f(int x){
if (dsu[x]==x) return x;
return dsu[x]=f(dsu[x]);
}
void unite(int x,int y){
x=f(x),y=f(y);
if (x==y) return;
if (pq[x].size()<pq[y].size()) swap(x,y);
while (pq[y].size()){
auto pqt=pq[y].top();
pq[y].pop();
pq[x].push(pqt);
}
dsu[y]=x;
}
signed main(){
int n,q;cin>>n>>q;
for (int i=1;i<=n;i++){
dsu[i]=i;
int num;cin>>num;
pq[i].push({num,i});
}
while (q--){
int op;cin>>op;
if (op==1){
int x,y;cin>>x>>y;
if (!d[x]&&!d[y])
unite(x,y);
}
else{
int x;cin>>x;
if (d[x]) cout<<-1<<endl;
else{
x=f(x);
auto [num,id]=pq[x].top();
pq[x].pop();
d[id]=1;
cout<<num<<endl;
}
}
}
}
但是这并不代表别的可并堆都一无是处,因为小并大会带来一个额外的对数复杂度,意味着总体复杂度是
如果暂时不考虑删除操作,证明依旧是考虑一个元素:让它从一个优先队列移动到另一个优先队列的复杂度是
:::warning[P3377 中删除操作带来的一个小问题]
P3377 本身支持删除堆顶,因此堆的大小会下降,所以严格来说,不能直接说“一个元素所在堆的大小一生只会不断翻倍”。
不过这个做法依旧可以用势能分析证明到
所以这道题本身的复杂度结论依旧成立,只是证明比最纯的小并大稍微多一步。
:::
DSU on tree?点分治?线段树合并?不如我的小并大
叠甲:笔者并不是说小并大永远比这些树上算法好用,很多题其实都能用很多种方法做出来,只是某些情况某个会更好用一点。
例题:P3605 [USACO17JAN] Promotion Counting P
这题解法很多很多。如果您很有实力的话,第一步可能就会往 DFS 序、线段树合并或者是各种数据结构(平衡树、可持久化线段树等)方向想。但对于萌新来说最直观的解法肯定是把子树下真正的信息维护出来,然后从中找出答案。而这种方法有可能实现吗?可以的!不过由于这里需要按秩查找,单纯的 std::set 并不支持,这里我们使用平板电视的 __gnu_pbds::tree 来维护信息。
#include <bits/extc++.h>
#define int long long
using namespace std;
using namespace __gnu_pbds;
using oset = tree<int, null_type, less<int>, rb_tree_tag, tree_order_statistics_node_update>;
vector<int> adj[100005];
int a[100005];
int ans[100005];
void merge(oset &s, oset &s2)
{
if (s.size() < s2.size())
s.swap(s2);
for (int i : s2)
s.insert(i);
}
void dfs(int x, oset &ret)
{
ret.insert(a[x]);
for (int i : adj[x])
{
oset c;
dfs(i, c);
merge(ret, c);
}
ans[x] = ret.size() - ret.order_of_key(a[x] + 1);
}
signed main()
{
cin.tie(0)->sync_with_stdio(0);
int n;
cin >> n;
for (int i = 1; i <= n; i++)
cin >> a[i];
for (int i = 2; i <= n; i++)
{
int p;
cin >> p;
adj[p].push_back(i);
}
oset arst;
dfs(1, arst);
for (int i = 1; i <= n; i++)
cout << ans[i] << "\n";
}
复杂度证明的话可以考虑一个节点所在的集合大小变化,依旧每次会至少翻倍,所以每个元素最多被移动
关于实现的温馨提示
由于小并大是合并集合,这个集合可以是很多的数据结构。但由于我们需要用到很多 swap 操作,请务必确认 swap 的时间复杂度。
尤其是 PBDS,建议优先使用成员函数 a.swap(b)。
总结
当碰到需要维护集合且需要合并的情况下,可以考虑小并大。尤其是在树上问题需要维护子树信息的,除了 DSU on tree、点分治等算法,小并大通常也可以 AC,注意分析好时间复杂度即可。
重链剖分
复杂度
此处假设读者已经有了部分重链剖分的基础,若完全不会建议先了解一下。
众所周知,重链剖分中从一个点跳到根最多只会跨
对于时间复杂度的分析,我们发现一次跳链相当于当前点要从重链的头往上走一步。那么这意味着什么呢?还是翻倍!
假设我们当前在重链的头
所以
那么这相当于往上一走,当前子树大小至少翻倍!
这点就和小并大的复杂度分析很像了,它切换的上限就是
需要注意的是,严格来说这里证明的是“一条根路径上最多跨
DSU on tree
这个技巧经常被称之为“树上启发式合并”,在此简单复习下具体的技巧:
- 先递归所有轻儿子,把轻儿子的答案全部算出来。
- 递归重儿子,算出重儿子的答案,并且保留重儿子的子树信息,让当前节点复用这个信息。
- 暴力把所有轻儿子的合并到当前点,这样就能知道当前点的答案了。
不知道大家有没有感觉这个算法非常的暴力,但是它的复杂度确实是
此处就用到了重链剖分的同款思想。
考虑一个点
而一条根路径上的轻边数量是
总的来说,所谓的“树上启发式合并”和“启发式合并”虽然复杂度分析差不多,但是前者更多是在利用 Heavy-Light 的轻重儿子结构。除了复杂度分析很像以外,和小并大关系并不大,希望大家能理清楚它们之间的关系。
二进制分组
这个思想我感觉非常符合本文的主题,但这个技巧本人认为相当鸡肋,经常能被更好的数据结构代替,用途相对受限。
例题:P5076 【深基16.例7】普通二叉树(简化版)
注意:仅用于演示二进制分组,这种题平衡树肯定是比二进制分组好的。
二进制分组的思路是把一个有
插入元素后像做二进制加法一样不断地向前进位,进位就等同于合并下一位与当前的集合。
时间复杂度分析则是显然的:由于合并是线性的,每个元素最多往前移动
按照这种方式插入后,我们相当于把当前集合拆成了
参考代码:
#include <bits/stdc++.h>
using namespace std;
vector<int> v[30];
int cnt;
void ins(int x)
{
cnt++;
v[0].push_back(x);
sort(v[0].begin(), v[0].end());
for (int i = 0; 1 << i <= cnt; i++)
{
if (v[i].size() > 1 << i)
{
vector<int> tmp(v[i].size() + v[i + 1].size());
merge(v[i + 1].begin(), v[i + 1].end(), v[i].begin(), v[i].end(), tmp.begin());
v[i + 1].swap(tmp);
v[i].clear();
}
else
break;
}
}
int rnk(int x)
{
int ans = 0;
for (int i = 0; 1 << i <= cnt; i++)
ans += lower_bound(v[i].begin(), v[i].end(), x) - v[i].begin();
return ans + 1;
}
int pre(int x)
{
int ans = -2147483647;
for (int i = 0; 1 << i <= cnt; i++)
{
auto it = lower_bound(v[i].begin(), v[i].end(), x);
if (it == v[i].begin())
continue;
ans = max(ans, *prev(it));
}
return ans;
}
int suc(int x)
{
int ans = 2147483647;
for (int i = 0; 1 << i <= cnt; i++)
{
auto it = upper_bound(v[i].begin(), v[i].end(), x);
if (it == v[i].end())
continue;
ans = min(ans, *it);
}
return ans;
}
signed main()
{
int q;
cin >> q;
while (q--)
{
int op, x;
cin >> op >> x;
if (op == 1)
cout << rnk(x) << endl;
if (op == 2)
{
int l = -1e9 - 5, r = 1e9 + 5;
while (l + 1 != r)
{
int mid = (l + r) / 2;
if (rnk(mid) <= x)
l = mid;
else
r = mid;
}
cout << l << endl;
}
if (op == 3)
cout << pre(x) << endl;
if (op == 4)
cout << suc(x) << endl;
if (op == 5)
ins(x);
}
}
总结
以上是我总结出的三大类依靠“翻倍”思想的对数复杂度算法。很多初学者都会认为对数复杂度与“减半”有关,但其实“翻倍”也是很重要的一点。
这类算法很多时候需要均摊分析,关注每个小元素一生会贡献多少次,而不是只看单次操作的最坏代价,所以这种复杂度相对比较难第一眼发现。
希望我的文章能让大家更好地理解这些依靠“翻倍”得到对数复杂度的算法。
顺带一提,我的初衷其实是想要更加推广一下启发式合并(小并大)的思想,因为这个思想其实我认为非常重要,涵盖了很多“翻倍”导致的对数算法情况,且适用性其实较广。尽管大多数时候都有别的算法可以替代,但依旧可以作为一个“万金油”算法来考虑。
:::info[AI 辅助声明]
本文主体结构、核心观点、主要算法思路与代码均由作者自行完成。
ChatGPT 用于检索并核对洛谷 Markdown / LaTeX 排版规范、检查复杂度分析中的技术性问题,并对少量存在歧义或明显病句的文字进行了修改。所有修改均由作者人工审核后采用。
:::