数据结构
分治
ST 表
ST 表是用倍增思想在 \(O(n\log n)\) 的预处理时间复杂度后用 \(O(1)\) 时间求区间 RMQ 的数据结构。我们记 \(f_{u,t}\) 表示区间 \([u,u+2^t)\) 的极值,然后通过递推式 \(f_{u,t}=\max / \min(f_{u,t-1},f_{u+2^{t-1},t-1})\) 就可以求出所有的 \(f\) 值,然后查询相当于两个长度为 \(2^{\lfloor \log n \rfloor}\) 的 \(f\) 值的极值。
code:
inline void build()
{
for (int i = 1; i <= n; i++) st[i][0] = a[i];
for (int j = 1; 1 + (1 << j) <= n; j++)
for (int i = 1; i + (1 << j) - 1 <= n; i++)
st[i][j] = max(st[i][j - 1], st[i + (1 << j - 1)][j - 1]);
}
inline int query(int l, int r)
{
int t = log2(r - l + 1);
return max(st[l][t], st[r - (1 << t) + 1][t]);
}
维护信息特点
显然,如果 ST 表只能维护 RMQ 问题那是不是太弱了。于是我们发现区间 \(\gcd\) 也可以用 ST 表维护。那 ST 表维护的信息到底有什么特点呢?
-
满足结合律。我们需要保证算贡献的顺序对结果没有影响;
-
可重复贡献。发现
query
中有一部分的贡献是重复的。
这解释了为什么不能用 ST 表维护区间和。
笛卡儿树
主要用途是区间 RMQ。
如何维护
笛卡尔树每个节点维护两个值 \(k,w\),其中 \(k\) 表示键值,\(w\) 表示值。在这个树上,\(k\) 满足 BST 性质,\(w\) 满足小根堆性质。
建树
我们按键值排序,然后每次当前节点肯定插在右链的最末端,于是我们用一个栈维护右链,考虑将一个节点插进来的时候,我们在栈中自顶向下找到第一个满足 \(w < w_i\) 的节点,当前节点作这个点的右儿子,剩下的所有节点当当前节点的左儿子,并且全部弹出栈,如果排序是 \(O(n)\) 的(大多情况是把下标当作 \(k\)),那么复杂度就是 \(O(n)\) 的。
code:
for (int i = 1; i <= n; i++)
{
int k = top;
while (k && a[stk[k]] > a[i]) --k;
if (k) rs[stk[k]] = i;
if (k < top) ls[i] = stk[k + 1];
stk[++k] = i, top = k;
}
这样我们就能解释为什么笛卡尔树不是笛卡尔本人发明的却还要叫这个名字了?
感觉跟雷峰塔名字里有雷锋是因为乐于助人一样扯。
性质
对于一个以 \(u\) 为根节点的子树,我们可以知道 \(u\) 的子树构成了一个序列上的连续区间,这个区间满足最大(或最小)值是 \(u\) 的值,设这个区间是 \(S\),且\(S\) 的任意连续子集,只要包含 \(u\),那么这个区间最大(或最小)值肯定是 \(u\) 的值。
这个性质可以帮助我们迅速找出每个数作为 \(\max\) 管辖的区间,从而进行别的操作,例如 这个题 就利用了这样的性质进行分治。
线段树
线段树的具体操作就不再赘述了。
双半群结构
我们来看线段树维护的这个结构是什么样子的。
-
线段树每个节点上有一个标记信息,这些信息属于集合 \(T\);
-
然后每个点自己还有一个信息,这些信息属于集合 \(D\);
-
合并时有运算 \(*\) 满足 \(D*D\rightarrow D\);
-
懒标记对当前节点的信息有影响。设运算方式为 @ 满足 \(D\) @ \(T \rightarrow D\);
-
我们有标记复合运算 # 满足 \(T\) # \(T \rightarrow T\)。
那这样的系统要满足什么条件呢?
-
对于不同的区间的合并,不同的合并顺序并不影响结果。设三个区间维护的信息分别是 \(d_1,d_2,d_3\),那么总应该有 \((d_1*d_2)*d_3=d_1*(d_2*d_3)\),即满足结合律;
-
标记满足结合律:\((t_1\)#\(t_2)\)#\(t_3=t_1\)#\((t_2\)#\(t_3)\);
-
\((t_1\)#\(t_2)@d=t_1@(t_2@d)\);
-
\(t@(d_1*d_2)=(t@d_1)*(t@d_2)\);
-
如果没有任何的标记,也就是说标记是空的,记为 \(\epsilon\),满足 \(\epsilon @ d = d\);
-
\(\epsilon\) # \(t=t\)。
数学中我们把配备了二元运算且满足结合律的结构叫做半群,于是我们发现:
-
所有信息,加上合并操作,构成半群;
-
所有标记,加上标记复合操作,构成半群。因其有单位元 \(\epsilon\),所以它构成 幺半群;
-
标记会对信息有影响,这在数学中称作幺半群作用。
由此我们可以根据抽象数据结构的原理来维护线段树。
李超线段树
李超线段树主要作用是维护区间上的线段,操作有插入一条线段和查询某个点上最值。
每个节点上我们村的是当前区间的中间点最优的情况,这里用到了标记永久化思想(毕竟一次函数是变的,当前区间最优不一定是这个区间子区间的最优)。
对于插入操作,不妨设区间中点处新的更优,那么如果左端点新的反而比旧的劣,说明新旧两条线在左侧有交点,那就再去更新左侧,同理,如果右端点新的反而比旧的劣,更新右侧。
对于查询操作,注意因为有标记永久化,所以每一层都要取一个最优。
实现
struct Line
{
int id;
D k, b;
Line() {}
Line(D x0, D y0, D x1, D y1, int _id)
{
id = _id;
if (abs(x0 - x1) < eps) k = 0, b = max(y0, y1);
else
{
k = (y1 - y0) / (x1 - x0);
b = y0 - k * x0;
}
}
D val(D x) { return k * x + b; }
};
Line t[M << 2];
void update(int p, int pl, int pr, int L, int R, Line g)
{
if (L == pl && pr == R)
{
if (t[p].id == 0) return t[p] = g, void();
int mid = (pl + pr) >> 1;
if (abs(t[p].val(mid) - g.val(mid)) < eps)
{
if (t[p].id < g.id) swap(t[p], g);
}
else if (t[p].val(mid) < g.val(mid)) swap(t[p], g);
int fl = (g.val(pl) + eps < t[p].val(pl));
int fr = (g.val(pr) + eps < t[p].val(pr));
if (fl && fr) return;
if (!fl) update(ls(p), pl, mid, L, mid, g);
if (!fr) update(rs(p), mid + 1, pr, mid + 1, R, g);
return;
}
int mid = (pl + pr) >> 1;
if (R <= mid) update(ls(p), pl, mid, L, R, g);
else if (L > mid) update(rs(p), mid + 1, pr, L, R, g);
else update(ls(p), pl, mid, L, mid, g), update(rs(p), mid + 1, pr, mid + 1, R, g);
}
Line query(int p, int pl, int pr, int pos)
{
if (pl == pr) return t[p];
int mid = (pl + pr) >> 1;
Line ret;
if (pos <= mid) ret = query(ls(p), pl, mid, pos);
else ret = query(rs(p), mid + 1, pr, pos);
if (abs(ret.val(pos) - t[p].val(pos)) < eps)
{
if (ret.id < t[p].id) return ret;
return t[p];
}
else
{
if (ret.val(pos) > t[p].val(pos)) return ret;
else return t[p];
}
}
动态开点
=李超线段树+动态开点。
struct line
{
int k, b;
line() { k = 0, b = INF; }
line(int _k, int _b) { k = _k, b = _b; }
int f(int x) { return k * x + b; }
};
struct node
{
int ls, rs;
line l;
int f(int x) { return l.k * x + l.b; }
// 为什么这里要在节点内把 line 分开?
// 因为 update 里的 swap 操作如果直接交换 tr[p] 和 g 会出错。
} tr[N];
int nc, rt;
void update(int &p, int pl, int pr, line g)
{
if (!p) return tr[p = ++nc].l = g, void();
int mid = (pl + pr) >> 1;
if (tr[p].f(mid) > g.f(mid)) swap(tr[p].l, g);
if (tr[p].l.k < g.k) update(tr[p].ls, pl, mid, g);
else update(tr[p].rs, mid + 1, pr, g);
}
int query(int p, int pl, int pr, int k)
{
if (!p) return INF;
int mid = (pl + pr) >> 1;
if (k <= mid) return min(tr[p].f(k), query(tr[p].ls, pl, mid, k));
else return min(tr[p].f(k), query(tr[p].rs, mid + 1, pr, k));
}
应用
所有斜率优化的题都可以用李超线段树草过去!
为什么呢?因为斜率优化和李超线段树的区别不过是维护直线方式不同罢了,其他完全一样。
单侧递归线段树
又名:楼房重建线段树(来源是其例题),兔队线段树(因为粉兔致力于推广该方法)
题意相当于求前缀 \(\max\) 的个数。这里我们用线段树维护。
这里左区间对右区间有影响,我们考虑在 pushup
的时候维护一个 calc
函数,calc(p,v)
表示考虑到节点 \(p\),前面对他的影响是 \(v\),再算答案。
如果 \(v\geq mx_p\),直接 return 0
;
再如果 \(v \geq mx_{ls(p)}\),递归右儿子 calc(rs(p),v)
;
否则递归左儿子,结果是 calc(ls(p),v)+c[p]-c[ls(p)]
。其中 \(c_p=c_{ls(p)}\) 表示的是右儿子在左儿子的影响下对答案的贡献。这是因为既然原来较大的 \(mx_{ls(p)}\) 都没对右边产生影响,那较小的 \(v\) 自然不会对右边产生影响。
code:
int n, m, c[N << 2];
double mx[N << 2];
int calc(int p, int pl, int pr, double v)
{
if (v >= mx[p]) return 0;
if (pl == pr) return (mx[p] > v);
int mid = (pl + pr) >> 1;
if (mx[ls(p)] <= v) return calc(rs(p), mid + 1, pr, v);
else return calc(ls(p), pl, mid, v) + c[p] - c[ls(p)];
}
void update(int p, int pl, int pr, int pos, int v)
{
if (pl == pr)
{
mx[p] = 1.0 * v / pos, c[p] = 1;
return;
}
int mid = (pl + pr) >> 1;
if (pos <= mid) update(ls(p), pl, mid, pos, v);
else update(rs(p), mid + 1, pr, pos, v);
mx[p] = max(mx[ls(p)], mx[rs(p)]);
c[p] = c[ls(p)] + calc(rs(p), mid + 1, pr, mx[ls(p)]);
}
这个 trick 越来越常用了啊。
平衡树
平衡树维护信息的特征其实和线段树是一样的,只不过是在加上区间反转操作之后就不能用线段树了,因为线段树没有办法交换两个儿子。
Splay
节点维护信息
-
rt
:根节点 -
tot
:节点个数 -
val[p]
:节点的权值 -
siz[p]
:节点子树大小 -
fa[p]
: 父亲节点的编号 -
ch[p][0/1]
:左儿子和右儿子的编号
下面的讲解中,我们设 \(p\) 表示当前节点,\(f\) 表示 \(p\) 的父亲,\(g\) 表示 \(f\) 的父亲。
基本操作
-
pushup
:更新节点子树大小 -
get
:判断这个节点是一个右儿子还是一个左儿子 -
clear
:删掉当前节点
实现:
void pushup(int p) { siz[p] = siz[ch[p][0]] + siz[ch[p][1]] + cnt[p]; }
bool get(int p) { return p == ch[fa[p]][1]; }
void clear(int p) { siz[p] = ch[p][0] = ch[p][1] = val[p] = fa[p] = cnt[p] = 0; }
旋转操作
旋转操作分为左旋和右旋,左旋可以看做右旋的逆过程,所以这里只讲解右旋。我们要求旋转之后,当前节点 \(p\) 的深度减一,而这棵树仍然满足 BST 性质。
旋转过程:
- 将 \(p\) 的右儿子接到 \(f\) 的左儿子上,将 \(f\) 接到 \(p\) 的右儿子上
- 如果 \(g\) 存在,用 \(p\) 来填上 \(f\) 原来的位置
- 更新 \(f\) ,\(p\)
实现:
void rotate(int p)
{
int f = fa[p], g = fa[f], chk = get(p);
ch[f][chk] = ch[p][chk ^ 1];
if (ch[p][chk ^ 1]) fa[ch[p][chk ^ 1]] = f;
ch[p][chk ^ 1] = f, fa[f] = p, fa[p] = g;
if (g) ch[g][f == ch[g][1]] = p;
pushup(f), pushup(p);
}
Splay 操作
Splay 操作规定:每一次旋转之后,都要将节点 \(p\) 旋至根节点。
Splay 操作就是对 \(p\) 做多次 Splay 步骤。每对 \(p\) 做一次 Splay 步骤,\(p\) 到 \(rt\) 的距离就减一。Splay 步骤分三种,具体有 6 种操作。分类很繁杂难理解,但代码很好写。
先把实现放上:
void splay(int p)
{
for (int f = fa[p]; f = fa[p], f; rotate(p))
if (fa[f]) rotate(get(p) == get(f) ? f : p);
rt = p;
}
你没看错,这么短的代码,蕴含了 3 种复杂操作和十分复杂的复杂度分析。我们来分析这三种操作。
-
zig:将 \(p\) 直接左旋或右旋。这个操作用于 \(f=rt\) 时,这样就可以直接把 \(p\) 旋转到 \(rt\)。
-
zig-zig:当 \(f\) 不是根节点,而 \(chk(f)=chk(p)\) 时,我们先将 \(g\) 左旋或右旋,再将 \(p\) 左旋或右旋。
-
zig-zag:使用条件就是上面两种都不适用的时候。我们将 \(p\) 先左旋再右旋或先右旋后左旋。
手摸一下就可以理解这 3 种操作。至于上面说的 6 种,是因为有左旋和右旋的区别,我们在 rotate
里已经解决掉了。这样的操作时间复杂度是均摊 \(O(\log n)\) 的,证明方法要用到势能分析法,我决定抽空学学。
剩下的操作就是次要的了,可以用递归实现。这里直接放代码。
void nw(int k, int fath, int op)
{
val[++tot] = k, siz[tot] = cnt[tot] = 1, fa[tot] = fath, ch[fath][op] = tot;
}
void ins(int k)
{
if (!rt)
{
val[++tot] = k, cnt[tot]++, rt = tot;
return pushup(rt);
}
int cur = rt, f = 0;
while (1)
{
if (val[cur] == k)
{
cnt[cur]++;
pushup(cur), pushup(f), splay(cur);
break;
}
f = cur;
cur = ch[cur][val[cur] < k];
if (!cur)
{
val[++tot] = k;
cnt[tot]++;
fa[tot] = f;
ch[f][val[f] < k] = tot;
pushup(tot), pushup(f), splay(tot);
break;
}
}
}
int rk(int k)
{
int p = rt, res = 0;
while (true)
{
if (k < val[p]) p = ch[p][0];
else
{
res += siz[ch[p][0]];
if (!p) return res + 1;
if (val[p] == k)
{
splay(p);
return res + 1;
}
res += cnt[p], p = ch[p][1];
}
}
}
int prev()
{
int p = ch[rt][0];
if (!p) return p;
while (ch[p][1]) p = ch[p][1];
splay(p);
return p;
}
int next()
{
int p = ch[rt][1];
if (!p) return p;
while (ch[p][0]) p = ch[p][0];
splay(p);
return p;
}
void del(int k)
{
rk(k);
if (cnt[rt] > 1)
{
cnt[rt]--;
pushup(rt);
return;
}
if (!ch[rt][0] && !ch[rt][1])
{
clear(rt);
rt = 0;
return;
}
if (!ch[rt][0])
{
int cur = rt;
rt = ch[rt][1];
fa[rt] = 0;
clear(cur);
return;
}
if (!ch[rt][1])
{
int cur = rt;
rt = ch[rt][0];
fa[rt] = 0;
clear(cur);
return;
}
int cur = rt;
int x = prev();
fa[ch[cur][1]] = x;
ch[x][1] = ch[cur][1];
clear(cur), pushup(rt);
}
int kth(int k)
{
int p = rt;
while (1)
{
if (ch[p][0] && k <= siz[ch[p][0]]) p = ch[p][0];
else
{
k -= cnt[p] + siz[ch[p][0]];
if (k <= 0)
{
splay(p);
return val[p];
}
p = ch[p][1];
}
}
}
无旋 Treap(FHQ-Treap)
FHQ-Treap 是由范浩强发明的不用旋转的 Treap,其不用旋转的性质可以帮助我们进行可持久化操作。
作为 Treap,FHQ-Treap 的每个点都记录一个优先级 pri
和其权值 val
,其中 pri
满足二叉堆的性质,val
满足 BST 性质,其中 pri
是随的。这样做能平衡的原因是数据范围越大,随机出来一条链的可能性就越小,也就是说树的形态越平衡,至于小数据,平不平衡就无所谓了。
FHQ-Treap 的核心操作为分裂与合并。
分裂操作
分裂操作分为按排名合并与按大小合并。这里只讲按大小分裂。假设当前节点为 \(u\),split
的参考值是 \(x\),分裂后两棵树是 \(L\) 和 \(R\),如果 \(val(x)\le x\),那么 \(u\) 的左儿子一定在 \(L\) 上,于是我们把 \(u\) 接到 \(L\) 上,递归右儿子,\(R\) 不变,下一层如果要接到 \(L\) 上,那么让它接到 \(u\) 的右儿子上,这个可以用传参来维护,反之亦然。这里的传参相当于给后来人留接口,告诉他们分裂之后当谁的儿子。
void split(int u, int x, int &L, int &R)
{
if (!u) return L = R = 0, void();
if (t[u].val <= x) L = u, split(t[u].rs, x, t[u].rs, R);
else R = u, split(t[u].ls, x, L, t[u].ls);
pushup(u);
}
合并操作
设这两棵树是 \(L\) 和 \(R\),如果两者有一个是空的,那么返回不空的那一个;如果 \(L\) 的 \(pri\) 小于 \(R\) 的,我们就让 \(L\) 的右儿子和 \(R\) 合并,然后接到 \(L\) 的右儿子上,更新 \(L\),反之亦然。
int merge(int L, int R)
{
if (!L || !R) return L | R;
if (t[L].pri <= t[R].pri) return t[L].rs = merge(t[L].rs, R), pushup(L), L;
else return t[R].ls = merge(L, t[R].ls), pushup(R), R;
}
然后其他操作就迎刃而解了。还是直接放代码。
inline void Insert(int x) { newnode(x), split(rt, x, L, R), rt = merge(merge(L, cnt), R); }
inline void Delete(int x)
{
split(rt, x - 1, L, R), split(R, x, p, R);
p = merge(t[p].ls, t[p].rs), rt = merge(merge(L, p), R);
}
inline int get_rank(int x)
{
split(rt, x - 1, L, R);
int y = t[L].siz + 1;
rt = merge(L, R);
return y;
}
inline int get_val(int u, int k)
{
int now = u;
while (now)
{
int x = t[t[now].ls].siz + 1;
if (x == k) return t[now].val;
if (x < k) now = t[now].rs, k -= x;
else now = t[now].ls;
}
}
inline int get_pre(int x)
{
split(rt, x - 1, L, R);
int y = get_val(L, t[L].siz);
rt = merge(L, R);
return y;
}
inline int get_nxt(int x)
{
split(rt, x, L, R);
int y = get_val(R, 1);
rt = merge(L, R);
return y;
}
分块
分块思想
一般思路
分成 \(\sqrt n\) 块,大段维护,小段暴力。
为什么是 \(\sqrt n\) 块?我们不妨设分了 \(B\) 段,那么大段维护的时间复杂度是 \(O(\frac{n}{B})\),小段暴力的复杂度是 \(O(B)\),总复杂度 \(O(\frac{n}{B}+B)\),由均值不等式我们知道当 \(\frac{n}{B}=B\) 时时间复杂度取最小值,此时 \(B=\sqrt n\),于是总的时间复杂度就是 \(O(n\sqrt n)\)。
实现
一般地,我们记下每个块的左端点与右端点,以及每个点所属的块的编号,提前预处理。
block = sqrt(n);
for (int i = 1; i <= block; i++) st[i] = (i - 1) * block + 1, ed[i] = i * block;
if (ed[block] < n) ed[block] = n;
for (int i = 1; i <= block; i++)
for (int j = st[i]; j <= ed[i]; j++) pos[j] = i, sum[i] += a[j];
莫队
普通莫队算法
例题:P3901 数列找不同
考虑最基础的暴力,就是离线下来,然后不断挪左右端点,然后每次就是 \(O(n)\),稳 TLE。
莫队就是把 \(\{a_n\}\) 分块,然后对于每个区间,以左端点所属块的编号为第一关键字,右端点为第二关键字排序,然后再进行一边上面的暴力。时间复杂度 \(O(n\sqrt n)\),可过。
实现
struct que
{
int l, r, id;
bool operator<(const que &b)const
{
if(bel[l] == bel[b.l]) return r < b.r;
return bel[l] < bel[b.l];
}
} q[N];
void move(int now, int op){...}
void solve()
{
int block = sqrt(n);
sort(q + 1, q + 1 + m);
int l = 1, r = 0;
for(int i = 1; i <= m; i++)
{
while(l > q[i].l) move(--pl, 1);
while(r < q[i].r) move(++pr, 1);
while(l < q[i].l) move(pl++, -1);
while(r > q[i].r) move(pr--, -1);
ans[q[i].id] = nowAns;
}
}
注意这四个指针挪动的顺序不能变。
复杂度证明
考虑几何方式。我们把暴力和莫队的图都画出来。
暴力:
莫队:
我们发现莫队移动的总量被严格控制在 \(O(n\sqrt{n})\) 之内,所以时间复杂度就是 \(O(n\sqrt n)\) 的。
奇偶段优化
我们来看一组数据(来自 OI-Wiki):
// 设块的大小为 2 (假设)
1 1
2 100
3 1
4 100
手摸发现 \(r\) 指针挪了 300 次,非常不好,这里使用奇偶段优化。我们考虑计数编号的块里 \(r\) 从大到小排序,否则从小到大排序,这样就可以保证块与块之间不会有突变了。
实现
struct query
{
int l, r, id;
bool operator<(const query &b) const
{
if (bel[l] != bel[b.l]) return l < b.l;
if (bel[l] & 1) return r < b.r;
return r > b.r;
}
} q[N];