Алгоритмы и анализ сложности Часть 2 Поиск Достоинства отсортированных массивов: - быстрый поиск в ОП ( log n ), - возможность поиска в диапазоне, на или , простота получения порядковых статистик. Недостатки отсортированных массивов: - неприспособленность к динамическим изменениям, - поиск, недостаточно быстрый для ВУ. => Во многих случаях необходимы динамические структуры данных для быстрого поиска информационных объектов по ключам. Как правило, такие структуры хранят ключевые значения и ссылки на соответствующие объекты (реже – сами объекты). Хеширование Основная идея – получать адрес искомого объекта на основе функционального преобразования его ключевого значения: по ключу p вычисляется значение f ( p) , определяющее номер записи в некоторой таблице объектов (ссылок). Требования к функции f ( p) : 1. p1 p 2 => f ( p1 ) f ( p2 ) 2. простота вычисления (быстрое вычисление адреса) Для сохранения n объектов\ссылок необходимо выделить таблицу с q n записями. Если диапазон возможных значений ключа pmin p pmax , то, как правило, pmax pmin q . => в общем случае возможно образование конфликтов (коллизий): p1 p 2 , но f ( p1 ) f ( p2 ) . Общий подход: - не исключать коллизии, а предусмотреть их обработку, - выбирать f ( p) таким образом, чтобы она по возможности равномерно распределяла pmax pmin потенциальных значений ключа по q адресам. f ( p) - хеш-функция (hash=смесь). Хеш-таблица – информационная структура – таблица записей, номера\адреса которых вычисляются на основе f ( p) . Пример хорошей ф-ции: f ( p) ord ( p) mod q , где q - простое (значения f ( p) достаточно равномерно распределяются на [0...q 1], т.е. хеш-таблица используется эффективно). Использование хеш-функции для быстрого сравнения объектов и в криптографии Обработка коллизий – метод цепочек Идея: объединять эл-ты, образующие коллизии, в q списков. Вариант 1: значения только добавляются, их макс.количество n известно заранее. Хеш-таблица состоит из 2 частей – n элтов для массива списков и q начальных эл-тов списков. Пусть p1 p2 , f ( p1 ) f ( p2 ) i , значение p1 размещается в таблице на шаге j , p 2 – на шаге k j : 0 0 -1 i j p1 k p2 k -1 n 1 j q 1 Выбор q . Добавление и поиск эл-тов. Удаление элементов Вариант 2: q n , таблица длины q с динамической областью переполнения (незанятые эл-ты содержат значение ): 0 i p1 s q 1 p0 s p2 t p4 t -1 -1 Добавление элементов. Успешный и безуспешный поиск. Удаление элементов, «удаленное» значение p0 . Равномерное размещение ключей по q O(n) спискам: трудоемкость построения таблицы O(n) , поиска O(1) . Наихудший случай – O (1) списков (коллизий): трудоемкость построения таблицы O(n 2 ) , поиска O(n) . Обработка коллизий – метод открытой адресации Идея: - хеш-таблица длины q n содержит только ключи, - элементы, образующие коллизии, объединяются в списки, - если некоторый список образуют эл-ты, для которых f ( p) i , то они займут незанятые ячейки таблицы с номерами (i s j ) mod q , j 0 , где s0 0 , а остальные шаги s j не хранятся, а вычисляются. Пример: в таблицу последовательно включаются p x , p1 , p2 , p3 (все значения разные), f ( p1 ) f ( p2 ) f ( p3 ) i , f ( p x ) i 0 A p3 s2 i p1 q 1 px p2 s1 s3 Поиск элемента со значением p : k = i = ord(p) % q; j = 0; // j – номер шага while (A[k] != p && A[k] != ) { j++; s = текущий_шаг(j); k = (i + s) % q; } if (A[k] == p) return k; // поиск успешный else return -1; // поиск безуспешный Добавление и удаление элементов Методы вычисления текущего шага s j должны исключать зацикливание при поиске элементов. Вычисление шага – линейные пробы: - выбирается c const , c 1, c и q взаимно простые, - шаги s j cj , j 0 , а соответствующие адреса ячеек таблицы k ( f ( p) cj ) mod q . Недостатки, приводящие к росту трудоемкости поиска: - первичное скучивание – группировка элементов одного списка возле начального эл-та (особенно при c 1), - вторичное скучивание – группировка эл-тов разных списков. Желательно, чтобы размещение элементов в таблице было по возможности равномерным и выглядело, как случайное. Вычисление шага – квадратичные пробы: s j j 2 , адреса k ( f ( p) j 2 ) mod q . Поиск приведет к повторному выбору эл-та на шаге l q , если для l и некоторого предыдущего шага j l выполнится ( f ( p) l 2 ) mod q ( f ( p) j 2 ) mod q . Тогда (l 2 j 2 ) mod q (l j )(l j ) mod q 0 , т.е. l j mq . Это может выполниться только при числе шагов (длине списка) l q / 2 . Вычисление шага – двойное хеширование: s j h( f ( p)) j – различные шаги для разных списков. В список входят эл-ты с разными ключами pi , но одинаковыми f ( pi ) => можно взять h( f ( p)) f ( p) mod r 1, где r q . Пусть в таблице хранится n эл-в, и n / q – заполненность таблицы. Рассмотрим асимптотический случай q , n . Будем полагать, что при поиске на каждом шаге события «эл-т таблицы занят» и «эл-т таблицы свободен» независимы, и их вероятности равны, соответственно, и 1 . Вероятности событий «получение свободного элемента за…»: 1 шаг – (1 ) , 2 шага – (1 ) , i 1 шаг – i (1 ) . В силу независимости данных событий: i (1 ) 1 (условие нормировки). i0 Средняя длина безуспешного поиска (или трудоемкость размещения нового элемента) при заполненности : lunsucc i (1 )(i 1) (1 )(1 2 3 2 4 3 ...) i0 (1 ) (1 2 ...) ( 2 3 ...) ... 1 1 0.1 0.25 0.5 0.75 0.9 0.99 lunsucc 1.11 1.33 2 4 10 100 Пусть эл-ты таблицы не удаляются. Тогда при успешном поиске эл-та проводятся те же самые действия, что и при размещении данного эл-та в таблице. Трудоемкость успешного поиска эл-та равна числу шагов при размещении данного эл-та, а это число зависело от текущей заполненности таблицы. Пусть - текущая заполненность таблицы. При размещении n элементов изменяется от 0 до n / q . Средняя длина успешного поиска: l succ 1 d 1 1 dt 1 1 ln 0 1 1 t 1 lsucc t 1 , dt d 0.1 0.25 0.5 0.75 0.9 0.99 1.05 1.15 1.39 1.85 2.56 4.66 Вывод: задавать q 4n ; если при добавлении новых эл-тов 3 (увеличении n ) достигается указанная граница, следует выделить новую таблицу с q1 q записями и последовательно разместить в ней все эл-ты из старой (рехеширование). Применение хеширования для данных на ВУ Для размещения N элементов (ключей) выделяется q блоков по m записей т.о., что q N mq . Блок k ( 0 k q 1) содержит от 0 до m эл-тов pi , для к-рых f ( pi ) k , и еще 2 значения – текущее число эл-тов и или номер блока в области переполнения, куда попадает m 1-й эл-т с таким же значением хеш-функции 0 4 6 2 m q-1 5 1 При увеличении m (и соответствующем уменьшении q ) происходит более равномерное распределение записей по блокам. Кнут: при m 50 и заполненности таблицы N 0.9 среднее mq число чтений блоков составляет 1.04. Недостатки хеширования: - недостаточная приспособленность к динамическим изменениям (число эл-тов таблицы должно быть известно заранее, удаление эл-тов повышает трудоемкость поиска), - высокая трудоемкость в наихудшем, - поиск только по совпадению ключей. Информационные деревья Деревья в теории графов и теории алгоритмов Деревья как информационные структуры (списки): - некоторая начальная вершина – корень дерева, - каждой вершине соот-ет, по кр.мере, одно значение ключа, - каждая вершина имеет от 0 до m 2 ссылок на другие вершины (макс. число ссылок m – степень дерева). Случайное бинарное дерево ( m 2 , 1 значение вершины) Строится по заданному набору ключевых значений без их предварительной обработки (случайно выбранные значения). 1-е значение помещается в корень, для каждого последующего выделяется новая вершина. Поиск места для новой вершины начинается от корня и проводится по определенному правилу. Пример: дерево, построенное по значениям 7, 3, 1, 11, 6, 8, 4. 7 3 1 11 6 8 4 Корень, сыновья вершины, листья (вершины без потомков). Левое и правое поддерево вершины. Уровень вершины – длина пути от корня до вершины. Глубина (высота) дерева – длина максимального пути. Правило формирования бинарного дерева: вершины x все вершины его левого поддерева содержат значения x , все вершины правого – x . Добавление эл-тов и поиск в дереве начинаются с корня и производятся в соответствии с правилом форм-ния дерева. Добавляемая вершина всегда будет листом дерева. 7 7 7 7 3 3 3 1 1 7 3 1 7 11 6 11 7 3 1 11 6 8 3 1 11 6 4 8 Поиск вершины является составной частью всех операций над деревом. Трудоемкость поиска вершины опр-ся длиной пути от корня до данной вершины. Наихудший вариант бинарного дерева с n вершинами – вырождение в линейный список: длина пути составляет lworst (n) n и lmid (n) n / 2 . Наилучший вариант – идеальное (оптимальное, идеально сбалансированное) дерево, для которого выполняется: - число вершин в поддеревьях одной вершины отличаются 1, или - в дереве заполнены все уровни, кроме, м\б, последнего. При заданном числе вершин n идеальное дерево имеет минимальную высоту среди всех бинарных деревьев. Наихудший по трудоемкости поиска случай для идеального дерева: заполнены все уровни (т.е. построено максимальное число вершин максимального уровня) уровень число вершин 1 2 3 20 1 21 22 k 1 k 2k 2 2 k 1 Общее число вершин n k 1 2i 2 k 1. i0 Максимальная длина пути в полном идеальном дереве: lworst (n) k log(n 1) . Средняя длина пути в полном идеальном дереве: l mid (n) 1 0 1 2 k 2 k 1 1 2 2 2 3 2 ... ( k 1 ) 2 k 2 k 2 1 1 k 1 k 1 k 2 k 1 k 1 ( 1 2 ... 2 ) ( 2 4 ... 2 ) ( 2 2 ) 2 k 2 1 1 k (2 k 1) (2 k 21 ) ... (2 k 2 k 2 ) (2 k 2 k 1 ) 2 1 k 2 k 2 k 1 (k 1)2 k 1 k 1 log(n 1) 1 l worst (n) 1 k k 2 1 2 1 В общем случае идеальное дерево с k уровнями содержит 2 k 1 n 2 k 1 вершин, и оценки длин путей сохраняются: lworst (n) log(n 1) , lmid (n) lworst (n) 1. Построение идеального дерева (сортировка набора ключей). Недостатки идеального дерева Средняя длина пути в случайном дереве Предположим, что n ключевых значений были отсортированы, но при построении дерева выбирались случайным образом. Если корень содержит эл-т с номером i , то в его левое поддерево попадают эл-ты 1...i 1, а в правое – i 1...n . => Средняя длина пути в дереве с n вершинами и корнем i : i 1 ni li 1 ln i , где li 1 – это средняя длина пути n n i 1 для левого поддерева, а – вероятность попадания в левое n ni поддерево (аналогично ln i и для правого поддерева). i ln(i ) 1 Усредняем по всем возможным значениям i 1...n и получаем среднюю длину пути в случайном бинарном дереве: 1 n (i ) 1 n i 1 ni ln ln 1 li 1 ln i n i 1 n i 1 n n 1 n 2 n 1 1 2 (i 1)li 1 (n i )l n i 1 2 ili n i 1 n i0 Очевидно, что l0 0 , l1 1 и l 2 1.5 . Для n 3 на основе мат. индукции можно показать, что ln 2 ln n 2 ln 2 log n 1.39 log n . Средняя трудоемкость построения случайного дерева: Tbuild (n) nln O(n log n) . Удаление эл-тов из случайного бинарного дерева Пусть требуется удалить вершину со значением d . Если эта вершина – лист, то нужно просто удалить ссылку на нее в родительской вершине p . Если удаляется не лист, то в дереве необходимо найти вершину-замену и перестроить дерево, не нарушая правила его формирования. Пусть оба поддерева вершины d не пустые, {L} – мн-во значений вершин левого поддерева, l max – макс.эл-т левого поддерева, {R} – мн-во значений правого поддерева и rmin – мин.эл-т правого поддерева. Тогда {L} lmax d rmin {R}, и заменой d могут быть l max или rmin . Но если в дереве допустимы одинаковые значения (в левом поддереве), то d должно заменяться на l max . Варианты удаления вершины со значением d (штриховые стрелки показывают правило замены значения d , красным отмечены вершины, которые исключаются из дерева): лист 1 поддерево 2 поддерева p p p p d d d d l l f Вариант 1 c Вариант 2 Структура, представляющая вершину дерева, должна содержать, по крайней мере, 3 поля: - значение (val), - 2 ссылки на сыновей (left, right). Такую же структуру выгодно использовать вместо начального указателя на корень дерева. Пусть root – имя соответствующей переменной, тогда можно положить: root.val = MAX_VAL; // макс.возможное значение ключа root.right = NULL; // не используется root.left = адрес_корня; // NULL, пока дерево пустое Ниже в алгоритмах используются переменные pp, pd, pa – указатели на данную структуру. Поиск вершины d (указатель pd) и ее родительской вершины (указатель pp): pp = &root; pd = pp->left; while (pd != NULL && pd->val != d) { pp = pd; pd = (d > pd->val)? pd->right : pd->left; } if (pd != NULL) вершина_найдена; // pp - родитель else вершина_не_найдена; Удаление вершины d после ее успешного поиска (получены значения указателей pd и pp): if (pd->left==NULL && pd->right==NULL) {//лист if (pd == pp->left) pp->left = NULL; else pp->right = NULL; } else if (pd->left==NULL) {// только пр.поддерево if (pd == pp->left) pp->left = pd->right; else pp->right = pd->right; } else if (pd->right==NULL) {//только лев.поддерево if (pd == pp->left) pp->left = pd->left; else pp->right = pd->left; } else { // 2 поддерева pa = pd; // запоминаем вершину d pp = pd; pd = pd->left; // поиск замены while (pd->right != NULL) { pp = pd; pd = pd->right; } if (pd == pp->left) // вариант 1 pp->left = pd->left; else // вариант 2 pp->right = pd->left; pa->val = pd->val; // перенос значения } delete(pd); Сбалансированные деревья (АВЛ-деревья) Условие АВЛ-дерева: для любой вершины высоты ее левого и правого поддерева отличаются не более, чем на 1. Наихудший случай – деревья Фибоначчи (для всех вершин высоты их поддеревьев отличаются на 1): T0 – пусто, T1 – одна вершина (корень), Tk – корневая вершина с поддеревьями Tk 1 и Tk 2 . Деревья Фибоначчи имеют минимальное число вершин при заданной высоте среди всех сбалансированных деревьев: n0 0 , n1 1, nk nk 1 nk 2 1 – числа Леонарда. nk растут быстрее, чем числа Фибоначчи: nk f k , но nk 1 f k 1 при росте k этим различием можно пренебречь. => При переходе к дереву Фибоначчи следующего уровня число его вершин увеличивается не менее, чем в 1.62 раз. Следовательно, любое сбалансированное дерево с n вершинами имеет высоту (трудоемкость поиска в наихудшем) log n log 2 log n 1.44 log n . Доказано, что средняя трудоемкость поиска в АВЛ-дереве с n вершинами 1.05 log n (немного хуже, чем в идеальных). Важная особенность: для сбалансированных деревьев существуют эффективные алгоритмы восстановления сбалансированности при добавлении и удалении элементов. Добавление вершин к АВЛ-дереву Состоит из 2 шагов: - добавление вершины, как к случайному дереву, - проверка и восстановление сбалансированности. Добавление новой вершины всегда увеличивает высоту какого-то поддерева (хотя бы у родительской вершины), но не всегда нарушает сбалансированность. Проверку сбалансированности нужно проводить только для вершин, лежащих на пути от новой вершины до корня. Для ее реализации необходимо добавить в структуру, представляющую вершины дерева, еще 2 поля: parent – ссылка на родительскую вершину, balance – показатель сбалансированности (разность между высотой правого и левого поддеревьев, в норме -1, 0 или 1, при нарушениях -2 или 2). Примеры изменения balance при добавлении вершин 10, 3, 5: исходное + 10 8(-1) 8(0) / \ / \ 4(0) 9(0) 4(0) 9(1) / \ / \ \ 2(0) 6(0) 2(0) 6(0) 10(0) +3 8(-2) / \ 4(-1) 9(0) / \ 2(1) 6(0) \ 3(0) +5 8(-2) / \ 4(1) 9(0) / \ 2(0) 6(-1) / 5(0) Очевидно, что для новой вершины balance=0, а для остальных вершин на пути к корню balance изменяется по след.правилам: - уменьшается на 1 при переходе из левого поддерева, - увеличивается на 1 при переходе из правого поддерева. Если для некоторой вершины V новое значение balance=0, то это означает, что общая высота поддерева с корнем V не изменилась. => Для всех вершин, лежащих на пути от V к корню значение balance не изменится, поэтому и проверять их не надо. Ниже приведены алгоритмы добавления новой вершины (листа) и последующей проверки сбалансированности. Для доступа к вершинам дерева используются указатели. Добавление новой вершины (листа) со значением a : pp = &root; // фиктивный отец корня pa = pp->left; // корень АВЛ-дерева while (pa != NULL) { pp = pa; pa = (a > pa->val)? pa->right : pa->left; } pa = new(тип_для_вершины); pa->val = a; pa->left = pa->right = NULL; pa->balance = 0; pa->parent = pp; // pp – отец новой вершины pa if (a > pp->val) pp->right = pa; else pp->left = pa; Пересчет и проверка сбалансированности от a до корня (на вершину a указывает pa): pp = pa->parent; // pp – родительская вершина while (pp != &root) { pp->balance += (pa == pp->left)? -1 : 1; if (pp->balance == 0) break; if (abs(pp->balance) >= 2) break; pa = pp; pp = pp->parent; } if (pp == &root || pp->balance == 0) сбалансированность_не_нарушена; else сбалансированность_нарушена; //pp – вершина с нарушенным балансом //pa – сын pp (корень поддерева,содержащего a ) Для восстановления сбалансированности выполняются повороты АВЛ-дерева отн-но вершины, в которой баланс нарушен. Ниже показано, как выполняются повороты, если добавление вершины x увеличивает высоту левого поддерева вершины D и приводит к нарушению баланса в D ( LL - и LR -повороты). Изображенные на рисунках поддеревья 1-6 имеют одинаковую высоту k 0 . Для обрабатываемого поддерева вершины P указана его высота до и после включения x , а также после поворота. x может добавляться только в одно из поддеревьев 1-4, но на рисунках приводятся все возможные случаи. Выделены вершины, в которых изменяются ссылки left, right и\или parent. Очевидно, что для всех вершин от x до P пересчитывается значение balance. LL-поворот: P P k 3 (исх.) k 4 ( x) k 3 (= исх.) D B B E C A 5 C 6 1 1 2 3 E 2 3 4 x x D A x x По условию формирования бинарного дерева: {1} A {2} B {3} C {4} D {5} E {6}. 4 5 6 LR-поворот: P P k 4 ( x) k 3 (исх.) k 3 (=исх.) D C B B E C A 5 D A 6 E 3 1 2 3 x 4 1 2 4 5 x 6 x x При LR -повороте у корневых вершин деревьев 3 и 4 также изменяются ссылки на родительскую вершину (parent). После перебалансировки (поворота) высота изменяемого поддерева вершины P становится такой же, как до добавления x . Поэтому значения balance для вершин от P до корня дерева уже не изменятся, и дальнейших проверок не требуется. Для симметричных относительно D вариантов (правое поддерево на 1 выше левого, новая вершина x попадает в правое поддерево и увеличивает его высоту) используются симметричные к указанным выше RR - и RL -повороты. Добавление вершины x к сбалансированному дереву требует в наихудшем O(logn) шагов на поиск положения x , O(logn) на проверку сбалансированности и O (1) на поворот дерева. Экспериментально установлено, что на 2 добавления вершин приходится в среднем 1 поворот. Удаление вершин из АВЛ-дерева Состоит из 2 шагов: - удаление вершины, как в случайном дереве, - проверка и восстановление сбалансированности от низа дерева (вершины-замены) до корня включительно. Удаление вершины всегда уменьшает высоту какого-то поддерева (хотя бы у родительской вершины), но не всегда нарушает сбалансированность. Для всех вершин на пути к корню значение balance: - увеличивается на 1 при переходе из левого поддерева, - уменьшается на 1 при переходе из правого поддерева. Пусть для некоторой вершины V новое значение balance равно 1 или -1. Это означает, что старым значением для V было balance=0. => Высоты поддеревьев V до удаления были равны, а после удаления высота одного поддерева уменьшилась на 1, а другого – не изменилась. => Общая высота дерева с корнем V не изменилась. => Для всех вершин, лежащих на пути от V к корню значение balance не изменится, поэтому и проверять их не надо. Перебалансировка производится с помощью LL -, LR -, RR - и RL -поворотов (в среднем 1 раз на 5 удаленных вершин). Пример удаления вершины E (для проверяемых вершин указаны значения balance): A исходное дерево удалена вершина E E (1) D(1) C (1) K (1) B D(0) I (1) M G J FH L N C (2) K (1) B(1) A I (1) M G J FH L N после LL -поворота в C после RL -поворота в D I (0) D(2) B(0) D(0) K (1) A C ( 0) I (1) M G J L N B(0) A C G FH K (1) J M L N FH Для всех вершин, в которых при поворотах изменяются указатели left и\или right, должно пересчитываться значение balance. После RL -поворота в вершине I balance=0, поэтому проверка продолжается от I до корня. Красно-черные деревья (лучше сбалансированных в перестроениях, немного похуже в поиске). Прошитые деревья (2 типа дополнительных ссылок). Преимущества бинарных деревьев перед упорядоченными массивами и хеш-таблицами: - динамические структуры, - позволяют проводить поиск в диапазоне, выделять min и max, - позволяют найти предыдущее\следующее значение ключа и соответствующие объекты. Недостатком бинарных деревьев явл-ся высокая трудоемкость поиска вершин при хранении деревьев на ВУ: в среднем O(log N ) чтений файла с произвольных позиций. Разделение идеального дерева на блоки Идеальное дерево с N 2 1 вершинами делится на блоки m (страницы), содержащие по n 2 1 вершин. Чтение из файла производится целыми страницами – в наихудшем нужная вершина находится за k / m обращений к файлу. k Непригодность данного метода для неидеальных деревьев: - большие затраты памяти на пустые страницы, - неприспособленность к динамическим изменениям. B-деревья (сильно ветвящиеся деревья) B-дерево порядка k 1 строится по следующим правилам: - все значения в дереве различные, - каждая вершина занимает 1 блок (страницу), - каждая вершина может содержать от k до 2k упорядоченных по возрастанию значений (от 1 до 2k значений для корня), - каждая вершина с m значениями содержит m 1 ссылку на вершины-потомки (у листьев эти ссылки пустые), - все листья находятся на одном уровне и не имеют потомков. Структура вершины В-дерева ( i 1...m 1 : xi xi 1) x1 s1 x1 x2 s2 x1 & x2 ... s3 x2 & x3 xm 1 xm sm xm1 & xm sm1 xm Поиск вершины со значением p в В-дереве начинается с корня В-дерева. Если текущая вершина (страница) не содержит p , то следующая вершина выбирается по ссылке, соответствующей диапазону значений, в к-рый попадает p : p x1 – переход по s1 , xi 1 p xi , 1 i m – переход по si , p xm – переход по sm1 . Добавление значения к В-дереву Новое значение всегда должно включаться в соответствующий лист. Если данный лист содержит 2k значений, то новое значение просто добавляется к листу с соблюдением условия упорядоченности. Если же лист уже содержит 2k значений, то производится его сбалансированное расщепление: - 2k 1 значений ( 2k старых + 1 новое) сортируются по возрастанию, - k начальных значений формируют один новый лист (левый), k конечных – другой (правый), а медианное значение выталкивается наверх, в родительскую вершину. c a c d f + b => { a , b , c , d , f } => a b d f Если родительская вершина изменилась (в нее было добавлено новое значение), то она должна обрабатываться так же, как начальный лист. Процесс расщепления вершин может продолжаться вплоть до корня В-дерева. Если происходит расщепление корневой вершины, то образуется новый корень (содержащий 1 значение), то есть В-дерево растет корнем вверх. Пример построения В-дерева порядка 2 (красным указаны добавляемые значения, в фигурных скобках – множества значений, используемых при расщеплении вершин): +3 3 + 1, 6, 12 1 3 6 12 6 + 17 => { 1, 3, 6, 12, 17 } => 1 3 12 17 6 + 2, 5, 8,10 1 2 3 5 8 + 15 => { 8, 10, 12, 15, 17 } => 1 2 3 5 3 + 4 => { 1, 2, 3, 4, 5 } => 1 2 4 6 5 10 12 6 12 8 10 17 15 17 12 8 10 15 17 3 + 7, 11, 14, 16 1 2 4 5 + 9 => { 7, 8, 9, 10, 11 } => 1 2 4 5 1 2 6 4 5 8 10 11 3 6 9 12 7 7 12 7 8 + 13 => { 13, 14, 15, 16, 17 } => { 3, 6, 9, 12, 15 } => 3 6 10 14 11 15 14 15 16 17 16 17 16 17 9 8 10 11 12 15 13 14 Удаление значения v из В-дерева Если v находится в листе, то v удаляется непосредственно оттуда. Если v находится в вершине более высокого уровня W , то нужно найти в дереве замену – максимальное значение, меньшее v . Такое значение всегда располагается в некотором листе L (пусть это значение t , как на рисунке). t должно заменить v в W , а затем t нужно удалить из L . … Вершина W f g h f<g<h<v k l лист L v … m h<k<l<m<v r s t m<r<s<t<v При удаления значения из некоторого листа L возможны 2 случая: 1. L содержит k значений. Необходимо просто удалить нужное значение, сохраняя упорядоченность оставшихся. 2. L содержит ровно k значений. В этом случае нужно объединить остающиеся значения из L со значениями правого или левого соседа L (листа R ), и тем значением p из родительской (для L и R ) вершины, для которого выполняется условие {L} p {R} или {R} p {L} – в зависимости от взаимного расположения L и R . родитель d f L m n p z u v { m, p, u, v, w, x } w x R Пусть множество {L, R, p} содержит j значений. => 1. Если j 2k , то медианный элемент перемещается в родительскую вершину, а из значений меньших и больших медианного формируются новые вершины L и R . { m, p, u, v, w, x } родитель d f L m p u z v w x R 2. Если j 2k , то из данных значений нужно сформировать одну новую вершину L , удалить старую вершину R , и провести с родительской вершиной такие же проверки, как с L (родительская вершина изменилась, т.к. из нее было удалено значение p ). { m, p, u, v } родитель … … d f z L m p u v Процесс удаления вершин из В-дерева демонстрируют рисунки из пункта «Построение В-дерева» – в обратном порядке. Наихудшим для В-дерева порядка k с N значениями является случай, когда все вершины содержат по k значений и k 1 ссылке (а также 1 значение и 2 ссылки в корне). При этом память используется на 50%, а высота дерева (трудоемкость поиска в наихудшем) равна logk 1 N 1. Например, при k 100 (относительно небольшие страницы) 6 высота В-дерева, содержащего 10 значений, составляет всего 4 в наихудшем. При большом числе операций поиска выгодно хранить верх В-дерева в оперативной памяти. Простейший вариант – В-деревья порядка 1 или 2-3-деревья – используются при работе с динамическими объектами в ОП и являются альтернативой сбалансированным деревьям (2-3деревья немного лучше в перестроениях, но похуже в поиске). Вершины 2-3-дерева можно хранить как вершины бинарного дерева, в которых правая ссылка указывает либо на вершину следующего уровня, либо на соседнюю вершину по горизонтали (должен храниться и тип ссылки): a a b a& b a b Другие типы деревьев: - деревья оптимального поиска, - декартовы, тетра- и R-деревья. a a& b b b Абстрактные типы данных: внутренняя структура и конкретная реализация скрыты от пользователя, представление в виде интерфейсов. Список (list): head, tail, next, previous. Стек (stack): push, pop. Очередь (queue): push, pop. Ассоциативный массив (словарь, map) – хранит [уникальный ключ, значение]: insert, find, remove, each. пары Очередь с приоритетом (priority queue) – хранит пары [ключ, значение]: insert, min, extract_min, change_key. Информационные таблицы Записи, атрибуты, ключевые и неключевые поля. Поиск – основа операций добавления, удаления и модификации записей. 3 типа запросов на поиск: - по конкретным значениям одного или нескольких атрибутов, - по диапазонам значений, - по наборам значений с использованием логических операций. Варианты организации поиска по ключевому полю Сортировка записей. Хеш-таблица. Разреженный индекс - <макс.значение в блоке, ссылка на начало блока>, главный файл отсортирован. Покрытие ключа и поиск – линейный, двоичный, вычислением адреса. Плотный индекс - <значение ключа, ссылка на запись>. Реализация на основе упорядоченного файла или В-дерева. Поиск по неключевому полю (поиск по частичному соответствию): записи специфицированы не полностью, результатом всегда может быть множество записей. Можно использовать аналогичные стр-ры данных, но их выбор ограничен тем, как организован поиск по ключам. Вторичный индекс - <значение атрибута, список ссылок на записи\блоки\значения ключа> и формирование на его основе инвертированного файла (значения атрибута упорядочены и встречаются только 1 раз). Многоаспектный поиск (поиск по совокупности ключей). Варианты: 1. Индексы для каждого ключа; сокращение множества выбранных записей последовательным выбором по ключам A, B, C… 2. Индексы для каждого ключа; пересечение множеств записей, полученных при поиске по отдельным ключам. 3. Построение индексных ключей (плотный индекс). файлов по совокупности Если всего используется m ключей, из них в запросах ровно k , то нужно C mk индексов (например, для ключей A, B, C при m 3 и k 2 нужны индексы по AB , AC и BC ). Если есть m ключей, а запросы произвольны, то требуется 1 Cm Cm2 ... Cmm 2 m 1 индексов (например, 7 индексов для A, B, C – A, B, C , AB, AC , BC , ABC ). Метод сокращения их числа: ABC включает также A и AB , BC включает B , CA AC включает C , поэтому достаточно построить индексы по совокупностям ключей ABC , BC , CA . Сортировка по нескольким ключам аналогична сортировке строк: - производится последовательно по каждому отдельному ключу, начиная от последнего (правого), - продолжается для последующих ключей таким образом, чтобы не нарушалась упорядоченность по предыдущим (т.е. сортировка должна быть устойчивой). Операции над таблицами Варианты реализации проекции и естественного соединения: - сортировка, - хеш-таблицы, - инвертированные файлы. Реализация запроса на поиск с помощью естественного соединения (каждой строке исходной таблицы соответствует не более одной строки таблицы-запроса). Информационно-поисковые системы (ИПС) Имеется N поисковых объектов (статей, документов и т.д.), каждый объект характеризуется набором из 5-10 ключевых слов (полей для поиска). Общее число m ключевых слов в ИПС может достигать 1000 и более. Типичным для ИПС является включающий поиск (мн-во ключевых слов запроса мн-ва ключевых слов документа). Фактически каждое ключевое слово – это отдельный атрибут, который в любом документе равен 1 (присутствует) или 0 (отсутствует). Если m не слишком велико, то можно каким-то образом задать порядок для всех ключевых слов и для каждого документа хранить битовую строку длины m , в к-рой 1 в определенной позиции отмечает присутствие определенного ключевого слова. Запрос на поиск также задается битовой строкой, а нужные документы выбираются на основе логического умножения строк запроса и документа. Если m велико, то наилучшим выходом будет построение инвертированного файла по всем значениям ключевых слов. Поиск цепочек символов Текстовые редакторы, трансляторы, антивирусные программы Алфавит – мн-во знаков (символов), из к-рых строятся цепочки. – мощность алфавита. Цепочки (строки) образуются с помощью конкатенации: a, b => ab 2 , ab 2 – мн-во цепочек длины 2. n – мн-во цепочек длины n : n n 1 , n , n . единственная пустая цепочка , 0 . {} 2 3 ... * – итерация алфавита. Основные понятия и обозначения Цепочка (строка, текст) длины n 0 : T n T1T2 ...Tn – конкатенация n символов алфавита (Ti , T n * ). Для простоты изложения будем индексировать символы в строке от 1 (в том числе, и в программах). Префикс (длины k ) строки T n : R k [ T n , 0 k n , R k T1T2 ...Tk – k начальных символов T n (можно обозначить просто T k ). Суффикс (длины j ) строки T n : S j ] T n , 0 j n , S j Tn j 1Tn j 2 ...Tn – j конечных символов T n . Задача поиска подстроки Даны строка (текст) T n и подстрока (образец) P m , m n , T n , P m * . Найти все случаи вхождения P m в T n , т.е. все значения s 0 , для которых выполняется: Ts 1Ts 2 ...Ts m P1P2 ...Pm . Величины s , определяющие, какие символы строки T n будут сравниваться с символами подстроки P m , будем называть сдвигами ( P m относит-но T n ). Очевидно, что 0 s n m . Сдвиги, соответствующие вхождениям P m в T n , называются допустимыми. Простейший алгоритм: последовательно проверить условие Ts 1Ts 2 ...Ts m P1P2 ...Pm для всех сдвигов 0 s n m . for (s = 0; s <= n-m; s++) { for (i=s+1, j=1; j<=m && T[i]==P[j]; j++,i++); if (j > m) найдено_вхождение; } Основной недостаток данного алгоритма – постоянный сдвиг подстроки на 1, независимо от результатов сравнения символов. Трудоемкость наиболее высока при малой мощности алфавита (много совпадений символов P m и T n ) и в наихудшем составляет Om(n m) . Алгоритм Бойера-Мура (вариант с таблицей сдвигов) Основная идея: сравнивать пары символов T и P от конца подстроки и по результатам сравнения исключать заведомо недопустимые сдвиги. Пример (красн. стрелками отмечены несовпадения символов) T a c a b P a b a c a c a b a c a b a d a a a c a b a c a b a c a b a c c Символы строки T и подстроки P сравниваются от конца подстроки. Независимо от того, является ли текущий сдвиг s допустимым (найдено вхождение) или нет, величину следующего сдвига определяет символ Ts m (который сравнивался с последним символом подстроки Pm ). Для подстроки P необходимо построить таблицу сдвигов длины , в которой каждому символу алфавита соответствует величина приращения текущего сдвига (на сколько позиций от текущего положения нужно сдвинуть P ). k -й элемент данной таблицы соответствует символу char(k ) и равен: m , если char(k ) не входит в P m 1 (первые m 1 символов P m ); m r , где r - позиция самого правого вхождения char(k ) в P m 1, в противном случае. Построение таблицы сдвигов D для подстроки P длины m и стандартного набора из 256 символов: for (k = 0; k < 256; k++) D[k] = m; for (k = 1; k < m; k++) D[ord(P[k])] = m – k; Поиск подстроки P в тексте T длины n : for (s = 0; s <= n-m; ) { for (i=s+m, j=m; j>0 && T[i]==P[j]; j--, i--); if (j == 0) найдено_вхождение; s += D[ord(T[s+m])]; } Трудоемкость в наилучшем: O( n / m) . Трудоемкость в наихудшем: O m(n m) . Алгоритм наиболее пригоден для большого входного алфавита и нечастого появления подстроки в тексте. Поиск подстроки с помощью конечного автомата КА – это пятерка ( Q, q0 , A, , ), где Q - конечное множество состояний, q0 Q - начальное состояние, A Q - множество допускающих состояний, - входной алфавит, - функция переходов ( Q Q ). строки-образца P m определим суффикс-функцию : * {0,1,2,..., m}: для произвольной строки * значение ( ) равно длине максимального суффикса , являющегося префиксом P , т.е. Для ( ) max{k : P k ] }. Примеры значений суффикс-функции для P aabab: ( ) 0 , (abcaaba) 4 , (abca) 1, (aababb) 0 . Определим мн-во состояний КА для распознавания P m : Q {0,1,2,..., m}, q0 0 , A {m}. КА просматривает символы входной строки T n слева направо. Если после проверки символа Ti текущее состояние равно q , то это означает, что ровно q последних проверенных символов T совпадают с начальными символами P : P1P2 ...Pq Ti q 1Ti q 2 ...Ti , т.е. P q ] T i . По определению суффикс-функции из этого следует: (T i ) ( P q ) q . КА находится в состоянии q , проверяет очередной символ Ti 1 и переходит в новое состояние q1 (T i 1 ) ( P qTi 1 ) . Используем это условие для задания функции переходов КА: (q, x) ( P q x) , q {0,..., m}, x . Примеры переходов для P aabab (текущее состояние q 4 , анализируемые символы выделены красным): … с a a b a a … (4, a) 2 … с a a b a b … (4, b) 5 … с a a b a c … (4, c) 0 Таблица переходов и КА для {a, b, c} и (переходы в состояние 0 не указаны): a b c a a 0 1 0 0 1 2 0 0 0 a 1 a 2 b 3 2 2 3 0 3 4 0 0 4 2 5 0 5 1 0 0 P aabab a a 4 b 5 Построение таблицы переходов D для произвольной подстроки P m и алфавита : for (q = 0; q <= m; q++) foreach a { for (k = min(m, q+1); k > 0; k--) { q // P k является суффиксом P a ? if (P[k] != a) continue; for (i = k-1, j = q; i > 0; j--, i--) if (P[i] != P[j]) break; if (i == 0) break; } D[q][a] = k; q // P k - суффикс P a } Трудоемкость данного алгоритма составляет O(m 3 ) , размер таблицы – (m 1) элементов. Поиск всех вхождений подстроки P m в строку T n после построения таблицы переходов D : for (q = 0, i = 1; i <= n; i++) { q = D[q][T[i]]; if (q == m) найдено_вхождение(i–m); } Трудоемкость поиска подстроки составляет O(n) . Наиболее выгодно использовать КА для распознавания подстрок, если входной алфавит содержит мало символов и длина подстроки-образца m невелика. Алгоритм Кнута-Морриса-Пратта Алгоритм КМП похож на алг-м распознавания с помощью КА, но позволяет избавиться от главного недостатка последнего – высокой трудоемкости построения таблицы переходов и затрат памяти на ее сохранение. В алгоритме КМП строится не КА, а похожая на него машина распознавания, которая может делать несколько последовательных переходов из текущего состояния при анализе очередного символа текста. Рассмотрим пример распознавания подстроки P aabaab (выделены совпадающие символы текста и P , текущее состояние q 5, анализируемый символ Ti 1 – красный): 6 Ti … с Ti 1 a a b a a a a a b a a b b … Pq P q ] T i , но Ti 1 Pq 1 , поэтому машина не может перейти в q 1, а должна перейти в новое состояние q1 q (т.е. P сдвинется вправо относительно T ). Используя уже проверенную P q , перейдем в наибольшее возможное q состояние q1 q , для к-го P 1 ] T i , и сравним Ti 1 и Pq1 1 : Ti … с a a b Ti 1 a a a b … a a b a a b P q1 Ti 1 Pq1 1, повторяем попытку для макс-ного q2 q1 , P q2 ] T i : Ti … с a a b Ti 1 a a a b … a a b a a b P q2 Ti 1 Pq2 1: переходим в сост-е q 2 1, символ Ti 1 проверен. Отметим, что текущий анализируемый символ Ti 1 никак не влияет на выбор следующего состояния машины распознавания: в состоянии q (для к-рого P q ] T i ) выбирается q максимальное состояние q1 q , для к-рого P 1 ] T i . q При этом P 1 [ P q , т.е. P q1 является максимальным по длине префиксом P q , который совпадает с суффиксом P q . Отсюда следует, что очередное состояние определяется только подстрокой P и никак не зависит от проверяемого текста T . Для поиска нужного состояния при распознавании подстрокиобразца P m заранее вычисляется целочисленная префиксфункция (функция отказов): (q) max{k : 0 k q & P k ] P q }, 1 q m . 6 Таблица значений (q ) для подстроки P aabaab : q 1 2 3 4 5 6 (q ) 0 1 0 1 2 3 6 Машина распознавания подстроки P aabaab : 0 a 1 a 2 b 3 a 4 a 5 b Последовательность состояний при анализе строк: aabaaa… 1-2-3-4-5-2-1-2... aabaab… 1-2-3-4-5-6… aabaac… 1-2-3-4-5-2-1-0… 6 При анализе очередного символа Ti 1 возможен возврат через несколько состояний. Если текущее состояние q 0 , то можно сделать переходов в предыдущие состояния. q Но если машина распознавания находится в состоянии q , должно выполняться условие P1P2 ...Pq Ti q 1Ti q 2 ...Ti . => При анализе символов Ti q 1 ,...,Ti было сделано ровно q переходов в последующие состояния (по одному переходу на каждый символ, без переходов в предыдущие). => На каждый проверяемый символ текста приходится в среднем O (1) переходов. Вычисление префикс-функции F для подстроки P m : F[1] = 0; for (q = 2; q <= m; q++) { k = F[q–1]; while (k > 0 && P[q] != P[k+1]) k = F[k]; F[q] = (P[q] == P[k+1])? k+1 : 0; } В цикле while можно сделать максимум k 1 шаг, но это возможно, если до этого был выполнен k 1 шаг цикла for, на которых цикл while вообще не выполнялся. => Общая трудоемкость данного алгоритма составляет O(m) . Поиск всех вхождений подстроки P m в строку T n после вычисления префикс-функции F : for (q = 0, i = 1; i <= n; i++) { while (q > 0 && P[q+1] != T[i]) q = F[q]; if (P[q+1] == T[i]) q++; if (q == m) { найдено_вхождение(i–m); q = F[q]; } } Рассуждая по аналогии с алгоритмом вычисления F , можно сделать вывод, что трудоемкость данного алгоритма O(n) . => Общая трудоемкость алгоритма КМП составляет O(m n) и не зависит от количества символов в алфавите. Вариант алгоритма Бойера-Мура с вычислением функций стоп-символа и безопасного суффикса (Кормен). Алгоритм Рабина-Карпа При поиске подстроки P m в строке T n необходимо найти все допустимые сдвиги, т.е. такие значения s , 0 s n m , для которых Ts 1Ts 2 ...Ts m P1P2 ...Pm . Основная идея алгоритма: - преобразовать подстроку P m и сравниваемую с ней часть текста Ts 1...Ts m в целые числа, заданные m цифрами в системе счисления с основанием (обозначим эти числа, соответственно, p и t s ); - сравнивать не P m и Ts 1...Ts m , а p и t s (одно сравнение чисел вместо O(m) сравнений символов); - обеспечить быстрое вычисление числа t s 1 , соответствущего сдвигу s 1, преобразованием значения t s . Пусть d . Тогда: p P1d m1 P2 d m 2 P3 d m 3 ... Pm1d Pm t s Ts 1d m1 Ts 2 d m 2 Ts 3 d m 3 ... Ts m1d Ts m Сдвиг s является допустимым, если p t s . Для вычисления p не требуется знание строки T . Используя схему Горнера, можно получить p за время O(m) : p (...((P1d P2 )d P3 )d ... Pm1 )d Pm Также за O(m) можно вычислить t 0 : t0 (...((T1d T2 )d T3 )d ... Tm1 )d Tm Значения t s 1 и t s связаны рекуррентным соотношением: t s 1 (t s Ts 1d m1 )d Ts m1 m 1 Если заранее получить константу d (за время O(m) ), то t s 1 будет вычисляться по известному t s за O (1) ЭШ, а проверку всего текста T n можно провести за O(n) . К сожалению, числа p и t s могут быть очень большими. Если d 256, то уже при m 5 мы выйдем за пределы представления 4-байтовых целых чисел. Поэтому отдельные арифметические операции, на основании которых получены оценки трудоемкости, нельзя считать элементарными шагами. Для преодоления этой трудности нужно перейти к модульной арифметике: вместо сверхдлинных целых чисел использовать их остатки от деления по некоторому модулю q . Если задано некоторое целое q 0 , то любое произвольное целое x можно единственным образом представить в виде: x cq r , где 0 r q ( c - частное, r - остаток). Для любых целых x, y, z выполняется равенство: ( xy z ) mod q ( x mod q y mod q z mod q) mod q , т.е. операцию деления по модулю можно вносить в скобки и применять к отдельным значениям. Чтобы исключить переполнение для целых в алгоритме Рабина-Карпа необходимо выбрать такое q (простое), чтобы значение dq помещалось в машинное слово, и при расчете d m 1, p , t 0 и t s 1 постоянно вычислять остатки по mod q : вместо d использовать h d m1 mod q ; при вычислении h брать остаток после каждого умножения текущего числа на d , m 1 p ((...(P1d P2 ) mod q d ... Pm1 ) mod q d Pm ) mod q , t0 ((...(T1d T2 ) mod q d ... Tm1 ) mod q d Tm ) mod q , t s 1 ((t s Ts 1h) mod q d Ts m1 ) mod q . Приведенные выше оценки трудоемкостей сохранятся, но теперь все вычисления проводятся с короткими числами, и каждая арифметическая операция будет одним ЭШ. Пример вычисления t s 1 для строки из десятичных цифр ( d 10 , q 13, Кормен и др.): 3 1 4 1 7 8 5 2 14152 = (31415 - 3∙10000) ∙10 + 2 – для длинных целых. 14152 mod 13 = = ((31415 mod 13 – (3∙10000) mod 13) ∙10 + 2) mod 13 = = ((7 – 9) ∙10 + 2) mod 13 = 8 – для остатков. Правильное вычисление r = a mod q (при q > 0): r = a % q; if (r < 0) r += q; Если t s mod q p mod q , то и t s p . Но из t s mod q p mod q еще не следует t s p . Поэтому при совпадении остатков необходимо проверить, найдено ли действительно вхождение подстроки (т.е. выполняется ли Ts 1Ts 2 ...Ts m P1P2 ...Pm ) или произошло холостое срабатывание. В наихудшем случае (например, если T и P являются последовательностями из одного символа алфавита) будет постоянно выполняться t s p и последующее посимвольное сравнение. Поэтому трудоемкость в наихудшем составляет O(m(n m)) , как в простейшем алгоритме. Но в большинстве практических приложений совпадений остатков и холостых срабатываний должно быть немного, и трудоемкость алгоритма Рабина-Карпа составит O(n m) . Поиск всех вхождений P m в T s ( d ): p = P[1]; t = T[1]; h = 1; for (i = 2; i <= m; i++) { h = mod(h*d, q); p = mod(p*d + P[i], q); t = mod(t*d + T[i], q); } for (s = 0; s <= n-m; s++) { if (p == t) { for (i = 1; i <= m && P[i] == T[s+i]; i++); if (i > m) найдено_вхождение(s); } if (s < n-m) t = mod(mod(t–T[s+1]*h, q)*d+T[s+m+1], q); } Возможность использования приведенных алгоритмов для распознавания набора подстрок.