将单独的范围合并为最大可能的连续范围


20

我正在尝试合并多个日期范围(我的负载最多约为500,大多数情况下为10),这些日期范围可能会或可能不会重叠到最大的连续日期范围内。例如:

数据:

CREATE TABLE test (
  id SERIAL PRIMARY KEY NOT NULL,
  range DATERANGE
);

INSERT INTO test (range) VALUES 
  (DATERANGE('2015-01-01', '2015-01-05')),
  (DATERANGE('2015-01-01', '2015-01-03')),
  (DATERANGE('2015-01-03', '2015-01-06')),
  (DATERANGE('2015-01-07', '2015-01-09')),
  (DATERANGE('2015-01-08', '2015-01-09')),
  (DATERANGE('2015-01-12', NULL)),
  (DATERANGE('2015-01-10', '2015-01-12')),
  (DATERANGE('2015-01-10', '2015-01-12'));

表看起来像:

 id |          range
----+-------------------------
  1 | [2015-01-01,2015-01-05)
  2 | [2015-01-01,2015-01-03)
  3 | [2015-01-03,2015-01-06)
  4 | [2015-01-07,2015-01-09)
  5 | [2015-01-08,2015-01-09)
  6 | [2015-01-12,)
  7 | [2015-01-10,2015-01-12)
  8 | [2015-01-10,2015-01-12)
(8 rows)

所需结果:

         combined
--------------------------
 [2015-01-01, 2015-01-06)
 [2015-01-07, 2015-01-09)
 [2015-01-10, )

视觉表现:

1 | =====
2 | ===
3 |    ===
4 |        ==
5 |         =
6 |             =============>
7 |           ==
8 |           ==
--+---------------------------
  | ====== == ===============>

Answers:


22

假设/澄清

  1. 无需区分infinity和打开上限(upper(range) IS NULL)。(您可以使用任何一种方法,但是这种方法更简单。)

  2. 由于date是离散类型,因此所有范围都有默认[)范围。 每个文档:

    内置范围类型int4rangeint8rangedaterange都使用规范形式,包括下限,但不包括上限;即[)

    对于其他类型(如tsrange!),如果可能,我将强制执行相同的操作:

纯SQL解决方案

为了清晰起见,请使用CTE:

WITH a AS (
   SELECT range
        , COALESCE(lower(range),'-infinity') AS startdate
        , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
   FROM   test
   )
, b AS (
   SELECT *, lag(enddate) OVER (ORDER BY range) < startdate OR NULL AS step
   FROM   a
   )
, c AS (
   SELECT *, count(step) OVER (ORDER BY range) AS grp
   FROM   b
   )
SELECT daterange(min(startdate), max(enddate)) AS range
FROM   c
GROUP  BY grp
ORDER  BY 1;

,与子查询相同,速度更快但不太容易阅读:

SELECT daterange(min(startdate), max(enddate)) AS range
FROM  (
   SELECT *, count(step) OVER (ORDER BY range) AS grp
   FROM  (
      SELECT *, lag(enddate) OVER (ORDER BY range) < startdate OR NULL AS step
      FROM  (
         SELECT range
              , COALESCE(lower(range),'-infinity') AS startdate
              , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
         FROM   test
         ) a
      ) b
   ) c
GROUP  BY grp
ORDER  BY 1;

减少一个子查询级别,但翻转排序顺序:

SELECT daterange(min(COALESCE(lower(range), '-infinity')), max(enddate)) AS range
FROM  (
   SELECT *, count(nextstart > enddate OR NULL) OVER (ORDER BY range DESC NULLS LAST) AS grp
   FROM  (
      SELECT range
           , max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate
           , lead(lower(range)) OVER (ORDER BY range) As nextstart
      FROM   test
      ) a
   ) b
GROUP  BY grp
ORDER  BY 1;
  • 使用ORDER BY range DESC NULLS LAST(和NULLS LAST)在第二步中对窗口进行排序,以获得完全相反的排序顺序。这应该更便宜(更易于生产,与建议索引的排序顺序完全匹配),并且对于带有角的情况准确rank IS NULL

说明

a:在按排序时range,使用窗口函数计算上限()的运行最大值enddate
用+/-代替NULL边界(无界)infinity只是为了简化(没有特殊的NULL情况)。

b:按照相同的排序顺序,如果前一个排序enddate早于startdate我们的间隔,请开始一个新的范围(step)。
请记住,上限始终被排除。

cgrp通过计数另一个窗口功能的步骤来形成组()。

在外部SELECT构建中,每组的范围从下限到上限。Voilá。
与SO密切相关的答案,有更多解释:

plpgsql的过程解决方案

适用于任何表/列名称,但仅适用于type daterange
带循环的过程解决方案通常较慢,但是在这种特殊情况下,我希望该函数会更快,因为它只需要一次顺序扫描

CREATE OR REPLACE FUNCTION f_range_agg(_tbl text, _col text)
  RETURNS SETOF daterange AS
$func$
DECLARE
   _lower     date;
   _upper     date;
   _enddate   date;
   _startdate date;
BEGIN
   FOR _lower, _upper IN EXECUTE
      format($$SELECT COALESCE(lower(t.%2$I),'-infinity')  -- replace NULL with ...
                    , COALESCE(upper(t.%2$I), 'infinity')  -- ... +/- infinity
               FROM   %1$I t
               ORDER  BY t.%2$I$$
            , _tbl, _col)
   LOOP
      IF _lower > _enddate THEN     -- return previous range
         RETURN NEXT daterange(_startdate, _enddate);
         SELECT _lower, _upper  INTO _startdate, _enddate;

      ELSIF _upper > _enddate THEN  -- expand range
         _enddate := _upper;

      -- do nothing if _upper <= _enddate (range already included) ...

      ELSIF _enddate IS NULL THEN   -- init 1st round
         SELECT _lower, _upper  INTO _startdate, _enddate;
      END IF;
   END LOOP;

   IF FOUND THEN                    -- return last row
      RETURN NEXT daterange(_startdate, _enddate);
   END IF;
END
$func$  LANGUAGE plpgsql;

呼叫:

SELECT * FROM f_range_agg('test', 'range');  -- table and column name

逻辑类似于SQL解决方案,但是我们可以通过一次操作来完成。

SQL提琴。

有关:

在动态SQL中处理用户输入的常用方法:

指数

对于这些解决方案中的每一个,普通的(默认)btree索引range将对大表的性能有所帮助:

CREATE INDEX foo on test (range);

btree索引仅用于范围类型,但我们可以获取预排序的数据,甚至可以进行仅索引的扫描。


@Villiers:我将非常感兴趣这些解决方案中的每一个如何处理您的数据。也许您可以发布另一个包含测试结果的答案以及有关表设计和基数的信息?最好与EXPLAIN ( ANALYZE, TIMING OFF)五个最好的比较。
Erwin Brandstetter,2015年

这类问题的关键是比较排序行的值的滞后SQL函数(也可以使用Lead)。这样就消除了对自联接的需要,自联接也可以用于将重叠范围合并为单个范围。除了范围,任何涉及两列some_star和some_end的问题都可以使用此策略。
Kemin Zhou

@ErwinBrandstetter嘿,我试图理解此查询(带有CTE的查询),但是我不知道是什么意思(CTE A)max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddate?不能只是COALESCE(upper(range), 'infinity') as enddate吗?AFAIK max() + over (order by range)将返回upper(range)此处。
user606521

1
@ user606521:您观察到的情况是,当按范围排序时,上限持续增长-对于某些数据分布可能会得到保证,然后您可以按照建议进行简化。示例:固定长度范围。但是对于任意长度的范围,下一个范围可以具有较大的下限,但仍具有较低的上限。因此,我们需要到目前为止所有范围的最大上限。
Erwin Brandstetter

6

我想出了这个:

DO $$                                                                             
DECLARE 
    i date;
    a daterange := 'empty';
    day_as_range daterange;
    extreme_value date := '2100-12-31';
BEGIN
    FOR i IN 
        SELECT DISTINCT 
             generate_series(
                 lower(range), 
                 COALESCE(upper(range) - interval '1 day', extreme_value), 
                 interval '1 day'
             )::date
        FROM rangetest 
        ORDER BY 1
    LOOP
        day_as_range := daterange(i, i, '[]');
        BEGIN
            IF isempty(a)
            THEN a := day_as_range;
            ELSE a = a + day_as_range;
            END IF;
        EXCEPTION WHEN data_exception THEN
            RAISE INFO '%', a;
            a = day_as_range;
        END;
    END LOOP;

    IF upper(a) = extreme_value + interval '1 day'
    THEN a := daterange(lower(a), NULL);
    END IF;

    RAISE INFO '%', a;
END;
$$;

仍然需要一些磨练,但是思路如下:

  1. 将范围扩展到各个日期
  2. 为此,用一些极值替换无限上限
  3. 根据(1)的顺序,开始构建范围
  4. 当union(+)失败时,返回已经建立的范围并重新初始化
  5. 最后,返回其余值-如果达到预定义的极值,则将其替换为NULL以获取无限的上限

我发现generate_series()每一行的运行成本都很高,尤其是在可以开阔地域的情况下……
欧文·布兰德斯特

@ErwinBrandstetter是的,这是我要测试的问题(在我的第一个极端是9999-12-31以后:)。同时,我想知道为什么我的答案比您的答案更多。这可能更容易理解...因此,未来的选民:欧文的答案比我的要好!在那投票!
dezso 2015年

3

几年前,我测试了不同的解决方案(除了与@ErwinBrandstetter类似的解决方案),以合并Teradata系统上的重叠期间,发现以下最有效的解决方案(使用分析功能,较新版本的Teradata具有内置功能可用于该任务)。

  1. 按开始日期对行进行排序
  2. 查找之前所有行的最大结束日期: maxEnddate
  3. 如果该日期小于当前的开始日期,则您发现了一个差距。仅将这些行以及第一行保留在PARTITION中(用NULL表示),然后过滤所有其他行。现在,您将获得每个范围的开始日期和上一个范围的结束日期。
  4. 然后,您只需获得下一行的maxEnddate使用LEAD,就快完成了。仅对于最后一行,LEAD返回NULL,以解决此问题,并在步骤2中计算分区所有行的最大终止日期COALESCE

为什么速度更快?根据实际数据,步骤2可能会大大减少行数,因此下一步只需要对一个小的子集进行操作,此外,它还消除了聚合。

小提琴

SELECT
   daterange(startdate
            ,COALESCE(LEAD(maxPrevEnddate) -- next row's end date
                      OVER (ORDER BY startdate) 
                     ,maxEnddate)          -- or maximum end date
            ) AS range

FROM
 (
   SELECT
      range
     ,COALESCE(LOWER(range),'-infinity') AS startdate

   -- find the maximum end date of all previous rows
   -- i.e. the END of the previous range
     ,MAX(COALESCE(UPPER(range), 'infinity'))
      OVER (ORDER BY range
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS maxPrevEnddate

   -- maximum end date of this partition
   -- only needed for the last range
     ,MAX(COALESCE(UPPER(range), 'infinity'))
      OVER () AS maxEnddate
   FROM test
 ) AS dt
WHERE maxPrevEnddate < startdate -- keep the rows where a range start
   OR maxPrevEnddate IS NULL     -- and keep the first row
ORDER BY 1;  

由于这在Teradata上最快,因此我不知道PostgreSQL是否也一样,因此获得一些实际的性能数据会很好。


仅通过范围的开始订购就足够了吗?如果您具有三个范围相同且起点不同的范围是否有效?
Salman A

1
它仅适用于开始日期,无需添加以降序排序的结束日期(您仅需检查间隔,因此给定日期的第一行将
与之

-1

为了娱乐,我试了一下。我发现这是最快最干净的方法。首先,我们定义一个函数,如果有重叠或两个输入相邻,则合并,如果没有重叠或邻接,我们只返回第一个日期范围。提示+是范围范围内的范围联合。

CREATE FUNCTION merge_if_adjacent_or_overlaps (d1 daterange, d2 daterange)
RETURNS daterange AS $$
  SELECT
    CASE WHEN d1 && d2 OR d1 -|- d2
    THEN d1 + d2
    ELSE d1
    END;
$$ LANGUAGE sql
IMMUTABLE;

然后我们像这样使用它

SELECT DISTINCT ON (lower(cumrange)) cumrange
FROM (
  SELECT merge_if_adjacent_or_overlaps(
    t1.range,
    lag(t1.range) OVER (ORDER BY t1.range)
  ) AS cumrange
  FROM test AS t1
) AS t
ORDER BY lower(cumrange)::date, upper(cumrange)::date DESC NULLS first;

1
窗口函数一次仅考虑两个相邻的值,并且会丢失链。试试看('2015-01-01', '2015-01-03'), ('2015-01-03', '2015-01-05'), ('2015-01-05', '2015-01-06')
Erwin Brandstetter
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.