码二说之封装自己的专属线段树SegmentTree

前言

堆(Heap)是一种树结构,线段树(Segment Tree)也是一种树结构,它也被叫做区间树(Interval Tree)。

还是那句老话:光看文章能够掌握两成,动手敲代码、动脑思考、画图才可以掌握八成。源码仓库

不要完美主义。掌握好“度”。

太过于追求完美会把自己逼的太紧,会产生各种焦虑的心态,最后甚至会怀疑自己,温故而知新,不要停止不前,掌握好这个度,不存在你把那些你认为完全掌握了,然后就成了某一个领域的专家,相反一旦你产生很浓厚的厌恶感,那么就意味着你即将会放弃或者已经选择了放弃,虽然你之前想把它做到 100 分,但是由于你的放弃让它变为 0 分。

学习本着自己的目标去。不要在学的过程中偏离了自己的目标。要分清主次。 难的东西,你可以慢慢的回头看一看。那样才会更加的柳暗花明,更能提升自己的收获。

为什么使用线段树

为什么使用线段树,线段树解决什么样的特殊问题?
对于有一类的问题,只需要关心的是一个线段(或者区间),有一道竞赛的题目,也是最经典的线段树问题:区间染色,它是一个非常好的应用线段树的场景。有一面墙,长度为 n,每次选择一段儿墙进行染色,m 次操作后,可以看到多少种颜色?m 次操作后,可以在[i,j]区间内看到多少种颜色?染色操作(更新区间)、查询操作(查询区间)。

完全可以使用数组实现来解决这个问题。如果你要对某一段区间进行染色,那么就遍历这一段区间,把相应的值修改成新的元素就 ok 了。
染色操作(更新区间)相应的复杂度就是O(n)级别的,查询操作(查询区间)也只需要遍历一遍这个区间就 ok 了,相应的复杂度也是O(n)级别的。
如果对于一种数据结构,其中某一些操作是O(n)级别的话,动态的使用这种数据结构,相应的性能很有可能是不够的,在实际的环境中很有可能需要性能更加好的这样一种时间复杂度,这就是使用数组来实现区间染色问题相应的一个局限性

在这样的问题中主要关注的是区间或一个个的线段,所以此时线段树这样的数据结构就有用武之地了。在平时使用计算机来处理数据的时候,有一类很经典的同时也是应用范围非常广的的问题,就是进行区间查询,类似统计操作的查询,查询一个区间[i,j]的最大值、最小值,或者区间数字和。

实质:基于区间的统计查询

问题一:2017 年注册用户中到现在为止消费最高的用户、消费最少的用户、学习时间最长的用户?
2017 年注册的到现在为止,这个数据其实还在不断的变化,是一种动态的情况,此时线段树就是一个好的选择。

问题二:某个太空区间中天体总量?
由于天体不断的在运动,总会有一个天体从一个区间来到另外一个区间,甚至发生爆炸消失之类的物理现象,在某个区间中或某几个区间中都多了一些天体,会存在这样的现象的,所以就需要使用线段树了。

对于这面墙有一个不停的对一个区间进行染色这样的一个操作,就是更新这个操作,于此同时基于整个数据不时在不停的在更新,还需要进行查询这样的两个操作,同理其实对于这些问题,可以使用数组来实现,不过它的复杂度都是O(n)级别的,但是如果使用线段树的话,那么在区间类的统计查询这一类的问题上,更新和查询这两个操作都可以在O(logn)这个复杂度内完成。

对于线段树来说它一定也是一种二叉树的结构.对于线段树抽象出来就是解决这样的一类问题,对于一个给定的区间,相应的要支持两个操作。
更新:更新区间中一个元素或者一个区间的值。
查询:查询一个区间[i,j]的最大值、最小值、或者区间数字和。
对于一个区间可以查询的内容会很多,要根据实际的业务逻辑进调整,不过整体是基于一个区间进行这种统计查询的。

如何使用 logn 复杂度去实现这一点?
首先对于给定的数组进行构建,假如有八个元素,把它们构建成一棵线段树,对于线段树来说,不考虑往线段树中添加元素或者删除元素的。
在大多数情况下线段树所解决的问题它的区间是固定的。比如一面墙进行区间染色,那面墙本身是固定的,不去考虑这面墙后面又建起了新的一面墙这种情况,只考虑给定的这一面墙进行染色。
比如统计 2017 年注册的用户,那么这个区间是固定的;或者观察天体,观察的外太空所划分的区间已经固定了,只是区间中的元素可能会发生变化;所以对于这个数组直接使用静态数组就好了。

线段树也是一棵树,每一个节点表示一个区间内相应的信息。

比如 以线段树来统计区间内的和为例。在这样的情况下,线段树每一个节点存储的就是一段区间的数字和,根节点存储的就是整个区间相应的数字和,之后从根节点平均将整个的区间分成两段,这两段代表着两个节点,相应的这两个节点会再分出两个区间,直至最后每一个叶子节点只会存一个元素。
从区间的角度上来讲,每个元素本身就是一个区间,只不过每一个区间的长度为 1 而已,也就是对于整棵线段树来说每一个节点存储的是一个区间中相应的统计值。比如使用线段树求和,每个节点存储的是一个区间中数字和,当你查询某个区间的话,相应的你只需要找到对应的某个节点即可,一步就到了这个节点,并不需要将所有的元素全部都遍历一遍了。
但是并不是所有的节点每次都满足这样的条件,所以有时候需要到用两个节点,将两个节点进行相应的结合,结合之后就可以得到你想要的结果。尽管如此,当数据量非常大的时候,依然可以通过线段树非常快的找到你所关心的那个区间对应的一个或者多个节点,然后在对那些节点的内容进行操作,而不需要对那个区间中所有的元素中每一个元素相应的进行一次遍历,这一点也是线段树的优势

线段树的基础表示

线段树就是二叉树每一个节点存储的是一个线段(区间)相应的信息。这个相应的信息不是指把这个区间中所有的元素都存进去,比如 以求和操作为例,那么每一个节点相应的存储的就是这个节点所对应的区间的那个数字和。

线段树不一定是满的二叉树,线段树也不一定是一棵完全二叉树,线段树是一棵平衡二叉树,也就是说对于整棵树来说,最大的深度和最小的深度,它们之间的差最多只能为 1。
堆也是一棵平衡二叉树,完全二叉树本身也是一棵平衡二叉树,线段树虽然不是一棵完全二叉树,但是它满足平衡二叉树的定义,二分搜索树不一定是一棵平衡二叉树,因为二分搜索树没有任何机制能够保证最大深度和最小深度之间差不超过 1。

平衡二叉树的优势
不会像二分搜索树那样在最差的情况下退化为一个链表,一棵平衡二叉树整棵树的高度和它的节点之间的关系一定是一个 log 之间的关系,这使得在平衡二叉树上搜索查询是非常高效的。

线段树虽然不是完全二叉树,但是这样的一个平衡二叉树,也可以使用数组的方式来表示,对线段树来说其实可以把它看作是一棵满二叉树,但是可能在最后一层很多节点是不存在的,对于这些不存在的节点只需要把它看作是空即可,这样一来就是一棵满二叉树了,满二叉树是一棵特殊的完全二叉树,那么它就一定可以使用数组来表示。

满二叉树的性质
满二叉树每层的节点数与层数成次方关系,0 层就是 2^0,1 层就是 2^1。最后一层的节点数是 前面所有层的节点之和 然后再加上一(当前层节点数是 前面所有层节点数的总和 然后另外再加一),最后一层的节点数是 前面一层节点的两倍(当前层节点数是 前一层节点数的两倍)。
整棵满二叉树实际的节点个数就是2^h-1个(最后一层也就是(h-1层),有2^(h-1)个节点,最后一层节点数是 前面所有层节点数的总和 另外再加一,所以总节点数也就是2 * 2^(h-1)-1个节点,这样一来就是2^h-1个)。

那么就有一个问题了,如果区间中有 n 个元素,那么使用数组表示时那么数组的空间大小是多少,也就是这棵线段树上应该有多少个节点。
对于一棵满的二叉树,这一棵的层数和每一层的节点之间是有规律的,第 0 层节点数为 1,第 1 层节点数为 2,第 2 层节点数为 4,第 3 层节点数为 8。那么第(h-1)层节点数为2^(h-1),下层节点的数量是上层节点数量的 2 倍,第 3 层的节点数量是第 2 层的节点数量的 2 倍,所以对于满二叉树来说,h 层,一共有2^h-1个节点(大约是2^h)。
这是等比数列求和的公式,那么当数组的空间为2^h时一定可以装下满二叉树所有的元素,最后一层(h-1层),有2^(h-1)个节点,那么最后一层的节点数大致等于前面所有层节点之和。

那么原来的问题是如果区间有 n 个元素,数组表示需要有多少节点?
答案是 log 以 2 为底的 n 为多少,也就是 2 的多少次方为 n。
如果这 n 是 2 的整数次幂,那么只需要 2n 的空间,这是因为除了最后一层之外,上层的所有节点大概也等于 n。虽然实际来说是 n-1,但是这一个空间富余出来没有关系,只需要 2n 的空间就足以存储整棵树了,但是关键是通常这个 n 不一定是 2 的 k 次方幂,也就是这个 n 不一定是 2 的整数次幂。
如 n=2^k+r,r 肯定不等于 2^k,那么在最坏的情况下,如果 n=2^k+1,那么最后一层不足以存储整个叶子节点的,因为叶子节点的索引范围会超出 2n 的数组范围内,n=2^k+3 就会超出,那么叶子节点肯定是在倒数的两层的范围里,那么就还需要再加一层,加的这一层如果使用满二叉树的方式存储的话,那么就在原来的基础上再加一倍的空间,此时整棵满二叉树需要 4n 的空间,这样才可以存储所有的节点。
对于创建的这棵线段树来说,如果你考虑的这个区间一共有 n 个元素,那么选择使用数组的方式进行存储的话,只需要有 4n 的空间就可以存储整棵线段树了。在这 4n 的空间里并不是所有的空间都被利用了,因为这个计算本身是一个估计值,在计算的过程中不是严格的正好可以存储整个线段树的所有的节点。 其实做了一些富余,对于线段树来说并不一定是一棵满二叉树,所以才在最后一层的地方,很有可能很多位置都是空的,这 4n 的空间有可能是有浪费掉的,在最坏的情况下至少有一半的空间是被浪费掉的。
但是不过度的考虑这些浪费的情况,对于现代计算机来说存储空间本身并不是问题,做算法的关键就是使用空间来换时间,希望在时间性能上有巨大的提升。这部分浪费本身也是可以避免的,不使用数组来存储整棵线段树,而使用链式的结构如二分搜索树那种节点的方式来存储整棵线段树,就可以避免这种空间的浪费。

如果区间有 n 个元素,数组需要开 4n 的空间就好了,于此同时这 4n 的空间是一个静态的空间,因为对于线段树来说并不考虑添加元素,也就是说考虑的整个区间是固定的,这个区间的大小不会再改变了,真正改变的是区间中的元素,所以不需要使用自己实现的动态数组,直接开 4n 的静态空间即可。

代码示例

(class: MySegmentTree)

MySegmentTree

// 自定义线段树 SegmentTree
class MySegmentTree {
    constructor(array) {
        // 拷贝一份参数数组中的元素
        this.data = new Array(array.length);
        for (var i = 0; i < array.length; i++) this.data[i] = array[i];

        // 初始化线段树 开4倍的空间 这样才能在所有情况下存储线段树上所有的节点
        this.tree = new Array(4 * this.data.length);
    }

    // 获取线段树中实际的元素个数
    getSize() {
        return this.data.length;
    }

    // 根据索引获取元素
    get(index) {
        if (index < 0 || index >= this.getSize())
        throw new Error('index is illegal.');
        return this.data[index];
    }

    // 辅助函数:返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引
    // 计算出线段树中指定索引位置的元素其左孩子节点的索引 -
    calcLeftChildIndex(index) {
        return index * 2 + 1;
    }

    // 辅助函数:返回完全二叉树的数组表示中,一个索引所表示的元素的右孩子节点的索引
    // 计算出线段树中指定索引位置的元素其右孩子节点的索引 -
    calcRightChildIndex(index) {
        return index * 2 + 2;
    }
}
复制代码

创建线段树

将线段树看作是一棵满的二叉树,这样一来就可以使用数组来存储整个线段树上所有的节点了,如果考虑的这个区间中有 n 个元素,那么这个数组就需要开 4n 个空间。

在数组中存储什么才可以构建出一棵线段树?这个逻辑是一个非常典型的递归逻辑,对于这个线段树的定义,根节点所存储的信息实际上就是它的两个孩子所存储的信息相应的一个综合,怎么去综合是以业务逻辑去定义的。
比如是以求和为例,创建这棵线段树是为了查询区间中数据的元素和这样的一个操作,相应的每一个节点存储的就是相应的一个区间中所有元素的和。比如有十个元素,那么根节点存储的就是这十个元素的和,相应它分出两个孩子节点,左孩子就是这十个元素中前五个元素相应的和,右孩子就是这十个元素中后五个元素相应的和,这两个节点下面的左右孩子节点依次再这样的划分,直到到达叶子节点。

这整个过程相当于是创建这棵线段树根,创建这棵线段树的根必须先创建好这个根节点对应的左右两个子树,只要有了这左右两个子树的根节点,那么这个线段树的根节点对应的这个值就是它的两个孩子所对应的值进行一下加法运算即可。
对于左右两棵子树的创建也是如此,为了要创建它们的根节点,那么还是要创建这个根节点对应的左右两个子树。依此类推,直到递归到底为止。也就是这个节点所对应的区间不能够再划分了,该节点所存储的这个区间的长度只为 1 了,这个区间只有一个元素,对于这一个元素,它的和就是这一个元素本身,那么就递归到底了。整体这个递归结构就是如此清晰的。

BuildingSegmentTree 的方法

有三个参数;第一个参数是在初始的时候这个线段树对应的索引,索引应该为 0,表示从 0 开始;第二、三参数是指对于这个节点它所表示的那个线段(区间)左右端点是什么,初始的时候,左端点的索引应该为 0,右端点的索引应该为原数组的长度减 1;递归使用的时候,也就是在 treeIndex 的位置创建表示区间[l...r]的线段树。

BuildingSegmentTree 的方法的逻辑
如果真的要表示一个区间的话,那么相应的处理方式是这样的,先获取这个区间的左右节点的索引,这个节点一定会有左右孩子,先创建和这个节点的左右子树,基于两个区间才能创建线段树。
计算这个区间的左右范围,计算公式:mid = (left + right) / 2,这个计算可能会出现整型溢出的问题,但是概率很低,那么计算公式可以换一种写法:mid = left + (right - left) / 2
左子树区间为 left至mid,右子树区间为 mid+1至right,递归创建线段树,之后进行业务处理操作。例如 求和、取最大值、取最小值,综合左右两个线段的信息,来得到当前的更大的这个线段相应的信息。
如果去综合,是根据你的业务逻辑来决定的,使用一个如何去综合的接口,这样一来就会根据你传入的方法来进行综合的操作。这个和 自定义的优先队列中的 updateCompare 传入的 方法的意义是一样的,只不过 updateCompare 是传入比较的方法,用来在优先队列中如何比较两个元素值,而 updateMerge 是传入融合的方法,用来线段树中构建线段树时两个元素如何去融合。

代码示例

(class: MySegmentTree, class: Main)

MySegmentTree:线段树

// 自定义线段树 SegmentTree
class MySegmentTree {
    constructor(array) {
        // 拷贝一份参数数组中的元素
        this.data = new Array(array.length);
        for (var i = 0; i < array.length; i++) this.data[i] = array[i];

        // 初始化线段树 开4倍的空间 这样才能在所有情况下存储线段树上所有的节点
        this.tree = new Array(4 * this.data.length);

        // 开始构建线段树
        this.buildingSegmentTree(0, 0, this.data.length - 1);
    }

    // 获取线段树中实际的元素个数
    getSize() {
        return this.data.length;
    }

    // 根据索引获取元素
    get(index) {
        if (index < 0 || index >= this.getSize())
        throw new Error('index is illegal.');
        return this.data[index];
    }

    // 构建线段树
    buildingSegmentTree(treeIndex, left, right) {
        // 解决最基本问题
        // 当一条线段的两端相同时,说明这个区间只有一个元素,
        // 那么递归也到底了
        if (left === right) {
            this.tree[treeIndex] = this.data[left];
            return;
        }

        // 计算当前线段树的左右子树的索引
        const leftChildIndex = this.calcLeftChildIndex(treeIndex);
        const rightChildIndex = this.calcRightChildIndex(treeIndex);

        // 将一个区间拆分为两段,然后继续构建其左右子线段树
        let middle = Math.floor(left + (right - left) / 2); //(left + right) / 2

        // 构建左子线段树
        this.buildingSegmentTree(leftChildIndex, left, middle);
        // 构建右子线段树
        this.buildingSegmentTree(rightChildIndex, middle + 1, right);

        // 融合左子线段树和右子线段树
        this.tree[treeIndex] = this.merge(
        this.tree[leftChildIndex],
        this.tree[rightChildIndex]
        );
    }

    // 辅助函数:返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引
    // 计算出线段树中指定索引位置的元素其左孩子节点的索引 -
    calcLeftChildIndex(index) {
        return index * 2 + 1;
    }

    // 辅助函数:返回完全二叉树的数组表示中,一个索引所表示的元素的右孩子节点的索引
    // 计算出线段树中指定索引位置的元素其右孩子节点的索引 -
    calcRightChildIndex(index) {
        return index * 2 + 2;
    }

    // 辅助函数: 融合两棵线段树,也就是对线段树进行业务逻辑的处理
    merge(treeElementA, treeElmentB) {
        // 默认进行求和操作
        return treeElementA + treeElmentB;
    }

    // 辅助函数:更新融合的方法,也就是自定义处理线段树融合的业务逻辑
    updateMerge(mergeMethod) {
        this.merge = mergeMethod;
    }

    // @Override toString() 2018-11-7 jwl
    toString() {
        let segmentTreeConsoleInfo = ''; // 控制台信息
        let segmentTreePageInfo = ''; // 页面信息

        // 输出头部信息
        segmentTreeConsoleInfo += 'SegmentTree:';
        segmentTreePageInfo += 'SegmentTree:';
        segmentTreeConsoleInfo += '\r\n';
        segmentTreePageInfo += '<br/><br/>';

        // 输出传入的数据信息
        segmentTreeConsoleInfo += 'data = [';
        segmentTreePageInfo += 'data = [';

        for (let i = 0; i < this.data.length - 1; i++) {
        segmentTreeConsoleInfo += this.data[i] + ',';
        segmentTreePageInfo += this.data[i] + ',';
        }

        if (this.data != null && this.data.length != 0) {
        segmentTreeConsoleInfo += this.data[this.data.length - 1];
        segmentTreePageInfo += this.data[this.data.length - 1];
        }
        segmentTreeConsoleInfo += '],\r\n';
        segmentTreePageInfo += '],<br/><br/>';

        // 输出生成的线段树信息
        segmentTreeConsoleInfo += 'tree = [';
        segmentTreePageInfo += 'tree = [';
        let treeSize = 0;
        for (let i = 0; i < this.tree.length - 1; i++) {
        if (this.tree[i] !== undefined) treeSize++;
        segmentTreeConsoleInfo += this.tree[i] + ',';
        segmentTreePageInfo += this.tree[i] + ',';
        }
        if (this.tree != null && this.tree.length != 0) {
        if (this.tree[this.tree.length - 1] !== undefined) treeSize++;
        segmentTreeConsoleInfo += this.tree[this.tree.length - 1];
        segmentTreePageInfo += this.tree[this.tree.length - 1];
        }
        segmentTreeConsoleInfo += '],\r\n';
        segmentTreePageInfo += '],<br/><br/>';
        segmentTreeConsoleInfo += 'originArraySize:' + this.getSize() + ',';
        segmentTreePageInfo += 'originArraySize:' + this.getSize() + ',';
        segmentTreeConsoleInfo +=
        'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
        segmentTreePageInfo +=
        'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;

        // 返回输出的总信息
        document.body.innerHTML += segmentTreePageInfo;
        return segmentTreeConsoleInfo;
    }
}
复制代码

Main:主函数

// main 函数
class Main {
    constructor() {
        this.alterLine('MySegmentTree Area');
        // 初始数据
        const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
        // 初始化线段树,将初始数据和融合器传入进去
        let mySegmentTree = new MySegmentTree(nums);
        // 指定线段树的融合器
        mySegmentTree.updateMerge((a, b) => a + b);

        // 输出
        console.log(mySegmentTree.toString());
    }

    // 将内容显示在页面上
    show(content) {
        document.body.innerHTML += `${content}<br /><br />`;
    }

    // 展示分割线
    alterLine(title) {
        let line = `--------------------${title}----------------------`;
        console.log(line);
        document.body.innerHTML += `${line}<br /><br />`;
    }
}

// 页面加载完毕
window.onload = function() {
    // 执行主函数
    new Main();
};
复制代码

线段树查询

要有两个查询方法,一个普通查询,一个是递归查询。

普通查询

有两个参数,也就是你要查询的区间,左端点与右端点的索引,先检查 待查询的区间左右两端的索引是否符合要求,有没有越界。
然后调用递归查询,首次递归函数调用时,需要从根节点开始,也就是第一个参数索引为 0,以及搜索范围从根节点的左右两端开始也就是从 0 到原数组的长度减 1。
然后就是你要指定要查询的线段(区间),也就是从一个大范围内找到一个小线段(区间),最后也是获取这个线段(区间)。
其实就是获取这个线段(区间)在进行过业务处理操作后得到的结果,如 求和、取最大值、取最小值,综合线段(区间)树的信息返回最终结果。

递归查询

有五个参数,第一个 当前节点所对应的索引,第二个第三个 当前节点它所表示的那个线段(区间)左右端点是什么,第四个第五个 待查询的线段(区间),也就是要查询的这个线段(区间)的左右端点。

递归查询的逻辑

如果查询范围的左右端点刚好与待查询的线段(区间)的左右端点一致,那么就说明当前正好就查询到了待查询的这个线段了,那么直接返回当前当前节点即可。
不一致的话,说明还需要向下缩小可查询范围,从而能够匹配到待查询的这个线段(区间)。向下缩小范围的方式,就是当前这个节点的左右端点之和除以 2,获取左右端点的中间值,求出 middle 之后,再继续递归查询当前节点的左右孩子节点,查询范围是当前节点的左端点到 middle 以及 middle+1 到右端点。
但是查询之前要判断待查询的线段(区间)到底在当前节点左子树中还是右子树中,如果在左子树中那么就直接把查询范围定位到当前节点左孩子节点中。如果在右子树中那么就直接把查询范围定位到当前节点右孩子节点中,这样就完成了在一个节点的左子线段树或右子线段树中再继续查找了。
这个查询范围在很明确情况下开始收缩,直到查询范围的左右端点刚好与待查询的线段(区间)的左右端点完全一致,递归查询就完毕了,直接返回那个线段(区间)的节点即可。
但是问题来了,如果待查询的线段(区间)很不巧的同时分布在某一个线段(区间)左右子线段树中,这样一来就永远都无法匹配到查询范围的左右端点刚好与待查询的线段(区间)的左右端点一致的情况,那就麻烦了。
那么就需要同时在某一个线段(区间)左右子线段树中查询,查询的时候待查询的线段(区间)也要做相应的缩小,因为查询的范围也缩小了,如果待查询的线段(区间)不做相应的缩小,那就会形成死递归,因为永远无法完全匹配。
随着查询的范围缩小,待查询的线段(区间)会大于这个查询范围,待查询的线段(区间)缩小的方式和查询范围缩小的方式一致,从待查询的线段(区间)左端点到 middle 以及 middle+1 到右端点,最后将查询到的两个结果进行一下融合,最终返回这个融合的结果,一样可以达到如此的效果。

代码示例

(class: MySegmentTree, class: Main)

MySegmentTree:线段树

// 自定义线段树 SegmentTree
class MySegmentTree {
    constructor(array) {
        // 拷贝一份参数数组中的元素
        this.data = new Array(array.length);
        for (var i = 0; i < array.length; i++) this.data[i] = array[i];

        // 初始化线段树 开4倍的空间 这样才能在所有情况下存储线段树上所有的节点
        this.tree = new Array(4 * this.data.length);

        // 开始构建线段树
        this.buildingSegmentTree(0, 0, this.data.length - 1);
    }

    // 获取线段树中实际的元素个数
    getSize() {
        return this.data.length;
    }

    // 根据索引获取元素
    get(index) {
        if (index < 0 || index >= this.getSize())
        throw new Error('index is illegal.');
        return this.data[index];
    }

    // 构建线段树
    buildingSegmentTree(treeIndex, left, right) {
        // 解决最基本问题
        // 当一条线段的两端相同时,说明这个区间只有一个元素,
        // 那么递归也到底了
        if (left === right) {
        this.tree[treeIndex] = this.data[left];
        return;
        }

        // 计算当前线段树的左右子树的索引
        const leftChildIndex = this.calcLeftChildIndex(treeIndex);
        const rightChildIndex = this.calcRightChildIndex(treeIndex);

        // 将一个区间拆分为两段,然后继续构建其左右子线段树
        let middle = Math.floor(left + (right - left) / 2); //(left + right) / 2

        // 构建左子线段树
        this.buildingSegmentTree(leftChildIndex, left, middle);
        // 构建右子线段树
        this.buildingSegmentTree(rightChildIndex, middle + 1, right);

        // 融合左子线段树和右子线段树
        this.tree[treeIndex] = this.merge(
        this.tree[leftChildIndex],
        this.tree[rightChildIndex]
        );
    }

    // 查询指定区间的线段树数据
    // 返回区间[queryLeft, queryRight]的值
    query(queryLeft, queryRight) {
        if (
        queryLeft < 0 ||
        queryRight < 0 ||
        queryLeft > queryRight ||
        queryLeft >= this.data.length ||
        queryRight >= this.data.length
        )
        throw new Error('queryLeft or queryRight is illegal.');

        // 调用递归的查询方法
        return this.recursiveQuery(
        0,
        0,
        this.data.length - 1,
        queryLeft,
        queryRight
        );
    }

    // 递归的查询方法 -
    // 在以treeIndex为根的线段树中[left...right]的范围里,
    // 搜索区间[queryLeft...queryRight]的值
    recursiveQuery(treeIndex, left, right, queryLeft, queryRight) {
        // 如果查询范围 与 指定的线段树的区间 相同,那么说明完全匹配,
        // 直接返回当前这个线段即可,每一个节点代表 一个线段(区间)处理后的结果
        if (left === queryLeft && right === queryRight)
        return this.tree[treeIndex];

        // 求出当前查询范围的中间值
        const middle = Math.floor(left + (right - left) / 2);

        // 满二叉树肯定有左右孩子节点
        // 上面的判断没有完全匹配,说明需要继续 缩小查询范围,也就是要在左右子树中进行查询了
        const leftChildIndex = this.calcLeftChildIndex(treeIndex);
        const rightChildIndex = this.calcRightChildIndex(treeIndex);

        // 判断:
        //    1. 从左子树中查还是右子树中查,又或者从左右子树中同时查,然后将两个查询结果融合。
        //    2. 如果 待查询的区间的左端点大于查询范围的中间值,说明只需要从右子树中进行查询即可。
        //    3. 如果 待查询的区间的右端点小于查询范围的中间值 + 1,说明只需要从左子树中进行查询。
        //    4. 如果 待查询的区间在左右端点各分部一部分,说明要同时从左右子树中进行查询。
        if (queryLeft > middle)
        return this.recursiveQuery(
            rightChildIndex,
            middle + 1,
            right,
            queryLeft,
            queryRight
        );
        else if (queryRight < middle + 1)
        return this.recursiveQuery(
            leftChildIndex,
            left,
            middle,
            queryLeft,
            queryRight
        );
        else {
        // 求出 左子树中一部分待查询区间中的值
        const leftChildValue = this.recursiveQuery(
            leftChildIndex,
            left,
            middle,
            queryLeft,
            middle
        );
        // 求出 右子树中一部分待查询区间中的值
        const rightChildValue = this.recursiveQuery(
            rightChildIndex,
            middle + 1,
            right,
            middle + 1,
            queryRight
        );
        // 融合左右子树种的数据并返回
        return this.merge(leftChildValue, rightChildValue);
        }
    }

    // 辅助函数:返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引
    // 计算出线段树中指定索引位置的元素其左孩子节点的索引 -
    calcLeftChildIndex(index) {
        return index * 2 + 1;
    }

    // 辅助函数:返回完全二叉树的数组表示中,一个索引所表示的元素的右孩子节点的索引
    // 计算出线段树中指定索引位置的元素其右孩子节点的索引 -
    calcRightChildIndex(index) {
        return index * 2 + 2;
    }

    // 辅助函数: 融合两棵线段树,也就是对线段树进行业务逻辑的处理 -
    merge(treeElementA, treeElmentB) {
        // 默认进行求和操作
        return treeElementA + treeElmentB;
    }

    // 辅助函数:更新融合的方法,也就是自定义处理线段树融合的业务逻辑 +
    updateMerge(mergeMethod) {
        this.merge = mergeMethod;
    }

    // @Override toString() 2018-11-7 jwl
    toString() {
        let segmentTreeConsoleInfo = ''; // 控制台信息
        let segmentTreePageInfo = ''; // 页面信息

        // 输出头部信息
        segmentTreeConsoleInfo += 'SegmentTree:';
        segmentTreePageInfo += 'SegmentTree:';
        segmentTreeConsoleInfo += '\r\n';
        segmentTreePageInfo += '<br/><br/>';

        // 输出传入的数据信息
        segmentTreeConsoleInfo += 'data = [';
        segmentTreePageInfo += 'data = [';

        for (let i = 0; i < this.data.length - 1; i++) {
        segmentTreeConsoleInfo += this.data[i] + ',';
        segmentTreePageInfo += this.data[i] + ',';
        }

        if (this.data != null && this.data.length != 0) {
        segmentTreeConsoleInfo += this.data[this.data.length - 1];
        segmentTreePageInfo += this.data[this.data.length - 1];
        }
        segmentTreeConsoleInfo += '],\r\n';
        segmentTreePageInfo += '],<br/><br/>';

        // 输出生成的线段树信息
        segmentTreeConsoleInfo += 'tree = [';
        segmentTreePageInfo += 'tree = [';
        let treeSize = 0;
        for (let i = 0; i < this.tree.length - 1; i++) {
        if (this.tree[i] !== undefined) treeSize++;
        segmentTreeConsoleInfo += this.tree[i] + ',';
        segmentTreePageInfo += this.tree[i] + ',';
        }
        if (this.tree != null && this.tree.length != 0) {
        if (this.tree[this.tree.length - 1] !== undefined) treeSize++;
        segmentTreeConsoleInfo += this.tree[this.tree.length - 1];
        segmentTreePageInfo += this.tree[this.tree.length - 1];
        }
        segmentTreeConsoleInfo += '],\r\n';
        segmentTreePageInfo += '],<br/><br/>';
        segmentTreeConsoleInfo += 'originArraySize:' + this.getSize() + ',';
        segmentTreePageInfo += 'originArraySize:' + this.getSize() + ',';
        segmentTreeConsoleInfo +=
        'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
        segmentTreePageInfo +=
        'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;

        // 返回输出的总信息
        document.body.innerHTML += segmentTreePageInfo;
        return segmentTreeConsoleInfo;
    }
}
复制代码

Main:主函数

// main 函数
class Main {
    constructor() {
        this.alterLine('MySegmentTree Area');
        // 初始数据
        const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
        // 初始化线段树,将初始数据和融合器传入进去
        let mySegmentTree = new MySegmentTree(nums);
        // 指定线段树的融合器
        mySegmentTree.updateMerge((a, b) => a + b);

        // 输出
        console.log(mySegmentTree.toString());
        this.show('');
        this.alterLine('MySegmentTree Queue Area');
        console.log('查询区间[0, 2]:' + mySegmentTree.query(0, 2));
        this.show('查询区间[0, 2]:' + mySegmentTree.query(0, 2));
        console.log('查询区间[3, 9]:' + mySegmentTree.query(3, 9));
        this.show('查询区间[3, 9]:' + mySegmentTree.query(3, 9));
        console.log('查询区间[0, 9]:' + mySegmentTree.query(0, 9));
        this.show('查询区间[0, 9]:' + mySegmentTree.query(0, 9));
    }

    // 将内容显示在页面上
    show(content) {
        document.body.innerHTML += `${content}<br /><br />`;
    }

    // 展示分割线
    alterLine(title) {
        let line = `--------------------${title}----------------------`;
        console.log(line);
        document.body.innerHTML += `${line}<br /><br />`;
    }
}

// 页面加载完毕
window.onload = function() {
    // 执行主函数
    new Main();
};
复制代码

练一练 Leetcode上两个与线段树相关的问题

303.区域和检索-数组不可变https://leetcode-cn.com/problems/range-sum-query-immutable/
方式一:使用线段树
方式二:对数组进行一定的预处理

307.区域和检索 - 数组可修改https://leetcode-cn.com/problems/range-sum-query-mutable/
方式一:对数组进行一定的预处理,但是性能不是很好

代码示例

303 方式一 和 方式二

// 答题
class Solution {
    // leetcode 303. 区域和检索-数组不可变
    NumArray(nums) {
        /**
         * @param {number[]} nums
         * 处理方式一:对原数组进行预处理操作
         */
        var NumArray = function(nums) {
        if (nums.length > 0) {
            this.data = new Array(nums.length + 1);
            this.data[0] = 0;
            for (var i = 0; i < nums.length; i++) {
                this.data[i + 1] = this.data[i] + nums[i];
            }
        }
        };

        /**
         * @param {number} i
         * @param {number} j
         * @return {number}
         */
        NumArray.prototype.sumRange = function(i, j) {
        return this.data[j + 1] - this.data[i];
        };

        /**
         * Your NumArray object will be instantiated and called as such:
         * var obj = Object.create(NumArray).createNew(nums)
         * var param_1 = obj.sumRange(i,j)
         */

        /**
         * @param {number[]} nums
         * 处理方式二:使用线段树
         */
        var NumArray = function(nums) {
        if (nums.length > 0) {
            this.mySegmentTree = new MySegmentTree(nums);
        }
        };

        /**
         * @param {number} i
         * @param {number} j
         * @return {number}
         */
        NumArray.prototype.sumRange = function(i, j) {
        return this.mySegmentTree.query(i, j);
        };

        return new NumArray(nums);
    }
}
复制代码

307 方式一

// 答题
class Solution {
    // leetcode 307. 区域和检索 - 数组可修改
    NumArray2(nums) {
        /**
         * @param {number[]} nums
         * 方式一:对原数组进行预处理操作
         */
        var NumArray = function(nums) {
        // 克隆一份原数组
        this.data = new Array(nums.length);
        for (var i = 0; i < nums.length; i++) {
            this.data[i] = nums[i];
        }

        if (nums.length > 0) {
            this.sum = new Array(nums.length + 1);
            this.sum[0] = 0;
            for (let i = 0; i < nums.length; i++)
                this.sum[i + 1] = this.sum[i] + nums[i];
        }
        };

        /**
         * @param {number} i
         * @param {number} val
         * @return {void}
         */
        NumArray.prototype.update = function(i, val) {
        this.data[i] = val;

        for (let j = 0; j < this.data.length; j++)
            this.sum[j + 1] = this.sum[j] + this.data[j];
        };

        /**
         * @param {number} i
         * @param {number} j
         * @return {number}
         */
        NumArray.prototype.sumRange = function(i, j) {
        return this.sum[j + 1] - this.sum[i];
        };

        /**
         * Your NumArray object will be instantiated and called as such:
         * var obj = Object.create(NumArray).createNew(nums)
         * obj.update(i,val)
         * var param_2 = obj.sumRange(i,j)
         */
    }
}
复制代码

Main

// main 函数
class Main {
    constructor() {
        this.alterLine('leetcode 303. 区域和检索-数组不可变');
        let s = new Solution();
        let nums = [-2, 0, 3, -5, 2, -1];
        let numArray = s.NumArray(nums);

        console.log(numArray.sumRange(0, 2));
        this.show(numArray.sumRange(0, 2));
        console.log(numArray.sumRange(2, 5));
        this.show(numArray.sumRange(2, 5));
        console.log(numArray.sumRange(0, 5));
        this.show(numArray.sumRange(0, 5));
    }

    // 将内容显示在页面上
    show(content) {
        document.body.innerHTML += `${content}<br /><br />`;
    }

    // 展示分割线
    alterLine(title) {
        let line = `--------------------${title}----------------------`;
        console.log(line);
        document.body.innerHTML += `${line}<br /><br />`;
    }
}

// 页面加载完毕
window.onload = function() {
    // 执行主函数
    new Main();
};
复制代码

小总结

在 leetcode 上可以找到线段树相关的题目,https://leetcode-cn.com/tag/segment-tree/。题目总体难度都是困难的,所以线段树是一种高级数据结构,在一般的面试环节是不会看到线段树的影子的。线段树的题目整体是有一定的难度的,尤其是这些问题在具体使用线段树的时候,不一定是直接的使用线段树,很有可能需要绕几个弯子,如果你不去参加算法竞赛的话,线段树不是一个重点。

线段树中的更新操作

通过 leetcode 上 303 及 307 号题目可以分析出使用数组实现时,更新的操作是O(n)级别的,查询的操作是O(1)级别的,只不过初始化操作时是O(n)级别的。使用线段树实现时,更新和查询的操作都是O(logn)级别的,但是线段树在创建的时候是O(n)的复杂度,更准确的说是 4 倍的 O(n)的复杂度,因为所用的空间是 4n 个,并且要对每个空间进行赋值。

对于线段树来说,要考虑区间这样的这样的一种数据,尤其是要查询区间相关的统计信息的时候,同时数据是动态的,不时的还需要更新你的数据,在这样的情况下,线段树是一种非常好的数据结构。不过对于线段树来说,大多数本科甚至是研究生的算法教材中都不会涉及这种数据结构,它本身是一种高级的数据结构,更多的应用于算法竞赛中。

更新操作和构建线段树的操作类似。如果要修改某个索引位置的值,那么就需要知道这个索引位置所对应的叶子节点,递归到底后就能够知道这个叶子节点,这时候只需要赋值一下,然后 重新进行融合操作,因为该索引位置所在的区间需要进行更新,只有这样才能够达到修改线段树中某一个节点的值后也可以改变相应的线段(区间)。

代码示例

(class: MySegmentTree, class: NumArray2, class: Main)

MySegmentTree

// 自定义线段树 SegmentTree
class MySegmentTree {
    constructor(array) {
        // 拷贝一份参数数组中的元素
        this.data = new Array(array.length);
        for (var i = 0; i < array.length; i++) this.data[i] = array[i];

        // 初始化线段树 开4倍的空间 这样才能在所有情况下存储线段树上所有的节点
        this.tree = new Array(4 * this.data.length);

        // 开始构建线段树
        this.buildingSegmentTree(0, 0, this.data.length - 1);
    }

    // 获取线段树中实际的元素个数
    getSize() {
        return this.data.length;
    }

    // 根据索引获取元素
    get(index) {
        if (index < 0 || index >= this.getSize())
        throw new Error('index is illegal.');
        return this.data[index];
    }

    // 构建线段树
    buildingSegmentTree(treeIndex, left, right) {
        // 解决最基本问题
        // 当一条线段的两端相同时,说明这个区间只有一个元素,
        // 那么递归也到底了
        if (left === right) {
        this.tree[treeIndex] = this.data[left];
        return;
        }

        // 计算当前线段树的左右子树的索引
        const leftChildIndex = this.calcLeftChildIndex(treeIndex);
        const rightChildIndex = this.calcRightChildIndex(treeIndex);

        // 将一个区间拆分为两段,然后继续构建其左右子线段树
        let middle = Math.floor(left + (right - left) / 2); //(left + right) / 2

        // 构建左子线段树
        this.buildingSegmentTree(leftChildIndex, left, middle);
        // 构建右子线段树
        this.buildingSegmentTree(rightChildIndex, middle + 1, right);

        // 融合左子线段树和右子线段树
        this.tree[treeIndex] = this.merge(
        this.tree[leftChildIndex],
        this.tree[rightChildIndex]
        );
    }

    // 查询指定区间的线段树数据
    // 返回区间[queryLeft, queryRight]的值
    query(queryLeft, queryRight) {
        if (
        queryLeft < 0 ||
        queryRight < 0 ||
        queryLeft > queryRight ||
        queryLeft >= this.data.length ||
        queryRight >= this.data.length
        )
        throw new Error('queryLeft or queryRight is illegal.');

        // 调用递归的查询方法
        return this.recursiveQuery(
        0,
        0,
        this.data.length - 1,
        queryLeft,
        queryRight
        );
    }

    // 递归的查询方法 -
    // 在以treeIndex为根的线段树中[left...right]的范围里,
    // 搜索区间[queryLeft...queryRight]的值
    recursiveQuery(treeIndex, left, right, queryLeft, queryRight) {
        // 如果查询范围 与 指定的线段树的区间 相同,那么说明完全匹配,
        // 直接返回当前这个线段即可,每一个节点代表 一个线段(区间)处理后的结果
        if (left === queryLeft && right === queryRight)
        return this.tree[treeIndex];

        // 求出当前查询范围的中间值
        const middle = Math.floor(left + (right - left) / 2);

        // 满二叉树肯定有左右孩子节点
        // 上面的判断没有完全匹配,说明需要继续 缩小查询范围,也就是要在左右子树中进行查询了
        const leftChildIndex = this.calcLeftChildIndex(treeIndex);
        const rightChildIndex = this.calcRightChildIndex(treeIndex);

        // 判断:
        //    1. 从左子树中查还是右子树中查,又或者从左右子树中同时查,然后将两个查询结果融合。
        //    2. 如果 待查询的区间的左端点大于查询范围的中间值,说明只需要从右子树中进行查询即可。
        //    3. 如果 待查询的区间的右端点小于查询范围的中间值 + 1,说明只需要从左子树中进行查询。
        //    4. 如果 待查询的区间在左右端点各分部一部分,说明要同时从左右子树中进行查询。
        if (queryLeft > middle)
        return this.recursiveQuery(
            rightChildIndex,
            middle + 1,
            right,
            queryLeft,
            queryRight
        );
        else if (queryRight < middle + 1)
        return this.recursiveQuery(
            leftChildIndex,
            left,
            middle,
            queryLeft,
            queryRight
        );
        else {
        // 求出 左子树中一部分待查询区间中的值
        const leftChildValue = this.recursiveQuery(
            leftChildIndex,
            left,
            middle,
            queryLeft,
            middle
        );
        // 求出 右子树中一部分待查询区间中的值
        const rightChildValue = this.recursiveQuery(
            rightChildIndex,
            middle + 1,
            right,
            middle + 1,
            queryRight
        );
        // 融合左右子树种的数据并返回
        return this.merge(leftChildValue, rightChildValue);
        }
    }

    // 设置指定索引位置的元素 更新操作
    set(index, element) {
        if (index < 0 || index >= this.data.length)
        throw new Error('index is illegal.');

        this.recursiveSet(0, 0, this.data.length - 1, index, element);
    }

    // 递归的设置指定索引位置元素的方法 -
    // 在以treeIndex为根的线段树中更新index的值为element
    recursiveSet(treeIndex, left, right, index, element) {
        // 解决最基本的问题 递归到底了就结束
        // 因为找到了该索引位置的节点了
        if (left === right) {
        this.tree[treeIndex] = element;
        this.data[index] = element;
        return;
        }

        // 求出当前查询范围的中间值
        const middle = Math.floor(left + (right - left) / 2);

        // 满二叉树肯定有左右孩子节点
        // 上面的判断没有完全匹配,说明需要继续 缩小查询范围,也就是要在左右子树中进行查询了
        const leftChildIndex = this.calcLeftChildIndex(treeIndex);
        const rightChildIndex = this.calcRightChildIndex(treeIndex);

        // 如果指定的索引大于 查询范围的中间值,那就说明 该索引的元素在右子树中
        // 否则该索引元素在左子树中
        if (index > middle)
        this.recursiveSet(
            rightChildIndex,
            middle + 1,
            right,
            index,
            element
        );
        // index < middle + 1
        else this.recursiveSet(leftChildIndex, left, middle, index, element);

        // 将改变后的左右子树再进行一下融合,因为递归到底时修改了指定索引位置的元素,
        // 那么指定索引位置所在的线段(区间)也需要再次进行融合操作,
        // 从而达到修改一个值改变 相应的线段(区间)
        this.tree[treeIndex] = this.merge(
        this.tree[leftChildIndex],
        this.tree[rightChildIndex]
        );
    }

    // 辅助函数:返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引
    // 计算出线段树中指定索引位置的元素其左孩子节点的索引 -
    calcLeftChildIndex(index) {
        return index * 2 + 1;
    }

    // 辅助函数:返回完全二叉树的数组表示中,一个索引所表示的元素的右孩子节点的索引
    // 计算出线段树中指定索引位置的元素其右孩子节点的索引 -
    calcRightChildIndex(index) {
        return index * 2 + 2;
    }

    // 辅助函数: 融合两棵线段树,也就是对线段树进行业务逻辑的处理 -
    merge(treeElementA, treeElmentB) {
        // 默认进行求和操作
        return treeElementA + treeElmentB;
    }

    // 辅助函数:更新融合的方法,也就是自定义处理线段树融合的业务逻辑 +
    updateMerge(mergeMethod) {
        this.merge = mergeMethod;
    }

    // @Override toString() 2018-11-7 jwl
    toString() {
        let segmentTreeConsoleInfo = ''; // 控制台信息
        let segmentTreePageInfo = ''; // 页面信息

        // 输出头部信息
        segmentTreeConsoleInfo += 'SegmentTree:';
        segmentTreePageInfo += 'SegmentTree:';
        segmentTreeConsoleInfo += '\r\n';
        segmentTreePageInfo += '<br/><br/>';

        // 输出传入的数据信息
        segmentTreeConsoleInfo += 'data = [';
        segmentTreePageInfo += 'data = [';

        for (let i = 0; i < this.data.length - 1; i++) {
        segmentTreeConsoleInfo += this.data[i] + ',';
        segmentTreePageInfo += this.data[i] + ',';
        }

        if (this.data != null && this.data.length != 0) {
        segmentTreeConsoleInfo += this.data[this.data.length - 1];
        segmentTreePageInfo += this.data[this.data.length - 1];
        }
        segmentTreeConsoleInfo += '],\r\n';
        segmentTreePageInfo += '],<br/><br/>';

        // 输出生成的线段树信息
        segmentTreeConsoleInfo += 'tree = [';
        segmentTreePageInfo += 'tree = [';
        let treeSize = 0;
        for (let i = 0; i < this.tree.length - 1; i++) {
        if (this.tree[i] !== undefined) treeSize++;
        segmentTreeConsoleInfo += this.tree[i] + ',';
        segmentTreePageInfo += this.tree[i] + ',';
        }
        if (this.tree != null && this.tree.length != 0) {
        if (this.tree[this.tree.length - 1] !== undefined) treeSize++;
        segmentTreeConsoleInfo += this.tree[this.tree.length - 1];
        segmentTreePageInfo += this.tree[this.tree.length - 1];
        }
        segmentTreeConsoleInfo += '],\r\n';
        segmentTreePageInfo += '],<br/><br/>';
        segmentTreeConsoleInfo += 'originArraySize:' + this.getSize() + ',';
        segmentTreePageInfo += 'originArraySize:' + this.getSize() + ',';
        segmentTreeConsoleInfo +=
        'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;
        segmentTreePageInfo +=
        'treeCapacity: ' + this.tree.length + ',treeSize: ' + treeSize;

        // 返回输出的总信息
        document.body.innerHTML += segmentTreePageInfo;
        return segmentTreeConsoleInfo;
    }
}
复制代码

NumArray2

// 答题
class Solution {
    // leetcode 307. 区域和检索 - 数组可修改
    NumArray2(nums) {
        /**
         * @param {number[]} nums
         * 方式一:对原数组进行预处理操作
         */
        var NumArray = function(nums) {
        // 克隆一份原数组
        this.data = new Array(nums.length);
        for (var i = 0; i < nums.length; i++) {
            this.data[i] = nums[i];
        }

        if (nums.length > 0) {
            this.sum = new Array(nums.length + 1);
            this.sum[0] = 0;
            for (let i = 0; i < nums.length; i++)
                this.sum[i + 1] = this.sum[i] + nums[i];
        }
        };

        /**
         * @param {number} i
         * @param {number} val
         * @return {void}
         */
        NumArray.prototype.update = function(i, val) {
        this.data[i] = val;

        for (let j = 0; j < this.data.length; j++)
            this.sum[j + 1] = this.sum[j] + this.data[j];
        };

        /**
         * @param {number} i
         * @param {number} j
         * @return {number}
         */
        NumArray.prototype.sumRange = function(i, j) {
        return this.sum[j + 1] - this.sum[i];
        };

        /**
         * Your NumArray object will be instantiated and called as such:
         * var obj = Object.create(NumArray).createNew(nums)
         * obj.update(i,val)
         * var param_2 = obj.sumRange(i,j)
         */

        /**
         * @param {number[]} nums
         * 方式二:对原数组进行预处理操作
         */
        var NumArray = function(nums) {
        this.tree = new MySegmentTree(nums);
        };

        /**
         * @param {number} i
         * @param {number} val
         * @return {void}
         */
        NumArray.prototype.update = function(i, val) {
        this.tree.set(i, val);
        };

        /**
         * @param {number} i
         * @param {number} j
         * @return {number}
         */
        NumArray.prototype.sumRange = function(i, j) {
        return this.tree.query(i, j);
        };

        return new NumArray(nums);
    }
}
复制代码

Main

// main 函数
class Main {
    constructor() {
        this.alterLine('leetcode 307. 区域和检索 - 数组可修改');
        let s = new Solution();
        let nums = [1, 3, 5];
        let numArray = s.NumArray2(nums);

        console.log(numArray.sumRange(0, 2));
        this.show(numArray.sumRange(0, 2));
        numArray.update(1, 2);
        console.log(numArray.sumRange(0, 2));
        this.show(numArray.sumRange(0, 2));
    }

    // 将内容显示在页面上
    show(content) {
        document.body.innerHTML += `${content}<br /><br />`;
    }

    // 展示分割线
    alterLine(title) {
        let line = `--------------------${title}----------------------`;
        console.log(line);
        document.body.innerHTML += `${line}<br /><br />`;
    }
}

// 页面加载完毕
window.onload = function() {
    // 执行主函数
    new Main();
};
复制代码

总结

线段树虽然不是一个完全二叉树,但是可以把它看作是一棵满二叉树,进而可以使用数组的方式去存储这些结构,这和之前实现的堆相应的存储方式是一致的。
对线段树的学习可以深入理解树这种结构,当节点中存储的内容不一样的时候它所表示的意义也不一样的时候,相应的就可以来解决各种各样的问题。
它使用的范围是非常广泛的,对于线段树的构建,对于线段树节点存储的是什么,它的左右子树代表的是什么意思,其实和二分搜索树是完全不同的,当你赋予这种结构合理的定义之后,就可以非常高效的处理一些特殊的问题。
比如说对于线段树来说,就可以非常高效的处理了和线段(区间)有关的问题。

封装的线段树实现了三个方法,创建线段树、查询线段树、更新线段树中一个元素,这三个方法都使用了递归的操作,同时这个递归的写法在有一些层面和之前的二分搜索树是不同的。很大程度的不同是表现在递归之后最终还是要对线段树中左右两个孩子的节点进行一个融合的操作,这实际上是一种后序遍历的思想。

递归的代码无论是在宏观的角度上还是从微观的角度,都能够更深一步的对递归有进一步的认识。

对于线段树来说其实还有很多可以深入挖掘的东西。例如对线段树中一个区间进行更新,对应的时间复杂度是O(n)级别的,因为这个区间里所有的元素都要访问到,这个操作相对来说是比较慢的。
为了解决这个问题,在线段树中有一个专门的方式来解决它,对应的方法通常称之为懒惰更新,也可以叫做懒惰的传播。在自己实现的动态数组中有一个缩容的操作,就有使用到懒惰的这个概念,在线段树中也可以使用这样的思想。
在更新了中间节点的时候其实还要更新下面的叶子节点,但是先不进行这个更新,这就是懒的地方,先使用另外一个叫做 lazy 的数组记录这次未更新的内容。有了这个记录,就不需要实际的去更新这些节点,当你再有一次更新或者查询操作的时候,也就是当你再碰到这些节点的时候,那么碰到这些节点之前都要先查一下已经记录的这个 lazy 数组中是否有之前需要更新的内容。
如果没有更新,那么在访问它们之前,先将 lazy 数组中记录的未更新的内容进行一下更新,更新以后再来进行应该进行的访问操作,这样做在更新一个区间的内容的时候,就又变成了 logn 的复杂度了。
只需要访问到中间节点就够了,不需要对底层的所有节点都进行访问,于此同时对于其他的查询或者新的更新操作,也依然是这样的一个复杂度,只不过碰到相应的节点的时候看一下 lazy 数组中有没有记录相应的内容就好了。这个思想在实现的时候,有相应的很多细节需要注意,这一点也是一个比较高级的话题,有一个印象有一个概念就 ok。

现在实现的线段树本质上是一个一维的线段树,线段树还可以扩充到二维,一维线段树就是指处理的空间是在一个一维空间中,是在一个坐标轴中。
如果根节点是一个线段的话,左边半段就是它的左节点,右边半段就是它的右节点,但是可以把这个思想扩展成二维空间中,对于根节点可以记录的是一个矩阵的内容。然后对这个矩阵进行分块儿,把它分成四块儿,分别是左上、右上、左下、右下这四块儿,这样一来就可以让每一个节点有四个孩子。
对于这四个孩子每个孩子表示这个矩阵中相应的一块儿,对于每一个孩子它们依旧是一个更小的矩阵,对于这个更小的矩阵又可以把它分成四块儿,相应的每一个节点有四个孩子,依此类推,直到在叶子节点的时候每一个节点只表示一个元素,这样的一种线段树就叫做二维线段树。
所以对于二维区间相应的查询问题,也可以使用线段树这样的思路来解决。所以不仅是二维线段树,其实也可以设计出三维线段树,那么对于一个三维的矩阵,或者是对于一个立方体上的数据,可以把这个立方体切成八块儿,那么每一个节点可以分成八个节点,对于每一个小的节点,它是一个更小的立方体,然后可以这样继续细分下去。

线段树本身它就是一个思想,是在如何使用树这种数据结构,将一个大的数据单元拆分成不同的小的数据单元,递归的来表示这些数据,同时利用这种递归的结构可以高效的进行访问,从而进行诸如像更新查询这样的操作,这本身就是树这种结构的一个实质。

现在实现的线段树是一个数组的存储方式,使用数组的存储方式,相应的就会出现如果你有 n 个元素,那么就需要开辟 4n 个存储空间,在这个空间中其实有很多空间是被浪费的。
对于线段树其实可以使用链式的方式进行存储,可以设计一个单独的线段树所使用的节点类,在这个节点类中就可以存储所表示的区间它的左边界是谁、右边界是谁、相应的元素值是谁、以及它的左右孩子。
对于这样的一个节点也可以使用链式的方式也可以创建出这个线段树,在这种情况下,不需要浪费任何的空间,如果你的线段树要处理的节点非常多的话,有可能开 4n 的空间对你的电脑的存储资源负担比较大,这时候就可以考虑使用链式这种所谓动态线段树。

实际上对于动态线段树来说有一个更加重要的应用,现在实现的线段树,对于一个区间中相应的每一个元素都要使用一个节点来表达,这样的结果就是整个线段树所占的空间大小是 4n。
如果想要探讨的这个区间特别大的话,例如有一亿这么大的一个区间,但是其实很有可能你并不会对这么大的一个区间中每一个长度为 1 的子区间都感兴趣。
在这种情况下很有可能不需要一上来就建立一个巨大的线段树,就从根节点开始,初始的时候就这一个节点,它表示从 0 到一亿这样的一个区间。如果你关注[5,16]这样的一个区间,在这种情况下再开始动态的创建这个线段树,那么这个动态创建的方法,可能是首先将这个线段树根节点分成两部分,左孩子表示[0,4]这样的一个区间,右孩子表示 5 到一亿这样的一个区间,进而对 5 到一亿这样的区间再给分成两部分,左半部分表示[5,16],右半部分表示 17 到一亿这个区间。
至此对于这棵线段树来说,只有 5 个节点,也可以非常快速的关注到[5,16]这个区间相应的内容,那么使用这样的方式,如果你的区间非常大,但是你关注的区间其实并不会分布到这个大区间中每一个小部分的时候,可以实现这样的一个动态线段树,因为更加的有利。

拓展

区间操作相关-另外一个重要的数据结构

树状数组(Binary Index Tree),对区间这种数据进行操作时,就可能会使用到这种数据结构了,也就是树状数组,也被简称为 BIT,也叫二叉索引树,树状数组也是一个非常经典的树状结构,也是算法竞赛中的常客,在某一些问题上树状数组解决的问题和线段树是重叠的,不过在另外一些问题上树状数组也有它独特的优势。

区间相关的问题

对于区间相关的问题不一定使用线段树或者树状数组这样的专门的数据结构来解决。和区间相关的有一类非常经典的问题,叫做 RMQ(Range Minimum Query),也就是在一个区间中去相应的查询最小值,其实使用自己实现的线段树完全可以解决这个问题,不过对于 RMQ 问题由于它太过经典,有非常多个研究相应的也产生了非常多的其它办法来解决这个问题,而不仅仅是使用线段树或者是使用树状数组。

Trie 简述

Trie 这种树结构和之前的二分搜索树、堆、线段树不一样,之前的树结构本质都是一棵二叉树,而 Trie 是一种奇特的 n 叉树,这个 n 是大于 2 的,这种 n 叉树可以非常快速的处理和字符串相关的问题,也就是字典树。

猜你喜欢

转载自juejin.im/post/7082729574139691015