Поиск сравнениями

Бинарный поиск

Бинарный поиск в массиве

Давайте представим, что мы ищем слово в словаре. Можно, конечно, воспользоваться алгоритмом последовательного поиска, переворачивать страницу за страницей, начав с самой первой. Но, кажется, есть вариант получше.

В словарях все слова упорядочены. Можно воспользоваться этим свойством словаря. Вот пусть нам надо найти слово «мямлик». Откроем словарь посередине, и посмотрим на первое слово на странице. Оно начинается на букву «Н», которая идет после буквы «М» значит вторая, нижняя половина нам не нужна. Продолжаем поиск в первой, верхней половине. Снова располовиниваем половину, и смотрим на первую букву первого слова. Это буква «К». Теперь нужно отбросить уже первую половину половины, и оставить вторую половину половины.

Продолжаем этот процесс, смотрим на вторую букву слова, мы находимся внутри блока слов на букву «М», на третью и так далее, пока, наконец, не найдем наше слово.

Вообще, весь этот процесс описывался довольно долго, особенно по сравнению очень коротким описанием наивного алгоритма «перебирай по порядку». Но сейчас мы формализуем это наше действо, проанализируем алгоритм и поймем, что он очень эффективный.

У нас есть массив упорядоченных ключей K1,K2,K3,,Kn1,KnK_1, K_2, K_3, \dotsc, K_{n-1}, K_n.

K1<K2<K3<<Kn1<KnK_1 < K_2 < K_3 < \dotsb < K_{n-1} < K_n

Я хочу найти в этом массиве номер ключа KK, то есть такое jj, что Kj=KK_j = K.

Допустим, я сейчас нахожусь на позиции ii и смотрю на ключ KiK_i. Я сравниваю этот ключ KiK_i с искомым ключом KK. У меня три варианта:

  1. Ki<KK_i < K. Все записи, меньшие ii, мне не подходят.
  2. Ki=KK_i = K. Ура, я нашел ключ!
  3. Ki>KK_i > K. Все записи, бóльшие ii, мне не подходят.

Этот алгоритм называется бинарным поиском. Слово «бинарный» здесь присутствует потому, что мы каждый раз делим область поиска пополам.

Перед тем, как превратить наше рукомахание в рабочий код, надо провести некоторые манипуляции.

Для начала надо завести переменные left и right, показывающие границы поиска. В самом начале left=0\code{left} = 0, а right=n1\code{right} = n - 1. Серединный элемент будем постоянно выбирать как middle=(left+right)/2\code{middle} = \lfloor (\code{left} + \code{right}) / 2 \rfloor, и на основе значения KmiddleK_{\code{middle}} будем менять или правую границу, или левую.

Все действия будем делать до тех пор, пока в границе поиска не останется один элемент. В этом случае мы просто проверим, совпадает ли он с нашей целью.

function binary_search(sequence a, target) -> int: int left = 0 int right = length(a) - 1 while left <= right: int middle = (left + right) / 2 if a[middle] == target: return middle else if a[middle] < target: left = middle + 1 else: right = middle - 1 return not found

Область поиска мы каждую итерацию делим пополам. Получается, что временная сложность алгоритма O(logn)O(\log n).

Существует альтернативный подход к бинарному поиску — однородный бинарный поиск. Вместо хранения границ left и right он использует текущую позицию i и размер шага m, который уменьшается вдвое на каждой итерации.

function uniform_binary_search(sequence a, target) -> int: int n = length(a) int i = n / 2 int m = n / 2 while m > 0: m = m / 2 if target == a[i]: return i else if target < a[i]: i = i - m else: i = i + m if i < n and a[i] == target: return i else: return not found

Этот алгоритм особенно полезен в системах с ограниченной памятью, ведь мы используем всего 22 переменные вместо 33.

Последовательность шагов предсказуема, ведь шаг всегда равен m,m/2,m/4,m/8,m, m/2, m/4, m/8, \dotsc, поэтому можно создавать эффективные логические схемы.

Каждую итерацию цикла мы вынуждены вычислять новое значение mm. Можно заранее посчитать все значения mm, а потом многократно использовать посчитанные значения, ведь они зависят только от размера массива nn.

Всего нам нужно хранить log2n+2\lfloor \log_2 n \rfloor + 2 значения в таблице

δ[j]=n2j12jдля 1jlog2n+2\delta[j] = \left\lfloor \frac{n - 2^{j-1}}{2^j} \right\rfloor \quad \text{для}~ 1 \le j \le \lfloor \log_2 n \rfloor + 2

Тогда на jj-м шаге нужно брать m=δ[j]m = \delta[j]

function uniform_binary_search(sequence a, target) -> int: int n = length(a) int max_j = floor(log2(n)) + 2 array[int] DELTA[max_j+1] DELTA[0] = none for int j = 1; j <= max_j; j++: DELTA[j] = floor((n + 2^(j-1)) / 2^j) int i = DELTA[1] int j = 2 while true: if DELTA[j] == 0: return not found if target < a[i]: i = i - DELTA[j] else if target > a[i]: i = i + DELTA[j] else: return i j++

Анализ алгоритма

Давайте поподробнее проанализируем алгоритм и его время работы.

Для наглядности изобразим всю процедуру в виде дерева принятия решений для массива длины 1515.

Начинаем мы с самой верхушки дерева. Каждую итерацию мы сравниваем искомое значение KK со значением в вершине, и выбираем, куда пойти: вправо или влево. Дерево решений построено так, что левое поддерево каждой вершины меньше значения в этой вершине, а правое поддерево больше.

Количество уровней в дереве решений log2n+1\lfloor \log_2 n \rfloor + 1. Каждую итерацию мы спускаемся вниз ровно на 11 уровень.

Эффективность алгоритма оценивается количеством сравнений CC. Получается, что успешный поиск при 2k1n<2k2^{k-1} \le n < 2^k требует минимум 11 и максимум kk сравнений, и неудачный поиск при 2k1n<2k12^{k-1} \le n < 2^k - 1 требует k1k-1 или kk сравнений.

Распределение аргументов поиска

Структура массива (и, как следствие, дерева решений) у нас фиксированная. Поэтому никакие предположения о распределении аргументов поиска не дадут нам совершенно никакой пользы.

Единственное, что нам остается делать — смотреть на ситуацию с максимальной энтропией.

Оценим среднее количество сравнений, предположив равномерное распределение аргументов поиска. Поиск может закончиться успешно или неудачно. Пусть CC — количество сравнений при успешном поиске, а CC' — количество сравнений при неудачном поиске.

При расчете количества сравнений в неудачном поиске мы суммируем глубину несуществующих фиктивных листьев, то есть «пустых» мест, куда попадает неудачный поиск. Получаем соотношения

EC=1+1nвершина vdepthvиEC=1n+1фиктивный лист vdepthv+2n\expect C = 1 + \frac{1}{n} \sum\limits_{\text{вершина}~ v} \depth v \quad\text{и}\quad \expect C' = \frac{1}{n+1} \sum\limits_{\text{фиктивный лист}~ v} \depth v + 2n

Ну и можно вывести интересную связь между успешным и неудачном поиском

EC=(1+1n)EC1\expect C = \left(1 + \frac{1}{n}\right) \cdot \expect C' - 1

Глубина дерева решений log2n\lfloor \log_2 n \rfloor. При этом в дереве решений заполнены все уровни, кроме, возможно, последнего. На всех уровнях, кроме последнего, всего 2log2n12^{\lfloor \log_2 n \rfloor} - 1 элементов. На последнем уровне n2log2n+1n - 2^{\lfloor \log_2 n \rfloor} + 1 элементов.

Для подсчета средней глубины элементов воспользуемся формулой

E(глубина элемента)=1n(d=0log2n1d2d+log2n(n2log2n+1))\expect(\text{глубина элемента}) = \frac{1}{n} \Bigg( \sum\limits_{d=0}^{\lfloor \log_2 n \rfloor - 1} d \cdot 2^d + \lfloor \log_2 n \rfloor \cdot \big( n - 2^{\lfloor \log_2 n \rfloor} + 1 \big) \Bigg)

Подставляем это в формулу EC=1+E(глубина элемента)\expect C = 1 + \expect(\text{глубина элемента}), считаем и получаем

EC=log2n(1+1n)2log2nn+2n+1=log2n+O(lognn)\expect C = \lfloor \log_2 n \rfloor \cdot \left( 1 + \frac{1}{n} \right) - \frac{2^{\lfloor \log_2 n \rfloor}}{n} + \frac{2}{n} + 1 = \log_2 n + O \left( \frac{\log n}{n} \right)

Внимание, ловушка!

Полученное значение может немного сбить с толку. Но все хорошо.

Максимальная оценка количества сравнений — количество уровней в дереве, maxC=log2n+1\max\limits C = \lfloor \log_2 n \rfloor + 1. А EC=log2n+O(logn/n)\expect C = \log_2 n + O \big( \log n / n \big), что где-то на 11 меньше.

Бинарный поиск по условию

Почти отсортированный массив

Посмотрим теперь на массив, который почти отсортирован. В нём каждый элемент находится не дальше чем на kk элементов от своей правильной позиции в полностью отсортированном массиве.

То есть, если элемент в отсортированном массиве должен находится на позиции jj, то в почти отсортированном массиве он может находиться на любой позиции от jkj-k до j+kj+k.

Поиск Фибоначчи

Числа Фибоначчи могут играть такую же роль, что и степени двойки. Числа Фибоначчи позволяют разработать альтернативу бинарному поиску, где не нужно будет выполнять никаких операций, кроме сложения.

Что, если у нас нет быстрой операции деления? Или мы хотим минимизировать количество сравнений для массивов, которые не идеально укладываются в степень двойки? Здесь на помощь приходят числа Фибоначчи.

Идея алгоритма заключается в том, чтобы использовать числа Фибоначчи для разбиения текущего отрезка поиска. Вместо деления пополам, мы делим отрезок в пропорции, заданной двумя последовательными числами Фибоначчи.

Пусть нам нужно искать ключ KK в отсортированном массиве с nn ключами K1<K2<<KnK_1 < K_2 < \dotsb < K_n.

Мы хотим делить массив на два подмассива так, чтобы длины подмассивов бли похожи на числа Фибоначчи. Поэтому в начале придётся совершить некоторые подготовительные действия.

Найдем такое наименьшее m0m \ge 0, что n+mn + m на 11 меньше какого-то числа Фибоначчи. То есть n+m=Fk+11n + m = F_{k+1} - 1.

Установим начальный индекс поиска j=Fkmj = F_k - m. Это будет наш текущий индекс-указатель. Будем вычислять шаги по двум числам pp и qq, которые всегда являются числами Фибоначчи. В начале p=Fk1p = F_{k-1} и q=Fk2q = F_{k-2}.

На каждой итерации нам нужно выполнять проверку 44 условий и совершать действия в зависимости от результата

  1. j0j \le 0. Тогда мы вышли за границу массива, и надо вернуться назад. Сдвигаемся на номер j+qj + q и обновляем переменные: p = p - q и q = q - p.

  2. Ki>KK_i > K. Тогда искомый элемент лежит в левой части, и мы сдвигаемся назад на номер jqj - q. После обновляем переменные: (p, q) = (q, p - q).

  3. Kj<KK_j < K. Тогда искомый элемент лежит в правой части, и мы сдвигаемся вперёд на номер j+qj + q. После обновляем переменные: p = p - q и q = q - p.

  4. Ki=KK_i = K. Мы нашли ключ.

При этом поиск завершается неудачей, если после сравнения оказалось, что q=0q = 0, или, что то же самое, p=1p = 1.

function fibonacci_search(sequence a, target) -> int: n = length(K) if n == 0: return not found fib_m2 = 0 # F_{k-2} fib_m1 = 1 # F_{k-1} fib_k = 1 # F_k while fib_k < n: fib_m2, fib_m1, fib_k = fib_m1, fib_k, fib_m1 + fib_k offset = -1 while fib_k > 1: i = min(offset + fib_m2, n - 1) if arr[i] < target: fib_k = fib_m1 fib_m1 = fib_m2 fib_m2 = fib_k - fib_m1 offset = i else if arr[i] > target: fib_k = fib_m2 fib_m1 = fib_m1 - fib_m2 fib_m2 = fib_k - fib_m1 else: return i if fib_m1 and offset + 1 < n and arr[offset + 1] == target: return offset + 1 return not found

Реализация на псевдокоде чуть-чуть отличается от описанного классического алгоритма.

В классическом алгоритме требуется вычисление дополнительного параметра mm для виртуального расширения массива до размера Fk+1F_{k+1}. В данной реализации этот шаг опущен, используется непосредственная работа с исходным массивом размера nn.

Классический алгоритм использует начальную позицию FkmF_k - m, тогда как здесь используется фиксированное начальное смещение offset=1\code{offset} = -1. Это устраняет необходимость вычисления mm и упрощает логику работы с границами массива.

При этом проверки на выход за границы реализованы не через проверку условия j0j \le 0, а через функцию min(offset + fib_m2, n - 1), что еще больше упрощает реализацию.

И, наконец, алгоритм останавливается тогда, когда fib_k1\code{fib\_k} \le 1, а не когда q=0p=1q = 0 \lor p = 1. Получается на 11 итерацию меньше.

В итоге в реализации количество сравнений, совершаемых алгоритмов, на 22 меньше количества сравнений в классическом варианте. Но считать я буду все равно количество сравнений в классическом алгоритме.

Дерево решений при таком поиске будет представлять собой дерево Фибоначчи. У дерева Фибоначчи порядка kk левым поддеревом является дерево Фибоначчи порядка k1k-1, а правым поддеревом является дерево Фибоначчи порядка k2k-2.

При этом высота дерева Фибоначчи равна kk, где Fk<n+1Fk+1F_k < n + 1 \le F_{k+1}. Подставляя формулу Бине можно получить, что высота этого дерева Фибоначчи равна

logϕ(5n+51/2)=logϕn+O(1)\bigl\lfloor \log_\vphi \bigl( \sqrt{5} n + \sqrt{5} - 1/2 \bigr) \bigr\rfloor = \log_\vphi n + O(1)

Разделённый поиск

В бинарном поиске мы делили область поиска пополам. В поиске Фибоначчи мы делили область поиска в соотношении ϕ\vphi. Давайте рассмотрим общий случай, при котором область поиска делится в соотношении p:qp : q. Алгоритм, соответствующий ткому произвольному разделению называется разделённым поиском.

Итак, пусть pp и qq — положительные числа и p+q=1p + q = 1. Алгоритм разделённого поиска ищет ключ KK в отсортированном массиве с nn ключами K1<K2<<KnK_1 < K_2 < \dotsb < K_n.

Мы сравниваем ключ с номером npnp с искомым ключом KK, и в зависимости от результатов сравнения выбираем левую или правую часть массива. Продолжаем эту операцию с получившимся подмассивом.

function s_search(sequence a, target) -> int: return not found

Пусть C(n)C(n) — среднее количество сравнений, необходимых для разделённого поиска в массиве длины nn. Тогда можно примерно оценить

C(1)=0иC(n)1+pC(pn)+qC(qn)для n>1C(1) = 0 \qquad\text{и}\qquad C(n) \approx 1 + p \cdot C(pn) + q \cdot C(qn) \quad\text{для}~ n > 1

Возникает такая формула потому, что после сравнения поиск в nn элементах сводится с вероятностью pp к поиску в примерно pnpn элементах и с вероятностью qq к поиску в примерно qnqn элементах

Из этой рекурсивной формулы получаем, что

Clogppqqn=lognplogp+qlogqC \approx \log_{p^{-p} \, q^{-q}} n = - \frac{\log n}{p \log p + q \log q}

В знаменателе получилась такая интересная формула не случайно.

Давайте делить массив не на 22 части, а на mm частей. Пусть относительные размеры частей будут p1,p2,,pmp_1, p_2, \dotsc, p_m, при этом они такие, что p1+p2++pm=1p_1 + p_2 + \dotsb + p_m = 1.

Нам нужно на каждой итерации определять, какой части области поиска принадлежит искомый элемент. Можно это делать, выполняя m1m-1 сравнение с опорными элементами на границах частей.

Пусть снова C(n)C(n) — среднее количество сравнений, необходимых для разделённого поиска в массиве длины nn. Тогда можно примерно оценить

C(1)=0иC(n)(m1)+p1C(p1n)+p2C(p2n)++pmC(pmn)для n>1C(1) = 0 \qquad\text{и}\qquad C(n) \approx (m-1) + p_1 \cdot C(p_1 n) + p_2 \cdot C(p_2 n) + \dotsc + p_m \cdot C(p_m n) \quad\text{для}~ n > 1

Формула получилась из тех же самых соображений, что и в случае с m=2m = 2: pjp_j — вероятность попадания в jj-ю часть массива, в которой находится примерно pjnp_j n элементов.

Слагаемое m1m-1 присутствует в формуле потому, что мы совершаем m1m-1 сравнения для определения части, в которой находится искомый ключ KK. Если мы работаем в какой-то другой, небинарной логике, это слагаемое следует изменить.

Решая рекуррентное соотношение, получаем

C(n)(k1)lognHC(n) \approx (k-1) \cdot \frac{\log n}{\H}

где H=j=1mpjlogpj\H = - \sum\limits_{j=1}^m p_j \log p_j — энтропия распределения p1,p2,,pmp_1, p_2, \dotsc, p_m. Основания логарифмов одинаковые.

В этой формуле скрыт глубокий смысл, отсылающий нас к основам теории информации. logn\log n — полное количество информации, которое нам нужно получить, чтобы найти элемент среди nn. H\H — среднее количество информации, которое мы получаем за один шаг алгоритма, при этом каждый шаг требует m1m-1 сравнений.

Интерполяционный поиск

Давайте снова возьмем словарь и будем искать в нйм слова. Если нам поступил запрос на поиск слова «абака», то, кажется, нет смысла открывать словарь в середине, ведь слово прям очень близко к началу словаря. Такой подход свойственен нам, людям, и абсолютно чужд для машин. Так научим же их!

Мы хотим научиться предсказывать позицию искомого элемента, и корректировать область поиска в случае промаха.

У нас есть массив упорядоченных ключей K1,K2,K3,,Kn1,KnK_1, K_2, K_3, \dotsc, K_{n-1}, K_n. При этом на ключах нам задано не только линейное отношение порядка, но и способ мерить масштаб. Формально, нам нужно, чтобы ключи были элементами линейного упорядоченного аффинного пространства над линейным упорядоченным полем. Но можно не думать об этих страшных словах и просто считать, что мы ищем среди чисел.

K1<K2<K3<<Kn1<KnK_1 < K_2 < K_3 < \dotsb < K_{n-1} < K_n

Нам нужно найти в этом массиве номер ключа KK, то есть такое jj, что Kj=KK_j = K.

Как и с бинарным поиском, заведем переменные, указывающие на границы области поиска left и right. Но опорный индекс мы будем выбирать не как середину, а будем пытаться предсказывать положение целевого элемента KK.

Для того, чтобы что-то предположить, нам нужно какое-то основание. Пусть мы будем надеяться на то, что элементы по индексам подчиняются линейной зависимости.

В итоге мы просто линейно интерполируем по двум точкам (left,a[left])\big( \code{left}, a[\code{left}] \big) и (right,a[right])\big( \code{right}, a[\code{right}] \big), и вычисляем индекс jj, с надеждой, что a[j]=Ka[j] = K. Формула вычисления индекса получается

j=left+(Ka[left])(rightleft)a[right]a[left]j = \code{left} + \frac{\big( K - a[\code{left}] \big) \cdot (\code{right} - \code{left})}{a[\code{right}] - a[\code{left}]}

В остальном алгоритм полностью аналогичен бинарному поиску.

function interpolation_search(sequence a, target) -> int: int left = 0 int right = length(a) - 1 while left <= right: int j = left + (target - a[left]) * (right - left) / (a[right] - a[left]) if a[j] == target: return j else if a[j] < target: left = j + 1 else: right = j - 1 return not found

Анализ количества сравнений

Оно O(loglogn)O(\log \log n). Надо будет написать доказательство.

Упражнения

    1

    Бинарный поиск в неупорядоченных массивах. Довольно часто попадаются задачи, где нужно реализовать бинарный поиск в массиве, который когда-то был отсортирован, и над которым совершили какие-то надругательства. В этом упражнении предлагается попробовать свои силы в реализации бинарного поиска на некоторых неотсортированных массивах.

    В каждой задаче массив a состоит из nn различных элементов. Нужно написать код, который будет быстро находить в массиве данный элемент tt, или устанавливать факт его отсутствия в массиве.

    1. Отсортированный по возрастанию массив был циклически сдвинут.

    2. В отсортированном по возрастанию массиве поменяли местами два случайных элемента.

    3. К отсортированному по возрастанию массиву приписали отсортированный по убыванию.

    4. Склеили два отсортированных по возрастанию массива.

    5. К отсортированному по возрастанию массиву приписали отсортированный по убыванию. Получившийся массив циклически сдвинули.

    6. В отсортированном по возрастанию массиве некоторые элементы заменили на none. Несколько none могут идти подряд. В получившимся массиве нужно искать элементы, игнорируя none.