LeetCode 473. 火柴拼正方形
https://leetcode.cn/problems/matchsticks-to-square/description/
一、题意简要概括
给定 \(n \le 15\) 根火柴,每根有一个长度。
要求每根火柴恰好用一次,把它们拼成一个正方形(四条边等长),火柴不能折断,可以拼接。
判断是否可行。
二、关键观察与剪枝思路
1️⃣ 必要条件
- 设火柴总长度为 \(S\) ,如果 \(S \bmod 4 \ne 0\) ,必不可能。
- 正方形每条边长度为\[side = \frac{S}{4} \]
- 若存在某根火柴长度 \(> side\) ,直接返回
false。
2️⃣ 问题转化
等价于:
把所有火柴分成 4 组,每组的长度和都等于
side。
这是一个典型的状态压缩 + DFS 回溯问题(n ≤ 15 非常关键)。
三、搜索策略(DFS + 回溯)
核心思想
- 用一个数组
edge[4]表示当前 4 条边已拼的长度 - 按顺序处理火柴,第
idx根火柴尝试放到 4 条边中的任意一条 - 只要不超过
side就可以尝试 - 最终若所有火柴放完,则成功
重要剪枝技巧 ⭐
1️⃣ 排序 + 先放长的
sort(matchsticks.begin(), matchsticks.end(), greater<int>());
长火柴更“难放”,提前失败可以减少搜索量。
2️⃣ 等价状态剪枝
如果某条边当前长度和另一条边相同,把火柴放在这两条边上是等价的,只需要试一次:
if (i > 0 && edge[i] == edge[i-1]) continue;
这是本题能否通过的关键剪枝。
四、算法复杂度
- 理论最坏: \(O(4^n)\)
- 实际由于排序 + 剪枝,搜索空间被大量削减
- 在 \(n \le 15\) 的限制下完全可行
五、C++ 实现(OI 风格,详细注释)
#include <bits/stdc++.h>
using namespace std;
class Solution {
public:
vector<int> a;
int n, side;
int edge[4]; // 记录四条边当前长度
// dfs(idx):处理第 idx 根火柴
bool dfs(int idx) {
// 所有火柴都已使用
if (idx == n) {
// 由于总和已保证为 4 * side,这里可以直接返回 true
return true;
}
// 尝试把第 idx 根火柴放到四条边中的一条
for (int i = 0; i < 4; i++) {
// 剪枝1:如果当前边放不下
if (edge[i] + a[idx] > side) continue;
// 剪枝2:等价状态剪枝
// 若当前边长度和前一条边相同,则没必要重复尝试
if (i > 0 && edge[i] == edge[i - 1]) continue;
// 放到第 i 条边
edge[i] += a[idx];
if (dfs(idx + 1)) return true;
// 回溯
edge[i] -= a[idx];
}
return false;
}
bool makesquare(vector<int>& matchsticks) {
a = matchsticks;
n = a.size();
long long sum = 0;
for (int x : a) sum += x;
// 总长度必须能被 4 整除
if (sum % 4 != 0) return false;
side = sum / 4;
// 排序(从大到小,极其重要)
sort(a.begin(), a.end(), greater<int>());
// 最大火柴超过边长,必不可能
if (a[0] > side) return false;
memset(edge, 0, sizeof(edge));
return dfs(0);
}
};
六、总结一句话版 🧠
本题本质是 把 ≤15 个数划分为 4 个等和子集,
用 DFS + 排序 + 等价剪枝 即可高效解决。
如果你愿意,我也可以:
- 给你 状态压缩 DP(位运算)版本
- 或手把手画一棵 DFS 搜索树帮你理解剪枝为何有效
Prompt:
状态压缩 DP
Response:
下面专门讲 473. 火柴拼正方形 的「状态压缩 DP」解法,这是这题最经典、也最“竞赛味”的做法。
一、题意简要概括
给定 \(n \le 15\) 根火柴,每根长度为 \(a_i\) 。
要求 每根恰好使用一次,拼成一个正方形,即分成 4 条边,每条边长度相同。
二、核心转化(DP 思维)
设火柴总长度为 \(S\) ,若
直接返回 false。
每条边长度为:
三、状态压缩 DP 设计
1️⃣ 状态定义
用一个二进制数 mask 表示 哪些火柴已经被使用:
mask的第 \(i\) 位为 1:第 \(i\) 根火柴已使用- 状态总数: \(2^n \le 2^{15} = 32768\)
定义:
- 若
mask不可达,则dp[mask] = -1 - 若
dp[mask] = x,表示:- 已经拼完若干整边
- 正在拼一条新边
- 当前边已用了长度 \(x\)
2️⃣ 初始状态
dp[0] = 0;
表示:还没用任何火柴,当前边长度为 0。
四、状态转移方程
从一个合法状态 mask 出发,尝试加入一根未使用的火柴 i:
转移条件
mask的第i位为 0dp[mask] + a[i] <= side
转移规则
new_mask = mask | (1 << i)
if dp[mask] + a[i] < side:
dp[new_mask] = dp[mask] + a[i]
else if dp[mask] + a[i] == side:
dp[new_mask] = 0 // 当前边拼满,开始下一条边
数学含义总结
用
mask中的火柴,已经完整拼好
\(\left\lfloor \frac{\sum(mask)}{side} \right\rfloor\) 条边
当前边进度由dp[mask]表示
五、终止条件
最终目标状态是:
mask = (1 << n) - 1
若:
dp[mask] == 0
说明 恰好拼完 4 条边,返回 true。
六、复杂度分析
- 状态数: \(2^n\)
- 转移:每个状态枚举 \(n\) 根火柴
- 时间复杂度:
- 空间复杂度:
在 \(n \le 15\) 时非常稳定。
七、C++ 实现(OI 风格,详细注释)
#include <bits/stdc++.h>
using namespace std;
class Solution {
public:
bool makesquare(vector<int>& matchsticks) {
int n = matchsticks.size();
long long sum = 0;
for (int x : matchsticks) sum += x;
// 总长度不能被 4 整除
if (sum % 4 != 0) return false;
int side = sum / 4;
// 排序(非必须,但能略微加速)
sort(matchsticks.begin(), matchsticks.end(), greater<int>());
if (matchsticks[0] > side) return false;
int N = 1 << n;
vector<int> dp(N, -1);
// 初始状态
dp[0] = 0;
// 枚举所有状态
for (int mask = 0; mask < N; mask++) {
if (dp[mask] == -1) continue;
// 尝试加入一根未使用的火柴
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) continue;
int nxt = mask | (1 << i);
if (dp[mask] + matchsticks[i] > side) continue;
// 如果当前边正好拼满,下一条边从 0 开始
if (dp[mask] + matchsticks[i] == side)
dp[nxt] = 0;
else
dp[nxt] = dp[mask] + matchsticks[i];
}
}
// 是否能恰好拼完 4 条边
return dp[N - 1] == 0;
}
};
八、DFS vs 状态压缩 DP 对比
| 方法 | 特点 | 推荐度 |
|---|---|---|
| DFS + 剪枝 | 思路直观,代码短 | ⭐⭐⭐ |
| 状态压缩 DP | 思维严谨、稳定不爆 | ⭐⭐⭐⭐ |
九、竞赛常见一句话总结
用
dp[mask]记录当前边已拼长度,
每当长度达到side就“清零换边”,
最终dp[(1<<n)-1] == 0即可。
记忆化状态压缩 dp 写法
class Solution {
public:
vector<int> a; // 存放所有火柴的长度
int n; // 火柴数量 n <= 15
int side; // 正方形每一条边的目标长度 = 总和 / 4
int dp[1<<15]; // 记忆化数组:
// dp[status] = -1 表示未计算
// dp[status] = 0 表示从该状态无法完成
// dp[status] = 1 表示从该状态可以完成
// 主函数:判断是否能用所有火柴拼成正方形
bool makesquare(vector<int>& matchsticks) {
a = matchsticks;
n = matchsticks.size();
// 计算所有火柴的总长度
long long sum = 0;
for (int i = 0; i < n; i++) {
sum += matchsticks[i];
}
// 总长度不能被 4 整除,必然无法拼成正方形
if (sum % 4 != 0) return false;
// 每条边的长度
side = sum / 4;
// 按火柴长度从大到小排序
// 目的:优先放长火柴,加快剪枝
sort(a.begin(), a.end(), greater<int>());
// dp 数组初始化为 -1,表示所有状态都未计算
memset(dp, -1, sizeof(dp));
// 初始状态:
// status = (1<<n)-1 表示 n 根火柴全部“未使用”
// cur = 0 当前正在拼的边长度为 0
// rest = 4 还需要拼 4 条边
return dfs((1<<n) - 1, 0, 4);
}
/*
dfs(status, cur, rest)
含义:
- status : 二进制状态,1 表示该火柴还没用
- cur : 当前正在拼的这条边,已经拼出的长度
- rest : 还剩多少条边需要拼
返回值:
- 是否能用 status 中剩余的火柴,完成 rest 条边
*/
bool dfs(int status, int cur, int rest) {
// 如果已经不需要再拼边了
// 只有当所有火柴都正好用完(status == 0)才算成功
if (rest == 0) return status == 0;
// 记忆化剪枝:
// 如果这个 status 之前已经算过,直接返回结果
// 注意:cur 和 rest 都可以由 status 唯一确定
if (dp[status] != -1)
return dp[status];
// 枚举每一根火柴,尝试作为当前状态下的下一步选择
for (int i = 0; i < n; i++) {
// 如果第 i 根火柴已经用过,跳过
if (!(status & (1<<i))) continue;
// 如果把第 i 根火柴放到当前边后长度超过 side,不合法
if (cur + a[i] > side) continue;
// 使用第 i 根火柴,更新状态
int nstatus = status ^ (1<<i); // 把第 i 位从 1 变成 0
int ncur = cur + a[i]; // 当前边长度增加
int nrest = rest;
// 如果当前边刚好拼满
if (ncur == side) {
ncur = 0; // 开始拼下一条边
nrest--; // 剩余边数减一
}
// 递归搜索
if (dfs(nstatus, ncur, nrest)) {
// 只要有一种选择能成功,这个 status 就是成功状态
return dp[status] = true;
}
}
// 所有选择都尝试过,仍然失败
// 说明这个 status 无法完成剩余的边
return dp[status] = false;
}
};

浙公网安备 33010602011771号