Informatyczny kącik olimpijski
Robot sortujący
Tym razem omówimy zadanie Robot sortujący (ang. Robotic Sort) z Mistrzostw Europy Środkowej w Programowaniu Zespołowym 2007 (CERC 2007).
W zadaniu występuje nietypowy algorytm sortowania
-elementowego
ciągu liczb całkowitych. W
-tym kroku (dla
)
znajdujemy w ciągu
-ty najmniejszy element; załóżmy, że znajduje się
on na pozycji
Następnie odwracamy fragment ciągu znajdujący się
między pozycjami
-tą a
-tą i w ten sposób rozważany element
trafia na docelową pozycję w posortowanym ciągu. Naszym zadaniem jest
efektywnie zasymulować ten algorytm, a dokładniej, wyznaczyć ciąg pozycji
Przykładowo, jeśli ciągiem do posortowania jest 3, 4, 5, 1, 6, 2,
to wynikowy ciąg pozycji odpowiadających prawym końcom kolejnych
odwróceń to 4, 6, 4, 5, 6, 6. Dla uproszczenia założymy, że wyjściowy ciąg
jest permutacją liczb
W oryginalnym zadaniu ciąg nie musiał
być różnowartościowy, ale sprowadzenie ogólnego przypadku do
przypadku permutacji jest całkiem proste.
Bezpośrednia implementacja algorytmu sortowania przez odwracanie ma
złożoność czasową
Można się o tym przekonać,
sprawdzając działanie algorytmu dla ciągu 2, 4, 6, 8,
–
właśnie w tym przypadku wykonujemy największą liczbę operacji.
W takim razie spróbujemy zastosować jakieś inne podejście. Ponumerujmy
elementy ciągu po kolei, od lewej do prawej, od 1 do
Załóżmy, że
najmniejszy element ciągu znajduje się na pozycji
Wówczas po
pierwszym odwróceniu elementy ciągu będą ustawione w następującej
kolejności:

Widzimy, że tak naprawdę struktura ciągu zmieniła się dosyć nieznacznie.
Jeśli teraz drugi najmniejszy element znajdował się, przykładowo, na pozycji
to po drugim odwróceniu ciąg będzie miał postać:

Domyślamy się już, co będzie dalej. Po
-tym odwróceniu ciąg będzie
miał postać

dla pewnego
przy czym
to numery
najmniejszych elementów ciągu, zaś
oznacza ciąg
liczb
ustawionych w takim właśnie porządku (jeśli
) lub odwrotnie, malejąco (jeśli
).
Zauważmy, że używając takiej reprezentacji, kolejne odwrócenie
w ciągu możemy wykonać w czasie
o ile tylko będziemy
dla każdego elementu
pamiętać, na której pozycji
wyjściowego ciągu się znajdował (oznaczenie:
). Załóżmy, że
Znajdujemy teraz taką grupę
że
rozbijamy ją na dwie, a następnie między te dwie części
wstawiamy, odwrócone, wszystkie wcześniejsze grupy. Przykładowo, jeśli
to wynikowy ciąg ma postać:

Przypadek
rozpatrujemy analogicznie. Po każdej
operacji przybywa co najwyżej jedna grupa – być może zero, jeśli
znajdowało się na skraju grupy
To jednak
oznacza, że liczba grup może rosnąć liniowo, więc koszt czasowy całego
algorytmu może być rzędu
Pozostawiamy Czytelnikowi
znalezienie złośliwego przykładu, wymuszającego wykonanie takiej liczby
operacji – co ciekawe, poprzednio podany złośliwy przykład nie ma tej
własności.
Mogłoby się wydawać, że ponieśliśmy porażkę – tyle wysiłku, a na
końcu i tak algorytm jest kwadratowy. Możemy jednak osiągnąć lepszą
złożoność czasową, jeśli tylko dostrzeżemy mocną stronę naszego
algorytmu: otóż na samym początku jest on bardzo szybki. Koszt pierwszych
kroków algorytmu to
Przyjmijmy
;
wówczas podane kroki wykonujemy w czasie
Kolejne kroki
byłyby bardziej kosztowne, dlatego zamiast nich zrobimy... porządek. Możemy
mianowicie wypisać explicite wszystkie wyrazy ciągu powstałego po
odwróceniach, ponumerować je kolejno, wyznaczyć od nowa tablicę
indeksów poszczególnych elementów w ciągu
i kolejne
odwrócenia wykonywać już za pomocą nowego ciągu.
Uporządkowanie ciągu po
odwróceniach zajmuje czas
taki sam jak symulacja tych
odwróceń. W trakcie
całego algorytmu takich większych faz musimy wykonać mniej więcej
co daje łączną złożoność czasową
Na koniec warto dodać, że całe zadanie można rozwiązać także w czasie
używając struktur danych opartych na drzewach
zrównoważonych. Jednak takie rozwiązanie nie było nigdy autorowi kącika do
szczęścia potrzebne.