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, …, an) liczb 1, …, n można zakodować za pomocą ciągu B = (b1, …, bn), w którym bi jest równe liczbie wszystkich aj takich, że j < i oraz aj> ai, dla każdego i = 1, …, n.
Przykład
Kodem permutacji A = (1, 5, 2, 6, 4, 7, 3) jest ciąg B = (0, 0, 1, 0, 2, 0, 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 ⩽ 30 000. 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ż 30 000. 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.
sztOF dIKs / KOdOWANIE pErMutACJI
Przykłady
Dla danych wejściowych:
7 0 0 1 0 2 0 4
poprawną odpowiedzią jest:
1 5 2 6 4 7 3
natomiast dla danych:
4 0 2 0 0
poprawnym wynikiem jest:
NIE
sztOF dIKs / KOdOWANIE pErMutACJI
Rozwiązanie
Pragnę przedstawić dwa zadania mojego autorstwa, pochodzące z zawodów Olimpiady Informatycznej. Oba zadania tylko z pozoru są bardzo łatwe. Naj- prostsze 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 infor- matycznych, 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, do- starczał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 2012, ale ma ono walory dydaktyczne, które były kluczowe w uczeniu algoryt- miki 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 po- czą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 Informatycz- nej 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 al- gorytmie 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
sztOF dIKs / KOdOWANIE pErMutACJI
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 abstrak- cyjnej, 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 zapropono- wany 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: 0 ⩽ bi< i dla każdego i = 1, 2, …, n. Wynika stąd, że różnych możliwych kodów jest co najwyżej n!, ponieważ bi 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
0 ⩽ bi< 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 za- kładamy, że B jest kodem pewnej permutacji i naszym celem jest „odkodowanie”
właśnie tej permutacji.
Rozpoczniemy od prostego spostrzeżenia, że bn 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 bn mówi, że na lewo od an w permutacji A jest dokładnie bn elementów większych od niego. Ponieważ an jest ostatnim ele- mentem w permutacji, to jest to element (bn+ 1)-szy co do wielkości w zbiorze {n, n − 1, …, 1}, licząc od największego. Jeśli teraz ze zbioru kandydatów usu- niemy an, to an −1 będzie równe elementowi (bn −1 + 1)-szemu co do wielkości w uaktualnionym zbiorze kandydatów {n, n − 1, …, 1} \ {an}. Element an −2 jest teraz elementem (bn −2 + 1)-szym co do wielkości w {n, n − 1, …, 1} \ {an,an −1} itd.
sztOF dIKs / KOdOWANIE pErMutACJI
Oto pełny, abstrakcyjny opis powyższego algorytmu:
Algorithm OdkodujPermutację((b1, …, bn)) Kandydaci := {n, n − 1, …, 1}
for i := n downto 1 do k := bi+ 1
e := element k-ty co do wielkości w Kandydaci ai := e
Kandydaci := Kandydaci\ {e}
return (a1, …, an)
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 re- prezentowali przez n-elementowe tablice, odpowiednio B[1..n] i A[1..n]. Jednak najważniejszym elementem rozwiązania naszego zadania jest dobra implemen- tacja 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ęk- szego, 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 war- toś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] = 0. Przy takiej reprezentacji zbioru Kandydaci najprostsza implementacja funkcji ZnajdźUsuń polega na przegląda- niu tablicy Kand od strony prawej (od indeksu n) do lewej (do indeksu 1) w po- szukiwaniu 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 0 w tablicy Kand na odpowiadającej mu pozycji.
sztOF dIKs / KOdOWANIE pErMutACJI
Function ZnajdźUsuń(k) licz := 0; e := n + 1 while licz < k do
e := e − 1
if Kand[e] = 1 then licz := licz + 1 Kand[e] := 0
return e
Nietrudno zauważyć, że znalezienie wartości e, która jest wynikiem działa- nia 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 odko- dowywania permutacji wymaga przejrzenia łącznie ∑ne =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ą (0 w tablicy Kand).
Żeby temu zapobiec, możemy zbiór kandydatów reprezentować jako uporząd- kowaną 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 pierw- szych 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] = 0), 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
sztOF dIKs / KOdOWANIE pErMutACJI
W tej implementacji koszt odkodowywania zależy od kodu B. Nietrudno zauważyć, że łączna liczba przejść po elementach zbioru Kandydaci wynosi
∑ni =1 (bi+ 1), która to suma przyjmuje wartość n (najmniejszą możliwą), gdy wszystkie bi są równe 0, oraz wartość n(n + 1)/2 (największą możliwą), gdy B = (0, 1, 2, …, n − 1). Zatem przy reprezentacji listowej czas odkodowywania waha się od liniowego do kwadratowego. Jak nasza implementacja zachowu- je 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 jed- nakowo 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ą Kan- d[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 pozy- cjach 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], ponie- waż pomijamy wszystkie jedynki z podtablicy Kand[s+1..p]. Nasze rozważania skonkretyzujemy z pomocą rekurencyjnej funkcji ZnajdźUsuń(k, l, p), która znaj- duje 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] := 0 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)
sztOF dIKs / KOdOWANIE pErMutACJI
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 do- kładnością do jednego elementu) od przedziału, w którym znajdujemy się aktual- nie. 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ń wy- starczy zastąpić wywołanie IleJedynek(s + 1, p) przez IleJed[s]. A co z usuwa- niem 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] := 0 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)
sztOF dIKs / KOdOWANIE pErMutACJI
Pozostawiamy Czytelnikowi dowód, że koszt wykonania IniIleJed(1, n) jest liniowy. Ponadto, dociekliwy Czytelnik powinien zauważyć, że w ostatnim roz- wią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 implemen- tacji 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żo- noś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 elemen- tu aj liczby elementów w A większych od aj i położonych na lewo od aj. Prosta implementacja tego algorytmu działa w czasie O(n2). Można go jednak zaimple- mentować z pomocą zrównoważonych drzew wyszukiwań binarnych, otrzy- mują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 pro- blemu wyjściowego, a także udowodnić poprawność takiego rozwiązania. Roz- ważmy następujące, ogólniejsze zadanie.
Zadanie. Dana jest tablica A[1..n] liczb całkowitych z przedziału od 0 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]}|.
sztOF dIKs / KOdOWANIE pErMutACJI
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 za- chodzi ⌊a/2⌋ = ⌊b/2⌋ wtedy i tylko wtedy, gdy a = 2k i b = 2k + 1, dla pewnego całkowitego k ⩾ 0.
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 ⩾ 0. Ta obserwacja umożliwia zapro- ponowanie 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[0..n]
zliczamy wystąpienia poszczególnych liczb nieparzystych pojawiających przy przeglądaniu (zmieniającej) się tablicy A od strony lewej do prawej.
sztOF dIKs / KOdOWANIE pErMutACJI
Algorithm ZakodujPermutację(A) for i := 1 to n do
B[i] := 0 ile_zer := 0
while ile_zer < n do for i := 0 to n do Nieparzyste[i] := 0
ile_zer := 0 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] = 0 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).
sztOF dIKs / KOdOWANIE pErMutACJI