目录

线段树-树状树学习及应用

假设我们有一个数组:

  • 数组不变,求区间和:「前缀和」、「树状数组」、「线段树」

  • 多次修改某个数(单点),求区间和:「树状数组」、「线段树」

  • 多次修改某个区间,输出最终结果:「差分」

  • 多次修改某个区间,求区间和:「线段树」、「树状数组」(看修改区间范围大小)

  • 多次将某个区间变成同一个数,求区间和:「线段树」、「树状数组」(看修改区间范围大小)

但是因为线段树代码量很大,所以我们应该按如下优先级进行考虑:

  1. 简单求区间和,用「前缀和」;
  2. 多次将某个区间变成同一个数,用「线段树」;
  3. 其他情况,用「树状数组」

注意:树状数组仅仅适合于求区间和的问题,而线段树适合于区间问题。

例题:LeetCode699:本题就是将区间变成同一个数,求最大值,注意并不是相加。

差分数组

前缀和主要适用的场景是原始数组不会被修改的情况下,频繁查询某个区间的累加和。

差分数组主要适用的场景是多次修改某个区间,输出最终结果

构造差分数组,diff[i] = nums[i] - nums[i-1]https://narcissusblog-img.oss-cn-beijing.aliyuncs.com/uPic/file-2022-09/image-20220907134005928.png

如果需要对区间[i - j]的元素全部加上k,则只需要diff[i] += k diff[j+1] -= k即可

通过差分数组还原原数组,可以看出原理,diff[i] += k 相当于给nums[i…]所有元素都加上k。

代码如下:

n := len(nums)
diff := make([]int, n)

// 构建差分数组
diff[0] = nums[0]
for i := 1; i < n; i++ {
  diff[i] = nums[i] - nums[i-1]
}

// 根据差分数组还原原数组
nums[0] = diff[0]
for i := 1; i < n; i++ {
  nums[i] = nums[i-1] + diff[i]
}

LeetCode题目1109

树状数组

1. 介绍

树状数组就是用数组来模拟树形结构。可以用来解决大部分基于区间上的更新以及求和问题。树状数组可以解决的问题都可以用线段树解决。但是线段树代码很长,常数很大,所以优先考虑树状树。修改和查询的复杂度都是O(logN),树状数组本质就是二进制规律的应用。

树形结构如下:

https://narcissusblog-img.oss-cn-beijing.aliyuncs.com/uPic/file-2022-09/image-20220907114619964.png

黑色数组代表原来的数组(下面用A[i]代替),红色结构代表我们的树状数组(下面用C[i]代替),则有如下:

C[1] = A[1]

C[2] = A[1] + A[2]

C[3] = A[3]

C[4] = A[1] + A[2] + A[3] + A[4]

C[5] = A[5]

C[6] = A[5] + A[6]

C[7] = A[7]

C[8] = A[1] + A[2] + A[3] + A[4] + A[5] + A[6] + A[7] + A[8]

可以发现规律为:C[i] = A[i-2^k+1]+A[i-2^k+2]+…+A[k]; (k为i的二进制中从最低位到高位连续零的长度)

例如,i=8(1000)的时候,k=3。

如何求和?例如找前7项和,那么SUM=C[7]+C[6]+C[4];

则可以发现求和规律:SUMi = C[i]+C[i-2^k1]+C[(i-2^k1)-2^k2]+……

注意:2^k怎么算呢?前辈的智慧:i&(-i),并且有一个专门的名称,lowbit。

2. 建立树状数组

如果我们更新A[i],则会影响到包含A[i]的所有位置,A[i]包含于C[i+2^k],C[(i+2^k)+2^k]……

单点更新,区间查询

代码如下:

	tree := make([]int, n+1) // int64

	// 更新,在i位置加上val
	add := func(i int, val int) {
		for ; i < len(tree); i += i & -i {
			tree[i] += val
		}
	}

	// 求原数组[1 - i]的和
	sum := func(i int) (res int) {
		for ; i > 0; i &= i - 1 {
			res += tree[i]
		}
		return
	}

	// 求原数组[l - r]的和
	query := func(l, r int) int {
		return sum(r) - sum(l-1)
	}

结构体写法

// 结构体写法
type fenwick struct {
	tree []int64
}

func newFenwickTree(n int) fenwick {
	return fenwick{make([]int64, n+1)}
}

// 位置 i 增加 val
// 1<=i<=n
func (f fenwick) add(i int, val int64) {
	for ; i < len(f.tree); i += i & -i {
		f.tree[i] += val
	}
}

// 求前缀和 [0,i]
// 0<=i<=n
func (f fenwick) sum(i int) (res int64) {
	for ; i > 0; i &= i - 1 {
		res += f.tree[i]
	}
	return
}

// 求区间和 [l,r]
// 1<=l<=r<=n
func (f fenwick) query(l, r int) int64 {
	return f.sum(r) - f.sum(l-1)
}

LeetCode题目:307

区间更新,单点查询

如果需要将[x - y]区间内所有值均加上k,如果采用上述方式,复杂度肯定不行。这个时候就不需要用原数据建树,而是用差分数组建树。

则当对[x - y]区间内所有值做修改,只需要修改差分数组D[x]、D[y+1]即可。

此时求差分数组D[1 - i]的和就是求元素组A[i]。D[i] + D[i-1] +…+D[0] = A[i] - A[i-1] + A[i-1] - A[i-2] +…+A[1] - A[0] = A[i] - A[0] = A[i]

注意:为了构建差分数组,规定A[0] = 0

代码如下:

tree := make([]int, n+1) // int64

// 更新,在i位置加上val
add := func(i int, val int) {
  for ; i < len(tree); i += i & -i {
    tree[i] += val
  }
}

// 更新区间[l, r]均加上val
// 注意:r+1即使超过n也没关系,因为不会用到
addRange := func(l, r int, val int) {
  add(l, val)
  add(r+1, -val)
}

// 求差分数组[1 - i]的和,即求A[i]
sum := func(i int) (res int) {
  for ; i > 0; i &= i - 1 {
    res += tree[i]
  }
  return
}

区间更新,区间查询

因为仍然需要区间更新,所以依然使用差分数组建树。推到如下:

https://narcissusblog-img.oss-cn-beijing.aliyuncs.com/uPic/file-2022-09/image-20220907142444410.png

代码如下:

tree1 := make([]int, n+1) // D[i]
tree2 := make([]int, n+1) // D[i]*(i-1)

// 更新,在i位置加上val
add := func(i int, val int) {
  x := i - 1
  for ; i < len(tree); i += i & -i {
    tree1[i] += val
    tree2[i] += val*x
  }
}

// 更新区间[l, r]均加上val
// 注意:r+1即使超过n也没关系,因为不会用到
addRange := func(l, r int, val int) {
  add(l, val)
  add(r+1, -val)
}

// 求原数组[1 - i]的和
sum := func(i int) (res int) {
  x := i
  for ; i > 0; i &= i - 1 {
    res += x*tree1[i] - tree2[i]
  }
  return
}

// 求原数组区间[l - r]的和
query := func(l, r int) int {
  return sum(r) - sum(l-1)
}

线段树

树状数组能解决的问题,线段树都能解决。线段树应用范围很广,主要用于解决区间问题,但远不止如此。

线段树是一种二叉搜索树,可以在线维护修改以及查询区间上的最值、求和。

线段树基本内容

首先需要明白:每个节点存什么?节点下标是什么?如何建树?

例如,A[1:6] = {1, 8, 6, 4, 3, 5} 如下区间最大值情况:

https://narcissusblog-img.oss-cn-beijing.aliyuncs.com/uPic/file-2022-09/image-20220907161154452.png

可以发现,每个叶子节点的值就是数组的值,每个非叶子节点的度都是二,且左右两个孩子分别存储父亲一半的区间。每个父亲的存储值也就是两个孩子存储的值的最大值。

最重要的问题是如何存储区间?以及如何快速找到没叶子结点的孩子以及非根节点的父亲呢?

对于上述线段树,我们增加绿色数字为每个节点的下标: https://narcissusblog-img.oss-cn-beijing.aliyuncs.com/uPic/file-2022-09/image-20220907161933581.png

为什么叶子下标从9跳到了12?因为中间也有两个空间,这也是为什么无优化的线段树建树需要22^k空间,一本会开到4n的空间防止RE。

节点k的左子树下标为k << 1 右子树的下标为k<<1 | 1

常使用递归的方式建树。

线段树基本操作

1. 单点更新,区间查询

更新和查询的时间复杂度是O(logN)

对于上述线段树,我们把a[3]+7,更新后线段树应该为:

https://narcissusblog-img.oss-cn-beijing.aliyuncs.com/uPic/file-2022-09/image-20220907163818434.png

我们发现,无论更新哪一个叶子节点,最终都会到根节点,而把这个过程逆过来就是从根节点开始,找到左子树还是右子树包含需要更新的叶子节点,往下更新即可。所以采用递归的方式实现线段树更新

对于上述线段树,比如现在我们要查询[2, 5]区间的最值,可以看看那些区间是[2, 5]的真子集,

https://narcissusblog-img.oss-cn-beijing.aliyuncs.com/uPic/file-2022-09/image-20220907164852933.png

一共有五个区间,而区间[4, 5]已经包含其儿子节点的信息,所以一共需要查询3个区间。我们依然从根开始递归,如果当前节点是需要查询的区间的真子集,则返回节点信息并且停止递归。时间复杂度是O(logN)。

代码如下:

// l 和 r 也可以写到方法参数上,实测二者在执行效率上无异
// 考虑到 debug 和 bug free 上的优点,写到结构体参数中
type seg []struct {
	l, r int
	val  int
}

func newSegmentTree(a []int) seg {
	t := make(seg, 4*len(a))
	t.build(a, 1, 1, len(a))
	return t
}

// 单点更新:build 和 update 通用
func (t seg) set(o, val int) {
	t[o].val = val
}

// 合并两个节点上的数据:maintain 和 query 通用
// 要求操作满足区间可加性
// 例如 + * | & ^ min max gcd mulMatrix 摩尔投票 最大子段和 ...
func (seg) op(a, b int) int {
  // 示例为max操作
	if a > b {
		return a
	}
	return b
}

// 更新节点o
func (t seg) maintain(o int) {
	lo, ro := t[o<<1], t[o<<1|1]
	t[o].val = t.op(lo.val, ro.val)
}

// o为当前需要建立的节点,l,r分别表示当前节点表示区间的左右端点
func (t seg) build(a []int, o, l, r int) {
	t[o].l, t[o].r = l, r
	if l == r {
		t.set(o, a[l-1])
		return
	}
	m := (l + r) >> 1
	t.build(a, o<<1, l, m)	//递归构建左儿子节点
	t.build(a, o<<1|1, m+1, r)
	t.maintain(o) // 更新父节点
}

// o表示当前更新到的结点,从o=1开始更新
// i 表示要更新的节点,val表示要更新的值
func (t seg) update(o, i, val int) {
	if t[o].l == t[o].r {
		t.set(o, val)
		return
	}
	if m := (t[o].l + t[o].r) >> 1; i <= m {
		t.update(o<<1, i, val)
	} else {
		t.update(o<<1|1, i, val)
	}
	t.maintain(o)
}

// o=1 表示从根开始递归
// [l,r] 1<=l<=r<=n
func (t seg) query(o, l, r int) int {
	if l <= t[o].l && t[o].r <= r {
		return t[o].val
	}
	m := (t[o].l + t[o].r) >> 1 // 左儿子区间[t[o].l, m] 右儿子区间[m+1, t[o].r]
	if r <= m {
		return t.query(o<<1, l, r)
	}
	if m < l {
		return t.query(o<<1|1, l, r)
	}
	vl := t.query(o<<1, l, r)
	vr := t.query(o<<1|1, l, r)
	return t.op(vl, vr)
}

// 查询整个区间的信息
func (t seg) queryAll() int { 
  return t[1].val 
}

2. 区间更新区间查询

树状数组中区间更新我们采用了差分的思想,而线段树中,我们将引入lazy_tag(懒惰标记)线段树进行区间更新的时候,每次更新只更新到更新区间完全覆盖线段树节点区间为止,这样会导致被更新节点的子孙节点的区间得不到更新信息,所以在被更新节点上打上一个标记。等下次访问到这个节点的子节点时再讲这个标记传递给子节点,所以也可以叫延迟标记。

也就是说,在递归更新过程中,更新到节点区间为需要更新区间的真子集时不再递归更新,下次如果需要用到再更新,所以时间复杂度也是O(logN)。

type lazySeg []struct {
	l, r int
	todo int64
	sum  int64
}

// a 从 0 开始
func newLazySegmentTree(a []int64) lazySeg {
	t := make(lazySeg, 4*len(a))
	t.build(a, 1, 1, len(a))
	return t
}

func (lazySeg) op(a, b int64) int64 {
	return a + b // % mod
}

func (t lazySeg) maintain(o int) {
	lo, ro := t[o<<1], t[o<<1|1]
	t[o].sum = t.op(lo.sum, ro.sum)
}

func (t lazySeg) build(a []int64, o, l, r int) {
	t[o].l, t[o].r = l, r
	if l == r {
		t[o].sum = a[l-1]
		return
	}
	m := (l + r) >> 1
	t.build(a, o<<1, l, m)
	t.build(a, o<<1|1, m+1, r)
	t.maintain(o)
}

func (t lazySeg) do(o int, add int64) {
	to := &t[o]
	to.todo += add                     // % mod
	to.sum += int64(to.r-to.l+1) * add // % mod
}

func (t lazySeg) spread(o int) {
	if add := t[o].todo; add != 0 {
		t.do(o<<1, add)
		t.do(o<<1|1, add)
		t[o].todo = 0
	}
}

// o=1  [l,r] 1<=l<=r<=n
func (t lazySeg) update(o, l, r int, add int64) {
	if l <= t[o].l && t[o].r <= r {
		t.do(o, add)
		return
	}
	t.spread(o)
	m := (t[o].l + t[o].r) >> 1
	if l <= m {
		t.update(o<<1, l, r, add)
	}
	if m < r {
		t.update(o<<1|1, l, r, add)
	}
	t.maintain(o)
}

// o=1  [l,r] 1<=l<=r<=n
func (t lazySeg) query(o, l, r int) int64 {
	if l <= t[o].l && t[o].r <= r {
		return t[o].sum
	}
	t.spread(o)
	m := (t[o].l + t[o].r) >> 1
	if r <= m {
		return t.query(o<<1, l, r)
	}
	if m < l {
		return t.query(o<<1|1, l, r)
	}
	vl := t.query(o<<1, l, r)
	vr := t.query(o<<1|1, l, r)
	return t.op(vl, vr)
}

3. 动态开点

常规线段树,一般以满二叉树方式存储,浪费了很多空间,有时需要动态开点的方式,动态开点的优势就是按需开点,因此一般采用链表方式实现而不是数组方式实现。

3.1 单点修改的动态开点

代码如下

// 创建根节点
rt := &stNode{l: 1, r: 1e9}

type stNode struct {
	lo, ro *stNode
	l, r   int
	sum    int64
}

func (o *stNode) get() int64 {
	if o != nil {
		return o.sum
	}
	return 0 // inf
}

func (stNode) op(a, b int64) int64 {
	return a + b
}

func (o *stNode) maintain() {
	o.sum = o.op(o.lo.get(), o.ro.get())
}

func (o *stNode) update(i int, add int64) {
	if o.l == o.r {
		o.sum += add
		return
	}
	m := (o.l + o.r) >> 1
	if i <= m {
		if o.lo == nil {
			o.lo = &stNode{l: o.l, r: m}
		}
		o.lo.update(i, add)
	} else {
		if o.ro == nil {
			o.ro = &stNode{l: m + 1, r: o.r}
		}
		o.ro.update(i, add)
	}
	o.maintain()
}

func (o *stNode) query(l, r int) int64 {
	if o == nil || l > o.r || r < o.l {
		return 0 // inf
	}
	if l <= o.l && o.r <= r {
		return o.sum
	}
	return o.op(o.lo.query(l, r), o.ro.query(l, r))
}
3.2 区间修改的动态开点

代码如下

// 创建根节点
rt := &lazyNode{l: 1, r: 1e9}

type lazyNode struct {
	lo, ro *lazyNode
	l, r   int
	sum    int64
	todo   int64
}

func (o *lazyNode) get() int64 {
	if o != nil {
		return o.sum
	}
	return 0 // inf
}

func (lazyNode) op(a, b int64) int64 {
	return a + b
}

func (o *lazyNode) maintain() {
	o.sum = o.op(o.lo.get(), o.ro.get())
}

func (o *lazyNode) do(add int64) {
	o.todo += add                   // % mod
	o.sum += int64(o.r-o.l+1) * add // % mod
}

func (o *lazyNode) spread() {
	m := (o.l + o.r) >> 1
  // 动态开点
	if o.lo == nil {
		o.lo = &lazyNode{l: o.l, r: m}
	}
	if o.ro == nil {
		o.ro = &lazyNode{l: m + 1, r: o.r}
	}
	if add := o.todo; add != 0 {
		o.lo.do(add)
		o.ro.do(add)
		o.todo = 0 // -1
	}
}

func (o *lazyNode) update(l, r int, add int64) {
	if l <= o.l && o.r <= r {
		o.do(add)
		return
	}
	o.spread()
	m := (o.l + o.r) >> 1
	if l <= m {
		o.lo.update(l, r, add)
	}
	if m < r {
		o.ro.update(l, r, add)
	}
	o.maintain()
}

func (o *lazyNode) query(l, r int) int64 {
	// 对于不在线段树中的点,应按照题意来返回
	if o == nil || l > o.r || r < o.l {
		return 0
	}
	if l <= o.l && o.r <= r {
		return o.sum
	}
	o.spread()
	return o.op(o.lo.query(l, r), o.ro.query(l, r))
}

4. 离散化

离散化常用于二维状态在一维线段树建树的技巧,所谓离散化就是将无限的个体映射到有限的个体中,提高算法效率,常用Hash实现。

树状数组有时也需要离散化。例题LeetCode327。