刷 LeetCode SQL 的题,很多人做到 1308 这道题时会有一个共同感受:题目看懂了,表结构也顺手,但自己一写就发现跟正确答案对不上。这道题原名叫 Running Total for Different Genders,中文一般翻译成“不同性别每日分数总计”,题号 1308,难度中等。它最核心的考点不是 JOIN 也不是子查询,而是你有没有真正理解“每个日期的总计”到底指的是当天独立分数,还是包括之前所有天的累计分数。
如果按后者理解,那这道题就是在考察 SQL 窗口函数里的累计求和写法:SUM(...) OVER (PARTITION BY gender ORDER BY day)。但这里是 LeetCode,表结构里藏着不少细节,只记住一个窗口函数模板是不够的。我当初第一次刷这题就被“同名次、同日期的多个选手”给坑了,跑出来结果居然比预期答案大了好几倍。这篇文章就把 1308 从头到尾拆开讲,包括表结构、题意、错误写法、标准解法,以及我认为真正值得记住的一整套分析套路。
1. 先看需求:1308 到底想算一个什么“总计”
1.1 表结构里的主键设定,直接决定了解题方向
先给表结构。LeetCode 原题里给的 Scores 表类似下面这样:
sql复制CREATE TABLE Scores (
player_name VARCHAR(100),
gender VARCHAR(10),
day DATE,
score_points INT,
PRIMARY KEY (gender, day, player_name)
);
这里最重要的信息是主键是 (gender, day, player_name)。也就是说,同一场比赛里,同一天、同一性别下可能会有多个不同玩家出现。这不是一个“每天每种性别只有一条记录”的简单维度表,而是一张偏明细事实表的表结构。
这一点很关键。很多 SQL 新手刷题时,看到一张表先不关心主键,上来就写 SQL,结果后面错误频出。刷题经验丰富的人拿到表结构第一件事就是确认三件事:
- 表里每一行代表什么粒度?是“一个玩家一天的一场比赛记录”,还是“一个性别在某天的汇总”;
- 有没有可能存在同一个分组键下出现多行的情况;
- 如果存在多行,是先聚合还是先开窗。
在 Scores 表中,粒度就是一个选手在某个日期贡献的一次分数。所以“每个性别每天”要往下收敛一层,分组键是 gender + day,但分组前表里同一个 gender + day 可能有多行。
1.2 “每天的总分”是当日值,还是运行累计值
表结构看完以后,还要看输出要求。这题英文原题叫 Running Total for Different Genders,光看 Running Total 就知道它不是要每天独立分数,而是要“截至某一天为止的历史累计分数”。
题目的输出列是:
text复制gender | day | total
示例表格里也不会明确提醒你这东西叫 running total,但如果你把样例数据手算一遍,会发现每个日期对应的并不是当天新增分数,而是截至当天的累计分数。比如男性在 2020-01-01 当天有两个人参赛,分别得 17 分和 8 分;到了 2020-01-07,又有一个男性得 25 分。这一天的输出 total 不是 25,而是 17 + 8 + 25 = 50。
也就是说,你要计算的满足条件是:
text复制total(某性别, 某天) = 该性别所有 <= 该天的分数之和
弄错这个口径的人,最简单的错误写法就是:
sql复制SELECT gender, day, SUM(score_points) AS total
FROM Scores
GROUP BY gender, day
ORDER BY gender, day;
这样算出来的是每日独立新增,不是累计值。如果题目要求的是每天分组统计,那这样写没问题。但 1308 不是这个意思。
1.3 用样例数据手算一遍,确认预期输出
假设 Scores 表里有下面这些数据:
| player_name | gender | day | score_points |
|---|---|---|---|
| Aron | M | 2020-01-01 | 17 |
| Alice | F | 2020-01-01 | 17 |
| Bao | M | 2020-01-01 | 8 |
| Jalen | M | 2020-01-07 | 25 |
| Frank | M | 2020-01-08 | 6 |
| Nikki | F | 2020-01-09 | 12 |
| Ken | M | 2020-01-09 | 11 |
| Bryan | M | 2020-01-09 | 7 |
| Vivian | F | 2020-01-10 | 11 |
| Arthi | F | 2020-01-11 | 5 |
先手算男生:
- 2020-01-01 有 Aron 17 和 Bao 8,当日新增 25,累计 25;
- 2020-01-07 有 Jalen 25,当日新增 25,累计 50;
- 2020-01-08 有 Frank 6,当日新增 6,累计 56;
- 2020-01-09 有 Ken 11 和 Bryan 7,当日新增 18,累计 74。
女生这边:
- 2020-01-01 只有 Alice 17,累计 17;
- 2020-01-09 有 Nikki 12,累计 29;
- 2020-01-10 有 Vivian 11,累计 40;
- 2020-01-11 有 Arthi 5,累计 45。
最后排序按性别升序、日期升序,输出:
text复制+--------+------------+-------+
| gender | day | total |
+--------+------------+-------+
| F | 2020-01-01 | 17 |
| F | 2020-01-09 | 29 |
| F | 2020-01-10 | 40 |
| F | 2020-01-11 | 45 |
| M | 2020-01-01 | 25 |
| M | 2020-01-07 | 50 |
| M | 2020-01-08 | 56 |
| M | 2020-01-09 | 74 |
+--------+------------+-------+
记住这个手算过程,后面所有写法和验证都会基于这组数据展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最容易走偏的思路:先分组求和,再试图累加
2.1 为什么第一反应是 GROUP BY gender, day
任何人拿到这个需求,第一直觉都是先按性别和日期做一次 GROUP BY。这种思路本身没有错,毕竟如果连“每个性别每天到底产生了多少分”都算不出来,更不用谈累计。
于是很多人的第一版 SQL 长这样:
sql复制SELECT
gender,
day,
SUM(score_points) AS total
FROM Scores
GROUP BY gender, day
ORDER BY gender, day;
上面这版跑出来以后,得到的不是预期输出。因为每一行都是某个性别在某天的新增分,而不是截至当天的累计分。以男生 2020-01-01 为例,这版输出的是 25,但题目期望的 total 就是 25,看起来对。再看 2020-01-07,这版输出是 25,但预期是 50。从这里开始就错了。
所以这道题的真正麻烦在于:你必须先得到按 gender, day 聚合后的当日分,再基于这个聚合结果继续滚动累加。两个步骤缺一不可。
2.2 光靠 GROUP BY 解决不了“滚动”问题
为什么 GROUP BY 解决不了滚动?因为 GROUP BY 的作用是“把多行压成一行”,它不跨行计算。它可以告诉你 1 月 7 日的新增是多少,但它不知道 1 月 1 日到 1 月 7 日之间一共累计了多少。想要知道历史累计,你需要读多行数据,这就是窗口函数和自连接擅长的事。
可以这样理解:GROUP BY 是横向折叠,窗口函数是纵向滚动。折叠只改变当前行的归属,滚动则是在保留当前行的同时,看看它之前有哪些行,然后一起参与计算。
刷题时最忌讳的就是不区分这两类操作。一旦发现需求里有“累计”“截至今天”“历史总和”这类字眼,就该条件反射地想到窗口函数里的排序累计,或者自连接中通过 <= 条件的跨行求和。如果需求只是“每天每个团队各自的总金额”,那 GROUP BY 就够了。1308 多了一个累计含义,因此不能只在明细表上做一层 GROUP BY。
2.3 错误写法造成的数值膨胀问题
在 Scores 这张表里,还有一个隐藏得更深的坑:同一个 gender + day 下有多个 player。如果你不管三七二十一,直接在原始表上写窗口函数:
sql复制SELECT
gender,
day,
SUM(score_points) OVER (PARTITION BY gender ORDER BY day) AS total
FROM Scores
ORDER BY gender, day;
这段 SQL 的逻辑是:按 gender 分区,在分区内按 day 排序,从分区第一行累加到当前行。但问题是,原始表里一个 gender + day 可能有多条选手记录,窗口函数会把它们每一行都当作独立的一行参与累计。例如男生在 2020-01-01 有两行:17 和 8,在窗口里 2020-01-01 这个日期会被累加两次,变成 25 后继续向下滚动,随后某个日期会把之前重复累加过的分数再算一遍,结果必然偏大。最可怕的是这种错误在某些边界用例里并不容易一眼看出来,只有当男生某天多人参赛时才暴露。
这可以看作是一道中型题里的一层进阶考验。标准做法必须先在子查询里用 GROUP BY 把同一天、同一性别收敛成一行,拿到当日分,然后在外层用窗口函数做累计。换句话说:先去重粒度和分组,再滚动累加。
3. 窗口函数版解法:先聚合成“每日粒度”,再做滚动求和
3.1 一段子查询读懂整体结构
最终推荐的解法很清晰,分两层:
sql复制WITH daily_scores AS (
SELECT
gender,
day,
SUM(score_points) AS score
FROM Scores
GROUP BY gender, day
)
SELECT
gender,
day,
SUM(score) OVER (
PARTITION BY gender
ORDER BY day
) AS total
FROM daily_scores
ORDER BY gender, day;
这段 SQL 的核心思想可以拆成三句话:
daily_scoresCTE 先按gender, day分组,把同一天同一性别的多人分数汇总为一个当日分,得到每个性别每天的“新增分数”;- 外层查询在这个结果集上使用窗口函数,
PARTITION BY gender表示男女各自独立累计,ORDER BY day表示按日期从小到大累加; - 最后再以
gender, day作为最终排序条件,和题目要求保持一致。
其实不只是 LeetCode,实际业务里这种“先聚合出每日/每小时粒度,再在这个粒度上算累计”的做法非常常见,尤其是做报表和指标看板时。
对于 MySQL 8.0、PostgreSQL、SQL Server、Oracle 等支持窗口函数的数据库,上面这段 SQL 基本都能直接跑通。如果面试环境不支持 CTE 语法,也可以改写成子查询:
sql复制SELECT
gender,
day,
SUM(score) OVER (
PARTITION BY gender
ORDER BY day
) AS total
FROM (
SELECT
gender,
day,
SUM(score_points) AS score
FROM Scores
GROUP BY gender, day
) t
ORDER BY gender, day;
两种写法的计算结果完全一样。
3.2 窗口函数执行过程中发生了什么
很多人会用窗口函数,但不清楚它什么时候执行。如果只是靠死记硬背,碰到类似题目换一层皮就可能出错。窗口函数在实际执行顺序里位于 GROUP BY / HAVING 之后,但位于最外层的 ORDER BY 之前。
所以在 1308 这题中,整个执行流程大致是:
- 从 Scores 表读数据;
- 先执行 CTE 或子查询里的
GROUP BY gender, day,生成每个性别每天一行的小表; - 在这个小表上执行窗口函数。窗口函数会为每一行计算它所属分区内的累计值,分区的划分条件是
gender相同; - 窗口内部按
day升序排序,从最早一条记录开始,把score依次相加; - 完成窗口计算后,最外层应用
ORDER BY gender, day。
下图用文字描述一下这个处理过程:male 分区里先有 2020-01-01 的 25,再遇到 2020-01-07 的 25,窗口就把前面算出来的 25 加进来变成 50;接下来是有 6 的 2020-01-08,累加得到 56;之后是有 18 的 2020-01-09,再累加得到 74。female 分区独立重复这个过程。
如果一开始不是按 gender, day 聚合后再开窗,那窗口函数就会在原始明细行之间滚动。还是那句话,窗口函数替你做的“从当前行一直看到分区第一行”的操作,是按行发生的;同一分区里同一个日期的多行并不会自动合并成一个值。
3.3 额外注意点:使用 ROWS 还是 RANGE
标准写法里省略了窗口函数的 frame 子句,默认行为是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。由于这里已经按 gender, day 聚合过,同一分区内同一个 day 只有一行,所以用 RANGE 和 ROWS 的结果是一致的。
但如果哪天你遇到一个业务场景,分区内可能存在相同排序键的多行,并且你希望只累计到当前这一行时,就最好显式写成:
sql复制SUM(score) OVER (
PARTITION BY gender
ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS total
ROWS 模式会把窗口物理地限定为从分区第一行到当前行;而默认的 RANGE 模式在遇到排序键相同的情况下,会把所有相同排序键的行都归入同一个窗口帧,导致累加结果一下子覆盖多行。
4. 不借助窗口函数的替代方案:自连接与关联子查询
窗口函数不是万能的,有些数据库版本比较老,不支持窗口函数。这时候就要用自连接或关联子查询来解决问题。这部分的价值不只是应付 LeetCode,很多遗留系统里的 SQL 优化也离不开这类思路。
4.1 先聚合,再自连接
如果不使用窗口函数,一个比较稳妥的做法是:先通过子查询把每日每性别的分数算出来,然后让这张“每日表”和自己做大等于连接。SQL 可以写成:
sql复制WITH daily_scores AS (
SELECT
gender,
day,
SUM(score_points) AS score
FROM Scores
GROUP BY gender, day
)
SELECT
a.gender,
a.day,
SUM(b.score) AS total
FROM daily_scores a
JOIN daily_scores b
ON a.gender = b.gender
AND a.day >= b.day
GROUP BY a.gender, a.day
ORDER BY a.gender, a.day;
这里的逻辑是:对于男生 2020-01-07 这一行,我们从 b 表中找出所有男生日期小于等于 2020-01-07 的行,它们分别是 2020-01-01 和 2020-01-07,再把它们的当日分加起来,得到 25 + 25 = 50。
这种思路完全没有窗口函数,只依赖 JOIN 和 GROUP BY。缺点是性能一般,因为表会与自己发生笛卡尔积式的连接。如果每天数据 100 行,那么同一个性别内部会产生 5050 行中间结果;如果 Daily 表很大,这种自连接可能会非常慢。窗口函数本质上就是数据库在内部优化这种累计计算,不管从性能还是可维护性来看都更优。不过这种写法可以帮助你理解累计计算的本质:把小于等于自己的所有历史行拼在一起再求和。
4.2 关联子查询写法
还有一种非常符合直觉的写法:对每一行聚合后的记录,去查一遍“从开始到今天”的分数总和。关联子查询在这里的特点是,内层查询需要引用外层查询的字段。
sql复制SELECT
d.gender,
d.day,
(
SELECT SUM(s2.score_points)
FROM Scores s2
WHERE s2.gender = d.gender
AND s2.day <= d.day
) AS total
FROM (
SELECT DISTINCT gender, day
FROM Scores
) d
ORDER BY d.gender, d.day;
这段 SQL 从 Scores 中取出所有不同的 (gender, day),然后针对每一个组合执行一次关联子查询,统计该性别从最早日期到当日的所有分数总和。
不过需要注意的是,这里子查询里统计的是明细表 Scores 的全部分数,而不是先聚合后的分数,所以只要 WHERE s2.gender = d.gender AND s2.day <= d.day 条件正确,即使每个 gender/day 下有多个人,也不会出现重复累计问题。因为子查询会直接把符合条件的每一行分数加一遍。
4.3 直接对原始表做自连接的错误示范
你可能在网上看到过这种写法:直接拿 Scores 自连接,再按大等于日期进行连接,不先对每日分数聚合:
sql复制SELECT
s1.gender,
s1.day,
SUM(s2.score_points) AS total
FROM Scores s1
JOIN Scores s2
ON s1.gender = s2.gender
AND s1.day >= s2.day
GROUP BY s1.gender, s1.day
ORDER BY s1.gender, s1.day;
这个写法看着很简短,但它有一个非常隐蔽的错误:如果某一天同一个性别下有多个选手,s1 会展开成多行,每行 s1 都会去匹配同样的一套 s2 历史行。比如男性 2020-01-01 有两个选手 17 和 8,s1 分成两行,而 s2 在 day <= 2020-01-01 范围内也有两行,这样两两组合会产生 4 行:17+17、17+8、8+17、8+8,最后 SUM 得到 50,而不是 25。
即便外层再通过 GROUP BY s1.gender, s1.day 压缩行数,被放大的 SUM 值并不会被纠正过来。这个问题在最初几天看不出,但如果某日多人参赛,total 就会虚高。所以实际项目中要对这种直接自连接非常谨慎。
正确的自连接需要先做一次“每日汇总”,把粒度统一成 (gender, day) 一行,之后再进行两两比较。这恰好也说明为什么窗口函数能够成为现代 SQL 处理累计场景的首选:它把“连接后可能膨胀”这个问题完全规避了。
4.4 三种方案对比
| 方案 | 是否依赖窗口函数 | 主要风险 | 适用场景 |
|---|---|---|---|
| 先聚合,再窗口函数 | 是 | 数据库版本需支持窗口函数 | 标准做法,8.0+ / PG / SQL Server / Oracle 均可 |
| 先聚合,再自连接 | 否 | JOIN 会产生较大中间结果,性能开销高 | 老数据库不支持窗口函数 |
| 关联子查询 | 否 | 每一行都执行一次子查询,数据量大时慢 | 数据量小或作为理解题意的辅助写法 |
5. 边界条件与验证:数据不全时题目会不会翻车
5.1 某一天某个性别没有参赛,结果里要不要出现那行
如果某天没有男生参赛,结果表里不会出现男生的那天。LeetCode 1308 的答案不要求补零,因为 Scores 表里没有那天的记录。这个逻辑和很多真实业务需求不同,真实报表经常要拼接一个日期维表把缺失日期补出来。所以做这道题不建议自己脑补补零逻辑,否则反而画蛇添足。当然,如果面试官追问“业务上你希望缺失日期为 0 该怎么处理”,这属于后续可以拓展的话题。
5.2 只有一个性别参赛时,窗口函数结果是否正常
正常,PARTITION BY gender 之后,另一个分区的行不存在就不会出现在结果里。窗口函数只处理实际存在的行。因此不需要额外加 WHERE 条件限制性别。
5.3 日期不是连续日期,累计是否正确
日期不连续完全不影响。窗口函数的 ORDER BY day 只要求排序正确,不需要日期之间连续。它会把“所有小于等于当前日期的行”一起累加,而不是只找前一天。这一点和“相邻两天差值”类问题不同。
如果日期是字符串类型并且格式不标准,比如 2020/1/7 这种非零填充格式,则排序可能会出现 2020/1/7 排在 2020/1/10 后面的情况。因此实际开发中,如果数据源能用 DATE 类型最好用 DATE 类型,不能的话至少统一成 YYYY-MM-DD 再做排序。
5.4 如何快速验证 SQL 是否正确
写完 SQL 后,不要急着提交,先在本地脑内或者实际环境里跑一遍样例数据。验证方法很简单:挑一个有多人参赛的日期,比如 2020-01-01 的 M 或者 2020-01-09 的 M,手工算一下该日在结果中的 total 是否等于“该性别从第一天到这一天所有分数的总和”。
如果发现 2020-01-09 的 M total 正好等于 74,那基本可以判断逻辑没问题。这也是为什么我建议刷题时不要只看输出对不对,要通过中间过程判断自己是否误解了题意。遇到 Running Total 类题目,只要发现较早日期数值对、越往后偏大,多半就是窗口没有建立在已经聚合的粒度上。
6. 从 1308 引申到真实场景:累计统计不要只会背模板
6.1 现实业务中这类问题非常常见
LeetCode 里的表名和业务字段都比较抽象,但“每个性别每日分数总计”搬到现实中几乎无处不在。比如下面这些场景:
- 电商报表:每个品类的每日 GMV,以及截至每天的累计 GMV;
- 用户运营:每个注册渠道每天新增用户数,以及渠道累计用户数;
- 财务系统:每个部门每天的报销金额,以及截至当天的累计报销额;
- 内容平台:每个作者每天的阅读量,以及历史累计阅读量。
如果把这些需求里的 gender 替换成 category,把 score_points 替换成 amount,本质就是完全相同的 SQL 结构。所以 1308 这个题目训练的不是背窗口函数语法,而是建模能力:先想清楚最终输出表的粒度,再决定怎么聚合、怎么开窗、怎么排序。
6.2 做题时的一个通用分析模板
我现在碰到这类 SQL 题,通常会按四步走:
- 理解输出粒度:结果是
(gender, day)为一行,还是(gender, day, player_name)为一行? - 判断表当前粒度:原始表的粒度是什么?是否需要先聚合到输出粒度?
- 判断计算方式:如果是每日独立统计,只需 GROUP BY。如果是累计到当前,则要考虑窗口函数。
- 确认窗口顺序:窗口函数内部要不要 PARTITION BY、ORDER BY、ROWS BETWEEN 等限制。
这套模板适用于很多 SQL 题目。1308 正好能把这四步都覆盖一遍,所以它是很好的训练题。真正理解了这套分析方式,以后遇到“部门月度累计”“渠道日活累计”等各类题目,都能快速套用。
6.3 性能层面的一些思考
窗口函数在多数数据库里会在内存或临时磁盘中完成排序和累计,相比逐行关联子查询,通常性能更可控。真实场景下,数据量大时还需要考虑索引和分区。针对累计项,数据库经常很难直接走普通 B+ 树索引加速类似 SUM(...) OVER (ORDER BY day) 的窗口计算,因为窗口函数需要读取当前分区内大量有序数据。如果实际表达到千万行以上,建议先在子查询层面过滤出需要的业务范围,再在少量数据上做窗口计算。
另外,如果有按天增量落库的日志表,每天只跑当天数据的累计,比较常见的做法是先读历史累计结果表的最新值,再加上当天新增,更新到目标表。这也是为什么很多报表系统不会对整张历史大表反复执行窗口函数,而是会把“当日新增”和“累计结果”作为两张表分开维护。LeetCode 题目里往往不会涉及这类工程化处理,但如果你有真实项目经验,可以在面试时提到这种增量思路,会是不错的加分点。
6.4 最后的个人复习心得
1308 这道题我在不同时间段刷过好几次,每次都有点新认识。第一次只知道背 SUM() OVER (PARTITION BY ... ORDER BY ...);第二次意识到要先 GROUP BY 聚合,否则重复计算;第三次开始琢磨其他 AC 写法时,才发现自连接、关联子查询各有各的边界问题。刷 SQL 题不建议只把正确答案跑通就翻篇。最好问自己一句:如果表里有同一天多条明细,我这个 SQL 还正确吗?如果数据库不支持窗口函数,我能不能用自连接完成?如果累计条件不是“从最早到今天”,而是“最近 7 天”,我该怎么改 SQL?
这几个问题都想明白以后,再回头看 1308,题目本身已经不重要了,它只是帮你把运行总计相关知识点串成了一个体系。平时项目里真正写大段 SQL 的机会可能不多,但每当遇到“截至当前这个时间点的累计值多少”这种问题,脑中能立刻想到分区、排序、聚合粒度和窗口函数之间的关系,这就是刷这道题最大的回报。
