Gąsienica

Znajdowanie monotonicznych rozwiązań

Teoria

Algortym Gąsienicy

Motywacja algorytmu

Wyobraźmy sobie, że posiadamy budkę z lodami. Mamy też do dyspozycji analizę sprzedaży z ostatnich kilku lat, która pokazuje nam, ile lodów sprzedamy każdego dnia, zobacz Rysunek 1a.

Oczywiście liczba sprzedanych lodów każdego dnia nie może być ujemna, więc w tym zadaniu zakładamy, że wszystkie liczby są nieujemne. To założenie będzie bardzo przydatne w dalszej części lekcji.

Teraz zastanawiamy się ile najwięcej lodów możemy sprzedać. Utrzymanie budki każdego dnia może być nieopłacalne, gdyż musimy opłacić prąd, wodę, wynagrodzenie pracowników i inne koszty. Dodatkowo zwijanie budki i ponowne jej rozstawianie również sporo kosztuje.

Dlatego chcemy znaleźć najkrótszy, spójny okres, w którym sprzedamy co najmniej MM lodów, zobacz Rysunek 1b.

Dla uproszczenia przyjmijmy w tej lekcji, że M=10M=10.

Szukamy najkrótszego fragmentu o sumie co najmniej M = 10.
Rysunek 1a. Szukamy najkrótszego fragmentu o sumie co najmniej M=10M = 10.
Przykładowy okres, w którym sprzedamy co najmniej M=10 lodów. Czy istnieje krótszy przedział?
Rysunek 1b. Przykładowy okres, w którym sprzedamy co najmniej M=10M=10 lodów. Czy istnieje krótszy przedział?

Rozwiązania brutalne

Na początku warto zastanowić się nad prostszymi sposobami rozwiązania tego zadania.

Pierwsze rozwiązanie

Możemy sprawdzić każdy możliwy przedział [l,r][l,r], w którym 1≤l≤r≤n1 \le l \le r \le n.

Dla każdego takiego przedziału liczymy jego sumę, jeśli wynosi co najmniej 1010 to aktualizujemy wynik uwzgledniająć nową długość r−l+1r-l+1. Na końcu wybieramy najmniejszą taką długość.

To rozwiązanie jest poprawne, ale bardzo wolne. Dlaczego? Bo wszystkich przedziałów jest około n2n^2, a policzenie sumy w jednym z nich może wymagać przejrzenia nawet nn elementów. Daje to łącznie około n3n^3 operacji.

To zdecydowanie za dużo dla dużych danych.

Drugie rozwiązanie

Ustalmy lewy koniec ll na 66 i zastanówmy się dla jakich końców rr, suma przedziału [6,r][6, r] jest mniejsza niż 1010, a dla jakich jest co najmniej 1010.

Czerwone pola oznaczają koniec, dla którego suma jest mniejsza niż 10, a zielone pola oznaczają koniec, dla którego suma jest co najmniej 10.
Rysunek 2. Czerwone pola oznaczają koniec, dla którego suma jest mniejsza niż 1010, a zielone pola oznaczają koniec, dla którego suma jest co najmniej 1010.

Ponieważ wszystkie liczby są nieujemne, to widzimy pewną monotoniczność. Najpierw występują same czerwone pola - dla tych końców suma jest zbyt mała. Następnie pojawia się pierwsze zielone pole - dla tego końca suma jest co najmniej 1010. Wszystkie kolejne pola są również zielone.

Dla początku l=6l=6 interesuje nas zatem najbliższe zielone pole. To właśnie jest optymalny prawy koniec dla lewego końca l=6l=6.

A zatem możemy przyspieszyć nasze rozwiązanie. Ustalamy lewy koniec ll a następnie przesuwamy prawy koniec rr, zaczynając od ll, aż znajdziemy pierwszy przedział, którego suma jest co najmniej 1010. Dla ustalonego ll wykonamy co najwyżej nn operacji.

Ponieważ musimy sprawdzić wszystkie możliwe lewe końce, to w sumie wykonamy około n2n^2 operacji.

To nadal za dużo, ale już coraz lepiej rozumiemy jak wygląda struktura rozwiązania.

Czy da się jeszcze lepiej?

Obserwacja

Zanim przejdziemy do właściwego algorytmu, zauważmy jedną ważną rzecz.

Dla każdego lewego końca, zaznaczmy optymalny prawy koniec - czyli najbliższy koniec, dla którego suma wynosi co najmniej 1010; zobacz Rysunek 3.

Optymalne prawe końce dla dla kolejnych początków.
Rysunek 3. Optymalne prawe końce dla dla kolejnych początków.

Na rysunku widać, że gdy lewy koniec przesuwa się w prawo, prawy koniec może przesunać się tylko w prawo. Czasami zostaje w tym samym miejscu, ale nigdy się nie cofa.

Obserwacja Jeśli znamy najlepszy prawy koniec dla lewego końca ll, to dla lewego końca l+1l+1 najlepszy prawy koniec — o ile istnieje — nigdy nie przesunie się w lewo. Może zostać w tym samym miejscu albo przesunąć się w prawo.

Intuicja jest prosta: jeśli przesuwamy lewy koniec w prawo, to z przedziału usuwamy jeden element. Przez to suma może się tylko zmniejszyć.

Właściwy algorytm

Przejdźmy teraz do właściwego algorytmu, czyli algorytmu gąsienicy, zwanego też metodą dwóch wskaźników.

W tym algorytmie będziemy utrzymywać dwa wskaźniki: lewy koniec ll i prawy koniec rr. Dodatkowo utrzymujemy też zmmienną 𝑎𝑘𝑡𝑢_𝑠𝑢𝑚𝑎\mathit{aktu\_suma}, czyli sumę aktualnego przedziału.

Na początku ustawiamy lewy koniec l=1l=1 i przesuwamy prawy koniec w prawo, aż suma wyniesie co najmniej 1010; zobacz Rysunek 4. Ponieważ w każdym kroku przesuamy prawy koniec o jeden w prawo, łatwo możemy aktualizowac sumę 𝑎𝑘𝑡𝑢_𝑠𝑢𝑚𝑎\mathit{aktu\_suma}.

Gdy przedział [l,r][l,r] osiągnie sumę 1010, zapamiętujemy jego długość. Następnie zwiększamy lewy koniec do l=2l=2; zobacz Rysunek 5. Zwiększając lewy koniec, musimy pamiętać o aktualizacji sumy 𝑎𝑘𝑡𝑢_𝑠𝑢𝑚𝑎\mathit{aktu\_suma}, bo z przedziału usuwamy jeden element.

Z poprzedniej obserwacji wiemy, że nowego prawego końca nie musimy szukać od początku. Możemy zacząć od miejsca, w którym zatrzymaliśmy się wcześniej; zobacz Rysunek 6.

Jesli suma przedziału [l,r][l,r] spadnie poniżej 1010, to przesuwamy prawy koniec w prawo, aż suma znów osiągnie co najmniej 1010.

Zauważmy, że w tym algorytmie, oba wskaźniki poruszają się tylko w prawo. To będzie ważne w następnej części przy analizowaniu złożoności.

Ustawiamy l=1 i przesuwamy r do 3. Przedział [1,3] ma sumę 12.
Rysunek 4. Ustawiamy l=1l=1 i przesuwamy rr do 33. Przedział [1,3][1,3] ma sumę 1212.
Przesuwamy l z 1 na 2. Z przedziału usuwamy 3, więc suma jest mniejsza od 10, wynosi 9.
Rysunek 5. Przesuwamy ll z 11 na 22. Z przedziału usuwamy 33, więc suma jest mniejsza od 1010, wynosi 99.
Nie cofamy r. Przesuwamy ten wskaźnik z 3 na 5, aż przedział znów ma sumę co najmniej 10.
Rysunek 6. Nie cofamy rr. Przesuwamy ten wskaźnik z 33 na 55, aż przedział znów ma sumę co najmniej 1010.

Złożoność

Teraz najważniejsze pytanie: dlaczego to rozwiązanie jest istotnie szybsze od dwóch poprzednich?

Spójrzmy osobno na oba wskaźniki. Lewy koniec ll przesuwa się w prawo co najwyżej nn razy. Prawy koniec rr zaczyna od 00, nie przekracza nn i nigdy się nie cofa, więc również przesuwa się w prawo najwyżej nn razy.

Nawet jeśli dla jednego, konkretnego lewego końca, prawy koniec przesunie się wiele razy, to łącznie w całym algorytmie wykona najwyżej nn przesunięć.

A więc wszystkich przesunięć jest łącznie co najwyżej 2n2n. Dlatego całe rozwiązanie działa w czasie 𝒪(n)\mathcal{O}(n).

Typowe błędy

  1. Pominięcie warunku r<nr<n w pętli 𝑤h𝑖𝑙𝑒\mathit{while}. Bez tego prawy koniec może wyjść poza ciąg.

  2. Brak uwzględnienia zdegenerowanych przedziałów (czyli takich, że r<lr < l), które mogą się pojawić w rozwiązaniu.

Kod C++

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main (){
    ios_base::sync_with_stdio(0);
    cin.tie(0); cout.tie(0);

    int n;
    long long M;

    cin >> n >> M;
    vector<int> lody(n+1, 0);
    for(int i = 1; i <= n; i++){
        cin >> lody[i];
    }

    int res = n+1;
    int l, r = 1;
    long long aktu_suma = lody[1];

    for(l = 1; l <= n; l++){

        if( r < l ){
            r = l;
            aktu_suma = lody[l];
        }

        while(r < n && aktu_suma < M){
            r++;
            aktu_suma += lody[r];
        }

        if( aktu_suma >= M ) res = min(res, r-l+1);
        aktu_suma -= lody[l];

    }

    if( res == n+1 ) cout << "-1\n";
    else             cout << res << "\n";

    return 0;
}

input Linie 13–17

Wczytujemy dane. Musimy pamiętać, że zmienna MM może przyjmować wartości większe niż 10910^9, więc używamy long longów.

initial_values Linie 19–21

Ustawiamy wskaźniki lewego i prawego końca na 11, a zatem zmienna 𝑎𝑘𝑡𝑢_𝑠𝑢𝑚𝑎\mathit{aktu\_suma} wynosi po prostu 𝑙𝑜𝑑𝑦[1]\mathit{lody}[1].

W zmiennej 𝑟𝑒𝑠\mathit{res} będziemy trzymać najkrótszy przedział o sumie co najmiej MM. Ustawiamy jej wartość na n+1n+1, gdyż optymalny wynik nie może być większy niż długość ciągu.

corner_case Linie 25–28

Jeśli prawy koniec rr znajduje się wcześniej niż lewy koniec ll to mamy zdegenerowany przedział i musimy ręcznie przesunąć prawy koniec.

Czy wiesz kiedy prawy koniec znajdzie się przed lewym?

increasing_right_end Linie 30–33

Zwiększamy prawy koniec rr dopóki nie osiągniemy sumy co najmniej MM. Musimy uważać, aby nie wyjść prawym końcem poza zakres tablicy. Tak się może zdarzyć, gdy dla lewego końca ll, nie istnieje optymalny prawy koniec. Innnymi słowy, wszystkie prawie końce mają za małą sumę.

update_result Linie 35–36

Jeśli wartość 𝑎𝑘𝑡𝑢_𝑠𝑢𝑚𝑎\mathit{aktu\_suma} jest co najmniej MM to aktualizujemy nasz wynik 𝑟𝑒𝑠\mathit{res}. Dodatkowo w kolejnym obrocie pętli, wskaźnik ll przesunie sie o jeden w prawo, więc to dobry moment, żeby zaakutalizować (zazwyczaj zmniejszyć) zmienną 𝑎𝑘𝑡𝑢_𝑠𝑢𝑚𝑎\mathit{aktu\_suma}.

output Linie 40–41

Wypisujemy wynik. Jeśli zmienna 𝑟𝑒𝑠\mathit{res} ma dalej wartość n+1n+1 po całym algorytmie to znaczy, że żaden przedział nie osiągnął sumy co najmiej MM, czyli wypisujemy −1-1.