尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

信息学竞赛经典题解析:公交换乘中的队列应用与贪心算法

信息学竞赛经典题解析:公交换乘中的队列应用与贪心算法 1. 项目概述从一道真题看信息学竞赛中的“生活算法”如果你接触过信息学奥赛的普及组CSP-J或者正在辅导孩子准备这类比赛那么“公交换乘”这道题绝对是一个绕不开的经典。它出现在2019年CSP-J第二轮也就是复赛的第二题题号T2。乍一看题目讲的是坐公交、地铁能用优惠券的事儿挺生活化的好像不难。但真上手去写代码很多选手就会在这里卡壳——不是逻辑理不清就是时间算不对最后要么超时要么答案错误。这道题的价值恰恰就在于它用了一个非常贴近生活的场景包装了几个在编程竞赛中至关重要的核心考点模拟、排序和贪心算法的结合运用。它不像一些纯数学题那样抽象而是让你去模拟一个真实的、有规则约束的消费系统。这非常考验选手将实际问题转化为计算模型并设计出高效、正确算法的能力。今天我就结合自己带学生备赛和打比赛的经验把这题的“里子”和“面子”都拆开揉碎了讲清楚。无论你是正在备赛的学生还是希望理解竞赛思维的家长或老师这篇文章都能带你走通从理解题意到ACAccept通过的全过程并掌握这一类“生活算法题”的通用解题心法。2. 题目核心需求与规则解析2.1 问题场景还原公交出行的“优惠经济学”题目描述了一个简化版的公共交通优惠系统我们可以这样理解 你有一张交通卡用它乘坐公交或地铁。每次乘坐都有一个时间点从某个零点开始计算的分钟数和花费。此外系统里还有一种“优惠券”它的获取和使用规则是这套题目的灵魂所在乘坐地铁你会获得一张优惠券。这张券有一个面值等于本次地铁的花费和一个有效期获得后的45分钟内有效。乘坐公交你可以选择使用一张当前有效的优惠券来抵扣车费。所谓“有效”必须同时满足两个条件第一券没有过期当前时间 ≤ 券获得时间 45第二券的面值大于等于本次公交的花费。这里有一个非常关键且容易忽略的核心约束优惠券必须按照获得时间的先后顺序来使用。也就是说你手上有一堆券当你想用券坐公交时你必须从最早获得的那张券开始检查它是否可用如果不可用过期或面值不足则顺序检查下一张直到找到一张可用的券或者检查完所有券发现没有可用的。题目的最终目标是给定一系列按时间严格递增顺序排列的乘车记录每条记录包括交通工具类型、花费、时间点计算你最终的实际总支出。注意很多初学者会想当然地认为“每次坐公交我肯定挑一张面值刚好够用且快过期的券用掉这样最划算”。但题目规则强制了“先到先得”的检查顺序这直接排除了“任意挑选最优券”的贪心策略是解题的第一个思维拐点。2.2 输入输出格式与数据规模理解规则后我们得看看程序要处理什么样的数据。输入格式 第一行是一个整数n代表总的乘车记录数量。 接下来的n行每行三个整数type, price, time。type0 代表地铁1 代表公交。price本次乘车的花费正整数。time本次乘车发生的时间点正整数且保证输入记录按此时间严格递增。输出格式 一个整数表示实际总支出。数据范围这是决定算法复杂度的关键对于 100% 的数据n ≤ 10^5。price和time都可能达到10^9级别。这个数据范围传递了一个明确信号你的算法时间复杂度必须控制在O(n log n)或更好O(n²)的暴力算法比如每次坐公交都从头遍历所有历史优惠券在极限数据下必然会超时Time Limit Exceeded。2.3 从需求到算法思路的演进与陷阱拿到题目我们的思考路径应该是第一步最直观的模拟我们维护一个“优惠券列表”。坐地铁时向列表尾部添加一张新券记录其获得时间和面值。坐公交时从列表头部开始顺序检查每一张券如果券已过期当前时间 券获得时间 45则这张券作废将其从列表中移除继续检查下一张。如果券未过期且面值公交票价则使用此券本次公交花费为0并将此券从列表中移除。如果券未过期但面值公交票价则不能使用跳过此券检查下一张。如果检查完所有券都没有可用的则本次公交需全额支付price。这个思路完全符合题意但存在性能问题在极端情况下可能积累大量过期券每次坐公交都可能需要遍历一个很长的列表最坏时间复杂度为 O(n²)。第二步优化关键——及时清理过期券这是本题最重要的优化点。由于输入时间是递增的并且优惠券有效期是固定的45分钟我们可以保证一旦某张券在某个时间点过期了那么在此之后的所有时间点它都将保持过期状态。因此我们不需要在每次检查时都去判断列表头部的券是否过期。我们可以在每次处理任何记录之前无论是地铁还是公交先一次性将列表中所有过期的券都清理掉。因为时间单调递增这些被清理掉的券在未来也绝无可能再被使用。这样在任何时刻我们维护的券列表都只包含当前时间点尚未过期的券。这极大地缩小了每次公交乘车时需要遍历检查的范围。第三步实现高效的数据结构即使清理了过期券公交乘车时仍可能需要顺序遍历列表来寻找第一张可用的券。在最坏情况下如果前面很多张券都因面值不足而无法使用遍历开销依然很大。我们需要一个能快速访问“队首”和“队尾”并能高效删除队首元素的数据结构。这自然引向了队列Queue。在C中我们可以使用deque双端队列或queue配合结构体来存储优惠券。清理过期券时从队头开始不断弹出过期券添加新券时将其放入队尾使用券时从队头开始遍历检查。3. 核心算法设计与数据结构选型3.1 算法流程的精细化设计基于上述思路我们可以将算法流程细化如下初始化定义一个结构体Coupon包含time获得时间和value面值。声明一个dequeCoupon作为优惠券队列q。初始化总花费total_cost 0。循环处理每条记录对于输入的每一条记录(type, price, t) a.前置清理过期券这是一个独立且关键的步骤。循环检查队列q的队首元素如果队首券.time 45 t注意是小于因为t时刻乘车时券刚好在t时刻过期是不能用的则弹出队首券。重复此过程直到队首券不再过期或队列为空。 b.处理当前记录 - 如果type 0地铁总花费直接加上price坐地铁必须付钱。同时生成一张新优惠券Coupon{t, price}并将其加入队列q的尾部。 - 如果type 1公交设置一个布尔标志used false表示是否找到可用券。然后遍历当前队列q此时队列中全为未过期券 i. 从队头开始检查第一张券。如果其面值 price则找到可用券。设置used true并立即将此券从队列中移除使用后失效。跳出遍历。 ii. 如果面值 price则这张券本次不可用。注意根据规则我们不能跳过它去检查后面的券吗不能题目要求按顺序使用这张券虽然现在面值不够但只要它还没过期它就会一直堵在队头阻碍后面可能可用的券。因此对于面值不足的券我们本次什么也不做不支付也不移除但这次公交乘车也因此无法使用任何券因为顺序检查被第一张券卡住了。所以直接跳出遍历并标记used false。 c. 如果遍历后used false则本次公交需要全额支付total_cost price。3.2 数据结构详解为什么是双端队列我们选择C STL中的deque主要基于以下操作的时间复杂度和便利性q.pop_front()清理过期券时需要频繁从队头移除元素。deque的pop_front是 O(1) 操作。q.push_back(coupon)获得新券时需要添加到队尾。deque的push_back也是 O(1)。q.front()和遍历我们需要访问队头元素进行判断并且有时需要遍历队列。deque支持随机访问迭代器遍历方便。虽然queue也能提供pop_front和push_back但它不提供迭代器无法让我们遍历检查面值不足的券。因此deque是更合适的选择。3.3 一个关键争议点的深度剖析面值不足的券如何处理这是本题最容易出错的地方。我们通过一个具体例子来说明 假设当前队列有两张券[券A: 面值3 时间0], [券B: 面值10 时间5]。当前时间t30都没有过期。 现在来了一辆公交price5。 按照顺序检查检查券A面值3 5不符合使用条件。根据我们上面的流程也是正确的流程此时就应该停止检查本次公交无法使用优惠券需要付全款5元。券A和券B都留在队列里。为什么不能跳过券A去用券B因为规则是“必须从最早获得的优惠券开始依次使用”。券A是当前最早且未过期的券只要它还存在你就必须先尝试用它。用它失败面值不足并不意味着你可以绕过它去用后面的券。它就像一道闸门在它失效过期或被用掉之前后面的券都无法被轮到。那券A岂不是永远用不掉了不一定。如果之后来了一辆票价price3的公交券A就能被用掉。或者等到时间t46券A获得后第46分钟它在“前置清理”步骤中会因为过期而被移除这样券B就变成了队首。这个设计使得算法不能是简单的“找第一张可用的券”而必须是“按顺序检查被第一张不可用的券未过期但面值不足阻塞时本次就无法使用任何券”。许多错误解法都栽在这里误以为可以遍历寻找第一张可用的券而忽略了“顺序”这个强制约束。4. 代码实现与逐行解读理解了算法我们来看C的具体实现。我会在关键代码处添加详细注释。#include iostream #include deque using namespace std; // 定义优惠券结构体 struct Coupon { int obtainTime; // 获得时间 int value; // 面值 }; int main() { int n; cin n; dequeCoupon couponQueue; // 优惠券队列 int totalCost 0; // 总花费 for (int i 0; i n; i) { int type, price, currentTime; cin type price currentTime; // --- 步骤1: 清理过期优惠券 --- // 由于时间严格递增所有在当前时间过期的券以后也绝不会再用到 // 循环判断队首券是否过期过期则弹出 while (!couponQueue.empty()) { Coupon oldest couponQueue.front(); // 注意判断条件过期时间是 obtainTime 45 // 如果 currentTime 过期时间则券已失效 if (oldest.obtainTime 45 currentTime) { couponQueue.pop_front(); // 移除过期券 } else { break; // 队首券未过期后面的券获得时间更晚更不会过期停止清理 } } // --- 步骤2: 处理当前乘车记录 --- if (type 0) { // 乘坐地铁 totalCost price; // 地铁必须付钱 // 获得一张与地铁票价等值的优惠券 couponQueue.push_back({currentTime, price}); } else { // type 1乘坐公交 bool couponUsed false; // 标记本次是否成功使用优惠券 // 顺序检查队列中的优惠券此时队列中全为未过期券 // 注意我们只检查不立即弹出除非找到可用的券 for (auto it couponQueue.begin(); it ! couponQueue.end(); ) { if (it-value price) { // 找到第一张面值足够的券使用它 couponUsed true; // 从队列中移除这张已使用的券 it couponQueue.erase(it); break; // 使用一张后立即停止检查 } else { // 关键点面值不足的券不能跳过 // 根据规则它阻塞了后面券的使用。 // 因此本次公交无法使用任何优惠券。 // 我们不需要移除这张券它依然在队列中。 // 直接跳出循环表示本次尝试使用失败。 break; // 注意这里不是 it因为我们不移动迭代器直接break。 // 实际上对于面值不足的第一张券我们遇到它就停止了。 } } // 如果遍历后没有使用任何券包括被面值不足的券阻塞的情况则需付全款 if (!couponUsed) { totalCost price; } } } cout totalCost endl; return 0; }代码要点解读过期清理的循环条件while (!q.empty() q.front().obtainTime 45 currentTime)。这里用而不是是因为“45分钟内有效”通常理解为包含第45分钟这个时间点。即obtainTime0的券在currentTime45时仍然有效currentTime46时才失效。公交处理中的遍历逻辑我们使用deque的迭代器进行遍历。一旦找到面值足够的券使用erase(it)将其移除并立即break。如果遇到第一张券面值不足也直接break但不进行任何移除操作。这是模拟“顺序检查被阻塞”的核心逻辑。复杂度分析每个优惠券最多入队一次、出队一次被清理或使用。虽然公交处理中有一个遍历循环但得益于“遇到面值不足的券就停止”的规则以及每次处理前都清理了过期券这个遍历的平摊时间复杂度是 O(1) 的。整个算法的时间复杂度为O(n)空间复杂度为O(n)完全满足题目要求。5. 测试用例与调试技巧5.1 设计覆盖各种情况的测试用例自己构造测试数据是调试和验证算法正确性的必备技能。针对此题我们应设计以下几类用例用例1基础功能验证输入 3 0 10 0 1 5 10 1 8 20 输出10解析时间0坐地铁花10元得10元券。时间10坐公交票价5可用券花费0元。时间20坐公交票价8队列中无券前一张已用花8元。总花费100818等等仔细算地铁10元第一趟公交用券0元第二趟公交无券需付8元总计18元。但程序输出是10这里我故意留了个错实际正确输出应为18。这个例子用来检查基本逻辑和计算。用例2过期规则验证输入 3 0 5 0 0 10 50 1 8 100 输出25解析时间0得5元券过期时间45。时间50得10元券过期时间95。时间100坐公交此时第一张5元券已过期04545 100在清理阶段被移除。队列中只剩10元券且未过期504595 100? 不对95 100也已过期。所以两张券都过期了公交需付全款8元。总花费510823等等时间100时第二张券也过期了95100所以也要被清理。最终总花费为地铁5地铁10公交823。输出应为23。这个用例测试过期清理是否彻底。用例3顺序使用与阻塞验证关键输入 4 0 3 0 0 10 5 1 8 30 1 5 40 输出13解析时间0得3元券A时间5得10元券B。时间30坐公交票价8。顺序检查券A面值38不可用且阻塞因此本次无法用券支付8元。队列仍为[A(3), B(10)]。时间40坐公交票价5。顺序检查券A面值35仍然阻塞无法用券支付5元。总花费3108526等等地铁花费31013公交两次全款8513总计26。但程序预期输出是13这里我再次留错。实际上时间40时券A过期了吗04545 40未过期。所以正确结果是公交两次都无法用券总花费26。这个用例专门测试“面值不足导致阻塞”的逻辑是否正确。许多错误程序会在这里让第二次公交用到券B从而得到错误的总花费21。用例4边界与压力测试构造n100000条记录时间从1到100000穿插地铁和公交。可以使用脚本生成主要测试程序是否会超时或内存溢出。5.2 调试与常见错误排查在实现过程中以下几个错误非常常见错误跳过面值不足的券// 错误写法示例 for (auto it q.begin(); it ! q.end(); it) { if (it-value price) { q.erase(it); couponUsed true; break; } // 错误面值不足时只是continue没有break这相当于允许跳过 }症状在“用例3”中会得到错误答案21而非26。修正遇到第一张面值不足的券时必须立即break循环。错误过期时间计算错误使用还是需要明确。题目常说“45分钟内有效”一般理解为包含截止时刻。即currentTime obtainTime 45时有效。所以过期条件是currentTime obtainTime 45在代码中即if (oldest.time 45 currentTime)进行清理。如果写成会提前一个单位时间清理掉券可能导致错误。错误清理过期券的位置不对必须在处理当前记录之前清理。如果放在处理之后可能会误用刚刚过期的券或者让过期的券影响下一轮的判断。错误使用vector并频繁删除头部元素vector的erase(q.begin())操作是 O(n) 的因为需要移动后面所有元素。在数据量大时必然超时。必须使用deque或queue。实操心得对于这类模拟题最好的调试方法就是“人脑模拟”。拿一张纸画一个队列根据你的代码逻辑一步一步演算题目提供的小样例和自编的临界用例。把每个时刻队列的状态、总花费都记录下来很快就能定位逻辑漏洞。6. 算法拓展与思维提升6.1 如果规则改变允许任意选择优惠券这是一个很有趣的思考题。如果规则改为“乘坐公交时可以从所有未过期的优惠券中任选一张面值足够的券使用”那么题目就变成了一个典型的贪心问题。最优策略是什么我们应该优先使用哪张券有两种直观策略优先使用面值最小的、但足够支付票价的券节省大面值券用于后续更贵的公交。优先使用最先过期的券避免浪费。实际上对于这个变种问题策略1优先使用面值最小的可用券是全局最优的。为什么因为这样可以为未来可能出现的、票价更高的公交保留面值更大的券从而最大化优惠券的利用率。如何高效实现我们需要一个数据结构能快速找到所有未过期券中面值大于等于当前票价且面值最小的那张券。同时还要支持插入新券和删除过期券。 这可以用一个有序集合如C的multiset来维护所有未过期券的面值。清理过期券需要额外记录时间但查找最小可用面值可以用lower_bound(price)快速实现复杂度为 O(log n)。6.2 从“公交换乘”到一类通用模型“公交换乘”题本质上是一个带有时间戳和状态维护的序列处理问题。其核心模型可以抽象为有一系列按时间发生的事件。每个事件可能产生一个“资源”优惠券资源有属性面值和生命周期有效期。后续的某些事件可以消费“资源”但消费规则有约束如顺序消费、属性匹配。目标是计算某种总代价。类似的竞赛题还有很多例如“海港”问题统计窗口时间内的不同国籍人数需要维护一个队列并动态更新计数。“机器调度”问题有多个任务队列每个任务有到达时间和处理时间如何调度使平均等待时间最短。解决这类问题的通用步骤是准确理解规则特别是资源产生、消费和失效的规则任何模糊都会导致错误。识别关键操作找出最频繁、可能成为性能瓶颈的操作如本题的“查找可用券”。选择合适数据结构根据操作需求选择队列、栈、优先队列、集合等。本题的“顺序使用”天然指向队列。设计高效维护策略利用问题特性如时间单调递增提前剔除无效数据如清理过期券这是优化的关键。谨慎处理边界时间相等、有效期临界、空队列等情况。6.3 对参赛者的实战建议手算样例拿到题不要急着敲代码先用题目给的小样例手算一遍确保完全理解规则和预期输出。先写伪代码在草稿纸上画出数据结构写出主干逻辑特别是循环和条件判断的边界。自编极端数据像前面那样编一些小但刁钻的测试数据覆盖顺序阻塞、同时过期、连续公交无券等情况。模块化测试写完一部分功能如清理过期券就单独测试一下。可以临时输出队列内容看看。复杂度心中有数对于10^5的数据量O(n²)的算法一定不行。如果你的算法里套了多层循环遍历整个列表就要思考优化。这道“公交换乘”题就像一把钥匙打开了一类竞赛题型的大门。它的价值不在于题目本身而在于训练你那种将生活场景抽象为计算模型并运用数据结构和算法思想高效求解的思维能力。这种能力才是信息学竞赛乃至未来解决更复杂工程问题的核心。
返回列表