数据结构_02_线段树


线段树简介

线段树(Segment Tree)经常出现在竞赛中,可以以 O ( l o g n ) O(logn) O(logn)的时间复杂度实现单点修改单点查询区间修改区间查询等功能。实现相对于树状数组更复杂,但是原理相对来说更清晰。

相关LeetCode问题


一、关键要点

1. 整体结构

  • 原数组: n u m s nums nums,长度为 n n n
  • 树数组: t r e e tree tree,长度为 4 n 4n 4n (关于为什么取 4 n 4n 4n,可以查看相关文章:线段树数组到底是2n还是4n)。
    树的根节点为 t r e e [ 0 ] tree[0] tree[0],节点 t r e e [ i ] tree[i] tree[i]的左右子节点分别是 t r e e [ 2 ∗ i + 1 ] tree[2*i+1] tree[2i+1] t r e e [ 2 ∗ i + 2 ] tree[2*i+2] tree[2i+2]
  • 树节点内部类: N o d e Node Node,(也可以使用几个数组代替,使用内部类相对直观)
  • 代码如下:
	private class Node{
		// 区间左边界
		int left;
		// 区间右边界
		int right;
		// 节点对应区间和
		int sum;
		// 节点懒标记
		int lazy;
	}

*上述结构均不需要暴露给外部,因此都可以设为私有类型


2. 线段树的构建

线段树的构建分为两步,首先是变量的初始化,其次是在初始化完成后的进行相关计算,填充树的各节点的值。

变量的初始化
  • 向构造方法中传入原数组 n u m s nums nums
  • 根据 n u m s nums nums构建 t r e e tree tree数组,并初始化其值
  • 调用build方法填充数据
  • 代码如下:
    public SegmentTree(int[] nums) {
        // 保存原数组
        this.nums = nums;
        // 初始化tree数组
        this.tree = new Node[4*nums.length];
        for(int i = 0; i<this.tree.length; i++){
            this.tree[i] = new Node();
        }
        // 调用build方法创建线段树
        build(0,nums.length-1,0);
    }
节点值的填充
  • 采用递归方法填充树中的各节点值
  • 针对非叶子节点
    1. 若此节点对应区间为 [ l e f t , r i g h t ] [left,right] [left,right] m i d mid mid是区间的中点,则其左右子节点对应的区间分别为 [ l e f t , m i d ] [left,mid] [left,mid] [ m i d + 1 , r i g h t ] [mid+1,right] [mid+1,right]
    2. 当前节点对应的区间和( s u m sum sum)等于左右两子节点的区间和之和:
      t r e e [ i ] . s u m = t r e e [ i ∗ 2 + 1 ] . s u m + t r e e [ i ∗ 2 + 2 ] . s u m tree[i].sum = tree[i*2+1].sum+tree[i*2+2].sum tree[i].sum=tree[i2+1].sum+tree[i2+2].sum
  • 针对叶子节点
    1. 此节点区间左右边界重合( l e f t = = r i g h t left==right left==right),因此只对应原数组中的一个元素
    2. 当前节点对应区间和即为原数组中对应元素的值:
      t r e e [ i ] . s u m = n u m s [ t r e e [ i ] . l e f t ] tree[i].sum = nums[tree[i].left] tree[i].sum=nums[tree[i].left]
  • 代码如下:
 	/**
    * @param index:
    * @return: void
    * @date: 2022/5/19 9:09
    * @description: 更新指定节点值
    */
    private void updateNode(int index){
        this.tree[index].sum = this.tree[index*2+1].sum+this.tree[index*2+2].sum;
    }

    /**
    * @param left:
    * @param right:
    * @param index:
    * @return: void
    * @date: 2022/5/19 9:10
    * @description: 创建指定节点
    */
    private void build(int left, int right, int index){
        Node node = tree[index];
        // 为节点赋值
        node.left = left;
        node.right = right;
        // 生成区间值
        // 若当前节点为叶子节点,则当前节点值为单点值
        if(left == right){
            node.sum = this.nums[left];
            return;
        }
        // 若当前节点不为叶子节点,则当前节点的值需要由两个子节点的值相加而得
        // 由于各个区间不重叠,因此不存在重复计算的情况
        int mid = left + (right - left)/2;
        // 建立左子节点
        build(left,mid,index*2+1);
        // 建立右子节点
        build(mid+1, right,index*2+2);
        // 更新当前节点的值
        updateNode(index);
    }

3. 区间修改和lazy标记

线段树中数据的修改包括单点修改和区间修改,单点修改即将原数组中某个位置的值替换为新的值,而区间修改则是将指定区间内的数加上或者减去某个值。单点修改只是区间修改的特殊情况,可以采用统一的方式解决。

在区间修改的过程中,线段树巧妙地采用lazy标记,将部分值的修改操作延迟到区间查询时执行。lazy标记对于线段树及其重要,可以极大的提升区间修改的速度!

设对于第 i i i个节点,要修改的区间为 [ l , r ] [l,r] [l,r],区间中元素需要加上 v a l val val

  • 若要修改的区间和 i i i节点对应的区间不重合,则对于 i i i的子节点来说,会出现三种情况:

    1. 左子节点对应的区间可以完全覆盖 [ l , r ] [l,r] [l,r],则将修改操作传递给左子节点,被修改区间不变
    2. 右子节点对应的区间可以完全覆盖 [ l , r ] [l,r] [l,r],则将修改操作传递给右子节点,被修改区间不变
    3. 被修改区间在左右子节点对应的区间中皆有分布,若节点 i i i对应的区间的中点为 m i d mid mid,则将修改操作传递给左右子节点,被修改区间分别为 [ l , m i d ] [l,mid] [l,mid] [ m i d + 1 , r ] [mid+1,r] [mid+1,r]

    在节点 i i i的子节点完成修改后,更新节点 i i i的值。

  • 若要修改的区间和 i i i节点对应的区间重合,则无需再向下传递修改操作,直接修改当前节点的区间和,并添加lazy标记:

    1. 首先,统计区间中包含的元素的数目 n = r − l + 1 n=r-l+1 n=rl+1
    2. 之后,将当前节点的区间和修改为 t r e e [ i ] . s u m = t r e e [ i ] . s u m + n ∗ v a l tree[i].sum = tree[i].sum+n*val tree[i].sum=tree[i].sum+nval,表示区间中每个数都加上了 v a l val val (子节点实际上没有修改)
    3. 最后,将当前节点的lazy标记设为 v a l val val
  • 代码如下:

    /**
     * @param index:
     * @param num:
     * @return: void
     * @date: 2022/5/19 10:02
     * @description: 单点修改
     */
    public void update(int index, int num){
        if(index<0||index>=this.nums.length)return;
        // 从根节点改起
        num = num-this.nums[index];
        modifySegment(index,index,num,0);
        this.nums[index]+=num;
    }


    /**
     * @param left:
     * @param right:
     * @param num:
     * @return: void
     * @date: 2022/5/19 10:13
     * @description: 区间修改,将l到r中的所有值都加上num
     * 若当前要修改的区间完全覆盖左子区间或者右子区间,则在左右子节点上加上懒标记,延迟修改
     */
    public void update(int left, int right, int num){
        // 将l到r区间内的值全部加上num
        if(left > right || left <0|| right <0|| left >=this.nums.length|| right >=this.nums.length){
            System.out.println("区间修改失败!");
            return;
        }
        // 采用子函数进行修改
        modifySegment(left, right,num,0);
        // 更改原数组
        for(int i = left; i<= right; i++)this.nums[i]+=num;
    }

    /**
     * @param left:
     * @param right:
     * @param num:
     * @param indNode:
     * @return: void
     * @date: 2022/5/19 10:22
     * @description: 区间修改,采用懒标记延迟修改
     */
    public void modifySegment(int left, int right, int num, int indNode){
        // 获取当前节点
        Node node = this.tree[indNode];
        if(node.left == left &&node.right == right){
            // 当前区间被完全覆盖
            // 区间中有几个需要修改的值,区间就加几倍的num
            node.sum += (right - left +1)*num;
            // 添加懒标记,在查询时传递给子节点
            node.lazy += num;
            return;
        }
        // 若当前区间没有被完全覆盖,则根据具体情况判断
        int mid = node.left +(node.right -node.left)/2;
        if(mid>= right){
            // 左子区间被完全覆盖,修改左子区间
            modifySegment(left, right,num,indNode*2+1);
        }else if(mid+1<= left){
            // 右子区间被完全覆盖,修改右子区间
            modifySegment(left, right,num,indNode*2+2);
        }else{
            // 左右子区间都有部分覆盖,两边都要修改
            modifySegment(left,mid,num,indNode*2+1);
            modifySegment(mid+1, right,num,indNode*2+2);
        }
        // 更新当前节点的值
        updateNode(indNode);
    }

*在完成子节点修改之后,由子节点区间和计算父节点区间和的过程被称为线段树的pushUp过程


4. 区间查询和lazy标记的下传

在区间修改时,采用了lazy标记实现部分修改的延迟处理,在区间查询时,就会对存在lazy标记的被查询区间进行标记下传(pushDown),并对产生lazy标记的区间进行实际修改

pushDown操作

pushDown操作的目的就是执行区间修改中未实际执行的修改,并将lazy标记向子节点传递,若当前查询到节点 i i i,且节点 i i i具有lazy标记,则根据节点 i i i的类型,将操作分为两种情况:

  • 若节点 i i i为不存在子节点的叶子节点,则lazy标记无需也无法下传,直接清除当前节点的lazy标记 t r e e [ i ] . l a z y = 0 tree[i].lazy=0 tree[i].lazy=0
  • 若节点 i i i不为叶子节点,则操作分为三个步骤:
    1. 根据节点 i i i的lazy标记,修改子节点的值,修改方式与区间修改相同,即:
    新的区间和=区间中元素的个数X t r e e [ i ] . l a z y tree[i].lazy tree[i].lazy+原区间和
    2. 向子节点传递lazy标记
    t r e e [ i ∗ 2 + 1 ] . l a z y = t r e e [ i ] . l a z y tree[i*2+1].lazy = tree[i].lazy tree[i2+1].lazy=tree[i].lazy
    t r e e [ i ∗ 2 + 2 ] . l a z y = t r e e [ i ] . l a z y tree[i*2+2].lazy = tree[i].lazy tree[i2+2].lazy=tree[i].lazy
    3. 清除节点 i i i的lazy标记 t r e e [ i ] . l a z y = 0 tree[i].lazy = 0 tree[i].lazy=0
  • 代码如下:
    /**
     * @param indNode:
     * @return: void
     * @date: 2022/5/19 11:02
     * @description: 向下传递lazy标记
     */
    private void pushDown(int indNode){
        Node node = this.tree[indNode];
        // 查看当前节点是否为叶节点
        // 若为叶节点,则直接清除标记,不在下传
        if(node.left ==node.right){
            node.lazy = 0;
            return;
        }
        // 传递lazy
        Node left = this.tree[indNode*2+1];
        Node right =this.tree[indNode*2+2];
        // 将lazy传递给子节点
        left.lazy = node.lazy;
        right.lazy = node.lazy;
        // 修改子节点的值
        left.sum += (left.right - left.left +1)*left.lazy;
        right.sum += (right.right -right.left +1)*right.lazy;
        // 清除当前节点的lazy值
        node.lazy = 0;
    }
区间查询操作

设当前节点为 i i i,需要查询的区间为 [ l , r , ] [l,r,] [l,r,]

  • 首先,判断当前节点是否存在lazy标记,若存在,则进行pushDown操作
  • 之后,判断当前节点对应的区间是否和要查询的区间重合 t r e e [ i ] . l e f t = = l & & t r e e [ i ] . r i g h t = = r tree[i].left==l\&\&tree[i].right==r tree[i].left==l&&tree[i].right==r,若重合,则直接返回当前当前节点的区间和 t r e e [ i ] . s u m tree[i].sum tree[i].sum,否则进行下一步
  • 判断待查询区间和当前节点对应区间的关系,判断标准与区间修改过程中的判断标准相同,判断完成后,对子节点进行查询操作,并由子节点的查询结果生成当前节点的查询结果
  • 代码如下:
    /**
     * @param left:
     * @param right:
     * @return: int
     * @date: 2022/5/19 11:11
     * @description: 区间查询
     */
    public int query(int left, int right){
        if(left > right || left <0|| left >=this.nums.length|| right <0|| right >=this.nums.length){
            System.out.println("输入区间不合法!");
            return -1;
        }
        return querySegment(left, right, 0);
    }

    /**
     * @param left:
     * @param right:
     * @param index:
     * @return: int
     * @date: 2022/5/19 11:18
     * @description: 区间查询
     */
    private int querySegment(int left, int right, int index){
        Node node = this.tree[index];
        // 判断是否需要传递lazy标记
        if(node.lazy!=0){
            pushDown(index);
        }
        // 查看当前区间是否可以直接返回
        if(node.left == left &&node.right == right)return node.sum;
        // 查询左右区间的值,并进行拼接
        int mid = node.left +(node.right -node.left)/2;
        if(right <=mid){
            // 左子区间被完全包含
            return querySegment(left, right,index*2+1);
        }else if(left >mid){
            // 右子区间被完全包含
            return querySegment(left, right,index*2+2);
        }
        // 返回左右区间之和
        return querySegment(left,mid,index*2+1)+querySegment(mid+1, right,index*2+2);
    }

到此为止,线段树中所有的关键方法都已经介绍完毕,下面给出线段树的完整的Java实现


二、Java实现

public class SegmentTree {

    // 用来保存节点的数组,以树的形式组织
    // 根节点为0,n的左子节点为n*2+1,右子节点为n*2+2
    private Node[] tree;
    private int[] nums;
    public SegmentTree(int[] nums) {
        // 保存原数组
        this.nums = nums;
        // 初始化tree数组
        this.tree = new Node[4*nums.length];
        for(int i = 0; i<this.tree.length; i++){
            this.tree[i] = new Node();
        }
        // 创建线段树
        build(0,nums.length-1,0);
    }

    /**
     * @param index:
     * @return: void
     * @date: 2022/5/19 9:09
     * @description: 更新指定节点值
     */
    private void updateNode(int index){
        this.tree[index].sum = this.tree[index*2+1].sum+this.tree[index*2+2].sum;
    }

    /**
     * @param left:
     * @param right:
     * @param index:
     * @return: void
     * @date: 2022/5/19 9:10
     * @description: 创建指定节点
     */
    private void build(int left, int right, int index){
        Node node = tree[index];
        // 为节点赋值
        node.left = left;
        node.right = right;
        // 生成区间值
        // 若当前节点为叶子节点,则当前节点值为单点值
        if(left == right){
            node.sum = this.nums[left];
            return;
        }
        // 若当前节点不为叶子节点,则当前节点的值需要由两个子节点的值相加而得
        // 由于各个区间不重叠,因此不存在重复计算的情况
        int mid = left + (right - left)/2;
        // 建立左子节点
        build(left,mid,index*2+1);
        // 建立右子节点
        build(mid+1, right,index*2+2);
        // 更新当前节点的值
        updateNode(index);
    }

    /**
     * @param index:
     * @param num:
     * @return: void
     * @date: 2022/5/19 10:02
     * @description: 单点修改
     */
    public void update(int index, int num){
        if(index<0||index>=this.nums.length)return;
        // 从根节点改起
        num = num-this.nums[index];
        modifySegment(index,index,num,0);
        this.nums[index]+=num;
    }


    /**
     * @param left:
     * @param right:
     * @param num:
     * @return: void
     * @date: 2022/5/19 10:13
     * @description: 区间修改,将l到r中的所有值都加上num
     * 若当前要修改的区间完全覆盖左子区间或者右子区间,则在左右子节点上加上懒标记,延迟修改
     */
    public void update(int left, int right, int num){
        // 将l到r区间内的值全部加上num
        if(left > right || left <0|| right <0|| left >=this.nums.length|| right >=this.nums.length){
            System.out.println("区间修改失败!");
            return;
        }
        // 采用子函数进行修改
        modifySegment(left, right,num,0);
        // 更改原数组
        for(int i = left; i<= right; i++)this.nums[i]+=num;
    }

    /**
     * @param left:
     * @param right:
     * @param num:
     * @param indNode:
     * @return: void
     * @date: 2022/5/19 10:22
     * @description: 区间修改,采用懒标记延迟修改
     */
    public void modifySegment(int left, int right, int num, int indNode){
        // 获取当前节点
        Node node = this.tree[indNode];
        if(node.left == left &&node.right == right){
            // 当前区间被完全覆盖
            // 区间中有几个需要修改的值,区间就加几倍的num
            node.sum += (right - left +1)*num;
            // 添加懒标记,在查询时传递给子节点
            node.lazy += num;
            return;
        }
        // 若当前区间没有被完全覆盖,则根据具体情况判断
        int mid = node.left +(node.right -node.left)/2;
        if(mid>= right){
            // 左子区间被完全覆盖,修改左子区间
            modifySegment(left, right,num,indNode*2+1);
        }else if(mid+1<= left){
            // 右子区间被完全覆盖,修改右子区间
            modifySegment(left, right,num,indNode*2+2);
        }else{
            // 左右子区间都有部分覆盖,两边都要修改
            modifySegment(left,mid,num,indNode*2+1);
            modifySegment(mid+1, right,num,indNode*2+2);
        }
        // 更新当前节点的值
        updateNode(indNode);
    }

    /**
     * @param indNode:
     * @return: void
     * @date: 2022/5/19 11:02
     * @description: 向下传递lazy标记
     */
    private void pushDown(int indNode){
        Node node = this.tree[indNode];
        // 查看当前节点是否为叶节点
        // 若为叶节点,则直接清除标记,不在下传
        if(node.left ==node.right){
            node.lazy = 0;
            return;
        }
        // 传递lazy
        Node left = this.tree[indNode*2+1];
        Node right =this.tree[indNode*2+2];
        // 将lazy传递给子节点
        left.lazy = node.lazy;
        right.lazy = node.lazy;
        // 修改子节点的值
        left.sum += (left.right - left.left +1)*left.lazy;
        right.sum += (right.right -right.left +1)*right.lazy;
        // 清除当前节点的lazy值
        node.lazy = 0;
    }

    /**
     * @param left:
     * @param right:
     * @return: int
     * @date: 2022/5/19 11:11
     * @description: 区间查询
     */
    public int query(int left, int right){
        if(left > right || left <0|| left >=this.nums.length|| right <0|| right >=this.nums.length){
            System.out.println("输入区间不合法!");
            return -1;
        }
        return querySegment(left, right, 0);
    }

    /**
     * @param left:
     * @param right:
     * @param index:
     * @return: int
     * @date: 2022/5/19 11:18
     * @description: 区间查询
     */
    private int querySegment(int left, int right, int index){
        Node node = this.tree[index];
        // 判断是否需要传递lazy标记
        if(node.lazy!=0){
            pushDown(index);
        }
        // 查看当前区间是否可以直接返回
        if(node.left == left &&node.right == right)return node.sum;
        // 查询左右区间的值,并进行拼接
        int mid = node.left +(node.right -node.left)/2;
        if(right <=mid){
            // 左子区间被完全包含
            return querySegment(left, right,index*2+1);
        }else if(left >mid){
            // 右子区间被完全包含
            return querySegment(left, right,index*2+2);
        }
        // 返回左右区间之和
        return querySegment(left,mid,index*2+1)+querySegment(mid+1, right,index*2+2);
    }

    /**
     * @date: 2022/5/19 9:01
     * @description: 线段树上的节点,在数组中以数组位置确定父子关系
     * 节点中存储区间左右边界(闭区间),区间内元素和,区间懒标记
     */
    private class Node{
        // 区间左边界
        int left;
        // 区间右边界
        int right;
        // 节点对应区间和
        int sum;
        // 节点懒标记
        int lazy;
    }
}

总结

线段树不仅可以用于区间和查询,区间和修改等场景,经适当修改后,还可以应对区间最大值,区间乘积等问题,是一种非常有用的数据结构,但是线段树的实现有许多要点,只有经过多次练习,才能把它用得炉火纯青。

参考

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值