[题解] 河南萌新联赛 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 \) 进制整数表示:
其中第 \( j \) 列的颜色可以通过除法和取模得到。
2. 直接枚举相邻两行为什么不合理
最直接的行 DP 是:
- 枚举上一行状态 \( S \);
- 枚举当前行状态 \( T \);
- 检查 \( T \) 内部是否横向合法;
- 检查 \( S,T \) 的每一列是否纵向不同。
如果直接枚举全部状态,一行有 \( m^q \) 种可能,相邻两行的状态对就有 \( m^{2q} \) 个。
即使只保留横向合法的行,一行的合法状态数仍为:
于是每一层仍可能需要枚举 \( K^2 \) 对状态。以 \( m=4,q=8 \) 为例:
仅一层就有七千多万对状态。更关键的是,其中大量状态对会在检查第一、第二列时就发现冲突,却仍然被完整枚举出来。
问题不在于“状态压缩”本身,而在于我们一次完成了整行到整行的大转移。既然两个状态是否兼容是逐列判断的,就应该把一次大转移拆成 \( q \) 次小转移。
3. 轮廓线状态如何形成
处理第 \( r \) 行时,不一次生成完整的新行,而是从左到右逐格填色。
假设当前已经填完新一行的前 \( c \) 列,状态中保存的是:
也就是说,状态左边已经是当前行,右边仍然是上一行。两部分的分界就是轮廓线。
准备填写第 \( c+1 \) 列时:
- 状态中这一列原来的颜色,是正上方格子的颜色;
- 把这一位替换为新颜色,就相当于把轮廓线向右推进一格。
设这一位的权值为 \( w=m^{q-c-1} \),原颜色为 \( col \),新颜色为 \( ncol \),替换后的状态为:
只要保证 \( ncol\ne col \),就满足了当前格子与上方格子的纵向限制。
两份代码的区别,主要在于横向限制什么时候检查:
- 第一份代码先完成整行,再统一判断这一行是否横向合法;
- 第二份代码在选择当前格颜色时,立即检查它是否与左侧颜色不同。
方法一:迭代式轮廓线 DP
1. 预处理横向合法的整行状态
第一份代码先枚举 \( [0,m^q) \) 内的所有状态,逐位检查相邻颜色是否不同,把合法状态保存到 sta。
这里的 sta 只在一行全部处理完成后使用。轮廓线位于一行内部时,状态同时包含新旧两行的颜色,不能要求整个状态本身是一行合法染色。
2. DP 状态
在开始处理某一行之前:
进入当前行的逐列转移后,s 的含义逐渐改变:
- 已处理的列保存当前行颜色;
- 未处理的列保存上一行颜色。
初始第一行已经固定,因此:
其中 fir 是第一行的压缩状态。
3. 单个格子的转移
处理第 \( c \) 列时,代码先从状态中取出这一列原来的颜色:
int col=s/pw[q-c-1]%m;
这个颜色就是当前格子上方的颜色。枚举新颜色 ncol,只要满足:
就可以替换这一位:
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 \):
所以:
fac[i]=fac[i-1]*4;
当 \( m<4 \) 时,数字 \( m,m+1,\ldots,3 \) 不会被实际枚举,只是状态数组仍按照 \( 4^q \) 开设。
2. 枚举当前格子的颜色
当前格子颜色 i 必须同时满足:
以及:
对应代码:
if(i!=mp[row][col-1]&&i!=mp[row-1][col])
第一个条件保证横向不同,第二个条件保证纵向不同。合法后把状态中当前列的旧颜色替换为 i:
其中 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 统计的恰好是所有满足相邻颜色不同、且首末行等于给定状态的染色方案。
边界与实现注意
- 两份代码都默认 \( m^q\le 65536 \),这与 \( q\le8,m\le4 \) 的状态规模对应。
- 两份代码默认给定的第一行、最后一行本身横向合法;若题面不保证,需要预先检查。
- 第一份代码对 \( p=1 \) 比较
fir==lst;第二份代码直接输出1,等价于默认两次给出的同一行一定一致。若题面不保证,应额外比较。 - 第二份代码固定使用四进制,不能把
fac[i]=fac[i-1]*4随意改成其他值;若改成 \( m \) 进制,状态数组和所有取位逻辑也要同步修改。 - 第一份代码只有完整行才能用
sta判断合法,处理中间列时的混合状态不能直接套用sta。

浙公网安备 33010602011771号