内部结构是一个数组。
对外提供三个接口:
add(L,R,V):在LR范围上的位置上的所有的数都加上某个V值
update(L,R,V):在LR范围上的位置上的所有的数都更新成某个V值
getsum(L,R):获取LR范围上的所有的数的累加和
如何比较快的实现上述方法?假设数据规模是N,使其每个方法的操作时间复杂度都达到O(logN)的级别。
public static class Right { public int[] arr; public Right(int[] origin) { //i从1开始,0位置不用 arr = new int[origin.length + 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; } }
这种遍历,显而易见,时间复杂度达不到O(logN)
在一棵满二叉树中,如果叶子节点是N个,那么非叶子节点则是N-1个
因此,树节点的总个数= N +N-1 = 2N-1
假设我们要求数组中1~8位置的累加和,那么其实可以把这个问题进行二分拆解:
18二分拆成14和58,而14和5~8又可以继续二分拆解。。 直到拆到最终的直接对应位置的值,这样就可以形成一棵满二叉树(如图上)
每一步的拆解成的范围节点,都可以看成是一个结果的存储,因此图上最多需要2*8-1 =15个位置
可以看到上面18区间的长度为8,是2的次幂,这样的话可以保证从18开始向下分,一定可以均等的拆分下去到叶子节点,最终形成满二叉树。
如果要求的区间不是2的次幂怎么办? 例如1-6,那此时则需要手动多补齐成2的次幂(1~8)的范围问题,只不过多出来的叶子位置补上0即可。
为了满足2的次幂个数的区间长度,往往需要手动额外补齐一些空间,最坏情况下,假设任意区间N有2n+1的长度,那么如果符合2n的数量达到X的话,还需要额外填充X的空间,也就是2X的叶子节点空间
而非叶子节点的个数则需要2X-1的个数 。 因此,最多需要4X的存储空间。
上面描述的最终形成的树的结构,其实只是我们想象的逻辑结构,最终我们实际操作的是数组,那如何在数组中表示这种到树的映射呢?
假设有数组[3,2,5,7]。我们可以想象构建出树的叶子节点,就是我们数组下标的直接位置上的值。然后依次向上推出父节点
我们维护一个sum数组来保存各个区间累加和的结果,从根节点开始,依次放入数组当中。 此数组下标0位置不用,从1位置开始
放入之后如何找到对应区间累加和的所在数组的下标?
从最大范围(L-R)的入手,最大范围LR累加和的下标一定是数组的下标1位置。 类似堆的操作。对于一棵满二叉树来说,一个节点的左孩子=此节点位置2,右孩子=此节点位置2+1
例如上面构建好的数组sum中, 我要拿4-4区间累加和的值:
1、从14区间累加和的位置开始,此时L=1,需要首先来到34的累加和位置,也就是右孩子, 也就是L2+1=3
2、来到34。因为44属于3~4的右孩子,因此继续,此时L=3, 4~4的下标是 32+1 =7, 也就是说 , 4~4累加和的位置在sum数组的7位置上
线段树的实现O(logN)时间复杂度的关键在于一手对于范围上的新增或者更新操作的“懒缓存”。
实现要点:
1、借助数组的结构来模拟我们逻辑中想象的由大到小的范围累加和形成的树结构
2、对于新增、更新、查询操作,都是从大范围开始匹配,并且利用懒缓存尽可能少的向下传递值的改变,在保证可以完全捕获结果的时候,直接进行返回结果,进而提升效率,如果不能完全捕获,则需要向下面的二分子范围进行分发(例如在18的累加和范围上,要匹配和操作的就是18的累加和,那么直接匹配操作即可,且仅仅对1~8范围进行操作,而不向下传递,但会缓存住变动; 一旦范围不能完全捕获:例如在18的累加和范围内,要匹配和操作的是35范围的内容,则需要继续向下分发匹配)
3、因为懒缓存的存在,如果当前捕获的范围和不能满足需要匹配的范围的时候,在向下分发之前,先需要首先将当前范围之前懒缓存住的更新和新增操作向后分发,以保证结果正确。 这一点,在新增、更新、查询操作中,都是如此。
public static class SegmentTree { // arr[]为原序列的信息从0开始,但在arr里是从1开始的 // sums[]模拟线段树维护区间和 // lazies[]为累加懒惰标记 // changes[]为更新的值 // updates[]为更新慵懒标记 int MAXN; int[] arr; int[] sums; int[] lazies; int[] changes; boolean[] updates; public SegmentTree(int[] originArr) { MAXN = originArr.length + 1; arr = new int[MAXN];// arr[0] 不用 从1开始使用 for (int i = 1; i < arr.length; i++) { arr[i] = originArr[i - 1]; } sums = new int[MAXN << 2];// 用来支持脑补概念中,某一个范围的累加和信息 lazies = new int[MAXN << 2];// 用来支持脑补概念中,某一个范围沒有往下传递的累加任务 changes = new int[MAXN << 2];// 用来支持脑补概念中,某一个范围有没有更新操作的任务 updates = new boolean[MAXN << 2]; // 用来支持脑补概念中,某一个范围更新任务,更新成了什么 build(1, originArr.length, 1); //对原数组中的数据进行初始化sums累加和的内容 } // 在初始化阶段,先把sum数组,填好 // 在arr[L~R]范围上,去build,1~N, // pos : 这个范围在sum中的下标 public void build(int L, int R, int pos) { if (L == R) { sums[pos] = arr[L]; return; } //对当前L..R范围内的累加和进行二分 int mid = (L + R) >> 1; build(L, mid, pos << 1); build(mid + 1, R, pos << 1 | 1); setCurrentPosSum(pos); } private void setCurrentPosSum(int pos) { sums[pos] = sums[pos << 1] + sums[pos << 1 | 1]; } // L..R -> 任务范围 ,所有的值累加上addNum // LRange,RRange -> 表达的范围 // pos 去哪找LRange,RRange范围上的信息 public void add(int L, int R, int LRange, int RRange, int addNum, int pos) { // 任务的范围彻底覆盖了,当前表达的范围 if (L <= LRange && R >= RRange) { sums[pos] += (RRange - LRange + 1) * addNum; lazies[pos] += addNum; return; } // 任务并没有把LRange...RRange全包住 // 要把当前任务往下发 // 任务 L, R 没有把本身表达范围 任务并没有把LRanger,RRange全包住彻底包住 int mid = (RRange + LRange) >> 1; //另一种表达: LRange + ((RRange - LRange) >> 1) // 下发之前所有攒的懒任务 clearLazyAddAndUpdate(mid - LRange + 1, RRange - mid, pos); // 左孩子是否需要接到任务 if (L <= mid) { add(L, R, LRange, mid, addNum, pos << 1); } // 右孩子是否需要接到任务 if(R>mid){ add(L, R, mid+1,RRange, addNum, pos << 1 | 1); } // 左右孩子做完任务后,我更新我的sum信息 setCurrentPosSum(pos); } public void update(int L, int R, int LRange, int RRange, int updateNum, int pos) { if (L <= LRange && R >= RRange) { sums[pos] = (RRange - LRange + 1) * updateNum; lazies[pos] =0; updates[pos] = true; changes[pos] = updateNum; return; } // 当前任务躲不掉,无法懒更新,要往下发 int mid = (RRange + LRange) >> 1; clearLazyAddAndUpdate(mid - LRange + 1, RRange - mid, pos); if (L <= mid) { update(L, R, LRange, mid, updateNum, pos << 1); } if(R>mid){ update(L, R, mid+1,RRange, updateNum, pos << 1 | 1); } setCurrentPosSum(pos); } public int query(int L, int R, int LRange, int RRange, int pos) { //如果全命中,直接返回 if (L <= LRange && R >= RRange) { return sums[pos]; } //否则,尝试分发给左右孩子去获取 int mid = (RRange + LRange) >> 1; //但在分发之前,依然需要进行清空之前懒缓存住的add和update clearLazyAddAndUpdate(mid - LRange + 1, RRange - mid, pos); int res=0; if (L <= mid) { res+=query(L, R, LRange, mid, pos << 1); } if(R>mid){ res+= query(L, R, mid+1,RRange, pos << 1 | 1); } return res; } // 之前的,所有懒增加,和懒更新,从父范围,发给左右两个子范围 // leftSize表示左子树元素结点个数,rightSize表示右子树结点个数 private void clearLazyAddAndUpdate(int leftSize, int rightSize, int pos) { //为什么先下发更新,再下发新增? 因为如果进行了更新操作(也就是如果updates有值),则必然导致lazies被清空; // 但也会存在updates和lazies同时有值的情况:在一次更新之后,进行了多次新增的操作,而下一次更新还没有到来的时候,此时,两者都有值,并且,这意味着更新一定先于新增操作,所以要按顺序进行下发处理 if(updates[pos]){ sums[pos << 1] = leftSize * changes[pos]; sums[pos << 1 | 1] = rightSize * changes[pos]; changes[pos << 1] = changes[pos]; changes[pos << 1 | 1] = changes[pos]; updates[pos << 1] = true; updates[pos << 1 | 1] = true; lazies[pos << 1] =0; lazies[pos << 1 | 1] = 0; updates[pos] = false; } if (lazies[pos] != 0) { sums[pos << 1] += leftSize * lazies[pos]; sums[pos << 1 | 1] += rightSize * lazies[pos]; lazies[pos << 1] += lazies[pos]; lazies[pos << 1 | 1] += lazies[pos]; lazies[pos] = 0; } } }