💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
[TOC] # 1 线段树(又名为线段修改树) 线段树所要解决的问题是,区间的修改,查询和更新,如何更新查询的更快? 线段树结构提供三个主要的方法, 假设大小为N的数组,以下三个方法,均要达到O(logN) : ```Java // L到R范围的数,每个数加上V void add(int L, int R, int V, int[] arr); // L到R范围的数,每个数都更新成V void update(int L, int R, int V, int[] arr); // L到R范围的数,累加和返回 int getSum(int L, int R, int[] arr); ``` ## 1.1 线段树概念建立 ### 1.1.1 累加和数组建立 1、对于大小为n的数组,我们二分它,每次二分我们都记录一个信息 2、对于每次二分,成立树结构,我们想拿任何区间的信息,可以由我们的二分结构组合得到。例如我们1到8的数组,可以二分得到的信息为: ``` graph TD '1-8'-->'1-4' '1-8'-->'5-8' '1-4'-->'1-2' '1-4'-->'3-4' '5-8'-->'5-6' '5-8'-->'7-8' '1-2'-->'1' '1-2'-->'2' '3-4'-->'3' '3-4'-->'4' '5-6'-->'5' '5-6'-->'6' '7-8'-->'7' '7-8'-->'8' ``` 每一个节点的信息,可以由该节点左右孩子信息得到,最下层信息就是自己的信息。由以上的规则,对于N个数,我们需要申请2N-1个空间用来保存节点信息。如果N并非等于2的某次方,我们把N补成2的某次方的长度,用来保证我们构建出来的信息数是满二叉树。例如我们的长度是6,我们补到8个,后两个位置值为0。 对于任意的N,我们需要准备多少空间,可以把N补成2的某次方,得到的二分信息都装下?答案是4N。4N虽然有可能多分空间,但是多余的空间都是0,并无影响,而且兼容N为任意值的情况 例如四个数长度的数组arr[4]{3,2,5,7},我们得到累加和的二分信息为如下的树: ``` graph TD '1到4=17'-->'1到2=5' '1到4=17'-->'3到4=12' '1到2=5'-->'3' '1到2=5'-->'2' '3到4=12'-->'5' '3到4=12'-->'7' ``` 我们申请4N的空间,即16,arr[16]。0位置不用。arr[1]=17,arr[2]=5,arr[3]=12,arr[4]=3,arr[5]=2,arr[6]=5,arr[7]=7。剩下位置都为0。任何一个节点左孩子下标为2i,右孩子下标为2i+1 得到累加和信息的分布树的大小,和值的情况,那么update更新树,和add累加树,同样的大小和同样的坐标关系构建。 ### 1.1.2更新结构数组建立 懒更新概念,例如有8个数,我们要把1到6的数都减小2。那么先看1到6是否完全囊括8个数,如果囊括直接更新。很显然这里没有囊括,记录要更新1到6,下发该任务给1到4和5到8。1到6完全囊括1到4,记录到lazy中,不再下发;5到8没有囊括1到6,继续下发给5到6和7到8,5到6被囊括,记录到lazy不再继续下发,7到8不接受该任务 这种懒更新机制的时间复杂度为O(logN),由于一个区间经过左右子树下发,只会经过一个绝对路径到叶子节点,其他节点都会被懒住。如果某个节点有新的任务进来,会把之前懒住的信息下发给左右孩子 对于update操作,如果update操作经过的信息节点上存在懒任务,那么该次update操作会取消该节点的lazy,无需下发,因为下发了也会给update覆盖掉; ```Java public class Code01_SegmentTree { public static class SegmentTree { // arr[]为原序列的信息从0开始,但在arr里是从1开始的 // sum[]模拟线段树维护区间和 // lazy[]为累加懒惰标记 // change[]为更新的值 // update[]为更新慵懒标记 private int MAXN; private int[] arr; // 4*arr.length() private int[] sum; // 4*arr.length() private int[] lazy; // 4*arr.length() private int[] change; // 4*arr.length() private boolean[] update; // 根据int[] origin来初始化我们的线段树结构 public SegmentTree(int[] origin) { MAXN = origin.length + 1; arr = new int[MAXN]; // arr[0] 不用 从1开始使用 for (int i = 1; i < MAXN; i++) { arr[i] = origin[i - 1]; } // sum数组开辟的大小是原始数组的4倍 sum = new int[MAXN << 2]; // 用来支持脑补概念中,某一个范围的累加和信息 lazy = new int[MAXN << 2]; // 用来支持脑补概念中,某一个范围沒有往下傳遞的纍加任務 change = new int[MAXN << 2]; // 用来支持脑补概念中,某一个范围有没有更新操作的任务 update = new boolean[MAXN << 2]; // 用来支持脑补概念中,某一个范围更新任务,更新成了什么 } // 汇总当前位置rt的信息,为左孩子信息加上右孩子信息 private void pushUp(int rt) { sum[rt] = sum[rt << 1] + sum[rt << 1 | 1]; } // 之前的,所有懒增加,和懒更新,从父范围,发给左右两个子范围 // 分发策略是什么 // ln表示左子树元素结点个数,rn表示右子树结点个数 private void pushDown(int rt, int ln, int rn) { // 首先检查父亲范围上有没有懒更新操作 if (update[rt]) { // 父范围有懒更新操作,左右子范围就有懒更新操作 update[rt << 1] = true; update[rt << 1 | 1] = true; // 左右子范围的change以父亲分发的为准 change[rt << 1] = change[rt]; change[rt << 1 | 1] = change[rt]; // 左右子范围的懒任务全部清空 lazy[rt << 1] = 0; lazy[rt << 1 | 1] = 0; // 左右子范围的累加和全部变为当前父节点下发的change乘以左右孩子的范围个数 sum[rt << 1] = change[rt] * ln; sum[rt << 1 | 1] = change[rt] * rn; // 父范围的更新任务被分发到左右子范围,当前父范围的更新任务改为false update[rt] = false; } // 如果上面的if也进入,该if也进入,表示之前的最晚懒住的更新到现在还没有发生过新的更新使之下发,却来了个add任务 // 所以该节点即懒住了更新任务,又懒住一个add任务,接着又来了一个update任务,所以更新要先下发到子范围,接着要把当前的add任务下发下去 // 如果当前节点的懒信息不为空。 if (lazy[rt] != 0) { // 下发给左孩子 lazy[rt << 1] += lazy[rt]; sum[rt << 1] += lazy[rt] * ln; // 下发给右孩子 lazy[rt << 1 | 1] += lazy[rt]; sum[rt << 1 | 1] += lazy[rt] * rn; // 清空当前节点的懒任务信息 lazy[rt] = 0; } } // 在初始化阶段,先把sum数组,填好 // 在arr[l~r]范围上,去build,1~N, // rt : 这个范围在sum中的下标 public void build(int l, int r, int rt) { if (l == r) { sum[rt] = arr[l]; return; } // 得到l到r的中间位置 int mid = (l + r) >> 1; // l到r左侧,填充到sum数组rt下标的2倍的位置,因为在数组中当前节点和左孩子的关系得到 // 递归rt左区间 build(l, mid, rt << 1); // 右侧,填充到2*rt+1的位置 // 递归rt右区间 build(mid + 1, r, rt << 1 | 1); pushUp(rt); } // 更新操作 public void update(int L, int R, int C, int l, int r, int rt) { // 如果更新任务彻底覆盖当前边界 if (L <= l && r <= R) { // 当前位置的update标记为true update[rt] = true; // 当前位置需要改变为C, update和change搭配使用 change[rt] = C; // 当前节点的累加和信息,被C * (r - l + 1)覆盖掉 sum[rt] = C * (r - l + 1); // 清空之前存在该节点的懒任务 lazy[rt] = 0; return; } // 当前任务躲不掉,无法懒更新,要往下发 int mid = (l + r) >> 1; // 之前的,所有懒更新,从父范围,发给左右两个子范围 pushDown(rt, mid - l + 1, r - mid); // 更新任务发给左孩子 if (L <= mid) { update(L, R, C, l, mid, rt << 1); } // 更新任务发给右孩子 if (R > mid) { update(L, R, C, mid + 1, r, rt << 1 | 1); } pushUp(rt); } // L..R -> 任务范围 ,所有的值累加上C // l,r -> 表达的范围 // rt 去哪找l,r范围上的信息 public void add( int L, int R, int C, int l, int r, int rt) { // 任务的范围彻底覆盖了,当前表达的范围,懒住 if (L <= l && r <= R) { // 当前位置的累加和加上C * (r - l + 1),等同于下边节点都加上C,由于被懒住,下面节点并没有真正意思上add一个C sum[rt] += C * (r - l + 1); // 之前懒住的信息,例如之前该节点加上3,又来一个加上7的任务,那么此时lazt[rt]==10 lazy[rt] += C; return; } // 任务并没有把l...r全包住 // 要把当前任务往下发 // 任务 L, R 没有把本身表达范围 l,r 彻底包住 int mid = (l + r) >> 1; // l..mid (rt << 1) mid+1...r(rt << 1 | 1) // 下发之前该节点所有攒的懒任务到孩子节点 pushDown(rt, mid - l + 1, r - mid); // 左孩子是否需要接到任务 if (L <= mid) { add(L, R, C, l, mid, rt << 1); } // 右孩子是否需要接到任务 if (R > mid) { add(L, R, C, mid + 1, r, rt << 1 | 1); } // 左右孩子做完任务后,我更新我的sum信息 pushUp(rt); } // 1~6 累加和是多少? 1~8 rt public long query(int L, int R, int l, int r, int rt) { // 累加任务覆盖当前节点范围,返回当前节点范围的累加和 if (L <= l && r <= R) { return sum[rt]; } // 没覆盖当前节点的范围,汇总左右子范围的累加和,汇总给到当前节点 int mid = (l + r) >> 1; pushDown(rt, mid - l + 1, r - mid); long ans = 0; if (L <= mid) { ans += query(L, R, l, mid, rt << 1); } if (R > mid) { ans += query(L, R, mid + 1, r, rt << 1 | 1); } return ans; } } // 暴力解法,用来做对数器 public static class Right { public int[] arr; public Right(int[] origin) { arr = new int[origin.length + 1]; // 做一层拷贝,arr[0]位置废弃不用,下标从1开始 for (int i = 0; i < origin.length; i++) { arr[i + 1] = origin[i]; } } public void update(int L, int R, int C) { for (int i = L; i <= R; i++) { arr[i] = C; } } public void add(int L, int R, int C) { for (int i = L; i <= R; i++) { arr[i] += C; } } public long query(int L, int R) { long ans = 0; for (int i = L; i <= R; i++) { ans += arr[i]; } return ans; } } public static int[] genarateRandomArray(int len, int max) { int size = (int) (Math.random() * len) + 1; int[] origin = new int[size]; for (int i = 0; i < size; i++) { origin[i] = (int) (Math.random() * max) - (int) (Math.random() * max); } return origin; } public static boolean test() { int len = 100; int max = 1000; int testTimes = 5000; int addOrUpdateTimes = 1000; int queryTimes = 500; for (int i = 0; i < testTimes; i++) { int[] origin = genarateRandomArray(len, max); SegmentTree seg = new SegmentTree(origin); int S = 1; int N = origin.length; int root = 1; seg.build(S, N, root); Right rig = new Right(origin); for (int j = 0; j < addOrUpdateTimes; j++) { int num1 = (int) (Math.random() * N) + 1; int num2 = (int) (Math.random() * N) + 1; int L = Math.min(num1, num2); int R = Math.max(num1, num2); int C = (int) (Math.random() * max) - (int) (Math.random() * max); if (Math.random() < 0.5) { seg.add(L, R, C, S, N, root); rig.add(L, R, C); } else { seg.update(L, R, C, S, N, root); rig.update(L, R, C); } } for (int k = 0; k < queryTimes; k++) { int num1 = (int) (Math.random() * N) + 1; int num2 = (int) (Math.random() * N) + 1; int L = Math.min(num1, num2); int R = Math.max(num1, num2); long ans1 = seg.query(L, R, S, N, root); long ans2 = rig.query(L, R); if (ans1 != ans2) { return false; } } } return true; } public static void main(String[] args) { int[] origin = { 2, 1, 1, 2, 3, 4, 5 }; SegmentTree seg = new SegmentTree(origin); int S = 1; // 整个区间的开始位置,规定从1开始,不从0开始 -> 固定 int N = origin.length; // 整个区间的结束位置,规定能到N,不是N-1 -> 固定 int root = 1; // 整棵树的头节点位置,规定是1,不是0 -> 固定 int L = 2; // 操作区间的开始位置 -> 可变 int R = 5; // 操作区间的结束位置 -> 可变 int C = 4; // 要加的数字或者要更新的数字 -> 可变 // 区间生成,必须在[S,N]整个范围上build seg.build(S, N, root); // 区间修改,可以改变L、R和C的值,其他值不可改变 seg.add(L, R, C, S, N, root); // 区间更新,可以改变L、R和C的值,其他值不可改变 seg.update(L, R, C, S, N, root); // 区间查询,可以改变L和R的值,其他值不可改变 long sum = seg.query(L, R, S, N, root); System.out.println(sum); System.out.println("对数器测试开始..."); System.out.println("测试结果 : " + (test() ? "通过" : "未通过")); } } ``` ## 1.2 线段树案例实战 想象一下标准的俄罗斯方块游戏,X轴是积木最终下落到底的轴线 下面是这个游戏的简化版: 1)只会下落正方形积木 2)[a,b] -> 代表一个边长为b的正方形积木,积木左边缘沿着X = a这条线从上方掉落 3)认为整个X轴都可能接住积木,也就是说简化版游戏是没有整体的左右边界的 4)没有整体的左右边界,所以简化版游戏不会消除积木,因为不会有哪一层被填满。 给定一个N*2的二维数组matrix,可以代表N个积木依次掉落, 返回每一次掉落之后的最大高度 > 线段树原结构,是收集范围累加和,本题是范围上收集最大高度当成收集的信息 ```Java public class Code02_FallingSquares { // 0位置不用,从1开始 public static class SegmentTree { private int[] max; private int[] change; private boolean[] update; public SegmentTree(int size) { int N = size + 1; max = new int[N << 2]; change = new int[N << 2]; update = new boolean[N << 2]; } private void pushUp(int rt) { max[rt] = Math.max(max[rt << 1], max[rt << 1 | 1]); } // ln表示左子树元素结点个数,rn表示右子树结点个数 private void pushDown(int rt, int ln, int rn) { if (update[rt]) { update[rt << 1] = true; update[rt << 1 | 1] = true; change[rt << 1] = change[rt]; change[rt << 1 | 1] = change[rt]; max[rt << 1] = change[rt]; max[rt << 1 | 1] = change[rt]; update[rt] = false; } } public void update(int L, int R, int C, int l, int r, int rt) { if (L <= l && r <= R) { update[rt] = true; change[rt] = C; max[rt] = C; return; } int mid = (l + r) >> 1; pushDown(rt, mid - l + 1, r - mid); if (L <= mid) { update(L, R, C, l, mid, rt << 1); } if (R > mid) { update(L, R, C, mid + 1, r, rt << 1 | 1); } pushUp(rt); } public int query(int L, int R, int l, int r, int rt) { if (L <= l && r <= R) { return max[rt]; } int mid = (l + r) >> 1; pushDown(rt, mid - l + 1, r - mid); int left = 0; int right = 0; if (L <= mid) { left = query(L, R, l, mid, rt << 1); } if (R > mid) { right = query(L, R, mid + 1, r, rt << 1 | 1); } return Math.max(left, right); } } // positions // [2,7] -> 表示位置从2开始,边长为7的方块,落下的x轴范围为2到8,不包括9是因为下一个位置为9可以落得下来; 2 , 8 // [3, 10] -> 3, 12 // // 用treeSet做离散化,避免多申请空间 public HashMap<Integer, Integer> index(int[][] positions) { TreeSet<Integer> pos = new TreeSet<>(); for (int[] arr : positions) { pos.add(arr[0]); pos.add(arr[0] + arr[1] - 1); } HashMap<Integer, Integer> map = new HashMap<>(); int count = 0; for (Integer index : pos) { map.put(index, ++count); } return map; } public List<Integer> fallingSquares(int[][] positions) { HashMap<Integer, Integer> map = index(positions); // 100 -> 1 306 -> 2 403 -> 3 // [100,403] 1~3 int N = map.size(); // 1 ~ N SegmentTree segmentTree = new SegmentTree(N); int max = 0; List<Integer> res = new ArrayList<>(); // 每落一个正方形,收集一下,所有东西组成的图像,最高高度是什么 for (int[] arr : positions) { int L = map.get(arr[0]); int R = map.get(arr[0] + arr[1] - 1); int height = segmentTree.query(L, R, 1, N, 1) + arr[1]; max = Math.max(max, height); res.add(max); segmentTree.update(L, R, height, 1, N, 1); } return res; } } ``` 本题为leetCode原题:https://leetcode.com/problems/falling-squares/ ## 1.3 什么样的题目可以用线段树来解决? 区间范围上,统一增加,或者统一更新一个值。大范围信息可以只由左、右两侧信息加工出, 而不必遍历左右两个子范围的具体状况