这篇文章上次修改于 279 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
算法学习笔记(13):树上启发式合并
启发式算法是根据经验和直观感觉,对算法的优化,也就是指优化容易理解。
树上启发式合并看似高级,实际上是针对树上不带修改的离线子树问题的解决。
离线指的就是树一旦给出,便不会修改。
例题:树上数颜色
给一棵根为1的树以及每个节点的颜色,每次询问子树颜色种类数。
DFS暴力解决
当然,对于一个树的每个子树或者是节点的所有信息进行保存,可以直接为每个节点保存它的信息,dfs可以很顺利的保存所有的信息并往上传递。显然DFS也可以解决问题,但这句话是不考虑成本的。
通常,子树信息非常庞大,关联性也很复杂,对于两个子树之间的信息合并,也要花费很大的运算成本。
对于树上数颜色问题,内存实际上就有很大的使用,我们需要为每个节点创建一个cnt数组,记录每个颜色的出现次数。节点一旦多,颜色种类一旦很多,那么内存就完全不够使用了。
树上合并
我们储存了每个节点的数据,但实际上并用不到这么多。例如我们先获取所有的查询,那么我们只需要保存对于查询的每个答案。此时,许多数据就不会再使用了,因为题目是离线的,得到答案之后就不需要了。
此时我们就可以考虑只使用一份数据,这份数据随着我们的遍历不断修改,到了对应的节点就会是对应的答案。如果这个点就是我们要查询的点,直接把答案保存,然后继续修改这唯一的一份数据。
那我们现在考虑如何遍历的问题,先考虑对于一个二叉树,它有左子树和右子树,当我从下往上统计完左子树的数据之后,我要是再想统计右子树的数据,那么我的这一份数据就需要清空,因为它们是相互独立的。
清空之后,遍历完右子树,我们就需要把数据传给父节点,此时我们没有左子树的数据(之前被清空了),就需要再去把左子树的数据再统计一遍,这个过程中,一直是右子树的数据加上左子树的数据。走完这个过程,再把这个数据给父节点。
现在如果是三叉树ABC,我们就要走ABCAB,除了最后一个子树,前面的子树我都要再统计一遍。
启发式树上合并
四叉树N叉树?都是一个子树只走一遍,其它所有子树都走两遍。那我们这里就可以优化一下子了。我们选择把最大的子树只走一遍,其它都走两遍,也就是先走所有轻子节点,最后走重,再回头把所有轻节点都统计一遍。
非常容易理解的优化:只能少走一颗子树,选择少走一遍重子树(节点最多的子树)。
code:
struct Detail{
// 放入需要统计的数据
};
class DSUonTree{
public:
DSUonTree(int nodeNum,vector<int> * adj_): size(nodeNum + 1), heavySon(nodeNum + 1), treeLeftOrd(nodeNum + 1),
treeRightOrd(nodeNum + 1), ord2Node(nodeNum + 1), adj(adj_){
initDfs(1, 0);
dfs1(1, 0, false);
}
void initDfs(int cur, int parent){
treeLeftOrd[cur] = ++curDfsOrder;
ord2Node[curDfsOrder] = cur;
size[cur] = 1;
for (int son : adj[cur]){
if (son == parent) continue;
initDfs(son, cur);
size[cur] += size[son];
if (heavySon[cur] == 0 or size[heavySon[cur]] < size[son])
heavySon[cur] = son;
}
treeRightOrd[cur] = curDfsOrder;
}
void dfs1(int cur, int parent, bool keep)
{
// 计算轻儿子的答案
for (int son : adj[cur]){
if (son == parent or son == heavySon[cur]) continue;
dfs1(son, cur, false);
}
// 计算重儿子答案并保留计算过程中的数据(用于继承)
if (heavySon[cur])
dfs1(heavySon[cur], cur, true);
for (int son : adj[cur]){
if(son == parent or son == heavySon[cur]) continue;
for (int ord = treeLeftOrd[son]; ord <= treeRightOrd[son]; ord++)
addNodeDetail(ord2Node[ord]);
}
addNodeDetail(cur);
collectAns(cur);
if (not keep)
{
for (int ord = treeLeftOrd[cur]; ord <= treeRightOrd[cur]; ord++)
{
delNodeDetail(ord2Node[ord]);
}
}
}
void addNodeDetail(int node){
// 数据结合自行完成
}
void delNodeDetail(int node){
// 数据移除自行完成
}
void collectAns(int node){
// 答案收集自行完成
}
private:
vector<int> size, heavySon, treeLeftOrd, treeRightOrd, ord2Node;
Detail data;
vector<int> * adj;
int curDfsOrder = 0;
};
这是一个启发式合并的运算思想代码体现,借助这个模板,需要自行完成数据结合数据移除,答案收集等方面。
模板归模板,重于体现思维。作为另一个例子,体现一种DSUonTree的思想变式
例题:XOR Tree
你有一棵无根树,点数为 𝑛n,每个点有个点权 𝑎𝑢,定义一条路径 𝑃(𝑢,𝑣) 的权值为经过的所有点的点权的异或和。定义一棵树是合法的,当且仅当树上所有简单路径(只经过每个点一次的路径)的的权值都不为 0。
你可以对权值进行修改,可以改成任意正整数,问最少修改多少次才能让这棵树合法。
输出最小修改次数。
强调启发式合并只是一种运算时的工具,题目难点和突破口仍然需要自己去解决,此题应当把异或和作为关键突破口。设xorVal[node] 为从根到 node 的异或和,至于简单路径的异或和,也就是两个点AB作为两段,权值为 xorVal[A] ^ xorVal[B] ^ value[LCA] = 0。
如果对于一颗子树来说,经过子树根节点的所有路径都会与它们俩的LCA也就是根节点有关。所以只要修改根节点的值就会让所有路过这个点的路径权值都不为0.
结合启发式合并
启发式合并的重点在于少走一次最多点的子树。
本题只要考虑不同的点作为这个子树的根,都没有权值为0的路径即可,一旦有0的路径,就去改这个根。
那么作为从下往上的记录,我们就需要记录它们所有子树上的节点的xorVal,当我走到点x,只要记录的点中满足xorVal[A] ^ xorVal[B] ^ value[LCA] = 0, 就要修改一次,此时,记录的所有值都可以被清空。用set记录,只需要看 xorVal[p] ^ value[LCA] 是否在 set中即可。
不同点
作为启发式合并的使用,应当关注到这题的不同之处。
我们首先走完所有轻子节点,运算结束之后,再走重子节点,回头我们会再来看一次轻节点。
但是如果在第一次轻子节点中,我们就做了一次值修改,那么从那个被修改的点往下,就都不需要被考虑了,所以在第二遍中,并不用加入所有的点。
应当使用标记,一旦走到标记,就不再往下计算了。而我们这里的启发式合并,是根据记录的元素量来判断哪一个来少走一次。