持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第20天,点击查看活动详情
题目链接:473. 火柴拼正方形
题目描述
你将得到一个整数数组 matchsticks ,其中 matchsticks[i] 是第 i 个火柴棒的长度。你要用 所有的火柴棍 拼成一个正方形。你 不能折断 任何一根火柴棒,但你可以把它们连在一起,而且每根火柴棒必须 使用一次 。
如果你能使这个正方形,则返回 true ,否则返回 false 。
提示:
示例 1:
输入: matchsticks = [1,1,2,2,2]
输出: true
解释: 能拼成一个边长为2的正方形,每边两根火柴。
示例 2:
输入: matchsticks = [3,3,3,3,4]
输出: false
解释: 不能用所有火柴拼成一个正方形。
整理题意
题目给定一组整数数组 matchsticks,每个整数表示每根火柴的长度,题目要求使用 全部火柴,且每根火柴只能 使用一次,并且 不能折断,问是否能够拼接成一个正方形,如果能,返回 true,否则返回 false。
解题思路分析
首先观察题目数据范围:
- 火柴数量在 以内,看到这个数据范围并且题目要求每根火柴只能 使用一次,涉及到了取或者不取。联想到之前总结的套路:当我们看见数据范围较小(小于
20时),同时这道题目中有涉及到是否选取、是否使用这样的二元状态,那么这道题目很可能就是一道状态压缩的题目。 - 火柴长度在 以内,长度较大,需要考虑是否使用
long long存储,但由于火柴数量较小,所以总长度不超过 ,使用int也能存储。
- 首先计算出所有火柴的总长度,判断是否能够拼凑成正方形,并得出正方形边长。
- 采用二进制 状态压缩 表示每个火柴的使用状态,并记录使用的火柴总长度。
- 因为长度受限于正方形边长,需要判断添加当前火柴是否超过边长长度,不断对正方形边长进行取模运算。
- 最终能够拼凑成正方形的状态为:使用了所有火柴,且最后刚刚好取模正方形边长为
0。
具体实现
- 首先判断火柴总长度是否能够拼凑成正方形,也就是判断总长度是否能够被
4整除。 - 我们使用二进制来表示火柴的使用状态,也就是将火柴使用情况进行 状态压缩。
- 从状态
0出发,表示未使用任何一根火柴,初始化将状态0标记为可达状态,其他状态初始化为不可达状态。 - 状态 从小到大 遍历(保证了递推关系),判断每种状态是否能够从之前的状态转移过来。
- 更具体的,对于当前状态,我们判断是否能够通过移除其中一根火柴达到上一个已经遍历过的状态,并判断上一个状态是否可达,同时判断加上当前火柴是否超过正方形边长,如果上一个状态为可达状态,且加上这根火柴后未超过正方形边长,那么当前状态也为可达状态,更新当前状态。
- 遍历完所有状态后,判断最终状态(所有火柴都使用上的情况)是否为可达状态,且火柴长度取模后是否为
0,表示能够使用 全部火柴,且每根火柴只能 使用一次,并且 不折断 的情况下拼接成一个正方形。
优化
- 考虑到每个状态的火柴总长度是固定的,所以取模正方形边长为固定值,因此只需判断当前状态是否能够通过移除某根火柴后得到的上一个状态转移过来即可。
- 所以当我们判断当前状态为可达状态时可以直接
break继续判断下一个状态,比如1 1 1 0可以由1 1 0 0转移过来为可达状态,那我们不必再继续尝试1 1 1 0是否可以由1 0 1 0转移过来,因为只要有一种方式能够到达1 1 1 0即可。
复杂度分析
- 时间复杂度:,其中
n是火柴的数目。总共有 个状态,计算每个状态都需要 。 - 空间复杂度:。保存数组
dp需要 的空间。
代码实现
动态规划
class Solution {
public:
bool makesquare(vector<int>& matchsticks) {
int n = matchsticks.size();
//计算正方形边长
int len = 0;
for(int i = 0; i < n; i++) len += matchsticks[i];
//不能整除4的话就不能围成正方形
if(len % 4 != 0) return false;
len /= 4;
//dp[i]表示状态为 i 的状况下火柴长度为dp[i]
int m = 1 << n;
int dp[m];
//初始化为 -1 表示 false
for(int i = 0; i < (1 << n); i++) dp[i] = -1;
//没有放火柴状态的时候长度为0
dp[0] = 0;
//遍历每种状态
for(int i = 1; i < m; i++){
//遍历每根火柴
for(int j = 0; j < n; j++){
//没有第 j 根火柴可以移走
if((i & (1 << j)) == 0) continue;
//移走后的状态
int pre = i & ~(1 << j);
//判断上一个状态是否为可到达的情况,并且判断加上当前这根火柴是否超过正方形边长
if(dp[pre] >= 0 && dp[pre] + matchsticks[j] <= len){
//更新当前状态下的火柴长度
dp[i] = (dp[pre] + matchsticks[j]) % len;
break;
}
}
}
if(dp[m - 1] == 0) return true;
return false;
}
};
总结
- 该题具有较为明显的 状态压缩 特征,遇到状态压缩的题目需要考虑状态的转移,一般是由初始状态向最终状态转移,所以遍历状态时是从小到大遍历状态,从而可以实现由已经遍历过的状态向未遍历过的状态进行转移。
- 该题还可以使用 回溯 的方法进行解题,不过需要优化和剪枝,在时间复杂度上是较慢的。需要记录
4条边已经放入的火柴总长度。对于第i根火柴,尝试把它放入其中一条边内且满足放入后该边的火柴总长度不超过正方形边长,然后继续枚举下一根火柴的放置情况,如果所有火柴都已经被放置,那么说明可以拼成正方形。为了减少搜索量,需要对火柴长度从大到小进行排序。- 剪枝一:把火柴按长度从大到小排序,优先尝试较长的火柴,然后限制构成一条边的火柴长度是递减的。假设有2根火柴,长度分别为
x和y,且有x>y,显然我们先拼接x再拼接y和先拼接y再拼接x是等效的。 - 剪枝二:对于当前边,记录最近一次拼接的火柴的长度,如果分支搜索失败,则不再向该边中拼接相同长度的火柴。
- 剪枝三:对于当前边,如果加入第一根火柴的递归分支就返回失败,那么直接判定当前分支失败。在拼入这根火柴前,面对的边长度都是
0,这根火柴如果拼接在当前边失败,拼接在其他边必然也会失败。 - 剪枝四:对于当前边,如果加入最后一根火柴的递归分支返回失败,那么直接判定当前分支失败。与剪枝三同理。
- 剪枝一:把火柴按长度从大到小排序,优先尝试较长的火柴,然后限制构成一条边的火柴长度是递减的。假设有2根火柴,长度分别为
- 用上述四个剪枝后,本题的速度可以比动态规划更快。
- 测试结果:
可以看到优化和剪枝的效果还是非常明显的。
结束语
几乎每一个成功的人,都有一段沉默的努力时光,里面有艰辛的汗水,也有无数次失败时的泪水。很多人都是在黎明前一刻认输了,只有那些能从黑暗中穿行而过的人,才获得了最终的成功。