线段树详解

news/2025/2/5 18:36:56/文章来源:https://www.cnblogs.com/autumnmist/p/18695595

授人以鱼不如授人以渔

本文尽量详细地讲述线段树的引入,实现,应用,以及相关进阶知识。

引入

引入线段树通用的例子:

给定一组整数\(nums\),定义两种操作

  • 修改列表里的第\(i\)个数据为\(val\)

  • 查询区间和\([L,R]\)

为了同时实现两种操作,现在考虑处理\(nums\)的方式

简单的,可以直接使用数组

此时对于操作①,简单赋值nums[i] = v即可。复杂度\(O(1)\)。但是此时想执行
操作②,却需要枚举计算\(\sum_{i=L}^Rnums[i]\),这个复杂度是\(O(n)\)的。

稍微进阶一点,可以使用前缀和数组
此处面向小白单独再解释下前缀和。
已知数组\(a\),定义前缀和数组\(pre[n+1]\)\(pre[i]\)表示数组\(a\)的前\(i\)项之和,即\(pre[i]=\sum^{i-1}_{j=0}a[j]\),特别的\(pre[0]\)表示不选择任何前缀,\(pre[0]=0\)

重要引申:\(a\)的子数组\([L,R]\)内的元素和,即\(a[L]+\cdots+a[R] =\sum_{i=L}^{R}a[i]=pre[R+1]-pre[L]\)

所以预处理原数据,生成前缀和数组,此时对于操作②,\(\sum_{i=L}^Rnums[i]=pre[R+1]-pre[L]\)是可以以\(O(1)\)计算的。
但是此时对于操作①,修改任意\(nums[i]\)都需要对\(pre[i+1]\sim pre[n]\)的所有前缀和修改,反而是\(O(n)\)的。

综合来看,两种处理策略都是一种操作\(O(1)\),另一种操作\(O(n)\),混合操作显然复杂度均为\(O(n)\)
如何可以同时处理两种操作且单次复杂度均低于\(O(n)\)

线段树应运而生。Jon Bentley 1977年在解决Klee提出的问题 时发明出线段树

对于上述两种操作,线段树均能以单次\(O(logn)\)复杂度完成。

线段树代码复杂,应用广泛,还有各种变种和进阶知识。是小白到进阶的分水岭

基本实现

线段树,顾名思义,树的节点上存储的是一个区间数据(线段)

下面用数组构造一颗最基础的线段树。

根节点表示的是整个\([0\sim n-1]\)范围数据之和
假设root是根节点,有\(root.sum = \sum_{i = 0}^{n-1}nums[i]\)

对于每个节点,其左右子节点分别代表当前节点左右两半区间的数据,然后递归建立这棵树。
前n个叶节点对应数组\(nums\)

线段树可看做是一颗完全二叉树。

从数学角度可以计算线段树中至多有多少个节点:
数组\(nums\)长度为\(n\),如果\(n\)为2的幂,则可以构成一颗完全二叉树,此时节点数为\(2n-1\)
\(n\)可能不为2的幂时,考虑极限情况取\(n=2^k+1\),此时这颗完全二叉树会多出一层,这层个数为\(2*(n-1)\),整体为\(4n-5\)

一般的,通常初始化数组长度为\(4n\)

以数组\([1,2,3,4,5]\)为例,线段树节点数组定义为vector<int> tree

以数组方式存树时,习惯节点下标从1开始,若当前节点位置为\(p\),则左右孩子节点可以简单表示为\(p*2\)\(p*2+1\)

本例中,根节点\(tree[1]=15\),表示原数组区间\([0,4]\)和为15。

以下递归进行:

每次对当前节点区间折半处理,左半部分是左子树数据,右半部分是右子树数据
则有\(tree[2]=6\),代表区间\([0,2]\)和为15。\(tree[3]=9\),代表区间\([3,4]\)和为9。
\(\cdots\)

下面基于这颗树,来处理这两种操作。

首先写一个简单的模板定义,结构如下:

class SegmentTree
{
public:SegmentTree(int n) { this->n = n; tree.resize(n * 4); }; //4n定义void Update(int i, int val) { ... } //操作1int Sum(int l, int r) { return ... } //操作2
private:int n; //原始数据的长度vector<int> tree; //树结构数组
}

单点修改

通常称操作1为单点修改

显然,当我们修改某一项时,必须要对应修改包含了该点的所有区间节点。

比如修改\(nums[2]=0\),则节点1(表示区间[0-4]),节点2(表示区间[0-2]),节点5(表示区间[2-2]),均需要同步修改。

具体地,我们要做的是:

  • 自顶向下递归地分割区间,直到定位到目标节点

  • 自底向上合并结果,从目标节点的修改,向上逐步修改父节点的结果。

以下是基础版线段树单点修改代码

//API,更新原数据第i个位置为val i∈[0,n-1]
void Update(int i, int val) { Update(1, 0, n - 1, i, val); }
//具体实现
void Update(int p, int s, int t, int i, int val)
{if (s == t){tree[p] = val;return;}//分治int mid = (s + t) >> 1;if (i <= mid) Update(p * 2, s, mid, i, val);if (i > mid) Update(p * 2 + 1, mid + 1, t, i, val);//合并tree[p] = tree[p * 2] + tree[p * 2 + 1];
}

单次修改复杂度是\(O(logn)\)

以上是一段优美简洁的分治思想代码,建议细品。

p表示的是当前节点索引,对应tree数组中,p的范围大致是[1,4n]
[s,t]表示的就是p节点管辖的区间
目标是定位到原数组中位置i最终所占的区间[i,i],递归函数的出口处有s=t=i

区间查询

通常称操作2为区间查询

容易理解的是,1号根节点表示区间[0,n-1]也就是所有元素和。
向下,2号节点是左半区间,3号节点是右半区间。
递归向下,依次可以得到不同的区间数据。
但是这些区间都是"折半区间",当待查区间是任意区间时,比如查询[0,3]?

类似单点查询,同样需要递归分治处理。
以数组范围[0,n-1],目标区间[l,r]为例
当递归处理到某个节点区间[s,t]时,如果这个区间被目标区间完全覆盖,则可以直接返回该节点的区间和。
否则,将当前区间折半,如果左侧或者右侧的区间与目标区间有交集,则递归进入计算,否则可以忽略。
最终返回左右区间结果的和。
具体代码如下:

//API 查询原数组[l,r]区间和 l,r∈[0,n -1]
int Sum(int l, int r) { return Sum(1, 0, n - 1, l, r); }
//具体实现
int Sum(int p, int s, int t, int l, int r)
{if (s >= l && t <= r) return tree[p];int res = 0;int mid = (s + t) >> 1;if (l <= mid) res += Sum(p * 2, s, mid, l, r);if (r > mid) res += Sum(p * 2 + 1, mid + 1, t, l, r);return res;
}

至此,我们实际完成了基础线段树的核心部分,同时可以处理操作12,且单次操作复杂度均为\(O(logn)\)

模板题,LeetCode上307. 区域和检索 - 数组可修改

class NumArray {
public:SegmentTree* tree;NumArray(vector<int>& nums) {int n = nums.size();tree = new SegmentTree(n);for (int i = 0; i < n; i++) tree->Update(i, nums[i]);}void update(int index, int val) {tree->Update(index, val);}int sumRange(int left, int right) {return tree->Sum(left, right);}
};

建树

注意上述代码里,初始化树的过程中,我们枚举了每个原数组下标,调用了:

for (int i = 0; i < n; i++) tree->Update(i, nums[i]);

显然这个复杂度为\(O(nlogn)\)。虽然也能通过本题,但是在标准线段树处理里,可以通过一次递归,完成所有数据的初始化

由于只有\(4n\)个节点,因此建树的复杂度可以降为\(O(n)\)

更具体地,本题中设数组长度为\(n\),查询次数为\(m\)

则整体复杂度可以从\(O(nlogn+mlogn)\)降为\(O(n+mlogn)\)

这是一个常数优化,标准线段树中一般会包含建树处理。

void Build(vector<int>& nums) { Build(1, 0, n - 1, nums); }
void Build(int p, int s, int t, vector<int>& nums)
{if (s == t){tree[p] = nums[s];return;}int mid = (s + t) >> 1;Build(p * 2, s, mid, nums);Build(p * 2 + 1, mid + 1, t, nums);tree[p] = tree[p * 2] + tree[p * 2 + 1];
}

这样原数组的初始化可以改为简单调用 tree->Build(nums);

合并操作

注意到刷新区间和时的代码,在建树和单点修改都写了

tree[p] = tree[p * 2] + tree[p * 2 + 1];

本质是依据左右子节点数据合并到当前节点数据
本例子可能只有一句,但是线段树在复杂应用中会有较多修改。
一般来说放到一个统一的函数内处理较为合理。

一般命名为PushUp 或者Merge 之类。

void PushUp(int p)
{tree[p] = tree[p * 2] + tree[p * 2 + 1];//如果是复杂应用 可能还会有别的处理
}

是不是也有PushDown?是的,后面会用到

最后借用这个模板题307. 区域和检索 - 数组可修改 贴一下完整代码

基础版线段树

支持单点修改,查询区间和。

//基础线段树模板
class SegmentTree {
public:SegmentTree(int n) { this->n = n; tree.resize(n * 4); }//建树void Build(vector<int> nums) { Build(1, 0, n - 1, nums); }//单点修改void Update(int i, int val) { Update(1, 0, n - 1, i, val); }//区间查询int Sum(int l, int r) { return Sum(1, 0, n - 1, l, r); }
private:int n;vector<int> tree;void Build(int p, int s, int t, vector<int>& nums) {if (s == t){tree[p] = nums[s];return;}int mid = (s + t) >> 1;Build(p * 2, s, mid, nums);Build(p * 2 + 1, mid + 1, t, nums);PushUp(p);}void Update(int p, int s, int t, int i, int val) {if (s == t){tree[p] = val;return;}int mid = (s + t) >> 1;if (i <= mid) Update(p * 2, s, mid, i, val);if (i > mid) Update(p * 2 + 1, mid + 1, t, i, val);PushUp(p);}void PushUp(int p){tree[p] = tree[p * 2] + tree[p * 2 + 1];}int Sum(int p, int s, int t, int l, int r){if (s >= l && t <= r) return tree[p];int res = 0;int mid = (s + t) >> 1;if (l <= mid) res += Sum(p * 2, s, mid, l, r);if (r > mid) res += Sum(p * 2 + 1, mid + 1, t, l, r);return res;}
};
//模板结束
class NumArray {
public:SegmentTree* tree;NumArray(vector<int>& nums) {int n = nums.size();tree = new SegmentTree(n);tree->Build(nums);}void update(int index, int val) {tree->Update(index, val);}int sumRange(int left, int right) {return tree->Sum(left, right);}
};

树状数组和ST表

额外介绍两个类似的数据结构。

ST表: 仅支持区间查询,不支持修改。以复杂度\(O(nlogn)\)预处理,每次查询\(O(1)\)

树状数组:同样支持单点修改和区间查询操作。而且代码实现简单,且执行效率更高(常数低),主要原因是避免了递归调用。

既然树状数组各方面都比线段树更优,那么线段树的意义是什么?

因为线段树可以支持区间修改!

更强大的线段树

区间修改

难以置信,居然可以以单次复杂度\(O(logn)\)进行区间修改。

区间修改,同样以最初的例子说明
即一次操作中选择任意区间[l,r],把该区间的元素都改为val。
如果我们用最简单的思维方式,枚举区间每个元素,单点修改为val
当区间足够宽时,这个复杂度显然是\(O(nlogn)\)的,无法接受。

模仿上面分治+合并的方式可以实现这样一种思路:
当递归到的区间[s,t]被[l,r]完全覆盖时,不需要再向下递归。
[s,t]的区间和也简明就是\(val * (t - s + 1)\)
然后向上更新区间和
此时的单次操作复杂度\(O(logn)\)

这个做法是否正确呢?有问题。

当某个区间执行过区间修改后后,再次查询区间内的某个子区间和时,得到的依然是旧值。
因为我们上面为了保证复杂度,递归到某个完全覆盖区间后会直接返回。
那么当查询这个区间的子区间时,这个子区间不知道父区间发生过修改,也无法获得最新的val。

这时,我们引入了延迟标记 (或称为懒标记)
当区间更新递归到某个区间被目标区间完整覆盖,由于复杂度问题,我们需要直接返回。
但是我们额外记录下这个区间的最新目标修改值,称为延迟标记。
如果某次区间查询(区间修改也需要)查到了这个区间的子区间,这时我们在递归中把这个标记下推到子区间。然后清空标记。
由于直到某个子区间被查询或者修改时才会触发这个标记更新,所以称为延迟标记。
标记下推的过程 一般封装为上文提到过的PushDown函数

延迟标记保证了区间修改的复杂度\(O(logn)\),同时保证了数据的正确性。

通过延迟处理降低复杂度在别的地方也有应用,比如懒删除堆等。

ps. 单点修改可看做\(L=R\)的区间修改。

具体实现上,我们额外在节点存储一个数据vector<int> data\(data[i]\)表示该节点代表的区间被修改时的目标值,以及一个是否有未同步数据的标记vector<bool> tag

比如区间修改\([0,2]\)为2,则有\(tree[2]=2*3=6\),然后标记\(val[2]=2\) 表示节点2最新修改值为2,且\(tag[2]=true\) 表示尚未向下同步

然后当向节点2子节点访问时,先向下同步节点2的\(tag\)值,然后再调用递归逻辑,之后清除节点2的\(tag\)标记。

由于本例子是修改元素值+查询区间和,理论上可以用data是否等于零判断是否有标记。考虑到扩展性,保留了tag标记

//区间更新[l,r]为val
void Update(int p, int s, int t, int l, int r, int val)
{if(s >= l && t <= r){//直接返回,但是记录标记tree[p] = val * (r - l + 1);data[p] = val;tag[p] = true;return;}int mid = (s + t) >> 1;//此处下推标记PushDown(p, mid - s + 1, t - mid);//...
}
//区间查询 只增加下推标记处理
int Sum(int p, int s, int t, int l, int r)
{//...int mid = (s + t) >> 1;//此处下推标记PushDown(p, mid - s + 1, t - mid);//...
}
//下推标记函数
void PushDown(int p, int cl, int cr)
{if (!tag[p]) return;//标记下推到子节点tag[p * 2] = tag[p * 2 + 1] = true;data[p * 2] = data[p * 2 + 1] = data[p];tree[p * 2] = data[p] * cl;tree[p * 2 + 1] = data[p] * cr;//清空父节点标记tag[p] = false;data[p] = 0;
}

节点封装

实现区间修改后,我们发现节点数据增加了不少,之前只有tree数组,又增加了两个标记数组。
如果是复杂应用可能会更多。
工程上为了可读性,一般会把节点封装到一起。比如:

class Node {
public:long sum = 0;bool tag = false;long val = 0;
};

这样可以在线段树中只定义一个数组vector<Node> tree

由于单点修改等价于l=r的区间修改,所以我们直接给出一个完整的区间修改线段树。

完整模板

支持区间修改的线段树模板
注意是修改区间为目标值,以及查询区间和

class Node {
public:long sum = 0;bool tag = false;long val = 0;
};
class SegmentTree {
public:SegmentTree(int n) { this->n = n; tree.resize(n * 4); }//建树void Build(vector<int> nums) { Build(1, 0, n - 1, nums); }//区间修改void Update(int l, int r, int val) { Update(1, 0, n - 1, l, r, val); }//区间查询int Sum(int l, int r) { return Sum(1, 0, n - 1, l, r); }
private:int n;vector<Node> tree;void Build(int p, int s, int t, vector<int>& nums) {if (s == t){tree[p].sum = nums[s];return;}int mid = (s + t) >> 1;Build(p * 2, s, mid, nums);Build(p * 2 + 1, mid + 1, t, nums);PushUp(p);}void Update(int p, int s, int t, int l, int r, int val) {if (s == t){tree[p].sum = val * (r - l + 1);tree[p].val = val;tree[p].tag = true;return;}int mid = (s + t) >> 1;PushDown(p, mid - s + 1, t - mid);if (l <= mid) Update(p * 2, s, mid, l, r, val);if (r > mid) Update(p * 2 + 1, mid + 1, t, l, r, val);PushUp(p);}int Sum(int p, int s, int t, int l, int r){if (s >= l && t <= r) return tree[p].sum;int res = 0;int mid = (s + t) >> 1;PushDown(p, mid - s + 1, t - mid);if (l <= mid) res += Sum(p * 2, s, mid, l, r);if (r > mid) res += Sum(p * 2 + 1, mid + 1, t, l, r);return res;}void PushUp(int p){tree[p].sum = tree[p * 2].sum + tree[p * 2 + 1].sum;}void PushDown(int p, int cl, int cr){if (!tree[p].tag) return;tree[p * 2].tag = tree[p * 2 + 1].tag = true;tree[p * 2].val = tree[p * 2 + 1].val = tree[p].val;tree[p * 2].sum = tree[p].val * cl;tree[p * 2 + 1].sum = tree[p].val * cr;tree[p].tag = false;tree[p].val = 0;}
};
//模板结束
class NumArray {
public:SegmentTree* tree;NumArray(vector<int>& nums) {int n = nums.size();tree = new SegmentTree(n);tree->Build(nums);}void update(int index, int val) {//注意这里是[index, index]的区间修改模拟单点修改tree->Update(index, index, val);}int sumRange(int left, int right) {return tree->Sum(left, right);}
};

动态开点

现在我们考虑一个新问题,线段树的节点数组初始化长度是\(4n\),如果\(n\)非常大怎么办?

比如没有初始数组,然后区间修改的范围\([L,R]\)非常大,如1e9,显然我们创建4e9长度的数组,内存过大。还能否使用线段树处理?

解决上面问题有个通用技巧叫做离散化。假设我们已知了所有操作,操作数\(q\)是比较小的,如1e5,那么每次操作出现的不同区间\(L,R\),也是比较少的,离散分布于\([0,1e9]\)范围内。那么我们可以把这些所有的\([L,R]\)映射到\([0,1e5]\)范围内。

具体地,排序所有出现的数据,第一个数据对应1,之后每个不同的数据递增。映射完毕后,再建线段树即可。这个技巧就是离散化

离散化有一个严格的要求,就是所有的查询必须是离线的,也就是必须预先知道所有的查询数据。如果是强制在线呢?

此时我们需要使用动态开点技巧。

所谓动态开点,就是我们直到真正使用某个节点时,再创建这个节点数据,显然每个节点的左右节点也不再是简单的\(p*2\)\(p*2+1\)

可以使用两个数组int[] left, right 表示每个节点的左右子节点的索引,根节点依然是1,然后每次创建节点时,索引递增。

区间范围会很大,但是操作数相对较小,一般的,值域大致U=1e9,操作数m依旧是1e5左右。

整体复杂度为\(O(mlogU)\),所需数组大小大致为\(30m\)这样。

贴的代码太多了 这里就不写了。

大神的模板

此刻CF第一人 jiangly用的线段树板子
参考这里
主要思路是把延迟标记,PushUp/PushDown操作,都封装到类里(jiangly模板里叫Info类,以及维护延迟标记的Tag类),这样线段树框架代码可以保持不变
每次针对题目只需要修改:

  • Info类的实现
  • Tag类的视线
  • Info重载加法运算
    非常值得借鉴。

这里按个人习惯修改下命名,然后就是我的板子了
下面同样是307. 区域和检索 - 数组可修改的完整jiangly模板代码

//jiangly segment tree
//这里只保留了区间修改,区间查询。不支持动态开点
template<class Info, class Tag>
struct SegmentTree {
#define l(p) (p << 1)
#define r(p) (p << 1 | 1)int n;std::vector<Info> info;std::vector<Tag> tag;SegmentTree() {}SegmentTree(int _n, Info _v = Info()) { init(_n, _v); }template<class T>SegmentTree(std::vector<T> _init) { init(_init); }void init(int _n, Info _v = Info()) { init(std::vector(_n, _v)); }template<class T>void init(std::vector<T> _init) {n = _init.size();info.assign(n * 4, Info());tag.assign(n * 4, Tag());auto build = [&](auto self, int p, int l, int r) {if (l == r) {info[p] = _init[l];return;}int m = (l + r) >> 1;self(self, l(p), l, m);self(self, r(p), m + 1, r);pushup(p);};build(build, 1, 0, n - 1);}void pushup(int p) { info[p] = info[l(p)] + info[r(p)]; }void apply(int p, const Tag& v, int len) {info[p].apply(v, len);tag[p].apply(v);}void pushdown(int p, int len) {apply(l(p), tag[p], (len + 1) / 2);apply(r(p), tag[p], len / 2);tag[p] = Tag();}Info query(int p, int l, int r, int x, int y) {if (l > y or r < x) {return Info();}if (l >= x and r <= y) {return info[p];}int m = l + r >> 1;pushdown(p, r - l + 1);return query(l(p), l, m, x, y) + query(r(p), m + 1, r, x, y);}void update(int p, int l, int r, int x, int y, const Tag& v) {if (l > y or r < x) {return;}if (l >= x and r <= y) {apply(p, v, r - l + 1);return;}int m = l + r >> 1;pushdown(p, r - l + 1);update(l(p), l, m, x, y, v);update(r(p), m + 1, r, x, y, v);pushup(p);}//API 区间查询Info Query(int l, int r) {return query(1, 0, n - 1, l, r);}//API 区间修改void Update(int l, int r, const Tag& v) {return update(1, 0, n - 1, l, r, v);}
#undef l(p)
#undef r(p)
};//每次只需要修改以下三个地方。
struct Tag {int val;bool flag;Tag(int _val = 0, bool _flag = false) :val(_val), flag(_flag) {}void apply(Tag t) {if(t.flag){val = t.val;flag = t.flag;}}
};
struct Info {int sum = 0;void apply(Tag t, int len) {if (t.flag) sum = t.val * len;}
};
inline Info operator+(Info a, Info b) {Info c;c.sum = a.sum + b.sum;return c;
}
class NumArray {
public:SegmentTree<Info, Tag>* tree;NumArray(vector<int>& nums) {int n = nums.size();vector<Info> infos(n);for (int i = 0; i < n; i++) infos[i].sum = nums[i];tree = new SegmentTree<Info, Tag>(infos);}void update(int index, int val) {tree->Update(index, index, Tag(val, true));}int sumRange(int left, int right) {return tree->Query(left, right).sum;}
};

应用

引入线段树的例子中,区间修改和区间查询对应的是区间赋值和查询区间和。
但是区间操作远不止这两种行为。还可能是各种能用线段树维护的操作,如求和,求极值,与或,或者某些自定义操作。

一般来说,满足区间结合律的操作,都可以使用线段树维护。比如
\(sum[1,5]=sum[1,3]+sum[4,5]\)
\(max[1,5]=max(max[1,3],max[4,5])\)
自定义的行为可能会更复杂 后面会举例说明。

不同类型的题目中,线段树模板是类似的,但是具体到Node节点设计,PushUp/PushDown具体行为可能都需要修改。
很多大神都有过很好的线段树模板设计,最后会简要介绍一下。

下面由浅入深的介绍线段树的常见使用。

前缀和/差分/ST表/树状数组等上位替代

首先是这三类题目,线段树一定可以做,特性更完整,支持更广,缺点是复杂度更高或常数更大。

前缀和/差分/ST表的复杂度为\(O(1)\)

树状数组的复杂度虽然也是\(O(logn)\),但是常数要比线段低很多。

特别提醒,日常训练,这些类型的题目就尽量不要使用线段树了。
线段树用多了降思维能力。

极值

699. 掉落的方块 区间修改+极值
这里我们用下jiangly的板子


//每次只需要修改以下三个地方。
struct Tag {int val;bool flag;Tag(int _val = 0, bool _flag = false) :val(_val), flag(_flag) {}void apply(Tag t) {//如果父节点有延迟标记 则下推到子节点if(t.flag){val = t.val;flag = t.flag;}}
};
struct Info {int max = 0; //注意这里最小值是0 可能有些题目需要INT_MINvoid apply(Tag t, int len) {//有延迟标记时再更新if (t.flag) max = t.val;}
};
inline Info operator+(Info a, Info b) {Info c;c.max = max(a.max, b.max); //极值的合并方式return c;
}
class Solution {
public:vector<int> fallingSquares(vector<vector<int>>& positions) {//本题数据范围较大,直接建树空间过大TLE。//一种做法是动态开点,另一种是离散化。由于数据可以离线处理,正好使用下jiangly模板。//以下是离散化处理set<int> set{0, (int)1e9};for(auto& v : positions){set.insert(v[0]);set.insert(v[0] + v[1] - 1);}int n = set.size();map<int, int> raw;int pos = 0;for(auto v : set) raw[v] = pos++;//离散化结束SegmentTree<Info, Tag> tree(n);vector<int> ans;for(auto& v : positions){//由于左右边界允许紧贴,所以统一按照左闭右开查询和更新(Trick)int L = raw[v[0]], R = raw[v[0] + v[1] - 1], H = v[1];int mx = tree.Query(L, R).max;mx += H;tree.Update(L, R, Tag(mx, true));//注意是查询整个区间的最大值 而非当前更新区域的最大值ans.push_back(tree.Query(raw[0], raw[1e9]).max);}return ans;}
};

乘法+加法

2569. 更新数组后处理求和查询
可以有别的做法,但是考虑到0-1的互换等价于 乘-1再加上1 我们实现一下同时维护乘,加,区间和查询的线段树

//每次只需要修改以下三个地方。
using i64 = long long;
struct Tag {i64 mul;i64 add;bool flag;Tag(i64 _mul = 1, i64 _add = 0, bool _flag = false) : mul(_mul), add(_add), flag(_flag) {}void apply(Tag t) {if(t.flag){mul *= t.mul;add *= t.mul;add += t.add;flag = true;}}
};
struct Info {i64 sum;void apply(Tag t, int len) {if (t.flag){sum *= t.mul;sum += t.add * len;}}
};
inline Info operator+(Info a, Info b) {Info c;c.sum = a.sum + b.sum;return c;
}//具体执行乘加处理
tree.Update(v[1], v[2], Tag(-1, 1, true));
//其它略

二维偏序

这个是线段树中的经典用法,需要熟练掌握。

偏序粗略理解就是大小关系,一维偏序可类比数组里元素的大小顺序。

二维偏序意思是某个数据有两个维度\(x,y\),原数据可能是这种数据的集合,需求是查询两个维度同时满足条件的情况下的结果。

比如二维数组\(nums\) [[1,5],[2,10],[3,3],[4,1]],对于每个元素\(nums[i]\),已知整数\(x,y\),查询满足\(nums[i][0]<x\),且\(nums[i][1]<y\)的元素个数。

单次查询显然可以通过一次遍历得出结果。但是如果是多次查询呢?查询次数如果是\(m\),整体复杂度将达到\(O(nm)\),这是无法接受的。

二维偏序的通用解法是,首先对其中一个维度排序,这样当枚举时,该维度就都是有效的。而另一个维度,使用线段树维护结果,可以以\(O(logU)\)查询,其中\(U\)是该维度的最大值。

没错,对元素值建树是常用的技巧。

求数组逆序对个数其实是典型的二维偏序问题,不过其中一个维度(数组下标)已经是排序好的。

更典型的例子2736. 最大和查询

线段树优化dp

线段树本身功能强大,应用广泛,可能只是作为工具用来优化某一部分逻辑。

典型如线段树优化的动态规划。

2407. 最长递增子序列 II

//没有贴线段树的板子,是一个支持动态开点的模板。
class Solution {
public:int lengthOfLIS(vector<int>& nums, int k) {int n = nums.size();SegmentTree tree;for (int i = 0; i < n; i++){int v = nums[i];//要求严格递增 所以区间右边界是v-1,要求差值<=k,所以左边界是v-kint max = (int)tree.Max(Math.Max(v - k, 0), v - 1) + 1;int pre = (int)tree.Sum(v, v);if (max > pre) tree.Update(v, v, max);}return (int)tree.Max(0, (long)1e9);}
};

分治

这类问题是线段树基础应用中最有难度的部分

  • 不是直观的类似求和求极值这种运算
  • 非常考验对分治的理解,对线段树的理解
  • 需要充分挖掘题目需求,找出合并处理

3165. 不包含相邻元素的子序列的最大和
可以先做一下 单次查询版本 198. 打家劫舍
这里大致写下合并的代码

//node节点下维护sum数组
void PushUp(Node* node) {// --左右两个端点不选// 可以是左区间选择右端点+右区间都不选// 可以是左区间都不选+右区间选择左端点// 取两者较大值node->sum[0] = max(node->left->sum[2] + node->right->sum[0], node->left->sum[0] + node->right->sum[1]);// --左选右不选node->sum[1] = max(node->left->sum[3] + node->right->sum[0], node->left->sum[1] + node->right->sum[1]);// --右选左不选node->sum[2] = max(node->left->sum[2] + node->right->sum[2], node->left->sum[0] + node->right->sum[3]);// --左右两个端点都可选node->sum[3] = max(node->left->sum[1] + node->right->sum[3], node->left->sum[3] + node->right->sum[2]);
}

另一个同样经典的题目 线段树维护最大子段和
3410. 删除所有值为某个元素后的最大子数组和

对应洛谷上的题目是 P4513 小白逛公园

进阶

写不动了 简单介绍下

线段树的性能和zkw线段树

上文提到,线段树的常数较大。最近LeetCode的题目出的限制越来越大,导致很容易卡常。

开销比较高的点主要是

  • 查询和更新自带的log系数,对比前缀和 ST表等单次\(O(1)\)
  • 递归调用的开销,对比树状数组的非递归写法。
  • 空间复杂度偏高,\(4n\)的空间开销,以及较多的节点数据
  • 如果是动态开点,往往是\(O(logU)\)的系数,此时\(U=1e9\),和对应的\(O(nlogU)\)的空间

可以通过典型题目测试下自己的板子的开销。

下面几个是对性能要求较高的题目 可以用作实验。

3161. 物块放置查询
3413. 收集连续 K 个袋子可以获得的最多硬币数量

zkw线段树是一个非递归的线段树实现,要比递归线段树的性能更好一些。

可持久化线段树

配合动态开点,可以维护多个历史版本的线段树。

考虑线段树增加一个版本概念。

每次操作都可以针对某个版本进行,版本之间的数互不影响。

要处理这种情况,我们需要对每次新增版本时,增加根节点,然后操作时递归向下建立新节点(如果有必要)。

根节点本身变成一个列表,每次操作都是指定某个版本下的线段树进行操作。

注意:空间复杂度由普通线段树的\(O(n)\) 增加到\(O(nlogn)\)

静态区间第k小 著名可持久化线段树模板题

1157. 子数组中占绝大多数的元素 LC上的一个可以用可持久化线段树解决的例子。

树套树

线段树套线段树

可以配合二维前缀和/二维差分一起理解。

区域单词查询复杂度\(O(logn*logm)\)

ps.有一种四叉树的写法 似乎复杂度会被卡成\(O(n)\)

308. 二维区域和检索 - 矩阵可修改

线段树套平衡树

树套树模板题

线段树建图

什么时候有时间了再补充

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/879199.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

[Python] 依赖注入的使用,多模块任务隔离

使用google/pinject(依赖注入库)搭建了一个多模块运行、相互隔绝的项目。定义全局单例的依赖注入容器:"""依赖注入容器"""from typing import Any, List, Type, TypeVar import pinject import pinject.findingclass Ioc:"""依…

关于设计模式的一点想法

《设计模式:可复用面向对象软件的基础》书评最早读这本《设计模式:可复用面向对象软件的基础》是在大学的时候。读了一些片段,看到了讲文本编辑器的滚动条装饰,觉得有点意思,可以用来做图形界面。记得有一天晚上上床睡觉后,和两位同寝室室友聊天。一位室友LL说,他为了找…

ACK 容器监控存储全面更新:让您的应用运行更稳定、更透明

容器存储是容器应用运行时的数据保障,本次 ACK 容器存储监控的更新能够帮助用户全面、精细地掌控集群中的存储细节,快速定位业务运行过程中可能出现的 IO 瓶颈和 IO 问题,更好地保证业务的平稳运行。作者:邱圆辉(霜序) 背景 随着容器化应用的日益普及、业务规模的增长以及…

LLM大模型:deepseek浅度解析(三):R1的reinforcement learning复现

deepseek-R1比较创新的点就是reward函数了,其自创的GRPO方法,详解如下:https://www.cnblogs.com/theseventhson/p/18696408训练出了R1-zero和R1两个强化学习版本!幸运的是,GRPO的这个算法已经有人实现,并集成到huggingface啦,直接调用就行,demo在这里:https://gist.gi…

并发编程 - 线程同步(三)之原子操作Interlocked简介

原子操作是不可分割的操作单元,Interlocked提供硬件级别原子操作,比传统锁机制效率高。Interlocked支持多种原子操作,如增减、替换、位操作等,确保多线程安全。上一章我们了解了3种处理多线程中共享资源安全的方法,今天我们将更近一步,学习一种针对简单线程同步场景的解决…

从易用性到高级分析:五款优秀报表软件盘点

本文将为大家介绍五款报表软件,详细描述它们的功能亮点和适用场景。山海鲸报表、Qlik Sense、Looker、Domo和Power BI分别在自助分析、实时数据访问、数据整合、可视化以及人工智能支持等方面展现了强大的功能。这些软件适用于不同规模的企业,能够帮助企业实现数据的可视化、…

阿里云可观测 2024 年 12 月产品动态

阿里云可观测 2024 年 12 月产品动态

pre-norm、post-norm

同一设置之下,Pre Norm结构往往更容易训练,但最终效果通常不如Post Norm参考资料 苏剑林. (Mar. 29, 2022). 《为什么Pre Norm的效果不如Post Norm? 》[Blog post]. Retrieved from https://kexue.fm/archives/9009为什么大模型结构设计中往往使用postNorm而不用preNorm?

连接VNC时出现attempting to reconnect to vnc server

参考 https://blog.csdn.net/weixin_46031767/article/details/128076399 使用VNC View连接kvm虚拟机无法出现画面提示 Attempting to reconnect to VNc Server.. Protocol error: invalid message type 255将画质调低解决该问题

【FMC173】l基于VITA57.1标准的4通道4GSPS AD采集、4通道12GSPS DA回放FMC子卡模块

产品概述 FMC173是一款基于VITA57.1标准的,实现4路12-bit、4GSPS ADC采集功能、4路16-bit 12GSPS DA回放的FMC子卡模块。该模块遵循VITA57.1(HPC)标准,搭配FPGA载板可以灵活的实现多通道高速采集与回放功能。模块的主芯片采用ADI公司高度集成的AD9081芯片,该芯片与ADI公司…

数字化办公新宠:文档协作工具如何重塑团队协作?

文档协作工具以其强大的功能为团队管理者带来了诸多便利。在选择适合的工具时,团队管理者应根据团队规模、需求以及预算等因素进行综合考虑。同时,还应关注工具的安全性、易用性以及与其他应用程序或系统的集成能力等方面。通过合理利用文档协作工具的功能优势,团队管理者可…

腾讯云HAI服务器上部署与调用DeepSeek-R1大模型的实战指南

上次我们大概了解了一下 DeepSeek-R1 大模型,并简单提及了 Ollama 的一些基本信息。今天,我们将深入实际操作,利用腾讯云的 HAI 服务器进行 5 分钟部署,并实现本地 DeepSeek-R1 大模型的实时调用。接下来,我们直接进入部署过程。 服务器准备 首先,我们需要登录腾讯云平台…