news 2026/8/23 5:42:54

CSP-J公交换乘题解:队列与双指针优化算法详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CSP-J公交换乘题解:队列与双指针优化算法详解

1. 项目概述:从一道经典真题看算法竞赛中的模拟与优化

如果你正在准备信息学奥赛(CSP-J/S)或者洛谷上刷题,那么“公交换乘”这道题绝对是一个绕不开的经典。它源自2019年CSP-J(原NOIP普及组)的第三题,题号在《信息学奥赛一本通》里是1983,在洛谷上是P5661。这道题之所以经典,不仅仅因为它是真题,更因为它完美地融合了“生活场景模拟”和“数据结构优化”两大核心考点。题目描述了一个非常贴近我们日常的场景:乘坐公交车和地铁,可以使用优惠券。但在这个看似简单的规则背后,却藏着对选手时间管理、数据处理和算法优化能力的全面考察。

很多初学者第一次看到这道题,会觉得“这不就是个简单的if-else判断吗?”。但一旦动手实现,很快就会陷入“超时”(TLE)的困境。这正是这道题的魅力所在——它用生活化的外壳,包装了一个需要你仔细设计数据结构和遍历策略的算法内核。解决它,你不仅能学会如何处理带有时间窗口的优惠规则,更能深刻理解“暴力模拟”与“高效算法”之间的天壤之别,这是从编程爱好者迈向竞赛选手的关键一步。接下来,我就结合自己当年打比赛和后来辅导学生的经验,带你彻底拆解这道题,不仅告诉你“怎么做”,更要讲清楚“为什么这么做”,以及“怎么做得更快、更稳”。

2. 核心需求与规则解析:把生活规则翻译成代码逻辑

在动手写一行代码之前,我们必须像律师审合同一样,把题目规则一字一句地“翻译”成无歧义的计算机逻辑。这是所有模拟题成功的第一步,也是最容易踩坑的地方。

2.1 题目规则逐条拆解

题目给出了一个n条乘坐记录的序列,每条记录包含三个信息:type(类型,0代表地铁,1代表公交车)、price(票价,单位为元)、time(乘车时间,单位为分钟,从当天0点开始计算)。我们需要计算小明这一天乘坐公共交通的总花费。

规则如下:

  1. 乘坐地铁:直接支付全价price元,并获得一张优惠券。这张优惠券包含两个属性:获得时间time(即乘车时间),和面值price(即本次地铁票价)。
  2. 乘坐公交车:可以使用优惠券抵扣。使用规则是:
    • 当前公交车乘车时间t_bus
    • 可以使用的优惠券必须满足:优惠券的获得时间t_coupon满足t_bus - t_coupon <= 45。即优惠券在45分钟(含)内有效。
    • 在所有有效的优惠券中,必须选择面值大于等于当前公交车票价price_bus的一张。
    • 如果存在多张满足条件的优惠券,必须选择获得时间最早的那一张使用。(这是一个关键且容易忽略的约束!)
    • 如果找到了符合条件的优惠券,则本次公交车免费,并消耗掉这张优惠券。
    • 如果找不到符合条件的优惠券,则本次公交车需要支付全价price_bus元。

2.2 关键约束与边界条件分析

仅仅理解规则还不够,我们必须明确其中的约束和边界,这直接决定了后续的算法设计。

  • 时间单向递增:输入保证乘车记录按时间time严格递增给出。这是一个极其重要的简化条件,意味着我们处理记录的顺序就是时间顺序,不需要额外排序。
  • 优惠券使用策略:“最早获得”意味着我们需要在有效券中,维护一个按获得时间排序的队列。这强烈暗示了要使用“队列”(Queue)或类似的数据结构。
  • 45分钟有效期:这是一个滑动的时间窗口。随着当前处理时间的推进,一些较早的优惠券会过期(当前时间 - 获得时间 > 45)。我们需要一种机制来及时清理这些过期的优惠券,防止无效数据堆积影响效率和判断。
  • 面值匹配:优惠券面值必须大于等于公交车票价才能使用。这意味着我们不仅需要按时间管理优惠券,还需要能快速从中找出面值满足条件的一张。
  • 数据规模:这是决定算法复杂度的关键。n最大可达10^5。这意味着任何O(n^2)的算法(例如,每次坐公交车都遍历所有已有优惠券)都极有可能超时。我们的目标必须是O(n log n)O(n)的算法。

注意:很多同学会忽略“选择获得时间最早”这个条件,简单地用“任意一张”满足面值的券,这样会导致答案错误。竞赛题目的每一个字都是有用的,必须严格遵守。

3. 算法设计思路:从暴力模拟到高效优化

理解了规则,我们来一步步思考如何实现。这个过程很像软件架构设计,我们需要权衡时间复杂度和实现复杂度。

3.1 最直观的暴力模拟法(及为什么不行)

最直接的想法是:用一个数组或列表coupons来存储所有获得的优惠券(每个券是一个(获得时间, 面值)的二元组)。

  • 坐地铁时,将新券加入列表。
  • 坐公交车时,遍历整个coupons列表,寻找满足时间差<=45面值>=车票的券。如果找到多个,再从中找出获得时间最早的。

复杂度分析:在最坏情况下,每坐一次公交车(共约n/2次),都需要遍历整个优惠券列表(长度也可能接近n)。这导致了O(n^2)的时间复杂度。对于n=10^5,计算量级是10^10,远超普通计算机1秒内能完成的运算量(约10^7~10^8),必然超时。

3.2 优化方向一:维护有效优惠券队列

既然时间严格递增,且优惠券有45分钟有效期,一个自然的优化是:我们只关心“当前时间”45分钟内的优惠券。更早的券可以直接丢弃。

因此,我们可以维护一个队列q(可以用数组模拟,或deque)。这个队列按优惠券获得时间从早到晚排列。

  • 入队:每次获得新优惠券(坐地铁),将其加入队尾。
  • 出队(清理过期):在处理任何一次乘车(无论是地铁还是公交)之前,我们都检查队首的优惠券。如果队首券的获得时间t_front满足当前时间 - t_front > 45,则它已过期,将其从队首弹出。重复此过程,直到队首券在有效期内。这样,队列q中始终只保存有效的优惠券。

这个操作将每次查找的范围从“所有历史券”缩小到了“当前有效券”,是一个巨大的进步。

3.3 优化方向二:高效查找满足面值的券

现在,我们面对的是一个有效券队列q。坐公交车时,我们需要从这个队列中,找到第一张面值大于等于公交车票价的券。

如果仍然采用遍历队列的方式,在最坏情况下,队列长度可能接近n(如果45分钟内发生了很多次地铁),那么单次查找仍然是O(n),总体复杂度还是O(n^2)

我们需要更快的查找方法。目标是在q中快速找到满足面值 >= price_bus的券。由于队列必须按时间顺序维护(为了清理过期券和满足“最早获得”规则),我们不能对其按面值排序,否则会破坏时间顺序。

这里的一个关键洞察是:“最早获得”这个规则,结合队列的FIFO(先进先出)性质,允许我们进行一种“贪心”的查找。我们可以顺序遍历当前有效队列q,寻找第一张满足面值条件的券。但是,如果一张券A面值小于当前公交车票价,那么它对于之后的、票价更高或相等的公交车,更不可能被使用。因为后面的公交车时间更晚,A可能过期,即使不过期,后面公交车的票价要求可能更高,A更不满足。

然而,有一个反例:如果后面来了一趟票价更低的公交车,这张小额券A可能就有用了。所以,我们不能简单丢弃不满足当前查询的券。

3.4 核心数据结构:队列 + 布尔标记数组

一个经典且高效的解法是:使用一个队列q来按时间顺序存储所有优惠券(包括已使用和未使用的),同时使用一个布尔数组used(或直接在券的结构体中加一个标记位)来记录每张券是否已被使用。

算法流程细化

  1. 初始化总花费ans = 0,一个空队列q,队列中每个元素是(time, price)
  2. 循环处理每一条乘车记录(type, price, t)t为当前记录时间): a.清理过期券:检查队首元素,如果t - 队首券.time > 45,则持续弹出队首(无论是否已使用)。 b. 如果type == 0(地铁): * 总花费增加price。 * 将新券(t, price)加入队尾,并标记为未使用。 c. 如果type == 1(公交车): * 初始化一个标志found = false。 * 遍历当前队列q(从队首到队尾): * 跳过已使用(used[i] == true)的券。 * 如果遇到一张未使用且券.price >= price的券: * 标记该券为已使用。 *found = true。 *跳出循环。 * 如果found == false,说明没找到可用券,总花费增加price

这个算法为什么比纯暴力好?虽然坐公交车时仍然需要遍历队列,但队列q经过了过期清理,长度得到了控制。更重要的是,一旦一张券被使用,它就会被标记,之后的遍历会跳过它,避免了重复检查。但理论上,在最坏情况下(比如一直坐公交,没有地铁产生新券,队列里全是无效的小额券),每次公交遍历的复杂度仍是O(队列长度)

3.5 进一步优化:双指针或单调性优化

对于CSP-J级别的竞赛,上述“队列+标记”的方法已经足够通过所有测试点,因为它巧妙地利用了问题性质,实际运行效率很高。但追求更极致的效率,我们可以引入“双指针”技巧。

我们维护两个指针ij,它们都指向队列(可以用数组模拟)coupons

  • i是“队首”指针,用于清理过期券。当coupons[i].time < t - 45时,就i++
  • j是“待查找”指针。当我们坐公交车需要找券时,我们从j开始向后查找第一张未使用且面值足够的券。找到后,标记它,并将j移动到下一个位置。

为什么这样优化?因为优惠券一旦被检查过(在j之前),无论是否被使用,对于之后的公交车,它们要么已过期(被i清理),要么面值太小(在之前查找时就被跳过了,说明它们小于之前某次公交的票价,那么对于票价更高或相等的后续公交,它们更不可能被使用)。这保证了j只会单调向后移动,整个算法过程中,查找部分的总操作次数是O(n)的,从而将整体复杂度降到了严格的O(n)

数据结构选择:我们可以用一个结构体数组Coupon coupons[N]来存储券,包含time, price, used三个属性。用i, j, idx三个指针分别管理队首、查找起点、队尾。

4. 代码实现与逐行解析

这里给出采用“数组模拟队列 + 双指针优化”的C++实现。这是兼顾了易懂性和高效率的方案。

#include <iostream> using namespace std; const int MAXN = 100005; // 根据数据范围设定 struct Coupon { int time; // 获得时间 int price; // 面值 bool used; // 是否已使用 }; Coupon coupons[MAXN]; // 优惠券数组 int i = 0; // 队首指针,用于清理过期券 int j = 0; // 查找起始指针 int idx = 0; // 队尾指针,指向下一个空位 long long totalCost = 0; // 总花费,注意可能超过int范围 int main() { int n; cin >> n; for (int k = 0; k < n; k++) { int type, price, t; cin >> type >> price >> t; // 步骤1: 清理过期优惠券(无论是否使用) while (i < idx && t - coupons[i].time > 45) { i++; } // 确保查找指针j不落后于队首指针i if (j < i) { j = i; } if (type == 0) { // 乘坐地铁 totalCost += price; // 获得优惠券,加入队尾 coupons[idx].time = t; coupons[idx].price = price; coupons[idx].used = false; idx++; } else { // 乘坐公交车 bool found = false; // 从j开始查找可用的优惠券 for (int p = j; p < idx; p++) { if (!coupons[p].used && coupons[p].price >= price) { // 找到符合条件的券 coupons[p].used = true; found = true; // 重要:更新j指针到p+1,因为p之前的券对于后续查找都“无效”了 // 解释:p之前的券,要么已使用,要么面值小于当前price。 // 对于后面票价>=price的公交,这些券更不可能满足条件。 j = p + 1; break; } } if (!found) { totalCost += price; } } } cout << totalCost << endl; return 0; }

代码关键点解析

  1. 数据结构:使用定长数组coupons模拟队列,i是头,idx是尾(新元素插入位置)。used标记是否已使用。
  2. 过期清理(while (i < idx && t - coupons[i].time > 45)): 严格判断大于45分钟,因为题目条件是“<=45”有效,所以“>45”过期。清理时i++已使用的券也会被清理掉,这很关键,保证了空间和时间的效率。
  3. 指针j的维护j是查找的起点。if (j < i) j = i;这行保证了当队首清理后,j不会指向一个已经被清理(无效)的位置。
  4. 查找与更新j(for (int p = j; p < idx; p++)): 这是双指针优化的核心。查找从j开始。一旦找到符合条件的券,除了标记使用和跳出循环,我们还将j更新为p+1为什么?因为位置p之前的券(即下标在[j, p-1]区间的券),在这次查找中被扫描过且跳过了。它们被跳过只有两种可能:①已使用;②未使用但面值< price。对于未来时间更晚的公交车,其票价price_future >= price_current(不一定,但即使更小,这些券也可能因为时间或面值原因无效)。更重要的是,由于时间递增,未来公交车的查找起点j至少是当前的j,所以这些被跳过的券永远不会再被考虑。这保证了j只增不减,整个查找过程所有p的移动加起来是O(n)的。
  5. 时间复杂度:每个元素入队一次,每个元素最多被ij指针各访问一次(清理和查找),因此是严格的O(n),完美应对10^5的数据量。
  6. 数据类型:总花费totalCost使用long long,因为极端情况下(所有行程都付费),总花费可能超过int范围(10^5 * 1000 = 10^8,仍在int内,但习惯上好)。

5. 常见错误与调试心得

这道题在实战中错误率很高,以下是我总结的几个常见“坑点”和调试技巧。

5.1 错误类型与排查表

错误类型可能现象原因分析解决方法
理解错误样例不过或得分很低1. 忽略了“选择最早获得”的券,随便用一张。
2. 错误理解有效期(如认为是“获得后45分钟内”而非“乘车前45分钟内获得”)。
3. 认为优惠券可以累积多次使用。
重新精读题目,用笔在纸上模拟题目给的样例。
超时(TLE)大数据点全部超时使用了O(n^2)的暴力算法,每次公交遍历所有历史券。采用队列维护有效券,并尝试双指针优化,确保复杂度为O(n)
答案错误(WA)部分测试点错误1.过期判断条件写错t - coupon.time > 45写成>=
2.指针维护错误j指针在清理过期券后没有与i同步 (if (j < i) j = i)。
3.面值比较错误:公交使用券条件是券.price >= bus.price,漏了等号。
4.数据类型溢出:总花费用了int,在极大情况下溢出成负数。
1. 仔细核对边界条件。
2. 添加打印语句,输出关键步骤后i, j, idx的值和队列状态,进行人工核对。
3. 使用long long存储总花费。
运行时错误RE(如数组越界)数组大小开小了。n最大为100000,但优惠券数量最多也可能接近n将数组大小至少设为100005或更大。

5.2 调试与测试技巧

  1. 构造边界数据

    • 全地铁:输入全为0,检查总花费是否为所有票价之和。
    • 全公交:输入全为1,且票价很高,检查是否全部自费。
    • 时间边界:设计一张券在第45分钟刚好被使用,以及在第46分钟过期的情况。
    • 面值边界:设计一张券面值刚好等于公交车票价的情况。
    • “最早获得”规则验证:在有效期内有两张满足面值的券,时间分别为t1t2(t1<t2),公交车时间t,确保程序选择了t1的券。
  2. 使用小规模数据打印中间状态:这是最有效的调试方法。在清理过期券、查找优惠券、更新指针等关键步骤后,打印出当前队列内容、各指针位置、总花费。与手工计算过程对比,能快速定位逻辑错误。

  3. 理解双指针的单调性:如果使用双指针优化,务必在脑中或纸上模拟ij的移动。i只随当前时间t增长而右移(清理)。j只在找到券后向右跳,且不会小于i。它们的单调不降是正确性的保证。

6. 算法扩展与思维提升

解决这道题,绝不仅仅是为了AC。它蕴含的算法思想可以迁移到许多其他场景。

  1. 滑动窗口最大值/最小值问题:本题中,我们维护了一个45分钟的“时间窗口”,并在窗口内查找满足特定条件(面值>=票价)的元素。这与经典的滑动窗口问题有相似之处,但查找条件更复杂。如果题目变为“求45分钟内最大面值的优惠券”,就可以直接用单调队列在O(1)时间内解决。
  2. 任务调度与资源分配:可以把优惠券看作一种“资源”(有生效时间和价值),把公交车看作“任务”(有发生时间和资源需求)。问题就变成了如何按时间顺序处理任务,并为每个任务分配一个“最早可用且满足条件”的资源。这种模型在操作系统、生产调度中很常见。
  3. 离线查询与在线处理:本题要求在线处理(按输入顺序即时决策)。如果题目改为先给出所有记录再询问总花费,就成了离线问题,或许可以用差分、排序等不同方法解决。
  4. 从模拟到优化:这道题是一个经典的范例,展示了如何将一个直观的O(n^2)模拟过程,通过分析问题性质(时间有序、有效期、贪心选择),逐步优化到O(n)。这种“先实现朴素算法,再寻找优化点”的思维模式,是解决所有算法竞赛题目的通用法门。

最后,我的个人体会是,像“公交换乘”这类模拟题,是锻炼编程严谨性和算法优化思维的绝佳材料。它要求你像机器一样精确理解规则,又要求你像数学家一样抽象出模型并优化。多练习这类题目,当你再看到诸如“预约系统”、“订单处理”、“日志分析”等实际问题时,你会自然而然地想到队列、滑动窗口、双指针这些工具,思考如何设计高效的数据流转方案。这才是信息学竞赛带给我们的,超越比赛本身的长期价值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/23 5:42:06

3A游戏显示器选购指南:Mini LED、OLED与IPS画质对比与避坑

玩3A大作最容易买错的游戏显示器&#xff1a;高刷不等于画质好&#xff01;22款4K、Mini LED与OLED电竞屏横评&#xff0c;雷鸟、海信、AOC、微星、华硕怎么选&#xff1f; 给3A游戏配显示器&#xff0c;很多人第一反应就是“刷新率越高越好”&#xff0c;结果买回家发现画面发…

作者头像 李华
网站建设 2026/8/23 5:38:10

工业边缘AI实战:基于FCU3501硬核平台的设计、部署与优化

1. 从“边缘”到“核心”&#xff1a;为什么工业场景需要FCU3501这样的“硬核”平台&#xff1f;最近几年&#xff0c;但凡和工业自动化、智能制造沾边的项目&#xff0c;几乎都绕不开“边缘AI”这个词。听起来很酷&#xff0c;但真正干过项目的人都知道&#xff0c;把AI模型从…

作者头像 李华
网站建设 2026/8/23 5:36:53

个人量化系统要不要自建:用维护工时和故障责任做四层决策

会写几段Python&#xff0c;并不等于有时间维护一套完整量化系统。数据更新失败、依赖升级、定时任务中断、账户权限变化&#xff0c;都需要有人发现并处理。个人投资者决定自建前&#xff0c;可以先把系统拆成数据、研究、运行和账户四层&#xff0c;再计算每周愿意投入多少维…

作者头像 李华
网站建设 2026/8/23 5:35:46

基于排队论与粒子群算法的核酸检测点服务台优化模型

1. 项目概述&#xff1a;当数学建模遇上现实痛点最近几年&#xff0c;排队问题从一个纯粹的运筹学理论课题&#xff0c;变成了我们每个人生活中都切身体会过的现实场景。尤其是在特定时期&#xff0c;核酸检测点前的长龙&#xff0c;几乎成了城市一景。队伍蜿蜒曲折&#xff0c…

作者头像 李华
网站建设 2026/8/23 5:34:50

大模型推理优化:KV Cache与Prompt Cache原理及DeepSeek Harness实践

大家好&#xff0c;我是专注于AI工程化实践的技术博主。在部署和使用大语言模型&#xff08;LLM&#xff09;时&#xff0c;你是否遇到过这样的困扰&#xff1a;模型推理速度慢、显存消耗巨大&#xff0c;导致API调用成本居高不下&#xff0c;尤其是在处理具有重复前缀的批量请…

作者头像 李华
网站建设 2026/8/23 5:32:40

PyCharm高效插件配置指南:6类刚需场景精准选型

1. PyCharm插件推荐&#xff1a;不是“装得越多越好”&#xff0c;而是“用得准、省得狠、稳得住”你打开PyCharm&#xff0c;新建一个Python项目&#xff0c;写完三行代码就卡顿半秒&#xff1b;调试时想看变量值得反复点开嵌套字典&#xff1b;团队协作时同事提交的代码格式五…

作者头像 李华