• 树状数组综述


    树状数组综述

    树状数组能解决的问题

    树状数组,也称作“二叉索引树”(Binary Indexed Tree)或 Fenwick 树。
    它可以高效地解决处理如下两个操作:1、数组前缀和的查询;2、单点更新。

    下面具体解释这两个操作:

    操作1:数组前缀和的查询

    例1:已知数组 [10, 15, 17, 19, 20, 14, 12] 。
    1、求索引 0 至索引 4 的元素的和;
    2、求索引 0 至索引 5 的元素的和;
    3、求索引 0 至索引 7 的元素的和。

    是不是觉得很简单,“前缀和”定义了一个从数组的“头”开始的区间,统计的是这个从头开始的区间的“和”。

    操作2:单点更新

    例 2:已知数组 [10, 15, 17, 19, 20, 14, 12] 。
    1、将索引为 4 的元素增加 2;
    2、将索引为 6 的元素减少 3。

    “单点更新”定义了将数组指定索引的元素值变更为另一个值,给出的参数是一个改变的数值,即“更新以后的值-原来的值”。

    如果我们不使用任何数据结构,仅依靠定义,操作1 的时间复杂度是 (O(n)),操作2的时间复杂度是 (O(1))
    我们觉得操作1 的时间复杂度较大,要扫描这个区间的一大部分元素,才能得到这个区间的和,为此,一个常见的做法是,我们可以实现计算出一个“前缀和数组”,这个数组的每个元素对应的正是原来数组的前缀和。

    例3:已知数组 nums = [1, 2, 3, 4, 5, 6, 7] ,求前缀和数组 cumsum。
    分析:前缀和数组 cumsum = [1, 3, 6, 10, 15, 21, 28]。

    有了前缀和数组每次查询前缀和时间复杂度就变成了 O(1)。前缀和数组得到以后,就可以以 O(1) 的时间复杂度解决“区间和”问题。

    例4:已知数组 nums = [1, 2, 3, 4, 5, 6, 7] ,求区间 [3, 7] 的和(数组索引从 1 开始)。
    分析:由于,前缀和(7) = nums[1] + nums[2] + nums[3] + nums[4] + nums[5] + nums[6] + nums[7] ;
    前缀和(2) = nums[1] + nums[2];
    区间 [3, 7] 的和 = nums[3] + nums[4] + nums[5] + nums[6] + nums[7];
    因此,区间 [3, 7] 的和 = 前缀和(7) - 前缀和(2) 。

    前缀和数组知道了,区间和也可以很快地求出来。

    那如果我要执行操作2,就得更新这个前缀和数组,又得计算一次前缀和,时间复杂度为 O(n)。
    那如果我在一次业务场景中“操作1”(前缀和)和“操作2”(单点更新)的次数都很多,前缀和数组就不高效了。因此,Fenwick 树就诞生了。

    树状数组长什么样子

    我们首先先看看树状数组长什么样,有一个直观的认识。

    例5:我们以一个有 8 个元素的数组 A 为例(如上图),在数组 A 之上建立一个数组 C,使得数组 C 的形成如上的一个多叉树形状,数组 C 就是一个树状数组。此时我们有以下疑问:
    1、树状数组要建成树形结构吗?
    分析:不。学习过堆、线段树的朋友一定知道,只要能方便地索引左右孩子结点、双亲结点,这样的树就不必创建成结点、指针那样的树形结构;
    2、如何解释“前缀和查询”、“单点更新”。
    分析:例如,我们要查询前缀和(4),本来应该问 A1、A2、A3、A4,有了 C 数组之后,我们只要问 C2、C3、A4 即可。
    再如,我们要更新结点 A1 的值,只要自底向上更新 C1、C2、C4、C8 的值即可。

    在这个小数组中,可能我们无法体会到 ** Fenwick 树的威力,各位看官稍安勿躁,学习到后面,你就知道为什么 ** Fenwick 树对于“前缀和查询”和“单点更新”都非常频繁的业务来说是高效的。

    理解 C 数组的定义

    • 首先我们强调一下,树状数组的下标从 1 开始计数,这一点我们看到后面就会很清晰了。
    • 数组 C 是一个对原始数组 A 的预处理数组。
    • 几个记号。

    为了方便说明,避免后面行文啰嗦,我们将固定使用记号 (i)(j)(k),它们的定义如下,请读者一定先记住这两个记号所代表的含义:
    记号 (i) :表示预处理数组 (C) 的索引(十进制表示)。
    记号 (j) :表示原始数组 (A) 的索引(十进制表示)。
    我们通过以下的图,来看看 (C1)、C2、C3、C4、C5、C6、C7、C8 分别是如何定义的。

    上面的过程我们用如下的表来表示。

    (C) 数组的索引与 (A) 数组的索引的关系

    我们注意到上表中标注了“数组 (C) 中的元素来自数组 (A) 的个数”,它们的规律如下:
    将数组 (C) 的索引 (i) 表示成二进制,从右向左数出 0 的个数,遇到 1 则停止,记为 (k),计算 (2^k) 就是“数组 (C) 中的元素来自数组 (A) 的个数”,并且可以具体得到来自数组 (A) 的表示,即从当前索引 i 开始,从右向前数出 (2^k) 个数组 (A) 中的元素的和,即组成了 (C[i])。下面具体说明。

    记号 (k) :将 (i) 的二进制表示从右向左数出 0 的个数,遇到 1 则停止,记为 (k)。 我们只对数组 (C) 的索引 (i) 进行这个计算,数组 (A) 的索引 (j) 不进行相应的计算。理解 (k) 是怎么得到的是一个关键,请务必重视:

    例5:当 (i = 5) 时,计算 (k)
    分析:因为 5 的二进制表示是 0000 0101,从右边向左边数,第 1 个是 1 ,因此 0 的个数是 0,此时 (k) = 0。

    例6:当 (i = 8) 时,求 (k)
    分析:因为 8 的二进制表示是 0000 1000,从右边向左边数遇到 1 之前,遇到了 3 个 0,此时 (k) = 3。

    计算出 (k) 以后,(2^k) 立马得到,为此,我们可以马上画出如下表格:

    我们看到 (2^k) 是我们最终想要的。下面,我们介绍一种很酷的操作,叫做 lowbit ,它可以高效地计算 (2^k),即我们要证明:

    [lowbit(i) = 2^k ]

    其中 (k) 是将 (i) 表示成二进制以后,从右向左数出 0 的个数,遇到 1 则停止。

    通过 lowbit 操作高效计算 (2^k)

    lowbit(i) = i & (-i)
    

    对,就是这么简单。理解这行伪代码需要一些二进制和位运算的知识作为铺垫。
    首先,负数的二进制表示为:相应正数的二进制表示的反码 + 1。

    例7:计算 -6 的二进制表示。
    分析:6 的二进制表示为 0000 0110,先表示成反码,即 0 变1,1变0,得 1111 1001,再加 1,得 1111 1010。

    例8:当 i = 6 时,计算 lowbit(i) = i & (-i)。
    分析:由例 7,及“与运算”的定义,把它们按照数位对齐上下写好,
    0000 0110
    1111 1010
    0000 0010(上下同时为1才写 1,否则写 0)
    得 0000 0010,这个二进制数表示成十进制数就是 2。建议大家多在稿纸上写几个具体的例子来计算 lowbit,进而来理解为什么 lowbit(i) = (2^k)。下面我给出一个我的直观解释:
    如果我们直接将一个整数取反,在与原来的数做“与运算”,一定得到 0。妙就妙在,负数的二进制表示上,除了要求对“相应的正数取反”以外,还要“加 1”,在“加 1 ”的过程中产生的进位的次数即是“将 i 表示成二进制以后,从右向左数出 0 的个数(遇到1停止)”。

    众所周知,位运算是十分高效的,那么我们知道了 lowbit 以后,又有什么用呢?回答:它能帮助我们在树状数组中“从子结点索引到双亲节点”(对应“单点更新”操作),“计算前缀和由预处理数组的那些元素表示”(对应“前缀和查询操作”)。

    lowbit 的魅力所在

    “从子结点索引到双亲节点”(对应“单点更新”操作),

    例9:修改 (A[3]), 分析对数组 (C) 产生的变化。
    从图中,我们可以看出 (A[3]) 的双亲结点以及祖先结点依次是 (C[3])(C[4])(C[8]) ,所以修改了 (A[3]) 以后 (C[3])(C[4])(C[8]) 的值也要修改。
    先看 (C[3])(lowbit(3) = 1)(3 + lowbit(3) = 4) 就是 (C[3]) 的父亲结点 (C[4]) 的索引值。
    再看 (C[4])(lowbit(4) = 4)(4 + lowbit(4) = 8) 就是 (C[4]) 的父亲结点 (C[8]) 的索引值。
    从图中,也请大家验证,红色结点的索引值 + 右下角蓝色圆形结点的值 = 红色结点的双亲结点的索引值。我试图解释这个现象:3 即 0011,从右向左,遇到 0 放过,遇到 1 ,给这个数位加 1,(这个操作就相当于加上了一个 (2^k) 的二进制数,即一个 lowbit 值)可以看到,马上就发发生了进位,得到 0100,即 4 的二进制表示。
    接下来处理 0100,从右向左,遇到 0 放过,遇到 1 ,给这个数位加 1(同样地,这个操作就相当于加上了一个 (2^k) 的二进制数,即一个 lowbit 值),可以看到,马上就发发生了进位,得到 1000,即 8 的二进制表示。

    从我上面的描述中,你可以发现,我们又在做,从右边到左边数出 0 的个数这件事情了,
    因此从已知子结点的索引 (i)(i) 的父节点的索引 parent 即:(parent(i) = i + lowbit(i))

    可不过我还想说明的是,这不是巧合,和循环论证,这正是因为对 “从右边到左边数出 0 的个数,遇到 1 停止这件事情”的定义,使用 lowbit 可以快速计算这件事成立,才会有的。

    分析到这里“单点更新”的代码就可以马上写出来了。

    /**
     * 单点更新
     *
     * @param i     原始数组索引 i
     * @param delta 变化值 = 更新以后的值 - 原始值
     */
    public void update(int i, int delta) {
        // 从下到上更新,注意,预处理数组,比原始数组的 len 大 1,故 预处理索引的最大值为 len
        while (i <= len) {
            tree[i] += delta;
            i += lowbit(i);
        }
    }
    
    public static int lowbit(int x) {
        return x & (-x);
    }
    

    计算前缀和由预处理数组的那些元素表示”(对应“前缀和查询操作”)

    还是这张图。

    例 10 :求出前缀和(6)。
    由图可以看出 前缀和(6) = (C[6]) + (C[4])
    先看 (C[6])(lowbit(6) = 2)(6 - lowbit(6) = 4) 正好是 (C[6]) 的上一个非叶子结点 (C[4]) 的索引值。这里给出我的一个直观解释,如果下标表示高度,那么上一个非叶子节点,其实就是从右边向左边画一条水平线,遇到的墙的索引。只要这个值大于 0,都能正确求出来。

    例11:求出前缀和(5)。
    再看 (C[5])(lowbit(5) = 1)(5 - lowbit(6) = 4) 正好是 (C[5]) 的上一个非叶子结点 (C[4]) 的索引值,
    故 前缀和(5) = (C[5]) + (C[4])

    例12:求出前缀和(7)。
    再看 (C[7])(lowbit(7) = 1)(7 - lowbit(7) = 6) 正好是 (C[7]) 的上一个非叶子结点 (C[6]) 的索引值,再由例9 的分析,前缀和(7) =(C[7]) + (C[6]) + (C[4])

    例13:求出前缀和(8)。
    再看 (C[8])(lowbit(8) = 8)(8 - lowbit(8) = 0) , 0 表示没有,从图上也可以看出从右边向左边画一条水平线,不会遇到的墙,故前缀和(8) = (C[8]),是不是很酷 。

    经过以上的分析,求前缀和的代码也可以写出来了。

    /**
     * 查询前缀和
     *
     * @param i 前缀的最大索引,即查询区间 [0, i] 的所有元素之和
     */
    public int query(int i) {
        // 从右到左查询
        int sum = 0;
        while (i > 0) {
            sum += tree[i];
            i -= lowbit(i);
        }
        return sum;
    }
    

    树状数组的初始化

    树状数组的初始化可以通过“单点更新”来实现,因为“最最开始”的时候,数组的每个元素的值都为 0,每个都对应地加上原始数组的值,就完成了预处理数组 (C) 的创建。
    这里要特别注意,update 操作的第 2 个索引值是一个变化值,而不是变化以后的值。因为我们的操作是逐层上报,汇报变更值会让我们的操作更加简单,这一点请大家反复体会。

    public FenwickTree(int[] nums) {
        this.len = nums.length + 1;
        tree = new int[this.len + 1];
        for (int i = 1; i <= len; i++) {
            update(i, nums[i]);
        }
    }
    

    综上,我们的树状数组的代码已经可以写出来了,是不是很酷。

    public class FenwickTree {
    
        /**
         * 预处理数组
         */
        private int[] tree;
        private int len;
    
        public FenwickTree(int n) {
            this.len = n;
            tree = new int[n + 1];
        }
    
        public FenwickTree(int[] nums) {
            this.len = nums.length + 1;
            tree = new int[this.len + 1];
            for (int i = 1; i <= len; i++) {
                update(i, nums[i]);
            }
        }
    
        /**
         * 单点更新
         *
         * @param i     原始数组索引 i
         * @param delta 变化值 = 更新以后的值 - 原始值
         */
        public void update(int i, int delta) {
            // 从下到上更新,注意,预处理数组,比原始数组的 len 大 1,故 预处理索引的最大值为 len
            while (i <= len) {
                tree[i] += delta;
                i += lowbit(i);
            }
        }
    
        /**
         * 查询前缀和
         *
         * @param i 前缀的最大索引,即查询区间 [0, i] 的所有元素之和
         */
        public int query(int i) {
            // 从右到左查询
            int sum = 0;
            while (i > 0) {
                sum += tree[i];
                i -= lowbit(i);
            }
            return sum;
        }
    
        public static int lowbit(int x) {
            return x & (-x);
        }
    }
    

    暂时先到这里,后面我们会添加一些例题来解释树状数组的应用。

  • 相关阅读:
    打造绿色JRE
    AOSP(Android Open Source Project) Android开源项目
    SyncML协议简述
    诺基亚 索爱 低端手机及智能手机 与 QQ邮箱或MyTT 通讯录同步 介绍
    Android 的源代码结构
    recovery v1跟recovery v2的区别
    Android系统架构
    haoGOD6.6 修改自XDA的thedroidsector的kingkernel #8 04/22
    下一代Android或官方支持“App2sd”
    Android 中的微型云
  • 原文地址:https://www.cnblogs.com/liweiwei1419/p/9143081.html
Copyright © 2020-2023  润新知