线段树介绍

1 线段树介绍

  线段树,我刚开始听到这个名字的时候,感觉就是高大上,怕难度会很大,但是实际学起来的时候,会发现还是挺好理解的,很亲民→_→
  扯远了,先盗个图抛个问题:
线段树引子1
  图中的RMQ指的是(Range Minimum Query)问题(区间最值查询)。按暴力法直接一个for循环过去查询,每次查询的时间复杂度为O(N),频繁查询就是O(MN)了,性能上肯定要爆炸,代码要是这样写,分分钟被leader扔下楼。。。
  线段树主要就是用于频繁的区间查询区间更新,区间查询、更新的时间复杂度是O(logN),是典型的以空间换时间

1.1 线段树的结构及性质

  以长度为5的区间为例,线段树的结构如下图所示。其中圆框内表示该节点所管理的区间,红色字体表示节点id。例如,根节点的节点id是1,它管理的区间范围为[1, 5]。
线段树结构
  从图中可以看出线段树的几个性质:

  • 线段树的每个节点维护一个区间[i, j]的信息。如果一个节点维护着区间[i, j]的信息,且i != j,那么其左孩子维护着区间[i, (i+j)/2]的信息,右孩子维护着区间[(i+j)/2+1, j]的信息。
  • 线段树中,如果一个节点的编号为x,那么左孩子的编号为2x,右孩子的编号为2x+1。
  • N个元素的线段树的高度为logN+1

  线段树常用静态数组进行存储。数组长度通常开最大长度的4倍,不然很容易出现数组越界程序core掉。

1.2 建树

  为了方便理解,这里使用一个简单的小例子来描述建树的过程。
  对于原始数组:A = {1, 5, 8, 2, 90},我们用这个数组来建立线段树,用于查询区间的最小值。实际上建树过程就是建立普通二叉树的先序建树,如下图所示。图中的[x, y]:z表示x, y是区间端点,z是该区间的最小值。
  对于每个父节点,建立完其左右子节点后,父节点的最小值 = min(左节点的最小值, 右节点的最小值),自底向上求出每个节点所对应的区间的最小值。
建树
  建树的代码也很简洁,采用递归实现:

/**
 * 递归创建线段树
 * 参数:viData 初始数据
 * 参数:dwNodeId 当前构造的线段树节点id
 * 参数:dwLeft 当前构造的线段树节点管理的左区间(闭区间)
 * 参数:dwRight 当前构造的线段树节点管理的右区间(闭区间)
 */
void Build(const std::vector<int>& viData, uint32_t dwNodeId
				, uint32_t dwLeft, uint32_t dwRight)
{
	if (dwLeft == dwRight)	// 叶子节点
	{
		m_viMin[dwNodeId] = viData[dwLeft];
		return;
	}

	uint32_t dwMid = (dwLeft + dwRight) >> 1;
	Build(viData, dwNodeId << 1, dwLeft, dwMid);	// 左子树
	Build(viData, dwNodeId << 1 | 1, dwMid + 1, dwRight);	// 右子树
	// 更新当前节点的最小值
	m_viMin[dwNodeId] = std::min(m_viMin[dwNodeId << 1], m_viMin[dwNodeId << 1 | 1]);
}

  计算左右节点id,我这边是习惯用位运算 dwNodeId << 1 替换 2 * dwNodeId,用 dwNodeId << 1 | 1 替换 2 * dwNodeId + 1,因为位运算的效率要高一些。

2 区间查询

  在刚才建好的线段树上查询RMQ(3, 5),查询过程如下图所示:
区间查询
  首先从根节点开始查询,发现根节点的区间[1, 5]有部分不符合查询区间[3, 5],向下查询其左右子区间,直到找出一个节点的区间能够完全被查询区间包围为止,即图中的[3, 3]及[4, 5]区间,此时就不需要再继续往下查询了。
  得到子区间的RMQ值后,返回给父亲节点,父亲节点再从返回的2个子区间的RMQ值中取一个小的return给其父亲,层层返回,最后得到所要求区间的RMQ值。

代码如下:

/**
 * 区间查询
 * 参数:dwNodeId 当前查询的线段树节点id
 * 参数:dwNodeLeft 当前查询的线段树节点管理的左区间(闭区间)
 * 参数:dwNodeRight 当前查询的线段树节点管理的右区间(闭区间)
 * 参数:dwQueryLeft 所要查询的左区间(闭区间)
 * 参数:dwQueryRight 所要查询的右区间(闭区间)
 * 返回值:查询到的最小值,INF表示无穷大,意味着查询结果不存在
 */
int Query(uint32_t dwNodeId, uint32_t dwNodeLeft, uint32_t dwNodeRight
			, uint32_t dwQueryLeft, uint32_t dwQueryRIght)
{
	// 查询区间与线段树节点管理的区间无交集,结束查询
	if (dwQueryLeft > dwNodeRight || dwQueryRight < dwNodeLeft)
		return INF;
	// 当前节点管理的区间完全被查询区间包含,直接返回
	if (dwQueryLeft <= dwNodeLeft && dwQueryRight >= dwNodeRight)
		return m_viMin[dwNodeId];
	
	// 递归查询左右子树
	uint32_t dwMid = (dwNodeLeft + dwNodeRight) >> 1;
	int iRetLeft = Query(dwNodeId << 1, dwNodeLeft, dwMid
									, dwQueryLeft, dwQueryRight);
	int iRetRight = Query(dwNodeId << 1 | 1, dwMid + 1, dwNodeRIght
									, dwQueryLeft, dwQueryRight);
	return std::min(iRetLeft, iRetRight);
}

从代码可以推算得出,线段树区间查询的时间复杂度为O(logN)。

3 单点更新

  只有查询当然不可能啦,总会需要更新节点信息的,那么首先就先讲一下单点更新。
  还是以刚才那颗线段树为例子。例如要修改A[2]为0,那么首先从根节点开始往下找,找到区间为[2, 2]的节点之后,将它的最小值更新为0,之后向上递归更新各个父节点的最小值。过程如下图所示:
单点更新
  自顶向下找到对应节点后,修改其值,并原路返回,自底向上更新所经过的各区间RMQ值。
  显然,每次更新修改不超过logN个节点的信息。

代码如下:

/**
 * 单点更新
 * 参数:dwNodeId 当前更新的线段树节点id
 * 参数:dwNodeLeft 当前更新的线段树节点管理的左区间(闭区间)
 * 参数:dwNodeRight 当前更新的线段树节点管理的右区间(闭区间)
 * 参数:dwUpdateId 所要更新的数据节点id
 * 参数:iVal 所要更新的数据值
 */
void Update(uint32_t dwNodeId, uint32_t dwNodeLeft, uint32_t dwNodeRight
					, uint32_t dwUpdateId, int iVal)
{
	// 更新点id不在当前线段树节点管理的区间中,结束更新
	if (dwUpdateId < dwNodeLeft || dwUpdateId > dwNodeRight) return;

	if (dwNodeLeft == dwNodeRight && dwNodeLeft == dwUpdateId)
	{
		// 找到所要更新的节点
		m_viMin[dwNodeId] = iVal;
		return;
	}

	// 递归更新左右子树
	uint32_t dwMid = (dwNodeLeft + dwNodeRight) >> 1;
	Update(dwNodeId << 1, dwNodeLeft, dwMid, dwUpdateId, iVal);
	Update(dwNodeId << 1 | 1, dwMid + 1, dwNodeRight, dwUpdateId, iVal);
	// 更新当前节点的最小值
	m_viMin[dwNodeId] = std::min(m_viMin[dwNodeId << 1], m_viMin[dwNodeId << 1 | 1]);
}

  从代码可以推算得出,线段树单点更新的时间复杂度为O(logN)。

4 区间更新

  上面讲完了区间查询、单点更新之后,是不是就没了咧?当然不是,线段树可不是这么简陋就完事了的。实际问题中,总会需要进行区间更新的,如果是把区间拆开分别调用单点更新,那么每次区间更新的时间复杂度就是O(NlogN),要是区间更新的操作频繁一点的话,那就是O(NMlogN),分分钟想哭。
  于是咧,就要开始讲线段树的精华所在——延迟标记
  延迟标记是指对每个节点新增加一个标记,记录这个节点是否进行了某种修改(这种修改操作会影响其子节点)。
引子2
  对于一段区间[L,R]的更新,先按照之前方法递归下去,直到遇到一个节点所管辖区间完全被[L,R]包含的节点时,我们只需要更新此节点,然后在此处做上一个延迟标记 add[x]=val 表示该节点下所有子节点都被加了一个值val,此时即可结束更新过程。

在这里大家可能会有个疑问,为什么做了延迟标记后就可以终止更新呢?
  假设当前节点管辖区间为[L,R]。打下延迟标记时,已经修正了 sum[L_R]的值,后续的查询或更新操作可能只访问到当前节点而不需要知道其子区间的sum值。因此,不必去更新其子区间的值。
  而当我们需要查询或更新区间[L,R]的子区间时(例如[L,mid]),此时我们才需要把标记传递到儿子节点,并更新他们所管辖左右子区间的值。
  这也就是延迟标记这个名字的由来(我猜的哈)。对于暂时不需要碰到的区间,我只在它的父节点上打个标记,迟一些需要操作这个区间了再来处理之前打过的标。

  因为是区间更新,所以这回换个例子,不用那个区间最小值的线段树做例子,改为区间求和的线段树。
  要对区间[1, 4]的值都加上5,那么就向下查找出能够完全被区间[1, 4]包围的节点,即图中的[1, 3]和[4, 4]。然后给这两个节点打上延迟标记,并维护当前区间的区间和,至于它们的子区间就不去管了。
区间更新延迟标记1
  之后,查询区间[3, 3]的区间和,那么在查询到[3, 3]之前,会先查到[1, 3]这个区间,由于需要查询[1, 3]的子区间,所以将区间[1, 3]的延迟标记下方到它的子区间[1, 2]和[3, 3],并维护这两个子区间的区间和,并清除区间[1, 3]的延迟标记。最后才往下查询,找到区间[3, 3],并返回它的区间和。
区间更新延迟标记2
  总结起来就是,先按照区间查询、单点更新的方法处理当前区间, 当需要访问当前区间的子区间时,则先下放延迟标记(并清除自身标记) ,再递归到子区间进行更新或查询操作。
  按照刚才的描述,那么区间更新其实就分为两部分:下放延迟标记更新节点
  下放延迟标记的代码如下:

/**
 * 下放延迟标记(并清除自身标记)
 * 参数:dwNodeId 当前处理的线段树节点id
 * 参数:dwLeft 当前处理的线段树节点管理的左区间(闭区间)
 * 参数:dwRight 当前处理的线段树节点管理的右区间(闭区间)
 */
void PushDown(uint32_t dwNodeId, uint32_t dwLeft, uint32_t dwRight)
{
	// 无延迟标记,结束
	if (0 == m_viAdd[dwNodeId]) return;
	// 将延迟标记下放到左右子树,并修正左右子树的sum值
	uint32_t dwMid = (dwLeft + dwRight) >> 1;
	m_viAdd[dwNodeId << 1] += m_viAdd[dwNodeId];
	m_viSum[dwNodeId << 1] += (dwMid - dwLeft + 1) * m_viAdd[dwNodeId];
	m_viAdd[dwNodeId << 1 | 1] += m_viAdd[dwNodeId];
	m_viSum[dwNodeId << 1 | 1] += (dwRight - dwMid) * m_viAdd[dwNodeId];
	// 清除自身标记
	m_viAdd[dwNodeId] = 0;
}

  区间更新的代码如下:

/**
 * 区间更新
 * 参数:dwNodeId 当前更新的线段树节点id
 * 参数:dwNodeLeft 当前更新的线段树节点管理的左区间(闭区间)
 * 参数:dwNodeRIght 当前更新的线段树节点管理的右区间(闭区间)
 * 参数:dwUpdateLeft 所要更新的左区间(闭区间)
 * 参数:dwUpdateRight 所要更新的右区间(闭区间)
 * 参数:iVal 所要更新的数据值
 */
void Update(uint32_t dwNodeId, uint32_t dwNodeLeft, uint32_t dwNodeRight
					, uint32_t dwUpdateLeft, uint32_t dwUpdateRight, int iVal)
{
	// 更新点id不在当前线段树节点管理的区间中,结束更新
	if (dwUpdateRight < dwNodeLeft || dwUpdateLeft > dwNodeRight) return;

	if (dwUpdateLeft <= dwNodeLeft && dwUpdateRight >= dwNodeRight)
	{
		// 当前节点管理的区间完全被更新区间包含,更新sum,打上延迟标记,结束更新
		m_viSum[dwNodeId] += (dwNodeRight - dwNodeLeft + 1) * iVal;
		m_viAdd[dwNodeId] += iVal;
		return;
	}

	PushDown(dwNodeId, dwNodeLeft, dwNodeRight);	// 下放延迟标记
	// 递归更新左右子树
	uint32_t dwMid = (dwNodeLeft + dwNodeRight) >> 1;
	Update(dwNodeId << 1, dwNodeLeft, dwMid, dwUpdateLeft, dwUpdateRight, iVal);
	Update(dwNodeId << 1 | 1, dwMid + 1, dwNodeRight, dwUpdateLeft, dwUpdateRight, iVal);
	// 更新当前节点的区间和
	m_viSum[dwNodeId] = m_viSum[dwNodeId << 1] + m_viSum[dwNodeId << 1 | 1];
}

  可以发现,和单点更新相比,几乎只多了一步PushDown处理延迟标记 ,其余地方的处理方法几乎一样。
  使用延迟标记,让线段树区间更新的时间复杂度变为O(logN)

  到这里,线段树的内容就讲完了 才没完呢。刚才说了,使用延迟标记之后,当查询、更新子区间的时候,需要下放延迟标记。所以呢,前面区间查询的代码也需要小改一下:

/**
 * 区间查询
 * 参数:dwNodeId 当前查询的线段树节点id
 * 参数:dwNodeLeft 当前查询的线段树节点管理的左区间(闭区间)
 * 参数:dwNodeRight 当前查询的线段树节点管理的右区间(闭区间)
 * 参数:dwQueryLeft 所要查询的左区间(闭区间)
 * 参数:dwQueryRight 所要查询的右区间(闭区间)
 * 返回值:查询结果
 */
int Query(uint32_t dwNodeId, uint32_t dwNodeLeft, uint32_t dwNodeRight
				, uint32_t dwQueryLeft, uint32_t dwQueryRight)
{
	// 查询区间与当前线段树节点管理的区间无交集,结束查询
	if (dwQueryLeft > dwNodeRight || dwQueryRight < dwNodeLeft)
		return 0;

	// 当前节点管理的区间完全被查询区间包含,直接返回
	if (dwQueryLeft <= dwNodeLeft && dwQueryRight >= dwNodeRight)
		return m_viSum[dwNodeId];

	PushDown(dwNodeId, dwNodeLeft, dwNodeRight);	// 下放延迟标记
	// 递归查询左右子树
	uint32_t dwMid = (dwNodeLeft + dwNodeRight) >> 1;
	int iRetLeft = Query(dwNodeId << 1, dwNodeLeft, dwMid, dwQueryLeft, dwQueryRight);
	int iRetRight = Query(dwNodeId << 1 | 1, dwMid + 1, dwNodeRight, dwQueryLeft, dwQueryRight);
	return iRetLeft + iRetRight;
}

  和之前的区间查询相比,几乎只多了一步PushDown处理延迟标记 ,其余地方的处理方法和之前几乎一样。时间复杂度依旧是O(logN)。

5 线段树的优缺点

  优点

  • 区间查询、区间更新的时间复杂度为O(log N)
  • 延迟标记的思想
  • 代码简洁,实现方便

  缺点

  • 不支持区间插入、删除,每次修改区间范围都需要重新建树,性能会降很多
  • 所解决的问题需要能划分成子问题来处理
  • 需要较大的额外空间来存放线段树,一般需要开4N的空间

  前两个缺点比较致命,不符合那两点要求的场景,线段树都不适用。 然而符合这两点要求的,大部分都只存在于算法竞赛或者笔试面试题中。。。Orz(也可能是因为我太菜,还没接触到或者联想起来。。。Orz)


后记

  到这里,线段树的基本介绍就差不多说完了,准备下一篇博客讲一下线段树的应用。
  其实为什么突然翻出线段树来讲呢?因为自从学了线段树以来,一直都是用来打ACM或者是刷笔试面试编程题,由于线段树那两个致命缺陷,我也没有想出线段树能在实际应用中用在哪个地方。直到最近在公司有同学分享了一下STL的内存分配,给了我灵感:线段树是不是可以用于内存管理? 对于固定的一片内存区域,在其中进行内存分配、内存释放,其实这些也属于区间操作。想到这,两眼就放光。简单的代码差不多撸完了,这几天再写篇新的博客来记录一下思路和过程,也欢迎各位大佬的指点。

  • 14
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值