树状数组/线段树算法合集 - By 迪杰小凤凰

树状数组/线段树算法合集

By 迪杰小凤凰

树状数组(BIT)

区间查询,单点修改

引入

问题:对于一个长度为 \(n\) 的数组,满足 \(m\) 次单点修改和区间查询

如果朴素的算法,即每次修改以后均将查询区间全部遍历,时间复杂度 \(O(nm)\),明显过大

不难发现,每次修改以后都只有一些区间受到影响,其他不受影响,因此可以考虑预处理这些区间

但是对于一个长度为 \(n\) 的数组,其非空子序列有 \(\frac{n(1+n)}{2}\) 个,处理起来仍然困难

原理

不难发现,区间 \([l,mid]\) 的和 \(+\) 区间 \([mid+1,r]\) 的和就等于区间 \([l,r]\) 的和

所以就考虑二进制拆分,即只关注二进制中 1 的位置,就可以表示所有 \([1,k]\) 的区间

形象的表示:

于是就得到了一棵树

我们发现,这并不是一颗完整的二叉树,但是却能表示出 \(1\) 到任意位的区间

观察发现,同一层区间的右端点二进制从后往前数第一个 1 的位置一样

那么如何合并这些区间呢?以 7 为例

只需要 \([1,4]\) \([5,6]\) \([7,7]\) 三个区间

仔细观察,发现每一次向上都是在从二进制最右位向左找下一个 1,并将这几个区间加起来

同理,在修改操作的时候需要将所有包含这一位的区间修改,也一样是找 1

那么如何快速找到下一个 1

计算机中一个数加上负号的表示就是将它取反再加 \(1\)

例如 \((72)_{10} = (1001000)_2\)\((-72)_{10} = (1011000)_2\)

那么取反后所有 0 变为了 1,而再加上 1 就会使第一个原来的1 后面的所有 0 变成的 1 变回 0,而那个原来的 1 会变回 1,而其他的还是 0

那么就可以用与运算找出第一个 1

定义 \(lowbit(x) = x\&(-x)\)

不难发现 \(x\) 的父节点为 \(x + lowbit(x)\),那么循环拆解就可以了

最后再运用前缀和的思想,通过 \([1,r] - [1,l-1]\) 得到 \([l,r]\)

建树,查询,修改的函数都很类似

void build(int n)//建树
{
    for(int i=1;i<=n;i++)
    {
        for(int j=i;j<=n;j+=lowbit(j))//j+=lowbit(j) 不断找父节点编号,直到 n 号,即整个数组
            tree[j]+=ary[i];//建树的时候每一个被覆盖的区间的和都要加上数组这一位的值
    }
}
int query(int x)//查询前 x 个元素的和
{
    int ans=0;
    for(int i=x;i;i-=lowbit(i))
        ans+=tree[i];//把每一段区间加起来
    return ans;
}

int ans(int l,int r)
{
    return query(r)-query(l-1);
}
void add(int x,int k)//将 x 号元素加上 k
{
    for(int i=x;i<=n;i+=lowbit(i))
        tree[i]+=k;
}

时间复杂度 \(O(m \log n)\)

例题:洛谷 P3374 【模板】树状数组 1

区间修改,单点查询

那么如果问题改变一下呢

问题:对于一个长度为 \(n\) 的数组,满足 \(m\) 次区间修改和单点查询

原理

我们发现,刚才的树状数组维护的是区间和,那么要把单点转化为区间和,而把 \(1\) 次区间修改变为 \(O(\log n)\)

先从区间修改考虑,如何把区间修改转换为单点

回顾以前的知识,差分数组可以通过前缀和得到原数组,而树状数组正好查询的时候是前缀和,那么只需要维护差分数组即可,将区间 \([l,r]\) 加上 \(k\) 转换为在 \(l\) 上加 \(k\),在 \(r+1\) 上减 \(k\)

实现

void build(int n)//建树
{
    for(int i=1;i<=n;i++)
    {
        for(int j=i;j<=n;j+=lowbit(j))//j+=lowbit(j) 不断找父节点编号,直到 n 号,即整个数组
            tree[j]+=ary[i];//建树的时候每一个被覆盖的区间的和都要加上数组这一位的值
    }
}
int query(int x)//查询差分数组前 x 个的前缀和即原数组的第 x 个
{
    int ans=0;
    for(int i=x;i;i-=lowbit(i))
        ans+=tree[i];//把每一段区间加起来
    return ans;
}
void add(int x,int k)//将 x 号元素加上 k
{
    for(int i=x;i<=n;i+=lowbit(i))
        tree[i]+=k;
}

void ADD(int l,int r,int k)//区间加 k
{
    add(l,k);
    add(r+1,-k);
}

例题: P3368 【模板】树状数组 2

区间修改,区间查询

引入

那么如果把问题改成

问题:对于一个长度为 \(n\) 的数组,满足 \(m\) 次区间修改和区间查询

我们会发现,在前两种情况中,查询或修改必定只有一个可以区间化而另一个只能单点操作

那么如何才能同时做到区间修改和区间查询呢?

推导

接着区间修改单点查询的情况,即维护原数组 \(ary\) 的查分数组 \(d\),我们发现要区间查询 \([1,k]\),写成数学式子就是

\[\sum^{k}_{i=1} ary_i \]

\[\sum^{k}_{i=1} \sum^{i}_{j=1} d_j \]

这样看来,需要两个循环才能求出区间和

那么如何消掉一个循环呢

我们不妨统计每一个 \(d_j\) 出现的次数:

\(i = 1\) 时只有 \(d_1\)

\(i = 2\) 时有 \(d_1,d_2\)

\(i = m\) 时有 \(d_1,d_2,\dots ,d_m\)

那么 \(d_j\) 出现了 \((k - i + 1)\)

即原式 \(=\)

\[\sum^{k}_{i=1} (k - i + 1)d_i \]

\((k - i + 1)\)\(k + 1\) 是常数,\(i\) 是变量

提出来

\[\sum^{k}_{i=1} (k + 1)d_i - \sum^{k}_{i=1} i \ast d_i \]

那么我们只需要在树状数组中维护 \(d_i\)\(i \ast d_i\) 即可

实现

写成以下代码

// 单点更新 tree1,维护 d[i]
void add1(int x, int k)
{
    for(int i=x;i<=n;i+=lowbit(i))
        tree1[i] += k;
}

// 单点更新 tree2,维护 d[i]*i
void add2(int x, int k)
{
    for(int i=x;i<=n;i+=lowbit(i))
        tree2[i] += k;
}

// 区间 [l, r] 统一加 k
void ADD(int l, int r, int k)
{
    add1(l, k);//差分,区间维护 d[i]
    add1(r + 1, -k);

    add2(l, l * k);//维护 d[i]*i
    add2(r + 1, -1 * (r + 1) * k);
}

// 建树,建两棵树
void build(int n)
{
    for(int i=1;i<=n;i++)
    {
        // 建树等价于向为 0 的点加上 ary[i]
        add1(i, ary[i]);
        add2(i, i * ary[i]);
    }
}
// 查询前缀 1~x 的总和
long long query(int x)
{
    long long sum1 = 0, sum2 = 0;
    for(int i=x;i;i-=lowbit(i))
        sum1 += tree1[i];
    for(int i=x;i;i-=lowbit(i))
        sum2 += tree2[i];
    return (x + 1) * sum1 - sum2; //带入刚才推导的式子
}

// 查询区间 [l, r] 的和
long long QRY(int l, int r)
{
    return query(r) - query(l - 1);
}

二维树状数组

区间查询,单点修改

引入

类似一维前缀和和二维前缀和,一维差分和二维差分,树状数组也可以写成二维的

类比一维树状数组把一维区间拆分成多个起点为 \(i\),长度为 \(lowbit(i)\) 的线段区间,二维树状数组把二维区间拆分为多个左上角是 \((x,y)\),长 \(lowbit(i)\),宽 \(lowbit(j)\) 的矩形区间

那么类似地,利用两层循环,分别遍历对应的 \(i,j\),并将所有区间都加起来得到 \((1,1)\)\((x,y)\) 的区间和

最后利用二维前缀和求 \((x_1,y_1)\)\((x_2,y_2)\),即

\[res = query(x_2,y_2) − query(x_1−1,y_2) − query(x_2,y_1−1) + query(x_1−1,y_1−1) \]

实现

void build(int n, int m)//建树
{
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=m;j++)
        {
            for(int p=i;p<=n;p+=lowbit(p))//p+=lowbit(p) 不断找父节点行号,直到 n 号,即整个数组
                for(int q=j;q<=m;q+=lowbit(q))//q+=lowbit(q) 不断找父节点列号,直到 m 号,即整个数组
                    tree[p][q] += ary[i][j];//建树的时候每一个被覆盖的区间的和都要加上数组这一位的值
        }
    }
}
void add(int x, int y, int k)//将 (x,y) 单点元素加上 k
{
    for(int i=x;i<=n;i+=lowbit(i))
        for(int j=y;j<=m;j+=lowbit(j))
            tree[i][j] += k;
}
int query(int x, int y)//查询左上角(1,1)到(x,y)的二维前缀和
{
    long long ans = 0;
    for(int i=x;i;i-=lowbit(i))
        for(int j=y;j;j-=lowbit(j))
            ans += tree[i][j];//把每一段二维区间加起来
    return ans;
}

// 查询矩形 [x1,y1] ~ [x2,y2] 区间和
int QRY(int x1, int y1, int x2, int y2)
{
    return query(x2, y2) - query(x1-1, y2) - query(x2, y1-1) + query(x1-1, y1-1);
}

区间修改,单点查询

原理

同样的,按照二维差分的的方式,对区间 \([(x_1,y_1),(x_2,y_2)]\) 整体加 \(k\) 只需要在差分数组上将 \((x_1,y_1),(x_2+1,y_2+1)\)\(k\)\((x_2+1,y_1),(x_1,y_2+1)\)\(k\) 即可

实现

void build(int n, int m)//建树
{
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=m;j++)
        {
            for(int p=i;p<=n;p+=lowbit(p))//p+=lowbit(p) 不断找父节点行号,直到 n 号,即整个数组
                for(int q=j;q<=m;q+=lowbit(q))//q+=lowbit(q) 不断找父节点列号,直到 m 号,即整个数组
                    tree[p][q] += ary[i][j];//建树的时候每一个被覆盖的区间的和都要加上数组这一位的值
        }
    }
}
// 矩形 [x1,y1] ~ [x2,y2] 区间整体加上 k
void add(int x1, int y1, int x2, int y2, int k)
{
    for(int i=x1;i<=n;i+=lowbit(i))
        for(int j=y1;j<=m;j+=lowbit(j))
            tree[i][j] += k;

    for(int i=x2+1;i<=n;i+=lowbit(i))
        for(int j=y1;j<=m;j+=lowbit(j))
            tree[i][j] -= k;

    for(int i=x1;i<=n;i+=lowbit(i))
        for(int j=y2+1;j<=m;j+=lowbit(j))
            tree[i][j] -= k;

    for(int i=x2+1;i<=n;i+=lowbit(i))
        for(int j=y2+1;j<=m;j+=lowbit(j))
            tree[i][j] += k;
}
// 查询单点 (x,y) 当前数值
int query(int x, int y)
{
    long long ans = 0;
    for(int i=x;i;i-=lowbit(i))
        for(int j=y;j;j-=lowbit(j))
            ans += tree[i][j];//累加二维差分前缀,得到单点原值
    return ans;
}

区间查询,区间修改

原理

前面的章节提到,一维树状数组实现区间查询,区间修改是维护两个值 \(d_i,d_i \ast i\) 并带入式子

\[\sum^{k}_{i=1} (k + 1)d_i - \sum^{k}_{i=1} i \ast d_i \]

那么类似的,我们写出二维树状数组在差分数组 \(d_{i,j}\) 区间查询 \([(1,1),(x,y)]\) 的式子

\[\sum^{x}_{i=1} \sum^{y}_{j=1} \sum^{i}_{k=1} \sum^{j}_{l=1} d_{k,l} \]

像一维树状数组区间查询,区间修改的推导一样,统计 \(d_{k,l}\) 出现的次数:

消掉 \(\sum^{j}_{l=1}\)\(\sum^{i}_{k=1}\) 原式得

\[\sum^{x}_{i=1} \sum^{y}_{j=1} d_{i,j} \ast (x+1-i) \ast (y+1-j) \]

\((x+1),(y+1),i,j\) 看做整体,将式子展开

\[\sum^{x}_{i=1} \sum^{y}_{j=1} d_{i,j} \ast [(x+1) \ast (y+1) - i \ast (y+1) - j \ast (x+1) + i \ast j] \]

因为 \((x+1),(y+1),(x+1) \ast (y+1)\) 是常量,那么我们要维护的就是 \(d_{i,j},d_{i,j} \ast i,d_{i,j} \ast j,d_{i,j} \ast i \ast j\)

实现

写成如下代码

void build(int n, int m)//建树
{
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=m;j++)
        {
            for(int p=i;p<=n;p+=lowbit(p))//p+=lowbit(p) 不断找父节点行号,直到 n 号,即整个数组
                for(int q=j;q<=m;q+=lowbit(q))//q+=lowbit(q) 不断找父节点列号,直到 m 号,即整个数组
                    tree[p][q] += ary[i][j];//建树的时候每一个被覆盖的区间的和都要加上数组这一位的值
        }
    }
}
// 二维树状数组四维护差分更新,即差分数组上单点加
void upd(int x, int y, int k)
{
    for(int i=x;i<=n;i+=lowbit(i))
        for(int j=y;j<=m;j+=lowbit(j))
        {
            tree1[i][j] += k;
            tree2[i][j] += x * k;
            tree3[i][j] += y * k;
            tree4[i][j] += x * y * k;
        }
}
// 矩形 [x1,y1] ~ [x2,y2] 区间整体加上 k
void add(int x1, int y1, int x2, int y2, int k)
{
    upd(x1, y1, k);
    upd(x1, y2+1, -k);
    upd(x2+1, y1, -k);
    upd(x2+1, y2+1, k);
}
// 查询左上角(1,1)到(x,y)二维前缀和
int query(int x, int y)
{
    long long ans = 0;
    for(int i=x;i;i-=lowbit(i))
        for(int j=y;j;j-=lowbit(j))
        {
            ans += (x+1)*(y+1) * tree1[i][j];
            ans -= (y+1) * tree2[i][j];
            ans -= (x+1) * tree3[i][j];
            ans += tree4[i][j];
        }
    return ans;
}
// 查询矩形 [x1,y1] ~ [x2,y2] 区间和
int QRY(int x1, int y1, int x2, int y2)
{
    return query(x2, y2) - query(x1-1, y2) - query(x2, y1-1) + query(x1-1, y1-1);
}

权值树状数组

洛谷 P1908 逆序对 为例题

朴素做法

每次枚举逆序对左端点 \(i\),在 \(i\) 的右边枚举右端点 \(j\),对比 \(a_i\)\(a_j\) 的值,如果是逆序对则记录答案

时间复杂度 \(O (n^2)\),炸完了

\(O (n \log n)\) 做法(归并排序)

这个做法在本文中不做详解

发现归并排序中合并两个有序序列的时候,每次对比两个序列的首个元素并且将大的加入当前答案

而左边的序列中所有元素的编号都比右边的小,所以记录对比时取左边序列的元素次数即可,时间复杂度 \(O (n \log n)\)

\(O (n \log n)\) 做法,权值树状数组

什么是权值树状数组

权值树状数组即维护权值数组的树状数组

权值数组记录每个元素在原数组出现的次数即权值数组 \(b_i = i\) 在数组 \(a\) 中出现的次数

思路

通过权值树状数组,我们就可以 \(O (\log n)\) 的时间内找到元素 \(1 \sim k\) 在数组中出现的次数

那么我们先令权值数组为空,从原数组的最后一个元素开始,依次累计比其小且已经加入权值数组的元素个数(因为从后往前加,已经加入的元素编号都比现在加的元素编号大)

可以手动模拟一下,以原数组 \(a = \{5,4,2,6,3,1\}\) 为例,数组 \(a,b\) 下标从 \(0\) 开始

1.\(b={0,0,0,0,0,0,0}\),取 \(a_5=1\)\(\sum^0_{i=0} b_i = 0\),当前逆序对个数为 \(0\),把 \(1\) 加入 \(b\)

2.\(b={0,1,0,0,0,0,0}\),取 \(a_4=3\)\(\sum^2_{i=0} b_i = 1\),当前逆序对个数为 \(1\),把 \(3\) 加入 \(b\)

3.\(b={0,1,0,1,0,0,0}\),取 \(a_3=6\)\(\sum^5_{i=0} b_i = 2\),当前逆序对个数为 \(3\),把 \(6\) 加入 \(b\)

4.\(b={0,1,0,1,0,0,1}\),取 \(a_2=2\)\(\sum^1_{i=0} b_i = 1\),当前逆序对个数为 \(4\),把 \(2\) 加入 \(b\)

5.\(b={0,1,1,1,0,0,1}\),取 \(a_1=4\)\(\sum^3_{i=0} b_i = 3\),当前逆序对个数为 \(7\),把 \(4\) 加入 \(b\)

6.\(b={0,1,1,1,1,0,1}\),取 \(a_0=5\)\(\sum^4_{i=0} b_i = 4\),当前逆序对个数为 \(11\),把 \(5\) 加入 \(b\)

综上所述,\(a\) 中逆序对有 \(11\)

实现

那么代码就可以写出来了

#include<bits/stdc++.h>
using namespace std;
#define int long long

int a[1000005],ct[1000005];

//单点修改区间查询树状数组
int tree[1000006];
inline int lowbit(int x)
{
	return x&(-x);
}
inline void add(int i,int x)
{
	for(int j=i;j<1000001;j+=lowbit(j))
		tree[j]+=x;
}
inline int sum(int n)
{
	int ans=0;
	for(int i=n;i;i-=lowbit(i))
		ans+=tree[i];
	return ans;
}
inline int qsum(int l,int r)
{
	return sum(r)-sum(l-1);
}

bool cmp(int x,int y)
{
	if(a[x]==a[y])return x>y;
	return a[x]>a[y];
}

signed main(void)
{
	int n,ans=0;
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		cin>>a[i];
		ct[i]=i;
	}
	sort(ct+1,ct+n+1,cmp);//数字会达到 1e9,需要离散化
	for(int i=1;i<=n;i++)
	{
		add(ct[i],1);//把 ct[i] 加入权值数组
		ans+=sum(ct[i]-1);//累计当前比 ct[i] 小的元素个数
	}
	cout<<ans;
	return 0;
}

相关的题

\(k\) 小元素
题面

给定一个序列的初始状态,维护若干次操作,每次修改序列中某个元素,或者查询序列中第 \(k\) 小的元素

解法

通过权值线段树可以 \(O(\log n)\) 查询小于 \(x\) 的元素个数,且查询到的答案满足单调不降,故可以二分答案找到该元素

离线树状数组

定义

离线树状数组是一种在所有操作已知的情况下,通过排序和离散化处理,实现高效区间统计和查询的技巧

离线树状数组,即在所有查询操作已知的情况下,将所有查询储存起来,再扫一遍得到所有答案

离线树状数组常搭配 CDQ 分治进行降维(本文不涉及)

例子

区间比 \(x\) 小的元素个数

原理

1.把询问的 \([l,r]\) 拆成 \([1,l-1]\)\([1,r]\),并且标记每一个区间是 \([1,l-1]\) 一类还是 \([1,r]\) 一类

2.将所有询问按照右端点从小到大排序,保证指针不回退,

3.从左往右依次加入数字并在询问处处理询问,保证元素一直加入,扫一遍就能得到所有答案

实现

#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int N = 100005;

int tree[N];

int lowbit(int x)
{
    return x & -x;
}

// 权值x位置计数+1
void add(int x)
{
    for(int i = x; i <= N; i += lowbit(i))
        tree[i]++;
}

// 查询 1~x 一共有多少个数
int query(int x)
{
    int sum = 0;
    for(int i = x; i > 0; i -= lowbit(i))
        sum += tree[i];
    return sum;
}

// 存储数组每个元素:下标位置、数值
struct Num
{
    int pos;
    int val;
}num[N];

// 存储离线询问
struct Query
{
    int pos;    // 查询到哪个位置(r 或 l-1)
    int limit;  // 小于这个数
    int id;     // 原来第几个询问
    int opt;    // 1加答案,-1减答案
}ask[200005];

int ans[N];
int n, m;   // n数组长度,m询问次数

// 按位置从小到大排序
bool cmpNum(Num a, Num b)
{
    return a.pos < b.pos;
}

bool cmpAsk(Query a, Query b)
{
    return a.pos < b.pos;
}

int main()
{
    cin >> n >> m;

    // 读入原数组
    for(int i = 1; i <= n; i++)
    {
        num[i].pos = i;
        cin >> num[i].val;
    }

    int cnt = 0;    // 询问总数计数

    // 把一个区间询问拆成两个前缀询问
    for(int i = 1; i <= m; i++)
    {
        int l, r, x;
        cin >> l >> r >> x;

        // 拆分区间 [l,r] < x  =  [1,r]<x  -  [1,l-1]<x
        cnt++;
        ask[cnt].pos = r;
        ask[cnt].limit = x - 1;//比 x 小即 [1,x-1] 范围
        ask[cnt].id = i;
        ask[cnt].opt = 1;

        cnt++;
        ask[cnt].pos = l - 1;
        ask[cnt].limit = x - 1;
        ask[cnt].id = i;
        ask[cnt].opt = -1;
    }

    // 排序数组、排序所有询问
    sort(num + 1, num + n + 1, cmpNum);
    sort(ask + 1, ask + cnt + 1, cmpAsk);

    int p = 0;  // 双指针,当前已经加入树的最后位置

    // 逐个处理所有离线询问
    for(int i = 1; i <= cnt; i++)
    {
        // 把所有位置 <= 当前询问pos 的数,全部加入树状数组
        while(p < n && num[p + 1].pos <= ask[i].pos)
        {
            p++;
            add(num[p].val);
        }

        // 计算贡献,累加答案
        int now = query(ask[i].limit);
        ans[ask[i].id] += now * ask[i].opt;
    }

    // 按输入顺序输出答案
    for(int i = 1; i <= m; i++)
        cout << ans[i] << endl;

    return 0;
}

可持久化树状数组

定义

可持久化树状数组是一种结合了树状数组和持久化技术的数据结构,能够在支持高效查询和修改的同时,保留每个历史版本的状态,即可以查询某个历史状态或在某个历史状态的基础上进行修改。这种数据结构在需要版本回溯或多版本访问的场景中非常有用

原理

采用动态开点的方式,每修改一次通过建立新的点和边只保存和当前修改有关的区间,其他的套用以前的区间

思想和实现都很像可持久化线段树,作为可持久化线段树的下位替代品,具体讲解见 可持久化线段树 一章

像线段树一样,可持久化树状数组将所有区间组织成一棵树,我们规定:节点 \(i\) 的左儿子存储在 \(i-\frac{lowbit(i)}{2}\),右儿子存储在 \(i+\frac{lowbit(i)}{2}\)

那么我们每一次修改从根节点出发,更新沿路上所有节点,直到找到对应目标元素的叶子节点

更新过的节点与修改无关的儿子就指向基础版本的对应节点的对应儿子(左,右儿子用 \(ls_i,rs_i\) 表示)

实现

#include <iostream>
using namespace std;
typedef long long ll;

const int MAXN = 1e5 + 5;
const int MAXNODE = MAXN * 40; // 动态开点内存池

inline int lowbit(int x) { return x & -x; }

// 动态开点线段树节点,ls/rs按 i-lowbit(i)/2、i+lowbit(i)/2 划分
struct Node 
{
    int ls, rs;
    ll val;
} tr[MAXNODE];
int tot = 0;
int root[MAXN]; // root[ver] 存每个版本BIT根

// 新建空节点,动态开点
int newnode() 
{
    tot++;
    tr[tot].ls = tr[tot].rs = 0;
    tr[tot].val = 0;
    return tot;
}

// build:初始化0号初始版本(全0)
int build(int l, int r) 
{
    int p = newnode();
    if (l == r) return p;
    int mid = (l + r) >> 1;
    tr[p].ls = build(l, mid);
    tr[p].rs = build(mid + 1, r);
    return p;
}

// 内部单点修改:基于pre节点复制,pos位置加k,值域[l,r]
int upd(int pre, int l, int r, int pos, ll k) 
{
    int p = newnode();
    tr[p] = tr[pre]; // 复制旧版本节点
    tr[p].val += k;
    if (l == r) return p;

    int low = lowbit(pos);
    int ls_bound = pos - low / 2;
    int rs_bound = pos + low / 2;
    int mid = (l + r) >> 1;

    // 按题目规定左右儿子分界判断走左/右子树
    if (pos <= ls_bound)
        tr[p].ls = upd(tr[pre].ls, l, mid, pos, k);
    else if (pos >= rs_bound)
        tr[p].rs = upd(tr[pre].rs, mid + 1, r, pos, k);
    return p;
}

// update:对外接口,BIT单点x加k,生成新版本ver,数组上界n
void update(int ver, int x, ll k, int n) 
{
    int pre = root[ver - 1];
    // 和你add1/add2完全一致的BIT向上循环
    for (int i = x; i <= n; i += lowbit(i)) 
    {
        pre = upd(pre, 1, n, i, k);
    }
    root[ver] = pre;
}

// 内部查询:节点p,查询[1,pos]前缀和
ll qry(int p, int l, int r, int pos)
 {
    if (!p || r <= pos) return tr[p].val;
    int low = lowbit(pos);
    int ls_bound = pos - low / 2;
    int rs_bound = pos + low / 2;
    int mid = (l + r) >> 1;
    ll res = 0;

    if (pos <= ls_bound)
        res += qry(tr[p].ls, l, mid, pos);
    else if (pos >= rs_bound)
        res += tr[tr[p].ls].val + qry(tr[p].rs, mid + 1, pos);
    else
        res += tr[tr[p].ls].val;
    return res;
}

// query:对外接口,查询版本ver下前缀[1,x]和
ll query(int ver, int x, int n) 
{
    ll ans = 0;
    // 标准BIT向下拆分求和
    for (int i = x; i; i -= lowbit(i)) 
    {
        ans += qry(root[ver], 1, n, i);
    }
    return ans;
}

// 区间查询 [l, r] 版本ver总和
inline ll range_query(int ver, int l, int r, int n)
{
    return query(ver, r, n) - query(ver, l - 1, n);
}

会发现和可持久化线段树很相似

树状数组套树状数组(BIT 套 BIT)

引入

我们返回来再看二位树状数组,我们每一维都开了 \(n\) 大小的空间,那么二维就是 \(O(n^2)\) 的空间复杂度,对于一些数据量大的问题很容易 \(MLE\)

所以就会引入一种新的东西:树状数组套树状数组

原理

我们发现,其实 \(n^2\) 大小的二维树状数组中有很多节点是没有访问过的,那么把这些节点开出来很浪费空间

所以我们就可以想到动态开点,把第一维树状数组的每一个节点都延伸出一棵新的树状数组

那么只有被访问过的内层节点才创建,空间 \(O(n \log_2 n)\),单次插入 / 查询 \(O(\log n \log V)\),其中 \(V\) 为第二维范围

这里我们通过 \(vector\) 实现动态开点,分别实现内层树状数组修改,外层修改,内层查询,外层查询

其中内层的实现和普通的树状数组很相似,外层实现就是沿路将对应的内层区间的查询结果加起来

这里放上图示

那么我们要查询红色框的部分就是查询黄色框部分之和

也就是查询 \([(1,1),(x,y)]\),转化为求 \(\sum^x_{i=1} Tree[i][1 \sim y]\)

实现

// 内层普通BIT单点加
void innerAdd(vector<int>& tr, int y, int val) 
{
    for (; y <= maxY; y += lowbit(y)) 
    {
        if (y >= tr.size()) tr.resize(y + 1, 0);
        tr[y] += val;
    }
}

// 内层普通BIT前缀查询
int innerQuery(vector<int>& tr, int y) 
{
    int res = 0;
    for (; y; y -= lowbit(y)) 
    {
        if (y < tr.size()) res += tr[y];
    }
    return res;
}

// 外层BIT单点更新 (x,y) += val
void add(int x, int y, int val) 
{
    for (; x <= MAX_X - 1; x += lowbit(x)) 
    {
        innerAdd(tree[x], y, val);
    }
}

// 查询左上角(1,1)到(x,y)矩形总和
int query(int x, int y) 
{
    int res = 0;
    for (; x; x -= lowbit(x)) 
    {
        res += innerQuery(tree[x], y);
    }
    return res;
}

// 查询任意矩形 [x1,y1] ~ [x2,y2]
int rectQuery(int x1, int y1, int x2, int y2) 
{
    return query(x2, y2) - query(x1 - 1, y2) - query(x2, y1 - 1) + query(x1 - 1, y1 - 1);
}

线段树(单点修改,区间查询)

引入

我们前面提到的树状数组,基础形式简单,但是要变形却不容易(例如变为区间修改单点查询需要考虑查分),而且因为求任意区间 \([x,y]\) 需要求 \([1,x-1],[1,y]\) 的前缀和,存在重复计算的部分(即使可以通过改进算法,使计算区间先向 \(r\) 逼近,再向 \(l\) 逼近),导致面对大规模数据运行会偏慢

那么我们就会引入一种更加万能,易懂(没有 \(lowbit\),理解容易),但是更难写的算法 —— 线段树

原理

在树状数组中,我们通过 \(lowbit\),进行二进制拆分,从而将大区间拆成数个小区间

而线段树采用了分治的思想(可能吧),每次将一个大区间平分,再平分,直到变成叶子节点(即区间长度为 \(1\)),即令 \(mid=(l+r)/2 ,[l,r] = [l,mid] + [mid+1,r]\)

如图

考虑如何将这样的一棵树存进数组里,因为这是一棵 二叉树 ,也就是一个节点最多有两个儿子,所以我们就规定,\(i\) 的左儿子在 \(i \ast 2\),右儿子在 \(i \ast 2 + 1\)

注意,线段树中节点的编号和它代表的区间没什么关系

我们来证明一下不会发生冲突

假如节点 \(j\) 的右儿子和 \(i\) 的左儿子发生了冲突,即 \(j \ast 2 + 1 = i \ast 2\)

那么 \(i = j + \frac{1}{2}\)

因为 \(i,j\) 为整数,所以冲突的情况不成立

首先我们要建树

我们从根节点出发,依次便利这个节点的左右儿子,直到找到叶子节点,把叶子节点的值赋为对应的原数组上的值

叶子节点赋值好了,但是非叶子节点所代表的区间和怎么办呢?我们知道,\([l,r] = [l,mid] + [mid+1,r]\),那么在处理完两个儿子之后,再把这两个儿子的区间和代回这个式子,就可以得到这个节点的区间和

我们称这个操作叫做 \(pushup\)

那么我们如何单点修改呢?

以求区间和为例

很显然,我们需要 从根节点出发,每次判断目标 \(x\) 在当前节点的左儿子中还是右儿子中,并继续递归下去,直到叶子节点,做出修改

那么我们又会发现,修改了一个叶子节点以后,包含它的区间的和也改变了(即从根节点出发找这个叶子节点的路径上的所有节点)

所以要对于路径上的所有节点进行 \(pushup\)

那我们再看看如何进行区间查询

不难发现,线段树上不一定有刚好被完全覆盖的区间,如 \([1,7]\) 在上图的线段树中没有

那么来拆分一下这个区间,使每个拆分出来的区间尽量大

\([1,7] = [1,4] + [5,6] + [7,7]\)

也就是说,对于每个节点,如果这个节点对应的区间 \([l,r]\) 被目标区间 \([L,R]\) 包含,即 \(L \leq l \And \And r \leq R\),那么就不必往下找了,直接返回当前区间和就行(反正上面一级会把答案加起来)

如果没有完全包含,那么就要去左右两个儿子寻找被包含的部分。考虑是被左儿子包含(\(L \leq mid\)),被右儿子包含(\(mid+1 \leq R\)),还是均包含(所以区别于单点修改,这里要用 if-if 语句而不是 if-else),并且把两个儿子的答案累计起来(对于求区间和就是把它们加起来)

单次操作时间复杂度 \(O(\log n)\)

注意,线段树的数组一般需要开 \(4\) 倍原数组大小来存下一棵树

实现

那么如果没有看懂,就看看代码上的注释

int n,a[100005<<2],tree[100005<<2];//因为要涉及 i*2+1,所以要开大一点(一般 4 倍)

inline void pushup(int idx)//pushup,上传 idx 的两个儿子的数据
{
    tree[idx]=tree[idx*2]+tree[idx*2+1];
}

void build(int l,int r,int idx)//建树,当前节点为 idx,对应区间 [l,r]
{
    if(l==r)//到了叶子节点
    {
        tree[idx]=a[l];//叶子节点赋值
        return;
    }
    int mid=(l+r)>>1;
    build(l,mid,idx*2);//建树要把两边都遍历
    build(mid+1,r,idx*2+1);
    pushup(idx);//处理完两个儿子以后再 pushup
}

void add(int l,int r,int x,int k,int idx)//当前节点 idx,对应区间 [l,r],目标位置(原数组下标)x,将它加上 k
{
    if(l==r)
    {
        tree[idx]+=k;//找到了目标叶子节点
        return;
    }
    int mid=(l+r)>>1;
    if(x<=mid)add(l,mid,L,R,k,idx*2);//如果目标在左子树
    if(mid+1<=x)add(mid+1,r,L,R,k,idx*2+1);//如果目标在右子树
    pushup(idx);//修改玩要 pushup
}

int query(int l,int r,int L,int R,int idx)//当前节点 idx,对应区间 [l,r],要查询 [L,R]
{
    if(L<=l&&r<=R)return tree[idx];//被目标区间包含
    int ans=0;
    int mid=(l+r)>>1;
    if(L<=mid)ans+=query(l,mid,L,R,idx*2);//如果左子树包含了一部分
    if(mid+1<=R)ans+=query(mid+1,r,L,R,idx*2+1);//如果右子树包含了一部分
    return ans;//把左右儿子的答案加起来
}

线段树(区间修改)

引入

那么我们来看看线段树如何实现区间修改

我们知道,线段树的单点修改,是从根节点出发,一直走下去,找到对应的叶子结点,时间复杂度 \(O(\log n)\)

那么如果我们想区间修改呢?那就相当于把这个对应区间的一整棵子树都要遍历一遍,时间复杂度会达到 \(O(n \log n)\)

很显然,暴力修改这个数组的时间复杂度都是 \(O(n)\),这样区间修改和暴力修改原数组再重新建树没什么区别

那么理论上,线段树没法有更好的区间修改时间复杂度

那么就得用一些小寄巧来优化一下

原理

我们发现,很多时候对于线段树的操作都不是一次查询一次修改的,而是多次修改以后再查询

那么我们就可以考虑将这些修改合并起来,即先不执行这些修改,直到有关这个区间操作的时候再一起执行

那么如何合并呢?我们可以先在将要修改的区间在树上拆分成最大的几个区间进行修改,并打上标记,然后就不再遍历子树,直接不管了

其中修改当前区间的和需要乘上区间长度,形式化的讲,将节点 \([l,r]\) 加上 \(k\),就需要将这个节点的值加上 \(k\ast(r-l+1)\)

到查询的时候我们再把要查询的区间里带标记的子树进行修改,每次将标记下发到两个子节点上,并更新子节点的值,然后不要忘了清空这个点的标记

我们称这个标记叫做 懒惰标记(LazyTag),下发的操作叫做 pushdown

还是这棵线段树

第一次修改对区间 \([3,6]\) 加上 \(2\)

那么我们对节点 \([3,4],[5,6]\) 打上 \(2\) 的懒惰标记,并且将这两个节点加上 \(2\ast 2 = 4\)

第二次修改对区间 \([5,8]\) 区间加上 \(1\)

对节点 \([5,8]\) 打上 \(1\) 的标记,并将这个节点加上 \(1\ast 4=4\)

最后我们查询 \([5,8]\) 之前将和 \([5,8]\) 有关的标记下发下来

得到区间和为 \(34\)

实现

#include<bits/stdc++.h>
using namespace std;
#define int long long

int n,m,a[100005<<4],tree[100005<<4],lazy[100005<<4];

inline void pushup(int idx)
{
    tree[idx]=tree[idx*2]+tree[idx*2+1];
}

inline void pushdown(int l,int r,int idx)//下发标记
{
    if(lazy[idx]==0)return;
    int mid=(l+r)>>1;
    lazy[idx*2]+=lazy[idx];
    lazy[idx*2+1]+=lazy[idx];//把标记下发到子节点上
    tree[idx*2]+=lazy[idx]*(mid-l+1);
    tree[idx*2+1]+=lazy[idx]*(r-(mid+1)+1);//修改要乘上区间长度,才是对区间和的修改
    lazy[idx]=0;//清空当前的标记
}

void build(int l,int r,int idx)//建树
{
    if(l==r)
    {
        tree[idx]=a[l];
        return;
    }
    int mid=(l+r)>>1;
    build(l,mid,idx*2);
    build(mid+1,r,idx*2+1);
    pushup(idx);
}

void add(int l,int r,int L,int R,int k,int idx)//修改
{
    if(L<=l&&r<=R)
    {
        tree[idx]+=k*(r-l+1);
        lazy[idx]+=k;
        return;
    }
    pushdown(l,r,idx);//有关这个区间的所有操作都要下发标记
    int mid=(l+r)>>1;
    if(L<=mid)add(l,mid,L,R,k,idx*2);
    if(mid+1<=R)add(mid+1,r,L,R,k,idx*2+1);
    pushup(idx);
}

int query(int l,int r,int L,int R,int idx)
{
    if(L<=l&&r<=R)return tree[idx];
    pushdown(l,r,idx);//查询之前下发标记
    int ans=0;
    int mid=(l+r)>>1;
    if(L<=mid)ans+=query(l,mid,L,R,idx*2);
    if(mid+1<=R)ans+=query(mid+1,r,L,R,idx*2+1);
    return ans;
}

signed main(void)
{
    cin>>n>>m;
    for(int i=1;i<=n;i++)
        cin>>a[i];
    build(1,n,1);
    while(m--)
    {
        int op;
        cin>>op;
        if(op==1)
        {
            int x,y,k;
            cin>>x>>y>>k;
            add(1,n,x,y,k,1);
        }
        else
        {
            int x,y;
            cin>>x>>y;
            cout<<query(1,n,x,y,1)<<endl;
        }
    }
}

例题

洛谷 P3372 【模板】线段树 1

洛谷 P3373 【模板】线段树 2

动态开点线段树

引入

众所周知,普通的线段树需要在程序初始化的时候执行 \(build()\) 函数来建树,需要 \(4 \ast N\) 的空间大小

但是有些题目原数组大小非常大,以至于开 \(4\) 倍会导致空间超限,而实际上查询和修改的区间并没有这么大

那么很显然,这些从始至终都没有访问过的节点就被浪费了

因此我们要引入动态开点线段树

原理

那么如何处理这些没有用的节点呢?很明显我们根本就不需要建立这些节点

所以我们一开始就不建树,在访问时遇到没有建立的节点再建立这个点

但是前面提到的线段树都是 \(i\) 的左儿子在 \(i\ast 2\),右儿子在 \(i\ast 2+1\),是固定的,无论是否开某个点,整个数组的大小都不会变

所以我们要优化这种左右儿子的逻辑,考虑用指针(这里指的是一个下标数组,表示某个节点的儿子的下标),我们用 \(ls_i\) 表示 \(i\) 的左儿子的编号,\(rs_i\) 表示 \(i\) 的右儿子的编号

我们在操作中发现要访问一个节点 \(u\) 的某个儿子为空时,就创建一个新的节点,并将 \(u\) 的对应儿子的指针指向这个新节点,新的节点的左右儿子的指针都暂时为空(因为如果一个节点已经创建,那么它的祖先节点都已经创建)

因为是动态开点,没有传统线段树的左右儿子计算,所以也不需要传 \(idx\) 这个参数

实现

那么我们写成代码

int cnt;//当前的节点总数
struct node
{
    int val,ls,rs;//val:存储的值 ls:左儿子编号 rs:右儿子编号
}tree[N];

void pushup(int u)//上传的逻辑还是和普通线段树一样
{
    tree[u].val=tree[tree[u].ls].val+tree[tree[u].rs].val;//如果这个儿子是空的,ls/rs会自然指向 0,tree[0].val 的值仍然为 0,不影响计算
}

int NewNode(void)//开新的节点,返回开的节点的编号
{
    cnt++;
    tree[cnt].val=0;
    tree[cnt].ls=tree[cnt].rs=0;//初始左右儿子都是空的
}

// u:当前节点 [l,r] pos:单点修改位置 c:增加的值
void update(int &u,int l,int r,int pos,int c)//这里把 u 取地址是为了以后如果创建新节点就可以直接把儿子指向这个新节点
{
    if(!u)u=newnode(); // 节点不存在则新建
    tr[u].val+=c;
    if(l==r)return;
    int mid=(l+r)/2;
    if(pos<=mid)update(tree[u].ls,l,mid,pos,c);
    else update(tree[u].rs,mid+1,r,pos,c);
    pushup(u);
}

int query(int u,int l,int r,int L,int R){
    if(!u)return 0; // 没有节点,区间全0
    if(L<=l&&r<=R)return tree[u].val;
    int mid=(l+r)/2;
    lint res = 0;
    if(L<=mid) res+=query(tree[u].ls,l,mid,L,R);
    if(R>=mid+1) res+=query(tree[u].rs,mid+1,r,L,R);
    return res;
}

例题

洛谷 U503807 【模板】动态开点线段树

洛谷 P13825 【模板】线段树 1.5

CF915E Physical Education Lessons

洛谷 P4588 [TJOI2018] 数学计算

可持久化线段树

我们之前讲过可持久化树状数组,但是因为其逻辑和可持久化线段树更相似,所以当时并没有细讲其实现,故可持久化线段树的原理和实现会在这一节详细讲解

回顾一下可持久化的定义

可以在任何时候访问以前的版本,或者在某一历史版本上进行修改

原理

朴素做法

考虑把 \(t\) 个历史版本的线段树都储存下来,每次产生新的版本时就把这个版本先全部复制一份,作为一棵新的树,然后在这棵新的树上修改

时间复杂度 \(O(t\ast n\log n)\),空间复杂度 \(O(t \ast n\log n)\),明显过大

可持久化线段树做法

我们发现,每次修改都会产生新的版本,而其中修改的部分却只有从根到叶子节点的一条链,那么复制一整棵树实在是浪费,所以我们考虑只重新新建这一条链上的节点,其他的部分直接套用原来的树

假如说我们要修改这条链

img

那么我们就只新建这几个节点的副本

img

但是我们发现这样也和这棵树没什么关系,还只是一条孤独的链,没办法从这条链遍历到这棵树

那么我们把这个链上的每个节点连上它对应的儿子

img

我们发现这样从我们新建的节点出发,就能遍历整棵新版本的树

因此我们可以通过根节点的编号来表示这个版本

既然要新建节点,那么就要用到上一节提到的动态开点线段树

实现

#include<bits/stdc++.h>
using namespace std;
int tree[26000006];//因为可持久化要新加点,所以要开到 6*点数+20*操作数
int ary[1000006];
int n,m,cnt=0,verson=0;
int ls[26000006],rs[26000006];
int root[1000006];//表示某个版本的根节点

int build(int l,int r)//建树
{
    int now=++cnt;
    if(l==r)
    {
        tree[now]=ary[l];
        return now;
    }
    int mid=(l+r)>>1;
    ls[now]=build(l,mid);
    rs[now]=build(mid+1,r);
    return now;
}

int update(int l,int r,int tgt,int c,int now)//修改
{
    int nNode=++cnt;//一路上复制节点
    ls[nNode]=ls[now];rs[nNode]=rs[now];//新的节点的左右儿子要指向原来的左右儿子
    if(l==r)
    {
        tree[nNode]=c;
        return nNode;
    }
    int mid=(l+r)>>1;
    if(tgt<=mid)ls[nNode]=update(l,mid,tgt,c,ls[nNode]);//如果某个儿子也是新的节点就要指过去
    else rs[nNode]=update(mid+1,r,tgt,c,rs[nNode]);
    return nNode;
}

int query(int l,int r,int tgt,int now)
{
    if(l==r)
        return tree[now];
    int mid=(l+r)>>1;
    if(tgt<=mid)
        return query(l,mid,tgt,ls[now]);
    else
        return query(mid+1,r,tgt,rs[now]);
}

signed main(void)
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin>>n>>m;
    for(int i=1;i<=n;i++)
        cin>>ary[i];
    root[0]=build(1,n);//我们令初始版本为版本 0
    for(int i=1;i<=m;i++)
    {
        int v,op;
        cin>>v>>op;
        if(op==1)//修改
        {
            int p,c;
            cin>>p>>c;
            root[++verson]=update(1,n,p,c,root[v]);
        }
        if(op==2)//查询
        {
            int p;
            cin>>p;
            cout<<query(1,n,p,root[v])<<"\n";
            root[++verson]=root[v];
        }
    }
    return 0;
}

持续更新中

posted @ 2026-07-15 14:14  迪杰小凤凰  阅读(18)  评论(0)    收藏  举报