文章

【GESP】C++五级练习题 luogu-P12733 磨合

GESP C++ 五级练习题,二分查找和贪心思想考点应用,五级考生可以练习。题目难度⭐⭐⭐☆☆,洛谷难度等级普及/提高−

luogu-P12733 磨合

题目要求

题目背景

「能够像这样『磨合』,实在是帮了个大忙。」
——绫濑沙季

题目描述

悠太和沙季遇到了 $n$ 个问题,问题的难度分别为 $d_1,\dots,d_n$。

他们可以以任意顺序解决问题,对于准备解决的第 $i$ 个问题,每将难度减少 $1$,两人需要花费 $i$ 秒。将难度减少为 $0$ 时问题被解决,他们才可以继续解决下一个问题。

如果他们正在解决第 $i$ 个问题(即难度尚未减少为 $0$),但剩余时间少于 $i$ 秒,他们就不能继续解决剩下的问题了,第 $i$ 个问题也没有解决。

他们想要知道,如果共有 $t$ 秒,那么最多能解决多少个问题。由于他们可能面对很多种不同情况,所以会多次改变 $t$ 进行询问。

输入格式

第一行输入两个整数 $n,q$ 表示问题总数和询问次数。

第二行输入 $n$ 个整数,表示 $d_1,\dots,d_n$。

接下来 $q$ 行,每行输入一个整数表示询问的总时间 $t$。

输出格式

对于每次询问输出一行一个整数表示最多能解决的问题数量。

输入输出样例 #1

输入 #1
1
2
3
4
3 2
1 7 3
10
16

输出 #1

1
2
2
3

输入输出样例 #2

输入 #2
1
2
3
4
5
10 3
923 243 389 974 100 485 296 377 61 552
2403
5819
0

输出 #2

1
2
3
5
6
0

说明/提示

样例 1 解释

若 $t=10$,则第 $1$ 个解决难度为 $7$ 的问题,第 $2$ 个解决难度为 $1$ 的问题,花费的时间为 $1\times7+2\times1=9$ 秒。可以证明他们无法解决三个问题。

若 $t=16$,则依次解决难度为 $7,3,1$ 的问题,花费的时间为 $1\times7+2\times3+3\times1=16$ 秒。

数据范围与限制

本题采用捆绑测试,各 Subtask 的限制与分值如下。

Subtask No.$n\le$$q\le$$d_i\le$$t\le$分值依赖子任务
$1$$10$$1$$10$$10^3$$13$ 
$2$$10^3$$1$$10^3$$10^9$$24$$1$
$3$$10^3$$10^6$$10^3$$10^9$$16$$1,2$
$4$$10^6$$1$$10^3$$10^{16}$$16$$1,2$
$5$$10^6$$10^6$$10^3$$10^{16}$$31$$1,2,3,4$

对于所有数据,满足 $1\le n,q\le10^6$,$1\le d_i\le10^3$,$0\le t\le10^{16}$。


题目分析

1. 贪心策略与数学推导

题目要求在有限时间 $t$ 内解决最多的问题。为了解决更多的问题,直观的想法是我们应该优先选择难度较小的问题(贪心策略 1)。

假设我们要解决 $k$ 个问题,根据题意,这 $k$ 个问题的解决顺序会影响总花费时间。 设选出的 $k$ 个问题的难度从小到大排序为 $d’_1, d’_2, \dots, d’_k$。 如果我们把这 $k$ 个问题按照某种顺序解决,第 $i$ 个解决的问题会被乘以系数 $i$。 根据排序不等式(或者生活经验),为了使总和最小,我们应该让 较小的难度乘以较大的系数,较大的难度乘以较小的系数贪心策略 2)。

即:

  • 第 1 个解决的问题(系数 1)应该是这 $k$ 个里难度最大的 $d’_k$。
  • 第 2 个解决的问题(系数 2)应该是难度第二大的 $d’_{k-1}$。
  • 第 $k$ 个解决的问题(系数 $k$)应该是难度最小的 $d’_1$。

总花费公式为: \(\text{Cost}(k) = 1 \cdot d'_k + 2 \cdot d'_{k-1} + \dots + k \cdot d'_1\)

2. 前缀和优化

我们发现上面的公式可以转化为前缀和的形式。 设 $sum[i]$ 表示排序后前 $i$ 个难度之和,即 $sum[i] = d’_1 + d’_2 + \dots + d’_i$。 再设 $cost[i]$ 为 $sum$ 的前缀和,即 $cost[i] = sum[1] + sum[2] + \dots + sum[i]$。

展开 $cost[k]$: \(\begin{aligned} cost[k] &= sum[1] + sum[2] + \dots + sum[k] \\ &= (d'_1) + (d'_1 + d'_2) + \dots + (d'_1 + d'_2 + \dots + d'_k) \end{aligned}\) 在上述式子中:

  • $d’_1$ 出现了 $k$ 次(在 $sum[1] \dots sum[k]$ 中都有),总贡献 $k \cdot d’_1$。
  • $d’_2$ 出现了 $k-1$ 次(在 $sum[2] \dots sum[k]$ 中都有),总贡献 $(k-1) \cdot d’_2$。
  • $d’_k$ 出现了 $1$ 次(在 $sum[k]$ 中),总贡献 $1 \cdot d’_k$。

这正好对应了解决前 $k$ 个最小难度问题的最优总时间。 因此,我们可以通过对原数组排序,并进行两次前缀和预处理,在 $O(1)$ 时间内求出解决任意 $k$ 个最小难度问题的最优时间 $cost[k]$。

3. 重点提示:TLE 问题与二分查找

本题的数据范围是 $N, Q \le 10^6$。这是一个非常大的数据规模。

  • 为什么暴力会 TLE? 对于每个询问 $t$,如果我们从 $k=1$ 开始遍历直到 $cost[k] > t$,单次查询的最坏时间复杂度是 $O(N)$。 总时间复杂度为 $O(Q \times N) = 10^6 \times 10^6 = 10^{12}$。 计算机每秒通常能处理 $10^8$ 级别运算,$10^{12}$ 远超 1 秒的时限,必然导致 Time Limit Exceeded (TLE)

  • 如何通过二分查找优化? 我们发现 $cost[k]$ 是随着 $k$ 增大而单调递增的(因为难度 $d_i > 0$),这就满足了二分查找的单调性条件。 因此,对于给定的 $t$,我们可以使用 二分查找 在 $cost$ 数组中快速找到第一个大于 $t$ 的位置,或者最后一个小于等于 $t$ 的位置。 二分查找的复杂度是 $O(\log N)$。 总时间复杂度优化为 $O(N \log N \text{ (排序)} + Q \log N \text{ (查询)})$。 代入数据:$10^6 \times 20 \approx 2 \times 10^7$,完全可以在 1 秒内运行完成。

  • I/O 优化 由于输入输出量巨大(百万级),建议使用 std::ios::sync_with_stdio(false); std::cin.tie(NULL); 来加速 I/O,否则即使算法正确也可能因为读写太慢而 TLE。

4. 总结

  1. 排序:将难度从小到大排序。
  2. 预处理:计算两次前缀和得到 $cost$ 数组。
  3. 查询:对于每个 $t$,使用 upper_bound 或手写二分查找 cost 数组中满足 $\le t$ 的最大下标。


示例代码

方法一、手写二分查找

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <algorithm>
#include <iostream>
#include <vector>

// 使用 long long 防止溢出,t 最大可达 10^16,累加和也会很大
typedef long long ll;

const int MAXN = 1000005;
ll d[MAXN];     // 存储问题难度
ll sum[MAXN];   // 难度的前缀和
ll cost[MAXN];  // 解决前 i 个问题的最小花费(即 sum 的前缀和)

int main() {
    // 优化 I/O 操作速度 (防止 TLE)
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, q;
    std::cin >> n >> q;
    for (int i = 1; i <= n; i++) {
        std::cin >> d[i];
    }

    // 核心逻辑 1: 贪心
    // 为了让解决 k 个问题的总时间最少,我们应该:
    // 1. 选择难度最小的 k 个问题。
    // 2.
    // 在解决这k个问题时,把难度大的放在前面解决(乘数小),难度小的放在后面解决(乘数大)。
    //    具体推导:假设选了 k 个问题,难度从小到大排序为 a1, a2, ..., ak。
    //    第 1 个解决的问题耗时:1 * 难度
    //    第 i 个解决的问题耗时:i * 难度
    //    根据排序不等式,要使乘积之和最小,应该由小的系数乘大的难度,大的系数乘小的难度。
    //    即:1 * ak + 2 * ak-1 + ... + k * a1。
    //    这个式子等价于前缀和的前缀和。
    std::sort(d + 1, d + 1 + n);

    // 核心逻辑 2: 前缀和预处理
    // sum[i] = d[1] + ... + d[i]
    // cost[i] = sum[1] + ... + sum[i]
    //         = (d[1]) + (d[1]+d[2]) + ... + (d[1]+...+d[i])
    //         = i*d[1] + (i-1)*d[2] + ... + 1*d[i]
    // 这正好对应了解决前 i 个最小难度问题的最优总时间。
    for (int i = 1; i <= n; i++) {
        sum[i] = sum[i - 1] + d[i];
        cost[i] = cost[i - 1] + sum[i];
    }

    // 处理询问
    for (int i = 0; i < q; i++) {
        ll t;
        std::cin >> t;

        // 核心逻辑 3: 二分查找 (手动实现)
        // 目标:找到满足 cost[x] <= t 的最大的 x
        // l: 可能解区间的左边界,r: 可能解区间的右边界,ans:
        // 记录当前找到的合法解
        ll l = 1, r = n, ans = 0;
        while (l <= r) {
            ll mid = l + (r - l) / 2;  // 防止 (l+r) 溢出
            if (cost[mid] <= t) {
                // 如果前 mid 个题也能做完,说明 mid 是一个可行解
                ans = mid;    // 记录这个可行解
                l = mid + 1;  // 尝试找更大的解,往右边搜
            } else {
                // 如果前 mid 个题做不完,说明 mid 太大了,答案肯定在左边
                r = mid - 1;
            }
        }
        std::cout << ans << "\n";
    }

    return 0;
}

方法二、利用upper_bound函数

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include <algorithm>
#include <iostream>
#include <vector>

// 使用 long long 防止溢出,t 最大可达 10^16,累加和也会很大
typedef long long ll;

const int MAXN = 1000005;
ll d[MAXN];     // 存储问题难度
ll sum[MAXN];   // 难度的前缀和
ll cost[MAXN];  // 解决前 i 个问题的最小花费(即 sum 的前缀和)

int main() {
    // 优化 I/O 操作速度 (防止 TLE)
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, q;
    std::cin >> n >> q;
    for (int i = 1; i <= n; i++) {
        std::cin >> d[i];
    }

    // 核心逻辑 1: 贪心
    // 为了让解决 k 个问题的总时间最少,我们应该:
    // 1. 选择难度最小的 k 个问题。
    // 2.
    // 在解决这k个问题时,把难度大的放在前面解决(乘数小),难度小的放在后面解决(乘数大)。
    //    具体推导:假设选了 k 个问题,难度从小到大排序为 a1, a2, ..., ak。
    //    第 1 个解决的问题耗时:1 * 难度
    //    第 i 个解决的问题耗时:i * 难度
    //    根据排序不等式,要使乘积之和最小,应该由小的系数乘大的难度,大的系数乘小的难度。
    //    即:1 * ak + 2 * ak-1 + ... + k * a1。
    //    这个式子等价于前缀和的前缀和。
    std::sort(d + 1, d + 1 + n);

    // 核心逻辑 2: 前缀和预处理
    // sum[i] = d[1] + ... + d[i]
    // cost[i] = sum[1] + ... + sum[i]
    //         = (d[1]) + (d[1]+d[2]) + ... + (d[1]+...+d[i])
    //         = i*d[1] + (i-1)*d[2] + ... + 1*d[i]
    // 这正好对应了解决前 i 个最小难度问题的最优总时间。
    for (int i = 1; i <= n; i++) {
        sum[i] = sum[i - 1] + d[i];
        cost[i] = cost[i - 1] + sum[i];
    }

    // 处理询问
    for (int i = 0; i < q; i++) {
        ll t;
        std::cin >> t;

        // 核心逻辑 3: 二分查找
        // 说明:std::upper_bound 在有序区间 [first, last) 中查找第一个 *大于* t
        // 的元素位置。 返回值是一个指针(或迭代器)。 假设 cost[k] <= t 且
        // cost[k+1] > t,那么 upper_bound 会返回 &cost[k+1]。
        // 我们计算下标偏移量: ans = &cost[k+1] - &cost[1] = ( k + 1 ) - 1 =
        // k。 所以 ans 正好就是满足条件 cost[i] <= t 的最大
        // i,也就是能解决的最大问题数。 注意:upper_bound 是 C++
        // 标准库算法,C++98 即支持,C++11/14/17/20 均可使用。
        int ans = std::upper_bound(cost + 1, cost + 1 + n, t) - (cost + 1);
        std::cout << ans << "\n";
    }

    return 0;
}

所有代码已上传至Github:https://github.com/lihongzheshuai/yummy-code

GESP 学习专题站:GESP WIKI

luogu-”系列题目可在洛谷题库进行在线评测。

bcqm-”系列题目可在编程启蒙题库进行在线评测。

欢迎加入Java、C++、Python技术交流QQ群(982860385),大佬免费带队,有问必答

欢迎加入C++ GESP/CSP认证学习QQ频道,考试资源总结汇总

欢迎加入C++ GESP/CSP学习交流QQ群(688906745),考试认证学员交流,互帮互助

GESP/CSP 认证学习微信公众号
GESP/CSP 认证学习微信公众号
本文由作者按照 CC BY-NC-SA 4.0 进行授权