最近在看些线段树的东西,虽然不是搞ACM的,但了解这些东西还是有好处的。这里是Quora上的一个讨论,谈了对程序员可能一辈子都写不到的数据结构或算法的体会,If advanced algorithms and data structures are never used in industry, then why learn them?
在网上看了很多教程和文章,感觉有帮助但很多都写得不易懂,新手比较难理解。所以决定写篇通俗点的,方便以后入门的同学。
线段树定义
线段树通常用来处理与线段/区间统计有关的问题。例如需要对某个区间进行批量修改,或者需要按区间进行多次查询,那么通常可以用线段树来进行优化。
首先,来看线段树的定义,如下图1,表示一颗[0, 7]区间的线段树。每个节点都表示一条线段,如根节点就代表[0, 7]的线段。然后每条线段的左右儿子线段分别是该线段的左半段和右半段,如[0,7]的左右儿子分别是[0,3], [4,7]。
上面只是线段树的结构(或者说是骨架),实际应用里最有用的是存储在线段树节点里的信息。具体节点里面保存或维护什么信息需要根据具体问题来确定,下面先来看一个简单的例子。
简单的例子
给定n条线段: {[2,5], [4, 6], [0, 7]},m个点{2, 4, 7},判断这m个点分别在几条线段出现过。
解法1
最简单的解法,就是对每个点分别遍历n条线段,看每个点是否在线段内,时间复杂度为O(n * m)。这种做法简单,但是对于每一次查询,都需要遍历n条线段。因此我们想到,是不是可以对n条线段进行预处理,然后每次查询只需要利用预处理后的更少的数据(小于n),而不需要原来的n条数据。
解法2
事实上,我们可以用一个数组count[0 ... n],count[i]表示第i个点出现在线段的次数。然后根据以下过程:
- 初始时: [0, 0, 0, 0, 0, 0, 0, 0]
- 插入[2,5]: [0, 0, 1, 1, 1, 1, 0, 0]
- 插入[4,6]: [0, 0, 1, 1, 2, 2, 1, 0]
- 插入[0,7]: [1, 1, 2, 2, 3, 3, 2, 1]
即每次插入一条线段时,就将该线段的所有点的次数加1,这样查询时只要直接根据count[i]就可以确定点i出现在线段的次数,查询时间复杂度是O(1)。插入一条线段的时间复杂度取决于线段的长度,例如插入n条[0, n]的线段,时间复杂度为O(n^2),显然插入的时间复杂度不令人满意。
解法3(线段树解法)
如何优化插入线段的时间?这时候就可以利用线段树来批量更新区间的信息。
首先,我们给线段树的每个节点增加一个count成员来记录该节点所表示线段出现的次数。然后将所有线段依次插入。对要插入的线段A从根节点开始遍历,有以下4种情况:
- 线段A刚好与节点所表示线段完全重合,将该节点count + 1,返回;
- 线段A在节点的左半区间,则从该节点的左儿子开始递归遍历;
- 线段A在节点的右半区间,则从该节点的右儿子开始递归遍历;
- 线段A横跨节点的左右半区间,将线段A分成2段,左半段从该节点的左儿子开始遍历,右半段从该节点的右儿子开始遍历。
线段[2,5], [4,6], [0,7]的插入过程:
插入[2,5]:
- 将[2,5]与【0,7】比较,分成两部分,[2,3]插到左儿子【0,3】,[4,5]插到右儿子【4,7】
- 将[2,3]与【0,3】比较,插到右儿子【2,3】;将[4,5]与【4,7】比较,插到左儿子【4,5】
- [2,3]与【2,3】匹配,【2,3】的count + 1;[4,5]与【4,5】匹配,【4,5】的count + 1
插入[4,6]:
- 将[4,6]与【0,7】比较,插到右儿子【4,7】
- 将[4,6]与【4,7】比较,分成两部分,[4,5]插到左儿子【4,5】,[6,6]插到右儿子【6,7】
- [4,5]与【4,5】匹配,【4,5】的count + 1;[6,6]与【6,7】比较,插到左儿子【6,6】
- [6,6]与【6,6】匹配,【6,6】的count + 1
插入[0,7]:
- [0,7]与【0,7】匹配,【0,7】的count + 1
这样插入n条线段的平均时间为O(nlgn)。而查询的时候,只需要将包含查询点的节点的count值相加即可,查询的时间复杂度为O(lgn)。例如查询点2,只需要将【0,7】,【0,3】,【2,3】,【2,2】相加得到2。
Python代码如下:
总结
涉及到区间的信息更新或查询时我们通常可以使用线段树来优化,例如上面插入线段[0,7]时只需要简单将节点【0,7】的count+1,而如果采用解法2,插入线段[0,7]必须更新表的8个位置。在实际问题中,要根据具体情况选择好的解决方案。例如只查询1次,那么解法1是最好的选择。如果给出的线段长度都很短,而且查询很多次,那么解法2是最好选择。而解法2是相对中庸的选择。这个例子中,查询的是点而不是区间,如果查询的是区间,那么线段树将是最好的选择。
使用线段树解决问题最重要是要根据实际问题想清楚每个节点里面维护哪些信息,以及这些信息如何更高效地维护和查询。接下来一篇会介绍高效处理这些信息的一些技巧,包括点更新和延迟更新。