Omówienie zadania Płytkie nawiasowania (I etap XXX OI)
Poprawność nawiasowania
Problem sprawdzenia, czy dane nawiasowanie jest poprawne, jest dość znanym i ważnym z perspektywy algorytmiki problemem. Typowe rozwiązanie polega na utrzymywaniu licznika początkowo ustawionego na \(0\) (o którym można myśleć jak o aktualnej głębokości nawiasowania, czy też poziomie zagnieżdżenia) i przejściu kolejno po wszystkich nawiasach:
- dodając \(1\) do licznika, jeśli napotkamy nawias otwierający,
- odejmując \(1\) od licznika, jeśli napotkamy nawias zamykający.
Podzadanie 1. (\(n \leq 20\))
W tym podzadaniu wystarczy, dla każdego możliwego nawiasowania długości n, sprawdzić:
- czy jest ono poprawne,
- czy jego głębokość jest niewiększa niż \(H\),
- ile nawiasów zostało odwróconych względem wyjściowego ciągu.
Sprawdzenie 1., 2. i 3. można wykonać w czasie liniowym względem długości nawiasowania, a wszystkich możliwych nawiasowań długości \(n\) jest \(2^n\), bo dla każdego indeksu \(1,\ldots,n\) są 2 możliwości: \(\text{'('}\) lub \(\text{')'}\). Rozwiązanie to działa więc w czasie \(O(2^nn)\).
Ciągi nawiasów a ciągi liczb
Poniższa obserwacja nie jest konieczna do rozwiązania tego zadania, ale może być pomocna w zrozumieniu problemu. W szczególności dalej w tekście będziemy korzystać z tej interpretacji.
Zauważmy, że nasz ciąg nawiasów możemy interpretować jako ciąg liczb całkowitych, gdzie \(\text{'('}\) odpowiada \(1\), a \(\text{')'}\) odpowiada \(-1\). Wówczas poprawność naszego ciągu oznacza dokładnie tyle, że każda suma prefiksowa tego ciągu jest nieujemna oraz suma całego ciągu wynosi 0. Ograniczenie głębokości nawiasowania przez \(H\) oznacza, że żadna z sum prefiksowych nie przekracza \(H\).
Podzadanie 2. (\(n \leq 3000\))
W tym podzadaniu można zastosować algorytm dynamiczny. Niech \(dp[i][j]\) oznacza minimalną liczbę zamian \(1 \rightarrow -1\) lub \(-1 \rightarrow 1\) (odwróceń nawiasów) na prefiksie długości \(i\) naszego ciągu potrzebnych, aby suma tego prefiksu wynosiła \(j\), a sam prefiks był poprawny, tj. każda wcześniejsza suma prefiksowa należała do przedziału \([0, H]\). Wówczas odpowiedzią do zadania będzie \(dp[n][0]\).
Stanem początkowym będą wartości \(dp[0][0] = 0\) oraz \(dp[0][j] = \infty\) dla \(j \neq 0\) (pusty ciąg ma zawsze sumę 0). A jak wyznaczamy wartość \(dp[i][j]\) na podstawie poprzednich wartości? Załóżmy, że na \(i\)-tej pozycji jest \(1\) (nawias otwierający). Mamy dwie opcje — możemy zamienić go na \(-1\) albo zostawić. Jeśli zamienimy, a chcemy uzyskać sumę \(j\), to poprzednim stanem naszego zamienionego ciągu był ten o sumie \(j+1\) z prefiksu \(i-1\). Musimy więc wybrać \(dp[i-1][j+1]\) i dodać \(1\), gdyż dokonaliśmy zamiany. Jeśli nie zamienimy, a chcemy uzyskać sumę \(j\), to poprzednim stanem naszego zamienionego ciągu był ten o sumie \(j-1\) z prefiksu \(i-1\). Musimy więc wybrać \(dp[i-1][j-1]\), ale już bez dodawania \(1\), gdyż nie dokonaliśmy zamiany. Zatem \(dp[i][j] = \min \left( dp[i-1][j+1] + 1, dp[i-1][j-1] \right)\). Analogicznie, w przypadku gdy na \(i\)-tej pozycji jest \(-1\) (nawias zamykający), to \(dp[i][j] = \min \left( dp[i-1][j-1] + 1, dp[i-1][j+1] \right)\).
Wartość \(dp[i][j]\) można obliczyć w czasie stałym na podstawie poprzednich wartości, więc przedstawiony algorytm działa w czasie \(O(nH)\).
Rozwiązanie wzorcowe (\(n \leq 10^6\))
Rozważmy dowolny ciąg nawiasów (liczb 1, -1) i pierwszą w nim pozycję \(i\) taką, że suma prefiksowa do \(i\)-tego elementu nie należy do przedziału \([0, H]\). To oznacza, że musimy odwrócić któryś z nawiasów na pozycjach \(1, 2, \ldots, i\), aby warunki zadania były spełnione. Zauważmy, że dla kolejnych prefiksów nie ma znaczenia, na których pozycjach były wykonywane zamiany, a jedynie ile ich było (i rodzaje nawiasów, które zamieniliśmy). Zatem możemy zmiany odkładać tak długo, jak to możliwe i to zapewni nam minimalną liczbę zmian nawiasów oraz należenie wszystkich sum prefiksowych do przedziału \([0, H]\).
Ta obserwacja pociąga za sobą następujące rozwiązanie zachłanne:
- Przechodzimy kolejno po nawiasach naszego nawiasowania i utrzymujemy licznik głębokości (sumę \(1\) i \(-1\) przy interpretacji jako ciąg liczb całkowitych).
- Jeśli kolejny nawias sprawiłby, że głębokość wyjdzie poza przedział \([0, H]\), to odwracamy go, a w przeciwnym wypadku zostawiamy taki, jaki był.
Uważny czytelnik zapyta jeszcze, czy na pewno po takich operacjach suma całego ciągu wyniesie 0. Zatem przeprowadźmy dowód.
Niech \(n\) będzie liczbą zmian typu \(1 \rightarrow -1\), a \(m\) będzie liczbą zmian typu \(-1 \rightarrow 1\). Pokażemy, że \(n=m\), a więc, skoro suma ciągu wyjściowego wynosiła 0, to suma ciągu po zastosowaniu algorytmu również wyniesie 0.
Przypuśćmy, że \(n < m\). Niech \(i\) oznacza pozycję ostatniej zmiany typu \(-1 \rightarrow 1\). Wówczas na prefiksie \([1, i-1]\) było \(m-1\) zmian typu \(-1 \rightarrow 1\) oraz nie więcej niż \(n\leq m-1\) zmian typu \(1 \rightarrow -1\). To z kolei oznacza, że suma prefiksowa do (\(i-1\))-tego elementu względem oryginalnego ciągu mogła się tylko zwiększyć. Skoro na \(i\)-tej pozycji w oryginalnym ciągu była \(-1\), to suma prefiksowa do (\(i-1\))-tego elementu wynosiła w oryginalnym ciągu co najmniej \(1\), a więc po zmianach wciąż wynosi co najmniej \(1\). To z kolei oznacza, że zmiana na \(i\)-tej pozycji nie była konieczna, czyli algorytm jej nie wykonał, co prowadzi do sprzeczności, a zatem \(n \geq m\). Podobnie pokazuje się, że \(n \leq m\) (tam suma prefiksowa do (\(i-1\))-tej pozycji wynosi maksymalnie \(H-1\)), co kończy dowód.
Opis przygotowano w ramach projektu "Mistrzostwa w Algorytmice i Programowaniu - Uczniowie" - zadania finansowanego ze środków Ministerstwa Cyfryzacji.