插頭與輪廓線與基於連通性狀態壓縮的dp 學習指南
*插頭,真的插,插了又插*
什麼是插頭dp
像這樣在一個n*m的棋盤上(n與m很小),求有多少種不同的迴路數,或是用1條迴路經過所有點或部分點的方案數,或是求一條路徑上的權值和最大的問題。
通常稱為插頭dp。
這類問題通常很明顯,但程式碼量大又容易出錯,有時TLE有時MLE。
一般解法
插頭:對於一個4連通的問題來說,它通常有上下左右4個插頭,一個方向的插頭存在表示這個格子在這個方向可以與外面相連。
對於一個迴路上的格子,必然是從一個方向進入另一個方向出去,共有圖示6中可能。
解插頭dp的一般方法是逐格遞推,按照從上到下,從左到右的順序依次考慮每一格
我們稱圖中的藍線為輪廓線,任何時候只有輪廓線上方的格子才會對輪廓線以下的格子產生直接的影響。
以圖中第三行第三列的格子D為例,假設逐格推到當前格子:
當左邊有向右的插頭,上邊有向下的插頭,那麼D只能考慮左上插頭。
當左邊有向右的插頭、上邊向下的插頭中有且只有一個,那麼D可以接受到該方向的插頭並有向右或向下兩個插頭的其中一個。
當左邊上邊都沒有插頭時,D可以有右下插頭。
這三種情況就是在遞推時要考慮的基本狀態。
易知,對於m列的格子,輪廓線上有m+1個插頭資訊,m個格子的上方的插頭資訊,以及當前推到的格子左側的插頭資訊。
那麼對於一條路的問題,該如何保證最後在圖中只有一個連通分量呢。
我們用最小表示法表示格子的連通性。
所有的障礙格子標記為0,第一個非障礙格子以及與它連通的所有格子標記為1,然後再找第一個未標記的非障礙格子以及與它連通的格子標記為2,……,重複這個過程,直到所有的格子都標記完畢。
當兩個屬於不同連通分量的格子合併到一起時,我們將所有屬於這兩個連通分量的格子的連通性更新,使其具有相同的值。
對於逐格遞推,在每一行開始時,我們應該有一個數組,陣列中存放著m個元素,即m列,上一行的每一列是否有向下的插頭,即當前行是否有向上的插頭。
在每個格子開始時,我們還應該知道這個格子左側的是否有插頭。
在每個格子結束時,我們要設定下一行同一列的格子的插頭也要設定這個格子右邊的插頭。
為了處理方便,我們要把遞推到每個格子時的輪廓線用一個整數來表示,這個過程成為 encode。
m列的格子,用一個m+1的陣列code來表示輪廓線上的資訊(包括連通性)。
對於當前的格子(i,j),code[j-1]中是它左側格子的插頭資訊,code[j]中時它上方格子的插頭資訊。
我們根據這兩個值列舉(i,j)的所有可能的狀態,然後將 code[j-1]設為(i,j)下方格子的插頭資訊,code[j]設為(i,j)右側插頭資訊。
將code陣列編碼為整數,作為(i,j+1)格子的起始狀態。
當j已經是最後一列時,我們將code陣列的所有元素向右平移,將第一個元素code[0]設為0。
這樣對於一個新的一列,code陣列就能表示它上方所有格子的資訊。
對於涉及到連通性的問題,code陣列儲存的是插頭的連通性。
對於不涉及連通性的問題,code陣列儲存插頭的有無。
程式碼實現
【例】Formula 1 [Ural1519]
給你一個m * n的棋盤,有的格子是障礙,問共有多少條迴路使得經過每個非障礙格子恰好一次.m, n ≤ 12.
解題步驟
首先將棋盤讀入,有障礙的格子設為0,沒有障礙設為1,注意要將棋盤之外的格子都設為有障礙。
題目要求要經過所有的格子,這說明在某個格子形成閉合迴路時,在它之後不會再有別的空格子了,因此形成閉合迴路的格子必定是最右下的格子,用(ex,ey)表示。
memset(maze,0,sizeof(maze));
ex=ey=0;
for (int i=1;i<=n;i++){
scanf("%s",s+1);
for (int j=1;j<=m;j++){
if (s[j]=='.'){
maze[i][j]=1;
ex=i;
ey=j;
}
}
}
我們用兩個hash表來儲存當前格子輪廓線上所有可能的狀態與下一個格子輪廓線所有可能的狀態。
這種狀態可能出現的次數記在 f 中。
struct HASHMAP{
int head[seed],next[maxn],size;
LL state[maxn];
LL f[maxn];
void clear(){
size=0;
memset(head,-1,sizeof(head));
}
void insert(LL st,LL ans){
int h=st%seed;
for (int i=head[h];i!=-1;i=next[i]){
if (state[i]==st){
f[i]+=ans;
return;
}
}
state[size]=st;
f[size]=ans;
next[size]=head[h];
head[h]=size++;
}
}hm[2];
主要處理過程如下,輪流使用兩個雜湊表儲存狀態。遞推格子,如果當前無障礙,則呼叫 dpblank,否則呼叫 dpblock。
遞推完最後一個格子後,將所有可能的狀態中的方案數相加即為答案,實際上對於本題來說,最終只會有一個狀態,就是code陣列中的元素全為0,因為最後行上不可能有向下的插頭。
int cur=0;
LL ans=0;
hm[cur].clear();
hm[cur].insert(0,1);
for (int i=1;i<=n;i++){
for (int j=1;j<=m;j++){
hm[cur^1].clear();
if (maze[i][j]) dpblank(i,j,cur);
else dpblock(i,j,cur);
cur^=1;
}
}
for (int i=0;i<hm[cur].size;i++){
ans+=hm[cur].f[i];
}
編碼的過程並不複雜,只要利用狀態壓縮的知識按照一定的規則將陣列中的值儲存在一個整數的不同位上即可。
由於題目中m<=12,所以顯然最多隻有6個不同的連通分量。因此code陣列中元素的值不應超過6,用3個二進位制位來表示0~7的整數。
注意在壓位的過程中,由於在合併不同連通性的插頭時會消去一個連通分量,因此要對連通性的編號重新做處理,重新按1~cnt編碼。
LL encode(int code[],int m){
LL st=0;
int cnt=0;
memset(ch,-1,sizeof(ch));
ch[0]=0;
for (int i=0;i<=m;i++){
if (ch[code[i]]==-1) ch[code[i]]=++cnt;
code[i]=ch[code[i]];
st<<=3;
st|=code[i];
}
return st;
}
解碼更加簡單。
void decode(int code[],int m,LL st){
for (int i=m;i>=0;i--){
code[i]=st&7;
st>>=3;
}
shift 函式將code中的所有元素向右移動一位。當j==m時需要這樣做。
void shift(int code[],int m){
for (int i=m;i>0;i--) code[i]=code[i-1];
code[0]=0;
}
對空位置處理時,先列舉當前可能的所有的輪廓線狀態。
對於每個狀態,先用 decode 解碼出 code 陣列。
那麼它左側的資訊left=code[j-1],上方的資訊up=code[j]。
按照解法中所說的情況進行討論。
當左上有插頭時,當兩個插頭屬於相同的連通分量時,如果這個格子恰好是最後一個格子才能合併迴路。兩個插頭不屬於相同連通分量時,合併它們。
對於左邊有一個插頭或上方有一個插頭的情況,判斷右邊或下邊是否是障礙,如果不是的話就連線一個插頭,這個插頭跟接入這個格子的插頭屬於相同的連通分量。
對於沒有插頭的格子,那麼他只能是一個新的連通分量,向右下設定插頭。將新的連通分量設為一個不可能出現的最大值,當編碼時會對它重新設定,不用擔心溢位。
對於每一種討論出的狀態,將其加入下一個雜湊表中。
當j==m時,在編碼之前要進行shift,但是某些情況下j不可能等於m,因此不做shift操作也可以。
void dpblank(int i,int j,int cur){
int left,up;
for (int k=0;k<hm[cur].size;k++){
decode(code,m,hm[cur].state[k]);
left=code[j-1];
up=code[j];
if (left&&up){
if (left==up){
if (ex==i&&ey==j){
code[j-1]=code[j]=0;
if (j==m) shift(code,m);
hm[cur^1].insert(encode(code,m),hm[cur].f[k]);
}
}
else{
code[j-1]=code[j]=0;
for (int i=0;i<=m;i++){
if (code[i]==left) code[i]=up;
}
if (j==m) shift(code,m);
hm[cur^1].insert(encode(code,m),hm[cur].f[k]);
}
}
else if (left||up){
int t;
if (left) t=left;
else t=up;
if (maze[i][j+1]){
code[j-1]=0;
code[j]=t;
hm[cur^1].insert(encode(code,m),hm[cur].f[k]);
}
if (maze[i+1][j]){
code[j-1]=t;
code[j]=0;
if (j==m) shift(code,m);
hm[cur^1].insert(encode(code,m),hm[cur].f[k]);
}
}
else{
if (maze[i][j+1]&&maze[i+1][j]){
code[j-1]=code[j]=13;
hm[cur^1].insert(encode(code,m),hm[cur].f[k]);
}
}
}
}
一個障礙是不可能有向下或向右的插頭的,將其設為0。void dpblock(int i,int j,int cur){
for (int k=0;k<hm[cur].size;k++){
decode(code,m,hm[cur].state[k]);
code[j-1]=code[j]=0;
if (j==m) shift(code,m);
hm[cur^1].insert(encode(code,m),hm[cur].f[k]);
}
}
這整個過程其實就是一個插頭dp的模板(山寨自kuangbin大牛)。
經典問題
HDU 1693 Eat the Trees
多回路經過所有格子的方案數。
不需要記錄連通性,最簡單的題,對所有空格子都考慮適當的插頭即可。URAL 1519 Formula 1
單迴路經過所有格子的方案數。
記錄連通性的入門題,要保證只在最後一個格子形成迴路。
FZU 1977 Pandora adventure
單迴路數,格子有了三種:障礙格子、必選格子和可選格子。
在編碼時新增一位標誌,表示是否形成了迴路,如果形成了迴路後還遇到了必選格子,就廢棄掉這個狀態。
HDU 1964 Pipes
單迴路求最小花費。求花費的題,在雜湊時改為記錄當前的最佳值。在最後一個格子判斷才能形成迴路。
HDU 3377 Plan
從左上角走到右下角,可選格子,每個格子有個分數,求最大分數。
對左上和右下的格子單獨進行處理,左上的格子只能有向下或向右的插頭,而右下的格子只能有向上和向左的插頭,不能形成迴路。
POJ 1739 Tony's Tour
從左下角走到右下角,每個非障礙格子僅走一次的方法數。在最後新增兩行,倒數第二行中間部分全設定為障礙。然後求一條迴路的方案數即可。
POJ 3133 Manhattan Wiring
格子中有兩個2,兩個3.求把兩個2連起來,兩個3連起來。 兩條路徑不能交叉。對2和3的點單獨進行考慮,格子上不能形成迴路,而且只有出入兩種可能,加上方向只有四種可能。
連通性只有兩個選擇2或3。對於不確定2還是3的普通格子就兩個都嘗試一下。
ZOJ 3466 The Hive II
多回路走有障礙的六邊形格子,求方案數。將格子行列顛倒一下,發現一個格子有6個方向的插頭,左右,加上上邊兩個下邊兩個。
將code陣列擴充套件成兩倍,一個格子佔用code陣列中的三個元素,code[2*j-2]為左邊的插頭,code[2*j-1]為左上的插頭,code[2*j]為右上的插頭。
按照不同情況進行推導之後,將code[2*j-2]為左下的插頭,code[2*j-1]為右下的插頭,code[2*j]為右邊的插頭。這樣下一個格子仍然可以從code中取到合適的資訊。
而奇數行與偶數行的左下與右下座標的計算方法是不同的,這個要注意處理,而且由於六邊形的特殊性,只有偶數行才需要shift操作。
ZOJ 3213 Beautiful Meadow
簡單路徑得到最大的分數。HDU 4285 circuits
求K個迴路的方案數。不能環套環。將當前的閉合迴路數壓入狀態中。
對於環套環,如果當前格子左邊有奇數個不同連通分量的插頭,那麼如果在左上形成閉合的迴路,那麼就會出現環套環的情況,只要對這種情況跳過即可。