[算法学习笔记] 线段树

概述:本文将介绍 线段树 的基本内容及可持久化操作。在最新版 NOI 大纲上,可持久化部分是 NOI级 算法,备考CSP-J/S NOIP 的读者可选择性学习。

Update:

2024/06/28 更新了可持久化部分内容。

2024/07/04 更新了线段树染色。

前言

  • 线段树可以维护一系列区间问题。包括但不限于区间加,区间乘,区间最值等。

  • 本文介绍的多为模板性内容。日后可能视情况更新题目解析。

  • 本文可能持续更新。

普通线段树

例题

给定一个数列,需要支持如下两种操作:

\(1\) \(x\) \(y\) \(k\) 将区间 \([x,y]\) 中每一个数加 \(k\) .

\(2\) \(x\) \(y\) 查询区间 \([x,y]\) 的和.

\(1\le n,m\le 10^5\)

容易发现,若本题区间修改和区间查询操作分离,可以使用前缀和查分解决。但当区间修改和查询操作混在一起时,显然前缀和无法解决问题。

Description

线段树是一棵平衡二叉树,在本题中,每个节点存储了区间 \([l,r]\) 的和。左儿子和右儿子分别存储 \([l,mid]\)\([r,mid]\) 的和。(\(mid = (l+r)\div 2\)).

build

容易发现,线段树的建立可以递归进行。线段树的 root 定义为区间 \([1,n]\) 的区间和。参考代码如下:

build 操作
void build(ll l,ll r,ll p) // l,r 表示当前区间;p表示当前节点编号,参考二叉树的建立。
{
    if(l == r) // 到达叶子赋值
    {
        tree[p] = a[l];
        return;
    }
    ll mid = (l+r) / 2;
    build(l,mid,p*2);
    build(mid+1,r,p*2+1);
    tree[p] = tree[p*2] + tree[p*2+1]; // 回溯统计区间和
}

modify

这里引入“懒标记” 的概念(即 lazy tag),可以理解为懒惰修改。

我们在 “堆优化 dijkstra” 中,使用了懒惰修改。由于 priority_queue 不支持随机删除,每次松弛结束的点我们可以打上一个 tag,再次访问时可以直接 continue。

线段树中的 lazy tag 可以类比理解。具体地,对于区间 \([L,R]\) 的修改,朴素做法是不断递归,复杂度太高无法接受。若设当前访问区间为 \([l,r]\) ,当满足 \(l\geq L\)\(r \le R\) 时,也就是区间 \([l,r]\) 是区间 \([L,R]\) 的子集。直接修改即可。同时标记当前区间的所有值均需修改。不必向下递归修改。

毕竟很多时候,我们的询问不需要将区间划分如此详细,等到询问需要将当前区间细分的时候再下放 lazy tag 修改即可。

反之,当区间 \([l,r]\) 去区间 \([L,R]\) 有交集。我们不得不将区间 \([l,r]\) 拆分,递归求解。此时将区间的 lazy tag 下放。我们只下方一层即可,等后期需要的时候再继续下放。

别忘了将原区间的 lazy tag 删除,因为已经下放到子节点,避免重复修改。

lazy tag 的下放只需要一层,等下面区间再需要继续分解时再继续下放(可以理解为不到迫不得已不干吧!懒标记是线段树的精髓!)

事实上,线段树的 pushdown 操作每道题目处理都不一样,但核心思想是一样的。理解了为什么 pushdown 是关键。下文给出了少量简单线段树例题。

参考代码如下

modify 操作
void pushdown(ll p,ll len) // 下放 lazy tag操作
{
    mark[p*2] += mark[p];
    mark[p*2+1] += mark[p];
	tree[p*2] += (len-len/2)*mark[p];
    tree[p*2+1] += (len/2)*mark[p];
    mark[p] = 0;  
}

void update(ll l,ll r,ll pl,ll pr,ll p,ll d)
{
    if(pl > r || pr < l) return; //当前区间与所求区间没有交集,不操作
    if(pl >= l && pr <= r) // 当前区间是所求区间的子集,直接修改,标记lazy tag
    {
        tree[p] += (pr-pl+1) * d;
        if(l < r) mark[p] += d;
    }
    else //当前区间与所求区间有交集,将当前区间拆分,递归修改
    {
        ll mid = (pl+pr) / 2;
        pushdown(p,(pr-pl+1)); // 下放 lazy tag
        update(l,r,pl,mid,p*2,d);
        update(l,r,mid+1,pr,p*2+1,d);
        tree[p] = tree[p*2] + tree[p*2+1]; // 儿子更新完后重新赋值父节点
    }
}

query

查询操作类比区间修改。见代码。

在查询的时候,如果当前区间需要继续分解,别忘了下放 lazy tag,同时将子节点的值更改。

query 操作
ll query(ll l,ll r,ll pl,ll pr,ll p)
{
    if(pl > r || pr < l) return 0;
    if(pl >= l && pr <= r) return tree[p]; // 当前区间是所求区间的子集,直接返回值
    else
    {
        ll mid = (pl+pr) / 2;
        return query(l,r,pl,mid,p*2) + query(l,r,mid+1,pr,p*2+1); // 直接递归查询
    }
}

以上是区间修改区间查询操作,完整代码如下.

区间修改,区间查询模板
#include <iostream>
#include <cstdio>
#include <algorithm>
#define ll long long
#define N 1000010
using namespace std;
ll tree[N];
ll mark[N];
ll a[N];
ll n,m;
void pushdown(ll p,ll len)
{
    mark[p*2] += mark[p];
    mark[p*2+1] += mark[p];
	tree[p*2] += (len-len/2)*mark[p];
    tree[p*2+1] += (len/2)*mark[p];
    mark[p] = 0;  
}
void build(ll l,ll r,ll p)
{
    if(l == r)
    {
        tree[p] = a[l];
        return;
    }
    ll mid = (l+r) / 2;
    build(l,mid,p*2);
    build(mid+1,r,p*2+1);
    tree[p] = tree[p*2] + tree[p*2+1];
}
void update(ll l,ll r,ll pl,ll pr,ll p,ll d)
{
    if(pl > r || pr < l) return;
    if(pl >= l && pr <= r)
    {
        tree[p] += (pr-pl+1) * d;
        if(l < r) mark[p] += d;
    }
    else
    {
        ll mid = (pl+pr) / 2;
        pushdown(p,(pr-pl+1));
        update(l,r,pl,mid,p*2,d);
        update(l,r,mid+1,pr,p*2+1,d);
        tree[p] = tree[p*2] + tree[p*2+1];
    }
}
ll query(ll l,ll r,ll pl,ll pr,ll p)
{
    if(pl > r || pr < l) return 0;
    if(pl >= l && pr <= r) return tree[p];
    else
    {
        ll mid = (pl+pr) / 2;
        pushdown(p,(pr-pl+1));
        return query(l,r,pl,mid,p*2) + query(l,r,mid+1,pr,p*2+1);
    }
}
int main()
{
    scanf("%lld%lld",&n,&m);
    for(ll i=1;i<=n;i++)
    {
        scanf("%lld",&a[i]);
    }
    build(1,n,1);
    for(ll i=1;i<=m;i++)
    {
        ll flg,x,y,z;
        scanf("%lld",&flg);
        if(flg == 1)
        {
            scanf("%lld%lld%lld",&x,&y,&z);
            update(x,y,1,n,1,z);
        }
        else
        {
            scanf("%lld%lld",&x,&y);
            printf("%lld\n",query(x,y,1,n,1));
        }
    }
}

简单的进阶操作

Warning: 在了解进阶操作前,读者需确保理解上文内容.

1. 区间加乘

模板 线段树2

Description

如题,已知一个数列,你需要进行下面三种操作:

  • 将某区间每一个数乘上 \(x\)
  • 将某区间每一个数加上 \(x\)
  • 求出某区间每一个数的和。

同样使用线段树维护区间值,只是在 pushdown 和 modify 操作中,需要注意优先级。必须确保 “先乘后加”。

image
(摘自算法学习笔记 线段树Trick

然后就是常规的线段树写法,区间加和区间乘是两个独立的 lazy tag,显然。

参考代码
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define ll long long
#define lc p<<1
#define rc p<<1|1
using namespace std;
const int N = 100005;
struct Node
{
    ll v,lz1=1,lz2;
}tree[N<<2];
int n,q,m;
int a[N];
void build(int l,int r,int p)
{
    if(l == r) 
    {
        tree[p].v = a[l];
        return;
    }
    int mid = (l+r) / 2 ;
    build(l,mid,lc);
    build(mid+1,r,rc);
    tree[p].v = tree[lc].v + tree[rc].v;
}
void pushdown(int p,int l,int r)
{
    int mid=l+r>>1;
    ll &lz1=tree[p].lz1,&lz2=tree[p].lz2;
	if(lz1!=1){
		tree[lc].lz2=tree[lc].lz2*lz1%m;
        tree[rc].lz2=tree[rc].lz2*lz1%m;
		tree[lc].lz1=tree[lc].lz1*lz1%m;
        tree[rc].lz1=tree[rc].lz1*lz1%m;
		tree[lc].v=tree[lc].v*lz1%m;
        tree[rc].v=tree[rc].v*lz1%m;
		lz1=1;
	}
	if(lz2){
		tree[lc].v=(tree[lc].v+(mid-l+1)*lz2)%m;
        tree[lc].lz2=(tree[lc].lz2+lz2)%m;
		tree[rc].v=(tree[rc].v+(r-mid)*lz2)%m;
        tree[rc].lz2=(tree[rc].lz2+lz2)%m;
		lz2=0;
	}
}
void update1(int l,int r,int pl,int pr,int d,int p)
{
    if(pl > r || pr < l) return;
    if(pl >= l && pr <= r)
    {
        tree[p].lz1 = tree[p].lz1 * d % m;
        tree[p].lz2 = tree[p].lz2 * d % m;
        tree[p].v = (tree[p].v *d) % m;
        return;
    }
    int mid = (pl+pr) >> 1;
    pushdown(p,pl,pr);
    update1(l,r,pl,mid,d,lc);
    update1(l,r,mid+1,pr,d,rc);
    tree[p].v = tree[lc].v + tree[rc].v;
}
void update2(int l,int r,int pl,int pr,int d,int p)
{
    if(pl > r || pr < l) return;
    if(pl >= l && pr <= r)
    {
        tree[p].lz2 = (tree[p].lz2 + d) % m;
        tree[p].v = (tree[p].v + (pr-pl+1)*d) % m;
        return;
    }
    int mid = (pl+pr) >> 1;
    pushdown(p,pl,pr);
    update2(l,r,pl,mid,d,lc);
    update2(l,r,mid+1,pr,d,rc);
    tree[p].v = (tree[lc].v + tree[rc].v) % m;
}
long long query(int l,int r,int pl,int pr,int p)
{
    if(pl > r || pr < l) return 0;
    if(pl >= l && pr <= r)
    {
        return tree[p].v;
    }
    int mid = (pl+pr) >> 1;
    pushdown(p,pl,pr);
    return (query(l,r,pl,mid,lc) + query(l,r,mid+1,pr,rc))%m;
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>q>>m;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
    }   
    build(1,n,1);
    while(q--)
    {
        int flg,x,y,k;
        cin>>flg;
        if(flg == 1) 
        {
            cin>>x>>y>>k;
            update1(x,y,1,n,k,1);
        }
        if(flg == 2)
        {
            cin>>x>>y>>k;
            update2(x,y,1,n,k,1);
        }
        else if(flg == 3)
        {
            cin>>x>>y;
            cout<<query(x,y,1,n,1)<<endl;
        }
    }
    return 0;
}

2.区间最值

前文提到,线段树可以维护区间型问题。容易发现区间 \([L,R]\) 的最值是 \(\max([L,mid],[mid+1,R])\)。可以使用线段树维护。

静态 RMQ 问题

对于静态 RMQ 问题,可以使用 ST 表维护。参见 算法学习笔记 ST表

这里介绍使用线段树维护静态区间最值。

首先建立线段树,存储区间 \([l,r]\) 的最值。

build
void build(int l,int r,int p)
{
	if(l == r) 
	{
		tree[p] = a[l];
		return;
	}
	int mid = (l+r) / 2;
	build(l,mid,p*2);
	build(mid+1,r,p*2+1);
	tree[p] = max(tree[p*2],tree[p*2+1]);
}

上文提到基本原理。我们维护区间 \([l,r]\) 的最值。对于查询,如果当前区间 \([l,r]\) 是所求区间 \([L,R]\) 的子集,直接返回。如果有交集,则拆分,分别求解。容易发现和查询区间和操作是类似的。

query
int query(int l,int r,int ll,int rr,int p)
{
	if(l > rr || r > ll) return INF; //没有交集
	int mid = (l+r) / 2;
	if(l>=ll && r<= rr) return tree[p];
	else return min(query(l,mid,ll,rr,p*2),query(mid+1,r,ll,rr,p*2+1));
}	
}
动态 RMQ 问题

常见的动态 RMQ 问题一般是在静态 RMQ 的基础上添加了单点修改,这就使得我们必须要更新区间最值。

对于单点修改,搜索即可。类似于二分查找,如果需要修改的未知 \(x \le mid\) ,则递归左区间,否则递归右区间。

对于边界判定,如果到达叶子且当前位置是所需修改的位置,直接更改。回溯时更改区间最值即可。

modify
void modify(int l,int r,int p,int x,int y) //将 a_x 修改为y
{
	if(l == r && l == x) tree[p] = y,return;
	int mid = (l+r) / 2;
	if(r <= mid) modify(l,mid,p*2,x);
	else modify(mid+1,r,p*2+1,x);
	tree[p] = max(tree[p*2],tree[p*2+1]);
}

这里提供线段树解决动态 RMQ 问题模板,供参考。

模板
#include <bits/stdc++.h>
using namespace std;
const int N = 10000010;
int tree[N];
int n;
int a[N];
void build(int p,int l,int r)
{
    if(l == r) 
    {
        tree[p] = a[l];
        return;
    }
    int mid = (l+r) / 2;
    build(p*2,l,mid);
    build(p*2+1,mid+1,r);
    tree[p] = max(tree[p*2],tree[p*2+1]);
}
void modify(int l,int r,int p,int x,int y)
{
    int mid = (l+r) / 2;
    if(l == r && l == x)
    {
        tree[p] = y;
        return; 
    } 
    if(x <= mid) modify(l,mid,p*2,x,y);
    else modify(mid+1,r,p*2+1,x,y);
    tree[p] = max(tree[p*2],tree[p*2+1]);
}
int query(int l,int r,int p,int L,int R)
{
    if(r < L || l > R) return -1;
    if(l >= L && r <= R) return tree[p];
    else
    {
        int mid = (l+r) / 2;
        return max(query(l,mid,p*2,L,R),query(mid+1,r,p*2+1,L,R));
    }
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n;
    for(int i=1;i<=n;i++)
        cin>>a[i];
    build(1,1,n);
    int q;
    cin>>q;
    while(q--)
    {
        int op,x,y;
        cin>>op;
        if(op == 1)
        {
            cin>>x>>y;
            modify(1,n,1,x,y);
        }
        else
        {
            cin>>x>>y;
            cout<<query(1,n,1,x,y)<<endl;
        }
    }
    return 0;
}

事实上,线段树使用时,应用结构体将节点封装是一种更好的选择。这里不再赘述。

一些例题 (可能会随时更新)

线段树维护异或

P3870 [TJOI2009] 开关

注意到本题的修改操作是区间取反。询问给定区间内 1 的个数。而对同一个区间进行偶数次取反是无效的。考虑异或。

线段树维护区间内 1 的个数。这是比较常见的思路,题目需要求什么就维护什么。lazy tag 标记当前所有的数是否需要被取反。传递的时候需要注意,lazy tag 需要进行异或操作,因为偶数次取反和不操作是一样的。不能进行简单的赋值。

其他的就是基本的线段树操作。

Code
#include <bits/stdc++.h>
using namespace std;
const int N  = 10000010;
int tree[N];
int mark[N];
int n,m;
void pushdown(int p,int l,int r)
{
    if(!mark[p]) return; //如果下面的区间不需要取反就不操作!
    mark[p*2] ^= 1;// 传递
    mark[p*2+1] ^= 1; 
    int mid = (l+r) / 2;
    tree[p<<1]=(mid-l+1)-tree[p<<1]; // 修改下一层
    tree[p<<1|1]=(r-mid)-tree[p<<1|1];
    mark[p] = 0; // 删除 lazy tag
}
void modify(int l,int r,int p,int L,int R)
{
    if(l > R || r < L) return;
    if(l >= L && r <= R)
    {
        tree[p] = (r-l+1)-tree[p]; //当前区间是所求区间的子集,进行取反,同时修改lazy tag
        mark[p] ^= 1;
        return;
    }
    int mid = (l+r) / 2;
    pushdown(p,l,r);
    modify(l,mid,p*2,L,R);
    modify(mid+1,r,p*2+1,L,R);
    tree[p] = tree[p*2] + tree[p*2+1];
}
int query(int l,int r,int p,int L,int R)
{
    if(l > R || r < L) return 0;
    if(l >= L && r <= R)
    {
        return tree[p];
    }
    else
    {
        int mid = (l+r) / 2;
        pushdown(p,l,r);
        return query(l,mid,p*2,L,R) + query(mid+1,r,p*2+1,L,R);
    }
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>m;
    while(m--)
    {
        int op,a,b;
        cin>>op>>a>>b;
        if(!op)
        {
            modify(1,n,1,a,b);
        }
        else 
        {
            cout<<query(1,n,1,a,b)<<endl;
        }
    }
    return 0;
}

(其实本题是多倍经验,自己去看讨论区吧)

线段树维护差分

P1438 无聊的数列

Description

维护一个数列 \(a_i\),支持两种操作:

  • 1 l r K D:给出一个长度等于 \(r-l+1\) 的等差数列,首项为 \(K\),公差为 \(D\),并将它对应加到 \([l,r]\) 范围中的每一个数上。即:令 \(a_l=a_l+K,a_{l+1}=a_{l+1}+K+D\ldots a_r=a_r+K+(r-l) \times D\)

  • 2 p:询问序列的第 \(p\) 个数的值 \(a_p\)

对于 \(100\%\) 数据,\(0\le n,m \le 10^5,-200\le a_i,K,D\le 200, 1 \leq l \leq r \leq n, 1 \leq p \leq n\)

一看数据范围 1e5,数据结构题。

和普通线段树不同的是,本题是给区间 \([l,r]\) 加上一个等差数列。我们知道等差数列的公差是相同的,因此,我们可以在差分数组上进行更改。

举个例子。

原序列:0 0 0 0 0 0
差分序列:0 0 0 0 0 0
等差序列:1 3 5 7 9
加上等差数列后的序列:1 3 5 7 9 0
差分:1 2 2 2 2 -9

我们发现,在原始序列 \([l,r]\) 加上等差数列,我们只需要在对应的差分数组 \(a_l\) 加上首项,\(a_{l+1}-a_{r}\) 加上公差 \(d\),在 \(a_{r+1}\) 减去尾项即可。

为什么呢?

显然,一个等差数列的差分数组元素一定相同。因此我们可以在差分数组 \(a_{l+1}-a_{r}\) 加上公差。最后别忘了在尾项的下一位减去,消除影响。

最后询问的时候前缀和即可。剩下的就是线段树模板。

Code
#include <bits/stdc++.h>
#define ll long long
#define int long long
using namespace std;
const int N  = 1000010;
int a[N],tree[N],c[N];
int mark[N];
int n,m;
void pushdown(ll p,ll len)
{
    mark[p*2] += mark[p];
    mark[p*2+1] += mark[p];
	tree[p*2] += (len-len/2)*mark[p];
    tree[p*2+1] += (len/2)*mark[p];
    mark[p] = 0;  
}
void build(ll l,ll r,ll p)
{
    if(l == r)
    {
        tree[p] = a[l];
        return;
    }
    ll mid = (l+r) / 2;
    build(l,mid,p*2);
    build(mid+1,r,p*2+1);
    tree[p] = tree[p*2] + tree[p*2+1];
}
void update(ll l,ll r,ll pl,ll pr,ll p,ll d)
{
    if(pl > r || pr < l) return;
    if(pl >= l && pr <= r)
    {
        tree[p] += (pr-pl+1) * d;
        if(l < r) mark[p] += d;
    }
    else
    {
        ll mid = (pl+pr) / 2;
        pushdown(p,(pr-pl+1));
        update(l,r,pl,mid,p*2,d);
        update(l,r,mid+1,pr,p*2+1,d);
        tree[p] = tree[p*2] + tree[p*2+1];
    }
}
ll query(ll l,ll r,ll pl,ll pr,ll p)
{
    if(pl > r || pr < l) return 0;
    if(pl >= l && pr <= r) return tree[p];
    else
    {
        ll mid = (pl+pr) / 2;
        pushdown(p,(pr-pl+1));
        return query(l,r,pl,mid,p*2) + query(l,r,mid+1,pr,p*2+1);
    }
}
signed main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>m;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
    }
    for(int i=n-1;i>0;i--) a[i+1] = a[i+1] - a[i];
    build(1,n,1);
    while(m--)
    {
        int op,l,r,k,d;
        cin>>op;
        if(op == 1)
        {
            cin>>l>>r>>k>>d;
             update(l,l,1,n,1,k);
             update(l+1,r,1,n,1,d);
            int t = -(k+d*(r-l));
        //    cout<<t<<"qq"<<endl;
            update(r+1,r+1,1,n,1,-(k+d*(r-l)));
        }
        else
        {
            int p;
            cin>>p;
            cout<<query(1,p,1,n,1)<<endl;
        }
    }
}

线段树染色

我们先看一道例题。Luogu P3740

Description

给定一个长度为 \(n\) 的序列 \(a\),共有 \(q\) 次操作,操作 \(i\) 对区间 \([l,r]\) 涂上颜色 \(i\)。涂的颜色可以覆盖。求所有操作完毕后序列上可以看到的颜色数量。

一道非常经典的线段树染色问题。

染色线段树和普通线段树非常类似,只是在某些操作上略有不同,这里讲解一下。

在线段树染色中,同样令 \(mark_p\) 表示当前区间 \(p\) 需要染什么颜色。还是同样的 lazy tag。令 \(tree_p \in\{-1,0,c\}\) 表示当前区间颜色状态。( \(-1\) 表示当前区间有多种颜色,\(0\) 表示无色,\(c\) 是一个常量,表示当前区间全部被染成了 \(c\) 颜色)。

此类问题往往初始线段树都为空,当然也不乏有初始颜色的情况,这里不谈。

modify 操作

先放代码。

染色 modify
void modify(int l,int r,int pl,int pr,int p,int d)
{
    if(l > pr || r < pl) return;
    if(l >= pl && r <= pr)
    {
        tree[p] = d;
        mark[p] = d;
        return;
    }
    int mid = (l+r) >> 1;
    pushdown(p,r-l+1);
    modify(l,mid,pl,pr,p*2,d);
    modify(mid+1,r,pl,pr,p*2+1,d);
    if(tree[p*2] == tree[p*2+1]) tree[p] = tree[p*2];
    else tree[p] = -1; 
}

如你所见,和普通线段树几乎一模一样。只是在修改的时候直接覆盖,lazy tag 也直接覆盖。pushup 若左右子树都是同一种颜色,则传递,否则标记为 \(-1\)。(若左右子树都是 \(-1\) 那也传递成 \(-1\) 了)。区间被标记成 \(-1\) 的,在查询操作的时候继续向下递归。

pushdown

pushdown
void pushdown(int p,int len)
{
    if(!mark[p]) return;
    mark[p*2] = mark[p];
    mark[p*2+1] = mark[p];
    tree[p*2] = mark[p];
    tree[p*2+1] = mark[p];
    mark[p] = 0;
}

传递的时候非常暴力,直接覆盖即可。

query

query
void query(int l,int r,int pl,int pr,int p)
{
    if(l > pr || r < pl) return;
    else if(!tree[p]) return;
    else if(tree[p] == -1)
    {
        int mid = (l+r) >> 1;
        pushdown(p,r-l+1);
        query(l,mid,pl,pr,p*2);
        query(mid+1,r,pl,pr,p*2+1);
        if(tree[p*2] == tree[p*2+1]) tree[p] = tree[p*2];
        else tree[p] = -1;
    }
    else
    {
        if(!vis[tree[p]]) 
        {
            ans ++;
            vis[tree[p]] = 1;
        }
        return;
    }
}

看着略微复杂。在查询的时候,若当前区间都是单一的颜色,直接统计即可。若被标记为 \(-1\),我们 pushdown 后进行左右子树查询,然后 pushup(和 modify 同理)。这里统计答案为了避免重复,开了一个 vis 数组来判定当前颜色是否被统计。

对于线段树染色,这里主要想讲的是 tree 数组的合理表示。选择一个合理的表示方法会事半功倍。大家可以记住这种处理方法。当然有更好的方法欢迎交流。2024-07-04 14:57:22 星期四

可持久化

我们先讲解“可持久化” 为何意。

可持久化,即对于每一次操作,我们需要记录它的历史版本。

我们首先暴力地思考,对于每一次修改操作,我们可以首先将原本地数据结构复制一份,然后在复制件上进行操作。但这样操作空间消耗太大。

注意到,我们在修改线段树的时候,我们显然并不需要将所有的节点都修改。我们只需要将修改的节点内容备份一份即可。对于修改的节点,新开一个点来修改,然后连边关系和原来的点一致。

对于不需要修改的点,不必动它。

image

(图源OI-wiki,侵删。)

运用这种思想,我们可以对多种数据结构进行可持久化操作。例如 Trie 树,数组等。这种可持久化的前提是 一种数据结构在修改后它的拓扑序不发生改变,例如线段树,Trie 树,数组它们在修改后本身形态并没有改变,改变的仅仅是内容。满足这个条件的数据结构都可以类似的进行可持久化操作。

在讲解可持久化线段树前,我们先放两道例题。

Luogu P3919 [模板]可持久化线段树1(可持久化数组)

Luogu P3834 [模板]可持久化线段树2

第一个题是可持久化线段树的模板题,要求我们在某个历史版本上修改某个值,并访问某个版本的某个值。考虑建立可持久化线段树。这里需要强调,可持久化线段树需要动态开点建立,因为我们在修改的时候需要新开点,这也就意味着可持久化线段树的父亲孩子表示不可以使用类似于 \(p\times 2,p \times 2 + 1\),应当使用结构体来记录儿子的节点编号。

build

建立可持久化线段树比较简单。和建立普通线段树一基本一样。但是这里我们期望得到它的左节点和右节点的标号,不妨 build 返回节点编号,会更好处理一点。也比较直观。

build
void build(int l,int r)
{
    int p = ++cnt;
    if(l == r) 
    {
        tree[p].val = a[l];
    }
    int mid = (l+r) / 2;
    tree[p].ls = build(l,mid);
    tree[p].rs = build(mid+1,r);
}

modify

对于修改操作,和普通线段树不同的是,我们显然不能直接修改。考虑在一棵线段树上,我们只需要修改递归到的节点即可。首先执行动态开点,将原节点信息复制一份给新节点。然后对新节点进行修改。

由于本题中我们实现的“可持久化数组” 每次修改只牵扯到一个点,其他点不需要管。所以处理到这就可以了。下面的主席树操作略微复杂,后文再讲。

modify
int modify(int p,int l,int r,int add,int w)
{
    tree[++cnt] = tree[p];
    p = cnt; 
    if(l == r) 
    {
        tree[p].val = w;
    }  
    else
    {
        int mid = (l+r) / 2;
    if(add <= mid) tree[p].ls = modify(tree[p].ls,l,mid,add,w);
    else tree[p].rs = modify(tree[p].rs,mid+1,r,add,w);
    }
    
    return p;
}

query

查询操作更简单了,我们记录每个版本的根节点编号,然后从根节点往下查找即可。

query
int query(int p,int l,int r,int add)
{
    if(l == r) return tree[p].val;
    int mid = (l+r) / 2;
    if(add <= mid)
    {
        return query(tree[p].ls,l,mid,add);
    }
    else return query(tree[p].rs,mid+1,r,add);
}

完整模板代码

Code
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 60000010;
int n,m;
int a[N];
int root[N];
int cnt = 0;
struct Node
{
    int ls,rs,val;
}tree[N];
void build(int l,int r)
{
    int p = ++cnt;
    if(l == r) 
    {
        tree[p].val = a[l];
    }
    int mid = (l+r) / 2;
    tree[p].ls = build(l,mid);
    tree[p].rs = build(mid+1,r);
}
int modify(int p,int l,int r,int add,int w)
{
    tree[++cnt] = tree[p];
    p = cnt; 
    if(l == r) 
    {
        tree[p].val = w;
    }  
    else
    {
        int mid = (l+r) / 2;
    if(add <= mid) tree[p].ls = modify(tree[p].ls,l,mid,add,w);
    else tree[p].rs = modify(tree[p].rs,mid+1,r,add,w);
    }
    
    return p;
}
int query(int p,int l,int r,int add)
{
    if(l == r) return tree[p].val;
    int mid = (l+r) / 2;
    if(add <= mid)
    {
        return query(tree[p].ls,l,mid,add);
    }
    else return query(tree[p].rs,mid+1,r,add);
}
signed main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>m;
    for(int i=1;i<=n;i++) cin>>a[i];
    build(1,n);
    root[0] = 1;
    for(int i=1;i<=m;i++)
    {
        int v,op,loc;
        cin>>v>>op>>loc;
        if(op == 1)
        {
            int val;
            cin>>val;
            root[i] = modify(root[v],1,n,loc,val);
        }
        else
        {   
            cout<<query(root[v],1,n,loc)<<endl;
            root[i] = root[v];
        }
    }
}

上述操作仅仅应用了可持久化的基本思想。请读者确保理解后再继续阅读。接下来将介绍可持久化权值线段树(又名主席树)。

可持久化权值线段树

我们首先需要了解 “权值线段树” 为何物。

我们在普通线段树中,无论是维护区间最值,区间和,区间乘积,维护的都是数据内容。但在权值线段树中,维护的是每个元素出现个数。容易发现这个也满足结合律。故可以用线段树维护。

朴素权值线段树可以维护 全局区间第 \(k\) 小/大。具体地,和朴素线段树一样,将区间分为左右两部分。若左半区间内的数字个数大于等于当前需要查找的第 \(k\) 个数,证明第 \(k\) 小一定在左半部分。否则在右半部分,同时将 \(k\) 减去左半部分的元素个数。

这里给出权值线段树维护全局区间第 \(k\) 小模板代码。

权值线段树
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
const int N = 10000010;
int n,k,q;
int a[N],vis[N];
int tree[N];
void build(int l,int r,int p) //l,r 即为当前区间
{
    if(l == r)
    {
        tree[p] = vis[a[l]]; 
        return;
    }
    int mid = (l+r) / 2;
    build(l,mid,p*2);
    build(mid+1,r,p*2+1);
    tree[p] = tree[p*2] + tree[p*2+1];
}
int query(int l,int r,int p,int k)
{
    if(l == r) return l;
    int mid = (l+r) / 2;
    if(tree[p*2] >= k) return query(l,mid,p*2,k); 
    else return query(mid+1,r,p*2+1,k-tree[p*2]);
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>q;
    int minn = INF,maxn = -1;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
        maxn = max(maxn,a[i]);
        minn = min(minn,a[i]);
        vis[a[i]] ++; // 统计每个数字的出现次数
    }
    build(1,n,1);
    while(q--)
    {
        int l,r,k;
        cin>>l>>r>>k;
        cout<<query(1,n,1,k)<<endl;
        cout<<a[query(1,n,1,k)]<<endl;
    }
}

但如果我们要求区间 \([L,R]\) 的第 \(k\) 小/大呢?这当然可以用权值线段树解决。注意到区间 \([1,R]\) 的内容减去区间 \([1,(L-1)]\) 的内容就是区间 \([L,R]\) 的内容。这就是前缀和。

显然我们可以对于每个 \(i\),开一棵权值线段树来统计 \(\forall[i,n]\) 的内容。然后根据上文提到的前缀和计算出区间 \([L,R]\) 的内容。但这样会 MLE。

读者这里可能会注意到,我们显然不必开多个线段树。我们可以动态开点,只更新修改过的点即可。这里的“修改过” 指区间 \([1,R+1]\) 相对于区间 \([1,R]\) 增加的元素。

这就是可持久化权值线段树。也叫主席树。主席树的来历可以自行网络搜索,由于某方面原因这里不讲。理论比较简单,具体实现需要读者多加练习。

这里提供模板代码,可以通过Luogu P3834 [模板]可持久化线段树2

Code
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5+5;
int n,cnt,m;
struct Node
{
    int l,r,val;
}tree[N*35];
int root[N];
int modify(int p,int l,int r,int x)
{
	//cout<<l<<" "<<r<<endl; 
    int v = ++cnt;
    tree[v] = tree[p];
    if(l == r) tree[v].val ++;
    else
    {
        int mid = (l+r) >> 1;
        if(x <= mid) tree[v].l = modify(tree[p].l,l,mid,x);
        else tree[v].r = modify(tree[p].r,mid+1,r,x);
        tree[v].val = tree[tree[v].l].val + tree[tree[v].r].val;
    }
    return v;
}
int query(int p,int v,int l,int r,int x)
{
//	cout<<p<<endl;
    if(l == r) return l;
    int mid = (l+r) >>1;
    int s = tree[tree[p].l].val - tree[tree[v].l].val;
    if(x <= s) return query(tree[p].l,tree[v].l,l,mid,x);
    else return query(tree[p].r,tree[v].r,mid+1,r,x-s); 
}
int main()
{
  //  freopen("input.txt","r",stdin);
   // freopen("output.txt","w",stdout);
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>m;
    for(int i=1;i<=n;i++) 
    {
        int t;
        cin>>t;
        root[i] = modify(root[i-1],-1e9,1e9,t);
    }
    while(m--)
    {
        int l,r,k;
        cin>>l>>r>>k;
        cout<<query(root[r],root[l-1],-1e9,1e9,k)<<endl;
    }
}
posted @ 2024-04-04 11:42  SXqwq  阅读(57)  评论(0编辑  收藏  举报