模板索引:数据结构

单调栈

求序列每个后缀最大值下标

例题 B3666

给定一个数列\(a\),初始为空。有\(n\)次操作,每次在\(a\)的末尾添加一个正整数\(x\)
每次操作结束后,请你找到当前\(a\)所有的后缀最大值的下标(下标从 1 开始)。一个下标\(i\)是当前\(a\)的后缀最大值下标当且仅当:对于所有的>\(i<j\leq|a|\),都有\(a_i>a_j\),其中\(|a|\)表示当前\(a\)的元素个数。
为了避免输出过大,请你每次操作结束后都输出一个整数表示当前数列所有后缀最大值的下标的按位异或和。
\(1\leq n\leq10^6,1\leq x_i<2^{64}.\)

点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int n;
ull ans;
vector<ull> vc;
vector<ull> tmp; // 这个不是栈,这个存的是原数组
int main(){
    cin >> n;
    tmp.push_back(1);
    for(int i=1; i<=n; i++){
        ull x = read();
        tmp.push_back(x);
        while(!vc.empty() && x >= tmp[vc.back()]){
            ans ^= vc.back();
            vc.pop_back();
        }
        ans ^= i;
        vc.push_back(i);
        printf("%llu\n", ans);
    }
	return 0;
}

求所有连续子序列的最值的差

P6503 [COCI 2010/2011 #3] DIFERENCIJA

给出一个长度为 \(n\) 的序列 \(a_i\),求出下列式子的值:

\[\sum_{i=1}^{n} \sum_{j=i}^{n} (\max_{i\le k\le j} a_k-\min_{i\le k\le j} a_k) \]

即定义一个子序列的权值为序列内最大值与最小值的差。求出所有连续子序列的权值和。

  • 方法一
    我们考虑对每个\(a_i\)算它的贡献。
    注意到当且仅当\(a_i\)为区间\([l,r]\)的最大值时,\(a_i\)才会被计算一次。
    那么现在只需要计算有多少个区间\([l,r]\)使得\(a_i\)为区间最大值。

  • 对于最大值贡献:
    \(maxl[i]\)\(i\) 左侧最近的满足 \(a[j] > a[i]\) 的位置;
    \(maxr[i]\)\(i\) 右侧最近的满足 \(a[j] >= a[i]\) 的位置。
    那么以 \(a[i]\) 作为该区间最大值代表的区间个数为:
    \((i - maxl[i]) * (maxr[i] - i)\)

  • 对于最小值贡献:
    \(minl[i]\)\(i\) 左侧最近的满足 \(a[j] < a[i]\) 的位置;
    \(minr[i]\)\(i\) 右侧最近的满足 \(a[j] <= a[i]\) 的位置。
    那么以 \(a[i]\) 作为该区间最小值代表的区间个数为:
    \((i - minl[i]) * (minr[i] - i)\)

这套写法等价于:当区间最值出现多次时,把贡献归给最右侧那个最值位置。

点击查看代码
int maxl[maxn], maxr[maxn], minl[maxn], minr[maxn];
ll ans, a[maxn];
vector<int> tmp;
 
int main(){

	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];

	for(int i = 1; i <= n; i++){
		while(!tmp.empty() && a[i] >= a[tmp.back()]){
			maxr[tmp.back()] = i;
			tmp.pop_back();
		}
		if(!tmp.size()) maxl[i] = 0;
		else maxl[i] = tmp[tmp.size() - 1];
		tmp.push_back(i);
	}
	while(!tmp.empty()){
		maxr[tmp.back()] = n + 1;
		tmp.pop_back();
	}

	for(int i = 1; i <= n; i++){
		while(!tmp.empty() && a[i] <= a[tmp.back()]){
			minr[tmp.back()] = i;
			tmp.pop_back();
		}
		if(!tmp.size()) minl[i] = 0;
		else minl[i] = tmp[tmp.size() - 1];
		tmp.push_back(i);
	}
	while(!tmp.empty()){
		minr[tmp.back()] = n + 1;
		tmp.pop_back();
	}

	for(int i = 1; i <= n; i++){
		ans += a[i]*(maxr[i] - i)*(i - maxl[i]);
		ans -= a[i]*(minr[i] - i)*(i - minl[i]);
	}
	cout << ans << endl;
	return 0;
}
  • 方法二
    我们设\(f_i/g_i\)为以\(i\)结尾的所有子序列的最大/最小值之和,那么答案为\(\sum f_i-g_i\)
    考虑如何维护\(f,g\)
    不妨设\(p_i<i\)为满足\(a_{p_i}>a_i\)的最大的\(p_i\) (特别的,如果\(p_i\)不存在则为0),那么有转移方程\(f_i=f_{p_i}+a_i\times(i-p_i),p_i\)可以用单调栈维护。
    稍微解释一下上面的转移方程是如何得来的:
  • 因为\(a_i\)\(p_i\)以及\(p_i\)以前的最大值没有影响 (即\([1,p_i]\)\([1,i]\),\([2,p_i]\)\([2,i]\cdots[p_i,p_i]\)
    \([p_i,i]\)的最大值相同),所以可以直接由\(f_{p_i}\)转移得来。
  • 而根据\(p_i\)的定义,后面\(i-p_i\)个子序列 (即\([p_i+1,i],[p_i+2,i]\cdots,[i,i]\))的最大值为\(a_i\) ,
    所以加上\(a_i\times(i-p_i)\)
  • 所以转移方程为\(f_i=f_{p_i}+a_i\times(i-p_i)\)
点击查看代码
for(int i=1;i<=n;i++){
		ll x=read(),p;
		while(t1&&x>=a[t1].fi)t1--;
		while(t2&&x<=b[t2].fi)t2--;
		p=a[t1].se; f[i]=f[p]+x*(i-p);
		p=b[t2].se; g[i]=g[p]+x*(i-p);
		ans+=f[i]-g[i],a[++t1]=b[++t2]={x,i};
}

单调队列

求窗口中后缀最大值的位置个数

例题 B3667

给定一个长度为 \(n\) 的数列 \(a\),对于其中每个长度为 \(k\) 的子区间,请你求出这个这个子区间构成的数列的所有后缀最大值的位置个数。
一个下标 \(i\) 是数列 \(b\) 的后缀最大值下标当且仅当:对于所有的 \(i < j \leq |b|\),都有\(b_i > b_j\),其中 \(|b|\) 表示 \(b\) 的元素个数。
\(1 \leq k \leq n \leq 10^6, 1 \leq x_i < 2^{64}.\)

点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int n, k;
int main(){
    cin >> n >> k;
    vector<ull> a(n+1);
    deque<int> q;
    for(int i=1; i<=n; i++){
        ull x = read();
        int l = max(i-k+1, 1);
        while(!q.empty() && q.front() < l ) q.pop_front();
        while(!q.empty() && a[q.back()] <= x) q.pop_back();
        q.push_back(i);
        a[i] = x;
        if(i >= k) printf("%d\n", q.size()); // 注意当区间大等于 k 才去计算 ans 
    }
	return 0;
}

手写队列

点击查看代码
    lmin = lmax = 1; // 特别注意如果手写队列要初始化
    rmin = rmax = 0; // ?
    for (int i = 1; i <= n; i++){
        while (lmax <= rmax && qumax[lmax] + k <= i) lmax++;
        while (lmax <= rmax && h[qumax[rmax]] < h[i]) rmax--;
        qumax[++rmax] = i;
        while (lmin <= rmin && qumin[lmin] + k <= i) lmin++;
        while (lmin <= rmin && h[qumin[rmin]] > h[i]) rmin--;
        qumin[++rmin] = i;
        if(i >= k)ans = max(ans, h[qumax[lmax]] - h[qumin[lmin]]); // 注意当区间大等于 k 才去计算 ans 
    }

更推荐的

(左闭右开,不用管初始化)

点击查看代码
for(int i = 1; i <= n; i++) {
        cin >> a[i];
        //if(i < k) continue;
        while(l1 < r1 && qmin[l1] + k <= i) l1++;
        while(l1 < r1 && a[i] < a[qmin[r1 - 1]]) r1--;
        qmin[r1++] = i;   
        minn[i] = a[qmin[l1]];

        while(l2 < r2 && qmax[l2] + k <= i) l2++;
        while(l2 < r2 && a[i] > a[qmax[r2 - 1]]) r2--;
        qmax[r2++] = i;
        maxx[i] = a[qmax[l2]];
    }

求滑块内最值

P1886 【模板】单调队列 / 滑动窗口

点击查看代码
int tmp[maxn], l, r; // 全初始化为0,代码略
    for(int i = 1; i <= n; i++){
        while(l < r && tmp[l] + k <= i) l++;
        while(l < r && a[i] < a[tmp[r - 1]]) r--;
        tmp[r++] = i;
        if(i >= k) cout << a[tmp[l]] << " ";
    }

tips

值得注意的是,诸如 P1725 琪露诺 一类的在一个规定长度的区间内转移,可以视其为滑动窗口,采用单调队列优化。

连续区间中位数:对顶堆

关联:P1168 中位数

给定一个长度为 \(N\) 的非负整数序列 \(A\),对于前奇数项求中位数。

使用两个堆,一个大根堆维护较小的值,一个小根堆维护较大的值,并维护大根堆的元素个数和小根堆的元素个数差值不大于 \(1\)

点击查看代码
priority_queue<int> big;
priority_queue<int, vector<int>, greater<int>> small;

void adjust(){
    while(big.size() > small.size()){
        small.push(big.top());
        big.pop();
    }
    while(small.size() > big.size() + 1){
        big.push(small.top());
        small.pop();
    }
    while (!big.empty() && !small.empty() && big.top() > small.top()) {
        int x = big.top(); big.pop();
        int y = small.top(); small.pop();
        big.push(y);
        small.push(x);
    }
}

int find(){
    adjust();
    return small.top();
}

带删连续区间中位数:multiset

关联题目:P16318 [ICPC 2023区域赛 济南站] 彩虹子数组

  • 题目大意:给你 \(k\) 次机会给任意一个数增加或减少 \(1\),问你能在原数组上构造出的最长连续递增子数组有多长。
    *连续:即后一个数恰好等于前一个数 \(+1\)

其实如果题目改为,要把子数组改成一个等差数列,做法也是一样的。
比如改成:\(x, x+d, x+2\dots\),代价就是 \(\sum{|a_i - (x + d*i)|}\)
\(b_i = a_i - d * i\),代价变为 \(\sum|b_i - x|\)

于是题目转化为要求把一个整个区间都变成一样的数,很显然把所有数变成中位数是花费次数最少的。
花费就是每个数减去中位数的绝对值之和。
若某区间可实现在 \(k\) 代价之内成为彩虹子数组,则其子区间区间也可实现(少操作一部分数,代价显然更小),所以可以用双指针来优化这一部分。
使用双指针会带来一个问题,左指针右移的时候要维护删除一个数。

带修用对顶堆就不好做了,于是我们使用 \(STL\) 里的可重集 multiset

特别注意 multiset.erase(x) 函数会把所有等于 x 的元素全删掉!
所以滑动窗口的时候,只删一个,应该这么写:

auto it = s.find(x);
s.erase(it);
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 5e5 + 10;

multiset<ll> L, R;// L: 较小一半, R: 较大一半

ll suml = 0, sumr = 0;
void adjust(){ // 保证 L.size() == R.size() 或 L.size() == R.size() + 1
    while(L.size() < R.size()){
        auto it = R.begin(); // R 的最小值
        ll x = *it; 
        R.erase(it);
        sumr -= x;

        L.insert(x);
        suml += x;
    }
    while(L.size() > R.size() + 1){
        auto it = prev(L.end()); // L 的最大值
        // prev: 把迭代器往前挪一个位置,等效于 L.rbegin()
        ll x = *it;
        L.erase(it);
        suml -= x;
        R.insert(x);
        sumr += x;
    }
    /* 由于保证插入正确,所以只要两个集合的大小关系正确即可,下面这段调整可以不需要
    while (!L.empty() && !R.empty() && *L.rbegin() > *R.begin()) {
        ll x = *L.rbegin();
        ll y = *R.begin();

        L.erase(L.find(x));
        R.erase(R.find(y));
        suml -= x; sumr -= y;

        L.insert(y);
        R.insert(x);
        suml += y; sumr += x;
    }*/
}
void insert(ll x){
    if(L.empty() || x <= *L.rbegin()){
        L.insert(x);
        suml += x;
    }
    else{
        R.insert(x);
        sumr += x;
    }
    adjust();
}
void erase(ll x){
    auto it = L.find(x);
    if(it != L.end()){
        L.erase(it);
        suml -= x;
    }
    else {
        it = R.find(x);
        if(it != R.end()){
            R.erase(it);
            sumr -= x;
        }
    }
    adjust();
}

ll median(){
    // adjust(); 由于插入正确,故任意时刻集合都是对的不用再调整
    // 即便是偶数序列,只给下中位数也没问题,因为这个题其实选上下中位数代价都是一样的
    return *L.rbegin(); // 下中位数

}
ll cost(){
    ll x = median();
    return sumr - x * R.size() + x * L.size() - suml;
}

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0), cout.tie(0);

    int t ;
    cin >> t;
    while(t--){
        int n; ll k;
        cin >> n >> k;

        // 多测不清空,亲人两行泪
        L.clear();
        R.clear();
        suml = sumr = 0;

        vector<ll> a(n + 1), b(n + 1);
        for(int i = 1; i <= n; i++) {
            cin >> a[i];
            b[i] = a[i] - i;
        }
        
        int ans = 0;
        int l = 1;
    
        for(int r = 1; r <= n; r++){
            insert(b[r]);
            while(cost() > k){
                erase(b[l]);
                l++;
            }
            ans = max(ans, r - l + 1);
        }
        cout << ans << "\n";
    }
	return 0;
}

RMQ问题(ST表)

复杂度 \(O(nlogn)\)
适于处理静态区间查询问题,如:

  • 区间最值
  • 区间 \(gcd\)
  • 某些满足可重复合并性质的区间运算

RMQ + 二分

洛谷 P7167 喷泉

一个喷泉由 \(n\) 个圆盘组成,从上到下依次编号为 1 到 \(n\),第 \(i\) 个圆盘的直径为 \(d_i\),容量为 \(c_i\)。当一个圆盘里的水多于该圆盘的容量时,水会溢出往下流,直到流入半径大于该圆盘的圆盘里。如果下面没有满足要求的圆盘,水就会流到喷泉下面的水池里。
现在有 \(q\) 组询问,每一组询问描述:向第 \(r_i\) 个圆盘里倒入 \(v_i\) 的水,求水最后会流到哪一个圆盘停止。如果最终流入了水池里,那么输出 0。注意,每个询问互不影响,也就是每次向圆盘倒水前,所有的圆盘中都没有水。
数据范围:\(2 \leq n \leq 10^5\)\(1 \leq q \leq 2 \times 10^5\)\(1 \leq c_i \leq 1000\)\(1 \leq d_i, v_i \leq 10^9\)\(1 \leq r_i \leq n\)

点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
#define inf 0x3f3f3f3f
const int maxn = 1e5+10;
int n, q, d[maxn], c[maxn], rmax[maxn][20], f[maxn][20], log_2[maxn], g[maxn][20];

int querymax(int l, int r){
	int x = log_2[r-l+1];
	return max(rmax[l][x], rmax[r-(1<<x)+1][x]);
}

int main(){
	cin >> n >> q;
	for(int i = 1; i <= n; i++) {
		d[i] = read();
		c[i] = read();
	}
	for(int i = 2; i <= n; i++) log_2[i] = log_2[i >> 1] + 1;

	for(int i = 1; i <= n; i++) rmax[i][0] = d[i];

	for(int j = 1; (1 << j) <= n; j++) 
	for(int i = 1; i <= n - (1 << j) + 1; i++)
		rmax[i][j] = max(rmax[i][j - 1], rmax[i + (1 << j-1)][j - 1]);

	c[n+1] = inf;

	for(int i = 1; i < n; i++){
		int l = i + 1, r = n + 1, mid;
		while(l < r){
			mid = l + r >> 1;
			if(querymax(i+1, mid) <= d[i]) l = mid + 1;
			else r = mid;
		}
		f[i][0] = l;
		g[i][0] = c[f[i][0]];
	}

	f[n][0] = n + 1;	
	g[n][0] = c[f[n][0]];

	for(int t = 1; t <= 16; t++)
	for(int i = 1; i <= n; i++){
		f[i][t] = f[f[i][t-1]][t-1];
		g[i][t] = g[i][t-1] + g[f[i][t-1]][t-1];
	}
	while(q--){
		int r = read(), v = read();
		if(v > c[r]){
			v -= c[r];
			for(int t = 16; t >= 0; t--)
				if(v > g[r][t]){
					v -= g[r][t];
					r = f[r][t];
				}
			r = f[r][0];
		}
		if(r == n+1) r = 0;
		printf("%d\n", r);
	}
	return 0;
}

普通并查集

点击查看代码
int fd(int x){
    if(fa[x] == x) return x;
    return fa[x] = fd(fa[x]); // 要把fa[x]丢入查询
}

void merge(int x, int y){
    int fx = fd(x), fy = fd(y);
    if(fx != fy) fa[fx] = fy; // 要把x的根挂给y,不是挂x,值得警醒
}

带权并查集

P1196 银河英雄传说

题目链接
有一个划分成 n 列的星际战场,各列编号为 1~n。有 n 艘战舰,编号也为 1~n,初始时第 i号战舰处于第 i列。
有 m 条指令,每条指令为以下两种之一:

  1. M i j 表示让第 i号战舰所在列按原有顺序接在第 j 号战舰所在列后面。
  2. C i j 表示询问第 i,j号战舰之间间隔了多少艘战舰,或判断不在同一列。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 3e4+10;
int f[maxn];
int n = 3e4, m, sz[maxn], d[maxn];

int find(int x){
    if(x == f[x]) return x;
    int rt = find(f[x]); // ! 
    d[x] += d[f[x]]; // 注意这两条语句顺序。因为节点距root的距离是一层层累加下来的
    return f[x] = rt;
}

void merge(int x, int y){
    x = find(x); y = find(y);
    if(x == y) return ;
    f[x] = y; d[x] = sz[y]; // x距离root距离是被合并集合原本整个集合的大小
    sz[y] += sz[x];
}

int ask(int x, int y){
    int rtx = find(x), rty = find(y);
    if(rtx != rty) return -1;
    return abs(d[x] - d[y]) - 1;
}


int main(){
    ios::sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> m; // 注意关闭同步流不要用read()了,很神秘的爆零了

    // 千万要记得初始化!
    for(int i = 1; i <= n; i++)
        f[i] = i, sz[i] = 1;

    for(int i = 1, x, y; i <= m; i++){
        char ch;
        cin >> ch >> x >> y;
        if(ch == 'M') merge(x, y);
        else cout << ask(x, y) << endl;
    }
    

	return 0;
}

拓展域并查集

P1525 关押罪犯

有 n 名罪犯要关押进两个监狱中。
这些罪犯相互之间有 m 个仇恨关系,每个仇恨关系为 x,y之间有怨气值为 z 的仇恨。如果 x,y不在同一个监狱,则仇恨关系作废。
你希望最小化最大的怨气值

点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int maxn = 2e4+10, maxm = 1e5+10;
int n, m, f[maxm*2];

struct node{
    int x, y, z;
    friend bool operator < (node cmpx, node cmpy){
        return cmpx.z > cmpy.z;
    }
}e[maxm];
int find(int x){
    return x == f[x] ? x : f[x] = find(f[x]);
}
void merge(){}

int main(){
    ios::sync_with_stdio(0);
    cin.tie(0), cout.tie(0);
    cin >> n >> m;
    for(int i = 1, a, b, c; i <= m; i++)
        cin >> e[i].x >> e[i].y >> e[i].z;
    sort(e + 1, e + 1 + m);
    iota(f + 1, f + 1 + n*2, 1);

    for(int i = 1; i <= m; i++){
        int x = find(e[i].x), y = find(e[i].y);
        if(x == y) return cout<< e[i].z<< endl, 0;
        int X = find(e[i].x + n), Y = find(e[i].y + n);
        f[x] = Y; f[y] = X;
    }cout << 0 << endl;
	return 0;
}

拓展域并查集通过以下机制避免了贪心的问题:
虚拟点表示所有可能性:每个点的两种状态(\(A\)\(B\)) 被显式建模为虚拟点。这相当于扩展了状态空间,允许并查集同时跟踪所有可能的选择,而不立即做出决策。
约束转化为等价关系:将“不能同集”约束转化为两个合并操作(\(\text{union}(i_A, j_B)\)\(\text{union}(i_B, j_A)\)),这捕获了约束的完整逻辑:
\(\text{union}(i_A, j_B)\) 表示“如果 \(i\)\(A\),则 \(j\) 必须在 \(B\)”。
\(\text{union}(i_B, j_A)\) 表示“如果 \(i\)\(B\),则 \(j\) 必须在 \(A\)”。
这些合并操作建立了虚拟点之间的等价链,能够通过并查集的连通性传递关系。

所以并查集本质就是维护关系可能性,通过并查集的连通性,约束关系形成传递链。

  • 普通并查集:维护"必须在一起"的等价关系,只处理单一可能性:i和j必须属于同一集合
  • 拓展域并查集:维护"不能在一起"的互斥关系,同时处理两种可能性:
    • 可能性1:i在A → j在B (对应i_A ≡ j_B)
    • 可能性2:i在B → j在A (对应i_B ≡ j_A)
    • 操作:同时执行union(i_A, j_B)和union(i_B, j_A)

例题P1892 [BalticOI 2003] 团伙

树状数组

若区间信息不可减(即无法由两个前缀信息推出),比如区间修,区间最值查询,就要用线段树。

前缀树状数组

对于序列 \(\{a_n\}\),定义序列 \(\{b_n\}\),满足

\[b_i = \sum_{j = i - \text{lowbit}(i) + 1}^{i} a_j \]

其中,\(\text{lowbit}(i)\) 表示 \(i\) 二进制下最低位的位权,例如
\(\text{lowbit}(6) = \text{lowbit}((110)_2) = 2\)
\(\text{lowbit}(20) = \text{lowbit}((10100)_2) = 4\)
我们称点 \(i\)支配区间\([i - \text{lowbit}(i) + 1, i]\)

对于点 \(x\),其在树上的父节点为 \(x + lowbit(x)\)

  • 单点修改:往右跳,更新所有包含点 x 的支配区间。
  • 前缀查询:往左跳,拼凑相离区间。
  • 线性建树:先将所有 \(b_i\) 赋值为 \(a_i\),再枚举 \(i = 1, 2, \dots, n\),若 \(i + \text{lowbit}(i) \le n\),将 \(b_{i+\text{lowbit}(i)}\) 加上 \(b_i\) 即可(注意即使节点为0也要按顺序跑完所有点以把整个点传递上)。

区间加,区间查询

树状数组可以解决区间加、区间查询的序列问题:
设原数组为 \(a_i\),定义差分数组 (b):

那么显然有:\(a_i=\sum_{j=1}^i b_j\)
带入可得 \(\sum_{i=l}^r a_i = \sum_{i=l}^r \sum_{j=1}^i b_j\)

交换求和顺序(?)后,分两段讨论:

  • \(1\le j<l\) 时,\(b_j\) 会在每个 \(a_l,a_{l+1},\dots,a_r\) 中都出现,共出现 \(r-l+1\)
  • \(l\le j\le r\) 时,\(b_j\) 会在\(a_j,a_{j+1},\dots,a_r\) 中出现,共出现 \(r-j+1\)
    因此:

\[\sum_{i=l}^r \sum_{j=1}^i b_j = \sum_{j=1}^{l-1} b_j(r-l+1)+\sum_{j=l}^r b_j(r-j+1) = (r-l+1)\sum_{j=1}^{l-1} b_j + (r+1)\sum_{j=l}^r b_j - \sum_{j=l}^r j·b_j \]

故:

\[ \sum_{i=l}^r a_i = (r+1)\sum_{j=1}^r b_j - l\sum_{j=1}^{l-1} b_j - \sum_{j=l}^r j·b_j \]

于是只需要开两个树状数组即可

  • 第一个树状数组维护差分数组 \(b_i\)
  • 第二个树状数组维护加权差分数组 \(i\cdot b_i\)

树状数组维护异或和

单点修改
查询一段区间所有子区间的异或和的异或和

只要分析l,r的奇偶性,再对a_i分析其有贡献的子区间个数,偶数贡献为0,于是拆分两个树状数组,一个存奇数位前缀异或和,一个偶数即可。

树状数组上倍增

给定长度为 \(n(n \le 10^5)\) 的序列 \(a\)\(q(q \le 10^5)\) 次命令,每次命令形如 \(x, y, k\),表示将 \(a_x\) 改为 \(y\),并立即查询序列中第 \(k\) 小的数。
保证任意时刻 \(a\) 中的任意元素均不超过 \(10^5\)

这个题相当有意思,首先开一个权值树状数组,然后可以二分答案,但是发现倍增思想和二分很像还能优化:
普通二分就是猜 mid 再验证,每次验证还要 query(mid),需要两个 \(log\) 的时间复杂度。
按位枚举倍增好办,比如 \(tree[x]\) 存的是一个长度为 \(lowbit(x)\) 的块和。
于是我们可以像这样找答案:
先看能不能跳 \(2^p\),再看能不能跳 \(2^{p-1}\),再看能不能跳 \(2^{p-2}\)
从高位往低位一位一位确定答案,或者说按位构造答案。

核心思想
设当前已经确定答案前缀位置是 pos,当前累计个数是 sum
如果:

\[sum + tree[pos + 2^j] < k \]

说明第 k 小一定不在这一整块里,可以整块跳过去:
(注意不要下标越界访问)

pos += 2^j;
sum += tree[pos];

否则不能跳,继续试更小的块。

这样每一层只做 一次常数判断,总共试 \(logV\) 层,所以是一个log的复杂度。
注意最终本题答案是 \(pos + 1\)

点击查看代码
struct BIT {
    int n;
    vector<int> tr;

    BIT(int n = 0): n(n), tr(n + 1) {}

    int lowbit(int x){ return x & -x; }

    void add(int x, int v){
        for(; x <= n; x += lowbit(x)) tr[x] += v; // 注意边界要跳到n
    }

    int sum(int x){
        int res = 0;
        for(; x; x -= lowbit(x)) res += tr[x];
        return res;
    }

    int kth(int k){
        int pos = 0, cur = 0;
        int LOG = 1;
        while((1 << LOG) <= n) LOG++;
        for(int j = LOG; j >= 0; j--){
            int nxt = pos + (1 << j);
            if(nxt <= n && cur + tr[nxt] < k){
                pos = nxt;
                cur += tr[nxt];
            }
        }
        return pos + 1;
    }
};

从前向后跳的树状数组倍增只支持前缀树状数组,从后向前则只支持后缀树状数组

那怎么维护后缀树状数组呢,可以用总和减去 sum[i - 1] 得到 sum[i] 的后缀和

例题 P6619 [省选联考 2020 A/B 卷] 冰火战士

(可见有趣思维题收录)

后缀树状数组

考虑修改 \(b_i\) 的含义为

\[b_i = \sum_{j=i}^{i+\text{lowbit}(i)-1} a_j \]

此时,\(i\) 的支配区间为 \([i, i + \text{lowbit}(i) - 1]\)
\(i\) 的父亲为 \(i - \text{lowbit}(i)\)
其可以直接支持单点修改,后缀查询,时间复杂度相同。

操作方向正好相反:

  • 查后缀:i 往右跳:i += lowbit(i)
  • 单点改:i 往左跳:i -= lowbit(i)

二维树状数组

定义:

\[b_{i,j} = \sum_{i_0 = i - \text{lowbit}(i) + 1}^{i} \sum_{j_0 = j - \text{lowbit}(j) + 1}^{j} a_{i_0,j_0} \]

  • 二维单点更新:\(O(log² N)\)
    如果你给某个点 (x,y) 加 delta,哪些矩形块会“覆盖到”它?
    在一维里:所有 i 满足区间 (i-lowbit(i)+1..i) 包含 x,这些 i 组成链 x, x+lowbit(x), ...
    二维就是两个维度独立地走这条链,组合起来就是双重循环:
for (int i = x; i <= n; i += lowbit(i))
  for (int j = y; j <= m; j += lowbit(j))
    b[i][j] += delta;
  • 查询:\(O(log² N)\)
long long sum(int x, int y){
  long long res = 0;
  for (int i = x; i > 0; i -= lowbit(i))
    for (int j = y; j > 0; j -= lowbit(j))
      res += b[i][j];
  return res;
}

理解:
二维的 b[i][j] 为什么“变成一个块”?因为二维定义就是:

b[i][j] 管的范围 =(x 轴 i 管的行区间)×(y 轴 j 管的列区间)
也就是两个一维区间的笛卡尔积,自然就是一个矩形块。

其实和前缀和很像,就是小块凑大块,不过二维树状数组是先用 x 分大长块,再用 y 切成 lowbit(i) × lowbit(j) 的矩形

推广:对于更高维的树状数组,设维数为 \(d\),则时间复杂度为 \(O(log^d N)\)

求线段相交数量

开两个树状数组,分别维护左端点树(代码中0)和右端点树(代码中1)前缀数量,于是对于一段区间 \([l, r]\),其交已加入线段的数量就是:
相交线段数 = 总线段数 - (右端点 \(< l\) 的线段数) - (左端点 \(> r\) 的线段数)。

int l = mp[seg[i].l], r = mp[seg[i].r];
int num = fd(0, r) - fd(1, l - 1); // 化简自  i - 1 - fd(1, l - 1) - (i - 1 - fd(0, r));
add(0, l, 1);
add(1, r, 1);

线段树

对于一个操作含区间修改、区间查询的问题,什么情况可以使用线段树?

  • 对于一次区间修改,若要对某个节点打上标记,能够快速更新该节点的信息(update & pushdown)。
  • 标记在知晓前后顺序时能够合并(pushdown)。
  • 对于所有无标记的节点,能够根据两个子节点的信息,推出当前节点的信息(pushup)。
    对于区间加、查询区间和的问题,其三个条件均满足,则可以使用线段树维护。
    对于区间加、查询区间最大子段和的问题,若直接扩展不带修的经典解法,第一个条件无法满足,因此难以维护。

标记永久化?

线段树能够标记永久化,必须满足懒标记的合并具有交换律,即懒标记合并的结果与它们的先后顺序无关。
加法是有交换律的,而赋值、矩阵乘法,都没有交换律。

线段树空间问题

对 [1, n] 建线段树,最大编号 \(f(n) < 2m\),其中 \(m\)\(≥ n\) 的最小二的次幂。
证明:把线段树填满为一个最小二的次幂个叶子结点的满二叉树即可。

单点/区间加,区间求和

P3372 【模板】线段树 1

(这个题也可用两个树状数组解决,拆贡献即可推导)

点击查看代码
// 线段树1
// 区间加,区间求和
ll w[maxn * 4], a[maxn], lzy[maxn];
int n, m;

void pushup(int u){w[u] = w[u << 1] + w[u << 1 | 1];}
bool inrange(int l, int r, int L, int R){return L <= l && r <= R;}
bool outofrange(int l, int r, int L, int R){return r < L || l > R;}

void maketag(int u, int len, ll x){
    lzy[u] += x;
    w[u] += x * len;
}
void pushdown(int u, int l, int r){
    if(lzy[u]){
        int mid = l + r >> 1;
        maketag(u << 1, mid - l + 1, lzy[u]);
        maketag(u << 1 | 1, r - mid, lzy[u]);
        lzy[u] = 0;
    }
}
// 别忘了build
void build(int u, int l, int r){
    if(l == r){
        w[u] = a[l];
        return;
    }
    int mid = l + r >> 1;
    build(u << 1, l, mid);
    build(u << 1 | 1, mid + 1, r);
    pushup(u);
}

ll ddcx(int u, int l, int r, int p){
    if(l == r) return w[u];
    int mid = l + r >> 1;
    pushdown(u, l ,r);
    if(p <= mid) return ddcx(u << 1, l, mid, p);
    else return ddcx(u << 1 | 1, mid + 1, r, p);
}
void ddxg(int u, int l, int r, int p, ll x){
    if(l == r) {
        w[u] += x;
        return;
    }
    int mid = l + r >> 1;
    pushdown(u, l, r);
    if(p <= mid) ddxg(u << 1, l, mid, p, x);
    else ddxg(u << 1 | 1, mid + 1, r, p, x);
    pushup(u);
}
ll qjcx(int u, int l, int r, int L, int R){
    if(inrange(l, r, L, R)) return w[u];
    else if(outofrange(l, r, L, R)) return 0;
    else {
        int mid = l + r >> 1;
        pushdown(u, l, r); // 注意这里用的是拼凑区间的l, r
        return qjcx(u << 1, l, mid, L, R) + qjcx(u << 1 | 1, mid + 1, r, L, R);
    }
}
void qjxg(int u, int l, int r, int L, int R, ll x){
    if(inrange(l, r, L, R)) maketag(u, r - l + 1, x);
    else if(!outofrange(l, r, L, R)){
        int mid = l + r >> 1;
        pushdown(u, l, r);
        qjxg(u << 1, l, mid, L, R, x);
        qjxg(u << 1 | 1, mid + 1, r, L, R, x);
        pushup(u);
    }
}

动态开点

P13825 【模板】线段树 1.5
注意最大值能到 \(1e19\), 所以需要开 unsigned long long
尽管有初值,但是不要 build 整棵树,不然 n = 1e9 会炸,正确办法是用一个等差数列。

如果改为区间赋值,那就对没有修改过的区间做等差数列即可。

如果不使用结构体封装节点,注意不要用 u << 1 来访问儿子,因为标记是离散的,要用数组存左右儿子下标。

点击查看代码
// 线段树1.5
// 区间加,区间求和
// 动态开点
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ll;
const int maxn = 1e5 + 500;

int n, m;
ll a[maxn];
int tot, root;

struct node{
    int ls, rs;
    ll lzy, w;
}tr[maxn * 160]; // tree
// 理论大小384MB,注意大小MLE可能性
// * 4 * 40 = * 160; (40 系 n + m次log)

void pushup(int u){tr[u].w = tr[tr[u].ls].w + tr[tr[u].rs].w;}
bool inrange(int l, int r, int L, int R){return L <= l && r <= R;}
bool outofrange(int l, int r, int L, int R){return r < L || l > R;}

void maketag(int& u, int len, ll x){
    if(!u) u = ++tot;
    tr[u].lzy += x;
    tr[u].w += x * len;
}

void pushdown(int u, int l, int r){
    if(!u || !tr[u].lzy) return;
    int mid = (l + r) >> 1;
    maketag(tr[u].ls, mid - l + 1, tr[u].lzy);
    maketag(tr[u].rs, r - mid, tr[u].lzy);
    tr[u].lzy = 0;
}
// build() 函数会递归创建所有节点,若值域过大(如 1e9)会直接爆内存。非常危险,这里先删了
void build();

ll ddcx(int u, int l, int r, int p){
    if(!u) return 0;
    if(l == r) return tr[u].w;
    int mid = (l + r) >> 1;
    pushdown(u, l, r);
    if(p <= mid) return ddcx(tr[u].ls, l, mid, p);
    else return ddcx(tr[u].rs, mid + 1, r, p);
}

void ddxg(int& u, int l, int r, int p, ll x){
    if(!u) u = ++tot;
    if(l == r){
        tr[u].w = x;
        tr[u].lzy = 0;
        return;
    }
    int mid = (l + r) >> 1;
    pushdown(u, l, r);
    if(p <= mid) ddxg(tr[u].ls, l, mid, p, x);
    else ddxg(tr[u].rs, mid + 1, r, p, x);
    pushup(u);
}

ll qjcx(int u, int l, int r, int L, int R){
    if(!u || outofrange(l, r, L, R)) return 0;
    if(inrange(l, r, L, R)) return tr[u].w;
    int mid = (l + r) >> 1;
    pushdown(u, l, r);
    return qjcx(tr[u].ls, l, mid, L, R) + qjcx(tr[u].rs, mid + 1, r, L, R);
}

void qjxg(int& u, int l, int r, int L, int R, ll x){
    if(outofrange(l, r, L, R)) return;
    if(!u) u = ++tot;
    if(inrange(l, r, L, R)){
        maketag(u, r - l + 1, x);
        return;
    }
    int mid = (l + r) >> 1;
    pushdown(u, l, r);
    qjxg(tr[u].ls, l, mid, L, R, x);
    qjxg(tr[u].rs, mid + 1, r, L, R, x);
    pushup(u);
}
ll getsum(int l, int r){
    return 1ll * (l + r) * (r - l + 1) / 2;
}
int main(){
    ios::sync_with_stdio(0);
    cin.tie(0), cout.tie(0);

    cin >> n >> m;

    while (m--) {
        int op, x, y;
        cin >> op >> x >> y;
        if (op == 1){
            ll k;
            cin >> k;
            qjxg(root, 1, n, x, y, k);
        }
        else  cout << qjcx(root, 1, n, x, y) + getsum(x, y) << "\n";    
    }
    return 0;
}

区间最大子段和

注意模板不适用于非正数情况。
\(pushup\) 和区间查询都用 \(merge\) 即可。

点击查看代码
struct Node {
    int sum;   // 区间和
    int lmx;   // 最大前缀和
    int rmx;   // 最大后缀和
    int mx;    // 最大子段和
} tr[N << 2];

Node merge(const Node& L, const Node& R) {
    Node res;
    res.sum = L.sum + R.sum;
    res.lmx = max(L.lmx, L.sum + R.lmx);
    res.rmx = max(R.rmx, R.sum + L.rmx);
    res.mx = max({L.mx, R.mx, L.rmx + R.lmx});
    return res;
}

Node qjcx(int u, int l, int r, int L, int R){
    if(L <= l && r <= R) return tr[u];
    int mid = (l + r) >> 1;
    if(R <= mid) return qjcx(u << 1, l, mid, L, R);
    if(L > mid) return qjcx(u << 1 | 1, mid + 1, r, L, R);
    return merge(qjcx(u << 1, l, mid, L, R), qjcx(u << 1 | 1, mid + 1, r, L, R));
}

所有子区间的异或和的总和

按位拆分,开 \(31\)(取决于位数)课线段树即可。
注意每个节点应额外维护前后缀信息,以便于合并。

权值线段树

权值线段树维护了相关信息,比如一个序列每个数值的出现次数。

多种查询第 k 小的区分

  1. 静态整体 Kth

\(\texttt{sort}\) 一遍即可。

时间复杂度 \(O(n\log n)\),空间复杂度 \(O(n)\)


  1. 动态整体 Kth(权值线段树上二分)

离散化后开一棵权值线段树,每个位置的值表示这个位置对应的那个数(离散化后的)有多少个,向上维护和;

查询时先查询左子树的 sum,比较 \(k\)\(sum\) 的大小:

\(k \le sum\) 则说明第 \(k\) 小数在左子树中,递归查询左子树;

否则,这个数对应的就是右子树中第 \(k-sum\) 小的数,递归查询右子树。

时间复杂度 \(O(n\log n)\),空间复杂度 \(O(n)\)


  1. 静态区间 Kth

对每个点以前缀开一棵权值线段树,那么任意一段区间均可以表示成两棵权值线段树作差,即 \(R\) 位置的线段树减去 \(L-1\) 位置上的线段树。

每个点开一棵线段树空间复杂度 \(O(n^2)\),MLE。考虑到后一个位置相比前一个位置的更改只有 \(\log n\) 个节点,所以使用主席树。

时间复杂度 \(O(n\log n)\),空间复杂度 \(O(n\log n)\)


  1. 动态区间 Kth

还是要想办法维护前缀和。如果只是同 3 的前缀和的话,就要对前缀和进行 \(O(n\log n)\) 的单次修改,显然 TLE。

这里考虑用树状数组维护前缀和。修改时,可以只修改 \(\log n\) 个位置,复杂度 \(O(\log^2 n)\)

查询时,依旧是 \(R\) 位置减去 \(L-1\) 位置,这时候不再是两棵线段树作差,而是 \(\log\) 棵线段树与 \(\log\) 棵线段树作差,跳的时候,\(\log\) 个节点一起跳到左子树 / 右子树。

时间复杂度 \(O(n\log^2 n)\),空间复杂度 \(O(n\log n)\)

权值线段树合并

权值线段树支持合并。

两个权值线段树 \(T_1, T_2\) 的合并是递归的过程。具体来说,设目前要合并的两个子树分别为 \(T_1\) 中节点 \(x\)\(T_2\) 中节点 \(y\) 的子树,其对应区间均为 \([l,r]\),那么:

  • 首先,若 \(x = 0\)\(y = 0\),则 \(x, y\) 至少一者对应一个空节点,直接返回 \(x + y\) 即可。
  • 先将 \(x, y\) 的左儿子合并,设合并后的节点编号为 \(p\)
  • 再将 \(x, y\) 的右儿子合并,设合并后的节点编号为 \(q\)
  • \(y\) 的左儿子设为 \(p\),右儿子设为 \(q\),完成合并,并返回 \(y\) 表示合并后的节点编号为 \(y\)

特别的,当 \(x\)\(y\) 为叶子时,直接合并信息。

注意,在合并过程中,我们需要实时下放标记(pushdown),并实时更新节点信息(pushup)。

int merge(int x, int y, int l, int r) {
    if (!x || !y) return x | y;
    if (l == r) {
        tr[y].sum += tr[x].sum;
        return y;
    }
    pushdown(...);
    int mid = (l + r) >> 1;
    tr[y].ls = merge(tr[x].ls, tr[y].ls, l, mid);
    tr[y].rs = merge(tr[x].rs, tr[y].rs, mid + 1, r);
    return pushup(y), y;
}

时间复杂度分析
\(x, y\) 不都为 \(0\),我们就会删去其中之一,于是线段树合并的时间复杂度与删去的节点同级,而删去节点数肯定不会多于添加的节点,所以复杂度一般忽略,因为不会成为复杂度瓶颈。

可持久化权值线段树

主席树,可持久化线段树,可持久化权值线段树
一般在普通线段树上不会可持久化,一般都在权值线段树上对下标轴或时间轴建立版本,所以一般可持久化线段树就指可持久化权值线段树。

查询区间第 \(K\) 小(主席树)

P3834 【模板】可持久化线段树 2

给定 \(n\) 个整数构成的序列 \(a\),将对于指定的闭区间 \([l, r]\) 查询其区间内的第 \(k\) 小值。

update20260528:deepseek指出洛谷版本的 \(change()\) 函数在叶节点时未继承历史值。可能导致新节点 \(val\) 未复制旧值导致计数丢失,差分查询结果错误。这里只保留李煜东版本。

insert改版

核心区别在于先整体复制了,所以后面不用再处理各种共享版本问题所以非常好写

点击查看代码
// change改为李煜东的insert写法
struct seg{
    int val, ls, rs;
};

int root[maxn], cnt;
struct Tree{  // 主席树
    seg tree[maxn << 5];
    int query(int u1, int u2, int l, int r, int q){
        if(l == r) return l;
        int mid = l + r >> 1;
        int tmp = tree[tree[u2].ls].val - tree[tree[u1].ls].val; // 差分前缀左子树,得到区间左子树值
        if(tmp >= q) return query(tree[u1].ls, tree[u2].ls, l, mid, q); // 在左边
        else return query(tree[u1].rs, tree[u2].rs, mid + 1, r, q - tmp);
    }
    void pushup(int u){
        tree[u].val = tree[tree[u].ls].val + tree[tree[u].rs].val;
    }
    int insert(int now, int l, int r, int x, int val) {
        int p = ++cnt;
        tree[p] = tree[now];
        if (l == r) {
            tree[p].val += val;
            return p;
        }
        int mid = (l + r) >> 1;
        if (x <= mid) tree[p].ls = insert(tree[now].ls, l, mid, x, val);
        else tree[p].rs = insert(tree[now].rs, mid + 1, r, x, val);
        pushup(p);
        return p;
    }
}tr;

posted @ 2025-08-22 11:46  [丘李]Chilllee  阅读(38)  评论(0)    收藏  举报