VB MS Access VC Delphi Builder C прінціпитехнологія алгоритми програмування

[ виправити ] текст може містити помилки, будь ласка перевіряйте перш ніж використовувати.

скачати

<1><2><1><2><1><2>

Введення 8

Цільова аудиторія 9

Глава 1. Основні поняття 14

Що таке алгоритми? 15

Аналіз швидкості виконання алгоритмів 16

Простір - час 16

Оцінка з точністю до порядку 17

Пошук складних частин алгоритму 18

Складність рекурсивних алгоритмів 20

Багаторазова рекурсія 21

Непряма рекурсія 21

Вимоги рекурсивних алгоритмів до обсягу пам'яті 22

Найгірший і усереднений випадок 23

Часто зустрічаються функції оцінки порядку Загалом, 24

Логарифми 24

Реальні умови - наскільки швидко? 25

Звернення до файлу підкачки 26

Псевдоуказателі, посилання на об'єкти і колекції 27

Резюме 29

Глава 2. Списки 30

Знайомство зі списками 30

Прості списки 31

Колекції 31

Список змінного розміру 32

Клас SimpleList 35

Невпорядковані списки 36

Зв'язні списки 40

Додавання елементів до зв'язного списку 43

Видалення елементів з зв'язкового списку 43

Знищення зв'язного списку 44

Сигнальні позначки 45

Інкапсуляція зв'язкових списків 46

Доступ до осередків 47

Різновиди зв'язкових списків 48

Циклічні зв'язні списки 49

Проблема циклічних посилань 50

Двозв'язній списки 50

Потоки 53

Інші зв'язкові структури 56

Псевдоуказателі 56

Резюме 59

Глава 3. Стеки і черги 59

Стеки 60

Множинні стеки 62

Черги 63

Циклічні черзі 65

Черги на основі зв'язних списків 69

Застосування колекцій як черг 69

Пріоритетні черзі 70

Багатопотокові черзі 72

Резюме 74

Глава 4. Масиви 74

Трикутні масиви 75

Діагональні елементи 77

Нерегулярні масиви 78

Пряма зірка 78

Нерегулярні зв'язні списки 79

Розріджені масиви 80

Індексування масиву 82

Дуже розріджені масиви 84

Резюме 86

Глава 5. Рекурсія 86

Що таке рекурсія? 86

Рекурсивне обчислення факторіалів 87

Аналіз часу виконання програми 89

Рекурсивне обчислення найбільшого спільного дільника 90

Аналіз часу виконання програми 90

Рекурсивне обчислення чисел Фібоначчі 92

Аналіз часу виконання програми 93

Рекурсивне побудова кривих Гільберта 94

Аналіз часу виконання програми 96

Рекурсивне побудова кривих Серпінського 98

Аналіз часу виконання програми 100

Небезпеки рекурсії 101

Нескінченна рекурсія 101

Втрати пам'яті 102

Необгрунтоване застосування рекурсії 103

Коли потрібно використовувати рекурсію 104

Хвостова рекурсія 105

Нерекурсівние обчислення чисел Фібоначчі 107

Усунення рекурсії в загальному випадку 110

Нерекурсівние побудова кривих Гільберта 114

Нерекурсівние побудова кривих Серпінського 117

Резюме 121

Глава 6. Дерева 122

Визначення 122

Уявлення дерев 123

Повні вузли 123

Списки нащадків 124

Представлення нумерацією зв'язків 126

Повні дерева 129

Обхід дерева 130

Впорядковані дерева 135

Додавання елементів 135

Видалення елементів 136

Обхід впорядкованих дерев 140

Дерева з посиланнями 141

Робота з деревами з посиланнями 144

Квадродерево 145

Зміна MAX_PER_NODE 151

Використання псевдоуказателей в квадродерево 152

Вісімкові дерева 152

Резюме 153

Глава 7. Збалансовані дерева 153

Збалансованість дерева 153

АВЛ дерева 154

Видалення вузла з АВЛ дерева 161

Б дерева 166

Продуктивність Б дерев 167

Вставка елементів в Б дерево 168

Видалення елементів з Б дерева 168

Різновиди Б дерев 170

Поліпшення продуктивності Б дерев 172

Балансування для усунення розбиття блоків 172

Питання, пов'язані з зверненням до диска 173

База даних на основі Б + дерева 176

Резюме 179

Глава 8. Дерева рішень 180

Пошук у деревах ігри 180

Мінімакс пошук 181

Поліпшення пошуку в дереві гри 185

Пошук в інших деревах рішень 187

Метод гілок і меж 187

Евристики 192

Інші складні завдання 208

Завдання про здійснимість 208

Завдання про розбивку 209

Завдання пошуку гамильтонова шляху 210

Завдання комівояжера 211

Задача про пожежних депо 211

Коротка характеристика складних завдань 212

Резюме 213

Глава 9. Сортування 213

Загальні міркування 214

Таблиці покажчиків 214

Об'єднання і стиснення ключів 216

Приклади програм 218

Сортування вибором 219

Рандомізація 221

Сортування вставкою 222

Вставка в зв'язкових списках 223

Бульбашкова сортування 224

Швидке сортування 228

Сортування злиттям 233

Пірамідальна сортування 235

Піраміди 235

Пріоритетні черги 238

Алгоритм пірамідальної сортування 240

Сортування підрахунком 242

Сортування комірками 243

Сортування комірками із застосуванням зв'язного списку 244

Сортування комірками на основі масиву 246

Резюме 248

Глава 10. Пошук 249

Приклади програм 249

Пошук методом повного перебору 250

Пошук в упорядкованих списках 251

Пошук в зв'язкових списках 252

Двійковий пошук 254

Інтерполяційний пошук 255

Строкові дані 260

Стежить пошук 260

Інтерполяційний стежить пошук 262

Резюме 263

Глава 11. Хешування 264

Зв'язування 266

Переваги та недоліки зв'язування 267

Блоки 269

Зберігання хеш таблиць на диску 271

Зв'язування блоків 274

Видалення елементів 276

Переваги та недоліки застосування блоків 277

Відкрита адресація 278

Лінійна перевірка 278

Квадратична перевірка 284

Псевдослучайная перевірка 287

Видалення елементів 289

Резюме 292

Глава 12. Мережеві алгоритми 293

Визначення 293

Уявлення мережі 294

Оперування вузлами і зв'язками 295

Обходи мережі 296

Найменші кістяк 299

Найкоротший маршрут 302

Установка влучний 304

Корекція міток 308

Інші завдання пошуку найкоротшого маршруту 312

Застосування методу пошуку найкоротшого маршруту 316

Максимальний потік 319

Програми максимального потоку 325

Резюме 327

Глава 13. Об'єктно орієнтовані методи 328

Переваги ООП 328

Інкапсуляція 328

Поліморфізм 331

Успадкування та повторне використання 334

Парадигми ООП 335

Керуючі об'єкти 336

Контролюючий об'єкт 337

Ітератор 338

Дружній клас 339

Інтерфейс 340

Фасад 341

Породжує об'єкт 341

Єдиний об'єкт 341

Перетворення в послідовну форму 342

Парадигма Модель / Вид / Контролер. 345

Резюме 346

Вимоги до апаратного забезпечення 347

Виконання програм прикладів 347


programmer@newmail.ru


Далі йде «текст», який будь-який поважаючий себе програміст повинен прочитати хоча б один раз. (Це нашу суб'єктивну думку)


Введення

Програмування під Windows завжди було нелегким завданням. Інтерфейс прикладного програмування (Application Programming Interface) Windows надає в розпорядження програміста набір потужних, але не завжди безпечних інструментів для розробки додатків. Можна порівняти його з бульдозером, за допомогою якого вдається досягти вражаючих результатів, але без відповідних навичок і обережності, швидше за все, справа закінчиться тільки руйнуваннями і збитками.

Ця картина змінилася з появою Visual Basic. Використовуючи візуальний інтерфейс, Visual Basic дозволяє швидко і легко розробляти закінчені додатки. За допомогою Visual Basic можна розробляти і тестувати складні додатки без прямого використання функцій API. Позбавляючи програміста від проблем з API, Visual Basic дозволяє сконцентруватися на деталях програми.

Хоча Visual Basic і полегшує розробку користувальницького інтерфейсу, завдання написання коду для реакції на вхідні дії, обробки їх, та подання результатів лягає на плечі програміста. Тут починається застосування алгоритмів.

Алгоритми представляють собою формальні інструкції для виконання складних завдань на комп'ютері. Наприклад, алгоритм сортування може визначати, як знайти певний запис у базі з 10 мільйонів записів. Залежно від класу використовуваних алгоритмів шукані дані можуть бути знайдені за секунди, годинник або взагалі не знайдені.

У цьому матеріалі обговорюються алгоритми на Visual Basic і міститься велика кількість потужних алгоритмів, повністю написаних на цій мові. У ній також аналізуються методи поводження зі структурами даних, такими, як списки, стеки, черги і дерева, і алгоритми для виконання типових завдань, таких як сортування, пошук та хешування.

Для того щоб успішно застосовувати ці алгоритми, недостатньо їх просто скопіювати в свою програму. Необхідно крім цього розуміти, як різні алгоритми ведуть себе в різних ситуаціях, що в кінцевому результаті і буде визначати вибір найбільш підходящого алгоритму.

У цьому матеріалі поведінка алгоритмів у типовому і найгіршому випадках описано доступною мовою. Це дозволить зрозуміти, чого ви вправі очікувати від того чи іншого алгоритму і розпізнати, в яких умовах зустрічається найгірший випадок, і відповідно до цього переписати або поміняти алгоритм. Навіть найкращий алгоритм не допоможе у вирішенні завдання, якщо застосовувати його неправильно.


============= Xi


Всі алгоритми також представлені у вигляді вихідних текстів на Visual Basic, які ви можете використовувати у своїх програмах без будь-яких змін. Вони демонструють використання алгоритмів у програмах, а також важливі характерні особливості роботи самих алгоритмів.

Що дають вам ці знання

Після ознайомлення з даним матеріалом і прикладами ви отримаєте:

  1. Поняття про алгоритми. Після прочитання даного матеріалу і виконання прикладів програм, ви зможете застосовувати складні алгоритми в своїх проектах на Visual Basic і критично оцінювати інші алгоритми, написані вами або ким-небудь ще.

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

  3. Готові приклади програм дадуть вам можливість протестувати алгоритми. Ви можете використовувати ці приклади і модифікувати їх для поглибленого вивчення алгоритмів і розуміння їхньої роботи, або використовувати їх як основу для розробки власних додатків.

Цільова аудиторія

У цьому матеріалі обговорюються поглиблені питання програмування на Visual Basic. Вони не призначена для навчання програмуванню на цій мові. Якщо ви добре розумієтеся на засадах програмування на Visual Basic, ви зможете сконцентрувати увагу на алгоритмах замість того, щоб застрявати на деталях мови.

У цьому матеріалі викладено важливі концепції програмування, які можуть бути з успіхом застосовані для вирішення нових завдань. Наведені алгоритми використовують потужні програмні методи, такі як рекурсія, розбиття на частини, динамічний розподіл пам'яті і мережеві структури даних, які ви можете застосовувати для вирішення своїх конкретних завдань.

Навіть якщо ви ще не оволоділи повною мірою програмуванням на Visual Basic, ви зможете скомпілювати приклади програм і порівняти продуктивність різних алгоритмів. Більше того, ви зможете вибрати задовольняючі вашим вимогам алгоритми і додати їх до ваших проектів на Visual Basic.

Сумісність з різними версіями Visual Basic

Вибір найкращого алгоритму визначається не особливостями версії мови програмування, а фундаментальними принципами програмування.


================= Xii


Деякі нові поняття, такі як посилання на об'єкти, класи та колекції, які були вперше введені в 4-й версії Visual Basic, полегшують розуміння, розробку і налагодження деяких алгоритмів. Класи можуть укладати деякі алгоритми в добре продуманих модулях, які легко вставити в програму. Хоча для того, щоб застосовувати ці алгоритми, необов'язково розбиратися в нових поняттях мови, ці нові можливості надають занадто великі переваги, щоб ними можна було знехтувати.

Тому приклади алгоритмів у цьому матеріалі написані для використання в 4-й і 5-й версіях Visual. Якщо ви відкриєте їх у 5-й версії Visual Basic, середовище розробки запропонує вам зберегти їх у форматі 5-й версії, але ніяких змін до коду вносити не доведеться. Всі алгоритми були протестовані в обох версіях.

Ці програми демонструють використання алгоритмів без застосування об'єктно-орієнтованого підходу. Посилання і колекції полегшують програмування, але їх застосування може призводити до деякого уповільнення роботи програм в порівнянні зі старими версіями.

Тим не менш, ігнорування класів, об'єктів і колекцій призвело б до недогляду багатьох справді потужних властивостей. Їх використання дозволяє досягти нового рівня модульності, розробки та повторного використання коду. Їх, безумовно, необхідно мати на увазі, принаймні, на початкових етапах розробки. Надалі, якщо виникнуть проблеми з продуктивністю, ви зможете модифікувати код, використовуючи більш швидкі низькорівневі методи.

Мови програмування найчастіше розвиваються в бік ускладнення, але рідко в протилежному напрямку. Чудовим прикладом цього є наявність оператора goto в мові C. Це незручний оператор, потенційне джерело помилок, який майже не використовується більшістю програмістів на C, але він як і раніше залишається в синтаксисі мови з 1970 року. Він навіть був включений в C + + і пізніше в Java, хоча створення нової мови було хорошим приводом позбутися від нього.

Так і нові версії Visual Basic будуть продовжувати вводити нові властивості у мову, але малоймовірно, що з них будуть виключені будівельні блоки, використані при застосуванні алгоритмів, описаних в даному матеріалі. Незалежно від того, що буде додано до 6-ї, 7-й або 8-й версії Visual Basic, класи, масиви і визначені користувачем типи даних залишаться в мові. Велика частина, а може і всі алгоритми з наведених нижче, будуть виконуватися без змін протягом ще багатьох років.

Огляд глав

У 1 розділі розглядаються поняття, які ви повинні розуміти до того, як приступити до аналізу складних алгоритмів. У ній викладено методи, які будуть потрібні для теоретичного аналізу обчислювальної складності алгоритмів. Деякі алгоритми з високою теоретичною продуктивністю на практиці дають не дуже хороші результати, тому в цьому розділі також зачіпаються практичні міркування, наприклад звернення до файлу підкачки і порівнюється використання колекцій і масивів.

У 2 розділі показано, як утворюються різні види списків з використанням масивів, об'єктів, і псевдоуказателей. Ці структури даних можна з успіхом застосовувати в багатьох програмах, і вони використовуються в наступних розділах

У 3 розділі описані два особливих типи списків: стеки і черги. Ці структури даних використовуються в багатьох алгоритмах, включаючи деякі алгоритми, описані у наступних розділах. Наприкінці глави наведена модель черги на реєстрацію в аеропорту.

У 5 чолі обговорюється потужний інструмент - рекурсія. Рекурсія може бути також заплутаною і призводити до проблем. У 5 розділі пояснюється, в яких випадках слід застосовувати рекурсію і показує, як можна від неї позбутися, якщо це необхідно.

У 6 главі використовуються багато з раніше описаних прийомів, такі як рекурсія і зв'язні списки, для вивчення більш складної теми - дерев. Ця глава також охоплює різні уявлення дерев, такі як дерева з повними вузлами (fat node) та представлення у вигляді нумерацією зв'язків (forward star). У ній також описані деякі важливі алгоритми роботи з деревами, таки як обхід вершин дерева.

У 7 чолі порушена більш складна тема. Збалансовані дерева мають особливі властивості, які дозволяють їм залишатися врівноваженими і ефективними. Алгоритми збалансованих дерев дивно просто описуються, але їх досить важко реалізувати програмно. У цій главі використовується одна з найбільш потужних структур подібного типу - Б + дерево (B + Tree) для створення складної бази даних.

У 8 чолі обговорюються завдання, які можна описати як пошук відповідей у ​​дереві рішень. Навіть для невеликих завдань, ці дерева можуть бути гігантськими, тому необхідно здійснювати пошук в них максимально ефективно. У цьому розділі порівнюються деякі різні методи, які дозволяють виконати такий пошук.

Глава 9 присвячена, мабуть, найбільш досліджуваної галузі теорії алгоритмів - сортуванню. Алгоритми сортування цікаві з кількох причин. По-перше, сортування - часто зустрічається завдання. По-друге, різні алгоритми сортувань мають своїми сильними і слабкими сторонами, тому не існує одного алгоритму, який показував би найкращі результати в будь-яких ситуаціях. І, нарешті, алгоритми сортування демонструють широкий спектр важливих алгоритмічних методів, таких як рекурсія, піраміди, а також використання генератора випадкових чисел для зменшення ймовірності випадання найгіршого випадку.

У розділі 10 розглядається близька до сортування тема. Після виконання сортування списку, програмі може знадобитися знайти елементи в ньому. У цьому розділі порівнюється кілька найбільш ефективних методів пошуку елементів у сортованих списках.


===== 69


Private Sub AtoB (ByVal I As Integer, ByVal J As Integer, X As Integer)

Dim tmp As Integer


If I <J Then 'Поміняти місцями I і J.

tmp = I

I = J

J = tmp

End If

I = I + 1

X = I * (I - 1) / 2 + J

End Sub


Процедура перетворення BtoA повинна віднімати з I одиницю перед поверненням значення.


Private Sub BtoA (ByVal X As Integer, I As Integer, J As Integer)

I = Int ((1 + Sqr (1 + 8 * X)) / 2)

J = X - I * (I - 1) / 2

I = J - 1

End Sub


Програма Triang2 аналогічна програмі Triang, але вона використовує для роботи з діагональними елементами в масиві A ці нові функції. Програма TriangC2 аналогічна програмі TriangC, але використовує клас TriangularArray, який включає діагональні елементи.

Нерегулярні масиви

У деяких програмах потрібні масиви нестандартного розміру і форми. Двовимірний масив може містити шість елементів в першому ряду, три - в другому, чотири - в третьому, і т.д. Це може знадобитися, наприклад, для збереження низки багатокутників, кожен з яких складається з різної кількості точок. Масив буде при цьому виглядати, як на рис. 4.3.

Масиви в Visual Basic не можуть мати такі нерівні краї. Можна було б використовувати масив, досить великий для того, щоб у ньому могли поміститися всі рядки, але при цьому в такому масиві було б безліч невикористовуваних осередків. Наприклад, масив на рис. 4.3 міг би бути оголошений за допомогою оператора Dim Polygons (1 To 3, 1 To 6), і при цьому чотири осередки залишаться невикористаними.

Існує кілька способів подання нерегулярних масивів.


@ Рис. 4.3. Нерегулярний масив


===== 70


Пряма зірка

Один зі способів уникнути втрат пам'яті полягає в тому, щоб упакувати дані в одновимірному масиві B. На відміну від трикутних масивів, для нерегулярних масивів не можна записати формули для визначення відповідності елементів у різних масивах. Щоб справитися з цим завданням, можна створити ще один масив A із зсувами для кожного рядка в одновимірному масиві B.

Для спрощення визначення в масиві B положення точок, відповідних кожному рядку, в кінець масиву A можна додати сигнальну мітку, яка вказує на точку відразу за останнім елементом у масиві B. Тоді точки, що утворюють багатокутник I, займають в масиві B позиції з A (I) до A (I +1) -1. Наприклад, програма може перерахувати елементи, що утворюють рядок I, використовуючи наступний код:


For J = A (I) To A (I + 1) - 1

'Внести до списку елемент I.

:

Next J


Цей метод називається прямий зіркою (forward star). На рис. 4.4 показана вистава нерегулярного масиву з рис. 4.3 у вигляді прямої зірки. Сигнальна мітка зафарбована сірим кольором.

Цей метод можна легко узагальнити для створення багатовимірних нерегулярних масивів. Для зберігання набору малюнків, кожний з яких складається з різної кількості багатокутників, можна використовувати тривимірну пряму зірку.

На рис. 4.5 схематично представлена ​​тривимірна структура даних у вигляді прямої зірки. Дві сигнальних мітки зафарбовані сірим кольором. Вони вказують на одну позицію позаду значущих даних у масиві.

Таке уявлення у вигляді прямої зірки вимагає дуже невеликих витрат пам'яті. Тільки пам'ять, займана сигнальними мітками, витрачається «даремно».

При використанні структури даних прямий зірки легко і швидко можна перерахувати точки, що утворюють багатокутник. Так само просто зберігати такі дані на диску і завантажувати їх назад в пам'ять. З іншого боку, оновлювати масиви, записані у форматі прямої зірки, дуже складно. Припустимо, ви хочете додати нову точку до першого багатокутнику на рис. 4.4. Для цього знадобиться зрушити всі елементи праворуч від нової точки на одну позицію, щоб звільнити місце для нового елемента. Потім потрібно додати по одиниці до всіх елементів масиву A, які йдуть після першого, щоб врахувати зсув, викликаний додаванням точки. І, нарешті, треба вставити новий елемент. Подібні проблеми виникають при видаленні точки з першого багатокутника.


@ Рис. 4.4. Уявлення нерегулярного масиву у вигляді прямої зірки


===== 71


@ Рис. 4.5. Тривимірна пряма зірка


На рис. 4.6 показана вистава у вигляді прямої зірки з рис. 4.4 після додавання однієї точки до першого багатокутнику. Елементи, які були змінені, зафарбовані сірим кольором. Як видно з малюнка, майже всі елементи в обох масивах були змінені.

Нерегулярні зв'язні списки

Іншим методом створення нерегулярних масивів є використання зв'язкових списків. Кожна клітинка містить покажчик на наступну комірку на тому ж рівні ієрархії, і покажчик на список осередків на більш низькому рівні ієрархії. Наприклад, комірка багатокутника може містити покажчик на наступний багатокутник і покажчик на клітинку, що містить координати першої точки.

Наступний код наводить визначення змінних для класів, які можна використовувати для створення зв'язного списку малюнків. Кожен з малюнків містить зв'язний список багатокутників, кожен з яких містить зв'язний список точок.

У класі PictureCell:


Dim NextPicture As PictureCell 'Наступний малюнок.

Dim FirstPolygon As PolyfonCell 'Перший багатокутник на цьому малюнку.


У класі PolygonCell:


Dim NextPolygon As PolygonCell 'Наступний багатокутник.

Dim FirstPoint As PointCell "Перша точка в цьому багатокутнику.


У класі PointCell:


@ Рис. 4.6. Додавання точки до прямої зірку


====== 72


Dim NextPoint As PointCell 'Наступна точка в цьому багатокутнику.

Dim X As Single 'Координати точки.

Dim Y As Single


Використовуючи ці методи, можна легко додавати і видаляти малюнки, багатокутники або точки в будь-якому місці структури даних.

Програма Poly на диску містить зв'язний список багатокутників. Кожен багатокутник містить зв'язний список точок. Коли ви закриваєте форму, посилання на список багатокутників з форми знищується. Це зменшує лічильник посилань на верхню клітинку багатокутників до нуля. Вона знищується, тому її посилання на наступний багатокутник і його першу точку також знищуються. Лічильники посилань на ці клітинки також зменшуються до нуля, і вони теж знищуються. Знищення кожного осередку багатокутника або точки призводить до знищення наступної комірки. Цей процес продовжується до тих пір, поки всі багатокутники і точки не будуть знищені.

Розріджені масиви

У багатьох програмах потрібні великі масиви, які містять лише невелике число ненульових елементів. Матриця суміжності для авіаліній, наприклад, може містити 1 у позиції A (I, J) якщо є рейс між містами I і J. Багато авіалінії обслуговують сотні міст, але число існуючих рейсів набагато менше, ніж N 2 можливих комбінацій. На рис. 4.8 показана невелика карта рейсів авіалінії, на якій зображені тільки 11 існуючих рейсів з 100 можливих пар поєднань міст.


@ Рис. 4.7. Програма Poly


==== 73


@ Рис. 4.8. Карта рейсів авіалінії


Можна побудувати матрицю суміжності для цього прикладу за допомогою масиву 10 на 10 пунктів, але цей масив буде здебільшого порожнім. Можна уникнути втрат пам'яті, використовуючи для створення розрідженого масиву покажчики. Кожна клітинка містить покажчики на наступний елемент у рядку і стовпці масиву. Це дозволяє програмі визначити положення будь-якого елемента в масиві і обходити елементи в рядку або стовпці. Залежно від програми, може виявитися корисним також додати зворотні покажчики. На рис. 4.9 показана розріджена матриця суміжності, відповідна карті рейсів з рис. 4.8.

Щоб побудувати розріджений масив у Visual Basic, створіть клас для представлення елементів масиву. У цьому випадку, кожна осередок наявність рейсів між двома містами. Для представлення зв'язку, клас повинен містити змінні з індексами міст, які пов'язані між собою. Ці індекси, по суті, дають номери рядків і стовпців комірки. Кожна клітинка також повинна містити покажчики на наступну комірку в рядку і стовпці.

Наступний код показує оголошення змінних в класі ConnectionCell:


Public FromCity As Integer 'Рядок осередки.

Public ToCity As Integer 'Стовпець осередки.

Public NextInRow As ConnectionCell

Public NextInCol As ConnectionCell


Рядки та стовпці в цьому масиві по суті представляють собою зв'язні списки. Як це часто трапляється з зв'язковими списками, з ними простіше працювати, якщо вони містять сигнальні мітки. Наприклад, мінлива RowHead (I) повинна містити сигнальну мітку для рядка I. Для обходу рядка I в масиві можна використовувати наступний код:


Private Sub PrintRow (I As Integer)

Dim cell As ConnectionCell


Set Cell = RowHead (I). Next 'Перший елемент даних.

Do While Not (cell Is Nothing)

Print Format $ (cell.FromCity) & "->" & Format $ (cell.ToCity)

Set cell = cell.NextInRow

Loop

End Sub


==== 74


@ Рис. 4.9. Розріджена матриця суміжності


Індексування масиву

Нормальне індексування масиву типу A (I, J) не буде працювати з такими структурами. Можна полегшити індексування, написавши процедури, які витягують і встановлюють значення елементів масиву. Якщо масив представляє матрицю, можуть також знадобитися процедури для складання, множення, та інших матричних операцій.

Спеціальне значення NoValue представляє пустий елемент масиву. Процедура, яка витягує елементи масиву, повинна повертати значення NoValue при спробі отримати значення елемента, що не міститься в масиві. Аналогічно, процедура, яка встановлює значення елементів, повинна видаляти клітинку з масиву, якщо її значення встановлено в NoValue.

Значення NoValue повинно вибиратися в залежності від природи даних програми. Для матриці суміжності авіалінії порожні клітинки можуть мати значення False. При цьому значення A (I, J) може встановлюватися рівним True, якщо існує рейс між містами I і J.

Клас SparseArray визначає процедуру get для властивості Value для повернення значення елемента в масиві. Процедура починає з першого осередку у вказаному рядку і потім переміщається по зв'язного списку осередків рядка. Як тільки знайдеться осередок з потрібним номером стовпця, це і буде шукана осередок. Так як осередки у списку рядки розташовані по порядку, процедура може зупинитися, якщо знайдеться осередок, номер стовпця якої більше шуканого.


===== 75


Property Get Value (t As Integer, c As Integer) As Variant

Dim cell As SparseArrayCell

Value = NoValue 'Припустимо, що ми не знайдемо елемент.

If r <1 Or c <1 Or _

r> NumRows Or c> NumCols _

Then Exit Property


Set cell = RowHead (r). NextInRow 'Пропустити позначку.

Do

If cell Is Nothing Then Exit Property 'Не знайдено.

If cell.Col> c Then Exit Property 'Не знайдено.

If cell.Col = c Then Exit Do 'Найден.

Set cell = cell.NextInRow

Loop

Value = cell. Data

End Property


Процедура let властивості value присвоює осередку нового значення. Якщо нове значення дорівнює NoValue, процедура викликає для видалення елемента з масиву. В іншому випадку, вона шукає необхідне положення елемента в потрібному рядку. Якщо елемент уже існує, процедура оновлює його значення. Інакше, вона створює новий елемент і додає його до списку рядка. Потім вона додає новий елемент у правильне положення у відповідному списку стовпців.


Property Let Value (r As Integer, c As Integer, new_value As Variant)

Dim i As Integer

Dim found_it As Boolean

Dim cell As SparseArrayCell

Dim nxt As SparseArrayCell

Dim new_cell As SparseArrayCell


'Якщо value = MoValue, видалити елемент з масиву.

If new_value = NoValue Then

RemoveEntry r, c

Exit Property

End If


'Якщо потрібно, додати рядки.

If r> NumRows Then

ReDim Preserve RowHead (1 To r)


'Ініціалізувати мітку для кожної нового рядка.

For i = NumRows + 1 To r

Set RowHead (i) = New SparseArrayCell

Next i

End If


'Якщо потрібно, додати стовпці.

If c> NumCols Then

ReDim Preserve ColHead (1 To c)


'Ініціалізувати мітку для кожної нового рядка.

For i = NumCols + 1 To c

Set ColHead (i) = New SparseArrayCell

Next i

NumCols = c

End If


'Спроба знайти елемент.

Set cell = RowHead (r)

Set nxt = cell.NextInRow

Do

If nxt Is Nothing Then Exit Do

If nxt.Col> = c Then Exit Do

Set cell = nxt

Set nxt = cell.NextInRow

Loop


'Перевірка, чи знайдений елемент.

If nxt Is Nothing Then

found_it = False

Else

found_it = (nxt.Col = c)

End If


'Якщо елемент не знайдений, створити його.

If Not found_it Then

Set new_cell = New SparseArrayCell


'Помістити елемент у список рядка.

Set new_cell.NextInRow = nxt

Set cell.NextInRow = new_cell


'Помістити елемент у список стовпця.

Set cell = ColHead (c)

Set nxt = cell.NextInCol

Do

If nxt Is Nothing Then Exit Do

If nxt.Col> = c Then Exit Do

Set cell = nxt

Set nxt = cell.NextInRow

Loop


Set new_cell.NextInCol = nxt

Set cell.NextInCol = new_cell

new_cell.Row = r

new_cell.Col = c


'Помістимо значення в елемент nxt.

Set nxt = new_cell

End If


'Встановимо значення.

nxt.Data = new_value

End Property


Програма Sparse, показана на рис. 4.10, використовує класи SparseArray і SparseArrayCell для роботи з розрідженим масивом. Використовуючи програму, можна встановлювати та виймати елементи масиву. У цій програмі значення NoValue дорівнює нулю, тому якщо ви встановите значення елемента рівним нулю, програма видалить цей елемент з масиву.

Дуже розріджені масиви

Деякі масиви містять так мало непустих елементів, що багато рядки і стовпці повністю порожні. У цьому випадку, краще зберігати заголовки рядків і стовпців в зв'язкових списках, а не в масивах. Це дозволяє програмі повністю пропускати порожні рядки та стовпці. Заголовки рядків і стовпців вказують на зв'язні списки елементів рядків і стовпців. На рис. 4.11 показаний масив 100 на 100, який містить всього 7 непустих елементів.


@ Рис. 4.10. Програма Sparse


===== 76-78


@ Рис. 4.11. Дуже розріджений масив


Для роботи з масивами цього типу можна досить просто доопрацювати попередній код. Велика частина коду залишається незмінною, і для елементів масиву можна використовувати той же самий клас SparseArray.Тем Проте, замість зберігання міток рядків і стовпців в масивах, вони записуються в зв'язкових списках.

Об'єкти класу HeaderCell представляють зв'язні списки рядків і стовпців. У цьому класі визначаються змінні, що містять число рядків і стовпців, які він представляє, сигнальна мітка на початку зв'язного списку елементів рядків або стовпців, і об'єкт HeaderCell, представляє наступний заголовок рядка або стовпця.


Public Number As Integer 'Номер рядка або стовпця.

Public Sentinel As SparseArrayCell 'Мітка для рядка або

'Стовпця.

Public NextHeader As HeaderCell 'Наступний рядок або

'Стовпець.


Наприклад, щоб звернутися до рядку I, потрібно спочатку переглянути зв'язний список заголовків HeaderCells рядків, поки не знайдеться заголовок, відповідний рядку I. Потім триває робота з рядком I.


Private Sub PrintRow (r As Integer)

Dim row As HeaderCell

Dim cell As SparseArrayCell


'Знайти правильний заголовок рядка.

Set row = RowHead. NextHeader 'Список першого рядка.

Do

If row Is Nothing Then Exit Sub 'Такий рядка немає.

If row.Number> r Then Exit Sub 'Такий рядка немає.

If row.Number = r Then Exit Do 'Рядок знайдена.

Set row = row.NextHeader

Loop


'Вивести елементи в рядку.

Set cell = row.Sentinel. NextInRow 'Перший елемент у рядку.


Do While Not (cell Is Nothing)

Print Format $ (cell.FromCity) & "->" & Format $ (cell.ToCity)

Set cell = cell.NextInRow

Loop

End Sub


Резюме

Деякі програми використовують масиви, що містять тільки невелику кількість значущих елементів. Використання звичайних масивів Visual Basic призвело б до великих втрат пам'яті. Використовуючи трикутні, нерегулярні, розріджені і дуже розріджені масиви, ви можете створювати потужні подання масивів, які вимагають набагато менших обсягів пам'яті.


========= 80


Глава 5. Рекурсія

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

Після того, як ви придбаєте досвід застосування рекурсії, ви будете виявляти її всюди. Багато програмістів, нещодавно опанували рекурсією, захоплюються, і починають застосовувати її в ситуаціях, коли вона є непотрібною, а іноді й шкідливою.

У перших розділах цієї глави обговорюється обчислення факторіалів, чисел Фібоначчі, і найбільшого загального дільника. Всі ці алгоритми є прикладами поганого використання рекурсії - нерекурсівние версії цих алгоритмів набагато ефективніше. Ці приклади цікаві і наочні, тому має сенс обговорити їх.

Потім, в розділі розглядається кілька прикладів, у яких застосування рекурсії більш доречно. Алгоритми побудови кривих Гільберта і Серпінського використовують рекурсію правильно і ефективно.

В останніх розділах цього розділу пояснюється, чому реалізацію алгоритмів обчислення факторіалів, чисел Фібоначчі, і найбільшого загального дільника краще здійснювати без застосування рекурсії. У цих параграфах пояснюється також, коли слід уникати рекурсії, і наводяться способи усунення рекурсії, якщо це необхідно.

Що таке рекурсія?

Рекурсія відбувається, якщо функція або підпрограма викликає сама себе. Пряма рекурсія (direct recursion) виглядає приблизно так:


Function Factorial (num As Long) As Long

Factorial = num * Factorial (num - 1)

End Function


У випадку непрямої рекурсії (indirect recursion) рекурсивна процедура викликає іншу процедуру, яка, у свою чергу, викликає першу:


Private Sub Ping (num As Integer)

Pong (num - 1)

End Sub


Private Sub Pong (num As Integer)

Ping (num / 2)

End Sub


=========== 81


Рекурсія корисна при вирішенні завдань, які природним чином розбиваються на кілька підзадач, кожна з яких є більш простим випадком вихідної задачі. Можна уявити дерево на рис. 5.1 у вигляді «стволи», на якому знаходяться два дерева менших розмірів. Тоді можна написати рекурсивну процедуру для малювання дерев:


Private Sub DrawTree ()

Намалювати "ствол"

Намалювати дерево меншого розміру, повернене на -45 градусів

Намалювати дерево меншого розміру, повернене на 45 градусів

End Sub


Хоча рекурсія і може спростити розуміння деяких проблем, люди зазвичай не мислять рекурсивно. Вони зазвичай прагнуть розбити складні завдання на завдання меншого обсягу, які можуть бути виконані послідовно одна за одною до повного завершення. Наприклад, щоб пофарбувати огорожу, можна почати з її лівого краю і продовжувати рухатися вправо до завершення. Ймовірно, під час виконання такого завдання ви не думаєте про можливість рекурсивної забарвлення - спочатку лівої половини огорожі, а потім рекурсивно - правою.

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

Рекурсивне обчислення факторіалів

Факторіал числа N записується як N! (Вимовляється «Ен факторіал»). За визначенням, 0! дорівнює 1. Останні значення визначаються формулою:


N! = N * (N - 1) * (N - 2) * ... * 2 * 1


Як вже згадувалося в 1 розділі, ця функція надзвичайно швидко зростає із збільшенням N. У табл. 5.1 наведено 10 перших значень функції факторіалу.

Можна також визначити функцію факторіала рекурсивно:


0! = 1

N! = N * (N - 1)! для N> 0.


@ Рис. 5.1. Дерево, складене з двох дерев меншого розміру


=========== 82


@ Таблиця 5.1. Значення функції факторіалу


Легко написати на основі цього визначення рекурсивну функцію:


Public Function Factorial (num As Integer) As Integer

If num <= 0 Then

Factorial = 1

Else

Factorial = num * Factorial (num - 1)

End If

End Function


Спочатку ця функція перевіряє, що число менше або дорівнює 0. Факторіал для чисел менше нуля не визначений, але ця умова перевіряється для підстраховки. Якби функція перевіряла тільки умова рівності числа нулю, то для негативних чисел рекурсія була б нескінченною.

Якщо вхідна значення менше або дорівнює 0, функція повертає значення 1. В інших випадках, значення функції дорівнює добутку вхідного значення на факторіал від вхідного значення, зменшеного на одиницю.

Те, що ця рекурсивна функція врешті-решт зупиниться, гарантується двома фактами. По-перше, при кожному наступному виклику, значення параметра num зменшується на одиницю. По-друге, значення num обмежена знизу нулем. Коли num стає рівним 0, функція зупиняє рекурсію. Умова, наприклад, в даному випадку умова num <= 0, називається або умовою зупинки рекурсії (base case або stopping case).

При кожному виклику підпрограми, система зберігає ряд параметрів в системному стеці, як описувалося в 3 главі. Так як цей стек грає важливу роль, іноді його називають просто стеком. Якщо рекурсивна функція викличе себе занадто багато разів, вона може вичерпати стекової простір та аварійно завершити роботу із помилкою "Out of stack space».

Кількість разів, що функція може викликати сама себе до того, як використовує всі стекової простір, залежить від обсягу встановленої на комп'ютері пам'яті і кількість даних, які розміщені програмою в стек. В одному з тестів, програма вичерпала стекової простір після 452 рекурсивних викликів. Після зміни рекурсивної функції таким чином, щоб вона визначала 10 локальних змінних при кожному виклику, програма могла викликати себе тільки 271 разів.

Аналіз часу виконання програми

Функції факторіала потрібно єдиний аргумент: число, факторіал від якого потрібно обчислити. Аналіз обчислювальної складності алгоритму зазвичай досліджує залежність часу виконання програми як функції від розмірності (size) завдання або числа вхідних значень (number of inputs). Оскільки в даному випадку вхідний значення всього одне, такі розрахунки могли б здатися трохи дивними.


======== 83


Тому, алгоритми з єдиним вхідним параметром зазвичай оцінюються через число бітів, необхідних для зберігання вхідного значення, а не кількість вхідних значень. У певному сенсі, це і є розмір входу, так як стільки біт потрібно для того, щоб записати вхідний значення. Тим не менш, це не дуже наочний спосіб подання цього завдання. Крім того, теоретично комп'ютер міг би записати вхідний значення N в log 2 (N) біт, але в дійсності найімовірніше N займає фіксоване число бітів. Наприклад, всі числа формату long займають 32 біта.

Тому в цьому розділі алгоритми цього типу аналізуються на основі значення входу, а не його розмірності. Якщо ви хочете переписати результати в термінах розмірності входу, ви можете це зробити, скориставшись тим, що N = 2 M, де М - число бітів, необхідне для запису N. Якщо час виконання алгоритму порядку O (N 2) в термінах вхідного значення N, то воно складе порядку O ((2 2M) 2) = O (2 2 * M) = O ((2 2) M) = O (4 M ) у термінах розмірності входу M.

Функції порядку O (N) ростуть досить повільно, тому можна очікувати від цього алгоритму гарній продуктивності. Так воно і є. Ця функція призводить до проблем тільки при переповненні стека після безлічі рекурсивних викликів, або коли значення N! стає занадто великим і не поміщається у формат цілого числа, викликаючи помилку переповнювання.

Так як N! зростає дуже швидко, переповнення настає раніше, якщо тільки стік не використовується інтенсивно для інших цілей. При використанні даних цілого типу, переповнення настає для 8!, Оскільки 8! = 40.320, що більше, ніж найбільше ціле число 32.767. Для того щоб програма могла обчислювати наближені значення факторіала великих чисел, можна змінити функцію, використовуючи замість цілих чисел значення типу double. Тоді максимальне число, яке зможе обчислити алгоритм, дорівнюватиме 170! = 7,257 E +306.

Програма Facto демонструє рекурсивну функцію факторіала. Введіть значення і натисніть на кнопку Go, щоб обчислити його факторіал.

Рекурсивне обчислення найбільшого спільного дільника

Найбільшим спільним дільником (greatest common divisor, GCD) двох чисел називається найбільше ціле, на яке діляться два числа без залишку. Наприклад, найбільший загальний дільник чисел 12 і 9 дорівнює 3. Два числа називаються взаємно простими (relatively prime), якщо їх найбільший спільний дільник дорівнює 1.

Математик Ейлер, який жив у вісімнадцятому столітті, виявив цікавий факт:


Якщо A без остачі ділиться на B, то GCD (A, B) = A.

Інакше GCD (A, B) = GCD (B Mod A, A).


Цей факт можна використовувати для швидкого обчислення найбільшого спільного дільника. Наприклад:


GCD (9, 12) = GCD (12 Mod 9, 9)

= GCD (3, 9)

= 3


======== 84


На кожному кроці числа стають все менше, тому що 1 <= B Mod A

Відкриття Ейлера закономірним чином приводить до рекурсивного алгоритму обчислення найбільшого спільного дільника:


public Function GCD (A As Integer, B As Integer) As Integer

If B Mod A = 0 Then 'Ділиться чи B на A без остачі?

GCD = A 'Так. Процедура завершена.

Else

GCD = GCD (B Mod A, A) 'Ні. Рекурсія.

End If

End Function


Аналіз часу виконання програми

Щоб проаналізувати час виконання цього алгоритму, необхідно визначити, наскільки швидко убуває мінлива A. Так як функція зупиняється, коли A доходить до значення 1, то швидкість зменшення A дає верхню межу оцінки часу виконання алгоритму. Виявляється, при кожному другому виконанні функції GCD, параметр A зменшується, принаймні, в 2 рази.

Припустимо, A <B. Це умова завжди виконується при першому виклику функції GCD. Якщо B Mod A <= A / 2, то при наступному виклику функції GCD перший параметр зменшиться, принаймні, в 2 рази, і доказ закінчено.

Припустимо протилежне. Припустимо, B Mod A> A / 2. Першим рекурсивним викликом функції GCD буде GCD (B Mod A, A).

Підстановка у функцію значення B Mod A і A замість A і B дає наступний рекурсивний виклик GCD (B Mod A, A).

Але ми припустили, що B Mod A> A / 2. Тоді B Mod A розділиться на A тільки один раз, із залишком A - (B Mod A). Так як B Mod A більше, ніж A / 2, то A - (B Mod A) повинно бути менше, ніж A / 2. Отже, перший параметр другий рекурсивного виклику функції GCD менше, ніж A / 2, що й потрібно було довести.

Припустимо тепер, що N - це початкове значення параметра A. Після двох викликів функції GCD, значення параметра A повинно зменшиться, принаймні, до N / 2. Після чотирьох викликів, це значення буде не більше, ніж (N / 2) / 2 = N / 4. Після шести викликів, значення не буде перевершувати (N / 4) / 2 = N / 8. У загальному випадку, після 2 * K викликів функції GCD, значення параметра A буде не більше, ніж N / 2 K.

Оскільки алгоритм повинен зупинитися, коли значення параметра A дійде до 1, він може продовжувати роботу лише до тих, поки не виконується рівність N / 2 K = 1. Це відбувається, коли N = 2 K або коли K = log 2 (N). Так як алгоритм виконується за 2 * K кроків це означає, що алгоритм зупиниться не більш, ніж через 2 * log 2 (N) кроків. З точністю до постійного множника, це означає, що алгоритм виконується за час порядку O (log (N)).


======= 85


Цей алгоритм - один з безлічі рекурсивних алгоритмів, які виконуються за час порядку O (log (N)). При виконанні фіксованого числа кроків, в даному випадку 2, розмір задачі зменшується вдвічі. У загальному випадку, якщо розмір задачі зменшується, щонайменше, в D раз після кожних S кроків, то завдання вимагатиме S * log D (N) кроків.

Оскільки при оцінці по порядку величини можна ігнорувати постійні множники і підстави логарифмів, то будь-який алгоритм, який виконується за час S * log D (N), буде алгоритмом порядку O (log (N)). Це не обов'язково означає, що цими постійними можна повністю знехтувати при реалізації алгоритму. Алгоритм, який зменшує розмір задачі при кожному кроці в 10 разів, ймовірно, буде швидше, ніж алгоритм, який зменшує розмір задачі вдвічі через кожні 5 кроків. Тим не менш, обидва ці алгоритму мають час виконання порядку O (log (N)).

Алгоритми порядку O (log (N)) зазвичай виконуються дуже швидко, і алгоритм знаходження найбільшого спільного дільника не є виключенням з цього правила. Наприклад, щоб знайти, що найбільший спільний дільник чисел 1.736.751.235 і 2.135.723.523 рівний 71, функція викликається всього 17 разів. Фактично, алгоритм практично миттєво обчислює значення, які не перевищують максимального значення числа у форматі long - 2.147.483.647. Функція Visual Basic Mod не може оперувати значеннями, більшими цього, тому це практична межа для даної реалізації алгоритму.

Програма GCD використовує цей алгоритм для рекурсивного обчислення найбільшого спільного дільника. Введіть значення для A і B, потім натисніть на кнопку Go, і програма обчислить найбільший спільний дільник цих двох чисел.

Рекурсивне обчислення чисел Фібоначчі

Можна рекурсивно визначити числа Фібоначчі (Fibonacci numbers) за допомогою рівнянь:


Fib (0) = 0

Fib (1) = 1

Fib (N) = Fib (N - 1) + Fib (N - 2) для N> 1.


Третє рівняння рекурсивно двічі викликає функцію Fib, один раз з вхідним значенням N-1, а інший - зі значенням N-2. Це визначає необхідність 2 умов зупинки рекурсії: Fib (0) = 0 і Fib (1) = 1. Якщо задати лише одне з них, рекурсія може виявитися нескінченною. Наприклад, якщо задати тільки Fib (0) = 0, то значення Fib (2) могло б обчислюватися так:


Fib (2) = Fib (1) + Fib (0)

= [Fib (0) + Fib (-1)] + 0

= 0 + [Fib (-2) + Fib (-3)]

= [Fib (-3) + Fib (-4)] + [Fib (-4) + Fib (-5)]

І т.д.


Це визначення чисел Фібоначчі легко перетворити на рекурсивну функцію:


Public Function Fib (num As Integer) As Integer

If num <= 1 Then

Fib = num

Else

Fib = Fib (num - 1) + Fib (num - 2)

End If

End Function


========= 86


Аналіз часу виконання програми

Аналіз цього алгоритму достатньо складний. По-перше, визначимо, скільки разів виконується одна з умов зупинки num <= 1. Нехай G (N) - кількість разів, що алгоритм досягає умови зупинки для входу N. Якщо N <= 1, то функція досягає умови зупинки один раз і не вимагає рекурсії.

Якщо N> 1, то функція рекурсивно обчислює Fib (N-1) і Fib (N-2), і завершує роботу. При першому виклику функції, умова зупинки не виконується - воно досягається тільки в наступних, рекурсивних викликах. Повне число виконання умови зупинки для вхідного значення N, складається з числа разів, що воно виконується для значення N-1 і числа раз, яке воно виконувалося для значення N-2. Все це можна записати так:


G (0) = 1

G (1) = 1

G (N) = G (N - 1) + G (N - 2) для N> 1.


Це рекурсивне визначення дуже схоже на визначення чисел Фібоначчі. У табл. 5.2 наведені деякі значення функцій G (N) і Fib (N). Легко побачити, що G (N) = Fib (N +1).

Тепер розглянемо, скільки разів алгоритм досягає рекурсивного кроку. Якщо N <= 1, функція не досягає цього кроку. При N> 1, функція досягає цього кроку 1 раз і потім рекурсивно обчислює Fib (n-1) і Fib (N-2). Нехай H (N) - кількість разів, що алгоритм досягає рекурсивного кроку для входу N. Тоді H (N) = 1 + H (N-1) + H (N-2). Рівняння, що визначають H (N):


H (0) = 0

H (1) = 0

H (N) = 1 + H (N - 1) + H (N - 2) для N> 1.


У табл. 5.3 показані деякі значення для функцій Fib (N) і H (N). Можна побачити, що H (N) = Fib (N +1) -1.


@ Таблиця 5.2. Значення чисел Фібоначчі і функції G (N)


====== 87


@ Таблиця 5.3. Значення чисел Фібоначчі і функції H (N)


Об'єднуючи результати для G (N) і H (N), отримуємо повне час виконання для алгоритму:


Час виконання = G (N) + H (N)

= Fib (N + 1) + Fib (N + 1) - 1

= 2 * Fib (N + 1) - 1


Оскільки Fib (N + 1)> = Fib (N) для всіх значень N, то:


Час виконання> = 2 * Fib (N) - 1


З точністю до порядку це складе O (Fib (N)). Цікаво, що ця функція не тільки рекурсивна, але вона також використовується для оцінки часу її виконання.

Щоб допомогти вам представити швидкість зростання функції Фібоначчі, можна показати, що Fib (M)>  M-2 де  - константа, яка дорівнює 1,6. Це означає, що час виконання не менше, ніж значення експоненційної функції O ( M). Як і інші експоненціальні функції, ця функція зростає швидше, ніж поліноміальні функції, але повільніше, ніж функція факторіала.

Оскільки час виконання росте дуже швидко, цей алгоритм досить повільно виконується для великих вхідних значень. Фактично, настільки повільно, що на практиці майже неможливо обчислити значення функції Fib (N) для N, які набагато більше 30. У табл. 5.4 показано час виконання для цього алгоритму на комп'ютері з процесором Pentium з тактовою частотою 90 МГц при різних вхідних значеннях.

Програма Fibo використовує цей рекурсивний алгоритм обчислення чисел Фібоначчі. Введіть ціле число і натисніть на кнопку Go для обчислення чисел Фібоначчі. Почніть з невеликих чисел, поки не оціните, наскільки швидко ваш комп'ютер може виконувати ці обчислення.

Рекурсивне побудова кривих Гільберта

Криві Гільберта (Hilbert curves) - це самоподібні (self similar) криві, які зазвичай визначаються за допомогою рекурсії. На рис. 5.2. показані криві Гільберта з 1, 2 або 3 порядку.


@ Таблиця 5.4. Час виконання програми Fibonacci


===== 88


@ Рис. 5.2. Криві Гільберта


Крива Гільберта, як і будь-яка інша самоподібних крива, створюється розбивкою великий кривої на менші частини. Потім ви можете використовувати цю ж криву, після зміни розміру та повороту, для побудови цих частин. Ці частини можна розбити на більш дрібні частини, і так далі, поки процес не досягне потрібної глибини рекурсії. Порядок кривої визначається як максимальна глибина рекурсії, якої досягає процедура.

Процедура Hilbert управляє глибиною рекурсії, використовуючи відповідний параметр. При кожному рекурсивному виклику, процедура зменшує параметр глибини рекурсії на одиницю. Якщо процедура викликається з глибиною рекурсії, яка дорівнює 1, вона малює просту криву 1 порядку, показану на рис. 5.2 зліва і завершує роботу. Ця умова зупинки рекурсії.

Наприклад, крива Гільберта 2 порядку складається з чотирьох кривих Гільберта 1 порядку. Аналогічно, крива Гільберта 3 порядку складається з чотирьох кривих 2 порядку, кожна з яких складається з чотирьох кривих 1 порядку. На рис. 5.3 показані криві Гільберта 2 і 3 порядку. Менші криві, з яких побудовані криві більшого розміру, виділені напівжирними лініями.

Наступний код будує криву Гільберта 1 порядку:


Line-Step (Length, 0)

Line-Step (0, Length)

Line-Step (-Length, 0)


Передбачається, що малювання починається з верхнього лівого кута області і що Length - це задана довжина кожного відрізка ліній.

Можна накидати чернетку методу, що малює криві Гільберта більш високих порядків:


Private Sub Hilbert (Depth As Integer)

If Depth = 1 Then

Намалювати криву Гільберта 1 порядку

Else

Намалювати і з'єднати 4 криві порядку (Depth - 1)

End If

End Sub


==== 89


@ Рис. 5.3. Криві Гільберта, утворені меншими кривими


Цей метод вимагає невеликого ускладнення для визначення напрямку малювання кривих. Це потрібно для того, щоб вибрати тип використовуваних кривих Гільберта.

Цю інформацію можна передати процедурі за допомогою параметрів Dx і Dy для визначення напрямку виходу першої лінії в кривій. Для кривої 1 порядку, процедура малює першу лінію за допомогою функції Line-Step (Dx, Dy). Якщо крива має вищий порядок, процедура з'єднує перші дві подкрівих, використовуючи функцію Line-Step (Dx, Dy). У будь-якому випадку, процедура може використовувати параметри Dx і Dy для вибору напрямку, в якому вона повинна малювати лінії, що утворюють криву.

Код мовою Visual Basic для малювання кривих Гільберта короткий, але складний. Вам може знадобитися кілька разів пройти його в відладчик для кривих 1 і 2 порядку, щоб побачити, як змінюються параметри Dx і Dy, при побудові різних частин кривої.


Private Sub Hilbert (depth As Integer, Dx As Single, Dy As Single)

If depth> 1 Then Hilbert depth - 1, Dy, Dx

HilbertPicture.Line-Step (Dx, Dy)

If depth> 1 Then Hilbert depth - 1, Dx, Dy

HilbertPicture.Line-Step (Dy, Dx)

If depth> 1 Then Hilbert depth - 1, Dx, Dy

HilbertPicture.Line-Step (-Dx,-Dy)

If depth> 1 Then Hilbert depth - 1,-Dy,-Dx

End Sub


Аналіз часу виконання програми

Щоб проаналізувати час виконання цієї процедури, ви можете визначити кількість викликів процедури Hilbert. При кожній рекурсії вона викликає себе чотири рази. Якщо T (N) - це число викликів процедури, коли вона викликається з глибиною рекурсії N, то:


T (1) = 1

T (N) = 1 + 4 * T (N - 1) для N> 1.


Якщо розкрити визначення T (N), отримаємо:


T (N) = 1 + 4 * T (N - 1)

= 1 + 4 * (1 + 4 * T (N - 2))

= 1 + 4 + 16 * T (N - 2)

= 1 + 4 + 16 * (1 + 4 * T (N - 3))

= 1 + 4 + 16 + 64 * T (N - 3)

= ...

= 40 + 41 + 42 + 43 + ... + 4K * T (N - K)


Розкривши це рівняння до тих пір, поки не буде виконана умова зупинки рекурсії T (1) = 1, отримаємо:


T (N) = 4 0 + 4 1 + 4 2 + 4 3 + ... + 4 N-1


Це рівняння можна спростити, скориставшись співвідношенням:


X 0 + X 1 + X 2 + X 3 + ... + X M = (X M +1 - 1) / (X - 1)


Після перетворення, рівняння приводиться до вигляду:


T (N) = (4 (N-1) +1 - 1) / (4 - 1)

= (4 N - 1) / 3


===== 90


З точністю до постійних, ця процедура виконується за час порядку O (4 N). У табл. 5.5 наведено кілька перших значень функції часу виконання. Якщо ви уважно подивитеся на ці числа, то побачите, що вони відповідають рекурсивному визначенням.

Цей алгоритм є типовим прикладом рекурсивного алгоритму, який виконується за час порядку O (C N), де C - деяка постійна. При кожному виклику підпрограми Hilbert, вона збільшує розмірність задачі в 4 рази. У загальному випадку, якщо при кожному виконанні деякого числа кроків алгоритму розмір задачі збільшується не менш, ніж в C раз, то час виконання алгоритму буде порядку O (C N).

Це поведінка протилежно поведінки алгоритму пошуку найбільшого загального дільника. Процедура GCD зменшує розмірність задачі в 2 рази при кожному другому своєму виклик, і тому час її виконання порядку O (log (N)). Процедура побудови кривих Гільберта збільшує розмір задачі в 4 рази при кожному своєму виклик, тому час її виконання порядку O (4 N).

Функція (4 N -1) / 3 - це експонентна функція, яка зростає дуже швидко. Фактично, вона зростає настільки швидко, що ви можете припустити, що це не дуже ефективний алгоритм. Насправді робота цього алгоритму займає багато часу, але є дві причини, за якими це не так вже й погано.

По-перше, жоден алгоритм для побудови кривих Гільберта не може бути набагато швидше. Криві Гільберта містять безліч відрізків ліній, і будь-який малює їхній алгоритм буде вимагати досить багато часу. При кожному виклику процедури Hilbert, вона малює три лінії. Нехай L (N) - сумарна кількість ліній, з яких складається крива Гільберта порядку N. Тоді L (N) = 3 * T (N) = 4N - 1, тому L (N) також порядку O (4N). Будь-який алгоритм, який малює криві Гільберта, повинен вивести O (4N) ліній, виконавши при цьому O (4N) кроків. Існують інші алгоритми побудови кривих Гільберта, але вони займають майже стільки ж часу, скільки і цей алгоритм.


@ Таблиця 5.5. Число рекурсивних викликів підпрограми Hilbert


===== 91


Другий факт, який показує, що цей алгоритм не так уже й поганий, полягає в тому, що криві Гільберта 9 порядку містять так багато ліній, що екран більшості комп'ютерних моніторів при цьому виявляється повністю зафарбовані. Це не дивно, тому що ця крива містить 262.143 відрізків ліній. Це означає, що вам ймовірно ніколи не знадобиться виводити на екран криві Гільберта 9 або більш високих порядків. На якомусь порядку ви зіштовхнетеся з обмеженнями мови Visual Basic і вашого комп'ютера, але, швидше за все, ви ще раніше будете обмежені максимальним дозволом екрану.

Програма Hilbert, показана на рис. 5.4, ​​використовує цей рекурсивний алгоритм для малювання кривих Гільберта. При виконанні програми не ставте дуже велику глибину рекурсії (більше 6) до тих пір, поки ви не визначите, наскільки швидко виконується ця програма на вашому комп'ютері.

Рекурсивне побудова кривих Серпінського

Як і криві Гільберта, криві Серпінського (Sierpinski curves) - це самоподібні криві, які зазвичай визначаються рекурсивно. На рис. 5.5 показані криві Серпінського 1, 2 і 3 порядку.

Алгоритм побудови кривих Гільберта використовує всього одну підпрограму для малювання кривих. Криві Серпінського простіше малювати, використовуючи чотири окремі процедури, які працюють спільно. Ці процедури називаються SierpA, SierpB, SierpC і SierpD. Це процедури з непрямою рекурсією - кожна процедура викликає інші, які потім викликають первісну процедуру. Вони малюють верхню, ліву, нижню праву частини кривої Серпінського, відповідно.

На рис. 5.6 показано, як ці процедури працюють спільно, утворюючи криву Серпінського 1 порядку. Подкрівие зображені стрілками, щоб показати напрямок, в якому вони малюються. Відрізки, що сполучають чотири подкрівие, намальовані пунктирними лініями.


@ Рис. 5.4. Програма Hilbert


===== 92


@ Рис. 5.5. Криві Серпінського


Кожна з чотирьох основних кривих складається з діагонального відрізка, потім вертикального або горизонтального відрізка, і ще одного діагонального відрізка. Якщо глибина рекурсії більше одиниці, кожна з цих кривих розбивається на менші частини. Це здійснюється розбивкою кожного з двох діагональних відрізків на дві подкрівие.

Наприклад, для розбиття кривою типу A, перший діагональний відрізок розбивається на криву типу A, за якою слідує крива типу B. Потім малюється без змін горизонтальний відрізок з вихідної кривої типу A. Нарешті, другий діагональний відрізок розбивається на криву типу D, за якою слідує крива типу A. На рис. 5.7 показано, як крива типу A другого порядку утворюється з декількох кривих 1 порядку. Подкрівие зображені жирними лініями.

На рис. 5.8 показано, як повна крива Серпінського 2 порядку утворюється з 4 подкрівих 1 порядку. Кожна з подкрівих обведена контурною лінією.

Можна використовувати стрілки  і  для позначення типу ліній, що з'єднують подкрівие (тонкі лінії на рис. 5.8), тоді можна буде зобразити рекурсивні відносини між чотирма типами кривих так, як це показано на рис. 5.9.


@ Рис. 5.6. Частини кривої Серпінського


===== 93


@ Рис. 5.7. Розбиття кривою типу A


Всі процедури для побудови подкрівих Серпінського дуже схожі, тому ми наводимо тут тільки одну з них. Співвідношення на рис. 5.9 показують, які операції потрібно виконати для малювання кривих різних типів. Співвідношення для кривою типу A реалізовані в наступному коді. Ви можете використовувати інші співвідношення, щоб визначити, які зміни потрібно внести в код для малювання кривих інших типів.


Private Sub SierpA (Depth As Integer, Dist As Single)

If Depth = 1 Then

Line-Step (-Dist, Dist)

Line-Step (-Dist, 0)

Line-Step (-Dist,-Dist)

Else

SierpA Depth - 1, Dist

Line-Step (-Dist, Dist)

SierpB Depth - 1, Dist

Line-Step (-Dist, 0)

SierpD Depth - 1, Dist

Line-Step (-Dist,-Dist)

SierpA Depth - 1, Dist

End If

End Sub


@ Рис. 5.8. Криві Серпінського, утворені з менших кривих Серпінського


===== 94


@ Рис. 5.9. Рекурсивні співвідношення між кривими Серпінського


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


Sub Sierpinski (Depth As Integer, Dist As Single)

SierpB Depth, Dist

Line-Step (Dist, Dist)

SierpC Depth, Dist

Line-Step (Dist,-Dist)

SierpD Depth, Dist

Line-Step (-Dist,-Dist)

SierpA Depth, Dist

Line-Step (-Dist, Dist)

End Sub


Аналіз часу виконання програми

Щоб проаналізувати час виконання цього алгоритму, необхідно визначити число викликів для кожної з чотирьох процедур малювання кривих. Нехай T (N) - число викликів будь-який з чотирьох основних підпрограм основної процедури Sierpinski при побудові кривої порядку N.

Якщо порядок кривої дорівнює 1, крива кожного типу малюється тільки один раз. Додавши сюди основну процедуру, отримаємо T (1) = 5.

При кожному рекурсивному виклику, процедура викликає саму себе або інші процедури чотири рази. Так як ці процедури практично однакові, то T (N) буде однаковим, незалежно від того, яка процедура викликається першої. Це обумовлено тим, що криві Серпінського симетричні і містять одне і те ж число кривих різних типів. Рекурсивні рівняння для T (N) виглядають так:


T (1) = 5

T (N) = 1 + 4 * T (N-1) для N> 1.


Ці рівняння майже збігаються з рівняннями, які використовувалися для оцінки часу виконання алгоритму, що малює криві Гільберта. Єдина відмінність полягає в тому, що для кривих Гільберта T (1) = 1. Порівняння значень цих рівнянь показує, що T Sierpinski (N) = T Hilbert (N +1). У кінці попереднього розділу було показано, що T Hilbert (N) = (4 N - 1) / 3, тому T Sierpinski (N) = (4 N +1 - 1) / 3, що також становить O (4 N).


===== 95


Так само, як і алгоритм побудови кривих Гільберта, цей алгоритм виконується за час порядку O (4 N), але це не так вже й погано. Крива Серпінського складається з O (4 N) ліній, тому жоден алгоритм не може намалювати криву Серпінського швидше, ніж за час порядку O (4 N).

Криві Серпінського також повністю заповнюють екран більшості комп'ютерів при порядку кривої, більшому або рівному 9. При якому то порядку, більшому 9, ви зіштовхнетеся з обмеженнями мови Visual Basic і можливостей вашого комп'ютера, але, швидше за все, ви ще раніше будете обмежені граничним розміром екрану.

Програма Sierp, показана на рис. 5.10, використовує цей рекурсивний алгоритм для малювання кривих Серпінського. При виконанні програми, задавайте спочатку невелику глибину рекурсії (менше 6), до тих пір, поки ви не визначите, наскільки швидко виконується ця програма на вашому комп'ютері.

Небезпеки рекурсії

Рекурсія може служити потужним методом розбиття великих завдань на частини, але вона таїть у собі кілька небезпек. У цьому розділі ми намагаємося охопити деякі з цих небезпек і пояснити, коли варто і не варто використовувати рекурсію. У наступних розділах наводяться методи усунення від рекурсії, коли це необхідно.

Нескінченна рекурсія

Найбільш очевидна небезпека рекурсії полягає в нескінченній рекурсії. Якщо неправильно побудувати алгоритм, то функція може пропустити умова зупинки рекурсії та виконуватися нескінченно. Найпростіше зробити цю помилку, якщо просто забути про перевірку умови зупинки, як це зроблено в наступній помилковою версії функції факторіалу. Оскільки функція не перевіряє, чи досягнута умова зупинки рекурсії, вона буде нескінченно викликати сама себе.


@ Рис. 5.10 Програма Sierp


===== 96


Private Function BadFactorial (num As Integer) As Integer

BadFactorial = num * BadFactorial (num - 1)

End Function


Функція також може викликати себе нескінченно, якщо умова зупинки не припиняє всі можливі шляхи рекурсії. У наступній помилковою версії функції факторіалу, функція буде нескінченно викликати себе, якщо вхідний значення - не ціле число, або якщо воно менше 0. Ці значення не є допустимими вхідними значеннями для функції факторіалу, тому в програмі, яка використовує цю функцію, може знадобитися перевірка вхідних значень. Тим не менш, буде краще, якщо функція виконає цю перевірку сама.


Private Function BadFactorial2 (num As Double) As Double

If num = 0 Then

BadFactorial2 = 1

Else

BadFactorial2 = num * BadFactorial2 (num-1)

End If

End Function


Наступна версія функції Fibonacci є більш складним прикладом. У ній умова зупинки рекурсії припиняє виконання лише кількох шляхів рекурсії, і виникають ті ж проблеми, що і при виконанні функції BadFactorial2, якщо вхідні значення негативні або не цілі.


Private Function BadFib (num As Double) As Double

If num = 0 Then

BadFib = 0

Else

BadFib = BadPib (num - 1) + BadFib (num - 2)

End If

End Function


І остання проблема, пов'язана з нескінченною рекурсією, полягає в тому, що «нескінченна» насправді означає «до тих пір, поки не буде вичерпано стекової простір». Навіть коректно написані рекурсивні процедури будуть іноді приводити до переповнення стека і аварійного завершення роботи. Наступна функція, яка обчислює суму N + (N - 1) + ... + 2 +1, призводить до вичерпання стекового простору при великих значеннях N. Найбільше можливе значення N, при якому програма ще буде працювати, залежить від конфігурації вашого комп'ютера.


Private Function BigAdd (N As Double) As Double

If N <= 1 Then

BigAdd = 1

Else

BigAdd = N + BigAdd (N - 1)

End If

End Function


===== 97


Програма BigAdd демонструє цей алгоритм. Перевірте, наскільки великий вхідний значення ви можете ввести в цій програмі до того, як настане переповнення стека на вашому комп'ютері.

Втрати пам'яті

Інша небезпека рекурсії полягає у втратах пам'яті. При кожному виклику підпрограми, система виділяє пам'ять для локальних змінних нової процедури. Під час складної послідовності рекурсивних викликів, значна частина часу і пам'яті комп'ютера буде йти на виділення і звільнення пам'яті для цих змінних під час рекурсії. Навіть якщо це не призведе до вичерпання стекового простору, час, витрачений на роботу зі змінними, може бути значним.

Існує кілька способів зменшення цих накладних витрат. По-перше, не слід використовувати великої кількості непотрібних змінних. Навіть якщо підпрограма не використовує їх, Visual Basic все одно буде відводити пам'ять під ці змінні. Наступна версія функції BigAdd ще швидше призводить до переповнення стека, ніж попередня.


Private Function BigAdd (N As Double) As Double

Dim I1 As Integer

Dim I2 As Integer

Dim I3 As Integer

Dim I4 As Integer

Dim I5 As Integer


If N <= 1 Then

BigAdd = 1

Else

BigAdd = N + BigAdd (N - 1)

End If

End Function


Якщо ви не впевнені, чи потрібна змінна, використовуйте оператор Option Explicit і закоментуйте визначення змінної. При спробі виконати програму, Visual Basic повідомить про помилку, якщо змінна використовується в програмі.

Ви також можете зменшити використання стека за рахунок застосування глобальних змінних. Якщо ви визначите змінні в секції Declarations модуля замість того, щоб визначати в підпрограмі, то системі не знадобиться відводити пам'ять при кожному виклику підпрограми.

Кращим рішенням буде визначення змінних у процедурі за допомогою зарезервованого слова Static. Статичні змінні використовуються спільно всіма примірниками процедури, і системі не потрібно відводити пам'ять під нові копії змінних при кожному виклику підпрограми.

Необгрунтоване застосування рекурсії

Менш очевидною небезпекою є необгрунтоване застосування рекурсії. При цьому використання рекурсії не є найкращим способом вирішення завдання. Наведені вище функції факторіалу, найбільшого загального дільника, чисел Фібоначчі і функції BigAdd не обов'язково повинні бути рекурсивними. Кращі, не рекурсивні версії цих функцій описуються пізніше в цій главі.


===== 98


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

З іншого боку, застосування рекурсії погіршує алгоритм обчислення чисел Фібоначчі. Для обчислення Fib (N), алгоритм спочатку обчислює Fib (N - 1) та Fib (N - 2). Але для обчислення Fib (N - 1) він повинен спочатку обчислити Fib (N - 2) і Fib (N - 3). При цьому Fib (N - 2) обчислюється двічі.

Попередній аналіз цього алгоритму показав, що Fib (1) і Fib (0) обчислюються Fib (N + 1) саме під час обчислення Fib (N). Так як Fib (30) = 832.040 те, щоб обчислити Fib (29), доводиться обчислювати одні й ті ж значення Fib (0) і Fib (1) 832.040 разів. Алгоритм обчислення чисел Фібоначчі витрачає величезну кількість часу на обчислення цих проміжних значень знову і знову.

У функції BigAdd існує інша проблема. Хоча вона виконується швидко, вона призводить до великої глибині вкладеності рекурсії, і дуже швидко призводить до вичерпання стекового простору. Якщо б не переповнення стека, то ця функція могла б обчислювати результати для великих вхідних значень.

Схожа проблема існує і у функції факторіалу. Для вхідного значення N глибина рекурсії для факторіала і функції BigAdd дорівнює N. Функція факторіала не може бути обчислена для таких великих вхідних значень, які допустимі для функції BigAdd. Максимальне значення факторіала, яке може вміститися у змінній типу double, так само 170!  7,257 E +306, тому це найбільше значення, яке може обчислити ця функція. Хоча ця функція призводить до глибокої рекурсії, вона викликає переповнення до того, як настане переповнення стека.

Коли потрібно використовувати рекурсію

Ці міркування можуть змусити вас думати, що рекурсія завжди небажана. Але це безумовно не так. Багато алгоритми є рекурсивними за своєю природою. І хоча будь-який алгоритм можна переписати так, щоб він не містив рекурсії, багато алгоритмів складніше розуміти, аналізувати, налагоджувати і підтримувати, якщо вони написані нерекурсивний.

У наступних розділах наведено методи усунення рекурсії з будь-якого алгоритму. Деякі з отриманих нерекурсивних алгоритмів також прості в розумінні. Функції, що обчислюють без застосування рекурсії факторіал, найбільший загальний дільник, числа Фібоначчі, і функцію BigAdd, відносно прості.

З іншого боку, нерекурсівние версії алгоритмів побудов кривих Гільберта і Серпінського набагато складніше. Їх важче зрозуміти, підтримувати, і вони навіть виконуються трохи повільніше, ніж рекурсивні версії. Вони наведені лише для того, щоб продемонструвати методи, які ви можете використовувати для усунення рекурсії зі складних алгоритмів, а не тому, що вони краще, ніж рекурсивні версії відповідних алгоритмів.

Якщо алгоритм рекурсівен за природою, записуйте його з використанням рекурсії. У кращому випадку, ви не зустрінетеся жодної з описаних проблем. Якщо ж ви зіткнетеся з деякими з них, ви зможете переписати алгоритм без використання рекурсії за допомогою методів, представлених в наступних розділах. Переписати алгоритм часто набагато простіше, ніж з самого початку написати його без застосування рекурсії.


====== 99


Хвостова рекурсія

Згадаймо представлені раніше функції для обчислення факторіалів й найбільшого загального дільника, а також функцію BigAdd, яка призводить до переповнення стека навіть для відносно невеликих вхідних значень.


Private Function Factorial (num As Integer) As Integer

If num <= 0 Then

Factorial = 1

Else

Factorial = num * Factorial (num - 1)

End If

End Function


Private Function GCD (A As Integer, B As Integer) As Integer

If B Mod A = 0 Then

GCD = A

Else

GCD = GCD (B Mod A, A)

End If

End Function


Private Function BigAdd (N As Double) As Double

If N <= 1 Then

BigAdd = 1

Else

BigAdd = N + BigAdd (N - 1)

End If

End Function


У всіх цих функціях, останню дію перед завершенням функції - це рекурсивний крок. Цей тип рекурсії в кінці процедури називається хвостовою рекурсією (tail recursion або end recursion).

Так як після рекурсії в процедурі нічого не відбувається, існує простий спосіб її усунення. Замість рекурсивного виклику функції, процедура скидає свої параметри, встановлюючи ті, які б вона отримала при рекурсивному виклику, і потім виконується знову.

Розглянемо загальний випадок рекурсивної процедури:


Private Sub Recurse (A As Integer)

'Виконуються будь-які дії, обчислюється B, і т.д.

Recurse B

End Sub


====== 100


Цю процедуру можна переписати без рекурсії як:


Private Sub NoRecurse (A As Integer)

Do While (not done)

'Виконуються будь-які дії, обчислюється B, і т.д.

A = B

Loop

End Sub


Ця процедура називається усуненням хвостової рекурсії (tail recursion removal або end recursion removal). Цей прийом не змінює час виконання програми. Рекурсивні кроки просто замінюються проходами в циклі While.

Усунення хвостової рекурсії, тим не менш, усуває виклики підпрограм, і тому може збільшити швидкість роботи алгоритму. Що більш важливо, цей метод також зменшує використання стека. Алгоритми типу функції BigAdd, які обмежені глибиною рекурсії, можуть від цього значно виграти.

Деякі компілятори автоматично усувають хвостову рекурсію, але компілятор Visual Basic цього не робить. В іншому випадку, функція BigAdd, наведена в попередньому розділі, не призводила б до переповнення стека.

Використовуючи усунення хвостової рекурсії, легко переписати функції факторіалу, найбільшого загального дільника, і BigAdd без рекурсії. Ці версії використовують зарезервоване слово ByVal для збереження значень своїх параметрів для викликає процедури.


Private Function Factorial (ByVal N As Integer) As Double

Dim value As Double


value = 1 # 'Це буде значенням функції.

Do While N> 1

value = value * N

N = N - 1 'Підготувати аргументи для "рекурсії".

Loop

Factorial = value

End Function


Private Function GCD (ByVal A As Double, ByVal B As Double) As Double

Dim B_Mod_A As Double


B_Mod_A = B Mod A

Do While B_Mod_A <> 0

'Підготувати аргументи для "рекурсії".

B = A

A = B_Mod_A

B_Mod_A = B Mod A

Loop

GCD = A

End Function


Private Function BigAdd (ByVal N As Double) As Double

Dim value As Double


value = 1 # '' Це буде значенням функції.

Do While N> 1

value = value + N

N = N - 1 'підготувати параметри для "рекурсії".

Loop

BigAdd = value

End Function


===== 101


Для алгоритмів обчислення факторіала й найбільшого загального дільника практично не існує різниці між рекурсивної і нерекурсивний версіями. Обидві версії виконуються досить швидко, і обидві вони можуть оперувати завданнями великої розмірності.

Для функції BigAdd, тим не менше, різниця величезна. Рекурсивна версія призводить до переповнення стека навіть для досить невеликих вхідних значень. Оскільки нерекурсивний версія не використовує стек, вона може обчислювати результат для значень N аж до 10 154. Після цього наступить переповнення для даних типу double. Звичайно, виконання 10 154 кроків алгоритму займе дуже багато часу, тому можливо ви не станете перевіряти цей факт самі. Зауважимо також, що значення цієї функції збігається зі значенням більш просто обчислюється функції N * N (N + 1) / 2.

Програми Facto2, GCD2 і BigAdd2 демонструють ці нерекурсівние алгоритми.

Нерекурсівние обчислення чисел Фібоначчі

На жаль, нерекурсивний алгоритм обчислення чисел Фібоначчі не містить тільки хвостову рекурсію. Цей алгоритм використовує два рекурсивних виклику для обчислення значення, і другий виклик слід після завершення першого. Оскільки перший виклик не знаходиться в самому кінці функції, то це не хвостова рекурсія, і від її не можна позбутися, використовуючи прийом усунення хвостової рекурсії.

Це може бути пов'язано і з тим, що обмеження рекурсивного алгоритму обчислення чисел Фібоначчі пов'язано з тим, що він обчислює занадто багато проміжних значень, а не глибиною вкладеності рекурсії. Усунення хвостової рекурсії зменшує глибину рекурсії, але воно не змінює час виконання алгоритму. Навіть якщо б усунення хвостової рекурсії було б застосувати до алгоритму обчислення чисел Фібоначчі, цей алгоритм все одно залишився б надзвичайно повільним.

Проблема цього алгоритму в тому, що він багаторазово обчислює одні й ті ж значення. Значення Fib (1) і Fib (0) обчислюються Fib (N + 1) раз, коли алгоритм обчислює Fib (N). Для обчислення Fib (29), алгоритм обчислює одні й ті ж значення Fib (0) і Fib (1) 832.040 разів.

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


===== 102


У цьому прикладі можна створити таблицю для зберігання значень функції Фібоначчі Fib (N) для N, що не перевищують 1477. Для N> = 1477 відбувається переповнювання змінних типу double, використовуваних у функції. Наступний код містить змінену таким чином функцію, яка обчислює числа Фібоначчі.


Const MAX_FIB = 1476 'Максимальне значення.


Dim FibValues ​​(0 To MAX_FIB) As Double


Private Function Fib (N As Integer) As Double

'Обчислити значення, якщо воно не знаходиться в таблиці.

If FibValues ​​(N) <0 Then _

FibValues ​​(M) = Fib (N - 1) + Fib (N - 2)


Fib = FibValues ​​(N)

End Function


При запуску програми, вона привласнює кожному елементу в масиві FibValues ​​значення -1. Потім вона привласнює FibValues ​​(0) значення 0, і FibValues ​​(1) - значення 1. Це умови зупинки рекурсії.

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

Програма Fibo2 використовує цей метод для обчислення чисел Фібоначчі. Програма може швидко обчислити Fib (N) для N до 100 або 200. Але якщо ви спробуєте вирахувати Fib (1476), то програма виконає послідовність рекурсивних викликів глибиною 1476 рівнів, яка ймовірно переповнить стек вашої системи.

Тим не менше, у міру того, як програма обчислює нові значення, вона заповнює масив FibValues. Значення з масиву дозволяють функції обчислювати все більші і більші значення без глибокої рекурсії. Наприклад, якщо обчислити послідовно Fib (100), Fib (200), Fib (300), і т.д. то, врешті-решт, можна буде заповнити масив значень FibValues ​​і обчислити максимальне можливо значення Fib (1476).

Процес повільного заповнення масиву FibValues ​​призводить до нового методу обчислення чисел Фібоначчі. Коли програма ініціалізує масив FibValues, вона може заздалегідь обчислити всі числа Фібоначчі.


Private Sub InitializeFibValues ​​()

Dim i As Integer


FibValues ​​(0) = 0 'Ініціалізація умов зупинки.

FibValues ​​(1) = 1

For i = 2 To MAX_FIB

FibValues ​​(i) = FibValues ​​(i - 1) + FibValues ​​(i - 2)

Next i

End Sub


Private Function Fib (N As Integer) As Duble

Fib - FibValues ​​(N)

End Function


===== 104


Певний час у цьому алгоритмі займає складання масиву з табличними значеннями. Але після того як масив створений, для отримання елемента з масиву потрібно всього один крок. Ні процедура ініціалізації, ні функція Fib не використовують рекурсію, тому жодна з них не призведе до вичерпання стекового простору. Програма Fibo3 демонструє цей підхід.

Варто згадати ще один метод обчислення чисел Фібоначчі. Перше рекурсивне визначення функції Фібоначчі використовує підхід зверху вниз. Для отримання значення Fib (N), алгоритм рекурсивно обчислює Fib (N - 1) та Fib (N - 2) і потім складає їх.

Підпрограма InitializeFibValues, з іншого боку, працює знизу вгору. Вона починає зі значень Fib (0) і Fib (1). Вона потім використовує менші значення для обчислення великих, до тих пір, поки таблиця не заповниться.

Ви можете використовувати той же підхід знизу вгору для прямого обчислення значень функції Фібоначчі кожен раз, коли вам буде потрібно значення. Цей метод вимагає більше часу, ніж вибірка значень з масиву, але не вимагає додаткової пам'яті для таблиці значень. Це приклад просторово тимчасового компромісу. Використання більшого обсягу пам'яті для зберігання таблиці значень робить виконання алгоритму більш швидким.


Private Function Fib (N As Integer) As Double

Dim Fib_i_minus_1 As Double

Dim Fib_i_minus_2 As Double

Dim fib_i As Double

Dim i As Integer


If N <= 1 Then

Fib = N

Else

Fib_i_minus_2 = 0 'Спочатку Fib (0)

Fib_i_minus_1 = 1 'Спочатку Fib (1)

For i = 2 To N

fib_i = Fib_i_minus_1 + Fib_i_minus_2

Fib_i_minus_2 = Fib_i_minus_1

Fib_i_minus_1 = fib_i

Next i

Fib = fib_i

End If

End Function


Цієї версії потрібно порядку O (N) кроків для обчислення Fib (N). Це більше, ніж один крок, який був потрібний у попередній версії, але набагато швидше, ніж O (Fib (N)) кроків у вихідній версії алгоритму. На комп'ютері з процесором Pentium з тактовою частотою 90 МГц, вихідному рекурсивному алгоритмом знадобилося майже 52 секунди для обчислення Fib (32) = 2.178.309. Час обчислення Fib (1476)  1,31 E +308 за допомогою нового алгоритму дуже малий. Програма Fibo4 використовує цей метод для обчислення чисел Фібоначчі.


===== 105


Усунення рекурсії в загальному випадку

Функції факторіала, найбільшого загального дільника, і BigAdd можна спростити усуненням хвостової рекурсії. Функцію, яка обчислює числа Фібоначчі, можна спростити, використовуючи таблицю значень або переформулював завдання з використанням підходу знизу вгору.

Деякі рекурсивні алгоритми настільки складні, то застосування цих методів ускладнене або неможливе. Досить складно було б написати нерекурсивний алгоритм для побудови кривих Гільберта або Серпінського з нуля. Інші рекурсивні алгоритми більш прості.

Раніше було показано, що алгоритм, який малює криві Гільберта або Серпінського, повинен включати порядку O (N 4) кроків, так що вихідні рекурсивні версії досить хороші. Вони досягають майже максимальної можливої ​​продуктивності при прийнятній глибині рекурсії.

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

Основний підхід при цьому полягає в тому, щоб розглянути порядок виконання рекурсії на комп'ютері і потім спробувати зімітувати кроки, виконувані комп'ютером. Потім новий алгоритм буде сам здійснювати «рекурсію» замість того, щоб всю роботу виконував комп'ютер.

Оскільки новий алгоритм виконує практично ті ж кроки, що й комп'ютер, можна поцікавитися, чи зросте швидкість обчислень. У Visual Basic це звичайно не виконується. Комп'ютер може виконувати завдання, які потрібні при рекурсії, швидше, ніж ви можете їх імітувати. Тим не менш, оперування цими деталями самостійно забезпечує кращий контроль над виділенням пам'яті під локальні змінні, і дозволяє уникнути глибокого рівня вкладеності рекурсії.

Зазвичай, при виклику підпрограми, система виконує три речі. По-перше, зберігає дані, які потрібні їй для продовження виконання після завершення підпрограми. По друге, вона проводить підготовку до виклику підпрограми і передає їй управління. По-третє, коли викликається процедура завершується, система відновлює дані, збережені на першому кроці, і передає управління тому у відповідну точку програми. Якщо ви перетворюєте рекурсивну процедуру в нерекурсивний, вам доводиться виконувати ці три кроки самостійно.

Розглянемо наступну узагальнену рекурсивну процедуру:


Sub Subr (num)

<1 блок коду>

Subr (<параметри>)

<2 блок коду>

End Sub


Оскільки після рекурсивного кроку є ще оператори, ви не можете використовувати усунення хвостової рекурсії для цього алгоритму.


===== 105


Спочатку пометим перші рядки в 1 і 2 блоках коду. Потім ці мітки будуть використовуватися для визначення місця, з якого потрібно продовжити виконання при поверненні з «рекурсії». Ці мітки використовуються тільки для того, щоб допомогти вам зрозуміти, що робить алгоритм - вони не є частиною коду Visual Basic. У цьому прикладі мітки будуть виглядати так:


Sub Subr (num)

1 <1 блок коду>

Subr (<параметри>)

2 <2 блок коду>

End Sub


Використовуємо спеціальну мітку «0» для позначення кінця «рекурсії». Тепер можна переписати процедуру без використання рекурсії, наприклад, так:


Sub Subr (num)

Dim pc As Integer 'Визначає, де потрібно продовжити рекурсію.


pc = 1 'Почати спочатку.

Do

Select Case pc

Case 1

<1 блок коду>

If (досягнуто умова зупинки) Then

'Пропустити рекурсію і перейти до блоку 2.

pc = 2

Else

'Зберегти змінні, потрібні після рекурсії.

'Зберегти pc = 2. Точка, з якої продовжиться

'Виконання після повернення з "рекурсії".

'Встановити змінні, потрібні для рекурсії.

'Наприклад, num = num - 1.

:

'Перейти до блоку 1 для початку рекурсії.

pc = 1

End If

Case 2 'Виконати 2 блок коду

<2 блок коду>

pc = 0

Case 0

If (це остання рекурсія) Then Exit Do

'Інакше відновити pc та інші змінні,

'Збережені перед рекурсією.

End Select

Loop

End Sub


====== 106


Змінна pc, яка відповідає лічильнику програми, повідомляє процедурі, який крок вона повинна виконати наступним. Наприклад, при pc = 1, процедура повинна виконати 1 блок коду.

Коли процедура досягає умови зупинки, вона не виконує рекурсію. Замість цього, вона привласнює pc значення 2, і продовжує виконання 2 блоки коду.

Якщо процедура не досягла умови зупинки, вона виконує «рекурсію». Для цього вона зберігає значення всіх локальних змінних, які їй знадобляться пізніше після завершення «рекурсії». Вона також зберігає значення pc для ділянки коду, який вона буде виконувати після завершення «рекурсії». У цьому прикладі наступним виконується 2 блок коду, тому вона зберігає 2 в якості наступного значення pc. Найпростіший спосіб збереження значень локальних змінних і pc полягає у використанні стеків, подібних тим, які описувалися в 3 главі.

Реальний приклад допоможе вам зрозуміти цю схему. Розглянемо злегка змінену версію функції факторіалу. У ньому переписана тільки підпрограма, яка повертає своє значення за допомогою змінної, а не функції, для спрощення роботи.


Private Sub Factorial (num As Integer, value As Integer)

Dim partial As Integer

1 If num <= 1 Then

value = 1

Else

Factorial (num - 1, partial)

2 value = num * partial

End If

End Sub


Після повернення процедури з рекурсії, потрібно дізнатися вихідне значення змінної num, щоб виконати операцію множення value = num * partial. Оскільки процедурі потрібен доступ до значення num після повернення з рекурсії, вона повинна зберігати значення змінних pc і num до початку рекурсії.

Наступна процедура зберігає ці значення у двох стеках на основі масивів. При підготовці до рекурсії, вона проштовхує значення змінних num і pc в стеки. Після завершення рекурсії, вона виштовхує додані останніми значення з стеків. Наступний код демонструє нерекурсивний версію підпрограми обчислення факторіала.


Private Sub Factorial (num As Integer, value As Integer)

ReDim num_stack (1 to 200) As Integer

ReDim pc_stack (1 to 200) As Integer

Dim stack_top As Integer 'Вершина стека.

Dim pc As Integer


pc = 1

Do

Select Case pc

Case 1

If num <= 1 Then 'Ця умова зупинки. value = 1

pc = 0 'Кінець рекурсії.

Else 'Рекурсія.

'Зберегти num і таке значення pc.

stack_top = stack_top + 1

num_stack (stack_top) = num

pc_stack (stack_top) = 2 'Відновити з 2.

'Почати рекурсію.

num = num - 1

'Перенести блок керування в початок.

pc = 1

End If

Case 2

'Value містить результат останньої

'Рекурсії. Помножити його на num.

value = value * num

'"Повернення" з "рекурсії".

pc = 0

Case 0

'Кінець "рекурсії".

'Якщо стеки порожні, вихідний виклик

'Підпрограми завершений.

If stack_top <= 0 Then Exit Do

'Інакше відновити локальні змінні і pc.

num = num_stack (stack_top)

pc = pc_stack (stack_top)

stack_top = stacK_top - 1

End Select

Loop

End Sub


Так само, як і усунення хвостової рекурсії, цей метод імітує поведінку рекурсивного алгоритму. Процедура замінює кожен рекурсивний виклик итерацией циклу While. Оскільки число кроків залишається тим же самим, повний час виконання алгоритму не змінюється.

Так само, як і у випадку з усуненням хвостової рекурсії, цей метод усуває глибоку рекурсію, яка може переповнити стек.

Нерекурсівние побудова кривих Гільберта

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


======= 107-108


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

У якості більш цікавого прикладу, розглянемо нерекурсивний алгоритм побудови кривих Гільберта.


Private Sub Hilbert (depth As Integer, Dx As Single, Dy As Single)

If depth> 1 Then Hilbert depth - 1, Dy, Dx

HilbertPicture.Line-Step (Dx, Dy)

If depth> 1 Then Hilbert depth - 1, Dx, Dy

HilbertPicture.Line-Step (Dy, Dx)

If depth> 1 Then Hilbert depth - 1, Dx, Dy

HilbertPicture.Line-Step (-Dx,-Dy)

If depth> 1 Then Hilbert depth - 1,-Dy,-Dx

End Sub


У наступному фрагменті коду перші рядки кожного блоку коду між рекурсивними кроками пронумеровані. Ці блоки включають перший рядок процедури та будь-які інші точки, в яких може знадобитися продовжити виконання після повернення після «рекурсії».


Private Sub Hilbert (depth As Integer, Dx As Single, Dy As Single)

1 If depth> 1 Then Hilbert depth - 1, Dy, Dx

2 HilbertPicture.Line-Step (Dx, Dy)

If depth> 1 Then Hilbert depth - 1, Dx, Dy

3 HilbertPicture.Line-Step (Dy, Dx)

If depth> 1 Then Hilbert depth - 1, Dx, Dy

4 HilbertPicture.Line-Step (-Dx,-Dy)

If depth> 1 Then Hilbert depth - 1,-Dy,-Dx

End Sub


Кожного разу, коли нерекурсивний процедура починає «рекурсію», вона повинна зберігати значення локальних змінних Depth, Dx, і Dy, а також таке значення змінної pc. Після повернення з «рекурсії», вона відновлює ці значення. Для спрощення роботи, можна написати пару допоміжних процедур для заталківанія та виштовхування цих значень з декількох стеків.


==== 109


Const STACK_SIZE = 20

Dim DepthStack (0 To STACK_SIZE)

Dim DxStack (0 To STACK_SIZE)

Dim DyStack (0 To STACK_SIZE)

Dim PCStack (0 To STACK_SIZE)

Dim TopOfStack As Integer


Private Sub SaveValues ​​(Depth As Integer, Dx As Single, _

Dy As Single, pc As Integer)

TopOfStack = TopOfStack + 1

DepthStack (TopOfStack) = Depth

DxStack (TopOfStack) = Dx

DyStack (TopOfStack) = Dy

PCStack (TopOfStack) = pc

End Sub


Private Sub RestoreValues ​​(Depth As Integer, Dx As Single, _

Dy As Single, pc As Integer)

Depth = DepthStack (TopOfStack)

Dx = DxStack (TopOfStack)

Dy = DyStack (TopOfStack)

pc = PCStack (TopOfStack)

TopOfStack = TopOfStack - 1


End Sub


Наступний код демонструє нерекурсивний версію підпрограми Hilbert.


Private Sub Hilbert (Depth As Integer, Dx As Single, Dy As Single)

Dim pc As Integer

Dim tmp As Single


pc = 1

Do

Select Case pc

Case 1

If Depth> 1 Then 'Рекурсія.

'Зберегти поточні значення.

SaveValues ​​Depth, Dx, Dy, 2

'Підготуватися до рекурсії.

Depth = Depth - 1

tmp = Dx

Dx = Dy

Dy = tmp

pc = 1 'Перейти на початок рекурсивного виклику.

Else 'Умова зупинки.

'Досить глибокий рівень рекурсії.

'Продовжити з 2 блоком коду.

pc = 2

End If

Case 2

HilbertPicture.Line-Step (Dx, Dy)

If Depth> 1 Then 'Рекурсія.

'Зберегти поточні значення.

SaveValues ​​Depth, Dx, Dy, 3

'Підготуватися до рекурсії.

Depth = Depth - 1

'Dx і Dy залишаються без змін.

pc = 1 Перейти на початок рекурсивного виклику.

Else 'Умова зупинки.

'Досить глибокий рівень рекурсії.

'Продовжити з 3 блоком коду.

pc = 3

End If

Case 3

HilbertPicture.Line-Step (Dy, Dx)

If Depth> 1 Then 'Рекурсія.

'Зберегти поточні значення.

SaveValues ​​Depth, Dx, Dy, 4

'Підготуватися до рекурсії.

Depth = Depth - 1

'Dx і Dy залишаються без змін.

pc = 1 Перейти на початок рекурсивного виклику.

Else 'Умова зупинки.

'Досить глибокий рівень рекурсії.

'Продовжити з 4 блоком коду.

pc = 4

End If

Case 4

HilbertPicture.Line-Step (-Dx,-Dy)

If Depth> 1 Then 'Рекурсія.

'Зберегти поточні значення.

SaveValues ​​Depth, Dx, Dy, 0

'Підготуватися до рекурсії.

Depth = Depth - 1

tmp = Dx

Dx =-Dy

Dy =-tmp

pc = 1 Перейти на початок рекурсивного виклику.

Else 'Умова зупинки.

'Досить глибокий рівень рекурсії.

'Кінець цього рекурсивного виклику.

pc = 0

End If

Case 0 'Повернення з рекурсії.

If TopOfStack> 0 Then

RestoreValues ​​Depth, Dx, Dy, pc

Else

'Стек порожній. Вихід.

Exit Do

End If

End Select

Loop

End Sub


====== 111


Час виконання цього алгоритму може бути нелегко оцінити безпосередньо. Оскільки методи перетворення рекурсивних процедур у нерекурсівние не змінюють час виконання алгоритму, ця процедура так само, як і попередня версія, має час виконання порядку O (N 4).

Програма Hilbert2 демонструє нерекурсивний алгоритм побудови кривих Гільберта. Задавайте спочатку побудова нескладних кривих (менше 6 порядку), поки не дізнаєтеся, наскільки швидко буде виконуватися ця програма на вашому комп'ютері.

Нерекурсівние побудова кривих Серпінського

Наведений раніше алгоритм побудови кривих Серпінського включає в себе непряму та множинну рекурсію. Так як алгоритм складається з чотирьох підпрограм, які викликають один одного, то не можна просто пронумерувати важливі рядки, як це можна було зробити у разі алгоритму побудови кривих Гільберта. З цією проблемою можна впоратися, дещо змінивши алгоритм.

Рекурсивна версія цього алгоритму складається з чотирьох підпрограм SierpA, SierpB, SierpC і SierpD. Підпрограма SierpA виглядає так:


Private Sub SierpA (Depth As Integer, Dist As Single)

If Depth = 1 Then

Line-Step (-Dist, Dist)

Line-Step (-Dist, 0)

Line-Step (-Dist,-Dist)

Else

SierpA Depth - 1, Dist

Line-Step (-Dist, Dist)

SierpB Depth - 1, Dist

Line-Step (-Dist, 0)

SierpD Depth - 1, Dist

Line-Step (-Dist,-Dist)

SierpA Depth - 1, Dist

End If

End Sub


Три інші процедури аналогічні. Нескладно об'єднати ці чотири процедури в одну підпрограму.


Private Sub SierpAll (Depth As Integer, Dist As Single, Func As Integer)

Select Case Punc

Case 1 'SierpA

<Код SierpA code>

Case 2 'SierpB

<Код SierpB>

Case 3 'SierpC

<Код SierpC>

Case 4 'SierpD

<Код SierpD>

End Select

End Sub


====== 112


Параметр Func повідомляє підпрограмі, який блок коду виконувати. Виклики підпрограм замінюються на виклики процедури SierpAll з відповідним значенням Func. Наприклад, виклик підпрограми SierpA заміняється на виклик процедури SierpAll з параметром Func, рівним 1. Таким же чином замінюються виклики підпрограм SierpB, SierpC і SierpD.

Отримана процедура рекурсивно викликає себе в 16 різних точках. Ця процедура набагато складніше, ніж процедура Hilbert, але в інших відносинах вона має таку ж структуру і тому до неї можна застосувати ті ж методи усунення рекурсії.

Можна використовувати першу цифру міток pc, для визначення номера блоку коду, який повинен виконуватися. Перенумеруем рядки в коді SierpA числами 11, 12, 13 і т.д. Перенумеруем рядки в коді SierpB числами 21, 22, 23 і т.д.

Тепер можна пронумерувати ключові рядки коду всередині кожного з блоків. Для коду підпрограми SierpA ключовими рядками будуть:


'Код SierpA.

11 If Depth = 1 Then

Line-Step (-Dist, Dist)

Line-Step (-Dist, 0)

Line-Step (-Dist,-Dist)

Else

SierpA Depth - 1, Dist

12 Line-Step (-Dist, Dist)

SierpB Depth - 1, Dist

13 Line-Step (-Dist, 0)

SierpD Depth - 1, Dist

14 Line-Step (-Dist,-Dist)

SierpA Depth - 1, Dist

End If


Типова «рекурсія» з коду підпрограми SierpA в код підпрограми SierpB виглядає так:


SaveValues ​​Depth, 13 'Продовжити з кроку 13 після завершення.

Depth = Depth - 1

pc = 21 'Передати керування на початок коду SierpB.


====== 113


Мітка 0 зарезервована для позначення виходу з «рекурсії». Наступний код демонструє нерекурсивний версію процедури SierpAll. Код для підпрограм SierpB, SierpC, і SierpD аналогічний коду для SierpA, тому він опущений.


Private Sub SierpAll (Depth As Integer, pc As Integer)

Do

Select Case pc

'**********

'* SierpA *

'**********

Case 11

If Depth <= 1 Then

SierpPicture.Line-Step (-Dist, Dist)

SierpPicture.Line-Step (-Dist, 0)

SierpPicture.Line-Step (-Dist,-Dist)

pc = 0

Else

SaveValues ​​Depth, 12 'Виконати SierpA

Depth = Depth - 1

pc = 11

End If

Case 12

SierpPicture.Line-Step (-Dist, Dist)

SaveValues ​​Depth, 13 'Виконати SierpB

Depth = Depth - 1

pc = 21

Case 13

SierpPicture.Line-Step (-Dist, 0)

SaveValues ​​Depth, 14 'Виконати SierpD

Depth = Depth - 1

pc = 41

Case 14

SierpPicture.Line-Step (-Dist,-Dist)

SaveValues ​​Depth, 0 'Виконати SierpA

Depth = Depth - 1

pc = 11


'Код для SierpB, SierpC і SierpD опущений.

:


'*******************

'* Кінець рекурсії. *

'*******************

Case 0

If TopOfStack <= 0 Then Exit Do

RestoreValues ​​Depth, pc

End Select

Loop

End Sub


===== 114

Так само, як і у випадку з алгоритмом побудови кривих Гільберта, перетворення алгоритму побудови кривих Серпінського в нерекурсивний форму не змінює час виконання алгоритму. Нова версія алгоритму імітує рекурсивний алгоритм, який виконується за час порядку O (N 4), тому порядок часу виконання нової версії також становить O (N 4). Вона виконується трохи повільніше, ніж рекурсивна версія, і є набагато більш складною.

Нерекурсівние версія також могла б малювати криві більш високих порядків, але побудова кривих Серпінського з порядком вище 8 або 9 непрактично. Всі ці факти визначають перевагу рекурсивного алгоритму.

Програма Sierp2 використовує цей нерекурсивний алгоритм для побудови кривих Серпінського. Задавайте спочатку побудова нескладних кривих (менше 6 порядку), поки не визначите, наскільки швидко буде виконуватися ця програма на вашому комп'ютері.

Резюме

При застосуванні рекурсивних алгоритмів слід уникати трьох основних небезпек:

  • Нескінченної рекурсії. Переконайтеся, що умови зупинки вашого алгоритму припиняють усі рекурсивні шляху.

  • Глибокої рекурсії. Якщо алгоритм досягає занадто великої глибини рекурсії, він може призвести до переповнення стека. Мінімізуйте використання стека за рахунок зменшення числа визначених у процедурі змінних, використання глобальних змінних, або визначення змінних як статичних. Якщо процедура все одно призводить до переповнення стека, перепишіть алгоритм у нерекурсивним вигляді, використовуючи усунення хвостової рекурсії.

  • Непотрібної рекурсії. Зазвичай це відбувається, якщо алгоритм типу рекурсивного обчислення чисел Фібоначчі, багаторазово обчислює одні й ті ж проміжні значення. Якщо ви зіштовхнетеся з цією проблемою у своїй програмі, спробуйте переписати алгоритм, використовуючи підхід знизу вгору. Якщо алгоритм не дозволяє вдатися до підходу знизу вгору, створіть таблицю проміжних значень.

Застосування рекурсії не завжди неправильно. Багато задач є рекурсивними за своєю природою. У цих випадках рекурсивний алгоритм буде простіше зрозуміти, налагоджувати і підтримувати, ніж його нерекурсивний версію. Як приклад можна навести алгоритми побудови кривих Гільберта і Серпінського. Обидва за своєю природою рекурсивно і набагато зрозуміліше, ніж їх нерекурсівние модифікації. При цьому рекурсивні версії навіть виконуються трохи швидше.

Якщо у вас є алгоритм, який рекурсівен за своєю природою, але ви не впевнені, чи буде рекурсивна версія позбавлена ​​проблем, запишіть алгоритм у рекурсивному вигляді і з'ясуєте це. Може бути, проблеми не виникнуть. Якщо ж вони виникнуть, то, можливо, виявиться простіше перетворити цю рекурсивну версію у нерекурсивний, ніж написати нерекурсивний версію з нуля.


====== 115


Глава 6. Дерева

У 2 чолі наводилися способи створення динамічних зв'язкових структур, таких, як зображені на рис 6.1. Такі структури даних називаються графами (graphs). У 12 главі алгоритми роботи з графами і мережами обговорюються більш детально. У цій главі розглядаються графи особливого типу, які називаються деревами (trees).

На початку цієї глави наводиться визначення дерева і роз'яснюються деякі терміни. Потім в ній описуються деякі методи реалізації дерев різних типів мовою Visual Basic. У наступних розділах розглядається декілька алгоритмів обходу для дерев, записаних у цих різних форматах. Глава закінчується обговоренням деяких спеціальних типів дерев, включаючи впорядковані дерева (sorted trees), дерева з посиланнями (threaded trees), бори (tries) і квадродерево (quadtrees).

У 7 і 8 чолі обговорюються більш складні теми - збалансовані дерева і дерева рішень.


@ Рис. 6.1. Графи


===== 117


Визначення

Можна рекурсивно визначити дерево як:

  • Порожню структуру або

  • Вузол, званий коренем (node) дерева, пов'язаний з нулем або більше піддерев (subtrees).

На рис. 6.2 показано дерево. Кореневий вузол A пов'язаний з трьома піддеревами, що починаються у вузлах B, C і D. Ці вузли пов'язані з піддеревами з корінням E, F і G, і ці вузли, у свою чергу пов'язані з піддеревами з корінням H, I і J.

Термінологія дерев представляє собою суміш термінів, запозичених з ботаніки та генеалогії. З ботаніки прийшли терміни, такі як вузол (node), який визначається як точка, в якій може починатися розгалуження, гілка (branch), що визначається як зв'язок між двома вузлами, і лист (leaf) - вузол, з якого не виходять інші гілки.

З генеалогії прийшли терміни, які описують спорідненість. Якщо один вузол знаходиться безпосередньо над іншим, верхній вузол називається батьком (parent), а нижній дочірнім вузлом (child). Вузли на шляху вгору від вузла до кореня називаються предками (ancestors) вузла. Наприклад, на рис. 6.2 вузли E, B і A - це все предки вузла I.

Вузли, які знаходяться нижче якого або вузла дерева, називаються нащадками (descendants) цього вузла. Вузли E, H, I і J на ​​рис. 6.2 - це всі нащадки вузла B.

Іноді вузли, що мають одного батька, називаються вузлами братами або сестрами вузлами (sibling nodes).

Існує ще кілька термінів, які не прийшли з ботаніки або генеалогії. Внутрішнім вузлом (internal node) називається вузол, який не є листом. Порядком вузла (node ​​degree) називається число його дочірніх вузлів. Порядок дерева - це найбільший порядок його вузлів. Дерево на рис. 6.2 - третього порядку, тому що вузли з найбільшим порядком, вузли A і E, мають по 3 дочірніх вузла.

Глибина (depth) дерева дорівнює числу його предків плюс 1. На рис. 6.2 глибина вузла E дорівнює 3. Глибиною (depth) або висотою (height) дерева називається найбільша глибина його вузлів. Глибина дерева на рис. 6.2 дорівнює 4.

Дерево 2 порядку називається двійковим деревом (binary tree). Дерева третього порядку іноді називаються трійковими (ternary) деревами. Більш того, дерева порядку N іноді називаються N ічнимі (N ary) деревами.


@ Рис. 6.2. Дерево


====== 118


Дерево близько 12, наприклад, називається 12 ковий (12 ary) деревом, а не додекадерічним (dodecadary) деревом. Деякі уникають вживання зайвих термінів і просто кажуть «дерева 12 порядку».

Рис. 6.3 ілюструє деякі з цих термінів.

Уявлення дерев

Тепер, коли ви познайомилися з термінологією, ви можете уявити собі способи реалізації дерев на мові Visual Basic. Один зі способів - створити окремий клас для кожного типу вузлів дерева. Для побудови дерева, показаного на рис. 6.3, ви можете визначити структури даних для вузлів, які мають нуль, один, два або три дочірніх вузла. Цей підхід був би досить незручним. Крім того, що потрібно було б управляти чотирма різними класами, в класах було б потрібно якісь прапори, які б вказували тип дочірніх вузлів. Алгоритми, які оперували б цими деревами, повинні були б уміти працювати з усім різними типами дерев.

Повні вузли

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

Дерево, зображене на рис 6.3, має 3 порядок. Для побудови цього дерева з використанням методу повних вузлів (fat nodes), потрібно визначити єдиний клас, який містить покажчики на три дочірніх вузла. Наступний код демонструє, як ці покажчики можуть бути визначені в класі TernaryNode.


Public LeftChild As TernaryNode

Public MiddleChild As TernaryNode

Public RightChild As TernaryNode


@ Рис. 6.3. Частини трійкового (3 порядку) дерева


====== 119


За допомогою цього класу можна побудувати дерево, використовуючи записи Child вузлів, для зв'язку їх один з одним. Наступний фрагмент коду будує два верхніх рівня дерева, показаного на рис. 6.3.


Dim A As New TernaryNode

Dim B As New TernaryNode

Dim C As New TernaryNode

Dim D As New TernaryNode

:


Set A. LeftChild = B

Set A. MiddleChild = C

Set A. RightChild = D

:


Програма Binary, показана на рис. 6.4, використовує метод повних вузлів бітового деревом. Коли ви вибираєте вузол за допомогою миші, програма підсвічує кнопку Add Left (Додати ліворуч), якщо вузол не має лівого нащадка і кнопку Add Right (Додати праворуч), якщо вузол не має правої нащадка. Кнопка Remove (Видалити) розблокується, якщо обраний вузол не є кореневим. Якщо ви натиснете на кнопку Remove, програма видалить вузол і всіх його нащадків.

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

Списки нащадків

Якщо порядки вузлів у дереві сильно розрізняються, метод повних вузлів призводить до марному витрачанню великої кількості пам'яті. Щоб побудувати дерево, показане на рис. 6.5 з використанням повних вузлів, вам знадобиться визначити в кожному вузлі по шість покажчиків, хоча тільки в одному вузлі всі шість з них використовуються. Це подання дерева зажадає 72 покажчиків на дочірні вузли, з яких насправді буде використовуватися лише 11.


@ Рис. 6.4. Програма Binary


====== 120


Деякі програми додають і видаляють вузли, змінюючи порядок вузлів у процесі виконання. У цьому випадку метод повних вузлів не буде працювати. Такі динамічно змінюються дерева можна уявити, помістивши дочірні вузли до списків. Є кілька підходів, які можна використовувати для створення списків дочірніх вузлів. Найбільш очевидний підхід полягає у створенні в класі вузла відкритого (public) масиву дочірніх вузлів, як показано в наступному коді. Тоді для оперування дочірніми вузлами можна використовувати методи роботи зі списками на основі масивів.


Public Children () As TreeNode

Public NumChildren As Integer


На жаль, Visual Basic не дозволяє визначати відкриті масиви в класах. Це обмеження можна обійти, визначивши масив як закритий (private), і оперуючи елементами масиву за допомогою процедур властивостей.


Private m_Chirdren () As TreeNode

Private m_NumChildren As Integer


Property Get Children (Index As Integer) As TreeNode

Set Children = m_Children (Index)

End Property


Property Get NumChildren () As Integer

NumChildren = m_NumChildren ()

End Property


Другий підхід полягає в тому, щоб зберігати посилання на дочірні вузли в зв'язкових списках. Кожен вузол містить посилання на першого нащадка. Він також містить посилання на наступного нащадка на тому ж рівні дерева. Ці зв'язки утворюють зв'язний список вузлів одного рівня, тому я називаю цей метод представленням у вигляді зв'язного списку вузлів одного рівня (linked sibling). За інформацією про зв'язкових списках ви можете звернутися до 2 чолі.


@ Рис. 6.5. Дерево з вузлами різних порядків


====== 121


Третій підхід полягає в тому, щоб визначити в класі вузла відкриту колекцію, яка буде містити дочірні вузли:


Public Children As New Collection


Це рішення дозволяє використовувати всі переваги колекцій. Програма може при цьому легко додавати і видаляти елементи з колекції, присвоювати дочірнім версій сайту ключі, і використовувати оператор For Each для виконання циклів з посиланнями на дочірні вузли.

Програма NAry, показана на рис. 6.6, використовує колекцію дочірніх вузлів для роботи з деревами порядку N в основному таким же чином, як програма Binary працює з двійковими деревами. У цій програмі, тим не менш, можна додавати до кожного вузла будь-яку кількість нащадків.

Для того щоб уникнути надмірного ускладнення користувальницького інтерфейсу, програма NAry завжди додає нові вузли в кінець колекції дочірніх вузлів батька. Ви можете змінювати цю програму, реалізувавши вставку дочірніх вузлів у середину колекції, але для користувача інтерфейс при цьому ускладниться.

Представлення нумерацією зв'язків

Представлення нумерацією зв'язків (forward star), вперше згадане в 4 розділі, дозволяє компактно представити дерева, графи і мережі за допомогою масиву. Для представлення дерева нумерацією зв'язків, в масиві FirstLink записується індекс для перших гілок, що виходять з кожного вузла. В іншій масив, ToNode, заносяться вузли, до яких веде гілку.

Сигнальна мітка в кінці масиву FirstLink вказує на точку відразу після останнього елемента масиву ToNode. Це дозволяє легко визначити, які гілки виходять з кожного вузла. Гілки, що виходять з вузла I, знаходяться під номерами від FirstLink (I) до FirstLink (I +1) -1. Для виведення зв'язків, що виходять з вузла I, можна використовувати наступний код:


For link = FirstLink (I) To FirstLink (I + 1) - 1

Print Format $ (I) & "->" & Format $ (ToNode (link))

Next link


@ Рис. 6.6. Програма Nary


======= 123


На рис. 6.7 показано дерево та його подання нумерацією зв'язків. Зв'язки, що виходять з 3 вузли (позначеного літерою D) це зв'язку від FirstLink (3) до FirstLink (4) -1. Значення FirstLink (3) дорівнює 9, а FirstLink (4) = 11, тому це зв'язку з номерами 9 і 10. Записи ToNode для цих зв'язків рівні ToNode (9) = 10 і ToNode (10) = 11, тому вузли 10 і 11 будуть дочірніми для 3 вузла. Це вузли, позначені літерами K та L. Це означає, що зв'язки, які вилітають вузол D, ведуть до вузлів K та L.

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

З цих причин більша частина літератури з мережних алгоритмам використовує уявлення нумерацією зв'язків. Наприклад, багато статей, що стосуються обчислення найкоротшого шляху, припускають, що дані знаходяться в подібному форматі. Якщо вам коли-небудь доведеться вивчати ці алгоритми в журналах, таких як "Management Science" або "Operations Research", вам необхідно розібратися в цьому поданні.


@ Рис. 6.7. Ліс та його подання нумерацією зв'язків


======= 123


Використовуючи подання нумерацією зв'язків, можна швидко знайти зв'язку, що виходять з певного сайту. З іншого боку, дуже важко змінювати структуру даних, представлених в такому вигляді. Щоб додати до вузла A на рис. 6.7 ще одного нащадка, доведеться змінити майже всі елементи в обох масивах FirstLink і ToNode. По-перше, кожен елемент в масиві ToNode потрібно зрушити на одну позицію вправо, щоб звільнити місце під новий елемент. Потім, потрібно вставити новий запис у масив ToNode, яка вказує на новий вузол. І, нарешті, потрібно обійти масив ToNode, оновивши кожен елемент, щоб він вказував на нове положення відповідного запису ToNode. Оскільки всі записи в масиві ToNode зрушилися на одну позицію вправо, щоб звільнити місце для нового зв'язку, буде потрібно додати одиницю до всіх порушених записам FirstLink.

На рис. 6.8 показано дерево після додавання нового вузла. Записи, які змінилися, зафарбовані сірим кольором.

Видалення вузла з початку вистави нумерацією зв'язків так само складно, як і вставка вузла. Якщо видаляється вузол має нащадків, процес займає ще більше часу, оскільки доведеться видаляти і всі дочірні вузли.

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


@ Рис. 6.8. Вставка вузла в дерево, представлене нумерацією зв'язків


======= 124


Програма Fstar використовує уявлення нумерацією зв'язків для роботи з деревом, що мають вузли різного порядку. Вона аналогічна програмі NAry, за винятком того, що вона використовує подання на основі масиву, а не колекцій.

Якщо ви подивіться на код програми Fstar, ви побачите, наскільки складно в неї додавати і видаляти вузли. Наступний код демонструє видалення вузла з дерева.


Sub FreeNodeAndChildren (ByVal parent As Integer, _

ByVal link As Integer, ByVal node As Integer)


'Recursively remove the node's children.

Do While FirstLink (node) <FirstLink (node ​​+ 1)

FreeNodeAndChildren node, FirstLink (node), _

ToNode (FirstLink (node))

Loop


'Видалити зв'язок.

RemoveLink parent, link


'Видалити сам вузол.

RemoveNode node

End Sub


Sub RemoveLink (node ​​As Integer, link As Integer)

Dim i As Integer


'Оновити записи масиву FirstLink.

For i = node + 1 To NumNodes

FirstLink (i) = FirstLink (i) - 1

Next i


'Перемістити масив ToNode щоб заповнити порожню комірку.

For i = link + 1 To NumLinks - 1

ToNode (i - 1) = ToNode (i)

Next i


'Видалити зайвий елемент з ToNode.

NumLinks = NumLinks - 1

If NumLinks> 0 Then ReDim Preserve ToNode (0 To NumLinks - 1)

End Sub


Sub RemoveNode (node ​​As Integer)

Dim i As Integer


'Перемістити елементи масиву FirstLink, щоб заповнити

'Порожню комірку.

For i = node + 1 To NumNodes

FirstLink (i - 1) = FirstLink (i)

Next i


'Перемістити елементи масиву NodeCaption.

For i = node + 1 To NumNodes - 1

NodeCaption (i - 1) = NodeCaption (i)

Next i


'Оновити записи масиву ToNode.

For i = 0 To NumLinks - 1

If ToNode (i)> = node Then ToNode (i) = ToNode (i) - 1

Next i


'Видалити зайву запис масиву FirstLink.

NumNodes = NumNodes - 1

ReDim Preserve FirstLink (0 To NumNodes)


ReDim Preserve NodeCaption (0 To NumNodes - 1)

Unload FStarForm.NodeLabel (NumNodes)

End Sub


Це набагато складніше, ніж відповідний код у програмі NAry:


Public Function DeleteDescendant (target As NAryNode) As Boolean

Dim i As Integer

Dim child As NAryNode


'Чи є вузол дочірнім вузлом.

For i = 1 To Children.Count

If Children.Item (i) Is target Then

Children.Remove i

DeleteDescendant = True

Exit Function

End If

Next i


'Якщо це не дочірній вузол, рекурсивно

'Перевірити інших нащадків.

For Each child In Children

If child.DeleteDescendant (target) Then

DeleteDescendant = True

Exit Function

End If

Next child

End Function


======= 125-126


Повні дерева

Повне дерево (complete tree) містить максимально можливе число вузлів на кожному рівні, крім нижнього. Всі вузли на нижньому рівні зсуваються вліво. Наприклад, кожен рівень трійкового дерева містить в точності три дочірніх вузла, за винятком листя, і можливо, одного вузла на один рівень вище листя. На рис. 6.9 показані повні двійкове і троїчну дерева.

Повні дерева мають ряд важливих властивостей. По-перше, це найкоротший дерева, які можуть містити заданий число вузлів. Наприклад, бінарне дерево на рис. 6.9 - одне з найкоротших двійкових дерев з шістьма вузлами. Існують інші виконавчі дерева з шістьма вузлами, але жодне з них не має висоту менше 3.

По друге, якщо повне дерево порядку D складається з N вузлів, воно буде мати висоту порядку O (log D (N)) і O (N) листя. Ці факти мають велике значення, оскільки багато алгоритмів обходять дерева зверху вниз або в протилежному напрямку. Час виконання алгоритму, що виконує одну із цих дій, буде порядку O (N).

Надзвичайно корисна властивість повних дерев полягає в тому, що вони можуть бути дуже компактно записані у масивах. Якщо пронумерувати вузли в «природному» порядку, зверху вниз і зліва направо, то можна помістити елементи дерева в масив в цьому порядку. На рис. 6.10 показано, як можна записати повне дерево в масиві.

Корінь дерева знаходиться в нульовій позиції. Дочірні вузли вузла I перебувають на позиціях 2 * I + 1 і 2 * I + 2. Наприклад, на рис. 6.10, нащадки вузла в позиції 1 (вузла B), знаходяться в позиціях 3 і 4 (вузли D і E).

Легко узагальнити це подання на повні дерева більш високого порядку D. Корінь дерева також буде знаходитися в позиції 0. Нащадки вузла I займають позиції від D * I + 1 до D * I + (I - 1). Наприклад, в троїчному дереві, нащадки вузла в позиції 2, будуть займати позиції 7, 8 і 9. На рис. 6.11 показано повне троїчну дерево і його представлення у вигляді масиву.


@ Рис. 6.9. Повні дерева


========= 127


@ Рис. 6.10. Запис повного двійкового дерева в масиві


При використанні цього методу запису дерева в масиві легко і просто отримати доступ до нащадків вузла. При цьому не потрібно додаткової пам'яті для колекцій дочірніх вузлів або міток у разі подання нумерацією зв'язків. Читання і запис дерева в файл зводиться просто до збереження або читання масиву. Тому це безсумнівно краще представлення дерева для програм, які зберігають дані в повних деревах.

Обхід дерева

Послідовне звернення до всіх вузлів називається обходом (traversing) дерева. Існує декілька послідовностей обходу вузлів двійкового дерева. Три найпростіших з них - прямий (divorder), симетричний (inorder), і зворотний (postorder) обхід, описуються простими рекурсивними алгоритмами. Для кожного заданого вузла алгоритми виконують такі дії:

Прямий обхід:

  1. Звернення до вузла.

  2. Рекурсивний прямий обхід лівого піддерева.

  3. Рекурсивний прямий обхід правого піддерева.

Симетричний обхід:

  1. Рекурсивний симетричний обхід лівого піддерева.

  2. Звернення до вузла.

  3. Рекурсивний симетричний обхід лівого піддерева.

Зворотний обхід:

  1. Рекурсивний зворотний обхід лівого піддерева.

  2. Рекурсивний зворотний обхід правого піддерева.

  3. Звернення до вузла.


@ Рис. 6.11. Запис повного трійкового дерева в масиві


======= 128


Всі три порядки обходу є прикладами обходу в глибину (depth first traversal). Обхід починається з проходу всередину дерева до тих пір, поки алгоритм не досягне листя. При поверненні з рекурсивного виклику підпрограми, алгоритм переміщається по дереву у зворотному напрямку, переглядаючи шляху, які він пропустив при русі вниз.

Обхід в глибину зручно використовувати в алгоритмах, які повинні спочатку обійти листя. Наприклад, метод гілок і меж, описаний у 8 розділі, як можна швидше намагається досягти листя. Він використовує результати, отримані на рівні листя для зменшення часу пошуку в частині дерева.

Четвертий метод перебору вузлів дерева - це обхід в ширину (breadth first traversal). Цей метод звертається до всіх вузлів на заданому рівні дерева, перед тим, як перейти до більш глибоких рівнів. Алгоритми, які проводять повний пошук по дереву, часто використовують обхід в ширину. Алгоритм пошуку найкоротшого маршруту з установкою міток, описаний в 12 главі, являє собою обхід в ширину, дерева найкоротшого шляху в мережі.

На рис. 6.12 показано невелике дерево і порядок, у якому перебираються вузли під час прямого, симетричної і зворотного обходу, а також обходу в ширину.


@ Рис. 6.12. Обходи дерева


====== 129


Для дерев більше, ніж 2 порядку, все ще має сенс визначати прямий, зворотний обхід, і обхід в ширину. Симетричний обхід визначається неоднозначно, оскільки звернення до кожного вузла може відбуватися після звернення до одного, двох, або трьох його нащадкам. Наприклад, в троїчному дереві, звернення до вузла може відбуватися після звернення до його першого нащадка або після звернення до другого нащадку.

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

Особливо просто обходити повні дерева, записані в масиві. Алгоритм обходу в ширину, який вимагає додаткових зусиль в інших уявленнях дерев, для вистав на основі масиву тривіальний, так як вузли записані в такому ж порядку.

Наступний код демонструє алгоритми обходу повного двійкового дерева:


Dim NodeLabel () As String 'Запис міток вузлів.

Dim NumNodes As Integer


'Ініціалізація дерева.

:

Private Sub Preorder (node ​​As Integer)

Print NodeLabel (node) 'Вузол.

'Перший нащадок.

If node * 2 + 1 <= NumNodes Then Preorder node * 2 + 1

'Другий нащадок.

If node * 2 + 2 <= NumNodes Then Preorder node * 2 + 2

End Sub


Private Sub Inorder (node ​​As Integer)

'Перший нащадок.

If node * 2 + 1 <= NumNodes Then Inorder node * 2 + 1

Print NodeLabel (node) 'Вузол.

'Другий нащадок.

If node * 2 + 2 <= NumNodes Then Inorder node * 2 + 2

End Sub


Private Sub Postorder (node ​​As Integer)

'Перший нащадок.

If node * 2 + 1 <= NumNodes Then Postorder node * 2 + 1

'Другий нащадок.

If node * 2 + 2 <= NumNodes Then Postorder node * 2 + 2

Print NodeLabel (node) 'Вузол.

End Sub


Private Sub BreadthFirstPrint ()

Dim i As Integer


For i = 0 To NumNodes

Print NodeLabel (i)

Next i

End Sub


====== 130


Програма Trav1 демонструє прямий, симетричний і зворотний обходи, а також обхід в ширину для двійкових дерев на основі масивів. Введіть висоту дерева, і натисніть на кнопку Create Tree (Створити дерево) для створення повного двійкового дерева. Потім натисніть на кнопки Preorder (Прямий обхід), Inorder (Симетричний обхід), Postorder (Інший обхід) або Breadth-First (Обхід у ширину) для того, щоб побачити, як відбувається обхід дерева. На рис. 6.13 показано вікно програми, в якому відображається прямий обхід дерева 4 порядку.

Прямий і зворотний обхід для інших уявлень дерева здійснюється так само просто. Наступний код демонструє процедуру прямого обходу для дерева, записаного у форматі з нумерацією зв'язків:


Private Sub PreorderPrint (node ​​As Integer)

Dim link As Integer


Print NodeLabel (node)

For link = FirstLink (node) To FirstLink (node ​​+ 1) - 1

PreorderPrint ToNode (link)

Next link

End Sub


@ Рис. 6.13. Приклад прямого обходу дерева в програмі Trav1


======= 131


Як згадувалося раніше, складно дати визначення симетричного обходу для дерев більше 2 порядки. Тим не менше, після того, як ви зрозумієте, що мається на увазі під симетричним обходом, реалізувати його досить просто. Наступний код демонструє процедуру симетричного обходу, яка звертається до половини нащадків вузла (з округленням у більшу сторону), потім до самого вузла, а потім - до решти нащадкам.


Private Sub InorderPrint (node ​​As Integer)

Dim mid_link As Integer

Dim link As Integer


'Знайти середній дочірній вузол.

mid_link - (FirstLink (node ​​+ 1) - 1 + FirstLink (node)) \ 2


'Обхід першої групи нащадків.

For link = FirstLink (node) To mid_link

InorderPrint ToNode (link)

Next link


'Звернення до вузла.

Print NodeLabel (node)


'Обхід другої групи нащадків.

For link = mid_link + 1 To FirstLink (node ​​+ 1) - 1

InorderPrint ToNode (link)

Next link

End Sub


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

Для обходу дерев інших типів можна використовувати чергу для зберігання вузлів, які ще не були обійдені. Спочатку помістимо в чергу кореневий вузол. Після звернення до вузла, він видаляється з початку черги, а його нащадки поміщаються в її кінець. Процес повторюється до тих пір, поки черга не спорожніє. Наступний код демонструє процедуру обходу в ширину для дерева, яке використовує вузли з колекціями нащадків:


Dim Root As TreeNode

'Ініціалізація дерева.

:


Private Sub BreadthFirstPrint (}

Dim queue As New Collection 'Черга на основі колекцій.

Dim node As TreeNode

Dim child As TreeNode


'Почати з кореня дерева в черзі.

queue.Add Root


'Багаторазова обробка першого елемента

'В черзі, поки черга не спорожніє.

Do While queue.Count> 0

node = queue.Item (1)

queue.Remove 1


'Звернення до вузла.

Print NodeLabel (node)


'Помістити в чергу нащадків вузла.

For Each child In node.Children

queue.Add child

Next child

Loop

End Sub


===== 132


Програма Trav2 демонструє обхід дерев, що використовують колекції дочірніх вузлів. Програма є об'єднанням програм Nary, яка оперує деревами порядку N, і програми Trav1, яка демонструє обходи дерев.

Виберіть вузол, і натисніть на кнопку Add Child (Додати дочірній вузол), щоб додати до вузла нащадка. Натисніть на кнопки Preorder, Inorder, Postorder або Breadth First, щоб побачити приклади відповідних обходів. На рис. 6.14 показана програма Trav2, яка відображає зворотний обхід.

Впорядковані дерева

Двійкові дерева часто є природним способом представлення і обробки даних у комп'ютерних програмах. Оскільки багато комп'ютерні операції є двійковими, вони природно перетворюються в операції з двійковими деревами. Наприклад, можна перетворити двійкове ставлення «менше» у двійкове дерево. Якщо використовувати внутрішні вузли дерева для позначення того, що «лівий нащадок менше правого» ви можете використовувати двійкове дерево для запису упорядкованого списку. На рис. 6.15 показано двійкове дерево, упорядкованого список з числами 1, 2, 4, 6, 7, 9.


@ Рис. 6.14. Приклад зворотного обходу дерева в програмі Trav2


====== 133


@ Рис. 6.15. Упорядкований список: 1, 2, 4, 6, 7, 9.


Додавання елементів

Алгоритм вставки нового елемента в дерево такого типу досить простий. Почнемо з кореневого вузла. По черзі порівняємо значення всіх вузлів із значенням нового елемента. Якщо значення нового елемента менше або дорівнює значенню вузла, перейдемо вниз по лівій гілки дерева. Якщо нове значення більше, ніж значення вузла, перейдемо вниз по правій гілці. Коли цей процес дійде до листа, елемент міститься в цю точку.

Щоб помістити значення 8 в дерево, показане на рис. 6.15, ми починаємо з кореня, який має значення 4. Оскільки 8 більше, ніж 4, переходимо по правій гілки до вузла 9. Оскільки 8 менше 9, переходимо потім по лівій гілки до вузла 7. Оскільки 8 більше 7, знову намагаємося піти по правій гілці, але у цього вузла немає правого нащадка. Тому новий елемент вставляється в цій точці, і виходить дерево, показане на рис. 6.16.

Наступний код додає нове значення нижче вузла в упорядкованому дереві. Програма починає вставку з кореня, викликаючи процедуру InsertItem Root, new_value.


Private Sub InsertItem (node ​​As SortNode, new_value As Integer)

Dim child As SortNode


If node Is Nothing Then

'Ми дійшли до листа.

'Вставити елемент тут.

Set node = New SortNode

node.Value = new_value

MaxBox = MaxBox + 1

Load NodeLabel (MaxBox)

Set node.Box = NodeLabel (MaxBox)

With NodeLabel (MaxBox)

. Caption = Format $ (new_value)

. Visible = True

End With

ElseIf new_value <= node.Value Then

'Іти до лівої галузі.

Set child = node.LeftChild

InsertItem child, new_value

Set node.LeftChild = child

Else

'Іти до правої гілки.

Set child = node.RightChild

InsertItem child, new_value

Set node.RightChild = child

End If

End Sub


Коли ця процедура досягає кінця дерева, відбувається щось зовсім неочевидне. У Visual Basic, коли ви передаєте параметр підпрограмі, цей параметр передається по посиланню, якщо ви не використовуєте зарезервоване слово ByVal. Це означає, що підпрограма працює з тією ж копією параметра, яку використовує викликає процедура. Якщо підпрограма змінює значення параметра, значення в зухвалому процедурі також змінюється.

Коли процедура InsertItem рекурсивно викликає сама себе, вона передає покажчик на дочірній вузол в дереві. Наприклад, в наступних операторах процедура передає покажчик на правого нащадка вузла як параметр вузла процедури InsertItem. Якщо викликається процедура змінює значення параметра вузла, покажчик на нащадка також автоматично оновлюється в зухвалому процедурою. Потім в останньому рядку коду значення правого нащадка встановлюється рівним новому значенням, так що створений новий вузол додається до дерева.


Set child = node.RightChild

Insertltem child, new_value

Set node.RightChild = child


Видалення елементів

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


===== 134-135


@ Рис. 6.17. Видалення вузла з єдиним нащадком


По-перше, якщо у видаляється вузла немає нащадків, ви можете просто прибрати його з дерева, так як порядок залишилися вузлів при цьому не зміниться.

По друге, якщо у вузла всього один дочірній вузол, ви можете помістити його на місце видаленого вузла. Порядок інших нащадків віддаленого вузла залишиться незмінним, оскільки вони є також нащадками і дочірнього вузла. На рис. 6.17 показано дерево, з якого видаляється вузол 4, який має всього один дочірній вузол.

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

Щоб вирішити цю проблему, віддалений вузол замінюється найбільш правим вузлом з лівої гілки. Іншими словами, потрібно зрушити на один крок вниз по лівій гілки, що виходила з віддаленого вузла. Потім потрібно рухатися по правих гілкам вниз до тих пір, поки не знайдеться вузол, який не має правої гілки. Це самий правий вузол на гілки ліворуч від видаляється вузла. У дереві, показаному зліва на рис. 6.18, вузол 3 є найбільш правим вузлом в лівій від вузла 4 гілки. Можна замінити вузол 4 листом 3, зберігши при цьому порядок дерева.


@ Рис. 6.18. Видалення вузла, який має два дочірні


======= 136


@ Рис. 6.19. Видалення вузла, якщо його замінює вузол має нащадка


Залишається останній варіант - коли замінює вузол має лівого нащадка. У цьому випадку, ви можете перемістити цього нащадка на місце, що звільнилося в результаті переміщення заміщає вузла, і дерево знову буде розташовано в потрібному порядку. Вже відомо, що самий правий вузол не має правої нащадка, інакше він не був би таким. Це означає, що не потрібно турбуватися, чи не має замісник вузол двох нащадків.

Ця складна ситуація показана на рис. 6.19. У цьому прикладі видаляється вузол 8. Самий правий елемент у його лівій гілки - це вузол 7, який має нащадка - вузол 5. Щоб зберегти порядок дерева після видалення вузла 8, замінимо вузол 8 вузлом 7, а вузол 7 - вузлом 5. Зауважте, що вузол 7 отримує нових нащадків, а вузол 5 зберігає своїх.

Наступний код видаляє вузол з упорядкованого двійкового дерева:


Private Sub DeleteItem (node ​​As SortNode, target_value As Integer)

Dim target As SortNode

Dim child As SortNode


'Якщо вузол не знайдений, вивести повідомлення.

If node Is Nothing Then

Beep

MsgBox "Item" & Format $ (target_value) & _

"Не знайдено у лісі."

Exit Sub

End If


If target_value <node.Value Then

'Продовжити для лівого піддерева.

Set child = node.LeftChild

DeleteItem child, target_value

Set node.LeftChild = child

ElseIf target_value> node.Value Then

'Продовжити для правого піддерева.

Set child = node.RightChild

DeleteItem child, target_value

Set node.RightChild = child

Else

'Бажаємий вузол знайдений.

Set target = node

If target.LeftChild Is Nothing Then

'Замінити шуканий вузол його правим нащадком.

Set node = node.RightChild

ElseIf target.RightChild Is Nothing Then

'Замінити шуканий вузол його лівим нащадком.

Set node = node.LeftChild

Else

"Виклик подпрограми ReplaceRightmost для заміни

'Шуканого вузла найбільш правим вузлом

'У його лівій гілки.

Set child = node.LeftChild

ReplaceRightmost node, child

Set node.LeftChild = child

End If

End If

End Sub


Private Sub ReplaceRightmost (target As SortNode, repl As SortNode)

Dim old_repl As SortNode

Dim child As SortNode


If Not (repl.RightChild Is Nothing) Then

'Продовжити рух вправо і вниз.

Set child = repl.RightChild

ReplaceRightmost target, child

Set repl.RightChild = child

Else

'Досягли дна.

'Запам'ятати замінює вузол repl.

Set old_repl = repl


'Замінити вузол repl його лівим нащадком.

Set repl = repl.LeftChild


'Замінити шуканий вузол target with repl.

Set old_repl.LeftChild = target.LeftChild

Set old_repl.RightChild = target.RightChild

Set target = old_repl

End If

End Sub


====== 137-138


Алгоритм використовує у двох місцях прийом передачі параметрів у рекурсивні підпрограми по посиланню. По-перше, підпрограма DeleteItem використовує цей прийом для того, щоб батько шуканого вузла вказував на замінюючий вузол. Наступні оператори показують, як викликається підпрограма DeleteItem:


Set child = node.LeftChild

DeleteItem child, target_value

Set node.LeftChild = child


Коли процедура виявляє шуканий вузол (вузол 8 на рис. 6.19), вона отримує в якості параметра вузла покажчик батька на шуканий вузол. Встановлюючи параметр на заміщає вузол (вузол 7), підпрограма DeleteItem задає дочірній вузол для батька так, щоб він вказував на новий вузол.

Наступні оператори показують, як процедура ReplaceRightMost рекурсивно викликає себе:


Set child = repl.RightChild

ReplaceRightmost target, child

Set repl.RightChild = child


Коли процедура знаходить самий правий вузол в лівій від видаляється вузла гілки (вузол 7), в параметрі repl знаходиться покажчик батька на самий правий вузол. Коли процедура встановлює значення repl рівним repl.LeftChild, вона автоматично з'єднує батька самого правого вузла з лівим дочірнім вузлом самого правого вузла (вузлом 5).

Програма TreeSort використовує ці процедури для роботи з впорядкованими двійковими деревами. Введіть ціле число, і натисніть на кнопку Add, щоб додати елемент до дерева. Введіть ціле число, і натисніть на кнопку Remove, щоб видалити цей елемент із дерева. Після видалення вузла, дерево автоматично переупорядочівается для збереження порядку «менше».

Обхід впорядкованих дерев

Корисна властивість впорядкованих дерев полягає в тому, що їх лад збігається з порядком симетричного обходу. Наприклад, при симетричному обході дерева, показаного на рис. 6.20, звернення до вузлів відбувається в порядку 2-4-5-6-7-8-9.


@ Рис. 6.20. Симетричний обхід упорядкованого дерева: 2, 4, 5, 6, 7, 8, 9


========= 139


Це властивість симетричного обходу впорядкованих дерев призводить до простого алгоритму сортування:

  1. Додати елемент до впорядкованого дереву.

  2. Вивести елементи, використовуючи симетричний обхід.

Цей алгоритм зазвичай працює досить добре. Тим не менш, якщо додавати елементи до дерева у визначеному порядку, то дерево може стати високим і тонким. На рис. 6.21 показано впорядковане дерево, яке виходить при додаванні до нього елементів в порядку 1, 6, 5, 2, 3, 4. Інші послідовності також можуть призводити до появи високих і тонких дерев.

Чим вище стає впорядковане дерево, тим більше часу потрібно для додавання нових елементів у нижню частину дерева. У найгіршому випадку, після додавання N елементів, дерево буде мати висоту порядку O (N). Повний час вставки всіх елементів в дерево буде при цьому порядку O (N 2). Оскільки для обходу дерева потрібен час порядку O (N), повний час сортування чисел з використанням дерева дорівнюватиме O (N 2) + O (N) = O (N 2).

Якщо дерево залишається досить коротким, воно має висоту порядку O (log (N)). У цьому випадку для вставки елемента в дерево потрібно всього порядку O (log (N)) кроків. Вставка всіх N елементів в дерево потрібно близько O (N * log (N)) кроків. Тоді сортування елементів за допомогою дерева зажадає часу порядку O (N * log (N)) + O (N) = O (N * log (N)).

Час виконання порядку O (N * log (N)) набагато менше, ніж O (N 2). Наприклад, побудова високого і тонкого дерева, що містить 1000 елементів, вимагатиме виконання близько мільйона кроків. Побудова короткого дерева з висотою порядку O (log (N)) займе всього близько 10.000 кроків.

Якщо елементи спочатку розташовані у випадковому порядку, форма дерева буде представляти що щось середнє між цими двома крайніми випадками. Хоча його висота може виявитися трохи більше, ніж log (N), воно, швидше за все, не буде дуже тонким і високим, тому алгоритм сортування буде виконуватися достатньо швидко.


@ Рис. 6.21. Дерево, отримане додаванням елементів в порядку 1, 6, 5, 2, 3, 4


========== 140


У 7 главі описуються способи балансування дерев, для того, щоб вони не ставали занадто високими і тонкими, незалежно від того, в якому порядку в них додаються нові елементи. Тим не менше, ці методи досить складні, і їх не має сенсу застосовувати в алгоритмі сортування за допомогою дерева. Багато з алгоритмів сортування, описаних у 9 розділі, більш прості в реалізації і забезпечують при цьому кращу продуктивність.

Дерева з посиланнями

У 2 розділі показано, як додавання посилань до зв'язковим списками дозволяє спростити висновок елементів у різному порядку. Ви можете використовувати той же підхід для спрощення звернення до вузлів дерева в різному порядку. Наприклад, розміщуючи посилання в листя двійкового дерева, ви можете полегшити виконання симетричного і зворотного обходів. Для впорядкованого дерева, це обхід в прямому і зворотному порядку сортування.

Для створення посилань, покажчики на попередній і наступний вузли в порядку симетричного обходу поміщаються в невикористовуваних покажчиках на дочірні вузли. Якщо не використовується покажчик на лівого нащадка, то посилання записується на його місце, вказуючи на попередній вузол при симетричному обході. Якщо не використовується покажчик на правого нащадка, то посилання записується на його місце, вказуючи на наступний вузол при симетричному обході. Оскільки посилання симетричні, і посилання лівих нащадків вказують на попередні, а правих - на наступні вузли, цей тип дерев називається деревом з симетричними посиланнями (symmetrically threaded tree). На рис. 6.22 показано дерево з симетричними посиланнями, які позначені пунктирними лініями.

Оскільки посилання займають місце покажчиків на дочірні вузли дерева, потрібно як то розрізняти посилання і звичайні покажчики на нащадків. Простіше за все додати до вузлів нові змінні HasLeftChild і HasRightChild типу Boolean, які будуть рівні True, якщо вузол має лівого чи правого нащадка відповідно.

Щоб використовувати посилання для пошуку попереднього вузла, потрібно перевірити покажчик на лівого нащадка вузла. Якщо цей покажчик є посиланням, то посилання вказує на попередній вузол. Якщо значення покажчика одно Nothing, значить це перший вузол дерева, і тому він не має попередників. В іншому випадку, перейдемо за вказівником до лівого дочірньому вузлу. Потім проследуем за вказівниками на правий дочірній вузол нащадків, до тих пір, поки не досягнемо вузла, в якому на місці покажчика на правого нащадка перебуває посилання. Цей вузол (а не той, на який вказує посилання) є попередником вихідного вузла. Цей вузол є найбільш правим в лівій від вихідного вузла гілки дерева. Наступний код демонструє пошук попередника:


@ Рис. 6.22. Дерево з симетричними посиланнями


========== 141


Private Function Predecessor (node ​​As ThreadedNode) As ThreadedNode Dim child As ThreadedNode


If node.LeftChild Is Nothing Then

'Це перший вузол в порядку симетричного обходу.

Set Predecessor = Nothing

Else If node.HasLeftChild Then

'Це покажчик на вузол.

'Знайти самий правий вузол в лівій гілки.

Set child = node.LeftChild

Do While child.HasRightChild

Set child = child.RightChild

Loop

Set Predecessor = child

Else

'Посилання вказує на попередника.

Set Predecessor = node.LeftChild

End If

End Function


Аналогічно виконується пошук наступного вузла. Якщо покажчик на правий дочірній вузол є посиланням, то вона свідчить про наступний вузол. Якщо покажчик має значення Nothing, то це останній вузол дерева, тому він не має послідовника. В іншому випадку, переходимо за вказівником до правого нащадку вузла. Потім переміщаємося за вказівниками дочірніх вузлів до тих, пір, поки черговий покажчик на лівий дочірній вузол не виявиться посиланням. Тоді знайдений вузол буде наступним за вихідним. Це буде самий лівий вузол у правій від вихідного вузла гілки дерева.

Зручно також ввести функції для знаходження першого і останнього вузлів дерева. Щоб знайти перший вузол, просто проследуем за вказівниками на лівого нащадка вниз від кореня до тих пір, поки не досягнемо вузла, значення покажчика на лівого нащадка для якого дорівнює Nothing. Щоб знайти останній вузол, проследуем за вказівниками на правого нащадка вниз від кореня до тих пір, поки не досягнемо вузла, значення покажчика на правого нащадка для якого дорівнює Nothing.


Private Function FirstNode () As ThreadedNode

Dim node As ThreadedNode


Set node = Root

Do While Not (node.LeftChild Is Nothing)

Set node = node.LeftChild

Loop

Set PirstNode = node

End Function


Private Function LastNode () As ThreadedNode

Dim node As ThreadedNode

Set node = Root

Do While Not (node.RightChild Is Nothing)

Set node = node.RightChild

Loop

Set FirstNode = node

End Function


========= 142


За допомогою цих функцій ви можете легко написати процедури, які виводять вузли дерева в прямому або зворотному порядку:


Private Sub Inorder ()

Dim node As ThreadedNode


'Знайти перший вузол.

Set node = FirstNode ()


'Виведення списку.

Do While Not (node ​​Is Nothing)

Print node.Value

Set node = Successor (node)

Loop

End Sub


Private Sub PrintReverseInorder ()

Dim node As ThreadedNode


'Знайти останній вузол

Set node = LastNode


'Виведення списку.

Do While Not (node ​​Is Nothing)

Print node. Value

Set node = Predecessor (node)

Loop

End Sub


Процедура виведення вузлів у порядку симетричного обходу, наведена раніше в цьому розділі, використовує рекурсію. Для усунення рекурсії ви можете використовувати ці нові процедури, які не використовують ні рекурсію, ні системний стек.

Кожний покажчик на дочірні вузли в дереві містить або покажчик на нащадка, або посилання на попередника чи послідовника. Так як кожен вузол має два покажчика на дочірні вузли, то, якщо дерево має N вузлів, то воно буде містити 2 * N посилань і покажчиків. Ці алгоритми обходу звертаються до всіх посилань і вказівниками дерева один раз, тому вони вимагатимуть виконання O (2 * N) = O (N) кроків.

Можна трохи прискорити виконання цих підпрограм, якщо відслідковувати покажчики на перший і останній вузли дерева. Тоді вам не знадобиться виконувати пошук першого і останнього вузлів перед тим, як вивести список вузлів по порядку. Так як при цьому алгоритм звертається до всіх N вузлам дерева, час виконання цього алгоритму також буде порядку O (N), але на практиці він буде виконуватися трохи швидше.


======== 143


Робота з деревами з посиланнями

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

Припустимо, що потрібно додати нового лівого нащадка вузла A. Так як це місце не зайняте, то на місці покажчика на лівого нащадка вузла A знаходиться посилання, яка вказує на попередника вузла A. Оскільки новий вузол займе місце лівого нащадка вузла A, він стане попередником вузла A. Вузол A буде послідовником нового вузла. Вузол, який був попередником вузла A до цього, тепер стає попередником нового вузла. На рис. 6.23 показано дерево з рис. 6.22 після додавання нового вузла X як лівий нащадка вузла H.

Якщо відслідковувати покажчик на перший і останній вузли дерева, то потрібно також перевірити, чи не є тепер новий вузол перший вузлом дерева. Якщо посилання на попередника для нового вузла має значення Nothing, то це новий перший вузол дерева.


@ Рис. 6.23. Додавання вузла X до дерева з посиланнями


========= 144


Враховуючи все вищевикладене, легко написати процедуру, яка додає нового лівого нащадка до вузла. Вставка правого нащадка виконується аналогічно.


Private Sub AddLeftChild (parent As ThreadedNode, child As ThreadedNode)

'Попередник батька стає попередником нового вузла.

Set child. LeftChild = parent.LeftChild

child.HasLeftChild = False


'Вставити вузол.

Set parent.LeftChild = child

parent.HasLeftChild = True


'Батько є послідовником нового вузла.

Set child.RightChild = parent

child.HasRightChild = False


'Визначити, чи є новий вузол першого вузлом дерева.

If child.LeftChild Is Nothing Then Set FirstNode = child

End Sub


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

Припустимо, що видаляється вузол є лівим нащадком свого батька. Його покажчик на лівого нащадка є посиланням, що вказує на попередній вузол в дереві. Після видалення вузла, його попередник стає попередником батька віддаленого вузла. Щоб видалити вузол, просто замінюємо покажчик на лівого нащадка його батька на покажчик на лівого нащадка видаляється вузла.

Покажчик на правого нащадка видаляється вузла є посиланням, яка вказує на наступний вузол в дереві. Так як видаляється вузол є лівим нащадком свого батька, і оскільки у нього немає нащадків, ця посилання вказує на батька, тому її можна просто опустити. На рис. 6.24 показано дерево з рис. 6.23 після видалення вузла F. Аналогічно видаляється правий нащадок.


Private Sub RemoveLeftChild (parent As ThreadedNode)

Dim target As ThreadedNode


Set target = parent.LeftChild

Set parent.LeftChild = target.LeftChild

End Sub


@ Рис. 6.24. Видалення вузла F з дерева з посиланнями


========= 145


Квадродерево

Квадродерево (quadtrees) описують просторові відносини між елементами на площі. Наприклад, це може бути карта, а елементи можуть являти собою положення будинків або підприємств на ній.

Кожен вузол квадродерева являє собою ділянку на площі, представленої квадродерево. Кожен вузол, крім листя, має чотири нащадка, які представляють чотири квадранта. Листя можуть зберігати свої елементи в колекціях зв'язкових списків. Наступний код показує секцію Declarations для класу QtreeNode.


'Нащадки.

Public NWchild As QtreeNode

Public NEchild As QtreeNode

Public SWchild As QtreeNode

Public SEchild As QtreeNode


'Елементи вузлу, якщо це не лист.

Public Items As New Collection


Елементи, записані в квадродерево, можуть містити просторові дані будь-якого типу. Вони можуть містити інформацію про положення, яку дерево може використовувати для пошуку елементів. Змінні в простому класі QtreeItem, який представляє елементи, що складаються з точок на місцевості, визначаються так:


Public X As Single

Public Y As Single


Щоб побудувати квадродерево, спочатку помістимо всі елементи в кореневий вузол. Потім визначимо, чи містить цей вузол досить багато елементів, щоб його варто було розділити на декілька вузлів. Якщо це так, створимо чотири нащадка вузла і розподілимо елементи між чотирма нащадками відповідно до їх становищем у чотирьох квадрантах вихідної області. Потім рекурсивно перевіряємо, чи не потрібно розбити на кілька вузлів дочірні вузли. Продовжимо розбиття до тих пір, поки все листя не будуть містити не більше деякого заданого числа елементів.

На рис. 6.25 показано кілька елементів даних, розташованих у вигляді квадродерева. Кожна область розбивається до тих пір, поки вона не буде містити не більше двох елементів.

Квадродерево зручно застосовувати для пошуку довколишніх об'єктів. Припустимо, є програма, яка малює карту з більшим числом населених пунктів. Після того, як користувач клацне мишею по карті, програма повинна знайти найближчий до виділеного пункту населений пункт. Програма може перебрати весь список населених пунктів, перевіряючи для кожного його відстань від заданої точки. Якщо в списку N елементів, то складність цього алгоритму порядку O (N).


==== 146


@ Рис. 6.25. Квадродерево


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

Функція LocateLeaf класу QtreeNode використовує цей підхід для пошуку аркуша дерева, який містить вибрану точку. Програма може викликати цю функцію в рядку Set the_leaf = Root.LocateLeaf (X, Y, Gxmin, Gxmax, Gymax), де Gxmin, Gxmax, Gymin, Gymax - це межі представленої деревом області.


Public Function LocateLeaf (X As Single, Y As Single, _

xmin As Single, xmax As Single, ymin As Single, ymax As Single) _

As QtreeNode


Dim xmid As Single

Dim ymid As Single

Dim node As QtreeNode


If NWchild Is Nothing Then

'Вузол не має нащадків. Бажаємий вузол знайдений.

Set LocateLeaf = Me

Exit Function

End If


'Знайти відпо нащадка.

xmid = (xmax + xmin) / 2

ymid = (ymax + ymin) / 2

If X <= xmid Then

If Y <= ymid Then

Set LocateLeaf = NWchild.LocateLeaf (_

X, Y, xmin, xmid, ymin, ymid)

Else

Set LocateLeaf = SWchild.LocateLeaf _

X, Y, xmin, xmid, ymid, ymax)

End If

Else

If Y <= ymid Then

Set LocateLeaf = NEchild.LocateLeaf (_

X, Y, xmid, xmax, ymin, ymid)

Else

Set LocateLeaf = SEchild.LocateLeaf (_

X, Y, xmid, xmax, ymid, ymax)

End If

End If

End Function


Після знаходження листа, який містить точку, перевіряємо населені пункти в листі, щоб знайти, який з них ближче всього від обраної точки. Це робиться за допомогою процедури NearPointInLeaf.


Public Sub NearPointInLeaf (X As Single, Y As Single, _

best_item As QtreeItem, best_dist As Single, comparisons As Long)


Dim new_item As QtreeItem

Dim Dx As Single

Dim Dy As Single

Dim new_dist As Single


'Почнемо з явно поганого рішення.

best_dist = 10000000

Set best_item = Nothing


'Зупинитися якщо лист не містить елементів.

If Items.Count <1 Then Exit Sub


For Each new_item In Items

comparisons = comparisons + 1

Dx = new_item.X - X

Dy = new_item.Y - Y

new_dist = Dx * Dx + Dy * Dy

If best_dist> new_dist Then

best_dist = new_dist

Set best_item = new_item

End If

Next new_item

End Sub


====== 147-148


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

Припустимо, що D min - це відстань між вибраною користувачем точкою і найближчим зі знайдених до цих пір населених пунктів. Якщо D min менше, ніж відстань від обраної точки до краю аркуша, то пошук закінчений. Населений пункт знаходиться при цьому занадто далеко від краю аркуша, щоб в будь-якому іншому аркуші міг існувати пункт, розташований ближче до заданої точки.

В іншому випадку потрібно знову почати з кореня і рухатися по дереву, перевіряючи всі вузли квадродерево, які знаходяться на відстані менше, ніж D min від заданої точки. Якщо знайдуться елементи, які розташовані ближче, змінимо значення D min і продовжимо пошук. Після завершення перевірки найближчих до точки листя, потрібний елемент буде знайдений. Підпрограма CheckNearByLeaves використовує цей підхід для завершення пошуку.


Public Sub CheckNearbyLeaves (exclude As QtreeNode, _

X As Single, Y As Single, best_item As QtreeItem, _

best_dist As Single, comparisons As Long, _

xmin As Single, xmax As Single, ymin As Single, ymax As Single)


Dim xmid As Single

Dim ymid As Single

Dim new_dist As Single

Dim new_item As QtreeItem


'Якщо це лист, який ми повинні виключити,

'Нічого не робити.

If Me Is exclude Then Exit Sub


'Якщо це лист, перевірити його.

If SWchild Is Nothing Then

NearPointInLeaf X, Y, new_item, new_dist, comparisons

If best_dist> new_dist Then

best_dist = new_dist

Set best_item = new_item

End If

Exit Sub

End If


'Знайти нащадків, які віддалені не більше, ніж на best_dist

'Від обраної точки.

xmid = (xmax + xmin) / 2

ymid = (ymax + ymin) / 2

If X - Sqr (best_dist) <= xmid Then


'Продовжуємо з нащадками на заході.

If Y - Sqr (best_dist) <= ymid Then

'Перевірити північно-західного нащадка.

NWchild.CheckNearbyLeaves _

exclude, X, Y, best_item, _

best_dist, comparisons, _

xmin, xmid, ymin, ymid

End If

If Y + Sqr (best_dist)> ymid Then

'Перевірити південно-західного нащадка.

SWchiId.CheckNearbyLeaves _

exclude, X, Y, best_item, _

best_dist, comparisons, _

xmin, xmid, ymid, ymax

End If

End If

If X + Sqr (best_dist)> xmid Then

'Продовжити з нащадками на сході.

If Y - Sqr (best_dist) <= ymid Then

'Перевірити північно-східного нащадка.

NEchild.CheckNearbyLeaves _

exclude, X, Y, best_item, _

best_dist, comparisons, _

xmid, xmax, ymin, ymid

End If

If Y + Sqr (best_dist)> ymid Then

'Перевірити південносході нащадка.

SEchild.CheckNearbyLeaves _

exclude, X, Y, best_item, _

best_dist, comparisons, _

xmid, xmax, ymid, ymax

End If

End If

End Sub


===== 149-150


Підпрограма FindPoint використовує підпрограми LocateLeaf, NearPointInLeaf, і CheckNearbyLeaves, з класу QtreeNode для швидкого пошуку елемента в квадродерево.


Function FindPoint (X As Single, Y As Single, comparisons As Long) _ As QtreeItem


Dim leaf As QtreeNode

Dim best_item As QtreeItem

Dim best_dist As Single


'Визначити, в якому аркуші знаходиться точка.

Set leaf = Root.LocateLeaf (_

X, Y, Gxmin, Gxmax, Gymin, Gymax)


'Знайти найближчу крапку в листі.

leaf.NearPointInLeaf _

X, Y, best_item, best_dist, comparisons


'Перевірити сусідні листя.

Root.CheckNearbyLeaves _

leaf, X, Y, best_item, best_dist, _

comparisons, Gxmin, Gxmax, Gymin, Gymax


Set FindPoint = best_item

End Function


Програма Qtree використовує квадродерево. При старті програма запитує число елементів даних, яке вона повинна створити, потім вона створює елементи й малює їх у вигляді крапок. Задавайте спочатку невелика (близько 1000) число елементів, поки ви не визначите, наскільки швидко ваш комп'ютер може створювати елементи.

Цікаво спостерігати квадродерево, елементи яких розподілені нерівномірно, тому програма вибирає точки за допомогою функції дивного атрактора (strange attractor) з теорії хаосу (chaos theory). Хоча здається, що елементи слідують у випадковому порядку, вони утворюють цікаві кластери.

При виборі будь-якої точки на формі за допомогою миші, програма Qtree знаходить найближчий до неї елемент. Вона підсвічує цей елемент і виводить число перевірених при його пошуку елементів.

У меню Options (Параметри) програми можна задати, чи повинна програма використовувати квадродерево чи ні. Якщо поставити галочку в пункті Use Quadtree (Використовувати квадродерево), то програма виводить на екран квадродерево і використовує його для пошуку елементів. Якщо цей пункт не обраний, програма не відображає квадродерево і знаходить потрібні елементи шляхом перебору.

Програма перевіряє набагато менше число елементів і працює набагато швидше при використанні квадродерева. Якщо цей ефект не дуже помітний на вашому комп'ютері, запустіть програму, задавши при старті 10.000 або 20.000 вхідних елементів. Ви помітите різницю навіть на комп'ютері з процесором Pentium з тактовою частотою 90 МГц.

На рис. 6.26 показано вікно програма Qtree на якому зображено 10.000 елементів. Маленький прямокутник у верхньому правому куті позначає вибраний елемент. Мітка у верхньому лівому куті показує, що програма перевірила лише 40 з 10.000 елементів перед тим, як знайти потрібний.

Зміна MAX_PER_NODE

Цікаво поекспериментувати з програмою Qtree, змінюючи значення MAX_PER_NODE, визначене в розділі Declarations класу QtreeNode. Це максимальна кількість елементів, які можуть поміститися у вузлі квадродерева без його розбиття. Програма звичайно використовує значення MAX_PER_NODE = 100.


====== 151


@ Рис. 6.26. Програма Qtree


Якщо ви зменшите їх кількість, наприклад, до 10, то в кожному вузлі буде знаходитися менше елементів, тому програма буде перевіряти менше елементів, щоб знайти найближчий до обраної вами точці. Пошук буде виконуватися швидше. З іншого боку, програмі доведеться створити набагато більше вузлів квадродерева, тому вона займе більше пам'яті.

Навпаки, якщо ви збільшите MAX_PER_NODE до 1000, програма створить набагато менше вузлів. При цьому буде потрібно більше часу на пошук елементів, але дерево буде менше, і займе менше пам'яті.

Це приклад компромісу між часом і простором. Використання більшої кількості вузлів квадродерева прискорює пошук, але займає більше пам'яті. У цьому прикладі, при значенні змінної MAX_PER_NODE приблизно рівну 100, досягається рівновага між швидкістю і використанням пам'яті. Для інших програм вам може знадобитися поекспериментувати з різними значеннями змінної MAX_PER_NODE, щоб знайти оптимальне.

Використання псевдоуказателей в квадродерево

Програма Qtree використовує велику кількість класів і колекцій. Кожен внутрішній вузол квадродерева містить чотири посилання на дочірні вузли. Листя включають великі колекції, в яких знаходяться елементи вузла. Всі ці об'єкти та колекції уповільнюють роботу програми, якщо вона містить велику числі елементів. Створення об'єктів забирає багато часу і пам'яті. Якщо програма створює безліч об'єктів, вона може почати звертатися до файлу підкачки, що сильно сповільнить її роботу.

На жаль, виграш від використання квадродерево буде максимальним, якщо програма містить багато елементів. Щоб поліпшити продуктивність великих додатків, ви можете використовувати методи роботи з псевдоуказателямі, описані в 2 чолі.


===== 152


Програма Qtree2 створює квадродерево за допомогою псевдоуказателей. Вузли й елементи знаходяться в масивах певних користувачем структур даних. В якості покажчиків, ця програма використовує індекси масивів замість посилань на об'єкти. В одному з тестів на комп'ютері з процесором Pentium з тактовою частотою 90 МГц, програмі Qtree знадобилося 25 секунд для побудови квадродерева, що містить 30.000 елементів. Програмі Qtree2 знадобилося всього 3 секунди для створення того ж дерева.

Вісімкові дерева

Вісімкові дерева (octtrees) аналогічні квадродерево, але вони розбивають область не двовимірного, а тривимірного простору. Вісімкові дерева містять не чотири нащадка, як квадродерево, а вісім, розбиваючи обсяг області на вісім частин - верхню північно західну, нижню північно західну, верхню північно східну, нижню північно східну і так далі.

Вісімкові дерева корисні при роботі з об'єктами, розташованими в просторі. Наприклад, робот може використовувати вісімкове дерево для відстеження довколишніх об'єктів. Програма рейтрейсінга може використовувати вісімкове дерево для того, щоб швидко оцінити, чи проходить промінь поблизу від об'єкту перед тим, як почати повільний процес обчислень точного перетину об'єкта і променя.

Вісімкові дерева можна будувати, використовуючи приблизно ті ж методи, що і для квадродерево.

Резюме

Існує безліч способів представлення дерев. Найбільш ефективним і компактним з них є запис повних дерев у масивах. Представлення дерев у вигляді колекцій дочірніх вузлів спрощує роботу з ними, але при цьому програма виконується повільніше і використовує більше пам'яті. Представлення нумерацією зв'язків дозволяє швидко виконувати обхід дерева і використовує менше пам'яті, ніж колекції нащадків, але його складно модифікувати. Тим не менш, його важливо уявляти, бо воно часто використовується в мережевих алгоритмах.


===== 153


Глава 7. Збалансовані дерева

При роботі з упорядкованим деревом, виймання або видаленні вузлів, дерево може стати незбалансованим. Коли це відбувається, то алгоритми, роботи з деревом стають менш ефективними. Якщо дерево стає сильно незбалансованим, воно практично представляє всього лише складну форму зв'язного списку, і програма, що використовує таке дерево, може мати дуже низьку продуктивність.

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

Глава починається з опису того, що розуміється під незбалансованим деревом і демонстрації погіршення продуктивності для незбалансованих дерев. Потім у ній обговорюються АВЛ дерева, висота лівого і правого піддерев в кожному вузлі яких відрізняється не більше, ніж на одиницю. Зберігаючи це властивість АВЛ дерев, можна підтримувати таке дерево збалансованим.

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

Збалансованість дерева

Як згадувалося в 6 чолі, форма упорядкованого дерева залежить від порядку вставки до нього нових вузлів. На рис. 7.1 показано два різних дерева, створених при додаванні одних і тих самих елементів в різному порядку.

Високі і тонкі дерева, такі як ліве дерево на рис. 7.1, можуть мати глибину порядку O (N). Вставлення або пошук елемента в такому незбалансованому дереві може займати порядку O (N) кроків. Навіть якщо нові елементи вставляються в дерево у випадковому порядку, в середньому вони дадуть дерево з глибиною N / 2, що також порядку O (N).

Припустимо, що будується впорядковане двійкове дерево, що містить 1000 вузлів. Якщо дерево збалансовано, то висота дерева буде порядку log 2 (1000), або приблизно дорівнює 10. Вставка нового елемента в дерево займе всього 10 кроків. Якщо дерево високе і тонка, вона може мати висоту 1000. У цьому випадку, вставка елемента в кінець дерева займе 1000 кроків.


====== 155


@ Рис. 7.1. Дерева, побудовані в різному порядку


Припустимо тепер, що ми хочемо додати до дерева ще 1000 вузлів. Якщо дерево залишається збалансованим, то всі 1000 вузлів помістяться на наступному рівні дерева. При цьому для вставки нових елементів буде потрібно близько 10 * 1000 = 10.000 кроків. Якщо дерево було не збалансовано і залишається таким в процесі росту, то при вставці кожного нового елемента воно буде ставати все вище. Вставка елементів при цьому потрібно близько 1000 + +1001 + ... +2000 = 1,5 мільйона кроків.

Хоча не можна бути впевненим, що елементи будуть додаватися і віддалятися з дерева в потрібному порядку, можна використовувати методи, які будуть підтримувати збалансованість дерева, незалежно від порядку вставки або видалення елементів.

АВЛ дерева

АВЛ дерева (AVL trees) були названі на честь російських математиків Адельсона Бєльського і Лендіса, які їх винайшли. Для кожного вузла АВЛ дерева, висота лівого і правого піддерев відрізняється не більше, ніж на одиницю. На рис. 7.2 показано кілька АВЛ дерев.

Хоча АВЛ дерево може бути трохи вище, ніж повне дерево з тим же числом вузлів, воно також має висоту порядку O (log (N)). Це означає, що пошук вузла в АВЛ дереві займає час порядку O (log (N)), що досить швидко. Не настільки очевидно, що можна вставити або видалити елемент з АВЛ дерева за час порядку O (log (N)), зберігаючи при цьому порядок дерева.


====== 156


@ Рис. 7.2. АВЛ дерева


Процедура, яка вставляє в дерево новий вузол, рекурсивно спускається вниз по дереву, щоб знайти місце розташування вузла. Після вставки елемента, відбуваються повернення з рекурсивних викликів процедури і зворотний прохід вгору по дереву. При кожному повернення з процедури, вона перевіряє, чи зберігається все ще властивість АВЛ дерев на верхньому рівні. Цей тип зворотного рекурсії, коли процедура виконує важливі дії при виході з ланцюжка рекурсивних викликів, називається висхідною (bottom up) рекурсією.

При зворотному проході вгору по дереву, процедура також перевіряє, чи не змінилася висота піддерева, з яким вона працює. Якщо процедура доходить до точки, в якій висота піддереві не змінилася, то висота наступних піддерев також не могла змінитися. У цьому випадку, знову потрібно балансування дерева, і процедура може закінчити перевірку.

Наприклад, дерево зліва на рис. 7.3 є збалансованим АВЛ деревом. Якщо додати до дерева новий вузол E, то вийде середнє дерево на малюнку. Потім виконується прохід вгору по дереву від нового вузла E. У самому вузлі E дерево збалансовано, так як обидва його піддереві порожні і мають однакову висоту 0.

У вузлі D дерево також збалансовано, так як його ліве піддерево пусте, і має тому висоту 0. Праве піддерево містить єдиний вузол E, і тому його висота дорівнює 1. Висоти піддерев відрізняються не більше, ніж на одиницю, тому дерево збалансовано у вузлі D.

У вузлі C дерево вже не збалансовано. Ліве піддерево вузла C має висоту 0, а праве - висоту 2. Ці піддерева можна збалансувати, як показано на рис. 7.3 праворуч, при цьому вузол C замінюється вузлом D. Тепер піддерево з коренем у вузлі D містить вузли C, D і E, і має висоту 2. Зауважте, що висота піддерева з коренем у вузлі C, яке раніше знаходилося в цьому місці, також була дорівнює 2 до вставки нового вузла. Так як висота піддереві не змінилася, то дерево також виявиться збалансованим у всіх вузлах вище D.

Обертання АВЛ дерев

При вставці вузла в АВЛ дерево, в залежності від того, в яку частину дерева додається вузол, існує чотири варіанти балансування. Ці способи називаються правим та лівим обертанням, і обертанням вліво-вправо і вправо-вліво, і позначаються R, L, LR і RL.

Припустимо, що в АВЛ дерево вставляється новий вузол, і тепер дерево стає незбалансованим у вузлі X, як показано на рис. 7.4. На малюнку зображені тільки вузол X і два його дочірніх вузла, а решта частини дерева позначені трикутниками, оскільки їх не потрібно розглядати докладно.

Новий вузол може бути вставлений у будь-який з чотирьох піддерев вузла X, зображених у вигляді трикутників. Якщо ви вставляєте вузол в одне з цих піддерев, то для балансування дерева потрібно виконати відповідне обертання. Пам'ятайте, що іноді балансування не потрібна, якщо вставка нового вузла не порушує впорядкованість дерева.

Праве обертання

Спочатку припустимо, що новий вузол вставляється в піддерево R на рис. 7.4. У цьому випадку не потрібно змінювати два правих піддерева вузла X, тому їх можна об'єднати, зобразивши одним трикутником, як показано на рис. 7.5. Новий вузол вставляється в дерево T 1, при цьому піддерево T A з коренем у вузлі A стає не менш, ніж на два рівні вище, ніж піддерево T 3.

Насправді, оскільки до вставки нового вузла дерево було АВЛ деревом, то T A повинно було бути вище піддереві T3 не більше, ніж на один рівень. Після вставки одного вузла T A повинно бути вище піддерева T 3 рівно на два рівні.

Також відомо, що піддерево T 1 вище піддерева T 2 не більше, ніж на один рівень. Інакше вузол X не був би самим нижнім вузлом з незбалансованими піддерев. Якщо б T 1 було на два рівні вище, ніж T 2, то дерево було б незбалансованим у вузлі A.


@ Рис. 7.4. Аналіз незбалансованого АВЛ дерева


======== 158


@ Рис. 7.5. Вставка нового вузла в піддерево R


У цьому випадку, можна змінити порядок розташування вузли за допомогою правого обертання (right rotation), як показано на рис. 7.6. Це обертання називається правим, так як вузли A і X як би обертаються вправо.

Зауважимо, що це обертання зберігає порядок «менше» розташування вузлів дерева. При симетричному обході будь-якого з таких дерев звернення до всіх піддерев і вузлах дерева відбувається в порядку T 1, A, T 2, X, T 3. Оскільки симетричний обхід обох дерев відбувається однаково, то й порядок розташування елементів у них буде однаковим.

Важливо також зауважити, що висота піддерева, з яким ми працюємо, залишається незмінною. Перед тим, як був вставлений новий вузол, висота піддереві була дорівнює висоті піддерева T 2 плюс 2. Після вставки вузла та виконання правого обертання, висота піддереві також залишається рівною висоті піддерева T 2 плюс 2. Всі частини дерева, що лежать нижче вузла X при цьому також залишаються збалансованими, тому не потрібно продовжувати балансування дерева далі.

Ліве обертання

Ліве обертання (left rotation) виконується аналогічно правому. Воно використовується, якщо новий вузол вставляється в піддерево L, показане на рис. 7.4. На рис. 7.7 показано АВЛ дерево до і після лівого обертання.


@ Рис. 7.6. Праве обертання


======== 159


@ Рис. 7.7. До і після лівого обертання


Обертання вліво вправо

Якщо вузол вставляється в піддерево LR, показане на рис. 7.4, потрібно розглянути ще один нижележащий рівень. На рис. 7.8. показано дерево, в якій новий вузол вставляється в ліву частину T 2 піддереві LR. Так само легко можна вставити вузол в праве піддерево T 3. В обох випадках, піддерева T A і T C залишаться АВЛ піддеревами, але піддерево T X вже не буде таким.

Так як дерево до вставки вузла було АВЛ деревом, то T A було вище T 4 не більше, ніж на один рівень. Оскільки доданий тільки один вузол, то T A виросте тільки на один рівень. Це означає, що T A тепер буде точно на два рівні вище T 4.

Також відомо, що піддерево T 2 не більш, ніж на один рівень вище, ніж T 3. Інакше T C не було би збалансованим, і вузол X не був би самим нижнім в дереві вузлом з незбалансованими піддерев.

Піддерево T 1 повинне мати ту ж глибину, що і T 3. Якби воно було коротше, то піддерево T A було б не збалансовано, що знову суперечить припущенню про те, що вузол X - самий нижній вузол в дереві, що має незбалансовані піддерева. Якби піддерево T 1 мало велику глибину, ніж T 3, то глибина піддерева T 1 була б на 2 рівні більше, ніж глибина піддерева T 4. У цьому випадку дерево було б незбалансованим до вставки в нього нового вузла.

Все це означає, що нижні частини дерев виглядають в точності так, як показано на рис. 7.8. Піддерево T 2 має найбільшу глибину, глибина T 1 і T 3 на один рівень менше, а T 4 розташовано ще на один рівень вище, ніж T 3 та T 3.


@ Рис. 7.8. Вставка нового вузла в піддерево LR


========== 160


@ Рис. 7.9. Обертання вліво вправо


Використовуючи ці факти, можна збалансувати дерево, як показано на рис. 7.9. Це називається обертанням вліво вправо (left right rotation), так як при цьому спочатку вузли A і C як би обертаються вліво, а потім вузли C і X обертаються вправо.

Як і інші обертання, обертання цього типу не змінює порядок елементів у дереві. При симетричному обході дерева до і після обертання звернення до вузлів і піддерев відбувається в порядку: T 1, A, T 2, C, T 3, X, T 4.

Висота дерево після балансування також не змінюється. До вставки нового вузла, праве піддерево мало висоту піддерева T 1 плюс 2. Після балансування дерева, висота цього піддерева знову буде дорівнює висоті T 1 плюс 2. Це означає, що інша частина дерева також залишається збалансованою, і немає необхідності продовжувати балансування далі.

Обертання вправо-вліво

Обертання вправо-вліво (right left rotation) аналогічно обертанню вліво вправо (). Воно використовується для балансування дерева після вставки вузла в піддерево RL на рис. 7.4. На рис. 7.10 показано АВЛ дерево до і після обертання вправо-вліво.

Резюме

На рис. 7.11 показані всі можливі обертання АВЛ дерева. Всі вони зберігають порядок симетричного обходу дерева, і висота дерева при цьому завжди залишається незмінною. Після вставки нового елемента та виконання відповідного обертання, дерево знову опиняється збалансованим.

Вставка вузлів мовою Visual Basic

Перед тим, як перейти до обговорення видалення вузлів з АВЛ дерев, в цьому розділі обговорюються деякі деталі реалізації вставки вузла в АВЛ дерево мовою Visual Basic.

Крім звичайних полів LeftChild і RightChild, клас AVLNode містить також поле Balance, яке вказує, яке з піддерев вузла вище. Його значення дорівнює -1, якщо ліве піддерево вище, 1 - якщо вище праве, і 0 - якщо обидва піддереві мають однакову висоту.


====== 161


@ Рис. 7.10. До і після обертання вправо-вліво


Public LeftChild As AVLNode

Public RightChild As AVLNode

Public Balance As Integer


Щоб зробити код більш простим для читання, можна використовувати постійні LEFT_HEAVY, RIGHT_HEAVY, і BALANCED для подання цих значень.


Global Const LEFT_HEAVY = -1

Global Const BALANCED = 0

Global Const RIGHT_HEAVY = 1


Процедура InsertItem, подана нижче, рекурсивно спускається вниз по дереву в пошуку нового місця розташування елемента. Коли вона доходить до нижнього рівня дерева, вона створює новий вузол і вставляє його в дерево.

Потім процедура InsertItem використовує висхідну рекурсію для балансування дерева. При виході з рекурсивних викликів процедури, вона рухається назад по дереву. При кожному повернення з процедури, вона встановлює параметр has_grown, щоб визначити, чи збільшилася висота піддерева, яке вона залишає. У примірнику процедури InsertItem, який викликав цей рекурсивний виклик, процедура використовує цей параметр для визначення того, чи є проверяемое дерево незбалансованим. Якщо це так, то процедура застосовує для балансування дерева відповідне обертання.

Припустимо, що процедура зараз звертається до вузла X. Припустимо, що вона перед цим зверталася до правого піддерево знизу від вузла X і що параметр has_grown дорівнює true, означаючи, що праве піддерево збільшилася. Якщо піддерева вузла X до цього мали однакову висоту, тоді права піддерево стане тепер вище лівого. У цій точці дерево збалансовано, але піддерево з коренем у вузлі X збільшилась, так як зросла його праве піддерево.

Якщо ліве піддерево вузла X спочатку було вище, ніж праве, то ліве і праве піддерева тепер будуть мати однакову висоту. Висота піддерева з коренем у вузлі X не змінилася - вона як і раніше дорівнює висоті лівого піддерева плюс 1. У цьому випадку процедура InsertItem встановить значення змінної has_grown рівним false, показуючи, що дерево збалансовано.


======== 162


@ Рис. 7.11 Різні обертання АВЛ дерева


====== 163


Зрештою, якщо праве піддерево вузла X було спочатку вище лівого, то вставка нового вузла робить дерево незбалансованим у вузлі X. Процедура InsertItem викликає підпрограму RebalanceRigthGrew для балансування дерева. Процедура RebalanceRigthGrew виконує ліве обертання або обертання вправо-вліво, в залежності від ситуації.

Якщо новий елемент вставляється в ліве піддерево, то підпрограма InsertItem виконує аналогічну процедуру.


Public Sub InsertItem (node ​​As AVLNode, parent As AVLNode, _

txt As String, has_grown As Boolean)

Dim child As AVLNode


'Якщо це нижній рівень дерева, помістити

'У батька покажчик на новий вузол.

If parent Is Nothing Then

Set parent = node

parent.Balance = BALANCED

has_grown = True

Exit Sub

End If


'Продовжити з лівим і правим піддерев.

If txt <= parent.Box.Caption Then

'Вставити нащадка в ліве піддерево.

Set child = parent.LeftChild

InsertItem node, child, txt, has_grown

Set parent.LeftChild = child


'Перевірити, чи потрібна балансування. Вона буде

'Не потрібна, якщо вставка вузла не порушила

'Балансування дерева або воно вже було збалансовано

'На більш глибокому рівні рекурсії. У будь-якому випадку

'Значення змінної has_grown дорівнюватиме False.

If Not has_grown Then Exit Sub


If parent.Balance = RIGHT_HEAVY Then

'Переважає право гілку, тепер баланс

'Відновлений. Це піддерево не виросло,

'Тому дерево збалансовано.

parent.Balance = BALANCED

has_grown = False

ElseIf parent.Balance = BALANCED Then

'Було збалансовано, тепер переважує ліва гілка.

'Піддерево все ще збалансовано, але воно виросло,

'Тому необхідно продовжити перевірку дерева.

parent.Balance = LEFT_HEAVY

Else

'Переважувала ліва гілка, залишилося незбалансований.

'Виконати обертання для балансування на рівні

'Цього вузла.

RebalanceLeftGrew parent

has_grown = False

End If 'Завершити перевірку балансування цього вузла.

Else

'Вставити нащадка в праве піддерево.

Set child = parent.RightChild

InsertItem node, child, txt, has_grown

Set parent.RightChild = child


'Перевірити, чи потрібна балансування. Вона буде

'Не потрібна, якщо вставка вузла не порушила

'Балансування дерева або воно вже було збалансовано

'На більш глибокому рівні рекурсії. У будь-якому випадку

'Значення змінної has_grown дорівнюватиме False.

If Not has_grown Then Exit Sub


If parent.Balance = LEFT_HEAVY Then

'Переважувала ліва гілка, тепер баланс

'Відновлений. Це піддерево не виросло,

'Тому дерево збалансовано.

parent.Balance = BALANCED

has_grown = False

ElseIf parent.Balance = BALANCED Then

'Було збалансовано, тепер переважує права

'Гілку. Піддерево все ще збалансовано,

'Але воно виросло, тому необхідно продовжити

'Перевірку дерева.

parent.Balance = RIGHT_HEAVY

Else

'Переважає право гілку, залишилося незбалансований.

'Виконати обертання для балансування на рівні

'Цього вузла.

RebalanceRightGrew parent

has_grown = False

End If 'Завершити перевірку балансування цього вузла.

End If 'End if для лівого піддерева else праве піддерево.

End Sub


======== 165


Private Sub RebalanceRightGrew (parent As AVLNode)

Dim child As AVLNode

Dim grandchild As AVLNode


Set child = parent.RightChild


If child.Balance = RIGHT_HEAVY Then

'Виконати ліве обертання.

Set parent.RightChild = child.LeftChild

Set child.LeftChild = parent

parent.Balance = BALANCED

Set parent = child

Else

'Виконати обертання вправо-вліво.

Set grandchild = child.LeftChild

Set child.LeftChild = grandchild.RightChild

Set grandchild.RightChild = child

Set parent.RightChild = grandchild.LeftChild

Set grandchild.LeftChild = parent

If grandchild.Balance = RIGHT_HEAVY Then

parent.Balance = LEFT_HEAVY

Else

parent.Balance = BALANCED

End If

If grandchild.Balance = LEFT_HEAVY Then

child.Balance = RIGHT_HEAVY

Else

child.Balance = BALANCED

End If

Set parent = grandchild

End If 'End if для правого обертання else подвійне праве

'Обертання.

parent.Balance = BALANCED

End Sub


Видалення вузла з АВЛ дерева

У 6 чолі було показано, що видалити елемент з впорядкованого дерева складніше, ніж вставити його. Якщо видаляється елемент має лише одного нащадка, можна замінити його цим нащадком, зберігши при цьому порядок дерева. Якщо у дерева два дочірніх вузла, то він замінюється на самий правий вузол в лівій гілки дерева. Якщо в цього вузла існує лівий нащадок, то цей лівий нащадок також займає його місце.


====== 166


Так як АВЛ дерева є особливим типом впорядкованих дерев, то для них потрібно виконати ті ж самі кроки. Тим не менш, після їх завершення необхідно повернутися назад по дереву, щоб переконатися в тому, що воно залишилося збалансованим. Якщо знайдеться вузол, для якого не виконується властивість АВЛ дерев, то потрібно виконати для балансування дерева відповідне обертання. Хоча це ті ж самі обертання, які використовувалися раніше для вставки вузла в дерево, вони застосовуються в інших випадках.

Ліве обертання

Припустимо, що ми видаляємо вузол з лівого піддерева вузла X. Також припустимо, що праве піддерево або урівноважене, або висота його правої половини на одиницю більше, ніж висота лівої. Тоді ліве обертання, показане на рис. 7.12, призведе до балансуванню дерева у вузлі X.

Нижній рівень піддерева T 2 зафарбований сірим кольором, щоб показати, що піддерево T B або урівноважене (T 2 і T 3 мають однакову висоту), або його права половина вище (T 3 вище, ніж T 2). Іншими словами, зафарбований рівень може існувати в піддереві T 2 або відсутнім.

Якщо T 2 і T 3 мають однакову висоту, то висота піддерева T X з коренем у вузлі X не змінюється після видалення вузла. Висота T X при цьому залишається рівною висоті піддерева T 2 плюс 2. Так як ця висота не змінюється, то дерево вище цього вузла залишається збалансованим.

Якщо T 3 вище, ніж T 2, то піддерево T X стає нижче на одиницю. У цьому випадку, дерево може бути незбалансованим вище вузла X, тому необхідно продовжити перевірку дерева, щоб визначити, чи виконується властивість АВЛ дерев для предків вузла X.

Обертання вправо-вліво

Припустимо тепер, що вузол видаляється з лівого піддерева вузла X, але ліва половина правого піддерева вище, ніж права. Тоді для балансування дерева потрібно використовувати обертання вправо-вліво, показане на рис. 7.13.

Якщо ліве або праве піддерева T 2 або T 3 вище, то обертання вправо-вліво призведе до балансуванню піддерева T X, і зменшить при цьому висоту T X на одиницю. Це означає, що дерево вище вузла X може бути незбалансованим, тому необхідно продовжити перевірку виконання властивості АВЛ дерев для предків вузла X.


@ Рис. 7.12. Ліве обертання при видаленні вузла


======== 167


@ Рис. 7.13. Обертання вправо вліво при видаленні вузла


Інші обертання

Решта обертання виконуються аналогічно. У цьому випадку видаляється вузол знаходиться у правому піддереві вузла X. Ці чотири обертання - ті ж самі, які використовувалися для балансування дерева при вставці вузла, за одним винятком.

Якщо новий вузол вставляється в дерево, то перше що виконується обертання здійснює балансування піддерева T X, не змінюючи його висоту. Це означає, що дерево вище вузла T X буде при цьому залишатися збалансованим. Якщо ж ці обертання використовуються після видалення вузла з дерева, то обертання може зменшити висоту піддерева T X на одиницю. У цьому випадку, не можна бути впевненим, що дерево вище вузла X залишилося збалансованим. Потрібно продовжити перевірку виконання властивості АВЛ дерев вгору по дереву.

Реалізація видалення вузлів на мові Visual Basic

Підпрограма DeleteItem видаляє елементи з дерева. Вона рекурсивно спускається по дереву в пошуку видаляється елемента і коли вона знаходить шуканий вузол, то видаляє його. Якщо в цього вузла немає нащадків, то процедура завершується. Якщо є тільки один нащадок, то процедура замінює вузол його нащадком.

Якщо вузол має двох нащадків, процедура DeleteItem викликає процедуру ReplaceRightMost для заміни шуканого вузла найбільш правим вузлом у його лівій гілки. Процедура ReplaceRightMost виконується приблизно так само, як і процедура з 6 глави, яка видаляє елементи з звичайного (неупорядкованого) дерева. Основна відмінність виникає при поверненні з процедури і рекурсивному проході вгору по дереву. При цьому процедура ReplaceRightMost використовує висхідну рекурсію, щоб переконатися, що дерево залишається збалансованим для всіх вузлів.

При кожному повернення з процедури, примірник процедури ReplaceRightMost викликає підпрограму RebalanceRightShrunk, щоб переконатися, що дерево в цій точці збалансовано. Так як процедура ReplaceRightMost опускається по правій гілці, то вона завжди використовує для виконання балансування підпрограму RebalanceRightShrunk, а не RebalanceLeftShrunk.

При першому виклику підпрограми ReplaceRightMost процедура DeleteItem направляє її по лівій від видаляється вузла гілки. При поверненні з першого виклику підпрограми ReplaceRightMost, процедура DeleteItem використовує підпрограму RebalanceLeftShrunk, щоб переконатися, що дерево збалансовано в цій точці.


========= 168


Після цього, один за іншим відбуваються рекурсивні повернення з процедури DeleteItem при проході дерева в зворотному напрямку. Так само, як і процедура ReplaceRightmost, процедура DeleteItem викликає підпрограми RebalanceRightShrunk або RebalanceLeftShrunk в залежності від того, яким шляхом відбувається спуск по дереву.

Підпрограма RebalanceLeftShrunk аналогічна підпрограмі RebalanceRightShrunk, тому вона не показана в наступному коді.


Public Sub DeleteItem (node ​​As AVLNode, txt As String, shrunk As Boolean)

Dim child As AVLNode

Dim target As AVLNode


If node Is Nothing Then

Beep

MsgBox "Елемент" & txt & "не міститься в дереві."

shrunk = False

Exit Sub

End If


If txt <node.Box.Caption Then

Set child = node.LeftChild

DeleteItem child, txt, shrunk

Set node.LeftChild = child

If shrunk Then RebalanceLeftShrunk node, shrunk

ElseIf txt> node.Box.Caption Then

Set child = node.RightChild

DeleteItem child, txt, shrunk

Set node.RightChild = child

If shrunk Then RebalanceRightShrunk node, shrunk

Else

Set target = node

If target.RightChild Is Nothing Then

'Нащадків немає або є тільки правий.

Set node = target.LeftChild

shrunk = True

ElseIf target.LeftChild Is Nothing Then

'Є тільки правий нащадок.

Set node = target.RightChild

shrunk = True

Else

'Є два нащадка.

Set child = target.LeftChild

ReplaceRightmost child, shrunk, target

Set target.LeftChild = child

If shrunk Then RebalanceLeftShrunk node, shrunk

End If

End If

End Sub


Private Sub ReplaceRightmost (repl As AVLNode, shrunk As Boolean, target As AVLNode)

Dim child As AVLNode


If repl.RightChild Is Nothing Then

target.Box.Caption = repl.Box.Caption

Set target = repl

Set repl = repl.LeftChild

shrunk = True

Else

Set child = repl.RightChild

ReplaceRightmost child, shrunk, target

Set repl.RightChild = child

If shrunk Then RebalanceRightShrunk repl, shrunk

End If

End Sub


Private Sub RebalanceRightShrunk (node ​​As AVLNode, shrunk As Boolean)

Dim child As AVLNode

Dim child_bal As Integer

Dim grandchild As AVLNode

Dim grandchild_bal As Integer


If node.Balance = RIGHT_HEAVY Then

'Права частина переважує, тепер баланс відновлено.

node.Balance = BALANCED

ElseIf node.Balance = BALANCED Then

'Було збалансовано, тепер переважує ліва частина.

node.Balance = LEFT_HEAVY

shrunk = False

Else

'Ліва частина переважує, тепер не збалансовано.

Set child = node.LeftChild

child_bal = child.Balance

If child_bal <= 0 Then

"Правое обертання.

Set node.LeftChild = child.RightChild

Set child.RightChild = node

If child_bal = BALANCED Then

node.Balance = LEFT_HEAVY

child.Balance = RIGHT_HEAVY

shrunk = False

Else

node.Balance = BALANCED

child.Balance = BALANCED

End If

Set node = child

Else

'Обертання вліво вправо.

Set grandchild = child.RightChild

grandchild_bal = grandchild.Balance

Set child.RightChild = grandchild.LeftChild

Set grandchild.LeftChild = child

Set node.LeftChild = grandchild.RightChild

Set grandchild.RightChild = node

If grandchild_bal = LEFT_HEAVY Then

node.Balance = RIGHT_HEAVY

Else

node.Balance = BALANCED

End If

If grandchild_bal = RIGHT_HEAVY Then

child.Balance = LEFT_HEAVY

Else

child.Balance = BALANCED

End If

Set node = grandchild

grandchild.Balance = BALANCED

End If

End If

End Sub


Програма AVL оперує АВЛ деревом. Введіть текст і натисніть на кнопку Add, щоб додати елемент до дерева. Введіть значення, і натисніть на кнопку Remove, щоб видалити цей елемент із дерева. На рис. 7.14 показана програма AVL.

Б дерева

Б дерева (B trees) є іншою формою збалансованих дерев, трохи більш наочною, ніж АВЛ дерева. Кожен вузол в Б дереві може містити кілька ключів даних і кілька покажчиків на дочірні вузли. Оскільки кожен вузол містить кілька елементів, такі вузли іноді називаються блоками.


======= 171


@ Рис. 7.14. Програма AVL


Між кожною парою сусідніх покажчиків знаходиться ключ, який можна використовувати для визначення гілки, по якій потрібно дотримуватися при вставці або пошуку елемента. Наприклад, в дереві, показаному на рис. 7.15, кореневий вузол містить два ключі: G і R. Щоб знайти елемент зі значенням, яке йде перед G, потрібно шукати в першій гілки. Щоб знайти елемент, що має значення між G і R, перевіряється друга гілка. Щоб знайти елемент, який слід за R, вибирається третя гілка.

Б дерево порядку K має наступні властивості:

  • Кожен вузол містить не більше 2 * K ключів.

  • Кожен вузол, крім може бути кореневого, містить не менш K ключів.

  • Внутрішній вузол, що має M ключів, має M + 1 дочірніх вузлів.

  • Усі листки дерева знаходяться на одному рівні.

Б дерево на рис. 7.15 має 2 порядок. Кожен вузол може мати до 4 ключів. Кожен вузол, крім може бути кореневого, повинен мати не менше двох ключів. Для зручності, вузли Б дерева зазвичай мають парне число ключів, тому порядок дерева зазвичай є цілим числом.

Виконання вимоги, щоб кожен вузол Бдерева порядку K містив від K до 2 * K ключів, підтримує дерево збалансованим. Так як кожен вузол повинен мати не менше K ключів, він повинен при цьому мати не менше K + 1 дочірніх вузлів, тому дерево не може стати занадто високим і тонким. Найбільша висота Б дерева, що містить N вузлів, може бути дорівнює O (log K +1 (N)). Це означає, що складність алгоритму пошуку в такому дереві порядку O (log (N)). Хоча це і не так очевидно, операції вставки і видалення елемента з Б дерева також мають складність порядку O (log (N)).


@ Рис. 7.15. Б дерево


======= 172


Продуктивність Б дерев

Застосування Б дерев особливо корисно при розробці великих додатків, що працюють з базами даних. При досить великому порядку Б дерева, будь-який елемент в дереві можна знайти після перевірки всього декількох вузлів. Наприклад, висота Б дерева 10 порядку, що містить мільйон записів, не може бути більше log 11 (1.000.000), або вище шести рівнів. Щоб знайти певний елемент, потрібно перевірити не більше шести вузлів.

Збалансоване двійкове дерево з мільйоном елементів мало б висоту log 2 (1.000.000), або близько 20. Тим не менш, вузли двійкового дерева містять лише по одному ключовому значенням. Для пошуку елемента в двійковому дереві, довелося б перевірити 20 вузлів і 20 значень. Для пошуку елемента в Б дереві довелося б перевірити 5 вузлів і 100 ключів.

Застосування Б дерев може забезпечити більш високу швидкість роботи, якщо перевірка ключів виконується відносно просто, на відміну від перевірки вузлів. Наприклад, якщо база даних знаходиться на диску, читання даних з диска може відбуватися досить повільно. Коли ж дані знаходяться в пам'яті, їх перевірка може відбуватися дуже швидко.

Читання даних з диска відбувається великими блоками, і зчитування цілого блоку займає стільки ж часу, скільки і читання одного байта. Якщо вузли Б дерева не занадто великі, то читання вузла Б дерева з диска займе не більше часу, ніж читання вузла двійкового дерева. У цьому випадку, для пошуку 5 вузлів в Б дереві потрібно виконати 5 повільних звернень до диску, плюс 100 швидких звернень до пам'яті. Пошук 20 вузлів в двійковому дереві зажадає 20 повільних звернень до диска і 20 швидких звернень до пам'яті, при цьому пошук в двійковому дереві буде більш повільним, оскільки час, витрачений на 15 зайвих звернень до диску буде набагато більше, ніж заощаджений час 80 звернень до пам'яті . Питання, пов'язані зі зверненням до диску, пізніше обговорюються в цій главі більш докладно.

Вставка елементів в Б дерево

Щоб вставити новий елемент в Б дерево, знайдемо лист, у який він повинен бути поміщений. Якщо цей вузол містить менше, ніж 2 * K ключів, то в цьому вузлі залишається місце для додавання нового елемента. Вставимо новий вузол на місце так, щоб порядок елементів усередині вузла не порушився.

Якщо вузол уже містить 2 * K елементів, то місця для нового елемента у вузлі вже не залишається. Розіб'ємо тоді вузол на два нових вузла, помістивши в кожний з них K елементів у правильному порядку. Потім середній елемент перемістимо в батьківський вузол.

Наприклад, припустимо, що ми хочемо помістити новий елемент Q в Б дерево, показане на рис. 7.15. Цей новий елемент повинен знаходитися в другому листі, який вже заповнений. Для розбиття цього вузла, розділимо елементи J, K, L, N і Q між двома новими вузлами. Помістимо елементи J і K в лівий вузол, а елементи N і Q - у правий. Потім перемістимо середній елемент, L в батьківський вузол. На рис. 7.16 показано нове дерево.


========= Xiv


У розділі 11 обговорюються методи збереження і пошуку елементів, що працюють навіть швидше, ніж це можливо при використанні дерев, сортування або пошуку. У цьому розділі також описані деякі методи хешування, включаючи використання блоків і зв'язкових списків, і кілька варіантів відкритої адресації.

У главі 12 описана інша категорія алгоритмів - мережеві алгоритми. Деякі з цих алгоритмів, такі як обчислення найкоротшого шляху, безпосередньо застосовні до фізичних мереж. Ці алгоритми також можуть побічно застосовуватися для вирішення інших завдань, які на перший погляд не здаються пов'язаними з мережами. Наприклад, алгоритми пошуку найкоротшого відстані можуть розбивати мережу на райони або визначати критичні завдання в розкладі проекту.

У розділі 13 пояснюються методи, застосування яких стало можливим завдяки введенню класів в 4 й версії Visual Basic. Ці методи використовують об'єктно орієнтований підхід для реалізації нетипового для традиційних алгоритмів поведінки.


=================== Xv


Апаратні вимоги

Для роботи з прикладами вам буде потрібно комп'ютер, конфігурація якого задовольняє вимогам для роботи програмного середовища Visual Basic. Ці вимоги виконуються майже для всіх комп'ютерів, на яких може працювати операційна система Windows.

На комп'ютерах різної конфігурації алгоритми виконуються з різною швидкістю. Комп'ютер з процесором Pentium Pro з тактовою частотою 2000 МГц і 64 Мбайт оперативної пам'яті буде працювати набагато швидше, ніж машина з 386 процесором і всього 4 Мбайт пам'яті. Ви швидко дізнаєтеся, на що здатне вашого апаратного забезпечення.

Зміни у другому виданні

Найбільше зміна в новій версії Visual Basic - це поява класів. Класи дозволяють розглянути деякі завдання з іншого боку, дозволяючи використовувати більш простий і природний підхід до розуміння і застосування багатьох алгоритмів. Зміни в коді програм в цьому викладі використовують переваги, надані класами. Їх можна розбити на три категорії:

  1. Заміна псевдоуказателей класами. Хоча всі алгоритми, які були написані для старих версій VB, все ще працюють, багато хто з тих, що були написані з застосуванням псевдоуказателей (описаних у 2 розділі), набагато простіше зрозуміти, використовуючи класи.

  2. Інкапсуляція. Класи дозволяють укласти алгоритм в компактний модуль, який легко використовувати в програмі. Наприклад, за допомогою класів можна створити кілька зв'язкових списків і не писати при цьому додатковий код для управління кожним списком окремо.

  3. Об'єктно орієнтовані технології. Використання класів також дозволяє легше зрозуміти деякі об'єктно орієнтовані алгоритми. У розділі 13 описуються методи, які складно реалізувати без використання класів.

Як користуватися цим матеріалом

У розділі 1 даються загальні поняття, які використовуються протягом усього викладу, тому вам слід почати читання з цієї глави. Вам варто ознайомитися з цією тематикою, навіть якщо ви не хочете відразу ж досягти глибокого розуміння алгоритмів.

У 6 чолі обговорюються поняття, які використовуються в 7, 8 і 12 розділах, тому вам слід прочитати 6 главу до того, як братися за них. Інші глави можна читати в будь-якому порядку.


============= Xvi


У табл. 1 показані три можливих навчальних плану, якими ви можете керуватися при вивченні матеріалу залежно від того, наскільки широко ви хочете ознайомитися з алгоритмами. Перший план включає в себе освоєння основних методів і структур даних, які можуть бути корисні при розробці вами власних програм. Другий крім цього описує також основні алгоритми, такі як алгоритми сортування та пошуку, які можуть знадобитися при написанні більш складних програм.

Останній план дає порядок для вивчення всього матеріалу цілком. Хоча 7 і 8 глави логічно випливають з 6 глави, вони складніші для вивчення, ніж наступні розділи, тому вони вивчаються дещо пізніше.

Чому саме Visual Basic?

Найбільш часто зустрічаються скарги на повільне виконання програм, написаних на Visual Basic. Багато інших компілятори, такі як Delphi, Visual C + + дають більш швидкий і гнучкий код, і надають програмісту більш потужні засоби, ніж Visual Basic. Тому логічно поставити запитання - «Чому я повинен використовувати саме Visual Basic для написання складних алгоритмів? Чи не краще було б використовувати Delphi або C + + або, принаймні, написати алгоритми на одному з цих мов і підключати їх до програм на Visual Basic за допомогою бібліотек? »Написання алгоритмів на Visual Basic має сенс з кількох причин.

По-перше, розробка програми на Visual C + + набагато складніше і проблематичніше, ніж на Visual Basic. Некоректна реалізація в програмі всіх деталей програмування під Windows може призвести до збоїв у вашому додатку, середовищі розробки, або в самій операційній системі Windows.

По-друге, розробка бібліотеки на мові C + + для використання в програмах на Visual Basic включає в себе багато потенційних небезпек, характерних і для додатків Windows, написаних на C + +. Якщо бібліотека буде неправильно взаємодіяти з програмою на Visual Basic, вона також приведе до збоїв у програмі, а можливо і в середовищі розробки і системі.

По-третє, багато алгоритмів досить ефективні і показують непогану продуктивність навіть при застосуванні не дуже швидких компіляторів, таких, як Visual Basic. Наприклад, алгоритм сортування підрахунком,


@ Таблиця 1. Плани занять


=============== Xvii


описуваний в 9 розділі, сортує мільйон цілих чисел менше ніж за 2 секунди на комп'ютері з процесором Pentium з тактовою частотою 233 Мгц. Використовуючи бібліотеку C + +, можна було б зробити алгоритм трохи швидше, але швидкості версії на Visual Basic і так вистачає для більшості додатків. Скомпільовані за допомогою 5 й версією Visual Basic виконувані файли зводять відставання по швидкості до мінімуму.

У кінцевому рахунку, розробка алгоритмів на будь-якій мові програмування дозволяє більше дізнатися про алгоритми взагалі. У міру вивчення алгоритмів, ви освоїте методи, які зможете застосовувати в інших частинах своїх програм. Після того, як ви оволодієте досконало алгоритмами на Visual Basic, вам буде набагато легше реалізувати їх на Delphi або C + +, якщо це буде необхідно.


============= Xviii


Глава 1. Основні поняття

У цьому розділі містяться загальні поняття, які потрібно засвоїти перед початком серйозного вивчення алгоритмів. Починається вона з питання «Що таке алгоритми?». Перш ніж заглибитися в деталі програмування алгоритмів, варто витратити трохи часу, щоб розібратися в тому, що це таке.

Потім у цій главі дається введення в формальну теорію складності алгоритмів (complexity theory). За допомогою цієї теорії можна оцінити теоретичну обчислювальну складність алгоритмів. Цей підхід дозволяє порівнювати різні алгоритми і передбачати їх продуктивність у різних умовах. У розділі наводиться кілька прикладів застосування теорії складності до невеликих завданням.

Деякі алгоритми з високою теоретичною продуктивністю не надто добре працюють на практиці, тому в даній главі також обговорюються деякі реальні передумови для створення програм. Занадто часте звертання до файлу підкачки або погане використання посилань на об'єкти і колекції може значно знизити продуктивність прекрасного в інших відносинах додатки.

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

Що таке алгоритми?

Алгоритм - це послідовність інструкцій для виконання будь якого завдання. Коли ви даєте кому то інструкції про те, як відремонтувати газонокосарку, спекти торт, ви тим самим задаєте алгоритм дій. Звичайно, подібні побутові алгоритми описуються неформально, наприклад, так:


Перевірте, чи знаходиться машина на стоянці.

Переконайтеся, що машина поставлена ​​на ручне гальмо.

Поверніть ключ.

І т.д.


========== 1


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

Якщо ж складається алгоритм для виконання комп'ютером, ви не можете покладатися на те, що комп'ютер зрозуміє що або, якщо це не описано заздалегідь. Словник комп'ютера (мова програмування) дуже обмежений і всі інструкції для комп'ютера повинні бути сформульовані на цій мові. Тому для написання комп'ютерних алгоритмів використовується формалізований стиль.

Цікаво спробувати написати формалізований алгоритм для звичайних щоденних завдань. Наприклад, алгоритм водіння машини міг би виглядати приблизно так:


Якщо двері закриті:

Вставити ключ у замок

Повернути ключ

Якщо двері залишається закритою, то:

Повернути ключ в інший бік

Повернути ручку дверей

І т.д.


Цей фрагмент «коду» відповідає лише за відкривання дверей; при цьому навіть не перевіряється, які двері відкривається. Якщо двері заїло або в машині встановлена ​​протиугінна система, то алгоритм відкривання дверей може бути досить складним.

Формалізацією алгоритмів займаються вже тисячі років. За 300 років до н.е. Евклід написав алгоритми розподілу кутів навпіл, перевірки рівності трикутників та вирішення інших геометричних задач. Він почав з невеликого словника аксіом, таких як «паралельні лінії не перетинаються» і побудував на їх основі алгоритми для вирішення складних завдань.

Формалізовані алгоритми такого типу добре підходять для задач математики, де повинна бути доведена істинність якого або положення або можливість виконання якихось дій, швидкість ж виконання алгоритму не важлива. Для виконання реальних завдань, пов'язаних з виконанням інструкцій, наприклад завдання сортування на комп'ютері записів про мільйон покупців, ефективність виконання стає важливою частиною постановки завдання.

Аналіз швидкості виконання алгоритмів

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

Простір - час

Багато алгоритми надають вибір між швидкістю виконання і використовуваними програмою ресурсами. Завдання може виконуватися

швидше, використовуючи більше пам'яті, або навпаки, повільніше, зайнявши менший об'єм пам'яті.


=========== 2


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

При цьому ми отримаємо результат практично миттєво, але це зажадає великого обсягу пам'яті. Карта вулиць для великого міста, такого як Бостон або Денвер, може містити сотні тисяч точок. Для такої мережі таблиця найкоротших відстаней містила б понад 10 мільярдів записів. У цьому випадку вибір між часом виконання і обсягом необхідної пам'яті очевидний: поставивши додаткові 10 гігабайт оперативної пам'яті, можна змусити програму виконуватися набагато швидше.

З зв'язку з цим випливає ідея просторово часової складності алгоритмів. При цьому підході складність алгоритму оцінюється в термінах часу і простору, і знаходиться компроміс між ними.

У цьому матеріалі основну увагу приділяється часової складності, але ми також постаралися звернути увагу і на особливі вимоги до обсягу пам'яті для деяких алгоритмів. Наприклад, сортування злиттям (mergesort), обговорювана в 9 розділі, вимагає більше тимчасової пам'яті. Інші алгоритми, наприклад пірамідальна сортування (heapsort), яка також обговорюється в 9 розділі, вимагає звичайного обсягу пам'яті.

Оцінка з точністю до порядку

При порівнянні різних алгоритмів важливо розуміти, як складність алгоритму співвідноситься зі складністю розв'язуваної задачі. При розрахунках за одним алгоритмом сортування тисячі чисел може зайняти 1 секунду, а сортування мільйони - 10 секунд, у той час як розрахунки за іншим алгоритмом можуть зажадати 2 і 5 секунд відповідно. У цьому випадку не можна однозначно сказати, яка з двох програм краще - це буде залежати від вихідних даних.

Різниця продуктивності алгоритмів на завданнях різної обчислювальної складності часто більш важливо, ніж просто швидкість алгоритму. У наведеному вище випадку, перший алгоритм швидше сортує короткі списки, а другий - довгі.

Продуктивність алгоритму можна оцінити по порядку величини. Алгоритм має складність порядку O (f (N)) (вимовляється «Про велике від F від N»), якщо час виконання алгоритму зростає пропорційно функції f (N) зі збільшенням розмірності вихідних даних N. Наприклад, розглянемо фрагмент коду, сортують позитивні числа:


For I = 1 To N

'Пошук найбільшого елемента в списку.

MaxValue = 0

For J = 1 to N

If Value (J)> MaxValue Then

MaxValue = Value (J)

MaxJ = J

End If

Next J

'Висновок найбільшого елемента на друк.

Print Format $ (MaxJ) & ":" & Str $ (MaxValue)

'Обнулення елемента для виключення його з подальшого пошуку.

Value (MaxJ) = 0

Next I


=============== 3


У цьому алгоритмі мінлива циклу I послідовно приймає значення від 1 до N. Для кожного збільшення I мінлива J в свою чергу також приймає значення від 1 до N. Таким чином, в кожному зовнішньому циклі виконується ще N внутрішніх процедур. У результаті внутрішній цикл виконується N * N або N 2 разів і, отже, складність алгоритму порядку O (N 2).

При оцінці порядку складності алгоритмів використовується тільки найбільш швидко зростаюча частина рівняння алгоритму. Припустимо, час виконання алгоритму пропорційно N 3 + N. Тоді складність алгоритму буде дорівнює O (N 3). Відкидання повільно зростаючих частин рівняння дозволяє оцінити поведінку алгоритму при збільшенні розмірності даних завдання N.

При великих N внесок другої частини в рівняння N 3 + N стає все менш помітним. При N = 100, різниця N 3 + N = 1.000.100 та N 3 дорівнює всього 100, або менше ніж 0,01 відсотка. Але це вірно тільки для великих N. При N = 2, різниця між N 3 + N = 10 та N 3 = 8 дорівнює 2, а це вже 20 відсотків.

Постійні множники в співвідношенні також ігноруються. Це дозволяє легко оцінити зміни в обчислювальній складності завдання. Алгоритм, час виконання якого пропорційно 3 * N 2, буде мати порядок O (N 2). Якщо збільшити N в 2 рази, то час виконання завдання зросте приблизно в 2 2, тобто в 4 рази.

Ігнорування постійних множників дозволяє також спростити підрахунок числа кроків алгоритму. У попередньому прикладі внутрішній цикл виконується N 2 разів, при цьому всередині циклу виконується кілька інструкцій. Можна просто підрахувати число інструкцій If, можна підрахувати також інструкції, що виконуються всередині циклу або, крім того, ще й інструкції в зовнішньому циклі, наприклад оператори Print.

Обчислювальна складність алгоритму при цьому буде пропорційна N 2, 3 * N 2 або 3 * N 2 + N. Оцінка складності алгоритму по порядку величини дасть одне і те ж значення O (N 3) і відпаде необхідність у точному підрахунку кількості операторів.

Пошук складних частин алгоритму

Зазвичай найбільш складним є виконання циклів і викликів процедур. У попередньому прикладі, весь алгоритм укладено у двох циклах.


============ 4


Якщо процедура викликає іншу процедуру, необхідно враховувати складність викликається процедури. Якщо в ній виконується фіксоване число інструкцій, наприклад, здійснюється виведення на друк, то при оцінці порядку складності її можна не враховувати. З іншого боку, якщо в викликається процедурі виконується O (N) кроків, вона може вносити значний внесок в складність алгоритму. Якщо виклик процедури здійснюється всередині циклу, цей внесок може бути ще більше.

Наведемо як приклад програму, яка містить повільну процедуру Slow зі складністю порядку O (N 3) і швидку процедуру Fast зі складністю порядку O (N 2). Складність всієї програми буде залежати від співвідношення між цими двома процедурами.

Якщо процедура Slow викликається в кожному циклі процедури Fast, порядки складності процедур перемножуються. У цьому випадку складність алгоритму дорівнює добутку O (N 2) і O (N 3) або O (N 3 N 2) = O (N 5). Наведемо ілюструє цей випадок фрагмент коду:


@ Рис. 7.16. Б дерево після вставки елемента Q


========= 173


Розбиття вузла на два називається розбиттям блоку. Коли воно відбувається, до батьківського вузла додається новий ключ і новий покажчик. Якщо батьківський вузол вже заповнений, то це також може призвести до його розбиття. Це, у свою чергу, вимагатиме додавання нового запису на більш високому рівні і так далі. У найгіршому випадку, вставка елемента викличе «ланцюгову реакцію», яка призведе до змін на всіх вищих рівнях аж до розбиття кореневого вузла.

Коли відбувається розбиття кореневого вузла, Б дерево стає вище. Це єдиний випадок, при якому його висота збільшується. Тому Б дерева мають незвичайним властивістю - вони завжди ростуть від листя до кореня.

Видалення елементів з Б дерева

Теоретично, видалити вузол з Б дерева так само просто, як і вставити його. На практиці, деталі цього процесу досить складні.

Якщо видаляється вузол не є листом, то його потрібно замінити іншим елементом, щоб зберегти порядок елементів. Це схоже на випадок вилучень елемента з впорядкованого дерева або АВЛ дерева і його можна обробляти аналогічно. Замінимо елемент самим крайнім правим елементом з лівої гілки. Цей елемент завжди буде листом. Після заміни елемента, можна просто вважати, що замість нього просто видалений замінив його лист.

Щоб видалити елемент з листа, спочатку потрібно при необхідності зрушити всі інші елементи вліво, щоб заповнити простір, що утворився. Пам'ятайте, що кожен вузол в Б дереві порядку K повинен мати від K до 2 * K елементів. Після видалення елемента з листа, може виявитися, що він містить всього K - 1 елементів.

У цьому випадку, можна спробувати взяти кілька елементів з вузлів на тому ж рівні. Потім можна розподілити елементи в двох вузлах так, щоб вони обидва мали не менше K елементів. На рис. 7.17 об'єкт був видалений з самого лівого листа дерева, при цьому в ньому залишається лише один елемент. Після перерозподілу елементів між вузлом і правим вузлом на тому ж рівні, обидва вузла мають не менше двох ключів. Зауважте, що середній елемент J переміщається в батьківський вузол.


@ Рис. 7.17. Балансування після видалення елемента


======= 174


@ Рис. 7.18. Злиття після видалення елемента


При спробі збалансувати дерево таким чином, може виявитися, що сусідній вузол на тому ж рівні містить всього K елементів. Тоді два вузли разом містять всього 2 * K - 1 елементів, що недостатньо для заповнення двох вузлів. У цьому випадку, всі елементи з обох вузлів можуть поміститися в одному вузлі, тому їх можна злити. Видалимо ключ, який відокремлює два вузла від батька. Помістимо цей елемент і 2 * K - 1 елементів з двох вузлів в один загальний вузол. Цей процес називається злиттям вузлів (bucket merge або bucket join). На рис. 7.18 показано злиття двох вузлів.

При злитті двох вузлів, з батьківського вузла віддаляється ключ, при цьому в батьківському вузлі може залишитися K - 1 елементів. У цьому випадку, може знадобитися балансування або злиття батька з одним з вузлів на його рівні. Це також може призвести до того, що у вузлі на більш високому рівні також залишиться K - 1 елементів, і процес повториться. У найгіршому випадку, видалення приведе до «ланцюгової реакції» злиттів блоків, яка може дійти до кореневого вузла.

При видаленні останнього елемента з кореневого вузла, два його залишилися дочірніх вузла зливаються, утворюючи новий корінь, і дерево при цьому стає коротше на один рівень. Єдиний спосіб зменшення висоти Б дерева - злиття двох дочірніх вузлів кореня і утворення нового кореня.

Програма Btree дозволяє вам оперувати Б деревом. Введіть текст, і натисніть на кнопку Add, щоб додати елемент в дерево. Для видалення елемента введіть його значення і натисніть на кнопку Remove. На рис. 7.19 показано вікно програми Btree з Б деревом 2 порядки.


@ Рис. 7.19. Програма Btree


======== 175


Різновиди Б дерев

Існує кілька різновидів Б дерев, з яких тут описані тільки деякі. Спадні Б дерева (top down B trees) трохи інакше управляють структурою Б дерева. За рахунок розбиття зустрічаються повних вузлів, цей різновид алгоритму використовує при вставці елементів більш наочну спадну рекурсію замість висхідній. Ця також зменшує ймовірність виникнення тривалої послідовності розбиттів блоків.

Іншим різновидом Б дерев є Б + дерева (B + trees). В Б + деревах внутрішні вузли містять тільки ключі даних, а самі записи знаходяться у листі. Це дозволяє Б + деревах зберігати в кожному блоці більше елементів, тому такі дерева коротше, ніж відповідні Б дерева.

Спадні Б дерева

Підпрограма, яка додає новий елемент в Б дерево, спочатку виконує рекурсивний пошук по дереву, щоб знайти блок, в який його потрібно помістити. Коли вона намагається вставити новий елемент на його місце, їй може знадобитися розбити блок і перемістити один з елементів вузла в його батьківський вузол.

При поверненні з рекурсивних викликів процедури, що викликає процедура перевіряє, чи потрібне розбиття батьківського вузла. Якщо так, то елемент міститься в батьківський вузол. При кожному поверненні з рекурсивного виклику, викликає процедура повинна перевіряти, чи не потрібна розбиття наступного предка. Так як ці розбиття блоків відбуваються при поверненні з рекурсивних викликів процедура, це висхідна рекурсія, тому іноді Б дерева, якими маніпулюють таким чином, називаються висхідними Б деревами (bottom up B trees).

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

Коли процедура доходить до листа, в який потрібно помістити елемент, то в його рідному сайті завжди є вільне місце, і якщо програмі потрібно розбити лист, то завжди можна помістити середній елемент у батьківський вузол. Так як при цьому процедура працює з деревом зверху вниз, Б дерева такого типу іноді називаються спадними Б деревами (top down B trees).

При цьому розбиття блоків відбувається частіше, ніж це абсолютно необхідно. У низхідному Б дереві повний вузол розбивається, навіть якщо в його дочірніх вузлах досить багато вільного місця. За рахунок попереднього розбиття вузлів, при використанні низхідного методу в дереві міститься більше порожнього простору, ніж у висхідному Б дереві. З іншого боку, такий підхід зменшує ймовірність виникнення тривалої послідовності розбиттів блоків.

На жаль, не існує низхідній версії для злиття вузлів. При просуванні вниз по дереву, процедура видалення вузлів не може об'єднувати зустрічаються наполовину порожні вузли, тому що в цей момент ще невідомо, чи треба буде об'єднати два дочірніх вузла і видалити елемент з їхнього батька. Так як невідомо також, чи буде видалений елемент з батьківського сайту, то не можна заздалегідь сказати, чи буде потрібно злиття батька з одним з вузлів, що знаходяться на тому ж рівні.


========== 176


Б + дерева

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

У цьому випадку впорядкування елементів може бути досить повільним. Щоб злити два блоки, програмі може знадобитися перемістити безліч записів, кожна з яких може бути досить великою. Аналогічно, для розбиття блоку може знадобитися перемістити безліч записів великого обсягу.

Щоб уникнути переміщення великих блоків даних, програма може записувати у внутрішніх вузлах Б дерева тільки ключі. При цьому вузли також містять посилання на самі записи даних, які записані в іншому місці. Тепер, якщо програмі потрібно змінити порядок блоки, то потрібно перемістити тільки ключі і покажчики, а не самі записи. Цей тип Б дерева називається Б + деревом (B + tree).

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

Наприклад, припустимо, що є Б дерево 2 порядку, тобто кожен вузол має від трьох до п'яти дочірніх вузлів. Таке дерево, що містить мільйон записів, повинно було б мати висоту між log 5 (1.000.000) і log 3 (1.000.000), або між 9 і 13. Щоб знайти елемент у такому дереві, програма повинна виконати від 9 до 13 звернень до диска.

Тепер припустимо, що ті ж мільйон записів знаходяться в Б + дереві, вузли якого мають приблизно той же розмір в байтах. Оскільки у вузлах Б + дерева містяться тільки ключі, то в кожному вузлі дерева може зберігатися до 20 ключів до записів. У цьому випадку, кожен вузол буде мати від 11 до 21 дочірніх вузлів, тому висота дерева буде від log 21 (1.000.000) до log 11 (1.000.000), або між 5 і 6. Щоб знайти елемент, програмі знадобиться всього 6 звернень до диску для знаходження його ключа, і ще одне звернення до диска, щоб вважати сам елемент.

В Б + деревах також просто зв'язати з набором записів безліч ключів. У системі, що оперує записами про співробітників, одне Б + дерево може використовувати як ключі прізвища, а інше - ідентифікаційні номери соціального страхування. Обидва дерева будуть містити покажчики на записи даних, які будуть знаходитися за межами дерев.

Поліпшення продуктивності Б дерев

У цьому розділі описані два методи поліпшення продуктивності Б і Б + дерев. Перший метод дозволяє перерозподілити елементи між вузлами одного рівня, щоб уникнути розбиття блоків. Другий дозволяє поміщати порожні клітинки в дерево, щоб зменшити ймовірність необхідності розбиття блоків у майбутньому.


======= 177


Балансування для усунення розбиття блоків

При додаванні елемента до блоку, який вже заповнений, блок розбивається на два. Цього можна уникнути, якщо виконати балансування цього вузла з одним з вузлів на тому ж рівні. Наприклад, вставка нового елемента Q в Б дерево, показане ліворуч на рис. 7.20 зазвичай викликає розбиття блоку. Цього можна уникнути, виконавши балансування вузла, що містить J, K, L і N і лівого вузла на тому ж рівні, що містить B і E. При цьому виходить дерево, показане на рис. 7.20 справа.

Така балансування має ряд переваг. По-перше, при цьому блоки використовуються більш ефективно. У них перебуває менше порожніх клітинок, при цьому зменшиться кількість витрачається даремно пам'яті.

Що більш важливо, якщо не потрібно буде розбиття блоків, то не знадобиться і переміщення елемента в батьківський вузол. Це запобігає виникненню тривалої послідовності розбиттів блоків.

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

Додавання вільного простору

Припустимо, що є невелика база даних клієнтів, що містить 10 записів. Можна завантажувати записи в Б дерево так, щоб вони заповнювали кожен блок цілком, як показано на рис. 7.21. При цьому дерево містить мало вільного простору, і вставка нового елемента відразу ж призводить до розбиття блоків. Фактично, так як всі блоки заповнені, вона викличе послідовність розбиття блоків, яка дійде до кореневого вузла.

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

У реальних додатках Б дерева зазвичай мають набагато більший порядок, ніж дерева, наведені тут. Додавання вільного простору в дерево значно зменшує необхідність балансування і розбиття блоків. Наприклад, можна додати в Б дерево 10 близько 10 відсотків вільного простору, щоб у кожному вузлі було місце ще для двох елементів. З таким деревом можна буде працювати досить довго, перш ніж виникнуть довгі ланцюжки розбиття блоків.

Це черговий приклад просторово тимчасового компромісу. Добавка у вузли порожнього простору збільшує розмір дерева, але зменшує ймовірність розбиття блоків.


@ Рис. 7.20. Балансування для усунення розбиття блоків


======= 178


@ Рис. 7.21. Щільне заповнення Б дерева


Питання, пов'язані з зверненням до диска

Б і Б + дерева добре підходять для створення великих додатків баз даних. Типове Б + дерево може містити сотні, тисячі і навіть мільйони записів. У цьому випадку в будь-який момент часу в пам'яті буде знаходитися тільки невелика частина дерева і при кожному зверненні до вузла, програмі знадобиться завантажити його з диска. У цьому розділі описані три моменти, враховувати які особливо важливо, якщо дані містяться на диску: застосування псевдоуказателей, вибір розміру блоків, і кешування кореневого вузла.

Псевдоуказателі

Колекції і посилання на об'єкти зручні для побудови дерев в пам'яті, але вони можуть бути даремні при зберіганні дерева на диску. Не можна створити посилання на запис у файлі.

Замість цього можна використовувати методи роботи з псевдоуказателямі, схожі на ті, які були описані в 2 чолі. Замість використання як покажчики на вузли дерева посилань на об'єкти при цьому використовується номер запису вузла у файлі. Припустимо, що Б + дерево 12 порядку використовує 80 байтниє ключі. Структуру даних вузла можна визначити в наступному коді:


Global Const ORDER = 12

Global Const KEYS_PER_NODE = 2 * ORDER


Type BtreeNode

Key (1 To KEYS_PER_NODE) ​​As String * 80 'Ключі.

Child (0 To KEYS_PER_NODE) ​​As Integer 'Покажчики нащадків.

End Type


Значення елементів масиву Child представляють собою номери записів з дочірніх вузлів у файлі. Довільний доступ до даних Б + дерева з файлу здійснюється за допомогою записів, які відповідають структурі BtreeNode.


@ Рис. 7.22. Вільне заповнення Б дерева


====== 179


Dim node As BtreeNode


Open Filename For Random As # filenum Len = Len (node)


Після відкриття файлу, за допомогою оператора Get можна вибрати будь-який запис:


Dim node As BtreeNode


'Вибрати запис з номером recnum.

Get # filenum, recnum, node


Щоб спростити роботу з Б + деревами, можна зберігати вузли Б + дерева і запису даних у різних файлах і використовувати для управління кожним з них псевдоуказателі.

Коли лічильник посилань на об'єкт стає рівним нулю, то Visual Basic автоматично знищує його. Це полегшує роботу зі структурами даних у пам'яті. З іншого боку, якщо програмі більше не потрібна якась або запис у файлі, то вона не може просто очистити всі посилання на неї. Якщо зробити так, то програма більше не зможе використовувати цей запис, але запис і раніше, буде займати місце у файлі.

Програма повинна стежити за невикористовуваними записами, щоб пізніше можна було використовувати їх. Один з простих способів зробити це - вести зв'язний список невикористовуваних записів. Якщо запис більше не потрібна, вона додається до списку. Якщо програмі потрібно місце для нового запису, вона видаляє один запис зі списку. Якщо програмі потрібно вставити ще один елемент, а список порожній, вона збільшує файл даних.

Вибір розміру блоку

Читання даних з диска відбувається блоками, які називаються кластерами. Розмір кластера звичайно становить 512 або 1024 байти, або ще якесь число байтів, рівне ступеню двійки. Читання всього кластеру займає стільки ж часу, скільки і читання одного байта.

Можна скористатися цим фактом і створювати блоки, розмір яких складає ціле число кластерів, а потім вмістити в цей розмір максимальне число ключів або записів. Наприклад, припустимо, що ми вирішили створювати блоки розміром 2048 байт. При створенні Б + дерева з 80 байтними ключами в кожен блок можна помістити 24 ключа і 25 покажчиків (якщо покажчик являє собою 4 байтноє число типу long). Потім можна створити Б + дерево 12 порядку з блоками, які визначаються в наступному коді:


Global Const ORDER = 12

Global Const KEYS_PER_NODE = 2 * ORDER

Type BtreeNode

Key (1 To KEYS_PER_NODE) ​​As String * 80 'Ключ даних.

Child (0 To KEYS_PER_NODE) ​​As Integer 'Покажчики нащадків.

End Type


======= 180


Для того, щоб зчитувати дані максимально швидко, програма повинна використовувати оператор Visual Basic Get для читання вузла цілком. Якщо використовувати цикл For для читання ключів і даних для кожного елемента по черзі, то програмі доведеться звертатися до диска при читанні кожного елемента. Це набагато повільніше, ніж зчитування всього вузла відразу. В одному з тестів, для масиву з 1000 елементів певного користувачем типу читання елементів поодинці зайняло в 27 разів більше часу, ніж читання їх усіх одразу. Наступний код демонструє обидва способи читання даних з вузла:


Dim i As Integer

Dim node As BtreeNode


'Повільний спосіб доступу до даних.

For i = 1 To KEYS_PER_NODE

Get # filenum,, node.Key (i)

Next i


'Швидкий спосіб доступу до даних.

Get # filenum,, node


Кешування вузлів

Кожен пошук в Б дереві починається з кореневого вузла. Можна прискорити пошук, якщо кореневий вузол буде весь час перебувати в пам'яті. Тоді під час пошуку доведеться на один раз менше звертатися до диска. При цьому все одно необхідно записувати кореневий вузол на диск при кожному його зміну, інакше при повторному завантаженні після відмови програми зміни в Б дереві будуть втрачені.

Можна також кешувати в пам'яті й інші вузли Б дерева. Якщо зберігати в пам'яті всі дочірні вузли кореня, то їх також не буде потрібно зчитувати з диска. Для Б дерева порядку K, кореневий вузол буде мати від 1 до 2 * K ключів і тому у нього буде від 2 до 2 * K + 1 дочірніх вузлів. Це означає, що в цьому випадку доведеться кешувати до 2 * K + 1 вузлів.

Програма також може кешувати вузли при обході Б дерева. Наприклад, при прямому обході програма звертається до кожного вузла і потім рекурсивно обходить всі його дочірні вузли. При цьому вона спочатку спускається до першого дочірньому вузла, а після повернення переходить до наступного. При кожному поверненні, програма повинна знову звернутися до батьківського вузла, щоб визначити, до якого з дочірніх вузлів звертатися в наступну чергу. Кешіруя батьківський вузол в пам'яті, програма уникає необхідності знову читати його з диска.

Застосування рекурсії дозволяє програмі автоматично зберігати вузли в пам'яті без використання складної схеми кешування. При кожному виклику рекурсивного алгоритму обходу, визначається локальна змінна, в якій знаходиться вузол до тих пір, поки він не знадобиться. При поверненні з рекурсивного виклику Visual Basic автоматично звільняє цю змінну. Наступний код демонструє, як можна реалізувати цей алгоритм обходу мовою Visual Basic.


======= 181


Private Sub PreorderPrint (node_index As Integer)

Dim i As Integer

Dim node As BtreeNode


Get # filenum, node_index, node 'Кешувати вузол.

Print node_index 'Звернення до вузла.

For i = 0 To KEYS_PER_NODE

If node.Child (i) <0 Then Exit For 'Виклик нащадків.

PreorderPrint node.Child (i) "Виклик нащадка.

Next i

End Sub


База даних на основі Б + дерева

Програма Bplus працює з базою даних на основі Б + дерева, використовуючи два файли даних. Файл Custs.DAT містить записи з даними про клієнтів, а файл Custs.IDX - вузли Б + дерева.

Щоб додати новий запис в базу даних, введіть дані в поле Customer Record (Запис про клієнта), і потім натисніть на кнопку Add. Для пошуку запису заповніть поля Last Name (Прізвище) і First Name (Ім'я) у верхній частині форми і натисніть на кнопку Find (Знайти).

На рис. 7.23 показано вікно програми після виконання пошуку запису для Роду Стівенса. Статистика внизу показує, що дані були знайдені в записі номер 302 після всього лише трьох звернень до диска. Висота Б + дерева в програмі дорівнює 3, і вона містить 1303 записів даних і 118 блоків.

Коли ви вводите запис або проводите пошук, програма Bplus вибирає цей запис з файлу. Після натискання на кнопку Remove програма видаляє запис з бази даних.


@ Рис. 7.23. Програма Bplus


======== 182


Якщо вибрати в меню Display (Показати) команду Internal Nodes (Внутрішні вузли), то програма виведе список внутрішніх вузлів дерева. Вона також виводить поруч з кожним вузлом ключі, щоб показати внутрішню структуру дерева.

За допомогою команди Complete Tree (Все дерево) з меню Display можна вивести структуру дерева цілком. Дані про клієнтів виводяться всередині пунктирних дужок.

Крім звичайних полів адреси та прізвища, програма Bplus також включає поле NextGarbage, яке програма використовує для роботи зі зв'язковим списком невикористаних у файлі записів.


Type CustRecord

LastName As String * 20

FirstName As String * 20

Address As String * 40

City As String * 20

State As String * 2

Zip As String * 10

Phone As String * 12

NextGarbage As Long

End Type


'Розмір запису даних про клієнта.

Global Const CUST_SIZE = 20 + 20 + 40 + 20 + 2 + 10 + 12 + 4


Внутрішні вузли Б + дерева містять ключі, які використовуються для пошуку даних про клієнта. Ключем для запису є прізвище клієнта, доповнена у кінці пробілами до 20 символів і закінчується комою, за якою слідує ім'я клієнта, доповнене пробілами до 20 символів. Наприклад, "Washington .........., George ..............". При цьому повна довжина ключа становить 41 символ.

Кожен внутрішній вузол також містить покажчики на дочірні вузли. Ці покажчики визначають положення записів з даними про клієнта у файлі Custs.DAT. Вузли також включають змінну NumKeys, яка містить число використовуваних ключів.

Програма читає і пише дані блоками по 1024 байта. Якщо припустити, що блок містить K ключів, то в кожному блоці буде K ключів довжиною 41 байт, K + 1 покажчиків на дочірні вузли довжиною по 4 байти, і двухбайтное ціле число NumKeys. При цьому блоки повинні мати максимально можливий розмір і бути не більше 1024 байт.

Розв'язавши рівняння 41 * K + 4 * (K + 1) + 2 <= 1.024, отримаємо K <= 22,62, тому K має дорівнювати 22. У цьому випадку Б + дерево повинно мати 11 порядок, тому воно містить по 22 ключа в кожному блоці. Кожен блок займає 41 * 22 + 4 * (22 + 1) + 2 = 996 байт. Наступний код демонструє визначення блоків у програмі Bplus.


======= 183


Const KEY_SIZE = 41

Const ORDER = 11

Global Const KEYS_PER_NODE = 2 * ORDER


Type Bucket

NumKeys As Integer

Key (1 To KEYS_PER_NODE) ​​As String * KEY_SIZE

Child (0 To KEYS_PER_NODE) ​​As Long

End Type

Global Const BUCKET_SIZE = 2 + _

KEYS_PER_NODE * KEY_SIZE + _

(KEYS_PER_NODE + 1) * 4


Програма Bplus записує блоки Б + дерева в файлі Custs.IDX. Перший запис в цьому файлі містить заголовок, який описує поточний стан Б + дерева. У заголовок входить покажчик на кореневий вузол, поточна висота дерева, вказівник на перший порожній блок у файлі Custs.IDX, і покажчик на перший порожній блок у файлі Custs.DAT.

Щоб спростити читання і запис заголовка, можна визначити ще одну структуру, яка має в точності такий же розмір, що й блоки даних, але містить поля заголовка. Останнє поле у ​​визначенні - це рядок, який заповнює кінець структури, щоб її розмір був точно дорівнює розміру блока.


Global Const HEADER_PADDING = _

BUCKET_SIZE - (7 * 4 + 2)

Type HeaderRecord

NumBuckets As Long

NumRecords As Long

Root As Long

NextTreeRecord As Long

NextCustRecord As Long

FirstTreeGarbage As Long

FirstCustGarbage As Long

Height As Integer

Padding As String * HEADER_PADDING

End Type


При запуску програми вона запитує директорію, в якій знаходяться дані, і потім відкриває файли Custs.DAT файли Custs.IDX в цій директорії. Якщо ці файли не існують, то програма їх створює. В іншому випадку, вона зчитує заголовок з інформацією про дерево з файлу Custs.IDX. Потім вона зчитує кореневий вузол Б + дерева і кешує його в пам'яті.

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

Збільшення розміру блоків дозволяє зробити Б + дерева більш ефективними, але при цьому тестувати їх вручну буде складніше. Щоб висота Б + дерева 11 порядку стала дорівнює 2, необхідно додати до бази даних 23 елемента. Щоб збільшити висоту дерева до 3 рівня, необхідно додати більше 250 додаткових елементів.


======= 184


Щоб було простіше тестувати програму Bplus, ви можете захотіти зменшити порядок Б + дерева до 2. Для цього закоментуйте у файлі Bplus.BAS рядок, яка визначає 11 порядок, і приберіть коментар з рядка, яка задає 2 порядок:


'Const ORDER = 11

Const ORDER = 2


Команда Create Data (Створити дані) в меню Data (Дані) дозволяє швидко створити безліч записів даних. Введіть число записів, які ви хочете створити, і число, яке програма повинна використовувати для створення першого елемента. Потім програма створить запису і вставить їх в Б + дерево. Наприклад, якщо задати в програмі створення 100 записів, починаючи зі значення 200, то програма створить запису 200, 201, ... 299, які будуть виглядати так:


FirstName: First 0000200

LastName: Last 0000200

Address: Addr 0000200

Cuty: City 0000200


Резюме

Застосування збалансованих дерев у програмі дозволяє ефективно працювати з даними. Для запису великих баз даних на дисках або інших щодо повільних запам'ятовуючих пристроях особливо зручні Б + дерева високого порядку. Більш того, можна використовувати кілька Б + дерев для створення декількох індексів одного і того ж великого набору даних.

У розділі 11 описана альтернатива збалансованим деревах. Хешування в деяких випадках дозволяє досягти ще більш швидкого доступу до даних, хоча воно і не дозволяє виконувати такі операції, як послідовний висновок записів.


======== 185


Глава 8. Дерева рішень

Багато складних реальні завдання можна змоделювати за допомогою дерев рішень (decision trees). Кожен вузол дерева представляє один крок рішення задачі. Кожна гілка в дереві представляє рішення, яке веде до більш повного вирішення. Листя представляють собою остаточне рішення. Мета полягає в тому, щоб знайти «найкращий» шлях від кореня до листа при виконанні певних умов. Ці умови і значення поняття «найкращий» на шляху залежить від завдання.

Дерева рішень зазвичай мають величезний розмір. Дерево рішень для гри в хрестики нулики містить понад півмільйона вузлів. Ця гра досить проста, і багато реальні завдання набагато складніші. Відповідні їм деревини рішень могли б містити більше вузлів, ніж число атомів у всесвіті.

У цьому розділі обговорюються методи, які можна використовувати для пошуку в таких величезних деревах. По-перше, в ній спочатку розглядаються дерева гри (game trees). На прикладі гри в хрестики нулики обговорюються способи пошуку в деревах ігри для знаходження найкращого можливого перебігу.

У наступних розділах описуються способи пошуку в більш загальних деревах рішень. Для самих маленьких дерев, можна використовувати метод повного перебору (exhaustive searching) усіх можливих рішень. Для дерев більшого розміру, можна використовувати метод гілок і меж (branch and bound technique) дозволяє знайти найкраще рішення без необхідності виконувати пошук по всьому дереву.

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

У кінці цієї глави обговорюються деякі дуже складні завдання, які ви можете спробувати вирішити за допомогою методу гілок і меж або евристичного методу. Багато з цих завдань мають важливі застосування, і знаходження хороших рішень для них вкрай необхідно.

Пошук у деревах ігри

Стратегію настільних ігор, таких як шахи, шашки, або хрестики нулики можна змоделювати за допомогою дерев гри. Якщо в якийсь момент гри існує 30 можливих ходів, то відповідний вузол в дереві гри буде мати 30 гілок.


======== 187


Наприклад, для гри в хрестики нулики кореневий вузол відповідає початковій позиції, при якій дошка порожня. Перший гравець може помістити хрестик у будь-яку з дев'яти клітин дошки. Кожному з цих дев'яти можливих ходів відповідає виходить з кореня гілку. Дев'ять вузлів на кінці ці гілок відповідають дев'яти різних позицій після першого ходу гравця.

Після того, як перший гравець зробив хід, другий може поставити нулик в будь-яку з решти восьми клітин. Кожному з цих ходів відповідає гілку, що виходить з вузла, відповідного поточної позиції гри. На рис. 8.1 показаний невеликий фрагмент дерева гри в хрестики нулики.

Як можна побачити на рис. 8.1, дерево гри в хрестики нулики зростає дуже швидко. Якщо він продовжить рости таким чином, так що кожен наступний вузол в дереві буде мати на одну гілку менше, ніж його батько, то дерево цілком буде мати 9 * 8 * 7 ... * 1 = 362.880 листя. У дереві буде 362.880 можливих шляхів, відповідних 362.800 можливим ігор.

Насправді багато хто з вузлів дерева будуть відсутні, так як відповідні їм ходи заборонені правилами гри. Якщо гравець, який ходив першим, за три своїх ходу поставить хрестики у верхній лівій, верхній середній і верхній правій клітинах, то він виграє і гра закінчиться. Вузол, що відповідає цій позиції, не буде мати нащадків, так як гра завершується на цьому кроці. Ця гра показана на рис. 8.2.

Після видалення всіх неможливих вузлів у дереві залишається близько чверті мільйона листя. Це все ще дуже велике дерево, і пошук його методом повного перебору займає досить багато часу. Для більш складних ігор, таких як шашки, шахи або го, дерева ігри мають величезний розмір. Якби під час кожного ходу в шахах гравець мав 16 можливих варіантів, то дерево гри мало б більше трильйона вузлів після п'яти ходів кожного з гравців. У кінці цієї глави обговорюється пошук в таких величезних деревах гри, а наступний розділ присвячений більш простому приміром гри в хрестики нулики.


@ Рис. 8.1. Фрагмент дерева гри в хрестики нулики


======== 188


@ Рис. 8.2. Швидке закінчення гри


Мінімакс пошук

Для виконання пошуку в дереві ігри потрібно мати можливість визначити вагу позиції на дошці. Для гри в хрестики нулики, для першого гравця більшу вагу мають позиції, в яких три хрестики розташовані в ряд, так як при цьому перший гравець виграє. Вага тих же позицій для другого гравця малий, тому, що в цьому випадку він програє.

Для кожного гравця, можна привласнити позиції один з чотирьох ваг. Якщо вага дорівнює 4, то це означає, що гравець у цій позиції виграє. Якщо вага дорівнює 3, то з поточного становища на дошці неясно, хто з гравців виграє в кінці кінців. Вага, рівний 2, означає, що позиція призводить до нічиєї. І, нарешті, вага, рівний 1, означає, що виграє супротивник.

Для пошуку дерева методом повного перебору можна використовувати мінімаксних (minimax) стратегію, при якій робиться спроба мінімізувати максимальна вага, який може мати позиція для супротивника після наступного ходу. Це можна зробити, визначивши максимально можливий вага позиції для супротивника після кожного зі своїх можливих ходів, і потім вибравши хід, який дає позиції з мінімальною вагою для противника.

Підпрограма BoardValue, наведена нижче, обчислює вагу позиції на дошці, перевіряючи всі можливі ходи. Для кожного ходу вона рекурсивно викликає себе, щоб знайти вага, який буде мати нова позиція для противника. Потім вона вибирає хід, при якому вага отриманої позиції для противника буде найменшим.

Для визначення ваги позиції на дошці процедура BoardValue рекурсивно викликає себе до тих пір, поки не відбудеться одна з трьох подій. По-перше, вона може дійти до позиції, в якій гравець виграє. У цьому випадку, вона привласнює позиції вага 4, що вказує на виграш гравця, який вчинив останній хід.


====== 189


По-друге, процедура BoardValue може знайти позицію, в якій жоден з гравців не може зробити наступний хід. Гра при цьому закінчується нічиєю, тому процедура присвоює цій позиції вага 2.

І нарешті, процедура може досягти заданої максимальної глибини рекурсії. У цьому випадку, процедура BoardValue присвоює позиції вага 3, що вказує, що вона не може визначити переможця. Завдання максимальної глибини рекурсії обмежує час пошуку в дереві гри. Це особливо важливо для більш складних ігор, таких як шахи, в яких пошук в дереві гри може тривати практично вічно. Максимальна глибина пошуку також може задавати рівень майстерності програми. Чим далі вперед програма зможе аналізувати ходи, тим краще вона гратиме.

На рис. 8.3 показано дерево гри в хрестики нулики в кінці партії. Ходить гравець, який грає хрестиками, і у нього є три можливих ходу. Щоб вибрати найкращий хід, процедура BoardValue рекурсивно перевіряє кожен з трьох можливих ходів. Перший і третій можливі ходи (ліва і права гілки дерева) призводять до виграшу противника, тому їх вагу для противника дорівнює 4. Другий можливий хід призводить до нічиєї, і його вагу для противника дорівнює 2. Процедура BoardValue вибирає цей хід, тому що він має найменшу вагу для противника.


@ Рис. 8.3. Нижня частина дерева гри


Private Sub BoardValue (best_move As Integer, best_value As Integer, pl1 As Integer, pl2 As Integer, Depth As Integer)

Dim pl As Integer

Dim i As Integer

Dim good_i As Integer

Dim good_value As Integer

Dim enemy_i As Integer

Dim enemy_value As Integer


DoEvents 'Не позичати 100% процесорного часу.


'Якщо глибина рекурсії занадто велика, результат невідомий.

If Depth> = SkillLevel Then

best_value = VALUE_UNKNOWN

Exit Sub

End If


'Якщо гра завершується, то результат відомий.

pl = Winner ()

If pl <> PLAYER_NONE Then

'Перетворити вагу для переможця pl у вагу для гравця pl1.

If pl = pl1 Then

best_value = VALUE_WIN

ElseIf pl = pl2 Then

best_value = VALUE_LOSE

Else

best_value = VALUE_DRAW

End If

Exit Sub

End If


'Перевірити всі допустимі ходи.

good_i = -1

good_value = VALUE_HIGH

For i = 1 To NUM_SQUARES

'Перевірити хід, якщо він дозволений правилами.

If Board (i) = PLAYER_NONE Then

'Знайти вага отриманого положення для противника.

If ShowTrials Then _

MoveLabel.Caption = _

MoveLabel.Caption & Format $ (i)

'Зробити хід.

Board (i) = pl1

BoardValue enemy_i, enemy_value, pl2, pl1, Depth + 1

'Відмінити хід.

Board (i) = PLAYER_NONE

If ShowTrials Then _

MoveLabel.Caption = _

Left $ (MoveLabel.Caption, Depth)


'Менше чи ця вага, ніж попередній.

If enemy_value <good_value Then

good_i = i

good_value = enemy_value

'Якщо ми виграємо, то кращого рішення немає,

'Тому вибирається цей хід.

If good_value <= VALUE_LOSE Then Exit For

End If

End If 'End if Board (i) = PLAYER_NONE ...

Next i


'Перетворити вага позиції для супротивника у вагу для гравця.

If good_value = VALUE_WIN Then

'Противник виграє, ми програли.

best_value = VALUE_LOSE

ElseIf enemy_value = VALUE_LOSE Then

'Противник програв, ми виграли.

best_value = VALUE_WIN

Else

'Вага нічиєї або невизначеної позиції

'Однаковий для обох гравців.

best_value = good_value

End If

best_move = good_i

End Sub


Програма TicTac використовує процедуру BoardValue. Основна частина коду програми забезпечує взаємодію з користувачем, малює дошку, дозволяє користувачеві вибрати хід, задавати опції і так далі.

Якщо не вибрано команда Show Test Moves (Показувати перевіряються ходи) з меню Options (Параметри), то продуктивність програми буде набагато вище. Якщо вибрана ця опція, то програма виводить кожен аналізований хід. Постійне оновлення екрану займає набагато більше часу, ніж дійсний пошук в дереві.

Інші команди в меню Options дозволяють вам, вибрати рівень майстерності програми (максимальну глибину рекурсії) і вибрати гру хрестиками або нулями. При високому рівні майстерності перший хід займає набагато більше часу.


===== 192


Здача

Підпрограма BoardValue має цікавий побічний ефект. Якщо вона знаходить два однаково хороших ходу, то вона вибирає з них перший-ліпший. Іноді це призводить до дивного поведінки програми. Наприклад, якщо програма визначає, що при будь-якому своєму ході вона програє, то вона вибирає перший з них. Іноді цей хід може здатися людині дурним. Може скластися враження, що комп'ютер вибрав випадковий хід і здається. В якійсь мірі це дійсно так.

Наприклад, запустимо програму TicTac з третім рівнем майстерності. Перенумеруем клітини так, як показано на рис. 8.4. Зробимо першими хід у клітку 6. Програма вибере клітку 1. Виберемо клітку 3, програма відповість ходом на клітку 9. Тепер, якщо зайняти клітку 5, то настає виграш, якщо наступним ходом піти на клітину 4 або 7.

Комп'ютер тепер може переглянути дерево гри до кінця і переконатися в своєму програші. У такій ситуації людина спробувала б заблокувати один з виграшних ходів, або помістити два нулика в ряд, щоб спробувати виграти на наступному ходу. У більш складній грі, такий як шахи, людина також може вибрати одну з цих стратегій, в надії на те, що суперник не побачить шляху до перемоги. Суперник може помилитися, даючи гравцеві тим самим шанс на перемогу.

Програма ж вважає, що супротивник грає безпомилково і також знає про свій виграш. Так як ні один хід не призводить до перемоги, то програма вибирає перший-ліпший хід, в даному випадку займає клітку 2. Цей хід здається дурним, так як він не блокує жодного з можливих виграшних ходів, і не робить спробу виграти на наступному ходу. При цьому здається, що комп'ютер здається. Ця гра показана на рис. 8.5.

Один із способів запобігання такої поведінки полягає в тому, щоб задати більше різних ваг позицій. У програмі TicTac всі програшні позиції мають однакову вагу. Можна присвоїти позиції, в якій програш відбувається за два ходи, більшої ваги, ніж позиції, в якій програш наступає на наступному ходу. Тоді програма зможе вибирати ходи, які приведуть до затягування гри. Також можна присвоювати більшу вагу позиції, в якій є два можливих виграшних ходу, ніж позиції, в якій є тільки один виграшний хід. У такому випадку комп'ютер спробував би заблокувати один з можливих виграшних ходів.

Поліпшення пошуку в дереві гри

Якби для пошуку в дереві гри ми мали лише мінімаксної стратегією, то виконати пошук у великих деревах було б дуже складно. Такі ігри, як шахи, настільки складні, що програма може провести пошук всього лише на декількох рівнях дерева. На щастя, існують кілька прийомів, які можна використовувати для пошуку у великих деревах гри.


@ Рис. 8.4. Нумерація клітин дошки гри в хрестики нулики


====== 193


@ Рис. 8.5. Програма гри в хрестики нулики здається


Попереднє обчислення початкових ходів

По-перше, в програмі можуть бути записані початкові ходи, вибрані експертами. Можна вирішити, що програма гри в хрестики нулики повинна робити перший хід у центральну клітку. Це визначає першу гілку дерева гри, тому програма може ігнорувати всі шляхи, не проходять через першу гілку. Це зменшує дерево гри в хрестики нулики в 9 разів.

Фактично, програму не потрібно виконувати пошук в дереві до того, поки супротивник не зробить свій хід. У цей момент і комп'ютер і противник вибрали кожен свою гілку, тому що залишився дерево стане набагато менше, і буде містити менше ніж 7! = 5040 шляхів. Прорахувавши наперед всього один хід, можна зменшити розмір дерева гри від чверті мільйона до менше ніж 5040 шляхів.

Аналогічно, можна записати відповіді на перші ходи, якщо супротивник ходить першим. Є дев'ять варіантів першого ходу, отже, потрібно записати дев'ять відповідних ходів. При цьому програмі не потрібно поводити пошук по дереву, поки супротивник не зробить два ходи, а комп'ютер - один. Тоді дерево гри буде містити менше ніж 6! = 720 шляхів. Записано всього дев `ять ходів, а розмір дерева при цьому зменшується дуже сильно. Це ще один приклад просторово тимчасового компромісу. Використання більшої кількості пам'яті зменшує час, необхідний для пошуку в дереві гри.

Програма TicTac2 використовує 10 записаних ходів. Задайте 9 рівень майстерності, і нехай програма робить перший хід. Потім задайте ті ж опції в програмі TicTac. Ви побачите величезну різницю у швидкості роботи цих двох програм.

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

Визначення важливих позицій

Інший спосіб покращення пошуку в дереві гри полягає в тому, щоб визначати важливі позиції. Якщо програма розпізнає одну з цих позицій, вона може виконати певні дії або змінити спосіб пошуку в дереві гри.


======== 194


Під час гри в шахи гравці часто мають у своєму розпорядженні фігура так, щоб вони захищали інші фігури. Якщо супротивник бере фігуру, то гравець бере фігуру супротивника натомість. Часто таке взяття дозволяє супротивникові в свою чергу взяти іншу фігуру, що приводить до серії обмінів.

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

Деякі шахові програми також відстежують рокіровки, ходи, при яких під боєм виявляється відразу кілька фігур, шах або напад на ферзя і так далі.

Евристики

В іграх, більш складних, ніж хрестики нулики, практично неможливо провести пошук навіть у невеликому фрагменті дерева гри. У цих випадках, можна використовувати різні евристики. Евристикою називає алгоритм або емпіричне правило, яке ймовірно, але не обов'язково дасть гарний результат.

Наприклад, у шахах звичайної евристикою є «посилення переваги». Якщо у супротивника менше сильних фігур і однакове число інших, то слід йти на розмін при кожній нагоді. Наприклад, якщо ви берете коня противника, втрачаючи при цьому свого, то такий обмін слід виконати. Зменшення числа залишилися фігур робить дерево рішень коротшим і може збільшити відносну перевагу. Ця стратегія не гарантує виграшу, але підвищує його ймовірність.

Інша часто використовувана евристика полягає у привласненні різних ваг різних частин дошки. У шахах вага клітин в центрі дошки вище, так як фігури, що знаходяться на цих позиціях, можуть атакувати більшу частину дошки. Коли процедура BoardValue обчислює вага поточної позиції на дошці, вона може привласнювати більшу вагу постатям, які займають клітини в центрі дошки.

Пошук в інших деревах рішень

Деякі методи пошуку в деревах гри незастосовні до узагальнених деревах рішень. Багато хто з цих дерев не включають почергових ходів гравців, тому мінімаксний метод і обчислені заздалегідь ходи в даному випадку безглузді. У наступних розділах описані методи, які можна використовувати для пошуку в цих типах дерев рішень.


======= 195


Метод гілок і меж

Метод гілок і меж (branch and bound) є одним з методів відсікання (pruning) гілок у дереві рішень, щоб не було необхідно розглядати всі гілки дерева. Загальний підхід при цьому полягає в тому, щоб відслідковувати кордону вже виявлених і можливих рішень. Якщо в якійсь точці найкраще з вже знайдених рішень краще, ніж найкраще можливе рішення в нижніх гілках, то можна ігнорувати всі шляхи вниз від вузла.

Наприклад, припустимо, що має 100 мільйонів доларів, які потрібно вкласти в декілька можливих інвестицій. Кожне з вкладень має різну вартість і дає різний прибуток. Необхідно вирішити, як вкласти гроші найкращим чином, щоб сумарний прибуток була максимальної.

Завдання такого типу називаються завданням формування портфеля (knapsack problem). Є декілька позицій (інвестицій), які повинні поміститися в портфель фіксованого розміру (100 мільйонів доларів). Кожна з позицій має вартість (гроші) і ціну (теж гроші). Необхідно знайти набір позицій, що міститься у портфель і має максимально можливу ціну.

Це завдання можна змоделювати за допомогою дерева рішень. Кожен вузол дерева відповідає певній комбінації позицій у портфелі. Кожна гілка відповідає прийняттю рішення про те, щоб видалити позицію з портфеля або додати її в нього. Ліва гілка першого вузла відповідає першому вкладенню. На рис. 8.6 показано дерево рішень для чотирьох можливих інвестицій.

Дерево рішень для цієї задачі являє собою повне бінарне дерево, глибина якого дорівнює числу інвестицій. Кожен лист відповідає повного набору інвестицій.

Розмір цього дерева дуже швидко зростає із збільшенням числа інвестицій. Для 10 можливих інвестицій, в дереві буде знаходитися лютий 1910 = 1024 аркуша. Для 20 інвестицій, в дереві буде вже більше мільйона листя. Можна провести повний пошук по такому дереву, але при подальшому збільшенні числа можливих інвестицій розмір дерева стане дуже великим.


@ Рис. 8.6. Дерево рішень для інвестицій


======= 196


Щоб використовувати метод гілок і меж, створимо масив, який буде містити позиції з найкращого знайденого до цих пір рішення. При ініціалізації масив повинен бути порожній. Можна також використовувати змінну для відстеження ціни цього рішення. Спочатку ця змінна може мати невелике значення, щоб перше ж знайдене реальне рішення було краще вихідного.

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

В якості конкретного прикладу, припустимо, що є інвестиції, наведені в табл. 8.1. На рис. 8.6 показано відповідне дерево рішень. Деякі з цих інвестиційних пакетів порушують граничні умови задачі. Наприклад, самий лівий шлях привів би до вкладення 178 мільйонів доларів у всі чотири можливих інвестиції.

Припустимо, що ми почали пошук в дереві, зображеному на рис. 8.6 і виявили, що можна витратити 97 мільйонів доларів на позиції A і B, отримавши 23 млн прибутку. Це відповідає четвертому аркуші ліворуч на рис. 8.6.

При продовженні пошуку в дереві, можна дійти до другого зліва вузла B на рис. 8.6. Це відповідає інвестиційному пакету, який включає позицію A, не включає позицію B, і може включати або не включати позиції C і D. У цій точці пакет вже коштує 45 мільйонів доларів за рахунок позиції A, і приносить 10 мільйонів прибутку.

Інші позиції C і D разом узяті можуть підвищити прибуток ще на 12 мільйонів. Поточне рішення приносить 10 мільйонів прибутку, тому найкраще можливе рішення нижче цього вузла принесе не більше 11 мільйонів прибутку. Це менше, ніж дохід у 23 мільйони для вже знайденого рішення, тому немає сенсу продовжувати пошук вниз по цьому шляху.

У міру просування програми по дереву їй не потрібно постійно перевіряти, чи буде часткове рішення, яке вона розглядає, краще, ніж найкраще знайдене до цих пір рішення. Якщо часткове рішення краще, то краще буде і самий правий вузол внизу від цього часткового вирішення. Цей вузол представляє той же самий набір позицій, як і часткове рішення, так як всі інші позиції при цьому виключені. Це означає, що програмі необхідно шукати найкраще рішення тільки тоді, коли вона досягає аркуша.


@ Таблиця 8.1. Можливі інвестиції


====== 197


Фактично, будь-який лист, до якого доходить програма завжди а є більш гарним рішенням. Якби це було не так, то гілка, на якому знаходиться цей лист, була б відсічена, коли програма розглядала батьківський вузол. У цій точці переміщення до листа зменшить ціну необраних позицій до нуля. Якщо ціна рішення не більше, ніж найкраще знайдене до цих пір рішення, то перевірка нижньої межі зупинить просування програми до листа. Використовуючи цей факт, програма може оновлювати найкраще рішення при досягненні аркуша.

Наступний код використовує перевірку верхньої та нижньої межі для реалізації алгоритму гілок і кордонів:


'Повна нерозподілений прибуток.

Private unassigned_profit As Integer


Public NumItems As Integer

Public MaxItem As Integer


Global Const OPTION_EXHAUSTIVE_SEARCH = 0

Global Const OPTION_BRANCH_AND_BOUND = 1


Type Item

Cost As Integer

Profit As Integer

End Type


Global Items () As Item

Global NodesVisited As Long

Global ToSpend As Integer

Global best_cost As Integer

Global best_profit As Integer


'Так само True для позицій в поточному найкращому рішенні.

Public best_solution () As Boolean


'Рішення, яке ми перевіряємо.

Private test_solution () As Boolean

Private test_cost As Integer

Private test_profit As Integer


'Ініціалізація змінних і початок пошуку.

Public Sub Search (search_type As Integer)

Dim i As Integer


'Завдання розміру масивів рішення.

ReDim best_solution (0 To MaxItem)

ReDim test_solution (0 To MaxItem)


'Ініціалізація - порожній список інвестицій.

NodesVisited = 0

best_profit = 0

best_cost = 0

unassigned_profit = 0

For i = 0 To MaxItem

unassigned_profit = unassigned_profit + Items (i). Profit

Next i

test_profit = 0

test_cost = 0


'Почнемо пошук з першої позиції.

BranchAndBound 0

End Sub


'Виконати пошук методом гілок і меж починаючи з цієї позиції.

Public Sub BranchAndBound (item_num As Integer)

Dim i As Integer


NodesVisited = NodesVisited + 1


'Якщо це лист, то це краще рішення, ніж

'Те, яке ми мали раніше, інакше він був би

'Відтятий під час пошуку раніше.

If item_num> MaxItem Then

For i = 0 To MaxItem

best_solution (i) = test_solution (i)

best_profit = test_profit

best_cost = test_cost

Next i

Exit Sub

End If


'Інакше перейти по гілки вниз по гілкам нащадка.

'Спочатку спробувати додати цю позицію. Переконатися,

'Що вона не перевищує обмеження за ціною.

If test_cost + Items (item_num). Cost <= ToSpend Then

'Додати позицію до тестового рішенням.

test_solution (item_num) = True

test_cost = test_cost + Items (item_num). Cost

test_profit = test_profit + Items (item_num). Profit

unassigned_profit = unassigned_profit - Items (item_num). Profit


'Рекурсивна перевірка можливого результату.

BranchAndBound item_num + 1


'Видалити позицію з тестового рішення.

test_solution (item_num) = False

test_cost = test_cost - Items (item_num). Cost

test_profit = test_profit - Items (item_num). Profit

unassigned_profit = unassigned_profit + Items (item_num). Profit

End If


'Спробувати виключити позицію. З'ясувати, чи принесуть

'Залишилися позиції достатній дохід, щоб

'Шлях вниз по цій гілки перевищив нижню межу.

unassigned_profit = unassigned_profit - Items (item_num). Profit

If test_profit + unassigned_profit> best_profit Then BranchAndBound item_num + 1

unassigned_profit = unassigned_profit + Items (item_num). Profit

End Sub


Програма BandB використовує метод повного перебору і метод гілок і меж для вирішення задачі про формування портфеля. Введіть максимальну і мінімальну вартість і ціну, які ви хочете присвоїти позиціях, а також число позицій, яке потрібно створити. Потім натисніть на кнопку Randomize (рандомізоване), щоб створити список позицій.

Потім за допомогою перемикача внизу форми виберіть або Exhaustive Search (Повний перебір), або Branch and Bound (Метод гілок і меж). Коли ви натиснете на кнопку Go (Почати), то програма знайде найкраще рішення за допомогою вибраного методу. Потім вона виведе на екран це рішення, а також число вузлів в повному дереві рішень і число вузлів, які програма насправді перевірила. На рис. 8.7 показано вікно програми BindB після рішення задачі портфеля для 20 позицій. Перед тим, як виконати повний перебір для 20 позицій, спробуйте спочатку запустити приклади меншого розміру. На комп'ютері з процесором Pentium з тактовою частотою 90 МГц пошук рішення задачі портфеля для 20 позицій методом повного перебору зайняв більше 30 секунд.

При пошуку методом гілок і меж число перевіряються вузлів набагато менше, ніж при повному переборі. Дерево рішень для задачі портфеля з 20 позиціями містить 2.097.151 вузол. При повному переборі доведеться перевірити їх усі, при пошуку методом гілок і меж знадобиться перевірити тільки приблизно 1.500 з них.


@ Рис. 8.7. Програма BindB


====== 200


Число вузлів, які перевіряє програма при використанні методу гілок і меж, залежить від точних значень даних. Якщо ціна позицій висока, то в правильне рішення буде входити небагато елементів. Після приміщення кількох позицій в пробне рішення, що залишилися позиції дуже дорого стоять, щоб поміститися у портфелі, тому велика частина дерева буде відсічена.

З іншого боку, якщо елементи мають низьку вартість, то в правильне рішення увійде велика їх кількість, тому програмі доведеться досліджувати безліч комбінацій. У табл. 8.2 наведено кількість вузлів, перевірене програмою BindB в серії тестів при різній вартості позицій. Програма створювала 20 випадкових позицій, і повна вартість рішення була дорівнює 100.

Евристики

Іноді навіть алгоритм гілок і кордонів не може провести повний пошук в дереві. Дерево рішень для задачі портфеля з 65 позиціями містить більше 7 * 10 19 вузлів. Якщо алгоритм гілок і меж перевіряє тільки одну десяту відсотка цих вузлів, і якщо комп'ютер перевіряє мільйон вузлів за секунду, то для вирішення цього завдання треба було б більше 2 мільйонів років. У задачах, для яких алгоритм гілок і меж виконується дуже повільно, можна використовувати евристичний підхід.

Якщо якість рішення не так важливо, то прийнятним може бути результат, отриманий за допомогою евристики. У деяких випадках точність вхідних даних може бути недостатньою. Тоді хороше евристичне рішення може бути таким же правильним, як і теоретично «найкраще» рішення.

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


@ Таблиця 8.2. Число вузлів, перевірених при пошуку методами повного перебору і гілок і меж


======= 201


У цьому розділі обговорюються евристики, які корисні при вирішенні багатьох складних завдань. Програма Heur демонструє кожну з евристик. Вона також дозволяє порівняти результати, отримані за допомогою евристик і методів повного перебору і гілок і меж. Введіть значення мінімальної і максимальної вартості і доходу, а також число позицій і повну вартість портфеля у відповідних полях області Parameters (Параметри), щоб задати параметри створюваних даних. Потім виберіть алгоритми, які ви хочете протестувати, і натисніть на кнопку Go. Програма виведе повну вартість і дохід для найкращого рішення, знайденого за допомогою кожного з алгоритмів. Вона також сортує рішення по максимальному отриманого доходу і виводить час виконання для кожного з алгоритмів. Використовуйте метод гілок і меж тільки для невеликих завдань, а метод повного перебору тільки для завдань ще меншого обсягу.

На рис. 8.8 показано вікно програми Heur після рішення задачі формування портфеля для 20 позицій. Евристики Fixed1, Fixed2 і No Changes 1, які будуть незабаром описані, дали найкращі евристичні рішення. Зауважте, що ці рішення трохи гірше, ніж точні рішення, які отримані при використанні методу гілок і меж.

Сходження на пагорб

Евристика сходження на пагорб (hill climbing) вносить зміни до поточне рішення, щоб максимально наблизити його до мети. Цей процес називається сходженням на пагорб, так як він схожий на те, як заблукав мандрівник намагається вночі дістатися до вершини гори. Навіть якщо вже занадто темно, щоб ще можна було розгледіти що то далеко, мандрівник може спробувати дістатися до вершини гори, постійно рухаючись вгору.

Звичайно, існує ймовірність, що мандрівник застрягне на вершині пагорба меншого і не добереться до піку. Ця проблема завжди може виникати при використанні цієї евристики. Алгоритм може знайти рішення, яке може виявитися локально прийнятним, але це не обов'язково найкраще можливе рішення.

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


@ Рис. 8.8. Програма Heur


======== 202


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

Для списку інвестицій з табл. 8.3, програма спочатку вибирає позицію A, так як вона дає максимальний прибуток - 9 мільйонів доларів. Потім програма вибирає таку позицію C, яка дає прибуток 8 мільйонів. У цей момент витрачені вже 93 мільйони з 100, і програма не може придбати більше позицій. Рішення, отримане за допомогою евристики, включає позиції A і C, має вартість 93 млн, і приносить 17 мільйонів прибутку.


@ Таблиця 8.3. Можливі інвестиції


Евристика сходження на пагорб заповнює портфель дуже швидко. Якщо позиції спочатку були відсортовані в порядку убування принесеної прибутку, то складність цього алгоритму порядку O (N). Програма просто переміщається за списком, додаючи кожну позицію, якщо під неї є місце. Навіть якщо список не впорядковано, то це алгоритм зі складністю порядку O (N 2). Це набагато краще, ніж O (2 N) кроків, які потрібні для повного перебору всіх вузлів у дереві. Для 20 позицій ця евристика вимагає всього близько 400 кроків, метод гілок і меж - кілька тисяч, а повний перебір - більш ніж 2 мільйони.


Public Sub HillClimbing ()

Dim i As Integer

Dim j As Integer

Dim big_value As Integer

Dim big_j As Integer


'Багаторазовий обхід списку і пошук наступної

'Позиції, що приносить найбільший прибуток,

'Вартість якої не перевищує верхньої межі.

For i = 1 To NumItems

big_value = 0

big_j = -1

For j = 1 To NumItems

'Перевірити, чи не знаходиться він вже

'В рішенні.

If (Not test_solution (j)) And _

(Test_cost + Items (j). Cost <= ToSpend) And _

(Big_value <Items (j). Profit)

Then

big_value = Items (j). Profit

big_j = j

End If

Next j


'Зупинитися, якщо не знайдена позиція,

'Задовольняє умовам.

If big_j <0 Then Exit For


test_cost = test_cost + Items (big_j). Cost

test_solution (big_j) = True

test_profit = test_profit + Items (big_j). Profit

Next i

End Sub


Метод найменшої вартості

Стратегія, яка в якомусь сенсі протилежна стратегії сходження на пагорб, називається стратегією найменшою вартістю (least cost). Замість того щоб на кожному кроці намагатися максимально наблизити рішення до мети, можна спробувати зменшити вартість рішення, наскільки це можливо. У прикладі з формуванням портфеля, на кожному кроці до вирішення додається позиція з мінімальною вартістю.

Ця стратегія намагається помістити в рішення максимально можливе число позицій. Це буде непоганим рішенням, якщо всі позиції мають приблизно однакову вартість. Якщо дорога позиція приносить великий прибуток, то ця стратегія може втратити цю можливість, даючи не кращий з можливих результатів.

Для інвестицій, показаних у табл. 8.3, алгоритм найменшої вартості починає з додавання до вирішення позиції E з вартістю 23 мільйони доларів. Потім він вибирає позицію D, що стоїть 27 мільйонів, і потім позицію C з вартістю 30 мільйонів. У цій точці алгоритм вже витратив 80 мільйонів з 100 можливих, тому більше він не може вибрати жодної позиції.

Це рішення має вартість 80 мільйонів і дає 18 мільйонів прибутку. Це на мільйон краще, ніж рішення для евристики сходження на пагорб, але стратегія найменшої вартості не завжди дає краще рішення, ніж сходження на пагорб. Яка з евристик дає кращі результати, залежить від значень вхідних даних.

Структура програми, що реалізує евристику найменшої вартості, майже ідентична структурі програми для евристики сходження на пагорб. Єдина відмінність між ними полягає у виборі такої позиції, щоб додати до вирішення. Евристика найменшої вартості вибирає позицію з мінімальною ціною; метод сходження на пагорб вибирає позицію з максимальним прибутком. Так як ці два методи дуже схожі, вони виконуються за однаковий час. Якщо позиції впорядковані відповідним чином, то обидва алгоритму виконуються за час порядку O (N). Якщо позиції розташовані випадковим чином, то обидва виконуються за час порядку O (N 2).


======== 203-204


Так як код мовою Visual Basic для цих двох евристик дуже схожий, то ми наводимо лише рядки, в яких відбувається вибір черговий позиції.


If (Not test_solution (j)) And _

(Test_cost + Items (j). Cost <= ToSpend) And _

(Small_cost> Items (j). Cost)

Then

small_cost = Items (j). Cost

small_j = j

End If


Збалансована прибуток

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

Евристика збалансованої прибутку (balanced profit) порівнює при виборі вартість позицій і принесену ними прибуток. На кожному кроці евристика вибирає позицію з найбільшим відношенням прибуток вартість.

У табл. 8.4 приведені ті ж дані, що і в табл. 8.3, але в ній додано ще одна колонка з відношенням прибуток вартість. При цьому підході спочатку вибирається позиція C, оскільки вона має максимальне співвідношення прибуток вартість - 0,27. Потім до вирішення додається позиція D з відношенням 0,26, і позиція B з відношенням 0,20. У цій точці, буде витрачено 92 млн зі 100 можливих, і в рішення не можна буде додати більше жодної позиції.

Рішення буде мати вартість 92 млн і давати 22 млн прибутку. Це на 4 мільйони краще, ніж рішення з найменшою вартістю і на 5 мільйонів краще, ніж рішення методом сходження на пагорб. У цьому випадку, це буде також найкращим можливим рішенням, і його також можна знайти повним перебором або методом гілок і меж. Метод збалансованої прибутку тим не менш, є евристичним, тому він не обов'язково знаходить найкраще можливе рішення. Він часто знаходить краще рішення, ніж методи найменшої вартості та сходження на пагорб, але це не обов'язково так.


@ Таблиця 8.4. Можливі інвестиції з співвідношенням прибуток вартість


========= 205


Структура програми, що реалізує евристику збалансованої прибутку, майже ідентична структурі програм для сходження на пагорб і найменшої вартості. Єдина відмінність полягає в методі вибору наступної позиції, яка додається до рішення:


If (Not test_solution (j)) And _

(Test_cost + Items (j). Cost <= ToSpend) And _

(Good_ratio <Items (j). Profit / CDbl (Items (j). Cost)) _

Then

good_ratio = Items (j). Profit / CDbl (Items (j). Cost)

good_j = j

End If


Випадковий пошук

Випадковий пошук (random search) виконується згідно зі своєю назвою. На кожному кроці алгоритм додає випадкову позицію, яка задовольняє верхньому обмеженню на сумарну вартість позицій у портфелі. Цей метод пошуку також називається методом Монте Карло (Monte Carlo search або Monte Carlo simulation).

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

Перевага випадкового пошуку полягає також і в тому, що цей метод легкий в розумінні і реалізації. Іноді складно уявити, як реалізувати рішення задачі за допомогою евристик сходження на пагорб, найменшою вартістю, або збалансованого доходу, але завжди просто вибирати рішення випадковим чином. Навіть для дуже складних проблем, випадковий пошук є простим евристичним методом.

Підпрограма RandomSearch у програмі Heur використовує функцію AddToSolution для додавання до вирішення випадкової позиції. Ця функція повертає значення True, якщо вона не може знайти позицію, яка задовольняє умовам, і False в іншому випадку. Підпрограма RandomSearch викликає функцію AddToSolution до тих пір, поки більше не можна додати жодної позиції.


Public Sub RandomSearch ()

Dim num_trials As Integer

Dim trial As Integer

Dim i As Integer


'Зробити декілька спроб і вибрати найкращий результат.

num_trials = NumItems 'Використовувати N спроб.

For trial = 1 To num_trials

'Випадковий вибір позицій, поки це можливо.

Do While AddToSolution ()

'Всю роботу виконує функція AddToSolution.

Loop


'Визначити, чи краще це рішення, ніж попереднє.

If test_profit> best_profit Then

best_profit = test_profit

best_cost = test_cost

For i = 1 To NumItems

best_solution (i) = test_solution (i)

Next i

End If


'Скинути пробне рішення і зробити ще одну спробу.

test_profit = 0

test_cost = 0

For i = 1 To NumItems

test_solution (i) = False

Next i

Next trial

End Sub


Private Function AddToSolution () As Boolean

Dim num_left As Integer

Dim j As Integer

Dim selection As Integer


'Визначити, скільки залишилося позицій, які

'Задовольняють обмеження максимальної вартості.

num_left = 0

For j = 1 To NumItems

If (Not test_solution (j)) And _

(Test_cost + Items (j). Cost <= ToSpend) _

Then num_left = num_left + 1

Next j


'Зупинитися, якщо не можна знайти нову позицію.

If num_left <1 Then

AddToSolution = False

Exit Function

End If


'Вибрати випадкову позицію.

selection = Int ((num_left) * Rnd + 1)


'Знайти випадково обрану позицію.

For j = 1 To NumItems

If (Not test_solution (j)) And _

(Test_cost + Items (j). Cost <= ToSpend) _

Then

selection = selection - 1

If selection <1 Then Exit For

End If

Next j


test_profit = test_profit + Items (j). Profit

test_cost = test_cost + Items (j). Cost

test_solution (j) = True


AddToSolution = True

End Function


Послідовне наближення

Ще одна стратегія полягає в тому, щоб почати з випадкового рішення і потім робити послідовні наближення (incremental improvements). Почавши з випадково обраного рішення, програма робить випадковий вибір. Якщо нове рішення краще попереднього, програма закріплює зміни і продовжує перевірку інших випадкових змін. Якщо зміна не покращує рішення, програма відкидає його і робить нову спробу.

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

Момент зупинки

Є кілька хороших способів визначити момент, коли слід припинити випадкові зміни. Для проблеми з N позиціями, можна виконати N або N 2 випадкових змін, перед тим, як зупинитися.


===== 206-208


У програмі Heur цей підхід реалізовано у процедурі MakeChangesFixed. Вона виконує певне число випадкових змін з низкою випадкових пробних рішень:


Public Sub MakeChangesFixed (K As Integer, num_trials As Integer, num_changes As Integer)

Dim trial As Integer

Dim change As Integer

Dim i As Integer

Dim removal As Integer


For trial = 1 To num_trials

'Знайти випадкове пробне рішення і використовувати його

'В якості початкової точки.

Do While AddToSolution ()

'All the work is done by AddToSolution.

Loop


'Почати з цього пробного рішення.

trial_profit = test_profit

trial_cost = test_cost

For i = 1 To NumItems

trial_solution (i) = test_solution (i)

Next i


For change = 1 To num_changes

'Видалити K випадкових позицій.

For removal = 1 To K

RemoveFromSolution

Next removal


'Додати максимально можливе

'Число позицій.

Do While AddToSolution ()

'All the work is done by AddToSolution.

Loop


'Якщо це покращує пробне рішення, зберегти його.

'Інакше повернути колишнє значення пробного рішення.

If test_profit> trial_profit Then

'Зберегти зміни.

trial_profit = test_profit

trial_cost = test_cost

For i = 1 To NumItems

trial_solution (i) = test_solution (i)

Next i

Else

'Скинути пробне рішення.

test_profit = trial_profit

test_cost = trial_cost

For i = 1 To NumItems

test_solution (i) = trial_solution (i)

Next i

End If

Next change


'Якщо пробне рішення краще попереднього

'Найкращого рішення, зберегти його.

If trial_profit> best_profit Then

best_profit = trial_profit

best_cost = trial_cost

For i = 1 To NumItems

best_solution (i) = trial_solution (i)

Next i

End If


'Скинути пробне рішення для

'Наступної спроби.

test_profit = 0

test_cost = 0

For i = 1 To NumItems

test_solution (i) = False

Next i

Next trial

End Sub


Private Sub RemoveFromSolution ()

Dim num_in_solution As Integer

Dim j As Integer

Dim selection As Integer


'Визначити число позицій у рішенні.

num_in_solution = 0

For j = 1 To NumItems

If test_solution (j) Then num_in_solution = num_in_solution + 1

Next j

If num_in_solution <1 Then Exit Sub

'Вибрати випадкову позицію.

selection = Int ((num_in_solution) * Rnd + 1)

'Знайти випадково обрану позицію.

For j = 1 To NumItems

If test_solution (j) Then

selection = selection - 1

If selection <1 Then Exit For

End If

Next j


'Видалити позицію з рішення.

test_profit = test_profit - Items (j). Profit

test_cost = test_cost - Items (j). Cost

test_solution (j) = False

End Sub


====== 209-210


Інша стратегія полягає в тому, щоб вносити зміни до тих пір, поки кілька послідовних змін не приносять поліпшень. Для задачі з N позиціями, програма може вносити зміни до тих пір, поки протягом N змін підряд поліпшень не буде.

Ця стратегія реалізована в підпрограмі MakeChangesNoChange програми Heur. Вона повторює спроби до тих пір, поки певна кількість послідовних спроб не дасть ніяких поліпшень. Для кожної спроби вона вносить випадкові зміни в пробне рішення до тих пір, поки після певного числа змін не настане ніяких поліпшень.


Public Sub MakeChangesNoChange (K As Integer, _

max_bad_trials As Integer, max_non_changes As Integer)

Dim i As Integer

Dim removal As Integer

Dim bad_trials As Integer 'неефективних спроб поспіль.

Dim non_changes As Integer 'неефективних змін підряд.


'Повторювати спроби, поки не зустрінеться max_bad_trials

'Спроб поспіль без поліпшень.

bad_trials = 0

Do

'Вибрати випадкове пробне рішення для

'Використання в якості початкової точки.

Do While AddToSolution ()

'All the work is done by AddToSolution.

Loop


'Почати з цього пробного рішення.

trial_profit = test_profit

trial_cost = test_cost

For i = 1 To NumItems

trial_solution (i) = test_solution (i)

Next i


'Повторювати, поки max_non_changes змін

'Поспіль не дасть поліпшень.

non_changes = 0

Do While non_changes <max_non_changes

'Видалити K випадкових позицій.

For removal = 1 To K

RemoveFromSolution

Next removal


'Повернути максимально можливе число позицій.

Do While AddToSolution ()

'All the work is done by

'AddToSolution.

Loop


'Якщо це покращує пробне значення, зберегти його.

'Інакше повернути колишнє значення пробного рішення.

If test_profit> trial_profit Then

'Зберегти поліпшення.

trial_profit = test_profit

trial_cost = test_cost

For i = 1 To NumItems

trial_solution (i) = test_solution (i)

Next i

non_changes = 0 'This was a good change.

Else

'Reset the trial.

test_profit = trial_profit

test_cost = trial_cost

For i = 1 To NumItems

test_solution (i) = trial_solution (i)

Next i

non_changes = non_changes + 1 "Погане зміна.

End If

Loop 'Продовжити перевірку випадкових змін.


'Якщо ця спроба краще, ніж попереднє найкраще

'Рішення, зберегти його.

If trial_profit> best_profit Then

best_profit = trial_profit

best_cost = trial_cost

For i = 1 To NumItems

best_solution (i) = trial_solution (i)

Next i

bad_trials = 0 'Гарна спроба.

Else

bad_trials = bad_trials + 1 "Погана спроба.

End If


'Скинути тестове рішення для наступної спроби.

test_profit = 0

test_cost = 0

For i = 1 To NumItems

test_solution (i) = False

Next i

Loop While bad_trials <max_bad_trials

End Sub


Локальні оптимуми

Якщо програма замінює випадково обрану позицію у пробному рішенні, то може зустрітися рішення, яке вона не може поліпшити, але яка при цьому не буде найкращим з можливих рішень. Наприклад, розглянемо список інвестицій, наведений в табл. 8.5.

Припустимо, що алгоритм випадково вибрав позиції A і B в якості початкового рішення. Його вартість буде дорівнює 90 мільйонам доларів, і воно принесе 17 млн ​​прибутку.

Якщо програма видалить позиції A і B, то вартість рішення буде все ще настільки велика, що програма зможе додати всього лише одну позицію до рішення. Так як найбільший прибуток приносять позиції A і B, то заміна їх іншими позиціями зменшить сумарний прибуток. Випадкове видалення однієї позиції з цього рішення ніколи не приведе до поліпшення рішення.

Найкраще рішення містить позиції C, D і E. Його повна вартість одно 98 мільйонам доларів і сумарний прибуток становить 18 мільйонів доларів. Щоб знайти це рішення, алгоритмом б знадобилося видалити з рішення відразу обидві позиції A і B і потім додати на їх місце нові позиції.

Рішення такого типу, для яких невеликі зміни рішення не можуть поліпшити його, називаються локальним оптимумом (local optimum). Можна використовувати два способи для того, щоб програма не застрявали в локальному оптимумі, і могла знайти глобальний оптимум (global optimum).


@ Таблиця 8.5. Можливі інвестиції


============= 213


По-перше, можна змінити програму так, щоб вона видаляла більше однієї позиції під час випадкових змін. У цьому прикладі, програма могла б знайти правильне рішення, якщо б вона одночасно видаляла б по дві випадково вибраних позиції. Тим не менш, для задач більшого розміру, видалення двох позицій може бути недостатньо. Програмою може знадобитися видаляти три, чотири, або більше позицій.

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

Програма Heur демонструє три стратегії послідовних наближень. При виборі методу Fixed 1 (Фіксований 1) робиться N спроб. Під час кожної спроби вибирається випадково рішення, яке програма потім намагається поліпшити за 2 * N спроб, випадково видаляючи з однієї позиції.

При виборі евристики Fixed 2 (Фіксований 2) робиться лише одна спроба. При цьому програма вибирає випадкове рішення і намагається поліпшити його, випадковим чином видаляючи по одній позиції до тих пір, поки протягом N послідовних змін не буде ніяких поліпшень.

При виборі евристики No Changes 1 (Без змін 1) програма виконує спроби до тих пір, поки після N послідовних спроб не буде ніяких поліпшень. Під час кожної спроби програма вибирає випадкове рішення і потім намагається поліпшити його, випадковим чином видаляючи по одній позиції до тих пір, поки протягом N послідовних змін не буде ніяких поліпшень.

При виборі евристики No Changes 2 (Без змін 2) робиться одна спроба. При цьому програма вибирає випадкове рішення і намагається поліпшити його, випадковим чином видаляючи по дві позиції до тих пір, поки протягом N послідовних змін не буде ніяких поліпшень.

Назви евристик і їх опису наведено в табл. 8.6.

Алгоритм «відпалу»

Метод відпалу (simulated annealing) веде свій початок з термодинаміки. При відпалі металу він нагрівається до високої температури. Молекули в нагрітому металі здійснюють швидкі коливання, а при повільному охолодженні вони починають розташовуватися впорядковано, утворюючи кристали. При цьому молекули поступово переходять у стан з мінімальною енергією.


@ Таблиця 8.6. Стратегії послідовних наближень


=========== 214


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

Починаючи з стану з високою енергією, молекули зрештою досягають стану з дуже низькою енергією. На шляху до кінцевого положення, вони проходять безліч локальних мінімумів енергії. Кожне поєднання кристалів утворює локальний мінімум. Кристали можуть об'єднуватися один з одним тільки за рахунок тимчасового підвищення енергії системи, щоб потім перейти до стану з меншою енергією.

Метод відпалу використовує аналогічний підхід для пошуку найкращого рішення задачі. Під час пошуку рішення програмою, вона може застрягти в локальному оптимумі. Щоб уникнути цього, програма час від часу вносить до рішення випадкові зміни, навіть якщо чергову зміну і не призводить до миттєвого поліпшення результату. Це може допомогти програмі вийти з локального оптимуму й відшукати краще рішення. Якщо це зміна не веде до кращого рішення, то ймовірно, через деякий час програма його відкине.

Щоб ці зміни не виникали постійно, алгоритм змінює ймовірність виникнення випадкових змін з часом. Імовірність P виникнення одного з подібних змін визначається формулою P = 1 / Exp (E / (k * T)), де E - збільшення «енергії» системи, k - деяка постійна, і T - змінна, відповідна «температурі».

Спочатку температура повинна бути високою, тому і ймовірність змін P = 1 / Exp (E / (k * T)) також досить велика. Інакше випадкові зміни могли б ніколи не виникнути. З плином часу значення змінної T поступово знижується, і вірогідність випадкових змін також зменшується. Після того, як модель дійде до точки, в якій вона ніякі зміни не зможуть покращити рішення, і температура T стане досить низькою, щоб вірогідність випадкових змін була мала, алгоритм закінчує роботу.

Для задачі про формування портфеля, в якості надбавки «енергії» E виступає зменшення прибутку рішення. Наприклад, при видаленні позиції, яка дає прибуток 10 мільйонів, і заміни її на позицію, яка приносить 7 мільйонів прибутку, енергія, додана до системи, буде дорівнює 3.

Зауважте, що якщо енергія велика, то ймовірність змін P = 1 / Exp (E / (k * T)) мала, тому імовірність великих змін нижче.

Алгоритм відпалу в програмі Heur встановлює значення постійної k рівним різниці між найбільшою і найменшою прибутком можливих інвестицій. Початкова температура T задається рівною 0,75. Після виконання певного числа випадкових змін, температура T зменшується множенням на постійну 0,95.


========= 215


Public Sub AnnealTrial (K As Integer, max_non_changes As Integer, _

max_back_slips As Integer)

Const TFACTOR = 0.95


Dim i As Integer

Dim non_changes As Integer

Dim t As Double

Dim max_profit As Integer

Dim min_profit As Integer

Dim doit As Boolean

Dim back_slips As Integer


'Знайти позицію з мінімальною і максимальною прибутком.

max_profit = Items (1). Profit

min_profit = max_profit

For i = 2 To NumItems

If max_profit <Items (i). Profit Then max_profit = Items (i). Profit

If min_profit> Items (i). Profit Then min_profit = Items (i). Profit

Next i


t = 0.75 * (max_profit - min_profit)

back_slips = 0

'Вибрати випадкове пробне рішення

'В якості початкової точки.

Do While AddToSolution ()

'Вся робота виконується в процедурі AddToSolution.

Loop


'Використовувати як пробного рішення.

best_profit = test_profit

best_cost = test_cost

For i = 1 To NumItems

best_solution (i) = test_solution (i)

Next i


'Повторювати, поки протягом max_non_changes змін

'Поспіль не буде поліпшень.

non_changes = 0

Do While non_changes <max_non_changes

'Видалити випадкову позицію.

For i = 1 To K

RemoveFromSolution

Next i

'Додати максимально можливе число позицій.

Do While AddToSolution ()

'Вся робота виконується в процедурі AddToSolution.

Loop

'Якщо зміна покращує пробне рішення, зберегти його.

'Інакше повернути колишнє значення рішення.

If test_profit> best_profit Then

doit = ​​True

ElseIf test_profit <best_profit Then

doit = ​​(Rnd <Exp ((test_profit - best_profit) / t))

back_slips = back_slips + 1

If back_slips> max_back_slips Then

back_slips = 0

t = t * TFACTOR

End If

Else

doit = ​​False

End If

If doit Then

'Зберегти поліпшення.

best_profit = test_profit

best_cost = test_cost

For i = 1 To NumItems

best_solution (i) = test_solution (i)

Next i

non_changes = 0 'Хороше зміна.

Else

'Reset the trial.

test_profit = best_profit

test_cost = best_cost

For i = 1 To NumItems

test_solution (i) = best_solution (i)

Next i

non_changes = non_changes + 1 "Погане зміна.

End If

Loop 'Продовжити перевірку випадкових змін.

End Sub


Порівняння евристик

Різні евристики по різному ведуть себе в різних завданнях. Для задачі про формування портфеля, евристика збалансованої прибутку працює досить добре, з огляду на її простоту. Стратегії послідовного наближення зазвичай дають порівнянні результати, але для великих завдань, їх виконання займає набагато більше часу. Для інших завдань найкращою може бути яка-небудь інша евристика, в тому числі з тих, які не обговорювалися в цій главі.


======== 216-217


Евристичні методи звичайно виконуються швидше, ніж метод гілок і меж. Деякі з них, наприклад методи сходження на пагорб, найменшою вартістю і збалансованої прибутку, виконуються дуже швидко, тому що вони розглядають лише одне можливе рішення. Вони виконуються настільки швидко, що має сенс виконати їх всі по черзі, і потім вибрати найкраще з трьох отриманих рішень. Це не гарантує того, що це рішення буде найкращим, але дає деяку впевненість, що воно виявиться досить гарним.

Інші складні завдання

Існує безліч дуже складних завдань, більшість з яких не має рішень з поліноміальної обчислювальною складністю. Іншими словами, не існує алгоритмів, які вирішували б ці завдання за час порядку O (N C) для будь-яких постійних C, навіть за O (N 1000).

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

Завдання про здійснимість

Якщо є логічне твердження, наприклад "(A And Not B) Or C", то чи існують значення змінних A, B і C, при яких це твердження істинно? У даному прикладі легко побачити, що твердження істинно, якщо A = true, B = false і C = false. Для більш складних тверджень, що містять сотні змінних, буває досить складно визначити, чи може бути утвердження істинним.

За допомогою методу, схожого на той, який використовувався під час вирішення завдання про формування портфеля, можна простроено дерево рішень для задачі про здійснимість (satisfiability problem). Кожна гілка дерева буде відповідати рішенню про присвоєння змінній значення true або false. Наприклад, ліва гілка, що виходить з кореня, відповідає значенню першої змінної true.

Якщо в логічному вираженні N змінних, то дерево рішень являє собою двійкове дерево висотою N + 1. Це дерево має 2 N листя, кожен з яких відповідає різної комбінації значень змінних.

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

Не можна також використовувати евристики для пошуку приблизного рішення для задачі про здійснимість. Будь-яке значення змінних, отримане за допомогою евристики, буде робити вираз істинним або хибним. У математичній логіці не існує такого поняття, як наближене рішення.

Через незастосовності евристик і меншої ефективності методу гілок і меж, завдання про здійснимість зазвичай є дуже складною і вирішується тільки у випадку невеликого розміру задачі.

Завдання про розбивку

Якщо задано безліч елементів зі значеннями X 1, X 2, ..., X N, то існує спосіб розбити його на дві підмножини, так щоб сума значень всіх елементів у кожному з підмножин була однаковою? Наприклад, якщо елементи мають значення 3, 4, 5 і 6, то їх можна розбити на дві підмножини {3, 6} і {4, 5}, сума значень елементів у кожному з яких дорівнює 9.

Щоб змоделювати цю задачу за допомогою дерева, припустимо, що гілкам відповідає приміщення елемента в одне з двох підмножин. Ліва гілка, що виходить з кореневого вузла, відповідає приміщенню першого елемента в перше підмножина, а права гілка - в друге підмножина.

Якщо всього існує N елементів, то дерево рішення буде представляти собою двійкове дерево висотою N + 1. Воно буде містити 2 N листя і 2 N +1 вузлів. Кожен лист відповідає одному з варіантів розміщення елементів у двох підмножинах.

При вирішенні цього завдання можна застосувати метод гілок і меж. При розгляді часткових розв'язків задачі можна відстежувати, наскільки різняться сумарні значення елементів у двох підмножинах. Якщо в якийсь момент сумарне значення елементів для одного з підмножин настільки менше, ніж для іншого, що додавання всіх елементів, що залишилися не дозволяє змінити це співвідношення, то немає сенсу продовжувати рух вниз по цій гілці.

Так само, як і у випадку з задачею про здійснимість, для задачі про розбивку (partition problem) не можна одержати наближений розв'язок. У результаті завжди повинно вийти дві підмножини, сумарне значення елементів у яких буде чи не буде однаковим. Це означає, що для вирішення цього завдання незастосовні евристики, які використовувалися для вирішення задачі про формування портфеля.

Завдання про розбитті можна узагальнити наступним чином: якщо є безліч елементів зі значеннями X 1, X 2, ..., X N, як розбити його на дві підмножини, щоб різниця суми значень елементів у двох підмножинах була мінімальною?

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

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

Завдання пошуку гамильтонова шляху

Якщо задана мережа, то Гамільтонови шляхом (Hamiltonian path) для неї називається шлях, ходять по всі вузли в мережі тільки один раз і потім повертається в початкову точку.

На рис. 8.9 показана невелика мережа і Гамільтонів шлях для неї, намальований жирною лінією.

Завдання пошуку гамильтонова шляху формулюється так: якщо задана мережа, чи існує для неї Гамільтонів шлях?


============== 219


@ Рис. 8.9. Гамільтонів шлях


Так як Гамільтонів шлях обходить всі вузли в мережі, то не потрібно визначати, які з вузлів потрапляють до нього, а які ні. Необхідно встановити тільки порядок, в якому їх потрібно обійти для створення гамильтонова шляху.

Для моделювання цього завдання за допомогою дерева, припустимо, що гілки відповідають вибору наступного вузла в дорозі. Кореневий вузол тоді буде містити N гілок, що відповідають початку шляху в кожному з N вузлів. Кожен з вузлів першого рівня буде мати N - 1 гілок, по одній гілки для кожного з решти N - 1 вузлів. Вузли на наступному рівні дерева будуть мати N - 2 гілок, і так далі. Нижній рівень дерева буде містити N! листя, відповідних N! можливих шляхів. Всього в дереві буде знаходитися порядку O (N!) вузлів.

Кожен лист відповідає Гамільтонови шляху, але число листя може бути різним для різних мереж. Якщо два вузла в мережі не пов'язані один з одним, то в дереві будуть відсутні гілки, які відповідають переходам між цими двома вузлами. Це зменшує число шляхів у дереві і відповідно, кількість листя.

Так само, як і в завданнях про здійснимість і про розбивку, для задачі пошуку гамильтонова шляху не можна отримати наближене рішення. Шлях може або бути Гамільтонови, або ні. Це означає, що евристичний підхід і метод гілок і меж не допоможуть при пошуку гамильтонова шляху. Що ще гірше, дерево рішень для задачі пошуку гамильтонова подорож містить порядку O (N!) вузлів. Це набагато більше, ніж порядку O (2 N) вузлів, які містять дерева рішень для задач про здійснимість і розбитті. Наприклад, 20 Лютого приблизно дорівнює 1 * 10 6, тоді як 20! складає близько 2,4 * 10 18 - у мільйон разів більше. З за дуже великого розміру дерева рішень задачі знаходження гамильтонова шляху, пошук в ньому можна виконати тільки для задач дуже невеликого розміру.

Завдання комівояжера

Завдання комівояжера (traveling salesman problem) тісно пов'язана із завданням пошуку гамильтонова шляху. Вона формулюється так: знайти найкоротший Гамільтонів шлях для мережі.


======== 220


Це завдання має приблизно таке ж відношення до задачі пошуку гамильтонова шляху, як узагальнений випадок завдання про розбивку до простого завдання про розбивці. У першому випадку виникає питання про існування рішення. У другому - яке наближене рішення буде найкращим. Якби існувало просте рішення другого завдання, то його можна було б використати для вирішення першого варіанту завдання.

Зазвичай завдання комівояжера виникає тільки в мережах, що містять велику кількість гамільтонових шляхів. У типовому прикладі, комівояжеру потрібно відвідати декілька клієнтів, використовуючи найкоротший маршрут. У разі звичайної мережі вулиць, будь-які дві точки в мережі пов'язані між собою, тому будь-який маршрут являє собою Гамільтонів шлях. Завдання полягає в тому, щоб знайти найкоротший з них.

Так само як і у випадку пошуку гамильтонова шляху, дерево рішень для цієї задачі містить порядку O (N!) вузлів. Так само, як і в узагальненій задачі про розбивку, для відсікання гілок дерева і прискорення пошуку вирішення завдань середніх розмірів можна використовувати метод гілок і меж.

Існує також декілька хороших евристичних методів послідовних наближень для задачі комівояжера. Наприклад, використання стратегії пар шляхів, при якій перебираються пари відрізків маршруту. Програма перевіряє, чи стане маршрут коротше, якщо видалити пару відрізків і замінити їх двома новими, так щоб маршрут при цьому залишався замкнутим. На рис. 8.10 показано як змінюється маршрут, якщо відрізки X 1 і X 2 замінити відрізками Y 1 і Y 2. Аналогічні стратегії послідовних наближень розглядають заміну трьох або більше відрізків шляху одночасно.

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

Задача про пожежних депо

Задача про пожежних депо (firehouse problem) формулюється так: якщо задана мережа, деяке число F, і відстань D, то чи існує спосіб розмісити F пожежних депо таким чином, щоб всі вузли мережі знаходилися не далі, ніж на відстані D від найближчого пожежного депо ?


@ Рис. 8.10. Послідовне наближення при вирішенні задачі комівояжера


======== 221


Це завдання можна змоделювати за допомогою дерева рішень, в якому кожна гілка визначає місце розташування відповідного пожежного депо в мережі. Кореневий вузол буде мати N гілок, відповідних розміщення першого пожежного депо в одному з N вузлів мережі. Вузли на наступному рівні дерева будуть мати N - 1 гілок, відповідних розміщенню другого пожежного депо в одному з решти N - 1 вузлів. Якщо всього існує F пожежних депо, то висота дерева рішень буде дорівнює F, і воно буде містити порядку O (N F) вузлів. У дереві буде N * (N - 1) * ... * (N - F) листя, що відповідають різним варіантам розміщення пожежних депо в мережі.

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

Можна, тим не менш, використовувати різновид методу гілок і меж, якщо на ранніх етапах рішення визначити, які з варіантів розміщення пожежних депо не призводять до вирішення. Наприклад, безглуздо поміщати чергове депо між двома іншими, розташованими поряд. Якщо всі вузли на відстані D від нового пожежного депо вже знаходяться в межах цієї відстані від іншого депо, значить, нове депо потрібно помістити в яке інше місце. Тим не менш, такого роду обчислення також забирають досить багато часу, і завдання все ще залишається дуже складною.

Так само, як і для задач про розбивку та пошуку гамильтонова шляху, існує узагальнений випадок задачі про пожежних депо. В узагальненому випадку завдання формулюється так: якщо задана мережа і деяке число F, в яких вузлах мережі потрібно помістити F пожежних депо, щоб найбільша відстань від будь-якого вузла до пожежного депо було мінімальним?

Так само, як і узагальнених випадках інших завдань, для пошуку часткового і наближеного рішень цієї задачі можна використовувати метод гілок і меж і евристичний підхід. Це дещо спрощує перевірку дерева рішень. Хоча дерево рішень все ще залишається величезним, можна принаймні знайти приблизні рішення, навіть якщо вони і не є найкращими.

Коротка характеристика складних завдань

Під час читання попередніх параграфів ви могли помітити, що існує два варіанти багатьох складних завдань. Перший варіант завдання ставить запитання: «Чи існує рішення задачі, що задовольняє певним умовам?». Другий, більш загальний випадок дає відповідь на запитання: «Яке рішення задачі буде найкращим?»

Обидва завдання при цьому мають однакове дерево рішень. У першому випадку дерево рішень проглядається до тих пір, поки не буде знайдено будь-яке рішення. Так як для цих завдань не існує часткового або наближеного рішення, то зазвичай не можна використовувати для зменшення обсягу роботи евристичний підхід або метод гілок і меж. Зазвичай всього лише кілька шляхів у дереві ведуть до вирішення, тому рішення цих завдань - дуже трудомісткий процес.

При вирішенні узагальненого випадку задачі, часто можна використовувати часткові рішення і застосувати метод гілок і меж. Це не полегшує пошук найкращого рішення завдання, тому не допоможе отримати точне рішення для приватної завдання. Наприклад, складніше знайти найкоротший Гамільтонів шлях в мережі, ніж знайти довільний Гамільтонів шлях для тієї ж мережі.

========== 222


З іншого боку, ці питання зазвичай ставляться до різних вхідних даних. Зазвичай питання про існування гамильтонова шляху виникає, якщо мережа розріджена, і складно сказати, чи існує такий шлях. Питання про найкоротший гамільтоновим шляху виникає звичайно, якщо мережа достатньо щільна і існує безліч таких шляхів. У цьому випадку легко знайти часткові рішення, і метод гілок і меж може сильно спростити рішення задачі.

Резюме

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

Метод гілок і меж дозволяє відсікати більшу частину гілок в деяких деревах рішень, що дозволяє отримувати точне рішення для завдань значно більшого розміру.

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


========== 223


Глава 9. Сортування

Сортування - одна з найбільш активно досліджуваних тем у комп'ютерних алгоритмах з ряду причин. По-перше, сортування - це завдання, яка частина зустрічається у багатьох додатках. Майже будь-який список даних буде нести більше сенсу, якщо його відсортувати якимось чином. Часто потрібно сортувати дані кількома різними способами.

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

Нарешті, сортування є однією з небагатьох завдань з точними теоретичними обмеженнями продуктивності. Можна показати, що час виконання будь-якого алгоритму сортування, який використовує порівняння, становить порядку O (N * log (N)). Деякі алгоритми досягають теоретичного межі, тобто вони є оптимальними в цьому сенсі. Є навіть ряд кілька алгоритмів, які використовують інші методи замість порівнянь, які виконуються швидше, ніж за час порядку O (N * log (N)).

Загальні міркування

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

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

Таблиці покажчиків

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


======== 225


Для підвищення продуктивності при сортуванні великих об'єктів можна поміщати ключові поля даних, що використовуються для сортування, в таблицю індексів. У цій таблиці знаходяться ключі до записів та індекси елементів іншого масиву, в якому і знаходяться запису даних. Наприклад, припустимо, що ви збираєтеся відсортувати список записів про співробітників, визначається наступною структурою:


Type Emloyee

ID As Integer

LastName As String

FirstName As String

<І т.д.>

End Type

'Виділити пам'ять під записи.

Dim EmloyeeData (1 To 10000)


Щоб відсортувати співробітників за ідентифікаційним номером, потрібно створити таблицю індексів, яка містить індекси і значення ID values ​​із записів. Індекс елемента показує, яка запис у масиві EmployeeData містить відповідні дані.


Type IdIndex

ID As Integer

Index As Integer

End Type


'Таблиця індексів.

Dim IdIndexData (1 To 10000)


Проініціалізіруем таблицю індексів так, щоб перший індекс вказував на перший запис даних, другий - на другу, і т.д.


For i = 1 To 10000

IdIndexData (i). ID = EmployeeData (i). ID

IdIndexData (i). Index = i

Next i


Потім, відсортуємо таблицю індексів за ідентифікаційним номером ID. Після цього, поле Index у кожному елементі IdIndexData вказує на відповідний запис даних. Наприклад, перший запис у відсортованому списку - це EmployeeData (IdIndexData (1). Index). На рис. 9.1 показано взаємозв'язок між індексом і записом даних до, і після сортування.


======= 226


@ Малюнок 9.1. Сортування за допомогою таблиці індексів


Для того, щоб сортувати дані в різному порядку, можна створити кілька різних таблиць індексів і керувати ними окремо. У наведеному прикладі можна було б створити ще одну таблицю індексів, упорядковують співробітників на прізвище. Подібно до цього списки з посиланнями можуть сортувати список різними способами, як показано в 2 чолі. При додаванні або видаленні запису необхідно оновлювати кожну таблицю індексів незалежно.

Пам'ятайте, що таблиці індексів займають додаткову пам'ять. Якщо створити по таблиці індексів для кожного з полів даних, обсяг займаної пам'яті більш ніж подвоїться.

Об'єднання і стиснення ключів

Іноді можна зберігати ключі списку в комбінованої або стислій формі. Наприклад, можна було б об'єднати (combine) у програмі два поля, відповідних імені та прізвища, в одні ключ. Це дозволило б спростити і прискорити порівняння. Зверніть увагу на відмінності між двома наступними фрагментами коду, які порівнюють два записи про співробітників:


'Використовуючи різні ключі.

If emp1.LastName> emp2.LastName Or _

(Emp1.LastName = emp2.LastName And _

And emp1.FirstName> emp2.FirstName) Then

DoSomething


'Використовуючи об'єднаний ключ.

If emp1.CominedName> emp2.CombinedName Then

DoSomething


======== 227


Також іноді можна стискати (comdivss) ключі. Стислі ключі займають менше місця, зменшуючи розмір таблиць індексів. Це дозволяє сортувати списки більшого розміру без перевитрати пам'яті, швидше переміщати елементи в списку, і часто також прискорює порівняння елементів.

Одні з методів стиснення рядків - кодування їх цілими числами або даними іншого числового формату. Числові дані займають менше місця, ніж рядки і порівняння двох чисельних значень також відбувається набагато швидше, ніж порівняння двох рядків. Звичайно, рядкові операції незастосовні для рядків, представлених числами.

Наприклад, припустимо, що ми хочемо закодувати рядки, що складаються із заголовних латинських букв. Можна вважати, що кожен символ - це число по підставі 27. Необхідно використовувати підставу 27, щоб представити 26 букв і ще одну цифру для позначення кінця слова. Без відмітки кінця слова, закодована рядок AA йшла б після рядка B, тому що в рядку AA дві цифри, а в рядку B - одна.

Код по підставі 27 для рядки з трьох символів дає формула 27 лютого * (перша буква - A + 1) + 27 * (друга літера - A + 1) + 27 * (третя буква - A + 1). Якщо в рядку менше трьох символів, замість значення (третя буква - A + 1) підставляється 0. Наприклад, рядок FOX кодується так:


27 лютого * (F - A + 1) + 27 * (O - A + 1) + (X - A +1) = 4803


Рядок NO кодується таким чином:


27 лютого * (N - A + 1) + 27 * (O - A + 1) + (0) = 10.611


Зауважимо, що 10.611 більше 4803, оскільки NO> FOX.

Таким же чином можна закодувати рядки з 6 великих літер у вигляді числа у форматі long і рядки з 10 літер - як число у форматі double. Дві наступні процедури конвертують рядки у числа у форматі double і назад:


Const STRING_BASE = 27

Const ASC_A = 65 'ASCII код для символу "A".

'Перетворення рядка з число в форматі double.

'

'Full_len - повна довжина, яку повинна мати рядок.

'Потрібна, якщо рядок занадто коротка (наприклад "AX" -

'Це рядок з трьох символів).

Function StringToDbl (txt As String, full_len As Integer) As Double

Dim strlen As Integer

Dim i As Integer

Dim value As Double

Dim ch As String * 1


strlen = Len (txt)

If strlen> full_len Then strlen = full_len


value = 0 #

For i = 1 To strlen

ch = Mid $ (txt, i, 1)

value = value * STRING_BASE + Asc (ch) - ASC_A + 1

Next i


For i = strlen + 1 To full_len

value = value * STRING_BASE

Next i

End Function


'Зворотне декодування рядки з формату double.

Function DblToString (ByVal value As Double) As String

Dim strlen As Integer

Dim i As Integer

Dim txt As String

Dim Power As Integer

Dim ch As Integer

Dim new_value As Double


txt = ""

Do While value> 0

new_value = Int (value / STRING_BASE)

ch = value - new_value * STRING_BASE

If ch <> 0 Then txt = Chr $ (ch + ASC_A - 1) + txt

value = new_value

Loop


DblToString = txt

End Function


=========== 228


У табл. 9.1 наведено час виконання програмою Encode сортування 2000 рядків різної довжини на комп'ютері з процесором Pentium і тактовою частотою 90 МГц. Зауважимо, що результати схожі для кожного типу кодування. Сортування 2000 чисел у форматі double займає приблизно однаковий час незалежно від того, чи становлять вони рядки з 3 або 10 символів.


======== 229


@ Таблиця 9.1. Час сортування 2000 рядків з використанням різних кодувань в секундах


Можна також кодувати рядки, які складаються не тільки з великих літер. Рядок із заголовних букв і цифр можна закодувати по підставі 37 замість 27. Код букви A буде дорівнює 1, B - 2, ..., Z - 26, код 0 буде 27, ..., і 9 - 36. Рядок AH7 буде кодуватися як 37 2 * 1 + 37 * 8 + 35 = 1700.

Звичайно, при використанні більшої підстави, довжина рядка, яку можна закодувати числом типу integer, long або double буде відповідно коротше. При заснуванні рівному 37, можна закодувати рядок з 2 символів в числі формату integer, з 5 символів в числі формату long, і 10 символів в числі формату double.

Приклади програм

Щоб полегшити порівняння різних алгоритмів сортування, програма Sort демонструє більшість алгоритмів, описаних у цій главі. Сортування дозволяє задати число сортируемих елементів, їх максимальне значення, і порядок розташування елементів - прямий, зворотний або розташування у випадковому порядку. Програма створює список випадково розташованих чисел у форматі long і сортує його, використовуючи вибраний алгоритм. Спочатку сортуйте короткі списки, поки не визначите, наскільки швидко ваш комп'ютер може виконувати операції сортування. Це особливо важливо для повільних алгоритмів сортування вставкою, сортування вставкою з використанням зв'язного списку, сортування вибором, і бульбашкової сортування.

Деякі алгоритми переміщають великі блоки пам'яті. Наприклад, алгоритм сортування вставкою переміщує елементи списку для того, щоб можна було вставити новий елемент в середину списку. Для переміщення елементів програми, написаної на Visual Basic, доводиться використовувати цикл For. Наступний код показує, як сортування вставкою переміщує елементи з List (j) до List (max_sorted) для того, щоб звільнити місце під новий елемент у позиції List (j):


For k = max_sorted To j Step -1

List (k + 1) = List (k)

Next k

List (j) = next_num


========== 230


Інтерфейс прикладного програмування системи Windows включає дві функції, які дозволяють набагато швидше виконувати переміщення блоків пам'яті. Програми, скомпільовані 16 бітної версією компілятора Visual Basic 4, можуть використовувати функцію hmemcopy. Програми, скомпільовані 32 бітними компіляторами Visual Basic 4 і 5, можуть використовувати функцію RtlMoveMemory. Обидві функції приймають в якості параметрів кінцевий і початковий адреси і число байт, яке має бути скопійовано. Наступний код показує, як оголошувати ці функції в модулі. BAS:


# If Win16 Then

Declare Sub MemCopy Lib "Kernel" Alias ​​_

"Hmemcpy" (dest As Any, src As Any, _

ByVal numbytes As Long)

# Else

Declare Sub MemCopy Lib "Kernel32" Alias ​​_

"RtlMoveMemory" (dest As Any, src As Any, _

ByVal numbytes As Long)

# EndIf


Наступний фрагмент коду показує, як сортування вставкою може використовувати ці функції для копіювання блоків пам'яті. Цей код виконує ті ж дії, що й цикл For, наведений вище, але робить це набагато швидше:


If max_sorted> = j Then _

MemCopy List (j + 1), List (j), _

Len (next_num) * (max_sorted - j + 1)

List (j) = next_num


Програма FastSort аналогічна програмі Sort, але вона використовує функцію MemCopy для прискорення роботи деяких алгоритмів. У програмі FastSort алгоритми, які використовують функцію MemCopy, виділені синім кольором.

Сортування вибором

Сортування вибором (selectionsort) - простий алгоритм зі складність порядку O (N 2). Ідея полягає у пошуку найменшого елемента в списку, який потім міняється місцями з елементом на вершині списку. Потім перебуває найменший елемент з решти, і міняється місцями з другим елементом. Процес продовжується до тих пір, поки всі елементи не займуть своє кінцеве становище.


Public Sub Selectionsort (List () As Long, min As Long, max As Long)

Dim i As Long

Dim j As Long

Dim best_value As Long

Dim best_j As Long


For i = min To max - 1

'Знайти найменший елемент з решти.

best_value = List (i)

best_j = i

For j = i + 1 To max

If List (j) <best_value Then

best_value = List (j)

best_j = j

End If

Next j


'Помістити елемент на місце.

List (best_j) = List (i)

List (i) = best_value

Next i

End Sub


======== 231


При пошуку I-го найменшого елемента, алгоритму доводиться перебрати NI елементів, які ще не зайняли своє кінцеве становище. Час виконання алгоритму пропорційно N + (N - 1) + (N - 2) + ... + 1, або порядку O (N 2).

Сортування вибором непогано працює зі списками, елементи в яких розташовані випадково або в прямому порядку, але трохи гірше, якщо список спочатку відсортований у зворотному порядку. Для пошуку найменшого елемента в списку сортування вибором виконує наступний код:


If list (j) <best_value Then

best_value = list (j)

best_j = j

End If


Якщо спочатку список відсортований у зворотному порядку, умова list (j) <best_value виконується велику частину часу. Наприклад, при першому проході воно буде істинно для всіх елементів, оскільки кожен елемент менше попереднього. Алгоритм буде багаторазово виконувати рядки з оператором If, що призведе до деякого уповільнення роботи алгоритму.

Це не найшвидший алгоритм з числа описаних у розділі, але він надзвичайно простий. Це не тільки полегшує його розробку і налагодження, але і робить сортування вибором досить швидкої для невеликих завдань. Багато інших алгоритми настільки складні, що вони сортують дуже маленькі списки повільніше.

Рандомізація

У деяких програмах потрібне виконання операції, зворотної сортуванню. Отримавши список елементів, програма повинна розташувати їх у випадковому порядку. Рандомізації (unsorting) списку нескладно виконати, використовуючи алгоритм, схожий на сортування вибором.

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


Public Sub Unsort (List () As Long, min As Long, max As Long)

Dim i As Long

Dim Pos As Long

Dim tmp As Long


For i - min To max - 1

pos = Int ((max - i + 1) * Rnd + i)

tmp = List (pos)

List (pos) = List (i)

List (i) = tmp

Next i

End Sub


============== 232


Оскільки алгоритм заповнює кожну позицію тільки один раз, його складність порядку O (N).

Нескладно показати, що ймовірність того, що елемент виявиться на якій-небудь позиції, дорівнює 1 / N. Оскільки елемент може опинитися в будь-якому положенні з однаковою ймовірністю, цей алгоритм дійсно приводить до випадкового розміщення елементів.

Результат залежить від того, наскільки хорошим є генератор випадкових чисел. Функція Rnd в Visual Basic дає прийнятний результат для більшості випадків. Слід переконатися, що програма використовує оператор Randomize для ініціалізації функції Rnd, інакше при кожному запуску програми функція Rnd буде видавати одну і ту ж послідовність «випадкових» значень.

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

Програма Unsort показує використання цього алгоритму для рандомізації відсортованого списку. Введіть число елементів, які ви хочете рандомізовані, і натисніть кнопку Go (Почати). Програма показує початковий відсортований список чисел і результат рандомізації.

Сортування вставкою

Сортування вставкою (insertionsort) - ще один алгоритм зі складністю порядку O (N 2). Ідея полягає в тому, щоб створити новий сортований список, переглядаючи по черзі всі елементи у вихідному списку. При цьому, вибираючи черговий елемент, алгоритм переглядає зростаючий відсортований список, знаходить потрібне положення елемента в ньому, і поміщає елемент на своє місце в новий список.


Public Sub Insertionsort (List () As Long, min As Long, max As Long)

Dim i As Long

Dim j As Long

Dim k As Long

Dim max_sorted As Long

Dim next_num As Long


max_sorted = min -1

For i = min To max

'Це вставляються число.

Next_num = List (i)


'Пошук його позиції в списку.

For j = min To max_sorted

If List (j)> = next_num Then Exit For

Next j


'Перемістити великі елементи вниз, щоб

'Звільнити місце для нового числа.

For k = max_sorted To j Step -1

List (k + 1) = List (k)

Next k


'Помістити новий елемент.

List (j) = next_num


'Збільшити лічильник відсортованих елементів.

max_sorted = max_sorted + 1

Next i

End Sub


======= 233


Може виявитися, що для кожного з елементів у вихідному списку, алгоритмом доведеться перевіряти всі вже відсортовані елементи. Це відбувається, наприклад, якщо у вихідному списку елементи були вже відсортовані. У цьому випадку, алгоритм поміщає кожен новий елемент в кінець зростаючого відсортованого списку.

Повне число кроків, які потрібно виконати, становить 1 + 2 + 3 + ... + (N - 1), тобто O (N 2). Це не дуже ефективно, якщо порівняти з теоретичним межею O (N * log (N)) для алгоритмів на основі операцій порівняння. Фактично, цей алгоритм не занадто швидкий навіть у порівнянні з іншими алгоритмами порядку O (N 2), такими як сортування вибором.

Досить багато часу алгоритм сортування вставкою витрачає на переміщення елементів для того, щоб вставити новий елемент у середину відсортованого списку. Використання для цього функції API MemCopy збільшує швидкість роботи алгоритму майже вдвічі.

Досить багато часу витрачається і на пошук правильного положення для нового елемента. У 10 главі описано декілька алгоритмів пошуку в відсортованих списках. Застосування алгоритму інтерполяційного пошуку набагато прискорює виконання алгоритму сортування вставкою. Інтерполяційний пошук докладно описується в 10 главі, тому ми не будемо зараз на ньому зупинятися.

Програма FastSort використовує обидва цих методу для покращення продуктивності сортування вставкою. З використанням функції MemCopy і інтерполяційного пошуку, ця версія алгоритму більш ніж в 15 разів швидше, ніж вихідна.

Вставка в зв'язкових списках

Можна використовувати варіант сортування вставкою для впорядкування елементів не в масиві, а в зв'язному списку. Цей алгоритм шукає необхідне положення елемента в зростаючому зв'язковому списку, і потім поміщає туди новий елемент, використовуючи операції роботи зі зв'язківцями списками.


========= 234


Public Sub LinkInsertionSort (ListTop As ListCell)

Dim new_top As New ListCell

Dim old_top As ListCell

Dim cell As ListCell

Dim after_me As ListCell

Dim nxt As ListCell


Set old_top = ListTop.NextCell

Do While Not (old_top Is Nothing)

Set cell = old_top

Set old_top = old_top.NextCell


'Знайти, куди необхідно помістити елемент.

Set after_me = new_top

Do

Set nxt = after_me.NextCell

If nxt Is Nothing Then Exit Do

If nxt.Value> = cell.Value Then Exit Do

Set after_me = nxt

Loop


'Вставити елемент після позиції after_me.

Set after_me.NextCll = cell

Set cell.NextCell = nx

Loop

Set ListTop.NextCell = new_top.NextCell

End Sub


Оскільки цей алгоритм перебирає всі елементи, може знадобитися порівняння кожного елемента з усіма елементами у відсортованому списку. У цьому найгіршому випадку обчислювальна складність алгоритму порядку O (N 2).

Найкращий випадок для цього алгоритму досягається, коли вихідний списку спочатку відсортований у зворотному порядку. При цьому кожний наступний елемент менше, ніж попередній, тому алгоритм поміщає його в початок відсортованого списку. При цьому потрібно виконати тільки одну операцію порівняння елементів, і в найкращому випадку час виконання алгоритму буде порядку O (N).

У усередненому випадку, алгоритмом доведеться провести пошук приблизно по половині відсортованого списку для того, щоб знайти місце розташування елемента. При цьому алгоритм виконується приблизно за 1 + 1 + 2 + 2 + ... + N / 2, або порядку O (N 2) кроків.

Покращена процедура сортування вставкою, що використовує інтерполяційний пошук і функцію MemCopy, працює набагато швидше, ніж версія з зв'язковим списком, тому останню процедуру краще використовувати, якщо програма вже зберігає елементи в зв'язковому списку.

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


======= 235


Бульбашкова сортування

Бульбашкова сортування (bubblesort) - це алгоритм, призначений для сортування списків, які вже знаходяться в майже упорядкованому стані. Якщо на початку процедури список повністю відсортований, алгоритм виконується дуже швидко за час порядку O (N). Якщо частина елементів знаходяться не на своїх місцях, алгоритм виконується повільніше. Якщо спочатку елементи розташовані у випадковому порядку, алгоритм виконується за час порядку O (N 2). Тому перед застосуванням бульбашкової сортування важливо переконатися, що елементи в основному розташовані по порядку.

При бульбашкової сортування список проглядається до тих пір, поки не знайдуться два сусідніх елемента, розташованих не по порядку. Тоді вони міняються місцями, і процедура триває далі. Алгоритм повторює цей процес до тих пір, поки всі елементи не займуть свої місця.

На рис. 9.2 показано, як алгоритм спочатку виявляє, що елементи 6 і 3 розташовані не по порядку, і тому міняє їх місцями. Під час наступного проходу, міняються місцями елементи 5 і 3, у наступному - 4 і 3. Після ще одного проходу алгоритм виявляє, що всі елементи розташовані по порядку, і завершує роботу.

Можна простежити за переміщеннями елемента, який спочатку був розташований нижче, ніж після сортування, наприклад елемента 3 на рис. 9.2. Під час кожного проходу елемент переміщається на одну позицію ближче до свого кінцевого положення. Він рухається до вершини списку подібно бульбашки газу, який спливає до поверхні в склянці води. Цей ефект і дав назву алгоритму бульбашкового сортування.

Можна внести в алгоритм кілька поліпшень. По-перше, якщо елемент розташований у списку вище, ніж повинно бути, ви побачите картину, відмінну від тієї, яка наведена на рис. 9.2. На рис. 9.3 показано, що алгоритм спочатку виявляє, що елементи 6 і 3 розташовані в неправильному порядку, і міняє їх місцями. Потім алгоритм продовжує переглядати масив і зауважує, що тепер неправильно розташовані елементи 6 і 4, і також змінює їх місцями. Потім міняються місцями елементи 6 і 5, і елемент 6 займає своє місце.


@ Рис. 9.2. «Спливання» елемента


======== 236


@ Рис. 9.3. «Занурення» елемента


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

Під час проходів зверху вниз, найбільший елемент списку переміщується на місце, а під час проходів знизу вгору - найменший. Якщо M елементів списку розташовані не на своїх місцях, алгоритму буде потрібно не більше M проходів для того, щоб розташувати елементи по черзі. Якщо в списку N елементів, алгоритму потрібно N кроків для кожного проходу. Таким чином, повний час виконання для цього алгоритму буде порядку O (M * N).

Якщо спочатку список організований випадковим чином, більша частина елементів буде знаходитися не на своїх місцях. У прикладі, наведеному на рис. 9.3, елемент 6 тричі міняється місцями з сусідніми елементами. Замість виконання трьох окремих перестановок, можна зберегти значення 6 в тимчасовій змінної до тих пір, поки не буде знайдено кінцеве положення елемента. Це може заощадити велику кількість кроків алгоритму, якщо елементи переміщуються на великі відстані всередині масиву.

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


======== 237


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

Реалізація алгоритму бульбашкового сортування мовою Visual Basic використовує змінні min і max для позначення першого і останнього елементів списку, які знаходяться не на своїх місцях. У міру того, як алгоритму повторює проходи за списком, ці змінні оновлюються, вказуючи положення останньої перестановки.


Public Sub Bubblesort (List () As Long, ByVal min As Long, ByVal max As Long)

Dim last_swap As Long

Dim i As Long

Dim j As Long

Dim tmp As Long


'Повторювати до завершення.

Do While min <max

'«Спливання».

last_swap = min - 1

"Тобто For i = min + 1 To max.

i = min + 1

Do While i <= max

'Знайти «бульбашка».

If List (i - 1)> List (i) Then

'Знайти, куди його помістити.

tmp = List (i - 1)

j = i

Do

List (j - 1) = List (j)

j = j + 1

If j> max Then Exit Do

Loop While List (j) <tmp

List (j - 1) = tmp

last_swap = j - 1

i = j + 1

Else

i = i + 1

End If

Loop

'Оновити змінну max.

max = last_swap - 1


'«Занурення».

last_swap = max + 1

"Тобто For i = max -1 To min Step -1

i = max - 1

Do While i> = min

'Знайти «бульбашка».

If List (i + 1) <List (i) Then

'Знайти, куди його помістити.

tmp = List (i + 1)

j = i

Do

List (j + 1) = List (j)

j = j - 1

If j <min Then Exit Do

Loop While List (j)> tmp

List (j + 1) = tmp

last_swap = j + 1

i = j - 1

Else

i = i - 1

End If

Loop

'Оновити змінну min.

Min = last_swap + 1

Loop

End Sub


========== 238


Для того, щоб протестувати алгоритм бульбашкової сортування за допомогою програми Sort, поставте галочку в поле Sorted (Відсортовані) в області Initial Ordering (Початковий порядок). Введіть число елементів у полі # Unsorted (Число несортованих). Після натискання на кнопку Go (Почати), програма створює і сортує список, а потім переставляє випадково вибрані пари елементів. Наприклад, якщо ви введете число 10 в поле # Unsorted, програма переставить 5 пар чисел, тобто 10 елементів виявляться не на своїх місцях.

Для другого варіанту первісного алгоритму, програма зберігає елемент в тимчасовій змінної при переміщенні на кілька кроків. Цей відбувається ще швидше, якщо використовувати функцію API MemCopy. Алгоритм бульбашкової сортування в програмі FastSort, використовуючи функцію MemCopy, сортує елементи в 50 або 75 разів швидше, ніж первинна версія, реалізована в програмі Sort.

У табл. 9.2 наведено час виконання бульбашкової сортування 2000 елементів на комп'ютері з процесором Pentium з тактовою частотою 90 МГц в залежності від ступеня первісної впорядкованості списку. З таблиці видно, що алгоритм бульбашкової сортування забезпечує гарну продуктивність, тільки якщо список із самого початку майже відсортований. Алгоритм швидкого сортування, який описується далі в цьому розділі, здатний відсортувати той же список з 2000 елементів приблизно за 0,12 сек, незалежно від початкового порядку розташування елементів у списку. Бульбашкова сортування може перевершити цей результат, тільки якщо приблизно 97 відсотків списку було упорядковано до початку сортування.


===== 239


@ Таблиця 9.2. Час бульбашкової сортування 2.000 елементів


Незважаючи на те, що бульбашкова сортування повільніше, ніж багато інших алгоритми, у неї є свої застосування. Бульбашкова сортування часто дає найкращі результати, якщо список спочатку вже майже впорядкований. Якщо програма управляє списком, який сортується при створенні, а потім до нього додаються нові елементи, бульбашкова сортування може бути кращим вибором.

Швидке сортування

Швидке сортування (quicksort) - рекурсивний алгоритм, який використовує підхід «розділяй і володарюй». Якщо сортований список більше, ніж мінімальний заданий розмір, процедура швидкого сортування розбиває його на два підсписки, а потім рекурсивно викликає себе для сортування двох підсписків.

Перша версія алгоритму швидкого сортування, обговорювана тут, досить проста. Якщо алгоритм викликається для підсписки, що містить не більше одного елемента, то подспісок вже відсортований, і підпрограма завершує роботу.

Інакше, процедура вибирає який або пункт у списку і використовує його для розбиття списку на два підсписки. Вона поміщає елементи, які менше, ніж обраний елементи в перший подспісок, а решта - в другій, і потім рекурсивно викликає себе для сортування двох підсписків.


Public Sub QuickSort (List () As Long, ByVal min as Integer, _

ByVal max As Integer)

Dim med_value As Long

Dim hi As Integer

Dim lo As Integer


'Якщо залишилося менше 1 елемента, подспісок відсортований.

If min> = max Then Exit Sub


'Вибрати значення для розподілу списку.

med_value = list (min)

lo = min

hi = max

Do

Перегляд від hi до значення <med_value.

Do While list (hi)> = med_value

hi = hi - 1

If hi <= lo Then Exit Do

Loop

If hi <= lo Then

list (lo) = med_value

Exit Do

End If

'Поміняти місцями значення lo і hi.

list (lo) = list (hi)


'Перегляд від lo до значення> = med_value.

lo = lo + 1

Do While list (lo) <med_values

lo = lo + 1

If lo> = hi Then Exit Do

Loop

If lo> = hi Then

lo = hi

list (hi) = med_value

Exit Do

End If

'Поміняти місцями значення lo і hi.

list (hi) = list (lo)

Loop


'Рекурсивна сортування двох подлист.

QuickSort list (), min, lo - 1

QuickSort list (), lo + 1, max

End Sub


========= 240


Є кілька важливих моментів у цій версії алгоритму, які варто згадати. По-перше, значення med_value для поділу списку не входить ні в один подспісок. Це означає, що у двох підсписки міститься на одні елемент менше, ніж у вихідному списку. Оскільки число розглянутих елементів зменшується, то в кінцевому підсумку алгоритм завершить роботу.

Ця версія алгоритму використовує як роздільник перший елемент у списку. В ідеалі, це значення повинне було б перебувати десь у середині списку, так щоб два підсписки були приблизно рівного розміру. Тим не менш, якщо елементи спочатку майже відсортовані, то перший елемент - найменший у списку. При цьому алгоритм не помістить жодного елемента в перший подспісок, і всі елементи у другій. Послідовність дій алгоритму буде приблизно такою, як показано на рис. 9.4.

У цьому випадку кожен виклик підпрограми вимагає порядку O (N) кроків для переміщення всіх елементів у другій подспісок. Оскільки алгоритм рекурсивно викликає себе N - 1 раз, час його виконання буде порядку O (N 2), що не краща, ніж у раніше розглянутих алгоритмів. Ситуацію ще більш погіршує те, що рівень вкладеності рекурсії алгоритму N - 1. Для великих списків величезна глибина рекурсії призведе до переповнення стека і збою в роботі програми.


========= 242


@ Рис. 9.4. Швидке сортування впорядкованого списку


Існує багато стратегій вибору розділового елемента. Можна використовувати елемент з середини списку. Це може виявитися непоганим вибором, тим не менш, може виявитися і так, що це виявиться найменший чи найбільший елемент списку. При цьому один подспісок буде набагато більше, ніж інший, що призведе до зниження продуктивності до порядку O (N 2) і глибокому рівню рекурсії.

Інша стратегія може полягати в тому, щоб переглянути весь список, обчислити середнє арифметичне всіх значень, і використовувати його як розділовий значення. Цей підхід буде давати непогані результати, але зажадає додаткових зусиль. Додатковий прохід зі складністю порядку O (N) не змінить теоретичний час виконання алгоритму, але знизить загальну продуктивність.

Третя стратегія - вибрати середній з елементів на початку, кінці та в середині списку. Перевага цього підходу в швидкості, тому що буде потрібно вибрати всього три елементи. При цьому гарантується, що цей елемент не є найбільшим або найменшим у списку, і ймовірно виявиться десь у середині списку.

І, нарешті, остання стратегія, яка використовується в програмі Sort, полягає у випадковому виборі елемента зі списку. Можливо, це буде непоганим вибором. Навіть якщо це не так, можливо на наступному кроці алгоритм, можливо, зробить кращий вибір. Імовірність постійного випадіння найгіршого випадку дуже мала.

Цікаво, що цей метод перетворює ситуацію «невелика ймовірність того, що завжди буде погана продуктивність» в ситуацію «завжди невелика ймовірність поганий продуктивності». Це досить заплутане твердження пояснюється в наступних абзацах.

При використанні інших методів вибору точки розділу, існує невелика ймовірність того, що при певній організації списку час сортування буде порядку O (N 2), Хоча малоймовірно, що подібна організація списку на початку сортування зустрінеться насправді, тим не менше, час виконання при цьому буде визначено порядку O (N 2), неважливо чому. Це те, що можна назвати «невеликою ймовірністю того, що завжди буде погана продуктивність».


=========== 242


При випадковому виборі точки розділу первісне розташування елементів не впливає на продуктивність алгоритму. Існує невелика ймовірність невдалого вибору елемента, але ймовірність того, що це відбуватиметься постійно, надзвичайно мала. Це можна позначити як «завжди невелика ймовірність поганий продуктивності». Незалежно від первісної організації списку, дуже малоймовірно, що продуктивність алгоритму буде порядку O (N 2).

Тим не менш, все ще залишається ситуація, яка може викликати проблеми при використанні будь-якого з цих методів. Якщо в списку дуже мало різних значень у списку, алгоритм заносить безліч однакових значень у подспісок при кожному виклику. Наприклад, якщо кожен елемент у списку має значення 1, послідовність виконання буде такою, як показано на рис. 9.5. Це призводить до великого рівня вкладеності рекурсії і дає продуктивність порядку O (N 2).

Схожа поведінка відбувається також за наявності великої кількості повторюваних значень. Якщо список складається з 10.000 елементів зі значеннями від 1 до 10, алгоритм досить швидко розділить список на підсписки, кожен з яких містить лише одне значення.

Найбільш простий вихід - ігнорувати цю проблему. Якщо ви знаєте, що дані не мають такого розподілу, то проблеми немає. Якщо дані мають невеликий діапазон значень, то вам варто розглянути інший алгоритм сортування. Описувані далі в цій главі алгоритми сортування підрахунком і блокового сортування дуже швидко сортують списки, даних у яких знаходяться у вузькому діапазоні.

Можна внести ще одне невелике поліпшення в алгоритм швидкого сортування. Подібно багатьох інших більш складним алгоритмам, описаним далі в цій главі, швидке сортування - не найкращий вибір для сортування невеликих списків. Завдяки своїй простоті, сортування вибором швидше при сортуванні приблизно десятка записів.


@ Рис. 9.5. Швидке сортування списку з одиниць


========== 243


@ Таблиця 9.3. Час швидкої сортування 20.000 елементів


Можна поліпшити продуктивність швидкого сортування, якщо припинити рекурсію до того, як підсписки зменшаться до нуля, і використовувати для завершення роботи сортування вибором. У табл. 9.3 наведено час, який займає виконання швидкого сортування 20.000 елементів на комп'ютері з процесором Pentium з тактовою частотою 90 МГц, якщо зупиняти сортування при досягненні підсписки певного розміру. У цьому тесті оптимальне значення цього параметра було дорівнює 15.

Наступний код демонструє доопрацьований алгоритм:


Public Sub QuickSort * List () As Long, ByVal min As Long, ByVal max As Long)

Dim med_value As Long

Dim hi As Long

Dim lo As Long

Dim i As Long


'Якщо в списку більше, ніж CutOff елементів,

'Завершити його сортування процедурою SelectionSort.

If max - min <cutOff Then

SelectionSort List (), min, max

Exit Sub

End If


'Вибрати розділяє значення.

i = Int ((max - min + 1) * Rnd + min)

med_value = List (i)


'Перенести його вперед.

List (i) = List (min)


lo = min

hi = max

Do

'Перегляд зверху вниз від hi до значення <med_value.

Do While List (hi)> = med_value

hi = hi - 1

If hi <= lo Then Exit Do

Loop

If hi <= lo Then

List (lo) = med_value

Exit Do

End If


'Поміняти місцями значення lo і hi.

List (lo) = List (hi)


'Перегляд знизу вгору від lo до значення> = med_value.

lo = lo + 1

Do While List (lo) <med_value

lo = lo + 1

If lo> = hi Then Exit Do

Loop

If lo> = hi Then

lo = hi

List (hi) = med_value

Exit Do

End If


'Поміняти місцями значення lo і hi.

List (hi) = List (lo)

Loop


'Сортувати два підсписки.

QuickSort List (), min, lo - 1

QuickSort List (), lo + 1, max

End Sub


======= 244


Багато програмістів вибирають алгоритм швидкого сортування, тому що він дає хорошу продуктивність в більшості обставин.

Сортування злиттям

Як і швидке сортування, сортування злиттям (mergesort) - це рекурсивний алгоритм. Він також поділяє список на два підсписки, і рекурсивно сортує підсписки.

Сортування злиттям ділить список навпіл, формуючи два підсписки однакового розміру. Потім підсписки рекурсивно сортуються, і відсортовані підсписки зливаються, утворюючи повністю відсортований список.

Хоча етап злиття легко зрозуміти, це найбільш цікава частина алгоритму. Підсписки зливаються в тимчасовий масив, і результат копіюється в початковий список. Створення тимчасової масиву може бути недоліком, особливо якщо розмір елементів великий. Якщо розмір тимчасового розміру дуже великий, він може призводити до звернення до файлу підкачки і значно знижувати продуктивність. Робота з тимчасовим масивом також призводить до того, що велика частина часу йде на копіювання елементів між масивами.

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


========= 245


Public Sub Mergesort (List () As Long, Scratch () As Long, _

ByVal min As Long, ByVal max As Long)

Dim middle As Long

Dim i1 As Long

Dim i2 As Long

Dim i3 As Long


'Якщо в списку більше, ніж CutOff елементів,

'Завершити його сортування процедурою SelectionSort.

If max - min <CutOff Then

Selectionsort List (), min, max

Exit Sub

End If


'Рекурсивна сортування підсписків.

middle = max \ 2 + min \ 2

Mergesort List (), Scratch (), min, middle

Mergesort List (), Scratch (), middle + 1, max


'Злити відсортовані списки.

i1 = min 'Індекс списку 1.

i2 = middle + 1 'Індекс списку 2.

i3 = min 'Індекс об'єднаного списку.

Do While i1 <= middle And i2 <= max

If List (i1) <= List (i2) Then

Scratch (i3) = List (i1)

i1 = i1 + 1

Else

Scratch (i3) = List (i2)

i2 = i2 + 1

end If

i3 = i3 + 1

Loop


"Видалення непорожньої списку.

Do While i1 <= middle

Scratch (i3) = List (i1)

i1 = i1 + 1

i3 = i3 + 1

Loop

Do While i2 <= max

Scratch (i3) = List (i2)

i2 = i2 + 1

i3 = i3 + 1

Loop


'Помістити відсортований список на місце вихідного.

For i3 = min To max

List (i3) = Scratch (i3)

Next i3

End Sub


======== 246


Сортування злиттям витрачає багато часу на копіювання тимчасового масиву на місце початкового. Програма FastSort використовує функцію API MemCopy, щоб трохи прискорити цю операцію.

Навіть з використанням функції MemCopy, сортування злиттям трохи повільніше, ніж швидке сортування. У нашому тесті на комп'ютері з процесором Pentium з тактовою частотою 90 МГц, сортування злиттям зажадала 2,95 сек для упорядкування 30.000 елементів зі значеннями в діапазоні від 1 до 10.000. Швидке сортування зажадала всього 2,44 сек.

Перевага сортування злиттям в тому, що час її виконання залишається однаковим незалежно від різних розподілів і початкового розташування даних. Швидка ж сортування дає продуктивність порядку O (N 2) і досягає глибокого рівня вкладеності рекурсії, якщо список містить багато однакових значень. Якщо список великий, швидке сортування може переповнити стек і привести до аварійного завершення роботи програми. Сортування злиттям ніколи не досягає занадто глибокого рівня вкладеності рекурсії, тому що завжди поділяє список на рівні частини. Для списку з N елементів, глибина вкладеності рекурсії для сортування злиттям складає всього лише log (N).

В іншому тесті, в якому використовувалися 30.000 елементів зі значеннями від 1 до 100, сортування злиттям зажадала стільки ж часу, скільки і для елементів зі значеннями від 1 до 10.000 - 2,95 секунд. Швидке сортування зайняла 15,82 секунди. Якщо значення лежали між 1 і 50, сортування злиттям зажадала 2,95 секунд, тоді як швидке сортування - 138,52 секунди.

Пірамідальна сортування

Пірамідальна сортування (heapsort) використовує спеціальну структуру, яка називається пірамідою (heap), для організації елементів у списку. Піраміди цікаві самі по собі і корисні при реалізації пріоритетних черг.

На початку цієї глави описуються піраміди, і пояснюється, як ви можете реалізувати піраміди мовою Visual Basic. Потім показано, як використовувати піраміду для побудови ефективної пріоритетною черзі. Маючи в своєму розпорядженні засобами для управління пірамідами і пріоритетними чергами, легко реалізувати алгоритм пірамідальної сортування.

Піраміди

Піраміда (heap) - це повне бінарне дерево, в якому кожен вузол не менше, ніж обидва його нащадка. Це нічого не говорить про взаємозв'язок між нащадками. Вони повинні бути менше батька, але будь-який з них може бути більше, ніж інший. На рис. 9.6 показана невелика піраміда.

Оскільки кожен вузол не менше, ніж два нижележащих вузла, корінь дерева - завжди найбільший елемент в піраміді. Це робить піраміди зручною структурою даних для реалізації пріоритетних черг. Якщо вам потрібен елемент черги з найвищим пріоритетом, вона завжди перебуває на вершині піраміди.


========= 247


Рис. 9.6. Піраміда


Оскільки піраміда є повним двійковим деревом, ви можете використовувати методи, викладені у 6 розділі, для збереження піраміди в масиві. Помістіть кореневий вузол у 1 позицію масиву. Нащадки вузла I розміщуються в позиціях 2 * I і 2 * I + 1. Рис. 9.7 показує піраміду з рис. 9.6, записану у вигляді масиву.

Щоб зрозуміти, як влаштована піраміда, зауважимо, що піраміда створена з пірамід меншого розміру. Піддерево, що починається з будь-якого вузла піраміди, також є пірамідою. Наприклад, в піраміді, показаної на рис. 9.8, піддерево з коренем у вузлі 13 також є пірамідою.

Використовуючи цей факт, можна побудувати піраміду знизу вгору. Спочатку, розмістимо елементи у вигляді дерева, як показано на рис. 9.9. Потім організуємо піраміди з невеликих піддерев внизу дерева. Оскільки в них всього по три вузли, зробити це досить просто. Порівняємо вершину з кожним із нащадків. Якщо один з нащадків більше, він міняється місцями з батьком. Якщо обидва нащадка більше, більший нащадок міняється місцями з батьком. Цей крок повторюється до тих пір, поки всі піддерева, що мають по 3 вузли, не будуть перетворені в піраміди, як показано на рис. 9.10.

Тепер об'єднаємо маленькі піраміди для створення більш великих пірамід. З'єднаємо на рис. 9.10 маленькі піраміди з вершинами 15 і 5 і елемент, створивши піраміду більшого розміру. Порівняємо нову вершину 7 з кожним із нащадків. Якщо один з нащадків більше, поміняємо його місцями з вершиною. У нашому випадку 15 більше, ніж 7 і 4, тому вузол 15 міняється місцями з вузлом 7.

Оскільки праве піддерево, що починається з вузла 4, не змінювалося, це піддерево Як і раніше є пірамідою. Ліве ж піддерево змінилося. Щоб визначити, чи є воно все ще пірамідою, порівняємо його нову вершину 7 з нащадками 13 і 12. Оскільки 13 більше, ніж 7 і 12, необхідно поміняти місцями вузли 7 і 13.


@ Рис. 9.7. Представлення піраміди у вигляді масиву


======== 248


@ Рис. 9.8. Піраміда утворюється з менших пірамід


@ Рис. 9.9. Невпорядкований список у повному дереві


@ Рис. 9.10. Піддерева другого рівня є пірамідами


========= 249


@ Рис. 9.11. Об'єднання пірамід у піраміду більшого розміру


Якщо піддерево вище, можна продовжити переміщення вузла 7 вниз по піддереві. Врешті-решт, або буде досягнута точка, в якій вузол 7 більше обох своїх нащадків, або алгоритм досягне основи дерева. На рис. 9.11 показано дерево після перетворення цього піддерева в піраміду.

Продовжимо об'єднання пірамід, утворюючи піраміди більшого розміру до тих пір, поки всі елементи не утворюють одну велику піраміду, таку як на рис. 9.6.

Наступний код переміщує елемент з положення List (min) вниз по піраміді. Якщо піддерева нижче List (min) є пірамідами, то процедура зливає піраміди, утворюючи піраміду більшого розміру.


Private Sub HeapPushDown (List () s Long, ByVal min As Long, _

ByVal max As Long)

Dim tmp As Long

Dim j As Long


tmp = List (min)

Do

j = 2 * min

If j <= max Then

'Розмістити в j покажчик на більшого нащадка.

If j <max Then

If List (j + 1)> List (j) Then _

j = j + 1

End If


If List (j)> tmp Then

'Нащадок більше. Поміняти його місцями з батьком.

List (min) = List (j)

'Переміщення цього нащадка вниз.

min = j

Else

'Батько більше. Процедура закінчена.

Exit Do

End If

Else

Exit Do

End If

Loop

List (min) = tmp

End Sub


Повний алгоритм, що використовує процедуру HeapPushDown для створення піраміди з дерева елементів, надзвичайно простий:


Private Sub BuildHeap ()

Dim i As Integer


For i = (max + min) \ 2 To min Step -1

HeapPushDown list (), i, max

Next i

End Sub


Пріоритетні черзі

Пріоритетною чергою (priority queue) легко керувати за допомогою процедур BuildHeap і HeapPushDown. Якщо в якості пріоритетної черги використовується піраміда, легко знайти елемент з найвищим пріоритетом - він завжди знаходиться на вершині піраміди. Але якщо його видалити, що вийшло дерево без кореня вже не буде пірамідою.

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


Public Function Pop () As Long

If NumInQueue <1 Then Exit Function


'Видалити верхній елемент.

Pop = Pqueue (1)


'Перемістити останній елемент на вершину.

PQueue (1) = PQueue (NumInPQueue)

NumInPQueue = NumInPQueue - 1


'Знову зробити дерево пірамідою.

HeapPushDown PQueue (), 1, NumInPQueue

End Function


Щоб додати новий елемент до пріоритетної черги, збільште піраміду. Помістіть новий елемент на вільне місце в кінці масиву. Отримане дерево також не буде пірамідою.

Щоб знову перетворити його в піраміду, порівняйте новий елемент з його батьком. Якщо новий елемент більше, поміняйте їх місцями. Заздалегідь відомо, що другий нащадок менше, ніж батько, тому немає необхідності порівнювати новий елемент з іншим нащадком. Якщо елемент більше батька, то він також більше і другого нащадка.

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


Private Sub HeapPushUp (List () As Long, ByVal max As Integer)

Dim tmp As Long

Dim j As Integer


tmp = List (max)

Do

j = max \ 2

If j <1 Then Exit Do

If List (j) <tmp Then

List (max) = List (j)

max = j

Else

Exit Do

End If

Loop

List (max) = tmp

End Sub


Підпрограма Push додає новий елемент до дерева і використовує підпрограму HeapPushDown для відновлення піраміди.


Public Sub Push (value As Long)

NumInPQueue = NumInPQueue + 1

If NumInPQueue> PQueueSize Then ResizePQueue


PQueue (NumInPQueue) = value

HeapPushUp PQueue (), NumInPQueue

End Sub


======== 252


Аналіз пірамід

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

При створенні кожної піраміди може знадобитися просувати елемент вниз по піраміді, можливо до тих пір, поки він не досягне кінцевого вузла. Найвищі з побудованих пірамід матимуть висоту порядку O (log (N)). Так як створюється O (N) пірамід, і для побудови найвищою з них потрібно O (log (n)) кроків, то все піраміди можна побудувати за час порядку O (N * log (N)).

Насправді часу буде потрібно ще менше. Тільки деякі піраміди будуть мати висоту порядку O (log (N)). Більшість з них набагато нижче. Тільки одна піраміда має висоту, рівну log (N), і половина пірамід - висоту всього в 2 вузла. Якщо підсумувати всі кроки, необхідні для створення всіх пірамід, насправді потрібно не більше O (N) кроків.

Щоб побачити, чи так це, припустимо, що дерево містить N вузлів. Нехай H - висота дерева. Це повне бінарне дерево, отже, H = log (N).

Тепер припустимо, що ви будуєте всі великі і великі піраміди. Для кожного вузла, який знаходиться на відстані HI рівнів від кореня дерева, будується піраміда з висотою I. Усього таких вузлів 2 HI, і всього створюється 2 HI пірамід з висотою I.

Для побудови цих пірамід може знадобитися пересувати елемент вниз до тих пір, поки він не досягне кінцевого вузла. Переміщення елемента вниз по піраміді з висотою I вимагає до I кроків. Для пірамід з висотою I повне число кроків, яке потрібно для побудови 2 HI пірамід, так само I * 2 HI.

Склавши всі кроки, що витрачаються на побудову пірамід різного розміру, отримуємо 1 * 2 H-1 +2 * 2 H-2 +3 * 2 H-3 + ... + (H-1) * 2 1. Винісши за дужки 2 H, отримаємо 2 H * (1 / 2 +2 / 2 2 +3 / 2 3 + ... + (H-1) / 2 H-1).

Можна показати, що (1 / 2 +2 / 2 2 +3 / 2 3 + ... + (H-1) / 2 H-1) менше 2. Тоді повне число кроків, яке потрібно для побудови всіх пірамід, менше, ніж 2 H * 2. Так як H - висота дерева, рівна log (N), то повне число кроків менше, ніж 2 log (N) * 2 = N * 2. Це означає, що для первісного побудови піраміди потрібно порядку O (N) кроків.

Для видалення елемента з пріоритетною черзі, останній елемент переміщається на вершину дерева. Потім він просувається вниз, поки не займе своє остаточне положення, і дерево знову не стане пірамідою. Так як дерево має висоту log (N), процес може зайняти не більше log (N) кроків. Це означає, що новий елемент до пріоритетної черги на основі піраміди можна додати за O (log (N)) кроків.

Іншим способом роботи з пріоритетними чергами є використання упорядкованого списку. Вставка та видалення елемента з упорядкованого списку з мільйоном елементів займає приблизно мільйон кроків. Вставка та видалення елемента з порівнянної за розмірами пріоритетною черзі, заснованої на піраміді, займає всього 20 кроків.


====== 253


Алгоритм пірамідальної сортування

Алгоритм пірамідальної сортування просто використовує вже описані алгоритми для роботи з пірамідами. Ідея полягає в тому, щоб створити пріоритетну чергу і послідовно видаляти по одному елементу з черги.

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

Після того, як найбільший елемент помінявся місцями з останнім, масив більше не є пірамідою, тому що новий елемент на вершині може виявитися менше, ніж його нащадки. Тому алгоритм використовує процедуру HeapPushDown для просування елемента на його місце. Алгоритм продовжує міняти елементи місцями і відновлювати піраміду до тих пір, поки в піраміді не залишиться елементів.


Public Sub Heapsort (List () As Long, ByVal min As Long, ByVal max As Long)

Dim i As Long

Dim tmp As Long


'Створити піраміду (крім кореневого вузла).

For i = (max + min) \ 2 To min + 1 Step -1

HeapPushDown List (), i, max

Next i


'Повторювати:

'1. Просунутися вниз по піраміді.

'2. Видати корінь.

For i = max To min + 1 Step -1

'Просунутися вниз по піраміді.

HeapPushDown List (), min, i


'Видати корінь.

tmp = List (min)

List (min) = List (i)

List (i) = tmp

Next i

End Sub


Попереднє обговорення пріоритетних черг показало, що початкове побудова піраміди вимагає O (N) кроків. Після цього потрібно O (log (N)) кроків для відновлення піраміди, коли елемент просувається на своє місце. Пірамідальна сортування виконує цю дію N раз, тому потрібно всього порядку O (N) * O (log (N)) = O (N * log (N)) кроків, щоб отримати з піраміди упорядкований список. Повний час виконання для алгоритму пірамідальної сортування складає порядку O (N) + O (N * log (N)) = O (N * log (N)).


========= 254


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

Хоча зазвичай пірамідальна сортування працює трохи повільніше, ніж сортування злиттям, для неї не потрібно додаткового простору для зберігання тимчасових значень, як для сортування злиттям. Пірамідальна сортування створює первинну піраміду і впорядковує елементи в межах вихідного масиву списку.

Сортування підрахунком

Сортування підрахунком (countingsort) - спеціалізований алгоритм, який дуже добре працює, якщо елементи даних - цілі числа, значення яких знаходяться у відносно вузькому діапазоні. Цей алгоритм працює досить швидко, наприклад, якщо значення перебувають між 1 і 1000.

Якщо список задовольняє цим вимогам, сортування підрахунком виконується неймовірно швидко. В одному з тестів на комп'ютері з процесором Pentium з тактовою частотою 90 МГц, швидке сортування 100.000 елементів зі значеннями між 1 і 1000 зайняла 24,44 секунди. Для сортування тих же елементів сортування підрахунком треба було всього 0,88 секунд - у 27 разів менше часу.

Видатна швидкість сортування підрахунком досягається за рахунок того, що при цьому не використовуються операції порівняння. Раніше в цій главі відзначалося, що час виконання будь-якого алгоритму сортування, що використовує операції порівняння, порядку O (N * log (N)). Без використання операцій порівняння, алгоритм сортування підрахунком дозволяє впорядковувати елементи за час порядку O (N).

Сортування підрахунком починається зі створення масиву для підрахунку числа елементів, що мають певне значення. Якщо значення перебувають у діапазоні між min_value і max_value, алгоритм створює масив Counts з нижньою межею min_value і верхньою межею max_value. Якщо використовується масив з попереднього проходу, необхідно обнулити значення його елементів. Якщо існує M значень елементів, масив містить M записів, і час виконання цього кроку порядку O (M).


For i = min To max

Counts (List (i)) = Counts (List (i)) + 1

Next i


Зрештою, алгоритм обходить масив Counts, поміщаючи відповідне число елементів у відсортований масив. Для кожного значення i між min_value і max_value, він поміщає Counts (i) елементів зі значенням i в масив. Так як цей крок поміщає по одному запису у кожну позицію в масиві, він вимагає порядку O (N) кроків.


new_index = min

For i = min_value To max_value

For j = 1 To Counts (i)

sorted_list (new_index) = i

new_index = new_index + 1

Next j

Next i


====== 255


Алгоритм цілком вимагає порядку O (M) + O (N) + O (N) = O (M + N) кроків. Якщо M мало в порівнянні з N, він виконується дуже швидко. Наприклад, якщо M

З іншого боку, якщо M більше, ніж O (N * log (N)), тоді O (M + N) буде більше, ніж O (N * log (N)). У цьому випадку сортування підрахунком може виявитися повільніше, ніж алгоритми зі складністю порядку O (N * log (N)), такі як швидке сортування. В одному з тестів швидке сортування 1000 елементів зі значеннями від 1 до 500.000 зажадав 0,054 сек, в той час як сортування підрахунком зажадала 1,76 секунд.

Сортування підрахунком спирається на той факт, що значення даних - цілі числа, тому цей алгоритм не може просто сортувати дані інших типів. У Visual Basic не можна створити масив з кордонами від AAA до ZZZ.

Раніше в цій главі в розділі «об'єднання і стиснення ключів» було продемонстровано, як можна кодувати строкові дані за допомогою цілих чисел. Якщо ви може закодувати дані за допомогою даних типу Integer або Long, ви все ще можете використовувати сортування підрахунком.

Сортування комірками

Як і сортування підрахунком, блокова сортування (bucketsort) не використовує операцій порівняння елементів. Цей алгоритм використовує значення елементів для розбиття їх на блоки, і потім рекурсивно сортує отримані блоки. Коли блоки стають досить малими, алгоритм зупиняється і використовує більш простий алгоритм типу сортування вибором для завершення процесу.

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

Для розподілу списку на блоки, алгоритм передбачає, що значення даних розподілені рівномірно, і розподіляє елементи по блоках рівномірно. Наприклад, припустимо, що дані мають значення у діапазоні від 1 до 100 і алгоритм використовує 10 блоків. Алгоритм поміщає елементи зі значеннями 1 10 в перший блок, зі значеннями 11 20 - в другій, і т.д. На рис. 9.12 показаний список з 10 елементів зі значеннями від 1 до 100, які розташовані в 10 блоках.


@ Рис. 9.12. Розташування елементів в блоках.


======= 256


Якщо елементи розподілені рівномірно, в кожен блок потрапляє приблизно однакове число елементів. Якщо в списку N елементів, і алгоритм використовує N блоків, в кожен блок потрапляє всього один або два елементи. Програма може відсортувати їх за кінцеве число кроків, тому час виконання алгоритму в цілому порядку O (N).

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

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

Сортування комірками із застосуванням зв'язного списку

Реалізувати алгоритм блокового сортування на Visual Basic можна різними способами. По-перше, можна використовувати в якості блоків зв'язні списки. Це полегшує переміщення елементів між блоками в процесі роботи алгоритму.

Цей метод може бути більш складним, якщо елементи спочатку розташовані в масиві. У цьому випадку, необхідно переміщати елементи з масиву у зв'язний список і назад в масив після завершення сортування. Для створення зв'язного списку також потрібна додаткова пам'ять. Наступний код демонструє алгоритм блокового сортування із застосуванням зв'язкових списків:


Public Sub LinkBucketSort (ListTop As ListCell)

Dim count As Long

Dim min_value As Long

Dim max_value As Long

Dim Value As Long

Dim item As ListCell

Dim nxt As ListCell

Dim bucket () As New ListCell

Dim value_scale As Double

Dim bucket.num As Long

Dim i As Long


Set item = ListTop.NextCell

If item Is Nothing Then Exit Sub


'Підрахувати елементи і знайти значення min і max.

count = 1

min_value = item.Value

max_value = min_value

Set item = item.NextCell

Do While Not (item Is Nothing)

count = count + 1

Value = item.Value

If min_value> Value Then min_value = Value

If max_value <Value Then max_value = Value

Set item = item.NextCell

Loop


'Якщо min_value = max_value, значить, є єдине

'Значення, і список відсортований.

If min_value = max_value Then Exit Sub


'Якщо в списку не більше, ніж CutOff елементів,

'Завершити сортування процедурою LinkInsertionSort.

If count <= CutOff Then

LinkInsertionSort ListTop

Exit Sub

End If


'Створити порожні блоки.

ReDim bucket (1 To count)


value_scale = _

CDbl (count - 1) / _

CDbl (max_value - min_value)


'Розмістити елементи в блоках.

Set item = ListTop.NextCell

Do While Not (item Is Nothing)

Set nxt = item.NextCell

Value = item.Value

If Value = max_value Then

bucket_num = count

Else

bucket_num = _

Int ((Value - min_value) * _

value_scale) + 1

End If

Set item.NextCell = bucket (bucket_num). NextCell

Set bucket (bucket_num). NextCell = item

Set item = nxt

Loop


'Рекурсивна сортування блоків, що містять

'Більше одного елемента.

For i = 1 To count

If Not (bucket (i). NextCell Is Nothing) Then _

LinkBucketSort bucket (i)

Next i


'Об'єднати відсортовані списки.

Set ListTop.NextCell = bucket (count). NextCell

For i = count - 1 To 1 Step -1

Set item = bucket (i). NextCell

If Not (item Is Nothing) Then

Do While Not (item.NextCell Is Nothing)

Set item = item.NextCell

Loop

Set item.NextCell = ListTop.NextCell

Set ListTop.NextCell = bucket (i). NextCell

End If

Next i

End Sub


========= 257-258


Ця версія блокового сортування набагато швидше, ніж сортування вставкою з використанням зв'язкових списків. У тесті на комп'ютері з процесором Pentium з тактовою частотою 90 МГц сортування вставкою знадобилося 6,65 секунд для сортування 2000 елементів, блокова сортування зайняла 1,32 секунди. Для більш довгих списків різниця буде ще більше, так як продуктивність сортування вставкою порядку O (N 2).

Сортування комірками на основі масиву

Блокову сортування також можна реалізувати в масиві, використовуючи ідеї подібні до тих, які використовуються при сортуванні підрахунком. При кожному виклику алгоритму, спочатку підраховується число елементів, які відносяться до кожного блоку. Потім на основі цих даних розраховуються зсуву в тимчасовому масиві, які потім використовуються для правильного розташування елементів в масиві. Зрештою, блоки рекурсивно сортуються, і відсортовані дані переміщаються назад у вихідний масив.


Public Sub ArrayBucketSort (List () As Long, Scratch () As Long, _

min As Long, max As Long, NumBuckets As Long)

Dim counts () As Long

Dim offsets () As Long


Dim i As Long

Dim Value As Long

Dim min_value As Long

Dim max_value As Long

Dim value_scale As Double

Dim bucket_num As Long

Dim next_spot As Long

Dim num_in_bucket As Long


'Якщо в списку не більш ніж CutOff елементів,

'Закінчити сортування процедурою SelectionSort.

If max - min + 1 <CutOff Then

Selectionsort List (), min, max

Exit Sub

End If


'Знайти значення min і max.

min_value = List (min)

max_value = min_value

For i = min + 1 To max

Value = List (i)

If min_value> Value Then min_value = Value

If max_value <Value Then max_value = Value

Next i


'Якщо min_value = max_value, значить, є єдине

'Значення, і список відсортований.

If min_value = max_value Then Exit Sub


'Створити порожній масив з відліками блоків.

ReDim counts (l To NumBuckets)


value_scale = _

CDbl (NumBuckets - 1) / _

CDbl (max_value - min_value)


'Створити відліки блоків.

For i = min To max

If List (i) = max_value Then

bucket_num = NumBuckets

Else

bucket_num = _

Int ((List (i) - min_value) * _

value_scale) + 1

End If

counts (bucket_num) = counts (bucket_num) + 1

Next i


'Перетворити відліки в зміщення у масиві.

ReDim offsets (l To NumBuckets)

next_spot = min

For i = 1 To NumBuckets

offsets (i) = next_spot

next_spot = next_spot + counts (i)

Next i


'Розмістити значення у відповідних блоках.

For i = min To max

If List (i) = max_value Then

bucket_num = NumBuckets

Else

bucket_num = _

Int ((List (i) - min_value) * _

value_scale) + 1

End If

Scratch (offsets (bucket_num)) = List (i)

offsets (bucket_num) = offsets (bucket_num) + 1

Next i


'Рекурсивна сортування блоків, що містять

'Більше одного елемента.

next_spot = min

For i = 1 To NumBuckets

If counts (i)> 1 Then ArrayBucketSort _

Scratch (), List (), next_spot, _

next_spot + counts (i) - 1, counts (i)

next_spot = next_spot + counts (i)

Next i


'Скопіювати тимчасовий масив тому в початковий список.

For i = min To max

List (i) = Scratch (i)

Next i

End Sub


З за накладних витрат, які потрібні для роботи зі зв'язківцями списками, ця версія блокового сортування працює набагато швидше, ніж версія з використанням зв'язкових списків. Тим не менш, використовуючи методи роботи з псевдоуказателямі, описані у 2 розділі, можна поліпшити продуктивність версії з використанням зв'язкових списків, так що обидві версії стануть практично еквівалентними за швидкістю.

Нову версію також можна зробити ще швидше, використовуючи функцію API MemCopy для копіювання елементів з тимчасового масиву назад у вихідний список. Ця вдосконалену версію алгоритму демонструє програма FastSort.


=========== 259-261


Резюме

У таб. 9.4 наведено переваги та недоліки алгоритмів сортування, описаних у цій главі, з яких можна вивести кілька правил, які можуть допомогти вам вибрати алгоритм сортування.

Ці правила, викладені в наступному списку, і інформація в табл. 9.4 може допомогти вам підібрати алгоритм, який забезпечить максимальну продуктивність:

  • якщо вам потрібно швидко реалізувати алгоритм сортування, використовуйте швидку сортування, а потім при необхідності поміняйте алгоритм;

  • якщо більше 99 відсотків списку вже відсортовано, використовуйте бульбашкову сортування;

  • якщо список дуже малий (100 або менше елементів), використовуйте сортування вибором;

  • якщо значення перебувають у зв'язковому списку, використовуйте блокову сортування на основі зв'язного списку;

  • якщо елементи в списку - цілі числа, розкид значень яких невеликий (до декількох тисяч), використовуйте сортування підрахунком;

  • якщо значення лежать в широкому діапазоні і не є цілими числами, використовуйте блокову сортування на основі масиву;

  • якщо ви не можете витрачати додаткову пам'ять, яка потрібна для блокового сортування, використовуйте швидку сортування

Якщо ви знаєте структуру даних і різні алгоритми сортування, ви можете вибрати алгоритм, найбільш підходящий для ваших потреб.


@ Таблиця 9.4. Переваги та недоліки алгоритмів сортування


========= 263


Глава 10. Пошук

Після того, як список елементів відсортований, може знадобитися знайти певний елемент у списку. У цьому розділі описані деякі алгоритми для пошуку елементів у впорядкованих списках. Вона починається з короткого опису сортування методом повного перебору. Хоча цей алгоритм виконується не так швидко, як інші, метод повного перебору є дуже простим, що полегшує його реалізацію і налагодження. З за простоти цього методу, сортування повним перебором також виконується швидше інших алгоритмів для дуже маленьких списків.

Далі у розділі описаний двійковий пошук. При двійковому пошуку список багаторазово розбивається на частини, при цьому для великих списків такий пошук виконується набагато швидше, ніж повний перебір. Ув'язнена в цьому методі ідея досить проста, але реалізувати її досить складно.

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

Наприкінці глави обговорюються методи стежить пошуку. Застосування цього методу іноді зменшує час пошуку в кілька разів.

Приклади програм

Програма Search демонструє всі описані в главі алгоритми. Введіть значення елементів, які повинен містити список, і потім натисніть на кнопку Make List (Створити список), і програма створить список на основі масиву, в якому кожен елемент більше попереднього на число від 0 до 5. Програма виводить значення найбільшого елементу в списку, щоб ви представляли діапазон значень елементів.

Після створення списку виберіть алгоритми, які ви хочете використовувати, встановивши відповідні прапорці. Потім введіть значення, яке ви хочете знайти та натисніть на кнопку Search (Пошук), і програма виконає пошук елемента за допомогою обраного вами алгоритму. Оскільки список містить не всі можливі елементи в заданому діапазоні значень, то вам може знадобитися ввести декілька різних значень, перш ніж одне з них знайдеться в списку.

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


======= 265


На рис. 10.1 показано вікно програми Search після пошуку елемента зі значенням 250.000. Цей елемент перебував на позиції 99.802 в списку з 100.000 елементів. Щоб знайти цей елемент, треба було перевірити 99.802 елемента при використанні алгоритму повного перебору, 16 елементів - при використанні двійкового пошуку і всього 3 - при виконанні інтерполяційного пошуку.

Пошук методом повного перебору

При виконанні лінійного (linear) пошуку або пошуку методом повного перебору (exhaustive search), пошук ведеться з початку списку, і елементи перебираються послідовно, поки серед них не буде знайдений шуканий.


Public Function LinearSearch (target As Long) As Long

Dim i As Long


For i = 1 To NumItems

If List (i)> = target Then Exit For

Next i


If i> NumItems Then

Search = 0 'Елемент не знайдений.

Else

Search = i 'Елемент знайдений.

End If

End Function


Так як цей алгоритм перевіряє елементи послідовно, то він знаходить елементи на початку списку швидше, ніж елементи, розташовані в кінці. Найгірший випадок для цього алгоритму виникає, якщо елемент знаходиться в кінці списку чи взагалі не присутня в ньому. У цих випадках, алгоритм перевіряє всі елементи в списку, тому час його виконання складність у найгіршому випадку порядку O (N).


@ Рис. 10.1. Програма Search


======== 266


Якщо елемент знаходиться у списку, то в середньому алгоритм перевіряє N / 2 елементів до того, як виявить шуканий. Таким чином, в усередненому випадку час виконання алгоритму також порядку O (N).

Хоча алгоритми, які виконуються за час порядку O (N), не є дуже швидкими, цей алгоритм досить простий, щоб давати на практиці непогані результати. Для невеликих списків цей алгоритм має прийнятну продуктивність.

Пошук в упорядкованих списках

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

Наприклад, припустимо, що ми шукаємо значення 12 і дійшли до значення 17. При цьому ми вже пройшли ту ділянку списку, в якому міг би знаходиться елемент зі значенням 12, значить, елемент 12 в списку відсутня. Наступний код демонструє доопрацьовану версію алгоритму пошуку повним перебором:


Public Function LinearSearch (target As Long) As Long

Dim i As Long


NumSearches = 0


For i = 1 To NumItems

NumSearches = NumSearches + 1

If List (i)> = target Then Exit For

Next i


If i> NumItems Then

LinearSearch = 0 'Елемент не знайдений.

ElseIf List (i) <> target Then

LinearSearch = 0 'Елемент не знайдений.

Else

LinearSearch = i 'Елемент знайдений.

End If

End Function


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

Якщо шуканий елемент розташований випадково між найбільшим і найменшим елементами у списку, то в середньому алгоритмом знадобиться порядку O (N) кроків, щоб визначити, що шуканий елемент відсутній у списку. Час виконання при цьому має той самий порядок, але на практиці його продуктивність буде трохи вище. Програма Search використовує цю версію алгоритму.


====== 267


Пошук в зв'язкових списках

Пошук методом повного перебору - це єдиний спосіб пошуку в зв'язкових списках. Так як доступ до елементів можливий тільки за допомогою покажчиків NextCell на наступний елемент, то необхідно перевірити по черзі всі елементи з початку списку, щоб знайти шуканий.

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


Public Function LListSearch (target As Long) As SearchCell

Dim cell As SearchCell


NumSearches = 0

Set cell = ListTop.NextCell

Do While Not (cell Is Nothing)

NumSearches = NumSearches + 1


If cell.Value> = target Then Exit Do

Set cell = cell.NextCell

Loop

If Not (cell Is Nothing) Then

If cell.Value = target Then

Set LListSearch = cell 'Елемент знайдений.

End If

End If

End Function


Програма Search використовує цей алгоритм для пошуку елементів у зв'язковому списку. Цей алгоритм виконується трохи повільніше, ніж алгоритм повного перебору в масиві з за додатковими накладними витратами, які пов'язані з управлінням покажчиками на об'єкти. Зауважте, що програма Search будує зв'язні списки, тільки якщо список містить не більше 10.000 елементів.

Щоб алгоритм виконувався трохи швидше, в нього можна внести ще одну зміну. Якщо зберігати покажчик на кінець списку, то можна додати в кінець списку клітинку, яка буде містити шуканий елемент. Цей елемент називається сигнальної міткою (sentinel), і служить для тих же цілей, що і сигнальні мітки, описані в 2 чолі. Це дозволяє обробляти особливий випадок кінця списку так само, як і всі інші.

У цьому випадку, додавання мітки в кінець списку гарантує, що в кінці кінців шуканий елемент буде знайдений. При цьому програма не може вийти за кінець списку, і немає необхідності перевіряти умова Not (cell Is Nothing) у кожному циклі While.


Public Function SentinelSearch (target As Long) As SearchCell

Dim cell As SearchCell

Dim sentinel As New SearchCell


NumSearches = 0


'Встановити сигнальну позначку.

sentinel.Value = target

Set ListBottom.NextCell = sentinel

'Знайти шуканий елемент.

Set cell = ListTop.NextCell

Do While cell.Value <target

NumSearches = NumSearches + 1

Set cell = cell.NextCell

Loop


'Визначити чи знайдений шуканий елемент.

If Not ((cell Is sentinel) Or _

(Cell.Value <> target)) _

Then

Set SentinelSearch = cell 'Елемент знайдений.

End If


'Видалити сигнальну позначку.

Set ListBottom.NextCell = Nothing

End Function


Хоча може здатися, що це зміна незначно, перевірка Not (cell Is Nothing) виконується в циклі, який викликається дуже часто. Для великих списків цей цикл викликається безліч разів, і виграш часу підсумовується. У Visual Basic, цей версія алгоритму пошуку в зв'язкових списках виконується на 20 відсотків швидше, ніж попередня версія. У програмі Search наведені обидві версії цього алгоритму, і ви можете порівняти їх.

Деякі алгоритми використовують потоки для прискорення пошуку в зв'язкових списках. Наприклад, за допомогою покажчиків в осередках списку можна організувати список у вигляді двійкового дерева. Пошук елемента з використанням цього дерева займе час порядку O (log (N)), якщо дерево збалансовано. Такі структури даних вже не є просто списками, тому ми не обговорюємо їх у цьому розділі. Щоб більше дізнатися про дерева, зверніться до 6 і 7 главам

Двійковий пошук

Як вже згадувалося в попередніх розділах, пошук повним перебором виконується дуже швидко для невеликих списків, але для великих списків набагато швидше виконується двійковий пошук. Алгоритм двійкового пошуку (binary search) порівнює елемент у середині списку з шуканим. Якщо шуканий елемент менше, ніж знайдений, то алгоритм продовжує пошук у першій половині списку, якщо більше - у правій половині. На рис. 10.2 цей процес зображено графічно.

Хоча за своєю природою цей алгоритм є рекурсивним, його досить просто записати і без застосування рекурсії. Так як цей алгоритм простий для розуміння в будь-якому варіанті (з рекурсією або без), то ми наводимо тут його нерекурсивний версію, яка містить менше викликів функцій.

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

Алгоритм використовує дві змінні, min і max, в яких знаходяться мінімальний і максимальний індекси осередків масиву, які можуть містити шуканий елемент. Під час виконання алгоритму, індекс шуканої осередку завжди буде лежати між min і max. Іншими словами, min <= target index <= max.


Sub Slow ()

Dim I As Integer

Dim J As Integer

Dim K As Integer

For I = 1 To N

For J = 1 To N

For K = 1 To N

'Виконати будь-які дії.

Next K

Next J

Next I

End Sub


Sub Fast ()

Dim I As Integer

Dim J As Integer

Dim K As Integer

For I = 1 To N

For J = 1 To N

Slow "Виклик процедури Slow.

Next J

Next I

End Sub


Sub MainProgram ()

Fast

End Sub


З іншого боку, якщо процедури, незалежно викликаються з основної програми, їх обчислювальна складність підсумовується. У цьому випадку повна складність буде дорівнює O (N 3) + O (N 2) = O (N 3). Таку складність, наприклад, буде мати наступний фрагмент коду:


Sub Slow ()

Dim I As Integer

Dim J As Integer

Dim K As Integer


For I = 1 To N

For J = 1 To N

For K = 1 To N

'Виконати будь-які дії.

Next K

Next J

Next I

End Sub


Sub Fast ()

Dim I As Integer

Dim J As Integer

For I = 1 To N

For J = 1 To N

'Виконати будь-які дії.

Next J

Next I

End Sub


Sub MainProgram ()

Slow

Fast

End Sub


============== 5


Складність рекурсивних алгоритмів

Рекурсивними процедурами (recursive procedure) називаються процедури, що викликають самі себе. У багатьох рекурсивних алгоритмах саме ступінь вкладеності рекурсії визначає складність алгоритму, при цьому не завжди легко оцінити порядок складності. Рекурсивна процедура може виглядати простий, але при цьому вносити великий внесок у складність програми, багаторазово викликаючи саму себе.

Наступний фрагмент коду містить підпрограму всього з двох операторів. Тим не менш, для заданого N підпрограма виконується N разів, таким чином, обчислювальна складність фрагмента порядку O (N).


Sub CountDown (N As Integer)

If N <= 0 Then Exit Sub

CountDown N - 1

End Sub


=========== 6


Багаторазова рекурсія

Рекурсивний алгоритм, що викликає себе кілька разів, є прикладом багаторазової рекурсії (multiple recursion). Процедури з множинною рекурсією складніше аналізувати, ніж просто рекурсивні алгоритми, і вони можуть давати більший внесок у загальну складність алгоритму.

Нижченаведена підпрограма схожа на попередню підпрограму CountDown, тільки вона викликає саму себе двічі:


Sub DoubleCountDown (N As Integer)

If N <= 0 Then Exit Sub

DoubleCountDown N - 1

DoubleCountDown N - 1

End Sub


Можна було б припустити, що час виконання цієї процедури буде в два рази більше, ніж для підпрограми CountDown, і оцінити її складність порядку 2 * O (N) = O (N). Насправді ситуація трохи складніше.

Якщо T (N) - кількість разів, що виконується процедура DoubleCountDown з параметром N, то легко помітити, що T (0) = 1. Якщо викликати процедуру з параметром N рівним 0, то вона просто закінчить свою роботу після першого кроку.

Для великих значень N процедура викликає себе двічі з параметром, рівним N-1, виконуючи 1 +2 * T (N-1) раз. У табл. 1.1 наведені деякі значення функції T (0) = 1 і T (N) = 1 +2 * T (N-1). Якщо звернути увагу на ці значення, можна побачити, що T (N) = 2 (N +1) -1, що дає оцінку складності процедури порядку O (2 N). Хоча процедури CountDown і DoubleCountDown і схожі, друга процедура вимагає виконання набагато більшого числа кроків.


@ Таблиця 1.1. Значення функції часу виконання для підпрограми DoubleCountDown


Непряма рекурсія

Процедура також може викликати іншу процедуру, яка в свою чергу викликає першу. Такі процедури іноді навіть складніше аналізувати, ніж процедури з множинною рекурсією. Алгоритм обчислення кривої Серпінського, який обговорюється в 5 главі, включає в себе чотири процедури, які використовують як множинну, так і непряму рекурсію. Кожна з цих процедур викликає себе й інші три процедури до чотирьох разів. Після досить складних підрахунків можна показати, що цей алгоритм має складність порядку O (4 N).

Вимоги рекурсивних алгоритмів до обсягу пам'яті

Для деяких рекурсивних алгоритмів важливий обсяг доступної пам'яті. Можна легко написати рекурсивний алгоритм, який буде запитувати


============ 7


невеликий обсяг пам'яті при кожному своєму виклику. Обсяг використаної пам'яті може збільшуватися в процесі послідовних рекурсивних викликів.

Тому для рекурсивних алгоритмів необхідно хоча б приблизно оцінювати вимоги до обсягу пам'яті, щоб переконатися, що програма не вичерпає при виконанні всю доступну пам'ять.

Наведена нижче підпрограма запитує пам'ять при кожному виклику. Після 100 або 200 рекурсивних викликів, процедура займе всю вільну пам'ять, і програма аварійно зупиниться з помилкою «Out of Memory».


Sub GobbleMemory (N As Integer)

Dim Array () As Integer


ReDim Array (1 To 32000)

GobbleMemory N + 1

End Sub


Навіть якщо всередині процедури пам'ять не запитується, система виділяє пам'ять з системного стека (system stack) для збереження параметрів при кожному виклику процедури. Після повернення з процедури пам'ять з стека звільняється для подальшого використання.

Якщо в підпрограмі зустрічається довга послідовність рекурсивних викликів, програма може вичерпати стек, навіть якщо виділена програмі пам'ять ще не вся використана. Якщо запустити на виконання наступну підпрограму, вона швидко вичерпає всю вільну стекову пам'ять і програма аварійно припинить роботу з повідомленням про помилку "Out of stack Space». Після цього ви зможете дізнатися значення змінної Count, щоб дізнатися, скільки разів підпрограма викликала себе перед тим, як вичерпати стек.


Sub UseStack ()

Static Count As Integer


Count = Count + 1

UseStack

End Sub


Визначення локальних змінних усередині підпрограми також може займати пам'ять з стека. Якщо змінити підпрограму UseStack з попереднього прикладу так, щоб вона визначала три змінних при кожному виклику, програма вичерпає стекової простір ще швидше:


Sub UseStack ()

Static Count As Integer

Dim I As Variant

Dim J As Variant

Dim K As Variant


Count = Count + 1

UseStack

End Sub


У 5 чолі рекурсивні алгоритми обговорюються більш детально.


============== 8


Найгірший і усереднений випадок

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


Function LocateItem (target As Integer) As Integer

For I = 1 To N

If Value (I) = target Then Exit For

Next I

LocateItem = I

End Sub


Якщо шуканий елемент знаходиться у кінці списку, доведеться перебрати все N елементів для того, щоб його знайти. Це займе N кроків, значить складність алгоритму порядку O (N). У цьому, так званому найгіршому випадку (worst case) час виконання алгоритму буде найбільшим.

З іншого боку, якщо шукане число на початок списку, алгоритм завершить роботу практично відразу, зробивши всього декілька ітерацій. Це так званий найкращий випадок (best case) зі складністю порядку O (1). Зазвичай і найкращий, і найгірший випадки зустрічаються відносно рідко, і інтерес представляє оцінка усередненого або очікуваного (expected case) поведінки.

Якщо спочатку числа у списку розподілені випадково, шуканий елемент може опинитися в будь-якому місці списку. У середньому буде потрібно перевірити N / 2 елементів для того, щоб його знайти. Значить, складність цього алгоритму в усередненому випадку порядку O (N / 2), або O (N), якщо прибрати постійний множник.

Для деяких алгоритмів порядок складності для найгіршого та найкращого варіантів розрізняється. Наприклад, складність алгоритму швидкого сортування з 9 глави в найгіршому випадку порядку O (N 2), але в середньому його складність порядку O (N * log (N)), що набагато швидше. Іноді алгоритми типу швидкого сортування бувають дуже довгими, щоб найгірший випадок досягався вкрай рідко.

Часто зустрічаються функції оцінки порядку складності

У табл. 1.2 наведені деякі функції, які зазвичай зустрічаються при оцінці складності алгоритмів. Функції наведені у порядку зростання обчислювальної складності зверху вниз. Це означає, що алгоритми зі складністю порядку функцій, розташованих вгорі таблиці, будуть виконуватися швидше, ніж ті, складність яких визначається функціями з нижньої частини таблиці.


============== 9


@ Таблиця 1.2. Часто зустрічаються функції оцінки порядку складності


Складність алгоритму, що визначається рівнянням, яке являє собою суму функцій з таблиці, буде зводитися до складності тієї з функцій, яка розташована в таблиці нижче. Наприклад, O (log (N) + N 2) - це те ж саме, що і O (N 2).

Зазвичай алгоритми зі складністю порядку N * log (N) і менш складних функцій виконуються дуже швидко. Алгоритми порядку N C при малих C, наприклад N 2 виконуються досить швидко. Обчислювальна ж складність алгоритмів, порядок яких визначається функціями C N або N! дуже велика і ці алгоритми придатні тільки для вирішення завдань з невеликим N.

В якості прикладу в табл. 1.3 показано, як довго комп'ютер, що виконує мільйон інструкцій у секунду, буде виконувати деякі повільні алгоритми. З таблиці видно, що при складності порядку O (C N) можуть бути вирішені тільки невеликі завдання, і ще менше параметр N може бути для завдань зі складністю порядку O (N!). Для вирішення завдання порядку O (N!) при N = 24 знадобилося б час, більше, ніж час існування всесвіту.

Логарифми

Перед тим, як продовжити далі, слід зупинитися на логарифмах, так як вони грають важливу роль в різних алгоритмах. Логарифм числа N за основою B це ступінь P, в яку треба звести підстава, щоб отримати N, тобто B P = N. Наприклад, якщо 2 3 = 8, то відповідно log 2 (8) = 3.


================== 10


@ Таблиця 1.3. Час виконання складних алгоритмів


Можна навести логарифм до іншого підстави за допомогою співвідношення log B (N) = log C (N) / log C (B). Наприклад, щоб обчислити логарифм числа за основою 10, знаючи його логарифм за основою 2, можна скористатися формулою log 10 (N) = log 2 (N) / log 2 (10). При цьому log 2 (10) - це таблична константа, приблизно рівна 3,32. Так як постійні множники при оцінці складності алгоритму можна опустити, то O (log 2 (N)) - це ж саме, що і O (log 10 (N)) або O (log B (N)) для будь-якого B. Оскільки підстава логарифма не має значення, часто просто пишуть, що складність алгоритму порядку O (log (N)).

У програмуванні часто зустрічаються логарифми по підставі 2, що обумовлено застосовуваної в комп'ютерах двійковою системою числення. Тому ми для спрощення виразів будемо скрізь писати log (N), маючи на увазі під цим log 2 (N). Якщо використовується інша підстава алгоритму, це буде позначено особливо.

Реальні умови - наскільки швидко?

Хоча при дослідженні складності алгоритму зазвичай корисно відкинути малі члени рівняння і постійні множники, іноді їх все таки необхідно враховувати, особливо якщо розмірність даних завдання N мала, а постійні множники досить великі.

Припустимо, ми розглядаємо два алгоритми вирішення одного завдання. Один виконується за час порядку O (N), а інший - порядку O (N 2). Для великих N перший алгоритм, ймовірно, буде працювати швидше.

Тим не менш, якщо взяти конкретні функції оцінки часу виконання для кожної з двох алгоритмів, наприклад, для першого f (N) = 30 * N +7000, а для другого f (N) = N 2, то в цьому випадку при N менше 100 другий алгоритм буде виконуватися швидше. Тому, якщо відомо, що розмірність даних завдання не буде перевищувати 100, можливо буде доцільніше застосувати другий алгоритм.

З іншого боку, час виконання різних інструкцій може сильно відрізнятися. Якщо перший алгоритм використовує швидкі операції з пам'яттю, а другий використовує повільне звернення до диска, то перший алгоритм буде швидше у всіх випадках.


================== 11


Інші фактори можуть також ускладнити проблему вибору оптимального алгоритму. Наприклад, перший алгоритм може вимагати більшого обсягу пам'яті, ніж встановлено на комп'ютері. Реалізація другого алгоритму, в свою чергу, може вимагати набагато більше часу, якщо цей алгоритм набагато складніше, а його налагодження може перетворитися на справжній кошмар. Іноді подібні практичні міркування можуть зробити теоретичний аналіз складності алгоритму майже безглуздим.

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

Іноді тестування алгоритмів є найбільш підходящим способом визначити найкращий алгоритм. При такому тестуванні важливо, щоб тестові дані були максимально наближені до реальних даних. Якщо тестові дані сильно відрізняються від реальних, результати тестування можуть сильно відрізнятися від реальних.

Звернення до файлу підкачки

Важливим чинником при роботі в реальних умовах є частота звернення до файлу підкачки (page file). Операційна система Windows відводить частину дискового простору під віртуальну пам'ять (virtual memory). Коли вичерпується оперативна пам'ять, Windows скидає частина її вмісту на диск. Звільнена пам'ять надається програмі. Цей процес називається підкачкою, оскільки сторінки, скинуті на диск, можуть бути завантажений системою назад в пам'ять при зверненні до них.

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

Наведена в числі прикладів програма Pager запитує все більше і більше пам'яті під створювані масиви до тих пір, поки програма не почне звертатися до файлу підкачки. Введіть кількість пам'яті в мегабайтах, яке програма повинна запитати, і натисніть кнопку Page (Підкачка). Якщо ввести невелике значення, наприклад 1 або 2 Мбайт, програма створить масив в оперативній пам'яті, і буде виконуватися швидко.

Якщо ж ви введете значення, близьке до обсягу оперативної пам'яті вашого комп'ютера, то програма почне використовувати файл підкачки. Цілком ймовірно, що вона буде при цьому звертатися до диска постійно. Ви також помітите, що програма виконується набагато повільніше. Збільшення розміру масиву на 10 відсотків може призвести до 100 відсоткового збільшення часу виконання.

Програма Pager може використовувати пам'ять одним із двох способів. Якщо ви натиснете кнопку Page, програма почне послідовно звертатися до елементів масиву. У міру переходу від однієї частини масиву до іншої, системі може знадобитися довантажувати їх з диска. Після того, як частина масиву опинилася в пам'яті, програма може продовжити роботу з нею.


============ 12


Якщо ж ви натиснете на кнопку Thrash (Пробуксовування), програма буде випадково звертатися до різних ділянок пам'яті. При цьому вірогідність того, що потрібна сторінка знаходиться в цей момент на диску, набагато зростає. Це надмірне звернення до файлу підкачки називається пробуксовкою пам'яті (thrashing). У табл. 1.4 наведено час виконання програми Pager на комп'ютері з процесором Pentium з тактовою частотою 90 МГц і 24 Мбайт оперативної пам'яті. Залежно від конфігурації вашого комп'ютера, швидкості роботи з диском, кількості встановленої оперативної пам'яті, а також наявності інших запущених паралельно додатків час виконання програми може сильно відрізнятися.

Спочатку час виконання тесту зростає майже пропорційно розміру використаної пам'яті. Коли починається звернення до файлу підкачки, швидкість роботи програми різко падає. Зауважте, що до цього тести зі зверненням до файлу підкачки і пробуксовкою ведуть себе практично однаково, тобто коли весь масив знаходиться в оперативній пам'яті, послідовне і випадкове звертання до елементів масиву займає однаковий час. При підкачування елементів масиву з диска випадковий доступ до пам'яті набагато менш ефективний.

Для зменшення числа звернень до файлу підкачки є кілька способів. Основний прийом - економне витрачання пам'яті. При цьому треба пам'ятати, що програма зазвичай не може зайняти всю фізичну пам'ять, тому що частина її займає система та інші програми. Комп'ютер, на якому були отримані результати, наведені в табл. 1.4, починав інтенсивно звертатися до диска, коли програма посідала 20 Мбайт з 24 Мбайт фізичної пам'яті.

Іноді можна написати код так, що програма буде звертатися до блоків пам'яті послідовно. Алгоритм сортування злиттям, описаний у 9 розділі, маніпулює великими блоками даних. Ці блоки сортуються, а потім зливаються разом. Упорядкована робота з пам'яттю зменшує число звернень до диска.


@ Таблиця 1.4. Час виконання програми Pager в секундах


========== 269


@ Рис. 10.2. Двійковий пошук елемента зі значенням 44


Під час кожного проходу, алгоритм виконує присвоєння middle = (min + max) / 2 і перевіряє клітинку, індекс якої дорівнює middle. Якщо її значення дорівнює шуканого, то мета знайдена і алгоритм завершує свою роботу.

Якщо значення шуканого елемента менше, ніж значення середнього, то алгоритм встановлює значення змінної max рівним middle - 1 і продовжує пошук. Так як тепер індекси елементів, які можуть містити шуканий елемент, знаходяться в діапазоні від min до middle - 1, то програма при цьому виконує пошук у першій половині списку.

Врешті-решт, програма або знайде шуканий елемент, або наступить момент, коли значення змінної min стане більше, ніж значення max. Оскільки індекс шуканого елемента повинен знаходитися між мінімальним і максимальним можливими індексами, це означає, що шуканий елемент відсутній у списку.

Наступний код демонструє виконання двійкового пошуку у програмі Search:


Public Function BinarySearch (target As Long) As Long

Dim min As Long

Dim max As Long

Dim middle As Long


NumSearches = 0


'Під час пошуку індекс шуканого елемента буде знаходитися

'Між Min і Max: Min <= target index <= Max

min = 1

max = NumItems

Do While min <= max

NumSearches = NumSearches + 1

middle = (max + min) / 2

If target = List (middle) Then 'Ми знайшли шуканий елемент!

BinarySearch = middle

Exit Function

ElseIf target <List (middle) Then 'Пошук в лівій половині.

max = middle - 1

Else 'Пошук в правій половині.

min = middle + 1

End If

Loop

'Якщо ми опинилися тут, то шуканого елемента немає у списку.

BinarySearch = 0

End Function


На кожному кроці число елементів, які ще можуть мати дані значення, зменшується вдвічі. Для списку розміру N, алгоритмом може знадобитися максимум O (log (N)) кроків, щоб знайти будь-який елемент або визначити, що його немає в списку. Це набагато швидше, ніж у випадку застосування алгоритму повного перебору. Повний перебір списку з мільйона елементів зажадав би в середньому 500.000 кроків. Алгоритму двійкового пошуку потрібно не більше, ніж log (1.000.000) або 20 кроків.

Інтерполяційний пошук

Двійковий пошук забезпечує значне збільшення швидкості пошуку в порівнянні з повним перебором, тому що він виключає великі частини списку, не перевіряючи при цьому значення виключаються елементів. Якщо, крім того, відомо, що значення елементів розподілені досить рівномірно, то можна виключати на кожному кроці ще більше елементів, використовуючи інтерполяційний пошук (interpolation search).

Інтерполяцією називається процес передбачення невідомих значень на основі наявних. У даному випадку, індекси відомих значень у списку використовуються для визначення можливого положення шуканого елемента в списку.

Наприклад, припустимо, що мається той же самий список значень, показаний на рис. 10.2. Цей список містить 20 елементів зі значеннями між 1 і 70. Припустимо тепер, що потрібно знайти елемент у списку, що має значення 44. Значення 44 становить 64 відсотки відстані між 1 і 70 на шкалі чисел. Якщо вважати, що значення елементів розподілені рівномірно, то можна припустити, що шуканий елемент розташований приблизно в точці, яка становить 64 відсотка від розміру списку, і займає позицію 13.

Якщо позиція, обрана за допомогою інтерполяції, виявляється неправильною, то алгоритм порівнює дані значення зі значенням елемента в обраній позиції. Якщо шукане значення менше, то пошук продовжується в першій частині списку, якщо більше - у другій частині. На рис. 10.3 графічно зображений інтерполяційний пошук.

При двійковому пошуку список послідовно розбивається посередині на дві частини. Інтерполяційний пошук кожен раз розбиває список, намагаючись знайти найближчий до шуканого елемент у списку, при цьому точка розбиття визначається наступним кодом:


middle = min + (target - List (min)) * _


((Max - min) / (List (max) - List (min)))


======== 270-271


@ Рис. 10.3 Інтерполяційний пошук значення 44


Цей оператор поміщає значення middle між min і max у такому ж співвідношенні, в якому шукане значення знаходиться між List (min) і List (max). Якщо шуканий елемент знаходиться поруч з List (min), то різниця target - List (min) майже дорівнює нулю. Тоді все співвідношення цілком виглядає майже як middle = min + 0, тому значення змінної middle майже дорівнює min. Сенс цього полягає в тому, що якщо індекс елемента майже дорівнює min, то його значення майже дорівнює List (min).

Аналогічно, якщо шуканий елемент знаходиться поруч з List (max), то різниця target - List (min) майже дорівнює різниці List (max) - List (min). Їхня особиста майже дорівнює одиниці, і співвідношення виглядає майже як middle = min + (max - min), або middle = max, якщо спростити вираз. Сенс цього співвідношення полягає в тому, що якщо значення елемента близько до List (max), то його індекс майже дорівнює max.

Після того, як програма обчислить значення middle, вона порівнює значення елемента в цій позиції з шуканим так само, як і в алгоритмі двійкового пошуку. Якщо ці значення збігаються, то шуканий елемент знайдено і процес закінчений. Якщо значення шуканого елемента менше, ніж значення знайденого, то програма встановлює значення max рівним middle - 1 і продовжує пошук елементів списку з меншими значеннями. Якщо значення шуканого елемента більше, ніж значення знайденого, то програма встановлює значення min рівним middle + 1 і продовжує пошук елементів списку з великими значеннями.

Зауважте, що в знаменнику співвідношення, яке знаходить нове значення змінної middle, знаходиться різницю (List (max) - Lsit (min)). Якщо значення List (max) і List (min) однакові, то відбудеться поділ на нуль і програма аварійно завершить роботу. Таке може статися, якщо два елементи в списку мають однакові значення. Так як алгоритм підтримує співвідношення min <= target index <= max, то ця проблема може також виникнути, якщо min буде рости, а max зменшуватися до тих пір, поки їх значення не зрівняються.

Щоб справитися з цією проблемою, програма перед виконанням операції ділення перевіряє, чи не чи рівні List (max) і List (min). Якщо це так, значить залишилося перевірити тільки одне значення. При цьому програма просто перевіряє, чи збігається воно з шуканим.

Ще одна тонкість полягає в тому, що розрахований значення middle не завжди лежить між min і max. У найпростішому випадку це може бути так, якщо значення шуканого елемента виходить за межі діапазону значень елементів у списку. Припустимо, що ми намагаємося знайти значення 300 в списку з елементів 100, 150 і 200. На першому кроці обчислень min = 1 і max = 3. Тоді middle = 1 + (300 - List (1)) * (3 - 1) / (List (3) - List (1)) = 1 + (300 - 100) * 2 / (200 - 100) = 5. Індекс 5 не тільки не перебуває в діапазоні між min і max, він також виходить за межі масиву. Якщо програма спробує звернутися до елементу масиву List (5), то вона аварійно завершить роботу з повідомленням про помилку "Subscript out of range".


=========== 272


Схожа проблема виникає, якщо значення елементів розподілені між min і max дуже нерівномірно. Припустимо, що ми хочемо знайти значення 100 у списку 0, 1, 2, 199, 200. При першому обчисленні значення змінної middle, ми отримаємо в програмі middle = 1 + (100 - 0) * (5 - 1) / (200 - 0) = 3. Потім програма порівнює значення елемента List (3) з шуканим значенням 100. Так як List (3) = 2, що менше 100, вона задає min = middle + 1, тобто min = 4.

При наступному обчислення значення змінної middle, програма знаходить middle = 4 + (100 - 199) * (5 - 4) / (200 - 199) = -98. Значення -98 не потрапляє в діапазон min <= target index <= max і також далеко виходить за межі масиву.

Якщо розглянути процес обчислення змінної middle, то можна побачити, що існують два варіанти, за яких нове значення може виявитися менше, ніж min або більше, ніж max. Спочатку припустимо, що middle менше, ніж min.


min + (target - List (min)) * ((max - min) / (List (max) - List (min))) <min


Після вирахування min з обох частин рівняння, отримаємо:


(Target - List (min)) * ((max - min) / (List (max) - List (min))) <0


Так як max> = min, то різницю (max - min) повинна бути більше нуля. Так як List (max)> = List (min), то різниця (List (max) - List (min)) також повинна бути більше нуля. Тоді все значення може бути менше нуля, тільки якщо (target - List (min)) менше нуля. Це означає, що шукане значення менше, ніж значення елемента List (min). У цьому випадку, шуканий елемент не може перебувати у списку, так як всі елементи списку зі значенням меншим, ніж List (min) вже були виключені.

Тепер припустимо, що middle більше, ніж max.


min + (target - List (min)) * ((max - min) / (List (max) - List (min)))> max


Після вирахування min з обох частин рівняння, отримаємо:


(Target - List (min)) * ((max - min) / (List (max) - List (min)))> 0


Множення обох частин на (List (max) - List (min)) / (max - min) призводить співвідношення до вигляду:


target - List (min)> List (max) - List (min)


І, нарешті, додавши до обох частин List (min), отримаємо:


target> List (max)


Це означає, що шукане значення більше, ніж значення елемента List (max). У цьому випадку, шукають значення не може перебувати у списку, так як всі елементи списку зі значеннями більшими, ніж List (max) вже були виключені.


========== 273


Враховуючи всі ці результати, отримуємо, що нове значення змінної middle може вийти з діапазону між min і max тільки в тому випадку, якщо шукане значення виходить за межі діапазону від List (min) до List (max). Алгоритм може використовувати цей факт при обчисленні нового значення змінної middle. Він спочатку перевіряє, чи знаходиться нове значення між min і max. Якщо ні, то шуканого елемента немає у списку і робота алгоритму завершена.

Наступний код демонструє реалізацію інтерполяційного пошуку в програмі Search:


Public Function InterpSearch (target As Long) As Long

Dim min As Long

Dim max As Long

Dim middle As Long


min = 1

max = NumItems

Do While min <= max

'Уникаємо ділення на нуль.

If List (min) = List (max) Then

'Це шуканий елемент (якщо він є в списку).

If List (min) = target Then

InterpSearch = min

Else

InterpSearch = 0

End If

Exit Function

End If


'Знайти точку розбиття списку.

middle = min + (target - List (min)) * _

((Max - min) / (List (max) - List (min)))


'Перевірити, чи не вийшли ми за кордону.

If middle <min Or middle> max Then

'Шуканого елемента немає у списку.

InterpSearch = 0

Exit Function

End If


NumSearches = NumSearches + 1

If target = List (middle) Then 'Бажаємий елемент знайдено.

InterpSearch = middle

Exit Function

ElseIf target <List (middle) Then 'Пошук в лівій частині.

max = middle - 1

Else 'Пошук в правій частині.

min = middle + 1

End If

Loop


'Якщо ми дійшли до цієї точки, то елемента немає у списку.

InterpSearch = 0

End Function


Двійковий пошук виконується дуже швидко, а інтерполяційний ще швидше. В одному з тестів, двійковий пошук зажадав у 7 разів більше часу для пошуку значень у списку з 100.000 елементів. Ця різниця могла б бути ще більше, якщо б дані знаходилися на диску або якому або іншому повільному пристрої. Хоча при інтерполяційного пошуку на обчислення йде більше часу, ніж у випадку двійкового пошуку, за рахунок меншого числа звернень до диску ми заощадили б набагато більше часу.

Строкові дані

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

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

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

Якщо рядки занадто довгі, і їх не можна закодувати навіть числами у форматі double, то все ще можна використовувати для інтерполяції значення рядків. Спочатку знайдемо перший відрізняється символ для рядків List (min) і List (max). Потім закодуємо його і наступні два символи у кожному рядку за допомогою методів з 9 глави. Потім можна використовувати ці значення для виконання інтерполяційного пошуку.

Наприклад, припустимо, що ми шукаємо рядок TARGET в списку TABULATE, TANTRUM, TARGET, TATTERED, TAXATION. Якщо min = 1 і max = 5, то перевіряються значення TABULATE і THEATER. Ці рядки відрізняються у другому символі, тому потрібно розглядати три символи, що починаються з другого. Це будуть символи ABU для List (1), AXA для List (5) і ARG для шуканої рядка.

Ці значення кодуються числами 804, 1378 і 1222 відповідно. Підставляючи ці значення у формулу для змінної middle, отримаємо:


middle = min + (target - List (min)) * ((max - min) / (List (max) - List (min)))

= 1 + (1222 - 804) * ((5 - 1) / (1378 - 804))

= 2,91


========= 275


Це приблизно дорівнює 3, тому наступне значення змінної middle дорівнює 3. Це положення рядка TARGET в списку, тому пошук при цьому закінчується.

Стежить пошук

Щоб почати двійковий стежить пошук (binary hunt and search), порівняємо дані значення з попереднього пошуку з новим шуканим значенням. Якщо нове значення менше, почнемо стеження вліво, якщо більше - вправо.

Для виконання стеження вліво, встановимо значення змінних min і max рівними індексом, отриманого під час попереднього пошуку. Потім зменшимо значення min на одиницю і порівняємо дані значення зі значенням елемента List (min). Якщо шукане значення менше, ніж значення List (min), встановимо max = min і min = min -2, і зробимо ще одну перевірку. Якщо шукають значення все ще менше, встановимо max = min і min = min -4, якщо це не допоможе, встановимо max = min і min = min -8 і так далі. Продовжимо встановлювати значення змінної max рівним значенню змінної min і віднімати чергові ступеня двійки із значення змінної min до тих пір, поки не знайдеться значення min, для якого значення елемента List (min) будемо менше шуканого значення.

Необхідно стежити за тим, щоб не вийти за межі масиву, якщо min менше, ніж нижня межа масиву. Якщо в якийсь момент це буде так, то min потрібно присвоїти значення нижньої межі масиву. Якщо при цьому значення елемента List (min) все ще більше шуканого, значить шуканого елемента немає у списку. На рис. 10.4 показаний стежить пошук елемента зі значенням 17 вліво від попереднього шуканого елемента зі значенням 44.

Слідкування вправо виконується аналогічно. Спочатку значення змінних min і max встановлюються рівними значенням індексу, отриманого під час попереднього пошуку. Потім послідовно встановлюється min = max і max = max + 1, min = max і max = max + 2, min = max і max = max + 4, і так далі до тих пір, поки в якійсь точці значення елемента масиву List ( max) не стане більше шуканого. І знову необхідно стежити за тим, щоб не вийти за кордон масиву.

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


@ Рис. 10.4. Стежить пошук значення 17 з значення 44


=============== 276


Якщо новий шуканий елемент знаходиться недалеко від попереднього, то алгоритм стежить пошуку дуже швидко знайде значення max і min. Якщо новий і старий шукані елементи відстоять один від одного на P позицій, то буде потрібно порядку log (P) кроків для слідкуючого пошуку нових значень змінних min і max.

Припустимо, що ми почали звичайний двійковий пошук без фази стеження. Тоді буде потрібно близько log (NumItems) - log (P) кроків для того, щоб значення min і max були на відстані не більше, ніж P позицій один від одного. Це означає, що стежить пошук буде швидше звичайного двійкового пошуку, якщо log (P) <log (NumItems) - log (P). Додавши до обох частин рівняння log (P), отримаємо 2 * log (P)> log (NumItems). Якщо звести обидві частини рівняння до степеня двійки, отримаємо 2 2 * log (P) <2 log (NumItems) або (2 log (P)) 2 <NumItems, або після спрощення P 2 <NumItems.

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

Інтерполяційний стежить пошук

Використовуючи методи з попередніх розділів можна виконати стежить інтерполяційний пошук (interpolative hunt and search). Спочатку, як і раніше, порівняємо дані значення з попереднього пошуку з новим. Якщо нове шукане значення менше, почнемо стеження вліво, якщо більше - вправо.

Для стеження вліво будемо тепер використовувати інтерполяцію, щоб припустити, де може знаходитися шукане значення в діапазоні між попереднім значенням і значенням елемента List (1). Але це буде просто інтерполяційний пошук, в якому min = 1 і max одно індексом, отриманого під час попереднього пошуку. Після першого кроку, фаза стеження закінчується і далі можна продовжити звичайний інтерполяційний пошук.

Аналогічно виконується стеження вправо. Просто прирівнюємо max = Numitems і встановлюємо min рівним індексом, отриманого під час попереднього пошуку. Потім продовжуємо звичайний інтерполяційний пошук.

На рис. 10.5 показаний інтерполяційний пошук елемента зі значенням 17, що починається з попереднього елемента зі значенням 44.

Якщо значення даних розташовані майже рівномірно, то інтерполяційний пошук завжди вибирає значення, яке знаходиться поруч з потрібним на першому чи наступному кроці. Це означає, що починаючи з попереднього знайденого значення, не можна значно поліпшити цей алгоритм. На першому кроці, навіть без використання результату попереднього пошуку, інтерполяційний пошук, ймовірно, обере індекс, який знаходиться досить близько від індексу шуканого елемента.


@ Рис. 10.5. Інтерполяційний пошук значення 17 з значення 44


============= 277


З іншого боку, використання попереднього значення може допомогти у разі, якщо дані розподілені нерівномірно. Якщо відомо, що нове шукане значення знаходиться близько до старого, інтерполяційний пошук, що починається з попереднього значення, обов'язково знайде елемент, який знаходиться поруч з попереднім знайденим. Це означає, що використання в якості стартової точки попереднього знайденого значення може давати певну перевагу.

Результат попереднього пошуку також сильніше обмежує діапазон можливих положень нового елемента, в порівнянні з діапазоном від 1 до NumItems, тому алгоритм може заощадити при цьому один або два кроки. Це особливо важливо, якщо список знаходиться на диску або якому або іншому повільному пристрої. Якщо зберігати результат попереднього пошуку в пам'яті, то можна, принаймні, порівняти нове шукане значення з попереднім без звернення до диска.

Резюме

Якщо елементи знаходяться в зв'язковому списку, використовуйте пошук методом повного перебору. По можливості використовуйте сигнальну мітку в кінці списку для прискорення пошуку.

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

Якщо потрібно проводити пошук у великих списках, використовуйте інтерполяційний пошук. Якщо значення даних розподілені досить рівномірно, то інтерполяційний пошук забезпечить найкращу продуктивність. Якщо список знаходиться на диску або якому або іншому повільному пристрої, різниця у швидкості між інтерполяційним пошуком і іншими методами пошуку може бути досить велика.

Якщо використовуються строкові дані, можна спробувати закодувати їх числами у форматі integer, long або double, при цьому для їх пошуку можна буде використовувати інтерполяційний метод. Якщо рядки занадто довгі і не поміщаються навіть в числа формату double, то простіше за все може виявитися використовувати двійковий пошук. У табл. 10.1 перераховані переваги та недоліки для різних методів пошуку.

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


@ Таблиця 10.1 Переваги та недоліки різних методів пошуку.


=========== 278


Тим не менш, в такій великій список важко вносити зміни. Вставка та видалення елемента з упорядкованого списку займе час порядку O (N). Якщо елемент знаходиться на початку списку, виконання цих операцій може зажадати дуже великої кількості часу, особливо якщо список знаходиться на якомусь повільному пристрої.

Якщо потрібно вставляти і видаляти елементи з великого списку, слід розглянути можливість заміни його на іншу структуру даних. У 7 чолі обговорюються збалансовані дерева, вставка і додавання елемента в які вимагає часу порядку O (log (N)).

У 11 главі обговорюються методи, що дозволяють виконувати вставку і видалення елементів ще швидше. Для досягнення такої високої швидкості, у цих методах використовується додатковий простір для зберігання проміжних даних. Хеш таблиці не зберігають інформацію про порядок розташування даних. У хеш таблицю можна вставляти, видаляти, і знаходити елементи, але складно вивести елементи з таблиці по порядку.

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


============= 279


Глава 11. Хешування

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

Хешування (hashing) використовує аналогічний підхід, відображаючи елементи в хеш таблиці (hash table). Алгоритм хешування використовує деяку функцію, яка визначає ймовірне положення елемента в таблиці на основі значення шуканого елемента.

Наприклад, припустимо, що потрібно запам'ятати кілька записів, кожна з яких має унікальний ключ зі значенням від 1 до 100. Для цього можна створити масив з 100 осередками і проініціалізувати кожну клітинку нульовим ключем. Щоб додати в масив новий запис, дані з неї просто копіюються у відповідний осередок масиву. Щоб додати запис з ключем 37, дані з неї просто копіюються в 37 позицію в масиві. Щоб знайти запис з певним ключем, просто вибирається відповідна осередок масиву. Для видалення запису ключу відповідної комірки масиву просто присвоюється нульове значення. Використовуючи цю схему, можна додати, знайти і видалити елемент з масиву за один крок.

На жаль, в реальних додатках значення ключа не завжди знаходяться в невеликому діапазоні. Зазвичай діапазон можливих значень ключа достатньо великий. База даних співробітників може використовувати в якості ключа ідентифікаційний номер соціального страхування. Теоретично можна було б створити масив, кожна осередок якого відповідала одному з можливих дев'ятизначних чисел; але на практиці для цього не вистачить пам'яті або дискового простору. Якщо для зберігання одного запису потрібно 1 кілобайт пам'яті, то такий масив зайняв би 1 терабайт (мільйон мегабайт) пам'яті. Навіть якщо можна було б виділити такий обсяг пам'яті, така схема була б дуже неекономною. Якщо штат вашої компанії менше 10 мільйонів співробітників, то більше 99 відсотків масиву будуть порожні.


======= 281


Щоб справитися з цією проблемою, схеми хешування відображають потенційно велике число можливих ключів на досить компактну хеш таблицю. Якщо у вашій компанії працює 700 співробітників, ви можете створити хеш таблицю з 1000 осередків. Схема хешування встановлює відповідність між 700 записами про співробітників і 1000 позиціями в таблиці. Наприклад, можна розташовувати записи в таблиці відповідно до трьома першими цифрами ідентифікаційного номеру в системі соціального страхування. При цьому запис про співробітника з номером соціального страхування 123 45 6789 буде знаходитися в 123 клітинці таблиці.

Очевидно, що оскільки існує більше можливих значень ключа, ніж осередків у таблиці, то деякі значення ключів можуть відповідати одним і тим же комірок таблиці. Наприклад, обидва значення 123 45 6789 і 12399 9999 відображаються на одну і ту ж комірку таблиці 123. Якщо є мільярд можливих номерів системи соціального страхування, і таблиця має 1000 осередків, то в середньому кожна клітинка буде відповідати мільйону записів.

Щоб уникнути цієї потенційної проблеми, схема хешування повинна включати в себе алгоритм вирішення конфліктів (collision resolution policy), який визначає послідовність дій у разі, якщо ключ відповідає позиції в таблиці, яка вже зайнята іншою записом. У наступних розділах описуються кілька різних методів вирішення конфліктів.

Всі обговорювані тут методи використовують для вирішення конфліктів приблизно однаковий підхід. Вони спочатку встановлюють відповідність між ключем запису і положенням в хеш таблиці. Якщо цей осередок вже зайнята, вони відображають ключ на яку або іншу клітинку таблиці. Якщо вона також вже зайнята, то процес повторюється знову про тих пір, поки врешті-решт алгоритм не знайде порожню клітинку в таблиці. Послідовність перевіряються при пошуку або вставці елементу в хеш таблицю позицій називається тестової послідовністю (probe sequence).

У підсумку, для реалізації хешування необхідні три речі:

  • Структура даних (хеш таблиця) для зберігання даних;

  • Функція хешування, що встановлює відповідність між значенням ключа і положенням в таблиці;

  • Алгоритм вирішення конфліктів, що визначає послідовність дій, якщо кілька ключів відповідають одній клітинці таблиці.

У наступних розділах описані деякі структури даних, які можна використовувати для хешування. Кожна з них має відповідну функцію хешування та один або більш алгоритмів вирішення конфліктів. Так само, як і в більшості комп'ютерних алгоритмів, кожен з цих методів має свої переваги і недоліки. В останньому розділі описані переваги та недоліки різних методів, щоб допомогти вам вибрати найкращий для даної ситуації метод хешування.

Зв'язування

Один з методів вирішення конфліктів полягає в зберіганні записів, які займають однакове положення в таблиці, в зв'язкових списках. Щоб додати до таблиці новий запис, за допомогою функції хешування вибирається зв'язний список, який має його утримувати. Потім запис додається до цього списку.

На рис. 11.1 показаний приклад зв'язування хеш таблиці, яка містить 10 клітинок. Функція хешування відображає ключ K на клітинку K Mod 10 в масиві. Кожна клітинка масиву містить вказівник на перший елемент зв'язного списку. При вставці елементу в таблицю він поміщається у відповідний список.


====== 282


@ Рис. 11.1. Зв'язування


Щоб створити хеш таблицю в Visual Basic, використовуйте оператор ReDim для розміщення сигнальних міток початку списків. Якщо ви хочете створити в хеш таблиці NumLists зв'язкових списків, задайте розмір масиву ListTops за допомогою оператора ReDim ListTops (0 To NumLists - 1). Спочатку всі списки порожні, тому покажчик NextCell кожної позначки повинен мати значення Nothing. Якщо ви використовуєте для зміни масиву міток оператор ReDim, то Visual Basic автоматично ініціалізує покажчики NextCell значенням Nothing.

Щоб знайти в хеш таблиці елемент з ключем K, потрібно обчислити K Mod NumLists, отримавши індекс мітки зв'язного списку, який може містити шуканий елемент. Потім потрібно переглянути список до тих пір, поки шуканий елемент не буде знайдений або процедура не дійде до кінця списку.


Global Const HASH_FOUND = 0

Global Const HASH_NOT_FOUND = 1

Global Const HASH_INSERTED = 2


Private Function LocateItemUnsorted (Value As Long) As Integer

Dim cell As ChainCell


'Отримати вершину зв'язного списку.

Set cell = m_ListTops (Value Mod NumLists). NextCell

Do While Not (cell Is Nothing)

If cell.Value = Value Then Exit Do

Set cell = cell.NextCell

Loop

If cell Is Nothing Then

LocateItemUnsorted = HASH_NOT_FOUND

Else

LocateItemUnsorted = HASH_FOUND

End If

End Function


Функції для вставки і видалення елементів з зв'язкових списків аналогічні функціям, описаним у 2 чолі.


======== 283


Переваги та недоліки зв'язування

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

З хеш таблиці, яка використовує зв'язування, також просто видаляти елементи, при цьому елемент просто видаляється з відповідного зв'язного списку. У деяких інших схемах хешування видалити елемент непросто або неможливо.

Один з недоліків зв'язування полягає в тому, що якщо число зв'язних списків недостатньо велике, то розмір списків може стати великим, при цьому для вставки або пошуку елемента необхідно буде перевірити велика кількість елементів списку. Якщо хеш таблиця містить 10 зв'язкових списків і до неї додається 1000 елементів, то середня довжина зв'язного списку буде дорівнює 100. Щоб знайти елемент у таблиці, доведеться перевірити близько 100 осередків.

Можна трохи прискорити пошук, якщо використовувати впорядковані списки. Тоді можна використовувати для пошуку елементів у впорядкованих зв'язкових списках методи, описані в 10 главі. Це дозволяє припинити пошук, якщо під час його виконання зустрінеться елемент зі значенням, більшим шуканого. У середньому буде потрібно перевірити тільки половину зв'язного списку, щоб знайти елемент або визначити, що його немає в списку.


Private Function LocateItemSorted (Value As Long) As Integer

Dim cell As ChainCell


'Отримати вершину зв'язного списку.

Set cell = m_ListTops (Value Mod NumLists). NextCell

Do While Not (cell Is Nothing)

If cell.Value> = Value Then Exit Do

Set cell = cell.NextCell

Loop


If cell Is Nothing Then

LocateItemSorted = HASH_NOT_FOUND

ElseIf cell.Value = Value Then

LocateItemSorted = HASH_FOUND

Else

LocateItemSorted = HASH_NOT_FOUND

End If

End Function


Використання впорядкованих списків дозволяє прискорити пошук, але не знімає цю проблему, пов'язану з переповнення таблиці. Кращим, але більш трудомістким рішенням буде створення хеш таблиці більшого розміру і повторне хешування елементів у новій таблиці так, щоб зв'язні списки в ній мали менший розмір. Це може зайняти досить багато часу, особливо якщо списки записані на диску або якому або іншому повільному пристрої, а не в пам'яті.


======== 284


У програмі Chain реалізована хеш таблиця зі зв'язуванням. Введіть число списків у полі області Table Creation (Створення таблиці) на формі і встановіть прапорець Sort Lists (Впорядковані списки), якщо ви хочете, щоб програма використовувала впорядковані списки. Потім натисніть на кнопку Create Table (Створити таблицю). Потім ви можете ввести нові значення і знову натиснути на кнопку Create Table, щоб створити нову хеш таблицю.

Так як цікаво вивчати хеш таблиці, які містять велику кількість значень, то програма Chain дозволяє заповнювати таблицю випадковими елементами. Введіть число елементів, які ви хочете створити і максимальне значення елементів в області Random Items (Випадкові елементи), потім натисніть на кнопку Create Items (Створити елементи), і програма додасть в хеш таблицю випадково створені елементи.

І, нарешті, введіть значення в області Search (Пошук). Якщо ви натиснете на кнопку Add (Додати), то програма вставить елемент у хеш таблицю, якщо він ще не знаходиться в ній. Якщо ви натиснете на кнопку Find (Знайти), то програма виконає пошук елемента в таблиці.

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

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

На рис. 11.2 показано вікно програми Chain після успішного пошуку елемента 414.

Блоки

Інший спосіб вирішення конфліктів полягає в тому, щоб виділити ряд блоків, кожен з яких може містити кілька елементів. Для вставки елемента в таблицю, він відображається на один з блоків і потім поміщається в цей блок. Якщо блок вже заповнений, то використовується обробка переповнення.


@ Рис. 11.2. Програма Chain


====== 285


Можливо, найпростіший метод обробки переповнення полягає в тому, щоб помістити всі зайві елементи в спеціальні блоки в кінці масиву «нормальних» блоків. Це дозволяє при необхідності легко збільшувати розмір хеш таблиці. Якщо потрібно більше додаткових блоків, то розмір масиву блоків просто збільшується, і в кінці масиву створюються нові додаткові блоки.

Наприклад, щоб додати новий елемент K в хеш таблицю, яка містить п'ять блоків, спочатку ми намагаємося помістити його в блок з номером K Mod 5. Якщо цей блок заповнений, елемент міститься в додатковий блок.

Щоб знайти елемент у таблиці, обчислимо K Mod 5, щоб знайти його положення, і потім виконаємо пошук в цьому блоці. Якщо елемента в цьому блоці немає, і блок не заповнений, значить елемента в хеш таблиці немає. Якщо елемента в блоці немає і блок заповнений, необхідно перевірити додаткові блоки.

На рис. 11.3 показані п'ять блоків з номерами від 0 до 4 і один додатковий блок. Кожен блок може містити за 5 елементів. У цьому прикладі в хеш таблицю були вставлені наступні елементи: 50, 13, 10, 72, 25, 46, 68, 30, 99, 85, 93, 65, 70. При вставці елементів 65 і 70 блоки вже були заповнені, тому ці елементи були поміщені в перший додатковий блок.

Щоб реалізувати метод блокового хешування в Visual Basic, можна використовувати для зберігання блоків двовимірний масив. Якщо потрібно NumBuckets блоків, кожен з яких може містити BucketSize осередків, виділимо пам'ять під блоки за допомогою оператора ReDim TheBuckets (0 To BucketSize -1, 0 To NumBuckets - 1). Другий вимір відповідає номеру блоку. Оператор Visual Basic ReDim дозволяє змінити тільки розмір масиву, тому номер блоку повинен бути другим виміром масиву.

Щоб знайти елемент K, обчислимо номер блоку K Mod NumBuckets. Потім проведемо пошук в блоці до тих пір, поки не знайдеться шуканий елемент, або порожній осередок блоку, або блок не закінчиться. Якщо елемент знайдено, пошук завершено. Якщо зустрінеться порожня клітинка, значить елемента в хеш таблиці немає, і процес також завершений. Якщо перевірений весь блок, і не знайдено шуканий елемент або порожня клітинка, потрібно перевірити додаткові блоки.



@ Рис. 11.3. Хешування з використанням блоків


====== 286


Public Function LocateItem (Value As Long, _

bucket_probes As Integer, item_probes As Integer) As Integer

Dim bucket As Integer

Dim pos As Integer


bucket_probes = 1

item_probes = 0


'Визначити, до якого блоку він відноситься.

bucket = (Value Mod NumBuckets)

'Пошук елемента або порожнього вічка.

For pos = 0 To BucketSize - 1

item_probes = item_probes + 1

If Buckets (pos, bucket). Value = UNUSED Then

LocateItem = HASH_NOT_FOUND 'Елемент відсутня.

Exit Function

End If

If Buckets (pos, bucket). Value = Value Then

LocateItem = HASH_FOUND 'Елемент знайдений.

Exit Function

End If

Next pos


'Перевірити додаткові блоки.

For bucket = NumBuckets To MaxOverflow

bucket_probes = bucket_probes + 1

For pos = 0 To BucketSize - 1

item_probes = item_probes + 1

If Buckets (pos, bucket). Value = UNUSED Then

LocateItem = HASH_NOT_FOUND 'Not here.

Exit Function

End If

If Buckets (pos, bucket). Value = Value Then

LocateItem = HASH_FOUND 'Елемент знайдений.

Exit Function

End If

Next pos

Next bucket


'Якщо елемент до цих пір не знайдений, то його немає в таблиці.

LocateItem = HASH_NOT_FOUND

End Function


====== 287


Програма Bucket демонструє цей метод. Ця програма дуже схожа на програму Chain, але вона використовує блоки, а не зв'язні списки. Коли ця програма виводить довжину тестової послідовності, вона показує число перевірених блоків і кількість перевірених елементів в блоках. На рис. 11.4 показано вікно програми після успішного пошуку елемента 661 в першому додатковому блоці. У цьому прикладі програма перевірила 9 елементів у двох блоках.

Зберігання хеш таблиць на диску

Багато запам'ятовуючі пристрої, такі як стримери, дисководи і жорсткі диски, можуть зчитувати великі шматки даних за одне звернення до пристрою. Зазвичай ці блоки мають розмір 512 або 1024 байта. Читання всього блоку даних займає стільки ж часу, скільки і читання одного байта.

Якщо є велика хеш таблиця, записана на диску, то цей факт можна використовувати для поліпшення продуктивності. Доступ до даних на диску займає набагато більше часу, ніж доступ до даних у пам'яті. Якщо відразу завантажувати всі елементи блоку, то можна буде прочитати їх всі під час одного звертання до диска. Після того, як всі елементи опиняться в пам'яті, їх перевірка може виконуватися набагато швидше, ніж якщо б довелося їх зчитувати з диска по одному.

Якщо для читання елементів з диска використовується цикл For, то Visual Basic буде звертатися до диска при читанні кожного елемента. З іншого боку, можна використовувати оператор Visual Basic Get для читання всього блоку відразу. При цьому буде потрібно всього одне звернення до диска, і програма буде виконуватися набагато швидше.

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


Global Const ITEMS_PER_BUCKET = 10 'Кількість елементів у блоці.

Global Const MAX_ITEM = 9 'ITEMS_PER_BUCKET - 1.


Type ItemType

Value As Long

End Type

Global Const ITEM_SIZE = 4 'Розмір даних цього типу.


Type BucketType

Item (0 To MAX_ITEM) As ItemType

End Type

Global Const BUCKET_SIZE = ITEMS_PER_BUCKET * ITEM_SIZE


Перед тим, як розпочати читання даних з файлу, він відкривається для довільного доступу:


Open filename For Random As # DataFile Len = BUCKET_SIZE


========= 288


@ Рис. 11.4. Програма Bucket


Для зручності роботи можна написати функції для читання і запису блоків. Ці функції читають і пишуть дані в глобальну змінну TheBucket, яка містить дані одного блоку. Після того, як дані завантажені в цю змінну, можна виконати пошук серед елементів цього блоку в пам'яті.

Тому що при довільному зверненні до файлу запису нумеруються з одиниці, а не з нуля, то ці функції повинні додавати до номера блоку в хеш таблиці одиницю перед зчитуванням даних з файлу. Наприклад, нульового блоку в хеш таблиці буде відповідати запис з номером 1.


Private Sub GetBucket (num As Integer)

Get # DataFile, num + 1, TheBucket

End Sub


Private Sub PutBucket (num As Integer)

Put # DataFile, num + 1, TheBucket

End Sub


Використовуючи функції GetBucket і PutBucket, можна переписати процедуру пошук в хеш таблиці для читання записів з файлу:


Public Function LocateItem (Value As Long, _

bucket_probes As Integer, item_probes As Integer) As Integer

Dim bucket As Integer

Dim pos As Integer


item_probes = 0


'Визначити, до якого блоку належить елемент.

GetBucket Value Mod NumBuckets

bucket_probes = 1


'Пошук елемента або порожнього вічка.

For pos = 0 To MAX_ITEM

item_probes = item_probes + 1

If TheBucket.Item (pos). Value = UNUSED Then

LocateItem = HASH_NOT_FOUND 'Елементу немає в таблиці.

Exit Function

End If

If TheBucket.Item (pos). Value = Value Then

LocateItem = HASH_FOUND 'Елемент знайдений.

Exit Function

End If

Next pos

'Перевірити додаткові блоки

For bucket = NumBuckets To MaxOverflow

'Перевірити наступний додатковий блок.

GetBucket bucket

bucket_probes = bucket_probes + 1

For pos = 0 To MAX_ITEM

item_probes = item_probes + 1

If TheBucket.Item (pos). Value = UNUSED Then

LocateItem = HASH_NOT_FOUND 'Елементу немає.

Exit Function

End If

If TheBucket.Item (pos). Value = Value Then

LocateItem = HASH_FOUND 'Елемент знайдений.

Exit Function

End If

Next pos

Next bucket

'Якщо елемент все ще не знайдений, його немає в таблиці.

LocateItem = HASH_NOT_FOUND

End Function


Програма Bucket2 аналогічна програмі Bucket, але вона зберігає блоки на диску. Вона також не обчислює і не виводить на екран середню довжину тестової послідовності, так як ці обчислення зажадали б великого числа звернень до диска і сильно сповільнили б роботу програми.


============ 290


Так як при зверненні до блоків відбувається читання з диска, а звернення до елементів блоку відбувається в пам'яті, то число перевіряються блоків набагато сильніше впливає на час виконання програми, ніж повне число перевірених елементів. Для порівняння середнього числа перевірених блоків і елементів при пошуку елементів можна використовувати програму Bucket.

Кожен блок в програмі Bucket2 може містити до 10 елементів. Це дозволяє легко вставляти елементи в блоки до тих пір, поки вони не переповняться. У реальному програмі слід спробувати помістити в блок максимально можливе число елементів так, щоб розмір блоку залишався при цьому так цілому числу кластерів диска.

Наприклад, можна читати дані блоками по 1024 байта. Якщо елемент даних має розмір 44 байта, то в один блок може поміститися 23 елемента даних, і при цьому розмір блоку буде менше 1024 байт.


Global Const ITEMS_PER_BUCKET = 23 'Кількість елементів у блоці.

Global Const MAX_ITEM = 22 'ITEMS_PER_BUCKET - 1.


Type ItemType

LastName As String * 20 '20 байт.

FirstName As String * 20 '20 байт.

EmloyeeId As Long '4 байти (це ключ).

End Type

Global Const ITEM_SIZE = 44 Розмір даних цього типу.


Type BucketType

Item (0 To MAX_ITEM) As ItemType

End Type

Global Const BUCKET_SIZE = ITEMS_PER_BUCKET * ITEM_SIZE


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

З іншого боку, якщо блоки досить великі, то вони можуть містити велику кількість порожніх комірок. Якщо дані нерівномірно розподілені по блоках, то одні блоки можуть бути переповнені, а інші - практично порожні. Використання іншого варіанту розміщення з великим числом блоків меншого розміру може зменшити цю проблему. Навіть якщо деякі блоки все ще будуть переповнені, а деякі порожні, то майже порожні блоки будуть мати менший розмір, тому вони не будуть містити так багато порожніх клітинок.

На рис. 11.5 показані два варіанти розташування одних і тих же даних в блоках. У розташуванні нагорі використовуються 5 блоків, кожен з яких містить по 5 елементів. При цьому додаткові блоки не використовуються, і всього є 12 порожніх клітинок. Розташування внизу використовує 10 блоків, кожен з яких містить по 2 елементи. У ньому є 9 порожніх клітинок і один додатковий блок.


======== 291


@ Рис. 11.5. Два варіанти розташування елементів в блоках


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

Зв'язування блоків

Можна використовувати інший підхід, якщо при переповненні блоків створювати ланцюжки з блоків. Для кожного заповненого блоку створюється своя ланцюжок блоків, замість того, щоб зберігати всі зайві елементи в одних і тих самих додаткових блоках. При пошуку елемента в заповненому блоці немає необхідності перевіряти елементи в додаткових блоках, які були поміщені туди в результаті переповнення інших блоків. Якщо безліч блоків переповнене, то це може заощадити досить багато часу.

На рис. 11.6 показано застосування двох різних схем хешування для одних і тих же даних. Вгорі зайві елементи містяться в загальні додаткові блоки. Щоб знайти елементи 32 і 30, потрібно перевірити три блоки. По-перше, перевіряється блок, в якому елемент повинен знаходиться. Елемента в цьому блоці немає, тому перевіряється перший додатковий блок, в якому елементу теж немає. Тому потрібно перевірити другий додатковий блок, в якому, нарешті, знаходиться шуканий елемент.

У нижньому розташуванні заповнені блоки пов'язані зі своїми власними додатковими блоками. При такому розташуванні будь-який елемент можна знайти після звернення не більш ніж до двох блоків. Як і раніше, спочатку перевіряється блок, в якому елемент повинен перебувати. Якщо його там немає, то перевіряється зв'язний список додаткових блоків. У цьому прикладі щоб знайти шуканий елемент потрібно перевірити тільки один додатковий блок.


========= 292


@ Рис. 11.6. Зв'язкові додаткові блоки


Якщо додаткові блоки хеш таблиці містить велику кількість елементів, то організація ланцюжків з додаткових блоків може заощадити досить багато часу. Припустимо, що є відносно велика хеш таблиця, яка містить 1000 блоків, в кожному з яких знаходиться 10 пунктів. Припустимо також, що в додаткових блоках знаходиться 1000 елементів, для яких знадобиться 100 додаткових блоків. Щоб знайти один з останніх елементів в додаткових блоках, буде потрібно перевірити 101 блок.

Більш того, припустимо, що ми намагалися знайти елемент K, якого немає в таблиці, але який повинен був би знаходитися в одному із заповнених блоків. У цьому випадку довелося б перевірити всі 100 додаткових блоків, перш ніж з'ясувалося б, що елемент відсутній у таблиці. Якщо програма часто намагається знайти елементи, яких немає в таблиці, то значна частина часу буде витрачатися на перевірку додаткових блоків.

Якщо додаткові блоки зв'язані між собою і ключові значення розподілені рівномірно, то можна буде знаходити елементи набагато швидше. Якщо максимальне число додаткових елементів для одного блоку дорівнює 10, то кожен блок може мати не більше одного додаткового. У цьому випадку можна знайти елемент або визначити, що його немає в таблиці, перевіривши не більше двох блоків.

З іншого боку, якщо хеш таблиця тільки злегка переповнена, то багато блоки будуть мати додаткові блоки, що містять всього один або два елементи. Припустимо, що в кожному блоці повинен розміщуватися 11 елементів. Так що кожен блок може вмістити тільки 10 елементів, для кожного звичайного блоку потрібно буде створити один додатковий. У цьому випадку буде потрібно 1000 додаткових блоків, кожен з яких буде містити лише один елемент, і всього в додаткових блоках буде 900 порожніх клітинок.

Це ще один приклад просторово тимчасового компромісу. Зв'язування блоків один з одним дозволяє швидше вставляти і знаходити елементи, але воно також може заповнювати хеш таблицю порожніми осередками. Звичайно, можна уникнути цієї проблеми, створивши нову хеш таблицю більшого розміру і розмістивши в ній всі елементи таблиці.


===== 293


Видалення елементів

Видалення елементів із блоків складніше, ніж з зв'язкових списків, але вона можлива. По-перше, знайдемо елемент, який потрібно видалити з хеш таблиці. Якщо блок не заповнений, то на місце видаленого елемента міститься останній елемент блоку, при цьому всі непорожні осередку блоку буде перебувати на його початку. Тоді, якщо при пошуку елемента в блоці пізніше знайдеться порожня клітинка, то можна буде зробити висновок, що елемента в таблиці немає.

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

Інакше, якщо в додатковому блоці існує елемент, який належить до даного блоку, то знайдений елемент з додаткового блоку поміщається на місце видаленого елемента. При цьому в додатковому блоці утвориться порожній простір, але це легко виправити - в порожнє місце поміщається останній елемент з останнього додаткового блоку.

На рис. 11.7 показаний процес видалення елемента із заповненого блоку. По-перше, з блоку 0 видаляється елемент 24. Оскільки блок 0 був заповнений, то потрібно спробувати знайти елемент з додаткових блоків, який можна було б вставити на його місце в блок 0. У даному випадку блок 0 містить всі парні елементи, тому будь-який парний елемент з додаткових блоків підійде. Перший парних елементом у додаткових блоках буде елемент 14, тому можна замінити елементи 24 у блоці 0 елементом 14.

При цьому в третій позиції першого додаткового блоку утворюється порожня клітинка. Заповнимо її останнім елементом з останнього додаткового блоку, в даному випадку елементом 79. У цей момент хеш таблиця знову готова до роботи.

Інший метод полягає в тому, щоб замість видалення елемента позначати його як віддалений. Для пошуку елементів у такому блоці потрібно ігнорувати видалені елементи. Якщо пізніше в блок будуть додаватися нові елементи, можна буде поміщати їх на місце елементів, позначених як вилучені.


@ Рис. 11.7. Видалення елемента з блоку


========= 294


Швидше і легше замість видалення елемента просто позначати його як віддалений, але, врешті-решт, таблиця може виявитися заповненої невикористовуваними осередками. Якщо додати хеш таблицю ряд елементів і потім видалити більшість з них у порядку перший увійшов - першим вийшов, то розташування елементів в блоках може виявитися «перевернутим». Велика частина цих даних буде знаходитися в кінці блоків і в додаткових блоках. Додавати нові елементи в таблицю буде просто, але при пошуку елемента досить багато часу буде витрачатися на пропуск видалених елементів.

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

Переваги та недоліки застосування блоків

Вставка та видалення елемента в хеш таблицю з блоками виконується досить швидко, навіть якщо таблиця майже заповнена. Фактично, хеш таблиця, яка використовує блоки, зазвичай буде швидше, ніж таблиця зі зв'язуванням (зв'язуванням з попередньої глави, а не зв'язуванням блоків). Якщо хеш таблиця знаходиться на диску, блочний алгоритм може зчитувати за одне звернення до диска весь блок. При використанні зв'язних списків, наступний елемент може перебувати на диску не обов'язково поряд з попереднім. При цьому для кожної перевірки елемента буде потрібно звернення до диска.

Видалення елемента з таблиці складніше виконати з використанням блоків, ніж при застосуванні зв'язкових списків. Щоб видалити елемент із заповненого блоку, може знадобитися перевірити всі додаткові блоки в пошуку елемента, який потрібно помістити на його місце.

І ще одна перевага хеш таблиці, використовує блоки, полягає в тому, що якщо таблиця переповнюється, то можна легко збільшити її розмір. Коли всі додаткові блоки заповняться, можна просто змінити розмір масиву і створити в його кінці новий додатковий блок.

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

Відкрита адресація

Іноді елементи даних занадто великі, щоб їх було зручно розміщувати в блоках. Якщо потрібно список з 1000 елементів, кожен з яких займає на диску 1 Мбайт, може бути складно використовувати блоки, які містили б більш одного або двох елементів. Якщо кожен з блоків буде містити всього один або два елементи, то для пошуку або вставки елемента буде потрібно перевірити безліч блоків.

При використанні відкритої адресації (open addressing) хеш функція використовується для безпосереднього обчислення положення елементів даних у масиві. Наприклад, можна використовувати в якості хеш таблиці масив з нижнім індексом 0 і верхнім 99. Тоді хеш функція може зіставляти ключу зі значенням K індекс масиву, рівний K Mod 100. При цьому елемент зі значенням 1723 виявиться в таблиці на 23 позиції. Потім, коли знадобиться знайти елемент 1723, перевіряється 23 позиція в масиві.


========== 295


Різні схеми відкритої адресації використовують різні методи для формування тестових послідовностей. У наступних розділах розглядаються три найбільш важливих методу: лінійна, квадратична і псевдослучайная перевірка.

Лінійна перевірка

Якщо позиція, на яку відображається новий елемент у масиві, вже зайнята, то можна просто переглянути масив з цієї точки до тих пір, поки не знайдеться незайнята позиція. Цей метод вирішення конфліктів називається лінійної перевіркою (linear probing), так як при цьому таблиця проглядається послідовно.

Розглянемо знову приклад, в якому є масив з нижньою межею 0 і верхньою межею 99, і хеш функція відображає елемент K в позицію K Mod 100. Щоб вставити елемент 1723, спочатку перевіряється позиція 23. Якщо ця комірка заповнена, то перевіряється позиція 24. Якщо вона також зайнята, то перевіряються позиції 25, 26, 27 і так далі до тих пір, поки не знайдеться вільна клітинка.

Щоб вставити новий елемент у хеш таблицю, застосовується обрана тестова послідовність до тих пір, поки не буде знайдена порожня клітинка. Щоб знайти елемент у таблиці, застосовується обрана тестова послідовність до тих пір, поки не буде знайдений елемент або порожня клітинка. Якщо порожня клітинка зустрінеться раніше, значить елемент у хеш таблиці відсутній.

Можна записати комбіновану функцію перевірки і хешування:


Hash (K, P) = (K + P) Mod 100 де P = 0, 1, 2, ...


Тут P - число елементів у тестової послідовності для K. Іншими словами, для хешування елемента K перевіряються елементи Hash (K, 0), Hash (K, 1), Hash (K, 2), ... до тих пір, поки не знайдеться порожня клітинка.

Можна узагальнити цю ідею для створення таблиці розміру N на основі масиву з індексами від 0 до N - 1. Хеш функція буде мати вигляд:


Hash (K, P) = (K + P) Mod N де P = 0, 1, 2, ...


Наступний код показує, як виконується пошук елемента за допомогою лінійної перевірки:


Public Function LocateItem (Value As Long, pos As Integer, _

probes As Integer) As Integer

Dim new_value As Long


probes = 1

pos = (Value Mod m_NumEntries)

Do

new_value = m_HashTable (pos)

'Елемент знайдений.

If new_value = Value Then

LocateItem = HASH_FOUND

Exit Function

End If

'Елементу в таблиці немає.

If new_value = UNUSED Or probes> = NumEntries Then

LocateItem = HASH_NOT_FOUND

pos = -1

Exit Function

End If


pos = (pos + 1) Mod NumEntries

probes = probes + 1

Loop

End Function


Програма Linear демонструє відкриту адресацію з лінійною перевіркою. Заповнивши поле Table Size (Розмір таблиці) і натиснувши на кнопку Create table (Створити таблицю), можна створювати хеш таблиці різних розмірів. Потім можна ввести значення елемента і натиснути на кнопку Add (Додати) або Find (Знайти), щоб вставити або знайти елемент у таблиці.

Щоб додати до таблиці відразу кілька випадкових значень, введіть число елементів, які ви хочете додати і максимальне значення, яке вони можуть мати в області Random Items (Випадкові елементи), і потім натисніть на кнопку Create Items (Створити елементи).

Після завершення програмою будь-якої операції вона виводить статус операції (успішне або безуспішне завершення) і довжину тестової послідовності. Вона також виводить середню довжину успішної і безуспішною тестової послідовностей. Програма обчислює середню довжину тестової послідовності, виконуючи пошук всіх значень від 1 до максимального значення в таблиці.

У табл. 11.1 наведена середня довжина успішних і безуспішних тестових послідовностей, отриманих у програмі Linear для таблиці з 100 осередками, елементи в яких знаходяться в діапазоні від 1 до 999. З таблиці видно, що продуктивність алгоритму падає в міру заповнення таблиці. Чи є продуктивність прийнятною, залежить від того, як використовується таблиця. Якщо програма витрачає більшу частину часу на пошук значень, які є в таблиці, то продуктивність може бути непоганий, навіть якщо таблиця практично не заповнена. Якщо ж програма часто шукає значення, яких немає в таблиці, то продуктивність може бути дуже низькою, якщо таблиця переповнена.

Як правило, хешування забезпечує прийнятну продуктивність, не витрачаючи при цьому занадто багато пам'яті, якщо заповнено від 50 до 75 відсотків таблиці. Якщо таблиця заповнена більше, ніж на 75 відсотків, то продуктивність падає. Якщо таблиця заповнена менше, ніж на 50 відсотків, то вона займає більше пам'яті, ніж це необхідно. Це робить відкриту адресацію хорошим прикладом просторово тимчасового компромісу. Збільшуючи хеш таблицю, можна зменшити час, необхідний для вставки або пошуку елементів.


======= 297


@ Таблиця 11.1. Довжина успішної і безуспішною тестових послідовностей


Первинна кластеризація

Лінійна перевірка має одну неприємну властивість, яке називається первинної кластеризацією (primary clustering). Після додавання великої кількості елементів у таблицю, виникає конфлікт між новими елементами і вже наявними кластерами, при цьому для вставки нового елемента потрібно обійти кластер, щоб знайти порожню комірку.

Щоб побачити, як утворюються кластери, припустимо, що спочатку є порожня хеш таблиця, яка може містити N елементів. Якщо вибрати випадкове число і вставити його в таблицю, то ймовірність того, що елемент займе будь-яку задану позицію P в таблиці, дорівнює 1 / N.

При вставці другий випадково обраного елемента, він може відобразитися на ту ж позицію з імовірністю 1 / N. Через конфлікту в цьому випадку він поміщається в позицію P + 1. Також існує ймовірність 1 / N, що елемент і повинен розташовуватися в позиції P + 1, і ймовірність 1 / N, що він повинен знаходитися в позиції P - 1. У всіх цих трьох випадках новий елемент розташовується поруч з попереднім. Таким чином, в цілому існує ймовірність 3 / N того, що 2 елементи виявляться розташованими поблизу одне від одного, утворюючи невеликий кластер.

У міру зростання кластеру ймовірність того, що наступні елементи будуть розташовуватися поблизу кластера, зростає. Якщо в кластері знаходиться два елементи, то ймовірність того, що черговий елемент приєднається до кластеру, дорівнює 4 / N, якщо в кластері чотири елементи, то ця ймовірність дорівнює 6 / N, і так далі.

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


====== 298


В ідеальному випадку хеш таблиця повинна бути наполовину порожня, і елементи в ній повинні чергуватися з порожніми осередками. Тоді з вірогідністю 50 відсотків алгоритм відразу ж знайде порожню комірку для нового додається елемента. Також існує 50 процентна вірогідність того, що він знайде порожню клітинку після перевірки всього лише двох позицій в таблиці. Середня довжина тестової послідовності дорівнює 0,5 * 1 + 0,5 * 2 = 1,5.

У найгіршому випадку всі елементи в таблиці будуть згруповані в один гігантський кластер. При цьому все ще є 50 процентна вірогідність того, що алгоритм відразу знайде порожню клітинку, в яку можна помістити новий елемент. Тим не менш, якщо алгоритм не знайде порожню комірку на першому кроці, то пошук вільного осередку зажадає набагато більше часу. Якщо елемент має знаходитися на першій позиції кластера, то алгоритмом доведеться перевірити всі елементи в кластері, щоб знайти вільну комірку. У середньому для вставки елемента при такому розподілі буде потрібно набагато більше часу, ніж коли елементи рівномірно розподілені по таблиці.

На практиці, ступінь кластеризації буде перебувати між цими двома крайніми випадками. Ви можете використовувати програму Linear для дослідження ефекту кластеризації. Запустіть програму і створіть хеш таблицю з 100 осередками, а потім додайте 50 випадкових елементів зі значеннями до 999. Ви виявите, що утворилося кілька кластерів. В одному з тестів 38 з 50 елементів стали частиною кластерів. Якщо додати ще 25 елементів до таблиці, то більшість елементів будуть входити в кластери. В іншому тесті 70 з 75 елементів були згруповані в кластери.

Упорядкована лінійна перевірка

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

Можна використовувати подібну ідею при пошуку в хеш таблиці. Припустимо, що можна організувати елементи в хеш таблиці таким чином, що значення в кожній тестової послідовності знаходяться в порядку зростання. Тоді при виконанні тестової послідовності під час пошуку елемента можна припинити пошук, якщо зустрінеться елемент зі значенням, більшим шуканого. У цьому випадку позиція, в якій повинен був би знаходитися шуканий елемент, вже залишилася позаду, і значить елемента немає у таблиці.


Public Function LocateItem (Value As Long, pos As Integer, _

probes As Integer) As Integer

Dim new_value As Long


probes = 1

pos = (Value Mod m_NumEntries)

Do

new_value = m_HashTable (pos)

'Елементу в таблиці немає.

If new_value = UNUSED Or probes> NumEntries Then

LocateItem = HASH_NOT_FOUND

pos = -1

Exit Function

End If

'Елемент знайдено або його немає в таблиці.

If new_value> = Value Then Exit Do

pos = (pos + 1) Mod NumEntries

probes = probes + 1

Loop


If Value = new_value Then

LocateItem = HASH_FOUND

Else

LocateItem = HASH_NOT_FOUND

End If

End Function


Для того, щоб цей метод працював, необхідно організувати елементи в хеш таблиці так, щоб при виконанні тестової послідовності вони зустрічалися у зростаючому порядку. Існує досить простий метод вставки елементів, який гарантує таке розташування елементів.

Коли в таблицю вставляється новий елемент, для нього виконується тестова послідовність. Якщо знайдеться вільна комірка, то елемент вставляється в цю позицію і процедура завершена. Якщо зустрічається елемент, значення якого більше значення нового елемента, то вони міняються місцями і продовжується виконання тестової послідовності для більшого елемента. При цьому може зустрітися елемент з ще більшим значенням. Тоді елементи знову міняються місцями, і виконується пошук нового місця розташування для цього елемента. Цей процес продовжується до тих пір, поки, зрештою, не знайдеться вільна комірка, при цьому можливо кілька елементів міняються місцями.


======== 299-300


Public Function InsertItem (ByVal Value As Long, pos As Integer, _ probes As Integer) As Integer

Dim new_value As Long

Dim status As Integer


'Перевірити, заповнена чи таблиця.

If m_NumUnused <1 Then

'Пошук елемента.

status = LocateItem (Value, pos, probes)

If status = HASH_FOUND Then

InsertItem = HASH_FOUND

Else

InsertItem = HASH_TABLE_FULL

pos = -1

End If

Exit Function

End If


probes = 1

pos = (Value Mod m_NumEntries)

Do

new_value = m_HashTable (pos)


'Якщо значення знайдене, пошук завершено.

If new_value = Value Then

InsertItem = HASH_FOUND

Exit Function

End If


'Якщо осередок вільна, елемент повинен знаходитися в ній.

If new_value = UNUSED Then

m_HashTable (pos) = Value

HashForm.TableControl (pos). Caption = Format $ (Value)

InsertItem = HASH_INSERTED

m_NumUnused = m_NumUnused - 1

Exit Function

End If

'Якщо значення в клітинці таблиці більше значення

'Елемента, поміняти їх місцями і продовжити.

If new_value> Value Then

m_HashTable (pos) = Value

Value = new_value

End If

pos = (pos + 1) Mod NumEntries

probes = probes + 1

Loop

End Function


Програма Ordered демонструє відкриту адресацію з упорядкованою лінійної перевіркою. Вона ідентична програмі Linear, але використовує впорядковану хеш таблицю.

У табл. 11.2 наведена середня довжина успішної і безуспішною тестових послідовностей при використанні лінійної і впорядкованої лінійної перевірок. Середня довжина успішної перевірки для обох методів майже однакова, але в разі неуспіху упорядкована лінійна перевірка виконується набагато швидше. Різниця в Особливо помітна, якщо хеш таблиця заповнена більш, ніж на 70 відсотків.


========= 301


@ Таблиця 11.2. Довжина пошуку при використанні лінійної і впорядкованої лінійної перевірки


В обох методах для вставки нового елемента потрібно приблизно однакове число кроків. Щоб вставити елемент K в таблицю, кожен з методів починає з позиції (K Mod NumEntries) і переміщається по таблиці до тих пір, поки не знайде вільну комірку. Під час упорядкованого хешування може знадобитися поміняти вставляється елемент на інші в його тестової послідовності. Якщо елементи являють собою записи великого розміру, то на це може знадобитися більше часу, особливо якщо записи знаходяться на диску або якому або іншому повільному пристрої зберігання даних.

Упорядкована лінійна перевірка безумовно є найкращим вибором, якщо ви знаєте, що програмі доведеться здійснювати велику кількість безуспішних операцій пошуку. Якщо програма буде часто виконувати пошук елементів, яких немає в таблиці, або елементи таблиці мають великий розмір і переміщати їх досить складно, то можна отримати кращу продуктивність при використанні невпорядкованою лінійної перевірки.

Квадратична перевірка

Один із способів зменшити первинну кластеризацію полягає в тому, щоб використовувати хеш функцію такого вигляду:


Hash (K, P) = (K + P2) Mod N де P = 0, 1, 2, ...


Припустимо, що при вставці елементу в хеш таблицю він відображається у кластер, утворений іншими елементами. Якщо елемент відображається в позицію біля початку кластера, то виникне ще кілька конфліктів перш, ніж знайдеться вільна комірка для елемента. У міру зростання параметра P у тестовій функції, значення цієї функції швидко змінюється. Це означає, що позиція, в яку потрапить елемент у кінцевому підсумку, можливо, опиниться далеко від кластеру.


======= 302


На рис. 11.8 показана хеш таблиця, яка містить великий кластер елементів. На ньому також показані тестові послідовності, які виникають при спробі вставити два різних елемента в позиції, займані кластером. Обидві ці тестові послідовності закінчуються в точці, яка не прилягає до кластеру, тому після вставки цих елементів розмір кластера не збільшується.

Наступний код демонструє пошук елемента з використанням квадратичної перевірки (quadratic probing):


Public Function LocateItem (Value As Long, pos As Integer, probes As Integer) As Integer

Dim new_value As Long


probes = 1

pos = (Value Mod m_NumEntries)

Do

new_value = m_HashTable (pos)

'Елемент знайдений.

If new_value = Value Then

LocateItem = HASH_FOUND

Exit Function

End If

'Елементу немає в таблиці.

If new_value = UNUSED Or probes> NumEntries Then

LocateItem = HASH_NOT_FOUND

pos = -1

Exit Function

End If


pos = (Value + probes * probes) Mod NumEntries

probes = probes + 1

Loop

End Function


Програма Quad демонструє відкриту адресацію з використанням квадратичної перевірки. Вона аналогічна програмі Linear, але використовує квадратичну, а не лінійну перевірку.

У табл. 11.3 наведена середня довжина тестових послідовностей, отриманих в програмах Linear і Quad для хеш таблиці з 100 осередками, значення елементів в якої знаходяться в діапазоні від 1 до 999. Квадратична перевірка зазвичай дає кращі результати.


@ Рис. 11.8. Квадратична перевірка


====== 303


@ Таблиця 11.3. Довжина пошуку при використанні лінійної і квадратичної перевірки


Квадратична перевірка також має деякі недоліки. Через способу формування тестової послідовності, не можна гарантувати, що вона обійде всі комірки в таблиці, що означає, що іноді в таблицю не можна буде вставити елемент, навіть якщо вона не заповнена до кінця.

Наприклад, розглянемо невелику хеш таблицю, що складається всього з шести осередків. Тестова послідовність для числа 3 буде наступною:


3

3 + 1 2 = 4 = 4 (Mod 6)

3 + 2 2 = 7 = 1 (Mod 6)

3 + 3 2 = 12 = 0 (Mod 6)

3 + 4 2 = 19 = 1 (Mod 6)

3 + 5 2 = 28 = 4 (Mod 6)

3 + 6 2 = 39 = 3 (Mod 6)

3 + 7 2 = 52 = 4 (Mod 6)

3 + 8 2 = 67 = 1 (Mod 6)

3 + 9 2 = 84 = 0 (Mod 6)

3 + 10 2 = 103 = 1 (Mod 6)

і так далі.


Ця тестова послідовність звертається до позицій 1 і 4 двічі перед тим, як звернутися до позиції 3, і ніколи не потрапляє в позиції 2 та 5. Щоб поспостерігати цей ефект, створіть у програмі Quad хеш таблицю з шістьма осередками, а потім вставте елементи 1, 3, 4, 6 і 9. Програма визначить, що таблиця заповнена повністю, хоча два осередки і залишилися невикористаними. Тестова послідовність для елемента 9 не звертається до елементів 2 і 5, тому програма не може вставити в таблицю новий елемент.


======= 304


Можна показати, що квадратична тестова послідовність буде звертатися, щонайменше, до N / 2 елементів таблиці, якщо розмір таблиці N - просте число. Хоча при цьому гарантується певний рівень продуктивності, все одно можуть виникнути проблеми, якщо таблиця майже заповнена. Оскільки продуктивність для майже заповненої таблиці у будь-якому випадку сильно падає, то можливо краще буде просто збільшити розмір хеш-таблиці, а не турбуватися про те, чи зможе тестова послідовність знайти вільну комірку.

Не настільки очевидна проблема, яка виникає при застосуванні квадратичної перевірки, полягає в тому, що хоча вона усуває первинну кластеризацію, під час неї може виникати схожа проблема, яка називається вторинною кластеризацією (secondary clustering). Якщо два елементи відображаються в одну клітинку, для них буде виконуватися одна і так само тестова послідовність. Якщо безліч елементів відображаються на одну з клітинок таблиці, вони утворюють вторинний кластер, який розподілено за хеш таблиці. Якщо з'являється новий елемент з тим же самим початковим значенням, для нього доводиться виконувати тривалу тестову послідовність, перш ніж він обійде елементи у вторинному кластері.

На рис. 11.9 показана хеш таблиця, яка може містити 10 осередків. У таблиці знаходяться елементи 2, 12, 22 і 32, які все спочатку відображаються в позицію 2. Якщо спробувати вставити в таблицю елемент 42, то потрібно буде виконати тривалу тестову послідовність, яка обійде всі ці елементи, перш ніж знайде вільну комірку.

Псевдослучайная перевірка

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

Один зі способів зробити це полягає у використанні в тестової послідовності генератора псевдовипадкових чисел. Для обчислення тестової послідовності для елемента, його значення використовується для ініціалізації генератора випадкових чисел. Потім для побудови тестової послідовності використовуються послідовні випадкові числа, що отримуються на виході генератора. Це називається псевдовипадковою перевіркою (pseudo random probing).

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


@ Рис. 11.9. Вторинна кластеризація


========== 305


Якщо використовується якісний генератор випадкових чисел, то різні значення елементів будуть давати різні випадкові числа і відповідно різні тестові послідовності. Навіть якщо два значення спочатку відображаються на одну й ту ж клітинку, то наступні позиції в тестової послідовності будуть вже різними. У цьому випадку в хеш таблиці не буде виникати первинна або вторинна кластеризація.

Можна проініціалізувати генератор випадкових чисел Visual Basic, використовуючи початкове число, за допомогою двох рядків коду:


Rnd -1

Randomize seed_value


Оператор Rnd дає одну і ту ж послідовність чисел після ініціалізації одним і тим же початковим числом. Наступний коду показує, як можна виконувати пошук елемента з використанням псевдовипадковою перевірки:


Public Function LocateItem (Value As Long, pos As Integer, _

probes As Integer) As Integer

Dim new_value As Long


'Проініціалізувати генератор випадкових чисел.

Rnd -1

Randomize Value


probes = 1

pos = Int (Rnd * m_NumEntries)

Do

new_value = m_HashTable (pos)


'Елемент знайдений.

If new_value = Value Then

LocateItem = HASH_FOUND

Exit Function

End If


'Елементу немає в таблиці.

If new_value = UNUSED Or probes> NumEntries Then

LocateItem = HASH_NOT_FOUND

pos = -1

Exit Function

End If


pos = Int (Rnd * m_NumEntries)

probes = probes + 1

Loop

End Function


======= 306


Програма Rand демонструє відкриту адресацію з псевдовипадковою перевіркою. Вона аналогічна програмам Linear і Quad, але використовує псевдовипадкову, а не лінійну або квадратичну перевірку.

У табл. 11.4 наведена приблизна середня довжина тестової послідовності, отриманої в програмах Quad або Rand для хеш таблиці з 100 осередками та елементами, значення яких знаходяться в діапазоні від 1 до 999. Зазвичай псевдослучайная перевірка дає найкращі результати, хоча різниця між псевдовипадковою і квадратичної перевірками не так велика, як між лінійної і квадратичної.

Псевдослучайная перевірка також має свої недоліки. Так як тестова послідовність вибирається псевдовипадковою, не можна точно передбачити, наскільки швидко алгоритм обійде всі елементи в таблиці. Якщо таблиця менше, ніж число можливих псевдовипадкових значень, то існує ймовірність того, що тестова послідовність звернеться до одного значення кілька разів до того, як вона вибере інші значення в таблиці. Можливо також, що тестова послідовність буде пропускати яку клітинку в таблиці і не зможе вставити новий елемент, навіть якщо таблиця не заповнена до кінця.

Так само, як і у випадку квадратичної перевірки, ці ефекти можуть викликати труднощі, тільки якщо таблиця майже заповнена. У цьому випадку збільшення таблиці дає набагато більший приріст продуктивності, ніж пошук невикористовуваних елементів таблиці.


@ Рис. 11.4. Довжина пошуку при використанні квадратичної і псевдовипадковою перевірки


======= 307


Видалення елементів

Видалення елементів з хеш таблиці, в якій використовується відкрита адресація, виконується не так просто, як видалення їх з таблиці, використовує зв'язні списки або блоки. Просто видалити елемент з таблиці не можна, так як він може перебувати в тестової послідовності іншого елемента.

Припустимо, що елемент A перебуває в тестовій послідовності елемента B. Якщо видалити з таблиці елемент A, знайти елемент B буде неможливо. Під час пошуку об'єкта B зустрінеться порожня клітинка, яка залишилася після видалення елемента A, тому буде зроблено неправильний висновок про те, що елемент B відсутній у таблиці.

Замість видалення елемента з хеш таблиці можна просто позначити його як віддалений. Можна використовувати цю клітинку пізніше, якщо вона зустрінеться під час виконання вставки нового елемента в таблицю. Якщо позначений елемент зустрічається під час пошуку іншого елемента, він просто ігнорується і тестова послідовність продовжиться.

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

Рехешірованіе

Щоб звільнити видалені елементи з хеш таблиці, можна виконати її рехешірованіе (rehashing) на місці. Щоб цей алгоритм міг працювати, потрібно мати якийсь спосіб для визначення, чи було виконано рехешірованіе елемента. Найпростіший спосіб зробити це - визначити елементи у вигляді структур даних, що містять поле Rehashed.


Type ItemType

Value As Long

Rehashed As Boolean

End Type


Спочатку привласнимо полю Rehashed значення false. Потім виконаємо прохід по таблиці в пошуку осередків, які не позначені як видалені, і для яких ще не було виконано рехешірованіе.

Якщо такий елемент зустрінеться, то виконується його видалення з таблиці і повторне хешування, при цьому виконується звичайна тестова послідовність для елемента. Якщо зустрічається вільна або позначена як віддалена осередок, елемент розміщується в ній, позначається як рехешірованний, і триває перевірка інших елементів, для яких ще не було виконано рехешірованіе.

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


====== 308


Зміна розміру хеш таблиць

Якщо хеш таблиця стає майже заповненою, продуктивність значно падає. У цьому випадку може знадобитися збільшення розміру таблиці, щоб в ній було більше місця для елементів. І навпаки, якщо в таблиці занадто мало осередків, може знадобитися зменшити її, щоб звільнити займану пам'ять. Використовуючи методи, схожі на ті, які використовувалися при рехешірованіі таблиці на місці, можна збільшувати і зменшувати розмір хеш таблиці.

Щоб збільшити хеш таблицю, спочатку розмір масиву, в якому вона знаходиться, збільшується за допомогою оператора Dim Preserve. Потім виконується рехешірованіе таблиці, при цьому елементи можуть займати осередки у створеній вільної області в кінці таблиці. Після завершення рехешірованія таблиця буде готова до використання.

Щоб зменшити розмір таблиці, спочатку визначимо, скільки елементів має міститися в масиві таблиці після зменшення. Потім виконуємо рехешірованіе таблиці, причому елементи розміщуються лише зменшену частину таблиці. Після завершення рехешірованія всіх елементів, розмір масиву зменшується за допомогою оператора ReDim Preserve.

Наступний код демонструє рехешірованіе таблиці з використанням лінійної перевірки. Код для рехешірованія таблиці з використанням квадратичної або псевдовипадковою перевірки виглядає майже так само:


Public Sub Rehash ()

Dim i As Integer

Dim pos As Integer

Dim probes As Integer

Dim Value As Long

Dim new_value As Long


'Помітити всі елементи як нерехешірованние.

For i = 0 To NumEntries - 1

m_HashTable (i). Rehashed = False

Next i

'Пошук нерехешірованних елементів.

For i = 0 To NumEntries - 1

If Not m_HashTable (i). Rehashed Then

Value = m_HashTable (i). Value

m_HashTable (i). Value = UNUSED


If Value <> DELETED And Value <> UNUSED Then

'Виконати тестову послідовність

'Для цього елемента, поки не знайдеться вільна,

'Віддалена або нерехешірованная осередок.

probes = 0

Do

pos = (Value + probes) Mod NumEntries

new_value = m_HashTable (pos). Value

'Якщо осередок вільна або позначена як

'Віддалена, помістити елемент у неї.

If new_value = UNUSED Or _

new_value = DELETED _

Then

m_HashTable (pos). Value = Value

m_HashTable (pos). Rehashed = True

Exit Do

End If

'Якщо осередок не позначена як рехешірованная,

'Поміняти їх місцями і продовжити.

If Not m_HashTable (pos). Rehashed Then

m_HashTable (pos). Value = Value

m_HashTable (pos). Rehashed = True

Value = new_value

probes = 0

Else

probes = probes + 1

End If

Loop

End If

End If

Next i

End Sub


Програма Rehash використовує відкриту адресацію з лінійною перевіркою. Вона аналогічна програмі Linear, але дозволяє також позначати об'єкти як вилучені і виконувати рехешірованіе таблиці.

Резюме

Різні типи хеш таблиць, описані в цьому розділі, мають свої переваги і недоліки.

Для хеш таблиць, які використовують зв'язні списки або блоки можна легко змінювати розмір таблиці і видаляти з її елементи. Використання блоків також дозволяє легко працювати з таблицями на диску, дозволяючи вважати за одне звернення до диска відразу безліч елементів даних. Тим не менш, обидва ці методу є більш повільними, ніж відкрита адресація.

Лінійна перевірка проста і дозволяє досить швидко вставляти і видаляти елементи з таблиці. Застосування впорядкованої лінійної перевірки дозволяє швидше, ніж у випадку невпорядкованою лінійної перевірки, встановити, що елемент відсутній у таблиці. З іншого боку, вставку елементів у таблицю при цьому виконати складніше.

Квадратична перевірка дозволяє уникнути кластеризації, яка характерна для лінійної перевірки, і тому забезпечує більш високу продуктивність. Псевдослучайная перевірка забезпечує ще більш високу продуктивність, тому що при цьому вдається позбутися як первинної, так і від вторинної кластеризації.

У табл. 11.5 наведено переваги та недоліки різних методів хешування.


====== 310


@ Таблиця 11.5. Переваги та недоліки різних методів хешування


Вибір найкращого методу хешування для цього додатка залежить від даних завдання та способів їх використання. При застосуванні різних схем досягаються різні компроміси між займаної пам'яттю, швидкістю і простотою змін. Табл. 11.5 може допомогти вам вибрати найкращий алгоритм для вашої програми.


======= 311


Глава 12. Мережеві алгоритми

У 6 і 7 главах обговорювалися алгоритми роботи з деревами. Дана глава присвячена більш загальній темі мереж. Мережі грають важливу роль у багатьох додатках. Їх можна використовувати для моделювання таких об'єктів, як мережа вулиць, телефонна або електрична мережа, водопровід, каналізація, водостік, мережа авіаперевезень або залізниць. Менш очевидна можливість використання мереж для вирішення таких завдань, як розбивка на райони, складання розкладу методом критичного шляху, планування колективної роботи або розподілу роботи.

Визначення

Як і у визначенні дерев, мережею (network) або графом (graph) називається набір вузлів (nodes), з'єднаних ребрами (edges) або зв'язками (links). Для графа, на відміну від дерева, не визначено поняття батьківського або дочірнього вузла.

З ребрами мережі може бути пов'язано відповідний напрям, тоді в цьому випадку мережа називається орієнтованої мережею (directed network). Для кожної з цим можна також визначити її ціну (cost). Для мережі доріг, наприклад, ціна може бути дорівнює часу, який займе проїзд по відрізку дороги, представленому ребром мережі. У телефонній мережі ціна може дорівнювати коефіцієнту електричних втрат в кабелі, представленому зв'язком. На рис. 12.1 показана невелика орієнтована мережу, в якій числа поруч з ребрами відповідають ціні ребра.

Шляхом (path) між вузлами A і B називається послідовність ребер, яка зв'язує два цих вузла між собою. Якщо між будь-якими двома вузлами мережі є не більше одного ребра, то шлях можна однозначно описати, перерахувавши входять до нього вузли. Так як такий опис простіше уявити наочно, то шляхи по можливості описуються таким чином. На рис. 12.1 шлях, що проходить через вузли B, E, F, G, E і D, з'єднує вузли B і D.

Циклом (cycle) називається шлях який пов'язує вузол з ним самим. Шлях E, F, G, E на рис. 12.1 є циклом. Шлях називається простим (simple), якщо він не містить циклів. Шлях B, E, F, G, E, D не є простим, тому що він містить цикл E, F, G, E.

Якщо існує будь-якої шлях між двома вузлами, то повинен існувати і простий шлях між ними. Цей шлях можна знайти, якщо видалити всі цикли з вихідного шляху. Наприклад, якщо замінити цикл E, F, G, E в дорозі B, E, F, G, E, D на вузол E, то вийде простий шлях B, E, D, що зв'язує вузли B і D.


======= 313


@ Рис. 12.1. Орієнтована мережу з ціною ребер


Мережа називається зв'язковий (connected), якщо між будь-якими двома вузлами існує хоча б один шлях. У орієнтованої мережі не завжди очевидно, чи є мережа зв'язковою. На рис. 12.2 мережа зліва є зв'язковою. Мережа праворуч не є зв'язковий, так як не існує шляху з вузла E у вузол C.

Уявлення мережі

У 6 чолі було описано декілька уявлень дерев. Більшість з них застосовується також і для роботи з мережами. Наприклад, уявлення повними вузлами, списком нащадків (списком сусідів для мереж) або нумерацією зв'язків також можуть використовуватися для зберігання мереж. За описом цих уявлень зверніться до 6 чолі.


@ Рис. 12.2. Зв'язкова (ліворуч) і несвязная (праворуч) мережі


====== 314


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

Крім цього, деякі варіанти представлення ребер можуть спростити роботу з певними типами мереж. Ці уявлення використовують один клас для вузлів і інший - для представлення зв'язків. Застосування класу для зв'язків полегшує роботу з властивостями зв'язків, такими, як ціна зв'язку.

Наприклад, орієнтована мережу з ціною зв'язків може використовувати наступне визначення для класу вузла:


Public Id As Integer 'Номер вузла.

Public Links As Collection 'зв'язку, що ведуть до сусідніх вузлів.


Можна використовувати наступне визначення класу зв'язків:


Public ToNode As NetworkNode 'Вузол на іншому кінці зв'язку.

Public Cost As Integer 'Ціна зв'язку.


Використовуючи ці визначення, програма може знайти зв'язок з найменшою ціною, використовуючи наступний код:


Dim link As NetworkLink

Dim best_link As NetworkLink

Dim best_cost As Integer


best_cost = 32767

For Each link In node.Links

If link.cost <best_cost Then

Set best_link = link

best_cost = link.cost

End If

Next link


Класи node та link часто розширюються для зручності роботи з конкретними алгоритмами. Наприклад, до класу node часто додається прапор Marked. Якщо програма звертається до вузла, то вона встановлює значення поля Marked рівним true, щоб знати, що вузол вже був перевірений.

Програма, що управляє неориентированной мережею, може використовувати трохи інше уявлення. Клас node залишається тим же, що й раніше, але клас link містить посилання на обидва вузла на кінцях зв'язку.


Public Node1 As NetwokNode 'Один з вузлів на кінці зв'язку.

Public Node2 As NetwokNode 'Інший вузол.

Public Cost As Integer 'Ціна зв'язку.


Для неориентированной мережі, попереднє подання використовувало б два об'єкти для представлення кожної зв'язку - по одному для кожного з напрямків зв'язку. У новій версії кожна зв'язок представлена ​​одним об'єктом. Це уявлення досить наочно, тому воно використовується далі в цій главі.


======= 315


Використовуючи це подання, програма NetEdit дозволяє оперувати неорієнтованим мережами з ціною зв'язків. Меню File (Файл) дозволяє завантажувати і зберігати мережі у файлах. Команди в меню Edit (Правка) дозволяють вам вставляти і видаляти вузли та зв'язку. На рис. 12.3 показано вікно програми NetEdit.

Директорія OldSrc \ Ch12 містить програми, які використовують уявлення нумерацією зв'язків. Ці програми трохи складніше зрозуміти, але вони зазвичай працюють швидше. Вони не описані в тексті, але використані в них методи схожі на ті, які застосовувалися в програмах, написаних для 4 версії Visual Basic. Наприклад, обидві програми Src \ Ch12 \ Paths і OldSrc \ Ch12 \ Paths знаходять найкоротший маршрут, використовуючи описаний нижче алгоритм установки міток. Основна відмінність між ними полягає в тому, що перша програма використовує колекції і класи, а друга - псевдоуказателі та подання нумерацією зв'язків.

Оперування вузлами і зв'язками

Корінь дерева - це єдиний вузол, який не має батьків. Можна знайти будь-який вузол в мережі, почавши від кореня і слідуючи за вказівниками на дочірні вузли. Таким чином, вузол представляє підставу дерева. Якщо ввести змінну, яка буде містити покажчик на кореневий вузол, то згодом можна буде отримати доступ до всіх вузлів у дереві.

Мережі не завжди містять вузол, який займає таке особливе положення. У незв'язною мережі може не існувати способу обійти всі вузли зі зв'язків, почавши з одного вузла.

Тому програми, які працюють з мережами, зазвичай містять повний список всіх вузлів в мережі. Програма також може зберігати повний список всіх зв'язків. За допомогою цих списків можна легко виконати будь-які дії над усіма вузлами або зв'язками в мережі. Наприклад, якщо програма зберігає покажчики на вузли та зв'язку в колекціях Nodes та Links, вона може вивести мережу на екран за допомогою наступного методу:


@ Рис. 12.3. Програма NetEdit


======= 316


Dim node As NetworkNode

dim link As NetworkLink

For Each link in links

'Намалювати зв'язок.

:

Next link


For Each node in nodes

'Намалювати вузол.

:

Next node


Програма NetEdit використовує колекції Nodes і Links для виведення мереж на екран.

Обходи мережі

Обхід мережі виконується аналогічно обходу дерева. Можна обходити мережу, використовуючи або обхід в глибину, або обхід в ширину. Обхід у ширину зазвичай схожий на прямий обхід дерева, хоча для мереж можна визначити також зворотний і навіть симетричний обхід.

Алгоритм для виконання прямого обходу двійкового дерева, описаний в 6 чолі, формулюється так:

  1. Звернутися до вузла.

  2. Виконати рекурсивний прямий обхід лівого піддерева.

  3. Виконати рекурсивний прямий обхід правого піддерева.

У дереві між пов'язаними між собою вузлами існує відношення батько нащадок. Так як алгоритм починається з кореневого вузла і завжди виконується зверху вниз, він не звертається двічі ні до одного вузла.

У мережі вузли не обов'язково пов'язані в напрямку зверху вниз. Якщо спробувати застосувати до мережі алгоритм прямого обходу, може виникнути нескінченний цикл.

Щоб уникнути цього, алгоритм повинен позначати вузол після звернення до нього, при цьому при пошуку в сусідніх вузлах, звернення відбувається тільки до вузлів, які ще не були помічені. Після того, як алгоритм завершить роботу, всі вузли в мережі будуть позначені (якщо мережа є зв'язкової). Алгоритм прямого обходу мережі формулюється так:

  1. Помітити вузол.

  2. Звернутися до вузла.

  3. Виконати рекурсивний обхід не помічених сусідніх вузлів.


======== 317


У Visual Basic можна додати прапор Marked до класу NetworkNode.


Public Id As Long

Public Marked As Boolean

Public Links As Collection


Клас NetworkNode може включати відкриту процедуру для обходу мережі, починаючи з цього вузла. Процедура вузла PreorderPrint звертається до всіх непозначених вузлів, які доступні з цього сайту. Якщо мережа є зв'язковий, то при такому обході відбудеться звертання до всіх вузлів мережі.


Public Sub PreorderPrint ()

Dim link As NoworkLink

Dim node As NetworkNode


'Помітити вузол.

Marked = True


'Звернутися до непозначених вузлів.

For Each link In Links

'Знайти сусідній вузол.

If link.Node1 Is Me Then

Set node = link.Node2

Else

Set node = link.Node1

End If


'Визначити, чи потрібне звернення до сусіднього вузла.

If Not node.Marked Then node.PreorderPrint

Next link

End Sub


Так як ця процедура не звертається ні до одного вузла двічі, то колекція обхідних зв'язків не містить циклів і утворює дерево.

Якщо мережа є зв'язковий, то дерево буде оминати усі вузли мережі. Так як це дерево охоплює всі вузли мережі, то воно називається кістяк (spanning tree). На рис. 12.4 показана невелика мережа з кістяк з коренем у вузлі A, яка зображена жирними лініями.

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


====== 318


@ Рис. 12.4. Кістяк


В алгоритмі обходу мережі потрібно спочатку переконатися, що вузол не перевірявся раніше чи він вже не знаходиться в черзі. Для цього ми позначаємо кожен вузол, який поміщається в чергу. Мережева версія цього алгоритму виглядає так:

  1. Помітити перший вузол (який буде коренем кістяка) і додати його в кінець черги.

  2. Повторювати наступні кроки до тих пір, поки черга не спорожніє:

  1. Видалити з черги перший вузол і звернутися до нього.

  2. Для кожного з непозначених сусідніх вузлів, помітити його і додати в кінець черги.

Наступна процедура друкує список вузлів мережі в порядку обходу в ширину:


Public Sub BreadthFirstPrint (root As NetworkNode)

Dim queue As New Collection

Dim node As NetworkNode

Dim neighbor As NetworkNode

Dim link As NetworkLink


'Помістити корінь в чергу.

root.Marked = True

queue.Add root


'Багаторазово поміщати верхній елемент в чергу

'Поки черга не спорожніє.

Do While queue.Count> 0

'Вибрати наступний вузол з черги.

Set node = queue.Item (1)

queue.Remove 1


'Звернутися до вузла.

Print node.Id


'Додати в чергу всі непозначених сусідні вузли.

For Each link In node.Links

'Знайти сусідній вузол.

If link.Node1 Is Me Then

Set neighbor = link.Node2

Else

Set neighbor = link.Node1

End If


'Перевірити, чи потрібно звернення до сусіднього вузла.

If Not neighbor.Marked Then queue.Add neighbor

Next link

Loop

End Sub


Найменші кістяк

Якщо задана мережа з ціною зв'язків, то найменшим кістяк (minimal spanning tree) називається кістяк, у якому сумарна ціна всіх зв'язків у дереві буде найменшою. Найменша кістяк можна використовувати, щоб зв'язати всі вузли в мережі шляхом з найменшою ціною.

Наприклад, припустимо, що потрібно розробити телефонну мережу, яка має з'єднати шість міст. Можна прокласти магістральний кабель між усіма парами міст, але це буде невиправдано дорого. Меншу вартість буде мати рішення, при якому міста будуть з'єднані зв'язками, які містяться в найменшому кістяк. На рис. 12.5 показано шість міст, кожні два з яких з'єднані магістральним кабелем. Жирними лініями намальовано найменше кістяк.

Зауважте, що мережа може мати декілька найменших остовних дерев. На рис. 12.6 показані два зображення мережі з двома різними найменшими остовних деревами, які намальовані жирними лініями. Повна ціна обох дерев дорівнює 32.


@ Рис. 12.5. Магістральні телефонні кабелі, які зв'язують шість міст


======== 320


@ Рис. 12.6. Два різних найменших остовних дерева для однієї мережі


Існує простий алгоритм пошуку найменшого кістяка для мережі. Спочатку помістимо в кістяк будь-який вузол. Потім знайдемо зв'язок з найменшою ціною, яка з'єднує вузол в дереві з вузлом, який ще не поміщений в дерево. Додамо цей зв'язок і відповідний вузол в дерево. Потім ця процедура повторюється до тих пір, поки всі вузли не опиняться в дереві.

Цей алгоритм схожий на евристику сходження на пагорб, описану в 8 чолі. На кожному кроці обидва алгоритму змінюють рішення, намагаючись його максимально поліпшити. Алгоритм кістяка на кожному кроці вибирає зв'язок з найменшою ціною, яка додає новий вузол у дерево. На відміну від евристики сходження на пагорб, яка не завжди знаходить найкраще рішення, цей алгоритм гарантовано знаходить найменше кістяк.

Подібні алгоритми, які знаходять глобальний оптимум, за допомогою серії локально оптимальних наближень називаються поглинаючими алгоритмами (greedy algorithms). Можна уявляти собі поглинають алгоритми як алгоритми типу сходження на пагорб, що не є при цьому евристиками - вони гарантовано знаходять найкраще можливе рішення.

Алгоритм найменшого кістяка використовує колекцію для зберігання списку зв'язків, які можуть бути додані до кістяка. Спочатку алгоритм поміщає в цей список зв'язку кореневого вузла. Потім проводиться пошук зв'язку з найменшою ціною в цьому списку. Щоб максимально прискорити пошук, програма може використовувати пріоритетну чергу типу описаної в 9 розділі. Або навпаки, щоб спростити реалізацію, програма може використовувати для зберігання списку можливих зв'язків колекцію.

Якщо вузол на іншому кінці зв'язку ще не перебуває в кістяк, то програма додає його і відповідну зв'язок в дерево. Потім вона додає зв'язку, що виходять з нового вузла, до списку можливих вузлів.

Алгоритм використовує прапор Used в класі link, щоб визначити, потрапляла чи цей зв'язок раніше до списку можливих зв'язків. Якщо так, то вона не заноситься до цього списку знову.

Може виявитися, що список можливих зв'язків спорожніє до того, як всі вузли будуть додані в кістяк. У цьому випадку мережа є незв'язною, і не існує шлях, який пов'язує кореневий вузол з усіма іншими вузлами мережі.


========= 321


Private Sub FindSpanningTree (root As SpanNode)

Dim candidates As New Collection

Dim to_node As SpanNode

Dim link As SpanLink

Dim i As Integer

Dim best_i As Integer

Dim best_cost As Integer

Dim best_to_node As SpanNode


If root Is Nothing Then Exit Sub

'Скинути прапор Marked для всіх вузлів і прапори

'Used і InSpanningTree для всіх зв'язків.

ResetSpanningTree


'Почати з кореня кістяка.

root.Marked = True

Set best_to_node = root


Do

'Додати зв'язку останнього вузла до списку

'Можливих зв'язків.

For Each link In best_to_node.Links

If Not link.Used Then

candidates.Add link

link.Used = True

End If

Next link


'Знайти найкоротшу зв'язок в списку можливих

'Язків, яка веде до вузла, якого ще немає

'В дереві.

best_i = 0

best_cost = INFINITY

i = 1

Do While i <= candidates.Count

Set link = candidates (i)

If link.Node1.Marked Then

Set to_node = link.Node2

Else

Set to_node = link.Node1

End If

If to_node.Marked Then

Зв'язок з'єднує два вузли, які

'Обидва знаходяться в дереві.

'Видалити її зі списку можливих зв'язків.

candidates.Remove i

Else

If link.Cost <best_cost Then

best_i = i

best_cost = link.Cost

Set best_to_node = to_node

End If

i = i + 1

End If

Loop

'Якщо більше не залишилося зв'язків, які можна

'Було б додати, то ми зробили все, що могли.

If best_i <1 Then Exit Do


'Додати найкращу зв'язок і вузол на її кінці в дерево.

Set link = candidates (best_i)

link.InSpanningTree = True

candidates.Remove best_i

best_to_node.Marked = True

Loop


GotSpanningTree = True

'Перемалювати мережу.

DrawNetwork

End Sub


Цей алгоритм перевіряє кожну зв'язок не більше одного разу. При перевірці кожної зв'язку, вона додається до списку можливих зв'язків, а потім віддаляється з нього. Якщо цей список знаходиться у пріоритетній черзі на основі пірамід, то для вставки або видалення елемента з черги буде потрібно час порядку O (log (N)), де - число зв'язків у мережі. У цьому випадку повне час виконання алгоритму буде порядку O (N * log (N)).

Якщо список можливих зв'язків знаходиться в колекції, як у наведеному вище коді, то для пошуку в списку зв'язку з найменшою ціною буде потрібен час порядку O (N), при цьому повне час виконання алгоритму буде порядку O (N 2). Для малих N продуктивність буде прийнятною. Якщо ж число зв'язків у мережі дуже багато, то список можливих зв'язків слід зберігати в пріоритетною черзі, а не в колекції.

Програма Span використовує цей алгоритм для пошуку найменшого кістяка. Ця програма аналогічна програмі NetEdit. Вона дозволяє завантажувати, редагувати і зберігати на диску файли, що представляють мережу. Якщо вибрати який або вузол у програмі подвійним клацанням миші, то програма знайде й виведе на екран найменше кістяк з коренем в цьому вузлі. На рис. 12.7 показано вікно програми Span, в якому показано найменше кістяк з коренем у вузлі 9.


====== 322-323


@ Рис. 12.7. Програма Span


Найкоротший маршрут

Алгоритми пошуку найкоротшого маршруту, які обговорюються в наступних розділах, знаходять все найкоротші шляхи з заданої точки до всіх інших точок мережі, при цьому передбачається, що мережа є зв'язаною. Набір зв'язків, використовуваний усіма найкоротшими маршрутами, називається деревом найкоротшого маршруту (shortest path tree).

На рис. 12.8 показано дерево, в якому дерево найкоротшого маршруту з коренем у вузлі A намальовано жирною лінією. Це дерево зображує найкоротший маршрут з вузла A до всіх інших вузлів у мережі. Наприклад, найкоротший маршрут з вузла A у вузол F проходить через вузли A, C, E, F.

Багато алгоритми пошуку найкоротшого маршруту починають з порожнього дерева, до якого потім додається по одній зв'язку до тих пір, поки дерево не буде заповнено. Ці алгоритми можна розбити на дві категорії у відповідності зі способом вибору наступній зв'язку, яка повинна бути додана до зростаючого дереву найкоротшого маршруту.

Алгоритми установки міток (label setting) завжди вибирають зв'язок, яка гарантовано виявиться частиною кінцевого найкоротшого маршруту. Цей метод працює аналогічно методу пошуку найменшого кістяка. Якщо зв'язок додана в дерево, то вона не буде видалена пізніше.

Алгоритми корекції міток (label correcting) додають зв'язку, які можуть бути або не бути частиною кінцевого найкоротшого маршруту. У процесі раби алгоритму він може визначити, що на місце вже перебуває в дереві зв'язку потрібно помістити інший зв'язок. У цьому випадку алгоритм замінює стару зв'язок нової і продовжує роботу. Заміна зв'язку в дереві може зробити можливими шляхи, які не були можливі до цього. Щоб перевірити ці шляхи, алгоритму доводиться знову перевірити шляхи, які були додані в дерево раніше і використовували віддалену зв'язок.


===== 324


@ Рис. 12.8. Дерево найкоротшого маршруту


Алгоритми установки і корекції міток, описані в наступних розділах, використовують схожі класи для подання вузлів і зв'язків. Клас вузла включає поле Dist, яке визначає відстань від кореня до вузла в зростаючому дереві найкоротшого маршруту. В алгоритмі установки міток, після вставки вузла в дерево полю Dist присвоюється правильне значення, і воно надалі не змінюється. В алгоритмі корекції міток, значення поля Dist може знадобитися оновити, якщо алгоритм замінить зв'язок.

Клас вузла також включає поле NodeStatus, яке вказує, чи знаходиться вузол в дереві найкоротшого маршруту, списку можливих зв'язків, або ні в одній із цих структур. Поле InLink вказує на зв'язок, яка веде до вузла в дереві найкоротшого маршруту.


Public Id As Integer

Public X As Single

Public Y As Single

Public Links As Collection

Public Dist As Integer 'Відстань від кореня дерева шляху.

Public NodeStatus As Integer 'Статус дерева маршруту.

Public InLink As PathSLink Зв'язок, ведуча до вузла.


====== 325


Використовуючи поле InLink, програма може перерахувати вузли в дорозі від кореня до вузла I в зворотному порядку за допомогою наступного коду:


Dim node As PathSNode


Set node = I

Do

'Вивести вузол.

Print node.Id

If node Is Root Then Exit Do


'Перейти до наступного вузла вгору по дереву.

If node.IsLink.Node1 Is node Then

Set node = node.InLink.Node2

Else

Set node = node.InLink.Node1

End If

Loop


Клас link в алгоритмі включає поле InPathTree, яке вказує, чи є зв'язок частиною дерева найкоротшого маршруту.


Public Node1 As PathSNode

Public Node2 As PathSNode

Public Cost As Integer

Public InPathTree As Boolean


Обидва алгоритми установки і корекції міток використовують список можливих зв'язків, в якому знаходяться зв'язку, які можуть бути додані в дерево найкоротшого маршруту, але вони по різному оперують цим списком. Алгоритм установки міток завжди вибирає зв'язок, яка обов'язково виявиться частиною дерева найкоротшого маршруту. Алгоритм корекції міток вибирає елемент, який знаходиться на вершині списку.

Встановлення міток

На початку цього алгоритму значення поля Dist кореневого вузла встановлюється рівним 0. Потім кореневий вузол міститься у список можливих вузлів, при цьому значення поля NodeStatus цього вузла приймає значення NOW_IN_LIST, вказуючи на те, що він знаходиться в списку.

Після цього виконується пошук у списку вузла з найменшим значенням Dist. Спочатку це буде кореневий вузол, тому що він єдиний у списку.

Потім алгоритм видаляє цей вузол зі списку, і встановлює значення поля NodeStatus для цього вузла рівним WAS_IN_LIST, вказуючи на те, що цей вузол тепер є частиною дерева найкоротшого маршруту. Поля Dist і IsLink вузла вже мають правильні значення. Для кожного кореневого вузла, значення поля IsLink одно Nothing, а значення поля Dist дорівнює нулю.

Після цього алгоритм перевіряє всі зв'язки, що виходять з обраного вузла. Якщо сусідній вузол на іншому кінці зв'язку ніколи не перебував у списку можливих вузлів, то алгоритм додає його до списку. Він встановлює значення поля NodeStatus сусіднього вузла рівним NOW_IN_LIST., А значення поля Dist - відстані від кореневого вузла до вибраного вузла плюс ціною зв'язку. І, нарешті, він привласнює значення полю InLink сусіднього вузла так, щоб воно вказувало на зв'язок з сусіднім вузлом.


======== 326


Під час перевірки алгоритмом зв'язків, що виходять з обраного вузла, якщо значення поля NodeStatus сусіднього вузла одно NOW_IN_LIST, то цей вузол вже знаходиться в списку можливих вузлів. Алгоритм перевіряє поточне значення Dist сусіднього вузла, перевіряючи, чи не буде шлях через вибраний вузол коротше. Якщо це так, то він оновлює поля InLink і Dist сусіднього вузла і залишає сусідній вузол у списку можливих вузлів.

Алгоритм повторює цей процес, видаляючи вузли зі списку можливих вузлів, перевіряючи сусідні з ними вузли і додаючи сусідні вузли до списку до тих пір, поки список не спорожніє.

На рис. 12.9 показана частина дерева найкоротшого маршруту. У цій точці алгоритм перевірив вузли A і B, вилучив його зі списку можливих вузлів, і перевірив їх зв'язку. Вузли A і B вже додані до дерева найкоротшого маршруту, і тепер у списку можливих вузлів знаходяться вузли C, D і E. Жирні стріл

Додати в блог або на сайт

Цей текст може містити помилки.

Програмування, комп'ютери, інформатика і кібернетика | Реферат
890.8кб. | скачати


Схожі роботи:
Створення клієнтської програми для користування базою данних MS ACCESS в Delphi 4 0
Програмування в Delphi
Програмування Delphi
Програмування в Delphi
Середовище програмування DELPHI 2 0 2
Середовище програмування DELPHI 2 0
Середовище програмування DELPHI 20
Середовище програмування Delphi
Алгоритми і структури даних Програмування у Cі
© Усі права захищені
написати до нас