【问题标题】:Merging date intervals in SQL Server在 SQL Server 中合并日期间隔
【发布时间】:2011-05-20 07:11:09
【问题描述】:

我有以下数据:

StartDate   |  EndDate
-------------------------
1982.03.02  |  1982.09.30 
1982.10.01  |  1985.01.17 
1985.06.26  |  1985.07.26 
1985.07.30  |  1991.12.31 
1992.01.01  |  1995.12.31 
1996.01.01  |  2004.05.31 
2004.06.05  |  2006.01.31 
2006.02.01  |  2011.05.20              

我需要合并任何相邻的间隔(开始日期和结束日期都包含在间隔中,因此结束于 2003.05.06 的间隔与开始于 2003.05.07 的间隔相邻),所以在这种情况下,结果集应该是:

StartDate   |  EndDate
-------------------------
1982.03.02  |  1985.01.17 
1985.06.26  |  1985.07.26 
1985.07.30  |  2004.05.31 
2004.06.05  |  2011.05.20              

对我来说,最明显的方法是使用游标迭代集合,并逐行构造结果集。但是,此功能将在一天内可能被调用数千次的代码中,在负载很重的服务器上,所以我希望没有任何性能问题。任何数据集都很小(顶部 20 行),而且数据范围很大,因此任何生成一个范围内所有日期的解决方案都是不可行的。

有没有更好的方法我没看到?


初始化代码(来自 Damien 的回答):

CREATE TABLE Periods (
    StartDate datetime NOT NULL CONSTRAINT PK_Periods PRIMARY KEY CLUSTERED,
    EndDate datetime NOT NULL
)

INSERT INTO Periods(StartDate,EndDate)
SELECT '19820302', '19820930'
UNION ALL SELECT '19821001', '19850117'
UNION ALL SELECT '19850626', '19850726'
UNION ALL SELECT '19850730', '19911231'
UNION ALL SELECT '19920101', '19951231'
UNION ALL SELECT '19960101', '20040531'
UNION ALL SELECT '20040605', '20060131'
UNION ALL SELECT '20060201', '20110520'

【问题讨论】:

  • 为什么你的结果中的最后两个时期没有合并?
  • 对不起,这个例子被搞砸了。现在已经分开了。

标签: sql sql-server tsql


【解决方案1】:

设置示例数据比编写查询花费的时间更长 - 如果您发布包含 CREATE TABLEINSERT/SELECT 语句的问题会更好。我不知道你的表叫什么,我叫我的周期:

create table Periods (
    StartDate date not null,
    EndDate date not null
)
go
insert into Periods(StartDate,EndDate)
select '19820302','19820930' union all
select '19821001','19850117' union all
select '19850626','19850726' union all
select '19850730','19911231' union all
select '19920101','19951231' union all
select '19960101','20040531' union all
select '20040605','20060131' union all
select '20060201','20110520'
go
; with MergedPeriods as (
    Select p1.StartDate, p1.EndDate
    from
        Periods p1
            left join
        Periods p2
            on
                p1.StartDate = DATEADD(day,1,p2.EndDate)
    where
        p2.StartDate is null
    union all
    select p1.StartDate,p2.EndDate
    from
        MergedPeriods p1
            inner join
        Periods p2
            on
                p1.EndDate = DATEADD(day,-1,p2.StartDate)
)
select StartDate,MAX(EndDate) as EndDate
from MergedPeriods group by StartDate

结果:

StartDate   EndDate
1982-03-02  1985-01-17
1985-06-26  1985-07-26
1985-07-30  2004-05-31
2004-06-05  2011-05-20

【讨论】:

  • 伟大的递归 CTE-fu。我有一种感觉 CTE 可以以某种方式解决这个问题,只是不知道从哪里开始。
【解决方案2】:

这是迄今为止在所有提交中表现最好的查询,执行计划中只有两个表访问(而不是三个或更多)。所有查询当然都得到索引的帮助。请注意,执行计划将此查询评为更昂贵,但实际读取和 CPU 明显更好。执行计划中的估计成本与实际性能不同。

WITH Grps AS (
   SELECT
      (Row_Number() OVER (ORDER BY P1.StartDate) - 1) / 2 Grp,
      P1.StartDate,
      P1.EndDate
   FROM
      Periods P1
      CROSS JOIN (SELECT -1 UNION ALL SELECT 1) D (Dir)
      LEFT JOIN Periods P2 ON
         DateAdd(Day, D.Dir, P1.StartDate) = P2.EndDate
         OR DateAdd(Day, D.Dir, P1.EndDate) = P2.StartDate
   WHERE
      (Dir = -1 AND P2.EndDate IS NULL)
      OR (Dir = 1 AND P2.StartDate IS NULL)
)
SELECT
   Min(StartDate) StartDate,
   Max(EndDate) EndDate
FROM Grps
GROUP BY Grp;

我认为值得一提的另一件事是,如果您使用专有结束日期(也称为“开放”结束日期)而不是封闭日期,那么在大多数情况下查询您的日期周期表会更简单且性能更好:

StartDate   | EndDate     | EndDate
(Inclusive) | (Inclusive) | (Exclusive)
---------------------------------------
1982.03.02  | 1982.09.30  | 1982.10.01
1982.10.01  | 1985.01.17  | 1985.01.18

在大多数情况下(在我看来)使用独占结束日期是最佳做法,因为它允许您更改日期列的数据类型或更改日期的分辨率,而不会影响任何查询、代码或其他逻辑。例如,如果您的日期需要精确到 12 小时而不是 24 小时,那么您需要做大量工作才能完成,而如果您使用专有结束日期,则无需更改任何内容!

如果您使用独占结束日期,我的查询将如下所示:

WITH Grps AS (
   SELECT
      (Row_Number() OVER (ORDER BY P1.StartDate) - 1) / 2 Grp,
      P1.StartDate,
      P1.EndDate
   FROM
      Periods P1
      CROSS JOIN (SELECT 1 UNION ALL SELECT 2) X (Which)
      LEFT JOIN Periods P2 ON
         (X.Which = 1 AND P1.StartDate = P2.EndDate)
         OR (X.Which = 2 AND P1.EndDate = P2.StartDate)
   WHERE
      P2.EndDate IS NULL
      OR P2.StartDate IS NULL
)
SELECT
   Min(StartDate) StartDate,
   Max(EndDate) EndDate
FROM Grps
GROUP BY Grp;

请注意,现在没有 DateAdd 或 DateDiff,如果您切换到 12 小时周期,硬编码值“1 天”就必须更改。

更新

这是一个更新的查询,其中包含了我在过去近 5 年中学到的东西。这个查询现在根本没有连接,虽然它确实有 3 个排序操作,这可能是性能问题,但我认为这个查询将竞争得相当好,并且在没有索引的情况下可能会击败所有其他人。

WITH Groups AS (
   SELECT Grp = Row_Number() OVER (ORDER BY StartDate) / 2, *
   FROM
      #Periods
      (VALUES (0), (0)) X (Dup)
), Ranges AS (
   SELECT StartDate = Max(StartDate), EndDate = Min(EndDate)
   FROM Groups
   GROUP BY Grp
   HAVING Max(StartDate) <> DateAdd(day, 1, Min(EndDate))
), ReGroups AS (
   SELECT
      Grp = Row_Number() OVER (ORDER BY StartDate) / 2,
      StartDate,
      EndDate
   FROM
      Ranges
      CROSS JOIN (VALUES (0), (0)) X (Dup)
)
SELECT
   StartDate = Min(StartDate),
   EndDate = Max(EndDate)
FROM ReGroups
GROUP BY Grp
HAVING Count(*) = 2
;

这是另一个使用窗口函数的版本(前一个查询正在模拟的那种):

WITH LeadLag AS (
   SELECT
      PrevEndDate = Coalesce(Lag(EndDate) OVER (ORDER BY StartDate), '00010101'),
      NextStartDate = Coalesce(Lead(StartDate) OVER (ORDER BY StartDate), '99991231'),
      *
   FROM #Periods
), Dates AS (
   SELECT
      X.*
   FROM
      LeadLag
      CROSS APPLY (
         SELECT
            StartDate = CASE WHEN DateAdd(day, 1, PrevEndDate) <> StartDate THEN StartDate ELSE NULL END,
            EndDate = CASE WHEN DateAdd(day, 1, EndDate) <> NextStartDate THEN EndDate ELSE NULL END
      ) X
   WHERE
      X.StartDate IS NOT NULL
      OR X.EndDate IS NOT NULL
), Final AS (
   SELECT
      StartDate,
      EndDate = Min(EndDate) OVER (ORDER BY EndDate ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
   FROM Dates
)
SELECT *
FROM Final
WHERE StartDate IS NOT NULL
;

【讨论】:

  • 周一试一试,我身上没有真实环境:)
  • @ErikE,聪明的解决方案,但是您能否将您的查询分解并解释为各个步骤?为什么减一然后除以二,是创建包含两个项目的单个分组的开始日期和结束日期的想法吗? CROSS JOIN 将列转换为不同的行; -1 和 1 的目的是什么?它们是简单的键,可以是任何两个不同的值,供以后在 LEFT JOIN 中使用吗?为什么 CTE 起作用而不是派生表?
  • @MoGauvin 好吧,那行不通。也许今晚?
  • @ErikE 我破译了 CROSS JOIN 的用法,它将列旋转到不同的行。然后用于添加或减去一天的“Dir”列是我迷路的地方。不幸的是,没有办法打印出递归 CTE 的每个步骤的结果,以查看幕后发生的事情。很难直观地了解为什么这样做有效。
  • @MoGauvin 是的,你是对的,CROSS JOIN 将每一行乘以两行,Dir​​ -1 和 1 值。这些用于直接匹配之前或之后的范围。在 WHERE 子句中消除了所有匹配的范围(只留下范围结束)。如果您从 CTE 内部查询中SELECT Dir, P1.StartDate, P1.EndDate, P2.StartDate, P2.EndDate,没有 WHERE 子句,您可以看到选择了哪些行。最后,包含 WHERE 子句和 Row_Number() 表达式,您将看到每对相邻行如何组合在一起以便将它们连接在一起。
【解决方案3】:

您可以查找标题:以句点开头的行。然后在子查询中搜索下一个头部之前的最后一个结束日期:

; with heads as
        (
        select  StartDate
        ,       EndDate
        ,       row_number() over (order by StartDate) as rn
        from    @YourTable h
        where   not exists
                (
                select  *
                from    @YourTable next
                where   next.EndDate = dateadd(day, -1, h.StartDate)
                )
        )
select  heads.StartDate
,       (
        select  top 1 EndDate
        from    @YourTable
        where   EndDate < COALESCE(
                (
                select  StartDate
                from    heads h2
                where   heads.rn + 1 = h2.rn
                ), '9999-01-01')
        order by
                EndDate desc
        ) as EndDate
from    heads

Example at ODATA.

【讨论】:

    【解决方案4】:

    嗯……我知道你说过

    在一个范围内生成所有日期的任何解决方案都是不可行的。

    但出于某种原因,我只是想展示如何做到这一点。我不想浪费你的时间。

    首先,如果您还没有数字表,请创建一个。

    CREATE TABLE Numbers (
       Num int NOT NULL CONSTRAINT PK_Numbers PRIMARY KEY CLUSTERED
    )
    INSERT Numbers VALUES (0)
    WHILE @@RowCount < 65536
       INSERT Numbers SELECT Num FROM Numbers + (SELECT Max(Num) FROM Numbers) + 1
    

    然后将一些岛屿分组!

    WITH Dts AS (
       SELECT
          DateAdd(Day, Num, StartDate) Dt,
          DateAdd(
             Day,
             -DENSE_RANK() OVER (ORDER BY StartDate, Num),
             DateAdd(Day, Num, StartDate)
          ) Grp
       FROM
          Periods P
          INNER JOIN Numbers N ON DateDiff(Day, P.StartDate, P.EndDate) >= N.Num
    )
    SELECT Min(Dt) StartDate, Max(Dt) EndDate
    FROM Dts
    GROUP BY Grp
    ORDER BY StartDate
    

    如果您使用的是 SQL 2000,这将不起作用,所以请告诉我,我会为您提供另一个解决方案。

    【讨论】:

    • Tnx 的答案。但是,我的数据集很小,但是覆盖范围很广,所以递归解决方案是最快的。
    • @SWeko 你实际测试过速度吗?我现在远离 SQL 框...请注意,如果您的日期范围小于 22 1/2 年,您可以将内置的 master.dbo.spt_values 表用于您的数字表。如果这真的很慢,我有另一个想法,但它必须等到明天。
    • 是的,我确实对它们进行了测试,递归的效果最好(我认为 Andomars 解决方案应该更快,但事实并非如此)。另外我认为 Damien 的解决方案是最容易理解的解决方案,所以我最终使用了那个解决方案。
    【解决方案5】:

    这是一个非常相似的 PostgreSQL 线程:

    PostgreSQL matching interval between start and end time against timestamp

    我对 T-SQL 只是稍微熟悉,所以我不完全确定外卖是否适用于您,但总体思路是另外存储带有 GIST(或 R-tree)索引的可索引几何类型,并对其进行查询。这将使查询非常快。

    (以下示例段代码来自 peufeu 的回复,也适用于日期范围):

    CREATE TABLE segments( start INTEGER NOT NULL, stop INTEGER NOT NULL, range_box BOX NOT NULL );
    INSERT INTO segments SELECT n,n+1,BOX(POINT(n,-1),POINT(n+1,1)) FROM generate_series( 1, 1000000 ) n;
    CREATE INDEX segments_box ON segments USING gist( range_box );
    CREATE INDEX segments_start ON segments(start);
    CREATE INDEX segments_stop ON segments(stop);
    
    EXPLAIN ANALYZE SELECT * FROM segments WHERE 300000 BETWEEN start AND stop;
     Index Scan using segments_start on segments  (cost=0.00..12959.24 rows=209597 width=72) (actual time=91.990..91.990 rows=2 loops=1)
       Index Cond: (300000 >= start)
       Filter: (300000 <= stop)
     Total runtime: 92.023 ms
    
    EXPLAIN ANALYZE SELECT * FROM segments WHERE range_box && '(300000,0,300000,0)'::BOX;
     Bitmap Heap Scan on segments  (cost=283.49..9740.27 rows=5000 width=72) (actual time=0.036..0.037 rows=2 loops=1)
       Recheck Cond: (range_box && '(300000,0),(300000,0)'::box)
       ->  Bitmap Index Scan on segments_box  (cost=0.00..282.24 rows=5000 width=0) (actual time=0.032..0.032 rows=2 loops=1)
             Index Cond: (range_box && '(300000,0),(300000,0)'::box)
     Total runtime: 0.064 ms
    

    同样,以上是 PostgreSQL 特定的,但如果 T-SQL 中所需的类型/运算符/索引也存在,可能值得一看。

    【讨论】:

      【解决方案6】:

      旧线程,但如果有人正在寻找在 PostGIS 中执行此操作的实现,这里有一个示例:

      -- Create the data:
      drop table if exists periods;
      create temporary table periods as
      select '19820302'::date as StartDate,'19820930'::date as EndDate union all
      select '19821001'::date,'19850117'::date union all
      select '19850626'::date,'19850726'::date union all
      select '19850730'::date,'19911231'::date union all
      select '19920101'::date,'19951231'::date union all
      select '19960101'::date,'20040531'::date union all
      select '20040605'::date,'20060131'::date union all
      select '20060201'::date,'20110520'::date;
      
      -- Run with PostGIS
      -- Convert all intervals to lines, and then do point intersection.
      select 
        '1970-01-01'::date+st_x(st_astext(st_pointn(line,1)))::int4 as start, 
        '1970-01-01'::date+st_x(st_astext(st_pointn(line,st_numpoints(line))))::int4-1 as end 
      from 
      (select (st_dump(st_linemerge(st_union(the_geom)))).geom as line from 
      (select st_makeline(st_makepoint(startdate-'1970-01-01',0),
              st_makepoint(enddate-'1970-01-01'+1,0)) as the_geom from periods)t 
      )x;  
      
      -- Result
      start       |  end
      -------------------------
      1982-03-02  |  1985-01-17 
      1985-06-26  |  1985-07-26 
      1985-07-30  |  2004-05-31 
      2004-06-05  |  2011-05-20  
      

      【讨论】:

        【解决方案7】:
        alter table MergedPeriods (
           StartDate date not null,
        EndDate date not null
        )
        go
        insert into MergedPeriods(StartDate,EndDate)
        select '20130210','20130215' union all
        select '20130216','20130228' union all
        select '20130302','20130312' union all
        select '20130317','20130325' union all
        select '20130326','20130405' union all
        select '20130406','20130411' union all
        select '20130502','20130610' 
        go
        ; with MergedPeriods as (
            Select p1.StartDate, p1.EndDate
            from
                [test].[dbo].[Periods] p1
                    left join
                [test].[dbo].[Periods] p2
                    on
                        p1.StartDate = DATEADD(day,1,p2.EndDate)
            where
        
               p2.StartDate is null
            union all
            select p1.StartDate,p2.EndDate
            from
                MergedPeriods p1
                    inner join
                [test].[dbo].[Periods] p2
                    on
                        p1.EndDate = DATEADD(day,-1,p2.StartDate)
        
        
        
        )
        
        select MIN(StartDate),MAX(EndDate) as EndDate
        from MergedPeriods group by StartDate
        

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2011-02-03
          • 2021-09-21
          • 2021-04-04
          • 2021-09-03
          • 1970-01-01
          相关资源
          最近更新 更多