Odpowiedź na pytanie
Historia pytania. Potrzeba konsolidacji nakładających się przedziałów czasowych pochodzi z algebry przedziałowej Allena (1983) oraz wczesnych badań nad bazami danych relacyjnymi w zakresie baz danych czasowych. Systemy ubezpieczeniowe, platformy rezerwacji hotelowych oraz aplikacje harmonogramowania zasobów często napotykają ten problem, gdy wiele okresów pokrycia, rezerwacji lub okien konserwacyjnych się pokrywa i wymaga normalizacji w rozłączne, ciągłe bloki w celu dokładnego raportowania dostępności lub rozliczeń. W przeciwieństwie do prostej agregacji, operacja ta wymaga zrozumienia kolejności i ciągłości, co czyni ją standardowym testem zaawansowanej biegłości w korzystaniu z funkcji okiennych ANSI SQL.
Problem. Mając tabelę przedziałów dat zdefiniowanych przez kolumny start_date i end_date, celem jest połączenie wszystkich nakładających się lub sąsiadujących przedziałów w minimalny zestaw nie nakładających się zakresów. Podejście proceduralne utrzymywałoby bufor bieżący, porównując każdy wiersz z bieżącym połączonym zakresem, ale naruszałoby to paradygmat zestawowy SQL. Kluczowa trudność polega na identyfikacji „wysp” ciągłości bez self-joinów lub rekurencyjnych CTE, szczególnie gdy istnieją przejrzyste nakładki (zakres A nakłada się na B, B nakłada się na C, chociaż A i C nie stykają się bezpośrednio).
Rozwiązanie. Wykorzystaj funkcje okienne ANSI SQL, aby wykryć początek każdej nowej wyspy, porównując bieżący start_date z maksymalnym end_date wszystkich poprzednich wierszy w tej samej partycji. Gdy start_date przekracza poprzednią maksymalną datę końca, zaczyna się nowa wyspa; w przeciwnym razie bieżący wiersz rozszerza istniejącą wyspę. Przypisz sumę tych „flag przerwania” jako island_id, a następnie grupuj według tego identyfikatora, aby obliczyć skonsolidowane min(start_date) i max(end_date). To podejście osiąga złożoność O(n log n) dzięki sortowaniu i agregacji w jednym przebiegu.
Sytuacja z życia
Opis problemu. Międzynarodowy dostawca usług zdrowotnych prowadził bazę danych przetwarzania roszczeń, w której pacjenci posiadali wiele nakładających się polis ubezpieczeniowych—główne pokrycie od 1 stycznia do 31 marca, drugorzędne od 15 lutego do 15 kwietnia i trzeciorzędne zaczynające się 1 maja. Istniejący system generował podwójne odrzucenia roszczeń, ponieważ traktował je jako oddzielne aktywne okresy, a nie jeden ciągły blok pokrycia od 1 stycznia do 15 kwietnia, a następnie przedłużenie w maju. Biznes wymagał skonsolidowanego widoku w celu egzekwowania zasad „braku podwójnej płatności”, jednocześnie zachowując ścieżki audytu oryginalnych rekordów polis.
Rozwiązanie 1: Podejście oparte na kursorkach proceduralnych. Początkowa propozycja wykorzystała procedurę składowaną z kursorem uporządkowanym według start_date, utrzymując zmienne @current_start i @current_end. Dla każdego wiersza, jeśli start_date ≤ @current_end, kod zaktualizował @current_end do max(@current_end, end_date); w przeciwnym razie wydobył bieżący zakres i zresetował zmienne. Zalety: Koncepcyjnie proste dla programistów z tłem imperatywnym; łatwe do debugowania krok po kroku. Wady: Wymaga rozszerzeń proceduralnych PL/pgSQL lub T-SQL; wykonuje operacje wiersz po wierszu z pamięcią O(n), ale słabą wydajnością I/O; narusza wymóg czystego deklaratywnego ANSI SQL, które może działać na dowolnym zgodnym silniku.
Rozwiązanie 2: Self-join z wykryciem przejrzystości. Inne podejście wykonało self-join t1 JOIN t2 ON t1.start_date <= t2.end_date AND t1.end_date >= t2.start_date, aby znaleźć natychmiastowe nakładki, a następnie użyto rekurencyjnego CTE, aby przejść przez graf nakładek i zidentyfikować skojarzone komponenty. Zalety: Teoretycznie poprawnie obsługuje złożone relacje przejrzysto, bez funkcji okiennych. Wady: Generuje O(n²) pośrednich wierszy przed rekurencją; obliczeniowo eksplozywne dla dużych zbiorów danych; polega na rekurencyjnych CTE, które, chociaż są standardem ANSI SQL, są mniej wydajne niż funkcje okienne dla tego konkretnego problemu liniowego porządkowania.
Rozwiązanie 3: Wykrywanie luk funkcji okiennych (wybrane). Zespół wdrożył czyste rozwiązanie funkcji okiennych: flaga is_new_island = CASE WHEN start_date > MAX(end_date) OVER (PARTITION BY patient_id ORDER BY start_date ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) THEN 1 ELSE 0 END, a następnie obliczył island_id = SUM(is_new_island) OVER (PARTITION BY patient_id ORDER BY start_date). Ostateczna agregacja grupowała według patient_id, island_id. Zalety: Wykonanie w jednym przebiegu wykorzystujące standardową składnię ANSI SQL; złożoność O(n log n) ograniczona przez sortowanie; automatycznie obsługuje przejrzyste nakładki poprzez bieżącą maksymalną wartość. Wady: Wymaga ostrożnego obchodzenia się z NULL-owymi datami końca (nieokreślone pokrycie) i semantyką interwału sąsiedniego (czy styki zakresów łączą się).
Wynik. Wdrożenie skonsolidowało 2,3 miliona rekordów polis w 890 000 ciągłych bloków pokrycia w mniej niż 12 sekund na standardowym sprzęcie, zastępując 45-minutowy proces wsadowy oparty na kursorze. Zapytanie zostało zamknięte jako widok, co umożliwiło przeprowadzanie bieżących kontroli kwalifikacji oraz wyeliminowało 99% podwójnych odrzucenia roszczeń w kolejnych kwartałach.
WITH coverage_flags AS ( SELECT patient_id, start_date, end_date, CASE WHEN start_date > MAX(end_date) OVER ( PARTITION BY patient_id ORDER BY start_date ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING ) THEN 1 ELSE 0 END AS is_new_island FROM insurance_periods ), islands AS ( SELECT patient_id, start_date, end_date, SUM(is_new_island) OVER ( PARTITION BY patient_id ORDER BY start_date ) AS island_id FROM coverage_flags ) SELECT patient_id, MIN(start_date) AS consolidated_start, MAX(end_date) AS consolidated_end FROM islands GROUP BY patient_id, island_id;
Co często umyka kandydatom
Jak radzisz sobie z sąsiednimi przedziałami, które stykają się na końcach, takimi jak [1 stycznia-10] i [11 stycznia-20], a jaka modyfikacja predykatu jest wymagana?
Kandydaci często używają ścisłej nierówności start_date > previous_end_date, co traktuje sąsiednie przedziały jako oddzielne wyspy. W przypadku pokrycia w zakresie usług zdrowotnych lub ciągłego harmonogramowania, sąsiednie okresy zazwyczaj reprezentują nieprzerwaną usługę i powinny być łączone. Predykat musi uwzględniać typ interwału: dla przedziałów zamkniętych (włącznie z początkiem i końcem) użyj start_date > previous_end_date + INTERVAL '1' DAY. Dla przedziałów półotwartych [start, end) (gdzie koniec jest wyłączny), warunek start_date > previous_end_date naturalnie łączy sąsiadów, ponieważ 11 stycznia równa się 11 stycznia. ANSI SQL bezpośrednio wspiera arytmetykę interwałów, więc rozwiązanie wymaga CASE WHEN start_date > MAX(end_date) OVER (...) + INTERVAL '1' DAY THEN 1 ELSE 0 END.
Dlaczego funkcja okienna MAX(end_date) produkuje niepoprawne granice wysp, gdy wejście zawiera wartości NULL reprezentujące nieokreślone pokrycie?
ANSI SQL agregatowe funkcje okienne takie jak MAX() ignorują wartości NULL w ramce. Jeśli polisa nie ma daty zakończenia (NULL oznacza “aktualny”), MAX(end_date) w poprzednich wierszach zwraca najnowszą nie-NULL datę, co potencjalnie łączy kolejne interwały, które powinny rozpocząć nową wyspę po nieokreślonej przerwie. Kandydaci muszą rozpoznać, że NULL-y wymagają szczególnego traktowania: albo należy je odfiltrować w wstępnym CTE, albo użyć COALESCE(end_date, DATE '9999-12-31'), aby traktować nieokreślone pokrycie, jako rozszerzające się do nieskończoności. Alternatywnie, traktować NULL jako wymuszoną przerwę, używając logiki CASE WHEN end_date IS NULL THEN 0 ELSE 1 END, zapewniając, że następny wiersz rozpoczyna nową wyspę.
Jak rozszerzyłbyś tę logikę na pakowanie wielowymiarowe, na przykład konsolidując interwały osobno dla każdej kombinacji patient_id i insurance_type bez utraty atomowości?
Wielu kandydatów próbuje subzapytania lub self-joiny ręcznie partycjonowane. Prawidłowe podejście wykorzystuje klauzulę PARTITION BY w funkcjach okiennych ANSI SQL. Należy zmodyfikować specyfikację ramki do PARTITION BY patient_id, insurance_type w obliczeniach zarówno MAX(end_date), jak i SUM(is_new_island). To zapewnia, że bieżąca maksymalna wartość i licznik ID wysp resetują się dla każdej unikalnej grupy, utrzymując wydajność O(n log n) w partycjach. Brak prawidłowego partycjonowania powoduje „przenikanie”, w którym luka w harmonogramie jednego pacjenta błędnie wywołuje nową wyspę dla innego pacjenta, psując logikę konsolidacji.