数据结构进阶·其二:用简洁的树状数组处理动态区间问题及两种常见变形

在算法问题中,我们常常需要处理一个数组的区间修改与查询:比如给某个区间加上一个值,或者求某个区间的和。如果数组是静态的,前缀和就能很好地解决问题;但一旦需要支持动态修改,每次修改后重新计算前缀和的代价就太高了。

树状数组,正是为解决这类"动态区间操作"而生的数据结构。它利用二进制中最低位 111 的性质,将线性数组组织成一棵隐式的树,使得单点修改和前缀查询都只需要 O(log⁡n)O(\log n)O(logn) 的时间。相比于线段树,树状数组代码更简短、常数更小、占用空间更少,是竞赛中处理可差分信息(如和、异或、乘积等)的首选工具。

1、树状数组的引入

(1)、树状数组的介绍

树状数组是一种动态的数据结构,可以支持修改和查询,其主要是支持一些可差分 的信息。

可差分也就是:知道一个区间和他子区间的信息,可以得出剩余区间的信息

例如 1,51,51,5 的乘积为 404040,2,32,32,3 的乘积为 888,那么剩下区间的乘积就是 555,那么乘积就是可差分的。

再例如 1,51,51,52,32,32,3 的最大值都是 888,你不能确定剩下区间的最大值,也就是说最大值是不可差分的。

(2)、树状数组的优缺点

树状数组与线段树都是 O(log⁡n)O(\log n)O(logn) 的查询复杂度,两者都支持动态修改和查询。

但是,树状数组相比线段树代码简单,空间占用小,常数时间较优 ,同时维护信息的类型有限,而线段树就显得更万能。

2、树状数组的原理与实现

(1)、单点增加,范围查询类

这是最经典的树状数组,首先,我们以范围求和 为例,陈述一下他的基本原理:

假设原数组是 vvv,我们把原数组两两合并,例如 12,34,56...12,34,56...12,34,56...,然后再进行两两合并,例如 1234,56781234,56781234,5678,再进行两两合并...一直到不能再合并,注意这里的合并必须是两个区间长度相等 的区间,那么树状数组里的每个值就存储以他为最右值的最长区间 的总和,例如 tree1=v1tree1 = v1tree1=v1,tree4=sum(v(1,4))tree4 = sum(v(1,4))tree4=sum(v(1,4)),tree12=sum(v(9,12))tree12 = sum(v(9,12))tree12=sum(v(9,12)),这样恰好可以让尽可能少的子区间记录出每个区间的值(根据可差分性)。

注意:由于这种特殊性质,数组下标必须从 111 开始。

我们首先需要明确:树状数组每个值包含哪个区间的信息,通过总结规律,我们发现,iii 的左端点是他的二进制去掉最左侧的 111 再加 111 ,例如 121212 的二进制是 011000110001100,去掉最左侧的 111 是 010000100001000,再加 111 是 010010100101001,也就是 999,他的区间就是 9,129,129,12

那么如何根据这个算出区间和呢,比如求 1−121-121−12 的和,我们根据 tree12tree12tree12 算出 999 到 121212 的值,再根据上述原理,121212 减去最左位的 111,就是左端点 −1-1−1 ,也就是 888,以此类推,一直到右端点小于等于 000,那么求 lll 到 rrr 的和就是 111 到 rrr 的和减去 111 到 l−1l-1l−1 的和,同时定义 111 到 000 的和是 000。也就是 tree0=0tree0 = 0tree0=0。

那么如何进行单点修改呢,我们发现,树状数组里包括 iii 的下标,可以用 iii 一直加上最左侧的 111 得到 ,例如包含 333 的,333 是 000110001100011,加最左侧 111 分别是 00100,01000,1000000100,01000,1000000100,01000,10000,也就是 4,8,164,8,164,8,16,只要让他们一起加 valvalval 即可,同时输入时,也通过包含 iii 的下标加 vvv 的值来直接构建树状数组。

那么上面一直频繁调用最左侧的 111,怎么求呢?我们首先知道,计算机编码的时候,把 −x-x−x 当做 xxx 取反再加 111 ,那么取 x & -x 正好就是最左侧的 111,例如原来 xxx 等于 010111010001011101000101110100,−x-x−x 就是 101000101110100010111010001011 再加 111,就是 101000110010100011001010001100,和 xxx 取且,就是 000000010000000001000000000100,这样就得到了最左侧的 111。

例题链接:https://www.luogu.com.cn/problem/P3374

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
using  ll = long long;
const int N=2e5,LOG = 19;
int n;
vector<int> tree;

int lowBit(int i) {
    return i & -i;
}

void add(int i,int v) {
    while (i <= n) {
        tree[i] += v;
        i += lowBit(i);
    }
}

int sum(int i) {
    int ans = 0;
    while (i > 0) {
        ans += tree[i];
        i -= lowBit(i);
    }
    return ans;
}

int range(int l,int r) {
    return sum(r) - sum(l-1);
}

signed main() {
    ios::sync_with_stdio(false);cin.tie(0);
    int m,x,y,k,op,val;
    cin >> n >> m;
    tree.resize(n+1);
    for (int i = 1 ; i <= n ; i++) {
        cin >> val;
        add(i,val);
    }
    while (m--) {
        cin >> op;
        if (op == 1) {
            cin >> x >> k;
            add(x,k);
        }else if (op == 2) {
            cin >> x >> y;
            cout << range(x,y) << "\n";
        }
    }
    return 0;
}

(2)、范围增加,单点查询类

要想实现范围增加,如果再像之前一样逐个点修改,假设区间长度为 mmm,那么修改一次的时间复杂度就是 O(mlog⁡n)O(m \log n)O(mlogn),这当然是不可接受的。

于是,我们选择用树状数组维护差分数组 的信息,那么在 l,rl,rl,r 区间进行增加操作时只需要对 lll 和 r+1r+1r+1 位置分别进行 +v+v+v 和 −v-v−v 就可以了。

如果我们需要求某个点 xxx 的值,根据差分数组的性质,只需要求 tree1tree1tree1 到 treextreextreex 的总和就行,也就是 sum(x)

根据上面的信息,我们不需要修改原树状数组,只需要修改主函数,尤其是构建时传入的值为 add(i, arri-arri-1)

例题链接 https://www.luogu.com.cn/problem/P3368

示例代码如下

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
using  ll = long long;
const int N=2e5,LOG = 19;
int n;
vector<ll> tree;

int lowBit(int i) {
    return i & -i;
}

void add(int i,ll v) {
    while (i <= n) {
        tree[i] += v;
        i += lowBit(i);
    }
}

ll sum(int i) {
    ll ans = 0;
    while (i > 0) {
        ans += tree[i];
        i -= lowBit(i);
    }
    return ans;
}

ll range(int l,int r) {
    return sum(r) - sum(l-1);
}

signed main() {
    ios::sync_with_stdio(false);cin.tie(0);
    int m,l,r,x,y,op;
    cin >> n >> m;
    vector<int> arr(n+1,0);
    tree.resize(n+1);
    for (int i = 1 ; i <= n ; i++) {
        cin >> arr[i];
        add(i,arr[i]-arr[i-1]);
    }
    while (m--) {
        cin >> op;
        if (op == 1) {
            cin >> l >> r >> x;
            add(l,x);
            add(r+1,-x);
        }else if (op == 2) {
            cin >> y;
            cout << sum(y) << "\n";
        }
    }
    return 0;
}

(3)、范围增加,范围查询类

此类问题适合线段树处理,但树状数组仍有代码量小等优点,但原理较之前较复杂。

我们还是用差分数组来维护,那么范围增加就像第二类一样,控制树状数组两边的值就可以。

那么范围查询该怎么办呢?

要求 111 到 nnn 的总和,也就是求 ∑i=1nvi\sum_{i=1}^{n} v_i∑i=1nvi,我们知道 vi=∑j=1idjv_i = \sum_{j=1}^{i} d_jvi=∑j=1idj,vvv 是原数组,ddd 是差分数组,那么:

∑i=1nvi=d1+(d1+d2)+⋯=∑i=1n(n−i+1)⋅di=n⋅∑i=1ndi−∑i=1n(i−1)⋅di \sum_{i=1}^{n} v_i = d_1 + (d_1 + d_2) + \dots = \sum_{i=1}^{n} (n-i+1) \cdot d_i = n \cdot \sum_{i=1}^{n} d_i - \sum_{i=1}^{n} (i-1) \\cdot d_i i=1∑nvi=d1+(d1+d2)+⋯=i=1∑n(n−i+1)⋅di=n⋅i=1∑ndi−i=1∑n(i−1)⋅di

我们只需要用两个树状数组 分别维护 did_idi 和 (i−1)⋅di(i-1) \cdot d_i(i−1)⋅di 就可以。

sum(n)=n×sum1(n)−sum2(n)sum(n) = n \times sum1(n) - sum2(n)sum(n)=n×sum1(n)−sum2(n)

range(l,r)=sum(r)−sum(l−1)=r×sum1(r)−sum2(r)−(l−1)×sum1(l−1)+sum2(l−1)range(l,r) = sum(r) - sum(l-1) = r \times sum1(r) - sum2(r) - (l-1) \times sum1(l-1) + sum2(l-1)range(l,r)=sum(r)−sum(l−1)=r×sum1(r)−sum2(r)−(l−1)×sum1(l−1)+sum2(l−1)

那么 add 在处理 tree2 时就要特殊处理,由于 tree2 是在 tree1 的基础上乘了 (i−1)(i-1)(i−1),所以只需要在 lll 加上 (l−1)×v(l-1) \times v(l−1)×v,在 r+1r+1r+1 减去 r×vr \times vr×v,就能保证依旧满足 d2i=(i−1)⋅d1id2_i = (i-1) \cdot d1_id2i=(i−1)⋅d1i,比如 d1l=d+vd1_l = d + vd1l=d+v,d2l=(l−1)d+(l−1)v=(l−1)⋅d1ld2_l = (l-1)d + (l-1)v = (l-1) \cdot d1_ld2l=(l−1)d+(l−1)v=(l−1)⋅d1l。

例题链接(其实是线段树的板子):https://www.luogu.com.cn/problem/P3372

示例代码如下

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
using  ll = long long;
const int N=2e5,LOG = 19;
int n;
vector<ll> tree1;
vector<ll> tree2;

int lowBit(int i) {
    return i & -i;
}

void add(int i,ll v,vector<ll>& tree) {
    while (i <= n) {
        tree[i] += v;
        i += lowBit(i);
    }
}

ll sum(int i,vector<ll>& tree) {
    ll ans = 0;
    while (i > 0) {
        ans += tree[i];
        i -= lowBit(i);
    }
    return ans;
}

ll range(int l,int r) {
    return r * sum(r,tree1) - sum(r,tree2) - (l-1) * sum(l-1,tree1) + sum(l-1,tree2);
}

signed main() {
    ios::sync_with_stdio(false);cin.tie(0);
    ll m,x,y,k,op;
    cin >> n >> m;
    vector<ll> arr(n+1,0);
    tree1.resize(n+1);
    tree2.resize(n+1);
    for (int i = 1 ; i <= n ; i++) {
        cin >> arr[i];
        add(i,arr[i]-arr[i-1],tree1);
        add(i,(arr[i]-arr[i-1])*(i-1),tree2);
    }
    while (m--) {
        cin >> op;
        if (op == 1) {
            cin >> x >> y >> k;
            add(x,k,tree1);
            add(y+1,-k,tree1);
            add(x,k*(x-1),tree2);
            add(y+1,-k*y,tree2);
        }else if (op == 2) {
            cin >> x >> y;
            cout << range(x,y) << "\n";
        }
    }
    return 0;
}

总结

树状数组是一种精巧且高效的数据结构,核心是利用二进制位的性质将区间信息压缩到对数级别的节点中。根据应用场景的不同,树状数组主要有三种形态:

  • 单点增加 + 范围查询 :最经典的形式,直接维护原数组信息。修改时沿着下标不断加上 lowbit\text{lowbit}lowbit 向上更新,查询时沿着下标不断减去 lowbit\text{lowbit}lowbit 累加前缀和。
  • 范围增加 + 单点查询:通过维护差分数组,将区间修改变成两个单点修改,查询时求差分的前缀和即可得到原值。
  • 范围增加 + 范围查询 :用两个树状数组分别维护差分值和差分值乘以 (i−1)(i-1)(i−1) 的项,从而将区间修改和区间求和都维持在 O(log⁡n)O(\log n)O(logn) 的复杂度。

在实际解题中,遇到"区间修改 + 区间查询"时,线段树往往更通用,但树状数组凭借其常数小、码量少的优势,在维护可差分信息时仍是不错的选择。理解lowbit的含义以及前缀和与差分的转化关系,是灵活运用树状数组的关键。

复制代码
相关推荐
不瘦80斤不改名1 小时前
05-vibe-coding-向agentic-engineering演进
人工智能·笔记·python·prompt
Smoothcloud润云1 小时前
GPU租赁数据安全怎么做?
人工智能·算法·ai·aigc·gpu算力·gpu
呜喵王阿尔萨斯1 小时前
extern “C“ in C++
c语言·c++·算法
乐观勇敢坚强的老彭1 小时前
C++信奥静态数组和动态数组
数据结构·c++·算法
九硕智慧建筑一体化厂家2 小时前
直流照明|无尘风淋室照明,高均匀无频闪,适配洁净车间高频合规工况
大数据·人工智能·笔记·智慧城市
LATASA2 小时前
【 从0到1构建 Agent Harness学习笔记】
网络·笔记·学习
gumichef2 小时前
第一章:面向对象核心——类和对象深度讲解
开发语言·c++
FakeOccupational2 小时前
【电路笔记 仿真】SPICE仿真器软件 LTspice:绘制简单电路(新建图页+添加组件)并仿真+模型导入+SUBCKT (子电路)绘制与使用+特殊器件绘制
笔记
郝学胜-神的一滴2 小时前
[简化版 GAMES 104] 现代游戏引擎 04:从0到1构建你的游戏世界
c++·游戏·unity·游戏引擎·软件工程·unreal engine
月光船幽幽2 小时前
呼吸孔设计重塑系统稳定性
python·科技·算法