树状数组精讲:逆序对计数与离散化(附双语言解法)

一、从刚落幕的国际信息学奥林匹克说起

2026 年 8 月 9 日至 15 日,第 38 届国际信息学奥林匹克竞赛在乌兹别克斯坦首都塔什干举行,来自全球 90 多个国家和地区的近 400 名选手同场竞技。中国队四名选手斩获 3 金 1 银、团体总分第一,其中一位选手以 498.27 分成为本届最高分。截至本届,中国队累计金牌已突破 100 枚。

值得注意的是官方对赛题范围的描述:算法设计、数据结构与程序设计实现。"数据结构"被单独拎出来,不是偶然------在真实赛场上,同一道题用暴力和用合适的数据结构,往往就是 0 分和满分的差别。

今天要讲的 树状数组(Binary Indexed Tree,也叫 Fenwick Tree) 就是这样一件"性价比极高"的兵器:代码只有十来行,却能把很多 O(n\^2) 的统计问题压到 O(n \\log n)。我们用一道原创题把它彻底讲透。


二、原创练习题:科技节机器人列队

题目描述

学校科技节要举办机器人巡游表演。n 台机器人排成一列,从左到右第 i 台的高度为 a_i。导演希望它们按高度从矮到高(非递减)排列,这样队形看起来最整齐。

由于场地狭窄,机器人只能与相邻的那一台交换位置,每交换一次记为一步。

请回答三个问题:

  • 问题 A:有多少组"高度倒置"的机器人?即满足 i \< ja_i \> a_j 的下标对 (i, j) 有多少个。
  • 问题 B:最少需要多少步相邻交换,才能让整列机器人变成非递减排列?
  • 问题 C :对每一台机器人,输出它左边比它高的机器人数量。

输入格式

第一行一个整数 n

第二行 n 个整数 a_1, a_2, \\dots, a_n

输出格式

第一行输出问题 A 的答案(问题 B 的答案与它相同,原因见后文)。

第二行输出 n 个整数,为问题 C 的答案。

数据范围

0 \\le n \\le 5 \\times 10\^5-10\^9 \\le a_i \\le 10\^9,高度可能重复。

样例输入

复制代码
8
5 3 9 3 7 1 9 2

样例输出

复制代码
15
0 1 0 2 1 5 0 6

样例解释

以第 8 台(高度 2)为例,它左边有 5、3、9、3、7、9 共 6 台比它高,所以第 8 个数是 6。把 8 个位置的贡献相加:0+1+0+2+1+5+0+6 = 15,恰好等于逆序对总数 15。这个"分解求和"的关系是本题的核心线索。


三、核心考点拆解

这道题看似只是数数,实际串起了五个高频考点。

考点 1:逆序对的定义与"相邻交换"的等价性

问题 A 求的就是标准的逆序对数量。问题 B 为什么答案完全一样?

关键观察:交换相邻两个元素,逆序对数量最多变化 1

  • a_i \\le a_{i+1},交换后它们变成一个新的逆序对,总数 +1
  • a_i \> a_{i+1},交换后这一对被消掉,总数 -1
  • 而对于其他任意一对元素 (x, y),交换相邻位置并不改变它们的前后相对次序,所以贡献不变。

排好序的数组逆序对为 0。每一步最多消掉 1 个逆序对,所以至少需要"逆序对个数"步;而冒泡排序恰好每次交换都消掉 1 个,能达到这个下界。结论:最少相邻交换次数 = 逆序对个数,这是一个必须记住的经典结论。

顺带一提:如果允许任意两个位置交换,答案就完全不同了(见进阶三),别混为一谈。

考点 2:为什么必须离散化

树状数组是以"值"为下标的桶。本题 a_i 可以到 10\^9,开这么大的数组直接爆内存。

离散化就是把值域压缩到 1 \\dots mm \\le n 为不同值的个数),只保留大小关系:

  1. 复制一份数组,排序;
  2. 去重;
  3. 用二分查找(lower_bound / bisect_left)把每个原值换成它在去重数组里的排名 +1(转成 1-based)。

排名保持了原有的大小顺序,因此所有比较结果不变。这一步同时顺手解决了负数和小数(先转排名即可)的问题。

考点 3:树状数组的两个动作

树状数组只做两件事,都靠 lowbit(i) = i & (-i) 驱动:

复制代码
add(i, v)   : 把位置 i 的值加上 v      ------ 沿 i += lowbit(i) 往上跳
query(i)    : 返回前缀和 [1..i]        ------ 沿 i -= lowbit(i) 往下跳

tree[i] 管理的是区间 (i - \\text{lowbit}(i),\\ i\]。两个操作跳的次数都不超过 \\log n,所以单次都是 O(\\log n)

在本题里,我们用它维护一个计数桶add(r, 1) 表示"排名 r 的高度出现了一次",query(r) 表示"目前已插入的元素中,排名 \\le r 的有多少个"。

考点 4:扫描方向决定统计口径

同一个树状数组,扫描方向不同,含义完全不同。这是最容易写错的地方。

目标 扫描方向 查询写法 含义
问题 A / B:逆序对总数 从右往左 query(r - 1) 右侧严格小于 a_i 的个数
问题 C:左边比它高的个数 从左往右 i - query(r) 已插入 i 个,减去 \\le a_i 的个数

注意问题 A 用 query(r - 1) 而不是 query(r):逆序对要求严格大于,相等的高度不算倒置,必须把等于 a_i 的那一档排除掉。这个"差一"是本题最高频的错误来源。

考点 5:答案的量级

n = 5 \\times 10\^5 且完全逆序时,逆序对数为 \\frac{n(n-1)}{2} \\approx 1.25 \\times 10\^{11},早已超过 32 位 int 上限 2147483647必须用 long long(Python 无此顾虑)。这是本题最经典的"隐形丢分点"。


四、解法一:树状数组 + 离散化(主解)

思路一句话:从右往左扫,每遇到一个元素,先问"右边已经放进去了多少个比我矮的",再把自己放进桶里。

C++ 实现

cpp 复制代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;

int m;                 // 离散化后的值域大小
vector<int> tree_;     // 树状数组,有效下标 1..m

void add(int i, int v) {                 // 单点加
    for (; i <= m; i += i & (-i)) tree_[i] += v;
}

int query(int i) {                       // 前缀和 [1..i]
    int s = 0;
    for (; i > 0; i -= i & (-i)) s += tree_[i];
    return s;
}

int main() {
    int n;
    if (scanf("%d", &n) != 1) return 0;
    vector<int> a(n);
    for (int i = 0; i < n; ++i) scanf("%d", &a[i]);

    // ---- 离散化:把任意大小的高度压到 1..m ----
    vector<int> s(a);
    sort(s.begin(), s.end());
    s.erase(unique(s.begin(), s.end()), s.end());
    m = (int)s.size();
    tree_.assign(m + 1, 0);

    // ---- 问题 A / B:从右往左统计逆序对 ----
    long long ans = 0;                   // 必须 long long
    for (int i = n - 1; i >= 0; --i) {
        int r = int(lower_bound(s.begin(), s.end(), a[i]) - s.begin()) + 1;
        ans += query(r - 1);             // 右侧严格小于 a[i] 的个数
        add(r, 1);
    }
    printf("%lld\n", ans);

    // ---- 问题 C:清空后从左往右 ----
    tree_.assign(m + 1, 0);
    for (int i = 0; i < n; ++i) {
        int r = int(lower_bound(s.begin(), s.end(), a[i]) - s.begin()) + 1;
        printf("%d%c", i - query(r), i + 1 == n ? '\n' : ' ');
        add(r, 1);
    }
    return 0;
}

Python 实现

python 复制代码
import sys
from bisect import bisect_left


def main():
    data = sys.stdin.read().split()
    if not data:
        return
    n = int(data[0])
    a = list(map(int, data[1:1 + n]))

    # ---- 离散化 ----
    s = sorted(set(a))
    m = len(s)
    tree = [0] * (m + 1)

    def add(i):
        while i <= m:
            tree[i] += 1
            i += i & (-i)

    def query(i):
        t = 0
        while i > 0:
            t += tree[i]
            i -= i & (-i)
        return t

    # ---- 问题 A / B:从右往左 ----
    ans = 0
    for x in reversed(a):
        r = bisect_left(s, x) + 1
        ans += query(r - 1)          # 严格小于,所以是 r-1
        add(r)
    print(ans)

    # ---- 问题 C:清空后从左往右 ----
    tree = [0] * (m + 1)
    res = []
    for idx, x in enumerate(a):
        r = bisect_left(s, x) + 1
        res.append(idx - query(r))   # 已插入 idx 个,减去 <= x 的
        add(r)
    print(' '.join(map(str, res)))


main()

两份代码对样例均输出:

复制代码
15
0 1 0 2 1 5 0 6

五、解法二:归并排序求逆序对(对拍神器)

逆序对还有一条完全不同的路:在归并排序合并两个有序段时顺手计数。

当左段 L[i] > R[j],说明 R[j] 比左段剩下的 L[i..] 全部 都小,一次性累加 len(L) - i 个逆序对。

python 复制代码
def count_inversions(a):
    def rec(arr):
        if len(arr) <= 1:
            return arr, 0
        mid = len(arr) // 2
        L, c1 = rec(arr[:mid])
        R, c2 = rec(arr[mid:])
        out, i, j, c = [], 0, 0, c1 + c2
        while i < len(L) and j < len(R):
            if L[i] <= R[j]:          # 注意是 <=,相等不算逆序对
                out.append(L[i]); i += 1
            else:
                out.append(R[j]); j += 1
                c += len(L) - i        # 关键一行
        out.extend(L[i:]); out.extend(R[j:])
        return out, c
    return rec(list(a))[1]

两种解法的取舍:

树状数组 归并排序
代码量 稍多(含离散化) 稍少
是否需要离散化 需要 不需要
能否顺带回答问题 C 能(换扫描方向) 不方便
可扩展性 强(区间查询、第 k 小、二维偏序) 弱(专用于逆序对)
常数 递归开销略大

建议:赛场上写树状数组做主解,写归并排序做对拍验证。 两条思路互相独立,一旦答案不一致必有一方写错,比盯着代码看要高效得多。


六、复杂度分析

树状数组解法

  • 时间:离散化排序 O(n \\log n) + nadd/queryO(\\log n),共 O(n \\log n)
  • 空间:原数组 + 离散化数组 + 树状数组,共 O(n)

归并排序解法:时间 O(n \\log n),空间 O(n)(递归栈 O(\\log n),合并临时数组 O(n))。

暴力枚举O(n\^2)。在 n = 5 \\times 10\^5 时约 1.25 \\times 10\^{11} 次比较,必然超时;但它是最好的对拍基准,n \\le 2000 时可以放心用。


七、易错点清单

写这道题最容易踩的七个坑,逐条对照检查:

  1. 答案用了 int1.25 \\times 10\^{11} 远超 int 上限,必须 long long。这是本题第一大丢分点。
  2. query(r - 1) 误写成 query(r) 。逆序对要求严格大于,相等的高度不能算,必须用 r - 1 把等于 a_i 的那一档排除掉;写成 r 会在有重复元素时多算一批。
  3. 树状数组下标从 0 开始lowbit(0) = 0add(0, 1) 会陷入死循环。离散化后的排名必须 +1 变成 1-based。
  4. 两次统计之间忘记清空树状数组 。问题 A 和问题 C 共用一棵树,中间必须 assign(m + 1, 0) 重置,否则问题 C 的结果会掺进问题 A 的残留计数。
  5. 离散化忘记去重 。不去重会让 m 变成 n,虽然一般不至于出错,但相等元素的排名不唯一,lower_bound 的语义会变得混乱,容易连带写错第 2 条。
  6. 归并排序里写成 L[i] < R[j] 。必须是 <=,否则相等元素会被误算成逆序对。
  7. n = 0 未处理 。空输入时循环不执行、输出空行即可,但要保证 tree_ 大小至少为 1,sort/unique 对空 vector 也要安全。

八、四个进阶方向

进阶一:树状数组倍增求第 k 小

树状数组不只能算前缀和,还能在 O(\\log n) 内直接定位"第 k 小的值",比"二分套 query"的 O(\\log\^2 n) 更快。

原理是从最高位往低位试着往右跳,能跳就跳、并把已经越过的计数从 k 里扣掉:

cpp 复制代码
int LOG;                    // 满足 (1<<LOG) <= m 的最大值
int kth(int k) {            // 返回第 k 小的排名下标(1..m)
    int pos = 0;
    for (int j = LOG; j >= 0; --j) {
        int nxt = pos + (1 << j);
        if (nxt <= m && tree_[nxt] < k) {
            pos = nxt;
            k -= tree_[nxt];
        }
    }
    return pos + 1;
}

配合 add(r, 1) / add(r, -1) 就能维护一个支持插入、删除、查询第 k 小的动态排名集合------这是"权值树状数组"的标准玩法,很多"动态中位数""滑动窗口第 k 大"的题都靠它。

进阶二:逆序对奇偶性与数字华容道

逆序对的奇偶性是一个排列的不变量:任意一次相邻交换都会让它 \\pm 1,也就是奇偶性翻转一次。

这条性质能直接判定经典的 15 数码(数字华容道)是否可解:把空格看作最大数,计算整个排列的逆序对奇偶性,再结合空格所在行号,就能在不搜索的情况下断定目标状态可达与否。判定只需 O(n\^2)O(n \\log n),比盲目跑 BFS 省下天量时间。

进阶三:换成"任意交换",答案完全不同

如果放开限制,允许交换任意两个位置(元素互不相同),最少交换次数变成:

\\text{答案} = n - (\\text{置换环的个数})

做法是把"当前位置 → 排序后该元素应在的位置"看成一个置换,找出所有环,每个长度为 L 的环需要 L - 1 次交换。

对比一下差别有多大:数组 [3, 2, 1] 的逆序对是 3 (相邻交换要 3 步),但任意交换只需 1 步(直接换 3 和 1)。读题时务必看清是"相邻交换"还是"任意交换",这是同类题最常见的陷阱设置。

进阶四:从单点修改升级到区间修改区间查询

原始树状数组是"单点改 + 前缀查"。用两棵树状数组 B_1B_2 配合差分,就能支持"区间加 + 区间求和":

  • 区间 \[l, r\]v:在 B_1 上做 add(l, v)add(r+1, -v);在 B_2 上做 add(l, v*(l-1))add(r+1, -v*r)
  • 前缀和 \[1, i\] 查询:i \\times \\text{query}*{B_1}(i) - \\text{query}*{B_2}(i)

这样一来,很多"必须上线段树"的题目就能用两个十行函数解决,代码短、常数小、调试快。如果还要处理"区间内逆序对个数"这类问题,再往上就是莫队算法或树套树,可以作为下一阶段的目标。


九、小结与互动

回顾一下这道题给出的完整链条:

  1. 问题转化:把"最少相邻交换次数"识别为"逆序对计数"(考点 1 的等价性证明);
  2. 离散化:把 10\^9 的值域压到 O(n),让"以值为下标"成为可能;
  3. 树状数组 :用 lowbit 两个方向的跳跃,O(\\log n) 完成单点加与前缀和;
  4. 扫描方向:右→左统计逆序对,左→右统计前缀贡献,同一份数据结构两种口径;
  5. 对拍验证:归并排序 + 暴力枚举双重兜底,避免"差一"和溢出这类沉默错误。

本文所有代码在发布前都做过验证:Python 的树状数组、归并排序、暴力三套解法在 3000 组随机数据(含空数组、全相等、完全逆序、含负数、大值域等边界)上答案完全一致;C++ 版与 Python 版对样例输出逐字符相同;进阶一的倍增求第 k 小在 2000 组随机计数分布上与暴力扫描一致;进阶三的置换环公式与小规模 BFS 最短交换次数一致。

留几个问题给你动手:

  1. 如果题目改成"求满足 i \< ja_i \> 2 \\times a_j 的对数",树状数组的写法要怎么改?(提示:查询边界不再是 r-1,需要另做一次二分)
  2. 问题 C 若改成"右边比它矮的个数",扫描方向和查询式子各是什么?
  3. 试着把进阶四的两棵树状数组写出来,用随机数据和暴力前缀和对拍,看能不能一次通过。

如果这篇讲解帮你理清了树状数组的思路,欢迎点赞收藏;对哪个考点还有疑问,评论区留言,下一篇挑最多人问的展开细讲。

下一篇预告:倍增思想的另一大应用------树上最近公共祖先(LCA),以及它如何把树上路径查询降到 O(\\log n)


📚 免费少儿编程资料(夸克网盘领取)

以下资料来自夸克网盘分享,点击链接可直接保存;若需在 App 内打开,也可复制下方明文链接:

  1. 全国青少年信息素养大赛复赛集训题目Python&C++.docx
    https://pan.quark.cn/s/93995d3cb150
  2. 2024信息素养-智能算法应用挑战赛-复赛初中组题目7月7日.pdf
    https://pan.quark.cn/s/da97b5dbf75d
  3. Python背记手册.pdf
    https://pan.quark.cn/s/7568ae9ca92b
  4. Python课程
    https://pan.quark.cn/s/a94bf02d00c6
  5. 2024信息素养大赛图形化复赛集训题答案3-9
    https://pan.quark.cn/s/6ccab7ec3cbc
  6. 2025年03月份电子学会考级真题
    https://pan.quark.cn/s/4403c4228912
  7. 2025全国青少年信息素养大赛赛项说明
    https://pan.quark.cn/s/d9d0df4a9f29
  8. 青少儿信息素养大赛编程资料
    https://pan.quark.cn/s/4ab6bd83be8a

资料持续更新,关注获取最新分享。

相关推荐
血小板要健康34 分钟前
网格 dfs 与 FloodFill:从岛屿、区域到搜索路径
笔记·算法·leetcode·深度优先
VALENIAN瓦伦尼安教学设备1 小时前
设备状态检测振动分析实训台案例分析
大数据·数据库·人工智能·嵌入式硬件·算法
小小晓.1 小时前
C++ 值类别与完美转发深度剖析:从左值右值到 std::forward 源码解密
开发语言·c++·算法
怕浪猫1 小时前
用了两年 AI 编程工具后,我重新理解了什么是「资深工程师」
算法·面试·架构
hetao17338372 小时前
2026-08-27~29 hetao1733837 的刷题记录
c++·算法
云淡风轻~窗明几净2 小时前
宇宙管理学猜想
算法·图论
春风解人意2 小时前
从零开始学习嵌入式P28----线程
c语言·学习·算法
luj_17682 小时前
合法避税与投资评估实战指南
c语言·开发语言·网络·经验分享·算法
Bruce_Liuxiaowei3 小时前
驴滑块拼图游戏:从19世纪的纸片谜题到数学博弈论
人工智能·算法