Algorytm żółwia i zająca

Dana jest tablica A[0..n] zawierająca liczby {1..n}. Napisz algorytm, który znajdzie dowolną liczbę występującą w tablicy więcej niż raz. Algorytm powinien działać w czasie liniowym i zużywać jedynie stałą ilość dodatkowej pamięci.

Kandydat: Czy można modyfikować tablicę wejściową?
Rekruter: Nie.

Zadanie to pojawia się czasem na rozmowach kwalifikacyjnych w pewnej firmie (kod trzydzieści trzy – dziesięć), choć generalnie nie powinno się go tu stosować. Dobre zadanie rekrutacyjne powinno mieć wiele możliwych rozwiązań i powinno sprawdzać umiejętność rozwiązywania problemów. To zadanie z kolei sprawdza jedynie wiedzę. Albo kandydat zna algorytm o który pyta rekruter, albo go nie zna i na rozmowie kwalifikacyjnej raczej go nie wymyśli. Algorytm jest ciekawy, a zatem warty przedstawienia go tutaj i ma praktyczne zastosowanie m.in. w algorytmie rho Pollarda.

Zacznijmy od kilku prostych obserwacji:

  • W tablicy wejściowej musi istnieć liczba, która występuje dwukrotnie.
  • A[0] ≠ 0

Utwórzmy graf, którego wierzchołkami będą liczby {0, 1, …, n}. Jeżeli A[u] = v to w naszym grafie wstawiamy krawędź skierowaną z wierzchołka u do wierzchołka v. Nasz graf ma następujące własności:

  • Graf jest zbiorem cykli z dołączonymi do niego drzewami
  • Wierzchołek 0 jest częścią drzewa (a nie cyklu)
  • Każdy wierzchołek cyklu do którego doczepione jest drzewo ma numer, który występuje co najmniej dwukrotnie w tablicy wejściowej (bo jego stopień wejściowy wynosi co najmniej 2: prowadzi do niego jeden wierzchołek z cyklu i co najmniej jeden wierzchołek z drzewa).
Przykładowy graf utworzony z tablicy A = [3, 4, 4, 2, 6, 3, 2]

Ideą algorytmu jest rozpoczęcie od wierzchołka 0 (należącego do drzewa) i znalezienie na ścieżce od niego, pierwszego wierzchołka występującego w cyklu (na rysunku jest to wierzchołek 2). Numer wierzchołka zwracamy jako wynik działania algorytmu.

Algorytm, który przedstawię idzie po grafie dwoma wskaźnikami. Jeden z nich w każdym obrocie pętli będzie przesuwał się o jeden wierzchołek (żółw), a drugi będzie przesuwał się o dwa wierzchołki (zając). Pętlę kończymy, gdy oba wskaźniki będą wskazywały ten sam wierzchołek. Oznaczać to będzie, że zarówno żółw jak i zając znajdują się na cyklu. Zacznijmy od udowodnienia, że nasz algorytm się nie zapętla i działa w czasie liniowym:

Lemat. Zając spotka się z żółwiem i nastąpi to szybciej niż żółw zdąży okrążyć cały cykl.

Dowód. Rozważmy moment w którym żółw po raz pierwszy znalazł się na cyklu. Niech L oznacza długość cyklu. Przenumerujmy wierzchołki na cyklu liczbami od 0 do L-1 w ten sposób aby zając znalazł się na pozycji 0. Oznaczmy pozycję żółwia jako p. Po p iteracjach pętli, zarówno zając jak i żółw znajdą się na wierzchołku z numerem 2 * p (mod L), co kończy dowód.

Znaleźliśmy cykl. Musimy jeszcze znaleźć pierwszy wierzchołek na cyklu. Pomoże nam w tym celu drugi lemat.

Lemat. Niech x oznacza długość ścieżki od wierzchołka zero do pierwszego wierzchołka na cyklu; p oznacza liczbę kroków od wejścia żółwia na cykl do spotkania zająca; L oznacza długość cyklu i niech z = L – p. Wtedy zachodzi x mod L = z.

Dowód. Żółw przeszedł x + p wierzchołków. W tym czasie zając musiał przejść 2(x + p) wierzchołków. Najpierw zając przeszedł drogę do cyklu o długości x, a następnie mógł przejść kilkukrotnie cykl, aby skończyć na wierzchołku oddalonym o p od pierwszego wierzchołka na cyklu:

2(x + p) = x + kL + p

gdzie k ≥ 1 oznacza liczbę okrążeń cyklu przez zająca. Odejmując obustronnie x + p i przenosząc p na drugą stronę, otrzymujemy:

x = kL – p = (k – 1)L + L – p = (k – 1)L + z

co kończy nasz dowód.

W jaki sposób pomaga to naszemu algorytmowi znaleźć punkt wejścia do pętli? Żółw i zając spotkali się w punkcie p. Jeśli teraz puścilibyśmy z punktu 0 drugiego żółwia, to po dojściu do cyklu spotkałby on pierwszego żółwia. Drugi żółw przeszedłby x wierzchołków, zatem również pierwszy musiałby tyle przejść. Ale ponieważ x mod L = z to pierwszy żółw startując z pozycji p, musi się znaleźć na wejściu do cyklu po przejściu (k-1)L + z kroków.

Finalnie nasz algorytm wygląda następująco:

int znajdzDuplikat(const vector<int>& A)
{
    int zolw = 0;
    int zajac = 0;

    do
    {
        zolw = A[zolw];
        zajac = A[A[zajac]];
    } while (zolw != zajac);

    int drugi_zolw = 0;

    do
    {
        zolw = A[zolw];
        drugi_zolw = A[drugi_zolw];
    } while (drugi_zolw != zolw);

    return zolw;
}

Pierwsza pętla do while wykonuje x + p kroków o czym mówi nam pierwszy z lematów. Druga pętla wykonuje x kroków, zatem całość działa w czasie O(n) w najgorszym przypadku. Algorytm używa jedynie trzech dodatkowych zmiennych, zatem jego złożoność pamięciowa to O(1).

Algorytm nazywa się algorytmem Floyda, ale “algorytm żółwia i zająca” wydawał mi się bardziej chwytliwym tytułem.

Jeśli ktoś nie znał tego algorytmu i dał radę wymyślić go podczas rozmowy kwalifikacyjnej, to niech da znać. Bardzo chciałbym go poznać!

Back To Top