「一本通 5.4 例 2」牧场的安排

GitHub跳转原题关系图返回列表

按行逐行做状态压缩 DP:每行种植方案压成无相邻 1 的位掩码,转移只保留与上一行按位与为 0 的掩码,滚动计数并对 10^8 取模。

OJ: roj

题目 ID: 1593

难度:普及+/提高-

标签:状压 DP动态规划网格位运算python

创建: 2026-09-30 20:38

更新: 2026-10-07 13:50

后置题目
roj 3153炮兵阵地B 直接沿用 A 的「按行推进 + 位运算筛行内合法掩码 + 用该行地形掩码过滤可选方案」这一步,只把行内条件加上列距 2,并把跨行状态从一行扩到两行。roj 3152「Mondriaan's Dream」 蒙德里安的梦想A 教的是「按行推进 + 行掩码状态、行间只靠两掩码的按位关系传递约束」,B 的阶段与状态设计直接沿用这一骨架(f_{j,s} 与代码 g(j,s) 都是行编号加 N 位掩码,并用 s & b 做列冲突判定),只是把掩码从「本行种植」改成「竖牌伸出列」,再叠加行内逐格 dfs 铺牌这一额外流程。roj 1596「一本通 5.4 练习 3」动物园B 沿用 A 教的「按推进方向分段、把影响未来的局部信息压成掩码、只保留最近窗口做滚动 DP」这一状态设计:A 用行掩码只记相邻两行,B 换成 5 位窗口状态只记最近 5 个围栏,再叠加按窗口起点结算得分与枚举初窗口破环。luogu P1896[SCOI2005] 互不侵犯B 的 main.cpp 里 `if ((cur & prev) || ((cur << 1) & prev) || ((cur >> 1) & prev)) continue;` 直接复用了 A 教的「把一行压成掩码、行间同列冲突用 `t & s == 0` 这一条位运算判定」的步骤,只在外面每人多带一个国王数维度并补上左上/右上两条冲突式,难度由普及+/提高- 升到普及+/提高,方向与台阶都成立。luogu P1123取数游戏B 直接复用 A 的合法行掩码横向不相邻判定 `(mask&(mask<<1))==0` 与逐行按位相容转移,只在其上补两条对角位移约束。

形式化题目

给定 MM 行 NN 列的 0/1 网格(1⩽M,N⩽121 \leqslant M, N \leqslant 12),值为 11 的格子称为肥沃格,值为 00 的格子贫瘠、不可使用。求满足下列两个条件的格子集合个数:

  1. 集合只由肥沃格组成;
  2. 集合中任意两格不共享公共边(上下或左右相邻)。

空集合(一格不种)也计入答案,输出答案对 10810^8 取模的结果。

这就是网格图上的独立集计数:有边当且仅当两格四相邻,贫瘠格等价于"必须不选"的顶点。

样例网格(M=2M = 2、N=3N = 3)与它的掩码编码如下。把每行的选择压成 NN 位二进制,低位对应左侧第 0 列:

行 土地状态 肥沃掩码 fertile 本行允许的合法掩码
第 0 行 1 1 1 111 000 001 010 100 101
第 1 行 0 1 0 010 000 010

表中"合法掩码"须同时满足两点:不含横向相邻的两个 1(如 011、110 被排除),且只踩肥沃格(第 1 行的 001、100 落在贫瘠格上被排除)。样例答案为 99,推演见下文。

正解

思路

朴素做法与瓶颈。 最直接的想法是枚举全部 2MN2^{MN} 个格子子集,再逐对检查是否相邻:MNMN 最大 144144,21442^{144} 完全不可行;改成"逐格选/不选 + 与已选格相邻则剪枝"的回溯,最坏复杂度仍是指数级。瓶颈在于它把 144 个格子当成互相关联的维度——但相邻关系只发生在同一行内和上下两行之间,与更远的行毫无关系,状态里根本不必记住那么久的历史。

观察一:按行推进,把一行压成一个掩码。 先决定第 0 行种哪些格,再决定第 1 行……第 M−1M-1 行。第 rr 行的选择压成 NN 位掩码 ss(第 cc 位为 1 表示格 (r,c)(r, c) 种草):

  • 横向冲突只在掩码内部:ss 合法   ⟺  \iff s & (s << 1) == 0,即没有两个横向相邻的 1;
  • 贫瘠格只在本行过滤:设 fertile[r] 为第 rr 行肥沃格掩码,则要求 s & ~fertile[r] == 0。

N⩽12N \leqslant 12,无相邻 1 的掩码只有 377 个(N=12N = 12 时,计数恰为斐波那契数 FN+2F_{N+2}),远小于 212=40962^{12} = 4096。

观察二:跨行约束只是两掩码相容。 相邻两行只有"同列同时种草"才会纵向相邻,因此第 r−1r-1 行掩码 tt 与第 rr 行掩码 ss 相容   ⟺  \iff t & s == 0。三个条件(横向、纵向、贫瘠格)各由一条位运算负责,无需任何几何检查。

状态与转移。 逐行滚动,记

dpr[s]=只考虑第 0∼r 行、且第 r 行掩码恰为 s 的合法方案数, dp_r[s] = \text{只考虑第 } 0 \sim r \text{ 行、且第 } r \text{ 行掩码恰为 } s \text{ 的合法方案数},

其中 ss 自身须无横向相邻且只踩肥沃格,则对 r⩾1r \geqslant 1

dpr[s]=∑t 合法t&s=0dpr−1[t]. dp_r[s] = \sum_{\substack{t \text{ 合法} \\ t \,\&\, s = 0}} dp_{r-1}[t].

初始时把第 0 行上方视为空行:dp={0↦1}dp = \{0 \mapsto 1\}(空掩码唯一且合法),第 0 行的转移用同一个式子、把空掩码当作 tt;处理完第 M−1M-1 行后,所有掩码的方案数求和即为答案——这自然包含了"最后一行不种"乃至全程空方案。每步累加后对 10810^8 取模。

样例推演。 样例的逐行 DP 转移表(掩码写成三位二进制):

步骤 该步之后的 dp(掩码 ↦\mapsto 方案数) 发生了什么
初始(第 0 行上方) 000 ↦\mapsto 1 空行,只有空掩码
处理第 0 行(fertile=111) 000$\mapsto1,‘001‘1,`001`\mapsto1,‘010‘1,`010`\mapsto1,‘100‘1,`100`\mapsto1,‘101‘1,`101`\mapsto$1 5 个合法掩码都只与空掩码相容,各得 1 种
处理第 1 行(fertile=010) 000 ↦\mapsto 5,010 ↦\mapsto 4 本行只许 000、010;000 与上一行 5 个掩码都相容,010 只与不含中列的 000 001 100 101 相容
求和 5+4=95 + 4 = 9 输出 9

读表可以看到转移的两个方向:000 ↦\mapsto 5 是"第 1 行一格不种",上一行怎么种都行;010 ↦\mapsto 4 中上一行的 010 因纵向同列被排除,其余 4 个都可接。合计 99,与样例输出一致。

正确性。 对行数归纳:任一合法方案在每行的掩码必然满足"掩码合法 + 只踩肥沃格 + 与上一行按位与为 0",其贡献恰在对应转移中被累加一次(不漏);而方案由各行掩码唯一确定,不同转移路径对应不同方案(不重)。初始与求和边界把"全程不种"和"末行不种"都计入,故总和即所求。

实现对应。 main.py 中,valid 用一个列表推导一次性筛出无横向相邻的掩码(与行无关);每行的 row_ok 再按 fertile[r] 剔除踩贫瘠格的掩码;双层循环 for prev, cnt in dp.items(): for s in row_ok: if not s & prev: 就是转移式,nxt[s] = (nxt.get(s, 0) + cnt) % MOD 完成求和与取模;dp = nxt 滚动到下一行,最后 sum(dp.values()) % MOD 输出。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 20:39
# update_at: 2026-09-30 20:39

import sys

MOD = 10**8  # 题面要求输出方案数除以 10^8 的余数


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    m, n = next(data), next(data)  # m 行 n 列
    cells = [next(data) for _ in range(m * n)]  # 行优先铺开的全部格子

    # 每行压成 n 位掩码:第 c 位为 1 表示该格肥沃可种草
    fertile = [
        sum(1 << c for c in range(n) if cells[r * n + c])  # 行内逐格置位
        for r in range(m)
    ]

    # 与肥沃度无关的公共候选:n 位内没有横向相邻 1 的掩码(含空掩码 0)
    valid = [s for s in range(1 << n) if not s & (s << 1)]

    dp = {0: 1}  # 逐行推进:上一行的种植掩码 -> 方案数;第 0 行上方视为空行
    for r in range(m):
        row_ok = [s for s in valid if not s & ~fertile[r]]  # 剔除落在贫瘠格上的掩码
        nxt: dict[int, int] = {}
        for prev, cnt in dp.items():
            for s in row_ok:
                if not s & prev:  # 与上一行同列不同时种草,杜绝纵向相邻
                    nxt[s] = (nxt.get(s, 0) + cnt) % MOD
        dp = nxt

    print(sum(dp.values()) % MOD)  # 末行任意掩码都合法,求和即总方案数


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-05 11:23
 * update_at: 2026-10-05 11:23
 */
// main.cpp:按行状压 DP,统计网格上不相邻种草方案数。
#include <cstdio>

typedef long long ll;

const int MAXN = 13;
const ll MOD = 100000000; // 题面要求方案数对 10^8 取模

int m, n;
int fertile[MAXN];            // fertile[r]:第 r 行肥沃格掩码,第 c 位为 1 表示可种草
int valid_mask[1 << MAXN];    // 所有不含横向相邻 1 的掩码
int valid_cnt;

ll dp[1 << MAXN];   // dp[s]:处理到上一行、且上一行种植掩码恰为 s 的方案数
ll nxt[1 << MAXN];  // nxt[s]:处理到本行、且本行种植掩码恰为 s 的方案数

int main() {
    scanf("%d%d", &m, &n);
    for (int r = 0; r < m; r++) {
        int mask = 0;
        for (int c = 0; c < n; c++) {
            int x;
            scanf("%d", &x);
            if (x == 1) {
                mask |= 1 << c; // 第 c 位为 1 表示这一格肥沃
            }
        }
        fertile[r] = mask;
    }

    // 与行无关的公共候选:掩码内部没有两个相邻的 1
    valid_cnt = 0;
    for (int s = 0; s < (1 << n); s++) {
        if ((s & (s << 1)) == 0) {
            valid_mask[valid_cnt] = s;
            valid_cnt++;
        }
    }

    // 初始时把第 0 行上方视为空行,只有空掩码 1 种方案
    for (int s = 0; s < (1 << n); s++) {
        dp[s] = 0;
    }
    dp[0] = 1;

    for (int r = 0; r < m; r++) {
        for (int s = 0; s < (1 << n); s++) {
            nxt[s] = 0;
        }
        for (int i = 0; i < valid_cnt; i++) {
            int s = valid_mask[i];
            if (s & ~fertile[r]) {
                continue; // 该掩码踩到了贫瘠格
            }
            ll sum = 0;
            for (int j = 0; j < valid_cnt; j++) {
                int t = valid_mask[j];
                if (s & t) {
                    continue; // 与上一行同列同时种草,会产生纵向相邻
                }
                sum += dp[t];
            }
            nxt[s] = sum % MOD;
        }
        for (int s = 0; s < (1 << n); s++) {
            dp[s] = nxt[s]; // 滚动到下一行
        }
    }

    // 末行任意合法掩码都可以作为结尾,求和即总方案数(含全程不种)
    ll ans = 0;
    for (int s = 0; s < (1 << n); s++) {
        ans = (ans + dp[s]) % MOD;
    }
    printf("%lld\n", ans);
    return 0;
}

复杂度

  • 时间:预筛合法掩码 O(2N)O(2^N);每行过滤 O(∣V∣)O(|V|)、转移 O(∣V∣2)O(|V|^2),总计 O(2N+M⋅∣V∣2)O(2^N + M \cdot |V|^2),其中 ∣V∣⩽377|V| \leqslant 377。最坏约 12×3772≈1.7×10612 \times 377^2 \approx 1.7 \times 10^6 次字典操作,与 C++ 正解同阶;实测 10 组真实数据单例耗时约 0.020.02 s(OJ 限时 1000 ms)。
  • 空间:valid、row_ok、dp、nxt 均为 O(∣V∣)=O(2N)O(|V|) = O(2^N),上限 377 个字典项,实测峰值约 1515 MB(限制 128 MB)。

总结

  • 相邻约束是局部的:行内只看本行掩码,行间只看上下两行掩码。按行推进 + 位掩码状态,把 21442^{144} 的子集枚举压缩成 MM 层、每层至多 377 个状态的滚动 DP。
  • 三条约束各对应一条位运算:横向不相邻 s & (s << 1) == 0、不踩贫瘠格 s & ~fertile[r] == 0、纵向不相邻 t & s == 0。掩码是"把一行几何冲突翻译成代数条件"的工具。
  • 计数 DP 的两个易错点:初始状态要包含空掩码(否则丢掉不种的方案),结束时要对所有掩码求和而非只取某个状态;模数是 10810^8 而非常见的 109+710^9+7,每次累加后都要取模。