数据结构
链表
单链表
// head存储链表头,e[]存储节点的值,ne[]存储节点的next指针,idx表示当前用到了哪个节点
// MAXN表示最大点数
int head = 0, e[MAXN], ne[MAXN], idx;
void init() {
//初始化链表
idx = 0, head = 0;
}
void insert(int val) {
// 在链表头部插入一个数val
e[++ idx] = val, ne[idx] = head, head = idx;
}
void insert_after(int k, int val) {
// 在下标为k的节点后插入一个数val
e[++idx] = val;
ne[idx] = ne[k];
ne[k] = idx;
}
void remove_head() {
//删除头结点
head = ne[head];
}
void remove(int k) {
//删除下标为k的节点的后一个节点
ne[k] = ne[ne[k]];
}
//遍历链表
for (int i = head; i; i = ne[i]) {
//do something
}
链式前向星加边
// MAXN表示最大点数,MAXM表示最大边数,无向边的最大边数要乘以2
int head[MAXN], idx;
void init() {
memset(head, 0, sizeof head);
idx = 0;
}
struct EDGE {
int to, val, next;
}edge[MAXM];
void add(int head[], int from, int to, int val) {
//head表示表头,可能不止建一张图
edge[++ idx].to = to, edge[idx].val = val, edge[idx].next = head[from], head[from] = idx;
}
队列和栈
模拟栈
// tt表示栈顶
int stk[MAXN], tt = 0;
// 向栈顶插入一个数
stk[ ++ tt] = x;
// 从栈顶弹出一个数
tt -- ;
// 栈顶的值
stk[tt];
// 判断栈是否为空
if (tt > 0) {
}
模拟队列
普通队列
int q[MAXN];//队列
int hh, tt = -1;//队头和队尾,队头删除队尾插入
q[++ tt] = x;//插入元素
hh ++;//删除元素
if (hh <= tt) not empty //判断是否为空
else empty;
q[hh];//取出队头元素
q[tt];//取出队尾元素
循环队列
// hh 表示队头,tt表示队尾的后一个位置
int q[MAXN], hh = 0, tt = 0;
// 向队尾插入一个数
q[tt ++ ] = x;
if (tt == N) tt = 0;
// 从队头弹出一个数
hh ++ ;
if (hh == N) hh = 0;
// 队头的值
q[hh];
// 判断队列是否为空
if (hh != tt) {
}
单调栈
适用范围
给定一个序列,求序列中的每一个数左边或者右边第一个比他大或者比他小的数在什么地方。
维护
单调递增栈:在保持栈内元素单调递增的前提下,如果栈顶元素大于要入栈的元素,则将其弹出。将新元素入栈
单调递减栈:在保持栈内元素单调递减的前提下,如果栈顶元素小于要入栈的元素,则将其弹出。将新元素入栈。
时间复杂度为 O ( n ) O(n) O(n)
详细解释
单调递增栈:对于将要入栈的元素来说,在对栈进行更新后(即弹出了所有比自己大的元素),此时栈顶元素就是数组中左侧第一个比自己小的元素;当栈内元素被弹出时,遇到的就是数组中右边第一个比自己小的元素。
单调递减栈:对于将要入栈的元素来说,在对栈进行更新后(即弹出了所有比自己小的元素),此时栈顶元素就是数组中左侧第一个比自己大的元素;当栈内元素被弹出时,遇到的就是数组中右边第一个比自己大的元素。
代码模板
找出每个数左边第一个比自己小的元素
int stk[MAXN], tt;
int n;
void solve() {
n = read();
cin >> n;
for (int i = 1; i <= n; i++) {
int x;
cin >> x;
while (tt && stk[tt] >= x) tt --;
if (!tt) cout << -1 << " "; //找不到比他更小的数
else cout << stk[tt] << " "; //输出左边第一个比自己小的元素
stk[++tt] = x;
}
}
单调队列
定义
还是依旧类似于单调栈的思想,求离自己最近的比自己大/小的值,或者求一段区间内的最大值和最小值。队尾插入,队头弹出。
代码模板
滑动窗口,求区间最大值和最小值
设两个数 a i a_i ai 和 a j a_j aj 在某个队列中,现在要求的是区间最小值。如果 a i a_i ai 比 a j a_j aj 更靠近队头且 a i a_i ai 大于 a j a_j aj ,说明 a i a_i ai 之后永远不可能作为最小值输出,所以要在之前的操作中把 a i a_i ai 弹出即可。这样操作下来,每次队头保存的都是最小值,整个队列的值都是单调的。
每次从队尾加入元素后判断一下队头有没有被覆盖掉。
// 滑动窗口,求区间内的最大值和最小值
int q[MAXN], hh, tt, n, k, a[MAXN];
//队列存放的是下标
//一共有n个数,区间长度为k
void solve() {
n = read(), k = read();
for (int i = 1; i <= n; i++) a[i] = read();
hh = 1, tt = 0;
//处理最小值
for (int i = 1; i <= n; i ++) {
while (hh <= tt && a[i] <= a[q[tt]]) tt--; //队尾不单调
q[++tt] = i;
if (i - k >= q[hh]) hh++; //队头已不在队列中
if (i >= k) cout << a[q[hh]] << " ";
}
cout << endl;
//处理最大值
hh = 1, tt = 0;
for (int i = 1; i <= n; i++) {
while (hh <= tt && a[i] >= a[q[tt]]) tt--; //队尾不单调
q[++tt] = i;
if (i - k >= q[hh]) hh++; //队头已不在队列中
if (i >= k) cout << a[q[hh]] << " ";
}
cout << endl;
}
算前缀和时预先插入一个0.
题目是求 1 ≤ l e n ≤ k 1 \leq len \leq k 1≤len≤k 的最大连续子序列和。
int n, k;
ll sum[MAXN], a[MAXN];
int q[MAXN];
int hh = 1, tt = 0;
void solve() {
n = read(), k = read();
for (int i = 1; i <= n; i++) {
a[i] = read();
sum[i] = sum[i - 1] + a[i];
}
ll ans = -INF;
//枚举以i作为终点的,长度不超过k的最大子序和
q[++tt] = 0;
for (int i = 1; i <= n; i++) {
while (hh <= tt && i - q[hh] > k) hh++;
ans = max(ans, sum[i] - sum[q[hh]]);
while (hh <= tt && sum[q[tt]] >= sum[i]) tt--;
q[++tt] = i;
}
printf("%lld\n", ans);
}
trie树(前缀树,字典树,01trie)
字符串前缀树
定义
T r i e Trie Trie 树又称字典树、单词查找树。是一种能够高效存储和查找字符串集合的数据结构。储存形式如下:
其中, 0 0 0 既代表根节点,也代表空节点。
根据上图的过程,我们模拟一下字典树的建立过程。
插入的时候,如果能在之前的点中找到与当前的值相匹配的,就跳到那个点上去,否则就自己开辟一个空间继续往下建树。建完树后,终点打上标记,用于统计方案数。
查询的时候,依旧是往下查找。如果无法查找到,说明之前未插入这个前缀,个数返回 0 0 0 。如果能够到达终点,说明能匹配上整个串,就返回之前打好的标记。
n o w now now 用于表示当前节点的编号,开辟的方法类似于链式前向星用 + + t o t ++tot ++tot 来表示。
一个小细节, t r i e trie trie 树的信息其实是保存在边上而不是节点上的,而节点只存了边的信息。只有当一条边存在时,才说明这条边表示的信息存在。所以实际上,存储的信息是在边上的。
字典树需要的空间开销往往较大,注意仔细计算。往往, M A X N MAXN MAXN 指的是字符串的长度乘以个数,树中儿子的个数是 26 26 26
模板1,统计字符串出现数
ll son[MAXN][30]; //字典树
ll cnt[MAXN], tot; //结尾处的标记
char s[MAXN];
int len;
char c[10];
void insert(char s[], int len) {
//往字典树中插入一个字符串
int now = 0; //0为根节点,now表示节点编号
for (int i = 1; i <= len; i++) {
int u = s[i] - 'a';
if (!son[now][u])
son[now][u] = ++ tot; //如果没有这个字符就给他加上
now = son[now][u]; //往下层继续寻找
}
cnt[now]++; //标记终点
}
int query(char s[], int len) {
//统计某个字符串出现的次数
int now = 0;
for (int i = 1; i <= len; i++) {
int u = s[i] - 'a';
if (!son[now][u])
return 0; //找不到了
now = son[now][u]; //往下层继续寻找
}
return cnt[now];
}
void solve() {
int n;
n = read();
while (n --) {
scanf("%s", c);
scanf("%s", s + 1);
int len = strlen(s + 1);
if (c[0] == 'I')
insert(s, len);
else
printf("%d\n", query(s, len));
}
}
模板2, 统计前缀
不仅要统计有多少个字符串是给定字符串的前缀,也要求出给定字符串是多少个字符串的前缀并求出他们的和
类似的定义一个数组 s u m sum sum ,表示节点编号为 i i i 的点被 经过 的次数。判断有多少字符串是给定字符串的前缀依然照旧计算。迭代到最后时,在累加上 s u m [ n o w ] sum[now] sum[now] 即为给定字符串可匹配的前缀的个数
ll n, m;
int son[500005][15], tot, cnt[MAXN], sum[MAXN];
int s[MAXN];
void insert(int s[], int len) {
int now = 0;
for (int i = 1; i <= len; i++) {
int u = s[i];
if (!son[now][u])
son[now][u] = ++ tot;
now = son[now][u];
sum[now] ++; //经过now
}
cnt[now]++; //以now结束
}
ll query(int s[], int len) {
int now = 0;
ll ans = 0;
for (int i = 1; i <= len; i++) {
int u = s[i];
if (cnt[now])
ans += cnt[now];
if (!son[now][u])
return ans;
now = son[now][u];
//if(cnt[now])
//ans+=sum[now]应该这样写,但为了方便就换成这种写法,因为后面还要减去cnt[now]+sum[now]有点啰嗦
}
ans = ans + sum[now]; //给定字符串作为前缀的匹配数量
return ans;
}
void solve() {
n = read(), m = read();
for (int i = 1; i <= n; i ++) {
//插入n个字符串
int len = read();
for (int j = 1; j <= len; j ++) s[j] = read();
insert(s, len);
}
for (int i = 1; i <= m; i ++) {
//统计前缀
int len = read();
for (int j = 1; j <= len; j++) s[j] = read();
printf("%lld\n", query(s, len));
}
}
01trie
定义
所谓 01 t r i e 01trie 01trie 即每个节点只有 0 , 1 0,1 0,1 两个儿子的 T r i e Trie Trie 树,比较经典的是贪心求异或最大值
tips: 有些题目需要cnt数组记录路径贡献(子树大小),这时从父亲节点往下走时一定要判断一下当前这条路径是否非空!!!或者把根节点和now改为1,这样就不用加上cnt的判断也能过(虽然不知道为什么)
模板1,最大异或对
由于异或对是不同的位数越大越多值就越大,所以要尽可能的匹配不同值的数。位数从高到低遍历,既满足前缀树的条件也根据异或的性质来贪心。
ll n;
ll a[MAXN];
ll son[MAXM][2], tot;
void insert(ll x) {
int now = 0;
for (int i = 31; i >= 0; i--) {
int u = (x >> i) & 1; //第i位的数
if (!son[now][u])
son[now][u] = ++tot;
now = son[now][u];
}
}
ll query(ll x) {
ll ans = 0;
ll now = 0;
for (int i = 31; i >= 0; i--) {
int u = (x >> i) & 1;
if (son[now][!u]) {
ans = ans * 2 + !u;
now = son[now][!u];
}
else {
ans = ans * 2 + u;
now = son[now][u];
}
}
return ans;
}
void solve() {
n = read();
for (int i = 1; i <= n; i++) a[i] = read();
ll ans = 0;
for (int i = 1; i <= n; i++) {
insert(a[i]);
ll b = query(a[i]);
ans = max(ans, a[i] ^ b);
}
printf("%lld\n", ans);
}
模板2,01trie的删除和维护
给定一个长度为 n n n 的序列 s i s_i si ,求 m a x ( s i + s j ) ⨁ s k ∣ i ≠ j , j ≠ k , i ≠ k max{(s_i+s_j)\bigoplus s_k} | i\neq j,j\neq k,i\neq k max(si+sj)⨁sk∣i=j,j=k,i=k
需要注意的是 i , j , k i,j,k i,j,k 必须各不相同。
基本一致。枚举 i , j i,j i,j ,由于必须保证不同,所以在查找前要先把 a [ i ] , a [ j ] a[i],a[j] a[i],a[j] 的贡献去除掉,也就是将字典树中 a [ i ] a[i] a[i] 和 a [ j ] a[j] a[j] 的路径清空 ,然后再查询 a [ i ] + a [ j ] a[i]+a[j] a[i]+a[j] 的最大异或值,最后再把 a [ i ] a[i] a[i] 和 a [ j ] a[j] a[j] 的贡献加上。
可以另开一个数组 c n t cnt cnt 记录节点访问次数,通过对访问次数的加减再写一个 u p d a t e update update 函数进行删除和添加操作。
ll n, m;
ll son[MAXN][2], tot, cnt[MAXN * 10];
ll a[MAXN];
void update(ll x, ll y) {
int now = 0;
for (int i = 31; i >= 0; i--) {
int u = (x >> i) & 1;
now = son[now][u];
cnt[now] += y;
}
}
ll query(ll x) {
int now = 0;
ll ans = 0;
for (int i = 31; i >= 0; i--) {
int u = (x >> i) & 1;
if (son[now][!u] && cnt[son[now][!u]]) {
//如果这个节点存在并且没有被删除掉
ans = ans * 2 + !u;
now = son[now][!u];
}
else {
ans = ans * 2 + u;
now = son[now][u];
}
}
return ans;
}
void solve() {
n = read();
for (int i = 1; i <= n; i++) a[i] = read(), update(a[i], 1);
ll ans = 0;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
update(a[i], -1), update(a[j], -1);
ans = max(ans, (a[i] + a[j]) ^ (query(a[i] + a[j])));
update(a[i], 1), update(a[j], 1);
}
}
printf("%lld\n", ans);
}
模板3,区间最大异或和
给定一个包含 n n n 个元素的数组, a 1 , a 2 , . . . , a n a_1,a_2,...,a_n a1,a2,...,an
计算 $(a_{l_1} \bigoplus a_{l_1+1} \bigoplus … \bigoplus a_{r_1}) +(a_{l_2} \bigoplus a_{l_2+1} \bigoplus … \bigoplus a_{r_2}) $ 的最大值 , l 2 > r 1 l_2 > r_1 l2>r1
异或运算有一个特别的性质就是 x ⨁ x = 0 x \bigoplus x=0 x⨁x=0
a
l
1
⨁
a
l
1
+
1
⨁
a
l
1
+
2
⨁
.
.
.
⨁
a
r
1
=
(
a
1
⨁
a
2
⨁
.
.
.
⨁
a
l
−
1
)
⨁
(
a
1
⨁
a
2
⨁
a
3
⨁
.
.
.
⨁
a
r
)
a_{l_1}\bigoplus a_{l_1+1} \bigoplus a_{l_1+2} \bigoplus ... \bigoplus a_{r_1} =(a_1 \bigoplus a_2 \bigoplus ... \bigoplus a_{l-1})\bigoplus(a_1\bigoplus a_2 \bigoplus a_3 \bigoplus ... \bigoplus a_r)
al1⨁al1+1⨁al1+2⨁...⨁ar1=(a1⨁a2⨁...⨁al−1)⨁(a1⨁a2⨁a3⨁...⨁ar)
这样区间求异或最大值就转化成了最大异或对问题。一直维护一个前缀异或和,然后查找该值能匹配到的最大异或值,最后把当前的前缀异或和插入到字典树中。用类似
D
P
DP
DP 的过程来表示区间
[
1
,
i
]
[1,i]
[1,i] 中能取到的最大区间异或和为
l
[
i
]
l[i]
l[i] ,转移方程为
l
[
i
]
=
m
a
x
(
l
[
i
−
1
]
,
q
u
e
r
y
(
前缀异或和
)
⨁
前缀异或和
)
l[i]=max(l[i-1],query(前缀异或和)\bigoplus前缀异或和)
l[i]=max(l[i−1],query(前缀异或和)⨁前缀异或和)
因为要保证两个区间不相交,所以还要反着来一遍后缀异或和,推理过程类似。
r [ i ] r[i] r[i] 表示区间 [ i , n ] [i,n] [i,n] 中能取到的最大区间异或和为 r [ i ] r[i] r[i] ,转移方程为 r [ i ] = m a x ( r [ i ] , q u e r y ( 后缀异或和 ) ⨁ ( 后缀异或和 ) ) r[i]=max(r[i],query(后缀异或和)\bigoplus(后缀异或和)) r[i]=max(r[i],query(后缀异或和)⨁(后缀异或和))
**update: **说一下这类求最大异或区间通用的细节。若先查询后插入,那么需要提前插入一个 0 0 0 ;若先插入后查询,则需保证前缀的第一位和后缀的第一位一定为 0 0 0。 这题就是因为没保证后缀第一位为 0 0 0 导致疯狂 W A WA WA ,后来 i n s e r t insert insert 了个 0 0 0 才过了,其实是画蛇添足。
ll son[MAXN][2], tot, tmp;
ll pre[400005], suf[400005];
ll n, a[400005];
void insert(ll x) {
int now = 0;
for (int i = 31; i >= 0; i--) {
int u = (x >> i) & 1;
if (!son[now][u])
son[now][u] = ++tot;
now = son[now][u];
}
}
ll query(ll x) {
int now = 0;
ll ans = 0;
for (int i = 31; i >= 0; i--) {
int u = (x >> i) & 1;
if (son[now][!u]) {
ans = ans * 2 + !u;
now = now[son][!u];
}
else {
ans = ans * 2 + u;
now = now[son][u];
}
}
return ans;
}
void solve() {
n = read();
for (int i = 1; i <= n; i++) a[i] = read();
tmp = 0;
for (int i = 1; i <= n; i++) {
tmp ^= a[i];
insert(tmp);
pre[i] = max(pre[i - 1], query(tmp) ^ tmp);
}
insert(0);
for (int i = n; i >= 1; i--) {
tmp ^= a[i];
insert(tmp);
suf[i] = max(suf[i + 1], query(tmp) ^ tmp);
}
ll ans = 0;
for (int i = 1; i < n; i++) ans = max(ans, pre[i] + suf[i + 1]);
printf("%lld\n", ans);
}
可持久化 trie
概述
可持久化是一个重要的思想。我对于可持久化的理解就是:支持回到一个历史版本怀旧服并能够在历史版本上进行询问。
既然要访问历史版本,那么最直观的想法就是开 m m m 个数组来记录下每个版本的 t r i e trie trie 树。当需要插入一个新的节点时,先完全复制上一个历史版本,然后再进行操作。然而这样操作空间是很捉急的。要知道, t r i e trie trie 树的本质目的是为了进行空间优化,实现的方法是公用枝条。可以在可持久化 t r i e trie trie 中借鉴这种思想。
可以注意到,不是有必要把所有点都复制过来。可以这样操作,规定一个数组为 r t [ M A X N ] rt[MAXN] rt[MAXN]作为各个版本的入口,每次插入新版本前都先更新这个入口的编号。与朴素 t r i e trie trie 节点不同的是,每次插入一个点都必须新建一个节点,然后去看看上个版本的信息。如果上个版本同等高度下具有相同的点,把这个点的儿子复制下来(除了当前要插入的点),在把要插入的边填到新节点下。插入完之后一起往下走递归操作直到抵达叶子节点。
通过上图可以发现,从一个版本入口开始遍历树,一定只能获得该版本内的所有串,并且空间大大减少。
模板,可持久化01trie
给定一个长度为 n n n 的非负整数序列 a a a
有
m
m
m 个操作,在末尾添加一个数,长度增加一;输入
l
,
r
,
x
l,r,x
l,r,x,找到一个位置
p
p
p ,满足
l
≤
p
≤
r
l \leq p \leq r
l≤p≤r ,使得:
a
[
p
]
⨁
a
[
p
+
1
]
⨁
.
.
.
⨁
a
[
n
]
⨁
x
a[p] \bigoplus a[p+1] \bigoplus ... \bigoplus a[n] \bigoplus x
a[p]⨁a[p+1]⨁...⨁a[n]⨁x 最大并输出这个最大值
a
[
p
]
⨁
a
[
p
+
1
]
⨁
.
.
.
⨁
a
[
n
]
⨁
x
=
s
u
m
[
p
−
1
]
⨁
s
u
m
[
n
]
⨁
x
a[p] \bigoplus a[p+1] \bigoplus ... \bigoplus a[n] \bigoplus x=sum[p-1] \bigoplus sum[n] \bigoplus x
a[p]⨁a[p+1]⨁...⨁a[n]⨁x=sum[p−1]⨁sum[n]⨁x
这个式子很容易推出来。
s
u
m
[
n
]
⨁
x
sum[n] \bigoplus x
sum[n]⨁x 可以直接算出来,所以目标就是利用
t
r
i
e
trie
trie 快速求出
s
u
m
[
p
−
1
]
sum[p-1]
sum[p−1] 使得
l
−
1
≤
p
−
1
≤
r
−
1
l-1 \leq p-1 \leq r-1
l−1≤p−1≤r−1 且异或上某个值最大。
利用可持久化的思想,满足 p − 1 ≤ r − 1 p-1 \leq r-1 p−1≤r−1 很容易实现,只要从 r − 1 r-1 r−1 版本入口进入字典树查询即可。
考虑新建一个数组 m x [ M A X M ] mx[MAXM] mx[MAXM] 表示某位上的某个节点出现在串中的最新时间(版本),如果了解 t a r j a n tarjan tarjan 求强连通分量的算法,这个数组记录的就是时间戳。这样在查询的过程中,我们只要保证该点下的子节点中至少存在一个节点的 m x [ i ] mx[i] mx[i] 大于等于 l − 1 l-1 l−1 ,就一定能够保证这条路径的出现时间是大于等于 l − 1 l-1 l−1 的。
有一个细节。 0 0 0 号节点在 t r i e trie trie 中意义非凡,既表示不存在也表示为根节点,在朴素字典树中根节点是不能存储信息的。因为前缀和的存在,必须插入一个值为 0 0 0的点作为 0 0 0 号版本。可是 m x [ 0 ] mx[0] mx[0] 初始值为 0 0 0 ,也就是 0 0 0 号节点最新出现在 0 0 0 号版本 。可显然 0 0 0 号节点是不能被访问到的,所以要把其初始化为任意负数。
一段一段分析插入和查询的代码吧。
插入:
void insert(int id, int last, int now)
//id表示当前插入的前缀和编号为i,last表示上个版本下和现在这个版本上和now同等意义的点
{
mx[now] = id; //更新最新版本信息
for (int i = 31; i >= 0; i --) //枚举每一位
{
int u = sum[id] >> i & 1; //取出当前位数
//如果前一个节点存在当前节点没有的分支,就把当前这个节点的空的路径指过去,相当于复制
if (last)
son[now][!u] = son[last][!u];
son[now][u] = ++ tot; //正常的插入操作
//可以注意到此时只复制了!u这个点的信息,u方向的还没复制过
//不是说不用复制了
//而是因为now现在插入的数字是u,而last版本下这条路径也是u,所以暂时不需要复制
//如果之后的某个位置与last版本出现了偏差,在从那里开始复制就好了
last = son[last][u], now = son[now][u]; //同步往下走(递归)
mx[son[now][u]] = id; //更新当前点的最新版本信息
}
}
查询:
ll query(int now, int l, ll x)
{
ll ans = 0;
for (int i = 31; i >= 0; i --)
{
int u = (x >> i) & 1;
if (mx[son[now][!u]] >= l)
//1.!u这个点要出现过
//2.!u这个点的最新版本出现时间要大于等于l
//2包含1,只要满足2那么1也一定满足
{
now = son[now][!u];
ans = ans * 2 + !u;
}
else
{
now = son[now][u];
ans = ans * 2 + u;
}
}
return ans;
}
初始化:
mx[0] = -114514;
//看不懂翻上面,还看不懂建议注释掉这句话。
//然后看看输入111,1111,11111这种与0异或后是最大的情况,看看发生什么
rt[0] = ++ tot;
sum[0] = 0;
insert(0, 0, rt[0]);//前缀和0是有意义的,必须插入
完整代码:
int n, m;
int son[MAXM][2], sum[MAXN], mx[MAXM], tot;
int rt[MAXM];
void insert(int id, int last, int now) {
mx[now] = id;
for (int i = 31; i >= 0; i--) {
int u = (sum[id] >> i) & 1;
if (last)
son[now][!u] = son[last][!u];
son[now][u] = ++ tot;
last = son[last][u], now = son[now][u];
mx[son[now][u]] = id;
}
}
ll query(int now, int l, ll x) {
ll ans = 0;
for (int i = 31; i >= 0; i--) {
int u = (x >> i) & 1;
if (mx[son[now][!u]] >= l) {
now = son[now][!u];
ans = ans * 2 + !u;
}
else {
now = son[now][u];
ans = ans * 2 + u;
}
}
return ans;
}
void solve() {
n = read(), m = read();
mx[0] = -114514;
rt[0] = ++tot;
sum[0] = 0;
insert(0, 0, rt[0]);
for (int i = 1; i <= n; i++) {
ll tmp;
tmp = read();
sum[i] = sum[i - 1] ^ tmp;
rt[i] = ++ tot;
insert(i, rt[i - 1], rt[i]);
}
while (m--) {
char s[10];
scanf("%s", s);
if (s[0] == 'A') {
//插入一个数
ll x;
x = read();
n ++; //多了一个数
sum[n] = sum[n - 1] ^ x; //更新前缀和
rt[n] = ++ tot; //新增一个版本
insert(n, rt[n - 1], rt[n]);
}
else {
ll x, l, r;
l = read(), r = read(), x = read();
//query返回的是从r-1这个版本进去,最早版本为l-1,与sum[n]^x异或后最大的数
printf("%lld\n", query(rt[r - 1], l - 1, sum[n] ^ x) ^ (sum[n] ^ x));
}
}
}
并查集
朴素并查集
int f[MAXN]; //祖先节点
void init() {
//初始化
for (int i = 1; i <= n; i ++) f[i] = i;
}
int findx(int x) {
//找根节点
if (x == f[x]) return x;
else return f[x] = findx(f[x]);
}
void merge(int x, int y) {
//合并
int fx = findx(x);
int fy = findx(y);
if (fy != fx) f[fy] = fx;
}
维护size的并查集
int f[MAXN]; //祖先节点
int size[MAXN]; //集团内部元素个数
void init() {
for (int i = 1; i <= n; i ++) f[i] = i, size[i] = 1;
}
int findx(int x) {
if (x == f[x]) return x;
else return f[x] = findx(f[x]);
}
void merge(int x, int y) {
int fx = findx(x);
int fy = findx(y);
if (fy != fx) size[fx] += size[fy], f[fy] = fx;
}
带权并查集(节点到根的权值)
概述
这就是基于路径压缩的带权并查集的样子。可以看到,在普通并查集的基础上,带权并查集在边中添加了一些额外的信息可以更好的处理问题。在 边上记录额外的信息 的并查集就是 带权并查集 。
t i p s : tips: tips: 要判断矛盾时可以想想带权并查集能不能做
带权并查集のfind
每一条边都记录了节点到根节点的一个权值。基于路径压缩就会产生两个问题:
- 我们希望得到的是 节点与根节点 的权值。但是在路径压缩之前,每个节点都是与 父节点 连接的,这个权值自然也是和其 父节点 的权值。因此在路径压缩的过程中,权值也应当做出更新。
- 在两个集团进行合并时,因为两个集团的根节点不同,需要把一个集团的根节点同时赋为另一个集团的根节点。自然也要进行权值的更新
先记录下原本父节点的编号,接着路径压缩后父节点就会变成根节点,此时父节点的权值,已经是父节点到根节点的权值了。再加上当前节点与其父节点的权值,就会得到当前节点到根节点的权值
带权并查集のmerge
第一张图是 m e r g e merge merge 前,第二张图是 m e r g e merge merge 后
我们需要求出 p x px px 和 p y py py 这条边的权值为多少。显然 x → y → p y x \rightarrow y \rightarrow py x→y→py 和 $x \rightarrow px \rightarrow py $ 这两条边的权值之和应该相同。容易求出 v a l u e [ p x ] = v a l u e [ y ] + t e m p − v a l u e [ x ] value[px]=value[y]+temp-value[x] value[px]=value[y]+temp−value[x] (temp为题目给出的关系)。虽然思想一样,但实际上更新方法都是需要都是参考具体题目的。
int val[MAXN]; //表示节点x到根的权值
int f[MAXN];
int findx(int x) {
if (x != f[x]) {
int t = f[x];//记录父亲节点的编号
f[x] = findx(f[x]);
val[x] += val[t];//父节点已经完成更新了
}
return f[x];
}
void merge(int x, int y) {
int fx=findx(x);
int fy=findx(y)
if(fx!=fy) {
f[fx]=fy;
val[fx]=val[y]+temp-val[x];
}
}
哈希
字符串哈希
模板,使用 u n s i g n e d l o n g l o n g unsigned long long unsignedlonglong 自然取模
typedef unsigned long long ULL;
ULL h[N], p[N]; // h[k]存储字符串前k个字母的哈希值, p[k]存储 P^k mod 2^64
// 初始化
p[0] = 1;
for (int i = 1; i <= n; i++) {
h[i] = h[i - 1] * P + str[i];
p[i] = p[i - 1] * P;
}
// 计算子串 str[l ~ r] 的哈希值
ULL get(int l, int r) {
return h[r] - h[l - 1] * p[r - l + 1];
}
双哈希,即计算子串哈希值时取两个不同的模数来增加精确性
ull hash1[MAXN], ull hash2[MAXN], ull pre1[MAXN], ull pre2[MAXN];
ull p = 131; //指定质数
ll n;
int cnt = 0;
char s[MAXN];
pair<ull, ull> temp[MAXN];
map<pair<ull, ull>, ll> mp; //哈希表,key为双哈希组成的二元组,value为相同的个数
void init() {
pre1[0] = 1;
pre2[0] = 1;
for (int i = 1; i <= 400000; i++) {
pre1[i] = (pre1[i - 1]) * p % mod1;
pre2[i] = (pre2[i - 1]) * p % mod2;
}
}
ull hashing1() {
int m = strlen(s + 1);
for (int i = 1; i <= m; i++) {
hash1[i] = (hash1[i - 1] * p % mod1 + (s[i] - 'a' + 1)) % mod1;
}
return hash1[m];
}
ull hashing2() {
int m = strlen(s + 1);
for (int i = 1; i <= m; i++) {
hash2[i] = (hash2[i - 1] * p % mod2 + (s[i] - 'a' + 1)) % mod2;
}
return hash2[m];
}
ull query1(ll l, ll r) {
return (hash1[r] - hash1[l - 1] * pre1[r - l + 1] % mod1 + mod1) % mod1;
}
ull query2(ll l, ll r) {
return (hash2[r] - hash2[l - 1] * pre2[r - l + 1] % mod2 + mod2) % mod2;
}
pair<ull, ull> query(ll l, ll r) {
return make_pair<ull, ull>(query1(l, r), query2(l, r));
}
STL常用
vector
变长数组,倍增的思想
size() 返回元素个数
empty() 返回是否为空
clear() 清空
front()/back()
push_back()/pop_back()
begin()/end()
[]
支持比较运算,按字典序
vector<int> a(n, x) n个x
pair
typedef pair<int, int> pii;
pair<int, int>
first, 第一个元素
second, 第二个元素
支持比较运算,以first为第一关键字,以second为第二关键字(字典序)
string字符串
size()/length() 返回字符串长度
empty()
clear()
substr(起始下标,(子串长度)) 返回子串
c_str() 返回字符串所在字符数组的起始地址、
int pos = s.find("abc"); //返回"abc"在做字符串s中的下标位置
int pos = s.find("b", 5); //从字符串s下标5开始,寻找字符串b
string s1 = "abcd", s2 = "abcdedg";
s1.find_first_not_of(s2); //查找s1中与s2第一个不匹配的位置
string str, s1;
int pos = str.rfind(s1); //返回s1在str中最后出现的位置
int pos = str.find_first_of(s1) //返回s1在str中第一次出现的位置
queue队列
size()
empty()
push() 向队尾插入一个元素
front() 返回队头元素
back() 返回队尾元素
pop() 弹出队头元素
priority_queue 优先队列(大根堆)
size()
empty()
push() 插入一个元素
top() 返回堆顶元素
pop() 弹出堆顶元素
定义成小根堆的方式:priority_queue<int, vector<int>, greater<int>> q;
stack栈
size()
empty()
push() 向栈顶插入一个元素
top() 返回栈顶元素
pop() 弹出栈顶元素
deque双端队列
size()
empty()
clear()
front()/back()
push_back()/pop_back()
push_front()/pop_front()
begin()/end()
[]
set,map,基于红黑树动态维护有序序列
size()
empty()
clear()
begin()/end()
++, -- 返回前驱和后继,时间复杂度 O(logn)
重载运算符时要重载全
set/multiset
insert() 插入一个数
find() 查找一个数
count() 返回某一个数的个数
erase()
(1) 输入是一个数x,删除所有x O(k + logn)
(2) 输入一个迭代器,删除这个迭代器
lower_bound()/upper_bound()
lower_bound(x) 返回大于等于x的最小的数的迭代器
upper_bound(x) 返回大于x的最小的数的迭代器
map/multimap
insert() 插入的数是一个pair
erase() 输入的参数是pair或者迭代器
find()
[] 注意multimap不支持此操作。 时间复杂度是 O(logn)
lower_bound()/upper_bound()
unordered_set, unordered_map, unordered_multiset, unordered_multimap, 哈希表
和上面类似,增删改查的时间复杂度是 O(1)
不支持 lower_bound()/upper_bound(), 迭代器的++,--
bitset
bitset大概就是类似于bool数组一样的东西
但是它的每个位置只占1bit(特别特别小)
bitset的原理大概是将很多数压成一个,从而节省空间和时间(暴力出奇迹)
一般来说bitset会让你的算法复杂度 /32
定义方法:bitset<10000> s;
bitset类型可以用string和整数初始化(整数转化成对应的二进制)
bitset<23>bit (string("11101001"));
cout<<bit<<endl;
bit=233;
cout<<bit<<endl;
输出结果:
00000000000000011101001
00000000000000011101001
bitset支持所有位运算
bitset<23>bita(string("11101001"));
bitset<23>bitb(string("11101000"));
cout<<(bita^bitb)<<endl;
//输出00000000000000000000001
bitset<23>bita(string("11101001"));
bitset<23>bitb(string("11101000"));
cout<<(bita|bitb)<<endl;
//输出00000000000000011101001
bitset<23>bita(string("11101001"));
bitset<23>bitb(string("11101000"));
cout<<(bita&bitb)<<endl;
//输出00000000000000011101000
bitset<23>bit(string("11101001"));
cout<<(bit<<5)<<endl;
//输出00000000001110100100000
bitset<23>bit(string("11101001"));
cout<<(bit>>5)<<endl;
//输出00000000000000000000111
~, &, |, ^
>>, <<
==, !=
[]
bitset方法
bit.size() 返回大小(位数)
bit.count() 返回1的个数
bit.any() 返回是否有1
bit.none() 返回是否没有1
bit.set() 全都变成1
bit.set(p) 将第p + 1位变成1(bitset是从第0位开始的!)
bit.set(p, x) 将第p + 1位变成x
bit.reset() 全都变成0
bit.reset(p) 将第p + 1位变成0
bit.flip() 全都取反
bit.flip(p) 将第p + 1位取反
bit.to_ulong() 返回它转换为unsigned long的结果,如果超出范围则报错
bit.to_ullong() 返回它转换为unsigned long long的结果,如果超出范围则报错
bit.to_string() 返回它转换为string的结果
树状数组
概述
基于二进制
图中 a a a 表示原数组, c c c 表示树状数组包含的区间
查询的时候根据图来理解就是 l o w b i t lowbit lowbit 一路减过去得到的各个区间的值的和
修改是因为每个单节点可能对多个区间有贡献,所以要 l o w b i t lowbit lowbit 一路加上去做出自己相对应的贡献
基础模板
#define MAXN 200005
int n;
int a[MAXN];//原数组
int c[MAXN];//树状数组,表示树状数组节点i节点覆盖的范围和
int lowbit(int x) {
//返回非负整数x在二进制表示下最低位1及其后面的0构成的数值
return x & -x;
}
void add(int x, int k) {
//将原序列中第x个数加上k
for (int i = x; i <= n; i += lowbit(i)) c[i] += k;
}
int ask(int x) {
//查询序列前x个数的和
int sum = 0;
for(int i = x; i; i -= lowbit(i)) sum += c[i];
return sum;
}
求逆序对
根据前缀和的的思想,比 v a l val val 大的数量为 s u m [ n ] − s u m [ v a l ] sum[n]-sum[val] sum[n]−sum[val] 。前缀和又可以用树状数组来维护。
先从左往右,每次枚举一个新的数时,先直接用树状数组查找当前比他大的数有几个,然后用类似桶的思想更新这个数的贡献。他对桶的贡献为 1 1 1 。
int c[MAXN];//这是一个类似桶的东西,记录每个数出现了几次
for(int i = 1; i <= n; i++) {
int y = a[i];
big[i] = ask(n) - ask(y);//当前该点逆序对数量
add(y, 1);//加上1的贡献
}
区间修改+单点查询
核心就是维护一个差分数组的前缀和
int n, m;
int a[MAXN];
int c[MAXN];
int lowbit(int x) { return x & -x; }
int ask(int x) {
int sum = 0;
for (int i = x; i; i -= lowbit(i)) sum += c[i];
return sum;
}
void add(int x, int k) {
for (int i = x; i <= n; i += lowbit(i)) c[i] += k;
}
void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> c[i];
for (int i = 1; i <= n; i++) a[i] = c[i] - c[i - 1];
memset(c, 0, sizeof c);
for (int i = 1; i <= n; i++) add(i, a[i]);
while (m--) {
char c;
cin >> c;
if (c == 'Q') {
int x;
cin >> x;
cout << ask(x) << endl;
}
else {
int l, r, k;
cin >> l >> r >> k;
add(l, k), add(r + 1, -k);
}
}
}
树状数组求kth
二分出前缀和等于 k k k 的位置
int n, q;
int c[MAXN];
int lowbit(int x) {
return x & -x;
}
int ask(int x) {
int ans = 0;
for (int i = x; i; i -= lowbit(i)) {
ans += c[i];
}
return ans;
}
int kth(int x) {
int l = 1, r = n;
int ans;
while (l <= r) {
int mid = l + r >> 1;
if (ask(mid) >= x) {
ans = mid;
r = mid - 1;
}
else
l = mid + 1;
}
return ans;
}
void update(int x, int val) {
for (int i = x; i <= n; i += lowbit(i))
c[i] += val;
}
void solve() {
n = read(), q = read();
for (int i = 1; i <= n; i++) {
int x = read();
update(x, 1);
}
for (int i = 1; i <= q; i++) {
int opt = read();
if (opt > 0) {
update(opt, 1);
}
else if (opt < 0) {
opt = -opt;
int num = kth(opt);
update(num, -1);
}
}
if (!ask(n)) {
puts("0");
return;
}
for (int i = 1; i <= n; i++) {
if (ask(i) - ask(i - 1) > 0) {
printf("%d\n", i);
return;
}
}
}
二维树状数组求子矩阵
第一行两个整数,n(1 <= n <= 1000)和m(1 <= m <= 100000),分别代表正方形格子的边长和询问次数。
接下来n行,每一行有n个bool形数字(0或1),代表灯泡的状态。
接下来m行,每一行第一个数字f(1或2)代表操作的类型,如果f是1,那么接下来输入一个坐标(x, y)(1 <= x, y <= n),对于当前位置的灯泡状态进行改变,如果是2,那么接下来输入两个坐标(x1, y1)(1 <= x1, y1 <= n), (x2, y2)(1 <= x2, y2 <= n),确定子矩阵的位置,输出子矩阵中亮着的灯泡数量并换行。
int c[MAXN][MAXN], a[MAXN][MAXN];
int n, m;
int lowbit(int x) {
return x & -x;
}
void update(int x, int y, int val) {
for (int i = x; i <= n; i += lowbit(i))
for (int j = y; j <= n; j += lowbit(j))
c[i][j] += val;
}
int ask(int x, int y) {
int ans = 0;
for (int i = x; i; i -= lowbit(i))
for (int j = y; j; j -= lowbit(j))
ans += c[i][j];
return ans;
}
void solve() {
n = read(), m = read();
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
a[i][j] = read();
if (a[i][j])
update(i, j, 1);
}
}
while (m--) {
int opt = read();
if (opt == 1) {
int x = read(), y = read();
if (a[x][y] == 1) {
a[x][y] = 0;
update(x, y, -1);
}
else {
a[x][y] = 1;
update(x, y, 1);
}
}
else if (opt == 2) {
int x = read(), y = read(), xx = read(), yy = read();
printf("%d\n", ask(xx, yy) - ask(xx, y - 1) - ask(x - 1, yy) + ask(x - 1, y - 1));
}
}
}
线段树
普通线段树
区间合并
单点修改和查询区间内最大的连续子段和(有负数)
int n, m;
int a[MAXN];
struct segmentnode {
int l, r;
ll sum, lmx, rmx, mx; //mx表示最大区间和,lsum表示最大前缀和,rsum表示最大后缀和
} tree[MAXN << 2];
void pushup(int rt) {
int suml = tree[rt << 1].mx;
int sum = tree[rt << 1].rmx + tree[rt << 1 | 1].lmx;
int sumr = tree[rt << 1 | 1].mx;
tree[rt].sum = tree[rt << 1].sum + tree[rt << 1 | 1].sum;
tree[rt].mx = max({suml, sum, sumr});
tree[rt].lmx = max(tree[rt << 1].lmx, tree[rt << 1].sum + tree[rt << 1 | 1].lmx);
tree[rt].rmx = max(tree[rt << 1 | 1].rmx, tree[rt << 1 | 1].sum + tree[rt << 1].rmx);
}
void build(int rt, int l, int r) {
tree[rt].l = l, tree[rt].r = r;
if (l == r) {
tree[rt].sum = a[l];
tree[rt].mx = a[l];
tree[rt].lmx = a[l], tree[rt].rmx = a[l];
return;
}
int mid = l + r >> 1;
build(rt << 1, l, mid);
build(rt << 1 | 1, mid + 1, r);
pushup(rt);
}
segmentnode query(int rt, int l, int r) {
if (tree[rt].l >= l && tree[rt].r <= r)
return tree[rt];
int mid = (tree[rt].l + tree[rt].r) >> 1;
if (mid >= r)
return query(rt << 1, l, r);
else if (mid + 1 <= l)
return query(rt << 1 | 1, l, r);
else {
auto left = query(rt << 1, l, r);
auto right = query(rt << 1 | 1, l, r);
segmentnode ans;
ans.sum = left.sum + right.sum;
ans.lmx = max(left.lmx, left.sum + right.lmx);
ans.rmx = max(right.rmx, right.sum + left.rmx);
ans.mx = max({left.mx, right.mx, left.rmx + right.lmx});
return ans;
}
}
void update(int rt, int pos, ll x) {
if (tree[rt].l == tree[rt].r && pos == tree[rt].l) {
tree[rt].sum = x;
tree[rt].mx = x;
tree[rt].lmx = x, tree[rt].rmx = x;
return;
}
int mid = tree[rt].l + tree[rt].r >> 1;
if (pos <= mid)
update(rt << 1, pos, x);
else if (pos > mid)
update(rt << 1 | 1, pos, x);
pushup(rt);
}
void solve() {
n = read(), m = read();
for (int i = 1; i <= n; i ++) a[i] = read();
build(1, 1, n);
while (m--) {
int opt, x, y;
opt = read(), x = read(), y = read();
if (opt == 1) {
//查询
if (x > y) swap(x, y);
printf("%lld\n", query(1, x, y).mx);
}
else if (opt == 2) //修改
update(1, x, y);
}
}
懒惰节点的熟练应用(区间加+区间乘两种懒惰标记)
ll a[MAXN];
ll n, m, mod;
ll opt, l, r, x, y;
struct node {
int l, r, len;
ll sum;
ll add, mul; //懒惰标记
} tree[MAXN << 2];
void work(node &now, ll c, ll d) {
//c(ax+b)+d
now.sum = (now.sum * c + now.len * d) % mod;
now.mul = (now.mul * c) % mod;
now.add = (now.add * c + d) % mod;
}
void pushup(int rt) {
tree[rt].sum = (tree[rt << 1].sum + tree[rt << 1 | 1].sum) % mod;
}
void pushdown(int rt) {
work(tree[rt << 1], tree[rt].mul, tree[rt].add);
work(tree[rt << 1 | 1], tree[rt].mul, tree[rt].add);
tree[rt].add = 0;
tree[rt].mul = 1;
}
void build(int rt, int l, int r) {
tree[rt].l = l;
tree[rt].r = r;
tree[rt].len = r - l + 1;
tree[rt].add = 0, tree[rt].mul = 1;
if (l == r) {
tree[rt].sum = a[l];
return;
}
int mid = l + r >> 1;
build(rt << 1, l, mid);
build(rt << 1 | 1, mid + 1, r);
pushup(rt);
}
void update(int rt, int l, int r, ll c, ll d) {
//c表示乘,d表示加
if (tree[rt].l >= l && tree[rt].r <= r) {
work(tree[rt], c, d);
return;
}
pushdown(rt);
int mid = tree[rt].l + tree[rt].r >> 1;
if (mid >= l)
update(rt << 1, l, r, c, d);
if (mid + 1 <= r)
update(rt << 1 | 1, l, r, c, d);
pushup(rt);
}
ll query(int rt, int l, int r) {
if (tree[rt].l >= l && tree[rt].r <= r) {
return tree[rt].sum;
}
pushdown(rt);
int mid = tree[rt].l + tree[rt].r >> 1;
ll ans = 0;
if (mid >= l)
ans = (ans + query(rt << 1, l, r)) % mod;
if (mid + 1 <= r)
ans = (ans + query(rt << 1 | 1, l, r)) % mod;
return ans;
}
void solve() {
n = read(), mod = read();
for (int i = 1; i <= n; i ++) a[i] = read();
build(1, 1, n);
m = read();
while (m--) {
opt = read();
if (opt == 1) {
//multiply
l = read(), r = read(), x = read();
update(1, l, r, x, 0);
}
else if (opt == 2) {
l = read(), r = read(), x = read();
update(1, l, r, 1, x);
}
else {
l = read(), r = read();
printf("%lld\n", query(1, l, r));
}
}
}
动态开点
建立一颗 "残疾"的线段树,上面只有询问过的相关节点,从而节省了大量空间,让值域特别大的权值线段树的方案可行。
原版线段树是一颗完整的二叉树,所以可以采取计算的方法求出左右儿子的编号。可是动态开点法不一样,因为这是一颗残疾的树,此时这种计算方法根本无效,所以左右儿子节点必须人为规定。
具体实现方法就是,如果当前的节点还未被使用过( r t rt rt == 0),那么就把 ++ i d x idx idx 赋给这个点。与此同时,由于是递归回溯结构,所以这个时候该点的左儿子编号和右儿子编号都已经确定过了。
几个注意点: i d x idx idx 初值赋为 1 1 1,给根节点预留出来;动态开点结构体存的是左右儿子的编号不是区间信息,区间信息通过传参引入; u p d a t e update update 时 r t rt rt 必须加上取址符,因为这个传进来的 r t rt rt 需要变化。
空间复杂度: O ( q l o g N ) O(qlogN) O(qlogN) ,时间复杂度: O ( q l o g N ) O(qlogN) O(qlogN)
以求逆序对为例
int n, idx = 1, rt = 1; //idx必须设为1
int LL = 1, RR = 1000000000; //值域
struct node {
int l, r; //左右儿子的编号
ll cnt;
}tree[MAXN * 27];
void pushup (int rt) {tree[rt].cnt = tree[tree[rt].l].cnt + tree[tree[rt].r].cnt;}
void update(int &rt, int L, int R, ll val, ll y) {
//别忘了加取址符
if (!rt)
rt = ++idx;
if (L == R) {
tree[rt].cnt += y;
return;
}
ll mid = L + R >> 1;
if (val <= mid) update(tree[rt].l, L, mid, val, y);
else update(tree[rt].r, mid + 1, R, val, y);
pushup(rt);
}
ll query(int rt, int L, int R, int l, int r) {
if (L >= l && R <= r) return tree[rt].cnt;
int mid = L + R >> 1;
ll ans = 0;
if (mid + 1 <= r) ans += query(tree[rt].r, mid + 1, R, l, r);
if (l <= mid) ans += query(tree[rt].l, L, mid, l, r);
return ans;
}
void solve() {
n = read();
ll ans = 0;
for (int i = 1; i <= n; i++) {
int num = read();
ans += query(rt, LL, RR, num + 1, RR);
update(rt, LL, RR, num, 1);
}
printf("%lld\n", ans);
}
权值线段树
定义
权值线段树是线段树的一个扩展,但是不同于普通线段树,它维护的是值域。
举个例子,对于一个给定的数组,普通线段树可能维护的是某个子数组的和,而权值线段树可以维护某个区间内数组元素出现的次数。
在实现上,由于值域范围通常较大,权值线段树一般采用 离线+离散化+堆式存储 或者 动态开点+链式存储的策略来优化空间。单次操作的时间复杂度为 O ( l o g n ) O(logn) O(logn)。
权值线段树的节点用来表示一个区间的数出现的次数,以下图为例
存储结构
堆式存储:rt, rt << 1 | 1, rt << 1,
链式存储: struct node {int cnt, l, r},l和r不再存放区间的范围而是左儿子和右儿子的下标
基本作用
查询第 k k k 小或第 k k k 大
查询某个数的排名
查询比某个数小的最大值,查询比某个数大的最小值
堆式存储(常规线段树)模板(以普通平衡树为例题)
int n; //节点个数
vector <int> num; //离散化后的数据
int opt[MAXN], a[MAXN]; //离线存储操作
struct node {
// 基于二叉堆建树,而非指针
int l, r, cnt; //值域[l,r](离散化后)里出现了cnt个数, l,r表示区间中的理论最值
}tree[MAXN << 2];
void pushup(int rt) {
tree[rt].cnt = tree[rt << 1].cnt + tree[rt << 1 | 1].cnt;
}
void build(int rt, int l, int r) {
tree[rt] = {l, r, 0};
if (l == r) return;
int mid = l + r >> 1;
build(rt << 1, l, mid);
build(rt << 1 | 1, mid + 1, r);
}
void update(int rt, int x, int y) {
//插入或删除一个值为x的数
if(tree[rt].l == tree[rt].r) {
tree[rt].cnt += y;
return;
}
int mid = tree[rt].l + tree[rt].r >> 1;
if (x <= mid) update(rt << 1, x, y);
if (mid + 1 <= x) update(rt << 1 | 1, x, y);
pushup(rt);
}
int getrank(int rt, int x) {
//查询值为x的数的排名,实则查询[1,x-1]已出现的数字的个数,相当于区间查询
if (tree[rt].r < x) return tree[rt].cnt;
int mid = tree[rt].l + tree[rt].r >> 1;
int ans = getrank(rt << 1, x); //左边肯定有贡献,因为会取到最小值
if (x > mid + 1) ans += getrank(rt << 1 | 1, x);
return ans;
}
int kth(int rt, int k) {
//查询排名为k的数的大小
if (tree[rt].l == tree[rt].r) {
return tree[rt].l;
}
if (tree[rt << 1].cnt >= k) return kth(rt << 1, k);
else return kth(rt << 1 | 1, k - tree[rt << 1].cnt); //注意减去左子树的大小
}
int findmx(int rt) {
//区间中的实际最大值
if (tree[rt].l == tree[rt].r) return tree[rt].r;
if (tree[rt << 1 | 1].cnt) return findmx(rt << 1 | 1); //右子树中还有数
else return findmx(rt << 1);
}
int findpre(int rt, int x) {
//查找最大的比x小的数是多少
if (tree[rt].r < x) {
if (tree[rt].cnt) return findmx(rt);
else return 0;
}
int mid = tree[rt].l + tree[rt].r >> 1;
if (x > mid + 1 && tree[rt << 1 | 1].cnt) {
int ans = findpre(rt << 1 | 1, x); //按照值来看在右子树中
if (ans) return ans; //右子树中不存在这个节点
}
return findpre(rt << 1, x); //在右子树找不到只能去左子树找
}
int findmn(int rt) {
//区间中的实际最小值
if (tree[rt].l == tree[rt].r) return tree[rt].l;
if (tree[rt << 1].cnt) return findmn(rt << 1);
else return findmn(rt << 1 | 1);
}
int findnxt(int rt,int x) {
//查找最小的比x大的数
if (tree[rt].l > x) {
if(tree[rt].cnt) return findmn(rt);
else return 0;
}
int mid = tree[rt].l + tree[rt].r >> 1;
if (x < mid && tree[rt << 1].cnt) {
int ans = findnxt(rt << 1, x);
if (ans) return ans;
}
return findnxt(rt << 1 | 1, x);
}
int findx(int x) {
return lower_bound(num.begin(), num.end(), x) - num.begin();
}
void solve() {
n = read();
num.pb(-1e8);
for (int i = 1; i <= n; i++) {
opt[i] = read();
a[i] = read();
if (opt[i] != 4) //离线操作插入所有数字,4是查询排名没有出现可能未知的数字
num.pb(a[i]);
}
sort(num.begin(), num.end());
num.erase(unique(num.begin(), num.end()), num.end());
int siz = num.size(); //一共出现了多少数字
build(1, 1, siz);
for (int i = 1; i <= n; i++) {
int t = opt[i];
if (t == 1) //插入x
update(1, findx(a[i]), 1);
else if (t == 2) //删除x
update(1, findx(a[i]), -1);
else if (t == 3) //查询x的排名
printf("%d\n", getrank(1, findx(a[i])) + 1);
else if (t == 4) //查询排名为x的数
printf("%d\n", num[kth(1, a[i])]);
else if (t == 5) //查询小于x的最大的数
printf("%d\n", num[findpre(1, findx(a[i]))]);
else if (t == 6) //查询大于x的最小的数
printf("%d\n", num[findnxt(1, findx(a[i]))]);
}
}
int main() {
solve();
return 0;
}
动态开点初始建树时,只建根节点,然后将各个节点逐一插入。
具体的,当修改函数要修改一个不存在的节点时,就新建一个节点,用 i d x idx idx 来实现赋予下标的操作
在动态开点过程中,存储必须以结构体形式。因为动态开点的下标是人为规定的,并且不是按满二叉树的形式存储。由于涉及到更改一个节点左右儿子的编号值,传节点编号时必须传引用。同时,由于权值线段树维护值域而不是区间范围的特殊之处,根节点无法像往常一样在 b u i l d build build 函数中完成建立,所以必须预留一个空位置给根节点,也就是 i d x idx idx 从 1 1 1 开始。
链式存储(动态开点)模板,以普通平衡树为例
int n;
#define base 10000001 //本题特殊,有负数,加上这个数是为了保证非负
#define LL 1 //区间范围下限
#define RR 20000001 //区间范围上限
int idx = 1; //用于权值线段树动态开点,必须预留一个位置给根节点
struct node {
//存放的l和r是左右子树的下标而非区间的范围
int l, r, cnt; // cnt表示当前区间的数字出现的次数
}tree[MAXN << 2];
void pushup(int rt) {
tree[rt].cnt = tree[tree[rt].l].cnt + tree[tree[rt].r].cnt;
}
void update(int &rt, int L, int R, int val, int y) {
//L,R表示区间信息,一定要加引用
if (!rt) {
rt = ++ idx;
tree[rt] = {0, 0, 0};
}
if (L == R) {
tree[rt].cnt += y;
if (tree[rt].cnt < 0) tree[rt].cnt = 0;
return;
}
int mid = L + R >> 1;
if (val <= mid) update(tree[rt].l, L, mid, val, y);
if (mid + 1 <= val) update(tree[rt].r, mid + 1, R, val, y);
pushup(rt);
}
int query(int rt, int L, int R, int l, int r) {
//L,R表示区间信息,查询x的排名是多少
if (!rt) return 0;
if (L >= l && R <= r) return tree[rt].cnt;
int mid = L + R >> 1;
int ans = 0;
if (l <= mid) ans += query(tree[rt].l, L, mid, l, r);
if (mid + 1 <= r) ans += query(tree[rt].r, mid + 1, R, l, r);
return ans;
}
int kth(int rt, int L, int R, int k) {
//查询第k小的数是什么
if (L == R) return L;
int mid = L + R >> 1;
if (tree[tree[rt].l].cnt >= k) return kth(tree[rt].l, L, mid, k);
else return kth(tree[rt].r, mid + 1, R, k - tree[tree[rt].l].cnt);
}
int getpre(int rt, int x) {
//查找小于x的最大值
int rk = query(1, LL, RR, LL, x - 1 + base);
return kth(1, LL, RR, rk) - base;
}
int getnxt(int rt, int x) {
//查找大于x的最小值
int rk = query(1, LL, RR, LL, x + base) + 1;
return kth(1, LL, RR, rk) - base;
}
void solve() {
n = read();
//输入有负数的存在
for (int i = 1; i <= n; i++) {
int opt, x;
opt = read(), x = read();
int rt = 1;
if (opt == 1) // 插入一个数
update(rt, LL, RR, x + base, 1);
else if (opt == 2) //删除一个数
update(rt, LL, RR, x + base, -1);
else if (opt == 3) //查询数值为x的数的排名
printf("%d\n", query(1, LL, RR, LL, base + x - 1) + 1);
else if (opt == 4) //查询排名为x的数的数值大小
printf("%d\n", kth(1, LL, RR, x) - base);
else if (opt == 5) //查找小于x的最大数
printf("%d\n", getpre(1, x));
else if (opt == 6) //查找大于x的最小数
printf("%d\n", getnxt(1, x));
}
}
吉司机线段树(势能线段树)
介绍
传统的区间修改是通过 l a z y lazy lazy 标记来规避掉大规模的单点修改来节省时间。若要使用 l a z y lazy lazy 标记,需要满足以下条件
- 区间节点的值可以利用 l a z y lazy lazy 标记来更新
- 多次 l a z y lazy lazy 标记可以快速合并
比如区间求和,当前节点的值可以通过 l a z y lazy lazy 乘区间长度来更新, l a z y lazy lazy 标记的值也可以快速加减。
但是对于区间开根号,区间位运算来说,区间节点的值不能利用 l a z y lazy lazy 标记来计算,就不能实现传统的 l a z y lazy lazy 合并和区间更新,只能对所有叶子节点进行单独修改。
但是仔细观察可以发现,这些操作对每个节点的操作次数是有一个隐含的上限的,就像一个固定的势能,只要超过这个上限值,相应的操作变回退化失效,即势能为 0 0 0 。而当势能为 0 0 0 的节点组成区间时,这个区间上的所有 u p d a t e update update 操作就可以一并规避掉。并且通常情况下,势能的下降是非常迅速的。具体做法就是
- 用势能函数替代 l a z y lazy lazy 节点,记录和维护当前区间节点的势能值
- 对于每次的区间修改,若当前区间的势能值为 0 0 0 ,则直接规避掉
- 若存在势能值不为 0 0 0 的节点,继续向下递归到叶子节点或者满足上一点。
时间复杂度不超过 O ( l o g 2 N ) O(log^2N) O(log2N)
代码模板1,区间开方求区间和
以区间开方求区间和为例,规定势能函数节点范围内最大的值。若势能值为 1 1 1 ,就可以规避掉所有的区间修改操作
ll n;
ll a[MAXN];
struct node {
int l, r;
ll sum;
ll mxopt; //传说中的势能函数
}tree[MAXN << 2];
void pushup(int rt) {
tree[rt].sum = tree[rt << 1].sum + tree[rt << 1 | 1].sum;
tree[rt].mxopt = max(tree[rt << 1].mxopt, tree[rt << 1 | 1].mxopt); //更新势能函数
}
void build(int rt, int l, int r) {
tree[rt].l = l, tree[rt].r = r;
if (l == r) {
tree[rt].sum = a[l];
tree[rt].mxopt = a[l];
return;
}
int mid = l + r >> 1;
build(rt << 1, l, mid);
build(rt << 1 | 1, mid + 1, r);
pushup(rt);
}
void update(int rt, int l, int r) {
if (tree[rt].l == tree[rt].r) {
//看似是单点更新,但根据更新上限小的特点时间复杂度说得过去
tree[rt].sum = (ll)(sqrt(tree[rt].sum));
tree[rt].mxopt = (ll)(sqrt(tree[rt].mxopt));
return;
}
int mid = tree[rt].l + tree[rt].r >> 1;
if (mid >= l && tree[rt << 1].mxopt > 1) update(rt << 1, l, r);
if (mid + 1 <= r && tree[rt << 1 | 1].mxopt > 1) update(rt << 1 | 1, l, r);
pushup(rt);
}
ll query(int rt, int l, int r) {
if (tree[rt].l >= l && tree[rt].r <= r) return tree[rt].sum;
ll ans = 0;
int mid = tree[rt].l + tree[rt].r >> 1;
if (mid >= l) ans += query(rt << 1, l, r);
if (mid + 1 <= r) ans += query(rt << 1 | 1, l, r);
return ans;
}
void solve() {
n = read();
for (int i = 1; i <= n; i ++) a[i] = read();
build(1, 1, n);
int m;
m = read();
while (m--) {
int opt, l, r;
opt = read(), l = read(), r = read();
if (l > r) swap(l, r);
if (opt == 0) update(1, l, r); //区间开方
else printf("%lld\n", query(1, l, r)); //区间求和
}
}
代码模板2,区间与运算求区间最大值
区间与运算求区间最大值。三个操作,区间内所有数与一个数,改变一个数,求区间最值。可以很直观的把二进制下 1 1 1 的个数当做势能函数,若势能值为 0 0 0 就规避,但这样常数太大还是会被卡。
实际上可以利用与运算不会多产生 1 1 1 的性质。区间最大值改变有两种情况,原先的最大值变小了或者产生了一个新的最大值。在区间与运算框架下只可能是前者。所以,只有当区间里的值在运算后有可能变小后才进行区间修改操作。
规定势能函数为节点范围内所有值的或,当势能值与运算给定值后势能值会变小才进行区间修改操作。否则就相当于区间内的所有数都不会变小,也就是当前的最大值不可能变小,就没必要进行区间修改操作。
int n, m;
int a[MAXN];
struct node {
int l, r;
int mxopt;
int mx;
}tree[MAXN << 2];
void pushup(int rt) {
tree[rt].mxopt = tree[rt << 1].mxopt | tree[rt << 1 | 1].mxopt;
tree[rt].mx = max(tree[rt << 1].mx, tree[rt << 1 | 1].mx);
}
void build(int rt, int l, int r) {
tree[rt].l = l, tree[rt].r = r;
if (l == r) {
tree[rt].mxopt = a[l];
tree[rt].mx = a[l];
return;
}
int mid = l + r >> 1;
build(rt << 1, l, mid);
build(rt << 1 | 1, mid + 1, r);
pushup(rt);
}
void update(int rt, int l, int r, int k) {
if (tree[rt].l == tree[rt].r) {
tree[rt].mx = tree[rt].mx & k;
tree[rt].mxopt = tree[rt].mxopt & k;
return;
}
int mid = tree[rt].l + tree[rt].r >> 1;
if (mid >= l && (tree[rt << 1].mxopt & k) < tree[rt << 1].mxopt)
update(rt << 1, l, r, k);
if (mid + 1 <= r && (tree[rt << 1 | 1].mxopt & k) < tree[rt << 1 | 1].mxopt)
update(rt << 1 | 1, l, r, k);
pushup(rt);
}
void change(int rt, int pos, int k) {
if (tree[rt].l == tree[rt].r && tree[rt].l == pos) {
tree[rt].mx = k;
tree[rt].mxopt = k;
return;
}
int mid = tree[rt].l + tree[rt].r >> 1;
if (pos <= mid) change(rt << 1, pos, k);
if (mid + 1 <= pos) change(rt << 1 | 1, pos, k);
pushup(rt);
}
int query(int rt, int l, int r) {
if (tree[rt].l >= l && tree[rt].r <= r) return tree[rt].mx;
int ans = 0;
int mid = tree[rt].l + tree[rt].r >> 1;
if (mid >= l) ans = query(rt << 1, l, r);
if (mid + 1 <= r) ans = max(ans, query(rt << 1 | 1, l, r));
return ans;
}
void solve() {
n = read(), m = read();
for (int i = 1; i <= n; i ++) a[i] = read();
build(1, 1, n);
char s[10];
int l, r;
int k;
while (m--) {
scanf("%s", s);
l = read(), r = read();
if (s[0] == 'A') {
//区间与运算
k = read();
update(1, l, r, k);
}
else if (s[0] == 'U') {
//改变某一个位置的值
change(1, l, r);
}
else //区间求最大值
printf("%d\n", query(1, l, r));
}
}
扫描线
二维平面有 n n n 个平行于坐标轴的矩形,现在要求出这些矩形的面积并。
由于 y y y 可能取浮点数,所以要对所有的 y 1 , y 2 , . . . y 2 n y_1, y_2, ... y_{2n} y1,y2,...y2n 提前读入离散化去重后存储。也就是说,这些 y y y 其实是端点的编号,访问真实值需要通过这些编号去 v e c t o r vector vector 提取。
定义扫描线为输入给出的矩形的所有横向边。整个过程按照纵坐标从小到大的顺序依次通过这些线段对整个区间进行扫描,顾名思义扫描线。
模拟一下整个过程,应该还是比较清晰的:
扫到 1 1 1 时, B , C B,C B,C 区间出现,面积贡献不变
扫到 2 2 2 时, A , B A,B A,B 区间出现,同时 B B B 区间的贡献增加 l e n B × d x 1 len_B \times dx_1 lenB×dx1 , C C C 区间的贡献增加 l e n C × d x 1 len_C \times dx_1 lenC×dx1
扫到 3 3 3 时, B , C B,C B,C 区间出现次数减 1 1 1 , A A A 区间的贡献增加 l e n A × d x 2 len_A \times dx_2 lenA×dx2 , B B B 区间的贡献增加 l e n B × d x 2 len_B \times dx_2 lenB×dx2 , C C C 区间的贡献增加 l e n C × d x 2 len_C \times dx_2 lenC×dx2 。 C C C 区间消失。
扫到 4 4 4 时,类似于前面的过程, A , B A,B A,B 区间算上各自对面积的贡献后消失,程序结束。
由此可以看到,对于每条扫描线,我们需要记录下他的左右端点,纵坐标大小以及其贡献是正还是负。读入所有扫描线后按照纵坐标大小将其排序然后顺次枚举。面积就是 ∑ 1 扫描线个数 − 1 d x × 被覆盖的区间总长 \sum_1^{扫描线个数-1}{dx \times 被覆盖的区间总长} ∑1扫描线个数−1dx×被覆盖的区间总长 ,这段区间总长用线段树求解。
线段树叶子节点存储的是原子区间信息(是区间不是节点), l 和 r l和r l和r 表示左右子区间的序号, c n t cnt cnt 表示该段区间出现的次数, l e n len len 表示该段区间真正的长度。
线段树维护的根本信息就是 c n t cnt cnt 和 l e n len len 。
结论(不会证明):扫描线问题中区间修改不需要 p u s h d o w n pushdown pushdown
int n; //矩形个数
struct segment {
//扫描线信息
double x, y1, y2;
int k; //区分一个矩形中的两个线段
}seg[MAXN << 1]; //MAXN 表示矩形个数,乘以2才表示线段
bool cmp(segment a, segment b) {
return a.x < b.x;
}
struct node {
//线段树节点表示的是区间不是节点
int l, r, cnt; //cnt表示该段区间出现的次数,l和r表示左右两个子区间的序号
double len; //线段真正的长度,而非 r - l + 1,公式下面会推
}tree[MAXN << 3]; //一共有2n个区间所以要再乘以2
vector <double> ys; //去重离散化,存放所有横坐标上点的位置,为了线段树的区间操作做准备
int findx(double x) {return lower_bound(ys.begin(), ys.end(), x) - ys.begin();}
void pushup(int rt) {
//假设左区间的编号 = 0,右区间的编号 = 1;
//y[0]为ys[0]到ys[1]的距离,表示0号区间的长度; y[1]为ys[1]到ys[2]的距离,表示1号区间的长度
//当前区间的长度为 y[0]+y[1]=ys[1]-ys[0]+ys[2]-ys[1]=ys[2]-ys[0]
//结论:tree[rt].len = ys[tree[rt].r + 1] - ys[tree[rt].l]
if (tree[rt].cnt) tree[rt].len = ys[tree[rt].r + 1] - ys[tree[rt].l]; //整个区间都被覆盖
else if (tree[rt].l != tree[rt].r) tree[rt].len = tree[rt << 1].len + tree[rt << 1 | 1].len;
else tree[rt].len = 0; //叶子节点且未被覆盖
}
void build(int rt, int l, int r) {
tree[rt] = {l, r, 0, 0};
if (l == r) return;
int mid = l + r >> 1;
build(rt << 1, l, mid);
build(rt << 1 | 1, mid + 1, r);
}
void update(int rt, int l, int r, int k) {
//l点到r点的出现次数+1
if (tree[rt].l >= l && tree[rt].r <= r) {
tree[rt].cnt += k;
pushup(rt); //更新len
return;
}
int mid = tree[rt].l + tree[rt].r >> 1;
if (mid >= l) update(rt << 1, l, r, k);
if (mid + 1 <= r) update(rt << 1 | 1, l, r, k);
pushup(rt);
}
void solve() {
// n = read();
ys.clear(); //多组测试数据清空数据
for (int i = 1, j = 0; i <= n; i++) {
double x1, x2, y1, y2;
scanf("%lf%lf%lf%lf", &x1, &y1, &x2, &y2);
seg[++j] = {x1, y1, y2, 1};
seg[++j] = {x2, y1, y2, -1};
ys.pb(y1), ys.pb(y2);
}
sort(ys.begin(), ys.end());
ys.erase(unique(ys.begin(), ys.end()), ys.end()); //离散化去重
sort(seg + 1, seg + 1 + 2 * n, cmp); //将线段按纵坐标排序
// 去重后y轴上一共有siz个节点,siz - 1个区间,对这些区间建树
int siz = ys.size();
build(1, 0, siz - 2);
double ans = 0;
for (int i = 1; i <= 2 * n; i ++) {
//扫描线扫过去
if (i > 1) ans += tree[1].len * (seg[i].x - seg[i - 1].x);
update(1, findx(seg[i].y1), findx(seg[i].y2) - 1, seg[i].k);
}
printf("%.2lf\n", ans);
}
主席树(可持久化权值线段树)
概述
就像可持久化 01 t r i e 01trie 01trie ,为了实现可持久化,就要保存线段树的历史版本。通过观察不难发现,每次进行 单点修改 时,发生变化的只有从根节点到叶子节点这一条链上的节点。也就是说,只有$ logn$ 个节点发生了变化,而其他的节点都可以重用,所以就有了如下的思路:
每次修改(或插入),新建一个根节点,并且向下递归需要新建的节点。
以求区间第 k k k 小值为例题。已知利用权值线段树可以求出整个区间的第 k k k 小数,可以利用可持久化权值线段树分别求得 [ 1 , l − 1 ] [1,l-1] [1,l−1] 和 [ 1 , r ] [1,r] [1,r] 的前缀和。前者可以求出插入 l − 1 l-1 l−1 个数,也就是第 l − 1 l-1 l−1 个版本下区间的第 k k k 小值,后者同理。他们的差就是这中间插入的数的总和,利用这个总和就可以求得区间 k k k 小值。如果左子树的数量大于等于 k k k, 就递归访问左子树,否则就递归访问右子树。
对于动态开点,类似于可持久化 01 t r i e 01trie 01trie
- 与上一版本没差异的地方直接复制节点编号
- 有差异,裂开(在这个版本号下递归生成一条链)
时间复杂度 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) ,空间复杂度 O ( 4 n + n l o g 2 n ) O(4n+nlog_2n) O(4n+nlog2n),一般开MAXN << 5就没啥问题
模板,求区间k小值
vector <int> num; //离散化用
int findx(int x) {return lower_bound(num.begin(), num.end(), x) - num.begin() + 1;} //离散化用
int n, m; //n个数据, m组询问
int a[MAXN]; //存放原始数据和离散化后结果
int rt[MAXN], idx; //不同的版本入口
struct node {
int l, r, cnt;
}tree[(MAXN << 2) + MAXN * 17]; //空间复杂度 O(原本size + nlog2n)
int build(int l, int r) {
int now = ++idx;
int mid = l + r >> 1;
if (l < r) {
tree[now].l = build(l, mid);
tree[now].r = build(mid + 1, r);
}
tree[now].cnt = 0;
return now;
}
int update(int pre, int L, int R, int k) {
//上一个版本变化的部分全部裂开成一条新的链,其他不变的继承上一个版本
int now = ++idx;
int mid = L + R >> 1;
tree[now].l = tree[pre].l, tree[now].r = tree[pre].r, tree[now].cnt = tree[pre].cnt + 1;//继承
if (L < R) {
if (k <= mid) tree[now].l = update(tree[pre].l, L, mid, k); //裂开
else if (mid + 1 <= k) tree[now].r = update(tree[pre].r, mid + 1, R, k); //裂开
}
return now;
}
int query(int l, int r, int L, int R, int k) {
//sum[r] - sum[l] 即为区间第k大值
if (L == R) return L; //找到了这个数
int mid = L + R >> 1;
int sum = tree[tree[r].l].cnt - tree[tree[l].l].cnt; //这段区间的左部分插入了sum个数
if (sum >= k)
return query(tree[l].l, tree[r].l, L, mid, k);
else
return query(tree[l].r, tree[r].r, mid + 1, R, k - sum);
}
void solve() {
n = read(), m = read();
for (int i = 1; i <= n; i ++) a[i] = read(),num.pb(a[i]);
sort(num.begin(), num.end());
num.erase(unique(num.begin(), num.end()), num.end());
for (int i = 1 ; i <= n; i++)
a[i] = findx(a[i]);
int len = num.size();
rt[0] = build(1, len);
for (int i = 1; i <= n; i++)
rt[i] = update(rt[i-1], 1, len, a[i]);
while (m--) {
//询问[l,r]这个区间的第k小数
int l, r, k;
l = read(), r = read(), k = read();
int ans = query(rt[l - 1], rt[r], 1, len, k);
printf("%d\n", num[ans - 1]);
}
}
平衡树(treap实现)
顾名思义, t r e a p = t r e e + h e a p treap = tree + heap treap=tree+heap ,其中 t r e e tree tree 指的是二叉搜索树, h e a p heap heap 指的是大根堆。这种数据结构要求每个节点拥有两种权值,其中一种满足二叉搜索树的性质,另外一种满足大根堆的性质。这样做是为了让整个二叉搜索树尽可能随机,防止二叉搜索树向链的方向演变。
众所周知,一个序列对应有好几种二叉搜索树,然而这些搜索树时间复杂度差异甚大。 t r e a p treap treap 无法将时间复杂度降到最优,但是通过随机的演变可以避免最坏情况的发生。具体的实现就是通过引入另一权值,为了让这个权值满足大根堆的性质(这个值是随机取的),随机的左旋右旋转转当前的搜索树。这样,利用堆,二叉搜索树的不稳定性就被人为锁死了。
先放两张左旋和右旋的图以免自己猪脑过载。
int n; //节点数量
int opt, x;
int root, idx; //有些操作要引用,定义root;idx就相当于链表操作
struct node {
//存放平衡树中的节点信息
int l, r, cnt; //左右儿子编号,当前数出现次数
int key, val; //BST和大根堆的权值
int size; //子树大小
}tree[MAXN];
void pushup(int rt) {
//维护size
tree[rt].size = tree[tree[rt].l].size + tree[tree[rt].r].size + tree[rt].cnt;
}
int new_node(int key) {
//新建一个节点,相当于new一个指针
int now = ++idx;
tree[now] = {0, 0, 1, key, rand(), 1};
return idx;
}
void zag(int &p) {
//左旋
int q = tree[p].r;
tree[p].r = tree[q].l, tree[q].l = p, p = q;
pushup(tree[p].l), pushup(p);
}
void zig(int &p) {
//右旋
int q = tree[p].l;
tree[p].l = tree[q].r, tree[q].r = p, p = q;
pushup(tree[p].r), pushup(p);
}
void build() {
//加两个哨兵,正无穷和负无穷防止越界
new_node(-INF), new_node(INF);
root = 1, tree[1].r = 2;
pushup(root);
if (tree[1].val < tree[2].val) zag(root); //满足堆的性质,旋转
}
void insert(int &now, int key) {
//插入一个值为key的值,因为涉及到根的修改所以加个引用
if (!now) now = new_node(key); //走到叶子节点就新建一个
else {
if (key == tree[now].key) tree[now].cnt ++; //找到了相同的节点
else if (key < tree[now].key) {
insert(tree[now].l, key);
if (tree[tree[now].l].val > tree[now].val) zig(now);
}
else if (key > tree[now].key) {
insert(tree[now].r, key);
if (tree[tree[now].r].val > tree[now].val) zag(now);
}
}
pushup(now); //别忘了更新size
}
void remove(int &now, int key) {
//删除一个值为key的值,因为涉及到根的修改所以加个引用
if (!now) return; //要删除的节点不存在,直接退出
if (tree[now].key == key) {
if (tree[now].cnt > 1) tree[now].cnt --;
else {
//这个点直接没了
if (tree[now].l || tree[now].r) {
//判断是不是叶子节点
if (!tree[now].r && tree[tree[now].l].val) {
zig(now);
remove(tree[now].r, key);
}
else {
zag(now);
remove(tree[now].l, key);
}
}
else now = 0; //叶子节点不用转来转去直接删
}
}
else if (key > tree[now].key) remove(tree[now].r, key);
else remove(tree[now].l, key);
pushup(now); //别忘了更新size
}
int getrank(int rt, int key) {
//查询值为x的排名
if (!rt) return 0;
if (key == tree[rt].key) return tree[tree[rt].l].size + 1;
else if (key < tree[rt].key) return getrank(tree[rt].l, key);
else return getrank(tree[rt].r, key) + tree[tree[rt].l].size + tree[rt].cnt;
}
int kth(int rt, int k) {
//查询第k大的数
if (!rt) return INF;
if (tree[tree[rt].l].size >= k) return kth(tree[rt].l, k);
else if (tree[tree[rt].l].size + tree[rt].cnt >= k) return tree[rt].key;
else return kth(tree[rt].r, k - tree[tree[rt].l].size - tree[rt].cnt);
}
int getpre(int rt, int key) {
//查找小于key的最大数
if (!rt) return -INF;
if (key <= tree[rt].key) return getpre(tree[rt].l, key);
else return max(tree[rt].key, getpre(tree[rt].r, key));
}
int getnxt(int rt, int key) {
//查找大于key的最小数
if (!rt) return INF;
if (key >= tree[rt].key) return getnxt(tree[rt].r, key);
else return min(tree[rt].key, getnxt(tree[rt].l, key));
}
void solve() {
n = read();
build(); //别忘了建树
for (int i = 1; i <= n; i++) {
opt = read(), x = read();
if (opt == 1) //插入一个数
insert(root, x);
else if (opt == 2) //删除一个数
remove(root, x);
else if (opt == 3) //查询一个数的排名
printf("%d\n", getrank(root, x) - 1); //减一是因为插入了一个负无穷大的哨兵
else if (opt == 4) //查询第k大的数
printf("%d\n", kth(root, x + 1)); //同样是因为负无穷大的哨兵
else if (opt == 5) //查询小于x的最大数
printf("%d\n", getpre(root, x));
else if (opt == 6) //查询大于x的最小数
printf("%d\n", getnxt(root, x));
}
}
树链剖分
概述
通过一些操作,将树转化成一个序列,把树中的任意一条路径对应到 O ( l o g n ) O(logn) O(logn) 段的连续区间,然后用其他的数据结构维护区间信息。
定义
重/轻儿子:当前节点的子节点中子树 s i z e size size 最大的子节点称为该点的重儿子,其余都为轻儿子。
重/轻边:当前节点到重儿子的边称为重边,到轻儿子的边称为轻边
重链:由重边构成的极大路径
其中红色节点为重儿子,红色边为重边,框起来的是重链,显然轻儿子是重链的顶点。
d f s dfs dfs 序:优先遍历重儿子,即可保证重链上所有点的编号是连续的
结论:树中任意一条路径均可拆分为 O ( l o g n ) O(logn) O(logn) 个连续区间/重链
具体转化方法
类似于求 L C A LCA LCA 的思想,通过重链往上爬,找到最近公共重链,每爬一次就修改一次。最后到达公共重链以后,再把公共重链给修改一次。
两趟 d f s dfs dfs ,第一趟预处理所有节点的重儿子,子树的 s i z e size size ,所有节点的深度,以及所有节点的父节点。
第二趟找到每个节点所属重链的顶点, d f s dfs dfs 序的编号,建立从本身 i d id id 到 d f s dfs dfs 序的映射,以及映射意义下所有点的权值(用于建线段树)
修改查询区间信息时间复杂度为 O ( l o g n ) O(logn) O(logn) ,一共有 l o g n logn logn 个区间,所以处理一次询问的时间复杂度是 O ( l o g 2 n ) O(log^2n) O(log2n)
int n;
int siz[MAXN], son[MAXN], fa[MAXN], depth[MAXN], top[MAXN]; //son表示重儿子,fa表示父节点,top表示重链顶端
int key[MAXN], val[MAXN], a[MAXN], cnt;//key表示映射关系val表示映射关系意义下的权值
int head[MAXN];int tot;
struct EDGE {
int to,next;
}edge[MAXM];
void add_edge(int from,int to) {
edge[++tot].to=to;edge[tot].next=head[from];head[from]=tot;
}
struct node {
int l, r, len;
ll add, sum;
}tree[MAXN << 2];
void dfs1(int now,int dep, int pre) {
//得到以当前节点为根的子树大小,当前节点的父节点,当前节点的深度,当前节点的重儿子
depth[now] = dep, fa[now] = pre, siz[now] = 1;
for (int i = head[now]; i; i = edge[i].next) {
int to = edge[i].to;
if (to == pre) continue;
dfs1(to, dep + 1, now);
siz[now] += siz[to];
if (siz[to] > siz[son[now]])
son[now] = to;
}
}
void dfs2(int now,int boss) {
//按照dfs序给每个节点编号为之后的区间处理做准备
key[now] = ++ cnt, a[cnt] = val[now], top[now] = boss;
if (!son[now]) return;
dfs2(son[now], boss);
for (int i = head[now]; i; i = edge[i].next) {
int to = edge[i].to;
if (to == fa[now] || to == son[now]) continue;
dfs2(to, to);
}
}
void pushup(int rt) {
tree[rt].sum = tree[rt << 1].sum + tree[rt << 1 | 1].sum;
}
void work(node &rt, ll add) {
rt.sum += rt.len * add;
rt.add += add;
}
void pushdown(int rt) {
work(tree[rt << 1], tree[rt].add);
work(tree[rt << 1 | 1], tree[rt].add);
tree[rt].add = 0;
}
void build(int rt, int l, int r) {
tree[rt] = {l, r, r - l + 1, 0, a[l]};
if (l == r) return;
int mid = l + r >> 1;
build(rt << 1, l, mid);
build(rt << 1 | 1, mid + 1, r);
pushup(rt);
}
void update(int rt, int l, int r, int add) {
if (tree[rt].l >= l && tree[rt].r <= r) {
work(tree[rt], add);
return;
}
pushdown(rt);
int mid = tree[rt].l + tree[rt].r >> 1;
if (mid >= l) update(rt << 1, l, r, add);
if (mid + 1 <= r) update(rt << 1 | 1, l, r, add);
pushup(rt);
}
ll query(int rt, int l, int r) {
if (tree[rt].l >= l && tree[rt].r <= r) {
return tree[rt].sum;
}
pushdown(rt);
int mid = tree[rt].l + tree[rt].r >> 1;
ll ans = 0;
if (mid >= l) ans += query(rt << 1, l, r);
if (mid + 1 <= r) ans += query (rt << 1 | 1, l, r);
return ans;
}
void update_tree(int rt, int add) {
update(1, key[rt], key[rt] + siz[rt] - 1, add);
}
ll query_tree (int rt) {
return query(1, key[rt], key[rt] + siz[rt] - 1);
}
void update_path(int u, int v, int add) {
while (top[u] != top[v]) {
//寻找最近公共重链
if (depth[top[u]] < depth[top[v]]) swap(u, v);
update(1, key[top[u]], key[u], add);
u = fa[top[u]];
}
if (depth[u] < depth[v]) swap(u, v);
update(1, key[v], key[u], add); //公共重链上别忘了更新区间信息
}
ll query_path(int u, int v) {
ll ans = 0;
while (top[u] != top[v]) {
//寻找最近公共重链
if (depth[top[u]] < depth[top[v]]) swap(u, v);
ans += query(1, key[top[u]], key[u]);
u = fa[top[u]];
}
if (depth[u] < depth[v]) swap(u, v);
ans += query(1, key[v], key[u]); //公共重链上别忘了计算区间贡献
return ans;
}
void solve() {
n = read();
for (int i = 1; i <= n; i++) val[i] = read();
for (int i = 1; i < n; i++) {
int u, v;
u = read(), v = read();
add_edge(u, v);
add_edge(v, u);
}
dfs1(1, 1, -1);
dfs2(1, 1);
build(1, 1, n);
int k;
k = read();
for (int i = 1; i <= k; i++) {
int opt;
int u, v, add;
opt = read(), u = read();
if (opt == 1) {
//修改路径节点权值
v = read(), add = read();
update_path(u, v, add);
}
else if (opt == 2) {
//修改子树节点权值
add = read();
update_tree(u, add);
}
else if(opt == 3) {
//查询路径节点权值和
v = read();
printf("%lld\n", query_path(u, v));
}
else //查询子树节点权值和
printf("%lld\n", query_tree(u));
}
}
分块
分块是一种思想,一种非常优美的暴力做法。
大体思路就是把一个数组分成若干块(一般来说都是 n \sqrt{n} n ),这样段的长度和个数都是 n \sqrt{n} n 。以线段树模板区间修改(加上某个数)和区间求和为例。这样每次查询或修改时就可以将区间分成两部分,个数 ≤ n \leq \sqrt{n} ≤n 的完整段和长度 $\leq 2\sqrt{n} $ 的两个部分段。这样操作可以分为两部分,一部分对完整段,一部分对操作区间内的残缺段。
这样每一段就都可以看成一个节点(就像线段树的节点一样)。为了保证效率与准确性需要记录以下信息:
- a d d add add :类似于懒惰标记,本段中的所有数都要加上 a d d add add 的值,但是不会下推
- s u m sum sum :本段的真实和是多少(加上懒惰标记后的值)
然后修改操作,时间复杂度 O ( n ) O(\sqrt{n}) O(n)
查询操作,时间复杂度 O ( n ) O(\sqrt{n}) O(n)
思维难度 | 时间复杂度 | 代码难度 | |
---|---|---|---|
分块 | e a s y easy easy | n \sqrt{n} n ,但常数小 | 长度适中易调试 |
线段树 | h a r d hard hard | l o g n logn logn ,常数大 | 长又难调试 |
树状数组 | h a r d hard hard | l o g n logn logn | 最短但难理解 |
以区间加和区间求和为例题,代码这样写
ll add[320], sum[320]; //懒惰标记和区间和的真实值
int n, m; //区间长度和操作个数
char opt[10];
int a[MAXN];
int l, r, d;
int len; //块的长度
int get(int x) {
//通过点编号获得块编号
return (x - 1) / len;
}
void update(int l, int r, int d) {
if (get(l) == get(r)) {
//段内直接暴力
for (int i = l; i <= r; i ++) a[i] += d, sum[get(i)] += d;
}
else {
int i = l, j = r;
while (get(i) == get(l)) sum[get(i)] += d, a[i] += d, i ++;
while (get(j) == get(r)) sum[get(j)] += d, a[j] += d, j --;
for (int k = get(i); k <= get(j); k ++) sum[k] += d * len, add[k] += d;
}
}
ll query(int l, int r) {
ll ans = 0;
if (get(l) == get(r)) {
//段内直接暴力
for (int i = l; i <= r; i ++) ans += a[i] + add[get(i)];
}
else {
int i = l, j = r;
while (get(i) == get(l)) ans += a[i] + add[get(i)], i ++;
while (get(j) == get(r)) ans += a[j] + add[get(j)], j --;
for (int k = get(i); k <= get(j); k ++) ans += sum[k];
}
return ans;
}
void solve() {
n = read(), m = read();
len = sqrt(n); //分块
for (int i = 1; i <= n; i ++) a[i] = read(), sum[get(i)] += a[i];
while (m--) {
scanf("%s%d%d",opt, &l, &r);
if (opt[0] == 'C') {
d = read();
update(l, r, d);
}
else
printf("%lld\n", query(l, r));
}
}
int main() {
solve();
return 0;
}
莫队
莫队算法可以理解成对 双指针处理离线询问的一种优化。精髓就是用较为合理的对询问进行排序,然后基于这个较优的询问顺序暴力回答每个询问。这也是双指针的核心,处理完一个询问后,可以用它的信息得到下一个询问区间的答案。
给出一组数据,可以看看莫队和普通排序访问有什么不同
( 1 , 99999 ) ( 2 , 2 ) ( 3 , 99999 ) ( 4 , 4 ) ( 5 , 99999 ) ( 6 , 6 ) . . . ( m / 2 − 1 , 99999 ) , ( m / 2 , m / 2 ) (1, 99999) (2, 2)(3,99999)(4,4)(5,99999)(6,6)...(m/2-1,99999),(m/2,m/2) (1,99999)(2,2)(3,99999)(4,4)(5,99999)(6,6)...(m/2−1,99999),(m/2,m/2)
莫队提供了这样一种排序方案:将原序列以 n \sqrt{n} n 为一块进行分块(当然大小可以调整),排序第一关键字是询问的左端点所在块的编号,第二关键字是询问的右端点本身的位置,都是升序。
这样所有的询问就被分成了 n \sqrt{n} n 块,块的长度为 n \sqrt{n} n ,块内部所有点的右端点都是递增的。
这样右端点的总数在每一块内走的总数 ≤ n \leq n ≤n ,一共有 n \sqrt{n} n 块,所以右端点走的次数不超过 n n n\sqrt{n} nn 次。时间复杂度 O ( n n ) O(n\sqrt{n}) O(nn)
左端点块内移动不超过 n \sqrt{n} n ,一共有 m m m 个询问,总共不超过 m n m\sqrt{n} mn ;块间移动不超过 2 n 2\sqrt{n} 2n ,块间移动的次数不超过 n − 1 \sqrt{n} - 1 n−1 ,时间复杂度不超过 2 n 2n 2n 。时间复杂度 O ( m n ) O(m\sqrt{n}) O(mn)
由于出题人过于毒瘤,有一种较为常见的玄学优化。奇数块块内右端点从小到大,偶数块右端点从大到小排。
只写了最基础的莫队,以求区间内有多少个不同的数为例
#define get(x) (x / len)
using namespace std;
const int N = 5e4 + 5, M = 2e5 + 5, S = 1e6 + 5;
int n, m, a[N], ans[M], cnt[S], len;
struct Query {
int id, l, r;
} q[M];
bool cmp(Query a, Query b) {
int i = get(a.l), j = get(b.l);
if (i != j) return i < j;
return a.r < b.r;
}
void add(int x, int &res) {
if (!cnt[x]) res ++;
cnt[x] ++;
}
void del(int x, int &res) {
cnt[x] --;
if (!cnt[x]) res --;
}
signed main() {
scanf("%d", &n);
for (int i = 1; i <= n; i ++) scanf("%d", &a[i]);
scanf("%d", &m);
len = sqrt((double)n * n / m);
for (int i = 0; i < m; i ++) scanf("%d%d", &q[i].l, &q[i].r), q[i].id = i;
sort(q, q + m, cmp);
for (int k = 0, i = 0, j = 1, res = 0; k < m; k ++) {
int id = q[k].id, l = q[k].l, r = q[k].r;
while (i < r) add(a[++ i], res);
while (i > r) del(a[i --], res);
while (j > l) add(a[-- j], res);
while (j < l) del(a[j ++], res);
ans[id] = res;
}
for (int i = 0; i < m; i ++) printf("%d\n", ans[i]);
}