如果我们用邻接矩阵来存储图,那么绝大多数图算法的运行时间都是Ω(|V|2)(V为一个图的顶点集),但还是有些例外。比如,给定一个有向图G的邻接矩阵A,我们可以在Ο(|V|)时间内判断图G是否包含一个通用汇点,即一个入度为|V|-1出度为0的顶点。请给出这样的算法。
(注:这一段是我从教师手册翻译过来的,和我的思路一样,不过表述和逻辑更清晰。)如果A[i, j] = 1,即(i, j)∈E(1≤i≤|V|,1≤j≤|V|,E是G的边集),那么顶点i就不可能是通用汇点,因为它有一条出边。因此,如果第i行有一个元素为1,那么顶点i就不可能是通用汇点。这也意味着如果i有一条自循环边,那么它就不可能是通用汇点。现在假设A[i, j] = 0,即(i, j)∉E,且i≠j。在这种情况下,顶点j就不可能是通用汇点,因为要么它的入度严格小于|V|-1,要么它包含一个自循环边。因此,如果第j列有一个非对角线上的元素为0,那么顶点j就不可能是通用汇点。
因此,这个问题等价于:给定一个有向图G的|V|×|V|邻接矩阵A,在O(|V|)时间内判断是否存在一个整数j(1≤j≤|V|),使得对于所有的i(1≤i≤|V|且i≠j)都有A[i, j] = 1,对于所有的k(1≤k≤|V|)都有A[j, k] = 0。更形象地说,就是判断A里面是否有这样一个“十”字:这“十”字的横全是0,竖全是1(除了“十”字的中心)。
(注:下面用aij表示A[i, j],用左箭头←表示赋值操作。)
为了证明该算法的正确性,我们可以使用以下三个循环不变量(loop invariant):
在第2至8行的while循环每次开始迭代时:
i≤j; A[i, i..j-1]中的所有元素都是0; 在子矩阵A[1..i-1, 1..i-1]的每一列中,要么对角线上的元素为1,要么对角线以上的某个元素为0。以下是对这三个循环不变量的论证:
初始化(第一次while迭代):i = j = 1,显然这三个循环不变量都成立。
维持(Maintainance):假设while循环的本次迭代开始时,三个循环不变量都成立。如果A[i, j] = 0,那么i的值会保持不变,而j的值则会加1,因此i≤j仍然会成立;而j的增大会使A[i, j] = 0被添加到向量A[i, i..j-1]中,因此第二个不变量在下一次迭代开始时仍然会成立;子矩阵A[1..i-1, 1..i-1]不会变动,因此第三个不变量显然会继续成立。如果A[i, j] = 1且i = j,那么i和j都会加1,因此下一次迭代开始时仍然有i = j(第一个循环不变量会继续成立),于是i > j - 1,第二个循环不变量显然成立;子矩阵A[1..i-1, 1..i-1]的底部会添加一行,右侧会添加一列,而新添加的列的最底部的元素(即对角线上的元素)为1,因此第三个循环不变量仍会成立。如果A[i, j] = 1且i < j,我们就会将i的值设为和j相等,于是和之前类似,第一和第二个循环不变量仍会成立;子矩阵A[1..i-1, 1..i-1]的底部会添加j-i行,右侧会添加j-i列,而新添加的每一列的第i行都为0(因为这一段开始的假设),因为i < j,即第i行是在对角线上的,所以第三个循环不变量在下次迭代开始时仍会成立。
终止:当while循环终止时,根据初始化和维持两段的论证,这三个循环不变量仍然是成立的。而此时我们有j = |V|+1。如果i = j,那么根据第三个循环不变量,在邻接矩阵A的每一列中,要么对角线上的元素为1,要么对角线上方的某个元素为0,因此并不存在这样的整数j(1≤j≤|V|),使得对于所有的i(1≤i≤|V|且i≠j)都有A[i, j] = 1,对于所有的k(1≤k≤|V|)都有A[j, k] = 0;即不存在这种“十”字。如果i < j,那么类似地,根据第三个循环不变量,1..i-1肯定不是这样的整数;而根据第二个循环不变量,i+1..|V|也不可能是这样的整数。因此i是唯一可能满足要求的整数。算法中第8行之后的语句就是检查i是否满足要求。
在while循环的每次迭代中,我们都会增大i或j的值,或对两者的值都增大。循环刚开始时有i = j = 1,而结束时有j = |V|,因此while循环最多迭代2|V|-1次(算上每次对j≤|V|的判断)。可以把这个过程看作是将一个光标从A的左上角移到右下角。每次迭代时,光标都会离右下角更近。算法第8行之后的两个for循环最多迭代|V| + 1次。因此HAS-UNIVERSAL-SINK的运行时间是Θ(|V|),当然也等于O(|V|)。
(注:教师手册的解法和我的基本一样,只不过在细节上有略微差别。在我的算法中,“光标”始终在A的对角线和上三角区域中活动,而教师手册的算法则没有这个特点。另外教师手册的阐述貌似更通俗易懂一些。我的论述可能太数学化了。)
首先实现一个子过程IS-SINK。该过程判断一个给定的顶点k是不是通用汇点,是则返回TRUE,否则返回FALSE:
因为这个子过程的运行时间是O(|V|),所以如果要在O(|V|)时间内判断图G是否包含一个通用汇点,我们就只能对该子过程调用O(1)次。
同时我们可以注意到,一个有向图最多只能包含一个通用汇点。这是因为,如果某个顶点j是通用汇点,那么对所有的i ≠ j都有(i, j) ∈ E,这样其它顶点i都不可能是通用汇点了。
以下算法以邻接矩阵A作为输入,如果A含有通用汇点,该算法会将通用汇点的标识输出,否则会输出“没有通用汇点”的消息。
UNIVERSAL-SINK从邻接矩阵的左上角开始,根据当前元素A[i, j]是0还是1,将“光标”向右或向下移动一个位置。当i或j大于|V|时则停止迭代。
和我的解法类似,为了证明UNIVERSAL-SINK的正确性,我们需要证明:当while循环终止时,i是唯一有可能是通用汇点的顶点。随后对IS-SINK的调用就是检查i是否符合通用汇点的定义。
我们先把while循环终止时i和j的值固定下来。所有符合1 ≤ k < i的顶点k都不可能是通用顶点。这是因为在while循环中,i的值被增大的唯一原因就是当前元素为1,于是在while循环终止时,第i行上方的每一行都包含至少一个1。而根据我们一开始阐述的思路,若第k行有至少一个元素为1,那么k就不可能是通用汇点。
如果循环终止时i > |V|,那我们就将所有顶点都排除了,所以G里面不可能有通用汇点。而如果循环终止时i ≤ |V|,我们就需要证明序号大于i而小于等于|V|的顶点都不可能是通用汇点。如果循环终止时i ≤ |V|,那就肯定有j > |V|。这就意味着我们在每一列都找到了0。回顾之前的思路,如果第k列在非对角线位置上有一个0,那么顶点k就不可能是通用汇点。我们在所有的第k(i < k ≤ |V|)列中都发现了0;而我们在while循环中还从没检查过第i行以下的任何一行,也就是说,我们还没检查过这些第k列对角线上的元素,因此这些第k列所包含的0都是在非对角线的位置上。因此所有满足i < k ≤ |V|的顶点k都不是通用汇点。
因此,所有小于i的顶点和所有大于i的顶点都不是通用汇点。唯一可能是通用汇点的就是i。while循环之后对IS-SINK的调用就是检查i是否为通用汇点。
和我的算法同理,while循环每次迭代时i或者j都会增加(不过在这里要么是i增加,要么是j增加,且增量始终为1,因此在某些情况下,速度上估计比我的算法稍微慢一丁点儿)。因此,while循环最多只迭代2|V| - 1次,每次迭代的时间都是O(1),因此while的总时间为O(|V|)。再加上时间复杂度同样为O(|V|)的IS-SINK,UNIVERSAL-SINK的时间复杂度就是O(|V|)。