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\) ,若

\[S \bmod 4 \ne 0 \]

直接返回 false

每条边长度为:

\[side = \frac{S}{4} \]


三、状态压缩 DP 设计

1️⃣ 状态定义

用一个二进制数 mask 表示 哪些火柴已经被使用

  • mask 的第 \(i\) 位为 1:第 \(i\) 根火柴已使用
  • 状态总数: \(2^n \le 2^{15} = 32768\)

定义:

\[dp[mask] = 当前已拼好部分中,\textbf{当前这条边已经使用的长度} \]

  • mask 不可达,则 dp[mask] = -1
  • dp[mask] = x,表示:
    • 已经拼完若干整边
    • 正在拼一条新边
    • 当前边已用了长度 \(x\)

2️⃣ 初始状态

dp[0] = 0;

表示:还没用任何火柴,当前边长度为 0。


四、状态转移方程

从一个合法状态 mask 出发,尝试加入一根未使用的火柴 i

转移条件

  • mask 的第 i 位为 0
  • dp[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\) 根火柴
  • 时间复杂度:

\[O(n \cdot 2^n) \]

  • 空间复杂度:

\[O(2^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;
    }
};

posted @ 2026-01-07 10:40  katago  阅读(5)  评论(0)    收藏  举报