1. 先把题目拆开:这道题到底在考什么
1.1 一句话概括业务模型
这道题是给一份号码前缀费率表,再给一批通话起止时间,要求算出每通电话的费用。模型可以概括为:用最长前缀匹配把号码映射到计费方案,再把通话时间按自然日和日内时段拆成若干段,分别乘价格累加。任务涉及字符串匹配、日期计算和区间拆分三类技能。
它不需要复杂算法,但每一步都容易出现隐蔽错误。我见过不少实现能跑通样例,换一组跨天数据就偏差一大截。典型原因是天数差没算对、跨午夜那一段被漏掉或重复计算、单价用浮点累加导致尾数对不上。这篇把这个题从头到尾拆一遍,从数据结构选型到最终金额输出,给出可以直接照抄的实现,也讲讲怎么避免这些错误。
1.2 三个子问题与它们的耦合点
拆开看,这道题其实是三个子问题叠在一起。
第一个是号码到方案的映射。号码是一串数字,费率表里给的是若干前缀以及对应的价格方案。规则是“最长前缀优先”:一个号码能匹配到多个前缀时,取字符数最多的那个。比如费率表里有9、91、913三条规则,号码91312345678就应该匹配913,而不是9。原因在于运营商的号段是分级管理的,越长的前缀代表越精确的归属,价格也就越具体。
第二个是时间字符串到绝对秒的转换。输入给的通常是2024-03-15 23:40:00这种人类可读格式,而计算需要能相减的数值。转换过程涉及闰年、大小月、星期推算,用错函数会导致结果随机器时区漂移。
第三个是跨天和跨时段的区间切分。一通电话从 23:40 打到次日 00:25,中间跨过了午夜。午夜前后可能属于不同的价格区间,甚至可能落在周末和工作日的分界上,所以不能拿总时长乘一个单价。
三个子问题本身都不难,难的是耦合:切分出来的每一段,都要回头去问“这一天的这个时刻属于哪个价格”。只要其中一环出错,最终金额就会错,而且错得很隐蔽,因为数字看起来仍然“像那么回事”。
1.3 这套模型到底影响哪些场景
这套模型适用范围很广,不只是教材里的练习题。
电信运营商的离线话单计费是最直接的应用对象。真实系统里,被叫号码的前缀决定了结算价,主叫的套餐决定了折扣,通话跨越结算日时需要拆分到不同账期。云服务按量计费也使用了相似逻辑:不同区域、不同实例规格的单价不同,按秒或按小时累计,跨整点、跨自然日要做区间切分。停车场的分时收费、充电桩的峰谷电价、跨境业务的阶梯结算,本质都是同一套逻辑的不同变体。
所以这道题真正的价值在于:它把“区间切分 + 分段定价 + 精确取整”这三个在计费系统里反复出现的要素压缩到了很小的规模。掌握这套思路对于计费系统开发很有帮助。后面讲到的时间轴切分方法,换成任何“按时间段定价”的业务都能直接套用。
2. 最长前缀匹配:从暴力到字典树
2.1 为什么不能用全等匹配
很多人第一反应是拿号码去费率表里find一下,找不到就报错。这在实际业务里是不可行的。
费率表里的前缀是“号段”,不是完整号码。一个前缀913可能对应十万个具体号码,费率表不可能把每个号码都写一遍。而且前缀长度不固定,可能有 1 位、2 位、4 位混在一起。这就要求匹配是“前缀匹配”而非“完全匹配”。
另一个容易被忽略的点是默认兜底。业务上通常有一条“其余号码按标准价”的规则,可以用空前缀表示。匹配的时候如果所有前缀都没有命中,要能落到默认方案上。如果直接写if (find(number) == end)就报错退出,遇到陌生号段整批数据都会失败。
还有一个细节:号码里可能出现非数字字符,比如分隔符或者特殊的服务号标记。稳妥做法是遇到非数字就停止向下匹配,用已匹配到的结果,而不是把整个字符串当成数字序列去处理。
2.2 暴力枚举长度也能过,但确实不够规范
最朴素的正确做法是:从号码长度开始,往下枚举每一个前缀长度,用哈希表查一下这个子串在不在费率表里,第一个命中的就是最长匹配。
int bestPlan = defaultPlan; for (int len = number.size(); len >= 1; --len) { string prefix = number.substr(0, len); auto it = rateTable.find(prefix); if (it != rateTable.end()) { bestPlan = it->second; break; } }这段代码在题目规模下完全能过,实现成本也最低。它的复杂度是O(L^2),其中L是号码长度。因为每次substr都要复制一遍字符串,字符串哈希也要扫一遍整个子串。
当号码长度为 11 位时,最坏情况要做 11 次子串构造,每次最多 11 个字符,总共 121 次字符操作,感觉不明显。但如果数据量涨到百万级别的话单,这个常数就会显现出来。.substr()是堆分配,百万次调用足以让程序慢好几倍。我在早期项目里就吃过这个亏,一个看似无害的substr把整个批处理从 2 秒拖到了 15 秒。
所以如果这道题只要求正确,枚举长度是够的;如果还想顺手练一下工程思维,字典树是更自然的选择。
2.3 字典树的节点设计与内存估算
字典树(Trie)的思路很直观:把每个前缀当成一条从根出发的路径,每个节点记下“走到这里对应哪个方案”。匹配时沿着号码往下走,一路上记下最后一个有方案的节点,走到走不动为止,那个最后记下的就是最长匹配。
节点结构可以这样写:
struct TrieNode { int child[10]; // 数字 0-9 的子节点下标 int planId; // -1 表示这个节点不是任何前缀的终点 TrieNode() { for (int i = 0; i < 10; ++i) child[i] = -1; planId = -1; } };用数组存子节点比用哈希表快得多,因为号码字符集就是 0 到 9,固定 10 个槽位,一次下标访问就能定位,不需要哈希计算。代价是空间:每个节点固定占 40 字节左右(10 个 int 加一个 int,再加上对齐)。如果前缀总长度是一百万个字符,节点数最多一百万零一个,占用大约 40MB。这个量级可以接受,但如果内存被严格限制在 64MB 以内,就要考虑压缩。
压缩思路有两个。一是把child换成int child[10]的稀疏表示,比如每个节点存一个vector<pair<char,int>>,只在真的存在分支时才分配。二是干脆不用树,用排序数组加二分。具体选哪个要看数据特点:号码前缀高度集中在前几位时,树的实际分支很少,稀疏表示能省很多;前缀分布很分散时,稀疏表示反而更费。
还需要注意“同前缀重复定义”的情况。如果费率表里出现了两条完全相同的前缀,后插入的应该覆盖先插入的,还是报错?工程上我倾向于覆盖,同时在调试期打印一条警告日志,这样线上不会因为脏数据崩溃,排查时又能看到线索。题目里一般保证不重复,但写代码时留个心眼总没错。
2.4 有序容器加二分的替代方案
如果不想手写树,还有个折中方案:把所有前缀放进std::map<string, int>,匹配时用lower_bound找到第一个不小于当前号码的键,然后往前回退,检查这个键是不是号码的前缀。
auto it = rateTable.lower_bound(number); int bestPlan = defaultPlan; if (it != rateTable.end() && it->first == number) bestPlan = it->second; while (it != rateTable.begin()) { --it; if (number.compare(0, it->first.size(), it->first) == 0) { bestPlan = it->second; break; } }这个写法的正确性依赖一个性质:按字典序排好之后,所有是某个字符串前缀的键,一定紧挨在它前面。所以从lower_bound往回找,第一个满足前缀关系的键就是最长前缀。这个性质我在纸上推过几次,确认无误,但第一次写的时候还是容易把边界条件写错,尤其是it == begin()那一下。
它的复杂度是O(log N * L),比暴力枚举好,比字典树差一点,但代码量小,可读性高。如果面试或者作业里不想写一大段树结构,这是个很实用的选择。
2.5 三种方案对比与选型建议
| 方案 | 时间复杂度 | 额外空间 | 代码量 | 适用场景 |
|---|---|---|---|---|
| 枚举长度加哈希 | O(L²) | O(前缀总数) | 最少 | 数据量小、追求开发速度 |
| 有序 map 加二分 | O(L·logN) | O(前缀总数) | 中等 | 前缀长、总量中等 |
| 字典树 | O(L) | O(前缀总字符数) | 偏多 | 数据量大、需要极致查询性能 |
我的建议是:先写能过的版本,再考虑优化。绝大多数教学题目和中小规模业务,枚举长度就够了,不要为了“看起来专业”把代码写复杂,最后调试时间比省下来的运行时间还多。真要上量,字典树的收益才明显,而且要注意节点分配方式,用vector一次性预留容量比逐个new快很多。
3. 跨天计费:把时间轴切成互不重叠的原子段
3.1 为什么“总秒数乘单价”一定会错
假设一通电话从周五 23:40 打到周六 00:25,总时长 2730 秒。如果直接乘一个单价,比如每分钟 0.2 元,结果是 9.1 元。但真实价格可能不是这样:周五夜间的价格是 0.15,周六凌晨的价格是 0.12,两段应该分开算。
更隐蔽的情况是跨过“工作日与周末”的分界。周五 23:40 属于工作日夜间,周六 00:00 之后就属于周末夜间,哪怕只差一秒,价格档位也已经变了。如果不做切分,这类账单一定会在对账时被业务方挑出来。
所以正确的做法是唯一一条:把通话时间轴切成若干段,保证每一段内的“计费属性”完全一致,再逐段计算。这里的计费属性包括日期(决定工作日还是周末)和日内时刻(决定哪个价格区间)。
3.2 时间表示:为什么不推荐 time_t
初学者常常用tm结构体加mktime,理由是“标准库里有现成的”。这条路在跨时区、跨夏令时的环境下会出现问题。
mktime依赖系统本地时区设置,同一份代码在不同机器上跑出来的秒数可能不一样。夏令时切换的那两个小时,某些时刻甚至“不存在”或者“出现两次”,mktime对这种情况的处理是实现定义的。我见过一个服务在切环境后整个账单偏移一小时,排查时发现是容器时区不是 UTC。这类问题如果换到线上环境才暴露,代价很大。
稳妥做法是用纯算术把“年月日时分秒”转换成“自某个基准日期起的天数”,再换算成秒。这里推荐 Howard Hinnant 提出的days_from_civil算法,它的做法是把公历的闰年规则化简成纯整数运算,完全不依赖任何库,也不关心时区。
long long daysFromCivil(int y, int m, int d) { y -= (m <= 2); const int era = (y >= 0 ? y : y - 399) / 400; const unsigned yoe = (unsigned)(y - era * 400); // [0, 399] const unsigned doy = (153u * (m + (m > 2 ? -3 : 9)) + 2u) / 5u + d - 1; const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; return (long long)era * 146097LL + (long long)doe - 719468LL; }函数返回的是距离 1970-01-01 的天数,可以是负数,覆盖 1970 年之前。有了天数,绝对秒就是days * 86400 + h * 3600 + min * 60 + sec,两个时刻相减直接得到秒差,不用管闰秒,也不管夏令时。
顺带还能算出星期几。1970-01-01 是星期四,所以如果约定 0 表示周日、1 表示周一,那么dow = (days % 7 + 7) % 7 + 4再对 7 取模就得到了。这个公式我写成一行放在代码里,注释写清楚,避免以后再推算一遍。
3.3 按自然日切分的第一层拆解
得到绝对秒之后,第一层切分是按自然日边界切开。
思路:当前时刻cur,算出它所在那一天的零点dayStart = floor(cur / 86400) * 86400,这一天的结束就是dayStart + 86400。取min(通话结束, 当天结束)作为这一段的终点,然后把cur推进到这一段终点,继续下一轮。
这里的关键是floor必须是真正的向下取整,而不是 C++ 整数除法的向零取整。虽然实际业务里的日期都是 1970 年之后,不会出现负数,但写成通用的向下取整函数更保险,也方便以后复用到存在负偏移的场景。
long long floorDiv(long long a, long long b) { long long q = a / b, r = a % b; if (r != 0 && ((r < 0) != (b < 0))) --q; return q; }切分循环写成:
long long cur = startSec; while (cur < endSec) { long long dayStart = floorDiv(cur, 86400) * 86400; long long segEnd = std::min(endSec, dayStart + 86400); processSegment(cur, segEnd, dayStart); cur = segEnd; }这段循环的边界条件值得单独说。cur < endSec用的是严格小于,因为区间是左闭右开[start, end),结束时刻本身不属于这段通话。如果写成<=,最后一秒会被重复计费,而且跨天时整好落在零点的通话会被多算一整个空段。
另外一个常被忽略的点:通话时长为 0 怎么办。如果开始等于结束,上面这个循环一次都不会进,费用自然是 0。但业务上通常规定“不足一分钟按一分钟计”,0 秒通话到底收不收这 1 分钟,需要在题面里找依据。我的处理是:如果题面没说,就按“0 秒不收钱”实现,同时在注释里标注这个假设,方便以后改。
3.4 按日内时段切分的第二层拆解
第一层切完之后,每一段都落在同一天内。第二层是把这一段再按当天的价格区间切开。
价格区间通常定义为若干条互不重叠、并且覆盖全天的规则,比如[00:00:00, 08:00:00)是夜间价,[08:00:00, 22:00:00)白天价,[22:00:00, 24:00:00)又是夜间价。规则覆盖全天这个前提很重要,如果不覆盖,落在空隙里的通话就没有价格,还得再加一层兜底逻辑,反而更复杂。
切分方法是取交集。当前段在当天的偏移是a = segStart - dayStart到b = segEnd - dayStart,对每条规则[rs, re),交集是[max(a, rs), min(b, re)),长度为正就说明有重叠。
long long a = segStart - dayStart; long long b = segEnd - dayStart; for (const auto& rule : rulesOfDay) { long long lo = std::max(a, (long long)rule.startSec); long long hi = std::min(b, (long long)rule.endSec); if (lo < hi) { long long secs = hi - lo; totalFee += secs * rule.priceFen * 1000LL / 60; } }用交集而不是用if判断“这段中心落在哪个区间”,是因为一通短电话恰好压在两段交界处时,两边都要算。用中心点判断会整体归到一边,边界上的金额就会有误差,而且误差大小取决于通话长度,很难在测试里发现。
rulesOfDay的取法依赖这一天的星期几:周六周日走周末表,周一到周五走工作日表。这个判断放在第一层切分之后做,因为同一个dayStart算出来的星期几,对它下面所有子段都适用,不必重复计算。
3.5 取整标准:秒、分钟、分段取整的三笔账
金额的取整方式会直接影响结果,而且不同题目、不同业务给定的规则可能不一样,必须看清楚。
第一种是按秒精确累加,最后一次性取整。每段费用secs * priceFen / 60,所有段加起来之后,再对总额四舍五入到分。这种算法最接近真实金额,误差只来自最后一步。
第二种是每段单独向上取整到分钟。一段 61 秒算 2 分钟,两段各 61 秒就是 4 分钟,而整通 122 秒按总量算只需要 3 分钟。分段取整会让客户多付钱,业务上通常不接受,除非规则明确写了“每段独立计费”。
第三种是整通电话按总秒数向上取整到分钟。这类做法在单一时段下很常见,但跨时段之后就没法用了,因为你不知道这个“总分钟数”该乘哪个单价。
我一般采取的做法是:按秒累计,用整数微单位存储,最后统一取整。具体实现里,我把费用单位设为“千分之一分”,即 1 元等于 100000 个微单位。这样secs * priceFen * 1000 / 60全程是整数运算,不会有浮点误差,也不会有中间结果被提前截断的问题。最终输出时再决定是四舍五入到分还是保留更多位。
long long microFen = 0; // 单位:千分之一分 microFen += secs * rule.priceFen * 1000LL / 60; // 输出前四舍五入到分 long long fen = (microFen + 500) / 1000;浮点累加的隐患在于大数量级下的尾数丢失。一通电话的误差可以忽略,但十万通话单累加起来,分位上的偏差会被业务对账放大成明显的差额。用整数存微单位,是我踩过几次“差一分钱”的坑之后固定下来的习惯。
4. 完整实现:从读入到出账
4.1 数据结构与全局约定
先把约定写清楚,后面所有代码都依赖这几个约定。
- 时间统一用
long long存“自 1970-01-01 00:00:00 起的秒数”。 - 费用统一用
long long存“千分之一分”。 - 价格表按“方案”组织,一个方案包含工作日时段表和周休日时段表。
- 每条时段规则是左闭右开区间,同一天内所有规则必须覆盖
[0, 86400)且互不重叠。 - 号码匹配结果用方案下标表示,
-1表示没有匹配到任何方案。
struct RateRule { int startSec; int endSec; long long priceFen; // 分/分钟 }; struct Plan { std::vector<RateRule> weekday; std::vector<RateRule> weekend; }; struct CallRecord { std::string caller; std::string callee; long long startSec; long long endSec; };这些结构都很轻,Plan里只放两个vector,拷贝成本也不高。如果方案数量很多,可以把Plan放进vector<Plan>,用下标引用,避免字符串键查找。
4.2 Trie 的插入与最长匹配
插入时逐个字符往下走,走到末尾把planId写在这个节点上。匹配时边走边记最后一个非-1的节点。
std::vector<TrieNode> trie; void trieInsert(const std::string& prefix, int planId) { int cur = 0; for (char ch : prefix) { int d = ch - '0'; if (trie[cur].child[d] == -1) { trie[cur].child[d] = (int)trie.size(); trie.emplace_back(); } cur = trie[cur].child[d]; } trie[cur].planId = planId; } int trieMatch(const std::string& number) { int cur = 0; int best = trie[0].planId; // 空前缀作为兜底方案 for (char ch : number) { int d = ch - '0'; if (d < 0 || d > 9) break; // 非数字直接停止 if (trie[cur].child[d] == -1) break; cur = trie[cur].child[d]; if (trie[cur].planId != -1) best = trie[cur].planId; } return best; }两个函数里都有同一个细节值得说:best的初值取根节点的planId。这样即使一个前缀都没匹配上,也能落到默认方案。很多实现忘了这一步,遇到陌生号码直接返回 -1,后面取方案表就数组越界了。
另一个细节是if (d < 0 || d > 9) break;。它保证遇到非数字字符时立刻停止,而不是继续往下算出一个错误的子节点下标。输入数据里如果混进了-或者空格,这一行可以让程序不给错误结果。
4.3 时间解析与日期运算
日期解析用sscanf按格式拆,比手工找分隔符稳,也更好读。
long long parseDateTime(const std::string& s) { int y = 0, mo = 0, d = 0, h = 0, mi = 0, se = 0; std::sscanf(s.c_str(), "%d-%d-%d %d:%d:%d", &y, &mo, &d, &h, &mi, &se); return daysFromCivil(y, mo, d) * 86400LL + h * 3600LL + mi * 60LL + se; }如果输入可能是2024/03/15这种格式,可以先用std::replace统一成短横线再解析。我建议把格式统一放在读入层,后面的计算层只认“绝对秒”,不要在计算函数里再做字符串判断,那样会把逻辑搞乱。
星期推算单独封装:
int dayOfWeek(long long epochSec) { long long days = floorDiv(epochSec, 86400); long long dow = ((days % 7) + 7) % 7 + 4; // 1970-01-01 是周四 return (int)(dow % 7); // 0=周日, 6=周六 }这行公式第一次写很容易错,验证方法很简单:拿1970-01-01代进去,应该输出 4(周四);拿2024-03-15代进去,应该输出 5(周五)。两个都对上之后,公式就可以放心用了。
4.4 切分与计费主循环
把前面几块拼起来,计费函数长这样:
long long calcFee(long long startSec, long long endSec, const Plan& plan) { long long total = 0; long long cur = startSec; while (cur < endSec) { long long dayStart = floorDiv(cur, 86400) * 86400; long long segEnd = std::min(endSec, dayStart + 86400); int dow = dayOfWeek(dayStart); const std::vector<RateRule>& rules = (dow == 0 || dow == 6) ? plan.weekend : plan.weekday; long long a = cur - dayStart; long long b = segEnd - dayStart; for (const auto& r : rules) { long long lo = std::max(a, (long long)r.startSec); long long hi = std::min(b, (long long)r.endSec); if (lo < hi) { long long secs = hi - lo; total += secs * r.priceFen * 1000LL / 60; } } cur = segEnd; } return total; }这段代码把两层切分合并进了一个循环:外层按天推进,内层按时段取交集。这样比分两个函数调用来得紧凑,也少一次中间存储。调试时如果想看每段明细,可以在内层if里加一行打印,用完再删。
需要注意rules用的是引用,不能写成值拷贝,否则每处理一通电话都要复制一遍时段表,数据量上来时这个开销很可观。
4.5 一组手算校验用例
验证代码是否正确,光靠样例不够,得自己手算几组有代表性的数据对。
先设两套价格。工作日:夜间[00:00, 08:00)和[22:00, 24:00)是 15 分/分钟,白天[08:00, 22:00)是 30 分/分钟。周休日:夜间 12 分/分钟,白天 24 分/分钟。
用例一:跨午夜,且跨越工作日与周休日。
通话2024-03-15 23:40:00到2024-03-16 00:25:30。2024-03-15 是周五,2024-03-16 是周六。
| 段 | 日期 | 时段 | 秒数 | 单价(分/分) | 费用(元) |
|---|---|---|---|---|---|
| 1 | 03-15 周五 | 23:40:00 - 24:00:00 | 1200 | 15 | 3.00 |
| 2 | 03-16 周六 | 00:00:00 - 00:25:30 | 1530 | 12 | 3.06 |
合计 6.06 元。如果实现里漏掉第一天那 1200 秒,或者用周五的价格算第二天,金额都会对不上。
用例二:跨多个自然日。
通话2024-03-15 22:00:00到2024-03-17 02:30:00。逐日拆开:15 日 7200 秒,16 日一整天 86400 秒,17 日 9000 秒。
| 日期 | 时段 | 秒数 | 单价(分/分) | 费用(元) |
|---|---|---|---|---|
| 03-15 周五 | 22:00 - 24:00 夜间 | 7200 | 15 | 18.00 |
| 03-16 周六 | 00:00 - 08:00 夜间 | 28800 | 12 | 57.60 |
| 03-16 周六 | 08:00 - 22:00 白天 | 50400 | 24 | 201.60 |
| 03-16 周六 | 22:00 - 24:00 夜间 | 7200 | 12 | 14.40 |
| 03-17 周日 | 00:00 - 02:30 夜间 | 9000 | 12 | 18.00 |
合计 309.60 元。这个用例能同时验证跨天循环、周休日切换和日内多时段切分,是我每次改完代码必跑的一条。
用例三:卡在交界点上的短通话。
2024-03-16 07:59:30到2024-03-16 08:00:30,共 60 秒,正好横跨夜间与白天的分界。正确结果是前 30 秒按 12 分/分钟算 6 分,后 30 秒按 24 分/分钟算 12 分,合计 18 分。如果用“取中点判断归属”的写法,整段都会按一边算,结果要么 12 分要么 24 分,和正确答案都不一样。这条用例专门用来发现边界处理的粗糙实现。
4.6 输出格式与精度处理
内部用微单位算完之后,输出层要转成“元”并保证两位小数。整数转法是:
long long yuan = total / 100000; long long fen = (total % 100000) / 1000; long long milli = total % 1000; if (milli >= 500) { // 四舍五入到分 fen += 1; } if (fen >= 100) { fen -= 100; yuan += 1; } std::printf("%lld.%02lld\n", yuan, fen);这里的进位要连着处理两次:微单位进到分,分满了再进到元。我第一次写的时候只处理了第一次进位,结果0.995输出成了0.100,看起来像少了一位。
如果题目要求“不足一分向上取整”,那就把if (milli >= 500)改成if (milli > 0)。这两种规则在不同业务里都真实存在,实现之前一定要确认清楚,猜错就是系统性偏差。
5. 踩坑实录与排查速查表
5.1 边界问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 跨天通话少算第一天 | 循环用了cur <= endSec之外的写法,或漏了第一段 | 打印每段起止秒,核对段数 |
| 零点那一秒被算两次 | 区间用了闭区间 | 统一改成左闭右开 |
| 金额差几分钱 | 中间结果用浮点或过早截断 | 改成整数微单位累加 |
| 周休日价格没生效 | 星期几算错,或dow取值约定不一致 | 用1970-01-01验证公式 |
| 陌生号码数组越界 | 匹配失败返回 -1 未兜底 | 根节点挂默认方案 |
| 长通话耗时骤增 | 循环内做了字符串操作或拷贝 | 把解析提到循环外 |
| 2 月 29 日算错 | 用365硬算天数 | 换days_from_civil |
| 非数字字符导致结果异常 | 匹配未中断 | 遇到非数字立即 break |
5.2 几个我确实遇到过的坑
第一个坑是“时间差直接用两个tm结构减”。早期写这个题时,我把年月日时分秒分别相减再折算,闰年和大小月全乱套。后来改成先转绝对秒再减,问题才彻底消失。凡是涉及日期差的地方,都不要逐字段相减。
第二个坑是“把时段规则写成闭区间”。规则写[08:00, 22:00],切分又用左闭右开,结果 22:00 整那一刻既属于白天又属于夜间。金额上不会差多少,但边界用例一定挂。现在的习惯是:只要涉及时间区间,一律左闭右开,写注释标清楚。
第三个坑是“周休日判断依赖了外部格式化函数”。有次用某个日期库取星期,本地跑出来是对的,放到容器里跑偏了一天,原因是那个函数的周起始约定不一样。后来换成自己算,几行代码,不再有这种不确定性。依赖越少,行为越可控,这在计费类代码里尤其重要。
第四个坑是“前缀表里出现一条空串规则”。空串被当成普通前缀插入,结果匹配时永远命中它,所有号码都按默认价算。解决办法是在读入阶段单独处理空串,或者明确禁止空前缀,用配置项表达“默认方案”。这类数据问题不在代码逻辑里,只能靠读入校验挡住。
第五个坑是“费用累加用了int”。单笔金额小的时候没问题,跑批量汇总时溢出,结果变成负数。计费相关的累加变量一律用long long,并且单位选得比最终输出更细,这是最省心的做法。
5.3 调试与对拍技巧
这个题的正确性验证,我一般分三步走。
第一步是打印中间段。在切分循环里输出每一段的起止时刻、所属日期、命中的价格区间和秒数。肉眼扫一遍,能立刻发现“段数不对”“日期跳了”“价格取了错的行”这类问题。这比盯着最终金额猜要快得多。
第二步是构造极端输入。包括:零秒通话、整点开始整点结束、跨年、闰年 2 月 29 日、刚好卡在时段分界、跨三个自然日以上。这些输入不追求真实,只追求把分支走全。
第三步是写一个暴力版本对拍。暴力版本的思路是“每秒单独算一次”:从开始到结束,逐秒判断这一秒属于哪天、哪个时段,累加对应单价。它慢得离谱,但正确性几乎不可能写错。用小规模随机数据生成器跑几千组,和主实现比对结果,能揪出绝大多数边界问题。我到现在还保留着这个习惯,尤其是改动过切分逻辑之后。
随机数据生成器也要注意覆盖跨天。我一般让开始时间在一天内随机,时长在 0 到 48 小时之间随机,这样跨天、跨多天、跨周休日的情况都能自然出现。
6. 一些延伸想法
这道题做完之后,其实还能往下走几步,而且每一步都能遇到新的真实问题。
往上加阶梯定价:同一时段内,前 100 分钟一个价,之后另一个价。这需要在切分的基础上再加一层“额度消耗”状态,切分段的顺序就不能随便调了,必须严格按时间先后处理。
往上加多号码合并账单:同一个账户下多个号码,要按月汇总,还要处理跨月通话的归属问题。这时候“按自然日切分”就要升级成“按账期切分”,账期边界不一定是零点,可能是每月 1 日凌晨某点,切分逻辑要参数化。
往上加反向追溯:给定一笔总金额,反推通话明细是否合理。这类校验在真实对账里很常见,考的是同一套模型的逆运算。
我个人在实际写这类计费代码时的体会是:真正容易出错的不是算法,而是约定。区间是开是闭、取整是四舍五入还是向上、星期几从 0 开始还是从 1 开始、费用单位是分还是微单位,这些约定只要有一处没写清楚,代码写完自己都记不住。所以我现在写任何计费模块,第一件事是在文件开头用一段注释把所有约定列出来,函数签名和变量名也跟着约定走。看起来是笨办法,但省下来的返工时间比写注释多得多。