
题目版权归考试主办方所有!!!本文仅做非盈利性质的交流分享!!!
多多的减满活动
有n个各不相同的商品,每个商品都有一个价格。若挑选的两个商品的价格综合是m的倍数的花,可以免费带走这两个商品。求带走任意两件商品,总共有多少种组合?
输入第一行为n和m。已知1<=n<=2*10^5, 1<=m<=2*10^5。
第二行为n个商品的价格nums[i]
。已知1<=nums[i]<=10^9
。
测试用例1:
text
2 4
1 3
输出:
text
1
测试用例2:
text
5 2
1 2 3 4 5
输出:
text
4
首先想到O(n^2)复杂度的暴力解法,但是会超时:
C++
#include <bits/stdc++.h>
int main() {
int n; // 商品个数
int m; // 挑选两个商品,要求价格是m的倍数
std::cin >> n;
std::cin >> m;
std::vector<int64_t> nums;
for (int i = 0; i < n; ++i) {
int t;
std::cin >> t;
nums.push_back(t);
}
// 枚举所有组合可能
int64_t ans = 0;
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
int64_t sum = nums[i] + nums[j];
if (sum % m == 0) {
++ans;
}
}
}
std::cout << ans;
}
如果想要将复杂度降到O(n),一种直观的思路是借鉴"两数之和"的思路,引入哈希表来消除内循环。
已知(a + b) % m = 0
,要借鉴两数之和的思路,就必须先搞清楚这个式子里a和b的关系。即尝试使用a来表示b。
根据模除的分配律性质,可以得到(rem_a + rem_b) % m = 0
,其中rem_a = a % m
,rem_b = b % m
。
另外0 <= rem_a < m
,0 <= rem_b < m
,于是可知0 <= rem_a + rem_b < 2 * m
。
于是我们知道rem_a + rem_b
的取值要么为0
,要么为m
。现在以rem_a
的取值情况,进一步进行讨论:
- 当
rem_a = 0
时,一定有rem_b = 0
- 当
rem_a ≠ 0
时,一定有rem_b = m - rem_a
C++
#include <iostream>
#include <vector>
#include <unordered_map>
int main() {
int n, m;
std::cin >> n >> m;
std::vector<int64_t> ar(n);
for (int i = 0; i < n; ++i) {
std::cin >> ar[i];
}
int64_t ans = 0;
std::unordered_map<int, int> remainder_count;
for (int i = 0; i < n; ++i) {
int rem_a = ar[i] % m;
int rem_b = rem_a == 0 ? 0 : m - rem_a;
ans += remainder_count[rem_b];
++remainder_count[rem_a];
}
std::cout << ans;
}
多多果园的小动物
果园仓库里有x个果实。
从第1天开始,每天早晨都会有一只小动物前来果园申请定居。如果第i天来的小动物成功申请到定居资格,那么它从第i天开始,一直到第n天结束为止,每天都必须要从仓库中吃掉c[i]
个果实。
多多先生非常善良,他希望能有尽可能多的小动物在果园定居下来。
求到第i天结束时,最多能有几只小动物在果园成功定居下来?
第一行输入为n和x。已知1<=n<=2*10^5,1<=x<=10^18。
第二行输入为n个小动物各自每天需要吃掉的果实数c[i]
,已知1<=c[i]<=300
。
测试用例1:
text
3 8
2 2 2
输出:
text
2
测试用例2:
text
3 12
2 2 2
输出:
text
3
第一眼看上去这是一个"选或不选"的经典DP问题。但是尴尬的是,这题严卡空间,这种需要开辟辅助数组的解法过不了。
C++
#include <bits/stdc++.h>
int main() {
int days; // 总天数
int64_t x; // 初始时的果实筐数
std::cin >> days >> x;
// 计算出每只小动物总共的食量
std::vector<int> total_eating;
for (int day = 0; day < days; ++day) {
int t;
std::cin >> t;
total_eating.push_back(t * (days - day));
}
// 对于每只小动物,都有选或者不选两种方案
// dp[i][j] 总果实数为j,从第0~i只小动物中选,最多可以选几只小动物
std::vector<int> dp(x + 1, 0);
for (int j = total_eating[0]; j <= x; ++j) {
dp[j] = 1;
}
for (int i = 1; i < days; ++i) {
std::vector<int> new_dp(x + 1);
for (int j = 0; j <= x; ++j) {
new_dp[j] = dp[j];
if (0 <= j - total_eating[i]) {
new_dp[j] = std::max(new_dp[j], dp[j - total_eating[i]] + 1);
}
}
dp = std::move(new_dp);
}
std::cout << dp.back();
}
当dp被卡空间时,这是一个强烈的信号,引导我们使用更直接的贪心算法。
事实上这道题的贪心策略也很好理解:既然我们希望定居的小动物越多,那相应地我们希望果实x的消耗速度也越慢越好。也就是说,我们应该优先让总食量小的动物优先来定居。
C++
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
int days; // 总天数
int64_t x; // 初始时的果实筐数
std::cin >> days >> x;
// 计算出每只小动物总共的食量
std::vector<int> total_eating;
for (int day = 0; day < days; ++day) {
int t;
std::cin >> t;
total_eating.push_back(t * (days - day));
}
std::sort(total_eating.begin(), total_eating.end());
int ans = 0;
for (int i = 0; i < days; ++i) {
if (total_eating[i] > x) break;
x -= total_eating[i];
++ans;
}
std::cout << ans;
}
从这道题出发,我们还可以更进一步地思考一下:为什么贪心算法对传统01背包问题不生效,却可以用来解这道题。其中到底是哪一步导致了这个不同呢?
标准的0/1背包问题有三个核心要素:
- 容量 (Capacity) :背包的总承重 W。
- 物品的重量 (Weight) :每个物品 i 有一个重量 w_i。
- 物品的价值 (Value) :每个物品 i 有一个价值 v_i。
目标 :在不超过总容量 W 的前提下,选择物品,使得总价值 V 最大化。
为什么贪心会失效?
我们可能会想出几种贪心策略:
- 优先选价值最高的? 可能一个价值极高的物品也极重,直接占满了所有空间,导致总价值不高。
- 优先选重量最轻的? 可能装了一堆很轻的"垃圾",总价值很低。
- 优先选"性价比"最高的(价值/重量比)? 这是最接近正确答案的贪心策略,但对0/1背包问题依然会失效。
看一个经典的反例:
- 背包容量 W = 50
- 物品1: w_1 = 10, v_1 = 60 (性价比 = 6)
- 物品2: w_2 = 20, v_2 = 100 (性价比 = 5)
- 物品3: w_3 = 30, v_3 = 120 (性价比 = 4)
如果按照性价比贪心:
- 选择物品1 (性价比最高)。剩余容量 50 - 10 = 40。总价值 60。
- 选择物品2。剩余容量 40 - 20 = 20。总价值 60 + 100 = 160。
- 物品3装不下了。
最终方案:物品1+物品2,总价值160。
但最优解 是:
选择物品2和物品3,总重量 20 + 30 = 50,总价值 100 + 120 = 220。
这个反例清晰地表明,因为物品的价值不同,贪心地选择局部最优(当前性价比最高)无法保证全局最优。
现在我们回头看"多多果园"这道题。我们可以把它也套进背包问题的框架里:
- 容量 (Capacity) :初始果实总量 X。
- 物品的"重量" (Weight) :收养一只小动物所需的果实总消耗量 total_eating_i。
- 物品的"价值" (Value) :每只小动物的价值都是 1。
目标 :在不超过总容量 X 的前提下,选择小动物,使得总数量 (也就是总价值) 最大化。
你发现关键区别了吗?
所有物品的"价值"都相等 (都为1)!
在这个核心前提下,我们的目标函数变得非常纯粹:最大化物品的数量。
要想在有限的预算(容量 X)内,买到最多的东西,你会怎么做?当然是永远先买最便宜的!
这个直觉是绝对正确的。因为每个物品(小动物)对最终目标(动物数量)的贡献是完全相同的(都是+1),所以为了能容纳更多的物品,我们唯一的策略就是让每个物品的"成本"(果实消耗)尽可能地低。
多多的闯关游戏
一款闯关游戏,游戏中有N个依次排列的关卡,每个关卡都有两个属性
- 通关奖励:通关后可以获得多少积分
- 挑战难度:一个正整数,数值越大表示关卡越难
现在多多想要选择一段连续的关卡来挑战(比如第3关到第7关),选择的这段关卡区间需要满足两个要求:
- 这些关卡通关后获得的总积分至少需要达到T
- 在满足积分要求的前提下,选择的这些关卡中,挑战难度的最大值要尽可能地小
请找出满足条件的一段关卡,并输出这段关卡中挑战难度的最大值
第一行输入为N和T。已知1≤N≤10^6
,1≤T≤10^18
。
接下来的N行,分别表示每个关卡的通关奖励s[i]
和挑战难度d[i]
。已知1≤s[i]≤10^18
,1≤d[i]≤10^9
。
测试用例1:
text
5 10
4 10
6 15
3 5
4 9
3 6
输出:
text
9
暴力解法,会被卡时间:
C++
#include <bits/stdc++.h>
#include <limits>
int level_num; // 关卡数目
int target_score; // 目标分数
int main() {
std::cin >> level_num >> target_score;
std::vector<int> score; // score[i] 第i个关卡的得分
std::vector<int> nandu; // nandu[i] 第i个关卡的难度
for (int i = 0; i < level_num; ++i) {
int s, d;
std::cin >> s >> d;
score.push_back(s);
nandu.push_back(d);
}
int nandu_ans = std::numeric_limits<int>::max();
for (int i = 0; i < level_num; ++i) {
int score_sum = score[i];
int max_nandu = nandu[i];
if (score_sum >= target_score) {
nandu_ans = std::min(nandu_ans, max_nandu);
}
for (int j = i + 1; j < level_num; ++j) {
score_sum += score[j];
max_nandu = std::max(max_nandu, nandu[j]);
if (score_sum >= target_score) {
nandu_ans = std::min(nandu_ans, max_nandu);
}
}
}
std::cout << nandu_ans;
}
再次分析题目,发现其中的"选择一段连续的关卡",这往往是提示我们使用滑动窗口的一个强烈信号。
这里我们跨度先不要太大,可以先写一版,把滑动窗口的代码框架搭建起来:
C++
#include <vector>
#include <limits>
#include <iostream>
int level_num; // 关卡数目
int target_score; // 目标分数
int main() {
std::cin >> level_num >> target_score;
std::vector<int> score; // score[i] 第i个关卡的得分
std::vector<int> nandu; // nandu[i] 第i个关卡的难度
for (int i = 0; i < level_num; ++i) {
int s, d;
std::cin >> s >> d;
score.push_back(s);
nandu.push_back(d);
}
int score_sum = 0;
int ans_nandu = std::numeric_limits<int>::max();
int l = 0;
int r = 0;
while (r < level_num) {
score_sum += score[r];
// 如果窗口内的总分数<target_score,窗口持续向右扩张
if (score_sum < target_score) {
++r;
continue;
}
// 收缩左边界
while (l <= r && score_sum >= target_score) {
int max_nandu = nandu[l];
for (int i = l + 1; i <= r; ++i) {
max_nandu = std::max(max_nandu, nandu[i]);
}
ans_nandu = std::min(ans_nandu, max_nandu);
score_sum -= score[l];
++l;
}
++r;
}
std::cout << ans_nandu;
}
虽然现在滑动窗口的代码,但我们很容易就会发现其中存在着一个致命问题:每当滑动窗口的左边界发生收缩时,虽然更新score_sum
和l
的开销都是O(1),但很不幸更新max_nandu
的开销上界仍然是O(n),这意味着整个算法的时间复杂度上界仍然是O(n^2)。
我们的关键任务在于如何高效地维护和查询窗口内的最值。幸运的是,我们学过单调队列。它正是为了解决"滑动窗口(定长或不定长)中的最值问题"而生的完美工具,对本题一样适用。
修改版的代码如下,可以证明它的时间复杂度已被压缩到O(n):
C++
#include <vector>
#include <limits>
#include <iostream>
#include <deque>
#include <queue>
int level_num; // 关卡数目
int target_score; // 目标分数
int main() {
std::cin >> level_num >> target_score;
std::vector<int64_t> score; // score[i] 第i个关卡的得分
std::vector<int64_t> nandu; // nandu[i] 第i个关卡的难度
for (int i = 0; i < level_num; ++i) {
int s, d;
std::cin >> s >> d;
score.push_back(s);
nandu.push_back(d);
}
int64_t score_sum = 0;
int64_t ans_nandu = std::numeric_limits<int>::max();
int l = 0;
int r = 0;
std::deque<int> dq;
while (r < level_num) {
score_sum += score[r];
while (!dq.empty() && nandu[dq.back()] <= nandu[r]) {
dq.pop_back();
}
dq.push_back(r);
// 如果窗口内的总分数<target_score,窗口持续向右扩张
if (score_sum < target_score) {
++r;
continue;
}
// 收缩左边界
while (l <= r && score_sum >= target_score) {
ans_nandu = std::min(ans_nandu, nandu[dq.front()]);
if (dq.front() == l) {
dq.pop_front();
}
score_sum -= score[l];
++l;
}
++r;
}
std::cout << ans_nandu;
}
还有一种做法是,对题目进行转换:是否存在一个连续的关卡区间,其中所有关卡的难度都 ≤ X,并且它们的总得分 ≥ target_score? 我们可以先把这个检测函数写出来(O(n)复杂度),然后直接利用这个函数来二分答案(O(logD))即可,总复杂度为O(nlogD)。由于D的上界为10^9,log(10^9)≈29,远小于n的数量级,因此这个算法的效率是非常可观的。
C++
#include <vector>
#include <limits>
#include <iostream>
int level_num; // 关卡数目
int64_t target_score; // 目标分数
std::vector<int64_t> score; // score[i] 第i个关卡的得分
std::vector<int64_t> nandu; // nandu[i] 第i个关卡的难度
// 是否存在一组连续的关卡,它们的总得分≥target_score,且最大的关卡难度≤x
bool Check(int64_t x) {
int64_t score_sum = 0;
int64_t l = 0;
int64_t r = 0;
while (r < level_num) {
if (nandu[r] > x) {
++r;
l = r;
score_sum = 0;
continue;
}
score_sum += score[r];
// 如果窗口内的总分数<target_score,窗口持续向右扩张
if (score_sum < target_score) {
++r;
continue;
}
if (score_sum >= target_score) {
return true;
}
}
return false;
}
int main() {
std::cin >> level_num >> target_score;
int64_t max_nandu = 0;
for (int i = 0; i < level_num; ++i) {
int64_t s, d;
std::cin >> s >> d;
score.push_back(s);
nandu.push_back(d);
max_nandu = std::max(max_nandu, d);
}
int64_t i = 0;
int64_t j = max_nandu;
// [0, i), [i, j], (j, max_nandu]
// <最终答案 >=最终答案
while (i <= j) {
int64_t mid = ((j - i) >> 1) + i;
if (Check(mid)) {
j = mid - 1;
} else {
i = mid + 1;
}
}
std::cout << i;
}
多多的植树计划
多多君准备在多多路上再种一些树木。
多多路上已经有了N棵树(编号1~N)。对于每一棵树,都有一个美观值。第i棵树的美观值记作ar[i]
。
另外对于多多路上的每个区间,我们定义其整体的美观值等于该区间中每棵树的美观值之和。
多多君不太喜欢数字M,所以当某个区间的美观值为M时,就需要在该区间中再种若干棵树木,使得没有任何一个区间的美观值为M。
请问:假设新种植的树木的美观值可以任选的情况下,最少需要再种多少棵树木,可以满足上述要求?
输入第一行分别为N和M。已知1<=N<=10^5,-10^6<=M<=10^6
第二行为N个整数,从ar[1]
到ar[N]
。已知-10^6<=ar[i]<=10^6
,且题目保证a[i]≠M
。
输出最少需要再栽种的树木数量。
测试用例1:
text
4 -1
3 -3 2 3
输出:
1
测试用例2:
5 100
1 2 3 4 5
输出:
0
测试用例3:
diff
9 0
-1 1 -1 1 -1 1 1 -1 -1
输出:
6
首先用大白话翻译一下题目描述的"再栽种树木"到底是什么意思:如果原数组中存在一个元素和为M的子区间ar[i], ar[i + 1], ar[i + 2], ..., ar[j]
(后文中我们称之为"坏区间"),我们可以通过在它中间的某个位置(例如就在ar[k]
和ar[k+1]
之间),插入一个数值v(v≠0),来破坏掉原先的这个子区间。也就是说,插入这个新的数值之后,区间ar[i], ar[i + 1], ar[i + 2], ..., ar[j]
的元素和就不是M了,而是经过调整之后的M+v。
当然光想到这一步,其实我们还是有一丝担忧的;这个新插入的v,会不会导致在原数组中引入新的"坏区间"呢?
其实这个担忧是没有必要的。既然题目中特别强调了新插入的数值可以任选,那么我们完全可以每次都插入某个特殊值,来避免这个问题。比如说,∞就可以。
由此可见,本题中每次插入v的值并不是我们需要考虑的点。我们的关注重点事实上在于如何让插入∞的次数最小化。
分析到这一步,就不难发现本题可以通过贪心策略轻松解答。简单来说,从左到右遍历原数组,只在迫不得已的时候插入一个∞。
想象一下,假设我们从左到右遍历遍历原数组ar[0], ar[1], ar[2], ar[3], ar[4]
,结果在遍历到ar[4]
时发现区间ar[2, 4]
的和等于M,那么这个时候我们就应该在ar[3]
和ar[4]
之间插入一个∞,来把这个和为M的子区间破坏掉。
下面解释一下为什么这么做是符合贪心算法的贪心选择性质:
- 为什么我们不在遍历到
ar[4]
之后才插入一个∞呢? 这样的话区间ar[2, 4]
就成为了一个不符合题意的"坏区间"了,这肯定是不允许的。 - 为什么我们不在遍历到
ar[4]
之前就插入一个∞呢? 很显然,按上面的描述,区间ar[0, 3]
内压根不存在和为M的子区间了(如果存在的话,算法在遍历到ar[4]
之前就已经执行插入操作了)。也就是说,在遍历到ar[4]
之前就插入一个∞是没有必要的;如果我们真的在ar[4]
就插入了一个∞,则我们一定可以安全地将它移动到ar[3]
和ar[4]
之间。
另一方面,右侧区间ar[4, N-1]
由于新插入的∞这个天堑的存在,已经与左侧的ar[0, 3]
没有任何联系了。这意味着ar[4, N-1]
已经变成了一个新的、独立的子问题,可以使用相同的方法进行求解。这显然符合贪心策略最优子结构的性质。
在实际编码时,为了快速确定是否存在一个以ar[i]
结尾的、和为M的子区间,可以使用哈希集合对前缀和的结果进行缓存。不过需要注意的是,每次做出贪心选择后,我们都会得到一个与原问题解法相同的子问题,因此这个缓存用的哈希集合也要进行相应的重置。
C++
#include <iostream>
#include <vector>
#include <unordered_set>
int main() {
int n, m;
std::cin >> n >> m;
std::vector<int> nums(n);
for (int i = 0; i < n; ++i) {
std::cin >> nums[i];
}
int prefix = 0;
std::unordered_set<int> prefix_cache;
prefix_cache.insert(0);
int ans = 0;
for (int i = 0; i < n; ++i) {
prefix += nums[i];
if (prefix_cache.count(prefix - m)) {
++ans;
prefix = nums[i];
prefix_cache.clear();
prefix_cache.insert(0);
}
prefix_cache.insert(prefix);
}
std::cout << ans;
}