news 2026/10/20 7:30:21

SQL窗口函数巧解连续日期状态区间问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL窗口函数巧解连续日期状态区间问题

LeetCode 数据库题单做到后面,会发现大量题目其实是在为真实世界的报表需求做“降维”。这道“报告系统状态的连续日期”就是非常典型的一道。题面很短:系统每天运行,成功日期和失败日期分别记在两张表里,要求把某个时间段内所有“连续状态区间”找出来,输出每个区间是成功还是失败、从哪天开始到哪天结束。很多人第一反应是“按状态分组然后取MIN、MAX不就行了”,真动手写才发现,难点全在“连续区间”四个字上。这篇文章就从头梳理这道题的完整推导过程、最稳的窗口函数解法,以及我实际写 SQL 和做报表时踩过的几个坑。

1. 题目到底在问什么:从两张状态表还原系统运行时间线

1.1 表结构与我对题意的理解

先看数据结构。题目给了两张表,一张记录失败日期,一张记录成功日期:

表名字段说明
Failedfail_date系统失败的日期,主键
Succeededsuccess_date系统成功的日期,主键

题目有个隐含前提:系统每天运行,所以每一天的状态是唯一的,要么成功、要么失败,两张表的日期不会重叠。需要输出的结果包含三列:period_state(状态)、start_date(区间开始日期)、end_date(区间结束日期),按开始日期排序。

光说概念有点抽象,我构造一个简化例子。假设只关注 2019 年 1 月 1 日到 1 月 8 日,状态分别是:

  • 1、2 日成功
  • 3、4 日失败
  • 5、6、7 日成功
  • 8 日失败

那么期望输出应该是:

period_statestart_dateend_date
succeeded2019-01-012019-01-02
failed2019-01-032019-01-04
succeeded2019-01-052019-01-07
failed2019-01-082019-01-08

注意最后一行,失败状态只有一天,也要作为一个独立区间输出,不能因为“太短”就丢。

1.2 真实业务里“连续区间”问题的样子

这道题看起来是刷题,实际上是很多报表需求的缩影。比如监控系统要统计“服务连续可用了多少天”“最近一次故障从哪天持续到哪天”;用户增长团队要统计“每个用户连续登录了多少天”“最大连续签到天数”;电商运营要看“优惠券连续发放了多少天”。这些需求背后的数学模型都一样:给一堆带有日期、带有状态标签的记录,把相同状态且日期连续的记录归成一段,然后计算每段的起止时间。

如果只按状态分组,会出大问题。比如系统 1 月成功、2 月失败、3 月又成功,直接 GROUP BY 状态会把 1 月和 3 月的成功记录合并成一条“成功区间 1 月 1 日到 3 月 31 日”,中间的失败段被彻底吞掉。所以必须先识别“连续段”,再对段做聚合。这也是为什么不能简单用GROUP BY period_state解决。

2. 连续区间分组的核心原理:为什么不是简单的前后行比较

2.1 自连接与逐行判断的困境

我第一次见这道题时,脑子里冒出来的方案是:先按日期排序,然后看每一天和前一天的日期是否相差 1 天。如果相差 1 天,说明连续;如果不连续,说明是新区间开头。这个思路没有错,但要落地非常麻烦。你要么用LAG()逐行判断后,再想方设法把“是否连续”的标记转换成区间编号;要么用自连接,把每一天往前 JOIN 半天,找出所有连续段的起点和终点,再把它们拼接起来。后者在数据量小的时候能跑,但 SQL 写出来又长又脆,遇到时间跨度大、状态切换频繁的情况,自己都能把自己绕晕。

这类问题的本质,不是“判断两个日期是否连续”,而是“给每个连续段分配一个稳定且唯一的编号”。一旦拿到了段编号,之后所有统计都变成普通的GROUP BY。所以问题核心在这里:怎么一分钱不花地给每个连续段编上号。

2.2 两个序号相减:把连续行变成同一个分组标识

这里要引入两个窗口函数编号:

  • rn_all:按日期全局排序,得到每一行在整个时间线里的位置。
  • rn_state:按状态分区,再按日期排序,得到每一行在“同一种状态内部”的位置。

看下面这个表,我按 1 月 1 日到 8 日的数据演示:

日期状态rn_allrn_statern_all - rn_state
2019-01-01succeeded110
2019-01-02succeeded220
2019-01-03failed312
2019-01-04failed422
2019-01-05succeeded532
2019-01-06succeeded642
2019-01-07succeeded752
2019-01-08failed835

仔细观察最后两列。连续的 1 号、2 号都是成功状态,rn_all和rn_state同步递增,差值都是 0;5 号到 7 号成功状态里,全局序号在涨,状态内序号也在涨,两者差值恒定在 2。反而是状态切换时,比如 3 号变成失败,rn_all从 2 跳到 3,但失败状态的rn_state从 0 开始计为 1,差值一下子从 0 跳到 2。这就是关键:同一段连续状态内,差值保持不变;一旦状态切换或日期断档,差值就会改变。

所以GROUP BY period_state, rn_all - rn_state就能把每一段连续区间分开。成功状态里差值 0 是 1-2 号,差值 2 是 5-7 号;失败状态里差值 2 是 3-4 号,差值 5 是 8 号,完美切分。

2.3 一个帮助理解的生活化类比

我给同事讲这个思路时,习惯用电影院座位来形容。假设观众按到达顺序拿到排队号,这是“全局编号”;但影厅座位是按排编号的,同一排从 1 号开始,这是“分区编号”。如果一拨人连续买走同一排的 1、2、3 号座位,他们的排队号和座位号同步增长,排队号减座位号的差值恒定。一旦中间有人买走了前一排的票,再往后的人虽然座位号也从 1 开始,但排队号已经高出很多,差值一下就变了。状态切换就像换了一排座位,“全局序号”和“分区序号”错位的程度,恰好告诉我们谁和谁属于同一个连续小团体。

2.4 只按状态编号也能做:原理等价但更脆

有朋友可能会问,我不用rn_all,只按状态内部编号行不行?比如直接ROW_NUMBER() OVER (PARTITION BY status ORDER BY date) AS rn_state,然后拿date - rn_state作为分组锚点。在“每天只有一条记录”的干净数据下,这样确实也能分组:连续两天日期每天加 1,状态内编号也每天加 1,相减后锚点不变;一旦断档,锚点右移,新段就有了新锚点。

那为什么很多题解仍然用两个编号相减?因为真实场景常有脏数据,比如同一天出现多条状态记录。如果某天系统里状态被记录了两笔,只按状态编号做日期减法就会被多出来的行干扰,连续区间的边界容易算错。两个编号相减的写法,对“先把时间线压平、再分组”这件事更鲁棒。这也是我推荐大家理解并掌握两个ROW_NUMBER()写法的原因。

3. 从零推导完整 SQL:合并时间线、编号、分组取端点

3.1 第一步:把成功和失败记录合并成一条时间线

两张表各管一种状态,但我们的连续区间要看完整时间线,所以第一步是把它们合并起来,给每一行补上状态标签。我用 CTE 来组织,逻辑清晰:

WITH all_dates AS ( SELECT fail_date AS event_date, 'failed' AS period_state FROM Failed WHERE fail_date BETWEEN '2019-01-01' AND '2019-12-31' UNION ALL SELECT success_date AS event_date, 'succeeded' AS period_state FROM Succeeded WHERE success_date BETWEEN '2019-01-01' AND '2019-12-31' )

这里用UNION ALL而不是UNION,因为两张表日期不会重叠,而且每行都有状态标签,不需要去重;即便以后遇到可能有重复的脏数据,也应该在这里先做去重,而不是依赖UNION悄悄过滤。

为什么要在这里做日期范围过滤?先记住这个动作,第 4 章我会专门讲不这么写的坑。

3.2 第二步:分别计算全局编号和状态内编号

接着在all_dates之上加窗口函数:

numbered AS ( SELECT event_date, period_state, ROW_NUMBER() OVER (ORDER BY event_date) AS rn_all, ROW_NUMBER() OVER (PARTITION BY period_state ORDER BY event_date) AS rn_state FROM all_dates )

PARTITION BY period_state是给状态分区,让成功和失败各自从 1 开始编号。ORDER BY event_date保证每个分区内按时间先后编号。两个窗口函数放在同一个查询里没有冲突,它们各自独立计算。

3.3 第三步:按“状态+差值”分组,取每个区间的起止日期

分组和聚合就很简单了:

SELECT period_state, MIN(event_date) AS start_date, MAX(event_date) AS end_date FROM numbered GROUP BY period_state, rn_all - rn_state ORDER BY start_date;

rn_all - rn_state就是上一节说的差值,它和period_state共同充当区间编号。同一段连续的日期,差值相同,MIN、MAX自然取到段首和段尾。这里要注意,SELECT里没有直接选rn_all - rn_state,只把它放在GROUP BY中,避免某些数据库对 SELECT 列和 GROUP BY 列不一致的报错。

3.4 建表验证:从数据到结果的完整过程

为了让大家能直接复现,我给出一个 MySQL 8 下的完整示例:

CREATE TABLE Succeeded ( success_date DATE PRIMARY KEY ); CREATE TABLE Failed ( fail_date DATE PRIMARY KEY ); INSERT INTO Succeeded VALUES ('2019-01-01'), ('2019-01-02'), ('2019-01-05'), ('2019-01-06'), ('2019-01-07'); INSERT INTO Failed VALUES ('2019-01-03'), ('2019-01-04'), ('2019-01-08');

然后把前面三段 SQL 拼起来执行,结果应该是:

period_statestart_dateend_date
succeeded2019-01-012019-01-02
failed2019-01-032019-01-04
succeeded2019-01-052019-01-07
failed2019-01-082019-01-08

和我的示例完全一致。把数据换成长达一年的真实数据,逻辑也不会变。

4. 提交之后我才注意到的几个坑:过滤、边界和方言差异

4.1 日期过滤放在外层,会让连续区间跨年并产生“幽灵段”

这是最容易翻车的点。假设系统在 2018 年 12 月 31 日成功,2019 年 1 月 1 日也成功,如果不在合并前过滤日期,这两行会一起进入编号,被划到同一个连续区间里。结果start_date变成 2018 年最后一天,而题目只关心 2019 年。有人可能想“那我再对结果加一个WHERE start_date >= '2019-01-01'不就行了”,但这样会把 12 月 31 日和 1 月 1 日连在一起的区间直接砍掉,而不是把起点修正为 1 月 1 日,语义就错了。

最稳妥的办法,就是像我在 3.1 写的那样:在UNION ALL之前,对每张表都单独加上BETWEEN '2019-01-01' AND '2019-12-31'。先把时间线裁剪成题目关心的范围,再谈连续性。这样跨年段自然被排除在计算之外。

4.2 单日区间不是 bug,别用 HAVING COUNT > 1 过滤

我看到不少人在写完分组后,顺手加一句HAVING COUNT(*) > 1,理由是“只有一天的区间没什么分析价值”。在真实业务里这个需求可能存在,但在这道题里是错的。失败一天也是一个完整区间,8 号单独一段,必须出现在结果里。真想过滤单日段,说明业务上对“至少连续 N 天”有要求,这属于需求变形,不是题目原本的意思。可以留到第 5 章变体里做,不要在基础解法中画蛇添足。

4.3 不同数据库的窗口函数与方言差异

窗口函数现在已经是主流数据库的标配了:MySQL 8.0、PostgreSQL、SQL Server、Oracle 都支持ROW_NUMBER() OVER (...)。但要注意几个细节:

  • SQL Server 中GROUP BY对表达式的处理比较严格,如果你SELECT rn_all - rn_state但没把它放进GROUP BY会报错。我的写法里没有把它放到 SELECT 列表,所以问题不大。
  • MySQL 8.0 之前没有窗口函数,老项目里如果还在用 5.7,需要改成用户变量模拟累加,或者先做自连接。核心思想仍然一样:构造一个随连续段变化的差值。
  • 不同数据库对日期加减的函数名不同,比如 MySQL 用DATE_SUB/DATE_ADD,SQL Server 用DATEADD。如果只把“差值”当作整数分组键,就可以完全避开日期函数,这也是我喜欢这种写法的原因之一。

4.4 真实数据没有题目承诺的那么干净:先压平再编号

题目保证两张表日期不重叠、每天恰好一条,但真实业务流里常常不是这样。比如一张“系统状态变更流水表”,一天可能记录三次状态切换,既有成功又有失败。如果直接拿流水表跑上面的 SQL,同一天会有多行,窗口函数的编号彻底乱掉,连续区间也会被切成碎片。

正确做法是先做“日粒度折叠”:按日期分组,取出当天的最终状态(或者业务上定义的快照状态),得到一张干净的“每日状态表”,然后再套用窗口函数。这一步很关键,可以说它才是真实报表里真正的重点。我遇到过不少同事,把刷题逻辑背得很熟,结果一遇到流水表就崩,原因就是漏了“压平”预处理。

5. 这套解法能迁移到哪些场景:从系统状态到用户行为分析

5.1 变体一:统计每个状态最长的一段连续日期

理解了分段原理后,所有后续统计都变成了在“段”上的普通聚合。想找“最长连续失败天数”,可以在分组结果上再加一层:

SELECT period_state, MAX(continuous_days) AS max_continuous_days FROM ( SELECT period_state, DATEDIFF(MAX(event_date), MIN(event_date)) + 1 AS continuous_days FROM numbered GROUP BY period_state, rn_all - rn_state ) t GROUP BY period_state;

这里DATEDIFF的方言差异比较大,MySQL 里DATEDIFF(MAX, MIN)返回天数差;SQL Server 里要写成DATEDIFF(day, MIN, MAX)。加 1 是因为首尾都要算进连续天数,比如 1 号和 2 号相差 1 天,但实际连续 2 天。

5.2 变体二:只有一张流水表时,统计每个用户的连续登录天数

这是最常被问到的变形。假设有一张user_login(user_id, login_date)表,每个用户每天可能登录多次,也可能漏登。先压平成“每个用户每天一条登录记录”,然后用同一个思路,只不过分区变成用户和状态标记:

SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS consecutive_days FROM ( SELECT user_id, login_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn_user, ROW_NUMBER() OVER (PARTITION BY user_id, is_login ORDER BY login_date) AS rn_flag FROM daily_login ) t GROUP BY user_id, rn_user - rn_flag;

这里的关键是is_login这个标记列。因为每日登录表已经只有登录记录,没有一个“未登录”的显式状态,所以更常见的做法是:只按用户分区编号,然后用login_date - rn_user做锚点。连续登录的日期,日期和编号同步增加,相减后锚点相同;一旦某天没登录,日期跳变但编号只加 1,锚点右移,自然地分出新段。这个写法和 2.4 里说的“状态内部编号 + 日期减法”本质一致。

5.3 变体三:连续 N 次以上才算有效区间的等级化判定

业务上经常有“连续签到 7 天发奖励”“连续失败超过 3 天触发告警”的规则。这只需要在分组聚合后加一个HAVING:

SELECT period_state, MIN(event_date) AS start_date, MAX(event_date) AS end_date, COUNT(*) AS days FROM numbered GROUP BY period_state, rn_all - rn_state HAVING COUNT(*) >= 3;

比如“连续失败超过 3 天”就取period_state = 'failed'且HAVING COUNT(*) >= 3。段编号的思想完全不变,过滤条件随意加。

5.4 面试里最常被追问的点:为什么先要把时间线压平

很多面试官顺着这道题会继续问:同一个用户一天登录三次怎么办?系统一天内有多次状态切换怎么办?这些追问的核心都在考察一件事——你清不清楚数据粒度。窗口函数、GROUP BY都只是工具,真正决定结果正确与否的,是你在编号前有没有把时间线整理成“每天一行”的稳定粒度。

我的经验是,遇到所有“连续区间”类问题,先按下面的顺序自查一遍:

  1. 记录能不能折叠成“时间 + 状态”的日粒度?
  2. 同一时间、同一状态会不会有多条重复记录?
  3. 连续的定义是什么:日期连续,还是业务上的自然日连续?
  4. 空表、单行数据、单日区间这些边界有没有验证?

这四件事都想清楚,再落 SQL 就不容易出偏差。

我个人在这道题上最大的收获,还不是记住了这个 SQL 模板,而是养成了先画“时间线”再写代码的习惯。以前遇到连续在线、连续登录、持续故障这些需求,我第一反应是写递归查询,或者在应用层循环遍历;现在反而会先停下来想:这些记录能不能被折叠成一条干净的每日状态线?如果能,后面就是两个ROW_NUMBER()加一个GROUP BY的事。这道题好就好在它把连续区间问题浓缩得很干净,不炫技,却把窗口函数、CTE、分组思想全串起来了。

多说一句:SQL 里的连续问题,本质上都是“给连续段找一个稳定身份”。你找到了这个身份,后面所有统计都水到渠成。看完文章如果觉得理解了,一定自己建一张表把示例数据跑一遍——跑通一次,比盯着题解看十遍都管用。

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

两级式三相光伏逆变并网系统Simulink仿真完整搭建与调试

几个月前我把一套“两极式三相光伏逆变并网系统”的Simulink仿真模型完整跑通了。从最开始的光伏阵列建模,到Boost升压、三相逆变、并网控制,再到最后的说明文档和参考文献整理,整个过程踩了不少坑。这篇记录打算把这套模型的搭建思路、控制链…

作者头像 李华
网站建设 2026/10/20 7:27:52

基于ESP32的鱼缸智能监控系统:GitHub项目dz-1032完整实战

做过物联网或者嵌入式DIY的朋友应该都有这种感觉:真正难的不是写代码,而是把一堆传感器、执行机构、通信协议组合成一个能持续稳定运行的小系统。这个代号为“dz-1032”的鱼缸智能监控系统,恰好就是这样一个足够典型、又足够落地的项目。它把…

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

FPGA稳定版Verilog串口收发程序设计与调试实战

串口这个老家伙,在FPGA开发里看起来人畜无害,但真正把它调稳、调透,能覆盖各种板级环境的人其实不多。我见过太多项目死在外设联调的串口上——不是偶尔乱码,就是高速率下丢字节,要么就是收发的时序余量根本经不起温度…

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

AI真的能信吗?拆解大模型的能力边界与判断框架

这几年我一直在和各种大模型打交道,从内容生成、代码辅助到数据分析,几乎每一个环节都有人在问我同一个问题:"AI到底能不能信?"问得多了,我发现大家真正缺的不是工具,而是一套稳定、清晰的判断框…

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

大型C++工程CMakeLists组织指南:多目录多目标与第三方依赖集成

简介:这份资源面向具备一定C基础、希望掌握大型工程构建管理的开发者,聚焦CMakeLists.txt在实际项目中的组织与运用。内容围绕项目初始化、子目录划分、目标属性设置、外部依赖查找、CTest测试集成、安装部署及可配置选项等核心环节展开,帮助…

作者头像 李华
网站建设 2026/10/20 7:25:40

OpenClaw 断舍离、全面切 Hermes:Agent 记忆系统到底差在哪?

OpenClaw 断舍离、全面切 Hermes:Agent 记忆系统到底差在哪? 【免费下载链接】hindsight Hindsight: Agent Memory That Learns 项目地址: https://gitcode.com/GitHub_Trending/hindsight2/hindsight "断舍离 OpenClaw、全面切换 Hermes Ag…

作者头像 李华