Математические основы алгоритмов, осень 2019 г. Лекция 7: B-деревья. Строковые алгоритмы. Полиномиальное хэширование: алгоритм Рабина–Карпа, наибольшая общая подстрока, самый длинный палиндром∗ Александр Охотин 1 июля 2020 г. Содержание 1 B-деревья 1.1 Вставка в B-дереве . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2 Удаление в B-дереве . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.3 Понятие о красно-чёрных деревьях . . . . . . . . . . . . . . . . . . . . . . . . . 1 2 3 4 2 Полиномиальное хэширование 2.1 Хэш-функции для строк . . . 2.2 Алгоритм Рабина–Карпа . . . 2.3 Наибольшая общая подстрока 2.4 Самый длинный палиндром . 4 4 5 5 6 1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . B-деревья Двоичные деревья поиска рассчитаны на хранение в оперативной памяти компьютера, позволяющей за одну операцию обратиться не более чем к нескольким байтам. Каждая вершина дерева может быть обработана за несколько таких операций, и такое использование оперативной памяти оптимально. Для хранения деревьев во внешней, медленной памяти (такой, как жёсткий диск) структуру данных необходимо адаптировать. Главная особенность внешней памяти в том, что за одну операцию читается или записывается блок данных размером в несколько килобайт — например, сектор на жёстком диске. Поэтому для доступа к одной вершине двоичного дерева пришлось бы работать с целым блоком, и поиск в дереве с n вершинами потребовал бы порядка log2 n операций с блоками. Это неоптимально. Предложенные Байером и Маккрайтом [1972] B-деревья — это адаптация деревьев поиска для хранения во внешней памяти. Главная мысль — использовать вершины большой степени — с тем, чтобы каждая вершина занимала один блок, а высота дерева уменьшилась бы. Например, если все вершины имеют степень 1000, то высота дерева с миллиардом вершин будет равна всего лишь трём (а не тридцати, как у двоичного дерева), и поиск нужного листа потребует прочитать лишь 4 блока. ∗ Краткое содержание лекций, прочитанных студентам 1-го курса СПбГУ, обучающимся по программам «Математика» и «МАиАД», в осеннем семестре 2019–2020 учебного года. Страница курса: http://users.math-cs.spbu.ru/~okhotin/teaching/algorithms_2019/. 1 Рис. 1: Рудольф Байер (род. 1939), Эдвард Маккрайт. Пусть вершины в двоичном дереве — это «2-вершины», поскольку у каждой из них 2 потомка и 1 значение, по которому эти потомки разделяются. У m-вершины — m потомков (деревья t1 , . . . , tm — возможно, пустые), и в ней находится m − 1 значение: x1 , . . . , xm−1 , где x1 6 . . . 6 xm−1 . Все значения в каждом поддереве ti больше или равны xi и меньше или равны xi+1 , как показано на рис. 2. В B-дереве могут одновременно содержаться вершины различных степеней: выбирается некоторое число k > 2, после чего корень может иметь степень от 0 до 2k, а все остальные вершины — любые степени от k до 2k. При этом дерево сбалансировано: длины всех путей равны. x1 ... xi –1 x1 xi –1 t1 xi ... xm –1 xi xm –1 ti tm Рис. 2: m-вершина в B-дереве При поиске элемента x в B-дереве, на каждом шаге рассматривается некоторая mвершина, в которой размещены значения x1 , . . . , xm−1 . В этом массиве запускается двоичный поиск элемента x. Если x находится в массиве, то поиск в дереве на этом завершён, а если нет, то двоичный поиск указывает на поддерево ti , для которого верно xi−1 < x < xi , и потому элемент x может находиться только в нём. Поиск продолжается в поддереве ti . 1.1 Вставка в B-дереве В АВЛ-дереве вставка и удаление начинается с поиска данного элемента, после чего исправляется возможная разбалансировка. В худшем случае придётся пройти путь от корня к листу, а потом обратно от листа к корню. В B-дереве вставка и удаление делаются иначе: исправление потенциальной разбалансировки начинается уже на этапе поиска удаляемого элемента в дереве. Пусть вставка или удаление производятся в листе, и это m-вершина. Тогда, если этот лист заполнен не до конца (m < 2k), то в нём найдётся место для дополнительного значения, а если он заполнен не минимально (m > k), то из него можно удалить любое из его значений. При вставке, спускаясь вниз по дереву, нужно разделять каждую очередную встреченную «полную» 2k-вершину на две k-вершины — за счёт её сестёр или родительницы. При 2 этом одно лишнее значение выталкивается на один уровень выше, как на рис. 3. Поскольку на уровне выше не может быть 2k-вершины (ведь алгоритм только что оттуда спустился), в ней есть место, в которое можно вытолкнуть лишнее значение. В итоге найденный лист тоже окажется степени не более чем 2k − 1 — то есть, в нём будет не более чем 2k − 1 пустых указателей на несуществующих потомков, и между ними не более чем 2k −2 значений; следовательно, в этом листе найдётся, куда вставить новое значение. y xyz t1 t2 x t3 t4 z t1 t2 t3 t4 Рис. 3: Разделение вершины при вставке в B-дереве, для k = 2: (слева) переполненная 4-вершина; (справа) выталкивание переполнения наверх. Что делать, если при вставке окажется, что степень корня — 2k? Здесь важно, что степень корня не ограничена снизу. Тогда корень точно так же разделяется на две k-вершины, лишнее значение так же выталкивается на уровень выше, где появляется новый корень степени 2, с одним значением. Высота дерева увеличивается только в этом случае. 1.2 Удаление в B-дереве При удалении, спускаясь вниз, нужно точно так же увеличивать каждую встреченную kвершину за счёт её сестёр. Для всякой встреченной k-вершины уже обеспечено, что её родительница — не менее чем (k + 1)-вершина. Значит, у текущей k-вершины есть не менее k сестёр. • Если одна из соседних сестёр — не менее чем (k + 1)-вершина, то недостающее поддерево перегоняется из неё, как показано на рис. 4. y z z x x y ... ... t t Рис. 4: Заимствование поддерева у соседней сестры при удалении в B-дереве: (слева) малоимущая k-вершина, выделенная красным; (справа) поддерево заимствовано у сестры справа. • Если же соседняя сестра — k-вершина, то она объединяется с текущей k-вершиной в одну 2k-вершину — это в точности обратная операция к разделению вершины, изображённому на рис. 3. При этом у родительницы станет на одно поддерево меньше, но она это переживёт, поскольку она не k-вершина. 3 Когда же наконец находится вершина, содержащая удаляемое значение, если это лист, то значение просто удаляется (он же степени хотя бы k + 1!). Если же нужно удалить элемент, находящийся во внутренней вершине, то запоминается указатель на эту вершину, после чего делается следующее. • Если в предшествующем значению поддереве, в его корне есть хотя бы k + 1 значение, то в поддереве находится значение-предшественник, и процедура стирания продолжается уже для него (а само оно замещает стираемый элемент). • Аналогично в следующем поддереве. • Если же у соседних поддеревьев по k значений, то они объединяются в одно с 2k значениями. Корень нужно рассмотреть особо. Поскольку его степень не ограничена снизу, сам по себе он не нуждается в исправлении. Однако если корень — 2-вершина, а оба его потомка — k-вершины, то исправление этих k-вершин приведёт к полному опустошению корня. В этом случае все три вершины объединяются в одну 2k-вершину, а высота дерева уменьшается на единицу. 1.3 Понятие о красно-чёрных деревьях B-дерево для k = 2 называется 2-3-4-деревом, и такое дерево удобно хранить в оперативной памяти. Оно работает незначительно быстрее АВЛ-дерева, однако требует огромного объёма кода для своей реализации. Красно-чёрное дерево — это представление 2-3-4-дерева, в котором каждая 3-вершина разбита на две двоичных, а каждая 4-вершина — на три двоичных, и они определённым образом раскрашены в два цвета. Каждая операция над 2-3-4-деревом представляется в виде нескольких более простых операций над красно-чёрными деревьями. Поэтому красночёрные деревья существенно удобнее программировать, и они успешно используются на практике. 2 Полиномиальное хэширование Строки над алфавитом Σ, подстроки, префиксы, суффиксы. Задача 1 (Поиск в строке). Дана длинная строка («текст») w = a1 . . . an , и короткая искомая строка («шаблон») x = b1 . . . bm . Требуется найти все вхождения x в w в качестве подстроки, то есть, все смещения s, для которых подстрока ws = as+1 . . . as+m совпадает с b1 . . . bm . «Наивный» алгоритм: сравнивать все m символов для каждого s, время O(mn). В худшем случае достигается, пример: x = 0m−1 1 и w = 02m−1 1, всего m2 сравнений. Но можно искать быстрее. 2.1 Хэш-функции для строк Каждой строке ставится в соответствие число: w h(w). Пусть ключи в таблице — это строки над алфавитом Σ. Как лучше определить хэшфункцию? 4 Самое простое, что можно придумать: сложить коды всех символов, взять сумму по модулю размера таблицы. Недостаток: распределение кодов неравномерно! Поэтому и сумма будет распределена неравномерно, и существенная часть элементов таблицы не будет использоваться, а строки, совпадающие с точностью до порядка символов, получат одинаковые значения. Это совсем плохо. Полиномиальное хэширование: берётся P некоторое основание степени p. Пусть w = a1 . . . a` — строка. Тогда используется сумма `i=1 ai · p`−i , взятая по некоторому модулю M . 2.2 Алгоритм Рабина–Карпа Алгоритм, предложенный Карпом и Рабином [1987] основан на полиномиальном хэшировании степени m − 1: сперва вычисляется значение хэш-функции для искомой строки, а затем для всех m-символьных подстрок данного текста последовательно вычисляется значение их хэш-функции. Когда значение для подстроки совпадает со значением для искомой строки, алгоритм проводит прямое сравнение символов, как в «наивном» алгоритме. Рис. 5: Майкл Рабин (род. 1931) и Ричард Карп (род. 1935). P m−i Значение хэш-функции для искомой строки: X = m i=1 bi · p P по модулю q. m−i по модулю Значение хэш-функции для подстроки со смещением s: Ws = m i=1 as+i · p q. Алгоритм сперва вычисляет X, а затем последовательно вычисляет Ws для s от 0 до n − m. Удобство этой хэш-функции в том, что каждое следующее значение Ws+1 можно вычислить на основе значения Ws за O(1) шагов. Для этого достаточно заметить, что числа pWs и Ws+1 отличаются всего на два слагаемых. pWs = as+1 pm + as+2 pm−1 + . . . + as+m p Ws+1 = as+2 pm−1 + . . . + as+m p + as+m+1 Отсюда Ws+1 получается из Ws по следующей формуле (вся арифметика — по модулю q). Ws+1 = p · Ws − as+1 · pm + as+m+1 Сложность: Θ(m) на подготовку, и затем в худшем случае O(mn), если хэш-функция выдаст одинаковые значения для всех подстрок. Это неизбежно, если, например, все подстроки одинаковы, то есть, w = an и x = am . Но в среднем случае получается время работы Θ(n). 5 2.3 Наибольшая общая подстрока Полиномиальное хэширование позволяет легко построить относительно неплохие, пусть и не самые оптимальные, алгоритмы решения ряда задач. Задача 2 (Наибольшая общая подстрока). Дано: u = a1 . . . am и v = b1 . . . bn . Найти самую длинную строку x, для которой u = u1 xu2 и v = v1 xv2 . Наивное решение: для каждой пары позиций найти самую длинную подстроку, время O((m + n)3 ). Как можно сделать это лучше? Сперва с помощью полиномиального хэширования надо научиться отвечать на вопрос «Есть ли общая подстрока длины `?» за время O((m + n) log(m + n)), Для этого находятся значения хэш-функции для всех подстрок u длины `, размещаются в двоичном дереве поиска — время m log m. То же самое — для v, время n log n. Далее исследуются совпадающие значения. Наконец, используется двоичный поиск по `. Его можно записать в виде рекурсивной процедуры, отвечающей на вопрос «Найти длину наибольшей общей подстроки, если известно, что её длина не меньше, чем `1 , и строго меньше, чем `2 ?» (алгоритм 1). А можно то же самое сделать и без рекурсии. Алгоритм 1 Нахождение длины наибольшей общей подстроки двоичным поиском процедура f (`1 , `2 ) 1: if `2 − `1 = 1 then 2: `return `1 1 +`2 3: k = 2 4: if есть общая подстрока длины k then 5: return f (k, `2 ) 6: else 7: return f (`1 , k) 2.4 Самый длинный палиндром Обращение строки — запись всех символов в обратном порядке: (a1 a2 . . . an−1 an )R = an an−1 . . . a2 a1 . Если u = uR , такая строка называется палиндромом. Задача 3 (Самый длинный палиндром). По данной строке w = a1 . . . an найти самую длинную её подстроку-палиндром. Наивный алгоритм: искать вокруг каждого символа на длину вплоть до n2 , время O(n2 ). Улучшенный алгоритм основан на решении подзадачи: «Есть ли подстрока-палиндром данной длины `?» После того, как будет построен алгоритм для решения подзадачи, останется использовать двоичный поиск по `. Задачу поиска палиндромов заранее заданной длины ` можно переформулировать так. Подстрока ai+1 . . . ai+` строки w — палиндром, если она совпадает со строкой ai+` . . . ai+1 , которая в свою очередь встречается в строке wR , начиная с позиции n − i − ` + 1. Стало быть, нужно проверять на равенство подстроки длины `, начинающиеся с позиции i + 1 в w и с позиции n − i − ` + 1 в wR . Это удобно сделать, вычислив значения полиномиального 6 хэша для всех подстрок длиныX w и wR длины `, а затем для каждого i сравнить хэш w для позиции i + 1 и хэш wR для позиции n − i − ` + 1. Для каждого совпадения придётся проверять подстроки на равенство посимвольно, но если хэш-функция не подведёт, это будет требоваться очень редко, так что среднее время будет линейным. Двоичный поиск по ` даст время O(n log n). Список литературы [1972] R. Bayer, E. M. McCreight, “Organization and maintenance of large ordered indexes”, Acta Informatica, 1:3 (1972), 173–189. [1978] L. J. Guibas, R. Sedgewick, “A dichromatic framework for balanced trees”, FOCS 1978, 8–21. [1987] R. M. Karp, M. O. Rabin, “Efficient randomized pattern-matching algorithms”, IBM Journal of Research and Development, 31:2 (1987), 249–260. 7