线段树.cpp

前言

本人采集,2023 年 CSP 一个奖没拿(),所以有什么问题欢迎联系我斧正。(luogu @_IceCream_)

如果您是数据结构大师,可以直接看后面的ex线段树部分。

线段树的基本操作

概念及其复杂度

例 0.

给你长度为 n 的一个正整数序列 A,需要你实现将某一位的值修改成某值,或区间加和。1n105,1ai109


这时候暴力或者差分及前缀和都是 O(nq) 的,有没有什么 logn 量级的算法呢?不难想到,一棵 n 个节点的树的层数正好是 logn 的。

那么现在最主要的问题是,怎么样将一个序列映射到一棵树上?

映射很好办,我们将每一个区间用点表示。若点 x 的区间是 [l,r],那么 x 的左子节点对应的是 [l,l+r2],右子节点是 (l+r2,r]。特别的,根对应的区间是 [1,n]。例如 n=5,A=1,2,5,3,4

最后出来的树就是这样的,这玩意就叫线段是。若采用堆式存储(编号为 x 的点左子节点编号是 2x,右子节点编号是 2x+1), 那么叶子节点个数是 2logn 个,所以总个数就是 2logn+11。因为模拟可以发现,线段树其实是一棵完全二叉树。可以把这个式子丢到软件里面,可以看到开 2n 刚刚好够了。

但是 OI-wiki 给出的证明是要开 4n,如下:

分析:容易知道线段树的深度是 logn 的,则在堆式储存情况下叶子节点(包括无用的叶子节点)数量为 2logn 个,又由于其为一棵完全二叉树,则其总节点个数 2logn+11]。当然如果你懒得计算的话可以直接把数组长度设为 4n,因为 2logn+11n 的最大值在 n=2x+1(xN+) 时取到,此时节点数为 2logn+11=2x+21=4n5

当然个人推荐开 4n,毕竟 2n 是与图像差不多重合的,有些冒险。所以空间复杂度 O(4n),而我们在接下来的操作中需要保持时间复杂度为 O(logn)

建树

还是 n=5,A=1,2,3,4,5 举例。

我们从根节点 1 出发,从中点,也就是 l+r2 分开,分别访问两个子节点。若自己就是叶子节点,也就是 l=r,此时就可以直接赋值返回了。最后将两个子节点合并,就是自身的和。

这里建议将合并写成函数,会方便许多。

void pushup (int node) {tree[node] = tree[node << 1] + tree[(node << 1) + 1];} // 这里注意加法和位运算的优先级

void build (int node, int l, int r) {
    if (l == r) {
        // 若自己就是叶子节点,赋值返回。
        tree[node] = a[l];
        return ;
    }
    
    int mid = (l + r) >> 1; // 分成两个子节点下传。
    build (node << 1, l, r);
    build ((node << 1) + 1, mid + 1, r);
    pushup (node); // 别忘记上传
}

可以知道我们最多访问了 logn 次,因为深度最多也是 logn 的。那么时间复杂度就是 O(logn)

单点修改

设当前遍历的区间是 [l,r]mid=l+r2),要修改的位置是 s。那么有两种情况,

  1. s[l,mid],此时位置在左子节点对应的区间中,所以向左遍历。
  2. s(mid,r],此时位置在右子节点对应的区间中,所以向右遍历。

举个例子,我们要把 A3 修改成 7

那么抽象成未知的,再写成代码就是这样的。

void modify (int node, int l, int r, int s, int c) {
    if (l == r) { // Step 3. 找到单点
        tree[node] = c;
        return ;
    }
    
    int mid = (l + r) >> 1;
    // 按 mid 划分左右区间,分别判断遍历。
    if (s <= mid) modify (node << 1, l, mid, s, c);
    else modify ((node << 1) + 1, mid + 1, r, s, c);
    pushup (node);
}

叶子节点深度不超过 logn,所以时间复杂度仍然是 O(logn)

区间查询

因为我懒单点查询跟单点修改思路几乎一致,所以这里不再过多赘述。

假如当前我们遍历到的区间是 [l,r],要查询的区间是 [s,t],要遍历下去就有以下四种情况。

  1. sl,rt,此时 [l,r][s,t] 中,正好是我们要求的答案,直接返回;
  2. tmid,也就是 [s,t][l,mid] 中,此时查询区间在左半部分的区间中,向左遍历;
  3. s>mid,也就是 [s,t](mid,r] 中,此时查询区间在右半部分,向右遍历;
  4. smid,t>mid,此时 [s,t] 两个部分中均有要查询的元素,所以两边都要遍历。

不妨简化一下,我们发现 tmid 可以得到 smid,且两种对应情况(第 2,4 种)查询区间都需要遍历左区间。同理,s>mid 可以得到 t>mid。那么就可以简化成:

  • sl,rt,直接返回节点值;
  • smid,遍历左区间;
  • t>mid,遍历右区间。
long long query (int node, int l, int r, int s, int t) {
    if (s <= l && r <= t) return tree[node];  // 第一种情况
    
    int mid = (l + r) >> 1;
    long long ret = 0;
    if (s <= mid) ret += query (node << 1, l, mid, s, t); // 第二种情况
    if (t > mid) ret += query ((node << 1) + 1, mid + 1, r, s, t); // 第三种情况
    return ret; // 别忘了返回答案
}

我们最多可以将 [l,r] 拆分成 logn 个极大区间,因为最大也就是 20+21+22++2logn1 这样子。并且深度也是 logn 的,所以时间复杂度也就是 O(logn)

区间修改与懒惰标记

若我们想要区间修改,最先想到的就是用 n 次单点修改,但这样时间复杂度是 O(nlogn),会超时。那么我们考虑在树上设计一种标记,这种标记可以帮我们完成区间修改。

遍历的过程跟区间查询差不多,如果遍历到了要修改的点就修改权值并挂标记即可。

若点 x 有这种标记,那么就可以下传给自己的两个子节点,并且修改子节点,然后清空在点 x 上的标记即可。但是不能挂上了标记就马上下传,这样复杂度还是 O(nlogn) 的。反过来,如果延迟下传呢?那么这次修改遍历影响到的就是下一次操作的遍历,我们何不在下一次遍历时带着标记再下传,这样复杂度就成功降到了 O(logn)

我们称它叫做懒惰标记,还可以把要修改的权值给懒标记的权值,这样修改起来更方便。

这个东西需要考虑的细节有点多,这里列举出来。

  • 函数内部遍历时下传顺序先于遍历
  • 需要考虑修改的运算跟整体(大小)有无关系,例如加法,就需要在修改节点权值是乘一个 rl+1
  • 注意任何修改都需要上传更新
  • 修改后别忘了清除标记
long long laz[N];

void pushdown (int node, int l, int r) {
	int ls = node << 1, rs = (node << 1) + 1, mid = l + ((r - l) >> 1);
    if (laz[node]) {
        long long x = laz[node];
		tree[ls] += x * (mid - l + 1), tree[rs] += x * (r - mid); // 注意点 2
        laz[ls] += x, laz[rs] += x;
        laz[node] = 0; // 注意点 4
    }
}

void modify (int node, int l, int r, int s, int t, long long c) {
	if (s <= l && r <= t) {
        tree[node] += (r - l + 1) * c;
        laz[node] += c;
        return ;
    }
    
    int mid = l + ((r - l) >> 1);
    pushdown (node, l, r); // 注意点 1
    if (s <= mid) modify (node << 1, l, mid, s, t, c);
    if (t > mid) modify ((node << 1) + 1, mid + 1, r, s, t, c);
    pushup (node) // 注意点 3
}

由于线段树的适用性极高,这使得它在不同的题目上可能会有不同的应用,从而被 OIer 们开发并延伸出了很多变形与操作。

ex线段树

动态开点线段树

动态开点线段树是针对于线段树空间大这一缺点进行优化的。实际上这玩意还是传统线段树。

字面意义,动态开点的核心思想正是在有需要的时候才新建节点。而且我们不用 2x2x+1 记录子节点了,而是新开一个 ls 和 rs 变量来记录子节点的编号。

单次操作复杂度仍是 O(logn),并且最多只需要 2n1 个节点就可以了。

int build (int l, int r) {
	int node = ++tot; // 改动 1
	if (l == r) {
		tree[node].val = a[l];
		return node;
	}
	
	int mid = l + ((r - l) >> 1); 
    // 改动 2
	tree[node].ls = build (l, mid);
	tree[node].rs = build (mid + 1, r);
	return node;
}

小清新线段树

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

有长度为 n 的正整数数列 am 次操作,需要你实现区间开平方(向下取整)和查询区间和。1n,m105,1ai1012

小清新线段树,你可以理解为带剪枝的线段树,也可以说是时间复杂度分析和懒标记的灵活应用的一类传统线段树。

例题中如果我们直接单点开平方的话,复杂度飙升到 O(mnlogn),肯定不行的。但除此以外好像就没有其它线段树做法了。

注意到 ai 范围是 1012,最多开平方 6 次就等于 1 了,而 1=1,也就无限循环。

那么我们可以再开一个变量记录区间中的最大数,若当前区间最大数 1,就不用修改了,反之再暴力遍历。

void modify (int node, int l, int r, int s, int t) {
	if (l == r) {
		tree[node] = sqrt (tree[node]);
		mx[node] = sqrt (mx[node]);
		return ;
	}
	
	int mid = l + ((r - l) >> 1);
	if (s <= mid && mx[node << 1] > 1 /*若 max > 1 则继续暴力遍历*/) modify (node << 1, l, mid, s, t);
	if (t > mid && mx[(node << 1) + 1] > 1) modify ((node << 1) + 1, mid + 1, r, s, t);
	pushup (node);
}

可以发现这玩意跑的非常快,时间复杂度的话均摊一下复杂度就还是 O(logn) 。但是因为我们开方最多开 6 次,所以要带个六倍常数。(反正笔者跑最高只用了 62ms)

吉司机线段树

这是吉如一老师在 2016 年国家队论文集上提出的线段树处理区间历史最值的问题。如果你想详细了解,可以在这里下载 2016 年的国家队论文。

例题 BZOJ4695 最佳女选手

此题需要你支持以下 6 种操作,分别是

  • 区间加上 x
  • lir,ai=max(ai,x)
  • lir,ai=min(ai,x)
  • 查询区间和、最小值和最大值。

重点在 2,3 两个操作上。先看操作 2,意思就是将区间 [l,r] 内所有小于 x 的数修改为 x

我们考虑像小清新线段树一样省去一些操作。记 mi,mx,sx,cx 分别为区间 [l,r] 的最小值,最大值,严格次大值和最大值个数。那么我们会得到三种情况。

  • mix,此时修改毫无意义,直接返回;
  • cx<xmx,此时 x 可以更新区间种的最大值,那么我们让和加上 cnt×(xmx),并给挂上一个标记,将 mx 修改为 x
  • xcx,此时我们不知道具体有多少个数会被修改,那么就继续暴力下传。

那么这种方法有多快呢?由于笔者不会势能分析,所以这里可以参考ran_qwq大佬的题解

笔者の提交记录:

void modify_max (int node, int l, int r, int s, int t, int c) {
	if (tree[node].mi >= c) return ;
	
	if (s <= l && r <= t && tree[node].si > c) { // 这里是修改值在次大与最大之间的情况
		tree[node].sum += (c - tree[node].mi) * tree[node].ci;
		if (tree[node].sx == tree[node].mi) tree[node].sx = c;
		if (tree[node].mx == tree[node].mi) tree[node].mx = c;
		tree[node].lazi = max (tree[node].lazi, c);
		tree[node].mi = tree[node].lazx = c;
		return ;
	}
	
	pushdown (node, l, r);
	int mid = l + ((r - l) >> 1);
	if (s <= mid) modify_max (node << 1, l, mid, s, t, c); 
	if (t > mid) modify_max ((node << 1) + 1, mid + 1, r, s, t, c);
	pushup (node);
}


先咕咕咕...

一些练习

推荐题单

基本操作

luogu P3372 【模板】线段树 1
luogu P3373 【模板】线段树 2

[USACO15DEC] Counting Haybale P

小清新线段树

UOJ.228(相当于花神游历各国加强版)

CodeForces 920F. SUM and REPLACE

吉司机线段树

luogu P6242【模板】线段树 3

线段树进阶

luogu P4839 P 哥的桶(线性基套线段树)

luogu P6327 区间加区间 sin 和(三角函数和差角公式,注意double)

luogu P3747 [六省联考 2017] 相逢是问候(建议先做前置,此题难度较大,建议先能灵活运用数论与线段树后再做)

SPOJ GSS7 - Can you answer these queries VII(线段树 + 树链剖分最终版,建议先做题单中的线段树 + 树链剖分和GSS1),GSS3

posted @   Xeanin  阅读(18)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示