线段树初探

前言

先从一个问题说起:

假设现在有 n 个数,编号为 0 ~ n-1。现在,每一次会给你一个区间 [a, b] (0 <= a <= b < n),要求给出这 n 个数中编号在区间 [a, b] 中的数字的和、区间 [a, b] 中的最大数字。

题目并不难,我们用一个数组储存这 n 个数字,然后对于每一个给定的 [a, b] 我们用一个循环就可以求出区间 [a, b] 中数字的和和区间 [a, b] 中最大的数字。这样的话时间复杂度是 O(b - a + 1),因为我们并不知道 a 和 b 的具体值,也有可能 b - a 等于 n ,所以其时间复杂度为 O(n),那么如果需要查询 m 次的话,时间复杂度就是 O(m*n),相对于这个时间复杂度,我们可以有更快的方法,将这个复杂度降低到 O(m*logn),秘诀就是用线段树。

基本概念的理解

首先,线段树是一颗二叉树,而且是一颗满二叉树(这里的说法不完全准确,为了好理解,我们可以先这样理解,先埋个伏笔,文末再来讨论这个问题)。对于满二叉树的定义,国内和国外的定义不完全一样,国外的定义是:
如果一棵二叉树的结点要么是叶子结点,要么它有两个孩子结点,这样的树就是满二叉树。

国内的定义是:
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是(2^k) -1 ,则它就是满二叉树。

如图是一颗常见的满二叉树:
这里写图片描述
除了最底层的叶子节点外,其余节点都有 2 个孩子节点。

Ok,了解了这个概念之后,我们继续,我们知道:对一个满二叉树,如果它的节点数为 n 个,那么它的高度为 log(n+1) ,我们把这个值近似为 logn。那么我们怎么把这个 logn 和上面的题目联系起来呢?
在这里,我们可以把二叉树的节点储存的内容定义为:当前节点表示的的区间范围 [start, end] 、在区间范围 [start, end] 内的数字的和、在区间范围 [start, end] 内数字的最大值,当然还需要储存这个节点的左右子节点。
于是对于二叉树节点,我们可以定义为以下形式:

struct Node {
    int start, end; // 表示该二叉树节点储存的区间范围 [start, end]
    int sum; // 表示该二叉树节点储存的区间范围内的数字的和
    int maxValue; // 表示该二叉树节点储存的区间范围的数字的最大值
    struct Node *left; // 指向该二叉树结点的左子结点的指针
    struct Node *right; // 指向该二叉树结点的右子节点的指针
};

那么问题来了,二叉树节点内的这些内容从何而来呢?左右子节点的指针很好办,我们在创建二叉树的时候就可以将它们分别指向当前节点的左右子节点。那么 start, end 呢?我们可以这样看,假设这个二叉树节点的父节点表示的区间范围为 [m, n](m < n),那么如果当前节点如果是其父节点的左子结点,当前节点的 start = m, end = (m + n) / 2, 如果当前节点是其父节点的右子节点,那么当前节点的 start = (m + n) / 2 + 1, end = n。也就是相当于一个分治的过程,父节点的左子结点管理父节点区间的左半部分区间,而父节点的右子节点管理父节点区间的右半部分区间。什么时候分治结束呢?当然是 start == end 的时候了,也就是说此时的节点就是一个叶子节点,它管理的范围就是 1。
那么节点中的 sum 和 maxValue 也是同理,左子结点的 sum 为父节点区间的左半部分区间的数字和,右子节点的 sum 为父节点区间的右半部分区间的数字和,左子结点的 maxValue 为父节点区间的左半部分区间的最大数字值,右子节点的 maxValue为父节点区间的右半部分区间的最大数字值。

可能这样说还是有点抽象,下面我们用一个具体的例子来看:
在文章开头的那个问题中,我们假设当前 n 为 8 ,这八个数字分别为:5, 1, 4, 9, 2, 6, 3, 7
此时,先不管这颗二叉树的构造过程,如果我们构造好了这颗二叉树,那么它应该是这个样子:

这里写图片描述

因为格子空间有限,所以最下面一层的叶子节点的内容都用箭头指写出来了,看不清的话放大看一下。我们继续:假设我们已经构造好了这颗二叉树,不对,现在它应该叫作线段树了。
那么如果我们要求区间 [0, 7] 中数字的和,是不是直接可以返回这颗线段树的根结点的 sum ,对于 maxValue 也是一样

如果我们要求区间 [0, 0] 中数字的和怎么办呢?我们发现区间 [0, 0],完全被包裹在当前线段树根结点的左子节点表示的范围([0, 3])中,于是我们递归向左子树寻找,到了左子节点,我们右发现区间 [0, 0] 还是被完全包裹在其左子节点表示的范围([0, 1])中,于是我们还递归向当前节点的左子树寻找…到了最左边的这颗节点,这个时候的节点表示的范围为 [0, 0],刚好符合要查询的区间,于是我们返回它的 sum 值,即为 5 。

另外一个例子,就是如果我们需要查询区间 [0, 6] 中的数字的和,这个区间特点是它不直接包裹任何一个节点表示区间,此时我们需要分开来查询,首先它的区间范围小于当前线段树根结点的区间范围,那么我们肯定要到当前线段树的根结点的左右子树中查询,我们知道根结点的左子树表示的范围是 [0, 3],被区间 [0, 6] 包裹,因此我们先返回根结点左子结点的 sum,之后要查询的区间就剩下 [4, 6],这个区间属于根结点的右子树,于是我们到根结点的右子树查找…到最后我们会返回一个表示范围为 [4, 5] 的节点的 sum 和一个表示范围为 [6, 6] 的节点的 sum,将所有返回的 sum 想加就是答案,可以看下图加深理解:

这里写图片描述

最后,只要当要查找的区间 [a, b] 完全包裹某个节点表示的区间(a <= start && b >= end)时,我们就可以直接返回这个节点储存的内容,这个相信很容易理解。而因为查寻方式都是递归向下的,所以每次查询的时间复杂度不会超过线段树的高度,即 O(logn)。

线段树更新

好了,通过前面我们已经解决了文章开头留下的问题,下面再来理解一个问题:线段树的节点更新。
我们还是拿上面那颗已经构造好的线段树来举例:
假设我们现在要更新表示区间为 [6, 6] 的节点的值。我们看看更新它会影响多少个节点:

这里写图片描述

可以清楚的看到,更新完一个节点之后,我们还需要向上对它的祖先节点进行处理,即所有直接或者间接包含这个节点的节点,这个很好理解,但是需要注意的是,我们在更新线段树的时候。最开始更新的节点只能是叶子节点,为什么这么说呢,假设我们把根结点的 sum 改为 40,那么我们该如何处理其的左右子树呢?这里并没有给出处理左右子树的规则,但是如果不处理左右子树的话,更改根结点之后,其左右子树的 sum 的和又不等于根结点的 sum,这样的话就会发生错误。所以一般情况我们都是先对叶子节点进行更新,然后向上更新父节点。

代码实现

讲了那么多理论,如何用代码实现线段树呢?如果你理解了上面所讲的,我相信你大致已经可以写出线段树的代码,这里给出实现代码(java 版):

public class Solution {
    
    SegmentTreeNode root; // 线段树根结点
    
   /**
    * @param A: 一个整形数组,储存了线段树根结点表示的区间内的所有数字
    */
    public Solution(int[] A) {
        this.root = build(0, A.length - 1, A);
    }

	// 根据传入的整形数组和区间范围创建一颗线段树,返回创建的线段树的根结点
    public SegmentTreeNode build(int start, int end, int a[]) {
        // 此处应做特殊处理
        if (start > end) {
            return null;
        }
        // 叶子节点
        if (start == end) {
            return new SegmentTreeNode(start, end, a[start], null, null);
		// 递归创建左右子树
        } else {
            int mid = (start + end) / 2;
            // 左子树管理区间:[start, mid]
            SegmentTreeNode left = build(start, mid, a);
            // 右子树管理区间:[mid+1, end]
            SegmentTreeNode right = build(mid + 1, end, a);
            // 当前节点的 sum 等于左右子节点的 sum 之和
            return new SegmentTreeNode(start, end, left.sum + right.sum, left, right);
        }
    }

    /**
	 * 查询区间 [start, end] 中数字的和
     */
    public long query(int start, int end) {
        if (root != null) {
            return (long) root.query(start, end);
        }
        return -1;
    }

    /**
	 * 修改表示区间为 [index, index] 的叶子节点的值为 value 
     */
    public void modify(int index, int value) {
        if (root != null) {
            root.modify(index, value);
        }
    }
	
	// 表示线段树节点的类
    public static class SegmentTreeNode {
        public int start;
        public int end; // 当前节点表示的区间 [start, end]
		public int sum; // 当前节点表示的区间的数字的和
        public SegmentTreeNode left;
        public SegmentTreeNode right;
        
        public SegmentTreeNode(int start, int end, int sum, 
                                SegmentTreeNode left, SegmentTreeNode right) {
            this.start = start;
            this.end = end;
            this.sum = sum;
            this.left = left;
            this.right = right;
        }
        
		/**
		 * 查询区间 [start, end] 中的数字的和
		 */
        public int query(int start, int end) {
            // 此处应做特殊处理
            if (start > end) {
                return -1;
            }
            // 查询的区间完全包裹当前节点包含的区间,直接返回当前节点的 sum
            if (start <= this.start && end >= this.end) {
                return this.sum;
            } else {
                int ans = 0;
                int mid = (this.start + this.end) / 2;
                // 如果查询的区间包含当前节点的左子树包含的区间,那么查询左子树
                if (start <= mid && this.left != null) {
                    ans += this.left.query(start, end);
                }
                // 如果查询的区间包含当前节点的右子树包含的区间,那么查询右子树
                if (mid + 1 <= end && this.right != null) {
                    ans += this.right.query(start, end);
                }
                return ans;
            }
        }
        
		/**
		 * 更改表示区间为 [index, index] 的叶子节点的值为 value
		 */
        public void modify(int index, int value) {
            // 此处应做特殊处理
            if (index < this.start || index > this.end) {
                return ;
            }
            // 找到要更改的叶子节点
            if (index == this.start && this.start == this.end) {
                this.sum = value;
            } else {
                int mid = (this.start + this.end) / 2;
                // 要修改的节点位置在左子树中
                if (index <= mid && this.left != null) {
                    this.left.modify(index, value);
                // 要修改的节点位置在右子树中
                } else if (index >= mid + 1 && this.right != null) {
                    this.right.modify(index, value);
                }
                // 更新修改后的值
                this.sum = this.left.sum + this.right.sum;
            }
        }   
    }
}

C++ 版本的就不贴了(其实是没写,哈哈~),如果你理解了线段树的原理,那么相信你已经可以写出来了。

额外话题

最后上文提到过关于线段树是满二叉树的正确性问题,我们理解了线段树之后再来看这个问题,假设现在有 3 个数:1, 2, 3 ,对应的下标为 0, 1, 2。现在如果需要用线段树来保存对应区间的值,那么构造出来的线段树就是这样的:

这里写图片描述

我们再来看一下国内外对于满二叉树的定义:

国内:
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是(2^k) -1 ,则它就是满二叉树。

国外:
如果一棵二叉树的结点要么是叶子结点,要么它有两个孩子结点,这样的树就是满二叉树。

我们可以发现,这颗线段树是不满足国内的满二叉树的定义的,因为其最后一层的节点数(2)并未达到该层的最大值(2^2 = 4),但是对比国外的满二叉树的性质,我们可以发现它是满足的。也就是说国内对满二叉树的要求更加严格。对于这个,就得看你怎么看这个问题了。哈哈。

最后,我们知道上面 n 为 8 的例子构造出来的线段树是既满足国外的定义又满足国内的定义的,和上面 n 为 8 的例子相比,你可以思考一下为什么当前 n 为 3 的时候构造出来的线段树会出现这种情况(其代表的满二叉树只满足国外的定义,不满足国内的定义),答案就在眼前。

OK,总结一下:
1、线段树是擅长处理区间任务的,其擅长对一定区间内的数字的和(sum)、区间内数字的最小值(min)和最大值(max)等的查询,一颗构造好的线段树进行区间查询的时间复杂度为O(logn);

2、更新线段树的一个叶子节点的值之后需要一直向上更新这个节点的父节点…直到最后的根结点。

3、线段树一旦构造好,其区间范围不能更改,如果要更改区间范围,那么就需要重新构造一颗新的线段树。比如:一开始我们已经构造好一颗区间范围为:[0, 7] 的线段树,如果要更改其表示的区间范围为 [1, 6],我们就需要根据更改后的区间范围重新构造一颗线段树。因为表示的区间更改之后线段树的所有节点数据都需要重新处理。

4、对于一个表示范围为 [0, n] 的线段树,所含的节点个数为 n + n/2 + n/4 + … + 1 = 2n, 其建树的时间复杂度为 O(2n),即为 O(n)

如果想做题练习的话,可以试试下面的:

蓝桥杯:操作格子

lintcode:线段树的查询
这个网站里面有很多线段树的题目,在网站里面搜索一下就好了。

如果本文对你有帮助,请不要吝啬您的赞,如果博客中有什么不正确的地方,还请多多指点
谢谢观看。。。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值