题目描述

这是 LeetCode 上的 ​​473. 火柴拼正方形 ,难度为 中等

Tag : 「剪枝」、「DFS」、「爆搜」、「模拟退火」、「启发式搜索」

你将得到一个整数数组 ​​matchsticks​​​,其中 473. 火柴拼正方形 :「剪枝」&「调参」_Java 是第 ​​​i​​ 个火柴棒的长度。你要用 所有的火柴棍 拼成一个正方形。

你 不能折断 任何一根火柴棒,但你可以把它们连在一起,而且每根火柴棒必须 使用一次 。

如果你能使这个正方形,则返回 ​​true​​​,否则返回 ​​false​​。

示例 1: 473. 火柴拼正方形 :「剪枝」&「调参」_后端_02

输入: matchsticks = [1,1,2,2,2]

输出: true

解释: 能拼成一个边长为2的正方形,每边两根火柴。

示例 2:

输入: matchsticks = [3,3,3,3,4]

输出: false

解释: 不能用所有火柴拼成一个正方形。

提示:

  • 473. 火柴拼正方形 :「剪枝」&「调参」_迭代_03
  • 473. 火柴拼正方形 :「剪枝」&「调参」_后端_04


祝大家儿童节快乐,永葆童心,一直善良 ???? ~


DFS 剪枝

为了方便,我们称 ​​matchsticks​​​ 为 ​​ms​​。

数据范围为 473. 火柴拼正方形 :「剪枝」&「调参」_Java_05,朴素的 ​​​DFS​​​ 爆搜(搜索过程中维护一个大小为 473. 火柴拼正方形 :「剪枝」&「调参」_Java_06 的数组 ​​​cur​​​,数组中的每一位代表正方形一条边长所使用到的火柴总长度,若最终数组中每一位均等于 473. 火柴拼正方形 :「剪枝」&「调参」_LeetCode_07,代表存在合法方案)复杂度为 473. 火柴拼正方形 :「剪枝」&「调参」_迭代_08,会 ​​​TLE​​。

我们考虑如何进行「剪枝」。

首先一个较为明显的剪枝操作是进行「可行性剪枝」:我们在决策 473. 火柴拼正方形 :「剪枝」&「调参」_模拟退火_09 时,如果将其累加到某个 473. 火柴拼正方形 :「剪枝」&「调参」_LeetCode_10 之后,会导致 473. 火柴拼正方形 :「剪枝」&「调参」_迭代_11,则说明必然不会是合法方案,该分支不再往后搜索。

另外一个较为 trick 的剪枝是通过调整搜索顺序来进行「重复性剪枝」:我们可以先对 ​​ms​​ 排倒序,进行「从大到小」的爆搜。本质上,我们是将一些小火柴重复放到某几个桶的搜索路径(其实对应的是相同的分配方案),放到了最后处理。

代码:

class Solution {
int[] ms;
int t;
public boolean makesquare(int[] _ms) {
ms = _ms;
int sum = 0;
for (int i : ms) sum += i;
t = sum / 4;
if (t * 4 != sum) return false;
Arrays.sort(ms);
return dfs(ms.length - 1, new int[4]);
}
boolean dfs(int idx, int[] cur) {
if (idx == -1) {
boolean ok = true;
for (int i : cur) {
if (i != t) ok = false;
}
return ok;
}
for (int i = 0; i < 4; i++) {
int u = ms[idx];
if (cur[i] + u > t) continue;
cur[i] += u;
if (dfs(idx - 1, cur)) return true;
cur[i] -= u;
}
return false;
}
}
  • 时间复杂度:473. 火柴拼正方形 :「剪枝」&「调参」_模拟退火_12
  • 空间复杂度:排序的复杂度为473. 火柴拼正方形 :「剪枝」&「调参」_LeetCode_13,忽略递归带来的额外空间开销,复杂度为473. 火柴拼正方形 :「剪枝」&「调参」_LeetCode_13

模拟退火

事实上,这道题还能使用「模拟退火」进行求解。

因为将 473. 火柴拼正方形 :「剪枝」&「调参」_模拟退火_15 个数划分为 473. 火柴拼正方形 :「剪枝」&「调参」_Java_06 份,等效于用 473. 火柴拼正方形 :「剪枝」&「调参」_模拟退火_15 个数构造出一个「特定排列」,然后对「特定排列」进行固定模式的构造逻辑,就能实现「答案」与「最优排列」的对应关系。

基于此,我们可以使用「模拟退火」进行求解。

单次迭代的基本流程:

  1. 随机选择两个下标,计算「交换下标元素前对应序列的得分」&「交换下标元素后对应序列的得分」
  2. 如果温度下降(交换后的序列更优),进入下一次迭代
  3. 如果温度上升(交换前的序列更优),以「一定的概率」恢复现场(再交换回来)

值得一提的是,这道题造数据的人十分有水平,在最后一个样例中放了个卡 ​​SA​​ 的数据:

[403,636,824,973,815,318,881,506,863,21,834,211,316,772,803]

但我不知道为什么 LC 的提交结果会这么奇怪,已通过 ​​184/183​​ 样例?是总样例数哪个地方更新漏了,还是缓存没刷新:

473. 火柴拼正方形 :「剪枝」&「调参」_模拟退火_18

这个数据点优秀在于起始排序可以导致我们固定的 ​​calc​​​ 逻辑最终落入局部最优。针对这种情况,也很好解决,只需要在执行 ​​SA​​ 之前,先对原数组进行一次随机化打乱即可。

代码(​​2022/06/01​​ 可通过):

class Solution {
int ms[];
int n, k;
Random random = new Random(20220601);
boolean ans = false;
double hi = 1e4, lo = 1e-4, fa = 0.98;
int N = 400;
int calc() {
int diff = 0;
for (int i = 0, j = 0; i < 4; i++) {
int cnt = 0;
while (j < n && cnt < k) cnt += ms[j++];
diff += Math.abs(cnt - k);
}
if (diff == 0) ans = true;
return diff;
}
void sa() {
for (double t = hi; t > lo && !ans; t *= fa) {
int a = random.nextInt(n), b = random.nextInt(n);
int prev = calc();
swap(ms, a, b);
int cur = calc();
int diff = prev - cur;
if (Math.log(diff / t) > random.nextDouble()) swap(ms, a, b);
}
}
public boolean makesquare(int[] _ms) {
ms = _ms;
n = ms.length;
int sum = 0;
for (int i : ms) sum += i;
k = sum / 4;
if (k * 4 != sum) return false;
shuffle(ms);
while (N-- > 0) sa();
return ans;
}
void shuffle(int[] arr) {
int n = arr.length;
for (int i = n; i > 0; i--) {
int idx = random.nextInt(i);
swap(arr, idx, i - 1);
}
}
void swap(int[] arr, int i, int j) {
int c = arr[i];
arr[i] = arr[j];
arr[j] = c;
}
}

我猜你问

Q0. 模拟退火有何风险? 随机算法,会面临 ​​WA​​​ 和 ​​TLE​​ 风险。

Q1. 模拟退火中的参数如何敲定的? 根据经验猜的,然后提交。根据结果是 ​​WA​​​ 还是 ​​TLE​​​ 来决定之后的调参方向。如果是 ​​WA​​ 说明部分数据落到了「局部最优」或者尚未达到「全局最优」。

Q2. 参数如何调整? 如果是 ​​WA​​​ 了,一般我是优先调大 fa 参数,使降温变慢,来变相增加迭代次数;如果是 ​​TLE​​​ 了,一般是优先调小 fa 参数,使降温变快,减小迭代次数。总迭代参数 ​​N​​ 也是同理。

可以简单理解调大 fa 代表将「大步」改为「baby step」,防止越过全局最优,同时增加总执行步数。

Q3. 关于「模拟退火」正确性?

随机种子不变,测试数据不变,迭代参数不变,那么退火的过程就是恒定的,必然都能找到这些测试样例的「全局最优」。

最后

这是我们「刷穿 LeetCode」系列文章的第 ​​No.472​​ 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。

在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。

为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour… 。

在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。