Kodowanie permutacji Konkurs: II Olimpiada Informatyczna Autor zadania: Krzysztof Diks Pamięć: 32 MB http://main.edu.pl/pl/archive/oi/2/kod Każdą permutację A = (a1,, a n ) liczb 1,, n można zakodować za pomocą ciągu B = (b1,, b n ), w którym b i jest równe liczbie wszystkich a j takich, że j < i oraz a j > a i, dla każdego i = 1,, n. Przykład Kodem permutacji A = (1, 5, 2, 6, 4, 7, 3) jest ciąg B = (,, 1,, 2,, 4). Zadanie Napisz program, który: wczytuje z wejścia długość n i kolejne wyrazy ciągu liczb B, sprawdza, czy jest on kodem jakiejś permutacji liczb 1,, n, jeżeli tak, to znajduje tę permutację i zapisuje ją na wyjściu, w przeciwnym przypadku zapisuje na wyjściu jedno słowo NIE. Wejście W pierwszym wierszu wejścia jest zapisana dodatnia liczba całkowita n 3. Jest to liczba wyrazów ciągu B. W każdym z kolejnych n wierszy jest zapisana jedna liczba całkowita nieujemna nie większa niż 3. Jest to kolejny wyraz ciągu B. Wyjście Na wyjściu należy zapisać: w każdym z kolejnych n wierszy jeden wyraz permutacji A, której kodem jest dany ciąg B, zapisany na wejściu, albo jedno słowo NIE, jeśli ciąg B nie jest kodem żadnej permutacji. 87
Przykłady Dla danych wejściowych: 7 1 2 4 poprawną odpowiedzią jest: 1 5 2 6 4 7 3 natomiast dla danych: 4 2 poprawnym wynikiem jest: NIE 88
Rozwiązanie Pragnę przedstawić dwa zadania mojego autorstwa, pochodzące z zawodów Olimpiady Informatycznej. Oba zadania tylko z pozoru są bardzo łatwe. Najprostsze rozwiązanie poda natychmiast nawet początkujący algorytmik. Takie rozwiązania ad hoc okazują się jednak zbyt wolne, a prawdziwym wyzwaniem jest zaproponowanie rozwiązań znacząco szybszych. Oba zadania dobrze ilustrują moją filozofię układania zadań dla młodych adeptów algorytmiki. Proponując zadania dla uczestników zawodów informatycznych, nie tyle myślę o tym, żeby były one oryginalne, ale żeby niosły ze sobą jakiś przekaz, np. przedstawiały techniki projektowania algorytmów, ukazywały rolę struktur danych w efektywnej implementacji algorytmów, dostarczały szablonów do rozwiązywania podobnych problemów itp. Tego typu zadaniem jest Kodowanie permutacji, które można uznać za standardowe w roku 212, ale ma ono walory dydaktyczne, które były kluczowe w uczeniu algorytmiki w roku 1994 (brak podręczników, serwisów z zadaniami), a i dzisiaj jest ono bardzo pouczające. Podobne walory ma zadanie Wieże. Oprócz wszystkich dobrych cech zadania Kodowanie permutacji, zadanie Wieże posiada jeszcze jedną zaletę. Pozwala ono na eksperymenty (zabawę) z prawdziwą szachownicą lub z kartką papieru. Rok 1994 był dla mnie szczególnie ważny. To rok, w którym rozpocząłem przygodę z popularyzowaniem algorytmiki wśród polskiej młodzieży rok początku mojej pracy w Olimpiadzie Informatycznej. Praca z młodzieżą uzdolnioną informatycznie przyniosła mi, i nadal przynosi, dużo satysfakcji i wspaniałych przeżyć związanych z sukcesami młodych polskich informatyków. Miałem szczęście pracować ze zwycięzcami Międzynarodowej Olimpiady Informatycznej oraz mistrzami świata w programowaniu zespołowym. Olbrzymią radość sprawia mi obserwowanie, jak byli uczestnicy konkursów stają się znakomitymi naukowcami lub czołowymi pracownikami najlepszych firm informatycznych na świecie. A wszystko może zacząć się od próby rozwiązania zadań takich, jak te poniżej. Spojrzenie abstrakcyjne Początkujący algorytmicy często popełniają błąd polegający na myśleniu o algorytmie od razu w kategoriach implementacyjnych, a nie abstrakcyjnych. Takie podejście niejednokrotnie wyklucza całą gamę rozwiązań. Dużo lepiej jest myśleć o możliwych rozwiązaniach algorytmicznych, operując abstrakcyjnymi 89
obiektami, takimi jak na przykład zbiór i ciąg, oraz operacjami na tych obiektach (np. dodaj element do zbioru, sprawdź, czy zbiór zawiera zadany element, podaj i-ty element ciągu ). Dopiero gdy opiszemy algorytm w postaci abstrakcyjnej, mamy wolną rękę w doborze takiej implementacji, która jest najlepsza w danym kontekście. Tutaj najlepsza implementacja oznacza implementację o możliwie najkrótszym czasie działania. Spróbujmy rozwiązać zadanie Kodowanie permutacji w sposób zaproponowany powyżej. Na początek zauważmy, że dla każdej n-elementowej permutacji A elementy jej kodu B spełniają następujący warunek: b i < i dla każdego i = 1, 2,, n. Wynika stąd, że różnych możliwych kodów jest co najwyżej n!, ponieważ b i może przyjąć co najwyżej i różnych wartości. Z drugiej strony nietrudno pokazać, że dowolne dwie różne n-elementowe permutacje A i A mają różne kody. W tym celu wystarczy rozważyć największy taki indeks j, że a j jest różne od a j. Bez straty ogólności przyjmijmy, że a j < a j. Wówczas b j musi być większe od b j, ponieważ na lewo od a j w permutacji A znajdują się wszystkie elementy, które są większe od a j i leżą na lewo od a j w permutacji A, a ponadto znajduje się tam a j. Ponieważ permutacji jest n!, to różnych kodów jest też n!. Tak więc każdy ciąg B spełniający warunek b i < i dla każdego i = 1, 2,, n ( ) jest (jednoznacznym) kodem pewnej permutacji. Nietrudno w czasie liniowym sprawdzić, czy dany ciąg B spełnia warunek ( ), czyli, czy jest kodem pewnej permutacji. Zatem w dalszej części opisu zakładamy, że B jest kodem pewnej permutacji i naszym celem jest odkodowanie właśnie tej permutacji. Rozpoczniemy od prostego spostrzeżenia, że b n jednoznacznie wskazuje na element w zbiorze kandydatów {n, n 1,, 1}, który należy umieścić na ostatniej pozycji w permutacji A. Element b n mówi, że na lewo od a n w permutacji A jest dokładnie b n elementów większych od niego. Ponieważ a n jest ostatnim elementem w permutacji, to jest to element (b n + 1)-szy co do wielkości w zbiorze {n, n 1,, 1}, licząc od największego. Jeśli teraz ze zbioru kandydatów usuniemy a n, to a n 1 będzie równe elementowi (b n 1 + 1)-szemu co do wielkości w uaktualnionym zbiorze kandydatów {n, n 1,, 1} \ {a n }. Element a n 2 jest teraz elementem (b n 2 + 1)-szym co do wielkości w {n, n 1,, 1} \ {a n,a n 1} itd. 9
Oto pełny, abstrakcyjny opis powyższego algorytmu: Algorithm OdkodujPermutację((b1,, b n )) Kandydaci := {n, n 1,, 1} for i := n downto 1 do k := b i + 1 e := element k-ty co do wielkości w Kandydaci a i := e Kandydaci := Kandydaci \ {e} return (a1,, a n ) Mając w ręku taki abstrakcyjny opis algorytmu, możemy się zastanawiać nad jego implementacją. Zarówno kod B, jak i obliczaną permutację A będziemy reprezentowali przez n-elementowe tablice, odpowiednio B[1..n] i A[1..n]. Jednak najważniejszym elementem rozwiązania naszego zadania jest dobra implementacja zbioru Kandydaci. Co wiemy o tym zbiorze? Na początku zbiór Kandydaci zawiera wszystkie liczby 1, 2,, n. W każdym obrocie pętli znajdujemy w zbiorze Kandydaci element e, który jest w nim k-tym co do wielkości, licząc od największego, dla pewnego k pomiędzy 1 a aktualną liczbą elementów w tym zbiorze. Następnie (wskazany) element e usuwamy ze zbioru Kandydaci. W naszych rozwiązaniach połączymy te dwie operacje w jedną operację ZnajdźUsuń(k). Widać, że złożoność algorytmu odkodowywania permutacji zależy od sposobu reprezentacji zbioru Kandydaci i implementacji operacji ZnajdźUsuń. Implementacja algorytmu Sposób 1 tablica. W tej reprezentacji przyjmujemy, że zbiór Kandydaci jest reprezentowany przez n-elementową tablicę Kand[1..n] przyjmującą tylko wartości zero lub jeden, przy czym Kand[e] = 1 tylko wtedy, gdy e jest w zbiorze Kandydaci. Gdy e nie ma w zbiorze, Kand[e] =. Przy takiej reprezentacji zbioru Kandydaci najprostsza implementacja funkcji ZnajdźUsuń polega na przeglądaniu tablicy Kand od strony prawej (od indeksu n) do lewej (do indeksu 1) w poszukiwaniu k-tej jedynki od prawej strony. Indeks pozycji, na której znajduje się ta jedynka, to poszukiwany element w zbiorze Kandydaci. Usunięcie elementu polega na zapisaniu w tablicy Kand na odpowiadającej mu pozycji. 91
Function ZnajdźUsuń(k) licz := ; e := n + 1 while licz < k do e := e 1 if Kand[e] = 1 then licz := licz + 1 Kand[e] := return e Nietrudno zauważyć, że znalezienie wartości e, która jest wynikiem działania ZnajdźUsuń, wymaga przejrzenia n e + 1 pozycji w tablicy Kand. Ponieważ każdy element zbioru Kandydaci musi w końcu zostać znaleziony, to koszt odkodowywania permutacji wymaga przejrzenia łącznie n e =1(n e + 1) = n(n + 1)/2 pozycji w tablicy Kand. Niestety odkodowywanie permutacji tym sposobem zawsze zabiera czas rzędu n2, niezależnie od postaci kodu permutacji B. Sposób 2 lista. Powodem tego, że poprzednie rozwiązanie zawsze daje czas kwadratowy, jest to, że przy poszukiwaniu k-tego elementu w zbiorze Kandydaci oglądamy zarówno elementy, które do tego zbioru aktualnie należą (1 w tablicy Kand), jak i te, które już kandydatami nie są ( w tablicy Kand). Żeby temu zapobiec, możemy zbiór kandydatów reprezentować jako uporządkowaną malejąco listę jego elementów. W tym przypadku znalezienie k-tego elementu w zbiorze polega na wyznaczeniu po prostu k-tego elementu listy, a do tego wystarczy obejrzeć tylko k elementów zbioru Kandydaci k pierwszych elementów listy. Usunięcie znalezionego elementu można już wykonać w czasie stałym. Jeśli przyjmiemy, że listę mamy zadaną przez pola nast[e], które wskazują na następny po e element listy (jeśli e jest ostatnim elementem listy, to nast[e] = ), oraz że dostęp do listy jest dany przez wskaźnik Kand do dodatkowego elementu s (tzw. strażnika), którego pole nast[s] wskazuje na pierwszy element listy (kandydatów), to operację ZnajdźUsuń można zapisać następująco: Function ZnajdźUsuń(k) licz := 1; f := Kand while licz < k do f := nast[f] licz := licz + 1 e := nast[f]; nast[f] := nast[e] return e 92
W tej implementacji koszt odkodowywania zależy od kodu B. Nietrudno zauważyć, że łączna liczba przejść po elementach zbioru Kandydaci wynosi n i =1 (b i + 1), która to suma przyjmuje wartość n (najmniejszą możliwą), gdy wszystkie b i są równe, oraz wartość n(n + 1)/2 (największą możliwą), gdy B = (, 1, 2,, n 1). Zatem przy reprezentacji listowej czas odkodowywania waha się od liniowego do kwadratowego. Jak nasza implementacja zachowuje się w średnim przypadku? To oczywiście zależy od rozkładu danych. Jeśli przyjmiemy, że B jest kodem losowej permutacji, a każda permutacja jest jednakowo prawdopodobna (czyli pojawia się z prawdopodobieństwem 1/n!), to można pokazać, że średnia łączna liczba przejść po elementach zmieniającej się listy Kand wynosi n(n + 2)/4. Czy można nasze zadanie rozwiązać szybciej? Sposób 3 wyszukiwanie binarne i drzewo. Wróćmy do implementacji, w której zbiór Kandydaci jest reprezentowany przez tablicę zero-jedynkową Kand[1..n], i załóżmy, że mamy do dyspozycji magiczną funkcję IleJedynek(l, p), której wartością jest liczba jedynek w podtablicy Kand[l..p], czyli na pozycjach od l do p. Teraz do znalezienia k-tej jedynki od prawej strony można użyć wyszukiwania binarnego. Jeśli chcemy znaleźć k-tą jedynkę od prawej w Kand[l..p], dzielimy Kand[l..p] na dwie równe, z dokładnością do jednego elementu, podtablice Kand[l..s] i Kand[s + 1..p], gdzie s = (l + p) / 2. Jeśli Ile- Jedynek(s + 1, p) k, to kontynuujemy wyszukiwanie k-tej jedynki od prawej w podtablicy Kand[s + 1..p]. Jeśli natomiast IleJedynek(s + 1, p) < k, to szukamy jedynki na pozycji k IleJedynek(s + 1, p) od prawej strony w Kand[l..s], ponieważ pomijamy wszystkie jedynki z podtablicy Kand[s+1..p]. Nasze rozważania skonkretyzujemy z pomocą rekurencyjnej funkcji ZnajdźUsuń(k, l, p), która znajduje i usuwa k-tą jedynkę od prawej z podtablicy Kand[l..p]. W celu znalezienia i usunięcia k-tej jedynki z całej tablicy wywołujemy ZnajdźUsuń(k, 1, n). Function ZnajdźUsuń(k, l, p) if l = p then Kand[l] := return l else s := (l + p)/2 if IleJedynek(s + 1,p) k then return ZnajdźUsuń(k, s + 1, p) else return ZnajdźUsuń(k IleJedynek(s + 1,p), l, s) 93
Gdybyśmy funkcję IleJedynek potrafili obliczać w czasie stałym, to koszt wykonania ZnajdźUsuń(k, 1, n) wyniósłby O(log n), ponieważ za każdym razem przedział, do którego ograniczamy poszukiwanie, jest o połowę krótszy (z dokładnością do jednego elementu) od przedziału, w którym znajdujemy się aktualnie. Ale skąd wziąć odpowiednie wartości funkcji IleJedynek? Potrzebne wartości funkcji IleJedynek można stablicować. Załóżmy, że mamy tablicę IleJed[1..n] taką, że dla l < p, które mogą być parametrami funkcji ZnajdźUsuń, IleJed[s] = IleJedynek(s + 1, p), gdzie s = (l + p)/2. Wówczas w funkcji ZnajdźUsuń wystarczy zastąpić wywołanie IleJedynek(s + 1, p) przez IleJed[s]. A co z usuwaniem elementu e ze zbioru Kandydaci? To też jest proste. Ilekroć poszukiwanie kontynuujemy w prawym podprzedziale Kand[s + 1..p], oznacza to, że z tego podprzedziału zostanie usunięta jedynka. Zatem IleJed[s] należy zmniejszyć o 1. Opisane pomysły konkretyzujemy w poniższej funkcji: Function ZnajdźUsuń(k, l, p) if l = p then Kand[l] := return l else s := (l + p)/2 if IleJed[s] k then IleJed[s] := IleJed[s] 1 return ZnajdźUsuń(k, s + 1, p) else return ZnajdźUsuń(k IleJed[s], l, s) Pozostaje pokazać, w jaki sposób zainicjować tablicę IleJed. To nie jest trudne, gdyż na początku w tablicy Kand są same jedynki. Oto rekurencyjna, naturalna inicjalizacja tablicy IleJed: Function IniIleJed(l, p) if l < p then s := (l + p)/2 IleJed[s] := p s IniIleJed(l, s) IniIleJed(s + 1, p) 94
Pozostawiamy Czytelnikowi dowód, że koszt wykonania IniIleJed(1, n) jest liniowy. Ponadto, dociekliwy Czytelnik powinien zauważyć, że w ostatnim rozwiązaniu tablica Kand jest nam niepotrzebna, jeśli nie liczyć jasności prezentacji. Przedstawione zadanie jest jednym z najłatwiejszych w tej książce. Niejeden czytelnik zauważy, że mamy tu do czynienia z tzw. wektorami inwersji, które opisują trudność algorytmu sortowania przez wstawianie. Inwersją w ciągu liczbowym nazywamy każdą nieuporządkowaną parę elementów. Złożoność algorytmu sortowania przez wstawianie jest proporcjonalna do sumy długości sortowanego ciągu i liczby zawartych w nim inwersji. Mimo że zadanie jest proste, pozwala ono przedstawić pewne sposoby atakowania problemów algorytmicznych w celu uzyskania jak najlepszego (w terminach złożoności czasowej) rozwiązania. Zaczynamy od rozwiązania abstrakcyjnego, a następnie poszukujemy jak najlepszych sposobów implementacji abstrakcyjnych obiektów. To zadanie ma jeszcze jedną zaletę. Może ono posłużyć do omówienia różnorodnych struktur danych (tablic, list i drzew), metod przeszukiwania (liniowej i binarnej), rekurencji, pojęć czasowej złożoności obliczeniowej (pesymistycznej i oczekiwanej) itd. Odwracamy problem Na koniec tego opisu zastanówmy się, jak szybko można rozwiązać zadanie odwrotne z permutacji A otrzymać jej kod B. Naturalne rozwiązanie polega na przejrzeniu permutacji A od lewej do prawej i policzeniu dla każdego elementu a j liczby elementów w A większych od a j i położonych na lewo od a j. Prosta implementacja tego algorytmu działa w czasie O(n2). Można go jednak zaimplementować z pomocą zrównoważonych drzew wyszukiwań binarnych, otrzymując złożoność O(n log n). Tutaj jednak przedstawimy inny sposób obliczania kodu B w czasie O(n log n), w którym nie korzystamy z żadnych złożonych struktur danych. Pomysł tego rozwiązania pochodzi od prof. Wojciecha Ryttera. Czasami zdarza się, że łatwiej jest rozwiązać problem ogólniejszy od problemu wyjściowego, a także udowodnić poprawność takiego rozwiązania. Rozważmy następujące, ogólniejsze zadanie. Zadanie. Dana jest tablica A[1..n] liczb całkowitych z przedziału od do n 1, niekoniecznie różnych. Należy obliczyć tablicę B[1..n] taką, że B[j] jest liczbą elementów większych od A[j] i położonych na lewo od A[j], czyli B[j] = {1 k < j : A[k] > A[j]}. 95
Zauważmy, że jeżeli A jest permutacją liczb {1,, n}, to po odjęciu jedynki od każdego A[j] i policzeniu tablicy B dostaniemy kod permutacji A. Nasze rozwiązanie wykorzystuje następującą prostą obserwację: Obserwacja. Dla dowolnych nieujemnych, różnych liczb całkowitych a < b zachodzi a/2 = b/2 wtedy i tylko wtedy, gdy a = 2k i b = 2k + 1, dla pewnego całkowitego k. Co daje nam ta obserwacja? Jeśli podzielimy każdy element A[j] całkowicie przez 2, to liczba par (A[l], A[p]) takich, że A[l] > A[p] i l < p, pozostanie taka sama, nie licząc par, dla których przed podzieleniem zachodziło A[l] = 2k + 1 i A[p] = 2k, dla pewnego całkowitego k. Ta obserwacja umożliwia zaproponowanie następującego algorytmu. Dopóki w tablicy A jest co najmniej jeden element większy od zera, dla każdego elementu parzystego A[j] oblicz, ile jest elementów (nieparzystych) o jeden większych od niego i położonych z lewej strony. Tak wyznaczoną liczbę dodaj do ogólnej liczby elementów większych od elementu na pozycji j-tej w wyjściowej tablicy A i położonych na lewo od niego. Następnie każdy element w A podziel całkowicie przez 2 i ponów powyższe obliczenia. Poniższy program konkretyzuje nasze pomysły. W tablicy Nieparzyste[..n] zliczamy wystąpienia poszczególnych liczb nieparzystych pojawiających przy przeglądaniu (zmieniającej) się tablicy A od strony lewej do prawej. 96
Algorithm ZakodujPermutację(A) for i := 1 to n do B[i] := ile_zer := while ile_zer < n do for i := to n do Nieparzyste[i] := ile_zer := for i := 1 to n do if 2 A[i] then Nieparzyste[A[i]] := Nieparzyste[A[i]] + 1 else B[i] := B[i] + Nieparzyste[A[i] + 1] A[i] := A[i] div 2 if A[i] = then ile_zer := ile_zer + 1 return B Każda iteracja pętli while wykonuje się w czasie O(n). Iteracji jest co najwyżej log(n + 1). Zatem cały algorytm działa w czasie O(n log n). 97