题目大意
原题(CF 2229F) · editorial · jiangly AC
题面
给定长度 n n n 的序列 a a a 和整数 k k k 。可以先任意重排 a a a 的元素,然后定义 f ( a ) f(a) f ( a ) 如下:
求所有重排中 f ( a ) f(a) f ( a ) 的最大值。
关键自由度 :(1) a a a 的顺序由我们选;(2) 每步若 b b b 里多个元素并列最小,加到哪个由我们选。
数据范围
1 ≤ k ≤ n ≤ 18 1 \le k \le n \le 18 1 ≤ k ≤ n ≤ 1 8
1 ≤ a i ≤ 1 0 9 1 \le a_i \le 10^9 1 ≤ a i ≤ 1 0 9
∑ 2 n ≤ 2 18 \sum 2^n \le 2^{18} ∑ 2 n ≤ 2 1 8 (所有 testcase 加起来)
时限 1.5 s (“unusually low time limit”)
t ≤ 1 0 4 t \le 10^4 t ≤ 1 0 4 testcase
样例
样例 1( n = 4 , k = 2 , a = [ 1 , 2 , 4 , 5 ] n=4, k=2, a=[1,2,4,5] n = 4 , k = 2 , a = [ 1 , 2 , 4 , 5 ] → 8):题面给的最优重排是 [ 1 , 4 , 2 , 5 ] [1, 4, 2, 5] [ 1 , 4 , 2 , 5 ] , b b b 演化 [ 0 , 0 ] → [ 1 , 0 ] → [ 1 , 4 ] → [ 3 , 4 ] → [ 8 , 4 ] [0,0] \to [1,0] \to [1,4] \to [3,4] \to [8,4] [ 0 , 0 ] → [ 1 , 0 ] → [ 1 , 4 ] → [ 3 , 4 ] → [ 8 , 4 ] , max ( b ) = 8 \max(b) = 8 max ( b ) = 8 。
样例 2( n = 8 , k = 3 , a = [ 3 , 4 , 1 , 3 , 2 , 4 , 5 , 3 ] n=8, k=3, a=[3,4,1,3,2,4,5,3] n = 8 , k = 3 , a = [ 3 , 4 , 1 , 3 , 2 , 4 , 5 , 3 ] → 11);样例 3( n = 3 , k = 1 , a = [ 1 0 9 , 1 0 9 , 1 0 9 ] n=3, k=1, a=[10^9,10^9,10^9] n = 3 , k = 1 , a = [ 1 0 9 , 1 0 9 , 1 0 9 ] → 3 × 1 0 9 3 \times 10^9 3 × 1 0 9 )。
思路讲解
一句话
k k k 桶等价, max ( a ) \max(a) max ( a ) 必能放末位(exchange argument),扔掉它后剩下 n − 1 n-1 n − 1 个元素装 k k k 桶最大化 min ( b ′ ) \min(b') min ( b ′ ) ;这是教科书 “max-min partition”,二分答案 + 状压 DP 贪心凑 ≥ k \ge k ≥ k 个不相交子集每组 s u m ≥ v \mathrm{sum} \ge v s u m ≥ v 。
第 1 步:复杂度预算反推算法
∑ 2 n ≤ 2 18 \sum 2^n \le 2^{18} ∑ 2 n ≤ 2 1 8 几乎就是 bitmask DP 的明牌信号。每个 mask 一次 O ( n ) O(n) O ( n ) 转移: 2 18 ⋅ 18 ≈ 5 × 1 0 6 2^{18} \cdot 18 \approx 5 \times 10^6 2 1 8 ⋅ 1 8 ≈ 5 × 1 0 6 ,外层套二分 log A \log A log A 系数 ≈ 35 \approx 35 ≈ 3 5 倍还在 1 0 8 10^8 1 0 8 以内,1.5 s 紧但能过(“unusually low time limit” 就是预算线索)。
第 2 步:把 b b b multiset 压成标量——但 simulate 不通
直觉上 dp 状态要追踪当前 b b b (长度 k k k 的 multiset),但 b b b 是 k k k 维大整数 tuple, ∑ a ≤ 1.8 × 1 0 10 \sum a \le 1.8 \times 10^{10} ∑ a ≤ 1 . 8 × 1 0 1 0 ,装不进 dp 下标 。
min ( b ) \min(b) min ( b ) 是"每步加 a i a_i a i 的动作锚点",但单独追踪它不够——加完后新 min 是谁还要看其它桶。死磕 “naive simulate” 路线状压压不下来。
关键扭转 :我们不需要 sufficient state 来 simulate。只关心最终的 max ( b ) \max(b) max ( b ) 这一个数,从这个数倒推就豁然开朗。
第 3 步:Observation 1 —— max ( a ) \max(a) max ( a ) 必能放末位
设序列里最后处理的元素是 a last a_{\text{last}} a last ,最后一步把它加到某个 min 桶上。最终:
max ( b ) final = max ( max ( b before ) , min ( b before ) + a last ) \max(b)_{\text{final}} = \max\bigl(\max(b_{\text{before}}),\ \min(b_{\text{before}}) + a_{\text{last}}\bigr)
max ( b ) final = max ( max ( b before ) , min ( b before ) + a last )
直觉:右项 a last a_{\text{last}} a last 越大越占便宜。严格证明用 exchange argument——若最优重排里 max ( a ) \max(a) max ( a ) 不在末位,把它换到末位可证 max ( b final ) ≥ ans \max(b_{\text{final}}) \ge \text{ans} max ( b final ) ≥ ans 。两 case 分类( y y y 原桶仍是 min / x x x 原桶变成 min)见 editorial 与配套 HTML 演示(下方"动画与源码"节)。
结论 :从一开始就锁定 a last = max ( a ) a_{\text{last}} = \max(a) a last = max ( a ) 。
第 4 步:Observation 2 —— 子问题转化为 max-min partition
记 x = max ( a ) x = \max(a) x = max ( a ) , a ′ = a ∖ { x } a' = a \setminus \{x\} a ′ = a ∖ { x } , b ′ b' b ′ = 用 a ′ a' a ′ 处理完后的 b b b 状态。
引理 : max ( b ′ ) − min ( b ′ ) ≤ max ( a ′ ) ≤ x \max(b') - \min(b') \le \max(a') \le x max ( b ′ ) − min ( b ′ ) ≤ max ( a ′ ) ≤ x (归纳易得:每步加 a i a_i a i 到 min 桶,差要么不增、要么变成 ≤ a i \le a_i ≤ a i )。
→ min ( b ′ ) + x ≥ max ( b ′ ) \min(b') + x \ge \max(b') min ( b ′ ) + x ≥ max ( b ′ ) 恒成立。所以:
max ( b ) final = max ( max ( b ′ ) , min ( b ′ ) + x ) = min ( b ′ ) + x \max(b)_{\text{final}} = \max\bigl(\max(b'),\ \min(b') + x\bigr) = \min(b') + x
max ( b ) final = max ( max ( b ′ ) , min ( b ′ ) + x ) = min ( b ′ ) + x
原问题完全等价于:用 n − 1 n-1 n − 1 个元素 a ′ a' a ′ 装 k k k 桶,最大化最终 min ( b ′ ) \min(b') min ( b ′ ) 。
更进一步(editorial 给的 greedy 构造):过程约束可以丢 。任意一种把 a ′ a' a ′ 划分成子集的分法 { S j } \{S_j\} { S j } (每个 s u m ( S j ) ≥ v \mathrm{sum}(S_j) \ge v s u m ( S j ) ≥ v ),都能找到合法重排 + tie-breaking 使最终每桶恰好是 S j S_j S j (每次从 S j S_j S j 里挑剩余最大的元素加)。所以划分本身就是充要条件 :
check( v v v ) = 能否把 a ′ a' a ′ 划分成 ≥ k \ge k ≥ k 个不相交子集,每个 s u m ≥ v \mathrm{sum} \ge v s u m ≥ v ?
第 5 步:bitmask DP 的关键 trick —— k k k 桶等价 + 贪心凑子集
新手一看 check( v v v ) 容易想成"每个物品 k k k 种状态"(分到哪个桶),状态空间 k n k^n k n 爆。
关键观察 :题目里 k k k 个桶是匿名等价的(不区分 id,只看 multiset)。所以 check( v v v ) 等价于"贪心一个一个凑子集"——把"凑子集"想成线性流水线:开始 → 凑组 1 → 凑组 2 → … → 凑 ≥ k \ge k ≥ k 组 → ✓。
任意时刻状态就两个标量:
加上 mask(哪些元素已用), d p [ mask ] = ( cnt , cur ) dp[\text{mask}] = (\text{cnt}, \text{cur}) d p [ mask ] = ( cnt , cur ) 就够了。
第 6 步:DP 转移
push 风格(jiangly 写法):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 for (int s = 0 ; s < (1 << n); s++) { auto [cur, sum] = dp[s]; for (int i = 0 ; i < n; i++) { if (~s >> i & 1 ) { int t = s | 1 << i; int ncur = cur; i64 nsum = sum + a[i]; if (nsum >= x) { ncur++; nsum = 0 ; } dp[t] = std::max (dp[t], std::make_pair (ncur, nsum)); } } } return dp.back ().first >= k;
字典序取 max:先比 cnt 后比 sum 。为什么不只比 cnt? 同 cnt 下更大 sum 意味着未封口组更接近封口,未来加 1 个元素可能再封一组 → 保留更大 sum 更优(这是经典 max-min partition DP 的字典序套路)。
第 7 步:二分 + 答案
二分上界: min ( b ′ ) ≤ ∑ a ′ / k ≤ ∑ a \min(b') \le \sum a' / k \le \sum a min ( b ′ ) ≤ ∑ a ′ / k ≤ ∑ a , U ≈ 2 × 1 0 10 U \approx 2 \times 10^{10} U ≈ 2 × 1 0 1 0 , log ≈ 35 \log \approx 35 log ≈ 3 5 。
check 一次 O ( 2 n − 1 ⋅ ( n − 1 ) ) O(2^{n-1} \cdot (n-1)) O ( 2 n − 1 ⋅ ( n − 1 ) ) 。
总: O ( 2 n − 1 ⋅ ( n − 1 ) ⋅ log U ) O(2^{n-1} \cdot (n-1) \cdot \log U) O ( 2 n − 1 ⋅ ( n − 1 ) ⋅ log U ) , n = 18 n=18 n = 1 8 时 ≈ 2 17 ⋅ 17 ⋅ 35 ≈ 7.8 × 1 0 7 \approx 2^{17} \cdot 17 \cdot 35 \approx 7.8 \times 10^7 ≈ 2 1 7 ⋅ 1 7 ⋅ 3 5 ≈ 7 . 8 × 1 0 7 。
答案 = v max + max ( a ) v_{\max} + \max(a) v m a x + max ( a ) 。
退化 case 校验
n = 1 , k = 1 n = 1, k = 1 n = 1 , k = 1 :弹出 max \max max 后 a ′ a' a ′ 空,dp 全 0, check 恒 false → ans = 0 + a 0 = a 0 \text{ans} = 0 + a_0 = a_0 ans = 0 + a 0 = a 0 ✓
k = 1 k = 1 k = 1 :全部元素装一桶,答案 = ∑ a \sum a ∑ a ✓(check( ∑ a ′ \sum a' ∑ a ′ ) 时整个 a ′ a' a ′ 一组凑成)
n = k n = k n = k :每桶恰一元素,答案 = max ( a ) \max(a) max ( a ) ✓
📎 动画与源码
附在末尾「附件」节:
exchange-argument.html —— 交互式可视化。上半 4 个 preset 数值 demo 看 swap 前后桶演化;下半 Case A / B 抽象代数证明配图(柱高只标 a , b , c , ans , ans-y a, b, c, \text{ans}, \text{ans-y} a , b , c , ans , ans-y 等代数符号,5 步分步动画)。
mentor.pdf —— 完整带教对话录(从复杂度预算反推 → Observation 1/2 → bitmask DP 状态设计每一步的引导问题 + me/Claude 气泡留底)。
2229F.cpp.txt —— 用户的 AC 实现(push DP,含调试日志宏 + Gosper hack 枚举子集)。
AC 代码
AC 提交链接(jiangly 写法参考)
展开完整 C++ 源码(jiangly 风格 push DP)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 #include <bits/stdc++.h> using i64 = long long ;namespace rgs = std::ranges;void solve () { int n, k; std::cin >> n >> k; std::vector<int > a (n) ; for (int i = 0 ; i < n; i++) std::cin >> a[i]; rgs::sort (a); n--; int last = a[n]; a.resize (n); auto check = [&](i64 x) { std::vector<std::pair<int , i64>> dp (1 << n); for (int s = 0 ; s < (1 << n); s++) { auto [cur, sum] = dp[s]; for (int i = 0 ; i < n; i++) { if (~s >> i & 1 ) { int t = s | 1 << i; int ncur = cur; i64 nsum = sum + a[i]; if (nsum >= x) { ncur++; nsum = 0 ; } dp[t] = std::max (dp[t], std::make_pair (ncur, nsum)); } } } return dp.back ().first >= k; }; i64 lo = 0 , hi = 2E10 ; while (lo < hi) { i64 x = (lo + hi + 1 ) / 2 ; if (check (x)) lo = x; else hi = x - 1 ; } std::cout << lo + last << "\n" ; } int main () { std::ios::sync_with_stdio (false ); std::cin.tie (nullptr ); int t; std::cin >> t; while (t--) solve (); return 0 ; }
心路历程
写自己的版本时(pull DP + Gosper hack 枚举固定 popcount 的 sta)一上来踩了两个 bug,自己 debug 出来的:
Bug 1:外层 use 从 1 起,跳过了 use=0 这一层
1 2 3 4 5 6 for (int use = 1 ; use <= N_1; ++use) { long long sta = (1LL << use) - 1 ; while (sta < (1LL << N_1)) { } }
use = 当前 source mask sta 的 popcount。从 use=1 起,意思是从 popcount=1 的 mask 出发往外推。唯一显式 init 的只有 dp[0] = {0, 0} 这一格, dp[1] / dp[2] / dp[4] (popcount=1) 这些从未被 dp[0] 推出过,直接拿来当 source 时读到的是 vector default 的 (0,0)——丢了 A [ 0 ] A[0] A [ 0 ] 这个元素。
跳 use=0 的真原因:Gosper’s hack 在 sta=0 时 c = sta & -sta = 0 除零崩。正确做法:要么外层 for use = 0 ,要么手动展开 use=0 那一层从 dp[0] 推到所有 dp[1<<i] 。
Bug 2:vis[sta] 跟踪的是 source,但应该跟踪 target
1 2 3 4 5 6 if (vis[sta] == tim) { dp[sta | (1ll << i)] = max (now, dp[sta | (1ll << i)]); } else { vis[sta] = tim; dp[sta | (1ll << i)] = now; }
想做"延迟清空 dp"——多次 check 复用 dp 数组,靠 vis[?] == tim 判断"本次 check 是否已 init"。但 vis 应该追踪 target sta | (1<<i) 是否被本次 init 过,写成了 source sta 。后果:
sta=1, i=1 推 dp[3] : vis[1] != tim → else, dp[3]=(0,2) 直接覆盖(OK)。
sta=2, i=0 推 dp[3] : vis[2] != tim → else, dp[3]=(0,1) 又一次直接覆盖前面的 (0,2) ,没走 max。
每个新 source 第一次进 else 时都会"无条件覆盖"它推出的所有 target——把别的 source 已经 init 过的 dp[target] 丢掉。
正确写法:
1 2 3 int t = sta | (1ll << i);if (vis[t] == tim) dp[t] = max (now, dp[t]);else { vis[t] = tim; dp[t] = now; }
验证
两个 bug 都让 check(3) 对样例 1 错返 false,二分塌到 lo=0 ,输出 0 + max(a) = 5 ,期望 8 。修完两条后 sample 全过。
附件
exchange-argument.html 关键帧(Case A 抽象代数证明)
完整 5 步动画(含 Case B 抽象证明)见下方 exchange-argument.html.txt(下载后改回 .html 用浏览器打开 = 可点 Step 看每一帧)。
mentor.pdf — 完整带教对话录
从复杂度预算反推 → Observation 1/2 → bitmask DP 状态设计的全过程引导问答 + me/Claude 气泡留底
源码 & HTML 本体
2229F.cpp.txt
exchange-argument.html.txt