Przejdź do treści

Wyszukiwanie liniowe i binarne: dwie pętle, w których łatwo się pomylić

W poprzedniej części tablica mieszająca odpowiadała na pytanie „czy jest klucz X” w czasie stałym. Teraz cofniemy się o krok i zapytamy, co robić, gdy takiej tablicy nie mamy – bo dane są w zwykłej liście, w pliku, w kolumnie bazy albo po prostu jest ich za mało, żeby budować cokolwiek fantazyjnego.

Wyszukiwanie liniowe i binarne to dwa algorytmy, które każdy zna. Warto je znać dokładniej, bo pierwszy bywa niesłusznie pogardzany, a drugi jest sławny z tego, że prawie nikt nie pisze go poprawnie za pierwszym razem.

Liniowe: niedoceniane

# Python
def find_linear(items, wanted):
    """Przejrzyj po kolei. Nic nie zakłada o danych – i to jest jego siła."""
    for index, value in enumerate(items):
        if value == wanted:
            return index
    return -1

O(n), zero wymagań wstępnych, dwie linijki. Wyszukiwanie liniowe wygrywa częściej, niż sugeruje tabelka złożoności:

  • Na krótkich listach. Poniżej kilkudziesięciu elementów narzut na cokolwiek mądrzejszego się nie zwraca, a dane leżą w pamięci obok siebie, więc procesor czyta je błyskawicznie.
  • Gdy szukasz raz. Posortowanie n elementów kosztuje O(n log n) – dużo więcej niż jedno przejrzenie listy.
  • Gdy warunek jest złożony. „Pierwszy element spełniający trzy warunki naraz” to zawsze przegląd, bo nie da się tego zindeksować.

W Pythonie zresztą rzadko pisze się tę pętlę ręcznie – x in items, items.index(x), next(i for i in items if ...) to wszystko wyszukiwanie liniowe, tylko wykonane w C.

Binarne: szybkie, pod jednym warunkiem

Wyszukiwanie binarne wymaga danych posortowanych i w zamian daje O(log n). Dla miliona elementów to dwadzieścia porównań zamiast miliona.

# Python
def find_binary(items, wanted):
    """Przedział domknięty [lo, hi]; niezmiennik: jeśli wartość istnieje, leży w [lo, hi]."""
    lo, hi = 0, len(items) - 1
    while lo <= hi:                     # <= , bo przedział jest domknięty z obu stron
        mid = (lo + hi) // 2
        if items[mid] == wanted:
            return mid
        if items[mid] < wanted:
            lo = mid + 1                # +1 i -1 gwarantują, że przedział MALEJE
        else:
            hi = mid - 1
    return -1

Cała trudność siedzi w trzech miejscach, w których łatwo o pomyłkę:

  1. Warunek pętli. Przy przedziale domkniętym [lo, hi] musi być lo <= hi. Przy półotwartym [lo, hi)lo < hi, a hi startuje z len(items). Zmieszanie obu konwencji to najczęstszy błąd: pętla albo gubi ostatni element, albo nie kończy się nigdy.
  2. Aktualizacja granic. Bez +1 i -1 przedział przestaje się kurczyć i dostajesz pętlę nieskończoną – klasyczny objaw przy dwuelementowej tablicy.
  3. Niezmiennik. Zdanie „jeśli szukana wartość istnieje, to leży w [lo, hi]” musi być prawdziwe przed pętlą, po każdym obiegu i po jej zakończeniu. Jeśli potrafisz je zapisać, kod prawie pisze się sam.

Błąd, który przeżył dziewięć lat w bibliotece standardowej. Linia mid = (lo + hi) / 2 w językach o stałej szerokości liczb całkowitych jest niepoprawna: dla tablic o co najmniej 2³⁰ elementów suma lo + hi przekracza zakres int i staje się liczbą ujemną, a indeks wychodzi poza tablicę. Błąd siedział w java.util.Arrays.binarySearch od pierwszej wersji i został zgłoszony dopiero jako JDK-5045582. Poprawka to mid = (lo + hi) >>> 1 – przesunięcie bitowe bez znaku daje właściwy wynik nawet po przekręceniu licznika. W Pythonie problem nie występuje, bo liczby całkowite nie mają górnego ograniczenia; w C, Javie, C# czy Ruście – jak najbardziej.

Nie pisz tego sam: bisect

Python ma to wszystko gotowe w module bisect, i to w wersji, która odpowiada na pytanie ciekawsze niż „czy jest”:

# Python
from bisect import bisect_left, bisect_right, insort

oceny = [2, 3, 3, 3, 4, 5]

bisect_left(oceny, 3)      # 1 – pierwsza pozycja, na której może stać 3
bisect_right(oceny, 3)     # 4 – pierwsza pozycja ZA wszystkimi trójkami
bisect_right(oceny, 3) - bisect_left(oceny, 3)    # 3 – ile jest trójek, bez zliczania

insort(oceny, 4)           # wstawienie z zachowaniem porządku

bisect_left i bisect_right to w praktyce zapytanie zakresowe: pozwalają policzyć elementy w przedziale albo znaleźć najbliższy większy bez przeglądania listy. Od Pythona 3.10 obie funkcje przyjmują argument key=, więc działają też na listach obiektów bez budowania osobnej listy kluczy.

Uwaga na koszt: insort znajduje miejsce w O(log n), ale samo wstawienie do listy to O(n), bo trzeba przesunąć ogon. Przy wielu wstawieniach lepszą strukturą będzie kopiec albo drzewo.

Ile trzeba zapytań, żeby opłaciło się posortować

To jest pytanie, które warto umieć policzyć na serwetce. Przy k zapytaniach do zbioru n elementów:

  • liniowo: k · n
  • posortuj raz, potem binarnie: n log₂ n + k · log₂ n

Dla n = 100 000 sortowanie kosztuje około 1,7 mln operacji, a każde zapytanie 17 zamiast 100 000. Próg opłacalności wypada więc przy około 17 zapytaniach – powyżej tego sortowanie zwraca się z nawiązką, poniżej jest stratą.

ZapytańLiniowoSortowanie + binarnie
1100 tys.1,7 mln
202 mln1,7 mln
1 000100 mln1,72 mln
1 000 000100 mld18,7 mln

I to jest dokładnie ten sam rachunek, który wykonuje baza danych, gdy decyduje, czy skorzystać z indeksu, czy przeskanować tabelę. Indeks to nic innego jak trwale utrzymany porządek, dzięki któremu koszt sortowania płacimy raz, przy zapisie, a nie przy każdym zapytaniu. Wrócę do tego w części o B-drzewach.

Dwa warianty, które warto znać

Wyszukiwanie wykładnicze – gdy nie znasz rozmiaru zbioru albo szukana wartość jest prawdopodobnie blisko początku: podwajaj granicę (1, 2, 4, 8…), aż przeskoczysz szukaną wartość, a potem zrób wyszukiwanie binarne w ostatnim przedziale. To technika z galopowania w TimSorcie, które opisywałem w serii o sortowaniu.

Wyszukiwanie interpolacyjne – zamiast celować w środek przedziału, zgaduj pozycję proporcjonalnie do wartości (jak przy szukaniu hasła w słowniku: „Z” otwierasz od końca). Dla danych rozłożonych równomiernie daje O(log log n), ale dla danych skupionych degeneruje się do O(n). Znowu ten sam wzorzec, co przy quicksorcie: świetnie średnio, kiepsko w najgorszym przypadku.

Podsumowanie

MetodaWymagaCzasKiedy
linioweniczegoO(n)krótkie listy, jednorazowe pytania, złożone warunki
binarneposortowanych danychO(log n)wiele zapytań do stabilnego zbioru
tablica mieszającafunkcji skrótuO(1) średniopytania o konkretny klucz, bez porządku
indeks w bazieutrzymania przy zapisieO(log n)dane większe niż pamięć, zapytania zakresowe

Wyszukiwanie binarne wymaga porządku, a utrzymanie porządku w zbiorze, który cały czas się zmienia, to osobny problem: po każdym wstawieniu trzeba by przesuwać pół tablicy. Rozwiązaniem są struktury, które trzymają porządek bez przesuwania czegokolwiek – drzewa poszukiwań. To temat następnej części.