正文
本次问题主要集中在HiHo一下第16~21周,从Tarjan的Sparse-Table(ST)算法开始到线段树的使用主要讲述RMQ问题的解决方案以及离散化思想。RMQ问题在《算法竞赛入门经典——训练指南》一书中第三章也有专门讨论,并包含了这里不会讨论的树状数组。
HiHo一下第16周
与前面的一样,题目不再详述。解决这个问题使用了Tarjan的Sparse-Table方法,并且在题目的提示中已经讲解了,但是并没有给出图示,只给出了递推关系式,这里补充给出题目给出的case的解决过程来具体说明ST方法的工作原理。
首先需要明确的是ST方法实际上依旧是分治法的一种,并且需要一个表来保存计算结果以备查询(看起来与动态规划很像哈)。
来看看使用ST方法对已知case的解决过程吧:
先上主要工作,也就是构建Table的过程图:
图中的第i
行第j
列的数据记录以j开始,长度为2^i的区间内的最小值
。很显然,我们的第0行就是原数据了。
第1行则是两个元素
中的最小值,注意这个结果可以保存下来用来计算第2行的数据。
第2行则是四个元素
中的最小值,同样可以用来计算第3行的数据。
第三行则是八个元素
中的最小值。
由于case中的数据量为10,所以我们没有第4行了(注意我们是从0开始计数的,HiHo中的数据则是从1开始计数的)。
计算每一行的最大耗费为O(N)
,总共需要计算log(N)
行,因此ST算法总的时间复杂度上界为O(N*logN)
。
既然已经计算出了查询表,那么接下来就应该使用个这表来处理查询了,处理查询的思想是这样的:
假定想知道从下标为1
的元素开始,长度为7
的区间内数据的最小值,那么我们可以将这个区间表示为这样的连个区间的并:
- 下标为
1
的元素开始,长度为4
的区间; - 下标为
4
的元素开始,长度为4
的区间;
如图示:
由于区间长度为4 = 2^2,我们去找第2行中下表为1
和4
的数据:1556和981,两者的最小值981就是想要的结果。
再来一个例子:
假定想知道从下标为1
的元素开始,长度为9
的区间内数据的最小值,那么我们可以将这个区间表示为这样的连个区间的并:
- 下标为
1
的元素开始,长度为8
的区间; - 下标为
2
的元素开始,长度为8
的区间;
如图示:
由于区间长度为8 = 2^3,我们去找第3行中下表为1
和2
的数据:981和981,两者的最小值981就是想要的结果。
case中的查询按照上面的思想就可以求解了,很显然经过预处理之后,查询操作的时间复杂度是O(1)
。
接下来给出我的TLE代码……
真是够了,跟《训练指南》中的代码比较过,除了内循环那部分,别的没出入,依旧TLE,然而改成书中的格式之后反而更慢(sad face)。
1 | #include <iostream> |
HiHO一下第17周
前面讨论了RMQ问题的ST算法,这次就是它在LCA问题上的具体应用了,其中的一个关键思想就是:将树转化为数组
。
具体方法是这样的:我们知道DFS在工作的时候将会进入一个节点一次,退出一个节点一次(实际上就是该节点的Environment入栈和出栈啦),在这个过程中我们可以得到节点的深度信息,那么我们可以根据DFS的路径将每次遇到的深度信息放到一个数组中:1
2
3
4
5
6
7
8
9void dfs(Node* n, vector<int>& v)
{
foreach( node in n->children)
{
v.push_back(n->depth);
dfs(node,v);
v.push_back(n->depth);
}
}
于是对于树:
上图说明了DFS的路径。
有下面的数组:1
2
3
4DFS路径:
[A, B, D, D, B, E, I, I, E, B, A, C, F, J, J, F, C, G, G, C, H, H, C, A]
得到的对应数组:
[0, 1, 2, 2, 1, 2, 3, 3, 2, 1, 0, 1, 2, 3, 3, 2, 1, 2, 2, 1, 2, 2, 1, 0]
回顾LCA问题,实际上就是求两个节点在树中深度最低的那个公共祖先,结社我们要求D(深度2)
和I(深度3)
的LCA,已知结果为B
,的对应深度为1
。
记下D
和I
最后一次 出现的位置下标:3
和7
,在这段区间中的最小值为1
对于节点为B
,正是所求的结果。
d
同理,对于节点I
和J
的LCA,下标7
和14
之间的区间内,最小值为0
,对应节点为A
,同样为所求结果。
再有,对于节点C
和J
的LCA,下标22
和14
之间的区间内,最小值为1
,对应节点为C
,同样为所求结果。
事实上,上面的DFS路径其实是将树的无向边变为双向边得到的。
线段树
很显然,ST算法在原数组中的数据不会发生改动的时候工作的很好,而且代码也不难写,然而当原数组中的数据发生改变的时候,即便是只改变一个数据,就会导致表中数据大量的更新(看一下上面构建表过程图中的关系表示就知道了),这是不可接受的。
本想用《算法导论》上面的线段树例子,不过对比之后感觉不太合适进行应用方面的说明。还是按照《训练指南》上的说法来吧。
事实上,线段树跟前面图形方面的文中的Quad Tree
很像的:子树的并集“覆盖”(之所以用引号是因为多少还是不同的,不过这种类比可以更好的理解线段树,或者反之理解Quad Tree
)了父节点所代表的空间;这种类似甚至包含了对于树的查询过程:满足要求的节点直接得到结果,而完全不满足要求的则拒绝向下求解,部分满足要求的则继续向下细分。
直接借用HiHo上面的图来说明这一点:
一图胜千言,说白了就是Quad Tree
的一维形式,然后标准的Quad Tree
是二维的,三位的则是Oct Tree
。
下面我们想要查询区间[3, 9]
:
橘黄色的节点就是我们想要询问的节点了,这里的每个节点都保存着与该节点相关的区间的“目标数据”比如:最大值,最小值,和等等。查询最坏情况也只对两个边界来一个深度查询,因此也是O(logN)
的耗费。
回想一下我们讨论的Quad/Oct Tree
的工作方式,是不是几乎一样的?将空间中的物体集合换成其他信息了而已,思路都是一样的。
既然知道了查询,那么修改怎么办呢?区间修改是一个down-top的过程,我们对每个节点维护一个指向父节点的指针,自下而上修改相关区间的“目标数据”就可以了,非相关区间很明显是不受影响的,于是修改的时间耗费就成了树的高度,线段树很明显是一个二叉树,那么修改操作的时间耗费就是O(logN)
。
关于修改,还有一个 比较重要 的方法就是“懒惰标识”,这个思想跟“懒惰求值”是一样的,也就是不到万不得已不去修改,只做一个标记说明此处在下一次“问询”的时候需要重新计算。这种情况一般多见于对于未知问询的保守策略,不问的东西永远不会去求解,从而能提高效率(不需要每次修改把所有的点都计算一遍,只计算参与问询的点)。
线段树基本上介绍完毕了,接下来是与线段树相关的一个思想,在解决“区间相关”问题上非常有帮助(再比如2/3D空间的包含、重叠之类的问题)。
离散化
离散化思想的应用在HiHo一下第21周。下面根据题目跟出的case来说明一下离散化思想的应用。
元数据
首先是最基本的“元数据”离散化解法:
将长度为N
的区间分成N
个单位元,这个样我们可以得到一个如下所示的线段树:
绿色的叶子结点完全覆盖了整个区间,接下来我们按照顺序将给出的区间覆盖上去,这里用颜色来标注每一条线段覆盖上去之后的变化:
首先是区间[4, 10]
的线段。注意,严格来讲,代表区间[5, 10]
的节点以及它下属的所有节点都应该被标注为红色,但是我们这里不这样做,只是加上一个懒惰标识
,在接下来的过程中会看到懒惰标识
是如何发挥作用的。
接下来是区间[0, 2]
。
区间[1, 6]
。这个过程需要注意的是:一旦出现包含懒惰标识
但依旧需要细分的节点,那么它必须先向下给自己的子节点染色,例如代表区间[5, 10]
的节点。
区间[5, 9]
。
区间[3, 4]
。
最后,我们深度遍历整棵树,遇到有颜色的节点就终止深度递归,记录这个过程中遇到的不同的颜色数,就可以得到结果了。结果是5,正应了case的正确结果。
然而上面这个过程需要首先对整个区间建树,其耗费与区间长度N
正相关,是一个O(NlogN)
的过程,如果区间很大而实际出现的线段数量很少,那么就得不偿失了。事实上,我们只需要根据线段来建树,这样可以使得问题的求解只与线段数目相关,而这只要对线段做一个预处理就可以了。
超元数据
“超元数据”离散化解法:
先上图,注意与原来的划分方法比较,并注意缺失的3条垂直虚线与结果的联系:
首先根据线段的左右端点来对整个区间进行划分:1
[0, 1] [1, 2] [2, 3] [3, 4] [4, 5] [5, 6] [6, 9] [9, 10]
按照[0, 1, 2, 3, 4, 5, 6, 9, 10]
划分,对应的线段树:
是不是比前面的少了一些节点呢?注意虽然少了一些节点,但是绿色的叶子节点一样覆盖了整个区间。
然后就是跟前面一样按顺序覆盖线段了:
区间[4, 10]
。
区间[0, 2]
。
区间[1, 6]
。
区间[5, 9]
。
区间[3, 4]
。
完毕,比前面按照“元数据”划分要来的省心多了。
最后统计的时候依旧按照前面的方法就可以了。
超元数据
与元数据
的概念借鉴自:陈宏《数据结构的选择与算法效率——从IOI98试题PICTURE谈起》,1999.
至于代码……我的代码一直WA,讨论区的测试用例都过了(sad…),程序用的是链表,没用数组,必定TLE了(实际上通过的那些case都已经超时了)……
给出其他人的AC代码:点击这里
离散策略
在上面的题目对离散区间采用按照端点连续划分的,也就是相邻区间以[m,n] [n, k] [k, l]...
这样的区间划分(我自己的代码就是这样的)
还有一种划分方法是按区间编号划分:
那么对于线段[5, 9]
,它所对应的区间编号应为{6, 7, 8, 9}
,除了划分区间不同之外,其他方法一致,上面链接中的AC代码就是按照这种方式进行区间划分的。
结
题目没有AC感觉真是不好……自己功夫不到家,拿不到HiHo的测试数据,代码TLE了还好,WA了真是找不到错在哪里了,唉……