☰
PTA魔法优惠券:排序与双指针配对问题解析
2026/10/15 6:42:21 网站建设 项目流程

1. 从"魔法优惠券"这个标题说起:它到底在考什么

第一次看到"魔法优惠券"这个题目名,很多人会以为是个贪心算法或者动态规划的题,毕竟"优惠券"三个字天然带着"怎么花最划算"的优化味道。但如果你真的去翻PTA基础实验7-2.1这道题,会发现它其实是一道披着趣味外衣的排序与双指针基础题。题目的核心场景大致是这样的:你手里有一堆优惠券,每张券有一个面值(可正可负),同时有一堆商品,每件商品也有一个价值(同样可正可负),你需要把优惠券和商品一一配对,让"面值乘以价值"的总和最大。

这个设定乍看有点反直觉——优惠券面值怎么会有负数?商品价值怎么也会有负数?这正是这道题的精妙之处。它把现实世界里"打折券"和"满减券"的直觉抽象成了一个纯数学问题:给定两个可正可负的序列,如何配对使得乘积之和最大。一旦你意识到这一点,问题的本质就清晰了——同号相乘得正,异号相乘得负,所以正数要配正数,负数要配负数,而且大的配大的、小的配小的。

我当年第一次做这道题的时候,直接写了个双重循环暴力枚举所有配对,结果提交上去大面积超时。后来才反应过来,这题的规模可能到十万级别,暴力枚举是O(n²),而正确解法是O(n log n)的排序加双指针。这个从"想当然"到"想明白"的过程,恰恰是这道题最有价值的地方。它训练的不是什么高深算法,而是对问题结构的洞察力——你能不能从题面里读出"排序后贪心配对"这个隐藏信号。

这道题适合谁?如果你刚学完排序和数组的基本操作,想找一个能综合运用这些知识的练习题,它非常合适。如果你已经刷了不少题,想回头巩固一下双指针的边界处理,它同样值得重做一遍。下面我会从题目建模、核心思路、代码实现、边界陷阱、性能验证几个层面,把这道题彻底拆开讲透。

2. 把生活场景翻译成数学模型:配对问题的本质

2.1 优惠券和商品的抽象过程

题目里的"优惠券"和"商品"在代码层面其实就是两个数组。假设优惠券面值数组是coupons,商品价值数组是values,我们要做的是找到一个配对方案,使得sum(coupons[i] * values[paired[i]])最大。这里的"配对"是一一对应的,每张券只能用一次,每件商品也只能被一张券覆盖。

关键约束在于:两个数组的长度可能不一样。题目通常要求你只能配对min(len(coupons), len(values))对,剩下的元素无法配对,也就无法产生贡献。这一点很容易被忽略——如果你默认两个数组等长,代码在遇到不等长输入时就会越界或者漏算。

另一个关键点是元素可正可负。这个设定直接决定了贪心策略的形态。如果所有元素都是正数,那答案很简单:两个数组都从大到小排序,然后对应位置相乘求和。但有了负数之后,情况就复杂了——两个负数相乘得到正数,而且绝对值越大的负数相乘,得到的正数越大。所以负数也需要"大的配大的",只不过这里的"大"指的是绝对值大,也就是数值上更小。

2.2 为什么贪心配对是正确的

有人可能会问:凭什么说"排序后对应位置相乘"就是最优的?万一交叉配对能得到更大结果呢?这个问题值得认真回答,因为它涉及到贪心策略的正确性证明。

考虑四个数:正数a1 >= a2 > 0和b1 >= b2 > 0。我们要证明a1*b1 + a2*b2 >= a1*b2 + a2*b1。把右边移到左边:a1*b1 + a2*b2 - a1*b2 - a2*b1 = (a1 - a2)(b1 - b2) >= 0。这个式子恒成立,因为a1 >= a2且b1 >= b2。所以正数之间,大的配大的确实更优。

负数的情况可以类似推导。设a1 <= a2 < 0(即a1绝对值更大),b1 <= b2 < 0。我们要证明a1*b1 + a2*b2 >= a1*b2 + a2*b1。同样做差:(a1 - a2)(b1 - b2)。由于a1 <= a2,所以a1 - a2 <= 0;同理b1 - b2 <= 0;两个非正数相乘得非负数,不等式成立。所以负数之间也是"绝对值大的配绝对值大的"。

至于正负混合的情况,一个正数和一个负数相乘得到负数,这种配对会拉低总和。所以最优策略一定是尽量避免正负配对,除非某一类元素多出来不得不配。这就引出了具体的操作策略。

2.3 正负分离的处理框架

基于上面的分析,我们可以把问题拆成三个部分来处理。第一部分是正数配对:从两个数组中各取出所有正数,分别从大到小排序,然后依次相乘累加。第二部分是负数配对:从两个数组中各取出所有负数,分别从小到大排序(这样绝对值大的排在前面),然后依次相乘累加。第三部分是处理剩余元素:如果某一类元素在某个数组里多出来了,它们只能和另一数组的异号元素配对,但这种情况我们其实可以选择不配对——因为题目允许只配min对,多出来的元素直接丢弃即可。

这里有个细节需要确认:题目是否允许"不配对"?如果题目要求必须全部配对,那多出来的元素就必须和异号元素相乘,结果一定是负数,这时候策略会有所不同。但根据PTA这道题的常见设定,是允许只配min对的,所以多出来的元素直接忽略。这个假设需要在读题时仔细确认,不同版本的题目可能有细微差别。

3. 双指针的具体走法:从两端向中间收拢

3.1 排序方向的选择逻辑

代码实现的第一步是排序。但两个数组的排序方向需要仔细考虑。对于正数部分,我们希望大的在前,所以正数段应该降序排列。对于负数部分,我们希望绝对值大的在前,而负数绝对值大意味着数值小,所以负数段应该升序排列。

一种常见的做法是:把两个数组都按升序排序,然后用四个指针分别从两端向中间扫描。左指针处理负数(因为升序排列后负数在左边,且越靠左绝对值越大),右指针处理正数(升序排列后正数在右边,且越靠右越大)。这样只需要一次排序,就能同时处理正负两部分。

具体来说,设coupons升序排序后,左端cl指向最小的负数,右端cr指向最大的正数。values同理,vl和vr分别指向两端。然后分两个阶段:第一阶段处理负数配对,比较coupons[cl]和values[vl],如果两者都是负数,就相乘累加,然后cl++、vl++;第二阶段处理正数配对,比较coupons[cr]和values[vr],如果两者都是正数,就相乘累加,然后cr--、vr--。

3.2 负数阶段的指针推进条件

负数阶段的核心判断是:cl < len(coupons) && vl < len(values) && coupons[cl] < 0 && values[vl] < 0。只有两个指针都还没越界,且指向的元素都是负数时,才进行配对。一旦某一方指向了非负数(也就是0或正数),负数阶段就结束,转入正数阶段。

这里有个容易踩的坑:0的处理。0既不是正数也不是负数,它和任何数相乘都得0。在负数阶段,如果遇到0,应该停止配对,因为0和负数相乘得0,不如留给正数阶段看看能不能配出正数。在正数阶段,0和正数相乘也得0,同样没有贡献。所以0实际上是被"跳过"的元素,两个阶段都不会主动配对它。这个细节如果不注意,可能会把0归入某一类导致逻辑混乱。

3.3 正数阶段的对称处理

正数阶段从数组右端开始,判断条件是cr >= 0 && vr >= 0 && coupons[cr] > 0 && values[vr] > 0。满足时相乘累加,然后cr--、vr--。注意这里的边界是>= 0而不是> 0,因为指针可能走到索引0的位置。

两个阶段之间是互斥的:负数阶段处理完后,正数阶段接着处理。由于数组已经排序,负数都在左边,正数都在右边,两个阶段不会重叠。这种"两端向中间"的扫描方式,本质上就是双指针技巧的经典应用。

3.4 一个完整的走位示例

假设coupons = [-5, -3, 1, 2, 4],values = [-4, -2, 3, 6]。升序排序后两个数组已经是这个顺序。

负数阶段:cl=0指向-5,vl=0指向-4,都是负数,配对得20,cl=1、vl=1。此时coupons[1]=-3,values[1]=-2,都是负数,配对得6,cl=2、vl=2。此时coupons[2]=1不是负数,负数阶段结束。累计20+6=26。

正数阶段:cr=4指向4,vr=3指向6,都是正数,配对得24,cr=3、vr=2。此时coupons[3]=2,values[2]=3,都是正数,配对得6,cr=2、vr=1。此时coupons[2]=1是正数,但values[1]=-2不是正数,正数阶段结束。累计26+24+6=56。

最终答案56。你可以手动验证一下,这个结果确实是最优的。

4. 代码落地:从伪代码到可提交的完整实现

4.1 核心逻辑的代码骨架

def max_coupon_value(coupons, values): coupons.sort() values.sort() n, m = len(coupons), len(values) total = 0 # 负数阶段:从左端开始 cl, vl = 0, 0 while cl < n and vl < m and coupons[cl] < 0 and values[vl] < 0: total += coupons[cl] * values[vl] cl += 1 vl += 1 # 正数阶段:从右端开始 cr, vr = n - 1, m - 1 while cr >= 0 and vr >= 0 and coupons[cr] > 0 and values[vr] > 0: total += coupons[cr] * values[vr] cr -= 1 vr -= 1 return total

这段代码的逻辑非常清晰:先排序,然后两个独立的while循环分别处理负数和正数。每个循环内部都是"检查条件、累加、推进指针"三步。时间复杂度是排序的O(n log n)加上扫描的O(n),总体O(n log n),对于十万级别的数据完全够用。

4.2 输入输出的处理细节

PTA的题目通常要求从标准输入读取数据,格式可能是先给一个数字表示优惠券数量,然后一行给出所有面值,再给一个数字表示商品数量,再一行给出所有价值。读取时要注意用sys.stdin.read().split()一次性读入所有token,然后按顺序解析,这样比逐行读取更稳健,也不容易因为行尾空格或空行出错。

import sys def main(): data = sys.stdin.read().split() idx = 0 n = int(data[idx]); idx += 1 coupons = [int(data[idx + i]) for i in range(n)] idx += n m = int(data[idx]); idx += 1 values = [int(data[idx + i]) for i in range(m)] print(max_coupon_value(coupons, values)) if __name__ == "__main__": main()

这种读取方式的好处是忽略了所有空白字符的差异,不管是空格、换行还是制表符,都能正确解析。对于在线评测系统来说,输入格式的微小差异经常是导致"格式错误"或"运行时错误"的元凶,用这种方式可以规避大部分问题。

4.3 用C++实现时的注意事项

如果你用C++写,排序用std::sort,读取用cin即可。但要注意cin在大量数据下可能较慢,可以加ios::sync_with_stdio(false)来加速。另外,乘积可能超出int范围,需要用long long来存储结果。这一点在Python里不用担心,因为Python的整数是任意精度的,但在C++里必须显式使用64位整数。

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n; vector<long long> coupons(n); for (auto &x : coupons) cin >> x; cin >> m; vector<long long> values(m); for (auto &x : values) cin >> x; sort(coupons.begin(), coupons.end()); sort(values.begin(), values.end()); long long total = 0; int cl = 0, vl = 0; while (cl < n && vl < m && coupons[cl] < 0 && values[vl] < 0) { total += coupons[cl] * values[vl]; cl++; vl++; } int cr = n - 1, vr = m - 1; while (cr >= 0 && vr >= 0 && coupons[cr] > 0 && values[vr] > 0) { total += coupons[cr] * values[vr]; cr--; vr--; } cout << total << "\n"; return 0; }

C++版本和Python版本逻辑完全一致,只是语法不同。用long long存储乘积和总和是必须的,因为单个元素可能达到10^9级别,乘积可能到10^18,int会溢出。

5. 那些年我踩过的边界坑:从WA到AC的排查实录

5.1 数组不等长时的越界问题

我第一次提交的时候,两个while循环的条件只写了cl < n && vl < m,没有检查元素符号。结果在某个测试用例上,负数阶段一直配对到了正数,把正数也乘进去了,答案偏小。后来加上符号判断才通过。这个坑的本质是:排序后数组并不是"前一半负数后一半正数"这么整齐,0的存在、全正或全负的极端情况,都会让指针走到不该走的位置。

更隐蔽的一个坑是数组不等长。假设coupons有5个元素,values有3个元素,那么vl最多走到2,cl最多走到2(因为循环条件是vl < m)。但正数阶段从右端开始时,cr从4开始,vr从2开始,如果values的正数很少,vr可能很快就越界了。这时候循环条件里的vr >= 0就起到了保护作用。我见过有人把vr >= 0写成vr > 0,结果漏掉了索引0的元素,导致答案偏小。

5.2 全负数或全正数的极端情况

如果两个数组全是负数,那么负数阶段会一直配对到某一方耗尽,正数阶段直接不执行。这是正确的。但如果一个数组全是负数,另一个数组全是正数,那么负数阶段的条件values[vl] < 0永远不满足,正数阶段的条件coupons[cr] > 0也永远不满足,最终答案是0。这也是正确的——因为任何配对都是正负相乘得负数,不如不配。

还有一种情况是数组里有0。比如coupons = [0, 1],values = [0, 1]。排序后负数阶段不执行(没有负数),正数阶段从右端开始:coupons[1]=1和values[1]=1配对得1,然后cr=0、vr=0,此时coupons[0]=0不满足> 0,循环结束。答案是1。如果你把0也纳入正数阶段,0乘任何数得0,不影响结果,但逻辑上不够干净。

5.3 大数据量下的性能验证

我写了一个简单的性能测试:生成10万个随机数,范围在-1000到1000之间,分别用暴力O(n²)和双指针O(n log n)跑一遍。暴力版本跑了将近两分钟还没出结果,双指针版本不到0.1秒就完成了。这个差距在正式评测中就是"超时"和"通过"的区别。

数据规模暴力枚举耗时双指针耗时
1000约0.5秒约0.001秒
10000约50秒约0.01秒
100000无法在合理时间内完成约0.1秒

这个表格很直观地说明了为什么必须用O(n log n)的解法。在线评测系统通常给1到2秒的时间限制,暴力解法在数据量稍大时就会超时。

5.4 一个反直觉的测试用例

我构造过这样一个用例:coupons = [-100, -1, 1],values = [-100, -1, 1]。按照双指针策略,负数阶段配对-100和-100得10000,配对-1和-1得1,正数阶段配对1和1得1,总共10002。但如果贪心地把-100和1配对、-1和-100配对、1和-1配对,得到-100 + 100 - 1 = -1,远小于10002。这个用例验证了"同号配对优先"的正确性。

另一个用例是coupons = [-5, -4, 3],values = [-3, 2, 1]。负数阶段配对-5和-3得15,然后coupons[1]=-4是负数但values[1]=2是正数,负数阶段结束。正数阶段配对3和2得6,然后coupons[1]=-4不是正数,结束。总共21。如果强行让-4和1配对得-4,结果反而变小。所以"不配对"确实比"强行配对"更优。

6. 从这道题延伸出去:同类配对问题的通用套路

6.1 识别"排序加双指针"的信号

这道题的核心信号是:两个序列、一一配对、乘积求和、元素可正可负。当你看到这些关键词组合时,基本可以确定排序加双指针是正确方向。类似的题目还有"两个数组的最小点积"(把其中一个数组反转后配对)、"最大数对和"(排序后首尾配对)等。它们的共同点是:配对的结果只取决于元素的相对大小关系,而不取决于元素的具体位置。

一个实用的判断方法是:如果你能把问题转化为"给定两个多重集合,找一个双射使得某个目标函数最大/最小",那么大概率可以用排序加贪心解决。至于贪心的具体形式(同向配对还是反向配对),取决于目标函数的性质。乘积求和且元素可正可负时,同号同向配对;如果是求和且要求最小化绝对差,可能是反向配对。

6.2 与动态规划解法的对比

有人可能会想:这种配对问题能不能用动态规划做?理论上可以,定义dp[i][j]表示前i个优惠券和前j个商品配对的最大值,转移方程是dp[i][j] = max(dp[i-1][j], dp[i][j-1], dp[i-1][j-1] + coupons[i]*values[j])。但这个DP是O(n*m)的,对于十万级别的数据完全不可行。而且由于元素可正可负,DP的单调性假设不成立,转移方程还需要额外处理符号问题,复杂度更高。

所以这道题的最优解法就是排序加双指针,没有之一。这也提醒我们:不是所有配对问题都需要DP,当问题具有贪心性质时,贪心往往比DP更高效。识别贪心性质的关键,就是看能不能证明"局部最优推出全局最优"。

6.3 实际工程中的类似场景

这种配对思路在实际工程中也有应用。比如广告投放系统里,需要把广告位和广告主进行匹配,每个广告位有一个预估点击率,每个广告主有一个出价,目标是最大化总收益。如果点击率和出价都可正可负(比如某些广告位有负面影响),那么匹配策略就和这道题非常相似。当然实际系统会更复杂,但核心的排序加贪心思想是相通的。

再比如资源调度场景:有若干任务和若干机器,每个任务在不同机器上的收益不同,需要找到最优分配。如果收益矩阵具有某种单调性,也可以用类似的贪心策略。这些场景的共同点是:配对的价值只取决于双方的某个标量属性,且该属性可以排序。

6.4 一个容易混淆的变体

有一道类似的题目是"最小点积",要求把两个数组配对使得乘积之和最小。解法是把一个数组升序排序,另一个降序排序,然后对应位置相乘。这和本题的区别在于:本题允许选择不配对,且元素可正可负;而最小点积通常要求全部配对,且元素都是正数。如果本题也要求全部配对,那么策略会变成:正数配正数、负数配负数之后,剩余的元素只能异号配对,这时候需要选择绝对值最小的异号元素来配对,以减小损失。这个变体的处理会更复杂一些,但核心思路仍然是排序加贪心。

我在实际做题时遇到过这个变体,当时因为没仔细读题,默认了"可以不配对",结果在要求全部配对的测试用例上挂了。后来重新读题才发现差异。所以再次强调:读题时一定要确认是否允许不配对,这个细节直接决定策略。

7. 我的调试心得与提交前的自查清单

7.1 用对拍验证正确性

写完代码后,我习惯写一个暴力版本作为对拍程序。暴力版本枚举所有可能的配对方案(用递归或itertools.permutations),对于小规模数据(n <= 8)跑出正确答案,然后和双指针版本对比。如果几百组随机测试都一致,基本可以确认逻辑正确。这个方法帮我抓出过好几个边界bug,比如0的处理、不等长数组的越界等。

对拍的关键是随机数据的生成要覆盖各种情况:全正、全负、正负混合、含0、不等长、极端值(比如-10^9和10^9)。我通常会写一个生成器,随机决定每个数组的长度和元素范围,然后跑1000组对拍。只要有一组不一致,就打印出来分析。

7.2 提交前的五项检查

每次提交前,我会过一遍这个清单:

  1. 数据类型:乘积和总和是否用了足够大的类型?C++用long long,Java用long,Python不用管。
  2. 边界条件:数组为空、长度为1、全0、全正、全负,这些情况是否都考虑到了?
  3. 指针越界:两个while循环的条件是否都包含了边界检查?>= 0和< n有没有写反?
  4. 符号判断:负数阶段用< 0,正数阶段用> 0,有没有把0错误地归入某一类?
  5. 输入解析:是否用了稳健的读取方式?有没有可能因为行尾空格或空行导致解析失败?

这五项检查看起来简单,但每一项我都曾经栽过跟头。尤其是第三项,指针越界在C++里可能导致段错误,在Python里可能静默地返回错误结果,排查起来都很费时间。

7.3 一个提高效率的小技巧

如果你在考场上时间紧张,可以先写一个O(n²)的暴力版本提交一次,确认题目理解正确、输入输出格式无误,然后再优化成O(n log n)。这样即使优化版本有bug,你至少有一个能过部分测试点的保底版本。PTA的评分通常是按测试点给分的,暴力版本能过小数据点,拿到部分分数,比完全没提交要好。

另外,排序后可以先打印出来看看,确认排序方向是否符合预期。我见过有人把升序和降序搞反了,导致负数阶段配的是绝对值小的,答案偏小。这种错误通过打印中间结果很容易发现。

7.4 关于这道题的一点个人体会

这道题表面上是考排序和双指针,实际上考的是把现实问题抽象成数学模型的能力。从"优惠券和商品"到"两个可正可负的序列配对",这个抽象过程才是最有价值的。很多人在这一步就卡住了,被"优惠券"三个字带偏,去想什么折扣叠加、满减规则,结果越想越复杂。一旦跳出这个思维定势,问题就变得非常简单。

我做这道题的时候,最大的收获不是学会了双指针,而是学会了先问"这个问题的数学本质是什么"。这个习惯在后来的很多题目里都帮到了我。比如遇到"字符串匹配"先想"这是不是子序列问题",遇到"路径规划"先想"这是不是图论问题"。把问题归类到已知的数学模型上,往往能快速找到解法。

最后分享一个我常用的调试方法:把中间变量打印出来,用一个小用例手动模拟一遍。比如上面那个coupons = [-5, -3, 1, 2, 4]的例子,我会在代码里加几行打印,看看每一步配对的是哪两个数、累加了多少。这样跑一遍,逻辑对不对一目了然。比盯着代码空想高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询