Loading

【笔记】线段树

动态维护区间信息的一种数据结构,又称区间树。

相较于树状数组,线段树能维护的信息更全面也更灵活,缺点是常数较大,在数据量 \(10^6\) 及以上时易被卡常。

普通线段树

思想

线段树将每一个长度不为 \(1\) 的区间分成两半进行维护,分别是大区间的左子树和右子树,通过不断合并子树来求得区间信息。每个叶子结点就是原序列的每一项。

任意区间都易表示,如在这棵线段树中,\([2,5]\) 区间的表示即为:

线段树有以下性质:

  • 树高为 \(O(\log n)\)
  • 任意区间都可以用线段树上不超过 \(O(\log n)\) 个结点表示。
  • 进行修改时,最多只修改 \(O(\log n)\) 个结点。
  • 设一个结点的编号为 \(i\),那么这个结点的左子树根节点编号为 \(2 \times i\),右子树根节点编号为 \(2 \times i+1\)

若原序列长度为 \(n\),则对应线段树有 \(n\) 个叶子节点。由于除了叶子节点的点均有 \(2\) 个子节点,我们设这部分点数量为 \(k\),则有等式:

\[n+k=2\times k+1 \]

右侧为子节点数量,加上根节点。

等式即 \(k=n-1\),则线段树一共有 \(2n-1\) 个节点。

但叶子节点的空间并不是连续存储的,我们要开满最后一层所有的位置。因此一棵维护 \(n\) 个元素序列的线段树需要的存储空间为 \(4n-2\)

建立

一个最基本的线段树节点要维护的信息有,其代表区间的左、右端点,及其区间信息。

struct Node{
    int l,r;
    int dat;
}tr[4*N];

建立线段树,我们从根节点开始递归,二分区间,直到叶子,记录每个节点的区间左、右端点。然后将叶子节点的信息设为原始序列的对应元素。

接着根据要维护的区间信息,从叶子节点开始向上合并区间,进而求得每个节点的初始信息。以下均以区间和为例。

void pushup(int p){
    tr[p].dat=tr[2*p].dat+tr[2*p+1].dat;
}
void build(int p,int l,int r){
    tr[p].l=l,tr[p].r=r;
    if(l==r){//叶子
        tr[p].dat=a[l];
        return ;
    }
    int mid=(l+r)/2;
    build(2*p,l,mid);
    build(2*p+1,mid+1,r);
    pushup(p);//合并区间信息
}

由此即可建立一棵线段树。

更新(单点)

从根节点出发向下递归,每次看目标节点在左子区间还是右子区间,决定向何处递归。到达叶子后修改此点,然后向上合并区间。

void update(int p,int x,int k){
    if(tr[p].l==tr[p].r){
        tr[p].dat+=k;//加 k
        return ;
    }
    int mid=(tr[p].l+tr[p].r)/2;
    if(x<=mid) update(2*p,x,k);//向左子区间递归
    else update(2*p+1,x,k);//向右子区间递归
    pushup(p);
}

查询(单点)

和单点更新一样的方法找到目标点,然后返回。

void query(int p,int x){
    if(tr[p].l==tr[p].r&&tr[p].l==x){
        return tr[p].dat;
    }
    int mid=(tr[p].l+tr[p].r)/2;
    if(x<=mid) return query(2*p,x);
    else return query(2*p+1,x);
}

查询(区间)

当然,如果只查询单点,那么我们维护的区间信息就完全没用了。

当一个区间完全被包含在查询区间时,它将产生贡献。否则,则应分割此区间,向下找到完全被包含的子区间。

int query(int p,int l,int r){
    if(l<=tr[p].l&&tr[p].r<=r){//完全被包含
        return tr[p].dat;
    }
    int mid=(tr[p].l+tr[p].r)/2,res=0;
    if(l<=mid) res+=query(2*p,l,r);//左子区间有查询
    if(r>mid) res+=query(2*p+1,l,r);//右子区间有查询
    return res;
}

更新(区间)

如果只修改单点,那多数情况下就可以用树状数组代替。线段树支持高效的区间修改,比树状数组泛用性更高。

区间即为多个单点,但如果分别修改区间内每个单点,时间复杂度反而不如暴力维护。

这时候考虑优化。区间查询时我们只递归到完全被包含的区间,而不去触及每一个叶子节点。修改时也需要这么做。因为我们无法及时地修改每个点和区间的信息,我们就可以把它们应被修改的信息暂存在上方的区间中,以后用到时再去修改。

我们为每个节点多维护一个懒标记,代表此区间的所有子区间还有什么修改等待完成。在用到这些子区间时,我们再把懒标记往下传,去修改这些子区间。这样就能做到正确的时间复杂度。

此处修改为区间加。

struct Node{
    int l,r;
    int dat,laz;
}tr[4*N];
void pushdown(int p){
    if(tr[p].laz){
        tr[2*p].dat+=(tr[2*p].r-tr[2*p].l+1)*tr[p].laz;//修改子区间信息,并把懒标记向下传递,让其能修改自身的子区间
        tr[2*p].laz+=tr[p].laz;
        tr[2*p+1].dat+=(tr[2*p+1].r-tr[2*p+1].l+1)*tr[p].laz;
        tr[2*p+1].laz+=tr[p].laz;
        tr[p].laz=0;//修改完成,懒标记清空
    }
}
void update(int p,int l,int r,int k){
    if(l<=tr[p].l&&tr[p].r<=r){
        tr[p].dat+=(tr[p].r-tr[p].l+1)*k;//修改区间信息
        tr[p].laz+=k;
        //此处用不到再往下的子区间,所以无需下传
        return ;
    }
    pushdown(p);//子区间要被用到,下传
    int mid=(tr[p].l+tr[p].r)/2,res=0;
    if(l<=mid) update(2*p,l,r);
    if(r>mid) update(2*p+1,l,r);
    pushup(p);//修改子节点后合并区间
}
int query(int p,int l,int r){
    if(l<=tr[p].l&&tr[p].r<=r){
        return tr[p].dat;
    }
    int res=0,mid=(tr[p].l+tr[p].r)/2;
    pushdown(p);//查询子区间,要先下传更新
    if(l<=mid) res+=query(2*p,l,r);
    if(r>mid) res+=query(2*p+1,l,r);
    return res;
}

封装

我们一般将线段树用一个结构体封装,便于阅读与调试。

struct Node{
    int l,r;
    int dat,laz;
};
struct Segtr{
    Node tr[4*N];
    void pushup(int p){
        tr[p].dat=tr[2*p].dat+tr[2*p+1].dat;
	}
    void pushdown(int p){
        if(tr[p].laz){
            tr[2*p].dat+=(tr[2*p].r-tr[2*p].l+1)*tr[p].laz;
            tr[2*p].laz+=tr[p].laz;
            tr[2*p+1].dat+=(tr[2*p+1].r-tr[2*p+1].l+1)*tr[p].laz;
            tr[2*p+1].laz+=tr[p].laz;
            tr[p].laz=0;
        }
    }
    void build(int p,int l,int r){
        tr[p].l=l,tr[p].r=r;
        if(l==r){
            tr[p].dat=a[l];
            return ;
        }
        int mid=(l+r)/2;
        build(2*p,l,mid);
        build(2*p+1,mid+1,r);
        pushup(p);
    }
    void update(int p,int l,int r,int k){
        if(l<=tr[p].l&&tr[p].r<=r){
            tr[p].dat+=(tr[p].r-tr[p].l+1)*k;
            tr[p].laz+=k;
            return ; 
        }
        pushdown(p);
        int mid=(tr[p].l+tr[p].r)/2;
        if(l<=mid) update(2*p,l,r,k);
        if(r>mid) update(2*p+1,l,r,k);
        pushup(p);
    }
    int query(int p,int l,int r){
        if(l<=tr[p].l&&tr[p].r<=r){
        return tr[p].dat;
        }
        int res=0,mid=(tr[p].l+tr[p].r)/2;
        pushdown(p);
        if(l<=mid) res+=query(2*p,l,r);
        if(r>mid) res+=query(2*p+1,l,r);
        return res;
    }
}seg;

标记永久化

在很特定的情况懒标记是不能下传的,因此我们有了标记永久化。顾名思义,即标记永远在一开始的节点上不下传。

这时候每个区间维护的信息就不是真实值了,真实值是从根到节点路径上的标记之和加上自己的信息。这时候就不能 pushup,在查询时再算上即可。

标记永久化常数更优但比较局限,仅在特定情况下可用。

void update(int p,int l,int r,int k){
    tr[p].sum+=(min(r,tr[p].r)-max(tr[p].l,l)+1)*k;
    if(l<=tr[p].l&&tr[p].r<=r){
        tr[p].laz+=k;
        return ;
    }
    int mid=(tr[p].l+tr[p].r)>>1;
    if(l<=mid) update(2*p,l,r,k);
    if(r>mid) update(2*p+1,l,r,k);
}
int query(int p,int l,int r,int L){
    if(l<=tr[p].l&&tr[p].r<=r) return (tr[p].r-tr[p].l+1)*L+tr[p].sum;
    L+=tr[p].laz;
    int mid=(tr[p].l+tr[p].r)>>1,res=0;
    if(l<=mid) res+=query(2*p,l,r,L);
    if(r>mid) res+=query(2*p+1,l,r,L);
    return res;
}

线段树二分

对于一些有单调性的查询,比如说 \(x\) 左侧第一个 \(\le a_x\) 的元素在哪里,或者是正序列前缀和第一个 \(\le s_x\) 的地方在哪等等,可以用二分求解,check 套上线段树查询。但是这样是 \(O(\log^2 n)\) 的,比较慢,有没有更好的做法?

注意到我们在线段树上查询时也是把区间二分,我们考虑在这一步就完成二分时的工作。

如果是某某左侧第一个,则要优先在左侧区间找右子区间,如果合法可以找到就直接返回,如果不合法就再去找左子区间。右侧第一个同理。递归到叶子结束。

前缀和这类就在递归右子区间给查询减掉左子区间和,这样就能单独在右子区间内查询。

于是就用 \(O(\log n)\) 的复杂度完成了这样的查询。

以一个找 \(<a_x\) 举例。

int binsch(int p,int l,int r,int x,int op){
    if(tr[p].l>r||tr[p].r<l||tr[p].mina>x) return -1;
    if(tr[p].l==tr[p].r) return tr[p].l;
    pushdown(p);
    int res=-1;
    if(op==1){
        if(tr[2*p].mina<=x) res=binsch(2*p,l,r,x,op);
        if(res!=-1) return res;
        else return binsch(2*p+1,l,r,x,op);
    }else{
        if(tr[2*p+1].mina<=x) res=binsch(2*p+1,l,r,x,op);
        if(res!=-1) return res;
        else return binsch(2*p,l,r,x,op);
    }
}

简单变体线段树

区间加、乘线段树

Luogu P3373【模板】线段树 2

涉及区间加、区间乘两种操作,我们自然是要维护两个懒标记处理。此时要着重考虑两种更新操作叠加的运算顺序。

对于区间加,不会影响此前的任何操作,直接加即可。但对于区间乘,注意到此前所有的区间加操作相应的都会乘上这次操作,也就是说要同步修改区间加的懒标记。

然后在懒标记下传的时候,因为区间加懒标记已经在更新时同步乘过了,就需要先传乘再传加,这样加就不会被重复乘。

最后注意乘法懒标记初始为 \(1\)

struct Node{
    int l,r;
    int dat,add,mul;
}tr[4*N];
void pushdown(int p){
    if(tr[p].mul!=1){
        tr[2*p].add=(tr[2*p].add*tr[p].mul)%mod;
        tr[2*p].mul=(tr[2*p].mul*tr[p].mul)%mod;
        tr[2*p].dat=(tr[2*p].dat*tr[p].mul)%mod;
        tr[2*p+1].add=(tr[2*p+1].add*tr[p].mul)%mod;
        tr[2*p+1].mul=(tr[2*p+1].mul*tr[p].mul)%mod;
        tr[2*p+1].dat=(tr[2*p+1].dat*tr[p].mul)%mod;
        tr[p].mul=1;
    }
    if(tr[p].add){
        tr[2*p].dat=(tr[2*p].dat+(tr[2*p].r-tr[2*p].l+1)*tr[p].add%mod)%mod;
        tr[2*p].add=(tr[2*p].add+tr[p].add)%mod;
        tr[2*p+1].dat=(tr[2*p+1].dat+(tr[2*p+1].r-tr[2*p+1].l+1)*tr[p].add%mod)%mod;
        tr[2*p+1].add=(tr[2*p+1].add+tr[p].add)%mod;
        tr[p].add=0;
        }
}
void updateA(int p,int l,int r,int op,int k){
    if(l<=tr[p].l&&tr[p].r<=r){
        if(op==1){
            tr[p].dat=(tr[p].dat+(tr[p].r-tr[p].l+1)*k%mod)%mod;
            tr[p].add=(tr[p].add+k)%mod;
        }else{
            tr[p].dat=(tr[p].dat*k)%mod;
            tr[p].add=(tr[p].add*k)%mod;
            tr[p].mul=(tr[p].mul*k)%mod;
        }
        return ;
    }
    pushdown(p);
    int mid=(tr[p].l+tr[p].r)/2;
    if(l<=mid) updateA(2*p,l,r,op,k);
    if(r>mid) updateA(2*p+1,l,r,op,k);
    pushup(p);
}

区间异或线段树

Luogu P2574 XOR的艺术

异或则是把区间内 \(1\) 变为 \(0\)\(0\) 变为 \(1\)\(0\)\(1\) 总个数是区间大小。修改直接拿区间大小 \(-\) 区间和就可以了,懒标记改成异或。

Codeforces 242E XOR on Segment

考虑拆位,然后和上题一致。

区间加、推平线段树

Luogu P1253 扶苏的问题

区间推平是将区间赋为一个数,此前的加懒标记都将不算数。同样在下传时要先推平再加,避免重复推平。

注意推平懒标记的初始值应当为 INF。

void pushdown(int p){
    if(tr[p].pus!=INF){
        tr[2*p].dat=tr[p].pus;
        tr[2*p].add=0;
        tr[2*p].pus=tr[p].pus;
        tr[2*p+1].dat=tr[p].pus;
        tr[2*p+1].add=0;
        tr[2*p+1].pus=tr[p].pus;
        tr[p].pus=INF; 
    }
    if(tr[p].add!=0){
        tr[2*p].dat+=tr[p].add;
        tr[2*p].add+=tr[p].add;
        tr[2*p+1].dat+=tr[p].add;
        tr[2*p+1].add+=tr[p].add;
        tr[p].add=0;
    } 
}
void update(int p,int l,int r,int op,int k){
    if(l<=tr[p].l&&tr[p].r<=r){
        if(op==1){
            tr[p].dat=k;
            tr[p].add=0;
            tr[p].pus=k;
        }else{
            tr[p].dat+=k;
            tr[p].add+=k;
        }
        return ;
    }
    pushdown(p);
    int mid=(tr[p].l+tr[p].r)/2;
    if(l<=mid) update(2*p,l,r,op,k);
    if(r>mid) update(2*p+1,l,r,op,k);
    pushup(p);
}

根号线段树

Luogu P4145 上帝造题的七分钟 2 / 花神游历各国

因为我们无法实现维护区间和区间开根的数据结构,因此考虑发掘性质。

注意到所有的数均不超过 \(2^{64}\),也就是说最多开根 \(7\) 次就会变成 \(1\)

于是在区间修改向下递归时,如果区间 \(\max=1\) 则不去修改,剩下的递归到叶子暴力修改即可。

void update(int p,int l,int r){
    if(tr[p].l==tr[p].r){
    tr[p].sum=sqrt(tr[p].sum);
        tr[p].mxa=tr[p].sum;
        return ; 
    }
    int mid=(tr[p].l+tr[p].r)/2;
    if(l<=mid&&tr[2*p].mxa>1) update(2*p,l,r);
    if(r>mid&&tr[2*p+1].mxa>1) update(2*p+1,l,r);
    pushup(p);
}

权值线段树

权值树为把值离散化后,在值域上开的线段树。

Luogu P3369 【模板】普通平衡树

区间信息表示值在区间内的元素数量。

插入删除直接在对应位置 \(+1-1\),查询多少个数比 \(x\) 小即查询前缀和。找排名、前驱、后继均为线段树二分,找到第一个前缀和 \(>=x\) 的位置、最后一个 \(<x\) 的位置、第一个 \(>x\) 的位置。

int getrank(int p,int x){
    if(tr[p].l==tr[p].r) return tr[p].l;
    if(x<=tr[2*p].dat) return getrank(2*p,x);
    else return getrank(2*p+1,x-tr[2*p].dat);
}
int findpre(int p,int x){
    if(tr[p].l==tr[p].r){
        if(tr[p].l<x&&tr[p].dat) return tr[p].l;
        else return 0;
    }
    int res=0,mid=(tr[p].l+tr[p].r)/2;
    if(x>mid+1&&tr[2*p+1].dat) res=findpre(2*p+1,x);
    if(res) return res;
    else return findpre(2*p,x);
}
int findnxt(int p,int x){
    if(tr[p].l==tr[p].r){
        if(tr[p].l>x&&tr[p].dat) return tr[p].l;
        else return 0;
    }
    int res=0,mid=(tr[p].l+tr[p].r)/2;
    if(x<mid&&tr[2*p].dat) res=findnxt(2*p,x);
    if(res) return res;
    else return findnxt(2*p+1,x);
}
for(int i=1;i<=n;i++){
    int t=lower_bound(d+1,d+1+lend,q[i].x)-d;
    if(q[i].op==1) seg.update(1,t,1);
    else if(q[i].op==2) seg.update(1,t,-1);
    else if(q[i].op==3) cout<<seg.query(1,1,t-1)+1<<'\n';
    else if(q[i].op==4) cout<<d[seg.getrank(1,q[i].x)]<<'\n';
    else if(q[i].op==5) cout<<d[seg.findpre(1,t)]<<'\n';
    else cout<<d[seg.findnxt(1,t)]<<'\n';
}

动态开点线段树

我们在普通线段树中,预先使用一个 build 函数将整个序列全部纳入,按照 \(2\times p\)\(2\times p+1\) 的规则开点建树。但是对于区间长度特别大的情况,因为只有查询和更新到的点是有用的,我们就可以对线段树进行动态开点。

Luogu P13825【模板】线段树 1.5

struct Node{
    int ls,rs;
    int dat,laz;
};
struct Segtr{
    Node tr[4*N];
    void pushup(int p){
        tr[p].dat=tr[tr[p].ls].dat+tr[tr[p].rs].dat;
    }
    void pushdown(int p,int l,int r,int mid){
        if(tr[p].laz){
            if(!tr[p].ls) tr[p].ls=++idx;
            if(!tr[p].rs) tr[p].rs=++idx;
            tr[tr[p].ls].dat+=(mid-l+1)*tr[p].laz;
            tr[tr[p].ls].laz+=tr[p].laz;
            tr[tr[p].rs].dat+=(r-mid)*tr[p].laz;
            tr[tr[p].rs].laz+=tr[p].laz;
            tr[p].laz=0;
        }
    }
    void update(int &p,int l,int r,int ql,int qr,int k){
        if(!p) p=++idx;
        if(ql<=l&&r<=qr){
            tr[p].dat+=(r-l+1)*k;
            tr[p].laz+=k;
            return ; 
        }
        int mid=(l+r)/2;
        pushdown(p,l,r,mid);
        if(ql<=mid) update(tr[p].ls,l,mid,ql,qr,k);
        if(qr>mid) update(tr[p].rs,mid+1,r,ql,qr,k);
        pushup(p);
    }
    int query(int p,int l,int r,int ql,int qr){
        if(ql<=l&&r<=qr) return tr[p].dat;
        int val=0,mid=(l+r)/2;
        pushdown(p,l,r,mid);
        if(ql<=mid) val+=query(tr[p].ls,l,mid,ql,qr);
        if(qr>mid) val+=query(tr[p].rs,mid+1,r,ql,qr);
        return val;
    }
}seg;

在修改过程中对 \(p\) 取地址,如果不存在就新建节点。由于不能建树,节点 \(l,r\) 要在递归中一并维护。

这个技巧在可持久化线段树中也一并会用到。

也可以标记永久化。

练习题

Luogu P1438 无聊的数列

注意到加一个等差数列相当于给差分数组区间加,上线段树维护。

Luogu P4513 小白逛公园 / SPOJ GSS3 - Can you answer these queries III

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

考虑两个区间怎么合并。有可能是左区间或者右区间的最大子段和,也有可能是两个区间拼成的。

拼成的子段一定要连续,所以是左区间的最大后缀和 \(+\) 右区间的最大前缀和。

因此维护最大前缀和、最大后缀和、最大子段和。新区间的最大前缀和有可能是左区间和 \(+\) 右区间的最大前缀和,后缀和同理,所以还要维护区间和。

查询时以类似操作合并区间,可以写成 “全在左区间 - 全在右区间 - 跨过左右区间” 的分讨形式,避免初始化问题。

struct SegNode{
    int l,r;
    int pre,suf,sum,dat;
};
struct Segtr{
    SegNode tr[4*N];
    void pushup(int p){
        tr[p].sum=tr[2*p].sum+tr[2*p+1].sum;
        tr[p].pre=max(tr[2*p].pre,tr[2*p].sum+tr[2*p+1].pre);
        tr[p].suf=max(tr[2*p+1].suf,tr[2*p+1].sum+tr[2*p].suf);
        tr[p].dat=max(tr[2*p].suf+tr[2*p+1].pre,max(tr[2*p].dat,tr[2*p+1].dat));
    } 
    void build(int p,int l,int r){
        tr[p].l=l,tr[p].r=r;
        if(l==r){
            tr[p].sum=a[l];
            tr[p].pre=tr[p].suf=tr[p].dat=max(0,a[l]);
            return ;
        }
        int mid=(l+r)>>1;
        build(2*p,l,mid);
        build(2*p+1,mid+1,r);
        pushup(p); 
    }
    SegNode query(int p,int l,int r){
        if(l<=tr[p].l&&tr[p].r<=r) return tr[p];
        int mid=(tr[p].l+tr[p].r)>>1;
        if(r<=mid) return query(2*p,l,r);
        else if(l>mid) return query(2*p+1,l,r);
        else{
            SegNode res,ls,rs;
            ls=query(2*p,l,r);
            rs=query(2*p+1,l,r);
            res.sum=ls.sum+rs.sum;
            res.pre=max(ls.pre,ls.sum+rs.pre);
            res.suf=max(rs.suf,rs.sum+ls.suf);
            res.dat=max(ls.suf+rs.pre,max(ls.dat,rs.dat));
            return res;
        }
    }
}seg;

Luogu P2572 [SCOI2010] 序列操作

和子段和很像,也是左区间后 \(+\) 右区间前合并。维护前缀 \(0/1\)、后缀 \(0/1\)、整段 \(0/1\)、区间和,还有懒标记。

比较难写。

Luogu P1471 方差

区间方差。

方差有个合并公式在高中数学必修二中介绍,设 \(\overline{x_1}\)\(\overline{x_2}\)\(\overline{x}\) 表示两个区间的平均数和大区间平均数,\(s_1^2\)\(s_2^2\)\(s^2\) 表示两个区间方差和大区间方差,\(n_1\)\(n_2\)\(n\) 表示两个区间元素数量和大区间元素数量,则有:

\[s^2=\frac{n_1\times(s_1^2+(\overline{x_1}-\overline{x})^2)+n_2\times(s_2^2+(\overline{x_2}-\overline{x})^2)}{n} \]

上线段树维护平均数和方差即可。

如果没有区间修改(Luogu P5142 区间方差)也可以展开方差计算公式然后维护区间和、区间平方和计算。

posted @ 2026-08-24 22:36  Seqfrel  阅读(3)  评论(0)    收藏  举报