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 lodów, zobacz Rysunek 1b.
Dla uproszczenia przyjmijmy w tej lekcji, że .
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ł , w którym .
Dla każdego takiego przedziału liczymy jego sumę, jeśli wynosi co najmniej to aktualizujemy wynik uwzgledniająć nową długość . Na końcu wybieramy najmniejszą taką długość.
To rozwiązanie jest poprawne, ale bardzo wolne. Dlaczego? Bo wszystkich przedziałów jest około , a policzenie sumy w jednym z nich może wymagać przejrzenia nawet elementów. Daje to łącznie około operacji.
To zdecydowanie za dużo dla dużych danych.
Drugie rozwiązanie
Ustalmy lewy koniec na i zastanówmy się dla jakich końców , suma przedziału jest mniejsza niż , a dla jakich jest co najmniej .
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 . Wszystkie kolejne pola są również zielone.
Dla początku interesuje nas zatem najbliższe zielone pole. To właśnie jest optymalny prawy koniec dla lewego końca .
A zatem możemy przyspieszyć nasze rozwiązanie. Ustalamy lewy koniec a następnie przesuwamy prawy koniec , zaczynając od , aż znajdziemy pierwszy przedział, którego suma jest co najmniej . Dla ustalonego wykonamy co najwyżej operacji.
Ponieważ musimy sprawdzić wszystkie możliwe lewe końce, to w sumie wykonamy około 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 ; zobacz Rysunek 3.
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 , to dla lewego końca 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 i prawy koniec . Dodatkowo utrzymujemy też zmmienną , czyli sumę aktualnego przedziału.
Na początku ustawiamy lewy koniec i przesuwamy prawy koniec w prawo, aż suma wyniesie co najmniej ; zobacz Rysunek 4. Ponieważ w każdym kroku przesuamy prawy koniec o jeden w prawo, łatwo możemy aktualizowac sumę .
Gdy przedział osiągnie sumę , zapamiętujemy jego długość. Następnie zwiększamy lewy koniec do ; zobacz Rysunek 5. Zwiększając lewy koniec, musimy pamiętać o aktualizacji sumy , 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 spadnie poniżej , to przesuwamy prawy koniec w prawo, aż suma znów osiągnie co najmniej .
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.
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 przesuwa się w prawo co najwyżej razy. Prawy koniec zaczyna od , nie przekracza i nigdy się nie cofa, więc również przesuwa się w prawo najwyżej 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 przesunięć.
A więc wszystkich przesunięć jest łącznie co najwyżej . Dlatego całe rozwiązanie działa w czasie .
Typowe błędy
Pominięcie warunku w pętli . Bez tego prawy koniec może wyjść poza ciąg.
Brak uwzględnienia zdegenerowanych przedziałów (czyli takich, że ), 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 może przyjmować wartości większe niż , więc używamy long longów.
initial_values Linie 19–21
Ustawiamy wskaźniki lewego i prawego końca na , a zatem zmienna wynosi po prostu .
W zmiennej będziemy trzymać najkrótszy przedział o sumie co najmiej . Ustawiamy jej wartość na , gdyż optymalny wynik nie może być większy niż długość ciągu.
corner_case Linie 25–28
Jeśli prawy koniec znajduje się wcześniej niż lewy koniec 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 dopóki nie osiągniemy sumy co najmniej . Musimy uważać, aby nie wyjść prawym końcem poza zakres tablicy. Tak się może zdarzyć, gdy dla lewego końca , nie istnieje optymalny prawy koniec. Innnymi słowy, wszystkie prawie końce mają za małą sumę.
update_result Linie 35–36
Jeśli wartość jest co najmniej to aktualizujemy nasz wynik . Dodatkowo w kolejnym obrocie pętli, wskaźnik przesunie sie o jeden w prawo, więc to dobry moment, żeby zaakutalizować (zazwyczaj zmniejszyć) zmienną .
output Linie 40–41
Wypisujemy wynik. Jeśli zmienna ma dalej wartość po całym algorytmie to znaczy, że żaden przedział nie osiągnął sumy co najmiej , czyli wypisujemy .