线段树:学会这个就会陷入什么题都是线段树的怪圈?但是它确实够顶啊!

线段树

前言

以前学过一次线段树,但是因为间隔时间太长,当时又没怎么实现,现在重新学了一遍。

我是跟着左神一期关于线段树入门的课程写的,代码也有很深的他的影子,他的课程地址在B站这个链接.

如果一时不明白可以看他的视频,但是他画的图有点问题,会带给我们错误的线段树认识,但是当做入门还是很好的。

错误在于,左神强调线段树是完全二叉树,实际上最后一层不符合完全二叉树的特征,而是一个部分位置有空缺跳跃的树【比如区间[1,6]这棵树就是】。
真正与完全二叉树相近的原因是利用数组这个数据结构来存树。但是我觉得这种写法并不算非常好,反而用指针树【动态开点2】这个物理结构理解起来简单,写起来又很容易,效率也是最高的,这才是力扣线段树的正解。

一、原理

解决的问题

线段树主要解决这样一类问题:

一个数组,或者一个有限的数轴,对其内某一段区间反复进行修改和查询。

若只做查询,前缀和最方便;若反复修改而查询只有一次,选用差分最方便;若只是单纯的修改和求区间和,树状数组最方便。

但是,它们都没有线段树的普适性。可以说,它们能做的线段树都能做,它们不能做的线段树也能做。

有很多人因为线段树在实际在线评测中时间评比往往在一半以下,而且代码贼长,有些反感线段树,我觉得Duck不必:

1)如果会一个算法或者数据结构能解决很多问题,我们在不断解决这类问题时也会对这个算法或者数据结构越来越熟练,长不长就是多打点字的问题,而且线段树代码有很多重复部分,可以直接粘贴复制;

2)作为业余人员,算法只要时间复杂度达到要求即可,不必太在意算法流程上的多少;

3)线段树从理论上来说,比树状数组好理解的多【看完核心思想部分就了解的差不多了】,即使coding能力比较差,写不出来,也可以面试时吹吹。

核心思想

我们想象一个数据结构,它能够表示一个数轴上的区间【或者数组的一段连续区间,后面我会统称为数轴】,比如[1, 20]【默认闭区间】,它拥有这个区间的一些信息,比如这个区间的区间和、最大值等;

此时,若我们需要[1,20]区间的信息,直接去这个节点取就行了。

但是,假设我们要[1, 10]区间的信息呢?

另一个问题来了,若[1,20]和[1,10]区间信息单独维护,必然多了很多重复计算,经过观察发现,[1,10]节点也维护了[1,20]的一半需要的数据,同时,假设还有一个节点[11,20],[1,20]节点就能从它们两个获取到完整的信息了【假设为区间和,将[1,10]和[11,20]区间和累计起来就行;假设是最大值,求以下两个区间的各自的最大值中的较大者即可】。

顺着这样类似递归的思路,[1,10]的信息也可以委托给[1,5]和[6, 10], 同理,[1,5]后面也可以有[1,3]和[4, 5]…

这样的划分到了区间只有一个元素时就可以停止了,我们发现每次都是折半划分区间,这正好符合一个二叉树的结构,具体说来,就是这样的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DiixDfJZ-1654864618431)(https://gitee.com/wonderson/picgo/raw/master/img/image-20220527162545650.png)]

只画了部分的节点,但是还是足够看清楚了。

图中,我将叶子节点标记为蓝色,表示区间只有一个元素,如果我们额外给这个节点增加一个区间和的信息sum,就是一颗入门级别的线段树了。

在这里的结构下,我们想象我们的最终目的:区间修改和区间查询如何实现。

区间查询

假如我们要查询的刚好是一个节点表示的范围的区间和,比如[1,10],[2,3]等,我们只要顺着树的定义,不断二分区间,直到找到那个满足条件的点,取出它维护的sum即可;

假设是[5, 12]这样的区间和呢?

线段树是这样做的,假设当前节点所维护的区间q刚好被要查询的区间Q所囊括,比如,[5,12]中有区间[5], [6, 8], [9, 10], [11, 12]这样区间是线段树的节点,当我们遍历到这些节点时,直接可以返回;否则将当前区间进行一步拆分,知道可以直接返回为止。

以Q=[5, 12]为例,我们刚开始的q=[1,20] (q的初始值是固定的,必然是线段树的根节点),我们比较q和Q,发现q并不是被Q囊括,我们按照线段树的定义拆分q区间,得到[1,10],[11, 20]两个区间;

我们按照树的先序遍历查看这些拆分的区间,即先令q=[1,10], 发现仍然不符合被Q囊括,继续拆分;q=[1,5],仍然不满足,继续拆分为[1, 3], [4, 5], 这时候有一些特殊情况了,我们发现[1, 3]和[5,12]八竿子打不着,为了避免无意义的搜索,我们直接放弃这部分区间,用剪枝的策略在这里结束这个区间的搜索;我们继续对[4, 5]区间的拆分,拆分为[4]和[5]区间,我们继续剪枝掉4区间,只剩下[5]区间,我们终于发现了一个完全被覆盖的区间,我们将这个区间维护的sum信息返回,结束这个子树的搜索;按照先序遍历规则,接下来会继续对[6, 10]区间的搜搜,我们发现它被[5, 12]完全囊括,可以直接返回;接下来就是[11, 20]了,[16, 20]区间也会被直接剪枝,只需搜索[11, 15]…

最终,每个父节点收到下面传来的他一部分的区间信息,就是所求的区间和了;

区间修改

基本上也是一样的,只不过一些细节需要注意:

1)子节点修改后,它的sum信息必然修改,而子节点的sum信息修改,父节点sum也会修改,这就需要借助递归返回时父节点对子节点进行一次汇总, p.sum = p.l.sum + p.r.sum;

2)若达到q被Q完全覆盖,我们需要对q区间完全做修改,具体说来,就是q=[l, r], 修改的值为u => sum = (r - l + 1) * u, 然后直接返回;

这里你可能会奇怪,如果达到了完全覆盖就返回,那么这个区间还有子区间就不做修改了吗?

没错,的确暂时不用修改,我们使用一个lazy标识来标明这个q区间有多少值没有让子区间做修改,在下次有必须进入子区间的需求之前,再将懒信息派发给子区间先进行更新,然后再继续针对子区间做本次的操作。

这种延迟更新的策略被称为“懒机制”,也是线段树高效性的体现;

以刚刚修改[5,12]区间为例,假如我们真的把所有应该修改的节点全部修改了,也就需要访问约20个节点才能完全修改完,这比直接去数组修改的次数还要多的多,那我还费半天劲去用这个数据结构干嘛呢?

有了懒机制,更新的时间复杂度将下降到logn级别,最终的目的是二分查找左右修改边界,而边界中很多区间都会因为被完全覆盖而被直接返回,最终的结果就是两条树高的路径而已。

而我们构建树时,是按照区间的大小不断二分区间构建的,这就保证了树高也就是logn, 因此我们只需要搜索大约2 * logn的量即可。

二、实现

首先考虑它的物理结构:

区间修改部分,我们说过树的划分是不断二分的,也就保证每个父节点左右子树的大小最多相差一,如此一来,这样的树必然是一棵平衡二叉树,由于平衡树接近完全二叉树,我们可以考虑用完全二叉树的数组表示法来表示线段树,这样做还有一个好处,由于在舍弃数组0下标的情况下,左子树的下标刚好是父节点的两倍,而右子树就是父节点下标两倍加一,我们可以用位运算进行下标计算,设父节点的下标为rt,那么左子树就是 rt << 1, 右子树就是 rt << 1 | 1 。这会对性能有一些提升,且基于数组的实现无需定义Node的结构,写起来也会方便一些。

当然,采取树经典的递归结构也是可行的,这一般用于动态开点,是一种更普适性的做法:

有时候区间的范围过于巨大,比如[1,1e9], 采取直接构造数组的方式必然空间溢出,动态开点方式不会先构造线段树,而是在实际遍历到这个区间节点时才去构造这个节点的子节点, 这样,中间很多我们用不到的区间节点不会被构造,空间复杂度就会大大降低,后面会有好几道例题使用这个策略。

数组实现

下面的代码可以当做模板背,一般只需要实现update + query或者add + query即可, build()除了一些模板题都不需要实现,再去掉我的一些注释,每题需要实现的部分大概只有这个五分之二左右。

/**
 * 实现update和add的线段树
 */
public class SegmentTree {
    //丢弃0下标
    private int[] arr;
    private int[] sum;
    private int[] lazy;
    private int[] update;
    private boolean[] changed;
    private final int N;
    private final int n;

    public SegmentTree(int[] arr) {
        n = arr.length;
        N = n + 1;
        this.arr = new int[N];
        for (int i = 1; i < N; i++) {
            this.arr[i] = arr[i - 1];
        }
        /**
         * 将满二叉树数组设计为N的4倍;
         * arr实际构成了满二叉树nodes的`叶子结点`,即2^(h - 1)[h为树高], 而满二叉树的节点数为2^h - 1
         *  => 剩下的非叶子节点数为2^(h - 1) - 1, 即N - 1。
         * 最好状态下,N恰好为2的整数次幂,那么要设计为满二叉树,数量为N + N - 1 = 2N - 1;
         * 最坏状态下,N恰好为2的整数次幂加一,要设计为满二叉树,叶子结点需要补足2的整数次幂,
         *  即N + N - 2 = 2N - 2,非叶子节点为2N - 3, 共4N - 5, 舍去几个常数节点,即4N个。
         * 综上,4N个下标可以满足任何情况下的建树。
         * 
         * 最终会变成这样:
         *          X
         *        X   X
         *      X  O X  O
         * 
        
         *  O表示没有用上。   
         *  可以看到,若节点真的有空闲,会在左右子树都有空闲,在build()过程中是直接按照数组来划分边界的。
         * */
        int size = N << 2;
        sum = new int[size];
        changed = new boolean[size];
        update = new int[size];
        lazy = new int[size];

        //整棵树的根节点:1
        build(1, n, 1);
    }


    /**
     L, R表示要建立的树所在的左右边界;
     //在这里及以后的方法中,l,r表示`规整的`边界,即确实是树的左右边界;
     //L,R表示非规整的边界,即用户传递的要进行特殊操作的区间边界;
     //root表示当前这段边界的子树根节点下标。
     */
    private void build(int L, int R, int root) {
        if (L == R) {
            sum[root] = arr[L];
            return;
        }
        //将这段数组一分为二,分别记录在左右子树之中。
        int mid = (L + R) >> 1;

        //满二叉树左右子树根节点下标
        int left = root << 1, right = root << 1 | 1;
        build(L, mid, left);
        build(mid + 1, R, right);
        sum[root] = sum[left] + sum[right];
    }

    /**
     * 将[L,R]区间的每个元素的值加上add

    解释为什么mid=(l+r)/2是树(l, r)的左子树:
        由于是满二叉树,树的节点数为2^h - 1 = 2^(h - 1) + 1
        除去根节点,则两个子树的节点总数为2^(h - 1);
        由于满二叉树的左右子树都是满二叉树,且高度相等,可得左右子树的数量都是mid = 2^(h - 2) = (r - l) / 2;[左右边界直接相减,会使得区间长度减一]
        故,当前树[l,r]的左子树[l + 1, mid], 右子树[mid + 1, r]
     */
    public void add(int L, int R, int add, int l, int r, int root) { 
        if (L <= l && R >= r) {
            //懒惰累加
            //若当前累加区间完全覆盖该子树区间,此累加只记录在当前根节点不再向下传递
            lazy[root] += add;
            sum[root] += (r - l + 1) * add;
            return;
        }
        //不满足完全覆盖,需要先将之前的懒惰值传递给直接子节点,再进行递归得到更新
        int mid = (l + r) >> 1;
        int ln = mid - l + 1, rn = r - mid;
        pushDown(root, ln, rn);

        int left = root << 1, right = root << 1 | 1;

        if (L <= mid) {
            add(L, R, add, l, mid, left);
        }
        if (R > mid) {
            add(L, R, add, mid + 1, R, right);
        }

        //将左右子树更新的sum更新到root的sum上
        sum[root] = sum[left] + sum[right];
    }

    /**
     * 大体上和add()原理差不多,需要注意的是:一旦进行了update, add的记录应该要失效
     * */ 
    public void update(int L, int R, int C, int l, int r, int root) {
        if (L <= l && R >= r) {
            //覆盖
            lazy[root] = 0;
            changed[root] = true;
            update[root] = C;
            sum[root] = C * (r - l + 1);
            return;
        }
        int mid = (r + l) >> 1;
        int ln = (mid - l + 1), rn = r - mid;
        pushDown(root, ln, rn);

        int left = root << 1, right = root << 1 | 1;

        if (L <= mid) {
            update(L, R, C, l, mid, left);
        }
        if (R > mid) {
            update(L, R, C, mid + 1, r, right);
        }
        
        sum[root] = sum[left] + sum[right];
    }

    /**
     * 查询L,R的区间和
     */
    public long query(int L, int R, int l, int r, int root) {
        if (L <= l && R >= r) {
            return sum[root];
        }
        int mid = (l + r) >> 1;
        int ln = mid - l + 1, rn = r - mid;
        pushDown(root, ln, rn);

        long res = 0L;
        int left = root << 1, right = root << 1 | 1;
        if (L <= mid) {
            res += query(L, R, l, mid, left);
        }
        if (R > mid) {
            res += query(L, R, mid + 1, r, right);
        }

        return res;
    }

    /**
     * 在update和add之前,先将父节点的懒数据传播给子节点。
     * @param root 根节点下标
     * @param ln 根节点的左子树的节点数量
     * @param rn 根节点的右子树的节点数量
     */
    private void pushDown(int root, int ln, int rn) {
        int left = root >> 1, right = root >> 1 | 1;

        if (changed[root]) {
            //change必须首先传播,因为change会使得lazy失效,这样可以减少一个add()的无效操作。
            changed[root] = false;
            lazy[root] = 0;

            changed[left] = true;
            changed[right] = true;

            update[left] = update[root];
            update[right] = update[root];
            sum[left] = ln * update[root];
            sum[right] = rn * update[root];
        }

        if (lazy[root] != 0) {
            //有传递懒增加的必要
            lazy[left] += lazy[root];
            lazy[right] += lazy[root];
            sum[left] += lazy[root] * ln;
            sum[right] += lazy[root] * rn;
            sum[root] = sum[left] + sum[right];
            lazy[root] = 0;
        }
    }
}

同时,也给出两类动态开点的模板,由于是我从题目中选取的,只实现了update(),但是你照猫画虎应该可以实现另外的add()或者build()

动态开点1:预估容量

这个还是要借助数组,但是不会开区间大小,根据判题系统允许的空间开辟一个Node数组,力扣一般能开到600000左右。

Node记录左右子树在Node数组的下标。

这种做法我其实不太推荐,因为写起来是三种实现最难的,而且时间性能并不比第三种方式好

		 class Node {
            int li, ri; //左右子节点的下标
            int max, lazy;
        }

        Node[] t = new Node[100010];

        int idx = 1;

        void lazyCreate(int rt) {
            if (t[rt] == null) {
                t[rt] = new Node();
            }
            int l = t[rt].li, r = t[rt].ri;
            if (l == 0) {
                t[++idx] = new Node();
                t[rt].li = idx;
            }
            if (r == 0) {
                t[++idx] = new Node();
                t[rt].ri = idx;
            }
        }

        void pushUp(Node cur) {
            cur.max = Math.max(t[cur.li].max, t[cur.ri].max);
        }

        void pushDown(Node cur) {
            if (cur.lazy != 0) {
                int l = cur.li, r = cur.ri;
                t[l].lazy = cur.lazy;
                t[r].lazy = cur.lazy;
                t[l].max = cur.lazy;
                t[r].max = cur.lazy;
                cur.lazy = 0;
            }
        }

        public int query(int L, int R, int l, int r, int rt) {
            if (L <= l && R >= r) {
                return t[rt].max;
            }
            lazyCreate(rt);
            Node cur = t[rt];
            pushDown(cur);
            int mid = (l + r) >> 1;
            int res = 0;
            if (mid >= L) {
                res = Math.max(res, query(L, R, l, mid, cur.li));
            }
            if (R > mid) {
                res = Math.max(res, query(L, R, mid + 1, r, cur.ri));
            }
            return res;
        }

        public void update(int L, int R, int U, int l, int r, int rt) {
            if (L <= l && R >= r) {
                t[rt].lazy = U;
                t[rt].max = U;
                return;
            }
            lazyCreate(rt);
            Node cur = t[rt];
            pushDown(cur);
            int mid = (l + r) >> 1;
            if (mid >= L) {
                update(L, R, U, l, mid, cur.li);
            }
            if (R > mid) {
                update(L, R, U, mid + 1, r, cur.ri);
            }
            pushUp(cur);
        }
}

lazyCreate()是懒惰创建子节点的方法。

动态开点2:树的经典定义

 		static class Node {
            Node l, r;
            int max, lazy;
        }

        Node root = new Node();
        void lazyCreate(Node rt) {
            if (rt.l == null) {
                rt.l = new Node();
            }
            if (rt.r == null) {
                rt.r = new Node();
            }
        }

        void pushDown(Node rt) {
            if (rt.lazy != 0) {
                rt.l.lazy = rt.lazy;
                rt.r.lazy = rt.lazy;
                rt.l.max = rt.lazy;
                rt.r.max = rt.lazy;
                rt.lazy = 0;
            }
        }

        void pushUp(Node rt) {
            rt.max = Math.max(rt.l.max, rt.r.max);
        }

        public int query(int L, int R, int l, int r, Node rt) {
            if (L <= l && R >= r) {
                return rt.max;
            }
            lazyCreate(rt);
            pushDown(rt);
            int mid = (l + r) >> 1, res = 0;
            if (mid >= L) {
                res = query(L, R, l, mid, rt.l);
            }
            if (mid < R) {
                res = Math.max(res, query(L, R, mid + 1, r, rt.r));
            }
            return res;
        }

        public void update(int L, int R, int U, int l, int r, Node rt) {
            if (L <= l && R >= r) {
                rt.lazy = U;
                rt.max = U;
                return;
            }
            lazyCreate(rt);
            pushDown(rt);
            int mid = (l + r) >> 1;
            if (mid >= L) {
                update(L, R, U, l, mid, rt.l);
            }
            if (mid < R) {
                update(L, R, U, mid + 1, r, rt.r);
            }
            pushUp(rt);
        }

本质上其实和上一种很像,但是少了很多麻烦的事情,比如开数组,算下标,一切都是那么随心所欲。

注意有一点:root节点必须开头初始化好,作为其他实现中类似rt的作用。

几个套路

当你非常熟悉上面三个模板后,几个套路或者技巧也值得学一下:

1. 离散化

如果你特别喜欢第一种实现方式,离散化是应对数据范围大的另一个利器,这种实现就可以使用普通数组来做线段树了,但是没有动态开点那么好用。

但是离散化也常用于其他算法,比如差分、树状数组等,还是很值得学的。

线段树的离散化是指,传入的update和查询区间可能是这样的:[1, 10000], [2, 5], [9, 800], 它们的数值跨度特别大,而我们实际上不关心它的数值到底是什么,只关心它的大小顺序,比如在[1, 10000]统一更新为1,你直接说在所有区间都更新为1也是一样的,没必要强调这个10000, 下次查询[1, 10000]我也直管查询全部区间和即可。

我们用一个自增数字表示这些区间端点的相对顺序,即上面的数组转为[1, 6], [2, 3], [4, 5], 这样我们只需要开辟长度为6的几个数组即可,就可以使用第一种实现方法了。

java的离散化模板:

Map<Integer, Integer> map = new HashMap<>();
public List<Integer> fallingSquares(int[][] positions) {
    TreeSet<Integer> set = new TreeSet<>();
    for (int[] p : positions) {
        set.add(p[0]);
        set.add(p[1]);
    }
    int idx = 1;
    for (int t : set) {
        map.put(t, idx++);
    }
    for (int[] p : positions) {
        int L = map.get(p[0]), R = map.get( p[1]), U = p[1];
        query(L, R, 1, n, 1);
        update(L, R, U, 1, n, 1);
    }
}
2. 计算模型

熟悉模板后,要考虑如何应用到各种题型中。

最大值模型

若是有求最大值的需求,pushUp()就需要修改求最大值:

private void pushUp(Node rt) {
    rt.max = Math.max(rt.l.max, rt.r.max);
}

同时,覆盖解:

rt.lazy += u;
rt.max += u;

这是由于大部分的最大值题都是说在某某区间再加上一个u,而这个区间原本最大值就已经是lazy和max了,在完全覆盖的情况下,更改就是加上这个u;

同理,pushDown也是类似,直接加上父节点的lazy即可:

private void pushDown(Node rt) {
    if (rt.lazy != null) {
        rt.l.lazy += rt.lazy;
        rt.r.lazy += rt.lazy;
        rt.l.l.max += rt.lazy;
        rt.l.r.max += rt.lazy;
    	rt.lazy = 0;
    }
}

最大值类型较为灵活,可以根据题意决定是先更新还是先query。

求和模型

某一个区间的和都被增加一个值的操作覆盖,那个区间的所有节点都要增加这个值,那么根节点就需要增加自己的全部子孙节点数*u

pushUp(Node rt) {
    rt.sum = rt.r.sum + rt.r.sum;
}

//全部覆盖
rt.lazy += u;
rt.sum += (r - l + 1) * u; //全部子孙节点数量*u

pushDown(int ln, int rn, Node rt) { //左右子树的节点数
    if (rt.lazy != 0) {
        rt.l.lazy += rt.lazy;
        rt.r.lazy += rt.lazy;
        rt.l.l.sum += rt.lazy * ln;
        rt.l.r.sum += rt.lazy * rn;
    	rt.lazy = 0;
    }
}

三、例题

未完待续。。。

其实题目我都写完了,但是现在不想搬上来,写的快吐了。。。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值