\(AcWing\) \(167\). 木棒
一、题目描述
乔治拿来 一组等长 的木棒,将它们随机地砍断,使得每一节木棍的长度都 不超过 \(50\) 个长度单位。
然后他又想把这些木棍 恢复到为裁截前的状态 ,但忘记了 初始时有多少木棒 以及 木棒的初始长度。
请你设计一个程序,帮助乔治计算木棒的 可能最小长度。
每一节木棍的长度都用大于零的整数表示。
输入格式
输入包含多组数据,每组数据包括两行:
第一行是一个不超过 \(64\) 的整数,表示砍断之后共有多少节木棍。
第二行是截断以后,所得到的各节木棍的长度。
在最后一组数据之后,是一个零。
输出格式
为每组数据,分别输出原始木棒的可能最小长度,每组数据占一行。
备注
\(1≤N≤60\)
二、题目分析
这道题可以说是搜索剪枝例题中的经典,涉及到的剪枝操作令人汗颜,如果能够完完全全吃透这道题,对剪枝的体悟一定能更深!
东西 | 尺寸 |
---|---|
木棒 | 大 |
木棍 | 小 |
先来一下 朴素版本 的搜索代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 65;
int a[N], n, len, sum;
bool st[N];
// len:每个木棒的长度
// u:当前正在装的木棒
// last:当前木棒已经装入的木棍长度
bool dfs(int u, int last) {
// 假设有100个同学,10个房间,每个房间10个人,如果前9个房间完全装满,则最后一个房间10个人
// sum = 100,len = 10
// u = 10,表示前9个已装满,已经装入了90人,剩余10人,则最后一个房间一定是装满的
if (u * len == sum) return true; //如果u成功来到最后一个房间,则表明可以完整的填充最后一个房间
//如果本木棒已填满,就开启下一个木棒
if (last == len) return dfs(u + 1, 0);
for (int i = 0; i < n; i++) {
if (st[i] || last + a[i] > len) continue; //如果使过了,或者,当前木棒剩余空间不足以装入i木棍,放过
st[i] = true; //装入
if (dfs(u, last + a[i])) return true; //装入后,此路径的后续汇报了成功标识,则本路径就是成功的,不需要再继续搜索了
st[i] = false; //回溯
}
return false;
}
int main() {
while (cin >> n && n) {
sum = 0;
memset(st, false, sizeof st);
for (int i = 0; i < n; i++) cin >> a[i], sum += a[i];
//从1~sum,由小到大逐个枚举木棒长度,这样,可以保证第一个符合条件的木棒长度是最小的
//在 木棒长度=len的前提下,开始向第1个木棒里装东西,目前第1个木棒内已装入的长度为0
//如果当前木棒长度=len的情况下,可以找到完美的装填办法,就是找到的答案
for (len = 1; len <= sum; len++) {
if (dfs(1, 0)) {
printf("%d\n", len);
break;
}
}
}
return 0;
}
毫无疑问,\(TLE\)了,需要优化,本题的优化挺\(BT\)的,很极限,需要仔细阅读理解:
1、木棒长度是所有木棍总长度的约数
因为原来每根木棒长度是相同的,所以
也就是一定要有$$\large sum% len=0$$
2、要组合不要排列
这个问题中的木棒长度跟搭配 顺序 没有关系 ,比如编号为\([1,2,3]\)结合的木棍,与编号为\([3,2,1]\)结合的木棍,视为同一个组合,我们只要控制好枚举的顺序,就可以得到唯一的组合搭配顺序,而不需要枚举出所有的排列形式。这一点也很好实现:每个木棒开始时,从下标\(0\)开始找木棍进行填充,并且记录这个上一个是从哪个下标开始的,新选择的木棍号码,一定要比上一个大就行,不可以选择比上一个小的,防止了非单调递增序的产生,下面的我们首先需要理解的组合与排列的对比代码:
排列
#include <bits/stdc++.h>
using namespace std;
const int N = 20;
int n, m;
bool st[N];
vector<int> path;
void dfs(int u) {
if (u == m) {
for (int i = 0; i < path.size(); i++)
cout << path[i] << " ";
cout << endl;
return;
}
for (int i = 1; i <= n; i++) {
if (!st[i]) {
path.push_back(i);
st[i] = true;
dfs(u + 1);
st[i] = false;
path.pop_back();
}
}
}
// 测试用例:
// 5 3
int main() {
cin >> n >> m;
dfs(0);
return 0;
}
组合
#include <bits/stdc++.h>
using namespace std;
const int N = 20;
int n, m;
bool st[N];
vector<int> path;
void dfs(int u, int start) {
if (u == m) {
for (int i = 0; i < path.size(); i++) cout << path[i] << " ";
cout << endl;
return;
}
for (int i = start; i <= n; i++) {
if (!st[i]) {
path.push_back(i);
st[i] = true;
dfs(u + 1, i + 1);
st[i] = false;
path.pop_back();
}
}
}
// 测试用例:
// 5 3
int main() {
cin >> n >> m;
dfs(0, 1);
return 0;
}
3、优化搜索顺序
有了运输小猫那道题的经验,我们知道:将木棍按 由大到小 排序,然后去深搜,可以让搜索更快。
原理:先枚举大的,后面的搜索空间就少了,可以减少分支。
4、当填充某个木棍失败时(回溯后)
- \(①\) 在一个木棒结尾正好装下,但后续动作无法完成完美填充
- \(②\) 在一个空木棒头部放木棍,但后续动作无法完成完美填充
推论
假如一根小木棍放在当前大木棒中,正好在尾部填充满,无法完成后续的完美填充,那么可以判断它放在任何一根大木棒中都无法实现完美填充。
证明
反证法:假如有一根小木棍放在当前大木棒\(x\)的最后一根不行,但是放在其他大木\(y\)的某个位置行,则大木\(y\)中该小木棍可以通过平移使得该小木棍置于 最后一个位置,这样就存在方案使得该小木放在大木棒最后一个位置可行,与假设相悖,得证。
同理,\(②\)也是正确的结论。
三、代码实现
\(23ms\)
#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;
const int N = 70;
int a[N]; //用来装每个木棍的长度
int n; //木棍个数
int len; //组长度
int sum; //总长度
bool st[N]; //标识某个木棍是不是使用过了
/*
start:从哪个索引号开始继续查找
u:木棒号,下标从0开始
res:最后一个木棒目前的长度
只要搜索到解就停止的dfs问题,一般用bool类型作为返回值,因为这样,搜到最优解就返回true,用dfs的返回值作为条件,
就可以瞬间退出递归了。比上一题中专门设置标志变量的方法要更好。
*/
bool dfs(int u, int last, int start) {
//因为填充的逻辑是填充满了一个,才能走到下一个面前,所以如果成功到达了第u个前面的话,说明前u-1个都是填充满的
//如果在第u个面前,检查到木棒长度 乘以 木棒数量 等于总长度,说明完成了所有填充工作,递归终止
if (len * u == sum) return true;
//因为每一个木棒原来都是客观存在的,所以,每组木棍必须可以填充满一个木棒
//不能填充满,就不能继续填充下一个木棒
if (last == len) return dfs(u + 1, 0, 0); // 注意当一组完成时,下一组从0开始搜
//在当前木棒没有填充满的情况下,需要继续找某个木棍进行填充
// start表示搜索开始的位置
//防止出现 abc ,cba这样的情况,我们要的是组合,不要排列,每次查找选择元素后面的就可以了
for (int i = start; i < n; i++) {
//使用过 or 超过枚举的木棒长度
if (st[i] || last + a[i] > len) continue;
//准备将i号木棍,放入u这个木棒中
st[i] = true; //标识i号木棍已使用过
if (dfs(u, last + a[i], start + 1)) return true; //将i号木棍放入u号木棒中
st[i] = false; //恢复现场
//可行性剪枝
//优化4:如果在第u组放置失败,且此时第u组长度为0,这是最理解的状态,这种情况都放不下,那么占用了一些空间的情况下,就肯定更放不下!
if (last == 0) return false;
//优化5:如果加入一个元素后某个分组和等于len了,但是后续搜索失败了(后续肯定是开新组并且last从0开始),则没有可行解,和4是等价的。
if (last + a[i] == len) return false;
//优化6:冗余性剪枝
//如果当前未放置成功,则后面和该木棒长度相等的也一样,直接略过即可
while (i < n - 1 && a[i] == a[i + 1]) i++;
}
return false;
}
int main() {
//多组测试数据,以0结束输入
while (scanf("%d", &n) && n) {
//多组数组初始化
sum = 0; //木棒总长清零
memset(st, false, sizeof st); //清空使用状态桶数组
//录入n个木棍的长度
for (int i = 0; i < n; i++) scanf("%d", &a[i]), sum += a[i]; //总长度
//优化1:按小猫思路,由大到小排序,因为大卡车越往前越好找空放,越往后越不容易找空
//根据搜索顺序优化,枚举长度小的分支比长度大的分支要多,所以先枚举长度大的
sort(a, a + n, greater<int>());
//枚举每一个可能的木棒长度,注意这是一个全局量,因为需要控制递归的出口
for (len = a[0]; len <= sum; len++) { //优化2:从最短的木棍长度开始,可以减枝
//优化3:如果总长度不能整除木棒长度,那么不能按这个长度设置木棒长度
if (sum % len) continue;
//在当前len长度的基础上,开始搜索
if (dfs(1, 0, 0)) {
printf("%d\n", len); //找到最短的木棒长度
break; //找到一个就退出
}
}
}
return 0;
}
四、桶优化版本
\(2ms\)
#include <cstdio>
#include <cstring>
#include <cmath>
using namespace std;
const int N = 70;
const int INF = 0x3f3f3f3f;
int n; // n个木棍
int Min, Max; //最小长度,最大长度
int sum; //木棍的总长度
int len; //枚举的每个木棒长度
int b[N]; //桶
/*
u:正在填充第u个木棒
last:最后一个木棒填充了的长度
start:已经填充完的木棍长度,后续的需要小于等于这个长度
*/
bool dfs(int u, int last, int start) {
//如果前面u-1个都已经完整填充了,成功创建了第u个木棒
//前面使用了(u-1)*len个长度,剩余的长度:sum-(u-1)*len,并且sum=len*u,所以此木棒需要装入的长度就是len,肯定能实现完美填充
if (u == sum / len) return true;
//每个木棒,必须由若干个木棍填充满,否则没有资格创建新的木棒
//因为早晚都得填充满,就这么多可用的木棍,每个都研究了一遍,还填充不满当前的,说明当前枚举的木棒长度不对,无法填充,返回
//如果当前木棒已经填满,则创建一个新的木棒,新木棒已填充长度为0,可以从Max最大的数开始填充
if (last == len) return dfs(u + 1, 0, Max);
//从大到小枚举每个可能的木棍长度,尝试把长度为i的木棍填充进u木棒
//要装入u木棒中,因为组合的问题,只能装的长度是小于等于start,也就是前一个长度的木棍
//同时,秉承先放大再放小的贪心原则,倒序枚举每个可能木棍的长度
for (int i = start; i >= Min; i--)
if (b[i] && last + i <= len) { //如果存在此长度的木棍,并且,当前木棒剩余空间可以装的下此木棍的长度
b[i]--; //装一下试试,试一下的话,就用了一个i长度的木棍
if (dfs(u, last + i, i)) return true; //剩余木棒数量没有减少,还是num。此木棒的长度变为last+i,起始值变成i,表示:下一个用来填充的木棍长度最小是i
b[i]++; //如果没有成功完成最终的填充任务,回溯
if (last == 0 || last + i == len) return false; //上面回溯了,没有成功,而且,i如果放在开头,或者i放在尾巴上,后继却无法完成完美填充,说明木棒长度不符合,详见题解证明
}
return false;
}
int main() {
while (scanf("%d", &n) && n) {
Min = INF, Max = 0; //木棍最小长度,木棍最大长度
sum = 0; //木棍总长度
memset(b, 0, sizeof b); //桶
for (int i = 1; i <= n; i++) {
int x;
scanf("%d", &x); //木棍长度
b[x]++; //用桶记录木棍长度,主要是因为数据保证每一节木棍的长度均不大于 50,有以下两个特点:1、数据的重复性高,2、范围小,适合用桶来计数
Min = min(x, Min), Max = max(x, Max); //木棍最小值,木棍最大值。桶计数时,必须标配最小值和最大值,方便确定数据范围,不做无意义的遍历
sum += x; //木棍总长度
}
//要求:输出原始木棒的可能最小长度
//策略:由小到大枚举木棒可能的长度,一旦某个长度可以满足要求,完美还原木棒,则可以停止循环,找到了答案
//细节:
//(1)必须大过最长的那个木棍,否则没法装下最长的木棍
//(2)不可能比木棍总长还长,但是可以相等,相等时表示只有一个木棒,把所有木棍全部装在一个木棒里,此时木棒长度最长,值=sum
for (len = Max; len <= sum; len++) {
if (sum % len) continue; //木棒长度必须是总长度的约数,才能保证能平均分开.不能有余数
if (dfs(1, 0, Max)) { //如果在当前木棒长度=len的情况下,可以完成完美填充任务,就是找到了最小长度len, 直接输出并退出即可
printf("%d\n", len);
break;
}
}
}
return 0;
}
五、问题与解答
\(Q\):为什么采用桶优化后,性能提升了\(10\)倍以上,它优化了什么内容?
\(A\):其实就是优化了如果当前木棍长度\(x\)放不下去的话,其它也是\(x\)长度的木棍就不用再试了,在第一种优化解法中,采用
while (i < n - 1 && a[i] == a[i + 1]) i++;
进行冗余性剪枝,其实这是没有必要的,是浪费。考虑到区间范围较小,数据的重复度很高,可以考虑采用桶的形式,将数据直接记录到桶中,以
- 数值
- 个数
- 最大
- 最小
这四个维度去维护数据,一旦发现某个木棍长度无法完成填充任务后,马上就会跳过所有长度=\(x\)的数字,大大加快了执行速度。
六、经验总结
\(dfs\)的\(bool\)返回与\(void\)返回的对比:
1、\(bool\)返回,方便剪枝,\(void\)就不方便,代码冗长
2、要学习和适应 \(bool\) \(dfs\)返回值方式