Бинарные деревья поиска
В разделе Поиск сравнениями мы убедились, что деревья облегчают понимание бинарного поиска и его модификаций. Деревья там вводились не явно, а как иллюстрация дерева решений при выполнении алгоритмов. Явная структура дерева помогает обрести дополнительные возможности в виде быстрой вставки и удаления элементов.
Бинарное дерево поиска
Бинарное дерево поиска (BST, binary search tree) — бинарное дерево, в котором для каждого узла все узлы левого поддерева меньше его, и все узлы правого поддерева больше его. BST позволяет добавлять, удалять и искать элементы за время .
Бинарное дерево поиска построить на любом отношении тотального порядка. Это отношение порядка может быть задано не на самих данных, а на их ключах.
Обходы бинарного дерева
Обход по порядку (inorder)
Необходимо вывести все узлы BST в отсортированном порядке. Для этого, по свойству BST, необходимо обойти в отсортированном порядке левое от корня поддерево, затем пройти по корню, затем обойти в отсортированном порядке правое от корня поддерево. Временная сложность при на итерацию.
iterator inorder(node root):
if root == none:
stop
yield from inorder(root.left)
yield root
yield from inorder(root.right)Прямой обход (preorder)
Необходимо вывести все узлы BST в порядке: вершина, все левое поддерево, все правое поддерево. Временная сложность при на итерацию.
iterator preorder(node root):
if root == none:
stop
yield root
yield from preorder(root.left)
yield from preorder(root.right)Обратный обход (postorder)
Необходимо вывести все узлы BST в порядке: все левое поддерево, все правое поддерево, вершина. Временная сложность при на итерацию.
Такой обход полезен, например при удалении всех вершин, ведь сначала обходятся вершины без детей,
а дети всех последующих вершин гарантированно были выведены ранее.
Поэтому можно освобождать память без риска словить nullptr.
iterator postorder(node root):
if root == none:
stop
yield from postorder(root.left)
yield from postorder(root.right)
yield rootОптимальные деревья
Построение оптимальных деревьев
Как мы уже убедились, эффективность алгоритмов сильно зависит от структуры дерева. А поскольку бинарных деревьев поиска, построенных на заданном массиве длины много, аж целых штук, встаёт вопрос о выборе оптимального бинарного дерева поиска для ключей с заданными частотами.
Давайте для начала поймём, как считать оптимальность. Пусть , то есть массив состоит из ключей , и . Пусть также вероятности ключей соответственно. Всего возможно деревьев
наверное картинка
Интересует нас как успешный, так и неудачный поиск. В некоторых специфических задачах неудачный поиск даже являются определяющим, причём настолько, что при анализе эффективности успешным поиском пренебрегают.
Итак, мы ищем в бинарном дереве поиска с ключами ключ
Определим вероятностей и :
Получается, что
Обозначим за ожидаемое количество сравнений при поиске, а точнее сумму количеств сравнений при успешном и неудачном поиске. Получаем формулу
здесь — узел с номером при in-order обходе дерева, и — фиктивный лист с номером .
Ожидаемое количество сравнений при поиске будем называть ценой дерева. Оптимальное дерево будет иметь наименьшую цену. При этом можно убрать требование на то, что сумма вероятностей должна быть , и рассматривать произвольный набор чисел , который далее буду именовать весами дерева.
Как же находить оптимальные деревья? В лоб минимизировать не получится — задача вычислительно очень сложная.
Важный факт, делающий возможным достаточно эффективные алгоритмы: все поддеревья оптимального дерева тоже оптимальны. Запахло динамикой.
Пусть — цена оптимального поддерева, содержащего ключи с по с весами . Пусть также — сумма этих весов. Оба показателя определены для .
Минимально возможная цена дерева с корнем равна . Значит
Обозначим за при множество всех , при которых достигается минимум в формуле динамики. Это множество определяет корни оптимальных поддеревьев.
Теперь можем вычислять значения с помощью восходящего динамического программирования, рассматривая интервалы в порядке от до .
Нам нужно посчитать значения для всех . Всего нам потребуется ячеей памяти
Каждый подсчёт требует выполнений блока минимизации, значит всего для вычисления всей динамики понадобится операций
Итого на подсчёт динамики нам нужно ячеек памяти и время .
Мы можем использовать оптимизацию Кнута и добиться временной сложности .
Введём для произвольных множеств действительных чисел и отношение :
На русский язык это отношение можно перевести так: «если какой-то элемент из множества меньше какого-то элемента из множества , то эти два элемента должны принадлежать обоим множествам одновременно». Другими словами, минимальный элемент объединения лежит в , если .
По индукции по доказывается следующее «неравенство» для
Можно не хранить всё , а брать только одного представителя . Наше «неравенство» для множеств превращается в неравенство для представителей :
Получается, что в рекуррентной формуле при вычислении минимального можно брать всего значений от до вместо значений от до .
Для интервала фиксированной длины общее количество проверяемых значений при вычислении всех равно
Тогда общее количество проверяемых значений равно
Точное значение зависит от , поэтому в общем виде его вычислить нельзя. Грубую оценку можно получить, оценив величину , поэтому .
Давайте теперь составим алгоритм.
Нам даны весов . Алгоритм будет строить оптимальное бинарное дерево поиска, при этом дерево будет представлено как каскад вложенных поддеревьев.
function knuth_bst(array[real] p, array[real] q)
-> (matrix[real] c, matrix[int] r, matrix[real] w):
# p: array of n probabilities for successful search [p1, p2, ..., pn]
# q: array of n+1 probabilities for unsuccessful search [q0, q1, ..., qn]
# Returns
# c: cost matrix
# r: root matrix
# w: weight matrix
matrix[real] c[n+1, n+1]
matrix[int] r[n+1, n+1]
matrix[real] w[n+1, n+1]
for int i = 0; i <= n; i++:
c[i][i] = 0
w[i][i] = q[i]
for int j = i+1; j <= n; j++:
w[i][j] = w[i][j-1] + p[j-1] + q[j]
for int j = 1; j <= n; j++:
int i = j - 1
c[i][j] = w[i][j]
r[i][j] = j
real current_val
real min_val
int min_k
for int d = 2; d <= n; d++:
for int j = d; j <= n; j++:
int i = j - d
min_val = inf
min_k = -1
for int k = r[i][j-1]; k <= r[i+1][j]; k++:
current_val = c[i][k-1] + c[k][j]
if current_val < min_val:
min_val = current_val
min_k = k
c[i][j] = w[i][j] + min_val
r[i][j] = min_k
return c, r, w
struct tree:
int root
tree left
tree right
function build_optimal_tree(matrix[int] r, int i, int j) -> tree:
# r: root matrix
# i, j: subtree indexes
if i == j:
return empty
return tree(
root = r[i][j],
left = build_optimal_tree(r, i, r[i][j] - 1),
right = build_optimal_tree(r, r[i][j], j)
)
input array[real] p[n]
input array[real] q[n+1]
c, r, w = knuth_optimal_bst(p, q)
tree bst = build_optimal_tree(r, 0, n)
output bstАлгоритм рабочий, но, к сожалению, для больших не подходит. Поскольку алгоритм требует ячейки памяти для хранения цен, в ГБ оперативной памяти сможет поместиться расчёт для .