Лекция 9,5. Бинарный поиск. Куча

Алгоритмическая сложность

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

Сразу возникает вопрос: как по возможности корректно определить это “быстрее”? Можно было бы написать две программы, одну работающую с алгоритмом линейного, другую с алгоритмом бинарного поиска, замерить результаты на одних и тех же данных, и сказать, на сколько миллисекунд одна программа работает быстрее другой. Однако, на этом пути нас подстерегают две проблемы. Во-первых, результаты измерений сильно зависят от модели процессора, системной шины и модулей памяти. Более того, постоянно появляются новые модели этих устройств и наша информация о быстродействии алгоритмов быстро устареет. Но это не главное, ведь вторая проблема состоит в том, что на входных данных разного размера эти две программы будут работать с разной скоростью. При увеличении размера данных в 10 раз алгоритм линейного поиска будет работать в среднем в 10 раз медленнее, а бинарного в среднем в 3.32 (\(= \log_2 10\)) медленнее. Можно было бы составить таблицу, но она опять будет устаревать с выходом новых вычислительных компонент... Однако выход есть, и он состоит в том, чтобы описывать время работы каждого алгоритма математической оценкой зависимости времени работы от количества входных данных.

Обозначение f(n) = O(g(n)) означает, что существует такие константы C и N, что \(|f(n)| \leq C |g(n)|\) для любого n > N.

Обозначение f(n) = o(g(n)) означает, что для любого \(\varepsilon\) существует такое N, что \(|f(n)| < \varepsilon |g(n)|\).

O(g(n)) обычно используется для того, чтобы указать, что алгоритм работает “не хуже, чем за время g(n), умноженное на некоторую константу”. При этом в некоторых случаях (например, на специально подготовленных входных данных) алгоритм может работать лучше, то есть O(g(n)) не сводится только к пропорциональности.

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

Пусть у нас есть отсортированный массив из N элементов, и нам нужно найти в нём элемент, равный x. Мы уже знаем, как работает линейный поиск и можем воспользоваться им, просматривая массив слева направо или справа налево. Но так как наш массив уже отсортирован, логично задаться вопросом, сможет ли этот факт нам как нибудь помочь найти искомый элемент быстрее. И действительно, если взять наугад любой элемент массива ai и он окажется меньше x, то все элементы слева от него тоже будут меньше x, так как после сортировки они не должны превосходить ai. Аналогично, если ai больше x, то и все элементы ak, k > i будут больше x. Теперь совсем несложно догадаться, что для того, чтобы максимально уменьшить себе объём работы, нужно выбрать элемент не случайно, а в середине массива. Напишем же такую функцию:

int bin_search(int N, int arr[], int x)
{
    int left = 0, right = N;
    while(right - left > 1) {
        int mid = left + (right - left) / 2;
        if (x == arr[mid]) {
            return mid;
        } else if (x < arr[mid]) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }
    if (right - left == 1 && x == arr[left]) {
        return left;
    } else {
        return -1;
    }
}

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

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

\[\begin{split}\forall i < k, a[i] < a[k]\end{split}\]\[\forall i \geq k, a[i] \geq a[k]\]

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

int bin_search_left(int N, int arr[], int x)
{
    int left = 0, right = N;
    while(right - left > 1) {
        int mid = left + (right - left) / 2;
        if (x <= arr[mid]) {
            right = mid;
        } else {
            left = mid;
        }
    }
    if (arr[left] >= x) {
        return left;
    } else {
        return right;
    }
}

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

Решение задачи “Очень сложная задача” с сайта informatics.mccme.ru (№490).

// Решение задачи "Очень простая задача"
// с помощью бинарного поиска по ответу.
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

// Глобальные переменные, могут читаться и изменяться
// всеми функциями, расположенными в коде после них.
int N, x, y;

// Чтобы поиск по ответу работал, функция должна
// возвращать 0 (ложь) при всех time < T.
// И возвращать 1 (истину) при всех time >= T.
int f(int time)
{
    // Быстрый принтер печатает сначала,
    // медленный с момента времени x.
    return (time / x + (time - x) / y) >= N; 
}

// Ищет крайнее левое истинное значение функции
// f в диапазоне от start до (end - 1) включительно
int bin_search_left(int start, int end)
{
    int left = 0, right = N;
    while(right - left > 1) {
        int mid = left + (right - left) / 2;
        if (f(mid)) {
            right = mid;
        } else {
            left = mid;
        }
    }
    if (f(left)) {
        return left;
    } else {
        return right;
    }
}

int main()
{
    scanf("%d%d%d", &N, &x, &y);
    if (y < x) {
        int t = x;
        x = y;
        y = t;
    }
    // Теперь x -- время работы быстрого принтера.
    int start = 1; // Нижняя граница: 1 секунда.
    int end = N * x + 1; // Верхняя граница: печатаем только быстрым принтером
                 // прибавляем 1, так как bin_search_left не включает правый индекс.
    int answer = bin_search_left(start, end);
    printf("%d\n", answer);
    return 0;
}

Куча

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

Мы могли бы реализовать такую структуру с помощью простого массива, в котором приоритеты расположены по возрастанию, и вставлять новый приоритет в массив, сдвигая хвост массива так, чтобы он вновь был отсортирован. Ясно, что при вставке случайного приоритета нам в среднем придётся сдвигать около половины массива и операция вставки будет занимать время O(N). К счастью, специально для такой задачи существует структура данных под названием куча, которая позволяет извлекать и добавлять элементы за время O(log(N)).

Куча – это:

  1. Бинарное дерево (один корневой узел, у каждого узла не более двух дочерних узлов.
  2. В дереве должны быть заполнены до конца все уровни, кроме последнего. Если последний заполнен не полностью, его следует заполнять слева направо.
  3. Значение в каждом узле должно быть не меньше, чем в его дочерних узлах, если они есть.

Пример кучи показан на рисунке 1.

_images/heap01.png

Сразу же возникает вопрос: как хранить такое дерево в памяти компьютера, ведь она, как мы помним, линейна? Ответ на такой вопрос достаточно прост: мы можем уложить все элементы дерева по очереди в массив, начиная с верхнего уровня и располагая элементы одного уровня слева направо.

_images/heap02.png

При этом, из индекса родительского узла parent индексы дочерних получаются с помощью выражений 2 * parent + 1 и 2 * parent + 2. Обратное преобразование и для левых, и для правых дочерних узлов child даётся выражением (child - 1) / 2.

Если нужно добавить элемент в кучу, он добавляется на последнюю позицию нижнего уровня (или на новый уровень, если нижний заполнен). При этом вовсе не обязательно будет выполняться свойство 3. Чтобы оно выполнилось, мы обменяем местами вновь добавленный элемент и родительский элемент. Если после этого для новой позиции добавленного элемента вновь не выполняется свойство 3, мы вновь поменяем местами два элемента. И так пока родительский элемент не окажется больше добавленного или мы не переместим новый элемент на верхний уровень. Пример на рисунке.

_images/heap03.png

Извлечение элемента происходит в несколько этапов:

  • Сохранение максимума (см. рис. 4.1) На верхнем уровне уже находится нужный нам максимальный элемент. Мы сразу же получаем результат, но так как сам элемент нужно удалить из кучи, над восстановлением свойств кучи придётся немного повозиться.
  • Этап опускания “пустого” элемента (рис. 4.2, 4.3). Чтобы для пустого узла выполнялось свойство 3, мы берём максимальных и дочерних элементов (их может быть как 2, так и 1) и записываем его в пустой узел. Повторяем этот процесс, считая только что перемещённый узел пустым, пока не дойдём до нижнего уровня дерева.
  • Этап восставновления свойства 1 Как видно на рис. 4.4, вовсе не обязательно пустой элемент окажется в конце последнего заполненного уровня, и возможно нарушения заполнения уровней кучи. Мы просто переместим последний заполненный элемент кучи в образовавшуюся дырку, и уменьшим счётчик числа элементов на 1, “удалив” тем самым последний элемент.
  • Этап подъёма элемента Так же вовсе не обязательно, что перемещённый элемент будет меньше своего нового родительского элемента (см. рис. 4.5), и его надо “поднять” по дереву по алгоритму, уже описанному для добавления элемента в кучу.
_images/heap04.png