轮廓线dp
轮廓线:对于一个n*m棋盘上的决策,当我们考虑到(i,j)这个格子时,第i行前j-1个格子的下边以及第j个格子的左边以及第j到第m-1-j个格子的上边所组成的一条线,我们称之为轮廓线
轮廓线dp:当我们在棋盘第(i,j)格子上放置棋子时,我们的决策受到且仅受到(i,j)格子左边以及上边的状态的影响,我们这个时候就可以 采取轮廓线dp的方法来解决
例题:P5074 Eat the Trees - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
题目大意:给出n*m的棋盘,其中为1的格子必须铺线,为0的格子禁止铺线,棋盘上可形成多条回路,求所有合法方案数
具体思路:对于一个格子而言,我们的线的走向有且仅有6种情况,用1-4表示一个格子的4条边,(a,b)二元对表示这个格子的线跨越哪两条边,那么6种情况分别为:(1,2),(1,3),(1,4),(2,3),(2,4),(3,4)。我们考虑轮廓线dp:当边被线跨越时,我们将这条边记为1,否则,我们将这条边记为0。
更普遍地说,我们将该边被跨越,称为这条边上有插头,反之,我们称这条边上无插头
那么一条轮廓线的状态,我们就可以用一个m+1位的二进制数来表示
现在来考虑轮廓线的状态(下面称之为s)如何转移:
先考虑一般情况,我们现在考虑第(i,j)个格子,s的第j位表示该格子的左边是否有插头,我们记为d1,j+1位表示该格子的上边是否有插头,我们记为d2。由位运算我们可以知道,d1=s>>j&1;d2=s>>j+1&1;
当我们更新完第i行第j列的格子的状态时,其下边和右边的插头状态就会被更新,而由于我们下一个dp的就是第j+1格,对于j+1格而言,第j位表示的就是第j格下边的插头,第j+1格表示的就是第j+1格左边的插头,也就是第j格右边的插头。因此,我们在更新第j个格子的插头时,第j位的状态从表示第j格左边插头更新到表示第j格下边插头,第j+1位的状态从表示第j格上边插头更新到表示第j格右边插头。
我们现在考虑d1和d2会有以下几种情况:
当该格子能铺线时:
1.当d1无插且d2无插时,这时说明之前的回路已闭合,我们要手动创造一个回路的开头,也就是将(i,j)格子的下边和右边都加上插头,也就是把d1从0更新到1,d2从0更新到1,也就是从状态s转移到了状态s^3<<j
2.当d1有插且d2无插时,为了保证插头的连通性,我们必须要把d1插头进行延伸,可以将其插向下边,也可以将其插向右边,也就是把d1更新为1、d2更新为0或者d1更新为0,d2更新为1,也就是从状态s转移到状态s以及从状态s转移到s^3<<j
3.当d1无插且d2有插时,同上,从状态s转移到状态s以及从状态s转移到状态s^3<<j
4.当d1有插且d2有插,这个时候我们只能把d1延伸出来的线和d2延伸出来的线连在一起,形成一个回路,那么这个时候第j格的下边和右边将不会有插头出现,也就是把d1从1更新到0,把d2从1更新到0,也就是从状态s转移到状态s^3<<j
当该格子不能铺线时:
此时若该格子的左边或者上边有插头的话,就无法继承其延伸出来的线,也就是这种状态下必不可能转移到一种合法的状态,因此这个时候我们不继承其方案数。那么当且仅当d1无插且d2无插的时候,我们我们这个格子才可以不铺线也不会影响方案的合法性,也就是从状态s转移到状态s
以上为在第i行内的状态转移情况,那么我们现在再来考虑行间如何进行状态转移
当我们dp第i行的第0个格子时,状态s的第0位表示的是该格子左边的插头情况,且其只能为0,(第0格左边不可能出现插头),而第1位表示的是该格子下边的插头。当我们dp到第i行的最后一个,也就是第m-1个格子时,我们会把第m-1位更新为第m-1个格子的下边的插头,而把第m位更新为第m-1个格子的右边的插头 ,此时,我们也希望且一定要,右边的插头不存在,否则就不是合法方案
因此,我们在完成对第i行的状态转移,将转移到第i+1行时,由于第0位已经更新为了第i行第0个格子下边的插头,也就是第i+1行第0个格子上边的插头,因此,我们应当将这个状态s整体向左移一位,这样,第1位又重新表示回了第0个格子上方的插头(同时往后的第2到第m位也同理),而第0位也被左移初始化为0,表示第0个格子左边的插头,且一定没有插头。同时,原来的第m位(表示右插的)被左移到第m+1位,如果它原本为1的话,那么由于我们遍历状态s的时候,只遍历到第m位就结束了,也就是从0到(1<<m+1)-1那么这个右插存在的不合法状态一定不会被我们遍历到,也就是不会被转移,我们就成功将其筛出考虑范围外了。
总结的话就是,在行间转移的时候,我们将所有状态向左移1位更新即可
实现代码:
#include <iostream>
#include <cstdio>
using namespace std;
const int M = 14, N = 1 << 14;
#define limit (1 << m + 1)
//滚动数组
unsigned long long dp[2][N], tmp[N];
bool book[M][M];
int T, n, m, now, pre;
int main()
{
cin >> T;
while (T--)
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 0; j < m; j++)
{
book[i][j] = false;
cin >> book[i][j];
} // true为能铺
for (int i = 0; i < limit; i++) dp[0][i] = 0;
dp[0][0] = 1;
now = 1; pre = 0;
//遍历第k行
for (int k = 1; k <= n; k++)
{
// 换行处理
for (int i = 0; i < limit; i++)
{
tmp[i] = dp[pre][i];
dp[pre][i] = 0;
}
for (int i = 0; i < limit; i++)
dp[pre][i << 1] = tmp[i];
//遍历第j格
for (int j = 0; j < m; j++)
{
//初始化为0
for (int s = 0; s < limit; s++) dp[now][s] = 0;
//枚举状态s
for(int s=0;s<limit;s++){
//不能铺
if(!book[k][j]) {if(!(s>>j&3)) dp[now][s]+=dp[pre][s];}
//能铺
else{
if((s>>j&3)==2 || (s>>j&3)==1) dp[now][s]+=dp[pre][s];
dp[now][s^3<<j]+=dp[pre][s];
}
}
now ^= 1;
pre ^= 1;
}
}
cout << dp[pre][0] << endl;
}
return 0;
}
插头dp:
题目链接:P5056 【模板】插头 DP - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
题目和上面的题目基本一样,唯一的区别是棋盘上只能形成一条回路
我们考虑如何限制只能形成一条回路这个条件:
我们来思考为何刚刚的做法可以形成多条回路? 是因为我们可以创建多种流通线并可以随时闭合
那么我们来考虑一条回路的特点:对于一条回路而言,我们可以任意选取其的一个起点以及一个终点,我们发现,从其起点左边到终点是一条线直通,起点右边到终点也是一条线直通。因此,我们只需要在dp搭线的时候也如此限制就好了。
括号表示法:
我们将之前的插头进行细分,分为左插头和右插头,左插头就是回路向左边延伸的插头,右插头就是回路向右边延伸的插头
这里用到一个定理:当一条轮廓线上,存在着且仅有这样的排序:左插1,左插2,右插2,右插1时,我们可以保证左插1是与右插1匹配,而左插2与右插2匹配,若左插1与右插2匹配,则这两条回路一定会在上方相交从而矛盾
因此,一条边上的状态就有了3种,我们可以用2位二进制来表示插头状态,分别为:
00(0):无插,01(1):左插,10(2):右插,11(3):无意义
下面我们再来考虑状态转移:
首先是行间状态转移和上题是一样的,只不过这里变成了左移2位
然后是逐格转移:
考虑dp到该行第j格,d1表示该格左边的插头,而d2表示该格上边的插头
那么d1=s>>j*2&3;d2=s>>(j+1)*2&3;
有以下几种情况:
首先是该格子能铺线时:
1.d1无插且d2无插:这时要将第j格的下边更新为左插,把右边更新为右插,即从状态s转移到状态s^1<<j*2^2<<(j+1)*2, (也可以写成s^9<<j*2)
2.d1为左插且d2无插或d1为右插且d2无插:这时可以把插头向下边延伸或者向右边延伸,即从状态s转移到状态s或者从状态s转移到状态s^d1<<j*2^d1<<(j+1)*2
3.d1无插且d2为左插或d1无插且d2为右插:这时可以把插头向下边延伸或向右边延伸,即从状态s转移到状态s或者从状态s转移到状态s^d2<<j*2^d2<<(j+1)*2
4.d1为左插且d2为左插,这个时候要把d1和d2两边的插头连接,且由于两个左插连接,那么d2所对应的右插就应该要更新为左插,才能保证d1所对应的右插有左插对应,假设找到d2所对应的右插(向高位找)在第k/2个格子,那么就是从状态s转移到状态s^1<<j*2^1<<(j+1)*2^3<<k,(也可以写成s^5<<j*2^3<<k)
5.d1为右插且d2为右插,这个时候要把d1和d2两边的插头连接,且要把d1所对应的左插更新为右插,才能保证d2所对应的左插有右插对应,假设找到d1所对应的左插(向低位找)在第k/2个格子,那么就是从状态s转移到状态s^2<<j*2^2<<(j+1)*2^3<<k,(也可以写成s^10<<j*2^3<<k)
6.d1为右插且d2为左插,这种情况我们直接把d1和d2两边的插头连接即可,也就是从状态s转移到状态s^d1<<j*2^d2<<(j+1)*2,(也可以写成s^6<<j*2)
7.d1为左插且d2为右插,这种情况我们的回路就要闭合了,所以当且仅当我们dp到最后一格终点的时候,我们才能继承这个状态的合法方案数
然后是该格子不能铺线的时候:
和上题一样,当且仅当d1和d2都无插的时候,从状态s继承到状态s
最后,由于我们需要枚举的数字高达1<<2*(m+1),我们要使用哈希表进行枚举优化,仅枚举上次状态转移保存下来的合法方案数即可,保存合法状态时,要注意放下插头的所对应的下一格是否能铺线,不能铺线的话,我们就不保存该合法状态
实现代码:
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
int n, m, ex, ey, now, pre;
const int M = 15, N = 1 << 26, mod = 299987;
bool book[M][M]; char str[M];
int head[300000], nxt[N], que[2][N], cnt[2];
long long val[2][N], ans;
//book[i][j]:记录第i行第j列是否能铺线
//head:哈希表表头
//que:保存合法状态
//cnt:合法状态数
//val:合法状态对应的合法方案数
void read() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> str;
for (int j = 0; j < m; j++) {
if (str[j] == '.') { book[i][j] = true; ex = i; ey = j; }
}
}
}
//插入哈希表
void insert(int sta, long long num) {
int u = sta % mod + 1;
for (int i = head[u]; i; i = nxt[i]) {
if (que[now][i] == sta) {
val[now][i] += num;
return;
}
}
nxt[++cnt[now]] = head[u];
head[u] = cnt[now];
que[now][cnt[now]] = sta;
val[now][cnt[now]] = num;
}
void solve() {
cnt[now] = 1; val[now][1] = 1; que[now][1] = 0;//起初的合法状态就是全空
for (int i = 1; i <= n; i++) {
//换行处理,左移2位
for (int s = 1; s <= cnt[now]; s++) que[now][s] <<= 2;
for (int j = 0; j < m; j++) {
memset(head, 0, sizeof(head));
pre = now; now ^= 1;
cnt[now] = 0;
//遍历队列内的合法状态
for (int k = 1, sta, d1, d2; k <= cnt[pre]; k++) {
sta = que[pre][k]; long long num = val[pre][k];
d1 = sta >> 2 * j & 3; d2 = sta >> 2 * (j + 1) & 3;
//0:无插 1:左插 2:右插 3:无意义
//能铺
if (book[i][j]) {
//无插
if (!d1 && !d2) { if (book[i + 1][j] && book[i][j + 1]) insert(sta ^ 9 << j * 2, num); }
//无+左,无+右
else if (!d1 && d2) {
if (book[i][j + 1]) insert(sta, num);
if (book[i + 1][j]) insert(sta ^ d2 << j * 2 ^ d2 << 2 * (j + 1), num);
}
//左+无,右+无
else if (d1 && !d2) {
if (book[i + 1][j]) insert(sta, num);
if (book[i][j + 1]) insert(sta ^ d1 << j * 2 ^ d1 << 2 * (j + 1), num);
}
//右+左
else if (d1 == 2 && d2 == 1) insert(sta ^ 6 << j * 2, num);
//右+右
else if (d1 == 2 && d2 == 2) {
int check = 0;
//向低位找
for (int s = 2 * j; s >= 0; s -= 2) {
if ((sta >> s & 3) == 2) check++;
else if ((sta >> s & 3) == 1) check--;
if (!check) { insert(sta ^ 10 << j * 2 ^ 3 << s, num); break; }
}
}
//左+左
else if (d1 == 1 && d2 == 1) {
int check = 0;
//向高位找
for (int s = j * 2+2; s < 2 * (m + 1); s += 2) {
if ((sta >> s & 3) == 1) check++;
else if ((sta >> s & 3) == 2) check--;
if (!check) { insert(sta ^ 5 << j * 2 ^ 3 << s, num); break; }
}
}
//左+右
else if (i == ex && j == ey) ans += num;
}
else {
if (!d1 && !d2) insert(sta, num);
}
}
}
}
}
int main() {
read();
solve();
cout << ans;
return 0;
}