12.1 知识点选讲

12.1.1 自动机

有限自动机。一个DFA(Deterministic Finite Automaton,确定有限状态自动机)可以用一个5元组(Q, Σ, δ, q0, F)表示,其中Q为状态集,Σ为字母表,δ为转移函数,q0为起始状态,F为终态集。

这个DFA代表一个字符串集合。如何判断一个字符串是否属于这个集合(称为“被这个DFA接受”)呢?方法是边读边进行状态转移。一开始时,自动机在起始状态q0,每读入一个字符c后,状态转移到δ(q,c),其中q为当前状态。当整个字符串读完之后,当且仅当q在终态集F中时,DFA接受这个字符串。如图12-1所示,Q={S1, S2}, Σ={0, 1}, q0=S1, F={S1}(用双圈表示),状态转移函数用转移弧来表示(如S1上面标有1的弧表示δ(S1,1)=S1):

不难发现,上面的DFA接受的字符串集合是:0的个数为偶数的01串。

NFA(Nondeterministic Finite Automata,非确定自动机)和DFA差不多,唯一的区别是状态转移函数返回的是一个集合(可能是空集!)而不是一个状态,实际转移到集合中的任何一个状态(所以是“非确定性”)。如图12-2所示,从p出发有两条标记为1的弧,即δ(p,1)={p,q}。

 

  图12-1 DFA示例     图12-2 NFA示例  

不难发现,上面的NFA接受的字符串集合是:以1结尾的01串。NFA有一个变种,即ε-NFA,它和NFA的唯一区别是:可以有标记为ε的转移弧,表示不需要输入任何一个字符就可以完成转移。下面是一个例子,如图12-3所示,接收的字符串集合是:0的个数为偶数或者1的个数为偶数。

图12-3 ε-NFA示例

仔细观察这个自动机会发现:它实际上是两个DFA的并。上面的DFA(起始状态为S1)表示“0的个数为偶数”,下面的DFA(起始状态为S3)表示“1的个数为偶数”。

给定一个ε-NFA,如何判断一个字符串是否被它接受?为方便起见,一般会先把ε-NFA转化为等价的NFA,方法是先求出每个状态的所谓“ε-闭包”,即只允许经过ε-转移弧时可以到达的状态集(例如图12-3中S0的闭包为{S0,S1,S3}),然后把每个状态转移δ(q,c)=S改成δ(q,c)=S',其中S'等于S中所有状态的ε-闭包的并集。这样,就去掉了所有的ε-转移。不过需要注意的是,这个NFA的起始状态有多个,它等于原ε-NFA的起始状态的ε-闭包。例如,对于图12-3,得到的NFA如图12-4所示,其中起始状态集为{S0,S1,S3}。注意,这个NFA包含了3个互不相干的部分。

假定字符串为010,可以用递推的方法求出输入每个字符之后的状态集。

起始状态集:{S0, S1, S3}。

输入字符0之后:{S2, S3}。

输入字符1之后:{S2, S4}。

输入字符0之后:{S1, S4}。

因为状态集中包含终态S1,串010被接受。不难把上述过程推广到一般情况,如果NFA的状态个数为m,字符串长度为n,则判断该串是否被接受的时间复杂度为O(mn)。

图12-4 由图12-3得到的NFA

例题12-1 语言的历史(History of Languages, ACM/ICPC Hangzhou 2008, UVa1671)

输入两个DFA,判断是否等价。第一行为字母表的大小T(2≤T≤26),然后是两个DFA的描述。每个DFA的第一行为状态数nn≤2000),以下n行每行描述一个状态,格式为F, X0, X1,…, XT-1,其中F表示是否为终态(F=1表示是,0表示否)。-1≤Xi<N,表示该状态读入i后转移到的状态,其中-1表示该转移不存在。两个DFA的起始状态均为0。

【分析】

本题的做法不止一种,这里选择一个概念上最简单的做法:把“a和b等价”转化为“a的补和b不相交,且b的补和a不相交”。

如何求DFA的补?也就是把接受的串变成不接受的串,不接受的串变成接受的串。由此可以想到,只需把终态和非终态互换即可。

如何判断两个DFA不相交?可试着找一个同时被两个DFA接受的串,如果找不到,则说明两个DFA不相交。如何找这个串?构造一个新的DFA,它的每个状态都可以写成(q1, q2),其中q1q2分别是两个DFA中的状态,当且仅当q1q2分别是两个DFA的终态时,(q1, q2)是新DFA的终态。这样,问题就转化为了:找一个被新DFA接受的串。这只需要用经典的图遍历(DFS或BFS)即可,时间复杂度为O(n2)。

本题还有一个细节,即对于“该转移不存在”的处理。虽然可以直接处理,但更经典的方法是加一个“所有转移都指向自己”的“孤岛状态”,把所有不存在的转移都改成转移到孤岛。这样一来,所有转移都是存在的,程序比较好写。

例题12-2 不相交的正规表达式(Disjoint Regular Expressions, ACM/ICPC NEERC 2012, UVa1672)

输入两个正规表达式,判断二者是否不相交(即不存在一个串同时满足两个正规表达式)。本题的正规表达式比较简单,只包含以下几种情况。

 

另外,多余的括号可以省略,克莱因闭包的优先级最高,其次是连接,最后是或。例如,abc*|de表示(ab(c*))|(de)。

输入的两个正规表达式P和D均不超过100个字符。如果P和D不是不相交的,应输出一个字符串,同时满足P和D。例如,a(ab)*b和a(a|b)*ab是不相交的,但a(ab)*a和a(a|b)*ba不是不相交的,因为aaba同时满足二者。

【分析】

正规表达式(regular expression,也译为正则表达式)是进行文本处理的有力工具。对它的完整讨论超出了本书的范围,但是本题的解法仍然是支持更复杂的正规表达式语法的基础。

例12-2中用到的是DFA,但是本题似乎很难直接从正规表达式构造DFA,因为DFA有一个很强的限制:每个转移都是确定性的。如果放宽这一限制,是否能构造出NFA甚至ε-NFA呢?

幸运的是,ε-NFA并不难构造(1)。图12-5中分别是单字符的自动机、(A|B)的自动机、(AB)的自动机和(A*)的自动机。

图12-5 单字符、(A|B)、(AB)和(A*)自动机

从上面的自动机可以清楚地看到构造原理,不过状态有点多。

 

现在已经拥有两个ε-NFA了。为了方便起见,先把得到的两个ε-NFA转化为NFA。接下来就可以采用和上一题相同的思路,用BFS寻找一个同时被两个自动机接受的非空串了。注意这个串必须非空,所以要用三元组(q1,q2, b)来描述状态,表示两个自动机分别处于状态q1和q2,b=0表示没有进行过非ε转移,b=1表示进行过。

DAWG。有一种特殊的自动机DAWG(Directed Acyclic Word Graph)(2),简记为Dw,可以接受一个字符串w的所有子串,而且状态只有O(n)个,其中nw的长度。

听上去很神奇吧?理解DAWG的关键是end-set。一个单词的end-set是它在w中出现位置(从1开始编号)的右端点集合。例如,对于w=abcbc,end-setw(bc)=end-setw(c)={3, 5}。在DAWG中,end-set相同的子串属于同一个状态。如图12-6所示是w=abcbc的DAWG的两种画法,其中图12-6(a)中的结点里写着end-set,图12-6(b)的结点里写着子串集合本身。

 

  (a)     (b)  

图12-6 w=abcbc的DAWG

对于任意结点S,从根结点到S的路径与S中的字符串是一一对应的,并且所有路径上的各个字母连接起来就是S中对应的那个字符串。例如,end-set为{4}的结点中有3个串abcb, bcb, cb,从根结点到该结点的3条路径分别为a->b->c->b、b->c->b和c->b。另外,每个状态中都有一个最长串,其他的都是它的后缀,并且长度连续。

任意两个结点的end-set要么不相交(没有公共元素),要么其中一个为另一个的子集,因此可以得到一个树状结构T(w),如图12-7所示。

图12-7(a)中的虚线是DAWG中的边,实线是T(w)的边。这棵树其实是w的逆序串的后缀树,如图12-7(b)所示。T(w)最重要的性质就是:对于任意一个结点S,假设它的最长子串为x,则x的所有后缀就是S及其所有祖先结点中的字符串集合。例如,字符串abc是结点{abc}的最长串,它和它的祖先{bc, c}与{空串}就是abc的后缀集。

 

  (a)     (b)  

图12-7 树状结构T(w)

DAWG可以在线性时间内在线构造,即每次在字符串末尾添加一个字符后,只需O(1)时间就可以更新DAWG。不过对该构造算法的具体讨论超出了本书的范围,强烈建议读者在网上搜索相关资料,学会了DAWG的构造算法以后再看下面的例题。另外需要特别指出的是,end-set中包含元素n的状态对应w的后缀。如果只把那些状态设为接受态,则可以得到一个后缀自动机(suffix automaton,SAM)。一般来说,介绍后缀自动机的文献中讲的“后缀自动机的构造算法”实际上就是DAWG的构造算法。

例题12-3 数字子串的和(str2int, ACM/ICPC Tianjin 2012, UVa1673)

输入nn≤10000)个数字串(即由0~9组成的字符串),把所有数字串的所有连续子串提取出来转化为整数,然后去掉重复整数。例如,两个数字串101和123可以得到8个整数:1, 10, 101, 2, 3, 12, 23, 123。求这些整数之和除以2011的余数。所有数字串的长度之和不超过105

【分析】

DAWG在概念上很适合这道题目:每个状态里的字符串集合就是不同的子串集合。不过要想完整地解决本题,还有两个障碍。第一,本题的数字串有多个,而DAWG是针对单个字符串的;第二,因为数字0的存在,两个不同子串可能对应同一个整数。

第一个问题的解决方案在《训练指南》中已经介绍过了。设输入的数字串为w1, w2,…, wn,把它们拼成一个长串w=w1$w2$…$wn后,构造w的DAWG。第二个问题需要用递推来解决。从根结点开始走,规定不能走$边,且第一次不能走0边。设c(u)和s(u)分别表示到达结点u的方案数(也就是结点u中合法子串对应的整数个数)以及这些整数之和除以2011的余数,就可以递推出结果了,细节留给读者思考。

需要注意的是:因为字符串的总长度比较大,最好先对DAWG的各个状态拓扑排序,再递推,而不要直接进行记忆化搜索,否则可能会栈溢出。

12.1.2 树的经典问题和方法

路径统计。给定一棵n个结点的正权树,定义dist(u,v)为u,v两点间唯一路径的长度(即所有边的权和),再给定一个正数K,统计有多少对结点(a,b)满足dist(a,b)≤K

【分析】

如果直接计算出任意个结点之间的距离,则时间复杂度高达O(n2)。因为一条路径要么经过根结点,要么完全在一棵子树中,所以可以尝试使用分治算法:选取一个点将无根树转为有根树,再递归处理每一棵以根结点的儿子为根的子树,如图12-8所示。

还记得第9章中介绍的“重心”吗?可以证明:如果选重心为根结点,每棵子树的结点个数均不大于n/2,因此递归深度不超过O(logn)。

在确立了递归的算法框架之后,需要统计3类路径。

情况1:完全位于一棵子树内的路径。这一步是分治算法中的“递归”部分。

情况2:其中一个端点是根结点。这一步只需要统计满足d(i)≤K的非根结点i的个数,其中d(i)表示点i到根结点的路径长度。

情况3:经过根结点的路径。这种情况比较复杂,需要继续讨论。

s(i)表示根结点的哪棵子树包含i,那么要统计的就是:满足d(i)+d(j)≤Ks(i)不等于s(j)的(i,j)个数,如图12-9所示。

 

  图12-8 分治算法     图12-9 符合条件的s(i  )

由图12-9可看出,任意两个s值不同的点之间都是一条经过根的路径,可以使用补集转换。

设A为满足d(i)+d(j)≤K的(i, j)个数,B为满足d(i)+d(j)≤Ks(i)=s(j)的(i, j)个数,则答案等于A-B。如何计算A呢?首先把所有d值排序,然后进行一次线性扫描即可。B的计算方法也一样,只不过是对于根的每个子结点分别处理,把s值等于该子结点的所有d值排序,然后线性扫描。根据主定理,算法的总时间复杂度为O(n(logn)2)。

上面介绍的是基于点的分治算法。实际上,还有基于边和链的分治算法,有兴趣的读者可以参考相关资料。

例题12-4 铁人比赛(Ironman Race in Treeland, ACM/ICPC Kuala Lumpur 2008, UVa12161)

给定一棵n个结点的树,每条边包含长度L和费用D(1≤D, L≤1000)两个权值。要求选择一条总费用不超过m的路径,使得路径总长度尽量大。输入保证有解,1≤n≤30000,1≤m≤108

【分析】

沿用前面的分治算法框架,关键问题就是如何计算经过树根的最优路径。首先用DFS求出子树内所有结点到根的路径长度和费用,然后按照DFS序从小到大枚举这些结点。枚举到结点i时,假设它到根的路径的费用为c(i),则需要在i之前的结点(即已经枚举过的结点)中找一个费用不超过D-c(i)的前提下,到根结点距离最大的结点u

注意,对于两个结点uu',如果u到根的路径费用比u'大但路径长度比u'小,则u一定不是最优解的端点,可以删除。这样,i之前的结点可以组织成单调集合:到根的路径长度和路径费用同时递增。如果把这个单调集合保存到BST中,就可以在O(logn)的时间找到“费用不超过给定值的前提下距离最大的结点”。这样,在O(nlogn)时间内求出了“经过树根的最优路径”。根据主定理,总时间复杂度为O(n(logn)2)。

还有一种方法,即求解子树时“顺便”把单调集合也构造出来。如果细节处理得当(需要避开BST),还可以把计算“经过树根的最优路径”的时间复杂度降为O(n),细节留给读者思考。

欧拉序列。对有根树T进行DFS(深度优先遍历),无论是递归还是回溯,每次到达一个结点时都将编号记录下来,可以得到一个长度为2N-1的序列,称为树T的欧拉序列F(类似于欧拉回路)。

如图12-10所示,结点1的深度为0,结点2, 3, 4的深度为1,结点5, 6的深度为2,因此欧拉序列F和深度序列B如表12-1所示。

图12-10 欧拉序列

表12-1 欧拉序列F和深度序列B

为了方便,把结点k在欧拉序列中第一次出现的序号记为pos(k),则图12-10中各个结点的pos值分别为1, 2, 8, 10, 3, 5。欧拉序列中每个结点的第一次出现用灰色背景表示。

有了欧拉序列,LCA问题可以在线性时间内转化为RMQ问题:LCA(T, u, v) = RMQ(B, pos(u), pos(v))。这里的RMQ返回值是下标而不是值本身。

这个等式不难理解:从u走到v的过程中一定会经过LCA(T, u, v),但不会经过LCA(T, u, v)的祖先。因此,从u走到v的过程中,深度最小的那个结点就是LCA(T, u, v)。

用DFS计算欧拉序列的时间复杂度是O(N),且欧拉序列的长度为2N-1 = O(N),所以LCA问题可以在O(N)的时间内转化为等规模的RMQ问题。

树的动态查询问题I。给定一棵带边权的树,要求支持两种操作:修改某条边的权值和询问树中某两点间的距离。

首先把无根树变成有根树,则把一条边u-v(假定uv的父结点)的权值增加d时,以v为根的整个子树的“到根结点的距离”同时增加d。不难发现,一棵子树内的结点对应欧拉序列中的一段连续序列,因此如果用dist[i]表示欧拉序列中第i个结点到根的距离,则修改操作就是dist数组上的“区间增量”,而查询时的距离(u,v)等于dist(u)+dist(v)-2dist(w),其中w=LCA(u,v)。这样,只需用一个支持快速区间增量和单点查询的数据结构(例如Fenwick树或者线段树)来维护dist数组,就可以在O(logn)时间内支持两个操作。

轻重路径剖分。给定一棵有根树,对于每个非叶结点u,设u的子树中结点数最多的子树的树根为v,则标记(u,v)为重边,从u出发往下的其他边均为轻边,如图12-11所示(结点中的数字代表结点的size值,即以该结点为根的子树的结点数)。

根据上面的定义,只需一次DFS就能把一棵有根树分解成若干重路径(重边组成的路径)和若干轻边。有些资料也把重路径称为树链,因此轻重路径剖分也称树链剖分。

路径剖分中最重要的定理如下:若vu的子结点,(u,v)是轻边,则size(v)<size(u)/2,其中size(u)表示以u为根的子树中的结点总数。

证明并不复杂。由定义,所有非叶结点往下都有一条重边。假设size(v)≥size(u)/2,那么对于u向下的重边(u,w)来说,size(w)≥size(v)≥size(u)/2,因此size(u)≥1+size(v)+size(w)≥1+size(u),与假设矛盾。

由此可以得到如下的重要结论:对于任意非根结点u,在u到根的路径上,轻边和重路径的条数均不超过log2n,因为每碰到一条轻边,size值就会减半。

树的动态查询问题II。给定一棵带边权的树,要求支持两种操作:修改某条边的权值和询问树中某两点的唯一路径上最大边权。

首先把无根树变成有根树并且求出路径剖分。如图12-12所示,任意结点u到其祖先x的简单路径中包含一些轻边和重路径,但这些重路径可能并不是原树中的完整重路径,而只是一些“片段”,因此可以在轻边中直接保存边权,而用线段树维护重路径。

这样,两个操作都不难实现。

修改:轻边直接修改,重边需要在重路径对应的线段树中修改。

查询:设LCA(u,v)=p,则只需求出u到其祖先p之间的最大边权maxw(u,p),再用类似的方法求出maxw(v,p),则答案为max{maxw(u,p), maxw(v,p)}。为了求出maxw(u,p),依次访问up之间的每条重路径和轻边即可。根据刚才的结论,轻边和重路径的条数均不超过log2n。这样,修改的时间复杂度为O(logn),查询的时间复杂度为O(log2n)。虽然存在时间复杂度更低的方法(3),但上述方法已经很实用了。

 

  图12-11 轻重路径剖分     图12-12 树的动态查询  

Link-Cut树。值得一提的是,轻重路径剖分有一个“动态版本”——Sleator和Tarjan的Link-Cut树(4)。该数据结构解决的是所谓的动态树(Dynamic Tree)问题,即维护一个有根树组成的森林。支持以下4个操作。

 

其中CUT和JOIN是两个最经典的操作,利用它们可以灵活地改变树的结构。“重路径”在Link-Cut树中称为Preferred Path。每条Preferred Path用一棵辅助树表示(通常是伸展树(5)),而不同的辅助树之间通过父结点指针连在一起。

图12-13展示了Link-Cut树最重要的操作:Access操作。Access(u)的作用是把从根结点到u的路径变成重路径。为此,可能需要把一些其他的重边变成轻边以维持“每个非叶结点往下最多有一条重边”这一性质。图12-13(a)执行Access(N)之后得到图12-13(b),其中重边A-B, H-J, I-K都变成了轻边。另外,根结点和执行Access操作的结点必须是重路径的两个端点,所以N-O也必须变成轻边。

 

  (a)     (b)  

图12-13 Link-Cut树中Access操作

如果把每条Preferred Path用一个序列表示(实际上用伸展树储存),则上面两棵树如图12-14所示。

 

图12-14 将Preferred Path用序列表示

对于Link-Cut树的完整讨论超出了本书的范围,建议读者熟练掌握它(包括时间复杂度和程序实现),之后再阅读下面的例题。

例题12-5 快乐涂色(Happy Painting, UVa11994)

n个结点组成了若干棵有根树,树中的每条边都有一个特定的颜色。你的任务是执行m条操作,输出结果。操作一共有3种,如表12-2所示。

表12-2 3种操作

 

  操作     含义  
  1 x y c     把x的父结点改成y。如果x=y或者x是y的祖先,则忽略这条指令,否则删除x和它原先父结点之间的边,而新边的颜色为c  
  2 x y c     把x和y的简单路径上的所有边涂成颜色c。如果x和y之间没有路径,则忽略此指令  
  3 x y     统计x和y的简单路径上的边数,以及这些边一共有多少种颜色  

每组数据第一行为nm(1≤n≤50000,1≤m≤200000),然后是每个结点的父结点编号和该结点与父结点之间的边的颜色(对于根结点,父结点编号为0,且“与父结点之间的边的颜色”无意义)。接下来是m条指令。对于所有指令,1≤x,yn;对于类型2指令,1≤c≤30。结点编号为1~n,颜色编号为1~30。

对于每个类型3指令,输出对应的结果。

【分析】

这是一个标准的动态树问题,不过多了一个“统计颜色数”操作。注意到颜色只有30种,可以用一个32位整数表示一个颜色集合。由于辅助树用伸展树保存,可以在伸展树的每个结点中加一个信息c,即以该结点为根的子树所对应的重路径“片段”所拥有的颜色集,则操作2和3都对应于经典的伸展树的修改和查询操作。

例题12-6 闪电的能量(Lightning Energy Report, ACM/ICPC Jakarta 2010, UVa1674)

nn≤50000)座房子形成树状结构,还有QQ≤10000)道闪电。每次闪电会打到两个房子a, b,你需要把二者路径上的所有点(包括a,b)的闪电值加上cc≤100)。最后输出每个房子的总闪电值。

【分析】

出题者的标准解法是利用路径剖分:每次最多更新2logn条重路径,而每条重路径上的区间更新需要O(logn)时间。

   

图12-15 mark修改操作后结果

这样做也没有错,但是有点小题大做。其实,对于询问(a, b, c),可以首先算出d = LCA(a, b),然后执行mark[a]+=c, mark[b]+=c, mark[d]-=c。如果d不是树根,还要让d的父结点p的mark值减c。原理是这样的:mark[u]=w的意思是u到根的路径上每个点的权都要加上w,即结点i的闪电值等于根为i的子树的总mark值。如图12-15所示,经过上述mark修改操作之后,只有a到b路径上所有点的“子树总mark值”增加了c,其他结点保持不变。

最后用一次DFS,即可求出以每个结点为根的子树的总mark值。

12.1.3 可持久化数据结构

《训练指南》中介绍了一些基本的数据结构,例如BIT、线段树等,也介绍了一些高级数据结构技巧,例如嵌套数据结构和分块数据结构。但有一个重要的话题并来涉及,那就是可持久化数据结构(persistent data structures)。

之前学过的很多数据结构都是可变的,所有修改操作都直接改变了数据结构本身。修改之后,就无法得到修改之前的数据结构了。有时,需要在修改数据结构之后得到的是该数据结构的一个新版本,同时保留修改前的“老版本”。该如何实现呢?

基本思路是:不许修改结点内的值;必要时创建或者复制结点;尽量复用存储空间。

如图12-16所示,我们希望在一个链表的第3个结点后面新加一个白色结点,只需要复制前3个结点即可。

图12-16 在链表结点中加入结点

虽然整个结构看上去比较奇怪,但是从两个链表各自的表头指针开始访问,沿途访问到的就是该链表自身的结点。

当然,这个例子并不是那么吸引人,因为平均情况下要复制一半的结点,不过这个方法可以用来实现一个可持久化的栈——在链表的头部进行入栈和出栈,不仅时间是O(1)的,附加空间也是O(1)的。

如果是一棵满的排序二叉树,没有插入和删除,只有修改,则不需要旋转操作,因此很容易用上述方法改造成可持久化的排序二叉树。修改单个结点时,只需把从根结点到修改结点的所有结点(只有O(logn)个)复制一份并设置好链接关系,其他结点保持不变即可,如图12-17所示。把a作为根访问到的就是老树,把b作为根访问到的就是新树。

图12-17 将满的排序二叉树改造成可持久化的排序二叉树

顺便一提:已经有一些编程语言中“自带”了可持久化数据结构,例如Scala、Erlang和Clojure,有兴趣的读者可以参考这些语言的入门书籍,会对可持久化数据结构有一个更加清晰具体的认识。

例题12-7 自带版本控制功能的IDE(Version Controlled IDE, ACM/ICPC Hatyai 2012, UVa12538)

编写一个支持查询历史记录的编辑器,支持以下3种操作。

 

缓冲区一开始是空串,是版本0,每次执行操作1或2之后版本号加1。每个查询回答之后才能读到下一个查询。操作数n≤50000,插入串总长不超过1MB,输出总长保证不超过200KB。

【分析】

本题要实现的数据结构就是一个典型的可持久化数据结构。在《训练指南》中曾经见过一道类似的例题,但是只需非持久化版本的题目:《排列变换》。在那道题目中,用到了伸展树的split和merge操作,本题可以如法炮制。

split操作。假定要把序列子树S分裂成LR两部分,其中左边有left_size个结点。如果left_size小于S左子树的结点个数,则可以先递归调用split操作把S的左子树分裂为LR',其中L的结点个数为left_size,然后创建一个值和S一样的新结点R,左右子树分别为R'S的右子树。不难发现,LR合起来正好是S的所有元素,并且L里有left_size个元素。left_size比较大时也可以类似处理,如图12-18所示。

图12-18 split操作

merge操作。假定要把两个序列a和b合并成一个序列S。和split类似,也有两种方法合并,但两种方法都可以用,并不是上面的“二选一”。例如,图12-19(a)就是先递归调用merge操作把a的右子树和b合并成R,然后创建一个新结点S,而图12-19(b)则是相反。不管选哪种merge方式都有可能合并成一棵形态不好的树,所以随机合并。

 

  (a)     (b)  

图12-19 merge操作

实际上,这就是一个可持久化treap的split和merge操作。对上述方法的理论分析超出了本书的范围,但可以告诉大家的是,它的实际效果非常好,并且程序易于实现,是可持久化数据结构的经典例子。

值得指出的是,如果可以使用STL扩展,那么用rope实现本题也是一个不错的选择。有兴趣的读者可以阅读维基百科(6)

12.1.4 多边形的布尔运算

布尔运算是指把多边形看成一个点集,然后执行集合的布尔运算。最常见的布尔运算是交和并。虽然概念简单,但实际上多边形的布尔运算不是那么容易实现的。如果要高效实现,更是难上加难。

例题12-8 多边形相交(Polygon Intersections, ACM/ICPC World Finals 1998, UVa805)

输入两个简单多边形,求二者相交的区域(如图12-20的深色区域所示)。如果有多个区域,应分别输出。共线的相邻边应合并(细节请参考原题)。

图12-20 多边形相交

【分析】

为了叙述方便,设输入的多边形为A(用细线表示)和B(用粗线表示),答案为C(图中未画出),如图12-21所示。输入的是简单多边形,所以C是不会出现洞的,但是可能会不连通。算法大概是这样的:首先对于每条线段求出它和其他线段的交点,然后在交点处把线段打散(即切割成若干条线段)。不难发现,打散后的每条小线段要么完全在C的边界上,要么不在。如何判断呢?只判断端点是不行的,例如在图12-21(a)中,细线正方形的上边和左边都有一个端点在C的边界上,但是这两条边本身却不在C的边界上。正确的做法是判断每条小线段的中点。如果中点同时在A和B的内部或者边界上,则这条小线段是C的边界。

图12-21(b)和图12-21(c)也有些难以处理。在图12-21(b)中,A和B有一条公共线段,但是并没有在C中出现;图12-21(c)中A和B也有一条公共线段(注意A的右边界已被打断成3条线段),但它却在C里出现了。解决这个不一致的方法有多种,这里只介绍笔者认为相对常见和容易编写的一种:把多边形的边按照逆时针顺序定向,然后去掉重复的有向线段,如图12-22所示。

 

              
   (a)       (b)       (c)   

图12-21 多边形相交问题分析

经过上述处理之后,得到了若干有向线段。只要把它们拼起来,然后把退化的多边形(折线)删除,只保留多边形区域,就得到了最终的答案。例如,图12-22(b)拼起来以后得到了一个只有两个点的“多边形”,输入退化情况,应删除。

 

  (a)     (b)  

图12-22 解决不一致问题的方法

例题12-9 王国的重新合并(Kingdom Reunion, ACM/ICPC NEERC 2012, UVa1675)

输入3个国家Aastria、Abstria和Aabstria的边界,判断Aastria、Abstria是否可以恰好不重叠地合并成Aabstria。输入可能有误,即3个边界都可能不是多边形。输出有6种情况。

情况1:如果Aastria的边界不是合法多边形,输出Aastria is not a polygon。

情况2:如果Abstria的边界不是合法多边形,输出Abstria is not a polygon。

情况3:如果Aabstria的边界不是合法多边形,输出Aabstria is not a polygon。

情况4:如果Aastria和Abstria相交,输出Aastria and Abstria intersect。

情况5:如果Aastria和Abstria的合并不是Aabstria,输出The union of Aastria and Abstria is not equal to Aabstria。

情况6:输出OK。

图12-23中4幅图分别对应情况6、情况1、情况4、情况5。输入中每个边界上的点数都不超过10000。

图12-23 情况6、情况1、情况4和情况5

【分析】

本题的数据范围很大,但在优化之前要先思考一下:不考虑时间复杂度的情况下如何求并。一般情况下,两个多边形A和B的“并”可能是一个“有洞多边形”,如图12-24所示。不过本题只需要判断A和B的并是否等于C,所以可以不考虑这种情况。

不难发现,此处仍然可以使用刚才介绍的方法:把每条边定向,打断线段并判重,然后逐一判断。这个方法是正确的,可惜对于本题来说速度太慢,就连“判断多边形相交”和“打断线段”这一步都不能用On2)的朴素算法进行判断,更别说判断每条(打断后的)线段是否在两个多边形内了。

解决方法是《算法竞赛入门经典——训练指南》中的扫描法。具体写法有很多种,这里只介绍一种相对不容易写错的方法,分为3个阶段。为了叙述方便,设Aastria和Abstria的轮廓为A和B,Aabstria的轮廓为C。

阶段1:用扫描法判断A、B、C是否为合法多边形。这一步看似简单,其实有陷阱。在扫描法中,新增或者删除线段时会判断相邻线段是否相交。这个“相交”一般会理解成“只要有公共点就算相交”,而不一定是规范相交。但是在本阶段中,如果这样写就错了(因为这两条线段可能恰好是同一个顶点出发的两条边)。另一方面,也不能把这里的“相交”理解成“规范相交”,因为图12-25中所示就不是规范相交,但它也不是一个合法多边形,应当被检测出来。阶段1的另一个作用是用所有顶点去打断每条边,具体细节留给读者思考。

 

  图12-24 两个多边形的并     图12-25 非规范相交  

阶段2:判断A和B是否相交。首先要排除内含的情况,然后对于每个点,判断从它出发的所有边是否导致多边形相交。如图12-26所示,图12-26(a)的两个多边形没有相交,但是图12-26(b)的多边形相交了。本阶段还需要计算出每条边的“反向边”(u->v和v->u互为反向边)。

 

  (a)     (b)  

图12-26 判断A和B是否相交

接下来就可以忽略同一个顶点出发的边了。再扫描一次,和阶段1一样判断线段相交。但是这次不需要打断线段,而且每到一个事件点时要把与它关联的所有相邻边一次性加到扫描线上,就不会认为这些边相交了。

阶段3:判断A和B是否覆盖了C。以A为例,首先枚举A的每条边u->v,看看C是否也有一条从u出发的边。如果C中没有从u出发的边,则B中必须有边v->u,这样才能和A中的u->v相互“抵消”,让C的边界中不必出现这条边。类似地,如果C有一条完全相同的边u->v,则B中不能有边v->u。因为之前已经算过了反向边,所以对于每个顶点u,只需常数时间内就可以完成上述判断。

例题12-10 清洁机器人(The Cleaning Robot, Rujia Liu's Present 4, UVa12314)

有一个半径为r的圆形清洁机器人和一个nn≤100)边形障碍。需要把机器人放到某个地方,使得它无法移动到无穷远处,要求能清洁到的区域面积尽量大。如图12-27所示,图12-27(a)的阴影部分就是能清洁到的区域,而图12-27(b)中有两个选择,其中右边那个区域更大。

 

  (a)     (b)  

图12-27 “清洁机器人”问题示意图

【分析】

首先看看机器人的圆心可能在哪些位置。根据题意,圆心不可能在多边形内部,到多边形的距离也不能小于r,所以可以设计一个“膨胀”操作(7),计算出圆心禁止出现的区域,它实际上等于若干个矩形、若干个圆以及原多边形的并,如图12-28所示。

图12-28 圆心禁止出现的区域

图12-28看上去很规则:每条边外扩,然后用每个顶点处的圆弧连接。但有时有些边会消失,还是只能使用多边形并的算法,如图12-29所示。

 

图12-29 多边形并的算法

现在假定已经写好了膨胀操作,主算法可以这样设计:首先让输入多边形往外“膨胀”,得到一个带洞多边形(如果没有洞则无解),则每个洞都是一个机器人圆心可以出现的区域。需要注意的是,这个“洞”可能退化成线段甚至是点(如题目中左图的例子)。为了避免出问题,最好是把膨胀的偏移值缩小一点。

然后计算每个区域的可清洁面积,方法是再次“膨胀”,然后计算面积。注意,两次膨胀得到的“多边形”都可能是带圆弧的,需要把直线段和圆弧都打断。

本题的算法虽然概念简单,但是实现起来还是颇有难度的,建议读者编程实践。