甲级1016 Phone Bills,是我在刷翁恺老师PAT配套题库时第一次感受到“读题比写代码难”的题。这道题放在甲级里不算算法含量高,但错误率一直不低。很多人一上来就对着英文题面发懵:又是电话费又是24个费率,到底想让我算什么?其实把它拆开看,就是一个非常经典的多记录配对模拟题。无论你是正在备考PAT、准备考研机试,还是单纯想练一练对边界条件的掌控力,这道题都值得静下心来完整写一遍。这篇博文就把我刷这道题时的完整思路、踩坑记录和最终的代码框架都摊开讲清楚。
1. 题目到底在模拟什么:先读懂计费规则再动手
1.1 一句话概括题目逻辑
题目给了24个整数,第i个整数表示一天中第i个小时里每分钟的电话费,单位是美分。然后给一堆通话记录,每条记录由用户名、月:日:时:分、状态组成,状态只有两种:on-line表示电话接通,off-line表示电话挂断。
你需要做的就是:把每个用户的有效通话记录找出来,按照规则计算每一通电话的时长和费用,最后按用户名字典序输出账单。
这个题目最迷惑人的地方在于“有效记录”的定义。并不是所有on-line和off-line都能配对。题面里明确说:一个有效的通话记录,必须是一条on-line记录后面紧接着一条off-line记录,而且这两条记录属于同一个用户。注意“紧接着”这三个字,它意味着如果同一个用户出现了连续两条on-line,那么前一条是无效的;或者说,一条off-line前面如果没有on-line,这条off-line也是无效的。
我当时第一次做这道题,就用错了思路,想着把所有on-line和off-line按时间匹配,结果样例都过不去。后来才反应过来,这个“相邻配对”的规则是核心。
1.2 计费规则的本质:分段线性累加
费率是按小时给的,不是按天。比如说0点这个小时费率是10美分/分钟,1点是20美分/分钟,那么从00:30到01:30这60分钟的电话,费用就是前30分钟按10美分算,后30分钟按20美分算,总共是30 * 10 + 30 * 20 = 900美分,也就是9美元。
注意,跨天也没有特殊的“夜间优惠”或者“周末优惠”,费率每天重复循环。所以只要你能算出任意一个时刻相对于月初的累计费用,两个时刻的累计费用之差,就是中间这段通话的费用。这个思路是后面用前缀和算费用的基础。
1.3 输入输出的边界要提前确认
输入先给24个整数,再给一个整数N,表示记录总条数。N的范围题目里写的最大1000,所以N为0的边界情况也要考虑。接着是N行记录,每行的格式是用户名 月:日:时:分 状态,例如CYLL 01:01:06:01 on-line。
输出要求:每个有有效通话记录的用户,先输出一行用户名 月,然后按时间顺序输出每一通电话,格式是:
code复制开始日:时:分 结束日:时:分 通话时长 $费用
最后输出这个用户的总费用。费用保留两位小数,时间要补零成两位。所有用户按名字的字典序排列。
这里有个容易看漏的点:没有有效通话记录的用户,一个字都不能输出。哪怕他有很多条记录,只要配不上对,就直接跳过。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据准备:排序和配对是两个关键动作
2.1 为什么必须先排序再配对
原始输入是乱序的,同一个用户的记录可能分散在各处。如果不排序,你根本没法判断哪条on-line后面“紧接着”哪条off-line。所以第一步一定是把所有记录按规则排序。
排序规则很简单:先按用户名字典序升序,再按时间先后升序。名字相同的情况下,时间早的排在前面。
时间怎么比?可以先把时间转换成一个整数:(day * 24 + hour) * 60 + minute。因为题目保证所有记录都在同一个月内,所以用这个值就能完全确定先后顺序。如果担心数据跨月,可以再加一个月份的权重,公式变成((month * 31 + day) * 24 + hour) * 60 + minute,但PAT官方数据基本不会出幺蛾子,按天算就够了。
2.2 相邻配对的完整逻辑:比“i和i+1比较”更稳的写法
我见过很多题解用“扫描排序后的数组,比较第i条和第i+1条”的方式来判断配对。这个写法在大多数情况下是对的,但很容易写错。比如一个用户有三条记录:
code复制CYLL 01:01:00:00 on-line
CYLL 01:01:30:00 on-line
CYLL 01:01:40:00 off-line
按照“相邻配对”规则,第一条on-line后面跟着的是第二条on-line,不是off-line,所以第一条作废;第二条on-line和第三条off-line配对成功。如果你用“i和i+1比较”的写法,第一次比较发现第0条on-line和第1条on-line不配对,跳过第0条继续,然后第1条和第2条配对。这样写确实能过,但逻辑不够清晰。
我更推荐用一个“缓存待配对的on-line记录”的写法。遍历某用户的已排序记录时:
- 遇到
on-line,就把当前这条记录临时存起来,作为“等待配对的开始记录”。如果之前已经存了一条,直接覆盖掉,因为旧的这条注定无效。 - 遇到
off-line,如果当前没有等待配对的on-line,说明这条off-line是多余的,直接丢弃;如果有,就配对成功,计算费用,然后把等待状态清空。
这个思路的好处在于:不用操心各种连续的on-line或连续的off-line交叉情况,逻辑上只有“有没有等待配对的开始记录”这一个状态,非常不容易出错。
2.3 结构体与排序器的组织方式
用C++写的话,可以定义一个结构体:
cpp复制#include <bits/stdc++.h>
using namespace std;
struct Rec {
string name;
int month, day, hour, minute;
int status; // 1 表示 on-line, 0 表示 off-line
int time; // 从月初始开始的分钟数,用于排序和时长计算
};
排序器:
cpp复制bool cmp(const Rec& a, const Rec& b) {
if (a.name != b.name) return a.name < b.name;
return a.time < b.time;
}
读入时,建议把状态字符串on-line和off-line统一转成0和1。判断时可以直接看status == 1,不用每次都比较字符串。
输入解析要小心,那行记录里有冒号分隔的时间,可以用scanf的格式串直接读:
cpp复制string name, status;
cin >> recs[i].name;
scanf("%d:%d:%d:%d", &recs[i].month, &recs[i].day, &recs[i].hour, &recs[i].minute);
cin >> status;
recs[i].status = (status == "on-line");
recs[i].time = (recs[i].day * 24 + recs[i].hour) * 60 + recs[i].minute;
这里有个小坑:scanf读int时会跳过前面的换行和空格,但用cin读字符串再接scanf,中间不要混着一个getline,否则很可能把上一行末尾的换行符吃进去。我习惯用cin读名字和状态,用scanf读时间,两端代码中间不加多余输入操作,实测没有问题。
3. 计费实现:费率累加的正确打开方式
3.1 为什么不能直接用“分钟差 × 平均费率”
电话费是分小时档位累加的,费率不是常数。从00:30到01:30的60分钟,如果直接算分钟差60,然后乘以某个费率,得到的结果肯定不对。所以计费必须精确到每一分钟,每分钟按当时所在的小时费率计算。
最朴素的写法是从开始时刻逐分钟累加到结束时刻:
cpp复制int calculate(const Rec& start, const Rec& end) {
int res = 0;
int day = start.day, hour = start.hour, minute = start.minute;
while (day != end.day || hour != end.hour || minute != end.minute) {
res += rate[hour];
minute++;
if (minute == 60) {
minute = 0;
hour++;
if (hour == 24) {
hour = 0;
day++;
}
}
}
return res;
}
这个写法在逻辑上绝对正确,而且不容易出错。但每通电话都要一个循环,通话时间长的话要循环上千次。考虑到N最大1000,总计算量其实很小,完全可以接受。不过代码不够优雅,而且容易在进位那里写错。
3.2 前缀和思路:把区间费用变成两个前缀之差
更推荐的做法是前缀和。
既然费率每天重复,可以先算出一整天的总费用dayCost:
cpp复制long long dayCost = 0;
for (int i = 0; i < 24; ++i) {
dayCost += 1LL * rate[i] * 60;
}
然后写一个函数,计算从“本月第1天00:00”到某个时刻的累计费用:
cpp复制long long costFromMonthStart(int day, int hour, int minute) {
long long res = 1LL * (day - 1) * dayCost;
for (int i = 0; i < hour; ++i) {
res += 1LL * rate[i] * 60;
}
res += 1LL * rate[hour] * minute;
return res;
}
这个函数分三部分:
(day - 1) * dayCost:前面完整的天数,每天都一样,直接乘。rate[0] + rate[1] + ... + rate[hour - 1]各乘60:当天已经完整走过的小时。rate[hour] * minute:当前小时里已经走过的分钟。
一通从start到end的电话费用,就是:
cpp复制long long cents = costFromMonthStart(end.day, end.hour, end.minute)
- costFromMonthStart(start.day, start.hour, start.minute);
这个做法的好处是代码清晰,不容易在进位那里出错。通话时长和费用也可以分开算,时长直接用两个time字段做差,费用用前缀和做差。两件事互不干扰。
3.3 费率的单位陷阱和整数溢出
费率的单位是美分/分钟,不是美元。如果你把费率当成美元来算,最后结果会差100倍。我个人习惯是全程用美分当整数计算,最后统一转成美元输出:
cpp复制double fee = cents / 100.0;
为什么不直接用double累加?因为浮点累加会出现精度误差。比如多次通话的费用是0.1 + 0.2,二进制浮点存出来可能是0.30000000000000004,输出时用%.2f可能侥幸没显示出来,但总费用累加时误差可能被放大。用整数美分累加,最后再除以100,是最稳妥的方案。
int会不会溢出?一天最多1440分钟,题目给的费率上限我印象中不超过1000美分/分钟,一天撑死也就一百多万美分,一个月三千万美分,int其实存得下。但计算中间值如day * dayCost时,如果day是31,再乘以几百万,可能有溢出风险。保险起见,统一用long long,一点毛病没有。
3.4 前缀和公式的边界自测
自己写代码时建议用几个边界用例验证前缀和公式:
- 从01:01:00:00到01:01:00:01,通话1分钟,费用应该是
rate[0]。带入公式:costFromMonthStart(1, 0, 1)减去costFromMonthStart(1, 0, 0),结果是rate[0] * 1,正确。 - 从01:01:23:59到01:02:00:00,通话1分钟,费用应该是
rate[23]。带入公式:costFromMonthStart(2, 0, 0)减去costFromMonthStart(1, 23, 59),前者是dayCost,后者是dayCost - rate[23] * 1,差值是rate[23],正确。 - 从01:01:00:00到01:02:00:00,通话1440分钟,费用应该是
dayCost。公式做差也正好是dayCost。
这三个自测用例通了,公式基本没问题。
4. 格式输出和案例实测:把坑都趟一遍
4.1 输出格式的每个字符都可能是失分点
PAT的输出判得很严,格式不对直接给你一个Presentation Error。Phone Bills这题最常见的失分原因就是输出格式。
第一行用户信息,是用户名加空格加两位月份,比如CYJJ 01。月份必须补零,用%02d输出。
每条通话记录,依次是:
code复制开始时间 结束时间 时长 $费用
其中时间格式是日:时:分,每个都是两位。比如01:05:59 01:07:00 61 $12.10。注意中间是空格分隔,不是制表符。费用前面有美元符号,保留两位小数。
最后一个用户的总费用单独一行,格式是:
code复制Total amount: $12.10
注意冒号后面有个空格。这个空格我见过有人漏掉,结果一直PE。
还有一点:不同用户之间不要输出额外空行。原题的输出样例里,上一个用户的Total amount和下一个用户的用户名之间是紧挨着的,没有空行。这一点容易被样例误导,需要特别留意。
4.2 常见错误对照表
我把刷题过程中常见的坑整理成了表格,建议写代码前先扫一遍:
| 错误类型 | 具体表现 | 解决办法 |
|---|---|---|
| 时间补零遗漏 | 输出1:5:59而不是01:05:59 |
所有时间字段用%02d |
| 费用补零遗漏 | 输出$12.1而不是$12.10 |
用%.2f |
| 状态判断写反 | 把on-line当成0,把off-line当成1 |
读入时统一处理,定义1为online,0为offline |
| 配对逻辑错误 | 两条连续online错误配对 | 用“缓存待配对online”的思路 |
| 无效用户也输出 | 没有任何有效通话记录的用户也输出了空账单 | 只有配对成功过才输出用户名 |
| 浮点累加误差 | 总费用出现$12.300000000000001 |
全程用整数美分计算,最后转double |
| 总费用前少了空格 | 输出Total amount:$12.10 |
冒号后加一个空格 |
| N为0时不处理 | 没有记录时程序崩溃或输出额外内容 | N为0直接结束,不输出任何内容 |
4.3 一个完整推演:从输入到输出的整个过程
为了把逻辑串起来,我手动推一个简单用例。
假设24个费率全是0,只有第0个小时费率是10,其他小时费率都是0。这样一天的总费用dayCost = 10 * 60 = 600美分。
再假设有以下三条记录:
code复制CYLL 01:01:00:00 on-line
CYLL 01:01:00:30 on-line
CYLL 01:01:01:00 off-line
排序后还是这个顺序。扫描:
- 第0条是
on-line,缓存pending = 00:00。 - 第1条是
on-line,覆盖pending = 00:30,因为00:00那条已经注定无效。 - 第2条是
off-line,此时有等待的on-line,配对成功。
通话从01:01:00:30到01:01:01:00,时长30分钟。费用是0点这个小时费率10美分/分钟,共300美分,即3美元。
输出:
code复制CYLL 01
01:00:30 01:01:00 30 $3.00
Total amount: $3.00
再换一个更复杂的跨小时例子。费率0点是10,1点是20。通话从00:30到01:30,时长60分钟,费用900美分,即9美元。这种用例可以拿来验证前缀和的正确性。
4.4 完整代码参考
把上面的思路整合成一份可运行的C++代码,放在这里作为参考。这份代码我用多个测试用例跑过,包括空数据、单用户多记录、多用户、连续online、多余offline等边界情况。
cpp复制#include <bits/stdc++.h>
using namespace std;
struct Rec {
string name;
int month, day, hour, minute;
int status; // 1 online, 0 offline
int time;
};
bool cmp(const Rec& a, const Rec& b) {
if (a.name != b.name) return a.name < b.name;
return a.time < b.time;
}
int rate[24];
long long dayCost;
long long costFromMonthStart(int day, int hour, int minute) {
long long res = 1LL * (day - 1) * dayCost;
for (int i = 0; i < hour; ++i) {
res += 1LL * rate[i] * 60;
}
res += 1LL * rate[hour] * minute;
return res;
}
int main() {
for (int i = 0; i < 24; ++i) {
cin >> rate[i];
}
dayCost = 0;
for (int i = 0; i < 24; ++i) {
dayCost += 1LL * rate[i] * 60;
}
int n;
cin >> n;
vector<Rec> recs(n);
for (int i = 0; i < n; ++i) {
string status;
cin >> recs[i].name;
scanf("%d:%d:%d:%d", &recs[i].month, &recs[i].day, &recs[i].hour, &recs[i].minute);
cin >> status;
recs[i].status = (status == "on-line");
recs[i].time = (recs[i].day * 24 + recs[i].hour) * 60 + recs[i].minute;
}
sort(recs.begin(), recs.end(), cmp);
int i = 0;
while (i < n) {
string curName = recs[i].name;
bool hasValid = false;
long long totalCents = 0;
const Rec* pending = nullptr;
while (i < n && recs[i].name == curName) {
if (recs[i].status == 1) {
pending = &recs[i];
} else {
if (pending != nullptr) {
long long cents = costFromMonthStart(recs[i].day, recs[i].hour, recs[i].minute)
- costFromMonthStart(pending->day, pending->hour, pending->minute);
int duration = recs[i].time - pending->time;
if (!hasValid) {
printf("%s %02d\n", curName.c_str(), recs[i].month);
hasValid = true;
}
printf("%02d:%02d:%02d %02d:%02d:%02d %d $%.2f\n",
pending->day, pending->hour, pending->minute,
recs[i].day, recs[i].hour, recs[i].minute,
duration, cents / 100.0);
totalCents += cents;
pending = nullptr;
}
}
++i;
}
if (hasValid) {
printf("Total amount: $%.2f\n", totalCents / 100.0);
}
}
return 0;
}
这段代码有几个值得注意的设计:
pending用const Rec*而不是Rec拷贝,避免复制整个结构体,也方便引用原始数据。- 判断用户分组时用
while循环扫描当前用户的所有记录,直到名字变了再开始下一个用户。 hasValid变量用来确认该用户是否真的有有效通话,避免输出空账单。
4.5 测试用例怎么造
本地测试时,不要只跑题目样例。建议自己造几组针对性用例:
- 所有记录全是
on-line,没有任何off-line。期望输出为空。 - 所有记录全是
off-line,没有任何on-line。期望输出为空。 - 两个用户交错记录,验证排序是否正确后分组。
- 一个用户有多条连续
on-line后再跟off-line,验证旧on-line被覆盖。 - 一条通话跨到第二天,验证
dayCost参与计算是否正确。
N为0时,程序不输出任何内容,直接结束。这个也要测一下。
5. 甲级模拟题的通用解法:从Phone Bills想到的
5.1 模拟题其实有固定套路
刷完Phone Bills你会发现,PAT甲级里不少模拟题都是同一个套路:
读题 → 设计数据结构 → 排序 → 配对或模拟 → 格式化输出。
第一步读题往往最耗时。英文题面加上大段背景描述,很容易把人绕晕。我现在的做法是:先看输入和输出样例,理解“输入了什么、要我输出什么”,再回头读题面里的规则细节。规则里凡是出现“valid”“invalid”“only”这类词,都要重点关注,它们往往决定了哪些数据要丢弃。
第二步设计数据结构。Phone Bills里用结构体存一条记录。大多数模拟题都可以用结构体表示一条数据,然后排序。
第三步排序是为了把散落的记录归拢。模拟题里的排序通常不是难点,但一定要想清楚排序的先后依据,比如这道题是先按名字再按时间,而不是反过来。
第四步配对是核心。配对逻辑一定要在纸上先把各种边界情况画出来。比如这道题的“连续on-line”“多余off-line”就是两种典型的边界情况。我建议在写代码之前,用一个简单的二维表把可能的记录序列列出来,模拟一遍配对过程,确认无误后再动手。
第五步格式化输出。PAT对输出格式的挑剔程度超乎想象。空格、换行、补零、保留几位小数,任何一点偏差都过不了。我的经验是:在代码写完进入提交阶段前,把所有printf的格式串单独检查一遍,逐字符对照题面输出要求的说明。
5.2 为什么说Phone Bills适合作为模拟题入门口
这道题不涉及深奥的算法,只要你会排序、会遍历数组,就能做。但它几乎覆盖了模拟题的所有常见难点:
- 读题:需要从英文题面中提取规则。
- 数据结构:需要把时间、状态、用户信息组织起来。
- 排序:需要自定义比较器,按两个关键字排序。
- 配对:需要处理丢弃无效记录的边界情况。
- 计费:需要设计一个不容易出错的计算方式。
- 输出:需要严格按格式输出,一分一毫都不能差。
把这道题完整做透,你的PAT甲级模拟题基础就算打牢了。后面遇到类似题,比如各类账单、日程、批量处理题目,处理思路会顺手很多。
5.3 备考路上的几个实用建议
如果你正在刷PAT,或者准备考研机试,我有几个比较实际的建议。
第一,不要一上来就写代码。模拟题花10分钟读题和设计数据结构,比花10分钟写完再debug要划算得多。Phone Bills这种题,想清楚配对逻辑再写,一次性AC的概率会高很多。
第二,IDE里跑通样例不代表能AC。样例数据通常很温和,边角情况全靠自己造。养成“写完代码先自己构造5组以上边界测试”的习惯,能帮你省下大量提交试错的时间。
第三,善用PAT的反馈机制。PAT的判题反馈有AC、WA、PE等几种,如果出现PE,说明主干逻辑已经对了,问题基本集中在输出格式。这个时候不要怀疑核心算法,专心检查冒号、空格、补零这些细节就行。
第四,平时练习多看看别人的题解也没问题,但一定不要直接抄。模拟题的价值在于让你亲身体会“从混乱数据中整理出有序结果”的过程,直接抄代码就完全失去了练习的意义。我看题解主要看别人是怎么处理配对边界的,然后自己重新实现一遍。
5.4 一些关于PAT模式的补充
再提一嘴“PAT模式”这个词。很多人问PAT考试到底是个什么模式,其实它就是一种限时的上机编程考试。甲级通常3小时做几道题,每题按测试点给分,不是只看最终结果。这种模式决定了你必须写对每一处细节,因为一个测试点没过就会扣分。所以平时练习时就要养成“考虑所有边界”的习惯,考试时才不会慌。
翁恺老师的PAT配套课程和题库是我见过比较适合入门的学习资源。他的做题思路很强调“先想清楚,再写代码”,这一点对模拟题的帮助特别大。如果时间允许,建议把乙级的基础题先扫一遍,再回来啃甲级,会轻松不少。
最后再分享一点我的实际操作体会
我刷这道题最大的教训,是在“配对”这一步上栽了跟头。第一次写的时候,我用的是“比较第i条和第i+1条”的思路,结果遇到连续on-line的记录,代码总是差那么一点。后来改成“缓存待配对online”的写法,瞬间清爽了。这也让我意识到,很多模拟题的复杂逻辑,本质上都可以简化成一个状态变量加一个遍历循环,关键是要选对状态的定义方式。
还有一个小技巧:把费率相关的常量定义在全局,把dayCost提前算好,这样在函数里用起来很方便,也不容易出现传参传错的低级问题。代码风格上,我习惯在关键逻辑处加一行注释,标明“这步是在丢弃无效的旧online”之类的意图,这样回头复查时能快速定位。
如果你现在正卡在这道题上,别急。按照这篇文章的思路,先画一遍配对过程,再写一遍代码,重点检查输出格式,大概率很快就能AC。刷完这道题,你离拿下甲级模拟题又近了一步。
