基于联通性的状态压缩动态规划是一类非常典型的状态压缩动态规划问题,由于其压缩的本质并不像是普通的状态压缩动态规划那样用0或者1来表示未使用、使用两种状态,而是使用数字来表示类似插头的状态,因此。它又被称作插头DP。
插头DP本质上是一类状态压缩DP,因此,依旧避免不了其指数级别的算法复杂度,即便如此,它依旧要比普通的搜索算法快非常多。
【例】Postal Vans(USACO training 6.1.1)
有一个4*n的矩阵。从左上角出发,每次能够向四个方向走一步,求经过每一个格子恰好一次。再回到起点的走法数。
【算法分析】
看到此题,很多读者认为4非常小。会想到搜索算法或者是递推公式。而实际上。搜索算法是不能解决此题的。当n稍大一点。搜索算法即使写的再美丽,也不能通过此题。本题确实有递推公式,但递推公式却不是那么好找。因此。能够考虑使用插头DP。
为了更好的了解插头DP,首先引入下面几个概念:
1.插头
对于矩阵上的不论什么一个格点,路径总是会穿过它,也就是从一头进入,从一头出去,这种情况一共同拥有6种,例如以下所看到的:
一个合法的路径须要满足的必要条件之中的一个是:它的每个格子上的路径插头都是上述六者之中的一个。而且要相互匹配。相互匹配的意思是。假设一个格子上方的格子有向下的插头,那么这个格子就必须有向上的插头与它相匹配。
2.轮廓线
对于不论什么一个未决策的格子,仅有其上边和左边的格子对其的放置方法有影响。因此。能够依据当前已经决策的格子画出一条轮廓线,切割出已经决策和未决策的格子。
如上图就是两种典型的轮廓线,一种是基于格子的轮廓线,当前该转移的是轮廓线拐角处的格子;一种是基于行的轮廓线。当前该转移的是轮廓线下方的一整行。
对于第一种情况,涉及的插头一共同拥有N+1个,当中N个下插头。1个右插头。须要保存的插头数量是N+1个,对于另外一种情况,仅仅有N个下插头。须要保存的插头数是N个。
3.连通性
对于这类动态规划问题。除了要保存每个插头外。还须要记录这些插头的连通性情况。
比如。使用[(1,2)(3,4)]来表示该行第1、2个格子已经连通,第3、4个格子已经连通。
如图所看到的,两者的下插头全然一致,但连通性却全然不同。因此。还须要在状态中表示他们的连通性。
因为插头的表示已经是指数级别的空间,表示连通性假设再须要指数型的空间,那么空间和时间的消耗将是巨大的!因此。须要有更好的办法去表示连通性。通用的一个办法我们称作“括号表示法”。
对于同一行的四个格子,如果他们都有下插头,则,他们的连通性仅仅可能有上图两种情况[(1,2),(3,4)],[(1,4),(2,3)],而不可能是[(1,3),(2,4)]。更普遍的,由于插头永远都不可能有交叉。因此。不论什么两个格子之间的联通性也不会存在交叉。
这和括号匹配是全然一致的!
括号表示法的基本思想是三进制:
0:无插头状态,用#表示
1:左括号插头,用(表示
2:右括号插头,用)表示
图左(使用格点转移)能够表示为:(()#)
图右(使用行来转移)能够表示为:(())
在此基础上,继续来了解插头DP的状态转移方式。
1.基于格点的状态转移:
基于格点的状态转移方式每次转移仅一个格子。转移的时候须要考虑这个格子的左方以及上方是否有插头。
左方无插头,上方无插头。[****##****](*代表#()中随意一个)。仅仅能在此处增加一个形插头,状态变为[****()****]。
左方和上方仅仅有一个插头。此时,该格必定有一个插头和这个插头匹配,还有一个插头插向下方或右方,这个格子相当于延续了之前该插头的连通状态,因此,转移方式是:将插头所在的位不动(插头插向下方)或向右移动一位(插向右方)。
左方有插头。上方有插头。这样的情况下相当于合并了两个连通块,须要考虑两个插头的括号表示情况:
case1:”((”,两者都是左插头。此刻要把两个两个连通分量合并,就必须改动他们相应的右括号,使得它们相应的右括号匹配起来。比如:[#((##))],将两个’(’合并时,须要改动第二个’(’所匹配的’)’为’(’。使他们继续匹配。变为[#####()]
case2:”))”,两者都是右插头,此时,合并他们相当于直接把它们变为”##”就可以。
case3:”()”。即两者本来就是匹配的,此时,合并他们相当于把它们连起来形成回路。对于须要形成一条回路的题目。仅仅有在最后一个格子形成回路才是合法的转移方式。
当中,左方有插头,上方无插头以及左方无插头。上方有插头的情况十分类似,在实现代码的时候能够合并。
2.基于行的状态转移:
基于行的状态转移,使用搜索的办法实现,dfs每一行的合法状态,然后更新状态至下一行。easy实现但算法复杂度较高,非常少有人使用。在此处不再赘述。
须要注意的是,尽管插头DP使用的是三进制的状态表示,可是往往在实现的时候。使用的却是四进制(仅使用0、1、2。3不表示不论什么意义)。原因是因为计算机本身的设计导致其对2的幂次方进制数计算速度非常快,使用四进制能够利用到位运算,加快执行速度。
此外,因为在状态转移的过程中。须要知道每个左括号所相应的右括号的位置。因为合法状态是非常有限的。因此,能够通过预处理的方式将合法状态以及这些状态下每个左括号所匹配的右括号的位置记录下来,利用额外空间换来时间复杂度的下降。
在实现代码时,使用一个int类型来保存每个状态。括号在四进制中从低位到高位依次表示从左到右的每个括号,比如:9(21)3的实际上代表了一个从左到右的匹配的括号()。请读者在阅读代码的时候注意。
本题在读入数据过大的时候会超过long long类型。因此须要用到高精度运算。实现时能够用结构体复写加法运算符的方式来实现。熟悉java的读者也能够直接使用java中的BigInteger类。
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <iostream>
using namespace std;
const int n = 4;
struct Num{
short arr[500];
int len;
void init(int i)
{
memset(arr, 0, sizeof(arr));
arr[0] = i;
len = 1;
}
void print()
{
for (int i = len - 1; i >= 0; i--)
{
cout << arr[i];
}
cout << endl;
}
};
void operator += (Num &a, Num b)
{
a.len = max(a.len, b.len);
for (int i = 0; i < a.len; i++)
{
a.arr[i] += b.arr[i];
a.arr[i + 1] += a.arr[i] / 10;
a.arr[i] %= 10;
}
if (a.arr[a.len]) a.len++;
}
int N;
int stat[1110];
int brk[1110][8], stack[8], top = 0, tot = 0;
Num dp[8][1110];
int main() {
freopen("vans.in", "r", stdin);
freopen("vans.out", "w", stdout);
cin >> N;
int m = 1 << ((n + 1) << 1);
for (int i = 0; i < m; i++)
{
top = 0;
for (int j = 0; j <= n; j++)
{
int x = i >> (j << 1);
if ((x & 3) == 1) stack[top++] = j;
if ((x & 3) == 2)
if (top--)
{
brk[tot][j] = stack[top];
brk[tot][stack[top]] = j;
} else break;
if ((x & 3) == 3)
{
top = -1;
break;
}
}
if (!top) stat[tot++] = i;
}
Num ans;
ans.init(0);
memset(dp, 0, sizeof(dp));
dp[n][0].init(1);
for (int k = 1; k <= N; k++)
{
for (int i = 0; i < tot; i++)
{
if (stat[i] & 3) dp[0][stat[i]].init(0);
else dp[0][stat[i]] = dp[n][stat[i] >> 2];
}
for (int i = 1; i <= n; i++)
{
int x = (i - 1) << 1;
memset(dp[i], 0, sizeof(dp[i]));
for (int j = 0; j < tot; j++) {
int p = (stat[j] >> x) & 3;
int q = (stat[j] >> (x + 2)) & 3;
// ## -> ()
// 9 = (21)4
//左上都无插头
if (!p && !q) dp[i][stat[j] | (9 << x)] += dp[i - 1][stat[j]];
else
//左上都有插头
if (p && q)
{
//两个((或者两个))
if (p == q)
{
// ((...)) ->
// ##...()
// 5 = (11)4 : ## = (( ^ 5
// () = )) ^ 3
//两个((。把其匹配位置的)改为(
if (p == 1) dp[i][stat[j] ^ (5 << x) ^ (3 << (brk[j][i] << 1))] += dp[i - 1][stat[j]];
// ((...)) ->
// ()...##
// 10 = (22)4
//两个)),把其匹配位置的(改为)
else
dp[i][stat[j] ^ (10 << x) ^ (3 << (brk[j][i - 1] << 1))] += dp[i - 1][stat[j]];
}
else
//()或)(
{
//()的情况,假设是最后一个格子。将答案加进来。否则跳过
if (p == 1)
{
if (k == N && i == n && stat[j] == (9 << x))
ans += dp[i - 1][stat[j]];
}
//)(的情况。直接把)(改成##
else dp[i][stat[j] ^ (6 << x)] += dp[i - 1][stat[j]]; // )( -> ##, 6 = (12)4
}
}
//仅仅有当中一个位置有插头
else
{
//当原来状态是#(或者#)时。状态不变意味着插头向右
//当原来状态是(#或者)#时,状态不变意味着插头向下
dp[i][stat[j]] += dp[i - 1][stat[j]];
//当原来状态是#(或者#)时,状态交换意味着插头向下
//当原来状态是(#或者)#时,状态交换意味着插头向右
dp[i][stat[j] ^ (p << x) ^ (q << x + 2) | (p << x + 2) | (q << x)] += dp[i - 1][stat[j]];
}
}
}
}
//连通之后答案要乘以2,由于一个环有两种遍历的方向
ans += ans;
ans.print();
return 0;
}
本题稍作变化,就能够出非常多道插头DP类的题目:比如。从(1。1)点出发。回到(1。n)点。找出若干个环把图上全部点都覆盖。从(1。1)点出发,把全部点恰好都走一次,但不须要回到(1,1)点等。
本质上都是本题的一点点改变。仅仅须要彻底理解插头DP的思想。做出这些题目都不是问题。
1.从(1,1)点出发。回到(1,n)点。我们能够人为的觉得这个迷宫是从(0。1)点进入,最后回到(0,n)点。这样一来,我们仅仅须要在转移状态的时候,强制要求(1,1)点和(1,n)点必须得有上插头,其它不变就可以。
2.找出若干个环把图上全部点都覆盖。在原来的题目中,必须仅仅有一个环,因此。仅仅有在最后一个点(n,n)的时候,才处理了同一时候有左上插头而且他们是()的情况。在本问题里,仅仅须要把这个特殊要求去掉就可以,即:在不论什么一个点。仅仅要满足左上都有插头且他们是(),就把它们合并。
3.从(1,1)点出发。把全部点恰好都走一次,但不须要回到(1。1)点。此时,人为的给(1,1)点加入一个上插头。使得(1。1)点仅仅可能被经过一次,之后在状态中多加入一维状态[0 or 1]用来表示当前是否已经把某一个点作为终点,当这一维状态是0的时候,能够在某一个格子转移的时候仅仅给他加入一个插头,然后把0改动成1,最后到(n,n)点要求必须是()才更新答案就可以;假设到达最后一个节点(n,n)这一维状态依旧是0,则在此处考虑两种插头的插法,并更新答案。