题面是 2×N 骨牌覆盖的斐波那契递推;随题测试数据实为整数划分,主解改用完全背包计数以匹配评测语义。
OJ: roj
题目 ID: 1677
难度:普及+/提高-
标签:动态规划递推计数完全背包python
创建: 2026-10-01 02:14
更新: 2026-10-07 13:50
形式化题目
本题的材料里存在两套互不相同的数学结构,先把它们分别写清楚。
题面语义:给定整数
随题测试数据语义:给定整数
前者是骨牌覆盖计数(ROJ 官方标签「动态规划、状态压缩」与它一致),
后者是经典的整数划分计数。两者的关系见 ## 数据说明。
铺法一:三块都竖放 铺法二:第 1 列竖放,后两列上下各横放一块
A B C A B B
A B C A C C
铺法三:前两列上下各横放一块,第 3 列竖放
A A C
B B C这三类恰好对应递推的两个分支:铺法一、二的最左列都被一块竖放的骨牌独占
(剩下
解法总览
本题的两个解法解决不是同一个问题,没有"朴素 → 优化"的递进关系:
- 解法一按题面语义求解:
棋盘被 骨牌铺满的方案数,答案是斐波那契型递推。 参考实现放在 domino.py。 - 解法二按随题测试数据的实际语义求解:
分成 份正整数的无序划分方案数, 用完全背包计数。这是本题的正式主解,放在 main.py。
之所以两个都给出:只看题面会写出解法一,但它与随题数据(整数划分)对不上; 只看数据会失去题面想教的内容。两个解法都列出,读者才能同时理解题意和数据。
解法一:题面语义—— 骨牌覆盖
思路
朴素想法是把棋盘当成
瓶颈在于重复:同一个结果会被不同的放置顺序反复枚举。棋盘是规则长条, 铺放可以按列从左往右推进,一旦前若干列被铺满,剩余问题只取决于"还剩多少列", 与前面具体怎么铺的细节无关。于是状态只需一个参数。
优化思路:只看最左边一列(前面列已全部铺满)。设左上是
- 在正下方
,即竖放:占满第 1 列,问题缩小为 ; - 在右侧
,即横放:此时 也必须被覆盖,而它的另一格只能是 ( 越界、 已被占用),于是上下两块横放的骨牌一起占满前两列, 问题缩小为 。
两种情形互斥且穷尽了
其中
递推数值从
代码
题面语义的参考实现(不是本题的提交文件;提交解见解法二)。
#!/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-10-01 02:17
# update_at: 2026-10-01 02:33
#
# 本题面语义(2×N 棋盘用 1×2 骨牌铺满)的参考实现,对应 index.md 的「解法一」。
# 注意:随题测试数据实际考查的是整数划分,提交解必须用 main.py。
import sys
def count_tilings(width: int) -> int:
"""求 2×width 棋盘被 1×2 骨牌铺满的方案数(滚动变量,只保留前两项)。"""
prev, cur = 1, 1 # f(0) = 1(空棋盘)、f(1) = 1(竖放一块)
for _ in range(width):
prev, cur = cur, prev + cur # f(i) = f(i-1) + f(i-2)
return prev
def solve() -> None:
width = int(sys.stdin.buffer.read().split()[0]) # 题面只说"2×N 的棋盘",N 是唯一输入
print(count_tilings(width))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:
,一次循环 轮、每轮常数次加法。若 达到 级别, 把递推写成矩阵形式 的快速幂可降为 ; 题面没有给 的范围,随题数据也不涉及这一层。 - 空间复杂度:
,只用 prev、cur两个滚动变量。
解法二:评测数据语义——整数划分
这一层才是随题测试数据真正考查的问题,
main.py实现的就是它。
思路
朴素想法沿用评测程序 std.cpp 的做法:枚举单调不降的
瓶颈是搜索树里没有子问题复用:处在"已放
优化思路分三步。
- 消掉"每份至少 1"。每份先各分 1,共消耗
;剩下的 可以任意分配, 于是问题等价于把 分成至多 份(可以为 0、可以重复)。若 ,无解,答案为 0。 - 认出这是完全背包。"把
拆成不超过 的部分、每部分可重复"与 "用面额 的硬币凑出 、每种面额可用无限次"是同一个计数: 把一个大小为 的部分记作一枚面额 的硬币即可。 - 确认"不计顺序"被自动满足。外层按面额升序枚举、内层按目标值升序滚动,
每个划分只会沿"面额从小到大"这唯一一条路径被构造出来,
与 不会各算一次。
转移方程就是完全背包的计数形式:
| 已处理面额 | |||||
|---|---|---|---|---|---|
| 初始(还没处理任何面额) | 1 | 0 | 0 | 0 | 0 |
| 1 | 1 | 1 | 1 | 1 | |
| 1 | 1 | 2 | 2 | 3 | |
| 1 | 1 | 2 | 3 | 4 |
行列含义:行是"已经考虑过哪些面额",列是目标值
内层必须升序:dp[x - coin] 用的是本轮刚更新过的值,代表"还能再用一枚同面额硬币",
这正是完全背包与 0/1 背包的唯一区别。写成降序则每种面额最多用一次,
上面
代码
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-10-01 02:17
# update_at: 2026-10-01 02:33
#
# 数据说明:本题源仓库随题的测试数据与评测程序 std.cpp 实际考查的是「整数划分」,
# 而不是题面写的 2×N 骨牌覆盖 —— 上游 roj/1677 的 data/、data.py、std.cpp 与
# roj/1675 字节级相同,输入是 `n k`,std.cpp 数的是把 n 拆成 k 份正整数的方案数。
# 下面的 count_partitions 复刻 std.cpp 的语义,使输出与评测数据逐字节一致;
# 题面语义下的骨牌解法 f(n) = f(n-1) + f(n-2) 见 index.md 的 ### 思路。
import sys
def count_partitions(total: int, parts: int) -> int:
"""求把 total 分成 parts 份正整数(不计顺序)的方案数。
std.cpp 的 dfs 枚举的是"单调不降的 parts 元组",与无序划分是同一计数。
每份至少为 1,先各减 1,问题等价于把 total - parts 用不超过 parts 的数拆分:
这是完全背包的计数版本,面额 1..parts 可重复使用,n 升序滚动即可。
"""
rest = total - parts
if rest < 0:
return 0 # 份数比总和还多,无解
dp = [1] + [0] * rest # dp[0] = 1:拆 0 只有空方案
for coin in range(1, parts + 1):
for used in range(coin, rest + 1):
dp[used] += dp[used - coin] # 再添一枚面额 coin 的方案数
return dp[rest]
def solve() -> None:
total, parts = map(int, sys.stdin.buffer.read().split())
print(count_partitions(total, parts))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-06 01:45
* update_at: 2026-10-06 01:45
*/
#include <cstdio>
typedef long long ll;
// 数据说明:本题源仓库随题的测试数据与评测程序 std.cpp 实际考查的是「整数划分」,
// 而不是题面写的 2×N 骨牌覆盖(上游 1677 的 data/、data.py、std.cpp 与 1675 字节级相同)。
// 输入是 n k,要求把 n 拆成 k 份正整数(不计顺序)的方案数,本题解按此语义实现。
const int MAXN = 1000005; // 随题数据 n<=200,这里留足余量,避免越界
ll dp[MAXN]; // dp[x]:已经考虑过的面额凑出 x 的方案数(完全背包计数)
int main() {
ll n, k;
scanf("%lld %lld", &n, &k);
// 每份至少为 1,先各减 1,余下 rest 用不超过 k 的数拆分(可以重复、可以为 0)
ll rest = n - k;
if (rest < 0) {
printf("0\n");
return 0;
}
dp[0] = 1; // 凑 0 只有空方案
// 外层按面额升序、内层按目标值升序滚动,保证每个划分只沿唯一路径被构造一次
for (ll coin = 1; coin <= k; coin++) {
for (ll used = coin; used <= rest; used++) {
dp[used] += dp[used - coin]; // 再添一枚面额 coin 的方案数
}
}
printf("%lld\n", dp[rest]);
return 0;
}复杂度
- 时间复杂度:
。外层 种面额、内层至多 个目标值,每个单元格一次加法。 代入随题数据上界( 、 ),总操作数不到 次。 - 空间复杂度:
的一维 dp数组,上界 198 个整数,远低于 128 MB 限制。
复杂度对比
| 解法 | 问题 | 时间复杂度 | 空间复杂度 | 与数据的关系 |
|---|---|---|---|---|
一(domino.py) |
与随题数据不符,仅供理解题面 | |||
二(main.py) |
与随题数据一致,是提交解 |
数据说明
本题测试数据与题面不一致
下面记录的是源仓库测试数据的实际状态,不是算法结论。
核对真实数据时发现:ROJ 1677 的题面是「
| 核对项 | 结果 |
|---|---|
data/problem1.in |
106 6 —— 两个整数 n k,不是骨牌题的单个 |
data/problem1.out |
189509 |
std.cpp |
dfs 枚举单调不降的 k 元组并检查剩余,统计的正是 |
std.cpp 与 roj/1675/std.cpp |
字节级相同(md5 均为 c492e1bc35b8a823812f2eb545292e02) |
data.py |
与 roj/1675/data.py 相同:randint(10,200)、randint(3,7) |
data/*.in、data/*.out、data.json |
与 roj/1675 全部逐字节相同 |
| 上游仓库 | raw.githubusercontent.com/rainboyOJ/problems/master/roj/1677/ 也是这一份,不是本地下载故障 |
也就是说,1677 的随题数据疑似是相邻题 1675「数字划分」的数据被一起放进了同一个目录
(1676「骨牌1」目录下也是同一份数据)。用整数划分的递推复算 10 组数据,
与 .out 10/10 全部一致,例如 problem1 的
由此决定了本目录的两个实现口径:
main.py(提交解)实现整数划分,与评测数据逐字节一致,这也是### 代码中 正式主解的位置;### 思路的解法一按题面语义给出骨牌正解(domino.py),它在一份按题面正常生成 的数据上才是正确答案。
给读者的建议
这道题的正解要看你手上的数据是哪一份:按题面自己造
总结
- 题面模型:
棋盘用 骨牌铺满。按最左列的两种盖法分类, 立即得到 , 。 - 数据模型:
分成 份正整数的无序划分。每份先减 1 后等价于 「用面额 的硬币凑出 」,即完全背包计数,转移 。 - 关键实现点:完全背包的内层必须升序,否则每种面额只能用一次,
只会剩下 ,答案变成 1。 - 数据瑕疵:随题数据与
std.cpp均与 1675 字节级相同,考查的是整数划分而非骨牌, 因此提交解按整数划分实现,题面语义的解另附并在此透明说明。 - 复杂度:解法一
时间、 空间;解法二 时间、 空间。