
LeetCode 180 连续出现的数字”这道 SQL 题几乎可以算面试里的“老熟人”了。题目不长表结构也简单但它问得特别刁钻光会用 GROUP BY 没用你得先把“连续”这两个字在关系表里翻译出来。我第一次刷的时候上来就GROUP BY num HAVING COUNT(*) 3一跑发现错得离谱才知道这题的坑在“连续性”不在“次数”。这道题非常适合作为窗口函数入门的第一道实践题也适合拿来复盘自连接、去重、边界条件这些 SQL 基本功。下面我不光给你三种能跑通的解法还会把每种写法背后的思路、适用场景、以及面试时怎么把逻辑讲清楚都拆开说一遍。1. 题目拆解连续出现的数字到底在考什么1.1 原题长什么样先过一遍题目LeetCode 180 的题目描述很简短。有一张表Logs结构如下--------- | id | num | --------- | 1 | 1 | | 2 | 1 | | 3 | 1 | | 4 | 2 | | 5 | 1 | | 6 | 2 | | 7 | 2 | ---------要求是写一段 SQL找出所有至少连续出现三次的数字。输出列名是ConsecutiveNums示例数据的预期结果是----------------- | ConsecutiveNums | ----------------- | 1 | -----------------为什么输出只有 1因为数字 1 在 id 为 1、2、3 的位置连续出现了三次数字 2 虽然在 id 为 6、7 出现了两次但不够三次id 为 5 的 1 又被 id 为 4 的 2 隔断了所以不能和前面的 1 算作一个连续段。1.2 三个隐藏前提连续、按 id 排序、去重输出这道题有三个容易忽略的前提条件理解了它们你后面写 SQL 的方向才不会歪。第一“连续”不是简单的次数多而是要求“位置连续”。在这个表里id 就是位置标记连续出现意味着 id 相邻的三行必须是同一个数字。比如 id 为 1、2、3 的 num 都是 1这是一段连续id 为 1、2、5 的 num 虽然也是 1但它们中间隔着别的行不能算连续。第二表格数据的判断顺序由 id 决定。id 在这里约等于自增主键代表插入顺序。实际业务里如果你的表没有这样的有序字段你就得先想清楚“按什么字段排序才算连续”这是很多人在面试里被追问时卡住的地方。第三输出要去重。如果某个数字连续出现了四行甚至更多它可能被多个“三连片段”命中但结果里只需要输出一个数字。这就是为什么很多解法里必须加DISTINCT少了它你的结果行数会比预期多。1.3 这道题为什么值得反复刷说实话这道题本身不难难度评级也就是中等偏下。但它在面试里出现频率非常高因为一张Logs表可以演变出无数个“连续类”问题连续登录 N 天、连续签到、连续消费、连续上涨……底层逻辑都差不多。你把这题吃透了等于掌握了一个通用模板。另外LeetCode 环境默认用 MySQL 8.0支持窗口函数所以这道题的解法可以从最朴素的三表自连接一路讲到窗口函数再到分组法非常适合用来展示你 SQL 能力的层次感。面试官听你讲解法能快速判断你是背题选手还是真的理解数据之间的关系。2. 解法一三表自连接把“连续”翻译成 id 相邻2.1 自连接的思路与 SQL最直接的想法是既然连续出现三次意味着存在三行它们的 id 是相邻的并且 num 相等。那我让表自己和自己连接三次就能找到这样的三行组合。SELECT DISTINCT l1.num AS ConsecutiveNums FROM Logs l1 JOIN Logs l2 ON l1.id l2.id - 1 JOIN Logs l3 ON l2.id l3.id - 1 WHERE l1.num l2.num AND l2.num l3.num;这里我把Logs表起了三个别名l1、l2、l3。l1.id l2.id - 1表示 l2 是 l1 的下一行l2.id l3.id - 1表示 l3 是 l2 的下一行这样(l1, l2, l3)就构成了一个 id 连续的三人组。再要求它们的 num 完全相等那这三个位置就是“连续出现三次”的字面意思。你也可以不用JOIN写成老式的逗号连接SELECT DISTINCT a.num AS ConsecutiveNums FROM Logs a, Logs b, Logs c WHERE a.id b.id - 1 AND b.id c.id - 1 AND a.num b.num AND b.num c.num;效果一样但在实际项目里我更推荐显式JOIN可读性更好也方便后面加连接条件。2.2 为什么必须加 DISTINCT如果去掉DISTINCT返回结果会出问题。比如数字 1 连续出现四次分别是 id 1、2、3、4那么三元组(id1, id2, id3)和(id2, id3, id4)都满足条件这样会输出两个 1。本质上是因为“起点不同”但你要的是“去重后的数字”而不是“满足条件的三元组数量”。还有个容易犯的错误是拿GROUP BY num去替代DISTINCT。严格来说也能得到去重效果但会让人误以为这里有什么聚合逻辑反而干扰阅读。所以这道题直接用DISTINCT是最清爽的。2.3 自连接的局限扩展性和执行效率三表自连接的优点是直观它把“连续”的定义原封不动翻译成了 SQL 条件几乎不需要解释。缺点也很明显一是扩展性差如果题目改成连续出现五次你就得写五表连接SQL 会变得又臭又长二是执行代价高Logs表数据量一上来多个自连接会产生大量中间结果即使 id 上有主键索引性能也不乐观。面试时如果你先讲这个解法我建议你补一句“这个写法适合小数据量理解题意数据量大以后我更推荐窗口函数”。这句话能立刻拉高面试官对你的评价说明你有性能意识。3. 解法二LEAD/LAG 窗口函数面试中更讨喜的写法3.1 LEAD 写法和执行逻辑窗口函数是 MySQL 8.0 引入的解决这类“相邻行比较”问题非常顺手。LEAD可以拿到“按指定排序之后当前行后面第 N 行的值”。这里我需要后两行的 num所以分别偏移 1 和 2。SELECT DISTINCT num AS ConsecutiveNums FROM ( SELECT id, num, LEAD(num, 1) OVER (ORDER BY id) AS next_num_1, LEAD(num, 2) OVER (ORDER BY id) AS next_num_2 FROM Logs ) t WHERE num next_num_1 AND num next_num_2;内层子查询先按 id 排序为每一行附上“下一个位置的 num”和“下下个位置的 num”外层再判断这三个值是否相等。等于把“连续三次”转换成了“当前行跟后面两行相同”。拿示例数据走一遍id 为 1 的 num 是 1next_num_1是 id 为 2 的 1next_num_2是 id 为 3 的 1相等所以命中。id 为 4 的 num 是 2next_num_1是 id 为 5 的 1不等直接过滤掉。LEAD对最后两行会返回 NULL但WHERE num NULL的结果是 UNKNOWN会被过滤掉所以不用担心末尾产生误判。3.2 LAG 写法与两种函数怎么选除了LEAD你还可以用LAG去拿前面的值逻辑完全对称SELECT DISTINCT num AS ConsecutiveNums FROM ( SELECT id, num, LAG(num, 1) OVER (ORDER BY id) AS prev_num_1, LAG(num, 2) OVER (ORDER BY id) AS prev_num_2 FROM Logs ) t WHERE num prev_num_1 AND num prev_num_2;LAG和LEAD的区别只是方向一个是向上看一个是向下看。实际工作中如果我想把“当前行作为连续段末尾”来思考就用LAG如果我把“当前行作为连续段起点”来思考就用LEAD。对这道题来说结果没有区别。3.3 窗口函数解法需要注意的环境差异用窗口函数之前先确认数据库版本。MySQL 5.7 不支持LEAD/LAGOracle、PostgreSQL、SQL Server 都支持但 MySQL 要 8.0 以上。LeetCode 的判题环境是 MySQL 8.0所以这个解法能直接通过。还有一点窗口函数的ORDER BY id不要求 id 物理上连续。它只看排序后的位置关系即使 id 中间有空洞比如 1、3、5只要这三行在排序后相邻且 num 相同就会判为连续。这个特性在讨论“id 不连续怎么办”时特别有用也是我认为窗口函数比三表自连接更稳的原因之一。4. 解法三序号差值分组法一条 SQL 带走所有连续题4.1 row_number 差值分组的原理第三种解法是很多“连续题”的标准模板思路比前两种稍微绕一点但通用性最强。我先给每一行生成两个序号一个是全局序号按 id 从 1 开始排另一个是分区序号按 num 分组、组内按 id 排。然后两个序号相减如果 num 连续出现它们会落在同一个差值组里。SELECT DISTINCT num AS ConsecutiveNums FROM ( SELECT id, num, ROW_NUMBER() OVER (ORDER BY id) AS rn_all, ROW_NUMBER() OVER (PARTITION BY num ORDER BY id) AS rn_num FROM Logs ) t GROUP BY num, (rn_all - rn_num) HAVING COUNT(*) 3;直观理解全局序号一直往上加分区序号遇到新的数字时会从 1 重新开始。当数字发生变化时分区序号会“重置”而全局序号不会所以两者的差值就会突变只要 num 连续不变两个序号同步增长差值就保持不变。用示例数据算一遍idnumrn_allrn_num差值11110212203133042413515416262472734数字 1 在 id 1、2、3 这一段差值都是 0分到同一组COUNT(*)为 3满足条件。id 为 5 的 1虽然也是数字 1但差值变成了 1说明它和前一段被隔开了不能混在一起。数字 2 在 id 6、7 的差值都是 4数量只有 2不足三次。外层加DISTINCT是因为一个数字可能有多段都满足连续三次比如数字 3 连续出现三次、间隔后又连续出现三次它会在两个 grp 里都出现最后结果只需要一个 3。4.2 对 id 有空洞的情况怎么改造刚才这个写法里rn_all - rn_num用的是两个 row_number不直接依赖 id 的数值所以 id 中间有空洞也能正确处理。因为全局序号和分区序号都是按顺序生成的连续整数差值的稳定性只看“分区内是否连续”不看 id 的数字大小。还有另一个变体是把公式换成id - ROW_NUMBER() OVER (PARTITION BY num ORDER BY id)这是网上很容易看到的写法。这个写法要求 id 本身也是连续递增的比如 1、2、3、4。如果表里因为删除操作导致 id 变成 1、2、4、5这个公式会把连续段拆散或者导致分组错误。所以在 LeetCode 默认 id 连续的前提下它能过测试但真实业务里我更建议大家用两个 row_number 相减的版本少踩一个坑。提示分组法的核心思想不是背公式而是理解“排序序号和分组序号之间的差能标记出一段连续区间”。这个思路在连续登录、连续签到题里几乎天天用。4.3 把“至少连续 3 次”改成“连续 N 次”分组法最大的好处是改成任意 N 次只需要动一个数字HAVING COUNT(*) 5;就能找出所有连续出现至少五次的数字。如果题目改成“连续出现恰好三次”就是HAVING COUNT(*) 3。这样一套逻辑打天下比三表连接法优雅得多。窗口函数解法也能做 N 次但要写 N 个LEAD或LAGN 一旦变大代码就很冗余。所以从扩展性角度我通常会优先推荐分组法。5. 面试变体从这一题到“连续登录天数”5.1 连续登录天数题的迁移方法面试时LeetCode 180 往往只是开胃菜真正的追问常常是如果有一张用户登录表要你统计连续登录 3 天以上的用户怎么写结构其实一模一样。把Logs换成user_login把num换成user_id把id换成login_date差别只在“连续”的单位从 id 变成了日期。先假设登录表有一个用户一天可能登录多次那第一步要按用户和日期去重WITH tmp AS ( SELECT DISTINCT user_id, login_date FROM user_login ) SELECT user_id FROM ( SELECT user_id, login_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn FROM tmp ) t GROUP BY user_id, DATE_SUB(login_date, INTERVAL rn DAY) HAVING COUNT(*) 3;这里DATE_SUB(login_date, INTERVAL rn DAY)就是“日期减去序号”。如果登录日期是连续的比如 1 号、2 号、3 号那么减完后的日期是同一个基准日就会并入同一组一旦中间断了一天基准日就变了。我建议你把这个变体写在简历项目里的时候要想清楚因为面试官非常有可能会追一句“如果一个用户同一天登录两次你会怎么处理”答案就是在前面加DISTINCT去重。这个细节直接看出你处理真实数据的能力。5.2 输出每个数字的最大连续次数LeetCode 180 只要求找出至少连续三次的数字但面试中可能进一步问统计每个数字最长的连续出现次数是多长思路也很简单先用分组法把所有连续段找出来数出每段长度再按数字取最大值SELECT num, MAX(cnt) AS max_consecutive FROM ( SELECT num, COUNT(*) AS cnt FROM ( SELECT num, ROW_NUMBER() OVER (ORDER BY id) - ROW_NUMBER() OVER (PARTITION BY num ORDER BY id) AS grp FROM Logs ) t GROUP BY num, grp ) s GROUP BY num;这样既能回答“至少出现三次的数字”也能回答“每个数字最大连续几次”代码改动很小。这种层层嵌套的写法看起来复杂但拆开来看就是“分组查到连续段再聚合查最大段”逻辑非常清晰。5.3 我建议的刷题顺序如果你是想通过刷题巩固 SQL我推荐按这个顺序来先做 175组合两个表练连接再做 176第二高的薪水练子查询和LIMIT的坑然后做 180 这道连续题接着做 185部门工资前三高的员工练窗口函数排名最后可以做 262行程和用户综合体验一下复杂过滤和连接。顺序的意义在于每一道题都会复用前面的知识点但难度曲线是爬升的。180 卡在中间刚好是“会用 JOIN但还不熟窗口函数”的过渡区特别适合拿来打通两条思路。6. 常见问题与避坑指南6.1 忘了 DISTINCT结果多出来重复行这是这道题提交错误最常见的原因。只要某个数字连续出现四行以上三表连接和窗口函数都会把多组三元组都挑出来结果里出现重复数字。我用DISTINCT解决但在生产环境里如果结果集很大我更愿意在外层改成GROUP BY num语义上同样去重但配合聚合统计时更自然。6.2 表里恰好有 NULL 或者空表num字段如果允许为 NULL那你得想清楚业务上 NULL 算不算一种“有效数字”。SQL 里NULL NULL的结果不是 true而是未知所以WHERE num next_num_1会把大多数 NULL 相关的行过滤掉。如果你想把 NULL 也纳入连续计算要用COALESCE(num, )先转成统一值。空表的话三解法的结果都是空集不用额外处理。6.3 MySQL 5.7 没有窗口函数时的变量写法如果你的面试环境比较老用的是 MySQL 5.7窗口函数用不了除了三表连接还有一个用用户变量的兼容写法SELECT DISTINCT num AS ConsecutiveNums FROM ( SELECT num, cnt : IF(prev num, cnt 1, 1) AS cnt, prev : num FROM Logs, (SELECT prev : NULL, cnt : 0) r ORDER BY id ) t WHERE cnt 3;思路是遍历每一行如果当前 num 和上一行的 num 相同计数器加 1否则计数器重置为 1。这里必须把ORDER BY id放在子查询里保证计算顺序和 id 顺序一致。不过说实话我不推荐在生产项目里大量用用户变量。MySQL 对用户变量的赋值顺序没有严格保证8.0 以后窗口函数已经是更规范、可读性更好的方案。面试里提到这个写法更多是展示你知道旧版兼容方案而不是真的让你去天天写。6.4 性能有关的几点实操体会三表自连接在小表上没问题但到了百万行级别每个连接都要走索引中间结果会膨胀查询计划也难优化。窗口函数通常只需要一次排序能显著减少扫描成本。我曾经在本地用一张十万行的日志表测试过三表自连接跑了差不多 1.8 秒窗口函数直接降到 0.1 秒左右。这个差距在真正的大表上会更大。所以性能敏感的场景我一般默认窗口函数 分组法而不是自连接。6.5 常见错误快速对照表问题现象可能原因解决方式结果多出重复数字缺少 DISTINCT外层加 DISTINCT 或 GROUP BY明明有连续三次但搜不到用 num 分组而不是按 id 判断连续性改用三元组条件或窗口函数id 有空洞导致结果错误使用了 id - row_number 公式改用两个 row_number 相减日期连续题结果不对忘记去重或日期有重复先按 user_id、login_date 去重窗口函数报错数据库版本不支持确认 MySQL 8.0或退化为自连接7. 最后说点个人的刷题心得我每次给朋友讲这道题都会强调一件事SQL 题不是死记解法而是要把题目里的业务逻辑翻译成表之间的关系。LeetCode 180 考的“连续”本质上是一个“相邻行比较”问题你只要抓住这个本质三表自连接、窗口函数、分组法其实都是同一件事的不同表达。我自己的习惯是先写一版最笨的自连接把题意验证清楚再用窗口函数重构一版最后用分组法把代码收敛成模板。这样三轮下来对“连续”类题目的理解会非常扎实。后面遇到连续登录、连续消费、连续签到都能直接套用这套思路而不是每次重新看题再挣扎两个小时。希望这篇文章能帮你把这道题彻底吃掉下次再见到它能在五分钟内写出你自己最满意的那一版。