https://pl.spoj.com/problems/FZI_STEF
Skrócony opis problemu:
Dla podanego ciągu n liczb należy znaleźć podłańcuch o maksymalnej sumie.
Dla przypomnienia, podłańcuch to spójny podciąg.
Gdybyśmy mieli do znalezienia podciąg o maksymalnej sumie to wynikiem byłaby po prostu suma wszystkich liczb dodatnich.
Rozwiązanie naiwne:
Możemy znaleźć wszystkie podłańcuchy, obliczyć ich sumy i na koniec wypisać największą z nich.
Jak znaleźć wszystkie podłańcuchy? Ano na przykład tak, że bierzemy sobie wszystkie możliwe pary pozycji (również pozycje w parze same ze sobą) i te pary wyznaczają nam końce podłańcuchów. Dla każdego z nich liczymy ich sumę i wypisujemy największą z nich.
Złożoność: O(n^3), bo mamy \frac{n(n-1)}{2} par i dla każdej obliczamy sumę w pesymistycznym czasie O(n).
Algorytm:
int main() { int i, j, k, x, max = 0, tab[t]; get(t); for(i = 0; i < t; ++i) get(tab[i]); for(i = 0; i < t; ++i) for(j = i; j < t; ++j) { for(x = 0, k = i; k <= j; ++k) x += tab[k]; if(x > max) max = x; } print(max); }
Rozwiązanie sprytniejsze:
Możemy przyspieszyć powyższe rozwiązanie, szybciej zliczając sumy. Skorzystamy tutaj z sum prefiksowych.
Sumy prefiksowe to sumy wszystkich wyrazów ciągu od jego początku. A więc dla ciągu: 1 2 3 sumy prefiksowe to: 1 3 6.
Sumy prefiksowe możemy obliczyć w czasie liniowym. Iterujemy po prostu do tablicy i do poprzedniej sumy prefiksowej dodajemy nowy element.
Po co nam one? Ano po to, że dzięki nim możemy obliczyć sumę dowolnego podłańcucha o końcach w a i b w czasie stałym. Jak? Ano tak, że bierzemy tab[b] i odejmujemy od niego tab[a-1] (lub jeśli a=0 nic nie odejmujemy). Działa to dlatego, że tab[b] jest sumą wyrazów od początku do pozycji b. Musimy się zatem pozbyć sumy elementów przed pozycją a, a więc tab[a-1].
Złożoność: O(n^2), gdyż dla każdego z \frac{n(n-1)}{2} podłańcuchów obliczamy jego sumę w czasie stałym.
Algorytm:
int main() { int t, i, j, k, x, max = 0, tab[t]; get(t); for(i = 0; i < t; ++i) { get(tab[i]); if(i > 0) tab[i] += tab[i-1]; // w tab będą teraz sumy prefiksowe; same elementy nie są nam już potrzebne } for(i = 0; i < t; ++i) for(j = i; j < t; ++j) { x = tab[j]; if(i > 0) x -= tab[i]; if(x > max) max = x; } print(max); }
Rozwiązanie sprytniejsze nr 2:
Rozwiązanie to jest podobne do poprzedniego. Tu również będziemy zliczać sumy w czasie stałym, ale tym razem inaczej. Na bieżąco.
Zauważmy, że sprawdzamy podłańcuchy w takiej kolejności (dla n=4, indeksujemy od 0): 0-0, 0-1, 0-2, 0-3, 1-1, 1-2, 1-3, 2-2, 2-3, 3-3. Dla pierwszej pary potrzebujemy tylko pierwszego elementu. Następnie pierwszych dwóch, potem pierwszych trzech. Itd. Możemy więc na bieżąco dodawać tylko jeden element do sumy i gdy przejdziemy wszystkie podłańcuchy dla danej pozycji startowej, to będziemy zerować naszą sumę.
Złożoność: O(n^2).
Algorytm:
int main() { int t, i, j, k, x, max = 0, tab[t]; get(t); for(i = 0; i < t; ++i) get(tab[i]); for(i = 0; i < t; ++i) for(x = 0, j = i; j < t; ++j) // zerujemy sumę { x += tab[j]; // dodajemy najnowszy element do sumy if(x > max) max = x; } print(max); }
Rozwiązanie jeszcze sprytniejsze:
Do rozwiązania tego problemu możemy również użyć metody "dziel i zwyciężaj".
Polega ona na tym, że dzielimy problem na mniejsze (często 2), a następnie obliczamy dla nich rekurencyjnie wyniki schodząc do coraz mniejszych danych, aż osiągnie się skrajnie małe dane, dla których można obliczyć wynik w czasie stałym. A następnie mając te [często 2] wyniki pośrednie łączy się je w jeden, ostateczny wynik.
Tak więc w tym przypadku, podzielimy sobie tablicę naszych elementów na 2 części i znajdziemy rekurencyjnie podłańcuch o największej sumie dla każdego z nich, a następnie wybierzemy większy z nich. Wystarczy? Nie. Może się bowiem zdarzyć, że podłańcuch o największej sumie będzie przebiegał przez miejsce naszego podziału. Taką sytuację też musimy wziąć pod uwagę. Tak więc de facto wynikiem będzie maksimum z wyniku z lewej części ciągu, prawej oraz największej sumy przechodzącej przez miejsce podziału.
Pierwsze 2 wyniki obliczamy rekurencyjnie. Nie ma w tym żadnej filozofii, jak zobaczycie w poniższym kodzie. Jak jednak znajdywać podłańcuch o największej sumie przechodzący przez miejsce podziału m? Ano tak, że sprawdzamy wszystkie łańcuchy od m do początku ciągu z naszego wywołania rekurencyjnego (czyli w lewo) i bierzemy z tego największą sumę x oraz sprawdzamy wszystkie łańcuchy od m+1 do końca ciągu z naszego wywołania rekurencyjnego (czyli w prawo) i bierzemy z tego największą sumę y. Na koniec łączymy oba wyniki w x+y i to jest nasz trzeci wynik pośredni, który porównujemy z dwoma poprzednimi i wybieramy największy.
Np. dla ciągu: -2 -5 6 -2 -3 1 6 -5 najpierw dzielimy go na podciągi: -2 -5 6 -2 oraz -3 1 6 -5, następnie znajdujemy rekurencyjnie dla obu wyniki pośrednie. Odpowiednio: 6 i 7. A następnie szukamy trzeciego wyniku, biorąc za m -2 albo -3. Ja wezmę -3, bo jestem za prawicą. Tak więc idziemy od -3 w lewo i obliczamy kolejne sumy (wszystkie muszą kończyć się na -3). Są to: -3, -5, 1, -4, -6. Bierzemy z nich maxa, czyli 1. Teraz zaczynamy od elementu następującym po -3, czyli od 1. Kolejne sumy [idąc w prawo] to: 1, 7, 2. Maxem jest 7. Łączymy te sumy i wychodzi nam, że trzeci wynik pośredni to 1+7=8. Bierzemy maxa z tych trzech: 6, 7, 8. Ostateczny wynik to zatem 8.
Złożoność: O(n \log n). Ale skąd się tam wziął logarytm? Przecież nie sortujemy niczego, nie wyszukujemy niczego binarnie, nie używamy seta, itd. Złożoność ta wzięła się z rozwiązania poniższego równania rekurencyjnego:
T(n) = 2T\left(\frac{n}{2}\right) + \Theta(n)
Wykonujemy bowiem 2 wywołania rekurencyjne, ale dla połowy poprzednich danych oraz dodatkowo liczenie dodatkowego wyniku pośredniego w czasie liniowym.
Jak się rozwiązuje równania rekurencyjne?
- równania typu "dziel i rządź" lub "dziel i zwyciężaj"
T(n) = \begin{cases} c, &\text{gdy } n = 1 \\ a \cdot T\left(\frac{n}{b}\right) + d(n), &\text{gdy } n = b^k \end{cases}
..którego rozwiązanie to:
T(n) = \begin{cases} \Theta\left(n^{log_b d(b)}\right), &\text{gdy } a < d(b) \\ \Theta\left(n^{log_b a} log n\right), &\text{gdy } a = d(b) \\ \Theta\left(n^{log_b a}\right), &\text{gdy } a > d(b) \end{cases} - równania typu "jeden krok w tył"
T(n) = \begin{cases} c, &\text{gdy } n = 0 \\ a \cdot T(n-1) + d(n), &\text{gdy } n > 0 \end{cases}
...którego rozwiązanie to:
T(n) = \begin{cases} \Theta\left(a^n\right), &\text{gdy } d(n) = O\left(\frac{a^n}{n^\varepsilon}\right), \varepsilon > 1 \\ O\left(n \cdot a^n\right), &\text{gdy } d(n) = O\left(a^n\right) \\ O\left(n \cdot d(n)\right), &\text{gdy } d(n) = \omega\left(a^n\right) \end{cases}
Algorytm:
int crossing(int l, int m, int r) { int s = 0, s1 = -2e9, s2 = -2e9, i; for(i = m; i >= l; --i) { s += tab[i]; if(s > s1) s1 = s; } s = 0; // zerujemy s for(i = m+1; i <= r; ++i) { s += tab[i]; if(s > s2) s2 = s; } return s1+s2; } int half(int l, int r) { if(l == r) // skrajny warunek return tab[r]; int m = (l+r)/2; return max(half(l, m), half(m+1, r), crossing(l, m, r), 0); // bierzemy maxa z 3 wyników pośrednich i zera, żeby wynik nie był ujemny } int main() { int t, i, tab[t]; get(t); for(i = 0; i < t; ++i) get(tab[i]); print(half(0, t-1)); }
Rozwiązanie najsprytniejsze:
Możemy do problemu podejść również zachłannie. Powiedzmy, że na jakimś etapie i mamy największą możliwą do uzyskania sumę, która kończy się na elemencie tab[i] (nazwijmy ją w_i). Wtedy jeśli weźmiemy nowy element i chcemy dla niego obliczyć w_{i+1} to będzie on równy w_i+tab[i+1] jeśli w_i > 0 lub tab[i+1] w przeciwnym wypadku. Dlaczego? Bo jeśli największa możliwa suma po lewej stronie będzie ujemna, to nie opłaca jej się brać pod uwagę. Po każdym rozpatrzeniu elementu aktualizujemy końcowy wynik sprawdzając czy w_i nie jest przypadkiem od niego większa.
Możemy zauważyć też, że skoro do obliczenia w_{i+1} potrzebujemy tylko w_i i a_{i+1} to nie potrzebujemy tablicować ani w ani tab, gdyż bierzemy tylko ostatnią wartość.
Złożoność: O(n), gdyż przechodzimy wszystkie elementy tylko raz i dla każdego z nich wykonujemy tylko kilka porównań i przypisać. Należy też zwrócić uwagę, że jest to również najlepsze rozwiązanie pod względem złożoności pamięciowej, która wynosi O(1), a wynosiła zawsze O(n).
Ostateczny algorytm:
int main() { int t, w = 0, s = 0, x; get(t); while(t--) { get(x); if(w > 0) w += x; else w = x; if(w > s) s = w; } print(s); }
Uwaga: Być może zainteresuje Cię wariant dwuwymiarowy tego problemu, opisany tutaj.
To jest łatwe zadanie?
OdpowiedzUsuń