Pewnie grałeś już w grę "zgadnij liczbę", polega ona na tym, że jeden gracz prubuje zgadnąć, jaką liczbę, z określonego zakresu (np. 1-100), wymyślił drugi gracz. W tym celu może strzelać, wskazując w jakąś liczbę i otrzymując w odpowiedzi informację, czy szukana liczba jest miejsza, większa, czy równa wskazanej przez niego liczbie.
Zobaczmy przykładowe rozgrywki:
Zobaczmy te same rozgrywki, ale w optymalniejszej formie:
Ten sposób nazywa się binsearch.
Bierzemy punkt po środku zakresu, by zmiejszyć zakres o połowę.
Dzięki temu dojdziemy do rozwiązania w ok. log n ruchów, gdzie n to długość początkowego zakresu. Oznacza to, że złożoność obliczeniowa tego rozwiązania to O(log n).
Jednak jak to wykorzystać? Przecież w zadaniach raczej wczytujemy jakąś liczbę, a nie próbujemy ją zgadnąć!
Binsearch przydaje się, gdy chcemy znaleźć szybko pierwsze wystąpienie danej liczby w posortowanej tablicy (w przypadku gry "zgadnij liczbę" w zbiorze liczb naturalnych od 1 do 100), a dokładniej znaleźć jej indeks (jeśli ta liczba w ogóle istnieje w tej tablicy).
Popatrzmy na ten przykład:
Mamy tablicę tab = [1, 3, 8, 10, 12, 13, 15, 17]
Jak znajdziesz w niej pozycję liczby 15?
Możliwe, że przeszedłbyś po elementach, aż doszedłbyś do 15, ale lepiej zrobić to tak:
Tak naprawdę w programie nie będziemy usuwać elementów tylko zmieniać zakres, który rozpatrujemy.
Wczytywanie jest liniowe, więc nasz program nie będzie miał złożoności O(log n), tylko O(n), dlatego binsearch opłaca się tylko wtedy, gdy mamy znaleźć wiele elementów we wczytanej tablicy.
Zapiszmy nasz teraźniejszy przedział indeksów jako [p, k] (p - początek, k - koniec). Początkowo p = 0, a k = długość tablicy - 1.
Za każdym razem znajdujemy indeks elementu środkowego S = ⌊p + (k - p)/2⌋ = ⌊(2p + k - p)/2⌋ = ⌊(p + k)/2⌋.
Oznaczmy wartość, której indeks mamy znaleźć jako a.
tab = [1, 2, 3, 5, 6, 7, 9, 10, 11, 12, 16, 16, 17], a = 16
| iteracja | p | k | S | tab[S] |
|---|---|---|---|---|
| 0 | 0 | 12 | 6 | 9 |
| 1 | 7 | 12 | 9 | 12 |
| 2 | 10 | 12 | 11 | 16 |
| 3 | 10 | 11 | 10 | 16 |
| 4 | 10 | 10 | - | - |
Pierwsze wystąpienie liczby 16 w tej tablicy jest na indeksie 10.
Co jednak, gdy w tablicy nie ma szukanej liczby?
tab = [1, 2, 4, 8, 16, 32], a = 7
| iteracja | p | k | S | tab[S] |
|---|---|---|---|---|
| 0 | 0 | 5 | 2 | 4 |
| 1 | 3 | 5 | 4 | 16 |
| 2 | 3 | 3 | - | - |
Przecież tab[3] = 8, a nie 7. Co więc oznacza te 3?
tab[ans] jest najmniejszą liczbą ≥ a. Gdy a istnieje w tablicy, to ans to indeks jej pierwszego wystąpienia.
Binsearch w drugą stronę znajduje indeks największej liczby z posortowanej tablicy, która jest ≤ a. Jeśli ta liczba jest równa a, to znajduje jej ostatnie wystąpienie.
Jest on częściej stosowany niż "zwykły" binsearch.
Jedyne różnice są w warunkach i warunku zakączenia algorytmu:
Zapiszmy nasz teraźniejszy przedział indeksów jako [p, k] (p - początek, k - koniec). Początkowo p = 0, a k = długość tablicy - 1.
Za każdym razem znajdujemy indeks elementu środkowego S = ⌊p + (k - p)/2⌋ = ⌊(2p + k - p)/2⌋ = ⌊(p + k)/2⌋.
Oznaczmy wartość, której indeks mamy znaleźć jako a.
Zauważmy, że dla tab[S] < a i tab[S] = a robimy to samo (ustawiamy p na S), więc możemy zamienić te dwa warunki w jeden:
Jeśli tab[S] ≤ a, to zmieniamy p na S.
Przy dużej ilości zapytań opłaca się nawet posortować tablice, żeby zrobić wiele razy binsearcha, niż wyszukiwać elementy liniowo!
| ilość elementów w tablicy | binsearch - O(log n) | liniowe wyszukiwanie elementów - O(n) |
|---|---|---|
| 103 | 10 operacji - 0 s | 103 operacji - 0 s |
| 106 | 20 operacji - 0 s | 106 operacji - 0 s |
| 109 | 30 operacji - 0 s | 109 operacji - 1 s |
| 1012 | 40 operacji - 0 s | 1012 operacji - 1000 s = 16 min |
| 10100 | 340 operacji - 0 s | 10100 operacji - 10100 s = 1070 lat! |
W C++ są już gotowe funkcje do binsearcha:
- znajduje największy element ≤ a w posortowanej tablicy.
- znajduje najmniejszy element > a w posortowanej tablicy.
- mówi, czy w posortowanej tablicy występuje dany element.