Конспект лекций

по курсу

 

 

 

Алгоритмы и алгоритмические языки

 

 

 

 

 

 

 

 

 

 

Доцент каф. Вычислительной математики

Механико-Математического ф-та

МГУ им. М.В.Ломоносова

Староверов В.М.

2013г.


 

 

Лекция 1. 5

Представление чисел в ЭВМ... 5

Целые. 5

Вещественные. 5

Ошибки вычислений. 9

Лекция 2. 12

Алгоритмы. Сведение алгоритмов. 12

Нижние и верхние оценки. 12

Сортировки. 14

Постановка задачи. 14

Сортировка пузырьком. 15

Сортировка слиянием с рекурсией. 17

Сортировка слиянием без рекурсии. 19

Лекция 3. 20

Алгоритмы. Сведение алгоритмов. 20

Сортировки и связанные с ними задачи. 20

QuickSort. 20

Доказательство корректности работы алгоритма. 23

Оценки времени работы алгоритма. 24

Некоторые задачи, сводящиеся к сортировке. 27

Лекция 4. 32

Алгоритмы. Сведение алгоритмов. 32

Сортировки и связанные с ними задачи. 32

HeapSort или сортировка с помощью пирамиды. 32

Алгоритмы сортировки за время O(N) 34

Сортировка подсчетом.. 34

Цифровая сортировка. 35

Сортировка вычерпыванием.. 35

Лекция 5. 38

Алгоритмы. Сведение алгоритмов. 38

Порядковые статистики. 38

Поиск порядковой статистики за время Q(N) в среднем.. 38

Поиск порядковой статистики за время Q(N) в худшем случае. 40

Язык программирования C. 41

Переменные. 42

Структуры данных. 44

Вектор. 44

Стек. 45

Лекция 6. 48

Структуры данных ( + в языке С: массивы, структуры, оператор typedef). 48

Стек. 48

Стек. Реализация 1 (на основе массива). 48

Стек. Реализация 2 (на основе массива с использованием общей структуры). 49

Стек. Реализация 3 (на основе указателей). 49

Стек. Реализация 4 (на основе массива из двух указателей). 50

Стек. Реализация 5 (на основе указателя на указатель). 50

Очередь. 51

Дек. 52

Списки. 53

Стандартная ссылочная реализация списков. 54

Ссылочная реализация списков с фиктивным элементом.. 56

Реализация L2-списка на основе двух стеков. 57

Реализация L2-списка с обеспечением выделения/освобождения памяти. 58

Лекция 7. 59

Структуры данных. Графы. 59

Графы.. 59

Поиск пути в графе с наименьшим количеством промежуточных вершин. 59

Представление графа в памяти ЭВМ... 64

Лекция 8. 68

Структуры данных. Графы. 68

Поиск кратчайшего пути в графе. 68

Лекция 9. 73

Бинарные деревья поиска. 73

Поиск элемента в дереве. 74

Добавление элемента в дерево. 74

Поиск минимального и максимального элемента в дереве. 75

Удаление элемента из дерева. 75

Поиск следующего/предыдущего элемента в дереве. 75

Слияние двух деревьев. 75

Разбиение дерева по разбивающему элементу. 76

Сбалансированные и идеально сбалансированные бинарные деревья поиска. 77

Операции с идеально сбалансированным деревом.. 78

Операции со сбалансированным деревом.. 79

Поиск элемента в дереве. 79

Добавление элемента в дерево. 79

Удаление элемента из дерева. 83

Поиск минимального и максимального элемента в дереве. 83

Поиск следующего/предыдущего элемента в дереве. 83

Слияние двух деревьев. 83

Разбиение дерева по разбивающему элементу. 85

Лекция 10. 87

Красно-черные деревья. 87

Отступление на тему языка С. Поля структур. 87

Отступление на тему языка С. Бинарные операции. 89

Высота красно-черного дерева. 89

Добавление элемента в красно-черное дерево. 90

Однопроходное добавление элемента в красно-черное дерево. 92

Удаление элемента из красно-черного дерева. 94

Лекция 11. 97

B-деревья. 97

Высота B-дерева. 98

Поиск вершины в B-дереве. 99

Отступление на тему языка С. Быстрый поиск и сортировка в языке С.. 99

Добавление вершины в B-дерево. 101

Удаление вершины из B-дерева. 102

Лекция 12. 106

Хеширование. 106

Метод многих списков. 106

Метод линейных проб. 107

Метод цепочек. 111

Хэш-функции. 113

Хэш-функции на основе деления. 113

Хэш-функции на основе умножения. 113

CRC-алгоритмы обнаружения ошибок. 114

Лекция 14. 118

Поиск строк. 118

Отступление на тему языка С. Ввод-вывод строк из файла. 118

Алгоритм поиска подстроки с использованием хеш-функции (Алгоритм Рабина-Карпа) 119

Конечные автоматы.. 120

Отступление на тему языка С. Работа со строками. 121

Алгоритм поиска подстроки, основанный на конечных автоматах. 121

Лекция 15. 123

Алгоритм поиска подстроки Кнута-Морриса-Пратта (на основе префикс-функции) 123

Алгоритм поиска подстроки Бойера-Мура (на основе стоп-символов/безопасных суффиксов) 125

Эвристика стоп-символа. 125

Эвристика безопасного суффикса. 127

Форматы BMP и RLE.. 130


Лекция 1

Представление чисел в ЭВМ

 

Целые

Беззнаковые целые.

Используется двоичное представление чисел.

x = an-1*2n-1 + an-2*2n-2 + … a1*21 + a0*20

здесь каждый член ai представляет собой 0 или 1 или, иначе говоря, одну цифру в двоичном представлении данных.

            n задается количеством бит (в одном бите хранится одна двоичная цифра), отводящихся под данное число.

 

BCD - Binary Coded Decimal

В одном байте хранятся две цифры десятичного представления. Применяется, например, в СУБД Oracle для хранения вещественных чисел с плавающей точкой.

 

Целые со знаком.

Существует три основных формата:

  • прямой (старший бит служит признаком знака: 1=’-‘; 0=’+’)
  • обратный (для представления отрицательного числа берется представление его модуля, а потом все биты инвертируются)
  • дополнительный (для представления отрицательного числа берется представление его модуля, все биты инвертируются, к результату прибавляется 1)

 

Реально, в основном, используется только дополнительный код.

 

Утверждение 1. Представление чисел в дополнительном коде эквивалентно представлению чисел в кольце вычетов по модулю 2n, где n – количество бит в двоичном представлении числа.

 

Следствие. Операции сложения, вычитания и умножения корректно производятся независимо от способа интерпретации целых чисел – дополнительного кода (для знакового целого) или беззнакового целого.

 

Вещественные

 

Вещественные числа с фиксированной запятой.

 

VB тип Currency - восьмибайтовая переменная, которая содержит вещественное число в формате с фиксированной десятичной запятой (четыре десятичных знака после запятой).

 

C# тип decimal

Вещественные числа с плавающей запятой.

 

Формат закреплен IEEE: Institute of Electrical and Electronics Engineers.

Число представляется в виде

 

x = s * m * 2d

 

где

 s – знак (отводится один бит)

 m – мантисса в виде 1.xxxxx (где x – двоичная цифра; 1 не хранится)

 d – степень (оставшиеся биты)

 

Согласно формату IEEE вместо степени хранится характеристика = d-dmin , где dmin – минимально возможное значение мантиссы. Таким образом, минимально возможное значение характеристики = 0.

На самом деле, по стандарту IEEE  представление более сложное. Пока характеристика положительна поддерживается вышеуказанное представление. Для нулевой характеристики число хранится уже как число с фиксированное запятой в виде

 

x = p * 2-dmin

где  p – число с фиксированной точкой в виде x.xxxxx (где x – двоичная цифра; хранятся все указанные биты!).

 

Таким образом, отрицательные степени двойки будут иметь битовое представление (степень выделена пробелами):

 

  2-122:   1.88079e-037: 0 00000101 00000000000000000000000

  2-123:   9.40395e-038: 0 00000100 00000000000000000000000

  2-124:   4.70198e-038: 0 00000011 00000000000000000000000

  2-125:   2.35099e-038: 0 00000010 00000000000000000000000

  2-126:   1.17549e-038: 0 00000001 00000000000000000000000

  2-127:   5.87747e-039: 0 00000000 10000000000000000000000

  2-128:   2.93874e-039: 0 00000000 01000000000000000000000

  2-129:   1.46937e-039: 0 00000000 00100000000000000000000

  2-130:   7.34684e-040: 0 00000000 00010000000000000000000

  2-131:   3.67342e-040: 0 00000000 00001000000000000000000

  2-132:   1.83671e-040: 0 00000000 00000100000000000000000

  2-133:   9.18355e-041: 0 00000000 00000010000000000000000

  2-134:   4.59177e-041: 0 00000000 00000001000000000000000

  2-135:   2.29589e-041: 0 00000000 00000000100000000000000

  2-136:   1.14794e-041: 0 00000000 00000000010000000000000

  2-148:   5.73972e-042: 0 00000000 00000000000000000000010

  2-149:   2.86986e-042: 0 00000000 00000000000000000000001

  2-150:   1.43493e-042: 0 00000000 00000000000000000000000

Здесь следует отметить, что в Intel-архитектуре байты переставлены в обратном порядке и в вышеописанном представлении байты выводятся в порядке: 3, 2, 1, 0.

Т.о. число 2-150 уже неотличимо от 0.Если вышеуказанные число получались каждый раз путем деления предыдущего числа на 2, то в результате получения последнего число из 2-149 мы получили ситуацию underflow – нижнее переполнение. Как правило, процессоры могут отслеживать underflow, но нижнее переполнение можно расценивать как ошибку, а можно и не расценивать, ведь при многих вычислениях слишком маленькие числа можно расценивать как нулевые. Поэтому, обычно никаких сообщений в ситуации underflow не производится.

Переполнение при увеличении чисел называется overflow. При увеличении чисел с плавающей точкой степень увеличивается до максимально возможной. Когда степень достигает максимально возможного значения, то подобное число уже числом не считается, независимо от значения бит мантиссы:

 

  2118:   3.32307e+035: 0 11110101 00000000000000000000000

  2119:   6.64614e+035: 0 11110110 00000000000000000000000

  2120:   1.32923e+036: 0 11110111 00000000000000000000000

  2121:   2.65846e+036: 0 11111000 00000000000000000000000

  2122:   5.31691e+036: 0 11111001 00000000000000000000000

  2123:   1.06338e+037: 0 11111010 00000000000000000000000

  2124:   2.12676e+037: 0 11111011 00000000000000000000000

  2125:   4.25353e+037: 0 11111100 00000000000000000000000

  2126:   8.50706e+037: 0 11111101 00000000000000000000000

  2127:   1.70141e+038: 0 11111110 00000000000000000000000

  2128:         1.#INF :     0 11111111 00000000000000000000000

  2129:         1.#INF :     0 11111111 00000000000000000000000

  2130:         1.#INF :     0 11111111 00000000000000000000000

 

Будем далее называть представление вещественных чисел с плавающей точкой в диапазоне [2-dmin, 2dmin] стандартным представлением вещественных чисел с плавающей точкой.

 

Итак, стандартом IEEE закреплено наличие дополнительных констант:

 

NAN – not a number. Не число = 0/0   [ (0111 1111)(1100 0000) 0…0]

+INF – плюс бесконечность                [ (0111 1111)(1000 0000) 0…0]

-INF – минус бесконечность               [ (1111 1111)(1000 0000) 0…0]

+0 = 0x00 00 00 00

-0  = 0x80 00 00 00

 

+INF, -INF,+0, -0  можно корректно сравнивать. +0 = = -0 = = 0.

NAN при сравнении возвращает ложь всегда кроме !=. В этом случае возвращается истина.

 

 

Для IBM PC:

 

float  (32bit)

1bit – знак

8bits – степень+127 (127=27-1)

23bits – символы xxxxx из мантиссы (мантисса в виде 1.xxxxxx)

 

пример 1.f=

 

0             = знак

01111111 = степень+127 (занимает 8 бит)

0000…0 = символы xxxxx из мантиссы (мантисса в виде 1.xxxxxx)

Итого: 00111111 10000000 00000000 00000000

Осталось заметить, что байты идут в обратном порядке (также и в последующих примерах).

 

doudle (64bit)

1bit – знак

11bits – степень+210-1

52bits – символы xxxxx из мантиссы (мантисса в виде 1.xxxxxx)

 

пример 1.f=

00111111 11110000 00000000 00000000 00000000 00000000 00000000 00000000

 

long double (80bit)

1bit – знак

15bits – степень+214-1

64bits – символы 1xxxxx из мантиссы (мантисса в виде 1.xxxxxx)

 

В некоторых версиях языка С тип long double присутствует, но является всего лишь синонимом типа double (например, в текущей версии Microsoft C это именно так).

Надо всегда иметь в виду, что выше описан стандарт представления вещественных чисел с плавающей точкой, который поддерживается на архитектуре используемых персональных компьютеров (Intel/AMD), но существуют и другие (нестандартные) реализации вещественных чисел с плавающей точкой.

Например, IBM-формат вещественных чисел с плавающей точкой предполагает, что число представляется в виде: x = s * m * 16d.  Подобный подход расширяет диапазон возможных значений числа, но уменьшает точность представления чисел. Кроме того, данный подход требует хранения всех бит мантиссы (в стандартном представлении старший бит всегда равен 1 и поэтому он не хранится в машинном представлении числа). Числа в данном формате не меняют своего представления для очень маленьких и очень больших по модулю чисел, что упрощает реализацию алгоритмов работы с такими числами. При этом нет проблем с представлением нуля: нулевая мантисса всегда соответствует числу, равному нулю. Данный формат представления чисел до сих пор используется, например, при представлении сейсмических данных в геологической разведке.

Даже в Intel-архитектуре представления вещественных чисел могут присутствовать отклонения от стандарта. Так, 80-битные вещественные числа обычно используются для представления вещественных чисел в родном Intel-формате чисел, с которыми, собственно, работает процессор (тут следует отметить, что в зависимости от установок процессора 80-битные вещественные числа, вообще говоря, могут интерпретироваться процессором по-разному). В этом формате  старший бит мантиссы обычно не изымается из машинного представления вещественного числа, что несколько уменьшает возможную точность представления вещественного число, в  обмен на более простые алгоритмы работы с такими числами.

 

Ошибки вычислений

 

Для экономии времени мы не будем давать точного определения понятий `много больше’ (>>) и `много меньше’ (<<).

 

Определение 1.  e1 = argmin{v>0|1+v != v}

Определение 2.  e2 = argmax{v>0|1+v = = v}

 

Легко видеть, что e1 =[(0011 1111) (1 000 0000)(0000 0000)(0000 0001)]-1=2-23 и что e2  отличается от него на очень мало, поэтому можно говорить об одном числе e =e1 .

 

Определение 3. Назовем абсолютной ошибкой приближения числа x0 с помощью числа x такое  D, что

| x - x0| < D

 

Определение 4. Назовем относительной ошибкой приближения числа x0 с помощью числа x такое  d, что

| x - x0| / | x0 | < d

 

Часто более удобно пользоваться другим определением числа d:

| x - x0| / | x | < d

Это связано с тем, что, как правило, нам известно лишь приближение исследуемого числа ( x ), а не оно само ( x0 ). В ситуации, когда | x - x0| << | x0 | и | x - x0| << | x | эти два Определения отличаются несущественно. Кроме данных допущений, мы также будем предполагать, что все относительные ошибки много меньше 1.

 

Утверждение 2. При сложении/вычитании чисел их абсолютные ошибки складываются, т.е.

| (x+y) (x0+y0) |  £ Dx+Dy

 

| (x y) (x0 y0) |  £ Dx+Dy

где | x x0  |  < Dx  ,  | y y0  |  < Dy  .

 

Доказательство элементарно.

 

Утверждение 3. При умножении/делении чисел их относительные ошибки складываются (с точностью до пренебрежимо малых членов), т.е.

| (x*y x0*y0) / (x*y)|  £» dx+dy

 

| (x/y x0/y0) / (x/y)|  £» dx+dy

 

где | x - x0| / | x | < dx  ,  | y y0  | / | y | < dy  .

 

Доказательство.

1.

Итак, рассмотрим сумму относительных ошибок:

 

 (x - x0) /  x  + (y y0 ) /  y = (x* y - x0* y + y* x - x* y0) /  (x* y) =

 

= (x* y - x0* y + y* x - x* y0    -  x* y + x0* y0 ) /  (x* y) + ( x* y + x0* y0 ) /  (x* y)

= dx * dy + ( x* y - x0* y0 ) /  (x* y)

 

получаем:

|( x* y - x0* y0 ) /  (x* y)|  £» dx + dy

2.

Перепишем относительную ошибку частного:

 (x/y x0/y0) / (x/y) = (x* y0 x0 y) / (x*y0)

 

Итак, рассмотрим сумму относительных ошибок (незначительно отличающихся от основного определения):

 

 (x - x0) /  x  + (y0 y ) /  y0 = (x* y0 - x0* y0 + y0* x - x* y) /  (x* y0) =

 

= (x* y0 - x0* y0 + y0* x - x* y - x* y0 + x0 y) /  (x* y0) + (x* y0 x0 y) / (x*y0)

»= dx * dy + (x* y0 x0 y) / (x*y0)

 

получаем:

| (x/y x0/y0) / (x/y)|  £» dx+dy

Ч.Т.Д.

 

Легко видеть, что число e  представляет собой абсолютную ошибку представления числа 1. Здесь имеется в виду, что все числа, отличающиеся от 1 менее, чем на e, будут равны 1. Отсюда сразу же вытекает, что e также является и относительной погрешностью представления числа 1 в ЭВМ.

Из представления вещественного числа

x = s * m * 2d  (1≤m<2)

сразу вытекает, что значение вещественного числа по порядку задается только его степенью. Здесь имеется в виду, что при одной и той же степени, изменение мантиссы числа может изменить значение числа не более, чем вдвое:

1≤x /( s  * 2d )<2 (где x = s * m * 2d)

т.е.

x = s * m * 2d  =» s * 2d

Но легко видеть, число x = s * m * 2d   имеет абсолютную погрешность представления, равную  e * 2d , т.к. эта величина равна значению последнего бита мантиссы при заданном значении степени. Отсюда сразу вытекает, что относительная ошибка представления числа x = s * m * 2d   , равная

e * 2d / (m * 2d) = e  / m

не более, чем вдвое отличается от e. Т.о. мы получили следующую важную теорему

 

Теорема. Любое вещественное число x в стандартном представлении числа с плавающей точкой (т.е. не являющееся слишком большим или слишком маленьким по модулю в вышеописанном смысле) имеет относительную ошибку представления порядка e (точнее, не более, чем вдвое отличающуюся от e) и имеет абсолютную ошибку представления порядка x*e (точнее, не более, чем вдвое отличающуюся от x*e).

 

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

 


Лекция 2

Алгоритмы. Сведение алгоритмов.

Нижние и верхние оценки.

 

Д.Кнут. Искусство программирования  для ЭВМ. тт 1-3. Москва. Мир. 1996-1998

Т.Кормен, Ч.Лейзерсон, Р.Ривест. Алгоритмы. Построение и анализ. Москва. МЦНМО. 1999.

Препарата Ф., Шеймос М. Вычислительная геометрия. Москва. Мир. 1989

 

Мы не будем давать строгого определения понятия алгоритма, однако основные свойства понятия  алгоритма, задаваемые при его определении, мы все же опишем.

Алгоритмом m называется формально описанная процедура, имеющая некоторый набор входных данных In(m) и выходных данных Out(m). Вводится некоторый параметр, оценивающий объем входных данных N=N(In(m)).  Будем называть этот параметр размером входных данных. Заметим, что, на самом деле, алгоритм решает некоторую формально поставленную задачу z, имеющую те же самые входные и выходные данные.

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

Для оценки времени работы алгоритма с каждой допустимой операцией ассоциируется время ее выполнения. Временем выполнения реализации алгоритма T(m) для определенных начальных данных называется сумма времен выполнения всех операций алгоритма при выполнении данной реализации.

Верхней оценкой времени выполнения алгоритма m называется такая функция F(N), что для любого набора входных данных In(m) размером не более N время выполнения алгоритма не будет превосходить F (N). Будем говорить, что задача z  имеет верхнюю  оценку времени решения F (N), если существует алгоритм m с верхней оценкой времени выполнения F (N).

Разумно задаться вопросом: а для любого ли алгоритма существует его верхняя оценка времени работы? Элементарно привести пример, для которого верхней оценки времени работы не существует (например, можно рассмотреть задачу выписывания всех десятичных знаков обычного целого числа). Однако, если количество различных вариантов входных данных объема N алгоритма для любого N конечно, то легко показать, что верхняя оценка времени работы алгоритма существует. Нас интересуют алгоритмы, которые можно реализовать на компьютере. Но у компьютера конечное количество ячеек памяти, поэтому и количество их комбинаций конечно. В таком случае на компьютере можно задать только конечное количество вариантов входных данных для любой задачи.

Последний факт очевиден, но, все же, требует доказательства. Доказательство строится от противного: допустим, количество различных вариантов наборов значений ячеек памяти компьютера N, а задача имеет большее количество наборов исходных данных. В таком случае найдется хотя бы два варианта исходных данных задачи, соответствующих одному и тому же набору значений ячеек памяти компьютера, что противоречит тому, что мы можем задать на компьютере все варианты исходных данных задачи.

Итак, мы получили, что для всех алгоритмов, которые можно реализовать на компьютере существует верхняя оценка времени работы. На самом деле, этот факт не стоит ничего, т.к., с одной стороны, функция F (N)  может очень быстро расти, а с другой, конечность количества ячеек компьютера всегда вступает в противоречие с бесконечностью мира. Например, конечность количества ячеек делает бессмысленным рассмотрение функции  F (N)  для любого натурального N.

Нижней оценкой времени выполнения алгоритма m  называется такая функция j(N), что для любого N найдется такой набор входных данных алгоритма размером N, что время работы данного алгоритма на указанных данных будет не меньше j(N).

Нижней оценкой времени решения задачи z  называется такая функция j(N), что для любого алгоритма, решающего данную задачу,  j(N) будет нижней оценкой времени работы данного алгоритма.

 

Будем говорить, что задача z1 сводится к задаче z2 за время g(N), если

·         входные данные задачи z1, имеющие объем N, могут быть приведены к входным данным задачи z2, при этом входные данные задачи z2 тоже имеют объем N;

·         выходные данные задачи z2 могут быть приведены к выходным данным задачи z1,

и все это за суммарное время g(N) (т.е. суммарное время выполнения алгоритмов приведения = g(N)). По аналогии, можно говорить, что алгоритм m1 сводится к алгоритму m2 за время g(N) (определение аналогично).

 

Теорема 1. Если задача z1 сводится к задаче z2 за время g(N) и задача z2 имеет верхнюю оценку времени решения F2(N), то задача z1  имеет верхнюю оценку времени решения F1(N) = F2(N) + g(N).

 

Доказательство теоремы тривиально.

 

Теорема 2. Если задача z1 сводится к задаче z2 за время g(N) и задача z1 имеет нижнюю оценку времени решения j1(N), то задача z2  имеет нижнюю оценку времени решения j2(N) = j1(N) - g(N).

Доказательство. Выпишем аккуратно условие того, что j2(N) является нижней оценкой времени решения задачи z2:

" алгоритма m2 :  j2(N) -  нижняя оценка времени решения задачи z2.

или:

" алгоритма m2 и "N>0 $ In(m2) с размером, равным N:  T(m2 (In(m2)))≥j2(N)  = j1(N) - g(N).

Поведем доказательство от противного. Допустим это не так, т.е. выполняется условие:

$ алгоритм m2 и N>0: " In(m2) :  T(m2 (In(m2)))<j2(N)  = j1(N) - g(N).

Тогда существует алгоритм (заключающийся в сведении задачи m1 к задаче m2 за время g(N)), для которого на всех исходных данных размера N задача решается за время, меньшее j1(N) - g(N)+ g(N)= j1(N) , что противоречит условию теоремы.

¢

 

 

 

 

 

Введем обозначения.

 

g(n)=O(f(n))  если $ N0>0 и С>0, такие что " n>N0 :| g(n) | £ C| f(n) |

 

g(n)=o(f(n)) если " С>0 $ N0>0,  такое что " n>N0 : |g(n) | £ C |f(n) |

 

g(n)=Q(f(n)) если $ N0>0 и С1, С2>0, такие что " n>N0 :

С1 |f(n) | £ |g(n) | £ С2 |f(n) |

 

g(n)=W(f(n)) если $ N0>0 и С>0, такие что " n>N0 : |g(n) | ³ C |f(n) |

 

 

Сортировки

Постановка задачи

Для элементов некоторого множества P введены соотношения сравнения. Под этим будем подразумевать следующее: для каждых двух элементов a,b Î P  верно ровно одно из  трех соотношений: a<b, a>b, a=b. Эти соотношения должны обладать свойствами транзитивности:

a<b, b<c  Þ  a<c

a>b, b>c  Þ  a>c

a=b, b=c  Þ  a=c

и аналогом свойства симметричности:

a<b Û  b>a

 

Пусть дано некоторое упорядоченное подмножество (последовательность) элементов из P : {a1, …, aN}, aiÎ P. Требуется найти такую перестановку (x1,…,xN), что ax1, …, axN – будет неубывающей последовательностью, т.е. axi < ax(i+1) или axi = ax(i+1)  . Напомним, что перестановкой n элементов мы называем некоторое взаимно-однозначное соответствие множества чисел {1,…,N} с таким же множеством чисел {1,…,N}, т.е. такую функцию s: {1,…,N} -> {1,…,N}, для которой если i¹j , то s(i)¹s(j).

Здесь, конечно, надо сразу задаться вопросом: а возможно ли это сделать при данных ограничениях на приведенные операции сравнения? Другим разумным вопросом будет: а если это можно сделать, то единственным ли (с точностью до перестановок подряд идущих элементов, между которыми выполняется соотношение =) способом? Ответы на оба вопросы положительны.

Доказательства утверждения, кроющегося в первом вопросе (о существовании перестановки), легко провести по индукции по n.

Для доказательства утверждения, кроющегося во втором вопросе (о единственности перестановки), можно сначала показать, что в упорядоченном множестве элементы, между которыми выполняется соотношение = должны идти подряд, что дает возможность заменить их одним элементом. Далее можно ввести функцию M(i) – количество элементов из {a1, …, aN}, меньших ai. Отметим, что для любых ij  выполняется: M(i)≠M(j) (действительно: выполняется либо ai < aj, либо ai > aj, откуда легко вывести, что, соответственно, M(i)<M(j) , либо M(i)>M(j)), из чего сразу вытекает (учитывая, что 0≤M(i)<n), что функция M(i) принимает все возможные значения от 0 до n-1. Легко показать, что эта функция однозначно определяет положение элемента ai в упорядоченном множестве.

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

Будем говорить, что алгоритм сортировки основан на операциях сравнения, если алгоритм может быть записан в виде бинарного дерева (дерева решения), каждая вершина которого либо является завершающей (т.е. при попадании в нее исходная последовательность данных оказывается отсортированной), либо:

·         вычисляется некоторая функция от входных данных алгоритма,

·         производится сравнение полученной величины с 0 (одной из операций: <, > или =)

·         от каждой вершины дерева, в зависимости от полученного результата, происходит переход к левой или правой ветви дерева

·          на каждой ветви дерева происходит одна транспозиция элементов входных данных (обмен местами двух определенных элементов последовательности).

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

 

Будем говорить, что алгоритм сортировки основан на операциях простого сравнения, если алгоритм основан на операциях сравнения и в нем допускаются только попарные сравнения элементов исходного массива данных.

 

Если исходные данные задачи принадлежат k-мерному Евклидову пространству и если вычисляемая в узлах функция является многочленом степени n, то говорят, что алгоритм представим в виде алгебраического дерева степени n.

Сортировка пузырьком.

Алгоритм:

 

N-1 раз выполняется следующая процедура:

    Для всех i  от 1 до N-1 c шагом 1:

        если axi > ax(i+1)   то поменять местами axi и ax(i+1)

 

Легко видеть, что алгоритм требует порядка O(N2) арифметических операций.

 

Теорема. Алгоритм сортировки пузырьком является оптимальным по порядку времени выполнения среди алгоритмов, основанных на операции сравнения, если обмен местами двух элементов последовательности требует O(N) времени.

 

Ситуация, отраженная в теореме, возможна, например, при сортировке данных записанных на ленте, в какой-то мере, для данных, записанных на жестком диске. В быту данная ситуация реализуется в случае, когда у нас есть большой набор карточек с какими-то записями, выложенными в длинный ряд.

 

Доказательство. Рассмотрим следующую последовательность:

N, N-1, N-2, … 2, 1

Для любой сортировки этой последовательности следует первый в ней элемент поставить на последнее место (=порядка O(N-1) операций), второй элемент поставить на предпоследнее место (=порядка O(N-2) операций) и т.д. Итого, только перестановки займут не менее O(N2) времени, откуда мы получаем нижнюю оценку времени выполнения алгоритмов данного класса. Данная оценка достигается на предложенном алгоритме.

¢

 

 

 


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

 

Теорема.  Нижней оценкой времени решения задачи сортировки в рамках алгоритмов, основанных на операции сравнения, является Q (N log2 N). Т.е. существует функция g(N)=Q (N log2 N), являющаяся нижней оценкой решения задачи сортировки в рамках алгоритмов, основанных на операции сравнения.

 

Замечание. На самом деле, не обязательно ограничивать операции, допустимые после сравнения элементов в дереве решения, лишь одной транспозицией. Можно разрешить выполнять произвольную перестановку всех N элементов за 1 единицу времени. Теорема останется верной. 

 

Доказательство. Рассмотрим решение задачи о сортировке набора из N целых чисел от 1 до N. Решение можно представить как дерево решения. Исключим из этого дерева все ветки, начиная с элемента, в который нельзя попасть и до завершающего элемента дерева. Удаление данных веток никак не повлияет на реальный алгоритм.

Теперь каждой перестановке s(1,…,N)  (здесь под s подразумевается перестановка элементов множетва {1,…,N}) соответствует своя концевая вершина в дереве решения (соответствующая только этой перестановке), такая что ветка дерева решения от корня до данной вершины задает перестановку p, обратную s.: p(s(i))=i "iÎ{1,…,N}.  Т.е. данная ветка задает решение задачи сортировки для последовательности исходных данных {s(1), s(2,)… s(N)}.

Действительно, в силу определения дерева решений, для каждой последовательности исходных данных {s(1), s(2,)… s(N)} мы имеем ровно одну ветвь дерева решений (от корня до завершающей дерево вершины), сортирующую данную последовательность. Причем, каждая завершающая дерево вершина является концевой ровно для одной перестановки s(1,…,N). Действительно, мы исключили вершины, до которых нельзя в принципе добраться, поэтому осталось исключить ситуацию, когда вершина соответствует сразу двум различным перестановкам s(1,…,N) и r(1,…,N). Но в последнем случае мы имеем: p(s(i))=i и p(r(i))=i "iÎ{1,…,N}, где перестановка p описана выше. Из чего сразу получаем, что перестановки s и r совпадают.

Таким образом, мы доказали, что количество завершающих вершин дерева решений равно количеству перестановок множества {1,…,N}, равно n!.

Будем называть глубиной дерева количество вершин в его самой длинной ветке. Для дерева глубины h мы имеем, что хотя бы в одной ветви дерева количество сравнений равно h-1, из чего сразу получаем, что h-1 является нижней оценкой времени работы всех алгоритмов, описываемых деревьями сравнения глубины h (мы задаем время сравнения равное 1).

Дерево глубины h не может иметь количество концевых вершин K более чем

2h-1: K2h-1, откуда получаем: h  ³ (log2 K) +1.

Итого, в нашем случае:

 

h  ³ (log2 K) +1 = (log2 N!) + 1 ~N log2 N.

Здесь мы использовали известную формулу Стирлинга:

n! = nne-nsqrt(2pN) ( 1+o(1) )

из которой сразу следует, что

log2 N! = (N log2 N  -N log2 e + log2 sqrt(2pN) )( 1+o(1) ) = (N log2 N)( 1+o(1) )

¢

 

Приведем примеры, показывающие, что приведенная нижняя оценка времени решения задачи сортировки достижима.

 

Сортировка слиянием с рекурсией.

Слиянием двух упорядоченных множеств называется процесс упорядочения объединения данных множеств.

 

Теорема. Пусть даны два упорядоченных множества {A1,…,AN } и {B1,…,BN }.       В рамках алгоритмов, основанных на простых сравнениях, данные множества нельзя слить быстрее, чем за 2N-1 сравнение в худшем случае. Т.е. 2N-1 является нижней оценкой времени работы алгоритма, если учитывать только время, расходуемой на сравнения элементов множеств, и если положить время одного сравнения равным 1.

 

Доказательство. Пусть для конкретных заданных множеств выполняются соотношения Ai< Bi и Ai+1> Bi. Тогда отсортированное объединение множеств выглядит следующим образом: {A1, B1, A2, B2 ,…, AN,BN }.  Если хотя бы одно из приведенных 2N-1 соотношений не будет проверено, то найдется еще хотя бы одна перестановка элементов множества, удовлетворяющая всем приведенным соотношениям. Например, если не будет проверено соотношение A2> B1, то следующая последовательность будет удовлетворять всем остальным соотношениям:

{A1, A2, B1, B2 ,…, AN,BN }.

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

 

¢

 

Дословно так же доказывается следующая теорема

 

Теорема. Пусть даны два упорядоченных множества {A1,…,AN +1} и {B1,…,BN }.       В рамках алгоритмов, основанных на простых сравнениях, данные множества нельзя слить быстрее, чем за 2N сравнений элементов множества в худшем случае.

 

Алгоритм слияния. Пусть даны два упорядоченных множества {A1,…,AM} и {B1,…,BN }. Введем индексы i, j и k . Изначально i=1, j=1 и k=1 .

 


Пока  i£M и j£N:

     Если Ai < Bj  то

       Сk++ = Ai++

       иначе

      Сk++ = Bi++

    Конец Если

 Конец Цикла

Пока  I £ M:

       Сk++ = Ai++

Конец Цикла

Пока  j £ N:

      Сk++ = Bi++

Конец Цикла

 

 


Легко увидеть,  что в данном алгоритме элементы множества сравниваются не более M+N-1 раз. Т.о. данный алгоритм оказывается строго оптимальным по числу сравнений элементов сортируемого множества (по крайней мере в алгоритмах, основанных на простых сравнениях).

 

Вопрос на понимание: можно ли два упорядоченных множества {A1,…,AN } и {B1,…,BN} слить быстрее чем за 2N-1 операций сравнения в каком либо алгоритме, основанном операциях сравнения? … на операциях простого сравнения?

 

Алгоритм сортировки слиянием. Обозначим данный алгоритм Z(A1,…,AM ), где {A1,…,AN } – сортируемое множество элементов. Алгоритм имеет следующий вид

 


Если число обрабатываемых элементов  £ 1  то ВЫЙТИ

M1 = [ M/2 ]; M2 = M-M1; // размеры половин массива

Z(A1,…,AM1 )

Z(AM1+1,…,AM )

Слить упорядоченные множества {A1,…,A M1 } и { AM1+1,…,AM } в массив B.

Скопировать массив B в массив {A1,…,AN }.

 

 


Легко видеть, что данный алгоритм решает задачу за время O(N log2 N), где N – количество элементов в сортируемом массиве.

Недостатком алгоритма является необходимость использования дополнительного массива с размером, равным  размеру исходного массива.

 

Сортировка слиянием без рекурсии.

 

Предыдущий алгоритм можно модифицировать так, что он уже не будет использовать рекурсию. Действительно. Рассмотрим последовательно все пары элементов в сортируемом массиве. Каждый из элементов в паре представляет собой уже отсортированный массив длины 1, поэтому эти массивы (пока длины 1) можно слить в упорядоченные куски длины 2. Далее мы рассматриваем уже пары упорядоченных массивов длины 2 и сливаем их в массивы длины 4. И т.д.

Отметим, что при этих операциях на k-том проходе по упорядочиваемому массиву на правом конце массива мы будем получать либо ситуацию, когда у правого оставшегося куска (длины £ 2k ) вообще нет парного куска для слияния, либо кусок есть и его длина £ 2k. В первом случае делать вообще ничего не нужно, а во втором следует стандартным способом сливать куски, возможно, существенно различной длины.

Легко видеть, что данный алгоритм решает задачу за время O(N log2 N), где N – количество элементов в сортируемом массиве.

 

 


Лекция 3

Алгоритмы. Сведение алгоритмов.

Сортировки и связанные с ними задачи.

 

Д.Кнут. Искусство программирования  для ЭВМ. тт 1-3. Москва. Мир. 1996-1998

Т.Кормен, Ч.Лейзерсон, Р.Ривест. Алгоритмы. Построение и анализ. Москва. МЦНМО. 1999.

Препарата Ф., Шеймос М. Вычислительная геометрия. Москва. Мир. 1989

 

QuickSort.

Определение. Медианой множества А = {a1 ,…, aN } называется элемент с индексом (N+1)/2 в отсортированном по возрастанию множестве А.

 

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

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

В следующей реализации в комментариях показаны соотношения на значения элементов, которые выполняются после каждого шага алгоритма. Эти соотношения доказывают, что каждый раз массив разбивается на части, левая из которых не превосходит медианы, а правая – не меньше медианы. Здесь для простоты множество элементов { A s  , A s+1 , …  ,  A t } будем обозначать {s,t}. Медиану будем обозначать M.

 

QuickSort(A,p,q)

Если  q-p < 1 то ВЫЙТИ

Вечный цикл

   i=p; j=q; // пусть M=A j

//цикл 1:

   Пока Ai  < A j :  i + +;

//{p,i-1}<=M<={j,q}, Ai>=M

   поменять местами A i  и A j ;//x -> Ai

//{p,i}<=M<={j,q}

   j --;

//{p,i}<=M<={j+1,q}

   Если  i >= j то

//либо i==j                  то {p, j}<=M<={ j+1,q}

// либо i==j+1             то M== Aj+1  => {p, j}<=M<={ j+1,q}

     { QuickSort(A, p, j ); QuickSort(A, j+1, q );ВЫЙТИ }

//цикл 2:

   Пока A j  > Ai :  j - -;

//{p,i}<=M<={j+1,q}, A j<=M

   поменять местами A i  и A j ;//x -> A j

//{p,i}<=M<={j,q}

   i + +;

//{p,i-1}<=M<={j,q}

   Если  i >= j то

//либо i==j                  то M== Aj  => {p, j}<=M<={ j+1,q}

// либо i==j+1             то {p, j}<=M<={ j+1,q}

     { QuickSort(A, p, j ); QuickSort(A, j+1, q );ВЫЙТИ }

Конец вечного цикла

 

В силу построения алгоритма j не может стать меньше 0 и не может быть больше или равным q, поэтому гарантируется, что мы не попадем в бесконечную  рекурсию и границы рассмотрения массива корректны.

 

Отметим, что после первого цикла также имеем:

   Если  i >= j то

//либо i==j                  то {p, i}<=M<={ i+1,q}

// либо i==j+1             то M== Aj+1  => {p, i}<=M<={ i+1,q}

 т.е. рекурсию можно было бы организовать в виде:

{ QuickSort(A, p, i ); QuickSort(A, i+1, q );ВЫЙТИ }

но в этом случае мы можем попасть в бесконечную рекурсию, т.к. в цикле i может дойти вплоть до q.

После второго цикла также имеем:

   Если  i >= j то

//либо i==j                  то {p, i-1}<=M<={ i,q}

// либо i==j+1             то {p, i-1}<=M<={ i,q}

 т.е. рекурсию можно было бы организовать в виде:

{ QuickSort(A, p, i-1 ); QuickSort(A, i, q );ВЫЙТИ }

В этом случае i не может стать меньше 1 и не может быть больше q, поэтому такой вариант алгоритма также возможен.

 

--------------------------------------------------------------------------------

В более стандартной реализацией основной идеи данного алгоритма выбирается произвольный элемент x сортируемого множества в качестве среднего элемента и помещается в отдельную ячейку памяти. Далее, один шаг алгоритма заключается в следующем.  Мы двигаемся слева направо, пока элементы множества меньше x. Затем мы движемся справа налево, пока элементы множества больше x. Если мы еще не встретились, то следует поменять местами элементы, на которых мы стоим при движении в каждую сторону и повторить шаг алгоритма. Если же мы встретились, то алгоритм вызывается для каждой из полученных половин множества.

 


QuickSort(A,p,q)

Если  q-p < 1 то ВЫЙТИ

 i=p; j=q; x=Ai

Вечный цикл

   Пока A i  < x :  i + +;//{p,i-1}<=x, Ai >=x

   Пока A j  > x :  j - -;//{j+1,q}>=x, Aj <=x

 

   Если  i < j то

      поменять местами A i  и A j ; //{p,i}<=x, {j,q}>=x

   иначе

     {

//либо i==j      то Ai ==x => {p,j}<=x, {j+1,q}>=x

//либо i==j+1 то {p,j}<=x, {j+1,q}>=x

      QuickSort(A, p, j ); QuickSort(A, j+1, q );ВЫЙТИ

     }

   i + +; j - -;//{p,i-1}<=x, {j+1,q}>=x

Конец вечного цикла

 

Замечание 1. При работе алгоритм индексы массива i и j никогда не выйдут за его границы p и  q.

 

Замечание 2. В алгоритме никогда не произойдет вечной рекурсии, т.е. при рекурсивном вызове

p £  j < q

 

Замечание 3. Алгоритм гарантирует корректное разбиение массива, т.е. после разбиения массива выполняются соотношения

Ak £ x для всех k:  p £ k £  j

Al ³ x для всех k:  j+1 £ k £ qj

 

Тонкость алгоритма характеризует следующее наблюдение. Давайте попробуем ``обезопасить алгоритм’’ и включим обеспечение условия i £  j  в циклы алгоритма Пока…  . Т.е. приведем алгоритм к следующему виду:

 

 


QuickSort*(A,p,q)

Если  q-p < 1 то ВЫЙТИ

 i=p; j=q; x=Ai

Вечный цикл

   Пока A i  < x  и  i <  j :  i + +;

   Пока A j  > x  и  i <  j :  j - -;

   Если  i < j то

      поменять местами A i  и A j ;

   иначе

     { QuickSort(A, p, j ); QuickSort(A, j+1, q );ВЫЙТИ }

   i + +; j - -;

Конец вечного цикла

 

Алгоритм QuickSort* оказывается неверным !!! Это легко увидеть на простейшем примере:  {3,4,2,5}.

Доказательство корректности работы алгоритма.

Доказательство корректности работы алгоритма сводится к доказательству Замечаний 1-3 для каждого тела вечного цикла алгоритма.

 

Рассмотрим два принципиально разных случая.

1. Случай Ap=min{ Ap , Ap+1 , ..., Aq }. Все остальные элементы массива больше Ap.

В конце первого выполнения тела вечного цикла алгоритма i=p, j=p. Все элементы в первой половине множества (она, в данном случае, состоит из одного элемента) оказываются меньше  элементов из второй половины. Массив разбивается на половины размерами 1:N-1, где N=q-p+1количество элементов в массиве.

2. Все остальные случаи.

 

Доказательство Замечания 1. При работе алгоритм индексы массива i и j никогда не выйдут за его границы p и  q.

Выход за границы массива, потенциально, может произойти только в результате выполнения циклов Покаили при выполнении операций   i + +; j - -;  в конце тела вечного цикла алгоритма.

Изначально на первом месте массива стоит Ap = x. В конце выполнения первого тела вечного цикла алгоритма  Ap может поменяться местами с элементом меньше либо равным x и в дальнейшем элемент Ap не изменится. Т.о. на первом месте массива всегда будет стоять элемент £ x и в процессе выполнения цикла Пока… j не сможет оказаться меньше p. В результате выполнения операций i + +; j - -;  выхода за левую границу массива также не может произойти, т.к. если бы это произошло, то это означало бы, что перед этой операцией выполнялось бы  j=p. Но в этом случае оказывается неверным соотношение  i < j  (т.к. i³p ) и, следовательно, попасть в данную точку алгоритма при этих условиях оказывается невозможным.

Разберемся с правой границей. Если в первый момент Aq ³ x, то j сразу же уменьшится на 1 и впоследствии Aq не изменится. Это гарантирует, что в процессе выполнения цикла Пока… i не сможет оказаться больше q. Если же Aq < x, то после циклов Пока…  i  и  j не изменятся и Ap и Aq сразу же поменяются местами. Далее Aq не изменится и i  не сможет превысить q. В результате выполнения операций i + +; j - -;  выхода за правую границу массива не сможет произойти по причинам аналогичным обсужденным ранее. Замечание доказано.

Доказательство Замечания 2. В алгоритме никогда не произойдет вечной рекурсии, т.е. при рекурсивном вызове

p £  j < q

 

Первое из требуемых неравенств доказано выше. Второе также легко доказать. Действительно, если при первом входе в тело вечного цикла алгоритма Aq ³ x, то j сразу же уменьшится, и мы получим требуемое. Иначе, при первом выполнении тела вечного цикла алгоритма в циклах Пока… , i и j  не изменятся, поэтому после этих циклов  i < j  и  j обязано уменьшится в конце тела цикла. Замечание доказано.

Доказательство Замечания 3. Алгоритм гарантирует корректное разбиение массива, т.е. после разбиения массива выполняются соотношения

Ak £ x для всех k:  p £ k £  j

Al ³ x для всех k:  j+1 £ l £ q

Согласно построению алгоритма в конце выполнения тела вечного цикла алгоритма гарантируется, что

Ak £ x для всех k < i

Al ³ x для всех l > j

 

Т.о. мы сразу же получаем выполнение второго из неравенств Замечания 3. Первое неравенство оказывается более хитрым (именно оно не выполняется при работе алгоритма QuickSort*). В рассматриваемом случае среди элементов правее первого найдется элемент меньше первого, из чего следует, что после первого выполнения циклов Пока…  в тела вечного цикла алгоритма  i останется строго меньше  j, после чего Ai  поменяется местом с Aj и выполнится  i + +; j - - . Т.о. элемент со значением x далее останется в правой половине массива (этот факт мы используем далее в доказательстве теоремы о среднем времени работы алгоритма QuickSort).

Если после выполнения циклов Пока..  в некотором теле вечного цикла алгоритма окажется, что i > j, то из приведенных соотношений сразу следует первое неравенство Замечания 3. Случай i < j говорит о незавершенности алгоритма. Осталось рассмотреть случай i = j. Этот вариант может реализоваться только в случае, когда Aj = x, тогда получаем, что Ak £ x для всех   k <  j  и Ak = x для всех   k =  j. Итого, получаем первое неравенство Замечания 3 (в алгоритме QuickSort* этот случай создается искусственно и поэтому первое неравенство из Замечания 3 остается невыполненным).

¢

Оценки времени работы алгоритма.

Оценим временя работы приведенного алгоритма в худшем случае.

Теорема. Время работы алгоритма  QuickSort  равно O(N 2), где N – количество элементов в сортируемом массиве.

Доказательство. После каждого разбиения массива на две части длина самой большой из двух образовавшихся половин оказывается меньше либо равной длине разбиваемого массива –1. Поэтому на каждой ветви алгоритма будет не более N узлов (разбиений массива). На каждом уровне дерева разбиений присутствуют не более N сортируемых элементов, поэтому время, затрачиваемое на слияние их подмножеств равно O( N ). Итого, суммарное время работы алгоритма равно O( N ) * N = O( N 2).

Данная оценка достижима на массиве {N,N-1,…,1}.

¢

 

Оказывается, что число ``неприятных’’ случаев, т.е. таких расположений массивов чисел, при которых  время работы алгоритма QuickSort велико, оказывается, относительно, небольшим. Вообще, верна теорема

 

Теорема. Среднее время работы алгоритма QuickSort равно Q(N log2 N), где N – количество элементов в сортируемом массиве. Под средним временем подразумевается среднее время по всем перестановкам любого массива входных данных длины N, состоящего из различных элементов.

 

Данная теорема объясняет, в каком смысле данный алгоритм является оптимальным. В то же время, в реальной жизни, часто поток входных данных не является случайным, поэтому в качестве медианы следует брать случайно выбранный элемент. Для этого внесем в алгоритм QuickSort следующее дополнение. Перед присваиванием x=Ai поменяем местами i-ый элемент массива со случайно выбранным элементом среди элементов с индексами от p до q. Назовем получившийся алгоритм QuickSortP. Приведенная теорема верна также и для алгоритма QuickSortP. Докажем ее именно для последнего алгоритма. Будем, кроме того, предполагать, что все элементы входной последовательности различны, или, что то же самое, на входе подается последовательность различных элементов из множества {1,…,N}.

В рассматриваемом случае если x=1, то перед входом в рекурсию алгоритма QuickSort множество разобьется на части размером 1 и N-1. В любом другом случае, как отмечалось выше в доказательстве Замечания 3, элемент x останется в правой половине массива и размер левой половины массива, поэтому, будет равен x-1.

Выпишем рекуррентное соотношение на среднее время работы алгоритма

 

            

T(N) =  [ (T( 1 ) +T( N-1 )) + Si=2i£N (T( i-1 ) +T( N-i+1 ))]/N + Q(N) =

=  [ (T( 1 ) +T( N-1 )) + Si=1i<N (T( i ) +T( N-i ))]/N + Q(N) =

 

= [ (T( 1 ) +T( N-1 ) ]/N + [  Si=1i<N (T( i ) +T( N-i ))]/N + Q(N) =

= [  Si=1i<N (T( i ) +T( N-i ))]/N + Q(N)

 

Предположим, для  i<N верно:

T( i )<a i log i +c для некоторых a>0,  c>0 ,

тогда задача сводится к нахождению таких a>0,  c>0 , что для них всегда бы выполнялось соотношение

 

[  Si=1i<N (T( i ) +T( N-i ))]/N + Q(N) < a N log N +c

Итак

[  Si=1i<N (T( i ) +T( N-i ))]/N < [  Si=1i<N (a i log i +c + a (N-i)  log (N-i) +c ))]/N =

= [  Si=1i<N (a i log i +c ))]2/N < a [  Si=1i<N i log i ]2/N +2 c

 

Оценим сумму из соотношения:

 

Si=1i<N i log i = Si=1i<N i log N + Si=1i<N i log (i/N) = N 2 log N / 2 - Si=1i<N i log (N/i) £  N 2 log N / 2 - Si=1i<N/4 i log (N/i) £  N 2 log N / 2 - Si=1i<N/4 i 2 £  N 2 log N / 2 - N 2/ 8

 

Т.о. имеем

 

[  Si=1i<N (T( i ) +T( N-i ))]/N + Q(N) < a (N  log N  - N / 4) + 2 c + Q(N) =

= a N log N  +  c + (Q(N) + c – a N / 4 )

Осталось взять такое большое a, что  (Q(N) + ca N / 4 )<0, после чего мы получаем требуемое соотношение.

¢

 

 


К сожалению, обе приведенные реализации алгоритма QuickSort не являются жизнеспособными. Это связано с тем, что в обоих алгоритмах максимально возможная глубина рекурсии равна N. В этом случае требуется порядка O(N) байт дополнительной памяти, что не фатально, но проблема в том, что эта память будет выделяться в стеке задачи, а стек задачи всегда имеет маленький (относительно) размер. Поэтому, например, в Microsoft Visual Studio мы может ожидать ситуации stack overflow при размерах целочисленного массива порядка 100000 (размер стека здесь по умолчанию равен 1M).

Выходом из положения является следующее решение. Будем рекурсивно решать задачу сортировки только для меньшей половины массива. А большую половину будем сортировать в этой же процедуре. В таком случае глубина погружения в рекурсию не будет превосходить ]log2N[, что является приемлемым.

В реальности для данной реализации требуется внести минимальные добавки в исходный алгоритм быстрой сортировки. Например, алгоритм QuickSort может быть модифицирован следующим образом:

 


QuickSortR1(A,p,q)

Если  q-p < 1 то ВЫЙТИ

Вечный цикл

Метка:

   i=p; j=q; x=Ai

   Пока A i  < x :  i + +;

   Пока A j  > x :  j - -;

   Если  i < j то

      поменять местами A i  и A j ;

   иначе

     {

      if(j-p<q-(j+1))

      {

       QuickSort(A, p, j );

        p=j+1;q=q;goto Метка;

      }

      иначе

      {

       QuickSort(A, j+1, q );

        p=p;q=j;goto Метка;

      }

      ВЫЙТИ

     }

   i + +; j - -;

Конец вечного цикла

 

Здесь добавлены:

- метка (может быть заменена еще одним вечным циклом),

- проверка, какая часть массива больше,

- переназначение p,q в каждом случае.

 

 


Некоторые задачи, сводящиеся к сортировке.

К задачам сортировки могут быть за линейное время  сведены следующие классические задачи

 

Задача 1. Найти все различные элементы множества {A1,…,AN }, где – Ai , например, целые или вещественные числа.

 

Задача 2. Определить, все ли элементы в множестве A={A1,…,AN } различаются, где – Ai , например, целые или вещественные числа.

 

 

Задача 3. Определить, совпадают ли два множества {A1,…,AN } и {B1,…,BN } с учетом количества одинаковых элементов в каждом множестве, где  Ai , Bi , например, – целые или вещественные числа.

 

Т.о., мы получаем, что для всех трех приведенных задач верхняя оценка времени решения равна Q (N log N).

Можно задаться вопросом: а можно ли решить эти задачи быстрее? Заметим, что Задача 2 может быть за линейное время сведена к Задаче 1, поэтому если мы докажем, что нижняя оценка времени решения Задачи 2 есть Q(N log N), то тем мы докажем неулучшаемость полученной оценки для Задачи 1.

Задача 2 относится к классу задач о принятии решения. Это значит, что на выходе таких задач выдается всего один бит информации, т.е. ответ `да’ или `нет’. Мы будем рассматривать алгоритмы решения задач о принятии решений, которые сводятся к бинарному дереву принятия решений. Под деревом принятия решений имеется в виду следующее. Пусть на входе нашего алгоритма находится вектор входных данных aÎIR   N.  Рассмотрим бинарное дерево, в каждой вершине которого вычисляется некоторая функция от вектора a и в зависимости от знака этой функции происходит переход на правую или левую ветвь дерева. Каждая конечная вершина дерева будет называться либо принимающей либо отвергающей, в зависимости от приписанного ей соответствующего атрибута. Достижение принимающей вершины означает выдачу алгоритмом ответа  `да’, а отвергающей, соответственно, -  `нет’.

Дерево принятия решений называется алгебраическим деревом принятия решений степени n, если функции в вершинах дерева являются многочленами степени не выше n.

Далее мы рассмотрим лишь алгоритмы, представимые в виде алгебраического дерева принятия решений степени 1. Мы будем предполагать, что время вычисления каждой функции в вершине дерева есть Q(1). Приведенная далее теорема верна и для деревьев высшей степени, но для ее доказательства потребуются весьма серьезные факты из теории функций, которые мы здесь привести не сможем.

Введем два определения.

Разделимые множества. Множества A, B Ì IR   N называются разделимыми  (линейно-разделимыми) если " aÎ A, bÎ B найдется cÎ [a,b], такое что сÏ A, сÏ B.

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

Теорема. Пусть W Ì IR   N – множество точек, на которых решением задачи будет `да’. Пусть #W – количество разделимых связных компонент множества W. Тогда, в рамках алгоритмов, описывающихся алгебраическими деревьями степени 1, нижняя оценка времени решения задачи равна W(log 2 #W).

Доказательство. Докажем, что количество принимающих вершин дерева принятия решений не меньше  #W.  Для этого докажем, что все элементы R   N, относящиеся к одной принимающей вершине дерева решений находятся внутри одной разделимой связной компоненты W.

Предположим противное: существует  принимающая вершина дерева принятия решений, в которую попадает алгоритм при использовании двух различных a,bÎ W ,  таких что a и b принадлежат различным разделимым связным компонентам Va , Vb Ì W,  соответственно. Рассмотрим [a,b]. Линейные функции от N переменных обладают свойством монотонности на отрезке, поэтому все функции, вычисляющиеся в вершинах дерева принятия решений, сохраняют свой знак на [a,b].  Т.о. весь отрезок [a,b] Ì W (т.к. все точки из отрезка обязаны попасть в одно и ту же принимающую вершину). Это противоречит предположению о принадлежности a и b различным разделимым компонентам.

Итак, мы получили, что количество концевых вершин бинарного дерева принятия решений не меньше #W, из чего сразу следует, что высота дерева не меньше [log 2 #W].

¢

 

Вернемся к решению Задачи 2. Рассмотрим множество W для Задачи 2. Легко увидеть, что W – открыто. Для точки P Î IR   N будем называть связной компонентой, содержащей P, множество WP Ì W, состоящее из таких точек R Î W, для которых существует ломаная, соединяющая P и R , содержащаяся в W. В силу открытости W каждая такая точка R имеет окрестность OR Ì W, а значит OR Ì WP . Но тогда WP  можно представить как объединение всех описанных окрестностей OR , а объединение любого количества открытых множеств открыто. Т.о. множество WP открыто, а значит (в силу определения множества) и связно.

Рассмотрим в качестве различных вариантов входных данных задачи все перестановки последовательности {1,2,…,N}. Т.е.

Ap= {p(1), p(2),…, p(N)}, где pнекоторая перестановка.

Докажем, что каждая последовательность Ap принадлежит своей связной компоненте WAP множества W и что все WAP линейно разделимы. Тогда мы докажем, что каждая Ap принадлежит своей разделимой связной компоненте. Действительно, допустим противное, т.е. пусть две различные последовательности Ap1  и  Ap2 принадлежат одной связной компоненте W. Тогда найдутся 1 £ i, j £ N, такие что (p1(i)- p1(j)) (p2(i)- p2(j))<0, т.е. (p1(i)- p1(j)) и (p2(i)- p2(j)) имеют разные знаки. Но тогда для любой ломаной S, соединяющей точки Ap1  и  Ap2 Î IR   N , найдется x Î S такая, что xi = xj , что противоречит принадлежности Ap1  и  Ap2 одной связной компоненте множества W.

Аналогично доказывается, что компоненты WP1 и WP2 линейно разделимы. Действительно, рассмотрим произвольные точки p1Î WP1 и p2Î WP2. Для тех же самых индексов i, j получим, что (p1(i)- p1(j)) (p2(i)- p2(j))<0 (здесь мы использовали следующее утверждение: для любой пары i, j внутри одной открытой связной компоненты W знак p(i)- p(j) для всех точек одинаков), из чего сразу получаем, что на отрезке, соединяющем p1 и p2, найдется точка, не принадлежащая W.

В приведенном доказательстве требует объяснения следующий факт: для двух различных перестановок множества {1,2,…,N}  всегда найдутся 1 £ i, j £ N, такие что (p1(i)- p1(j)) и (p2(i)- p2(j)) имеют разные знаки. Пусть для каждой перестановки p:  tk – количество чисел p(i) больших p(k) для i>k. Легко увидеть, что p и t однозначно задают друг друга.

Действительно, для p(i)=N имеем t(i)=0, причем для j<i выполняется: t(j)>0. (отметим, что t(i)=0 может выполняться и для других i) Поэтому если мы найдем минимальное i такое, что t(i)=0, то сразу получим, что p(i)=N; далее положим p(i)=-1, что потребует уменьшить на 1 все t(j) для j<i, а t(i) тоже положим равной -1, чтобы исключить из рассмотрения. Для нахождения i такого, что p(i)=N-1, мы опять ищем минимальное i такое, что t(i)=0. После исключения из рассмотрения найденного i и уменьшения на 1 всех t(j) для j<i мы можем перейти к поиску i такого, что p(i)=N-2, и т.д.

Пример:

p={4,5,3,1,2} => t={1,0,0,1,0}

Ищем p по заданной t:

1)Ищем минимальное i, для которого t(i)=0: i=2 => p(2)=5

Для всех j<2 уменьшаем t на 1 и кладем t(i)=-1:

t={0,-1,0,1,0}

2) Ищем минимальное i, для которого t(i)=0: i=1 => p(1)=4

Для всех j<1 уменьшаем t на 1 (таковых нет) и кладем t(i)=-1:

t={-1,-1,0,1,0}

3) Ищем минимальное i, для которого t(i)=0: i=3 => p(3)=3

Для всех j<3 уменьшаем t на 1 и кладем t(i)=-1:

t={-2,-3,-1,1,0}

4) Ищем минимальное i, для которого t(i)=0: i=5 => p(5)=2

Для всех j<5 уменьшаем t на 1 и кладем t(i)=-1:

t={-3,-4,-2,0,-1}

5) Ищем минимальное i, для которого t(i)=0: i=4 => p(4)=1

 

Т.о. для двух различных перестановок  p1 и p2  мы получим различные соответствующие t1 и t2 . Выберем i: t1(i)  ¹ t2(i). Пусть, например, t1(i)  > t2(i), но тогда найдется j>i, такое что p1(j)> p1(i), но  p2(j)< p2(i). Получили требуемое.

¢

 

Итак, мы доказали, что  верна следующая

Теорема. Задачи 1 и 2 имеют нижнюю оценку времени решения W (NlogN) на алгоритмах, основанных на алгебраическом дереве принятия  решения первой степени.

 

Для Задачи 3 также можно доказать аналогичную теорему:

 

Теорема. Задача 3 имеет нижнюю оценку времени решения W (NlogN) на алгоритмах, основанных на алгебраическом дереве принятия  решения первой степени.

Для доказательства рассмотрим все пары последовательностей {1,2,…,N} и {s1,s2,…,sN} для всех перестановок s. Назовем эти последовательности A и Bs. Покажем, что количество принимающих вершин любого алгебраического дерева принятия решения первой степени, решающего заданную задачу, не меньше, чем количество всевозможных пар {A, Bs}. В этом случае мы сразу получим, что высота дерева принятия решения будет не меньше Q (log(N!))= Q (N log N), что докажет нашу теорему.

Доказательство требуемого факта тривиально. Нам достаточно доказать, что никакие две различные пары {A, Bs} и {A, Br} не могут попасть в одну принимающую вершину данного дерева принятия решений. Докажем данный факт от противного.

Пусть есть две различные пары {A, Bs} и {A, Br}, на которых данный алгоритм попадает в одну принимающую вершину алгебраического дерева принятий решений первой степени. Тогда найдется индекс i для которого risi. Рассмотрим пары последовательностей {A, Bst}, для которых Bst=tBs+(1-t) Br , где tÎ(0,1). Значения Bst для tÎ[0,1] образуют собой отрезок в пространстве IR   2N. В данных парах последовательностей элемент Bst с индексом i должен принимать все возможное значения в интервале (Min(ri,si), Max(ri,si)). Тогда, с одной стороны  любая пара {A, Bst} должна попасть в ту же принимающую вершину (в силу проверки линейных соотношений в каждой вершине дерева и сохранения знака линейной функции на отрезке), а с другой, среди возможных значений t обязательно найдется значение, не встречающееся в последовательности A. Получаемое противоречие завершает доказательство.

¢

Заметим, что Задача 3 также, как и Задача 2, может быть сведена к Задаче 1.

 

 


Лекция 4

Алгоритмы. Сведение алгоритмов.

Сортировки и связанные с ними задачи.

 

Д.Кнут. Искусство программирования  для ЭВМ. тт 1-3. Москва. Мир. 1996-1998

Т.Кормен, Ч.Лейзерсон, Р.Ривест. Алгоритмы. Построение и анализ. Москва. МЦНМО. 1999.

Препарата Ф., Шеймос М. Вычислительная геометрия. Москва. Мир. 1989

 

 

 

К вопросу о понимании предыдущих лекций. Найти ошибку.
``Доказательство'' того, что любое натуральное число можно однозначно получить с помощью алгоритма, задаваемого не более, чем 20-тью словами (имеется в виду, можно использовать только существующие в языке слова, а не что-нибудь вроде "ШестьсотШестьдесятШесть"). 
Пусть это не так. Тогда существует множество, являющееся подмножеством натуральных чисел, каждый элемент которого невозможно получить с помощью алгоритма, задаваемого не более, чем 30 словами. У всякого подмножества натуральных чисел есть наименьший элемент.
Получаем: "Наименьшее натуральное число, которое нельзя получить с помощью алгоритма, задаваемого не более, чем 30 словами, имеющимися в русском языке" - итого 20 слов потребовалось, дабы назвать данное число, которое принадлежит этому великолепному множеству => противоречие.
 
 

 

 

HeapSort или сортировка с помощью пирамиды.

Алгоритм основан на промежуточном упорядочивании массива входных данных {A1 ,…, AN }.  Мы докажем, что промежуточно-упорядоченный массив (мы будем его называть пирамидально-упорядоченным) обладает свойством максимальности своего первого элемента. Тогда мы отрезаем от массива первый элемент и восстанавливаем утраченное свойство пирамидально-упорядоченности у оставшегося куска. Так, отрезая по одному (максимальному из оставшихся) элементу, мы можем `набрать’ полный упорядоченный массив.

Определение. Массив {A1 ,…, AN } называется пирамидально-упорядоченным, если для всех допустимых i:  A[i/2] ³ Ai .

Иначе данное соотношение можно выписать следующим образом:

Ai ³ A2i и Ai ³ A2i+1                                                   (*)

Легко видеть, что данные соотношения задают древообразную структуру, в вершине которой находится первый элемент дерева. Его потомками являются элементы с номерами 2 и 3, и т.д. В получившемся дереве все слои заполнены, кроме, быть может, последнего. Поэтому глубина дерева равна [log N]+1, где N – количество элементов в множестве.

Пусть для некоторого поддерева пирамиды, начинающегося с элемента с индексом i   и заканчивающегося элементом с индексом N, выполнено свойство  (*) для всех элементов поддерева, кроме вершины поддерева. Т.е. свойство выполняется для всех элементов, имеющих индексы больше  i (здесь имеется в виду возможное невыполнения условий Ak ³ A2k и Ak ³ A2k+1  для k=i и его выполнение при k>i).

Определим процедуру Heapify(A,i,N), которая в данном случае подправляет элементы поддерева до полной пирамидально-упорядоченности элементов с индексами от i до N. Здесь Aрассматриваемый массив, iиндекс массива с которого начинается рассматриваемое поддерево, Nколичество элементов во всем дереве.

Процедура Heapify(A,i,N) осуществляет следующие действия. Она проверяет условия

Ai ³ A2i    в случае 2i£N  

Ai ³ A2i+1  в случае 2i+1£N.

Если они выполняются (случаи 2i³N, 2i+1³N легко рассмотреть отдельно), то дальше ничего делать не надо, происходит выход из процедуры. Иначе, выбирается максимальный из элементов Ai , A2i, A2i+1 и выбранный элемент меняется местами с Ai . Не ограничивая общности рассуждений, допустим, что максимальным оказался элемент A2i , тогда после перестановки имеем Ai ³ A2i+1  , при этом элемент A2i+1  не изменился, поэтому свойство пирамидально-упорядоченности будет выполняться и дальше в данном (правом) поддереве. Далее рекурсивно вызываем процедуру Heapify(A,2i,N).

Исходя из построения процедуры Heapify, имеем следующее утверждение

Утверждение 1. Процедура Heapify(A,i,N) выполняется за время O( h(i,N) ), где h(i,N) – глубина поддерева в пирамиде из N элементов, начинающегося с элемента с индексом i.

 

Алгоритм Heapsort(A,N) выглядит следующим образом

 

Heapsort(A,N)

 


Для всех i от N-1 до 1 с шагом –1 выполнить: Heapify(A,i,N)

Для всех i от 1 до N-1  с шагом 1 выполнить

    Поменять местами элементы A1 и  AN-i+1

    Heapify(A,1,N-i)

 

 

Первый цикл в алгоритме создает пирамиду, а второй, используя ее свойство максимальности первого элемента, создает упорядоченный массив. Согласно Утверждению 1, каждый цикл состоит из N процедур, каждая из которых выполняется за время O(log 2 N), из чего вытекает теорема

Теорема. Время работы алгоритма Heapsort(A,N) равно O(N log 2 N).

 

На самом деле, оказывается, что время работы первого из двух циклов алгоритма равно O(N). Действительно, процедура Heapify(A,i)  для каждого i из последнего уровня дерева выполняется за время O(1) (а в этом уровне содержится половина всех элементов!). Для следующего уровня время выполнения процедуры равно уже O(2). И т.д.

Т.о. суммарное время работы алгоритма вычисляется по формуле

T(N) =  O(Si=0i£h (h-i+1) 2i)

где высота дерева равна h+1 (т.е. дерево имеет уровни с номерами от 0 до h).

Докажем соотношение T(N)/2h = Q (1). Отсюда и из того, что количество элементов в дереве высотой h+1 находится между 2h-1 и 2h, мы сразу получим, что время работы алгоритма равно O(N).

Рассмотрим следующие равенства для некоторого x¹1

1 + x + x2 + … + xN = ( 1- xN+1 )/(1-x),  тогда, взяв производную по x, получим

1 +2x + 3x2 + … + NxN-1 = (- (N+1)xN(1-x) + ( 1- xN+1 )  )/(1-x)2=Q (1),  если x =1/2.

C другой стороны, положим  j=h-i+1, тогда

 T(N)/2h= O(Si=0 i £ h (h-i+1) 2i-h)= O(Sj=1 j £ h+1 j 2 - j+1)= O(Sj=1 j £ h+1 j 2 - j) =Q (1).

¢

Из вышесказанного вытекает, что мы имеем возможность получить упорядоченный подмассив, состоящий из довольно большого количества самых больших элементов исходного массива, за время O(N), где N – количество элементов в массиве. Более строго, верна теорема

Теорема. Получить упорядоченный массив из N / log 2 N самых больших элементов массива можно за время O(N).

 

 


Алгоритмы сортировки за время O(N)

 

Итак, мы рассмотрели алгоритмы, основанные на операциях сравнения, и для них получили нижнюю оценку времени выполнения. Возникает вопрос, а можно ли на ЭВМ выполнять операцию сортировки быстрее? Здесь следует отметить, что на ЭВМ есть операция, которая принципиально не вписывается в множество рассмотренных операций. Это – операция индексации массива с использованием в качестве индекса функций, вычисляемых от упорядочиваемых элементов. Все алгоритмы, выполняющиеся за время O(N) используют эту операцию.

Сортировка подсчетом

Пусть мы хотим отсортировать N целых чисел A={A1,…, AN}, каждое из которых не превосходит K, при этом K=O(N). Тогда мы можем создать временный массив B размером K, в который можно поместить для каждого i  количество чисел в массиве A, не превосходящих i. Тогда для каждого 1 £ i £ N: в отсортированном массиве в элементе с индексом BA i  лежит элемент, равный Ai .

Итак, приведем реализацию данного алгоритма. Результат будем помещать в третий массив C

CountingSort (A,C, N,K,  B)

 


Для всех i от 1 до K с шагом 1 выполнить: B[i]=0

Для всех i от 1 до N с шагом 1 выполнить: B[A[i]] ++

Для всех i от 1 до N с шагом 1 выполнить: B[A[i]]= B[A[i]]+ B[A[i-1]] 

Для всех i от N до 1 с шагом -1 выполнить: C[B[A[i]]] = A[i]; B[A[i]]- -

 

 


Единственным дополнением к вышеприведенному описанию в этом алгоритме является добавка в его конец `B[A[i]]- -’ . Эта добавка гарантирует, что если в массиве A есть элементы с равными значениями, то они будут положены в различные ячейки массива C. Более того, каждый следующий элемент со значением, равным некоторому x (при обратном проходе!), будет помещаться в ячейку левее предыдущей. Поэтому данная сортировка сохраняет взаимное расположение равных элементов. Этой свойство сортировки называется устойчивостью. Это свойство имеет смысл, когда равенство элементов в смысле сравнения не влечет тождественного равенства элементов. Например, это происходит если сортировка идет по ключу.

Цифровая сортировка

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

Теорема. Алгоритм цифровой сортировки требует O(nd) операций, где n – максимальное количество операций для одной внутренней сортировки, d – количество цифр.

Этот алгоритм облегчает использование сортировки подсчетом. Действительно, если есть большой массив 32-битных целых чисел без приемлемых ограничений на их величину,  то можно разбить их на 2 либо 4 части и рассмотреть каждую часть как одну цифру в алгоритме цифровой сортировки.

Сортировка вычерпыванием

Пусть требуется отсортировать массив из N вещественных чисел A={A1,…, AN}, равномерно распределенных на интервале [0,1). Идея алгоритма заключается в следующем. Разобьем интервал [0,1) на N равных частей и каждой части сопоставим свой контейнер элементов (например, в самом простом случае, массив вещественных чисел длины N). Каждое число x положим в контейнер с номером [x*N]. После этого отсортируем элементы в каждом контейнере и соберем по порядку элементы из всех контейнеров вместе.

Более конкретно, для реализации контейнеров мы сначала посчитаем, сколько элементов попадет в каждый контейнер, а потом для распределения элементов по контейнерам нам достаточно будет иметь один массив вещественных чисел длины N. Итак, для сортировки массива A, состоящего из N элементов, мы должны завести массивы целых чисел M, I длины N и массив вещественных чисел B длины N.  Пусть функция Sort(B,i0,n) выполняет сортировку пузырьком части массива B , начинающейся с элемента с индексом i0, состоящей из n элементов. Тогда алгоритм имеет следующий вид

SortB (A, N, M, B)

 


Для всех i от 1 до N с шагом 1 выполнить: M[i]=0; I[i]=0; B[i]=0

Для всех i от 1 до N с шагом 1 выполнить: M[A[i]*N+1] ++

Для всех i от 2 до N с шагом 1 выполнить: M[i] = M[i]+ M[i-1]

Для всех i от N до 2 с шагом -1 выполнить: M[i] = M[i-1]

M[0]=0

Для всех i от 1 до N с шагом 1 выполнить: B[M[i]+I[i]+A[i]*N+1]= A[i]; I[i]++

Для всех i от 1 до N с шагом 1 выполнить: Sort(B,M[i],I[i])

Для всех i от 1 до N с шагом 1 выполнить:

    Для всех j от 1 до I[i] с шагом 1 выполнить: A[k]= B[ M[i]+j ]; k++

 

 


Во втором цикле алгоритма мы подсчитываем количество элементов, попавших в i-ый интервал. В третьем и четвертом циклах мы помещаем в M[i] индекс первого элемента части массива B, относящейся к контейнеру с номером i. В пятом цикле мы помещаем элементы в соответствующие контейнеры. В шестом цикле происходит сортировка элементов в контейнерах. Далее мы последовательно выбираем элементы в результирующий массив A.

 

Теорема. Алгоритм SortB работает за время O(N) в среднем, где N – количество сортируемых элементов.

Доказательство. Пусть p=1/N. Вероятность попадания в один контейнер k элементов равна pkNk pk (1-p)N-k  (биноминальное распределение).  Время работы алгоритма сортировки в одном контейнере равно S O(k2), где k – количество элементов, попавших в i-ый контейнер.

Согласно свойствам биномиального распределения, среднее (математическое ожидание) количество элементов в контейнере равно M(k)= Sk  pkk=Np=1.  Средне-квадратичное отклонение от среднего значения (дисперсия) количества элементов в контейнере равно D(k)= S k  pk(k- M(k))2=S  k pk(k-1)2=Np(1-p)=1-1/N.

D(k)=M(k2) – (M(k))2  из чего сразу следует M(k2)=D(k) +(M(k))2=2-1/N. Итого, среднее время сортировки одного контейнера равно O(1), а среднее время сортировок N контейнеров равно O(N).

¢

 



Лекция 5

Алгоритмы. Сведение алгоритмов.

 

Д.Кнут. Искусство программирования  для ЭВМ. тт 1-3. Москва. Мир. 1996-1998

Т.Кормен, Ч.Лейзерсон, Р.Ривест. Алгоритмы. Построение и анализ. Москва. МЦНМО. 1999.

Препарата Ф., Шеймос М. Вычислительная геометрия. Москва. Мир. 1989

 

 

 

Порядковые статистики.

Определение. Медианой множества А = {a1 ,…, aN } называется элемент с индексом (N+1)/2 в отсортированном по возрастанию множестве А.

 

Определение. k-той порядковой статистикой множества из N вещественных чисел A={A1,…, AN} называется k-тое число в упорядоченном множестве A. Легко увидеть, что [(N+1)/2]-ая порядковая статистика является медианой множества. В свою очередь, если бы мы могли эффективно искать медиану множества, то это дало бы хорошую модификацию алгоритма QuickSort.

Из предыдущей главы следует, что верхней оценкой  времени поиска k-той порядковой статистикой является O(N log2 N). Оказывается, что эту оценку можно улучшить.

Поиск порядковой статистики за время Q(N) в среднем

Для поиска порядковых статистик можно, практически один в один, применять алгоритм QuickSort с единственной модификацией: после каждого деления массива на две части мы можем точно сказать, в какой из них лежит искомая k-тая порядковая статистика, поэтому другую половину можно далее не рассматривать.

 


 QFindStatP (A,p,q,k,N)

Если  q-p < 1 то return Ap

Вечный цикл

   i=p; j=q;

  Поменять местами Ap и случайно выбранный элемент Al , где p £ l £ q

   x=Ai

   Пока A i  < x :  i + +;

   Пока A j  > x :  j - -;

   Если  i < j то

      поменять местами A i  и A j ;

   иначе

     {Если k £ j

       то  return QFindStatP (A, p, j, k,N )

       иначе return QFindStatP (A, j+1, q, k,N ) 

     }

   i + +; j - -;

Конец вечного цикла

 

 

Теорема. Время работы алгоритма  QFindStatP  равно O(N 2), где N – количество элементов в обрабатываемом массиве.

Доказательство. После каждого разбиения массива на две части длина самой большой из двух образовавшихся половин оказывается меньше либо равной длине разбиваемого массива –1. Поэтому на каждой ветви алгоритма будет не более N узлов (разбиений массива). На каждом уровне дерева разбиений присутствуют не более N элементов, по которым производится поиск, поэтому суммарное время работы на одном уровне дерева равно O( N ). Итого, суммарное время работы алгоритма равно O( N ) * N = O( N 2).

Данная оценка достижима на массиве {1, …, N-1,N} при поиске, например, N-ой порядковой статистики и при том, что в качестве псевдомедианы каждый раз будет выбираться первый элемент в подмассиве.

 

Теорема. Среднее время работы алгоритма QFindStatP равно Q(N), где N – количество элементов в обрабатываемом массиве. Под средним временем подразумевается среднее время по всем перестановкам любого массива входных данных длины N.

Доказательство. Выпишем рекуррентное соотношение на среднее время работы алгоритма

 

            

T(N) £ [ T( N-1 )  + Si=2i£N MAX(T( i-1 ), T( N-i+1 )) ]/N + O(N) =

=  [ T( N -1)  + Si=1i<N MAX(T( i ), T( N-i )) ]/N + O(N) £

£  [ T( N -1)  + 2Si=N/2i<N T( i ) ]/N + O(N) £

£  [ 2 Si= N/2i<N T( i ) ]/N + O(N) 

 

Предположим, для  i<N верно:

T( i )<a i + c для некоторых a>0,  c>0 ,

тогда задача сводится к нахождению таких a>0,  c>0 , что для них всегда бы выполнялось соотношение

[ 2 Si= N/2i<N T( i ) ]/N + O(N)  < a N + c

Итак

T(N) £  [ 2 Si= N/2i<N T( i ) ]/N + O(N)   £ a 3/4 * N + c + O(N)

 

Осталось взять такое большое a, что  a 3/4 * N  + O(N) < a N , после чего мы получаем T(N) = O(N). Осталось заметить, что первое же разбиение массива на две части (а в лучшем случае оно же будет и последним) требует времени Q(N),  из чего мы получаем, что T(N) =Q(N).

¢

 

Поиск порядковой статистики за время Q(N) в худшем случае

Зададимся целью написать алгоритм нахождения k-ой порядковой статистики, требующий  Q(N) операций в худшем случае. Это было бы возможным, если бы в алгоритме QFindStatP  на каждом этапе разбиения множества на две части мы бы получали части размером не менее sL, где L – длина разбиваемой части множества, s<1. Для этой цели мы построим алгоритм QFindStat5, который перед разбиением множества на две части разбивает его на пятерки последовательных элементов, в каждой пятерке ищет медиану и на полученном множестве медиан пятерок чисел запускает самого себя для поиска медианы полученного множества. Полученную медиану медиан x алгоритм использует для разбиения множества на две части, состоящих, соответственно, из элементов меньше или равных x, и из элементов больше или равных x. Далее, в зависимости от k, следует применить QFindStat5 к одной из полученных половин множества.

QFindStat5(A,1,N,k)

 


Если N £ 5 то найти медиану x любым методом; return x

Разбить массив A[1…N] на пятерки элементов и

отсортировать элементы внутри пятерок

 x= QFindStat5 (A,1,[(N+4)/5], ([(N+4)/5]+1)/2), где A – массив медиан пятерок

Выполнить один шаг QuickSortP для псевдомедианы x

     => массив разбит на куски длиной L и N-L

Если k £ L то return QFindStat5 (A,1,L,k)

                    иначе return QFindStat5 (A,L+1,N,k)

 

 


Теорема. Время работы алгоритма QFindStat5 равно Q(N), где N – количество элементов в обрабатываемом массиве.

Доказательство. В массиве медиан пятерок [(N+4)/5] элементов. Нахождение медианы x этого множества гарантирует, что в каждой пятерке справа от x , включая пятерку с x (см. рисунок ниже), и кроме последней пятерки, 3 элемента больше или равны x (на рисунке эти элементы выделены серой областью). Количество всех указанных пятерок не менее [([(N+4)/5]+1)/2], последняя пятерка может быть не полной и в ней, в худшем случае, может быть всего один элемент больше или равный x. Если теперь исключить из описанного множества x, то получается, что мы имеем 3[([(N+4)/5]+1)/2] - 3 элементов больше или равных x. Легко показать, что такое же количество элементов меньше или равны x. Т.о. после выполнения одного шага QuickSortP  останется не более N - 3[([(N+4)/5]+1)/2] + 3 £ N – 3N/10 + 3 = 7N/10 + 3  элементов.

Оценим время выполнения алгоритма QFindStat5 : T(N).  Оно складывается из времени поиска медианы медиан пятерок элементов (T([(N+4)/5])), времени поиска медианы в отрезанном куске множества длиной не более 7N/10 + 3  (T(7N/10 + 3)) и всего остального (O(N)).

 

 

T(N) £ T(N/5+1) + T(7N/10 + 3) + O(N)

Теперь, если предположить, что T(i) £ c i, для i<N, то получим

T(N) £  с(N/5+1) + с(7N/10 + 3) + O(N) £ с( 9N/10 + 4 ) + O(N) =

= сN + (O(N) – c(N/10 - 4))

Выбрав достаточно большое c получим, что для N>40

O(N) – c(N/10 - 4) £ 0

Далее, выбрав еще большее c можно получить, что для N£40

T(N) £ сN

 

Т.о. мы получим, что T(N) £ сN для любого N.

¢

 

 


Язык программирования C.

 

Б.В. Керниган, Д.М. Ричи. Язык программирования С.

 

 

 

Переменные

 

Основные типы

В языке С есть две основные разновидности базовых типов: целые и вещественные. К целым типам относятся типы

char

short

int

long

Перед каждым вышеупомянутым типом может присутствовать слово signed или unsigned. Идентификатор signed указывает на то, что переменная имеет знак, а unsigned – на отсутствие знака у переменной. По умолчанию, все переменные имеют знак. Исключением является переменная типа char. Полагаться на наличие/отсутствие знака у переменной данного типа нельзя, т.к., как правило,  данное свойство может быть изменено с помощью ключей компилятора.

Известно, что в данном списке указанные типы располагаются по неубыванию их размеров. Если требуется узнать конкретный размер переменной в байтах, то он может быть получен с помощью оператора sizeof(): sizeof(char).

К вещественным типам относятся

float

double

Известно, что sizeof(float) £ sizeof(double).

Кроме базовых типов, существуют производные типы, задающиеся с помощью понятий массив, указатель, функция, структура, объединение.

 

Базовые понятия

Ø  описание и определение

Ø  время жизни

Ø  область видимости или область действия

Одними из основных понятий программирования являются описание и определение объектов.

Описание переменной задает ее тип и указывает на то, что переменная где-то определена. Признаком описания переменной служит наличие ключевого слова extern перед оператором, ее задающим. Отсутствие ключевого слова extern говорит об определении переменной.

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

В программе может быть сколько угодно согласующихся описаний одной и той же переменной, но должно быть ровно одно ее определение.

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

Автоматические переменные всегда определяются внутри некоторого блока. Блоком называется часть тела некоторой функции, заключенная в фигурные скобки. Телом функции называется набор операторов, задающих действия, совершаемые функцией. В частности, тело функции также является блоком. Автоматические переменные всегда являются локальными, т.е. их область видимости ограничивается их блоком. Автоматические переменные рождаются в момент входа выполнения программы в данный блок и умирают в момент выхода из блока.

Внутри одного блока могут быть другие блоки и если во внутренних блоках определить переменные с именами, совпадающими с именами переменных во внешних блоках, то внутренние имена переменных перекроют видимость для внешних переменных. Перекрытие области видимости не сказывается на времени жизни переменных.

Автоматические переменные разрешается определять только в начале блока, перед выполняемыми операторами.

Автоматические переменные используют память, выделяемые в системном стеке, что делает процедуру отведения/очистки памяти под них весьма быстрой. Однако, как правило, системный стек имеет ограниченный размер и, поэтому, нельзя создавать автоматические переменные слишком большого размера. Причиной переполнения стека (stack overflow) может также служить бесконечная рекурсия, случайно созданная в программе.

Статически созданные переменные рождаются в момент запуска программы и умирают в момент прекращения работы программы. Память под них отводится в общей куче (heap), т.е. в обще-используемой памяти. Ее размер ограничивается размером памяти, доступной программе. Статически созданные переменные это – либо глобальные переменные (т.е. переменные, определенные вне всех блоков программы), либо локальные

Осталось упомянуть о внешних статических переменных – глобальных переменных, область видимости которых ограничена данным файлом. Как и все глобальные переменные, эти переменные определяются вне всех блоков программы, но перед их определением написано ключевое слово static.

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

Изначально в языке С присутствуют регистровые переменные. Наличие ключевого слова register перед определением переменной служит указанием компилятору использовать внутренние машинные регистры для хранения данной переменной. На данный момент, при использовании оптимизирующих компиляторов наличие данного типа, как правило, не может служить для ускорения работы программы. Более того, обязательное использование регистра для хранения данной переменной запрещает его использование для других целей, что может послужить поводом к замедлению работы программы.

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

 


Структуры данных.

 

Структурой данных является реализация понятия множества элементов определенного типа. Под реализацией понимается способ хранения данных. Вместе со способом хранения  задается набор операций (=алгоритмов) по добавлению, поиску, удалению элементов множества.

 

Вектор.

 Массив в языке С является точной реализацией структуры данных вектор. Вектором называется структура данных, в которой к каждому элементу множества можно обратиться по целочисленному индексу. В структуре данных вектор значение индекса может быть ограничено некоторой наперед заданной величиной  -  длиной вектора. В качестве опций в соответствующий исполнитель могут быть добавлены функции проверки невыхода индекса за границу вектора, проверки того, что по данному индексу когда-то был положен элемент.

Создание исполнителя вектор предполагает наличие следующих функций

Ø  создать вектор длины n

Ø  положить элемент в вектор по индексу i

Ø  взять элемент из вектора по индексу i

Ø  уничтожить вектор

При использовании массивов для реализации структуры данных вектор, создание/уничтожение объекта происходит в соответствующие моменты автоматически. Если же этим процессом надо управлять, то следует использовать функции malloc() / free().

Возможна ситуация, когда размер вектора становится известным уже после написания программы, но до ее компиляции (или мы для одной программы хотим получать ее различные версии для различных длин вектора). В этой ситуации можно задать размер массива константой препроцессора. Значение константы можно передать через ключи компилятора. Например, в программе (в файле prog.c) можно записать следующий набор операторов :

 

#ifndef N

#define N  100

#endif

int Array[N] ;

 

Если константа N  не определена, то ее значение полагается равным 100. Далее создается массив из N элементов. У большинства компиляторов значение константы препроцессора можно передать через ключ `D’, например, для компилятора gcc это будет выглядеть так:

 

gccDN=200  prog.c

 

В получившейся программе с именем ./a.out везде вместо идентификатора N будет подставляться 200.

 

Стек.

 Стеком называется структура данных, организованная по принципу LIFOlast-in, first-out , т.е. элемент, попавшим в множество последним, должен первым его покинуть. При практическом использовании часто налагается ограничение на длину стека, т.е. требуется, чтобы количество элементов не превосходило N для некоторого целого N.

Создание исполнителя стек предполагает наличие следующих функций

Ø  инициализация

Ø  добавление элемента на вершину стека

Ø  взятие/извлечение элемента с вершины стека

Ø  проверка: пуст ли стек?

Ø  очистка стека

 

Стек можно реализовать на базе массива или (в языке С ) это можно сделать на базе указателей.

Стек. Реализация 1.

Для реализации стека целых чисел, состоящего не более чем из 100  чисел, на базе массива в языке С  следует определить массив целых, состоящий из 100  чисел и целую переменную, указывающую на вершину стека (ее значение будет также равно числу элементов в стеке)

int stack[100], i0=0;

 

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

Стек. Реализация 2.

Для группировки различных переменных в один объект (например, чтобы впоследствии, так или иначе, передавать этот объект в функции за один прием) в языке С следует использовать структуры. Например, все данные, относящиеся к стеку можно поместить в структуру struct SStack:

 

struct SStack

{

int stack[100];

int i0;

};

 

Здесь создан новый тип с именем struct SStack. Далее можно создать переменную этого типа:

struct SStack st2;

 

Структуры нельзя передавать в качестве параметров функций, их нельзя возвращать как результат работы функций, но во всех этих случаях можно использовать указатели на структуры. Например, следующая функция будет инициализировать наш стек:

 

 void InitStack(struct SStack *ss){ ss->i0=0 ;}

 

Вызов функции осуществляется следующим способом :

 

InitStack(&st2) ;

 

Стек. Реализация 3.

 

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

int *stack, *head;

 

Как и ранее, эти переменные можно объединить в структуру:

 

struct SStack3{ int *stack, *head; };

 

Тогда соответствующую переменную st3 можно определить оператором

 

struct SStack3 st3;

 

Стек. Реализация 4.

 

Однако, можно поступить и по-другому. Т.к. элементы stack и head имеют один тип, то их можно объединить в один массив объектов соответствующего типа (т.е. типа int* ). Массив, естественно, должен быть длины 2:

 

int *st4[2];

 

Здесь следует заметить, что при определении/описании переменных квадратные скобки имеют приоритет больший, чем *, поэтому переменная st4  имеет тип `массив указателей’, а не `указатель на массив’.

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

 

void StackCreate4(int n, int *st[2] ) {st[1]= st[0] = (int*)malloc(n*sizeof(int));}

 

а ее вызов будет выглядеть так: StackCreate4(n,st4);

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

 

void StackAdd4(int v, int * st[2] ) { (*(st[1]++)) = v;}

 

а ее вызов будет выглядеть так: StackAdd4 (v, st4);

Проверка стека на пустоту выглядит следующим образом :

 

int StackIsEmpty4 ( int * st[2] ) { return st[1]<=st[0] ; }

 

Стек. Реализация 5.

 

У Реализации 4 есть существенный недостаток. Допустим, что стек создан внутри некоторой функции  и требуется использовать его вне данной функции. Тогда у нас есть единственная возможность осуществить данную реализацию, это - сделать переменную st4 глобальной или локальной статической. В противном случае, при выходе из данной функции переменная st4 утратит свое существование и указателями st4[0], st4[1] уже нельзя будет пользоваться. Но, как уже писалось, подобный способ реализации является дурным стилем.

Собственно, вся наша проблема состоит в том, что память под переменную st4 отводится и очищается автоматически. В качестве альтернативы, отведение/очистку памяти под указатели можно взять на себя. Для этого следует использовать указатель на указатель на целую переменную:

int **st5;

 

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

 

int ** StackCreate5(int n )

 {int **st; st = (int**)malloc(2*sizeof(int*)); st[1]= st[0] = (int*)malloc(n*sizeof(int));}

 

а ее вызов будет выглядеть так: st5=StackCreate5(n);

Теперь переменная st5 может быть локальной и если ее вернуть из функции, то содержимое стека не будет потерянным. Очистку стека можно произвести с помощью следующей функции

 

void StackDelete5(int  **st ) { free(st[0]); free(st);}

а ее вызов будет выглядеть так:  StackDelete5 (st5);

 

 



Лекция 6

 

Структуры данных ( + в языке С: массивы, структуры, оператор typedef).

 

Д.Кнут. Искусство программирования  для ЭВМ. тт 1-3. Москва. Мир. 1996-1998

Т.Кормен, Ч.Лейзерсон, Р.Ривест. Алгоритмы. Построение и анализ. Москва. МЦНМО. 1999.

 

 

Структурой данных является реализация понятия множества элементов определенного типа. Под реализацией понимается способ хранения данных. Вместе со способом хранения  задается набор операций (=алгоритмов) по добавлению, поиску, удалению элементов множества.

 

Стек.

 Стеком называется структура данных, организованная по принципу LIFOlast-in, first-out , т.е. элемент, попавшим в множество последним, должен первым его покинуть. При практическом использовании часто налагается ограничение на длину стека, т.е. требуется, чтобы количество элементов не превосходило N для некоторого целого N.

Создание исполнителя стек предполагает наличие следующих функций

Ø  инициализация

Ø  добавление элемента на вершину стека

Ø  взятие/извлечение элемента с вершины стека

Ø  проверка: пуст ли стек?

Ø  очистка стека

 

Стек можно реализовать на базе массива или (в языке С ) это можно сделать на базе указателей.

Стек. Реализация 1 (на основе массива).

Для реализации стека целых чисел, состоящего не более чем из 100  чисел, на базе массива в языке С  следует определить массив целых, состоящий из 100  чисел и целую переменную, указывающую на вершину стека (ее значение будет также равно числу элементов в стеке)

int stack[100], i0=0;

 

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

Стек. Реализация 2 (на основе массива с использованием общей структуры).

Для группировки различных переменных в один объект (например, чтобы впоследствии, так или иначе, передавать этот объект в функции за один прием) в языке С следует использовать структуры. Например, все данные, относящиеся к стеку можно поместить в общую структуру struct SStack:

 

struct SStack

{

int stack[100];

int i0;

};

 

Здесь создан новый тип с именем struct SStack. Далее можно создать переменную этого типа:

struct SStack st2;

 

Структуры нельзя передавать в качестве параметров функций, их нельзя возвращать как результат работы функций, но во всех этих случаях можно использовать указатели на структуры. Например, следующая функция будет инициализировать наш стек:

 

 void InitStack(struct SStack *ss){ ss->i0=0 ;}

 

Вызов функции осуществляется следующим способом :

 

InitStack(&st2) ;

 

Стек. Реализация 3 (на основе указателей).

 

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

int *stack, *head;

 

Как и ранее, эти переменные можно объединить в структуру:

 

struct SStack3{ int *stack, *head; };

 

Тогда соответствующую переменную st3 можно определить оператором

 

struct SStack3 st3;

 

Стек. Реализация 4 (на основе массива из двух указателей).

 

Однако, можно поступить и по-другому. Т.к. элементы stack и head имеют один тип, то их можно объединить в один массив объектов соответствующего типа (т.е. типа int* ). Массив, естественно, должен быть длины 2:

 

int *st4[2];

 

Здесь следует заметить, что при определении/описании переменных квадратные скобки имеют приоритет больший, чем *, поэтому переменная st4  имеет тип `массив указателей’, а не `указатель на массив’.

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

 

void StackCreate4(int n, int *st[2] ) {st[1]= st[0] = (int*)malloc(n*sizeof(int));}

 

а ее вызов будет выглядеть так: StackCreate4(n,st4);

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

 

void StackAdd4(int v, int * st[2] ) { (*(st[1]++)) = v;}

 

а ее вызов будет выглядеть так: StackAdd4 (v, st4);

Проверка стека на пустоту выглядит следующим образом :

 

int StackIsEmpty4 ( int * st[2] ) { return st[1]<=st[0] ; }

 

Стек. Реализация 5 (на основе указателя на указатель).

 

У Реализации 4 есть существенный недостаток. Допустим, что стек создан внутри некоторой функции  и требуется использовать его вне данной функции. Тогда у нас есть единственная возможность осуществить данную реализацию, это - сделать переменную st4 глобальной или локальной статической. В противном случае, при выходе из данной функции переменная st4 утратит свое существование и указателями st4[0], st4[1] уже нельзя будет пользоваться. Но, как уже писалось, подобный способ реализации является дурным стилем.

Собственно, вся наша проблема состоит в том, что память под переменную st4 отводится и очищается автоматически. В качестве альтернативы, отведение/очистку памяти под указатели можно взять на себя. Для этого следует использовать указатель на указатель на целую переменную:

int **st5;

 

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

 

int ** StackCreate5(int n )

 {int **st; st = (int**)malloc(2*sizeof(int*));

  st[1]= st[0] = (int*)malloc(n*sizeof(int));

}

 

а ее вызов будет выглядеть так: st5=StackCreate5(n);

Теперь переменная st5 может быть локальной и если ее вернуть из функции, то содержимое стека не будет потерянным. Очистку стека можно произвести с помощью следующей функции

 

void StackDelete5(int  **st ) { free(st[0]); free(st);}

а ее вызов будет выглядеть так:  StackDelete5 (st5);

Если мы хотим уменьшить накладные расходы при отведении  памяти и ускорить создание/уничтожение стека, то имеет смысл изменить несколько скорректировать функции StackCreate5 и StackDelete5. Действительно, память можно отвести всего один раз. Размер отведенной памяти должен быть достаточен для хранения (последовательно) массива из двух указателей и для массива из n элементов стека. В таком случае функции отведения/освобождения памяти могут выглядеть следующим образом

int ** StackCreate5x(int n )

 {int **st; st = (int**)malloc(2*sizeof(int*)+ n*sizeof(int));

  st[1]= st[0] = (int*)(st+2);

}

void StackDelete5x(int  **st ) {free(st);}

 

 

 


Очередь.

 Очередью называется структура данных, организованная по принципу FIFOfirst-in, first-out , т.е. элемент, попавшим в множество первым, должен первым его и покинуть. При практическом использовании часто налагается ограничение на длину очереди, т.е. требуется, чтобы количество элементов не превосходило N для некоторого целого N.

Создание исполнителя очередь предполагает наличие следующих функций

Ø  инициализация

Ø  добавление элемента в конец очереди

Ø  взятие/извлечение элемента с начала очереди

Ø  проверка: пуста ли очередь?

Ø  очистка очереди

 

Обычно, используется реализация циклической очереди. Т.е. под очередь отводится некоторый непрерывный кусок памяти (массив) и при подходе хвоста очереди к концу массива хвост автоматически перебрасывается на противоположный конец массива. Например, для реализации стандартной очереди из менее чем 100 целых чисел на базе массива в языке С следует определить следующие данные

#define  N  100

int array[N], begin=N-1,end=N-1;

 

Было бы логичным объединить все эти данные в единую структуру:

struct SQueue

{

 int Array[N];

 int  Begin, End;

};

 

Тогда функция инициализации очереди может выглядеть

void  Init(struct SQueue *queue){ queue->Begin=queue->End=N-1;}

Функция добавления элемента может выглядеть следующим образом

 

int  Add(struct SQueue *queue, int value)

{

 if(queue->End - queue->Begin == 1 ||

     queue->Begin - queue->End == N-1)return -1;

  queue->Array[queue->End-- ]=value;

  if(queue->End<0) queue->End=N-1;

return 0;

}

 

Отметим, что при данной реализации один элемент в очереди всегда будет не использован.

Функция извлечения элемента с уничтожением может выглядеть следующим образом

int  Get(struct SQueue *queue, int *value)

{

 if(queue->Begin== queue->End)return -1;

 *value= queue->Array[queue->Begin];

 if((--queue->Begin)<0) queue->Begin=N-1;

 return 0;

}

 

В функции сначала проводится проверка очереди на пустоту, далее из нее извлекается элемент. Если необходимо, индекс начала очереди перебрасывается на конец массива.

Дек.

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

Создание исполнителя дек предполагает наличие следующих функций

Ø  инициализация

Ø  добавление элемента в начало дека

Ø  добавление элемента в конец дека

Ø  взятие/извлечение элемента из начала дека

Ø  взятие/извлечение элемента с конца дека

Ø  проверка: пуст ли дек?

Ø  очистка дека

 

Аналогично очереди, обычно используются циклические реализации дека.

Списки

 

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

Более конкретно, создание исполнителя односвязный список (L1-список) предполагает наличие следующих функций

Ø  инициализация

Ø  установка текущего элемента в начало списка

Ø  перемещение текущего элемента к следующему элементу списка

Ø  взятие значения текущего элемента

Ø  уничтожение текущего элемента с автоматическим перемещением текущего элемента к следующему элементу списка

Ø  вставка нового элемента после текущего

Ø  проверка: пуст ли список?

Ø  проверка: текущий элемент в конце списка?

Ø  очистка списка

 

Двусвязный список отличается от односвязного наличием дополнительных операций:

Ø  перемещение текущего элемента к предыдущему элементу списка

Ø  вставка нового элемента перед текущим

Ø  проверка: текущий элемент в начале списка?

 

Иногда используются циклические списки. В них ссылки на концах списка циклически замыкаются. Хотя, формально, в данной структуре данных нет начала и конца, тем не менее, понятие первого элемента списка следует оставить, т.к. без него сложно, например,  реализовать перебор всех элементов списка. Список предписаний несколько модифицируется. Например, для двусвязного списка он может выглядеть следующим образом:

Ø  инициализация

Ø  установка текущего элемента в первый элемент списка

Ø  перемещение текущего элемента к следующему элементу списка

Ø  перемещение текущего элемента к предыдущему элементу списка

Ø  взятие значения текущего элемента

Ø  уничтожение текущего элемента с автоматическим перемещением текущего элемента к следующему элементу списка

Ø  вставка нового элемента после текущего

Ø  вставка нового элемента перед текущим

Ø  проверка: пуст ли список?

Ø  проверка: текущий элемент первый в списке?

Ø  очистка списка

 

Стандартная ссылочная реализация списков

Обычно, используется ссылочная реализация списков. Для этого следует создать объект, содержащий собственно данные, ссылку на следующий аналогичные объект и, для L2-списков, ссылку на предыдущий объект. Например, для реализации двунаправленного списка целых чисел на языке С следует определить следующий тип

 

struct SList2

{

 int value;

struct SList2 *prev, *next;

};

здесь данные будут храниться в члене структуры value, а ссылки, соответственно, на предыдущий/следующий объекты представлены указателями prev и next. Признаком того, что данный элемент является первым в списке, может служить нулевое значение указателя prev, а признаком конца списка может служить нулевое значение указателя next.

Для сокращения имени типа в языке С можно использовать оператор typedef. Принцип работы данного оператора следующий. Если перед определением некоторой переменной с именем NAME написать оператор typedef, то NAME из имени переменной превратится в имя нового типа. Т.о. следующий оператор создает новый тип TList2, который можно использовать вместо типа struct SList2:

typedef struct SList2 TList2;

 

Для работы со списком следует определить два указателя: указатель на головной элемент списка и указатель на текущий элемент списка:

TList2 *head=NULL, *current=NULL;

Признаком пустоты списка служит нулевое значение указателя head. Установка текущего элемента на начало списка сводится к присвоению

current=head;

Вставка элемента со значением value после текущего может быть произведена следующим образом:

TList2 *InsertData(int value, TList2 **head, TList2 **current)

{

 TList2 *New=( TList2 *)malloc(sizeof(TList2));

 New->value = value;

 if(*head == NULL) //Случай пустого списка

 {*current =*head = New; (*head)->next= (*head)->prev=NULL; return New;}

 if(*current==NULL) //Случай вставки в начало списка

 {

  (*current)=New; (*current)->next=*head; (*current)->prev=NULL;

  (*head)->prev=New;

  (*head)=New;

  return New;

 }

 New->next = (*current)->next; New->prev=*current;

 if((*current)->next != NULL) (*current)->next->prev = New;

 (*current) ->next = New;

return New;

}

 

функция возвращает указатель на новый элемент. Текущее положение в списке не изменяется. Для того, чтобы при вызове указатель на текущий элемент перемещался бы на новый элемент, данная функция может быть вызвана следующим образом

 

current = InsertData( value, &head, &current);

 

Здесь следует отметить, что в функции могут изменяться значения указателей на головной и текущий элементы списка. Особенностью языка С является то, что параметры передаются в функции исключительно по значению, т.е. в функцию передаются копии переменных. Альтернативой, в других языках программирования (например С++) служит передача параметров по ссылке. При передаче параметров по ссылке реально передается не значение переменной, а ее адрес. При этом внешний вид (синтаксис) как формальных, так и фактических параметров не отличается от случая передачи параметров по значению (фактическими параметрами называются параметры, передаваемые снаружи при вызове функции, а формальными – параметры, встречающиеся в определении функции).  Для указания того, что переменная передается по ссылке, в языке С++ используется символ & перед именем переменной в описании параметров функции. Поэтому в языке С++ вышеуказанная функция может выглядеть чуть проще

 

TList2 *InsertData(int value, TList2 *&head, TList2 *&current)

{

 TList2 *New=( TList2 *)malloc(sizeof(TList2));

 New->value = value;

 if(head == NULL) //Случай пустого списка

 {current =head = New; head->next= head->prev=NULL; return New;}

 if(current==NULL) //Случай вставки в начало списка

 {

  current=New; current->next=head; current->prev=NULL;

  head->prev=New;

  head=New;

  return New;

 }

 New->next = current->next; New->prev=current;

 if(current->next != NULL) current->next->prev = New;

 current ->next = New;

return New;

}

Вызывать эту функцию надо следующим образом

InsertData( value, head, current);

 

Для полноты описания следует отметить, что в языке С++ структуры можно передавать в функции по значению.

Возможно, в силу невозможности передачи параметров по ссылке, в зыке С нельзя передавать структуры в функции в качестве параметров. Вместо этого, естественно,  можно передавать указатели на структуры. Ситуация с массивами выглядит несколько двусмысленно. С одной сторону, массивы, в чистом виде, передаются по ссылке (т.е. при указании массива в качестве параметров функции передается не копия массива а адрес его нулевого элемента), но, с другой стороны, если вспомнить, что массивы, по всем своим свойствам являются постоянными указателями, то все сразу становится ясным: передавая массив в качестве параметра, мы передаем указатель на нулевой элемент массива и, при этом, сам указатель передается по значению.

Для правильного понимания понятия `структура’ следует иметь представление о выравнивании в структурах. Имеется в виду следующее. При определении структуры гарантируется, что первый член структуры будет располагаться по адресу самой структуры и все члены структуры располагаются в памяти в порядке их расположения в структуре. Однако, размер структуры может не  совпадать с суммой размеров членов структуры. Это объясняется тем, что, обычно, адреса всех элементов в структуре должны иметь определенную четность – т.е. они должны быть кратны одному из значений 1, 2, 4, 8 и т.д. Это значение определяет выравнивание элементов в структуре (Structure Alignment). Например, если создать структуру

struct SS{char a; int b;};

то, с большой вероятностью, по умолчанию, ее размер будет равен 8 (т.е. sizeof(struct SS) = = 8). Поведение по умолчанию, как правило, можно изменить с помощью соответствующих ключей компилятора. Но это можно сделать не на всех машинах и не на всех компиляторах.

Ссылочная реализация списков с фиктивным элементом

Алгоритмы работы со списком можно упростить, если использовать фиктивный элемент в списке. Фиктивный элемент будет играть роль разделителя между началом и концом списка. При этом, в качестве указателя на головной элемент списка будет выступать указатель на следующий элемент за фиктивным. В последнем элементе списка указатель на следующий элемент и в первом элементе указатель на предыдущий элемент должны указывать на фиктивный элемент. При этом, скорость работы алгоритмов повышается, но требуется память под лишний элемент списка.

Для локализации всех данных, относящихся к одному списку, можно завести следующую структуру

typedef struct TList_

{

 TList2 temp;

 TList2 *current;

}TList;

В данном определении типа мы объединили определение типа struct TList_ и определение нового типа TList с помощью оператора typedef. При этом в большинстве реализаций языка С (но не во всех!!!) идентификатор TList_можно вообще не писать.

В качестве инициализации списка можно использовать следующую функцию

void ListInit(TList *list)
{list->temp.next=list->temp.prev=&list->temp; list->current=&list->temp;} 

 

Вставка элемента со значением value после текущего может быть произведена следующим образом:

TList2 *InsertData(int value, TList *list)

{

 TList2 *New=( TList2 *)malloc(sizeof(TList2));

 New->value = value;

 New->next = list->current->next;  New->prev=list->current;

 list->current->next->prev = New;  list->current ->next = New;

 return New;

}

 

Реализация L2-списка на основе двух стеков

Интересной реализацией L2-списка является его реализация на основе двух стеков, расположенных `вершиной друг к другу’. Первый из стеков представляет начало списка (часть от начала списка до текущего элемента включительно), а второй – конец (часть после текущего элемента). Текущий элемент списка лежит на вершине первого стека. При необходимости переместиться к следующему элементу, значение вершины второго стека извлекается и помещается на вершину первого стека. При необходимости переместиться к предыдущему элементу, значение вершины первого стека извлекается и помещается на вершину второго стека.

Данная реализация активно применялась, например, при создании текстовых редакторов. Текстовый редактор представляет собой двунаправленный список строк. Работа с каждой строкой может быть представлена как работа с простым вектором символов. Операции вставки, редактирования, удаления символов в строке могут быть реализованы за время = O(N), где N – количество символов в строке.

Список строк реализуется в виде двух стеков, каждый из которых может быть реализован в виде временного файла. Текущая строка (и, возможно несколько соседних строк) хранятся в оперативной памяти, начало файла – в виде одного временного файла, конец (в обратном порядке) – в виде второго временного файла.

 

Реализация L2-списка с обеспечением выделения/освобождения памяти

 

При работе со списками может оказаться, что работа функции malloc требует слишком много времени. К тому же, эта функция реально выделяет памяти больше, чем запрошено, что делает ее применение слишком дорогим. Возможна реализация списка, в которой память под весь список выделяется сразу как массив элементов списка. На основе данного массива строятся два списка – основной список и список свободного места. В начальный момент список свободного места объединяет все элементы созданного массива.

Теперь для добавления элемента в список требуется взять его из списка свободного места и добавить в основной список. Для извлечения элемента из списка элемент извлекается из основного списка и добавляется в список свободного места.

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

Итак, для хранения всех данных, относящихся к подобному списку можно использовать следующую структуру

typedef struct

{

 TList2 *array;

 TList data, empty;

 int NMax;

} SListMem;

 

Функция инициализации может выглядеть следующим образом

void SListMemInit( SListMem *list)

{int i;

 list->NMax=1000;

 list->array=(TList2*)malloc(list->NMax*sizeof(TList2));

 ListInit(&(list->data));

 list->empty.current=list->empty.temp.next=list->array+0;

 list->empty.temp.prev=list->array+ list->NMax-1;

 list->array[0].prev=&(list->empty.temp); 

 for(i=1;i<list->NMax;i++)

 {

  list->array[i-1].next=list->array+i; 

  list->array[i].prev=list->array+i-1;

 }

 list->array[list->NMax-1].next=&(list->empty.temp); 

}


Лекция 7

 

Структуры данных. Графы.

 

Д.Кнут. Искусство программирования  для ЭВМ. тт 1-3. Москва. Мир. 1996-1998

Т.Кормен, Ч.Лейзерсон, Р.Ривест. Алгоритмы. Построение и анализ. Москва. МЦНМО. 1999.

 

 

Графы

Определение. Графом называется пара G=(V,E), где V - множество объектов произвольной природы, называемых вершинами (vertices, nodes), а E - семейство пар ei=(vi1, vi2), vijÎV, называемых ребрами (edges). В общем случае множество V и/или семейство E могут содержать бесконечное число элементов, но мы будем рассматривать только конечные графы, т.е. графы, у которых как V, так и E конечны. Отметим, что, вообще говоря, для каждой пары вершин ребра в семействе E могут быть неуникальными, что не дает возможности назвать E множеством.

Если порядок элементов, входящих в ei, имеет значение, то граф называется ориентированным (directed graph), сокращенно - орграф (digraph), иначе - неориентированным (undirected graph). Ребра орграфа называются дугами (arcs).

Некоторая вершина и некоторое ребро графа называются инцидентными, если вершина является одной из вершин ребра. Соответственно, пара вершин инцидентна ребру, если эти вершины являются вершинами одного ребра, а сами вершины называются смежными. Два ребра графа называются смежными, если они инцидентны одной вершине.

 

Поиск пути в графе с наименьшим количеством промежуточных вершин

Пусть дан некоторый конечный граф G=(V,E) и две его вершины a,bÎV. Требуется найти путь между вершины графа a и b с минимальным количеством промежуточных вершин, т.е. требуется найти последовательность {a1,…,an}, где aiÎV, (ai, ai+1)ÎE  с минимально возможным n.

Стандартным решением данной задачи является алгоритм волны. Базовым понятием в нем является волна степени n – множество вершин графа, до которых можно добраться из вершины a за n шагов и нельзя добраться за меньшее количество шагов. Будем называть это множество вершин Sn . Для точки a волной степени 0 является множество, состоящее из одной точки a.

Основная идея алгоритма заключается в том, что волной степени n+1, при известной волне степени n, будет являться множество точек Sn+1, состоящее изо всех точек, смежных к точкам из волны степени n, которые при этом не состоят в волнах степени меньше или равной n. Т.о. утверждается, что Sn+1= Sn+1.

Доказательство этого факта тривиально. Докажем, что Sn+1Ì Sn+1. Действительно, до точек  из Sn+1 можно добраться за n+1 шаг по построению. За меньшее количество шагов добраться нельзя, т.к. для любого i£n  выбранные точки не принадлежат Si .

Докажем, что Sn+1Ì Sn+1. Допустим, что найдется точка x, которую мы не включили в созданное множество Sn+1, но xÎSn+1. По условию xÎSn+1 имеем, что существует путь к данной точке за n+1 шагов, а значит, у данной точки есть смежная вершина из Sn. По определению Sn+1 , до точки x нельзя добраться за n или менее шагов, поэтому x должна быть включена в создаваемое множество, согласно его построению.

¢

 

Осталось завести два исполнителя множество S0 и S1 (например, на базе вектора, количество элементов в котором не превосходит количество вершин графа) и добавить в каждую вершину графа x дополнительную целую переменную lx, которая будет указывать длину кратчайшего пути от вершины a до данной вершины. В начальный момент S0 и S1 должны быть пусты, а все значения l=-1. Первая часть алгоритма состоит в определении для каждой вершины x, до которой можно добраться из a медленнее, чем до b, значения lx:

Алгоритм волны. Часть 1.

 


n=0; S1= {(a,0)}

Вечный цикл

n++

S0= S1

S1=Æ

          Для всех xÎ S0

                      Для всех точек, смежных x: y, таких что ly==-1

                                  ly=n; занести y в S1;

                                  Если b==x  то ВЫЙТИ ИЗ ВЕЧНОГО ЦИКЛА

Если S1  пусто то ВЫЙТИ ИЗ ВЕЧНОГО ЦИКЛА

 


Если после выхода из алгоритма  S1  пусто, то это говорит о том, что из a в b добраться нельзя.

Иначе, во второй части алгоритма следует по результатам построений из первой части алгоритма определить кратчайший (один из кратчайших) путь из b в a. Для этого следует заполнить искомую последовательность {x1,…,xn}.

xn=b;  x1=a

Далее для каждого i от n до 3 ищется вершина xi-1, смежная к xi, такая что lx(i-1)==i-1. Такая вершина существует из соображений, приведенных выше.

¢

Если в постановке задачи рассматривается ориентированный граф, то ничего принципиального в алгоритме не изменяется.

В каждой вершине можно хранить дополнительную информацию о том, откуда мы в нее пришли, тогда вторая часть алгоритма будет выполняться за время O(n), где n – длина минимального пути из a в b (в случае возможности дойти из a в b), или за время O(N) , где N – количество вершин в графе (в любом случае).

Можно задаться вопросом о времени работы алгоритма волны. Легко видеть, что без дополнительных предположений это время нельзя оценить с помощью функции от n, т.к. количество ребер, инцидентных вершине не ограничено (здесь имеется в виду, что одна пара вершин может иметь неограниченное количество инцидентных ребер). Тривиальна следующая

Теорема 1. Верны оценки для графа, состоящего из N вершин

1. Если в графе отсутствуют петли и кратные ребра, то алгоритм волны работает за время O(N2).

2. Если каждая вершина графа имеет не более M1  инцидентных ребер, то алгоритм волны работает за время O(M1 N).

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

Доказательство теоремы сводится к тому, что время работы алгоритма волны равно O(M+N) , где N – количество вершин, M – количество ребер в графе (точнее, не ребер, а пар смежных вершин, но в условиях теоремы это – одно и тоже). Последний факт следует из того, что каждое ребро при взятии смежной вершины в алгоритме может встретиться не более двух раз.

Чтобы получить более оптимистичную оценку введем несколько новых понятий.

Пусть дан некоторый граф G=(V,E), пусть каждой его вершине сопоставлена точка в некотором евклидовом пространстве, причем точки, соответствующие различным вершинам не совпадают. Пусть каждому ребру графа сопоставлена некоторая непрерывная кривая, соединяющая соответствующие вершины графа, причем кривые, соединяющие различные пары точек не пересекаются нигде, кроме вершин графа. Такое представление графа называется его геометрическим представлением.

Теорема 2.  Для любого конечного графа существует его геометрическое представление в R3.

Доказательство. Рассмотрим произвольный отрезок [a,b]Ì R3. Сопоставим всем вершинам графа различные точки на отрезке [a,b]. Сопоставим каждому ребру графа свою плоскость, проходящую через [a,b]. Плоскости, соответствующие ребрам, пересекаются только вдоль отрезка [a,b], поэтому в каждой плоскости можно нарисовать кривую, соединяющую вершины, инцидентные соответствующему ребру, которая не пересекается с другими построенными кривыми нигде, кроме как в вершинах графа.

¢

Граф называется планарным, если существует его геометрическое представление в R2. Плоской укладкой планарного графа называется такое его геометрическое представление, при котором каждое ребро представляется отрезком на плоскости.

Верна следующая известная теорема, которой мы не будем пользоваться и которую мы представим без доказательства

 Теорема 3. Для любого конечного планарного графа без кратных ребер и петель существует его плоская укладка.

Иногда в литературе последнее утверждение выступает в виде определения планарного графа, а не его свойства.

Геометрическое представление планарного графа разбивает плоскость на некоторое количество связных областей (одна из них - бесконечна). Эти области называются гранями.

Путем в графе называется такая последовательность {x1,…,xn} вершин графа, что для всех i: (xi,xi+1)ÎE.

Циклом в графе называется такой путь, что крайние вершины в нем совпадают. Т.е. последовательность {x1,…,xn} вершин графа называется циклом, если для всех i: (xi,xi+1)ÎE  и x1=xn.

Граф называется связным, если для любых двух вершин графа a и b существует путь по ребрам графа от a до b, т.е. существует последовательность {x1,…,xn} вершин графа, такая что x1=a, xn=b и для всех i: пара (xi, xi+1) инцидентна некоторому ребру графа.

Связный граф без циклов называется деревом. Вершины, инцидентные не более чем одному ребру дерева называются конечными вершинами. Иногда в литературе такие вершины называют листьями, но нам такое определение листьев неудобно. Отметим, что понятия ориентации ребер в этом определении нет!

Ориентированным деревом называется ориентированный граф, являющийся деревом, для которого ровно одна вершина имеет нулевую степень захода (степень захода = количество ребер заходящих в вершину), а все остальные вершины имеют степень захода, равную 1. Для случая ориентированного графа конечной вершиной называется вершина, имеющая нулевую степень исхода (степень исхода  = количество ребер выходящих из вершины). Это определение отличается от определения для неориентированного дерева!

Лемма 1. Любое конечное дерево имеет плоскую укладку.

Доказательство. Возьмем произвольную вершину дерева. Поместим ее в точку плоскости с координатами (0,0) и назовем корнем дерева. Пусть ей инцидентно k ребер. Поместим вторые вершины, инцидентные данным ребрам в точки (-1,i). Каждой из вновь размещенных точек сопоставим полосу плоскости со сторонами, параллельными оси Y, не имеющую общих точек с полосами соседних точек. 

Пусть для каждой вершины x, помещенной на плоскость в точку с координатой y=-h найдутся lx парных ей вершин, которые еще не размещены на плоскости.  Будем называть эти вершины потомками вершины x, а вершину x – их родителем. Разобьем полосу, соответствующую вершине x на  lx непересекающихся полос и разместим в них потомков x на координате y=-h-1.

Для потомков x рекурсивно выполним процедуру, описанную в предыдущем абзаце.

Условие отсутствия циклов гарантирует, что вершина x будет соединена с новыми вершинами только одним ребром и что в графе не будет существовать ребер, соединяющих вершину x с уже размещенными вершинами. Т.о. для каждой вершины, размещенной на плоскости будут реализованы все ребра графа ей инцидентные. 

Условие связности гарантирует, что после окончания этого алгоритма все точки будут размещены на плоскости. Действительно, пусть вершина b принадлежит графу, тогда из связности графа следует, что существует путь по ребрам графа от корня дерева до b. Мы показали, что для вершин, размещенных на плоскости, реализуются все ребра графа, из чего по индукции получаем, что все вершины пути, соединяющего корень дерева и b будут размещены на плоскости.

¢

 

Доказательство Леммы 1 одновременно служит конструктивным доказательством того, что верна следующая

Лемма 2. В любом конечном дереве можно ввести ориентацию, т.е. для ребер произвольного дерева можно задать ориентацию так, что дерево станет ориентированным.

Верна следующая известная теорема

Теорема 4 (формула Эйлера). Пусть задан связный планарный граф, имеющий в некотором геометрическом представлении p вершин, q ребер, r граней, тогда верна формула

p-q+r=2

 

            Лемма 3. Пусть в некотором графе p вершин и q ребер, то  в графе содержится не менее p-q связных компонент. Если в графе нет циклов, то в графе ровно p-q связных компонент. Если в графе есть циклы, то в графе строго больше p-q связных компонент.

Доказательство леммы. Любой конечный граф G=(V,E) без циклов может быть получен из графа G0=(V,Æ) путем последовательного добавления ребер, при этом каждый промежуточный граф не будет содержать циклов. Для графа G0 данная лемма выполняется (количество связных компонент равно количеству вершин).

Рассмотрим случай отсутствия циклов. При добавлении каждого ребра мы либо соединяем две связные компоненты и при этом лемма остается верной, либо обе соединяемые вершины принадлежат одной связной компоненте. Но в последнем случае перед соединением существовал путь от одной вершины к другой, и добавление ребра образовало цикл, из чего получаем противоречие с отсутствием циклов в подграфах.

Теперь, если разрешить наличие связных компонент, то в предыдущем доказательстве станет возможным соединение вершин из одной связной компоненты, но тогда количество связных компонент будет строго больше p-q. Лемма доказана.

Следствием Леммы 3 является простой критерий наличия циклов в графе:

 p-q=количеству связных компонент в графе Û в графе нет циклов.

Доказательство формулы Эйлера. Исходя из леммы, получаем, что теорема Эйлера верна для деревьев. Действительно, по Лемме 1, каждое дерево с конечным числом вершин имеет плоскую укладку. Количество граней в этой укладке равно1 (иначе существует хотя бы одна конечная грань, а значит существует последовательность ребер на ее границе, образующая цикл). Количество граней в геометрическом представлении дерева равно 1 и  в силу Леммы 3 

p-q+r=1+1=2.

Рассмотрим произвольный связный граф с циклами с q0 ребрами. По Лемме 3  p-q0>1. Зафиксируем p и предположим, что для всех q<q0 теорема доказана. Заметим, что для случая p-q=1 мы уже доказали теорему. По предположению, в графе есть цикл, возьмем одно ребро в этом цикле и исключим из графа. Ребро было в цикле графа, поэтому связность нарушена не была. Т.к. ребро содержалось в цикле, то оно было общим для двух граней, поэтому при исключении ребра количество граней и количество ребер уменьшились на 1. По предположению индукции для урезанного графа теорема выполняется, следовательно, она выполняется и для исходного графа.

¢

Рассмотрим планарный граф без кратных ребер и петель. В его геометрическом представлении для каждой грани i количество ребер на ее границе qi³3. Т.о. имеем

S qi ³ 3r

С другой стороны каждое ребро принадлежит не более, чем двум граням, поэтому

2q ³ S qi

Итого имеем

2q ³3r

r £ 2/3 q.

Тогда по формуле Эйлера

q=p+r-2£ p+2/3 q -2.

q/3£p-2

q£3p

В силу последнего соотношения верна следующая

Терема 5. Для случая планарного графа без кратных ребер и петель, состоящего из N вершин, алгоритм волны работает за время O(N).

 

Отметим, что параллельно мы доказали, что в планарном графе количество ребер и граней оценивается через количество вершин, т.е. верна

Терема 6. Для случая планарного графа, имеющего в плоском представлении p вершин, q ребер, r граней верны оценки

q£3p

r£2p

 

 

Представление графа в памяти ЭВМ

Пусть имеется граф G=(V,E), имеющий N вершин и M ребер. Вершинам и ребрам можно сопоставить их номера от 1 до N и от 1 до M, соответственно. Рассмотрим различные варианты хранения данных об этом графе. Отметим, что для работы с графом требуются следующие операции

  • перечисление всех ребер, инцидентных вершине i
  • перечисление вершин, инцидентных ребру j
  • перечисление всех вершин, смежных с  вершиной i
  • проверка смежности двух вершин
  • проверка смежности двух ребер

 

Массив ребер

Для хранения информации о графе можно использовать массив ребер, в каждом элементе которого хранятся номера вершин, инцидентных ребру

#typedef  MMax 100

int edges[MMax][2], n_edges;

здесь предполагается, что в графе содержится не более MMax ребер;

Имеем следующие времена выполнения интересующих нас операций

  • перечисление всех ребер, инцидентных вершине i          T=O(M)
  • перечисление вершин, инцидентных ребру j                    T=O(1)
  • перечисление всех вершин, смежных с  вершиной i        T=O(M)
  • проверка смежности двух вершин                                                 T=O(M)
  • проверка смежности двух ребер                                         T=O(1)

 

Матрица смежности

Для хранения информации о графе можно использовать матрицу смежности из N строк и N столбцов, в (i,j) – элементе которой хранится количество ребер, инцидентных паре вершин с номерами i  и  j.

Имеем следующие времена выполнения интересующих нас операций

  • перечисление всех ребер, инцидентных вершине i          -------
  • перечисление вершин, инцидентных ребру j                    -------
  • перечисление всех вершин, смежных с  вершиной i        T=O(N)
  • проверка смежности двух вершин                                                 T=O(1)
  • проверка смежности двух ребер                                         -------

 

Матрица инцидентности

Для хранения информации о графе можно использовать матрицу инцидентности из N строк и М столбцов, в (i,j) – элементе которой хранится 1, если вершина i инцидентна ребру j, и 0 – если нет.

Имеем следующие времена выполнения интересующих нас операций

  • перечисление всех ребер, инцидентных вершине i          T=O(M)
  • перечисление вершин, инцидентных ребру j                    T=O(N)
  • перечисление всех вершин, смежных с  вершиной i        T=O(N M)
  • проверка смежности двух вершин                                                 T=O(N M)
  • проверка смежности двух ребер                                         T=O(N+M)

 

Списки смежных вершин

Для хранения информации о графе можно для каждой вершины хранить множество смежных вершин. Множество можно реализовать либо в виде массива:

#typedef  NMax 100

typedef struct CVertex1_

{

  int AdjacentVertices[NMax];

  int NAdjacentVertices;

} CVertex1;

CVertex1 vertices[NMax];

здесь предполагается, что в графе содержится не более NMax вершин;

либо в виде списка:

#typedef  NMax 100

typedef struct CAdjVertex_

{

  int i;               //номер текущей вершины

  int i_next;      //номер следующей вершины

} CAdjVertex;

typedef struct CVertex2_

{

CAdjVertex *AdjacentVerticesList_Head; //голова списка  смежных вершин                             int i;                  //номер текущей вершины

} CVertex2;

CVertex2 vertices[NMax];

 

Т.о. в каждой вершине графа будет храниться вектор или список смежных вершин к данной вершине.

Имеем следующие времена выполнения интересующих нас операций

  • перечисление всех ребер, инцидентных вершине i          -------
  • перечисление вершин, инцидентных ребру j                    -------
  • перечисление всех вершин, смежных с  вершиной i        T=O(N)
  • проверка смежности двух вершин                                                 T=O(N)
  • проверка смежности двух ребер                                         -------

Реберный список с двойными связями (РСДС) (для плоской укладки планарных графов)

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

typedef struct SEdge_

{

 int vertex0, vertex1;

 int edge0,edge1;

 int face0,face1;

}SEdge;

            здесь vertex0, vertex1 – номера первой и второй вершины ребра. Порядок вершин задает ориентацию. edge0 – номер первого ребра (в массиве элементов Sedge), выходящего из вершины vertex0, взятого против часовой стрелки (при обходе ребер из вершины vertex0), edge1 – первое ребро, выходящее из вершины vertex1, взятое против часовой стрелки (при обходе ребер из вершины vertex1). face0 – номер грани, находящейся слева от направленного отрезка (vertex0, vertex1), face1 – номер грани, находящейся справа от направленного отрезка (vertex0, vertex1).

Вместо структуры можно использовать массив из шести целых чисел.

На следующем рисунке показан пример графа. Стрелками на ребрах указан порядок вершин в узлах РСДС, соответствующих ребрам. Стрелками между ребрами указаны ссылки на ребра из соответствующих узлов РСДС.

 

Для данного графа РСДС будет представлен следующим массивом улов:

{

{1,2, 5,2, 1,4},

{2,4, 1,7, 1,4},

{1,3, 1,4, 4,2},

{3,4, 6,5, 3,2},

{1,4, 3,2, 2,1},

{5,3, 7,3, 3,4},

{4,5, 4,6, 3,4}

}

Легко видеть, что РСДС позволяет за постоянное время находить следующее против часовой стрелки ребро в множестве ребер, инцидентных одной вершине. Также за постоянное время можно находить следующее ребро для данной грани.


Лекция 8

 

Структуры данных. Графы.

 

Д.Кнут. Искусство программирования  для ЭВМ. тт 1-3. Москва. Мир. 1996-1998

Т.Кормен, Ч.Лейзерсон, Р.Ривест. Алгоритмы. Построение и анализ. Москва. МЦНМО. 1999.

 

 

Поиск кратчайшего пути в графе

Пусть дан некоторый конечный граф G=(V,E), состоящий из N  вершин, и две его вершины a, bÎV. Пусть для каждого ребра графа e задано положительное вещественное число l(e), которое будем называть длиной ребра. Требуется найти путь между вершинами графа a и b  минимальной длины, т.е. требуется найти последовательность {x1,…,xn}, где xiÎV, x1=a, xn=b, (xi, xi+1)ÎE  с минимально возможной S l(xi,xi+1).

Стандартным решением данной задачи является алгоритм Дейкстры.

Основная идея алгоритма заключается в следующем.

Пусть множество Qn представляет собой множество n ближайших вершин к вершине a вместе с длинами пути от a до этих вершин. Т.е. в каждом элементе множества  qiÎQn содержится номер соответствующей вершины xi и длина пути от a до этой вершины li: qi=(xi,li). Можно предполагать, что последовательность {li} является неубывающей.

Утверждается, что ближайшая вершина графа к a из вершин, не внесенных в Qn , задается следующим соотношением:

argmin(v,wÎV, wÎ Qn , vÏ Qn , (w, v) ÎE:  lw+|(w,v)| )                (*)

здесь и далее под wÎ Qn , vÏ Qn имеется в виду, что w встречается среди вершин, внесенных в Qn, а v – нет; длина ребра (w, v) обозначается |(w,v)|. Т.о. элемент qn+1 складывается из вершины v, на которой достигается указанный минимум и соответствующей длины ln+1 = lw+|(w,v)|.

Доказательство. Допустим, найдена вершина sÏ Qn с минимальной длиной до a. Пусть кратчайший путь от a до s проходит по последовательности вершин графа {a,x2,…,xn,s}. Длина пути {a,x2,…,xn} меньше длины пути до s, поэтому xnÎ Qn, но тогда длина пути {a,x2,…,xn,s} должна совпадать с длиной кратчайшего пути от a до найденного выше w.

¢

 

При поиске значения (*), формально, требуется перебрать все вершины wÎ Qn и для каждой из них требуется перебрать все смежные вершины. Будем далее предполагать, что в графе отсутствуют кратные ребра и петли, тогда  процедура (*) требует выполнения O(N2) операций, из чего следует, что суммарное время работы алгоритма есть O(N3).

На самом деле, для выполнения процедуры (*) можно обойтись O(N) операциями. Для этого заметим, что за один поиск значения  (*) к множеству Qn добавляется всего одна точка и, поэтому, на следующем шаге значения lx изменятся только у вершин, смежных этой новой точке. Т.о. для пересчета в (*) всех значений lx требуется пересчитать lx только для точек, смежных одной точке, полученной при предыдущем выполнении (*). В силу введенных предположений, это можно сделать за O(N) операций. Искать же минимум lx можно по всем вершинам графа, не принадлежащим Qn, что тоже требует O(N) операций. Т.о. процедуру (*) можно реализовать за O(N) операций.

Для реализации алгоритма Дейкстры следует добавить в каждую вершину w графа вещественное число lw, характеризующее длину кратчайшего пути от вершины a до вершины w и логическую переменную sw, указывающую – посчитана ли на данный момент эта кратчайшая длина пути.

Для упрощения дальнейшей жизни (т.е. для поиска, собственно, кратчайшего пути при определенных значения lw) можно в каждом элементе также хранить ссылку на вершину, из которой мы в данную вершину пришли. Для текущей вершины w будем эту ссылку называть backw. Т.о. элементы Qi представляются в виде q=(w, lw, sw, backw).

Будем предполагать, что конкретная техника нам позволяет инициализировать все  lw  значением = плюс бесконечность (+INF), что мы и сделаем. Инициализируем все sw  значением = FALSE.

Введем переменную c, которая будет хранить последнюю вершину, добавленную в множество вершин с найденной минимальной длиной пути до вершины. Тогда алгоритм будет выглядеть следующим образом

Алгоритм Дейкстры

 


n=0; c=a; sc=TRUE; lc=0; backc=NULL         //Q0={(a,0, true,NULL)}

Вечный цикл

   Для всех вершин v, смежных с

Если sc==FALSE и (lv==+INF или  lc+|(c,v)|< lv) то

 lv = lc+|(c,v)|; backv=c

l=+INF

Для всех вершин графа v

Если sc==FALSE и lv< l то l = lv; z=v

 

            Если l==+INF то ВЫЙТИ// в графе не осталось элементов

            Если z==b то ВЫЙТИ        // дошли до конца пути

   sz=TRUE; c= z                        // Qn+1= Qn È {(z,l,true)}

Конец вечного цикла

 


Если после завершения первой части алгоритма l==+INF , то это означает, что от a до b дойти по ребрам невозможно. Иначе, мы можем по ссылкам backw добраться от b до a.

Проанализировав алгоритм для случая планарных графов без кратных ребер и петель, мы сможем заметить, что его можно улучшить. Действительно, при коррекции lv для каждой вершины перебираются все смежные ребра и это происходит ровно один раз. Поэтому суммарное количество операций по коррекции lv не превосходит O(q), где – q количество ребер, а для рассматриваемого случая O(q)= O(p), где – q количество вершин графа.

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

На каждом шаге вышеописанного алгоритма мы можем хранить в множестве P  ссылки на все элементы, содержащие вершины, смежные с вершинами очередного  Qn, которые сами в Qn не содержатся. Заметим, что именно среди этих элементов происходит коррекция lv и поиск минимально lv. На каждом шаге алгоритма Дейкстры мы должны сначала добавить в P  ссылки на все элементы, смежные c, при изменении значений lv  модифицировать положение соответствующих элементов, а в конце извлечь из P  ссылку на элемент с минимальным значением lv. Т.о. алгоритм придет к следующему виду

 

Алгоритм Дейкстры модифицированный

 


n=0; c=a; sc=TRUE; lc=0; backc=NULL         //Q0={(a,0, true,NULL)}

P={Æ }

Вечный цикл

Для всех вершин v, смежных с

Если sc==FALSE и lv==+INF то

 Добавить ссылку на  v в P

            Если P пусто то lz =+INF; ВЫЙТИ// в графе не осталось элементов

   Для всех вершин v, смежных с

Если sc==FALSE и (lv==+INF или  lc+|(c,v)|< lv) то

 lv = lc+|(c,v)|; backv=c;

Скорректировать положение ссылки на  v в P

            Извлечь из P ссылку на  минимальный элемент и

            поместить минимальный элемент в z

l = lz

            Если z==b то ВЫЙТИ        // дошли до конца пути

   sz=TRUE; c= z                        // Qn+1= Qn È {(z,l,true)}

Конец вечного цикла

 


Осталось заметить, что для корректировки положения ссылки на v в P нам еще необходимо уметь находить эту ссылку на v в P, поэтому в каждом элементе исходного множества v еще придется хранить ссылку на ссылку на v в P. Т.о. при всех перемещениях элементов в P ссылки на эти элементы в исходном множестве придется модифицировать.

Итак для случая планарных графов без кратных ребер и петель, в каждом переборе смежных вершин на протяжении всего алгоритма каждое ребро встречается только два раза, поэтому суммарное время работы первого цикла Для всех… равно O(p log p), суммарное время работы второго цикла Для всех… имеет такую же ассимптотику. Наконец, извлечение из P ссылки на  минимальный элемент выполняется за время O(log p), а т.к. таких элементов O(p), то суммарное время этих извлечений = O(p log p). Таким образом, доказана следующая

Теорема 1.  Для случая планарных графов без кратных ребер и петель алгоритм Дейкстры работает за время O(p2), а модифицированный алгоритм Дейкстры работает за время O(p log p), где p – количество вершин в графе.

 

До сих пор мы не предъявили реализации структуры данных, содержащей вещественные числа, такой, что она должна уметь выполнять следующие операции:

·      добавлять элемент,

·      удалять минимальный элемент,

·      искать минимум элементов,

·      модифицировать положение элемента при изменении его значения,

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

В качестве подобной структуры данных может выступать пирамида, определенная для сортировки Heapsort. При ее определении мы требовали, чтобы в ее вершине лежал максимальный элемент (исходя из того, что A[i/2] ³ Ai ). Мы можем не менять определения пирамиды, потребовав, чтобы в нее заносились исходные элементы, умноженные на –1, в таком случае в вершине будет храниться минимальный элемент исходного множества (умноженный на –1).

Удалять максимальный элемент из пирамиды за время O(log N) мы научились в алгоритме Heapsort. Находить максимальный элемент за время O(1) можно элементарно – он лежит в первом элементе массива пирамиды. Осталось научиться вставлять новый элемент в пирамиду и корректировать его положение при изменении значения.

 

Добавление элемента

Пусть дан пирамидально-упорядоченный массив Ai, (i=1,…,N). Требуется добавить к нему еще один элемент Ai+1 и переупорядочить массив. Для этого введем текущий номер элемента в массиве k. В начальный момент положим k= N+1.

Свойство пирамидально-упорядоченности выполняется для всех соответствующих пар элементов, кроме, быть может, пары (k,k/2). Если для этой пары свойство A[k/2] ³ Ak  выполняется, то массив пирамидально упорядочен и ничего больше делать не надо. Иначе поменяем местами пару элементов с индексами k и k/2. При этом элемент с индексом k/2 не уменьшится, поэтому поддерево, с него начинающееся, но идущее по другой ветке, чем (k,k/2) останется пирамидально-упорядоченным (т.е., например для четного k, останется верным A[k/2] ³ Ak+1).

Перед обменом местами элементов с индексами (k,k/2), элемент A[k/2] был не меньше всех элементов в поддереве, начинающегося с A[k/2], кроме, быть может, Ak. Поэтому, после обмена местами элементов с индексами (k,k/2), элемент Ak будет не меньше всех элементов в поддереве, начинающегося с Ak. Осталось присвоить k=[k/2], и выполнить те же самые действия для нового k.

Таким образом, мы произведем не более [log N]+1 обменов местами соседних элементов, из чего следует, что процедура добавления элемента в пирамиду может быть произведена за время O(log N).

 

Корректировка положения элемента

Пусть дан пирамидально-упорядоченный массив Ai, (i=1,…,N). Для текущего элемента в массиве с номером k изменим значение элемента Ak. Требуется переупорядочить массив.

Если Ak³ A2k и Ak³ A2k+1, то задача полностью сводится к предыдущей.

Пусть, для определенности, Ak< A2k. Но тогда A[k/2] ³ Ak (т.к. A[k/2] ³ A2k> Ak), из чего получаем, что все элементы из поддерева, начинающегося с k-го элемента массива не превосходят A[k/2] . В этом случае возможно применить процедуру Heapify(A,k,N). Время ее работы O(log N).

¢

 

 



Лекция 9

 

Бинарные деревья поиска

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

typedef struct STree_

{

 int value;

struct STree_ *prev;

struct STree_ *left, *right;

} STree;

здесь указатель prev указывает на родительский элемент данной вершины, а left и right – на двух потомков, которых традиционно называют левым и правым. Величина value называется ключом вершины.

Бинарное дерево называется деревом поиска, если для любой вершины дерева a ключи всех вершин в правом поддереве больше или равны ключа a, а в левом – меньше. Неравенства можно заменить на строгие, если известно, что в дереве нет равных элементов.

Далее мы постараемся придерживать синтаксиса языка С, т.е., как правило, мы будем использовать в качестве вершины дерева a переменную типа

STree *a;

Отсутствие потомка обозначается нулевым значением соответствующего указателя.

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

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

Длиной ветви дерева называется количество элементов в ветви.

Высотой дерева называется максимальная длина всех ветвей дерева.

Основные операции, которые можно совершать с бинарными деревьями:

·                  поиск элемента в дереве (т.е. элемента с ключом, равным заданному)

·                  добавление элемента в дерево

·                  удаление элемента из дерева

·                  поиск минимального и максимального элемента в дереве

·                  если рассмотреть дерево поиска, как упорядоченную по возрастанию последовательность элементов, то: для текущего элемента поиск следующего/предыдущего

·                  для данной вершины дерева v разбиение дерева поиска T на два дерева поиска T1 и T2 таких, что все элементы в T1  меньше или равны v, и все элементы в T2  больше или равны v

·                  для двух деревьев поиска T1 и T2 таких, что все элементы в T1  меньше или равны всех элементов в T2  (будем далее для таких деревьев говорить, что дерево T1 меньше или равно дерева  T2:  T1 £ T2): слияние деревьев в одно дерево поиска T

 

Покажем, что все эти операции можно совершить за время O(h), где h – высота рассматриваемого дерева.

Поиск элемента в дереве

Требуется найти элемент, равный v, в дереве. Введем понятие текущей вершины дерева c. Сначала в качестве c выберем корень дерева. Рекурсивно вызывается следующая процедура:

 


Если c==NULL то ИСКОМЫЙ ЭЛЕМЕНТ В ДЕРЕВЕ НЕ СОДЕРЖИТСЯ

Если v==c то return c

Если v³c то выполнить эту же процедуру для c->right

  иначе выполнить эту же процедуру для c->left

 


На языке С это будет выглядеть следующим образом

STree *Find(STree *root, int v)

{

  if(root==NULL)return NULL;

  if(root->value==v)return root;

  if(root->value>=v)return Find(root->right,v);

  else return Find(root->left,v);

}

или более коротко:

STree *Find(STree *root, int v)

{

 return root==NULL?NULL: root->value==v? root: v>=root->value?

   Find(root->right,v): Find(root->left,v);

 }

 

Добавление элемента в дерево

Требуется добавить в дерево вершину v.

Для этого ищем лист, после которого следует вставить v и вставляем v после него.

Алгоритмом, аналогичным поиску элемента, найдем лист c, после которого следует вставить элемент v и вставим его. На языке С вставка вершины в дерево может быть выполнена следующим образом:

STree *Insert(STree *root, STree *v)

{

 if(v->value>=root->value)

    return root->right==NULL ?

   (v->back=root,v->right=v->left=NULL,root->right=v) : Insert(root->right, v);

 else

    return root->left==NULL ?

    (v->back=root,v->right=v->left=NULL,root->left=v) : Insert(root->left, v);

}

 

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

Поиск минимального и максимального элемента в дереве

STree *SearchMin(STree *root)

{ return root->left==NULL?root: SearchMin(root->left);}

 

STree *SearchMax(STree *root)

{ return root->right==NULL?root: SearchMin(root-> right);}

Удаление элемента из дерева

Удаление вершины v из дерева поиска не представляет проблем, если данная вершина является листом или имеет всего одного потомка. Иначе, например, из правого поддерева v можно изъять минимальный элемент (самый левый) и поместить его на место удаленного. При этом, дерево останется деревом поиска.

Поиск следующего/предыдущего элемента в дереве

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

STree *SearchNext(STree *cur)

{

 if(cur==NULL)return NULL;

 if(cur->right!=NULL)return SearchMin(cur->right);

 while(cur->back && cur!=cur->back->left)cur=cur->back;

return cur->back;

}

 

Аналогично ищется предыдущий элемент.

Слияние двух деревьев

Для двух деревьев поиска T1 и T2 таких, что все элементы в T1  меньше или равны всех элементов в T2 : слияние деревьев в одно дерево поиска T.

Выбираем из дерева T2 наименьший элемент (самый левый), исключаем его из дерева T2  и делаем его корнем нового дерева T. Его левым потомком будет корень дерева T1, а правым – корень дерева T2 . Дерево T будет деревом поиска.

Далее нам встретится немного другая задача – пусть задан некоторый элемент v и два дерева T1 и T2 такие, что все элементы в T1  меньше или равны v, а все элементы из  T2  - больше или равны. Слить все указанные данные в одно дерево поиска T. Элемент v, в этой ситуации, называется стыковочным. Задача в данной формулировке тривиальна.

 

Разбиение дерева по разбивающему элементу

Для данной вершины дерева v разбиение дерева поиска T на два дерева поиска T0 и T1 таких, что все элементы в T0  меньше v, и все элементы в T1  больше или равны v.

Для наглядности рассуждений расположим геометрически дерево поиска таким образом, чтобы вершины были упорядочены по оси OX. Проведем на графике из вершины v вертикальную линию. Все элементы в дереве слева от этой линии – меньше или равны v, а справа – больше или равны.

Вершина v имеет два поддерева, из нее выходящих, в одном из которых все элементы меньше v , а в другом – больше или равны. Назовем эти деревья T0 и T1 , соответственно. 

Вершины ветви, от v до корня дерева r назовем V’ = {v1=v,v2,v3,…,vn=r }.

Поддерево, выходящее из вершины, являющейся потомком viÏ V , назовем Ti  (см. рисунок выше).

Легко доказать, что верны следующие факты для любого i:

либо для любого j<i: Ti < vi £ Tj , Ti < vi £ vj  (vj Îправому поддереву vi),

либо для любого j<i: Ti ³ vi > Tj , Ti ³ vi > vj (vj Îлевому поддереву vi).

Легко увидеть, также, что все дерево T состоит из вершин, принадлежащих либо некоторому Ti , либо V.

Итак, конструирование двух требуемых поддеревьев будем производить следующим способом. Рассмотрим, сначала поддеревья T0 и T1. Будем последовательно добавлять в них данные, чтобы получить искомые деревья.

Будем далее последовательно перебирать вершины vi  для i от 2 до n.

На каждом шаге если vi £ vi-1, то Ti< vi£ T0 и мы сливаем деревья Ti и T0 c с помощью стыковочного элемента vi. Иначе vi > vi-1 , тогда Ti³ vi> T1 и мы сливаем деревья Ti и T1 c с помощью стыковочного элемента vi .

В силу вышесказанного, в конечном итоге, деревья T0 и T1 будут искомым разбиением исходного дерева поддеревья T  с помощью вершины v.

¢

Сбалансированные и идеально сбалансированные бинарные деревья поиска

Бинарное дерево называется сбалансированным, если для любой его вершины v высоты левого и правого поддерева, выходящих из v (т.е. поддеревьев с корнями v->left и v->right), отличаются не более чем на 1.

Бинарное дерево называется идеально сбалансированным, если длины всех ветвей, начинающихся в корне дерева и заканчивающихся в узле с хотя бы одним из нулевых указателей v->left и v->right, отличаются не более чем на 1.

В литературе встречается другое (неравносильное) определение идеально сбалансированных деревьев. Будем называть идеально сбалансированными’ деревьями такие деревья, для которых для каждой вершины количество элементов в левом и правом поддереве отличается не более, чем на единицу.

 

 

Следующее условие равносильно условию идеально сбалансированности дерева: длины любых двух ветвей, начинающихся в одной вершине дерева, отличаются не более чем на 1. Доказательство тривиально. Из данного условия сразу же вытекает

Теорема. Идеально сбалансированное дерево является сбалансированным.

 

Иными словами, можно сказать, что для идеально сбалансированного дерева полностью заполнены элементами все слои дерева, кроме, быть может, последнего. Т.о. для идеально сбалансированного дерева высоты h количество элементов лежит в пределах 2h-1£N<2h. из чего сразу же вытекает следующая элементарная

Теорема. Для идеально сбалансированного дерева, состоящего из N вершин, высота дерева h лежит в пределах

log2 N < h £ 1+ log2 N.

Оказывается, верна следующая

Теорема. Идеально сбалансированное’ дерево является идеально сбалансированным.

Доказательство. Докажем данную теорему по индукции. Для деревьев высоты не более 1 теорема верна. Пусть для деревьев высоты h теорема верна, докажем ее для деревьев высоты h +1.

По определению идеально сбалансированных’ деревьев, каждое поддерево такого дерева – идеально сбалансировано’, а по условию индукции левое и правое поддеревья корня дерева высоты h+1 – идеально сбалансированы. В идеально сбалансированных деревьях высоты l для количества элементов дерева N выполнено соотношение 2l-1£N<2l, из чего сразу вытекает: если у двух идеально сбалансированных деревьев количество элементов в них отличается не более, чем на единицу, то либо их высоты равны, либо (если их высоты, соответственно, равны 2l-1 и 2l) в меньшем дереве последний слой полностью заполнен. В обоих случаях длины всех ветвей обоих деревьев, начинающихся в корне, заканчивающихся в вершинах, не имеющих хотя бы одного потомка, отличаются не более, чем на единицу. Отсюда сразу вытекает, что и для дерева, полученного с помощью объединения двух таких деревьев с помощью общего корневого элемента, длины всех ветвей, начинающихся в корне, заканчивающихся в вершинах, не имеющих хотя бы одного потомка, отличаются не более, чем на единицу.

¢

 

Для сбалансированного дерева верна оценка высоты дерева через количество вершин в нем, аналогичная по порядку оценке для идеально сбалансированных деревьев.

Теорема. Для сбалансированного дерева, состоящего из N вершин, высота дерева h имеет оценку:

 h =Q( log2 N ).

Доказательство. Пусть tn – минимальное количество элементов в сбалансированном дереве высоты n. Тогда верна рекурсивная формула

tn=tn-1+tn-2+1

т.е. для сбалансированного дерева высоты n с минимальным количеством вершин одно из поддеревьев, дочерних корневому элементу, должно быть сбалансированным деревом высоты n-1 с минимальным количеством вершин, а другое - сбалансированным деревом высоты n-2 с минимальным количеством вершин.

Уравнение tn-tn-1-tn-2=1  имеет общее решение вида

tn1l1n + с1l2n –1

где li  являются решениями уравнения l2-l-1=0. Из этого следует, что

tn= (1+sqrt(5))/2)n (1+o(1))

после логарифмирования последнего равенства мы сразу получаем требуемое соотношение:

log2 C1  + log2 tn   n log2 ( (sqrt(5)+1)/2 )

n ≤ (log2 tn + log2 C1)/ log2 ( (sqrt(5)+1)/2 )

для некоторого C1>0. Или, в исходных обозначениях,:

h ≤ log2 N / log2 ( (sqrt(5)+1)/2 )+ log2 C2 1.45 log2 N+ C

 

¢

Операции с идеально сбалансированным деревом

Выше мы описали ряд алгоритмов выполнения базовых операций для деревьев поиска. К сожалению, не существует быстрых алгоритмов, для выполнения этих операций для идеально сбаланированных деревьев. Однако, определение идеально сбаланированного’ дерева, фактически, дает нам алгоритм его построения, что сразу же дает нам возможность строить и идеально сбаланированное дерево по тому же набору элементов. Для построения идеально сбаланированного’ дерева по набору из N элементов упорядочим этот набор. После этого алгоритм построения дерева сводится к разбиению полученной последовательности {ai}i=1,…,N на последовательности {ai}i=1,…,[N/2] и {ai}i=[N/2]+2,…,N. Эти последовательности либо имеют равную длину (для нечетных N), либо их длина отличается не более, чем на единицу (для четных N). В корень создаваемого дерева помещаем элемент a[N/2]+1 , а левое и правое поддеревья строим таким же алгоритмом, соответственно, для последовательностей {ai}i=1,…,[N/2] и {ai}i=[N/2]+2,…,N.

Итак, приведенный алгоритм доказывает следующую теорему:

Теорема. Идеально сбалансированное’ (а значит и идеально сбалансированное) дерево поиска, состоящее из N вершин, можно построить за время, равное  O(N log2 N).

 

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

Отметим, что наша задача выродилась. Идеально сбалансированные деревья полезны только для поиска элементов в этих деревьях, но тогда дерево вообще не нужно строить! Достаточно ограничиться упорядоченным массивом исходных элементов, а поиск производить с помощью бинарного поиска. В этом случае, согласно построению идеально сбалансированного дерева’, поиск по дереву будет в точности совпадать с поиском по массиву, что делает само дерево ненужным.

Операции со сбалансированным деревом

Оказывается, что для сбалансированных деревьев все описанные выше операции можно модифицировать так, что они будут сохранять сбалансированность дерева, при этом, время их выполнения не будет превышать O(log2 N) операций. Везде далее, говоря о сбалансированных деревьях, будем подразумевать, что мы имеем дело с сбалансированными деревьями поиска.

Правым и левым поддеревьями некоторой вершины дерева v называются поддеревья с корнями v->left и v->right, соответственно.

Балансом вершины дерева будем называть разность высот левого и правого поддеревьев этой вершины.

Поиск элемента в дереве

Не отличается от случая стандартных деревьев поиска.

Добавление элемента в дерево

Рассмотрим вершину дерева a, в которой нарушается баланс после добавления элемента. Все нижесказанное будет верным, если это не оговорено особо, и для случая какого-либо изменения одного из поддеревьев вершины a, приводящего к удлинению/укорачиванию соответствующего дерева на 1.

Будем предполагать, что для всех вершин, лежащих ниже a, баланс по модулю не превосходит 1.

Пусть, для определенности, элемент добавляется в левое поддерево вершины a. Справа и сверху от вершины будем писать баланс вершины после добавления новой вершины, а рядом, в круглых скобках, - баланс до добавления. Высоту соответствующего данной вершине поддерева будем писать справа снизу от вершины.

То, что дерево после добавления вершины разбалансировалось, означает, что до добавления вершины [a]=1, а после добавления [a]=2 (будем обозначать баланс вершины с помощью квадратных скобок: баланс вершины a = [a]). Возможны три случая баланса вершины b после изменения дерева: 1, 0, -1. Рассмотрим их

1.После добавления вершины  [b]=1 или 0 (или изменения поддерева с корнем b) ([b]=1 соответствует удлинению левого поддерева b)

На рисунке варианты [b]=1 или 0 печатаются через слеш (косую черту).

Приведенную здесь перестановку вершин (с соответствующими поддеревьями) будем называть правым поворотом (в соответствии с перемещением вершин d-b-a).

Будем обозначать высоты дерева с корнем в вершине x  hx , а баланс этой вершины [x].

Пусть he=h, тогда для случая  [b]=1

hd=h+1 (т.к. [b]=1)

hb=h+2

ha=h+3

hc=h       (т.к. [a]=2)

Из чего сразу следует, что после правого поворота

[a]=[b]=0

[c], [d], [e] не изменились

 

Т.о. данное дерево сбалансировалось. При этом, если изменение дерева произошло в результате добавления вершины, его высота не изменилась. Действительно, перед добавлением вершины hd=h из чего следует, что перед добавлением вершины ha=h+2. После добавления высота не изменилась.  Т.о. в случае  [b]=1 процесс балансировки дерева завершен.

Для случая [b]=0 нарисованное дерево тоже остается сбалансированным:

hd=h       (т.к. [b]=0)

hb=h+1

ha=h+2

hc=h-1    (т.к. [a]=2)

Из чего сразу следует, что после правого поворота

[a]=1, [b]=-1

[c], [d], [e] не изменились.

Однако высота всего нарисованного дерева изменяется (была до добавления ha=h+1, стала ha=h+2).

 

Итак, если перед изменением дерева hb=1, то процесс балансировки завершен. Иначе, может разбалансироваться родитель вершины a, и для нее нужно выполнить тот же алгоритм.

 

2.После добавления вершины [b]=-1 (или изменения поддерева с корнем b) (удлинение правого поддерева b)

 

Описанная перестановка может быть проведена за два вращения: левого g-e-b и правого b-e-a:

 

Возможны три варианта баланса c: [c]=0/1/-1. Пусть he=h, тогда, в соответствии с возможными вариантами [c] имеем:

hf=h-1/h-1/h-2

hg=h-1/h-2/h-1

hd=h-1

hb=h+1

ha=h+2

hc=h-1

Из чего сразу следует, что после правого поворота

[b]=0/0/1

[a]=0/-1/0

[e]=0

 [d], [f] , [g] , [c] не изменились.

 

Т.о. данное нарисованное дерево сбалансировалось. При этом, если изменение дерева произошло в результате добавления вершины, его высота не изменилась. Действительно, перед добавлением вершины he=h-1 из чего следует, что перед добавлением вершины ha=h+1. После добавления высота не изменилась.  Т.о. в случае добавления вершины при   [b]=-1 процесс балансировки дерева завершен.

 

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

Итак, мы доказали следующую теорему

Теорема. В сбалансированное дерево поиска, состоящее из N вершин, можно добавить одну вершину за время O(log2 N). При этом, для балансировки дерева потребуется не более двух поворотов.

 

Отметим, что хотя балансировок требуется не более двух, весь процесс балансировки, все же, требует времени O(log2 N), т.к. требуется еще найти – в какой вершине следует производить балансировку.

Удаление элемента из дерева

Удаление вершины из дерева поиска описано в параграфе Бинарные деревья поиска. Нам остается только сбалансировать, возможно, разбалансированное дерево.

Т.о. процедура удаления вершины v из сбалансированного дерева поиска сводится к следующему

·         нахождению вершины v, которую следует удалить,

·         ее удаления из дерева поиска (с помещением на ее место некторой вершины v'),

·         для каждой вершины ветви дерева от v' до корня  следует проверять условие балансировки – если оно нарушилось, то операциями вращения следует произвести балансировку соответствующего поддерева.

 

Итак, в силу построения алгоритма удаления вершины из сбалансированного дерева, верна

Теорема. Из сбалансированного дерево поиска, состоящего из N вершин, можно удалить одну вершину за время O(log2 N).

 

Поиск минимального и максимального элемента в дереве

Не отличается от случая стандартных деревьев поиска.

Поиск следующего/предыдущего элемента в дереве

Не отличается от случая стандартных деревьев поиска.

Слияние двух деревьев

Для двух сбалансированных деревьев поиска T1 и T2 таких, что все элементы в T1  меньше или равны всех элементов в T2 : слияние деревьев в одно дерево поиска T.

Выбираем из дерева T2 наименьший элемент v (самый левый) и удаляем его из дерева T2  . Элемент v  называется стыковочным. Для него верно: T1£ v£ T2 .

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

Будем, для определенности, предполагать, что высота дерева T1 больше или равна высоте дерева T2.

Рассмотрим правую ветвь дерева T1 : {v1,…,vK}. В силу сбалансированности дерева имеем: h(vi) - h(vi+1)  £ 2, тогда на этой ветви найдется вершина vl, такая что

h(vl) = h(T2)  или h(vl) = h(T2)+1

Сольем дерево с корнем в vl и  T2 с помощью стыковочной вершины v и подставим новое дерево на место старой вершины vl.

Вершина vl и все дерево, с нее начинающееся, окажутся сбалансированными. Высота же дерева, начинающегося с vl, увеличится на 1, т.к. h(T2) £h(vl) .

Итак, в результате изменений дерева у одной вершины w длина соответствующего поддерева увеличилась на 1. Далее нам следует запустить стандартную процедуру балансировки дерева. Мы должны пройти ветвь, заканчивающуюся на w, от w до корня и в каждой вершине проверить баланс. Если он будет по модулю больше 1, то баланс в данной вершине следует скорректировать одним или двумя вращениями.

Если, при этом, длина данного поддерева восстановится до значения, которому она была равна до слияния деревьев, то далее процесс проверки сбалансированности производить не надо (т.к. дерево T1 до слияния было сбалансированным). Иначе, процесс проверки следует продолжить.

Отметим, что, в отличии от добавления к дереву одной вершины, в данном случае после уравновешения одной вершины процесс может не завершиться, т.к. возможен следующий вариант

 

Этот вариант не мог реализоваться при добавлении одной вершины к дереву. В этом случае до слияния деревьев высота дерева с корнем a была равна h+1, а после слияния она стала равной h+2.

Итак, в силу построения алгоритма слияния двух  сбалансированных деревьев, верна

Теорема. Для двух сбалансированных деревьев поиска T1 и T2  , состоящих из N1 и N2 вершин, имеющих высоты h1 и h2,  и элемента v, таких, что все элементы в T1  меньше или равны v и v меньше или равно всех элементов в T2 : слияние деревьев с помощью стыковочного элемента v в одно сбалансированное дерево поиска T можно произвести за время T=O(log2 (N1+N2) ) или за время T=O(|h1-h2|). Указанные деревья T1 и T2 можно слить за время T=O(log2 (N1+N2) ).

 

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

 

 

Разбиение дерева по разбивающему элементу

Для данной вершины дерева v разбиение сбалансированного дерева поиска T на два сбалансированных дерева поиска T1 и T2 таких, что все элементы в T1  меньше или равны v, и все элементы в T2  больше или равны v.

Алгоритм, практически полностью, совпадает с алгоритмом разбиения обычного дерева поиска. Только, теперь, нам следует пользоваться алгоритмом слияния деревьев для сбалансированных деревьев поиска.

Пусть высота дерева T0 равна s0. Пусть с деревом T0 будут последовательно сливаться деревья T с индексами i1,…,iK (iK£h), в результате чего будут получаться деревья S1, S2,…, SK . Будем считать S0= T0.  Т.е. Sj-1+ Tij ® Sj .

Высота дерева Sj равна либо MAX(h(Sj-1),h(Tij )), либо MAX(h(Sj-1),h(Tij ))+1.

Пусть Ri – деревья с корнями в вершинах vi. Высота дерева Ri равна либо h(Ti)+1 либо h(Ti)+2. h(Ri) строго возрастают.

Покажем по индукции , что высота дерева Sj равна либо h(Rij), либо h(Rij)-1, либо h(Rij)-2. Пусть данное свойство выполнено для l<j, тогда

h(Sj )=MAX(h(Sj-1),h(Tij )) | MAX(h(Sj-1),h(Tij ))+1 следовательно

h(Sj )= MAX(h(Sj-1),h(Rij) -1) | MAX(h(Sj-1),h(Rij) -2) |

MAX(h(Sj-1),h(Rij) -1)+1 | MAX(h(Sj-1),h(Rij) -2)+1 следовательно по индукции

h(Sj )= (h(Rij) -1) | ((h(Rij) -1) | h(Rij) -2) |

h(Rij)  |                 (h(Rij) | h(Rij) -1) следовательно

h(Sj )= h(Rij) | (h(Rij) -1) | (h(Rij) -2)

Здесь под вертикальной чертой подразумевается разделение возможных вариантов.

Время работы всего алгоритма

T=O(|h(T0)- h(Ti1 )|+1 +| h(S1)- h(Ti2 )|+1 +…+| h(SK-1) - h(TiK )|+1)=

=O(|h(T0)- h(Ti1 )| +| h(S1)- h(Ti2 )| +…+| h(SK-1) - h(TiK )|)+O(h)=

=O(|h(T0 )- h(Ri1 )|+4 +| h(Ri1 )- h(Ri2 )|+4 +…+| h(Ri(K-1) ) - h(RiK )|+4)+O(h)=

(в силу возрастания  h(Ri) )

=O(|h(T0 )- h(Ri1 )|+| h(Ri1 )- h(Ri2 )|+…+| h(Ri(K-1) ) - h(RiK )|)+O(h)= O(h)

 

Т.о.  T= O(h)=O(log2 N), где Nколичество вершин в суммарном дереве.

Итак, верна следующая

Теорема. Для данной вершины v сбалансированного дерева поиска T разбиение на два сбалансированных дерева поиска T1 и T2 таких, что все элементы в T1  меньше или равны v, и все элементы в T2  больше или равны v. может быть произведено указанным алгоритмом за время  = O(log2 N), где Nсуммарное количество вершин в деревьях T1 и T2.

 

 

 


Лекция 10

 

Красно-черные деревья

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

·         корень дерева – черный

·         у каждой красной вершины потомки – черные

·         в любых двух ветвях от корня до листа количество содержащихся черных вершин равно

Для простоты реализации в дерево добавляются фиктивные черные вершины: для каждой вершины дерева, при отсутствии у нее потомка, на место соответствующего потомка вставляется фиктивная черная вершина.

Вершины, отличные от фиктивных, называются внутренними. Будем далее называть листьями вершины, все потомки которых – фиктивные. При определении высоты дерева фиктивные вершины учитывать не будем.

Например, для задания одной вершины красно-черного дерева целых чисел в языке С можно использовать следующую структуру

typedef struct SBTree_

{

 int IsRed;

 int value;

 struct STree_ *par;

 struct STree_ *left, *right;

} SBTree;

здесь указатель par указывает на родительский элемент данной вершины, а left и right – на двух потомков, которых традиционно называют левым и правым. Целая переменная IsRed указывает – является ли данная вершина красной. Величина value называется ключом вершины.

 


Отступление на тему языка С. Поля структур.

В вышеприведенном примере кажется весьма накладным использовать целую переменную для хранения всего одного бита информации. Можно попробовать отвести под эту переменную меньше памяти:

typedef struct SBTreeX_

{

 char IsRed;

 int value;

 struct STree_ *par;

 struct STree_ *left, *right;

} SBTreeX;

 

Однако, в силу наличия выравнивания в структурах, для большинства современных машин размеры структур SBTreeX и SBTree окажутся равными.

Можно попробовать `отщипнуть’ один бит для переменной IsRed из целой переменной с ключом данной структуры value. Это можно сделать с помощью полей в структурах. Поля в структурах это – переменные целого типа, при описании которых после имени переменной пишется двоеточие и вслед за ним – количество бит, которые должны быть отведены под данную переменную. Например, в нашем случае, можно определить вершину дерева следующим образом:

typedef struct SBTree1_

{

 unsigned int IsRed :1;

 unsigned int value :31;

 struct STree_ *par;

 struct STree_ *left, *right;

} SBTree1;

 

При этом, следует понимать, что теперь каждая операция с членами структуры IsRed и value будет происходить довольно сложно (имеется реализация данной операции в кодах). Действительно, например, для изменения переменной value ее сначала требуется извлечь из структуры (используя битовые операции), изменить, а затем – поместить обратно.

Следует ожидать, что на IBM-совместимых ЭВМ работа со следующей структурой SBTree2 будет происходить медленнее, чем со структурой SBTree1:

typedef struct SBTree2_

{

unsigned int value :31;

unsigned int IsRed :1;

 struct STree_ *par;

 struct STree_ *left, *right;

} SBTree2;

 

Здесь используется следующий факт: на IBM-совместимых ЭВМ переменные типа int занимают 4 байта и байты располагаются в обратном порядке: от старшего к младшему. Поэтому для извлечения целой переменной из структуры SBTree1 требуется скопировать первые 4 байта структуры в отдельную переменную и обнулить старший бит этой переменной. Для структуры SBTree2 после извлечения первых четырех байт из структуры во внешнюю целую переменную надо еще дополнительно сдвинуть все биты целой переменной вправо на 1 бит.

Простейшие тесты подтверждают данное предположение. Естественно, что разные компиляторы по разному оптимизируют работу с битовыми полями. Так, например, компилятор Microsoft Visual C++ почти нивелирует разницу в скорости работы со всеми описанными типами структур (разница в скорости элементарных операций с данными структурами оценивается примерно 10-20%). Для используемого же компилятора gnu C++  разница в скорости оказалась – вдвое.

Отметим, что данный подход применим далеко не всегда. Поля в структурах обязаны иметь тип unsigned int. В современных версиях языка С  это требование немного ослабло и вместо этого типа часто можно использовать другие целые типы, но, например, тип float все равно использовать нельзя. Пример использованной программы прилагается.

Отступление на тему языка С. Бинарные операции.

В языке С есть возможность стандартной работы с битами, в рамках возможностей, предоставляемых обычными ассемблерами. Для работы с битами используются следующие арифметические операции:

·         арифметическое и:                                          &

·         арифметическое или:                                      |

·         арифметическое не:                                         ~

·         арифметическое исключающее или:            ^

·         сдвиг влево на k разрядов:                             <<k

·         сдвиг вправо на k разрядов:                           >>k

 

 

С помощью этих операций можно осуществить базовые операции с битами:

k-ый бит целого числа i == 0? :                     (i&(1<<k))==0

Положить 1 в k-ый бит целого числа i :      i|=(1<<k)

Положить 0 в k-ый бит целого числа i :      i&=~(1<<k)

Присвоить l-ый бит целого числа j k-тому биту i :

   i = ((j&(1<<l))==0) ? (i&(~(1<<k))) : (i|(1<<k))

 

 

 

 


Высота красно-черного дерева

Наводящим соображением на то, что в красно-черном дереве, состоящем из N вершин, высота h=Q(log2N) ,  является следующий факт: в каждой ветви дерева не менее половины вершин – черные (т.к., по определению красно-черного дерева, вслед за красной вершиной всегда следует черная), с другой стороны: в каждой ветви находится равное количество черных вершин.

Назовем черной высотой дерева с корневой вершиной r максимальное количество черных вершин во всех ветвях, начинающихся в r и заканчивающихся в листьях, не считая саму вершину r. Будем обозначать ее hb(r). Верна следующая

Лемма. В красно-черном дереве с черной высотой hb количество внутренних вершин не менее 2hb+1-1.

Доказательство. По индукции по высоте дерева (обычной). Если рассмотреть лист (фиктивную вершину), то для нее лемма верна.

Рассмотрим внутреннюю вершину x. Пусть hb(x)=h. Тогда если ее потомок p - черный, то высота hb(p)=h-1, а если – красный, то hb(p)=h. Т.о., по предположению индукции, в поддеревьях содержится не менее 2h-1 вершин, а во всем дереве, соответственно, не менее 2h-1 + 2h-1 + 1=2h+1-1.

¢

Если обычная высота дерева равна h, то черная высота дерева будет не меньше h/2-1 и, по лемме, количество внутренних вершин в дереве

N ³ 2h/2-1.

Прологарифмировав неравенство, имеем:

log2 (N+1) ³ h/2

2log2 (N+1) ³  h

h £ 2log2 (N+1)

Итак, учитывая, что для любого бинарного дерева h > log2 N, получаем, что доказана следующая

Теорема.  Для красно-черного дерева, имеющего N внутренних вершин, верна следующая оценка для его высоты

h=Q(log2N),

или, более точно,

log2 N < h £ 2log2 (N+1).

Добавление элемента в красно-черное дерево

 

Новая вершина вставляется в красно-черное дерева в два этапа.

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

Добавление красной вершины x не меняет баланса дерева по черным вершинам.

Т.к. потомки новой вершины – фиктивные, то они – черные, по определению, что соответствует определению красно-черного дерева.

Единственная проблема, которая может возникнуть, это то, что у вставленной красной вершины x может оказаться красный родитель. Требуется изменить дерево, чтобы решить эту проблему.

При преобразованиях дерева мы будем сохранять указанное свойство: у нас будет сохраняться балансировка по черным вершинам и единственная возможная проблема это – некоторая красная вершина x будет иметь красного родителя.

Итак, x->par – красная, то x->par->par – черная (т.к. единственная проблема – нестыковка x->par и x, с другой стороны, у красной вершины может быть только черный родитель).

Будем называть вершину x->par->par->next, где next это – left или right дядей  вершины x, если x->par->par->next!= x->par.

Рассмотрим все возможные случаи.

1. Дядя вершины - x красный.

Перекрашиваем родителя, деда и дядю вершины x и рассматриваем в качестве вершины x ее деда: x=x->par->par.

Т.о. мы перенесли проблему выше по ветви дерева.

Осталось рассмотреть случаи, когда дядя вершины x - черный.

 

2. Дядя вершины - x черный, x – левый потомок x->par.

 

В этом случае мы проводим правый поворот x-b-a и в получившемся дереве перекрашиваем две вершины: a и b .

Вершина получившегося дерева – черная, проблем с цветами нет, баланс черного сохранился. Т.о. дерево сбалансировано. Последующая балансировка не требуется.

3. Дядя вершины - x черный, x – правый потомок x->par.

Делаем левый поворот f-x-b и ситуация сводится к предыдущему случаю.

 

4. У вершины x->par нет родителя, т.е. эта вершина – корневая. В таком случае мы просто перекрашиваем вершину x->par в черный цвет и процесс завершается.

Все случаи рассмотрены.

 

Итак, после добавления вершины процесс приведения дерева к виду красно-черного дерева сводится к некоторому количеству процедур перекраски 1 (не более h раз, где h – высота дерева) и не более чем к двум поворотам. Причем, после поворотов дерево не требует дальнейших изменений.

Итак, мы доказали следующую теорему

Теорема. Указанный алгоритм позволяет добавлять вершину к красно-черному дереву за время T=O(log2N) операций, где N – количество вершин в дереве.

 

Однопроходное добавление элемента в красно-черное дерево

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

Итак, все, что нам нужно, это – не допустить реализации случая 1, т.к. случай 3 сводится к случаю 2, а последний завершает алгоритм. Это можно обеспечить, перекрашивая вершины при поиске листа, после которого следует вставить новую вершину. Иными словами, нам следует обеспечить, чтобы либо у вставляемой был бы черный родитель (тогда ничего больше делать не надо), либо у вставляемой вершины был бы черный дядя (тогда дерево можно сделать красно-черным за два поворота).

При поиске листа, после которого следует вставить новую вершину, вы, сначала рассматриваем в качестве текущей вершины p корень дерева. Далее в качестве p рассматриваем один из потомков корня, и т.д. Пусть, для определенности, от вершины p мы переходим к вершине p->left, тогда нам следует обеспечить, чтобы p->right была бы черной вершиной.

Рассмотрим все возможные случаи. Следует рассматривать только случаи, когда оба потомка p красные (легко увидеть, что случаи, когда p->left или p->right – черные нас устраивают).

1. p->par – черный, оба потомка p – красные. Тогда, все, что нужно сделать – перекрасить p и его потомков и перейти к рассмотрению следующей вершины p->left.

 

 

2. p->par – красный, причем p – правый потомок p->par, p->par – левый потомок p->par->par, оба потомка p – красные (случай, когда оба потомка – левые - аналогичен) .

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

3. p->par – красный, причем p – правый потомок p->par, p->par – левый потомок p->par->par, оба потомка p – красные (случай, когда p – левый потомок p->par, p->par – правый потомок p->par->par - аналогичен).

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

 

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

Удаление элемента из красно-черного дерева

Сначала мы удаляем вершину, как в обычном дереве поиска.

Если у удаляемой вершины y всего один внутренний потомок x, то мы просто ставим x на место y. Если вершина y была красной, то проблем не возникает (черная длина дерева не изменяется). Если вершина y – черная, а x – красная, то проблем тоже нет: мы перекрашиваем вершину x, вставшую на место вершины y, в черный цвет и RB-свойства будут выполняться. Наконец, если обе вершины x и y – черные, то нам придется присвоить вершине y двойную черноту. Как с ней бороться – будет ясно далее.

Если у удаляемой вершины y два внутренних потомка w=y->right, z=y->left, то мы извлекаем следующий элемент за y (минимальный в дереве с корнем w) и ставим его на место y.

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

 

Итак, задача сводится к следующей. Есть вершина в красно-черном дереве x, обладающая двойной чернотой. Все свойства красно-черного дерева выполняются. Требуется привести дерево к такому виду, что в нем все вершины будут просто черными или красными.

Мы будем производить некоторые манипуляции с окрестностью вершины x, которые будут или перебрасывать двойную черноту вверх по дереву, или окончательно приведут дерево к требуемому виду. Если корень дерева приобретет двойную черноту, мы просто сделаем его черным, что завершит работу алгоритма.

Рассмотрим различные варианты:

1)брат x – красный;

2-4: брат x – черный:

               2)потомки брата x – черные;

               3)правый потомок брата x – черный, левый – красный;

               4)правый потомок брата x – красный.

 

1)      брат x красный.

 

 

 

 

x остается с двойной чернотой, но получает черного брата. Ситуация сводится к вариантам 2-4.

 

2) брат x – черный, потомки брата x – черные.


Одна чернота x и чернота b переходят к их родителю. Если родитель был красным, то процесс на это завершается. Иначе рассматриваем далее в качестве вершины x вершину a.

 

3) правый потомок брата x – черный, левый – красный.

 

 

Делаем правый поворот c-b-d и перекрашиваем вершины b и c. В результате, получаем, что правый потомок брата x – красный, т.е. приходим к случаю 4.

 

4) правый потомок брата x – красный, левый – не важно.

 

Делаем левый поворот d-b-a  и делаем указанную перекраску (x становится просто черной). При этом цвет корня дерева и вершины c не должны меняться.

Разберемся с балансировкой. Пусть hb(x)=h (не забывать, что в hb(x) не учитывает сама вершина x). То hb(a)=h+2, hb(b)=h+1=hb(d)= количеству черных вершин в любой ветви, начинающейся на c. Отсюда, в результате простой проверки, получаем, что новое дерево является красно-черным.

 

Итак, мы завершили разбор операции удаления вершины в красно-черном дереве.


Лекция 11

 

B-деревья

 До сих пор мы рассматривали только бинарные деревья. Теперь рассмотрим деревья, имеющие большую степень ветвления. При этом хочется, чтобы сохранялись свойства, аналогичные свойству сбалансированности в сбалансированных деревьях. Этим условиям удовлетворяют B-деревья. Отметим, что B-деревья являются основным инструментом построения многих современных файловых систем (RaiserFS, JFS, XFS).

В B-деревьях в каждой вершине может содержаться несколько элементов (ключей). Высота дерева определяется как максимальное количество вершин в ветвях. Будем далее рассматривать случай, когда все элементы (ключи) в дереве различны.

В-дерево степени n определяется следующим образом

·      каждая вершина дерева, кроме корня, содержит от n-1 до 2n-1 элемента (ключей) и от n до 2n ссылок на дочерние элементы; корень дерева содержит не более 2n-1 элементов (ключей) и не более 2n ссылок на дочерние элементы

·      В-дерево идеально сбалансировано, более того, длины всех ветвей совпадают;

·      элементы в каждой вершине упорядочены по возрастанию

·      если в вершине содержится k элементов, то в ней содержится k+1 ссылок на дочерние вершины (кроме листьев, ссылок на дочерние вершины не содержащих);

·      элементы в вершине и ссылки на дочерние вершины сопоставляются следующим образом: про первую ссылку говорят, что она располагается до первого элемента, про последнюю – что она располагается после последнего элемента, остальные ссылки располагаются каждая – между некоторой парой элементов в вершине;

·      все элементы xi в поддереве V, ссылка на который расположена после некоторого элемента y больше y; все элементы xj в поддереве V, ссылка на который расположена до некоторого элемента z больше z.

Пример В-дерева степени 3:

 

Как правило, В-деревья имеют достаточно большие степени. Например, их задают исходя из того, что одна вершина должна занимать один блок на диске.

На языке С тип данных для хранения одной вершины В-дерева степени 100 целых чисел можно определить следующим образом

#define NB 100

typedef struct BNode_

{

 struct BNode_ *par;

 int n;

 struct BNode_ *child[2*NB];

 int value[2*NB];

} BNode;

Здесь n – количество элементов, содержащихся в вершине, value[i] – значение i-го элемента, child[i] – указатель на соответствующего потомка. Заметим, что мы отвели на один целый элемент больше, чем нам требуется для хранения данных. Этим мы воспользуемся позднее – при поиске элемента в В-дереве.

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

#define NB 100

typedef struct BNode_

{

 struct BNode_ *par;

 int n;

 struct BNode_ *child[2*NB];

 char  *str[2*NB-1];

} BNode;

 

Инициализировать такую структуру можно очень простой функцией:

void Init(BNode *node){memset(node,0,sizeof(BNode));}

После инициализации занесение строки в k-ый элемент вершины можно осуществить следующей функцией

void Insert(BNode *node, int i_elem, char *str)

{

 if(node->str[i_elem]!=NULL)free(node->str[i_elem]);

 node->str[i_elem]=strdup(str);

}

 

Высота B-дерева

Получим оценку на высоту В-дерева через количество элементов в нем.

Корень дерева содержит не менее одного элемента. На втором уровне содержится не менее двух вершин, а в каждой вершине – не менее n-1 элементов. На каждом следующем уровне количество вершин увеличивается не менее чем в n раз (т.к. каждая вершина имеет не менее n потомков). Т.о. на k-ом уровне будет не менее  2nk-2 вершин для k>1, и, соответственно, не менее  2(n-1)nk-2 элементов.

Т.о. получаем оценку на количество элементов N в дереве высоты h

N ³ 1+Sk=2k£h 2(n-1)nk-2=1+2(n-1)Sk=0k£h-2 nk=

=1+2(n-1)(nh-1-1)/(n-1)= 2 nh-1-1

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

Теорема. Для В-дерева степени n, содержащего N элементов, высоты h верна оценка для высоты

h=Q(logn-1N).

Верна точная оценка

h £ logn((N+1)/2)+1.

Поиск вершины в B-дереве

Поиск вершины, содержащей заданный элемент (или элемент с ключом, равным заданному), осуществляется аналогично поиску в двоичном дереве поиска. Единственное отличие – для каждой вершины процедура поиска данного элемента более сложная, чем для случая дерева поиска. На языке поиск элемента, равного v, в В-дереве с корнем root можно оформить в виде следующей функции

BNode *BSearch(BNode *root, int v)

{

  if(root==NULL)return NULL;

  for(i=0;i<root->n;i++)

   if(root->value[i]==v)return root;

   else if(root->value[i]>v)return BSearch(root->child[i],v);

 return BSearch(root->child[i],v);

}

Отступление на тему языка С. Быстрый поиск и сортировка в языке С

В конкретных реализациях степень В-дерева может быть весьма большой. Поэтому поиск элемента в одной вершине при больших степенях В-деревьев следует производить с помощью двоичного поиска. В языке С есть стандартная функция для поиска в упорядоченном массиве bsearch. Например, в Microsoft Visual C эта функция имеет следующее описание

void *bsearch( const void *key, const void *base, size_t nmemb, size_t size, int ( __cdecl *compare ) ( const void *elem1, const void *elem2 ) );

В других компиляторах  описание этой функции аналогично, быть может, с точностью до тонкостей. Например, в компиляторе gcc описание этой функции имеет следующий вид

void *bsearch(const void *key, const void *base, size_t nmemb, size_t size, int (*compare)(const void * elem1, const void * elem2));

Здесь key – указатель на искомый элемент, base – указатель на массив с данными, nmemb – количество элементов в массиве, size – размер в байтах одного элемента массива, compare – указатель на функцию, получающую указатель на два элемента массива и возвращающую результат сравнения элементов: +1 – если первый элемент больше второго, -1 – если второй элемент больше первого, 0 – если элементы равны.

Для нашего случая – поиска целого числа в массиве функция сравнения может быть определена следующим образом

int compare(const void *v0,const void *v1)

{ return *(int*)v0>*(int*)v1 ? 1 : *(int*)v0<*(int*)v1 ? -1 : 0 ; }

 

К сожалению, если данный элемент в массиве не найдет, то функция bsearch возвращает NULL, при этом информация о том – между какими элементами находится искомый, теряется. Если нам все же хочется непременно воспользоваться функцией bsearch, то мы можем применить некоторый трюк: мы можем воспользоваться информацией о том, что реально – первый параметр bsearch это – адрес искомого элемента, а второй – адрес некоторого элемента в массиве. Исходя из алгоритма двоичной сортировки, если искомый элемент в массиве отсутствует, то последний элемент *v1 при вызове функции compare, для которого оказалось, что

*(int*)v0<*(int*)v1

будет ближайшим элементом массива, большим искомого (=*(int*)v0) . Адрес этого элемента можно запомнить в соответствующей глобальной переменной. Чтобы указанное свойство было верным и для последнего элемента исходного массива, поместим вслед за последним элементом массива самое большое из всех чисел типа int, и, соответственно, запретим его использование в обычной работе. Поиск же элемента будем производить в расширенном массиве. В этом случае функцию сравнения следует оформить следующим образом

int *v_gt_save=NULL;

int compare (const void *v0,const void *v1)

{

 if(*(int*)v0>*(int*)v1)return 1;

 if(*(int*)v0<*(int*)v1){v_gt_save=(int*)v1;return -1;}

 return 0;

}

 

Функция поиска вершины может тогда выглядеть следующим образом

 

BNode *BSearchQ(BNode *root, int v)

{

  if(root==NULL)return NULL;

  root->value[root->n]= INT_MAX;

  if(bsearch(&v, root->value, root->n+1, sizeof(int),compare))return root;

  return BSearchQ(root->child[v_gt_save-root->value], v);

}

Здесь константа INT_MAX обозначает максимальное число типа int. Данная константа (определяемая через #define) является, фактически, стандартной в разных версиях языка С. Так, например, в Microsoft Visual C и GCC эта константа определяются в стандартном файле include.h.

Отметим, что данный подход, возможно, не является оптимальным как в плане скорости счета (присутствует лишняя операция присваивания v_gt_save=(int*)v1), так и в плане выполнения правил хорошего тона (в алгоритме использовались глобальные переменные). Однако этот подход немного экономит время программиста (не надо программировать алгоритм двоичного поиска). В нашем же случае он, скорее, служит примером использования функции bsearch.

Еще одним примером использования указателей на функцию является использование функции быстрой сортировки. Функция имеет следующее описание в Microsoft Visual C

void qsort( void *base, size_t num, size_t size, int (__cdecl *compare )(const void *elem1, const void *elem2 ) );

а в GCC:

void qsort(void *base, size_t num, size_t size, int (*compar)(const void*,const void*));

здесь base – указатель на массив с данными, num – количество элементов в массиве, size – размер в байтах одного элемента массива, compare – указатель на функцию, получающую указатель на два элемента массива и возвращающую результат сравнения элементов: +1 – если первый элемент больше второго, -1 – если второй элемент больше первого, 0 – если элементы равны.

Например, в нашем случае отсортировать массив элементов одной вершины node В-дерева можно следующим образом

Bnode *node;

qsort(node->value, node->n, sizeof(int),compare);

Добавление вершины в B-дерево

По аналогии с деревом поиска новый элемент сначала вставляется в некоторый лист V В-дерева степени n. Если, после этого, количество элементов в данном листе останется меньшим 2n-1, то на этом процедура завершается. Иначе в элементах данной вершины находится медиана x и вершина разбивается на две вершины по n-1 элементу в каждой, причем элементы в первой вершине V-  должны быть меньше x, а во второй V+  – больше x. Элемент x вставляется в массив элементов в родительской вершине между элементами, между которыми находилась ссылка на вершину V. Ссылки на вершины V-  и V+ должны расположиться непосредственно слева и справа от x. После этого, если в родительской вершине количество элементов становится меньше 2n-1, то на этом процедура завершается. Иначе процедура разбиения вершины рекурсивно применяется для родителя.

Приведем пример. В следующее дерево требуется вставить элемент со значением 4.

Элемент 4 надо вставлять в вершину со значениями {1, 5, 7, 8}. Итого мы получим {1, 4, 5, 7, 8}. Т.е. количество элементов равно 2n-1. Вершина дерева разбивается на две: {1, 4} и {7, 8} со стыковочным элементом 5. Элемент 5 вставляется в родителя вершины {10, 15}, на чем процесс вставки завершается:

В худшем случае процедура будет последний раз применена для корня дерева и дерево увеличит свою высоту на 1.

Удаление вершины из B-дерева

 

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

n элементов (по определению В-дерева достаточно, чтобы в вершине присутствовало не менее n-1 элемента).

Итак, в процессе поиска вершины, содержащей удаляемый элемент v, мы вводим понятие текущей вершины x. Текущая вершина перемещается от корня дерева по соответствующей ветви к вершине, содержащей удаляемый элемент.

Для каждого очередного значения текущей вершины возможен один из следующих вариантов

 

1) Вершина является листом.

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

 

2) Вершина x - внутренняя. Элемент v в вершине x не найден.

Ищем потомка x->child[i] вершины x, с которого начинается поддерево,

содержащее элемент v (если он вообще есть в дереве). По условию мы должны гарантировать, чтобы в вершине x->child[i] содержалось бы не менее n элементов. Если это выполняется, то переходим к рассмотрению этой вершины:

x=x->child[i].

Иначе мы либо `перетаскиваем' один элемент из брата вершины x->child[i] в

вершину x->child[i], либо, если это невозможно, объединяем данную вершину с братом. Более подробно, есть два варианта:

   а) У вершины x->child[i] есть брат, содержащий не менее n элементов.

   Пусть, для определенности, это - правый брат, т.е.  x->child[i+1]->n ³ n.

   Тогда мы переносим элемент x->value[i] в конец массива элементов x->child[i]

   (соответственно, увеличив на 1 значение x->child[i]->n) и

   элемент x->child[i+1]->value[0] переносим на место x->value[i]

   (соответственно, уменьшив на 1 значение c->child[i+1]->n):

 

   x->child[i]->value[++x->child[i]->n]=x->value[i];

   x->value[i]=x->child[i+1]->value[0];

   for(i=1;i<x->child[i+1]->n;i++)

     x->child[i+1]->valuie[i-1]=x->child[i+1]->valuie[i];

   x->child[i+1]->n--;

 

Например, требуется в следующем дереве удалить вершину 11 в В-дереве степени 3:

У данной вершины есть сосед (левый), содержащий 3 элемента. Тогда мы максимальный элемент из этой вершины – 7 – помещаем на место 10, а 10 помещаем на место 11:



 

 


 

   Теперь мы можем перейти к рассмотрению следующей вершины:

   x=x->child[i];

 

   б) У вершины x->child[i] все братья содержат n-1 элемент.

Допустим,  для определенности, у вершины x->child[i] есть правый брат. Тогда, мы объединяем вершину x->child[i] с вершиной x->child[i+1] с помощью стыковочного элемента x->value[i]. Т.е. мы добавляем элемент x->value[i] в конец массива x->child[i]->value и затем добавляем все элементы из   x->child[i+1] в конец массива x->child[i]->value.

Осталось удалить все лишнее: элемент x->value[i] из массива  x->value, потомка    x->child[i+1], ссылку x->child[i+1] из массива x->child.

Здесь мы воспользовались тем, что в вершине x есть хотя бы n элементов.

 

Например, требуется в следующем дереве удалить вершину 14 в В-дереве степени 3:

 

 

Для этого мы объединяем вершину {11,14} с элементом 15 и с вершиной {17,57}:

 

 

 


 

 

    Теперь мы можем перейти к рассмотрению следующей вершины:

    x=x->child[i];

 

3. Вершина x - внутренняя, в вершине найден элемент x->value[i]==v.

Сначала удаление производится аналогично дереву поиска: в одном из поддеревьев, соседних данной вершине, например, для определенности, в правом соседнем поддереве данной вершины (т.е. в поддереве, начинающемся с вершины                       x->child[i+1]) находим элемент v0, ближайший к v. В нашем случае это – минимальный элемент поддерева, начинающегося с вершины x->child[i+1]. Далее, помещаем элемент v0 на  место элемента v и запускаем процедуру удаления старого (т.е. удаленного) элемента v0.

Здесь, чтобы не запутаться в `старом’ v0 и `новом’ v0 лучше сначала запомнить адрес элемента, на который мы должны скопировать v0  и само значение v0, далее можно осуществить процедуру удаления элемента v0 и только потом скопировать запомненное v0 по запомненному адресу. 

 

 


Лекция 12

 

Хеширование

Фактически, алгоритмы работы со всеми структурами данных, связанными с деревьями, основаны на операции сравнения. Можно использовать другой подход. Попробуем на основе значения элемента x, заносимого в структуру данных, вычислять некоторую функцию h(x), которая будет так или иначе отражать положение элемента x в структуре данных (например, индекс элемента в массиве). Такая функция называется хэш-функцией. Сама структура данных, поиск элементов в которой использует хэш-функцию, называется хэшируемой.

Наиболее прямолинейным способом хранения хэшируемых данных является массив массивов элементов. Т.е. для каждого значения хэш-функции отводится свой массив, в котором хранятся элементы, рассматриваемого типа. Например, для работы с множеством целых чисел, при использовании хэш-функции h(x) со значениями 0£h(x)<M, можно использовать массивы

int h_array[M][N], l_array[M];

Здесь константа N задает ограничение на количество чисел, содержащихся в структуре данных, для каждого значения хэш-функции. Данные, соответствующие значению хэш-функции h(x)=i, хранятся в массиве h_array[i], количество элементов в этом массиве хранится в переменной l_array[i].

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

Метод многих списков

Модификацией вышеописанного алгоритма является алгоритм, хранящий данные методом многих списков. В нем каждому значению хэш-функции сопоставляется свой список значений, содержащий хранимые данные. В этом случае на языке С при использовании стандартных списков (L1 или L2) для организации данных следует завести массив указателей на вершину списка:

CList *h_list[M];

здесь M – (как и выше) константа, ограничивающая максимальное значение хэш-функции; CList    тип переменной для хранения одной вершины списка.

Инициализация структуры данных тривиальна:

void Init(Clist *h_list[]){memset(h_list,0,M*sizeof(Clist*));}

 

Можно оценить среднее время поиска элемента в такой структуре данных в ситуации, когда у нас используется `идеальная’ хэш-функция, т.е. время ее работы равно O(1) и она с равной вероятностью выдает все свои значения для потока входных данных. В этом случае среднее время поиска элемента пропорционально среднему количеству элементов в произвольном списке из массива h_list.

Итак, пусть у нас хранится всего N  элементов в M  списках. Вероятность попадания элемента в один определенный список равна p=1/M. Тогда вероятность попадания k элементов в один конкретный список равна pk=CNkpk(1-p)N-k. Средняя длина списка равна

lN =Sk=0k£N k pk = Np = N/M

Данная формула доказывается следующим образом:

(x-q)N=Sk=0k£N CNkxk q N-k ;    продифференцируем по x:

(x-q)N  = N(x-q)N-1 = Sk=0k£N k CNkxk-1 q N-k

 

Теперь, если взять x=p, q=1-p, то получим

lN =Sk=0k£N k pk = p N(p- (1-p))N-1=Np

 

Т.о., мы доказали следующую теорему

 

Теорема. Если хэш-функция h(x) с равной вероятностью принимает все свои значения 0£h(x)<M, то среднее время поиска, добавления, удаления элемента в хэшируемом множестве, реализованном с помощью метода многих списков,

TN,M = Q(N/M).

В худшем случае для поиска, добавления, удаления элемента требуется время, равное Q(N).

 

Для случая хэширования с помощью массивов оценки аналогичны.

 

 

Метод линейных проб

Можно попробовать использовать для хранения данных таблицу без ссылок. Будем дополнительно в каждой ячейке таблицы хранить информацию о том – занята ли ячейка или нет. Если при занесении в таблицу нового элемента хэш-функция от заносимого значения укажет на пустую ячейку, то проблем никаких нет. Иначе, получается ситуация, называемая коллизией. Разрешение коллизий является основной целью при создании алгоритмов работы с хэшируемыми множествами.

Простейший способ разрешения коллизий в таблице следующий: если элемент, на который указывает хэш-функция, занят, то мы рассматриваем последовательно все элементы таблицы от текущего, пока не найдем свободной место. Новый элемент помещается в найденное свободное место. Чтобы отследить ситуацию с переполнением таблицы, мы введем переменную M – счетчик количества занятых ячеек в таблице. Если значение M достигло величины N-1, то мы считаем, что наступило переполнение. Это гарантирует нам, что в таблице всегда будет в наличии хотя бы одна пустая позиция.

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

На языке С алгоритм поиска элемента value в таблице из Nmax целых чисел можно реализовать следующим образом.

 

#define Nmax 1000

typedef struct CNode_{int value; int is_empty;} CNode;

CNode node[Nmax];

int hash(int value);

int search(CNode node[])

{

 int  i;

 for(i=hash(value); !node[i].is_empty; i=(i==0? Nmax-1 : i-1))

  if(node[i].value==value)return i;

 return –1;

}

 

здесь  hash(int value) хэш-функция; член структуры is_empty равен нулю, если элемент пуст, единицеиначе. В данной реализации поиск идет по направлению уменьшения индекса. Наличие в таблице хотя бы одной пустой позиции гарантирует нам, что цикл не будет вечным.

Чтобы сэкономить память признаки  пустоты элементов таблицы можно реализовать отдельно от самой таблицы. Тогда под каждую ячейку можно будет отвести ровно 1 бит информации:

 

#define Nmax 1000

int value[Nmax], is_empty[(Nmax+31)/32];

int hash(int value);

int search(CNode node[])

{

 int  i;

  for(i=hash(value); !(is_empty[i/32]&(i%32)); i=(i==0? Nmax-1 : i-1))

  if(node[i].value==value)return i;

 return –1;

}

 

Удаление элемента из таблицы несколько более сложное, чем добавление и поиск. Нельзя просто объявить позицию i, в которой требуется удалить элемент, пустой. Если это сделать, то элемент value[j] с индексом j<i, для которого h(value[j])>i и для которого все элементы с индексами между j и i заняты, окажется потерянными для последующего поиска (здесь мы рассматриваем случай отсутствия `перескока’ в конец массива при поиске очередного элемента). Действительно, при поиске этого элемента мы обязательно натолкнемся на пустую позицию value[i] и поиск будет завершен.

Чтобы исправить ситуацию мы должны найти первый такой элемент value[j], перенести его значение в позицию i и свести задачу к удалению элемента value[j]. Естественно, мы должны учитывать возможность `перескока’ в конец массива при поиске такого value[j]. Т.е., более строго, условия переноса value[j] в позицию i следующее:

h(value[j])>i>j (отсутствие перескока)

 

или

j>h(value[j])>i (перескок)

или

i>j>h(value[j]) (перескок)

 

Условием остановки алгоритма является пустота позиции j.

Подпрограмма удаления элемента с индексом i из списка может выглядеть следующим образом

void remove(CNode node[], int  i)

{int j; node[i].is_empty=1;

 for(j=i-1; !node[j].is_empty; j=(j==0? Nmax-1 : j-1))

 if( (hash(value[j])>i && i>j) || (hash(value[j])>i && j>hash(value[j])) ||

  (i>j && j>hash(value[j])))

 {

  node[i].is_empty=0; node[i].value=node[j].value;

  remove (node,j);

  break;

 }

}

 

 

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

 

Лемма. Si=0 M CL+iL = CL+M+1L+1

Доказательство.

M=0: CLL = CL+1L+1

M:  Si=0 M CL+iL =Si=0 M-1 CL+iL + CL+ML = CL+ML+1 + CL+ML =

=(L+M)!/((L+1)!M!) + ((L+M)!/(L!M!))= (L+M+1)!/((L+1)!M!)= CL+M+1L+1

¢

Оценим время добавления элемента к хэшированной таблице методом проб в случае, когда в таблице из N записей занято M позиций. Это время, фактически, совпадает с неудачным временем поиска значения в таблице (т.е. поиска элемента, если его в таблице нет).

Если вслед за позицией, на которую указала хэш-функция следует k-1 занятая запись (включая запись, на которую указала хэш-функция), то время вставки есть Q(k) (т.к. для поиска свободной позиции надо будет осуществить k проб). Пусть вероятность того, что начиная с данной позиции следует k-1 занятых позиций, а следующая позиция свободна, равна pk. Тогда, учитывая то, что количество подряд занятых позиций не может превзойти M, среднее время вставки элемента пропорционально

TM = Si=1 M+1 k pk

Вероятность pk равна количеству перестановок оставшихся M-k+1 занятых позиций среди оставшихся N-k записей, деленное на общее число перестановок CNM. Итак

pk= CN-kM-k+1 / CNM

 

TM = Sk=1 M+1 k pk = Si=1 M k CN-kM-k+1 / CNM =

(учитывая Cnk=Cnn-k)

=Sk=1 M+1 k CN-kN-M-1 / CNM =

(учитывая Si=1 M k pk=1 )

=N+1 - Sk=1 M+1 (N + 1- k ) CN-kN-M-1 / CNM =

=N+1 - Sk=1 M+1  ( (N-k+1)! / ((N-M-1)!(M-k+1)!) ) / CNM =

=N+1 - Sk=1 M+1 (N -M) CN-k+1N-M / CNM =

(замена i=M-k+1, k=M-i+1)

=N+1 - Si=0 M (N -M) CN-M+kN-M / CNM = N+1 -(N -M) CN-M+M+1N-M+1 / CNM=

=N+1-(N-M) ((N+1)!/((N-M+1)!M!))  M!(N-M)!/N! =

= N+1 – (N-M)(N+1)/(N-M+1) = (N+1)/(N-M+1)

 


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

TM = 1/(M+1) Si=0 M Ti = 1/(M+1) Si=0 M (N+1)/(N-i+1)=

= 1/(M+1) Si=0 M (N+1)/(N-i+1)= 1/(M+1) Sj=N-M+1 N+1(N+1)/j£

(N+1)/(M+1)òt=N-M+1 N+11/t dt=(N+1)/(M+1) log((N+1)/(N-M+1))

 

Т.о. если ввести коэффициент заполненности таблицы a=M/N, то верна следующая теорема

Теорема. Среднее время неудачного поиска элемента в таблице, состоящей из N записей, M из которых заполнены

ТM=Q(1/(1-a)), где a=M/Nкоэффициент заполненности таблицы.

Среднее время удачого поиска имеет следующую оценку

ТM =Q( (1/a) log(1/(1-a)) )

 

 


Метод цепочек

Для разрешения коллизий метод проб можно немного модифицировать. В каждый элемент таблицы, состоящей из N ячеек, можно добавить ссылку на элемент таблицы, с помощью которой можно ускорить поиск следующего элемента.

Для локализации свободного места введем переменную P, которая изначально указывает на конец списка: P=N+1. При возникновении коллизии свободную ячейку мы будем брать исходя из значения P: для этого P уменьшается на 1 до тех пор, пока ячейка с индексом P не станет свободной. Найденная ячейка будет использована для нового значения, заносимого в таблицу. Таким образом, все ячейки с индексами больше P всегда заняты.

Каждому значению хэш-функции будет сопоставлена цепочка ячеек, в которой будут находиться все значения, соответствующие данному значению хэш-функции. При добавлении нового значения x в ячейку таблицы с индексом P (если возникла коллизия) ссылка из последней ячейки соответствующей цепочки должна направиться на ячейку P.

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

Более формально, для хранения хэшированной методом цепочек таблицы, состоящей из не более чем 1000 целых чисел, необходимо определить следующие данные

#define Nmax 1000

typedef struct CNode_

{int value; int is_empty; int next;} CNode;

CNode node[Nmax];

int P;

int hash(int value);

 

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

Функция инициализации данных может выглядеть следующим образом

void Init(CNode node[])

{int i;

 for(i=0;i<Nmax;i++){node[i].is_empty=1; node[i].next=-1;} P=Nmax;

}

здесь мы использовали информацию о том, что ячейки в таблице имеют индексы от 0 до Nmax-1 и, поэтому, P указывает на первую (не существующую) ячейку после таблицы.

 

Процедура поиска в таблице будет иметь следующий вид

int search(CNode node[])

{

 int  i; i=hash(value);

 if(node[i].is_empty)return –1;

 for(; node[i].next>=0; i=node[i].next)

  if(node[i].value==value)return i;

 return –1;

}

Процедура занесения значения value в таблицу будет иметь следующий вид

int add(CNode node[], int value)

{

 int  i; i=hash(value);

 if(node[i].is_empty)//если нет коллизии

 {node[i].value=value; node[i].next=-1; return 0;}

//иначе – если коллизия есть: ищем конец цепочки:

  for(; node[i].next>=0; i=node[i].next);

//ищем свободное место:

 do{ P--;

       if(P<0)return –1;//переполненние

     }while(!node[P].is_empty) ;

//помещаем элемент на найденное место:

 node[P].value=value; node[P].is_empty=0; node[P].next=-1;

 node[i].next=P;

 return 0;

}

 

Для того, чтобы понять преимущество метода цепочек над методом линейных проб, рассмотрим следующую ситуацию. Пусть таблица сильно заполнена. Пусть в данный момент в таблицу еще не заносились элементы в позицию с индексом i. При первом появлении элемента x1 такого, что h(x1)=i все происходит почти также, как и в методе проб: элемент заносится в таблицу, ссылка на следующий элемент устанавливается в пустоту:

node[i].value= x1

node[i].next=-1

node[i].is_empty=0

 

Если же появится еще один элемент  x2 такой, что h(x2)=i, то сразу станет ясно отличие от метода проб: для нового элемента ищется свободное место с помощью уменьшения P до тех пор, пока не станет выполняться is_empty[P]==1; новый элемент помещается в позицию P и ссылка next[i] устанавливается на позицию P.

Т.о., с одной стороны, мы не просматриваем в поисках свободного места заведомо заполненную часть таблицы, а с другой - поиск элемента x2 теперь займет всего два сравнения. Вообще, переменная P может смежаться на одну позицию не более, чем N раз, а т.к. в таблицу заносится не более, чем N элементов, то получается, что в среднем для поиска свободного места для одного элемента переменная P изменяется всего на 1, т.е. в среднем проводится не более одной проверки на пустоту очередной позиции с индексом P.

 

 

 

 


Хэш-функции

Существует два наиболее простых метода построения хэш-функций: на основе деления и на основе умножения.

Хэш-функции на основе деления

Пусть требуется для числа A получить значение хэш-функции. Предлагается в качестве хэш-функции использовать остаток от деления A на некоторое K

h(A)=A (mod K)

Если A имеет довольно большую длину (например, A – строка текста), то данный алгоритм применим и в этом случае. Будем далее остаток от деления обозначать через оператор %. Представим A как число в позиционной системе счисления

A=Sk=0k<N r kck

где r=256 (в общем случае r – основание системы счисления, в которой представляется число A), ck–значение k-ой цифры в представлении A (в нашем случае = код k-ого символа строки), N – количество знаков (цифр) в представлении A. Будем предполагать, что

K<r.

Легко увидеть, что

(r N-1 cN-1 + r N-2 cN-2 )%K= (r N-2 ( (r cN-1 + cN-2)%K) )%K

Из чего сразу получаем, что

h(A)=( Sk=0k<N r kck )%K = ( Sk=0k<N-1 r kdk )%K,

где dN-2=(r cN-1 + cN-2)%K, di = ci (i<N-2).

Т.о. следующая подпрограмма вычисляет хэш-функцию на основе деления от строки текста

int hash(unsigned char *str, int K)

{unsigned short int p=0;

 if(str[0]=='\0')return 0;

 if(str[1]=='\0')return str[0]%K;

 p=str[0];

 while(str[1]!='\0')

 {

  p=(p<<8)|str[1];

  p=p%K;

  str++;

 }

 return p;

}

Хэш-функции на основе умножения

Алгоритм вычисления хэш-функции, основывающийся на умножении, задается следующей формулой

h(A) = [M({AK/ R})]

здесь фигурные скобки являются оператором взятия дробной части, квадратные скобки являются оператором взятия целой части,  R – размер машинного слова, в котором размещается A (например, если A размещается в целой 32-битной переменной, то R=232), K – некоторое число, взаимно простое с R. В качестве M часто выгодно брать M=2m.

Алгоритм является обобщением алгоритма, основанного на делении. Действительно, пусть K есть некоторое приближение к R/S, M=S, то

h(A) = [M({AK/ R})] = [S({A / S})] » A%S

Практически, алгоритм сводится к следующему: ставится десятичная точка перед числом A, полученное число умножается на K, из результата берутся первые m бит, расположенных после десятичной точки (здесь под десятичной точкой имеется в виду разделитель целой и дробной части в позиционной системе отсчета).

В случае, когда A представляет собой строку текста, состоящую из n байт, то, опять же, A рассматривается как число в позиционной системе исчисления. В этом случае R=256 n.

Умножение можно производить `столбиком’, тогда для m£16 подпрограмма вычисления хэш-функции имеет следующий вид

int hashm(unsigned char *str, int K, int m)

{union ICHAR {unsigned int i; unsigned char c[4];}s,srez,sm;

 int rez,l,i; l=strlen((char*)str); srez.i=s.i=sm.i=0;

 for(i=l-1;i>=0;i--)

 {

  srez.c[0]=s.c[0];    //кладем младший байт из пред.знач.

   s.i=0; s.c[0]=str[i];

   s.i*=K;                  //умножаем K на очередную цифру

   s.i+=sm.i;             //добавляем запомненное

   sm.c[0]=s.c[1];sm.c[1]=s.c[2];sm.c[2]=s.c[3];sm.c[3]=0;

  srez.c[1]=s.c[0];

 }

 rez=srez.i>>(16-m); //извлекаем m бит после точки

 return rez;

}

 

Здесь мы ввели union ICHAR для того, чтобы иметь возможность обращаться к отдельным байтам целой переменной. В цикле мы умножаем K на каждый байт строки и добавляем к результату то, что осталось от переполнения в предыдущем умножении. При этом под переполнение отводится 3 старших байта переменной s, а под основную цифру – один младший байт. Результат переполнения хранится в переменной sm. Т.к. конечный результат может занимать до двух байт, приходится вводить дополнительную переменную srez, для хранения последних байт произведения str*K.

CRC-алгоритмы обнаружения ошибок

 

Вообще говоря, использование в структурах данных – не единственное применение хэш-функций. Следует упомянуть еще хотя бы о двух областях, в которых появляются аналоги хэш-функций: составление контрольных сумм и криптография. Часто эти области имеют большое пересечение.

Контрольные суммы применяются в случаях, когда требуется уверенность в неизмененности передаваемых данных. Они активно используются при передаче данных по сети, при хранении данных на различных носителях и т.д.

Криптографические хэш-функции представляют собой такие функции, которые, вообще говоря, легко вычислить, но вычисление обратной функции к которым практически невозможно. Например, при хранении паролей в ЭВМ принято хранить не сами пароли, а значение некоторой криптографической функции от них. Процедура проверки правильности вводимого пароля сводится к вычислению от него той же самой функции и сравнению полученного значения с сохраненным.

Здесь мы немного расскажем об одном из наиболее часто используемых способов создания контрольных сумм – CRC. Данный класс алгоритмов достаточно хорошо выявляет ошибки в поток входных данных. Однако, алгоритм не лишен недостатков. Так, например, существуют алгоритмы, позволяющие добавлять к данным дополнительные байты таким образом, чтобы значение CRC не изменялось бы. Это ограничивает, например, возможности использования данных алгоритмов при подсчете контрольных сумм выполняемых файлов (действительно, из сказанного следует, что злоумышленник может изменить содержимое выполняемого файла, а затем, добавив в него необходимые байты, подогнать значение контрольной суммы к исходной).

Алгоритмы CRC основаны на понятии полиномиальная арифметика. В ней коэффициенты в позиционном представлении числа a={a0,a1,…,aN} рассматриваются, как коэффициенты многочлена Pa= a0 +a1 x +… +aN xN. Арифметические действия, при этом, переопределяются как действия над многочленами (более подробно – см. прилагающуюся статью). Нам интересен случай, когда рассматривается двоичное представление числа, а сами коэффициента многочлена рассматриваются как элементы кольца вычетов по модулю 2. Для данного модуля кольцо является еще и полем, т.е. в нем корректно определены операции сложения, вычитания, умножения и деления.

Можно забыть о многочленах и тогда говорить о соответствующих операциях с точки зрения правил выполнения операций над самими числами. Тогда мы получим арифметику, аналогичную обычной, но без операций выполнения переносов:

1+1=0

1-1=0

101+011=110

 

Деление целых чисел в этих терминах производится как обычно – столбиком. Далее под делением мы будем иметь в виду деление именно в указанном смысле. Отметим, что операция сложения и вычитания здесь не отличаются.

Исходные данные представляются как одно большое двоичное число X. Вычисление контрольных сумм сводится к вычислению остатка от деления числа X на некоторое заранее заданное число m. Приведем примеры стандартных значений m для различных алгоритмов ( в скобках номера единичных битов ):

16 бит:  (16,12,5,0)                                [стандарт X25]
 
         (16,15,2,0)                                ["CRC-16"]
 
32 бит:  (32,26,23,22,16,12,11,10,8,7,5,4,2,1,0)    [Ethernet]
 

Отметим, что для получения n-битного остатка от деления требуется n+1 -битный делитель.

Деление столбиком сводится к следующему. Заводится переменная x длиной n+1 бит, которую мы будем называть аккумулятором. В нее записываются первые n+1 бит данных. Далее циклически выполняется следующий шаг

Если старший бит x равен 1, то x=x^m. Далее x сдвигается влево на 1 бит и в младший бит записывается следующий бит данных.

В конце в переменной x будет лежать остаток от деления на m.

При использовании n+1 -битного делителя m, реальные данные дополняются с конца  n нулями, от полученных данных считается остаток от деления на m. Полученное значение записывается на место n последних нулей данных. Последнее эквивалентно вычитанию (=сложению) из данных  m, поэтому остаток от деления полученного большого числа на m станет равным 0. Именно это свойство и можно использовать при проверке сохранности данных.

Обычно, алгоритм немного модифицируется. Проблема заключается в том, что остаток от деления не зависит  от добавления некоторого количества нулей в начало данных. Для избежания этой проблемы в начало данных можно записывать некоторые стандартные биты. Данную операцию можно осуществить иначе – в аккумулятор, можно изначально не просто помещать первые n+1 бит данных, а выполнять еще x=x^x0, где x0 – некоторое начальное значение. Используется, также, конечное значение x1, для которого выполняется аналогичная операция с конечным результатом.

Стандартный алгоритм CRC16 не использует начальные и конечные значения. Модификация CRC16/CITT использует стартовое значение FFFFh. Алгоритм CRC32 использует FFFFFFFFh в качестве начального и конечного значений.

 

 

 


 

 

 

 


Лекция 14

 

Поиск строк

Пусть имеется последовательность символов S={ si } из алфавита S: si ÎS, i=1,…,N и последовательность W={ wi } из алфавита S: wi ÎS, i=1,…,M, M£N.

Ставится задача поиска всех таких целых 0£k£N-M, что для всех i=1,…,M: sk+i=wi .

Стандартной интерпретацией данной задачи является задача поиска заданного слова в строке или задача поиска слова в файле.

У данной задачи существует прямое решение, при котором происходит последовательная проверка совпадения подстроки W со всеми подряд идущими подстроками строки S длины M. Легко увидеть, что данный алгоритм требует времени порядка Q(MN) (реализация данного алгоритма приведена в следующем параграфе). На самом деле задачу можно решить существенно быстрее, о чем и пойдет речь далее.

Отступление на тему языка С. Ввод-вывод строк из файла

Стандартной интерпретацией поставленной задачи является задача поиска заданного слова в текстовом файле. В ОС UNIX имеется стандартная программа grep поиска слов по шаблону в текстовых файлах. Ее простейший формат вызова следующий:

grep шаблон список_файлов_поиска

здесь вместо слова шаблон можно подставить просто слово, которое требуется найти в тексте файлов из списка список_файлов_поиска. Имена файлов в списке разделяются пробелом. Если список имен файлов пуст, то слово ищется в стандартном потоке ввода.

Следующая программа демонстрирует, как можно простейшим способом реализовать функцию grep для случая, когда строки в файлах имеют длину не более 512 символов и когда вместо шаблона поиска вводится простое слово.

 


#include <stdio.h>

#include <string.h>

int main(int npar,char **par)

{FILE *f; int i,istr; char str[512]; if(npar<=1)return -1;

 for(i=(npar==2?1:2);i<npar;i++)

 {

  f=(npar==2?stdin:fopen(par[i],"r"));

  if(f)

  {

   for(istr=1;fgets(str,512,f);istr++)

    if(strstr(str,par[1]))

    {printf("%s: %d: %s",par[i],istr,str);}

   fclose(f);

  }

 }

 return 0;

}

 


Программа демонстрирует следующие возможности:

·         Передачу параметров из командной строки

·         Открытий/закрытие файлов

·         Ввод текста из файла

·         Использование стандартного потока ввода

·         Стандартную процедуру поиска слова в тексте

 

Детальное описание всех указанных возможностей следует искать в документации к языку С.

Алгоритм поиска подстроки с использованием хеш-функции (Алгоритм Рабина-Карпа)

Идея алгоритма проста: для каждой подстроки Si строки S, используемой при сравнении c W (т.е. подстроки длины, равной длине W), вычисляется значение некоторой хэш-функции h(Si). Если h(Si)= h(S), то данная подстрока является хорошим претендентом на равенство и для нее производится полное сравнение, иначе переходим к следующей подстроке Si+1. При вычислении h(Si) мы можем использовать тот факт, что строка Si отличается всего на два символа от строки Si-1, поэтому есть шанс использовать уже вычисленное значение h(Si-1) для вычисления h(Si). Действительно, это можно сделать, если в качестве хэш-функции использовать остаток от деления на некоторое число K. При этом строка должна интерпретироваться как одно большое целое число. Действительно

Si=(si+0,…, si+M-1) =( si-1, si+0,…, si+M-1)-256M si-1=

=256( si-1, si+0,… , si+M-2) + si+M-1 - 256M si-1

из чего вытекает

Si%K = ( 256 (( si-1, si+0,… , si+M-2) %K)  + si+M-1  – (256M%K)  si-1 ) %K

h(Si) = ( 256 h(Si-1) + si+M-1  – (256M%K)  si-1 ) %K

Единственное большое число, возникающее в последней формуле, это 256M, поэтому (256M%K) следует вычислить заранее. Наконец, если K выбрать таким образом, чтобы 256K<231-257, то  (256M%K)  si-1<231-257 ,  256 h(Si-1) <231-257  и тогда все вычисления могут производиться в рамках обычных целых чисел. Действительно

|256 h(Si-1) + si+M-1  – (256M%K)  si-1 |£

£ MAX(|256 h(Si-1) |,|(256M%K)  si-1|)+ si+M-1  £

£ 231-1

что помещается в переменную int.

Осталось заметить, что K должно быть простым числом. В качестве K можно взять K =8388593.  Действительно

256K   =2147479808

231-257            =2147483391

 

При идеальном распределении значений хэш-функции каждое ее значение будет появляться с вероятностью 1/K, поэтому время работы алгоритма для неудачного поиска будет складываться из времени предварительных вычислений Q(M), времени поиска при отсутствии коллизий Q(N) и времени поиска при наличии коллизий Q(MN/K). Полное время поиска при наличии в строке S n  вхождений строки W будет следующим

T=Q(M+N+MN/K)+ Q(Mn)

 

 

Итак, мы доказали следующую теорему

Теорема. При идеальном распределении значений хэш-функции в среднем алгоритм Рабина-Карпа требует времени

T=Q(M+N+MN/K)+ Q(Mn)

где M – длина искомой подстроки, N – длина строки входных данных, n – количество вхождений искомой строки в строку входных данных, K – модуль, используемый при вычислении остатка от деления в хэш-функции.

В худшем случае алгоритм работает за время

T=Q(MN).

 

Конечные автоматы

Начнем с примера тривиального конечного автомата.

Пусть у нас имеется некоторая кучка камней. В каждый момент состояние кучки q отражается числом, равным количеству камней в кучке. В начальный момент в кучке находилось q0 камней. Последовательно подаются запросы на добавление или удаление камня из кучки. Нас интересует момент, когда в кучке камней не останется или, что, то же самое, когда кучка придет к состоянию q=0.

Запросы на добавление/удаление камней можно проинтерпретировать как последовательность элементов ai из алфавита S, состоящего всего из двух чисел: 1 и  –1.

При появлении элемента ai состояние кучки изменяется, причем новое состояние можно вычислить как функцию от исходного состояния qi-1 и пришедшего элемента ai : qi-1 = d( qi , ai)=qi + ai. Функцию d нам будет удобнее задавать таблицей

 

состояние\входные символы

-1

1

   0

0

1

   1

0

2

   2

1

3

   3

2

3

Заметим, что данная табличная функция описывает кучку, состоящую не более чем из трех камней. Попытки занесения лишних камней, также как и попытка изъятия камня из пустующей кучки, игнорируются.

 

Осталось добавить, что выделенное состояние q=0, с точки зрения конечных автоматов, называется принимающим. Пример можно считать завершенным.

Сведя вместе все выделенные понятия из данного примера, можно дать строгое определение конечного автомата.

Конечным автоматом называется объект, состоящий из пяти множеств:

Q – конечное множество состояний;

AÌ Q  подмножество  принимающих состояний;

q0Î Q – начальное состояние;

Sконечный входной алфавит;

d: Q ´ A ® Q  – функция перехода.

 

Функция перехода, обычно, задается таблицей, поэтому считается, что вычисление одного значения функции требует времени O(1).

Отметим, что алгоритмы, основанные на конечных автоматах, принципиально не вкладывается в схему алгоритмов, основанных на сравнениях, т.к. значение табличной функции не может быть вычислено в рамках алгоритмов, основанных на сравнениях, за время O(1).

Отступление на тему языка С. Работа со строками

В языке С есть очень удобная библиотека для работы со строками. Большинство функций библиотеки, безусловно, следует выучить и активно ими пользоваться. Описания функций содержатся в файле string.h. Подробное описание функций следует прочитать в документации по языку С. Здесь мы кратко приведем описание нескольких функций, которые будем использовать в дальнейшем при объяснении алгоритмов, чтобы не вводить новых понятий.

int strlen(const char *);//длина строки

char *  strcpy(char *, const char *);//копирование второй строки в первую

char *  strcat(char *, const char *);//подклеивание второй строки к первой

char *  strstr(char *, const char *);//поиск второй строки в первой

int     strcmp(const char *, const char *);//лексикографич.сравнение строк

int     strncmp(const char *, const char *,int n);//лекс.сравн. первых n байт строк

 

Алгоритм поиска подстроки, основанный на конечных автоматах

С этого момента мы будем говорить о строках в понимании языка С.

Итак, в строке S, strlen(S)==N, следует найти все вхождения подстроки W, strlen(W)==M, т.е. следует найти все такие 0£ i £N-M, что strncmp(S+i,W,M)==0.

 

Будем говорить, что строка b является префиксом строки a, если

strlen(b)<=strlen(a) && strncmp(b,a,strlen(b))==0.

 

Будем говорить, что строка b является суффиксом строки a, если

strlen(b)<=strlen(a) && strcmp(b,a+strlen(a)-strlen(b))==0.

 

Основная идея алгоритма следующая: будем последовательно добавлять к входной строке S по одному символу из входного потока данных. При этом, каждый раз будем вычислять значение функции h(S,W), равной максимальной длине l суффикса строки S, совпадающего с префиксом строки W длины l:

strncmp(S+strlen(S)-l,W,l)==0

Например, для S=(ababa), W=(abac): h(S,W)=3.

Если, при этом, выполнится условие

h(S,W)==strlen(W)

то это будет обозначать, что найдено вхождение W  в строку S.

Допустим, что в некоторый момент мы знаем значение функции h(S,W). Пусть строка S2 получена с помощью добавления очередного символа a из входного потока данных в конец строки S.

Легко увидеть, что h(S2,W)<= h(S,W)+1 (иначе, мы сразу получим, что строка S имеет суффикс длины большей h(S,W), совпадающий с префиксом W), но зная значение h(S,W) мы сразу получаем значения h(S,W) последних символов S (это – первые h(S,W) символов строки W). Т.о. значение функции h(S2,W) может быть вычислено исходя из знания значения h(S,W) и a.

Итак, мы строим конечный автомат, в котором состояние системы задается величиной H= h(S,W). В качестве входного алфавита будут выступать символы, текста. Принимающим будет такое состояние H, когда H==strlen(W). Начальное состояние H0=0. О вычислении функции перехода поговорим позднее.

Итак, легко увидеть, что, если не задумываться о вычислении функции перехода, то основная часть алгоритма поиска выполняется за время T=Q(N), где N – длина входной последовательности текста.

Функцию перехода предлагается вычислять в лоб. Т.е. для случая, когда ищется строка W и когда алфавит состоит из 256 символов, строится таблица tab из 256 столбцов и strlen(W) строк. j-ый столбец будет соответствовать появлению символа с кодом  j, а  i-ая строка будет соответствовать состоянию автомата  i. Для получения значения tab[i][j] следует рассмотреть строку, состоящую из i первых символов строка W с добавленным в конец символом с кодом j. Длина максимального суффикса полученной строки, совпадающего с префиксом W, будет искомым значением tab[i][j].

Для получения значения tab[i][j] нужно не более i раз сравнить подстроку W  с подстрокой полученной строки. Итого, tab[i][j] вычисляется за время O(M2). Все значения tab[i][j] вычисляются за время O(256M3), где 256 – количество символов входного алфавита, M – длина искомого слова. Легко увидеть, что для данного алгоритма данная оценка точна. Т.о. мы доказали следующую теорему

 

Теорема. Поиск подстроки длины M, состоящей из символов алфавита из K символов, в тексте длины N с помощью предложенного алгоритма, использующего конечные автоматы, требует основного времени T1=Q(N). На подготовку, зависящую только от искомой подстроки и размера входного алфавита, требуется время T0=Q( K M3).

 

 


Лекция 15

 

Алгоритм поиска подстроки Кнута-Морриса-Пратта (на основе префикс-функции)

 

Основная проблема алгоритма поиска подстроки, основанного на конечных автоматах – необходимость вычисления функции перехода. Алгоритм Кнута-Морриса-Пратта обходит эту проблему за счет некоторого удорожания, собственно, процесса поиска и существенного сокращения предварительных вычислений.

Основная идея алгоритма следующая. Пусть Sk – подстрока строки S длины k. Пусть нам известно значение функции перехода h(Sk,W)  (см. предыдущий параграф). Требуется вычислить значение функции h(Sk+1,W), т.е. найти максимальный префикс W, являющийся суффиксом Sk+1.

Если S[k]==W[h(Sk,W)], то h(Sk+1,W)= h(Sk,W)+1 (как уже отмечалось ранее – больше быть не может, а то, что в этой ситуации h(Sk+1,W)³ h(Sk,W)+1 – получается по определению). Пример:

 

char S[]=”ababab”,W[]=”abaa”; int k=4;

 

h(S,4,W)==2

S          : abab ab

W         : __ab

 

h(S,5,W)==3

S          : ababa b

W         : __aba

 

 

Пусть S[k]!=W[h(Sk,W)], то h(Sk+1,W)< h(Sk,W)+1. В приведенном примере:

 

char S[]=”ababab”,W[]=”abaa”; int k=5;

 

h(S,5,W)==3

S          : ababab

W         : __aba

 

h(S,6,W)==2

S          : ababab

W         : ____ab

 

Для вычисления h(Sk+1,W) при отсутствии функции перехода можно не перебирать все префиксы W. Действительно, h(Sk+1,W) == длине l максимального префикса W, для которого S[k]==W[l], плюс 1. Тогда, для вычисления h(Sk+1,W) следует перебрать все префиксы W, являющиеся суффиксами Sk, в порядке убывания их длины и найти первый из них, для которого S[k]==W[l], где l – длина префикса. Тогда h(Sk+1,W) ==l+1.

Итак, если бы мы могли быстро вычислять длины всех префиксов W, являющиеся суффиксами Sk, в порядке их убывания, то задача поиска подстроки выполнялась бы за время T1=Q(N). Действительно, исходя из рассуждений, приведенных в предыдущих абзацах, T1 пропорционально количеству изменений переменной l  в процессе работы алгоритма. Но переменная l  может увеличиваться на 1 не более N раз, поэтому и уменьшаться она может не более N раз. Что и требовалось доказать.

 

Осталось понять, как вычислять длины префиксов W, являющихся суффиксами Sk.

 

Легко заметить, что если мы знаем, что имеется префикс W, являющийся суффиксом Sk, длины l, то для вычисления максимального префикса W меньшей длины, являющегося суффиксом Sk, не надо ничего знать о S. Достаточно информации только о строке W. Действительно, т.к. Wl - суффикс Sk, то следует найти максимальный префикс W, длины меньше l, являющийся суффиксом Wl.

Введем функцию p: {1,…,N}®{1,…,N-1}, такую что p(l)=длина максимального префикса Wl , являющегося суффиксом Wl , длиной  меньше l.

Теперь заметим, что Wp(l) является, одновременно, суффиксом Wl , поэтому следующий по длине (в порядке убывания) суффикс Wl , являющийся префиксом Wl , является суффиксом Wp(l). Осталось найти длину максимального суффикса  Wp(l) , с длиной меньше p(l), являющегося префиксом Wp(l). Данная величина, по определению, равна p(p(l))=по определению=p2(l).

Т.о., по индукции, получаем, что последовательность длин суффиксов Wl , совпадающих с префиксами Wl  и расположенных по убыванию длин, совпадает с последовательностью {l,p(l),p(p(l))…}={ p0(l), p1(l), p2(l), …}. Т.о., если бы мы имели таблицу значений функции p(*), то задача вычисления длин префиксов W, являющихся суффиксами Sk, оказалась бы решенной, что, в свою очередь, решило бы задачу поиска подстроки в строке.

 

Займемся вычислением табличной функции p(*).

 

Префикс-функция p(*) вычисляется в точности по уже приведенному алгоритму.

Пусть требуется вычислить p[k+1], если p[i] для i£k уже известны.

Если W [k]==W[p[k]], то p[k+1]= p[k]+1 .

Если W [k]!=W[p[k]], то, как и ранее, перебираем в порядке уменьшения длин l все префиксы W , совпадающие с суффиксами Wk , пока не выполнится

W[k]==W[l]

Каждое последующее l получается из предыдущего по формуле

l=p(l);

Положим в начале цикла l= p[k], то случай W [k]==W[p[k]]  подпадет под вычисления внутри последнего цикла и его отдельное рассмотрение будет излишним.

Внутренний цикл следует продолжать пока k³0. Если окажется, что k<0, то p[k+1]=0. Иначе, в конце внутреннего цикла имеем:  p[k+1]= l+1 .

Отметим, что мы можем положить

p[0]=-1;

после чего случай k<0 перестанет быть выделенным (в этом случае l=-1;p[k+1]=l+1; из чего сразу получаем p[k+1]=0).

Итак, на языке С подготовка функции (массива) p может выглядеть следующим образом

void MakeP(int *p, char *W, int M)

{int k,l; p[0]=-1; p[1]=0; l=0;

 for(k=1;k<M;k++)

 {

  l=p[k];

  while(l>=0 && W[k]!=W[l])l=p[l];

  p[k+1]=l+1;

 }

}

 

Основная функция, ищущая первое вхождение строки W в строку S, может выглядеть следующим образом

char *Search(char *S,int N, char *W, int M, int *p)

{int l=0,k;

 for(k=0;k<N;k++)

 {

  while(l>=0 && S[k]!=W[l])l=p[l];

  l++;

  if(l==M)return S+k-l+1;

 }

 return NULL;

}

 

Пример программы на языке С, использующей данные функции прилагается.  Программа написана по аналогии с функцией grep, которые мы обсудили в начале лекции.

 

Алгоритм поиска подстроки Бойера-Мура (на основе стоп-символов/безопасных суффиксов)

Алгоритм напоминает элементарный алгоритм поиска подстроки в строке. Основное отличие – сравнение искомой строки W с соответствующей частью строки S осуществляется не слева направо, а справа налево. По результатам сравнения делается вывод – на сколько можно сместиться вправо для сравнения W со следующей подстрокой S (в элементарном алгоритме поиска сдвиг всегда происходит на одну позицию). При этом, есть два независимых алгоритма, позволяющих вычислять, на сколько можно смещаться вправо для сравнения со следующей подстрокой S. Выбирается максимальный из сдвигов, получаемых по этим алгоритмам. Рассмотрим эти два алгоритма.

Эвристика стоп-символа

Рассмотрим несколько примеров.

Пример 1. S=”ababababa”, W=”cccc”.

Сначала сравниваем суффикс S4 и W (S4  - подстрока S,состоящая из ее первых четырех символов). Как уже отмечалось, сначала сравниваем S[3] и W[3]. Они не равны, следовательно, S4 и W не равны. Более того, т.к. S[3] вообще не встречается в W, то далее можно сравнивать с W  уже S4+strlen(W)=S8, т.к. суффиксы S4+1,…, S4+strlen(W)-1 заведомо не совпадают с W.

Пример 2. S=”ababacaba”, W=”abac”.

Сначала сравниваем суффикс S4 и W. Как уже отмечалось, сначала сравниваем S[3] и W[3]. Они не равны, следовательно, S4 и W не равны. S[3] встречается первый раз в W (при просмотре с конца) в позиции 1, то далее можно сравнивать с W уже S4+strlen(W)-1-1=S6, т.к. при таком сдвиге впервые S[3] совпадет с соответствующим символом W.

 

Вообще говоря, пусть сравнивается суффикс Sk и W. Пусть W[l] – первый справа символ W, не совпавший с соответствующим символом строки S, т.е.

S[k-strlen(W)+l]!=W[l], S[k-strlen(W)+i]==W[i] (strlen(W)>i>l).

 

Если l==0, то мы нашли вхождение W в S. Переходим к анализу Sk+1.

Рассмотрим случай l>0. Обозначим s= S[k-strlen(W)+l] .

Пусть m(s) – функция, выдающая самое правое вхождение символа s в строку W. В случае, если символ s в строке W не найден, пусть m(s)=-1. Тогда, следующим претендентом на сравнение будет Sk+MAX(1,strlen(W)-1-m(s)).

Здесь и далее MAX и MIN в языке С можно определить следующим образом

#define MAX(a,b) ((a)>(b)?(a):(b))

#define MIN(a,b) ((a)<(b)?(a):(b))

 

Функция m является табличной, поэтому ее можно заменить соответствующим массивом. Например, если мы производим поиск строк в языке С, то m можно определить следующим образом

unsigned char m[256];

 

Все значения массива изначально инициализируются значением -1. Далее для всех символов W[i]  строки W от первого до последнего следует положить

m[W[i]]=MAX(m[W[i]],i);

 

На самом деле, с точки зрения языка С, последнее утверждение не верно!!! Это связано с тем, что переменная m[k] , вообще говоря, знаковая. Следующая попытка исправить ситуацию тоже не верна

  m[(unsigned)W[i]]=MAX(m[(unsigned)W[i]],i);

Связано это с тем, что преобразование

signed char  -> unsigned int

происходит, на самом деле, более сложно:

signed char  -> signed int -> unsigned int

в результате получаем, что отрицательное 8-битное число преобразуется, сначала, в отрицательное 32-битное число, а только потом произойдет преобразование к беззнаковому числу. Итого

’а’=-32  ->  4294967264

 

Правильное преобразование показано в следующей функции, вычисляющей массив m

void MakeM(char W[], int l, int m[256])

{int i;

 for(i=0;i<256;i++)m[i]=-1;

 for(i=0;i<l;i++)m[(unsigned char)W[i]]=MAX(m[(unsigned char)W[i]],i);

}

 

Осталось написать подпрограмму, осуществляющую поиск первого вхождения строки W длины lW в строку S длины lS

char *Search(char S[], int lS, char W[], int lW, int m[256])

{int l,k; if(lS<lW)return NULL;

 for(k=lW;k<=lS;k=k+MAX(1,strlen(W)-1-m[(unsigned char)W[l]]))

 {

  for(l=lW-1;l>=0;l--)if(W[l]!=S[k-lW+l])break;

  if(l<0)return S+k-lW;

 }

 return NULL;

}

 

Эвристика безопасного суффикса

Рассмотрим несколько примеров.

Пример 1. S=”abababbaab”, W=”abbaab”.

Сначала сравниваем суффикс S6 и W. Как уже отмечалось, сравнение производим справа налево. Выясняется, что максимальный совпадающий суффикс S6 и W  abсостоит из двух символов. Ближайшее справа вхождение подстроки ab в строку W начинается с позиции 0, поэтому далее можно сравнивать с W уже S6+strlen(W)-strlen(”ab”)+0=S10, т.к. при таком сдвиге впервые та же самая подстрока abстроки S совпадет с соответствующей подстрокой W.

Иными словами, в этом примере мы искали максимальное i<6, такое что ab являлась суффиксом Wi. Следующий претендент на сравнение вычислялся по формуле S6+strlen(W)-i.

 

Пример 2. S=”abababbaab”, W=”bbbaab”.

Сначала, как и в предыдущем примере, сравниваем суффикс S6 и W. Как уже отмечалось, сравнение производим справа налево. Выясняется, что максимальный совпадающий суффикс S6 и W  abсостоит из двух символов. Подстрока abбольше в строку W не входит. Однако максимальное начало строки W, совпадающее с соответствующим суффиксом ab, имеет длину 1, поэтому далее можно сравнивать с W уже S6+strlen(W)-strlen(”ab”)+1=S11. Действительно, при таком сдвиге впервые часть той же самой подстрока abстроки S (имеется в виду подстрока b) совпадет с соответствующей подстрокой W.

Иными словами, в этом примере мы искали максимальное i£2, такое что Wi являлась бы суффиксом  ab. Следующий претендент на сравнение вычислялся по формуле S6+strlen(W)-i (сравнить с предыдущим примером).

 

 

Введем обозначение. Будем говорить, что строки A и B сравнимы: A ~ B, если A является суффиксом B или B является суффиксом A.

Обобщая приведенные примеры, мы можем сказать, что мы искали максимальное i<strlen(W), такое что Wi ~ ab.

Введем функцию g, такую что g(l) равна максимальному i<strlen(W), такому, что Wi сравнима с суффиксом W длины l. Если такого не нашлось, то g(l)=0.

Пусть сравнивается суффикс Sk и W. Пусть  C - максимальный по длине общий суффикс Sk и W. Следующим претендентом на сравнение будет

Sk+ strlen(W) - g(strlen(C)) .

 

Осталось выяснить – каким образом задать функцию g(l).

По определению g(l)=Max{i<strlen(W): Wi ~ Suff(W,l)}, где Suff(W,l) – суффикс W длины l. То же самое можно переписать иначе:

g(l) =    Max{   Max{i<strlen(W): Wi – суффикс Suff(W,l)},

Max{i<strlen(W): Suff(W,l) – суффикс Wi  } }

 

Выше мы ввели функцию p(i), равную максимальной длине суффикса строки Wi, являющегося префиксом W. По определению имеем, что Wp(strlen(W)) является суффиксом W, поэтому Wp(strlen(W)) ~ Suff(W,l). Из последнего вытекает, что

g(l)³ p(strlen(W))

Т.о. получаем

g(l) =    Max{   Max{i<strlen(W): Wi – суффикс Suff(W,l)},

Max{i<strlen(W): Suff(W,l) – суффикс Wi  } }

 

Более того, Max{i<strlen(W): Wi – суффикс Suff(W,l)} не может превзойти Wp(strlen(W)), т.к. если бы это произошло, то мы получили бы суффикс Suff(W,l) (а следовательно и суффикс W), являющийся префиксом W, длиной больше максимально возможной длины суффикса W, являющегося префиксом W. Т.о. получаем

g(l) = Max{ p(strlen(W)), Max{ p(strlen(W))£i<strlen(W): Suff(W,l) – суффикс Wi  } }

Легко увидеть, что поиск

w(l)=Max{ p(strlen(W))£i<strlen(W): Suff(W,l) – суффикс Wi  }

сводится к поиску самого правого участка строки W, совпадающего с Suff(W,l) (естественно, рассматриваются участки левее самого Suff(W,l)).

 

Пример:

l=2; W=”abacabacab”;//выделен Suff(W,l) и его правое вхождение в W

 

Отметим, что такого участка может не существовать. Если рассмотреть строку W, представляющую собой перевернутую строку W, то задачу можно свести к поиску самого левого вхождения строки Wl  в строку W (правее начальной позиции):

w(l)=Max{ p(strlen(W))£i<strlen(W): Suff(W,l) – суффикс Wi  }=

=strlen(W) - Min{ i>l: W’lсуффикс Wi  }+l

 

Пример:

l=2; W’=”bacabacaba”;//выделен Wl  и его левое вхождение в W

 

Рассмотрим начало строки W, завершающееся найденным левым вхождением Wl  в строку W (в примере это – bacaba). Более формально: рассмотрим WI, где I=argMin{ i>l: Wl – суффикс Wi  }.

Легко увидеть: l=p’(I), где p– префикс-функция W. Действительно, если бы нашелся больший суффикс WI , являющийся одновременно префиксом W , то, соответственно, нашлось бы и более левое вхождение подстроки Wl  в строку W (т.к. начало более длинного суффикса должно совпадать с Wl).

С другой стороны равенство l==p’(I) влечет за собой тот факт, что Wl является суффиксом WI.

Т.о. имеем

Min{ i>l: Wl – суффикс Wi  }= Min{ i>l: l==p’(i)}

Тогда получаем

w(l)= strlen(W) +l - Min{ i>l: l==p’(i)}

 

 

Последнее равенство дает алгоритм вычисления w(t): следует перебрать все значения i в порядке убывания и для каждого из них выполнить присвоение

w[p’(i)]= strlen(W) + p’(i) – i  если  i>p’(i)

В конце концов, получаем

g[l] = Max{ p(strlen(W)), w[l]}

В двух последних формулах мы реализовалиw и g как массивы.

 

В прилагаемой программе реализованы функции создания массивов m, p и g. Реализованы функции поиска, использующие только эвристику стоп-символа, только эвристику безопасного суффикса и, наконец, функция поиска по обоим эвристикам.

 

 


Форматы BMP и RLE

Формат BMP исторически является основным форматом представления изображений в ОС Microsoft Windows*. Постараемся дать, по возможности, полное описание формата.

Обычно формат не использует алгоритмов сжатия. Поэтому не представляет особого труда написать собственную программу, позволяющую считывать изображения с диска (т.е. конвертировать их из BMP формата в формат, удобный для непосредственного использования) и записывать их на диск.

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

unsigned char pal[256][4];

Каждая строка в массиве палитры задает один цвет с помощью указания его RGB составляющих, соответственно, в ячейках с номерами 0, 1, 2. Количество строк зависит от количества используемых цветов и, обычно, не превосходит 256 (что соответствует изображению, в котором на один пиксел отводится 8 бит).

Данные можно представлять в формате true color, когда каждый пиксел задается 4-мя байтами. Из них используется 3 байта для размещения RGB компонент цвета (по одному байту на каждую компоненту).

Возможно большое количество нестандартных вариаций данного формата. Например, отсутствие палитры в изображениях с толщиной 8 бит на пиксел может обозначать, что кодируется серое изображение, в каждом байте которого хранится яркость пиксела. Некоторые программы понимают форматы BMP в которых отводится 3 байта на пиксел (формат true color) или даже 2 байта на пиксел (в пикселе хранится только яркость, т.е. кодируется серое изображение).

Файл состоит из следующих разделов:

·         Заголовка

·         Возможно – палитры

·         Собственно данных

 

Заголовок файла представляется следующей структурой

struct BMPHEAD

  {                                                              

    unsigned short int Signature ;         // Must be 0x4d42 == ”BM”              //0

    unsigned long FileLength ;             // в байтах                                          //2

    unsigned long Zero ;                       // Must be 0                                          //6

    unsigned long Ptr ;                          // смещение к области данных         //10

    unsigned long Version ;// длина оставшейся части заголовка=0x28    //14

    unsigned long Width ;         // ширина изображения в пикселах             //18

    unsigned long Height ;        // высота изображения в пикселах                         //22

    unsigned short int   Planes ;            // к-во битовых плоскостей             //26

    unsigned short int   BitsPerPixel ;  // к-во бит на папиксел         //28

    unsigned long Compression ;          // сжатие: 0 или 1 или 2                    //30

    unsigned long SizeImage ;              // размер блока данных в байтах     //34

    unsigned long XPelsPerMeter ;      // в ширину: пикселов на метр         //38

    unsigned long YPelsPerMeter ;       // в высчоту: пикселов на метр       //42

    unsigned long ClrUsed ;                  // к-во цветов в палитре                  //46

    unsigned long ClrImportant ; // к-во используемых цветов в палитре //50

  } ;

 

Предполагается, что sizeof(unsigned long)==4, sizeof(unsigned short int)==2.

Соответствующие поля в файле с данным форматом непрерывно располагаются в указанном порядке.

Палитра (если она есть) следует сразу за заголовком. Данные начинаются с байта номер Ptr, начиная от начала файла.

 

BMP без сжатия.

 

Поле Compression определяет способ сжатия данных. Обычно значение этого поля=0, что соответствует отсутствию сжатия. При этом данные записываются по битам подряд. BMP формат со сжатием часто называется RLE форматом.

Длина каждой строки округляется в большую сторону до кратности 32 битам (4 байта). Т.о., например, при отсутствии сжатия если Width=3, то каждая строка на диске будет занимать

(Width* BitsPerPixel + 31)/8=4  байт.

Предполагается, что байты располагаются в порядке их нумерации, старший бит слева. Т.о., если BitsPerPixel =1, то самый  первый пиксел ляжет в старший бит самого первого байта данных.

 

Для хранения всего изображения структуру struct BMPHEAD следует дополнить массивом палитры и массивом самих данных. Если предположить, что мы будем иметь дело с изображениями не более 8 бит на пиксел, то для данных можно завести, например, массив unsigned char **v . Пиксел с координатами (i,j) можно хранить в переменной v[i][j].

Итак, все изображение можно хранить в структуре

struct CBMP

  {                                                             

    unsigned short int Signature ;         // Must be 0x4d42 == ”BM”              //0

    unsigned long FileLength ;             // в байтах                                          //2

    unsigned long Zero ;                       // Must be 0                                          //6

    unsigned long Ptr ;                          // смещение к области данных         //10

    unsigned long Version ;// длина оставшейся части заголовка=0x28    //14

    unsigned long Width ;         // ширина изображения в пикселах             //18

    unsigned long Height ;        // высота изображения в пикселах                         //22

    unsigned short int   Planes ;            // к-во битовых плоскостей             //26

    unsigned short int   BitsPerPixel ;  // к-во бит на папиксел         //28

    unsigned long Compression ;          // сжатие: 0 или 1 или 2                    //30

    unsigned long SizeImage ;              // размер блока данных в байтах     //34

    unsigned long XPelsPerMeter ;      // в ширину: пикселов на метр         //38

    unsigned long YPelsPerMeter ;       // в высчоту: пикселов на метр       //42

    unsigned long ClrUsed ;                  // к-во цветов в палитре                  //46

    unsigned long ClrImportant ; // к-во используемых цветов в палитре //50

    unsigned char pal[256][4];

    unsigned char **v;

  } ;

 

Отвести память можно, например, следующим образом:

struct CBMP pic;  int i;

pic.v=(unsigned char**)malloc(pic.Height*sizeof(char*));

for(i=0;i<pic.Height;i++)pic.v[i]= (unsigned char*)malloc(pic.Width);

 

Однако следующий способ гораздо более эффективен:

struct CBMP pic;  int i;

pic.v=(unsigned char**)malloc(pic.Height*sizeof(char*)+pic.Height*pic.Width);

pic.v[0]= (unsigned char**)(pic.v+pic.Height);

for(i=1;i<pic.Height;i++)pic.v[i]=pic.v[i-1]+pic.Width;

 

Преимуществом такого способа отведения памяти является уменьшение накладных расходов и упрощение процедуры освобождения памяти. Для освобождения отведенной памяти надо вызвать всего один оператор:

free(pic.v);