Skip to content

题解里面如果包含有目录,点击目录跳转后,题解的头几行会看不到 #1187

Description

@mjiew

点击目录后, 目录的头几行看不见了:

Image

可以用于测试的题解:

兼职

点击展开目录

1. 题目信息

  • 题目来源:Q1068
  • 题目难度:提高−
  • 关键词/算法标签:贪心、并查集(DSU)、排序、任务调度
  • 时空限制:未明确给出(常规 1s / 256MB)

2. 题意理解

前置知识

阅读本题题解前,请确认你已掌握以下知识:

  • 贪心算法的基本思想
  • 并查集(DSU)的路径压缩
  • 排序算法
  • 基本的可行性分析(Hall 定理的简单形式)

输入描述

给定 $N$ 份工作和时间上限 $M$ 天。每份工作 $i$ 有两个参数:

  • $A_i$:延迟天数。如果在第 $d$ 天完成工作 $i$,则在第 $d + A_i$ 天获得报酬。
  • $B_i$:报酬值。

输出描述

在从今天(第 $0$ 天)起不超过 $M$ 天的时间内,能够获得的最大总报酬。

核心问题

每份工作有一个"最晚完成日"(deadline),每天最多做一份工作,求在 deadline 约束下选择若干工作使总报酬最大。这是一个经典的带截止时间的任务调度问题。

数据范围分析

  • $1 \leqslant N \leqslant 10^5$
  • $1 \leqslant M \leqslant 10^6$
  • $1 \leqslant A_i \leqslant 10^5$
  • $1 \leqslant B_i \leqslant 10^4$

注意:$A_i$ 与 $M$ 的大小关系会影响有效工作数量。当 $A_i > M$ 时,即使第 $0$ 天完成工作 $i$,报酬也在第 $A_i > M$ 天到达,超出时间上限。因此这类工作不可能被完成,应直接忽略。

分层解法概览

数据范围 解法 时间复杂度 空间复杂度
$N \leqslant 20$ 暴力枚举 $O(2^N \cdot N)$ $O(N)$
$N \leqslant 5000$ 动态规划 $O(N \cdot \min(N, M))$ $O(N)$
$N \leqslant 10^5,\ M \leqslant 10^6$ 贪心 + 并查集 $O(N \log N + M)$ $O(N + M)$

3. 题目分析

3.1 初步观察

首先,我们将题目描述转化为更清晰的数学形式。

关键转化:如果在第 $d$ 天完成工作 $i$,报酬在第 $d + A_i$ 天到达。要求 $d + A_i \leqslant M$,即 $d \leqslant M - A_i$

$D_i = M - A_i$,称为工作 $i$截止时间(deadline)。问题转化为:

$N$ 个工作,每个工作有截止时间 $D_i$ 和报酬 $B_i$。每天最多做一个工作。选择若干工作并安排在不同的天数上,使得每个工作都在其截止时间当天或之前完成,求最大总报酬。

原始问题:
  工作 i: 第 d 天完成 → 第 d + A_i 天获得报酬 B_i
  约束: d + A_i ≤ M

转化:
  令 D_i = M - A_i(工作 i 的最晚完成日)
  约束变为: d ≤ D_i

等价问题:
  N 个工作, 每个工作有截止时间 D_i 和报酬 B_i
  每天最多做一个工作
  求最大总报酬

题眼识别:这是一个经典的带截止时间的任务调度(Job Scheduling with Deadlines)问题。

3.2 暴力解法分析

最朴素的思路:枚举所有工作的子集,检查每个子集是否可行(能否安排在不冲突的天数上),取可行子集中的最大报酬。

可行性判定需要一个关键引理:

可行性引理:$k$ 个工作,截止时间分别为 $D_1 \leqslant D_2 \leqslant \cdots \leqslant D_k$(已排序),它们可以被 feasibly 安排(每个工作在一个不同的天数 $\leqslant$ 其截止时间)当且仅当对所有 $i = 1, 2, \ldots, k$,都有 $D_i \geqslant i - 1$

证明

必要性:假设这 $k$ 个工作被 feasibly 安排在了 $k$ 个不同的天 $d_1, d_2, \ldots, d_k$。考虑截止时间最小的前 $i$ 个工作,它们被安排在了 $i$ 个不同的天,且每天 $\leqslant$ 各自截止时间 $\leqslant D_i$。因此 $[0, D_i]$ 范围内至少有 $i$ 个不同的整数,即 $D_i + 1 \geqslant i$,也就是 $D_i \geqslant i - 1$

充分性:如果 $D_i \geqslant i - 1$ 对所有 $i$ 成立,直接将第 $i$ 个工作安排在第 $i - 1$ 天。因为 $D_i \geqslant i - 1$,所以第 $i - 1$$\leqslant D_i$,合法。天数 $0, 1, \ldots, k-1$ 互不相同。因此这是一个可行安排。

$\blacksquare$

暴力解法的瓶颈:枚举 $2^N$ 个子集,每个子集需要 $O(N \log N)$ 排序 + $O(N)$ 检验,总复杂度 $O(2^N \cdot N \log N)$。当 $N > 20$ 时,$2^{20} \approx 10^6$,还能接受;但 $N = 30$$2^{30} \approx 10^9$严重超时

3.3 第一层 暴力枚举

💡 思考线索:为什么想到这一层解法?

  • 信号:$N \leqslant 20$,数据范围极小
  • 联想:$2^{20} \approx 10^6$,可以枚举所有子集
  • 模式匹配:小数据范围 → 暴力枚举所有方案

适用数据范围:$N \leqslant 20$

算法步骤

  1. 计算每个工作的截止时间 $D_i = M - A_i$
  2. 枚举 $0$$2^N - 1$ 的每个 bitmask
  3. 对每个 bitmask,提取选中的工作,按截止时间排序
  4. 用可行性引理检验是否可行
  5. 可行则计算总报酬,更新最大值
点击展开:第一层解法 C++ 代码(n ≤ 20)
#include <bits/stdc++.h>
using namespace std;

struct Job {
    int D, B; // D = 截止时间, B = 报酬
};

Job jobs[25];
int N, M;

// 检查选中的工作是否可行(能否安排在不冲突的天数内)
// 为什么这样检查?根据可行性引理:排序后第 i 个工作的截止时间必须 >= i
bool check(int mask) {
    int cnt = 0;
    int dl[25];
    for (int i = 0; i < N; i++) {
        if (mask & (1 << i)) {
            dl[cnt++] = jobs[i].D;
        }
    }
    // 按截止时间从小到大排序
    for (int i = 0; i < cnt - 1; i++)
        for (int j = i + 1; j < cnt; j++)
            if (dl[i] > dl[j]) {
                int tmp = dl[i]; dl[i] = dl[j]; dl[j] = tmp;
            }
    // 可行性引理:排序后第 i 个工作的截止时间必须 >= i
    for (int i = 0; i < cnt; i++) {
        if (dl[i] < i) return false;
    }
    return true;
}

int main() {
    scanf("%d %d", &N, &M);
    for (int i = 0; i < N; i++) {
        int a, b;
        scanf("%d %d", &a, &b);
        jobs[i].D = M - a; // 截止时间 = M - A_i
        jobs[i].B = b;
    }

    int best = 0;
    // 枚举所有子集
    for (int mask = 0; mask < (1 << N); mask++) {
        if (check(mask)) {
            int sum = 0;
            for (int i = 0; i < N; i++) {
                if (mask & (1 << i)) {
                    sum += jobs[i].B;
                }
            }
            if (sum > best) best = sum;
        }
    }

    printf("%d\n", best);
    return 0;
}

复杂度分析

  • 时间:$O(2^N \cdot N^2)$(枚举 $2^N$ 个子集,每个子集排序 $O(N^2)$$O(N \log N)$
  • 空间:$O(N)$

此解法仅适用于 $N \leqslant 20$。当 $N &gt; 20$ 时,$2^N$ 爆炸式增长,会超时。

3.4 第二层 动态规划

💡 思考线索:为什么想到这一层解法?

  • 信号:$N \leqslant 5000$,$O(N^2)$ 可过
  • 联想:排序后逐个考虑每个工作"选"或"不选" → 类似 0/1 背包
  • 模式匹配:从 $N$ 个物品中选若干使总价值最大 → 背包 DP

上一层的瓶颈:暴力枚举需要遍历 $2^N$ 个子集,指数级复杂度无法接受。我们需要一个多项式时间的算法。

思路分析

将所有工作按截止时间 $D_i$ 从小到大排序。排序后,我们逐个考虑每个工作"选"或"不选"。

状态定义:$dp[j]$ = 从前 $i$ 个工作中恰好选 $j$ 个的最大总报酬。

为什么这样定义? 因为排序后,如果我们选了 $j$ 个工作,且第 $i$ 个工作是其中之一(截止时间最大),那么可行性条件简化为 $j \leqslant D_i + 1$(由可行性引理,前 $j-1$ 个工作已 feasibly 安排,第 $j$ 个工作需要 $D_i \geqslant j - 1$)。

转移方程:对于第 $i$ 个工作(截止时间 $D_i$,报酬 $B_i$):

$$dp[j] = \max(dp[j],\ dp[j-1] + B_i) \quad \text{其中 } j \leqslant D_i + 1$$

  • 不选工作 $i$:$dp[j]$ 不变
  • 选工作 $i$:$dp[j] = dp[j-1] + B_i$,要求 $j \leqslant D_i + 1$

为什么 $j \leqslant D_i + 1$ 就够了? 因为前 $j-1$ 个选中的工作已经 feasibly 安排在 $j-1$ 天(由 $dp[j-1]$ 的合法性保证),它们可以重排到第 $0, 1, \ldots, j-2$ 天。工作 $i$ 的截止时间 $D_i \geqslant j - 1$,所以可以安排在第 $j - 1$ 天。

初始化:$dp[0] = 0$,$dp[j] = -1$($j \geqslant 1$,表示不可达)。

答案:$\max_{j} dp[j]$。

点击展开:第二层解法 C++ 代码(n ≤ 5000)
#include <bits/stdc++.h>
using namespace std;

struct Job {
    int D, B; // D = 截止时间, B = 报酬
};

const int MAXN = 5005;
Job jobs[MAXN];
int N, M;
long long dp[MAXN]; // dp[j] = 恰好选 j 个工作的最大报酬

bool cmp(const Job& a, const Job& b) {
    if (a.D != b.D) return a.D < b.D; // 按截止时间升序
    return a.B > b.B;
}

int main() {
    scanf("%d %d", &N, &M);
    for (int i = 0; i < N; i++) {
        int a, b;
        scanf("%d %d", &a, &b);
        jobs[i].D = M - a;
        jobs[i].B = b;
    }

    sort(jobs, jobs + N, cmp);

    // dp[j] = -1 表示不可达
    for (int j = 1; j <= N; j++) dp[j] = -1;
    dp[0] = 0;

    long long ans = 0;
    for (int i = 0; i < N; i++) {
        if (jobs[i].D < 0) continue; // 截止时间 < 0 的工作不可能完成
        // 最多能选 min(i+1, D_i+1) 个工作
        int maxJ = i + 1;
        if (maxJ > jobs[i].D + 1) maxJ = jobs[i].D + 1;
        // 从大到小枚举 j,避免同一工作被重复选择(0/1 背包技巧)
        for (int j = maxJ; j >= 1; j--) {
            if (dp[j - 1] >= 0) {
                long long val = dp[j - 1] + jobs[i].B;
                if (val > dp[j]) dp[j] = val;
            }
        }
    }

    for (int j = 0; j <= N; j++) {
        if (dp[j] > ans) ans = dp[j];
    }

    printf("%lld\n", ans);
    return 0;
}

复杂度分析

  • 时间:排序 $O(N \log N)$ + DP $O(N \cdot \min(N, M))$。当 $N = 5000$ 时,$O(N^2) = 2.5 \times 10^7$,可以接受。
  • 空间:$O(N)$(一维 DP 数组)

此解法仅适用于 $N \leqslant 5000$。当 $N = 10^5$ 时,$O(N^2) = 10^{10}$,会超时。

3.5 第三层 贪心与并查集

💡 思考线索:为什么想到这一层解法?

  • 信号:$N \leqslant 10^5$,需要 $O(N \log N)$ 级别
  • 联想:带截止时间的任务调度 → 经典贪心模型
  • 模式匹配:每天一个"槽位",需要快速找"最晚可用槽位" → 并查集

上一层的瓶颈:DP 的 $O(N \cdot \min(N, M))$$N = 10^5$ 时达到 $10^{10}$,无法接受。我们需要找到更高效的算法。

思路分析

观察发现,这个问题具有拟阵(matroid)结构:可行工作集合满足子集封闭性和交换性。对于拟阵上的最优化问题,贪心算法是最优的

贪心策略

  1. 将所有工作按报酬 $B_i$ 从大到小排序
  2. 依次考虑每个工作,尝试将其安排在 $\leqslant D_i$最晚可用天
  3. 如果能找到可用天,就安排该工作;否则跳过

为什么贪心是正确的?

下面给出交换论证的证明:

设贪心解为 $G$,最优解为 $O$,且 $\sum_{i \in O} B_i &gt; \sum_{i \in G} B_i$

$G$$O$ 中的工作分别按报酬从大到小排序:$g_1, g_2, \ldots, g_k$ 和 $o_1, o_2, \ldots, o_m$

由于贪心按报酬从大到小处理,$g_1$ 是所有工作中报酬最大的。如果 $g_1 \notin O$,我们可以将 $O$ 中报酬最小的工作替换为 $g_1$(因为 $g_1$ 报酬最大,替换后总报酬不降)。重复此过程,总可以将 $O$ 转化为 $G$ 而不降低总报酬,矛盾。

因此 $\sum_{i \in G} B_i \geqslant \sum_{i \in O} B_i$,贪心是最优的。$\blacksquare$

关键问题:如何高效找"最晚可用天"?

我们需要一个数据结构,支持以下操作:

  1. 查询 $\leqslant D$ 的最大可用天
  2. 将某天标记为"已占用"

候选数据结构分析

数据结构 查询最晚可用天 标记占用 是否满足?
数组暴力扫描 $O(M)$ $O(1)$ 不满足:查询太慢,总复杂度 $O(NM)$
std::set $O(\log M)$upper_bound $O(\log M)$ 满足,但常数较大
线段树 $O(\log M)$ $O(\log M)$ 满足,但代码复杂
并查集(DSU) 均摊 $O(\alpha(M))$ 均摊 $O(\alpha(M))$ 满足,代码最短、常数最小

选择并查集的原因:代码最短、常数最小,且完全满足需求。

并查集的设计

  • parent[x] 表示天 $x$ 的"代表元"
  • 初始时 parent[x] = x,表示每天都是可用的
  • 当某天 $d$ 被占用后,令 parent[d] = find(d - 1),表示"找天 $d$ 时,请去找 $d - 1$ 以内的最晚可用天"
  • find(x) 返回 $\leqslant x$ 的最晚可用天;如果返回 $-1$,表示没有可用天
并查集工作原理图示:

初始状态 (M=4):
parent: [0]=0  [1]=1  [2]=2  [3]=3  [4]=4
          ↑      ↑      ↑      ↑      ↑
        可用    可用    可用    可用    可用

占用第 2 天后:
parent: [0]=0  [1]=1  [2]=1  [3]=3  [4]=4
          ↑      ↑      ↑      ↑      ↑
        可用    可用   指向1   可用    可用
               (第2天被占, find(2)→find(1)=1)

再占用第 1 天后:
parent: [0]=0  [1]=0  [2]=0  [3]=3  [4]=4
          ↑      ↑      ↑      ↑      ↑
        可用   指向0  指向0   可用    可用
              (第1天被占, find(1)→find(0)=0, find(2)路径压缩→0)

再占用第 0 天后:
parent: [0]=-1 [1]=-1 [2]=-1 [3]=3  [4]=4
          ↑      ↑      ↑      ↑      ↑
        不可用  不可用  不可用   可用    可用

代码实现

#include <bits/stdc++.h>
using namespace std;

struct Job {
    int D, B; // D = 截止时间(最晚完成日), B = 报酬
};

const int MAXN = 100005;
const int MAXM = 1000005;
Job jobs[MAXN];
int parent[MAXM]; // 并查集:parent[x] 指向 x 以内最晚可用天
int N, M;

// 为什么用自定义 cmp 而不是 lambda?规范禁止 lambda
bool cmp(const Job& a, const Job& b) {
    return a.B > b.B; // 按报酬从大到小排序
}

// 并查集查找:找 x 以内最晚的可用天
// 为什么用并查集?因为需要高效查找"最晚可用天",路径压缩使均摊复杂度为 O(α(M))
int find(int x) {
    if (x < 0) return -1; // 没有可用天
    if (parent[x] == x) return x; // x 本身可用
    return parent[x] = find(parent[x]); // 路径压缩:直接指向最终可用天
}

int main() {
    scanf("%d %d", &N, &M);
    for (int i = 0; i < N; i++) {
        int a, b;
        scanf("%d %d", &a, &b);
        jobs[i].D = M - a; // 截止时间 = M - A_i
        jobs[i].B = b;
    }

    sort(jobs, jobs + N, cmp);

    // 初始化并查集:parent[i] = i 表示第 i 天可用
    for (int i = 0; i <= M; i++) parent[i] = i;

    long long ans = 0;
    for (int i = 0; i < N; i++) {
        if (jobs[i].D < 0) continue; // 截止时间 < 0,无法在 M 天内获得报酬
        int day = find(jobs[i].D); // 找 deadline 以内最晚的空闲天
        if (day >= 0) {
            ans += jobs[i].B;
            // 占用 day 天后,将 day 指向 day-1 以内的最晚可用天
            // 为什么这样写?因为 find(day-1) 会返回 day-1 以内最晚可用天
            parent[day] = find(day - 1);
        }
    }

    printf("%lld\n", ans);
    return 0;
}

正解算法流程(摘要):
步骤 1: 读入数据,计算每个工作的截止时间 D_i = M - A_i
步骤 2: 按报酬 B_i 从大到小排序
步骤 3: 初始化并查集 parent[i] = i(0 ≤ i ≤ M)
步骤 4: 依次处理每个工作,找 ≤ D_i 的最晚可用天
步骤 5: 若找到可用天,累加报酬并占用该天
步骤 6: 输出总报酬

3.6 样例逐步演示

以样例 1 为例,逐步展示算法执行过程。

样例输入

3 4
4 3
4 1
2 2

步骤 1:计算截止时间

工作 $A_i$ $B_i$ $D_i = M - A_i$
1 4 3 0
2 4 1 0
3 2 2 2

步骤 2:按报酬从大到小排序

顺序 工作 $D_i$ $B_i$
1 1 0 3
2 3 2 2
3 2 0 1

步骤 3:初始化并查集

parent: [0]=0  [1]=1  [2]=2  [3]=3  [4]=4

步骤 4:处理工作 1(D=0, B=3)

  • find(0)parent[0] = 0 → 返回 0
  • $0$ 天可用,安排工作 1
  • ans += 3 = 3
  • parent[0] = find(-1) = -1
parent: [0]=-1 [1]=1  [2]=2  [3]=3  [4]=4
         ↑ 不可用

步骤 5:处理工作 3(D=2, B=2)

  • find(2)parent[2] = 2 → 返回 2
  • $2$ 天可用,安排工作 3
  • ans += 2 = 5
  • parent[2] = find(1) = 1
parent: [0]=-1 [1]=1  [2]=1  [3]=3  [4]=4
         ↑      ↑      ↑
       不可用  可用   指向1

步骤 6:处理工作 2(D=0, B=1)

  • find(0)parent[0] = -1 → 返回 -1
  • 没有可用天,跳过

最终输出ans = 5


4. 复杂度分析总结

时间复杂度

算法部分 来源 单次复杂度 执行次数 该项总复杂度
排序 $N$ 个工作按 $B_i$ 降序排序 $O(\log N)$(比较) 排序共 $O(N \log N)$ $O(N \log N)$
DSU 初始化 初始化 parent[0..M] $O(1)$ $M + 1$ $O(M)$
DSU 查找 每个工作调用一次 find(D_i) 均摊 $O(\alpha(M))$ 最多 $N$ $O(N \cdot \alpha(M))$
DSU 合并 占用天时执行 parent[day] = find(day-1) 均摊 $O(\alpha(M))$ 最多 $N$ $O(N \cdot \alpha(M))$

总时间复杂度:$O(N \log N) + O(M) + O(N \cdot \alpha(M)) = $ $O(N \log N + M)$

其中 $\alpha(M)$ 是反 Ackermann 函数,对于所有实际数据规模 $\alpha(M) \leqslant 4$,可视为常数。

空间复杂度

数组/数据结构 大小 类型 占用空间
jobs[] $10^5 + 5$ struct Job(8 字节) 约 800 KB
parent[] $10^6 + 5$ int(4 字节) 约 4 MB

总空间复杂度:$O(N + M)$,约 5 MB,远在 256 MB 限制内。

总结表格

复杂度类型 分析
时间复杂度 排序 $O(N \log N)$ + DSU 初始化 $O(M)$ + DSU 操作 $O(N \cdot \alpha(M))$,总复杂度 $O(N \log N + M)$
空间复杂度 jobs[] $O(N)$ + parent[] $O(M)$,总空间 $O(N + M)$
正确性 基于拟阵上的贪心算法最优性,并通过交换论证严格证明

5. 多种解法 贪心与优先队列

除了贪心 + 并查集,还存在另一种同样能 AC 的解法:贪心 + 优先队列(小根堆)

解法名称

贪心 + 优先队列(小根堆)

核心思想

按截止时间从小到大处理工作,维护一个已选工作报酬的小根堆。对于每个工作:

  • 如果堆的大小 $\leqslant D_i$,说明还有空位,直接加入
  • 否则,如果当前工作报酬 $&gt;$ 堆顶(已选工作中报酬最小的),则替换

完整推导

排序方式:按截止时间 $D_i$ 从小到大排序。

维护什么:一个小根堆,存储已选工作的报酬值。堆的大小 $|S|$ 表示已选工作的数量。

处理逻辑

对于第 $i$ 个工作(截止时间 $D_i$,报酬 $B_i$):

  1. 如果 $|S| \leqslant D_i$:说明已选工作数量 $\leqslant D_i$,而 $D_i + 1$ 天($0$ 到 $D_i$)中至少有 $D_i + 1 - |S| \geqslant 1$ 天是空的。所以可以直接加入工作 $i$
  2. 如果 $|S| &gt; D_i$(即 $|S| = D_i + 1$,因为之前 $|S| \leqslant D_i + 1$):所有 $D_i + 1$ 天都被占用了。如果 $B_i > $ 堆顶(已选工作中报酬最小的),则替换掉堆顶,总报酬增加。

正确性证明

替换后,堆的大小不变(仍为 $D_i + 1$),且所有已选工作的截止时间都 $\leqslant D_i$。由可行性引理,$D_i + 1$ 个工作安排在 $D_i + 1$ 天($0$ 到 $D_i$)上是可行的。

代码实现

#include <bits/stdc++.h>
using namespace std;

struct Job {
    int D, B; // D = 截止时间, B = 报酬
};

const int MAXN = 100005;
Job jobs[MAXN];
int N, M;

bool cmp(const Job& a, const Job& b) {
    if (a.D != b.D) return a.D < b.D; // 按截止时间升序
    return a.B > b.B;
}

int main() {
    scanf("%d %d", &N, &M);
    for (int i = 0; i < N; i++) {
        int a, b;
        scanf("%d %d", &a, &b);
        jobs[i].D = M - a;
        jobs[i].B = b;
    }

    sort(jobs, jobs + N, cmp);

    // 小根堆:维护已选工作的报酬,堆顶是最小报酬
    // 为什么用小根堆?需要快速找到并替换已选工作中报酬最小的那个
    priority_queue<int, vector<int>, greater<int> > pq;

    for (int i = 0; i < N; i++) {
        if (jobs[i].D < 0) continue;
        if ((int)pq.size() <= jobs[i].D) {
            // 还有空位(已选数量 ≤ D_i),直接加入
            pq.push(jobs[i].B);
        } else if (!pq.empty() && pq.top() < jobs[i].B) {
            // 没有空位,但当前工作报酬更高,替换掉最小的
            pq.pop();
            pq.push(jobs[i].B);
        }
    }

    long long ans = 0;
    while (!pq.empty()) {
        ans += pq.top();
        pq.pop();
    }

    printf("%lld\n", ans);
    return 0;
}

复杂度分析

  • 时间:排序 $O(N \log N)$ + 堆操作 $O(N \log N)$ = $O(N \log N)$
  • 空间:$O(N)$(堆最多存 $N$ 个元素)

6. 解法对比与演化

对比表格

对比维度 贪心 + 并查集 贪心 + 优先队列
核心算法 贪心 + DSU 贪心 + 小根堆
排序方式 按报酬降序 按截止时间升序
时间复杂度 $O(N \log N + M)$ $O(N \log N)$
空间复杂度 $O(N + M)$ $O(N)$
代码复杂度 中等 简单
常数因子
思维难度
可扩展性 强(可输出具体方案) 弱(只能输出最优值)

本质联系

两种解法都是拟阵上贪心算法的不同实现:

  • 并查集版:按报酬从大到小处理,显式地将每个工作分配到具体的天。DSU 用于高效查找"最晚可用天"。
  • 优先队列版:按截止时间从小到大处理,隐式地维护已选工作集合。堆用于快速替换报酬最小的工作。

两者殊途同归,最终选出的工作集合是相同的(在报酬不重复的情况下)。

解法演化

暴力枚举 (O(2^N · N))
    ↓ 发现:排序后可以用可行性引理 O(N) 检验
    ↓ 优化:用 DP 代替枚举
动态规划 (O(N · min(N, M)))
    ↓ 发现:可行集构成拟阵,贪心最优
    ↓ 优化:用贪心 + 高效数据结构
贪心 + 并查集 / 优先队列 (O(N log N + M) / O(N log N))

7. 常见错误与易错点分析

7.1 常见错误思路

错误思路一:按截止时间从早到晚贪心,选报酬最大的

  • 为什么看起来对:EDF(最早截止优先)是经典的可行调度算法,直觉上"先做紧急的"很合理
  • 反例:$N = 2, M = 1$,工作 $(A=1, B=1), (A=1, B=100)$,截止时间都是 $D=0$。EDF 可能选了 $B=1$ 而错过 $B=100$
  • 错误本质:EDF 保证可行性,但不保证最优性;没有考虑报酬权值

错误思路二:按报酬从大到小贪心,但安排到最早可用天

  • 为什么看起来对:报酬最大的工作优先安排,直觉正确
  • 反例:$N = 3, M = 2$。工作:$(D=1, B=100), (D=0, B=10), (D=0, B=10)$。如果将 $B=100$ 安排在第 $0$ 天(最早可用),则 $D=0$ 的两个工作无法安排(第 $0$ 天被占),总计 $100$。正确做法是将 $B=100$ 安排在第 $1$ 天(最晚可用天),留第 $0$ 天给 $D=0$ 的工作,总计 $110$
  • 错误本质最早可用天会"挤占"后面截止时间更早的工作的空间;必须安排在最晚可用天,为后面的工作留出余地

错误思路三:认为答案可能超出 int 范围而盲目使用 long long,或不分析就使用 int

  • 分析:最大答案 $= \min(N, M) \times \max(B_i) = 10^5 \times 10^4 = 10^9$。$10^9 < 2^{31} - 1 \approx 2.1 \times 10^9$,所以 int 足够。但使用 long long 更安全且无性能损失
  • 建议:使用 long long,但要知道为什么不会溢出

7.2 代码易错点

  1. 截止时间计算错误

    • 错误:写成 D = AD = M + A
    • 正确D = M - A(因为 $d + A \leqslant M \Rightarrow d \leqslant M - A$
    • 后果:WA
  2. 未处理 $D_i &lt; 0$ 的情况

    • 错误:直接对所有工作调用 find(D_i),当 $D_i &lt; 0$ 时可能数组越界
    • 正确if (jobs[i].D < 0) continue;
    • 后果:RE 或 WA
  3. 并查集未初始化到 $M$

    • 错误:只初始化到 $N$$\max(D_i)$
    • 正确:初始化 parent[0..M],因为 $D_i$ 最大为 $M - 1$
    • 后果:RE 或 WA
  4. find 函数未处理 $x &lt; 0$

    • 错误find(-1) 导致数组越界
    • 正确if (x < 0) return -1;
    • 后果:RE
  5. 排序方向搞反

    • 错误:按报酬从小到大排序
    • 正确:按报酬从大到小排序
    • 后果:WA(贪心策略失效)

8. 边界与特殊情况处理

情况 分析 代码处理
所有 $A_i &gt; M$ 所有工作截止时间 $&lt; 0$,无法完成任何工作 D_i < 0continue,答案为 $0$
$N = 1$ 只有一份工作,能做就做 算法自然处理:$D_0 \geqslant 0$ 则安排,否则跳过
$M$ 非常大 所有工作都能完成($D_i \geqslant N$) 算法自然处理:所有工作都能找到可用天
多个工作截止时间相同 需要竞争有限的天数 并查集自动处理:每次找最晚可用天,冲突时自动跳过
答案为 $0$ 没有任何工作能在 $M$ 天内完成 样例 3 即为这种情况,算法输出 $0$

9. 解题总结

本题核心 trick

将"延迟 $A_i$"转化为"截止时间 $D_i = M - A_i$",从而将原问题等价转化为经典的带截止时间任务调度问题。然后利用拟阵上贪心的最优性,按报酬从大到小排序,用并查集高效查找最晚可用天。

可复用的解题模式

  1. 延迟 → 截止时间的转化:适用于所有"完成时间 + 延迟 ≤ 上限"的问题
  2. 贪心 + 并查集找最晚可用槽位:适用于所有"每个任务有截止时间、每个槽位最多放一个任务"的调度问题
  3. 可行性引理:排序后 $D_i \geqslant i - 1$ 是判断任务集合可调度性的充要条件

一句话记忆点

看到"带截止时间的任务调度,每个槽位最多一个任务" → 按价值排序 + 并查集找最晚可用天

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions