## [$AcWing$ $167$. 木棒](https://www.acwing.com/problem/content/169/) ### 一、题目描述 乔治拿来 **一组等长** 的木棒,将它们随机地砍断,使得每一节木棍的长度都 **不超过** $50$ 个长度单位。 然后他又想把这些木棍 **恢复到为裁截前的状态** ,但忘记了 **初始时有多少木棒** 以及 **木棒的初始长度**。 请你设计一个程序,帮助乔治计算木棒的 **可能最小长度**。 每一节木棍的长度都用大于零的整数表示。 **输入格式** 输入包含多组数据,每组数据包括两行: 第一行是一个不超过 $64$ 的整数,表示砍断之后共有多少节木棍。 第二行是截断以后,所得到的各节木棍的长度。 在最后一组数据之后,是一个零。 **输出格式** 为每组数据,分别输出原始木棒的可能最小长度,每组数据占一行。 **备注** > $1≤N≤60$ **输入样例**: ```cpp {.line-numbers} 9 5 2 1 5 2 1 5 2 1 4 1 2 3 4 0 ``` **输出样例**: ```cpp {.line-numbers} 6 5 ``` ### 二、朴素$dfs$ 这道题可以说是搜索剪枝例题中的经典,如果能够完全吃透这道题,对剪枝的领悟一定能更深!
| 东西 | 尺寸 | | ---- | ---- | | 木棒 | 大 | | 木棍 | 小 |
**朴素版本** 的搜索代码: ```cpp {.line-numbers} #include 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(单个木棒长度)*num(木棒数量)$$ 也就是一定要有$$\large sum\% len=0$$ > 也就是说我们在枚举$len$时,可以通过上面的性质,避免一些无效的、肯定不对的木棒长度枚举,从而起到优化的目的。 #### 2、要组合不要排列 这个问题中的木棒长度跟搭配 **顺序** **没有关系** ,比如编号为$[1,2,3]$结合的木棍,与编号为$[3,2,1]$结合的木棍,视为同一个组合,我们只要控制好枚举的顺序,就可以得到唯一的组合搭配顺序,而不需要枚举出所有的排列形式。这一点也很好实现:每个木棒开始时,从下标$0$开始找木棍进行填充,并且记录这个上一个是从哪个下标开始的,新选择的木棍号码,一定要比上一个大就行,不可以选择比上一个小的,防止了非单调递增序的产生,下面的我们首先需要理解的组合与排列的对比代码: **排列** ```cpp {.line-numbers} #include using namespace std; const int N = 20; int n, m; bool st[N]; vector 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; } ``` **组合** ```cpp {.line-numbers} #include using namespace std; const int N = 20; int n, m; bool st[N]; vector 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$ ```cpp {.line-numbers} #include 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()); // 枚举每一个可能的木棒长度,注意这是一个全局量,因为需要控制递归的出口 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; } ``` ### 五、理解与反思 > **问题**:老师的做法是把当前组放满,刚好放满了就开新组,如果这个组怎么也放不满,那就说明前面放错了。 这个方法和小猫爬山不一样,小猫爬山是对于当前猫,遍历所有的组,看看哪个组能放进去,然后也要尝试开新组,这里并不能贪心说如果有一个组能放就不开新组了。 我一直有个困惑,不知道应该是遍历物品(老师的木棍做法)还是遍历组(老师的小猫爬山做法)。 **答**: 小猫爬山,没有说一定要把每台车都装满,如果有空座,但小猫坐不下,太沉了,可以再租一辆车。所以需要枚举小猫,为小猫找车。 本题是要求木棍一定要填充满木棒,不填满不算完美解决,所以我们保证必须$A$木棒满了,才能创建$B$木棒