news 2026/10/20 8:35:15

SQL分桶统计缺失补0:从LeetCode 1435到业务报表

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL分桶统计缺失补0:从LeetCode 1435到业务报表

刷题平台数据库题库里有一道编号 1435 的题,翻译过来叫“制作会话柱状图”。我第一眼看到这题觉得是送分题:把 session 的时长按分钟分个组,count 一下不就完事了?结果第一次提交就被几个测试用例教育了——当某个时间桶里压根没有会话时,我的 SQL 不是输出 0,而是直接少了那一行。

这道题真正的考点,并不在 CASE WHEN 本身,而在于一个很多人踩过的经典陷阱:固定区间统计时,如何把“缺失的类别”补回来。这篇文章我会把题目先拆干净,再给出三种可提交的写法,然后重点说说排序、边界值和 COUNT 字段这些细节,最后聊聊这个套路在真实报表里的迁移。适合刚开始准备 SQL 面试的同学,也会对日常写统计查询的开发者有帮助。

1. 题面拆解:输入两个字段,输出四行固定桶

1.1 输入表只有 session_id 和 duration,但单位是秒

题目给的Sessions表结构非常简单:

CREATE TABLE Sessions ( session_id INT, duration INT );

duration的单位是秒。这一点看着不起眼,却是很多人踩坑的第一步:如果心里默认按分钟算,后面边界全乱。

题目要求按 5 分钟一个桶,把会话时长划分成四段:

桶名对应时长区间换算成秒
[0-5>0 分钟 ≤ x < 5 分钟0 ≤ x < 300
[5-10>5 分钟 ≤ x < 10 分钟300 ≤ x < 600
[10-15>10 分钟 ≤ x < 15 分钟600 ≤ x < 900
15 or morex ≥ 15 分钟x ≥ 900

这里要注意桶名的写法:[0-5>是左闭右开,表示包含 0 分钟、不包含 5 分钟;15 or more没有上限,表示 15 分钟及以上都算。

1.2 输出要求里藏着一个“题眼”

我拿一组示例数据来演示一下:

session_idduration(秒)
1120
2300
3599
4600
51200

换算一下:120 秒 = 2 分钟,落在[0-5>;300 秒 = 5 分钟整,落在[5-10>(因为[0-5>不包含 5 分钟);599 秒在 9 分 59 秒,落在[5-10>;600 秒 = 10 分钟整,落在[10-15>;1200 秒 = 20 分钟,落在15 or more。

正确的输出长这样:

bintotal
[0-5>1
[5-10>2
[10-15>1
15 or more1

这个例子每个桶都有数据,看起来平平无奇。真正让人抓狂的是另一种输入,比如表里所有 session 都在 3 分钟以内:

session_idduration
130
2120
3200

按照题目要求,输出必须是:

bintotal
[0-5>3
[5-10>0
[10-15>0
15 or more0

注意,[5-10>、[10-15>、15 or more这三行必须出现,total 用 0 填。“没有会话的桶也要返回 0”,这句话就是全题最核心的约束,也是很多解法挂掉的原因。

2. 最直觉的写法:GROUP BY + CASE,为什么会把空桶弄丢

2.1 第一版代码通常长这样

看到分桶统计,大多数人的第一反应是先给每条记录算一个桶名,再按桶名分组:

SELECT CASE WHEN duration / 60 < 5 THEN '[0-5>' WHEN duration / 60 < 10 THEN '[5-10>' WHEN duration / 60 < 15 THEN '[10-15>' ELSE '15 or more' END AS bin, COUNT(*) AS total FROM Sessions GROUP BY bin;

在“每个桶恰好都有数据”的时候,这版结果是对的。但一旦某个桶没数据,问题就出现了。

2.2 漏桶的本质:GROUP BY 只对被查询到的值分组

GROUP BY 的工作方式,是先把满足 WHERE 条件的行筛出来,再拿着这些行里的值去分组。它并不会像字典一样先声明“我要这四个桶”,再往里填充。

打个比方:老师点名只念“今天到场的同学”,缺勤的同学根本不会进入点名册,所以名单里直接没有这个人,而不是在名字旁写“0”。同理,如果表里没有一条记录落在[5-10>,这条桶的记录就不会出现在 GROUP BY 的结果里。

这一点在 LeetCode 判题时尤其阴险:它给的第一个示例往往每个桶都有数据,你一跑,结果正确,信心满满地提交,然后被第二个“空桶用例”一击致命。我自己就在这摔过一跤,所以现在看到“固定区间 + 分组统计”,第一反应永远是:某个区间可能没有数据,空桶需要主动补出来。

3. 三种能正确补 0 的解法

3.1 解法一:UNION ALL,四个区间各查一次,暴力但稳

这个思路最简单:我不指望 GROUP BY 帮我生成桶,而是自己把四个桶写死,每个桶单独 COUNT。因为没有桶标签是空字符串,即使某个桶计数为 0,SELECT 本身也会返回一行。

SELECT '[0-5>' AS bin, COUNT(*) AS total FROM Sessions WHERE duration < 300 UNION ALL SELECT '[5-10>', COUNT(*) FROM Sessions WHERE duration >= 300 AND duration < 600 UNION ALL SELECT '[10-15>', COUNT(*) FROM Sessions WHERE duration >= 600 AND duration < 900 UNION ALL SELECT '15 or more', COUNT(*) FROM Sessions WHERE duration >= 900;

这段代码有四个独立的子查询,分别统计四个区间。每个子查询都固定输出一行,所以空桶也一定会出现。比如表里只有 0 到 5 分钟的数据时,后面三段各自查到 0 行,COUNT(*)返回 0,这样四行结果就齐了。

这里有几个小细节值得说:

  • 用UNION ALL而不是UNION。虽然四个子查询的桶名各不相同,永远不会重复,但UNION会多做一次去重和排序,完全没必要。数据量大了以后,UNION ALL是稳妥的性能选择。
  • 四个区间之间用“左闭右开”的写法:duration >= 300 AND duration < 600。边界值不会重复计入两个桶,也不会漏掉任何一条。
  • 每个子查询里没有把bin作为条件写进 WHERE,因为桶名是固定的,只要区间条件正确,对应的就是正确的桶。

这个解法的缺点也很明显:如果桶的数量是几十个,代码会变得异常啰嗦。但对于这道题只有 4 个桶的场景,它是最不容易出错的答案,也完全不需要考虑排序问题——如果判题只看集合内容的话。

3.2 解法二:CTE 虚拟桶 + LEFT JOIN,把“缺失类别”明着补

如果不想写四段重复代码,可以用一个虚拟的桶表,然后 LEFT JOIN 原表。这是处理“补缺失类别”最通用的思路:

WITH bins AS ( SELECT '[0-5>' AS bin UNION ALL SELECT '[5-10>' UNION ALL SELECT '[10-15>' UNION ALL SELECT '15 or more' ) SELECT b.bin, COUNT(s.session_id) AS total FROM bins b LEFT JOIN Sessions s ON b.bin = CASE WHEN s.duration < 300 THEN '[0-5>' WHEN s.duration < 600 THEN '[5-10>' WHEN s.duration < 900 THEN '[10-15>' ELSE '15 or more' END GROUP BY b.bin;

理解这段代码,核心在 LEFT JOIN 的行为:

bins表固定有 4 行。LEFT JOIN 保证左边表(bins)的每一行都能保留下来。右边Sessions表没有匹配的行时,右边所有字段都是 NULL。于是空桶就变成了“右边全是 NULL 的一行”,而不是“消失的一行”。

这里有一个非常关键的坑:外层 SELECT 里不能用COUNT(*),必须用COUNT(s.session_id)。

原因在于,LEFT JOIN 之后,空桶的左边确实有一行 bins 记录。如果统计COUNT(*),它会把这一行也数进去,结果空桶的 total 会变成 1,而不是 0。而COUNT(s.session_id)只统计右边session_id不为 NULL 的行,空桶右边是 NULL,自然会计成 0。

这个坑我在第一次写 LEFT JOIN 补 0 报表时也踩过,当时查了半天数据,怎么都想不明白为什么明明没有用户,统计出来却是 1。后来才反应过来:COUNT(*)统计的是“输出行的数量”,而我们要的是“右边主表匹配行的数量”。

3.3 解法三:把桶边界放进表里,JOIN 时直接比区间

再看一个稍微进阶的写法:既然桶的边界是确定的,为什么不把上下界也存起来,让 JOIN 条件直接比较大小?这样连 CASE WHEN 都省了,排序也更自然:

WITH bucket_ranges AS ( SELECT '[0-5>' AS bin, 0 AS min_sec, 300 AS max_sec UNION ALL SELECT '[5-10>', 300, 600 UNION ALL SELECT '[10-15>', 600, 900 UNION ALL SELECT '15 or more', 900, 2147483647 ) SELECT r.bin, COUNT(s.session_id) AS total FROM bucket_ranges r LEFT JOIN Sessions s ON s.duration >= r.min_sec AND s.duration < r.max_sec GROUP BY r.bin, r.min_sec ORDER BY r.min_sec;

这个写法把“桶”抽象成了一张带有上下界的配置表。JOIN 条件直接落到秒数上了,没有 CASE WHEN 的字符串比较,也不容易记错边界。最后一桶的上界用了2147483647,就是 MySQL 的 INT 最大值,相当于“无穷大”。如果你不喜欢这么大个数字,用999999999也完全够用。

GROUP BY 里需要把r.bin和r.min_sec都带上,因为min_sec参与了排序和分组逻辑;只按bin分组在 MySQL 的 ONLY_FULL_GROUP_BY 模式下会报错。ORDER BYr.min_sec则天然保证了桶的顺序是 0-5、5-10、10-15、15+,不用再手工排序。

这三种写法并不冲突:

解法优点缺点推荐场景
UNION ALL 四段逻辑最直白,不需要理解 COUNT 坑桶多时代码冗长桶数量少且固定
CTE + CASE LEFT JOIN桶列表集中,代码整洁需要理解 LEFT JOIN 补 0 和 COUNT(NULL)通用补类别报表
桶边界驱动表边界清晰,排序自然,无 CASE稍绕,需要理解哨兵上界动态区间或需要维护多个桶表

4. 排序、边界值和 COUNT 字段:真正让你扣分的三个细节

4.1 直接 ORDER BY bin,结果顺序会出乎意料

如果你在做真实报表,而不是只在刷题平台提交答案,输出顺序往往比内容更让人头疼。bin字段是字符串,字符串排序按字符的字典序来。问题在于:

  • 15 or more第一个字符是1
  • [0-5>第一个字符是[

在常见字符集里,数字1排在字符[前面。所以如果直接ORDER BY bin,第一个跑出来的桶多半是15 or more,紧接着才是[0-5>、[10-15>、[5-10>,完全看不出“从小到大”的自然顺序。

解决办法一般有三种:

-- 办法一:CASE 手工映射序号 ORDER BY CASE bin WHEN '[0-5>' THEN 1 WHEN '[5-10>' THEN 2 WHEN '[10-15>' THEN 3 ELSE 4 END; -- 办法二:MySQL 的 FIELD 函数 ORDER BY FIELD(bin, '[0-5>', '[5-10>', '[10-15>', '15 or more');

不过最舒服的还是解法三里的思路:直接按桶的下界字段排序。数据里有了min_sec,排序问题直接消失,这也是我强烈建议在真实业务里用“桶边界表”的原因。

4.2 边界条件:300、600、900 这三个秒数,向左还是向右?

这道题对边界的要求非常严格:

  • duration = 300,也就是 5 分钟整,到底算哪个桶?
  • duration = 600,也就是 10 分钟整,到底算哪个桶?
  • duration = 900,也就是 15 分钟整,到底算哪个桶?

题目的桶名[0-5>、[5-10>、[10-15>是左闭右开区间,所以答案很清楚:300 秒落在[5-10>,600 秒落在[10-15>,900 秒落在15 or more。

反过来说,如果你写的条件是duration <= 300归到[0-5>,那 300 秒就被多算了一次,同时[5-10>少算了一行数据,两边都错。牢记一条规则:区间统计用左闭右开,边界值写在下一个区间的起始条件里。

另外,秒到分钟的换算最好直接用秒比较。比如duration / 60 < 5在语法上没问题,但如果某个数据库的除法结果存在浮点误差,或者你忘了加分母,逻辑就会模糊。直接用duration < 300,一眼能看出 5 分钟等于 300 秒,不容易翻车。

4.3 COUNT(*) 还是 COUNT(字段)?在 LEFT JOIN 场景下真的不一样

很多同学写 SQL 用得顺手了,觉得COUNT(1)、COUNT(*)、COUNT(session_id)差不多,在普通 GROUP BY 查询里确实差不多。但一旦出现 LEFT JOIN,这三者的含义就开始分化了:

  • COUNT(*):统计输出结果集的行数,不管字段是否 NULL。
  • COUNT(1):等价于COUNT(*),也是行数。
  • COUNT(session_id):只统计session_id不为 NULL 的行数。

在“左表驱动补空桶”的查询里,空桶左侧有行、右侧字段全是 NULL,COUNT(*)会把左侧那行也算进去,结果变成 1。因此,只要想统计“右侧主表匹配到了多少行”,就必须用COUNT(s.session_id)或者COUNT(s.duration)这类字段计数。

如果觉得这个规则太绕,还有另一个写法能绕开:用标量子查询。

SELECT b.bin, (SELECT COUNT(*) FROM Sessions s WHERE b.bin = CASE WHEN s.duration < 300 THEN '[0-5>' WHEN s.duration < 600 THEN '[5-10>' WHEN s.duration < 900 THEN '[10-15>' ELSE '15 or more' END) AS total FROM bins b;

子查询里没有 LEFT JOIN,COUNT(*)统计的纯粹是 Sessions 表的匹配行数,空桶自然返回 0。这个写法对初学者更友好,代价是每一行都要执行一次子查询,桶多时会慢一些。

5. 从刷题到业务:分桶补 0 的通用套路

5.1 订单金额分层,空桶就是“没人买的高价区间”

真实业务里的经典场景是订单金额分层。比如运营想看客单价的分布:

金额区间订单数
0-100 元1540
100-500 元823
500-1000 元106
1000 元以上8

如果直接用 GROUP BY,恰好某个月没有 500-1000 元的订单,这一行就会消失。运营同学看报表时很可能根本注意不到“高价位区间无人购买”,而补 0 之后,0 这个数字本身就会立刻引起注意:高价区间没人买,是需要调整选品,还是流量没匹配对?缺失的本义是“无记录”,而 0 的本义是“存在但为零”,两者传递的信息完全不同。

用秒桶那题练出的模板,拿过来直接改字段名就行:

WITH amount_buckets AS ( SELECT '[0-100)' AS bucket, 0 AS low, 100 AS high UNION ALL SELECT '[100-500)', 100, 500 UNION ALL SELECT '[500-1000)', 500, 1000 UNION ALL SELECT '1000+', 1000, 999999 ) SELECT b.bucket, COUNT(o.order_id) AS order_cnt FROM amount_buckets b LEFT JOIN orders o ON o.amount >= b.low AND o.amount < b.high GROUP BY b.bucket, b.low ORDER BY b.low;

这套 SQL 几乎就是 1435 题的翻版。会做这题的人,迁移到订单金额分层、用户活跃天数分层、页面停留时长分桶时,都不会有障碍。

5.2 接口延迟分桶,比平均数更诚实

做后端性能分析时,也经常需要看接口响应时间的分布。比如把耗时分成 <100ms、100-300ms、300-1000ms、>1000ms 四档,然后统计每个档位的调用次数。

这种场景比平均数更诚实:平均耗时 200ms 的背后,可能是 90% 请求都在 60ms,但有一个请求把平均值拉到了 2000ms。只有分桶直方图能让你一眼看出“尾部延迟有多长”。同理,如果 >1000ms 的桶没有数据,用 GROUP BY 会导致这一行直接不见,而补 0 后,性能报告里那个 0 才是真正的健康信号。

5.3 不同数据库方言下的写法差异

虽然刷题平台主要用 MySQL,实际工作中数据库可能各不相同,这套逻辑在不同方言里都有对应写法:

数据库推荐写法
MySQL 8+CTE + LEFT JOIN,或用桶边界表
MySQL 5.7不支持 CTE,把 CTE 改成派生表FROM (...) b即可
PostgreSQL / SQL Server用VALUES构造桶表,或使用COUNT(*) FILTER (WHERE ...)

PostgreSQL 的VALUES写法非常简洁,特别适合构造这种固定桶:

SELECT b.bin, COUNT(s.session_id) AS total FROM ( VALUES ('[0-5>', 0, 300), ('[5-10>', 300, 600), ('[10-15>', 600, 900), ('15 or more', 900, NULL) ) AS b(bin, lo, hi) LEFT JOIN Sessions s ON s.duration >= b.lo AND (b.hi IS NULL OR s.duration < b.hi) GROUP BY b.bin, b.lo ORDER BY b.lo;

这个写法的妙处在于,hi为 NULL 就表示“没有上限”,专门用来表达 15 or more 这种开放性区间,不需要硬塞一个 INT 最大值。如果你在工作中能用 PostgreSQL,我很推荐这种方式。

5.4 我把这套题的做题模板总结成了四步

做完 1435 之后,我把“固定区间统计 + 缺失补 0”的查询总结成四个步骤,分享给你,后面遇到同类问题直接套用:

  1. 先把桶的边界写在纸上。单位、开闭区间、边界值归属,这三件事在写 SQL 前就要定死。比如秒、左闭右开、300 进下一个桶。
  2. 用某种方式构造桶表。可以是一串 UNION ALL,也可以是 CTE,还可以是 VALUES。核心是“桶有固定行”。
  3. LEFT JOIN 原始表,JOIN 条件用区间判断。左表是桶,右表是业务表,匹配条件写区间范围。建议用边界值字段,而不是字符串 CASE。
  4. COUNT 右侧主键,按桶下界排序。这步解决两个坑:空桶被算成 1 的问题,以及字符串排序乱序的问题。

我在实际写这类查询时,发现只要把第二步和第三步做好,后面几乎不用回头改。反过来,如果写 SQL 时一开始就图省事用 GROUP BY + CASE,等到发现少桶了,改动成本远比分步规划高得多。

最后再分享一个个人习惯

我最早做这道题时,也迷信过 GROUP BY + CASE 的“短小精悍”,结果被空桶用例打脸。后来养成了一个习惯:写任何分桶统计之前,先自问一句“这个查询的输出行,是不是要固定包含一些类别?”。只要答案是“是”,我就默认走“桶表 + LEFT JOIN”的路子,不再走 GROUP BY 的老路。

这个小习惯帮我少踩了很多线上报表的坑。也希望你在看这篇题解的时候,不只是背下三个 SQL,而是记住那个最核心的认知:缺失的类别不是不存在,只是你没把它从结果里“声明”出来。把桶变成一张表,把统计变成 JOIN,这道 1435 的考点就彻底拿下了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 8:35:01

SQL分桶统计补零:LeetCode 1435会话柱状图解析

做数据分析的人&#xff0c;应该对“按区间分桶统计”这类需求不陌生。拿到一张用户会话表&#xff0c;要统计每个时长区间里有多少记录&#xff0c;还要把没数据的区间也补成0&#xff0c;在SQL里确实能绊倒不少人。LeetCode的Database第1435题“制作会话柱状图”&#xff0c;…

作者头像 李华
网站建设 2026/10/20 8:34:46

ESP8266四足机器人DIY:硬件设计、舵机控制与步态算法实战

基于ESP8266的四足机器人的设计做硬件DIY的朋友&#xff0c;不管你是刚入门Arduino的新手&#xff0c;还是已经玩过几块开发板的老玩家&#xff0c;大概率都动过自己搞一台四足机器人的念头。为什么&#xff1f;因为四足机器人是性价比最高的运动机器人平台之一&#xff0c;它能…

作者头像 李华
网站建设 2026/10/20 8:33:59

主流AI大模型全景梳理与选型实战:从闭源API到开源本地部署

这两年AI大模型的迭代速度&#xff0c;说实话已经快到让人有点跟不上。前脚刚搞清楚“参数量越大越强”的说法不靠谱&#xff0c;后脚推理模型、多模态模型又成了新焦点&#xff1b;这边还在纠结用哪个API做业务&#xff0c;那边开源权重模型已经把本地部署的门槛拉到了单张消费…

作者头像 李华
网站建设 2026/10/20 8:33:56

长音频ASR模型部署与端侧AI胸针:端云协同语音识别实战

这两天我一直在等着看同一个技术方向的两条消息&#xff1a;一边是有大厂开源了专门面向长音频的语音识别模型 VibeVoice-ASR&#xff0c;官方说法是能直接支持一小时连续音频处理&#xff1b;另一边是某硬件厂商端出了一个只有追踪器大小的 AI 胸针&#xff0c;双摄像头加三颗…

作者头像 李华
网站建设 2026/10/20 8:33:26

2026正规的多账号矩阵管理工具,企业抖音账号集中管控方案

当企业账号从几个扩张到几十个&#xff0c;反复登录切换、内容排期冲突、数据分散、权限失控成为常态。与此同时&#xff0c;平台风控持续收紧&#xff0c;靠群控脚本批量操作账号的路已经走不通。2026年&#xff0c;企业需要的不是更多工具&#xff0c;而是一条建立在官方规则…

作者头像 李华
网站建设 2026/10/20 8:33:19

车载安全“一芯一证”落地:四级密钥体系与产线实践

做车载安全这行这么多年&#xff0c;“一芯一证”这个词这几年越来越高频。尤其是在整车E/E架构从分布式往域集中、中央计算演进之后&#xff0c;每一颗ECU上的安全芯片被赋予的信任职责&#xff0c;跟过去完全不是一个量级。过去大家觉得给ECU写个防盗匹配、做一次升级鉴权就差…

作者头像 李华