Najdłuższy rosnący podciąg (LIS)

Wprowadzenie

Najdłuższy rosnący podciąg (ang. Longest Increasing Subsequence, LIS) to klasyczny problem optymalizacyjny w informatyce. Ma szerokie zastosowanie w: - Analizie trendów i danych czasowych - Optymalizacji procesów - Problemach pasjansa (np. Patience Sorting) - Algorytmach graficznych (np. planowanie zadań) - Teorii gier i strategii

Definicja problemu

Dany: - Ciąg liczb arr = [a₁, a₂, ..., aₙ]

Znajdź: - Najdłuższy podciąg (niekoniecznie ciągły), w którym każdy element jest większy od poprzedniego

Przykład:

arr = [10, 9, 2, 5, 3, 7, 101, 18]

Rosnące podciągi:
- [2, 5, 7, 101]  ← długość 4
- [2, 5, 7, 18]   ← długość 4
- [2, 3, 7, 18]   ← długość 4
- [2, 3, 7, 101]  ← długość 4

LIS = 4 (jeden z powyższych)

Uwaga: Podciąg może być: - Ściśle rosnący (strictly increasing): a[i] < a[j] dla i < j - Niemalejący (non-decreasing): a[i] ≤ a[j] dla i < j

W tej lekcji skupimy się na ściśle rosnącym.


Różnica: Podciąg vs Podtablica

Przypomnienie:

Ciąg: [3, 10, 2, 1, 20]

Podciąg: [3, 10, 20] ✅ (elementy nieciągłe, zachowana kolejność)
Podtablica: [10, 2, 1] ✅ (elementy MUSZĄ być ciągłe)

[3, 20, 10] ❌ (zmieniona kolejność - nie jest podciągiem)
[3, 2, 20] ✅ (podciąg, ale NIE podtablica)

Problem LIS dotyczy PODCIĄGÓW (nieciągłych).


Podejście 1: Rekurencja naiwna

Wzór rekurencyjny

Dla każdego elementu arr[i] mamy dwa wybory: 1. Włączyć arr[i] do LIS (jeśli arr[i] > ostatni element w LIS) 2. Pominąć arr[i]

LIS(arr, n, prev) =
    0                                    jeśli n = 0
    max(
        LIS(arr, n-1, prev),            // Pomijamy arr[n-1]
        1 + LIS(arr, n-1, arr[n-1])     // Bierzemy arr[n-1] (jeśli arr[n-1] > prev)
    )

Implementacja rekurencyjna

Problem: Wykładnicza złożoność - za wolne dla n > 20.


Podejście 2: Programowanie dynamiczne O(n²)

Wzór DP

Definiujemy dp[i] jako długość najdłuższego rosnącego podciągu kończącego się na pozycji i.

dp[i] = max(dp[j] + 1) dla wszystkich j < i, gdzie arr[j] < arr[i]
dp[i] = 1 jeśli żadne j nie spełnia warunku (arr[i] jest początkiem nowego LIS)

Wynik końcowy: max(dp[0], dp[1], ..., dp[n-1])


Implementacja O(n²)

Krok po kroku:

arr = [10, 9, 2, 5, 3, 7, 101, 18]

Inicjalizacja:
dp = [1, 1, 1, 1, 1, 1, 1, 1]

i=1 (arr[1]=9):
  j=0: arr[0]=10 > 9 → nie rozszerzamy
  dp[1] = 1

i=2 (arr[2]=2):
  j=0: 10 > 2 → nie
  j=1: 9 > 2 → nie
  dp[2] = 1

i=3 (arr[3]=5):
  j=0: 10 > 5 → nie
  j=1: 9 > 5 → nie
  j=2: 2 < 5 → dp[3] = max(1, dp[2]+1) = 2 ✓
  dp[3] = 2

i=4 (arr[4]=3):
  j=0,1: nie
  j=2: 2 < 3 → dp[4] = max(1, 1+1) = 2 ✓
  j=3: 5 > 3 → nie
  dp[4] = 2

i=5 (arr[5]=7):
  j=2: 2 < 7 → dp[5] = max(1, 1+1) = 2
  j=3: 5 < 7 → dp[5] = max(2, 2+1) = 3 ✓
  j=4: 3 < 7 → dp[5] = max(3, 2+1) = 3
  dp[5] = 3

i=6 (arr[6]=101):
  ...podobnie...
  dp[6] = 4 (rozszerzając LIS kończący się na 7)

i=7 (arr[7]=18):
  ...
  dp[7] = 4

Wynik końcowy:
dp = [1, 1, 1, 2, 2, 3, 4, 4]
max(dp) = 4

Rekonstrukcja LIS (O(n²))


Podejście 3: Algorytm optymalny O(n log n)

Idea: Patience Sorting

Kluczowa obserwacja: - Chcemy utrzymywać końce potencjalnych LIS o różnych długościach - Dla każdego nowego elementu, zastępujemy najmniejszy końcowy element większy lub równy nowemu elementowi

Algorytm: 1. Utrzymuj tablicę tails, gdzie tails[i] = najmniejszy końcowy element LIS długości i+1 2. Dla każdego elementu arr[i]: - Znajdź pozycję w tails używając wyszukiwania binarnego - Zaktualizuj tails na tej pozycji

Dlaczego to działa? - tails jest zawsze posortowana - Długość tails = długość aktualnego LIS


Implementacja O(n log n)

Krok po kroku:

arr = [10, 9, 2, 5, 3, 7, 101, 18]

Początek: tails = []

num=10:
  pos = bisect_left([], 10) = 0
  pos == len(tails) → append
  tails = [10]

num=9:
  pos = bisect_left([10], 9) = 0
  Zastąp tails[0] = 9
  tails = [9]

num=2:
  pos = bisect_left([9], 2) = 0
  Zastąp tails[0] = 2
  tails = [2]

num=5:
  pos = bisect_left([2], 5) = 1
  pos == len(tails) → append
  tails = [2, 5]

num=3:
  pos = bisect_left([2, 5], 3) = 1
  Zastąp tails[1] = 3
  tails = [2, 3]

num=7:
  pos = bisect_left([2, 3], 7) = 2
  pos == len(tails) → append
  tails = [2, 3, 7]

num=101:
  pos = bisect_left([2, 3, 7], 101) = 3
  pos == len(tails) → append
  tails = [2, 3, 7, 101]

num=18:
  pos = bisect_left([2, 3, 7, 101], 18) = 3
  Zastąp tails[3] = 18
  tails = [2, 3, 7, 18]

Długość LIS = len(tails) = 4

Uwaga: tails NIE JEST samym LIS, ale jego długość = długość LIS!


Rekonstrukcja LIS (O(n log n))

Uwaga: Rekonstrukcja pełnej sekwencji w O(n log n) jest technicznie możliwa, ale bardziej skomplikowana. W praktyce często używamy algorytmu O(n²) do rekonstrukcji, a O(n log n) tylko do obliczenia długości.


Warianty problemu LIS

1. Najdłuższy niemalejący podciąg (LDS)


2. Najdłuższy malejący podciąg


3. Liczba LIS

Znajdź liczbę różnych LIS o maksymalnej długości.


Benchmark: Porównanie O(n²) vs O(n log n)

Przykładowe wyniki:

n=15:
  Rekurencja      → czas: 0.002341s, długość LIS: 6
  DP O(n²)        → czas: 0.000021s, długość LIS: 6
  O(n log n)      → czas: 0.000015s, długość LIS: 6

n=100:
  DP O(n²)        → czas: 0.001234s, długość LIS: 12
  O(n log n)      → czas: 0.000089s, długość LIS: 12

n=1000:
  DP O(n²)        → czas: 0.123456s, długość LIS: 32
  O(n log n)      → czas: 0.001234s, długość LIS: 32

n=10000:
  DP O(n²)        → czas: 12.345678s, długość LIS: 98
  O(n log n)      → czas: 0.023456s, długość LIS: 98

Wnioski:
- Dla n=10000: O(n log n) jest ~500× szybsze!
- Różnica rośnie wraz z n

Praktyczne zastosowania LIS

1. Analiza trendów giełdowych

Wyjście:

Najdłuższy okres wzrostowy: 5 dni
Dni i ceny:
  Dzień 1: 80 zł
  Dzień 2: 90 zł
  Dzień 4: 95 zł
  Dzień 5: 110 zł
  Dzień 7: 120 zł

2. Planowanie zadań z zależnościami


3. Pasjans (Patience Sorting)

Wyjście:

Liczba stosów: 4
Stosy:
  Stos 1: [10, 9, 2]
  Stos 2: [5, 3]
  Stos 3: [7]
  Stos 4: [101, 18]

Minimalna liczba stosów (= długość LIS): 4

Podsumowanie algorytmów LIS

Algorytm Złożoność czasowa Złożoność pamięciowa Rekonstrukcja Uwagi
Rekurencja naiwna O(2^n) O(n) Trudna Za wolne dla n > 20
DP O(n²) O(n²) O(n) ✅ Prosta Dobry dla n ≤ 5000
O(n log n) O(n log n) O(n) Możliwa (złożona) Najszybszy, zalecany dla n > 5000

Porównanie: LCS vs LIS

Cecha LCS LIS
Typ danych Dwie sekwencje Jedna sekwencja
Warunek Elementy wspólne Elementy rosnące
Złożoność DP O(m·n) O(n²) lub O(n log n)
Zastosowania Diff, plagiat, DNA Trendy, optymalizacja, pasjans

Co dalej?

Po opanowaniu problemu LIS, następne kroki to:

  1. Inne problemy DP na sekwencjach:
  2. Maximum Subarray (Kadane's Algorithm)
  3. Edit Distance (Levenshtein Distance)
  4. Longest Palindromic Subsequence

  5. Zaawansowane problemy kombinatoryczne:

  6. Matrix Chain Multiplication
  7. Rod Cutting Problem
  8. Partition Problem

  9. Algorytmy grafowe z DP:

  10. Shortest Path (Bellman-Ford, Floyd-Warshall)
  11. Traveling Salesman Problem (TSP)
  12. Longest Path in DAG

  13. Struktury danych zaawansowane:

  14. Segment Trees
  15. Fenwick Trees (Binary Indexed Trees)
  16. Suffix Arrays i Suffix Trees

Zadania praktyczne

Zadanie 1: Liczba usunięć

Oblicz minimalną liczbę usunięć, aby ciąg stał się rosnący. Odpowiedź: n - LIS(arr).

Zadanie 2: Longest Bitonic Subsequence

Znajdź najdłuższy podciąg, który najpierw rośnie, potem maleje. Wskazówka: LIS od początku + LDS od końca.

Zadanie 3: Wizualizacja

Stwórz wizualizację algorytmu O(n log n) pokazującą, jak zmienia się tablica tails.

Zadanie 4: LIS z wagami

Każdy element ma wagę. Znajdź rosnący podciąg o maksymalnej sumie wag (niekoniecznie najdłuższy).


Podsumowanie kursu programowania dynamicznego

Gratulacje! Ukończyłeś kurs o programowaniu dynamicznym. Poznałeś:

  1. Lekcja 46: Wprowadzenie do DP - optymalna podstruktura, nakładające się podproblemy
  2. Lekcja 47: Memoization - top-down DP, @lru_cache
  3. Lekcja 48: Tabulation - bottom-up DP, optymalizacja pamięci
  4. Lekcje 49-51: Problem plecaka - analiza, zachłanny, DP
  5. Lekcja 52: LCS - porównywanie sekwencji, diff, bioinformatyka
  6. Lekcja 53: LIS - trendy, optymalizacja, O(n log n)

Kluczowe techniki: - Identyfikacja optymalnej podstruktury - Definiowanie stanów DP - Wzory rekurencyjne i tabelaryczne - Optymalizacja pamięci (2D → 1D) - Rekonstrukcja rozwiązań (backtracking) - Wybór między memoization a tabulation

Następne kroki: - Rozwiązuj problemy na platformach: LeetCode, Codeforces, HackerRank - Studiuj zaawansowane techniki: Divide and Conquer DP, DP na drzewach - Stosuj DP w projektach praktycznych

Powodzenia w dalszej nauce algorytmów! 🚀