Петер Брасс Усовершенствованные структуры данных Advanced Data Structures PETER BRASS City College of New York Усовершенствованные структуры данных ПЕТЕР БРАСС Нью-Йоркский городской колледж Москва, 2023 УДК 004.422.63 ББК 32.973.05 Б87 Б87 Петер Брасс Усовершенствованные структуры данных / пер. с англ. Е. В. Борисова, А. Н. Киселева. – М.: ДМК Пресс, 2023. – 426 с.: ил. ISBN 978-5-97060-873-9 В книге приводится всесторонний анализ идей и деталей реализации структур данных как важнейшей составляющей прикладных алгоритмов. Обсуждаются не только эффективные способы реализации операций над множествами чисел, интервалов или строк, представленных в виде различных поисковых структур данных – деревьев, множеств интервалов, кусочно-постоянных функций, прямоугольных областей, непересекающихся подмножеств, куч, хеш-таблиц, но и динамизация и персистентность (сохраняемость) структур. Структуры данных впервые рассматриваются не просто как вспомогательный материал для иллюстрации методологии объектно ориентированного программирования, а как ключевой вопрос разработки алгоритмов. Многочисленные примеры кода на языке C и более 500 ссылок на первоисточники делают книгу исключительно ценной. УДК 004.422.63 ББК 32.973.05 Copyright Original English language edition published by Cambridge University Press is part of the University of Cambridge. Russian language edition copyright © 2023 by DMK Press. All rights reserved. Все права защищены. Любая часть этой книги не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав. Материал, изложенный в данной книге, многократно проверен. Но, поскольку вероятность технических ошибок все равно существует, издательство не может гарантировать абсолютную точность и правильность приводимых сведений. В связи с этим издательство не несет ответственности за возможные ошибки, связанные с использованием книги. ISBN 978-0-52188-037-4 (англ.) ISBN 978-5-97060-873-9 (рус.) © Peter Brass, 2008 © Оформление, перевод на русский язык, издание, ДМК Пресс, 2023 Посвящается моим родителям Гизелле и Хельмуту Брассам Оглавление От авторов перевода..............................................................................................................8 Предисловие..........................................................................................................................10 Основные понятия.......................................................................................................... 12 Примеры кода.................................................................................................................. 14 Глава 1. Элементарные структуры.....................................................................................16 1.1. Стек............................................................................................................................ 16 1.2. Очередь..................................................................................................................... 23 1.3. Двусторонняя очередь.............................................................................................. 30 1.4. Динамическое выделение памяти для элементов................................................. 30 1.5. Теневые копии структур в массиве......................................................................... 32 Глава 2. Деревья поиска......................................................................................................37 2.1. Две модели деревьев поиска................................................................................... 37 2.2. Общие свойства и преобразования......................................................................... 40 2.3. Высота дерева поиска............................................................................................... 43 2.4. Основные операции: поиск, добавление, удаление............................................... 44 2.5. Возврат от листа к корню......................................................................................... 49 2.6. Повторяющиеся ключи............................................................................................ 50 2.7. Интервалы ключей.................................................................................................... 51 2.8. Построение оптимальных деревьев поиска........................................................... 53 2.9. Преобразование деревьев в списки........................................................................ 59 2.10. Удаление деревьев.................................................................................................. 60 Глава 3. Выровненные деревья поиска.............................................................................62 3.1. Выровненные по высоте деревья............................................................................ 62 3.2. Выровненные по весу деревья................................................................................. 72 3.3. (a, b)- и B-деревья..................................................................................................... 82 3.4. Красно-черные деревья и деревья почти оптимальной высоты.......................... 96 3.5. Нисходящее выравнивание красно-черных деревьев.........................................107 3.6. Деревья с постоянным временем обновления в заранее известном месте.......116 3.7. Пальцевые деревья и поуровневое связывание...................................................118 3.8. Деревья с частичной перестройкой: средняя оценка сложности.......................123 3.9. Дерево с всплывающими узлами: изменяемая структура данных.....................126 3.10. Списки с пропуском элементов: вероятностные структуры данных...............137 3.11. Соединение и разделение выровненных деревьев поиска...............................144 Глава 4. Древовидные структуры на множестве интервалов.................................... 149 4.1. Деревья пересекающихся отрезков.......................................................................149 4.2. Деревья полуоткрытых интервалов......................................................................154 4.3. Деревья объединения интервалов........................................................................161 4.4. Деревья сумм взвешенных интервалов................................................................168 4.5. Деревья поиска интервалов с ограниченной максимальной суммой весов......173 4.6. Деревья прямоугольных областей.........................................................................178 4.7. Деревья многомерных интервалов.......................................................................190 4.8. Блочные структуры данных...................................................................................193 Оглавление 7 4.9. Подсчет точек и модель полугруппы.....................................................................195 4.10. kd-деревья и связанные с ними структуры.........................................................197 Глава 5. Кучи....................................................................................................................... 202 5.1. Куча как выровненное дерево...............................................................................203 5.2. Куча в массиве........................................................................................................207 5.3. Куча как упорядоченное и полуупорядоченное дерево......................................213 5.4. Левосторонние кучи...............................................................................................218 5.5. Косые кучи..............................................................................................................225 5.6. Двоичные кучи.......................................................................................................228 5.7. Изменение ключей в кучах....................................................................................236 5.8. Кучи Фибоначчи.....................................................................................................238 5.9. Кучи оптимальной сложности...............................................................................248 5.10. Двусторонние и многомерные кучи...................................................................253 5.11. Связанные с кучей и постоянным временем обновления структуры..............257 Глава 6. Системы непересекающихся множеств и связанные с ними структуры... 263 6.1. Непересекающиеся множества: объединение классов разделов........................264 6.2. Система непересекающихся множеств с поддержкой копирования и динамическими деревьями интервалов...........................................................277 6.3. Разделение списка..................................................................................................287 6.4. Проблемы ориентированных корневых деревьев...............................................291 6.5. Поддержание линейного порядка.........................................................................301 Глава 7. Преобразования структуры данных................................................................. 305 7.1. Создание динамических структур.........................................................................305 7.2. Динамические структуры с сохранением истории..............................................314 Глава 8. Структуры данных для строк............................................................................ 319 8.1. Проверки и сжимаемые проверки........................................................................320 8.2. Словари, допускающие появление ошибок в запросах.......................................337 8.3. Суффиксные деревья..............................................................................................341 8.4. Суффиксные массивы.............................................................................................347 Глава 9. Хеш-таблицы........................................................................................................ 355 9.1. Основные хеш-таблицы и разрешение конфликтов............................................355 9.2. Универсальные семейства хеш-функций.............................................................360 9.3. Идеальные хеш-функции.......................................................................................370 9.4. Хеш-деревья............................................................................................................375 9.5. Расширяемое хеширование...................................................................................376 9.6. Проверка принадлежности ключей и фильтры Блума........................................380 Глава 10. Приложение....................................................................................................... 383 10.1. Ссылочная машина и другие модели вычислений.............................................383 10.2. Модели внешней памяти и алгоритмы без учета кеш-памяти.........................385 10.3. Названия структур данных..................................................................................386 10.4. Решение линейных рекуррентных соотношений..............................................386 10.5. Медленно растущие функции..............................................................................388 Глава 11. Список публикаций........................................................................................... 390 Предметный указатель..................................................................................................... 423 От авторов перевода Это не первый наш опыт перевода книги, изданной много лет назад (Вирт, Гуткнехт). Но тем не менее мы взялись за перевод этой якобы старой книги, изданной 14 лет назад, потому что она не потеряла своей ценности и по сей день представляет интерес не только для профессионалов, но и для студентов, поскольку в ней подробно описывается, как путем незначительных усовершенствований давно и хорошо известных программистам структур данных можно добиться существенной эффективности работы с ними, что автор подкрепляет еще и доказательствами. В сущности, книга представляет собой обзор различных способов модернизации давно известных структур данных с целью повышения их производительности. Поскольку в книге по большей части речь идет об оценке времени вычисления алгоритмов, то в таких случаях она подразумевается неявно. В тех же случаях, когда речь идет об оценке затрат памяти алгоритма, это явно оговаривается. В целом книга представляет собой довольно полный обзор различных методов и приемов, позволяющих в той или иной степени ускорить работу с традиционными структурами данных, приводя необходимые обоснования (доказательства) их эффективности и во многих случаях – алгоритмы их реализации на языке программирования C. К сожалению, автор нередко приводит вместо программной реализации алгоритмов их словесное описание, что несколько затрудняет их понимание. Книга может послужить хорошим справочником для всех тех, кто хочет понять, за счет каких приемов и методов можно ускорить работу с общеизвестными структурами данных. Большинство так называемых автором «теорем» – это не столько теоремы, сколько выводы из предшествующих этим «теоремам» рассуждений. Книга насыщена такими «теоремами», но без доказательств. Обоснования этих «теорем» не всегда точны и убедительны. Те читатели, кто не интересуются ими, могут довериться автору и просто пропустить их. Ну а тем, кто хочет разобраться в них досконально, придется приложить определенные усилия или обратиться к многочисленным первоисточникам. Конечно, эта книга адресована не столько программистам-любителям, сколько профессионалам, включая преподавателей и студентов, а также другим специалистам, интересующимся этой темой. Нужно сказать, что эта книга не для всех, а только для тех, кто хочет понять, с помощью каких методов и приемов можно достичь значительного ускорения работы алгоритмов с давно и многим известными структурами данных большого объема, включая базы данных. Эта книга придется не всем по зубам, но мы приложили максимум усилий для того, чтобы ее перевод на русский язык хотя бы немного расширил круг ее читателей, и все найдут в ней немало познавательного и полезного. О переводе В главе 10 автор совершенно справедливо говорит о том, что термины и понятия в оригинальных работах не всегда удачны. Поэтому при переводе мы, конечно же, старались придерживаться авторской терминологии и тех оригинальных работ, на которые он ссылается, но изредка позволяли себе употреблять более понятные, с нашей точки зрения, русскоязычные термины. Что из этого получилось, судить читателю. Несмотря на то что книга может заинтересовать довольно узкий круг читателей, мы надеемся, что наш перевод несколько расширит этот круг. Оригинал книги представляет собой скорее конспект лекций, чем детально продуманную монографию. Мы постарались превратить ее в полноценную монографию и, надеемся, интересную книгу для более широкого круга читателей – от студентов и препо- От авторов перевода 9 давателей до профессиональных программистов (специалистов), которые, как мы думаем, не разочаруются в ней. В предисловии к книге автор пишет, что она родилась из его набросков лекций по данной теме. И к сожалению, этот набросочный стиль изложения материала отчасти сохранился и в самой книге. Поэтому при переводе книги нам пришлось немало потрудиться, чтобы превратить авторские наброски в полноценную монографию, качество которой оценит наш читатель. Несколько слов о том, что красной нитью проходит по всей книге, – это оценка пространственно-временнóй сложности алгоритмов. Для них обычно используются обозначения: O – верхняя асимптотическая оценка сложности для худшего случая, Ω – нижняя асимптотическая оценка для худшего случая и Θ – жесткая асимптотическая оценка, когда сложность алгоритма для худшего случая не может выходить за пределы определяемых ею границ. Кроме того, нередко в книге идет речь об амортизированной (amortized) оценке вычислений, которую мы называли средней или усредненной. Чаще всего речь в книге идет именно об асимптотической оценке временнóй сложности алгоритмов для худшего случая и лишь изредка оценивается нижняя, жесткая, средняя и пространственная сложность алгоритмов. При переводе книги мы позволили себе ради ее улучшения, помимо прочих незначительных вольностей, пронумеровать все непронумерованные авторские рисунки, а также обширный список публикаций. Терминология Сложность алгоритма – это оценка его стоимости, цены и прочих затрат времени или памяти. Временнáя сложность – это количество выполняемых операций или просто время их выполнения. Пространственная сложность – это затраты памяти на выполнение операций. Чаще всего автор имеет в виду временнýю сложность. hieght balanced tree – выровненное по высоте дерево или равновысокое дерево. weight balanced tree – уравновешенное или равновесное дерево. rebalancing – выравнивание. Формулы Некоторые несложные «многоэтажные» математические формулы в виде дробей мы иногда заменяли строчной записью, конечно же, без потери их смысла. Так, например, –1 ––––––––––––––––––––––––––––– log n (α log α + (1 – α)log(1 – α)) мы заменили на –1/(α log α + (1 – α)log(1 – α)) log n. Е. В. Борисов, С. В. Чугунов, Март, 2021 Предисловие Эта книга – учебник для слушателей курсов по структурам данных. Структура данных – это метод1 представления данных для выполнения над ними некоторого набора операций. Классический пример структуры данных – это множество распознаваемых по значению ключа элементов в виде пар (ключ, элемент), которые можно добавлять, удалять или искать в этом множестве по заданному ключу. Структура, поддерживающая такие операции, называется словарем (dictionary). Словари могут быть реализованы разными способами, с разной степенью сложности и разными дополнительными операциями. Во многих первоисточниках было предложено и исследовано множество видов словарей, и некоторые из них2 мы рассмотрим в этой книге. Вообще говоря, структура данных – это своего рода набор высокоуровневых операций некоторой виртуальной машины: если алгоритм многократно выполняет некоторые операции, можно определить, какие из них выполняются чаще всего и как их лучше реализовать. Таким образом, основная задача структур данных – как реализовать набор операций над ними, предполагаемый результат которых нам известен. Сегодня нет недостатка в книгах, в названии которых есть словосочетание «структуры данных», но они лишь поверхностно касаются этой темы, затрагивая только простейшие структуры – стек, очередь и некоторые виды выровненных деревьев поиска с изрядным количеством пафоса (handwaving). Серьезный интерес к структурам данных начали проявлять в 1970-х годах, а в первой половине 1980-х почти в каждом номере журнала Communications ACM была статья о них. Структуры данных стали центральной темой, отдельным пунктом в классификаторе информатики3 и стандартной частью учебных программ по информатике4. С выходом книги Вирта «Структуры данных + Алгоритмы = Программы» термин «алгоритмы и структуры данных» стал общепринятым в учебниках по этой теме. Правда, единственная монография Овермарса 1983 года [450], посвященная именно алгоритмическому аспекту структур данных, до сих пор находится в печати; это своего рода рекорд для серии книг LNCS (Lecture Notes in Computer Science). Структурам данных уделяется внимание во многих приложениях и прежде всего в базах данных как индексным структурам. В том же контексте в монографиях Самета [482, 483] были изучены структуры для 1 2 3 4 Эта книга не об объектно ориентированном программировании, поэтому термины «метод» и «объект» употребляются в ней в самом обычном смысле. Наиболее интересные и значимые. – Прим. перев. Классификационный код E.1. «Структуры данных». К сожалению, Классификатор информатики слишком груб, чтобы быть полезным. ABET (Accreditation Board for Engineering and Technology, Inc. – Прим. перев.) по-прежнему причисляет их к одной из пяти основных тем: алгоритмы, структуры данных, разработка ПО, языки программирования и архитектура компьютеров. Предисловие 11 геометрических данных; подобные структуры данных были исследованы Лангетепе и Заманом [346] для компьютерной графики. В последнее время были подробно изучены строковые структуры данных, востребованные в первую очередь приложениями биоинформатики. Не иссякает поток пуб­ ликаций по теории структур данных для вычислительной геометрии и комбинаторики. Однако во многих учебниках структуры данных рассмат­ риваются только как примеры для объектно ориентированного программирования, исключая их важнейший алгоритмический аспект – как реализовать нетривиальные структуры данных, не выходя при этом за пределы худших оценок вычислительной сложности. Цель этой книги – сосредоточиться на структуре данных как основе любого алгоритма. Недавно изданный Справочник по структурам данных [411] – это еще один шаг в этом же направлении. В книге приводится реальный код для многих обсуждаемых в ней структур данных и довольно подробная информация о большинстве других структур данных, реализация которых в книге не дается. Многие учебники избегают таких подробностей, что является одной из причин, по которой структуры данных не используются там, где они должны быть. Выбор рассматриваемых в этой книге структур данных почти всюду ограничивается только теми структурами, которые вписываются в модель так называемой ссылочной машины (pointer-machine)5, за исключением хеш-таблиц, которые рассматриваются из-за их практической важности. Все коды книги – сугубо иллюстративные и не предназначены для использования в качестве готовых модулей; их правильность не гарантируется. Большинство кодов доступны на моей домашней странице с минимальными возможностями их проверки. Эта книга начиналась с набросков курса, который я читал в зимнем семестре 2000 года в Берлинском свободном университете (Free University of Berlin). Я благодарю Христиана Кнауэра, который был тогда моим ассистентом; мы с ним многому научились. Затем я представил этот курс в осенних семестрах 2004–2007 годов аспирантам Нью-Йоркского городского колледжа (City College of New York), а в июле 2006 года взял его за основу для летней школы по структурам данных в Корейском институте передовых технологий (Korean Advanced Institute of Science and Technology – KAIST). Работа над книгой была закончена в ноябре 2007 года. Благодарю Эмили Войтек и Гюнтера Роте за выявленные в моих кодах ошибки, Отфрида Чонга – за организацию летней школы в KAIST, а также самих участников летней школы – за выявленные ими мои прочие ошибки. Благодарю Христиана Кнауэра и Хельмута Брасса за литературу из превосходных математических библиотек Берлинского свободного университета и Брауншвейгского технического университета, а также Яноша Паха за доступ к онлайн-журналам Института Куранта. Эта книга 5 Один из вариантов абстрактной машины с косвенной адресацией (Random Access Machine, сокращенно – RAM), предназначенной для теоретической оценки сложности алгоритмов. – Прим. перев. 12 Предисловие была бы невозможна без доступа к хорошим библиотекам, и я старался цитировать только те источники, которые сам просматривал. Данная книга не поддерживалась ни одним грантовым агентством. Основные понятия Структура данных моделирует некоторый абстрактный объект, обладающий набором операций, которые обычно можно разделить на: • операции создания и удаления; • операции обновления; • операции обращения к данным (запросы). В случае словаря мы создаем или удаляем объект, обновляем его, добавляя или удаляя его элементы, или запрашиваем его элемент путем обращения к нему. После создания объекта он изменяется посредством операций обновления. Операции обращения не изменяют сам абстрактный объект, но могут изменить вид объекта внутри структуры данных. Это называется адаптивной (adaptive) структурой данных, которая может перестраиваться после каждого обращения к ней с целью ускорения последующих обращений к ее данным. Допускающие подобные обновления и обращения структуры данных называются динамическими (dynamic). Правда, есть более простые структуры, которые создаются только один раз и допускают обращения к своим элементам без их изменения; они называются статическими (static). Дина­мические структуры данных, как более общие, предпочтительнее, однако мы не должны пренебрегать и статическими структурами, так как они являются строительными блоками для динамических структур, хотя для некоторых довольно сложных объектов, с которыми мы столкнемся, динамическая структура вообще неизвестна. Наша цель – определить и реализовать структуру данных так, чтобы она обеспечивала максимальную скорость работы с заданным абстрактным объектом. Размер структуры – еще один ее качественный показатель, но его влияние на скорость обычно незначительно. Для оценки скорости нужна некая мера. Обычно ею является размер самого объекта, а не его представление. Отметим, что в результате многократного применения операции обновления (добавления/удаления) размер объекта может даже уменьшиться. Наиболее общая мера сложности вычислений – это так называемая худшая (worst-case) сложность6. Таким образом, сложность операции с заданной структурой данных оценивается величиной O(f(n)), если в любом состоянии структуры данных после применения ряда операций обновления, приведших объект к размеру n, эта операция требует времени, 6 То есть асимптотическая верхняя граница вычислительной сложности алгоритма. Далее в книге автор называет эту оценку временем выполнения алгоритма, что не вполне правильно. – Прим. перев. Предисловие 13 не превышающего Cf(n) для некоторой константы C. Альтернативная, но более мягкая мера – это средняя (amortized) сложность7. Операция обновления имеет среднюю сложность O(f(n)), если существует такая функция g(n), что любая последовательность из m таких операций, не увеличивающих размер n самого объекта, требует времени не более g(n) + mCf(n), поэтому при многократном применении такой операции сложность вычислений в среднем не превысит Cf(n). Некоторые структуры данных можно отнести к случайным (randomized). Обращение к одному и тому же объекту выполняется неким случайным образом, и одни и те же действия над ним не всегда приводят к одинаковой последовательности шагов. В этом случае оценивается ожидаемая (expected) сложность операции; она тоже случайна и потому относится к худшей для всех объектов того же размера с теми же операциями над ними. В некоторых случаях худшая оценка сложности вычислений O(f(n)) может ухудшаться из-за большого размера результата (выхода) операции, влияющего на ее сложность. В таких случаях имеет значение еще и чувствительность алгоритма к выходу, размер которого влияет на скорость алгоритма. Операция имеет чувствительную к выходу (output-sensitive) сложность O(f(n) + k), если для объекта размера n и выхода размера k она требует времени не более C(f(n) + k). Для динамических структур данных время создания нового объекта обычно постоянно, поэтому нас будут интересовать главным образом сложность операций обновления объекта структуры и обращения к нему. Время удаления структуры размера n почти всегда равно O(n). Для статических структур данных объект размера n создается сразу и не обновляется, поэтому в этом случае нас будет интересовать только время его создания (предварительной обработки) и время обращения к нему. В этой книге loga n – это логарифм по основанию a, но если основание логарифма не указано, то оно равно 2. Для интервалов используется стиль записи Бурбаки: [a, b] и ]a, b[ – соответственно закрытый и открытый интервалы от a до b, а ]a, b] и [a, b[ – полуоткрытые интервалы с исключением, соответственно, начала и конца. Подобно приведенному выше обозначению O(·) для асимптотической верхней границы вычислительной сложности, мы будем использовать также обозначения Ω(·) – для нижней границы и Θ(·) – для жесткой (верхней и нижней) границы. Неотрицательная функция f принадлежит O(g(n)) или Ω(g(n)), если для некоторого положительного C и любых достаточно больших n выполняется условие f(n) ≤ Cg(n) или f(n) ≥ Cg(n) соответственно. Таким образом, функция f принадлежит Θ(g(n)), если она принадлежит одно7 Обычно это некая усредненная оценка сложности выполнения цепочки операций над структурой данных, когда оценивается их средняя сложность в худшем случае. Она применяется, когда наиболее сложные операции выполняются довольно редко, но средняя сложность всей цепочки операций оказывается вполне приемлемой. Отметим, что средняя оценка не является n вероятностной (ожидаемой). Это всего лишь средняя оценка a = (∑ i=1ti)/n, где t1, t2, …, tn – сложность (или времена) выполнения в худшем случае цепочки из n операций над структурой данных. – Прим. перев. 14 Предисловие временно O(g(n)) и Ω(g(n)). Здесь «достаточно большие n» – это те значения, для которых g(n) определена и положительна. Примеры кода Примеры кода в этой книге приведены в стандарте языка C, но они будут вполне понятны всем, кто знаком с любым другим императивным языком программирования. В примерах кода символ «=» обозначает присваивание, а символ «==» – проверку на равенство. За пределами кода символ «=» имеет обычный математический смысл. Логические операции отрицания, конъюнкции и дизъюнкции обозначаются в коде соответственно символами «!», «&&» и «||», а символ «%» обозначает операцию деления по модулю. Разыменование указателя (ссылки) выполняется операцией «*»: если pt – указатель на область памяти, то *pt – это сама область памяти. Указатели имеют тип, определяющий способ интерпретации содержимого этой области памяти. Таким образом, при объявлении указателя объявляется и тип области памяти, на которую он ссылается. Так, например, «int *pt;» объявляет указатель pt на область памяти типа int. Указатели – это переменные, которые допускают операции присваивания и добавления к ним целого числа (указательная арифметика). Если pt указывает на область памяти определенного типа, то pt + 1 указывает на следующую область памяти такого же типа. Иными словами, каждый объявленный указатель трактует всю память как большой массив однотипных элементов. NULL – это указатель, который не указывает ни на какую область памяти, и его можно использовать в качестве особого значения в операциях сравнения. Структура – это определяемый пользователем тип данных, представляющий собой последовательность именованных элементов (полей) любого типа. Элемент структуры тоже может быть структурой, но отличной от типа самой структуры, иначе структура становится рекурсивной, то есть бесконечной. Но зато ее элементом вполне может быть указатель на объект того же типа, что и сама структура. Именно такие структуры обычно бывают объектами в теории структур данных. Полям структур можно присваивать значения и использовать их подобно любым другим переменным. Если, например, переменная z определена как структура следующего типа C: typedef struct { float x; float у; } C, то ее элементы z.x и z.y имеют тип float. Если переменная zpt объявлена как указатель на объект типа C (C *zpt;), то она может ссылаться на (*zpt).x и (*zpt).y. Для такой часто используемой комбинации операций разыменования указателя и обращения к элементу структуры в языке C существует альтернативная операция zpt->x и zpt->y. Эти операции эквивалентны, но последняя предпочтительнее, поскольку снимает проблему приоритета Примеры кода 15 операций: разыменование имеет более низкий, чем обращение к элементу структуры, приоритет, поэтому (*zpt).x – это не то же самое, что *zpt.x. Во всех функциях мы избегаем рекурсивных вызовов, хотя в некоторых случаях они могут существенно упростить код. Однако значительные издержки рекурсивных вызовов функций вступают в противоречие с нашей целью достижения наивысшей скорости работы со структурами данных. При этом мы не противники рекурсивных функций, поскольку хороший компилятор сам может избавиться от них, используя встроенные функции или макросы. Кроме того, в тексте книги объект данных нередко будет отождествляться со ссылкой на него. Глава 1 Элементарные структуры Элементарные структуры данных стек и очередь обычно изучаются в курсе «Программирование 2». Их объединяет иногда упоминаемая, но редко используемая на практике двусторонняя очередь. Стек и очередь – основные структуры данных, именно поэтому они будут подробно обсуждаться и использоваться для иллюстрации отдельных моментов реализации других структур данных. 1.1. Стек Стек – простейшая из всех структур с очевидной интерпретацией: добавить (втолкнуть) объект в стек и затем удалить (вытолкнуть) его из стека с доступом только к верхнему объекту (вершине). По этой причине его иногда называют памятью типа LIFO (Last In, First Out – последним вошел, первым вышел)1. В программировании стеки встречаются всюду, где есть вложенные блоки, локальные переменные, рекурсия или перебор с возвратами (backtracking). Типичные примеры программ с использованием стеков – это вычисление скобочных арифметических выражений с различными приоритетами операций или поиск пути в лабиринте методом перебора с возвратами. Стек должен поддерживать, по крайней мере, следующие операции: • push(obj) – добавить (втолкнуть) объект obj в вершину стека; • pop() – удалить (вытолкнуть) объект из вершины стека; • stack_empty() – проверить, пуст ли стек. Реализация этих операций должна, конечно же, обеспечивать правильные результаты, поэтому необходимо как-то определить правильное поведение стека. Одним из способов может быть алгебраическое определение операций и возвращаемых ими значений. Для простых структур, подобных стеку, это возможно, но для понимания структуры такое определение не очень полезно. Вместо него можно определить каноническую реализацию на некой абстрактной машине с бесконечной памятью, которая выдает 1 Типичный пример LIFO – пистолетная обойма с патронами. – Прим. перев. 1.1. Стек 17 правильные результаты для всех правильных цепочек операций (исключая операцию удаления из пустого стека). Если элементы стека имеют тип item_t, то такая реализация может выглядеть следующим образом: int i=0; item_t stack[∞]; int stack_empty(void) { return(i == 0); } void push( item_t x) { stack[i++] = x; } item_t pop(void) { return(stack[--i]); } Операции со стеком реализованы правильно, но в них есть одна проб­ лема – это массив бесконечного размера, в котором всякая последовательность операций всегда будет правильной. Поэтому более реалистичной может быть следующая версия: int i=0; item_t stack[MAXSIZE]; int stack_empty(void) { return(i == 0); } int push(item_t x) { if( i < MAXSIZE ) { stack[i++] = x; return(0); } else return(-1); } item_t pop(void) { return(stack[--i]); } Введенное ограничение максимального размера стека ограничивает правильное поведение стека, поэтому это не совсем то, что нам хотелось бы иметь. Но зато в этой реализации операция push возвращает сообще- 18 Глава 1. Элементарные структуры ние об ошибке (–1) при переполнении стека. Главный недостаток реализации структур данных в массиве заключается в том, что массивы имеют фиксированный размер, который должен быть задан заранее вне зависимости от истинного количества элементов структуры. Для устранения этого недостатка существует систематический способ, рассматриваемый в разделе 1.5, но предпочтение обычно отдается решению с динамически выделяе­мой памятью. В этой реализации ошибка выдается только при переполнении (overflow) стека, но не при его опустошении (underflow), поскольку переполнение стека – это ошибка, порожденная самой структурой, что невозможно в идеальной реализации, тогда как опустошение стека – это ошибка неправильного использования структуры и, следовательно, результат работы программы, считающей стек «черным ящиком». Ее функция pop возвращает вершину стека, но если нужно «поймать» ошибку опустошения стека, то она должна возвращать еще и признак ошибки. Последняя претензия к первоначальной версии реализации стека состоит в том, что в одной и той же программе может понадобиться не один, а несколько стеков, которые придется создавать динамически. Поэтому нам нужны дополнительные операции по созданию и удалению стеков, а каждая операция со стеком должна знать, с каким именно стеком она работает. Одним из возможных вариантов реализации стека может быть следующий: typedef struct {item_t *base; item_t *top; int size;} stack_t; stack_t *create_stack(int size) { stack_t *st; st = (stack_t *) malloc(sizeof(stack_t)); st->base = (item_t *) malloc(size * sizeof(item_t)); st->size = size; st->top = st->base; return(st); } int stack_empty(stack_t *st) { return(st->base == st->top); } int push( item_t x, stack_t *st) { if (st->top < st->base + st->size) { *(st->top) = x; st->top += 1; return( 0 ); } else return( -1 ); 1.1. Стек 19 } item_t pop(stack_t *st) { st->top -= 1; return(*(st->top)); } item_t top_element(stack_t *st) { return(*(st->top -1)); } void remove_stack(stack_t *st) { free(st->base); free(st); } Сюда мы включаем только некоторые проверки безопасности и исключаем другие. В общем, наша политика безопасности состоит в том, чтобы включать только те проверки, которые, в отличие от идеального стека, выявляют ошибки, вызванные ограничениями этой реализации при правильном использовании стека и неограниченном объеме памяти базовой операционной системы. Кроме того, мы добавили еще одну полезную операцию, которая просто возвращает вершину стека без ее удаления. Часто более предпочтительной реализацией стека является структура с динамически выделяемой памятью в виде связного списка, когда добавление и удаление элемента выполняется в начале списка. Преимущество такого решения в том, что у такой структуры нет фиксированного размера, поэтому (считая память компьютера неограниченной) к ней всегда можно добавить новый элемент без проверки на ошибку переполнения стека. Она так же проста, как и структура в массиве, при наличии функций get_node и return_node, реализация которых будет приведена в разделе 1.4. typedef struct st_t { item_t item; struct st_t *next; } stack_t; stack_t *create_stack(void) { stack_t *st; st = get_node(); st->next = NULL; return(st); } int stack_empty(stack_t *st) { return(st->next == NULL); } void push(item_t x, stack_t *st) 20 Глава 1. Элементарные структуры { stack_t *tmp; tmp = get_node(); tmp->item = x; tmp->next = st->next; st->next = tmp; } item_t pop(stack_t *st) { stack_t *tmp; item_t tmp_item; tmp = st->next; st->next = tmp->next; tmp_item = tmp->item; return_node(tmp); return(tmp_item); } item_t top_element(stack_t *st) { return(st->next->item); } void remove_stack(stack_t *st) { stack_t *tmp; do { tmp = st->next; return_node(st); st = tmp; } while( tmp != NULL ); } Обратите внимание, что связный список начинается с пустого элемента (заголовка), поэтому пустой стек – это список из одного заголовка, а вершина стека – это уже следующий элемент списка. Это нужно для того, чтобы возвращаемый функцией create_stack и используемый во всех операциях указатель стека не изменялся ими. Поэтому указатель вершины стека не может быть его идентификатором. Поскольку извлекаемые из списка элементы становятся недоступными, нам нужны временные копии их значений в pop и remove_stack. Операция remove_stack должна возвращать все оставшиеся в стеке элементы. Однако нет оснований полагать, что удаляться будут только пустые стеки, поэтому при неудачной попытке удалить оставшиеся элементы не исключена утечка памяти. заголовок вершина next next next next item item item item Рис. 1.1. Стек в виде списка из трех элементов 1.1. Стек 21 Реализация структуры с динамически выделяемой памятью более элегантна. Ей не грозит переполнение стека, и ей нужна только фактически используемая память, а не большие массивы, предельный размер которых определяется программистом. Один из недостатков такой реализации – возможное снижение производительности: разыменование указателя по времени не больше, чем увеличение индекса, однако адрес памяти, куда ссылается указатель, может находиться в любом месте памяти, тогда как следующий элемент массива находится вслед за предыдущим. Таким образом, структуры в массиве обычно очень хорошо работают с кешем, тогда как структуры с динамически выделяемой памятью могут создавать в кеше много пустот. Поэтому если мы совершенно уверены в максимально возможном размере стека (например, потому что его размер – всего лишь логарифм размера входных данных), то предпочтительна его реализация в массиве. Если же нужно объединить достоинства обеих реализаций, можно использовать связный список блоков (массивов), по заполнении каждого из которых к списку добавляется новый блок. Такая реализация может выглядеть следующим образом: typedef struct st_t { item_t *base; item_t *top; int size; struct st_t *previous; } stack_t; stack_t *create_stack(int size) { stack_t *st; st = (stack_t *) malloc(sizeof(stack_t)); st->base = (item_t *) malloc(size * sizeof(item_t)); st->size = size; st->top = st->base; st->previous = NULL; return(st); } int stack_empty(stack_t *st) { return( st->base == st->top && st->previous == NULL); } void push( item_t x, stack_t *st) { if( st->top < st->base + st->size ) { *(st->top) = x; st->top += 1; } else { stack_t *new; 22 Глава 1. Элементарные структуры new = (stack_t *) malloc(sizeof(stack_t)); new->base = st->base; new->top = st->top; new->size = st->size; new->previous = st->previous; st->previous = new; st->base = (item_t *) malloc(st->size * sizeof(item_t)); st->top = st->base + 1; *(st->base) = x; } } item_t pop(stack_t *st) { if( st->top == st->base ) { stack_t *old; old = st->previous; st->previous = old->previous; free(st->base); st->base = old->base; st->top = old->top; st->size = old->size; free(old); } st->top -= 1; return(*(st->top)); } item_t top_element(stack_t *st) { if( st->top == st->base ) return(*(st->previous->top - 1)); else return(*(st->top - 1)); } void remove_stack(stack_t *st) { stack_t *tmp; do { tmp = st->previous; free(st->base); free(st); st = tmp; } while( st != NULL ); } 1.2. Очередь 23 Согласно нашей классификации операции push и pop обновляют структуру, тогда как stack_empty и top_element просто обращаются к ней. Очевидно, что реализация в массиве позволяет выполнять все дейст­вия за постоянное время, поскольку они включают только постоянное число операций. Реализация в виде связного списка предполагает при выполнении операций push и pop разовые вызовы внешних функций get_node и return_node, поэтому они требуют постоянного времени, но лишь в предположении, что сами эти функции имеют фиксированное время выполнения. Реализацию с динамическим созданием элементов мы обсудим в разделе 1.4, но можем здесь (и во всех остальных структурах) допус­тить, что время их выполнения постоянно. Для связного списка блоков вместо создания промежуточного слоя, как описано в разделе 1.4, используются стандартные операции управления памятью malloc и free, которые выделяют и освобождают большие области памяти. Обычно считается, что выделение и освобождение памяти – это операции с постоянным временем выполнения, но (в особенности для операции free) здесь возникают нетривиальные проблемы, поэтому следует избегать их частого употребления. В случае списка блоков это может произойти, например, при наличии большого количества операций push / pop, которые могут выводить за пределы выделенного блока. Так что незначительные преимущества связного списка блоков, видимо, не стоят лишних проблем. В операции create_stack выполняется только одно выделение памяти, и потому время ее выполнения при любой реализации должно быть постоянным. А вот операция remove_stack, очевидно, не имеет постоянного времени выполнения, поскольку должна удалять потенциально большую структуру: если в стеке n элементов, то время его удаления – O(n). 1.2. Очередь Очередь – почти такая же простая структура, как стек; элементы в ней хранятся так же, но в отличие от стека сначала извлекаются те, что были помещены в нее первыми, то есть это память типа FIFO (First In, First Out – первым вошел, первым вышел). Очереди полезны в задачах с циклической обработкой данных. Кроме того, они являются центральной структурой при поиске в ширину (Breadth-First Search, BFS). Поиск в ширину и поиск в глубину (Depth-First Search, DFS) различаются, по сути, только тем, что для хранения очередного объекта BFS использует очередь, а DFS – стек. Очередь должна поддерживать, по крайней мере, следующие операции: • enqueue(obj) – добавить объект obj в конец очереди; • dequeue() – вернуть 1-й объект очереди с удалением его из очереди; • queue_empty() – проверить, пуста ли очередь. Разница между стеком и очередью, делающая ее чуть сложнее, – в том, что изменения происходят на обоих ее концах: на одном конце – добавле- 24 Глава 1. Элементарные структуры ния, на другом – удаления. Если очередь реализуется в массиве, то используемый ею отрезок массива как бы перемещается внутри него. При бесконечном массиве ее реализация не представляла бы никакой проблемы и выглядела бы так: int lower=0; int upper=0; item_t queue[∞]; int queue_empty(void) { return(lower == upper); } void enqueue(item_t x) { queue[upper++] = x; } item_t dequeue(void) { return(queue[lower++]); } На практике очередь в массиве ограниченной длины реализуется в виде кольца, когда индекс вычисляется по модулю длины массива. Тогда ее реализация могла бы выглядеть так: typedef struct { item_t *base; int front; int rear; int size; } queue_t; queue_t *create_queue(int size) { queue_t *qu; qu = (queue_t *) malloc( sizeof(queue_t) ); qu->base = (item_t *) malloc( size *sizeof(item_t) ); qu->size = size; qu->front = qu->rear = 0; return(qu); } int queue_empty(queue_t *qu) { return(qu->front == qu->rear); } int enqueue(item_t x, queue_t *qu) { if( qu->front != ((qu->rear +2)% qu->size) ) { qu->base[qu->rear] = x; qu->rear = ((qu->rear+1)%qu->size); return(0); 1.2. Очередь 25 } else return(-1); } item_t dequeue(queue_t *qu) { int tmp; tmp = qu->front; qu->front = ((qu->front +1)%qu->size); return(qu->base[tmp]); } item_t front_element(queue_t *qu) { return(qu->base[qu->front]); } void remove_queue(queue_t *qu) { free(qu->base); free(qu); } И здесь опять виден главный недостаток всякой структуры, реализованной в массиве, – его фиксированный размер. А это значит, что возможны ошибки переполнения и неправильная реализация структуры из-за его ограниченности. Кроме того, в этом случае всегда задается предполагаемый максимальный размер массива, который может оказаться невостребованным. Более предпочтительна альтернативная структура в виде связного списка с динамически выделяемой памятью; очевидной ее реализацией может быть такая: typedef struct qu_n_t { item_t item; struct qu_n_t *next; } qu_node_t; typedef struct { qu_node_t *remove; qu_node_t *insert; } queue_t; queue_t *create_queue() { queue_t *qu; qu = (queue_t *) malloc(sizeof(queue_t)); qu->remove = qu->insert = NULL; return(qu); } int queue_empty(queue_t *qu) { return(qu->insert == NULL); } void enqueue(item_t x, queue_t *qu) 26 Глава 1. Элементарные структуры { qu_node_t *tmp; tmp = t_node(); tmp->item = x; tmp->next = NULL; /* концевой маркер */ if( qu->insert != NULL ) /* очередь не пуста */ { qu->insert->next = tmp; qu->insert = tmp; } else /* добавить в пустую очередь */ { qu->remove = qu->insert = tmp; } } item_t dequeue(queue_t *qu) { qu_node_t *tmp; item_t tmp_item; tmp = qu->remove; tmp_item = tmp->item; qu->remove = tmp->next; if( qu->remove == NULL ) /* достигнут конец */ qu->insert = NULL; /* опустошить очередь */ return_node(tmp); return(tmp_item); } item_t front_element(queue_t *qu) { return(qu->remove->item); } void remove_queue(queue_t *qu) { qu_node_t *tmp; while( qu->remove != NULL ) { tmp = qu->remove; qu->remove = tmp->next; return_node(tmp); } free(qu); } Как и для всех динамических структур, мы снова считаем, что нам доступны операции get_node и return_node; они всегда работают правильно и выполняются с постоянным временем. Поскольку элементы удаляются из начала очереди, указатели в связном списке направлены от его начала к концу, куда добавляются новые элементы. Есть два эстетических недостатка этой очевидной реализации: требуется особая, отличная от остальных элементов списка точка входа и иная реализация операций с пустой оче- 1.2. Очередь 27 редью. При добавлении в пустую очередь и удалении из нее последнего и единственного элемента придется изменить указатели и добавления, и удаления, а для всех остальных операций будет изменяться лишь один из этих указателей. вход remove insert next next next next item item item item Рис. 1.2. Очередь в виде списка из четырех элементов Первый недостаток можно устранить закольцовыванием списка, сделав его циклическим, когда указатель последнего элемента очереди ссылается на первый. В этом случае можно обойтись без указателя удаления, потому что следующий элемент точки добавления ссылается на точку удаления. Таким образом, начальный элемент очереди нуждается только в одном указателе и потому имеет тот же тип, что и все остальные элементы очереди. Второй недостаток можно устранить добавлением в этот цикличес­кий список элемента-заголовка – между концом добавления и концом удаления. Точка входа по-прежнему указывает на конец добавления или, в случае пустого списка, на заголовок. Тогда, по крайней мере при добавлении, пустой список больше не будет особым случаем. вход next item заголовок начало очереди next next next next item item item item Рис. 1.3. Очередь в виде циклического списка из трех элементов Таким образом, реализация очереди в виде циклического списка может выглядеть так: typedef struct qu_t { item_t item; struct qu_t *next; } queue_t; queue_t *create_queue() { queue_t *entrypoint, *placeholder; entrypoint = (queue_t *) malloc(sizeof(queue_t)); placeholder = (queue_t *) malloc(sizeof(queue_t)); entrypoint->next = placeholder; placeholder->next = placeholder; return(entrypoint); } int queue_empty(queue_t *qu) 28 Глава 1. Элементарные структуры { return(qu->next == qu->next->next); } void enqueue(item_t x, queue_t *qu) { queue_t *tmp, *new; new = get_node(); new->item = x; tmp = qu->next; qu->next = new; new->next = tmp->next; tmp->next = new; } item_t dequeue(queue_t *qu) { queue_t *tmp; item_t tmp_item; tmp = qu->next->next->next; qu->next->next->next = tmp->next; if( tmp == qu->next ) qu->next = tmp->next; tmp_item = tmp->item; return_node(tmp); return( tmp_item ); } item_t front_element(queue_t *qu) { return(qu->next->next->next->item); } void remove_queue(queue_t *qu) { queue_t *tmp; tmp = qu->next->next; while( tmp != qu->next ) { qu->next->next = tmp->next; return_node(tmp); tmp = qu->next->next; } return_node(qu->next); return_node(qu); } Еще очередь можно реализовать в виде двусвязного списка, который почти не отличается от предыдущего, но требует двух указателей на каждый элемент. Минимизация количества указателей – это чисто эстетичес­ кий критерий, определяемый скорее объемом выполняемой на каждом шаге работы с сохранением связности структуры, нежели объемом, необходимым для структуры памяти. 1.2. Очередь 29 вход next previous item точка добавления точка удаления next next next next previous previous previous previous item item item item Рис. 1.4. Очередь в виде двусвязного списка из четырех элементов Реализация очереди в виде двусвязного списка выглядит так: typedef struct qu_t { item_t item; struct qu_t *next; struct qu_t *previous; } queue_t; queue_t *create_queue() { queue_t *entrypoint; entrypoint = (queue_t *) malloc(sizeof(queue_t)); entrypoint->next = entrypoint; entrypoint->previous = entrypoint; return(entrypoint); } int queue_empty(queue_t *qu) { return(qu->next == qu); } void enqueue(item_t x, queue_t *qu) { queue_t *new; new = get_node(); new->item = x; new->next = qu->next; qu->next = new; new->next->previous = new; new->previous = qu; } item_t dequeue(queue_t *qu) { queue_t *tmp; item_t tmp_item; tmp = qu->previous; tmp_item = tmp->item; tmp->previous->next = qu; qu->previous = tmp->previous; return_node(tmp); return(tmp_item); } item_t front_element(queue_t *qu) { return(qu->previous->item); 30 Глава 1. Элементарные структуры } void remove_queue(queue_t *qu) { queue_t *tmp; qu->previous->next = NULL; do { tmp = qu->next; return_node(qu); qu = tmp; } while( qu != NULL ); } Какая из реализаций лучше – дело вкуса. Обе они несколько сложнее стека, хотя сами структуры выглядят почти одинаково. Как и стек, очередь – это динамическая структура данных с операциями изменения очереди enqueue и dequeue и обращения к очереди queue_empty и front_element, каждая из которых имеет постоянное время выполнения. Кроме них, есть еще операции create_queue (создать очередь) и delete_queue (удалить очередь) с теми же ограничениями, что и у аналогичных операций для стека: создание очереди в массиве требует от системы управления памятью выделения большого блока памяти, тогда как для создания очереди в виде списка требуется всего лишь несколько элементов. Удаление очереди в массиве – это просто возврат блока в системную память, тогда как удаление очереди в виде списка требует возврата в системную память каждого его элемента. Для удаления очереди в виде списка из n элементов требуется время O(n). 1.3. Двусторонняя очередь Двусторонняя (double-ended) очередь – это очевидное обобщение стека и очереди. Это очередь, где добавление и удаление могут выполняться с обеих ее сторон. Реализовать ее можно либо в массиве, либо, как обычную очередь, в двусвязном списке. Поскольку в ее реализации нет ничего нового, ее код здесь не приводится. Двусторонняя очередь применяется редко, а вот «полуторасторонняя очередь» («one-and-a-half ended queue») иногда полезна, как в случае с minqueue, описанной в разделе 5.11. 1.4. Динамическое выделение памяти для элементов В предыдущих разделах для динамического создания и удаления элементов постоянного размера использовались операции get_node и return_node, которые отличаются от системных операций malloc и free, применяющихся только для объектов памяти произвольного, обычно большого, размера. Суть этого различия в том, что в конечном счете процесс выделения памяти – это единственный способ получить необходимый ресурс, и этот про- 1.4. Динамическое выделение памяти для элементов 31 цесс довольно сложен, так что нет гарантии, что время его выполнения будет постоянным. В любой эффективной реализации динамической структуры, где ее элементы постоянно появляются и исчезают, нельзя позволять каж­ дой операции опускаться до низкоуровневого управления памятью операционной системы. С этой целью вводится некий промежуточный уровень, получающий доступ к низкоуровневому управлению памятью лишь изредка, когда нужно выделить большой блок памяти, который возвращается обратно в систему лишь небольшими порциями постоянного размера, то есть поэлементно. Вообще говоря, эффективность операций get_node и return_node имеет решающее значение для любой динамической структуры, но, к счастью, нам не нужно создавать свою систему управления памятью, так как у нас есть два существенных упрощения. В отличие от функции malloc, выделяющей блоки памяти произвольного размера, мы имеем дело только с объектами постоянного размера и до завершения программы можем не возвращать память из промежуточного уровня в системный. И это разумно: выделенная на промежуточном уровне структуре данных память достаточна на данный момент, неизменна в объеме и не должна незамедлительно освобождаться для других параллельных программ или структур. В таком случае для повторного динамического выделения памяти элементам можно использовать свободный список (free list), содержащий не используемые в данный момент элементы. При каждом удалении элемента он просто добавляется к этому списку. Для операции get_node ситуация усложняется: если свободный список не пустой, то элемент берется из него; если же он пустой, а в текущем блоке памяти еще есть свободное место, то новый элемент создается в этом блоке. В противном случае с помощью функции malloc нужно выделить новый блок памяти и создать в нем новый элемент. Реализация такой схемы может выглядеть следующим образом: typedef struct nd_t { struct nd_t *next; /*и другие поля*/ } node_t; #define BLOCKSIZE 256 node_t *currentblock = NULL; int size_left; node_t *free_list = NULL; node_t *get_node() { node_t *tmp; if( free_list != NULL ) { tmp = free_list; free_list = free_list->next; } else { if( currentblock == NULL || size_left == 0 ) 32 Глава 1. Элементарные структуры { currentblock = (node_t *) malloc(BLOCKSIZE * sizeof(node_t)); size_left = BLOCKSIZE; } tmp = currentblock++; size_left -= 1; } return(tmp); } void return_node(node_t *node) { node->next = free_list; free_list = node; } Обычно динамическое выделение памяти – это источник множества ошибок, с трудом поддающихся исправлению. Простая дополнительная предосторожность во избежание некоторых распространенных ошибок – добавить к элементу структуры еще одно поле int_valid и заполнять его различными значениями в зависимости от того, был ли он только что возвращен операцией return_node или выделен операцией get_node. В этом случае можно удостовериться, что указатель действительно ссылается на существующий элемент и все ранее возвращенные return_node элементы тоже существуют. 1.5. Теневые копии структур в массиве Простота таких структур позволяет обойти ограничение их предельного размера в массиве. Для этого одновременно поддерживаются две копии структуры – активная (текущая) структура и ее копия большего размера, которая создается так, чтобы она была вполне готова к работе с ней до достижения активной структурой своего максимального размера. Для этого каждая операция со структурой, помимо прочего, копирует фиксированное количество элементов из старой структуры в новую. Как только старая структура полностью скопирована в новую более крупную, старая структура удаляется, а новая становится активной, и по мере необходимости создается еще более крупная копия. Кажется, что это просто и порождает лишь издержки преобразования структуры фиксированного размера в неограниченную структуру. Но есть некоторые нюансы: активная структура изменяется во время копирования, и эти изменения должны быть учтены в еще неполной большей копии. Для демонстрации этого принципа приведем код для стека в массиве: typedef struct { item_t *base; int size; int max_size; 1.5. Теневые копии структур в массиве 33 item_t *copy; int copy_size; } stack_t; stack_t *create_stack(int size) { stack_t *st; st = (stack_t *) malloc( sizeof(stack_t)); st->base = (item_t *) malloc(size * sizeof(item_t)); st->max_size = size; st->size = 0; st->copy = NULL; st->copy_size = 0; return(st); } int stack_empty(stack_t *st) { return(st->size == 0); } void push(item_t x, stack_t *st) { *(st->base + st->size) = x; st->size += 1; if( st->copy != NULL || st->size >= 0.75*st->max_size ) { /* продолжить или начать копирование */ int additional_copies = 4; if( st->copy == NULL ) /* начать копирование: выделить область */ { st->copy = (item_t *) malloc(2 * st->max_size * sizeof(item_t)); } /* продолжить копирование хотя бы 4 элементов */ while( additional_copies > 0 && st->copy_size < st->size ) { *(st->copy + st->copy_size) = *(st->base + st->copy_size); st->copy_size += 1; additional_copies -= 1; } if( st->copy_size == st->size ) /* копия готова */ { free(st->base); st->base = st->copy; st->max_size *= 2; st->copy = NULL; st->copy_size = 0; } } } 34 Глава 1. Элементарные структуры item_t pop(stack_t *st) { item_t tmp_item; st->size -= 1; tmp_item = *(st->base + st->size); if( st->copy_size == st->size ) /* копия готова */ { free(st->base); st->base = st-> copy; st->max_size *= 2; st->copy = NULL; st->copy_size = 0; } return(tmp_item); } item_t top_element(stack_t *st) { return(*(st->base + st->size - 1)); } void remove_stack(stack_t *st) { free(st->base ); if( st->copy != NULL ) free(st->copy); free(st); } Для стека ситуация особенно проста, поскольку сводится лишь к его копированию от основания до текущей вершины, так как между ними ничего не меняется. Пороговый размер активной структуры для запуска копирования (здесь 0,75*size), максимальный размер новой структуры (здесь вдвое больший старой) и количество копируемых на каждом шаге элементов (здесь 4), конечно, должны быть выбраны так, чтобы копирование закончилось до переполнения старой структуры. Заметьте, что копирование может закончиться в двух случаях − при фактическом копировании в push и при удалении еще не скопированных элементов в pop. В самом общем случае связь между пороговым размером, максимальным новым размером и количеством копируемых элементов выглядит следующим образом: • • • • если активная структура имеет максимальный размер smax, и копирование начинается по достижении αsmax (где α ≥ ½), и новая структура имеет максимальный размер 2smax, и каждая операция увеличивает фактический размер не более чем на 1, то остается не менее (1 – α)smax шагов для завершения копирования не более smax элементов из активной структуры в новую бóльшую структуру. 1.5. Теневые копии структур в массиве 35 По­этому, чтобы закончить копирование до переполнения активной структуры, нужно скопировать в каждой операции 1/(1 – α) элементов. При создании новой структуры ее размер удваивается, хотя можно было бы выбрать и другой размер βsmax, где β > 1, при условии что αβ > 1. Иначе процесс копирования пришлось бы начинать заново – до завершения предыдущего. В принципе, этот метод является довольно общим и применим не только к структурам в массивах. Он еще будет использоваться в разделах 3.6 и 7.1. Всегда есть возможность преодолеть фиксированный размер структуры, скопировав ее содержимое в бóльшую структуру. Но не всегда ясно, как разбить процесс копирования на ряд небольших шагов, которые могут выполняться параллельно с обычными операциями над структурой, как в нашем примере. Вместо этого можно скопировать всю структуру за один шаг и оценивать не худшее время выполнения, а среднее. Последний пример применения такого подхода и связанных с ним проб­ лем – это реализация расширяемого массива. Обычные массивы имеют фиксированный размер; им отводится определенная область памяти, которая не может увеличиваться во избежание возможных конфликтов с уже выделенными для других переменных областями. Доступ к элементу такого массива довольно быстр, и требуется всего лишь одно вычисление адреса. Но некоторые системы поддерживают массивы, размер которых может расти. Доступ к элементу такого массива гораздо сложнее и должен поддерживать следующие операции: • create_array – создать массив заданного размера; • set_value – присвоить значение элементу массива с заданным индексом; • get_value – получить значение элемента массива с заданным индексом; • extend_array – увеличить длину массива. Для реализации такой структуры используется тот же метод создания теневых копий. Но здесь появляется новая проблема, поскольку моделируемая структура растет при каждой операции не на один элемент. Например, при выполнении операции extend_array их может быть гораздо больше. Но тем не менее можно легко добиться постоянного среднего времени выполнения одной и той же операции. При создании массива размера s ему выделяется область, бóльшая, чем требуется. Допустив, что размер выделяемых массивов всегда кратен степени двух, можно выделить ему сначала область размера 2log s и сохранить в заголовке этой структуры ссылку на его начальную позицию, а также его текущий и максимальный размеры. Таким образом, обращение к элементам массива всегда будет начинаться с обращения к его заголовку. При каждом выполнении операции extend_array прежде всего проверяется, превышает ли его текущий (максимальный) размер требуемый. Если 36 Глава 1. Элементарные структуры это так, то просто увеличивается текущий размер. В противном случае выделяется новый массив, размер которого на 2k больше требуемого, и каждый элемент старого массива копируется в новый. Таким образом, обращение к элементу массива всегда требует постоянного времени O(1), то есть одного перехода по ссылке. А вот расширение массива может потребовать уже линейного времени, зависящего от размера массива. Но средняя сложность при этом не так уж плоха. Если предельный размер массива равен 2log k, то в худшем случае в extend_array на операции копирования массивов размером 1, 2, 4, ..., 2log k –1 будет затрачено в общей сложности время O(1 + 2 + … + 2log k–1) = O(k) для каждой операции extend_array без копи­рования массива. Следовательно, мы имеем следующую сложность: Теорема. В структуре расширяемого массива с теневыми копиями любая последовательность из n операций set_value, get_value и extend_array для массива с предельным размером k выполняется за время O(n + k). Если допустить, что обращение к элементам массива выполняется только один раз, то предельный размер – это не более чем количество обращений к элементам массива, что дает среднюю временнýю сложность O(1) на одну операцию. Конечно, было бы лучше разнести копирование элементов массива по более поздним операциям обращения к его элементам, но у нас нет возможности контролировать операцию extend_array. Очередное расширение массива возможно до завершения копирования текущего массива, поэтому наш метод не сработает для такой структуры. Еще одна существенная проблема с расширяемыми массивами состоит в том, что указатели на элементы массива отличаются от обычных указателей, поскольку положение массива в памяти может меняться. Поэтому в самом общем случае следует избегать расширяемых массивов, даже если язык программирования их поддерживает. Другой способ реализации расширяемых массивов рассмат­ривался в [129]. Глава 2 Деревья поиска Дерево поиска – это древовидная структура из объектов, обладающих уникальными ключами. Значения ключей объектов образуют линейно упорядоченное множество (обычно целых чисел). Дерево поиска начинается с корня (root) и обязательно содержит в каждом своем узле значение некоторого ключа, который сравнивается с искомым. Любые два ключа сравниваются за постоянное время, и результат этого сравнения служит критерием доступа к определенному объекту дерева. Таким образом, сравнивая искомый ключ с ключами узлов дерева, можно переходить от одного узла к другому, пока не найдется узел с искомым ключом. Такой тип древовидной структуры является фундаментальным для многих структур данных, он имеет множество разновидностей и является строи­тельным блоком для более сложных структур данных. По этой причине мы обсудим его во всех подробностях. Деревья поиска – это один из методов реализации абстрактной структуры, называемой словарем. Словарь – это структура, которая хранит объекты, определяемые ключами, и поддерживает операции поиска, вставки и удаления. Дерево поиска обычно поддерживает по крайней мере эти операции словаря, но есть и другие способы реализации словаря и приложения деревьев поиска, которые на первый взгляд не являются словарями. 2.1. Две модели деревьев поиска В только что приведенном наброске мы упустили важный момент, который на первый взгляд кажется незначительным, но на самом деле приводит к двум разным моделям деревьев поиска, каждая из которых согласуется с большей частью последующего материала, но одна из которых более предпочтительна. Если искомый ключ меньше ключа внутреннего узла, мы спускаемся по левой ветви дерева, а если больше – по правой. А если ключи равны? Значит, существует две модели деревьев поиска, которые выглядят следующим образом: 1) если искомый ключ меньше ключа узла, выбираем левую ветвь. Иначе выбираем правую ветвь, пока не спустимся до листа дерева. Внут­ 38 Глава 2. Деревья поиска ренние узлы дерева служат лишь для сравнения ключей, а объекты представлены листьями; 2) если искомый ключ меньше ключа узла, выбираем левую ветвь. Если искомый ключ больше ключа узла, выбираем правую ветвь. Если ключи равны, выбираем объект, находящийся непосредственно в узле. Этот незначительный момент имеет ряд следствий: • В основе модели 1 лежит двоичное дерево, тогда как в основе модели 2 – троичное с особым средним узлом. • В модели 1 каждый внутренний узел имеет левое и правое поддеревья, каждое из которых может оказаться листом дерева, а в модели 2 допускаются неполные узлы, когда левое или правое поддерево может вообще отсутствовать, но объект сравнения вместе со своим ключом обязательно существует. 5 5 3 8 2 4 obj5 7 1 2 3 4 obj1 obj2 obj3 obj4 6 9 3 7 obj3 obj7 7 8 9 2 4 6 9 obj7 obj8 obj9 obj2 obj4 obj6 obj9 5 6 1 8 obj5 obj6 obj1 obj8 Рис. 2.1. Деревья поиска согласно модели 1 и модели 2 Таким образом, структура дерева поиска в модели 1 более регулярна, чем в модели 2, что является ее явным преимуществом хотя бы при реализации. • При обходе дерева в модели 1 внутренний узел требует только одного сравнения, а в модели 2 – двух, чтобы проверить все три варианта. На самом деле деревья одинаковой высоты в моделях 1 и 2 содержат примерно одинаковое количество объектов, но в модели 2 требуется вдвое больше сравнений, чтобы спуститься до самых нижних объектов дерева. Конечно, и в модели 2 есть объекты, которые достигаются гораздо раньше. Например, объект в корне дерева можно найти за два сравнения, но большинство объектов расположены на самом нижнем уровне или близко к нему. Теорема. В дереве модели 1 при высоте h может быть не более 2h объектов. В дереве модели 2 при высоте h может быть не более 2h+1 – 1 объектов. 2.1. Две модели деревьев поиска 39 Это очевидно, поскольку в модели 1 каждое левое и правое поддерево дерева высоты h имеет высоту не более h – 1, а в модели 2 между ними есть еще один объект. • В модели 1 ключи внутренних узлов служат только для сравнения и могут еще появляться в листьях для идентификации объектов. В модели 2 каждый ключ появляется только один раз вместе со своим объектом. В модели 1 допустимы ключи, не связанные ни с одним объектом, если, например, объект уже удален. Это не должно удивлять, поскольку в этой модели мы мысленно делим ключи на два множества – сравниваемые и идентифицирующие объект. Поэтому в дальнейшем нам, возможно, придется вводить искусственные проверки ради удобного разбиения мно­ жества ключей поиска, не отвечающих ни одному объекту. Все сравниваемые ключи обязательно различны, поскольку в модели 1 любой внутренний узел дерева имеет непустые левое и правое поддеревья. Таким образом, каждый ключ встречается не более двух раз: однажды как ключ сравнения и однажды как ключ, идентифицирующий лист дерева. В большинстве учебников предпочтение отдается модели 2, потому что в ней не делается различия между объектом и его ключом, а сам ключ считается объектом дерева. В этом случае нет смысла дублировать ключ в древовидной структуре, хотя во всех реальных приложениях различия между ключом и объектом чрезвычайно важны. Просто никто не хочет иметь дело с множествами чисел, так как они обычно несут в себе некую дополнительную информацию, которая зачастую несколько шире, чем сам ключ. В тех источниках, где отмечается это различие, деревья модели 1 называются листовыми, а деревья модели 2 – узловыми [430]. Мы отдадим предпочтение дереву поиска согласно модели 1 и будем использовать его для всех структур, кроме деревьев с всплывающими узлами (splay trees) (которые, безусловно, относятся к модели 2). Структура узлов дерева согласно модели 1 такова: typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; /* дополнительная информация */ } tree_node_t; Кроме того, нам потребуется дополнительная информация о выравнивании дерева, что будет обсуждаться в главе 3. Так что эта структура – всего лишь набросок. Из узлов такого типа мы построим дерево, соответствующее следующему рекурсивному определению. Каждое дерево является либо пустым, либо листом, либо содержит специальный корневой узел, который указывает на 40 Глава 2. Деревья поиска два непустых дерева так, что все ключи левого поддерева меньше корневого ключа, а все ключи правого поддерева не меньше корневого ключа. Все это требует еще некоторых уточнений, особенно в отношении листьев дерева. Мы будем следовать следующему соглашению: • узел *n является листом, если n->right = NULL. Тогда n->left указывает на объект, хранящийся в этом листе, а n->key – его ключ. Нам также необходимы некоторые соглашения для корня, особенно для пустых деревьев. Каждое дерево имеет особый узел *root; • если root->left = NULL, то дерево пусто; • если root->left ≠ NULL и root->right = NULL, то root – лист, а дерево содержит лишь один объект; • если root->left ≠ NULL и root->right ≠ NULL, то root->right и root->left указывают на корни правого и левого поддеревьев. Для каждого узла *left_node левого поддерева left_node->key < root->key, а для каждого узла *right_node правого поддерева right_node->key ≥ root->key. Любая структура с такими свойствами является правильным деревом поиска для объектов и листовых ключей. С учетом этих соглашений теперь можно создать пустое дерево. tree_node_t *create_tree(void) { tree_node_t *tmp_node; tmp_node = get_node(); tmp_node->left = NULL; return(tmp_node); } 2.2. Общие свойства и преобразования В правильном дереве поиска каждый его узел можно связать с интервалом допустимых значений ключа, которые будут доступны через этот узел. Для корня root интервалом будет ]–∞, ∞[. А для каждого внутреннего узла *n, связанного с интервалом [a, b[, когда n->key ∈ [a, b[, n->left и n->right будут связаны соответственно с интервалами [a, n->key[ и [n->key, b[. За исключением интервалов с левым пределом –∞, все они – полуоткрытые, причем именно с левой, а не правой границей. Такая расширенная трактовка структуры деревьев поиска чрезвычайно полезна для понимания операций над ними (рис. 2.2). Одно и то же множество пар (ключ, объект) можно представить различными правильными деревьями поиска: листья остаются теми же и содержат пары (ключ, объект) в порядке возрастания ключей, но сами деревья, связывающие эти узлы, могут быть различными, и мы покажем, что не- 2.2. Общие свойства и преобразования 41 которые из них лучше других. Существуют две операции – левое и правое вращения, – которые преобразуют правильное дерево поиска в другое правильное дерево поиска для того же множества пар (рис. 2.3). Они являются базовыми для более сложных преобразований дерева, поскольку просты в реализации и универсальны. 10 ]-∞ ,∞ [ 5 ]-∞ ,10[ 4 ]-∞ ,5[ 16 [10,∞ [ 7 [5,10[ 20 [16,∞ [ 13 [10,16[ obj7 3 ]-∞ ,4[ 4 [4,5[ obj3 11 [10,13[ obj4 obj11 13 [13,16[ 30 [20,∞ [ 18 [16,20[ obj13 obj30 17 [16,18[ 19 [18,20[ obj19 16 [16,17[ 17 [17,18[ obj16 obj17 Рис. 2.2. Интервалы, связанные с узлами дерева поиска key left c key right left b right правое вращение key left b key right левое вращение [c,d[ [a,b[ left c right [a,b[ [b,c[ [b,c[ [c,d[ Рис. 2.3. Левое и правое вращения Пусть *n и n->right – внутренние узлы дерева. Тогда ключи трех узлов n->left, n->right->left и n->right->right образуют непрерывную после­ довательность интервалов, объединение которых дает интервал узла *n. Теперь вместо соединения 2-го и 3-го интервалов (n->right->left и n->right-> right) в узле n->right и последующего его соединения с 1-м интервалом n->left в узле *n мы могли бы добавить новый узел, соединяющий 1-й и 2-й интервалы, с последующим его соединением с 3-м узлом в *n. Этот прием называется левым вращением (left rotation), переставляющим три узла ниже 42 Глава 2. Деревья поиска заданного центра вращения *n. Такое локальное изменение, выполняемое за фиксированное время, не влия­ет ни на содержимое этих трех узлов, ни на что-либо ниже них или выше центра вращения *n. Следующий код реализует левое вращение вокруг *n: void left_rotation(tree_node_t *n) { tree_node_t *tmp_node; key_t tmp_key; tmp_node = n->left; tmp_key = n->key; n->left = n->right; n->key = n->right->key; n->right = n->left->right; n->left->right = n->left->left; n->left->left = tmp_node; n->left->key = tmp_key; } Обратите внимание, что мы перемещаем содержимое узлов, но при этом узел *n все равно должен оставаться корнем поддерева, потому что на него ссылаются вышестоящие узлы дерева. Если узлы содержат дополнительную информацию, то она, конечно же, должна копироваться или обновляться. Правое вращение – это просто обратная к левому вращению операция. void right_rotation(tree_node_t *n) { tree_node_t *tmp_node; key_t tmp_key; tmp_node = n->right; tmp_key = n->key; n->right = n->left; n->key = n->left->key; n->left = n->right->left; n->right->left = n->right->right; n->right->right = tmp_node; n->right->key = tmp_key; } Теорема. Левое и правое вращения вокруг одного и того же узла – взаимно обратные операции. Они преобразуют правильное дерево поиска в другое правильное дерево поиска для одного и того же множества пар (ключ, объект). Вращение как конструктивная операция с деревьями очень полезна тем, что она универсальна: любое правильное дерево поиска для некоторого множества пар (ключ, объект) может быть преобразовано в любое другое правильное дерево поиска через ряд вращений. Правда, нужно с осторожнос­тью 2.3. Высота дерева поиска 43 относиться к этому утверждению, потому что оно не вполне точно: в нашей модели деревьев поиска мы можем изменять значения ключей во внут­ ренних узлах без разрушения свойств дерева поиска только до тех пор, пока сохраняется установленный порядок отношения между ключами сравнения и ключами объектов. Вращения, безусловно, не меняют значения ключей. Важной структурой является дерево поиска комбинаторного (combinatorial) типа, в котором любое множество ключей сравнения коррект­но преобразуется вместе с самим деревом. Теорема. Любые два дерева поиска комбинаторного типа с одинаковым множеством пар (ключ, объект) могут быть преобразованы друг в друга через ряд вращений. Легко видеть, что если к дереву применять только правые вращения (пока их можно применить), получится вырожденное дерево, то есть дерево из одной правой ветви, к которой листья привязаны в возрастающем порядке. Таким образом, применяя только правые вращения, любое дерево поиска можно привести к этой канонической форме. Поскольку правое и левое вращения обратимы, к канонической форме можно прийти и через ряд левых вращений. Пространство деревьев поиска комбинаторного типа, то есть двоичных деревьев поиска с n листьями, изоморфно ряду других структур (семейство Каталана2). Вращения определяют расстояние в такой структуре, что было исследовано в ряде работ [157, 391, 504, 376]; диаметр такого пространства, как известно, составляет 2n – 6 при n ≥ 11 [504]. Точное значение нижней границы здесь оценить трудно, но легко оценить жесткую границу Θ(n) ([204], раздел 7.5). 2.3. Высота дерева поиска Основная мера качества дерева поиска (хорошее оно или плохое) комбинаторного типа на одном и том же множестве пар (ключ, объект) – это его высота (height). Высота дерева поиска – это наибольшее расстояние (длина пути) от корня до листьев дерева, обычно находящихся на разных расстояниях от корня. Расстояние от корня дерева до некоторого его узла называется глубиной (depth) этого узла. Как уже отмечалось в разделе 2.1, максимальное число листьев двоичного дерева поиска высоты h равно 2h. С другой стороны, минимальное число листьев равно h + 1, потому что дерево высоты h должно иметь по крайней мере один внутренний узел на каждой из глубин от 0 до h – 1, а дерево с h внутренними узлами имеет, очевидно, h + 1 листьев. Эти две величины определяют, соответственно, верхнюю и нижнюю границы высоты дерева. 2 Здесь речь идет об используемых в комбинаторике числах бельгийского математика Э. Ш. Каталана (известных еще Л. Эйлеру), которые, в частности, определяют количество неизоморфных (различных по форме, но не по содержанию) упорядоченных двоичных деревьев с корнем и n + 1 листьями. – Прим. перев. 44 Глава 2. Деревья поиска Теорема. Дерево поиска из n объектов имеет высоту не менее log n и не более n – 1. Легко видеть, что обе границы достижимы. Высота – это наибольшее (в худшем случае) из расстояний от корня до листьев дерева поиска. Другая, связанная с высотой мера качества дерева поиска – его средняя глубина (average depth), то есть среднее по всем объектам расстояние, которое нужно пройти до этого объекта. Его границы: Теорема. Средняя глубина дерева поиска из n объектов находится в пределах (n – 1) (n + 2) 1 от log n до ––––––––––––––––– = –––n. 2n 2 Доказательство. Без потери общности вместо средней глубины проще рассмотреть сумму глубин, которая складывается из глубин a и b соответственно левого и правого поддеревьев корня. Эти суммы удовлетворяют следующим рекурсивным отношениям: depthsummin(n) = n + min depthsummin(a) + depthsummin(b) a,b≥1 a+b=n и depthsummax(n) = n + max depthsummax(a) + depthsummax(b). a,b≥1 a+b=n С помощью этих рекурсий по индукции получаем и depthsummin(n) ≥ n log n depthsummax(n) = (n – 1)(n + 2)/2. В первом случае функция x log x выпукла, поэтому a log a + b log b ≥ (a + b) log (a + b)/2. 2.4. Основные операции: поиск, добавление, удаление Любое дерево поиска на множестве пар (ключ, объект) должно допус­кать определенные операции, наиболее важными из которых являются следующие: • find(tree, query_key) – найти в дереве tree объект с ключом query_key, если он есть; • insert(tree, key, object) – добавить в дерево tree пару (key, object); • delete(tree, key) – удалить из дерева tree объект с ключом key. 2.4. Основные операции: поиск, добавление, удаление 45 Рассмотрим подробно эти операции, которые в главе 3 будут дополнены некоторыми действиями по выравниванию дерева. Простейшая из них – операция поиска find, когда нужно просто спуститься по дереву tree до того единственного листа, где может находиться искомый объект. После этого проверяется, соответствует ли ключ этого единственно возможного кандидата искомому ключу query_key. Если это так, то объект найден, иначе объект с искомым ключом отсутствует в дереве (рис. 2.4). 37 34 50 9 35 5 11 3 2 7 3 5 10 8 34 13 13 47 35 40 47 37 21 60 53 43 41 60 51 45 50 55 51 53 57 Рис. 2.4. Дерево поиска и путь при неудачном поиске find (tree,42) object_t *find(tree_node_t *tree, key_t query_key) { tree_node_t *tmp_node; if( tree->left == NULL ) return(NULL); else { tmp_node = tree; while( tmp_node->right != NULL ) { if( query_key < tmp_node->key ) tmp_node = tmp_node->left; else tmp_node = tmp_node->right; } if( tmp_node->key == query_key ) return((object_t *) tmp_node->left); else return(); } } Спуск по дереву от корня до нужного уровня часто реализуется посредством рекурсии, хотя даже в лучших компиляторах рекурсия выполняется гораздо медленнее итерации. Несмотря на то что в наших кодах мы обычно избегаем рекурсии, для иллюстрации спуска по дереву мы приведем именно рекурсивную его версию. object_t *find(tree_node_t *tree, key_t query_key) { if( tree->left == NULL || 46 Глава 2. Деревья поиска (tree->right == NULL && tree->key != query_key )) return(NULL); else if( tree->right == NULL && tree->key == query_key ) return((object_t *) tree->left); else { if( query_key < tree->key ) return(find(tree->left, query_key)); else return(find(tree->right, query_key)); } } Операция добавления insert начинается подобно операции find, но она, найдя место вставки нового объекта, должна после этого создать новые внутренний и листовой узлы и вставить их в дерево. Как всегда, считаем, что нам доступны определенные в разделе 1.4 функции get_node и return_node. Кроме того, ввиду уникальности ключей мы будем фиксировать ошибку, когда в дереве уже есть объект с таким же ключом, хотя во многих практических приложениях нам придется иметь дело с объектами с одинаковыми ключами (см. раздел 2.6). int insert(tree_node_t *tree, key_t new_key, object_t *new_object) { tree_node_t *tmp_node; if( tree->left == NULL ) { tree->left = (tree_node_t *) new_object; tree->key = new_key; tree->right = NULL; } else { tmp_node = tree; while( tmp_node->right != ) { if( new_key < tmp_node->key ) tmp_node = tmp_node->left; else tmp_node = tmp_node->right; } /* найден лист кандидата: проверить, совпадают ли ключи */ if( tmp_node->key == new_key ) return(-1); /* ключ отличается: выполнить вставку */ { tree_node_t *old_leaf, *new_leaf; old_leaf = get_node(); old_leaf->left = tmp_node->left; old_leaf->key = tmp_node->key; old_leaf->right = NULL; 2.4. Основные операции: поиск, добавление, удаление 47 new_leaf = get_node(); new_leaf->left = (tree_node_t *) new_object; new_leaf->key = new_key; new_leaf->right = NULL; if( tmp_node->key < new_key ) { tmp_node->left = old_leaf; tmp_node->right = new_leaf; tmp_node->key = new_key; } else { tmp_node->left = new_leaf; tmp_node->right = old_leaf; } } } return(0); } Операция удаления delete сложнее, так как при удалении листа нужно удалить еще и внутренний узел над листом (рис. 2.5). Для этого, спус­каясь вниз по дереву, нам нужно следить за текущим узлом и его верхним соседом. Кроме того, эта операция может привести к ошибке, если объект с заданным ключом отсутствует. old key key left right old new key key left right old key key left right obj old NULL obj new key key left right obj old NULL new если key < key old NULL добавление или удаление new key key left right old key key left right obj old NULL new key key left right obj new Рис. 2.5. Добавление и удаление листа NULL если keyold < key new 48 Глава 2. Деревья поиска object_t *delete(tree_node_t *tree, key_t delete_key) { tree_node_t *tmp_node, *upper_node, *other_node; object_t *deleted_object; if( tree->left == NULL ) return(NULL); else if( tree->right == NULL ) { if( tree->key == delete_key ) { deleted_object = (object_t *) tree->left; tree->left = NULL; return(deleted_object); } else return(NULL); } else { tmp_node = tree; while( tmp_node->right != NULL ) { upper_node = tmp_node; if( delete_key < tmp_node->key ) { tmp_node = upper_node->left; other_node = upper_node->right; } else { tmp_node = upper_node->right; other_node = upper_node->left; } } if( tmp_node->key != delete_key ) return(NULL); else { upper_node->key = other_node->key; upper_node->left = other_node->left; upper_node->right = other_node->right; deleted_object = (object_t *) tmp_node->left; return_node(tmp_node); return_node(other_node); return(deleted_object); } } } Если узлы дерева содержат дополнительную информацию, то при копировании other_node в upper_node ее также следует скопировать или обновить. Обратите внимание, что удаляются только узлы, а не сам объект, поскольку возможны другие ссылки на него. Однако если объект имеет лишь одну 2.5. Возврат от листа к корню 49 ссылку, то во избежание утечки памяти необходимо удалить и его тоже. Поскольку за утилизацию объектов отвечает пользователь, функция возвращает указатель на этот объект. 2.5. Возврат от листа к корню Каждая операция в дереве начинается с корня и затем спускается по дереву вниз к листу, где находится искомый объект, или к узлу, где произ­водятся некоторые изменения. Во всех выровненных деревьях поиска, которые мы рассмотрим в главе 3, нам приходится возвращаться по тому же пути, но в обратном направлении – от листа к корню, чтобы попутно выполнить в узлах некоторые операции обновления или выравнивания. Все такие операции должны выполняться строго снизу вверх – от листа к корню. Однако определенная нами структура дерева поиска сама по себе, без дополнительных средств, не позволяет вернуться от листа к корню. Существует несколько способов добиться этого. 1. Стек. При спуске от корня к листу сохранять указатели на все пройденные узлы в стеке, что позволит на обратном пути извлекать их из стека в нужном порядке. Это самое наилучшее с точки зрения информационных затрат решение, так как оно не требует вносить никакой дополнительной информации в древовидную структуру. Кроме того, максимальный размер необходимого стека – это высота дерева, поэтому для выровненных деревьев поиска он равен логарифму размера дерева поиска. Для всех реаль­ных приложений будет вполне достаточно стека в массиве из 200 элементов, поскольку дерево поиска вряд ли может состоять из 2100 узлов. Это справедливо и для любой рекурсивной реализации дерева поиска, где стек используется неявно. 2. Обратный указатель. Если каждый узел дерева снабдить ссылкой не только на левое и правое поддеревья, но и ссылкой на роди­ тельский узел, то появляется возможность перемещаться вверх по дереву от любого узла к корню. Это потребует дополнительного поля в каждом узле и, следовательно, дополнительной памяти. Но при достаточно больших объемах памяти это теперь не проблема. Правда, такую ссылку придется корректировать в каждой операции, что может стать дополнительным источником программных ошибок. 3. Обратный указатель с отложенным (lazy) обновлением. Здесь каждый узел дерева также имеет поле обратной ссылки на родительский узел, но заполняется оно только при спуске по дереву от корня к листу. В этом случае по достижении листа дерева за нами остается след или правильный обратный путь от этого листа к корню. При всех прочих операциях с деревом такие обратные указатели не требуют корректировки, но они верны только для узлов дерева, лежащих на пути спуска от корня к листу. 50 Глава 2. Деревья поиска Любой из этих методов подойдет и может быть объединен с любым из методов выравнивания. Еще один метод, требующий большей осторожности в сочетании с различными методами выравнивания, заключается в следующем: 4. Обращение (reversing) пути. Для сохранения обратных ссылок при спуске по дереву от корня к листу можно обойтись без дополнительного поля в узлах, просто заменяя попутно прямые ссылки на обратные. Если мы спускаемся влево, обращается левый указатель, а если вправо – правый. На обратном пути от листа к корню нужно просто восстановить прежние указатели вперед3. Такой способ не нуждался в дополнительной памяти и потому представлял интерес, когда ограниченность основной памяти была серьезной проблемой. С появлением структур данных методы работы с деревьями, не требующие стека или обратных ссылок, изучались в ряде научных статей [366, 479, 187, 105, 146, 507, 414, 134, 135]. Однако эти методы порождали множество других проблем, поскольку структура дерева поиска ими временно разрушалась. Теперь же, когда проблема ограничения памяти почти снята, этот способ приводится лишь для полноты картины, но не рекомендуется к использованию. 2.6. Повторяющиеся ключи На практике в деревьях поиска нередко может быть несколько объектов с одинаковыми ключами. Например, в базах данных может храниться множество объектов с одинаковыми ключами, и каждый такой объект не всегда однозначно определяется значениями своих атрибутов. Таким образом, операции на дереве поиска могут выполняться над целым списком объектов, удовлетворяющих заданным значениям атрибутов. Поэтому операции для всякого реального дерева поиска высоты h должны учитывать это обстоятельство: • find – поиск объектов с заданным ключом за время O(h + k), зависящее от размера выхода k; • insert – добавление (вставка) объекта с заданным ключом за время O(h); • delete – удаление объектов с заданным ключом за время O(h). Очевидный способ реализации этих операций – хранить все элементы с одинаковыми ключами в связном списке под соответствующим ключу лис­ том дерева поиска. Тогда операция find просто выдает все элементы этого списка, insert добавляет элемент в начало этого спис­ка, и только операция delete, время выполнения которой не зависит от количества удаляемых элементов, требует дополнительной информации. Для ее выполнения между листом и связным списком нужен дополнительный узел с указателями на 3 Подобно «нити Ариадны». – Прим. перев. 2.7. Интервалы ключей 51 начало и конец списка. За счет этого можно перенести весь список в список свободной динамической памяти за время O(1). При этом удаляются только ссылки на объекты дерева. Если же нужно удалить сами объекты, придется пройти по всему списку, но уже не за постоянное время O(1), а за время, зависящее от количества удаляемых объектов. 2.7. Интервалы ключей До сих пор мы рассматривали только операции find, возвращающие объекты по заданному ключу. Часто бывает полезна более общая операция, которая находит все ключи из заданного интервала [a, b[. Если ключи создаются с небольшими погрешностями, мы не можем задать их точное значение, и нам желательно найти хотя бы ближайший ключ – чуть больший или меньший заданного. В противном случае операция find вообще не найдет объекта с заданным ключом, что правильно, но бесполезно. В главе 9 о хеш-таблицах мы рассмотрим другие типы словарей, не поддерживающие запросы такого типа. Однако деревья поиска позволяют это сделать за счет незначительной их модификации, которую можно осущест­ вить несколькими способами. 1. Листья дерева можно объединить в двусвязный список и переходить от одного листа к другому за время O(1). Для поддержки такого спис­ка необходимо изменить функции добавления и удаления, и это простое изменение потребует всего O(1) дополнительного времени. Функция поиска почти не изменится и потребует O(k) дополнительного времени, если список включает в общей сложности k ключей внутри интервала. 2. Такой способ не меняет структуру дерева, но меняет функцию поиска: спуск по дереву осуществляется не по ключу, а по заданному интервалу [a, b[. Мы спускаемся влево, если b < node->key, и вправо, если node->key ≤ a. Иногда нам приходится спускаться и влево, и вправо, если a < node->key ≤ b. В этом случае все пройденные ветви сохраняются в стеке, а посещаемые узлы – это узлы на пути поиска начала интервала a, на пути поиска конца интервала b, а также всех узлов между этими двумя путями. Если между этими путями расположено i внутренних узлов, то между ними должно быть не менее i + 1 листьев. Следовательно, если этот способ охватывает k листьев, то общее число посещенных узлов будет не более чем вдвое больше числа узлов, посещенных обычной операцией поиска, плюс O(k). Таким образом, этот способ несколько медленнее первого, но не требует изменения операций insert и delete. Ниже приводится реализация функции интервального поиска interval_ find с использованием стека. Мы описываем здесь обобщенные операции со стеком, чтобы проиллюстрировать только сам принцип; их, конечно 52 Глава 2. Деревья поиска же, нужно уточнять. Результат операции может быть довольно большим, поэтому вместо одного возвращается много объектов, для чего создается список найденных в интервале пар (ключ, объект), связанных с указателем right. После использования результата поиска во избежание утечки памяти элементы этого списка должны быть освобож­дены. tree_node_t *interval_find(tree_node_t *tree, key_t a, key_t b) { tree_node_t *tr_node; tree_node_t *result_list, *tmp; result_list = NULL; create_stack(); push(tree); while( !stack_empty() ) { tr_node = pop(); if( tr_node->right == NULL ) { /* это – лист: проверить границы интервала*/ if( a <= tr_node->key && tr_node->key < b ) { tmp = get_node(); /* ключ листа – внутри интервала */ tmp->key = tr_node->key; /* копируем его в выходной список */ tmp->left = tr_node->left; tmp->right = result_list; result_list = tmp; } } /* это не лист: можно спуститься вниз */ else if ( b <= tr_node->key ) /* весь интервал – слева */ push(tr_node->left); else if ( tr_node->key <= a ) /* весь интервал – справа */ push(tr_node->right); else /* ключ узла – внутри интервала: идти влево и вправо*/ { push(tr_node->left); push(tr_node->right); } } remove_stack(); return(result_list); } Список входящих в интервал ключей – это результат одномерного запроса. Многомерные запросы будут рассмотрены в главе 4. В самом общем случае запрос выдает некоторое множество или область определенного типа (в данном случае – интервал) и находит все пары (ключ, объект), ключ которых находится внутри этой области. Запросы подобного типа чрезвы- 2.8. Построение оптимальных деревьев поиска 53 чайно важны для таких более сложных областей, как прямоугольники, круги, полуплоскости и параллелепипеды. 2.8. Построение оптимальных деревьев поиска Иногда бывает полезно построить для заданного множества пар (ключ, объект) оптимальное дерево поиска, которое можно считать неизменяемой структурой данных, где нет операций добавления (вставки) и удаления и, следовательно, нет нужды в выравнивании дерева. Но в таком случае при заранее известных исходных данных оно должно быть построено наилучшим образом. Главный критерий оптимальности дерева – его высота. Поскольку дерево поиска высоты h может иметь не более 2h листьев, оптимальное дерево поиска для множества из n элементов имеет высоту log2 n. Если пары (ключ, объект) представлены в виде сортированного по возрастанию ключей списка, то существует два естественных способа построе­ ния дерева поиска оптимальной высоты – восходящий и нисходящий. 1 2 3 4 5 6 7 obj1 obj2 obj3 obj4 obj5 obj6 obj7 1 2 3 4 5 6 7 1 2 3 4 5 6 7 obj1 obj2 NULL NULL obj5 obj6 NULL 3 5 2 4 6 2 obj2 NULL 3 obj3 NULL obj4 5 NULL obj5 obj6 7 obj3 NULL 6 obj4 NULL 5 NULL 1 obj5 NULL NULL NULL 7 6 obj6 NULL NULL NULL 3 4 NULL 7 obj7 5 3 obj7 7 6 NULL 4 obj2 NULL 1 2 NULL 4 NULL 2 1 obj1 obj4 NULL 1 1 obj1 obj3 NULL NULL obj7 NULL NULL NULL 5 3 7 2 1 obj1 NULL 4 2 obj2 NULL 3 obj3 NULL 6 4 obj4 NULL 5 obj5 NULL 7 6 obj6 obj7 NULL NULL Рис. 2.6. Восходящее построение оптимального дерева по сортированному списку 54 Глава 2. Деревья поиска Восходящий способ (рис. 2.6) проще: исходный список рассматривается как список одноэлементных деревьев. Затем выполняется многократный проход по этому списку с соединением на каждом шаге двух соседних деревьев до тех пор, пока не получится единое дерево. При этом для добавления нового ключа сравнения в любой внутренний узел требуется совсем немного вычислений. Недостатком этого способа является то, что, несмот­ ря на оптимальную высоту, полученное дерево может оказаться невыровненным. Если исходное множество состоит из n = 2m + 1 элементов, то корень дерева будет иметь с одной стороны поддерево из 2m элементов, а с другой – поддерево из одного элемента (рис. 2.7). Рис. 2.7. Оптимальное дерево с 18 листьями, построенное восходящим и нисходящим способами Ниже приведен код для построения дерева восходящим способом. Здесь мы предполагаем, что элементы списка уже являются узлами дерева типа tree_node_t, где поле key содержит ключ объекта, left указывает на объект, а right – на следующий элемент (или равен NULL для последнего элемента списка). Сначала мы создаем список, в котором все узлы исходного спис­ ка – это листья, а затем работаем со списком деревьев, значение ключа для каждого из которых меньше, чем в его поддеревьях. tree_node_t *make_tree(tree_node_t *list) { tree_node_t *end, *root; if( list == NULL ) { root = get_node(); /* создать пустое дерево */ root->left = root->right = NULL; return(root); } else if( list->right == NULL ) return(list); /* дерево из одного листа */ else /* дерево минимум из двух листьев */ { root = end = get_node(); /* перенести исх. список листьев в новый список */ end->left = list; end->key = list->key; list = list->right; end->left->right = NULL; 2.8. Построение оптимальных деревьев поиска 55 while( list != NULL ) { end->right = get_node(); end = end->right; end->left = list; end->key = list->key; list = list->right; end->left->right = NULL; } end->right = NULL; /* конец создания списка листьев */ { tree_node_t *old_list, *new_list, *tmp1, *tmp2; old_list = root; while( old_list->right != NULL ) { /* объединить первые два дерева из old_list */ tmp1 = old_list; tmp2 = old_list->right; old_list = old_list->right->right; tmp2->right = tmp2->left; tmp2->left = tmp1->left; tmp1->left = tmp2; tmp1->right = NULL; new_list = end = tmp1; /* начать новый список new_list */ while( old_list != NULL ) /* еще не конец списка */ { if( old_list->right == NULL ) /* последнее дерево */ { end->right = old_list; old_list = NULL; } else /* объединить след. два дерева из old_list */ { tmp1 = old_list; tmp2 = old_list->right; old_list = old_list-> right->right; tmp2->right = tmp2->left; tmp2→left = tmp1->left; tmp1->left = tmp2; tmp1->right = NULL; end->right = tmp1; end = end->right; } } /* 1-й проход по списку old_list закончен */ 56 Глава 2. Деревья поиска old_list = new_list; } /* объединение 1-х двух деревьев закончено */ root = old_list->left; return_node(old_list); } return(root); } } Теорема. Дерево поиска оптимальной высоты можно построить по упорядоченному списку восходящим способом за время O(n). Первая половина алгоритма, дублирующая список и преобразующая все исходные узлы списка в листья, требует, очевидно, времени O(n). Это всего лишь один цикл по длине списка. Вторая его половина сложнее, но при каждом выполнении тела самого внутреннего цикла один из n внут­ренних узлов, созданных в первой половине, удаляется из текущего списка и помещается в выходное поддерево, поэтому самая внутренняя часть двух вложенных циклов выполняется только n раз. Нисходящий способ проще всего описать рекурсивно. Исходное мно­ жество объектов делится (почти) пополам, для каждой половины создаются оптимальные деревья (рис. 2.8), которые затем объединяются в одно дерево. Такой подход позволяет получить не только оптимальное, но и выровненное дерево: количество элементов в левом и правом поддеревьях различается не более чем на 1. Если исходные данные представлены спис­ ком, то временные затраты оцениваются как Θ(n log n)), так как накладные расходы деления списка пополам на каждом шаге рекурсии оцениваются величиной Θ(n). Но есть хорошая реализация с временнóй сложностью O(n), использующая стек. Для иллюстрации работы этого способа используются обобщенные операции со стеком push, pop, stack_empty, create_stack, remove_stack. В конкретной реализации их можно заменить одним из методов главы 1. В данном случае стек в массиве – лучший выбор, и его стоит объ­ явить локальным массивом внутри функции, избежав тем самым прочих вызовов функций. Идея нисходящего способа построения дерева состоит в том, что сначала строится «абстрактное» («пустое») дерево без значений ключей и ссылок на объекты. При этом не тратится время на поиск середины списка – нужно всего лишь определить количество элементов в левом и правом поддеревьях. Такое абстрактное дерево легко построить, используя стек. Сначала в стек помещается корень дерева вместе с исходным количеством объектов дерева. Затем, пока стек не пуст, из него извлекаются помещенные в него узлы. Они связываются с двумя вновь созданными узлами (по одному для каждой половины списка объектов), которые тоже помещаются в стек. Если размер очередной половины достиг 1, мы имеем дело с листом дерева. Лис­ 2.8. Построение оптимальных деревьев поиска 57 товой узел не добавляется в стек – в него просто вносятся значение ключа и ссылка на объект. Сложнее заполнить ключи сравнения во внутренних узлах, что возможно лишь по достижении листа дерева. С этой целью каждый элемент стека имеет два указателя: один – на соседний узел, который еще предстоит развернуть, другой – на вышестоящий родительский узел, куда будет внесен ключ сравнения, равный наименьшему из ключей лис­ тьев-потомков этого узла. Их количество также сохраняется в этом элементе стека. stack 1 NULL root 7 list stack 2 NULL 3 1 3 4 4 NULL 5 2 3 1 1 2 4 list 2 3 root list 2 3 2 root obj4 obj5 obj6 obj7 1 2 3 4 5 6 7 obj1 obj2 obj3 obj4 obj5 obj6 obj7 1 2 3 4 5 6 7 obj1 obj2 obj3 obj4 obj5 obj6 obj7 1 2 3 4 5 6 7 obj1 obj2 obj3 obj4 obj5 obj6 obj7 list 2 3 4 5 6 7 obj2 obj3 obj4 obj5 obj6 obj7 2 3 4 5 6 7 obj2 obj3 obj4 obj5 obj6 obj7 list 3 4 5 6 7 obj3 obj4 obj5 obj6 obj7 list 4 5 6 7 obj4 obj5 obj6 obj7 list 2 3 4 NULL NULL NULL NULL NULL 5 NULL 1 4 6 2 obj1 4 NULL 2 obj2 7 4 2 3 5 6 7 NULL 2 1 obj1 4 NULL 2 obj2 NULL 1 root 1 obj3 1 1 3 obj2 NULL 5 root stack obj1 3 4 1 1 4 obj1 stack 7 NULL 1 7 5 3 1 6 1 obj1 stack 5 5 2 4 1 6 2 7 5 3 1 4 1 root stack 3 1 4 5 2 3 1 2 1 root stack 1 1 2 6 NULL 3 3 5 3 obj3 7 NULL Рис. 2.8. Нисходящее построение оптимального дерева по сортированному списку. Первые шаги до исчерпания левой половины NULL 58 Глава 2. Деревья поиска После извлечения из стека текущего узла создаются два его дочерних узла, которые помещаются в стек: сначала – правый, потом – левый. По достижении листа именно левый узел (поддерево) становится листовым узлом дерева, куда вносятся значение ключа и указатель объекта, тогда как ключом сравнения текущего узла будет наименьший ключ его правого поддерева. Если допустить, что количество объектов дерева n < 2100, то для стека будет вполне достаточно массива из 100 элементов, так его размер не может превысить высоты дерева log2 n. tree_node_t *make_tree(tree_node_t *list) { typedef struct { tree_node_t *node1; tree_node_t *node2; int number; } st_item; st_item current, left, right; tree_node_t *tmp, *root; int length = 0; for( tmp = list; tmp != NULL; tmp = tmp - right ) length += 1; /* определить длину списка */ create_stack(); /* стек узлов st_item: может заменяться массивом */ root = get_node(); /* поместить корень дерева в стек */ current.node1 = root; current.node2 = NULL; current.number = length; /* root охватывает все листья */ push(current); while( !stack_empty() ) /* еще остались неразвернутые узлы */ { current = pop(); if( current.number > 1 ) /* создать (пустые) узлы дерева */ { left.node1 = get_node(); left.node2 = current.node2; left.number = current.number / 2; right.node1 = get_node(); right.node2 = current.node1; right.number = current.number - left.number; (current.node1)->left = left.node1; (current.node1)->right = right.node1; push(right); push(left); } else /* это лист: заменить его на список элементов */ { (current.node1)->left = list->left; /* заполнить лист из списка */ 2.9. Преобразование деревьев в списки 59 (current.node1)->key = list->key; (current.node1)->right = NULL; if( current.node2 != NULL ) /* вставить ключ сравнения во внутренний узел */ (current.node2)->key = list->key; tmp = list; /* отвязать 1-й элемент от списка */ list = list->right; /* его содержимое нужно сохранить */ return_node(tmp); /* это лист: узел создан */ } } return(root); } При оценке временнóй сложности алгоритма надо учесть, что в каж­дой итерации в стеке либо создаются два новых узла (в общей сложности n – 1 новых узлов), либо один из n элементов списка становится листом дерева. Так что общая сложность алгоритма – O(n). Теорема. Дерево поиска оптимальной высоты строится из упорядоченного списка нисходящим способом за время O(n). Несколько других нисходящих способов построения оптимального дерева по списку или преобразования дерева поиска в оптимальное были рассмотрены в работах [401, 164, 124, 512, 247, 332, 384]. Все они отличаются главным образом объемом необходимой дополнительной памяти. В нашем алгоритме это небольшой стек размера log2n, который не критичен. К сожалению, операции добавления и удаления узлов с поддержкой оптимальности дерева не позволяют избежать наихудшей сложности Ω(n). 2.9. Преобразование деревьев в списки Иногда возникает необходимость в обратном преобразовании дерева в упорядоченный список. Это делается очень просто, используя стек для обычного поиска по дереву в глубину по убыванию порядковых номеров листьев, которые добавляются в начало списка. Таким образом, дерево поиска с n листьями преобразуется в список из n элементов в возрастающем порядке за время O(n). Здесь снова используются универсальные функции работы со стеком, которые в конкретной реализации должны быть заменены правильным программным кодом. Если заранее известно, что высота дерева не слишком велика, то стек лучше реализовать в массиве, размер которого должен быть не меньше высоты дерева. 60 Глава 2. Деревья поиска tree_node_t *make_list(tree_node_t *tree) { tree_node_t *list, *node; if( tree->left == NULL ) { return_node(tree); return(NULL); } else { create_stack(); push(tree); list = NULL; while( !stack_empty() ) { node = pop(); if( node->right == NULL ) { node->right = list; list = node; } else { push(node->left); push(node->right); return_node(node); } } return(list); } } 2.10. Удаление деревьев Кроме всех прочих операций, нам необходима операция удаления дерева, когда оно нам больше не нужно. Как и для стека, важно правильно освободить все узлы динамической древовидной структуры так, чтобы избежать утечки памяти. Не стоит ожидать, что для древовидных структур потенциально большого размера такая операция будет выполняться за фиксированное время, но линейное от ее размера (то есть постоянное на один узел) время легко достижимо. Очевидный способ сделать это – воспользоваться стеком, подобно преобразованию дерева в упорядоченный список. Но более элегантный способ выглядит так: void remove_tree(tree_node_t *tree) { tree_node_t *current_node, *tmp; if( tree->left == NULL ) return_node(tree); else { current_node = tree; while( current_node->right != NULL ) 2.10. Удаление деревьев 61 { if( current_node->left->right == NULL ) { return_node(current_node->left); tmp = current_node->right; return_node(current_node); current_node = tmp; } else { tmp = current_node->left; current_node->left = tmp->right; tmp->right = current_node; current_node = tmp; } } return_node(current_node); } } В сущности, здесь выполняется вращение дерева относительно корня до тех пор, пока левый дочерний узел не окажется листом, который возвращается как результат. После этого корнем становится правый дочерний узел, а вышестоящий корень выдается в качестве следующего результата. Глава 3 Выровненные деревья поиска В предыдущей главе обсуждались деревья поиска с операциями find, insert и delete, сложность которых оценивалась как O(h), где h – высота дерева, то есть самый длинный путь от корня до листа. Например, линейный список из n объектов можно считать правильным деревом поиска высоты n, но он слишком прост и неэффективен. Критерием выровненности дерева поиска следовало бы считать не высоту O(n), а высоту O(log n). Эту ключевую идею и первый способ ее реализации предложили в 1962 году Адельсон-Вельский и Ландис, описавшие в своей работе [4] выровненное по высоте дерево, теперь часто называемое АВЛ-деревом. Высота h такого дерева не превышает величины 1,44 · log n + O(1). И это уже неплохо, так как высота любого дерева с n листьями никогда не может быть меньше log n. В этой главе рассматриваются некоторые способы достижения примерно таких же величин. 3.1. Выровненные по высоте деревья Дерево считается выровненным по высоте, если для каждого его внут­ реннего узла высоты правого и левого поддеревьев этого узла отличаются не более чем на 1. Это самый старый критерий выравнивания деревьев, предложенный и изученный в [4] и до сих пор считающийся самым популярным для выровненных по высоте деревьев поиска (АВЛ-деревьев). Выровненное по высоте дерево всегда имеет небольшую высоту. Теорема. Количество листьев в выровненном по высоте дереве высоты h не менее 1 + √5 h 3 – √5 1 – √5 h 3 + √5 �————� • �————� – �————� • �————� . 2√5 2 2√5 2 Высота выровненного по высоте дерева с n листьями не превышает величины �log 1 + √5 n� = �cFib log2 n� ≈ 1.44 log2 n, ———— 2 3.1. Выровненные по высоте деревья 63 1 + √5 –1 где cFib = �log2 �———— �� . 2 Доказательство. Пусть ℱh – выровненное по высоте дерево высоты h с минимальным количеством листьев. Либо левое, либо правое поддерево корня root(ℱh) должно иметь высоту h − 1, а поскольку дерево выровнено по высоте, то второе поддерево должно иметь высоту не менее h − 2. Таким образом, дерево ℱh имеет по крайней мере столько же листьев, сколько у деревьев ℱh−1 и ℱh−2 вместе взятых. И можно рекурсивно построить последовательность деревьев Фибоначчи ℱibh (рис. 3.1), для которых сохраняется равенство высот. Рис. 3.1. Деревья Фибоначчи с высотами от 0 до 5 Для этого в качест­ве левого поддерева дерева ℱibh берется дерево ℱibh−1, а в качестве правого − дерево ℱibh−2. Таким образом, коли­ чество листьев leaves(h) выровненных по высоте деревьев с минимальным количеством листьев отвечает рекурсивному отношению leaves(h) = leaves(h−1) + leaves(h − 2) с начальными значениями leaves(0) = 1 и leaves(1) = 2. Такие рекурсивные отношения могут быть разрешены с помощью описанной в главе 10 стандартной методики. В частности, данное рекурсивное отношение имеет решение: 3 + √5 1 + √5 h 3 – √5 1 – √5 h leaves(h) = �————� • �————� – �————� • �————� . 2√5 2 2√5 2 Таким образом, в сравнении с оптимальным деревом в выровненном по высоте дереве поиска коэффициент замедления, по крайней мере для опе3 рации find, не превышает —. Однако необходимо выяснить, как сохранить 2 равенство высот при выполнении операций insert и delete. Для этого в каждом внутреннем узле дерева поиска необходимо хранить дополнительную информацию – высоту поддерева этого узла (рис. 3.2). Поэтому структура узла будет следующей: typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; int height; /* возможна другая информация */ } tree_node_t; AE 3 64 Глава 3. Выровненные деревья поиска cuus247-brass 978 0 521 88037 4 August 4, 2008 11:40 Высота узла *n определяется рекурсивно следующими правилами: • если *n – лист (n->left = NULL), то n->height = 0, • иначе высота n->height на единицу больше максимума высот лево­ го и правого поддеревьев: n->height = 1 + max(n->left->height, n->right->height). При любом изменении дерева высоты его узлов должны обновляться и использоваться для выравнивания его по высоте. 7 6 5 4 5 3 1 2 2 0 0 1 0 1 0 0 0 0 3 4 1 3 0 0 2 1 0 3 2 1 2 2 1 1 0 0 0 1 0 0 0 1 1 0 0 0 0 4 0 0 2 1 1 0 0 3 0 0 1 2 2 0 0 1 0 0 1 0 0 0 0 0 0 0 0 Рис. 3.2. Выровненное по высоте дерево с высотами его узлов Дерево может измениться только при добавлении или удалении элемента. При этом высота может измениться только для узлов, содержащих в своем поддереве изменяемый лист, то есть только для узлов, лежащих на пути от корня к изменяемому листу. Как говорилось в разделе 2.5, нужно пройти по этому пути обратно вверх – от листа к корню – с пересчетом высот узлов и, возможно, с восстановлением нарушенного равенства высот. У измененного листа или (в случае добавления) у двух соседних листьев высота равна 0. На обратном пути от листа к корню в каждом узле возможны следующие ситуации. Высоты левого и правого поддеревьев уже известны, и оба они уже выровнены по высоте: одно – потому что высоты его поддеревьев выровнены на предыдущем шаге восхождения, а другое – потому что в нем ничего не изменилось. При этом высоты обоих поддеревьев отличаются не более чем на 2, поскольку до обновления их высоты различались не более чем на 1, а после обновления они могли измениться лишь на 1. Теперь, прежде чем подняться вверх по дереву, нужно выровнять этот узел и обновить его высоту. Если *n – текущий узел, то возможны следующие варианты: 1. |n->left->height − n->right->height| ≤ 1. Здесь выравнивание узла не требуется. Если его высота не изменилась, то выше этого узла тоже ничего не изменится и выравнивание 3.1. Выровненные по высоте деревья 65 не требуется; в противном случае нужно обновить высоту узла *n и перейти к вышестоящему узлу. 2. |n->left->height − n->right->height| = 2. Здесь необходимо выравнивание в узле *n. Это делается путем описанных в разделе 2.2 вращений по следующим правилам: 2.1. Если n->left->height = n->right->height + 2 и n->left->left->height = n->right->height + 1. Выполнить правое вращение вокруг n с последующим пересчетом высот в n->right и n (рис. 3.3). key key c height ? left right key key right left h [c,d[ h+1 [a,b[ right правое вращение b height h+2 left b height h+2 или h+3 left right h+1 [a,b[ h или h+1 [b,c[ c height h+1 или h+2 h h или h+1 [b,c[ [c,d[ Рис. 3.3. Выравнивание узла дерева по высоте: случай 2.1 2.2. Если n->left->height = n->right->height + 2 и n->left->left->height = n->right->height. Выполнить левое вращение вокруг n->left, затем правое вращение вокруг n, с последующим пересчетом высот в n->right, n->left и n (рис. 3.4). 2.3. Если n->right->height = n->left->height + 2 и n->right->right->height = n->left->height + 1. Выполнить левое вращение вокруг n с последующим пересчетом высот n->left и n. 2.4. Если n->right->height = n->left->height + 2 и n->right->right->height = n->left->height. Выполнить правое вращение вокруг n->right, затем левое вращение вокруг n с последующим пересчетом высот в n->right, n->left и n. После выполнения этих вращений нужно проверить высоту n: если она не изменилась, то выравнивание выполнено, иначе продолжить восхождение по дереву вплоть до корня. 66 Глава 3. Выровненные деревья поиска key d height ? left right key b height h+2 key left right key left d height ? left c height h+1 right h левое вращение [d,e[ right key c height ? left right key b height ? left right h [d,e[ h h или h-1 [a,b[ [c,d[ h или h-1 [b,c[ h или h-1 h [c,d[ h или h-1 [a,b[ key left key [b,c[ c height h+2 right b height h+1 left key right left d height h+1 right правое вращение h [a,b[ h или h-1 [b,c[ h или h-1 [c,d[ h [d,e[ Рис. 3.4. Выравнивание узла дерева по высоте: случай 2.2 Поскольку время обработки каждого узла по пути к корню порядка O(1) (не более двух вращений и трех расчетов высоты), а путь имеет длину O(log n), то операции выравнивания требуют времени O(log n). Но необходимо убедиться в том, что эти операции действительно выравнивают высоты. Нам будет вполне достаточно показать это только для одного шага, а затем по индукции распространить это утверждение на все дерево. Пусть nold обозначает узел до выравнивания, левое и правое поддеревья которого уже выровнены по высоте, но их высоты различаются на 2, и пусть *nnew – тот же узел, но уже после выравнивания. С учетом симметрии, не влияющей на доказательство, допустим, например, что nold->left->height = nold->right->height + 2. Пусть h = nold->right->height. Поскольку nold->left->height = h + 2, мы имеем max(nold->left->left->height, nold->left->right->height) = h + 1, и поскольку поддерево nold->left выровнено по высоте, возможны следующие варианты: (a) nold->left->left->height = h + 1 и nold->left->right->height ∈ {h, h + 1}. Согласно правилу 2.1 выполняется правое вращение вокруг nold. В результате вместо nold мы получаем nnew с такими значениями: nnew->left = nold->left->left, 3.1. Выровненные по высоте деревья 67 nnew->right->left = nold->left->right, nnew->right->right = nold->right, nnew->left->height = h + 1, nnew->right->left->height ∈ {h, h + 1}, nnew->right->right->height = h. Таким образом, узел nnew->right выровнен по высоте, и его высота nnew->right->height ∈ {h + 1, h + 2}. Следовательно, узел nnew выровнен по высоте. (b) nold->left->left->height = h и nold->left->right->height = h + 1. Согласно правилу 2.2 выполняется левое вращение вокруг nold->left, а затем правое вращение вокруг nold. В результате двух вращений вместо nold мы получим nnew с такими значениями: nnew->left->left = nold->left->left, nnew->left->right = nold->left->right->left, nnew->right->left = nold->left->right->right, nnew->right->right = nold->right, nnew->left->left->height = h, nnew->left->right->height ∈ {h − 1, h}, nnew->right->left->height ∈ {h − 1, h}, nnew->right->right->height = h. Таким образом, узлы nnew->left и nnew->right выровнены, и их высоты – nnew->left->height = h + 1 и nnew->right->height = h + 1. Следовательно, и сам узел nnew выровнен по высоте. Итак, мы доказали, что после добавлений и удалений в выровненные по высоте деревья их выравнивание может быть выполнено за время O(log n). Теорема. Выровненное по высоте дерево поиска поддерживает операции find, insert и delete за время O(log n). Одна из реализаций функции insert для выровненного по высоте дерева поиска может быть такой: int insert(tree_node_t *tree, key_t new_key, object_t *new_object) { tree_node_t *tmp_node; int finished; if( tree->left == NULL ) { tree->left = (tree_node_t *) new_object; tree->key = new_key; tree->height = 0; tree->right = NULL; } 68 Глава 3. Выровненные деревья поиска else { create_stack(); tmp_node = tree; while( tmp_node->right != NULL ) { push( tmp_node ); if( new_key < tmp_node->key ) tmp_node = tmp_node->left; else tmp_node = tmp_node->right; } /* найти лист-кандидат и сравнить ключи */ if( tmp_node->key == new_key ) return(-1); /* ключи различны: добавить */ { tree_node_t *old_leaf, *new_leaf; old_leaf = get_node(); old_leaf->left = tmp_node->left; old_leaf->key = tmp_node->key; old_leaf->right = NULL; old_leaf->height = 0; new_leaf = get_node(); new_leaf->left = (tree_node_t *) new_object; new_leaf->key = new_key; new_leaf->right = NULL; new_leaf->height = 0; if( tmp_node->key < new_key ) { tmp_node->left = old_leaf; tmp_node->right = new_leaf; tmp_node->key = new_key; } else { tmp_node->left = new_leaf; tmp_node->right = old_leaf; } tmp_node->height = 1; } /* выравнивание */ finished = 0; while( !stack_empty() && !finished ) { int tmp_height, old_height; tmp_node = pop(); old_height= tmp_node->height; if( tmp_node->left->height - tmp_node->right->height == 2 ) { if( tmp_node->left->left->height - tmp_node->right->height == 1 ) { right_rotation(tmp_node); 3.1. Выровненные по высоте деревья 69 tmp_node->right->height = tmp_node->right->left->height + 1; tmp_node->height = tmp_node->right->height + 1; } else { left_rotation(tmp_node->left); right_rotation(tmp_node); tmp_height = tmp_node->left->left->height; tmp_node->left->height = tmp_height + 1; tmp_node->right->height = tmp_height + 1; tmp_node->height = tmp_height + 2; } } else if( tmp_node->left->height – tmp_node->right-> height == -2 ) { if( tmp_node->right->right->height - tmp_node->left->height == 1 ) { left_rotation(tmp_node); tmp_node->left->height = tmp_node->left->right->height + 1; tmp_node->height = tmp_node->left->height + 1; } else { right_rotation(tmp_node->right); left_rotation(tmp_node); tmp_height = tmp_node->right->right->height; tmp_node->left->height = tmp_height + 1; tmp_node->right->height = tmp_height + 1; tmp_node->height = tmp_height + 2; } } else /* обновить высоту, даже если не было вращения */ { if( tmp_node->left->height > tmp_node->right->height ) tmp_node->height = tmp_node->left->height + 1; else tmp_node->height = tmp_node->right->height + 1; } if( tmp_node->height == old_height ) finished = 1; } remove_stack(); } return(0); } Функция delete требует тех же изменений с тем же кодом выравнивания при движении вверх по дереву. Очевидно, что функция find вообще не тре- 70 Глава 3. Выровненные деревья поиска бует никаких изменений. Поскольку известно, что высота стека ограничена величиной 1,44 · log n и что n < 2100, его вполне можно реализовать в массиве фиксированного (максимального) размера. В этой реализации для выравнивания дерева в каждом его узле добавлено поле высоты узла. Но для выравнивания дерева по высоте достаточно и меньшей информации: в узле можно хранить разность высот его левого и правого поддеревьев или одно из трех состояний4. В ранних источниках обычно обсуждались самые разные способы сокращения памяти для хранения узлов дерева, но поскольку размер памяти теперь не представляет проблемы, любую дополнительную информацию (ради простоты доступа к ней и ее проверки) лучше хранить в явном виде. Дальнейший анализ процесса выравнивания дерева показывает, что для операции insert вращения выполняются только на одном уровне, тогда как для операции delete – на любом уровне, если в дереве Фибоначчи удаляется, например, листовой узел с наименьшей глубиной. В ряде статей оценивалось количество вращений или изменений узлов дерева поиска, но все они оказывали незначительное влияние на реальную производительность такой структуры данных. Кроме того, если добавление узла требует выравнивания лишь на одном уровне, то может быть еще много других уровней, где требуется изменить или обновить информацию о высоте узлов. Средняя глубина дерева Фибоначчи с n листьями даже лучше, чем 1,44 · log n. Из рекурсивного определения дерева ℱibh легко видеть, что сумма depthsum(h) его глубин листьев отвечает рекурсии depthsum(h) = depthsum(h – 1) + depthsum(h – 2) + leaves(h), где leaves(h) – количество листьев ℱibh, которое в начале раздела было определено рекурсией leaves(h) = leaves(h – 1) + leaves(h – 2). Исключив функцию leaves из этих двух линейных рекурсий, получим рекурсию depthsum(h) – 2 depthsum(h – 1) – depthsum(h – 2) + 2 depthsum(h – 3) + depthsum(h – 4) = 0 с начальными значениями depthsum(0) = 0, depthsum(1) = 2, depthsum(2) = 5 и depthsum(3) = 12. Это рекурсивное отношение можно разрешить стандартными методами из главы 10 и получить 1 + √5 h –3 1 – √5 1 – √5 h 3 2 + √5 depthsum(h) = �——— + ————h��————� + �——— + ————h��————� 5√5 5 2 5√5 5 2 3 2 + √5 1 + √5 h = �——— + ————h��————� + о(1). 5√5 5 2 4 Под «тремя состояниями» здесь, видимо, понимается то, что разность высот может быть меньше, больше или равна 0, что можно «спрятать» внутри узла без добавления к нему новых полей. – Прим. перев. 3.1. Выровненные по высоте деревья 71 Таким образом, средняя глубина ℱibh очень близка к оптимальной глубине любого двоичного дерева с количеством листьев: depthsum(h) ————————— ≈ 1,04 log2(leaves(h)) + о(1). leaves(h) Однако для выровненных по высоте деревьев это не вполне справедливо. В 1990 году Клейн и Вуд [326] построили выровненные по высоте деревья, средняя глубина которых почти совпадает с их худшей глубиной. Поэтому нельзя надеяться на какое-то улучшение среднего показателя. Они определили строгие границы предельной средней глубины выровненного по высоте дерева с n листьями. Здесь мы покажем лишь конструкцию «плохих» выровненных по высоте деревьев. Теорема. Существуют выровненные по высоте деревья с n листь­ями и средней глубиной 1 + √5 –1 cFib log2 n – О(log n), где cFib = �log2�———— �� . 2 Доказательство. Пусть ℬinh обозначает полное двоичное дерево высоты h. В ℬinh левое и правое поддеревья корня будут ℬinh−1. В ℱibh левое поддерево – ℱibh−1, а правое – ℱibh−2. Давайте определим новое семейство выровненных по высоте деревьев 𝒢k,h, заменив поддерево высоты h – k, содержащее вершины максимальной глубины, полным двоичным деревом с той же высотой. Рекурсивная конструкция этих деревьев такова: • для k = 0 определим 𝒢0,,h = ℬinh и • для k ≥ 1 определим дерево 𝒢k,h с левым поддеревом 𝒢k−1,h−1 и правым поддеревом ℱibh−2. 𝒢h,k – выровненное по высоте дерево высоты h с количеством листьев leaves(𝒢h,k) = leaves(ℱibh) − leaves(ℱibh−k) + leaves(ℬinh−k) = leaves(h) − leaves(h − k) + 2h−k 1 + √5 h– k 3 + √5 1 + √5 h 3 + √5 = �————��————� – �————� �————� = 2h– k + о(1) 2√5 2 2√5 2 и суммарной глубиной листьев depthsum(𝒢h,k) = depthsum(ℱibh) − depthsum(ℱibh−k) − k · leaves(ℱibh−k) + depthsum(ℬinh−k) + k · leaves(ℬinh−k) = depthsum(h) − depthsum(h − k) − k leaves(h − k) + (h − k)2h−k + k2h−k. 72 Глава 3. Выровненные деревья поиска 2 + √5 1 + √5 3 Обозначив 𝜙 = ————, γ = ———— и δ = ——— , получим 5 2 5√5 h h−k h−k h leaves(𝒢h,k) = 𝜙 − 𝜙 + 2 + o(1) = 𝜙 + 2h−k + o(𝜙h−k), depthsum(𝒢h,k) = γh𝜙h + δ𝜙h − γ(h − k)𝜙h−k − δ𝜙h−k − k𝜙h−k + h2h−k + o(1) = γhφh + h2h−k + O(𝜙h). Если k = k(h) = (1 − log2 𝜙)h − log2 h, то h − k = (log2 𝜙)h + log2(h) и 2h−k = h𝜙h. И тогда leaves(𝒢h,k(h)) = h𝜙h + O(𝜙h), Следовательно, depthsum(𝒢h,k(h)) = h2𝜙h + O(h𝜙h). log2 (leaves(𝒢h,k(h))) = log2(h𝜙h) + o(1) = (log2 𝜙)h + log2 h + o(1), так что при n = leaves(𝒢h,k(h)) depthsum (𝒢h,k(h)) = n log2 n/ log2 𝜙 ≈ 1,44n log2 n. Ранее предлагались различные варианты выровненных по высоте деревьев, учитывающих разность высот между узлами. Они либо ослабляли условия выравнивания до некоторой большей (но все же постоянной) верхней границы разности высот в каждом узле [215, 309], либо, наоборот, усиливали их до минимальной (не более 1) разности высот между узлами, разделенными двумя уровнями [264], но все они не дали никаких существенных пре­имуществ. Долгое время оставались предметом исследований односторонне выровненные по высоте деревья [278, 334, 445, 569, 471], у которых помимо прочего высота правого поддерева не должна была превышать высоту левого, так как не было понятно, можно ли обновить такую структуру за время O(log n). Но с решением этой проблемы интерес к таким структурам пропал, поскольку они не сулили никаких алгоритмических преимуществ в сравнении с обычными выровненными по высоте деревьями. 3.2. Выровненные по весу деревья Выровненные по высоте деревья поиска были разработаны Адельсоном-Вельским и Ландисом в 1962 году, когда оперативная память ЭВМ была крайне ограничена и практическое применение такой структуры в то время было почти невозможно. В 1960-х годах вышло всего лишь несколько работ по этой теме5. Однако к 1970 году с развитием компьютерных технологий 5 Но было модно изучать распределение высот деревьев поиска без выравнивания при случайных добавлениях и удалениях. 3.2. Выровненные по весу деревья 73 интерес к таким структурам повысился, поскольку они стали практически осуществимыми и потому полезными. Было предложено несколько альтернативных критериев для таких деревьев поиска с оценочной высотой O(log n). Наиболее естественным из таких критериев стал вес дерева, то есть количество листьев в поддеревьях, а не их высота. Выровненные по весу деревья были предложены в 1973 году Нивергельтом и Рейнгольдом [428] и в 1974 году Нивергельтом [426] и названы деревьями с «ограниченным выравниванием» («bounded balance»), или BB[α]-деревьями (рис. 3.5), а затем исследованы в [43, 76]. Еще один вариант выровненного по весу дерева был предложен в работе [140]. 38 23 15 9 14 6 2 3 4 1 1 2 1 2 1 1 1 1 7 9 2 6 5 3 1 1 4 2 1 2 4 3 2 2 1 1 1 2 1 1 1 2 2 1 1 1 1 8 3 2 2 1 1 5 1 1 1 1 2 3 3 1 1 2 1 1 2 1 1 1 1 1 1 1 1 Рис. 3.5. BB[0,29]-дерево с весами узлов Вес дерева измеряется количеством его листьев, поэтому в выровненном по весу дереве веса его левого и правого поддеревьев в любом узле должны быть почти равными. Таким образом, построенные нисходящим способом в разделе 2.7 оптимальные деревья поиска предельно выровнены по весу, причем левый и правый веса различаются не более чем на 1. Но при добавлении и удалении узлов выравнивание не обеспечивает такого строгого равновесия с сохранением сложности O(log n). Так что вместо разности весов поддеревьев было бы лучше контролировать их отношение. Это порождает целое семейство условий равновесия BB[α]-деревьев, когда для каждого внутреннего узла его левое и правое поддеревья имеют долю от общего веса этого узла не менее α и не более (1 – α). Высота BB[α]-дерева – всегда небольшая. 1 Теорема. BB[α]-дерево высоты h ≥ 2 имеет не менее �————� лис­тьев. 1–α BB[α]-дерево с n листьями имеет высоту не более log –1 1 n = �log2 �————�� log2 n. 1–α ——— 1–α 1 Доказательство. Пусть 𝒯h – BB[α]-дерево высоты h с минимальным количеством листьев. Либо левое, либо правое поддерево 𝒯h должно 74 Глава 3. Выровненные деревья поиска иметь высоту h – 1, поэтому вес этих поддеревьев не менее leaves(𝒯h -1) и не более (1 – α) leaves(𝒯h). Таким образом, доказательство ограниченности высоты дерева величиной O(log n) даже проще, чем для выровненных по высоте деревьев. Однако алгоритм выравнивания такого дерева сложнее, и у нас нет возможности поддерживать равновесие для произвольных значе1 ний α. Нивергельт и Рейнгольд [428] определили α < 1 – —— как необхо√2 димое условие выравнивания. Но при этом величина α не должна быть слишком малой, иначе в отдельных случаях выравнивание может потерпеть неудачу. Блум и Мельхорн [76] оценили нижнюю границу как 2 α > — . На самом деле если использовать другой метод выравнивания 11 для небольших деревьев, то значение нижней границы α может быть еще меньше. В нашей модели мы ограничимся небольшим интер2 ,1– валом α ∈ [— 7 1 √2 —— ] ⊃ [0,286; 0,292], хотя за счет дополнительных усилий по выравниванию деревьев малого веса можно достичь довольно малых значений α. Чтобы описать алгоритм выравнивания такого типа, нужно сначала выбрать значение α, а также значение второго параметра ε, подчиняющегося 1 условию ε ≤ α2 – 2α + — . Как и в случае выровненных по высоте деревьев, 2 необходимо в каждом внутреннем узле дерева поиска хранить дополнительную информацию – вес поддерева этого узла. Поэтому структура узла может выглядеть примерно так: typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; int weight; /* возможна другая информация */ } tree_node_t; Вес узла *n определяется рекурсивно по следующим правилам: • если *n – это лист (n->left = NULL), то n->weight = 1; • иначе n->weight – сумма весов левого и правого поддеревьев n->left->weight + n->right->weight. Узел *n считается α-выровненным по весу, если n->left->weight ≥ α n->weight и n->right->weight ≥ α n->weight, что равносильно и α n->left->weight ≤ (1 – α) n->right->weight (1 – α) n->left->weight ≥ α n->right->weight. 3.2. Выровненные по весу деревья 75 И здесь для поддержания равновесия дерева вес его поддеревьев тоже должен корректироваться при каждом изменении дерева. Он изменяется, причем не более чем на 1, только при выполнении операций добавления и удаления и лишь в тех узлах, что лежат на пути от измененного листа к корню. Таким образом, как и в выровненных по высоте деревьях (раздел 3.1), для восстановления равновесия в каж­дом узле на пути от листа к корню здесь применяется один из методов раздела 2.5 в предположении, что поддеревья узла уже имеют почти равные веса. Если *n – текущий узел с уже исправленным весом, то возможны следующие варианты выравнивания. 1. n->left->weight ≥ αn->weight и n->right->weight ≥ αn->weight. В этом случае выравнивание в этом узле не требуется, и можно перей­ти к вышестоящему узлу. 2. n->right->weight < αn->weight 2.1. Если n->left->left->weight > (α + ε) · n->weight, выполняется правое вращение вокруг n с последующим пересчетом веса в узле n->right. 2.2. Иначе выполняется левое вращение вокруг n->left, затем правое вращение вокруг n с последующим пересчетом веса в n->left и n->right. 3. n->left->weight < αn->weight 3.1. Если n->right->right->weight > (α + ε) n->weight, выполняется левое вращение вокруг n с последующим пересчетом веса в n->left. 3.2. Иначе выполняется правое вращение вокруг n->right, а затем левое вращение вокруг n с последующим пересчетом весов в n->left и n->right. Обратите внимание, что в отличие от выровненных по высоте деревьев в выровненных по весу деревьях нужно пройти весь путь от узла до корня, так как в отличие от высоты узла его вес может меняться на протяжении всего пути. Поскольку затраты времени в каждом узле – всего O(1) (не более двух вращений и не более трех пересчетов веса), а длина пути ограничена величиной O(log n), затраты времени на выравнивание такого дерева составят всего O(log n). Но опять же нужно доказать, что выравнивание такого дерева действительно восстанавливает α-равновесие. Пусть *nold и *nnew – один и тот же узел до и после выравнивания соответст­ венно. Пусть w – это вес nold->weight = nnew->weight. Тогда нужно проанализировать лишь случай 2, так как в случае 1 узел уже уравновешен, а случай 3 вытекает из случая 2 из-за их симметрии. В случае 2 мы имеем nold ->right-> weight < αw, но поскольку вес узла изменился лишь на 1 и до этого узел был уравновешен, то nold->right->weight = αw – δ для некоторого δ ∈ ]0, 1]. Теперь нужно убедиться в том, что в случаях 2.1 и 2.2 все изменяемые на этих шагах узлы будут уравновешены. 76 Глава 3. Выровненные деревья поиска 2.1. Мы имеем nold ->left->left->weight > (α + ε) w и выполняем правое вращение вокруг nold. В результате получаем: nold->left->left становится nnew->left, nold->left->right становится nnew->right->left, и nold->right становится nnew->right->right. Поскольку nold->left был уравновешен с весом nold->left->weight = (1 – α) w + δ, мы имеем nnew->right->left->weight ∈ [α(1 – α)w + αδ, (1 – 2α – ε)w + δ], nnew->right->right->weight = α w – δ, nnew->right->weight ∈ [α(2 – α)w – (1 – α)δ, (1 – α – ε)w], nnew->left->weight ∈ [(α + ε)w, (1 - α)2w + (1 – α)δ]. Теперь для nnew->right условия равновесия таковы: a. αnnew->right->left->weight ≤ (1 – α)nnew->right->right->weight, поэтому α((1 – 2α – ε)w + δ) ≤ (1 – α)(αw – δ), что справедливо при (α2 + αε)w ≥ δ; а также b. (1 – α) nnew->right->left->weight ≥ αnnew->right->right->weight, поэтому (1 – α)(α (1 – α)w + αδ) ≥ α(αw – δ), что справедливо при усло3 – √5 вии 0 ≤ α ≤ ————. 2 А для nnew условиями равновесия являются c. αnnew->left->weight ≤ (1 – α) nnew->right->weight, поэтому α ((1 – α)2 w + (1 – α)δ) ≤ (1 – α)(α(2 – α)w – (1 – α)δ), что справедливо при αw ≥ δ; а также d. (1 – α) nnew->left->weight ≥ α nnew->right->weight, поэтому (1 – α) ((α + ε)w) ≥ α((1 – α – ε) w), что справедливо для всех α при строгом условии ε > 0. Все это вмес­ те доказывает, что в интересующем нас интервале α ∈ [0, 1 – —1— ] √2 для не слишком малых поддеревьев (когда α2w ≥ 1) в случае 2.1 α-равновесие восстанавливается. 2.2. Мы имеем nold->left->left->weight ≤ (α + ε)w и выполняем левое вращение вокруг nold->left, а затем правое – вокруг nold. После чего: nold->left->left становится nnew->left->left, nold->left->right->left становится nnew->left->right, nold ->left->right->right становится nnew->right->left, nold->right становится nnew->right->right. Поскольку nold->left уже уравновешено с весом nold->left->weight = (1 − α)w + δ, в случае 2.2 мы имеем nnew->left->left->weight ∈ [α(1 − α)w + αδ, (α + ε)w], 3.2. Выровненные по весу деревья 77 nold->left->right->weight ∈ [(1 − 2α − ε)w + δ, (1 − α)2w + (1 − α)δ], nnew->left->right->weight, nnew->right->left->weight ∈ [α(1 – 2α – ε)w + αδ, (1 – α)3w + (1 – α)2δ], nnew->right->right->weight = αw − δ, nnew->left->weight ∈ [(2α – 3α2 + α3)w + α(2 – α)δ, (1 – 2α + 2α2 + αε)w + (1 – α)δ], n ->right->weight ∈ [(2α – 2α2 – αε)w – (1 – α)δ, (1 – 2α + 3α2 – α3)w + α(α – 2)δ]. new Тогда условия равновесия для nnew->left таковы: a. αnnew->left->left->weight ≤ (1 − α)nnew->left->right->weight, поэтому α((α + ε)w) ≤ (1 − α)(α(1 − 2α − ε)w + αδ) 1 1 — [ и ε ≤ α2 - 2α + — ; а также для α ∈ [0 , 1 - —√2 2 b. (1 – α)n ->left->left->weight ≥ αnnew->left->right->weight, поэтому (1 – α)(α(1 – α)w + αδ) ≥ α(1 – α)3w + (1 – α)2δ для α ∈ [0 , 1]. new Условия равновесия для nnew->right таковы: c. αnnew->right->left->weight ≤ (1 − α)nnew->right->right->weight и α(1 – α)3 w + (1 – α)2 δ ≤ (1 – α)(αw – δ), что справедливо по крайней мере при (2 – α)α2w ≥ (1 + α – α2)δ; а также d. (1 – α)nnew->right->left->weight ≥ αnnew->right->right->weight, поэтому (1 – α)(α(1 – 2α – ε)w + αδ) ≥ α(αw – δ), 1 √2 что справедливо для α ∈ [0 , 1 – —— [ и ε ≤ 2α2 – 4α + 1. А условия равновесия для nnew е. αnnew->left->weight ≤ (1 - α)nnew->right->weight, поэтому α(1 – 2α + 2α2 + αε)w + (1 – α)δ ≤ (1 – α)(2α – 2α2 – αε)w – (1 – α)δ, что справедливо при α(1 – 2α – ε)w ≥ 1, и f. (1 – α)nnew->left->weight ≥ αnnew->right->weight, поэтому (1 – α)(2α – 3α2 + α3)w + α(2 – α)δ ≥ α(1 – 2α + 3α2 – α3)w + α(α – 2)δ, 3 – √5 что справедливо при α ∈ [0, ————]. 2 Все это вместе доказывает, что в интересующем нас интервале α ∈ ]0, 1 – 1 √2 —— 1 [ при ε ≤ α2 – 2α + — , если, по крайней мере, подде2 рево не слишком мало, из условия α2w ≥ 1 следует, что в интервале (2 – α)α2w ≥ (1 + α – α2)δ) и α(1 – 2α – ε)w ≥ 1) в случае 2.2 α-равновесие восстанавливается. Но нужно еще убедиться, что алгоритм выравнивания работает при w < α—2. К сожалению, в самом общем случае это не так, но справедливо для 78 Глава 3. Выровненные деревья поиска 2 1 √2 α ∈ ]— , 1 – —— [, поэтому это условие нужно проверять только для w ≤ 12 и 7 n->right->weight = ⌊αw⌋. В случае 2.1 мы имеем дополнительно nold->left->left->weight ≥ ⌈αw⌉ и только одно условие равновесия (a), которое может быть нарушено. В этом случае нужно убедиться, что nnew->right->right->weight > α nnew->right->weight или что ⌊αw⌋ > α(w – ⌈αw⌉), а это легко проверяется. В случае 2.2 мы имеем дополнительно nold->left->left->weight ≤ ⌊αw⌋. Поскольку nold->left->weight = w – ⌊αw⌋, то равновесие в nold->left определяется весами поддеревьев nold->left->left и nold->left->right, по которым нетрудно убедиться, что равновесие восстановлено. На этом завершается доказательство того, что выравнивание по весу деревьев после добавлений и удалений может быть выполнено за время O(log n). Теорема. Выровненная по весу древовидная структура поддерживает операции find, insert и delete со временем O(log n). Возможная реализация операции insert для выровненных по весу деревьев может быть следующей: #define ALPHA 0.288 #define EPSILON 0.005 int insert (tree_node_t *tree, key_t new_key, object_t *new_object) { tree_node_t * tmp_node; if( tree->left == NULL ) { tree->left = (tree_node_t *) new_object; tree->key = new_key; tree->weight = 1; tree->right = NULL; } else { create_stack (); tmp_node = tree; while( tmp_node->right != NULL ) { push(tmp_node); if( new_key < tmp_node->key ) tmp_node = tmp_node->left; else tmp_node = tmp_node->right; } /* найден лист-кандидат: сравнить ключи */ 3.2. Выровненные по весу деревья 79 if( tmp_node->key == new_key ) return(-1); /* ключ уже есть: добавить нельзя */ /* ключа нет, и его можно добавить */ { tree_node_t *old_leaf, *new_leaf; old_leaf = get_node(); old_leaf->left = tmp_node->left; old_leaf->key = tmp_node->key; old_leaf->right = NULL; old_leaf->weight = 1; new_leaf = get_node (); new_leaf->left = (tree_node_t *) new_object; new_leaf->key = new_key; new_leaf->right = NULL; new_leaf->weight = 1; if( tmp_node->key < new_key ) { tmp_node->left = old_leaf; tmp_node->right = new_leaf; tmp_node->key = new_key; } else { tmp_node->left = new_leaf; tmp_node->right = old_leaf; } tmp_node->weight = 2; } /* выравнивание */ while( !stack_empty() ) { tmp_node = pop(); tmp_node->weight = tmp_node->left->weight + tmp_node->right->weight; if ( tmp_node->right->weight < ALPHA * tmp_node->weight ) { if( tmp_node->left->left->weight > (ALPHA + EPSILON) * tmp_node->weight ) { right_rotation (tmp_node); tmp_node->right->weight = tmp_node->right->left->weight + tmp_node->right->right->weight; } else { left_rotation (tmp_node->left); right_rotation (tmp_node); tmp_node->right->weight = tmp_node->right->left->weight + tmp_node->right->right->weight; 80 Глава 3. Выровненные деревья поиска tmp_node->left->weight = tmp_node->left->left->weight + tmp_node->left->right->weight; } } else if( tmp_node->left->weight < ALPHA * tmp_node->weight ) { if( tmp_node->right->right->weight > (ALPHA + EPSILON) * tmp_node->weight ) { left_rotation (tmp_node); tmp_node->left->weight = tmp_node->left->left->weight+ tmp_node->left->right->weight; } else { right_rotation (tmp_node->right); left_rotation (tmp_node); tmp_node->right->weight = tmp_node->right->left->weight + tmp_node->right->right->weight; tmp_node->left->weight = tmp_node->left->left->weight + tmp_node->left->right->weight; } } } /* конец выравнивания */ remove_stack(); } return(0); } Функция delete нуждается в таких же изменениях с тем же кодом выравнивания при движении вверх по дереву, а функция find вообще не требует никаких изменений. Поскольку высота стека ограничена величиной 1 1–α (log ———)–1 log n, меньшей чем 2,07 log n при заданном α интервале, а n < 2100, то здесь будет вполне достаточно стека в массиве максимального (фиксированного) размера. Приведенный здесь алгоритм уравновешивания дерева подобен двух­ этапному выравниванию высоты дерева: сначала – спуск к листу, а затем – восходящее к корню выравнивание. В принципе, выровненные по весу деревья допускают и нисходящее выравнивание, что делает второй этап выравнивания излишним. Это возможно потому, что веса поддеревьев известны уже при спуске, и, следовательно, известно, требуется ли их выравнивание, тогда как высота поддерева становится известной только 3.2. Выровненные по весу деревья 81 по достижении листа. Именно так изначально был описан в [429] и затем исследован в [346] алгоритм выравнивания BB[α]-деревьев. Хотя строгий анализ нисходящего выравнивания дерева показывает, что для его осуществления требуется больше усилий, поскольку прогноз для узла-потомка слабее: он не обязательно выровнен, так как еще не выровнены его потомки, даже если не выровнен только один из них. Что касается максимальной высоты, то выровненные по весу деревья не так хороши, как выровненные по высоте. Так, для нашего интервала α коэффициент (log)–1 равен примерно 2 вместо 1,44, а для еще бóльших α он будет только хуже. Уже в [429] было отмечено, что средняя глубина листьев несколько лучше, чем у выровненных по высоте деревьев. Теорема. Средняя глубина BB[α]-дерева с n листьями не более –1 —————————————————— log n. α log α + (1 – α) log (1 – α) Для установленного нами для α интервала этот коэффициент равен примерно 1,15, тогда как для выровненных по высоте деревьев он был равен 1,44. Доказательство. Здесь снова вместо средней используется суммарная глубина с рекурсивным условием depthsum(n) ≤ n + depthsum(a) + depthsum(b) для некоторых a и b, где a + b = n, a ≥ αn и b ≥ αn. Нужно доказать, что согласно индукции для некоторого c depthsum(n) ≤ cn log n с учетом того, что depthsum(n) ≤ n + ca log a + cb log b = 1 a b — = cn (— + — n log a + n log b) = с 1 a a b b — = cn log n + cn (— +— log — ). с + n log — n n n Так как функция x log x + (1 – x) log (1 – x) отрицательна и убывает при x ∈ [0; 0,5], второе слагаемое неположительно для –1 c = —————————————————— . α log α + (1 – α) log (1 – α) Более важным свойством выровненных по весу деревьев является следующее: Теорема. Между двумя последовательными выравниваниями одного и того же узла доля листьев под ним изменяется. 82 Глава 3. Выровненные деревья поиска Это важно, поскольку почти все операции с деревом требуют выравнивания вблизи листьев, что было отмечено в [76]. Доказательство. Нетрудно убедиться, что операции выравнивания уравновешивают каждый из измененных узлов не только с коэффициентом α, но даже с еще бóльшим коэффициен­том α*(α, ε) > α. А это значит, что вес такого узла должен изменяться в бóльшую сторону, нарушающую равновесие, поэтому именно эта часть листьев узла должна быть добавлена в него или удалена из него до следующего его выравнивания. По этой причине в алгоритме выравнивания используется дополнительный параметр ε > 0, без которого в случае 2.1 один из узлов теряет это более жесткое условие выравнивания. Критерием для выровненных по высоте деревьев была разность высот, а для выровненных по весу деревьев – отношение весов. Поскольку в любом выровненном по весу дереве вес растет логарифмически, понятно, что оба эти критерия равносильны. В [260] исследовался несколько более слабый критерий отношения высот. Но оказалось, что его недостаточно для обеспечения логарифмической высоты: максимальная высота выровненного по высоте дерева с n листьями получилась равной 2Θ(√log n) вместо Θ(log n) = 2log log n + Θ(1). 3.3. (a, b)- и B-деревья Еще один способ поддержания небольшой высоты дерева – это увеличение кратности (degree) его узлов. Эта идея под названием «B-деревьев» была предложена в 1972 году Байером и Маккрейтом [52] и оказалась очень плодотворной. Изначально она была задумана для представления структур данных во внешней памяти, но в разделе 3.4 будет показано, что она имеет интересное применение и в обычной основной памяти. Особенность внешней памяти в том, что доступ к ней гораздо медленнее, чем к основной, и осуществляется блоками, которые намного больше отдельных элементов основной памяти и которые передаются в нее целиком. В 1970-х годах основная память компьютеров была не очень большой, хотя внешняя память была уже довольно значительной, поэтому важно было понять, как работать со структурой данных, бóльшая часть которой расположена не в основной, а во внешней памяти. Сегодня это обстоятельство уже не столь важно, но все-таки остается актуальным для приложений баз данных, где различные варианты B-деревьев широко используются для представления индексных файлов. Проблема состоит в том, что узлы обычных двоичных деревьев поиска во внешней памяти могут быть разбросаны по разным блокам, что выясняется только после извлечения из внешней памяти очередного блока. Таким образом, нам может понадобиться такое количество обращений к блокам внешней памяти, которое становится сопоставимым с высотой де- 3.3. (a, b)- и B-деревья 83 рева, превышающей log2 n, и все эти довольно большие блоки с множеством узлов будут извлекаться только ради одного узла. Идея B-деревьев состоит в том, чтобы считать каждый блок одним узлом, но с большей кратностью. В оригинальной версии каждый узел имеет кратность от a до 2a – 1, где a выбирается настолько большим, чтобы в блоке было достаточно места для 2a – 1 указателей и ключей. При этом условии можно поддерживать равновесие дерева так, чтобы все его листья находились на одной глубине. Наименьший интервал кратности, для которого работает алгоритм выравнивания из [52], – [a, 2a – 1]. Поскольку в каждом блоке отводится место не более чем для 2a – 1 элементов, а блок заполняется не более чем наполовину, такой способ выглядел бы вполне приемлемым для оптимизации использования памяти. Но позже, в 1982 году, Хеддлстон и Мелхорн [285] и независимо от них в 1981 году Мейер и Сэлветер [386] обнаружили, что для алгоритма выравнивания чуть больший интервал имеет существенное значение. Если увеличить интервал кратности узла до b ≥ 2a, то средняя сложность вычислений при выравнивании блока составит O(1), тогда как для первоначальной верхней границы интервала b = 2a – 1 выравнивание блока уже может потребовать времени Θ(log n). Для структуры данных в основной памяти (а этот вопрос исследовался во многих работах) время выравнивания меняется слабо. Но для структур данных во внешней памяти это важно, так как все измененные блоки должны возвращаться во внешнюю память. Таким образом, (а, b)-деревья – это новый способ представления деревьев поиска. (а, b)-деревья – это не двоичные деревья поиска, все листья которых имеют одинаковую глубину; каждый узел, не являющийся корнем, имеет кратность от a до b ≥ 2a, а корень имеет кратность не менее 2 и не более b (если дерево не пусто или имеет только один лист). (а, b)-дерево обязательно имеет небольшую высоту. Теорема. (а, b)-дерево высоты h ≥ 1 имеет не менее 2аh–1 и не более bh листьев. (а, b)-дерево с п ≥ 2 листьями имеет высоту не более loga(n) + (1 – loga 2) ≈ (1 / log2 a) log п. Это следует непосредственно из определения. Поскольку структура таких деревьев отличается от структуры описанных в главе 2 классических двоичных деревьев поиска, то в целях их выравнивания в их структуру добавляются дополнительные параметры. Узел такого дерева имеет следующую структуру: typedef struct tr_n_t { int degree; int height; key_t key[B]; struct tr_n_t *next[B]; /* возможна другая информация */ } tree_node_t; 84 next[0] next[1] next[2] next[3] obj obj obj obj key[3] 88 key[2] 70 key[1] 62 key[0] 55 next[3] next[6] obj next[2] next[5] obj obj next[4] obj next[1] next[3] obj next[0] next[2] obj obj next[1] obj key[2] 24 next[0] obj key[1] 23 next[4] obj 40 height obj key[3] 50 next[3] obj degree obj key[2] 20 next[3] key[3] 42 key[1] 10 next[2] key[0] 21 key[0] key[3] 14 next[2] obj key[6] 19 key[0] 10 next[1] key[5] 17 key[3] 7 next[0] key[4] 15 40 height key[2] 13 degree key[1] 12 70 height key[4] 9 degree key[2] 6 50 height key[1] 4 degree key[0] 1 next[1] 41 height next[0] degree obj brass3 Здесь (а, b)-дерево описано как структура основной памяти. В случае же внешней памяти нужно установить соответствие между узлами основной памяти и блоками внешней памяти. Кроме того, потребуются еще и функции извлечения узла из внешней памяти и возвращения его обратно во внешнюю память. В структуре узла указывается его кратность degree, не превышающая B, него ветвей next[B] и а также множество из более cuus247-brass 978не 0 521 88037 4чем B исходящих August 4, 2008 из 11:40 множество соответствующих им ключей key[B]. Чтобы выйти на нужную ветвь, обычно достаточно только одного значения ключа в пределах кратности узла. Для узлов самого нижнего уровня отдельные листовые узлы не создаются – их роль выполняют ссылки на объекты и значения их ключей. Чтобы определить узел самого нижнего (нулевого) уровня, в структуру узлов добавлено поле со значением высоты height (рис. 3.6). obj P1: KAE Глава 3. Выровненные деревья поиска Рис. 3.6. (a, b)-дерево (a = 4, b = 8)6 Как и в случае двоичных деревьев поиска, с каждым узлом связан полу­ открытый интервал возможных значений ключа, доступных через этот узел или его указатель. Если *n – узел для интервала [a, b[, то интервалы узлов, на которые он ссылается, будут такими: Узел 6 Интервал n->next[0] [a, n->key[1][ n->next[i], где 1 ≤ i ≤ n->degree – 2 [ n->key[i], n->key[i + 1][ n->next[n->degree – 1] [n->key[n->degree – 1], b[ На этом рисунке высота узла – это младшая цифра поля degree, хотя согласно определению структуры она должна находиться в отдельном поле height. – Прим. перев. 3.3. (a, b)- и B-деревья 85 А операция поиска find будет реализована следующим образом: object_t *find(tree_node_t *tree, key_t query_key) { tree_node_t *current_node; object_t *object; current_node = tree; while( current_node->height >= 0 ) { int lower, upper; /* двоичный поиск по ключам */ lower = 0; upper = current_node->degree; while( upper > lower +1 ) { if( query_key < current_node->key[(upper+lower)/2]) upper = (upper+lower)/2; else lower = (upper+lower)/2; } if( current_node->height > 0 ) current_node = current_node->next[lower]; else { /* блок высоты 0, содержит указатели объектов */ if( current_node->key[lower] == query_key ) object = (object_t *) current_node->next[lower]; else object = NULL; return(object); } } } Операция find двоичного поиска по узловым ключам выполняется так же быстро, как в обычном двоичном дереве. В заключение приведем реализацию операций insert и delete с выравниванием, сохраняющим структуру (a, b)-дерева. Операции insert и delete начинаются так же, как в случае обычного двоичного дерева поиска: сначала – спуск вниз по дереву до узла высоты 0 с определением места добавления нового листа или удаления старого. Если в узле еще есть место для нового листа или (в случае удаления) он содержит не более a объектов, то проблем нет. Если же узел переполнен при добавлении или неполон при удалении, то необходима перестройка дерева, возможно с подтягиванием его структуры вверх. Правила перестройки дерева таковы: • При добавлении текущий узел переполняется. a. Если текущий узел – корень, то создаются два новых узла и в каждый из них копируются корневые записи, а в корень поме- 86 Глава 3. Выровненные деревья поиска щаются только ссылки на них с разными ключами. Высота корня увеличивается на 1. b. Иначе создается новый узел, и в него переносится половина записей из переполненного узла, а ссылка на новый узел переносится в вышестоящий узел. Это называется «разделением» (splitting) узла. • При удалении текущий узел становится неполным. a. Если текущий узел – корень и в нем всего один указатель, то он – неполный. Тогда в него копируется содержимое нижнего узла, который затем удаляется (возвращается в систему управления памятью). b. Иначе на уровне текущего узла ищется блок, непосредственно предшествующий ему или следующий за ним в порядке следования ключей и имеющий тот же вышестоящий узел. Если найденный блок еще не совсем заполнен, то из него в текущий узел переносится один ключ и соответствующая ему ссылка, а в вышестоящем узле исправляются разделяющие эти два блока значения ключей. Это называется «перераспределением» (sharing) узлов. c. В противном случае весь текущий узел копируется в этот, еще неполный соседний узел той же высоты, а сам текущий узел и ссылка на него в вышестоящем узле удаляются. Это называется «соединением» (joining) узлов. Очевидно, что этот метод восстанавливает свойство (a, b)-дерева. Если узел переполнен, то в нем довольно много записей, чтобы разделить его на два узла. Если же узел неполон и у его соседа есть свободные элементы, то их можно соединить в один узел. Эти операции работают даже при b = 2a – 1 (то есть для классических B-деревьев), и поскольку на каждом уровне изменяется не более двух блоков, то понятно, что количество измененных блоков можно оценить как O(logan). Для классических B-деревьев эта оценка тоже является наилучшей из возможных: если b = 2a – 1, то оба новых блока, полученных путем разделения переполненного блока (с b + 1 = 2a входами), имеют почти минимальную кратность, поэтому удаление только что добавленного элемента заставит их снова соединиться. Нетрудно привести пример, когда при добавлении на всем пути разделяется каждый блок; таким образом, удаление только что добавленного элемента вновь приведет к соединению таких пар блоков. В [286, 387] было замечено: если увеличить верхнюю границу b хотя бы на 1 (b ≥ 2a), то оценка времени (правда, средняя) изменения блоков становится O(1), то есть гораздо лучше. Для доказательства этого факта определим на дереве поиска функцию потенциала и проверим, как она меняется при добавлении или удалении элемента. Изменение структуры дерева всегда 3.3. (a, b)- и B-деревья 87 проверяется непосредственно перед очередной операцией (разделения, перераспределения или соединения), поэтому уровень узла после удаления может стать a – 1, а после добавления – b + 1. Действия с корнем в расчет не принимаются, поскольку при добавлении или удалении корня требуется не более одной операции. Определим потенциал дерева как сумму потенциалов его узлов, где потенциал узла *n определяется следующим образом: ⎧ 4, если n->degree = a − 1 и *n не корень ⎜1, pot (*n) = 0, ⎨ 3, ⎜ ⎩ 6, если если если если n->degree = a a < n->degree < b n->degree = b n->degree = b + 1 и и и и *n *n *n *n не корень корень . не корень не корень Каждая операция начинается с добавления или удаления узла на самом нижнем уровне, так что до завершения любой операции перестройки дерева это просто изменение уровня одного из узлов на 1, в связи с чем потенциал всего дерева может увеличиться не более чем на 3. Теперь мы утверждаем, что каждая операция перестройки дерева уменьшает его потенциал по крайней мере на 2. Поскольку потенциал дерева неотрицателен и изначально ограничен величиной 6n, значит, каж­ 3 дое добавление или удаление может потребовать в среднем не более — 2 операций его перестройки и, возможно, еще одну операцию с корнем. Нам нужно доказать это утверждение для каждого из следующих вариантов перестройки дерева: • при добавлении текущий узел имеет кратность b + 1. a. Действие с корнем в расчет не принимается. b. Операция разделения берет текущий узел кратности b + 1 и делит b+1 b+1 его на два узла с кратностями �———� и �———� , увеличивая крат2 2 ность вышестоящего узла. Поэтому узел с потенциалом 6 удаляется, и создаются два новых узла, один из которых имеет потенциал 1 (с кратностью а), а другой – потенциал 0 (с кратностью от a + 1 до b – 1), что повышает кратность вышестоящего узла на 1, а его потенциал не более чем на 3. В итоге общий потенциал узла уменьшается по меньшей мере на 2; • при удалении текущий узел, если он не корень, имеет кратность a – 1. a. Действие с корнем в расчет не принимается. b. При перераспределении берется текущий узел кратности a – 1 и его сосед с кратностью не менее a + 1 и не более b и создается два новых узла, каждый из которых имеет кратность не менее a и не более b. Таким образом, удаляются узел с потенциа­лом 4 и узел с неотрицательным потенциалом, а вместо них создаются два но- 88 Глава 3. Выровненные деревья поиска вых узла с потенциалами не более 1 каждый. Общий потенциал уменьшается по меньшей мере на 2. c. При соединении берется текущий узел кратности a – 1 и его сосед кратности a и создается один новый узел кратности 2a – 1 < b, уменьшая тем самым кратность вышестоящего узла на 1. Удаляются два узла с потенциалами 4 и 1, и создается один новый узел с потенциалом 0, увеличивая тем самым потенциал вышестоящего узла не более чем на 3. Общий потенциал уменьшается по меньшей мере на 2. Таким образом, мы доказали, что структуру (a, b)-дерева можно эффективно поддерживать. Теорема. Структура (a, b)-дерева поддерживает операции find, insert, delete и требует при этом O(loga n) операций чтения/записи блоков и в среднем всего O(1) операций записи блока для insert и delete. В заключение предлагаем одну из возможных реализаций такой структуры. tree_node_t *create_tree() { tree_node_t *tmp; tmp = get_node(); tmp->height = 0; tmp->degree = 0; return( tmp ); } int insert(tree_node_t *tree, key_t new_key, object_t *new_object) { tree_node_t *current_node, *insert_pt; key_t insert_key; int finished; current_node = tree; if( tree->height == 0 && tree->degree == 0 ) { tree->key[0] = new_key; tree->next[0] = (tree_node_t *) new_object; tree->degree = 1; return(0); /* добавить в пустое дерево */ } create_stack(); while( current_node->height > 0 ) /* current_node не лист */ { int lower, upper; /* двоичный поиск по ключам */ push( current_node ); lower = 0; upper = current_node->degree; 3.3. (a, b)- и B-деревья 89 while( upper > lower + 1 ) { if( new_key < current_node->key[(upper+lower)/2] ) upper = (upper+lower)/2; else lower = (upper+lower)/2; } current_node = current_node->next[lower]; } /* теперь current_node – лист, куда можно добавлять */ insert_pt = (tree_node_t *) new_object; insert_key = new_key; finished = 0; while( !finished ) { int i, start; if( current_node->height > 0 ) start = 1; /* добавление не в лист начинается с 1 */ else start = 0; /* добавление не в лист начинается с 0 */ if( current_node->degree < B ) /* узел еще неполный */ { /* перенести все выше, освободив место для добавления */ i = current_node->degree; while( (i > start)&& (current_node->key[i-1] > insert_key) ) { current_node->key[i] = current_node->key[i-1]; current_node->next[i] = current_node->next[i-1]; i -= 1; } current_node->key[i] = insert_key; current_node->next[i] = insert_pt; current_node->degree += 1; finished = 1; } /* конец добавления в неполный узел */ else /* узел полный; его нужно разделить */ { tree_node_t * new_node; int j, insert_done = 0; new_node = get_node(); i = B - 1; j = (B - 1)/2; while( j >= 0 ) /* скопировать верхнюю половину в новый узел */ { if( insert_done || insert_key < current_node->key[i] ) { new_node->next[j] = current_node->next[i]; new_node->key[j--] = current_node->key[i--]; } else { new_node->next[j] = insert_pt; 90 Глава 3. Выровненные деревья поиска new_node->key[j--] = insert_key; insert_done = 1; } } /* верхняя половина готова; добавить в нижнюю половину, если нужно */ while( !insert_done ) { if( insert_key < current_node->key[i] && i >= start ) { current_node->next[i + 1] = current_node->next[i]; current_node->key[i + 1] = current_node->key[i]; i -= 1; } else { current_node->next[i + 1] = insert_pt; current_node->key[i + 1] = insert_key; insert_done = 1; } } /* добавление закончено */ current_node->degree = B+1 - ((B+1)/2); new_node->degree = (B+1)/2; new_node->height = current_node->height; /* разделение закончено; добавить новый узел выше */ insert_pt = new_node; insert_key = new_node->key[0]; if( !stack_empty() ) /* это не корень: подняться на уровень выше */ { current_node = pop(); } else /* разделение корня */ /* нужен новый узел для сохранения его адреса */ { new_node = get_node(); for( i = 0; i < current_node->degree; i++ ) { new_node->next[i] = current_node->next[i]; new_node->key[i] = current_node->key[i]; } new_node->height = current_node->height; new_node-> degree = current_node->degree; current_node->height + = 1; current_node->degree = 2; current_node->next[0] = new_node; current_node->next[1] = insert_pt; current_node->key[1] = insert_key; finished = 1; } /* разделение корня закончено */ } /* разделение узла закончено */ 3.3. (a, b)- и B-деревья 91 } /* выравнивание закончено */ remove_stack(); return(0); } object_t *delete(tree_node_t *tree, key_t delete_key) { tree_node_t *current, *tmp_node; int finished, i, j; current = tree; create_node_stack(); create_index_stack(); while( current->height > 0 ) /* не лист */ { int lower, upper; /* двоичный поиск по ключам */ lower = 0; upper = current->degree; while( upper > lower + 1 ) { if( delete_key < current->key[(upper+lower)/2]) upper = (upper+lower)/2; else lower = (upper+lower)/2; } push_index_stack(lower); push_node_stack(current); current = current->next[lower]; } /* теперь current — лист, из него можно удалять */ for( i=0; i < current->degree ; i++ ) if( current->key[i] == delete_key ) break; if( i == current->degree ) { return(NULL); /* ключа нет: удаление невозможно */ } else /* ключ есть: удалить его из листа */ { object_t *del_object; del_object = (object_t *) current->next[i]; current->degree -= 1; while( i < current->degree ) { current->next[i] = current->next[i+1]; current->key[i] = current->key[i+1]; i += 1; } /* ключ удален из узла — выровнять узел */ finished = 0; while( ! finished ) { if(current->degree >= A ) { finished = 1; /* узел пока полон, можно остановиться */ } else /* узел стал неполным */ { if( stack_empty() ) /* текущий узел — это корень */ 92 Глава 3. Выровненные деревья поиска { if(current->degree >= 2 ) finished = 1; /* корень еще нужен */ else if (current->height == 0 ) finished = 1; /* удаление остальных ключей корня */ else /* удалить корень, сохранив его адрес */ { tmp_node = current->next[0]; for( i=0; i< tmp_node→degree; i++ ) { current->next[i] = tmp_node->next[i]; current->key[i] = tmp_node->key[i]; } current->degree = tmp_node->degree; current->height = tmp_node->height; return_node( tmp_node ); finished = 1; } } /* корень обработан */ else /* удаление из некорневого узла */ { tree_node_t *upper, *neighbor; int curr; upper = pop_node_stack(); curr = pop_index_stack(); if( curr < upper->degree - 1 ) /* не последний */ { neighbor = upper->next[curr+1]; if( neighbor->degree > A ) { /* возможно перераспределение */ i = current->degree; if( current->height > 0 ) current->key[i] = upper->key[curr+1]; else /* это лист, берем его ключ */ { current->key[i] = neighbor->key[0]; neighbor->key[0] = neighbor->key[1]; } current->next[i] = neighbor->next[0]; upper->key[curr+1] = neighbor->key[1]; neighbor->next[0] = neighbor->next[1]; for( j = 2; j < neighbor->degree; j++) { neighbor->next[j-1] = neighbor->next[j]; neighbor->key[j-1] = neighbor->key[j]; } neighbor->degree -= 1; current->degree += 1; finished = 1; } /* перераспределение завершено */ else /* требуется соединение узлов */ { i = current->degree; 3.3. (a, b)- и B-деревья 93 if( current->height > 0 ) current->key[i] = upper->key[curr+1]; else /* уровень листа: выбрать его ключ */ current->key[i] = neighbor->key[0]; current->next[i] = neighbor->next[0]; for( j = 1; j < neighbor->degree; j++ ) { current->next[++i] = neighbor->next[j]; current->key[i] = neighbor->key[j]; } current->degree = i+1; return_node(neighbor); upper->degree -= 1; i = curr+1; while( i < upper->degree ) { upper->next[i] = upper->next[i+1]; upper->key[i] = upper->key[i+1]; i += 1; } /* удаленный из upper перенести вверх */ current = upper; } /* конец if-else: перераспределение-разделение*/ } else /* current — последний в upper */ { neighbor = upper->next[curr-1]; if( neighbor->degree > A ) { /* возможно перераспределение */ for( j = current->degree; j > 1; j-- ) { current->next[j] = current->next[j-1]; current->key[j] = current->key[j-1]; } current->next[1] = current->next[0]; i = neighbor->degree; current->next[0] = neighbor->next[i-1]; if( current->height > 0 ) { current->key[1] = upper->key[curr]; } else /* уровень листа: выбрать его ключ */ { current->key[1] = current->key[0]; current->key[0] = neighbor->key[i-1]; } upper->key[curr] = neighbor->key[i-1]; neighbor->degree -= 1; current->degree += 1; finished =1; } /* перераспределение завершено */ else /* nht,rtncz cjtlbytybt epkjd */ 94 Глава 3. Выровненные деревья поиска { i = neighbor->degree; if( current->height > 0 ) neighbor->key[i] = upper->key[curr]; else /* уровень листа: выбрать его ключ */ neighbor->key[i] = current->key[0]; neighbor->next[i] = current→next[0]; for( j = 1; j < current->degree; j++ ) { neighbor->next[++i] = current->next[j]; neighbor->key[i] = current->key[j]; } neighbor->degree = i+1; return_node(current); upper->degree -= 1; /* удаленный из upper перенести вверх */ current = upper; } /* конец if-else: перераспределение/соединение */ } /* end of current is (not) last in upper if-else*/ } /* end of delete root/non-root if-else */ } /* end of full/underfull if-else */ } /* end of while not finished */ return(del_object); } /* end of delete object exists if-else */ } В операции delete используются два стека: один − для узла, другой − для индекса в узле. Все стеки в операциях insert и delete – это массивы; их размер ограничивается максимальной высотой, то есть зависит от a – минимальной кратности узлов. Так как в большинстве приложений размер блока на диске равен 4–8 Кб, то значение a порядка 500 вполне приемлемо. В этом случае п < 2100 и допускает максимальную высоту 12, хотя в большинстве реальных приложений высота 3 уже считается большой. Поскольку обращение к блоку диска довольно медленное, а доступ к нескольким смежным блокам не намного медленнее, то размер узлов можно сделать намного больше размера блока на диске, если операционная система позволяет быстро сохранять смежные блоки. При b ≥ 2a структура (a, b)-дерева допускает даже нисходящее выравнивание, когда оно выполняется при спуске от корня к листу без возврата обратно к корню. Такой метод выглядит заманчиво и позволяет обойтись без стека, но его недостаток в том, что количество изменяемых узлов увеличивается. Суть метода довольно проста: при добавлении узла каждый узел кратности b на пути вниз разделяется. Разделение узла не затрагивает вышестоящий узел, поскольку тот уже разделен и в нем есть место для дополнительной записи. В узле самого нижнего уровня тоже есть мес­ то для нового листа, который туда и добавляется. При удалении, спускаясь вниз по дереву, выполняется перераспределение или соединение узлов 3.3. (a, b)- и B-деревья 95 кратнос­ти a. Эти действия тоже не затрагивают вышестоящих узлов, так как их кратность уже не превышает a + 1, но зато есть возможность сохранить удаляемую запись в нижестоящем узле. Таким образом выполняется опережающее разделение или соединение, когда изменения затрагивают только O(logan) узлов, а средняя оценка O(1) при этом вообще не меняется. Поскольку мы ввели условие b ≥ 2a, такой метод неприменим к классическим B-деревьям, где b = 2a – 1. Потенциально полезное свойство нисходящего метода заключается в том, что он требует блокировки только текущего узла и его соседей, а не всего пути до корня. Было предложено несколько альтернативных решений проблемы доступа к блочной памяти. Вместо создания новой структуры, подобной (a, b)-дереву и явно приспособленной для работы с блоками памяти, можно использовать обычные двоичные деревья (скажем, выровненные по высоте) и стараться группировать их узлы в блоки так, чтобы максимальное количество блоков на любом пути от корня к листу было небольшим [327, 509, 178, 253]. Поскольку плотность неявного представления поддерева в узле (a, b)-дерева в виде массивов ключей и указателей довольно высока, такое представление имеет лишь небольшое преимущество перед любым другим, явно сохраняющим поддерево в блоке. Но зато метод, допускающий произвольные деревья и группирующий узлы дерева в блоки, может многократно использовать исходную структуру дерева, а также выстроенную над ней блочную структуру, как описано в главе 4. Дублирование узлов дерева в разных блоках ускоряет обращение к ним, но замедляет их обновление [268]. Иной критерий выравнивания узлов-блоков того же типа, что в (a, b)-деревьях, был предложен в 1981 году [156]. Все листья их m-кратно разветв­ ленных деревьев с минимальным наполнением узлов r тоже располагаются на одной глубине. Однако выравнивание достигается за счет того, что любой некорневой узел с кратностью ниже предельной величины m имеет не менее r узлов с тем же вышестоящим узлом максимальной кратности. Этот критерий подобен братским (brother) деревьям [442, 443, 441] и наследует от них неэффективность алгоритма удаления со сложностью O((log n )m–1 вместо O(log n) для m-кратных деревьев. Для блоков небольшого размера был предложен метод использования двоичных деревьев поиска модели 2 с объектами в узлах, но с хранением в них нескольких последовательных ключей и соответствующих им объектов [298]. В этом случае у каждого узла есть только два нижестоящих узла – один для ключей, меньших наименьшего ключа, и один для ключей, больших наибольшего. Поскольку такое дерево все еще остается двоичным, к нему можно применить любую схему выравнивания, например ту же, что для выровненных по высоте деревьев. Мотивацией для такой структуры послужило то, что кеш процессора организован так же, как внешняя память, только с гораздо меньшим размером блока. Однако в одном таком блоке может быть размещено больше узлов, поэтому пакет из нескольких узлов дерева в кеше требует гораздо меньше операций обмена с кешем. 96 Глава 3. Выровненные деревья поиска Правда, такого же улучшения можно достичь, взяв (a, b)-дерево с небольшим значением b, например (4, 8). Для блоков большего размера этот метод менее эффективен, чем для (a, b)-дерева, так как при выравнивании высоты глубина дерева будет иметь пределы log2(n/b) и 1,4 log2(n/a) вместо logb n и loga n. 3.4. Красно-черные деревья и деревья почти оптимальной высоты Как отмечалось в предыдущем разделе, идея изменяющейся кратнос­ти узлов дерева поиска во внешней памяти полезна и для обычных двоичных деревьев поиска в основной памяти. Узел (a, b)-дерева может быть преобразован в небольшое двоичное дерево поиска с количеством листьев от a до b. Это было замечено еще Байером в [49] вместе с определенными им же B-деревьями как структуры внешней памяти [52]. Он предложил наименьший частный случай (2, 3)-дерева в качестве двоичного дерева поиска, где всякий узел кратности 3 заменяется двумя двоичными узлами, соединенными ветвью, которую он назвал «горизонтальной», потому что она связывала два узла одного уровня основного (2, 3)-дерева. Затем он же в [50] расширил эту идею до (2, 4)-деревьев в качестве основной структуры и назвал производные двоичные деревья поиска «симметричными двоичными B-деревьями» («Symmetric Binary B-trees»), или SBB-деревьями. В таких деревьях ветви разделяются на «нисходящие» («вертикальные») и «горизонтальные» по следующим правилам: • пути от корня до любого листа имеют одинаковое количество нисходящих ветвей; • не допускается двух последовательных горизонтальных ветвей. Такая структура в точности соответствует (2, 4)-деревьям: если собрать все горизонтальные ветви нижнего уровня в вышестоящем узле, получится дерево поиска с кратностью узлов от 2 до 4, все листья которого находятся на одном уровне. Из предыдущей главы известно, что такие деревья имеют высоту не более log2 n, поэтому производное двоичное дерево поиска будет иметь высоту не более 2 log2 n. Так что алгоритм выравнивания такого дерева наследуется от лежащей в его основе структуры (2, 4)-дерева. Дальнейшее развитие этой идеи было предложено Гибасом и Седжвиком в [263], где помечались не ветви, а узлы: верхний узел каждого небольшого двоичного дерева, замещающего (2, 4)-узел, помечался черным цветом, а остальные – красным (рис. 3.7). Это и есть красно-черное (red-black tree), или rb-дерево, которое теперь вошло во многие учебники как двоичное дерево поиска с узлами, окрашиваемыми красным или черным цветом, по следующим правилам: • пути от корня до любого листа имеют одинаковое количество черных узлов; P1: KAE brass3 cuus247-brass 978 0 521 88037 4 August 4, 2008 11:40 3.4. Красно-черные деревья и деревья почти оптимальной высоты 97 • нет двух последовательных красных узлов; • корень – всегда черный. Помимо узлов, окрашиваются еще и листья. Это правило нарушает полную аналогию с (2, 4)-деревьями, но зато удобно для выравнивания дерева. bl r bl или bl r r bl r Рис. 3.7. Преобразование (2, 4)-узлов в двоичные rb-деревья Любой красный узел можно перенести в вышестоящий черный узел и получить (2, 4)-дерево, не затрагивая листьев. Поэтому rb-дерево имеет высоту не более 2 log n + 1, а выравнивание со сложностью в худшем случае O(log n) и та же средняя сложность изменения узлов O(1) наследуются от лежащего в его основе (2, 4)-дерева. Единственный недостаток структуры из предыдущего раздела заключается в том, что алгоритм выравнивания вместо вращений использует более сложные операции разделения, перераспределения или соединения. Но ниже будет приведен алгоритм с той же сложностью, использующий вращения. Рис. 3.8. rb-дерево с раскраской узлов Другие равносильные версии этой же структуры – полувыровненные (half-balanced) деревья Оливье [439], отличающиеся тем, что самый длинный путь от каждого внутреннего узла до листа превышает самый короткий путь не более чем вдвое. Эквивалентность таких деревьев с rb-деревьями была установлена в [530]), а их эквивалентность со стандартными братскими (brother) деревьями – в [443, 438], узлы которых имеют одного или двух потомков, а листья находятся на одинаковой глубине, причем единственный потомок не может находиться на четных уровнях. Несколько альтернативных алгоритмов выравнивания таких структур были предложены в [530, 567, 25, 135]. В [263] было отмечено, что и другие способы выравнивания также можно осуществить путем раскраски подлежащих выравниванию узлов. Было давно известно, что для выровненных по высоте деревьев не нужно хранить высоту в каждом узле – достаточно лишь информации о соотношении 98 Глава 3. Выровненные деревья поиска высот его поддеревьев: они либо равны по высоте, либо их высоты различаются только на 1. Такой прием изначально применялся лишь для экономии памяти, но вполне вписался в рамки метода раскраски узлов. Если в выровненных по высоте деревьях каждый узел нечетной высоты с вышестоящим узлом четной высоты – красный, а все остальные узлы – черные, то он удовлетворяет условиям rb-дерева. Однако не все rb-деревья выровнены по высоте; дополнительное условие состоит в том, что если узел черный и оба его потомка тоже черные, то хотя бы один из их потомков должен быть красным. В этом случае по цвету его потомков и их потомков можно выровнять высоту узла, а с помощью этой информации можно выровнять высоту и всего дерева. В [263] приведен ряд других способов выравнивания rb-деревьев, наиболее интересный из которых − нисходящее выравнивание, так как его можно выполнить при спуске от корня к листу, что делает второй, обратный проход от листа к корню излишним. Еще один вывод, сделанный при переосмыслении (a, b)-деревьев в основной памяти, – это деревья малой высоты. Из главы 2 известно, что высота двоичного дерева поиска с n листьями не меньше log n, а ее верхний предел – за счет выравнивания – можно удерживать на уровне 1,44 log n. Эти пределы для rb-деревьев и выровненных по весу деревьев несколько хуже: 2 log n для rb-деревьев и не менее 2 log n (в зависимости от значения а) для выровненных по весу деревьев. Это наводит на мысль: можно ли улучшить верхний предел 1,44 log n, сохранив при этом время обновления структуры O(log n)? Ведь без этого требования можно было бы после каждого обновления просто перестраивать оптимальное дерево. Первым 1 решением c верхним пределом (1 + — ) log n при любом k ≥ 1 (для алгоритk мов, зависящих от k) были k-деревья Маурера, Оттмана и Сикса [403]. Однако Андерссон с соавторами [27] нашли еще более простое решение. Они просто приняли в качестве базовой структуры (2k, 2k + 1)-дерево и заменяли каждый узел высокой кратности на небольшое дерево поиска оптимальной высоты k + 1. В этом случае для базовой структуры уже существует алгоритм выравнивания (a, b)-деревьев посредством операций разделения, соединения или перераспределения, а для внутренних двоичных деревьев те же операции можно выполнить после нескольких вращений, поскольку в разделе 2.2 было показано, что на одном и том же множестве объектов любое преобразование дерева поиска можно выполнить путем нескольких вращений. Таким образом, для такой древовидной структуры при фиксированном количестве k выравниваний можно поддерживать высоту не бо1 k лее (k + 1) log2k (n) = (1 + — ) log2 n, за время O(log n) и со средним временем O(1) для вращений. Выбрав k = log log n, Андерссон с соавторами [27] снизили высоту до (1 + O(1)) log2 n, а Андерссон и Лай в своих диссертациях и статьях с соавторами снизили ее даже до O(log n). В итоге можно сказать, что высота log2 п, скорее всего, недостижима за O(п) выравниваний, так как при п = 2k отдельные деревья поиска высоты k различаются на Ω(n) для 3.4. Красно-черные деревья и деревья почти оптимальной высоты 99 {1, …, n} и {2, …, n + 1}, хотя высота log2 n + 1 вполне достижима путем O(log n) выравниваний [22, 344, 198]. Все это, конечно, не имеет большого практического значения, поскольку такие алгоритмы довольно сложны, и небольшой выигрыш во времени поиска объекта в дереве (а он не усложняется) не покрывает больших потерь времени при добавлении или обновлении объекта. Ранее мы определили границу высоты 2 log n + 1. Теорема. rb-дерево высоты h имеет не менее 2(h/2) + 1 – 1 листьев для 3 четного значения h и не менее �— �2(h–1)/2 – 1 листьев для нечетного 2 значения h. Максимальная высота rb-дерева с n листьями – 2 log n – O(1). Доказательство. Нам уже известно, что высота дерева ограничивается высотой (a, b)-дерева: высота (2, 4)-дерева с n листьями не превышает log n, а каждый его узел можно заменить двоичным деревом высоты 2 так, что высота базового двоичного дерева не превысит 2 log n. Но нужно показать, что и высота rb-дерева не превысит этой высоты: (2, 4)-дерево высоты log n может иметь узлы только кратности 2, и потому высота худшего (экстремального) базового двоичного дерева тоже может иметь высоту log n. В таком случае нужно определить худшее rb-дерево. Пусть 𝓣h − rb-дерево высоты h с минимальным количеством листьев (рис. 3.9). Тогда существует путь от корня к листу глубины h, на котором лежат только красные узлы (в противном случае количество лис­тьев можно уменьшить). Значит, структура дерева 𝓣h такова, что в нем существует путь длины h, вне которого могут быть только полные двоичные деревья черного цвета высоты i с 2i листьями. Поскольку вдоль этого пути высота двоичных поддеревьев вместе с их черными узлами неизменна, общее количество его листьев равно 1 + 2i + 2i + 2i + ··· + 2i , 1 2 3 h где ij ≤ ij+1, и каждый показатель степени встречается не более двух раз: один раз под самим красным узлом и один раз под его черным верхним соседом. Таким образом, при четном h количество листьев дерева 𝓣h равно 1 + 2(20 + 21 + 22 + ··· + 2(h/2)−1) = 2(h/2)+1 − 1, а при нечетном – 3 1 + 2(20 + 21 + 22 + ··· + 2((h−1)/2)−1) + 2(h−1)/2 = — 2(h−1)/2 − 1. 2 Таким образом, высота rb-дерева в худшем случае оценивается как 2 log n – O(1). 100 Глава 3. Выровненные деревья поиска Рис. 3.9. rb-дерево высоты 8 с минимальным количеством листьев Как и для выровненных по высоте деревьев, такой предел высоты является жестким не только для худшего случая, и не исключается, что почти все листья такого дерева находятся на одной и той же глубине. Такое rb-дерево было построено Кэмероном и Вудом [109]. Здесь мы опишем приводимый во всех учебниках классический метод восходящего выравнивания rb-дерева, а в разделе 3.5 приведем альтернативный метод нисходящего выравнивания. Оба метода работают с одной и той же структурой узлов, где достаточно лишь дополнительной информации о цвете узла. typedef struct tr_n_t { key_t key; struct tr_n_t * left; struct tr_n_t * right; enum { red, black } color; /* возможна другая информация */ } tree_node_t; При этом необходимо соблюдать следующие условия выравнивания: (1) каждый путь от корня до листа содержит одинаковое коли­чество черных узлов; (2) прямые потомки красного узла должны быть черными. К ним можно добавить еще одно условие: (3) корень должен быть обязательно черным. Данное условие не является ограничением, так как корень всегда можно сделать черным, не нарушая всех остальных условий. Но зато это условие гарантирует, что родитель каждого красного узла всегда черный, поэтому можно мысленно собрать все красные узлы в своих черных родителях, чтобы добиться изоморфизма с (2, 4)-деревьями. 3.4. Красно-черные деревья и деревья почти оптимальной высоты 101 Способы выравнивания для операций добавления и удаления различны. При добавлении выполняется обычное добавление с окрашиванием обоих новых листьев в красный цвет. Это может нарушить требование (2), но не нарушает требования (1). Поэтому после добавления выравнивание начинается с конфликтного красного узла, прямые потомки которого – тоже красные. Такой узел перемещается вверх к корню, пока конфликт не будет устранен. При удалении выполняется обычное удаление, но с сохранением цвета узлов. Если удаляемые листья черные, нарушается условие (1), но не нарушается условие (2). Так что при таком цветовом конфликте узел также перемещается вверх к корню до тех пор, пока конфликт не будет устранен. Выравнивание при добавлении (рис. 3.10) выполняется следующим образом. Если нарушение условия (2) произошло в корне, он окрашивается в черный цвет. Иначе пусть *upper – это узел с прямыми потомками *current и *other, где *current – родительский узел пары красных узлов, нарушающих условие (2). Поскольку существует только одна пара узлов, нарушающих условие (2), *upper – черный узел. Далее правила таковы: 1. Если other – красный, то окрасить current и other в черный, а upper – в красный. 2. Если current = upper->left 2.1. Если current->right->color – черный, выполняется правое вращение вокруг upper, а цвет upper->right меняется на красный. 2.2. Если current->right->color – красный, выполняется левое вращение вокруг current, затем правое вращение вокруг upper, цвета upper->right и upper->left меняются на черный, а цвет upper – на красный. 3. Если current = upper->right 3.1. Если current->left->color – черный, выполняется левое вращение вокруг upper, а цвет upper->left меняется на красный. 3.2. Если current->left->color – красный, выполняется правое вращение вокруг current, затем левое вращение вокруг upper, цвета upper->right и upper->left меняются на черный, а upper – на красный. Легко видеть, что эти операции не нарушают условие (1). Нарушенное условие (2) восстанавливается в случаях 1, 2.2 и 3.2 за счет перемещения вверх по дереву двух узлов, в случаях 2.1 и 3.1 – за счет вращений, в корне – за счет изменения его цвета. Поскольку на каждом уровне по пути к корню длиною O(log n) требуется только O(1) времени, то все выравнивание потребует времени O(log n). 102 Глава 3. Выровненные деревья поиска upper r current bl bl ? other r r r bl bl r r bl r ? bl bl r r bl bl r bl r bl bl bl bl ? bl bl bl bl bl Рис. 3.10. Выравнивание при добавлении: исходное состояние и случаи 1, 2.1 и 2.2; узел current имеет прямого потомка красного цвета В принципе, используя тот же способ доказательства, что и для (a, b)-деревьев, можно показать, что среднее количество вращений для rb-дерева – всего O(1). Для этого каждому черному узлу нужно присвоить потенциал (1, 0, 3, 6) согласно количеству (0, 1, 2, 3) его прямых потомков красного цвета. Тогда каждое добавление будет увеличивать сумму потенциалов минимум на 3, тогда как операции 1, 2.2 и 3.2 будут уменьшать эту сумму минимум на 2, а операции 2.1 и 3.1 при выравнивании будут выполняться всего один раз. Оценки сложности вычислений для rb-деревьев и (a, b)-деревьев одинаковы, хотя выравнивание путем вращений отличается от выравнивания путем разделения, соединения и перераспределения. Чуть усложнив правила выравнивания при добавлении, можно было бы получить оценку сложности четырех вращений для худшего случая. В случаях 2.2 и 3.2, где возможны вращения с перемещением вверх при цветовом конфликте, нужно изменить цвет upper->right и upper->left на черный, так как есть вероятность того, что оба прямых потомка current будут красными. Правда, это может произойти только один раз и только на уровне листьев, после чего может быть уже не более одного прямого потомка красного цвета. А в этом случае можно окрасить upper->right и upper->left в красный цвет, а upper – в черный. В результате этих изменений все вращения выше уровня листа устраняют цветовые конфликты. Выравнивание при удалении, к сожалению, намного сложнее7. В этом случае нарушается условие (1): узел *current, все пути через который ведут к удаляемому листу, содержит на один черный узел меньше, чем нужно. Возможны два простых варианта: 1. Если current – красный, он окрашивается в черный цвет; 2. Если current – корень, условие (1) не нарушается в любом случае. 7 Очень часто здесь легко допустить ошибку: в одном из известных учебников по алгоритмам один из вариантов выравнивания при удалении был неправильным. 3.4. Красно-черные деревья и деревья почти оптимальной высоты 103 В противном случае можно предположить, что *current – черный, а его родитель *upper имеет другого прямого потомка *other. Поскольку все пути от *other к листу содержат не менее двух черных узлов, то все узлы ниже *other, на которые есть ссылки в следующих случаях, действительно сущест­вуют. Случаи и правила преобразования таковы: 3. Если current = upper->left 3.1. Если upper, other и other->left – черные, выполняется левое вращение относительно upper, upper->left окрашивается в красный цвет, а upper – в черный. Условие (1) нарушается только в upper. 3.2. Если upper и other – черные, а other->left – красный, выполняется правое вращение относительно other и вслед за ним левое вращение относительно upper, а цвета upper->left, upper->right и upper меняются на черный. Условие (1) восстанавливается. 3.3. Если upper – черный, other – красный, а other->left->left – черный, выполняется левое вращение относительно upper и затем левое вращение относительно upper->left, цвет upper->left->left меняется на красный, а цвета upper->left и upper – на черный. Условие (1) восстанавливается. 3.4. Если upper – черный, other – красный, а other->left->left – красный, выполняется левое вращение вокруг upper, затем правое вращение вокруг upper->left->right и левое вращение вокруг upper->left. Цвета upper, upper ->left->left и upper->left->right меняются на черный, а upper->left – на красный. Условие (1) восстанавливается. 3.5. Если upper – красный, other – черный, а other->left – черный, выполняется левое вращение вокруг upper, цвет upper->left меняется на красный, а upper – на черный. Условие (1) восстанавливается. 3.6. Если upper – красный, other – черный, а other->left – красный, выполняется правое вращение вокруг other, затем левое вращение вокруг upper, цвета upper->left и upper->right меняются на черный, а цвет upper – на красный. Условие (1) восстанавливается. 4. Если current = upper->right 4.1. Если upper, other и other->right – черные, выполняется правое вращение вокруг upper, цвет upper->right меняется на красный, а upper – на черный. Условие (1) нарушается в upper. 4.2. Если upper – черный, other – черный, а other ->right – красный, выполняется левое вращение вокруг other, затем правое вращение вокруг upper, цвета upper->left, upper->right и upper меняются на черный. Условие (1) восстанавливается. P1: KAE brass3 104 cuus247-brass 978 0 521 88037 4 August 4, 2008 11:40 Глава 3. Выровненные деревья поиска 4.3. Если upper – черный, other – красный, а other->right->right – черный, выполняется правое вращение вокруг upper, а затем правое вращение вокруг upper->right, цвет upper->right->right меняется на красный, а upper->right и upper – на черный. Условие (1) восстанавливается. 4.4. Если upper – черный, other – красный, а other->right->right – красный, выполняется правое вращение вокруг upper, затем левое вращение вокруг upper->right->left и правое вращение вокруг upper->right, цвета upper->right->right и upper->right->left меняются на черный, upper->right – на красный, а upper – на черный. Условие (1) восстанавливается. 4.5. Если upper – красный, other – черный, а other->right – черный, выполняется правое вращение вокруг upper, цвет upper->right меняется на красный, а upper – на черный. Условие (1) восстанавливается. 4.6. Если upper – красный, other – черный, а other->right – красный, выполняется левое вращение вокруг other, затем правое вращение вокруг upper , цвета upper->left и upper->right меняются на черный, а upper – на красный. Условие (1) восстанавливается. upper 3.1 ? bl current ? other bl bl 3.2 bl bl bl r ? bl ? bl bl bl r bl bl 3.3 bl bl r bl bl 3.4 bl bl ? ? r bl ? ? r bl bl bl r 3.6 ? bl ? bl r ? ? ? bl bl bl bl ? bl bl r bl r bl bl ? bl r bl bl bl r r bl ? bl bl bl bl 3.5 bl bl bl bl bl bl ? bl bl bl bl ? bl Рис. 3.11. Выравнивание при удалении: исходное положение и случаи 3.1–3.6. Для путей, проходящих через current, слишком мало одного черного узла 3.4. Красно-черные деревья и деревья почти оптимальной высоты 105 И здесь сложность вычислений оценивается как O(1) на каждом уровне по пути от листа к корню, поэтому общая сложность не превысит O(log n). Правда, операции 3.1 и 4.1 могут выполняться более одного раза, но на самом деле они требуют времени Ω(log n), что можно увидеть, если начать с полного двоичного дерева черного цвета, удалив из него только одну вершину. На этом завершается доказательство того, что при добавлении и удалении для красно-черных деревьев выравнивание может быть осуществ­ лено за время O(log n). Теорема. rb-дерево поддерживает операции find, insert и delete за время O(log n). Ниже приводится реализация только операции добавления insert в красно-черные деревья. int insert(tree_node_t *tree, key_t new_key, object_t *new_object) { tree_node_t *current_node; int finished = 0; if( tree->left == NULL ) { tree->left = (tree_node_t *) new_object; tree->key = new_key; tree->color = black; /* корень - всегда черный */ tree->right = NULL; } else { create_stack(); current_node = tree; while( current_node->right != NULL ) { push( current_node ); if( new_key < current_node->key ) current_node = current_node->left; else current_node = current_node->right; } /* ищем лист-кандидат: проверяем различие ключей */ if( current_node->key == new_key ) return(-1); /* ключи различаются: выполнить добавление */ { tree_node_t *old_leaf, *new_leaf; old_leaf = get_node(); old_leaf->left = current_node->left; old_leaf->key = current_node->key; old_leaf->right = NULL; old_leaf->color = red; 106 Глава 3. Выровненные деревья поиска new_leaf = get_node(); new_leaf->left = (tree_node_t *)new_object; new_leaf->key = new_key; new_leaf->right = NULL; new_leaf->color = red; if ( current_node->key < new_key ) { current_node->left = old_leaf; current_node->right = new_leaf; current_node->key = new_key; } else { current_node->left = new_leaf; current_node->right = old_leaf; } } /* выравнивание */ if( current_node->color == black || current_node == tree ) finished = 1; /* иначе: current_node − верхний узел с красно-красным конфликтом */ while( !stack_empty() && !finished ) { tree_node_t *upper_node, *other_node; upper_node = pop(); if( upper_node->left->color == upper_node->right->color ) { /* и красный, и upper_node - черные */ upper_node->left->color = black; upper_node->right->color = black; upper_node->color = red; } else /* current_node − красный, other_node − черный */ { if( current_node == upper_node->left ) { other_node = upper_node->right; /* other_node->color == black */ if( current_node->right->color == black ) { right_rotation( upper_node ); upper_node->right->color = red; upper_node->color = black; finished = 1; } else /* current_node->right->color == red */ { left_rotation( current_node ); right_rotation( upper_node ); upper_node->right->color = black; upper_node->left->color = black; upper_node->color = red; } 3.5. Нисходящее выравнивание красно-черных деревьев 107 } else /* current_node == upper_node->right */ { other_node = upper_node->left; /* other_node->color == black */ if( current_node->left->color == black ) { left_rotation( upper_node ); upper_node->left->color = red; upper_node->color = black; finished = 1; } else /* current_node->left->color == red */ { right_rotation( current_node ); left_rotation( upper_node ); upper_node->right->color = black; upper_node->left->color = black; upper_node->color = red; } } /* конец текущего_узла слева/справа от верхнего */ current_node = upper_node; } /* конец other_node красный/черный */ if( !finished && !stack_empty() ) /* upper − красный, конфликт может пойти вверх */ { current_node = pop(); if( current_node->color == black ) finished = 1; /* выше нет конфликта */ /* иначе: current − верхний узел красно-красного конфликта */ } } /* конец while-цикла, вернувшегося в корень */ tree->color = black; /* корень − всегда черный */ } remove_stack(); return(0); } Код для функции удаления delete не приводится; она работает так же, но с описанными выше многочисленными вариантами выравнивания. Здесь, как и в предыдущей главе, стек должен быть массивом. 3.5. Нисходящее выравнивание красночерных деревьев Метод из предыдущего раздела опять очень похож на методы для выровненных по высоте и выровненных по весу деревьев, обсуждавшихся в разделах 3.1 и 3.2: в них сначала выполняется нисходящий поиск листа, а 108 Глава 3. Выровненные деревья поиска затем восходящее – от листа к корню – выравнивание. Однако rb-деревья допускают и нисходящее – без возврата к корню – выравнивание, как это делалось для выровненных по весу и (a, b)-деревьев. Этот метод – частный случай метода из раздела 3.3, но здесь он будет описан подробно и исключительно для rb-деревьев. При добавлении мы спускаемся от корня к листу и с помощью некоторых преобразований добиваемся того, чтобы текущий черный узел имел не более одного прямого красного потомка. Поэтому всякий раз, когда встречается черный узел с двумя прямыми красными потомками, выполняется выравнивание, подобное разделению узлов кратности 4 в (2, 4)-дереве. В итоге лист всегда получается черным, и к нему можно добавить (подвесить) новый лист без последующего выравнивания. При удалении мы спускаемся от корня к листу и с помощью некоторых преобразований добиваемся того, чтобы у текущего черного узла был хотя бы один прямой красный потомок. Поэтому всякий раз, когда встречается черный узел с двумя прямыми черными потомками, выполняется выравнивание, подобное соединению или разделению узлов кратности 2 в (2, 4)-дереве. В итоге лист всегда получается черным с по крайней мере одним прямым красным потомком, которого можно просто удалить без последующего выравнивания. Ниже приводятся правила нисходящего выравнивания при добавлении. Пусть *current – текущий черный узел на пути поиска листа, а *upper – вышестоящий черный узел, между которыми может быть красный узел. Согласно нашим правилам выравнивания, *upper имеет не более одного красного потомка. 1. Если current->left или current->right – черные, выравнивание не требуется. 2. Если current->left и current->right – красные и current->key < upper->key, то: 2.1. Если current = upper->left, то current->left и current->right – черные, а current – красный. Если upper->left->key < new_key: • заменить current на upper->left->left, иначе • заменить current на upper->left->right. 2.2. Если current = upper->left->left, то выполнить поворот вправо вокруг upper и заменить цвет upper->left и upper->right на красный, а цвет upper->left->left и upper->left->right – на черный. Если upper->left->key < new_key: • заменить current на upper->left->left, иначе • заменить current на upper->left->right. 2.3. Если current = upper->left->right, то выполнить поворот влево вокруг upper->left с последующим поворотом вправо вокруг 3.5. Нисходящее выравнивание красно-черных деревьев 109 upper, изменить цвет upper->left и upper->right на красный, а upper->left->right и upper->right->left – на черный. Если upper->key < new_key: • заменить current на upper->left->right, иначе • заменить current на upper->right->left. P1: KAE brass3 3. Иначе current->left и current->right – красные, и current->key ≥ upper->key, то: 3.1. Если current = upper->right, то current->left и current->right – черные, а current – красный. Если upper->right->key < new_key: • заменить current на upper->right->left, иначе • заменить current на upper->right->right. 3.2. Если current = upper->right->right, выполнить поворот влево вокруг upper и изменить цвет upper->left и upper->right на красный, а upper->right->left и upper->right->right – на черный. Если upper->right->key < new_key: • заменить current на upper->right->left, иначе • заменить current на upper->right->right. 3.3. Если current = upper->right->left , то выполнить поворот впраcuus247-brass 978 0 521 88037 4 August 4, 2008 11:40 во вокруг upper->right с последующим поворотом влево вокруг upper и изменить цвет upper->left и upper->right на красный, а цвет upper-> left->right и upper->right->left – на черный. Если upper->key < new_key: • заменить current на upper->left->right, иначе • заменить current на upper->right->left. bl 2.1 bl r bl ? r r r ? bl bl bl r bl 2.3 r bl bl bl bl r bl r bl r bl bl bl r bl 2.2 r bl r bl bl bl r Рис. 3.12. Случаи 2.1–2.3 нисходящего добавления: upper и current помечены как опускающийся вниз current bl bl 110 Глава 3. Выровненные деревья поиска Новый узел current в случаях 2 и 3 – сначала красный, поэтому оба его прямых потомка – черные. Но после выравнивания текущим узлом становится upper, и он продвигается далее вниз по пути поиска, пока не встретится черный узел или лист. Если встречается черный узел, то снова выполняется выравнивание. Если же встречается лист, выполняется добавление. Добавление создает новый внутренний узел ниже upper, который окрашивается в красный цвет. Если upper оказывается родителем этого нового красного узла, мы закончили, иначе единственный красный узел под upper является узлом над новым узлом; тогда выполняется вращение вокруг upper, и свойство rb-дерева восстанавливается. Ниже приводится реализация функции insert добавления в rb-дерево с нисходящим выравниванием. int insert (tree_node_t *tree, key_t new_key, object_t *new_object) { if( tree->left == NULL ) { tree->left = (tree_node_t *) new_object; tree->key = new_key; tree->color = black; /* корень – всегда черный */ tree->right = NULL; } else { tree_node_t *current, *next_node, *upper; current = tree; upper = NULL; while( current->right != NULL ) { if( new_key < current->key ) next_node = current->left; else next_node = current->right; } if( current->color == black) { if( current->left->color == black || current->right->color == black ) { upper = current; current = next_node; } else /* current->left и current->right – красные */ { /* требуется выравнивание */ if( upper == NULL ) /* current – корень */ { current->left->color = black; current->right->color = black; upper = current; } else if( current->key < upper->key ) 3.5. Нисходящее выравнивание красно-черных деревьев 111 { /* current слева от upper */ if( current == upper->left ) { current->left-> color = black; current->right-> color = black; current->color = red; } else if( current == upper->left->left ) { right_rotation(upper); upper->left->color = red; upper->right-> color = red; upper->left->left->color = black; upper->left->right->color = black; } else /* current == right->left->right */ { left_rotation(upper->left); right_rotation (upper); upper->left->color = red; upper->right->color = red; upper->right->left->color = black; upper->left->right->color = black; } } else /* current->key > = upper->key */ { /* current справа от upper */ if( current == upper->right ) { current->left->color = black; current->right->color = black; current->color = red; } else if( current == upper->right->right ) { left_rotation(upper); upper->left->color = red; upper->right->color = red; upper->right->left->color = black; upper->right->right->color = black; } else /* current == upper->right->left */ { right_rotation(upper->right); left_rotation(upper); upper->left->color = red; upper->right->color = red; upper->right->left->color = black; upper->left->right->color = black; } } /* окончание выравнивания */ 112 Глава 3. Выровненные деревья поиска current = next_node; upper = current; /* два черных нижних соседа */ } } else /* current – красный */ { current = next_node; / * двигаться вниз * / } } /* конец цикла while: достигнут лист, причем черный */ /* найден лист-кандидат; различны ли их ключи? */ if( current->key == new_key ) return(-1); /* ключи различны: добавить новый лист */ { tree_node_t *old_leaf, *new_leaf; old_leaf = get_node(); old_leaf->left = current->left; old_leaf->key = current->key; old_leaf->right = NULL; old_leaf->color = red; new_leaf = get_node(); new_leaf->left = (tree_node_t *) new_object; new_leaf->key = new_key; new_leaf->right = NULL; new_leaf->color = red; if( current->key < new_key ) { current->left = old_leaf; current->right = new_leaf; current->key = new_key; } else { current->left = new_leaf; current->right = old_leaf; } } } return(0); } При удалении нисходящее выравнивание тоже несколько усложняется. Пусть *current – текущий черный узел на пути поиска, а *upper – предыдущий черный узел, между которыми может быть красный узел. Спускаясь по дереву, нужно придерживаться этого требования так, чтобы узлы upper-> left или upper->right были красными. 3.5. Нисходящее выравнивание красно-черных деревьев 113 1. Если current->left или current->right – красные, выравнивание не требуется. Заменить upper на current, а current переместить вниз по пути поиска к следующему черному узлу. 2. Если current->left и current->right – черные и current->key < upper-> key, то: 2.1. Если current = upper->left и 2.1.1. upper->right->left->left и upper->right->left->right – черные, то выполняется левое вращение вокруг upper, цвет upper-> left меняется на черный, а upper->left->left и upper->left-> right – на красный, после этого current и upper меняются на upper->left. 2.1.2. upper->right->left->left – красный, выполняется правое вращение в upper->right->left, затем правое вращение в upper->right и левое вращение в upper, цвета upper->left и upper->right->left меняются на черный, а upper->right и upper->left->left – на красный; current и upper присваивается значение upper->left. 2.1.3. upper->right->left->left – черный, а upper->right->left-> right – красный, то выполняется правое вращение в upper-> right, а затем левое в upper; цвет upper->left и upper->right-> left меняется на черный, а upper->right и upper->left-> left – на красный; заменить current и upper на upper->left. 2.2. current = upper->left->left и 2.2.1. upper->left->right->left и upper->left->right->right – черные, то upper->left->left и upper->left->right становятся красными, а upper->left – черным; current и upper заменить на upper->left. 2.2.2. upper->left->right->right – красный, то выполнить левое вращение в upper->left, цвета upper->left->left и upper-> left->right заменяются на черный, а цвета upper->left и upper->left->left->left – на красный; заменить current и upper на upper->left->left. 2.2.3. upper->left->right->left – красный и upper->left->right-> right – черный, то выполнить правое вращение в upper-> left->right, затем левое вращение в upper->left, цвета upper-> left->left и upper->left->right поменять на черные, а цвета upper->left и upper->left->left->left – на красные; заменить current и upper на upper->left->left. 114 Глава 3. Выровненные деревья поиска 2.1 2.1.1 bl bl bl r bl bl bl bl bl bl bl r bl bl 2.1.2 bl bl bl bl bl bl r bl 2.1.3 r bl bl bl bl r bl bl bl ? bl bl bl r bl bl 2.2.1 bl r bl ? bl r r bl ? bl bl r bl bl bl bl bl bl bl r bl bl ? r bl r bl bl bl r bl bl bl bl bl r bl bl ? r bl ? bl r bl bl ? ? bl bl bl 2.3.3 bl r r ? bl r bl bl bl bl bl bl bl bl bl bl ? bl r bl ? bl bl bl bl bl bl bl bl bl r bl r r bl ? 2.3.2 bl ? 2.3.1 r bl ? bl bl bl ? r 2.2.3 r bl ? bl bl r bl bl bl bl bl bl bl ? bl bl 2.3 bl bl r bl bl r r bl ? 2.2.2 bl bl bl 2.2 bl r ? bl bl bl bl bl r bl bl r bl bl bl bl bl bl bl r bl ? bl bl bl bl bl bl bl r bl bl Рис. 3.13. Случаи 2.1–2.3 при нисходящем удалении: помечены upper и current 2.3. Если current = upper->left->right и 2.3.1. upper->left->left->left и upper->left->left->right – черные, то цвета upper->left->left и upper->left->right заменить на красные, а цвет upper->left – на черный; current и upper заменить на upper->left. 3.5. Нисходящее выравнивание красно-черных деревьев 115 2.3.2. upper->left->left->left – красный, выполнить правое вращение в upper->left, цвета upper->left->left и upper->left-> right заменить на черный, а цвета upper->left и upper->left-> right-> right – на красный; current и upper заменить на upper->left-> right. 2.3.3. upper->left->left->left – черный и upper->left->left->right – красный, выполнить левое вращение в upper->left->left, затем правое вращение в upper->left, цвета upper->left->left и upper->left->right заменить на черный, а цвета upper->left и upper->left->right->right – на красный; current и upper заменить на upper->left->right. 3. Иначе current->left и current->right – черные, а current->key ≥ upper->key. 3.1. Если current = upper->right и 3.1.1. upper->left->right->right и upper->left->right->left – черные, то выполнить правое вращение в upper, цвета upper->right изменить на черный, а upper->right->right и upper->right-> left – на красный; заменить current и upper на upper->right. 3.1.2. upper->left->right->right – красный, то выполнить левое вращение в upper->left->right, затем левое вращение в upper->left и правое вращение в upper, цвета upper->right и upper->left->right изменить на черный, а цвета upper->left и upper->right->right – на красный; установить current и upper заменить на upper->right. 3.1.3. upper->left->right->right – черный и upper->left->right-> left – красный, то выполнить левое вращение в upper->left, а затем правое вращение в upper, изменить цвета upper-> right и upper->left->right на черный, а цвета upper->left и upper->right->right – на красный; установить current и upper в upper->right. 3.2. Если current = upper->right->right и 3.2.1. upper->right->left->right и upper->right->left->left – черные, то цвета upper->right->right и upper->right->left изменить на красный, а цвет upper->right – на черный; current и upper заменить на upper->right. 3.2.2. upper->right->left->left – красный, то выполнить правое вращение в upper->right, цвета upper->right->right и upper-> right->left заменить на черный, а цвета upper->right и upper-> right-right->right – на красный; current и upper заменить на upper->right->right. 116 Глава 3. Выровненные деревья поиска 3.2.3. upper->right->left->right – красный и upper->right->left-> left – черный, то выполнить левое вращение в upper->right-> left, а затем правое вращение в upper->right и заменить цвета upper->right->right и upper->right->left на черный, а цвета upper->right и upper->right->right->right – на красный; current и upper заменить на upper->right->right. 3.3. Если current = upper->right->left и 3.3.1. upper->right->right->right и upper->right->right->left – черные, то заменить цвета upper->right->right и upper->right-> left на красный, а upper->right – на черный; current и upper заменить на upper->right. 3.3.2. upper->right->right->right – красный, то выполнить левое вращение в upper->right, а цвет upper->right->right и upper-> right->left заменить на черный, цвет upper->right и upper-> right->left->left – на красный; current и upper заменить на upper->right->left. 3.3.3. upper->right->right->right – черный и upper->right->right-> left – красный, то выполнить правое вращение в upper-> right->right с последующим левым вращением в upper-> right, цвета upper->right->right и upper->right->left меняются на черный, а upper->right и upper->right->left->left – на красный; current и upper заменить на upper->right->left. После этого выравнивания current опускается вниз по пути поиска, пока не встретится черный узел или лист. Если встретился черный узел, то выполняется выравнивание, а если встретился лист, выполняется удаление. При этом удаляется сам лист и внутренний узел ниже upper. Однако под upper может быть красный узел, поэтому если лист находится ниже этого красного узла, то удаляются он и красный узел. В противном случае выполняется вращение в upper так, чтобы над листом оказался красный узел. После этого удаляется и лист, и сам красный узел. Таким образом поддерживается основное свойство rb-дерева. 3.6. Деревья с постоянным временем обновления в заранее известном месте Нам уже известно, что среднее время изменения узлов любого (a, b)-дерева постоянно. В принципе, это справедливо и для родственных им rb-деревьев с той лишь разницей, что rb-деревья перестраиваются за счет вращений и перекраски узлов. В описанных в предыдущем разделе алгоритмах восходящего выравнивания нам было важно лишь постоянство среднего количества вращений, хотя по пути нужно было менять еще и цвет узлов. С по- 3.6. Деревья с постоянным временем обновления в заранее известном месте 117 мощью другого алгоритма выравнивания Тарьяну в [530] удалось снизить оценку числа вращений при обновлении rb-деревьев со средней до худшей оценки O(1), правда, без учета времени на поиск узлов, подлежащих вращениям и перекраске по пути вверх. Поэтому если лист, где требуется обновление, заранее известен, это время непостоянно даже при средней оценке. Овермарс [449] предложил очень простую идею преобразования любого двоичного дерева поиска для худшего случая со временем O(log n) и обновления в заранее известном месте (known location) со средним временем O(1) в худшем случае и небольшим увеличением постоянного множителя C. Предложенный им способ – сгруппировать листья самого нижнего уровня дерева в упорядоченные связные списки. Длина этих списков ограничена величиной log n, а время поиска при этом остается равным O(log n), так как поиск элемента – это просто переход к нужному листу дерева с последующим просмотром связного списка. Если длина упорядоченного списка превышает пороговое значение log n, то к нему добавляется новый элемент за то же время O(log n) с возможным последующим разделением списка на два (второй список – это новый лист дерева) и выравниванием дерева. Поскольку списки переполняются в среднем примерно после log n добавлений, выравнивание дерева выполняется в среднем только через каждые Ω(log n) шагов, а общее время выравнивания может быть оценено как O(log n). Таким образом, при добавлении средняя оценка выравнивания дерева – O(1) с учетом того, что место добавления уже точно известно. Этот метод не работает при удалении элемента, так как содержащий удаляемый объект список укорачивается, а соседние списки при этом могут оставаться довольно длинными, чтобы добавить в них новый элемент. Вместо этого Овермарс [448, 450] предложил гораздо более сильное преобразование, подобное полной перестройке теневой копии дерева в массиве, описанной в разделе 1.5. Основная его идея заключается в том, что в любой выровненной древовидной структуре выравнивание после удаления (в отличие от добавления) объекта можно отложить. При добавлении l новых элементов в выровненное дерево поиска с m листьями без выравнивания его высота может вырасти с C log m до l + C log m, тогда как выровненная высота может вырасти только до C log (m + l). С другой стороны, удаление l элементов без выравнивания дерева не увеличивает его высоту, тогда как его выровненная высота может уменьшиться до C log (m – l). Следовательно, половину элементов дерева можно удалить вообще без выравнивания и по-прежнему иметь предельную погрешность выравнивания высоты дерева C = O(1). Таким образом, можно установить порог количества удалений (скажем, m), а при его превышении начать выстраивать новое дерево, продолжая работать со старым и копируя O(1) элементов (скажем, по 4 на каждом шаге) так, чтобы закончить построение нового дерева, когда старое еще содержит, скажем, более m элементов. После этого можно перейти к новому дереву и начать перенос в него оставшихся узлов старого дерева опять же с постоянным количеством узлов за один шаг. Таким образом, 118 Глава 3. Выровненные деревья поиска издержки удаления известного листа в худшем случае составят O(1). Такой метод можно применить к любому выровненному дереву поиска с объектами любого типа, например к описанным выше деревьям с группировкой листьев и временем Θ(log n), для которых m = Θ(n/log n). Сложность реализации этого метода состоит в том, что текущее (старое) дерево изменяется при его копировании в новое. Таким образом, удаление с постоянным временем в худшем случае в выровненных деревьях поиска, в принципе, не представляет проблемы. Однако проблема увеличения константы C при добавлении в дерево нового элемента для худшего случая некоторое время оставалась открытой. Она была решена в [364] с использованием двухуровневой схемы группировки и в [212] с применением (a, 4a)-деревьев с одноуровневой группировкой и с отложенным разделением узлов, когда в них содержится не менее 2a + 1 элементов. Реализация этих методов здесь не приводится из-за сложности, связанной с необходимостью их объединения с методом полной перестройки дерева в случае нескольких удалений. 3.7. Пальцевые деревья и поуровневое связывание Основная идея пальцевых (finger) деревьев состоит в том, что поиск элемента дерева можно ускорить, если известно положение его соседнего элемента, называемого пальцем. В этом случае время поиска объекта зависит не от общего количества элементов базового множества S, а только от расстояния между пальцем f и искомым элементом q. Самый приемлемый способ измерения такого расстояния – количество элементов между пальцем и искомым элементом, а наилучшее время поиска, на которое при этом можно рассчитывать, – O(log |S ∩ [f, q]| ). Поскольку пальцевой поиск основан на обычном поиске (для этого достаточно добавить в базовое множество S элемент −∞ и тоже считать его пальцем), он не может быть быстрым, но логарифмического времени поиска все-таки можно достичь. Правда, это потребует некоторой дополнительной информации в структуре дерева поиска. Как известно, лист не ссылается на другие узлы дерева, а путь от листа к корню приходится сохранять в стеке, поскольку он не определяется одним лишь листом. И даже добавление обратных указателей не решает эту проблему, поскольку для перехода к соседнему листу, возможно, придется возвращаться к корню (например, при переходе от самого правого листа левого поддерева к его правому соседу). Для этого нужны дополнительные связи в дереве, называемые поуровневыми (level linking). Пальцевые деревья были описаны в [262] для основанной на B-деревьях структуры и чуть позже были исследованы в [99, 334] для (2, 3)-деревьев. Поуровневое связывание проще всего объяснить на (a, b)-деревьях, так как в них все листья находятся на одной глубине. В этом случае на каждом уров- 3.7. Пальцевые деревья и поуровневое связывание 119 не i дерева можно создать двусвязный список узлов, а каждый узел уровня снабдить обратной ссылкой на вышестоящий узел. После этого пальцевой метод поиска работает следующим образом: от пальцевого листа нужно подняться на несколько уровней вверх до некоторого уровня i, затем пройти по списку узлов этого уровня в нужном направлении, пока не найдется поддерево с искомым элементом, а потом спуститься по этому поддереву к искомому элементу. Достоинство поуровневых списков состоит в том, что чем выше уровень списка, тем шире охват соседних элементов дерева. По­ уровневые списки позволяют рассматривать множество элементов дерева в разных масштабах и, когда найден нужный список, быстро переместиться по нему всего за несколько шагов. Эта идея, конечно же, не распространяется на любые двоичные деревья поиска, так как в них пути от корня до листьев могут иметь разную длину. Но нам не нужно привязывать каждый узел к какому-то уровню – между ними может быть много других узлов. Достаточно только придерживаться двух следующих условий: 1) на каждом уровне узлы должны разбивать бесконечный интервал ]– ∞, +∞[ на более мелкие части; 2) на любом пути от корня к листу количество узлов между двумя узлами соседних уровней должно быть ограничено константой С. Очевидно, что эти условия выполняются для (a, b)-деревьев, для которых условие (2) излишне. Они также выполняются для rb-деревьев, так как черные узлы расположены на уровнях так, что между ними может быть не более одного красного узла. Поскольку в предыдущем разделе был сделан вывод, что выровненные по высоте деревья допускают красно-черную раскраску, то поуровневое связывание применимо и к выровненным по высоте деревьям [537]. Многие из рассмотренных ранее выровненных деревьев поиска допускают поуровневое связывание. Структура узла дерева с по­ уровневым связыванием выглядит следующим образом: typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; struct tr_n_t *up; struct tr_n_t *level_left; struct tr_n_t *level_right; /* некая информация о выравнивании */ /* возможна другая информация */ } tree_node_t; Здесь к обычным для двоичного дерева поиска ссылок left (левое поддерево) и right (правое поддерево) добавляются ссылки вверх up (для корня дерева up = NULL), влево level_left и вправо level_right, связывающие узлы одного уровня в двусвязный список. Условимся, что level_left = NULL brass3 cuus247-brass 120 978 0 521 88037 4 August 4, 2008 11:40 Глава 3. Выровненные деревья поиска и level_right = NULL для узлов между уровнями и один из них равен NULL для начального и конечного узлов поуровнего списка (рис. 3.14). P1: KAE brass3 978 0 521 8803710 4 cuus247-brass August 4, 2008 11:40 5 14 3 8 2 4 6 1 0 17 9 12 7 1 2 3 4 5 6 16 11 7 8 9 10 13 11 12 19 15 13 14 18 15 16 17 20 18 19 20 Рис. 3.14. Дерево поиска с поуровневыми двунаправленными связями Таким образом, стратегия пальцевого поиска заключается в том, что мы идем от пальца вверх по уровням, пока на некотором уровне не найдем узел, разделяющий ключи пальца и искомого элемента (рис. 3.15). Тогда под этим разделяющим их узлом находится поддерево, все листья которого лежат между пальцем и искомым элементом, и в этом поддереве находится экспоненциальной высоты список листьев, длина которого пропорциональна длине пути поиска. n i+1 ni n i+1->right n i->right finger finger key пальцевой ключ query key искомый ключ Рис. 3.15. Пальцевой поиск в дереве с поуровневыми связями узлов Согласно правилу (1) любой путь от листа к корню пересекает каждый уровень. Пусть ni – узел уровня i на пути от пальцевого листа к корню. Тогда для каждого уровня справедливо следующее: ni->level_left->key < finger->key < ni->level_right->key • Если finger->key < query_key, пусть i – последний уровень, для которого ni–>level_right->key ≤ query_key, тогда все листья поддерева ниже ni–>level_right->left имеют ключи между finger->key и query_key. Таким образом, между пальцем и искомым объектом как минимум 2i – 1 листьев. Теперь уровнем выше query_key < ni+1->level_right->key, так что искомый ключ находится или в поддереве ниже ni + 1, или в поддереве ниже ni + 1–>level_right–>left. Каждое из этих деревьев согласно правилу (2) имеет высоту не более C(i + 1). Вместе с путем от пальца до ni + 1 и всеми сравнениями сосе- 3.7. Пальцевые деревья и поуровневое связывание 121 дей по уровням для поиска ключа потребовалось время O(i), расстояние до пальца которого по крайней мере 2i - 1, давая заявленное нами ограничение O(log(distance(finger, query))). • Аналогично, если finger->key > query_key, пусть i – последний уровень, для которого ni->level_left->key > query_key, то все листья поддерева ниже ni->level_left->right имеют ключи между finger->key и query_key. Таким образом, между пальцем и искомым объектом как минимум 2i - 1 листьев. Теперь уровнем выше ni + 1->level_left-> key ≤ query_key, поэтому искомый ключ находится либо в поддереве ниже ni + 1->level_left->right, либо в поддереве ниже ni +1. Каждое из этих деревьев согласно правилу (2) имеет высоту не более C(i + 1). Вместе с путем от пальца до ni +1 и всеми сравнениями соседей по уровням для поиска ключа снова потребовалось O(i), расстояние до пальца которого по крайней мере 2i – 1, давая заявленное нами ограничение O(log (distance(finger, query))). Теорема. Поиск в пальцевом дереве с поуровневыми связями требует времени O(log (distance(finger, query))). Ниже приводится код для поиска в пальцевом дереве. Кроме этого, дерево должно, конечно же, поддерживать обычные операции поиска, добавления и удаления, однако при их реализации необходимо следить за по­уровневыми связями. В нашей реализации пальцевого поиска используется обычная функция поиска, которую в этом приложении нужно изменить так, чтобы она возвращала не указатель объекта, а указатель на лист, иначе у нас не будет возможности получить указатель пальца. tree_node_t *finger_search(tree_node_t *finger, key_t query_key) { tree_node_t *current_node, *tmp_result; current_node = finger; if( finger->key == query_key ) return(finger); else if( finger->key < query_key ) { while( current_node->up != NULL && ((current_node->level_right == NULL && current_node->level_left == NULL) || (current_node->level_right != NULL && current_node->level_right->key < query_key)) ) current_node = current_node->up; /* конец while */ if( (tmp_result = find(current_node, query_key)) != NULL ) return(tmp_result); else if( current_node->level_right != NULL ) return(find(current_node->level_right, query_key)); else 122 Глава 3. Выровненные деревья поиска return(NULL); } /* конец, если искомый ключ справа от пальца */ else /* query_key < finger->key */ { while( current_node->up! = NULL && ((current_node->level_right == NULL && current_node->level_left == NULL) || (current_node->level_left != NULL && query_key < current_node->level_left->key)) ) current_node = current_node->up; /* конец while */ if( (tmp_result = find(current_node, query_key)) != NULL ) return(tmp_result); else if( current_node->level_left != NULL ) return(find(current_node->level_left, query_key)); else return(NULL); } /* конец, если искомый ключ справа от пальца */ } Пальцевой поиск изучался изучался во многих работах, где исследовалось его применение к различным древовидным структурам и оценивалась стоимость их обновления. Лучше всего этот вопрос был исследован в [90, 92], где все операции обновления деревьев выполнялись за время O(1), не считая времени на поиск листа. На самом деле от пальцевого поиска немного пользы, если он не является сугубо локальным, так как обычный спуск по дереву от корня к листу просто заменяется подъемом от пальца до некоторой точки поворота с последующим спуском от нее к листу (даже в наилучшем случае такая работа примерно в четыре раза превышает расстоя­ние до искомого элемента). Поэтому такой метод эффективнее обычного спуска от корня к листу только тогда, когда путь вверх меньше половины высоты дерева. Так что если дерево имеет в общей сложности ch = п листьев и мы поднимаемся вверх не больше чем на половину высоты h дерева, то расстояние между пальцем и искомым элементом должно быть меньше п1/2 (обычно оно гораздо меньше). В противном случае обычный поиск гораздо эффективнее пальцевого. Еще одна проблема пальцевых деревьев состоит в том, что палец – это указатель, который не меняется до тех пор, пока неизменна сама структура, по крайней мере на уровне листьев. Таким образом, для сохранения этих указателей после каждого добавления элемента нужно соблюдать особую осторожность, чтобы лист оставался именно листом. Это отличается от того, что мы делали раньше, когда при добавлении нового элемента просто разделяли старый лист. Чтобы лист дерева всегда оставался листом, нужно изменить указатель верхнего соседа старого листа. Операция удаления может вызвать еще одну проблему – при удалении пальцевого элемента. 3.8. Деревья с частичной перестройкой: средняя оценка сложности 123 Предложенный в [73] метод заменяет палец укрупненной структурой, которая не требует добавления всех указателей в само дерево и тем самым экономит память. В деревьях с поуровневыми связями для оценки времени пальцевого поиска учитывается только путь вверх к корню и горизонтальные переходы к соседним узлам уровня на этом пути, используя при этом обычные ссылки на лежащие ниже поддеревья. Таким образом, основная проблема заключается в эффективном обновлении такой структуры после нахождения пальца. 3.8. Деревья с частичной перестройкой: средняя оценка сложности Совершенно иной метод выравнивания дерева поиска – это его перестройка. Сложность перестройки всего дерева можно оценить как Ω(n), и если перестраивать его при каждом обновлении, то добиться сложности O(log n) практически невозможно. Но добиться сопоставимой с ней средней сложнос­ти возможно, если дерево перестраивать только изредка, причем не полнос­ тью, а частично. Впервые это заметил Овермарс [450], который исследовал частичную перестройку как достаточно общий метод преобразования не допускающих обновлений статических структур данных в динамические, допускающие обновления. При этом худшую оценку сложности обновления получить уже невозможно, но зато можно оценить среднюю сложность ряда обновлений, каждое из которых может иметь сложность Θ(n). Частичная перестройка выровненных деревьев поиска была заново исследована в ином контексте Андерссоном [23, 24, 26], а также Гальпериным и Ривестом [242]. Они задались вопросом: каков необходимый минимум информации для выравнивания дерева? Для rb-деревьев по-прежнему требовался только один бит на узел, но оказалось, что для выравнивания дерева никакой дополнительной информации в его узлах вообще не требуется. Выровнять дерево можно, зная лишь общее количество листьев, и этого вполне достаточно для определения его высоты. Зная общее количество n элементов дерева, можно задать порог его высоты C log n с некоторым достаточно большим коэффициентом C. После этого всякий раз, спускаясь по дереву к листу, можно определить, превышена ли его пороговая высота. В этом случае содержащее такой лист поддерево должно быть выровнено, но его вершина еще неизвестна. Не исключено, что находящиеся выше этого листа log n узлов образуют вполне выровненное двоичное дерево вплоть до корня, но путь до него довольно велик. Поэтому, чтобы выравнивание дало существенное улучшение, нужно подниматься от листа к корню и по пути проверять в каждом узле, насколько выровнено его поддерево. На первый взгляд это выглядит нелепо, но поскольку проверяемые поддеревья растут экспоненциально, то на самом деле все сводится к выравниванию не всего дерева в целом, а лишь одного из его поддеревьев. 124 Глава 3. Выровненные деревья поиска Мерой выравнивания мы будем считать α-равновесие. Поскольку здесь используется иная стратегия выравнивания, то ограничения на величину α из раздела 3.2 здесь уже неприменимы, и применяется ограниче1 4 ние α < —. Но при этом предельной глубиной все-таки должна оставаться 1 1–α (log ———)–1 log n: если вдоль пути все узлы α-уравновешены, то это ограничение так и останется верхним пределом длины пути. При этом нарушение α-равновесия не может быть поводом для перестройки дерева, так как она может не уменьшить его высоты. При подъеме к корню вверх по дереву с 2k + 1 листьями дерево может быть крайне невыровненным, но все-таки оптимальным по высоте. Поэтому критерий его перестройки должен быть таким: высота дерева должна превысить максимальную высоту α-уравновешенного дерева с тем же количеством листьев, или, что равносильно, количество листьев дерева должно быть меньше минималь1 1–α ного количества листьев α-уравновешенного дерева той же высоты (———)h. Такое условие уже точно обеспечит уменьшение высоты дерева при его перестройке. Таким образом, операция добавления будет следующей. Сначала выполняется обычное добавление листа с определением его глубины по пути вверх к корню. Если его глубина не превысила пороговое значение, выравнивание не выполняется. В противном случае мы поднимаемся вверх по дереву и преобразуем поддерево текущего узла в связный список. Переходя к следующему вышестоящему узлу и используя метод из раздела 2.8, поддерево этого узла также преобразуется в связный список и объединяется с соседним связным списком. Если узел является i-м узлом на пути от 1 1–α листа к корню и количество листьев в его списке больше (———)i, выполняется переход к следующему вышестоящему узлу. В противном случае, используя нисходящий метод из раздела 2.7, список преобразуется в оптимальное дерево, и на этом выравнивание завершается. Поскольку длина 1 1–α пути превышает порог (log ———)–1 log n, то на этом пути должен быть узел, где количество листьев довольно мало при его высоте (в крайнем случае им может оказаться корень дерева). 1 1–α Отметим, что предельная высота (log ———)–1 log n сохраняется для любой последовательности добавлений. Если же этот предел достигнут еще до добавления, то после добавления он может увеличиться не более чем на 1. Но когда он увеличился, можно найти невыровненное поддерево и выровнять его, чтобы уменьшить высоту дерева как минимум на 1. Теперь докажем, что средняя сложность добавления равна O(log n). Для этого определим для дерева поиска функцию потенциала. Потенциал дерева – это сумма модулей разности весов левых и правых поддеревьев всех его внутренних узлов. Потенциал любого дерева неотрицателен, поэтому 3.8. Деревья с частичной перестройкой: средняя оценка сложности 125 одно добавление может изменить потенциалы его узлов на пути поиска не более чем на единицу, так что потенциал всего дерева может увеличиться 1 1–α не более чем на (log ———)–1 log n. Но при этом выравниваемое на этом пути поддерево будет первым, чья высота превышает заданный порог и не является α-уравновешенным деревом, так как оно не α-уравновешено в корне. Таким образом, потенциал этого поддерева с количеством листьев w не меньше (1 – 2α)w, и для его выравнивания потребуется время O(w). Теорема. При нисходящем спуске потенциал оптимального дерева 1 2 с — w листьями не превышает w. Доказательство. При нисходящем спуске в оптимальном дереве любой внутренний узел имеет потенциал 0 или 1 в зависимости от того, является ли количество листьев в поддереве четным или нечетным. Но один из нижних соседей нечетного узла должен быть четным, так что существует по крайней мере столько же четных узлов, сколько нечетных. Таким образом, при выравнивании потенциал снижается по меньшей 1 2 1 4 мере с (1 – 2α)w до — w, иными словами, при α < — потенциал снижается на Ω(w) при общей сложности вычислений O(w). Однако среднее уменьшение для ряда добавлений не может стать больше среднего увеличения, поэтому средняя сложность выравнивания после добавления оценивается как O(log n). Для операции удаления ситуация еще проще. Удаления не увеличивают высоту дерева, но слабо уменьшают предельно допустимую высоту. По­ этому, чтобы сохранить ее после многократных удалений, иногда придется полностью перестраивать дерево, чтобы уменьшить требуемую высоту на 1. Для этого вводится еще один счетчик, который после полной перестройки дерева с n листьями устанавливается в α·n. Всякий раз при простом удалении этот счетчик уменьшается, а по достижении им значения 0 дерево снова полностью перестраивается нисходящим спуском в оптимальное. По достижении счетчиком значения 0 все равно остается не менее (1 – α)n листьев, а то и больше, если были добавления. Таким образом, во время последней перестройки дерева его высота может уменьшиться не более чем на 1, и тем самым граница высоты сохраняется. Однако ее средняя сложность довольно мала и составляет всего O(1) на одну операцию удаления из-за выполнения полной перестройки дерева с с затратами времени O(n) на каждую Ω(n) операцию. Конечно, средняя стоимость удаления O(1) не сулит каких-то преимуществ по сравнению с O(log n), так как средняя стои­ мость добавления составляет O(log n), а количество добавлений по меньшей мере совпадает с количеством удалений. Но эта средняя сложность 126 Глава 3. Выровненные деревья поиска обновлений O(log n) достигается всего лишь за счет довольно простых инструментов – выполнения полной оптимальной нисходящей перестройки дерева и подсчета листьев поддеревьев, а также двух глобальных счетчиков количества листьев и количества последних удалений. Теорема. За счет частичной перестройки дерева поиска высотой не 1 1–α 1 4 сложность операций insert и delete на уровне O(log n) без какой бы более (log ———)–1 log n при α ∈ ]0, — [ можно поддерживать среднюю то ни было информации о выравнивании в узлах дерева. Таким образом, деревья, в узлах которых хранится информация о выравнивании, не следует считать альтернативой обычным выровненным по высоте деревьям. И хотя последние демонстрируют мощь их перестройки со всего лишь средней сложностью вычислений, она применима и для гораздо более сложных статических структур данных, и во многих случаях это наилучший инструмент преобразования статических структур в динамические. 3.9. Дерево с всплывающими узлами: изменяемая структура данных Изменяемые структуры данных – это такие структуры, которые изменяются для ускорения поиска часто используемых данных. Они изменяются не только при обновлении дерева, но и при поиске в нем. Изменяемое дерево поиска впервые было описано в работе Аллена и Манро [11], которые показали, что в дереве поиска согласно модели 2 подтягивание искомых объектов ближе к корню при многократном обращении к ним, и при неизменности их ключей сложность алгоритма в сравнении с оптимальным возрастает всего лишь за счет небольшого увеличения постоянного множителя. В 1979 году Битнер [71] и Мелхорн [409] рассмотрели похожие структуры D-деревьев, где изменение деревьев вполне сочеталось с их обновлениями. D-деревья вместе со скошенными (biased) [61] и взвешенными (weighted) АВЛ-деревьями [543] дают одну и ту же скорость, но только для некоторых операций с явно определяемыми и постоянно обновляемыми их вероятностями. Наиболее известные изменяемые структуры деревьев поиска – это деревья с всплывающими (splay) узлами8, описанные в 1985 году Слитором и Тарьяном [502]. В них искомый элемент тоже подтягивается к корню дерева, но чуть более сложным способом, и эти деревья обладают некоторыми дополнительными свойствами. Несколько других структур с аналогичными свойствами были описаны в [390, 286, 487, 289], а некоторые общие пра8 Такие деревья поиска называют по-разному: косые или скошенные, вытянутые или растянутые, раскидистые или расширяющиеся, что не совсем точно передает их смысл. Мы назвали их по аналогии с пузырьковым методом сортировки списков деревьями с вплывающими узлами. – Прим. перев. 3.9. Дерево с всплывающими узлами: изменяемая структура данных 127 вила их преобразования с сохранением таких же свойств были описаны в [515, 248]. Кроме того, рассматривались варианты деревьев с узлами-блоками, подобные B-деревьям [400, 496]. Деревья с всплывающими узлами обладают рядом адаптивных свойств, главное из которых – способность перестраиваться так, что при неизменности ключей ожидаемое время поиска элемента в таком дереве в сравнении с оптимальным лишь немного увеличивает постоянный множитель в оценке сложности алгоритма. Конечно, как и в случае пальцевых деревьев, для уменьшения постоянного множителя распределение ключей должно быть довольно равномерным, иначе любое выровненное дерево поиска тоже будет обладать теми же свойствами. Еще одно замечательное свойство деревьев с всплывающими узлами: они просты, и им не требуется никакой дополнительной информации о выравнивании ни в узлах, ни в каких-то глобальных переменных. Они подчиняются только ряду простых правил преобразования, которые чудесно выравнивают дерево, по крайней мере в среднем смысле. В отличие от всех прочих описанных в этой книге деревьев поиска, деревья с всплывающими узлами относятся только к модели 2, где в каждом узле есть ключ объекта. В любой другой модели можно было бы применить другие способы выравнивания, но для стандартной модели деревьев это невозможно. Изменяемость таких деревьев предполагает, что в дереве модели 2 некоторые объекты встречаются гораздо раньше, чем это определяется их средней глубиной. В корне дерева находится объект, который можно найти всего за два сравнения. А при последующих поисках по этому дереву искомый объект просто подтягивается к корню за счет нескольких перестановок на этом пути так, чтобы при повторном его поиске он был уже достаточно близок к корню. Узел этого дерева содержит только ключ, указатель на объект, а также обычные левую и правую ссылки на поддеревья и никакой иной информации о выравнивании. Таким образом, его структура выглядит следующим образом: typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; object_t *object; /* возможна другая информация */ } tree_node_t; Левые и правые вращения должны быть реализованы так, чтобы перемещались не только ключи, но и указатели объектов. Для листьев в качестве правого указателя используется пустое значение NULL. Связанные с узлами интервалы теперь являются открытыми. Операции добавления и удаления – это обычные операции с соответствующими изменениями для такой модели дерева. После добавления или удаления выравнивание не выполняется; изменение древовидной структуры происходит только при поиске. Глава 3. Выровненные деревья поиска 128 Правила поиска таковы. Сначала выполняется спуск по дереву к содержащему объект узлу с сохранением обратного пути к корню; назовем этот узел как current. Последующие шаги выполняются с учетом того, что current – это узел, где находится искомый объект, причем эти шаги выполняются до тех пор, пока current не станет корнем дерева и искомый объект не будет найден (рис. 3.16). 1. Если current – корень дерева, возвращается current->object. 2. В противном случае у current есть верхний родитель upper. Если upper – корень и 2.1. если current = upper->left, выполнить правое вращение в upper, установить current в upper и вернуть current->object, P1: KAE brass3 2.2. иначе current = upper->right, выполнить левое вращение в upper, установить current в upper и вернуть current->object. 3.cuus247-brass В противном случае 978 upper родителя upper2 0 521тоже 88037 имеет 4 August 4, 2008 11:40. 3.1. Если current = upper->left и upper = upper2->left, выполнить два последовательных правых вращения в upper2 и установить current в upper2. 3.2. Если current = upper->left и upper = upper2->right, выполнить правое вращение в upper, а затем левое вращение в upper2 и установить current в upper2. 3.3. Если current = upper->right и upper = upper2->left, выполнить левое вращение в upper, а затем правое вращение в upper2 и установить current в upper2. 3.4. Если current = upper->right и upper = upper2->right, выполнить два левых вращения в upper2 и установить current в upper2. Операции 2.1 и 2.2 называют «zig», 3.1 и 3.4 – «zig-zig», а 3.2 и 3.3 – «zig-zag». t u v v u u v v t v u u t t u v Рис. 3.16. Операции выравнивания 2.1, 3.1 и 3.2 в дереве с всплывающими узлами Теперь нужно показать, что сложность алгоритма перестройки дерева посредством этих операций не превысит средней. Мы получим сразу не- 3.9. Дерево с всплывающими узлами: изменяемая структура данных 129 сколько результатов в одном доказательстве за счет выбора разных весовых функций. Весовая функция w узла определяется как сумма всех положительных весов, лежащих ниже, и нормализованная по n. Для любой весовой функции и дерева поиска определяется несколько производных функций: • суммарный вес s(n) узла n – это сумма всех весов объектов под узлом n; • ранг r(n) узла n – это log(s(n)); • потенциал pot дерева – это сумма рангов всех узлов дерева. Главный инструмент – это следующая лемма, где определяется изменение потенциала при выравнивании дерева после обращения к нему. Ниже используются обозначения potbefore, rbefore, sbefore и potafter, rafter, safter для функций потенциала, ранга и суммарного веса до и после выравнивания соответст­ венно. Лемма 3.1. Если при обращении к узлу v применено k вращений, то справедливо следующее неравенство: k + (potafter − potbefore) ≤ 1 + 3 (rafter(v) − rbefore(v)). Доказательство. Выравнивание состоит из последовательности вращений, и благодаря вложенности этого неравенства можно доказать, что для операций 3.1, 3.2, 3.3 и 3.4, каждая из которых требует двух вращений, будет иметь место неравенство 2 + (potafter − potbefore) ≤ 3 (rafter(v) − rbefore(v)), а для операций 2.1 или 2.2, требующих не более одного вращения, будет иметь место неравенство 1 + (potafter − potbefore) ≤ 1 + 3 (rafter(v) − rbefore(v)). • Пусть для операций 2.1 и 2.2 верхним соседом v будет u. Поскольку rbefore (u) = rafter((v), rafter(v) ≥ rafter(u) и rafter(v) ≥ rbefore(v), получается следующее неравенство: potafter − potbefore = rafter(v) − rbefore(v) + rafter(u) − rbefore(u) = rafter(u) − rbefore(v) ≤ rafter(v) − rbefore(v) ≤ 3 (rafter(v) − rbefore(v)) . • Пусть для операций 3.1 и 3.4 верхним соседом v будет u , а верхним соседом u будет t. Тогда можно заметить, что sbefore(t) = safter(v) ≥ sbefore(v) + safter(t), 130 Глава 3. Выровненные деревья поиска таким образом: (rbefore(v) − rafter(v)) + (rafter(t) − rafter(v)) safter(t) sbefore(v) = log �—————� + log �—————� safter(v) safter(v) ≤ max (log α + log β) ≤ −2. α,β>0 α+β≤1 Используя это отношение, а также rbefore(v) ≤ rbefore(u) и rafter(u) ≤ rafter(v), у нас снова получается то же неравенство: potafter − potbefore = rafter(v) + rafter(u) + rafter(t) − rbefore(v) − rbefore(u) − rbefore(t) = rafter(u) + rafter(t) − rbefore(v) − rbefore(u) = 3 (rafter(v) − rbefore(v)) + (rbefore(v) − rafter(v)) + (rafter(t) − rafter(v)) + (rbefore(v) − rbefore(u)) + (rafter(u) − rafter(v)) ≤ 3 (rafter(v) − rbefore(v)) − 2. • Пусть для операций 3.2 и 3.3 u будет верхним соседом v, а t – верхним соседом u. Тогда sbefore(t) = safter(v) ≥ safter(u) + safter(t), поэтому (rafter(u) − rafter(v)) + (rafter(t) − rafter(v)) safter(t) safter(u) = log �—————� + log �—————� safter(v) safter(v) ≤ max (log α + log β) ≤ −2. α,β>0 α+β≤1 Используя это отношение, а также rbefore(v) ≤ rafter(v) и rbefore(v) ≤ rbefore(u), мы снова получаем то же самое неравенство: potafter − potbefore = rafter(v) + rafter(u) + rafter(t) − rbefore(v) − rbefore(u) − rbefore(t) = rafter(u) + rafter(t) − rbefore(v) − rbefore(u) = 3 (rafter(v) − rbefore(v)) + (rafter(u) − rafter(v)) + (rafter(t) − rafter(v)) + (rbefore(v) − rafter(v)) + (rbefore(v) − rbefore(u)) ≤ 3 (rafter(v) − rbefore(v)) − 2. Лемма доказана. 3.9. Дерево с всплывающими узлами: изменяемая структура данных 131 Теперь эту лемму можно использовать для доказательства средней оцен­ ки сложности любой последовательности операций поиска find. Сложность этих операций пропорциональна числу выполняемых в них вращений. Согласно лемме, число вращений в одной операции поиска ограничено изменением потенциала дерева плюс троекратным увеличением разности рангов корня и искомого узла, пока он не окажется новым корнем, плюс 1. В результате общее количество вращений по всем операциям не превысит величины количество операций ≤ �(potbefore − potafter) операции + �(r(root) − rbefore(искомый узел)) операции + количество операций. Первая сумма – вложенная; это разность начального и конечного потенциалов, которую можно ограничить независимо от последовательности операций разностью между максимальным и минимальным потенциалами дерева с заданными весами. Для оценки средней сложности операции поиска find, то есть для оценки используемого ею количества вращений, нужно ограничить другую сумму. Если каждый из n объектов дерева имеет вес 1, то вес корня равен n, а вес любого другого узла дерева равен по меньшей мере 1. Таким образом, ранги узлов – это числа от 0 до log n, и разность рангов между корнем дерева и искомым узлом не превышает log n. Кроме того, если дерево имеет n узлов, то его потенциал, то есть сумма его рангов, находится между 0 и n log n, а любая разность потенциалов составляет O(n log n). Стало быть, средней оценкой сложности алгоритма будет величина O(log n). Теорема. Любая последовательность из m операций find в дереве с всплывающими узлами из n объектов требует времени O((m +n) log n). Другая модель поиска состоит в том, что поиск объектов выполняется согласно некоторому распределению их вероятности (pi)ni= 1. После чего каж­ дому объекту i присваивается вес pi n. Так что и в этой модели сумма весов равна n, а ранг корня равен log n, и с вероятностью pi искомый объект имеет ранг log(pi n) = log(pi) + log n, поэтому ожидаемая разность рангов равна n n � pi (log n − log(pin)) = −�pi log pi = : H(p1, ..., pn), i=1 i=1 которая является мерой разброса вероятностей. Максимальный и минимальный потенциалы дерева с такими весами зависят от распределения (pi)ni = 1, поэтому его пределы оценить непросто, хотя эта максимальная разность потенциалов равна некоторой величине Δpotmax(p1, …, pn), не зависящей от последовательности операций поиска find. Из этого следует такая оценка: 132 Глава 3. Выровненные деревья поиска Теорема. Если поиск в дереве с всплывающими узлами осуществляется случайным образом согласно распределению вероятностей (pi)ni= 1, то ожидаемая сложность цепочки из m операций поиска составит O(Δpotmax(p1 , … , pn) + m (1 + H(p1 , … , pn))). n Однако энтропия H(p1, …, pn) = –� i = 1 pi log pi – это, по сути дела, ожидаемая глубина оптимального дерева при фиксированном распределении вероятностей. Это самая нижняя граница даже в более слабой модели 1 дерева поиска, где тоже можно изменять порядок следования ключей с сохранением распределения вероятностей. То есть ситуация с кодами переменной длины и нижней границей следует из неравенства Крафта. В такой модели глубина плюс максимум 1 может быть достигнута в деревьях Хаффмана или Шеннона–Фано. При смене модели 1 дерева поиска на модель 2 мы теряем максимум множитель 2, так как каждое дерево модели 2 можно преобразовать в дерево модели 1, заменив каждый узел модели 2 двумя узлами модели 1. Построение оптимальных или почти оптимальных деревьев поиска, особенно для модели 2, было довольно подробно изучено в [327, 407]. Таким образом, средняя оценка времени поиска в дереве с всплывающими узлами при постоянном оптимальном ожидаемом множителе и при распределении H(p1, …, pn) будет нижней его оценкой. В дереве с всплывающими узлами это достигается за счет его изменяемости во время выполнения поисков с учетом распределения ключей, которое используется лишь для анализа алгоритма с определением весовой функции, но не в самом алгоритме. Пальцевой поиск – тоже адаптивный, но деревья с всплывающими узлами допускают пальцевой поиск без использования пальцев. Пусть finger – некоторый фиксированный элемент (палец), и каждому элементу x дерева n ––––––––––––––––––––2––––––� , где distance(finger, x) – это коли­чество присвоен вес �–(distance( finger, x) + 1) элементов между finger и x. Тогда суммарный вес можно оценить как Θ(n), ∞ 1 π2 , так что ранг корня будет равен log n – O(1), а ранг поскольку �v = 1 —2 = — 6 v искомого элемента q – n log�––––––––––––––––––––– ––––––� = log n – O(log (distance(finger, q))). (distance(finger, q)2 + 1) Таким образом, разность рангов – это O(log (distance(finger, q))). Поскольку каждый узел имеет ранг от log n до log (n/((n – 1)2 + 1) > –log n, то потенциал всего дерева – от n log n до –n log n, поэтому любая разность потенциалов равна O(n log n). Отсюда следует: Теорема. Любая последовательность из m операций поиска элементов q1, ..., qm в дереве из n элементов с всплывающими узлами требует времени O(n log n + �mi = 1log (distance(finger, qi))). 3.9. Дерево с всплывающими узлами: изменяемая структура данных 133 Таким образом, дерево с всплывающими узлами приспосабливается к неоднородности или локальности поисков несколькими способами по крайней мере в среднем смысле. До настоящего времени мы уделяли внимание только поиску элементов в фиксированном множестве, исключая операции обновления. Мы можем выполнять обновления с помощью базовой вставки и удаления, возможно, после того же перемещения к вершине, выполненной для запросов. И если мы используем постоянный вес, применяется тот же средний анализ, потому что на самом деле нет никакой разницы между запросом и вставкой или удалением. Однако для адаптивного анализа даже модель становится менее понятной, потому что мы не можем изменить весовую функцию всякий раз, когда изменяется текущий набор. В заключение приводим код функции find поиска, а также функций добавления insert и удаления delete в деревьях с всплывающими узлами модели 2, для чего необходимо изменить условия, так как каждый узел содержит объект вместе с ключом. Поскольку вращения должны перемещать объект вместе с ключом, для пустого дерева в поле объекта используется указатель NULL. Операция удаления сложнее, чем в модели 1, поскольку ключи внут­ ренних узлов могут удаляться, и в этом случае требуется поднять нижний ключ вверх, чтобы заменить им удаленный. object_t *find (tree_node_t *tree, key_t query_key) { int finished = 0; if( tree->object == NULL ) return(NULL); /* дерево пусто */ else { tree_node_t *current_node; create_stack(); current_node = tree; while( !finished ) { push(current_node); if( query_key < current_node->key && current_node->left != NULL ) current_node = current_node->left; else if( query_key > current_node->key && current_node→right != NULL ) current_node = current_node->right; else finished = 1; } if( current_node->key != query_key ) return (NULL); else { tree_node_t *upper, *upper2; pop(); /* вытолкнуть узел с искомым ключом query_key */ 134 Глава 3. Выровненные деревья поиска while( current_node != tree ) { upper = pop(); /* узел над current_node */ if( upper == tree) { if( upper->left == current_node ) right_rotation (upper); else left_rotation (upper); current_node = upper; } else { upper2 = pop (); /* узел над upper */ if( upper == upper2->left ) { if( current_node == upper->left ) right_rotation(upper2); else left_rotation (upper); right_rotation (upper2); } else { if( current_node == upper->right ) left_rotation (upper2); else right_rotation (upper); left_rotation (upper2); } current_node = upper2; } } return (current_node->object); } } } int insert (tree_node_t *tree, key_t new_key, object_t *new_object) { tree_node_t *tmp_node, *next_node; if( tree->object == NULL ) { tree->object = new_object; tree->key = new_key; tree->left = NULL; tree->right = NULL; } else /* дерево не пустое: корень содержит ключ */ { next_node = tree; while( next_node != NULL ) { tmp_node = next_node; 3.9. Дерево с всплывающими узлами: изменяемая структура данных 135 if( new_key < tmp_node->key ) next_node = tmp_node->left; else if( new_key > tmp_node->key ) next_node = tmp_node->right; else /* new_key == tmp_node->key: ключ уже существует */ return (-1); } /* next_node == NULL: это новый лист */ { tree_node_t *new_leaf; new_leaf = get_node(); new_leaf->object = new_object; new_leaf->key = new_key; new_leaf->left = NULL; new_leaf->right = NULL; if( new_key < tmp_node->key ) tmp_node->left = new_leaf; else tmp_node->right = new_leaf; } } return(0); } object_t *delete(tree_node_t *tree, key_t delete_key) { tree_node_t *tmp_node, *upper_node, *next_node, *del_node; object_t *deleted_object; if( tree->object == NULL ) return(NULL); /* удалить из пустого дерева */ else { next_node = tree; tmp_node = NULL; while( next_node != NULL ) { upper_node = tmp_node; tmp_node = next_node; if( delete_key < tmp_node->key ) next_node = tmp_node->left; else if( delete_key > tmp_node->key ) next_node = tmp_node->right; else /* delete_key == tmp_node->key */ break; /* найден delete_key */ } if( next_node == NULL ) return(NULL); /* удаляемый ключ не найден */ else /* удалить tmp_node */ { deleted_object = tmp_node->object; 136 Глава 3. Выровненные деревья поиска if( tmp_node->left == NULL && tmp_node->right == NULL ) { /* удалить узел с нулевой кратностью */ if( upper_node! = NULL ) { if( tmp_node == upper_node->left ) upper_node->left = NULL; else upper_node->right = NULL; return_node(tmp_node); } else /* удалить последний объект */ tmp_node->object = NULL; } else if( tmp_node->left == NULL ) { tmp_node->left = tmp_node->right->left; tmp_node->key = tmp_node->right->key; tmp_node->object = tmp_node->right->object; del_node = tmp_node->right; tmp_node->right = tmp_node->right->right; return_node (del_node); } else if( tmp_node->right == NULL ) { tmp_node->right = tmp_node->left->right; tmp_node->key = tmp_node->left->key; tmp_node->object = tmp_node->left->object; del_node = tmp_node->left; tmp_node->left = tmp_node->left->left; return_node(del_node); } else /* удалить внутренний узел */ { upper_node = tmp_node; del_node = tmp_node->right; while( del_node->left != NULL ) { upper_node = del_node; del_node = del_node->left; } tmp_node->key = del_node->key; tmp_node->object = del_node->object; if( del_node = tmp_node->right ) tmp_node->right = del_node->right; else upper_node->left = del_node->right; return_node(del_node); } return(deleted_object); } 3.10. Списки с пропуском элементов: вероятностные структуры данных 137 } } Здесь нельзя использовать стек в массиве, так как глубина элемента в худшем случае может достигать n – 1, и придется использовать одну из реализаций стека в виде связного списка. На самом деле для отслеживания пути по дереву вместо стека лучше использовать обратные ссылки в каждом узле дерева, но в таком случае уже нельзя считать, что для выравнивания дерева в узлах не используются дополнительные поля. 3.10. Списки с пропуском элементов: вероятностные структуры данных Идея списка с пропуском элементов (skip lists) – добавить в сортированный связный список дополнительную ссылку, позволяющую пропускать ненужные элементы, чтобы быстрее найти нужный элемент списка (рис. 3.17). В обычном сортированном связном списке длины n для поиска элемента необходимо до n сравнений. Но если над этим списком надстроить второй список, содержащий только каждый второй элемент первого списка, 1 1 2 сравнений и по одному дополнинем уровне k потребуется не более �—k n�August 978 0 521 88037 4 4, 2008 11:40 cuus247-brass тельному сравнению в каждом лежащем ниже списке. Для k = log n время поиска можно оценить как log n. В сущности, такая списочная структура очень похожа на опрокинутое на бок оптимальное дерево с восходящим поиском, где левая ссылка узла – это переход к нижнему списку, а правая – к следующему элементу текущего списка. Однако при добавлении/удалении (обновлении) такую структуру невозможно поддерживать со временем O(log n), так как в этом случае расстояние между элементами может меняться, что потребует перестройки всех списков, начиная с обновленного элемента. NULL заголовок placeholder NULL NULL obj NULL obj NULL obj NULL obj NULL obj NULL obj NULL obj NULL obj NULL obj NULL obj NULL obj NULL obj NULL obj NULL NULL NULL NULL brass3 NULL P1: KAE то потребуется не более �—n� сравнений и одно дополнительное сравнение 2 в нижнем списке. Если же повторить этот прием k раз, то на самом верх- obj Рис. 3.17. Список с пропуском элементов В работе [468], где появилась идея такой структуры, было предложено пропускать списки без отслеживания точного расстояния между вышестоящими уровнями (2i на уровне i), а просто отслеживать его среднее значе- 138 Глава 3. Выровненные деревья поиска ние. Под средним значением понимается вероятность ряда случайных поисков в структуре данных такого типа. Таким образом, список с пропуском элементов представляет собой вероятностную структуру данных, обеспечивающую с ожидаемой вероятностью при поиске, добавлении и удалении сложность порядка O(log n). Ожидаемое значение вероятности имеет место только для фиксированной последовательности действий, так что для одной и той же последовательности действий может потребоваться разное время в зависимости от случайности поиска в такой структуре данных. При добавлении элемента ему присваивается уровень i ≥ 1, который не меняется, пока этот уровень существует, а сам элемент входит во все спис­ ки под этим уровнем. Распределение уровней – геометрическое с вероятностью (1 – p)pi – 1 для уровня i. Иными словами, при добавлении элемента он сначала попадает на уровень 1, а затем копируется с вероятностью p в вышестоящий уровень до тех пор, пока вероятность на уровне, большем или равном i, не достигнет значения pi – 1. Поиск элемента с заданным ключом начинается со списка наивысшего уровня. Мы перемещаемся по этому списку до тех пор, пока ключ следующего элемента в списке не превысит искомый ключ. После чего мы спускаемся на уровень ниже и повторяем ту же самую процедуру, пока не окажемся на самом нижнем уровне. Там мы либо находим нужный элемент, либо не находим его, если ключ очередного элемента списка превысил искомый ключ. При анализе такой структуры в первую очередь нужно помнить, что в ней при n элементах ожидаемое их количество на уровне i и выше равно pi – 1 n, поэтому максимальный уровень, где все еще можно найти искомый –1 log p элемент, – 1 + log1/p n = 1 + ———log n), в предположении, что ожидаемая высота структуры – O(log n). Чтобы убедиться в этом, нужно рассчитать максимальный уровень списка с пропусками из n элементов, то есть определить максимум из n независимых случайных величин Xj с Prob (Xj ≥ i) = pi. Тогда для x ∈ [0, 1] согласно неравенству Бернулли (1 – x )n ≥ 1 – nx получится следующая оценка: Exp(максимальный уровень пропускаемого списка из n элементов) = ∞ = Exp�max Xj� = � i Prob�max Xj = i� j=1,...,n j=1,...,n i=1 ∞ ∞ = �Prob�max Xj ≥ i� = ��1 − Prob�max Xj < i�� i=1 j=1,...,n log1/pn ∞ = �(1 − (1 − pi)n) < �1 + i=1 ∞ j=1,...,n i=1 i=1 ∞ � (1 − (1 − pi)n) i=log1/p n+1 < log1/pn + � (1 − (1 − npi)) = log1/pn + i=log1/p n+1 ∞ � pin i=log1/p n+1 3.10. Списки с пропуском элементов: вероятностные структуры данных 139 log1/pn = log1/pn + p ∞ p n �pi = log1/pn + ––––––– . 1−p i=1 Внутри каждого из этих log1/р n + O(1) уровней ожидаемое количество действий ограничено сверху расстоянием до следующего элемента более высокого уровня, так как на текущем нижнем уровне мы никогда не пропустим элемент более высокого уровня. Поскольку мы находимся в списке нижнего уровня, все элементы в списках более высокого уровня больше ключа искомого элемента. Но каждый элемент списка может выйти на вышестоящий уровень с вероятностью p, поэтому вероятность перехода на вышестоящие уровни за j действий имеет отрицательное экспоненциальное распределение p(1 – p)j – 1. Кроме того, этот параметр ограничивает длину списка верхнего уровня, так как выше него такого элемента уже быть не может. Таким образом, на каждом уровне ожидаемое количество действий оценивается как 1/p = O(1), а ожидаемое коли–1 чество уровней ———log n = O(log n), поэтому ожидаемое общее количест­ log p во действий – O(log n) при любой вероятности p ∈ ]0, 1[. Коэффициент –1 ———— минимален при p = 1/е ≈ 0,3678, при котором ожидаемое количество p log p сравнений равно 1,88 log n, хотя выбор величины p не имеет большого 1 1 2 3 1 4 значения: —, — или — – вполне приемлемые для нее значения. Таким образом выполняется поиск элемента по заданному ключу или определяется его положение в списке. При добавлении элемента нужно просто выполнить случайный поиск его уровня и затем добавить его во все вышестоящие списки, а при удалении – просто исключить его из всех вышестоящих списков. Обе операции требуют затрат времени O(1) на каж­ дом уровне, а в целом – O(log n). Теорема. Ожидаемое время выполнения операций find, insert и delete для списка с пропуском из n элементов оценивается как O(log n). Однако нам нужно описать представление элемента в различных спис­ ках. В статье [468] были предложены так называемые толстые узлы (fat nodes) со ссылками на все списки, которые могут потребоваться, вплоть до некоторого предопределенного максимального уровня. Такой подход, конечно, избавляет от всех недостатков структур в массивах фиксированного размера: если количество уровней в списке с пропусками элементов ограничено, то при достаточно большом n он, в сущности, вырождается в связный список с несколькими указателями, которые позволяют ускорить время поиска в сортированном списке до величины, ограниченной Θ(n). Вместо этого сам элемент представляется в виде связного списка, который формируется на самом верхнем уровне этого элемента и затем соединяется ссылкой down со списками нижних уровней вплоть до достижения самогó 140 Глава 3. Выровненные деревья поиска элемента. Это несколько увеличивает память структуры, так как ожидаемая 1 длина списка – это ожидаемый уровень этого элемента, то есть ———. Ключ 1–p этого элемента дублируется в каждом узле списка нижнего уровня, а узлы-листья просто привязываются к нему. Каждый из списков, находящихся ниже и относящихся к одному и тому же объекту, заканчивается узломлис­том, указатель down которого имеет значение NULL, а поле next ссылается на сам объект. Каждый из списков уровней заканчивается узлом, поле next которого – NULL. В начале каждого списка уровней есть узел-заголовок, который служит просто точкой входа в список с возможностью перехода к спискам более низкого уровня. Структура узла выглядит следующим образом: typedef struct tr_n_t { key_t key; struct tr_n_t * next; struct tr_n_t * down; /* возможна другая информация */ } tree_node_t; Теперь код для операций find, insert и delete в списках с пропуском элементов будет следующим. object_t *find(tree_node_t *tree, key_t query_key) { tree_node_t *current_node; int beyond_placeholder = 0; if( tree->next == NULL ) /* пустой список с пропусками */ return(NULL); else { current_node = tree; while( current_node->down != NULL ) { while( current_node->next != NULL && current_node->next->key <= query_key ) { current_node = current_node->next; byond_placeholder = 1; } current_node = current_node->down; } if( beyond_placeholder && current_node->key == query_key) return((object_t *)current_node->next); else return(NULL); } } tree_node_t *create_tree(void) { tree_node_t *tree; 3.10. Списки с пропуском элементов: вероятностные структуры данных 141 tree = get_node(); tree->next = NULL; tree->down = NULL; return(tree); } int insert(tree_node_t *tree, key_t new_key, object_t *new_object) { tree_node_t *current_node, *new_node, *tmp_node; int max_level, current_level, new_node_level; /* создать список нижнего уровня для нового узла */ { new_node = get_node(); new_node->key = new_key; new_node->down = NULL; new_node->next = (tree_node_t *)new_object; new_node_level = 0; do { tmp_node = get_node(); tmp_node->down = new_node; tmp_node->key = new_key; new_node = tmp_node; new_node_level += 1; } while( random(P) ); /* случайный выбор, вероятность P */ } tmp_node = tree; /* найти текущий максимальный уровень */ max_level = 0; while( tmp_node->down != NULL ) { tmp_node = tmp_node->down; max_level += 1; } while( max_level < new_node_level ) /* нет точки входа */ { tmp_node = get_node(); tmp_node->down = tree->down; tmp_node->next = tree->next; tree->down = tmp_node; tree->next = NULL; max_level += 1; } { /* найти элемент и добавить его на все уровни */ current_node = tree; current_level = max_level; while( current_level >= 1 ) { while( current_node->next != NULL 142 Глава 3. Выровненные деревья поиска && current_node->next->key < new_key ) current_node = current_node->next; if( current_level <= new_node_level ) { new_node->next = current_node->next; current_node->next = new_node; new_node = new_node->down; } if( current_level >= 2 ) current_node = current_node->down; current_level - = 1; } } return(0); } object_t *delete(tree_node_t *tree, key_t delete_key) { tree_node_t *current_node, *tmp_node; object_t *deleted_object = NULL; current_node = tree; while( current_node->down != NULL ) { while( current_node->next != NULL && current_node->next->key < delete_key ) current_node = current_node->next; if( current_node->next! = NULL && current_node->next->key == delete_key ) { tmp_node = current_node->next; /* отвязать узел */ current_node->next = tmp_node->next; if( tmp_node->down->down == NULL ) /* удалить лист */ { deleted_object = (object_t *)tmp_node->down->next; return_node(tmp_node->down); } return_node(tmp_node); } current_node = current_node->down; } /* удалить пустые уровни в заголовке */ while( tree->down! = NULL && tree->next == NULL ) { tmp_node = tree->down; tree->down = tmp_node->down; tree->next = tmp_node->next; return_node (tmp_node); } return(deleted_object); } 3.10. Списки с пропуском элементов: вероятностные структуры данных 143 Снабдив каждый узел полем уровня, можно было бы размещать указатели объектов уже в списке 1-го уровня, не создавая новый уровень из лис­ товых узлов, тогда добавление нового объекта стало бы несколько проще. Однако ради большей систематичности мы все-таки решили использовать в качестве начальных и концевых указателей списков элементы со значением NULL. Обратите внимание, что операция insert не проверяет, существует ли добавляемый ключ, и она всегда успешна. Работать с несколькими одинаковыми ключами в списке с пропусками элементов неудобно по нескольким причинам: если добавление нисходящее, как в нашем случае, то об этом станет известно только на самом нижнем уровне. Конечно, можно помещать узлы в стек, а затем удалять их, если такой ключ уже существует. Или выполнять восходящее добавление с сохранением в стеке узлов каждого уровня, добавляя их в список с возможностью случайного выбора только по пути вверх. В отличие от других деревьев поиска, здесь операция delete проще, чем insert. Наша версия списка с пропусками относится к деревьям модели 1. Вариант списков с пропусками для деревьев модели 2 был предложен в работе [138]. Другая похожая структура без многоуровневых списков – списки с переходами (jump lists) – была кратко описана в [97]. Сложность алгоритмов для списков с пропусками элементов изучалась в ряде работ [492, 166, 459, 323], и она оказалась вполне приемлемой. Поиск в списке с пропуском элементов легко сочетается с любыми известными распределениями вероятности: если изменить выбор уровней иным способом, то к нужному элементу с высокой вероятностью можно прийти более коротким путем [196, 45]. А если в список с пропуском элементов добавить обратные указатели в двух направлениях, его можно легко использовать для пальцевого поиска. Таким образом, если список с пропуском элементов показывает вполне хорошее ожидаемое поведение, то он является достаточно удобной структурой. Список с пропусками может работать и без использования вероятности [422]. Правда, для этого на каждом уровне i списка с пропусками нужно следить за тем, чтобы количество узлов на этом уровне между двумя узлами вышестоящего уровня находилось в пределах интервала [a, b]. Добавление нового элемента выполняется снизу вверх, то есть начиная с нижнего уровня. Если количество элементов на этом уровне – хотя бы между двумя элементами вышестоящего уровня – выходит за пределы этого интервала, предпринимается попытка добавить новый элемент на вышестоящий уровень, и так далее. Удаление элемента выполняется так же, но сверху вниз. Это похоже на (a, b)-дерево, но несколько хуже его, так как двоичный поиск в (a, b)-узлах невозможен и должен использоваться линейный поиск. Общим между списком с пропусками и (a, b)-деревьями является то, что при поиске на уровне i списка никогда не выполняются переходы по ссылке на вышестоящий элемент, так что при разрыве этих ссылок действительно получается (a, b)-дерево. Однако эти ссылки необходимы при обновлениях. 144 Глава 3. Выровненные деревья поиска Если исключить случайность в списках с пропусками, то теряется простота случайного выбора, поэтому детерминированный список с пропусками не дает никаких дополнительных преимуществ. Еще одна вероятностная структура дерева поиска предложена [490]. Это дерево поиска, где добавляемые элементы получают случайные приоритеты. Тогда корень каждого поддерева имеет ключ элемента с наивысшим приоритетом в этом поддереве. В сущности, это дает в среднем довольно хорошее случайное равномерное распределение элементов в поддереве с ожидаемой их глубиной порядка O(log n). В оригинальной версии это дерево относится к модели 2, но сама идея может быть применена и к любой другой модели. Эта структура представлена деревом, где каждый узел имеет два значения: ключ и случайно выбранный приоритет. Ключи узлов расположены, как в дереве поиска, а приоритеты – в куче, которая будет описана в разделе 5.3, что и дало имя этой структуре – treap, то есть tree+heap. Поскольку эта структура состоит из пар значений, то при заранее заданных – вместо случайных – приоритетах такой тип дерева был назван в [546] декартовым деревом. Если n таких пар отсортированы по ключу, то сложность создания соответствующей ей структуры оценивается как O(n) [549]. Еще одно вероятностное дерево поиска было предложено в [402]. Вероятностные варианты деревьев с всплывающими узлами и с теми же адаптивными возможностями были исследованы в [234, 10]. 3.11. Соединение и разделение выровненных деревьев поиска До сих пор рассматривались только операции find, insert и delete на множестве объектов, представленных в виде выровненных деревьев поиска. Однако существуют и другие полезные операции над описанными в этой главе деревьями поиска. В разделе 2.7 уже упоминалась операция interval_ find поиска в списке по ключу интервала с последующим поиском внутри него меньшего или большего ключа. Описанные в нем методы работают и для любого другого выровненного дерева поиска. Теорема. Любое выровненное дерево поиска с операциями find, insert и delete может быть расширено с дополнительными затратами времени O(1) так, чтобы поддерживать операции find_next_larger и find_next_smaller со временем O(log n), а также операцию interval_ find с чувствительным к выходу временем O(log(n) + k), когда в искомом интервале k объектов. Для всех операций поуровневые связи узлов дерева – двунаправленные и находятся на самом нижнем уровне. Для списков с пропусками элементов вполне достаточно однонаправленных связей, поэтому эта структура не требует никаких модификаций для поиска интервала. 3.11. Соединение и разделение выровненных деревьев поиска 145 Более сложные операции – разделение множества на два подмножества с меньшими и бóльшими значениями ключей и соединение двух множеств с заданным пороговым значением ключа. Обе операции split и join можно реализовать для большинства описанных в этой главе выровненных деревьев поиска за время O(log n). Проще всего это сделать для списка с пропусками элементов, потому что уровни его элементов практически ни от чего не зависят. Для его разделения нужно всего лишь найти по значению порогового ключа точку разделения, добавить в заголовок список новых ключей, превышающих пороговое значение, и добавить в них пустую концевую ссылку NULL во все вырезанные списки. Соединение двух списков с пропусками элементов так же просто. Здесь все ключи первого списка меньше ключей второго. Стало быть, нужно удалить все заголовки второго списка и привязать все его уровни к первому, возможно с добавлением дополнительных заголовков к первому списку, если его максимальный уровень окажется меньше максимального уровня второго. Сложность этих операций – O(log n), так как сначала нужно найти точку порогового ключа и затем выполнить O(1) операций на каждом уровне списка. Теорема. Для списков с пропусками элементов ожидаемая сложность их разделения и соединения при известном пороге ключа оценивается как O(log n). Оценка сложности разделения и соединения выровненных деревьев в худшем случае требует несколько больших усилий, при этом разделение возможно лишь после соединения. В случае выровненных по высоте деревьев это работает следующим образом. Пусть мы имеем два отдельных выровненных по высоте дерева поиска 𝒯1 и 𝒯2 с высотами h1 и h2, где все ключи 𝒯1 меньше ключей 𝒯2. 1. Если высоты h1 и h2 отличаются не более чем на 1, можно добавить новый общий корень, ключ которого – это ключ самого левого листа в 𝒯2. 2. Если h1 ≤ h2 – 2, то идем по самому левому пути в 𝒯2, отслеживая обратный путь к корню, пока не найдется узел, высота которого не превышает h1. Поскольку любые два последовательных узла на этом пути отличаются по высоте на 1 или 2, возможны следующие варианты (рис. 3.18): 2.1. Узел самого левого пути в 𝒯2 имеет высоту h1, а его родитель – высоту h1 + 2: тогда на этом пути ниже родителя создается новый узел с высотой h1 + 1, который на этом же пути в качестве правого потомка имеет узел высоты h1, в качестве левого потомка – корень дерева 𝒯1, а в качестве ключа – ключ самого левого листа в 𝒯2. Новое дерево тоже получается выровненным по высоте. 146 Глава 3. Выровненные деревья поиска 2. 2.1 s h2 h1 h1+2 h1 T1 s 2 T T1 2.2 2.3 s s h1+1 h1 T1 h1 h1-1 h1+1 T1 Рис. 3.18. Соединение двух выровненных по высоте деревьев с разделяющим ключом s. Случаи 2.1, 2.2, 2.3: добавление дерева при спуске по левому пути 2.2. Узел самого левого пути 𝒯2 имеет высоту h1, а его родитель – высоту h1 + 1: тогда на этом пути ниже родителя создается новый узел с высотой h1 + 1, который на этом же пути в качестве правого потомка имеет узел высоты h1, в качестве левого потомка – корень 𝒯1, а в качестве ключа – ключ самого левого листа в 𝒯2. После этого высота родителя становится равной h1 + 2, и выполняется выравнивание высоты в направлении корня дерева. 2.3. Узел самого левого пути 𝒯2 имеет высоту h1 – 1, а его родитель – высоту h1 + 1: тогда на этом пути ниже родителя просто создается новый узел с высотой h1 + 1, который на этом же пути в качестве правого потомка имеет узел высоты h1 – 1, в качестве левого потомка – корень 𝒯1, а в качестве ключа – ключ левого листа в 𝒯2. После этого высота родителя становится равной h1 + 2, и выполняется выравнивание высоты в направлении корня дерева. 3. Если h2 ≤ h1 – 2, мы следуем по самому правому пути в 𝒯1, отслеживая путь обратно к корню, пока не найдем узел, высота которого не превосходит h2. Поскольку любые два последовательных узла на этом пути отличаются по высоте на 1 или 2, возможны следующие случаи: 3.1. Узел на самом правом пути 𝒯1 имеет высоту h2, а его верхний сосед имеет высоту h2 + 2. Затем мы просто создаем новый узел с высотой h2 + 1 ниже верхнего соседа на пути, который имеет слева нижний сосед – узел высоты h2 на пути, как правый нижний сосед – корень 𝒯2, а в качестве ключа – ключ самого левого листа в 𝒯2. Новое дерево снова сбалансировано по высоте. 3.11. Соединение и разделение выровненных деревьев поиска 147 3.2. Узел на самом правом пути 𝒯1 имеет высоту h2, а его верхний сосед имеет высоту h2 + 1. Затем мы просто создаем новый узел с высотой h2 + 1 ниже верхнего соседа на пути, который имеет слева нижний сосед – узел высоты h2 на пути, как правый нижний сосед – корень 𝒯2, а в качестве ключа – ключ самого левого листа в 𝒯2. Затем мы корректируем высоту верхнего соседа до h2 + 2 и выполняем восходящее выравнивание. 3.3. Узел на крайнем левом пути 𝒯1 имеет высоту h2 – 1, а его верхний сосед имеет высоту h2 + 1. Затем мы просто создаем новый узел с высотой h2 + 1 ниже верхнего соседа на пути, который имеет как левый нижний сосед узел высоты h2 – 1 на пути, как правый нижний сосед – корень 𝒯2 и как ключ самого левого листа в 𝒯2. Потом мы корректируем высоту верхнего соседа до h2 + 2 и выполняем восходящее выравнивание. Таким образом, идея заключается просто в добавлении нового узла, ведущего к меньшему по высоте дереву по правильному внешнему пути более высокого дерева, с последующим применением методов выравнивания для восстановления равновесия. Нужно лишь спуститься по правому дереву для восстановления значения порогового ключа, разделяющего деревья. Когда его значение уже известно, то сложность оценивается как O(|h1 – h2|+1). Теорема. Два их дерева поиска 𝒯1 и 𝒯2 можно соединить за время O(log n). Если же пороговое значение ключа известно, то это время сокращается до O(|h1 – h2|+1), где h1, h2 – высоты деревьев 𝒯1 и 𝒯2 соответственно. Зная пороговое значение ключа, можно ускорить разделение дерева поиска на два дерева с последующим их соединением прямо во время поиска, собрав их вместе как левое и правое поддеревья. Это делается следующим образом. Мы спускаемся от корня к листу, учитывая пороговое значение ключа keysplit. Если мы спускаемся по левой ссылке left, то добавляем в начало правого списка указатель right и ключ, разделяющий правое и левое поддеревья; если же спускаемся по правой ссылке, то добавляем в начало левого списка указатель left и тот же разделяющий ключ. По достижении листа с пороговым значением ключа keysplit создаются два списка выровненных деревьев поиска с возрастающими ключами. Теперь эти два дерева поиска можно соединить в список по возрастанию их ключей, используя в качестве порогового значения ключ, который соответствует следующему по порядку дереву из списка. Ключ первого дерева в списке отбрасывается. Таким образом, сложность слияния двух списков – O(log n), так как мы просто спускаемся вниз к листу, а общая сложность операции соединения – O(log n), поскольку она является суммой высот деревьев в 148 Глава 3. Выровненные деревья поиска списке. При этом мы считаем, что высота двух соединенных деревьев не P1: KAE высоты наибольшего дерева. Из этого следует: меньше brass3 cuus247-brass 978 0 521 88037 4 August 4, 2008 11:40 Теорема. Для заданного порогового значения ключа выровненное по высоте дерево поиска можно разделить на два выровненных дерева поиска за время O(log n). Аналогичный метод работает для rb-деревьев и для (a, b)-деревьев. a b c d T1 T2 e T3 f T4 g T5 T6 h T7 T1 T2 a T4 b join h g f T3 T5 T6 T7 e c join Рис. 3.19. Разделение дерева по ключу h Рассмотренный в ряде статей еще один вариант – это отделение обновления дерева от его выравнивания, называемое мягким (relaxed) выравниванием, что обусловлено блочной моделью внешней памяти: для минимизации перемещений блоков они перемещаются только тогда, когда нагрузка на систему невелика. До выравнивания выполняются только добавления и удаления, а само выравнивание выполняется позже – отдельным проходом, называемым «чисткой» («clean-up») [435]. В этом случае алгоритмическая проблема состоит в том, что если раньше обновление всегда выполнялось в уже выровненном дереве, то здесь дерево выравнивается до его обновления [352]. Мягкое выравнивание возможно для большинства рассмотренных нами деревьев [435, 434, 506, 348–351], хотя для структур в основной памяти этот вопрос представляет только теоретический интерес, поскольку проблема выравнивания может возникнуть лишь в параллельных системах, где поиск по дереву, размещенному в общей памяти, может выполняться несколькими процессорами. В связи с этим можно еще упомянуть предложенное в [313] «ленивое» («lazy») выравнивание, выполняемое только при поиске. Глава 4 Древовидные структуры на множестве интервалов Выровненные деревья поиска – это не только словари, в которых можно быстро найти нужный объект. Они также являются основой любого приложения и многих других полезных структур данных и посредством тех же операций поиска могут поддерживать логарифмическую глубину узлов, даже не вдаваясь в способ выравнивания дерева. В этой главе приводятся описание и реализация нескольких совершенно иных абстрактных структур данных, в основе которых лежат выровненные деревья поиска. 4.1. Деревья пересекающихся отрезков Дерево строится на множестве пересекающихся отрезков, а поиск по ключу выдает все отрезки, куда попадает заданный ключ. Такая структура в некотором роде подобна одномерным диапазонам из раздела 2.7, где поиск выполнялся по множеству ключей и возвращался тот отрезок, куда все они входили. Здесь же наши исходные данные – это множество отрезков и только один ключ поиска. В каждом из этих случаев результат может оказаться потенциально большим, поэтому оценка сложности алгоритма будет чувствительной к размеру результата (выхода). Деревья отрезков были введены Эдельсбруннером9 и МакКрейтом10. Идея структуры дерева отрезков проста. Допустим, что множество отрезков – это {[a1, b1], [a2, b2], ..., [an, bn]}. Пусть 𝒯 – произвольное выровненное дерево поиска отрезков на множестве их начальных и конечных точек {a1, a2, ..., an, b1, b2, ..., bn}. Как описано в разделе 2.2, каждый внутренний узел такого дерева – это отрезок возможных значений ключа, которые попадают в отрезок этого узла. Каждый отрезок [ai, bi] этого множества здесь представляет собой узел, отвечающий следующим условиям: 9 10 В часто цитируемом, но почти недоступном техотчете H. Edelsbrunner. Dynamic Data Structures for Orthogonal Intersection Queries, Report F59, Institut für Informationsverarbeitung, Technische Universität Graz, Austria, 1980. Первая открытая публикация – это статья [190]. Только в недоступном техотчете E. M. McCreight. Efficient Algorithms for Enumerating Intersecting Intervals and Rectangles, Report CSL-80-9, Xerox Palo Alto Research Center, USA, 1980. Глава 4. Древовидные структуры на множестве интервалов 150 1) ключ узла находится внутри отрезка [ai, bi], 2) а сам отрезок [ai, bi] находится внутри вышестоящего отрезка. Такой узел легко найти. Зная отрезок [ai, bi], поиск по дереву 𝒯 начинается с корня как текущего узла с интервалом ]– ∞, ∞[, так что для него свойство 2 выполняется. Если ключ текущего узла попал в отрезок [ai, bi], то этот узел соответствует обоим условиям, и выбирается именно этот узел. В противном случае [ai, bi] – либо левее, либо правее ключа текущего узла, то есть этот ключ входит в отрезок либо левого, либо правого потомка очередного текущего узла. Таким образом, каждый отрезок спускается вниз по дереву поиска до тех пор, пока не будет найден узел, для которого выполняются оба условия 1 и 2. Этот узел может быть не единственным: если при спуске по дереву ключ текущего узла оказывается концом отрезка, то какой-то его потомок тоже может удовлетворять условиям 1 и 2. Для дерева отрезков не имеет значения, какой из этих узлов будет выбран. 1 2 3 4 5 6 7 7 4 3 1 [7,16] [4,12] [3,11] [1,9] 5 3 [3,7] 5 3 4 11 3 15 16 16 12 11 9 9 [4,12] [3,11] [1,9] [8,9] 6 13 10 [5,6] [13,15] [10,15] 13 11 6 4 14 15 15 [10,15] [13,15] 15 16 14 [14,16] 4 2 13 [7,16] 7 7 12 [2,4] 2 1 10 [3,7] 2 [2,4] 9 9 8 [8,9] [5,6] 8 5 8 6 7 10 8 9 12 10 11 [14,16] 14 12 13 16 14 15 16 Рис. 4.1. Множество отрезков и дерево поиска в нем В узле может быть несколько отрезков, которые могут сохраняться в нем. Отрезки сохраняются в двух упорядоченных списках – списке их левых концов по возрастанию и в списке их правых концов по убыванию. Каждый сохраненный в узле отрезок присутствует в обоих списках. Все левые концы меньше ключа в узле, а все правые – больше ключа или равны ему. Таким образом определяется абстрактная структура дерева отрезков. Для ее реализации нужны два разных типа узлов: узлы, ссылающиеся на левый и правый списки, и сами списки. Список узлов содержит, помимо 4.1. Деревья пересекающихся отрезков 151 конечной точки интервала, указатель на связанный с ним объект-отрезок. Узлы имеют следующую структуру: typedef struct ls_n_t { key_t key; struct ls_n_t *next; object_t *object; } list_node_t; typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; list_node_t *left_list; list_node_t *right_list; /* информация о выравнивании */ } tree_node_t; Определив структуру дерева отрезков, теперь можно описать алгоритм поиска в нем. Для заданного значения искомого ключа query_key мы спус­ каемся по дереву согласно обычному алгоритму поиска. В каждом узле дерева *n, который мы проходим, отрезки выводятся следующим образом: 1. Если query_key < n->key, то list устанавливается в n->left_list, пока list ≠ NULL и list->key ≤ query_key. 1.1. Выдается list->object, а list устанавливается в list->next. 2. Иначе query_key ≥ n->key и list устанавливается в n->right_list, пока list ≠ NULL и list->key ≤ query_key. 2.1. Выдается list->object, а list устанавливается в list->next. В каждом узле дерева время выполнения операции для каждого объекта – O(1), поэтому общее время выполнения всех операций – O(h + k), где h – высота дерева, а k – количество объектов в списке. Так что для любого выровненного дерева поиска как базовой структуры чувствительность к выходу можно оценить как O(log n + k). Однако нужно убедиться в правильности получаемого таким способом результата. Обратим внимание, что если отрезок [ai, bi] содержит искомый ключ, то он обязательно будет одним из узлов дерева на пути поиска ключа. На любом уровне существует не более одного узла, чей связанный с ним отрезок содержит [ai, bi], и если искомый ключ входит в него, то этот путь должен проходить именно через этот узел. Но для каждого узла нужно принимать в расчет только те отрезки, для которых искомый ключ находится между конечной точкой отрезка и ключом узла. А так как ключ узла может находиться во всех отрезках этого узла, то не нужно проверять конечную точку отрезка. Таким образом: 152 Глава 4. Древовидные структуры на множестве интервалов 1. Если искомый ключ меньше ключа узла 1.1. и ключ элемента списка меньше искомого, то левый конец отрезка ≤ искомый ключ < ключ узла ≤ правый конец отрезка; 1.2. если ключ элемента списка больше ключа узла, он сохраняется в возрастающем списке левых концов так, чтобы все остальные отрезки не содержали искомый ключ. 2. Если искомый ключ больше ключа узла 2.1. и ключ элемента списка больше искомого, то левый конец отрезка ≤ ключ узла ≤ искомый ключ ≤ правый конец отрезка; 2.2. если ключ элемента списка меньше ключа узла, то он сохраняется в убывающем списке правых концов так, чтобы остальные отрезки не содержали искомый ключ. Таким образом, этот алгоритм выдает только отрезки (или связанные с ними объекты), содержащие искомый ключ. Пока что мы описали лишь структуру и алгоритм поиска. Дерево отрезков – это статическая структура данных; она строится лишь однажды и не позволяет операций обновления: добавления/удаления отрезков в ней не допускаются. Чтобы построить такую структуру для заданного списка из n отрезков, сначала строится дерево поиска для правых концов отрезков за время O(n log n). Затем за время O(n log n) строится список отрезков по убыванию их левых концов, для каждого из которых ищется узел, куда его нужно поместить в начало левого списка со временем O(log n). В заключение за время O(n log n) строится список отрезков, отсортированных по возрастанию их правых концов с поиском для каждого из них того узла, где он должен быть сохранен и добавлен в начало нужного списка со временем O(log n) на один отрезок. Таким образом, за счет такой первоначальной сортировки и добавлений все списки узлов будут находиться в правильном порядке. Общее время для построения такой структуры – O(n log n). Необходимое для дерева отрезков пространство – O(n), так как для дерева поиска требуется пространство O(n), а каждый отрезок может оказаться только в двух списках. На этом анализ структуры дерева отрезков завершается. Теорема. Структура дерева отрезков – статическая. Она строится за время порядка O(n log n) и с затратами памяти порядка O(n). Все входящие в нее отрезки можно найти по искомому ключу за чувствительное к выходу время O(log n + k), где k – количество найденных отрезков. Прежде чем привести код функции поиска отрезков, нужно решить вопрос о том, как она будет выдавать несколько результатов, если их окажет- 4.1. Деревья пересекающихся отрезков 153 ся много. Мы решили выдавать такой результат в виде списка. Его просто понять, но он очень зависит от правильности работы программы из-за возможной утечки памяти. Альтернативой может быть разделение функции поиска на две части – сам процесс поиска и вывод каждого его очередного результата. list_node_t * find_intervals (tree_node_t * tree, key_t query_key) { tree_node_t *current_tree_node; list_node_t *current_list, *result_list, *new_result; if( tree->left == NULL ) return(NULL); else { current_tree_node = tree; result_list = NULL; while( current_tree_node->right != NULL ) { if( query_key <current_tree_node->key ) { current_list = current_tree_node-> left_list; while( current_list != NULL && current_list->key <= query_key ) { new_result = get_list_node(); new_result->next = result_list; new_result->object = current_list->object; result_list = new_result; current_list = current_list->next; } current_tree_node = current_tree_node->left; } else { current_list = current_tree_node->right_list; while( current_list != NULL && current_list->key >= query_key ) { new_result = get_list_node(); new_result->next = result_list; new_result->object = current_list->object; result_list = new_result; current_list = current_list->next; } current_tree_node = current_tree_node->right; } } return(result_list); } } 154 Глава 4. Древовидные структуры на множестве интервалов При преобразовании статической структуры данных в динамическую есть несколько проблем. Самая простая – добавление нового отрезка в нужный узел, особенно когда он добавляется в два упорядоченных списка левых и правых концов отрезков. Длина каждого из них может быть любой, вплоть до п, так что добавление в список длины l может потребовать времени вплоть до Ω(l). Это время можно сократить до O(log l), если левые и правые концы отрезков в выровненном дереве поиска представить в виде циклического двусвязного списка листьев. В этом случае потребуется время O(k) для просмотра первых k элементов списка плюс время O(log l) = O(log n) для добавления/удаления. Другая, еще не решенная проблема – это перестройка дерева отрезков. Его структура очень сильно зависит от каждого из отрезков, куда может попасть ключ узла дерева. Не всякий конец отрезка может оказаться ключом дерева поиска, поскольку в некоторых его узлах какие-то отрезки просто отсутствуют, поэтому может возникнуть необходимость в добавлении новых ключей в дерево поиска. По этой причине дерево поиска может оказаться невыровненным. А если требуется его выравнивание, хотя бы посредством вращений, то прежде всего нужно исправить связные списки, для чего потребуется объединить два упорядоченных списка и снова разбить их на два в зависимости от того, связаны ли отрезки со списком элементов, содержащих искомый ключ. Известного способа сделать это хотя бы за время O(n), к сожалению, нет. Если нам заранее известно некоторое надмножество всех концов отрезков, использующихся в структуре, для него можно, конечно, построить дерево поиска, которое вообще не нужно перестраивать. Это довольно эффективное решение, если это надмножество не слишком велико. Правда, для левого и правого списков в каждом узле нам все же потребуются деревья поиска для эффективного добавления/удаления новых отрезков. Несколько вариантов дерева отрезков во внешней памяти было предложено в [31, 37]. 4.2. Деревья полуоткрытых интервалов Основная задача дерева полуоткрытых интервалов11 та же, что для дерева отрезков: найти и выдать по заданному ключу на множестве из n интервалов за чувствительное к выходу время O(log n + k), где k – количество найденных интервалов. Пространственную сложность здесь можно оценить как O(n log n), и она несколько хуже, чем для дерева отрезков с пространственной сложностью O(n). Но зато опирающееся на каноническое разбиение интервалов дерево интервалов может служить базой, на которой можно решить ряд более общих задач. Деревья интервалов были введены Бентли12, и эта структура – тоже статическая. 11 12 Далее в этом разделе мы, как и сам автор, будем называть полуоткрытые интервалы просто интервалами. – Прим. перев. В другом часто цитируемом недоступном техническом отчете J. L. Bentley: Solution to Klee's Rectangle Problems, Technical Report, Carnegie-Mellon University, Pittsburgh, USA, 1977. 4.2. Деревья полуоткрытых интервалов 155 Пусть множество значений ключей – X = {x1, ..., xn}, а дерево поиска 𝒯 построено на множестве {−∞} ∪ X. Как обычно, каждый узел дерева 𝒯 – это интервал значений ключа, по которым может проходить путь его поиска. Любой интервал [xi, xj[ можно представить как объединение (разными способами) интервалов узлов13 так, что каждый из них будет подмножеством некоторого узла дерева. При любом из этих способов узел, находящийся ниже некоторого узла, становится ненужным, так как его интервал уже входит в вышестоящий узел. Среди таких узлов есть наивысший. В этом случае нужно просто охватить все узлы, интервалы которых входят в интервал [xi, xj[, и исключить из них избыточные. Это подмножество тех узлов, чьи интервалы входят в интервал [xi, xj[, но при этом вышестоящий интервал не входит в [xi, xj[. Это так называемое каноническое разбиение интервала [xi, xj[ в дереве поиска 𝒯 (рис. 4.2). 8 4 12 2 3 [2,4[ 1 -∞ 1 [1,2[ 2 3 6 [4,8[ 10 7 9 [8,10[ 5 4 5 6 7 8 9 14 10 11 13 11 12 15 13 14 15 Рис. 4.2. Каноническое разбиение интервала [1 , 10] Теорема. Каноническое разбиение интервала – это представление его в виде объединения непересекающихся (полуотрытых) интервалов. Любой путь поиска по ключу в дереве с каноническим разбиением интервала может проходить только через один узел. Каноническое разбиение интервалов легко построить. Оно начинается с корневого интервала дерева [xi, xj[: 1. Если интервал текущего узла целиком входит в [xi, xj[, выдается именно этот узел, и спуск по дереву прекращается, поскольку все лежащие ниже узлы избыточны. 2. Если интервал текущего узла частично перекрывается с [xi, xj[, выполняется спуск по обеим его ветвям. 3. Если интервал текущего узла не пересекается с [xi, xj[, спуск по дереву прекращается. Нетрудно видеть, что эта операция выбирает исключительно узлы канонического разбиения интервалов. Остается лишь оценить размер канонического разбиения и необходимое для его построения время. Для этого рассмотрим шаг 2, поскольку он единственный из всех шагов, не прекра13 Ключ −∞ введен как дополнительный лист дерева поиска, иначе было бы невозможно определить интервал с узлом, начинающимся с x1. Глава 4. Древовидные структуры на множестве интервалов 156 щающийся немедленно. Он выполняется только для тех узлов, чей интервал включает искомую конечную точку интервала [xi, xj[, поэтому отвечающие шагу 2 узлы – это те, что лежат на пути поиска xi и xj. Каждый из таких узлов требует просмотра находящихся ниже него узлов, а единственный путь, приводящий к шагам 1 или 3, проходит только через такие узлы. Общее количество пройденных узлов составляет менее 4 height(𝒯)14, а общее количество отобранных узлов – менее 2 height(𝒯). Теорема. Пусть X = {x1, ..., xn} – множество ключей, а 𝒯 – дерево поиска на множестве {– ∞} ∪ X. Тогда для любого интервала, заданного множеством ключей X, каноническое разбиение имеет размер не более 2 height(𝒯) и может быть построено за время O(height(𝒯)). Если высота дерева 𝒯 не превышает O(log n), то каноническое разбиение интервала можно оценить как O(log n), а сам интервал можно найти за время порядка O(log n). Теперь мы имеем каноническое разбиение интервала, поэтому структуру дерева на множестве интервалов {[a1, b1[, [a2, b2[, ..., [an, bn[} легко описать. Она представляет собой выровненное дерево поиска 𝒯 на расширенном множестве концевых точек интервалов {−∞, a1, a2, ..., an, b1, b2, …, bn}, где каждому узлу соответствует список тех интервалов [ai, bi[, для которых этот узел является частью канонического разбиения интервала (рис. 4.3). 1 2 3 4 5 6 7 8 9 10 11 12 13 14 7 4 11 2 6 1 -∞ 3 1 [1,8[ 2 [1,8[ 3 5 [2,7[ [3,4[ [3,14[ 4 6 5 9 [1,8[ [3,14[ [2,7[ [5,6[ 7 8 [7,10[ [1,8[ 8 13 [3,14[ 10 [9,12[ 9 [7,10[ 10 12 [3,14[ [11,14[ [11,13[ 11 [9,12[ 12 14 13 [3,14[ [11,14[ 14 Рис. 4.3. Каноническое разбиение интервала [1, 14] В такой структуре поиск интервалов довольно прост: по заданному ключу нужно спускаться вниз по дереву и попутно в каждом узле выводить все интервалы его списка. Все эти интервалы содержат искомый ключ, и каждый из них находится только в одном узле. Так что на выходе не будет повторов, а время поиска k интервалов при спуске по дереву – O(log n + k). Все это работает и для любого другого разбиения интервала, не содержащего избыточных элементов, но разбиение обязательно должно быть каноническим, поскольку оно невелико и его легко построить. В отличие от дерева отрезков, каждый интервал хранится в дереве интервалов многократно, поэтому необходимое ему пространство не дотягивает до O(n). При кано14 Функция height здесь обозначает высоту дерева 𝒯. – Прим. перев. 4.2. Деревья полуоткрытых интервалов 157 ническом разбиении для каждого интервала создается по крайней мере O(log n) элементов, поэтому общие затраты памяти составят O(n log n). Время же построения дерева интервалов может быть оценено как O(n log n): сначала строится выровненное дерево поиска, а затем в него вносятся канонически разбитые n интервалов за время O(log n) для каждого из них. Теорема. Дерево интервалов – это статическая структура данных, которую можно построить за время O(n log n), что потребует памяти порядка O(n log n). В ней будут представлены все интервалы, содержащие искомый ключ с чувствительным к выходу временем O(log n + k), где k – количество найденных интервалов. Для реализации структуры дерева интервалов нам снова нужны два типа узлов: сами узлы дерева и прикрепленные к ним списки интервалов. typedef struct ls_n_t { key_t key_a, key_b; /* интервал [a, b[ */ struct ls_n_t *next; object_t *object; } list_node_t; typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; list_node_t *interval_list; /* информация о выравнивании*/ } tree_node_t; Тогда алгоритм поиска будет выглядеть следующим образом: list_item_t *find_intervals(tree_node_t *tree, key_t query_key) { tree_node_t *current_tree_node; list_node_t *current_list, *result_list, *new_result; if( tree->left == NULL ) /* дерево пусто */ return(NULL); else /* дерево непустое, следуйте по пути поиска */ { current_tree_node = tree; result_list = NULL; while( current_tree_node->right != NULL ) { if( query_key < current_tree_node->key ) current_tree_node = current_tree_node->left; else current_tree_node = current_tree_node->right; current_list = current_tree_node->interval_list; while( current_list != NULL ) 158 Глава 4. Древовидные структуры на множестве интервалов { /* скопировать запись из списка узлов в итоговый список */ new_result = get_list_node(); new_result->next = result_list; new_result->key_a = current_list->key_a; new_result->key_b = current_list->key_b; new_result->object = current_list->object; result_list = new_result; current_list = current_list->next; } } return(result_list); } } Обратите внимание, что ни с корнем, ни с любым другим узлом на крайних левом или правом путях по дереву не могут быть связаны никакие интервалы канонического разбиения, так как они не имеют границ, нам же нужны только интервалы, имеющие границы. Обычно непустые списки узлов находятся ближе к листьям, а в дереве интервалов они, как правило, ближе к верхним узлам. Построение дерева интервалов состоит из двух шагов. Сначала строится выровненное дерево поиска, для которого можно выбрать любой метод из предыдущей главы или метод построения оптимального дерева из раздела 2.8. Изначально предполагается, что все поля списка интервалов interval_list узлов дерева пустые. После этого в списки последовательно вносятся интервалы. Ниже приведен код добавления в дерево интервала [a, b[; добавление интервала в список узла реализовано отдельной функцией. void attach_intv_node (tree_node_t *tree_node, key_t a, key_t b, object_t *object) { list_node_t *new_node; new_node = get_list_node(); new_node->next = tree_node->interval_list; new_node->key_a = a; new_node->key_b = b; new_node->object = object; tree_node->interval_list = new_node; } void insert_interval(tree_node_t *tree, key_t a, key_t b, object_t *object) { tree_node_t *current_node, *right_path, *left_path; 4.2. Деревья полуоткрытых интервалов 159 list_node_t *current_list, *new_node; if( tree->left == NULL ) exit(-1); /* неверное дерево */ else { current_node = tree; right_path = left_path = NULL; while( current_node->right != NULL ) /* не в листе */ { if( b < current_node->key ) /* идем влево: a < b < key */ current_node = current_node->left; else if( current_node->key < a ) /* идем вправо: key < b < a */ current_node = current_node->right; else if( a < current_node->key && current_node->key < b ) /* разделение узла: a < key < b */ { right_path = current_node->right; /* правый... */ left_path = current_node->left; /* и левый узлы */ break; } else if ( a == current_node->key ) /* a = key < b */ { right_path = current_node->right; /* влево пути нет */ break; } else /* current_node->key == b, так что a < key = b */ { left_path = current_node->left; /* вправо пути нет */ break; } } if( left_path != NULL ) { /* идем по левому пути до конечной точки а */ while( left_path->right != NULL ) { if( a < left_path->key ) { /* выбираем правый узел */ attach_intv_node(left_path->right, а, b, object); left_path = left_path->left; } else if( а == left_path->key ) { attach_intv_node(left_path->right, а, b, object); break; /* спускаться ниже не нужно */ 160 Глава 4. Древовидные структуры на множестве интервалов } else /* идем вправо, т. к. ни один узел не выбран */ left_path = left_path->right; } /* нужно выбрать левый лист, если он достигнут */ if( left_path->right == NULL && left_path->key == a ) attach_intv_node(left_path, a, b, object); } /* левый путь пройден */ if( right_path != NULL ) { /* теперь идем по правому пути до конечной точки b */ while( right_path->right != NULL ) { if( right_path->key < b ) { /* нужно выбрать левый узел */ attach_intv_node(right_path->left, а, b, object); right_path = right_path->right; } else if( right_path->key == b ) { attach_intv_node(right_path->left, а, b, object); break; /* дальнейший спуск не нужен */ } else /* идем влево, т. к. ни один узел не выбран */ right_path = right_path->left; } /* справа лист b недостижим */ } /* правый путь пройден */ } } Дерево интервалов, как и дерево отрезков, – это статическая структура, и у нее те же проблемы с преобразованием в динамическую: нужно реализовать добавление/удаление новых элементов в узлы, а также перестройку лежащего ниже узла поддерева. При добавлении/удалении, конечно же, снова используется дерево поиска. Но каждое добавление/удаление потребует еще O(log n) фрагментов канонического разбиения интервала. Такая операция может быть эффективной только для первого фрагмента и связанных с ним фрагментов списка. В этом случае для каждого узла дерева нужны две структуры: дерево поиска интервалов, чье каноническое разбиение имеет своим первым фрагментом именно этот узел, а также двусвязный список, допускающий добавление/удаление за время O(1) тех интервалов, которые есть где-то еще. Из этого следует, что добавление/ удаление интервалов можно выполнять за время O(log n) до тех пор, пока не изменится базовое дерево. Выравнивание лежащего ниже дерева путем вращений снова вызовет изменения в привязанных к узлам списках, что можно разрешить лишь путем просмотра всего списка, и потому такое вы- 4.3. Деревья объединения интервалов 161 равнивание неэффективно. Хотя ситуация здесь все-таки лучше, чем для деревьев отрезков, так как привязанные к узлу дерева цепочки интервалов не играют большой роли, и такой подход позволяет представить описанное позже (в разделе 6.2) дерево на множестве интервалов и сделать их действительно динамическими [339, 341]. В этом разделе использовались только полуоткрытые интервалы, так как они отражают структуру узловых интервалов. Ее легко перенести на древовидную структуру открытых или закрытых интервалов (отрезков), но для дерева полуоткрытых интервалов это все-таки проще. Версия дерева интервалов для внешней памяти описана в [72]. 4.3. Деревья объединения интервалов Несколько ранних работ по интервалам были мотивированы задачей из статьи [324], называемой теперь «мерой Клее». В ней был поставлен вопрос: можно ли определить меру (длину) объединения n интервалов (рис. 4.4) за время, лучшее чем Θ(n log n)? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Рис. 4.4. Система интервалов и их объединение Простейшее решение с временем O(n log n) – это сортировка концов интервалов с последующим проходом по ним, начиная с наименьшего, чтобы найти те интервалы, куда попадает искомая точка. Их количество увеличивается на 1 при прохождении левого конца интервала и уменьшается на 1 при прохождении правого конца интервала, при этом вычисляется сумма длин интервалов от наименьшего конца до наибольшего для всех тех точек, которые попадают хотя бы в один интервал. Поставленный вопрос был решен в статье [231], где была определена нижняя граница Ω(n log n) для модели дерева решений с линейными сравнениями15. Многомерный аналог этого вопроса – вычисление площади двумерных прямоугольников, выровненных вдоль одной оси, или суммарного объема ортогональных d-мерных гиперпараллелепипедов – был рассмотрен в [324]. Двумерная задача была решена Бентли с временем O(n log n)16, а для размерностей d ≥ 3 в [454] был получен лучший на сегодня результат – алгоритм с временем O(nd/2 log n), оказавшийся гораздо быстрее прежнего алгоритма [362]. Все методы оценки многомерных интервалов основаны на идее деления блоков размерности d координатной гиперплоскостью на блоки размернос­ 15 16 Но эта граница в [60] распространяется на более мощную алгебраическую модель дерева решений. В тех же неопубликованных его статьях, где описано дерево непересекающихся интервалов. 162 Глава 4. Древовидные структуры на множестве интервалов ти (d – 1). Эта гиперплоскость изменяет их всякий раз, когда проходит через начало или конец блока размерности d. В этом случае блок размерности (d – 1) добавляется в искомый блок или удаляется из него. Если структура поддерживает добавление/удаление блоков размерности (d – 1), ее можно использовать для оценки меры системы размерности d. Для двумерных систем была необходима структура с оценкой меры при добавлении/удалении объединяемых интервалов. Решение Бентли было основано на его деревьях непересекающихся интервалов. В качестве дополнительной информации каждый узел n содержал меру n->measure объединения всех интервалов, лежащих ниже n узлов и имеющих непустой список прикрепленных к нему интервалов, то есть они были частью кано­ нического разбиения некоторого интервала из заданного множества. Для любого из узлов n такую информацию легко восстановить по лежащим ниже него узлам: • если n->interval_list ≠ NULL, то n->measure – это длина интервала узла n; • если n – лист и n->interval_list = NULL, то n->measure равна 0; • если n – внутренний узел и n->interval_list = NULL, то n->measure = n->left->measure + n->right->measure. Поэтому после любого добавления/удаления интервала нужно просто обновить меру во всех измененных узлах или находящихся выше них. К таким узлам относятся узлы, лежащие на пути поиска левого конца интервала и их правые потомки, а также узлы, лежащие на пути поиска правого конца интервала, и их левые потомки. В итоге получается структура с временем добавления/удаления O(log n), которая при поиске в корне дерева обеспечивает меру объединенных интервалов порядка O(1). Но такое дерево наследует свойство дерева поиска непересекающихся отрезков: его нельзя менять, так как концы его интервалов должны быть заранее известны. В приложениях, где вычисляется мера прямоугольников, все обстоит точно так же, поскольку все прямоугольники должны быть заранее известны. Динамическая структура с оценкой меры дерева объединения интервалов была определена в [258]. Эта структура поддерживает множество из n интервалов с добавлением/удалением за время O(log n) и поиском за время O(1). Построение дерева мер начинается с построения дерева поиска, выровненного по концам исходного множества интервалов с включением в него −∞. Связанные с узлами интервалы – это те интервалы, в которые входит хотя бы один из концов другого интервала. Как сами интервалы узлов, так и связанные с ними интервалы не хранятся в узлах – они всего лишь понятийные. Отметим, что интервал, в который попал узел другого интервала, не обязательно связан с этим узлом: интервал [a, b[ связан только с теми узлами, что лежат на пути поиска концов a или b. 4.3. Деревья объединения интервалов 163 Каждый узел n дерева поиска содержит три дополнительных поля (рис. 4.5): • n->measure – мера пересечения интервала узла n с объединением всех связанных с ним интервалов; • n->rightmax – максимальный правый конец всех связанных с n интервалов; • n->leftmin – минимальный левый конец всех связанных с n интервалов. интервал узла связанный с узлом интервал leftmin rightmax добавленная мера узла Рис. 4.5. Интервал узла и связанные с ним интервалы Для любого внутреннего узла n эта информация может быть восстановлена из лежащего ниже узла. Два поля просты: • n->rightmax = max(n->left->rightmax, n->right->rightmax, • n->leftmin = min(n->left->leftmin, n->right->leftmin). Но сама мера определяется несколько сложнее. Пусть x – это произвольное значение в узле интервала n, объединяющего несколько связанных с ним интервалов, которые влияют на меру n->measure. Если x < n->key, то x попадает в узел интервала n->left. Если же значение x попадает в связанный с n->left интервал, то оно влияет на меру n->left->measure. Но не исключено, что x окажется также и в интервале, связанном с n, но не в интервале, связанном с n->left. В этом случае такой интервал связан с n->right и целиком включает в себя весь интервал узла n->left. Так что вклад узла n->left в n->measure – это полная его длина, если значение n->right->leftmin меньше левого конца интервала в узле n->left. То же самое имеет место и для n->left->measure, соответствующей вкладу узла n->right. Таким образом, если l и r – соответственно левый и правый концы интервала узла n, то в итоге получаем следующее (рис. 4.6): 1. если n->right->leftmin < l и n->left->rightmax ≥ r, n->measure = r – l; 2. если n->right->leftmin ≥ l и n->left->rightmax ≥ r, n->measure = ( r - n->key) + n->left->measure; 3. если n-> right->leftmin < l и n->left->rightmax < r, n->measure = n->right->measure + (n->key – l ); 4. если n->right->leftmin ≥ l и n->left->rightmax < r, n->measure = n->right->measure + n->left->measure. 164 Глава 4. Древовидные структуры на множестве интервалов интервалы узлов l n->left 1. n->right n->right->leftmin n->left->rightmax 2. 3. r n n->right->leftmin n->left->rightmax n->right->leftmin n->left->rightmax 4. n->right->leftmin n->left->rightmax l n->key r Рис. 4.6. Четыре варианта вычисления меры n->measure Согласно этим правилам теперь можно добавлять/удалять интервалы [a, b[. Сначала обновляются листья, содержащие a и b с возможными добавлениями/удалениями в случае необходимости. После этого нужно подняться вверх до корня и восстановить равновесие дерева с пересчетом трех дополнительных полей каждого измененного узла. Таким образом, для любого выровненного дерева оценкой времени будет O(log n), а общая мера объединения интервалов будет находиться в корне, так что время поиска интервала можно оценить как O(1). Если в текущем множестве есть несколько интервалов с одинаковыми концами, то обновление листа может стать непростым, поскольку с этим листом может быть связано несколько интервалов. Но их можно преобразовать в дерево поиска, позволяющее обновить лист за время O(log n), что не усложнит алгоритм. Таким образом, можно подвести итог оценки производительности этой структуры данных следующим образом: Теорема. Древовидная структура мер – это динамическая структура данных, в которой хранится множество из n интервалов. Она поддерживает операции добавления/удаления интервалов за время O(log n) и выполняет их поиск в соответствии с мерой их объединения за время O(1). Размер такой структуры – O(n). Иной способ – вместо вычисления меры интервалов представить их объединение в виде списка. В этом случае нужно добиться того, чтобы сложность поиска была чувствительной только к выходу: если на выходе есть лишь один длинный интервал, то ответ получается быстро, но если на выходе – объединение интервалов, то нужно дополнительное время, чтобы 4.3. Деревья объединения интервалов 165 выдать их все. Операция добавления/удаления одного интервала может существенно менять структуру их объединения. Наилучшее решение этой задачи было предложено в [137], где операции добавления/удаления интервалов требуют времени O(log n) с чувствительным к выходу временем O(k), если в объединение интервалов входит k элементов. Описываемая здесь структура дерева объединения интервалов опирается на статью [137]. Сначала строится выровненное дерево поиска на множестве {−∞, x1, ..., xn} всех концов интервалов. Как и в дереве мер, к каждому узлу привязываются все те интервалы множества, хотя бы один из концов которых входит в интервал этого узла. Для фиксированного узла n рассмот­ рим объединение всех связанных с этим узлом интервалов; это объединение состоит из связанных элементов, которые сами являются интервалами с концами из основного множества интервалов. Пусть [xi, xj[ – это самый левый элемент объединения, а [xk, xl[ – самый правый. Эти интервалы могут совпадать. Тогда дополнительными полями будут следующие: • • • • n->leftmin – ссылка на лист с ключом xi, n->leftmax – ссылка на лист с ключом xj, n->rightmin – ссылка на лист с ключом xk, n->rightmax – ссылка на лист с ключом xl. Кроме того, если узел n – лист, то потребуются еще два дополнительных поля: • n->next – ссылка на лист со следующим бóльшим ключом; • n->transfer – ссылка на самый высокий узел v, для которого v->left->rightmin = n и v->left->rightmax->key ≥ v->key, если он существует. Такая ссылка может быть только у тех листьев n, на которые ссылаются v->left->rightmin некоторых узлов v. При таком определении (рис. 4.7) алгоритм поиска теперь становится совсем простым. Он основан на том, что если [xi, xj[ – это интервал, входящий в объединение всех интервалов основного множества интервалов, а n – это лист с ключом xi, то ссылка n->transfer вполне определена, а n->transfer-> rightmax – это лист с ключом xj. Поэтому если известно начало объединения интервалов, то по ссылке transfer можно найти его конец. А если известен конец элемента, то следующий элемент должен начинаться со следующего большего ключа, определяемого по ссылке next, поскольку каждый ключ – это начало или конец некоторого интервала в исходном множестве интервалов. Наименьший ключ исходного множества интервалов должен быть началом самого первого элемента, так что подниматься вверх по дереву придется именно от этого узла со временем O(1) для каждого пройденного на этом пути элемента, что в итоге приведет к заявленному выше чувствительному к выходу времени поиска O(k), если объединение интервалов будет состоять из k элементов. 166 Глава 4. Древовидные структуры на множестве интервалов Интервалы Объединение 1 интервалов 2 3 4 5 6 7 8 9 10 11 12 13 14 7 3 11 1 5 -∞ 2 1 9 4 2 3 6 4 5 13 8 6 7 10 8 9 10 12 11 14 12 13 14 Рис. 4.7. Объединение интервалов и дерево поиска со ссылками transfer Но главное – это обновление такой структуры. Чтобы добавить/удалить интервал [a, b[, его нужно сначала добавить/удалить в нижележащем поддереве, предварительно удалив узел, если при этом нет другого интервала исходного множества с тем же концом интервала. На этом шаге можно обновить ссылку next, чтобы просто поддержать связный список листьев, о чем говорилось в разделе 3.11. После этого нужно вернуться к корню, выровнять дерево, а затем снова пройти вверх по тому же пути поиска и по тем соседним узлам, которые изменились при выравнивании, чтобы восстановить все остальные поля. При этом нужно учесть, что поля от leftmin до rightmax могли измениться только в тех узлах, где изменились связанные с ними интервалы, лежащие на пути поиска интервала [a, b[. Перестройка таких узлов при движении вверх по дереву довольно проста: если n->left и n->right уже содержат правильную информацию, то информация для n определяется по следующим правилам: 1. n->leftmin 1.1. если n->left->leftmin->key < n->right->leftmin->key, то n->leftmin устанавливается в n->left->leftmin; 1.2. иначе n->leftmin устанавливается в n->right->leftmin. 2. n->leftmax 2.1. если n->left->leftmax->key < n->right->leftmin->key, то n->leftmax устанавливается в n->left->leftmax; 4.3. Деревья объединения интервалов 167 2.2. иначе если n->left->rightmax->key < n->right->leftmax->key, то n->leftmax устанавливается в n->right->leftmax; 2.3. иначе если n->left->rightmax->key < n->right->rightmax->key, то n->leftmax устанавливается в n->left->rightmax; 2.4. иначе n->leftmax устанавливается в n->right->rightmax. 3. n->rightmin 3.1. если n->left->rightmax->key < n->right->rightmin->key, то n->rightmin устанавливается в n->right->rightmin; 3.2. иначе если n->left->rightmin->key < n->right->leftmin->key, то n->rightmin устанавливается в n->left->rightmin; 3.3. иначе если n->left->leftmax->key < n->right->leftmin->key, то n->rightmin устанавливается в n->right->leftmin; 3.4. иначе n->rightmin устанавливается в n->left->leftmin. 4. n->rightmax 4.1. если n->left->rightmax->key < n->right->rightmax->key, то n->rightmax устанавливается в n->right->rightmax; 4.2. иначе n->rightmax устанавливается в n->left->rightmax. Обновление ссылки transfer сложнее, потому что она обновляется не в узлах на пути поиска, а в листьях. Если n – лист, то n->transfer – ссылка на узел v с v->left->rightmin = n, поэтому единственные листья, требующие обновления ссылки transfer, – это те, что достигаются по значению v->left->rightmin узла v, находящегося на пути поиска или измененного при выравнивании. Эти O(log n) узлов узла v просматриваются в порядке уменьшения их глубины, то есть от листа к корню, и для каждого из них выполняются следующие действия: • если v->left->rightmax->key > v->key, то v->left->rightmin->transfer устанавливается в v. Если у листа n есть несколько внутренних узлов v со значениями v->left->rightmin = n и v->left->rightmax->key > v->key, то высший из них вносится во все более ранние поля n->transfer, так что они теперь ссылают- ся на самый высокий узел с такими значениями. Сложность любого обновления – O(log n), поскольку на каждом уровне выровненного дерева поиска для него требуется постоянное время. А необходимое пространство оценивается как O(n), так как в каждом из O(n) узлов дерева поиска добавляется всего шесть ссылок. В целом это дает следующий результат: Теорема. Дерево объединения интервалов – это динамическая структура данных, которая обеспечивает работу с множеством из n интервалов, поддерживая добавление/удаление со временем Глава 4. Древовидные структуры на множестве интервалов 168 O(log n), и выдает объединение этих интервалов за чувствительное к выходу время O(k), где k – количество найденных элементов. Размер такой структуры – O(n). 4.4. Деревья сумм взвешенных интервалов Простое и полезное применение идеи канонического разбиения интервалов – структура, в которой определяется ступенчатая функция f(x) сумм весов интервалов, когда каждому интервалу [a, b[ присваивается вес 1, если x ∈ [a, b[, и вес 0, если x ∉ [a, b[, а вес интервала определяется следующим образом: интервал [a, b[ с весом c ≡ f(x) = � c x ∈ [a, b[ . 0 x ∉ [a, b[ В этом случае можно использовать суммы весов тех интервалов, куда попадает значение х. Такая структура похожа на энергетическую систему и применяется в самых разных системах: каждая из них в течение некоторого времени находится на одном и том же энергетическом уровне, однако общая в каждый момент потребляемая энергия – это сумма энергий всех ее активных на данный момент подсистем (рис. 4.8). 1.9 1 2 0.3 3 0.0 4 1.9 1.4 -0.3 5 0.0 6 1.4 7 1.7 8 0.3 9 0.7 10 0.4 -1.2 0.4 0.3 11 1.5 1.1 12 1.1 13 0.6 14 -0.5 0.7 15 0.7 Рис. 4.8. Взвешенные интервалы и их веса Чтобы построить такую структуру, начнем с описанных в предыдущем разделе деревьев интервалов, но вместо вывода самих интервалов будем выводить лишь их количество. В этом случае не нужно хранить в каждом узле дерева список интервалов – достаточно лишь их количества. При поиске по всем пройденным узлам их веса суммируются, что дает структуру размером порядка O(n) со временем ее построения O(n log n) и временем поиска в ней O(log n), а для искомого ключа выдается количество включающих его интервалов. При таком подходе можно не ограничиваться только подсчетом интервалов: каждому из них можно придать произвольные положительные или отрицательные веса и определять сумму весов всех интервалов, содержащих искомый ключ. Таким образом вычисляется ступенчатая функция не более чем из n ступенек, и в любой момент ее можно вычислить за время O(log n). Более того, в отличие от дерева интервалов, где нужно было при каждом вращении обязательно обновлять довольно большие привязанные к узлам структуры, такую структуру легко превратить в динамическую, в которой достаточно скорректировать лишь частичные суммы. 4.4. Деревья сумм взвешенных интервалов 169 Таким образом, мы приходим к следующей структуре: дерево поиска концов интервалов или точек скачков ступенчатой функции связано с весом каждого узла. Значение функции для искомого ключа – это сумма весов, связанных с узлами на пути поиска этого ключа. Чтобы увеличить функцию для интервала [a, b[ на величину w, находятся все узлы, принадлежащие каноническому разбиению этого интервала, и их веса увеличиваются на w. Если a и b уже были ключами нижележащего дерева поиска, то добавление не требуется. В противном случае нужно обновить нижележащее дерево и настроить веса в узлах так, чтобы сумма весов на каждом из путей оставалась неизменной. При удалении интервала он добавляется с отрицательным весом, а ненужные листья удаляются. В итоге получаем следующее: Теорема. На множестве из п взвешенных интервалов можно построить динамическую структуру данных, поддерживающую операции добавления/удаления со временем порядка O(log n) и с вычислением суммы весов всех содержащих искомое значение x интервалов за время O(log n). Структура имеет размер O(n). Реализация такой древовидной структуры довольно проста, так как в ее узлах нужно сохранять и обновлять лишь одно число. Структура узла этого дерева такова (рис. 4.9): typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; number_t summand; /* некоторая информация о выравнивании */ } tree_node_t; 1.9 1 2 3 1.4 -0.3 4 5 6 7 8 -1.2 0.4 0.3 9 10 11 1.1 12 13 14 0.7 15 8 4 12 2 6 1 -∞ 3 1 0.3 2 5 -0.3 3 1.9 4 5 10 0.3 7 1.4 6 1.4 7 8 9 0.4 0.3 9 14 11 1.1 10 0.4 11 13 -0.5 12 1.1 13 15 14 0.7 15 Рис. 4.9. Дерево взвешенных интервалов: поле summand узлов с нулевым весом – пустое Как и в предыдущих структурах, в дереве поиска должен быть узел с ключом −∞, который добавляется при создании дерева. В таком дереве поиска 170 Глава 4. Древовидные структуры на множестве интервалов не нужны ключи объектов, а нужны непустые ссылки на объекты согласно правилам построения дерева поиска. Здесь ссылка на объект – это ссылка на корень дерева. tree_node_t *create_tree(void) { tree_node_t *tree; tree = get_node (); tree->left = NULL; tree->summand = 0; /* нужен ключ-infty. использовать root как непустой объект ptr */ insert(tree, NEGINFTY, (object_t *)tree); return(tree); } А сам алгоритм поиска выглядит так: number_t evaluate_sum(tree_node_t *tree, key_t query_key) { tree_node_t *current_tree_node; number_t sum; if( tree->left == NULL ) return(0); else { current_tree_node = tree; sum = tree->summand; while( current_tree_node->right != NULL ) { if( query_key < current_tree_node->key ) current_tree_node = current_tree_node->left; else current_tree_node = sum += current_tree_node->summand; } return(sum); } } Чтобы добавить взвешенный интервал, сначала его концы добавляются в нижнее поддерево, если их там нет. Для этого, возможно, придется разделить лист на два листа так, чтобы он стал внутренним, а его суммарный вес остался прежним. Два новых листа будут иметь суммарные веса 0, и на любом пути они не изменятся. Затем обязательно выполняется выравнивание, причем код вращения нужно изменить так, чтобы суммарные веса вдоль пути оставались неизменными. Это легко сделать, поскольку суммарный вес в дереве поиска можно опустить на нижний уровень: если веса каждого из внутренних узлов n->left->summand и n->right->summand увеличить на n->summand и затем присвоить n->right->summand значение 0, то на любом пути, проходящем через узел n, его суммарный вес не изменится (рис. 4.10). 4.4. Деревья сумм взвешенных интервалов 171 summand 0 summand a summand b summand c summand a+b summand a+c Рис. 4.10. Спуск суммарного веса узла на нижний уровень Таким образом, при вращении суммарные веса двух узлов меняются и становятся нулевыми. В подобном случае вращения можно выполнить без изменения суммарных весов вдоль пути, так как они при вращении узлов не меняются. Ниже приводится код для левого вращения: void left_rotation (tree_node_t *n) { tree_node_t *tmp_node; key_t tmp_key; /* опустить summand из n */ n->left->summand += n->summand; n->right->summand += n->summand; n->summand = 0; tmp_node = n->right; /* опустить summand из n->right */ tmp_node->left->summand += tmp_node->summand; tmp_node->right->summand += tmp_node->summand; tmp_node->summand = 0; tmp_node = n->left; /* выполнить нормальное вращение влево */ tmp_key = n->key; n->left = n->right; n->key = n->right->key; n->right = n->left->right; n->left->right = n->left->left; n->left->left = tmp_node; n->left->key = tmp_key; } Теперь приведем код для добавления интервала [a, b[ с весом w. void insert_interval (tree_node_t *tree, key_t a, key_t b, number_t w) { tree_node_t *tmp_node; if( find (tree, a) == NULL ) { insert (tree, a, (object_t *) tree); } /* использован узел как непустая ссылка на объект*/ if( find (tree, b) == NULL ) { insert (tree, b, (object_t *) tree); } 172 Глава 4. Древовидные структуры на множестве интервалов tmp_node = tree; /* следовать по пути поиска a */ while( tmp_node->right != NULL ) { /* добавить w по всему правому пути */ if( key < tmp_node-> ) { tmp_node->right->summand += w; tmp_node = tmp_node->left; } else tmp_node = tmp_node->right; } tmp_node->summand += w; /* лист с ключом а */ tmp_node = tree; /* следовать по пути поиска b */ while( tmp_node->right != NULL ) { /* вычесть w по всему правому пути */ if( b < tmp_node->key ) { tmp_node->right->summand -= w; tmp_node = tmp_node->left; } else tmp_node = tmp_node->right; } tmp_node->summand -= w; /* лист с ключом b */ } Здесь снова для любого из выровненных деревьев поиска используются функции find и insert с небольшими изменениями, когда поле summand вновь созданного листа имеет значение 0. Удаление узла – это просто добавление узла с отрицательным весом –w. При этом ставшие ненужными листья не удаляются. В минимальном древовидном представлении должны оставаться только те листья, у которых изменилась сумма весов. Так, после добавления интервала [a, b[ можно было бы вычислить сумму весов для листьев до a и после b. Однако сравнить поля summand таких листьев не всегда возможно, поскольку они могут лежать на разных путях. Если найдены два соседних листа с одной и той же суммой, их веса опускаются на нижележащий лист с бóльшим ключом, а затем этот ключ удаляется из дерева с обычным выраниванием. Подобная, но более сложная задача заключается в том, чтобы среди сумм весов найти ее максимальное значение, то есть в множестве взвешенных интервалов, содержащих искомый ключ, найти тот интервал, который имеет максимальный вес. Проблема состоит в том, чтобы превратить такую структуру в динамическую. Подобно статической структуре, здесь можно снова воспользоваться идеей канонического разбиения интервала и хранить в каждом узле максимальный вес тех интервалов, для которых этот 4.5. Деревья поиска интервалов с ограниченной максимальной суммой весов 173 узел – часть их канонического разбиения. Тогда при поиске будут выводиться все узлы вдоль пути поиска. Это удобно для добавлений, но не для удалений. Структура со средним временем добавления/удаления O(log n) и временем поиска в худшем случае O(log n) была описана в работе [6], улучшив более раннюю структуру [302]. 4.5. Деревья поиска интервалов с ограниченной максимальной суммой весов Обеспечивающая более строгий поиск структура со ступенчатой функцией на множестве взвешенных интервалов была описана в [80]. Пусть σ – некоторая ступенчатая функция, позволяющая структуре определять ее максимум не только для интервала [a, b[, но и для конкретного значения x, когда функция σ(x) достигает своего максимума. При поиске такой прием позволяет, как в предыдущей структуре, вычислить максимум интервала, даже когда он вырождается в одну точку. В такой структуре все операции обновления должны увеличивать/уменьшать σ на некоторую величину c для всех x ∈ [t, ∞[. Такая структура – тоже динамическая и представляет собой выровненное дерево поиска со ступенчатой функцией σ для оценки ключей и дополнительными полями partial_sum и summand в его узлах. Главное в такой структуре – это то, что для каждого узла *n максимум функции σ для связанного с ним интервала равен n->partial_sum плюс сумма m->summand по всем узлам *m на пути от корня до *n, включая n->summand (рис. 4.11). 1.9 1 0.3 2 0.0 3 1.9 4 1.4 -0.3 0.0 5 1.4 6 1.7 7 0.3 0.7 8 8 4 2 1 -∞ -0.3 1 2 5 1.9 3 1.9 1.5 11 1.1 4 12 0.6 13 -0.5 14 0.7 0.7 15 1.9 12 1.5 6 3 10 1.1 1.9 1.9 0.3 0.4 9 -1.2 0.4 0.3 0.4 7 1.4 5 10 1.1 1.7 1.4 6 1.4 0.3 1.4 7 9 8 0.3 11 0.3 9 14 10 1.1 11 -0.4 12 0.7 -1.2 13 1.1 1.1 13 15 14 15 Рис. 4.11. Сумма взвешенных интервалов и дерево поиска по максимальному весу: в каждом узле левое поле – key, правое верхнее – summand, правое нижнее – partial_sum, пустые поля имеют значение 0 -0.7 174 Глава 4. Древовидные структуры на множестве интервалов Таким образом, если искомый интервал [a, b[ связан с узлом, то его максимум можно определить, просто спустившись вниз по дереву к этому узлу и просуммировав все нужные слагаемые. Если искомый интервал не является узлом, то используется его каноническое разбиение: максимум всего интервала должен оказаться в одном из подынтервалов его канонического разбиения. В этом случае (опять же при спуске по дереву) ищутся интервалы разбиения и их максимумы с суммированием нужных значений узлов. Структура узла такого дерева следующая: typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; number_t summand; number_t partial_sum; /* информация о выравнивании */ } tree_node_t; В такой структуре поиск теперь похож на добавление в дерево отрезков: для каждого узла канонического разбиения интервала просто проверяется, превышает ли его суммарный вес текущий максимум. Функция поиска может быть описана следующим образом: int max_value_interval (tree_node_t *tree, key_t a, key_t b) { tree_node_t *current_node, *right_path, *left_path; number_t sum, left_sum, right_sum, tmp_sum, current_max; int first = 1; if( tree->left == NULL ) exit(-1); /* неправильное дерево */ else { current_node = tree; sum = 0; right_path = left_path = NULL; while( current_node->right != NULL ) /* не на листе */ { sum += current_node->summand; if( b < current_node->key ) /* идем влево: a < b < key */ current_node = current_node->left; else if( current_node->key < a ) /* идем вправо: key < b < a */ current_node = current_node->right; else if( a < current_node->key && current_node->key < b ) /* разбить: a < key < b */ { right_path = current_node->right; /* оба правые */ left_path = current_node->left; 4.5. Деревья поиска интервалов с ограниченной максимальной суммой весов 175 /* и влево */ break; } else if( a == current_node->key ) /* a = key < b */ { right_path = current_node->right; /* не левый */ break; } else /* current_node->key == b, и a < key = b */ { left_path = current_node->left; /* не правый */ break; } } if( left_path == NULL && right_path == NULL ) current_max = sum + current_node->summand + current_node-> partial_sum; left_sum = right_sum = sum; if( left_path != NULL ) { /* идем влево до конечной точки а */ while( left_path->right != NULL ) { left_sum += left_path->summand; if( а < left_path->key ) { /* правый узел может внести свой вклад */ tmp_sum = left_sum + left_path->right->summand + left_path->right->partial_sum; if( first || tmp_sum > current_max ) { current_max = tmp_sum; first = 0; } left_path = left_path->left; } else if( а == left_path->key ) { tmp_sum = left_sum + left_path->right->summand + left_path->right->partial_sum; if( first || tmp_sum > current_max ) { current_max = tmp_sum; first = 0; } break; /* вниз спускаться не нужно */ } else /* идти вправо: ни один узел не выбран */ left_path = left_path->right; } 176 Глава 4. Древовидные структуры на множестве интервалов /* есть ли левый лист, если он достигнут */ if( left_path->right == NULL ) { tmp_sum = left_sum + left_path->summand + left_path->partial_sum; if( first || tmp_sum > current_max ) { current_max = tmp_sum; first = 0; } } } /* конец левого пути */ if( right_path != NULL ) { /* идем по правому пути до конечной точки b */ while( right_path->right != NULL ) { right_sum += right_path->summand; if( right_path->key < b ) { /* левый узел может внести свой вклад */ tmp_sum = right_sum + right_path->left->summand + right_path->left->partial_sum; if( first || tmp_sum > current_max ) { current_max = tmp_sum; first = 0; } right_path = right_path-> right; } else if( right_path-> key == b ) { tmp_sum = right_sum + right_path->left->summand + right_path->left->partial_sum; if( first || tmp_sum > current_max ) { current_max = tmp_sum; first = 0; } break; /* ниже спускаться не нужно */ } else /* идем влево: ни один узел не выбран */ right_path = right_path->left; } if( right_path->right == NULL && right_path->key < b ) { tmp_sum = right_sum + right_path->summand + right_path->partial_sum; if( first || tmp_sum > current_max ) { current_max = tmp_sum; first = 0; } } } /* конец правого пути */ return(current_max); } } 4.5. Деревья поиска интервалов с ограниченной максимальной суммой весов 177 Здесь определяется максимум текущей функции σ при поиске интервала [a, b[, но не сообщается, где именно этот максимум достигнут. Простейший способ реализации поиска конкретного значения х с максимальным значением σ(х) – это поиск максимума при спуске по дереву. Когда максимум известен, можно найти интервал канонического разбиения, где достигается это значение, и затем внутри этого интервала спуститься вниз к листу, выбрав того потомка, интервал которого все еще имеет тот же максимум. Теперь опишем операцию обновления, которая похожа на ту же операцию из предыдущего раздела. Чтобы добавить [a, b[ с весом w, нужно добавить w к текущей функции σ для всех x ∈ [a, ∞[, а затем добавить –w для всех x ∈ [b, ∞[. Сначала посредством любой функции insert в исходное дерево поиска добавляются a и b с выравниванием дерева, что, как в предыдущем разделе, потребует модификации новых листьев. Если лист разделен и стал внутренним, то его summand и partial_sum остаются прежними, при этом partial_sum копируется в оба новых листа, а summand новых листьев становится равным 0. При этом сумма вдоль пути к корню сохраняется. В целях выравнивания исходного дерева поиска нужно опять же изменить стандартные операции вращения: summand опустить ниже и пересчитать поля partial_sum всех потомков узла. Пример кода измененного левого вращения выглядит следующим образом: void left_rotation (tree_node_t *n) { tree_node_t *tmp_node; key_t tmp_key; number_t tmp1, tmp2; tmp1 = n->summand; n->summand = 0; n->part_sum += tmp1; tmp2 = n->right->summand; n->right->summand = 0; n->left->summand += tmp1; n->right->left->summand += tmp1 + tmp2; n->right->right->summand += tmp1 + tmp2; tmp_node = n->left; tmp_key = n->key; n->left = n->right; n->key = n->right->key; n->right = n->left->right; n->left->right = n->left->left; n->left->left = tmp_node; n->left->key = tmp_key; tmp1 = n->left->left->summand + n->left->left->part_sum; tmp2 = n->left->right->summand + n->left->right->part_sum; n->left->part_sum = (tmp1 > tmp2) ? tmp1: tmp2; } 178 Глава 4. Древовидные структуры на множестве интервалов Все эти операции пока не меняют функцию σ и суммы в дереве поиска. Чтобы обновить функцию, нужно действовать, как в предыдущем разделе, то есть добавить w к summand в каждом узле, входящем в каноническое разбиение интервала [a, ∞[. Тогда путь к любому узлу, чей интервал целиком входит в [a, ∞[, будет содержать только один из этих узлов. Поскольку σ изменена на w на всем интервале узла, максимум σ(x) на этом интервале будет изменен на w, так что суммы сохранятся для всех этих узлов. Для узлов, чьи интервалы находятся за пределами [a, ∞[ и где функция σ изменилась, ничего больше не меняется – остаются только те узлы, которые содержат a и лежат на пути поиска a. Для них пересчитываются только суммы полей partial_sum по пути снизу вверх всех их потомков. Затем те же действия выполняются для интервала [b, ∞[, но с добавлением значения –w. Такое обновление не удаляет лишние листья, у которых ключ суммарной функции σ не изменяется. Описанный в предыдущем разделе метод можно использовать и для этой структуры. Для описанной структуры время любой операции можно оценить как O(h) при высоте дерева поиска h. При выборе любого выровненного дерева поиска из главы 3 предельная оценка времени поиска – O(log n). Глобальный максимум основной функции может быть ограничен даже постоянным временем: это просто сумма полей summand и partial_sum корня. Можно было бы добавить еще некоторые операции, если они поддерживаются деревом поиска, например разделение одной ступени функции на две или соединение двух ступеней в одну. Теорема. Структура на основе ступенчатой функции σ с п ступенями максимальных весов интервалов поддерживает их поиск, включая вырожденный в точку интервал, за время O(log n) и обновление самой функции за счет добавления к ней дополнительного веса w для всех x ≥ [a, b[ за то же время. При глобальном максимуме поиск в такой структуре выполняется за время O(1). 4.6. Деревья прямоугольных областей В разделе 2.7 рассматривалась задача поиска ключей среди отрезков, когда для заданного отрезка выводились все попадающие в него ключи исходного множества. Многомерный аналог этой задачи – это поиск прямоугольной области. При поиске определяемого осями координат прямоугольника (в самом общем случае d-мерного) выводятся все попадающие в него точки. Поиск в прямоугольных областях изучался не столько для геометрических структур, сколько для структур индексов баз данных. В базах данных часто встречаются кортежи из многих составляющих, и поиск многомерной области там вполне обычен. Например, «вывести всех сотрудников с зарплатой от 50 000 до 75 000 долларов в возрасте старше 50 лет, добивших- 4.6. Деревья прямоугольных областей 179 ся продаж более чем в 500 000 долларов за последние 3 года». Это – пятимерная прямоугольная область поиска. Поиск в прямоугольных областях также полезен для предварительной обработки запросов к базам данных, зависящих лишь от окружения точки запроса: сначала выделяется небольшое подмножество нужных точек, а затем на его основе выдается окончательный ответ на запрос. Итак, у нас есть множество точек p1, ..., pn, заданных своими координатами pi = (pi1, ..., pid) в d-мерном пространстве и некоторым образом хранящихся в структуре данных. Кроме того, у нас есть d-мерный запрос на поиск интервала [a1, b1[ × ··· × [ad, bd[, нам нужно вывести все входящие в него точки pi в диапазоне a1 ≤ pi1 < b1, ..., ad ≤ pid < bd за чувствительное к выходу время O(fd(n) + k) с очень медленно растущей функцией fd(n), где k – количество таких точек. Для поддержки запросов к такому типу структур было предложено множество решений. Для канонического разбиения интервалов очень удобна рекурсивная структура дерева прямоугольных областей, что было выяснено независимо в [63, 360, 377] и еще одном недоступном техническом отчете17. Идея дерева прямоугольных областей состоит в том, что для поиска в d-мерных прямоугольных областях сначала строится выровненное по первой координате дерево, и к каждому его узлу привязываются интервалы, в которые они входят, что позволяет сводить поиск среди d координат к поиску среди оставшихся d – 1 координат. Рис. 4.12. Рекурсивная структура трехмерного дерева прямоугольников: к каждому узлу такого дерева привязано двумерное дерево, к узлам которого, в свою очередь, привязано одномерное дерево Для такой структуры поиск d-мерного интервала [a1, b1[ × ··· × [ad, bd[ очень прост: сначала находятся O(log n) узлов канонического разбиения интервала [a1, b1[, и в каждом из них снова выполняется одномерный поиск меньшей размерности d – 1 в интервале [a2, b2[ × ··· × [ad, bd[. Каждая координата из интервала d-мерного поиска может оказаться только в одном из узлов, найденных при поиске для размерности d – 1. Все точки в таких узлах содержат только первую координату, принадлежащую интер17 D. E. Willard. Super-B-Tree Algorithm, Report TR-03-79, Aiken Computer Laboratory, Harvard Univer­ sity, USA, 1979. 180 Глава 4. Древовидные структуры на множестве интервалов валу [a1, b1[, поэтому первой координатой внутри этих узлов можно пренебречь. Теперь предположим, что есть r узлов, принадлежащих кано­ ническому разбиению интервала [a1, b1[, где r = O(log n), а узел ρ выдает k𝜌 совпадений за время O(fd−1(n) + k𝜌). Тогда общее время поиска канонического разбиения интервала для запросов в узлах размерности d – 1 – это r O(log n) + O(� 𝜌 = 1(fd−1(n) + k𝜌)). Поскольку суммарный размер выхода при r поиске – это сумма выходов всех подзадач k = � 𝜌 = 1 k𝜌, то общая чувствительность к выходу – O(fd(n) + k), где fd(n) = (O(fd–1(n)log n). Если для одномерного поиска в выровненном дереве используется любой метод поиска интервала из раздела 2.7, то f1(n) = O(log n), а fd(n) = O((log n)d). Таким образом, дерево прямоугольных областей – это статическая структура, обес­ печивающая d-мерный поиск на множестве d-мерных точек, с чувствительным к выходу временем O((log n)d + k), если выход состоит из k точек. Чтобы построить структуру для d ≥ 2, сначала нужно построить дерево по первой координате и добавить каждую из n точек во все из O(log n) узлов на пути поиска первой координаты. В каждом узле создается аналогичная древовидная структура размерности d – 1. Время построения такой структуры – О(n(log n)d), а требуемая память – O(n(log n)d −1), поскольку одномерная структура требует памяти порядка O(n). Теорема. Дерево прямоугольных областей – это статическая структура, поддерживающая d-мерный поиск прямоугольников в некотором диапазоне d-мерных точек за чувствительное к выходу время O((log n)d + k), если выход состоит из k точек. Дерево может быть построено за время O(n (log n)d) в памяти порядка O(n (log n)d −1). 6 3 1 0 {(0,1)} 1 {(1,5),(2,8)} {(1,5)} 8 {(0,1),(1,5),(2,8),(3,3),(5,0)} 5 {(0,1),(1,5),(2,8)} 2 {(0,1),(1,5),(2,8),(3,3),(5,0), (6,4),(7,6),(8,7),(9,9)} 2 3 7 {(3,3),(5,0)} {(3,3)} 5 {(5,0)} 6 {(6,4),(7,6),(8,7),(9,9)} 9 {(6,4),(7,6)} {(6,4)} 7 {(7,6)} 8 {(8,7),(9,9)} {(8,7)} 9 {(9,9)} {(2,8)} Рис. 4.13. Множество из девяти точек и дерево поиска по первой координате. Каждый узел обязательно включает первую координату интервала, а затем для каждого интервала создается дерево поиска по второй координате Поскольку многомерная структура определяется индуктивно с привязкой к ее узлам структур меньших размерностей, ее производительность можно улучшить в каждом из измерений, начиная с нижних уровней. Обычное одномерное дерево двоичного поиска не позволяет улучшений, особенно в модели на основе сравнений, как здесь: нижняя оценка времени сортировки Ω(n log n) ограничивает нижнюю оценку времени поиска в одномерной области до Ω(log n). Однако двумерную структуру можно улуч- 4.6. Деревья прямоугольных областей 181 шить со времени O((log n)2 + k) до O(log n + k), применив метод частичного каскадирования (fractional cascading) Уилларда18 и Харта19. Основная идея метода частичного каскадирования состоит в том, что когда нужно выполнить несколько поисков в разных, но связанных между собой множествах, то не нужно начинать каждый поиск заново, а достаточно создать связи между этими множествами так, чтобы можно было использовать информацию о предыдущем поиске для поиска в следующем множестве. Такие ситуации возникают в двумерном дереве прямоугольных областей, потому что при поиске второй координаты в каждом узле канонического разбиения интервалов вместе с первой координатой сохраняются интервалы и второй координаты. Частичное каскадирование встречается во многих алгоритмах и обсуждается в общих чертах в статьях [130, 131] и позже в статье [493]. В двумерном дереве прямоугольных областей поиск сам по себе не обес­ печивается нужными связями: множества в узлах канонического разбиения интервала не пересекаются, поэтому поиск в одном множестве не дает никакой информации о его положении в другом множестве. Но если вместе с ним ищется интервал и для второй координаты по пути поиска интервалов первой координаты, то попутно можно найти нужную нам структуру. В каждом узле хранящиеся в нем точки выстраиваются в список, сортированный по возрастанию второй координаты. Если узел не является лис­ том, то каждая точка в этом списке оказывается либо в списке левого потомка, либо в списке правого потомка. Каждая точка этого сортированного списка связывается: • с той же самой точкой левого или правого списка, когда она там есть; • с самой меньшей точкой второй координаты, когда ее в списке нет; • с первой точкой списка, когда точки с меньшей координатой вообще нет. На основе этой информации можно определить интервал корневого спис­ка по всем тем узлам, которые определяются его каноническим разбиением по первой координате. В каждом узле находится список, где есть начало и конец интервала, которые можно найти за время O(1) в списках левого или правого потомка узла, просто следуя по ссылкам между списками или, возможно, перейдя на один узел выше. Таким образом, если поиск выполняется на верхнем уровне за время O(log n), то на каждом нижележащем уровне требуется время порядка O(1). Здесь возможны два способа перехода на нижележащий уровень: либо просто идти по концам интервалов в поиске текущей точки, что требует времени O(1), либо идти по спис­ ку узла канонического разбиения интервала, что требует времени O(1 + k), 18 19 Обычно цитируется недоступный технический отчет 1978 года, вышедший раньше улучшенного варианта. Первым открытым источником была публикация 1985 года [553]. Еще один технический отчет – J. H. Hart. Optimal Two-Dimensional Range Queries Using Binary Range Lists, Technical Report 76–81, Department of Computer Science, University of Kentucky, USA, 1981. 182 Глава 4. Древовидные структуры на множестве интервалов если в нем будет найдено k элементов. В итоге на весь поиск потребуется время порядка O(log n + k), если выход состоит из k точек. Используя такую структуру для поиска в двумерных прямоугольных областях, при оценке его времени можно избавиться от одного множителя log n. Теорема. Деревья прямоугольных областей с частичным каскадированием – это статические структуры, которые поддерживают d-мерный поиск прямоугольников на множестве d-мерных точек, где d ≥ 2, за чувствительное к выходу из k точек время O((log n)d−1) + k). Такие деревья могут быть построены за время O(n(log n)d−1) в памяти порядка О(n(log n)d −1). 6 {(5,0), (0,1), (3,3), (6,4), (1,5), (7,6), (8,7), (2,8), (9,9)} 3 {(5,0), (0,1), (3,3), (1,5), (2,8)} 1 ({0,1), (1,5), (2,8)} 0 {(0,1)} 2 {(1,5), (2,8)} 1 {(1,5)} 5 {(5,0), (3,3)} 3 {(3,3)} 5 {(5,0)} 8 {(6,4), (7,6), (8,7), (9,9)} 7 {(6,4), (7,6)} 6 {(6,4)} 7 {(7,6)} 7 {(8,7), (9,9)} 8 {(8,7)} 9 {(9,9)} 2 {(2,8)} Рис. 4.14. Множество из девяти точек: дерево поиска прямоугольников с частичным каскадированием Чтобы описать дерево прямоугольных областей подробнее, приведем сначала код для общей рекурсивной структуры, а затем обсудим улучшение ее двумерного варианта за счет частичного каскадирования. В общем, d-мерное дерево прямоугольных областей представляет собой обычное выровненное дерево поиска со ссылкой в каждом узле на дерево размерности d – 1. Одномерное дерево поиска прямоугольных областей – это обычное выровненное дерево поиска интервала, как в разделе 2.7, поэтому оно вполне обеспечивает одномерный поиск. Итак, узел d-мерного дерева может выглядеть следующим образом: typedef struct tr_n_t { key_t key; struct tr_n_t *left; struct tr_n_t *right; struct tr_n_t *l_dim_tree; /* информация о выравнивании */ } tree_node_t; 4.6. Деревья прямоугольных областей 183 Хранящиеся в дереве точки представлены массивом координат с возможной ссылкой на связанные с ними объекты. Будем считать, что размерность массива задается некоторой глобальной константой. typedef struct { key_t coordinate[DIMENSION]; object_t object; } point_t; Для получения выходных данных определим следующий список. typedef struct p_ls_n_t { struct p_ls_n_t *next; point_t *point; } p_list_node_t; В рекурсивных алгоритмах координаты удобнее использовать в обратном порядке, то есть начиная с последней. Таким образом, каждая точка – это массив координат неизвестной длины, в котором ищется только первая координата. Ниже приводится рекурсивная процедура построения дерева прямо­ угольных областей по списку их точек. Сначала она создает список ключей по последней координате, сортирует их и строит по этим ключам дерево поиска с добавлением к ним значения –∞ и присоединением к каждому узлу дерева поиска списка точек нижележащего дерева меньшей размерности. После этого она (рекурсивно) вызывает саму себя, чтобы построить дерево нижнего уровня. Функция сортировки сортирует список ключей по самой последней координате и список точек по этому ключу, который прикрепляется к каждому узлу этого списка. Дерево поиска можно построить с помощью одной из функций раздела 2.8. tree_node_t *build_or_r_tree(p_list_node_t *pt_list, int dim) { if( pt_list == NULL ) return(NULL); /*дерево – пустое: ничего не делаем*/ else { tree_node_t *o_tree, *t_tmp, *key_list, *k_tmp; p_list_node_t *p_tmp, *p_tmp2; /* создать список ключей размерности dim */ key_list = NULL; p_tmp = pt_list; while( p_tmp! = NULL ) { k_tmp = get_node (); k_tmp->key = (p_tmp->point->coordinate)[dim]; p_tmp2 = get_p_list_node(); p_tmp2->point = p_tmp->point; p_tmp2->next = NULL; k_tmp->left = (tree_node_t *) p_tmp2; k_tmp->right = key_list; key_list = k_tmp; 184 Глава 4. Древовидные структуры на множестве интервалов p_tmp = p_tmp->next; } /* создать копию списка точек */ /* отсортировать и удалить дубликаты */ key_list = sort(key_list); if( dim >= 1 ) /* для декомпозиции интервалов нужен ключ -infty */ { k_tmp = get_node(); k_tmp->key = NEGINFTY; k_tmp->right = key_list; k_tmp->left = NULL; key_list = k_tmp; } /* создать дерево поиска */ o_tree = make_tree (key_list); /* сделать все нижележащие деревья пустыми */ create_stack(); push(o_tree); while( !stack_empty() ) { t_tmp = pop(); t_tmp->l_dim_tree = NULL; if( t_tmp->right != NULL ) { push (t_tmp->left); push (t_tmp->right); } } remove_stack(); if( dim == 0 ) return(o_tree); /* первое измерение закончено */ else /* нужно построить нижележащие деревья */ { /* добавить каждую точку, изначально прикрепив ее к списку узла */ while( pt_list != NULL ) { t_tmp = o_tree; /* дерево непустое */ while( t_tmp != NULL ) { p_tmp = get_p_list_node(); p_tmp->next = (p_list_node_t *) t_tmp->l_dim_tree; p_tmp->point = pt_list->point; t_tmp->l_dim_tree = (tree_node_t *) p_tmp; if( t_tmp->right != NULL && pt_list->point->coordinate[dim] < t_tmp->key ) t_tmp = t_tmp-> left; else t_tmp = t_tmp-> right; } /* прикрепленная точка к каждому узлу на своем пути поиска */ 4.6. Деревья прямоугольных областей 185 pt_list = pt_list-> next; /* перейти к следующей точке */ } /* создать деревья нижних уровней для всех узлов */ create_stack (); push(o_tree); while( !stack_empty() ) { t_tmp = pop(); if( t_tmp->l_dim_tree != NULL ) t_tmp->l_dim_tree = build_or_r_tree((p_list_node_t *) t_tmp->l_dim_tree, dim-1); if( t_tmp->right != NULL ) { push (t_tmp->left); push (t_tmp->right); } } remove_stack (); /* все */ return(o_tree); } } } Ключи интервала поиска [a1, b1[× ··· × [ad, bd[ задаются парой ссылок на массивы ключей a[] и b[]. При размерности больше 1 поиск основывается на каноническом разбиении интервала, который не раз использовался ранее. Разница лишь в том, что интервал поиска не обязательно охватывает все ключи дерева, поэтому левые и правые листья необходимо проверить, действительно ли значения их ключей попадают в интервал поиска. Для первого измерения используется иная, одномерная и приспособленная для этого случая функция из раздела 2.7. p_list_node_t *find_points_1d (tree_node_t *tree, key_t *a, key_t *b) { tree_node_t *tr_node; p_list_node_t *result_list, *tmp, *tmp2; result_list = NULL; create_stack(); push(tree); while( !stack_empty() ) { tr_node = pop(); if( tr_node->right == NULL ) { /* спустились к листу: его нужно проверить */ if( a[0] <= tr_node-> && tr_node->key < b[0] ) { /* нужно прикрепить все точки ниже этого листа */ tmp = (p_list_node_t *)tr_node->left; while( tmp != NULL ) 186 Глава 4. Древовидные структуры на множестве интервалов { tmp2 = get_p_list_node(); tmp2->point = tmp->point; tmp2->next = result_list; result_list = tmp2; tmp = tmp->next; } } } else if( b[0] <= tr_node->key ) push(tr_node->left); else if( tr_node->key <= a[0] ) push (tr_node->right); else { push (tr_node->left); push (tr_node->right); } } remove_stack(); return(result_list); } p_list_node_t *join_list(p_list_node_t *a, p_list_node_t *b) { if( b == NULL ) return(а); else { p_list_node_t *tmp; tmp = b; while( tmp->next != NULL ) tmp = tmp->next; tmp->next = a; return(b); } } p_list_node_t *find_points(tree_node_t *tree, key_t *a, key_t *b, int dim) { tree_node_t *current_node, *right_path, *left_path; p_list_node_t *current_list, *new_list; current_list = NULL; if( tree->left == NULL ) exit(-1); /* пустое дерево */ else if( dim == 0 ) return(find_points_1d (tree, a, b)); else { current_node = tree; right_path = left_path = NULL; 4.6. Деревья прямоугольных областей 187 while( current_node->right != NULL ) /* не лист */ { if( b[dim] < current_node->key ) /* идем влево: a < b < key */ current_node = current_node->left; else if( current_node->key < a[dim] ) /* идем вправо: key < b < a */ current_node = current_node->right; else if( a[dim] < current_node->key && current_node->key < b[dim] ) /* разделить узел: a < key < b */ { right_path = current_node->right; /* оба - правые */ left_path = current_node->left; /* и левые */ break; } else if( a[dim] == current_node->key ) /* a = key < b */ { right_path = current_node->right; /* левых нет */ break; } else /* current_node->key == b, т.е. a < key = b */ { left_path = current_node->left; /* правых нет */ break; } } if( left_path != NULL ) { /* теперь идем по левому пути до конечной точки а */ while( left_path->right != NULL ) { if( a[dim] < left_path->key ) { /* должен быть выбран правый узел */ new_list = find_points(left_path->right-l_dim_tree, a, b, dim-1); current_list = join_list(new_list, current_list); left_path = left_path->left; } else if (a[dim] == left_path->key) { new_list = find_points(left_path->right->l_dim_tree, a, b, dim-1); current_list = join_list (new_list, current_list); break; / * ниже спускаться не нужно */ } else /* узел не выбран: идем вправо */ 188 Глава 4. Древовидные структуры на множестве интервалов left_path = left_path->right; } /* нужно выбрать левый лист, если он достигнут в спуске */ if( left_path->right == NULL && left_path->key == a[dim] ) { new_list = find_points(left_path->l_dim_tree,a, b, dim-1); current_list = join_list(new_list, current_list); } } /* конец левого пути */ if( right_path != NULL ) { /* теперь идем вправо до конечной точки b */ while( right_path->right != NULL ) { if( right_path->key < b[dim] ) { /* нужно выбрать левый узел */ new_list = find_points(right_path->left->l_dim_tree, a, b, dim-1); current_list = join_list(new_list, current_list); right_path = right_path->right; } else if( right_path->key == b[dim] ) { new_list = find_points(right_path->left->l_dim_tree, a, b, dim-1); current_list = join_list(new_list, current_list); break; /* дальнейший спуск не нужен */ } else /* узел не выбран: идем влево */ right_path = right_path->left; } if( right_path->right == NULL && right_path->key < b[dim] ) { new_list = find_points(right_path->l_dim_tree, a, b, dim-1); current_list = join_list(new_list, current_list); } } /* конец правого пути */ } return(current_list); } Обратите внимание, что здесь результаты подзадач всегда добавляются только в начало итогового списка. Для этого нужно пройти по одному из них до самого конца, но если на каждом уровне рекурсии проходить до конца списка лишь новых результатов, то затраты времени будут O(k) при k результатах. Альтернативный способ – добавлять ссылки подзадач низшего уровня в начало и в конец итогового списка. Поиск двумерного интервала в дереве с частичным каскадированием гораздо сложнее. Сначала в нем выполняется поиск узлов интервалов по первой их координате. С каждым узлом такого дерева связана структура поиска второй координаты, но при частичном каскадировании они связываются только по мере необходимости, поэтому поиск выполняется лишь 4.6. Деревья прямоугольных областей 189 на множестве, связанном с первым узлом, и потом эта информация может использоваться для последующего поиска. Для этого достаточно привязать к каждому узлу первого дерева связный список всех упорядоченных по возрастанию координат точек из множества вторых точек этого узла, а также две ссылки на списки нижележащих узлов. Помимо этого, понадобится дерево поиска второго уровня, чтобы путем частичного каскадирования найти конечные координаты точек интервала в списке всех точек. Пока были приведены алгоритмы поиска именно для такой структуры данных. Для ее построения все значения координат сводятся в список по последней координате, и по их ключам строится дерево поиска. К каждому узлу такой структуры должна быть привязана нижележащая структура поиска всех тех точек, последняя координата которых находится в интервале, связанном с этим узлом. Таким образом, каждая точка может оказаться только в узлах на пути поиска ее последней координаты, а время размещения n точек в узлах – O(n log n). После этого нужно пройти по всем узлам и таким же образом построить для каждого из них структуру поиска нижележащего уровня, пока не будет достигнут самый первый уровень в простейшей структуре или второй уровень в структуре с частичным каскадированием. Для одномерной структуры просто строится обычное выровненное дерево поиска, листья которого представляются в виде связного списка. Это требует времени O(n log n). Для двумерной частично каскадированной структуры сначала строится дерево поиска по вторым координатам, листья которого образуют связный список, а затем строится дерево поиска по первым координатам. Список листьев дерева вторых координат привязывается к корню дерева первых координат. Потом выполняется спуск по дереву первых координат, и каждый связанный с его узлом список целиком копируется, а его элементы распределяются по списку нижележащего узла дерева вместе со ссылками из копии или их потомков нижних уровней. Поскольку глубина дерева O(log n) и каждая из n точек на каждом уровне оказывается только в одном списке, общее время построения такой структуры составляет O(n log n). С учетом рекурсии время построения такой простейшей структуры – O(n (log n)d), а при частичном каскадировании – O(n (log n)d−1). Описанная структура – статическая. Используя метод частичной перестройки, ее можно сделать динамической, правда, с усредненными границами времени [189, 379, 450]. Если же нужен не список точек в интервале, а только их количество, то структуру можно сделать полностью динамической с обновляемыми границами в худшем случае [556]. Если же нас интересует лишь количество точек в интервале, то именно эту величину и нужно хранить в узлах дерева, что проще, поскольку количества можно просто складывать и вычитать. Максимальное количество поисков различных интервалов на множестве из n точек в d-мерном пространстве изучалось 1 2d 1 2(2d)! 2d в [485]; оно колеблется в пределах от —d—— до ———— n2d + O(n2d−1). 2d n 190 Глава 4. Древовидные структуры на множестве интервалов 4.7. Деревья многомерных интервалов В предыдущем разделе рассматривалась задача поиска прямоугольной области, когда для заданного множества из n точек и заданной области поиска (d-мерного интервала) выдается список точек, находящихся внутри этой области. Но имеет место и обратная задача: для заданного множества из n областей (d-мерных интервалов) и заданной точки нужно найти все содержащие эту точку области. Эту задачу можно решить с помощью дерева d-мерных интервалов прямым обобщением дерева интервалов. Как и дерево прямоугольных областей, дерево d-мерных интервалов определяется рекурсивно. Сначала строится выровненное дерево поиска, ключи которого являются первыми координатами d-мерных интервалов, а каждый узел этого дерева соответствует интервалу меньшей размерности (d – 1), определяемому узлом *n. Все эти d-мерные интервалы [ai1, bi1[ × ··· × [aid, bid[ хранятся в структуре, *n является частью канонического разбиения интервала [ai1, bi1[. Поскольку первая координата может иметь не более чем 2n ключей, каноническое разбиение интервала имеет размер O(log n), а каждый d-мерный интервал хранится в O(log n) деревьях интервалов размерности (d – 1). Таким образом, необходимая память для построения d-мерного дерева из n интервалов – O(n (log n)d). Сначала ищется первая координата искомой точки, а в каждом узле выполняется поиск остальных координат в связанном с этим узлом дереве размерности (d − 1). Согласно свойству канонического разбиения интервала, любой d-мерный интервал содержит искомую точку только в одной из связанных структур. Поскольку путь поиска пролегает по O(log n) узлам, то в каждом из них выполняется поиск по интервалам размерности (d – 1) с чувствительным к выходу временем O((log n )d−1 + kj ), если количество выводимых интервалов равно kj. По индукции следует, что время поиска по d-мерному дереву из n интервалов чувствительно к выходу O((log n)d + k). Это опять-таки статическая структура, которую можно сделать динамической с частичной ее перестройкой и средней оценкой ее производительности. Теорема. Структура дерева d-мерных интервалов – статическая. Ее можно построить за время порядка O(n (log n)d) и с затратами памяти порядка O(n (log n)d). Она выводит список всех содержащих заданный ключ d-мерных интервалов с чувствительным к выходу временем O((log n)d + k), если найдено k таких интервалов. Кроме того, этот метод позволяет улучшить и двумерный вариант, так как снижает время поиска с O((log n)2 + k) до O(log n + k). Он может использоваться также в рекурсивной конструкции d-мерной структуры, чтобы получить чувствительное к выходу время O((log n )d −1 + k) поиска при d ≥ 2. Эта структура – S-дерево – была разработана в [542] и тоже использует метод частичного каскадирования. 4.7. Деревья многомерных интервалов 191 Чтобы описать этот метод для двумерной задачи, начнем с описанного ранее дерева двумерных интервалов. Сначала поиск интервала по дереву ведется по первой координате qx искомой точки, и в каждом из O(log n) узлов на этом пути ищется интервал по второй координате qy и выводятся все найденные на этом пути прямоугольники. Таким образом, поиск второй координаты выполняется вниз по дереву от корня к листу и выводятся находящиеся в его узлах прямоугольники. Если знать точно, к какому именно листу мы придем, то можно было бы пройти по тому же пути назад, вверх от листа к корню. И это было проще, потому что вторая координата qy всегда находится в одном и том же единственном листе. Таким образом, во-первых, нужно направить каждое дерево второй координаты назад от листа корню и объединить их согласно дереву первой координаты. После этого нужно пройти по O(log n) деревьев второй координаты и вывести по пути вверх все найденные прямоугольники. По пути вверх по ссылкам для этих O(log n) деревьев пустые узлы можно пропус­ кать, поэтому затраченное на i-й путь время будет равно O(1 + ki), где ki – количество найденных на этом пути прямоугольников. Отсюда следует, что общее чувствительное к выходу время поиска будет порядка O(log n + k), где k =�i ki, поскольку просматривается только O(log n) листьев вторых координат за время O(log n) вместо O((log n)2), необходимого для просмотра каждого листа дерева по отдельности. Проблема здесь состоит в том, что, несмотря на это, в каждом дереве нам нужно найти лист со второй координатой qy, некоторые поддеревья могут выглядеть несколько иначе, так как в них могут отсутствовать ключи вторых координат прямоугольников, которые добавляются в это дерево. Таким образом, может случиться так, что при спуске вниз по дереву первой координаты некоторые из координат деревьев второй координаты будут иметь только один лист с ключом, бóльшим, чем искомый. Если же нужно перейти за время O(1) от одного листа, содержащего qy, к соответствующему листу следующего дерева, то связанный с предыдущим листом интервал должен пересекаться в следующем дереве только с O(1) интервалами листьев. Поскольку интервалы листьев – это интервалы между последовательными ключами в дереве, этого можно достичь, если множество встречающихся в дереве вторых нижележащих координат ключей будет подмножеством ключей вышестоящего дерева. В таком случае каждый интервал листа вышестоящего дерева становится уникальным для интервала листа нижестоящего дерева, и можно просто создать ссылку от листа вышестоящего дерева к листу каждого из двух нижестоящих поддеревьев по каждой из координат вышестоящего дерева. Чтобы добиться этого, нужно сохранять вторые координаты каждого прямоугольника не только в деревьях вторых координат, где они находятся согласно каноническому разбиению интервала первой координаты, но и в вышестоящих деревьях первых координат. Правда, на каждом уровне есть два узла, в которых задается сам прямоугольник, поэтому каждый из 192 Глава 4. Древовидные структуры на множестве интервалов них встречается по-прежнему в O(log n) деревьях вторых координат, куда каждый из них вносит вклад O(log n), так что общий размер структуры по-прежнему составляет всего лишь O(log n), а время поиска, как описано ранее, – O(log n + k). Конструкция, к которой мы пришли, теперь такова: для заданных прямоугольников [ai,, bi[ × [ci, di[, где i = 1, ..., n: 1. Построить выровненное дерево поиска 𝒯1 для {a1, b1 , a2, b2, … ,an, bn}. 2. Присоединить к каждому узлу v этого дерева изначально пустое дерево поиска 𝒯2(v) второго уровня. 3. Для каждого i = 1, ..., n: 3.1. Начав с корня дерева 𝒯1, поместить его в стек и повторять эту операцию до тех пор, пока он не станет пустым. 3.2. Извлечь текущий узел v из стека. Вставить {ci , di} как ключ в дерево 𝒯2(v). Если интервал текущего узла v не входит в интервал [ai, bi[, то проверить, что интервалы v->left и v->right не пересекаются с интервалом [ai, bi[. Если пересекаются, то поместить их в стек. 4. Для каждого i = 1, ..., n: 4.1. Вставить прямоугольник [ai,, bi[ × [ci, di[ в дерево 𝒯2(v) для всех тех узлов v, которые входят в каноническое разбиение интервала [ai,, bi[ в дереве 𝒯1. 5. Для каждого узла v из 𝒯1: 5.1. Создать ссылки от каждого листа 𝒯2(v) к соответствующим листьям 𝒯2(v->left) и 𝒯2(v->right). 6. Для каждого узла v из 𝒯1: 6.1. В каждом узле w дерева 𝒯2(v) создать ссылку на вышестоящий узел, с которым связан некоторый прямоугольник. Эта структура – довольно сложная. Суть ее в том, что каждая пара {ci , di} на шаге 3 добавляется только O(log n) раз, поэтому деревья второго уровня 𝒯2(v) вместе могут иметь только O(n log n) узлов. Каждый прямоугольник может быть повторно добавлен не более чем в O((log n)2) списков узлов, так что общие размеры памяти и времени предварительной обработки не превысят O(n(log n)2). Здесь главное влияние оказывает время построения на шаге 4, а для всех остальных шагов требуется время O(n log n). Подводя итог производительности такой структуры, можно сделать следующее заключение: Теорема. S-дерево – это статическая структура данных для поиска в множестве из n прямоугольников с затратами памяти и времени предварительной обработки порядка O(n (log n)2). Она позволяет 4.8. Блочные структуры данных 193 выдать все k включающих заданную точку прямоугольников за чувствительное к выходу время O(log n + k). 4.8. Блочные структуры данных Во многих предыдущих алгоритмах в деревьях поиска использовалось каноническое разбиение интервала, состоящего из множества чисел. Его основная идея состоит в том, чтобы разбить большой интервал на меньшие блоки. И если нужно найти некоторый разбитый на блоки интервал, то дальнейший его поиск можно продолжить только в одном из его блоков (рис. 4.15). 1 2 3 4 5 6 7 8 9 10 11 12 13 14 1 2 3 4 5 6 7 8 9 10 11 12 13 14 1 2 3 4 5 6 7 8 9 10 11 12 13 14 1 2 3 4 5 6 7 8 9 10 11 12 13 14 2 3 4 5 6 7 9 10 12 14 11 13 Рис. 4.15. Каноническое разбиение интервала {1, ..., 14}: каждый интервал можно представить в виде объединения пяти блоков Это значит, что можно вести поиск поблочно, а затем поблочно восстановить результат поиска по всему интервалу20. Кроме того, нужна структура, выполняющая поиск в заданном блоке. И наконец, должна быть возможность представить каждый интервал в виде объединения лишь небольшого количества блоков. Такой подход похож на поиск прямоугольников. При каноническом разбиении интервала необходимо только n различных блоков размера O(n log n), а для поиска следующего интервала требуется время O(log n) в нижележащем блоке. Существуют различные варианты создания блоков: если нужно свести поиск интервала к небольшому количеству блоков, то их будет много, и для каждого из них нужно будет построить структуру для получения результата. В самом крайнем случае можно построить структуру для каждого возможного интервала поиска. Например, для прямоугольников количество всех возможных интервалов поиска первых координат равно �n2� = Θ(n2), для каждой из которых можно построить структуру поиска нижнего уровня. В таком случае на самом нижнем уровне потребуется всего один поиск вместо Θ(log n), но нужно гораздо больше времени для предварительного построения структур нижних уровней. Эта идея впервые использовалась в [66] для поиска d-мерных прямо­ угольников, где ее авторы показали, что можно достичь чувствительного 20 Это отличается от обсуждаемых в разделе 7.1 задач разбиения поиска. Там разбивается все множество искомых элементов, а здесь – только отдельный интервал. 194 Глава 4. Древовидные структуры на множестве интервалов к выходу времени поиска порядка O(f (d, ε) log n + k) со временем предварительной обработки порядка O(n1 + ε). Эта идея может быть применена ко многим другим задачам, хотя их детали, конечно же, зависят от того, как выстраиваются блоки. Чтобы описать метод подробнее, заметим сначала, что не нужно иметь дело с n произвольными значениями координат; можно считать, что они равны 1, ..., n. Такая нормализация достигается за счет того, что дерево поиска координат строится так, что сами координаты при поиске приводятся к их рангу, то есть координата i получает самый наименьший ранг. Время поиска увеличивается на O(log n), но поскольку оно не превышает Ω(log n), это не имеет существенного значения. Описанная в [66] система блоков – это структура с r уровнями, которую 1 можно считать записью чисел по основанию n—r . На верхнем уровне блоки представляют интервалы 1 1 — 1 �an1−—r , bn1−—r � при 0 ≤ a < b ≤ n r . На уровне j блоки представляют интервалы j j −1 j 1 — j −1 j −1 �an1−—r + cn1− ——r—, bn1−—r + cn1− ——r— � при 0 ≤ a < b ≤ n r и 0 ≤ c < n ——r— , для 2 ≤ j ≤ r. 1 — r Это составляет примерно �n2 � = O(n r ) блоков верхнего уровня, размер j j +1 которых не превышает n, а остальные n1−—r �n2 � = O(n ——r— ) блоков на уровне j j −1 не превышают размера n1− ——r—. Чтобы получить результат поиска, нужно не более одного блока на верхнем уровне и двух блоков на каждом из нижних уровней, что дает в общей сложности 2r – 1 поисков в блоках. Если время построения структуры для поиска результата в блоке размера m составляет preproc(m)21, то общее время построения этой структуры составит 2 — 1 — r r preproc(n) j +1 j −1 2 O� �n ——r— preproc�n1− ——r— �� = O�rn1+—r −−−−−−−−−−−−�. n j=1 В случае поиска d-мерных прямоугольных интервалов [66] любое нормальное выровненное дерево поиска позволяет получить структуру, выполняющую одномерный чувствительный к выходу поиск за время порядка O(log n + k) со временем предварительной обработки preproc1(n) = O(n log n). Если использовать такую структуру с r уровнями при поиске возможных интервалов по второй координате, то получится структура, выполняю­ щая двумерный поиск за чувствительное к выходу время O(r log n + k) 2 со временем предварительной обработки preproc2(n) = O(rn1+—r log n). При поиске возможных интервалов по третьей координате снова используется структура с r уровнями, а также двумерная структура для поиска в каждом блоке третьей координаты по первым двум. В итоге получается 21 Это функция предварительной обработки блока. – Прим. перев. 4.9. Подсчет точек и модель полугруппы 195 структура, выполняющая трехмерный поиск с чувствительным к выходу временем O(r2 log n + k) и требующая предварительной обработки со вре4 менем preproc3(n) = O(r2 n1+—r log n). Повторяя такую конструкцию, получается структура, выполняющая поиск d-мерного прямоугольника за время 2d −2 O(r2 log n + k) и требующая времени предварительной обработки O(rdn1+——r——). Теперь r выбирается настолько большим, чтобы получить чувствительное к выходу время поиска O(f (d, ε) log n + k) со временем предварительной обработки O(n1+ ε). К сожалению, этот метод хорош только для очень больших п, потому что множители в оценках времени O(·) очень велики [201]. Этот метод можно применять и к другим интервальным задачам. Например, метод разбиения на несколько блоков при поиске домена и предварительная их обработка не ограничиваются только их интервалами. Однако при поиске домена должна быть возможность быстро найти доменный блок и определить поблочный результат поиска. 1 2 3 4 5 6 7 8 9 10 11 1 2 3 4 5 6 7 8 9 10 11 5 6 7 8 9 10 5 6 7 8 9 5 6 7 8 5 6 7 8 5 6 7 6 7 8 6 7 8 6 7 6 7 1 2 3 4 1 2 3 1 2 3 2 3 4 2 3 4 2 3 2 3 1 1 4 4 5 5 12 13 14 11 12 13 14 10 11 12 13 14 9 10 11 9 10 11 12 13 14 9 10 12 13 9 10 11 10 11 12 13 14 13 14 Блоки верхнего уровня Блоки второго уровня 8 Рис. 4.16. Интервалы в двухуровневой структуре Бентли–Маурера {1, …, 14}: каждый интервал представлен как объединение трех блоков 4.9. Подсчет точек и модель полугруппы В задаче подсчета точек (range-counting) выдается не список точек внутри (прямоугольной) области, а только их количество. Поскольку на выходе задачи – всего одно число, то при оценке ее сложности в чувствительном к выходу слагаемом нет смысла. В этой задаче вместо объединения списков можно использовать идею прямоугольных областей, когда вычисленные в подзадачах количества точек можно просто суммировать при каноническом разбиении интервала. Эту идею легко обобщить, придав точкам 196 Глава 4. Древовидные структуры на множестве интервалов веса, а при поиске оценивать либо сумму весов точек внутри области, либо их максимальный вес. Итак, если определена коммутативная полугруппа (скажем, по сумме весов или по их максимумам), а каждая точка имеет соответствующее ей значение, то точно таким же образом можно определить и сумму в полугруппе всех точек внутри области поиска, построив каноническое разбиение интервала поиска по первой его координате и продолжив поиск на следующих уровнях с вычислением сумм по полугруппам. При одномерном поиске вычисляется просто количество ключей внутри интервала или их сумма в полугруппах, которую можно получить прямо по каноническому разбиению интервала, если значения ключей хранятся в узлах дерева. Особый случай – поддержка массива a1, ..., an вместе с его частичными суммами ai + ··· + aj при обновлениях его элементов аk. Эта задача изучалась для различных вариантов моделей [219, 222, 562, 565, 269, 104, 461]. Задача подсчета точек внутри области обладает двумя интересными свойствами, отличающими ее от задачи поиска области. Во-первых, она позволяет сделать структуру динамической, чтобы обновлять ее и выравнивать дерево, что было невозможно в задаче поиска области, так как привязанные к узлам дерева структуры были слишком велики и их приходилось снова перестраивать, а при подсчете точек нужно просто пересчитать одно число, начав с потомков этого узла. Это было отмечено в [377, 552–556]22. В такой структуре все операции insert, delete и range_count на множестве из n точек выполняются за среднее время O((log n)d). Но еще более интересное свойство заключается в том, что в такой структуре можно оценить нижнюю границу сложности любого алгоритма. В модели поиска области чувствительное к выходу слагаемое скрывает некоторые эффекты. Оценка нижних границ началась с работ [219–221, 562], где к этой модели предъявлялись дополнительные и гораздо более строгие, но несколько отличавшие­ся друг от друга требования. Правда, результаты показали, что их детали имели существенное значение. В модели Фредмана [220] для динамической структуры задачи подсчета точек, начиная с пустого множества, для некоторой последовательности из n операций insert, delete и range_count оценка времени их выполнения для любой коммутативной полугруппы – Ω(n (log n)d). Для худшего случая там же приводилась структура со временем выполнения тех же операций – O((log n)d). Однако его модель отличается от ссылочных машин или алгебраических деревьев решений, так как в них допускаются только арифметические операции определенного типа. Например, если для статического массива a1 , ..., an с вычислением частичных сумм его подмассивов ai + ··· + aj нас не тяготит дополнительная память и время поиска, то существует простой алгоритм с n ячейками дополнительной памяти и дополнительным временем поиска O(1). Если допускается операция вычитания, то просто сохраняются все частичные суммы, начиная с a1, то есть ai + ··· + aj = (a1 + ··· + aj) – (a1 + ··· + ai − 1). Но если операция вычитания при поиске недопустима и алгоритм позволяет до22 В упомянутых ранее в сноске 8 технических отчетах. 4.10. kd-деревья и связанные с ними структуры 197 бавлять дополнительную память, содержащую только неотрицательные линейные комбинации ai, то оценка сложности поиска на основе обратной функции Аккермана и п дополнительных ячеек памяти приводится в [562]23. Но тогда сложность такого алгоритма оценивается только количеством арифметических операций без учета времени обращения к дополнительной памяти для вычисления частичных сумм. Таким образом, в этом случае сложность несопоставима с иными оценками границ сложности. Самая важная из таких работ – это работа [221], где был предложен общий метод оценки сложности для задачи с динамическим поиском и подсчетом точек для определенного класса арифметических моделей. С тех пор было предложено множество других моделей; обзор оценок их нижних границ приведен в [460]. Различные компромиссы между затратами памяти и временем поиска в статических d-мерных моделях рассматривались в [541, 128, 129], а также в моделях совсем иного типа [274, 337, 484]. 4.10. kd-деревья и связанные с ними структуры kd-дерево – еще одна структура для поиска прямоугольников. Она довольно популярна и проста для понимания и реализации, но плоха из-за того, что в худшем случае ее производительность значительно ниже производительности деревьев поиска прямоугольников. Для двумерной ее версии оценка времени поиска в худшем случае – O(√n + k) вместо O((log n)2 + k), 1 а для d-мерного аналога эта оценка еще хуже – O(n1−—d + k) вместо O((logn)d + k). На практике ее производительность на примерах баз данных кажется лучше, чем ее теоретическая сложность для худшего случая, поэтому в литературе по базам данных эта и связанные с ней структуры широко изучались и использовались. kd-дерево было описано Бентли в 1975 году [62]24. Это прямой аналог обычного выровненного дерева поиска, которое выглядит как одномерное дерево, а под kd-деревом изначально понималось k-мерное (k-dimensional) дерево. Нижняя граница времени поиска была оценена в 1977 году Ли и Вонгом [359], а первый сравнительный анализ различных структур поиска интервалов, включая kd-деревья, деревья поиска прямоугольников (раздел 4.6) и структуры Бентли–Маурера (раздел 4.8), был дан в работе Бентли и Фридмана [65]. В худшем случае время поиска в kd-дереве гораздо больше, чем в других структурах. Но при определенных и довольно строгих условиях (таких как равномерное распределение точек и небольшие, «относительно квадратные» прямоугольники) производительность kd-деревьев становится сопоставимой с другими структурами. Квадратные пря23 24 В техническом отчете «N. Alon and B. Schieber. Optimal Preprocessing for Answering On-line Product Queries, Tel Aviv University, Israel, 1987» приводится подобный результат для частичного произведения статической последовательности элементов полугруппы с применением еще одной обратной функции Аккермана. Вторая премия в конкурсе лучших студенческих работ ACM. 198 Глава 4. Древовидные структуры на множестве интервалов моугольники поиска возникают, когда поиск нацелен именно на ближайшего потомка или, по крайней мере, на некоторую окрестность искомой точки. Различные структуры kd-деревьев исследовались во многих статьях, но с дополнительными требованиями к распределению точек на входе и к поис­ку [499, 158, 246, 185, 126, 186]. Другие аспекты классической структуры kd-дерева исследовались в [500, 282]. С целью добавления/удаления точек было приложено множество усилий, чтобы сделать kd-деревья динамическими [478], полудинамическими [64], разделенными [340], O-деревьями [475] и особыми структурами [261]. В этих структурах внимание уделялось также эффективности работы с внешней памятью. Связанные с ними последующие структуры с поддержкой различных типов поиска ограниченных областей разрабатывались сообществом, работающим с базами данных [266, 55, 375, 232, 6, 81, 36, 468, 482, 483, 238, 429]. Идея kd-дерева состоит в том, что у нас есть дерево поиска, где в каждом узле можно выполнить сравнение ключа и войти в левое или правое поддерево, но в отличие от обычных деревьев поиска здесь можно сравнивать ключи в разных узлах и по разным координатам. Проще всего проходить по координатам циклически: в корне выполняется сравнение по первой координате, в лежащих ниже узлах – по второй и т. д. В каждом узле для сравнения выбирается тот ключ, который делит множество точек ниже этого узла выровненно. Таким способом, как и в обычных деревьях поиска, для каждого узла определяется интервал, который здесь является d-мерным полуоткрытым прямоугольником, то есть множеством всех возможных искомых точек, путь поиска к которым проходит через этот узел. После сравнения ключа в узле прямоугольная область делится гиперплоскостью в направлении той координаты, которая использовалась при сравнении. Таким образом получается иерархия, возможно, неограниченных прямоугольников. В двумерной версии эти прямоугольники чередуются горизонтально и вертикально (рис. 4.17). Рис. 4.17. Множество из девяти точек в виде kd-структуры: все прямоугольники полуоткрыты справа и сверху 4.10. kd-деревья и связанные с ними структуры 199 В такой структуре поиск области выполняется так же, как в одномерном случае: начиная с корня, выполняется спуск по тем узлам, чьи интервалы имеют непустое пересечение с искомой областью. Спуск прекращается в той ветви, где это пересечение становится пустым. Этот довольно общий и естественный алгоритм можно применять для поиска не только прямо­ угольников, но и любой области. Структура такого типа имеет сильные стороны, но она не очень эффективна, так как количество пройденных (даже безрезультатно) листьев может оказаться гораздо больше, чем Ω(√n). Эта проблема возникает не только для «плохих» множеств точек или подструктур. Она возникает всегда, когда искомый прямоугольник пересекается с Ω(√n) ячейками, не содержащими ни одной точки основного множества (рис. 4.18). Рис. 4. 18. Правильное деление kd-дерева с «плохим» искомым прямоугольником: все точки оказываются за пределами прямоугольника Теперь предположим, что все координаты точек различны, а kd-дерево построено так, что в каждом его узле ключ делит количество точек в обоих поддеревьях как можно более равномерно и по горизонтали, и по вертикали. Тогда дерево имеет высоту ⌈log n⌉. Чтобы показать, что оценка времени поиска в таком дереве в худшем случае – O(√n + k), нужно ограничить количество посещаемых при поиске узлов. 200 Глава 4. Древовидные структуры на множестве интервалов Посещаются только те узлы, чей прямоугольный интервал пересекается с искомым прямоугольным интервалом. Среди них немного таких, чей интервал содержит искомый, – не более одного на каждом уровне дерева, и на каждом из них выполняется плоскостное деление. Поскольку дерево имеет высоту ⌈log n⌉, таких узлов – O(log n). Попадающие в искомый интервал интервалы узлов потенциально больше, и каждый из них добавляет хотя бы одну точку в результат поиска, поэтому таких узлов может быть не более k. Единственная проблема – это те узлы, чей интервал перекрывается с искомым лишь частично. Такие узлы пересекают лишь одну из сторон искомого интервала, поэтому можно сократить количество таких узлов в четыре раза по сравнению с максимальным, когда параллельный оси координат участок искомого интервала разбивается на части интервалами узлов. Пусть ai – количество таких узлов на уровне i. Поскольку разрезы чередуются с горизонтальных на вертикальные, на каждом втором уровне их количество вообще не увеличивается, а на остальных уровнях их количество может всего лишь удвоиться, то ai ≤ 2⌊(1/2)i⌋ и a0 + a1 + ··· +alog n ≤ 2 · 2(1/2)log n = O(√n). Отметим, что при этом требуется именно оптимальная высота ⌈log n⌉, так как более слабый критерий равновесия в узлах с высотой O(log n) будет недостаточен, чтобы добиться суммарной оценки O(√n). При такой высоте необходимо, чтобы всегда можно было разделить множество точек почти на равные части в предположении, что все координаты различны. Это строгое предположение можно отменить, снабдив каждый узел троекратным сравнением на равенство, поскольку всегда можно выбрать ключ сравнения так, чтобы операции «<» и «>» охватывали не более половины оставшихся точек, а операция «=» сводилась к одномерной задаче, которая решается за время O(log n). Чтобы доказать, что оценка O(√n) не может быть улучшена, нужно показать, что всегда существует искомый прямоугольник, пересекающий Ω(√n) листовых интервалов и не содержащий ни одной точки. Снова последуем предыдущему аргументу. Возьмем любую горизонтальную или вертикальную линию, и пусть bi – количество узлов на уровне i дерева, пересекающих эту линию. Тогда b2 = 2 и bi+2 = 2bi для i + 2 < log n. Таким образом, на уровне листа мы имеем blog n = Ω(√n). Если вокруг этой линии выбрать очень тонкий искомый прямоугольник, то он получится довольно плохим и вынудит посещать Ω(√n) листьев, вообще не содержащих искомой точки (рис. 4.19). Рис. 4.19. Горизонтальная линия отделяет 2i клеток на уровне 2i 4.10. kd-деревья и связанные с ними структуры 201 Подводя итог, производительность структуры для d-мерного случая такова: Теорема. kd-деревья – это статическая структура, поддерживающая поиск в d-мерных прямоугольных областях на множестве d-мерных 1 точек с чувствительным к выходу из k точек временем O(n1−—d + k). Такие деревья можно построить за время порядка O(n (log n)) и с затратами памяти порядка O(n). Глава 5 Кучи Кучи – это второй после деревьев поиска наиболее изученный тип данных. Такую абстрактную структуру называют еще очередями с приоритетами, объекты которых имеют ключ (приоритет) и поддерживают операции добавления (insert), поиска (find_min) и удаления (delete_min) объекта с минимальным ключом. Таким образом, в отличие от деревьев поиска, в кучах недопустимы операции произвольного поиска и удаления, но есть возможность сменить минимальный ключ на максимальный. Первый тип кучи называется минимальной кучей (min-heap), а второй – максимальной кучей (max-heap). Если же необходимы оба типа операций, структура становится несколько более сложной и называется двусторонней (doubleended) кучей. Структура кучи была впервые описана в 1964 году Уильямсом [557]25 для особого способа сортировки, хотя он описал ее как новую структуру данных с возможностью дальнейших применений. И только гораздо позже выяснилось, что кучи имеют множество других значительно более важных применений. При этом сортировка здесь имеет важное значение, так как нижняя граница ее сложности Ω(n log n) для n объектов определяет нижнюю границу сложности операций с кучей. При добавлении объектов в кучу ее можно отсортировать, а затем выполнять операции find_min и delete_min для обнаружения ее объектов, отсортированных в возрастающем порядке. Таким образом, сортировка n объектов возможна для любой из операций insert, find_min и delete_min, поэтому по крайней мере одна из них (в рамках модели сравнения) будет иметь сложность Ω(log n). Существует взаимосвязь между сложностью сортировки кучи и операциями с кучей во многих моделях – даже в тех, где основанная на сравнении нижняя граница сложности сортировки имеет иное значение [534]. Способы реализации куч различаются главным образом только поддерживающими их дополнительными операциями. Наиболее важная из них – слияние куч (с объединением множеств их объектов), иногда называемая также объединением со сменой ключа объекта (обычно в сторону его уменьшения), что требует дополнительной ссылки («пальца») на объект структуры. 25 В этой связи обычно ссылаются на статью Флойда 1964 года [214], но он всего лишь ввел внут­ реннюю сортировку кучи, улучшив свой алгоритм Treesort [213] 1962 года, чуть ранее усовершенствованный в работе [311]. 5.1. Куча как выровненное дерево 203 Наиболее интересные приложения куч – это все виды очередей событий, так как они встречаются в самых различных приложениях: развертке в вычислительной геометрии, в системах с дискретными событиями [197], в планировщиках и во многих классических алгоритмах вроде алгоритма кратчайшего пути Дейкстры. 5.1. Куча как выровненное дерево Поскольку мы уже подробно изучили выровненные деревья поиска, легко видеть, что они тоже поддерживают операции с кучей. Они имеют ту же лежащую в их основе абстрактную структуру и множество объектов с их ключами. Но вместо операций find и delete с произвольными ключами объектов куче нужны операции find и delete с наименьшим или наибольшим ключом. Чтобы найти объект с наименьшим ключом, нужно спуститься по дереву поиска по левой ссылке, а чтобы найти объект с наибольшим ключом – по правой. Поэтому любое выровненное дерево поиска можно считать кучей с операциями insert, find_min и delete_min и с общей сложностью O(log п). Операция find_min может иметь сложность O(1): для этого достаточно сохранить текущий минимум в некоторой переменной, а при выполнении последующей операции delete_min найти новый текущий минимум за то же самое время O(log n). На самом деле это двусторонняя куча: find_max и delete_max выполняются так же, как и прочие дополнительные операции (например, split), которые поддерживаются в обычном дереве поиска. min Рис. 5.1. Куча как выровненное дерево поиска Таким образом, для всех операций с кучей в виде выровненного дерева поиска легко достижима сложность O(log n), а сложность операции find_min становится даже постоянной. Такое представление кучи можно считать стандартным, так что другие ее представления должны обеспечивать еще бóльшую, чем эта, в некотором отношении производительность или обладать некими операциями, которых нет в выровненных деревьях поиска. Теорема. Структуру кучи можно представить в виде любого выровненного дерева поиска со временем O(log n) для операций insert, delete_min и для операции find_min со временем O(1). В дополнение к обычным выровненным деревьям в кучах часто используются деревья с всплывающими узлами и списки с пропусками. Ниже приводится код для простейшей реализации кучи в виде выровненного дерева. 204 Глава 5. Кучи typedef struct { key_t key; object_t *object; } heap_el_t; typedef struct { heap_el_t current_min; tree_node_t *tree; } heap_t; heap_t *create_heap(void) { heap_t *hp; hp = (heap_t *)malloc(sizeof(heap_t)); hp->heap = (heap_el_t *)malloc(size *sizeof(heap_el_t)); hp->max_size = size; hp->current_size = 0; return(hp); } int heap_empty (heap_t *hp) { return (hp->tree->left == NULL); } heap_el_t find_min (heap_t *hp) { return (hp->current_min); } void insert_heap(key_t new_key, object_t *new_obj, heap_t *hp) { if( hp->tree->left == NULL || new_key < hp->current_min.key ) { hp->current_min.key = new_key; hp->current_min.object = new_obj; } insert(hp->tree, new_key, new_obj); } object_t *delete_min (heap_t *hp) { object_t *del_obj; tree_node_t *tmp_node; if( hp->tree->left == NULL ) return (NULL); /* куча пуста */ else { del_obj = hp->current_min.object; delete(hp->tree, hp->current_min.key); tmp_node = hp->tree; if( tmp_node->left != NULL ) /* обновить current_min */ { while( tmp_node->right != NULL ) tmp_node = tmp_node->left; hp->current_min.key = tmp_node->key; hp->current_min.object = (object_t *)tmp_node->left; } return(del_obj); 5.1. Куча как выровненное дерево 205 } } void remove_heap (heap_t *hp) { remove_tree(hp->tree); free(hp); } Как сказано в начале раздела, операции insert, delete_min не могут гарантировать времени меньшего, чем O(log n). Однако выровненные деревья поиска позволяют обеспечить время O(1) для операции delete_min. Для этого нужно объединить листья в связный список, а лежащее ниже дерево поиска должно поддерживать операцию split за время O(log n) с разделением корня за время O(1), подобно выровненным по высоте или красно-черным деревьям. После этого в этом списке сохраняется указатель («палец») на текущий минимальный элемент, а при выполнении операции delete_min он просто смещается по списку без удаления самогó узла дерева. Такая стратегия называется отложенным (lazy) удалением. Конечно, в какой-то момент все ненужные объекты придется удалять и переносить в список свободных узлов. Но, в принципе, любое выровненное двоичное дерево поиска, поддерживающее операцию разделения узла со связанными в список листьями, можно использовать для реализации операции find_min со временем O(1) и операции insert со временем O(log n). Этот метод был описан в [262] и, видимо, стал толчком к тому, что во многих поздних реализациях куч обсуждались иные оценки сложности: O(1) – для insert и O(log n) – для delete_min. min удаляемые Рис. 5.2. Куча в виде дерева поиска с отложенным удалением: ненужные элементы уже удалены из кучи Короче говоря, нужна структура выровненного дерева поиска, которая поддерживает операцию разделения узлов и листья которой представлены обычным связным списком по возрастанию ключей или, что упрощает обновление, двусвязным списком. Кроме того, нужен указатель current_min на текущий минимум в списке. Наконец, нужна структура для удаляемых узлов, 206 Глава 5. Кучи позволяющая добавлять все поддерево узлов, чьи ключи и объекты уже полностью удалены, но которые поштучно возвращаются в список свободных объектов. В этом случае операции с кучей реализуются следующим образом: • find_min: 1. Возвратить current_min-> key и current_min->object. • insert: 1. Разделить дерево поиска в узле current_min->key и добавить нижнее поддерево в список удаляемых узлов. 2. Добавить новый ключ в дерево поиска. 3. Если новый ключ меньше current_min->key, внести в current_min новый ключ и сам объект. • delete_min: 1. Удалить current_min->object. 2. Перенести current_min в следующую позицию списка. 3. Если current_min-> key все еще больше ключа корня выровненного дерева поиска, то добавить его левое поддерево в список удаленных узлов, а правое поддерево сделать новым деревом поиска. Старый корень внести в список свободных узлов. 4. Перенести несколько удаляемых узлов в список свободных. Но пока не понятно, как реализовать структуру удаляемых узлов. Она должна позволять добавление поддерева и удаление узла за постоянное время. Простейший способ добиться этого – создать стек, чьи записи являются ссылками на корни поддеревьев. Чтобы добавить новое поддерево, достаточно просто внести его корень в стек, а чтобы удалить узел, нужно извлечь верхний корень из стека, поместить в него, если он не лист, левый и правый корни его поддеревьев, а сам корень отправить в список свободных узлов. Эти операции требуют постоянного времени. Единственный их недостаток – дополнительная память для стека, возможно, такая же, каков общий размер размещаемых в нем поддеревьев. Поэтому этот стек нельзя реализовать в массиве. Но если ограничение памяти несущественно, то эти издержки для худшего случая будут выражаться лишь в увеличении коэффициента менее чем в 4 раза. Очевидно, что операция find_min требует времени O(1). Операция insert требует времени O(log n) для шагов 1 и 2 и времени O(1) для шага 3, то есть суммарно – времени O(log n). А каждый шаг операции delete_min требует времени O(1). Отметим, что current_min всегда находится в левом поддереве дерева поиска, поэтому высота дерева поиска с удаляемыми узлами отличается от высоты дерева без удаляемых узлов не более чем на единицу. Подытожив оценку производительности такой структуры, можно сделать вывод: 5.2. Куча в массиве 207 Теорема. Кучу из n элементов можно реализовать в виде выровненного дерева поиска с отложенным удалением со сложностью O(log n) для операции insert и сложностью O(1) для операций find_min и delete_min. Той же производительности можно достичь, используя дерево поиска с постоянным временем удаления элемента в заранее известном месте, как описано в разделе 3.6. 5.2. Куча в массиве Изначально предложенный и описанный во многих учебниках классический вариант кучи представлялся в виде массива. Индекс или номер элемента в массиве (то есть косвенный указатель в неявной структуре данных) гораздо компактнее прямого указателя, так что при сортировке кучи в массиве не требуется дополнительной памяти. В такой структуре поддерживаются операции insert и delete_min со временем O(log n) и операция find_min со временем O(1), а количество сравнений ключей при сортировке (n добавлений с последующими n удалениями) близкó к теоретическому минимуму, поэтому при фиксированном размере массива это самая быст­ рая реализация кучи, и необходимости в дополнительных операциях не требуется. Таким образом, сортировать кучу в массиве очень просто. Куча выстраивается в массиве в виде полноценного двоичного дерева в порядке следования ее ключей, называемом порядком кучи. При заданном достаточно большом размере массива heap_key[MAX SIZE] куча в массиве должна отвечать следующим условиям: 1. Элементы кучи располагаются в начале массива. Если в куче n элементов, то она занимает в массиве элементы с индексами от 0 до n – 1. 2. Для каждого индекса массива i ≥ 0 heap_key[i] < heap_key[2i + 1], если 2i + 1 < n, и heap_key[i] < heap_key[2i + 2], если 2i + 2 < n. Отсюда следует, что наименьший ключ всегда находится в позиции 0, а первый удаляемый элемент массива – в позиции n. Каждый элемент массива должен отвечать трем правилам упорядочивания кучи: ключ элемента с индексом i должен быть меньше ключей двух вышестоящих26 элементов с индексами 2i + 1 и 2i + 2 (если они есть) и больше ключа нижестоящего 1 (i – 1)⌋ (если он есть). Таким образом, элементы элемента с индексом ⌊— 2 массива представляют собой полноценное двоичное дерево с высотой log n (рис. 5.3). 26 При отображении кучи в виде массива его начало всегда помещается слева или внизу, а затем он нумеруется слева направо или снизу вверх. Как правило, это приводит к тому, что корень неявного дерева кучи в массиве оказывается внизу дерева. Это единственные деревья в этой книге, растущие в правильном направлении. Глава 5. Кучи 208 heap[15] heap[16] heap[17] heap[7] heap[8] heap[9] heap[3] heap[10] heap[11] heap[12] heap[13] heap[14] heap[4] heap[5] heap[1] heap[6] heap[2] heap[0] Рис. 5.3. Куча в массиве с упорядоченными элементами В этом случае операция добавления insert выполняется следующим образом: новый элемент вносится в позицию n с увеличением ее номера, сохраняя условие 1. Но добавление нового элемента может нарушить условие 2, поэтому это условие нужно проверять и, возможно, менять местами новый элемент с нижним. Если новый (верхний) элемент в позиции i меняется местами с нижним в позиции ⌊(i – 1)⌋, то такой обмен, безусловно, уменьшает значение ключа в текущей позиции, однако соотношение ключей текущей позиции с вышестоящими элементами все еще может нарушаться, и его нужно проверить и, возможно, исправить. Этот процесс останавливается в худшем случае в позиции 0, когда новый элемент становится наименьшим и нижестоящего условия уже нет. Такой процесс требует не более одного сравнения ключа на каждом уровне, поэтому вся операция добавления потребует не более log n сравнений ключей. 34 44 5 10 34 44 20 20 30 9 15 11 14 19 24 6 33 10 8 7 5 30 9 15 14 4 19 24 6 33 8 7 4 3 3 34 44 20 10 11 34 44 20 9 30 5 15 11 14 19 24 6 7 8 4 3 33 10 9 30 7 15 11 14 19 24 6 5 33 8 4 3 Рис. 5.4. Куча в массиве: добавление нового элемента с ключом 5. Новый элемент становится последним в массиве и опускается вниз При выполнении операции delete_min нужно удалить элемент в позиции 0 и заменить его другим элементом. Простейшее решение – опустить 5.2. Куча в массиве 209 наименьший из двух верхних его соседей вниз, за счет чего удаляемый элемент поднимается вверх и условие 2 восстанавливается, но в позиции n – 1 условие 1 пока не восстановлено. Классический способ обойти эту проблему – на первом шаге опустить самый последний элемент из позиции n – 1 в позицию 0, а затем поднять его вверх на нужное место. Поскольку при подъеме нужно соблюсти два условия и на каждом шаге подъема нужны два сравнения, то сначала нужно сравнить ключи двух верхних соседей, а затем меньший из них сравнить с ключом удаляемого элемента. Если ключ верхнего соседа меньше ключа удаляемого элемента, то они меняются мес­ тами с перемещением удаляемого элемента вверх. На каждом уровне выполняются два сравнения: одно решает, какой из верхних соседей можно опустить вниз, а другое решает, нужно ли его опускать вниз. Так будет почти всегда, поскольку место самого нижнего элемента должен занять последний и больший элемент, так что не исключено, что его придется перемес­ тить по массиву далеко назад. Альтернативой этому может быть пропуск второго сравнения и простая замена удаляемого элемента меньшим из его верхних соседей с перемещением удаляемого элемента вверх и замещением последнего элемента меньшим верхним соседом удаляемого элемента. После этого на втором проходе этот последний элемент можно вернуть на нужное место. В худшем случае это ничего не меняет, но было доказано, что при сортировке такой прием действительно уменьшает общее коли­чество сравнений. Это так называемая восходящая сортировка кучи [547, 211]. Похожий метод был предложен в [559]; он сокращает всплытие удаляемого 2 элемента до — 3 возможной высоты дерева, добавляет последний элемент, а затем перемещает его вверх или вниз, если необходимо. Это снижает в худшем случае количество сравнений ключей в операции delete_min с 2 log n 4 до — log n. 3 34 44 21 10 34 44 20 30 9 15 24 14 33 11 6 19 10 8 7 20 30 9 15 24 14 4 33 11 6 19 8 7 4 21 34 44 34 44 10 20 30 9 15 24 14 33 11 6 7 8 21 4 19 10 20 30 9 15 24 14 33 11 21 7 19 8 6 4 Рис. 5.5. Куча в массиве: классический метод удаления минимального элемента. Последний элемент сначала занимает место удаленного, а затем всплывает вверх 210 Глава 5. Кучи Некоторого улучшения можно добиться, если на самом высшем уровне избегать элементов с двумя нижними соседями как можно дольше, не заполняя высшие уровни последовательно. Сначала заполняются нечетные позиции, а затем четные. Это требует изменения условия 2 [110] и сохранения одного сравнения ключа для половины значений n. Еще одна возможная модификация – это двоичный поиск при спуске элемента [257, 111]. Путем тщательного анализа можно даже найти точное минимальное количество сравнений ключа при добавлении и удалении [257, 113], а также границы для таких операций, как построение кучи в неупорядоченном массиве [406, 114] или слияние двух куч [480, 315]. Правда, количество сравнений ключей не соотносится с реальной скоростью. Это всего лишь пример двоичного поиска по пути, после чего нужно переместить все элементы на этом пути так, чтобы они встали на нужные места. Это доказывает, что уменьшение количества сравнений с log n до log log n бесполезно, если количество перемещений элементов также не уменьшается. В хорошей реа­лизации нужно избегать лишних перемещений данных. Еще один вариант кучи в массиве был предложен в [275]. Он позволяет частично восстановить структуру кучи, даже когда она нарушена. Расширение куч в массиве применительно к другим частично упорядоченным множествам было изложено в [433]. Ниже приведена реализация такой структуры кучи в массиве с заданным максимальным размером. Каждый элемент кучи – это ключ и указатель объекта; именно они и возвращаются при обращении к элементу кучи. typedef struct { key_t key; object_t *object; } heap_el_t; typedef struct { int max_size; int current_size; heap_el_t *heap; } heap_t; heap_t *create_heap (int size) { heap_t *hp; hp = (heap_t *) malloc(sizeof(heap_t)); hp->heap = (heap_el_t *) malloc (size *sizeof(heap_el_t)); hp->max_size = size; hp->current_size = 0; return(hp); } int heap_empty (heap_t *hp) { return (hp->current_size == 0); } heap_el_t *find_min (heap_t *hp) { return(hp->heap); 5.2. Куча в массиве 211 } int insert (key_t new_key, object_t *new_object, heap_t *hp) { if( hp->current_size < hp->max_size ) { int gap; gap = hp->current_size++; while( gap > 0 && new_key < (hp->heap[(gap-1)/2]).key ) { (hp->heap[gap]).key = (hp->heap[(gap - 1)/2]).key; (hp->heap[gap]).object = (hp->heap[(gap - 1)/2]).object gap = (gap - 1)/2; } (hp->heap[gap]).key = new_key; (hp->heap[gap]).object = new_object; return(0); /* добавление выполнено успешно */ } else return (-1); /* переполнение кучи */ } object_t *delete_min (heap_t *hp) { object_t *del_obj; int reached_top = 0; int gap, newgap, last; if( hp->current_size == 0) return (NULL); /* ошибка: попытка удаления из пустой кучи */ del_obj = (hp->heap[0]).object; gap = 0; while( !reached_top ) { if( 2*gap + 2 < hp->current_size ) { if( (hp->heap[2*gap + 1]).key < (hp->heap[2*gap + 2]).key ) newgap = 2*gap + 1; else newgap = 2*gap + 2; (hp->heap[gap]).key = (hp->heap[newgap]).key; (hp->heap[gap]).object = (hp->heap[newgap]).object; gap = newgap; } else if( 2*gap + 2 == hp->current_size ) { newgap = 2*gap + 1; (hp->heap[gap]).key = (hp->heap[newgap]).key; (hp->heap[gap]).object = (hp->heap[newgap]).object; hp->current_size -= 1; return (del_obj); /* завершено: выход на последнем элементе */ } Глава 5. Кучи 212 else reached_top = 1; } /* удаляемый элемент всплыл вверх: добавить последний объект в нужное место */ last = --hp->current_size; while( gap > 0 && (hp->heap[last]).key < (hp->heap[(gap - 1)/2]).key) { (hp->heap[gap]).key = (hp->heap[(gap-1)/2]).key; (hp->heap[gap]).object = (hp->heap[(gap-1)/2]).object gap = (gap-1)/2; } (hp->heap[gap]).key = (hp->heap[last]).key; (hp->heap[gap]).object = (hp->heap[last]).object; /* заместили удаленный элемент последним */ return(del_obj); } void remove_heap (heap_t *hp) { free(hp->heap); free(hp); } Эта версия кучи опять же имеет все недостатки любой структуры фиксированного размера, поэтому ее следует использовать, только когда заранее известен максимальный размер кучи, как при любой другой сортировке или в алгоритме Дейкстры. Ни одна из операций обновления не обеспечивает времени O(1), но эта версия все еще считается быстрой реализацией кучи. Чтобы подвести итог производительности такой структуры, делаем следующий вывод: Теорема. Структура кучи фиксированного максимального размера может быть реализована с использованием массива со временем O(1) для операции find_min и со временем O(log n) для операций insert и delete_min. Здесь приведена куча в массиве, которая по сути является двоичным деревом с использованием индексов массива. Точно так же можно построить k-мерное дерево [380], но условие 2 должно быть заменено на другое: 2'. Для всех i ≥ 0: heap_key[i] < heap_key[ki + 1], если ki + 1 < n, heap_key[i] < heap_key[ki + 2], если ki + 2 < n, ... до heap_key[i] < heap_key[ki + k], если ki + k < n. Это условие уменьшает высоту дерева и, следовательно, ускоряет операцию insert, но кратность каждого узла увеличивается, из-за чего опера- 5.3. Куча как упорядоченное и полуупорядоченное дерево 213 ция delete_min замедляется. В [294] было предложено поддерживать высоту кучи постоянной, но увеличивать кратность ее вершин с увеличением количества элементов. В этом случае операции insert потребовалось бы постоянное время, но для кучи высотой h из n элементов ее кратность будет 1 1 — — порядка n h , и поэтому операция delete_min потребует времени Ω(n h ). 5.3. Куча как упорядоченное и полуупорядоченное дерево Вместо реализации кучи в статическом массиве можно использовать динамическую структуру, когда куча представляется как обычное дерево. Но есть одна деталь, которая делает такую структуру гораздо проще дерева поиска. Каждый узел состоит из ключа и двух указателей на нижние узлы – корней других куч. Однако ключ узла не различает ключи в дочерних кучах, но он меньше каждого из них. Здесь нет обязательной связи между узлами дочерних куч, и при добавлении нового элемента можно выбрать любую из них. Это называется кучей в виде упорядоченного дерева, которое отличается от упорядоченного дерева поиска. В упорядоченной куче ключ, который мы ищем, всегда в находится в корне, а остальные ключи не повторяются в дочерних поддеревьях. Так что каждый ключ связан только с одним объектом, и двух моделей структуры, как для дерева поиска, быть не может: каждый ключ узла соответствует только своему объекту. Таким образом, узел (двоичного) упорядоченного дерева кучи имеет такую структуру: typedef struct hp_n_t { key_t object_t struct hp_n_t struct hp_n_t /* возможна другая } heap_node_t; key; *object; *left; *right; информация */ Два указателя left и right названы так же, как в дереве поиска, но в отличие от дерева поиска между ними нет отношения порядка. Упорядоченное дерево кучи также определяется рекурсивно: оно либо пусто, либо имеет корневой узел – ключ, объект и две ссылки, каждая из которых или NULL, или ссылается на другое упорядоченное дерево кучи, в котором все ключи больше ключа корневого узла. Любая структура с такими свойствами – это упорядоченное дерево для своих объектов и их ключей (рис. 5.6). Нужно установить соглашение для пометки пустой кучи. Это отличается от ситуации в деревьях поиска, где можно было бы использовать значение NULL в полях left и right, но в упорядоченном дереве кучи обе ссылки могут быть равными NULL. Можно было бы вносить значение NULL в поле object, но такие объекты вполне допустимы в упорядоченной куче. Таким образом, 214 Глава 5. Кучи соглашение для пустых куч будет принято позже и только в особых случаях, которые будут проверяться с корневого узла за время O(1). 5 obj1 6 42 obj2 obj3 99 10 50 obj4 obj5 obj6 11 15 78 obj7 obj8 obj9 12 72 16 89 80 obj10 obj11 obj12 obj13 obj14 13 91 81 obj15 obj16 obj17 14 22 92 97 obj18 obj19 obj20 obj21 Рис. 5.6. Куча как упорядоченное дерево При таких соглашениях теперь можно привести описание функций create_heap, heap_empty и find_min. Все они довольно просты и имеют постоянное оценочное время. Операция find_min разделена на две операции find_min_key и find_min_object, что удобнее, чем повторное возвращение всей структуры. heap_node_t *create_heap (void) { heap_node_t *tmp_node; tmp_node = get_node(); tmp_node->object = NULL; /* или другое значение для пустой кучи */ return(tmp_node); } int heap_empty(heap_node_t *hp) { return(hp->object == NULL); /* или другая проверка пустой кучи */ } key_t find_min_key(heap_node_t *hp) { return(hp->key); } object_t *find_min_object (heap_node_t *hp) { return(hp->object); } 5.3. Куча как упорядоченное и полуупорядоченное дерево 215 Однако для операций insert и delete_min нужна дополнительная структура. Если куча в массиве имела преимущество в том, что все пути от корня до листа были почти одинаковой длины, и было известно, какой из путей увеличится или уменьшится на единицу при добавлении или удалении элемента, то в упорядоченном дереве кучи любая операция должна начинаться с корня, потому что нет возможности прямого доступа к листу. Очевидный метод для insert должен начинаться с корня. Сначала выбирается любой путь к листу (не важно, левый он или правый), затем в нужное место добавляется новый узел с ключом и объектом, а к нему прикрепляется все, что ранее было в качестве поддерева ниже этого нового узла. 3 3 5 8 13 21 31 9 15 27 16 6 11 42 28 30 22 32 35 33 5 10 29 23 7 21 31 9 15 16 28 30 13 41 6 11 8 40 37 7 27 42 32 35 22 10 29 23 40 37 41 33 Рис. 5.7. Простой способ добавления в древовидную кучу нового узла. Поддерево добавленного узла просто опускается вниз Таким образом, нет нужды спускаться вниз до листа; просто глубина всего вставленного ниже вновь добавленного узла увеличивается на 1. В качест­ве альтернативы можно было бы добавить новый ключ в уже сущест­вующий узел, а затем спустить по этому пути каждый нижестоящий ключ на шаг вниз к листу, создав в конце концов новый лист. В этом случае глубина узлов остается неизменной, и только последний лист становится новым узлом, увеличивающим глубину дерева. Порядок кучи при этом не меняется, потому что в каждом узле текущий ключ меняется на меньший. Вся сложность этой операции заключается в поиске кратчайшего пути. Любое дерево из n узлов должно иметь некий путь длиной ⌊log(n + 1)⌋, который нужно найти. 3 3 5 8 11 13 21 31 9 15 16 27 42 6 30 22 33 28 35 37 32 29 40 5 10 7 23 8 7 41 13 21 9 15 16 27 42 6 11 30 22 33 28 32 35 37 29 10 23 40 41 31 Рис. 5.8. Альтернативный метод добавления в древовидную кучу: элементы произвольно выбранного пути просто опускаются вниз Для операции delete_min ситуация сложнее; самое очевидное – это удаление ключа и объекта из корня, сравнение ключей его левого и правого 216 Глава 5. Кучи нижних соседей и спуск меньшего вниз с рекурсивным удалением его из своего поддерева. Поэтому выбора нет: нужно пройти путь от корня до лис­ та с наименьшим ключом и по пути поднять все объекты на один уровень вверх к корню, удалив последний, теперь уже пустой, узел. Поскольку этот путь непредсказуем, то время выполнения данной операции – O(log n), при условии что длина всех путей от корня до любого листа – O(log n). 5 5 8 11 13 21 31 9 15 16 6 27 42 28 30 22 32 35 33 6 10 29 8 23 40 37 21 41 11 13 31 9 15 16 27 42 28 30 22 35 33 6 11 31 9 15 16 27 42 28 30 22 32 35 33 41 8 10 29 9 23 37 21 41 31 28 30 15 16 6 11 13 40 27 42 22 33 31 22 15 16 42 28 30 27 35 33 37 10 29 23 40 37 41 5 6 11 13 21 32 35 5 8 9 23 5 8 13 10 29 40 37 5 21 32 32 29 8 10 9 23 13 40 41 21 31 22 15 16 6 11 30 27 42 28 35 33 37 32 29 10 23 40 41 Рис. 5.9. Общий метод удаления в древовидной куче. Корень удаляется, а образовавшаяся дыра опускается вниз к листьям Таким образом, требуется какая-то информация о равновесии дерева. Можно было бы попытаться снова использовать какой-то из методов уравновешивания деревьев поиска, создав, например, уравновешенное по высоте дерево в упорядоченной куче. Высота такого дерева будет ограничена величиной O(log n), и можно будет поддерживать время всех операций в границах O(log n). Проблема только в том, что вращения неприменимы к кучам. Если поддерево нужно перевернуть, то ключ в его корне должен остаться прежним (согласно условиям упорядоченных куч) – так же, как и ключ в другом узле вращения тоже должен остаться прежним. Но после вращения у этого другого узла может появиться новый нижний сосед, который может нарушить порядок кучи. Таким образом, слепо применять методы выравнивания деревьев поиска к упорядоченным кучам нельзя. Альтернативой с более слабым условием упорядочивания кучи являются полуупорядоченные деревья. Это те же деревья, но с разными условиями для каждого узла: каждый ключ в правом поддереве должен быть больше корневого, а для левого это условие необязательно. 5.3. Куча как упорядоченное и полуупорядоченное дерево 217 x x >x нет ограничений >x >x Рис. 5.10. Полностью упорядоченное и полуупорядоченное деревья Таким образом, наименьший ключ не обязательно должен быть в корне, а может быть в любом узле крайнего левого пути. Такую более слабую структуру проще поддерживать. В полуупорядоченных деревьях куч можно выполнять стандартные вращения и применять любые способы выравнивания, как в деревьях поиска. Так как такое дерево имеет глубину O(log n), операцию find_min можно выполнять, следуя по крайнему левому пути, а операции insert и delete_min выполнять за время O(log n) при любом способе выравнивания [284]. a b c d e >b >a >c f >d g >e >f Рис. 5.11. Крайний левый путь в полуупорядоченном дереве: при предполагаемых условиях максимум должен находиться именно на этом пути Но самая главная причина использования такой структуры состоит в том, что это единственное представление дерева упорядоченной кучи с узлами любой кратности. Многие кучи, начиная с биномиальной, представлены в литературе именно таким образом, но чтобы их реализовать, нужно представить их узлы в двоичном виде или с фиксированным размером. Классический метод достижения этой цели – хранить узел в виде связного списка по ссылке left. Указатель right ссылается на первый узел в списке. При таком представлении никаких дополнительных условий к ветвям поддерева с указателем left не предъявляется, так как все они – просто нижние соседи одного и того же узла, но расположены в правом поддереве этого узла. Небольшая разница состоит в том, что в любом дереве упорядоченной кучи в корне находится наименьший элемент, что совсем не обязательно в полуупорядоченных деревьях куч. Да, классические биномиальные и связанные с ними кучи – это списки упорядоченных деревьев куч с узлами 218 Глава 5. Кучи любой кратности, поэтому у них нет общего корня. Но полуупорядоченные деревья куч изоморфны списочным упорядоченным деревьям куч с узлами любой кратности. 5.4. Левосторонние кучи Один из самых простых и ранних методов – это левосторонние кучи. Они были придуманы, скорее всего, Крейном27, а чуть позже пересмотрены и названы так в работе [327]. Они поддерживают операции insert и delete_min за время O(log n), что не так важно. Важно то, что они поддерживают дополнительную операцию – объединение двух куч за то же время O(log n), чего нельзя сделать ни в дереве поиска кучи, ни в куче в массиве. Левосторонние кучи – это упорядоченные кучи деревьев, которые используют для уравновешивания дерева так называемый ранг расстояния до ближайшего листа. Он отличается от высоты дерева – расстояние до самого дальнего листа. Каждый узел содержит дополнительное поле rank, которое определяется следующим образом: • n->rank = 1, если n->left = NULL или n->right = NULL; • n->rank = 1 + min(n->left->rank, n->right->rank), если n->left ≠ NULL и n->right ≠ NULL. Дополнительное поле rank позволяет определить корень пустой кучи с rank = 0. 3 2 3 1 2 1 1 1 2 1 1 2 2 1 1 1 1 1 1 1 Рис. 5.12. Дерево левосторонней кучи с узлами, помеченными рангами Левосторонняя куча обладает тем свойством, что в каждом узле кратчайший путь по левой стороне почти такой же, как по правой: • n->left->rank ≥ n->right->rank, если оба определены; • если они оба не определены, то если один из них существует, то он левый: n->left = NULL, при условии что n->right = NULL. 27 В техническом отчете C. A. Crane: Linear Lists and Priority Queues as Balanced Binary Trees, CS-72-259, Stanford University, USA, 1972. 5.4. Левосторонние кучи 219 Таким образом, левостороняя куча может быть невыровненной по крайней левой стороне, поскольку все изменения происходят именно там, но она всегда перемещается вправо. Куча из n элементов имеет путь длиной не более ⌊log(n + 1)⌋. Эта структура легко восстанавливается после изменения какого-то узла, потому что можно подняться к корню, пересчитать ранги узлов и при необходимости поменять местами поля left и right. При добавлении нужно идти по правому пути до нужного места для нового узла и добавить туда узел, переместив вместе с ним остальную часть самого правого пути влево ниже нового узла. Новый узел имеет ранг 1. Затем нужно пройти по этому же пути вверх с пересчетом рангов и восстановлением левого свойства вдоль всего этого пути. 23 42 62 5 2 7 1 8 1 10 1 14 1 15 1 11 23 11 11 1 42 62 5 2 7 1 8 1 10 1 14 1 15 1 11 1 23 42 23 62 5 2 7 1 8 1 10 1 14 1 15 1 31 11 1 42 31 52 71 62 14 1 15 1 8 1 10 1 11 1 23 42 23 62 5 2 7 1 8 1 10 1 14 1 15 1 11 1 91 42 23 62 52 71 81 91 14 1 15 1 10 1 42 23 62 5 2 7 1 8 1 10 1 14 1 15 1 11 1 12 1 42 62 5 2 7 1 10 2 8 1 14 1 15 1 11 1 12 1 11 1 Рис. 5.13. Четыре примера добавления элемента в левую кучу Первый этап – это, по сути дела, общая стратегия добавления элемента в упорядоченное дерево кучи, а на втором этапе необходим возврат к корню дерева, как обсуждалось в разделе 2.5. Поскольку длина самого правого пути не может превышать ⌊log (n + 1)⌋, то для возврата к корню можно воспользоваться стеком в массиве размером 100. Ниже приведен код для операции insert и прочих основных операций. typedef struct hp_n_t { int rank; key_t key; object_t *object; struct hp_n_t *left; struct hp_n_t *right; } heap_node_t; heap_node_t *create_heap(void) { heap_node_t *tmp_node; 220 Глава 5. Кучи tmp_node = get_node(); tmp_node->rank = 0; return(tmp_node); } int heap_empty(heap_node_t *hp) { return (hp->rank == 0); } key_t find_min_key(heap_node_t *hp) { return(hp->key); } object_t *find_min_object(heap_node_t *hp) { return (hp->object); } void remove_heap(heap_node_t *hp) { heap_node_t *current_node, *tmp; if( hp->rank == 0 ) return_node (hp); else { current_node = hp; while( current_node != NULL ) { if( current_node->left == NULL ) { tmp = current_node->right; return_node(current_node); current_node = tmp; } else { tmp = current_node; current_node = current_node->left; tmp->left = current_node->right; current_node->right = tmp; } } } } int insert (key_t new_key, object_t *new_obj, heap_node_t *hp) { if( hp->rank == 0 ) /* добавить в пустую кучу */ { hp->object = new_obj; hp->key = new_key; hp->left = hp->right = NULL; hp->rank = 1; 5.4. Левосторонние кучи 221 } else if (new_key < hp->key) /* новый наименьший элемент: заменить корень */ { heap_node_t *tmp; tmp = get_node(); tmp->left = hp->left; tmp->right = hp->right; tmp->object = hp->object; hp->left = tmp; hp->right = NULL; hp->key = new_key; hp->object = new_obj; hp->rank = 1; } else /* обычное добавление */ { heap_node_t *tmp, *tmp2, *new_node; tmp = hp; create_stack(); /* идти вниз по правому пути до места добавления */ while( tmp->right != NULL && tmp->right->key < new_key ) { push (tmp); tmp = tmp->right; } /* создать новый узел */ new_node = get_node(); new_node->key = new_key; new_node->object = new_obj; /* добавить по пути новый узел; все, что ниже него, перенести налево */ new_node->left = tmp->right; new_node->right = NULL; new_node->rank = 1; if( tmp->left == NULL ) /* возможно только в конце */ tmp->left = new_node; /* здесь tmp->right == NULL */ else /* добавить справа и восстановить левую сторону */ { tmp->right = new_node; tmp->rank = 2; /* имеет один ранг, но только слева */ /* добавление закончено; подняться вверх, пересчитать ранги и поменять местами left и right, где нужно */ while( !stack_empty() ) { tmp = pop(); { if( tmp->left->rank < tmp->right->rank) { tmp2 = tmp->left; tmp->left = tmp->right; tmp->right = tmp2; 222 Глава 5. Кучи } tmp->rank = tmp->right->rank +1; } } } /* путь к корню завершен */ remove_stack(); } return(0); /* добавление всегда завершается успешно */ } Основная идея левосторонних куч – это их объединение. В этом случае можно сократить время операции delete_min уже до их объединения: нужно просто удалить корень и объединить левое и правое поддеревья. В операции merge нужно просто объединить правые пути обоих деревьев, а затем сделать те же изменения, что и при добавлении: пересчитать rank и восстановить свойство левосторонней кучи, поменяв местами ссылки left и right там, где нужно. 4 12 3 30 2 40 1 2 3 7 15 2 9 5 2 10 1 1 13 1 33 1 16 1 28 1 20 1 29 1 3 8 2 6 14 2 1 42 1 объединение 2 11 1 19 1 41 1 17 1 2 5 13 1 29 1 2 4 2 6 1 4 12 3 30 2 15 2 7 9 40 1 33 1 16 1 28 1 20 1 41 1 17 1 уравновешивание 1 8 14 2 10 42 1 19 1 11 5 3 12 3 30 2 7 15 2 8 40 1 33 1 16 1 28 1 14 2 41 1 17 1 3 2 2 13 1 6 2 9 1 1 29 1 10 1 20 1 42 1 19 1 11 1 Рис. 5.14. Объединение двух левых куч путем слияния правых путей, пересчета рангов и восстановления свойства левой кучи по пути слияния Здесь также оба правых пути имеют длину не более ⌊log(n + 1)⌋, поэтому достаточно стека в массиве размером 200. Ниже приводится код для операций merge и delete_min. heap_node_t *merge(heap_node_t *hp1, heap_node_t *hp2) { heap_node_t *root, *tmp1, *tmp2, *tmp3; if( hp1->rank == 0 ) /* куча 1 пуста */ { return_node(hp1); 5.4. Левосторонние кучи 223 return(hp2); } if( hp2->rank == 0 ) /* куча 2 пуста */ { return_node(hp2); return(hp1); } /* выбрать новый корень, настроить объединение */ if( hp1->key < hp2->key ) { tmp1 = root = hp1; tmp2 = hp1->right; tmp3 = hp2; } else { tmp1 = root = hp2; tmp2 = hp2->right; tmp3 = hp1; } create_stack(); while( tmp2 != NULL && tmp3 { if( tmp2->key < tmp3->key { tmp1->right = tmp2; push(tmp1); tmp1 = tmp2; tmp2 = tmp2->right; } else { tmp1->right = tmp3; push(tmp1); tmp1 = tmp3; tmp3 = tmp3->right; } } if( tmp2 == NULL ) tmp1->right = tmp3; else tmp1->right = tmp2; /* объединение правых путей свойство левой кучи */ push(tmp1); while( !stack_empty() ) { tmp1 = pop(); if( tmp1->left == NULL || != NULL ) ) закончено: пересчитать ранг и восстановить (tmp1->left != NULL && tmp1->right != NULL && tmp1->left->rank < tmp1->right->rank) ) { tmp2 = tmp1->left; tmp1->left = tmp1->right; 224 Глава 5. Кучи tmp1->right = tmp2; } if( tmp1->right == NULL) tmp1->rank = 1; else tmp1->rank = tmp1->right->rank + 1; } remove_stack(); return(root); } object_t *delete_min(heap_node_t *hp) { object_t *del_obj; heap_node_t *heap1, *heap2, *tmp; del_obj = hp->object; heap1 = hp->left; heap2 = hp->right; if( heap1 == NULL && heap2 == NULL ) hp->rank = 0; else { if( heap2 == NULL ) tmp = heap1; else tmp = merge(heap1, heap2); /* теперь кучи объединены: скопировать корень в нужное место */ hp->key = tmp->key; hp->object = tmp->object; hp->rank = tmp->rank; hp->left = tmp->left; hp->right = tmp->right; return_node(tmp); } return(del_obj); } Производительность такой структуры будет следующей: Теорема. Структура левосторонней кучи поддерживает операцию find_min за время O(1), а операции insert, merge и delete_min за время O(log n). В левосторонних кучах, как и в обычных деревьях поиска, используется критерий равной высоты. Но к ним можно применить и критерий равного веса. Уравновешенные левосторонние кучи, где веса левых узлов не меньше весов правых куч, были изучены в статье [138]. Похожая, но более медленная структура кучи была предложена в ранней статье [295]. 5.5. Косые кучи 225 5.5. Косые кучи Косые кучи были введены в статье [503] как аналог левосторонних куч, но без выравнивания. Интересное свойство таких куч заключается в том, что, как и в косых деревьях, можно обойтись без этой информации, если вместо худшей оценки времени пользоваться средней. Без выравнивания структура становится проще, а последовательность операций – неизменной. Выгода в памяти без выравнивания незначительна (она теперь не является проблемой), а при восходящем варианте косых куч необходимо всего несколько ссылок на узел. Без информации о выравнивании невозможно определить, какое из поддеревьев – левое или правое – будет иметь больший ранг и нужно ли восстанавливать свойство левой кучи путем обмена куч местами. В косых кучах левое и правое поддеревья меняются местами всегда. Это приводит к более простому коду. Стек в таком случае уже не нужен, так как нет информации, которая должна проталкиваться к корню. Ниже приводится код для операций insert и merge; остальные операции те же, что и были, но ссылка на поле rank в них удалена. По этой причине в качестве пометки пустой кучи используется поле object. typedef struct hp_n_t { key_t key; object_t *object; struct hp_n_t *left; struct hp_n_t *right; } heap_node_t; int insert (key_t new_key, object_t *new_obj, heap_node_t *hp) { if( hp->object == NULL ) /* добавить в пустую кучу */ { hp->object = new_obj; hp->key = new_key; hp->left = hp->right = NULL; } else if(new_key < hp->key) /* новый наименьший элемент: заменить root */ { heap_node_t *tmp; tmp = get_node(); tmp->left = hp->left; tmp->right = hp->right; tmp->key = hp->key; tmp->object = hp->object; hp->left = tmp; hp->right = NULL; 226 Глава 5. Кучи hp->key hp->object = new_key; = new_obj; } else /* обычное добавление */ { heap_node_t *current, *tmp, *new_node; current = hp; /* спуститься ниже по правому пути к месту добавления */ while( current->right != NULL && current->right->key < new_key ) { tmp = current->right; /* обменять */ current->right = current->left; current->left = tmp; current = tmp; /* и спуститься ниже */ } /* теперь создать новый узел */ new_node = get_node(); new_node->key = new_key; new_node->object = new_obj; /* добавить новый узел в путь, идущий влево */ new_node->left = current->right; new_node->right = NULL; current->right = new_node; } return(0); } heap_node_t *merge(heap_node_t *hp1, heap_node_t *hp2) { heap_node_t *root, *tmp1, *tmp2, *tmp3; if( hp1-> object == NULL ) /* куча 1 пуста */ { return_node(hp1); return(hp2); } if( hp2-> object == NULL ) /* куча 2 пуста */ { return_node (hp2); return(hp1); } /* выбрать новый корень и настроить объединение */ if( hp1->key < hp2->key ) { tmp1 = root = hp1; tmp3 = hp2; } else { tmp1 = root = hp2; tmp3 = hp1; } tmp2 = tmp1->right; /* tmp1 – конец уже объединенного правого пути, tmp2 и tmp3 – это следующие узлы в оставшихся правых путях */ 5.5. Косые кучи 227 while( tmp2 != NULL && tmp3 != NULL ) { tmp1->right = tmp1->left; /* обмен в объединенном пути */ if( tmp2->key < tmp3->key ) { /* прикрепить tmp2 как следующий и спуститься ниже */ tmp1->left = tmp2; tmp1 = tmp2; tmp2 = tmp2->right; } else { /* прикрепить tmp3 как следующий и спуститься ниже */ tmp1->left = tmp3; tmp1 = tmp3; tmp3 = tmp3->right; } } /* один из путей пуст: прикрепить другой */ if( tmp2 == NULL ) tmp1->right = tmp3; else tmp1->right = tmp2; return(root); } Операции insert и merge меняют местами left и right в каждом узле, куда они попадают на своем пути; их сложность – O(1 + k), если обмен между left и right происходит в k узлах. Теперь нужно определить среднюю оценку времени. Для обычных деревьев нужна функция потенциала, а для куч нужна та же функция, но на множестве деревьев, так как для них определена операция merge. Функция потенциала в работе [503] – это количество узлов, увеличивающих вес правой стороны дерева так, чтобы правое поддерево было тяжелее левого. Здесь важно, чтобы на правом пути было не более log n «тяжелых левых» узлов, поскольку перемещение тяжелого левого узла вправо уменьшает вдвое количество узлов в дереве под текущим узлом. Таким образом, на правых путях дерево тяжелых левых узлов не должно быть слишком велико. И каждый раз, когда какая-то операция сталкивается с такой ситуацией, обмен между тяжелыми левым и правым узлами выполняется так, чтобы тяжелых правых узлов уже не оставалось. Для реализации такой идеи вычисляется потенциал. Операции insert и merge делятся на две части: сначала изменяется правый путь при добавлении в него нового элемента или при объединении правых путей, а затем в правом пути выполняется обмен всех пройденных узлов. На первом этапе операций insert или merge все узлы правого пути так и остаются тяжелыми, так как некоторые узлы могут добавляться в правое поддерево, тогда как в левом поддереве ничего не меняется. Возможно, 228 Глава 5. Кучи что тяжелые левые узлы в правом пути тоже останутся тяжелыми, но таких узлов может быть только O(log n), что увеличит потенциал максимум на O(log n). Узлы, не находящиеся на правом пути, не меняют своего состояния. На втором этапе операций insert или merge в каждом пройденном узле ссылки left и right меняются местами. Таким образом, происходит обмен данными между тяжелыми левым и правым узлами. Каждый тяжелый левый узел, став правым, увеличивает потенциал на 1, но среди пройденных левых узлов может быть всего log n тяжелых. Каждый тяжелый правый узел, став левым, уменьшает потенциал на 1. Таким образом, второй этап операций insert или merge увеличивает общий потенциал не более чем на O(log n). В заключение операция delete_min просто удаляет корень, создавая два дерева, что не увеличивает потенциал, а затем объединяет оба дерева, увеличивая потенциал, но не более, чем операция merge. Если у нас есть последовательность из m операций insert, merge или delete_min, меняющих местами ссылки left и right в ki узлах, каждая из которых содержит kℒi тяжелых левых и kℛi тяжелых правых узлов, то сумm m m марное время будет O(m + � i= 1ki) = O(m + � i= 1kℒi + � i= 1kℛi ) = O(m + m log n) + m O(� i = 1kℛi ). Чтобы связать последние суммы, отметим, что потенциал изначально не превосходит n, а в конечном итоге не будет меньше 0. m m m Поскольку � i= 1kℛi вычитается, а � i= 1kℒi ≤ m log n добавляется, то � i= 1kℛi ≤ n + m log n. В итоге общее время выполнения m операций над n элементами – O(n + m log n). Все вместе это приводит к выводу: Теорема. Структура косой кучи из п элементов поддерживает операцию find_min за время O(1), а операции insert, merge и delete_min за среднее время O(log n). В статье [503] был также описан и более сложный вариант – косые кучи с восхождением и средним временем O(1) выполнения операций insert и merge. Они имеют дополнительные ссылки, которые обновляются в операции delete_min. Для таких косых куч нельзя сократить время операции delete_min до времени операции merge. Среднее время операции delete_min все равно составит O(log n). Ни одно из этих усложнений не относится к худшему случаю; в худшем случае они могли бы выйти на время Ω(n). Такая структура была подробно изучена в работах [296, 299, 488]. 5.6. Двоичные кучи Двоичные кучи – еще один классический, хотя и несколько более сложный способ выполнять операции с такой кучей за время O(log n), включая операцию merge. В отличие от предыдущей структуры, операция find_min требует времени Θ(log n). Двоичные кучи рассматривались в статье [545] и в основном интересны новой дополнительной операцией – изменением значений ключей, которая обсуждается в следующем разделе. 5.6. Двоичные кучи 229 Двоичные кучи тоже могут представляться в виде двоичных деревьев с ключами и объектами в узлах, но они не упорядоченные, а только полу­ упорядоченные. 1. Если узел w находится в правом поддереве узла v, то v->key < w->key. Это более слабое условие, чем для упорядоченных куч: если бóльшие ключи перемещаются вправо, то левые ключи могут выстраиваться в любом порядке. Наименьший ключ может оказаться где-то на левом пути от корня. Это более слабое упорядочивание, но с более сильным выравниванием. 2. Если v – узел на левом пути от корня, то v->right – это корень полного двоичного дерева. Высота таких деревьев обязательно уменьшается на левом пути от корня. Таким образом, двоичная куча состоит из блоков следующей структуры, которые все переносятся в левый путь: узел ni на пути, чья ссылка right указывает на полное двоичное дерево высотой hi, которая уменьшается при движении по этому пути. Полное двоичное дерево высоты h содержит 2h+1 – 1 узлов, поэтому вместе с узлом на крайнем левом пути блок имеет 2hi+1 узлов. Кроме того, допускается также пустое дерево как полное бинарное дерево высотой –1, так что возможен даже блок 20 = 1 из одного узла на этом пути. Если размеры блоков вдоль пути 2h1 > 2h2 > ··· > 2hk, то 2h1 + 2h2 + ··· + 2hk = n, так что деление блоков отвечает двоичному разложению кучи из п элементов. Рис. 5.15. Структура двоичной кучи с блоками размеров 20, 21, 22, 24 и 26 Главное свойство таких блоков – это возможность объединять два блока одинакового размера 2h в один блок размера 2h+1 за время O(1). Если n и m – вершины узлов двух блоков, для которых обе правые ссылки n->right и m->right указывают на двоичные деревья высоты h при условии n->key < m->key, то можно сделать n новым верхним узлом, поле right которого указывает на m, а m становится корнем полного двоичного дерева высоты h + 1, с деревом, которое было под n->right, а теперь будет под m->left. В этом случае требуется выполнение более слабого условия 1. В противном случае было бы просто невозможно соединить эти деревья вместе, так как отношение порядка куч между m и новым m->left может нарушиться, тогда как условие 1 не требует какого-либо порядка на левых путях. 230 Глава 5. Кучи При таком «сложении» двух блоков можно объединить две двоичные кучи за время O(1), выполнив их сложение путем переноса двух их левых путей. Время остальных операций insert и delete_min может быть уменьшено до времени операции merge. Операция insert – это просто слияние куч одного узла. Для операции delete_min нужно найти наименьший узел в крайнем левом от корня пути: этот блок отсоединяется, а его верхний узел удаляется. Оставшийся блок становится полным двоичным деревом, которое становится двоичной кучей, которую можно снова объединить с первоначальной кучей, из которой он был удален, чтобы восстановить кучу, полученную после удаления. y x h >y >x y x x или h+1 >y >x y h+1 >x >y Рис. 5.16. Объединение двух блоков размера 2h в один блок размера 2h+1 Рис. 5.17. Блок после удаления корня снова становится двоичной кучей Заметив, что для всех операций insert, merge и delete_min двоичное сложение с переносом двух log n двоичных чисел потребует добавления O(log n) бит, можно получить общую оценку времени O(log n). Такую среднюю оценку можно улучшить для длинных цепочек операций insert, которые соответствуют повторному добавлению 1 или подсчету двоичных чисел. Общее количество переброшенных битов при подсчете в двоичном виде от n до n + i – O(i + log n), поэтому сложность i последовательных добавлений в кучу заданного размера составляет n – O(i + log n). Теперь рассмотрим последовательность из a операций insert и b операций merge или delete_min на множестве куч общего размера n и состоящего из k куч. Для любой кучи каждая операция delete_min или merge со сложностью O(log n) предшествует некоторой последовательности аj операций insert со сложностью O(aj + log n). Однако возможны аfinal операций insert, 5.6. Двоичные кучи 231 не идущих вслед за операциями delete_min или merge, что потребует времени не более O(аfinal + k log n). А так как a = аfinal + �j aj, то это даст суммарную сложность O(a + b log n + k log n). Поскольку k ≤ n, ясно, что средняя сложность операции insert составит O(1). Ниже приводится код операции merge для двух двоичных куч, а также код простых вспомогательных операций. Поскольку размер блоков уменьшается при движении по пути, а движение по пути начинается с блоков наименьшего размера, то на первом этапе блоки помещаются в стек, чтобы затем поменять их порядок. Альтернативным решением могло бы быть размещение блоков большого размера в самом левом пути; в этом случае сами блоки не будут полными двоичными деревьями, а объекты двойной высоты будут очень косыми. Общая работа будет одинаковой, и предпо­ чтительнее использовать стандартные двоичные деревья. Необходимая высота стека – всего 2 log n. typedef struct hp_n_t { int height; key_t key; object_t *object; struct hp_n_t *left; struct hp_n_t *right; } heap_node_t; heap_node_t *create_heap(void) { heap_node_t *tmp_node; tmp_node = get_node(); tmp_node->height = -1; tmp_node->left = tmp_node->right = NULL; return(tmp_node); } int heap_empty (heap_node_t *hp) { return (hp->height == -1); } key_t find_min_key (heap_node_t *hp) { heap_node_t *tmp; tmp_key = hp->key; while( tmp->left != NULL ) { tmp = tmp->left; if( tmp->key < tmp_key ) tmp_key = tmp->key; } return(tmp_key); } heap_node_t *merge (heap_node_t *hp1, heap_node_t *hp2) 232 Глава 5. Кучи { heap_node_t *tmp1, *tmp2, *current, *next; if( hp1->height == -1 ) /* куча 1 пуста */ { return_node(hp1); return(hp2); } if( hp2->height == -1 ) /* куча 2 пуста */ { return_node (hp2); return(hp1); } /* поместить все блоки в стек */ create_stack(); tmp1 = hp1; tmp2 = hp2; while( tmp1 != NULL && tmp2 != NULL ) { if( tmp1->height > tmp2->height ) { push(tmp1); tmp1 = tmp1->left; } else { push(tmp2); tmp2 = tmp2->left; } } /* один список пуст, поместить в стек остальные */ while( tmp1 != NULL ) { push(tmp1); tmp1 = tmp1->left; } while( tmp2 != NULL ) { push (tmp2); tmp2 = tmp2->left; } /* теперь все блоки в стеке */ /* сложить их вместе, чтобы собрать в один */ current = pop(); while( !stack_empty ) { next = pop(); if (next->height > current->height) { next->left = current; /* добавить перед списком left*/ current = next; } else if (next->height == current->height) /* добавить блоки */ { if( next->key < current->key ) { next->left = current->left; current->left = next->right; 5.6. Двоичные кучи 233 next->right = current; next->height += 1; current = next; } else { next->left = current->right; current->right = next; current->height += 1; } } else /* next->height < current->height */ { next->left = current->left; /* обменять current на next */ current->left = next; /* вставить next чуть ниже current */ } } return(current); } Код delete_min требует некоторой осторожности, так как здесь нужно сохранить адрес корневого узла, а результатом операции merge может быть совсем другой узел. То же справедливо и для операции insert. Здесь мы просто копируем корень в другой узел, а затем возвращаем результат обратно. Этого можно избежать, если использовать узел-заполнитель чуть выше корня. Этот узел может также иметь ссылку на текущий наименьший узел на крайнем левом пути, выполнив операцию find_min со временем O(1) за счет переноса минимального времени обновления на операции insert и delete_min. Для большей правильности структуры можно отказаться от узла-заполнителя. Ниже приводится код для операций delete_min и insert. object_t *delete_min(heap_node_t *hp) { object_t *del_obj; heap_node_t *tmp1, *tmp2, *min1, *min2; key_t tmp_key; if( hp->height == 0 ) /* удалить последний объект, поскольку куча пуста */ { hp->height = -1; return (hp->object); } /* теперь можно предположить, что куча не будет пустой */ tmp1 = tmp2 = hp; tmp_key = hp->key; min1 = min2 = hp; while( tmp1->left != NULL ) { tmp2 = tmp1; /* узел tmp2 выше tmp1 на левом пути */ tmp1 = tmp1->left; if( tmp1->key < tmp_key ) 234 Глава 5. Кучи { tmp_key = tmp1->key; /* min1 - наименьший узел */ min1 = tmp1; min2 = tmp2; /* узел min2 выше min1 */ } } del_obj = min1->object; if( min1 != min2 ) /* min1 не корень, значит, есть узел выше */ { min2->left = min1->left; /* несвязанный min1 */ if( min1->height > 0 ) /* min1 имеет правое поддерево */ { tmp1 = min1->right; /* сохранить правое дерево */ min1->key = hp->key; /* копировать корень в min1 */ min1->object = hp->object; min1->height = hp->height; min1->left = hp->left; min1->right = hp->right; tmp2 = merge(min1, tmp1); /* и объединить */ } else /* min1 – это лист на левом пути */ { return_node(min1); return(del_obj); } } /* min1 - корневой узел, вышел и правильные поддеревья */ else if( min1->left != NULL ) tmp2 = merge(min1->left, min1->right); else /* min1 – корневой узел с правым поддеревом */ tmp2 = min1->right; /* объединение завершено: новый корень копируется обратно */ hp->ключ = ключ tmp2 hp->object = tmp2->object; hp->height = tmp2->height; hp->left = tmp2->left; hp->right = tmp2->right; return_node(tmp2); return(del_obj); } int insert (key_t new_key, object_t *new_obj,heap_node_t *hp) { heap_node_t *new_node, *tmp, *tmp2; new_node = get_node(); /* создать кучу из одного элемента */ 5.6. Двоичные кучи 235 new_node->height = 0; new_node->key = new_key; new_node->object = new_obj; new_node->left = new_node->right = NULL; tmp = get_node(); /* копировать корень в tmp_node */ tmp->left = hp->left; tmp->right = hp->right; tmp->key = hp->key; tmp->object = hp->object; tmp->height = hp->height; tmp2 = merge (new_node, tmp); /* объединить кучи */ hp-> left = tmp2-> left; /* объединение завершено, вернуть корень обратно */ hp->right = tmp2->right; hp->key = tmp2->key; hp->object = tmp2->object; hp->height = tmp2->height; return_node(tmp2); return(0); } Подведем итог: Теорема. Структура двоичной кучи поддерживает операции insert, merge, find_min и delete_min за время O(log n). Средняя сложность операции insert составляет O(1). Любая последовательность из а операций insert и b операций delete_min или merge на множестве из n элементов с остающимися в итоге k кучами требует времени O(a + b log n + k log n). Ключевая идея структуры двоичной кучи – это разбиение ее на блоки канонического размера (2i для некоторого i), которые всегда имеют небольшую высоту, и их можно объединить в блоки большего размера за постоянное время. В такой структуре можно слить две кучи, выполнив двоичное сложение списков блоков. Возможны и иные реализации этой идеи, некоторые из которых обсуждались в [98]. Представление двоичной кучи в виде массива обсуждалось в статье [514]. Можно также изменить систему канонических размеров, если определить структуру блоков и правила объединения блоков одинакового размера в блоки большего размера. Это станет компромиссом сложности между операциями insert и delete_min, что было изучено в [199]. Структура двоичной кучи послужила основой для нескольких других куч, среди которых – описанная в разделе 5.8 куча Фибоначчи [230], соединяемая (pairing) куча [229] и ослабленная (relaxed) куча [182]. Соединяемая куча была очень популярна в течение некоторого времени, 236 Глава 5. Кучи потому что ее код проще; она, по сути, связана с двоичной кучей, как косая куча связана с левой кучей: она – самонастраивающаяся, и никакие условия выравнивания в ней не проверяются и не обновляются. Средняя оценка времени для них – O(log n), в отличие от двоичной кучи. Соединяемая куча была объектом нескольких опытных и теоретических исследований [510, 365, 223, 224, 288, 463]. Параметризованный вариант таких структур был обсужден в [192]. Полное преобразование структур типа двоичной кучи с отсрочкой сравнения иногда делает структуру более эффективной, что было изучено в статье [229]. Во всех этих структурах в качестве базовой модели используются полу­ упорядоченные деревья или деревья упорядоченных куч с изменяющейся кратностью. Структура, сочетающая в себе эти идеи со списком канонических блоков и с (двоичными) упорядоченными деревьями кучи вместо двоичных упорядоченных куч, – это так называемая M-куча [48], которая использует список полных двоичных упорядоченных куч деревьев в виде блочной структуры с возрастающими высотами блоков, за исключением первых двух. Тогда при операции insert первые два блока одинаковой высоты, если они существуют, объединяются в один или создается новый блок высотой 0, если его нет. Такая структура также обеспечивает в худшем случае время O(log n) для операций insert и delete_min. Любая из таких куч, основанных на двоичном добавлении блоков, дает среднюю оценку сложности O(1) для операции insert. 5.7. Изменение ключей в кучах Существует дополнительная операция с кучами, которая вызвала большой интерес к двоичным кучам и их различным вариантам с возможностью изменять их кучи в сторону уменьшения, что важно для алгоритма кратчайшего пути Дейкстры с одним источником и множеством комбинаторных алгоритмов оптимизации. Эта операция отличается от обсуждавшихся ранее операций тем, что она не определяет элемент, который нужно изменить. Куча не поддерживает операцию find, поэтому нужен указатель на структуру элемента, палец которого указывает на поиск в дереве. Этот палец возвращается операцией insert и должен ссылаться на элемент, пока он не будет удален. В любой реа­лизации это требует некоторой осторожности, так как узел, содержащий данный элемент, может измениться при операциях с кучей: • в куче в массиве элемент перемещается по массиву; • если же используются вращения как способ упорядочивания полу­ упорядоченных деревьев, то ранее описанное стандартное вращение копирует элемент в другой узел; • даже при реализации ранее описанной двоичной кучи элемент переносился в другой узел, хотя этого можно было бы избежать. 5.7. Изменение ключей в кучах 237 Возможные иные решения проблемы правильного хранения пальцев: • вводится один уровень косвенности, когда палец указывает на узел со ссылкой на текущий элемент, а сам текущий элемент ссылается на этот косвенный узел так, что его положение можно обновить; • код изменяется так, чтобы содержимое узла вообще не копировалось в другой узел, а менялись только ссылки. Даже в первом случае, когда куча находится в массиве, ее можно объединить с любой другой кучей, тогда как во втором случае этого сделать невозможно. Если решена задача идентификации элемента, то следующая задача – изменение ключа этого элемента. В первом случае обсуждались кучи в виде выровненных деревьев поиска, когда можно просто удалить элемент со старым ключом и заменить его новым, что требует времени O(log n). Такое сокращение времени при смене ключа достигается за счет последовательности операций delete и insert, которые работают в любой куче и позволяют удалять любые элементы. Но время можно сократить, если куча поддерживает операцию decrease_key. В этом случае тоже можно удалять любые элементы, но сначала ключ уменьшается до наименьшего, а затем выполняется операция delete_min. Классическая куча в массиве тоже поддерживает изменения ключей за время O(log n) простым перемещением элементов вверх или вниз, пока будет восстановлен требуемый порядок кучи. Все это обсуждалось в статье [294], но в ней нет никакой информации о том, как определить перемещаемый элемент, что характерно и для всех более поздних статей. Любое упорядоченное дерево куч могло бы поддерживать изменение ключей, если бы каждый узел имел бы обратную ссылку. В этом случае можно было бы перемещать элементы вверх или вниз согласно условиям кучи. Временнáя сложность при этом будет зависеть только от длины пути, по которому перемещается элемент, поэтому в худшем случае высота дерева не изменится. Ни левые, ни косые кучи не допускают сублинейных ограничений по высоте, поэтому их нельзя использовать для эффективных операций смены ключа. Двоичная куча, как это говорилось выше, имеет оптимальную высоту ⌊log(n + 1)⌋. Но и в ней тоже нужны обратные ссылки, чтобы перемещать элементы вверх к корню. Поскольку порядок двоичных куч не вполне отвечает общему порядку куч, есть разница между увеличением и уменьшением ключей. Если ключ узла уменьшился, нужно возвращаться обратно к корню, но при этом проверять порядок и, возможно, менять местами только те узлы, что лежат на правом пути; для левого пути этого не требуется. Так что операция decrease_key требует времени O(log n). Но если ключ узла увеличивается, придется проверять порядок во всех правых ветвях, к которым привязан левый путь, и, возможно, обменивать их с узлом с наименьшим ключом, и выполнять эту операцию до тех пор, пока условия левого 238 Глава 5. Кучи пути не будут полностью выполнены. Это потребует времени O((log n)2), но таким образом при удалении элемента и добавлении взамен его другого элемента его ключ сократится до минимума. Однако это будет все же быст­ рее, чем увеличение ключа. Но поскольку задача состоит в уменьшении значения ключей, то подобная операция становится довольно важной. Так что в двоичной куче выполняются все обычные операции, но кроме них – операции merge и decrease_key со временем выполнения O(log n). 5.8. Кучи Фибоначчи Операция decrease_key в различных комбинаторных алгоритмах оптимизации побудила разработку множества структур куч с этой же операцией, целью которых было достижение постоянного времени вместо времени O(log n). Этим структурам не вполне удалось достичь своих целей, поскольку оценки времени для них, по сравнению с худшими, получились всего лишь средними, но при применении данных структур в других алгоритмах, как нам известно, некоторые операции, особенно операция decrease_key, выполнялись гораздо чаще, чем операции insert и delete_min. Так что эти средние оценки вполне допустимы в алгоритмах для таких структур при оценке времени для худшего случая. Самая старая и самая известная из таких структур – это куча Фибоначчи [230], которая относится к двоичным кучам, описанным в разделе 5.6. Она представляет собой то же самое полуупорядоченное дерево, состоящее из блоков в самом левом пути, но структура этих блоков чуть слабее, так как они не всегда имеют разный размер и не всегда расположены в порядке убывания, как в двоичных кучах. При обновлении такой кучи почти все выравнивания этой структуры откладываются до следующей операции delete_min. Самый левый путь становится областью ожидания, где можно размещать блоки без их выравнивания и где не требуется их упорядочивания, а также соблюдения других условий для такой структуры. Структура кучи Фибоначчи выглядит следующим образом: в каждом узле n есть целочисленное поле n->rank и поле состояния n->state, которое может иметь значения complete (полный) или deficient (неполный). Тогда определяющими свойствами такой кучи являются: F1. Для любого узла n с рангом n->rank >= 1 и состоянием n->state = complete n->right ≠ NULL при этом F1.1. если n->state = complete, то левый путь от узла n->right имеет n->rank узлов с рангами по крайней мере n->rank – 1, n->rank – 2, ..., 0 в некоторой последовательности; F1.2. если же n->state = deficient, то отходящий от узла n->right левый путь имеет n->rank – 1 узлов, которые имеют ранги по крайней мере n->rank – 1, n->rank – 2, ..., 0 в некоторой последовательности. 5.8. Кучи Фибоначчи 239 F2. Для любого узла n с n->rank = 0 или n->rank = 1 и n->state = deficient n->right = NULL. 2 1 def 0 2 1 0 0 0 0 2 def 2 1 def 3 0 2 def 1 def 0 0 1 2 0 1 0 1 def 1 0 0 2 def 1 3 1 def 4 0 5 def 0 2 def 0 0 1 0 0 Рис. 5.18. Структура кучи Фибоначчи: каждый узел помечен рангом и статусом неполноты Если не допускать неполноту узлов, то нужно очень жестко уменьшать ранги в самом левом пути и усилить условие F1 следующим условием: B1. Для любого узла n с n->rank > 0 имеет место n->right ≠ NULL, и на левом пути ниже n->right есть узлы с n->rank, что могут только ранги n->rank – 1, n->rank – 2, ..., 0, причем в порядке убывания. В итоге куча Фибоначчи – это та же двоичная куча, но с несколько ослаб­ ленными условиями. Блок тоже состоит из узла n и нижнего поддерева n->right, и можно, как и в двоичной куче, добавить два блока ранга k к одному блоку ранга k + 1 за время O(1). Блок ранга k состоит из верхнего узла n и по крайней мере из k – 2 дополнительных блоков или k – 1 блоков, если n->state = complete, расположенных на левом пути ниже n->right, и имеет ранг не менее 0, 1, ..., k – 2. По­этому наименьшее количество f (k) узлов в блоке ранга k отвечает рекурсии f (k) = f (k – 2) + f (k – 3) + ... + f (1) + f (0) + 1. Используя формулу f (k – 1) = f (k – 3) + ... + f (1) + f (0) + 1, эту рекурсию можно переписать в виде f (k) = f (k – 1) + f (k – 2), что уже было в разделе 3.1. Здесь начальными значениями являются f (0) = f (1) = 1, так что f – это классическая начальная последовательность чисел Фибоначчи, послужившая именем этой куче. С помощью методов из раздела 10.4 можно разрешить эту рекурсию и получить в результате 240 Глава 5. Кучи 1 1 + √5 k+1 1 1 – √5 k+1 f (k) = ——�————� – ——�————� . √5 2 √5 2 Главное в куче Фибоначчи – это методы, поддерживающие такую структуру. Для этого в каждый узел нужно добавить две дополнительные ссылки: обычную обратную ссылку вверх up и вторую ссылку вверх по дереву r_up, которая для каждого узла n, не находящегося на самом левом пути, ссылается на тот узел m, для которого n находится на левом пути ниже m->right. Если же n находится на самом левом пути, то n->r_up устанавливается в NULL. Таким образом, структура узла кучи Фибоначчи имеет следующий вид: typedef struct hp_n_t { key_t key; object_t *object; struct hp_n_t *left; struct hp_n_t *right; struct hp_n_t *up; struct hp_n_t *r_up; int rank; enum { complete, deficient } state; } heap_node_t; При добавлении двух блоков одинакового ранга ссылки up и r_up можно изменить за время O(1). Хотя не исключено, что может быть много узлов со ссылками r_up на корневые узлы добавляемых блоков, но после их добавления они не нарушают ссылок r_up и не требуют их изменений. Кроме того, древовидная структура кучи Фибоначчи имеет ссылки на узел с наименьшим элементом и на последний узел самого левого пути. Поскольку куча Фибоначчи – это полуупорядоченнное дерево, то узел с наименьшим ключом находится где-то на самом левом пути. При наличии ссылки на наименьший элемент операция find_min будет выполняться за время O(1). Рис. 5.19. Куча Фибоначчи со ссылками r_up и обычными ссылками в дереве 5.8. Кучи Фибоначчи 241 Операция insert теперь очень проста: создается новый узел с рангом 0 и с новыми ключом и объектом. Этот узел помещается наверх самого левого пути. После этого проверяется, меньше ли новый ключ предыдущего наименьшего, и, возможно, исправляется ссылка на наименьший элемент. Тем же способом выполняется операция merge: два самых левых пути объединяются, для чего нужна левая ссылка на конец самого левого пути. Для операции decrease_key ситуация сложнее. Алгоритм уменьшения ключа в узле n работает следующим образом. 1. Уменьшить ключ в узле n. И если новый ключ меньше предыдущего наименьшего ключа, то новая ссылка становится ссылкой на наименьший ключ. 2. Если n->r_up = NULL, то узел n уже находится на самом левом пути. Для него не требуется никаких дополнительных условий, и процесс можно завершить. 3. В противном случае условие полуупорядоченного дерева может быть нарушено, как в n->r_up, так и, возможно, в некоторых вышестоящих узлах. Тогда нужно присвоить u значение n->r_up и изъять из левого пути узел n, где он находился, используя обратный указатель n->r_up, и поместить его в самый левый путь. 4. Теперь свойство F1 нарушено, так как потерян один узел в левом пути ниже u->right. 4.1. Если u->r_up = NULL, а u находится на самом левом пути, то, уменьшив u->rank на 1, свойство F1 восстанавливается, и процесс заканчивается. 4.2. В противном случае, если u->state = complete, нужно присвоить u->state значение deficient. После этого свойство F1 восстанавливается, и процесс заканчивается. 4.3. Иначе, если u->state = deficient, нужно уменьшить u->rank на 2 или на 1, если он стал отрицательным, а затем присвоить u->state значение complete. За счет этого восстанавливается условие F1 для узла u. Затем нужно отсоединить u от левого пути, к которому он принадлежал, используя обратную ссылку u->up, и поместить u на самый левый путь. После этого нужно присвоить u значение u->r_up и повторить шаг 4. Такой восходящий разрыв связей называется «каскадным» («cascading cat»). Он выполняется в операции decrease_key для узла n, если узлы n->r_up, n->r_up->r_up, ... находятся в состоянии deficient. На разрыв каждой связи требуется время O(1). Восходящий разрыв связей заканчивается, когда достигнут узел n->r_up->...->r_up, переходящий в состояние complete, а затем в состояние deficient, или когда достигнут узел в самом левом пути. 242 Глава 5. Кучи Этот узел единственный, состояние которого меняется на deficient, а все остальные, не связанные с ним узлы остаются в состоянии complete. Таким образом, в каждой операции decrease_key при выполнении k разрывов требуется время O(k), чтобы перевести один узел из состояния complete в состояние deficient и изменить состояние k – 1 узлов с deficient на complete. Поскольку каждый узел в состоянии deficient создается заранее операцией decrease_key, то любая последовательность из n таких операций, начинающаяся в куче из a узлов в состоянии deficient и заканчивающаяся в куче из b таких же узлов, потребует времени O(n + a – b) и памяти O(n + a – b) на самом левом пути. На все это для операции decrease_key потребуется среднее время O(1). Такая скорость достигается за счет задержки всех выравниваний с размещением элементов без обновления структуры в самом левом списке. Так что при наличии довольно большого количества операций decrease_key все элементы будут, в конце концов, размещены в этом списке в некотором порядке. Самый сложный этап – поиск нового наименьшего ключа после выполнения операции delete_min. Поскольку он может оказаться в любом узле самого левого пути, то придется пройти по всему этому левому пути. Это делается для сокращения самого левого пути с последующим выравниванием, чтобы очередная операция delete_min снова не попала на самый длинный путь самого левого пути. В этой операции нужно стремиться к тому, чтобы наибольший ранг любого узла этой структуры был O(log n), k+1 1— +— √5 поскольку для узла ранга k его блок содержит по крайней мере —1—�— —� √5 2 узлов. Операция delete_min работает следующим образом: 1. Отсоединить из самого левого пути узел n с наименьшим ключом. Затем поместить узлы n->right наверх левого пути и удалить узел n. 2. Создать массив ссылок на узлы размером Θ(log n) с записью для каждого узла возможного значения ранга. 3. Спуститься по самому левому пути. Установить n для следующего узла на самом левом пути. 3.1. Если массив ссылок не содержит запись с рангом n->rank, то нужно сохранить n в массиве и повторить шаг 3. 3.2. Иначе нужно удалить узел m того же ранга и добавить блоки, расположенные ниже n и m. Затем сделать n вершиной нового блока и повторить шаг 3.1. 4. Теперь все узлы, бывшие на самом левом пути, либо сохранены в массиве, либо стали частью других блоков. Пройдя по массиву, можно связать узлы вместе, чтобы получить новый самый левый путь. После этого нужно установить ссылку на узел с наименьшим среди них ключом, а левую ссылку – на последний узел самого левого пути. 5.8. Кучи Фибоначчи 243 Здесь шаг 1 требует времени O(log n), так как самый левый путь ниже n->right. Шаг 2 требует времени O(1). Шаг 4 зависит от размера массива и требует времени O(log n). Ключевым в оценке сложности алгоритма является шаг 3. В каждой его итерации используется один узел из самого левого пути, поэтому если перед операцией delete_min длина самого левого пути была l, то сам шаг 3 потребует времени O(l), а вся операция delete_min потребует времени O(l + log n), но сохранит длину самого левого пути порядка O(log n). Чтобы оценить общую сложность операций над кучей с n элементами, с i операциями insert, k операциями decrease_key и d операциями delete_min, можно сделать следующий вывод: • каждая операция insert требует времени O(1) и размещает один узел в самом левом пути; • каждая операция decrease_key требует времени O(1) при перемещении одного элемента на самый левый путь, а цепочка таких операций потребует времени не более O(k + n) на один элемент на самом левом пути; • каждая операция delete_min требует времени O(log n) плюс время O(1) на удаление элемента из самого левого пути. Итак, можно оценить производительность такой структуры. Теорема. Структура кучи Фибоначчи поддерживает операции find_ min, insert, merge, delete_min и decrease_key, find_min. Операции insert и merge требуют времени O(1), операция decrease_key требует среднего времени O(1), а операция delete_min – среднего времени O(log n). Любая последовательность из m операций на множестве куч с общим количеством n элементов, среди которых d delete_min операций, требует времени O(n + m + d log n). Куча Фибоначчи не вписывается в модель ссылочной машины, так как ей нужен массив для эффективного сбора узлов одинакового ранга. Поскольку поиск ведется по рангам, то средняяя сложность операции decrease_key повышается до величины O(log log n). Но если заранее известен размер массива и он невелик, то это обстоятельство не является существенным препятствием для ее эффективного использования. В заключение приводится реализация кучи Фибоначчи. Здесь используется узел-заполнитель без ключа в качестве точки входа, но со ссылкой left на текущий наименьший элемент, ссылкой up на конец самого левого пути и ссылкой right на корень кучи, если она не пуста. typedef struct hp_n_t { key_t key; object_t *object; struct hp_n_t *left; struct hp_n_t *right; 244 Глава 5. Кучи struct hp_n_t *up; struct hp_n_t *r_up; int rank; enum { complete, deficient } state; } heap_node_t; heap_node_t *create_heap (void) { heap_node_t *tmp_node; tmp_node = get_node(); tmp_node->right = NULL; return(tmp_node); } int heap_empty(heap_node_t *hp) { return (hp->right == NULL); } key_t find_min_key(heap_node_t *hp) { return(hp->left->key); } heap_node_t *insert(key_t new_key, object_t *new_obj, heap_node_t *hp) { heap_node_t *new_node; new_node = get_node(); /* создать новый узел */ new_node->right = NULL; new_node->key = new_key; new_node->object = new_obj; new_node->rank = 0; new_node->state = complete; if( hp->right == NULL ) /* добавить в пустую кучу */ { hp->right = hp->left = hp->up = new_node; new_node->left = NULL; } else /* куча не пуста: поместить ее наверх самого левого пути */ { new_node->left = hp->right; hp->right = new_node; new_node->left->up = new_node; if( hp->left->key > new_key ) /* обновить ссылку на min */ hp->left = new_node; } return(new_node); } heap_node_t *merge(heap_node_t *hp1, heap_node_t *hp2) 5.8. Кучи Фибоначчи 245 { if( hp1->right == NULL ) /* hp1 пуста */ { return_node (hp1); return (hp2); } else if( hp2->right == NULL ) /* hp2 пуста */ { return_node (hp2); return(hp1); } else /* обе кучи непустые */ { hp1->up->left = hp2->right; /* объединить самые левые пути */ hp2->right->up = hp1->up; /* объединить их ссылки */ hp1->up = hp2->up; /* восстановить самую левую ссылку */ if( hp1->left->key > hp2->left->key ) hp1->left = hp2->left; /* обновить ссылку min*/ return_node(hp2); return(hp1); } } void decrease_key(key_t new_key, heap_node_t *n, heap_node_t *hp) { heap_node_t *u, *tmp; int finished = 0; n->key = new_key; / * уменьшить ключ в n * / if( new_key < hp->left->key ) /* обновить ссылку min */ hp->left = n; while( n->r_up != NULL && !finished ) { u = n->r_up; /* n – на левом пути u->right: отсоединить n */ if( n == u->right ) /* n – на вершине левого пути u->right */ { u->right = n->left; if( n->left != NULL ) n->left->up = u; } else /* n опускается вниз по левому пути u->right */ { n->up->left = n->left; if( n->left != NULL ) n->left->up = n->up; } /* разорвать связь и добавить n в самый левый путь */ n->r_up = NULL; n->left = hp->right; n->left->up = n; 246 Глава 5. Кучи hp->right = n; /* восстановить u; при необходимости повторить обрезку */ if( u->r_up == NULL ) /* u уже на самом левом пути */ { u->rank - = 1; finished = 1; } else if( u->state == complete ) /* u становится незавершенным */ { u->state = deficient; finished = 1; } else /* u не завершен и не лежит на самом левом пути */ { if( u->rank >= 2 ) u->rank -= 2; else u->rank = 0; u->state = complete; /* ранг u – правильный */ } /* здесь нужно вырезать u из левого списка */ n = u; /* повторить операцию отмены связи */ } /* цикл while завершается «каскадным разрывом связей» */ } object_t *delete_min(heap_node_t *hp) { heap_node_t *min, *tmp, *tmp2; object_t *del_obj; heap_node_t *rank_class[100]; int i; key_t tmp_min; if( hp->right == NULL ) /* куча пуста: удалять нечего */ return(NULL); min = hp->left; /* отсоединить узел min в самом левом пути */ del_obj = min->object; if( min == hp->right ) /* узел min – теперь вверху самого левого пути */ { if( min->left != NULL ) /* вслед за min путь продолжается */ { hp->right = min->left; min->left->up = hp; } else /* min – наверху самого левого пути */ { if( min->right != NULL ) /* min – не последний */ { hp->right = min->right; min->right->up = hp; 5.8. Кучи Фибоначчи 247 min->right = NULL; } else /* теперь min - последний, куча пуста */ { hp->right = NULL; return_node(min); return(del_obj); } } } else /* от min спускаться вниз по самому левому пути */ { min->up->left = min->left; if( min->left != NULL ) /* min – это не последняя вершина */ min->left->up = min->up; } /* отсоединить min полностью */ /* спуститься вниз от min->right по самому левому пути */ if( min->right != NULL ) /* путь непустой */ { tmp = min->right; while( tmp->left != NULL ) /* найти конец пути */ tmp = tmp->left; tmp->left = hp->right; tmp->left->up = tmp; hp->right = min->right; min->right->up = hp; } /* теперь путь min->right связан с самым левым путем */ return_node(min); /* наименьший элемент удален */ /* начинается очистка */ for( i = 0; i < 100; i++ ) rank_class[i] = NULL; /* теперь выстраивается самый левый путь и собираются узлы одинакового ранга */ tmp = hp->right; /* взять 1-й узел на самом левом пути */ hp->right = hp->right->left; /* отвязать его */ while( tmp != NULL ) { if( rank_class[tmp->rank] == NULL ) { /* не найден ни один узел того же ранга: сохранить этот узел */ rank_class [tmp->rank] = tmp; tmp = hp->right; / * взять новый узел * / if( tmp != NULL ) hp->right = hp->right->left; /* отвязать этот узел */ } else /* найдено два узла одного ранга, добавить блоки */ 248 Глава 5. Кучи { tmp2 = rank_class[tmp->rank]; rank_class[tmp->rank] = NULL; if( tmp->key <tmp2->key ) { tmp2->left = tmp->right; tmp->right = tmp2; } else /* tmp->key> = tmp2->key */ { tmp->left = tmp2->right; tmp2->right = tmp; tmp = tmp2; } tmp->rank += 1; /* увеличить суммарный ранг блока */ } } /* все оставшиеся блоки сейчас в rank_class[] */ /* теперь перестраиваем самый левый путь */ hp->right = NULL; for( i = 0; i <100; i ++ ) { if( rank_class[i] != NULL ) { tmp = rank_class[i]; tmp->left = hp->right; hp->right = tmp; } } /* пересчитать ссылки на новый самый левый путь */ hp->left = hp->right; tmp_min = hp->left->key; for( tmp = hp->right; tmp->left != NULL; tmp = tmp->left ) { tmp->left->up = tmp; /* новые ссылки вверх */ if( tmp->left->key < tmp_min ) { hp->left = tmp->left; /* новая ссылка на min */ tmp_min = tmp->left->key; } } hp->up = tmp; /* конец самого левого пути */ /* фаза очистки закончена*/ return(del_obj); } 5.9. Кучи оптимальной сложности В разделе 5.1 уже отмечалось, что невозможно выполнить все операции с кучей за сублогарифмическое время. Для деревьев поиска с отложенным удалением было показано, что операция delete_min вместе с опера- 5.9. Кучи оптимальной сложности 249 цией insert выполняется с постоянным временем O(log n). Но поскольку операций insert гораздо больше, чем операций delete_min, возник вопрос: можно ли выполнить операцию insert, delete_min и все остальные операции, кроме удаления, с постоянным временем O(log n)? Ответ на этот вопрос во многом зависит от деталей, но (почти) положительный ответ с наилучшими структурами был приведен в статьях [87, 88]. Первым шагом в этом направлении была куча Фибоначчи [230], которая поддерживала операции insert, find_min и merge со средним временем O(1), а также операцию delete_min (вместо обычного удаления) со средним временем O(log n). Ценность такой структуры состоит в том, что, несмотря на усредненное время, этих оценок вполне достаточно, чтобы достичь в алгоритмах худших оценок времени при большом количестве операций с кучей, как, например, в алгоритме Дейкстры. Другие кучи – это пáрная [229], мягкая [182] и куча 2–3 [522]. Пáрные кучи, кроме их простой реализации, интересны еще и тем, что требуют для обычных операций всего лишь среднего времени O(log n) и, возможно, времени O(1) для операции decrease_key. Но в итоге в статье [223] для них была определена средняя оценка Ω(log log n). Мягкие кучи имеют два варианта, среди которых «пробежка» (run-relaxed) по куче, требующая в худшем случае времени O(1) для операций insert и decrease_key и времени O(log n) для операций find_min и delete_min. Однако кучи Фибоначчи и мягкие кучи не вписываются в модель ссылочной машины, поскольку требуют динамических массивов размера Θ(log n). Почти полный ответ на этот вопрос дан в двух структурах [87, 88]. Первая из них вписывается в модель ссылочной машины и поддерживает худшую оценку времени O(1) для операций insert, find_min и merge, а также оценку времени O(log n) для операций delete_min и decrease_key. Вторая структура снижает сложность операции decrease_key до постоянного времени, но не поддерживает модель ссылочной машины и требует динамических массивов размера Θ(log n). Другие структуры с теми же свойствами – это троичные кучи [521], а также кучи с преобразованием черного ящика [17]. Мы опишем здесь только первую структуру. Эта структура тоже представляет собой упорядоченное дерево в виде кучи с узлами довольно большой кратности, но выполняет операцию insert за постоянное время, сохраняя при этом саму структуру, а также выполняет операцию delete_min за время O(log n). Но здесь требуется много дополнительных структур с несколькими дополнительными ссылками в узлах, чтобы быстро пройти по остальным узлам, которые нужно исправить за постоянное время. Структура выглядит следующим образом: • Каждый узел имеет ключ меньший, чем у всех расположенных ниже его потомков, поскольку это дерево упорядоченных куч. • Каждый узел n имеет неотрицательный ранг для выравнивания дерева. 250 Глава 5. Кучи • Каждый узел имеет не более одного потомка, который может иметь любой ранг и множество потомков, чьи ранги меньше ранга этого узла. • Обычные потомки узла расположены в двусвязных списках по возрастанию их рангов. Ранги потомков узла n отвечают следующим свойствам: 1. Каждый ранг, меньший ранга узла n, может встречаться не менее одного и не более трех раз. 2. Между двумя трехкратными рангами может быть только один однократный ранг. 3. Перед первым трехкратным рангом может быть только однократный ранг. • Для каждого узла первые его потомки любого ранга, встречающиеся трижды, должны размещаться в связном списке в возрастающем порядке. • Корень имеет ранг 0. Структура узла такого дерева имеет следующий вид: 0 3 1 0 0 0 1 0 2 2 2 2 0 0 0 1 0 0 1 0 1 1 1 0 1 0 1 2 0 0 0 0 0 0 0 0 0 1 1 0 0 0 Рис. 5.20. Ранги узлов в куче Бродаля typedef struct hp_n_t { int key_t object_t struct hp_n_t struct hp_n_t rank; key; *object; *first; /* нижние соседи */ *last; /* нижние соседи */ 5.9. Кучи оптимальной сложности 251 struct hp_n_t *next; /* список того же уровня */ struct hp_n_t *previous; /* список того же уровня */ struct hp_n_t *thrice_repeated; struct hp_n_t *special; } heap_node_t; Согласно правилам кучи, корень содержит наименьший ключ, поэтому операция find_min выполняется просто и с постоянным временем. Операция insert сводится к стандартной операции merge с созданием кучи из одного нового элемента и объединением этой кучи со старой. Операция merge – основная и работает следующим образом. Пусть r1 и r2 – корни куч, которые нужно объединить. Поскольку корень имеет ранг 0, то у него может быть только один обычный потомок с любыми рангами r1->special и r2->special. Поэтому нужно добавить корень одной из куч в начало списка обычных потомков корня другой кучи, так как он имеет ранг 0. Но при этом нужно сохранить порядок кучи и последовательнось рангов в этом списке. Для упорядоченных рангов легко объединить поддеревья ниже двух некорневых узлов одинакового ранга в одно нижележащее дерево с бóльшим на единицу рангом: нужно просто увеличить ранг узла бóльшим ключом и добавить узел с меньшим ключом в конец списка обычных потомков в качестве нового узла с максимальным рангом. Шаги операции merge таковы: 1. Сравнить r1->key и r2->key и заменить r1 так, чтобы он стал корнем с наименьшим ключом. После чего он становится корнем объединенного дерева. 2. Сравнить r1->special->key и r2->key и обменять их местами так, чтобы выполнялось условие r1->key < r1->special->key < r2->key. 3. Если список трехкратных рангов ниже r1->special не пуст, перейти к первому рангу в списке и преобразовать два его узла в следующий более высокий ранг. Удалить этот ранг из списка трехкратных рангов, а если следующий более высокий ранг снова повторяется трижды, добавить его в список. 3.1. Если этот следующий более высокий ранг теперь совпадает с рангом r1->special, увеличить ранг r1->special на 1. 4. Вставить r2 в список обычных нижних потомков r1->special как r1->special->first. 4.1. Если в этом списке уже было два узла ранга 0, объединить их в один узел ранга 1. 4.2. Если ранг r1->special равен 1, увеличить его до 2. 4.3. Если теперь в списке три узла ранга 1, вставить первый из них в начало списка трехкратных рангов. 252 Глава 5. Кучи Эти операции восстанавливают требования к рангам. Шаг 3 перемещает первые трехкратные повторения рангов на шаг вперед или вообще снимает такое повторение при сохранении чередования некоторого ранга только один раз между любыми двумя трехкратными рангами до первого их появления. Это гарантирует, что ранг 1 встречается не более двух раз, так что, если необходимо, можно объединить два элемента ранга 0, чтобы оставить место на уровне 1. Операция delete_min более сложная. Общая стратегия ясна: сначала удаляется корень, его потомок перемещается вверх, и среди его потомков ищется наименьший ключ, который перемещается вверх и некоторым образом объединяет списки всех своих потомков. Однако здесь есть ряд трудностей. Для начала отметим, что упорядочивание рангов приводит к тому, что каждый узел имеет лишь O(log n) потомков. Узел ранга k имеет хотя бы одного потомка ранга 0, ..., k – 1, из чего по индукции следует, что поддерево с узлом ранга k в качестве корневого имеет не менее 2k узлов. Пусть r – исходный корень, n = r->special – его особый потомок, а m1, ..., ml – обычные потомки n. Первый шаг – это объединение n->special со списком обычных потомков n. Трудность здесь в том, что n->special может нарушить ранги обычных потомков n. Если n->special->rank ≥ n->rank, то можно уменьшить ранг списка n->special до ранга n->rank и присоединить верхнюю часть узлов ранга не меньше n->rank к самому верхнему списку потомков n. После этого ранг n->special уменьшится до n->rank, из-за чего ранг поддерева ниже n->special восстанавливается и исправляет n->special->thrice_repeated. Затем n->special добавляется в определенное место списка потомков n. После чего в n уже не будет n->special, но в списке его потомков может быть нарушен порядок следования рангов. Ранги все еще расположены в возрастающем порядке, и каждый ранг достигает наибольшей величины не более одного раза, но это может произойти более трех раз, и чередование рангов может быть утрачено. Но в списке более O(log n) узлов, так как каждый узел ранее был обычным предком либо n, либо n->special. Значит, нужно пройти по этому списку от n->first до n->last и всякий раз, когда встречаются три последовательных узла одного ранга, объединить два из них со следующим бóльшим рангом, чтобы в итоге каждый ранг встречался только один или два раза. Затем ранг n->rank увеличивается с до n->last->rank+1, и таким образом ранговые условия в n восстанавливаются. После этого очищается n->thrice_repeated. Все это требует времени O(log n), а n->special оказывается в спис­ке обычных потомков n. Теперь нужно пройти один раз по спискуот n->first до n->last, найти и отсоединить узел с наименьшим ключом. Пусть этот узел будет m. Тогда ключ и сам объект копируются из n в r с удалением предыдущего наименьшего ключа и переносом его из m в n. После этого список обычных потомков m объединяется со списком n, а список m->special копируется в список n->special, и сам список m удаляется. Список обычных потомков n теперь снова состоит 5.10. Двусторонние и многомерные кучи 253 из O(log n) узлов в порядке увеличения ранга, что, возможно, нарушает условие следования рангов, или ранг может вообще отсутствовать: если m был единственным узлом такого ранга в списке n, то этот ранг вообще мог бы отсутствовать. В этом случае берется следующий узел в списке и разделяется надвое так, чтобы четыре потомка этих двух узлов имели ранг на единицу меньше. Если это действие не дает нужного результата, то оно повторяется до конца списка. В заключение снова выполняется просмотр списка от n->first до n->last, и всякий раз, когда находятся три последовательных узла одного ранга, они объединяются с бóльшим рангом так, что каждый ранг будет встречаться только один или два раза. После этого n->rank устанавливается в n->last->rank+1, и куча снова будет соответствовать своим условиям. Все операции delete_min будут требовать времени O(log n). В итоге производительность такой структуры будет следующей: Теорема. Структура кучи Бродаля поддерживает операции insert, merge, find_min со временем O(1) в худшем случае, а delete_min – со временем O(log n) в худшем случае. Если добавить ссылки на вышестоящие узлы, то можно также довести время выполнения операции decrease_key и любых других операций удаления до времени O(log n). При этом необходима некоторая осторожность. Структура не дает границы высоты O(log n), так как список узлов ранга 0 со ссылкой r->special – это правильная куча. Поэтому стратегия состоит в том, чтобы идти вверх, пока не встретится ссылка r->special, которая, как описано выше, очищается и замещается текущим узлом. Предложенная в статье [116] куча в массиве с той же производительностью требует для операций insert и find_min в худшем случае времени O(1), а для операции delete_min – времени O(log n). Как всегда, кучи в массиве нельзя объединять, но за счет их неявного представления требования к памяти в них значительно ниже, чем в куче Бродаля, где требуется не меньше шести ссылок на один элемент. 5.10. Двусторонние и многомерные кучи Структуры кучи, которые мы обсуждали до сих пор, обеспечивают быстрый доступ к наименьшему из множества ключей, как уже здесь делалось, или к наибольшему, если изменить порядок их следования. Этого достаточно для всех приложений, но желательно обобщить задачу так, чтобы обеспечить быстрый доступ и к наименьшему, и к наибольшему ключам. Такая структура называется двусторонней кучей, которая должна поддерживать по крайней мере операции insert, find_min, find_max, delete_min, delete_max и, возможно, такие дополнительные операции, как merge или change_key. Если куча основана на выровненных деревьях поиска, как описано в разделе 5.1, можно сразу получить двустороннюю кучу с операциями над ней со временем их выполнения O(log n) и с операциями insert и change_key с постепенным удалением и тем же временем O(log n), а также операции 254 Глава 5. Кучи find_max, delete_min, delete_max со временем их выполнения O(1) в худшем случае. Все это не требует особых усилий в уравновешенном дереве поиска с листьями в виде двусвязного списка. Тем не менее было предложено множество других двусторонних куч28. Наиболее очевидным решением было бы иметь наименьшую и наибольшую кучи и добавлять каждый элемент в каждую из них, связывая две копии элемента ссылками. Это требует, чтобы нижележащие кучи поддерживали не только операцию удаления delete_min, но и удаление любых связанных элементов. Дублирование элементов приводит к тому, что операция insert делится на две операции добавления в нижележащих кучах. Операции delete_min и delete_max выполняют удаление в одной из нижележащих куч, а любое другое удаление – во всех остальных кучах. Если поддерживается операция merge, то она должна объединять две нижележащие кучи. Операция decrease_key не поддается обобщению до тех пор, пока нижележащая куча может менять ключи с обеих сторон, так как наименьшая и наибольшая кучи – разнонаправленные, что обсуждалось в разделе 5.7. Альтернатива дублированию элементов – группирование их в связанные пары, когда меньший элемент пары добавляется в наименьшую кучу, а больший – в наибольшую. Тогда операции удаления delete_min из наименьшей кучи или delete_max из наибольшей кучи действительно удаляют наименьший или наибольший элемент. Они разрывают связь только между одной парой, которую нужно исправить. Это может потребовать удаления еще каких-то элементов в одной из куч, если она содержала несколько несвязанных элементов, половину которых можно переместить в другую кучу. Если же нижележащая куча представляет собой упорядоченное дерево, то этого можно избежать, сравнивая только листья. Кучи в массиве изучались в работах [327, 112, 115, 363, 123, 133, 297]. В сущности, такие структуры различаются лишь тем, как они отображаются в массиве и как связываются, что влияет лишь на множитель в оценке времени O(log n) выполнения операций над ними. Поскольку все кучи – это массивы, они требуют для операций insert, delete_min, delete_max и даже для любого удаления элементов времени O(log n), а для операций find_min и find_max – времени O(1). Любые изменения ключа элемента в известной позиции можно выполнить за время O(log n) после его удаления и повторного добавления. Кучи в массивах можно объединять с другими упорядоченными структурами [39, 38] для реализации двусторонних куч. Они тоже требуют того же времени O(log n) для всех операций над кучами в массиве; единственное отличие – это дополнительные множители при сравнениях и, возможно, более сложная реализация. Куча с объединением ее наименьших и наибольших элементов, конечно же, не ограничивается только кучами в массивах. Она используется и в двоичных кучах [314], и в левых кучах [139], где рассматриваются все три ее варианта: дублирование элементов, глобальное связывание самих элемен28 Зачастую эта небольшая заметка 1964 года [557], где впервые было дано определение кучи, считается также первоисточником двусторонней кучи, что неверно. 5.10. Двусторонние и многомерные кучи 255 тов и их листьев. Этот метод изучается как общий принцип построения куч в статьях [142, 392]. Если нижележащая куча поддерживает операции merge и удаление любых элементов, то двусторонняя куча состоит из следующих частей: • • • • не более одного несвязанного элемента; наименьшая куча; наибольшая куча; связи между элементами наименьшей и наибольшей куч таковы, что в каждой связке элемент из наименьшей кучи обязательно меньше элемента из наибольшей кучи, и любой элемент может иметь доступ к связанному с ним элементу за время O(1). Теперь операции работают следующим образом: • insert: если есть несвязанный элемент, то новый элемент связывается с ним, меньший элемент добавляется в наименьшую кучу, а больший – в наибольшую. Если нет ни одного несвязанного элемента, то новый элемент становится несвязанным; • find_min: выполняет поиск в наименьшей куче и сравнивает результат с несвязанным элементом; если он есть, то возвращается меньший; • find_max: выполняет поиск в наибольшей куче и сравнивает результат с несвязанным элементом; если он есть, то возвращается больший; • delete_min: выполняет операцию find_min в наименьшей куче и сравнивает результат с несвязанным элементом, если он есть. Если несвязанный элемент меньше, он удаляется и возвращается другой несвязанный элемент. Иначе эта операция выполняется в наименьшей куче, затем выполняется обычная операция удаления delete элемента из наибольшей кучи и перенос его посредством операции insert из наибольшей кучи; • delete_max: выполняет операцию find_max в наибольшей куче и сравнивает результат с несвязанным элементом, если он есть. Если несвязанный элемент больше, он удаляется и возвращается другой несвязанный элемент. Иначе эта операция выполняется в наибольшей куче, затем выполняется обычная операция удаления delete элемента из наименьшей кучи и перенос его посредством операции insert из наименьшей кучи; • merge: выполняет объединение двух наименьших куч и двух наибольших куч. Если есть два несвязанных элемента (по одному в каждой из объединяемых куч), то операция сравнивает их и добавляет меньший в наименьшую кучу, а больший – в наибольшую. Если применить такое толкование к описанной в предыдущем разделе куче Бродаля [87], где операции insert и merge выполнялись со временем O(1), а операции delete_min и любые другие операции удаления – со 256 Глава 5. Кучи временем O(log n), то получится двусторонняя куча с операциями insert, find_min, find_max, merge со временем выполнения O(1) и операциями delete_ min, delete_max со временем выполнения O(log n) [142, 392]. Бродаль и сам предложил дублировать элементы, что приводит к той же производительности, но, правда, требует вдвое больше памяти для кучи. Если объекты вместе с ключами имеют несколько больший размер, это не имеет значения, так как сами объекты не дублируются. Теорема. Двусторонняя куча поддерживает операции insert, find_min, find_max, merge со временем их выполнения O(1) и операции delete_min, delete_max со временем их выполнения O(log n) в худшем случае. Позже двусторонние кучи со ссылками изучались в работах [437, 176], где используется иной порядок структуры куч с наименьшими и наибольшими слоями (layers), описанный в статье [39] для куч в массиве. Подобные кучи изучались также в работах [272, 513]. Дальнейшее обобщение двусторонней кучи – это многомерные или d-мерные кучи интервалов, предложенные в статье [363] и обсужденные чуть позже в статье [177]. Они манипулируют со множеством объектов, каждому из которых сопоставлен многомерный кортеж ключей с возможностью найти объекты с наименьшей или наибольшей координатой i для любого i = 1, ..., d. Это напоминает поиск интервалов, и действительно, в статье [363] было отмечено, что такая структура позволяет решать расширенный поиск прямоугольников, то есть выдавать точки за пределами данного прямоугольника за чувствительное к выходу время O (log n + k). Они реализованы в виде куч в массиве с операциями insert, delete_min и delete_ max для каждой координаты и временем выполнения O(log n). Многомерная наименьшая куча – это естественное обобщение всех этих структур: множество объектов, каждый из которых имеет d ключей в структуре, которая допускает добавление, поиск и удаление объекта с минимальной координатой i. Двусторонняя куча – это частный случай двумерной кучи, потому что в ней можно заменить каждый ключ на пару ключей (ключ–ключ). Тогда поиск в наибольшей куче сводится к поиску в наименьшей куче по второй координате. Поиск в многомерной куче интервалов – это частный случай поиска в двумерной наименьшей куче. Опять же, все это можно реализовать, используя несколько куч со связанными элементами, когда каждая куча соответствует одной координате [82]. Разница лишь в том, что нет возможности группировать элементы в d-кортежи и добавлять один из них в каждую кучу, поскольку не исключено, что один и тот же элемент окажется наименьшим для каждой координаты и потому должен добавляться в каждую кучу. Самый простой способ реализации такой структуры – дублирование элементов. Если есть d наименьших куч (по одной на каждую координату), то каждый элемент добавляется в каждую кучу, связывая ссылающиеся на один и тот же элемент узлы в циклический связный список. Тогда каждая операция insert сводится к d 5.11. Связанные с кучей и постоянным временем обновления структуры 257 добавлениям в нижележащие кучи, каждая операция delete_min по одной координате сводится к одной операции delete_min для одной кучи со спис­ ком копий, а d – 1 обычных удалений в определенных местах – для других куч. Операция merge просто объединяет d покоординатных куч. Используя кучу Бродаля в качестве основы кучи, получаем следующие оценки: Теорема. Многомерная наименьшая куча поддерживает операции insert, merge и find_min по каждой координате за время O(1) и операцию delete_min по каждой координате за время O(log n) в худшем случае. 5.11. Связанные с кучей и постоянным временем обновления структуры Исследовалось несколько структур, которые находят наименьший ключ в динамически меняющихся множествах, при условии что на эти изменения накладываются некоторые ограничения. Вообще говоря, чтобы любые добавления и удаления выполнялись за время O(log n), а поиск наименьшего элемента – за время O(1), можно воспользоваться деревом поиска. Но сейчас нам интересны те ситуации, когда обновления (добавления или удаления) выполняются значительно быстрее, чем за время O(log n), а в пределе – за время O(1). Простейший пример такой структуры – это отслеживание наименьшего элемента с помощью стека. Его можно представить в виде множества, изменяющегося особым образом: если элемент y добавляется после элемента x, то он должен удаляться до элемента х. Для множества наименьших ключей это значит, что либо добавление y уменьшает наименьший элемент, и тогда предыдущий наименьший элемент становится неактуальным до тех пор, пока y не будет удален, либо наименьший элемент не меняется. Таким образом можно отслеживать текущий наименьший элемент с помощью второго стека, куда он помещается. При каждой операции push со стеком выполняется сравнение текущего наименьшего элемента, иными словами, вершина второго стека сравнивается с новым элементом, а во второй стек вносится элемент с меньшим значением. При поиске элемента операцией find_min возвращается элемент из второго стека. Все эти операции требуют постоянного времени. Теорема. Двойной стек поддерживает операции push, pop и find_min со временем O(1) в худшем случае. 5 5 3 3 5 5 7 3 3 3 5 5 8 7 3 5 3 3 3 5 2 8 7 3 5 2 3 3 3 5 8 7 3 5 3 3 3 5 Рис. 5.21. Двойной стек с поддержкой наименьшего элемента: левая его часть содержит сам элемент, а правая – текущий наименьший элемент 7 3 3 3 5 5 258 Глава 5. Кучи Та же задача для очереди вместо стека несколько сложнее, но не менее важна. Структура наименьшей очереди наименьших элементов поддерживает операции enqueue, dequeue и find_min. Она моделирует на можестве элементов скользящее окно, в котором можно найти наименьшее значение ключа. Один из вариантов очереди наименьших элементов – это разбиение всего множества объектов на группы последовательных объектов определенного размера так, чтобы сами точки разбиения имели небольшие значения. Таким образом, каждая точка разбиения определяет интервал последующих потенциальных точек разбиения, которые тоже являются очередями, где нужно найти наименьшее значение следующей точки разбиения как функции предыдущей точки разбиения. Эта задача была впервые рассмотрена в [405] в контексте выбора точек разбиения страниц в индексной структуре внешней памяти с использованием куч [169]. Та же задача возникает и в других контекстах, например при форматировании текста при разбиении его на строки. Простая версия очереди наименьших элементов со средним временем выполнения операций O(1) работает следующим образом. Есть очередь объектов, а также дополнительная двойная (на самом деле она скорее полуторная) очередь с наименьшими значениями ключа. Операции с ней выполняются так: • enqueue: добавляет объект в конец очереди; удаляет из конца очереди наименьших ключей все ключи, превышающие ключ нового объекта, а затем добавляет новый ключ в конец очереди наименьших элементов; • dequeue: удаляет объект из начала очереди и возвращает его; если его ключ совпадает с первым ключом очереди наименьших объектов, то он вообще удаляется из очереди; • find_min: возвращает значение ключа из начала очереди наименьших ключей. 5 5 6 8 3 7 6 3 7 5 7 5 6 8 3 6 3 3 7 5 3 6 8 6 3 7 3 4 6 8 4 8 3 7 8 3 4 6 4 4 4 Рис. 5.22. Двойная очередь с поддержкой наименьших элементов: сверху – сам элемент, снизу – его наименьшее значение Такая структура двойной очереди требует среднего времени O(1), так как каждый объект и каждый ключ добавляются и удаляются один раз. Правда, в одной из операций добавления в очередь не исключено многократное удаление ключей из очереди наименьших элементов. Ниже приводится реа­лизация двойной очереди: 5.11. Связанные с кучей и постоянным временем обновления структуры 259 typedef struct qu_t { key_t key; object_t *object; struct qu_t *next; struct qu_t *prev; } queue_t; queue_t *create_minqueue() { queue_t *entrypoint; entrypoint = get_node(); /* создать пустую очередь объектов под entrypoint->next */ entrypoint->next = get_node(); entrypoint->next->next = entrypoint->next; entrypoint->next->prev = entrypoint->next; /* создать пустую очередь наименьших объектов под entrypoint->prev */ entrypoint->prev = get_node(); entrypoint->prev->next = entrypoint-> prev; entrypoint->prev->prev = entrypoint-> prev; /* наименьший в пустом множестве – +infty */ entrypoint->prev->key = POSINFTY; /* пустая очередь создана */ return(entrypoint); } int queue_empty (queue_t *qu) { return(qu->next->next == qu->next); } key_t find_min_key(queue_t *qu) { return(qu->prev->prev->key); } object_t *find_min_obj(queue_t *qu) { return (qu->prev->prev->object); } void enqueue (object_t *new_obj, key_t new_key, queue_t *qu) { queue_t *new, *tmp; tmp = NULL; /* создать узел и добавить его с новым объектом и ключом */ new = get_node(); new->object = new_obj; new->key = new_key; /* добавить узел qu->next->next в конец очереди объектов */ new->prev = qu->next; qu->next->next->prev = new; new->next = qu->next->next; qu->next->next = new; 260 Глава 5. Кучи /* удалить все большие ключи в конце очереди наименьших ключей */ while( qu->prev->next != qu->prev && qu->prev->next->key > new_key ) { if( tmp != NULL ) /* вернуть узел, если только получен другой */ return_node(tmp); tmp = qu->prev->next; / * теперь отключить tmp * / qu->prev->next = tmp->next; qu->prev->next->prev = qu->prev; } /* создать узел с новым ключом */ new = (tmp != NULL)? tmp: get_node(); new->object = new_obj; new->key = new_key; /* добавить узел в конец очереди наименьших ключей, как qu->prev->next */ new->prev = qu->prev; qu->prev->next->prev = new; new->next = qu->prev->next; qu->prev->next = new; } object_t *dequeue (queue_t *qu) { queue_t *tmp; object_t *tmp_object; if( qu->next->next == qu->next ) return(NULL); /* удаление из пустой очереди */ else { /* отвязать узел от начала очереди объектов */ tmp = qu->next->prev; tmp_object = tmp->object; qu->next->prev = tmp->prev; qu->next->prev->next = qu->next; /* проверить очередь наименьших объектов и отсоединить узел с тем же ключом */ if( tmp->key == qu->prev->prev-> key) { return_node(tmp); tmp = qu->prev->prev; qu->prev->prev = tmp->prev; qu->prev->prev->next = qu->prev; } return_node(tmp); return(tmp_object); } } void remove_minqueue(queue_t *qu) { queue_t *tmp; 5.11. Связанные с кучей и постоянным временем обновления структуры 261 /* связать все очереди qu->next->prev->next = qu->prev->prev->next = /* пройти по следующим do { tmp = qu->next; return_node(qu); qu = tmp; } while( qu != NULL ); в один список под next */ qu->prev; NULL; ссылкам и вернуть все узлы */ } Теорема. Двойная очередь поддерживает операции enqueue, dequeue и find_min со средним временем O(1). Структура, поддерживающая все операции с двойными очередями и операцию find_min за время O(1), в худшем случае описана в [239]. Еще одно расширение, допускающее сцепление со средним временем O(1), описано в [101]. Другое обобщение со временем O(1) в худшем случае – это куча наименьших элементов, которая отбрасывает при каждом добавлении все те элементы, ключи которых больше ключа нового элемента [516]. Здесь очередь наименьших ключей строилась на ранее описанной версии наименьших ключей. Ее замена на структуру [516] дает то же время O(1) в худшем случае для очереди наименьших элементов. Очереди наименьших элементов с операциями изменения ключа со средним временем O(1) использовались и в других приложениях [520]. Было предложено несколько структур куч, которые поддерживают обычные операции над ними, но используют при этом некоторые особые шаб­ лоны их обновления. Приоритетные очереди (queaps) [290] обеспечивают время выполнения O(1) для операции insert и время выполнения операции delete_min со средним временем O(log k), где k – количество элементов в куче, которых оказывается больше, чем в текущем наименьшем элементе. Таким образом, приоритетные кучи быстрее, если наименьший элемент всегда один из самых старых, так что новые элементы добавляются в кучу примерно в возрастающем порядке. Это достигается за счет наличия раздельных структур для «старых» и «новых» элементов с преобразованием всех «новых» элементов в «старые» каждый раз, когда текущий наименьший элемент попадает в «новую» часть. Таким образом, операция delete_ min находит наименьший элемент в обеих частях, но удаление чаще всего выполняет только в небольшой «старой» части. Структура «удочка» («fishspear») [210] работает лучше в обратном случае, когда текущий наименьший элемент обычно остается в куче только на короткое время. Такое происходит, когда добавляемые элементы выбираются из фиксированного распределения. «Удочка» требует среднего времени O(log m) для операции insert, когда наибольшее количество элементов m 262 Глава 5. Кучи меньше, чем в добавленном элементе, который существует до тех пор, пока снова не будет удален, а среднее время выполнения операции delete_ min – O(1). Подобное свойство было доказано [288] для куч со связыванием: среднее время выполнения операции delete_min для связанной кучи – O(log min (n, m)), где n – размер кучи на момент удаления, а m – количество операций между добавлением и удалением элемента. Как и в случае пальцевых деревьев и деревьев со всплывающими узлами, достоинство особых шаблонов обновления для приоритетных очередей и «удочки» довольно мало по сравнению с обычной кучей, если шаблон обновления не вполне строг. Глава 6 Системы непересекающихся множеств и связанные с ними структуры Задача, известная как организация «системы непересекающихся множеств» (union-find), заключается в таком разделении множества, при котором классы разделов можно объединить и получить ответ на вопрос о принадлежности двух элементов одному и тому же классу. Эта задача впервые обсуждалась в [35]29, а также в [240], с точки зрения определения эквивалентности идентификаторов, поскольку в Fortran и некоторых других ранних языках программирования имелась возможность дать несколько имен одной и той же переменной. Позднее были найдены гораздо более важные области применения, и описываемый здесь шаг разбиения мно­ жества, классы которого растут вместе, можно найти, например, в алгоритмах минимального остовного дерева Крускала и Борувки. Появление большого количества статей, посвященных этой и родст­ венным ей задачам, обусловлено не столько сложностью используемых структур, сколько трудностями анализа. Кроме того, как оказалось, правильный ответ во многом зависит от конкретного вопроса и вычислительной модели. Это одно из двух мест в алгоритмах30, где встречается обратная функция Аккермана – чрезвычайно медленно растущая функция, используемая не только как технический прием, но и позволяющая получить правильный порядок усредненной сложности классического решения этой задачи. Структуры непересекающихся множеств union-find, подобно двоичным деревьям поиска, являются полезными строительными блоками для построения более сложных структур данных. Но в данном случае следует быть особенно внимательными к набору поддерживаемых операций. 29 30 Одна из первых статей по алгоритмам в наших справочниках. И почти всей остальной математике. Другое место занимают последовательности Дэвенпорта– Шинцеля, нашедшие применение в упорядочении результатов некоторых задач вычислительной геометрии. P1: KAE brass6 264 cuus247-brass 978 0 521 88037 4 August 4, 2008 11:44 Глава 6. Системы непересекающихся множеств и связанные с ними структуры 6.1. Непересекающиеся множества: объединение классов разделов Классический вариант структуры union-find основан на следующей модели: есть множество элементов, на котором поддерживается некоторое разбиение. В это множество можно вставлять элементы, для каждого из которых в первый момент формируется класс раздела с одним элементом. Элементы идентифицируются указателями, ссылающимися на структуры, получающиеся в результате операции вставки. Это гарантирует доступ к элементам за постоянное время; в этой структуре нет ключа. Базовый раздел можно изменить, объединив два класса, причем классы определяются элементами, добавленными в них. К разделу можно обратиться, чтобы определить, относятся ли два элемента к одному классу. Итак, у нас есть следующие операции: • insert: получает элемент, возвращает указатель на узел, представляющий элемент, и создает для него класс с одним элементом; • join: принимает два указателя на узлы и объединяет классы, содержащие соответствующие элементы; • same_class: принимает два указателя на узлы и определяет, относятся ли их элементы к одному классу. Эти операции можно реализовать разными способами. Один из них: создать таблицу с классами элементов; в таком случае можно было бы быстро обрабатывать запросы, просто просматривая две записи в таблице и проверяя их совпадение, но, чтобы объединить два класса, потребуется изменить все записи в одном классе. Другой вариант: создать граф пар объединенных элементов, который можно очень быстро обновлять добавлением всего одного ребра, а затем использовать его для определения принадлежности двух элементов одному и тому же связанному компоненту. Но есть гораздо лучший класс методов, основанных на следующей идее, впервые представленной в [240]. Каждый класс представляется в виде ориентированного дерева, все ребра которого направлены в сторону корня. Тогда в каждом узле, представляющем элемент, достаточно иметь только один исходящий указатель, ссылающийся на соседа в дереве, который ближе к корню; в самом корне указатель получает значение NULL. a b c d e f g h i j k l m n o p q r Рис. 6.1. Классы {a, b}, {c, d, e, f, k, l, m}, {g, h, i, o, p, q, r}, {j} с отмеченными корневыми узлами 6.1. Непересекающиеся множества: объединение классов разделов 265 Учитывая это представление, можно легко узнать, принадлежат ли два элемента одному и тому же классу, проследовав от обоих узлов к их корням; они принадлежат одному классу, если при следовании по указателям достигается один и тот же корень. А объединить два класса можно, соединив корень одного дерева с корнем другого дерева. a b c d e f g h i j k l m n o p q r Рис. 6.2. Объединение двух классов путем соединения соответствующих корней Этот набросок оставляет довольно много свободы: нам предстоит решить, какой из двух корней должен стать общим корнем при объединении двух деревьев. Мы также можем реструктурировать дерево, в идеале сделав так, чтобы все вершины ссылались прямо на корень, потому что время, затрачиваемое на поиск ответа, равно длине пути к корню. В наиболее известном решении используются следующие два метода: • объединение по рангу: каждый узел имеет дополнительное поле ранга rank, которое в момент вставки получает начальное значение 0. Каждый раз, когда объединяются два класса, корень с бóльшим рангом становится новым корнем, а если оба корня имеют одинаковый ранг, то ранг увеличивается только в одном из них; • сжатие пути: каждый раз, когда выполняется поиск или обновление, путь к корню пересекается второй раз и во всех узлах обновляются указатели так, чтобы они ссылались непосредственно на корень. Обе эвристики были введены независимо, но одновременно, в нескольких работах31, например в [51] или [281]. Теперь мы можем написать реализацию этой очень простой структуры. typedef struct uf_n_t { int rank; item_t *item; struct uf_n_t *up; uf_node_t *insert(item_t *new_item) { uf_node_t *new_node; new_node = get_node(); new_node->item = new_item; new_node->rank = 0; new_node->up = NULL; 31 } uf_node_t; И в недоступных технических отчетах и личных сообщениях. 266 Глава 6. Системы непересекающихся множеств и связанные с ними структуры return( new_node ); } int same_class( uf_node_t *node1, uf_node_t *node2 ) { uf_node_t *root1, *root2, *tmp; /* найти оба корня */ for( root1 = node1; root1->up != NULL; root1 = root1->up); /* порйти путь к корню узла node1 */ for( root2 = node2; root2->up != NULL; root2 = root2->up); /* пройти путь к корню узла node2 */ /* переназначить указатели в обоих путях так, чтобы они ссылались непосредственно на соответствующие корни */ tmp = node1->up; while( tmp != root1 && tmp != NULL ) { node1->up = root1; node1 = tmp; tmp = node1->up; } tmp = node2->up; while( tmp != root2 && tmp != NULL ) { node2->up = root2; node2 = tmp; tmp = node2->up; } /* вернуть результат */ return( root1 == root2 ); } void join( uf_node_t *node1, uf_node_t *node2 ) { uf_node_t *root1, *root2, *new_root, *tmp; /* найти оба корня */ for( root1 = node1; root1->up != NULL; root1 = root1->up); /* пройти путь от узла node1 к корню */ for( root2 = node2; root2->up != NULL; root2 = root2->up); /* пройти путь от узла node2 к корню */ /* объединить ранги */ if( root1->rank > root2->rank ) { new_root = root1; root2->up = new_root; } else if( root1->rank < root2->rank ) { new_root = root2; root1->up = new_root; } else /* одинаковые ранги */ { new_root = root1; root2->up = new_root; new_root->rank += 1; } 6.1. Непересекающиеся множества: объединение классов разделов 267 /* переназначить указатели в обоих путях так, чтобы они ссылались непосредственно на соответствующие корни */ tmp = node1->up; while( tmp != new_root && tmp != NULL ) { node1->up = new_root; node1 = tmp; tmp = node1->up; P1: KAE } brass6 cuus247-brass 978 0 521 88037 4 August 4, 2008 11:44 tmp = node2->up; while( tmp != new_root && tmp != NULL ) { node2->up = new_root; node2 = tmp; tmp = node2->up; } } a b c d e f g h i j k l m n o p q r a b c d e f g h i j k l m n o p q r a b c d e f g h i j k l m n o p q r Рис. 6.3. Объединение классов с элементами a и c: объединение по рангу с последующим сжатием пути Сложность каждой из этих операций пропорциональна длине пути к корню, поэтому в наихудшем случае она равна высоте деревьев, полученных в результате этих операций. Как нетрудно увидеть, высота этих деревьев даже без сжатия путей равна O(log n). При построении плохих де- 268 Глава 6. Системы непересекающихся множеств и связанные с ними структуры ревьев с помощью последовательности этих операций можно избежать сжатия пути, если всегда выполнять операции join с корнями. Благодаря этому можно строить деревья с высотой Ω(log n), поэтому в худшем случае производительность этой структуры действительно равна O(log n). Объ­ единение по рангу – это лишь одно из нескольких очень похожих друг на друга правил, таких как объединение по высоте или объединение по весу, используемых для выбора корня на роль корня объединенного дерева в операции join; все эти правила имеют одинаковый эффект: дерево высотой h имеет не менее 2h узлов. Верхняя граница сложности O(log n) операций в этой структуре является точной, но только для первой из них: после выполнения операции, на которую потрачено много времени, мы за счет сжатия пути получаем гораздо лучшее древовидное представление. В результате у нас не может быть длинной последовательности операций, каждая из которых занимает Ω(log n) времени. Это говорит о том, что должна быть возможна лучшая усредненная граница, и это действительно так. После более ранних оценок в [209, 281, 347] Тарьян [527] получил известный результат, выражающий усредненную сложность в версии обратной функции Аккермана. Обратная функция Аккермана – это очень медленно растущая функция. Классическая функция Аккермана была определена Аккерманом как пример чрезвычайно быстро растущей функции в задачах определения вычислимости: A(m, 0) = 0 A(m, 1) = A(m – 1, 2) A(0, n) = 2n A(m, n) = A(m – 1, A(m, n – 1)) для m ≥ 1, для m ≥ 1, для n ≥ 0, для m ≥ 1, n ≥ 2. Эта функция Аккермана имеет две переменные, поэтому, к сожалению, не совсем ясно, какая функция является обратной по отношению к ней. Под этим именем существует несколько различных функций, часть из которых по техническим причинам выглядят довольно странно (например, Тарьян [527] использовал αTarjan(m, n) = min� k | A(k, 4 ⌈ m n ⌉ > log n �). В качестве обратной функции Аккермана мы будем использовать функцию α(n) = min{ i | A(i, 1) > n }. Теперь, имея эту функцию, можно определить производительность данной структуры union-find. Теорема. Структура union-find с объединением по рангу и сжатием пути поддерживает операцию insert со сложностью O(1) и операции same_class и join с одинаковой сложностью O(log n) на множестве с n элементами. Последовательность из m операций same_class или join с множеством из n элементов потребует времени O((m + n) α(n)). 6.1. Непересекающиеся множества: объединение классов разделов 269 Мы уже рассмотрели первую часть; максимальная длина любого пути в множестве с n элементами равна O(log n), поэтому время выполнения любой отдельной операции равно O(log n). Чтобы доказать вторую часть – усредненную границу, – определим последовательность разбиений (i)∞i=0 из IN. Здесь i представляет разбиение IN на блоки, являющиеся интервалами; j-й блок в i – это интервал [A(i, j), A(i, j + 1) − 1]. Каждый блок в i-м разделе является объединением блоков в (i − 1)-м разделе, потому что и [A(i, 0), A(i, 1) − 1] = [A(i − 1, 0), A(i − 1, 2) − 1] = [A(i − 1, 0), A(i − 1, 1) − 1] ∪ [A(i − 1, 1), A(i − 1, 2) − 1] [A(i, j), A(i, j + 1) − 1] = [A(i − 1, A(i, j − 1)), A(i − 1, A(i, j )) − 1] = [A(i − 1, A(i, j − 1)), A(i − 1, A(i, j − 1) + 1) − 1] ∪ ··· ∪ [A(i − 1, A(i, j) − 1), A(i − 1, A(i, j )) − 1]. Пусть bij – это количество блоков в i–1, которые вместе образуют j-й блок i, тогда bi0 = 2 и bij = A(i, j) − A(i, j − 1). Теперь α(n) – наименьшее i такое, что {1, . . ., n} содержится в 0-м блоке i. Определим уровень level узла v в данный момент в последовательности операций как 0 v->rank и level (v) = � min � i | v->next->rank в одном классе i если v->next = NULL � иначе. Если проследить за узлом v в последовательности операций, то можно заметить, что сначала он получает ранг, равный 0, который затем увеличивается последующими операциями join, но только до тех пор, пока v остается корнем своего дерева. Как только v станет некорневым узлом, его ранг перестает изменяться, и еще один важный момент – некорневой узел не может снова стать корневым. Таким образом, ранг v монотонно увеличивается, пока он остается корневым узлом, а затем фиксируется. До этого момента уровень level(v) равен 0; как только v станет некорневым узлом, level(v) начнет увеличиваться. Теперь появляется v->next->rank, дальнейшие операции могут только увеличивать его. Поскольку v->rank сейчас фиксирован, уровень level(v) может только увеличиваться. Прежде чем измерить общую работу, проделанную с узлом v в последовательности из m операций, следует сначала отметить, что пока v остается корневым узлом, в каждой операции с ним выполняется работа, равная O(1), при этом каждая операция затрагивает не более двух корневых узлов и, соответственно, часть работы, проделанной m операциями с кор- 270 Глава 6. Системы непересекающихся множеств и связанные с ними структуры невыми узлами, равна O(m). Основной вклад вносит работа с некорневыми узлами, то есть сжатие пути. Давайте посмотрим, как происходит сжатие пути; для этого требуется выполнить работу O(1) с каждым узлом в пути. Разобьем узлы в пути на две группы: • v принадлежит группе 1, если в том же пути присутствует узел w, ближе к корню, с уровнем level(v) = level(w); • иначе v является последним узлом со своим уровнем level и принадлежит к группе 2. Каждая операция выполняет сжатие не более двух путей, и в каждом пути имеется не более α(n) узлов группы 2, потому что существует только α(n) различных уровней. Так что общий объем работы, произведенной при выполнении m операций с узлами группы 2, равен O(m α(n)). Остается определить объем работы, выполняемой с узлами, которые принадлежат группе 1. Предположим, что x является таким узлом и в момент сжатия пути уровень level(x) = i. Таким образом, x->rank и x->next->rank принадлежат одному и тому же классу в i, но не в i–1. Поскольку x относится к группе 1, в пути есть другой узел y, более близкий к корню, с тем же уровнем level(y) = i. Пусть z – это корень. Поскольку ранги в пути увеличиваются, мы имеем: x->rank < x->next->rank ≤ y->rank < y->next->rank ≤ z->rank, и эту цепочку мы проходим не менее двух раз в i–1 для одного класса. Таким образом, z->rank и x->next->rank не принадлежат одному и тому же классу i–1. Поскольку после сжатия пути x->next будет ссылаться на z, это означает, что при каждом сжатии пути, в котором x участвует как вершина группы 1, и находясь на уровне i, ранг x->next перемещается в более высокий класс в i–1, но остается в том же классе i. Если x – некорневой узел, для которого x->rank содержится в j-м классе i, то x может участвовать как вершина группы 1 на уровне i не более чем в bij − 1 операциях сжатия пути. Пусть nij – количество узлов, ранг которых находится в j-м классе в i, когда они становятся некорневыми узлами и их ранг фиксируется. В таком случае общий объем работы, выполняемой m операциями с этими узлами в процессе сжатия путей, в которых они принадлежат группе 1, равен: α(n) � �nij (bij − 1). i=0 j n Чтобы определить nij, заметим, что существует не более 2k узлов с рангом k; ибо любой узел, достигший ранга k, является корнем дерева не менее чем из 2k узлов, и эти множества узлов не пересекаются. Соответственно 6.1. Непересекающиеся множества: объединение классов разделов 271 A(i,j+1)−1) nij ≤ � k=A(i,j) ∞ n n n —— < � —— = —— ——— . k k A(i,j)−1 2 2 2 k=A(i,j) Складывая вместе эти оценки и тривиальное ni0 ≤ n, получаем объем работы с узлами группы 1: α(n) α(n) α(n) � �nij (bij − 1) = �ni0 (bi0 − 1) + � �nij (bij − 1) i=0 j i=0 i=0 j≥1 α(n) n ≤ (α(n) + 1)n + � �—— ——— �A(i, j) −A(i, j − 1) − 1� A(i,j)−1 2 i=0 j≥1 α(n) 1 ≤ (α(n) + 1)n + � 2n�—— —— �A(i, j)� A(i,j) 2 i=0 j≥1 α(n) k ≤ (α(n) + 1)n + � 2n � —— 2k i=0 k≥A(i,j) α(n) A(i, 1) + 1 = (α(n) + 1)n + 8n � 2n ———A(i,1)−1 ———— 2 i=0 α(n) A(i, 1) + 1 = (α(n) + 1)n + 8n � ——— ———— A(i,1)+1 2 i=0 ∞ A(i, 1) + 1 < (α(n) + 1)n + 8n � ——— ———— A(i,1)+1 2 i=0 A(0, 1) + 2 < (α(n) + 1)n + 8n ——— ———— = n(α(n) + 1 + 8). 2A(0,1) Сложив с работой O(m), выполненной с корнями, и с работой O(m α(n)), выполненной с узлами группы 2, получаем общую сложность O((m + n) α(n)). Это доказательство представлено в [527, 531]. Альтернативные методы анализа данной структуры были предложены в [207], а также в [491]; все они приводят к одному и тому же результату. Сжатие путей, как и объ­ единение по рангу, является лишь одним из нескольких правил, имеющих одинаковый эффект и ведущих к одному и тому же результату, но требующих разных доказательств [532]. Эта усредненная граница объема работы, с учетом некоторых ограничений на m и n, известна как наилучшая возможная из нескольких вычислительных моделей [528, 529, 47, 532, 228, 465, 466], 272 Глава 6. Системы непересекающихся множеств и связанные с ними структуры поэтому появление обратной функции Аккермана не является артефактом доказательства. Как утверждается, оценка усредненной границы полезна, только если число операций m не меньше числа элементов n. Но, так как коли­чество нетривиальных операций join не превосходит n − 1, намного интереснее диагональный случай. Наша модель отличается от модели, лежащей в основе публикаций, посвященных этой проблеме, потому что в них определяется отдельная операция find для поиска корня и сжатия пути, после которой операция join считается допустимой только для корней. Усредненная граница, безусловно, не является наилучшей, если количество операций мало по сравнению с размером множества. Усредненная сложность классической структуры union-find является наилучшей, но сложность одной операции – нет. Структуры со сложностью log n –––––––––� операции join или same_class в наихудшем случае были предO�–log log n ложены в [75] и [505]. И снова эти оценки сложности в некотором смысле являются лучшими из возможных. Попытка одновременно достигнуть оптимальной усредненной и наихудшей сложности была предпринята в [13]. Чтобы уменьшить сложность операций в наихудшем случае, сохранив при этом представление в виде множества ориентированных деревьев, все ребра которых направлены в сторону корня, нужно уменьшить высоту деревьев. Высота определяется количеством узлов и полустепенью захода узлов. Поэтому нужно увеличить полустепень захода узлов. Идея, использованная в [505] для достижения этой цели, заключается в том, что в тех операциях join, когда оба корня имеют одинаковую высоту и небольшую полустепень захода, все входящие ребра одного корня перенаправляются в другой корень, в результате полустепень захода нового корня становится равной сумме предыдущих полустепеней, а высота остается прежней. Для этого нужно получить список всех узлов, исходящие ребра которых указывают на корень, чтобы изменить все эти ребра. Таким образом, время выполнения операции join пропорционально длине этого списка (полустепени захода корня) плюс высота дерева. Поскольку высота дерева с n внутренними узлами, всеми внутренними узлами степени k, равна n –––––� , с этим представлением нельзя получить сложность Θ(logk(n)) = Θ �––log log k log n log n –––––––––�, что соответствует k = Θ�––––––––––�. лучше, чем O�–log log n log log n Реализация этой структуры сопряжена с рядом проблем. Наше требование к полустепени захода для узлов меняется с изменением n, поэтому мы не можем сохранить это свойство в дочерних узлах с увеличением n. Также мы определили способ объединения только для двух деревьев одинаковой высоты. Для нас нежелательно вставлять дерево меньшей высоты в список потомков корня, потому что это увеличит длину списка, не добавляя много новых узлов в поддерево. Первую проблему можно преодолеть, потребовав, чтобы узел на высоте h, не являющийся корнем, имел по крайней 6.1. Непересекающиеся множества: объединение классов разделов 273 мере h! узлов в своем поддереве. Если мы сумеем обеспечить это требование, которое не зависит от n, то для всех узлов требование к высоте будет выполнено, потому что общее количество узлов не превосходит n, а h! ≤ n log n –––––––––�. Вторую проблему можно преодолеть, заставив означает, что h = O�–log log n корень меньшего дерева указывать не на корень большего дерева, а на некоторый узел в списке, указывающий на корень большего дерева. В таком случае список не увеличится, а если меньшее дерево имеет высоту не более h − 2, то высота тоже не увеличится. Теперь немного углубимся в детали. Каждый узел структуры имеет два указателя: • up, имеющий значение NULL в корне и указывающий на следующий узел в пути к корню во всех остальных узлах; • list, указывающий на список дочерних узлов в корне, на следующий узел в этом списке в дочерних узлах и имеющий значение NULL в остальных случаях. P1: KAE Каждый узел также содержит два числа: height cuus247-brass 978 0 521 88037 4 August 4,(высота) 2008 11:44и indegree (полустепень захода). С учетом этого правила объединения двух структур с корнями r и s можно выразить так (пусть r->height ≥ s->height ≥ 2): brass6 • если r->height > s->height, то в указатели всех дочерних узлов корня s и в самом корне s записывается ссылка на дочерний узел корневого узла r; • иначе, если r->height = s->height, то все дочерние узлы корня s добавляются в список дочерних узлов корня r; если r->height > r->indegree, то в указатель корня s записывается ссылка на один из дочерних узлов корня r; иначе s становится новым корнем, а r – его единственным дочерним узлом. r a b s c d x y r z a b c d x or или a r b c d x y s a z s s or или z y r b c d x y Рис. 6.4. Три случая объединения классов с корнями r и s z 274 Глава 6. Системы непересекающихся множеств и связанные с ними структуры С этими определениями теперь можно перейти к конкретной реализации. typedef struct uf_n_t { int int item_t struct uf_n_t struct uf_n_t height; indegree; *item; *up; *list; } uf_node_t; uf_node_t *uf_insert(item_t *new_item) { uf_node_t *new_node; new_node = get_uf_node(); new_node->item = new_item; new_node->height = 0; new_node->indegree = 0; new_node->up = NULL; new_node->list = NULL; return( new_node ); } int same_class( uf_node_t *node1, uf_node_t *node2 ) { uf_node_t *tmp1, *tmp2; /* найти оба корня */ for( tmp1 = node1; tmp1->up != NULL; tmp1 = tmp1->up); /* пройти путь от узла node1 к корню */ for( tmp2 = node2; tmp2->up != NULL; tmp2 = tmp2->up); /* пройти путь от узла node2 к корню */ /* вернуть результат */ return( tmp1 == tmp2 ); } void join( uf_node_t *node1, uf_node_t *node2 ) { uf_node_t *root1, *root2, *tmp; int i; /* найти оба корня */ for( root1 = node1; root1->up != NULL; root1 = root1->up); /* пройти путь от узла node1 к корню */ for( root2 = node2; root2->up != NULL; root2 = root2->up); /* пройти путь от узла node2 к корню */ if( root1->height < root2->height ) { tmp = root1; root1 = root2; root2 = tmp; } /* теперь root1 ссылается на большее поддерево */ if( root1->height >=2 ) 6.1. Непересекающиеся множества: объединение классов разделов 275 { /* вставка в поддерево root1 двумя уровнями ниже не изменит его высоту */ if( root2->height < root1->height ) { tmp = root2->list; /* обойти список дочерних узлов корня root2 */ while( tmp != NULL ) { tmp->up = root1->list; /* записать ссылку на узел из списка потомков корня root1 */ tmp = tmp->list; } root2->up = root1->list; /* в root2 тоже записать ссылку на этот же узел */ } else /* root2->height == root1->height */ { /* добавить список дочерних узлов root2 в список дочерних узлов root1, записать ссылки на root1 */ tmp = root2->list; tmp->up = root1; while( tmp->list != NULL ) { tmp = tmp->list; /* обойти список потомков корня root2 */ tmp->up = root1; } tmp->list = root1->list; root1->list = root2->list; /* связанные списки */ root1->indegree += root2->indegree; /* теперь списки объединены и их элементы являются потомками root1 */ if( root1->indegree <= root1->height ) root2->up =root1->list; /* записать ссылку на узел в списке потомков root1 */ else /* root2 становится новым корнем, а root1 – его единственным потомком */ { root1->up = root2; root1->list = NULL; root2->height += 1; root2->indegree = 1; root2->list = root1; } } } else /* root1->height <= 1*/ { if( root1->height == 0 ) { root1->height = 1; root1->indegree = 1; 276 Глава 6. Системы непересекающихся множеств и связанные с ними структуры root1->list = root2; root2->up = root1; /* root1 – новый корень */ } else /* root1->height == 1 */ /* любой корневой узел дерева с высотой 1 имеет ровно один дочерний узел */ { if( root2->height == 1 ) /* оба имеют высоту 1 */ root2->list->up = root1; /* сделать корень root1 дочерним узлом корня root2 */ root2->height = 2; root2->indegree = 1; root2->list = root1; root1->list = NULL; root1->up = root2; /* теперь root2 – новый корень */ } } } В этой структуре каждый узел на высоте h имеет полустепень захода не менее h, когда становится некорневым узлом, и не более h, пока остается корнем. Все дочерние узлы корня с высотой h сами имеют высоту h − 1, хотя позднее к ним могут присоединяться поддеревья, имеющие меньшую высоту. В результате каждый некорневой узел, находящийся на высоте h, в любой момент времени имеет не менее h дочерних узлов, находящихся на высоте h − 1, в дополнение к некоторым возможным дочерним узлам с меньшей высотой. Отсюда следует, что дерево высотой h в этой структуре содержит не менее (h − 1)! узлов; соответственно, с (h − 1)! ≤ n мы получаем log n –––––––––�. В заключение обсуждения производизаявленную оценку h = O�–log log n тельности этой структуры сформулируем следующую теорему: Теорема. Структура union-find, описанная выше, поддерживает операции insert с производительностью O(1) и same_class и join с произlog n –––––––––� на множестве с n элементами. водительностью O�–log log n Было предпринято много попыток расширить набор операций со структурами union-find, но даже простое удаление элемента из класса оказалось нетривиальной задачей, потому что сложность зависит от размера множества после удаления [303]. Для оценки можно использовать и наихудшее, и усредненное время32. Обзор вариантов и полученных результатов можно 32 При этом и без того непростая обратная функция Аккермана с двумя переменными получает третью переменную. 6.2. Система непересекающихся множеств с поддержкой копирования... 277 найти в [241]. Алгоритмы специальных последовательностей операций и известных заранее последовательностей объединений приводятся в [237 и 373]. Версия, поддерживающая отмену некоторых операций объединения для возврата к более раннему состоянию, имевшему место до выполнения этих операций объединения, изучалась в [396, 243, 244, 551]; в ограниченном варианте ссылочной машины нижняя амортизированная граница log n –––––––––�, и эта граница соответствует оценке наихудсложности равна Ω�–log log n шего случая для алгоритма [34]. Вариант структуры union-find, в котором операция same_class заменяется операцией проверки присутствия элемента x в множестве Y, обсуждалась в [304]. 6.2. Система непересекающихся множеств с поддержкой копирования и динамическими деревьями интервалов Структура, способная представлять обобщенные системы множеств, была бы очень полезна. До сих пор мы рассматривали очень ограниченную модель: множества должны быть непересекающимися, и поддерживается только их объединение. Таким образом, мы можем осуществить последовательность все более грубых разбиений, пока после n − 1 объединений все элементы не окажутся в одном классе. Другое менее очевидное, но не менее важное ограничение: наши элементы представлены указателями, а не ключами. Не существует варианта дерева поиска, поддерживающего объ­единение двух множеств. Конечно, мы можем использовать дерево поиска с указателями и получить O(log n) накладных расходов на каждую операцию, поэтому тривиального времени O(log n) для структуры union-find вполне будет достаточно. Как оказывается, детали очень важны при определении, какие расширения структуры union-find возможны, а какие нет. Для представления системы множеств, допускающей объединение, копирование (или неразрушающее объединение) и перечисление множеств, при условии что объединяются только непересекающиеся множества, можно сохранить характеристики производительности структуры union-find [341]. С другой стороны, если допустить возможность произвольного объединения, то нижняя граница производительности для последовательности из n операций в разумной модели будет равна Ω(n2) [367]. Таким образом, если допус­ тить участие пересекающихся множеств, сложность каждой операции увеличивается с сублогарифмической до, по крайней мере, линейной; причем реализация с линейным временем выполнения, использующая связанные списки, выглядит тривиально просто. На первый взгляд кажется, что крайне трудно гарантировать предположение о непересекающихся множествах, но оно имеет интересные приложения. 278 Глава 6. Системы непересекающихся множеств и связанные с ними структуры Структура непересекающихся множеств с поддержкой копирования (union-copy) ван Кревельда и Овермарса хранит набор элементов, представленных указателями, и множества, также представленные указателями. Она поддерживает следующие операции с элементами и множествами: • create_item: создает представление для нового элемента и возвращает указатель на него; • create_set: создает представление для нового множества и возвращает указатель на него; • insert: вставляет заданный элемент в заданное множество; требует, чтобы заданный элемент отсутствовал в множестве; • list_sets: перечисляет все множества, содержащие заданный элемент; • list_items: перечисляет все элементы, содержащиеся в заданном множестве; • join_sets: заменяет первое множество объединением двух заданных множеств и уничтожает второе множество; требует, чтобы два множества были непересекающимися; • join_items: заменяет первый элемент элементом, содержащимся во всех множествах, содержащих один из двух заданных элементов, и уничтожает второй элемент; требует, чтобы не существовало мно­ жества, содержащего оба элемента; • copy_set: создает новое множество, являющееся копией заданного множества, и возвращает указатель на него; • copy_item: создает новый элемент, являющийся копией заданного элемента, и возвращает указатель на него; • destroy_set: уничтожает заданное множество; • destroy_item: уничтожает заданный элемент. Из них только операции создания и вставки имеют сложность O(1), а сложность остальных зависит от сложности базовой структуры union-find, используемой в качестве основы для структуры union-copy. Однако эта структура не может использоваться напрямую – нужна дополнительная ее модификация. Базовая структура union-find, помимо обычной операции возврата текущего имени (корня) множества, содержащего данный элемент, должна также поддерживать обратную операцию, возвращающую список всех элементов множества с заданным корнем. Эти операции легко реализовать, потому что структура хранит только непересекающиеся множества: достаточно добавить в корень список указателей на элементы. Операция объединения будет просто объединять эти списки, а чтобы избавиться от указателей на начало и конец, можно использовать циклический связанный список. 6.2. Система непересекающихся множеств с поддержкой копирования... 279 works работает as как and и set объединение union множества list список Рис. 6.5. Строительный блок: расширенная структура union-find: с одной стороны возвращает корень множества, с другой стороны возвращает список элементов Базовое представление системы множеств выглядит следующим образом: структура данных состоит из узлов элементов, узлов множеств и множеств в двух расширенных структурах union-find (назовем их A и B), поддерживающих как обычные запросы поиска, так и запросы на получение списка. Это представление симметрично, как и поддерживаемые им операции, но поскольку указатели являются направленными ребрами графа, а две структуры union-find играют противоположные роли, мы опишем оба направления. В направлении от элементов к множествам структура организована следующим образом: 1. Каждый узел элемента имеет ровно одно исходящее ребро. 2. Каждое множество в структуре A имеет не менее двух входящих ребер (связывающих его с элементами множества) и ровно одно исходящее ребро (текущее имя множества, или корень). 3. Каждое множество в структуре B имеет ровно одно входящее ребро (текущее имя множества, или корень) и не менее двух исходящих ребер (связывающих его с элементами множества). 4. Каждый узел множества имеет ровно одно входящее ребро. 5. Элемент принадлежит множеству, если существует ориентированный путь от узла элемента до узла множества. 6. Между любым узлом элемента и любым узлом множества существует не более одного ориентированного пути. 7. Множества в одной структуре не связаны ребрами (из А в А или из В в В). Чтобы перейти от множеств к элементам, достаточно заменить свойства 1–4 их отраженными версиями: 1'. Каждый узел множества имеет ровно одно исходящее ребро. 2'. Каждое множество в структуре B имеет не менее двух входящих ребер (связывающих его с элементами множества) и ровно одно исходящее ребро (текущее имя множества, или корень). 3'. Каждое множество в структуре A имеет ровно одно входящее ребро (текущее имя множества, или корень) и не менее двух исходящих ребер (связывающих его с элементами множества). 4'. Каждый узел элемента имеет ровно одно входящее ребро. P1: KAE brass6 280 Глава 6. Системы непересекающихся множеств и связанные с ними структуры cuus247-brass 978 0 521 88037 4 August 4, 2008 11:44 То есть элементы связаны со своими множествами уникальными чередующимися путями через структуры A и B. Свойство чередования поддерживается в обновлениях путем объединения множеств всякий раз, когда множество оказывается напрямую связанным с другим множеством в той же структуре; это гарантирует сохранность и уникальность путей между узлами элементов и узлами множеств. 1 2 3 4 5 6 B 7 B A B A A A A B A C D E Рис. 6.6. Структура, представляющая систему множеств: A = {1, 2, 3}, B = {1, 2, 3, 4}, C = {3, 5}, D = {3, 6}, E = {6, 7} Свойство чередования является центральным, потому что оно позволяет ограничить общее количество ребер, связывающих структуры, и делает реали­зацию получения списка элементов чувствительной к объему вывода. Рассмотрим все элементы, имеющиеся в данном множестве; они соответствуют множеству ориентированных путей, которые в силу своей уникальности должны образовывать ориентированное дерево от узла множества через узлы в A и B к узлам элементов. В этом дереве каждый узел имеет только одно входящее ребро, и каждый узел в B имеет только одно исходящее ребро (согласно свойству 2'). Не существует двух последовательных узлов B (свойство чередования), поэтому, если связать входящие и исходящие ребра каждого узла B в одно ребро, мы получим граф на узлах A и элементов, в котором каждый узел A имеет не менее двух исходящих ребер (согласно свойству 3'). Таким образом, если положить, что общее коли­ чество листьев в этом дереве (т. е. узлов элементов, соответствующих элементам, содержащимся в множестве) равно k, то общее количество узлов A не будет превышать k – 1. Поскольку каждый узел B делит одно ребро этого графа и каждое ребро делится не более одного раза, в этом дереве может быть не более 2k − 1 узлов B. Таким образом, если множество содержит k элементов, то в структурах A и B будет иметься не более 3k − 2 узлов, через которые проходит дерево. Вычислив сумму по всем множествам, мы получим грубую оценку общего количества ребер между структурами A и B с одной стороны и мно­ жествами и узлами элементов – с другой: она сопоставима с общим размером системы множеств. Пусть n будет этим общим размером, то есть суммой размеров множеств в системе. Тогда обе структуры A и B являются структурами union-find, основанными на множестве размера n. 6.2. Система непересекающихся множеств с поддержкой копирования... 281 Из этого описания вытекает алгоритм для list_items. Чтобы перечислить все элементы данного множества, нужно выполнить следующие шаги: 0. Поместить в стек начальное исходящее ребро узла множества. 1. Пока стек не пуст, взять из стека следующее ребро. brass6 cuus247-brass 978 0 521 88037 4 August 4, 2008 11:44 1.1. Если ребро ведет к узлу элемента, добавить этот элемент в выходной список. 1.2. Если ребро ведет в структуру union-find A, запросить список и поместить в стек все исходящие ребра из полученного списка. 1.3. Если ребро ведет в структуру union-find B, запросить имя (корень) и поместить одно его исходящее ребро в стек. P1: KAE Если в результате выполнения этот алгоритм вернет список с k элементами, содержащимися в множестве, то шаг 1.1 выполнится k раз, каждый за время O(1), и не более 2k – 1 раз (согласно рассуждениям выше) выполнится шаг 1.3, который является обычным запросом получения имени (корня) в структуре объединения множества. Если принять, что время выполнения запроса в структуре равно uf(n), тогда алгоритму потребуется не более k uf(n) для выполнения этих запросов. На шаге 1.2 алгоритм выполнит также несколько j ≤ k − 1 запросов получения списков, возвращающих j списки a1, ..., aj элементов a с затратами O(1 + a) и �i=1ai = 2k − 1, т. е. в сумме шаг 1.2 занимает время O(k). Таким образом, общая сложность запроса list_items, возвращающего k элементов, зависит от объема выходных данных O(k uf(n)). То же верно для парного метода list_sets. 1 2 3 4 5 6 B 7 B A A B A A A A B C D E Рис. 6.7. Список элементов в множестве B Операция copy_set тоже имеет простую реализацию и выполняется за постоянное время. Для заданного узла множества выполняется переход по исходящему ребру. Существует всего два возможных случая: 1) если исходящее ребро узла множества ведет непосредственно к узлу элемента или к структуре A, то создается новый узел множества и множество с двумя новыми элементами в структуре B. Два узла множеств соединяются с элементами в B, а именем множества в B становится предыдущий исходящий указатель копируемого узла. Затем вновь созданный узел множества возвращается; P1: KAE brass6 282 Глава 6. Системы непересекающихся множеств и связанные с ними структуры 2) если исходящее ребро узла множества ведет в структуру B, то соз0 521 88037 4 4, 2008 11:44 в B, узел множества новый 978 узел множества и August новый элемент соединяется с элементом, и затем элемент добавляется в множество, на которое указывало предыдущее исходящее ребро. cuus247-brass дается И снова хочу обратить особое внимание на структуру union-find. Как вы наверняка помните, в первоначальном описании вставка нового элемента в множество не была отнесена к числу элементарных операций, потому что предполагала создание нового множества с одним элементом и его слияние с заданным множеством, а слияние, конечно, не является элементарной операцией, занимающей постоянное время. Но мы легко можем изменить структуру и разрешить вставку за постоянное время в то же множество, которому принадлежит данный элемент, просто скопировав указатель up без сжатия пути. Нам также нужно адаптировать все указатели в обратном направлении. Тем не менее все это можно сделать за время O(1). То же верно в отношении симметричной операции copy_item. A A B X X B X’ X X X’ B B X X X’ Рис. 6.8. Создание копии узла множества Ключевой операцией является join_sets. Она принимает два узла с множествами. В отношении их исходящих указателей возможны следующие случаи: 1) оба ведут к узлам в структуре A. В таком случае нужно выполнить объединение соответствующих множеств в структуре A и скорректировать указатель на узел множества, который теперь представляет объединение; 2) первый узел множества указывает на узел в структуре A, а второй – на узел в структуре B. В таком случае нужно создать новый элемент в A и добавить его в множество, на которое указывает первый узел множества. Этот новый элемент должен указывать на элемент в B, на который указывает второй узел множества. Первый узел множества теперь будет представлять объединение, а второй узел уничтожается; 3) оба узла множеств указывают на узлы в структуре B или узлы элементов. В таком случае нужно создать два новых элемента в структуре A и объединить их в новое множество. Элементы должны указывать на узлы в структуре B или узлов элементов, на которые ранее указывали узлы множеств, а множество в структуре A должно указывать на узел множества, представляющий объединение; 6.2. Система непересекающихся множеств с поддержкой копирования... 283 P1: KAE brass6 4) первый cuus247-brass узел множества наAugust узел4, в2008 структуре A, а второй – 978 0 521указывает 88037 4 11:44 на узел элемента. В таком случае нужно создать новый элемент в A и добавить его в множество, на которое указывает первый узел. Затем новый элемент должен указывать на узел элемента. Эти операции требуют в худшем случае одного объединения множеств и дополнительной работы со временем выполнения O(1). Соответственно, сложность операций join_sets и join_items составляет O(uf(n)). A A B A B A X B Y B X+Y B A X B Y X+Y A A A X Y X+Y X Y X+Y Рис. 6.9. Объединение двух узлов множеств, X и Y Теперь перейдем к операции insert, вставляющей элемент в множество, которое до сих пор его не содержало. Операция получает узел элемента и узел множества, с учетом исходящих указателей которых возможны следующие случаи: 1) узел множества указывает на узел в структуре А, а узел элемента указывает на узел в структуре А или узел множества. В таком случае нужно создать два новых элемента в структуре В и объединить их в новое множество. Затем это множество соединяется с узлом элемента, и один из новых элементов должен указывать на элемент в A или узел множества, на который ранее указывал узел элемента. Потом нужно создать новый элемент в A, добавить его в множество, на которое указывает узел множества, и объединить этот новый A-элемент с другим новым B-элементом; 2) узел множества указывает на узел в структуре A, а узел элемента – на узел в структуре B. В таком случае нужно создать новый элемент в A и добавить его в множество, на которое указывает узел множества, создать новый элемент в B и добавить его в множество, на которое указывает узел элемента. Затем два новых элемента должны указывать друг на друга; 3) узел множества указывает на узел в структуре B или узел элемента, а узел элемента указывает на узел в структуре A или узел множест­ ва. В таком случае нужно создать два новых элемента в A и объ­ 284 P1: KAE brass6 Глава 6. Системы непересекающихся множеств и связанные с ними структуры единить их в множество. Это новое множество должно указывать на узел множества, а один из новых А-элементов – на узел В или узел элемента, на который ранее указывал узел множества. Также нужно создать два новых элемента в B и объединить их в множество. Это новое множество должно указывать на узел элемента, а один из новых В-элементов должен указывать на узел А или узел множества, на который ранее указывал узел элемента. Затем два новых элемента cuus247-brass 978 0 521 88037 4 August 4, 2008 11:44 должны указывать друг на друга; 4) узел множества указывает на узел в структуре B или узел элемента, а узел элемента указывает на узел в структуре B. В таком случае нужно создать два новых элемента в структуре A и объединить их в новое множество. Затем множество соединяется с узлом множества, и один из новых элементов должен указывать на элемент в B или узел элемента, на который ранее указывал узел множества. Потом нужно создать новый элемент в B, добавить его в множество, на которое указывает узел элемента, и объединить этот новый B-элемент с другим новым A-элементом. Эти операции требуют только создания новых элементов в A и B, включения их в существующие множества и настройки некоторых указателей. Таким образом, операция insert имеет сложность O(1). y y y B A A A A X X+{y} A X X+{y} y y B A B B B A X y B B A B A y B y B A X+{y} X X+{y} Рис. 6.10. Вставка элемента y в множество X В заключение рассмотрим операцию уничтожения множества. И снова, чтобы иметь возможность удалять элементы, необходима некоторая модификация базовой структуры union-find. Необходимые модификации нетривиальны, если желательно сохранить оптимальную сложность [303] в усредненных или наихудших оптимальных границах. Операция destroy_ set получает узел множества. Алгоритм операции уничтожения подобен алгоритму операции получения списка множеств, и время его выполнения тоже зависит от размера уничтожаемого множества. 0. Поместить в стек начальное исходящее ребро заданного узла. 1. Пока стек не пуст, взять из стека следующее ребро. 6.2. Система непересекающихся множеств с поддержкой копирования... 285 1.1. Если ребро ведет к узлу элемента, удалить это ребро. 1.2. Если ребро ведет в структуру union-find A, запросить список и поместить в стек все исходящие ребра из полученного списка. 1.3. Если ребро ведет в структуру union-find B и множество, содержащее этот элемент, содержит по меньшей мере еще два элемента, то просто удалить элемент из B. 1.4. Если это ребро ведет в структуру union-find B и множество, содержащее этот элемент, содержит только один другой элемент, то нужно соединить узел, на который указывает этот другой элемент, непосредственно с узлом, на который указывает мно­ жество в B. Если оба этих узла являются узлами в A, то нужно объединить эти множества в A. Этот алгоритм почти полностью совпадает с алгоритмом операции list_items, только попутно должен объединить некоторые множества. Если уничтожаемое множество содержит k элементов, то алгоритм должен посетить O(k) узлов и выполнить O(k) объединений множеств, поэтому сложность операции destroy_set, применяемой к множеству с k элементами, составляет O(k uf(n)). Теперь обобщим все, что было сказано о производительности этой структуры. Если в качестве основы использовать структуру union-find, которая поддерживает операции объединения, удаления и получения имени за время uf(n), вставки нового элемента в множество за O(1) и получение списка за время O(k), то мы имеем следующее: Теорема. Структура union-copy хранит систему множеств с общим размером n и поддерживает операции: • create_item, create_set, insert, copy_set, copy_item со временем выполнения O(1); • list_sets, list_items со временем выполнения O(k uf(n)), где k – объем вывода; • join_sets, join_items со временем выполнения O(uf(n)); • destroy_set, destroy_item со временем выполнения O(k uf(n)), где k – размер уничтожаемого объекта. Здесь объединение разрешено только для непересекающихся множеств, а вставка может выполняться, только когда элемент отсутствует в множестве. Структура реализуется просто, если отказаться от поддержки операций удаления, delete_set и delete_item, и не требовать производительности выше uf(n) = O(log n) в приведенных выше границах сложности. В этом случае для операций поиска и объединения можно использовать деревья с методом объединения по рангу. Поддержку операции перечисления узлов расширенной структуры union-find можно организовать, либо поддерживая спи- 286 Глава 6. Системы непересекающихся множеств и связанные с ними структуры сок узлов для каждого дерева, либо связывая дочерние узлы каждого узла дерева в список, а затем выполняя обход этого дерева с использованием стека с размером O(log n). В любом случае мы получаем структуру, поддерживающую операции create_item, create_set, insert, copy_set, copy_item со временем выполнения O(1), чувствительные к объему вывода операции list_sets, list_items со временем выполнения O(k log n), где k – объем вывода, и join_sets, join_items со временем выполнения O(log n). Чтобы реализовать операции destroy_set и destroy_item, нужна возможность удаления узлов из деревьев с сохранением их сбалансированности. Для этого можно использовать структуру объединения множества с поддержкой операций удаления [303], или, пусть и за счет некоторой потери производительности, более простую реализацию структуры union-find, хранящую множества в сбалансированных по высоте деревьях. Теперь применим структуру к деревьям интервалов (segment tree). Деревья интервалов, описанные в разделе 4.2, – это статические структуры: они конструируются один раз на основе заданного набора интервалов, а затем используются для поиска. Чтобы поддержать хотя бы возможность вставки новых интервалов, можно использовать структуру union-copy. Идея в том, что с каждым узлом дерева связать множество интервалов, которое не требует для своей реализации никаких дополнительных структур33. Нужные нам деревья интервалов просто должны давать возможность вставлять интервалы в множества, связанные с узлами, при построении дерева и перечислять содержимое этих множеств во время поиска. Проблема создания динамической структуры заключается в невозможности изменить базовое дерево; чтобы вставить интервал, конечные точки которого еще не сущест­ вуют, нужно добавить в базовое дерево эти новые значения ключа, что не является проблемой на уровне листа, но после этого требуется перебалансировать дерево, а присоединенные множества в узлах плохо трансформируются при вращениях. Проблему с вращениями можно решить, выбрав другое представление множеств, присоединенных к узлам дерева, используя обсуждавшуюся выше структуру union-copy. Инвариантность должна сохраняться только для множеств в пути от корня к листу, и эти множества по своей природе являются непересекающимися, поэтому множество можно переместить вниз по дереву. Для этого оно удаляется из текущего узла, создается его копия и эти два экземпляра (исходное множество и его копия) объединяются с множествами в двух дочерних узлах. Создание копии занимает время O(1), а две операции объединения требуют времени O(uf(n)). В результате к узлу оказывается присоединено пустое множество, что избавляет нас от проблем при вращении. Таким образом, для каждого поворота требуется дополнительное время O(uf(n)), но существуют деревья поиска, которым требуется только O(1) поворотов на операцию вставки. Поскольку нам все 33 В отличие от деревьев пересекающихся интервалов, где элементы множеств, связанные с узлами дерева, были упорядочены так, чтобы можно было перечислить первые k из них за время O(k). Этот метод не распространяется на деревья интервалов в целом. 6.3. Разделение списка 287 равно нужно время O(log n) для вставки нового значения ключа в дерево, а uf(n) равно O(log n), то для нас это не проблема. Далее в дерево поиска, содержащее теперь новые интервалы как ключевые значения в листьях, должен быть вставлен новый интервал. Новый интервал вставляется в узлы за время O(log n), согласно его каноническому интервальному разложению. В каждом из этих узлов нужно выполнить одну операцию вставки в структуру union-copy, которая занимает время O(1). В результате общая сложность операций вставки в это полудинамическое дерево интервалов составляет O(log n). Сложность операций поиска несколько выше, потому что перечисление содержимого множества требует времени O(k uf(n)) вмес­то O(k) и зависит от объема вывода, и для k интервалов равна, соответственно, O(k uf(n)). Теорема. Дерево интервалов, использующее структуру union-copy для представления множеств, связанных с узлами дерева, поддерживает операцию insert вставки в дерево, уже содержащее n интервалов, за время O(log n), и операцию list_intervals перечисления интервалов для поиска значения, содержащегося в k интервалах, за время O(log n + k uf(n)), зависящее от объема вывода. В принципе, эта структура может быть полностью динамической. Из нее также можно удалить интервал, он представлен указателем, потому что наша структура union-copy поддерживает удаление элементов. Проблема лишь в том, что время удаления зависит от количества узлов, в которых представлен заданный интервал. Первоначально это время равно O(log n) и соответствует каноническому интервальному разложению, но увеличивается каждый раз, когда в процессе вращения копируется одно из множеств в узлах. Одно из решений этой проблемы – достаточно частая перестройка дерева. Благодаря этому и выбору различных структур для реализации базовых структур union-find A и B ван Кревельду и Овермарсу [341] удалось реализовать операцию удаления и удалить фактор uf(n) из времени выполнения операции перечисления, сделав дерево интервалов полностью динамическим. Та же структура использовалась для построения деревьев интервалов, позволяющих выполнять разбиение по значению ключа или соединение разделенных интервалов, по аналогии с деревьями поиска [339]. 6.3. Разделение списка В модели структуры данных union-find мы начинали с очень тонкого разделения и продолжали объединять классы, пока все элементы не оказывались в одном классе. Это предполагает обратную задачу: начав с одного класса, содержащего все элементы, итеративно разделить его. Концептуальная проблема заключается в том, что заранее неизвестно, как следует разделить класс: мы должны знать, какие элементы к какой части принадлежат, но в этом случае задача оказывается тривиально простой. Она становится 288 P1: KAE brass6 Глава 6. Системы непересекающихся множеств и связанные с ними структуры интересной только при наличии компактного способа представления выб­ ранного разбиения. В задаче разделения списка предполагается, что элементы линейно упорядочены, то есть заданы в виде списка, идентифицируются по указателям на элементы, а разделение задается элементом: сразу справа от него. Таким образом, список разбивается на все более мелкие подсписки, и каждый раз нужно снова ответить на вопрос, находятся ли два заданных элемента в одном и том же подсписке. Впервые эта задача была сформулирована Хопкрофтом и Ульманом [281] как обратная к задаче создания системы непересекающихся множеств (union-find), затем Габоу [235, 236] предложил вариант ее использования для решения задачи комбинаторной оптимизации, а Хоффманн с коллегами [278] – для сортировки пересечений двух кривых Жордана. В модели Габоу [235] элементы имеют ключевые значения и поддерживается определения значения cuus247-brass 978возможность 0 521 88037 4 August 4, 2008 максимального 11:44 ключа в списке текущего элемента. Итак, в задаче разделения списка наша модель изначально представляет собой упорядоченный список из n элементов, каждый из которых имеет вес. Затем этот список заменяется множеством списков, разбивающим элементы на интервалы в исходном порядке. Элементы идентифицируются по указателям. После некоторой предварительной обработки структура должна поддерживать следующие операции: • split: делит текущий список, содержащий заданный элемент, на два списка по границе непосредственно справа от этого элемента; • same_list: определяет, находятся ли два элемента в одном списке; • max_weight: возвращает указатель на элемент с наибольшим весом в списке, в котором находится заданный элемент. наибольший max 8вес 8 a 4 b 3 c 1 d 5 e 6 f 1 g 2 h 1 наибольший max вес 6 6 a 4 b 3 c 1 d 5 b 3 c 1 d 5 e 6 a 4 b 3 c 1 f 1 d 5 g 2 h 1 i 7 наибольший max 2вес 2 e 6 f 1 наибольший max6 вес 6 наибольший max вес 55 j 8 k 4 l 4 k 4 l 4 наибольший max 8вес 8 наибольший max вес 6 6 a 4 i 7 e 6 g 2 h 1 наибольший max 8вес 8 i 7 наибольший max 2вес 2 f 1 g 2 h 1 j 8 j 8 k 4 l 4 наибольший max 8вес 8 i 7 j 8 k 4 l 4 Рис. 6.11. Процесс разделения списка по границам после e, h и d Эти операции поддерживаются некоторыми сбалансированными деревьями поиска, например сбалансированными по высоте деревьями или красно-черными деревьями. На этапе предварительной обработки за время O(n) из списка строится единое сбалансированное дерево и в каждый 6.3. Разделение списка 289 узел включается указатель на элемент с наибольшим весом в его поддереве. Каждая операция разделения разбивает текущее дерево на два дерева. Операция same_list просто перемещается к корню текущего дерева и проверяет, находятся ли оба узла в одном пути к корню, а операция 1 перемещается к корню и возвращает указатель, хранящийся в нем. Каждая из этих операций в худшем случае выполняется за время O(log n). Кроме того, эта структура данных является динамической: она позволяет вставлять новые элементы в подсписок в виде соседей заданного элемента, а также удалять элементы и снова объединять списки, если дерево это поддерживает. Теорема. Используя любое сбалансированное дерево поиска, поддерживающее разделение и объединение, можно построить динамическую структуру, поддерживающую разделение списка, с операциями split, same_list, max_weight, join, insert и delete, выполняющимися за время O(log n) в худшем случае, где n – начальная длина списка. Для специальных приложений было предложено несколько улучшений усредненной сложности; здесь важно знать, что именно нужно. В [278] было использовано усовершенствование, дающее усредненное время выполнения O(1) операций вставки, удаления и разделения в последовательности из n операций, первоначально применяющихся к пустому списку. В этой работе отмечено, что (2, 4)-деревья с поуровневым связыванием, как описано в разделе 3.7, имеют усредненную стоимость операций вставки и удаления, равную O(1), как обсуждалось в разделе 3.3. Усредненное время операции разделения тоже оказывается меньше; разделение дерева с размером k на части с размерами k1 и k2 требует O(log min(k1, k2)) плюс усредненное время перебалансировки O(1) вместо O(log(k1 + k2)) в наихудшем случае. Эта разница хоть и невелика, но очень полезна, потому что сами члены O(log min(k1, k2)) могут быть усреднены за счет последовательного выполнения операций разделения. Это вытекает из потенциального аргумента. В качестве потенциала текущего семейства списков используется сумма потенциалов отдельных списков, при этом потенциал списка с длиной k равен k − log k. В таком случае если список с длиной k делится на два списка с длинами k1, k2 за время O(log min(k1, k2)), то изменение потенциала равно: potbefore − potafter = (k − log k) − (k1 − log(k1) + k2 − log(k2)) = −log(k) + log (max(k1, k2)) + log (min(k1, k2)) max(k1, k2) = log �———————� + log (min(k1, k2)) k 1 ≥ log —— + log (min(k1, k2)). 2 290 Глава 6. Системы непересекающихся множеств и связанные с ними структуры Выполняя последовательно n − 1 разделений списка с начальным размером n, отделяя списки с размерами k1, ..., kn−1, за время O(log k1), ..., O(log kn−1) получаем: potbeginning − potend = (n − log n) − n(1 − log 1) = −log n откуда следует: 1 1 ≥ �log k1 + log ——� + ··· + �log kn−1 + log ——�, 2 2 log k1 + ··· + log kn−1 ≤ n log 2 − log n = O(n). Таким образом, для последовательности из n – 1 разделений списка с начальной длиной n получаем усредненную временную границу O(1) на разделение. В этой структуре важно, чтобы узлы были с указателями, иначе один только поиск узла будет занимать время Ω(log n). Эта проблема отсутствует в версии структуры с разделяемым деревом поиска; там можно, например, идентифицировать узлы по их количеству в исходном списке. Для поиска можно воспользоваться преимуществом поддержки указателей в дереве с поуровневым связыванием и выполнять операцию same_list для двух элементов, заданных указателями, за время O(log d), где d – расстояние между элементами в исходном списке перед разделением или длина списка, содержащего любой из элементов в настоящее время. Другая стратегия улучшения усредненного времени предложена Хопкрофтом и Ульманом [281] и Габоу [235]. Ее суть заключается в увеличении степени узлов в модели дерева, используемой для представления списков. Это уменьшает высоту деревьев и позволяет ускорить операцию same_list. Поскольку требуется разделить все узлы на пути к корню, то при разделении списка, представленного деревом, узлы с большой степенью в худшем случае обходятся дорого. Но если к списку с длиной n не применяется никаких операций вставки и удаления, а только n – 1 операций разделения, то усредненная производительность будет лучше. Габоу [235] использовал схему блокировки, связанную с разделениями i, описанную в разделе 6.1, чтобы получить общую сложность O(n α(n)) для такой последовательности операций. Это дает усредненную сложность O(α(n)) для операции разделения и сложность O(α(n)) для same_class в худшем случае. Для случаев, когда желательно иметь одинаковую границу производительности операций разделения и поиска, Ла Путр [466] показал, что структура Габоу имеет оптимальную усредненную сложность. Если рассматривать списки как подынтервалы фиксированного интервала, то так же естественно снова соединить интервалы, чтобы объединить подсписки, следовавшие друг за другом в исходном списке. Это задача объединения–разделения–поиска (union-split–find). Ее подробно исследовали ван Эмде Боас, Каас и Зейлстра [193], а также Мельхорн, Наах и Альт [409]. Алгоритм из [193] выполняет каждую операцию за время O(log log n) в худ- 6.4. Проблемы ориентированных корневых деревьев 291 шем случае, которое, как показано в [409], является оптимальным. Интересно отметить, что «предположение о разделении» здесь имеет большое значение. Это техническое предположение об алгоритмах в ссылочной машине, введенное Тарьяном и использовавшееся как дополнительное условие во всех его нижних оценках в задачах union-find и других, связанных с ней; и только Ла Путре [465, 466] показал, что это предположение можно удалить из нижних границ. Но для задачи объединения–разделения–поиска, как было показано в [409], любой алгоритм, удовлетворяющий «условию разделения», требует времени Ω(log n), тогда как оптимальный алгоритм – Θ(log log n). 6.4. Проблемы ориентированных корневых деревьев Для реализации структуры union-find мы использовали ориентированные деревья, все ребра которых были направлены к корню. Для структур union-find это был просто инструмент организации данных, но есть также задачи, в которых деревья этого типа играют роль базовых абстракций. Наиболее хорошо изученной из таких задач является задача определения наименьшего (нижайшего) общего предка (Least Common Ancestor, LCA): P1: KAE наиболее удаленный от корня узел, лежащий на обоих путях от заданных brass6 cuus247-brass 978 0 521 88037 4 August 4, 2008 11:44 узлов. Если базовое дерево интерпретировать как генеалогическое дерево, то искомый узел является первым общим предком, а если дерево интерпретировать как упорядоченное множество, то искомый узел представляет собой соединение заданных узлов. Ориентированные корневые деревья могут использоваться для представления самых разных вещей, например Шарир [492] использовал их для представления разреженных функций на конечных числах и их конкатенаций. lca(x,y) x y Рис. 6.12. Корневое дерево с двумя узлами x, y и их нижайший общий предок lca(x, y) Впервые эта структура была изучена Ахо, Хопкрофтом и Ульманом [8]; примерно в то же время они изучали структуру непересекающихся множеств union-find. Задача управления корневыми деревьями имеет множество вариантов в зависимости от необходимой степени изменения дерева – только добавление листьев или связывание целых поддеревьев, связывание поддеревьев через какие угодно узлы или только через корни; также есть автономные варианты, в которых все операции должны объяв- 292 Глава 6. Системы непересекающихся множеств и связанные с ними структуры ляться заранее. Многочисленные возможные комбинации задач управления и соответствующая литература перечислены в [19]. Структура включает набор корневых деревьев и поддерживает как минимум следующие операции: • create_tree: создает новое дерево с единственным узлом, корнем, и возвращает указатель на корень; • add_leaf: добавляет новый лист к заданному узлу и возвращает указатель на этот новый лист; • lca: возвращает указатель на наименьшего (нижайшего) общего предка двух заданных узлов или NULL, если узлы принадлежат разным деревьям. Связывание целых поддеревьев – гораздо более сложная операция, чем простое добавление листа, поэтому не все структуры его поддерживают: • link: принимает два узла, x и y, принадлежащих разным поддеревьям, причем x является корнем своего поддерева, и связывает поддеревья, добавляя ребро, направленное от x к y. Имеются также операции, обратные операциям add_leaf и link, но их тоже довольно сложно реализовать: • delete_leaf: удаляет заданный узел, который должен быть листом; • cut: удаляет ребро, связывающее заданный узел с соседом выше, превращая заданный узел в корень нового дерева. Также есть несколько дополнительных операций, которые поддерживаются некоторыми структурами и могут пригодиться на практике: • find_root: возвращает указатель на корень дерева, которому принадлежит заданный узел; • depth: возвращает расстояние от заданного узла до корня. Оптимальные алгоритмы для create_tree, link и lca, найденные Альструпом и Торупом [19], основаны на использовании модели ссылочной машины. Их производительность соответствует нижней границе, доказанной Харелом и Тарьяном [270], и превосходит производительность более ранних структур, предложенных в [8, 385, 270 и 536]. Также изучались связанные проблемы в [236, 245, 100, 150 и 249]. Метод Альструпа и Торупа [19] выполняет последовательность из n операций link и m операций lca на наборе из n узлов за время O(n + m log log n), давая усредненное время O(log log(n)) на операцию. Далее мы рассмотрим лишь несколько наиболее простых структур, которые Альструп и Торуп объединили для преодоления ограничений отдельных структур. Одна из этих структур позволяет выполнять запросы lca за время O(log h), где h – высота базового дерева. Соответственно, если дерево с высотой 6.4. Проблемы ориентированных корневых деревьев 293 O(log n) сбалансировано, то это дает нам производительность O(log log n), к которой мы стремимся. Но структура не поддерживает обобщенную операцию link и позволяет добавлять только листья, а также поддерживает дополнительные операции find_root, depth и delete_leaf. Идея этой структуры основывается на двоичном поиске в путях от заданных узлов к корню. Предположим сначала, что оба узла находятся на одной глубине, имеют пути к корню одинаковой длины и нам нужно определить наименьшее значение i, соответствующее одному и тому же узлу в обоих путях. Если добавить в каждый узел список прямых ссылок до 2j-го узла на пути к корню, то мы сможем найти искомый первый общий узел с помощью двоичного поиска. Чтобы достичь времени O(log h), прямые ссылки должны проверяться в порядке от наибольшей к наименьшей, поэтому длина каждой ссылки будет проверена только один раз. lca x y Рис. 6.13. Корневое ориентированное дерево с узлами x, y, lca(x, y) и прямыми ссылками с длинами 2j от x и y Если заданные узлы находятся не на одной глубине, а на глубинах k1 и k2, где k1 > k2, то можно использовать те же прямые ссылки, чтобы заменить узел на глубине k1 узлом в его пути к корню, выполнив k1 − k2 шагов, и свес­ ти задачу поиска к общему случаю равной глубины. Эта идея двоичного поиска была представлена в [8], а затем дополнена и обобщена в [538 и 19]. Для такого поиска в каждом узле должен храниться список O(log h) прямых ссылок. Глубину узла можно восстановить с помощью двоичного поиска по прямым ссылкам, но проще хранить ее в узле. Это дает время поиска O(log h) и требует дополнительного пространства O(n log h); потребность в пространстве можно уменьшить до O(n), если присоединить список пря1 log h мых ссылок не ко всем узлам, а только к –––––– из них: тогда каждый поиск будет начинаться с перемещения вверх до следующего узла с прямыми ссылками. Фундаментальное ограничение этой структуры заключается в отсутствии возможности обновить все списки после операции link. Доба- 294 Глава 6. Системы непересекающихся множеств и связанные с ними структуры вить новый лист и построить его список из доступных списков несложно, но в link пришлось бы расширить множество списков. Поэтому данная структура имеет следующую производительность: Теорема. Структура lca, основанная на деревьях со списками экспо­ ненциальных прямых ссылок в узлах, поддерживает операции create_tree, depth со временем выполнения O(1) и add_leaf, delete_ leaf, lca и find_root со временем выполнения O(log h), где h – максимальная высота деревьев в базовом множестве. Эту структуру легко реализовать. В данном примере мы используем указатель up для представления ребер в базовом дереве и указатели next и prev для подключения двусвязного списка прямых ссылок. Узел дерева одновременно является и первым узлом списка; в последующих узлах списка указатель up ссылается на соответствующий элемент списка на 2j шагов впереди. Такая организация имеет незначительную проблему – узлы спис­ ка должны существовать как цели входящих ребер, даже если не имеют исходящих ребер, а количество узлов в списке, необходимых для входящих ребер, зависит не от глубины узла, а от максимальной глубины узлов, находящихся ниже текущего. Используемое здесь решение заключается в добавлении списка узлов в целевой список по мере необходимости. Альтернативой может быть немедленное создание для каждого узла дерева списка с узлами всех порядков на 2j меньше, чем глубина узла. Тогда все, кроме последнего, из этих узлов списка будут иметь правильный целевой узел для своих указателей up; последний узел будет иметь только входящие и не будет иметь исходящих ребер. typedef struct lca_n_t { int depth; item_t *item; struct lca_n_t *up; struct lca_n_t *next; struct lca_n_t *prev; } lca_node_t; lca_node_t *create_tree(item_t *new_item) { lca_node_t *new_node; new_node = get_lca_node(); new_node->item = new_item; new_node->depth = 0; new_node->prev = NULL; new_node->up = NULL; new_node->next = NULL; return( new_node ); } lca_node_t *add_leaf(lca_node_t *node, item_t *new_item) { lca_node_t *new_node; 6.4. Проблемы ориентированных корневых деревьев 295 /* создать узел дерева */ new_node = get_lca_node(); new_node->item = new_item; new_node->depth = node->depth + 1; new_node->up = node; new_node->prev = NULL; /* теперь создать список прямых ссылок */ { lca_node_t *tmp; int i; tmp = new_node; for( i = new_node->depth; i>1 ; i /=2 ) { /* добавить узел в список new_node */ tmp->next = get_lca_node(); tmp->next->prev = tmp; tmp->next->depth = tmp->depth; if( tmp->up->up->next == NULL ) { /* создать новый целевой узел */ tmp->up->up->next = get_lca_node(); tmp->up->up->next->prev = tmp->up->up; tmp->up->up->next->depth = tmp->up->up->depth; tmp->up->up->next->next = NULL; tmp->up->up->next->up = NULL; } /* теперь установить прямую ссылку */ tmp->next->up = tmp->up->up->next; tmp = tmp->next; } /* и завершить список */ tmp->next = NULL; } return( new_node ); } int depth(lca_node_t *node) { return( node->depth ); } lca_node_t *lca(lca_node_t *node1, lca_node_t *node2 ) { lca_node_t *tmp; int diff; if( node1->depth < node2->depth ) { tmp = node1; node1 = node2; node2 = tmp; } /* теперь node1 имеет большую глубину. Поднять его до уровня узла node2 */ { int diff; diff = node1->depth - node2->depth; while( diff > 1 ) { if( diff% 2 == 1 ) 296 Глава 6. Системы непересекающихся множеств и связанные с ними структуры node1 = node1->up->next; else node1 = node1->next; diff /= 2; } if( diff == 1 ) node1 = node1->up; while( node1->prev != NULL ) node1 = node1->prev; /* переместить обратно в начало списка */ } /* оба узла находятся на одном уровне */ if( node1 == node2 ) return( node1 ); /* если это не один и тот же узел, выполнить экспоненциальный поиск */ { int current_depth, step_size; current_depth = node1->depth; step_size = 1; while( current_depth >= 2* step_size ) { node1 = node1->next; node2 = node2->next; step_size *= 2; } /* максимальный размер шага, подняться вверх и уменьшить шаг */ while( current_depth >= 1 ) { if( step_size > current_depth ) { node1 = node1->prev; node2 = node2->prev; step_size /= 2; /* шаг слишком большой, уменьшить наполовину */ } else if( node1->up != node2->up ) { node1 = node1->up; /* узел на шаг выше все еще ниже lca */ node2 = node2->up; current_depth -= step_size; } else /* node1->up == node2->up */ { if( step_size > 1) /* верхняя граница для lca */ { node1 = node1->prev; node2 = node2->prev; step_size /= 2; } else /* непосредственно под lca */ return( node1->up ); } } return( NULL ); /* разные деревья */ } } 6.4. Проблемы ориентированных корневых деревьев 297 В данной реализации lca использовались числа для обозначения уровня, но вообще можно было бы ограничиться проверкой наличия указателей. Эта структура поддерживает также операцию level_ancestor, сообщающую, на какое количество k шагов предок ближе к корню. Это все тот же двоичный поиск вдоль пути к корню с использованием списка прямых ссылок, поэтому для выполнения этой операции требуется время O(log h). Операция level_ancestor изучалась Беркманом и Вишкиным [70], Алструпом и Холмом [15] и Бендером и Фарах-Колтоном [59]. Майер [385] и Алст­ руп и Холм [15] использовали другую схему прямых ссылок. Вместо спис­ ка со ссылками на узлы, находящиеся на 2i шага выше данного узла n для всех возможных i, они добавляли в каждый узел две группы ссылок: одну со спис­ком ссылок на узлы на j шагов выше для всех j ≤ 2r(n) и другую со ссылками на узлы выше, чем на r(n). Вторая группа ссылок используется для достижения за O(1) шагов узла, для которого ссылка на искомый узел находится в первой группе. Первая группа ссылок достаточно велика для некоторых узлов, но при правильном выборе функции r(n) в среднем имеет размер всего O(1). Реализовав группы ссылок в виде массивов, можно обеспечить выполнение операции level_ancestor за время O(1). В нашей ссылочной модели нам пришлось бы использовать дерево, чтобы найти j-й указатель в группе, что снова дает время наихудшего случая O(log h), которое мы уже имели с простым списком экспоненциальных шагов. К тому же поддерживать обновления в этой структуре достаточно сложно. Цакалидис [538] использовал еще одну систему ссылок, которая снова дает время выполнения операции O(log h), но дает возможность добавления листьев и удаления произвольных узлов за амортизированное постоянное время. Структура, описанная в [59], тоже поддерживает операцию level_ancestor со временем выполнения O(1), но ей тоже нужны массивы указателей, и она не допускает каких-либо обновлений. Производительность вышеупомянутых структур зависит от высоты h дерева. Достичь более высокой производительности O(log log n) вместо O(log h) можно за счет преобразования базового дерева. Этот прием был представлен Слейтором и Тарьяном [501] и использовался в [270] и [19]. Идея состоит в том, чтобы разделить ориентированное корневое дерево на ориентированные пути и сохранить их в сжатом графе как вершины, где каждый путь представлен узлом, ближайшим к корню, который называется вершиной пути. Две вершины в сжатом дереве соединяются ребром, если в исходном дереве пути были связаны каким-либо ребром, идущим от вершины одного пути к произвольному узлу в другом пути. Если мы сможем ответить на запрос lca в сжатом дереве, то мы сможем ответить на запрос lca в исходном дереве. Поиск в сжатом дереве выглядит немного сложнее: чтобы найти lca узлов x и y, сначала нужно отыскать путь p, содержащий lca, что является обычным поиском lca в сжатом дереве. Пути от x и y до корня в исходном дереве входят в путь p в вершинах x и y , а затем следуют за p до вершины 298 Глава 6. Системы непересекающихся множеств и связанные с ними структуры пути p. Таким образом, lca – первый общий узел этих путей – является узлом x и y , который ближе к корню и поэтому имеет меньшую глубину. Одно из возможных решений – использовать следующую структуру для представления сжатого дерева: ориентированное дерево со всеми исходными узлами и по одному дополнительному узлу для представления каждого пути; внутри каждого пути все узлы ссылаются на представителя пути, который в свою очередь ссылается на узел, на который ссылается вершина пути. В этом дереве операция lca c исходным деревом вернет одного из представителей пути, потому что все исходные узлы имеют полустепень захода, равную 1. Но если мы расширим код lca так, чтобы он возвращал два узла, непосредственно предшествующих первому общему узлу двух путей, что легко сделать, то правильным наименьшим (нижайшим) общим предком будет тот из этих двух узлов, который в исходном графе имеет меньшую глубину. Полезность такого сжатия зависит от того, действительно ли оно уменьшает высоту. Но это легко проверить. Чтобы определить разделение на пути, нужно для каждого узла выбрать одно из его входящих ребер. Если большая часть поддерева ниже текущего узла, например более 23, находится ниже одного из его дочерних узлов, то выбирается ребро до этого дочернего узла; в противном случае можно выбрать ребро до любого дочернего узла. В таком случае сжатое дерево имеет высоту O(log n), потому что каждое ребро сжатого дерева соответствует ребру исходного дерева, вдоль которого размер поддерева уменьшился как минимум на 23. 1 3 2 4 4 4 5 7 9 Рис. 6.14. Корневое дерево, разбитое на пути, и ребра сжатого дерева, входящие в вершину пути (цифрами обозначена глубина пути) Используя только это сжатое древовидное представление в сочетании с тривиальным поиском lca путем обхода путей к корню и определения первой общей вершины, можно получить структуру со временем O(log n) выполнения операции lca. При этом в каждом узле дерева должно сохраняться расстояние до вершины пути; это позволит вычислить глубину узла за O(log n), следуя по пути в сжатом дереве и суммируя расстояния. Получив глубину обоих узлов, можно снова обойти этот путь длиной O(log n) и найти первый общий узел, который должен находиться на той же глубине. Затем, 6.4. Проблемы ориентированных корневых деревьев 299 получив сжатый узел, который содержит искомого предка, можно выбрать правильный узел в исходном дереве, вернувшись на один шаг назад в сжатом дереве и сравнив глубину верхних соседей. При использовании этой структуры для соединения двух деревьев возникает проблема, заключающаяся в том, что может потребоваться изменить структуру пути в узлах, связывающих деревья, и, возможно, всех путей выше; а для этого может понадобиться посетить все узлы в этих путях, чтобы обновить сжатое дерево. Одно из возможных решений – реализовать сжатые узлы не только с помощью ссылок на представителя пути, но и построить сбалансированное дерево поиска в каждом сжатом узле для узлов пути с их глубиной в качестве ключа. Это позволит разделять и объединять отдельные узлы за O(log n), но тогда время обхода одного сжатого узла тоже будет равно Ω(log n). Теорема. Структура lca, основанная на сжатых деревьях, с узлами сжатого дерева, реализованными в виде деревьев поиска, поддерживает операцию create_tree со временем выполнения O(1) и операции lca, link, depth, find_root со временем выполнения в худшем случае O((log n)2), где n – количество элементов. В [501] описывается способ уменьшения сложности от наихудшей O((log n)2) до усредненной O(log n) с использованием смещенных деревьев поиска, а в [531] – с использованием косых деревьев. Также некоторого внимания заслуживает полностью статическая версия этой задачи, суть которой заключается в том, чтобы заданное ориентированное корневое дерево с размером n предварительно обработать за время O(n) и получить возможность быстро выполнять операцию lca или хотя бы операцию сравнения. Харел и Тарьян [270] предложили метод, основанный на модели оперативной памяти, разделенной на слова, который выполняет операцию lca за O(1). Идея его заключается в предварительной обработке числового массива так, чтобы для любого диапазона индексов минимальное число в этом диапазоне можно было найти за O(1) (как описывается в [550]). Некоторые применения, заявленные в [301], побудили к дальнейшему изучению задействованных констант, особенно для схем маркировки, чтобы на основе одних только меток уже можно было выполнять сравнения; для этого достаточно меток размера log n + O(√log n) [18], что лучше, чем предложено в [1]. Обобщенный обзор результатов можно найти в [18]. Операция lca естественным образом расширяется до поддержки более общих ориентированных древовидных структур. Например, произвольно ориентированные деревья обсуждались в [436]; если убрать требование, чтобы все ребра были ориентированы в сторону корня, то у двух элементов может не быть общего предка, но если он существует, то он уникален, и методы для ориентированных корневых деревьев обобщаются на эту ситуацию. Естественными моделями поиска lca, где наименьший (нижайший) общий предок всегда существует и уникален, являются полурешетки и решетки. 300 Глава 6. Системы непересекающихся множеств и связанные с ними структуры Для них нет разумного динамического варианта с локальным изменением, сохраняющим свойства решетки, и отсутствует понятие наименьшего (нижайшего) общего предка для любой пары элементов. Поэтому поиск возможен только в статической структуре. Если разрешить предварительную обработку и хранение за Θ(n2), то можно предварительно вычислить и свес­ ти в таблицу все ответы. Если для хранения ответов можно использовать массив, то это дает время выполнения операции O(1), а с использованием дерева поиска время увеличивается до O(log n). Статическая структура со сложностью субквадратичного пространства, поддерживающая решеточные операции, была продемонстрирована в [523, 524]. Другой тип операций с ориентированными корневыми деревьями обсуждался в [16]. Там авторы рассматривали дерево, в котором некоторые узлы отмечены динамически изменяющимися отметками, а операции отыскивают следующий отмеченный узел на пути к корню. Вот эти операции: • mark: отмечает заданный узел; • unmark: снимает метку с заданного узла; • marked_ancestor: возвращает следующий узел на пути от данного узла к отмеченному корню. Если базовое дерево представляет собой просто путь, то мы имеем прос­ тую задачу объединения–разделения–поиска (union-split–find), упоминавшуюся в разделе 6.3: отмеченные узлы являются концами подсписков, поэтому маркировка узла делит подсписок, удаление метки объединяет его со следующим, а с помощью marked_ancestor можно проверить, находятся ли два узла в одном и том же подсписке [409]. Для этой задачи можно снова использовать разделение дерева на пути, а также сжатое дерево, и представить пути деревьями поиска с глубиной узлов в качестве ключей. Установка меток делит деревья в отмеченных вершинах, а удаление меток снова соединяет их. В этом случае каждый путь в сжатом дереве представляет собой объединение путей, каждый из которых, кроме, возможно, самого высокого, имеет маркированный узел в роли вершины. Соответственно, чтобы найти ближайшего отмеченного предка (выполнить операцию marked_ancestor), нужно пройти не более O(log n) путей, вершина которых не отмечена, и один путь, вершина которого отмечена, каждый раз затрачивая время O(log n). В результате общее время выполнения операции составляет O((log n)2), в котором время O(log n) занимают операции mark и unmark, просто разделяющие или объединяющие деревья поиска. Эта структура также поддерживает обобщенную операцию link, выполняющуюся за время O((log n)2). Структура в [16] поддерживает операlog n –––––––––� , но ции mark, unmark и marked_ancestor, выполняющиеся за время O�–log log n для этого требуется более строгая модель вычислений; они также доказали соответствующую нижнюю границу. 6.5. Поддержание линейного порядка 301 6.5. Поддержание линейного порядка Проблема поддержания линейного порядка при выполнении операций вставки и удаления обсуждалась в ряде работ, посвященных описанию поддержания порядка в списке. Но будьте внимательны: структура, которую мы реализуем, не обязательно должна быть связанным списком; базовая абстрактная модель – это множество с линейным порядком, которое можно изобразить в виде списка. Необходимая нам операция в данном случае – это сравнение в линейном порядке, определяющее, меньше ли x (находится ли левее в списке), чем y, в текущем линейном порядке. Множество изменяется путем добавления новых и удаления существующих элементов, при этом положение нового элемента определяется его непосредственным соседом в линейном порядке. Элементы идентифицируются указателями. Итак, нам нужны следующие операции: • insert(x,y): вставляет x как ближайший меньший сосед y и возвращает указатель на x; • delete(x): удаляет элемент x; • compare(x,y): определяет, меньше ли x, чем y, в текущем линейном порядке. Эта задача решалась бы просто, если бы элементы изначально имели ключи, а порядок определялся бы порядком ключей. В таком случае для проверки отношения порядка достаточно было бы сравнить ключи. Наша проблема в том, что мы должны назначать эти ключи на основе информации о соседях во время вставки. Если бы ключи были действительными числами, то снова задача имела бы простое решение: достаточно было бы присвоить каждому элементу при вставке среднее значение ключей его соседей. Но в разумной вычислительной модели мы можем допустить использование только целых чисел, причем ограниченных по размеру. Конечно, размер нашей задачи n должен быть в диапазоне допустимых целых чисел и, возможно, даже n2, но не намного больше. Поэтому мы не можем просто начать с 2n, что позволило бы нам выполнить вставку новых элементов в середину интервала n раз. Существует простое решение, не требующее присваивания значений ключей и основанное на использовании сбалансированного дерева поиска. Если элементы являются листьями дерева поиска, то их можно сравнить в порядке расположения слева направо, пройдя пути до корня и проверив порядок, в каком пути входят в первую общую вершину. Если узлы несут информацию о глубине, то операции сравнения вставки и удаления легко реализуются и требуют времени O(log n). А время выполнения операции обновления можно даже сократить до O(1), если использовать дерево, позволяющее обновлять узлы в известном месте за постоянное время. Итак, простое решение на основе дерева поиска имеет следующую производительность: 302 Глава 6. Системы непересекающихся множеств и связанные с ними структуры Теорема. Используя сбалансированное дерево поиска с поддержкой обновления за постоянное время элементов в известном место­ положении, можно обеспечить поддержку линейного порядка с операция­ми insert и delete, выполняющимися за время O(1) в худшем случае, и операцией compare, выполняющейся за время O(log n) в худшем случае. При наличии ключей можно реализовать сравнение, выполняющееся за время O(1). Дитц [170] использовал дерево для построения ключей. Если использовать (a, b)-дерево или любое дерево, все листья которого находятся на одной глубине и узлы имеют степень не выше b, то можно пометить исходящие ребра любого внутреннего узла в их естественном порядке как 1, ..., b (в лучшем случае, возможно, меньше). Тогда последовательность меток ребер на пути от корня к узлу даст значение ключа для узла по основанию b, совместимое с естественным порядком листьев. Проблема в том, что при изменении узла потребуется переименовать все листья в его поддереве. Дитц использовал модифицированное (2, 3)-дерево, чтобы получить усредненное время O(log n) перемаркировки: любая последовательность из n вставок в изначально пустом дереве выполняется за время O(n log n). Также можно использовать сбалансированное по весу дерево и воспользоваться его свойством, согласно которому между двумя перебалансировками в одном и том же узле изменяется положительная доля листьев в его поддереве, что позволяет усреднить перемаркировку этого поддерева по сравнению с обновлениями узла. Но в сбалансированном по весу дереве не все листья находятся на одинаковой глубине, поэтому нужна другая схема маркировки [536]. Оба решения дают усредненное время обновления O(log n) и время сравнения O(1) в наихудшем случае. В этой первой статье [170] такая конструкция итеративно повторяется; если нижние уровни дерева сгруппированы в копии структуры, то любая операция поиска сначала проверяет принадлежность элементов одной и той же структуре нижнего уровня и сравнивает их там, если это возможно, иначе происходит переход к следующей структуре более высокого уровня и сравнение производится там. Для этого нужно столько элементарных сравнений, сколько существует уровней структуры, но большинство операций вставки выполняются в низкоуровневых структурах, а вызванные ими изменения распространяются вверх, только когда низкоуровневые структуры переполняются. Используя log*(n) уровней, Дитц получил время поиска O(log*(n)) в худшем случае и усредненное время вставки O(log*(n)). Здесь log* – это функция итеративного логарифмирования, определяемая рекурсией log*(n) = 1 + log*(log n), которая растет очень медленно, но все же быстрее, чем функция Аккермана (см. приложение 10.5). При желании число уровней можно сократить до двух, если использовать другой метод для структуры нижнего уровня. Это возможно, если отдельные множества в структуре нижнего уровня имеют размеры меньше log n. Этим небольшим множествам можно присвоить целочисленные ключи 6.5. Поддержание линейного порядка 303 меньше n, передавая каждой операции вставки в качестве ключа среднее значение соседних ключей. Если изначально имеются ключи 0 и 2k, то можно выполнить k таких шагов усреднения, прежде чем получится разность меньше 1. Соответственно, множествам с размером log n можно назначать целые ключи не больше n за время O(1) на вставку и O(1) на сравнение клюn ––––� групп чей. Далее можно рассечь все множество из n элементов на O �–log n последовательных элементов так, чтобы каждая группа имела размер не более ⌊log n⌋, и использовать свою схему нумерации внутри каждой группы, а для представления отношения порядка между группами – структуру с операцией сравнения, выполняющейся за время O(1), операцией вставки с усредненным временем выполнения O(log n). В таком случае всякое сравнение будет выполняться за время O(1): одно сравнение между группами и одно сравнение внутри группы. Каждая операция вставки будет выполняться за усредненное время O(1): если вставка возможна внутри группы, то она выполнится за время O(1); в противном случае группа переполнится, что потребует ее разделения на две группы, их перенумерацию за время O(log n) и вставку новой группы в структуру групп за усредненное время O(log n), но для переполнения новой группы должно выполниться log n операций вставки. Такое решение, основанное на использовании разных структур для верхнего и нижнего уровней, было предложено в [536] и использовано снова, но уже с другой структурой верхнего уровня, не использующей деревья явно, в [172]34, и еще с одной структурой верхнего уровня в работе [58]35. Незначительная трудность заключается в предположении о том, что n известно при начальной нумерации нижнего уровня; но так как списки все равно приходится перестраивать, их можно дополнительно перестраивать всякий раз, когда n превышает очередную степень двойки; это дает дополнительное амортизированное время O(1) на вставку. Добавление поддержки удаления на нижнем уровне не вызывает никаких сложностей, но на верхнем уровне такая возможность зависит от выбранной структуры. Теорема. Используя двухуровневую структуру, можно поддерживать линейный порядок с усредненным временем O(1) операций insert и delete и временем O(1) в худшем случае операции compare. Частный случай этой задачи, представляющий отдельный интерес, касается поддержки динамических плотных последовательных файлов. Под последовательным файлом здесь подразумевается множество элементов, расположенных в линейном порядке, которые должны быть отображены в адреса так, чтобы сохранить этот порядок и не использовать слишком много адресов: их должно быть немногим больше количества элементов. 34 35 С туманным описанием идеи нумерации нижнего уровня. По-настоящему ясное описание можно найти только в техническом отчете Д.Д. Слейтора и П.Ф. Дитца: Two Algorithms for Maintaining Order in a List, CMU-CS-88-113, Carnegie-Mellon University, September 1988, – выпущенном после [172]. Ссылающейся на [172] как источник более подробной информации о структуре нижнего уровня. 304 Глава 6. Системы непересекающихся множеств и связанные с ними структуры При вставке и удалении элементов может потребоваться их перенумерация, что соответствует перемещению элемента в другой адрес в памяти. Количество таких перемещений должно быть небольшим или, если адреса сгруппированы в дисковые блоки, количество изменяемых блоков должно быть небольшим. Важное отличие от предыдущей задачи – порядок определяется одним целочисленным ключом, а диапазон доступных ключей невелик. Эта задача была изучена Уиллардом в [552, 554, 555], предложившим алгоритм с усредненным временем выполнения O((log n)2); но его модель не полностью совместима, так как он предполагает, что максимальный размер задан заранее. Деамортизированная версия включает ряд дополнительных усложнений: она запускает алгоритм небольшими шагами, чтобы распределить время обновления, и добавляет несколько версий в базовые структуры, потому что операции поиска должны возвращать непротиворечивые результаты, когда обновления выполнены только частично. О другой деамортизации было объявлено в [58]36. 36 Где читателя отправляют за подробностями к полному описанию. Глава 7 Преобразования структуры данных До сих пор речь шла о различных и конкретных структурах данных. Но существуют еще и некоторые общие методы, наделяющие структуры данных дополнительными возможностями и свойствами. Вряд ли их можно улучшить, не зная их структуры, поэтому в каждом конкретном случае допус­ тимы лишь некоторые предположения об операциях над такими структурами и их реализацией. Здесь рассматриваются две хорошо изученные задачи – преобразование статической структуры в динамическую и поиск в такой структуре. 7.1. Создание динамических структур Некоторые из обсуждавшихся нами структур были статическими, например деревья интервалов: они создаются лишь раз, и поиск в них выполняется без всяких изменений основных данных. Чтобы сделать их динамическими, нужно допустить изменения в основных данных. Для такого обобщения у нас мало возможностей, но с учетом дополнительных соглашений можно применить к ним эффективные методы, которые будут воспринимать статические структуры данных как «черные ящики» для построения новой динамической структуры. Наиболее важный из таких классов задач – это разбиваемые задачи поиска (decomposable searching problems). Здесь основной абстрактный объект – это некоторое множество X, для которого нужно оценить некоторую функцию f(X, query), так чтобы при любом разбиении множества X = X1 ∪ X2 функцию f(X, query) можно было бы разбить на две функции f(X1 , query) и f(X2 , query). Если значение функции непостоянно, то нужно добиться, чтобы такая конструкция имела постоянное время. Это – основное свойство задачи, чтобы преобразование можно было применить к любой структуре, где такая задача решается. Одномерный двунаправленный поиск, или поиск в заданном множестве интервалов с искомым значением интервала, – это всего лишь одна задача. Статические деревья интервалов – единственная структура, к которой 306 Глава 7. Преобразования структуры данных можно применить методы этого раздела, чтобы сделать такую структуру динамической. Статические деревья интервалов имеют несколько иную трактовку, чем можно воспользоваться, но в разделе 6.3 был приведен другой метод для преобразования деревьев интервалов в динамические. При таком подходе возникает много проблем. Задача поиска в множест­ ве ближайшего узла с заданным значением элемента, видимо, самая интересная. Поиск элемента в словаре, или наименьшего элемента в куче, или суммы элементов, или интервала – однотипные задачи. Но есть задачи, не относящиеся к этому типу. Обсужденные в разделе 6.4 корневые деревья в основе своей даже не опираются на множества. При поиске наименьшего элемента множество можно разбить на части, однако поиск второго наименьшего элемента или медианы уже невозможен. Но, несмотря на это, такой тип задач представляет большой интерес. Разбиение на части поисковых деревьев и идея преобразования статической структуры в динамические была предложена в [63]. Изначально такие структуры допускали только операции добавления со средними оценками времени. Но потом к ним были добавлены операции удаления с оценками для худшего случая, а разнообразные компромиссы между временем поиска, добавления и удаления были рассмотрены в статьях [67, 361, 410, 452, 453, 191, 474]. Канонической работой по методам динамизации была монография [450]. Основная идея состоит в том, что множество объектов X разбивается на несколько блоков X1 ∪ ... ∪ Xm. Каждый блок – это статическая структура. Поиск выполняется в каждом из них, но результат поиска распространяется на все множество. Обновление выполняется путем восстановления одного или нескольких блоков. Разница между методами заключается только в размере блоков и в деталях их восстановления. Первоначальный метод [63] использует лишь блоки размера степени 2, причем блоки имеют разный размер. Так что если в основном множестве X содержится n элементов, то все блоки Xi этого множества при двоичном разбиении имеют разный размер. Поэтому таких блоков может быть не более log n. При каждом поиске в множестве X выполняется не более log n поисков в каждом из блоков Xi, поэтому время поиска увеличивается не более чем на коэффициент log n. При добавлении нового элемента создается блок размера 1, а затем выполняется двоичное сложение блоков одинакового размера, пока каждый блок не получит уникальный размер. Чтобы добавить два блока одинакового размера, структуры разбиваются на пару блоков для восстановления их элементов, после чего создается новая структура. Это приводит к излишней сложности вычислений в худшем случае, так как приходится восстановливать все в одной структуре; но структура размера 2i перестраивается только тогда, когда меняется бит i из п возможных, что происходит на каждом шаге 2i–1. Если preproc(k) – это время создания статической структуры размера k, то общее время первых n добавлений составит 7.1. Создание динамических структур 307 ⌊log n⌋ n � ––– preproc(2i). 2i i=0 Таким образом, среднее время всех добавлений на множестве из n элементов составит ⌊log n⌋ O(log n) , если preproc(n) = O(n) preproc(2i) ins(n) = � ––––––––i–––––– = � O((log n)c+1), если preproc(n) = O(n(log n)c). 2 O(nε) , если preproc(n) = O(n1+ε) i=0 Возможна и иная настройка с использованием других размеров блоков. В этом случае восстановление блока при добавлении элемента или при объединении двух блоков будет чуть проще построения статической структуры с нуля за счет повторного использования некоторой информации о порядке следования элементов. Подробности такого подхода обсуждались в работе [67], что позволило получить оценку времени с множителем log n. Размеры блоков и их предельные значения подробно рассматривались в работах [452, 410]. Но мы здесь ограничимся только самым важным частным случаем основного подхода. Теорема. С учетом разбиения структуры для поиска, которая может быть построена за время O(n (log n)c), что отвечает времени O(log n) на множестве из п элементов, двоичные блоки имеют ту же проблему: добавление требует времени O((log n)c+1) в среднем, а поиск – времени O ((log n)2) в худшем случае. Если применить такой подход к структуре дерева интервалов, которая строится за время O(n log n), то можно получить структуру, поддерживающую добавление и поиск со временем O((log n)2), однако время добавления становится средним. Правда, такой подход бесполезен при удалениях: если элемент удаляется из самого большого блока, то придется восстанавливать всю структуру, чтобы выстроить последовательность чередующихся операций insert и delete. Метод, поддерживающий удаление заданных в Θ(√n) блоках, – это множество размера O(√n), использующее пальцы, дополнительное дерево поиска или иной словарь для отслеживания иформации по каждому элементу [361]. В этом случае при каждом добавлении или удалении нужно входить в один из блоков, затрачивая время O(preproc(√n)), а при каждом поиске выполнять O(√n) поисков в блоках размера O(√n). Такая структура несколько хуже предыдущей. Если статическая структура требовала предварительного времени обработки O(n (log n)c) и времени поиска O(log n), то первая динамическая структура при обновлении требует времени O(n (log n)c+1, а при поиске – времени O((log n)2), тогда как вторая динамическая структура при обновлении требует времени (√n log O(n)c), а при поиске – времени O(√n log n). Но в первой динамической структуре 308 Глава 7. Преобразования структуры данных обновления касались только добавлений, поэтому время было всего лишь средним, тогда как во второй динамической структуре время добавления и удаления оценивались для худшего случая. Однако этот вариант все же лучше при восстановлении и поиске в статической структуре [67], поскольку любой поиск должен выполняться во всех блоках, чтобы достичь времени O(√n). Таких блоков должно быть не менее O(√n), поэтому наибольший размер блока может иметь размер не меньше Ω(√n). Если элементы поочередно добавляются в самый большой блок или удаляются из него, то при каждом удалении элемента блок размера Ω(√n)нужно перестраивать. Таким образом, несмотря на некоторый компромисс между временем поиска, временем обновления и уменьшением множителя log п, в такой модели нельзя достичь времени обновления и поиска меньше Ω(√n). Итак, подведем итог для самого важного частного случая. Теорема. Данная статическая структура при разбиении задачи поиска, которую можно построить за время O(n (log n)c) и выполнить поиск со временем O(log n) на множестве из п элементов с преобразованием √n-блоков, приводит структуру к той же проблеме, поддерживающей добавление и удаление со временем O(√n (log n)c) и поиск со временем O(√n log n) в худшем случае. Если нужно добиться большей производительности в структуре, поддерживающей удаление элементов, нужна дополнительная информация о них. Например, поддержка статической структурой так называемых «мягких» («weak») удалений [453, 448]. Мягкое удаление удаляет элемент так, что при поиске получается правильный результат, но при этом время последующих поисков и мягкого удаления не уменьшается. Прототип этого – удаление без изменения равновесия дерева поиска: элемент удаляется, но, несмотря на сокращение элементов дерева, его высота ни в момент удаления, ни позже не уменьшается. Мягкие удаления возможны лишь в статических структурах, но не во всем множестве объектов. Если объединить мягкое удаление с блоками экспоненциального размера, то получится следующая структура: множество разбивается на блоки, где каждый блок имеет номинальный и фактический размеры. Номинальный размер имеет степень 2 и встречается не более одного раза. Фактический размер блока с номинальным размером 2i находится между 2i−1 + 1 и 2i. В итоге операции работают следующим образом: • при удалении элемента ищется его блок и выполняется мягкое удаление с уменьшением фактического размера. Если же фактический размер блока становится равным 2i−1, то проверяется, есть ли блок с номинальным размером 2i−1. Если такого блока нет, то блок с фактическим размером 2i−1 перестраивается в блок с номинальным размером 2i−1. В противном случае блок с фактическим размером 2i−1 7.1. Создание динамических структур 309 вместе с элементами блока номинального размера 2i−1 становится блоком номинального размера 2i; • при добавлении элемента создается блок размером 1 и выполняется двоичное сложение блоков, исходя из их номинального размера; • поиск выполняется по всем блокам. При таком подходе мы снова получаем лишь усредненные оценки, проанализировать которые чуть сложнее. Здесь нужно отслеживать два потенциала. Потенциал операции удаления – это сумма разностей между номинальным и фактическим размерами блоков. Каждый раз, когда выполняется мягкое удаление, оно увеличивает размер на 1, а добавление ничего не меняет. Если после удаления перестроить блок размером 2i, то потенциал удаления уменьшится на 2i−1. Таким образом, уменьшение потенциала пропорционально размеру перестраиваемой конструкции. Значит, если время такой перестройки – preproc(2i), то его можно снизить за счет 2i мягких удалений, чтобы получить среднюю оценку удаления 1 preproc(2i) ––i preproc (2i) ≤ ––––––––i––––– 2 2 плюс стоимость мягкого удаления. При анализе операции добавления элемента рассматриваются блоки с номинальными размерами 2i и весами (⌈log n⌉ – i)2i−1, сумму которых можно считать потенциалом этой операции. Здесь n – это предельный размер основного множества, поэтому для последовательности операций с условием i ≤ ⌈log n⌉ все веса будут положительными. При добавлении создается новый блок размером 1 = 20, что увеличивает потенциал на ⌈log n⌉, но если появятся два блока одинакового размера 20, они ликвидируются, и каждый блок размером от 21 до 2i−1 перестраивается в новый блок размером 2i. Таким образом, потенциал меняется на следующий: i−1 −2⌈log n⌉ − � (⌈log n⌉ − j)2j−1 + (⌈log n⌉ − i)2i−1 i=1 i−1 = �� j 2j−1� − i 2i−1 = �(i − 2)2i−1 + 1� − i 2i−1 = −2i+1 + 1. i=1 Потенциал уменьшается на величину, пропорциональную размеру перестроенного блока. В этом случае добавление увеличивает потенциал на ⌈log n⌉, а удаление может только уменьшить номинальный размер блока и тем самым уменьшить его вес и потенциал. Таким образом при добавлении множество объектов не превышает n и средней сложности операции добавления 1 preproc(2i) ––i preproc (2i) ≤ ––––––––i–––––. 2 2 310 Глава 7. Преобразования структуры данных Подытожим производительность такой структуры. Теорема. Если статическая структура для разбиваемой задачи поиска может быть перестроена за время preproc(n) с мягким удалением за время weakdel(n) и выполняет поиск за время query(n) на мно­жестве из n элементов, то преобразование экспоненциальных блоков с мягким удалением дает структуру с той же проблемой. Добавление выполняется со средним временем preproc(n) O�(log n) –––––––––––––�, n удаление – со средним временем preproc(n) O�(weakdel(n) + –––––––––––––�, n а поиск – со временем O(log n query(n)) в худшем случае. Возможны и другие улучшения этой базовой схемы. Можно избавиться от усредненной оценки при удалениях за счет параллельного восстановления теневых копий, но это требует более глубокого исследования метода перестройки внутренней структуры. Поэтому вместо одноразового полного обновления при выполнении операции удаления можно выполнять обновление частями по мере выполнения операций удаления [453]. Методы, обсуждавшиеся до сих пор, требовали полной перестройки статической структуры, которая была просто черным ящиком с некоторыми операциями. При наличии большей информации можно добиться лучшего результата. Есть один класс задач построения динамической структуры на основе нижележащих черных ящиков – разбиение элементов со строгим порядком их следования (order-decomposable) [447, 450]. Это одна из разновидностей так называемого алгоритма «разделяй и властвуй». Суть его в том, чтобы на основном множестве X={x1, ..., xn} вычислить некоторую функцию f(X) на основе двух функций f({x1, ..., xi}) и f({xi+1, ..., xn}) с упорядоченными элементами. Заметим, что в отличие от разбиваемых задач поиска здесь поисковая функция f не имеет никаких дополнительных параметров, так что для данной статической структуры выполняется не просто поиск, но и вычисляется функция f(X), значение которой следует обновлять при изменениях основного множества. Стратегия здесь состоит в том, чтобы в основном множестве поддерживать равновесие дерева поиска на уровне листьев, так чтобы можно было разбить их на упорядоченные подмножества. Тогда каждому внутреннему узлу будет соответствовать упорядоченное подмножество {xi , xi+1, ..., xj} лис­тьев его поддерева и в каждом из этих узлов будет храниться значение функции f({xi, xi +1, ..., xj}) для этого подмножества. При поиске нужно прос­ то считать значение функции из корня. При обновлении лист добавляется 7.1. Создание динамических структур 311 или удаляется, после чего нужно подняться к корню с уравновешиванием дерева и пересчетом значений функций в каждом узле на этом пути. Предположим, что вычисление f (A ∪ B) по f (A) и f (B), где A и B – это упорядоченные подмножества, полученные в результате разбиения основного множества и требующие времени не более merge(| A ∪ B |). Тогда m m время добавления или удаления составит O(� i = 1merge(ni)), где (ni) i = 1 – это размеры поддеревьев ниже узлов, для которых должна пересчитываться функция. Это все узлы на пути к корню, но у каждого из них возможен потомок, который может изменяться за счет вращения. Каждый потомок имеет меньшее поддерево, поэтому каждое из них на пути от листа к корню нужно ограничивать только размерами. Это очень удобно, когда основное дерево – это уравновешенное дерево, как описано в разделе 3.2. В уравновешенном дереве каждый узел в своем поддереве имеет k листьев не более чем с (1 – α)k листьями. Поэтому размер поддеревьев на пути от корня к листу уменьшается по крайней мере геометрически. Таким образом, время O (log n) обновления дерева с n листьями – О��i =0 merge ((1 – α)i n)�. Кроме того, перед добавлением или удалением нужно найти место в соответствии с порядком разбиения, но это обычно делается с помощью двоичного поиска за время O(log n). Однако в этом методе есть неочевидная проблема, в случае когда значение вычисляемой функции непостоянно. Интересный пример применения такого метода еще до того, как он был формализован, был изложен в [451] – это вычисление динамической выпуклой оболочки множества точек на плоскости с операциями добавления и удаления. Если точки упорядочены по их первой координате, то, в принципе, можно объединить две выпуклые оболочки множеств с разделенными первыми координатами за время O(log n), хотя это потребует непростого представления выпуклых оболочек. Правда, если такое объединение выполняется, то объединенные оболочки невозможно использовать при последующем обновлении. Очевидная альтернатива – это копирование структуры, что требует времени, пропорционального размеру структуры, поэтому в примере с выпук­ лой оболочкой сложность объединения увеличивается с O(log n) до Θ(n). Правда, за это время можно было бы в любом случае вычислить выпуклую оболочку отсортированного множества. Чтобы использовать такой метод, нужна еще одна функция, обратная функции объединения, которая разбивает структуру в узле и восстанавливает структуры в его нижележащих узлах. Тогда при любом обновлении выполняется разбиение узлов при спус­ ке от корня к обновляемому листу, после чего выполняется подъем вверх с объединением по этому пути. При каждом вращении, возможно, придется выполнить еще одно разбиение с последующим объединением пар. Таким образом, время обновления составит O(log n) , если merge(n) = O(n) � merge �(1 – α)i n� = � O((log n)c+1), если merge(n) = O(n(log n)c), i=0 , если merge(n) = O(n1+ε) O(nε) O(log n) 312 Глава 7. Преобразования структуры данных если операция объединения является неразрушающей или обратная операция разбиения находится в тех же временны́х границах. Подытожим производительность такой структуры для самого важного ее частного случая. Теорема. Для разбиваемой упорядоченной структуры, в которой можно найти нужное место ее элемента за время O(log n) и в которой есть либо неразрушающая операция merge, либо пара операций merge и split, требующие времени O((log n)c), можно сохранить значение функции при добавлении и удалении элемента в основном мно­ жестве в худшем случае за время O((log n)c +1). Основные ограничения этой модели – это способ разбиения всего множества и то, что при поиске ищется определенное значение элемента, а не его функция, что позволяет заранее вычислить ее значение в каждом узле дерева. Эту модель можно слегка расширить, если выбрать значения функции с постоянной сложностью, но это возможно только для модели, где при поиске все данные находятся только в корневом узле. Многие структуры, которые мы обсуждали, были похожи на эту структуру: они строились на выровненных деревьях поиска с добавлением некоторой дополнительной информации в узлах. Но для оценки поиска в таких структурах нужно спус­ каться вниз по такому дереву и объединять всю эту информацию по всему пути. Есть общий метод динамизации структур такого типа, который основан на частичной перестройке структуры. Каноническая работа – это та же монография [450], где методы частичного восстановления изучаются систематически. Их частные случаи рассматривались чуть раньше [379] для динамизации деревьев прямоугольников. Принцип модели здесь заключается в том, что на основе любого дерева можно построить статическую структуру, в узлы которого можно добавить некоторую дополнительную информацию, и поиск производить вниз по дереву с объединением этой информации в каждом узле. В такой структуре нужен способ обновления, который сохраняет ее корректной, поэтому поиск всегда дает правильный ответ, но при этом нужно изменять древовидную структуру при выполнении операций добавления или удаления, из-за чего дерево может оказаться невыровненным. Так что необходим метод оптимального выравнивания каждого из поддеревьев такого дерева. Основная идея здесь в том, что если при поиске используются уравновешенные деревья, то большие поддеревья могут стать неуравновешенными только после многократных обновлений. В разделе 3.2 была теорема о том, что существует такое ε > 0, что между двумя последовательными выравниваниями поддеревьев фиксированного узла, по крайней мере, часть ε лис­ тьев этих поддеревьев была добавлена или удалена. Эту идею можно использовать для вычисления средней сложности при обновлениях. Каждое обновление листа повышает на единицу вес того поддерева, куда этот лист входит, а также вес каждого из O(log n) узлов на пути от корня до этого 7.1. Создание динамических структур 313 листа. Если перестраивается поддерево с k листьями, значит, в нем было εk обновлений. Если стоимость восстановления поддерева с k листьями – rebuild(k), то после всех обновлений средняя стоимость одного обновления log n k log n равна O�––––––– rebuild(k)�, что равносильно O�–––n–––– rebuild(n)�. Вдобавок к этому требуется время на операции добавления или удаления и на исправление самой структуры посредством операции basic_update(n), что потребует времени Ω(log n) на обновление дерева, но оно может оказаться несколько больше, поскольку дополнительная информация в узлах тоже должна обновляться. Однако при таких обновлениях само время обновления не меняется, так как дерево всегда остается уравновешенным. Итак, подытожим производительность этого метода. Теорема. В статической структуре данных, представляющей собой выровненное дерево поиска с дополнительной информацией в узле и позволяющей выполнять добавления и удаления без повторного выравнивания дерева из n элементов за время basic_update(n) и оптимально перестраивать поддерево из k листьев за время rebuild(k), можно сохранять такое дерево выровненным со средним временем добавления или удаления log n O�basic_update(n) + –––––––– rebuild(n)�. n Если время поиска в такой структуре для дерева высоты h равно query(h), то в худшем случае время поиска не превысит query(log n). В разделе 3.8 мы уже пользовались частичным восстановлением уравновешивания в деревьях поиска с другим критерием баланса и усредненной оценкой времени, но их тоже можно рассматривать как простейший частный случай этого метода. Другой класс допускающих некоторую динамизацию задач – это задачи минимизации переменных, предложенные в [180, 195]. Здесь основная идея состоит в том, что вводится функция f(x, y), для которой при добавлениях и удалениях нужно поддерживать наименьшее значение всех пар декартова произведения min{f(x, y) |x ∈ X, y ∈ Y} в множествах X и Y. Без дополнительной информации о f надежды на простой алгоритм нет: когда в X добавляется новая точка хnew, нужно для всех у ∈ Y вычислить f(хnew, у). Для этого нужна дополнительная динамическая структура, которая для точки поиска xquery находит у ∈ Y с наименьшим значением функции f(xquery, y). Если допускаются добавления, то это просто: нужно всего лишь проверить, дает ли новая точка меньшее значение, чем предыдущая. Правда, такой подход не поддерживает удаление. В работе [195] описан метод, поддерживающий наименьшие значения обеих переменных при добавлениях и удалениях со средним коэффициентом сложности log n для основной структуры при небольшом числе добавлений и со средним значением (log n)2 для удалений. 314 Глава 7. Преобразования структуры данных 7.2. Динамические структуры с сохранением истории Динамическая структура данных со временем меняется. Иногда это полезно, если есть доступ к предыдущим ее версиям. Помимо обычного поиска среди предыдущих версий это полезно в качестве инструмента для геометрических алгоритмов, где выполняется зачистка (sweep). В каждом из таких алгоритмов обычно есть структура для отслеживания позиции по линии зачистки, но иногда необходим доступ к тем областям, которые уже были пройдены. Еще одно очевидное применение – это пересмотр предыдущих версий в текстовых редакторах с командой «undo» [424, 217, 163], в многофайловых структурах [107, 108] и при исправлении ошибок в текстах [416]. В таких приложениях можно создавать особые структуры, однако успех в поиске общих методов динамизации был достигнут при решении именно геометрических задач и побудил поиск подобных им методов. Вопрос о доступе к предыдущим версиям динамических структур данных впервые поднимался в работах [179]37 и Овермарса38. Первые работы [179, 127] касались геометрических приложений. Это позволило предположить, что полное множество заранее известно и отвечает алгоритмам развертки, даже если это множество еще не зачищено. Действительно, находящаяся на линии зачистки последовательность объектов известна заранее, поэтому в таком случае это всего лишь вопрос предварительной обработки всего множества, так чтобы можно было продолжить поиск по любым другим позициям линии зачистки. Для обычных деревьев поиска задача поиска в предыдущих версиях динамических структур обсуждалась в работе [208]. Основополагающей стала работа [183], где рассматривались общие методы преобразования динамической структуры данных с возможностью доступа к предыдущим ее версиям. В ней было определено несколько ступеней доступа. Самую естественную, которая допускает поиск в предыдущих версиях, авторы назвали «частичной», когда для каждой текущей версии создается новая текущая версия, но с возможностью проверить прежние версии по времени их создания или по номеру. В этой же работе была изучена более общая схема устойчивости динамических структур под названием «полная», когда можно изменять прошлые версии, создавая дерево версий, не опираясь на текущую версию. В этом случае идентификация версии, на которую нужно сослаться, совсем непрос­та: для этого производится нумерация версий с частичным упорядочиванием дерева версий. Еще более общий вариант, но применительно только к структурам с поддержкой операции join – это «слияние» изучалось в первую очередь для двусторонних очередей [184, 102, 300], а затем в це37 38 Статья опубликована в FOCS в 1980 году, а ее журнальная версия [179] появилась лишь 5 лет спустя. Два препринта M. H. Overmars: Searching in the Past I, II, Rijksuniversiteit Utrecht RUU-CS-81-7 и RUU-CS-81-9,9, апрель и май 1981 года, которые до сих пор доступны только в виде онлайн-версии. 7.2. Динамические структуры с сохранением истории 315 лом в [207]. В достаточно устойчивой структуре при «слиянии» могут объединяться разные версии. Но эти более сильные варианты устойчивости динамических структур представляют лишь теоретический интерес. Более важное, но гораздо менее глубокое преобразование – это откат текущей версии до предыдущей с удалением всех внесенных изменений. Сохранить историю предыдущих версий помогает использование стека. Опять же, как при динамизации структур данных, здесь нужна дополнительная информация в основной структуре. Некоторые общие модели, подобные тем, что обсуждались для динамизации, рассматривались Овермарсом39. Если структура – это просто черный ящик, позволяющий выполнять некоторые операции, то можно скопировать его прежнее состояние или сохранить список выполненных операций обновления, которые можно выполнить заново, чтобы восстановить предыдущее состояние. Эти два метода можно соединить: если структура имеет общий размер не более n со временем поиска query(n) и временем обновления update(n), то если копировать структуру после каждого k-го обновления, можно добиться среднего времеn ни обновления O�— k + update(n)�. Так что при любом поиске сначала копируется ближайшее сохраненное состояние и над ним выполняется не более k – 1 обновлений, перед тем как, наконец, выполнить поиск в восстановленном состоянии. Это требует времени поиска O(n + k update(n) + query(n)). Здесь самый трудоемкий процесс – это время O(n) начального копирования структуры перед ее обновлением. Этого можно избежать, если обновления операций добавления и удаления чередуются. В этом случае при любом поиске берется ближайшее сохраненное состояние и в нем выполняется не более k – 1, а затем выполняется поиск с последовательностью чередующихся обновлений для восстановления сохранененного состояния. Это требует на поиск времени O(k update(n) + query(n)). Выбор k как функции от n допускает компромиссы между временем обновления и поиска, но без дальнейшего знания структуры невозможно обеспечить обновление и поиск одновременно меньше, чем за О(√n). Большим достижением статьи [183] стало то, что в ней были предложены две структуры для модели машины с косвенной адресацией. Первая структура подходит для любой машины с косвенной адресацией, но с коэффициентом O(log n) для худшего случая. Вторая – со средним временем O(1) – требует ограничения количества ссылок на ее узлы. Для второй структуры средняя оценка времени О(1) была улучшена до O(1) для худшего случая в работах [171, 89], но с теми же ограничениями. Это ограничение выполняется, скажем, для всех деревьев поиска, но не выполняется для структур непересекающихся множеств. Левая куча удовлетворяет этому ограничению, а кучи из работы [89] не удовлетворяют ему, так что это ограничение не совсем простое. Первое преобразование, называемое методом толстых (fat) узлов, заменяет каждый узел структуры ссылками на версионные деревья поиска 39 Препринт M. H. Overmars: Searching in the Past II, Rijksuniversiteit Utrecht preprint RUU-CS-81-9, май 1981 года. 316 Глава 7. Преобразования структуры данных с ключом времени поиска для каждой версии. Всякий раз, когда меняется основная структура, в каждый измененный толстый узел просто добавляется новая запись о его версии дерева поиска. Вновь создаваемые узлы ссылаются только на новые деревья поиска самой первой версии. Таким образом, любой поиск выполняется в основной структуре, где в дереве поиска нужно найти значение некоторого узла с нужным временем, поэтому время поиска в основной структуре для элементарной операции увеличивается на коэффициент O(log n). При обновлениях работает тот же аргумент: моделируется основная структура, но можно добиться большего, чем O(log n), успеха при ограничении каждого шага. Поскольку обновления и изменения в деревьях поиска происходят по наибольшему ключу, можно использовать дерево, поддерживающее добавление и поиск в самом его конце за постоянное время, как в дереве пальца с постоянным временем обновления. Таким образом, при моделировании основной структуры получается коэффициент O(1) на один шаг. В общем итоге получается следующая производительность. Теорема. Любую динамическую структуру в модели машины с косвенными ссылками, поддерживающей поиск со временем query(n) и обновление со временем update(n) на множестве из n элементов, можно сделать устойчивой со временем поиска в предыдущих версиях со временем O(log n · query(n)), в текущей версии – со временем O(query(n), а при обновлениях – со временем O(update(n)), используя метод толстых узлов в сочетании с деревом поиска, допускающим поиск и обновление с постоянным коэффициентом по наибольшему ключу. Если в такую структуру нужно добавить откаты, то должна быть возможность возврата к предыдущей по времени версии, чтобы отменить все последние обновления. Это можно сделать в любой ссылочной структуре с использованием толстых узлов, которые в этом случае просто содержат стек значений узлов с номерами их версий. При каждом обновлении новые значения узлов помещаются в стек, после чего выполняется поиск значения, находящегося на вершине стека. Для обоих таких изменений при каждой операции обновления или поиска коэффициент времени увеличивается только на постоянную величину. При откате нужно вытолкнуть из каждого стека все значения предыдущей версии, поэтому нужен список всех толстых узлов. В худшем случае время отката может быть значительным, так как нужно вернуться к началу и очистить все стеки. Однако средняя сложность отката остается постоянной и скрытой операциями обновления, так как при откате каждый удаляемый из стека элемент заранее помещается в него при обновлении. Теорема. Любая динамическая структура в модели машины с косвенной адресацией, поддерживающей поиск со временем query(n) и обновление со временем update(n) на множестве из n элемен- 7.2. Динамические структуры с сохранением истории 317 тов, может обеспечить откат, используя стеки для толстых узлов, со временем поиска O(query(n)), временем обновления O(update(n)) и средним временем отката O(1). В итоге общее время a обновлений, b поисков и c откатов, начиная с изначально пустого множества, составит O(a update(a) + b query(a) + с). Чтобы добиться средней сложности отката в худшем случае, можно использовать разделяемые (splittable) деревья поиска во всех толстых узлах, но тогда время обновления и поиска увеличится на коэффициент O(log n). Поcкольку каждый толстый узел имеет дерево поиска, по сути, для одного и того же объекта и множества дат, то каждый раз для них выполняется один и тот же поиск, что наводит на мысль попытаться связать эти деревья поиска так, чтобы результат поиска в предыдущем узле можно было бы использовать повторно для поиска нужной версии в текущем узле. И это снова частичное каскадирование. Но дело в том, что в каждом из узлов даты версий неодинаковые, и потому это не вполне тот же поиск. Если в основном множестве время обновления равно O(log n), то при обновлении узла новая дата добавляется только за время O(log n) для Ω(n) узлов, поэтому множество всех дат гораздо больше множества дат отдельных узлов. Идея другой структуры [183], названная методом «копирования узла», состоит в том, чтобы заменить дерево поиска в каждом толстом узле спис­ ком узлов постоянного размера, каждый из которых содержит только несколько версий и связан с узлами соответствующих ему версий в списках соседних с ним толстых узлов. Проблема здесь в том, что если в момент времени t новая версия добавляется в последний узел списка и он переполняется, нужно создать новый узел списка, после чего обновить все входящие в него ссылки. В списки всех толстых узлов, ссылающихся на толстые узлы, которые только что обновлены, нужно добавить новую версию со временем t и ссылкой на новый узел. Это может вызвать переполнение самих этих списков и повлечь создание новых узлов, которое пройдет по всей структуре и остановится только тогда, когда в узле появится место для новой версии. Если сделать узлы достаточно большими, то этот процесс, в конце концов, завершится. В работе [183] была обоснована средняя оценка O(1) для количества вновь создаваемых узлов. Используя правильную стратегию создания нового узла даже до того, как это нужно сделать, это уменьшило бы время их создания до O(1) на каждом шаге их обновления [89, 171]. Но все это возможно, лишь когда степень каждого узла в основной структуре ограничена, поскольку нужно распространять создание новых версий по всем входящим ребрам. Необходимое количество версий на узел списка зависит от этой степени и довольно велико. Подводя итог этому преобразованию, его производительность можно оценить так: Теорема. Любую динамическую структуру в модели машины с косвенной адресацией и с ограничением на степень узла, со временем 318 Глава 7. Преобразования структуры данных поиска query(n) и временем обновления update(n) на множестве из n элементов можно сделать устойчивой при поиске предыдущих версий со временем O(query(n)) и временем обновления O(update(n)), используя метод «копирования узла» в сочетании со стратегией копирования узла [89]. Связанное с устойчивостью структуры свойство – это обратимость (retroactivity), предложенная в [165], где структура называется обратимой, если допускает изменение предыдущих обновлений с сохранением всех последующих обновлений. Вполне устойчивая структура позволяет изменить все предыдущие обновления новой ветви дерева версий, не изменяя обновлений в самой ветви. Суть обратимости в том, что исправляются лишь прошлые ошибочные обновления без исправления последующих обновлений. Однако это порождает много других проблем, так как последующие обновления могут зависеть от предыдущих обновлений и поисков. Поэтому, в отличие от устойчивости, общего метода обратимости структуры данных не существует. Глава 8 Структуры данных для строк До сих пор предполагалось, что элементы данных имеют постоянный размер, а сравнение их ключей требует постоянного времени, потому что, по сути, ключи – это просто числа. Но есть очень важный класс объектов, для которых такие предположения не годятся, – это строки. В реальных приложениях работа с текстом сложнее работы с числами: фрагменты текста имеют длину, так что они уже не элементарные объекты, которые компьютер может обработать за один шаг. Поэтому, в отличие от чисел, для строк требуются несколько иные структуры данных. В первую очередь это относится к наилучшему инструменту для поиска объектов – выровненным двоичным деревьям, требующим сравнения ключей в каждом узле и потому довольно неэффективным в этом случае. Кроме того, поиск строки требует иного подхода. Конечно, строки можно упорядочить по алфавиту, но это не позволяет сопоставлять их в полной мере: две строки, различающиеся только первым символом, могут оказаться более похожими, нежели две строки, различающиеся, скажем, с третьего по десятый символы. Таким образом, для строк поиск диапазона имеет мало смысла. Строки, в отличие от чисел, не так просты, поэтому требуют особого подхода. Пусть у нас есть алфавит A, скажем, из ASCII-кодов, а строки – это цепочки символов этого алфавита. Значит, компьютеру прежде всего нужна дополнительная информация о том, где эта строка заканчивается. Обычно для этого применяют два способа: либо в конце строки явно указывают особый концевой символ, который не должен встречаться внутри строки, либо в строке хранится ее длина. Первый способ применяется в языке C, где концевым символом строки является символ '\0', а второй – в языке Паскаль и его потомках40. Специальный концевой символ '\0' имеет ряд достоинств, упрощая код, но его недостаток состоит в том, что один из символов алфавита уже не может находиться внутри строки. Если строка – 40 В различных языках строки понимаются по-разному, а операции высокого уровня (например, удаление части строки) считаются элементарными [283]. Примеры тому – строковые классы C++ и Java. Однако такие операции непостоянны по времени и потому не дают желаемой эффективности. 320 Глава 8. Структуры данных для строк это просто фрагмент текста, то в этом нет проблемы, так как в ASCII-кодах есть множество непечатных (невидимых) символов, к которым относится и '\0'. Однако существует множество приложений, где строки не являются просто текстом (например, машинные команды). В этом случае нельзя предполагать, что такие строки не будут содержать подобных символов. В дальнейших примерах кодов будут использоваться строки с концевым символом '\0', но нужно иметь в виду недостатки такого представления строк и его альтернативы. Строки, особенно с небольшим алфавитом, в последнее время вызывают большой интерес в биоинформатике, потому что для большей части данных о ДНК/РНК или белковых цепочках достаточно алфавита из 4/20 символов соответственно, что стало причиной для появления множества статей и книг о строковых данных и алгоритмах. Основополагающими среди них стали книги [265, 155]. 8.1. Проверки и сжимаемые проверки Основной инструмент работы со строками подобен выровненному двоичному дереву поиска и называется trie (часть слова «retrieval», означающего «поиск»)41. Такая структура была предложена еще в 1959 году [86], а первым доступным источником с использованием этого не вполне удачного термина была работа [218]. Основная идея осталась прежней – хранить множество строк в виде дерева поиска. Но в этом дереве узлы не двоичные, поскольку могут иметь еще один крайний элемент из алфавита A, так что степень узла не превысит размера алфавита |A|. Каждый узел в таком дереве соответствует префиксу некоторой строки множества. Если один и тот же префикс встречается несколько раз, то существует только один представляющий его узел. Корень древовидной структуры – это узел с пустым префиксом. Соответствующий префиксу σ1 узел для каждого символа a ∈ A ссылается на узел с префиксом σ1a, если он существует. Это значит, что в исходном множестве должна быть строка σ1a σ2. Выполнение операции поиска find в такой структуре начинается с узла с пустым префиксом, после чего по ссылке ищется следующий узел со строкой, начинающейся следующим символом. Выполнив такой частичный поиск, можно прийти к узлу (строке) с этим префиксом. Если искомая строка принадлежит множеству строк, хранящихся в дереве поиска, и оно не имеет префиксов, то этот узел связан с этой единственной строкой. Если предположить, что множество строк не содержит префиксов и используются строки с концевым символом '\0', то этот символ может быть только концевым, и ни одна строка не может быть префиксом другой строки. В таком предположении можно описать основную версию структуры дерева. Каждый узел имеет следующий формат: 41 В данном контексте было бы точнее назвать этот термин «поиском по частичному совпадению» или «частичным совпадением». Мы же будем называть его просто проверкой. – Прим. перев. P1: KAE brass8 978 0 521 880378.1. 4 Проверки Augustи4,сжимаемые 2008 11:49 проверки cuus247-brass 321 typedef struct trie_n_t { struct trie_n_t *next[256]; /* возможна дополнительная информация */ } trie_node_t; a c ae a c ε e a c ce a c e e P1: KAE brass8 a c aa e a c aaa e a c ac e a c aac e a c cc e a c aacc e a c aacce e a c aaccee e a c cea e a c ce e a c cec e a c e ee a c cece e a c eee e Рис. 8.1. Строки ааа, aaccee, ас, cc, cea, cece, еее и их префиксы в алфавите {a, c, e} cuus247-brass 978 же 0 521структура 88037 4 August 4, 2008 Теперь реализуется та словаря, что11:49 и для обычных деревьев поиска: отслеживается множество пар (ключ, объект) для операций insert, delete и find, но теперь их ключ – строка. Поле next[(int)'\0'] используется для хранения ссылки на объект, так как для концевых символов ссылка на следующий узел не нужна. Если использовать строки без концевого символа, то в узел следует добавить дополнительное поле. x e f a a i l t e r i m p l e \0 l \0 s e e \0 e \0 e \0 \0 u Рис. 8.2. Пути поиска для строк exam, example, fail, false, tree, trie, true: в каждом узле массива показаны только используемые поля \0 322 Глава 8. Структуры данных для строк Реализация дерева поиска для строк с концевым символом '\0' может выглядеть так: trie_node_t *create_trie(void) { trie_node_t *tmp_node; int i; tmp_node = get_node(); for( i = 0; i < 256; i++ ) tmp_node->next[i] = NULL; return(tmp_node); } object_t *find (trie_node_t *trie, char *query_string) { trie_node_t *tmp_node; char *query_next; tmp_node = trie; query_next = query_string; while( *query_next != '\0' ) { if( tmp_node->next[(int)(*query_next)] == NULL ) return(NULL); /* искомая строка не найдена */ else { tmp_node = tmp_node->next[(int)(*query_next)]; query_next += 1; /* перейти к следующему символу */ } } return((object_t *)tmp_node->next[(int)'\0']); } int insert(trie_node_t *trie, char *new_string, object_t *new_object) { trie_node_t *tmp_node, *new_node; char *query_next; tmp_node = trie; query_next = new_string; while( *query_next != '\0' ) { if( tmp_node->next[(int)(*query_next)] == NULL ) { new_node = get_node(); /* создать отсутствующий узел */ for( i = 0; i <256; i ++ ) new_node-> next [i] = NULL; tmp_node->next[(int)(*query_next)] = new_node; } /* перейти к следующему символу */ tmp_node = tmp_node->next[(int)(*query_next)]; query_next += 1; /* перейти к следующему персонажу */ } if( tmp_node->next[(int)'\0'] != NULL ) return(-1); /* строка уже существует, имеет объект */ else tmp_node->next[(int)'\0'] = (trie_node_t *) new_object; return(0); 8.1. Проверки и сжимаемые проверки 323 } object_t *delete(trie_node_t *trie, char *delete_string) { trie_node_t *tmp_node; object_t *tmp_object; char *next_char; int finished = 0; create_stack(); tmp_node = trie; next_char = delete_string; while (*next_char != '\0') { if( tmp_node->next[(int)(*next_char)] == NULL ) return(NULL); /* delete_string не существует */ else { tmp_node = tmp_node->next [(int)(*next_char)]; next_char += 1; /* перейти к следующему персонажу */ push(tmp_node); } } tmp_object = (object_t *)tmp_node->next[(int)'\0']; /* удалить все ненужные узлы */ /* корень не в стеке, поэтому он никогда не удаляется */ while( !stack_empty() && !finished ) { tmp_node = pop(); tmp_node->next[(int)(*next_char)] = NULL; for (i = 0; i < 256; i++) finished || = (tmp_node->next[i] != NULL); /* если все tmp_node равны NULL, то они должны быть удалены */ if( !finished ) { return_node(tmp_node); next_char -= 1; } } return(tmp_object); } void remove_trie(trie_node_t *trie) { trie_node_t *tmp_node; create_stack(); толчок (три); while( !stack_empty() ) { int i; tmp_node = pop (); for( i = 0; i < 256; i++) { if( tmp_node->next[i] != NULL && i != (int)'\0' ) push(tmp_node->next[i]); } return_node (tmp_node); } } 324 Глава 8. Структуры данных для строк Эта структура проста и очень эффективна. Единственная проблема – это зависимость от размера алфавита |A|, который определяет размер узлов. В этой реализации каждый узел содержит 256 ссылок (по одной на каждый символ), а сама ссылка может иметь размер от 4 до 8 байт, поэтому размер каждого узла будет не меньше 1 Кб. И если сохраняемые в такой структуре строки не сильно перекрываются, то потребуется примерно столько узлов, какова суммарная длина всех строк. Почти все узлы будут иметь только одну правильную ссылку, поскольку почти все префиксы могут иметь только одно возможное продолжение. Так что затраты памяти здесь будут огромны. Но даже при неограниченной памяти размер алфавита здесь сильно влияет на операции insert и delete, потому что новые узлы должны иметь пустые ссылки NULL, а при удалении узлов нужно проверять, используются ли они. Производительность приведенной здесь поисковой структуры оценивается так: Теорема. В поисковой структуре хранится множество слов в алфавите A. Она поддерживает операцию find для искомой строки q со временем O(length(q)), а также операции insert и delete со временем O(|A| length(q)). Оцениваемый размер памяти для хранения n строк w1, ..., wn – O(|A|∑i length(wi)). Зависимость от |A| в операции delete можно устранить, используя счетчик ссылок. Тогда все возвращаемые в свободный список узлы будут корректно заполняться NULL-ссылками так, что операции insert не нужно инициализировать повторно. Но все новые узлы должны инициализироваться, поэтому зависимость операции insert от |A| все-таки остается. Есть несколько способов сократить или избежать зависимости операции insert от размера алфавита |A| и NULL-узлов. В каждом из этих способов некоторые потери времени при поиске возмещаются за счет уменьшения размера памяти и времени обновления. Простой и эффективный способ в тех случаях, когда при большом количестве почти пустых узлов основной метод становится довольно расточительным, заключается в замене больших узлов на связные списки записей, которые действительно используются. Такой способ был впервые предложен в [86] и чуть позже в [518]. В следующей ниже реализации пустая строка представлена символом '\0' и уже содержится в пустом дереве при его создании. Она используется как точка входа в структуру, поскольку список должен иметь хотя бы один элемент, тогда как в массиве точки входа должны ссылаться только на NULL. Конечно, можно использовать отдельный список узлов-заголовков, но он удлиняет путь поиска. typedef struct trie_n_t { char this_char; struct trie_n_t *next; struct trie_n_t *list; /* возможна дополнительная информация */ 8.1. Проверки и сжимаемые проверки 325 } trie_node_t; trie_node_t *create_trie(void) { trie_node_t *tmp_node; tmp_node = get_node(); tmp_node->next = tmp_node->list = NULL; tmp_node->this_char = '\0'; return(tmp_node); } object_t *find(trie_node_t *trie, char *query_string) { trie_node_t *tmp_node; char *query_next; tmp_node = trie; query_next = query_string; while( *query_next != '\0' ) { while( tmp_node->this_char != *query_next ) { if( tmp_node->list == NULL ) return(NULL); /* искомая строка не найдена */ else tmp_node = tmp_node->list; } tmp_node = tmp_node->next; query_next += 1; } /* достигнут конец искомой строки */ while (tmp_node->this_char != '\0') { if( tmp_node->list == NULL ) return(NULL); /* искомая строка не найдена */ else tmp_node = tmp_node->list; } return((object_t *) tmp_node->next); } int insert (trie_node_t *trie, char *new_string, object_t *new_object) { trie_node_t *tmp_node; char *query_next; int finished = 0; tmp_node = trie; query_next = new_string; /* сначала пройти как можно дальше в существующее время */ 326 Глава 8. Структуры данных для строк while( !finished ) { /* идти по списку до совпадения с искомым символом */ while( tmp_node->this_char != *query_next && tmp_node->list != NULL ) tmp_node = tmp_node->list; if( tmp_node->this_char == *query_next ) { /* найден соответствующий символ, может быть последним */ if( *query_next != '\0' ) /* не последний. следовать */ { tmp_node = tmp_node->next; query_next += 1; } else /* добавление невозможно: строка уже существует */ return(-1); } else finished = 1; } /* оставил существующее дерево, создаем новую ветку */ tmp_node->list = get_node (); tmp_node = tmp_node->list; tmp_node->list = NULL; tmp_node->this_char = *query_next; while( *query_next != '\0' ) { query_next += 1; tmp_node->next = get_node(); tmp_node = tmp_node->next; tmp_node->list = NULL; tmp_node->this_char = *query_next; } tmp_node->next = (trie_node_t *) new_object; return(0); } object_t *delete(trie_node_t *trie, char *delete_string) { trie_node_t *tmp, *tmp_prev, first_del, *last_undel; object_t *del_object; char *del_next; if( trie->list == NULL || delete_string == '\0' ) return(NULL); /* удалить не удалось: trie - пустой */ else /* три не пустые, можно начать */ { int finished = 0; int branch = 1; last_undel = tmp_prev = trie; first_del = tmp = trie->list; del_next = delete_string; 8.1. Проверки и сжимаемые проверки 327 while( !finished ) { while( tmp->this_char != *del_next ) { /* идти по списку, чтобы найти нужный символ */ if( tmp->list == NULL ) /* не найден */ return(NULL); /* удаление не удалось */ else /* ветвящийся узел trie */ { tmp_prev = tmp; tmp = tmp->list; branch = 1; } } /* tmp совпадает со следующим символом */ if( branch || (tmp->list != NULL) ) { /* обновить точку удаления */ last_undel = tmp_prev; first_del = tmp; branch = 0; } if( *del_next == '\0' ) finished = 1; /* найдена удаляемая строка */ else { del_next += 1; tmp_prev = tmp; tmp = tmp->next; } } /* достигнут конец: отсоединить и удалить путь */ del_object = (object_t *) tmp->next; tmp->next = NULL; /* отсоединить del_object */ if( first_del == last_undel->next ) last_undel->next = first_del->list; else /* first_del == last_undel->list */ last_undel->list = first_del->list; /* окончательный путь неразветвления, узлы не связаны */ tmp = first_del; while( tmp != NULL ) /* следовать по пути, возврат узлов */ { first_del = tmp->next; return_node(tmp); tmp = first_del; } return(del_object); } } void remove_trie(trie_node_t *trie) { trie_node_t *tmp_node; create_stack(); push(trie); while( !stack_empty() ) 328 Глава 8. Структуры данных для строк { tmp_node = pop(); if( tmp_node-> this_char != '\0' ) push(tmp_node->next); if( tmp_node->list != NULL ) push(tmp_node->list); return_node(tmp_node); } } \0 x a m e f ε t p l e \0 exam \0 a r i l \0 fa l s e e e \0 i e \0 tr u e \0 \0 Рис. 8.3. Проверки для строк exam, example, fail, false, tree, trie, true выполняются с узлами списка: все ссылки направлены вправо или вниз Единственная сложность здесь – это операция удаления ненужных узлов, так как она требует различных операций разрыва связей по достижении ссылок next или list. Некоторых из этих проблем можно избежать за счет двукратного прохода по структуре: сначала находится место удаления, а потом выполняется само удаление. Такой метод оказывается проще, чем реализация на основе стека. Производительность подобной структуры будет следующей: Теорема. Структура дерева с узлами в виде списков хранит множество слов над алфавитом A. Она поддерживает операцию find для искомой строки q со временем O(|A| length(q)), а операции insert и delete со временем O(|A|length(q)). Оценка памяти для хранения n строк w1, ..., wn – O(|A|∑i length(wi)). Таким образом, главное здесь – это устранение проблемы перерасхода памяти. Влияние размера алфавита |A| на поиск и обновление возникает только тогда, когда механизм проверки нужно сделать эффективным, то есть когда множество префиксов позволяет проверить множество следующих за ними символов. Поэтому в приложениях с обычными текстовыми строками производительность будет намного выше. Если владеть некоторой информацией о вероятностях доступа к словам, можно оптимизи- 8.1. Проверки и сжимаемые проверки 329 ровать структуру, выбирая в каждом списке нужную последовательность символов [517]. Еще один способ избежать проблемы размера алфавита |A| – это его сжатие (reduction). Его можно представить как множество k-кортежей некоторого прямого произведения A1× ... ×Ak. За счет этого каждая строка становится длиннее в k раз, но размер алфавита сжимается до ⌈|A|1/k⌉. Каждый стандартный 8-битовый код символа ASCII можно разбить на два 4-битовых символа, что уменьшит размер узла с 256 до 16, но удвоит длину каждого пути поиска. 1100 1110 00101100 00101110 0101 01000101 1011 01001011 0010 01010100 0100 01011111 0101 0100 01100011 0110 1111 01100101 0111 01101110 01110110 01111110 0011 0101 1110 0110 1110 Рис. 8.4. Сжатие алфавита: вместо одного узла с 256 входами, из которых используются только 11, теперь есть лишь 5 узлов с 16 входами В крайнем случае можно использовать двоичный алфавит, где строки представляются как двоичные цепочки. Такие методы рассматривались в различных источниках, потому что они выглядят естественными. Однако множество необходимых для его реализации операций с битами делают его не очень удачным в реальных приложениях. Такой же метод применяется и для более общих алфавитов. Но при отсутствии прямого доступа к двоичному представлению символов придется сохранять частичные отобра­жения A → Ai для k алфавитов, непосредственно входящих в алфавит A в виде прямого произведения. Эти частичные отображения имеют общий размер k|A|, но их нужно хранить только раз, что не потребует большой памяти. В сжатом алфавите особая роль концевого символа строки теряется. Он представляется отдельной завершающей строкой, что требует дополнительной информации для его определения. Но такая структура тоже удобна для строк без концевого символа с явно указанной длиной. Ниже приводится реализация поиска в такой структуре строк из 8-битовых символов, 330 Глава 8. Структуры данных для строк разбитых на пару 4-битовых символов, где учитывается не значение концевого символа '\0', а указанная длина строки. Для ускорения операции удаления здесь используются счетчики ссылок в узлах. typedef struct trie_n_t { struct trie_n_t *next[16]; object_t *object; int reference_count; /* возможна дополнительная информация */ } trie_node_t; trie_node_t *create_trie(void) { trie_node_t *tmp_node; int i; tmp_node = get_node(); for( i = 0; i < 16; i++ ) tmp_node->next[i] = NULL; tmp_node->object = NULL; tmp_node->reference_count = 1; /* root не может быть удален */ return (tmp_node); } object_t *find(trie_node_t *trie, char *query_string, int query_length) { trie_node_t *tmp1_node, *tmp2_node; int query_pos; tmp1_node = trie; for( query_pos = 0; query_pos < query_length; query_pos++ ) { tmp2_node = tmp1_node->next[((((int)query_string [query_pos]) & 0xF0) >> 4)]; if( tmp2_node != NULL ) tmp1_node = tmp2_node; /* используются старшие 4 бита */ else return(NULL); /* искомая строка не найдена */ tmp2_node = tmp1_node-next[((int)query_string[query_pos]) & 0x0F]; if( tmp2_node != NULL ) tmp1_node = tmp2_node; /* используются младшие 4 бита */ else return(NULL); /* искомая строка не найдена */ } /* достигнут конец искомой строки */ return(tmp1_node->object); 8.1. Проверки и сжимаемые проверки 331 /* NULL, если искомая строка не найдена */ } int insert (trie_node_t *trie, char *new_string, int new_length, object_t *new_object) { trie_node_t *tmp1_node, *tmp2_node; int current_pos; int next_sub_char; tmp1_node = trie; for( current_pos = 0; current_pos < 2*new_length; current_pos++ ) { if( current_pos % 2 == 0 ) /* далее использовать старшие четыре бита */ next_sub_char = (((int) new_string [current_pos / 2]) & 0xF0) >> 4; else /* далее использовать младшие четыре бита */ next_sub_char = ((int)new_string[current_pos/2])&0x0F; tmp2_node = tmp1_node->next[next_sub_char]; if( tmp2_node != NULL ) tmp1_node = tmp2_node; /* используется четыре бита */ else /* создать новый узел */ { int i; tmp2_node = get_node (); for( i = 0; i < 16; i++ ) tmp2_node->next [i] = NULL; tmp2_node->object = NULL; tmp2_node->reference_count = 0; tmp1_node->next [next_sub_char] = tmp2_node; tmp1_node->reference_count += 1; tmp1_node = tmp2_node; } } if( tmp1_node->object! = NULL ) return (-1); /* строка уже существует, имеет связанный объект */ else { tmp1_node->object = new_object; tmp1_node->reference_count += 1; } return(0); } object_t *delete(trie_node_t *trie, char *del_string, int del_length) { trie_node_t *tmp1_node, *tmp2_node; 332 Глава 8. Структуры данных для строк int current_pos; int next_sub_char; trie_node_t *del_start_node; int del_start_pos; object_t *tmp_object; tmp1_node = trie; del_start_node = trie; del_start_pos = 0; for( current_pos = 0; current_pos < 2 * del_length; current_pos++ ) { if( current_pos % 2 == 0 ) /* затем используются старшие 4 бита */ next_sub_char = (((int) del_string[current_pos/2]) & 0xF0) >> 4; else /* затем используются младшие 4 бита */ next_sub_char = ((int)del_string[current_pos/2]) & 0x0F; tmp2_node = tmp1_node->next[next_sub_char]; if( tmp2_node != NULL ) { if( tmp1_node->reference_count > 1 ) { del_start_node = tmp1_node; del_start_pos = current_pos; } /* del_start_node – последний узел с двумя ссылками */ tmp1_node = tmp2_node; /* используется 4 бита */ } else return (NULL); /* delete_string не существует */ } if( tmp1_node->object == NULL ) return(NULL); /* delete_string не существует */ else { tmp1_node->reference_count -= 1; tmp_object = tmp1_node->object; tmp1_node->object = NULL; } if( tmp1_node->reference_count == 0 ) { tmp1_node = del_start_node; for( current_pos = del_start_pos; current_pos < 2 * del_length; current_pos++ ) { if( current_pos % 2 == 0 ) /* использовать старшие 4 бита далее */ next_sub_char = (((int) del_string[current_pos/2]) & 0xF0) >> 4; else /* далее используются младшие 4 бита */ next_sub_char = ((int)del_string[current_pos/2]) & 0x0F; tmp2_node = tmp1_node->next[next_sub_char]; tmp1_node->next [next_sub_char] = NULL; tmp1_node->reference_count -= 1; if( tmp1_node->reference_count == 0 ) 8.1. Проверки и сжимаемые проверки 333 return_node(tmp1_node); tmp1_node = tmp2_node; } return_node(tmp1_node); } return(tmp_object); } void remove_trie(trie_node_t *trie) { trie_node_t *tmp_node; create_stack(); push(trie); while( !stack_empty() ) { int i; tmp_node = pop(); for( i = 0; i < 16; i++ ) { if( tmp_node->next[i] != NULL ) push( tmp_node->next[i] ); } return_node(tmp_node); } } Производительность этой структуры такова: Теорема. В структуре дерева с k-кратным сжатием алфавита A хранится множество слов. Она поддерживает операции find и delete для искомой строки q со временем O(k length(q)) и операцию insert со временем O(k|A|1/k length(q)). Необходимая память для хранения n строк w1, ..., wn оценивается как O(k|A|1/k ∑i length(wi)). Обратная сжатию алфавита операция – это сжатие уровня с использованием некоторой степени Ak исходного алфавита, чтобы представить строку как группу из k символов. Этот прием исследовался в работах [28, 29, 431, 432] для битовых строк с алфавитом A = {0, 1} и других небольших алфавитов (quadtree), хотя для строк с алфавитом ASCII это невозможно. Каждый узел основной структуры дерева также представляет собой словарь с ключевым символом и ссылкой на следующий узел-объект. Таким способом можно реализовать узлы в любой на выбор словарной структуре. Любая из них представляет собой словарь с узлами в виде списка пар (ключ, объект), что очень неэффективно, если список слишком велик. Версии с сокращенными алфавитами можно считать деревьями со сжатыми алфавитами исходного алфавита как словари для каждого узла дерева. Еще один естественный вариант заключается в использовании выровненного дерева поиска в каждом узле. Здесь возможен выбор из множества деревьев 334 Глава 8. Структуры данных для строк поиска, но допускаются только выровненные деревья. Поскольку каждый узел словаря содержит не более |A| записей, в худшем случае время поиска нужной записи в узле составляет O(log|A|) даже в случае ее изменения. Используемая в любом дереве поиска память линейна по отношению к количеству хранимых в ней ключей, так что производительность структуры будет следующей: Теорема. В структуре выровненного дерева поиска в узлах хранится множество слов w1, ..., wп в алфавите А. Она поддерживает операции find, insert и delete для искомой строки q со временем O(log |A| length(q)) и требует памяти O(∑i length(wi)). Таким образом, зависимость от размера алфавита здесь не имеет значения, но дерево поиска по-прежнему имеет преимущество по сравнению со списком, когда многие узлы содержат большое количество записей. Приведенная ранее оценка высоты деревьев поиска для большинства узлов завышена, так как у многих узлов может не быть записи о каждом последующем возможном символе. Можно несколько улучшить эту оценку, если применять выравнивание не в каждом отдельном узле, а использовать некоторый критерий глобального выравнивания. Для статической структуры дерева это было сделано в работе [68], где предложен «тройной три». В ней в качестве основы для троичного дерева использовалось базовое дерево поиска согласно модели 2 (дерево узлов), где каждый узел содержит один символ в качестве ключа и одну ссылку на искомые символы, которые меньше, больше или равны. Для построения троичного дерева предполагается, что строки уже отсортированы в лексикографическом порядке. В каж­ дом узле в качестве ключа выбирается символ текущей позиции строки, которая является лексикографической медианой строк, остающихся на пути поиска. Затем на шаге поиска, когда текущий символ сравнивается с ключом узла: • если искомый символ и ключ узла совпадают, то выполняется переход к следующему искомому символу по той же «равной» ссылке узла; это возможно только length(q) раз; • если же искомый символ не совпадает с ключом узла, то нужно следовать по «меньшей» или «большей» ссылке, что сокращает коли­ чество возможных строк почти наполовину предыдущего количест­ ва и уменьшает время поиска до O(log n). Производительность такой структуры: Теорема. Тройная поисковая структура – статическая, в которой хранится множество слов w1, ..., wп в алфавите А. Она поддерживает операцию find для искомой строки q со временем O(log n + length(q)). Необходимая для нее память – O((∑i length(wi)), которую можно построить по сортированному множеству строк за то же время. 8.1. Проверки и сжимаемые проверки 335 Некоторые эвристические динамические варианты этой структуры обсуждались в [42], но настоящая динамизация довольно сложна. Идея разделения поисковых деревьев на двоичные сравнения со срединными символами появлялась раньше в работах [85, 151] в контексте суффиксных деревьев. Другой тип сжатия проверок – это сжатие пути, идея которого заключается в том, что узлы лишь с одной исходящей ссылкой пропускаются и отслеживается количество пропущенных символов. Итак, сжатый путь проверок содержит только узлы по меньшей мере с двумя исходящими ссылками, в каждой из которых содержится количество символов, которые нужно пропустить перед проверкой следующего за ними символа. Это уменьшает необходимое количество узлов от общей длины всех строк на количест­ во слов в нашей структуре. Но при пропуске всех этих промежуточных P1: KAE узлов при каждом обращении требуется второй проход по строке, чтобы brass8проверить cuus247-brass 978 0 521 88037 4 August 4, 2008 11:49 все эти пропущенные символы найденной строки в искомой строке. Эта структура известна как дерево PATRICIA (Practical Algorithm To Retrieve Information Coded In Alphanumeric – практический алгоритм поиска информации, закодированной в буквенно-цифровой форме) [415]. Идея сжатия пути может сочетаться с любым из вышеупомянутых вариантов проверок. Изначально она была описана для битовых строк, но для двухэлементного алфавита накладные расходы пространства настолько малы, что сегодня нет необходимости в сжатии пути. Техника уменьшения количества узлов оправдана только для больших алфавитов. \0 skip 3 ε t "example" exam \0 "exam" e f p skip 1 skip 1 i fa l "fail" "false" e "tree" i "trie" tr u "true" Рис. 8.5. Дерево PATRICIA для строк example, exam, fail, false, tree, trie, true: узлы реализованы как списки; каждый лист содержит всю строку Будучи статической структурой данных, дерево Patricia выглядит довольно простым, но реализация операций вставки и удаления сопряже- 336 Глава 8. Структуры данных для строк на со значительными трудностями. Чтобы вставить новую строку, нужно найти место для вставки нового разветвляющего узла, а для этого нужно знать пропущенные символы. Очевидным кажется решение присоединить к каждому узлу пропущенную подстроку, которая привела к нему, но тогда придется выделять и освобождать память для множества небольших строк разного размера; даже если сгруппировать их по нескольким стандартным размерам, это процедура повлечет за собой огромные накладные расходы. Другое решение – хранить указатель на одну из строк в поддереве, достижимом через этот узел. Но это решение требует большой осторожности, потому что строка может быть удалена, и тогда придется заменить все указатели на нее указателями на другую строку в том же поддереве. Чтобы позволить обнаружить, что указатель в пути указывает на какое-то место в строке, которая должна быть удалена, все такие указатели должны быть представлены в виде указателя на начало строки и смещения. Это довольно неуклюжее решение, но оно хотя бы осуществимо и имеет следующую производительность: Теорема. Древовидная структура Patricia хранит множество слов из символов, принадлежащих алфавиту A. Она поддерживает операции find для поиска строки q за время O(length(q)), а также insert и delete для вставки и удаления за время O(|A| length(q)). Пространство, необходимое для хранения n строк w1, ..., wn, равно O(n|A| +∑i length(wi)). Другое очевидное решение – выполнить обход некоторой ветви до конца, чтобы узнать, какие символы были пропущены; они должны быть одинаковыми для всех ветвей. Но это не позволяет оценить границу времени выполнения операции вставки строки q с точки зрения ее длины, потому что для вставки даже очень короткой строки может понадобиться пройти по очень длинным путям до конца. Таким образом, дерево Patricia – это структура, накладные расходы на реализацию которой нивелируют все ее преимущества в эффективности по сравнению с обычными решениями, например с представлением узлов в виде списков. Главное достоинство деревьев Patricia заключается в том, что они являются строительными блоками суффиксных деревьев (suffix trees), где эти проблемы не возникают. Наиболее естественно читать строки слева направо, но с технической точки зрения в этом нет никакой необходимости. Последовательность, в какой оцениваются символы строк, влияет на размер результирующей структуры дерева; если, например, все строки имеют длинный общий суффикс, то может оказаться выгоднее читать их с конца. Можно даже для каждого узла дерева указать, какая позиция будет считаться следующей – совершенно необязательно, чтобы эти позиции следовали в каком-то определенном порядке или в одном и том же порядке во всех ветвях дерева. Но оптимизация возможных вариантов последовательностей оказывается NP-полной задачей в любом варианте [153]. 8.2. Словари, допускающие появление ошибок в запросах 337 В [273] был предложен ряд дополнительных способов сжатия префиксных деревьев. Методы сжатия, описанные в [394] и [519], подходят только для статических деревьев; метод, предложенный в [32], требует очень больших массивов, а метод, представленный в [413], работает с теми же узлами, что и обычное дерево, и в описанных там экспериментах наблюдается уменьшение требуемого пространства в 2 раза. Но во всех этих компактных модификациях теряются простота и изящество префиксных деревьев. В связи с этим возникает вопрос, как представлять префиксные деревья или множества строк во внешней памяти. По сути, строки – это ключи переменной длины, поэтому они не соответствуют модели B-деревьев, зато хорошо вписываются в такие структуры, как строковые B-деревья, префиксные B-деревья и O-деревья [205, 440]. 8.2. Словари, допускающие появление ошибок в запросах Структуры на основе префиксного дерева, обсуждавшиеся в предыдущем разделе, позволяют находить только точные совпадения; если строка в запросе содержит ошибку, например слово написано с ошибкой или ошибка вкралась при передаче, то искомая строка, хранящаяся в дереве, не будет найдена. Эта ситуация отличается от числовых ключей в деревьях поиска, обсуждавшихся в главе 3, или деревьев прямоугольных областей для многомерных данных, обсуждавшихся в главе 4; для них легко найти значение, соседствующее с искомым, даже если искомое значение немного отличается от правильного. Однако в префиксных деревьях это невозможно, потому что префиксное дерево, по сути, отражает лексикографический порядок строк: если первый символ в искомой строке ошибочен, то поиск пойдет по совершенно другому пути. Было бы очень желательно иметь структуру словаря, хранящую множество строк и отыскивающую все строки, отличающиеся от искомой только d символами. Эта задача имеет элегантное, эффективное и практичное решение для d = 1 [91] и несколько еще более эффективных решений в вычислительных моделях, менее подходящих для наших целей [566, 206, 93], но для случаев d ≥ 2 она остается практически нерешенной. Пусть есть множество n слов w1, ..., wn из символов, принадлежащих алфаn виту A, с общей длиной ∑w =∑ i =1 length(wi) и требуется преобразовать его в структуру, позволяющую отыскивать все слова в множестве, отличающиеся не более чем в d позициях от искомой строки q для некоторого фиксированного значения d. Для этого случая есть два тривиальных решения: 1. Для каждого слова wi можно сгенерировать все слова, отличающие­ ся от него не более чем в d позициях, и сохранить все эти слова в префиксном дереве. В таком случае для каждого слова wi будет создано Θ(|A|d length(wi)d) вариантов, поэтому, если использовать 338 Глава 8. Структуры данных для строк стандартное префиксное дерево, размер базовой структуры увеn n личится с O(|A|∑w) = O(|A|∑ i =1 length(wi)) до O(|A|d+1∑ i =1 length(wi)d+1), тогда как время поиска останется равным O(length(q)). Этот размер недостижим даже для d = 1. 2. Можно использовать простое стандартное префиксное дерево для слов, но для каждой искомой строки q генерировать все слова, отличающиеся от нее не более чем в d позициях, и искать их в дереве. При таком подходе будет сгенерировано Θ(|A|d length(q)d) искомых строк, поиск каждой из которых требует времени Θ(length(q)), что снова оказывается бесполезным, по крайней мере для d ≥ 2. Здесь возможны небольшие улучшения. В первом решении можно сжать пути и уменьшить показатель степени требуемого пространства с d + 1 до d, потому что существует ограниченное количество листьев; но не совсем понятно, как построить структуру за это время [91]. Можно использовать префиксное дерево с узлами-списками и тем самым избавиться от одного множителя |A|. Также можно объединить оба решения, сохраняя все варианты с ошибками d1 и запрашивая все варианты искомых строк с ошибками d2, чтобы найти все слова с ошибками d1 + d2. Все это не особенно полезно, но, по сути, это лучшее, что у нас есть для случая d ≥ 2. Замечательным достижением, описанным в [91], является структура для d = 1, которая в нашей стандартной модели состоит всего из двух префиксных деревьев слов с размером O(|A| ∑w) каждое, если использовать стандартное префиксное дерево и одно сбалансированное дерево поиска размером O(∑w). Время поиска в этом случае составит O(length(q) log ∑w). Более того, это динамическая структура со временем вставки и удаления слова w, равным O(length(w) log ∑w). Его можно комбинировать со всеми вариантами префиксного дерева и даже использовать префиксное дерево с узлами-списками, чтобы уменьшить сложность пространства. Правда, сжатие путей окажется невозможным, если структура должна быть организована в виде динамического словаря. Если оставить нашу вычислительную модель и позволить использовать хеш-таблицу вместо дерева поиска, то исчезнет даже множитель log ∑w, а предварительная обработка и поиск будут выполняться за линейное время. Идея структуры с двумя префиксными деревьями предполагает построе­ ние одного дерева для всех слов wi и второго – для слов, записанных в обратном порядке wireversed. В таком случае каждый узел в первом дереве будет соответствовать некоторому префиксу π некоторого слова wi, а каждый узел во втором дереве – некоторому суффиксу σ слова wj. Для каждого слова wi выполняется поиск всех пар (префикс, суффикс), разделенных одним символом, чтобы отыскать разные варианты записи wi = πcσ, где c – символ. Каждое слово wi порождает length(wi) таких пар (π, σ), представленных парами узлов префиксного дерева, адресами или числами. Все эти пары ге- 8.2. Словари, допускающие появление ошибок в запросах 339 нерируются для заданного слова, сначала следуя в первом дереве по пути, соответствующему слову, до его конца, с помещением указателя на каждый пройденный узел в стек, а затем следуя во втором дереве по пути, соответствующем перевернутому слову, с объединением каждого узла, достигнутого во втором дереве, с соответствующим следующим узлом из стека. В таком случае все пары узлов можно сгенерировать за время O(length(wi)). Каждая из этих пар узлов, то есть пар указателей или номеров узлов, вводится в дерево поиска вместе с указателем на слово wi, породившее эту пару. Общее количество пар равно ∑w, поэтому каждая операция с деревом поиска будет выполнена всего за O(log ∑w). Соответственно, вся структура будет построена за время O(∑w log ∑w). Метод поиска теперь следует той же схеме: обход пути, соответствующего искомому слову, в первом дереве как можно дальше с добавлением указателя на каждый пройденный узел в стек. Если искомое слово действительно содержит ошибку, то поиск не дойдет до конца, но даст максимальный префикс искомого слова, который также является префиксом некоторого правильного слова. Затем поиск пройдет по пути, соответствующему перевернутому слову, во втором дереве, пока не окажется на расстоянии одного символа от конца этого максимального префикса. С этого момента, продолжая следовать по пути во втором дереве, каждый посещенный узел объединяется с префиксным узлом из стека, и полученная пара проверяется в дереве поиска, принадлежит ли она какому-либо правильному слову. В результате время поиска составит O(length(q) log ∑w). Если использовать префиксное дерево с узлами-списками и любым сбалансированным деревом поиска, то производительность такой структуры будет следующей: Теорема. Структура с двумя деревьями, в которой префиксное дерево содержит узлы, реализованные в виде списков, и сбалансированное дерево поиска хранит множество слов с общей длиной ∑w на алфавите A, поддерживает операцию find поиска строки q и отыс­ кивающую все слова, отличающиеся не более чем в одном месте за время, зависящее от объема вывода, O(|A| length(q) log ∑w + k), где k – количество таких слов. Операции insert и delete со словом w будут выполняться за время O(|A| length(w) log ∑w). Для хранения слов потребуется объем памяти O(∑w), а время построения структуры составит O(|A| ∑w log ∑w). Это практически предел возможного, и если вычислительная модель позволяет использовать хеш-таблицу вместо дерева поиска, то множитель log ∑w исчезает, поэтому все операции будут выполняться за линейное время, в зависимости от длины ввода, что, безусловно, наиболее оптимально. В промежуточной модели, предложенной в [206], используется гораздо более сложная структура, в которой log ∑w сводится к log log ∑w. Также в [21] был предложен другой метод, хотя и с худшей производительностью. 340 Глава 8. Структуры данных для строк Структура с двумя префиксными деревьями поддерживает также более обобщенную модель поиска с ошибками, допуская не только замену одного символа другим, но также добавление или удаление одного символа. В этом случае в процедуре поиска используется расстояние редактирования вместо расстояния Хэмминга. Для структуры с двумя префиксными деревьями это означает лишь, что для каждого слова w нужно вставить в дерево поиска еще length(w) + 1 допустимых пар (префикс, суффикс) – разложения w = πσ без промежуточной буквы; в таком случае тот же алгоритм поиска будет принимать все искомые слова q в форме q = πcσ, а для поиска по словам с одним отсутствующим символом использовать исходное множество допустимых пар, но при этом объединяться в пары будут текущий суффикс с его непосредственным префиксом, а не с префиксом, который на один символ короче. Ни одна из этих модификаций не меняет сложнос­ ти алгоритма. В [91] предложено также другое решение, использующее два префиксных дерева и дающее линейное время поиска без использования хеш-таблицы, но в нем применяется более сложный инструмент: сортировка всех входных строк в лексикографическом порядке за время O(∑w) и присваивание им рангов, соответствующих их порядковым номерам. В таком случае: • каждый узел в первом префиксном дереве соответствует интервалу в этом порядке – словам wi, начинающимся с этого префикса; • каждый узел во втором префиксном дереве соответствует некоторому подмножеству: словам, оканчивающимся на этот суффикс. Вместо проверки, представляет ли пара узлов из первого и второго префиксных деревьев пару (префикс, суффикс) из слова wi, путем поиска этой пары узлов в дереве поиска, авторы проверяют, пересекается ли интервал первого узла с подмножеством второго узла. Это ситуация, в которой можно применить частичное каскадирование: в процессе обхода пути во втором дереве подмножества становятся все более разреженными, поэтому под­множества представляются в виде отсортированных списков и подспис­ков с указателями от любого узла в списке к его следующим соседям в подспис­ках. Выполняя обход соответствующего пути в обратном направлении в первом префиксном дереве, мы получаем последовательность возрастающих интервалов. Поэтому, выполняя обход пути во втором дереве и сравнение с соответствующим узлом в первом дереве из стека, мы получаем последовательность возрастающих интервалов и последовательность убывающих отсортированных подсписков, и нам требуется проверить, возникает ли на каком-либо этапе пересечение интервала и подспис­ ка. Для этого достаточно найти позицию интервала в первом спис­ке, для чего необходимо дерево поиска, далее можно на каждом шаге за время O(1) проверить каждый указатель на соседа в подсписке и получить позицию предыдущего интервала в новом подсписке, а затем расширить интервал и 8.3. Суффиксные деревья 341 проверить, содержит ли он теперь одного из соседей в подсписке. Этот алгоритм требует времени O(length(q)) для обхода пути в первом префиксном дереве, насколько это возможно, и размещения узлов в стеке, того же времени для достижения соответствующей позиции во втором префиксном дереве, времени O(log n) для определения начальной позиции интервала в списке и времени O(1) для каждого перехода к следующему подсписку и интервалу, что дает общее время поиска O(length(q) + log n). Все это предполагает, что символы строки имеют примерно тот же размер, что и элементарные единицы компьютерной памяти, поэтому время, необходимое для чтения строки, фактически совпадает с ее длиной, вследствие чего нижняя граница производительности любой операции со словом w равна Ω(length(w)). Ситуация меняется, когда появляется возможность прочитать слово целиком за постоянное время, что соответствует модели, рассмотренной в [566] и [93], где разбираются множества из n-битных строк длиной m в машинной модели длины слова не менее m. В таком случае все искомое слово можно прочитать за время O(1), а для поиска точного совпадения можно было бы просто использовать хеш-таб­лицу и найти соответствующую запись за время O(1). Здесь снова возникает вопрос, как быстро в этой модели можно отыскать слова, отличающиеся одной позицией. Первое решение было предложено в [566], в котором использовалось O(n log m) слов с длиной m; оно обеспечило время поиска O(log log n). Это решение затем было улучшено в [93] до времени поиска O(1)42. О задаче поиска для случая d ≥ 2 практически ничего не известно, хотя некоторые аспекты обсуждались в [181] и [260], но ни в одной из этих работ не было дано никаких алгоритмов. 8.3. Суффиксные деревья Суффиксное дерево – это статическая структура данных, которая предварительно обрабатывает длинную строку s и позволяет отыскивать в строке s подстроку q. Проще говоря, суффиксное дерево помогает решать задачу сопоставления подстрок подобно классическим алгоритмам. Разница лишь в том, что время поиска подстроки зависит только от длины искомой строки q. Время поиска равно O(length(q)). Идея очень проста: каждая подстрока в s является префиксом суффикса s, а узлы любого префиксного дерева соответствуют префиксам строк, хранящихся в дереве, поэтому если создать дерево, хранящее все суффиксы длинной строки s, то ее узлы будут соответствовать подстрокам s, и тогда для любой подстроки q можно будет за время O(length(q)) определить, является ли q подстрокой строки s. 42 В этой работе авторы использовали модель побитовых вычислений, но если интерпретировать эти границы как строки в двоичном алфавите, то производительность окажется хуже, чем у структуры с двумя префиксными деревьями в сочетании с хеш-таблицей. Такие хорошие результаты были получены лишь потому, что операции со словами длиной m выполняются за время O(1). 342 Глава 8. Структуры данных для строк e p p r e r p \0 p \0 p r e e r \0 p \0 r e r \0 \0 \0 Рис. 8.6. Суффиксное дерево строки «pepper» Эта структура занимает O(length(s)2) узлов, и для ее создания потребуется столько же времени; но если применить сжатие пути, то в структуре останутся только length(s) узлов ветвления и, в отличие от деревьев Patricia, нет необходимости хранить все эти строки явно и можно закодировать каж­дую по начальному и конечному адресам в длинной строке s. Таким образом, мы получаем представление O(length(s)) для дерева Patricia с суффиксами s, что позволяет искать подстроки q за время O(length(q)). e Пропущено Skip 0 0 p e r Пропущено Skip 0 0 p e p p ε r \0 5 6 1 4 Skip 0 0 Пропущено p 0 pe r 3 2 p e p p e r \0 0123456 Рис. 8.7. Дерево Patricia суффиксов слова «pepper»: номера листьев указывают начальные позиции суффиксов Эта идея была предложена в [548]43. Основная проблема заключается в построении представления линейного размера за линейное время. Было предложено несколько алгоритмов, каждый из которых требует некоторого осмысления. Классические методы описаны в [548], где структура строится в обратном направлении, начиная с конца строки, и суффиксы добавляются в порядке возрастания длины; в [404] суффиксы добавляются в порядке уменьшения длины; а в [539] структура строится постепенно, начиная с начала, и с сохранением дерева суффиксов уже обработанного префикса. До решения [539] задача инкрементного построения суффиксного дерева в процессе чтения строки изучалась в ряде работ; алгоритм в [389] имеет нелинейное время выполнения, но алгоритмы в [312] для построения родственных деревьев позиций и алгоритм «квазиреального времени» в [335] конструируют структуры за линейное время. В [291] предложен ин43 Но название было дано в [404]. В [548] эта структура была названа префиксным двоичным деревом, а в [7] очень похожая структура была названа деревом позиций. 8.3. Суффиксные деревья 343 крементный метод, позволяющий добавлять узлы с любого конца строки; «ленивая» версия, которая строит дерево только во время поисков, была описана в [252], а попытка обобщить модели для этих алгоритмов была предпринята в [251]. В [535] был показан алгоритм с квадратичным временем для окружений с ограниченным объемом памяти, а в [287] представлены различные экспериментальные результаты. Поскольку любая реализация суффиксного дерева основана на префиксном дереве, требования к пространству, предъявляемые префиксными деревьями, особенно для больших алфавитов, автоматически становятся проблемой для суффиксных деревьев. Эта проблема рассматривалась в [30, 203, 343, 423 и 319]. Мы можем объединить идею суффиксного дерева с любым представлением префиксного дерева из обсуждавшихся в предыдущем разделе. В некоторых практических задачах уже используются структуры с небольшим алфавитом, например для поиска подстроки в генетической последовательности; но для поиска длинного текста в обычном алфавите удобнее использовать представление узлов дерева в виде списков. Алгоритмы будет проще понять, если сначала описать их без сжатия путей, т. е. когда лежащая в основе абстрактная структура представляет собой префиксное дерево, хранящее множество суффиксов входной строки, а узлы дерева соответствуют префиксам этих суффиксов, т. е. подстрокам. Каждый узел имеет несколько исходящих указателей, которые являются P1: KAE обычными ребрами дерева, соответствующими возможным расширениям brass8 cuus247-brass 978 0 521 88037 4 August 4, 2008 11:49 текущего префикса суффикса, т. е. более длинной подстроки с некоторым дополнительным символом в конце. В дополнение к этим указателям в [404 и 539] используются дополнительные указатели – суффиксные ссылки, — каждая из которых связывает узел, представляющий строку a0...ak, с узлом, представляющим строку a1...аk, то есть с его суффиксом после удаления первого символа. i i n i n o o o io ni n o ion nio \0 n nion \0 \0 n i oni o onio n onion \0 on \0 ε \0 Рис. 8.8. Пример суффиксов для слова «onion» с суффиксными ссылками Здесь описывается метод Укконена из [539]. Предположим, что мы уже построили структуру для строки c0...cn−1 и хотим добавить еще один символ cn в конец. Для этого нужно изменить только те узлы, которые соответствуют 344 Глава 8. Структуры данных для строк строкам ci...cn–1; узел, строка которого не является суффиксом для c0...cn−1, не может измениться расширением до c0...cn−1cn. Узлы, которые потенциально могут измениться, достижимы из узла, соответствующего c0...cn−1 по следующим суффиксным ссылкам; этот путь известен как граничный путь. Каждый узел в граничном пути относится к одному из следующих типов: • тип 1: узел не имеет исходящих ребер; • тип 2: узел имеет исходящие ребра, но ни одно из них не соответст­ вует следующему символу cn; • тип 3: узел имеет исходящее ребро, соответствующее следующему символу cn. Если проследовать по граничному пути из c0...cn–1 до cn–1, то эти три типа образуют последовательные интервалы, возможно пустые. Если узел, соответствующий ci...cn–1, имеет тип 1, то эта подстрока встречается только в конце, поэтому более длинная подстрока ci–1ci...cn−1 тоже встречается только в конце, и соответствующий ей узел тоже имеет тип 1. Аналогично, если узел, соответствующий ci...cn−1, имеет тип 3, то подстрока ci...cn−1cn уже встречалась где-то раньше, а значит, и более короткая подстрока ci+1...cn−1cn встречалась где-то раньше, поэтому соответствующий ей узел тоже имеет тип 3. Таким образом, все узлы типа 1 находятся в начале граничного пути, все узлы типа 3 – в конце, а узлы типа 2, если имеются, находятся между ними. Нам не нужно вносить никаких изменений в узел типа 3, потому что узел, нужный для нового последнего символа cn, уже существует. В узле типа 2 нужно создать новую ветвь и новый узел для строки ci...cn−1cn. Эта новая ветвь исходит из узла, у которого уже имеется хотя бы одно исходящее ребро, потому что общее количество листьев в префиксном дереве равно n, а узлы типа 2 встречаются всего n – 1 раз при индуктивном построении структуры для строки с длиной n. Таким образом, основная работа приходится на создание узлов типа 1, где нужно просто добавить еще один узел к узлу, который прежде не имел исходящего ребра. В этом случае путь просто удлиняется на один узел в конце, но именно такой структуры хотелось бы избежать за счет сжатия пути. Чтобы представить путь, не имеющий ветвей и продолжающийся до листа, нужен только первый узел с позицией в длинной строке, где встречается подстрока, представленная этим первым узлом, и информация, позволяющая принять все продолжения этой подстроки до конца длинной строки. Такой «открытый» узел вообще не нуждается в обновлении, когда длинная строка растет с этого конца, если только путь, представленный этим узлом, не начинает новую ветвь. Для открытых узлов суффиксная ссылка остается неопределенной. Таким образом, вся работа по обновлению, которую необходимо выполнить в граничном пути, выполняется в узлах типа 2, начиная с первого узла, соответствующего подстроке ci...cn−1, которая уже встречалась ранее как cj...cj–i+n–1, и заканчивая первым узлом, в котором уже есть запись для cn, откуда следует, что даже подстрока ci...cn−1cn уже встречалась раньше. Начальный узел на каждом шаге индуктивного построения найти несложно: 8.3. Суффиксные деревья 345 P1: KAE brass8 конечный узел одного этапа предшествует начальному узлу следующего этапа. Если на шаге добавления cn найден конечный узел ci...cn−1 и в первом узле уже есть запись для cn (тип 3), то на4,шаге cn+1 мы найcuus247-brass 978 0 521 88037 4 August 2008 добавления 11:49 дем ci...cn как первый узел, который уже имеет исходящий указатель (тип 2 или 3). Единственным исключением является ситуация, когда не найдено ни одного узла, уже имеющего запись для cn. В таком случае нужно пойти по граничному пути до корневого узла, представляющего пустую строку, и добавить в него новую запись для cn, и тогда корневой узел становится начальным узлом для следующего этапа. c * * * c a a * * * c a c a * c * * * * c a a c c * a a * * c a * * o a c a o o * o * c a o * * * Рис. 8.9. Инкрементное построение префиксного дерева с суффиксами для слова «cacao» – какао: узлы со звездочкой (*) отмечают текущий конец; они образуют граничный путь Итак, схема алгоритма построения суффиксного дерева для заданной строки s = c0...cn−1 выглядит следующим образом: 0. Создать корневой узел, представляющий префиксное дерево пустой строки. Назначить его активным узлом и определить i = 0. 1. Пока i < n 1.1. Пока в активном узле нет записи для ci 1.1.1. Создать новый узел, доступный из активного узла через запи­сь для ci. Этот новый узел является листом. 1.1.2. Переместить активный узел вниз по его суффиксной ссылке, если он еще не является корневым. 1.2. Переместить активный узел вверх по ссылке до ci, если только он не является корневым и мы только что не создали эту ссылку. Увеличить i. В этой схеме можно видеть два типа шагов: в 1.1 активный узел следует по суффиксной ссылке и перемещается в узел, представляющий строку, 346 Глава 8. Структуры данных для строк которая короче на один символ, а в 1.2 активный узел следует по обычной ссылке и перемещается в узел, представляющий строку, которая длиннее на один символ. Шаг 1.2 состоит их n итераций, поэтому шаг 1.1 также выполняется только n раз. Это предполагает сложность O(n). Однако есть несколько проблем, потому что некоторые узлы могут отсутствовать изза сжатия пути, особенно это касается узлов, представленных открытыми путями. И для этих узлов суффиксные ссылки также будут отсутствовать. Таким образом, мы должны найти неявные узлы, когда они понадобятся, и сделать их явными. Каждый неявный узел можно представить явным узлом, за которым следует подстрока: если явный узел представляет строку α, а подстрока имеет вид ci...cj, то вместе они представляют строку αci...cj. Это представление имеет постоянный размер, если использовать (i, j) для описания ci...cj. Каждый неявный узел имеет множество таких представлений, по одному для каждого явного узла на пути к этому неявному узлу. При таком представлении неявного узла мы можем сделать его явным, сначала проследовав по пути в сжатом префиксном дереве, насколько это возможно, а затем в последний явный узел вставив правильную ссылку на вновь созданный явный узел. Это также решает проблему отсутствия суффиксных ссылок для неявных узлов: если неявный узел представлен явным узлом, за которым следует ci...cj, то узел, достижимый по суффиксной ссылке из неявного узла, будет узлом, достижимым по суффиксной ссылке из явного узла, за которым следует та же самая подстрока ci...cj. Нам также нужно ограничить время преобразования неявных узлов в явные. На месте неявного узла создается явный на шаге 1.1.1, только когда он становится разветвляющим узлом. Всего возможно не более n – 1 разветвляющих узлов, поэтому такое преобразование происходит только O(n) раз. Для отдельной операции нет границы O(1), потому что может понадобиться пройти через множество явных узлов, чтобы, наконец, найти ссылку, куда должен быть вставлен явный узел взамен неявного. То же верно и применительно не только к текущему активному узлу, но и к текущему представлению активного узла. Представление активного узла включает явный узел и подстроки. Подстрока удлиняется только на шаге 1.2; каждый раз, когда происходит переход по суффиксной ссылке, длина строки не меняется, и каждый раз, когда происходит переход по ссылке на другой явный узел в процессе превращения неявного узла в явный, строка становится короче. Таким образом, общее количество пройденных явных узлов при преобразовании неявных узлов в явные равно O(n). Это дает общую оценку сложности O(n) построения суффиксного дерева для строки с длиной n. Эта структура имеет следующую производительность: Теорема. Суффиксное дерево – это статическая структура, осуществ­ ляющая предварительную обработку строки s и поддерживающая возможность поиска подстрок. Если узлы дерева реализованы в виде связанных списков, то предварительная операция создания суффиксного дерева make_suffix_tree преобразует строку с длиной n 8.4. Суффиксные массивы 347 в алфавите A за время O(|A|n) в структуру с размером O(n), которая поддерживает операцию find_string поиска подстроки q за время O(|A| length(q)). Суффиксное дерево с успехом можно использовать в различных задачах, связанных с обработкой строк [33, 265]. Некоторые применения обусловлены особенностями структуры, лежащей в основе, как, например, обработка параметризованных строк, предложенная в [46] и более подробно рассмотренная в [336] и [149]. Параметризованная строка состоит из символов базового алфавита и переменных, где все вхождения одной и той же переменной должны быть заменены одной и той же строкой. Это можно рассматривать как класс эквивалентности строк, например программу для переименования переменных. Другой вариант – двумерные строки, прямоугольные массивы символов алфавита, которые можно рассматривать как абстракцию изображений, где двумерная подстрока соответствует фрагменту в большом изображении. Двумерные суффиксные деревья были описаны в [250] и получили дальнейшее развитие в [141 и 149]. Обсуждение версий большей размерности можно найти в [318]. Суффиксные деревья также можно использовать для поиска повторяющихся фрагментов в тексте, что является важной подзадачей в методах сжатия на основе словаря, таких как Lempel-Ziv. Близкой родственной структурой является ориентированный ациклический граф слов (Directed Acyclic Word Graph, DAWG) – наименьший автомат, принимающий подслова из заданного слова [78, 77, 279]. Ее можно построить с помощью тех же алгоритмов, что и суффиксные деревья [137, 539]. Еще один вариант – аффиксное дерево, изученное в [382]. Вследствие большой востребованности суффиксных деревьев было бы желательно иметь ее динамический вариант, позволяющий изменять базовую строку. Этот вопрос уже рассматривался в [404], но существуют слова с длиной n, для которых изменение O(1) позиций в слове вызывает Ω(n) изменений в структуре суффиксного дерева [40]. Суффиксные деревья также можно построить для нескольких строк, если требуется определить, встречается ли искомая строка q как подстрока в какой-либо из k строк s1, ..., sk. Конструкция дерева в этом случае точно такая же; на самом деле можно просто объединить строки в s1s2, ..., sk и построить нормальное суффиксное дерево для этой объединенной строки. 8.4. Суффиксные массивы Суффиксный массив – это структура, альтернативная суффиксному дереву. Она была разработана Манбером и Майерсом в [395]44 и поддерживает те же операции, что и суффиксное дерево: предварительно обрабатывает 44 В то же время Гоннетом ([255]) была разработана аналогичная структура под названием PAT array для индексирования Оксфордского словаря. 348 Глава 8. Структуры данных для строк длинную строку, а затем позволяет быстро искать подстроки в этой строке. Одно из преимуществ суффиксного массива заключается в том, что его размер не зависит от размера алфавита и он предлагает совершенно другой подход к решению задач того же типа со строками. В общем случае суффиксный массив получается меньше суффиксного дерева, но это в некоторой степени зависит от способа кодирования; в самой простой реализации каждый символ исходной строки кодируется в суффиксном массиве тремя целыми числами, тогда как в суффиксном дереве с узлами-списками на каждый узел приходится пять указателей, а количество узлов не больше длины строки, но может быть меньше. В любом случае структура получается значительно больше исходной строки – суффиксное дерево в 20 раз45, простой суффиксный массив в 12 раз, – оба коэффициента можно уменьшить с помощью некоторых приемов кодирования, как было показано в исследовании [343] и во многих других работах. Для суффиксного массива необходимо, в частности, учитывать не только пространство, занимаемое самой конструкцией, но и дополнительную память, используемую в процессе создания структуры [292, 106, 398, 316, 425]. Также были предложены структуры, занимающие промежуточное положение между суффиксными массивами и деревьями [305, 152, 316]. В предыдущих главах мы часто заявляли, что память больше не является проблемой, но для структур, основанных на строках, эта проблема остается актуальной, потому что в этом случае накладные расходы получаются очень большими. Основная причина заключается в том, что стандартные символы ASCII занимают памяти намного меньше, чем целые числа или указатели. Если бы мы использовали еще меньший алфавит, соотношение было бы еще хуже. Точно так же на соотношение влияет ширина машинного слова: при использовании 64-битных указателей накладные расходы в простой реализации удваиваются, и имея простой текст длиной не более 216 символов, мы могли бы уместить все наши указатели и целые числа в 16-битные значения и вдвое сократить накладные расходы. По этой причине к различным числам, указанным в литературе, следует относиться с осторожностью; они предполагают, что длина текста меньше 232. Это особенно относится к суффиксным деревьям, размеры которых зависят от заданного текста, они часто являются значениями, полученными экспериментально на некотором множестве образцов. Чистым способом сравнения различных методов в моделях, подобных нашей ссылочной машине, был бы подсчет целых чисел и указателей, приходящихся на текстовый символ в худшем случае. Или можно начать считать биты дополнительного пространства, необходимого для структуры [280]. Основная идея суффиксного массива состоит в том, чтобы сохранить все суффиксы предварительно обработанной строки s в лексикографическом порядке и выполнить двоичный поиск по ним, чтобы найти искомую строку. Здесь сразу можно заметить один недостаток структуры: время поиска подстроки q в длинной строке s также зависит от length(s); чтобы найти под45 Во многих работах для суффиксных деревьев приводится коэффициент 28. 8.4. Суффиксные массивы 349 строку q среди length(s) возможных суффиксов, потребуется O(log length(s)) лексикографических сравнений между q и некоторым суффиксом s. Без использования дополнительной информации каждое сравнение занимает время O(length(q)), а весь поиск – O(length(q) log length(s)). Если доступна некоторая дополнительная информация о длине общих префиксов суффиксов s, то время поиска уменьшается до O(length(q) + log length(s)). Суффиксному же дереву требуется время O(length(q)) независимо от длины s. 5 4 12 8 9 10 1 2 0 6 13 3 7 11 0 1 2 3 4 5 6 7 8 9 10 11 12 13 xes uffixes tedsuffixes s suffixes sortedsuffixes rtedsuffixes ortedsuffixes ixes fixes ffixes es edsuffixes dsuffixes s o r t e d s u f f i x e s Рис. 8.10. Суффиксы слова «sortedsuffixes» в лексикографическом порядке с их начальными индексами в строке Суффиксы строки s нужно представить так, чтобы они были отсортированы в лексикографическом порядке и по ним можно было выполнять двоичный поиск. Самый естественный способ – создать один большой массив, хранящий начальные индексы лексикографически отсортированных суффиксов, т. е. нужен целочисленный массив той же длины, что и исходная строка. Это еще один недостаток. Может возникнуть проблема выделения памяти для массива целых чисел, если строка s будет иметь очень большую длину. А для хранения общей префиксной информации нужны еще два таких массива. Эта структура не вписывается в нашу ссылочную модель, допускающую только массивы фиксированного размера. Последняя проблема заключается в том, как построить структуру. В [395] предложен алгоритм, создающий суффиксный массив из строки с длиной n за время O(n log n), которое больше времени O(n), необходимого для создания суффиксного дерева. Суффиксный массив можно создать из суффиксного дерева за время O(n), но если суффиксное дерево уже имеется, то нет смысла строить суффиксный массив. За последующие десять лет в [306, 318, 319, 329, 330, 280] было предложено множество различных методов построе­ния структуры за время O(n); из них метод, предложенный в [306], вероятно, является самым простым46, и мы опишем его позже. К настоящему времени найдено множество других методов строительства суффиксного массива, обзор и сравнение которых можно найти в [469]. Следует отметить, что некоторые алгоритмы со сложностью O(n2) в худшем случае превосходят алгоритмы O(n) на реальных тестовых данных. Во всех этих работах, а также в [395 и 292] построение массива отсортированных суффиксов рассматривается как основная задача, являющаяся частным случаем классической задачи сортировки строк. Но на самом деле построение структуры происходит в два шага: сортировка суффиксов 46 Там же дается программный код, но в предложенном решении создается четыре вспомогательных массива одинаковой длины, что нивелирует основное достоинство суффиксного массива. 350 Глава 8. Структуры данных для строк и поиск информации об общем префиксе. Хороший метод для реализации этого второго шага был представлен в [310]. За время O(n) он строит информацию об общем префиксе из отсортированных суффиксов. Теперь опишем алгоритм поиска в суффиксном массиве, разработанный в [395]. По сути, он производит двоичный поиск в массиве с начальными индексами суффиксов исходной строки s, отсортированными в лексикографическом порядке. Дополнительно имеется два массива с информацией о самом длинном общем префиксе, найденном во время двоичного поиска. Предположим, мы уже знаем, что искомая строка q лексикографически находится между строками left и right, и теперь нужно сравнить ее со строкой middle. Если известно, что строки left и middle имеют общие первые k символов, то любая другая строка между ними в лексикографическом порядке также начинается с этих первых символов. Соответственно, если строки left и q имеют общие первые l символов, причем l < k, то искомая строка не может находиться между строками left и middle. Аналогично, если l > k, то строка middle не может находиться между строками left и q. Итак, имея числа k и l, можно определить результат сравнения на этом шаге двоичного поиска, не глядя на строку q, если только l = k. Если l = k, то необходимо сравнить строки q и middle за время, пропорциональное длине общего префикса q и middle. Если в результате этого сравнения обнаружится, что q находится справа от middle, то в двоичном поиске middle становится новой строкой left, и мы с пользой потратили, возможно, большое время на сравнение, поскольку обновили l и длину общего префикса q с left. Но если q находится левее middle, то эта информация не поможет избежать сравнения в будущем. Если мы будем искать q = b50a в строке ab100, используя только информацию о левом общем префиксе, мы будем на каж­ дом шаге сравнивать b50a от начала до середины строки b≥50, потому что длина общего префикса с левой строкой a остается равной 0. Этого можно избежать, сохраняя информацию о длине общего префикса слева и справа. На каждом шаге, где необходимо сравнивать строки q и middle, длина общего префикса слева или справа увеличивается на количество найденных дополнительных общих символов и в сумме не превышает length(q) за все шаги. Если есть возможность выполнить шаг двоичного поиска без сравнения символов, основываясь только на информации о длине общего префикса, то эта информация также дает новую длину общего префикса искомой строки q для новых строк left и right. left k middle общий префикс right left общий префикс query middle right left l l Рис. 8.11. Общие длины префиксов в двоичном поиске и позиция искомой строки query относительно строки middle middle query right 8.4. Суффиксные массивы 351 Из этих чисел можно заранее вычислить длину общего префикса left и middle, тогда как другое число необходимо сохранить во время поиска. У нас есть один массив left_middle_cp, хранящий длину общего префикса left и middle, и еще один массив right_middle_cp, хранящий длину общего префикса middle и right, для каждого интервала, который может встретиться в ходе двоичного поиска. Длина каждого из этих массивов равна длине исходного отсортированного списка строк, потому что в двоичном поиске каждый элемент встречается не более чем в одном интервале как точка разделения. Соответственно, номер среднего элемента можно использовать в процессе двоичного поиска как адрес интервала в массиве. В процессе поиска поддерживаются два числа – left_query_cp и right_ query_cp, – представляющих длины общих префиксов искомой строки с левой и правой конечными точками текущего интервала. Еще нам нужен массив sorted_str, содержащий указатели на строки, отсортированные в лексикографическом порядке. В суффиксных массивах эти строки являются суффиксами предварительно обработанной строки, но алгоритм поиска может работать с любым массивом отсортированных строк и может пригодиться не только при работе с суффиксными массивами. При анализе этого алгоритма двоичного поиска на наборе отсортированных строк было отмечено, что основной цикл двоичного поиска выполняется только log(n) раз, и все вычисления в нем, кроме определения общего префикса, производятся за постоянное время. Вычисление общего префикса длины i занимает время O(1 + i), и эта длина добавляется либо в left_query_cp, либо в right_query_cp, каждый из которых ограничен длиной искомой строки. Теперь подытожим работу этой структуры: Теорема. Массив указателей на n лексикографически отсортированных строк вместе с двумя массивами по n целых чисел в каждом, содержащими информацию о длине общего префикса, позволяет определить, совпадает ли искомая строка q с префиксом какой-либо из этих строк, за время O(length(q) + log n). Чтобы использовать этот алгоритм поиска с суффиксными массивами, нужно построить массив отсортированных суффиксов. Опишем теперь конструкцию Карккайнена и Сандерса [306]. Идея алгоритма заключается в рекурсивном построении суффиксного массива для более короткой строки в большом алфавите, а затем восстановлении суффиксных массивов для частей исходной строки и объединении их. Эта общая схема также используется в [318, 320] и присутствовала в алгоритме суффиксного дерева [203]. В действительности принцип интерпретации пар последовательных символов как символов нового алфавита и сопоставления строк с более короткой строкой в большем алфавите уже встречался в [309]. Отличия лишь в деталях. Карккайнен и Сандерс рассматривают тройки последовательных символов s[i]s[i + 1]s[i + 2] для i ≢ 0 mod 3. Это 32 n троек, упорядоченных в 352 Глава 8. Структуры данных для строк лексикографическом порядке. С помощью поразрядной сортировки каж­ дой тройке можно присвоить ранг и использовать его как каноническое имя. Поразрядная сортировка применяется просто потому, что сортируемые тройки – это тройки чисел меньше n и, следовательно, меньше n3, и их можно отсортировать за время O(n). Теперь построим новую строку с длиной 23 n, состоящую из последо­ вательности канонических имен троек s[3i + 1]s[3i + 2]s[3i + 3] для i = 0, ..., 13 n − 1, за которыми следует последовательность имен троек 1 s[3i + 2]s[3i + 3]s[3i + 4] для i = 0, ..., n − 1. Это строка на алфавите целых 3 чисел не более n. Суффикс этой новой строки, начинающейся с позиции i в первой группе, соответствует строке, которая является суффиксом исходной строки, начинающимся с позиции 3i + 1, за которым следует метка конца, и исходной строки, начинающейся с позиции 2. И суффикс этой новой строки, начинающейся с позиции i во второй группе, соответствует строке, которая является суффиксом исходной строки, начинающимся с позиции 3i + 2, за которым следует метка конца. Для лексикографического порядка этих строк часть после метки конца не имеет значения, поэтому лексикографический порядок суффиксов новой строки позволяет прочитать суффиксы исходной строки в лексикографическом порядке, начинающиеся с позиции 3i + 1 или 3i + 2. В качестве следующего шага нужно найти порядок суффиксов, начинающихся с позиций 3i + 0, и объединить эти порядки, чтобы получить лексикографический порядок всех суффиксов. Но порядок суффиксов, начинающихся с позиции 3i + 0, определяется первым символом этого суффикса, а среди суффиксов с таким же первым символом – порядком остальной части суффикса, который уже известен, потому что она начинается с позиции 3i + 1. Поэтому построить лексикографический порядок оставшихся суффиксов можно за время O(n). В заключение остается только объединить эти два порядка, для чего нужно сравнить лексикографический порядок суффиксов, начинающихся с позиции 3i + 0 и 3i + 1 или 3i + 2. Это сравнение можно выполнить за постоянное время, используя один или два первых символа: • если сравниваются суффиксы, начинающиеся в позиции 3i и 3j + 1, то для получения результата сравнения достаточно сравнить их первые символы или, если они совпадают, сравнить оставшиеся части, являющиеся суффиксами, начинающимися с позиций 3i + 1 и 3j + 2, а значит, встречающимися в одной и той же отсортированной последовательности, которые можно сравнить за время O(1); • если сравниваются суффиксы, начинающиеся в позиции 3i и 3j + 2, то для получения результата сравнения достаточно сравнить по два их первых символа или, если они совпадают, сравнить оставшиеся части, являющиеся суффиксами, начинающимися с позиций 3i + 2 и 3j + 4, а значит, встречающимися в одной и той же отсортированной последовательности, которые можно сравнить за время O(1). 8.4. Суффиксные массивы 353 Таким образом, время сортировки всех суффиксов строки с длиной n равно O(n) плюс время сортировки суффиксов строки с длиной 32 n, что в сумме дает границу O(n). Следует отметить, что, несмотря на увеличение размера алфавита, он никогда не превысит n, потому что каждый символ соответствует k-символьной подпоследовательности, которая встречается в строке для некоторого фиксированного значения k. Остается только вычислить массивы left_middle_cp и right_middle_cp. Для этого можно использовать метод, предложенный в [310], и сначала построить массив cp, где cp[i] – длина общего префикса суффиксов, начинающихся с sorted_suffix[i − 1] и sorted_suffix[i]. В этом методе также используется массив rank, содержащий перевернутый массив sorted_suffix: rank[i] = j, если sorted_suffix[j] = i. Ключевая особенность здесь состоит в следующем: когда известно, что суффикс, начинающийся в позиции i, и суффикс, смежный с ним в лексикографическом порядке, начинающийся в позиции sorted_ suffix[rank[i] + 1], имеют общий префикс с длиной не менее l, то суффикс, начинающийся с позиции i + 1, и суффикс, смежный с ним в лексикографическом порядке, начинающийся в позиции sorted_suffix[rank[i + 1] + 1], имеют общий префикс с длиной не менее l − 1. Соответственно, если определить длины общих префиксов смежных суффиксов в последовательнос­ ти, заданной массивом rank, то мы получаем два типа шагов: в которых эта длина уменьшается на 1, что занимает постоянное время, и в которых длина остается неизменной или увеличивается, что занимает время, пропорциональное увеличению. Но длина не может быть больше n, а общее уменьшение не превосходит n, поэтому общее увеличение меньше 2n, а время определения всех этих длин равно O(n). Наконец, нужно получить массивы left_middle_cp и right_middle_cp из cp. Для этого можно воспользоваться тем фактом, что длина общего префикса любых двух суффиксов равна минимальной длине общего префикса двух суффиксов, смежных в лексикографической последовательности. Таким образом, элементы в массивах left_middle_cp и right_middle_cp являются максимальными для интервалов, которые могут возникнуть при двоичном поиске между средней и левой и между средней и правой конечными точками. Но каждый такой интервал является объединением двух интервалов, которые могут встретиться на одном из следующих шагов в двоичном поиске. Поэтому если создавать их снизу вверх, начиная с наименьшего, каждую запись можно построить за время O(1) из предыдущих записей, что в сумме даст сложность O(n). Подытожим работу этой структуры: Теорема. Суффиксный массив – это статическая структура, которая выполняет предварительную обработку строки s и поддерживает поиск подстрок. Ее можно построить за время O(length(s)), используя пространство O(length(s)), и она будет поддерживать возможность поиска строки q за время O(length(q) + log(length(s))). 354 Глава 8. Структуры данных для строк Итак, суффиксный массив можно построить за то же время, что и суффиксное дерево, и получить почти такую же производительность поиска. Во многих случаях суффиксные массивы и суффиксные деревья могут использоваться взаимозаменяемо ([2]). Но если пространство не является проблемой, то суффиксные деревья выглядят более элегантными. Глава 9 Хеш-таблицы Хеш-таблица – это подобная словарю структура с большим практическим значением и большой эффективностью. Ее идея довольно проста: мно­ жество объектов хранится вместе с их ключами. Помимо этого, есть множество S = {0, ..., s – 1} участков памяти и некоторая функция h доступа к каждому из этих участков. Объекты с ключом u сохраняются в участке с функцией доступа h(u). Если есть несколько сохраняемых в одном участке объектов, то между ними может возникнуть конфликт. Если же конфликта нет, можно завести новый участок в виде массива из одного объекта. Проб­ лема хеш-таблиц в основном связана с вопросом: как поступать в случае конфликта и как подобрать такую функцию h, чтобы количество конфликтов было наименьшим. Идея хеш-таблиц появилась довольно давно (примерно в 1953 году) в компании IBM [326]. Долгое время главной причиной популярности хеш-таблиц была ее простая реализация с удачными хеш-функциями h – малопонятным способом представить большое множество объектов в виде таблицы небольшого размера. Эта структура практически стала легкореализуемой и азбучной для программистов, но без всякой гарантии ее высокой производительности. Для многих специалистов она во многих источниках до сих пор представляется именно так. Разработка и исследование методов хеш-таблиц, которые доказуемо хороши в том или ином смысле, начались только в 1980-х годах, и теперь хорошая хеш-таблица действительно может быть очень эффективной структурой. 9.1. Основные хеш-таблицы и разрешение конфликтов Если ключи большого универсального множества отображаются на небольшое множество ключей S = {0, ..., s – 1}, то не исключено, что одному ключу множества S будут соответствовать несколько ключей множества . В словаре не нужно хранить все множество , а лишь некоторое подмно­ жество X ⊂ из n ключей для объектов, находящихся в данный момент в словаре. Но если множество X неизвестно при выборе хеш-функции h : → S и оно может изменяться при добавлениях/удалениях элементов, 356 Глава 9. Хеш-таблицы то его можно построить так, чтобы его элементы отображались в один и тот же элемент s из множества S. Однако в случае конфликтов между его элементами нужно что-то предпринимать внутри множества X. Есть два классических решения этой проблемы: 1) наличие для каждого s ∈ S вторичной структуры, где хранятся все элементы x ∈ X с хеш-функцией h(x) = s. Таким образом, каждая из s групп содержит другой словарь, но поскольку группы должны содержать только несколько элементов, этот вторичный словарь может быть очень простым – объединить его элементы в связный список, который называется «цепочкой»47. Этот метод рекомендуется; 2) наличие для каждого u ∈ последовательности альтернативных адресов в S: если h(u) = h1(u) имеет конфликт с другим элементом, предпринимается попытка найти функции h2(u), h3(u)…, пока не будет найдена пустая группа. Это называется «прямой адресацией». Этот метод часто изучался, но его использование настоятельно не рекомендуется48. В первом решении множество делится на h–1(S) групп, где сохраняются элементы x ∈ X ⊂ , находящиеся в одном и том же разделе вторичной структуры. Можно добавлять и удалять элементы структуры, если это возможно во вторичной структуре, так как хеш-функция h просто направляет в нужную вторичную структуру. Если разделение на подмножества X имеет в каждом разделе не более нескольких элементов, то это хорошо, но если в одной группе много одинаковых элементов, то это хуже, чем используемая нами вторичная структура. В качестве вторичной структуры можно было бы использовать выровненное дерево поиска и получить оценку времени O(log n) для худшего случая плюс дополнительное время O(1) для всех тех элементов, что попали в одну группу. Но можно показать, что при правильном выборе хеш-функции и не слишком маленьком множестве S можно ожидать, что большинство групп будут почти пустыми. Для этого в качестве вторичной структуры поиска достаточно связного списка. Второе решение было очень популярным, потому что там не нужны были связные списки – так же, как и динамическая память. Таким образом, считалось, что его было очень легко реализовать и сэкономить память, поскольку это была неявная структура без использования ссылок. Эти незначительные преимущества, которые кажутся несущественными для современных компьютеров, имеют существенный недостаток: такая структура не поддерживает удаление. Чтобы добавить элемент x в пустую группу, просматривается последовательность хеш-функций h1(x), h2(x), ..., hk(x). Таким образом, при поиске нужно заново просматривать ту же самую после47 48 В первоисточниках это называется «косвенной», или «отделенной», цепочкой, поскольку узлы списков выносятся за пределы хеш-таблицы. «Прямое сцепление» («direct chaining») использует записи хеш-таблицы как узлы и имеет те же недостатки, что и метод 2. Некоторые варианты таких цепочек описаны в [53]. Статьи о различных вариантах прямой адресации не стоит публиковать. 9.1. Основные хеш-таблицы и разрешение конфликтов 357 довательность, пока не будет найден сам элемент или пустая группа. Если при этом элемент вдоль этой последовательности удаляется, то его группа становится пустой и дальнейший поиск x будет неудачным, поскольку путь поиска прерывается. Этого можно избежать, если помечать удаляемый элемент как недействительный, но группа при этом все равно остается заполненной. В этом случае нужно накапливать множество недействительных элементов, которые можно повторно использовать в операциях добавления, однако это повлияет на длину пути поиска, несмотря на то что эти элементы считаются недействительными. Если же удалить элемент из группы i, то можно попытаться идти вверх по пути поиска другого элемента, который имел группу i на этом же пути, и найти заполненную группу. Но это возможно, если известен потенциал другого элемента. Так что все элементы группы i, лежащие на пути поиска, должны входить в одну и ту же группу j, как и все последующие элементы на этом пути. Это очень плохо, так как приводит к скоплению одновременно растущих заполненных групп, и любой метод удаления обязательно приведет к такому скоплению. По этой причине наиболее очевидный выбор хеш-функции hi(x) = h0(x) + i плох. Если нет удалений, то в качестве путей поиска допускается множест­ во различных последовательностей функций (hi(x))si = 1, которые исследовали ожидаемую длину самого протяженного пути поиска. Большое количество статей было посвящено оптимальному выбору последовательности (hi(x))si = 1, называемой рядом проб [338, 540, 188, 328, 9, 264, 254, 355, 356, 563, 564, 378, 471]. Однако небольшой выигрыш в памяти за счет отсутствия ссылок никогда не перекроет главного недостатка – невозможности удаления элементов. Ниже приводится код для основной структуры хеш-таблицы с цепочками. typedef struct l_node { key_t key; object_t *obj; struct l_node *next; } list_node_t; typedef struct { int size; list_node_t **table; int (*hash_function)(key_t, hf_param_t); /* хеш-функция может нуждаться еще в некоторых параметрах */ hf_param_t hf_param; } hashtable_t; hashtable_t *create_hashtable(int size) { hashtable_t *tmp; int i; tmp = (hashtable_t *) 358 Глава 9. Хеш-таблицы malloc(sizeof(hashtable_t)); tmp->size = size; tmp->table=(list_node_t**) malloc(size*sizeof(list_node_t*)); for( i = 0; i < size; i++ ) (tmp->table)[i] = NULL; /* добавить и заполнить хеш-функцию */ /* и выбрать необходимые параметры */ return(tmp); } object_t *find(hashtable_t *ht, key_t query_key) { int i; list_node_t *tmp_node; i = ht->hash_function(query_key, ht->hf_param); tmp_node = (ht->table)[i]; while( tmp_node != NULL && tmp_node->key != query_key ) tmp_node = tmp_node->next; if( tmp_node == NULL ) return(NULL); /* ключ не найден */ else return(tmp_node->obj); /* ключ найден */ } void insert(hashtable_t *ht, key_t new_key, object_t *new_obj) { int i; list_node_t *tmp_node; i = ht->hash_function(new_key, ht->hf_param); tmp_node = (ht->table)[i]; /* добавить в начало */ (ht->table)[i] = get_node(); ((ht->table)[i])->next = tmp_node; ((ht->table)[i])->key = new_key; ((ht->table)[i])->obj = new_obj; } object_t *delete(hashtable_t *ht, key_t del_key) { int i; list_node_t *tmp_node; object_t *tmp_obj; i = ht->hash_function(del_key, ht->hf_param); tmp_node = (ht->table)[i]; if( tmp_node == NULL ) return(NULL); /* список пуст, удалять нечего */ if( tmp_node->key == del_key ) /* если первый в списке */ { tmp_obj = tmp_node->obj; (ht->table)[i] = tmp_node->next; return_node(tmp_node); return(tmp_obj); } 9.1. Основные хеш-таблицы и разрешение конфликтов 359 /* список не пустой: удалить не первый в списке */ while( tmp_node->next != NULL && tmp_node->next->key != del_key) tmp_node = tmp_node->next; if( tmp_node->next == NULL ) return(NULL); /* объект не найден: удалять нечего */ else { list_node_t *tmp_node2; /* узел разделения */ tmp_node2 = tmp_node->next; tmp_node->next = tmp_node2->next; tmp_obj = tmp_node2->obj; return_node(tmp_node2); return(tmp_obj); } } Оба метода породили множество вариантов. Поскольку проверяются последовательные объекты до обнаружения нужного ключа, то, спускаясь по цепочкам списков к нужной группе, желательно, чтобы часто используемые в каждом списке объекты были найдены как можно раньше. Таким образом, для каждой группы объектов это давно известная задача доступа к списку объектов с перемещением его элементов вперед, называемая двукратным соперничеством, когда доступ к списку не более чем вдвое превышает количество объектов, чем при стратегии с оптимальной последовательностью элементов. Так что это простая модификация, имеющая определенные достоинства с возможностью при поиске перемещать найденные объекты вперед по списку, когда список довольно испорчен. Такой подход был назван самонастраивающимися хеш-таблицами [458, 558]. Его можно объединить со стратегией прямой адресации, но ее реализация будет гораздо сложнее. Ниже приводится функция find со стратегией перемещения элементов вперед. object_t *find(hashtable_t *ht, key_t query_key) { int i; list_node_t *front_node, *tmp_node1, *tmp_node2; i = ht->hash_function(query_key, ht->hf_param); front_node = tmp_node1 = (ht->table)[i]; tmp_node2 = NULL; while( tmp_node1 != NULL && tmp_node1->key != query_key ) { tmp_node2 = tmp_node1; tmp_node1 = tmp_node1->next; } if( tmp_node1 == NULL ) return(NULL); /* ключ не найден */ else /* ключ найден */ { if( tmp_node1 != front_node ) /* идем дальше */ { tmp_node2->next = tmp_node1->next; /* разъединить */ 360 Глава 9. Хеш-таблицы tmp_node1-> next = front_node; (ht->table)[i] = tmp_node1; } return(tmp_node1->obj); } } Множество других вариантов изучались для схем c прямой адресацией. Главное здесь в том, что конфликт двух элементов полностью симметричен и нужно выбрать один из них, чтобы спуститься вниз по пути его поиска, тогда как другой должен остаться в своей группе. В схеме с прямой адресацией новый элемент всегда перемещается, но в этом нет необходимости. Поэтому при добавлениях появляется некая свобода в изменении таблицы. Впервые это было отмечено в [84], а затем исследовано во многих других стратегиях [20, 393, 256, 383, 477, 381]. Хеширования методами «Робин Гуд» [119, 168], «последний пришел – первый обслужен» [464], «Кукушка» [457, 167] относятся к этой же категории. Иной вариант прямой адресации – это хеширование с разбиением последовательности [372, 558], когда следующий шаг в последовательности зависит от ключа элемента, входящего в текущую группу, что делает его похожим на дерево поиска как вторичной структуры цепочки. Благодаря своей концептуальной простоте и отсутствию внутренних проблем цепочки порождают гораздо меньше вариантов и остаются рекомендуемым решением. Один из интересных вариантов – это двусторонние цепочки, когда каждый элемент пространства входит в две возможные группы и добавляется в группу с меньшим количеством элементов. Такое решение было предложено в работе [41], а затем исследовано в работе [69]. Существует несколько способов, позволяющих использовать ссылки до достижения предельной вместимости гуппы, после чего выстраиваются цепочки. Простейшее решение – это хеш-таблица в виде массива, где каж­ дой группе отводится место только для фиксированного количества элементов, а сами цепочки используются только тогда, когда группа заполнена. Другой способ – использование последовательности хеш-таблиц: если запись уже есть в одной таблице, то просматривается вторая таблица с другой хеш-функцией, и так далее до достижения фиксированного максимума количества таблиц. Но в конце концов все-таки придется выстраивать цепочки. Этот метод очень удобен для распараллеливания, так как поиск в разных таблицах независим [354, 94, 387]. 9.2. Универсальные семейства хеш-функций До конца 1970-х годов в любом исследовании хеширования предполагалось, что значения хеш-элементов были независимыми случайными величинами, равномерно распределенными по доступным адресам (это модель равномерного хеширования). Но при использовании хеш-функций 9.2. Универсальные семейства хеш-функций 361 неявно подразумевалось, что любая достаточно сложная функция, о которой программист не догадывается, будет случайной и будет перемешивать значения входных значений достаточно хорошо [462]. Это было довольно неудовлетворительно, так как при каждом применении хеш-таблицы используются вполне конкретные множества, и все они, конечно же, не распределяются равномерно в пространстве . Прорывом стало понятие универсальных семейств хеш-функций [118]. Его идея в том, чтобы вместо фиксированной хеш-функции с необоснованным предположением о случайном распределении ее входных данных выполнять случайный выбор самой хеш-функции из семейства хеш-функций и доказать, что для любых входных данных значения хеш-функций хорошо и с высокой вероятностью распределяют эти данные. Пусть – семейство функций, отображающих на S. Важнейшее свойство семейства ℱ, достаточное для распределения любого множества X ⊂ по S, – это произвольный выбор функции f ∈ . Тогда для некоторого с должно выполняться условие: с если u1, u2 ∈ различны, то Prob(f (u1) = f (u2)) ≤ ––––. |S| Так что вероятность конфликта любых двух элементов случайно выбранной хеш-функции всего лишь чуть больше той вероятности, когда их значения выбирались независимо и равномерно из S, что составляет 1/|S|. Любое семейство хеш-функций из с таким свойством называется универсальным семейством хеш-функций49. Иногда это свойство обозначается как 2-универсальное, потому что ограничивается двумя элементами. Но есть еще и k-универсальное свойство. Оно такое же, но для k-кортежей: в любом кортеже (u1 ... uk) из k различных элементов пространства его элементы конфликтуют с вероятностью не более с/|S|k –1. Прямым следствием этого является то, что любое множество X, сохраняемое в хеш-таблице S со случайно выбранной функцией h ∈ , любое значение y будет иметь конфликт с вероятностью менее с|X|/|S|, что следует из линейности вероятности, применяемой ко всем возможным конфликтующим парам. За исключением множителя с, это обычное случайное присваивание. Если хеш-таблица достаточно велика и позволяет сохранять все элементы X в разных группах при условии |S| ≥ |X|, то для любого элемента y ∈ ожидаемое количество конфликтов y в X оценивается как O(1). Так что при использовании цепочек ожидаемое время поиска, добавления или удаления тоже не превысит O(1). Свойства хеш-значений, получаемых при выборе из универсального семейства хеш-функций, очень схожи с их попарной независимостью. Это свойство гораздо слабее их полной независимости в старой единообразной модели хеширования, но зато здесь ожидаемое количество конфликтов одного элемента можно оценить как O(1). При распределении это свойство 49 Некоторые источники требуют, чтобы c = 1. Это требование несколько слабее, но оно проще для получения нужного результата. Различные варианты этого свойства рассматривались в [511, 342]. 362 Глава 9. Хеш-таблицы еще слабее: для n случайных значений с ожидаемым временем O(1) наибольшее значение может быть довольно большим. При хешировании множества из n элементов в таблице размера s ожидаемый размер наибольшей группы не превысит �� n2 O�1 + ����, s потому что ��������������� s max (0, bucketsize(i) ≤ 1 + ���max(0, bucketsize(i) − 1)� i=1,...,s 2 i=1 �������� s < 1 + ��2�bucketsize(i)�, 2 i=1 c а общее число конфликтущих пар �si =1�bucketsize(i)� меньше, чем �n2�−− , по2 s этому ��������� s E � max bucketsize(i)� < 1 + E ���2�bucketsize(i)�� i=1,...,s i=1 2 ���������� s ≤ 1 + �2E ���bucketsize(i)�� i=1 ���� 2 ��� c n2 �� ≤ 1 + �2�n2� � s ≤ 1 + �c s . Подытожим свойства универсальных семейств хеш-функций: Теорема. При распределении множества X ⊂ из п элементов в хеш-таблице S размера s при случайном выборе хеш-функции из универсального семейства: n ; • ожидаемое количество конфликтов любого элемента y ∈ ≤ c −− ��� s 2 n �� • а ожидаемый наибольший размер группы ≤ 1 + �c s . Конечно, это всего лишь верхняя граница ожидаемого наибольшего размера группы, но в работе [12] было показано, что это лучшее, чего можно добиться за счет универсальности функций с выбором конкретного универсального семейства хеш-функций для хеш-таблицы размера n и множества 9.2. Универсальные семейства хеш-функций 363 из n элементов, поэтому для любой функции из такого семейства получается группа из Θ(√n) элементов. Так что при наибольшем размере группы условие универсальности намного слабее, чем модель полной независимости с равномерным хешированием, которая дает ожидаемый наибольший n размер группы Θ�–––log ––––––––� при хешировании множества из n элементов в log log n таблице размера n [254]. Отдельные универсальные семейства хеш-функций обладают граздо лучшими пределами, чем O(√n) [12]. Пока не было примеров универсальных семейств хеш-функций. Прос­ тейший пример – это семейство функций, отображающих пространство на множество S. Это то же самое, что независимо присваивать каждому элементу пространства его представление, так что это просто иной способ выражения единообразной модели хеширования. Это семейство хеш-функций бесполезно, так как оно слишком велико: чтобы выбрать функцию, нужна таблица из | | записей. В этом случае требуются еще два свойства семейства универсальных хеш-функций: • они должны быть простыми и иметь удобную параметризацию, чтобы было легко выбрать случайную функцию из семейства; • их можно легко вычислить. Чтобы получить такое семейство, нужна бóльшая структуризация пространства . Классическая теория предполагает, что = {0, ..., p – 1} для некоторого простого числа p. Это разумно, если пространство представляет собой множество чисел. Затем выбирается некоторое достаточно большое простое число, чуть меньшее квадратного корня от наибольшего целого числа машины, так как эти числа придется перемножать, а они больше, чем все числа, встречающиеся в приложении50. Но важно то, что арифметические операции действительно можно выполнять без переполнения и обрезания по модулю 2wordsize; в противном случае семейства не могут быть универсальными и станут вести себя довольно плохо [421]. Предположим, что S = {0, ..., s – 1}, где s ≤ p. Простейшим универсальным семейством хеш-функций будет: ps = {ha : → S | ha(x) = (ax mod p) mod s, 1 ≤ a ≤ p – 1}. Это семейство состоит из p – 1 функций. Чтобы показать, что это универсальное семейство, нужно ограничить a, для которого ha(x) = ha(y) для любой фиксированной пары х, у различных элементов . Но если х ≠ у и (ax mod p) mod s = (ay mod p) mod s, то существует такое q ≠ 0, что –(p – 1) ≤ qs ≤ (p – 1) и ax mod p = ay mod p + qs. 50 Для 32-разрядной машины INT MAX = 2147483647, а p = 46337. Для 64-разрядной p = 3037000493. 364 Глава 9. Хеш-таблицы Для q существует не более 2(р – 1)/s возможных вариантов выбора. Для каждого q тождество ax ≡ ay + qs mod p имеет единственное решение а. Поэтому существует не более 2(р – 1)/s функций ha, для которых возникает конфликт между х и у. При равномерном случайном выборе из p – 1 функций вероятность конфликта не превысит 2/s, что соответствует определению универсального семейства. Описанное еще в работе [118] классическое универсальное семейство хеш-функций – двухпараметрическое: ps = {hab : → S | hab(x) = ((ax + b) mod p) mod s, 0 ≤ a, b ≤ p – 1}. Заметим, что это не просто циклическая перестановка предыдущих функций за b шагов: из равенства hab(x) = hab(y) совсем не следует, что ha(b+1)(x) = ha(b+1)(y). Как и предыдущий метод, он вполне хорош: при начальном случайном выборе достаточно выбрать два целых числа, а чтобы вычислить функцию, нужны лишь четыре арифметические операции. Чтобы понять, что это семейство универсально, нужно показать, что для каждой пары x, y из пространства , когда x ≠ y, доля c/s не превышает р2 возможных пар параметров a и b, вызывающих конфликты. Но если hab(x) = hab(y), то существует r ∈ {0, ..., s – 1} с hab(x) = r и hab(y) = r или ((ax + b) mod p) – r ≡ 0 mod s, ((ay + b) mod p) – r ≡ 0 mod s. Поэтому существуют целые числа qx, qy с условиями ((ax + b) mod p) – r = qx s, ((ay + b) mod p) – r = qy s. Но поскольку левая часть – это число между – (s – 1) и (p – 1), то qx и qy нужно искать в множестве {0, …, ⌊(р – 1)/s⌋}. И при любом выборе r, qx, qy существует единственная пара (a, b) в качестве решения системы линейных тождеств по модулю p ax + b ≡ r + qx s mod p, ay + b ≡ r + qy s mod p, которую можно считать системой линейных уравнений для a и b. Эта система невырожденная, потому что x ≠ y, а коэффициент b = 1. Таким образом, существует множество пар (a, b), приводящих к конфликтам, так как приходится выбирать из r, qx, qy с вероятностью s ⌈p/s⌉ ⌈р/s⌉. Это часть c/s всех пар (a, b) для c = ((⌈р/s⌉/(p/s))2, что очень близко к 1 для р, который намного больше s. Таким образом, семейство ps – это универсальное семейство хеш-функций, но с несколько лучшей константой c. 9.2. Универсальные семейства хеш-функций 365 Часто хотелось бы иметь пространство, которое представляет собой не простое множество чисел, а для некоторого фиксированного k может быть представлено кортежами из k чисел, например для позиций в игре на доске. Семейство хеш-функций легко распространяется и на этот случай: если = {0, ..., p – 1}k, то для некоторого простого числа p имеет место семейство функций ha1...akb (x1, ..., xk) = ((a1 x1 + ... + akxk + b) mod p) mod s. Доказательство полностью подобно частному случаю при k = 1: для (x1, ..., xk) и (y1, ..., yk) существуют r ∈ {0, ..., s – 1} и qx, qy ∈ {0, ..., ⌊р – 1/s⌋} с условиями (a1 x1 + ... + ak xk + b) mod p = r + qx s, (a1 y1 + ... + ak yk + b) mod p = r + qy s, а для заданных r, qx, qy система линейных уравнений a1 х1 + ··· + ak xk + b ≡ r + qx s mod p, a1 y1 + ··· + ak yk + b ≡ r + qy s mod p имеет рk−1 решений (a1, ..., ak, b ) ∈ {0, ..., p – 1}k +1. Для строк проблема состоит в том, что они не имеют постоянной длины. Их можно неявно расширить от 0 до некоторой наибольшей длины k. Это не изменит значение хеша, поэтому для коротких строк нет нужды вычислять хеш для их неявных расширений. Однако при наибольшей длине строки необходимо большее количество коэффициентов, но эти случайные коэффициенты нужно выбирать только тогда, когда они действительно необходимы. Еще одно легкореализуемое универсальное семейство хеш-функций с хорошей производительностью – это линейное отображение строк битов длины t в строки битов длины r в пространстве двоичных целых чисел Z2. В этом случае = {0, 1}t и S = {0, 1}r, что очень подходит к компьютерным приложениям. Чтобы определить линейное отображение, нужно отобразить каждое число длиной t бит в число длиной r бит. Чтобы отобразить заданное число х из t бит этого пространства, применяется операция сложения по модулю 2 или операция xor ко всем тем числам основного множества, которым соответствует бит 1 в числах х. Это просто следует из линейной алгебры, так что семейство всех линейных отображений – это действительно универсальное семейство хеш-функций. Такие семейства изучались в работах [399, 12], где было показано, что они в некотором роде близки к поведению равномерного хеширования и поэтому предпочтительнее для семейств ps или ps. Цена этому – рост данных семейств, так как требуется больше битов для их спецификации. Если предыдущим семействам нужно было только два числа размера log||, то этому семейству требуется log|| чисел размера log |S|. Но это может быть все-таки предпочтительнее из-за простых операций с битами, которые они используют. 366 Глава 9. Хеш-таблицы Ниже приводится код для универсальных хеш-функций семейства ps для пространства = {0, ..., MAXP – 1} для простого числа MAXP. #define MAXP 46337 /* простое число и 46337*46337 < 2147483647 */ typedef struct l_node { key_t key; object_t *obj; struct l_node *next; } list_node_t; typedef struct { int a; int b; int size; } hf_param_t; typedef struct { int size; list_node_t **table; int (*hash_function)(key_t, hf_param_t); hf_param_t hf_param; } hashtable_t; hashtable_t *create_hashtable(int size) { hashtable_t *tmp; int i; int a, b; int universalhashfunction (key_t, hf_param_t); if( size >= MAXP ) exit(-1); /* не должна вызываться с таким большим размером * / /* здесь можно запустить генерацию случайного числа */ tmp = (hashtable_t*)malloc(sizeof(hashtable_t)); tmp->size = size; tmp->table =(list_node_t**) malloc(size*sizeof(list_node_t*)); for( i = 0; i < size; i++ ) (tmp->table)[i] = NULL; tmp->hf_param.a = rand()% MAXP; tmp->hf_param.b = rand()% MAXP; tmp->hf_param.size = size; tmp->hashfunction = universalhashfunction; return(tmp); } int universalhashfunction(key key_t, hf_param_t hfp) { return (((hfp.a * key + hfp.b)% MAXP)% hfp.size); } Ниже приведена еще одна версия тех же функций для пространства строк. Здесь параметры универсальной функции представлены в виде списка коэффициентов, который растет с увеличением их длины. Тут также необходимо изменить функции find, insert и delete, чтобы сравнить всю строку для проверки, найден ли нужный ключ. 9.2. Универсальные семейства хеш-функций 367 #define MAXP 46337 /* простое число, и 46337 * 46337 < 2147483647 */ typedef struct l_node { char *key; object_t *obj; struct l_node *next; } list_node_t; typedef struct htp_l_node { int a; struct htp_l_node *next; } htp_l_node_t; typedef struct { int b; int size; struct htp_l_node *a_list; } hf_param_t; typedef struct { int size; list_node_t **table; int (*hash_function)(char*, hf_param_t); hf_param_t hf_param; } hashtable_t; hashtable_t *create_hashtable(int size) { hashtable_t *tmp; int i; int universalhashfunction (char *, hf_param_t); if( size >= MAXP ) exit(-1); / * размер превышает наибольший */ tmp = (hashtable_t *) malloc(sizeof(hashtable_t)); tmp->size = size; tmp->table = (list_node_t**) malloc(size*sizeof(list_node_t *)); for( i = 0; i < size; i++ ) (tmp->table)[i] = NULL; tmp->hf_param.b = rand()% MAXP; tmp->hf_param.size = size; tmp->hf_param.a_list = (htp_l_node_t *)get_node(); tmp->hf_param.a_list->next = NULL; tmp->hash_function = universalhashfunction; return(tmp); } int universalhashfunction(key char*, hf_param_t hfp) { int sum; htp_l_node_t *al; sum = hfp.b; 368 Глава 9. Хеш-таблицы al = hfp.a_list; while( *key != '\0' ) { if( al->next == NULL ) { al->next = (htp_l_node_t *)get_node(); al->next->next = NULL; al->a = rand()% MAXP; } sum += ((al->a)*((int)*key))% MAXP; key += 1; al = al->next; } return(sum % hfp.size); } object_t *find(hashtable_t *ht, char *query_key) { int i; list_node_t *tmp_node; char *tmp1, *tmp2; i = ht->hash_function(query_key, ht->hf_param); tmp_node = (ht->table)[i]; while( tmp_node! = NULL ) { tmp1 = tmp_node->key; tmp2 = query_key; while( *tmp1 != '\0' && *tmp2 != '\0' && *tmp1 == *tmp2 ) { tmp1++; tmp2++; } if( *tmp1 != *tmp2 ) /* строки не совпали */ tmp_node = tmp_node->next; else /* строки совпали */ break; } if( tmp_node == NULL ) return(NULL); /* ключ не найден */ else return(tmp_node->obj); /* ключ найден */ } void insert (hashtable_t *ht, char *new_key, object_t *new_obj) { int i; list_node_t* tmp_node; i = ht->hash_function(new_key, ht->hf_param); tmp_node = (ht->table)[i]; / * добавить в начало * / (ht->table)[i] = get_node(); ((ht->table)[i])->next = tmp_node; ((ht->table)[i])->key = new_key; ((ht->table)[i])->obj = new_obj; } object_t *delete(hashtable_t *ht, char *del_key) 9.2. Универсальные семейства хеш-функций 369 { int i; list_node_t *tmp_node; object_t *tmp_obj; char *tmp1, *tmp2; i = ht->hash_function(del_key, ht->hf_param); tmp_node = (ht->table)[i]; if( tmp_node == NULL ) return(NULL); /* список пуст, удаление невозможно */ /* проверить первый элемент в списке */ tmp1 = tmp_node->key; tmp2 = del_key; while( *tmp1 != '\0' && *tmp2 != '\0' && *tmp1 == *tmp2 ) { tmp1 ++; tmp2 ++; } if( *tmp1 == *tmp2 ) /* строки совпали: найдена нужная запись */ { tmp_obj = tmp_node->obj; /* удалить первую запись в списке */ (ht->table)[i] = tmp_node->next; return_node(tmp_node); return(tmp_obj); } /* список не пустой, удалить не первый в списке * / while( tmp_node->next != NULL ) { tmp1 = tmp_node->next->key; tmp2 = del_key; while( *tmp1! = '\0' && *tmp2! = '\0' && *tmp1 == *tmp2 ) { tmp1++; tmp2++; } if( *tmp1 != *tmp2 ) /* строки не совпали */ tmp_node = tmp_node->next; else /* строки совпали: запись найдена */ break; } if( tmp_node->next == NULL ) return (NULL); /* не найден, удалять нечего */ else { list_node_t * tmp_node2; /* узел разбиения */ tmp_node2 = tmp_node->next; tmp_node->next = tmp_node->next->next; tmp_obj = tmp_node2->obj; return_node (tmp_node2); return (tmp_obj); } } Подведем итог производительности структуры хеш-таблицы: Теорема. В хеш-таблице с цепочками и применением универсального семейства хеш-функций хранится множество из n элементов в таблице размера s, поддерживая операции find, insert и delete с ожидаемым временем O(1 + n/s) для каждой операции, но требуется дополнительное время O(n + s). 370 Глава 9. Хеш-таблицы Универсальные семейства хеш-функций – это очень полезные инструменты и в теории, и в практике. Другие семейства с более жесткими свойствами независимости исследовались в [497, 498, 397]. 9.3. Идеальные хеш-функции Хеш-функция идеальна, если она не приводит к конфликтам в соответствующем ей множестве. Можно считать это большим достоинством, но следует иметь в виду, что данное определение относится только к одному множеству, которое нужно знать заранее и сохранять его неизменным. Если даны множество X ∈ и хеш-таблица S = {0, ..., s – 1}, то можно определить функцию, отображающую на S и однозначно на X. Если |X| ≤ |S|, то такие функции всегда существуют: если линейно упорядочено, можно построить для X дерево поиска и хранить в каждом листе его адрес в S. Это, конечно же, совершенно бесполезно, поэтому есть важное дополнительное требование: функция должна вычисляться быстро и за постоянное время. Впервые это было предложено Кнутом [327] в качест­ ве упражнения для изобретательного ума конструктора функций, чтобы решить эту задачу вручную. Алгоритм задачи поиска идеальной хеш-функции для заданного множества X был впервые изучен в [508], где были приведены некоторые методы, всегда создающие идеальную хеш-функцию, но требующие очень большой таблицы S, поэтому поиск такой функции может занять очень много времени. Было предложено множество других методов построения хеш-функций, например [145]51 [293, 56, 121, 154, 481, 560, 125, 122, 160, 216, 162, 388, 159] (см. также обзор этих методов [161]). Все эти методы – это просто эвристики и непригодны для произвольных множеств X. Они успешны с высокой вероятностью, если множество Х выбирается равномерно случайным образом из . Методы, дающие идеальную хеш-функцию, но практически не реализуемые, были приведены в [533, 561]. А вот метод [227] – это действительно окончательное решение: он всегда работает, элегантен и достаточно прост, чтобы применяться на практике. Единственный общий недостаток идеальных хеш-функций в том, что они не поддерживают изменений в основном множестве. Предложенная в [227] идея заключается в использовании двухуровневой схемы распределения множества X по таблице размера |X| с использованием функции из универсального семейства хеш-функций. Это позволяет разбить множество X на классы X = X1 ∪ ··· ∪ Xk с одним и тем же значением хеш-функции. Все элементы каждого класса Xi конфликтуют, но универсальное семейство хеш-функций ограничивает ожидаемое общее коли­ чество конфликтующих пар. 51 Метод, который, несмотря на то что он вообще не работает и проверяет лишь первую и последнюю буквы строки, а также ее длину, по-прежнему рекомендуется различными «практикующими» авторами. 9.3. Идеальные хеш-функции 371 k E �общее количество конфликтов пар� = E ���|Xi|�� i=1 2 1 c ≤ c �|X |� ������ < � |X|. 2 2 table size Теперь для каждого множества Xi снова выбирается универсальная хеш-функция для распределения Xi в таблице размера |Xi|2. В предыдущем разделе было показано, что ожидаемый наибольший размер группы при распределении n элементов в хеш-таблице размера s равен ��� c O �1 + ��n2� � s �, поэтому для каждой из этих хеш-таблиц второго уровня ожидается наибольший размер группы O(1). Таким образом, этот метод предоставляет доступ к нужному элементу за время O(1): сначала вычисляется первая хеш-функция, затем в первой хеш-таблице выбирается и вычисляется вторая хеш-функция, после чего достаточно просмотреть не более O(1) кандидатов, а общий размер такой структуры не превысит O(|X|), так как k ��|X2i|� = O(|X|). i=1 Выбирая вторичные хеш-таблицы чуть большего размера и с постоянным множителем, зависящим от универсального семейства хеш-функций, можно даже добиться отсутствия конфликтов во вторичных хеш-таблицах. В работе [226] эта идея использовалась с первым универсальным семейством хеш-функции ha(x) = ((ax) mod p) mod s, которая была приведена в предыдущем разделе. За счет некоторых ухищрений удалось снизить размер структуры для множества из n чисел с O(n) до n + o(n), а время поиска элемента – до O(1). Здесь предполагается, что арифметические операции, а также операция поиска в таблице имеют постоянное время. Эта задача изучалась также и в других моделях вычислений, но они не нашли практической реализации. На самом деле практическая важность идеальных хеш-функций актуальна, несмотря на множество довольно сомнительных статей по этой теме. Если нужно хранить неизменяемое множество целых чисел, то метод из [226] без оптимизации памяти действительно легко реализуется и очень эффективен. Однако не так много случаев, когда нужно хранить неизменяемое множество целых чисел. Наибольшее количество приложений относятся к неизменяемым множествам строк. Большинство старых источников касаются ключевых слов языков программирования. Но и для строк получается та же производительность с поддержкой операций добавления и удаления. 372 Глава 9. Хеш-таблицы Ниже приводится реализация метода из работы [226] без оптимизации размера памяти. Хеш-функции выбираются случайным образом, и затем проверяется, удовлетворяют ли они требуемым условиям: для первичных хеш-функций это сумма квадратов их размеров, а для вторичных – их инъективность52. Выбор функции должен продолжаться, пока не выполнятся эти условия, что потребует O(1) попыток. При первичном хешировании элементы не разносятся по отдельным группам структуры, потому что для этого требуется дополнительная память. Взамен для всех групп выбирается вторичная хеш-функция, и уже с ее помощью начинается распределение элементов по группам. Если вторичная хеш-функция группы вызывает конфликт, то эта группа помечается как ошибочная. Если же были еще какие-то ошибочные группы, для каждой из них выбирается другая вторичная хеш-функция. В этом случае нужно очистить все ошибочные группы и заново разнести элементы посредством той же хеш-функции. После O(1) повторов такой операции ошибочных групп не останется. Такой метод имеет накладные расходы: при каждом повторе происходит перераспределение всех элементов, даже тех, в чьих группах уже нет конфликтов. Однако при этом нет нужды в промежуточных структурах для хранения самих групп, а время на накладные расходы становится всего лишь постоянным множителем. Для проверки на конфликты требуется значение, отличное от всех ключей, встречающихся в данных. Предполагаемое значение MAXP должно превышать наибольший ключ во всем пространстве. #define MAXP 46337 /* prime и 46337*46337 < 2147483647 */ typedef struct { int size; int primary_a; int *second_a; int *second_s; int *second_o; int *key; object_t *OBJS; } perf_hash_t; perf_hash_t *create_perf_hash(int size, int keys[], object_t objs[]) { perf_hash_t *tmp; int *table1, *table2, *table3, *table4; int i, j, k, collision, sq_bucket_sum, sq_sum_limit, a; object_t *objects; tmp = (perf_hash_t*)malloc(sizeof(perf_hash_t)); table1 = (int*)malloc(size * sizeof(int)); table2 = (int*)malloc(size * sizeof(int)); table3 = (int*)malloc(size * sizeof(int)); sq_sum_limit = 5 * size; sq_bucket_sum = 100 * size; 52 Однозначное отображение одного множества на другое, при котором некоторые элементы отображенного множества могут не иметь прообраза в отображаемом. – Прим. перев. 9.3. Идеальные хеш-функции 373 while( sq_bucket_sum > sq_sum_limit ) /* найти первичный хеш-множитель */ { a = rand()% MAXP; for( i = 0; i < size; i++ ) table1[i] = 0; for( i = 0; i < size; i++ ) table1[(((a * keys [i])% MAXP)% size)] += 1; sq_bucket_sum = 0; for( i = 0; i < size; i++ ) sq_bucket_sum += table1[i] * table1[i]; } /* вычислить размеры вторичных таблиц и их смещение */ for( i = 0; i < size; i++ ) { table1[i] = 2 * table1[i] * table1 [i]; table2[i] = (i > 0)? table2[i-1] + table1[i-1]: 0; } table4 = (int*) malloc(2 * sq_bucket_sum * sizeof(int)); for( i = 0; i < 2 * sq_bucket_sum; i++ ) table4[i] = MAXP; /* отличный от всех ключ */ collision = 1; for (i = 0; i<size; i++) table3[i] = rand()% MAXP; /* вторичный хеш-множитель */ while( collision ) { collision = 0; for( i = 0; i < size; i++ ) { j = ((keys [i] * a)% MAXP)% size; k = (keys [i] * table3 [j])% MAXP) % table1[j] + table2[j]; if( table4[k] == MAXP || table4[k] == keys[i] ) table4[k] = keys[i]; /* вход вверх, сейчас пусто */ else /* конфликт */ { collision = 1; table3[i] = 0; /* пометить группу как ошибочную */ } } if( collision ) { for( i = 0; i < size; i++ ) { if( table3[i] == 0 ) /* ошибочная группа */ { table3[i] = rand()% MAXP; /* выбрать новый множитель */ for( k = table2[i]; k < table2[i]+table1[i]; k++ ) table4[k] = MAXP; /* очистить i-ю вторичную таблицу */ } 374 Глава 9. Хеш-таблицы } } } /* теперь хеш-таблица не имеет коллизий */ /* ключи - в нужных местах, объекты вносятся туда */ objects = (object_t*) malloc(2 * sq_bucket_sum * sizeof(object_t)); for( i = 0; i < size; i++ ) { j = ((keys[i] * a)% MAXP)% size; k = ((keys[i] * table3[j])% MAXP)% table1[j] + table2[j]; objects[k] = objs[i]; } tmp->size = size; tmp->primary_a = a; /* множитель первичной хеш-таблицы */ tmp->second_a = table3; /* множители вторичной хеш-таблицы */ tmp->second_s = table1; /* размеры дополнительных хеш-таблиц */ tmp->primary_o = table2; /* смещения вторичных хеш-таблиц */ tmp->keys = table4; /* вторичные хеш-таблицы */ tmp->objs = objects; return(tmp); } object_t *find(perf_hash_t *ht, int query_key) { int i, j; i = ((ht->primary_a * query_key) % MAXP) % ht->size; if( ht->secondary_s[i] == 0 ) return(NULL); /* вторичная группа пуста */ else { j = ((ht->secondary_a[i] * query_key) % MAXP) % ht->second_с[i] + ht->second_о[i]; if( ht->keys[j] == query_key ) return((ht->objs) + j); /* найден правый ключ */ else return(NULL); /* query_key не существует */ } } Подведем итог работы этой структуры. Теорема. Идеальная структура хеш-таблицы [226] – это статический словарь на множестве из n элементов с целочисленными ключами. 9.4. Хеш-деревья 375 Для ее создания требуется время O(n) и память O(n), она поддерживает операции find за время O(1). Идеальные хеш-функции изучались и в других моделях вычислений, например в [408, 226, 486, 387, 173, 267], особенно при подсчете количества битов в программе для семейства функций, содержащего идеальную хешфункцию пространства из n элементов. 9.4. Хеш-деревья До сих пор всегда предполагалось, что хеш-функция отображает пространство в множество целых чисел S = {0, ..., s – 1}, используемых затем в качестве адресов внутри массива. Другая модель была предложена в [147], где хеш-функции отображают пространство в потенциально бесконечную строку битов, из которой можно выделить любое количество битов, чтобы выделить любой элемент из множества других элементов. Позже было предложено разбить строку битов на части из k бит и каждую из них считать ключом в структуре дерева над алфавитом {0, 1}k. После этого объект сохраняется в дереве именно с этим ключом. Такая структура не требует никаких массивов, но имеет в качестве основы три узла постоянного размера. Эту потенциально бесконечную строку можно считать другим методом устранения конфликтов: если два элемента u1, u2 ∈ конфликтуют в текущей структуре, то выбирается более длинный префикс их ключа до тех пор, пока они не перестанут конфликтовать. Однако если ключевая строка непостоянна, это может привести к некоторым изменениям в структуре дерева в зависимости от того, как разрешаются конфликты. В [147] предложено два способа. Если новый элемент – и2, а его место занимает u1 с тем же значением хеш-ключа вплоть до места расположения строки u2 в текущей структуре, то: • либо берется более длинный префикс хеш-ключа для строки u2, а u1 остается в том же самом узле. Этот метод был назван последовательными деревьями. Он проще при добавлениях, но чтобы найти объект с хеш-ключом b0b1b2..., нужно просмотреть узлы с ключами b0, b0b1, b0b1b2, …, каждый из которых содержит объект, и для каждого из них нужно проверить, соответствует ли его ключ в искомому ключу; • либо берутся более длинные префиксы хеш-ключей строк u1 и u2, причем настолько длинные, чтобы различить u1 и u2 и сохранить u1 и u2 в этих же узлах. Этот метод был назван префиксными деревьями. Он сохраняет объекты только в листьях дерева. При добавлении, возможно, придется перенести оба конфликтующих элемента в новый узел, но при поиске нужно будет сравнить исходный ключ из только с одним ключом узла. 376 Глава 9. Хеш-таблицы Если сравнение исходных ключей из затратно, скажем, из-за того, что длинные строки хешировались в короткие, второй метод явно предпочтительнее. Но он имеет недостаток: если есть хеш-ключи с длинным общим префиксом, то может быть много узлов с одним исходящим ребром. Таким образом, вместо хеш-ключей сравниваются ключи в самом исходном пространстве . Поскольку каждый проверяемый узел имеет не более 2k исходящих ветвей и не более n узлов, то существует лист на расстоянии не менее log2k(n) = 1/k log n от корня. Таким образом, даже для самой лучшей хеш-функциии нельзя добиться сложности лучше, чем O((1/k) log n). Для малых k такой производительности можно было бы достичь на выровненном дереве поиска, когда для сравнения ключей в требуется постоянное время. Для больших k это невозможно, потому что размер одного узла составляет O(2k), но при условии, что хеш-функция идеальна, можно достичь нужного листа дерева быстрее, если при k = log n ищется корневой узел, который является обычной хеш-таблицей. Таким образом, хеш-деревья, занимающие положение между хеш-таблицами и выровненными деревьями поиска, представляют интерес, если сравнение искомых ключей с исходными затратно. Как и в структурах хеш-таблиц, можно надеяться, что их отображение посредством хеш-функций улучшает распределение множества. Если хеш-функция плоха из-за того, что существуют хеш-ключи с длинными общими префиксами, дерево последовательностей может превращаться в неупорядоченный список, а префиксы деревьев могут тоже стать довольно плохими. В работе [147], как и во всех работах того периода, считалось, что значения хеш-функции независимы и равномерно распределены. В этом случае бесконечную строку битов можно считать вещественным числом в интервале [0, 1[. Наибольшая ожидаемая длина пути при попытке найти строку битов по искомому ключу с количеством узлов порядка O(2k) должна быть O((1/k)log n). 9.5. Расширяемое хеширование Классическая хеш-таблица имеет фиксированный наибольший размер. При разрешении конфликтов при прямой адресации это действительно жесткое ограничение, производительность резко снижается при приближении к наибольшему размеру и нужно стараться не приближаться к нему. Для рекомендованных ранее методов цепочек ситуация не так плоха, и их можно использовать с превышением номинальной памяти, но при этом утратить ожидаемое постоянное время операций. Итак, чтобы сделать хеш-таблицы действительно динамическими, не следует ограничивать их наибольший размер ради сохранения постоянного времени выполнения операций find, insert и delete. Как описано в разделе 1.5, этого можно легко достичь с помощью стандартного метода построения теневой копии хеш-таблицы в большом массиве, удваивая ее размер и копируя на каждом шаге некоторые элементы 9.5. Расширяемое хеширование 377 хеш-таблицы из меньшей в бóльшую так, чтобы меньшая не переполнялась. Это приводит к появлению дополнительного постоянного множителя, учитывающего издержки при каждой операции, однако все операции при использовании универсального семейства хеш-функций сохраняют ожидаемое постоянное время. Теперь этот подход, предложенный довольно давно, считается очевидным, но его детальное объяснение появилось в литературе гораздо позже [83]. Полная перестройка хеш-таблицы по достижении ею предела памяти применялась уже давно [54], но она снижала производительность в худшем случае и прерывала весь процесс ради построения новой хеш-таблицы. Такой подход намного менее требователен к памяти, потому что две таблицы сосуществуют только на этапе перестройки, тогда как при перестройке таблицы одновременно с ее использованием постоянно блокируется добавочная память, превышающая размер самой хеш-таблицы. Посвященные расширяемым хеш-таблицам ранние работы были сосредоточены на другом типе структур, которые обычно рассматривались как структуры внешней памяти и часто сравнивались с B-деревьями, где было заметно влияние ограниченности памяти. Классические структуры расширяемого хеширования были впервые предложены в работе [353] как «динамическое хеширование», в работе [369] как «виртуальное хеширование», в работе [370] как «линейное хеширование» и в работе [200] как «расширяемое хеширование». С тех пор было предложено много похожих методов (см. обзор [194]). В их основе лежит идея разбиения групп в случае их переполнения, но с контролем за ними. Все эти методы предполагают, что хеш-функция действительно выдает строку битов любой длины, как в модели хеш-дерева; так что если хеш-таблица становится большой, они просто берут еще больше битов хеш-функции. Их анализ был основан на предположении о равномерности и независимости хеширования. Но все они не гарантируют желаемой производительности для худшего случая53. Все эти структуры – двухуровневые: в первичной структуре значение хеш-функции переводится в номер группы, а во вторичной структуре – номер элемента каждой группы. Сама группа имеет наибольшую конечную вместимость B и часто связывается со страницей внешней памяти; но в основной памяти она может быть реализована многими способами, например в виде массива, связного списка или другой хеш-таблицы. Во многих работах основное внимание уделяется в основном объему памяти, который выражается количеством блоков, используемых для хранения n элементов. Предполагается, что первичная структура невелика и размещается в основной памяти, тогда как сами группы находятся во внешней памяти. Первый из этих методов «динамического хеширования» [353] относится к деревьям с хеш-префиксом, описанным в предыдущем разделе. Разница 53 Несмотря на часто повторяющиеся утверждения вроде «...гарантируется не более двух страниц ошибок при поиске данных...» [200], подобные утверждения для многих других структур повторяются во многих известных учебниках. Эти структуры считаются структурами внешней памяти, учитывая только обращения к внешнему блоку, но, в отличие от истинных структур внешней памяти, они не поддерживают постоянный размер внутренней памяти. 378 Глава 9. Хеш-таблицы лишь в том, что каждый лист может содержать несколько элементов. Так что первичная структура – это двоичный файл, где группы привязаны к листьям. Чтобы найти элемент, значение хеша становится просто строкой битов, но для поиска листа дерева берется только ее начальный довольно длинный префикс. А для поиска нужного элемента в связанной с листом группе используется остаток строки. Для добавления нового элемента нужно пройти по тому же пути и попытаться добавить его в связанную с листом группу. Если группа переполнена, лист разбивается и берется следующий бит значения хеша, а содержимое группы предыдущего листа распределяется по двум группам, привязываемым к новым листьям. Согласно предположению о равномерном хешировании, такое дерево будет выровненным с высотой O(log (n/B)), а количество групп для хранения n элементов будет порядка O(n/В) вместо 1,44 n/В, что позволит выиграть 70 % в объеме памяти. Некоторые дальнейшие уточнения рассмотрены в работе [353]. Если хеширование неравномерно, то такая структура легко становится довольно плохой из-за частого разбиения одной и той же группы. Метод «виртуального хеширования» [369] делает первичную структуру неявной и использует вместо префиксных хеш-деревьев последовательные, проходя при поиске через несколько групп. Если значение хеша равно b1b2b3..., то метод сначала ищет в группе b1, затем в группе b1b2, потом в группе b1b2b3 и т. д. При добавлении нужно придерживаться той же последовательности групп до тех пор, пока не будет найдена та группа, где есть мес­то для нового элемента. При этом необходимо отслеживать наибольшую длину префиксов групп и наращивать их длину по мере необходимости. Простой метод перевода строк битов с растущей длиной в массиве целочисленных адресов – отобразить b1b2...bk в 2k – 1 + |b1b2...bk|, где |...| – число в виде строки битов. Опять же, если значения хеша распределены равномерно, то этот метод хорошо работает при хранении O(n/В) групп из п элементов. Для поиска элемента нужно проверить O(log(n/В)) групп вместо одной из предыдущего метода. Преимущество здесь в том, что при переполнении группы не нужно перераспределять ее элементы, а просто перейти в следующую группу с новыми элементами и с тем же префиксом. Структура может стать довольно плохой, если значения хеша распределены неравномерно. Есть еще одна проблема: неявная первичная структура предполагает, что можно выделить произвольное количество последовательных групп. Иначе первичная структура все же нужна для преобразования номера группы в адрес, где она действительно хранится. В [369] считается, что это просто массив, но на самом деле, как описано в разделе 1.5, массив должен быть расширяемым со всеми вытекающими из этого проблемами. Если же массив имеет фиксированный размер, придется вернуться к первоначальной проблеме. Методы «линейного хеширования» [370], «расширяемого хеширования» [200] и «спирального хранилища» [401]54 также предполагают наличие 54 Из технического отчета: Martin G. N. Spiral Storage: Incrementally Augmentable Hash Addressed Storage, Technical Report 27, University of Warwick, USA, 1978; впервые опубликован в статье [419]. 9.5. Расширяемое хеширование 379 расширяемого массива в качестве первичной структуры и использование префикса значения хеша в качестве индекса этого массива, который затем выдает адрес группы, содержащей элемент. Если группы переполнены, размер массива увеличивается вдвое с более длинным префиксом значения хеша. Линейное и расширяемое хеширования отличаются политикой разбиения групп. При линейном хешировании группа просто разбивается в определенном циклическом порядке, причем разбивается не переполненная группа, а следующая. Затем проблема переполнения решается путем присоединения переполненной группы к неполной. Когда же эта группа тоже потребует разбиения, все ее элементы и все связанные с ней переполненные группы перераспределяются по следующему биту значения хеша. Если значения хеша считаются независимыми и равномерно распределенными, этой простой политики вполне достаточно, чтобы ожидаемое количество переполненных групп было небольшим. Однако многие группы могут разбиваться, хотя этого не требуется. Спиральное хранилище тоже разбивает группы циклически, но отличается схемой нумерации групп. В линейном хешировании одна часть разделенной группы сохраняет старый номер, а другая получает следующий номер, больший существующих на этот момент номеров групп, поэтому массив увеличивается только в конце, но всегда начинается с индекса 0. В спиральном хранилище обе части разделенной группы получают новые номера, а старая запись удаляется. Было предложено множество вариантов таких методов [371, 417, 489, 525, 473, 526, 357, 419, 358, 446, 445, 143, 44]. Расширяемое хеширование отличается от предыдущих методов тем, что оно разбивает только переполненные группы и допускает в первичной структуре несколько ссылок на одну и ту же группу. Когда группа переполняется, сначала проверяется, можно ли ее разбить в первичной структуре, потому что элементы, относящиеся к нескольким различным объектам, хранятся в одной группе. В таком случае просто создается новая группа и ее элементы разделяются. В противном случае увеличивается глубина первичной группы. Для этого массив удваивается с копированием всех предыдущих элементов в две последовательности массива, после чего каждая группа ссылается как минимум на два элемента массива, соответствующих двум префиксам значения хеша и различающихся только последним его битом. После этого можно разбить переполненную группу и разнести ее элементы по двум группам в соответствии с последним битом. Проверить, есть ли несколько элементов массива, ссылающихся на одну и ту же группу, легко, так как у них одинаковый хеш-префикс, а их положение в массиве последовательно. Это свойство должно сохраняться при разбиении группы. Элементы старой и новой групп должны оставаться последовательными в массиве. Другие варианты метода расширяемого хеширования были предложены в [374, 144]. Многомерный аналог расширяемого хеширования – это «решетчатый файл» («grid file») [427, 276, 476]. В нем элементы данных имеют в качестве 380 Глава 9. Хеш-таблицы ключа не одну последовательность битов, а d последовательностей. В каж­ дой из этих последовательностей выбирается префикс, который считается числом и номером группы в в d-мерном массиве, содержащем этот элемент данных. Здесь опять может появиться потенциально большое количество позиций массива, ссылающихся на одну и ту же группу, но они образуют d-мерный интервал между позициями индекса, поэтому можно пока разбить переполненную группу на несколько ссылающихся на нее позиций массива. Многомерный индекс структуры для точек похож на другие рассмотренные в этом разделе структуры и тоже может иметь множество вариантов. Совершенно иной тип структур, объединяющих хеш-таблицы с переменным размером, – это идеальное динамическое хеширование [175, 174]. Динамизация идеального хеширования [227] достигается за счет периодической перестройки частей двухуровневой структуры. Такая вероятностная структура поддерживает операцию поиска с постоянным временем в худшем случае, а операции добавления и удаления – с постоянным ожидаемым средним временем. 9.6. Проверка принадлежности ключей и фильтры Блума Рассмотренные в предыдущих главах словарные структуры деревья поиска, trie-структуры и хеш-таблицы определяли множество ключей для любого искомого объекта. Проверка принадлежности ключей слабее, поскольку она просто проверяет вхождение искомого ключа в это множество. Такая более слабая структура интересна рядом приложений, касающихся внешней памяти и сетей. Если выполняется поиск некоторых данных, находящихся в любом из множеств, то можно избежать просмотра каждой группы, если есть возможность проверки ее содержимого. Если области памяти – это страницы внешней памяти или другие компьютеры в распределенной системе, то в основной памяти можно выполнять только проверку принадлежности к группе, но не ее содержимое. Таким образом, проверка принадлежности ключей в основном интересна лишь тогда, когда их размер невелик по сравнению с размером всего множества, то есть с классической структурой словаря. При проверке принадлежности ключей можно ослабить требования и позволить ложные срабатывания, если неправильно принятых искомых ключей немного. Это значительно уменьшает размер структуры без существенного снижения ее полезности. Но в худшем случае придется многократно просматривать группу. Еще одно приложение, ставшее основой мотивации первоначального исследования, – это проверка правописания, когда нужно определить правильность написания слова, но допускать при этом несколько неправильных вариантов написания. То же самое было предложено для некоторых приложений, относящихся к текстовым 9.6. Проверка принадлежности ключей и фильтры Блума 381 индексам [421, 472, 495]. Такая структура была предложена в работе [74], а приблизительная проверка ключа была названа фильтром Блума. Точная и приблизительная принадлежности ключа были позже изучены в [117], а еще позже стали объектом многих исследований, связанных в основном с сетевыми приложениями [368, 95]. В классических словарях, в отличие от точной, применяется только приблизительная проверка ключа, когда связанный с ключом объект можно уточнить лишь целенаправленно [132], но приблизительных указателей не существует. Для построения и анализа проверки принадлежности ключей необходимо сделать дополнительные предположения о природе пространства , из которого выбираются множества, и о модели вычислений. Во всех рассмот­ ренных здесь структурах пространство – это конечное множество с u элементами, что делает их похожими на хеш-таблицы, в отличие от деревьев поиска, где считалось, что два элемента пространства можно сравнить только за постоянное время. Если нужно представить все 2u возможных подмножеств пространства, нет ничего лучшего, чем двоичная матрица, каждый из u бит которой представляет собой один элемент пространства, который входит или не входит в текущее множество. Для кодирования 2u возможных подмножеств требуется не менее u = log(2u) бит. Если же ограничиться подмножествами фиксированного размера п из u возможных элементов пространства, то нижней их границей будет log �un�, что близко к n log u для n, гораздо меньших, чем u. В работе [117] было предложено еще несколько точных проверок принадлежности ключей почти с теми же характеристиками, но достигаемое время поиска зависело от модели вычислений, некоторые из которых обеспечивали и небольшую память, и постоянное время поиска [96]. При приблизительной проверке ключей ситуация усложняется. Предложенный Блумом оригинальный метод состоит в том, что есть строка битов длины b и k хеш-функций hi : → {1, ..., b}. Для каждого принадлежащего множеству X элемента х структура устанавливает биты h1(x), ..., hk(x) в 1. Один и тот же бит может быть установлен в 1 для многих различных элементов множества. Чтобы выяснить, находится ли y ∈ в множестве X, вычисляются функции h1(y), ..., hk(y) и проверяется, все ли биты равны 1. Если это так, то у принадлежит множеству X, иначе не принадлежит. Такой подход допускает ложные срабатывания, но не ложные отрицания, что было изучено в работах [74, 117, 418] в предположении о равномерном хешировании. Если hi(x) независимы, равномерно распределены, а также используют b = (log2 e)kn бит и k хеш-функций для представления множества из n элементов, то верхняя граница ошибки будет равна 2–k независимо от размера самого пространства. Другой способ, требующий меньше предположений о независимости значений хеша, – это отображение большого постранства посредством выбранной из универсального семейства хеш-функции h в уменьшенное пространство с применением к нему точного вхождения ключа, что также было предложено в [74]. Однако обычная хеш-таблица там использовалась 382 Глава 9. Хеш-таблицы только для небольшого пространства. После этого в [117] это предложение было объединено с точной проверкой вхождения ключа. Для заданного искомого элемента у можно утверждать, что он принадлежит множеству X, если h(y) принадлежит h(X), в противном случае он не принадлежит этому множеству. Сомнительными будут лишь те результаты, когда x ∈ X и x ≠ y, но h(x) = h(y). Используя уменьшенное пространство размера n2k, вероятность конфликта при использовании универсальной хеш-функции с сомнительным результатом равна c2–k. Необходимая для такой структуры память – это память для точной проверки принадлежности ключей множества из п элементов в пространстве размера п2k. И здесь тоже требования к памяти не зависят от размера исходного пространства , а зависят только от п и частоты ошибок. Другие структуры с проверкой принадлежности ключей почти оптимального размера и временем поиска были разработаны в [96, 103, 455]; похожие структуры были также предложены в [322, 412]. Версии, допускающие отрицательные ответы при поиске, рассматривались в работе [456], а версия приблизительного поиска мультимножеств разбиралась в [148]. Все они – просто статические структуры, не допускающие добавлений или удалений для основного множества. Простейший способ сделать структуру динамической – это заменить биты в первой вышестоящей структуре счетчиками, увеличивая каждый счетчик при добавлении и уменьшая его при удалении при том условии, что при поиске все счетчики должны быть положительными. Это называется счетчиком фильтра Блума [202, 103], но такая структура не является вполне динамической, как хеш-таблицы с большим размером, потому что количество счетчиков, как и их размер, не меняется при добавлениях. Здесь были бы уместны такие методы, как теневые копии, расширяемые массивы или расширяемые хеш-таблицы, которые обсуждались в предыдущем разделе. Но основная причина очевидного практического интереса к приблизительной проверке принадлежности ключей – это ее малый размер. Глава 10 Приложение Ниже разъясняются некоторые понятия, полезные методики, а также тематические предпочтения и ограничения этой книги. 10.1. Ссылочная машина и другие модели вычислений В этой книге рассматривались только те структуры данных, которые вписывались в модель ссылочной машины (pointer-machine), или указателя-машины, и исключались структуры, требующие более сложных моделей вычислений. В ссылочной машине55 память состоит из узлов размера O(1), каждый из которых содержит некоторые значения и ссылки на другие узлы. Все, что можно делать с этими ссылками, – переходить по ним и создавать ссылку на уже созданный или новый узел. Все операции со ссылками, включая создание и удаление узлов, требуют постоянного времени. Почти все структуры вписывались в эту модель, за исключением хештаб­лиц, которые рассматривались ввиду их большого практического значения. В них ссылка вычисляется, исходя из неких входных данных, а в ссылочной машине, увы, нет «арифметической ссылки». Другое исключение – это частое использование массивов для стеков и куч, которые тоже требуют вычисления «арифметических ссылок» для объектов памяти непостоянного размера. Такие структуры использовались только из-за их эффективности, хотя можно было бы обойтись без них: стек в виде связного списка – простой пример структуры, вписывающейся в модель ссылочной машины. Кроме того, приводилось много примеров куч, вписывающихся в эту же модель. Структура кучи Фибоначчи не вписывается в модель ссылочной машины, но была рассмотрена здесь как простая куча с довольно быстрым средним поиском ключа. Другая важная и более мощная модель вычислений – это RAM (Random Access Machine) со словами длиной Θ(log n). В модели ссылочной машины 55 В литературе существует множество различных ссылочных машин. В работе [57] была предпринята попытка систематизировать их. Согласно этой классификации, мы используем «алгоритмы, допускающие ссылки (указатели)». 384 Глава 10. Приложение не нужно заботиться о размере значений полей узлов, поэтому в этой книге никогда не было речи о переполнении числового диапазона. Но если бы наша модель машины допускала операции с адресами, то потребовалось бы уточнить понятие адреса, тогда как модель ссылочной машины абстрагируется от таких деталей. Если же представлять память компьютера как большой массив слов фиксированного размера из w разрядов с укладывающимися в них адресами (указателями), то для обращения к n объектам потребовался бы размер слова w ≥ Ω(log n). Если предположить, что программа работает ровно с n объектами, что является существенным ограничением для любой разумной программы, то для адресации всех ячеек памяти будет достаточно O(log n) используемых программой разрядов памяти. По этой причине в модели RAM, где память делится на слова, длину слова обычно можно оценить как Θ(log n). Зависимость длины машинного слова от количества объектов кажется странной и неестественной. Но если длина слова постоянна, то для перехода по ссылке после ее считывания потребуется время Ω(log n). И если входные данные – это множество из n элементов, каждый из которых помещается в одно слово постоянного размера w, то каждый элемент требует только 2w = O(1) возможных значений, но такое множество может состоять лишь из одинаковых элементов. Это обстоятельство очень влияет на решаемые задачи, что делает, например, сортировку подсчетом наилучшим алгоритмом сортировки. Прямая адресация и манипуляции с адресами позволяют выполнять некоторые операции гораздо быстрее, чем в ссылочной машине. Но поскольку их асимптотическая сложность не гарантирует быстроты реализации, структуры с использованием прямых адресов не рассматривались в этой книге. Еще один вопрос при выборе модели: какие числа поддерживает машина? В памяти, разделенной на слова, естественно использовать только целые числа, умещающиеся в слове. Однако (особенно в геометрических структурах) удобно считать действительные числа элементарными объектами, над которыми можно выполнять любые арифметические операции с постоянным временем. Такой подход особенно хорош для ссылочных машин: числовые поля узла, как и ссылки, – это просто элементарные объекты, с которыми возможны некоторые операции с постоянным временем. А в модели RAM все зависит от того, различаются ли представления целого и действительного чисел. В этом случае мы имеем дело с обычной оперативной памятью. Или же мы допускаем в нашей модели такие, например, операции, как округление, для преобразования действительных чисел в целые, что порождает серьезные проблемы при оценке сложности некоторых алгоритмов. Кроме того, возникает еще один вопрос: уложится ли целое число в слово и не воспримет ли его машина как адрес памяти? Вычислительная модель чрезвычайно важна для оценки нижних границ сложности вычислений, очень зависящих от нее. В этой книге уделяется внимание только лишь алгоритмам без оценок нижних границ их сложности. 10.2. Модели внешней памяти и алгоритмы без учета кеш-памяти 385 В принципе, можно сказать, что наша модель вычислений состоит из набора правильных программ на языке C. Языки программирования обычно не накладывают ограничений на длину слова при использовании в программе целых чисел и ссылок (указателей), хотя и предоставляют доступ к двоичным разрядам любого слова. Но поскольку такие ограничения в компьютере, увы, существуют, асимптотические оценки времени выполнения алгоритмов при n → ∞ – всего лишь теоретические. 10.2. Модели внешней памяти и алгоритмы без учета кеш-памяти При обсуждении B-деревьев и (a, b)-деревьев поиска описывалась модель внешней памяти с обращениями к блокам большого постоянного размера. Та же проблема существует и для других структур данных: сколько блоков размера B нужно извлечь для решения поставленной задачи. Здесь нужен иной подход к оценке сложности: вместо подсчета количества операций нужен подсчет количества передач блоков и их минимизация. Эти вопросы обсуждались во многих статьях и для многих различных структур данных. Наиболее полный обзор приводится в [544]. В первую очередь он касается баз данных, которые обычно не умещаются целиком в основную память. Во многих случаях для них подходит один из вариантов B-дерева, а для других, в основном геометрических, требуются более сложные структуры. В этой книге мы старались избегать структур, связанных с внешней памятью, за исключением (a, b)-деревьев, которые подходят и для основной памяти. Любую из структур можно было рассматривать и в таком контексте, и многие из них так и рассматривались. С совершенствованием компьютеров совершенствуются и решаемые с их помощью задачи. Для решения задач пятилетней давности теперь уже достаточно основной памяти, но появляются и новые задачи, многие из которых также решаются в основной памяти. Простая модель основной памяти теперь подходит для почти всех моделей, поэтому именно ей мы отдаем предпочтение. Таким образом, мы полагаем, что модели внешней памяти (скажем, лента с последовательным доступом, считавшаяся 30 лет назад основной) и специальные алгоритмы для них скоро сойдут на нет. В настоящее время лучше всего изучена внешняя память без учета кеш-памяти [233]; она подобна внешним структурам, но пренебрегает размером блока. Суть ее в том, что основная память современного компьютера не так однородна, как принято в нашей стандартной модели: между процессором и основной памятью существует несколько уровней памяти, которые работают быстрее кеш-памяти. Каждая из них представляет собой некий блок с довольно быстрым доступом к нему, тогда как любая осечка при поиске в кеш-памяти может привести к другому, более медленному уровню, из которого будет считан и сохранен не только нужный адрес, 386 Глава 10. Приложение но и весь (небольшой) блок или «кеш-строка». Поведение кеш-структуры должно быть правильным при передаче блоков любого размера: это важно для любой задачи, касающейся структур данных. Только в этом случае есть возможность с разных сторон оценить и сложность вычислений (которая должна быть почти оптимальной), и количество передаваемых блоков произвольного размера. Подобно B-деревьям, для внешней памяти сущест­ вует канонический инструмент – макет дерева ван-Эмде-Боаса, который можно использовать и для структур в основной памяти без учета кеш-памяти. Однако эта тема в нашей книге не затрагивалась. 10.3. Названия структур данных Вообще говоря, структуры данных было бы лучше называть несколько иначе, чем они были названы их авторами. Их названия укоренились только из-за того, что они часто цитируются в литературе. Но не всегда понятно, что именно под ними подразумевается, будь то сама структура данных или метод ее реализации. Типичный пример такой структуры – куча. Во многих источниках она называется «очередью с приоритетами», хотя изначально куча – это просто массив. Но данные ей в последующих реализациях названия всегда имено­ вались «кучами» – либо двоичными (binomial), либо кучами Фибоначчи, либо мягкими (relaxed), либо пáрными (pairing) и т. д. Только левосторонние кучи (leftist heaps) не вписались в эти рамки, поэтому в литературе их назвали левосторонними деревьями. Поэтому мы называем кучей некую абстрактную структуру. Некоторые авторы называют слияние куч «объ­ единением» («meld»), хотя мы склоняемся к общепринятому «слиянию» («merge»). Слово «очередь» употребляется для многих совсем не связанных с очередью структур, таких, например, как «соединяемые очереди» («catenable queues») для деревьев поиска с операциями разделения и соединения, чего следует избегать для структур, на самом деле не являющихся очередями. То же самое относится и к «спискам», используемым во многих структурах данных. Всех их объединяет лишь то, что они неким образом линейно упорядочены. Мы же использовали понятие «список» только для связных списков. Правильное именование структур до сих пор остается проблемой, но в этой книге мы старались сохранить единообразие их названий. 10.4. Решение линейных рекуррентных соотношений При оценке сложности алгоритмов для выровненных по высоте деревьев поиска и прочих структур с экспоненциальной или логарифмической сложностью нередко используются линейные рекуррентные соотношения 10.4. Решение линейных рекуррентных соотношений 387 с постоянными коэффициентами, для которых существует простой способ их решения. Пусть у нас есть рекурсивная функция f(n + k) = ak – 1 f(n + k – 1) + ak – 2 f(n + k – 2) + ... + a1 f(n + 1) + a0 f(n) и ее начальные значения f(1), …, f(k). Множество всех решений этого рекуррентного соотношения зависит от его постоянных коэффициентов и слагаемых и потому образует линейное пространство размерности k, так что значения f(1), …, f(k) можно выбирать произвольно и затем согласно этому соотношению определять функцию для n > k. Это всегда приводит к решению такой рекурсии, и любые два решения, отвечающие начальным k значениям, будут одинаковыми. Так что нам нужно просто найти k линейно независимых решений этой рекурсии и затем сформировать их линейную комбинацию, удовлетворяющую заданным k начальным значениям. Определим полином степени k p(x) = xk – ak – 1xk – 1 – ak – 2xk – 2 – ... – a1x – a0, называемый характеристическим. У него должно быть ровно k корней, включая кратные и комплексные. Пусть c – один из таких корней, то есть ck = ak –1ck –1 + ak –2ck –2 + ... + a1c + a0, тогда fc(n) = cn – решение этого рекуррентного соотношения: fc(n + k) = cn+k = cn · ck = cn(ak−1ck−1 + ak−2ck−2 + ··· +a1c + a0) = ak−1cn+k−1 + ak−2cn+k−2 + ··· +a1cn+1 + a0cn = ak−1fc(n + k − 1) + ak−2fc(n + k − 2)+ ··· +a1fc(n + 1) + a0fc(n). Если c – кратный корень полинома p, то p(x) = (x – c)i r(x), где i ≥ 2, а r – некоторый полином. Но тогда c должен быть и корнем производных p', ..., p(i − 1) этого полинома. Поэтому мы имеем ck − ak−1ck−1 − ak−2ck−2 − ··· − a1c − a0 = 0, kck−1 − ak−1(k − 1)ck−2 − ak−2(k − 2)ck−3 − ··· − a1 = 0, ⋮ k(k − 1) ··· (k − i + 2)ck−i+1 − ak−1(k − 1)(k − 2) ··· (k − i + 1)ck−i − ak−2(k − 3)(k − 4) ··· (k − i)ck−i−1 − ··· − (i − 1)(i − 2) ··· 1ai−1 = 0. В таком случае должны существовать полиномы q0, …, qi −1 степеней от 0 до i – 1 вида (qj(x) = x(x – 1)(x – 2) ... (x – j + 1)), для которых 388 Глава 10. Приложение q0(k)ck − q0(k − 1)ak−1ck−1 − q0(k − 2)ak−2ck−2 − ··· − q0(1)c − q0(0) = 0, q1(k)ck − q1(k − 1)ak−1ck−1 − q1(k − 2)ak−2ck−2 − ··· − q1(1)c − q1(0) = 0, ⋮ k k−1 k−2 qi−1(k)c − qi−1(k − 1)ak−1c − qi−1(k − 2)ak−2c − ··· − qi−1(1)c − qi−1(0) = 0. Все эти полиномы линейно независимы и образуют пространство полиномов степени не выше i – 1. То есть всякий полином q степени не выше i – 1 можно представить в виде линейной системы уравнений для полиномов qj и получить из нее: q(k) ck – q(k – 1)ak – 1ck – 1 – q(k – 2)ak – 2ck – 2 – ... – q(1)c – q(0) = 0. Следовательно, если c – корень кратности i характеристического полинома p, то любой полином q степени не выше i – 1 становится решением fc,q(n) = q(n)cn нашего рекуррентного соотношения, после чего можно воспользоваться зависящим от k полиномом q(n + k). fc,q(n + k) = q(n + k)cn+k = cn · q(n + k)ck = cn�q(n + (k − 1))ak−1ck−1 + q(n + (k − 2))ak−2ck−2 + ··· + q(n + 1)a1c + q(n + 0)a0� = ak−1q(n + k − 1)cn+k−1 + ak−2q(n + k − 2)cn+k−2 + ··· + a1q(n + 1)cn+1 + a0q(n + 0)cn = ak−1fc,q(n + k − 1) + ak−2fc,q(n + k − 2) + ··· + a1fc,q(n + 1) + a0fc,q(n). Следовательно, для данного рекуррентного соотношения получена система линейно независимых уравнений с количеством элементов, равным размерности пространства решений и образующим его базис. В итоге для решения рекуррентных соотношений (или рекурсий) такого типа нужно просто записать их характеристический полином p, найти его корни с учетом их кратности, определить базис пространства его решений и расписать систему линейных уравнений, отвечающих заданным начальным условиям. Единственная возможная проблема такого метода – нахождение корней характеристического полинома. 10.5. Медленно растущие функции Мы часто использовали логарифмическую функцию, не очень быстро рас­ тущую для многих обычных задач. Поскольку размер задачи n > 2100 не вызывает проблем, можно считать, что log n ≤ 100. Исходя из этого, при реализации некоторых древовидных структур выбирался размер массива для стека. Однако есть функции, растущие еще медленнее, чем log n, и некоторые из них действительно имеют место для структур данных и алгоритмов. Ясно, что функция log log n растет еще медленнее, чем оптимальный для худшего случая вариант log n при решении задачи объединения множеств (см. раздел 6.1). Чтобы почувствовать разницу между медленно растущими 10.5. Медленно растущие функции 389 функциями, лучше всего сравнить их обратные, быстро растущие функции. Обратная функция для log n – это 2n, а обратная функция для log log n – n это 22 . Иногда полезна очень медленно растущая функция log*(п) как многократное логарифмирование до тех пор, пока ее результат не станет меньше 1. Равносильная ей версия имеет вид: ⋰2 � 22 log* n = k если 2 k ⋰2� 22 ≤ n < 2 k+1 . Обратная функция для log*(n) – это степеннáя «лестница» высоты n из главы 6. Но функция Аккермана растет еще быстрее степеннóй «лестницы»: A(m, 0) = 0 A(m, 1) = A (m – 1, 2) A(0, n) = 2n A(m, n) = A(m – 1, A(m, n – 1)) для для для для m ≥ 1, m ≥ 1, n ≥ 0, m ≥ 1, n ≥ 2. В 1928 году Аккерман56 придумал диагональную функцию A(n, n), растущую настолько быстро, что она не может быть выражена простой рекурсией. Чтобы почувствовать ее рост, представим ее в виде: A(0, n) = 2n (по определению), A(1, n) = A(0, A(1, n − 1)) = 2A(1, n − 1) = ··· = 2n−1A(1, 1) = 2n+1, A(2, n) = A(1, A(2, n − 1)) = 2A(2,n−1)+1 > 2A(2,n−1), то есть ⋰2� 22 A(2, n) > 2 n+1 раз. Проще говоря, A(k, n) – это результат n-кратного применения A(k – 1, ·). Самые главные свойства функции A(m, n) заключаются в том, что она растет (и очень быстро) по обеим переменным и A(m, 1) > m. При этом отметим, что A(i, 1) = A(i – 1, 2) = A(i – 2, A(i – 1, 1)) > A(i – 2, i – 2) > A(i – 2, 1), поэтому значение диагональной функции Аккермана A(n, n) лежит между значениями первых столбцов A(n, 1) и A(n + 2, 1). Так что определенная в разделе 6.1 обратная функция Аккермана α(n) = min{ i | A(i, 1) > n} отличается от обратной диагональной функции Аккермана αdiag(n) = min { i | A(i, i) > n } не более чем на 2. Функция α(n) – это самая медленно растущая функция из приведенных в этой книге. 56 На самом деле это не совсем то, что определил Аккерман. С 1928 года идея была упрощена, и с тех пор существует несколько вариантов начальных условий его рекурсии, из которых мы выбрали только самый подходящий для нашего приложения. Но, несмотря на это, поведение функции всегда одно и то же. Глава 11 Список публикаций Если статья доступна и в материалах конференций, и в журнальной версии, приводится ссылка на ее журнальную версию. Ссылки на технические отчеты и другие недоступные источники вообще не приводятся. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. S. Abiteboul, H. Kaplan, T. Milo. Compact Labeling Schemes for Ancestor Queries, SODA 2001 (Proc. 12th ACM-SIAM Symposium on Discrete Algorithms), 547–556. M. I. Abouelhoda, S. Kurtz, E. Ohlebusch. Replacing Suffix Trees with Enhanced Suffix Arrays, Journal of Discrete Algorithms 2 (2004) 53–86. W. Ackermann. Zum Hilbertschen Aufbau der reellen Zahlen, Mathematische Annalen 99 (1928) 118–133. Г. М. Адельсон-Вельский, Е. М. Ландис. Один алгоритм организации информации // Доклады АН СССР, 146, № 2 (1962), 263–266. (G. M. Adel'son-Vel'skiĭ, E. M. Landis. An Algorithm for the Organization of Information, Dokl. Akad. Nauk SSSR 146 (2) (1962) 1259–1262; англ. перевод в Soviet Mathematics Doklady 3 (1962) 1259–1263. P. K. Agarwal, L. Arge, K. Yi. An Optimal Dynamic Interval Stabbing-Max Data Structure, SODA 2005 (Proc. 16th ACM-SIAM Symposium on Discrete Algorithms), 803–812. P. K. Agarwal, M. de Berg, J. Gudmundsson, M. Hammar, H. J. Haverkort. Box-Trees and R-Trees with Near-Optimal Query Time, Discrete&Computational Geometry 28 (2002) 291–312. A. V. Aho, J. E. Hopcroft, J. D. Ullman. The Design and Analysis of Computer Algorithms, Addison-Wesley, 1974. (Есть рус. перевод: А. Ахо, Дж. Хопкрофт, Дж. Ульман. Построение и анализ вычислительных алгоритмов / под ред. Ю. В. Матиясевича. М.: Мир, 1979.) A. V. Aho, J. E. Hopcroft, J. D. Ullman. On Finding Lowest Common Ancestors in Trees, SIAM Journal on Computing 5 (1976) 115–132. M. Ajtai, J. Komlós, E. Szemerédi. There Is No Fast Single Hashing Algorithm. Information Processing Letters 7 (1978) 270–273. S. Albers, M. Karpinski. Randomized Splay Trees: Theoretical and Experimental Results. Information Processing Letters 81 (2002) 213–221. Список публикаций 391 13. 14. 15. 16. 17. 18. 19. 20. 21. 22. 23. 24. 25. 26. 27. 28. B. Allen, J. I. Munro. Self-Organizing Binary Search Trees, Journal of the ACM 25 (1978) 526–535. N. Alon, M. Dietzfelbinger, P. B. Miltersen, E. Petrank, G. Tardos. Linear Hash Functions, Journal of the ACM 46 (1999) 667–683. S. Alstrup, A. M. Ben-Amram, T. Rauhe. Worst-Case and Amortized Optimality in Union-Find. STOC 1999 (Proc. 31st Annual ACM Symposium on Theory of Computing), 499–506. S. Alstrup, C. Gavoille, H. Kaplan, T. Rauhe. Nearest Common Ancestors. A Survey and a New Distributes Algorithm. SPAA 2002 (Proc. 14th ACM Symposium on Parallel Algorithms and Architectures), 258–264. S. Alstrup, J. Holm. Improved Algorithms for Finding Level-Ancestors in Dynamic Trees. ICALP 2000 (Proc. 27th International Colloquium on Automata, Languages, and Programming), Springer. LNCS 1853, 73–84. S. Alstrup, T. Husfeldt, T. Rauhe. Marked Ancestor Queries. FOCS 1998 (Proc. 39th Annual IEEE Symposium on Foundations of Computer Science), 534–543. S. Alstrup, T. Husfeldt, T. Rauhe, M. Thorup. Black Box for Constant Time Insertion in Priority Queues, ACM Transactions on Algorithms 1 (2005) 102–106. S. Alstrup, T. Rauhe. Improved Labeling Schemes for Ancestor Queries. SODA 2002 (Proc. 13th ACM-SIAM Symposium on Discrete Algorithms), 947–953. S. Alstrup, M. Thorup. Optimal Pointer Algorithms for Finding Nearest Common Ancestors in Dynamic Trees, Journal of Algorithms 35 (2000) 169–188. O. Amble, D. E. Knuth. Ordered Hash Tables, The Computer Journal 17 (1974) 135–142. A. Amir, D. Keselman, G. M. Landau, M. Lewenstein, N. Lewenstein, M. Rodeh. Text Indexing and Dictionary Matching with One Error, Journal of Algorithms 37 (2000) 309–325. A. Andersson. Optimal Bounds on the Dictionary Problem. Proc. Symposium on Optimal Algorithms 1989a, Varna, Springer. LNCS 401, 106–114. A. Andersson. Improving Partial Rebuilding by Using Simple Balance Criteria. WADS 1989b (Proc. 1st Workshop on Algorithms and Data Structures), Springer. LNCS 382, 393–402. A. Andersson. Maintaining α-Balanced Trees by Partial Rebuilding, International Journal of Computer Mathematics 38 (1990) 37–48. A. Andersson. Balanced Search Trees Made Simple. WADS 1993 (Proc. 3rd Workshop on Algorithms and Data Structures), Springer. LNCS 709, 60–71. A. Andersson. General Balanced Trees, Journal of Algorithms 30 (1999) 1–28. 392 Глава 11. Список публикаций 29. A. Andersson, C. Icking, R. Klein, T. Ottmann. Binary Search Trees of Almost Optimal Height, Acta Informatica 28 (1990) 165–178. A. Andersson, S. Nilsson. Improved Behavior of Tries by Adaptive Branching, Information Processing Letters 46 (1993) 295–300. A. Andersson, S. Nilsson. Faster Searching in Tries and Quadtrees. ESA 1994 (Proc. of the 2nd Annual European Symposium on Algorithms), Springer. LNCS 855, 82–93. A. Andersson, S. Nilsson. Efficient Implementation of Suffix Trees, Software – Practice and Experience 25 (1995) 129–141. C.-H. Ang, K.-P. Tan. The Interval B-Tree, Information Processing Letters 53 (1995) 85–89. J.-I. Aoe, K. Morimoto, T. Sato. An Efficient Implementation of Trie Structures, Software – Practice and Experience 22 (1992) 685–721. A. Apostolico. The Myriad Virtues of Subword Trees, Combinatorial Algorithms on Words, Proc. of the NATO ASI, A. Apostolico, Z. Galil, eds., Springer 1985, 85–96. A. Apostolico, G.F. Italiano, G. Gambosi, M. Talamo. The Set Union Problem with Unlimited Backtracking, SIAM Journal on Computing 23 (1994) 50–70. B. W. Arden, B. A. Galler, R. M. Graham. An Algorithm for Equivalence Declarations, Communications ACM 4 (1961) 310–314. L. Arge, M. de Berg, H. J. Haverkort, K. Yi. The Priority R-Tree: A Practically Efficient and Worst-Case Optimal R-Tree, SIGMOD 2004 (Proc. 2004 ACM SIGMOD Conference on Management of Data), 347–358. L. Arge, J. S. Vitter. Optimal External Memory Interval Management, SIAM Journal on Computing 32 (2003) 1488–1508. A. Arvind, C. P. Rangan. Symmetric Min-Max Heap. A Simpler Data Structure for Double-Ended Priority Queue, Information Processing Letters 69 (1999) 197–199. M. D. Atkinson, J.-R. Sack, N. Santoro, T. Strothotte. Min-Max Heaps and Generalized Priority Queues, Communications ACM 29 (1986) 996–1000. M. Ayala-Rinc´on, P. D. Conejo. ALinear Time Lower Bound on McCreight and General Updating Algorithms for Suffix Trees, Algorithmica 37 (2003) 233–241. Y. Azar, A. Z. Broder, A. R. Karlin, E. Upfal. Balanced Allocations, SIAM Journal on Computing 29 (1999) 180–200. G. H. Badr, B. J. Oommen. Self-Adjusting of Ternary Search Tries Using Conditional Rotations and Randomized Heuristics, The Computer Journal 48 (2004) 200–219. J.-L. Baer. Weight-Balanced Trees. NCC 1975 (Proc. National Computer Conference) AFIPS Conference Proc. 44, 467–472. 30. 31. 32. 33. 34. 35. 36. 37. 38. 39. 40. 41. 42. 43. 44. 45. Список публикаций 393 46. 47. 48. 49. 50. 51. 52. 53. 54. 55. 56. 57. 58. 59. 60. 61. 62. R. A. Baeza-Yates, H. J. Soza-Pollman. Analysis of Linear Hashing Revisited, Nordic Journal of Computing 5 (1998) 70–85. A. Bagchi, A. L. Buchsbaum, M. T. Goodrich. Biased Skip Lists. ISAAC 2002 (Proc. 13th International Symposium on Algorithms and Computation), Springer. LNCS 2518, 1–13. B. S. Baker. A Theory of Parametrized Pattern Matching: Algorithms and Applications. STOC 93 (Proc. 25th Annual ACM Symposium on Theory of Computing), 71–80. L. Banachowski. A Complement to Tarjan's Result About the Lower Bound on the Complexity of the Set Union Problem, Information Processing Letters 11 (1980) 59–65. S. Bansil, S. Sreekanth, P. Gupta. M-Heap: A Modified Heap Data Structure, International Journal Foundations of Computer Science 14 (2003) 491–502. R. Bayer. Binary B-Trees for Virtual Memory. Proc. ACM SIGFIDET Workshop on Data Description, Access and Control 1971, 219–235. R. Bayer. Symmetric Binary B-Trees: Data Structure and Maintenance Algorithms, Acta Informatica 1 (1972a) 290–306. R. Bayer. Oriented Balanced Trees and Equivalence Relations, Information Processing Letters 1 (1972b) 226–228. R. Bayer, E. McCreight. Organization and Maintenance of Large Ordered Indexes, Acta Informatica 1 (1972) 173–189. C. Bays. Some Techniques for Structuring Chained Hash Tables, The Computer Journal 16 (1973a) 126–131. C. Bays. The Reallocation of Hash-Coded Tables, Communications ACM 16 (1973b) 11–14. N. Beckmann, H.-P. Kriegel, R. Schneider, B. Seeger. The R∗-Tree: An Efficient and Robust Access Method for Points and Rectangles. SIGMOD 1990 (Proc. 1990 ACM SIGMOD Conference on Management of Data), 322–331. R. C. Bell, B. Floyd. A Monte-Carlo Study of Cichelli Hash-Function Solvability, Communications ACM 26 (1983) 924–925. M. Ben-Amram. What Is a «Pointer Machine»?, ACM SIGACT News 26 (2) (1995) 88–95. M. A. Bender, R. Cole, E. D. Demaine, M. Farach-Colton, J. Zito. Two Simplified Algorithms for Maintaining Order in a List. ESA 2002 (Proc. of the 10th Annual European Symposium on Algorithms), Springer. LNCS 2461, 152–164. M. A. Bender, M. Farach-Colton. The Level Ancestor Problem Simplified, Theoretical Computer Science 321 (2004) 5–12. M. Ben-Or. Lower Bounds for Algebraic Computation Trees. STOC 1983 (Proc. of the 15th ACM Symposium on Theory of Computing), 80–86. 394 Глава 11. Список публикаций 63. S. W. Bent, D. D. Sleator, R. E. Tarjan. Biased Search Trees, SIAM Journal on Computing 14 (1985) 545–568. J. L. Bentley. Multidimendional Binary Search Used for Associative Searching, Communications ACM 18 (1975) 509–517. J. L. Bentley. Decomposable Searching Problems, Information Processing Letters 8 (1979) 244–251. J. L. Bentley. kd-Trees for Semidynamic Point Sets. SCG 1990 (Proc. 6th ACM Symposium on Computational Geometry), 360–369. J. L. Bentley, J. H. Friedman. Data Structures for Range Searching, ACM Computing Surveys 11 (1979) 397–409. J. L. Bentley, H. A. Maurer. Efficient Worst-Case Data Structures for Range Searching, Acta Informatica 13 (1980) 155–168. J. L. Bentley, J. B. Saxe. Decomposable Searching Problems I. Static-to-Dynamic Transformations, Journal of Algorithms 1 (1980) 301–358. J. L. Bentley, R. Sedgewick. Fast Algorithms for Sorting and Searching Strings. SODA 1997 (Proc. 8th ACM-SIAM Symposium on Discrete Algorithms), 360–369. P. Berenbrink, A. Czumaj, A. Steger, B. V¨ocking. Balanced Allocations. The Heavily Loaded Case. STOC 2000 (Proc. 32nd Annual ACM Symposium on Theory of Computing), 745–754. O. Berkman, U. Vishkin. Finding Level Ancestors in Trees, Journal of Computer and System Sciences 48 (1994) 214–230. J. R. Bitner. Heuristics That Dynamically Organize Data Structures, SIAM Journal on Computing 8 (1979) 82–110. G. Blankenagel, H. Güting. External Segment Trees, Algorithmica 12 (1994) 498–532. G. E. Blelloch, B. M. Maggs, S. L. M. Woo. Space-Efficient Finger Search on Degree-Balanced Search Trees. SODA 2003 (Proc. 14th Annual ACM-SIAM Symposium on Discrete Algorithms), 374–383. B. H. Bloom. Space/Time Trade-offs in Hash Coding with Allowable Errors, Communications ACM 13 (1970) 422–426. N. Blum. On the Single-Operation Worst-Case Time Complexity of the Disjoint Set Union Problem, SIAM Journal Computing 15 (1986) 1021–1024. N. Blum, K. Mehlhorn. On the Average Number of Rebalancing Operations in Weight-Balanced Trees, Theoretical Computer Science 11 (1980) 303–320. J. A. Blumer. How Much Is That DAWGin theWindow? A MovingWindow Algorithm for the Directed Acyclic Word Graph, Journal of Algorithms 8 (1987) 451–469. A. Blumer, J. A. Blumer, A. Ehrenfeucht, D. Haussler, M. T. Chen, J. Seiferas. The Smallest Automaton Recognizing the Subwords of a Text, Theoretical Computer Science 40 (1985) 31–55. 64. 65. 66. 67. 68. 69. 70. 71. 72. 73. 74. 75. 76. 77. 78. 79. 80. Список публикаций 395 81. 82. 83. 84. 85. 86. 87. 88. 89. 90. 91. 92. 93. 94. 95. 96. 97. 98. F. Bonomi, M. Mitzenmacher, R. Panigrahy, S. Singh, G. Varghese. An Improved Construction for Counting Bloom Filters. ESA 2006 (Proc. 14th Annual European Symposium on Algorithms), Springer. LNCS 4168, 684–695. P. Bose, M. van Kreveld, A. Maheshwari, P. Morin, J. Morrison. Translating a Regular Grid over a Point Set, Computational Geometry Theory Applications 25 (2003) 21–34. P. Bozanis, A. Nanopoulos, Y. Manolopoulos. LR-Tree. A Logarithmic Decomposable Spatial Index Method, The Computer Journal 46 (2003) 319–331. P. Brass. Multidimensional Heaps and Complementary Range Searching, Information Processing Letters 102 (2007) 152–155. G. Brassard, S. Kannan. The Generation of Random Permutations on the Fly, Information Processing Letters 28 (1988) 207–212. R. P. Brent. Reducing the Retrieval Time in Scatter Storage Techniques, Communications ACM 16 (1973) 105–109. D. Breslauer. Dictionary Matching on Unbounded Alphabets. Uniform Length Dictionaries, Journal of Algorithms 18 (1995) 278–295. R. de la Briandais. File Searching Using Variable Length Keys. Proc. of the Western Joint Computer Conference 1959, 295–298. G. S. Brodal. Fast Meldable Priority Queues. WADS 1995 (Proc. 4th Workshop on Algorithms and Data Structures), Springer. LNCS 955, 282–290. G. S. Brodal. Worst-Case Efficient Priority Queues. SODA 1996a (Proc. 7th ACM-SIAM Symposium on Discrete Algorithms), 52–58. G. S. Brodal. Partially Persistent Data Structures of Bounded Degree with Constant Update Time, Nordic Journal of Computing 3 (1996b) 238–255. G. S. Brodal. Finger Search Trees with Constant Insertion Time. SODA 1998 (Proc. 9th ACM-SIAM Symposium on Discrete Algorithms), 540–549. G. S. Brodal, L. Ga¸sieniec. Approximate Dictionary Queries. CPM 1996 (Proc. 7th Annual Symposium on Combinatorial Pattern Matching), Springer. LNCS 1075, 65–74. G. S. Brodal, G. Lagogiannis, C. Makris, A.K. Tsakalidis, K. Tsichlas. Optimal Finger Search Trees in the Pointer Machine. STOC 2002 (Proc. 34th Annual ACM Symposium on Theory of Computing), 583–591. G. S. Brodal, S. Venkatesh. Improved Bounds for Dictionary Look-Up with One Error, Information Processing Letters 75 (2000) 57–59. A. Z. Broder, A. R. Karlin. Multilevel Adaptive Hashing. SODA 1990 (Proc. 1st ACM-SIAM Symposium on Discrete Algorithms), 43–53. A. Z. Broder, M. Mitzenmacher. Network Applications of Bloom Filters. A Survey, Internet Mathematics 1 (2004) 485–509. A. Brodnik, J. I. Munro. Membership in Constant Time and Almost-Minimum Space, SIAM Journal on Computing 28 (1999) 1627–1640. 396 Глава 11. Список публикаций 99. H. Brönnimann, F. Cazals, M. Durand. Randomized Jumplists. A Jump-andWalk Dictionary Data Structure. STACS 2003 (Proc. of the 20th Annual Symposium on Theoretical Aspects of Computer Science), Springer. LNCS 2607, 283–294. M. R. Brown. Implementation and Analysis of Binomial Queue Algorithms, SIAM Journal on Computing 7 (1978) 298–319. M. R. Brown, R. E. Tarjan. Design and Analysis of a Data Structure for Representing Sorted Lists, SIAM Journal on Computing 9 (1980) 594–614. A. L. Buchsbaum, H. Kaplan, A. Rogers, J. R. Westbrook. Linear-Time Pointer-Machine Algorithms for Least Common Ancestors, MST Verification, and Dominators. STOC 1998 (Proc. 30th Annual ACM Symposium on Theory of Computing), 279–288. A. L. Buchsbaum, R. Sundar, R. E. Tarjan. Data Structural Bootstrapping, Linear Path Compression, and Catenable Heap Ordered Double Ended Queues. FOCS 1992 (Proc. 33rd IEEE Symposium Foundations of Computer Science), 40–49. A. L. Buchsbaum, R. E. Tarjan. Confluently Persistent Dequeues via Data Structural Bootstrapping, Journal of Algorithms 18 (1995) 513–547. H. Buhrman, P. B. Miltersen, J. Radhakrishnan, S. Venkatesh. Are Bitvectors Optimal?. STOC 2000 (Proc. 32nd Annual ACM Symposium on Theory of Computing), 449–458. J. Burghardt. Maintaining Partial Sums in Logarithmic Time, Nordic Journal of Computing 8 (2001) 473–474. W. A. Burkhard. Nonrecursive Traversals of Trees, The Computer Journal 18 (1975) 227–230. S. Burkhardt, J. Kӓrkkӓinen. Fast Lightweight Suffix Array Construction and Checking. CPM 2003 (Proc. 14th Annual Symposium on Combinatorial Pattern Matching), Springer. LNCS 2676, 55–69. F. W. Burton, M. M. Huntbach, J. G. Kollias. Multiple Generation Text Files Using Overlapping Tree Structures, The Computer Journal 28 (1985) 414– 416. F. W. Burton, J. G. Kollias, V. G. Kollias, D.G. Matsakis. Implementation of Overlapping B-Trees for Time and Space Efficient Representation of Collections of Similar Files, The Computer Journal 33 (1990) 279–280. H. Cameron, D. Wood. A Note on the Path Length of Red-Black Trees, Information Processing Letters 42 (1992) 287–292. S. Carlsson. Improving Worst-Case Behavior of Heaps, BIT 24 (1984) 14–18. S. Carlsson. A Variant of Heapsort with Almost Optimal Number of Comparisons, Information Processing Letters 24 (1987) 247–250. S. Carlsson. The Deap – A Double-Ended Heap to Implement Double-Ended Priority Queues, Information Processing Letters 26 (1987/88) 33–36. 100. 101. 102. 103. 104. 105. 106. 107. 108. 109. 110. 111. 112. 113. 114. Список публикаций 397 115. S. Carlsson. An Optimal Algorithm for Deleting the Root of a Heap, Information Processing Letters 37 (1991) 117–120. 116. S. Carlsson, J. Chen. The Complexity of Heaps. SODA 1992 (Proc. 3rd ACM-SIAM Symposium on Discrete Algorithms), 393–402. 117. S. Carlsson, J. Chen, T. Strothotte. A Note on the Construction of the Data Structure «DEAP», Information Processing Letters 31 (1989) 315–317. 118. S. Carlsson, J. I. Munro, P. V. Poblete. An Implicit Binomial Queue with Constant Insertion Time. SWAT 1988 (Proc. 1st ScandinavianWorkshop on Algorithm Theory), Springer. LNCS 318, 1–13. 119. J. L. Carter, R. Floyd, J. Gill, G. Markowsky, M.N. Wegman. Exact and Approximate Membership Testers. STOC 1978 (Proc. 10th Annual ACM Symposium on Theory of Computing), 59–65. 120. J. L. Carter, M. N. Wegman. Universal Classes of Hash Functions, Journal Computer System Sciences 18 (1979) 143–154. 121. P. Celis, P.-Å. Larson, J.I. Munro. Robin Hood Hashing. FOCS 1985 (Proc. 26th Annual IEEE Symposium on Foundations of Computer Science), 281–288. 122. D. J. Challab. Implementation of Flexible Arrays Using Balanced Trees, The Computer Journal 34 (1991) 386–396. 123. C.-C. Chang. The Study of an Ordered Minimal Perfect Hashing Scheme, Communications ACM 27 (1984) 384–387. 124. C.-C. Chang, C. Y. Chen, J.-K. Jan. On the Design of a Machine-Independent Perfect Hashing Scheme, The Computer Journal 34 (1991) 469–474. 125. S. C. Chang, M. W. Du. Diamond Deque. A Simple Data Structure for Prioririty Deques, Information Processing Letters 46 (1993) 231–237. 126. H. Chang, S. S. Iyengar. Efficient Algorithms to Globally Balance a Binary Search Tree, Communications ACM 27 (1984) 695–702. 127. C.-C. Chang, R. C. T. Lee. A Letter-Oriented Minimal Perfect Hashing Scheme, The Computer Journal 29 (1986) 277–281. 128. P. Chanzy, L. Devroye, C. Zamora-Cura. Analysis of Range Search for Random kd-Trees, Acta Informatica 37 (2001) 355–383. 129. B. Chazelle. How to Search in History, Information and Control 64 (1985) 77–99. 130. B. Chazelle. Lower Bounds for Orthogonal Range Searching. I. The Reporting Case, Journal of the ACM 37 (1990a) 200–212. 131. B. Chazelle. Lower Bounds for Orthogonal Range Searching. II. The Arithmetic Model, Journal of the ACM 37 (1990b) 439–463. 132. B. Chazelle, L. J. Guibas. Fractional Cascading I. A Data Structuring Technique, Algorithmica 1 (1986a) 133–162. 133. B. Chazelle, L. J. Guibas. Fractional Cascading II. Applications, Algorithmica 1 (1986b) 163–191. 398 Глава 11. Список публикаций 134. B. Chazelle, J. Killian, R. Rubinfeld, A. Tal. The Bloomier Filter. An Efficient Data Structure for Static Support Lookup Tables. SODA 2004 (Proc. 15th Annual ACM-SIAM Symposium on Discrete Algorithms), 30–39. 135. J. Chen. An Efficient Construction Algorithm for a Class of Implicit Double-Ended Priority Queues, The Computer Journal 38 (1995) 818–821. 136. L. Chen. O(1) Space Complexity Deletion in AVL Trees, Information Processing Letters 22 (1986) 147–149. 137. L. Chen, R. Schott. Optimal Operations on Red-Black Trees, International Journal Foundations of Computer Science 7 (1996) 227–239. 138. M. T. Chen, J. Seiferas. Efficient and Elegant Subword-Tree Construction. Combinatorial Algorithms on Words, Proc. of the NATO ASI (A. Apostolico, Z. Galil, eds.) Springer 1985, 97–107. 139. S.-W. Cheng, R. Janardan. Efficient Maintenance of the Union of Intervals on a Line, with Applications, Journal of Algorithms 12 (1991) 57–74. 140. S. Cho, S. Sahni. Weight-Biased Leftist Trees and Modified Skip Lists, ACM Journal on Experimental Algorithmics 3 (1998) Article 2. 141. S. Cho, S. Sahni. Mergeable Double-Ended Priority Queues, International Journal Foundations of Computer Science 10 (1999) 1–17. 142. S. Cho, S. Sahni. A New Weight Balanced Binary Search Tree, International Journal Foundations of Computer Science 11 (2000) 485–513. 143. Y. Choi, T.-W. Lam. Dynamic Suffix Trees and Two-Dimensional Texts Management, Information Processing Letters 61 (1997) 213–220. 144. K.-R. Chong, S. Sahni. Correspondence-Based Data Structures for Double-Ended Priority Queues, ACM Journal on Experimental Algorithmics 5 (2000) Article 2. 145. J.-H. Chu, G. D. Knott. An Analysis of Spiral Hashing, The Computer Journal 37 (1994) 715–719. 146. S. M. Chung. Indexed Extendible Hashing, Information Processing Letters 44 (1992) 1–6. 147. R. J. Cichelli. Minimal Perfect Hash Functions Made Simple, Communications ACM 23 (1980) 17–19. 148. D. W. Clark. A Fast Algorithm for Copying Binary Trees, Information Processing Letters 4 (1975) 62–63. 149. E. G. Coffman, Jr., J. Eve. File Structures Using Hashing Functions, Communications of the ACM 13 (1970) 427–436. 150. S. Cohen, Y. Matias. Spectral Bloom Filters. SIGMOD 2003 (Proc. 2003 ACM SIGMOD Conference on Management of Data), 241–252. 151. R. Cole, R. Hariharan. Faster Suffix Tree Construction with Missing Suffix Links, SIAM Journal on Computing 33 (2003) 26–42. 152. R. Cole, R. Hariharan. Dynamic LCA Queries on Trees, SIAM Journal on Computing 34 (2005) 894–923. Список публикаций 399 153. R. Cole, M. Lewenstein. Multidimensional Matching and Fast Search in Suffix Trees. SODA 2003 (Proc. 14th Annual ACM-SIAM Symposium on Discrete Algorithms), 851–852. 154. L. Colussi, A. De Col. A Time and Space Efficient Data Structure for String Searching on Large Texts, Information Processing Letters 58 (1996) 217–222. 155. D. Comer, R. Sethi. The Complexity of Trie Index Construction, Journal of the ACM 24 (1977) 428–440. 156. G. V. Cormack, R. N. S. Horspool, M. Kaiserswerth. Practical Perfect Hashing, The Computer Journal 28 (1985) 54–58. 157. M. Crochemore, W. Rytter. Jewels of Stringology, World Scientific 2003. 158. K. Culik, II, T. Ottmann, D. Wood. Dense Multiway Trees, ACM Transactions on Database Systems 6 (1981) 486–512. 159. K. Culik, II, D. Wood. A Note on Some Tree Similarity Measures, Information Processing Letters 15 (1982) 39–42. 160. W. Cunto, G. Lau, P. Flajolet. Analysis of kdt-Trees. kd-Trees Improved by Local Reorganization. WADS 1989 (Proc. 1st Workshop on Algorithms and Data Structures), Springer. LNCS 382, 24–38. 161. Z. J. Czech. Quasi-Perfect Hashing, The Computer Journal 41 (1998) 416– 421. 162. Z. J. Czech, G. Havas, B. S. Majewski. An Optimal Algorithm for Generating Minimal Perfect Hash Functions, Information Processing Letters 43 (1992) 257–264. 163. Z. J. Czech, G. Havas, B.S. Majewski. Perfect Hashing, Theoretical Computer Science 182 (1997) 1–143. 164. Z. J. Czech, B. S. Majewski. A Linear-Time Algorithm for Finding Minimal Perfect Hash Functions, The Computer Journal 36 (1993) 579–587. 165. R. B. Dannenberg. A Structure for Efficient Update, Incremental Redisplay, and Undo in Graphical Editors, Software – Practice and Experience 20 (1990) 109–132. 166. A. C. Day. Balancing a Binary Tree, The Computer Journal 19 (1976) 360–361. 167. E. D. Demaine, J. Iacono, S. Langerman. Retroactive Data Structures. SODA 2004 (Proc. 15th Annual ACM-SIAM Symposium on Discrete Algorithms), 281–290. 168. L. Devroye. A Limit Theory for Random Skip Lists, Annals of Applied Probability 2 (1992) 597–609. 169. L. Devroye, P. Morin. Cuckoo Hashing. Further Analysis, Information Processing Letters 86 (2003) 215–219. 170. L. Devroye, P. Morin, A. Viola. On Worst-Case Robin Hood Hashing, SIAM Journal on Computing 33 (2004) 923–936. 400 Глава 11. Список публикаций 171. G. Diehr, B. Faaland. Optimal Pagination of B-Trees with Variable-Length Items, Communications ACM 27 (1984) 241–247. 172. P. F. Dietz. Maintaining Order in a Linked List. STOC 1982 (Proc. 14th Annual ACM Symposium on Theory of Computing), 122–127. 173. P. F. Dietz, R. Raman. Persistence, Amortization, and Randomization. SODA 1991 (Proc. 2nd Annual ACM-SIAM Symposium on Discrete Algorithms), 78–88. 174. P. F. Dietz, D. D. Sleator. Two Algorithms for Maintaining Order in a List. STOC 1987 (Proc. 19th AnnualACMSymposium on Theory of Computing), 365–372. 175. M. Dietzfelbinger, T. Hagerup. Simple Minimal Perfect Hashing in Less Space. ESA 2001 (Proc. 9th Annual European Symposium on Algorithms), Springer. LNCS 2161, 109–120. 176. M. Dietzfelbinger, A. Karlin, K. Mehlhorn, F. Meyer auf der Heide, H. Rohnert, R.E. Tarjan. Dynamic Perfect Hashing. Upper and Lower Bounds, SIAM Journal on Computing 23 (1994) 738–761. 177. M. Dietzfelbinger, F. Meyer auf der Heide. Dynamic Hashing in Real Time. Informatik-Festschrift zum 60, Geburtstag von Günter Hotz, J. Buchmann et al., eds., Teubner 1992, 95–115. 178. Y. Ding, M. A. Weiss. The Relaxed Min-Max Heap, Acta Informatica 30 (1993) 215–231. 179. Y. Ding, M. A. Weiss. On the Complexity of Building an Interval Heap, Information Processing Letters 50 (1994) 143–144. 180. A. A. Diwan, S. Rane, S. Seshadri, S. Sudarshan. Clustering Techniques for Minimizing External Path Length. VLDB 1996 (Proc. 22nd International Conference on Very Large Data Bases), 342–353. 181. D. P. Dobkin, J. I. Munro. Efficient Uses of the Past, Journal of Algorithms 6 (1985) 455–465. 182. D. P. Dobkin, S. Suri. Maintenance of Geometric Extrema, Journal of the ACM 38 (1991) 275–298. 183. D. Dolev, Y. Harari, N. Linial, N. Nisan, M. Parnas. Neighborhood Preser­ ving Hashing and Approximate Queries. SODA 1994 (Proc. 5th ACM-SIAM Symposium on Discrete Algorithms), 251–259. 184. J. R. Driscoll, H. N. Gabow, R. Shrairman, R. E. Tarjan. Relaxed Heaps. An Alternative to Fibonacci Heaps with Applications to Parallel Computation, Communications ACM 31 (1988) 1343–1354. 185. J. R. Driscoll, N. Sarnak, D. D. Sleator, R. E. Tarjan. Making Data Structures Persistent, Journal of Computer and System Sciences 38 (1989) 86–124. 186. J. R. Driscoll, D. D. Sleator, R. E. Tarjan. Fully Persistent Lists with Catenation, Journal of the ACM 41 (1994) 943–959. Список публикаций 401 187. A. Duch, V. Estivill-Castro, C. Martinez. Randomized k-Dimensional Binary Search Trees. ISAAC 1998 (Proc. 9th International Symposium on Algorithms and Computation), Springer. LNCS 1533, 199–209. 188. A. Duch, C. Martinez. On the Average Performance of Orthogonal Range Search in Multidimensional Data Structures, Journal of Algorithms 44 (2002) 226–245. 189. B. Dwyer. Simple Algorithms for Traversing a Tree without Additional Stack, Information Processing Letters 2 (1974) 143–145. 190. A. Ecker. The Period of Search for the Quadratic and Related Hash Methods, The Computer Journal 17 (1974) 340–343. 191. H. Edelsbrunner. A Note on Dynamic Range Searching, Bulletin of the EATCS 15 (1981) 34–40. 192. H. Edelsbrunner, H. A. Maurer. On the Intersection of Orthogonal Objects, Information Processing Letters 13 (1981) 177–181. 193. H. Edelsbrunner, M. H. Overmars. Batched Dynamic Solutions to Decomposable Searching Problems, Journal of Algorithms 6 (1985) 515–542. 194. A. Elmasry. Parametrized Self-Adjusting Heaps, Journal of Algorithms 52 (2004) 103–119. 195. P. van Emde Boas, R. Kaas, E. Zijlstra. Design and Implementation of an Efficient Priority Queue, Mathematical Systems Theory 10 (1977) 99–127. 196. R. J. Enbody, H.-C. Du. Dynamic Hashing Schemes, ACM Computing Surveys 20 (1988) 85–113. 197. D. Eppstein. Dynamic Euclidean Minimum Spanning Trees and Extrema of Binary Functions, Discrete & Computational Geometry 13 (1995) 111–122. 198. F. Ergun, S. C. Sahinalp, J. Sharp, R. K. Sinha. Biased Skip Lists for Highly Skewed Access Patterns. ALENEX 2001 (Proc. 3rd Workshop on Algorithms Engineering and Experimentation), Springer. LNCS 2153, 216–229. 199. J. B. Evans. Experiments with Trees for the Storage and Retrieval of Future Events, Information Processing Letters 22 (1986) 237–242. 200. R. Fagerberg. Binary Search Trees. How Low Can You Go?. SWAT 1996a (Proc. 5th ScandinavianWorkshop on Algorithm Theory), Springer. LNCS 1097, 428–439. 201. R. Fagerberg. A Generalization of Binomial Queues, Information Processing Letters 57 (1996b) 109–114. 202. R. Fagin, J. Nievergelt, N. Pippenger, H. R. Strong. Extendible Hashing – A Fast Access Method for Dynamic Files, ACM Transactions on Database Systems 4 (1979) 315–344. 203. S. M. Falconer, B. G. Nickerson. On Multilevel k-Ranges for Range Search, International Journal Computational Geometry Applications 15 (2005) 565–573. 402 Глава 11. Список публикаций 204. L. Fan, P. Cao, J. Almeida, A. Z. Broder. Summary Cache. A ScaleableWide-AreaWeb Cache Sharing Protocol, ACM Transactions on Networking 8 (2000) 281–293. 205. M. Farach. Optimal Suffix Tree Construction with Large Alphabets. FOCS 1997 (Proc. 38th Annual IEEE Symposium on Foundations of Computer Science), 137–143. 206. S. Felsner. Geometric Graphs and Arrangements, Vieweg Verlag, 2004. 207. P. Ferragina, R. Grossi. The String B-Tree. A New Data Structure for String Search in External Memory and Its Applications, Journal of the ACM 46 (1999) 236–280. 208. P. Ferragina, S. Muthukrishnan, M. de Berg. Multi-Method Dispatching. A Geometric Approach with Applications to String Matching Problems, in STOC 1999 (Proc. 30th Annual ACM Symposium on Theory of Computing), 483–491. 209. A. Fiat, H. Kaplan. Making Data Structures Confluently Persistent, Journal of Algorithms 48 (2003) 16–58. 210. D. Field. A Note on a New Data Structure for In-The-Past Queries, Information Processing Letters 24 (1987) 95–96. 211. M. J. Fischer. Efficiency of Equivalence Algorithms. Complexity of Computer Computations, R.E. Miller, J.W. Thatcher, eds., Plenum Press 1972, 153–168. 212. M. J. Fischer, M. S. Paterson. Fishspear. A Priority Queue Algorithm, Journal ACM 41 (1994) 3–30. 213. R. Fleischer. A Tight Lower Bound for the Worst Case of Bottom-Up Heapsort, Algorithmica 11 (1994) 104–115. 214. R. Fleischer. A Simple Balanced Search Tree with O(1) Worst-Case Update Time, International Journal Foundations of Computer Science 7 (1996) 137–149. 215. R. W. Floyd. Algorithm 113. Treesort, Communications ACM 5 (1962) p. 434. 216. R. W. Floyd. Algorithm 245. Treesort 3, Communications ACM 7 (1964) p. 701. 217. C. C. Foster. A Generalization of AVL-Trees, Communications ACM 16 (1973) 513–517. 218. E. A. Fox, L. S. Heath, Q.F. Chen, A.M. Daoud. Practical Minimal Perfect Hash Functions for Large Databases, Communications ACM 35 (1992) 105– 121. 219. C. W. Fraser, E. W. Myers. An Editor for Revision Control, ACM Transactions on Programming Languages and Systems 9 (1987) 277–295. 220. E. Fredkin. Trie Memory, Communications ACM 4 (1961) 490–499. 221. M. L. Fredman. A Near Optimal Structure for a Type of Range Query Problems. STOC 1979 (Proc. 11th Annual ACM Symposium on Theory of Computing), 62–66. Список публикаций 403 222. M. L. Fredman. A Lower Bound on the Complexity of Orthogonal Range Queries, Journal ACM 28 (1981a) 696–705. 223. M. L. Fredman. Lower Bounds on the Complexity of Some Optimal Data Structures, SIAM Journal on Computing 10 (1981b) 1–10. 224. M. L. Fredman. The Complexity of Maintaining an Array and Computing Its Partial Sums, Journal ACM 29 (1982) 250–260. 225. M. L. Fredman. Information Theoretic Implications for Pairing Heaps. STOC 1998 (Proc. 30th Annual ACM Symposium on Theory of Compu­ting), 319–326. 226. M. L. Fredman. On the Efficiency of Pairing Heaps and Related Data Structures, Journal ACM 46 (1999a) 473–501. 227. M. L. Fredman. A Priority Queue Transform. WAE 1999b (Proc. 3rd Workshop on Algorithms Engineering), Springer. LNCS 1668, 243–257. 228. M. L. Fredman, J. Komlós. On the Size of Separating Systems and Families of Perfect Hash Functions, SIAM Journal Algebraic Discrete Methods 5 (1984) 61–68. 229. M. L. Fredman, J. Komlós, E. Szemerédi. Storing a Sparse Table with O(1) Worst Case Access Time, Journal ACM 31 (1984) 538–544. 230. M. L. Fredman, M. E. Saks. The Cell Probe Complexity of Dynamic Data Structures. STOC 1989 (Proc. 21st Annual ACM Symposium on Theory of Computing), 345–354. 231. M. L. Fredman, R. Sedgewick, D. D. Sleator, R. E. Tarjan. The Pairing Heap. A New Form of Self-Adjusting Heap, Algorithmica 1 (1986) 111–129. 232. M. L. Fredman, R. E. Tarjan. Fibonacci Heaps and Their Uses in Improved Network Optimization Algorithms, Journal ACM 34 (1987) 596–615. 233. M. L. Fredman, B. Weide. On the Complexity of Computing the Measure of U[ai, bi], Communications of the ACM 21 (1978) 540–544. 234. M. Freeston. A General Solution of the n-Dimensional B-Tree Problem. SIGMOD 1995 (Proc. 1995 ACM SIGMOD Conference on Management of Data), 80–91. 235. M. Frigo, C. E. Leiserson, H. Prokop, S. Ramachandran. Cache Oblivious Algorithms. FOCS 1999 (Proc. 40th Annual IEEE Symposium on Foundations of Computer Science), 285–298. 236. M. Fürer. Randomized Splay Trees. SODA 1999 (Proc. 10th ACM-SIAM Symposium on Discrete Algorithms), 903–904. 237. H. N. Gabow. A Scaling Algorithm for Weighted Matching on General Graphs. FOCS 1985 (Proc. 26th Annual IEEE Symposium on Foundations of Computer Science), 90–100. 238. H. N. Gabow. Data Structures for Weighted Matching and Nearest Common Ancestors with Linking. SODA 1990 (Proc. 1st ACM-SIAM Symposium on Discrete Algorithms), 434–443. 404 Глава 11. Список публикаций 239. H. N. Gabow, R. E. Tarjan. A Linear Time Algorithm for a Special Case of Disjoint Set Union, Journal of Computer and System Sciences 30 (1985) 209–221. 240. V. Gaede, O. Günther. Multidimensional Access Methods, ACM Compu­ting Surveys 30 (1998) 170–231. 241. H. Gajewska, R. E. Tarjan. Deques with Heap Order, Information Processing Letters 22 (1986) 197–200. 242. B. A. Galler, M. J. Fisher. An Improved Equivalence Algorithm, Communications ACM 7 (1964) 301–303. 243. Z. Galil, G. F. Italiano. Data Structures and Algorithms for Disjoint Set Union Problems, ACM Computing Surveys 23 (1991) 319–344. 244. I. Galperin, R. L. Rivest. Scapegoat Trees. SODA 1993 (Proc. 4th ACM SIAM Symposium on Discrete Algorithms), 165–174. 245. G. Gambosi, G. F. Italiano, M. Talamo. Getting Back to the Past in the Union-Find Problem. STACS 1988 (Proc. of the 5th Annual Symposium on Theoretical Aspects of Computer Science), Springer. LNCS 294, 8–17. 246. G. Gambosi, G. F. Italiano, M. Talamo. The Set Union Problem with Dynamic Weighted Backtracking, BIT 31 (1991) 382–393. 247. G. Gambosi, M. Protasi, M. Talamo. An Efficient Implicit Data Structure for Relation Testing and Searching in Partially Ordered Sets, BIT 33 (1993) 29–45. 248. D. Gardy, P. Flajolet, C. Puech. Average Cost of Orthogonal Range Queries in Multiattribute Trees, Information Systems 14 (1989) 341–350. 249. T. E. Gerasch. An Insertion Algorithm for a Minimal Internal Pathlength Binary Search Tree, Communications ACM 31 (1988) 579–585. 250. G. F. Georgakopoulos, D. J. McClurkin. Generalized Template Splay. A Basic Theory and Calculus, The Computer Journal 47 (2004) 10–19. 251. L. Georgiadis, R. E. Tarjan, R. F. Werneck. Design of a Data Structure for Mergeable Trees. SODA 2006 (Proc. 17th ACM-SIAM Symposium on Discrete Algorithms), 394–403. 252. R. Giancarlo. A Generalization of the Suffix Tree to Square Matrix, with Applications, SIAM Journal on Computing 24 (1995) 520–562. 253. R. Giegerich, S. Kurtz. From Ukkonen to McCreight and Weiner. A Unifying View of Linear-Time Suffix Tree Construction, Algorithmica 19 (1997) 331–353. 254. R. Giegerich, S. Kurtz, J. Stoye. Efficient Implementation of Lazy Suffix Trees, Software – Practice and Experience 33 (2003) 1035–1049. 255. J. Gil, A. Itai. How to Pack Trees, Journal of Algorithms 32 (1999) 108–132. 256. G. H. Gonnet. Expected Length of the Longest Probe Sequence in Hash Code Searching, Journal of the ACM 28 (1981) 289–304. Список публикаций 405 257. G. H. Gonnet, R. A. Baeza-Yates, T. Snider. New Indices for Texts. PAT Trees and PAT Arrays. Information Retrieval. Data Structures and Algorithms, W.B. Frakes, R.A. Baeza-Yates, eds., Prentice Hall 1992, 66–82. 258. G. H. Gonnet, J. I. Munro. Efficient Ordering of Hash Tables, SIAM Journal on Computing 8 (1979) 463–478. 259. G. H. Gonnet, J. I. Munro. Heaps on Heaps, SIAM Journal on Computing 15 (1986) 964–971. 260. G. H. Gonnet, J. I. Munro, D. Wood. Direct Dynamic Structures for Some Line-Segment Problems, Computer Vision, Graphics, and Image Proces­sing 23 (1983) 178–186. 261. G. H. Gonnet, H. Olivié, D. Wood. Height-Ratio Balanced Trees, The Computer Journal 26 (1983) 106–108. 262. D. Greene, M. Parnas, F. Yao. Multi-Index Hashing for Information Retrieval. FOCS 1994 (Proc. 34th Annual IEEE Symposium on Foundations of Computer Science), 722–731. 263. R. Grossi, G. F. Italiano. Efficient Splitting and Merging Algorithms for Order Decomposable Problems. ICALP 1997 (Proc. 24th International Colloquium on Automata, Languages, and Programming), Springer. LNCS 1256, 605–615. 264. L. J. Guibas, E. M. McCreight, M. F. Plass, J. R. Roberts. A New Representation for Linear Lists. STOC 1977 (Proc. 9th Annual ACM Symposium on Theory of Computing), 49–60. 265. L. J. Guibas, R. Sedgewick. A Dichromatic Framework for Balanced Trees. FOCS 1978 (Proc. 19th Annual IEEE Symposium on Foundations of Computer Science), 8–21. 266. L. J. Guibas, E. Szemer´edi. Analysis of Double Hashing, Journal Computer System Sciences 16 (1978) 226–274. 267. D. Gusfield. Algorithms on Strings, Trees, and Sequences, Cambridge University Press 1997. 268. A. Guttman. R-Trees. A Dynamic Index Structure for Spatial Searching. SIGMOD 1984 (Proc. 1984 ACM SIGMOD Conference on Management of Data), 47–57. 269. T. Hagerup, T. Tholey. Efficient Minimal Perfect Hashing in Nearly Minimal Space. STACS 2001 (Proc. of the 18th Annual Symposium on Theoretical Aspects of Computer Science), Springer. LNCS 2010, 317–326. 270. S. E. Hambrusch, C.-M. Liu. Data Replication in Static Tree Structures, Information Processing Letters 86 (2003) 197–202. 271. H. Hampapuram, M. L. Fredman. Optimal Biweighted Binary Trees and the Complexity of Maintaining Partial Sums, SIAM Journal on Computing 28 (1998) 1–9. 272. D. Harel, R. E. Tarjan. Fast Algorithms for Finding Nearest Common Ancestors, SIAM Journal on Computing 13 (1984) 338–355. 406 Глава 11. Список публикаций 273. G. C. Harfst, E. M. Reingold. A Potential-Based Amortized Analysis of the Union-Find Structure, ACM SIGACT News 31 (2000) 86–95. 274. A. Hasham, J.-R. Sack. Bounds for Min-Max Heaps, BIT 27 (1987) 315–323. 275. S. Heinz, J. Zobel, H. E. Williams. Burst Tries. A Fast, Efficient Data Structure for String Keys, ACM Transactions on Information Systems 20 (2002) 192–223. 276. J. M. Hellerstein, E. Koutsoupias, C.H. Papadimitriou. On the Analysis of Indexing Schemes. PODS 1997 (Proc. 16th ACM Symposium on Principles of Database Systems), 249–256. 277. T. Herman, T. Masuzawa. Available Stabilizing Heaps, Information Processing Letters 77 (2001) 115–121. 278. K. Hinrichs. Implementation of the Grid File. Design Concepts and Experience, BIT 25 (1985) 569–592. 279. D. S. Hirschberg. An Insertion Technique for One-Sided Height-Balanced Trees, Communications ACM 19 (1976) 471–473. 280. K. Hoffmann, K. Mehlhorn, P. Rosenstiehl, R. E. Tarjan. Sorting Jordan Sequences in Linear Time Using Level-Linked Search Trees, Information and Control 68 (1986) 170–184. 281. J. Holub, M. Crochemore. On the Implementation of Compact DAWGs. CIAA 2002 (Proc. 7th Conference on Implementation and Applications of Automata) Springer. LNCS 26 08 (2003) 289–294. 282. W.-K. Hon, K. Sadakane, W.-K. Sung. Breaking a Time-and-Space Barrier in Constructing Full-Text Indices. FOCS 2003 (Proc. 44th IEEE Symposium Foundations of Computer Science), 251–260. 283. J. E. Hopcroft, J. D. Ullman. Set Merging Algorithms, SIAM Journal on Computing 2 (1973) 294–303. 284. M. Hoshi, T. Yuba. A Counterexample to a Monotonicity Property of kdTrees, Information Processing Letters 15 (1982) 169–173. 285. R. J. W. Housden. On String Concepts and Their Implementation, The Computer Journal 18 (1975) 150–156. 286. P. Høyer. A General Technique for Implementing Efficient Priority Queues. ISTCS 1995 (Proc. 3rd Israel Symposium on Theory of Computing and Systems), IEEE 1995, 57–66. 287. S. Huddleston, K. Mehlhorn. A New Data Structure for Representing Sorted Lists, Acta Informatica 17 (1982) 157–184. 288. L. C. K. Hui, C. Martel. Unsuccessful Search in Self-Adjusting Data Structures, Journal Algorithms 15 (1993) 447–481. 289. E. Hunt, M. P. Atkinson, R. W. Irving. Database Indexing for Large DNA and Protein Sequence Collections, The VLDB Journal 11 (2002) 256–271. 290. J. Iacono. Improved Upper Bounds for Pairing Heaps. SWAT 2000 (Proc. 7th Scandinavian Workshop on Algorithm Theory), Springer. LNCS 1851, 32–45. Список публикаций 407 291. J. Iacono. Alternatives to Splay Trees with O(log n) Worst-Case Access Time. SODA 2001 (Proc. 12th ACM-SIAM Symposium on Discrete Algorithms), 516–522. 292. J. Iacono, S. Langerman. Queaps. ISAAC 2002 (Proc. 13th International Symposium on Algorithms and Computation), Springer. LNCS 2518, 211–218. 293. S. Inenaga. Bidirectional Construction of Suffix Trees, Nordic Journal of Computing 10 (2003) 52–67. 294. H. Itoh, H. Tanaka. An Efficient Method for in Memory Construction of Suffix Arrays. SPIRE 1999 (Proc. 6th IEEE Symposium String Processing Information Retrieval), 81–88. 295. G. Jaeschke. Reciprocal Hashing. A Method for Generating Minimal Perfect Hashing Functions, Communications ACM 24 (1981) 829–831. 296. D. B. Johnson. Priority Queues with Update and Finding Minimum Spanning Trees, Information Processing Letters 4 (1975) 53–57. 297. A. Jonassen, O.-J. Dahl. Analysis of an Algorithm for Priority Queue Administration, BIT 15 (1975) 409–422. 298. D. W. Jones. A Note on Bottom-Up Skew Heaps, SIAM Journal on Computing 16 (1987) 108–110. 299. H. Jung. The d-Deap. A Simple and Cache-Aligned d-ary Deap, Information Processing Letters 93 (2005) 63–67. 300. H. Jung, S. Sahni. Supernode Binary Search Trees, International Journal Foundations of Computer Science, 14 (2003) 465–490. 301. A. Kaldewaij, B. Schoenmakers. The Derivation of a Tighter Bound for TopDown Skew Heaps, Information Processing Letters 37 (1991) 265–271. 302. H. Kaplan, C. Okasaki, R. E. Tarjan. Simple Confluently Persistent Catenable Lists, SIAM Journal on Computing 30 (2000) 965–977. 303. H. Kaplan, T. Milo, R. Shabo. A Comparison of Labeling Schemes for Ancestor Queries. SODA 2002 (Proc. 13th ACM-SIAM Symposium on Discrete Algorithms), 954–963. 304. H. Kaplan, E. Molad, R. E. Tarjan. Dynamic Rectangular Intersection with Priorities. STOC 2003 (Proc. 35th Annual ACM Symposium on Theory of Computing), 639–648. 305. H. Kaplan, N. Shafrir, R. E. Tarjan. Union-Find with Deletions. SODA 2002a (Proc. 13th ACM-SIAM Symposium on Discrete Algorithms), 19–28. 306. H. Kaplan, N. Shafrir, R. E. Tarjan. Meldable Heaps and Boolean UnionFind. STOC 2002b (Proc. 34th Annual ACM Symposium on Theory of Computing), 573–582. 307. J. Kӓrkkӓinen. Suffix Cactus. A Cross between Suffix Tree and Suffix Array, in CPM 1995 (Proc. 6th Annual Symposium on Combinatorial Pattern Matching), Springer. LNCS 937, 191–204. 408 Глава 11. Список публикаций 308. J. Kӓrkkӓinen, P. Sanders. Simple Linear Work Suffix Array Construction. ICALP 2003 (Proc. 30th International Colloquium on Automata, Languages, and Programming), Springer. LNCS 2719, 943–955. 309. J. Kӓrkkӓinen, P. Sanders, S. Burkhardt. Linear Work Suffix Array Construction, Journal of the ACM 53 (2006) 918–936. 310. P. L. Karlton, S. H. Fuller, R. E. Scroggs, E. B. Kaehler. Performance of Height-Balanced Trees, Communications ACM 19 (1976) 23–28. 311. R. M. Karp, R. E. Miller, A. L. Rosenberg. Rapid Identification of Repeated Patterns in Strings, Trees, and Arrays. STOC 1972 (Proc. 4th Annual ACM Symposium on Theory of Computing), 125–136. 312. T. Kasai, G. Lee, H. Arimura, S. Arikawa, K. Park. Linear-Time Longest-Common-Prefix Computation in Suffix Arrays and Its Applications, in CPM 2001 (Proc. 12th Annual Symposium on Combinatorial Pattern Matching), Springer. LNCS 2089, 181–192. 313. A. F. Kaupe, Jr. Algorithm 143. Treesort 1, Algorithm 144. Treesort 2, Communications ACM 5 (1962) p. 604. 314. M. Kempf, R. Bayer, U. Güntzer. Time Optimal Left to Right Construction of Position Trees, Acta Informatica 24 (1987) 461–474. 315. J. L. W. Kessels. On-the-Fly Optimization of Data Structures, Communications ACM 26 (1983) 895–901. 316. C. M. Khoong, H. W. Leong. Double-Ended Binomial Queues. ISAAC 1993 (Proc. 4th International Symposium on Algorithms and Computation), Springer. LNCS 762, 128–137. 317. C. M. Khoong, H. W. Leong. Relaxed Inorder Heaps, International Journal Foundations of Computer Science, 5 (1994) 111–128. 318. D. K. Kim, J. E. Jeon, H. Park. An Efficient Index Data Structure with the Capabilities of Suffix Trees and Suffix Arrays for Alphabets of Non-Negligible Size. SPIRE 2004a (Proc. 11th Symposium String Processing Information Retrieval), Springer. LNCS 3246, 138–149. 319. D. K. Kim, J. Jo, H. Park. A Fast Algorithm for Constructing Suffix Arrays for Fixed-Size Alphabet. WEA 2004b (Proc. 3rd Workshop on Experimental and Efficient Algorithms), Springer. LNCS 3059, 301–314. 320. D. K. Kim, Y. A. Kim, K. Park. Generalization of Suffix Arrays to Multi-Dimensional Matrices, Theoretical Computer Science 302 (2003) 223–238. 321. D. K. Kim, H. Park. The Linearized Suffix Tree and Its Succinct Representation. Proc. 2005 Korea-Japan Joint Workshop on Algorithms and Computation 51–58. 322. D. K. Kim, J. S. Sim, H. Park, K. Park. Linear-Time Construction of Suffix Arrays. CPM 2003 (Proc. 14th Annual Symposium on Combinatorial Pattern Matching), Springer. LNCS 2676, 186–199. 323. D. K. Kim, J. S. Sim, H. Park, K. Park. Constructing Suffix Arrays in Linear Time, Journal of Discrete Algorithms 3 (2005) 126–142. Список публикаций 409 324. A. Kirsch, M. Mitzenmacher. Less Hashing, Same Performance. Building a Better Bloom Filter. ESA 2006 (Proc. 14th Annual European Symposium on Algorithms), Springer. LNCS 4168, 456–467. 325. P. Kirschenhofer, H. Prodinger. The Path Length of Random Skip Lists, Acta Informatica 31 (1994) 775–792. 326. V. Klee. Can the Measure of n I −1[ai, bi ] Be Computed in Less Than O(n log n) Steps?, American Mathematical Monthly 84 (1977) 284–285. 327. R. Klein, D. Wood. A Tight Upper Bound for the Path Length of AVL Trees, Theoretical Computer Science 72 (1990) 251–264. 328. G. D. Knott. Hashing Fuctions, The Computer Journal 18 (1972) 265–278. 329. D. E. Knuth. The Art of Computer Programming, Vol 3. Sorting and Searching, Addison-Wesley 1973. (Есть рус. перевод: Д. Кнут. Искусство программирования для ЭВМ. Т. 3: Сортировка и поиск. М., Мир, 1978.) 330. D. E. Knuth. Computer Science and Its Relation to Mathematics, American Mathematical Monthly 81 (1974) 323–343. 331. P. Ko, S. Aluru. Space-Efficient Linear-Time Construction of Suffix Arrays. CPM 2003 (Proc. 14th Annual Symposium on Combinatorial Pattern Matching), Springer. LNCS 2676, 200–210. 332. P. Ko, S. Aluru. Space-Efficient Linear-Time Construction of Suffix Arrays, Journal of Discrete Algorithms 3 (2005) 143–156. 333. A. P. Korah, M. R. Kaimal. Dynamic Optimal Binary Search Tree, International Journal Foundations of Computer Science 1 (1990) 449–464. 334. A. P. Korah, M. R. Kaimal. A Short Note on Perfectly Balanced Binary Search Trees, The Computer Journal 35 (1992) 660–662. 335. S. R. Kosaraju. Insertion and Deletion in One-Sided Height-Balanced Trees, Communications ACM 21 (1978) 226–227. 336. S. R. Kosaraju. Localized Search in Sorted Lists. STOC 1981 (Proc. 13th Annual ACM Symposium on Theory of Computing), 62–69. 337. S. R. Kosaraju. Real-Time Pattern Matching and Quasi-Real-Time Construction of Suffix Arrays in. STOC 1994 (Proc. 26th Annual ACM Symposium on Theory of Computing), 310–316. 338. S. R. Kosaraju. Faster Algorithms for the Construction of Parametrized Suffix Trees. FOCS 1995 (Proc. 36th IEEE Symposium Foundations of Computer Science), 631–637. 339. E. Koutsoupias, D. S. Taylor. Tight Bounds for 2-Dimensional Indexing Schemes. PODS 1998 (Proc. 17th ACM Symposium on Principles of Database Systems), 44–51. 340. J. Král. Some Properties of the Scatter Storage Technique with Linear Probing, The Computer Journal 14 (1971) 145–149. 341. M. J. van Kreveld, M. H. Overmars. Concatenable Segment Trees. STACS 1989 (Proc. of the 6th Annual Symposium on Theoretical Aspects of Computer Science), Springer. LNCS 349, 493–504. 410 Глава 11. Список публикаций 342. M. J. van Kreveld, M. H. Overmars. Divided kd-Trees, Algorithmica 6 (1991) 840–858. 343. M. J. van Kreveld, M. H. Overmars. Union-Copy Structures and Dynamic Segment Trees, Journal ACM 40 (1993) 635–652. 344. T. Krovetz, P. Rogaway. Variationally Universal Hashing, Information Processing Letters 100 (2006) 36–39. 345. S. Kurtz. Reducing the Space Requirement of Suffix Trees, Software – Practice and Experience 29 (1999) 1149–1171. 346. T.W. Lai, D. Wood. Updating Almost Complete Trees or One Level Makes All the Difference. STACS 1990 (Proc. of the 7th Annual Symposium on Theoretical Aspects of Computer Science), Springer. LNCS 415, 188–194. 347. T. W. Lai, D. Wood. A Top-Down Updating Algorithm for Weight-Balanced Trees, International Journal Foundations of Computer Science 4 (1993) 309–324. 348. E. Langetepe, G. Zachmann. Geometric Data Structures for Computer Graphics, A K Peters 2006. 349. M. J. Lao. A New Data Structure for the Union-Find Problem, Information Processing Letters 9 (1979) 39–45. 350. K. S. Larsen. Amortized Constant Relaxed Rebalancing Using Standard Rotations, Acta Informatica 35 (1998) 859–874. 351. K. S. Larsen. AVL Trees with Relaxed Balance, Journal of Computer and System Sciences 61 (2000) 508–522. 352. K. S. Larsen. Relaxed Red-Black Trees with Group Updates, Acta Informatica 38 (2002) 565–586. 353. K. S. Larsen. Relaxed Multi-Way Trees with Group Updates, Journal of Computer and System Sciences 66 (2003) 657–670. 354. K. S. Larsen, R. Fagerberg. Efficient Rebalancing of B-Trees with Relaxed Balance, International Journal Foundations of Computer Science 7 (1996) 169–186. 355. P.-Å. Larson. Dynamic Hashing, BIT 18 (1978) 184–201. 356. P.-Å. Larson. Analysis of Repeated Hashing, BIT 20 (1980) 25–32. 357. P.-Å. Larson. Expected Worst-Case Performance of Hash Files, The Computer Journal 25 (1982) 347–352. 358. P.-Å. Larson. Analysis of Uniform Hashing, Journal of the ACM 30 (1983) 805–819. 359. P.-Å. Larson. Performance Analysis of a Single-File Version of Linear Hashing, The Computer Journal 28 (1985) 319–329. 360. P.-Å. Larson. Dynamic Hash Tables, Communications of the ACM 31 (1988) 448–457. 361. D.-T. Lee, C.-K. Wong. Worst-Case Analysis for Region and Partial Region Searches in Multidimensional Binary Search Trees and Balanced Quad Trees, Acta Informatica 9 (1977) 23–29. Список публикаций 411 362. D.-T. Lee, C.-K. Wong. Quintary Trees. A File Structure for Multidimensional Database Systems, ACM Transactions Database Systems 5 (1980) 339–353. 363. J. van Leeuwen, D. Wood. Dynamization of Decomposable Searching Problems, Information Processing Letters 10 (1980a) 51–56; с незначительными изменениями см. 11 (1980) p. 57. 364. J. van Leeuwen, D. Wood. The Measure Problem for Rectangular Ranges in d-Space, Journal of Algorithms 2 (1980b) 282–300. 365. J. van Leeuwen, D.Wood. Interval Heaps, The Computer Journal 36 (1993) 209–216. 366. C. Levcopoulos, M. Overmars. A Balanced Search Tree with O(1)Worst-Case Update Time, Acta Informatica 26 (1988) 269–277. 367. A. M. Liao. Three Priority Queue Applications Revisited, Algorithmica 7 (1992) 415–427. 368. G. Lindstrom. Scanning List Structures without Stacks and Tag Bits, Information Processing Letters 2 (1973) 47–51. 369. R. J. Lipton, P. J. Martino, A. Neitzke. On the Complexity of a Set-Union Problem. FOCS 1997 (Proc. 38th IEEE Symposium Foundations of Computer Science), 110–115. 370. M. C. Little, S. K. Shrivastava, N. A. Speirs. Using Bloom Filters to SpeedUp Name Lookup in Distributed Systems, The Computer Journal 45 (2002) 645–652. 371. W. Litwin. Virtual Hashing: A Dynamically Changing Hashing. VLDB 1978 (Proc. 4th IEEE Conference on Very Large Databases), 517–523. 372. W. Litwin. Linear Hashing. A New Tool for File and Table Addressing. VLDB 1980 (Proc. 6th IEEE Conference on Very Large Databases), 212–223. 373. W. Litwin. Trie Hashing. SIGMOD 1981 (Proc. ACM SIGMOD Conference on Management of Data), 19–29. 374. E. Lodi, F. Luccio. Split Sequence Hash Tables, Information Processing Letters 20 (1985) 131–136. 375. M. Loebl, J. Nešetřil. Linearity and Unprovability of Set Union Strategies, Journal of Algorithms 23 (1997) 207–220. 376. D. B. Lomet. Bounded Index Exponential Hashing, ACM Transactions on Database Systems 8 (1983) 136–165. 377. D. B. Lomet, B. Salzberg. The hB-Tree: A Multiattribute Indexing Method with Good Guaranteed Performance, ACM Transactions on Database Systems 15 (1990) 625–658. 378. F. Luccio, L. Pagli. On the Upper Bound for the Rotation Distance of Binary Trees, Information Processing Letters 31 (1989) 57–60. 379. G. S. Lueker. A Data Structure for Orthogonal Range Queries. FOCS 1978 (Proc. 19th IEEE Symposium Foundations of Computer Science), 28–34. 412 Глава 11. Список публикаций 380. G. S. Lueker, M. Molodowitch. More Analysis of Double Hashing. STOC 1988 (Proc. 20th Annual ACM Symposium on Theory of Computing), 354–359. 381. G. S. Lueker, D. E. Willard. A Data Structure for Dynamic Range Queries, Information Processing Letters 15 (1982) 209–213. 382. R. W. P. Luk. Near Optimal β-Heap, The Computer Journal 42 (1999) 391–399. 383. G. Lyon. Achieving Hash Table Searches in One or Two Bucket Probes, The Computer Journal 28 (1985) 313–318. 384. M. G. Maaß. Linear Bidirectional On-Line Construction of Affix Trees, Algorithmica 37 (2003) 43–74. 385. J. A. T. Maddison. Fast Lookup in Hash Tables with Direct Rehashing, The Computer Journal 23 (1980) 188–189. 386. R. Maelbráncke, H. Olivié. Dynamic Tree Rebalancing Using Recurrent Rotations, International Journal Foundations of Computer Science 5 (1994) 247–260. 387. D. Maier. An Efficient Method for Storing Ancestor Information in Trees, SIAM Journal on Computing 8 (1979) 599–618. 388. D. Maier, S. C. Salveter. Hysterical B-Trees, Information Processing Letters 12 (1981) 199–202. 389. H. G. Mairson. The Effect of Table Expansion on the Program Complexity of Perfect Hash Functions, BIT 32 (1992) 430–440. 390. B. S. Majewski, N. C. Wormald, G. Havas, Z. J. Czech. A Family of Perfect Hashing Methods, The Computer Journal 39 (1996) 547–554. 391. M. E. Majster, A. Reiser. Efficient Online Construction and Correction of Position Trees, SIAM Journal on Computing 9 (1980) 785–807. 392. E. Mӓkinen. On Top-Down Splaying, BIT 27 (1987) 330–339. 393. E. Mӓkinen. On the Rotation Distance of Binary Trees, Information Processing Letters 26 (1988) 271–272. 394. C. Makris, A. Tsakalidis, K. Tsichlas. Reflected Min-Max Heaps, Information Processing Letters 86 (2003) 209–214. 395. E. G. Mallach. Scatter Storage Techniques: A Unifying Viewpoint and a Method for Reducing Retrieval Times, The Computer Journal 20 (1977) 137–140. 396. K. Maly. Compressed Tries, Communications ACM 19 (1978) 409–415. 397. U. Manber, G. Myers. Suffix Arrays: A New Method for On-Line String Searching, SIAM Journal on Computing 22 (1993) 935–948. 398. H. Mannila, E. Ukkonen. The Set Union Problem with Backtracking. ICALP 1986 (Proc. 13th International Colloquium on Automata, Languages, and Programming), Springer. LNCS 226, 236–243. 399. Y. Mansour, N. Nisan, P. Tiwari. The Computational Complexity of Universal Hashing, Theoretical Computer Science 107 (1993) 121–133. Список публикаций 413 400. G. Manzini, P. Ferragina. Engineering a Lightweight Suffix Array Construction Algorithm, Algorithmica 40 (2004) 33–50. 401. G. Markowsky, J. L. Carter, M. N. Wegman. Analysis of a Universal Class of Hash Functions. MFCS 1978 (Proc. Conference on Mathematical Foundations of Computer Science), Springer. LNCS 64, 345–354. 402. C. Martel. Self-Adjusting Multi-Way Search Trees, Information Processing Letters 38 (1991) 135–141. 403. W. A. Martin, D. N. Ness. Optimal Binary Trees Grown with a Sorting Algorithm, Communications ACM 15 (1972) 88–93. 404. C. Martinez, S. Roura. Randomized Binary Search Trees, Journal of the ACM 4 (1998) 288–323. 405. H. A. Maurer, T. Ottmann, H.-W. Six. Implementing Dictionaries Using Binary Trees of Very Small Height, Information Processing Letters 5 (1976) 11–14. 406. E. M. McCreight. A Space Economical Suffix Tree Construction Algorithm, Journal of the ACM 23 (1976) 262–272. 407. E. M. McCreight. Pagination of B∗-Trees with Variable-Length Records, Communications ACM 20 (1977) 670–674. 408. C. J. H. McDiarmid, B.A. Reed. Building Heaps Fast, Journal of Algorithms 10 (1989) 352–365. 409. K. Mehlhorn. Dynamic Binary Search, SIAM Journal on Computing 8 (1979) 175–198. 410. K. Mehlhorn. On the Program Size of Perfect and Universal Hash Functions. FOCS 1982 (Proc. 23rd Annual IEEE Symposium on Foundations of Computer Science), 170–175. 411. K. Mehlhorn, S. Näher, H. Alt. A Lower Bound on the Complexity of the UnionSplit-Find Problem, SIAM Journal on Computing 17 (1988) 1093–1102. 412. K. Mehlhorn, M. H. Overmars. Optimal Dynamization of Decomposable Searching Problems, Information Processing Letters 12 (1981) 93–98. 413. D. P. Mehta, S. Sahni (eds.). Handbook of Data Structures, CRC Press/Chapman & Hall 2005. 414. M. Mitzenmacher. Compressed Bloom Filters. PODC 2001 (Proc. 20th ACM Symposium on Principles of Distributed Computing), 144–150. 415. K. Morimoto, H. Iriguchi, J.-I. Aoe. A Method for Compressing Trie Structures, Software – Practice and Experience 24 (1994) 265–288. 416. J. M. Morris. Traversing Binary Trees Simply and Cheaply, Information Processing Letters 9 (1979) 197–200. 417. D. R. Morrison. PATRICIA – Practical Algorithm to Retrieve Information Coded in Alphanumeric, Journal of the ACM 15 (1968) 514–534. 418. J. K. Mullin. Change Area B-Trees. A Technique to Aid Error Recovery, The Computer Journal 24 (1981a) 367–373. 414 Глава 11. Список публикаций 419. J. K. Mullin. Tightly Controlled Linear Hashing Without Separate Overflow Storage, BIT 21 (1981b) 390–400. 420. J. K. Mullin. A Second Look at Bloom Filters, Communications ACM 26 (1983) 570–571. 421. J. K. Mullin. Spiral Storage. Efficient Dynamic Hashing with Constant Performance, The Computer Journal 28 (1985) 330–334. 422. J. K. Mullin. Accessing Textual Documents Using Compressed Indexes of Small Bloom Filters, The Computer Journal 30 (1987) 343–348. 423. J. K. Mullin. A Caution on Universal Classes of Hash Functions, Information Processing Letters 37 (1991) 247–256. 424. J. I. Munro, T. Papadakis, R. Sedgewick. Deterministic Skip Lists. SODA 1992 (Proc. 3rd ACM-SIAM Symposium on Discrete Algorithms), 367–375. 425. J. I. Munro, V. Raman, S. S. Rao. Space Efficient Suffix Trees, Journal of Algorithms 39 (2001) 205–222. 426. E. W. Myers. Efficient Applicative Data Types. POPL 1984 (Proc. 11th ACM Symposium on Principles of Programming Languages), 66–75. 427. J. C. Na. Linear-Time Construction of Compressed Suffix Arrays Using O(n log n)-Bit Working Space for Large Alphabets. CPM 1995 (Proc. 16th Annual Symposium on Combinatorial Pattern Matching), Springer. LNCS 3537 (2005) 57–67. 428. J. Nievergelt. Binary Search Trees and File Organization, ACM Computing Surveys 6 (1974) 195–207. 429. J. Nievergelt, H. Hinterberger, K. C. Sevcik. The Grid File. An Adaptable, Symmetric Multikey File Structure, ACM Transactions on Database Systems 9 (1984) 38–71. 430. J. Nievergelt, E. M. Reingold. Binary Trees of Bounded Balance, SIAM Journal on Computing 2 (1973) 33–43. 431. J. Nievergelt, P. Widmayer. Spatial Data Structures. Concepts and Design Choices. Handbook of Computational Geometry, J.-R. Sack, J. Urrutia, eds., Elsevier 1999, 723–764. 432. J. Nievergelt, C.-K. Wong. Upper Bounds for the Total Path Length of Binary Trees, Journal ACM 20 (1973) 1–6. 433. S. Nilsson, M. Tikkanen. Implementing a Dynamic Compressed Trie. WAE 1998 (Proc. 2nd Workshop on Algorithms Engineering), Max-Planck-Institut für Informatik, Saarbrücken 1998, 25–36. 434. S. Nilsson, M. Tikkanen. An Experimental Study of Compression Methods for Dynamic Tries, Algorithmica 33 (2002) 19–33. 435. H. Noltemeier. On a Generalization of Heaps. WG 1980 (Proc. Workshop on Graph-Theoretic Concepts in Computer Science), Springer. LNCS 100 (1981) 127–136. Список публикаций 415 436. O. Nurmi, E. Soisalon-Soininen. Chromatic Binary Search Trees – A Structure for Concurrent Rebalancing, Acta Informatica 33 (1996) 547–557. 437. O. Nurmi, E. Soisalon-Soininen, D.Wood. Concurrency Control in Database Structures with Relaxed Balance. PODS 1987 (Proc. 6th ACM Symposium on Principles of Database Systems), 170–176. 438. M. Nykӓnen, E. Ukkonen. Finding Lowest Common Ancestors in Arbitrarily Directed Trees, Information Processing Letters 50 (1994) 307–310. 439. S. Olariu, C. Overstreet, Z. Wen. A Mergeable Double-Ended Priority Queue, The Computer Journal 34 (1991) 423–427. 440. H. J. Olivié. On the Relationship between Son-Trees and Symmetric Binary B-Trees, Information Processing Letter 10 (1980) 4–8. 441. H. J. Olivié. A New Class of Balanced Search Trees. Half-Balanced Binary Search Trees, RAIRO Informatique Th´eorique 16 (1982) 51–71. 442. R. Orlandic, H. M. Mahmoud. Storage Overhead of O-Trees, B-Trees and Prefix Btrees. A Comparative Analysis, International Journal Foundations of Computer Science 7 (1996) 209–226. 443. T. Ottmann, D. S. Parker, A. L. Rosenberg, H.-W. Six, D. Wood. Minimal-Cost Brother Trees, SIAM Journal on Computing 13 (1984) 197–217. 444. T. Ottmann, H.-W. Six. Eine neue Klasse von ausgeglichenen Binärbäumen, Angewandte Informatik 9 (1976) 395–400. 445. T. Ottmann, H.-W. Six, D. Wood. Right Brother Trees, Communications ACM 21 (1978) 769–776. 446. T. Ottmann, D. Wood. Deletion in One-Sided Height-Balanced Search Trees, International Journal Computational Mathematics 6 (1978) 265–271. 447. S. F. Ou, A.L. Tharp. Hash Storage Utilization for Single-Probe Retrieval Linear Hashing, The Computer Journal 34 (1991) 455–468. 448. M. Ouksel, P. Scheuermann. Implicit Data Structures for Linear Hashing Schemes, Information Processing Letters 29 (1988) 183–189. 449. M. Overmars. Dynamization of Order Decomposable Set Problems, Journal of Algorithms 2 (1981a) 245–260. 450. M. Overmars. General Methods for «All Elements» and «All Pairs» Problems, Information Processing Letters 12 (1981b) 99–102. 451. M. Overmars. An O(1) Average Time Update Scheme for Balanced Search Trees, Bulletin of the EATCS 18 (1982) 27–29. 452. M. Overmars. The Design of Dynamic Data Structures, Springer. LNCS 156, 1983. 453. M. Overmars, J. van Leeuwen. Dynamically Maintaining Configurations in the Plane. STOC 1980 (Proc. 12th Annual ACM Symposium on Theory of Computing), 135–145. 454. M. Overmars, J. van Leeuwen. Some Principles for Dynamizing Decomposable Searching Problems, Information Processing Letters 12 (1981a) 49–53. 416 Глава 11. Список публикаций 455. M. Overmars, J. van Leeuwen. Worst-Case Optimal Insertion and Deletion Methods for Decomposable Searching Problems, Information Processing Letters 12 (1981b) 168–173. 456. M. Overmars, C.-K. Yap. New Upper Bounds in Klee's Measure Problem, SIAM Journal on Computing 20 (1991) 1034–1045. 457. A. Pagh, R. Pagh, S.S. Rao. An Optimal Bloom Filter Replacement. SODA 2005 (Proc. 16th ACM-SIAM Symposium on Discrete Algorithms), 823–829. 458. R. Pagh, F. F. Rodler. Lossy Dictionaries. ESA 2001 (Proc. of the 9th Annual European Symposium on Algorithms), Springer. LNCS 2161, 300–311. 459. R. Pagh, F. F. Rodler. Cuckoo Hashing, Journal of Algorithms 51 (2004) 122– 144. 460. L. Pagli. Self-Adjusting Hash Tables, Information Processing Letters 21 (1985) 23–25. 461. T. Papadakis, J. I. Munro, P. V. Poblete. Average Search and Update Costs in Skip Lists, BIT 32 (1992) 316–332. 462. M. Pǎtraşcu. Lower Bounds for 2-Dimensional Range Counting. STOC 2007 (Proc. 39th Annual ACM Symposium on Theory of Computing), 40–46. 463. M. Pǎtraşcu, E. D. Demaine. Tight Bounds for the Partial-Sums Problem. SODA 2004 (Proc. 15thACM-SIAMSymposium on Discrete Algorithms), 20–29. 464. P. K. Pearson. Fast Hashing of Variable-Length Text Strings, Communications ACM 33 (1990) 677–680. 465. S. Pettie. Towards a Final Analysis of Pairing Heaps. FOCS 2005 (Proc. 46th Annual IEEE Symposium on Foundations of Computer Science), 174–183. 466. P. V. Poblete, J. I. Munro. Last-Come-First-Served Hashing, Journal of Algorithms 10 (1989) 228–248. 467. J. A. La Poutr´e. New Techniques for the Union-Find Problem. SODA 1990a (Proc. 1st ACM-SIAM Symposium on Discrete Algorithms), 54–63. 468. J. A. La Poutré. Lower Bounds for the Union-Find and the Split-Find Problem on Pointer Machines. STOC 1990b (Proc. 22nd Annual ACM Symposium on Theory of Computing), 583–591. 469. O. Procopiuc, P. K. Agarwal, L. Arge, J. S. Vitter. Bkd-Tree. A Dynamic Scalable kd-Tree. SSTD 2003 (Proc. 8th International Symposium on Spatial and Temporal Databases) Springer. LNCS 2750, 46–65. 470. W. Pugh. Skip Lists. A Probabilistic Alternative to Balanced Trees, Communications ACM 33 (1990) 437–449. 471. S. J. Puglisi, W. F. Smyth, A. H. Turpin. A Taxonomy of Suffix Array Construction Algorithms, Computing Surveys 39 (2007) Article 4, 31 pages. 472. K.-J. Räihä, S. H. Zweben. An Optimal Insertion Algorithm for One-Sided Height-Balanced Binary Search Trees, Communications ACM 22 (1979) 508–512. Список публикаций 417 473. M. V. Ramakrishna. Analysis of Random Probing Hashing, Information Processing Letters 31 (1989a) 83–90. 474. M. V. Ramakrishna. Practical Performance of Bloom Filters and Parallel Free-Text Searching, Communications ACM 32 (1989b) 1237–1239. 475. K. Ramamohanarao, J. W. Lloyd. Dynamic Hashing Schemes, The Computer Journal 25 (1982) 478–485. 476. N. S. V. Rao, V. K. Vaishnavi, S. S. Iyengar. On the Dynamization of Data Structures, BIT 28 (1988) 37–53. 477. K. V. Ravi Kanth, A. Singh. Optimal Dynamic Range Searching in Non-Replicating Index Structures. ICDT 1999 (Proc. 7th International Conference on Database Theory) Springer. LNCS 1540, 257–276. 478. M. Regnier. Analysis of Grid File Algorithms, BIT 25 (1985) 335–357. 479. R. L. Rivest. Optimal Arrangement of Keys in a Hash Table, Journal of the ACM 25 (1978) 200–209. 480. J. T. Robinson. The kdB-Tree. A Search Structure for Large Multidimensional Indexes. SIGMOD 1981 (Proc. 1981 ACM SIGMOD Conference on Management of Data), 10–18. 481. J. M. Robson. An Improved Algorithm for Traversing Binary Trees without Auxiliary Stack, Information Processing Letters 2 (1973) 12–14. 482. J.-R. Sack, T. Strothotte. An Algorithm for Merging Heaps, Acta Informatica 22 (1985) 171–186. 483. T. J. Sager. A Polynomial-Time Generator for Minimal Perfect Hash Functions, Communications ACM 28 (1985) 522–532. 484. H. Samet. The Design and Analysis of Spatial Data Structures, Addison-Wesley 1990. 485. H. Samet. Foundations of Multidimensional and Metric Data Structures, Morgan Kaufmann 2006. 486. V. Samoladas, D. P. Miranker. A Lower Bound Theorem for Indexing Schemes and Its Application to Multidimensional Range Queries. PODS 1998 (Proc. 17th ACM Symposium on Principles of Database Systems), 44–51. 487. J. B. Saxe. On the Number of Range Queries in k-Space, Discrete Applied Mathematics 1 (1979) 217–225. 488. J. P. Schmidt, A. Siegel. The Spatial Complexity of Oblivious k-Probe Hash Functions, SIAM Journal on Computing 19 (1990) 775–786. 489. B. Schoenmakers. A Systematic Analysis of Splaying, Information Processing Letter 45 (1993) 41–50. 490. B. Schoenmakers. A Tight Lower Bound for Top-Down Skew Heaps, Information Processing Letter 61 (1997) 279–284. 491. M. Scholl. New File Organizations Based on Dynamic Hashing, ACM Transactions on Database Systems 6 (1981) 194–211. 418 Глава 11. Список публикаций 492. R. Seidel, C. R. Aragon. Randomized Search Trees, Algorithmica 16 (1996) 464–497. 493. R. Seidel, M. Sharir. Top-Down Analysis of Path Compression, SIAM Journal on Computing 34 (2005) 515–525. 494. S. Sen. Some Observations on Skip-Lists, Information Processing Letter 39 (1991) 173–176. 495. S. Sen. Fractional Cascading Revisited, Journal of Algorithms 19 (1995) 161– 172. 496. M. Sharir. Fast Composition of Sparse Maps, Information Processing Letters 15 (1982) 183–185. 497. M. A. Shepherd, W. J. Phillips, C.-K. Chu. A Fixed-Size Bloom Filter for Searching Textual Documents, The Computer Journal 89 (1989) 212–219. 498. M. Sherk. Self-Adjusting k-ary Search Trees, Journal of Algorithms 19 (1995) 25–44. 499. A. Siegel. On Universal Classes of Fast High-Performance Hash Functions, Their Time-Space Tradeoff, and Their Applications. FOCS 1989 (Proc. 30th IEEE Symposium Foundations of Computer Science), 20–25. 500. A. Siegel. On Universal Classes of Extremely Random Constant-Time Hash Functions, SIAM Journal on Computing 33 (2004) 505–543. 501. Y. V. Silva-Filho. Average Case Analysis of Region Search in Balanced k-dTrees, Information Processing Letters 8 (1979) 219–223. 502. Y. V. Silva-Filho. Optimal Choice of Discriminators in a Balanced k-d-Trees, Information Processing Letters 13 (1981) 67–70. 503. D. D. Sleator, R. E. Tarjan. A Data Structure for Dynamic Trees, Journal of Computer and System Sciences 26 (1983) 362–391. 504. D. D. Sleator, R. E. Tarjan. Self-Adjusting Binary Search Trees, Journal ACM 32 (1985) 652–686. 505. D. D. Sleator, R. E. Tarjan. Self-Adjusting Heaps, SIAM Journal on Computing 15 (1986) 52–69. 506. D. D. Sleator, R. E. Tarjan, W. P. Thurston. Rotation Distance, Triangulations, and Hyperbolic Geometry, Journal AMS 1 (1988) 647–682. 507. M. H. M. Smid. A Data Structure for the Union-Find Problem Having Good Single-Operation Complexity, Algorithms Review 1 (1990) 1–11 (Newsletter of the ESPRIT II Basic Research Actions Program, Project 3075 ALCOM). 508. E. Soisalon-Soininen, P. Widmayer. Relaxed Balancing in Search Trees. Advances in Algorithms, Languages, and Complexity, Kluwer 1997, 267–283. 509. S. Soule. A Note on the Nonrecursive Traversal of Binary Trees, The Computer Journal 20 (1977) 350–352. 510. R. Sprugnoli. Perfect Hashing Functions. A Single Probe Retrieving Method for Static Sets, Communications ACM 20 (1977) 841–850. Список публикаций 419 511. R. Sprugnoli. On the Allocation of Binary Trees to Secondary Storage, BIT 21 (1981) 305–316. 512. J. T. Stasko, J. S. Vitter. Pairing Heaps. Experiments and Analysis, Communications ACM 30 (1987) 234–249. 513. D. Stinson. Universal Hashing and Authentification Codes, Designs, Codes and Cryptography 4 (1994) 369–380. 514. Q. F. Stout, B. L. Warren. Tree Rebalancing in Optimal Time and Space, Communications ACM 29 (1986) 902–908. 515. T. Strothotte, P. Eriksson, S. Vallner. A Note on Constructing Min-MaxHeaps, BIT 29 (1989) 251–256. 516. T. Strothotte, J.-R. Sack. Heaps in Heaps, Congressus Numerantium 49 (1985) 223–235. 517. A. Subramanian. An Explanation of Splaying, Journal of Algorithms 20 (1996) 512–525. 518. R. Sundar. Worst-Case Data Structures for the Priority Queue with Attrition, Information Processing Letters 31 (1989) 69–75. 519. F. Suraweera. Use of Doubly Chained Tree Structures in File Organization for Optimal Searching, The Computer Journal 29 (1986) 52–59. 520. E. H. Sussenguth. Use of Tree Structures for Processing Files, Communications of the ACM 6 (1963) 272–279. 521. M. al-Suwaiyel, E. Horowitz. Algorithms for Trie Compaction, ACM Transactions on Database Systems 9 (1984) 243–263. 522. H. Suzuki, A. Ishiguro, T. Nishizeki. Variable-Priority Queue and Doughnut Rooting, Journal of Algorithms 13 (1992) 606–635. 523. T. Takaoka. Theory of Trinomial Heaps. COCOON 2000 (Proc. 6th International Symposium on Computing and Combinatorics), Springer. LNCS 1858, 362–372. 524. T. Takaoka. Theory of 2-3 Heaps, Discrete Applied Mathematics 126 (2003) 115–128. 525. M. Talamo, P. Vocca. A Data Structure for Lattices Representation, Theoretical Computer Science 175 (1997) 373–392. 526. M. Talamo, P. Vocca. An Efficient Data Structure for Lattice Operations, SIAM Journal on Computing 28 (1999) 1783–1805. 527. M. Tamminen. Order Preserving Extendible Hashing and Bucket Tries, BIT 21 (1981) 419–435. 528. M. Tamminen. Extensible Hashing with Overflow, Information Processing Letters 15 (1982) 227–232. 529. R. E. Tarjan. Efficiency of a Good But Not Linear Set Union Algorithm, Journal of the ACM 22 (1975) 215–225. 530. R. E. Tarjan. Applications of Path Compression on Balanced Trees, Journal of the ACM 26 (1979a) 690–715. 420 Глава 11. Список публикаций 531. R. E. Tarjan. A Class of Algorithms which Require Nonlinear Time to Maintain Disjoint Sets, Journal of Computer and System Sciences 18 (1979b) 110–127. 532. R. E. Tarjan. Updating a Balanced Search Tree in O(1) Rotations, Information Processing Letters 16 (1983a) 253–257. 533. R. E. Tarjan. Data Structures and Network Algorithms, CBMS Lecture Note Series 44, SIAM (1983b). 534. R. E. Tarjan, J. van Leeuwen. Worst-Case Analysis of Set Union Algorithms, Journal of the ACM 31 (1984) 245–281. 535. R. E. Tarjan, A.C.-C. Yao. Storing a Sparse Table, Communications ACM 22 (1979) 606–611. 536. M. Thorup. Equivalence Between Priority Queues and Sorting. FOCS 2002 (Proc. 43rd Annual IEEE Symposium on Foundations of Computer Science), 125–134. 537. Y. Tian, S. Tata, R. A. Hankins, J.M. Patel. Practical Methods for Constructing Suffix Trees, The VLDB Journal 14 (2005) 281–299. 538. A. K. Tsakalidis. Maintaining Order in a Generalized Linked List, Acta Informatica 21 (1984) 101–112. 539. A. K. Tsakalidis. AVL-Trees for Localized Search, Information and Computation 67 (1985) 173–194. 540. A. K. Tsakalidis. The Nearest Common Ancestor in a Dynamic Tree, Acta Informatica 25 (1988) 37–54. 541. E. Ukkonen. On-Line Construction of Suffix Trees, Algorithmica 14 (1995) 249–260. 542. J. D. Ullman. A Note on the Efficiency of Hashing Functions, Journal of the ACM 19 (1972) 569–575. 543. P. M. Vaidya. Space-Time Trade-Offs for Orthogonal Range Queries, SIAM Journal on Computing 18 (1989) 748–758. 544. V. K. Vaishnavi. Computing Point Enclosures, IEEE Transactions on Computers 31 (1982) 22–29. 545. V. K. Vaishnavi. Weighted Leaf AVL-Trees, SIAM Journal on Computing 16 (1987) 503–537, also Erratum 19 (1990) 591. 546. J. S. Vitter. External Memory Algorithms and Data Structures, ACM Computing Surveys 33 (2001) 209–271. 547. J. Vuillemin. A Data Structure for Manipulating Priority Queues, Communications of the ACM 21 (1978) 309–315. 548. J. Vuillemin. A Unifying Look at Data Structures, Communications of the ACM 23 (1980) 229–239. 549. I. Wegener. Bottom-Up Heapsort, A New Variant of Heapsort Beating an Average Quicksort (If n Is Not Very Small), Theoretical Computer Science 118 (1993) 81–98. Список публикаций 421 550. P. Weiner. Linear Pattern Matching Algorithms. Proc. of the 14th Annual IEEE Symposium on Switching and Automata Theory, 1973, 1–11. 551. M. A. Weiss. Linear-Time Construction of Treaps and Cartesian Trees, Information Processing Letters 52 (1994) 253–257; см. также замечание в след. томе 53 (1995) 127. 552. Z. Wen. New Algorithms for the LCA Problem and the Binary Tree Reconstruction Problem, Information Processing Letters 51 (1994) 11–16. 553. J. Westbrook, R. E. Tarjan. Amortized Analysis of Algorithms for Set Union with Backtracking, SIAM Journal on Computing 18 (1989) 1–11. 554. D. E. Willard. Maintaining Dense Sequential Files in a Dynamic Environment. STOC 1982 (Proc. 14th Annual ACM Symposium on Theory of Computing), 114–121. 555. D. E. Willard. New Data Structures for Orthogonal Range Queries, SIAM Journal on Computing 14 (1985) 232–253. 556. D. E. Willard. Good Worst-Case Algorithms for Inserting and Deleting Records in Dense Sequential Files. ACM SIGMOD Newsletter 15 (June 1986) 251–260. 557. D. E. Willard. A Density Control Algorithm for Doing Insertions and Deletions in a Sequentially Ordered File in Good Worst-Case Time, Information and Computation 97 (1992) 150–204. 558. D. E. Willard, G. S. Lueker. Adding Range Restriction Capability to Dynamic Data Structures, Journal of the ACM 32 (1985) 597–617. 559. J. W. J. Williams. Algorithm 232. Heapsort, Communications of the ACM 7 (1964) 347–348. 560. J. Wogulis. Self-Adjusting and Split Sequence Hash Tables, Information Processing Letters 30 (1989) 185–188. 561. G. Xunrang, Z. Yuzhang. A New Heapsort Algorithm and the Analysis of Its Complexity, The Computer Journal 33 (1990) 281. 562. W.-P. Yang, M. W. Du. A Backtracking Method for Constructing Perfect Hash Functions from a Set of Mapping Functions, BIT 25 (1985) 148–164. 563. A. C.-C. Yao. Should Tables Be Sorted?, Journal of the ACM 28 (1981) 615–628. 564. A. C.-C. Yao. Space-Time Tradeoff for Answering Range Queries. STOC 1982 (Proc. 14th Annual ACM Symposium on Theory of Computing) 128–136. 565. A. C.-C. Yao. Uniform Hashing Is Optimal, Journal of the ACM 32 (1985a) 687–693. 566. A. C.-C. Yao. On Optimal Arrangements of Keys with Double Hashing, Journal of Algorithms 6 (1985b) 253–264. 567. A. C.-C. Yao. On the Complexity of Maintaining Partial Sums, SIAM Journal on Computing 14 (1985c) 277–288. 422 Глава 11. Список публикаций 568. A. C.-C. Yao, F. F. Yao. Dictionary Look-Up with One Error, Journal of Algorithms 25 (1997) 194–202. 569. N. Zivani, H. J. Olivié, G. H. Gonnet. The Analysis of an Improved Symmetric Binary B-Tree Algorithm, The Computer Journal 28 (1985) 417–425. 570. S. H. Zweben, M. A. McDonald. An Optimal Method for Deletion in One-Sided Height-Balanced Trees, Communications of the ACM 21 (1978) 441–445. Предметный указатель Символы Б (a, b)-деревья 82 блочные структуры данных 193 Г B B-деревья 82 F FIFO (First In, First Out – первым вошел, первым вышел) 23 K k-деревья 98 kd-деревья 197 L LIFO (Last In, First Out – последним вошел, первым вышел) 16 P Patricia, дерево 335 R RAM (Random Access Machine – машина с произвольным доступом) 383 S SBB-деревья 96 А Аккермана функция 389 граничный путь 344 Д двоичные кучи 228 двусторонние кучи 253 двусторонняя очередь 30 деревья выровненные и кучи 203 хеш-деревья 375 деревья поиска 37, 62 выровненные по весу 72 выровненные по высоте 62 высота 43 деревья с всплывающими узлами 126 деревья сумм взвешенных интервалов 168 деревья интервалов с ограниченной максимальной суммой весов 173 деревья многомерных интервалов 190 деревья объединения интервалов 161 деревья пересекающихся отрезков 149 деревья полуоткрытых интервалов 154 деревья прямоугольных областей 178 интервалы ключей 51 красно-черные деревья 96 выравнивание 107 на множестве интервалов 149 общие свойства и преобразования 40 оптимальные 53 основные операции 44 пальцевые деревья 118 перераспределение узлов 86 перестройка 123 424 Предметный указатель повторяющиеся ключи 50 преобразование в списки 59 префиксные деревья 320 с постоянным временем обновления 116 соединение и разделение выровненных деревьев 144 соединение узлов 86 удаление 60 Фибоначчи 63 (a, b)-деревья 82 B-деревья 82 k-деревья 98 kd-деревья 197 SBB-деревья 96 динамическое выделение памяти 30 деревья интервалов с ограниченной максимальной суммой весов 173 деревья многомерных интервалов 190 деревья объединения интервалов 161 деревья пересекающихся отрезков 149 деревья полуоткрытых интервалов 154 деревья прямоугольных областей 178 деревья с всплывающими узлами 126 деревья сумм взвешенных интервалов 168 К каноническое разбиение интервала 155 косые кучи 225 красно-черные деревья 96 выравнивание 107 кучи 202 в как упорядоченные и полуупорядоченные деревья 213 в массиве 207 и выровненные деревья 203 изменение ключей в 236 двоичные 228 двусторонние 253 левосторонние 218 максимальные (max-heap) 202 минимальные (min-heap) 202 многомерные 253 невозрастающие (min-heap) 202 неубывающие (min-heap) 202 оптимальной сложности 248 структуры с постоянным временем обновления 257 Фибоначчи 238 Л левосторонние кучи 218 линейный порядок поддержание 301 М медленно растущие функции 388 многомерные кучи 253 модель внешней памяти 385 Н названия структур данных 386 непересекающиеся множества 263 операции 264 операция insert 264 операция join 264 операция same_class 264 объединение классов разделов 264 по рангу 265 сжатие пути 265 О обратная функция Аккермана 268 ориентированные корневые деревья 291 проблемы 291 очередь 23 двусторонняя 30 циклические списки 27 П пальцевые деревья 118, пальцевый поиск 122 поддержание линейного порядка 301 подсчет точек и модель полугруппы 195 поиск в глубину 23 Предметный указатель 425 поиск в ширину 23 преобразование структур данных 305 префиксные деревья 320 суффиксные массивы 347 суффиксные ссылки 343 Р теневые копии структур в массиве 32 разбиваемые задачи поиска 305 структуры данных 305 разделение списка 287 рекурсивное условие 81 решение линейных рекуррентных соотношений 386 С система непересекающихся множеств с поддержкой копирования 277 операция copy_item 278 операция copy_set 278 операция create_item 278 операция create_set 278 операция destroy_item 278 операция destroy_set 278 операция insert 278 операция join_items 278 операция join_sets 278 операция list_items 278 операция list_sets 278 словари, допускающие появление ошибок в запросах 337 списки с пропуском элементов 137 ссылочная машина 383 стек 16 реализация в виде связного списка 23 строковые структуры данных 319 префиксные деревья 320 структуры данных строковые 319 динамические 305 с сохранением истории 314 преобразование 305 суффиксные деревья 336, 341 Т Ф Фибоначчи деревья 63 фильтры Блума 380 функции медленно растущие 388 Х хеш-деревья 375 хеш-таблицы 355 виртуальное хеширование 377 динамическое хеширование 377 идеальные хеш-функции 370 линейное хеширование 377 разрешение конфликтов 355 расширяемое хеширование 376 универсальные семейства хеш-функций 360 хеш-функции идеальные 370 универсальные семейства 360 хеширование динамическое 377 расширяемое 376 виртуальное 377 линейное 377 Ц циклические списки 27 Э элементарные структуры 16 Книги издательства «ДМК ПРЕСС» можно купить оптом и в розницу в книготорговой компании «Галактика» (представляет интересы издательств «ДМК ПРЕСС», «СОЛОН ПРЕСС», «КТК Галактика»). Адрес: г. Москва, пр. Андропова, 38; Тел.: +7(499) 782-38-89. Электронная почта: books@alians-kniga.ru. При оформлении заказа следует указать адрес (полностью), по которому должны быть высланы книги; фамилию, имя и отчество получателя. Желательно также указать свой телефон и электронный адрес. Эти книги вы можете заказать и в интернет-магазине: www.galaktika-dmk.com. Петер Брасс Усовершенствованные структуры данных Главный редактор Мовчан Д. А. dmkpress@gmail.com Зам. главного редактора Сенченкова Е. А. Перевод Борисов Е. В., Киселев А. Н. Корректор Синяева Г. И. Верстка Паранская Н. В. Дизайн обложки Мовчан А. Г. Формат 70×100 1/16. Печать цифровая. Усл. печ. л. 34,61. Тираж 200 экз. Веб-сайт издательства: www.dmkpress.com