18线段树(又名为线段修改树)

/ 技术相关 / 0 条评论 / 180浏览

原文地址

1 线段树(又名为线段修改树)

线段树所要解决的问题是,区间的修改,查询和更新,如何更新查询的更快?

线段树结构提供三个主要的方法, 假设大小为N的数组,以下三个方法,均要达到O(logN) :

// 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覆盖掉;

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个积木依次掉落, 返回每一次掉落之后的最大高度

线段树原结构,是收集范围累加和,本题是范围上收集最大高度当成收集的信息

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 什么样的题目可以用线段树来解决?

区间范围上,统一增加,或者统一更新一个值。大范围信息可以只由左、右两侧信息加工出, 而不必遍历左右两个子范围的具体状况