你正在开发一个交易系统,需要实时完成两种操作:

  1. 更新某个时间点的价格(单点修改)
  2. 快速计算某段时间段内的交易总量(区间查询)

当数据量较小时,我们可能会这样实现:

vector<int> prices(n);
// 单点更新 - O(1)
prices[index] += new_value;
// 区间查询 - O(n)
int sum = accumulate(prices.begin() + l, prices.begin() + r + 1, 0);

但当数据量达到百万级时,这样的操作会导致严重的性能瓶颈。尤其当系统要求每秒处理数万次操作时,传统的数组结构显然力不从心。

聪明的开发者可能会想到前缀和优化:

vector<int> prefix(n + 1);
// 构建前缀和 - O(n)
for(int i = 1; i <= n; ++i)
prefix[i] = prefix[i-1] + prices[i-1];
// 区间查询 - O(1)
int sum = prefix[r+1] - prefix[l];

但新的问题随之而来——当某个prices[i]更新时,需要同步更新所有相关的prefix[j]j ≥ i+1),这使得单点修改的复杂度退化为O(n)。查询和修改互相矛盾,在动态数据场景下尤为突出。

正如计算机科学家Donald Knuth所言:"算法的本质,是通过组织数据来减少不必要的计算。树状数组(Fenwick Tree)正是在这样的需求背景下被Peter Fenwick于1994年提出。其精妙之处在于:

  1. 通过二进制索引的位运算建立层级关系
  2. 单点修改区间查询都在亚线性时间内完成
  3. 不消耗额外空间

树状数组的智慧设计

原始数组的每个位置 arr[i] 存储的就是本下标的值,这使得区间查询必须遍历所有元素。为了高效求和,现在我们构建树状数组,赋予每个位置新的使命——让它存储一段特定区间的聚合信息(如区间和)。那么,如何确定 tree[i] 应该管理原数组的哪些位置?

惯例上,树状数组下标从 1 开始。为了让一个长区间被拆为对数段,我们让其最低有效位(LSB)决定它管理的范围

\(\text{LSB(i)} = i \& -i\)

例如:

\(6 = 110_2 \Rightarrow \text{LSB(6)} = 10_2 = 2_{10}\)

\(8 = 1000_2 \Rightarrow \text{LSB(8)} = 1000_2 = 8_{10}\)

tree[i] 管理原数组的区间 \([i - \text{LSB(i)} + 1,\ i]\)

(即从去掉最低位的下一个数开始,到自身结束)

以 \(n=8\) 为例的树状数组结构:

索引 \(i\) 二进制 管辖范围
1 0001 \([1,1]\)
2 0010 \([1,2]\)
3 0011 \([3,3]\)
4 0100 \([1,4]\)
5 0101 \([5,5]\)
6 0110 \([5,6]\)
7 0111 \([7,7]\)
8 1000 \([1,8]\)

这个数组就叫做树状数组。有了这样一个数组,其前缀和 \(sum[1..k]\) 的计算可分解为:

\[\text{sum}[1..k] = \text{tree}[k] + \text{tree}[k - \text{LSB}(k)] + \text{tree}[k - \text{LSB}(k) - \text{LSB}(k-\text{LSB}(k))] + \cdots
\]

例如计算 \(\text{sum}[1..7]\),它包括:

  1. \(\text{tree}[7]\) (管理 \([7,7]\))
  2. \(\text{tree}[7-1=6]\) (管理 \([5,6]\))
  3. \(\text{tree}[6-2=4]\) (管理 \([1,4]\))
  4. \(\text{tree}[4-4=0]\) (终止)

操作次数恰好等于 \(k\) 的二进制表示中 1 的位数,即 \(O(\log n)\)。

树状数组的核心操作

我们将通过C++类实现来演示树状数组的三大核心操作:单点更新前缀查询区间查询

class FenwickTree {
private:
vector<int> tree; // 树状数组存储
int n; // 元素数量 // 计算最低有效位 (Least Significant Bit)
int LSB(int x) {
return x & -x; // 利用补码特性
} public:
// 构造函数:初始化大小为n+1(下标从1开始)
FenwickTree(int size) : n(size), tree(size + 1) {} // 操作函数将在下文实现...
};

单点更新(Point Add)

功能:在原数组的index位置增加delta

要更新一个点,我们需要从他自己开始,更新所有包含该位置的tree[i]。通过不断向高位跳跃找到所有相关节点:

void pointAdd(int index, int delta) {
// 从index开始向上更新父节点
for(; index <= n; index += LSB(index)) {
tree[index] += delta;
}
}

操作流程(以更新arr[3]为例):

  1. 更新tree[3](管理[3,3]
  2. 跳转到3 + LSB(3) = 4,更新tree[4](管理[1,4]
  3. 跳转到4 + LSB(4) = 8,更新tree[8](管理[1,8]
  4. 直到超出n停止

前缀查询(Prefix Query)

功能:查询原数组[1..index]的区间和

通过不断去掉最低位累加片段和:

int prefixQuery(int index) {
int sum = 0;
// 从index开始向下累加子区间
for(; index > 0; index -= LSB(index)) {
sum += tree[index];
}
return sum;
}

操作流程(查询sum[1..7]为例):

  1. tree[7][7,7]
  2. 跳转到7 - LSB(7) = 6,加tree[6][5,6]
  3. 跳转到6 - LSB(6) = 4,加tree[4][1,4]
  4. 跳转到4 - LSB(4) = 0终止

区间查询(Range Query)

功能:查询原数组[left, right]的区间和

求两次前缀和差分即可。

int rangeQuery(int left, int right) {
return prefixQuery(right) - prefixQuery(left - 1);
}
操作 时间复杂度 循环次数(最坏情况)
pointAdd \(O(\log n)\) \(\lfloor \log_2 n \rfloor + 1\)
prefixQuery \(O(\log n)\) \(\lfloor \log_2 n \rfloor + 1\)
rangeQuery \(O(\log n)\) \(2(\lfloor \log_2 n \rfloor + 1)\)

惯例上,树状数组下标从 1 开始。保持1-based索引可避免死循环(index=0时循环终止)

快速建树

将树状数组全部初始化成 0,然后对原数组的值挨个插入,可以完成初始化。

// 通过n次pointAdd操作建树
FenwickTree(int size, const vector<int>& nums) : n(size), tree(size + 1) {
for(int i = 1; i <= n; ++i) {
pointAdd(i, nums[i-1]); // 每次O(log n)
}
}

但是还有更高效的方法。利用每个节点的子节点已经计算的结果,对于节点 \(i\):

  1. 累加原数组 \(\text{arr}[i]\)
  2. 累加所有比 \(i\) 小且 \(j + \text{LSB}(j) = i\) 的 \(\text{tree}[j]\)
FenwickTree(int size, const vector<int>& nums) : n(size), tree(size + 1) {
// 第一步:直接拷贝原数组
for(int i = 1; i <= n; ++i) {
tree[i] = nums[i-1];
} // 第二步:递推更新父节点
for(int i = 1; i <= n; ++i) {
int j = i + LSB(i); // 找到直接父节点
if(j <= n) {
tree[j] += tree[i]; // 将当前节点的值贡献给父节点
}
}
}

建树过程示例(以数组[1,3,5,7,9,11]为例):

  1. 初始状态:tree = [0,1,3,5,7,9,11]
  2. 处理i=1j=1+1=2tree[2] += 1tree[2]=4
  3. 处理i=2j=2+2=4tree[4] += 4tree[4]=11
  4. 处理i=3j=3+1=4tree[4] += 5tree[4]=16
  5. 处理i=4j=4+4=8(超出范围跳过)
  6. 处理i=5j=5+1=6tree[6] += 9tree[6]=20
  7. 最终树状数组:[0,1,4,5,16,9,20]
建树方法 时间复杂度 适用场景
单点插入法 \(O(n \log n)\) 通用但较慢
递推法 \(O(n)\) 已知原数组时最优

树状数组实现区间修改

树状数组支持快速的单点修改和区间查询。而通过维护原数组的差分数组的树状数组,可以反过来实现区间修改(修改两个点)和单点查询(查询一个和)。而如果同时区间修改和区间维护呢?这就需要巧妙的数学构思。

通过维护两个树状数组 \(B_1\) 和 \(B_2\),实现区间操作:

  • 区间加:在\([l, r]\)上统一加\(\Delta\)
  • 区间和:查询\([l, r]\)的和

数学推导

  1. 定义差分数组 \(d[i] = \text{arr}[i] - \text{arr}[i-1]\)
  2. 前缀和可表示为:
    \[\sum_{i=1}^k \text{arr}[i] = \sum_{i=1}^k \sum_{j=1}^i d[j] = \sum_{i=1}^k (k-i+1)d[i]
    \]
  3. 展开得到:
    \[(k+1)\sum_{i=1}^k d[i] - \sum_{i=1}^k i \cdot d[i]
    \]

所以说,我们要维护两个树状数组,一个表示的是 \(d_i\),一个是\(i \cdot d_i\)。进行区间修改时,对两个数组分别进行两次单点修改;进行区间查询时,分别查询并用上文式子相加。

class RangeFenwick {
private:
FenwickTree B1, B2; // 两个基础树状数组 void rangeAddRaw(int l, int r, int delta) {
B1.pointAdd(l, delta);
B1.pointAdd(r+1, -delta);
B2.pointAdd(l, l*delta);
B2.pointAdd(r+1, -(r+1)*delta);
} public:
RangeFenwick(int n) : B1(n), B2(n) {} // 区间[l,r]增加delta
void rangeAdd(int l, int r, int delta) {
rangeAddRaw(l, r, delta);
} // 查询前缀和[1..k]
int prefixQuery(int k) {
return (k+1)*B1.prefixQuery(k) - B2.prefixQuery(k);
} // 查询区间和[l..r]
int rangeQuery(int l, int r) {
return prefixQuery(r) - prefixQuery(l-1);
}
};

终极思考题

如何设计支持以下操作的树状数组?

  • 区间加
  • 区间乘
  • 区间求和

(提示:维护三个树状数组分别存储\(\Delta\)、\(i\Delta\)和\(i^2\Delta\))

树状数组处理最值

求区间和时,我们直接求两次前缀和并相减,但对于最大值/最小值这类信息:

  • 不满足可减性:\(\max\{l..r\} \neq \max\{1..r\} - \max\{1..(l-1)\}\)

因此,我们需要手动分解目标区间,并统计答案。

class FenwickMax {
private:
vector<int> tree;
vector<int> origin; // 保存原始值,修改时也要一同修改
int n; void update(int i, int val) {
origin[i] = val;
for(; i <= n; i += LSB(i)) {
tree[i] = val;
for(int j = 1; j < LSB(i); j <<= 1) {
tree[i] = max(tree[i], tree[i-j]);
}
}
} public:
FenwickMax(const vector<int>& nums) : n(nums.size()),
tree(n+1, INT_MIN),
origin(n+1) {
for(int i = 1; i <= n; ++i) {
update(i, nums[i-1]);
}
} int rangeMax(int l, int r) {
int res = INT_MIN;
while(r >= l) {
// Case 1:当前区间完全在查询范围内
if(r - LSB(r) + 1 >= l) {
res = max(res, tree[r]);
r -= LSB(r); // 移动到前一个区间
}
// Case 2:需要单点检查
else {
res = max(res, origin[r]);
--r; // 退一位继续检查
}
}
return res;
}
};

以查询\(max[5..14]\)为例:

  1. 从右端点14向左查询:

    • 取\(tree[14]\)(管理\([13..14]\))
    • 剩余查询\([5..12]\)
  2. 处理\([5..12]\):
    • 取\(tree[12]\)(管理\([9..12]\))
    • 剩余查询\([5..8]\)
  3. 处理\([5..8]\):
    • 取\(tree[8]\)(管理\([1..8]\))→ 超出左边界
    • 必须改为单点检查\(arr[8]\)
    • 剩余查询\([5..7]\)

复杂度证明

  • 最佳情况:当 \(l\) 和 \(r\) 正好是某个 \(tree[i]\) 的边界时,仅需 \(\log n\) 步
  • 最差情况:需要交替执行 Case1 和 Case2 约 \(2\log n\) 次。每层需要处理 \(\log n\) 个碎片区间,每个区间需要\(\log n\)时间检查 → \(O(\log^2 n)\)

二维树状数组

这里简要介绍树状数组如何从维护数组改造为维护矩阵。在二维情景下,每个节点 tree[x][y] 管理原数组中从 (x - LSB(x) + 1, y - LSB(y) + 1)(x, y) 的子矩阵

单点更新(Point Add)

void pointAdd(int x, int y, int delta) {
for(int i = x; i <= n; i += LSB(i))
for(int j = y; j <= m; j += LSB(j))
tree[i][j] += delta;
}

更新所有包含 (x,y) 的矩形区域。例如更新 (3,3) 会影响:

  • tree[3][3](管理[3,3]×[3,3]
  • tree[3][4](管理[3,3]×[3,4]
  • tree[4][3](管理[3,4]×[3,3]
  • tree[4][4](管理[3,4]×[3,4]

前缀查询(Prefix Query)

int prefixQuery(int x, int y) {
int sum = 0;
for(int i = x; i > 0; i -= LSB(i))
for(int j = y; j > 0; j -= LSB(j))
sum += tree[i][j];
return sum;
}

通过二维前缀和分解:

\[∑_{i=1}^x ∑_{j=1}^y arr[i][j] =
tree[x][y]
+ tree[x - LSB(x)][y]
+ tree[x][y - LSB(y)]
- tree[x - LSB(x)][y - LSB(y)]
\]

区间查询(Range Query)

int rangeQuery(int x1, int y1, int x2, int y2) {
return prefixQuery(x2,y2)
- prefixQuery(x1-1,y2)
- prefixQuery(x2,y1-1)
+ prefixQuery(x1-1,y1-1);
}

通过四个前缀矩形的加减实现任意矩形区域求和(类比二维前缀和容斥)

操作 时间复杂度 循环次数(最坏)
单点更新 \(O(\log^2 n)\) \(\log^2 n\)
区间查询 \(O(\log^2 n)\) \(4\log^2 n\)

树状数组 vs 线段树

树状数组和线段树可以解决类似的问题。

特性 树状数组 线段树
代码复杂度 15-20行核心代码 50+行实现
区间修改支持 需改造(双树状数组) 原生支持
空间消耗 \(O(n)\),无额外空间消耗 \(O(n)\),需要约 4 倍空间
时间消耗 常数更小的 \(O(\log n)\) \(O(\log n)\)
不可差分信息 受限 完全支持
高维扩展 简单(嵌套结构) 复杂(四分树等)
动态开点 不支持 支持

树状数组(Fenwick Tree)原理和优化全面解析的更多相关文章

  1. HDU-3436 Queue-jumpers 树状数组 | Splay tree删除,移动

    题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=3436 树状数组做法<猛戳> Splay tree的经典题目,有删除和移动操作.首先要离散化 ...

  2. 树状数组lowbit()函数原理的解释 x&(x^(x-1)) x&-x

    树状数组lowbit()函数所求的就是最低位1的位置所以可以通过位运算来计算 树状数组通过 x&(x^(x-1)) 能够成功求出lowbit的原因: 首先设x=6,即110(2) 于是我们使 ...

  3. Ultra-QuickSort(归并排序+离散化树状数组)

    Ultra-QuickSort Time Limit: 7000MS   Memory Limit: 65536K Total Submissions: 50517   Accepted: 18534 ...

  4. bzoj2434 fail树 + dfs序 + 树状数组

    https://www.lydsy.com/JudgeOnline/problem.php?id=2434 打字机上只有28个按键,分别印有26个小写英文字母和'B'.'P'两个字母.经阿狸研究发现, ...

  5. CodeVS2492 上帝造题的七分钟2(树状数组+并查集)

    传送门 树状数组模板题.注意优化,假设某个数的值已经是1了的话.那么我们以后就不用对他进行操作了,这个能够用并查集实现. 这道题还有个坑的地方,给出查询区间端点的a,b,有可能a>b. #inc ...

  6. A Simple Problem with Integers(100棵树状数组)

    A Simple Problem with Integers Time Limit: 5000/1500 MS (Java/Others)    Memory Limit: 32768/32768 K ...

  7. POJ-2155-Matrix二位树状数组应用

    题目: 一个只有0和1构成的二维平面,给你两种指令,一种是区间的更新,即0变为1,1变为0:一种是查询一个点是1还是0: 由于是二进制,所以每次更新在相应的点上加一,最后对2取余即可. 至于二维的树状 ...

  8. HDU_4456_二维树状数组

    http://acm.hdu.edu.cn/showproblem.php?pid=4456 第一道二维树状数组就这么麻烦,题目要计算的是一个菱形范围内的和,于是可以把原来的坐标系旋转45度,就是求一 ...

  9. 树状数组,Fenwick Tree

    Fenwick Tree, (also known as Binary Indexed Tree,二叉索引树), is a high-performance data structure to cal ...

  10. 树状数组 Binary Indexed Tree/Fenwick Tree

    2018-03-25 17:29:29 树状数组是一个比较小众的数据结构,主要应用领域是快速的对mutable array进行区间求和. 对于一般的一维情况下的区间和问题,一般有以下两种解法: 1)D ...

随机推荐

  1. ChatGPT 背后的英雄——AI芯片

    本文分享自天翼云开发者社区<ChatGPT 背后的英雄--AI芯片>,作者:w****n AI芯片能为人工智能应用提供所需的基础算力:按技术架构主要分为GPU.FPGA和ASIC.Chat ...

  2. HPC云化部署的优势和挑战

    本文分享自天翼云开发者社区<HPC云化部署的优势和挑战> 作者:土豆炒肉丝 HPC云化部署指的是将高性能计算(HPC)工作负载部署在云计算平台上,这种方式带来了一些明显的优势,但同时也面临 ...

  3. Linux部署Python项目

    项目部署 项目部署对于操作通常是由运维人员来进行统一管理装配的,但是对于一个开发人员来讲,基础的项目部署还是要会的. 这里我主要讲解python的项目是如何部署的,至于项目哪里来的,这就要看观众如何抉 ...

  4. Flink批处理-简单案例-01

    一.简单案例 <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http ...

  5. 交叉编译SQLite3

    交叉编译SQLite3 SQLite是一个进程内的库,实现了自给自足的.无服务器的.零配置的.事务性的SQL 数据库引擎. 它是一个零配置的数据库,这意味着与其他数据库不一样,您不需要在系统中配置. ...

  6. 科研界DeepSeek+AI应用协作攻略来了!

    自从DeepSeek爆火 AI应用届开启"精英集结" 与DeepSeek携手撑起国产AI一片天 比如,DeepSeek+Midjourney 成为设计师的好帮手 DeepSeek+ ...

  7. 【Blender】杂项笔记

    [Blender]杂项笔记 空间坐标系 Blender 中的轴向: Y 轴向前(前视图看向的方向就是前方,其默认向 Y 轴看) Z 轴向上 保持轴向导出到 Unity 时(包括直接保存.导出 FBX ...

  8. 群晖NAS 6.2x Moments AI场景识别 补丁教程

    首先,我们需要在套件中心里,停用Moments: 正常情况下,群晖是不允许root账号直接登录的,必须使用管理员账户,然后sudo -i指令登录root账户,这样非常的麻烦.所以我们可以之际开启roo ...

  9. 一种基于虚拟摄像头、NDI、OBS以及yolo的多机视觉目标检测方案

    一种基于虚拟摄像头.NDI.OBS以及yolo的多机视觉目标检测方案 绪论 近来为了实现某种实时展示效果,笔者希望通过一套方案实现在两台主机上分别运行仿真平台以及视觉深度学习算法.透过对当下较为流行的 ...

  10. deepseek:封装一个axios

    封装一个包含上传和下载文件功能的 axios 实例,可以提高代码的复用性和可维护性.以下是一个完整的封装示例,支持文件上传.下载.以及常规的 GET/POST 请求. 封装代码 import axio ...