这篇文章上次修改于 238 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

ACM程序课算法笔记13——线段树入门

线段树介绍

线段树的功能是维护一个数组,当然你可以用普通数组维护,线段树只是一种维护方式,它的特点是可以在O(LogN)的时间复杂度完成单点修改,区间修改,区间查询。

线段树结构

线段树的结构目的是为了维护区间的某个信息,比如总和,最大值,最小值等等。

按照树的思想,把区间进行多次的分割,同时,线段树使用数组存储树,使用数组表示树可以使根节点编号为1。

例如区间[1,5] 我们根据树先进行分割一次[1, 3]和[4, 5]这就是一个根节点变成两个子节点。同时在对它们俩进行一个拆分,此时[1,3]变成[1,2]和[3,3]。[4, 5]拆成[4,4]和[5,5]结构如下图

segt1.svg

还有一点需要注意的是,线段树不一定要维护总和,我们可以用一个节点来做一些例子:(此处的加就负责节点的合并)

struct Node{
		int valSum;
		int valMax;
		int valMin;
		int lazy;
		Node operator+(Node n2){
				Node newNode;
				newNode.valSum = valSum + n2.valSum;
				newNode.valMax = max(valMax, n2.valMax);
				newNode.valMin = min(valMin, n2.valMin);
				return newNode;
		}
    Node operator+(int val){
				Node newNode;
				newNode.valSum += val;
				newNode.valMax += val;
				newNode.valMin += val;
				return newNode;
		}
}

线段树的构建

对于建立一个这样的空间,我们可以发现很多都是在做重复的工作,所以不妨使用递归。再从下往上构建,颇有回溯味道。再根据数组储存树的基础知识,获取左子节点的方法是 index * 2 右子节点是 index * 2 +1

void build(vector<Node> &a, int left, int right, int arrNo) {
	  if (left == right) {
		    arr[arrNo] = a[left];
		    return;
	  }
	  int mid = (left + right) / 2; // 当然你可以选择防溢出
	  
	  int leftTreeNo = arrNo * 2;
	  int rightTreeNo = arrNo * 2 + 1;
	  
	  build(a, left, mid, leftTreeNo);
	  build(a, mid + 1, right, rightTreeNo);
		// 特征值向上转移,这里使用相加,当然还可以使用其它方式
	  arr[arrNo] = arr[leftTreeNo] + arr[rightTreeNo];
}

线段树的区间查询

线段树的区间查询不一定是,这里我们以求和为例子,也就是arr[1]储存1~5所有值的和,每个元素管理的区间的区间特征值作为它的值。同样,你也可以使用结构体来代表一个节点,这样就可以储存多个值。

线段树的区间查询有一个特点,一次查询最后是两个节点的和。例如当我求区间[2, 5] 实际上访问的就是节点[2, 2]和[3, 5]两个节点的和并。

为什么是两个:已知如果是多个一定连续,如果两个连续大概率合并成一个。

所以对于区间查询来说,主要问题就是如何找到这两个节点。

Node getsum(int queryLeft, int queryRight, int nodeLeft, int nodeRight, int node) {
	  if (queryLeft <= nodeLeft && nodeRight <= queryRight)
	    return arr[node];
	  
	  int mid = (nodeLeft + nodeRight) / 2; // 当然你可以选择防溢出
	  int leftTreeNo = node * 2;
	  int rightTreeNo = node * 2 + 1;
	  Node sum = {0, MAX, MIN, 0};
	  
	  if (queryLeft <= mid) 
			  sum = sum + getsum(queryLeft, queryRight, nodeLeft, mid, leftTreeNo);
	  if (mid < queryRight) 
			  sum = sum + getsum(queryLeft, queryRight, mid + 1, nodeRight, rightTreeNo);
	  return sum;
}

通过递归从上往下去寻找即可。见上代码。

线段树的区间修改

根据简单的判断我们可以得出,线段树如果要修改一个值,就要同步修改上面的所有值,但如果修改一个区间,同样我们要进行很大的成本修改,对此,咱们提出了懒惰标级。

懒惰标记

懒惰标记的目的就是延迟对具体底下的值进行修改。例如我要修改的是区间[1, 3]的所有值,那我先只改[1, 2]和[3, 3],如果我之后查询的是[1, 4] 和 [1, 5]那么实际上就没有必要落实到1和2个题元素上,那我们就把这个修改暂时留在[1, 2]的这个节点上,等到我真的要去查询[1, 1]或者[2, 2],我再把这个修改下沉。

以下是关于懒惰标记的区间更新(区间增加或者区间减少)

void update(int queryLeft, int queryRight, int nodeLeft, int nodeRight, int node, int add) {
  if (queryLeft <= nodeLeft && nodeRight <= queryRight) {
		  arr[node] = arr[node] + (nodeRight - nodeLeft + 1) * add;
        
	    arr[node].lazy += add;
	    return;
  } 
  int mid = (nodeLeft + nodeRight) / 2;
  int leftTreeNo = node * 2;
  int rightTreeNo = node * 2 + 1;
  
  if (arr[node].lazy and nodeLeft != nodeRight) { // 向下转移懒惰标记
	    arr[leftTreeNo] = arr[leftTreeNo] + arr[node].lazy * (mid - nodeLeft + 1);
	    arr[rightTreeNo] = arr[rightTreeNo] + arr[node].lazy * (nodeRight - mid);
	    
	    arr[leftTreeNo].lazy += arr[node].lazy;
	    arr[rightTreeNo].lazy += arr[node].lazy;
	    
	    arr[node].lazy = 0;
  }
  
  if (queryLeft <= mid) 
		  update(queryLeft, queryRight, nodeLeft, mid, leftTreeNo, add);
  if (mid < queryRight) 
		  update(queryLeft, queryRight, mid + 1, nodeRight, rightTreeNo, add);
	
  arr[node] = arr[leftTreeNo] + arr[rightTreeNo];
}

以下是关于懒惰标记的区间查询

我们说过,一旦查询到下面,就需要对懒惰标记进行下移

Node getsum(int queryLeft, int queryRight, int nodeLeft, int nodeRight, int node) {
	  if (queryLeft <= nodeLeft && nodeRight <= queryRight)
	    return arr[node];
	  
	  int mid = (nodeLeft + nodeRight) / 2;
	  int leftTreeNo = node * 2;
	  int rightTreeNo = node * 2 + 1;
	  
	  if (arr[node].lazy and nodeLeft != nodeRight) { // 向下转移懒惰标记
	    arr[leftTreeNo] = arr[leftTreeNo] + arr[node].lazy * (mid - nodeLeft + 1);
	    arr[rightTreeNo] = arr[leftTreeNo] + arr[node].lazy * (nodeRight - mid);
	    
	    arr[leftTreeNo].lazy += arr[node].lazy;
	    arr[rightTreeNo].lazy += arr[node].lazy;
	    
	    arr[node].lazy = 0;
  }
	  
	  Node sum = {0, MAX, MIN, 0};
	  if (queryLeft <= mid) 
			  sum = sum + getsum(queryLeft, queryRight, nodeLeft, mid, leftTreeNo);
	  if (mid < queryRight) 
			  sum = sum + getsum(queryLeft, queryRight, mid + 1, nodeRight, rightTreeNo);
	  return sum;
}

当然也可以把对区间的操作从“都增加一个值add”变成“都变成一个值x”。

此时的lazy标签就代表要改的值了,但需要注意一点,主要另一个值来判断是否要修改,因为lazy标签为0时,代表的是全部值改为0而并不是不修改元素。

一些优化

  • 在叶子节点处无需下放懒惰标记,所以懒惰标记可以不下传到叶子节点。
  • 动态创建子节点,在不访问的时候就不创建节点。
  • 标记永久化:如果确定懒惰标记不会在中途被加到溢出,那么就可以将标记永久化。标记永久化可以避免下传懒惰标记,只需在进行询问时把标记的影响加到答案当中,从而降低程序常数。也就是不需要修改子节点的值,只需要一直利用路途中的懒惰标记即可。