多米诺和托米诺平铺 图解题解
这道题到底在问什么
- 输入
- n=1
- 输出
- 1(只能竖放一块多米诺)
- 输入
- n=3
- 输出
- 5
- 输入
- n=4
- 输出
- 11
最优解:为什么这么做
一句话答案:LeetCode 790 多米诺和托米诺平铺求 2×n 棋盘铺满的方案数:dp[i] 记铺满前 i 列的方案,联立「全铺满/差一角」两状态消元得 dp[i]=2·dp[i-1]+dp[i-3],每步取模,时间 O(n)、空间可压 O(1)。
一块 2×n 棋盘,多米诺加 L 形托米诺能铺出多少种花样
棋盘是 2 行 n 列。两种砖:2×1 的多米诺,横竖都行;L 形的托米诺,占三格、缺一角。用任意多块不重叠铺满,问有几种铺法,结果对 1e9+7 取模(取模=除以 1e9+7 只留余数,防数字大到存不下;1e9+7 是常用大质数)。n=1 只能竖一块多米诺得 1 种,n=3 有 5 种,n=4 有 11 种。
摆法随 n 指数翻,逐一枚举为什么数不完
把砖一块块摆、摆满就记一种,每往右推一列就分叉一次,铺法数随 n 指数级膨胀,n 一大数不过来;铺到一半的局面在算后面时又被从头重算。把「铺满前 i 列有几种」这个子问题算一次存下来复用,就是动态规划(把「铺满前 i 列」的答案存下来,后面直接取)。
只记「铺满前 i 列」,为什么推着推着会卡住
定义 dp[i] 为铺满 2×i 棋盘的方案数,答案是 dp[n]。可只盯它会卡壳:从左往右铺时两列分界线不总齐平。一块 L 形托米诺会让边界凸出一个角——前面铺满了,第 i 列却只填一格、另一格空着。这种「差一个角」的半满局面 dp[i] 装不下,得再补一个状态 gap[i]:前 i 列铺满、只在末列凸出一角的方案数。
补上「差一个角」的状态,递推怎么就成了 2·前一项加前第三项
给两个状态各列转移。铺满第 i 列有三条收尾方式:dp[i-1] 右边竖插一块多米诺、dp[i-2] 右边平放两块横多米诺、或拿托米诺补一个带角的 gap 局面(gap 有两种镜像方向,凑出系数 2)。
把两条式子联立、代入消去 gap(联立=把两条式子摆一起,消元=用代入法把 gap 约掉,只剩 dp),留下只含 dp 的递推 dp[i]=2·dp[i-1]+dp[i-3]。要紧的是:系数 2 和「前第三项」是消元后的代数结果,不对应具体铺法,别硬找图形对应。gap 这个辅助状态是这题的枢纽,少了它下面的消元根本联立不起来。
拿 n=3、n=4 亲手把 dp 推出 5 和 11
先摆好三个边界:dp[0]=1(空棋盘算一种)、dp[1]=1(宽 1 只能竖一块)、dp[2]=2(宽 2 有两竖和两横)。递推要读 dp[i-3],起手须凑齐前三项。套 dp[i]=2·dp[i-1]+dp[i-3]:dp[3]=2×dp[2]+dp[0]=2×2+1=5,对上题面 n=3;dp[4]=2×dp[3]+dp[1]=2×5+1=11,对上 n=4。逐格推到 dp[8],整条数组是 1、1、2、5、11、24、53、117、258。
漏掉系数 2,dp[4] 为什么会算成 6
从第 3 项推到第 n 项一趟线性循环,每步几次乘加取模,时间 O(n);数组长 n+1,空间 O(n),回看前一项和前第三项即可,压成三个滚动变量降到 O(1)。
漏系数 2 最常见,写成 dp[i]=dp[i-1]+dp[i-3],dp[4] 会算成 6 而不是 11。取模也别忘——2×dp[i-1]+dp[i-3] 一次加法就能顶破 32 位翻成负数,C++/Java 得用 64 位、每步取模。边界还得铺满前三项:第 3 项要读 dp[0] 又得先有 dp[2],只铺 dp[0]、dp[1] 就开推会读到没初始化的 0。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3记住这一句:当前 = 前一项×2 + 前第三项,边界 dp[0]=1、dp[1]=1、dp[2]=2。系数 2 与前第三项来自 full/gap 状态消元,下面每一帧都直接套这个递推。
- 4先填好三个边界:空棋盘 dp[0]=1(什么都不放算 1 种)、宽度 1 只能竖一块 dp[1]=1、宽度 2 有「两竖」和「两横」共 dp[2]=2。绿色这三格是递推的起点,后面 0 都会被逐格算出。
- 5推下标 3(棋盘宽 3):紫色 dp[3] 是这一步要算的,它只看两处绿色:紧邻的 dp[2]=2 和往前数第三个 dp[0]=1。
- 6套公式:dp[3] = 2×dp[2] + dp[0] = 2×2 + 1 = 5。系数 2 与前第三项是 full/gap 状态消元后的结果,这里直接用已消元的递推算值。
- 7dp[3] 落格为 5(这里数不大不触发取模,n 大时每步都要 % 1e9+7 防溢出)。蓝色是已算好的前缀,接着推下一格。
- 8推下标 4(棋盘宽 4):紫色 dp[4] 是这一步要算的,它只看两处绿色:紧邻的 dp[3]=5 和往前数第三个 dp[1]=1。
- 9套公式:dp[4] = 2×dp[3] + dp[1] = 2×5 + 1 = 11。系数 2 与前第三项是 full/gap 状态消元后的结果,这里直接用已消元的递推算值。
- 10dp[4] 落格为 11(这里数不大不触发取模,n 大时每步都要 % 1e9+7 防溢出)。蓝色是已算好的前缀,接着推下一格。
- 11推下标 5(棋盘宽 5):紫色 dp[5] 是这一步要算的,它只看两处绿色:紧邻的 dp[4]=11 和往前数第三个 dp[2]=2。
- 12套公式:dp[5] = 2×dp[4] + dp[2] = 2×11 + 2 = 24。系数 2 与前第三项是 full/gap 状态消元后的结果,这里直接用已消元的递推算值。
- 13dp[5] 落格为 24(这里数不大不触发取模,n 大时每步都要 % 1e9+7 防溢出)。蓝色是已算好的前缀,接着推下一格。
- 14推下标 6(棋盘宽 6):紫色 dp[6] 是这一步要算的,它只看两处绿色:紧邻的 dp[5]=24 和往前数第三个 dp[3]=5。
- 15套公式:dp[6] = 2×dp[5] + dp[3] = 2×24 + 5 = 53。系数 2 与前第三项是 full/gap 状态消元后的结果,这里直接用已消元的递推算值。
- 16dp[6] 落格为 53(这里数不大不触发取模,n 大时每步都要 % 1e9+7 防溢出)。蓝色是已算好的前缀,接着推下一格。
- 17推下标 7(棋盘宽 7):紫色 dp[7] 是这一步要算的,它只看两处绿色:紧邻的 dp[6]=53 和往前数第三个 dp[4]=11。
- 18套公式:dp[7] = 2×dp[6] + dp[4] = 2×53 + 11 = 117。系数 2 与前第三项是 full/gap 状态消元后的结果,这里直接用已消元的递推算值。
- 19dp[7] 落格为 117(这里数不大不触发取模,n 大时每步都要 % 1e9+7 防溢出)。蓝色是已算好的前缀,接着推下一格。
- 20推下标 8(棋盘宽 8):紫色 dp[8] 是这一步要算的,它只看两处绿色:紧邻的 dp[7]=117 和往前数第三个 dp[5]=24。
- 21套公式:dp[8] = 2×dp[7] + dp[5] = 2×117 + 24 = 258。系数 2 与前第三项是 full/gap 状态消元后的结果,这里直接用已消元的递推算值。
- 22dp[8] 落格为 258(这里数不大不触发取模,n 大时每步都要 % 1e9+7 防溢出)。蓝色是已算好的前缀,到这里 dp[8] 已是最后一格,接着回看整条数组。
- 23推到底:dp[8] = 258,就是 2×8 棋盘的全部铺法数。整条 dp 数组 [1, 1, 2, 5, 11, 24, 53, 117, 258] 一路只靠「前一项×2 + 前第三项」生长出来。
⚠️ 容易写错的地方
✗ 错:忘记每步取模,用 int 直接累乘
✓ 对:每步 % 1e9+7,且 C++/Java 用 long 中间量
取模后单项 dp[i−1] 虽不溢出,但整项 2×dp[i−1]+dp[i−3] 这次加法最大约 3.0e9,超出 32 位 int 上限会得到错误负值;必须边推边取模并用 64 位中间量保存这次加法
✗ 错:把递推记成 dp[i]=dp[i−1]+dp[i−3]
✓ 对:是 2·dp[i−1]+dp[i−3]
前一项要乘 2,漏掉系数会算出比真实值小的结果:比如在 dp[4] 这一步漏掉乘 2,会用 dp[3]+dp[1]=5+1 把正确的 11 算成 6
✗ 错:边界只设 dp[0]、dp[1] 就开始推 i=3
✓ 对:必须先有 dp[0]、dp[1]、dp[2] 三项
递推用到 dp[i−3],i=3 时要读 dp[0],i=2 这项也必须先给定,否则下标读到未初始化的 0
完整代码(Python / C++ / Java)
Python
class Solution:
def numTilings(self, n: int) -> int:
MOD = 10**9 + 7
if n <= 2:
return n
dp = [0] * (n + 1)
dp[0], dp[1], dp[2] = 1, 1, 2
for i in range(3, n + 1):
dp[i] = (2 * dp[i-1] + dp[i-3]) % MOD
return dp[n]C++
#include <vector>
using namespace std;
class Solution {
public:
int numTilings(int n) {
const int MOD = 1000000007;
if (n <= 2) return n;
vector<long long> dp(n + 1);
dp[0] = dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; ++i) dp[i] = (2 * dp[i-1] + dp[i-3]) % MOD;
return dp[n];
}
};Java
import java.util.*;
class Solution {
public int numTilings(int n) {
int MOD = 1_000_000_007;
if (n <= 2) return n;
long[] dp = new long[n + 1];
dp[0] = dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; i++) dp[i] = (2 * dp[i - 1] + dp[i - 3]) % MOD;
return (int) dp[n];
}
}复杂度
时间
O(n)
从 i=3 推到 n 一遍循环,每步常数次乘加与取模
空间
O(n)
用了长度 n+1 的 dp 数组;因只依赖前 1、前 3 项,可压成 3 个滚动变量降到 O(1)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 多米诺和托米诺平铺 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
空间能不能从 O(n) 优化到 O(1)?+
可以。dp[i] 只用到 dp[i-1] 和 dp[i-3],更早的项算完再也用不上。用三个滚动变量分别存 dp[i-3]、dp[i-2]、dp[i-1],每步算出 (2·dp[i-1]+dp[i-3])%MOD 作为新的当前项,再整体左移一位——最旧的丢掉、新值接上。空间降到 O(1),时间仍 O(n),是面试常见的追问点。
为什么系数偏偏是 2、又要加前第三项,能在棋盘上直接看出来吗?+
直接看不出来。系数 2 和前第三项不是某种铺法「恰好有 2 种」或「跨 3 列」的图形,而是把辅助状态 gap(前 i 列铺满、末列凸出一角)和 dp 两条转移联立、把 gap 消掉后剩下的代数结果。想验证就手推前几项:dp[3]=5、dp[4]=11、dp[5]=24,和真实枚举一致即可。硬去棋盘上给 2 和「前第三项」找图形对应,只会越想越拧。
这题和斐波那契那类一维递推是一回事吗?+
骨架同类、边界不同。斐波那契是 dp[i]=dp[i-1]+dp[i-2]、两个种子;这题是 dp[i]=2·dp[i-1]+dp[i-3]、三个种子,多一个系数、往前多够一项。它们都属于「当前项由固定几个前项线性组合得到」的一维递推,会写斐波那契的自底向上循环,这题只是把转移式和边界换掉。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 多米诺和托米诺平铺 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。