Ayrı aralıkları mümkün olan en büyük bitişik aralıklarla birleştirmek


20

Mümkün olan en büyük bitişik tarih aralıkları örtüşebilir veya örtüşmeyebilir birden fazla tarih aralıkları (yüküm yaklaşık 500, çoğu durumda 10) birleştirmeye çalışıyorum. Örneğin:

Veri:

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'));

Tablo şöyle:

 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)

Arzulanan sonuçlar:

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

Görsel sunum:

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

Yanıtlar:


22

Varsayımlar / Açıklamalar

  1. infinityÜst sınırı ( upper(range) IS NULL) ayırıp açmaya gerek yoktur . (Her iki şekilde de alabilirsiniz, ancak bu şekilde daha basit.)

  2. Yana dateayrık türüdür tüm aralıkları varsayılan sahip [)sınırları. Belgelere göre:

    Dahili aralığı türleri int4range, int8rangeve daterangetüm kullanım düşük bağlanmış ve hariç üst sınırı içeren standart bir formu; yani [).

    Diğer türler için (örneğin tsrange!) Mümkünse aynısını uygularım:

Saf SQL ile çözüm

Netlik için CTE'lerle:

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;

Ya da alt sorgular için de aynı şey daha hızlı ama daha az kolay okunabilir:

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;

Veya daha az alt sorgu düzeyiyle, ancak sıralama düzenini ters çevirerek:

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;
  • Mükemmel tersine çevrilmiş sıralama düzeni elde etmek için pencereyi ikinci adımda ORDER BY range DESC NULLS LAST(ile NULLS LAST) sıralayın. Bu, daha ucuz (üretilmesi daha kolay, önerilen dizinin sıralama düzenini mükemmel şekilde eşleştirir) ve köşe durumları için doğru olmalıdır . rank IS NULL

Açıklamak

a: Sipariş verirken range, bir pencere işleviyle üst sınırın ( ) çalışma maksimum değerini hesaplayın enddate. Basitleştirmek için
NULL sınırlarını (sınırsız) +/- ile değiştirin infinity(özel NULL durumlar yok).

b: Aynı sıralama düzeninde, bir öncekinden enddatedaha erkense startdate, bir boşluğa sahip oluruz ve yeni bir aralık başlatırız ( step).
Unutmayın, üst sınır her zaman hariç tutulur.

c: grpBaşka bir pencere işleviyle adımları sayarak gruplar ( ) oluşturun.

Dış SELECTyapıda her grupta alttan üst sınıra kadar değişir. Voila.
SO hakkında daha fazla açıklama ile yakından ilgili cevap:

Plpgsql ile prosedür çözümü

Herhangi bir tablo / sütun adı için çalışır, ancak yalnızca tür için çalışır daterange.
Döngülerle prosedürel çözümler genellikle daha yavaştır, ancak bu özel durumda, yalnızca tek bir sıralı taramaya ihtiyaç duyduğundan işlevin önemli ölçüde daha hızlı olmasını beklerim :

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;

Aramak:

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

Mantık SQL çözümlerine benzer, ancak tek bir geçişle yapabiliriz.

SQL Fiddle.

İlgili:

Dinamik SQL'de kullanıcı girişini işlemek için olağan matkap:

indeks

Bu çözümlerin her biri rangeiçin büyük tablolardaki performans için düz (varsayılan) bir btree dizini etkili olacaktır:

CREATE INDEX foo on test (range);

Bir btree dizini aralık türleri için sınırlı kullanımlıdır , ancak önceden sıralanmış veriler ve hatta yalnızca dizin içeren bir tarama elde edebiliriz.


@Villiers: Bu çözümlerin her birinin verilerinizle nasıl performans gösterdiğiyle ilgileniyorum. Belki de test sonuçları ve tablo tasarımınız ve kardinaliteleriniz hakkında bazı bilgilerle başka bir cevap gönderebilirsiniz? En EXPLAIN ( ANALYZE, TIMING OFF)iyisi ve beşinin en iyisini karşılaştırın.
Erwin Brandstetter

Bu tür problemlerin anahtarı, sıralı satırların değerlerini karşılaştıran gecikme SQL işlevidir (kurşun da kullanılabilir). Bu, örtüşen aralıkları tek bir aralıkta birleştirmek için de kullanılabilen kendi birleşme ihtiyacını ortadan kaldırdı. Range yerine, some_star, some_end adlı iki sütunu içeren herhangi bir sorun bu stratejiyi kullanabilir.
Kemin Zhou

@ErwinBrandstetter Hey, bu sorguyu anlamaya çalışıyorum (CTE'leri olan), ancak (CTE A) ' max(COALESCE(upper(range), 'infinity')) OVER (ORDER BY range) AS enddatenın ne için olduğunu anlayamıyorum ? Sadece olamaz COALESCE(upper(range), 'infinity') as enddatemı? AFAIK max() + over (order by range)tam upper(range)buraya dönecek .
user606521

1
@ user606521: Gözlemlediğiniz şey, üst sınırın aralığa göre sıralandığında sürekli büyüdüğü durumdur - bazı veri dağıtımları için garanti edilebilir ve daha sonra önerdiğiniz gibi basitleştirebilirsiniz. Örnek: sabit uzunluk aralıkları. Ancak, keyfi uzunluk aralıkları için bir sonraki aralık daha büyük bir alt sınıra sahip olabilir, ancak yine de bir alt üst sınır olabilir. Bu yüzden şimdiye kadar tüm aralıkların en büyük üst sınırına ihtiyacımız var.
Erwin Brandstetter

6

Ben bununla geldim:

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;
$$;

Hala biraz honlama gerekiyor, ancak fikir şu:

  1. aralıkların bireysel tarihlere göre patlatılması
  2. bunu yaparak, sonsuz üst sınırı bazı aşırı değerlerle değiştirin
  3. (1) 'den gelen siparişe göre aralıkları oluşturmaya başlayın
  4. union ( +) başarısız olduğunda, önceden oluşturulmuş aralığı döndürün ve yeniden başlatın
  5. son olarak, geri kalanını döndürün - önceden tanımlanmış aşırı değere ulaşıldığında, sonsuz bir üst sınır elde etmek için değeri NULL ile değiştirin

generate_series()Her sıra için koşmak bana oldukça pahalı geliyor , özellikle açık aralıklar olabilirse ...
Erwin Brandstetter

@ErwinBrandstetter evet, test etmek istediğim bir konu (ilk uç noktam 9999-12-31 :) sonra. Aynı zamanda cevabımın neden sizinkinden daha fazla oy aldığını merak ediyorum. Bunu anlamak daha kolay ... Bu yüzden, gelecekteki seçmenler: Erwin'in cevabı benimkinden daha üstündür! Oraya oy verin!
dezso

3

Birkaç yıl önce bir Teradata sisteminde çakışan dönemleri birleştirmek için farklı çözümleri (diğerleri arasında benzer bir şekilde @ErwinBrandstetter gibi) test ettim ve aşağıdakileri buldum (Analitik İşlevler kullanarak, Teradata'nın daha yeni sürümü yerleşik işlevlere sahip bu görev).

  1. satırları başlangıç ​​tarihine göre sırala
  2. önceki tüm satırların maksimum bitiş tarihini bulma: maxEnddate
  3. bu tarih geçerli başlangıç ​​tarihinden küçükse bir boşluk buldunuz. Yalnızca bu satırları ve (NULL ile gösterilen) BÖLÜM içindeki ilk satırı saklayın ve diğer tüm satırları filtreleyin. Artık her aralığın başlangıç ​​tarihini ve önceki aralığın bitiş tarihini alırsınız.
  4. Sonra sadece bir sonraki satırı maxEnddatekullanıyor LEADve neredeyse bitti. Sadece son satır için a LEADdöndürür NULL, bunu çözmek için adım 2'deki bir bölümün ve satırın tüm satırlarının maksimum bitiş tarihini hesaplayın COALESCE.

Neden daha hızlıydı? Gerçek veri adımına bağlı olarak # 2, satır sayısını büyük ölçüde azaltabilir, bu nedenle bir sonraki adımın yalnızca küçük bir alt kümede çalışması gerekir, ayrıca toplamaları kaldırır.

keman

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;  

Bu Teradata'da en hızlı olduğu için, PostgreSQL için aynı olup olmadığını bilmiyorum, bazı gerçek performans numaraları almak güzel olurdu.


Sadece aralık başlangıcına göre sipariş vermek yeterli mi? Her biri aynı başlangıç ​​ama değişen sonda üç aralığınız varsa çalışır mı?
Salman A

1
Yalnızca başlangıç ​​tarihi ile çalışır, azalan sıralama bitiş tarihini eklemenize gerek yoktur (yalnızca bir boşluğu kontrol edersiniz, bu nedenle belirli bir tarih için ilk satır ne olursa olsun eşleşir)
47'de dnoeth

-1

Eğlenmek için bir şans verdim. Bunu yapmanın en hızlı ve en temiz yöntemi olarak buldum . İlk olarak, bir çakışma varsa veya iki giriş bitişikse birleştirilen bir işlev tanımlarız, üst üste binme veya bitişiklik yoksa, sadece ilk tarih aralığını döndürürüz. İpucu +, aralıklar bağlamında bir aralık birliğidir.

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;

Sonra böyle kullanırız,

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
Pencere işlevi aynı anda yalnızca iki bitişik değeri dikkate alır ve zincirleri özler. İle deneyin ('2015-01-01', '2015-01-03'), ('2015-01-03', '2015-01-05'), ('2015-01-05', '2015-01-06').
Erwin Brandstetter
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.