如果你在PAT甲级考场上拿到这套题,翻到Find Coins这一题时,第一反应多半是:这不就是"两数之和"吗?但等你交上去,迎接你的可能不是AC,而是TLE或者WA。这道25分的题,放在整套卷子里不算难,却是每年栽跟头人数最多的题之一。我见过太多考生在它上面浪费时间,原因无非两个:要么想都没想直接双重循环,要么根本没读懂题干里藏着的那句"If there are many solutions, you must output the one with the minimum a"意味着什么。
这篇文章我会把这道Find Coins的完整解法、考场上的思考路径、以及我当时踩过的坑全部拆开讲清楚。不管你是刚开始刷PAT甲级的新手,还是已经刷到中期想补漏的老手,这篇文章都能让你少走弯路。
1. 题目到底在问什么:不只是一句"找两枚硬币"
PAT的题目描述通常很简洁,但越简洁的题,越喜欢在边界条件上埋雷。Find Coins就是典型代表,原题大意是这样:
Eva has a bunch of coins. She wants to pay exact amount M with two coins. Given all the coin values, you are supposed to tell her if she can find two coins that sum up to M. If there are many solutions, output the one with the minimum a. It is guaranteed that all the coin values are positive integers.
翻译成人话就是:给你一堆硬币,每个硬币有面值,问能不能找到两枚硬币,让它们面值加起来正好等于M。如果有多种组合,输出"第一枚硬币面值最小"的那组。注意,这里说的"第一枚"不是输入顺序,而是面值最小。
1.1 描述信息量最大的几个字
很多人栽就栽在"两枚"这个词上。两枚硬币意味着什么?意味着你不能拿同一枚硬币当两次用。举个例子,如果M是10,硬币里恰好只有一个面值为5的硬币,你能输出"5 5"吗?不能。因为根本不存在第二枚5。但如果你有两个面值为5的硬币,那就可以。
这个细节在英文题干中只体现为"two coins",读题快的人很容易顺手忽略。等到写哈希表版本的时候,如果你用了不记录次数的set,就会在这里翻车。
1.2 输入输出格式的隐含约束
输入第一行给两个正整数N和M,N是硬币数量,M是目标金额。第二行给出N个硬币的面值。输出格式有讲究:如果存在解,输出两个面值a和b,a ≤ b,并且a尽可能小;如果无解,输出一个固定字符串"No Solution"。
这里有个容易忽略的点:题目原题并没有说明硬币面值的上限。我印象中数据范围里硬币面值通常不超过500,但M却可能到1000。也就是说,你不能理所当然地认为"面值一定比M小",虽然如果面值大于M,它不可能出现在解里,但你不做剪枝也不会出错,只是浪费时间。
1.3 样例会说话
我们来看一个标准样例:
code复制输入:
8 15
1 2 8 7 2 4 11 15
输出:
4 11
为什么不是"7 8"?因为7+8=15也是合法解。但题目要求输出a最小的那组,4比7小,所以要输出"4 11"。如果只有一个解,那就无所谓。这个样例其实就是在暗示:你的算法必须能处理"多解取最小a"。
还有一组常见边界样例:
code复制输入:
7 14
1 8 7 2 4 11 15
输出:
No Solution
你看,1+15=16,8+7=15,2+4=6,7+11=18,没有一个组合等于14,那就要老老实实输出No Solution。注意,这个字符串大小写一个字母都不能错,我见过有人写成"no solution"或"No solution",直接白丢25分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力搜索为什么在PAT考场上几乎必死
很多第一次刷PAT的人看到这题,第一反应就是我第一段说的:直接两层循环枚举所有硬币组合,求和判断。逻辑没错,但问题在于数据范围。PAT甲级的N通常可以到10^5这个量级,10万枚硬币,两层循环就是10^10次操作。
2.1 用复杂度算一笔账
PAT的判题机器一般要求时间限制在几百毫秒到1秒左右。10^10次加法判断,哪怕每次只花1纳秒,也要10秒。更现实的情况是,C++在1秒内大概能跑2×10^8到5×10^8次简单操作,10^10直接超时十倍以上。
所以,暴力枚举不是"可能超时",而是"必然超时"。这道题考的就是你能不能跳出暴力思维,找到更优解法。
2.2 排序是一个转折点
我当年第一次做这题的时候,看到"找两枚硬币"这个场景,第一反应是哈希表。但后来我仔细想了下,排序之后再从两端往中间走,才是代码最不容易出错、逻辑也最容易说服自己的写法。
为什么排序能带来优势?因为一旦所有硬币面值排好序,问题就变成了经典的"有序数组两数之和"。比如硬币排完序是:
code复制1 2 2 4 7 8 11 15
M = 15
想象左右两个指针,左指针指向最小面值1,右指针指向最大面值15。1 + 15 = 16,大于15。这说明什么?说明当前右指针指向的15太大了,它和任何左边硬币相加,结果只会更大,不可能等于15。所以右指针必须往左挪。这就是双指针的核心直觉:通过一次比较,排除掉一个绝对不可能的解。
2.3 双指针为什么不会漏解
这个点很多教程都一笔带过,但我觉得值得说透。假设排序后数组是a[0] ≤ a[1] ≤ ... ≤ a[n-1],左右指针分别是i和j,i从0开始,j从n-1开始。每次计算sum = a[i] + a[j]:
- 如果sum等于M,直接输出,这是唯一需要处理的情况;
- 如果sum小于M,说明a[i]太小了,i右移;
- 如果sum大于M,说明a[j]太大了,j左移。
为什么这样不会漏解?关键在于:当sum大于M时,对于当前j,所有比a[i]更大的左侧硬币,和a[j]相加只会更大,更不可能等于M,所以a[j]可以放心被排除。同理,当sum小于M时,所有比a[j]更小的右侧硬币,和a[i]相加只会更小,也不可能等于M,所以a[i]可以放心被排除。每一次移动都在缩小搜索空间,而且排除的区间里不可能存在解,所以最终要么找到解,要么左右指针相遇,说明无解。
这个证明思路在面试和机试中其实比代码本身更值钱。以后遇到任何"有序数组找特定和"的变体题,你都可以复用这个思路。
3. 排序+双指针的完整C++实现
我们直接看代码。下面的实现是我在PAT环境下测试过、可以直接提交的版本。
cpp复制#include <cstdio>
#include <algorithm>
using namespace std;
const int MAXN = 100005;
int coins[MAXN];
int main() {
int N, M;
scanf("%d %d", &N, &M);
for (int i = 0; i < N; i++) {
scanf("%d", &coins[i]);
}
sort(coins, coins + N);
int left = 0, right = N - 1;
while (left < right) {
int sum = coins[left] + coins[right];
if (sum == M) {
printf("%d %d\n", coins[left], coins[right]);
return 0;
} else if (sum < M) {
left++;
} else {
right--;
}
}
printf("No Solution\n");
return 0;
}
3.1 这段代码的每一步都是坑
首先,为什么用scanf/printf而不是cin/cout?PAT的老题环境里,cin/cout在数据量大的时候确实可能成为性能瓶颈。虽然加了ios::sync_with_stdio(false)可以缓解,但直接用scanf/printf最稳妥,也不需要在代码里多一行。
其次,排序用sort(coins, coins + N),这是C++标准库的排序,时间O(N log N)。10^5个硬币排序大概就是十几毫秒,完全没压力。排序范围一定要写对,我之前见过有人写sort(coins, coins + N - 1),把最后一个元素漏掉,导致结果错得莫名其妙。
最后,while (left < right)这个条件是核心。为什么不是left <= right?因为两枚硬币必须是不同的两枚,如果left和right指向同一个位置,那就等于用同一枚硬币两次,不符合题意。这个细节写错,碰到"只有一个5但M=10"的测试点就会WA。
3.2 为什么这个解法能保证输出最小a
题目要求输出最小a,而排序后数组是递增的。双指针是左指针从左往右走,右指针从右往左走。找到的第一个解,左指针指向的面值一定是所有可行解中最小的a。
因为算法一旦sum等于M就立即输出并返回,不会继续往后找。而左指针在之前的移动中,凡是它经过的位置,都已经通过sum < M的判断被证明不可能和任何右指针位置组成解。所以第一个解就是最优解。这个性质使双指针解法天然满足题目要求,不需要额外记录和比较。
3.3 复杂度总结
排序O(N log N),双指针扫描O(N),整体复杂度O(N log N)。空间上只用了存储数组的O(N)。这个复杂度对N=10^5绰绰有余。
4. 哈希表解法:看起来优雅,坑比想象中多
双指针解法虽然好,但PAT讨论区里也有一批人喜欢用哈希表。思路很简单:遍历硬币时,检查M - coin是否出现过。如果出现过,就找到一组解。这个思路本身是对的,而且复杂度能到O(N),比双指针还快,但实现上的细节非常容易出错。
4.1 用bool数组还是计数数组
很多初学者喜欢这样做:
cpp复制bool exist[1005] = {false};
exist[coin] = true;
然后遍历每个coin,如果exist[M - coin]为true,就输出。这个写法的致命问题在于:M=10,coin=5,如果只有一个5,exist[5]=true,于是输出"5 5",但硬币数组里实际只有一个5,这就是错误。
正确做法是用计数数组,记录每个面值出现几次:
cpp复制int cnt[1005] = {0};
cnt[coin]++;
输出前检查cnt[M - coin]是否大于0,并且如果M - coin等于coin本身,则要求cnt[coin]至少为2。
4.2 哈希表版本的正确打开方式
cpp复制#include <cstdio>
#include <algorithm>
using namespace std;
const int MAXV = 1005;
int cnt[MAXV];
int main() {
int N, M;
scanf("%d %d", &N, &M);
for (int i = 0; i < N; i++) {
int x;
scanf("%d", &x);
cnt[x]++;
}
for (int a = 1; a < M; a++) {
if (cnt[a] > 0 && cnt[M - a] > 0) {
if (a == M - a && cnt[a] < 2) {
continue;
}
printf("%d %d\n", a, M - a);
return 0;
}
}
printf("No Solution\n");
return 0;
}
这里我直接从面值1开始枚举到M-1,因为要找最小a,所以从小到大枚举找到的第一个解就是答案。这个写法甚至不需要排序,因为cnt数组天生按面值排好序了。注意循环上限:a最多到M-1,因为至少要有两枚硬币,a不能等于M。
4.3 两种解法怎么选
从通过率角度看,双指针更稳,逻辑更直观,也不需要考虑"相同面值是否唯一"这种边界。哈希表的时间复杂度确实更优,但代码里隐藏的坑更多。
我在实际刷题时,更推荐双指针作为考场首选。原因有三:一是排序+双指针的思路可以迁移到大量同类题;二是它的正确性容易验证,写出来不容易出现藏在角落的边界问题;三是PAT判题对时间限制通常不会卡到必须用哈希表才能过。
| 对比维度 | 排序+双指针 | 哈希表计数 |
|---|---|---|
| 时间复杂度 | O(N log N) | O(N) |
| 空间复杂度 | O(N) | O(K),K为面值范围 |
| 实现难度 | 低 | 中 |
| 边界陷阱 | 少 | 多(同面值唯一性问题) |
| 扩展性 | 强,可迁移至三数之和 | 弱 |
5. PAT判题机的脾气:我踩过的坑
写题和写代码是两回事,尤其是在PAT这种严格的判题环境下,代码逻辑对不代表能过。下面这些都是我亲身踩过、或者帮学弟学妹排查过的真实问题。
5.1 "No Solution"的大小写和空格
PAT判题是逐字符比较的。No Solution必须严格写成"No Solution",N大写,S大写,中间一个空格,末尾没有多余空格。我见过有人写成"No solution"或"NoSolution",都是零分。这个问题在PTA的旧版OJ上尤其明显,新版可能给了更友好的提示,但考试时没人提醒你。
5.2 输出行末空格问题
双指针版本里我直接输出了printf("%d %d\n", a, b);,这个格式没问题。但如果你写成先输出第一个数,再循环输出空格加第二个数,就很容易在无意识中多出一个行尾空格。PAT的判题对行末多余空格通常是判错的,别抱侥幸心理。
5.3 硬币面值的查找边界
如果硬币面值可能很大,比如几千上万的测试点,计数数组就不能开固定大小。这时候要么用unordered_map,要么先把硬币排序再用双指针。我查过一些PAT的测试数据,面值范围还是克制的,但保险起见,用双指针的人根本不需要关心面值上限,这是它更大的优势。
5.4 排序前不能提前剪枝
有些同学会想:如果硬币面值大于M,那它肯定用不上,排序前先过滤掉行不行?行,但不建议。因为面值等于M的硬币虽然单独用不上,但它可以和面值为0的硬币组合——但题目说硬币面值是正整数,没有0。其实更关键的是,过滤操作本身要遍历一遍,而双指针本来就要排序,过滤并不会降低整体复杂度,反而多写一层逻辑,容易引入bug。别做多余的优化。
5.5 一个容易被忽略的TLE来源:cin
我在3.1提过一次,这里再强调。PAT的老题目和某些OJ一样,cin/cout的默认同步机制开销很大。在N=10^5、循环里还要不断cin的情况下,即使算法本身复杂度没问题,也可能因为IO开销从900ms变成1100ms,刚好卡在超时线上。
如果你习惯用cin/cout,至少加上这两行:
cpp复制ios::sync_with_stdio(false);
cin.tie(nullptr);
但要记住,加了之后不能再混用scanf/printf和cin/cout,否则可能出现读入顺序错乱。我的建议是:PAT考试直接用scanf/printf,一劳永逸。
6. 从Find Coins延伸出去:一类题的通吃思路
这道题做完,如果只是看一遍题解,价值就浪费了。同类题在PAT、CSP、蓝桥杯里反复出现,核心思想都是一样的。
6.1 变体一:找所有解而不是一个解
如果题目改成"输出所有满足条件的组合",双指针代码只需微调:找到sum == M时,输出后同时移动left和right,继续循环,而不是return。注意,只有当sum == M时才同时移动两个指针,因为只有两枚硬币都换了才可能出现新的合法组合;如果只移动一个指针,比如left++,那么新的sum一定小于M(因为a[left]变大了),不可能等于M,除非right也变,但这样不如直接同时移动更简洁。
6.2 变体二:输出a和b乘积最小的一组
这道题常常作为PAT其他真题的铺垫,比如有的题目会要求两数之和的同时还要求乘积最小。本质上你排序后,从最左端开始枚举a,找到第一个满足条件的b,就是乘积最小的解——因为越靠左的a越小,而b = M - a,乘积a * (M - a)在a的递增过程中先增后减,但实际上在a ≤ M/2的范围内是递增的。PAT里出现过类似的要求,理解了这个数学性质,写起来就不会慌。
6.3 变体三:三数之和
如果题目变成"找三个硬币,和等于M",双指针思路仍然可用。外层循环枚举第一个硬币a[i],内层用双指针在i+1到n-1区间里找两个数,使它们的和等于M - a[i]。复杂度是O(N^2),对N=10^3左右的题目够用。PAT里虽然没有直接考过三硬币,但CSP-J/S和蓝桥杯的类似题很多,本质都是这个思路的延伸。
6.4 考场时间分配策略
甲级一共4道题,总分100分,Find Coins这题25分,属于"保分题"。我的建议是:如果这题你10分钟内还没写出能过的版本,先跳到后面的题,最后再回来修复。因为后面的题可能有一道30分的大题,那才是区分度所在。反正双指针解法代码很短,一旦思路清晰,5分钟就能写完提交。如果WA了,优先检查边界条件,而不是重写整个算法。
7. 最后的实战建议
我自己的刷题习惯是,每道AC的题都会复盘一遍,问自己三个问题:这题考什么考点?我有没有更优的解法?如果变体问法,我能不能快速改出来?Find Coins这题看起来只有25分,但它把排序、双指针、哈希表、边界条件四个知识点都串起来了,值得你认真对待。
如果你现在还没有AC这道题,我建议你不要直接复制我上面的代码,先自己写一遍,卡住了再看。写完提交,如果过了,试着把双指针改成哈希表再交一次,体会两种思路的差异。如果没过,把报错信息贴到PTA的讨论区或者问你身边的大牛,但在这之前,先自己check一下我说的那几个坑:No Solution的拼写、left < right的条件、sum == M时有没有及时return。
这道题刷透了,你对"有序场景两数之和"这类问题的理解会上一个台阶。后面刷PAT的Three Friends、还有其他涉及two pointers的题,你会发现思路全都通着。祝你们都能在PAT上拿到想要的分数。
