การรวมช่วงแยกเป็นช่วงที่ใหญ่ที่สุดที่เป็นไปได้


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 |           ==
--+---------------------------
  | ====== == ===============>

คำตอบ:


22

ข้อสมมติฐาน / คำชี้แจง

  1. ไม่จำเป็นต้องแยกความแตกต่างระหว่างinfinityและเปิดขอบเขตบน ( upper(range) IS NULL) (คุณสามารถใช้วิธีใดวิธีหนึ่ง แต่วิธีนี้ง่ายกว่า)

  2. เนื่องจากdateเป็นชนิดที่ไม่ต่อเนื่องช่วงทั้งหมดจึงมี[)ขอบเขตเป็น ค่าเริ่มต้น ตามเอกสาร:

    ในตัวชนิดช่วงint4range, int8rangeและdaterangeการใช้งานทุกรูปแบบที่เป็นที่ยอมรับว่ามีการผูกพันที่ลดลงและไม่รวมผูกพันบน; นั่นคือ, [).

    สำหรับประเภทอื่น ๆ (เช่น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)
โปรดจำไว้ว่าขอบเขตบนจะไม่รวมอยู่เสมอ

c: กลุ่มแบบฟอร์ม ( grp) โดยการนับขั้นตอนด้วยฟังก์ชั่นหน้าต่างอื่น

ในการSELECTสร้างด้านนอกช่วงจากล่างถึงบนบนในแต่ละกลุ่ม voila
คำตอบที่เกี่ยวข้องกับ SO อย่างใกล้ชิดพร้อมคำอธิบายเพิ่มเติม:

วิธีการแก้ปัญหาขั้นตอนด้วย plpgsql

ใช้งานได้กับชื่อตาราง / คอลัมน์ใด ๆ แต่ใช้ได้กับประเภท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 แต่เราสามารถทำได้ด้วยการผ่านครั้งเดียว

ซอ Fiddle

ที่เกี่ยวข้อง:

การเจาะตามปกติสำหรับการจัดการอินพุตผู้ใช้ใน SQL แบบไดนามิก:

ดัชนี

สำหรับโซลูชันแต่ละรายการดัชนี btree ธรรมดา (ค่าเริ่มต้น) rangeจะเป็นเครื่องมือสำหรับประสิทธิภาพในตารางขนาดใหญ่:

CREATE INDEX foo on test (range);

ดัชนี btree มีการใช้งานที่ จำกัด สำหรับประเภทช่วงแต่เราสามารถรับข้อมูลที่จัดเรียงไว้ล่วงหน้าและอาจสแกนดัชนีเท่านั้น


@Villiers: ฉันสนใจเป็นอย่างมากว่าโซลูชันเหล่านี้ทำงานอย่างไรกับข้อมูลของคุณ บางทีคุณสามารถโพสต์คำตอบอื่นด้วยผลการทดสอบและข้อมูลบางอย่างเกี่ยวกับการออกแบบตารางและลำดับความสำคัญของคุณ? ดีที่สุดกับEXPLAIN ( ANALYZE, TIMING OFF)และเปรียบเทียบดีที่สุดในห้าข้อ
Erwin Brandstetter

กุญแจสำคัญของปัญหานี้คือฟังก์ชั่น SQL ล้าหลัง (สามารถใช้โอกาสในการขายได้) ซึ่งเปรียบเทียบค่าของแถวที่เรียงลำดับ สิ่งนี้ไม่จำเป็นสำหรับการรวมตัวเองซึ่งสามารถใช้ในการผสานช่วงที่ทับซ้อนกันเป็นช่วงเดียว แทนที่จะเป็นช่วงปัญหาที่เกี่ยวข้องกับสองคอลัมน์ 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()สำหรับทุกแถวโดยเฉพาะอย่างยิ่งถ้ามีช่วงเปิด ...
Erwin Brandstetter

@ErwinBrandstetter ใช่นั่นเป็นปัญหาที่ฉันต้องการทดสอบ (หลังจากสุดโต่งครั้งแรกของฉันคือ 9999-12-31 :) ในเวลาเดียวกันฉันสงสัยว่าทำไมคำตอบของฉันมี upvotes มากกว่าของคุณ นี่อาจจะง่ายกว่าที่จะเข้าใจ ... ดังนั้นผู้มีสิทธิเลือกตั้งในอนาคต: คำตอบของเออร์วินดีกว่าของฉัน! โหวตที่นั่น!
dezso

3

หลายปีที่ผ่านมาฉันทดสอบวิธีการที่แตกต่างกัน (ในหมู่คนอื่น ๆ ที่คล้ายกับจาก @ErwinBrandstetter) สำหรับการรวมช่วงเวลาที่ทับซ้อนกันบนระบบ Teradata และฉันพบว่ามีประสิทธิภาพมากที่สุดต่อไปนี้ (โดยใช้ฟังก์ชั่นวิเคราะห์รุ่น Teradata ในตัว งานนั้น)

  1. จัดเรียงแถวตามวันที่เริ่มต้น
  2. ค้นหาวันที่สิ้นสุดสูงสุดของแถวก่อนหน้าทั้งหมด: maxEnddate
  3. หากวันนี้น้อยกว่าวันที่เริ่มต้นปัจจุบันคุณจะพบช่องว่าง เก็บแถวเหล่านั้นบวกกับแถวแรกภายใน PARTITION (ซึ่งระบุโดย NULL) และกรองแถวอื่น ๆ ทั้งหมด ตอนนี้คุณจะได้รับวันที่เริ่มต้นสำหรับแต่ละช่วงและวันที่สิ้นสุดของช่วงก่อนหน้า
  4. แล้วคุณก็จะได้รับแถวถัดไปที่maxEnddateใช้LEADและคุณเสร็จสิ้นเกือบ เฉพาะแถวสุดท้ายที่LEADส่งคืน a 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

1
มันทำงานกับวันที่เริ่มต้นเท่านั้นไม่จำเป็นต้องเพิ่มวันที่สิ้นสุดเรียงจากมากไปน้อย (คุณตรวจสอบช่องว่างดังนั้นสิ่งที่เป็นแถวแรกสำหรับวันที่ที่กำหนดจะจับคู่)
dnoeth

-1

เพื่อความสนุกฉันได้ยิงมัน ฉันพบว่านี่เป็นวิธีที่เร็วและสะอาดที่สุดในการทำเช่นนี้ ครั้งแรกที่เรากำหนดฟังก์ชั่นที่ผสานถ้ามีการทับซ้อนกันหรือถ้าทั้งสองอินพุตอยู่ติดกันหากไม่มีการทับซ้อนกันหรือ adjacency เราก็กลับมา daterange แรก คำแนะนำ+คือการรวมช่วงในบริบทของช่วง

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
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.