线段树(Segment Tree)和树状数组(Fenwick Tree)

前言

在刷题过程中,经常会遇到求数组某区间之和的问题:给出数组a[0...n-1],求数组下标i~j的元素之和a[i]+...+a[j],0<=i<=j<n。
纯暴力做法是O(n),即把区间的数加起来。
好一点办法的是创建一个前缀和(prefix sum)数组p,使得p[i]=a[0]+...+a[i],那么所求结果就是p[j]-p[i-1],当然i=0需要特别处理一下。这样准备工作是O(n),查询只需要O(1)。
一般来说,数组不变的情况下方法二已经够用了。但是当数组有可能改变的时候,每次改变都需要更新前缀和数组,效率不高。
好在,有专门的数据结构来满足这种操作需求。今天我们就来简单学习一下线段树(Segment Tree)和树状数组(Fenwick Tree)。

线段树

顾名思义,线段树是树。具体一点,二叉树。那么“线段”二字如何体现?是把i-j区间之和想象成i-j的线段(我瞎猜的)。简而言之,线段树就是把区间之和用树来显示。
很自然地,根是整个数组区间,然后左右分半,依次类推。奇数情况下是左边多还是右边多呢?其实都可以,不过考虑区间[a,b],最自然的写法可分为[a,(a+b)/2]与[(a+b)/2,b],当b-a是奇数时右边多,这样写起来更自然方便些。
从网上搜了个图,这样更直观:


image.png

怎么构造这棵树呢?
首先要决定树的节点。左右子节点等属性是很自然的。同时需要存储表示的区间,以及区间之和。
需不需要指针指向parent节点呢?大可不必。
至于重要的几个操作,构造,查询,更新,抓住递归的特点,自下而上地进行,一切都显得自然了。
构造:递归,先构造左右,再构造自己,这样其实是从底层往上构造。
查询:同样递归,假如查询的区间就是自己马上返回,否则就有三种情况:查询左边,查询右边,两边都有。
更新:和查询类似,不同的是一次只更新一个元素,所以只会走一边。路径相当于查询[i,i],下到最底层更新值。注意路径之上的节点也要跟着更新和。
当然了,start和end在这里是inclusive的,和java传统惯例有点不一样,但是这系列的习惯好像就这样,毕竟要表示单个元素。树的高度是O(logn),而查询操作近似遍历树,所以也是O(logn)。更新操作类似,同样是O(logn)。构造的话,要把节点都过一遍,所以是O(n)。代码如下:

class SegmentTree {

    private static class TreeNode {
        int start, end, sum;
        TreeNode left, right;

        TreeNode(int start, int end) {
            this.start = start;
            this.end = end;
        }
    }

    private TreeNode buildTree(int[] nums, int start, int end) {
        if (start > end) return null;
        TreeNode cur = new TreeNode(start, end);
        if (start == end) cur.sum = nums[start];
        else {
            int mid = start + (end - start) / 2;
            cur.left = buildTree(nums, start, mid);
            cur.right = buildTree(nums, mid + 1, end);
            cur.sum = cur.left.sum + cur.right.sum;
        }
        return cur;
    }

    private void updateTree(TreeNode node, int i, int val) {
        if (node.start == node.end) {
            node.sum = val;
        } else {
            int mid = node.start + (node.end - node.start) / 2;
            if (i <= mid) updateTree(node.left, i, val);
            else updateTree(node.right, i, val);
            node.sum = node.left.sum + node.right.sum;
        }
    }

    private int queryTree(TreeNode node, int i, int j) {
        if (node.start == i && node.end == j) return node.sum;
        else {
            int mid = node.start + (node.end - node.start) / 2;
            if (j <= mid) {
                return queryTree(node.left, i, j);
            } else if (i >= (mid + 1)) {
                return queryTree(node.right, i, j);
            } else {
                return queryTree(node.left, i, mid) + queryTree(node.right, mid + 1, j);
            }
        }
    }

    private TreeNode root;

    SegmentTree(int[] nums) {
        root = buildTree(nums, 0, nums.length - 1);
    }

    public void update(int i, int val) {
        updateTree(root, i, val);
    }

    public int sumRange(int i, int j) {
        return queryTree(root, i, j);
    }
}

代码量还是不小的,毕竟用了树。

树状数组

树状数组(也叫Binary Indexed Tree)就是指用数组来表示一棵树,这样空间上更节约。类似的还有binary heap。
那么树怎么放进数组呢?根据维基百科,对于数组里的下标i进行一个特定的位运算,就可以获得其父节点所在的下标。听起来很神奇?刚开始我也不知所云,直到看了Competitive Programming Algorithms的解释:

image.png

翻译一下。本质上我们需要的就是一个函数g,使得0<=g(i)<=i,这样的话假如T[i]=A[g(i)]+...+A[i],我们想要求i的前缀和,因为已经有了g(i)之后的了,于是往前看,右边界就是g(i)-1,那么左边界是哪里呢?自然还是利用g函数,也就是说前一段是A[g(g(i)-1)]+...+A[g(i)-1],根据定义这就是T[g(i)-1]。然后依次类推直至到0。
这样时间复杂度主要取决于g函数。假如g(i)=i,那么T=A;假如g(i)=0,那么T就是前缀和数组。这些我们都知道并不是最优选择。所以这个数据结构巧妙的地方就在于g函数的设定,使得查询和更新都能达到O(logn)的效率。
g(i)的定义:将i二进制表示的末尾的连续的1全部换成0,例如g(0b1001)=0b1000,g(0b1100)=0b1100,g(0b1111)=0。或者可以用位运算阐释:g(i)=i&(i+1)
除此之外,我们还需要一个运算来更新。因为假如要更新i下标,我们得知道这个i到底在哪一段上,也就是说要找到所有的j,使得g(j)<=i<=j,然后更新T[j]。假设这个运算为h。h(j)=j|(j+1)。以i=10为例,第一个j自然是i本身,然后就是h(0b1010)=0b1011=11,而g(0b1011)=0b1000=8小于10满足条件;下一个是h(0b1011)=15,同样满足条件。通过几个例子可以看出,j|(j+1)肯定是大于等于j的。
同样引用cp-algo里面的图,绿色代表T涵盖的范围:
image.png

值得注意的一点是这里的T下标是从0开始的,也可以从1开始。
什么意思呢?之前往前推,我们需要用g(i)-1,那么假如我们直接让T[i]=|g(i)+1,i|范围的和,往前推就直接是g(i)了,更方便。但是这样有一个问题,就是i=0的时候不满足之前的假设。因此,我们让T下标都增加1,也就是说其实T[1]对应的是A[0],T[0]是0。
g和h函数也要相应调整。g(i)=i−(i & (−i))也就是把最后一个1变为0.h(i)=i+(i & (−i))即最后一个1进位。此写法显得更对称一些,因此网上大多采用这种。
代码实现:

class BinaryIndexedTree {

    private final int[] BITree, arr;

    public BinaryIndexedTree(int[] arr) {
        this.arr = arr;
        BITree = new int[arr.length + 1];
        for (int i = 0; i < arr.length; i++) updateBIT(i, arr[i]);
    }

    public int sumRange(int i, int j) {
        return getSum(j) - getSum(i - 1);
    }

    public void update(int i, int val) {
        int delta = val - arr[i];
        arr[i] = val;
        updateBIT(i, delta);
    }

    private void updateBIT(int index, int delta) {
        index++;
        while (index < BITree.length) {
            BITree[index] += delta;
            index += index & (-index);
        }
    }

    private int getSum(int index) {
        int sum = 0;
        index++;
        while (index > 0) {
            sum += BITree[index];
            index -= index & (-index);
        }
        return sum;
    }
}

可见,其实树状数组并不是简单地把线段树装进数组,而是有另一套计算方法,差别还是挺明显的。
相比之下,树状数组代码量更少,更高效,一般来说是更好的选择。

例题

Range Sum Query - Mutable - LeetCode
以上两种实现改个名字就可以直接当答案了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容