[算法学习笔记] 线段树

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

Update:

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

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

2024/08/24 更新了离散化,动态开点。

2024/08/27 更新了线段树扫描线,维护前缀最大值。

前言

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

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

  • 本文可能持续更新。

普通线段树

例题

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

\(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),可以理解为懒惰修改。

具体地,对于区间 \([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); // 直接递归查询
    }
}

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

区间修改,区间查询模板(2024.11.12 修改)
struct segtree
{
    #define ls p<<1
    #define rs p<<1|1
    int tree[N*8],mark[N*8];
    void pushup(int p){tree[p] = tree[ls] + tree[rs];}
    void pushdown(int p,int len)
    {
        if(!mark[p]) return;
        tree[ls] += mark[p] * (len-len/2),tree[rs] += mark[p] * (len/2);
        mark[ls] += mark[p],mark[rs] += mark[p];
        mark[p] = 0;
        return;
    }
    void build(int l = 1,int r = n,int p = 1)
    {
        if(l == r){tree[p] = a[l];return;}
        int mid = (l + r) >> 1;
        build(l,mid,ls),build(mid+1,r,rs);
        pushup(p);
    }
    void modify(int l,int r,int pl,int pr,int p,int d)
    {
        if(l >= pl && r <= pr) {tree[p] += (r-l+1)*d;mark[p] += d;return;}
        int mid = (l + r) >> 1;
        pushdown(p,r-l+1);
        if(pl <= mid) modify(l,mid,pl,pr,ls,d);
        if(pr > mid) modify(mid+1,r,pl,pr,rs,d);
        pushup(p);
    }
    int query(int l,int r,int pl,int pr,int p)
    {
        if(l >= pl && r <= pr)return tree[p];
        int mid = (l+r) >> 1,sum = 0;
        pushdown(p,r-l+1);
        if(pl <= mid) sum += query(l,mid,pl,pr,ls);
        if(pr > mid) sum += query(mid+1,r,pl,pr,rs);
        return sum;
    }
} seg;

动态开点

朴素线段树空间要开 \(4\) 倍。在一些题目中,这是无法接受的。\(p\times 2,p\times 2+1\) 的编号方式有很大的局限性。

注意到线段树并不是所有节点都有用,我们不妨修改时,若当前点没有开辟,才新建节点。这样线段树所有节点都是必要的。而且省空间。

build
void update(int l,int r,int pos,int &p,int d)
{
    if(!p) p = ++cnt; // 若未开辟,新建节点
    if(l == r) 
    {
        tree[p].sum = d;
        return;
    }
    int mid = (l+r) >> 1;
    if(pos <= mid) update(l,mid,pos,tree[p].l,d);
    if(pos > mid) update(mid+1,r,pos,tree[p].r,d);
    pushup(p);
}

如你所见,动态开点线段树 build 操作采用 单点修改 方式。

modify
void modify(int l,int r,int pl,int pr,int &p,int d)
{
    if(!p) p = ++cnt; // 若未开辟,新建节点
    if(l >= pl && r <= pr) 
    {
        tree[p].sum += (r-l+1)*d;
        tree[p].tag += d;
        return;
    }
    int mid = (l+r) >> 1;
    pushdown(p,l,r);
    if(pl <= mid) 
    modify(l,mid,pl,pr,tree[p].l,d);
    if(pr > mid)
        modify(mid+1,r,pl,pr,tree[p].r,d);
    pushup(p);
}

除了动态开点部分其余都是一样的。

pushdown
void pushdown(int p,int l,int r)
{
    if(!tree[p].tag) return;
    if(!tree[p].l) tree[p].l = ++cnt; // pushdown 时也要动态开点 先开完了再操作
    if(!tree[p].r) tree[p].r = ++cnt;
    int mid = (l+r) >> 1;
    tree[tree[p].l].sum +=(mid-l+1)*tree[p].tag;
    tree[tree[p].r].sum += (r-mid)*tree[p].tag;
    tree[tree[p].l].tag += tree[p].tag;
    tree[tree[p].r].tag += tree[p].tag;
    tree[p].tag = 0;
}
query
int query(int l,int r,int pl,int pr,int p) 
{
    if(!p) return 0; // 如果没有这个点直接返回 0
    if(l >= pl && r <= pr) return tree[p].sum;
    int mid = (l+r) >> 1;
    pushdown(p,l,r);
    int ans = 0;
    if(pl <= mid) ans += query(l,mid,pl,pr,tree[p].l);
    if(pr > mid) ans += query(mid+1,r,pl,pr,tree[p].r);
    return ans;
}
动态开点线段树模板(线段树1)
    struct segtree
    {
        int cnt = 1;
        struct TREE
        {
            int l,r,sum,tag;
        }tree[N];
        void pushup(int &p) {tree[p].sum = tree[tree[p].l].sum + tree[tree[p].r].sum;}
        void pushdown(int p,int l,int r)
        {
            if(!tree[p].tag) return;
            if(!tree[p].l) tree[p].l = ++cnt;
            if(!tree[p].r) tree[p].r = ++cnt;
            int mid = (l+r) >> 1;
            tree[tree[p].l].sum +=(mid-l+1)*tree[p].tag;
            tree[tree[p].r].sum += (r-mid)*tree[p].tag;
            tree[tree[p].l].tag += tree[p].tag;
            tree[tree[p].r].tag += tree[p].tag;
            tree[p].tag = 0;
        }
        void update(int l,int r,int pos,int &p,int d)
        {
            if(!p) p = ++cnt;
            if(l == r) 
            {
                tree[p].sum = d;
                return;
            }
            int mid = (l+r) >> 1;
            if(pos <= mid) update(l,mid,pos,tree[p].l,d);
            if(pos > mid) update(mid+1,r,pos,tree[p].r,d);
            pushup(p);
        }
        void modify(int l,int r,int pl,int pr,int &p,int d)
        {
            if(!p) p = ++cnt;
            if(l >= pl && r <= pr) 
            {
                tree[p].sum += (r-l+1)*d;
                tree[p].tag += d;
                return;
            }
            int mid = (l+r) >> 1;
            pushdown(p,l,r);
            if(pl <= mid) 
                modify(l,mid,pl,pr,tree[p].l,d);
            if(pr > mid)
                modify(mid+1,r,pl,pr,tree[p].r,d);
            pushup(p);
        }
        int query(int l,int r,int pl,int pr,int p)
        {
            if(!p) return 0;
            if(l >= pl && r <= pr) return tree[p].sum;
            int mid = (l+r) >> 1;
            pushdown(p,l,r);
            int ans = 0;
            if(pl <= mid) ans += query(l,mid,pl,pr,tree[p].l);
            if(pr > mid) ans += query(mid+1,r,pl,pr,tree[p].r);
            return ans;
        }
    }seg;

线段树区间离散化

将所有区间端点离散化后映射一下即可。

参考代码
void discretizing()
{
    sort(val+1,val+cnt+1);
    int x = unique(val+1,val+cnt+1)-val-1;
    sort(opt+1,opt+n+1);
    for(int i=1;i<=n;i++)
    {
        opt[i].l = lower_bound(val+1,val+x+1,opt[i].l)-val;
        opt[i].r = lower_bound(val+1,val+x+1,opt[i].r)-val;
        minl = min(minl,opt[i].l); // 记录区间两端点
        maxr = max(maxr,opt[i].r);
    }
}

简单的进阶操作

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;
        }
    }
}

P1712 [NOI2016] 区间

Source

在数轴上有 \(n\) 个闭区间从 \(1\)\(n\) 编号,第 \(i\) 个闭区间为 \([l_i,r_i]\)

现在要从中选出 \(m\) 个区间,使得这 \(m\) 个区间共同包含至少一个位置。换句话说,就是使得存在一个 \(x\) ,使得对于每一个被选中的区间 \([l_i,r_i]\),都有 \(l_i \leq x \leq r_i\)

对于一个合法的选取方案,它的花费为被选中的最长区间长度减去被选中的最短区间长度。

区间 \([l_i,r_i]\) 的长度定义为 \((r_i-l_i)\) ,即等于它的右端点的值减去左端点的值。

求所有合法方案中最小的花费。如果不存在合法的方案,输出 \(-1\)

对于全部的测试点,保证 \(1 \leq m \leq n\)\(1 \leq n \leq 5 \times 10^5\)\(1 \leq m \leq 2 \times 10^5\)\(0 \leq l_i \leq r_i \leq 10^9\)

Solution

发现答案和区间长度有关,那我们不妨先排序,反正这不影响答案。

排序后,我们暴力地加入区间,如果一个点被覆盖了超过 \(m\) 次,再往后加显然不会更优,我们移动左端点,不断撤销区间,直到不满足题意。再移动右端点。容易发现右端点一定单调移动,不可能回退。这里运用了双指针的思想。

如何维护区间呢?用线段树即可。线段树一个区间维护区间内所有点被覆盖最大值,查询时只需查 \(tree_1\) 即可。容易发现这玩意可以合并,且可以整体打 lazy tag。故复杂度是对的。

这里还练习了线段树区间离散化,其实非常简单,把所有单点离散化后映射到区间上即可。具体实现见代码。

Code
namespace solution
{
    struct segtree
    {
        #define ls p<<1
        #define rs p<<1|1
        struct TREE
        {
            int sum,maxn;
        }tree[N];
        void modify_dot(int p,int d){tree[p].maxn += d;tree[p].sum += d;}
        void pushup(int p){tree[p].maxn = max(tree[ls].maxn,tree[rs].maxn);}
        void pushdown(int p)
        {
            if(!tree[p].sum) return;
            modify_dot(ls,tree[p].sum);
            modify_dot(rs,tree[p].sum);
            tree[p].sum = 0;
        }
        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)
            {
                modify_dot(p,d);
                return;
            }
            int mid = (l+r) >> 1;
            pushdown(p);
            modify(l,mid,pl,pr,ls,d);
            modify(mid+1,r,pl,pr,rs,d);
            pushup(p);
        }
    }seg;
    struct Node
    {
        int l,r,len;
        bool operator <(const Node& a)const{
            return len < a.len;
        }
    }opt[N];
    int n,m,cnt = 0;
    int val[N];
    int minl = INF,maxr = 0;
    void discretizing()
    {
        sort(val+1,val+cnt+1);
        int x = unique(val+1,val+cnt+1)-val-1;
        sort(opt+1,opt+n+1);
        for(int i=1;i<=n;i++)
        {
            opt[i].l = lower_bound(val+1,val+x+1,opt[i].l)-val;
            opt[i].r = lower_bound(val+1,val+x+1,opt[i].r)-val;
            minl = min(minl,opt[i].l);
            maxr = max(maxr,opt[i].r);
        }
    }
    void solve()
    {
        int ans = INF;
        cin>>n>>m;
        for(int i=1;i<=n;i++)
        {
            cin>>opt[i].l>>opt[i].r;
            opt[i].len = opt[i].r - opt[i].l;
            val[++cnt] = opt[i].l,val[++cnt] = opt[i].r;
        }
        discretizing();
        int nowl = 1;
        for(int i=1;i<=n;i++)
        {
            seg.modify(minl,maxr,opt[i].l,opt[i].r,1,1);
            while(seg.tree[1].maxn >= m)
            {
                ans = min(ans,opt[i].len-opt[nowl].len);
                seg.modify(minl,maxr,opt[nowl].l,opt[nowl].r,1,-1);
                nowl ++;
            }
        }
        cout<<((ans == INF?-1:ans))<<endl;
        return;
    }
}

线段树染色

我们先看一道例题。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 数组的合理表示。选择一个合理的表示方法会事半功倍。大家可以记住这种处理方法。当然有更好的方法欢迎交流。

可持久化

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

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

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

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

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

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;
    }
}

线段树扫描线

例题:P5490 [模板]扫描线&矩形面积并

简化题意:给定平面上若干个矩形,求它们面积的并集。

\(1\le n\le 10^5,1\le x\le 10^9\)

由于都是矩形,面积可转化为 \(\sum\) 截线段长度 \(\times\) 所截高度。即将矩形分为若干个小矩形求解。

如图

(图源 https://www.luogu.com.cn/article/9pcpxgzs ,侵删)

问题在与我们如何维护当前所截线段长度。

不妨将矩形下面边权值设为 \(1\),上边权设置为 \(-11\)。令扫描线每扫到一个边就停下。将所有横边按照高度(\(y\) 值 )从小到大排序。这样,从下到上扫描,边权始终非负。

坐标范围太大,考虑离散化。


(图源 https://www.luogu.com.cn/article/9pcpxgzs ,侵删)

在这个例子中,我们维护线段 \(x_1\rightarrow x_2,x_2\rightarrow x_3,x_3\rightarrow x_4\)。对每段线段,我们需维护下列信息。

  • 该线段被覆盖了多少次。

  • 该线段内被整个图形所截的长度是多少。(如果完全覆盖为 \(len\)。)

显然,只要一条线段被覆盖,那它肯定被图形覆盖。因此,我们每次扫到边,查询根节点信息。统计贡献即可。

参考代码
namespace solution
{
    int L[N];
    int n;
    struct LINE
    {
        int l,r,h,tag;
        bool operator<(const LINE& a)const{
            return h < a.h;
        }
    }line[N*16];
    struct segtree
    {
        #define ls p<<1
        #define rs p<<1|1
        struct TREE
        {
            int l,r,sum,len;
        }tree[N*16];
        void pushup(int p)
        {
            int l = tree[p].l,r = tree[p].r; 
            if(tree[p].sum)
                tree[p].len = L[r+1] - L[l]; 
            else tree[p].len = tree[ls].len + tree[rs].len; 
        }
        void build(int l,int r,int p)
        {
            
            tree[p].l = l;
            tree[p].r = r;
            tree[p].len = tree[p].sum = 0;
            int mid = (l+r) >> 1;
            if(l == r) return;
            build(l,mid,ls);
            build(mid+1,r,rs);
            return;
        }
        void modify(int LL,int RR,int p,int d) // LL,RR 是真实的区间端点
        {
            int l = tree[p].l,r = tree[p].r; // l,r 是离散化完的
            if(LL >= L[r+1] || RR <= L[l]) return; 
            if(L[l] >= LL && L[r+1] <= RR)  
            {
                tree[p].sum += d;
                pushup(p);
                return;
            }
            modify(LL,RR,ls,d);
            modify(LL,RR,rs,d);
            pushup(p);
        }
    }seg;
    void solve()
    {
        cin>>n;
        for(int i=1;i<=n;i++)
        {
            int xa,ya,xb,yb;
            cin>>xa>>ya>>xb>>yb; 
            L[i*2-1] = xa,L[i*2] = xb; // 打上编号
            line[i*2-1] = {xa,xb,ya,1}; 
            line[i*2] = {xa,xb,yb,-1};
        }
        n <<= 1;
        sort(line+1,line+n+1);
        sort(L+1,L+n+1);
        int tot = unique(L+1,L+n+1) - L - 1; // 离散化 
      //  cerr<<tot<<endl;
        seg.build(1,tot-1,1);
        int ans = 0;
        for(int i=1;i<n;i++)
        {
            seg.modify(line[i].l,line[i].r,1,line[i].tag); // 每次加边后统计
            ans +=( seg.tree[1].len *(line[i+1].h-line[i].h));
        }
        cout<<ans<<endl;
        return;
    }
}

线段树维护前缀最大值

例题:P1498 楼房重建

小 A 的楼房外有一大片施工工地,工地上有 \(N\) 栋待建的楼房。每天,这片工地上的房子拆了又建、建了又拆。他经常无聊地看着窗外发呆,数自己能够看到多少栋房子。

为了简化问题,我们考虑这些事件发生在一个二维平面上。小 A 在平面上 \((0,0)\) 点的位置,第 \(i\) 栋楼房可以用一条连接 \((i,0)\)\((i,H_i)\) 的线段表示,其中 \(H_i\) 为第 \(i\) 栋楼房的高度。如果这栋楼房上任何一个高度大于 \(0\) 的点与 \((0,0)\) 的连线没有与之前的线段相交,那么这栋楼房就被认为是可见的。

施工队的建造总共进行了 \(M\) 天。初始时,所有楼房都还没有开始建造,它们的高度均为 \(0\)。在第 \(i\) 天,建筑队将会将横坐标为 \(X_i\) 的房屋的高度变为 \(Y_i\)(高度可以比原来大—修建,也可以比原来小—拆除,甚至可以保持不变—建筑队这天什么事也没做)。请你帮小 A 数数每天在建筑队完工之后,他能看到多少栋楼房?


\(s_i=H_i/i\),即楼房高度除以楼房坐标。即 \((0,0)\)\((i,H_i)\) 的斜率。且 \(s_0 = 0\)。则一栋楼房能被看见,当且仅当 \(\max\limits_{j=0}^{i-1}s_j<s_i\),即 \(s_i\)严格前缀最大值。

我们考虑用线段树维护这个东西。维护每个区间的最大值和 仅考虑这个区间 的答案,即我们先不考虑区间 \([1,l-1]\) 对区间 \([l,r]\) 的影响。

区间最大值非常简单。但是对于信息 2 直接相加是错误的,因为我们没有考虑 左子树对右子树的贡献

进一步的,直接继承左子树信息是正确的。对于右子树,它的答案会受到左子树的影响。我们考虑计算区间 \([l,r]\) 在考虑前缀最大值 \(pre\) 时的答案。

  • \(pre\) 小于左子树的最大值。此时 \(pre\) 对于右子树来说是无贡献的。直接递归进左子树。右子树的贡献直接用 “总的” 减去左子树的贡献即可。毕竟左子树是直接继承过来的,右子树不变。修改前 “总的” 减去修改前左子树的就等于右子树的。

  • \(pre\) 大于左子树的最大值。这太简单了。左子树不可能有贡献,直接进右子树即可。

这是一类特殊的线段树。

实现
int pushup(double t,int p,int l, int r)
{
    if(tree[p].maxx <= t) return 0;
    if(a[l] > t) return tree[p].len;
    if(l == r) return a[l] > t;
    int mid = (l+r) >> 1;
    if(tree[p*2].maxx <= t) return pushup(t,p*2+1,mid+1,r);
    else return pushup(t,p*2,l,mid)+tree[p].len-tree[p*2].len; 
}
void modify(int l,int r,int need_adr,int p,int nxt)
{
    if(l == r && l == need_adr)
    {
        tree[p].maxx = (double)nxt/need_adr;
        tree[p].len = 1;
        return;
    }
    int mid = (l+r) >> 1;
    if(need_adr <= mid) modify(l,mid,need_adr,p*2,nxt);
    else modify(mid+1,r,need_adr,p*2+1,nxt);
    tree[p].maxx = max(tree[p*2].maxx,tree[p*2+1].maxx);
    tree[p].len = tree[p*2].len + pushup(tree[p*2].maxx,p*2+1,mid+1,r);
}
signed main()
{
    // 省略
}

封装信息

若不带修,任何满足结合律且封闭的信息都可用线段树维护。我们通过一个经典例子来感受信息设计流程。

单点修改,区间查询最大子段和。

最大子段和可以分治求解,假设我们已知区间 \([l,mid],[mid+1,r]\) 的最大子段和,考虑求解区间 \([l,r]\) 的最大子段和。首先,区间 \([l,r]\) 的最大子段和可以由区间 \([l,mid],[mid+1,r]\) 最大子段和中较大的一个转移得到。但我们忽略了跨过中点的子段和。跨过中点的子段和一定是区间 \([l,mid]\) 的最大后缀和拼上区间 \([mid+1,r]\) 的最大前缀和。那么如何转移最大后缀和,最大前缀和?显然,区间 \([l,r]\) 的最大前缀和,最大后缀和可以由 \([l,mid],[mid+1,r]\) 直接转移得到,至于跨过中点的部分,最大前缀和一定形如 \([l,mid]\) 的和加上 \([mid+1,r]\) 的最大前缀和,最大后缀和同理。

综上,对于区间最大子段和问题,我们需要维护 区间最大前缀和,区间最大后缀和,区间最大子段和,区间和。我们来回顾一下信息设计流程。

首先从所求答案出发,考虑区间 \([l,r]\) 的答案如何从区间 \([l,mid],[mid+1,r]\) 合并。为此可能需要维护一些辅助信息,例如在上述问题中,需要维护最大前缀和,最大后缀和。再考虑辅助信息如何转移,一步步将信息封闭。

我们再来看一道例题。

给定一个长度为 \(n\) 的字符序列 \(a\),初始序列中全部是字母 L\(q\) 次修改,每次给定一个 \(x\),将 \(a_x\) 取反。
区间 \([l,r]\) 符合要求当且仅当不存在连续的 L 和连续的 R。每次修改后,求当前序列 \(a\) 最长的满足要求的序列。
\(n,q\le 2\times 10^5\)

数据范围 \(2\times 10^5\),经典数据结构题。考虑用线段树维护区间 \([l,r]\) 最长的满足要求的序列。

区间 \([l,r]\) 的最长满足要求的序列可以从 \([l,mid],[mid+1,r]\) 直接转移。我们还需考虑跨过中点的区间。需要维护 \([l,r]\) 的最长合法后缀,最长合法前缀。且 \([l,mid],[mid+1,r]\) 可拼接当且仅当 \(s_{mid}\ne s_{mid+1}\)

再考虑区间 \([l,r]\) 的最长合法前缀,最长合法后缀如何转移。首先可以从区间 \([l,mid],[mid+1,r]\) 直接转移。再考虑跨过中点的。区间 \([l,r]\) 的最长合法前缀可跨过中点当且仅当 \([l,mid]\) 整体合法且 \(s_{mid}\ne s_{mid+1}\)。最长合法后缀跨过中点的情形同理。至此,信息封闭。可用线段树维护。

线段树封装信息亦可和二维数点问题结合,例如。

求区间最大子段和 \([l,r]\),要求 \([l,r]\) 内的元素不可重复出现。若重复则不算贡献。

要求区间内元素不可重复贡献,不难想到 HH 的项链。HH 的项链这个题,我们把所有询问区间离线到一个数轴上,扫描右端点,并对于每个元素,只保留离着右端点最近的。在值域上维护树状数组即可解决问题。这个题我们同样扫描右端点,对于任意的 \(r\in[1,n]\),求以它为结尾的最大子段和。首先它作为右端点一定产生贡献,但若最大子段又出现它,贡献会变成 \(0\)。考虑抵消贡献。只需要将它前面出现的位置设为相反数即可。由于扫描到它的时候,它前面也设为相反数,我们需要将它设为 \(0\),防止抵消两遍。

本题在线段树基础上渗透了扫描线思想。

线段树二分

和权值线段树,静态全局第 \(k\) 小是类似的。

当答案具有单调性,且需要线段树维护时,往往可以将二分扔到线段树上,复杂度减少一个 \(\log\)。由 \(n\log^2n\) 变成 \(n\log n\)。看个例题。

有一个长度为 \(n\) 的序列 \(a\),每轮操作前会给定一个区间 \([l,r]\)\(d\) 表示将 \(a_l,a_{l+1}\dots a_r + d\)。注意每次操作有有效性,会对序列进行实质上的修改。有一个初始值 \(W\)\(W\) 分别减 \(a_1,a_2\dots a_n\) 然后所有 \(a_i\) 翻倍。又回来减 \(a_1\),以此类推。当 \(W\le 0\) 时一轮操作结束。序列 \(a\) 恢复到攻击前的状态。(区间修改后)对于每轮操作,求进行了多少次减法操作。

\(n\le 2\times 10^5\)

看到区间修改,且会对后续产生影响。考虑线段树。因为每执行 \(n\) 次减法操作后,序列里所有数会翻倍,能够完整减整个序列的次数应该不会很多,考虑先处理这个。可以二分,也可以直接枚举。

再处理剩下的。显然要二分。二分一个答案 \(mid\) 然后 check \([l,mid]\times round\),其中 \(round\) 即为翻了多少倍。复杂度 \(log^2 n\)。注意到线段树的儿子拆分方式就是二分,考虑直接在线段树上二分,每次 check 区间 \([l,mid]\) 的和,如果它乘 \(round\) 比剩下的 \(W\) 小,答案一定在右区间,令 \(W\) 减去区间 \([l,r]\) 的和乘 \(round\),递归进右区间查询即可。当到达叶子节点返回即可。复杂度少一个 \(\log\)。和权值线段树是类似的。

实现
int query(int l,int r,int p,__int128_t w,int round)
{
    if(l == r) return l;
    int mid = (l+r) >> 1;
    pushdown(p,r-l+1);
    __int128_t t = pow(2,round);
    if(tree[ls].val * (__int128_t)t >= w) return query(l,mid,ls,(__int128_t)(w),round);
    else return query(mid+1,r,rs,(__int128_t)(w-(tree[ls].val*t)),round);
} 
posted @ 2024-04-04 11:42  SXqwq  阅读(77)  评论(0编辑  收藏  举报