第一次看到“UVa 139 Telephone Tangles”这道题时,我的第一反应是:这不就是一张表配一个号码,查一下、算一下费用吗?真正动手写才发现,题目叫做“Tangles”(缠结)不是没有原因的——前缀长度、区号长度、本地号码长度全都在变,查询串里还夹着分钟数,一个边界没处理到位,输出就整段垮掉。这篇文章我想把这题的完整拆解和一套能跑的 C++ 实现写出来,把我踩过的坑也一并倒给你。如果你正准备刷字符串处理相关的练习题,或者工作中要做电话路由、规则匹配这类事,这篇应该能让你少走几条弯路。
1. 题目在说什么:先把“电话缠结”的输入输出看清
1.1 第一部分是资费表,藏着一个容易被忽略的“0”
题目会给一堆资费规则,每一行描述一个电话区域。常见的规则格式是这样的:
+100 0 8 0.20 甲示范地 +101 12 5 0.35 乙示范区 000000第一段持续读到000000为止。000000是一个六位零的分隔符,它前面是规则表,它后面才是真正要计算的电话记录。
很多人拿到规则表,第一反应是把每行拆成“国家代码、区号、本地号码长度、费率、名称”五个字段,然后存好。这个方向没问题,但注意第二行里的那个0。它不代表真正的区号是零,而是表示“这个国家区域没有区号”,或者说“全国统一走一个前缀”。如果你把0当成普通区号存进数据表,后面查询时就会陷入一个小尴尬:这个国家的号码根本没有区号段,你却偏要给它匹配一个区号出来。
名称字段也要留意。规则表按空格分隔,意味着名称里不能出现空格,否则解析会乱。大多数题目保证这一点,但你不能想当然,代码里最好加一层防御性的检查,实在不行就用整行读取后再手动切割。
1.2 第二部分是待查电话,别忘了后面跟着分钟数
规则表结束之后,每行是一个“拨号串 + 空格 + 分钟数”。例如:
+10012345678 3 +1011209875 5 +999123456 2 #最后一个#表示查询输入结束。
拨号串可能带+前缀,也可能不带。分钟数是一个整数,费用最终要乘以它。这题的输出不会只有一个“是哪个地区”,还要算钱。单价是美元,题目要求的费用保留两位小数。
如果你只把输入读成“号码字符串”,容易漏掉后面的分钟数。我用cin >> dial >> minutes的方式直接读两个变量,省掉自己手动分割的麻烦,但前提是题目保证拨号串里没有空格。大多数情况下确实没有,这也是最常见的输入形式。
1.3 题目真正想考的不是查表,而是“匹配 + 边界判断”
资费表给你的信息是:前缀、区号、本地号码长度、单价、名称。待查电话给你的信息是:一个完整拨号串、分钟数。你需要做的,是把这个拨号串拆成“国家代码、区号、本地号码”三段,再去规则表中找对应的项。
拆的时候问题来了:
- 国家代码长度不是固定的,可能是 1 到 3 位;
- 区号长度也不是固定的,可能 0 到 4 位;
- 本地号码长度必须和规则表里的一致。
这三个参数互相影响,你不可能一眼看出某个号码的国家代码是从第几位开始、区号是从第几位结束。所以解题核心是“尝试”。把所有可能的国家代码长度和区号长度都试一遍,再用规则表去验证。这就是所谓的前缀匹配问题,也是这题最值得练习的地方。
2. 解题思路:为什么说“最长前缀匹配”是核心
2.1 暴力枚举到底能不能过
先评估最朴素的做法:拿到一个查询串,把它所有可能的前缀都切出来,去规则表里逐个比较。
假设查询串最长也就 15 位左右,国家代码取 1 到 3 位,区号取 0 到 4 位,组合数最多也就是 3 × 5 = 15 种。规则表可能有几千条,每次查询最多比较 15 × 规则数。如果查询数量也不大,暴力确实能跑。
但暴力解法有个隐患:每一条规则的本地号码长度不一样,你比较的时候不能只看前缀相等,还必须保证剩余的本地号码位数也一致。如果把“前缀匹配”和“长度匹配”分开做两次循环,代码容易写得又臭又长,还容易漏掉边界情况。我建议不要一上来就写双层暴力,先用一个小的结构体把规则组织好,再用映射表加速查询。
2.2 用哈希表选对 key,比字典树省心
我见过一些人上来就写字典树,因为一看到前缀匹配就条件反射想到 Trie。这题确实能用 Trie,但对大部分刷题场景来说,一个map<string, Rule>就能解决问题,而且代码更短、更好懂。
关键在于怎么设计 key。一个很直接的想法是:把“国家代码 + 区号”拼成一个字符串作为 key。比如国家代码是101,区号是12,key 就是"10112"。但这个方案有隐患:国家代码10、区号112拼出来也是"10112",两者会冲突。
解决冲突的办法是加一个分隔符,比如"101|12"。在查询的时候,我同样把候选的国家代码、候选区号拼接成"101|12",再去映射表里精确查找。这样既避免歧义,又不需要额外写复杂的比较逻辑,代码结构非常清晰。
2.3 把判断分成三步:拆、查、算
我习惯把一个查询串的处理流程拆成三步:
- 拆:从拨号串里去掉
+,得到纯数字串; - 查:枚举国家代码长度和区号长度,在映射表里找 key;
- 算:找到后验证本地号码长度,与表中的长度一致,才计算费用并输出。
有人会问,如果映射表里已经存了规则,为什么查到时还要再验证一次本地号码长度?因为 key 只代表“这个前缀属于某个区域”,但本地号码是否正好是这个区域规定的位数,属于另一层约束。比如规则表里规定本地号码是 5 位,但查询串在这个前缀下剩了 6 位,这可能是号码抄错了,也可能是拨错号,必须当作不可识别处理。验证这一步能过滤掉很多无效匹配,是这道题最容易丢分的地方。
下面这张表总结了各方案的取舍:
| 方案 | 查询复杂度 | 代码量 | 优点 | 缺点 |
|---|---|---|---|---|
| 暴力双层遍历 | O(规则数 × 查询长度) | 小 | 思路直接 | 规则多时偏慢,逻辑容易散 |
| 哈希表精确匹配 | O(查询长度) | 中 | 速度快,key 设计灵活 | 需要解决 key 冲突 |
| 字典树 | O(查询长度) | 较大 | 天然适合前缀匹配 | 本题杀鸡用牛刀,写起来费劲 |
3. 一个能跑通的完整实现
3.1 先定义规则结构体
我会用一个小结构体把规则表里的字段包起来,方便存进map:
#include <bits/stdc++.h> using namespace std; struct Rule { string area; // 区号,若为空表示该区域没有区号 int localLen; // 本地号码位数 double price; // 每分钟费用 string name; // 区域名称 }; map<string, Rule> table; // key = "国家代码|区号"这里的area我故意设计成字符串。为什么要用字符串而不是整数?因为区号可能存在前导零,比如02之类的写法,用整数读会把前导零丢掉,后面还原本地号码时会出问题。字符串保留原始形态,才能保证比如查询串里02和规则表里的02准确对上。
3.2 读取规则表,处理“0 区号”
读入逻辑如下:
void addRule(const string& rawCC, const string& rawAC, int len, double price, const string& name) { string cc = rawCC; if (!cc.empty() && cc[0] == '+') cc = cc.substr(1); string ac = rawAC; if (ac == "0") ac = ""; // 0 代表没有区号 string key = cc + "|" + ac; table[key] = {ac, len, price, name}; } void readTable() { string cc, ac, name; int len; double price; while (cin >> cc) { if (cc == "000000") break; cin >> ac >> len >> price >> name; addRule(cc, ac, len, price, name); } }这段代码有个细节:读规则时,cc、ac都是以空格分隔的独立字符串。如果题目格式里把+100和12放在同一行但中间有空格,上面的读法没问题;如果你担心名称里有空格,就必须改用getline整行读取,再按空格拆分。
3.3 处理待查电话,枚举国家代码和区号长度
接下来是核心。我会枚举国家代码长度ccLen从 1 到 3,枚举区号长度acLen从 0 到 4,分别构造 key,去表里查:
void process(const string& dial, int minutes) { string digits = dial; if (!digits.empty() && digits[0] == '+') { digits = digits.substr(1); } for (int ccLen = 1; ccLen <= 3 && ccLen <= (int)digits.size(); ccLen++) { string cc = digits.substr(0, ccLen); for (int acLen = 0; acLen <= 4 && acLen + ccLen <= (int)digits.size(); acLen++) { string ac = (acLen == 0 ? "" : digits.substr(ccLen, acLen)); string key = cc + "|" + ac; auto it = table.find(key); if (it == table.end()) continue; int localLen = (int)digits.size() - ccLen - acLen; if (localLen != it->second.localLen) continue; string localNum = digits.substr(ccLen + acLen); double cost = it->second.price * minutes; printf("%-12s %-5s %-10s %5d %10.2f\n", it->second.name.c_str(), (it->second.area.empty() ? "-" : it->second.area.c_str()), localNum.c_str(), minutes, cost); return; } } printf("未被识别\n"); }主函数只需要调用两个循环:
int main() { readTable(); string dial; int minutes; while (cin >> dial) { if (dial == "#") break; cin >> minutes; process(dial, minutes); } return 0; }4. 常见问题与调试实录
4.1 数字越界和精度问题,比想象中更恶心
费用计算看似只是单价 × 分钟数,但浮点数在计算机里不是精确的。比如 0.1 × 3 在二进制里可能得到一个类似 0.30000000000000004 的值,直接输出会变成 0.30 吗?用printf("%.2f", ...)通常能正确四舍五入,但如果后续还要做更多运算,或者输出格式要求更严格,建议把价格转成整数“分”来算。
比如把0.20读入后乘以 100,得到20,费用就变成20 * minutes分,最后输出时除以 100 并取余。这样既避开浮点误差,也让代码更稳。一个通用的小函数:
long long parseCents(const string& s) { int dot = s.find('.'); if (dot == string::npos) return stoll(s) * 100; long long dollars = stoll(s.substr(0, dot)); string frac = s.substr(dot + 1); if (frac.size() == 1) frac += "0"; if (frac.size() > 2) frac = frac.substr(0, 2); long long cents = stoll(frac); return dollars * 100 + cents; }题目里的价格一般不会超过两位小数,这个函数足够应付。
4.2 前导零别丢,拆号码要用字符串
我调试时最常踩的坑是“本地号码开头是 0”。比如本地号码09875,如果把拨号串转成整数再拆分,开头的0就消失了,最终输出和正确答案对不上。
所以全程都不要把拨号串转成整数,字符串怎么读进来,就怎么切。这也是结构体里area和localNum都保留字符串的原因。输入阶段多花一点心思,输出阶段就能少掉一堆头发。
4.3 查询串里没有 + 号时怎么办
一个严谨的实现不能假设所有查询都带+。不带+的号码,可能是国内电话,也可能是本地电话。在线题库的原意里,这通常意味着不需要走国际前缀匹配,直接按照“本地号码”处理,或者走另一套国内规则。
在示例代码里,我直接把这个情况放给后续枚举逻辑:如果+不存在,digits保持原样。但有一类坑特别隐蔽:查询串以0开头,比如000开头,它可能代表国内长途前缀,而不是普通数字。这种就属于“题目变体”,你需要根据原题描述调整枚举范围。我建议保留一个开关变量isInternational,按题目要求区分逻辑,而不是硬编码。
4.4 key 设计不好,误匹配会非常隐蔽
如果你用country + area直接拼接作 key,比如国家代码10、区号12和国家代码101、区号2,拼出来都是"1012",查询时极容易查到一个错误的规则,输出结果却看似合理。
加一个分隔符是最简单的修复,例如"10|12"和"101|2"。千万不要嫌多打一个字符麻烦,这个问题让你排查两小时都是轻的。
5. 把“电话缠结”的思路迁移到真实工程
5.1 这类问题的本质是“规则路由匹配”
很多人刷题只是为了过题,但我觉得像 Telephone Tangles 这种题特别适合迁移到真实项目里。想想看,手机归属地查询、银行卡号发卡行识别、快递单号路由,本质上都是同一类问题:根据前缀和长度,判断归属方,然后执行后续逻辑。
真实工程里,你会遇到更长前缀的匹配,可能规则表有几万条,这时就需要考虑字典树或更高效的前缀索引。但解题核心没变:先确定匹配的键,再验证辅助条件,最后执行结果。这种“两步走”的逻辑,比一上来就搞高深数据结构更能帮你理清业务。
5.2 用一组基准测试保护重构
我在给某项目做路由匹配重构时,特意从这类题里借了一个思路:先准备一张“黄金用例表”,把典型前缀、边界长度、无效号码都写进去,然后每次改代码都跑一遍。
刷题时也可以这么干。你不用真的搭测试框架,把题目给的样例抄下来,再自己造几个用例,比如“区号为空”“本地号码长度短一位”“长一位”“完全没匹配”“前导零号码”,然后手算一遍期望输出,再跑代码对照。这样能把隐藏 bug 提前揪出来,也可以让你在讨论题解时更有底气。
5.3 最后再分享一个做题习惯
我不太建议一上来就把输入输出格式背死,因为不同来源的题目描述偶尔会有细微差异。更稳妥的做法是:先把核心匹配函数写好,让输入输出成为可替换的外壳。我写这题时,process函数完全不关心数据是从文件来还是从标准输入来,只接收两个参数:拨号串和分钟数。这样即使格式微调,核心逻辑也能复用。
这道题给我最大的收获,不是学会了查哈希表,而是意识到“边界条件往往是业务规则本身”。电话号码这个场景里,位数、前缀、区号,每一个字段都是规则的一部分;放到现实里,版本号、订单编号、优惠券码,也都是一样。你真正要修炼的,是用代码准确表达规则的能力,而不是背模板的能力。