1. 这道题到底在算什么先拆需求再碰键盘编号 0055 的这道「手机话费」题我第一次看到的时候也以为是个两三行就能写完的送分题读三个数套几个 if输出个两位小数收工。真上手写完提交才发现这类题的杀伤力全藏在边角上——不是逻辑难而是每一个计费单元的定义都可能埋一个坑你少想一步判题系统就多给你一个错误答案。手机话费这个场景本质上考的不是算术而是把一个模糊的生活规则翻译成一套无歧义、可复现的计算契约的能力。我先把这道题的模型摆出来后面所有讨论都围绕它展开。题目场景设定为某运营商的月结账单用户每月固定月租 38 元套餐内含三样资源100 分钟国内通话、20 条短信、1 GB 流量。超出套餐的部分按阶梯单价另行计费最终输出当月应缴总额保留两位小数。输入是一行三个整数分别是本月累计通话秒数、短信条数、流量消耗 MB 数三个数都不超过 10 的 9 次方。规则细节我列成下面这张表这张表就是整道题的宪法后面代码里的每一个常量都得在这里找到出处。计费项目套餐内额度超出部分的计价方式计量与取整规则月租无固定 38.00 元不参与任何折算国内通话6000 秒100 分钟0.15 元 / 分钟不足 1 分钟按 1 分钟计短信20 条0.10 元 / 条按条计不可拆分数据流量1024 MB3.00 元 / GB按 MB 线性折算结果四舍五入到分这张表乍看很温和但只要你多看两眼就会发现三个立刻要命的问题通话为什么给的是秒而单价是分钟流量的单价是 GB 但输入是 MB中间怎么折算四舍五入到分是在每一步都舍入还是最后一次性舍入这三个问题的答案直接决定了你的代码能不能过。我更愿意把这道题推荐给两类人一类是刚学完 C 基本语法、想找点真实场景练手的朋友这道题规模小、可验证、坑位典型是个很合适的第一次踩坑对象另一类是已经写了不少业务代码、但经常在金额计算上翻车的老手因为这道题浓缩的正是金融类计算的通用骨架——单位统一、整数化、舍入策略、边界保护一套东西换个皮就能套到云资源按量计费、快递首重续重、停车场计时收费上去。后面我会专门花一节讲这个可迁移性因为它比这道题本身值钱得多。2. 三个真正的难点取整、折算、精度2.1 不足一分钟按一分钟向上取整的两种写法与一个陷阱通话这一项输入的粒度是秒计价的粒度是分钟而且明确写了不足 1 分钟按 1 分钟计。这句话翻译成数学语言就是对超出部分做向上取整。注意是超出部分不是全部通话时长。这一点很多人第一遍会写错套餐内是 100 分钟你如果能用满 100 分钟不花钱那说明套餐内那部分是包干的不需要去做任何取整动作。只有当秒数超过 6000 之后超出的那一段才进入取整流程。假设用户打了 6001 秒超出 1 秒按规则收 1 分钟的钱也就是 0.15 元。假设用户打了 6059 秒超出 59 秒还是收 1 分钟。6060 秒超出 60 秒收 2 分钟。这个函数在 6001 到 6060 之间是平的在 6060 处跳一档是一条典型的阶梯函数。写法上整数版本最干净long long over sec - 6000; long long mins (over 59) / 60;(x 59) / 60是整数除法下向上取整的标准套路原理很简单加上除数减一把那些除不尽的零头顶到下一个整数上。x 从 1 到 60加上 59 之后是 60 到 119除以 60 得到 1x 是 61 到 120加完是 120 到 179除以 60 得到 2。完美对应不足一分钟算一分钟。与之对照浮点版本是ceil((double)over / 60)。我没有用它的理由不是性能这种虚的而是引入了一个不必要的中间态。ceil需要一个 doubledouble 在这个量级最大 10 的 9 次方除以 60本身是精确的所以其实不会出错但你的代码里从此多了一个必须保证这个除法不丢精度的隐含假设。而在金额计算里任何隐含假设都是一颗定时炸弹——今天它在 10 的 9 次方以内安全明天数据范围一改到 10 的 18 次方这行代码就可能悄悄给出一个错误的分钟数而且这种错误不会崩溃、不会报警只会让你的账单差 0.15 元。还有一个更隐蔽的坑我必须单独点出来不要用unsigned。有人觉得通话秒数、短信条数天然非负就用unsigned int。一旦你写了unsigned over sec - 6000而输入的 sec 恰好是 3000用户这个月没打够套餐3000 - 6000会回绕成一个天文数字随后(over 59) / 60给你一个上亿的分钟数账单直接爆炸。用有符号整型哪怕真出了负数至少你还能在调试时一眼看出来。金额和计数这类东西我个人的习惯是一律long long起步理由后面 2.3 节展开。2.2 线性折算与四舍五入那个 512 是怎么来的流量这一项才是这道题的核心难点。规则说超出部分按 3 元每 GB 计输入单位是 MB那就必须做单位折算。1 GB 等于 1024 MB所以每 MB 的单价是 3/1024 元约等于 0.0029297 元。如果超出的量是 1 MB那它到底算 0 元还是 0.01 元规则里写的是四舍五入到分所以 0.0029297 元会被舍成 0 元用户这 1 MB 白用。超出 3 MB0.0087891 元舍入后还是 0 分。超出 4 MB0.0117188 元进位到 1 分。这就是四舍五入在起作用。实现上我采用整数分体系把 1 元当成 100 分GB 单价是 300 分那么超出的每一 MB 对应300/1024分。计算公式写成long long over_mb mb - 1024; long long cost_fen (over_mb * 300 512) / 1024;这里的 512 就是 1024 的一半是整数除法实现四舍五入的标准技巧(a * b d/2) / d。原理是整数除法是截断如果余数小于 d/2加上 d/2 之后商不变如果余数大于等于 d/2加上 d/2 之后就会跨过下一个 d 的倍数商进一位。这正好对应看下一位是否达到半。但要提醒一句这个技巧严格来说只在除数为偶数时等价于标准四舍五入。1024 是偶数安全。如果哪天单价规则改成 1000 MB 一档除数是 1000同样偶数也没问题。但如果你碰到除数是奇数比如 3 MB 一档(a 1) / 3这个写法对恰好余 1的情况会进位对恰好余 2的情况也会进位实际效果不是四舍五入而是四舍五入偏上。这种细节在金额系统里就是账对不上的根源写的时候务必把除数看清楚。还有一个更危险的写法很多人下意识会这么干double cost (over_mb * 3.0) / 1024.0; long long cost_fen llround(cost * 100);看起来逻辑完全一样但这里叠了两层浮点乘除误差是累积的。对于大多数输入它能给出正确答案可一旦碰到恰好卡在 0.005 边界的值浮点表示误差可能让它舍到错误的一侧。这类错误极难复现、极难定位因为你手工算一遍是对的程序跑出来就差一分钱。金额计算里除法永远是最后一步而且最好只做一次整数除法——这是我踩过很多次坑之后给自己定的铁律。2.3 整数分为王为什么我坚持不碰 double顺着上面的话题往下说。这道题里幸亏只有三档计费浮点误差还能蒙混过关但如果你把它扩展成多套餐比较按月累计账单批量用户统计浮点误差就会开始显形。最经典的反例就是 0.1 0.2 不等于 0.3。你在代码里写0.1 0.2 0.3返回 false。金额场景下这意味着两笔 0.1 元的费用加在一起可能比 0.2 元多出一点点累加一百次之后账单上就会多出一个莫名其妙的分位偏差。我的处理方式是全流程整数化所有金额以分为单位存储和运算只在最终输出时把分拆成元 分两段打印。整道题的常量全部用long long定义const long long BASE_FEN 3800; const long long CALL_FREE_SEC 6000; const long long CALL_FEN_PER_MIN 15; const long long SMS_FREE 20; const long long SMS_FEN_PER 10; const long long DATA_FREE_MB 1024; const long long DATA_FEN_PER_GB 300;为什么是long long而不是int算一下就知道。输入流量上限是 10 的 9 次方 MB超出部分最大约 10 的 9 次方 MB乘以 300 分之后是 3 × 10 的 11 次方这个数已经远超int的 21 亿上限约 2.1 × 10 的 9 次方。用int会静默溢出得到一个负数或者一个完全不相干的小数字而且编译器在大多数默认配置下不会给你任何警告。这种 bug 在本地小数据上测不出来一提交大数据点就挂排查起来极其痛苦。顺带说个实操习惯定义这些常量的时候我一定把单位写进名字里。BASE_FEN、CALL_FEN_PER_MIN、DATA_FREE_MB一眼能看出这是分还是元、是秒还是分钟。我见过太多项目里叫price、cost、base的变量改了两周之后谁也说不清它到底是元还是分最后只能全文件搜出来逐个反推。名字多打几个字符省下的是未来几个小时。2.4 这套模型能搬到哪里去影响范围的一点分析讲完三个难点我想岔开说一句这套模型的价值。手机话费的分段计费结构几乎是所有资源型计费问题的母版。云主机的按量付费本质是每月前 N 小时免费、超出部分按时计费、不足 1 小时按 1 小时算——和通话那项一模一样。快递运费是首重 1 公斤 X 元、续重每公斤 Y 元、不足 1 公斤按 1 公斤算——和通话的向上取整完全同构。停车场的首小时 5 元、之后每半小时 3 元、不足半小时按半小时算还是同一个骨架。这意味着你在 0055 这道题上学到的东西不是怎么算话费而是怎么把一个带阶梯、带取整、带单位的现实规则安全地落到代码里。这个能力的使用频率比大多数算法模板都高。所以别把它当成一道水题刷完就丢把取整逻辑、单位换算、舍入位置这三件事想透了后面碰到计费类需求你能少走很多弯路。3. 完整实现从常量定义到样例验证3.1 结构体还是散装常量我为什么选后者先把代码骨架定下来。有人喜欢为这道题定义一个Bill结构体里面放月租、通话费、短信费、流量费、总计五个字段。对这道题来说我的判断是不需要理由是这道题的计算是单向的、一次性的三个计费函数各自独立没有任何状态需要在函数间传递。加一层结构体只会增加阅读时的跳跃成本——你得先看结构体定义再看每个函数怎么填它最后才看输出。真正需要结构体的时候是当你把这道题扩展成多套餐比较器第 5 章会做那时候每个套餐有自己的免费额度和单价参数需要成组传递结构体就变得必要了。该简单的时候别硬套模式这是我这些年最实在的一条经验。所以主程序就三块读入、累加、输出。#include cstdio const long long BASE_FEN 3800; const long long CALL_FREE_SEC 6000; const long long CALL_FEN_PER_MIN 15; const long long SMS_FREE 20; const long long SMS_FEN_PER 10; const long long DATA_FREE_MB 1024; const long long DATA_FEN_PER_GB 300; long long callCost(long long sec) { if (sec CALL_FREE_SEC) return 0; long long over sec - CALL_FREE_SEC; long long mins (over 59) / 60; return mins * CALL_FEN_PER_MIN; } long long smsCost(long long n) { if (n SMS_FREE) return 0; return (n - SMS_FREE) * SMS_FEN_PER; } long long dataCost(long long mb) { if (mb DATA_FREE_MB) return 0; long long over mb - DATA_FREE_MB; return (over * DATA_FEN_PER_GB 512) / 1024; }这三个函数有一个共同的特征开头都有一个套餐内直接返回 0的早退分支。这个分支不能省也不能写成超出部分为负数就自然算成负数然后总和里减掉因为一旦允许负数进入计算后面任何一个乘法都可能把负数放大成灾难。早退分支的作用是把免费区间和计费区间明确切开让后面的逻辑只处理非负的超出量。这是防御式编程的一个小习惯成本是两行代码收益是让函数在任何输入下都不会给出负的费用。3.2 主函数与输出格式把分拆成元输出这块有个小技巧值得单独讲。多数人的第一反应是printf(%.2f, total / 100.0)能过但它又引入了浮点。我的写法是全程整数int main() { long long sec, sms, mb; if (scanf(%lld %lld %lld, sec, sms, mb) ! 3) return 0; long long total BASE_FEN callCost(sec) smsCost(sms) dataCost(mb); printf(%lld.%02lld\n, total / 100, total % 100); return 0; }%02lld保证了分位永远两位哪怕是 5 分也会打成05不会出现38.5这种看着别扭的格式。这个小细节在判题系统里就是格式错误和通过的区别因为大多数题目对输出格式是严格逐字符比对的。另外注意scanf的返回值判断。我写了! 3就 return 0这是一种保守处理。有些判题平台的数据末尾带多余空行或者 EOF 之后还有内容加这层判断可以避免读到脏数据。当然对这道题来说不加也能过但我习惯加上——能一次写对的防御别留给调试。3.3 样例验算手算一遍比跑一遍更重要代码写完了别急着提交。我给自己定的规矩是至少手算三组数据覆盖全部在套餐内单项超出一丁点三项全超把每一步的中间结果写在纸上再和程序的输出对。第一组纯套餐内输入6000 20 1024。通话 6000 秒不超短信 20 条不超流量 1024 MB 不超。总计 3800 分输出38.00。第二组全都超一丁点输入6001 21 1025。通话超 1 秒(159)/60 1分钟15 分。短信超 1 条10 分。流量超 1 MB(1*300512)/1024 812/1024 0分——注意这里就是前面说的四舍五入生效1 MB 折算下来不到半分舍掉。总计 380015100 3825 分输出38.25。第三组大幅超出输入7200 45 2048。通话超 1200 秒(120059)/60 20分钟300 分。短信超 25 条250 分。流量超 1024 MB(1024*300512)/1024 307712/1024整数除法得 300 分。总计 3800300250300 4650 分输出46.50。三组算下来中间值我全部心算核对过一遍没有一个地方用到浮点也没有一个地方的取整是靠感觉定的。这个习惯看起来很笨但它能帮你抓住 80% 的低级错误尤其是取整方向搞反、单位漏乘 100 这类。3.4 对拍把我觉得对变成数据说对如果想要更稳可以再上一层对拍。写一个逻辑上明显正确、但性能很差的暴力版本用随机数据跑几千组比对两个程序的输出。对这道题来说暴力版可以直接用 Python 写逻辑写得更直白import math, random, subprocess def brute(sec, sms, mb): total 3800 if sec 6000: total math.ceil((sec - 6000) / 60) * 15 if sms 20: total (sms - 20) * 10 if mb 1024: total round((mb - 1024) * 300 / 1024) return total for _ in range(3000): sec random.randint(0, 10**6) sms random.randint(0, 10**3) mb random.randint(0, 10**6) exp brute(sec, sms, mb) out subprocess.run([./a.out], inputf{sec} {sms} {mb}, capture_outputTrue, textTrue).stdout.strip() got round(float(out) * 100) assert got exp, fmismatch {sec} {sms} {mb}: {got} vs {exp} print(all passed)Python 的round在 3.x 版本是银行家舍入.5 舍到偶数和 C 整数版的四舍五入在边界上会有分歧。所以我一般会在随机数据里刻意避开恰好落在 .5 上的输入或者干脆用math.floor(x 0.5)强制统一。这个坑我在别的项目里真实踩过——对拍跑了几百组发现全对换个随机种子就全错最后查出来是两边舍入规则不一致。对拍之前先确认两边对正确的定义是一回事。4. 提交之后的排查常见错误答案速查4.1 一张表覆盖大部分翻车场景我把这道题以及这类计费题里见过的高频错误整理成了下面这张表遇到错误答案可以先对着扫一遍比盲改效率高得多。现象最可能的原因定位方式小数据对、大数据错int溢出流量乘 300 之后越界把所有累加变量改成long long重新提交结果差 0.15 的整数倍通话取整方向反了用了向下取整检查是不是写成了over / 60结果差 0.01 到 0.03流量舍入位置错了先除后乘检查公式是不是over / 1024 * 300结果出现负数unsigned回绕或漏了套餐内的早退分支全文搜unsigned检查每个函数的if格式错误输出用了%.2f且数值含浮点误差改成%lld.%02lld整数拆分部分数据超时多组输入没有处理到 EOF 就退出检查是否用了while (scanf(...) 3)表里第五条值得单独说一下。%.2f本身是能用的问题出在如果你前面用 double 算过38.25在浮点里可能表示成38.249999999999996printf会正确地舍成38.25看起来没问题。但如果是38.255这种边界就可能舍成38.25而正确答案是38.26。整数拆分完全规避这一层代价只是多写一个%02lld。4.2 手工验算从输出倒推是哪一项错了当你发现答案不对但不知道错在哪一项时最快的定位方法是把四项单独打印出来看。在提交前临时加一行调试输出fprintf(stderr, base%lld call%lld sms%lld data%lld\n, BASE_FEN, callCost(sec), smsCost(sms), dataCost(mb));stderr不会被判题系统当成标准输出比对所以这行代码在本地调试时开着、提交前删掉就行。曾经有一次我卡了半小时就是因为流量项的公式里写的是over / 1024 * 300——先整除再乘导致超出 1023 MB 以内全部算成 0。打印一行马上就看出来了。先除后乘是整数运算里最容易犯的错误没有之一因为它在数学上看起来更自然但在整数世界里除法的截断会直接把小数部分永久抹掉后面的乘法再怎么乘也救不回来。还有一个小技巧拿第一组纯套餐内的数据跑一遍如果输出不是38.00说明问题在基础逻辑跟取整无关排查范围立刻缩小一半。这种用极端数据切分问题域的调试思路比一行行加打印快得多。4.3 三个容易被忽略的边界第一通话秒数恰好是 6000 的情况。sec 6000用的是小于等于如果写成 60006000 秒会被算成超出 0 秒然后取整逻辑给你 1 分钟多收 0.15 元。这个错误在小数据上极难发现因为绝大部分测试数据的通话时长都不会恰好卡在边界上。第二短信恰好 20 条。同样的道理和一字之差。第三流量恰好 1024 MB。这个更微妙因为 1024 是 2 的 10 次方很多随机数据生成器喜欢用 1024 的倍数撞上边界的概率反而更高。我一般会把每个阈值本身和阈值 ±1 都手测一遍一共九组数据两三分钟的事能挡住一大片问题。还有一点我想额外提醒如果题目是多组输入很多平台的题目会说明输入包含多组测试数据处理到文件结束那就得写成long long sec, sms, mb; while (scanf(%lld %lld %lld, sec, sms, mb) 3) { long long total BASE_FEN callCost(sec) smsCost(sms) dataCost(mb); printf(%lld.%02lld\n, total / 100, total % 100); }单组输入的写法在这种题上会直接答案错误因为只处理了第一组就退出了。这类问题在题干里往往只用一句话轻描淡写地带过特别容易被忽略。拿到题目先把输入格式那块读三遍这是我这些年养成的习惯。5. 再往深走一步把它做成多套餐比较器5.1 为什么值得扩展做到这里这道题已经能通过了。但我总觉得只做一个输入三个数、输出一个数的程序有点浪费这个模型。真实场景里用户面对的不是一个套餐而是三五个套餐摆在面前不知道选哪个。把这个需求加进来题目立刻从计算变成了比较而比较会逼着你处理一些单套餐版本里根本不会遇到的问题——比如参数怎么成组传递、比较时怎么处理并列、结果怎么格式化对齐。这套扩展做完你会发现原来那三个计费函数几乎原封不动就能复用这恰恰说明了把计费逻辑抽成独立函数的收益。如果当初把所有逻辑都塞在main里现在就得推倒重写。5.2 用结构体承载套餐参数先定义套餐结构体把月租、免费额度、单价全部打包struct Plan { const char* name; long long base_fen; long long call_free_sec; long long call_fen_per_min; long long sms_free; long long sms_fen_per; long long data_free_mb; long long data_fen_per_gb; };然后写一个统一的计费入口把原来三个函数改成接收套餐参数long long billOf(const Plan p, long long sec, long long sms, long long mb) { long long total p.base_fen; if (sec p.call_free_sec) total ((sec - p.call_free_sec 59) / 60) * p.call_fen_per_min; if (sms p.sms_free) total (sms - p.sms_free) * p.sms_fen_per; if (mb p.data_free_mb) { long long over mb - p.data_free_mb; total (over * p.data_fen_per_gb 512) / 1024; } return total; }注意这里我把三个早退分支化简成了if包裹效果一样但代码更紧凑。然后主函数就是遍历所有套餐、算费用、找最小值const Plan plans[] { {基础版, 3800, 6000, 15, 20, 10, 1024, 300}, {畅聊版, 5800, 12000, 10, 50, 10, 2048, 200}, {大流量版, 7800, 6000, 15, 20, 10, 8192, 150}, };枚举全部套餐之后输出每个套餐的费用并标出最省钱的那个。这里有个细节并列最低怎么处理。我的做法是保留第一个出现的最低价不额外报并列因为对用户来说同样便宜的情况下选哪个都行多输出一个并列反而增加理解成本。这种产品层面的决策在算法题里通常不会考但真做业务时会反复出现——技术上怎么实现是一回事怎么定义最好是另一回事。5.3 扩展之后暴露出来的新问题扩展版本跑起来之后我发现了两个单套餐版本里根本不会出现的坑。第一个是参数顺序错乱。结构体用聚合初始化七个数字连在一起{3800, 6000, 15, 20, 10, 1024, 300}你很难一眼看出哪个是哪个。我第一次写的时候就把sms_free和sms_fen_per的位置调换了导致免费 10 条、每条 20 分跑出来的账单一言难尽。解决办法是用 C20 的指定初始化{.name 基础版, .base_fen 3800, ...}或者在每个常量后面加行内注释。我选了后者兼容性更好。第二个是比较基准不统一。初始想法是直接比总价但用户的使用量是固定的比总价没问题如果你想做推荐就必须假设用户未来用量会变那就得画一张不同用量下的费用曲线。这时候你会发现两个套餐的费用曲线会有交点交点的位置决定了用量低于多少选 A、高于多少选 B。这个计算本身不难两个线性函数求交但它逼着你去想清楚推荐到底基于什么假设——这类思考在做真实产品时反而是最花时间的部分代码只占很小一块。// 求两个套餐费用相等时的通话分钟数其余两项固定 // billA baseA (m - freeA) * priceA // billB baseB (m - freeB) * priceB // 解出 m 即可注意处理 priceA priceB 的退化情况上面这段注释是我留在代码里的备忘。写的时候一定要先处理单价相同的退化情况否则除零会让程序直接崩掉。这种退化情况在数学上就是两条平行线永不相交程序里必须显式判断。5.4 从这道题带走的三个习惯写到这里这道题该讲的基本讲完了。我最后想留下的不是代码而是三个可以带到任何项目里的习惯。第一个习惯先定单位再写公式。拿到任何带金额、带时长、带容量的需求第一步永远是把所有量的单位列出来确认它们之间怎么换算然后决定内部用哪个最小单位分、秒、字节来存储。这一步花五分钟能省掉后面几小时的排查。我见过太多项目在开发中期才发现这个字段到底是元还是分没人说得清最后不得不全库改数据。第二个习惯整数运算除法最后做。只要能用整数表达就不要用浮点。实在要用也把除法压到最后一步并且只用一次。这条规则在金额、计数、比例计算里几乎无往不利。第三个习惯用边界数据切分问题域。出错的时候不要一行行加打印乱撞先想清楚哪一组极端输入能把问题范围缩小一半然后用那组数据跑一遍。这个方法比任何调试器技巧都通用因为它依赖的是你对问题结构的理解而不是对工具的熟练度。至于这道题本身我个人在实际操作中的体会是真正花时间的从来不是写那二十行代码而是把不足一分钟按一分钟计这句话反复咀嚼想明白它到底作用在哪一段区间上。想清楚了代码十分钟就写完了想不清楚改十遍也过不了。这类题目的价值也在这里——它用最小的篇幅把最容易被忽略的思维盲区摆到你面前。