1. 先把题面翻译成表关系:一通电话到底算给哪个国家
LeetCode 1501 这道题,我在带人刷 SQL 的时候经常拿来当“JOIN 之后到底会变成几行”的教学案例。它表面上是让找满足条件的国家,实际上考的是你能否把一个业务描述准确翻译成多表关联,然后再用聚合函数做一次组间比较。题目本身不算长,但很多人会卡在这种地方:Person 表里有国家区号,Country 表里有国家码,Calls 表里只有用户 id,那这个“国家”到底要怎么从用户表里带出来?
先把表结构理一遍。
sql复制Person(
id INT,
name VARCHAR,
phone_number VARCHAR
)
Country(
name VARCHAR,
country_code VARCHAR
)
Calls(
caller_id INT,
callee_id INT,
duration INT
)
Country 表里的 country_code 是国家区号。Person 表里的 phone_number 是带区号的完整电话号码,例如 52-55-1234-5678,前面那一段就对应墨西哥的 52。Calls 表记录了一次通话请求,caller_id 是主叫方用户,callee_id 是被叫方用户,duration 是通话秒数。
为了后面方便验证,我给出一组精简但能说明问题的数据。
| Person.id | Person.name | Person.phone_number |
|---|---|---|
| 1 | Alice | 1-312-555-0100 |
| 2 | Bob | 52-55-1234-5678 |
| 3 | Carol | 44-20-7946-0958 |
| Country.name | Country.country_code |
|---|---|
| USA | 1 |
| Mexico | 52 |
| UK | 44 |
| Calls.caller_id | Calls.callee_id | Calls.duration |
|---|---|---|
| 1 | 2 | 100 |
| 2 | 3 | 20 |
题目要求的“可以放心投资的国家”,翻译成 SQL 逻辑就是:
找到所有国家,这些国家用户参与的通话时长平均值,要严格大于全表所有通话时长的平均值。
注意这里不是说单次通话大于全局平均,而是要按国家分组之后,再对组内通话时长取平均。
在这个例子里,全球平均通话时长是 (100 + 20) / 2 = 60。
美国的 Alice 只参与了第一通电话,通话时长 100,所以美国平均时长是 100。墨西哥的 Bob 第一通电话作为被叫花了 100 秒,第二通电话作为主叫花了 20 秒,平均时长是 (100 + 20) / 2 = 60。英国的 Carol 只参与了第二通电话,平均时长是 20。
严格大于全球平均 60 的,只有美国,所以结果应该是:
| country |
|---|
| USA |
这道题需要的不是复杂的窗口函数,也不是递归查询,而是一步步把“国家标签”挂到每次通话上。链条是:Calls 通过用户 id 找到 Person,Person 再通过 phone_number 前面的区号找到 Country。只要链路上每一步的 JOIN 条件写对,后面的 GROUP BY 和 HAVING 就是常规操作。
之所以说它是 Medium 难度而不是 Easy,是因为中间有一个特别容易丢数据的地方:Calls 里的一个用户,既可能是主叫,也可能是被叫。如果只按 caller_id 关联,英国 Carol 那 20 秒就永远进不了任何国家的平均值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 写 SQL 之前,这三处口径最好先对清楚
这题真正考验人的不是 SQL 语法,而是三个口径问题。口径一旦想错,代码写得再漂亮也不会对。
2.1 国家码不是固定三位,所以别用 LEFT 截断
很多人拿到电话字段,第一反应是用 LEFT(phone_number, 3) 去和 country_code 比较,因为 LeetCode 有些题解里出现过这么写。这个写法在特定构造的数据里能过,但它默认了一个在现实里完全不成立的前提:所有国家区号都是三位。
国家区号长度不是统一的,美国的区号是 1,墨西哥是 52,英国是 44,马尔代夫是 960。如果你用 LEFT(phone_number, 3),取出来的可能是 1-3、52-、44-,这些值不可能和 country_code 字段匹配得上。就算你的数据集里碰巧把区号统一成了三位,比如把美国写成 001,这种写法也只是“看起来对”,换一批正常数据就露馅。
正确做法是取第一个短横线之前的部分。MySQL 里用 SUBSTRING_INDEX(phone_number, '-', 1),PostgreSQL 里可以用 SPLIT_PART(phone_number, '-', 1),SQL Server 里可以用 LEFT(phone_number, CHARINDEX('-', phone_number) - 1)。
用分隔符去解析字段,是一种基本的数据清洗素养。LeetCode 环境的用例可能不会为难你,但你在面试里写出 LEFT 截断时,面试官通常会追问一句:“如果国家码是 1 位,你还这么截吗?”
2.2 通话记录的两端都要进入统计,缺一个就少一个国家视角
我之前见过不少人写这道题,第一版长这样:
sql复制SELECT c.name AS country
FROM Person p
JOIN Country c ON SUBSTRING_INDEX(p.phone_number, '-', 1) = c.country_code
JOIN Calls cl ON cl.caller_id = p.id
GROUP BY c.name
这个查询只统计了“作为主叫用户”的通话时长。对于被叫用户所在的国家来说,只要它的人从来没有主动打出过电话,这个国家就直接从结果里消失了。正确逻辑是:只要这个国家的人参与了一通电话,不管他是主叫还是被叫,通话时长都应该被计入该国家的平均时长。
所以 JOIN 条件必须同时覆盖主叫和被叫:
sql复制JOIN Calls cl ON p.id = cl.caller_id OR p.id = cl.callee_id
在 MySQL 里这么写是正确的,但请你养成一个习惯:如果你看到 ON 条件里出现 OR,多想想能不能用两次 JOIN 配合 UNION ALL 把它拆开。不是因为 OR 本身错,而是因为它会让执行计划更复杂,索引利用率也不稳定。下面第 4 部分我会给一个更清晰的替代写法。
2.3 全球平均时长只在比较时用一次,别放到 WHERE 里
这道题要求每个国家的平均通话时长“大于全球平均”。很多人会自然而然地想先算一个全局平均,再拿到每个国家分组里面去比较。但在 SQL 的语法结构里,WHERE 是在分组之前执行的,此时还没有 AVG(cl.duration) 可用。如果你写:
sql复制WHERE AVG(cl.duration) > (SELECT AVG(duration) FROM Calls)
数据库会直接报错,因为聚合函数不能出现在 WHERE 子句里。
正确的过滤时机是在 GROUP BY 之后,用 HAVING 判断分组结果。这也是这类题的标准结构:子查询算出一个全局标量,然后 HAVING 拿到这个标量做比较。
sql复制HAVING AVG(cl.duration) > (SELECT AVG(duration) FROM Calls)
这里的子查询没有依赖外层查询的字段,是一个不相关的标量子查询。MySQL 优化器通常会在执行早期把它算出来,然后反复使用,你不需要担心它会对每一组国家都重新扫描一次 Calls 表。
说起扫描,顺便提一个很多人忽略的点:Calls 表可能非常长。如果这是真实业务数据,而不是 LeetCode 的测试表,全表 AVG(duration) 在有索引时也未必能走索引,因为要对整列做聚合。好在这道题的数据量不大,只需要理解逻辑即可。真到了生产环境,还需要考虑慢 SQL 优化,比如对 duration 做汇总表或物化视图。
3. 直接可跑的版本:从 Person 出发把通话拆到国家维度
现在我们把口径统一成三句话:
- 把 Person 按电话号码区号关联到 Country,得到每个用户的国家。
- 把 Calls 同时关联到主叫和被叫用户,保证每个通话参与者都能出现在明细里。
- 按国家分组,取平均通话时长,和全表平均时长做比较。
这个思路写出来就是:
sql复制SELECT
c.name AS country
FROM Person p
JOIN Country c
ON SUBSTRING_INDEX(p.phone_number, '-', 1) = c.country_code
JOIN Calls cl
ON p.id = cl.caller_id OR p.id = cl.callee_id
GROUP BY c.name
HAVING AVG(cl.duration) > (SELECT AVG(duration) FROM Calls);
我拿上面那组数据在这个查询上跑一遍,手动拆一下中间过程。
首先,Person JOIN Country 之后,Alice 变成“美国”,Bob 变成“墨西哥”,Carol 变成“英国”。
接着,每张 Person 表记录去 JOIN Calls。JOIN 条件因为包含 OR,所以可能出现一张 Person 记录对应多条 Calls 记录的情况。
Alice 在 Calls 表里只对应第一个呼叫的 caller_id,所以她带出一行 duration = 100,国家是 USA。Bob 在 Calls 表里第一次出现是第一个呼叫的 callee_id,第二次出现是第二个呼叫的 caller_id,所以他带出 100 和 20 两行,国家都是 Mexico。Carol 在 Calls 表里对应第二个呼叫的 callee_id,带出一行 duration = 20,国家是 UK。
所以 GROUP BY 之前,明细大概是:
| country | duration |
|---|---|
| USA | 100 |
| Mexico | 100 |
| Mexico | 20 |
| UK | 20 |
分组后:
- USA 平均时长 = 100
- Mexico 平均时长 = 60
- UK 平均时长 = 20
全球平均时长是 (100 + 20) / 2 = 60。最终 HAVING AVG(cl.duration) > 60 只留下一行,也就是 USA。
这里有一个很容易产生疑惑的地方:第一通电话明明是美国 Alice 打给墨西哥 Bob 的,为什么 Bob 的墨西哥也会拿到 100 秒?因为题目问的是“该国家用户参与的通话平均时长”,Bob 参与了这通电话,他当然要从墨西哥的角度被计入。一个国际电话被两端的国家各算一次,是这个业务模型的正常结果。
如果你担心这种统计方式会把同一个通话重复算给两个国家造成口径不一致,可以在面试时主动说清楚:“我这里的逻辑是把每个通话参与者都作为一条样本;如果你希望一通电话只归属到主叫国家,那应该换成只关联 caller_id 的写法。”这样即使面试官有不同理解,也能看出你考虑过问题边界,而不是闷头写代码。
4. 另一种更清晰的思路:先把主叫和被叫拉平成一张明细
从 Person 出发 JOIN Calls 虽然简短,但有一个阅读障碍:ON 条件里的 OR 总让人觉得像是在做笛卡尔积。虽然事实上不是笛卡尔积,只是两个等值条件做了并集,但可读性确实不够好。
我在实际写这题时,更喜欢先把 Calls 表“拆”成一张用户通话明细表,把主叫和被叫都变成同一列。这样后面只需要做一次简单等值 JOIN。
sql复制WITH user_country AS (
SELECT
p.id AS person_id,
c.name AS country
FROM Person p
JOIN Country c
ON SUBSTRING_INDEX(p.phone_number, '-', 1) = c.country_code
),
call_user AS (
SELECT caller_id AS person_id, duration FROM Calls
UNION ALL
SELECT callee_id AS person_id, duration FROM Calls
)
SELECT
uc.country
FROM call_user cu
JOIN user_country uc ON cu.person_id = uc.person_id
GROUP BY uc.country
HAVING AVG(cu.duration) > (SELECT AVG(duration) FROM Calls);
这个版本的核心是 call_user 这个 CTE。比如 Calls 表里有一条记录 (caller_id=1, callee_id=2, duration=100),它会被展开成两行:
person_id = 1, duration = 100person_id = 2, duration = 100
这样就把“主叫”和“被叫”这两种身份统一成了“参与通话的用户”。后续 JOIN user_country 时不会再出现身份判断的问题,也没有必要再用 OR。
这里用 UNION ALL 而不是 UNION,是因为两段数据本身不会有重复交集——同一个用户不可能在同一通电话里既是主叫又是被叫。UNION 会额外做一次去重开销,完全没有必要。
这个思路在生产环境里还有一个额外好处:你可以在 call_user 之上继续追加更多定制化字段,比如归属渠道、用户等级、地区编码等等,而不是把逻辑全部堆在 JOIN 条件里。可读性一旦变好,后来维护的人就不会为了看懂一个 OR 而头疼半天。
不过要注意,这两个版本的语义都是“按参与通话的用户样本计算平均时长”。如果有一通电话的主叫和被叫来自同一个国家,那么这个国家会拿到该通话的两条样本。多数情况下,因为两条时长相同,平均值不会变;但如果你的业务口径要求“一通电话只能算一次”,那就要在拆明细时做去重处理。SQL 题里这种边界通常不会直接影响答案,可放到真实业务中就必须和数据分析师确认清楚。
5. 同一个 SQL 逻辑迁移到真实业务时,要加的三层保护
LeetCode 的 SQL 题往往把问题切成非常干净的几张表,但真实业务数据没有这么理想。这道题背后的“组内平均值与全局平均值比较”是一个通用分析框架,换成“城市通话平均时长”“客服处理平均时长”“渠道订单平均金额”都能复用。我在做数据报表时经常用到这种思路。
如果把它迁移到真实业务,我不会直接拿题解代码上线,而是会额外加三层保护。
第一层是脏数据保护。真实表里的 phone_number 可能包含空格、+ 号、括号、
