Files
python/TangDou/AcWing/SegmentTree/线段树专题.md
HuangHai 1f397eca87 'commit'
2025-08-30 18:35:01 +08:00

18 KiB
Raw Blame History

线段树专题

一、线段树基础

1. 线段树简介

线段树是算法竞赛中常用的用来维护区间信息的数据结构。 线段树可以在很小的时间复杂度内实现 单点修改、区间修改、区间查询(即区间求和,求区间 max ,求区间 min ,区间 gcd 等)操作。

但是,线段树所维护的信息,需要满足 区间加法。

区间加法

如果一个区间 $[l,r]$(线段树中一个点表示一个区间)满足区间加法的意思是一个区间 [l,r] 的线段树维护的信息(即区间最大值,区间最小值,区间和,区间 gcd 等),可以由两个区间 [l,mid] 和 $[mid+1,r]$合并而来。

2. 线段树的基本概念

线段树,是一种基于分治思想的二叉搜索树。它支持的所有操作都可以 O(logn) 的时间复杂度完成。

线段树的 基本特点:

  • ① 线段树的每一个节点表示一个区间
  • ② 线段树有唯一根,这个根表示的所有会被线段树统计的总区间,一般情况下,根表示的区间就是 [1,n]
  • ③ 线段树的叶子节点表示的区间为 [x,x] ,且长度为 1
  • ④ 线段树中如果一个节点表示的区间是 [l,r] ,且这个点不为叶子节点,即 $l≠r$,那么这个节点的左子树的根表示的区间就是 [l,mid] 这个节点的右子树的根表示的区间就是 $[mid+1,r]$,其中 $\large mid=⌊\frac{l+r}{2}⌋$。

3.线段树的存储方式

直接采用堆存储方式,即一颗线段树的根的编号是 1 ,设一个不为根的节点编号 x ,则这个点的父节点是 ⌊\frac{x}{2}⌋ ,他的两个子节点的编号分别是 2x 和 2x+1 。为了线段树的节点不超过存储范围,一般线段树都要开 4n 的空间,即区间总长度的 4 倍。

因为一颗线段树最多是一颗满二叉树,而满二叉树的最后一层是n 个点,前面的点数是 n−1 ,所以一共要 2n−1 的空间,但由于线段树有可能最后一层节点还有子节点,比如说 n=10 的时候,如图:

这里就是一个例子,最后一层是多出来的,而最后一层节点最多 2n 个节点,最坏情况下就最右边两个节点,最右下角的一个节点的编号是 2n−1+2n=4n−1 ,所以线段树一般开 4n 。

4.建立线段树

思路 我们递归遍历初始区间,把遍历到的所有节点表示的区间记录下来,如果这个节点不是叶子节点(即区间长度大于1),那么就分别遍历左子树和右子树,否则就是叶子节点,不仅要把表示的区间记录下来,还要把线段树维护的信息也记录下来,维护的信息在叶子节点上基本上就是这个数本身。 时间复杂度 O(logn)

代码

struct Node {
    int l,r;
    LL sum;   //这里可以维护任何满足区间加法的信息,这里就用区间求和了
}tr[N << 2];   //要开四倍空间

void pushup (int u) {  //这里只有区间和,区间和就是由一个点的左右子节点的和相加
    tr[u].sum = tr[u << 1].sum + tr[u << 1 | 1].sum;
}

void build (int u,int l,int r) {  //当前正在下标为u的点,这个点表示的区间是[l,r]
    if (l == r) {
        tr[u] = {l,r,a[l]};//叶子,初始值
        return ;
    }

    tr[u] = {l,r};  //记得存储当前点表示的区间,否则你会调上一整天

    int mid = (l + r) >> 1;
    build (u << 1,l,mid),build (u << 1 | 1,mid + 1,r);  //u << 1就是u * 2,u << 1 | 1就是u * 2 + 1
    pushup (u);  //向上推送,意思是把某一个节点的信息由他的子节点算出来
}

5.单点修改

思路 我们通过二分查找的形式找到要修改的点,然后把找的过程上的链都修改一下。 时间复杂度 O(logn)

代码

void change(int u,int x,int v) {   //当前这个点是下标为u的点,要把第x个数修改成d
    if (tr[u].l == tr[u].r) {
        tr[u].sum = v;  //叶子修改
        return ;
    }
    int mid = tr[u].l + tr[u].r >> 1;
    if (x <= mid) change (u << 1 , x , v);  //如果在左边就递归修改左半边区间
    else change (u << 1 | 1 , x , v);       //如果在右边就递归修改右半边区间
    pushup (u)   //向上推送信息
}

6.区间修改 【线段树的精髓——$Lazy$标记】

(1). $Lazy$标记是啥?有啥用?

答: $Lazy$标记一般用在线段树的区间修改中使用了它,能将你原本需要 $O(n)$的区间修改,变得快非常多~ 举个例子秒懂:

  • 省里发通知,退休人员工资普遍上涨$200$元,比即日起生效。
  • 市里收到通知后,领导说:不行,咱们地方财政没有钱,先计着吧,不能发,记住哪些人能多发$200$元就行了。这样多快,不用花钱,不用费事~,这叫记录懒标记,懒政嘛~
  • 第二个月了,领导说:把上次那些没发$200$元的所有人,都修改为$400$。这叫懒标记叠加。
  • 第$N$个月,省里调查组到市里调研,去$XX$厂,会询问大家工资上涨的执行情况,这时候,市领导马上让执行:把$XX$厂的欠款马上发了,别让省里调查组调查出毛病来,其它厂先记着,还是不发。这叫懒标记下放。

(2). 如何理解$Lazy$标记?怎么写?

我们再用一次之前的图

此时,假设初始数据为:1 2 3 4 5 6 7 8 9 10

我们需要将3~7号数据修改为4(当然,加上的操作也是差不多的)

正常思维我们会顺着节点编号$dfs$,找到3~7号数据的所有叶子节点以及路径上的父亲节点,再一一修改

慢死了对不对,最差条件下速度为 O(n*q) ($q$是询问次数)

现在我们使用$Lazy$标记

从(1,10)节点出发,开始搜索;当搜索到(1,5)节点时,发现3 ~ $7$这个数据范围未完全覆盖此区间(说人话就是,$1$~$5$区间里,$1,2$号数据是不需要修改的),然后接着搜(1,3),(1,2).......

恭喜你搜索到了(3,3)节点!此时你发现$3$号数据需要修改,而(3,3)节点是一个叶节点(叶节点的$l$与$r$相等),那么,直接修改(3,3)节点的$max$值为4

接着搜,当你搜到$(4,5)$节点时,你会发现$4$号数据与$5$号数据都需要修改,如果继续搜索下去,去修改(4,4)节点与(5,5)节点,那就体现不出$Lazy$标记的 懒 之处了

我们选择不再往下搜索,而是将(4,5)节点的$Lazy$标记修改为$4$,然后直接回溯,这样就表示(4,5)节点覆盖的所有数据(就是$4$~$5$号数据啦)都被修改为了4

同理tr[12].lazy=4 ( 也就是(6,7)节点的$Lazy$标记为4)

来看看代码

// 区间[l,r]统一增加v
void modify(int u, int l, int r, int v) {
    if (l <= tr[u].l && r >= tr[u].r) {
        tr[u].tag += v; // 懒标记 
        tr[u].mx += v;  // 区间最大值也需要加上v
        return;
    }
    // 下放懒标记
    pushdown(u);
    // 分裂
    int mid = (tr[u].l + tr[u].r) >> 1;
    if (l <= mid) modify(u << 1, l, r, v);    // 与左区间有交集
    if (r > mid) modify(u << 1 | 1, l, r, v); // 与右区间有交集
    pushup(u);                                // 将结果的变更更新到祖先节点
}

第一个$if$的判断依据:若是需要修改的区间能完全覆盖当前区间,则直接修改当前区间的$Lazy$标记并回溯

为什么一定要在有新的$lazy$标记修改前进入$pushdown$呢?

若一个区间(比如说区间(1,5))的$Lazy$标记已经被修改为$3$,现在我们需要修改区间(1,3)为$4$,假如我们直接修改区间(1,3)的$Lazy$标记为$4$,那么,我们在询问$3$号数据时,会先访问到(1,5)的$Lazy$标记,而这个$Lazy$标记会挡住$(1,3)$上的Lazy

解释:当我们搜到(1,5)节点时,如果它的$Lazy$标记为$3$,那么我们就默认$1$~$5$号节点都被修改为了$3$,而不会继续搜索查看下一层的子节点,这也是为什么$Lazy$标记省时间的原因。

我们再看一次之前的图(可以对着比划嗷)

那么我们怎么解决这个问题呢?其实很简单的一个下放操作就可以解决啦

下放操作

// 父节点向子节点传递懒标记
void pushdown(int u) {
    auto &root = tr[u], &ls = tr[u << 1], &rs = tr[u << 1 | 1];
    if (root.tag) { // 如果存在懒标记
        // tag传递到子段,子段的sum和需要按 区间长度*root.tag 进行增加
        ls.tag += root.tag, ls.sum += (LL)(ls.r - ls.l + 1) * root.tag;
        rs.tag += root.tag, rs.sum += (LL)(rs.r - rs.l + 1) * root.tag;
        // 清除懒标记
        root.tag = 0;
    }
}

// 以u为根,在区间[l,r]之间全都增加v
void modify(int u, int l, int r, int v) {
    if (tr[u].l >= l && tr[u].r <= r) {               // 如果区间完整命中
        tr[u].sum += (LL)(tr[u].r - tr[u].l + 1) * v; // 总和增加 = 区间长度*v
        tr[u].tag += v;                               // 懒标记+v
        return;
    }

    pushdown(u); // 如果自己身上有旧的tag数值,在递归前需要将原tag值pushdown到子孙节点去
    if (tr[ls].r >= l) modify(ls, l, r, tag);
    if (tr[rs].l <= r) modify(rs, l, r, tag);
    pushup(u);
}

代码在递归时多了一个$pushdown$操作~

仔细看这个$pushdown$,发现它其实就是把自己的$lazy$传递给了自己的两个子节点,然后将自己的$lazy$标记清零,预备下一次的修改。

我们在进行新一轮修改的时候,使用$modify$函数,一直搜索到最底层的全覆盖节点。

搜索下一层之前,如果此次修改的节点$lazy$标记为$0$,也就是它以前没有被修改过,那就不管它,继续搜索

如果$lazy$标记不为零,也就是它以前被打上过标记,那么为了避免之前说过的无法更新的情况,需要将$lazy$标记清零(以便于下次询问时不会停留在上层标记不为零的非最新标记处)

但是直接清零标记会导致区间内一部分的小区间标记丢失,于是我们采用下放操作,将父亲节点的$lazy$标记传递给两个子节点,防止信息丢失

在递归边界处,如果区间已经被完全覆盖,就不用管$lazy$标记是否会影响下层(本来就是表示的该区间内所有数据都是这个标记的意思嘛),直接修改$lazy$标记

最后,多找题写写才能理解线段树(包括各种结构,算法)的灵魂呀,去洛谷搜索 线段树 tag 找题目做一做吧

7.区间查询

思路 区间查询类似区间修改,只不过变为查询了。

代码

int mid = (tr[u].l + tr[u].r) >> 1;
int sum = 0;
if (l <= mid) sum += query(ls, l, r);     // 左半边有被查询到的数据,就递归左半边
if (r >= mid + 1) sum += query(rs, l, r); // 右半边有被查询到的数据,就递归右半边
return sum;

或者

if (tr[ls].r < l) return query(rs, l, r);
if (tr[rs].l > r) return query(ls, l, r);
return query(ls, l, r) + query(rs, l, r);

时间复杂度 O(logn)

二、题单

AcWing 1275. 最大数 【单点修改,区间查询】

AcWing 245. 你能回答这些问题吗 【单点修改,区间最大子段和,前缀最大值+后缀最大值+区间和+整体最大值】

AcWing 246. 区间最大公约数 [单点修改,区间查询,差分,更相减损数,推式子,记录$sum$和,记录最大公约数,最大公约数具有传递性]

GSS1 - Can you answer these queries $I$ 【就是上面的AcWing $245$】

GSS3 - Can you answer these queries $III$ 【区间最大子段和+单点修改,$GSS1$的小修小改版】

GSS5 - Can you answer these queries $V$ 【有范围限制的区间最大子段和】

POJ 3264 Balanced $Lineup$ 【线段树+单点修改+区间查询最大值、最小值】

HDU 3333 Turing $Tree$ 【图灵树,线段树(树状数组)+单点修改+离线操作+边构建边计算+数字去重求区间和】

POJ 2828 Buy $Tickets$ 【线段树+倒序枚举+单点修改+魔改版本+怪异计数区间和】

AcWing 243. 一个简单的整数问题2 【区间修改+懒标记+区间查询】

HDU 1698 Just a $Hook$ 【区间修改统一值+区间查询】

HDU 3577 Fast $Arrangement$

【线段树+区间修改+维护最大值】

Luogu $P2894$(区间连续一段空的长度) 【线段树+区间修改+懒标记+维护连续空白房间数】

① 整体最大、左最大、右最大+组装出区间,更新父亲的统计信息 ② 当父亲接到修改标识,准备$pushdown$时,除了下传懒标记外,也同步更新了左右儿子的统计信息,以保证未将懒标记下传到底时,直接获取的子树统计信息也是正确的

P1253 扶苏的问题 【线段树+两个懒标记(注意标记的顺序和叠加),柯朵莉树】

P3740 [HAOI2014] 贴海报 【区间覆盖问题,离散化, 插入r[i]+1,倒序枚举】

P1204 [USACO1.2] 挤牛奶Milking $Cows$ 【贪心、线段重合、求最大重叠段长度和最大间距、柯朵莉树】

P4979 矿洞:坍塌 【线段树,区间数字是否一致,用的是数字HASH,在数字数量少的情况下是可行的,柯朵莉树需要吸氧】

P2787 语文$1$($chin1$)- 理理思维 【$26$棵线段树,利用桶来排序,懒标记$TAG=1$表示区间整体修改为$1$,$TAG=2$表示区间整体修改为$0$】

SP13015 Counting $Primes$ 【线段树,懒标记,模板题,欧拉筛】

CF915E Physical Education $Lessons$ 【线段树解法,柯朵莉树解法】

P4344 [$SHOI2015$]脑洞治疗仪 【线段树维护区间最大连续子序列和,柯朵莉树解法】

P2253 好一个一中腰鼓! 【线段树,区间内最长不重复区间长度】

P2572 [$SCOI2010$] 序列操作 【线段树,$8$个属性,$2$个懒标记的线段树,难度立即提升!柯朵莉树只能过$3$个测试点】

#10115. 「一本通 4.1 例 3」校门外的树 【左右括号问题,树状数组,线段树,单点修改】

CF444C DZY Loves $Colors$ 【魔改线段树,根据$same$属性决策是否懒标记下传】

(3)、线段树维护区间可合并信息

AcWing 1277. 维护序列 [扫描线+线段树+乘法加法两个懒标记]

POJ 2777 Count $Color$ [二进制表示选择状态+线段树计算1的个数和+懒标记更新]

(4)、线段树维护区间不可合并信息(暴力计算)

GSS4 - Can you answer these queries $IV$ [暴力开方]

P4145 上帝造题的七分钟 2 / 花神游历各国 这个其实就是上面那道题,一模一样,输出有点差别而已。

CF438D The Child and $Sequence$[暴力取模]

(5)、线段树优化建图

Legacy - $CF786B$

(6)、线段树+大小转换为$01$序列+二分

P2824 HEOI2016 排序

(7)、线段树维护树上信息

POJ 3321 Apple Tree [$dfs$序求子树节点和]

据说线段树还可以应用$bfs$序,目前还没有找到合适的练习题,挖坑待填吧~

dfs序基础讲解(小白版)

(8)、线段树分裂与合并

P5494 【模板】线段树分裂

P4556 [$Vani$有约会]雨天的尾巴 /【模板】线段树合并


TODO 难度太高 或者 还没有学习到

CF343D Water $Tree$

【涉及到树链剖分】

P4690 [$Ynoi2016$] 镜中的昆虫

【NOI级别,黑题,先不做】