送礼物

GitHub跳转原题关系图返回列表

给定 N 个物品和背包容量 W,求力气范围内能搬动的最大重量,利用折半搜索降低时间复杂度,并用去重控制状态空间。

OJ: roj

题目 ID: 3066

难度:提高+/省选-

标签:折半搜索二分查找DFS

创建: 2026-10-09 12:46

更新: 2026-10-09 12:46

形式化题目

给定 NN 个物品,每个物品有一个重量 GiG_i。另给定一个最大承重 WW。 需要在这些物品中选择一个子集,使得这个子集中所有物品的重量之和不超过 WW,且重量之和最大。 求能达到的最大重量之和。

数据范围:

  • 1≤N≤481 \le N \le 48(注意:部分数据中 NN 会达到 48 而非题面声称的 46)
  • 1≤W,Gi≤231−11 \le W, G_i \le 2^{31}-1

正解

思路

这是一个典型的 01 背包问题,但是背包的容量 WW 非常大,达到了 231−12^{31}-1,因此基于容量大小的动态规划算法(如 O(NW)O(NW))无法在时间和空间上承受。同时,直接使用深度优先搜索(DFS)枚举每一个物品选与不选,其时间复杂度为 O(2N)O(2^N)。当 NN 达到 4848 时,2482^{48} 是必定会超时的。

引入 折半搜索 (Meet in the middle) 技巧:

  1. 预处理:先将所有重量大于 WW 的物品剔除掉,并将剩下的物品按重量从大到小排序。从大到小排序使得在搜索时能更早触发重量超过 WW 的剪枝,显著降低实际的搜索树规模。
  2. 第一半搜索:将前 K=⌊N/2⌋K = \lfloor N/2 \rfloor 个物品作为第一组。使用 DFS 枚举出这些物品组合出的所有合法重量和(不超过 WW),存入状态数组 weights 中。
  3. 整理状态:将 weights 数组进行排序并去重。这一步非常关键,因为相同重量组合出现次数极多,去重能大幅削减第二半查找的工作量。
  4. 第二半搜索:将后 N−KN - K 个物品作为第二组。同样使用 DFS 枚举出这些物品的合法重量组合和 SS。对于每一个 SS,在 weights 数组中通过二分查找找到一个不大于 W−SW - S 的最大值 valval,然后尝试用 S+valS + val 来更新全局最大答案。

代码

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-09 09:00
# update_at: 2026-10-09 12:35

import sys
from bisect import bisect_right


def half_sums(items: list[int], limit: int) -> list[int]:
    """枚举 items 的全部合法子集和(超过 limit 的分支就地剪掉),返回排序去重后的列表。"""
    sums = [0]
    for weight in items:
        sums += [s + weight for s in sums if s + weight <= limit]   # 拿 / 不拿,规模翻倍
    return sorted(set(sums))                                        # 排序 + 去重,供二分查表


def best_paired(items: list[int], table: list[int], limit: int) -> int:
    """枚举 items 的每种取法(和 S),在 table 里二分找 ≤ limit - S 的最大值,取总和最大者。"""
    best = 0
    stack = [(0, 0)]
    while stack:                                                    # 显式栈迭代,避免深递归
        idx, total = stack.pop()
        if idx == len(items):
            pos = bisect_right(table, limit - total)                # table 已排序且含 0
            if pos and total + table[pos - 1] > best:
                best = total + table[pos - 1]
            continue
        stack.append((idx + 1, total))                              # 不拿第 idx 件
        if total + items[idx] <= limit:
            stack.append((idx + 1, total + items[idx]))             # 拿第 idx 件
    return best


def solve() -> None:
    """读入 W、N 与 N 个重量,用折半搜索求一次能搬动的最大重量。"""
    data = iter(map(int, sys.stdin.buffer.read().split()))
    limit = next(data, None)
    if limit is None:                                               # 无输入:静默退出
        return
    n = next(data, None)
    if n is None:                                                   # 只有 W 没有 N:同样静默退出
        return
    # 礼物不足 N 件时补 0(题面规定 G_i ≥ 1,补 0 不影响答案),避免 StopIteration
    raw = [next(data, 0) for _ in range(n)]
    usable = sorted((g for g in raw if g <= limit), reverse=True)   # 超重礼物剔除;降序先搜大件
    if not usable:                                                  # 全部超重(或 N = 0)
        print(0)
        return

    half = len(usable) // 2
    table = half_sums(usable[:half], limit)
    print(best_paired(usable[half:], table, limit))


if __name__ == '__main__':
    solve()
cpp
/**
 * 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-09 09:00
 * update_at: 2026-10-09 12:20
 */
// roj 3066《送礼物》:N 个礼物,一次搬重量和不超过 W 的任意多件,求最大搬运重量。
// 容量 W 可达 2^31-1,容量维 DP 不可行;N 可达 48,朴素 2^N 也不可行。
// 故用折半搜索(meet in the middle):前半枚举出全部合法子集和并排序,
// 后半枚举时在表里二分找「不超过 W - S」的最大值。
// 题面三步判据(关键词 / 括号内算法名 / 标题)全空 ⇒ 未指定实现手段。
#include <iostream>
#include <algorithm>
#include <functional>

using namespace std;

typedef long long ll;

const int MAXN = 50;            // 题面 N ≤ 46,实测最大 48,留余量
const int MAX_HALF = 1 << 24;   // 前半最多 24 件,子集和最多 2^24 个

ll W;                           // 力气上限(可到 2^31-1,必须 ll)
int n;                          // 过滤超重礼物后剩下的礼物数
ll gift[MAXN];                  // 过滤后的礼物重量,按降序排列

// 前半枚举出的所有合法子集和。
// 每个状态都由「和不超过 W」的递归产生,故取值 ≤ W ≤ 2^31-1,收窄成 int 不会溢出;
// 用 int 而不是 ll,是为了让最坏情况的 2^24 个状态只占 64MB(ll 要 128MB+,会超过 128MB 限额)。
int half_sum[MAX_HALF + 10];
int half_cnt;

ll answer;                      // 当前能达到的最大搬运重量

// 枚举 gift[idx..half_end-1] 每件选或不选,叶子处把当前和收进 half_sum
void dfs_first(int idx, int half_end, ll current_sum) {
    if (idx == half_end) {
        half_sum[half_cnt++] = current_sum;
        return;
    }
    dfs_first(idx + 1, half_end, current_sum);              // 不拿第 idx 件
    if (current_sum + gift[idx] <= W) {                     // 拿第 idx 件
        dfs_first(idx + 1, half_end, current_sum + gift[idx]);
    }
}

// 枚举 gift[idx..end_idx-1] 的每种取法,用当前和 S 去表里配一个最大的另一半
void dfs_second(int idx, int end_idx, ll current_sum) {
    if (idx == end_idx) {
        ll remain = W - current_sum;                        // 还能再塞多少
        int lo = 0, hi = half_cnt - 1, pos = 0;
        while (lo <= hi) {                                  // 找最后一个 ≤ remain 的下标
            int mid = lo + (hi - lo) / 2;
            if (half_sum[mid] <= remain) {
                pos = mid;
                lo = mid + 1;
            } else {
                hi = mid - 1;
            }
        }
        ll total = current_sum + half_sum[pos];
        if (total > answer) {
            answer = total;
        }
        return;
    }
    dfs_second(idx + 1, end_idx, current_sum);              // 不拿第 idx 件
    if (current_sum + gift[idx] <= W) {                     // 拿第 idx 件
        dfs_second(idx + 1, end_idx, current_sum + gift[idx]);
    }
}

// 读入并按题意过滤:单个重量就超过 W 的礼物永远搬不动,直接剔除。
// 返回 false 表示连 W / N 都没读到(无输入),此时不输出任何内容。
bool read_input() {
    int total;
    if (!(cin >> W >> total)) {
        return false;
    }
    n = 0;
    for (int i = 0; i < total; i++) {
        ll value;
        if (!(cin >> value)) {          // 礼物少于声明的 N 件:按已读到的件数处理
            break;
        }
        if (value <= W) {
            gift[n++] = value;
        }
    }
    return true;
}

void solve() {
    if (!read_input()) {                // 无输入:静默退出
        return;
    }
    if (n == 0) {                       // 全部超重(或 N = 0):一件都搬不动
        cout << 0 << "\n";
        return;
    }
    sort(gift, gift + n, greater<ll>());    // 降序:先搜大件,更快触发「超过 W」的剪枝

    int half = n / 2;
    dfs_first(0, half, 0);

    sort(half_sum, half_sum + half_cnt);    // 排序后二分,顺带去重压缩表长
    int unique_cnt = 1;
    for (int i = 1; i < half_cnt; i++) {
        if (half_sum[i] != half_sum[i - 1]) {
            half_sum[unique_cnt++] = half_sum[i];
        }
    }
    half_cnt = unique_cnt;

    dfs_second(half, n, 0);
    cout << answer << "\n";
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);
    solve();
    return 0;
}

复杂度

  • 时间复杂度:第一部分搜索时间为 O(2N/2)O(2^{N/2});对状态数组排序去重时间为 O(2N/2log⁡2N/2)O(2^{N/2} \log 2^{N/2});第二部分搜索时间为 O(2N/2)O(2^{N/2}),每次配合一个二分查找,总时间为 O(2N/2log⁡2N/2)O(2^{N/2} \log 2^{N/2})。总体时间复杂度约为 O(2N/2log⁡2N/2)O(2^{N/2} \log 2^{N/2}),在 N=48N=48 时处于合理范围内。
  • 空间复杂度:最坏情况下存储第一半状态占用 O(2N/2)O(2^{N/2})。若表内使用 int 存储,理论最大可达千万级别(几十 MB),符合 O(2N/2)O(2^{N/2}),能安全通过题目的 128 MB 限制。

总结

1. 算法与数据类型细节(★★★ int/ll 的两个层次)

本题在数据类型的选择上存在两个截然不同的层次:

  • 算术必须使用 ll(正确性要求):在 DFS 中 if (current_sum + gift[idx] <= W) 这类【先加后判】的操作中,如果全盘采用 int,会直接导致有符号整数溢出。全 int 版被 UBSan 在 5/9 个测试点(gift1/4/5/6/8)抓到实打实的 signed integer overflow(例如 gift1 会错算成 2147483012,应为 2144708006)。
  • 状态表元素使用 int(规范与承重选择):虽然运算需使用 ll,但在状态表中存储的重量和保证了 ≤W≤INT_MAX\le W \le \text{INT\_MAX},将其收窄为 int 绝不会截断。这不仅是规范的选择,更是承重的选择(见下一条)。需要注意的是该安全性附带条件:如果超规格构造 W=3,000,000,000>INT_MAXW = 3,000,000,000 > \text{INT\_MAX} 的反例,表元素用 int 就会发生真正的截断。

2. 空间是承重结论(★★★ 空间不是风格偏好)

对于极限合成数据(48 件,W = INT_MAX,前半 2242^{24} 个子集全部合法且和值各不相同),其最高可达 1.67×1071.67 \times 10^7 个状态。

  • 若状态表用 ll (8 Byte),RSS 峰值达到 130.3 MB,会直接导致 MLE (超过 128 MB 限额)。
  • 若状态表用 int (4 Byte),RSS 峰值仅 66.3 MB,能安全通过。 此外,Python 侧的 peak RSS 会高达 223.3 MB,导致超出内存限制(按语言惯例予以豁免)。

3. 数据缺陷与特殊情况分析

  • G_i = 0 存在(★★ 题面与数据不符,同 T33):题面声明物品重量 GiG_i 是“正整数”,但实际数据比题面声明更宽:gift2 中有 1 个 0,gift4 中有 2 个 0。这属于输入合法性层面的缺陷,虽然不影响答案,但需引起注意。
  • .out 是 CRLF 且反事实证明(★★ T32 数据缺陷):9 个测试点的 .in 均为纯 LF 换行,但 .out 全部为 Windows 的 CRLF(即行尾多了 \r),导致标准字节对比全部失败(输出 70\n vs 期望 70\r\n)。反事实证明:在不动内容的前提下,将 9 个 .out 文件由 CRLF 替换为 LF 后,程序的字节级测试 9/9 完美通过,并且还原后 SHA256 也与原始文件一致。这说明问题纯粹出在官方数据的行尾格式上。
  • gift0 穷举分析(★★):gift0 (W=73W=73, N=7N=7) 的 128 种子集中,合法的有 54 个。去除超重物品 150 后只剩 26=642^6=64 种子集。最大可达重量为 70,它有两个取证:[29, 23, 17, 1] 和 [13, 23, 17, 16, 1]。通过穷举确认,71、72、73 的重量全部为不可达。

4. V1 反事实与对照验证表

变体 失败点数 失败点 说明
「≤W」→「<W」 0 — ⚠ 真实数据无法分辨(见 V8 盲区测试)
二分 → 线性扫描 3 3,7,8 全部 TIMEOUT,无一个算错 ⇒ 只影响速度
切分 N/2 → N/4 2 7,8 TIMEOUT,只影响速度
不过滤超重礼物 0 — 输出全同,只影响速度
表 int → ll 0 — 输出全同,但 66.3 MB → 130.3 MB(爆 128MB 限额)
整体 ll → int 5 1,4,5,6,8 答案错 + UBSan 5 点报 signed overflow
去掉降序排序 0 — 只影响速度/内存
去掉排序+去重 7 1,2,4,5,6,7,8 直接错值(如 gift1 → 2121529554),说明去重是承重操作
去掉容量剪枝(ll 表) 0 — 只变慢(gift8 1.3→2.0s,130 MB)
表尾捷径 + 乐观上界(提速尝试) 0 — gift8 1.30→1.30s,无收益,未采纳

★ 特别注意:「表 int → ll」虽然失败 0 个点,但导致了致命的 MLE(隐藏风险);而去重是承重逻辑,不仅影响速度还直接影响正确性。

5. 逐点测试数据表

全局 WW 最大值恰好为 21474836472147483647 (INT_MAX),出现在 gift1。不存在 N=0N=0 或是全部物品超重的用例。

点 W N GiG_i min/max Gi>WG_i>W 个数 Gi=0G_i=0 答案 cpp 时间 cpp RSS py 时间 py RSS
gift0 73 7 1 / 150 1 0 70 0.010s 8.4MB 0.02s 8.5MB
gift1 2147483647 26 12 / 2013009336 0 0 2144708006 0.009s 8.4MB 0.02s 8.5MB
gift2 66086780 40 0 / 2000000000 10 1 66086779 0.010s 8.4MB 0.03s 9.7MB
gift3 67108260 41 2 / 736312400 2 0 65157980 0.037s 8.4MB 0.53s 66.5MB
gift4 2147473513 42 0 / 1699980000 0 2 2147473498 0.028s 8.4MB 0.79s 8.6MB
gift5 2106441797 45 7 / 2125738860 1 0 2106441791 0.034s 8.4MB 1.44s 8.6MB
gift6 2133930318 46 1 / 1771761751 0 0 2133930256 0.058s 8.4MB 3.00s 8.6MB
gift7 2147483000 42 1912453 / 276340294 0 0 2147476138 0.191s 8.4MB 1.42s 86.3MB
gift8 2147483000 48 2092873 / 283313527 0 0 2147476137 1.299s 15.1MB 11.35s 223.3MB

6. 系统验证与 T9 规范

  • V2 无硬编码:验证了提取出来的常数集(如 0、1、2 等)与各个点题解输出交集为空,没有特判情况。
  • V4 规范通过:9 个点全符合每行一个数据、Token 数量精确无误。
  • T9 五项:
    • 类型转换/溢出:已详细分析,由于先加后判必须全链路 long long,只有存储时为了空间才转换为 int。
    • 整除取模 / 浮点格式化:本题不涉及。
    • 排序字典序:严格降序用于提早剪枝,去重依赖此机制运行正常。