【算法导论】PDD 0817笔试题题解

题目版权归考试主办方所有!!!本文仅做非盈利性质的交流分享!!!

多多的减满活动

有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 % mrem_b = b % m

另外0 <= rem_a < m0 <= rem_b < m,于是可知0 <= rem_a + rem_b < 2 * m

于是我们知道rem_a + rem_b的取值要么为0,要么为m。现在以rem_a的取值情况,进一步进行讨论:

  1. rem_a = 0时,一定有rem_b = 0
  2. 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背包问题有三个核心要素:

  1. 容量 (Capacity) :背包的总承重 W。
  2. 物品的重量 (Weight) :每个物品 i 有一个重量 w_i。
  3. 物品的价值 (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. 选择物品1 (性价比最高)。剩余容量 50 - 10 = 40。总价值 60。
  2. 选择物品2。剩余容量 40 - 20 = 20。总价值 60 + 100 = 160。
  3. 物品3装不下了。
    最终方案:物品1+物品2,总价值160。

最优解 是:

选择物品2和物品3,总重量 20 + 30 = 50,总价值 100 + 120 = 220。

这个反例清晰地表明,因为物品的价值不同,贪心地选择局部最优(当前性价比最高)无法保证全局最优。

现在我们回头看"多多果园"这道题。我们可以把它也套进背包问题的框架里:

  1. 容量 (Capacity) :初始果实总量 X。
  2. 物品的"重量" (Weight) :收养一只小动物所需的果实总消耗量 total_eating_i。
  3. 物品的"价值" (Value)每只小动物的价值都是 1

目标 :在不超过总容量 X 的前提下,选择小动物,使得总数量 (也就是总价值) 最大化。

你发现关键区别了吗?

所有物品的"价值"都相等 (都为1)!

在这个核心前提下,我们的目标函数变得非常纯粹:最大化物品的数量

要想在有限的预算(容量 X)内,买到最多的东西,你会怎么做?当然是永远先买最便宜的

这个直觉是绝对正确的。因为每个物品(小动物)对最终目标(动物数量)的贡献是完全相同的(都是+1),所以为了能容纳更多的物品,我们唯一的策略就是让每个物品的"成本"(果实消耗)尽可能地低。

多多的闯关游戏

一款闯关游戏,游戏中有N个依次排列的关卡,每个关卡都有两个属性

  • 通关奖励:通关后可以获得多少积分
  • 挑战难度:一个正整数,数值越大表示关卡越难

现在多多想要选择一段连续的关卡来挑战(比如第3关到第7关),选择的这段关卡区间需要满足两个要求:

  1. 这些关卡通关后获得的总积分至少需要达到T
  2. 在满足积分要求的前提下,选择的这些关卡中,挑战难度的最大值要尽可能地小

请找出满足条件的一段关卡,并输出这段关卡中挑战难度的最大值


第一行输入为N和T。已知1≤N≤10^61≤T≤10^18

接下来的N行,分别表示每个关卡的通关奖励s[i]和挑战难度d[i]。已知1≤s[i]≤10^181≤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_suml的开销都是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;
}
相关推荐
地平线开发者4 小时前
ReID/OSNet 算法模型量化转换实践
算法·自动驾驶
地平线开发者5 小时前
开发者说|EmbodiedGen:为具身智能打造可交互3D世界生成引擎
算法·自动驾驶
星星火柴9366 小时前
关于“双指针法“的总结
数据结构·c++·笔记·学习·算法
艾莉丝努力练剑7 小时前
【洛谷刷题】用C语言和C++做一些入门题,练习洛谷IDE模式:分支机构(一)
c语言·开发语言·数据结构·c++·学习·算法
C++、Java和Python的菜鸟8 小时前
第六章 统计初步
算法·机器学习·概率论
Cx330❀8 小时前
【数据结构初阶】--排序(五):计数排序,排序算法复杂度对比和稳定性分析
c语言·数据结构·经验分享·笔记·算法·排序算法
散1128 小时前
01数据结构-Prim算法
数据结构·算法·图论
起个昵称吧8 小时前
线程相关编程、线程间通信、互斥锁
linux·算法
myzzb9 小时前
基于uiautomation的自动化流程RPA开源开发演示
运维·python·学习·算法·自动化·rpa