[题解] 河南萌新联赛 2026 第一场 K. 填魔法

[题解] 河南萌新联赛 2026 第一场 K. 填魔法

TAG: 动态规划,状态压缩,轮廓线 DP,记忆化搜索,牛客
题目链接: 牛客 K - 填魔法

题目大意

有一个 \( p\times q \) 的网格,需要使用 \( m \) 种颜色为每个格子染色,要求任意两个上下或左右相邻的格子颜色不同。

第一行和第 \( p \) 行的颜色已经给定,求中间各行的合法染色方案数,答案对 \( 998244353 \) 取模。

核心思路

1. 为什么自然地想到按行 DP

限制只发生在相邻格子之间。假设已经完成前 \( r-1 \) 行,现在准备确定第 \( r \) 行,那么新产生的限制只有两类:

  • 第 \( r \) 行内部,相邻两列颜色不能相同;
  • 第 \( r \) 行与第 \( r-1 \) 行之间,同一列颜色不能相同。

更早的行已经不会影响当前转移,因此只需要保存上一行的颜色。这说明可以把一整行压缩成一个状态,再按行进行动态规划。

若一行颜色依次为 \( c_1,c_2,\ldots,c_q \),可以使用 \( m \) 进制整数表示:

\[S=\sum_{j=1}^{q}c_jm^{q-j} \]

其中第 \( j \) 列的颜色可以通过除法和取模得到。

2. 直接枚举相邻两行为什么不合理

最直接的行 DP 是:

  • 枚举上一行状态 \( S \);
  • 枚举当前行状态 \( T \);
  • 检查 \( T \) 内部是否横向合法;
  • 检查 \( S,T \) 的每一列是否纵向不同。

如果直接枚举全部状态,一行有 \( m^q \) 种可能,相邻两行的状态对就有 \( m^{2q} \) 个。

即使只保留横向合法的行,一行的合法状态数仍为:

\[K=m(m-1)^{q-1} \]

于是每一层仍可能需要枚举 \( K^2 \) 对状态。以 \( m=4,q=8 \) 为例:

\[K=4\cdot3^7=8748 \]

仅一层就有七千多万对状态。更关键的是,其中大量状态对会在检查第一、第二列时就发现冲突,却仍然被完整枚举出来。

问题不在于“状态压缩”本身,而在于我们一次完成了整行到整行的大转移。既然两个状态是否兼容是逐列判断的,就应该把一次大转移拆成 \( q \) 次小转移。

3. 轮廓线状态如何形成

处理第 \( r \) 行时,不一次生成完整的新行,而是从左到右逐格填色。

假设当前已经填完新一行的前 \( c \) 列,状态中保存的是:

\[[\text{第 }r\text{ 行的 }1\sim c\text{ 列}] + [\text{第 }r-1\text{ 行的 }c+1\sim q\text{ 列}] \]

也就是说,状态左边已经是当前行,右边仍然是上一行。两部分的分界就是轮廓线。

准备填写第 \( c+1 \) 列时:

  • 状态中这一列原来的颜色,是正上方格子的颜色;
  • 把这一位替换为新颜色,就相当于把轮廓线向右推进一格。

设这一位的权值为 \( w=m^{q-c-1} \),原颜色为 \( col \),新颜色为 \( ncol \),替换后的状态为:

\[ns=s-col\cdot w+ncol\cdot w \]

只要保证 \( ncol\ne col \),就满足了当前格子与上方格子的纵向限制。

两份代码的区别,主要在于横向限制什么时候检查:

  • 第一份代码先完成整行,再统一判断这一行是否横向合法;
  • 第二份代码在选择当前格颜色时,立即检查它是否与左侧颜色不同。

方法一:迭代式轮廓线 DP

1. 预处理横向合法的整行状态

第一份代码先枚举 \( [0,m^q) \) 内的所有状态,逐位检查相邻颜色是否不同,把合法状态保存到 sta

这里的 sta 只在一行全部处理完成后使用。轮廓线位于一行内部时,状态同时包含新旧两行的颜色,不能要求整个状态本身是一行合法染色。

2. DP 状态

在开始处理某一行之前:

\[dp[s]=\text{上一行颜色状态为 }s\text{ 的方案数} \]

进入当前行的逐列转移后,s 的含义逐渐改变:

  • 已处理的列保存当前行颜色;
  • 未处理的列保存上一行颜色。

初始第一行已经固定,因此:

\[dp[fir]=1 \]

其中 fir 是第一行的压缩状态。

3. 单个格子的转移

处理第 \( c \) 列时,代码先从状态中取出这一列原来的颜色:

int col=s/pw[q-c-1]%m;

这个颜色就是当前格子上方的颜色。枚举新颜色 ncol,只要满足:

\[ncol\ne col \]

就可以替换这一位:

int ns=s-col*pw[q-c-1]+ncol*pw[q-c-1];
ndp[ns]=(ndp[ns]+dp[s])%mod;

这样一次只解决一个格子的纵向限制,把原来“枚举两行状态”的转移拆成了逐列转移。

4. 为什么最后还要用 sta 过滤

第一份代码在逐列选择颜色时没有立即判断左侧颜色,因此中间会暂时保留一些横向不合法的当前行。

当这一行的最后一列处理完成后,状态已经完全变成当前行。此时只保留 sta 中的状态:

memset(dp,0,sizeof(dp));
for(int s:sta) dp[s]=ndp[s];

所有横向存在相同相邻颜色的状态都会被删掉。

这种写法的优点是转移结构非常统一;缺点是某个前缀一旦已经横向冲突,代码仍会继续完成这一行,直到行末才将它删除。

5. 结束状态

依次完成第 \( 2,3,\ldots,p \) 行后,dp[lst] 就是最后一行恰好等于给定状态 lst 的方案数。

6. 复杂度

记 \( M=m^q \)。

  • 预处理合法行状态的时间复杂度:\( O(qM) \);
  • DP 时间复杂度:\( O(pqmM)=O(pq,m^{q+1}) \);
  • 空间复杂度:\( O(M) \)。

相比 \( O(pq,m^{2q}) \) 的整行两两枚举,指数部分从 \( 2q \) 降到了 \( q+1 \)。

7. 关键变量

  • pw[i]:\( m^i \),用于提取或修改某一列颜色;
  • M:一行状态总数 \( m^q \);
  • fir,lst:给定的第一行、最后一行压缩状态;
  • sta:所有横向相邻颜色不同的完整行状态;
  • dp[s]:当前轮廓状态 s 的方案数;
  • ndp[s]:处理下一个格子后的方案数。

8. 完整代码一:滚动数组写法

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
const int mod=998244353;
const int N=7e4+5;
int p,q,m,M;
int a[10],b[10];
int pw[10];
int dp[N],ndp[N];
signed main(){
	ios::sync_with_stdio(false);
	cin.tie(nullptr);
	cin>>p>>q>>m;
	int fir=0,lst=0;
	for(int i=1;i<=q;i++){
		cin>>a[i];
		fir=fir*m+a[i];
	}
	for(int i=1;i<=q;i++){
		cin>>b[i];
		lst=lst*m+b[i];
	}
	pw[0]=1;
	for(int i=1;i<=q;i++) pw[i]=pw[i-1]*m;
	M=pw[q];
	vector<int> sta;//所有满足左右相邻颜色不同的状态
	for(int s=0;s<M;s++){
		int x=s,pre=-1;
		bool ok=1;
		for(int i=1;i<=q;i++){
			int col=x%m;
			x/=m;
			if(col==pre){
				ok=false;
				break;
			}
			pre=col;
		}
		if(ok) sta.push_back(s);
	}
	if(p==1){cout<<(fir==lst)<<endl;return 0;}
	memset(dp,0,sizeof(dp));
	dp[fir]=1;
	for(int r=2;r<=p;r++){
		for(int c=0;c<q;c++){
			memset(ndp,0,sizeof(ndp));
			for(int s=0;s<M;s++){
				if(dp[s]==0) continue;
				int col=s/pw[q-c-1]%m;//原状态第 c+1 列的颜色
				for(int ncol=0;ncol<m;ncol++){
					if(ncol==col) continue;
					int ns=s-col*pw[q-c-1]+ncol*pw[q-c-1];
					ndp[ns]=(ndp[ns]+dp[s])%mod;
				}
			}
			if(c<q-1) memcpy(dp,ndp,sizeof(dp));
			else{
				memset(dp,0,sizeof(dp));
				for(int s:sta) dp[s]=ndp[s];
			}
		}
	}
	cout<<dp[lst]<<endl;
	return 0;
}

方法二:记忆化搜索式轮廓线 DP

第一份代码在行末统一过滤横向限制,第二份代码则更进一步:填每个格子时,同时检查左侧和上方,因此根本不会生成已经确定不合法的前缀。

1. DFS 状态

dfs(row,col,sta) 表示:

当前准备填写第 row 行第 col 列,轮廓状态为 sta 时,后续共有多少种合法方案。

在进入这个状态时:

  • sta 中第 col 列仍是上一行的颜色;
  • col-1 列已经替换成当前行的颜色;
  • mp[row-1][col] 是上方颜色;
  • mp[row][col-1] 是左侧颜色。

代码把轮廓状态固定使用四进制保存,因为颜色数不超过 \( 4 \):

\[sta=\sum_{j=1}^{q}c_j4^{q-j} \]

所以:

fac[i]=fac[i-1]*4;

当 \( m<4 \) 时,数字 \( m,m+1,\ldots,3 \) 不会被实际枚举,只是状态数组仍按照 \( 4^q \) 开设。

2. 枚举当前格子的颜色

当前格子颜色 i 必须同时满足:

\[i\ne mp[row][col-1] \]

以及:

\[i\ne mp[row-1][col] \]

对应代码:

if(i!=mp[row][col-1]&&i!=mp[row-1][col])

第一个条件保证横向不同,第二个条件保证纵向不同。合法后把状态中当前列的旧颜色替换为 i

\[nsta=sta-old\cdot4^{q-col}+i\cdot4^{q-col} \]

其中 old=mp[row-1][col]

如果当前列是最后一列,进入下一行第一列;否则继续处理当前行下一列。

3. 为什么 row==p 时直接检查最后一行

最后一行已经给定,不需要再枚举颜色。

当搜索推进到 row==p 时,第 \( p-1 \) 行已经完整确定。此时只需要逐列检查给定的第 \( p \) 行是否与第 \( p-1 \) 行纵向冲突:

if(row==p){
	for(int i=1;i<=q;i++){
		if(mp[p][i]==mp[p-1][i]) return 0;
	}
	return 1;
}

题目通常保证给定的首行和末行本身横向合法;如果没有这个保证,还需要在进入 DP 前单独检查两行内部是否存在相邻同色。

4. 记忆化为什么只需要 row,col,sta

后续转移只关心:

  • 当前处理到哪个格子;
  • 轮廓线上每一列的颜色。

更早的染色方案不会再影响未来。虽然代码通过 mp 读取左侧和上方颜色,但这两个颜色都已经被 sta 唯一确定,因此相同的 row,col,sta 一定具有相同答案,可以记忆化。

5. 复杂度

状态上界为 \( p\cdot q\cdot4^q \),每个状态枚举 \( m \) 种颜色:

  • 时间复杂度:\( O(pqm4^q) \);
  • 空间复杂度:\( O(pq4^q) \)。

实际搜索只会访问满足局部限制的状态,因此通常比这个上界小。不过三维 dp 数组占用较大;第一份滚动数组写法的空间更小。

6. 关键变量

  • mp[row][col]:搜索路径中对应格子的颜色;
  • fac[i]:\( 4^i \),用于修改四进制状态;
  • sta:当前轮廓线上各列的颜色;
  • row,col:当前准备填写的位置;
  • dp[row][col][sta]:该轮廓状态的记忆化答案。

7. 完整代码二:记忆化搜索写法

#include<bits/stdc++.h>
using namespace std;
const int mod=998244353;
#define endl '\n'
int p,q,m;
int mp[105][10],fac[10];
int dp[105][9][65537];
int dfs(int row,int col,int sta){
	if(dp[row][col][sta]!=-1) return dp[row][col][sta];
	if(row==p){
		for(int i=1;i<=q;i++){
			if(mp[p][i]==mp[p-1][i]) return 0;
		}
		return 1;
	}
	int sum=0;
	for(int i=0;i<m;i++){
		if(i!=mp[row][col-1]&&i!=mp[row-1][col]){
			mp[row][col]=i;
			int nsta=sta-mp[row-1][col]*fac[q-col]+i*fac[q-col];
			if(col==q) sum=(sum+dfs(row+1,1,nsta))%mod;
			else sum=(sum+dfs(row,col+1,nsta))%mod;
		}
	}
	return dp[row][col][sta]=sum;
}
signed main(){
	ios::sync_with_stdio(false);
	cin.tie(nullptr);
	memset(dp,-1,sizeof(dp));
	cin>>p>>q>>m;
	fac[0]=1;
	for(int i=1;i<=q;i++) fac[i]=fac[i-1]*4;
	int st=0;
	for(int i=1;i<=q;i++){
		cin>>mp[1][i];
		st=st*4+mp[1][i];
	}
	for(int i=1;i<=q;i++) cin>>mp[p][i];
	for(int i=1;i<=p;i++) mp[i][0]=-1;
	if(p==1) cout<<1<<endl;
	else cout<<dfs(2,1,st)<<endl;
	return 0;
}

两种写法的关系

对比项 方法一:迭代式 方法二:记忆化搜索式
轮廓状态 左边为当前行,右边为上一行 相同
横向限制 行末用 sta 统一过滤 填格子时立即检查左侧
纵向限制 替换颜色时检查 填格子时检查上方
状态进制 \( m \) 进制,更紧凑 固定四进制,写法直接
空间 \( O(m^q) \) \( O(pq4^q) \)
实现特点 循环清晰、空间较小 思路直观、自动跳过局部非法状态

本质上,两份代码没有改变 DP 所依赖的信息:都只保存轮廓线上 \( q \) 个格子的颜色。区别只是使用循环还是递归,以及横向非法状态是在行末删除,还是在产生时直接剪掉。

正确性说明

轮廓线 DP 始终维护以下不变量:

轮廓线左侧的格子已经属于当前行,右侧的格子仍属于上一行;轮廓线经过的每一列颜色都完整保存在状态中。

处理当前格时,上方颜色仍位于状态的当前列,左侧颜色已经写入状态的前一列。因此只需要检查这两个相邻格,就能保证所有新产生的边合法。

当这一位被新颜色替换后,旧的上方颜色不会再影响后续,可以安全丢弃。轮廓线不断右移,完成一行后,状态恰好变为整行颜色;继续处理下一行即可。

每条网格边都会在其右端点或下端点被填写时检查一次,不会遗漏,也不会重复决定。因此 DP 统计的恰好是所有满足相邻颜色不同、且首末行等于给定状态的染色方案。

边界与实现注意

  1. 两份代码都默认 \( m^q\le 65536 \),这与 \( q\le8,m\le4 \) 的状态规模对应。
  2. 两份代码默认给定的第一行、最后一行本身横向合法;若题面不保证,需要预先检查。
  3. 第一份代码对 \( p=1 \) 比较 fir==lst;第二份代码直接输出 1,等价于默认两次给出的同一行一定一致。若题面不保证,应额外比较。
  4. 第二份代码固定使用四进制,不能把 fac[i]=fac[i-1]*4 随意改成其他值;若改成 \( m \) 进制,状态数组和所有取位逻辑也要同步修改。
  5. 第一份代码只有完整行才能用 sta 判断合法,处理中间列时的混合状态不能直接套用 sta
posted @ 2026-07-20 19:31  艾拉别哭  阅读(17)  评论(0)    收藏  举报