hiho一下第63周《Hamiltonian Cycle》题目分析

15
1

题意分析

给定一个有向图,求图中哈密顿回路的数量。

算法分析

哈密顿回路,具体到本题中即从某一个点开始经过所有的点一次后再回到该点的不同路径数。对于这个不同需要注意两点:

  1. 如果我们将路径经过的点按顺序写下,比如当n=3时,若存在123和231。此时,我们认为这两条路径是同一条哈密顿回路。而123和213则是不同的哈密顿回路。

  2. 若两个点之间有多条边,经过不同的边的路径仍然看作同一条哈密顿回路。不同哈密顿回路只和经过的点有关。因此对于多条边的情况我们可以将其合并为一条边来考虑。

对于哈密顿回路,一个简单的想法就是枚举所有可能的路径,判定这个路径是否存在。即时间复杂度为O(n!)。而题目给定的数据范围为:n <= 12,所以最大可能的枚举次数为12! = 479,001,600。

极限的数据不到5亿,所以我们可以考虑使用暴力来枚举所有的哈密顿回路。直接采用DFS枚举我们的每一步,最后判定是否走回到起点。

伪代码如下:

DFS(int nowVertex, bool visitedVertex, int path, int length)
    visitedVertex[ nowVertex ] = True;
    If (all Vertex is visited) Then
        Count = Count + 1
    Else 
        For (u is next vertex of nowVertex)
            If (not visitedVertex[ u ]) Then
                path[ length ] = u
                DFS(u, visitedVertex, path, length + 1)
            End If
        End For    
    End If
    visitedVertex[ nowVertex ] = False

由于哈密顿回路始终会经过每一个点,所以我们只以1为起点就一定可以找出所有的哈密顿回路。

那么这样是否能够解决这道题目呢?我只能说不一定能够解决。

虽然伪代码相同,但是根据实现的方法会有不同的运行效率,在某些实现方法下就能够通过所有的数据点,在某些实现方法下就会超过时限。

这里我们介绍一种利用位运算的实现,能够使得整个程序的效率提高很多倍。

首先来看看代码:

const int MAXN = 14;

int edge[ MAXN ];
int p[1 << MAXN];
int cnt;

void dfs(int nowVertex, int unused) {
    if (!unused) {
        cnt += (edge[nowVertex] & 1);
        return ;
    }
    int rest = unused & edge[ nowVertex ];
    while (rest) {
        int tp = rest & (-rest);
        dfs(p[ tp ], unused - tp);
        rest -= tp;
    }
    return ;
}

int main()
{
    int n, m;
    scanf("%d %d", &n, &m);
    for (int i = 0; i < n; ++i)
        p[ 1 << i ] = i + 1;
    while (m--) {
        int u, v;
        scanf("%d %d", &u, &v);
        edge[u] |= 1 << (v - 1);
    }
    dfs(1, (1 << n) - 2);
    printf("%d\n", cnt);
    return 0;
}

我们一个一个来解释每一个变量的含义:

edge[i]表示点i的next节点情况,我们用二进制表示edge[i],比如当edge[i]=01011时:

+---+---+---+---+---+
| 5 | 4 | 3 | 2 | 1 | 右起第j位
+---+---+---+---+---+
| 0 | 1 | 0 | 1 | 1 | 二进制位的值
+---+---+---+---+---+

从右起第j位,若为1,则表示存在i到j的边;若为0,则表示不存在i到j的边。所以edge[i]=01011就表示节点i可以到达节点1,2,4。

p[i]是为了方便查找点的编号。在edge[i]中若存在01000,我们可以根据01000=8, 而p[8]=4来快速的通过二进制位来定位节点编号。

所以有初始化:

for (int i = 0; i < n; ++i)
    p[ 1 << i ] = i + 1;

而通过节点编号来找到二进制位,则可以直接使用1 << (i - 1)实现。

我们在读入数据时,通过位运算边可以记录下所有点之间的连边情况:

while (m--) {
    int u, v;
    scanf("%d %d", &u, &v);
    edge[u] |= 1 << (v - 1);    // 记录u有后继节点v
}

unused该二进制数表示我们尚未访问的节点集合,同样的右起第i位表示节点i,比如unused = 01100

+---+---+---+---+---+
| 5 | 4 | 3 | 2 | 1 | 右起第i位
+---+---+---+---+---+
| 0 | 1 | 1 | 0 | 0 | 二进制位的值
+---+---+---+---+---+

表示我们现在深度优先搜索已经经过了节点1,2,5,而节点3,4还尚未经过。

由于我们是以节点1作为起点,初始化的unused也就要去掉最右边的1,所以代码为dfs(1, (1 << n) - 2)

接下来我们逐行解释dfs函数:

if (!unused) {
    cnt += (edge[nowVertex] & 1);
    return ;
}

当我们所有的节点都经过一次之后,unused中一定不存在1,因此有!unused = true。但是此时并不一定找到了哈密顿回路,我们还需要判断当前节点是否能回到起点,也就是节点1。若nowVertex可以到达节点1,则edge[nowVertex]最右边1位一定是1,那么就一定有edge[nowVertex] & 1 = 1

int rest = unused & edge[ nowVertex ];

rest表示当前节点还可以走到的未访问节点。由于&运算的性质,只有当unusededge[ nowVertex ]对应二进制位同时为1时,rest对应的二进制位才会为1。其含义就是该节点尚未访问,且节点nowVertex可以到达该节点。

while (rest) {
    int tp = rest & (-rest);
    dfs(p[ tp ], unused - tp);
    rest -= tp;
}

该循环的作用是枚举每一个可以到达的点。

int tp = rest & (-rest);

这里利用了一个性质,即p & -p等于取出p的最右边的一个1。举个例子p=10110:

   +---+---+---+---+---+
p  | 1 | 0 | 1 | 1 | 0 | 
   +---+---+---+---+---+
-p | 0 | 1 | 0 | 1 | 0 | 
   +---+---+---+---+---+
&  | 0 | 0 | 0 | 1 | 0 | 
   +---+---+---+---+---+

我们不断的利用这个性质从rest里面取出可以使用的二进制位,进行dfs(p[ tp ], unused - tp);。同时再枚举完一个节点后,将其从rest中删除,即rest -= tp;

最后我们再使用printf("%d\n", cnt);来输出我们找到的方案数。


在上面DFS的基础上,我们还可以进一步优化。

递归的过程中,unused很有可能会出现重复的情况,比如说从1->3->2->4和从1->2->3->4,对于dfs(4, unused)来说,此时的unused值都是相同的。因此我们可以采用记忆化搜索的方式进一步降低复杂度。

定义数组f[n][unused]表示当前节点为n,且节点访问情况为unused时的方案数量。

那么有:

f[n][unused] = sigma(f[p][unused + (1 << (n - 1))] | (unused & (1 << (n - 1)) == 0) and (p != n) and (edge[p] & (1 << (n - 1)) != 0))

这对应的是原dfs函数中下面这段代码的逆过程。

while (rest) {
    int tp = rest & (-rest);
    dfs(p[ tp ], unused - tp);
    rest -= tp;
}

三个条件分别为:

  • (unused & (1 << (n - 1)) == 0)表示当前状态中右起第n位为0
  • (p != n)表示前驱结点不等于n
  • (edge[p] & (1 << (n - 1)) != 0)表示节点p能够到达节点n

在计算f[n][unused]的过程中,假设unused的二进制表示中有i个1,则我们需要事先计算出所有i+1个1的状态才能够保证unused + (1 << (p - 1))是正确的结果。

因此我们在枚举过程中,需要按照状态中1的个数来枚举。

其伪代码:

For numberOfOnes = n-1 .. 0
    For (the number of ones of unused equals numberOfOnes)
        For nowVertex = 1 .. n
            For prevVertex = 1 .. n
                If (unused & (1 << (nowVertex - 1)) == 0) and (prevVertex != nowVertex) and (edge[ prevVertex ] & (1 << (nowVertex - 1)) != 0) Then
                    f[ nowVertex ][ unused ] = f[ nowVertex ][ unused ] + f[ prevVertex ][unused + (1 << (nowVertex - 1))]
                End If
            End For
        End For
    End For
End For

对于f[n][ unused ]数组,其初始条件为f[1][(1 << n) - 2] = 1

最后需要将所有的f[n][0]中能够到达节点1的节点n累加起来,即可得到所有的方案数。该算法的理论时间复杂度为O(2^n*n^2)

结果分析

该题目一共只有7名选手通过,大部分提交过该题的选手也都有一定的部分分值。

本题直接采用搜索就能够通过,拉开差距的主要原因是对于伪代码实现方式的不同,而导致通过的测试点数量不同。

另外还有一个易错点是走完所有节点之后一定要判断是否可以回到起点。

write answer 切换为英文 切换为中文


转发分享