回溯搜索 + 四条剪枝:只枚举总长的因数、降序先放长木棍、同层等长跳过、空木棒或恰好补满即回溯,求原始木棒最小长度。
OJ: roj
题目 ID: 3062
难度:提高
标签:搜索剪枝DFS
创建: 2026-10-01 13:05
更新: 2026-10-07 13:50
形式化题目
给定一个正整数多重集
设
反过来,只要
数据里的非法节:题面声明"每一节木棍的长度均不大于 50",但本题实际使用的数据(沿用 POJ 1011 / 洛谷 P1120 加强版) 里混进了长度大于 50 的节,本数据集最大到 64。这些节与题面前提矛盾,处理时必须忽略。
证据:不过滤时,258 组数据中有 116 组连
正解
思路
朴素做法与瓶颈
最直接的做法是枚举答案
麻烦在于判断的状态是"还剩哪些木棍可用",也就是一个
| 浪费来源 | 具体表现 |
|---|---|
| 候选 |
从 |
| 组内顺序重复 | 同一根木棒里 |
| 等长木棍互换 | 一节长 5 的木棍失败后,其余等长的还要逐个再试 |
| 注定失败仍展开 | 空木棒里放不下某节、或某节恰好补满仍失败,都要搜完整棵子树才回退 |
| 最后一根白搜 | 拼好 |
所以本题的关键不在"换算法",而在把搜索树上这些多余的分支提前砍掉。要砍得准,先要知道
答案
关键观察:候选 只有 的因数
因为所有木棒等长,
,否则不可能均分; ,因为每一节都来自某根木棒,不可能比原来的木棒还长。
这两条把候选从约
来枚举,则
剪枝一:降序排序
先把木棍按长度从大到小排序。理由不在正确性上,而在搜索顺序上: 长木棍能落脚的位置少、约束强,先把它定下来,后面的短木棍才有较大自由度, 能更早搜到解或更早发现无解。另外降序后等长的木棍彼此相邻,也是剪枝二的基础。
需要强调的是:后面的四条剪枝在正确性上并不依赖降序,它们依赖的是 "未使用的木棍集合"这一状态本身。降序只是让搜索更快撞到解或更快撞到死路。
剪枝二:同层等长跳过
在同一层搜索里,如果刚刚试过长度为
代码里用一个变量记住本层刚失败的长度:
failed = 0
for i in start..n-1:
if a[i] == failed: continue # 等长剪枝
...
failed = a[i] # 必须在回溯之后才更新注意 failed 要在撤销标记之后再赋值,否则成功路径上也会被污染。
实际上由于数组已降序,等长的木棍本来就相邻,所以用"记住上一个失败长度"就足够;
另一种等价写法是 if not used[i-1] and a[i] == a[i-1]: continue。
剪枝三(致命):空木棒失败 ⇒ 整个 无解
设当前正在拼的这根木棒还是空的(current == 0),我们把
理由:先注意一个事实:此刻
current == 0,说明除了当前这根空木棒, 其它已动过的木棒都已拼满。于是含有的那根木棒 一定还没开始拼。 反设
可行,取一份可行解。它里面包含 的那根是 , 且当前这根空木棒对应解里的某组 ,两者元素和都是 。 把这两根木棒的内容交换:当前这根改成
,原来 的位置改成 。 两者元素和都是 ,剩下的木棒不动,所以仍是一份可行解—— 而且在这份解里, 恰好就是当前这根空木棒的第一节。 搜索确实枚举了"把
放进空木棒"这一步,并会穷尽之后所有可能的拆法。 既然上述可行解存在,这一步就必然能搜出解——与"这一步搜下去失败"矛盾。 故 无解。(这里只用到了" 还没开始拼",与降序无关。)
这条剪枝的威力在于:它把"一棵子树搜完"变成"一步判死",并且直接终止整个
剪枝四(致命):恰好补满却失败 ⇒ 整个 无解
设 current + a[i] == L),结果失败。
同样可以判定
理由:把当前这根木棒已拼好的前缀记为
( )。 反设 可行,并取一份与当前局面一致的可行解。 在这份解里,包含
的那根木棒由 组成,其中 是一组未使用的木棍, 满足 。又因为 还没被使用, 它属于某根还没动过的木棒 ,满足 。 若
:由 得 ,即这份解里就是" 恰好补满 当前这根木棒"——可这正是刚刚搜失败的那个分支,矛盾。 若
:把这两根木棒的内容对调一下组成新解—— 当前这根改为 ,原来 那根改为 。两根的和分别是 都恰好是
,且 里的木棍都是未使用的,装得下。其余木棒不动, 于是又是一份可行解,而且它就是" 恰好补满当前木棒"这一步之后的完成方式—— 与这一步搜下去失败矛盾。 两种情况都矛盾,故
无解。 这条比空木棒剪枝更强:空木棒要求
current == 0,而这里current可以是任意值, 只要恰好补齐。这也正是它最有杀伤力的地方。
剪枝五:最后一根免检
当已经拼好
组合式枚举
除剪枝外,还要消除"组内顺序重复"。做法是给递归加一个 start 参数:
选第
样例推演
样例 1:5 2 1 5 2 1 5 2 1,5 5 5 2 2 2 1 1 1。候选
| 试探顺序 | 根数 |
判定结果 | 具体分组 | |
|---|---|---|---|---|
| 1 | 6 | 4 | 可行 | |
| 2 | 8 | 3 | 可行但不必再试 | |
| 3 | 12 | 2 | 可行但不必再试 | |
| 4 | 24 | 1 | 可行但不必再试 | 全部合成一根 |
第一行就是答案:6。
表里后三行说明"可行解不唯一"——
样例 2:1 2 3 4,
| 试探顺序 | 根数 |
判定结果 | 具体分组 | |
|---|---|---|---|---|
| 1 | 5 | 2 | 可行 | |
| 2 | 10 | 1 | 可行但不必再试 |
输出 5。这两组样例都体现同一件事:枚举顺序决定了"第一个可行解即最小值"。
剪枝在小数据上的实际效果
拿一组 a = [4, 2, 2, 2, 2, 2, 1](
flowchart TD
A["试 L=5(S=15, k=3)<br/>fit(done=0, cur=0)"] --> B["放 4 → cur=4"]
B --> C["放 1 → cur=5,第一根拼满"]
C --> D["fit(done=1, cur=0)<br/>第二根还是空的"]
D --> E["放 2 → cur=2,再放 2 → cur=4"]
E --> F["剩 1 也已用过<br/>无木棍可填 → 回溯"]
F --> G["退回 cur=2 这一层<br/>等长剪枝 continue:其余 2 不再试"]
G --> H["再退到 cur=0<br/>空木棒剪枝 return:放 2 也失败"]
C --> I["恰好补满剪枝 return<br/>4+1=5 却失败"]
B --> J["空木棒剪枝 return<br/>cur=0 时放 4 也失败"]
H --> K["L=5 整个不可行"]
I --> K
J --> K
K --> Z["只剩 L=15<br/>done == S/L-1,最后一根免检<br/>输出 15"]
图里 G 对应 continue(只跳过一节等长木棍),H、I、J 对应 return False
(直接把这个 K 后只剩
注意 G 和 H 发生在回溯路上,这正好体现了剪枝的作用位置:
它们不阻止搜索往前走,而是当一条路走死时,把那些"注定同样失败"的兄弟分支一次删掉。
算法流程
读入一组 n 与 n 个长度
sticks = 过滤掉 > 50 的节,然后降序排序
若 sticks 为空: 输出 0
S = sum(sticks)
for k in range(S // sticks[0], 0, -1): # k = 根数,从多到少 ⇒ L 从小到大
if S % k != 0: continue # L = S/k 必须是整数
if fit(L = S // k, done = 0, current = 0, start = 0):
输出 L 并结束本组
fit(L, done, current, start):
if done == S // L - 1: return True # 最后一根免检
if current == L: return fit(L, done + 1, 0, 0)
failed = 0
for i in start .. n-1:
if used[i] or current + a[i] > L or a[i] == failed: continue
used[i] = True
if fit(L, done, current + a[i], i + 1): return True
used[i] = False
failed = a[i] # 等长剪枝:记住本层失败过的长度
if current == 0: return False # 空木棒剪枝
if current + a[i] == L: return False # 恰好补满剪枝
return False代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 13:05
# update_at: 2026-10-01 13:20
import sys
from collections.abc import Iterator
MAX_PIECE = 50 # 题面保证每节不超过 50 个单位;数据里混进的更长木棍不合法,直接忽略
def candidate_lengths(sticks: list[int]) -> Iterator[int]:
"""从小到大产出可能的目标长度:总长的因数,且不小于最长的那一节。
sticks 已按降序排列,所以 sticks[0] 就是最长的一节。目标木棒越长根数越少,
按根数从多到少枚举,长度就是从短到长,第一个能拼成的就是最小答案。
"""
total = sum(sticks)
for groups in range(total // sticks[0], 0, -1):
if total % groups == 0:
yield total // groups
def best_length(sticks: list[int]) -> int:
"""求原始木棒的最小可能长度;sticks 已降序排列且只含合法长度,空表返回 0。"""
if not sticks:
return 0
total = sum(sticks)
n = len(sticks)
used = [False] * n
def fit(length: int, done: int, current: int, start: int) -> bool:
"""已在目标长度 length 下拼好 done 根,当前这根长 current,从下标 start 继续选。"""
if done == total // length - 1: # 只剩最后一根,剩余木棍总长恰好是 length,必然拼成
return True
if current == length: # 这根拼满,换下一根,从最长的一节重新搜
return fit(length, done + 1, 0, 0)
failed = 0 # 本层试过并失败的长度:等长木棍互换后结果完全相同
for i in range(start, n):
if used[i] or current + sticks[i] > length or sticks[i] == failed:
continue
used[i] = True
if fit(length, done, current + sticks[i], i + 1):
return True
used[i] = False
failed = sticks[i]
# 空木棒都放不下它、或它恰好补满这根:再用更短的木棍补同一个空缺只会更没希望
if current == 0 or current + sticks[i] == length:
return False
return False
for length in candidate_lengths(sticks):
if fit(length, 0, 0, 0):
return length
return total # 每节各成一根,总是可行,是搜索的保底上界
def solve() -> None:
"""多组数据:每组给出砍断后的各节长度,输出原始木棒的最小可能长度。"""
data = iter(map(int, sys.stdin.buffer.read().split()))
out: list[str] = []
for n in data: # 每组的第一个数是节数,读到 0 表示输入结束
if n == 0:
break
pieces = [next(data) for _ in range(n)]
# 先放长的:长木棍落点少,尽早定下来能提前收紧搜索,砍掉更多分支
sticks = sorted((x for x in pieces if x <= MAX_PIECE), reverse=True)
out.append(str(best_length(sticks)))
print('\n'.join(out))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 17:02
* update_at: 2026-10-06 17:02
*/
// roj 3062 「Sticks」 木棒
// 多重集划分 + 回溯搜索(降序、等长跳过、空木棒剪枝、恰好补满剪枝、最后一根免检)
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 70; // 每组最多 64 节木棍,数组开大一点留余量
const int MAX_PIECE = 50; // 题面保证每节不超过 50;数据里混进的非法长节直接忽略
ll stick[MAXN]; // 清洗并降序排序后的木棍长度
ll used[MAXN]; // used[i] 标记第 i 节木棍是否已被用掉
ll stick_cnt; // 清洗后的木棍节数
ll total_len; // 清洗后所有木棍的总长度
ll target_len; // 当前正在判定的目标木棒长度 L
// 判定目标长度 target_len 是否可行。
// done 表示已拼好的木棒根数,cur 表示当前这根已拼长度,start 表示本层从哪个下标继续选。
// 返回 true 表示能恰好拼成若干根长度为 target_len 的木棒。
int fit(ll done, ll cur, ll start)
{
// 已经拼好 S/L - 1 根,剩下的木棍总长恰好是 target_len,最后一根必然成立
if (done == total_len / target_len - 1) {
return 1;
}
// 当前这根拼满,换下一根,从最长的木棍重新开始选
if (cur == target_len) {
return fit(done + 1, 0, 0);
}
ll failed = 0; // 本层已经试过并失败的长度;等长木棍互换后局面同构,无需重复试
for (ll i = start; i < stick_cnt; i++) {
if (used[i]) {
continue;
}
if (cur + stick[i] > target_len) {
continue;
}
if (stick[i] == failed) { // 等长剪枝:同层失败过的长度直接跳过
continue;
}
used[i] = 1;
if (fit(done, cur + stick[i], i + 1)) {
return 1;
}
used[i] = 0;
failed = stick[i]; // 必须在撤销标记之后记录失败长度,成功路径不会污染它
// 空木棒剪枝:当前木棒还是空的都放不下它,说明这个 L 无解,直接判死
if (cur == 0) {
return 0;
}
// 恰好补满剪枝:a[i] 恰好补齐当前空缺却失败,同样说明整个 L 无解
if (cur + stick[i] == target_len) {
return 0;
}
}
return 0;
}
// 求原始木棒的最小可能长度;清洗后无木棍时返回 0。
ll solve_one()
{
if (stick_cnt == 0) {
return 0;
}
total_len = 0;
for (ll i = 0; i < stick_cnt; i++) {
total_len += stick[i];
}
// 按根数 k 从多到少枚举,等价于把长度 L 从小到大枚举,第一个可行解即最小答案
for (ll groups = total_len / stick[0]; groups >= 1; groups--) {
if (total_len % groups != 0) {
continue;
}
target_len = total_len / groups;
for (ll i = 0; i < stick_cnt; i++) {
used[i] = 0; // 每次判定前清空标记
}
if (fit(0, 0, 0)) {
return target_len;
}
}
return total_len; // 兜底:每节各成一根总是可行
}
// 比较函数:降序,让长木棍先被放置,尽早收紧搜索
int cmp_desc(ll a, ll b)
{
return a > b;
}
int main()
{
ll n;
while (scanf("%lld", &n) == 1 && n != 0) {
ll raw[MAXN];
for (ll i = 0; i < n; i++) {
scanf("%lld", &raw[i]);
}
// 输入清洗:丢弃与题面矛盾的长度大于 50 的非法节
stick_cnt = 0;
for (ll i = 0; i < n; i++) {
if (raw[i] <= MAX_PIECE) {
stick[stick_cnt] = raw[i];
stick_cnt++;
}
}
sort(stick, stick + stick_cnt, cmp_desc);
printf("%lld\n", solve_one());
}
return 0;
}实现上有几处刻意为正确性服务:
MAX_PIECE = 50只是把"题面保证的上界"写成一个有名字的常量,过滤逻辑放在solve里, 而不是散落在搜索内部——它属于输入清洗,不属于算法。total // length - 1这个写法让fit不必再传一个groups参数: "最后一根"这件事只跟total和length有关。failed的赋值紧跟used[i] = False,保证只在真正失败后记录,成功路径不会污染它。candidate_lengths是生成器,调用点for length in candidate_lengths(sticks)找到即return, 不会白算后面的候选。- 兜底
return total:每个因数都试过仍失败时,(每节各成一根)必然可行。
复杂度
候选枚举:代码从
单次可行性判定:最坏指数级——本题的判定内核是装箱问题(bin packing),是 NP 难的,
无法给出多项式界。设搜索树节点数为
空间复杂度 used 数组加递归栈,深度不超过
实测(258 组真实数据全部通过):总耗时 0.023 s,峰值内存 15.3 MB, 距 1000 ms / 128 MB 有极大余量。插桩统计显示,258 组数据一共只展开了 13301 个搜索节点, 试过的候选长度共 360 个(258 组平均不到 2 个);而搜索过程中被提前拦下的分支有:等长跳过 1621 次、 空木棒剪枝 707 次、恰好补满剪枝 580 次,三项合计 2908 次。 这些数字的量级与真正展开的节点数相当,说明剪枝不是在"薅末梢", 而是真的在搜索树靠近根部的地方就把大子树成片砍掉了。
关于 Python 的最坏情况:本题最坏情况下仍可能超时,这是问题本身 NP 难的固有代价,正解(C++)同样靠剪枝控制规模。
因此这里不为了通过而替换成"枚举子集"之类的另一种算法——那会把可接受的数据规模从
总结
- 问题本质是多重集划分:把
分成若干元素和均为 的部分,求最小的 。 - 候选
只需枚举 的因数且 ;按根数 从大到小枚举, 就等价于把 从小到大枚举,第一个可行解即最小值。 - 四条剪枝是本题的全部:降序先放长木棍、同层等长跳过、空木棒失败即整个
无解、 恰好补满却失败即整个 无解;再加上"最后一根免检"。 - 其中"空木棒"与"恰好补满"两条会直接否掉整个
,是复杂度能接受的关键; 它们都基于同一个交换论证:把"填充当前空缺的那组木棍"与" 原本所在的那组"对调, 元素和仍是 ,所以可行解可以直接等价变形。 - 数据里长度
的节与题面矛盾,必须忽略;忽略后 258 组标准数据全部一致。
图示解析
这张图串起本题从建模到得到答案的主线:
输入 n 节长度
|- 输入清洗: 丢弃 > 50 的非法节(题面保证每节 <= 50)
`- 建模: 多重集 A 划分成若干和为 L 的部分,求最小 L
`- 候选压缩: L 必须是 S 的因数、且 L >= max(A)
`- 枚举顺序: 按 k = S/L 从大到小 <=> L 从小到大
`- 第一个可行 L 即答案
`- 判定内核: 降序 + 组合式回溯
|- 剪枝1 降序: 长木棍落点少,先定下来
|- 剪枝2 等长跳过: 同层失败的 v 不再重复试
|- 剪枝3 空木棒失败 => L 无解,直接返回
|- 剪枝4 恰好补满失败 => L 无解,直接返回
`- 剪枝5 拼到只剩最后一根 => 直接成立先看最上面两个节点:"输入清洗"提醒读者数据与题面不一致这一事实,漏掉它整道题都会算错;
“建模"把题面转化为多重集划分。中间的分支说明候选压缩与枚举顺序如何保证"第一个成功就是最小”。
最后五个叶子是 fit 内部的全部做法,其中剪枝 3、4 返回 False 会终止整个

