AcWing 167. 木棒
\(AcWing\) \(167\). 木棒
一、题目描述
乔治拿来 一组等长 的木棒,将它们随机地砍断,使得每一节木棍的长度都 不超过 \(50\) 个长度单位。
然后他又想把这些木棍 恢复到为裁截前的状态 ,但忘记了 初始时有多少木棒 以及 木棒的初始长度。
请你设计一个程序,帮助乔治计算木棒的 可能最小长度。
每一节木棍的长度都用大于零的整数表示。
输入格式
输入包含多组数据,每组数据包括两行:
第一行是一个不超过 \(64\) 的整数,表示砍断之后共有多少节木棍。
第二行是截断以后,所得到的各节木棍的长度。
在最后一组数据之后,是一个零。
输出格式
为每组数据,分别输出原始木棒的可能最小长度,每组数据占一行。
备注
\(1≤N≤60\)
输入样例:
9
5 2 1 5 2 1 5 2 1
4
1 2 3 4
0
输出样例:
6
5
二、朴素\(dfs\)
这道题可以说是搜索剪枝例题中的经典,如果能够完全吃透这道题,对剪枝的领悟一定能更深!
东西 | 尺寸 |
---|---|
木棒 | 大 |
木棍 | 小 |
朴素版本 的搜索代码:
#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) {
// 如果上一个木棒填充完整,开启了新的木棒,那么,新木棒的当前木棍总长是0,同时,如果u个木棒全部装满就可以包含所有,就是找到了合适的木棒长度和木棒个数
if (u * len == sum) return true;
// 如果当前木棒已填满,开启下一个木棒
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; // 装入后,此路径的后续汇报了成功标识,则本路径就是成功的,不需要再继续搜索了
// 注意:这里不能直接 return dfs(u,last+a[i]),原因是因为即使按现在这样的装法不能成功,但不代表着不可以尝试其它的木棍,不能直接 return
// 这个bool和void的使用方法有一些区别,冉要仔细体会 ,也可以理解为在使用 bool dfs时,return要小心再小心,就能不犯错误
st[i] = false; // 回溯
}
return false; // 如果上面的代码中未能return true,就表示进入了一条死的路径,当前len不合法 或者是 当前木棒选择的木棍不正确
}
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\)了,需要优化。
三、优化
1、木棒长度一定是所有木棍总长度的约数
因为原来每根木棒长度是相同的,所以
也就是一定要有$$\large sum% len=0$$
也就是说我们在枚举\(len\)时,可以通过上面的性质,避免一些无效的、肯定不对的木棒长度枚举,从而起到优化的目的。
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 <bits/stdc++.h>
using namespace std;
const int N = 70;
int a[N]; // 用来装每个木棍的长度
int n; // 木棍个数
int len; // 组长度
int sum; // 总长度
bool st[N]; // 标识某个木棍是不是使用过了
/*
u :木棒号,下标从0开始
last:最后一个木棒目前的长度
start:从哪个索引号开始继续查找
只要搜索到解就停止的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;
}