Структуры данных в C++
Содержание
Предисловие
Глава 1. Введение
1.2. Классы C++ и абстрактные типы
Передача сообщений
1.3. Объекты в приложениях C++
1.4. Разработка объектов
C++ геометрические классы
Объекты и наследование
Наследование в программировании
Упорядоченные списки и наследование
Повторное использование кода
Спецификации класса SeqList и OrderedList
1.5. Приложения с наследованием классов
1.6. Разработка объектно-ориентированных программ
Разработка
Кодирование
Тестирование
Иллюстрация программной разработки: Dice график
1.7. Тестирование и сопровождение программы
Тестирование управляющего модуля
Программное сопровождение и документирование
1.8. Язык программирования C++
1.9. Абстрактные базовые классы и полиморфизм
Письменные упражнения
Глава 2. Базовые типы данных
Данные в памяти
Представление целых в языке C++
2.2. Символьные типы
2.3. Вещественные типы
2.4. Типы перечисления
2.5. Указатели
Значения указателя
Встроенный тип массива C++
Сохранение одномерных массивов
Границы массива
Двумерные массивы
Сохранение двумерных массивов
2.7. Строковые константы и переменные
Приложение: перестановка имен
2.8. Записи
2.9. Файлы
2.10. Приложения массива и записи
Обменная сортировка
Подсчет зарезервированных слов C++
Письменные упражнения
Глава 3. Абстрактные типы данных и классы
Конструктор
Объявление объекта
Реализация класса
Реализация конструктора
Создание объектов
3.2. Примеры классов
Класс случайных чисел
3.3. Объекты и передача информации
Объект как параметр функции
3.4. Массивы объектов
3.5. Множественные конструкторы
3.6. Практическое применение: Треугольные матрицы
Класс TriMat
Письменные упражнения
Глава 4. Классы коллекций
Коллекции с последовательным доступом
Универсальная индексация
4.2. Описание нелинейных коллекций
4.3. Анализ алгоритмов
Общий порядок величин
4.4. Последовательный и бинарный поиск
4.5. Базовый класс последовательного списка
Письменные упражнения
Глава 5. Стеки и очереди
5.2. Класс Stack
5.3. Оценка выражений
Применение: постфиксный калькулятор
5.4. Очереди
5.5. Класс Queue
5.6. Очереди приоритетов
Приложение: службы поддержки компании
5.7. Практическое применение: Управляемое событиями моделирование
Информация моделирования
Установка параметров моделирования
Выполнение задачи моделирования
Письменные упражнения
Глава 6. Абстрактные операторы
Члены класса
Дружественные функции
6.2. Система рациональных чисел
Арифметика рациональных чисел
Преобразование рациональных чисел
6.3. Класс Rational
6.4. Операторы класса Rational как функции-члены
6.5. Операторы потока класса Rational как дружественные функции
6.6. Преобразование рациональных чисел
Преобразование из объектного типа
6.7. Использование рациональных чисел
Письменные упражнения
Глава 7. Параметризованные типы данных
7.2. Шаблонные классы
Объявление объектов шаблонного класса
Определение методов шаблонного класса
7.3. Шаблонные списковые классы
7.4. Вычисление инфиксного выражения
Письменные упражнения
Глава 8. Классы и динамическая память
Динамическое выделение массива
Оператор delete освобождения памяти
8.2. Динамически создаваемые объекты
8.3. Присваивание и инициализация
Перегруженный оператор присваивания
Указатель this
Проблемы инициализации
Создание конструктора копирования
8.4. Надежные массивы
Выделение памяти для класса Array
Проверка границ массива и перегруженный оператор []
Преобразование объекта в указатель
Использование класса Array
8.5. Класс String
8.6. Сопоставление с образцом
Алгоритм сопоставления с образцом
Анализ алгоритма сопоставления с образцом
8.7. Целочисленные множества
Побитовые операторы C++
Представление элементов множества
Решето Эратосфена
Письменные упражнения
Глава 9. Связанные списки
Обзор главы
9.1. Класс Node
Реализация класса Node
9.2. Создание связанных списков
Вставка узла: InsertFront
Прохождение по связанному списку
Вставка узла: InsertRear
Приложение: Список выпускников
Создание упорядоченного списка
Приложение: сортировка со связанными списками
9.3. Разработка класса связанного списка
Операции связанных списков
9.4. Класс LinkedList
Сортировка списка
9.5. Реализация класса LinkedList
9.6. Реализация коллекций со связанными списками
Реализация методов Queue
Использование объекта LinkedList с классом SeqList
Реализация методов доступа к данным класса SeqList
Приложение: Сравнение реализаций SeqList
9.7. Исследовательская задача: Буферизация печати
Разработка программы
Реализация метода UPDATE для класса Spooler
Методы оценки системы буферизации печати
9.8. Циклические списки
Приложение: Решение задачи Джозефуса
9.9. Двусвязные списки
Реализация класса DNode
9.10. Практическая задача: Управление окнами
Реализация класса WindowList
Письменные упражнения
Глава 10. Рекурсия
Рекурсивные задачи
10.2. Построение рекурсивных функций
10.3. Рекурсивный код и стек времени исполнения
10.4. Решение задач с помощью рекурсии
Комбинаторика: задача о комитетах
Комбинаторика: перестановки
Прохождение лабиринта
Реализация класса Maze
10.5. Оценка рекурсии
Письменные упражнения
Глава 11. Деревья
Бинарные деревья
11.1. Структура бинарного дерева
Построение бинарного дерева
11.2. Разработка функций класса TreeNode
Симметричный метод прохождения дерева
11.3. Использование алгоритмов прохождения деревьев
Приложение: печать дерева
Приложение: копирование и удаление деревьев
Приложение: вертикальная печать дерева
11.4. Бинарные деревья поиска
Операции на бинарном дереве поиска
Объявление абстрактного типа деревьев
11.5. Использование бинарных деревьев поиска
11.6. Реализация класса BinSTree
11.7. Практическая задача: конкорданс
Письменные упражнения
Глава 12. Наследование и абстрактные классы
12.2. Наследование в C++
Что нельзя наследовать
12.3. Полиморфизм и виртуальные функции
Приложение: геометрические фигуры и виртуальные методы
Виртуальные методы и деструктор
12.4. Абстрактные базовые классы
Образование класса SeqList из абстрактного базового класса List
12.5. Итераторы
Образование итераторов для списка
Построение итератора SeqList
Итератор массива
Приложение: слияние сортированных последовательностей
Реализация класса Arraylterator
12.6. Упорядоченные списки
12.7. Разнородные списки
Разнородные связанные списки
Письменные упражнения
Глава 13. Более сложные нелинейные структуры
13.2. Пирамиды
Класс Heap
13.3. Реализация класса Heap
13.4. Очереди приоритетов
13.5. AVL-деревья
13.6. Класс AVLTree
Оценка сбалансированных деревьев
13.7. Итераторы деревьев
Реализация класса Inorderlterator
Приложение: алгоритм TreeSort
13.8. Графы
13.9. Класс Graph
Реализация класса Graph
Способы прохождения графов
Приложения
Достижимость и алгоритм Уоршалла
Письменные упражнения
Глава 14. Организация коллекций
Сортировка методом пузырька
Вычислительная сложность сортировки методом пузырька
Сортировка вставками
14.2. \
Алгоритм Quicksort
Сравнение алгоритмов сортировки массивов
14.3. Хеширование
Хеш-функции
Другие методы хеширования
Разрешение коллизий
14.4. Класс хеш-таблиц
Реализация класса HashTable
Реализация класса HashTablelterator
14.5. Производительность методов поиска
14.6. Бинарные файлы и операции с данными на внешних носителях
Класс BinFile
Внешний поиск
Внешняя сортировка
Сортировка естественным слиянием
14.7. Словари
Упражнения по программированию
Приложение
Предметный указатель
Index
Text
                    СТРУКТУРЫ ДАННЫХ
в C++
Уильям Топп
Уильям Форд
БИНОМ


Data Structures with C++ William Ford University of the Pacific William Topp University of the Pacific Prentice-Hall International, Inc.
Уильям Топп, Уильям Форд Структуры данных в C++ Перевод с английского под редакцией В. Кузьменко Москва ЗАО «Издательство БИНОМ» 199 9
УДК 004.422 ББК 32.973 Т58 Уильям Топп, Уильям Форд. Структуры данных в C++: Пер. с англ. — М.: ЗАО «Издательство БИНОМ», 1999. - 816 с: ил. В книге на основе так называемых абстрактных структур данных (ADT) рассматриваются как встроенные, так и определяемые пользователем типы данных в языке C++. Подробно излагаются вопросы организации структур данных для эффективной их обработки методами сортировки и поиска информации, построением стеков, очередей и деревьев. Книга будет интересна всем категориям программистов — от начинающих до профессионалов. Все права защищены. Никакая часть этой книги не может быть воспроизведена в любой форме или любыми средствами, электронными или механическими, включая фотографирование, магнитную запись или иные средства копирования или сохранения информации без письменного разрешения издательства. ISBN 5-7989-0017-7 (рус.) ISBN 0-13-320938-5 (англ.) Authorized translation from the English language edition. © Original copyright. Prentice Hall, Inc. A Simon & Schuster Company © Издание на русском языке ЗАО «Издательство БИНОМ*, 1999.
Содержание Предисловие 13 Глава 1. Введение 19 1.1. Абстрактные типы данных 20 ADT — формат Г 21 1.2. Классы C++ и абстрактные типы 24 Инкапсуляция и скрытие информации 24 Передача сообщений 25 1.3. Объекты в приложениях C++ 25 Приложение: класс Circle 25 1.4. Разработка объектов . 28 Объекты и композиция 28 C++ геометрические классы 30 Объекты и наследование 30 Наследование в программировании . 31 Упорядоченные списки и наследование 34 Повторное использование кода 35 Спецификации класса SeqList и OrderedList 36 1.5. Приложения с наследованием классов 37 1.6. Разработка объектно-ориентированных программ 38 Анализ задачи/определение программы 39 Разработка 39 Кодирование 40 Тестирование 40 Иллюстрация программной разработки: Dice график 40 1.7. Тестирование и сопровождение программы 45 Объектное тестирование 45 Тестирование управляющего модуля 45 Программное сопровождение и документирование 46 1.8. Язык программирования C++ 47 1.9. Абстрактные базовые классы и полиморфизм 48 Полиморфизм и динамическое связывание 48 Письменные упражнения 50 Глава 2. Базовые типы данных 53 2.1. Целочисленные типы 54 Компьютерное хранение целых чисел 56 Данные в памяти 57 Представление целых в языке C++ 58 2.2. Символьные типы 58 Символы ASCII 58 2.3. Вещественные типы 60 Представление вещественных чисел 60 2.4. Типы перечисления 62 Реализация типов перечисления C++ 62 2.5. Указатели 63
Указатели ADT 63 Значения указателя 65 2.6. Массив (array) 65 Встроенный тип массива C++ 66 Сохранение одномерных массивов 66 Границы массива 67 Двумерные массивы 68 Сохранение двумерных массивов 69 2.7. Строковые константы и переменные 71 Строки C++ 73 Приложение: перестановка имен 74 2.8. Записи 76 Структуры C++ 77 2.9. Файлы 77 Иерархия потоков C++ 80 2.10. Приложения массива и записи 82 Последовательный поиск 82 Обменная сортировка 85 Подсчет зарезервированных слов C++ 87 Письменные упражнения 90 Упражнения по программированию 96 Глава 3. Абстрактные типы данных и классы 99 3.1. Пользовательский тип — КЛАСС 100 Объявление класса 100 Конструктор 101 Объявление объекта 102 Реализация класса 102 Реализация конструктора 103 Создание объектов 104 3.2. Примеры классов 107 Класс Temperature 108 Класс случайных чисел 110 3.3. Объекты и передача информации 114 Объект как возвращаемое значение 115 Объект как параметр функции 115 3.4. Массивы объектов 116 Конструктор умолчания 117 3.5. Множественные конструкторы 117 3.6. Практическое применение: Треугольные матрицы 120 Свойства верхней треугольной матрицы 121 Класс TriMat 124 Письменные упражнения 129 Упражнения по программированию 133 Глава 4. Классы коллекций 143 4.1. Описание линейных коллекций 146 Коллекции с прямым доступом 147 Коллекции с последовательным доступом 148 Универсальная индексация 151 4.2. Описание нелинейных коллекций 152
Коллекции групп 153 4.3. Анализ алгоритмов 155 Критерии эффективности 155 Общий порядок величин 159 4.4. Последовательный и бинарный поиск 161 Бинарный поиск 162 4.5. Базовый класс последовательного списка 166 Методы модификации списка 169 Письменные упражнения 175 Упражнения по программированию 178 Глава 5. Стеки и очереди 181 5.1. Стеки 182 5.2. Класс Stack 184 5.3. Оценка выражений 193 Постфиксная оценка 194 Применение: постфиксный калькулятор 195 5.4. Очереди 198 5.5. Класс Queue 201 5.6. Очереди приоритетов 212 Класс PQueue 214 Приложение: службы поддержки компании 217 5.7. Практическое применение: Управляемое событиями моделирование 220 Разработка приложения 221 Информация моделирования 224 Установка параметров моделирования 226 Выполнение задачи моделирования 226 Письменные упражнения 232 Упражнения по программированию 236 Глава 6. Абстрактные операторы 239 6.1. Описание перегрузки операторов 241 Определяемые пользователем внешние функции 241 Члены класса 242 Дружественные функции 244 6.2. Система рациональных чисел 245 Представление рациональных чисел 245 Арифметика рациональных чисел 246 Преобразование рациональных чисел 247 6.3. Класс Rational 247 6.4. Операторы класса Rational как функции-члены 249 Реализация операторов класса Rational 249 6.5. Операторы потока класса Rational как дружественные функции . 250 Реализация операторов потока класса Rational 251 6.6. Преобразование рациональных чисел 252 Преобразование в объектный тип 252 Преобразование из объектного типа 253 6.7. Использование рациональных чисел 254
Письменные упражнения 258 Упражнения по программированию 265 Глава 7. Параметризованные типы данных 269 7.1. Шаблонные функции . . . « 270 Сортировка на базе шаблона 273 7.2. Шаблонные классы 273 Определение шаблонного класса 274 Объявление объектов шаблонного класса 274 Определение методов шаблонного класса 274 7.3. Шаблонные списковые классы 276 7.4. Вычисление инфиксного выражения 277 Письменные упражнения 285 Упражнения по программированию 286 Глава 8. Классы и динамическая память 289 8.1. Указатели и динамические структуры данных 291 Оператор new для выделения памяти 292 Динамическое выделение массива 292 Оператор delete освобождения памяти 293 8.2. Динамически создаваемые объекты 293 Освобождение данных объекта: деструктор 295 8.3. Присваивание и инициализация 297 Проблемы присваивания 297 Перегруженный оператор присваивания 299 Указатель this 300 Проблемы инициализации 300 Создание конструктора копирования 301 8.4. Надежные массивы 303 Класс Array 303 Выделение памяти для класса Array 305 Проверка границ массива и перегруженный оператор [] 306 Преобразование объекта в указатель 307 Использование класса Array 309 8.5. Класс String 310 Реализация класса String 315 8.6. Сопоставление с образцом 320 Процесс Find 320 Алгоритм сопоставления с образцом 321 Анализ алгоритма сопоставления с образцом 325 8.7. Целочисленные множества 325 Множества целочисленных типов 326 Побитовые операторы C++ 327 Представление элементов множества 329 Решето Эратосфена 332 Письменные упражнения 336 Упражнения по программированию 345 Глава 9. Связанные списки 349 Описание связанного списка 351 Обзор главы 352
9.1. Класс Node 353 Объявление типа Node 353 Реализация класса Node 356 9.2. Создание связанных списков 358 Создание узла 358 Вставка узла: InsertFront 358 Прохождение по связанному списку 359 Вставка узла: InsertRear 361 Приложение: Список выпускников 365 Создание упорядоченного списка 367 Приложение: сортировка со связанными списками 369 9.3. Разработка класса связанного списка 371 Данные-члены связанных списков 371 Операции связанных списков 372 9.4. Класс LinkedList 374 Конкатенация двух списков 377 Сортировка списка 377 9.5. Реализация класса LinkedList 381 9.6. Реализация коллекций со связанными списками 388 Связанные очереди 389 Реализация методов Queue 390 Использование объекта LinkedList с классом SeqList 391 Реализация методов доступа к данным класса SeqList 392 Приложение: Сравнение реализаций SeqList 392 9.7. Исследовательская задача: Буферизация печати 394 Анализ проблемы 394 Разработка программы 395 Реализация метода UPDATE для класса Spooler 397 Методы оценки системы буферизации печати 398 9.8. Циклические списки 400 Реализация класса CNode 402 Приложение: Решение задачи Джозефуса 403 9.9. Двусвязные списки 406 Приложение: Сортировка двусвязного списка 408 Реализация класса DNode 410 9.10. Практическая задача: Управление окнами 411 Список окон 412 Реализация класса WindowList 415 Письменные упражнения 418 Упражнения по программированию 426 Глава 10. Рекурсия 431 10.1. Понятие рекурсии 432 Рекурсивные определения 433 Рекурсивные задачи 435 10.2. Построение рекурсивных функций 439 10.3. Рекурсивный код и стек времени исполнения 443 Стек времени исполнения 444 10.4. Решение задач с помощью рекурсии 445 Бинарный поиск 446 Комбинаторика: задача о комитетах 448 Комбинаторика: перестановки 451
Прохождение лабиринта 460 Реализация класса Maze 463 10.5. Оценка рекурсии 466 Письменные упражнения 470 Упражнения по программированию 473 Глава 11. Деревья 477 Терминология деревьев 479 Бинарные деревья 480 11.1. Структура бинарного дерева 483 Проектирование класса TreeNode 483 Построение бинарного дерева 485 11.2. Разработка функций класса TreeNode 487 Рекурсивные методы прохождения деревьев 489 Симметричный метод прохождения дерева 489 11.3. Использование алгоритмов прохождения деревьев 492 Приложение: посещение узлов дерева 492 Приложение: печать дерева 493 Приложение: копирование и удаление деревьев 495 Приложение: вертикальная печать дерева 500 11.4. Бинарные деревья поиска 503 Ключ в узле бинарного дерева поиска 505 Операции на бинарном дереве поиска 506 Объявление абстрактного типа деревьев 507 11.5. Использование бинарных деревьев поиска 510 Дублированные узлы 513 11.6. Реализация класса BinSTree 515 Операции обработки списков 516 11.7. Практическая задача: конкорданс 525 Письменные упражнения 529 Упражнения по программированию 536 Глава 12. Наследование и абстрактные классы 539 12.1. Понятие о наследовании 540 Терминология наследования 542 12.2. Наследование в C++ 543 Конструкторы и производные классы 544 Что нельзя наследовать 550 12.3. Полиморфизм и виртуальные функции 550 Демонстрация полиморфизма 553 Приложение: геометрические фигуры и виртуальные методы . . .556 Виртуальные методы и деструктор 558 12.4. Абстрактные базовые классы 559 Абстрактный базовый класс List 560 Образование класса SeqList из абстрактного базового класса List . 561 12.5. Итераторы 563 Абстрактный базовый класс Iterator 564 Образование итераторов для списка 564 Построение итератора SeqList 565 Итератор массива 569 Приложение: слияние сортированных последовательностей 570
Реализация класса Arraylterator 574 12.6. Упорядоченные списки 575 12.7. Разнородные списки 579 Разнородные массивы 579 Разнородные связанные списки 581 Письменные упражнения 586 Упражнения по программированию 595 Глава 13. Более сложные нелинейные структуры 599 13.1. Бинарные деревья, представляемые массивами 600 Приложение: турнирная сортировка 602 13.2. Пирамиды 607 Пирамида как список 607 Класс Heap 609 13.3. Реализация класса Heap 612 Приложение: пирамидальная сортировка 618 13.4. Очереди приоритетов 621 Приложение: длинные последовательности 622 13.5. AVL-деревья 627 Узлы AVL-дерева 628 13.6. Класс AVLTree 631 Распределение памяти для AVLTree 633 Оценка сбалансированных деревьев 640 13.7. Итераторы деревьев 642 Итератор симметричного метода прохождения 643 Реализация класса Inorderlterator . 644 Приложение: алгоритм TreeSort 646 13.8. Графы 647 Связанные компоненты 648 13.9. Класс Graph 649 Объявление абстрактного типа данных Graph 649 Реализация класса Graph 653 Способы прохождения графов 656 Приложения 659 Достижимость и алгоритм Уоршалла 666 Письменные упражнения 669 Упражнения по программированию 678 Глава 14. Организация коллекций 683 14.1. Основные алгоритмы сортировки массивов 684 Сортировка посредством выбора 684 Сортировка методом пузырька 686 Вычислительная сложность сортировки методом пузырька 688 Сортировка вставками 688 14.2. "Быстрая сортировка" 690 Описание "быстрой сортировки" 690 Алгоритм Quicksort 693 Сравнение алгоритмов сортировки массивов 696 14.3. Хеширование 700 Ключи и хеш-функция 701 Хеш-функции 702
Другие методы хеширования ". . 704 Разрешение коллизий 704 14.4. Класс хеш-таблиц 707 Приложение: частота символьных строк 709 Реализация класса HashTable 711 Реализация класса HashTablelterator 712 14.5. Производительность методов поиска 714 14.6. Бинарные файлы и операции с данными на внешних носителях 715 Бинарные файлы 716 Класс BinFile 718 Внешний поиск 723 Внешняя сортировка 727 Сортировка естественным слиянием 729 14.7. Словари 735 Письменные упражнения 742 Упражнения по программированию 748 Приложение. Ответы на избранные письменные упражнения 753 Предметный указатель 775 Index 795
Предисловие Книга посвящается Дэвиду Джонстоуну, редактору. Он разделял наше видение предмета. Несмотря на его трагическую гибель в результате акта бессмысленного насилия, мы сохранили это видение в нашей работе. Мы надеемся, что это — вклад, достойный его памяти. Решение проблемы Разработка программы Структуры данных Программирование Алгоритмы Эта книга предназначена для представления основных структур данных с точки зрения объектно-ориентированной перспективы. Изучение структур данных является ядром курса обучения информатике. Оно предоставляет богатый контекст для изучения методов решения задач и разработки программ и использует мощные конструкции и алгоритмы программирования. Эта книга использует гибкий язык C++, классы и объектно-ориентированные конструкции которого конкретно предназначаются для эффективной реализации структур данных. Хотя существует ряд объектно-ориентированных языков, C++ имеет преимущество вследствие его развития из популярного языка программирования С и использования многими продавцами программного обеспечения. Мы развиваем каждую структуру данных вокруг понятия абстрактного типа данных (abstract data type, ADT), которое определяет как организацию данных, так и операции их обработки. Нас поддерживает C++, обеспечивающий тип класса для представления ADT и эффективное использование этих структур в каком-либо объекте. Структура книги Книга "Структуры данных в C++" организует изучение структур данных вокруг классов коллекций, которые включают списки, деревья, множества, графы и словари. В процессе изучения мы охватываем основные темы структур данных и разрабатываем методологию объектно-ориентированного программирования. Эти структуры и методология реализуются в ряде законченных программ и практических задач. Для оценки эффективности алгоритмов мы вводим понятие записи "Big-O". В главах 1-11 излагаются традиционные темы первого курса по структурам данных (CS 2). Формальная трактовка наследования и виртуальных функций приводится в главе 12, и эти темы используются для реализации структур данных повышенной сложности в главах 13 и 14. Материал в главах 12-14 определяет темы, традиционно излагаемые в курсе по структурам данных/алгоритмам повышенной сложности (CS 7) и в курсе по продвинутому программированию. Мы включаем подробную разработку шаблонов и перегрузку операторов для поддержки общих структур и применяем эти мощные конструкции языка C++, чтобы упростить использование структур данных. Профессиональный программист может использовать "Структуры данных в C++" как самоучитель по структурам данных, который сделает возможным
понимание большинства библиотек классов, научно-исследовательских статей и профессиональных изданий повышенной сложности. Описание глав В большинстве глав книги разрабатываются абстрактные типы данных и описывается их реализация как класса C++. Объявление каждого класса и его ключевых методов также включены в эту книгу. Во многих случаях приводится полное определение, в некоторых случаях даются определения избранных методов классов. Полная реализация классов включена в программное приложение. Глава 1. Введение Эта глава является обзорной и знакомит с абстрактными типами данных и объектно-ориентированным программированием с использованием C++. Разрабатывается понятие ADT и относящиеся к нему атрибуты инкапсуляции данных и скрытия информации. Глава также знакомит с наследованием и полиморфизмом, которые формально излагаются в главе 12. Глава 2. Базовые типы данных Языки программирования предоставляют простые числовые и символьные типы, которые охватывают целые числа и числа с плавающей точкой, символьные данные и определяемые пользователем перечислимые типы. Простые типы объединяются для создания массивов, записей, строковых и файловых структур. Эта глава описывает ADT для типов языков, используя C++ в качестве примера. Глава 3. Абстрактные типы данных и классы В этой книге в целом формально рассматриваются абстрактные типы данных и их представление в качестве классов C++. Конкретно эта глава определяет основные понятия класса, включая данные-члены, конструкторы и определения методов. Глава 4. Классы коллекций Коллекция — это класс памяти с инструментами обработки данных для добавления, удаления или обновления элементов. Изучение классов коллекций находится в центре внимания этой книги. Поэтому в данной главе содержится пример различных типов коллекций, представленных в книге. Глава включает простое введение в запись "Big-O", которая позволяет определить эффективность какого-либо алгоритма. Эта запись используется на протяжение всей книги для сравнения и сопоставления различных алгоритмов. Глава завершается изучением класса SeqList, являющегося прототипом общей списочной структуры.
Глава 5. Стеки и очереди В этой главе обсуждаются стеки и очереди, которые являются основными классами, поддерживающими данные в порядке LIFO ("последний пришел — первый вышел") и FIFO ("первый пришел — первый вышел"). В ней разрабатывается также очередь приоритетов, модифицированная версия очереди, в которой клиент всегда удаляет из списка элемент с наивысшим приоритетом. В практическом примере используются очереди приоритетов для управляемого событиями моделирования. Глава 6. Абстрактные операторы Абстрактный тип данных определяет набор методов для инициализации и управления данными. В этой главе мы расширяем определяемые языком программирования операторы (например, +, *, < и так далее) до абстрактных типов данных. Процесс, называемый перегрузкой операторов, переопределяет стандартные символы операторов для реализации операций в ADT. Полностью разработанный класс рациональных чисел иллюстрирует перегрузку операторов и преобразование типов, а также введение дружественных функций для перегрузки стандартных операторов ввода/вывода C++. Глава 7. Параметризованные типы данных C++ использует шаблонный механизм для предоставления параметризованных функций и классов, поддерживающих различные типы данных. Шаблоны обеспечивают мощную параметризацию структур данных. Эту концепцию иллюстрирует основанная на шаблоне версия класса Stack и ее применение в вычислении инфиксного выражения. Глава 8. Классы и динамическая память Динамические структуры данных используют память, выделяемую системой во время исполнения приложения. Они позволяют определять структуры без ограничений по размеру и увеличивают возможность использования классов. Однако их применение требует особого внимания. Мы вводим конструктор копирования, перегруженный оператор присваивания и методы деструктора, позволяющие правильно копировать и присваивать динамические данные, а затем освобождать их при удалении объекта. Возможности динамических данных иллюстрируют классы Array, String и Set. Эти классы используются и далее в книге. Глава 9. Связанные списки Использование списков для хранения и выборки данных является темой, обсуждаемой на протяжение всей книги, поскольку списки очень важщ^ для разработки большинства приложений данных. Эта глава знакомит со связанными списками, позволяющими выполнять динамическую обработку списков. Мы используем двойной подход, при котором сначала разрабатывается базовый класс узлов и создаются функции для добавления и удаления элементов из списка. Более абстрактный подход создает класс связанных списков со встроенным механизмом прохождения для обработки элементов в списке.
Класс LinkedList используется для реализации класса SeqList и Queue. В каждом случае объект связанного списка включается композицией. Этот подход предоставляет мощный инструмент для разработки структур данных. В этой главе обсуждаются также циклические и двусвязные списки, имеющие интересное применение. Глава содержит также практическую задачу очереди для принтера. Глава 10. Рекурсия Рекурсия — это важный инструмент решения задач как в информатике, так и в математике. Мы описываем рекурсию и показываем ее использование в различном контексте. Ряд приложений используют рекурсию с математическими формулами, комбинаторикой и головоломками. Последовательность Фибоначчи используется для сравнения эффективности рекурсивного алгоритма, итеративного алгоритма или прямых вычислений при определении терма последовательности. Глава 11. Деревья Связанные списки определяют множество узлов с последовательным доступом, начиная с головы. Эта структура данных называется линейным списком. Во многих приложениях объекты сохраняются в нелинейном порядке, в котором элемент может иметь множество последователей. В главе 11 мы вводим базовую нелинейную структуру, называемую деревьями, в которой все элементы данных происходят из единого источника — корня. Дерево является идеальной структурой для описания иерархической структуры, такой как компьютерная файловая система или таблица бизнес-отчета. В этой главе мы ограничиваем анализ бинарными деревьями, в которых каждый узел имеет самое большее два наследника. Мы разрабатываем класс TreeNode для реализации этих деревьев и представляем приложения, включающие классические алгоритмы прямого, симметричного и обратного сканирования. Бинарные деревья находят применение в качестве списочной структуры, эффективно сохраняющей большие объемы данных. Эта структура, называемая деревом бинарного поиска, реализуется в классе BinSTree. Класс представлен в практической задаче, которая разрабатывает конкорданс. Глава 12. Наследование и абстрактные классы Наследование является основным понятием объектно-ориентированного программирования. В этой главе обсуждаются основные свойства наследования, подробно разрабатывается его реализация в C++ и вводятся виртуальные функции как инструменты, использующие возможности наследования. Разрабатывается также понятие абстрактного базового класса с чистыми виртуальными функциями. Виртуальные функции являются основными для объектно-ориентированного программирования и используются последующими темами в этой книге. Глава знакомит с итераторами, определяющими однородный и общий механизм прохождения для различных списков и завершается примером наследования и виртуальных функций для разработки неоднородных массивов и связанных списков.
Глава 13. Нелинейные структуры повышенной сложности Эта книга продолжает разработку бинарных деревьев и вводит дополнительные нелинейные структуры. В ней описываются основанные на массиве деревья, моделирующие массив как законченное бинарное дерево. Предоставляется также обширное изучение пирамид, и это понятие используется для реализации пирамидальной сортировки и очередей приоритетов. Хотя деревья бинарного поиска обычно являются хорошими структурами для реализации списка, вырожденные случаи могут быть неэффективными. Структуры данных предоставляют различные структуры со сбалансированной высотой, обеспечивающие быстрое среднее время поиска. Используя наследование, выводится новый класс дерева поиска, называемый AVL-деревьями. Глава завершается введением в графы, представляющим ряд классических алгоритмов. Глава 14. Организация коллекций В этой главе рассматриваются алгоритмы поиска и сортировки для общих коллекций и разрабатываются классические основанные на массиве алгоритмы сортировки выбором, пузырьковой сортировки и сортировки вставками. Наше исследование включает известный алгоритм "быстрой сортировки" Quicksort. В этой книге особенно выделяются данные, сохраняемые во внутренней памяти. Для более обширных множеств данные могут сохраняться на диске. Можно также использовать внешние методы для поиска и сортировки данных. Мы разрабатываем класс BinFile для прямого файлового доступа и используем его методы для иллюстрации как алгоритма внешнего индексного последовательного поиска, так и алгоритма внешней сортировки слиянием. Раздел, посвященный ассоциативным массивам, обобщает понятие индекса массива. Необходимая подготовка Эта книга предполагает, что читатель закончил первый курс программирования и свободно владеет базовым языком C++. Глава 2 определяет простые структуры данных C++ и показывает их использование в нескольких законченных программах. Эта глава может использоваться как стандарт для определения необходимых предпосылок C++. Для заинтересованного читателя авторы предоставляют учебник по C++, определяющий простые типы языка и синтаксис для массивов, управляющих структур, ввода/вывода, функций и указателей. Учебник включает обсуждение каждой темы вместе с примерами, законченными программами и упражнениями. Приложения Полные листинги исходных кодов для всех классов и программ доступны через канал Internet ftp из University of the Pacific, где работают авторы. Код C++ в этой книге протестирован и выполнен с использованием новейшего компилятора фирмы "Borland". За очень небольшими исключениями, эти программы можно компилировать и выполнять в системе Macintosh, используя Symantec C++ и в системе Unix, используя GNU C++. Те, кто имеют канал Internet, используйте адрес ftp.cs.uop.edu. После соединения с системой ваше логическое имя является анонимным а ваш
пароль — это ваш mail-адрес Internet. Программное обеспечение находится в каталоге "/риЬ/С-Ь+". Читатели могут обращаться непосредственно к авторам для получения копии учебника. Информация для заказа предоставляется по электронной почте: посылайте запрос по адресу "billf@uop.edu", или по международной почте: пишите Bill Тор, 456 S. Regent Stockton, CA 95204. Благодарности Во время подготовки книги "Структуры данных в C++" авторам оказывали поддержку друзья, студенты и коллеги. University of the Pacific щедро предоставлял ресурсы и поддержку для завершения проекта. Издательство "Prentice Hall" обеспечило преданную делу команду профессионалов, выполнивших дизайн и производство книги. Мы особенно благодарны редакторам Элизабет Джоунз, Биллу Зобристу и Алану Апту и выпускающему редактору Байани де Леону. Выпуск реализован совместно Spectrum Publisher Services и Prentice Hall. Большую помощь нам оказали Келли Риччи и Кристин Миллер из Spectrum. Студенты проявили ценный критицизм при обсуждении рукописи, обеспечивая обратную связь и непредвзятый взгляд на работу. Наши рецензенты оказывали помощь в начале работы над книгой, предоставляя комментарии как по содержанию, так и по педагогическим аспектам. Мы учли большинство из их рекомендаций. Особая благодарность Хамиду Р. Арабниа (University of Georgia), Рхода А. Ваггс (Florida Institute of Technology), Сандре Л. Барлетт (University of Michigan — Ann Arbor), Ричарду Т.Клоузу (U.S. Coast Guard Academy), Дэвиду Куку (U.S. Air Force Academy), Чарльзу Дж. Доулингу (Catonsville (Baltimore County) Community College), Дэвиду Дж. Хаглину (Mancato State University), Джиму Мерфи (California State University — Chico) и Герберту Шилдту. Наши коллеги Ральф Эутон (University of Texas — El Paso) и Дуглас Смит (University of the Pacific) внесли большой вклад в эту работу. Их взгляды и поддержка были бесценны для авторов и значительно улучшили окончательную структуру книги. Уильям Форд Уильям Топп
глава 1 Введение 1.1. Абстрактные типы данных 1.2. Классы C++ и абстрактные типы 1.3. Объекты в приложениях C++* 1.4. Разработка объектов 1.5. Приложения с наследованием классов 1.6. Разработка объектно-ориентированных программ 1.7. Тестирование и сопровождение программ 1.8. Язык программирования C++ 1.9. Абстрактные базовые классы и полиморфизм* Письменные упражнения
В этой книге разрабатываются структуры данных и алгоритмы в контексте объектно-ориентированного программирования с использованием языка C++. Мы разрабатываем каждую структуру данных как абстрактный тип, который определяет и организацию, и операции обработки данных. Структура, называемая абстрактный тип данных (abstract data type, ADT), — это абстрактная модель, описывающая интерфейс между клиентом (пользователем) и этими данными. Используя язык C++, мы разрабатываем представление каждой абстрактной структуры. Язык C++ поддерживает определяемый пользователем тип, называемый классом (class), для представления ADT и элементы этого типа, называемые объектами (objects), для хранения и обработки данных в приложении. В данной главе вводится понятие ADT и относящихся к нему атрибутов, называемое инкапсуляцией данных и скрытием информации. С помощью серии примеров мы показываем разработку ADT и создаем формат для определения организации данных и операций. Понятие конструктора класса в C++ является фундаментальным в нашем изучении структур данных и формально разрабатывается в главе 3. В данной главе мы начинаем с обзора класса C++ и рассматриваем его использование для представления какого-либо ADT. Необязательные разделы, помеченные символом звездочки (*), содержат примеры классов C++. В этой главе дается обзор разработки объектов, которая включает композицию объектов и наследование. Эти понятия являются строительными блоками объектно-ориентированного программирования. Глава включает основы разработки программ для построения более крупных приложений и изучения этой книги. Наследование и полиморфизм расширяют возможности объектно-ориентированного программирования и позволяют разрабатывать большие системы программирования на основе библиотек классов. Эти темы тщательно разрабатываются в главе 12 и используются выборочно для представления улучшенных структур данных. В данной главе предварительно рассматриваются темы, представленные в книге. Вы познакомитесь с ключевыми структурами данных и объектно-ориентированными понятиями до их формального рассмотрения. 1.1. Абстрактные типы данных Абстракция данных — центральное понятие в разработке программ. Абстракция определяет область и структуру данных вместе с набором операций, которые имеют доступ к данным. Абстракция, называемая абстрактным типом данных (ADT), создает определяемый пользователем тип данных, чьи операции указывают, как клиент может манипулировать этими данными. ADT является независимым от реализации и позволяет программисту сосредоточиться на идеализированных моделях данных и оперяттхтстх над ними. Пример 1.1 1. Программа учета для малого предприятия сопровождает инвентаризационную информацию. Каждый элемент в описи представлен записью данных, которая включает идентификационный номер этого элемента, текущий уровень запаса, ценовую информацию и информацию упорядочивания. Набор операций по обработке списка об-
новляет различные информационные поля и инициирует переупорядочивание запаса, когда его уровень падает ниже определенного порога. Абстракция данных описывает какой-либо элемент как запись, содержащую серию информационных полей и операций, необходимых менеджеру компании для инвентаризационного сопровождения. Операции могут включать изменение значения Stock on Hand (имеющийся запас) при продаже этого товара, изменение Unit Price (цены за единицу) при использовании новой ценовой политики и инициализации упорядочивания при падении уровня запаса ниже уровня переупорядочивания (Reorder Level). Данные Identification Stock on Hand Unit Price Reorder Level Операции UpdatestoekLevel AdjustUnitPrice Reorderltem 2. Игровая программа моделирует бросание набора костей. В этой разработке игральные кости описываются как абстрактный тип данных, которые включают число бросаемых костей, сумму очков в последнем бросании и список со значениями очков каждой кости в последнем бросании. Операции включают бросание костей (Toss), возвращение суммы очков в одном бросании (Total) и вывод очков для каждой отдельной кости (DisplayToss). Данные N diceTotai Dice List Операции Toss Total DisplayToss ADT — формат Для описания ADT используется формат, который включает заголовок с именем ADT, описание типа данных и список операций. Для каждой операции определяются входные (input) значения, предоставляемые клиентом, предусловия (preconditions), применяемые к данным до того, как операция может быть выполнена, и процесс (process), который выполняется операцией. После выполнения операции определяются выходные (output) значения, которые возвращаются клиенту, и постусловия (postconditions), указывающие на любые изменения данных. Большинство ADT имеют инициализирующую операцию (initializer), которая присваивает данным начальные значения. В среде языка C++ такой инициализатор называется конструктором (constructor). Мы используем этот термин для упрощения перехода от ADT к его преставлению в C++.
APT — формат ADT ADTJName Данные Описание структуры данных Операции Конструктор Начальные значения: Данные, используемые для инициализации объекта Процесс: Инициализация объекта Операция2 Вход: Данные от клиента Предусловия: Необходимое состояние системы перед выполнением операций Процесс: Действия, выполняемые с данными Выход: Данные, возвращаемые клиенту Постусловия: Состояние системы после выполнения операций Операция2 * • • Операцияп * • • Конец ADT Пример 1.2 1. Данные абстрактного типа Dice включают счетчик N числа игральных костей, которые используются в одном бросании, общую сумму очков и список из N элементов, который содержит значения очков, выпавших на каждой кости. ADT Dice Данные Число костей в каждом бросании — целое, большее либо равное 1. Целое значение, содержащее сумму очков всех костей в последнем бросании. Если N — число бросаемых костей, то число очков находится в диапазоне от N до 6N. Список, содержащий число очков каждой кости в бросании. Значение любого элемента списка находится в диапазоне от 1 до 6. Операции Конструктор Начальные значения: Число бросаемых костей Процесс: Инициализировать данные, определяющие число костей в каждом бросании Toss Вход: Нет Предусловия: Нет Процесс: Бросание костей и вычисление общей суммы очков Выход: Нет Постусловия: Общая сумма содержит сумму очков в бросании, а в списке находятся очки каждой кости DieTotal Вход: Нет Предусловия: Нет Процесс: Находит значение элемента, определяемого как сумма очков в последнем бросании
Выход: Возвращает сумму очков в последнем бросании Постусловия: Нет DisplayToss Вход: Нет Предусловия: Нет Процесс: Печатает список очков каждой кости в последнем бросании Выход: Нет Постусловия: Нет конец ADT Dice 2. Окружность определяется как набор точек, равноудаленных от точки, называемой центром. С целью графического отображения абстрактный тип данных для окружности включает как радиус (radius), так и положение центра. Для измеряющих приложений абстрактному типу данных требуется только радиус. Мы разрабатываем Circle ADT и включаем операции для вычисления площади (area) и длины окружности (circumference). Этот ADT применяется в следующем разделе для иллюстрации описания класса C++ и использования объектов при программировании приложений. radius radius Circumference Area ADT Circle Данные Неотрицательное действительное число, определяющее радиус окружности. Операции Конструктор Начальные значения: Радиус окружности Процесс: Присвоить радиусу начальное значение Area Вход: Нет Предусловия Нет Процесс: Вычислить площадь круга Выход: Возвратить площадь круга Постусловия: Нет Circumference Вход: Нет Предусловия Нет Процесс: Вычислить длину окружности Выход: Возвратить длину окружности Постусловия: Нет конец ADT Circle
1.2. Классы C++ и абстрактные типы Язык C++ поддерживает определяемый пользователем тип классов для представления абстрактных типов данных. Класс состоит из членов (members), которые включают значения данных и операции по обработке этих данных. Операции также называются методами (methods), поскольку они определяют методы доступа к данным. Переменная типа класса называется объектом (object). Класс содержит две отдельные части. Открытая (public) часть описывает интерфейс, позволяющий клиенту манипулировать объектами типа класса. Открытая часть представляет ADT и позволяет клиенту Класс private: Данные-члены: переменнаяь переменнаяг Внутренние операции public: Конструктор Операция1 Операцияг использовать объект и его операции без знания внутренних деталей реализации. Закрытая (private) часть содержит данные и внутренние операции, помогающие в реализации абстракции данных. Например, класс для представления ADT Circle содержит один закрытый член класса — radius. Открытые члены включают конструктор и методы вычисления площади круга и длины окружности Circle Класс private: radius public: Конструктор Area Circumference Инкапсуляция и скрытие информации Класс инкапсулирует (encapsulates) информацию, связывая вместе члены и методы и обращаясь сними как с одним целым. Структура класса скрывает реализацию деталей и тщательно ограничивает внешний доступ как к данным, так и к операциям. Этот принцип, известный как скрытие информации (information hiding), защищает целостность данных. Класс использует свои открытую и закрытую части для контроля за доступом клиентов к данным. Члены внутри закрытой части используются методами класса и изолированы от внешней среды. Данные обычно определяются в закрытой части класса для предотвращения нежелательного доступа
клиента. Открытые члены взаимодействуют с внешней средой и могут использоваться клиентами. Например, в Circle-классе radius является закрытым членом класса, доступ к которому может осуществляться только тремя методами. Конструктор присваивает начальное значение члену radius. Каждый из других методов использует radius. Например, area = р * raduis2. Здесь методы являются открытыми членами класса, которые могут вызываться всеми внешними единицами программы. Передача сообщений В приложении доступ клиентов к открытым членам какого-либо объекта может быть реализован вне этого объекта. Доступом управляют главная программа и подпрограммы (master control modules), которые наблюдают за взаимодействием между объектами. Управляющий код руководит объектом для доступа к его данным путем использования одного из его методов или операций. Процесс управления деятельностью объектов называется передачей сообщений (message passing). Отправитель передает сообщение получающему объекту и указывает этому объекту выполнить некоторую задачу. В нужный момент отправитель включает в сообщение информацию, которая используется получателем. Эта информация передается как данные ввода для операции. После выполнения задачи получатель может возвращать информацию отправителю (данные вывода) или передавать сообщения другим объектам, запрашивая выполнение дополнительных задач. Когда получающий объект выполняет операцию, он может обновлять некоторые из его собственных внутренних значений. В этом случае считается, что происходит изменение состояния (state change) объекта и возникают новые постусловия. 1.3. Объекты в приложениях C++* Абстрактный тип данных реализует общее описание данных и операций над данными. Класс C++ обычно вводится сначала объявлением этого класса без определения функций-членов. Это известно как объявление класса (class declaration) и является конкретным представлением ADT. Фактическое определение методов дается в реализации класса (class implementation), отдельной от объявления. Реализация классов C++ и использование объектов иллюстрируются следующей завершенной программой, которая определяет стоимость планировки бассейна. Программа объявляет Circle класс и показывает, как определяются и используются объекты. В коде содержатся определения открытого и закрытого разделов класса и используются функции C++ для определения операций. Главная программа — это клиент, который объявляет объекты, и затем использует их операции для выполнения вычислений. Главная программа отвечает за передачу всех сообщений в приложении. Приложение: класс Circle Объекты Circle используются для описания плавательного бассейна и дорожки вокруг него. С помощью методов Circumference (вычисление длины
окружности) и Area (вычисление площади круга) мы можем вычислить стоимость бетонирования дорожки и строительства ограды вокруг бассейна. К нашему приложению применяются следующие условия. Строительные правила требуют, чтобы плавательный бассейн окружала бетонная дорожка (темная область на следующем рисунке) и вся территория была огорожена. Текущая стоимость ограды составляет $ 3,50 за погонный фут, а стоимость бетонирования — $ 0,5 за кв. фут. Приложение предполагает, что ширина дорожки, окружающей бассейн, составляет, 3 фута и что клиент указывает радиус круглого бассейна. В качестве результата приложение должно определить стоимость строительства ограды и дорожки при планировании бассейна. Pool PoolRim Мы объявляем объект Circle с именем Pool, описывающий площадь плавательного бассейна. Второй объект — PoolRim, — это объект Circle, включающий как бассейн, так и окружающую дорожку. Конструктор вызывается при определении объекта. Для объекта Pool клиент задает радиус в качестве параметра, и затем использует радиус плюс 3 фута для определения объекта PoolRim. Для вызова операции класса задайте имя объекта, за которым следует точка (.) и операция. Например, Роо1.Агеа() и Circumference() вызывают операции Circle для Pool. Ограда располагается вдоль наружной стороны PoolRim. Вызовите операцию вычисления окружности PoolRim.Circumference() для вычисления стоимости ограды. FenceCost = PoolRim.Circumference() * 3.50 Площадь бетонной поверхности определяется вычитанием площади Pool из внешней площади PoolRim. ConcreteCost = (PoolRim.Area() - Pool.Area()) * 0.5 Программа 1.1. Конструкция и использование класса Circle Программа 1.1 реализует приложение для бассейна. Для оказания помощи в чтении кода C++ в тексте имеются комментарии. Объявление класса Circle показывает представление Circle ADT и использование закрытых и открытых директив для контроля за доступом к членам класса. Главная программа запрашивает клиента ввести радиус бассейна. Это значение используется для объявления объекта Pool. Второй объект — PoolRim объявляется как имеющий дополнительные три фута к его радиусу для размещения дорожки вокруг бассейна. Стоимость строительства ограды и стоимость бетонирования дорожки выводятся для печати.
Вне главного модуля программа определяет класс Circle. Читатель может обратить внимание на использование спецификатора const для указания на то, что функция-член не изменяет данные. Этот спецификатор используется с методами Circumference и Area в их объявлении и определении. Стоимость строительных материалов для сетки ограды и стоимость бетона задаются как константы. // рг01_01.срр #include <iostream.h> const float PI = 3.14152; const float FencePrice - 3.50; const float ConcretePrice = 0.50; // Объявление класса Circle, данных и методов class Circle { private: // член класса radius — число с плавающей запятой float radius; public: // конструктор Circle(float r); // вычисляющие функции float Circumference(void) const; float Area(void) const; }; // class implementation // конструктор инициализирует член класса radius Circle::Circle(float r): radius(r) { ) // возвратить длину окружности float Circle::Circumference(void) const { return 2 * PI * radius; } // возвратить площадь круга float Circle::Area(void) const { return PI * radius * radius; } void main() { float radius; float FenceCost, ConcreteCost; // настраивает поток вывода на выдачу двух знаков // после десятичной точки cout.setf(ios::fixed); cout.setf(ios::showpoint); cout.precision(2) ; // запрос па ввод радиуса cout « "Введите радиус бассейна: "; cin » radius;
// объявить объекты Circle Circle Pool (radius) ; Circle PoolRim(radius + 3) ; // вычислить стоимость ограды и выдать ее значение FenceCost = PoolRim.Circumference() * FencePrice; cout « "Стоимость ограды: $" « FenceCost « endl; // вычислить стоимость бетона и выдать ее значение ConcreteCost = (PoolRim.Area() - Pool.Area{))*ConcretePrice; cout « "Стоимость бетона: $" « ConcreteCost « endl; } /* Запуск программы pr01_01. cpp Введите радиус бассейна: 40 Стоимость ограды: $945.60 Стоимость бетона: $391.12 */ 1.4. Разработка объектов В этой книге разрабатываются структуры данных с классами и объектами. Мы начинаем с классов, которые определяются простыми данными-членами и операциями класса. Для более сложных структур классы могут содержать члены класса, которые сами являются объектами. Результирующие классы, созданные посредством композиции (composition), имеют доступ к функциям-членам в составляющих объектах. Использование композиции объектов расширяет понятия инкапсуляции и скрытия информации и обеспечивает повторное использование кода. Объектно-ориентированные языки также позволяют классу быть порожденным из других классов путем наследования (inheritance). Это дает возможность разработчику создавать новые классы как усовершенствования других классов и повторно использовать код, который был разработан ранее. Наследование является фундаментальным средством объектно-ориентированного программирования на языке C++. Эта тема вводится формально в главе 12 и используется для разработки и реализации улучшенных структур данных. Объекты и композиция Геометрические фигуры состоят из наборов точек, которые образуют линии, прямоугольники и т.д. Основными строительными блоками фигур являются точки, сочетающиеся с серией аксиом для определения геометрических объектов. В данном разделе мы рассматриваем точку как примитивный геометрический объект, а затем описываем линии и прямоугольники. Эти геометрические фигуры используются для иллюстрации объектов и композиции. Точка — это местоположение на плоской поверхности. Мы предполагаем, что объект точка расположена на сетке с координатами, которые измеряют горизонтальное (х) и вертикальное (у) расстояние от базовой точки. Например, точка р (3,1) находится на 3 единицы измерения правее и 1 единицу ниже базовой точки.
Линия образуется из точек, а две точки определяют линию. Последний факт используется для создания модели отрезка (line segment), который определяется своими конечными точками pi и р2 [Рис. 1.1 (А)]. базовая точка Прямоугольник — это четырехсторонняя фигура, чьи смежные стороны встречаются в прямых углах. Для рисования прямоугольник определяется двумя точками, которые отмечают верхний левый угол (ul) и нижний правый угол (1г) рамки [Рис. 1.1 (В)]. (А) Отрезок Цр1, р2) (В) Прямоугольник R(ul, lr) Рис. 1.1. Отрезок и прямоугольник Мы используем эти факты для создания классов Point, Line и Rectangle. Члены в классах Line и Rectangle являются объектами типа Point. Композиция — это важный инструмент в создании классов с объектами из других классов. Заметьте, что каждый класс имеет метод Draw для отображения рисунка на поверхности рисования. Класс Point содержит функции-члены для доступа к координатам х и у точки. Класс Point private: х у координаты public Конструктор, Draw, GetX, detY Класс Line private: Point pi, p2 public Конструктор, Draw Класс Rectangle private: Point ul, lr public: Конструктор, Draw Пример 1.3 Определите геометрический объект, задавая фигуру, за которой следуют имя объекта и параметры для указания объекта. 1. Point p(l,3); // объявляет объект point (1,3) 2. Point pl(4,2), p2(5,lb- Line 1(р1,р2); // линия: от (4,2) до (5,1) 3. Point pl(4,3), р2(6,4); Rectangle r(pl,p2); // прямоугольник: от (4,3) до р2(б,4) 4. Метод Draw в каждом классе делает наброски рисунка на поверхности рисования. p.DrawO; l.DrawO; r.DrawO;
Методы Draw C++ геометрические классы* Далее следуют объявления C++ для классов Point и Line. Заметьте, что конструктору для класса Line передаются координаты двух точек, определяющих линию. Каждый класс имеет функцию-член Draw, отображающую рисунок в области рисования. Спецификация класса Point ОБЪЯВЛЕНИЕ class Point { private: float x, у; // горизонтальная и вертикальная позиция public: Point (float h, float v); float GetX(void) const; // возвратить координату х float GetY(void) const; // возвратить координату у void Draw(void)Jconst; // нарисовать точку (х,у) >; Класс посредством композиции Line включает два объекта Point. Эти объекты инициализируются конструктором. Спецификация класса Line ОБЪЯВЛЕНИЕ class Line { private: Point PI, P2; // две конечные точки отрезка public: Line (Point a, Point b);// инициализировать PI и Р2 void Draw(void) const; // нарисовать отрезок }; Объекты и наследование Наследование — это интуитивное понятие, из которого мы можем извлекать примеры, основанные на каждодневном опыте. Все из нас наследуют
Проиллюстрируем наследование класса с помощью линейного списка, названного SeqList, который сохраняет информацию в последовательном порядке. Список — это важная и знакомая структура, используемая для ведения инвентаризационных записей, графика деловых встреч, типов и количества необходимых гастрономических товаров и т.д. Наследование возникает, когда мы объявляем упорядоченный список, который является особым типом последовательного списка. Упорядоченный список использует все базовые методы обработки списка из последовательного списка и обеспечивает свою собственную операцию вставки для того, чтобы элементы списка сохранялись в возрастающем порядке. В линейном списке, содержащем N элементов, любой элемент занимает одно из положений от 0 до N-1. Первое положение является передним, а последнее — конечным. На рис. 1.3 показан неупорядоченный список целых чисел с шестью элементами. Рис. 1.3. Неупорядоченный линейный список Базовые операции SeqList включают операцию Insert, которая добавляет новый элемент в конец списка (Рис. 1.4), и операцию Delete, которая удаляет первый элемент списка, соответствующий ключу. Вторая функция удаления, называемая DeleteFront, удаляет первый элемент в списке (Рис. 1.5). Структура определяет размер списка с помощью ListSize и предоставляет операцию Find, выполняющую поиск элемента в списке. Для управления данными пользователь может определить, является ли список пустым, и удалить его операцией ClearList. Insert (10) Рис. 1.4. Вставка значения 10 Delete (45) DeleteFront Рис. 1.5. Удаление элемента 45 и удаление первого элемента в списке Данный класс предоставляет метод GetData, позволяющий клиенту читать значение любого элемента списка. Например, для нахождения максимального значения в списке мы можем начать сканирование списка с нулевого элемента. Процесс заканчивается при достижении конца списка, который определяется с помощью ListSize. В каждом положении следует обновлять максимальное значение, если текущее значение (GetData) больше, чем текущий максимум. Например, для второго элемента списка число 22 сравнивается с
характерные черты от своих родителей такие, как раса, цвет глаз и тип крови. Мы можем думать о родителе как о базе, из которой мы наследуем характерные черты. Взаимосвязь иллюстрируется двумя объектами, связанными стрелкой, острие которой направлено к базовому объекту. Зоология формально изучает наследование у животных. На рис. 1.2 показана иерархия животных для млекопитающих, собак и колли. Млекопитающее — это теплокровное животное, которое имеет шерсть и вскармливает своих детенышей молоком. Собака — это млекопитающие, которое имеет клыки, ест мясо, имеет определенное строение скелета и является общественным животным. Колли — это собака с заостренной мордой, имеющая белый с рыжим окрас и хорошо развитые пастушеские инстинкты. Млекопитающее Теплокровное животное, имеет шерсть, вскармливает детенышей молоком Собака Имеет клыки, ест мясо, имеет определенное строение скелета, является общественным животным Колли Имеет заостренную морду, белый с рыжим окрас, хорошо развитые пастушеские инстинкты Рис. 1.2. Цепочка наследования у животных В иерархической цепочке класс наследует все характерные черты своего класса-предка. Например, собака имеет все черты млекопитающего плюс те, которые отличают ее от кошек, слонов и т.д. Порядок расположения классов указывает что Колли есть собака. Собака есть млекопитающее В этой цепочке класс млекопитающих определяется в качестве базового класса (base class) для собаки, а собака называется производным классом (derived class). Используя аналогию семейного наследования, мы говорим о базовом и производном классах как о родительском классе и классе-наследнике, соответственно. В случае расширенной цепочки наследник наследует характерные черты своего родительского и прародительского класса. Наследование в программировании Объектно-ориентированное программирование предоставляет механизм, посредством которого производному классу разрешается наследовать данные и операции от базового класса. Этот механизм, называемый наследование класса (class inheritance), позволяет производному классу использовать данные и операции, которые были определены ранее в базовом классе. Производный класс может добавлять новые операции или переписывать некоторые операции, так как он устанавливает методы для обработки его данных. Аналогично, ребенок может наследовать дом или автомашину от его (или ее) родителя. Затем он может затем использовать этот дом или автомашину. Если необходимо, наследник может модифицировать дом, чтобы он соответствовал его (или ее) особым условиям.
предыдущим максимумом, равным 3, поэтому текущее максимальное значение заменяется на 22. В конечном счете, число 23 определяется как максимальный элемент в списке. 3 <22 Новым максимумом будет 22 ADT SeqList Данные Неотрицательное целое число, указывающее количество элементов, находящихся в данный момент в списке (размер), и список элементов данных. Операции Коне труктор Начальные значения: Нет Процесс: Установка размера списка на О ListSize Вход: Нет Предусловия: Нет Процесс: Чтение размера списка Выход: Размер списка Постусловия: Нет ListEmpty Вход: Нет Предусловия: Нет Процесс: Проверка размера списка Выход: Возвращать TRUE, если список пустой; в противном случае — возвращать FALSE. Постусловия: Нет ClearList Вход: Нет Предусловия: Нет Процесс: Удаление всех элементов из списка и установка размера списка на 0. Выход: Нет Постусловия: Список пустой Find Вход: Элемент, который необходимо найти в списке. Предусловия: Нет Процесс: Сканирование списка для нахождения соответствующего элемента. Выход: Если соответствующий элемент списка не найден, возвращать FALSE; если он найден, возвращать TRUE и этот элемент. Постусловия: Нет Insert Вход: Элемент для вставки в список Предусловия: Нет
Процесс: Добавление этого элемента в конец списка. Выход: Нет Постусловия: Список имеет новый элемент; его размер увеличивается на 1. Delete Вход: Значение, которое должно быть удалено из списка. Предусловия: Нет Процесс: Сканирование списка и удаление первого найденного элемента в списке. Не выполнять никакого действия, если этот элемент не находится в списке. Выход: Нет Постусловия: Если соответствующий элемент найден, список уменьшается на один элемент. DeleteFront Вход: Нет Предусловия: Список не должен быть пустым. Процесс: Удаление первого элемента из списка. Выход: Возвращать значение удаляемого элемента Постусловия: Список имеет на один элемент меньше. GetData Вход: Положение (pos) в списке. Предусловия: Генерируется ошибка доступа, если pos меньше О или больше 0 (размер -1) Процесс: Получать значение в положении pos в списке. Выход: Значение элемента в положении pos. Постусловия: Нет Конец ADT SeqLlst Упорядоченные списки и наследование Упорядоченный список — это особый тип списка, в котором элементы сохраняются в возрастающем порядке. Как абстрактный тип данных этот особый список получает большинство своих операций из класса SeqList, за исключением операции Insert, которая должна добавлять новый элемент в положение, сохраняющее упорядочение (Рис. 1.6). insert(8) 23 45 Рис. 1.6. Упорядоченный список: Вставка элемента (8) Операции ListSize, ListEmpty, ClearList, Find и GetData независимы от любого упорядочения элементов. Операции Delete и DeleteFront удаляют элемент, но сохраняют остающиеся элементы упорядоченными. Далее следует ADT, отражающий сходство операций упорядоченного списка и SeqList.
ADT OrderedList Данные те же, что и для SeqList ADT Операции Конструктор выполняет конструктор базового класса ListSize тот же, что и для SeqList ADT ListEmpty тот же, что и для SeqList ADT ClearList тот же, что и для SeqList ADT Find тот же, что и для SeqList ADT Delete тот же, что и для SeqList ADT DeleteFront тот же, что и для SeqList ADT GetData тот же, что и для SeqList ADT Insert Вход: Элемент для вставки в список. Предусловия: Нет Процесс: Добавление элемента в положение, сохраняющее упорядочение. Выход: Нет Постусловия: Список имеет новый элемент, и его размер увеличивается на 1. Конец ADT OrderedList Класс OrderedList является производным от класса SeqList. Он наследует операции базового класса и модифицирует операцию Insert для упорядоченной вставки элементов. Класс SeqUst private: детали реализации public Коструктор Find ListEmpty Delete ClearList DeleteFront ListSize GetData Класс OrderedList private: public Конструктор. Insert Повторное использование кода Объектно-ориентированный подход к структурам данных способствует повторному использованию кода, который уже был разработан и протестирован, и может быть вставлен в ваше приложение. Мы уже рассматривали повторное использование кода с композицией. Наследование также является мощным инструментом для этой цели. Например, реализация класса упорядоченного списка требует от нас написания только методов Insert и конструктора. Все другие операции задаются кодом из класса SeqList. Повторное использование кода является важнейшим преимуществом в объектно-ориентированной разработке, поскольку оно сокращает время разработки и способствует однородности приложений и вариантов программного продукта. Например, при модернизации операционной системы к ней добавляются новые функции. В то же время, эта модернизация должна позволять выполнение существующих приложений. Одним из подходов является определение оригинальной операционной системы в качестве базового класса. Модернизированная система действует как производный класс с его новыми функциями и операциями.
Спецификации класса SeqList и OrderedList* Формальное описание класса SeqList приводится в главе 4. В этом разделе мы даем только спецификацию класса для того, чтобы вы могли соотнести этот класс и его методы с очень общим ADT. Класс OrderedList определяется для иллюстрации наследования. Тип элемента данных в списке представлен параметрическим именем DataType. Спецификация класса SeqList ОБЪЯВЛЕНИЕ class SeqList { private: // массив для хранения списка и число элементов текущего списка DataType listitem[ARRAYSIZE]; int size; public: // конструктор SeqList(void); // методы доступа списка int ListSize(void) const; int ListEmpty(void) const; int Find (DataType& item) const; DataType GetData (int pos) const; // методы модификации списка void Insert (const DataType& item) ; void Delete (const DataType& item) ; DataType DeleteFront (void); void ClearList (void); ); ОПИСАНИЕ Методы ListSize, ListEmpty, Find и GetData завершаются словом const после объявления функции. Они называются постоянными функциями, поскольку не изменяют состояние списка. Функции Insert, Delete имеют слово const как часть списка параметров. Этот синтаксис C++ передает ссылку на элемент, но указывает, что значение этого элемента не изменяется. C++ использует простой синтаксис для объявления производного класса В заголовке базовый класс указывается после двоеточия (:). Далее следует объявление класса OrderedList. Особенности описываются в главе 12, в которой содержится формальное введение в наследование. Спецификация класса OrderedList ОБЪЯВЛЕНИЕ class OrderedList: public SeqList // наследование класса SeqList { public: OrderedList (void); // инициализировать базовый класс // для создания пустого списка void Insert (const DataType& item) ; // вставить элемент по порядку }; ОПИСАНИЕ
Insert замещает метод базового класса с тем же именем. Она проходит по всему списку и вставляет элемент в положение, сохраняющее упорядочение списка. 1.5. Приложения с наследованием классов Понятие наследования класса имеет важное применение в программировании графического пользовательского интерфейса (grafical user interface — GUI) и системах баз данных. Графические приложения фокусируют внимание на окнах, меню, диалоговых окнах и так далее. Базовое окно — это структура данных с данными и операциями, являющимися общими для всех типов окон. Операции включают открытие окна, создание или изменение заголовка окна, установку линеек прокрутки и областей перетаскивания и т.д. Приложения GUI состоят из классов диалога, классов меню, классов текстовых окон и так далее, которые наследуют базовую структуру и операции от базового класса окна. Например, следующая иерархия класса включает класс Dialog и класс TextEdit, порожденные от класса Window. Класс Window Класс Dialog Класс TextEdit Этот пример класса Window показывает одиночное наследование, в котором производный класс имеет только один базовый класс. Однако при множественном наследовании (multiple inheritance) класс порождается от двух или более базовых классов. Некоторые приложения GUI используют это свойство. Программа текстового процессора объединяет редактор (editor) с менеджером просмотра (view manager) для пролистывания текста в некотором окне. Редактор читает строку символов и вносит изменения, вставляя и удаляя строки, и вводя информацию форматирования. Менеджер просмотра отвечает за копирование текста на экран с использованием информации о шрифте и окне. Редактор экрана может быть определен как производный класс, использующий класс Editor (редактора) и класс View (менеджера просмотра) в качестве базовых классов. Множественное наследование Класс View Класс Editor Класс Screen Editor
1.6. Разработка объектно-ориентированных программ Большие программные системы становятся все более сложными и требуют новых подходов к разработке. Традиционная разработка использует модель управления, которая предполагает наличие администратора верхнего уровня (top administrator), понимающего систему и поручающего задачи менеджерам. Такая нисходящая программная разработка (top-down program design) рассматривает систему как набор подпрограмм, состоящий из нескольких слоев. Главная программа Подпрограмма! Подпрограмма2 ПодпрограммаЗ На верхнем уровне главная программа управляет работой системы, выполняя последовательность вызовов подпрограмм, которые производят вычисления и возвращают информацию. Эти подпрограммы могут далее поручать выполнение некоторых задач другим подпрограммам. Элементы нисходящей программной разработки необходимы для всех систем. Однако, когда задача становится слишком большой, этот подход может оказаться неудачным, поскольку его сложность подавляет способность отдельного человека управлять такой иерархией подпрограмм. Кроме того, простые структурные изменения в подпрограммах около вершины иерархии могут потребовать дорогих и занимающих длительное время изменений в алгоритмах для подпрограмм, находящихся в нижней части диаграммы. Объектно-ориентированное программирование использует другую модель системной разработки. Оно рассматривает большую систему как набор объектов (агентов), которые взаимодействуют для выполнения задач. Каждый объект имеет методы, управляющие его данными. Целью программной разработки является создание читабельной и поддерживаемой архитектуры, которая может быть расширена, как диктует необходимость. Хорошо организованные системы легче понимать, разрабатывать и отлаживать. Все философии разработки пытаются преодолеть сложность программной системы с помощью принципа разделения и подчинения. Нисходящая программная разработка рассматривает систему как набор функциональных модулей, состоящий из слоев. Объектно-ориентированное программирование использует объекты как основу разработки. Не существует единственного способа программной разработки и строго определенного процесса, которому необходимо следовать. Разработка программ — это вид деятельности человека, который должен включать творческую свободу и гибкость. В этой книге мы обсуждаем общий подход, определяющий методологию разработки программного продукта (software development methodology). Этот подход включает отдельные фазы разработки программного продукта, среди которых анализ задачи и определение программы, разработка объекта и процесса, кодирование, тестирование и поддержка (сопровождение)
Анализ задачи/определение программы Программная разработка начинается, когда клиент обозначит некоторую задачу, которая должна быть решена. Эта задача часто определяется свободно, без ясного понимания, какие именно данные имеются в наличии (вход) и какая новая информация должна быть получена в результате (выход). Программист анализирует задачу вместе с клиентом и определяет, какую форму должны принять вход и выход и алгоритмы, которые используются при выполнении вычислений. Этот анализ формализуется в фазе разработки программы. Разработка Программная разработка описывает объекты, которые являются основными строительными блоками программы. Разработка описывает также управляющие модули, руководящие взаимодействием между объектами. В фазе объектной разработки определяются объекты, которые будут использоваться в программе, и пишется объявление для каждого класса. Класс тестируется путем его использования с какой-либо небольшой программой, тестирующей методы класса при управляемых условиях. Тот факт, что классы могут тестироваться отдельно, вне области большого приложения, является одной из важнейших возможностей объектно-ориентированной разработки. Фаза разработки управления процессом использует нисходящую разработку путем создания главной программы и подпрограмм для управления взаимодействием между объектами. Главная программа и подпрограммы образуют каркас разработки (design framework). Главный управляющий модуль соответствует главной функции в программе C++ и отвечает за поток данных программы. При нисходящей программной разработке система делится на последовательность действий, которые выполняются как независимые подпрограммы. Главная программа и ее подпрограммы организуются в нисходящую иерархию модулей, называемую структурным деревом (structure tree). Главный модуль является корнем этого дерева. Каждый модуль заключается в прямоугольник, а каждый класс, который используется модулем, заключается в овал. Мы представляем каждый модуль, указывая имя функции, входные и выходные параметры и краткое описание ее действия. класс Главная класс Подпрограмма Подпрограмма Подпрограмма Подпрограмма класс класс Подпрограмма
Кодирование В фазе кодирования пишутся главная программа и подпрограммы, реализующие каркас программной разработки. Тестирование Реализация и тестирование объектов выполняются в течение фазы объектной разработки. Это позволяет нам сосредоточить внимание на разработке управляющего модуля. Мы можем проверять кодирование программы, тестируя взаимодействие каждого объекта с управляющими модулями в каркасе разработки. Иллюстрация программной разработки: Dice график Разработку и реализацию объектно-ориентированной программы иллюстрирует использование графика для записи частоты результатов бросания при игре в кости. В последующих разделах описывается каждая фаза в жизненном цикле программы. Анализ задачи. Предположим, событие — это бросание двух костей. Для каждого бросания сумма лежит в диапазоне от 2 до 12. Используя повторное бросание костей, мы определяем эмпирическую вероятность того, что сумма равна 2, 3 ... , 11 или 12, и строим диаграмму, которая отражает вероятность каждого возможного результата. Замечание Эмпирическая вероятность определяется моделированием большого количества событий и записью результатов. Отношение количества появлений некоторого события к количеству всех моделируемых событий представляет эмпирическую вероятность того, рассматриваемое событие произойдет. Например, если бросание костей повторится 100000 раз и сумма 4 возникнет 10000 раз, то эмпирическая вероятность этой суммы равна 0,10. эмпирическая вероятность Прежде всего следует ясно определить задачу. Этот процесс включает понимание входа, выхода и промежуточных вычислений. В фазе анализа задачи клиент формирует серию требований к системе. Они включают контроль за вводом данных, указание вычислений и используемых формул и описание желаемого выхода. Определение программы. Программа запрашивает пользователя ввести число N — количество бросаний двух костей. Поскольку бросание костей имеет случайный результат, используем для моделирования N бросаний случайные числа. Программа ведет запись количества появлений каждой воз-
можной суммы S (2 < S < 12). Эмпирическая вероятность определяется делением количества результатов S на N. Что касается выхода, это дробное значение используется для определения высоты прямоугольника на нашей диаграмме. Результаты выводятся на экран как столбцовая диаграмма. Объектная разработка. Программа использует класс Line для создания осей координат и класс Rectangle — для построения столбцов. Эти классы вводятся в разделе 1.4 Разработка объектов. Бросание костей — это метод в классе Dice, который обрабатывает две кости. Далее следует объявление класса Dice. Его реализация и тестирование приводятся в программе вместе с реализацией и тестированием классов Line и Rectangle. #include random.h class Dice { private: // данные-члены int diceTotal/ // сумма двух костей int diceList[2]; // список очков двух костей // класс генератора случайных чисел, используемый для // моделирования бросаний RandcmNumber rnd; public: // конструктор Dice(void); // методы void Toss(void); int Total(void) const; void DisplayToss(void) const; }; Разработка управления процессом. Для построения диаграммы бросания костей главный модуль вызывает три подпрограммы, которые выполняют основные действия программы. Функция SimulateDieToss использует методы из класса Dice для бросания костей N раз. Draw Axes вызывает метод Draw в классе Line для рисования осей координат графика, a Plot рисует серию прямоугольников, которые образуют столбцовую диаграмму. Функция Plot вызывает Мах для определения максимального количества появлений любой возможной суммы. Это значение позволяет нам вычислить относительную высоту каждого прямоугольника диаграммы. Структурное дерево этой программы показано на рис. 1.8. Далее следуют объявления для каждого управляющего модуля в структурном дереве. Главная SimulateDieToss DrawAxes Plot Dice Line Rectangle Max Рис. 1.8. Древовидная структура программы Dice Graph
main Передаваемые параметры: Нет Выполнение: Запросить у пользователя количество бросаний костей в моделировании. Вызвать функцию SimulateDieToss для выполнения бросаний и записать количество раз, когда возникает каждая возможная сумма: (2 < Total <. 12). Нарисовать оси координат функцией DrawAxes и создать столбцовую диаграмму функцией Plot. Возвращаемые параметры: Нет SimulateDieToss Передаваемые параметры: tossTotal Массив tossTotal содержит количество появлений каждой суммы в диапазоне от 2 до 12. tossTotal [i] — это количество появлений суммы i при бросании костей tossCount раз. tossCount Количество бросаний N при моделировании. Выполнение: Создать объект Dice и использовать его для бросания костей указанное количество раз, записывая в массив tossTotal количество раз, когда возникает сумма 2, количество раз, когда возникает сумма 3, . . . , количество раз, когда возникает сумма 12. Возвращаемые параметры: Массив tossTotal с количеством раз, когда возникает каждая сумма. DrawAxes Передаваемые параметры: Нет Выполнение: Создать два объекта Line: один — для вертикальной оси (оси у) и один — для горизонтальной оси (оси х) . Ось у — это линия от (1.0, 3.25) до (1.0, 0.25) Ось х — это линия от (0.75, 3.0) до (7.0, 3.0) . Вертикальный диапазон графика равен 2,75. Возвращаемые параметры: Нет Мах Передаваемые параметры: а Массив, содержащий длинные значения данных, п Количество значений данных в а.
Выполнение: Найти максимальное значение элементов в массиве а. Возвращаемый параметр: Максимальное значение в массиве. Plot Передаваемый параметр: tossTotal Массив, содержащий количество появлений каждой возможной суммы, вычисленной в SimulateDieToss. Выполнение: Поиск максимальной суммы (maxTotal) в массиве tossTotal для диапазона индекса 2—12. Затем каждый элемент в массиве генерирует соответствующую часть (tossTotal [i]/ maxTotal) вертикального'диапазона графика. Разделить 6-дюймовый интервал оси х от (1.0, 3.0) до (7.0, 3.0) на 23 равных сегмента и построить соответствующие прямоугольники, чьи высоты — это функция (tossTotal [i]) / maxTotal*2.75, 2 < i й 12. Возвращаемые параметры: Нет Кодирование*. Завершает разработку программы кодирование управляющих модулей. Например, следующей программой задаются управляющие модули для dice-диаграммы: Программа 1.2. Диаграмма бросания костей Главная программа запрашивает пользователя ввести количество бросаний костей для моделирования. Наш запуск программы тестирует случай 500 000 бросаний костей. В конце выполнения задачи система ожидает нажатия клавиши или щелчка кнопкой мыши для завершения работы. Классы Line и Rectangle содержатся в файле figures.h, а класс Dice содержится в файле dice.h. Подпрограммы рисования графических примитивов находятся в graphlib.h. #include <iostream.h> #include "figures.h" #include "dice.h" iinclude "graphlib.h" // Бросание двух костей tossCount раз. // Запись числа выпавших "двоек" в tossTotal[2], // "троек" — в tossTotal[3] и так далее void SimulateDieToss( long tossTotal[], long tossCount ) { long tossEvent; int i; Dice D; // очистить каждый элемент массива tossTotal for (i=2; i 12; i++) tossTotal[i] = 0; // Бросание костей tossCount раз for (tossEvent = 1; tossEvent tossCount; tossEvent++ ) { D.TossO; // бросание костей tossTotal[D.Total()]++; // увеличение счетчика }
} // Нахождение максимального значения в массиве их п элементов long Max (long а [], int п) { long lmax - а[0] ; int i; for (i=l; i n; i++) if (a [i] lmax) lmax = a[i]; return lmax; } // Рисование двух осей void DrawAxes (void) { const float vertspan = 3.0; Line VerticalAxis(Point(1.0, vertspan+0.25),Point(1.0,0.25)); VerticalAxis.Draw(); Line HorizontalAxis(Point(0.75,vertspan),Point(0.75,vertspan)); HorizontalAxis.Draw(); } // Рисование графического столбца void Plot (long tossTotal [ ]) { const float vertspan = 3.0, scaleHeight = 2.75; float x, rectHeight, dx; long maxTotal; int i; // Нахождение максимального значения в массиве tossTotal. // Поиск для индексов в диапазоне 2-12 maxTotal =Мах(&tossTotal[2], 11) ; // теперь создаем прямоугольники dx = 6.0/23.0; х = 1.0 + dx; // Цикл — 11 раз. //В цикле: // определяется высота столбца для рисования в текущем положении; // создается объект Rectangle для соответствующего // положения, высота и ширина; // рисуется столбец и происходит переход к следующему столбцу for (i=2; i 12; i++) { rectHeight = (float(tossTotal[i])/maxTotal)*scaleHeight; Rectangle CurrRect(Point(x,vertspan-rectHeight), Point(x+dx,vertspan)); CurrRect.Draw(); x += 2.0*dx; } ) void main (void) { long numTosses; long tossTotal [13] ; // запрос числа моделирования бросаний cout « "Введите число бросаний: ";
cin » numTosses; SimulateDieToss(tossTotal, numTosses); //бросание InitGraphics (); // инициализация графической системы DrawAxes (); // рисование осей Plot (tossTotal); // построение графика ViewPause (); // ожидание нажатия клавиши или щечка мышью ShutdownGraphi.es (); // закрытие графической системы } /* Запуск программы рг01_02.срр Введите число бросаний: 50000 */ 1.7. Тестирование и сопровождение программы Использование объектно-ориентированного программирования способствует созданию систем, позволяющих выполнять независимое тестирование объектов и повторно использовать написанные ранее классы. Эти преимущества уменьшают риск появления ошибок при создании сложных программных систем, поскольку они развиваются, расширяясь из меньших систем, в которых вы уверены. Тестирование выполняется в процессе разработки программной системы. Объектное тестирование Тип класса — это самодостаточная структура данных, которая может передавать информацию внешнему компоненту программы и от него. Мы можем тестировать каждый класс, создав короткую программу, вызывающую каждый public-метод. Эта дополнительная программа иллюстрирует тестирование методов в классе Dice. Тестирование управляющего модуля Программа должна быть до конца протестирована путем ее выполнения с тщательно отобранными данными. Перед началом выполнения этой задачи правильность программы часто может быть оценена с помощью структурного сквозного контроля (structure walk-through), в котором программист показывает полную структуру и реализацию другому программисту и объясняет, что
именно происходит, от объектной разработки и до разработки управляющих модулей. Этот процесс часто вскрывает концептуальные ошибки, проясняет логику программы и подсказывает тесты, которые могут быть выполнены. Современные компиляторы поддерживают отладчик на уровне исходного кода (source-level debugger), который позволяет отслеживать отдельные команды и останавливает работу в выбранных контрольных точках. Во время управляемого выполнения значения переменных могут отображаться на экране, позволяя сравнивать выборочные части программы до и после возникновения ошибки. Основной проверкой правильности программы является ее выполнение с наборами хорошо подобранных данных. Выполняя тестирование, программист убеждается в правильности программы. Программисту следует также использовать неверные входные данные для проверки кода на устойчивость к ошибкам (robustness), которая определяет способность программы идентифицировать неверные данные и возвращать сообщения об ошибках. Данные для тестирования отбираются с помощью различных подходов. Одним из методов является так называемый метод "надеюсь и молюсь", когда программист запускает программу несколько раз с простыми данными и, если она работает, то он (или она) продолжает разработку. Более разумным подходом является выбор серии входных данных, которая тестирует различные алгоритмы в программе. Ввод должен включать простые, типовые и экстремальные данные, которые тестируют специальные случаи в nporpaiv ме, и неверные данные, проверяющие способность программы реагировать на ошибки ввода. Полностью структурированный тест рассматривает логическую структуру программы. Этот подход предполагает, что программа не тестируется до конца, если некоторая часть кода не была уже выполнена. Исчерпывающее тестирование требует от программиста выбора данных для проверки различных алгоритмов в программе: каждого условного оператора, каждой конструкции цикла, каждого вызова функции и так далее. Некоторые компиляторы предоставляют функции протоколирования, которые указывают количество вызовов функций в программе. Программное сопровождение и документирование Для удовлетворения дополнительным требованиям компьютерные программы часто нуждаются в обновлении. Объектно-ориентированное программирование упрощает программное сопровождение. Наследование классов делает возможным повторное использование существующих программ. Эти инструменты эффективны при поддержке хорошим программным документированием, описывающим классы и управляющие модули для того, чтобы помогать пользователям понимать программу и ее правильное выполнение. Большие программы обычно поддерживаются руководством пользователя, которое включает информацию по установке программного обеспечения и одну или более обучающие программы для иллюстрации центральных возможностей программного продукта. Объектные спецификации и структурные диаграммы управляющих модулей являются превосходными инструментами программного документирования. В исходном программном коде комментарии описывают действие отдельных функций и классов. Комментарии также помещаются там, где логика какого-либо алгоритма является особенно трудной.
1.8. Язык программирования C++ Эта книга знакомит читателя со структурами данных, используя язык объектно-ориентированного программирования C++. Несмотря на существование ряда объектно-ориентированных языков, C++ обладает преимуществом, благодаря своим корням в популярном языке программирования С и качеству компиляторов. Язык С был разработан в начале 70-х годов как структурный язык для системного программирования. Он содержал средства для вызова системных подпрограмм низкого уровня и реализации конструкций высокого уровня. С годами быстрые и эффективные компиляторы С появились на большинстве компьютерных платформ. Вся операционная система Unix, кроме небольшой части, написана на языке С, и С является основным языком программирования в среде Unix. Язык программирования C++ был разработан в Bell Laboratories Бьярном Страуструпом в качестве развития языка С. Использование языка С означало, что C++ не пришлось разрабатывать с самого начала, и эта связь с С дала новому языку широкую аудиторию квалифицированных программистов. Первоначально C++ назывался "С с классами" и стал доступен для пользователей в начале 80-х годов. Были написаны трансляторы для преобразования исходного кода С с классами в код С перед вызовом компилятора С для создания машинного кода. Название C++ было придумано Риком Масситти в 1983г. Он использовал оператор приращения ++ языка С, чтобы отразить его эволюцию из языка С и то, что он расширяет язык С. Многие спрашивали, должен ли C++ сохранять совместимость с С, в частности, поскольку C++ разрабатывает новые мощные конструкции и средства, которые не присутствуют в С. На практике этот язык, вероятно, будет продолжать оставаться развитием языка С. Количество существующих программ на языке С и количество функций библиотеки С будет заставлять разработчиков C++ сохранять крепкую связь с языком С. Определение C++ продолжает обеспечивать то, что общие конструкции С и C++ имеют одно и то же значение на обоих языках. Идеи многих конструкций C++ развивались в 70-е годы из языков Си- мула-67 и Алгол-68. Эти языки ввели в употребление серьезную проверку типов, понятия класса и модульность. Министерство обороны содействовало разработке Ада, который привел в систему многие ключевые идеи конструкции компилятора. Язык Ада стимулировал использование параметризации для возможности появления обобщенных классов. C++ использует похожую шаблонную конструкцию и имеет также общие с языком Ада механизмы обработки особых ситуаций. Популярность C++ и его миграция на многие платформы привели к необходимости стандартизации. Компания AT&T активно развивает этот язык. Сознательные усилия прилагаются для связи тех, кто пишет компилятор C++, с разработчиками оригинального языка и с растущей популяцией пользователей. AT&T развивает свой успех с Unix и работает совместно с пользователями для координации разработки стандартов ANSI C++ и опубликования окончательного справочного руководства по C++. Ожидается, что стандарт ANSI (Американский национальный институт стандартов) по C++ станет частью стандарта ISO (Международная организация по стандартам).
1.9. Абстрактные базовые классы и полиморфизм* Наследование классов объединяется с абстрактными базовыми классами (abstract base classes) для создания важного инструмента разработки структуры данных. Эти абстрактные базовые классы определяют открытый интерфейс класса, независимый от внутренней реализации данных класса и операций. Открытый интерфейс класса определяет методы доступа к данным. Клиент хочет, чтобы открытый интерфейс оставался постоянным, несмотря на изменения во внутренней реализации. Объектно-ориентированные языки подходят к этой проблеме, используя абстрактный базовый класс, который объявляет имена и параметры для каждого из public-методов. Абстрактный базовый класс предоставляет ограниченные детали реализации и сосредотачивает внимание на объявлении public-методов. Это объявление форсирует реализацию в производном классе. Абстрактный базовый класс C++ объявляет некоторые методы как чистые виртуальные функции (pure virtual functions). Следующее объявление определяет абстрактный базовый класс List, который задает операции списка. Слово "virtual" и присвоение нуля операции определяют чистую виртуальную функцию. template <class T> class List { protected: // число элементов в списке. // обновляется производным классом int size size; public: // конструктор List(void); // методы доступа virtual int ListSize(void) const; virtual int ListEmpty(void) const; virtual int Find(T& item) = 0; // методы модификации списка virtual void Insert(const T& item) = 0; virtual void Delete(const T& item) = 0; virtual void ClearList(void) = 0; }; Этот абстрактный базовый класс предназначен для описания очень общих списков. Он используется как база для серии классов наборов (структур списков) в последующих главах. Использование абстрактного класса в качестве базы требует, чтобы наборы реализовывали общие методы класса List. Для иллюстрации этого процесса класс SeqList снова рассматривается в главе 12, где он порождается от List. Полиморфизм и динамическое связывание Концепция наследования поддерживается в языке C++ рядом мощных конструкций. Мы уже рассмотрели чистые виртуальные функции в абстрактном базовом классе. Общая концепция виртуальных функций поддерживает
наследование в том, что позволяет двум или более объектам в иерархии наследования иметь операции с одним и тем же объявлением, выполняющие различные задачи. Это объектно-ориентированное свойство, называемое полиморфизм (polymorhism), позволяет объектам из множества классов отвечать на одно и то же сообщение. Получатель этого сообщения определяется динамически во время выполнения. Например, системный администратор может использовать полиморфизм для обработки резервных файлов в многосистемной среде. Предположим, что администратор имеет подсистему с магнитной лентой 1/2 дюйма и компактный магнитофон с лентой 1/4 дюйма. Классы HalfTape и QuarterTape являются производными от общего класса Таре и управляют соответствующими лентопротяжными устройствами. Класс Таре имеет виртуальную операцию Backup, содержащую действия, общие для всех лентопротяжных устройств. Производные классы имеют (виртуальную) операцию Backup, использующую специфическую внутреннюю информацию о лентопротяжных механизмах. Когда администратор дает указание выполнить системное резервное копирование, каждый лентопротяжный механизм принимает сообщение Backup и выполняет особую операцию, определенную для его аппаратных средств. Объект типа HalfTape выполняет резервное копирование на 1/2-дюймовую ленту, а объект типа QuarterTape — на 1/4-дюймовую ленту. Концепция полиморфизма является фундаментальной для объектно-ориентированного программирования. Профессионалы часто говорят об объектно-ориентированном программировании как о наследовании с полиморфизмом времени исполнения. C++ поддерживает эту конструкцию, используя динамическое связывание (dynamic binding) и виртуальные функции-члены (virtual member functions). Эти понятия описываются в главе 12. Сейчас же мы концентрируем внимание на этих понятиях, не давая технической информации о языковой реализации. Динамическое связывание позволяет многим различным объектам в системе отвечать на одно и то же сообщение. Каждый объект отвечает на это сообщение определенным для его типа способом. Рассмотрим работу профессионального маляра, когда он (или она) выполняет малярную работу с различными типами домов. Определенные общие задачи должны быть выполнены при покраске дома. Допустим, что они описываются в классе House. Кроме общих задач требуются особые методы работы для различных типов домов. Покраска деревянного дома отличается от покраски дома с наружной штукатуркой или дома с виниловой облицовкой стен и так далее. В контексте House Paint WoodFrame Stucco VinylSided Paint Paint Paint
объектно-ориентированного программирования особые малярные задачи для каждого вида дома задаются в производном классе, который наследует базовый класс House. Допустим, что каждый производный класс имеет операцию Paint. Класс House имеет операцию Paint, которая задается как виртуальная функция. Предположим, что BigWoody — это объект типа Wood- Frame. Мы можем указать BigWoody покрасить дом, вызывая явно операцию Paint. Это называется статическим связыванием (static binding). BigWoody. Paint ( ); // static binding Допустим, однако, что подрядчик имеет список адресов домов, которые нуждаются в покраске, и что он передает сообщение своей команде маляров пойти по адресам в списке и покрасить эти дома. В данном случае каждое сообщение привязано не к определенному дому, а скорее — к адресу дома- объекта в списке. Команда маляров приходит к дому и выбирает правильную малярную операцию Paint, после того, как она увидит тип дома. Этот процесс известен как динамическое связывание (dynamic binding). (House at address 414) .Paint ( ); // dynamic binding Процесс вызывает операцию Paint, соответствующую дому по данному адресу. Если дом по адресу 414 является деревянным, то выполняется операция Paint( ) из класса WoodFrame. При использовании структур наследования в C++ операции, которые динамически связываются с их объектом, объявляются как виртуальные функции-члены (virtual member functions). Генерируется код для создания таблицы, указывающей местоположения виртуальных функций какого-либо объекта и устанавливается связь между объектом и этой таблицей. Во время выполнения, когда на положение объекта осуществляется ссылка, система использует это положение для получения доступа к таблице и выполнения правильной функции. Понятие полиморфизма является фундаментальным в объектно-ориентированном программировании. Мы используем его с более совершенными структурами данных. Письменные упражнения 1.1 Укажите различие между инкапсуляцией и скрытием информации для объектов. 1.2 (а) Разработайте ADT для цилиндра. Данные включают радиус и высоту цилиндра. Операциями являются конструктор, вычисление площади и объема. (б) Разработайте ADT для телевизионного приемника. Данными являются настройки для регулирования громкости и канала. Операциями являются включение и выключение приемника, настройка громкости и изменение канала. (в) Разработайте ADT для шара. Данными являются радиус и его масса в фунтах. Операции возвращают радиус и массу шара. (г) Разработайте ADT для Примера 1.1, часть 2.
1.3 Геометрическое тело образовано высверливанием круглого отверстия с радиусом rh через центр цилиндра с радиусом г и высотой h. (а) Используйте ADT Cylinder, разработанный в упражнении 1.2(a), для нахождения объема геометрического тела. (б) Используйте ADT Circle, разработанный в тексте, и ADT Cylinder для вычисления площади этого геометрического тела. Площадь должна включать площадь боковой поверхности внутри отверстия. 1.4 Опишите несколько сообщений которые могут передаваться для ADT Television Set из упражнения 1.2(b). Что делает приемник в каждом случае? 1.5 Разработайте класс Cylinder, реализующий ADT из упражнения 1.2(a). 1.6 Нарисуйте цепочку наследования, включающую следующие термины: транспортное средство, автомобиль, грузовой автомобиль, автомобиль с откидным верхом, Форд и грузовой полуприцеп. 1.7 Какова структура разработки программного продукта? 1.8 Приведите три причины растущей популярности C++. 1.9 Какова связь между С и C++? 1.10 Перечислите некоторые компиляторы C++ общего использования. К каким компиляторам вы имеете доступ? Для каждого компилятора укажите, предоставляет ли он интегрированную среду разработки (IDE), в которой имеются редактор, компилятор, компоновщик, отладчик и исполняющая система. Альтернативой является компилятор с командной строкой.
1.11 (а) Объясните, что подразумевается под полиморфизмом. (б) Графическая система инкапсулирует операции окна в базовом классе Twindow. Производные классы реализуют окна главной программы, диалоги и элементы управления. Каждый класс имеет метод Setup Window, который инициализирует различные компоненты окна. Должен ли использоваться полиморфизм в объявлении SetupWindow в базовом классе?
глава Базовые типы данных 2.1. Целочисленные типы 2.2. Символьные типы 2.3. Вещественные типы данных 2.4. Типы перечисления 2.5. Указатели 2.6. Массивы 2.7. Строчные константы и переменные 2.8. Записи 2.9. Файлы 2.10. Приложения с массивами и записями Письменные упражнения Упражнения по программированию
Эта глава знакомит с серией базовых типов данных, которые включают числа, символы, определенные пользователем типы перечисления1 и указатели. Эти типы являются естественными для большинства языков программирования. Каждый базовый тип данных включает данные и операции, компоненты абстрактного типа данных (ADT). В этой главе мы предоставляем ADT для целочисленных, литерных типов, типов real number, типов перечисления и указателей. Языки программирования реализуют ADT на компьютере, используя различные представления данных, включая двоичные числа и символы в коде ASH (American Standard Code for Information Interchange). В этой книге язык C++ используется для иллюстрации реализации абстрактных типов данных. Численные, литерные типы и указатели описывают простые данные, потому что объекты этих типов не могут быть разделены на меньшие части. Наоборот, структурированные типы данных имеют компоненты, построенные из простых типов по правилам, определяющим связи между компонентами. Структурированные типы включают массивы, строки, записи, файлы, списки, стеки, очереди, деревья, графы и таблицы, которые определяют основные темы этой книги. Большинство языков программирования предоставляют синтаксические конструкции или библиотечные функции для обработки массивов, строк, записей и файловых структур. По существу, мы определяем их как встроенные (built-in) структурированные типы данных. Мы предоставляем абстрактные типы данных для этих встроенных структур и обсуждаем их реализацию в C++. Мы также представляем серию приложений для встроенных структурированных типов, которые вводят важные алгоритмы последовательного поиска и обменной сортировки. Одно из приложений иллюстрирует реализацию типа строки с использованием библиотеки строк C++. C++ использует иерархию наследования для реализации файлов. Другое приложение показывает обработку трех различных типов файлов. .1. Целочисленные типы Целые числа — это положительные или отрицательные целые числа, состоящие из знака и последовательности цифр. О целых числах говорят, как о знаковых (signed) числах. Например, далее следуют специфические целочисленные значения, называемые целочисленные константами (integer constants): +35 -278 19 (знак +) -28976510 Вы знакомы с элементарной арифметикой, в которой определяется серия операторов, имеющих результатом новые целые значения. Операторы, принимающие один операнд (унарный оператор) или два операнда (бинарный оператор) создают целые выражения: (Унарный +) +35 = 35 (Вычитание -) 73 — 50 = 23 (Сложение +) 4+6 = 10 (Умножение *) -3*7 = -21 Целые выражения могут использоваться с арифметическими операторами сравнения для получения результата, который является True или False. (Сравнение меньше, чем) 5 < 7 (True) (Сравнение больше, чем или равно ) 10 >= 18 (False) 1 Термины "типы перечисления" и "перечислимые типы" встречаются в русских источниках одинаково часто. — Прим. ред.
Теоретически, длина целых чисел не имеет ограничения, — факт, подразумевающийся в определении ADT. ADT, которые мы предусматриваем для простых типов, предполагают, что читатель знаком с предусловиями, входом и постусловиями для каждой операции. Например, полной спецификацией для операции деления целых (Integer Division) является: Integer Division Вход: Два целых значения и и v. Предусловия: Знаменатель v не может быть равен 0. Процесс: Разделить v на и, используя операцию Integer Division. Выход: Значение частного. Постусловия: Возникает условие ошибки, если v =* 0. В нашей спецификации мы используем нотацию оператора C++ вместо родовых названий операций и только описываем процесс. ADT Integer Данные Целое число N со знаком Операции Предположим, что и и v являются целыми выражениями, а N - целая переменная. Присваивание = N - и Присваивает значение выражения и переменной N Бинарные арифметические операции + u + v Сложение u — v Вычитание * u * v Умножение / u / v Деление нацело % u % v Остаток от деления нацело Унарные арифметические операции -и Изменение знака (унарный минус) + +и То же, что и и (унарный плюс) Операции отношения (Выражение отношения — это истинность заданного условия) =* и == v Результат — TRUE, если и эквивалентно v !=* и != v Результат — TRUE, если и не эквивалентно v < и < v Результат — TRUE, если и меньше v <= и <= v Результат — TRUE, если и меньше, либо равно v > и > v Результат — TRUE, если и больше v >= и >== v Результат — TRUE, если и больше, либо равно v Конец ADT Integer Пример 2.1 3+5 (выражение имеет значение 8) val =25/20 (val - 1) rem * 25 % 20 (rem = 5)
Компьютерное хранение целых чисел Реализация целых чисел обеспечивается описаниями типа языка программирования и компьютерными аппаратными средствами. Компьютерные системы сохраняют целые числа в блоках памяти с фиксированной длиной. Результирующая область значений лежит в ограниченном диапазоне. Длина блока памяти и диапазон значений зависят от реализации. Для того, чтобы способствовать некоторой стандартизации, языки программирования предоставляют простые встроенные типы данных для коротких и длинных целых. Когда требуются очень большие целые значения, приложение должно предоставлять библиотеку подпрограмм для выполнения операций. Библиотека целых операций может расширить реализацию целых до любой длины, хотя эти подпрограммы значительно снижают эффективность системы, исполняющей приложения. В компьютере целые хранятся как двоичные (бинарные) числа (binary numbers), состоящие из различных последовательностей цифр 0 и 1. Это представление моделируется на базе 10 (или десятичной системы, которая использует цифры 0, 1, 2 ,..., 9). Десятичное число сохраняется как набор цифр d0 , d2 , d2f и т.д., представляющий степени 10. Например, й-разрядное число Nw = dki dk2 „di... d2 d0 , для О < dt < 9 представляет Nio = dk.jflO*11)* dk.2(10k2)+...+di(10i)+...+ drflO'J+do (10°) Индекс 10 указывает на то, что N записано как десятичное число. Например, десятичное целое из четырех цифр 2589 представляет 258910 = 2(103)+ 5(102)+ 8(10!)+ 9(10°) = 2(1000)+ 5(100)4- 8(10)+ 9 Двоичные целые используют цифры 0 и 1 и степени 2-х (2° = 1, 21 = 2, 22 = 4, 23 = 8, 24 — 16 и так далее). Например, 1310 имеет двоичное представление 1310 = 1(23) + 1(22) + 0(2Х) + 1(2°) = 11012 Двоичная цифра называется бит (bit), сокращение, включающее Ы от binary и t — от digit. В общем виде /г-разрядное или fe-битовое двоичное число имеет представление: N2 ^ ^А-1 &fe-2 ••• Ь} ... bj bp = bk.1(2kl)+bk2(2k'2)+... +Ь^2г;+... +b1(21)+b0(2°), 0 <bx< 1 Следующее 6-битовое число дает двоичное представление 42. Десятичное значение двоичного числа вычисляется добавлением членов в сумме: 1010102= 1(25) + 0(24) + 1(23) + 0(22) + Ц21) + 0(2°) = 4210 Пример 2.2 Вычислить десятичное значение двоичного числа: 1101012= 1(25) + 1(24) + 0(23) + 1(22) + 0(2*) + 1(2°) = 5310 100001102=1(27) + 1(22) + Ц21) = 13410
Преобразование десятичного числа в его двоичный эквивалент может быть выполнено нахождением самой большой степени 2-х, которая меньше, чем это число или равна этому числу. Прогрессия степеней 2-х включает значения 1, 2, 4, 8, 16, 32, 64 и так далее. Это дает ведущую цифру в этом двоичном представлении. Остающиеся степени 2-х заполняются по убывающей до 0. Например, рассмотрим значение 35. Числом с самой большой степенью 2-х, меньшим, чем 35, является 32 = 25, это подразумевает, что 35 является 6-разрядным двоичным числом: 3510=1(32) + 0(16) + 0(8) + 0(4) + 1(2) + 1 = 1000112 Чистые двоичные числа являются просто суммой степеней 2-х. Они не имеют знака, ассоциированного с ними, и о них говорят как о беззнаковых числах (unsigned numbers). Эти числа представляют положительные целые. Отрицательные целые используют либо представление точного дополнения, либо — величины со знаком. В любом формате специальный бит, называемый знаковым битом (sign bit), указывает знак числа. Данные в памяти Числа сохраняются в памяти как последовательности двоичных цифр фиксированной длины. Распространенные длины включают 8, 16, и 32 бита. Последовательность битов измеряется 8-битовой единицей, называемой байт (byte). 0 0 10 0 0 11 Число 35 как байт В таблице 2.1 приводится диапазон беззнаковых чисел и чисел со знаком для этих распространенных длин. Таблица 2.1 Диапазоны чисел и размер в байтах Размер 8 (1 байт) 16 (2 байта) 32 (4 байта) Диапазон беззнакового числа 0 .. 255=28 " 1 0 .. 65535=216Н 0 .. 4294967295=232-1 Диапазон числа со знаком -27 =-128 .. 127=27-1 -215=-32768 .. 32767=215Н -231 .. 231-1 Компьютерная память — это последовательность байтов, к которой обращаются с помощью адресов 0, 1, 2, 3 и так далее. Адрес (address) целого в памяти — это местоположение первого байта последовательности. Рис.2.1 иллюстрирует вид памяти с числом 8710 = 10101112, находящимся в одном байте с адресом 3, и числом 50010 = 00000001111101002 с адресом 4. Адрес 0 1 2 01010111 3 00000001 11110100 4 5 Рис. 2.1. Вид памяти
Представление целых в языке C++ Целыми типами в языке C++ являются: int, short int и long int. Тип short int (short) предоставляет 16-битовые (2-байтные) целые значения в диапазоне от -32768 до 32767. Тип long int (long) предоставляет самый широкий диапазон целых значений и в большинстве систем реализуется с 32 битами (4 байтами), и поэтому его диапазон составляет от -231 до 231 - 1. Общий тип int идентифицирует целые, чья длина в битах зависит от компьютера и компилятора. Обычно компиляторы используют 16-битовые или 32-битовые целые. В некоторых случаях пользователь имеет возможность выбирать длину целого в качестве опции. Целые типы данных устанавливают область значений данных и задают арифметические операторы и операторы отношения. Каждый тип данных задает реализацию целого ADT с ограничением, что целые значения находятся в некотором конечном диапазоне. 2.2. Символьные типы Символьные данные включают алфавитно-цифровые элементы, которые определяют заглавные и строчные буквы, цифры, знаки пунктуации и специальные символы. Компьютерная индустрия использует различные представления символов для приложений. Набор символов ASCII из 128 элементов имеет самое широкое применение для текстовой обработки, ввода и вывода текста и передачи данных. Мы используем набор ASCII для нашего символьного ADT. Подобно целым, символы ASCII включают отношение порядка, определяющее набор операторов отношения. В случае алфавитных символов буквы следуют словарному порядку. В этом отношении все заглавные буквы меньше, чем строчные: T<W, b<d, T<b ADT Character Данные Набор символов ASCII Операции Присваивание Значение символа может присваиваться символьной переменной. Операция отношения Шесть стандартных операций отношения применяются к символам с использованием ASCII словарного отношения порядка. Конец ADT Character Символы ASCII Большинство компьютерных систем используют стандартную систему кодирования ASCII для символьного представления. Символы ASCII хранятся как 7-битовый целый код в 8-битовом числе. 27 = 128 различных кодов подразделяются на 95 печатаемых и 33 управляющих символа. Управляющий символ используется при передаче данных и вызывает выполнение управляющей функции устройством, например, перемещение курсора дисплея на одну строку вниз.
В таблице 2.2 показан печатаемый набор символов ASCII. Символ "пробел" представлен как ♦. Десятичный код для каждого символа задается десятичной позицией, которой соответствует номер строки, и единичной, — которой соответствует номер столбца. Например, символ "Т" имеет ASCII-значение 8410 и хранится в двоичном виде как 010101002. В символьном наборе ASCII десятичные цифры и алфавитные символы находятся в правильно определенных диацазонах (табл. 2.3). Это делает более легким преобразование между заглавными и строчными буквами и преобразование цифры ASCII ('0' ... '9') в соответствующее число (0 ... 9). Набор печатаемых символов ASCII Таблица 2.2 Левая цифра 3 4 5 6 7 8 9 10 11 12 Правая цифра 0 ( 2 < F Р Z d п X 1 ) 3 = G Q [ е о У 2 ♦ * 4 > Н R \ f Р z 3 j + 5 • I S ] g q { 4 - г 6 @ J T л h г 5 # • 7 A К U i s } 6 $ / 8 В L V > j t - 7 % 0 9 С M w a k u 8 & 1 • D N X b I V 9 i * E 0 Y с m w Коды 00-31 и 127 - управляющие символы, которые являются непечатаемыми. Таблица 2.3 Символьные диапазоны ASCII Символы ASCII Пробел Десятичный знак Символ верхнего регистра Символ нижнего регистра Десятичный 32 48-57 65-90 97-122 Двоичный 00100000 00110000-00111001 01000001-01011010 01100001-01111010 Пример 2.3 1. ASCII-значение для цифры '0' — это 48. Цифры упорядочены в диапазоне 48 — 57: ASCII-цифра '3' — это 51 (48 + 3) Соответствующая численная цифра получается вычитанием '0* (ASCII 48): Численная цифра: 3 = '3' — '0' = 51 — 48 2. Для преобразования заглавного символа в строчный добавьте 32 к ASCII-значению символа: ASCII ('А') = 65 ASCII (V) = 65 + 32 = 97
Для хранения символа в C++ используется простой тип char. Коды ASCII находятся в диапазоне от 0 до 127; однако, для использовалия остающихся значений диапазона часто определяются машинно-зависимые расширенные символы. Как целый тип, значение является кодом для символа. 2.3. Вещественные типы Целые типы, о которых говорят как о дискретных типах (discrete types), представляют значения данных, которые могут быть продолжены, например: -2, -1, 0, 1, 2, 3 и так далее. Многие приложения требуют чисел, которые имеют дробные значения. Эти значения, называемые вещественными числами (real numbers), могут быть представлены в формате с фиксированной точкой (fixed-point format) с целой и дробной частью: 9.6789 -6.345 +18.23 Вещественные числа могут быть также записаны как числа с плавающей точкой в экспоненциальном формате (scientific notation). Этот формат представляет числа как серию цифр, называемых мантиссой (mantissa) и порядком (exponent), который представляет степень 10-и. Например, 6.02е23 имеет мантиссу 6.02 и порядок 23. Число с фиксированной точкой является просто особым случаем числа с плавающей точкой с порядком 0. Как в случае с целыми и символами, вещественные числа составляют абстрактный тип данных. Стандартные арифметические и операции отношения применяются к вещественным числам, используемым вместо целых. ADT Real Данные Числа, описанные в формате с фиксированной или плавающей точкой. Операции • Приев аивание Вещественное выражение может быть присвоено действительной переменной. Арифметические операторы Стандартные двоичные и унарные арифметические операции применяются к вещественным числам вместо целых. Никаких других операторов не имеется Операции отношения К вещественным числам применяются шесть стандартных операций отношения. конец ADT Real Представление вещественных чисел Как и для целых чисел, область вещественного числа не имеет предела. Значения вещественных чисел являются безграничными и в отрицательном, и в положительном направлении, а мантисса распределяет действительные числа на последовательность точек числовой оси. Вещественные числа реализуются в конечном блоке памяти, ограничивающем диапазон значений и образующем дискретные точки числовой оси.
В течение многих лет специалисты использовали разнообразные форматы для хранения чисел с плавающей точкой. Формат IEEE (Института инженеров по электротехнике и радиоэлектронике) является широко используемым стандартом. Вы знакомы с вещественными числами, которые используют формат с фиксированной точкой. Таким является число, разделяемое на целую часть и дробную, цифры которой умножаются на 1/10, 1/100, 1/1000 и так далее. Десятичная точка разделяет эти части: 25.638 = 2(10!) + 5(10°) 4- 6(101) + 3(10'2) +8(10"3) Как в случае с целыми числами, имеются соответствующие двоичные представления для вещественных чисел с фиксированной точкой. Эти числа содержат целую и двоичную дробную части и двоичную точку с дробными цифрами, соответствующими 1/2, 1/4, 1/8 и т. д. Общая форма такого представления: N = ьп... Ъ0 . fe-i^". = Ъп2п + ... + Ъ02° + Ь^2г + Ъ-22~2 +... Например: 1011.1102 = 1(23) + Ц21) + 1(2°) + Ц2-1) + 1(2"2) + 1(2~4) = 8 + 2+1 +0.5 + 0.25 + 0.0625 = 11.812510 Преобразование десятичных и двоичных чисел с плавающей точкой использует алгоритмы, подобные разработанным для целых чисел. Преобразование в десятичные числа выполняется сложением произведений цифр и степеней 10-и. Обратный процесс является более сложным, поскольку десятичное число может потребовать бесконечного двоичного представления для создания эквивалентного числа с плавающей точкой. В компьютере количество цифр ограничено, так как используются только числа с плавающей точкой фиксированной длины. Пример 2.4 Преобразовать двоичное число с фиксированной точкой в десятичное число: 1. 0.011012 = 1/4 + 1/8 + 1/32 = 0.25 + 0.125 + 0.03125 - 0.4062510 Преобразовать десятичное число в двоичное число с плавающей точкой: 2. 4.3125ю = 4 + 0.25 + 0.0625 = 100.01012 3. Десятичное число 0.15 не имеет эквивалентной двоичной дроби фиксированной длины. Преобразование десятичной дроби в двоичную требует бесконечного двоичного расширения. Поскольку компьютерная память ограничена числами фиксированной длины, хвост бесконечного расширения отсекается, и частичная сумма является приближением десятичного значения: 0.15ю = 1/8+1/64+1/128+1/1024+ ... =0.0010011001 ...2 Большинство компьютеров хранят вещественные числа в двоичной форме, используя экспоненциальный формат со знаком, мантиссой и порядком: N = ±£>nDni - - • DjD0 . d2d2. . . dn x 2е C++ поддерживает три вещественных типа данных: float, double и long double. Тип long double применяется для вычислений, требующих высокой
точности и не используется в этой книге. Часто тип float реализуется с помощью 32-битового формата IEEE с плавающей точкой, тогда как 64-битовый формат используется для типа double. 2.4. Типы перечисления Набор символов ASCII использует целое представление символьных данных. Сходное целое представление может быть использовано для описания определяемых программистом наборов данных. Например, вот перечень месяцев, длина которых составляет 30 дней: Апрель, июнь, сентябрь, ноябрь Этот набор месяцев образует тип перечисления (enumerated data type). Для каждого типа упорядочение элементов определяется так, как эти элементы перечислены. Например: Цвет волос черные // первое значение белокурые // второе значение каштановые // третье значение рыжие // четвертое значение черные белокурые каштановые рыжие Этот тип поддерживает операцию присваивания и стандартные операции отношения. Например: черные < рыжие //черные находятся перед рыжими каштановые >= белокурые //каштановые находятся после белокурых Тип перечисления имеет данные и операторы и, следовательно, является ADT. ADT Enumerated Данные Определяемый пользователем список N отдельных элементов. Операции Присваивание Переменной типа перечисления может быть присвоен любой из элементов в списке. конец ADT Enumerated Реализация типов перечисления C++ C++ имеет тип перечисления, определяющий отдельные целые значения, на которые ссылаются именованные константы.
Пример 2.5 1. Булев тип может быть объявлен типом перечисления. Значением константы False является 0, а значение True — это 1. Переменная Done определяется как булева с первоначальным значением False. enum Boolean (False, True); Boolean Done = False; 2. Месяцы года объявляются как тип перечисления. По умолчанию первоначальным значением Jan является 0. Однако, целая последовательность может начинаться с другого значения путем присваивания этого значения первому элементу. В этом случае Jan является 1, и месяцы соответствуют последовательности 1, 2,. . . , 12. enum Month {Jan=l/Feb/ Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec}; Month Mon = Dec; 2.5. Указатели Тип указателей является основным в любом языке программирования. Указатель (pointer) — это беззнаковое целое, представляющее адрес памяти. Указатель служит также в качестве ссылки на данные по адресу. В адресе указателя тип данных называется базовым типом (base type) и используется в определении указателя. Например, указатель Р имеет значение 5000 в каждом случае на рис. 2.2. Однако, в (а) указатель ссылается на символ, а в (Ь) указатель ссылается на короткое целое. Указатель делает возможным эффективный доступ к элементам в списке и является фундаментальным для разработки динамических структур данных, таких как связанные тексты, деревья и графы. Рис. 2.2. Тип указатель Указатели ADT Как число, указатель использует некоторые арифметические операторы и операторы отношения. Арифметические операции требуют особого внимания. Указатель может быть увеличен или уменьшен на целое значение для ссылки на новые данные в памяти. Добавление 1 обновляет указатель для ссылки на следующий элемент этого типа в памяти. Например, если р указывает на объект char,, то р + 1 указывает на следующий байт в памяти. Добавление к > 0 перемещает указатель на к позиций данных вправо. Например, если р указывает на double, р + к ссылается на double:
N = sizeof (double) * k байтов вправо от р. Тип данных j char | int (2 байта) | double (4 байта) Текущий адрес р = 5000 р = 5000 р = 5000 Новый адрес р + 1 = 5001 р + 3 = 5000 + 3*2 = 5006 р - б = 5000 -6*4 = 4976 | Указатель использует адресный оператор "&", который возвращает адрес в памяти элемента данных. Напротив, оператор "*" ссылается на данные, ассоциированные со значением указателя. Упорядочение указателей происходит путем сравнения их беззнаковых целых значений. Динамическая память (dynamic memory) — это новая память распределяемая во время выполнения программы. Динамическая память отличается от статической памяти (static memory), чье наличие определяется до начала выполнения программы. Динамическая память описывается в главе 8. Оператор new принимает тип Т, динамически выделяет память для элемента типа Т и возвращает указатель на память, которую он выделил. Оператор delete принимает указатель в качестве параметра и освобождает динамическую память, выделенную ранее по этому адресу. ADT Pointer Данные Набор беззнаковых целых, который представляет адрес памяти для элемента данных базового типа Т. Операции Предположим, что и и v — это выражения-указатели, i — это целое выражение, ptr — это переменная pointer, a var — это переменная типа Т. Адрес & ptr = &var Присваивает ptr адрес переменной var. Присваивание = ptr = u Присваивает ptr значение указателя и. Разыменовывание * var = *ptr Присваивает элемент типа Т, на который ссылается ptr, переменной var. Выделение и освобождение динамической памяти new ptr = new Т Создает динамическую память для элемента типа Т и присваивает ptr его адрес. delete delete ptr Освобождает динамическую память, выделенную по адресу ptr. Арифметическая + u + i Указывает на элемент, размещенный на i элементов данных правее элемента, на который ссылается и. ~ и — i Указывает на элемент, размещенный на i элементов левее элемента, на который ссылается и. ~~ и — v Возвращает число элементов базового типа, которые находятся между двумя указателями. Отношения К указателям применяются шесть стандартных операторов отношения путем сравнения их беззнаковых целых значений. Конец ADT Pointer
Значения указателя Значение указателя — это адрес памяти, использующий 16, 32 или более битов в зависимости от машинной архитектуры. В качестве примера: PC — это указатель на char (1 байт), а РХ — это указатель на short int (2 байта). char str[] = ABCDEFG; char *PC = str; //PC указывает на строку str short X = 33; short *PC = &X; // РХ указывает на Х типа short Следующие операторы иллюстрируют основные операции с указателями. cout « *РС « endl; // напечатать 'А' РС+= 4; // сдвинуть PC вправо на четыре символа cout « *РС « endl; // напечатать 'Е' PC—; // сдвинуть PC влево на один символ cout « *РС « endl; // напечатать 'D' cout « *РХ + 3 « endl; // напечать 36 = 33 + 3 Операции new и delete обсуждаются в главе 8. 2.6. Массив (array) Массив является примером набора данных. Одномерный массив — это конечный, последовательный список элементов одного и того же типа данных — однородный массив (homogeneous array). Последовательность определяет первый элемент, второй элемент и так далее. С каждым элементом ассоциирован целый индекс (index), определяющий позицию элемента в списке. Массив имеет оператор индекса, который делает возможным прямой доступ (direct access) к элементам в списке при сохранении или возвращении какого-либо элемента. А0 А, А2 А3 ADT Array Данные Набор N — элементов одного и того же типа данных; индексы выбираются из диапазона целых от 0 до N — 1, которые определяют позицию элементов в списке и обеспечивают прямой доступ к элементам. Индекс 0 ссылается на первый элемент в этом списке, индекс 1 ссылается на второй элемент и т.д. Операции Индексирование [ ] Вход: Индекс Предусловия: Индекс находится в диапазоне от 0 до N — 1. Процесс: В правой части оператора присваивания оператор индексирования возвращает данные из элемента; в левой части оператора присваивания оператор индексирования возвращает адрес элемента массива, который сохраняет правую часть выражения. Выход: Если операция индексирования находится в правой части оператора присваивания, то операция выбирает данные из массива и возвращает эти данные клиенту. Постусловия: Если операция индексирования находится в левой части, то заменяется соответствующий элемент массива. Конец ADT Array
Встроенный тип массива C++ В качестве части своего базового синтаксиса C++ предоставляет встроенный тип статического массива, который определяет список элементов одного и того же типа. Явное объявление задает постоянный размер массива N и указывает, что индексы находятся в диапазоне от 0 до N-1. Объявление в примере 2.6 определяет статический массив C++. В главе 8 мы будем создавать динамические массивы, используя указатели. Пример 2.6 1. Объявить два массива типа double. Массив X имеет 50 элементов, а массив Y — 200 элементов: double X [ 50 ], Y [ 200 ]; 2. Объявить длинный массив А с размером, задаваемым целой константой ArraySize = 10. const int ArraySize - 10; long A[ArraySize]; Индекс массива в диапазоне от 0 до ArraySize - 1 используется для доступа к отдельным элементам массива. Элемент с индексом i имеет представление A[i]. Индексирование массива фактически выполняется путем использования оператора индексирования ([]). Это двоичный оператор, левый операнд которого является именем массива, а правый — позицией элемента в массиве. Доступ к элементам массива может осуществляться в любой части оператора присваивания: A[i] ■ х; // присвоить (сохранить) х как данные для элемента массива t e A[i] ; // возвратить данные из A[i] и присвоить их переменной t A[i] - A[i + 1] = х; // присвоить х элементу A[i +1]. Второе присваивание сохраняет данные из A[i +1] в A[i]. Сохранение одномерных массивов C++ одномерный массив А логически сохраняется как последовательное упорядочение элементов в памяти. Каждый элемент относится к одному и тому же типу данных. А[0] A[i] t i Address A[0] Address A[i] В C++ имя массива является константой и рассматривается как адрес первого элемента этого массива. Так, в объявлении Type A[ArraySize];
имя массива А является константой и местоположением в памяти первого элемента А[0]. Элементы А[1], А[2] и т.д. следуют за ним последовательно. Допустим, что sizeof(Type) = М, весь массив А занимает М * ArraySize байтов. А[0] А[1] А[2] А[3] Компилятор задает таблицу, называемую дескриптор массива (dope vector), для ведения записи характеристик массива. Таблица включает информацию о размере каждого элемента, начальный адрес массива и количество элементов в этом массиве: Начальный адрес: А Количество элементов массива: ArraySize Размер типа: М = sizeof(Type) Эта таблица используется компилятором также для реализации функции доступа (access function), которая определяет адрес элемента в памяти. Функция ArrayAccess использует начальный адрес массива и размер типа данных для установки индекса I по адресу А[1]: Adress A[I] « ArrayAccess (A, I, M) ArrayAccess задается: ArrayAccess (А, I, М) = А + I * М; Пример 2.7 Предположим, что float сохраняется с использованием 4 байт (sizeof(float) = 4) и массив Height начинается в памяти по адресу 20000. float Height [35]; Элемент массива Height[18] размещается по адресу 20000 + 18 * 4 = 20072 Границы массива ADT массива предполагает, что индексы находятся в целом диапазоне от 0 до N — 1, где N — это размер массива. То же самое и в C++. В действительности, большинство компиляторов C++ при доступе к элементу массива не генерируют код, который тестирует, находится ли индекс вне пределов массива. Например, следующая последовательность будет приниматься большинством компиляторов: int V = 20; int A[20]; //размер массива 20; индексный диапазон 0—19 A[V] =0; //индекс V больше, чем верхний предел
Массив занимает память в области данных пользователя. Функция доступа к массиву определяет адрес в памяти для отдельного элемента, и обычно не производится проверки того, находится ли адрес на самом деле в диапазоне элементов массива. В результате C++ программа может использовать индексы, находящиеся вне указанного диапазона. Адреса для элементов, которые находятся вне диапазона массива, могут все же находиться в области данных пользователя. В процессе выполнения программа может замещать другие переменные и вызывать нежелательные ошибки. На рисунке 2.3 показан вид программы в памяти. System Memory User Program User Data Block int N int arr[20]; float D; Array Space Рис 2.З. Размещение памяти в интервале данных пользователя Некоторые компиляторы реализуют проверку индекса массива, генерируя код времени исполнения для проверки того, находятся ли индексы массива внутри диапазона. Поскольку дополнительный код замедляет выполнение, большинство программистов используют проверку массива только во время программной разработки. Как только код отлажен, эта опция выключается, и программа перекомпилируется на более эффективный код. Другим подходом к проблеме является разработка "надежного массива", который реагирует на неправильные индексные ссылки и выводит сообщение об ошибке. Надежные массивы разрабатываются в главе 8. Двумерные массивы Двумерный массив, часто называемый матрицей, является структурированным типом данных, который создается путем вложения одномерных массивов. Доступ к элементам выполняется по индексам строк и столбцов. Например, на следующем рисунке представлен массив Т из 32 элементов с 4 строками и 8 столбцами. Значение 10 доступно с помощью пары индексов (строка, столбец) (1, 2), а -3 — с помощью индексов (2, 6). Концепция двумерного массива может быть расширена для охвата основных многомерных массивов, элементы в которых доступны с помощью трех или более индексов. Двумерные массивы имеют примененние в таких различных областях, как обработка данных и численное решение дифференциальных уравнений в частных производных.
Столбец Строка В С+4- объявление двумерного массива Т определяет количество строк, количество столбцов и тип данных элементов массива: type T [RowCount] [ColumnCount]; Ссылка на элементы массива Т производится с помощью индексов строки и столбца: T[i] [j], 0 <= i <= RowCount - 1, 0 <= j <= ColumnCount — 1 Например, матрица Т — это массив целых размером 4x8: int T[4] [8]; Значение Т[1][2] = 10 и Т[2][6] = -3. Мы можем представлять двумерный массив как список одномерных массивов. Например, Т[0] — это строка 0, которая состоит из ColumnCount отдельных элементов. Данная концепция полезна, когда двумерный массив передается в качестве параметра. Нотация int Т[][8] указывает на то, что Т является списком из 8-элементных массивов. Сохранение двумерных массивов Двумерный массив может инициализироваться присваиванием элементам по одной строке каждый раз. Например, массив Т задает таблицу размером 3x4: int T[3] [4] = {{20, 5, - 30, 0}, {-40, 15, 100, 80}, {3, 0, 0 -1)}; Как массив, элементы сохраняются в следующем порядке: первая строка, вторая строка, третья строка (рис. 2.4). Для доступа к элементу в памяти компилятор расширяет дескриптор массива для включения информации о количестве столбцов и длине каждой строки и использует новую функцию доступа, называемую Matrix Access, для возвращения адреса элемента.
Столбец S * 20 -40 3 5 15 0 -30 100 0 0 80 -1 Начальный адрес: Т Количество арок: RowCount Количество столбцов: ColumnCount Размер типа: М = sizeof(Type) Длина строки: RS = M*ColumnCount //длина всей строки Функция MatrixAccess принимает пару индексов строки и столбца (I,J) и возвращает адрес элемента T[I][J]: Address T[I] [J] = MatrixAccess (T, I, J) - T + (I * RS) + (J * M) Значение (I * RS) дает количество байтов, необходимое для хранения I строк данных. Значение (J * М) дает количество байтов для хранения первых J элементов в строке I. Построчное хранение массива 20 5 -30 0 Строка#0 -40 15 100 80 Строка#1 3 0 0 -1 Строка#2 Рис 2.4. Хранение матрицы Т Пример 2.8 Пусть Т будет матрицей 3x4 на рис. 2.4. Предположим, что длина целого равна 2, и матрица хранится в памяти по адресу 1000. Начальный адрес: 1000 Количество строк: 3 Количество столбцов: 4 Размер типа: 2 = sizeof(int) Длина строки: 8 = 2*4// длина всей строки 20 5 -30 0 / Строка#0 -40 15 100 80 / Строка#1 3 0 0 -1 / Строка#2
1. Адреса для строк в памяти следующие: Строка 0: Адрес 1000 Строка 1: Адрес 1000 + 1*8 = 1008 (строка — это 8 байт) Строка 2: Адрес 1000 + 2*8 = 1016 2. Адресом Т[1][3] является: ArrayAccess (Т, 1, 3)= 1000 + (1 * 8) + (2 * 3) = 1014 2.7. Строковые константы и переменные Массив — это структурированный тип данных, содержащий однородный список элементов. Особая форма массива содержит символьные данные, которые определяют имена, слова, предложения и так далее. Структура, называемая строкой (string), обращается с символами как с одним объектом и предоставляет операции для доступа к последовательностям символов в строке. Строка является важнейшей структурой данных для большинства приложений, использующих алфавитно-цифровые данные. Эта структура необходима для управления текстовой обработкой с ее операциями редактирования, ее алгоритмами поиска/замены и т. д. Например, лингвисту может потребоваться информация о количестве появлений определенного слова в документе, или программист может использовать шаблоны поиска/замены для изменения исходного кода в документе. Большинство языков объявляют строковые структуры и предоставляют встроенные операторы и библиотечные функции для управления строками. Для определения длины строки структура может включать 0 в конце строки (строка с нулевым завершающим символом или NULL-символом — NULL-terminated string) или отдельный параметр длины. Следующие представления строки содержат 6-символьную строку STRING. Строка с нулевым символом в конце S т R 1 N G NULL Строка со счетчиком длины 6 S т R 1 N G Длина Серия операций обрабатывает строку как единый блок символов. Например, мы можем определить длину строки, копировать одну строку в другую, объединять строки (конкатенация) и обрабатывать подстроки операциями вставки, удаления и отождествления* Строки имеют также операцию сравнения, позволяющую их упорядочивать. Эта операция использует ASCII-упорядочивание. Например:
"Baker" меньше, чем "Martin" // В следует перед М "Smith" меньше, чем "Smithson" "Barber" следует перед "barber" //заглавная В предшествует строчной b "123Stop" меньше, чем "AAA" //числа предшествуют буквам ADT String Данные Строка является последовательностью символов с ассоциированной длиной. Строковая структура может иметь NULL-символ или отдельный параметр длины. Операции Длина Вход: Нет Предусловия: Нет Процесс: Для строки с NULL-символом подсчитать символы до NULL-символа; для строки с параметром длины возвращать значение длины. Выход: Возвращать длину строки Постусловия: Нет Копирование Вход: Две строки: STR1 и STR2. STR2 - это источник, a STR1 — это место назначения. Предусловия: Нет Процесс: Копирование символов из STR2 в STR1. Выход: Возвращать доступ к STR1 Постусловия: Создается новая строка STR1 с длиной и данными, полученными из STR2. Конкатенация Вход: Две строки STR1 и STR2. Соединить STR2 с хвостом STR1 Предусловия: Нет Процесс: Нахождение конца STR1. Копирование символов из STR2 в конец STR1. Обновление информации о длине STR1. Выход: Возвращать доступ к STR1. Постусловия: STR1 изменяется. Сравнение Вход: Две строки: STR1 и STR2. Предусловия: Нет Процесс: Применение ASCII-упорядочения к этим строкам. Выход: Возвращать значение следующим образом: STR1 меньше, чем STR2: возвращать отрицательное значени STR1 равна STR2: возвращать значение О STR1 больше, чем STR2: возвращать положительное значени Постусловия: Нет Индексация Вход: Строка STR и одиночный символ СН Предусловия: Нет Процесс: Поиск STR для входного символа СН Выход: Возвращать адрес места, содержащего первое появление СН в STR или 0, если этот символ не найден. Постусловия: Нет Правый индекс Вход: Строка STR и одиночный символ СН
Предусловия: Нет Процесс: Поиск STR для последнего появления символа СН. Выход: Возвращать адрес места, содержащего последнее появление СН в STR или 0, если этот символ не найден. Постусловия: Нет Чтение Вход: Файловый поток, символы которого считываются, и строка STR для сохранения символов. Предусловия: Нет Процесс: Считывание последовательности символов из потока в строку STR. Выход: Нет Постусловия: Строке STR присваиваются считываемые символы Запись Вход: Строка, которая содержит символы для выхода, и поток, в который символы записываются. Предусловия: Нет Процесс: Пересылка строки символов в поток. Выход: Выходной поток изменяется. Постусловия: Нет Конец ADT String Строки C++ Глава 8 содержит спецификацию и реализацию C++ класса String. Этот класс содержит расширенный набор операторов сравнения и операций ввода/вывода. В этой главе мы используем строки с NULL-символами и C++ строковую библиотеку для реализации ADT. Строка в C++ — это строка с нулевым завершающим символом, в которой NULL-символ обозначается символом 0 в коде ASCII. Компилятор определяет строковую константу (string literal) как последовательность символов, заключенную в двойные кавычки. Строковая переменная (string variable) — это символьный массив, который содержит последовательность символов с NULL-символом в конце. Следующее объявление создает символьный массив и присваивает строковую константу массиву: char STR[9] = МА String"; Строка "A String" сохраняется в памяти как символьный массив из 9 элементов: А S t г i п 9 NULL STR C++ предоставляет ряд операторов ввода/вывода текста для потоков: cin (клавиатура), cout (экран), сегг (экран) и определяемых пользователем файловых потоков.
Строковые функции C++ и примеры Таблица 2.4 char sl[20]="dir/bin/appl", s2[20] - "file.asm", s3[20]; char *p; int result; 1. Длина int etrlen(char *s); cout << strlen(sl) « endl; // выходное значение равно 12 cout « strlen(s2) « endl; // выходное значение равно 8 2. Копирование char* etrcpy(char *sl, *s2); strcpy(s3,sl); // s3 = "dir/bin/appl" 3. Конкатенация char *etrcat(char *sl, *s2); strcat(s3, /); strcat(s3, s2); // s3="dir/bin/appl/file.asm" 4. Сравнение int strcmp(char *sl, *s2);) result - strcmp("baker", "Baker"); // result > 0 result - strcmp("12"/ "12"); // result = 0 result = strcmp("Joe", "Joseph"); // result < 0 5. Индекс char *etrchr(char *s, int c); p = strchar(s2, ' .'); II V указывает на '/' после bin if (P) strcpy(p, ".cpp"); // s2 « "file.cpp" 6. Правый индекс char *etrrchr(char *s, int c); p = strrchr(sl, '/'); II P указывает на ' /' после bin if (p) *p =■ 0; // закончить строку после bin; s2 = "dir/bin" 7. Считать StreamVariable » s 8. Записать StreamVariable << s cin > si; // если входная строка - "hello world", то si ccut < si; // указывает на строку // "hello world" // выход - "hello world" Строковая библиотека C++ <string.h> содержит гибкий набор функций строкового управления, который непосредственно реализует большинство операций ADT. В таблице 2.4 перечислены ключевые строковые функции C++. Приложение: перестановка имен Строковая прикладная программа иллюстрирует использование строковых библиотечных функций C++. В этом приложении функции strchr(), strcpy() и strcat() объединяются для копирования имени такого, как "John Doe" в "Doe", "John", в строку Newname. Следующие операторы реализуют этот алгоритм: char Name[10] = "John Doe", Newname[30]; char *p; Оператор 1: p * strchrfName, ' '); Возвратить указатель р на первый пробел в переменной Name. Первая буква фамилии находится по адресу р+1. J 1 О h п D 0 е NULL Name Р
Оператор 2: *р = 0; // заменить пробел на нулевой символ J 0 h n NULL D 0 e NULL Name p Оператор 3: strcpy(Newname, p+1); // копировать фамилию в Newname J t Name D 0 0 h e n NULL p+1 NULL D 0 e NULL Newname Оператор 4: strcat (Newname, ", " ); // добавить ',' и пробел к Newname D О e i NULL Newname Оператор 5: strcat (Newname, Name); // добавить к Newname имя J 0 h n NULL ' t Name D 0 e • J 0 h n null: Newname Программа 2.1. Перестановка имени Данная программа использует операторы 1 — 5 для перестановки имени. Эти шаги содержатся в функции ReverseName. Цикл главной программы тестирует алгоритм на трех строках ввода. Выходом в каждом случае является переставленное имя. // рг02_01.срр iinclude <iostream.h> ♦include <string.h> // перестановка имени и фамилии и разделение их запятой // результат копируется в Newname void ReverseName(char *name, char *newName) {
char *p; // поиск первого пробела в name и замена пробела // NULL-символом р = strchr(name,' ' ) ; *р« 0; // копировать фамилию в Newname, добавить ", "и // присоединить к Newname имя strcpy(newName,p+1); streat(newName,", "); streat(newName,name); *p = ' } void main (void) { char name [ 32 ], newName [ 32 ]; int i; // считать и обработать три имени for (i = 0; i < 3; i++) { cin.getline(name,32,'\n'); ReverseName(name,newName); cout << "Переставленное имя: " « newName « endl « endl; } } /* Оапуск программы pr02_01. cpp> Abraham Lincoln Переставленное имя: Lincoln, Abraham Debbie Rogers Переставленное имя: Rogers, Debbie Jim Brady Переставленное имя: Brady, Jim */ 2.8. Записи Запись (record) — это структура, которая связывает элементы различных типов в один объект. Элементы в записи называются полями (fields). Подобно массиву, запись имеет оператор доступа, который делает возможным прямой доступ к каждому полю. Например, Student — это структура записи, содержащая информацию о студенте, посещающем колледж. Эта информация включает имя (Name), адрес (Local Address), возраст (Age), профилирующую дисциплину (academic major) и среднюю успеваемость (grade-point average, GPA). Name Строка Local Address Строка Age Целое T Major ип перечисления GPA Действительное
Поля Name и Local Address содержат строковые данные. Age и GPA являются численными типами, a Major — это тип перечисления. Полагая, что Том — студент, мы получаем доступ к отдельным полям, объединяя записи имени и поля с использованием оператора доступа ".": Tom.Name Tom.Age Tom.GPA Тот.Major « Запись позволяет объединять данные различных типов (неоднородные типы — heterogeneous types) в структуре. В отличие от массива, запись описывает единственное значение, а не список значений. ADT Record Данные Элемент, содержащий набор полей неоднородного типа. Каждое поле имеет имя, обеспечивающее прямой доступ к данным в поле. Операции Оператор доступа Вход: Имя записи (recname) и поле Предусловия: Нет Процесс: Доступ к данным в поле Выход: При нахождении данных возвращать значение поля клиенту. Постусловия: При сохранении данных запись изменяется Конец ADT Record Структуры C++ C++ имеет встроенный тип struct, представляющий запись. Эта структура заимствована из языка С и сохраняется для совместимости. C++ определяет тип struct как особый случай класса, в котором все члены являются открытыми. Мы используем тип struct в этом тексте только, когда имеем дело со структурой записи. Пример 2.9 struct Student { int id; char name [30]; } Student S = {555, "Davis, Samuel"}; cout « S.id «" "< S.name < endl; 2.9. Файлы Большинство тем в этой книге концентрируют внимание на разработке и реализации внутренних структур данных (internal data structures), которые обращаются к информации, постоянно находящейся в памяти. Для приложений, однако, мы часто предполагаем, что данные доступны на устройстве внешней памяти, таком как диск. Это устройство (физический файл) сохра-
Клавиатура Память компьютера Входной поток Запоминающее устройство большой емкости Рис. 2.5. Поток данных ввода няет информацию в символьном потоке, и операционная система предоставляет ряд операций для передачи данных в память и из нее. Это позволяет нам выполнять ввод и вывод данных, которые могут постоянно храниться на внешнем устройстве. Сохраняемые данные вместе с операциями передачи определяют структуру данных, называемую (логическим) файлом (file), которая имеет важное преимущество сохранения большего количества информации, чем обычно находится постоянно в памяти. Языки программирования предоставляют высокоуровневые операции управления файлами для того, чтобы избавить программиста от необходимости использовать низкоуровневые вызовы операционной системы. Файловые операции используют поток (stream) данных, логически соединений с файлом. Stream ассоциирует поток данных с файлом. Для ввода поток позволяет данным последовательно перемещаться от внешнего устройства к памяти (рис.2.5). Та же программа может выводить информацию в файл, используя поток вывода (рис.2.6). Полезно определить ADT для файла. Данные состоят из последовательности символов, которые представляют текстовые данные или байты в виде двоичных данных. Для текста данные сохраняются как последовательность символов в коде ASCII, разделяемых newline-символами. Операции ADT задаются в большинстве случаев с концентрацией внимания на простых операциях ввода/вывода. Операция ввода Read извлекает последовательность символов из потока. Родственная операция вывода Write вставляет последовательность символов в поток. Специальные операции Get и Put управляют вводом/выводом одного символа. Поток управляет файловым указателем (file pointer), который определяет текущую позицию в потоке. Операция Input продвигает файловый указатель к следующему несчитанному элементу данных в потоке. Операция Output устанавливает файловый указатель в следующую позицию вывода. Операция установки Seek позволяет устанавливать файловый указатель в нужную по- Память компьютера Монитор Выходной поток Запоминающее устройство большой емкости
зицию в файле. Эта операция предполагает, что мы имеем доступ ко всем символам в файле и можем перемещаться в переднюю, заднюю и промежуточную позицию. Чаще всего операция установки используется для дисковых файлов. Файл обычно присоединяется к потоку в одном из трех режимов: read-only, write-only и read-write. Режимы read-only и write-only указывают на то, что поток используется для ввода или вывода, соответственно. Режим read-write обеспечивает поток данных в обоих направлениях. ADT File Данные Определение внешнего файла и направления потока данных. Последовательность данных, которые считываются из файла или записываются в файл. Операции Орел Вход: Имя файла и направление потока. Предусловия: Для ввода должен существовать внешний файл. Процесс: Связывание потока с файлом. Выход: Флажок, указывающий на успешность операции. Постусловия: Данные могут последовательно перемещаться между внешним файлом и системной памятью посредством потока. Close Вход: Нет Предусловия: Нет Процесс: Отделение потока от файла. Выход: Нет Постусловия: Данные больше не могут перемещаться посредством потока между внешним файлом и системной памятью. Чтение Вход: Массив размером N для хранения блоков данных. Предусловия: Поток должен быть открыт в направлении только для чтения или чтения-записи. Процесс: Ввод N символов из потока в массив. Остановка в конце файла. Выход: Возвращать количество символов, которые считываются. Постусловия: Файловый указатель перемещается вперед на N символов. Запись Вход: Массив; count N Предусловия: Поток должен быть открыт с направлением только для записи или для чтения-записи. Процесс: Вывод N символов из массива в поток. Выход: Возвращать количество символов, которые записываются. Постусловия: Поток содержит данные вывода, и файловый указатель перемещается вперед на N символов. Установка Вход: Параметры для переустановки файлового указателя. Предусловия: Нет Процесс: Переустановка файлового указателя. Выход: Возвращать флажок, указывающий на успешность установки. Постусловия: Устанавливается новый файловый указатель. Конец ADT File
Иерархия потоков C++ C++ обеспечивает файловое управление с потоковой системой ввода/вывода, которая реализуется путем использования иерархии классов, как частично показано на рис. 2.7. Поток C++ является объектом, соответствующим классу в этой иерархии. Каждый поток определяет файл и направление потока данных. Корневым классом в иерархии является ios, содержащий данные и операции для всех производных классов. Этот класс содержит флажки, которые определяют специфические атрибуты потока и методы форматирования, которые действительны для ввода и вывода. Например: cout.setf(ios:: fixed); устанавливает режим отображения для вещественных чисел на фиксированный формат, а не на экспоненциальный. Классы istream и ostream предоставляют базовые операции ввода и вывода и используются как базовые классы для остальной части потоковой иерархии ввода/вывода. Класс istream_withassign — это вариант istream, который позволяет выполнять объектное присваивание. Предопределенный объект cin является объектом этого класса. Предопределенные объекты cout и сегг — это объекты типа класса ostream_withassign. Во время выполнения эти три потока открыты для ввода с клавиатуры и вывода на экран. Объявления этих классов включены в файл <iostream.h>. Класс ifstream является производным класса istream и используется для дискового файлового ввода; аналогично of stream используется для дискового файлового вывода. Эти классы объявляются в файле <fstream.h>. Оба класса содержат операцию Open для присоединения файла к потоку и операцию Close для отделения потока от файла. Двумя типами дисковых файлов являются текстовые файлы (text files) и бинарные файлы (binary files). Текстовый файл содержит символы ASCII и является печатаемым, тогда как бинарный файл содержит чистые бинарные данные. Например, редактор использует текстовые файлы, а программа — электронная таблица создает и использует бинарные файлы. Пример текс- ios istream ostream istrstream istream_ withassign ifstream iostream ofstream ostream_ withassign ostrstream fstream strstream Рис.2.7. Иерархия потоковых классов
тового файлового ввода/вывода дается в программе 2.2, а бинарные файлы разрабатываются как класс в главе 14. Класс бинарных файлов используется для реализации алгоритмов внешнего поиска и сортировки. Класс fstream позволяет создавать и сопровождать файлы, которые требуют доступа и для чтения, и для записи. Класс fstream описывается вместе с приложениями в главе 14. Ввод/вывод на базе массива реализуется классами istrstream и ostrstream и объявляется в файле <strstream.h>. Здесь данные считываются из массива или записываются в массив вместо внешнего устройства. Текстовые редакторы часто используют ввод/вывод на базе массива для выполнения сложных операций форматирования. Программа 2.2. Файловый ввод/вывод Данная программа представляет потоки C++, включающие текстовый файл и ввод/вывод на базе массива. Программа использует cout и сегг, которые включены в <iostream.h>. Ввод текстового файла и вывод на базе массива используют файлы <fstream.h> и <strstream.h>, соответственно. Программа открывает файл и считывает каждую строку, содержащую пары переменная/значение в формате Name Value. С использованием потоковой операции на базе массива эта пара записывается в массив outputstr в формате name = Value и затем выводится на экран оператором cout. Например, строки ввода start 55 stop 8.5 выводятся на экран как строки start = 55 stop =8.5 // pr02_02.cpp #include <iostream.h> #include <fstream.h> #include <strstream.h> #include <stdlib.h> #include <string.h> void main(void) { // ввести текстовый файл, содержащий имена и значения ifstream fin; char name[30], outputstr[256]; //декларировать выходной поток, основанный на массиве и // использующий outputstr ostrstream outs(outputstr, sizeof(outputstr)); double value; // открыть для ввода файл 'names.dat', // убедиться в его существовании fin.open("names.dat", ios::in | ios::nocreate); if (!fin) {
cerr « "Невозможно открыть файл 'names.dat' " « endl; exit(1); } // читать имена и значения, // записывать в поток outs как 'имя = значение ' while(fin >> name) { fin >> value; outs « name « " = " « value « " "; } // NULL-символ для выходной строки outs << ends; cout << outputstr « endl; } /* <•' names, da t"> start 55 breakloop 225.39 stop 23 Оапуск программы pr02_02 . cpp> start = 55 breakloop = 225.39 stop = 23 */ 2.10. Приложения массива и записи Массивы и записи являются встроенными структурами данных в большинстве языков программирования. Данная глава знакомит с ADT для этих структур и описывает их реализацию C++. Мы используем эти структуры для разработки важных алгоритмов во всей книге. Массив является основной структурой данных для списков. Во многих приложениях мы используем утилиты search и sort для нахождения элемента в списке на базе массива и для упорядочения данных. Этот раздел знакомит с последовательным поиском и обменной сортировкой, которые легко кодировать и понимать. Последовательный поиск Последовательный поиск предназначен для поиска элемента в списке с использованием целевого значения, называемого ключом (key). Этот алгоритм начинает с индекса, предоставляемого пользователем, называемого start, и проходит через остальные элементы в списке, сравнивая каждый элемент с ключом. Сканирование продолжается, пока не будет найден ключ или список не будет исчерпан. Если ключ найден, функция возвращает индекс соответствующего элемента в списке; в противном случае возвращается значение -1. Для функции SeqSearch требуются четыре параметра: адрес списка, начальный индекс для поиска, количество элементов и ключ. Например, рассмотрим следующий список целых, содержащихся в массиве А: А: 8 3 6 2 6
Key = 6, Start = 0, n = 5. Искать с начала списка, возвращая индекс первого появления элемента 6. Кеу=б N=5 8 3 6 2 6 А-список Возвращаемое значение 2. Key = 6, Start = 3, n = 2. Начинать с А[3] и искать в списке, возвращая указатель на первое появление элемента 6. Кеу=6 N=2 8 3 6 2 6 * А список Возвращаемое значение 3. Key = 9, Start = 0, n = 5. Начинать с первого элемента и искать в списке число 9. Когда оно не найдено, возвращать значение -1. Кеу=9 N=5 8 3 6 2 6 А=список Возвращаемое значение = -1 Алгоритм последовательного поиска применяется к любому массиву, для которого оператор "==" определяется для типа элемента. Общий алгоритм поиска требует шаблонов и перегрузки операторов. Эти темы обсуждаются в главах 6 и 7. Следующая функция реализует последовательный поиск для массива целых: Функция последовательного поиска int SeqSearch(int list[], int start, int n, int key) { for (int i=start; i < n; i++) if (list [i] == key) return i; } return -1; } Программа 2.З. Повторяемый поиск Данная программа тестирует функцию последовательного поиска, подсчитывая количество появлений ключа в списке. Главная программа сначала вводит 10 целых чисел в массив А и затем запрашивает ключ. Программа выполняет повторяемые вызовы SeqSearch, используя различный начальный индекс. В исходном положении мы начинаем с индекса
О, начала массива. После каждого вызова SeqSearch счетчик количества появлений увеличивается, если ключ находится; в противном случае поиск прекращается, и счетчик является возвращаемым значением. Если ключ найден, возвращаемое значение определяет его позицию в списке. Следующий вызов SeqSearch выполняется со значения start, равного положению элемента, находящегося непосредственно справа от последнего найденного. // рг02__03.срр #include <iostream.h> // поиск в массиве из п целых значений элемента по ключу; // возвратить указатель на этот элемент или NULL, если элемент не найден int SeqSearch(int list[], int start, int n, int key) { for(int i=start;i < n; i++) ■ if (list[ij == key) return i; // возвратить индекс соответствующего элемента return -1; // неудачный поиск, возвратить -1 } void main(void) { int A[10]; int key, count = 0, pos; // запрос на ввод списка 10-ти целых чисел cout << "Введите список из 10 целых чисел: "; for (pos=0; pos < 10; pos++) cin >> A[pos]; cout « "Введите ключ: "; cin » key; // начать поиск с первого элемента массива pos = 0; // продвигаться по списку, пока ключ находится while ((pos = SeqSearch(A,pos,10,key)) != -1) { COUnt++; // продвинуться к следующему целому после найденного pos++; } cout << key « " появляется " « count « " раз(а) в этом списке." << endl; } /* Запуск программы рг02_03.срр Введите список из 10 целых чисел: 5298158753 Введите ключ:5 5 появляется 3 раз(а) в этом списке. */
Обменная сортировка Упорядочение элементов в списке является важным для многих приложений. Например, некоторый список может сортировать записи по их инвентарным номерам для обеспечения быстрого доступа к элементу, словарь сохраняет слова в алфавитном порядке и регистрационные порядковые записи студентов — по их номерам социального страхования. Для создания упорядоченного списка мы вводим алгоритм сортировки, называемый ExchangeSort, который упорядочивает элементы в возрастающем порядке. Этот алгоритм иллюстрируется списком 8, 3, 6, 2 и создает упорядоченный список 2, 3, 6, 8. Индекс 0: Рассмотрим полный список 8, 3, 6, 2. Элемент с индексом О сравнивается с каждым последующим элементом в списке с индексами 1, 2 и 3. Для каждого сравнения, если последующий элемент меньше, чем элемент с индексом 0, эти два элемента меняются местами. После выполнения всех сравнений наименьший элемент помещается в позицию с индексом 0. Индекс 0 Исходный список Действие Обмен Нет обмена Обмен Полученный список Индекс 1: При уже помещенном в позицию с индексом 0 самом маленьком элементе рассмотрим подсписок 8, 6, 3. Принимаются во внимание только элементы от индекса 1 до конца списка. Элемент с индексом 1 сравнивается с последующими элементами с индексами 2 и 3. Для каждого сравнения, если больший элемент находится в позиции с индексом 1, то два элемента меняются местами. После выполнения сравнений второй наименьший элемент в списке сохраняется в позиции с индексом 1. Индекс 1 Исходный список Действие Полученный список Обмен Обмен Индекс 2: Рассмотрим подсписок 8, 6. Этот процесс продолжается для подсписка из двух элементов с индексами 2 и 3. Между элементами выполняется простое сравнение, в результате которого происходит обмен. Индекс 2 Исходный список Действие Полученный список Обмен
У нас остался только один элемент с индексом 3, и список отсортирован. Отсортированный список 2 3 6 8 В C++ функция ExchangeSort использует вложенные циклы. Предположим, что размер списка задается значением п. Внешний цикл приращивает индекс i в диапазоне от 0 до п-2. Для каждого индекса i сравним последующие элементы при j=i+l, i+2, ..., п-1. Выполним сравнение и поменяем местами элементы, если listfi] > list[j]. Программа 2.4. Сортировка списка Эта программа иллюстрирует алгоритм сортировки. Список из 15 целых в диапазоне от 0 до 99 заполняет list. ExchangeSort упорядочивает список, используя функцию Swap для того, чтобы поменять местами два элемента массива. Программа выдает на экран список до и после сортировки. // рг02_04.срр #include <iostream.h> // поменять значения двух переменных целого типа х и у void Swap(int & х, int & у) { int temp - х; // сохранить первоначальное значение х х = у; // заменить х на у у - temp; // присвоить переменной у // первоначальное значение х } // сортировать целый массив n-элементов а в возрастающем порядке void ExchangeSort(int а[], int n) { int i, j; // реализовать n — 1 проходов.найти правильные значения // в а[],...,а[п-2]. for(i = 0; i < n-1; i++) // поместить минимум из а[п+1]...а[п-1] в a[i] for(j = i+1; j < n; j++) // заменить if a[i] > a[j] if (a[i] > a[j]) Swap(a[i], a[j]); } // пройти по списку, печатая каждое значение void PrintList(int a[], int n) { for (int i = 0; i n; i++) cout « a[i] < " "; cout « endl; } void main(void) { int list[15] - {38,58,13,15,51,27,10,19, 12,86,49,67,84,60,25);
int i; cout « "Исходный список \n"; PrintList(list,15); ExchangeSort(list,15); cout « endl «"Отсортированный список" « endl; PrintList(list,15); ) /* Оапуск программы pr02_04 . cpp> Исходный список 38 58 13 15 51 27 10 19 12 86 49 67 84 60 25 Отсортированный список 10 12 13 15 19 25 27 38 49 51 58 60 67 84 86 V Подсчет зарезервированных слов C++ В разделе 2.8 обсуждается тип записи, который реализуется в C++ как struct. В качестве иллюстрации записей программа подсчитывает количество раз, когда в файле появляются зарезервированные слова "else", "for", "if", "include" и "while". Эта программа использует строковые переменные также, как массив записей. Основной структурой данных программы является struct Key Word, чьи поля состоят из строковой переменной keyword и count типа integer: struct Keyword { char keyword[20]; int count; }/ В массиве KeyWordTable создается таблица для пяти зарезервированных слов. Каждый элемент в этой таблице инициализируется указанием зарезервированного слова и начального значения счетчика count=0. Например, первый инициализатор массива {"else", 0} приводит к тому, что элемент Кеу- WordTable[0] содержит строку "else" со значением счетчика 0: Keyword KeyWordTable[ ] = { {"else", 0}, {"for", 0}, {"if", 0}, {"include", 0}, {"while", 0} }; Программа читает отдельные слова в файле с помощью функции Get Word. Словом является любая последовательность символов, которая начинается с буквы и продолжается произвольным количеством букв или цифр. Например, когда представлена строка Expression: 3+5=8 (N1 + N2 = N3) GetWord извлекает слова "Expression", "Nl", "N2", и "N3" и отбрасывает другие символы. Функция SeqSearch сканирует таблицу, выполняя поиск соответствия ключевому слову. Когда поиск завершается успешно, функция возвращает индекс соответствующей записи, увеличивая на единицу поле count.
Программа 2.5. Подсчет зарезервированных слов Эта программа читает собственный исходный код в качестве ввода. В цикл читается каждое слово и вызывается функция SeqSearch для определения того, соответствует ли ввод зарезервированному слову в KeyWordTable. Если так, поле count в записи увеличивается на единицу. После завершения ввода, количество появлений каждого ключевого слова выводится на экран. Программа имеет интересный оператор, который динамически вычисляет количество элементов в массиве KeyWordTable с помощью выражения sizeof (KeyWordTable)/ sizeof (Keyword) Это выражение предоставляет независимый от системы метод вычисления количества элементов в каком-либо массиве. Если другие ключевые слова добавляются к этой таблице, последующая компиляция генерирует новый подсчет элементов. // рг02_05.срр #include <Iostream.h> #include <fstream.h> #include <string.h> #include <ctype.h> #include <stdlib.h> // объявление структуры слова struct Keyword { char keyword[20]; int count; }; // объявление и инициализация таблицы слов Keyword KeyWordTable[]= { {"else", 0), {"for", 0}, {"if", 0}, {"include", 0}, {"while", 0} ); // настраиваемый алгоритм поиска слов int SeqSearch(Keyword *tab, int n, char *word) { int i; // сканировать список, сравнивать word с keyword в текущей записи for (i=0; i < n; i++, tab++) if (strcmp(word, tab-keyword) == 0) return i; // при совпадении вернуть индекс return -1; // к сожалению, нет совпадения } // извлечь слово, начинающееся с буквы и, возможно, // другие буквы/цифры int GetWord(ifstreams fin, char w[]) { char c; int i = 0; // пропустить не алфавитный ввод while ( fin.get(с) && lisalpha(c) ) ; // вернуть 0 (Неудача) в конце файла
if (fin.eof () ) return 0; // записать первый символ word w[i++] =с; // собирать буквы, цифры и символ окончания строки while ( fin.get (с) && ( isalpha(c) | | isdigit(c) ) ) w[i++] = с; w[i] = 'Nonreturn 1; // вернуть 1 (Успех) } void main (void) { const int MAXWORD = 50; // максимальный размер любого слова // объявить и инициализировать размер таблицы const int NKEYWORDS = sizeof(KeyWordTable)/sizeof(Keyword); int n; char word [MAXWORD] , c; ifstream fin; // открыть файл с проверкой ошибки fin.open("pr02_05.cpp", ios::in | ios::nocreate); if (!fin) { cerr « "Невозможно открыть файл 'pr02_05.cpp' " « endl; exit (1) ; } // извлекать слова до конца файла while (GetWord(fin,word)) // при совпадении с таблицей keyword увеличивать счетчик if ((n= SeqSearch(KeyWordTable,NKEYWORDS,word)) != -1) KeyWordTable[n].count++; // сканировать таблицу keyword и печатать поля записи for (n = 0; n < NKEYWORDS; n++) if (KeyWordTable[n].count > 0) { cout « KeyWordTable[n].count; cout « " " « KeyWordTable[n].keyword « endl; } fin.close(); } */ Запуск программы pr02_05 . cpp 1 else 3 for 6 if 6 include 4 while */
Письменные упражнения 2.1 Вычислите десятичное значение каждого двоичного числа: (а) 101 (б) 1110 (в) 110111 (г) 1111111 2.2 Напишите каждое десятичное число в двоичном представлении: (а) 23 (б) 55 (в) 85 (г) 253 2.3 В современных компьютерных системах адреса обычно реализуются на аппаратном уровне как 16-битовые или 32-битовые двоичные значения. Естественно работать с адресами в двоичном представлении, а не преобразовывать их в десятичную систему. Поскольку числа такой длины затруднительно записывать как строку двоичных цифр, в качестве основания используется 16 или шестнадцатиричные (hexadecimal) числа. Такие числа, упоминаемые как hex numbers, являются важным представлением целых чисел и позволяют легко выполнять преобразования в двоичную систему и наоборот. Большинство системных программ имеет дело с машинными адресами в шестнадцатиричной системе. Шестнадцатиричные числа строятся на базе числа 16 с цифрами в диапазоне 0-15 (десятичном). Первые 10 цифр являются производными от десятичных чисел: 0, 1, 2, 3, . . ., 9. Цифры от 10-15 представлены буквами А, В, С, D, Е и F. Степени 16: 16° = 1, 161 = 16, 162 = 256, 163 = 4096 и так далее. В форме позиционной нотации примерами шестнадцатиричных чисел являются 17Е, 48 и FFFF8000. Числа преобразуются в десятичную форму расширением степеней 16 точно так же, как степени 2-х расширяются для двоичных чисел. Например, шестнадцатиричное число 2A3Fi6 преобразуется в десятичное расширением степеней 16-и. 2A3F16 = 2(1б3) + А(1б2 ) +3 (161) +F(16°) = 2(4096) +10 (256) +3 (16) +15 (1) - 8192 + 2560 +48 +48 + 15 = 1018510 Преобразуйте каждое шестнадцатиричное число в десятичное (а) 1А (б) 41F (в) 10ЕС (г) FF (д) 10000 Преобразуйте каждое десятичное число в шестнадцатиричное (е) 23 (ж) 87 (з) 115 (и) 255 2.4 Основной причиной введения шестнадцатиричных чисел является их естественное соответствие двоичным числам. Они обеспечивают компактное представление двоичных данных и адресов памяти. Шестнадцатиричные цифры имеют 4-битовое двоичное представление в диапазоне 0-15. Следующая таблица показывает соответствие между двоичными и шестнадцатиричными цифрами: Шестнадцатиричные 0 1 2 3 4 5 6 7 Двоичные 0000 0001 0010 0011 0100 0101 0110 0111 Шестнадцатиричные 8 9 А В С D Е F Двоичные 1000 1001 1010 1011 1100 1101 1110 1111
Для представления двоичного числа в шестнадцатиричном формате начинайте с правого конца числа и разделяйте биты на группы из четырех битов, добавляя начальный 0 слева в последней группе, если необходимо. Запишите каждую группу из 4-х битов как шестнадцатиричное число. Например: 1111000111011102 » 0111 1000 1110 1110 = 78ЕЕ1б Для преобразования шестнадцатиричного числа в двоичное выполните обратное действие и запишите каждое шестнадцатиричное число как 4 бита. Рассмотрим следующий пример: А7891б = 1010 0111 1000 1001 = 10100111100010012 Преобразуйте двоичные числа в шестнадцатиричные: (а) 1100 (б) 1010 ОНО (в) 1111 0010 (г) 1011 1101 1110 ООН Преобразуйте шестнадцатиричные числа в двоичные: (д) 061016 (е) AF2016 2.5 C++ позволяет программисту вводить и выводить числа в шестнадцатиричном представлении. При помещении манипулятора "hex" в поток режим ввода или вывода чисел становится шестнадцатиричным. Этот режим действует до тех пор, пока он не поменяется на десятичный с помощью манипулятора "dec". Например: cin >> hex » t » dec » u; // t читается как шестнадцатиричное; u — как десятичное <ввод 100 25б> t = 1001б и и = 25б10 cout « hex « 100 « t « u; // вывод 64 100 100 cout « dec « 100 << t « u; // вывод 100 256 256 Рассмотрим следующее объявление и выполняемые операторы: int i, j, k; cin » i ; cin » hex » j » dec; cin >> k; (а) Предположим, ввод является 50 50 32. Каков вывод для оператора? cout « hex « i « " " « j « " " « dec « k « endl; (б) Предположим, ввод является 32 32 64. Каков вывод для этого оператора? cout « dec « i « " " « hex << j « " " « k « endl; 2.6 Напишите полную спецификацию для оператора % в целом ADT. Выполните то же для оператора сравнения !=. 2.7 Булев тип определяет данные, которые имеют значения True или False. Некоторые языки программирования определяют базовый булев тип с рядом встроенных функций для обработки этих данных. C++ ассоциирует булево значение с каждым числовым выражением. (а) Определите булев ADT, описывающий область данных, и его операции. (б) Опишите реализацию этого ADT, используя языковые конструкции C++.
2.8 (а) Какой символ ASCII соответствует десятичному числу 78? (б) Какой символ ASCII соответствует двоичному числу 1001011г? (в) Каковы коды ASCII для символов "*", "q" и возврата каретки? Дайте ответы в десятичном и шестнадцатиричном представлении. 2.9 Что печатается следующим фрагментом кода? cout « char (86) « " " « int ( ' q' ) « " " « char( int ("0") + 8) « endl; 2.10 Объясните, почему оператор % (остаток) не дается в ADT для вещественных чисел. 2.11 Преобразуйте каждое двоичное число с фиксированной точкой в десятичное: (а) 110.110 (б) 1010.0101 (в) 1110.00001 (г) 11.111 . . . 111 . . . (Совет: Используйте формулу для суммы геометрического ряда). 2.12 Преобразуйте каждое десятичное число с фиксированной точкой в двоичное: (а) 2.25 (б) 1.125 (в) 1.0875 2.13 (а) Существует ли наименьшее положительное действительное число в ADT для вещественных чисел? Почему да или почему нет? (б) Когда в компьютере следует использовать вещественное числа, существует ли наименьшее положительное вещественное число? Почему да или почему нет? 2.14 Формат IEEE с плавающей точкой сохраняет знак числа отдельно, а порядок и мантиссу — как беззнаковые числа. Нормализованная форма позволяет получить уникальное представление для каждого числа с плавающей точкой. Нормализованная форма: Число с плавающей точкой задается так, что имеет одну не равную нулю цифру слева от двоичной точки N = ± 1 .d1d2 • • • dn_x 2е Число с плавающей точкой 0.0 сохраняется со знаком, порядком и мантиссой 0. В качестве примера: два двоичных числа преобразуются в представление в нормализованной форме. Двоичное число Нормализованная форма 1101.101 х 21 1.1011010 х 24 0.0011 х 2б 1.1 х 23 Тридцати-двух-битовые числа с плавающей точкой сохраняются в нормализованной форме с использованием внутреннего формата IEEE.
Знак Самый левый бит используется для знака. "+" имеет знаковый разряд 0, и "-" имеет знаковый разряд 1. Порядок Порядок задается 8-ю битами. Для обеспечения сохранения всех порядков как положительных (беззнаковых) чисел, формат IEEE задает использование нотации "excess -127" для порядка. Сохраняемый порядок (Ехр3) создается добавлением 127 к реальному порядку. Exps = Exp + 127 Истинный порядок Сохраняемый порядок Диапазон Диапазон -127 < Ехр < 128 0< Exps < 255 Мантисса Допустим, что число сохраняется в нормализованной форме, начальная цифра 1 скрыта, дробные цифры сохраняются в 23-битовой мантиссе, задается точность 24 бита. Знак 1 бит Порядок 8 битов Мантисса 23бита В качестве примера вычислим внутреннее представление -0.1875. Нормализованная форма (-) 1.100 * 2~3 Знак 1 Порядок Exps = -3 + 127 = 124 = 011111002 Мантисса <1>1000000 ... 0 -0.1875 - 10111110010000000000000000000000 Запишите каждое число в 32-битовой форме IEEE с плавающей точкой: (а) 7.5 (б) -1/4 Каково значение следующих 32-битовых чисел в формате IEEE в десятичной форме? Каждое число дается в шестнадцатиричной форме. (в) С1800000 (г) 41Е90000 2.15 (а) Перечислите в календарном порядке месяцы года, которые имеют символ "р" в имени. Это перечислимый тип. (б) Напишите реализацию C++ для перечислимого типа. (в) Какой месяц соответствует целому числу 4 в реализации C++? Какова позиция октября? (г) Напишите это перечисление в алфавитном порядке. Имеют ли какие- либо месяцы одну и ту же позицию в обоих списках? 2.16 Добавьте операции successor и predecessor к ADT для перечислимых типов. Используйте полные спецификации. Successor возвращает следующий элемент в списке, и predecessor возвращает предыдущий элемент. Будьте осторожны при определении того, что происходит на границах списка.
2.17 С учетом следующих объявлений и операторов укажите содержимое X, Y и А после выполнения этих операторов: int X =4, Y=7, *РХ = &Х, *PY; double А[ ] = {2.3, 4.5, 8.9, 1.0, 5.5, 3.5}, *РА - А; PY = &Y; (*РХ)--; *PY += *РХ; PY = РХ; *PY = 55; *РА += 3.0; РА++; *РА++ =* 6.8; РА+= 2; *++РА = 3.3; 2.18 (а) А объявляется как А[5]; short A[5]; Сколько байтов выделяется для массива А? Если адрес массива А = 6000, вычислите адрес А[3] и А[1]. (б) Предположим такое объявление: long А[ ] - {30, 500000, -100000, 5, 33}; Если длинное слово занимает 4 байта и адрес А равен 2050, □ Каково содержимое с адресом 2066? D Удвойте содержимое с адресом 2050 и адресом 2062. Выпишите массив А. □ Какой адрес у А[3]? 2.19 Предположим, что А — это массив размером mxn с индексами строк в диапазоне 0 — (ш-1) и индексами столбцов в диапазоне 0 — (п-1). Генерируйте функцию доступа, вычисляющую адрес A[row] [col], полагая, что элементы сохраняются столбцами. 2.20 А объявляется как short А[5] [б]; (а) Сколько байтов выделено для массива А? (б) Если адрес массива А = 1000, вычислите адрес А[3] [2] и А[1] [4]. (в) Какой элемент массива помещается по адресу 1020? По адресу 1034? 2.21 (а) Объявите строку Name с вашим именем в качестве начального значения. (б) Рассмотрите объявления строковой переменной: char sl[50], s2[50]; и операторы ввода: cin » SI >> S2; Каково значение SI и S2 для строки ввода "Джордж спешит!"? Каково значение S1 и S2 при вводе следующего текста (Ф— это пустой символ, а 1| — это конец строки.)?: Next* ^^<->Word
2.22 Рассмотрим следующие строковые объявления: char SI[30] = "Stockton, CA", S2[30] = "March 5, 1994м, *р; char S3 [30]; (а) Каково значение *p после выполнения каждого следующего оператора? р = strchr (SI, 't'); р = strrchr (SI, 't'); p = strrchr (S2, '6'); (б) Каково значение S3 после выполнения: strcpy (S3,SI); strcat (S3, ","); strcat (S3,S2); (в) Какое значение возвращается вызовом функции strcmp (S1.S2)? (г) Какое значение возвращается вызовом функции strcmp (&Sl[5],"ton")? 2.23 Функция void strinsert (char *s, char *t, int i); вставляет строку t в строку s в позиции с индексом i. Если i больше длины s, вставка не выполняется. Реализуйте strinsert, используя библиотечные функции C++ strlen, strcpy и strcat. Вам потребуется объявить временную строковую переменную для хранения оригинальных символов в s с индекса i до индекса strlen(s) -1. Вы можете полагать, что этот хвост никогда не превышает 127 символов. 2.24 Функция: void strdelete(char *s, int i, int n) ; удаляет последовательность п символов из строки s, начиная с индекса i. Если индекс i больше, чем длина s или равен ей, то никакие символы не удаляются. Если i+/z больше, чем длина s или равено ей, то удаляется конец строки, начиная с индекса i. Реализуйте strdelete, используя библиотечные функции C++ strlen и strcpy. 2.25 Альтернативой использованию строк с NULL-символом является помещение счетчика символов в первый элемент символьного массива. Это называется форматом со счетчиком байтов, и такие строки часто называют строками Паскаля, поскольку программные системы на языке Паскаль используют этот формат для строк. (а) Реализуйте функцию strcat, полагая, что строки сохраняются в формате со счетчиком байтов. (б) Функции PtoCStr и CtoCStr выполняют преобразование этих двух строковых форматов: void PtoCStr(char *s); // конвертировать s из Pascal в C++ void CtoPStr(char *s); // конвертировать s из C++ в Pascal Реализуйте эти две функции.
2.26 Добавьте оператор присваивания "=" к ADT записи, используя полную спецификацию. Точно определите, какое действие выполняется во время присваивания. 2.27 Комплексное число имеет форму х + iy, где i2 = -1. Комплексные числа имеют широкое применение в математике, физике и технике. Они имеют арифметику, управляемую рядом правил, включая следующие: Пусть и = а + ib, v = с + id u + v = (а + с) + i <b + d) u — v = (a — с) + i (b — d) u v ac + bd c2 + d2 + i be - ad c2 + d2 Представьте комплексное число, используя следующую структуру: struct Complex { float real; float imag; } и реализуйте следующие функции, которые выполняют операции с комплексными числами: Complex cadd(Complex& х, Complex& у); // х + у Complex csub{Complex& x, Complex& у); // х — у Complex cmul(Complex^ х, Complex& у); // х * у Complex cdiv(Complex& х, Complexs у); // х / у 2.28 Добавьте операцию FileSize к ADT для потоков. Она должна возвращать количество символов в файле. Точно укажите, для каких предусловий эта операция имеет смысл. (Совет: Как насчет cin/cout?) 2.29 Четко различайте текстовый и двоичный файл. Как вы думаете, возможно ли разработать программу, принимающую имя файла в качестве входа и определить текстовый он или бинарный? Упражнения по программированию 2.1 Напишите функцию void BaseOut(unsigned int n, int b) которая выводит п с основанием b, 2 < b <. 10. Напечатайте каждое число в диапазоне 2 < п < 50 с основанием 2, 4, 5, 8 и 9. 2.2 Напишите функцию void Octln(unsigned int& n); которая читает число с основанием 8 (восьмеричное) и присваивает его п. Используйте Octln в главной программе, которая читает следующие восьмеричные числа и печатает десятичные эквиваленты: 7, 177, 127, 7776, 177777 2.3 Напишите программу, которая объявляет три целые переменные i, у, k. Введите значение для i в десятичной форме и значения для у и ft — в шестнадцатиричной. Напечатайте все три переменные и в шестнадцатиричной, и в десятичной форме.
2.4 Изучите дискретность представления вещественных чисел на вашем компьютере путем вычисления 1 + D для D = 1/10, 1/100, 1/1000, ..., 1/10п до тех пор, пока 1 + D == 1.0. Если вы имеете доступ более, чем к одной машинной архитектуре, попробуйте выполнить это на других машинах. 2.5 Рассмотрим перечислимый тип enum DaysOfWeek {Sun, Mon, Tue, Wed, Thurs, Fri, Sat}; Напишите функцию void GetDay(DaysOfWeek& day); которая читает имя дня с клавиатуры как строку и присваивает дню соответствующее значение элемента перечисления. Напишите также функцию void PutDay(DaysOfWeek day) которая записывает значение элемента перечисления на экране. Разработайте главную программу для тестирования этих двух функций. 2.6 Введите ряд слов до конца файла, преобразуя каждое в слово на ломаной латыни. Если слово начинается с согласного, переместите первый символ слова на последнюю позицию и присоедините "ау". Если слово начинается с гласного, просто присоедините "ау". Например: Вход: this is simple Выход: histay isay implesay 2.7 Строка текста может быть зашифрована с использованием табуляцион- ного соответствия, которое ассоциирует каждую букву алфавита с уникальной буквой. Например, табуляционное соответствие abcdefghijklmnopqrstuvwxyz ==> ngzqtcobmuhelkpdawxfyivrsj устанавливает соответствие между "encrypt" и "tkzwsdf". Напишите программу, которая читает текст до конца файла и выводит зашифрованную форму. 2.8 Создайте свою программу табуляционного соответствия, которая выполняет установку соответствия, обратную той, которая использовалась в упражнении 2.7. Введите зашифрованный файл и выведите на экран его расшифрованную форму. 2.9 Напишите программу, которая вызывает выход за границы одного или более индексов массива. Доведите программу до такого состояния, чтобы она "разрушалась". Притворитесь, что вы не знаете, в чем заключается проблема. Используйте любой имеющийся в вашем распоряжении отладчик и диагностируйте причину такого поведения. 2.10 Измените сортировку обмена так, чтобы она сортировала список в порядке убывания. Протестируйте новый алгоритм, написав главную программу, подобную программе 2.4. 2.11 Рассмотрим объявление записи struct Month { char name[10]; // имя месяца int monthnum; // число дней в месяце };
(а) Напишите функцию void SortByName(Month months[ ], int n) ; которая сортирует массив с элементами типа Month, сравнивая имена (используйте функцию strcmp в C++). Напишите также функцию void SotrByDays (Month months[ ], int n); которая сортирует список, сравнивая количество дней в месяце. Напишите главную программу, которая объявляет массив, содержащий все месяцы года и сортирует его, используя обе функции. Выведите на экран каждый отсортированный список. (б) Заметьте, что сортировка списка месяцев по количеству дней создает связи соперничества. Когда это происходит, метод сортировки может использовать вторичный ключ (secondary key) для устранения этих связей. Напишите функцию void Sort2ByDays(Month months[ ], int n); которая сортирует список, сравнивая сначала количество дней, и, если связь возникает, разбивает ее, сравнивая имена. Используйте эту функцию в главной программе для распечатки упорядоченного списка всех месяцев года, упорядоченных по количеству дней в месяце. 2.12 Напишите программу, которая читает текстовый файл и выводит на экран счетчик количества появлений знаков пунктуации(. , ! ?). 2.13 Используя cin.getline, читайте строку, начинающуюся с имени функции из одного символа, за которым следуют последовательности "х" с символами "+" и "-", вставленными в промежутки. Строка не может заканчиваться символом "+" или "-". Образуйте строку в форме: SingleCharFuncName (х) = х**п ± х**щ±. . . в массиве, используя выход на базе массива. Если порядком является равным 1, то опустите "**1". Запишите каждую строку в файл "funcs.val". Например, строки Fxxx + xx — x Gxx — xxx + xxxx создают файл "funcs.val", имеющий строки F(x) = х**3 + х**2 - х G(x) = х**2 - х**3 +х**4 2.14 Напишите программу, которая вводит N х N матрицу А целых значений и выводит на экран след матрицы. След матрицы определяется как сумма диагональных элементов Trace (А) = А[0, 0} +А[1, 1] + . . . + A[N - 1, N - 1] 2.15 Это упражнение использует результаты упражнения 2.27 из предыдущего раздела "Упражнения". Напишите функцию f(z), вычисляющую комплексную полиномиальную функцию: z3 - 3z2 + 4z - 2 Определите полиномиал для следующих значений z: z = 2 +3i, -1 + i, 1 + i, 1 - i, 1 + Oi Заметьте, что последние три значения являются корнями от f.
глава Абстрактные типы данных и классы Т 3.1. Пользовательский тип - КЛАСС 3.2. Примеры классов 3.3. Объекты и передача информации 3.4. Массивы объектов 3.5. Множественные конструкторы 3.6. Практическое применение: треугольные матрицы Письменные упражнения Упражнения по программированию
В главе 1 были даны абстрактные типы данных (ADT) и их представление в качестве классов C++. Это введение описывает структуру класса, которая обеспечивает инкапсуляцию данных и скрытие информации. В этой главе содержится более полное описание базовых концепций класса. Мы рассматриваем разработку и использование конструкторов класса, реализацию методов класса и использование классов с другими структурами данных. Для обеспечения хорошего понимания классов читателем мы разрабатываем широкий диапазон примеров и используем их в законченных программах. Выбранные соответствующим образом ADT иллюстрируют связь между абстрактной структурой и объявлением класса. 3.1. Пользовательский тип — КЛАСС Класс — это определяемый пользователем тип с данными и функциями (методами), называемыми членами (members) класса. Переменная типа класс называется объект (object). Класс создает различные уровни доступа к его членам, разделяя объявление на части: private, protected и public. Часть private (закрытая) объекта может быть доступна только для функций-членов в этом классе. Часть public (открытая) объекта может быть доступна для внешних элементов программы, в области действия которых находится этот объект (рис. 3.1). Protected (защищенные) члены используются с производными классами и описываются в главе 12, посвященной наследованию. Члены класса private: Данные Операторы public: Данные Операторы Внешние программные единицы Рис. 3.1. Доступ к методам класса Объявление класса Объявление класса начинается с заголовка класса (class head), состоящего из зарезервированного слова class, за которым следует имя класса. Члены класса определяются в теле класса (class body), которое заключается в фигурные скобки и заканчивается точкой с запятой. Зарезервированные слова public и private разделяют члены класса, и эти спецификаторы доступа заканчиваются двоеточием. Члены класса объявляются как переменные C++, а методы задаются как объявления функций C++. Общая форма объявления класса такова: class Имя_класса { private: // Закрытые данные // Объявление закрытых методов // public: // Открытые данные // Объявление открытых методов // };
Следует, по возможности, помещать члены класса в закрытую секцию. В результате этого значение данных обновляется только функцией-членом класса. Это предотвращает нежелательные изменения в данных кодом использующего класс приложения. Пример 3.1 Класс Rectangle При геометрических измерениях прямоугольник определяется его длиной и шириной. Это позволяет нам вычислять периметр и площадь фигуры. Параметры длины и ширины и операции объединяются для образования абстрактного типа данных прямоугольной фигуры. Мы оставляем спецификацию ADT в качестве упражнения и разрабатываем класс Rectangle C++, который реализует этот ADT. Класс содержит конструктор и набор методов — GetLength, PutLength, Get Width и PutWidth, имеющих доступ к закрытым членам класса. Объявление класса Rectangle следующее: class Rectangle { private: //длина и ширина прямоугольного объекта float length, width; public: // конструктор Rectangle(float 1=0, float w = 0); // методы для нахождения и изменения закрытых данных float GetLength(void) const; void PutLength(float 1); float GetWidth(void) const; void PutWidth(float w); // вычислять и возвращать измерения прямоугольника float Perimeter(void) const; float Area(void) const; }; Обратите внимание, что методы GetLength, Get Width, Perimeter и Area имеют ключевое слово const после списка параметров. Это объявляет каждый метод как константный. В определении константного метода никакой элемент данных не может быть изменен. Другими словами, выполнение метода, объявленного как const, не изменяет состояния объекта Rectangle. Если первый спецификатор доступа опускается, начальные члены в классе являются закрытыми по умолчанию. Члены класса являются закрытыми до первого появления открытой или защищенной спецификации. C++ позволяет программисту чередовать закрытую, защищенную и открытую секции, хотя это обычно не рекомендуется. Конструктор Функция, называемая конструктором (constructor) класса, имеет то же имя, что и класс. Подобно другим функциям C++, конструктору могут передаваться параметры, используемые для инициализации одного или более данных-членов класса. В классе Rectangle конструктору дается имя Rectangle, и он принимает параметры 1 и w, используемые для инициализации длины и ширины объекта,
соответственно. Заметьте, что эти параметры имеют значения, по умолчанию, которые указывают, что используется значение 0, когда параметр 1 или w не передается явно. Пример 3.1 иллюстрирует объявление класса (class definition), так как методы описываются только объявлениями функций. Код C++ для определения отдельных функций создает реализацию класса (class implementation). Объявление объекта Объявление класса описывает новый тип данных. Объявление объекта типа класс создает экземпляр (instance) класса. Это делает реальным объект типа класс и автоматически вызывает конструктор для инициализации некоторых или всех данных-членов класса. Параметры для объекта передаются конструктору заключением их в скобки после имени объекта. Заметьте, что конструктор не имеет возвращаемого типа, поскольку вызывается только во время создания объекта: ClassName object(<parameters>); //список параметров может быть пустым Например, следующие объявления создают два объекта типа Rectangle: Rectangle room(12, 10); Rectangle t; //использование параметров по умолчанию (0, 0). Каждый объект имеет полный диапазон данных-членов и методов, объявляемых в классе. Открытые члены доступны с использованием имени объекта и имени члена, разделяемых "." (точкой). Например: х - room.Area(); // присваивает х площадь = 12 * 10 = 120 t.PutLength(20); // присваивает 20 как длину объекта Rectangle // Текущая длина равна 0, так как используются // параметры по умолчанию, cout < t.GetWidthO; // выводит текущую ширину, которая = 0 по умолчанию В объявлении объекта Room конструктор первоначально устанавливает значение длины, равным 12, а ширины — 10. Клиент может изменять размеры, используя методы доступа PutLength и PutWidth: room.PutLength(15); // изменение длины и ширины на 15 и 12 room.PutWidth(12) ; Объявление класса не обязательно должно включать конструктор. Это действие, которое не рекомендуется и не используется в этой книге, оставляет объект с неинициализированными данными в точке его объявления. Например, класс Rectangle может не иметь конструктора, а клиент мог бы задать длину и ширину с помощью открытых методов доступа. Включая в класс конструктор, мы обеспечиваем правильную инициализацию важных данных. Конструктор позволяет объекту инициализировать его собственные данные-члены класса. Класс Rectangle содержит члены класса типа float. В общем, класс может содержать элементы любого допустимого типа C++, даже других классов. Однако, класс не может содержать объект его собственного типа в качестве члена. Реализация класса Каждый метод в объявлении класса должен быть определен. Определения функций могут быть заданы в теле класса (встроенный код) или вне его. При помещении функции вне тела имя класса, за которым следует два двое-
точия, должно предшествовать имени этой функции. Символ "::" называется операцией разрешения области действия (scope resolution operator) и указывает на то, что функция принадлежит области действия класса. Это позволяет всем операторам в определении функции иметь доступ к закрытым членам класса. В случае с классом Rectangle идентификатор "Rectangle::" предшествует именам методов. Далее следует определение GetLength(), когда она записана вне тела класса Rectangle: float Regtangle::GetLength(void) const < return length; // доступ к закрытому члену length } Заметьте, что при определении константного метода может быть такэке использован квалификатор const. Функция-член класса может быть записана внутри тела класса. В этом случае код является расширенным встраиваемым (expanded inline), а операция разрешения области действия не используется, так как код находится в области действия тела класса. Встраиваемое определение операции GetLength имеет вид: class Rectangle { private: float length; float width; public: • • * float GetLength(void) const // код задается как inline { return(length); } * • • }; В этой книге обычно функции-члены определяются вне тела класса для того, чтобы придать особое значение различию между объявлением и реализацией класса. Inline-код используется в этой книге редко. Реализация конструктора Конструктор может быть определен как inline или вне тела класса. Например, следующий код определяет конструктор Rectangle: Rectangle::Rectangle(float 1, float w) { length « 1; width = w; } C++ предоставляет специальный синтаксис для инициализации членов класса. Список инициализации членов (member initialization list) — это список имен данных-членов класса, разделенных запятыми, за каждым из которых следует начальное его значение, заключенное в скобки. Начальные значения обычно являются параметрами конструктора, которые присваиваются соответствующим данным-членам класса в списке. Список инициализации членов помещается после заголовка функции и отделяется от списка параметров двоеточием:
ClassName: :ClassName (parm list) : datax (parir^), . . . , datantparn^) Например, параметры конструктора 1 и w могут быть присвоены данным- членам класса length и width: Rectangle::Rectangle(float 1, float w) : length (1), width(w) {} Создание объектов Один объект может использоваться для инициализации другого в каком- либо объявлении. Например, следующий оператор является правильным: Rectangle square(10, 10), yard = square, S; Объект square создается с length и width, равными 10. Второй объект yard создается с начальными данными, копируемыми из объекта square. Объект S имеет length и width, по умолчанию равными 0. Объекты могут свободно присваиваться один другому. Если только пользователь не создает пользовательский оператор присваивания, присваивание объекта может выполняться побитовым копированием данных-членов класса. Например, присваивание S = yard; копирует все данные из объекта yard в объект S. В этом случае length и width объекта yard копируются в length и width объекта S. Объект может быть создан ссылкой на его конструктор. Например, объявление Rectangle(10,5) создает временный объект с lengh = 10 и width = 5. В следующем операторе операция присваивания копирует данные из временного объекта в rectangle S: S = Rectangle(10,5); Пример 3.2 1. Операторы S = Rectangle(10,5); cout « S.Area О « endl; приводят к выводу в поток cout числа 50. 2. Оператор cout « Rectangle(10,5).GetWidth() « endl; ВЫВОДИТ ЧИСЛО 5. Программа 3.1. Использование класса Rectangle В этой программе вычисляется относительная стоимость отделочных работ передней стороны гаража. Пользователь задает размеры передней стороны гаража, а программа выдает различные размеры и стоимость двери. Пользователь замечает, что при выборе большей двери требуется меньше материала для обшивки и опалубки для кладки бетона. Учитывая стоимость пиломатериалов большая дверь может быть более экономичной.
Предположим, что опалубка проходит по периметру передней стороны и периметру проема двери. Мы запрашиваем у пользователя размер передней стороны гаража и затем вводим цикл, позволяющий выбрать размер двери. Цикл заканчивается, когда пользователем выбирается опция "Quit". Для каждого выбора двери программа определяет стоимость отделки передней стороны гаража и выводит это значение. Мы задаем константами стоимость деревянной обшивки $2 за кв. фут и стоимость опалубки на $0.50 за погонный фут. Опалубка Обшивка Дверь Длина опалубки равна сумме периметров передней стороны гаража и двери. Стоимость обшивки равна площади передней стороны гаража минус площадь двери. // рг03_01.срр #include <iostream.h> class Rectangle { private: // длина и ширина прямоугольного объекта float length,width; public: // конструктор Rectangle(float 1=0, float w = 0); // методы для получения и модификации закрытых данных float GetLength(void) const; void PutLength(float 1); float GetWidth (void) const; void PutWidth(float w); // вычисление характеристик прямоугольника float Perimeter(void) const; float Area(void) const; >; // конструктор, выполняет присваивания: length=l, width=w Rectangle::Rectangle (float 1, float w) : length(1), width(w) {} // возвратить длину прямоугольника float Rectangle::GetLength (void) const { return length; } // изменить длину прямоугольника void Rectangle::PutLength (float 1) {
length = 1; } // возвратить ширину прямоугольника float Rectangle: :GetWidth (void) const return width; // // изменить ширину прямоугольника void Rectangle: :PutWidth (float w) width = w; // вычислить и возвратить периметр прямоугольника float Rectangle::Perimeter (void) const return 2.0* (length + width); // вычислить и возвратить площадь прямоугольника float Rectangle: :Area (void) const return length*width; void main (void) // стоимости обшивки и опалубки — постоянные const float sidingCost = 2.00, moldingCost = 0.50; int completedSelections = 0; // опция из меню, выделенная пользователем char doorOption; // длина/ширина и стоимость двери float glength, gwidth, doorCost; // общая стоимость, включая дверь, обшивку и опалубку float totalCost; cout « "Введите длину и ширину гаража: "; cin » glength » gwidth; // создать объект garage (гараж) с размерами по умолчанию // создать объект door (дверь) с размерами по умолчанию Rectangle garage (glength, gwidth) ; Rectangle door; while (!completedSelections) { cout << "Введите 1-4 или ' q' для выхода" << endl « endl; cout << "Дверь 1 (12 x 8; $380) " « "Дверь 2 (12 x 10; $420) " « endl; cout « "Дверь 3 (16 x 8; $450) " « "Дверь 4 (16 x10; $480)" « endl; cout « endl; cin »doorOption; if (doorOption « ' q' ) completedSelections = 1; else
{ switch (doorOption) { case ' 1' :door.PutLength(12); // 12 x 8 ($380) door.PutWidth(8); doorCost = 380; break; case'2':door.PutLength(12) ; //12x10 ($420) door.PutWidth(lO); doorCost =420; break; case '3':door.PutLength(16); // 16 x 8 ($450) door.PutWidth(8); doorCost = 450; break; case '4':door.PutLength(12); // 16 x 10 ($480) door.PutWidth(lO) ; doorCost = 480; break; } totalCost = doorCost + moldingCost*(garage.Perimeter()+door.Perimeter()) + sidingCost*(garage.Area()-door.Area()); cout « "Общая стоимость двери, обшивки и опалубки: $" « totalCost « endl « endl; } } } /* Оапуск программы 3 . 1> Введите длину и ширину гаража: Введите 1-4 или ' q' для выхода Дверь 1 (12 х8; $380) Дверь 2 (12 х 10; $420) Дверь 3 (16 х8; $450) Дверь 4 (16 х 10; $480) Общая стоимость двери, обшивки и опалубки: $720 Введите 1-4 или ' q' для выхода Дверь 1 (12 х8; $380) Дверь 2 (12 х 10; $420) Дверь 3 (16 х8; $450) Дверь 4 (16 х 10; $480) q */ 3.2. Примеры классов Следующие два примера классов иллюстрируют конструкторы класса в C++. Класс Temperature поддерживает записи значений высокой и низкой температуры. В качестве приложения объект мог бы иметь высокую (точка кипения) и низкую (точка замерзания) температуры воды. ADT RandomNumber определяет тип для создания последовательности целых или с плавающей точкой случайных чисел. В реализации C++ конструктор позволяет клиенту самому инициализировать последовательность случайных чисел или использовать программный способ получения последовательности с системно-зависимой функцией времени.
Класс Temperature Класс Temperature содержит информацию о значениях высокой и низкой температуры. Конструктор присваивает начальные значения двум закрытым данным-членам highTemp и lowTemp, которые являются числами с плавающей точкой. Метод UpdateTemp принимает новое значение данных и определяет, должно ли обновляться одно из значений температуры в объекте. Если отмечается новое самое низкое значение, то обновляется lowTemp. Аналогично, новое самое высокое значение изменит highTemp. Этот класс имеет два метода доступа к данным: GetHighTemp возвращает самую высокую температуру, a GetLowTemp возвращает самую низкую температуру. Спецификация класса Temperature ОБЪЯВЛЕНИЕ class Temperature { private: float highTemp, lowTemp; // закрытые данные-члены public: Temperature (float h, float 1); void UpdateTemp(float temp); float GetHighTemp(void) const; float GetLowTemp(void) const; }; ОБСУЖДЕНИЕ Конструктору должны быть переданы начальные высокая и низкая температуры для объекта. Эти значения могут быть изменены методом UpdateTemp. Методы GetLowTemp и GetHighTemp являются константными функциями, так как они не изменяют никакие данные-члены в классе. Класс описан в файле "temp.h". ПРИМЕР /Уточка кипения/замерзания воды по Фаренгейту Temperature fwater{212, 32); //точка кипения/замерзания воды по Цельсию Temperature cwater(100, 0); cout « Вода замерзает при << cwater .GetLowtemp « " С" << endl; cout « Вода кипит при « fwater.GetHighTemp « " F" « endl; Выход: Вода замерзает при 0 С Вода кипит при 212 F Реализация класса Temperature Каждый метод в классе записывается вне тела класса с использованием оператора области действия. Конструктор принимает начальные показания высокой и низкой температуры, которые присваиваются полям highTemp и lowTemp. Эти значения могут изменяться только методом UpdateTemp, когда новая высокая или низкая температура передаются в качестве параметра. Функции доступа GetHighTemp и GetLowTemp возвращают значение высокой и низкой температуры. //конструктор, присвоить данные: highTemp=h и lowTemp=l Temperature::Temperature(float h, float 1): highTemp(h), lowTemp(1)
{} //обновление текущих показаний температуры void Temperature::UpdateTemp (float temp) { if (temp> highTemp) highTemp = temp; else if (temp < lowTemp) lowTemp = temp; } // возвратить high (самая высокая температура) float Temperature::GetHighTemp (void) const { return highTemp; } // возвратить low (самая низкая температура) float Temperature::GetLowTemp (void) const { return lowTemp; } Программа З.2. Использование класса Temperature // pr03_02.cpp #include <iostream.h> #include "temp.h" // void main(void) { // Temperature today (70,50); float temp; cout « "Введите температуру в полдень: "; cin » temp; // обновить объект для включения дневной температуры today.UpdateTemp(temp); cout « "В полдень: Наивысшая :" << today.GetHighTemp (); cout « " Низшая " « today.GetLowTempO « endl; cout « "Введите вечернюю температуру: "; cin » temp; // обновить объект для включения вечерней температуры today.UpdateTemp(temp); cout << "Сегодня наивысшая :" « today.GetHighTemp(); cout « " Низшая " « today.GetLowTempO « endl; } /* Оапуск программы pr03_02.cpp> Введите температуру в полдень: 80 В полдень: Наивысшая :80 Низшая 50 Введите вечернюю температуру: 40 Сегодня наивысшая :80 Низшая 40 */
Класс случайных чисел Для многих приложений требуются случайные данные, представляющие случайные события. Моделирование самолета, тестирующее реакцию летчика на непредвиденные изменения в поведении самолета, карточная игра, предполагающая, что дилер использует тасованную колоду, и изучение сбыта, предполагающее вариации в прибытии клиентов, — все это примеры компьютерных приложений, которые опираются на случайные данные. Компьютер использует генератор случайных чисел (random number generator), который выдает числа в фиксированном диапазоне таким образом, что числа равномерно распределяются в этом диапазоне. Генератор использует детерминистический алгоритм, который начинается с начального значения данных, называемого значением, инициализирующим алгоритм, или seed-значением. Алгоритм манипулирует этим значением для генерирования последовательности чисел. Этот процесс является детерминистическим, так как он берет начальное значение и выполняет фиксированный набор инструкций. Выход является уникальным, определенным данными и инструкциями. По существу, компьютер не производит истинные случайные числа, а создает последовательности псевдослучайных чисел (pseudorandom numbers), которые распределяются равномерно в диапазоне. Вследствие начальной зависимости от seed-значения, генератор создает ту же последовательность при использовании одного и того же seed-значения. Способность повторять случайную последовательность используется в исследованиях моделирования, где в приложении необходимо сравнить различные стратегии, реагирующие на один и тот же набор случайных условий. Например, имитатор полета использует одну и ту же последовательность случайных чисел для сравнения эффективности реакции двух летчиков на аварию самолета. Каждый летчик подвергается одному и тому же набору событий. Однако, если seed-значение изменяется каждый раз при запуске имитатора, мы имеем уникальное моделирование. Эта уникальность свойственна игре, которая обычно создает различную последовательность событий каждый раз в процессе игры. Большинство компиляторов предоставляют библиотечные функции, реализующие генератор псевдослучайных чисел. К сожалению, вариация этой реализации в зависимости от компилятора является значительной. Для предоставления генератора случайных чисел, переносимого из системы в систему, мы создаем класс RandomNumber. Этот класс содержит seed-значение, которое должно инициализироваться клиентом. В соответствии с начальным seed- значением генератор создает псевдослучайную последовательность. Класс обеспечивает автоматический выбор seed-значения, когда конструктору не передается никакого значения, и позволяет клиенту создавать независимые псевдослучайные последовательности. Спецификация класса RandomNumber ОБЪЯВЛЕНИЕ ♦include <time.h> // используется для генерации случайного числа //по текущему seed-значению const unsigned long maxshort - 65536L; const unsigned long multiplier = 1194211693L; const unsigned long adder = 12345L;
class RandomNumber { private: // закрытый член класса, содержащий текущее seed-значение unsigned long randSeed; public: // конструктор, параметр 0 (по умолчанию) задает автоматический // выбор seed-значения RandomNumber(unsigned long s » 0); // генерировать случайное целое в диапазоне [0, п-1] unsigned short Random(unsigned long n); // генерировать действительное число в диапазоне [0, 1.0] double fRandom(void); }; ОПИСАНИЕ Начальное seed-значение — это беззнаковое длинное число. Метод Random принимает беззнаковый длинный параметр п < 65536 и возвращает 16-битовое беззнаковое короткое значение в диапазоне 0,. . • , п - 1. Заметьте, что если возвращаемое методом Random значение присваивается целой переменной со знаком, то это значение может интерпретироваться как отрицательное, если п не будет удовлетворять неравенству п < 215 = 32768. Функция fRandom возвращает число с плавающей точкой в диапазоне 0 < fRandom() < 1.0. ПРИМЕР RandomNumber rnd; //seed-значение выбирается автоматически RandomNumber R(l); //создает последовательность с seed пользователя 1 cout « R.fRandomO; //выводит действительное число в диапазоне 0—1 //выводит 5 случайных целых чисел в диапазоне 0—99 for (int i = 0; i < 5; i++) cout « R.Random(lOO) « " "; // <sample> 93 21 45 5 3 Пример 3.3 Создание случайных данных 1. Значение грани кости находится в диапазоне 1 — 6 (шесть вариантов). Для имитации бросания кости используйте функцию die.Random(6), которая возвращает значения в диапазоне 0 — 5. Затем прибавьте 1 для перевода случайного числа в нужный диапазон. RandomNumber Die //использует автоматич. seeding dicevalue = die.Random(б) +1; 2. Объект FNum использует автоматическое задание seed-значения для создания случайной последовательности: RandomNumber FNum; Для вычисления плавающего значения в диапазоне 50 <, х < 75 генерируйте случайное число в диапазоне 0 — 25, умножая результат fRandom на 25. Это расширяет диапазон случайных чисел от 1-й единицы (0 < х < 1) до 25 единиц (0 < х < 25). Преобразуйте нижнюю границу нового диапазона, добавив 50: value = FNum. fRandom() *25 + 50; //умножение на 25; прибавление 50
Реализация класса RandomNumber Для создания псевдослучайных чисел мы используем линейный конгруэнтный алгоритм. Этот алгоритм использует большой нечетный постоянный множитель и постоянное слагаемое вместе с seed-значением для итеративного создания случайных чисел и обновления seed-значения: const unsigned long maxshort = 65536; const unsigned long multiplier = 1194211693; const unsigned long adder = 12345; Последовательность случайных чисел начинается с начального значения для длинного целого randSeed. Задание этого значения называется настройкой (seeding) генератора случайных чисел и выполняется конструктором. Конструктор позволяет клиенту передавать seed-значение или использовать для его получения машинно-зависимую функцию time. Мы подразумеваем, что функция time объявляется в файле <time.h>. При вызове конструктора с параметром 0 функция time возвращает беззнаковое длинное (32-битовое) число, указывая количество секунд, прошедших после базового времени. Используемое базовое время включает полночь 1-го января 1970 года и полночь 1-го января 1904 года. В любом случае, это большое беззнаковое длинное значение: //генерация seed-значения RandomNumber::RandomNumber (unsigned long s) { if (s == 0) randSeed = time(0); //использование системной функции time else randSeed = s; //пользовательское seed-значение } В каждой итерации используем константы для создания нового беззнакового длинного seed-значения: randSeed = multiplier * randSeed + adder; В результате умножения и сложения верхние 16 битов 32-битового значения randSeed являются случайными ("хорошо перемешанными") числами. Наш алгоритм создает случайное число в диапазоне от 0 до 65535, сдвигая 16 битов вправо. Мы отображаем это число на диапазон 0 ... п - 1, беря остаток от деления на п. Результатом является значение Random(n). //возвращать случайное целое 0 <= value <= п-1 < 65536 unsigned short RandomNumber::Random (unsigned long n) { randSeed = multiplier * randSeed + adder; return (unsigned short) ((randSeed) » 16) % n) ; } Для числа с плавающей точкой сначала вызываем метод Random(maxshort), который возвращает следующее случайное целое число в диапазоне от 0 до maxshort — 1. После деления на double(maxshort) получаем действительное число в интервале 0 < fRandom() < 1.0. double RandomNumber::fRandom (void) { return Random(maxshort)/double(maxshort); } Объявление и реализация RandomNumber содержится в файле "random.h".
Приложение; Частота выпадения лицевой стороны при бросании монет. Класс RandomNumber используется для имитации повторяемого бросания 10 монет. Во время бросания некоторые монеты падают лицевой стороной (head)1 вверх, а другие — обратной. Бросание десяти монет имеет результатом число падений лицевой стороной в диапазоне 0-10. Интуитивно вы подразумеваете, что 0 лицевых сторон или 10 лицевых сторон в бросании 10 монет — это относительно невероятно. Более вероятно, что количества выпадений разных сторон будут примерно равными. Число лицевых сторон будет находиться где-нибудь в середине диапазона 0—10, скажем, 4—6. Мы проверим это интуитивное предположение большим числом (50 0000) повторений бросания. Массив head ведет подсчет количества раз, когда соответствующий подсчет лицевых сторон составляет 0, 1, . . ., 10. Значение head[i] (0 <. i < 10) — это количество раз в 50 000 повторениях, когда ровно i лицевых сторон выпадает во время бросания 10 монет. Программа 3.3. График частоты Бросание 10 монет составляет событие. Метод Random с параметром 2 моделирует одно бросание монеты, интерпретируя возвращаемое значение 0 как обратные стороны, а возвращаемое значение 1 — как лицевые стороны. Функция TossCoins объявляет статический объект coinToss типа Random- Number, использущий автоматическое задание seed-значения. Так как этот объект является статическим, каждый вызов TossCoins использует следующее значение в одной последовательности случайных чисел. Бросание указанного количества монет выполняется суммированием 10 значений, выдаваемых CoinToss.Random(2). Возвращаемый результат приращивает соответствующий счетчик в массиве лицевых сторон. Выходом программы является частотный график количества лицевых сторон. График с числом лицевых сторон на оси х и относительным числом событий (occurences) — на оси у обеспечивает наглядное представление того, что известно как биномиальное распределение. Для каждого индекса i относительное число событий, при которых лицевые стороны выпали ровно i раз, составляет heads[i]/float(NTOSSES) Это значение используется для помещения символа * в относительном местоположении между 1-й и 72-й позицией строки. Результирующий график является аппроксимацией биномиального распределения. #include <iostream.h> #include <iomanip.h> #include "random.h" // включает генератор случайных чисел // "бросить" numberCoins монет и возвратить общее число // выпадений лицевой стороны int TossCoins(int numberCoins) { static RandomNumber coinToss; int i, tosses = 0; 1 Здесь под лицевой стороной подразумевается та сторона монеты, на которой изображен монарх или президент. — Прим. ред.
for (i«0;i < numberCoins;i++) // Random(2) * 1 индицирует лицевую сторону tosses += coinToss.Random(2); return tosses; } void main (void) { // число монет в бросании и число бросаний const int NCOINS = 10; const long NTOSSES = 50000; // heads [0]=сколько раз не выпало ни одной лицевой стороны // heads [1]=сколько раз выпала одна лицевая сторона и т.д. long i, heads[NCOINS + 1]; int j, position; // инициализация массива heads for (j=0; j <= NCOINS+l;j++) heads[j] = 0; // "бросать" монеты NTOSSES раз и записывать результаты в массив heads for (i=0;i< NTOSSES;i++) heads[TossCoins(NCOINS)]++; // печатать график частот for (i=0;i < NCOINS+l;i++) { position = int(float(heads[i])/float(NTOSSES) * 72); cout « setw(6) « i « " "; for (j=0;j <position-1;j++) cout « " " ; // ' *' относительное число бросаний с i лицевыми сторонами cout « ' *' « endl; } } /* Оапуск программы рг03_03 . срр> 0 * 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8 * 9 * 10 * */ 33. Объекты и передача информации Объект является экземпляром типа данных и как таковой может передаваться в качестве параметра функции или возвращаться как значение функции. Подобно другим типам C++, объектный параметр может передаваться по значению или по ссылке. Положения этого раздела иллюстрируются примерами из класса Temperature.
Объект как возвращаемое значение Любой тип класса может быть возвращаемым типом функции. Например, функция SetDailyTemp принимает в качестве параметра массив чисел, представляющий показания температуры, извлекает максимальное и минимальное показания из списка и возвращает объект Temperature с этими крайними значениями. Temperature SetDailyTemp (float readingf], int n) { //создание t с 1-ми значениями high и low Temperature t(reading[0], reading[0]); //обновление high или low, если необходимо for (int i = 1; i < n; i ++) t.UpdateTemp(reading[i]); //возвращение t с крайними температурами этого дня return t; } Массив reading содержит шесть температурных значений. Для определения высокой и низкой температур вызовите SetDailyTemp и присвойте результат объекту today. Чтобы вывести эти температуры на экран, используются методы GetHighTemp и GetLowTemp. float reading[6) = {40, 90, 80, 60, 20, 50}; Temperature today = SetDailyTemp(reading,6); cout « "Сегодняшние высокая и низкая температуры такие" « today.GetHighTemp () « "и" « today.GetLowTemp() « endl; Объект как параметр функции Объекты могут передаваться как параметры функции по значению или по ссылке. Следующие примеры иллюстрируют соответствующий синтаксис. Функция TemperatureRange использует вызов по значению (call by value) параметра Т типа Temperature и возвращает разницу между самой высокой и самой низкой температурами. При выполнении этой функции вызывающий элемент копирует объект типа Temperature (фактический параметр) в Т. float TemperatureRange(Temperature T) { return T.GetHighTemp() - Т.GetLowTemp(); } Функция Celsius использует вызов по ссылке (call by reference) параметра Т типа Temperature, который, как первоначально подразумевалось, содержит значения по Фаренгейту. Функция создает объект типа Temperature, чьи самое высокое и самое низкое показания преобразуются в значения по Цельсию, и присваивает его объекту Т. void Celsius(Temperatures T) { float hi, low; //с = 5/9 * (f-32) hi - float (5)/9 * (T.GetHighTempO -32); low = float(5)/9 * (T.GetLowTempO -32); T = Temperature(hi, low); }
Пример: объект Water содержит точку кипения (212° по Фаренгейту) и точку замерзания (32° по Фаренгейту) воды в качестве самого высокого и самого низкого температурных значений. Результат использования функции TemperatureRange показывает, что 180° — это диапазон для воды по шкале Фаренгейта. С помощью функции Celsius преобразуем эти температуры в значения по Цельсию и вызовем TemperatureRange, чтобы показать, что 100° — это соответствующий диапазон по шкале Цельсия. Temperature Water(212, 32); //кипение при 212F, замерзание при 32F cout <<"Температурный диапазон воды по шкале Фаренгейта" « TemperatureRange(Water) « endl; Celsius(Water); //преобразование температуры по Фаренгейту //в температуру по Цельсию cout «"Температурный диапазон воды по шкале Цельсия" « TemperatureRange(Water) « endl; 3.4. Массивы объектов Тип элемента массива может включать не только встроенные типы данных, такие как int или char, но также определяемые пользователем типы класса. Результирующий массив объектов может использоваться для создания списков, таблиц и так далее. Однако, использование объектных массивов требует осторожности. Объявление массива вызывает конструктор для каждого объекта в списке. Сравните простое объявление одного объекта Rectangle и массива из 100 объектов Rectangle. В каждом объявлении конструктор вызывается для создания объекта, который задает длину и ширину. В случае массива конструктор вызывается для каждого из 100 объектов. Rectangle pool(150, 100); //создание бассейна 150 х 100 Rectangle room[100]; //конструктор вызывается для //комната[0] .. [99] Объявление объекта pool передает начальные значения конструктору. Объекты room фактически имеют начальные значения, поскольку конструктор Rectangle присваивает нулевые значения по умолчанию длине и ширине объекта: Rectangle(float 1=0, float w=0); //параметры по умолчанию После объявления массива длина и ширина каждого объекта roomfi] имеют нулевые значения: cout « room[25].GetLengh() //выход 0; cout « room[25].GetWidth() //выход 0; room[25].PutLengh(lO) //установка длины комнаты[25] на 10 room[25].PutWidth(5) //установка ширины комнаты[25] на 5 Объявление массива объектов Rectangle поднимает важную проблему, касающуюся массиЕов и классов. Если конструктор класса Rectangle не имеет параметров по умолчанию, объявление массива room вызовет ошибку, потому что каждый массив будет требовать параметры. Объявлению потребуется список инициализаторов массива, который управляет каждым элементом в массиве. Например, для объявления массива room из 100 элементов и установки параметров длины и ширины на 0 потребуется список инициализаторов 100 объектов Rectangle. В действительности это на практикуется. Rectangle room[100] = {Rectangle(0, 0), . . . , Rectangle(0, 0)};
Для объявления массива объектов мы предоставляем конструктору значения по умолчанию или просто создаем конструктор без параметров. Конструктор умолчания Конструктор умолчания (default costructor) — это конструктор, не требующий никаких параметров. Это бывает, когда конструктор не имеет параметров или когда каждый параметр имеет значение по умолчанию. В этой главе класс Rectangle содержит конструктор умолчания, тогда как класс Temperature требует параметров при объявлении объекта. Класс Rectangle КОНСТРУКТОР Rectangle(float 1=0, float w=0); Конструктор содержит параметры 1 и w со значением по умолчанию 0. При создании массива Rectangle значения по умолчанию присваиваются каждому объекту. Rectangle R[25]; //каждый элемент имеет значение Rectangle(0, 0) Класс Temperature КОНСТРУКТОР Temperature(float h, float 1); Класс Temperature не содержит конструктор по умолчанию. Вместо этого, объекту должно быть дано начальное значение для высокой и низкой температуры. Объявление объектов today и week является недействительным! Temperature today; //недействительно: отсутствуют параметры Temperature week[7]; //Temperature не имеет конструктора по умолчанию 3.5. Множественные конструкторы До сих пор в наших классах мы разрабатывали как default-, так и nonde- fault-конструкторы1. В результате предыдущих рассуждений вы можете предположить, что они являются взаимоисключающими, поскольку все классы имели одиночный конструктор. C++ "признает" нашу потребность в разнообразии способов инициализации объекта и позволяет определять множественные конструкторы в одном и том же классе. Компилятор использует перегрузку функции для выбора правильной формы конструктора, когда мы создаем объект. Концепция перегрузки функции и ее правила обсуждаются в главе 6. Multiple-конструкторы добавляют большие возможности классу. Особый тип multiple-конструктора, называемый конструктором копирования (copy constructor), используется со многими классами, содержащими динамические данные-члены. Конструктор копирования описывается в главе 8. 1 Следует отметить, что в русских изданиях термин default constructor встречается как конструктор умолчания или default-конструктор. В то же время под термином nondefault constructor (или nondefault-конструктор) следует понимать конструктор с самым обычным синтаксисом, из которого не следуют никакие дополнительные свойства конструктора. — Прим. ред.
месяц 1 < m < 12 день 1 < d < 31 год 1900 <, у £ 1999 Класс Date иллюстрирует использование multiple конструкторов. Этот класс имеет три данных-члена, которые обозначают месяц, день и год в дате. Один конструктор имеет три параметра, соответствующие трем данным-членам. Действием конструктора является инициализация этих переменных. Второй конструктор позволяет клиенту объявлять дату как строку в форме "mm/dd/yy", читает эту строку и преобразует пары символов "mm" в месяц, "dd" в день и "уу" в год. Для каждого конструктора мы подразумеваем, что параметр, задающий год — это значение из двух цифр в диапазоне 00-99. Фактический год сохраняется добавлением 1900 к начальному значению: year - 1900 + уу Класс Date имеет метод, который выводит на экран полную дату с названием месяца, дня и значением года. Например, первый день в двадцатом веке был 1 января 1900 Спецификация класса Date ОБЪЯВЛЕНИЕ #include <string.h> #include <strstream.h> class Date { private: // закрытые члены, которые определяют дату int month, day, year/ public: // конструкторы, дата по умолчанию — Январь 1, 1900 Date (int m = 1, int d = 1, int у = 0); Date (char *dstr); // вывод данных в формате "месяц день, год" void PrintDate (void); }; ОПИСАНИЕ Для построения Date-объектов используются два конструктора, отличающиеся параметрами. Компилятор выбирает конкретный конструктор во время создания Date-объекта. Следующие примеры демонстрируют создание объектов. ПРИМЕРЫ Date dayl(6, 6, 44); // 6 июня 1944 Date day2; // значение по умолчанию для 1 января 1990 date day3("12/31/99"); // 31 декабря 1999 Реализация класса Date Сердцевиной класса Date являются два его конструктора, которые определяют дату, передавая значения месяца, дня и года или строки "mm/dd/yy".
Первый конструктор имеет три параметра со значениями по умолчанию, соответствующими 1 января 1900 года. Со значениями по умолчанию конструктор квалифицируется как конструктор умолчания: // конструктор, day и year задаются как целые ram dd yy Date::Date (int m, int d, int y) : month(m), day(d) { year = 1900 + у; // у — год в 20-м столетии ); Альтернативная форма конструктора принимает строковый параметр. Строка имеет форму "mm/dd/yy". Для преобразования пар данных мы используем ввод на базе массива, который преобразует символьные пары "mm" в целое значение месяца и так далее. Копируем строку параметра в массив inputBuffer и затем читаем символы в таком порядке: month — ch — day -ch — year Ввод ch удаляет два разделителя "/" из строки ввода. // конструктор // month, day и year задаются в виде строки "mm/dd/yy" Date::Date (char *dstr) { char inputBuffer[16]; char ch; // копирование в inputBuffer strcpy(inputBuffer,dstr); istrstream input(inputBuffer, sizeof(inputBuffer)); // чтение данных из входного потока ch используется в качестве символа '/' input » month » ch » day » ch »year; year += 1900/ ); При выводе метод Print дает текст полной даты, включающий название месяца, дня и год. Массив months содержит пустую строку (индекс 0) и 12 названий для календарных месяцев. Значение месяца используется как индекс в массиве для печати названия месяца. // печать даты с полным названием месяца void Date::PrintDate (void) { // статический массив с названиями месяцев static char *Months[] = {"","Январь","Февраль", "Март","Апрель","Май", "Июнь","Июль","Август", "Сентябрь","Октябрь", "Ноябрь","Декабрь"}; cout « Months [month] « " " « day « ", "« year ; }; Программа 3.4. Дата двадцатого века Тестовая прграмма использует конструкторы для установки демонстрационных объектов. Получаемые в результате данные печатаются. Класс Date содержится в файле "date.h".
#include <iostream.h> #include "date.h" // включение класса Date void main(void) { // Date-объекты с целыми, умалчиваемыми и строчными параметрами Date dayl(6,6,44) ; // Июнь б, 1944 Date day2; // Январь 1, 1900 Date day3("12/31/99"); // Декабрь 31, 1999 cout « "День Д во Второй Мировой войне — "; dayl.PrintDate(); cout « endl; cout « "Первый день 20-ого века — "; day2.PrintDate(); cout << endl; cout « "Последний день 20-ого века — "; day3.PrintDate(); cout « endl; } /* <Выполнение программы 3.4> День Д во Второй Мировой войне — Июнь б, 1944 Первый день 20-ого века — Январь 1, 1900 Последний день 20-ого века — Декабрь 31, 1999 */ 3.6. Практическое применение: Треугольные матрицы Двумерный массив, часто называемый матрицей (matrix), предоставляет важную для математики структуру данных. В этом разделе мы исследуем квадратные матрицы (square matrices — матрицы с одинаковым числом строк и столбцов), чьи элементы данных являются действительными числами. Мы разрабатываем класс TriMat, определяющий верхние треугольные матрицы (upper triangular matrices), в которых все элементы, находящиеся ниже диагонали, имеют нулевые значения. В математических терминах, Ау=0 для j<i. Верхний треугольник определяется элементами Ау для j>i. Эти матрицы имеют важные алгебраические
свойства и используются для решения систем уравнений. Реализация операций верхней треугольной матрицы в классе TriMat показывает способ эффективного хранения треугольной матрицы в виде одномерного массива. Свойства верхней треугольной матрицы Если верхняя треугольная матрица имеет л2 элементов, приблизительно половина из них являются нулевыми и нет необходимости сохранять их явно. Конкретно, если мы вычитаем п диагональных элементов из суммы п2 элементов, то половина оставшихся элементов являются нулевыми. Например, при л=25 имеется 300 элементов со значением 0: (П2 _ п)/2 = (252 _ 25)/2 = (625 — 25)/2 = 300 Далее следует набор операций для треугольных матриц. Мы определяем сложение, вычитание и умножение матриц, а также детерминант, который имеет важное применение для решения уравнений. Сумма или разность двух треугольных матриц А и В получается в результате сложения или вычитания соответствующих элементов матриц. Результирующая матрица является треугольной. Сложение С = А + В где С — это треугольная матрица с элементами Ctj = Ац + Bij. Вычитание С = А — В где С — это треугольная матрица с элементами dj = Aij - Bq. Умножение С = А * В Результирующая матрица С — это треугольная матрица с элементами С*,/, значения которых вычисляются из элементов строки i матрицы А и столбца у матрицы В: Citr(Aii0*B0,j) + (Au*Bltj) + (Ait2*B2J) + . . . + (Aitnl*Bn.u) Например, если Со,2 — это сумма произведений элементов строки 0 матрицы А и колонки 2 матрицы В. 1*4+1*1+0*3=5
Произведение матриц А и В: Для общей квадратной матрицы детерминант является сложной для вычисления функцией, однако вычислить детерминант треугольной матрицы не трудно. Просто получите произведение элементов на диагонали. Хранение треугольной матрицы Применение для хранения верхней треугольной матрицы стандартного двумерного массива требует использования всей памяти размером л2, несмотря на прогнозируемые нули, расположенные ниже диагонали. Для исключения этого пространства мы сохраняем элементы из треугольной матрицы в одномерном массиве М. Все элементы ниже главной диагонали не сохраняются. Таблица 3.1 показывает количество элементов, которые сохраняются в каждой строке. Таблица 3.1 Хранение треугольной матрицы Строка 0 1 2 ... п-2 п-1 Число элементов п п-1 п-2 ф •» 2 1 Элементы (Ао, о . . ■ Ао, n-i) (Ai( i . . . Ai, n-i) (A2, 2 . . . A2, n-i) • • • (An-2. n-2 • • • An-2, n-i) (An-1, n-i) Алгоритму сохранения требуется функция доступа, которая должна определять местоположение в массиве М элемента Ау. Для j < i элемент Ay является равным 0 и не сохраняется в М. Для j > i функция доступа использует информацию о числе сохраняемых элементов в каждой строке вплоть до строки i. Эта информация может быть вычислена для каждой строки i и сохранена в массиве (rowTable) для использования функцией доступа.
Строка 0 1 2 3 rowTable rowTable[0] = 0 rowTable[1] = n rowTable [2] = n + n-1 rowTable [3] = n + n-l+n-2 Замечание 0 элементов перед строкой 0 п элементов перед строкой 1 (от строки 0) п + п-1 элементов перед строкой 2 элементы перед строкой 3 n-1 rowTable[n-1] = n + n-1 + ... +2 Строка 0 1 2 rowTable rowTable[0] «■ 0 rowTable[1] - 3 rowTable[2] - 5 Замечание 0 элементов, сохраненных перед строкой 0 3 элемента строки 0 (110) 5 элементов из строк 0 и 1 (11021) Row Table Элементы треугольной матрицы сохраняются по строкам в массиве М. Массив М Элементы строки 0 Элементы строки 1 Элементы строки 2 С учетом того, что элементы треугольной матрицы сохраняются построчно в массиве М, функция доступа для Ац использует следующие параметры: Индексы i и j, Массив rowTable Алгоритм доступа к элементу Ау заключается в следующем: 1. Если j<i, Aij = 0 и этот элемент не сохрдняется. 2. Если j>i, то получается значение rowTable[i], являющееся количеством элементов, которые сохраняются в массиве М, для элементов до строки L В строке i первые i элементов являются нулевыми и не сохраняются в М. Элемент Aij помещается в M[rowTable[i] + (j — i)]. Пример 3.4 Рассмотрим матрицу X размера 3x3
Пример 3.5 Рассмотрим треугольную матрицу Х[3][3] из примера 3.4: 1. X0t2 =M[rowTable[0] + (2 — 0)] =М[0 + 2] =М[2] = 0 2. Xi,o не сохраняются 3. Xlt2 =M[rowTable[l] + (2 — 1)] =М[3 4- 1] =М[4] = 1 Класс TriMat Класс TriMat реализует ряд операций треугольной матрицы. Вычитание и умножение треугольной матрицы оставлены для упражнений в конце главы. Учитывая то ограничение, что мы должны использовать только статические массивы, наш класс ограничивает размер строки и столбца числом 25. При этом мы будем иметь 300 = (252 — 25)/2 нулевых элементов, поэтому массив М должен содержать 325 элементов. Спецификация класса TriMat ОБЪЯВЛЕНИЕ #include <iostream.h> #include <stdlib.h> // максимальное число элементов и строк // верхней треугольной матрицы const int ELEMENTLIMIT = 325; const int ROWLIMIT = 25; class TriMat { private: // закрытые данные-члены int rowTable[ROWLIMIT]; // начальный индекс строки в М int n; // размер строки/колонки double М[ELEMENTLIMIT]; public: // конструктор с параметрами TriMat(int matsize); // методы доступа к элементам матрицы void PutElement (double item, int i, int j); double GetElement(int i, int j) const; // матричные арифметические операции TriMat AddMat(const TriMat& A) const; double DelMat(void) const; // матричные операции ввода/вывода void ReadMat(void); void WriteMat(void) const;
// получить размерность матрицы int GetDimension(void) const; }; ОПИСАНИЕ Конструктор принимает число строк и столбцов матрицы. Методы PutEle- ment и GetElement сохраняют и возвращают элементы верхней треугольной матрицы. GetElement возвращает 0 для элементов ниже диагонали. AddMat возвращает сумму матрицы А с текущим объектом. Этот метод не изменяет значение текущей матрицы. Операторы ввода/вывода ReadMat и WriteMat работают со всеми элементами матрицы п х п. Сам метод ReadMat сохраняет только верхне-треугольные элементы матрицы. ПРИМЕР #include trimat.h // включить класс TriMat TriMat A(10), В(10), С(10); // треугольные матрицы 10x10 A.ReadMat(); // ввести матрицы А и В В.ReadMat(); С = A.AddMat(В); // вычислить С = А + В С.WriteMat(); // печатать С Реализация класса TriMat Конструктор инициализирует закрытый член п параметром matsize. Таким образом задается число строк и столбцов матрицы. Этот же параметр используется для инициализации массива rowTable, который используется для доступа к элементам матрицы. Если matsize превышает ROWLIMIT, выдается сообщение об ошибке и выполнение программы прерывается. // инициализация п и rowTable TriMat::TriMat(int matsize) { int storedElements = 0; // прервать программу, если matsize больше ROWLIMIT if (matsize > ROWLIMIT) { cerr « "Превышен размер матрицы" « ROWLIMIT « « "x" « ROWLIMIT « endl; exit (1); } n = matsize; // задать таблицу for(int i = 0; i < n; i++) { rowTable[i] = storedElements; storedElements += n — i; } } Матричные методы доступа. Ключевым моментом при работе с треугольными матрицами является возможность эффективного хранения ненулевых элементов в линейном массиве. Чтобы достичь такой эффективности и все же использовать обычные двумерные индексы i и j для доступа к элементу матрицы, нам необходимы функции PutElement и GetElement для сохранения и возвращения элементов матрицы в массиве.
Метод GetDimension предоставляет клиенту доступ к размеру матрицы. Эта информация может использоваться для обеспечения того, чтобы методам доступа передавались параметры, соответствующие правильной строке и столбцу: // возвратить размерность матрицы п int TriMat::GetDimension(void) const { return n; } Метод PutElement проверяет индексы i и j. Если j > i, мы сохраняем значение данных в М, используя функцию доступа к матрице для треугольных матриц: Если i или j не находится в диапазоне 0 . . (п-1), то программа заканчивается: // записать элемент матрицы [i,j] в массив М void TriMat::PutElement (double item, int i, int j) { // прервать программу, если индексы элемента вне индексного диапазона if ((i < О | I i >- п) II (j < О I | j >= n)) { cerr « "PutElement: индекс вне диапазона 0 — " « n-1 « endl; exit (l); } // все элементы ниже диагонали игнорируются if (j >= i) M[rowTable[i] + j-i] = item; } Для получения любого элемента метод GetElement проверяет индексы i и j. Если i или j не находится в диапазоне 0 . . (п — 1), программа заканчивается. Если j<i, то элемент находится в нижней треугольной матрице со значением 0. GetElement просто возвращает несохраняемое значение 0. В противном случае, j>i, и метод доступа может возвращать элемент из массива М: // получить матричный элемент [i, j] массива М double TriMat::GetElement(int i, int j) const { // прервать программу, если индексы вне индексного диапазона if ((i < 0 | | i >= n) || (j < 0 | I j >- n)) < cerr « "GetElement: индекс вне диапазона 0 — " « n-1 « endl; exit (1); } if (j >- i) // вернуть элемент, если он выше диагонали return M[rowTable[i] + j-i]; else // элемент равен 0, если он ниже диагонали return 0; } Ввод/вывод матричных объектов. Традиционно, ввод матрицы подразумевает, что данные вводятся построчно с полным набором значений строк, и столбцов. В объекте TriMat нижняя треугольная матрица является нулевой и значения не сохраняются в массиве. Тем не менее, пользователю предлагается ввести эти нулевые значения для сохранения обычного матричного ввода. // читать элементы матрицы построчно, клиент должен ввести // все (п х п) элементов void TriMat::ReadMat(void)
{ double item; int i, j; for (i = 0; i < n; i++) // сканировать строки for (j = 0; j < n; j++) // для каждой строки сканировать столбцы { cin >> item; //читать [i, j ] -й элемент матрицы PutElement (item, i, j ); // сохранить этот элемент } } // построчная выдача в поток элементов матрицы void TriMat::WriteMat(void) const { int i, j; // установка режима выдачи cout.setf(ios::fixed); cout.precision(3) ; cout.setf(ios::showpoint); for (i =0; i < n; i++) { for (j = 0; j < n; j++) cout « setw(7) « GetElement (i,j) ; cout << endl; } } Матричные операции. Класс TriMat имеет методы для вычисления суммы двух матриц и детерминанта матрицы. Метод AddMat принимает единственный параметр, который является правым операндом в сумме. Текущий объект соответствует левому операнду. Например, сумма треугольных матриц X и Y использует метод AddMat для объекта X. Предположим, сумма сохраняется в объекте Z. Для вычисления Z = X + Y используйте оператор Z = X.AddMat(Y); Алгоритм сложения двух объектов типа TriMat возвращает новую матрицу В с элементами By = CurrentObjecty + Ay: // возвращает сумму текущей и матрицы А. // текущий объект не изменяется TriMat TriMat::AddMat (const TriMat& A) const { int i,j; double itemCurrent, itemA; TriMat B(A.n); // в В будет искомая сумма for (i « 0; i < n; i++) // цикл по строкам { for (j - i; j < n; j++) // пропускать элементы ниже диагонали { itemCurrent = GetElement(i,j); itemA = A.GetElement(i,j); B.PutElement (itemCurrent + itemA, i, j); } } return B;
Метод DetMat возвращает детерминант текущего объекта. Возвращаемое значение — это действительное число, которое является произведением элементов диагонали. Полный текст кода для реализации класса TriMat можно найти в программном приложении. Программа 3.5. Операции с классом TriMat Тестовая программа иллюстрирует класс TriMat с операциями ввода/вывода, а также матричного суммирования и определения детерминанта. Каждая секция программы снабжена комментариями. #include <iostream.h> #include <iomanip.h> #include "trimat.h" // включить класс TriMat void main(void) { int n; // задать размер однородной матрицы cout << "Каков размер матрицы? "; cin >> п; // объявить три матрицы размером (n x п) TriMat A(n), B(n), C(n); // читать матрицы А и В cout « "Введите некоторую " << п « " х " « п « " треугольную марицу" << endl; A.ReadMat(); cout << endl; cout << "Введите некоторую " << n << " x " << n << " треугольную марицу" << endl; B.ReadMatO ; cout « endl; // выполнить операции и напечать результат cout « "Сумма А + в" << endl; С = A.AddMat(В); C.WriteMatO ; cout « endl; cout « "Детерминант A+B= " « С.DetMat{) « endl; } /* <Выполнение программы 3.5> Каков размер матрицы? 4 Введите некоторую 4x4 треугольную марицу 12-45 0 2 4 1 0 0 3 7 0 0 0 5 Введите некоторую 4x4 треугольную матрицу
14 6 7 О 2 б 12 0 0 3 1 0 0 0 2 Сумма. А + В 2.000 6.000 2.000 12.000 0.000 4.000 10.000 13.000 0.000 0.000 6.000 8.000 0.000 0.000 0.000 7.000 Детерминант А+В= 336.000 */ Письменные упражнения 3.1 Разработайте ADT Coins для набора из п монет. Данные включают количество монет, общее количество лицевых сторон в последнем бросании и список значений монет в последнем бросании. Операции должны включать инициализацию, бросание монет, возвращение общего количества выпадений лицевых сторон и печать значений в последнем бросании. 3.2 (а) Разработайте ADT для коробки. Включите в этот ADT инициализацию и операции, которые возвращают длины сторон и вычисляют площадь и объем. (б) Напишите класс Box, реализующий этот ADT. (в) Обхват коробки — это периметр прямоугольника, образованного двумя сторонами. Коробка имеет три возможных значений обхвата. Почтовая длина определяется обхватом плюс расстояние третьей стороны. Упаковка пригодна для пересылки по почте, если какая-либо из ее длин меньше 100. Напишите фрагмент кода для определения, пригоден ли объект В для пересылки по почте. 3.3 Определите все ошибки синтаксиса в определениях класса: (а) class X { private int t; private int q; public int X(int a, int b)/ { t = a; q = b; } void printX(void); } (б) class Y { private: int p; int q/ public Y (int n, int m) : n(p) q(m); { } };
3.4 (а) Объявите спецификацию для класса X, который имеет следующее: Закрытые члены: Целые переменные а, Ь, с. Открытые члены: Конструктор, который присваивает значения переменным а, Ъ, с; значения по умолчанию будут равны 1. Функцию F, возвращающую максимум переменных а, Ь, с. (б) Напишите конструктор для класса X пункта (а). (в) Напишите открытую функцию F, поместив ее определение вне класса. 3.5 Предположим следующее объявление: class Student { private: int studentid; intgradepts, units; float gpa; float ComputeGPA(void); public; Student(int studid; int studgradepts, int studunits); void ReadGradelnfo(void); void PrintGradelnfo(void); void UpdateGradeInfo(int newunits, int newgradepts); }; Этот класс ведет запись отметок для студента. Переменные gradepts и units используются методом ComputeGPA для присваивания средней успеваемости студента переменной gpa. Используйте формулу: gpa = gradepts/units Напишите код для этой функции-члена. Конструктор и ComputeGPA должны быть выполнены как код in-line. 3.6 ADT Calendar содержит элементы данных year и логическое значение leapyr. Его операции следующие: Конструктор Инициализирует данные-члены year и leapyr. NumDays(mm.dd) Возвращение количества дней с самого начала года до заданного месяца mm и дня dd. Leapyear(void) Указывает, является ли год високосным. PrintDate(ndays) Печатает дату ndays в year в формате mm/dd/yy. (а) Напишите ADT формально (б) Реализуйте ADT Calendar как класс. 3.7 Разработайте объявление для класса, который имеет следующие данные-члены. Объявите операции, соответствующие объекту этого типа. (а) Имя студента, профилирующая дисциплина, предлагаемый год окончания учебы, средняя успеваемость. (б) Штат, столица, население, площадь, губернатор (в) Цилиндр. Сделайте возможными изменения радиуса и высоты и включите вычисление площади поверхности и объема. 3.8 Следующий код является объявлением для класса, представляющего колоду карт:
class CardDeck { private: //колода карт реализуется как массив //целых от 0 до 51. int cards[52]; int currentCard; public: //конструктор, тасование колоды карт CardDeck(void); //тасование колоды карт void Shuffle(void); //возвращать следующую карту в колоде. СОВЕТ: вы //должны установить текущее местоположение в колоде. int GetCard(void); //трефы 0-12., бубны 13-25, черви 26-38, //пики 39-51. В каждом диапазоне первая карта — это //туз, а последние три карты — это валет, дама, король. //запишите карту с как масть, значение карты void PrintCard(int с); }; (а) Реализуйте эти методы. СОВЕТ: Для тасования карт используйте цикл со сканированием 52-х карт. Для карты i выберите случайное число в диапазоне от i до 51 и поменяйте местами карту с этим случайным индексом и карту с индексом i. (б) Запишите функцию void DealHand(CardDeck& d, int n) ; которая сдает п карт из d, сортирует их и печатает их значения. 3.9 Используя класс Temperature из раздела 3.2, напишите функцию: Temperature Average( temperature a[ ], int n); которая возвращает объект типа Temperature, содержащий среднюю низкую и высокую температуры п показаний. 3.10 Используйте класс Date из раздела 3.5. (а) Измените класс Date для включения метода IncrementDate. Он принимает положительное число дней в диапазоне 0-365, добавляет его к текущей дате и возвращает объект, имеющий новую дату. (б) Сделайте так, чтобы параметр для IncrementDate мог принимать отрицательные значения. 3.11 Покажите использование класса случайных чисел для моделирования следующего: (а) Одна пятая часть автомобилей штата не соответствует стандартам по вредным эмиссиям. Используйте fRandom, чтобы определить, отвечает ли этим стандартам случайно выбранная автомашина. (б) Вес особи в популяции варьируется в пределах 140-230 фунтов. Используйте Random для выбора веса какого-либо человека в этой популяции. 3.12 Рассмотрите класс Event, которому передано начальное значение для нижнего и верхнего предела времени какого-либо события. Границы принимают значение по умолчанию 0 и 1. Операции:
Конструктор Инициализировать границы данных. Если нижняя граница выходит за верхнюю границу, печатать сообщение об ошибке и выходить из программы. GetEvent Получать случайное событие в диапазоне: нижняя граница — верхняя граница. (а) Реализуйте этот класс, используя код in-line. (б) Реализуйте этот класс, определяя функции-члены вне объявления класса. (в) Приложение требует массив из пяти объектов Event, где каждый элемент принимает значение в диапазоне от 10 до 20. Как вы инициализируете массив? Проще ли решается данная задача, если диапазон для каждого объекта будет 0-1? 3.13 Напишите функцию Datelnterval, которая принимает два объекта класса Calendar из письменного упражнения 3.6 и возвращает число дней между двумя этими датами. 3.14* Матрицы могут использоваться для решения систем уравнений с п неизвестными. Мы показываем алгоритм для системы с тремя неизвестными. В этой системе уравнений элементы Ау являются коэффициентами неизвестных Хо, Xi и Хг. В правой части уравнений даются элементы Q. Эти уравнения могут быть описаны одним матричным уравнением где матрица называется матрицей коэффициентов (coefficient matrix). Например, система уравнений 1Х0 + 1ХХ + 0Х2 = 4 -ЗХ0 - lXj + 1Х2 = -11 2Х0 + 2Х: + 2Х2 « 14 соответствует матричному уравнению Теорема математики утверждает, что эти уравнения могут быть сведены к системе эквивалентных уравнений, в которой матрица коэффициентов является треугольной. В нашем примере: 1. Исключите элемент Аю = -3.
Умножьте элементы в строке 0 на константу 3 и сложите элементы строки 0 с элементами строки 1. 2. Исключите элемент Аго = 2. Умножьте элементы строки 0 на константу -2 и сложите элементы строки 0 с элементами строки 2. В этом процессе исключается также член Агь Матричное уравнение для новой системы имеет треугольную матрицу коэффициентов. (а) Сведите алгебраическую систему к уравнению, включающему треугольную матрицу коэффициентов (б) Найдите детерминант этой матрицы коэффициентов. Упражнения по программированию 3.1 Многие программные приложения используют постоянно обновляемый сумматор (accumulator). В качестве простого примера абстрактного типа данных предположим, что Accumulator — это тип данных, которые обновляются операцией сложения и выводятся с использованием операции печати. ADT Accumulator Данные Действительное значение для суммирования Операции Initialize Вход: Действительное значение N. Предусловия: Нет Процесс: Присваивание N в качестве значения суммы. Выход: Нет Постусловия: Сумма инициализируется. Add Вход: Действительное число N.
Предусловия: Нет Процесс: Сложение N с суммой. Выход: Нет Постусловия: Сумма обновляется. Print Вход: Нет Предусловия: Нет Процесс: Чтение суммы. Выход: Печать суммы. Постусловия: Нет Конец ADT Accumulator Банковское приложение считывает начальный баланс и последовательность операций. Отрицательная операция определяет дебет, а положительная — кредит. Используются три объекта типа Accumulator. Объект Balance определяется со стартовым балансом в качестве параметра конструктора. Объекты Debits и Credits имеют начальное значение 0 и используются для поддержки определения текущей суммы дебетных и кредитных операций. Оператор Add обновляет сумму в объектах. Считайте последовательность операций, заканчивающихся на операции 0.00. Печатайте окончательные значения баланса, дебетов и кредитов. 3.2 Напишите main-функцию, которая использует класс, реализованный в письменном упражнении 3.5 со следующими данными: Студент Id 1047 3050 | 0020 Grade Points 120 75 100 Units | 40 20 _75 J (а) Печатайте информацию по каждому студенту. (б) Последний студент (ID 0020) имеет дополнительные записи из летней школы. Обновите запись со следующими новыми данными: успеваемость 40 при 10 часах. Печатайте новые данные для этого студента. 3.3 Расширьте класс Circle из раздела 1.3 для вычисления площади сектора. Площадь сектора определяется по формуле (Q/360)*7ir2. Сектор Используйте этот класс для решения следующей задачи: Круглая игровая площадка определяется как объект с радиусом 100 футов. Программа определяет стоимость ограждения этой площадки. Стоимость ограждения $2,40 /фут. Площадь поверхности площадки в
основном травяная. Один сектор, измеряемый углом в 30°, не является лужайкой. Программа определяет стоимость лужайки для катания: $4,00 за полосу 2 х 8(16 кв.фута). Fencing Cost = Circumference * 2,40 Lawn Lawn_Area - Area — Sector_Area Number_Rolls = Lawn__Area/16 Cost = Number_Rolls* 4,00. 3.4 Напишите класс, содержащий индикатор пола (М или F), возраст и ID-номер в диапазоне от 0 до 100. Операции включают Read, Print и функции Getld/GetAge/GetGender, которые возвращают ID, возраст и пол человека, сохраняемые в объекте. Напишите программное приложение, определяющее объекты Young- Women и OldMen. Программа вводит информацию о ряде людей и присваивает данные о самых молодых женщинах в YoungWomen и самых старых мужчинах в OldMen. Ввод завершается ID-номером О. Используя Print, выполните вывод данных из этих объектов. 3.5 Реализуйте класс Geometry, закрытые данные которого содержат два (2) элемента данных VI и V2 типа double и переменную figuretype типа Figure. Этот класс должен содержать два конструктора, которые принимают 1 или 2 параметра, соответственно, метод Border, возвращающий периметр объекта, метод Area, возвращающий площадь и метод Diagonal, вычисляющий диагональ. Enum Figure (Circle, Rectangle) class Geometry { private: double VI, V2; Figure figuretype; public: Goemetry(double radius); // для окружности Geometry(double 1, double w); // для прямоугольника double Border(void) const; double Area(void) const; double Diagonal(void) const; } (а) Реализуйте класс Geometry, используя внешние функции. Первый конструктор для окружности будет иметь один параметр и следовательно присваивать объект типа Circle переменной figuretype. Другой конструктор, будет присваивать этой переменной объект типа Rectangle. Вычисляющие методы должны проверять тип figuretype перед вычислением возвращаемого значения. (б) Пользователь вводит внутренний радиус, который затем используется для создания маленькой окружности. Используйте эту информацию для объявления описывающего окружность прямоугольника и внешней окружности, описывающей этот прямоугольник. Печатайте периметр, площадь и диагональ каждого объекта.
радиус Вычислите площадь внешней полосы между двумя окружностями (площадь вне маленькой окружности, но внутри большой окружности). Вычислите периметр маленькой области, помеченной символом "X". 3.6 Класс Ref подсчитывает количество положительных (>0) и отрицательных (<0) чисел, "представленных на рассмотрение". Конструктор не имеет параметров и инициализирует элементы данных positiveCount и negativeCount нулевыми значениями. class Ref { private: int positiveCount; int negativeCount; public: Ref(void); void Count(int x) ; void Write(void) const; >; Объект типа Ref передается функции, которая использует его для записи количества положительных и отрицательных чисел в последовательность ввода из пяти целых чисел. Две версии функции иллюстрируют различие между передачей объектов по значению и по ссылке. В первой версии объект передается по значению. void PassByValue(Ref V) { int num; for(int i = 0; i<5; i++) { cin > num; if (num! = 0) v.Count (num); } } Вторая версия функции передает параметр по ссылке. void PassByReference (Ref & V) { int num; * • * }
Реализуйте Ref и создайте main-программу, которая вызывает каждую функцию и использует метод Write для печати результатов. В каждом экземпляре данными являются 1, 2, 3, -1 и -7. Объясните, почему PassByValue работает неправильно, a PassByReference выполняется успешно. 3.7 Рассмотрим следующее объявление класса: class Grade { private: char name[30]; float score; public: Grade(char student[], float score); Grade(void); int Compare(char s[]); void Read(void); Write(void); }; Напишите функцию main по частям от (а) до (е): (а) Напишите реализацию для функций-членов. Используйте строковую функцию strcpy для присваивания имени в первом конструкторе. Заметьте, что второй конструктор является конструктором умолчания. Он устанавливает переменную name в NULL-строку, a score — в 0.0. Метод Compare возвращает 1, если s равна имени, в противном случае возвращается 0. Используйте функцию strcmp. (б) В функции main объявите массив Students из пяти объектов с начальными значениями: {Grade("Johnп, 78.3), Grade("Sally", 86.5), Grade("Bob", 58.9), Grade("Donna", 98.3)}; (в) Пятый объект, Students[4], вводится с использованием функции-члена Read(). (г) Запишите функцию int Search(Grade Arr)[ ], int n, char keyname[ ]; которая ищет массив Arr из п элементов и возвращает индекс объекта, имя которого соответствует ключу. Если ключ не найден в Arr, возвращается -1. (д) Вызывайте Search три раза с разными именами для проверки правильности ее работы. 3.8 Используйте класс CardDeck, разработанный в письменном упражнении 3.8, для карточной игры под названием Hi-Low. Сдайте пять карт. Для каждой карты спросите игрока, будет ли случайно вытащенная из оставшихся карт в колоде карта больше или меньше данной карты. Туз — самая большая карта любой масти, и масти упорядочены от трефовой до пиковой. Печатайте количество удачных догадок. 3.9 В данном упражнении используйте класс Calendar, разработанный в письменном упражнении 3.6. Запишите функцию int Daylnterval(Calendar С, int mml, intddl, intmm2/ int dd2);
возвращающую количество дней между двумя данными. Напишите функцию main, выполняющую следующее: 1. Печатает, является ли текущий год високосным. 2. Использует NumDays для определения количества дней от начала года до Рождества. 3. Передает результат (2) функции PrintDate и проверяет правильность печати даты. 4. Включает вычисление количества дней от сегодняшнего дня до Рождества. 5. Вычисляет количество дней между 1 февраля и 1 марта. 3.10 Расширьте класс Dice из главы 1 до бросания п костей, п<:20. Если количество костей не дается конструктору, он устанавливается на значение по умолчанию 2. Используйте класс Dice для решения следующей задачи: Объявите массив из 30 объектов Dice. Инициализируйте каждый элемент для бросания пяти (5) костей. Вам понадобиться использовать конструктор умолчания в объявлении, а затем — цикл для инициализации каждого элемента пяти костей. Выполните Toss для каждого элемента. Сканируйте список и определите, сколько раз была брошена 5 или 12. Укажите, сколько раз сумма повторялась в следующем бросании. Например, 8 8 8 считается как два повтора. Сканируйте список и найдите самую большую сумму, отображая стороны со значениями кости. Сортируйте список, считая и печатая суммы. 3.11 Объявите перечисление: enum unit {metric, English}/ Класс Height содержит следующие закрытые данные-члены: char name[20]; unit measureType; float h; //высота в единицах measureType (футы или см). Операции включают: //конструктор:имя параметров, высота, тип измерения Height(char nm[ ], float ht, unit m); PrintHeight(void); //печать высоты в соответствующих единицах //ввод имени, типа измерения и высоты ReadHeight(void); float GetHeight(void); //возвращение высоты void Convert(unit m); //преобразование h в измерение m (а) Реализуйте этот класс. Один дюйм равен 2,54 см. (б) Напишите функцию, сканирующую список и преобразующую элементы в единицы измерения, заданные как параметр. Подразумевается, что объекты выражены в других единицах измерения. (в) Напишите функцию, которая сортирует массив объектов Height. Подразумевается, что каждый объект использует одну и ту же единицу измерения.
(г) Напишите функцию, сканирующую массив и возвращающую объект, представляющий самого высокого человека. Считайте, что все объекты используют одну и ту же единицу измерения. (д) Напишите программу для тестирования класса, создав список из пяти элементов типа Height. Инициализируйте первые три из них в объявлении и считайте последние два. Используйте функции, разработанные в частях (Ь), (с) и (d). 3.12 В банке с одним только кассиром заметили, что клиентские операции занимают интервал времени 5-10 минут. Очередь из 10 клиентов образовалась в момент открытия. Используйте класс Event, разработанный в письменном упражнении 3.12 для вычисления времени, необходимого кассиру, чтобы обслужить 10 клиентов. 3.13 Эллипс или овал определяется описывающим прямоугольником, размеры которого 2а х 2Ь. Константы а и b называются полуосями эллипса. Эллипс, полуоси которого имеют одну и ту же длину, являетя окружностью. Математическое уравнение эллипса имеет вид: (х - х0)2/а2 + (у - у0)2/Ь2 = 1 и его площадь равна nab. Разработайте класс Ellipse, функции-члены которого состоят из конструктора и метода Area. Используйте Ellips и класс Rectangle для решения следующей задачи: Необходимо построить овальный плавательный бассейн, полуоси которого являются длинами 30 и 40, внутри прямоугольной площади 80 х 60. Стоимость бассейна $25 000. Площадь снаружи бассейна необходимо зацементировать. Стоимость цемента составляет $50/кв.фут. Вычислите общую стоимость строительства. 3.14 Данные о бейсболисте включают номер игрока (number), количество раз, когда он отбивающий (times at bat), количество ударов по мячу (hits) и средний результат (NumberHits/ NumberAtBats). Эта информация сохраняется как данные-члены в закрытой секции класса Baseball. Все параметры конструктора имеют значения по умолчанию: номер униформы устанавливается равным -1, а значения числа отбиваний и ударов — 0. Применение для номера униформы значения по умолчанию подразумевает, что номер униформы игрока, количество ударов и количество раз, когда игрок является отбивающим, считываются с ис-
пользованием ReadPlayer. Для известного номера униформы функция- член ReadPlayer вводит количество ударов и количество отбиваний. Метод GetBatAve возвращает средний результат отбиваний. Закрытый метод ComputeBatAve используется как утилита конструктором, a ReadPlayer — для задания данного-члена, содержащего средний результат отбиваний. Метод WritePlayer выводит всю информацию об игроке в формате: Player <UniformNo> Average < BattingAvg> Средний результат выводится как целое число из трех цифр. Например, если количество ударов 30, а количество раз, когда игрок был отбивающим, равно 100, то средний результат выводится методом WritePlayer как 300. Объявление класса Baseball class Baseball { private: int playerno; int atbats; int hits; float batave; //ComputeBatAve дается с inline-кодом. float ComputeBatAve (void) const //закрытый метод { if(playerno == -1 atbats == 0) return(0); else returne(float(hits)/atbats); { public: Baseball (int n = -1, int ab - 0, int h = 0) ; void ReadPlayer(void) ; void WritePlayer(void) const; float GetBatAve(void) const; }; Реализуйте класс Baseball и используйте его в функции main следующим образом: 1. Объявите четыре объекта: Catcher Номер униформы 10, 100 отбиваний, 10 ударов Shortstop Имеется только номер униформы 44 Centerfielder Нет никакой информации Maxobject Нет никакой информации 2. Считайте необходимую информацию для объектов shortstop и centerfielder. 3. Выпишите всю информацию для объектов catcher, shortstop и centerfielder. 4. Используя операцию GetBatAve и присваивание объекта, присвойте игрока с самым высоким средним результатом объекту maxobject и распечатайте информацию.
3.15 Добавьте вычитание и умножение треугольной матрицы к классу TriMat и протестируйте их в программе, подобной программе 3.5. 3.16 При решении общей n x n системы алгебраических уравнений ряд операций сводит задачу к решению уравнения треугольной матрицы. Уравнение треугольной матрицы имеет единственное решение, при условии, что детерминант матрицы коэффициентов является ненулевым. Набор алгебраических уравнений получают умножением каждой строки в матрице коэффициентов на столбцовый массив неизвестных. Решая уравнения в порядке от п — 1 до 0, мы получаем единственное решение для переменных Xn-i, Xn-2, ..., Xi,Xo. Например, треугольная система уравнений, описанная в письменном упражнении 3.14, решается применением этого метода: Уравнение 0: ix0 + ixx + ox2 = 4 Уравнение 1: 2х1 + ix2 =1 Уравнение 2: 2х2 = б Решение для Х2 В уравнении 2 х2 = 6/2 = з Решение для Хх: В уравнении 1 подставьте 3 для Х2; решите уравнение для неизвестного Хх. 2Х1 + 3 = 1 Решение для Х0: В уравнении 0 подставьте -1 для Хг и 3 для Х2; решите уравнение для неизвестного Х0. Х0 - 1 = 4 Х0 = 5 Окончательное решение: Хо =5, Xi = -1, Х2 = 3. Объедините эти идеи и разработайте функцию void SolveEqn(const TriMat& A, double X[ ], double C[ ]); Она определяет единственное решение, если оно существует, общего уравнения треугольной матрицы (а) Используйте SolveEqn в программе для решения примера системы уравнений.
(б) Решите систему уравнений в письменном упражнении 3.14 (а).
глава Шеф Классы коллекций 4.1. Линейные коллекции 4.2. Нелинейные коллекции 4.3. Анализ алгоритмов 4.4. Последовательный и бинарный поиск 4.5. Базовый класс последовательного списка Письменные упражнения Упражнения по программированию
В главе 2 описываются базовые типы данных, которые непосредственно поддерживаются языком программирования и включают примитивные числовые и символьные данные, а также массивы, строки и записи. Эти структурированные типы данных являются примерами коллекций (collections), которые сохраняют данные и предоставляют операции доступа, добавляющие, удаляющие или обновляющие элементы данных. Изучению типов коллекций уделяется основное внимание в данной книге. Коллекции подразделяются на две основные категории: линейные и нелинейные. На рис. 4.1 приводятся методы доступа к данным для дальнейшего деления категорий и перечисления структур данных, представленных в этой книге. В данной главе приводится краткий обзор каждой коллекции вместе с описанием ее данных, операций и некоторых случаев практического использования. Линейная (linear) коллекция содержит список элементов, упорядоченных по положению (рис.4.2). В этом списке имеется первый элемент, второй и т.д. Массив с индексом, отражающим порядок элементов, является основным примером линейной коллекции. Нелинейная (nonlinear) коллекция определяет элементы без позиционного упорядочения. Например, цепочка управления рабочими на заводе или комплект мячей в сетке — это нелинейные коллекции (рис. 4.3). Эта глава включает также исследование эффективности алгоритмов. Мы описываем факторы, определяющие эффективность и вводим нотацию Big-0 (большая О) в качестве ее критерия. Этот критерий используется на протяжение всей книги для сравнения и сопоставления различных алгоритмов. Класс SeqList из главы 1 является основным типом коллекций. В данной главе описывается реализация этого класса на базе массива. Этот класс рассматривается также в главе 9, когда мы определяем реализацию связанного списка. В главе 12 SeqList используется с наследованием для создания упорядоченного списка. Когда C++ реализует коллекции как классы, компилятор требует параметры функции, чтобы иметь специфические типы данных, и выполняет тщательную проверку типа на предмет совместимости. Для наиболее общей реализации типов коллекций мы вводим классы шаблонов (template) C++ в Коллекции Линейные Нелинейные С индексным доступом С прямым доступом С последовательным доступом Иерархические Групповые Словарь Hash- таблица Массив Запись Файл Список Стек Очередь Очередь приоритетов Дерево Heap- дерево Набор Граф Рис. 4.1. Иерархия коллекций
главе 7. Классы шаблонов пишутся с использованием параметризованного имени, такого как Т для типа данных, управляемых коллекцией. Когда объявляется какой-либо объект, фактический тип для Т задается как параметр. Шаблоны являются мощным инструментом C++, позволяющим выполнять параметризованное объявление классов. Например, предположим, что класс коллекции имеет массив из 10 элементов. Первый элемент Второй элемент Третий элемент Последний элемент Рис. 4.2. Линейная коллекция Менеджер завода Менеджер производства Менеджер сбыта Рабочий Рабочий Рабочий Рабочий Рабочий Рис. 4.3. Нелинейные коллекции Первое объявление определяет массив целых. Версия шаблонов не предполагает определенного типа, а позволяет классу использовать параметризованное имя Т для типа элемента массива. Фактический тип указывается во время объявления объекта. Объявление 1 class Collection { • • • * int A[10]; //массив целых является данным-"членом } Collection object; //A — это массив целых Объявление 2 template <class T> class Collection {
* • • • Т А[10] ; //параметризованное объявление массива //задает Т при объявлении объекта } Collection<int> object; //А — это массив целых Collection<char> object; //A — это массив символов 4.1. Описание линейных коллекций Метод доступа для элементов различает линейные коллекции, показанные на рис. 4.1. С помощью прямого доступа (direct access) мы можем выбирать элемент непосредственно, не обращаясь сначала к предшествующим элементам в списке. Например, символы в строке могут быть доступны непосредственно. Третья буква в слове LIMEAR употреблена ошибочно. Первые две буквы написаны правильно. Мы можем исправить третью букву, не обращаясь сначала к первым двум буквам. В некоторых линейных коллекциях, называемых последовательными списками (sequential lists), прямой доступ невозможен. Вы обращаетесь к элементу, начиная с начала списка и двигаясь по списку до нужного элемента. Например, в бейсболе отбивающий благополучно достигает третьего пункта (base) только после первого и второго. Пример парковочного гаража может служить для сравнения списков с возможным прямым доступом и последовательных списков. Следующая диаграмма описывает гараж, в котором рядом с машинами имеется свободный проход. Служащий может выводить машину 3 из гаража, садясь непосредственно в нее и используя свободный проход. #0 #1 #2 #3 #4 Прямой доступ к машине 3 Следующая диаграмма иллюстрирует гараж с последовательной парковкой, в котором все машины паркуются в один ряд. Служащий имеет только последовательный доступ к машине. Чтобы вывести машину 3, он должен переместить машины 0 — 3 в таком порядке: #0 #1 #2 #3 #4 Последовательный доступ к машине 3
Коллекции с прямым доступом Массив (array) — это коллекция элементов, имеющих один и тот же тип данных, с прямым доступом посредством целого индекса. Aq A, A2 •• Aj •• An.-| Коллекция Array Данные Коллекция объектов одного и того же (однородного) типа. Операции Данные в каждом местоположении в массиве доступны непосредственно с помощью целого индекса. Статический массив (static array) содержит фиксированное количество элементов и задается в памяти во время компиляции. Динамический массив (dynamic array) создается с использованием методов динамического распределения памяти и его размер может быть изменен. Массив — это структура данных, которая может использоваться для хранения списка. В случае с последовательным списком массив позволяет выполнять эффективное добавление элементов в конец списка. Эта структура менее эффективна при удалении элемента, поскольку мы должны часто сдвигать элементы. Такой же сдвиг происходит, когда новые элементы вставляются в массив, хранящий упорядоченный список. Список M5I 201301 351401 Список 115 1201301351401 Вставить 25 45 120 на 301351401 Удалить 20 СШ 130135140 т Глава 8 знакомит с классом Array, расширяющим концепцию простого массива. Этот класс предоставляет новый индексный оператор, который перед сохранением или возвращением данных проверяет, находится ли соответствующий этим данным индекс в допустимом диапазоне. Класс, реализующий такие безопасные массивы (safe arrays), позволяет клиенту динамически распределять массив во время исполнения приложения. Символьная строка (character string) — это массив символов с ассоциированными операциями, которые определяют длину строки, склеивают (конкатенируют) две строки, удаляют подстроку и так далее. Общий класс String, имеющий расширенный набор строковых операций, разработан в главе 8. Коллекция String Данные Коллекция символов с известной длиной
Операции Имеются операции для определения длины строки, копирования одной строки в другую или их конкатенации, сравнения двух строк, выполнения сопоставления с образцом, ввода и вывода из строк. Запись (record) — это базовая структура коллекций для сохранения данных, которые могут состоять из разных типов. Для многих приложений различные элементы данных ассоциированы с одним объектом. Например, авиабилет включает такие данные, как номер рейса, номер места, имя пассажира, стоимость, данные об агенте и так далее. Единственный билетный объект — это набор полей разных типов. Коллекция записи связывает поля при обеспечении прямого доступа к данным в отдельных полях. Коллекция Record Данные Элемент с коллекцией полей, возможно, различных типов. Операции Точечный оператор (dot operator) обеспечивает прямой доступ к данным в поле. Коллекции с последовательным доступом Более общей коллекцией является список, сохраняющий элементы в последовательном порядке. Структура, называемая линейным списком (linear list), содержит произвольное число элементов. Размер списка изменяется добавлением или удалением элемента из этого списка, а ссылка на элементы в списке выполняется по их положению. Первый элемент находится в голове или в начале списка, последний элемент находится в конце списка. Каждый элемент, за исключением последнего, имеет единственный последующий элемент. 1-й 2-й 3-й 4-й • • • п-й передний последний Коллекция List Данные Произвольная коллекция объектов одного и того же (однородного) типа. Операции Для ссылки на отдельные элементы мы должны идти по списку от его начальной точки, проходя от элемента к элементу до достижения нужного местоположения. Вставки и удаления изменяют размер списка. Коллекция линейного списка может иметь любое количество элементов и подразумевает, что эта коллекция будет расширяться или сужаться по мере добавления новых элементов в список или удаления резидентных элементов. Эта структура списка является ограничивающей, когда необходим доступ к произвольным элементам, так как в ней нет прямого доступа. Для доступа к элементам списка необходимо выполнять прохождение элементов от начальной точки в списке. В зависимости от используемого метода, мы можем перемещаться одним из двух способов: слева направо или в обоих направлениях. В этой главе мы разрабатываем класс, который реализует последовательный список, используя массив. Результирующий список ограничивается размером массива. Более мощная реализация, описанная в главе 9, снимает все ограничения на размер использованием связанных списков и динамических структур.
Список покупок является примером последовательного списка. Покупатель первоначально создает список, записывая названия товаров. Делая покупки, он вычеркивает названия из списка, когда товары найдены или больше не нужны. Упорядоченный линейный список (ordered linear list) — это линейный список, данные которого упорядочены относительно друг друга. Например, список 3, 5, 6, 12, 18, 33 расположен в числовом порядке, а список 1, 6, 2, 5, 8 — нет. Бинарный поиск, описываемый в этой главе, является алгоритмом, использующим упорядоченный список. Стеки и очереди — это особые версии линейного списка с ограниченным доступом к элементам данных. В стеке (stack) элементы добавляются и удаляются только в один конец списка, называемый вершиной (top). Полка для подносов в столовой — это знакомый пример. Операция удаления элемента из списка называется извлечением из стека (popping the stack). О добавлении элемента в список говорится как о помещении (pushing) элемента в стек. вершина вершина Поместить в стек Извлечь из стека При помещении элемента в стек все другие элементы, находящиеся в данный момент в стеке, опускаются вниз, уступая место на вершине новому элементу. Когда элементы удаляются из стека, они перемещаются в обратном порядке. Последний элемент, помещенный в стек, является первым извлекаемым из стека. О таком типе хранения элементов говорят как о магазинном порядке (last -in/first-out (LIFO) — последним пришел/первым ушел). Коллекция Stack Данные Список элементов, которые могут быть доступны только на вершине списка. Операции Список поддерживает операции push и pop. Push добавляет новый элемент в вершину списка, и pop удаляет элемент из вершины списка. Мы вводим стеки в ряд приложений, которые включают оценку выражений, рекурсию и прохождение дерева. В этих случаях мы просматриваем элементы и затем обращаемся к ним в порядке LIFO. При помощи стека компиляторы передают параметры функциям, а также используют стек для хранения локальных переменных. Очередь (queue) — это список с доступом только в начале и в конце списка. Элементы вставляются в конец списка и удаляются из начала. При использовании обоих концов списка элементы оставляют очередь в том же порядке, в каком они поступают. Хранение элементов соответствует порядку поступления (first-in/first-out (FIFO) — первым пришел/первым утел).
Q-вставка последний Q-удаление передний Коллекция Queue Данные Список элементов с доступом в начале и в конце списка. Операции Добавление элемента в конец списка и удаление элемента из начала списка. Очередь является полезной коллекцией для ведения списков очередников. Моделью очереди является очередь обслуживания в банке или обслуживание покупателей в продовольственном отделе. Очереди находят машинное применение в моделирующих исследованиях и осуществляют планирование заданий в рамках операционной системы. Для некоторых приложений мы изменяем структуру очереди, устанавливая очередность элементов. При удалении объекта из списка определяется элемент с наивысшим приоритетом. Эта коллекция, называемая очередью приоритетов (priority queue), имеет операции insert (вставить) и delete (удалить). Где вставляются данные, является несущественным. Важным является то, что операция delete выбирает элемент с наивысшим приоритетом. В больничном отделении скорой помощи используется очередь приоритетов. Пациенты обслуживаются в порядке поступления, если только их состояние не является угрожающим для жизни, что дает им наивысший приоритет и первоочередной доступ к экстренной медицинской помощи. Коллекция Queue Priority Данные Список элементов, такой, что каждый элемент имеет приоритет. Операции Добавление элемента в список. При удалении элемента извлекается элемент с наивысшим приоритетом. Очереди приоритетов используются для планирования заданий в рамках операционной системы. Задания с наивысшим приоритетом должны выполняться в первую очередь. Очереди приоритетов используются также в моделировании, управляемом прерываниями (event-driven simulation). Например, в практическом приложении в главе 5 выполняется моделирование потока клиентов в банк и из банка. Каждый тип события ( появление или уход) вставляется в очередь приоритетов. Самое раннее по времени событие удаляется и обслуживается первым. В машинной системе файл (file) — это внешняя коллекция, которая имеет ассоциированную структуру данных, называемую потоком (stream). Мы приравниваем file к его stream и сосредоточиваем внимание на потоке данных. Прямой доступ осуществляется только к дисковому файлу, ленточные же файлы являются последовательными. Операция read удаляет данные из потока ввода, а операция write добавляет новые данные в конец потока вывода. Файл часто используется для хранения большого количества данных. Например, во время компиляции программы генерируются большие таблицы и часто сохраняются во временных файлах.
Коллекция File Данные Последовательность байтов, ассоциированная с внешним устройством. Данные перемещаются посредством потока к устройству и из него. Операции Открытие файла, считывание данных из файла, запись данных в файл, поиск указанного адреса в файле (прямой доступ), закрытие файла. Универсальная индексация Массив — это классическая коллекция, позволяющая иметь прямой доступ к каждому элементу, посредством целого индекса. Для многих приложений мы связываем с записью данных некоторый ключ, использующийся для доступа к записи. Когда вы звоните в банк или в страховую компанию для получения информации, вы даете ваш номер банковского счета, который становится ключом для нахождения записи этого счета. Коллекция, называемая хеш-таблица (hash table), сохраняет данные, связанные с ключом. Ключ трансформируется в целый индекс, используемый для нахождения данных. В одном часто используемом методе хеш-таблиц целое значение — это индекс в массиве коллекций. После преобразования ключа в индекс выполняется поиск ассоциированной коллекции. Ключ не обязательно должен быть целым числом. Например, запись данных может состоять из имени, классификации работы, количества лет работы в компании, жалования и так далее. "Уилсон, Сандра Р." 3 15 42500 В этом случае строка, указывающая имя, является ключом. Обычный словарь — это коллекция слов и их определений. Вы ищете слово, используя его как ключ. В структурах данных, коллекция, называемая словарем (dictionary), состоит из набора пар ключ-значение, называемых ассоциациями (associations). Ключ Значение Ассоциация Например, ключом может быть слово, а значением — строка, указывающая определение слова. К значению в ассоциации осуществляется прямой доступ с использованием ключа в качестве индекса. В результате, словарь подобен массиву, за исключением того, что индексы не должны быть целыми значениями. Например, если Diet является коллекцией dictionary, ищите определение слова dog, ссылаясь на Dict[dog]. Словари часто называют ассоциативными массивами (associative arrays), потому что они связывают (ассоциируют) общий индекс со значением данных.
above dog long Значение . . . 1 Значение . . . ■ • • Значение . . . count « Dict[dog] « endl; 4.2. Описание нелинейных коллекций На рис. 4.1 показано, что нелинейные коллекции разделяются на иерархические и групповые структуры. Иерархическая коллекция (hierarchical collection) — это масса элементов, которые разделяются по уровням. Элементы на данном уровне могут иметь несколько наследников на следующем уровне. Мы вводим особую иерархическую коллекцию, называемую деревом (tree), в которой все элементы данных происходят из одного источника, называемого корнем (root). Элементы в дереве называются узлами (nodes), каждый из которых указывает на нисходящие узлы, называемые детьми (children). Каждый элемент, за исключением корня, имеет единственного предка. Пути вниз по дереву начинаются в корне и развиваются по направлению к нижним уровням от родителя к ребенку. Корень Дерево является идеальной структурой для описания файловой системы с каталогами и подкаталогами. Модель для дерева — это организационная схема в бизнесе, определяющая цепочку управления, начиная с босса (СЕО, президента), и далее — к вице-президентам, супервайзерам и так далее. В этой книге мы рассматриваем особую форму дерева, в котором каждый узел имеет самое большее два потомка. Такая структура, бинарное дерево (binary tree), имеет важное применение в оценке арифметических выражений и в теории компиляции. С дополнительным упорядочением дерево становится деревом бинарного поиска (binary search tree), которое эффективно сохраняет большие объемы данных. Деревья бинарного поиска обеспечивают быстрый доступ к элементам, располагая узлы так, что данные можно находить, перемещаясь вниз по короткому пути из корневого узла. На рис. 4.4 показано дерево с 16 узлами. Самый длинный путь от корня к узлу включает четыре ветви. Предположим, что дерево относительно заполнено узлами, отношение
узлов к длине пути значительно улучшается по мере того, как мы увеличиваем размер дерева. Пример: если дерево бинарного поиска имеет 220 — 1 = 1 048 575 узлов, которые расположены на минимальном количестве уровней, то элемент данных можно найти, посещая не более, чем 20 узлов. Особое дерево бинарного поиска — это AVL-дерево, гарантирующее равномерное распределение узлов и обеспечивающее очень короткое время поиска. Корень Рис. 4.4. Дерево с 16 узлами Коллекция Tree Данные Иерархическая коллекция узлов, происходящих из корня. Каждый узел указывает на узлы-сыновья, которые сами являются корнями поддеревьев. Операции Структура дерева позволяет добавлять и удалять узлы. Несмотря на то, что дерево — это нелинейная структура, алгоритмы прохождения деревьев позволяют нам посещать отдельные узлы и осуществлять поиск ключа. Heap-дерево — это особая версия дерева, в котором самый маленький элемент всегда занимает корневой узел. Операция delete удаляет корневой узел, и обе операции insert и delete вызывают такую реорганизацию дерева, что самый маленький элемент вновь занимает корень такого дерева. Heap- дерево использует очень эффективные алгоритмы реорганизации, просматривая только короткие пути от корня вниз к концу дерева. Heap-дерево может использоваться для упорядочения списка элементов. Вместо использования медленных алгоритмов сортировки мы упорядочиваем их, повторно удаляя корневой узел из heap-дерева. Это позволяет получить быструю сортировку (heap-сортировку). Кроме того, при использовании heap-дерева наиболее часто реализуется очередь приоритетов. Коллекции групп Группа (group) представляет те нелинейные коллекции, которые содержат элементы без какого-либо упорядочения. Множество уникальных элементов является примером группы. Операции над коллекцией типа множество включают объединение (union) и пересечение (intersection). Другие операции над множеством тестируют на членство и отношение подмножеств. В главе 8 мы
вводим класс Set с перегрузкой операторов для реализации операций над множествами. S » {1.2.3}. Т » {3.8.5} S объединение Т-{1,2,3,8,5} 5 пересечение Т -{3} Коллекция Set Данные Неупорядоченная коллекция объектов без дубликатов. Операции Бинарные операции членства, объединения, пересечения и дифференциации, которые возвращают новое множество. Ряд операторов, тестирующих отношения подмножеств. Множество (set) — это коллекция, находящая применение, когда данные являются неупорядоченными и каждый элемент данных является единственным в своем роде, уникальным. Например, группа регистрации избирателей составляет банк телефонных номеров для того, чтобы звонить лицам, находящимся в списке. Каждый раз, когда группа контактирует с человеком из банка номеров, его имя помещается в список номеров, по которым позвонили, и удаляется из банка. Конечно, группа людей, которым еще не позвонили, тоже является множеством. Группа регистрации избирателей продолжает звонить, пока множество номеров, по которым не позвонили, не будет пустым, или не будет сделано разумное количество попыток позвонить. Граф (graph) — это структура данных, задающая набор вершин и набор связей, соединяющих вершины. Графы находят применение в планировании заданий, транспортировании и так далее. Например, строитель дома должен заключать контракты на этапы строительной работы. План работы должен быть составлен так, чтобы обеспечить выполнение всей подготовительной работы к моменту, когда начнется новый этап строительства. Например, кровельщики не могут начать свою работу, пока строители не завершат работу по сооружению дома, а строительные работы не могут быть выполнены, пока не будет заложен бетонный фундамент. Подвод водоснабжения Начало Подвод коллектора Сантехнические работы Строительные работы Закладка фундамента Кровельные работы
Коллекция Graph Данные Набор вершин и набор соединительных связей. Операции Как коллекция вершин и связей граф имеет операции для добавления и удаления этих элементов. Алгоритмы просмотра начинаются в заданной вершине и находят все другие вершины, которые достижимы из начальных вершин. Другие алгоритмы просмотра выполняют оба просмотра графа — в глубину и в ширину. Сеть (network) — это особая форма графа, которая присваивает вес каждой связи. Вес указывает стоимость использования связи при прохождении графа. Например, в следующей сети вершины представляют города, а вес, присваиваемый связям, — это расстояния между парами городов. 752 Salt Lake City 604 San Francisco .648 763 504 San Diego )~355 Phoenix Albuquerque 4.3. Анализ алгоритмов В этой книге мы разрабатываем, классы реализующие коллекции данных. Для реализации методов класса часто используются классические алгоритмы. Мы часто описываем подробно разработку и реализацию этих алгоритмов и анализируем их эффективность. Клиент судит о программе по ее корректности, легкости исцользования и эффективности. Легкость использования и корректность программы зависит от процедур разработки и тестирования. На эффективность программы влияет множество факторов, которые включают внутреннюю машинную систему, количество памяти, имеющейся для управления данными и сложность алгоритмов. Мы кратко рассматриваем эти факторы и затем сосредоточиваем внимание на вычислительной сложности алгоритмов. Мы разрабатываем критерии эффективности, позволяющие нам измерять эффективность какого-либо алгоритма в терминах размера коллекции. Критерии не зависят от определенной машинной системы и измеряют абстрактные характеристики эффективности алгоритмов. Для создания численной меры эффективности нами используется нотация Big-0. Критерии эффективности Алгоритм, в конечном счете, выполняется в машинной системе со специфическим набором команд и периферийными устройствами. Для отдельной системы какой-либо алгоритм может быть разработан для полного использования преимуществ данного компьютера и поэтому достигает высокой степени эффективности. Критерий, называемый системной эффективностью (system efficiency), сравнивает скорость выполнения двух или более алгоритмов, которые разработаны для выполнения одной и той же задачи. Выполняя эти алгоритмы на одном компьютере с одними и теми же наборами данных, мы
можем определить относительное время, используя внутренние системные часы. Оценка времени становится мерой системной эффективности для каждого из алгоритмов. При работе с некоторыми алгоритмами могут стать проблемой ограничения памяти. Процесс может потребовать большого временного хранения, ограничивающего размер первоначального набора данных, или вызвать требующую времени дисковую подкачку. Эффективность пространства (space efficiency) — это мера относительного количества внутренней памяти, используемой каким-либо алгоритмом. Она может указать, какого типа компьютер способен выполнять этот алгоритм и полную системную эффективность алгоритма. Вследствие увеличения объема памяти в новых системах, анализ пространственной эффективности становится менее важным. Третий критерий эффективности рассматривает внутреннюю структуру алгоритма, анализируя его разработку, включая количество тестов сравнения, итераций и операторов присваивания, используемых алгоритмом. Эти типы измерений являются независимыми от какой-либо отдельной машинной системы. Критерий измеряет вычислительную сложность алгоритма относительно п, количества элементов данных в коллекции. Мы называем эти критерии вычислительной эффективностью (computational efficiency) алгоритма и разрабатываем нотацию Big-О для построения измерений, являющихся функциями п. Нотация Big-O. Интуитивно вычислительная эффективность алгоритма измеряется количеством обрабатываемых им данных для определения ключевых операций алгоритма. Эти операции могут зависеть от типа коллекции данных, количества данных и их начального упорядочения. Нахождение минимального элемента в массиве — это простой алгоритм, основная операция которого включает сравнение элементов данных. Для массива с п элементами алгоритм требует п — 1 сравнений и мера эффективности пропорциональна п. Другие алгоритмы являются более сложными. Для обменной сортировки, описанной в главе 2, обработка данных включает серию сравнений в каждом прохождении. Если А — это массив из п элементов, то обменная сортировка выполняет п — 1 проходов. На рис. 4.5 показан этот алгоритм. После прохода элементы с X сохраняются Проход 1 п-1 сравнений Проход 2 п-2 сравнений Проход i n-i сравнений Проход N-1 1 сравнение Рис. 4.5. Проходы в обменной сортировке
Проход 1: Сравнение п-1 — элементов А[1] . . . А[п — 1] с А[0] и, если необходимо, такой обмен элементов, чтобы А[0] всегда имел наименьшее значение. Проход 2: Сравнение п-2 — элементов А[2] . . . А[п — 1] с А[1]. Проход i: Для общего случая, сравнение п4 — элементов A[i] . . . A[n — i] с A[i — 1]. Общее число сравнений в сортировке обмена задается арифметическим рядом f(n) от 1 до п-1: f(n) = (п — 1) + (п — 2) + . . . + 3 + 2 + 1 = п(п — 1)/2 Количество сравнений зависит от п2. Для обработки данных общих классов коллекций таких, как последовательные списки и деревья, мы используем сравнения в качестве меры эффективности алгоритмов. Алгоритмы зависят также от начального упорядочения данных. Например, нахождение минимального значения в массиве значительно упрощается, если мы знаем, что эти данные упорядочены. В возрастающем списке минимальное значение занимает первую позицию. Это значение находится в конце убывающего списка. В этих случаях вычислительная сложность включает единственный доступ к данным, который может быть выполнен в постоянную единицу времени. В примере с сортировкой, если список упорядочен, не требуется никакого обмена. Это условие наилучшего случая, и оно представляет наиболее эффективное выполнение алгоритма. Однако, если список отсортирован в обратном порядке, каждое сравнение приводит к обмену. Это условие наихудшего случая для сортировки. Общий случай предполагает некоторое промежуточное количество обменов в зависимости от порядка данных в списке. Для алгоритмов поиска и сортировки в классе коллекций мы используем количество сравнений как доминантное действие и меру вычислительной эффективности. Наш анализ определяет также начальное упорядочение данных, в котором можно различать наилучший случай (best case), наихудший случай (worst case) и средний случай (average case) для алгоритма. Средний случай — это ожидаемая эффективность алгоритма, если он выполняется много раз со случайным набором значений данных. Определяя вычислительную эффективность алгоритма, мы ассоциируем функцию f(n) с количеством сравнений. В действительности, точная форма функции может быть трудна для определения, и поэтому мы используем методы аппроксимации для определения хорошей верхней границы функции. Мы определяем простую функцию g(n) и константу К так, что K*g(n) превышает f(n) по мере того, как п значительно возрастает. Для большого значения п поведение f(n) ограничивается произведением функции g(ri) на некоторую константу. Мы используем эту математическую концепцию, называемую нотацией Big-О, чтобы дать меру вычислительной эффективности. Определение: Функция /(п) имеет порядок 0(£(л)), если имеется константа К и счетчик л0, такие, что f(n) < K*g(n), для п > /г0. Интуитивно это означает, что функция g в конечном счете превышает значение функции /. Мы говорим, что вычислительная сложность (computational complexity) (или порядок) алгоритма равна 0(g(n)). Традиционно значение Big-О для алгоритма структур данных выбирается среди небольшого набора полиномиальных, логарифмических и экспоненциальных функций. Для классических структур данных эти функции дают наилучшие верхние границы вычислительной сложности алгоритмов.
Kg(n) f(n) В примере с обменной сортировкой мы ищем функцию gf которая ограничивает f(n). В таблице 4.1 рассматриваются g(n) = х/2 п2 и f(n) для разных значений л. В конечном счете, функция f(n) ограничивается величиной l/2 g(n), где g(n) = п2. В этом случае возможное условие появляется непосредственно при п0 = 1 и К = 1/2. f(n) < l/2 п2 для всех п >_ 1 Мы говорим, что f(n) имеет порядок 0(g(n)) = 0(n2), поэтому вычислительная сложность обменной сортировки составляет 0(п2). Анализ наилучшего и наихудшего случаев также приводит к той же мере сложности, так как обменная сортировка всегда требует 1/2 п(п — 1) сравнений. Этот алгоритм сортировки требует порядка 0(п2) единиц времени для вычисления независимо от начального порядка данных. В нашем исследовании сортировки мы обнаружим, что некоторые алгоритмы имеют вычислительную сложность (порядок) 0(п log2n) для достаточно большого п0- Количество сравнений < К п log2 п для п > п0. В таблице 4.1 сравниваются значения л2 и п log2n. Заметьте, насколько более эффективным является алгоритм сортировки 0(п log2n), чем обменная сортировка. Например, в случае со списком из 10 000 элементов количество сравнений для обменной сортировки ограничивается величиной 100 000 000, тогда как более эффективный алгоритм имеет количество сравнений, ограниченное величиной 132 000. Новая сортировка приблизительно в 750 раз более эффективна. Таблица 4.1 п 10 100 1000 5000 10000 (1/2)п2 50 5.000 500.000 12.500.000 50.000.000 S(n) = n2/2 - п-2 45 4.950 499.500 12.497.500 49/995.000 п 5 10 100 1000 10000 п2 25 100 10000 1000000 100000000 п !одгп 11,6 33,2 664,3 9965,7 132877,1
При выполнении Big-O-аппроксимации функции f(n) мы используем термин доминирование для определения вычислительной сложности. Небольшой опыт работы с неравенствами дает возможность математически проверить эту стратегию. Например, в случае функции f(n) = п + 2 терм п является доминирующим. Функция g(n) = n используется в следующем неравенстве для проверки того, что / имеет порядок О(п). f(n) = и + 2</1 + гс = 2*л для п > 2 / также имеет порядок 0(п2) или 0(п3), так как g(n) = п2 и £(п) = п3 ограничивают /(я). Мы выбираем О(п), что представляет наилучшую оценку для этой функции. Пример 4.1 1. f(n) = п2 + п + 16 Доминирующий терм — п29 а / имеет порядок 0(п2). f(n) = п2 + п + 1 < п2 + п2 + п2 = Зп2 для п > 1 2. f(n) = sqrt(n+3) Доминирующий терм — sqrt(n), a / имеет порядок 0(sqrt(n)) f(n) = sqrt(n+3) < sqrt(n+n)=sqrt(2n)=sqrt(2)*sqrt(n) для п >, 3 3. /fra,) = 2Л + л + 2 Доминирующий терм — 2n, a / имеет порядок 0(2п). f(n) = 2п + п + 2 < 2п + 2п +2п = 3*2П, для п > 1 Сложность алгоритма. Big-O-оценка дает меру времени выполнения (runtime) алгоритма. Обычно алгоритм имеет разную вычислительную эффективность для наилучшего и наихудшего случаев, поэтому мы вычисляем конкретное значение Big-О для каждого случая. В разделе 4.4 излагается метод нахождения времении выполнения для последовательного и бинарного поиска. Каждый алгоритм имеет порядок для наилучшего и наихудшего случая, которые различны. Наилучший случай для алгоритма часто не важен, так как эти обстоятельства являются исключительными и неподходящими для решения о выборе какого-либо алгоритма. Наихудший случай может быть важен, так как эти обстоятельства будут наиболее негативно влиять на ваше приложение. Клиент может не допускать наихудшего случая и может предпочесть, чтобы вы выбрали алгоритм, который имеет более узкий диапазон эффективности. В общем, довольно трудно математически определить среднюю эффективность какого-либо алгоритма. Мы будем использовать только очень простые измерения ожидаемых значений и оставим математические детали для курса по теории сложности. Общий порядок величин Небольшой набор различных порядков определяет сложность большинства алгоритмов структур данных. Мы определяем различные порядки и описываем алгоритмы, приводящие в результате к таким оценкам.
Если алгоритм — порядка 0(1), то этот порядок не зависит от количества элементов данных в коллекции. Этот алгоритм выполняется за постоянную единицу времени (constant time). Например, присваивание некоторого значения элементу списка массива имеет порядок 0(1), при условии, что вы храните индекс, который определяет конец списка. Сохранение этого элемента включает только простой оператор присваивания. Прямая вставка в конец списка начало конец Алгоритм О(п) является линейным (linear). Сложность этого алгоритма пропорциональна размеру списка. Например, вставка элемента в конец списка п элементов будет линейной, если мы не храним ссылку на конец списка. Подразумевая, что мы можем просматривать элемент за элементом, алгоритм требует, чтобы мы протестировали п элементов перед определением конца списка. Порядком этого процесса является О(п). Нахождение максимального элемента в массиве из п элементов — это О(п), потому что должен быть проверен каждый из п элементов. Последовательная вставка в конец списка начало конец Ряд алгоритмов имеют порядок, включающий log2n, и называются логарифмическими (logarithmic). Эта сложность возникает, когда алгоритм неоднократно подразделяет данные на подсписки, длиной 1/2, 1/4, 1/8, и так далее от оригинального размера списка. Логарифмические порядки возникают при работе с бинарными деревьями. Бинарный поиск, изложенный в разделе 4.4, имеет сложность среднего и наихудшего случаев O(log2n). В главах 13 и 14 описываются алгоритмы сортировки с использованием дерева и быстрая сортировка порядка 0(п log2n). Алгоритмы, имеющие порядок 0(гс2), являются квадратическими (quadratic). Наиболее простые алгоритмы сортировки такие, как обменная сортировка, имеют порядок 0(п2). Квадратические алгоритмы используются на практике только для относительно небольших значений п. Всякий раз, когда п удваивается, время выполнения такого алгоритма увеличивается на множитель 4. Алгоритм показывает кубическое (cubic) время, если его порядок равен 0(л3), и такие алгоритмы очень медленные. Всякий раз, когда п удваивается, время выполнения алгоритма увеличивается в восемь раз. Алгоритм Уоршела, применимый к графам, — это алгоритм порядка 0(п3). Алгоритм со сложностью 0(2п) имеет экспоненциальную сложность (exponential complexity). Такие алгоритмы выполняются настолько медленно, что они используются только при малых значениях п. Этот тип сложности часто ассоциируется с проблемами, требующими неоднократного поиска дерева решений.
В таблице 4.2 приводятся линейный, квадратичный, кубический, экспоненциальный и логарифмический порядки величины для выбранных значений п. Из таблицы очевидно, что следует избегать использования кубических и экспоненциальных алгоритмов, если только значение п не мало. Таблица 4.2 Оценка порядка алгоритмов п 2 4 8 16 32 128 1024 65536 1од2П 1 2 3 4 5 7 10 16 п !од2П 2 8 24 64 160 896 10240 1048576 п2 4 16 64 256 1024 16384 1048576 4294967296 п3 8 64 512 4096 32768 2097152 1073741824 2.8 х 1014 2П 4 16 256 65536 4294967296 3.4 х 1038 1.8 х 10308 Избегайте! 4.4. Последовательный и бинарный поиск Теперь познакомимся с последовательным поиском в целях нахождения некоторого значения в списке. Предположим, что мы ищем пределы списка целых с использованием этого алгоритма. В действительности, мы можем выполнять поиск в массиве любого типа, для которого определен оператор ==. Необходимо модифицировать последовательный поиск для ссылки на параметризованный тип DataType, который является псевдонимом фактического типа. Мы создаем этот псевдоним, используя ключевое слово typedef. Например: typedef int DataType; //DataType это int или typedef double DataType: //DataType это double Если предположить, что программист имеет определенный тип DataType, то код для общего алгоритма последовательного поиска следующий: // поиск в массиве а из п элементов для нахождения соответствия с ключем // использовать последовательный поиск, возвращать индекс // соответствующего элемента массива или — 1, если нет соответствия int SeqSearch(DataType list[ ], int n, DataType key) { for (int i=0; i < n; i++) if (list[i] == key) return i; //возвращать индекс соответствующего элемента return -1; //поиск неудачный, возвращать -1 } При определении порядка алгоритма последовательного поиска различают поведение наилучшего и наихудшего случаев. Наилучшему случаю соответствует нахождение ключа в первом элементе списка. Время выполнения алгоритма при этом составляет 0(1). Наихудший случай имеет место, когда этот ключ не находится в списке или обнаруживается в конце списка. Он требует
проверки всех п элементов и имеет порядок О(л). Средний случай требует небольшого количества вероятностных рассуждений. Для случайного списка совпадение с ключом может с одинаковой вероятностью появиться в любой позиции списка. После выполнения проверок большого количества элементов средняя позиция совпадения — это срединный элемент (midpoint) п/2. Эта промежуточная точка анализируется после п/2 сравнений, что определяет ожидаемую стоимость поиска. По этой причине мы говорим, что средняя эффективность последовательного поиска составляет О(п). Бинарный поиск Последовательный поиск применим для любого списка. Если список является упорядоченным, алгоритм, называемый бинарный поиск (binary search), предоставляет улучшенный метод поиска. Ваш опыт по нахождению номера в большом телефонном справочнике — это модель такого алгоритма. Зная нужные имя и фамилию, вы открываете справочник ближе к началу, середине или концу, в зависимости от первой буквы фамилии. Вам может повезти, и вы сразу попадете на нужную страницу. В противном случае вы переходите к более ранней или более поздней странице в справочнике в зависимости от относительного местоположения имени человека по алфавиту. Например, если имя человека начинается с буквы R, а вы находитесь на странице с именами на букву Т, вы переходите на более раннюю страницу. Процесс продолжается до тех пор, пока вы не найдете соответствие или не обнаружите, что этого имени нет в справочнике. Соответствующая идея применима к поиску в упорядоченном списке. Мы идем к середине списка и ищем быстрое соответствие ключа значению срединного элемента. Если нам не удается найти соответствия, мы смотрим на относительный размер ключа и значение срединного элемента и затем перемещаемся в нижнюю или верхнюю половину списка. В общем, если мы знаем, как упорядочены данные, мы можем использовать эту информацию, чтобы сократить время поиска. Следующие шаги описывают алгоритм. Предположим, что список упорядочен, как массив. Индексами в концах списка являются: low = 0 и high = п — 1, где п — это количество элементов в массиве. 1. Сравнить индекс срединного элемента массива: mid * <low+high)/2. 2. Сравнить значение в срединном элементе с key (ключ). key key low mid high low mid high low mid high Совпадение найдено Поиск в левой половине Поиск в правой половине Если совпадение найдено, возвращать индекс mid для нахождения ключа, if (A[mid] -- key) return(mid); key
Если AJmid] < key, совпадение должно происходить в диапазоне индексов mid-fl . . . high, в правой половине рассматриваемого списка. Это верно, потому что список упорядочен. Новыми границами являются low=mid+l и high. Если key < A[mid], совпадение должно происходить в диапазоне индексов low . . . mid-1, в левой половине списка. Новыми границами являются low и high=mid-l. key key low mid - 1 = high low = mid + 1 high Проверка левой половины Проверка правой половины Алгоритм уточняет местоположение совпадающего с ключом элемента, деля пополам длину интервала, в котором может находиться этот элемент, и затем выполняя тот же алгоритм поиска в меньшем подсписке. В конце концов, если искомый элемент не находится в списке, low превысит high, и алгоритм возвращает индикатор сбоя — 1 (совпадение не произошло). Пример 4.2 Рассмотрим массив целых А. Этот пример дает выборку алгоритма для заданного ключа 33. low = О high = 8 mid = (0 + 8)/2 « 4 33 > A[mid] low — 5 high = 8 mid = (5 + 8)/2 = 6 33 > A[mid] low = 7 high «= 8 mid = (7 + 8)/2 = 7 33 > A[mid] Success! Заметьте, что этот алгоритм требует трех (3) сравнений. При линейном поиске в списке требуется восемь (8) сравнений.
Реализация бинарного поиска Функция использует параметризованное имя DataType, которое должно поддерживать оба оператора: равенства (==) и меньше чем (<)• Первоначально low равно 0, a high — (п-1), где п — число элементов в этом массиве. Функция возвращает номер удовлетворяющего условию элемента массива или -1, если такой элемент не найден (low>high). // dsearch.h // просмотреть сортированный массив на предмет совпадения // с ключом, используя бинарный поиск, возвращать индекс // совпадающего элемента массива или -1, если совпадение //не происходит int BinSearch(DataType list[], int low, int high, DataType key) { int mid; DataType midvalue; while (low <= high) { mid = (low+high)/2; // mid-индекс в подсписке midvalue = list[mid]; // значение при mid-индексе if (key == midvalue) return mid; // совпадение имеется, возвращаем // его положение в массиве else if (key < midvalue) high = mid-1; // перейти в нижний подсписок else low = mid+1; // перейти в верхний подсписок } return -l; // элемент не найден } Реализация последовательного и бинарного поиска включена в файл dsearch.h. Так как эта функция зависит от класса DataType, определение DataType должно предшествовать включению этого файла. Программа 4.1. Сравнение последовательного и бинарного поиска Программа сравнивает время вычисления последовательного и бинарного поиска. Массив А заполняется 1000 случайными целыми числами в диапазоне 0 . • 1999 и затем сортируется. Второму массиву В присваиваются 500 случайных целых чисел в том же диапазоне. Элементы в массиве В используются как ключи для алгоритмов поиска. Временная функция TickCount определяется в файле ticks.h и возвращает количество 1/60-х секунд со времени запуска системы. Мы измеряем время, которое занимает выполнение 500 поисков, используя каждый алгоритм. Выходная информация включает время в секундах и количество соответствий. #include <iostream.h> typedef int DataType; // данные типа integer #include "dsearch.h" #include "random.h" #include "ticks.h" // сортировать целый массив из п элементов // в возрастающем порядке void ExchangeSort(int a[], int n) {
int i, j, temp; for (i=0;i < n-l; i++) // поместить минимум элементов a[i] . . .a[n-1] в a[i] for (j = i+1; j < n; j++) // если a [ j ] < a[i], выполнить их замену if (a[j] <a[i]) { temp = a[i] ; a[i] = a[j] ; a[j] •= temp; } } void main (void) { //А содержит список для поиска, В содержит ключи int А[1000], В[500]; int i, matchCount; // используется для данных времени long tcount; RandomNumber rnd; // создать массив А из 1000 случайных чисел со значениями // в диапазоне 0. . 1999 for (i - 0; i < 1000; i++) A[i] = rnd.Random(2000); ExchangeSort(A,1000); // генерить 500 случайных ключей из того же диапазона for (i« 0; i < 500; i++) B[i] - rnd.Random(2000) ; cout « "Время последовательного поиска" << endl; tcount = TickCount (); // время начала matchCount = 0; for (i = 0; i < 500; i++) if (SeqSearch(A,1000, B[i]) !=-l) matchCount++; tcount = TickCount() — tcount; // cout « "Последовательный поиск занимает " « tcount/60.0 « " скунд для " « matchCount « " совпадений." « endl; cout « "Время бинарного поиска"« endl; tcount = TickCount() ; matchCount = 0; for (i = 0; i < 500; i++J if (BinSearch(A/0,999,B[i]) !=-1) matchCount++; tcount=TickCount() —tcount; // cout « "Бинарный поиск занимает " « tcount/60.0 « " секунд для " « matchCount « " совпадений." « endl; } /* <Выполнение программы 4 . 1> Время последовательного поиска Последовательный поиск занимает 0.816667 секунд для 181 совпадений. Время бинарного поиска Бинарный поиск занимает 0.016667 секунд для 181 совпадений. */
Неформальный анализ для бинарного поиска. Наилучший случай имеет место, когда совпадающий с ключом элемент находится в середине списка. При этом порядок алгоритма составляет 0(1 )f так как требуется только одно тестирующее сравнение равенства. При наихудшем случае, когда элемент не находится в списке или определяется в последнем сравнении, имеем порядок 0(log2n). Мы можем интуитивно вывести этот порядок. Наихудший случай возникает, когда мы должны уменьшать подсписок до длины 1. Каждая итерация, которая не находит соответствие, уменьшает длину подсписка на множитель 2. Размеры подсписков следующие: п п/2 п/4 п/8 ... 1 Разделение на подсписки требует т итераций, где т — это приблизительно log2n (см. подробный анализ). Для наихудшего случая мы имеем начальное сравнение в середине списка и затем — ряд итераций log2n. Каждая итерация требует одну операцию сравнения: Total Comparisons = 1 + log2n В результате наихудшим случаем для бинарного поиска является 0(log2ri). Этот результат проверяется имперически программой 4.1. Отношение времени выполнения последовательного поиска ко времени выполнения бинарного поиска равно 49,0. Теоретическое отношение ожидаемого времени приблизительно составляет 500/(log2l000)= 50,2. Формальный анализ бинарного поиска. Первая итерация цикла имеет дело со всем списком. Каждая последующая итерация делит пополам размер подсписка. Так, размерами списка для алгоритма являются п п/21 п/22 п/23 п/24 . . . п/2т В конце концов будет такое целое т, что п/2ш<2 или n<2m+1 Так как m — это первое целое, для которого n/2m<2, то должно быть верно n/2m-l>2 или 2m<n Из этого следует, что 2m<n<2m+1 Возьмите логарифм каждой части неравенства и получите /о^г^действи- тельному числу х: m<\og2n=x<m+1 Значение m — это наибольшее целое, которое .<х и задается int(x). Например, если n=50, log250=5,644. Следовательно, m=int(5,644)= 5 Можно показать, что средний случай также составляет 0(log2n). 4.5. Базовый класс последовательного списка Товары для покупки, автобусное расписание, телефонный справочник, налоговые таблицы и инвентаризационные записи являются примерами списков. В каждом случае объекты включают последовательность элементов. Во многих приложениях ведется какой-либо список. Например, перечень товаров пред-
приятия содержит информацию о поставках и заказах, персонал офиса создает платежную ведомость для списка работников компании, ключевые слова для компилятора сохраняются в списке зарезервированных слов и так далее. В главе 1 описывался ADT для базового последовательного списка. Операции базового списка включают вставку нового элемента в конец списка, удаление элемента, доступ к элементу в списке по позиции и очистку списка. Мы имеем также операции для тестирования, является ли список пустым, или находится ли какой-либо элемент в списке. В качестве примера этому из реальной жизни рассмотрим список продуктов для покупки в универсаме (Рис.4.6). Когда вы идете по универсаму, вы решаете купить дополнительные товары и добавляете их в конец списка. Когда товар найден, вы удаляете его из списка. Список с этими простыми операциями может использоваться для решения существенных задач. Приложение описывает отдел видеотоваров, в котором ведется список имеющихся фильмов и список клиентов. Когда какой-либо фильм выдается клиенту, он переходит из списка имеющихся фильмов в список клиентов. При возврате фильма происходит обратный процесс. Первоначальный список Вычеркнули картофель Добавили рыбу Вычеркнули хлеб Добавили молоко Рис.4.6. Список покупок Список ADT описывает однородные списки (homogeneous lists), в которых каждый элемент имеет один и тот же тип данных, называемый DataType. В определении ADT не упоминается о том, как хранятся элементы. Для этого может использоваться массив или связанный список с применением динамического распределения памяти. Реализации операций Insert, Delete и Find зависят от метода, используемого для хранения элементов списка. В главе 1 приводится лишь набросок спецификации этого класса. В данном разделе мы приводим реализацию класса SeqList, который сохраняет элементы в массиве. В главе 9 мы разрабатываем новую реализацию этого класса, используя связанные списки, и выводим этот класс из абстрактного базового класса List в главе 12. В главах 11, 13, и 14 разрабатываются классы сходной структуры для деревьев бинарного поиска, хеш-таблиц и словарей.
Спецификация класса SeqList ОБЪЯВЛЕНИЕ #include <iostream.h> #include <stdlib.h> typedef int DataType; const int MaxListSize = 50; class SeqList { private: // массив для списка и число элементов текущего списка DataType listitem[MaxListSize]; int size; public: // конструктор SeqList(void) ; // методы доступа int ListSize(void) const; int ListEmpty(void) const; int Find (DataType& item) const; DataType GetData(int pos) const; // методы модификации списка void Insert(const DataType& item); void Delete(const DataTypefc item); DataType DeleteFront(void); void ClearList(void); >; ОПИСАНИЕ Объявление и реализация находятся в файле aseqlist.h. Имя DataType используется для представления общего типа данных. Перед включением класса из файла используйте typedef для связывания имени DataType с конкретным типом. Переменная size поддерживает текущий размер списка. Первоначально размер установлен на 0. Так как статический массив используется для реализации списка, константа MaxListSize является верхней границей размера списка. Попытка вставить больше, чем MaxListSize элементов в список приводит к сообщению об ошибке и завершению программы. Реализация класса SeqList Данная реализация класса SeqList использует массив listitem для сохранения данных. Коллекция распределяет память для MaxListSize числа элементов типа DataType. Количество элементов в списке содержится в size (член класса). Файлы iostream.h и stdlib.h включены для обеспечения выдачи сообщения об ошибках и для завершения программы, если Insert приведет к тому, что размер превысит MaxListSize. Закрытый член size содержит длину списка для операций Insert и Delete. Значение size является центральным для конструктора и методов ListSize, ListEmpty и ClearList. Мы включаем конструктор, устанавливающий размер на 0. // конструктор, устанавливает size в 0 SeqList::SeqList (void) : size(0) {}
Методы модификации списка Метод Insert добавляет новый элемент в конец списка и увеличивает длину на 1, Если при этом превышается размер массива listitem, то метод выводит сообщение об ошибке и завершает программу. Ограничение на размер списка снимается в главе 9, где класс реализуется с использованием связанного списка. вставка (10) Элемент параметра передается в качестве ссылки константе. Если размер DataType большой, использование ссылочного параметра позволяет избежать неэффективного копирования данных, которое необходимо в вызове параметра по значению. Ключевое слово const указывает на то, что фактический параметр не может быть изменен. Этот же тип передачи параметра используется методом Delete. Insert // вставить элемент в хвост списка, прервать выполнение // программы, если размер списка превысит MaxListSize void SeqList::Insert(const DataTypeS item) { // проверка размера списка if (size+1 > MaxListSize) { cerr «"Превышен максимальный размер списка" « endl; exit(1); > // индекс хвоста равен размеру текущего списка listitem[sizej = item; size++; } Метод Delete определяет первое появление в списке заданного элемента. Функция требует, чтобы был определен оператор сравнения (==) для DataType. В некоторых случаях для этого может потребоваться, чтобы пользователь предоставил особую функцию, которая переопределяет оператор == для конкретного DataType. Эта тема формально излагается в главе 6. Если элемент не обнаруживается при индексе i, операция спокойно заканчивается без изменения списка. Если элемент найден, он удаляется из списка перемещением всех элементов с индексами i+1 к концу списка влево на одну позицию.
Например, удаление элемента со значением 45 из списка приведет к смещению влево хвостовых элементов 23 и 8. Длина списка изменяется с 6 на 5. Удаление элемента со значением 30 оставляет список неизменным. Delete // поиск и удаление элемента item из списка void SeqList::Delete(const DataTypefc item) { int i = 0; // поиск элемента while (i < size && «(item == listitem[i])) i++; if (i < size) { // передвинуть хвост списка на одну позицию влево while (i < size-1) { listitem[i] = listitem[i+i]; i++; } size—; // уменьшение size на 1 } } Методы доступа к списку. Метод GetData возвращает значение данных в позицию pos в списке. Если pos не находится в диапазоне от 0 до size-1, печатается сообщение об ошибке, и программа завершается. // возвращает значение элемента списка для индекса pos. если pos // не находится в диапазоне индексов списка, программа заканчивается //с сообщением об ошибке DataType SeqList::GetData(int pos) const { // прервать программу, если pos вне диапазона индексов списка if (pos < 0 || pos >= size) { cerr « "pos выходит за диапазон индексов!" « endl; exit(1); } return listitem[pos]; } Метод доступа Find принимает параметр, который служит в качестве ключа, и последовательно просматривает список для нахождения совпадения. Если список пустой или совпадение не найдено, Find возвращает 0 (False). Если элемент обнаруживается в списке в позиции с индексом i, Find присваивает запись из listitem[i] соответствующему элементу списка и возвращает 1 (True). Для данных, совпадающих с ключом, процесс присваивания значения данных элемента списка параметру является важнейшим в приложениях, касающихся записей данных. Например, предположим, что DataType — это структура с полем ключа и полем значения, и что оператор == тестирует
только поле ключа. При вводе элемент параметра может определять только поле ключа. При выводе элемент присваивается обоим полям. ключ ключ значение элемент (вводг ^элемент (вывод) SeqList ключ значение ключ значение ключ значение Совпадение Find // использовать item в качестве ключа для поиска в списке. // возвращать True, если элемент item находится в списке, и // False — в противном случае, если элемент найден, присвоить // его значение параметру item, передаваемому по ссылке int SeqList::Find(DataTypefi item) const { int i - 0; if (ListEmptyO ) return 0; // возвратить False, если список пуст while (i < size && ! (item » listitem[i])) i++; if (i < size) { item » listitem[i]; // присвоить item значение элемента списка return 1; // возвратить True } else return 0; // возвратить False } Класс SeqList не предоставляет метода для непосредственного изменения значения какого-либо элемента. Для выполнения такого изменения мы должны сначала найти этот элемент и возвратить запись данных, удалить этот элемент, изменить запись и вставить новые данные в список. Конечно, это изменяет положение элемента в списке, потому что новый элемент помещается в конец списка. Пример 4.3 Запись Inventoryltem содержит номер детали и количество деталей в запасе. Оператор == сравнивает две записи Inventoryltem, сравнивая поля partNumber. Выполняется поиск SeqList-объекта L для нахождения записи с partNumber 5. Если объект найден, запись обновляется увеличением поля count. struct Inventoryltem { int partNumber; int count; } int operator— (Invntoryltem x, Invntoryltem y) {
return x.partNumber == у.partNumber; } typedef Inventoryltem DataType; #include "aseqlist.h" * * * SeqList L; Inventoryltem item; • • • item.partNumber = 5; if(L.Find(item)) { L.Delete(item); item.count++; L.Insert(item); ) Так как любой элемент всегда вставляется в хвост списка, порядок (время выполнения) метода Insert зависит от п и равен O(l). Find выполняет последовательный поиск, поэтому среднее время его работы будет О(л). На протяжение многих проб метод Delete должен проверить в среднем п/2 элементов списка и должен перемещать в среднем п/2 элементов. Это означает, что среднее время выполнения для Delete составляет О(л). Порядок наихудшего случая для обоих методов Find и Delete также составляет О(п). Применение. Объекты SeqList используются для ведения списка имеющихся фильмов, и списка фильмов, взятых для просмотра клиентами в видеомагазине. Каждый элемент в списке является записью, которая состоит из названия фильма и (для проката) имени клиента. // структура записи для хранения данных о фильме и клиенте struct FilmData { char filmName[32]; char customerName[32]; } Так как метод Find в классе SeqList требует определения оператора сравнения ==, мы перегрузим этот оператор для структуры FilmData. Этот оператор проверяет имя файла, используя функцию C++ strcmp. // перегрузка == int operator -= (const FilmData &A, cost FilmData *B) { return strcmp(A.filmName, B.filmName); ) Чтобы использовать FilmData с классом SeqList, включите объявление typedef FilmData DataType; Определение оператора == для FilmData и DataType находятся в файле video.h. В видеомагазине ведется инвентаризационный список фильмов. Для простоты мы полагаем, что в магазине имеется только одна копия каждого фильма. Новый фильм добавляется в инвентаризационный список функцией Insert. Для проверки наличия фильма в списке используется функция Find. Если фильм найден, он удаляется из инвентаризационного списка фильмов и вставляется в список фильмов, отданных для просмотра.
Программа 4.2. Видеомагазин Main-программа эмулирует операции видеомагазина. Первоначально весь перечень фильмов считывается из файла films и сохраняется в списке с именем inventory List. Мы наблюдаем короткий промежуток времени деятельности видеомагазина и рассматриваем заказы четырех клиентов на прокат фильмов. В каждом случае мы вводим имя клиента и заказ фильма и определяем, имеется ли этот фильм в наличии в настоящее время. Если да, то мы удаляем его из инвентаризационного списка и добавляем клиента в список лиц, взявших фильмы напрокат. Если фильма нет в наличии, клиент уведомляется об этом. #include <iostream.h> #include <fstream.h> #include <stdlib.h> #include <string.h> tinclude "video.h" // объявления видео-данных #include "aseqlist.h" // включить класс SeqList // читать таблицу фильмов с диска void SetupInventoryList(seqList sinventoryList) { ifstream filmFile; FilmData fd; // открыть файл, с проверкой ошибок filmFile.open("Films", ios::in | ios::nocreate); if (!filmFile) { cerr « "Файл 'films' не найден!" « endl; exit(1); } // читать строки до конца файла; // вставлять наименования фильмов в инвентаризационный список while(filmFil.e.getline(fd.filmName,32,'\n')) inventoryList.Insert(fd); } // печать наименовании фильмов void PrintlnvemtoryList(const SeqList &inventoryList) { int i; FilmData fd; for (i - 0; i < inventoryList.ListSize (); i++) { fd «= inventoryList.GetData(i) ; // cout « fd.filmName « endl; // } > // цикл по списку клиентов, печать клиентов и фильмов void PrintCustomerList(const SeqList &customerList) { int i FilmData fd; for (i « 0; i < customerList.ListSize (); i++)
fd = customerList.GetData(i); // cout « fd.customerName « " (" « fd.filmName « ") " « endl; } } void main (void) { // SeqList invemtoryList, customerList; int i; // FilmData fdata; char customer [20]; SetupInventoryList (inventoryList); // читать файл с фильмами // запрос имени клиента и названия фильма. // если запрошенный фильм имеется в наличии, он вносится в список клиентов //и удаляется из списка фильмов; в противном случае выдается // сообщение об отсутствии фильма for (i = 0; i < 4; i++) { // ввод имени клиента и названия фильма cout « "Имя клиента: "; cin.getline(customer,32,'\n'); cout « "Запрашиваемый фильм: " ; cin.getline(fdata.filmName,32,'\n'); if (inventoryList.Find(fdata)) { strcpy(fdata.customerName, customer); // вставить название фильма в список клиентов customerList.Insert(fdata); // удалить из списка фильмов inventoryList.Delete(fdata); } else cout « "Сожалею! " « fdata. filmName « " отсутствует." « endl; } cout << endl; // печать списков клиентов и фильмов cout « "Клиенты, взявшие фильмы для просмотра" « endl; PrintCustomerList(customerList); cout « endl; cout « "Фильмы, оставшиеся в ведомости:"« endl; PrintlnventoryList(inventoryList); } /* <Входной файл "Films"> Война миров Касабланка Грязный Гарри Дом животных Десять заповедей Красавица и зверь Список Шиндлера Звуки музыки
La Strata Звездные войны <Выполнение программы 4.2> Имя клиента: Дон Бекер Запрашиваемый фильм: Дом животных Имя клиента: Тери Молтон Запрашиваемый фильм: Красавица и зверь Имя клиента: Деррик Лопез Запрашиваемый фильм: La Strata Имя клиента: Хиллари Дэн Запрашиваемый фильм: Дом животных Сожалею! Дом животных отсутствует. Клиенты, взявшие фильмы для просмотра Дон Бекер (Дом животных) Тери Молтон (Красавица и зверь) Деррик Лопез (La Strata) Фильмы, оставшиеся в ведомости: Война миров Касабланка Грязный Гарри Десять заповедей Список Шиндлера Звуки музыки Звездные войны */ Письменные упражнения 4.1 Объясните различие между линейной и нелинейной структурой данных. Дайте пример каждой. 4.2 Определите, какая структура данных является соответствующей для следующих ситуаций: (а) Сохранить абсолютное значение числа в элементе 5 целого списка. (б) Пройти по списку студентов в алфавитном порядке и напечатать отметки. (в) Когда арифметический оператор найден, два предыдущих числа удаляются из коллекции. (г) В исследовании с использованием моделирования каждое событие вставляется в коллекцию и удаляется в порядке его вставки. (д) Когда ферзь на шахматной доске может переместиться в какую-либо позицию, эта позиция вставляется в коллекцию. (е) Одно поле структуры данных является целым, другое — действительным значением, а последнее поле — это строка. (ж) Постоянно сохраняйте выходные данные программы, чтобы рассмотреть их позже. (з) Если ключ меньше, чем текущее значение в списке, рассмотрите предыдущие значения. (и) Минимальное значение всегда переходит в вершину списка. (к) Строка используется в качестве ключа для нахождения записи данных, находящейся где-то в коллекции. (л) Слово используется в качестве индекса для нахождения его определения в коллекции.
(м) В праздники телефонная сеть перегружена. Определите альтернативный набор путей для наилучшего распределения вызовов. 4;3 Ниже приведены меры сложности наихудшего случая для трех алгоритмов, которые решают одну и ту же задачу: Алгоритм 1 Алгоритм 2 Алгоритм 3 0(п2) 0(п log2n) 0(2n) Какой метод предпочтителен и почему? 4.4 Выполните анализ Big-О для каждой из следующих функций: (а) п + 5 (б) п2 +6п +7 (в) Vn + 3 (г) п3 + п2 - 1 п + 1 4.5 (а) Для какого значения п>1, 2п становится больше, чем и3? (б) Покажите, что 2п +п3 имеет порядок 0(2п). (в\ п2 + 5 v ' Дайте Big-O-оценку для — + 6 \og2n ? л + 3 4.6 Список целых ведется в массиве. Каков порядок алгоритма печати первого и последнего элемента в массиве? 4.7 Объясните, почему алгоритм порядка 0(log2n) имеет также порядок 0{п). 4.8 Каждый цикл является главным компонентом для алгоритма. Используйте нотацию Big-O, чтобы выразить время вычисления наихудшего случая для каждого из следующих алгоритмов как функции п. (а) for (dotprd=0.0,i=0;i < n;i++) dotprd +=a[i] * b[i]; (б) for (i=0;i<n;i++) if(a[i] == k) return 1; return 0; (в) for (i=0;i<n;i++) for (j=0;j<n;j++) b[i] [j] *= c; (r) for (i=0;i<n;i++) for(j=0;j<n;j++) { entry= 0.0; for (k=0;k<n;k++) entry +=a[i] [k] * b[k] [j]; c[i,j] = entry; } 4.9 Следующие коллекции из п элементов используются для сохранения данных. Каков порядок алгоритма нахождения минимального значения (а) в стеке? (б) в очереди приоритетов?
(в) в дереве бинарного поиска? (г) в последовательном списке с упорядочением по возрастанию? (д) в списке с возможностью прямого доступа к элементам с упорядочением по убыванию? 4.10 Последовательность чисел Фибоначчи имеет вид: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, . , . Первые два числа являются 1, и каждое последующее число Фибоначчи является суммой двух предшествующих. Эта последовательность описывается рекуррентным отношением fl = 1, f2=l, fn = fn-2 + fn-1, ДЛЯ П > 3 Следующая функция вычисляет n-ное число Фибоначчи. Каков ее порядок? long Fibonacci(int n) { long fnm2=l, fninl^l, fn; int i; if (n<« 2) return 1; for (i-~3;i<= n;i++) { fn - fnm2 + fnml; fnm2 - fnml; fnml - fn; } return fn; } В главе 10 рекурсивная функция записывается для вычисления п-ного числа Фибоначчи. Метод имеет экспоненциальный порядок. Ясно, что рекурсивное решение неприемлемо! 4.11 (а) Последовательный поиск используется в списке из 50000 элементов. □ Какое наименьшее количество сравнений выполнит этот поиск? □ Какое необходимо максимальное количество сравнений? □ Каково ожидаемое количество сравнений? (б) Бинарный поиск используется в списке из 50000 элементов □ Какое наименьшее количество сравнений выполнит этот поиск? D Какое максимальное количество сравнений необходимо? 4.12 Предположим, SeqList-объект L состоит из элементов: 34 11 22 16 40 (а) Задайте элементы в списке после каждой из следующих команд: п « L.DeleteFront(); L.Insert(n); if (L.Find(L.GetData(0)*2) L.Delete(16); (б) Используя объект L, приведите выход для следующей последовательности команд:
for (int i=0; i <5; i++) { L.Insert(L.DeleteFront()); cout « L.GetData(i) << ; } 4.13 Напишите функцию для реализации указанной задачи (а) Добавить SeqList-объект L в хвост объекта К. void Concatenate(SeqListfi К, SeqList& L); (б) Поменяйте на обратный порядок элементов в SeqList-объекте L. void Reverse (SeqList& L); 4.14 Функция Ques берет SeqList-объект L, элементы которого все являются положительными целыми. Каково действие функции над списком? {1, 3, 7, 2, 15, 0, 12}? Почему L должен передаваться по ссылке? typedef int DataType; #include "aseqlist.h" int M(const SeqList &L) { int i, nival, length « L.ListSize (); if (length == 0) { cerr « Список пустой << endl; return -1; } mval = L.GetData(O); for (i= 1; i< length; I++) if (L.GetData(i) > mval) mval « L.GetData(i); return mval; } void Ques(SeqList &L) { int mval = M(L); L.Delete(mval); } 4.15 Объясните, почему необходима пересылка данных при реализации метода Delete в классе SeqList на базе массива. Упражнения по программированию 4.1 Используйте класс SeqList с DataType int для создания utility функции InsertMax: void InsertMax(SeqList& L, int elt); InsertMax помещает elt в список L, только если он больше всех существующих элементов в списке.
Напишите main-программу, которая читает 10 целых и вызывает In- sertMax для каждого. Напечатайте список. 4.2 Объявите запись struct Person { char name [20]; int age; char gender; }; и вызовите класс SeqList следующим образом: #inclucle <string.h> //needed for SeqList class method Find int operator»* (Person x, Person y) { return strcmp(x.name, y.name)-* 0; } typedef Person DataType; tinclude "aseqlist.h" (а) Напишите функцию void PrintByGender(const SeqListS L, char sex); которая проходит по списку L и печатает все записи, имеющие заданный пол. (б) Напишите функцию int InList (const SeqList&, char *nm, Persons p); которая определяет, существует ли в списке L запись с полем имени nm. Выполните это, создав объект Person с полем имени nm и используя метод Find. Нет необходимости инициализировать поля age и gender записи. Сравнение двух записей выполняется сравнением полей имени. Если совпадение происходит, присвоить запись параметру р и возвратить 1; в противном случае возвратить 0. Параметр р не должен изменяться, если только не будет найдено совпадение. (в) Напишите main-программу для тестирования этих функций. 4.3 Напишите программу, которая запрашивает у пользователя целое п, генерирует массив из п случайных целых в диапазоне 0 ... 999 и сортирует список, используя обменную сортировку. Задайте время выполнения сортировки, используя функцию TickCount, определенную в файле ticks.h. Выполните программу, используя п= 50, 500, 1000 и 10000. Это является экспериментальным подтверждением того, что обменная сортировка имеет порядок 0(п2). Примечание: Так как локальный массив из 1000 или 10000 элементов может превысить объем динамической области системы, выделить динамический массив со следующим синтаксисом для хранения элементов. Вы можете использовать общую нотацию массива a[i] с динамической структурой. int *a; //определяет указатель • • • а = new int [n]; //п равен 50, 500, 1000 или 10000
Напишите программу, которая запрашивает у пользователя целое п, генерирует массив из п случайных целых в диапазоне 0 ... 999 и сортирует список, используя обменную сортировку. Задайте время выполнения сортировки. Выполните программу, используя п = 50, 500, 1000 и 10000. Это является экспериментальным подтверждением того, что обменная сортировка имеет порядок 0(п2). 4.4 Это упражнение расширяет применение Программы 4.2 (Видеомагазин) для включения возврата фильмов. Спросите клиента, берет ли он фильм напрокат или возвращает. Если фильм возвращается, удалите его из списка фильмов, взятых напрокат, и вставьте его в инвентаризационный список. 4.5 Ситуация тестирования содержит пример структуры SeqList. Студенты сдают контрольные работы на стол преподавателя титульной стороной вниз (вставка в конец списка). Предположим, что взволнованный студент обнаруживает правильный ответ на какой-либо вопрос и хочет проверить, как он (или она) ответил. Преподаватель должен перевернуть стопку контрольных работ так, чтобы первая работа оказалась титульной стороной вверх, просмотреть работу, пока не будет найдена работа этого студента, и затем удалить контрольную работу из списка. После того, как студент закончит проверку работы, преподаватель вставляет ее в коцец списка. Напишите программу, которая использует класс SeqList для моделирования этой ситуации. Ассоциируйте студента с контрольной работой, используя следующую запись: struct Test { char name [30]; int testNumber; }; Цикл в main-программе управляет выполнением, читая целое: 1=Сдать контрольную работу 2=Позволить студенту проверить работу З-Возвратить взятую работу 4=Выйти из программы Выполните следующие действия: Ввод 1: Запросите имя и номер работы; вставьте работу в список sub- mittedTests. Ввод 2: Запросите только имя, удалите работу из submitted-Tests и вставьте ее в список borrowedTests. Ввод 3: Запросите имя, удалите запись из borrowedTests и вставьте ее в submittedTests. Ввод 4: Преподаватель готов уйти, и все взятые на время работы должны быть возвращены. Удалите все элементы из borrowedTests, вставляя их в submittedTests. Печатайте окончательный список. Вы должны определить оператор ==, чтобы определить, равны ли две записи Test. Выполните это, используя функцию #include <string.h> int operator== (const Test& tl, Tests t2) { return strcmp(tl.name/ t2.name) -= 0; }
глава Стеки и очереди 5.1. Стеки 5.2. Класс Stack 5.3. Оценка выражения 5.4. Очереди 5.5. Класс Queue 5.6. Очереди приоритетов 5.7. Практическое применение: управляемое событиями моделирование Письменные упражнения Упражнения по программированию
В этой главе мы обсуждаем более подробно классический стек и очередь, являющиеся структурами данных, сохраняющими и возвращающими элементы из защищенных частей списка. Описывается также очередь приоритетов, модифицированная версия очереди, в которой из списка удаляется элемент с наивысшим приоритетом. Стек, очередь и очередь приоритетов реализуются как классы C++. Основные идеи этой главы иллюстрируют два практических примера. Демонстрируется действие RPN-калькулятора со стеком операндов. Пример обслуживания очереди клиентов кассирами в банке показан с помощью событийного моделирования. Это приложение использует очередь приоритетов и знакомит с важным инструментом управления в бизнесе. 5.1. Стеки Стек является одной из наиболее используемых и наиболее важных структур данных. Стеки применяются очень часто. Например, распознавание синтаксиса в компиляторе, как и оценка выражений, основано на стеке. На нижнем уровне стеки используются для передачи параметров функциям, выполнения вызова функции и возвращения из нее. Стек (stack) — это список элементов, доступных только в одном конце списка. Элементы добавляются или удаляются из списка только в вершине (top) стека. Подносы в столовой или стопка коробок являются моделями стека. Стек предназначен для хранения элементов, доступных естественным путем в вершине списка. Представим шампур, на который нанизаны нарезанные овощи, подготовленные для шашлыка. На рис. 5.1 овощи на шампуре 1 расположены в порядке: лук, грибочек, зеленый перец и лук. Перед приготовлением шашлыка гость сообщает, что он не ест грибов, и их необходимо убрать. Эта просьба означает удалить лук (шампур 2), уда- лить грибочек (шампур 3) и затем вновь нанизать лук (шампур 4). Если гость не любит зеленый перец или лук, это доставит повару больше проблем. В структуре стека важнейшее место занимают операции, добавляющие и удаляющие элементы. Операция Push добавляет элемент в вершину стека. Об операции удаления элемента из стека говорят как об извлечении (to pop the stack) из стека. На рис. 5.2 показана последовательность операций Push и Pop. Последний вставленный в стек элемент является первым удаляемым
элементом. По этой прцчине о стеке говорят, что он имеет порядок LIFO (last-in/first-out) (последний пришел/первый ушел). Лук Зеленый перец Грибы Рис. 5.1. Стек овощей Абстрактное понятие стека допускает неопределенно большой список. Логически подносы в столовой могут складываться бесконечно. В действительности подносы находятся на полке, а овощи нанизаны на коротких шампурах. Когда полка или шампур переполнены, мы не можем добавить (Push) еще один элемент в стек. Стек достигает максимального количества элементов, которыми он может управлять. Эта ситуация поясняет значение условия полного стека (stack full). Другая крайность — вы не можете взять поднос с пустой полки. Условие пустого стека (stack empty) подразумевает, что вы не можете удалить (Pop) элемент. Описание ADT Stack включает только условие пустого стека. Условие полного стека является уместным в том случае, если реализация содержит верхнюю границу размера списка. Push A Push В Push С Pop Pop Push D Рис. 5.2. Помещение в стек и извлечение из него
ADT Stack Данные Список элементов с позицией top, указывающей на вершину стека. Операции Конструктор Начальные значения: Нет Процесс: Инициализация вершины стека. StackEmpty Вход: Нет Предусловия: Нет Процесс: Проверка, пустой ли стек. Выход: Возвращать True, если стек пустой, иначе возвращать False. Постусловия: Нет Pop Вход: Нет Предусловия: Стек не пустой. Процесс: Удаление элемента из вершины стека. Выход: Возвращать элемент из вершины стека. Постусловия: Элемент удаляется из вершины стека. Push Вход: Элемент для стека. Предусловия: Нет Процесс: Сохранение элемента в вершине стека. Выход: Нет Постусловия: Стек имеет новый элемент в вершине. Peek Вход: Нет Предусловия: Стек не пустой. Процесс: Нахождение значения элемента в вершине стека. Выход: Возвращать значение элемента из вершины стека. Постусловия: Стек неизменный. ClearStack Вход: Нет Предусловия: Нет Процесс: Удаление всех элементов из стека и переустановка вершины стека. Выход: Нет Постусловия: Стек переустановлен в начальные условия. Конец ADT Stack 5.2. Класс Stack Члены класса Stack включают список, индекс или указатель на вершину стека и набор стековых операций. Для хранения элементов стека используется массив. В результате размер стека не может превысить количества элементов в массиве и условие полного стека является релевантным. В главе 9 мы снимаем это ограничение, когда разрабатываем класс Stack, используя связанный список.
Объявление объекта типа Stack включает размер стека, который определяет максимальное количество элементов в списке. Размер имеет значение по умолчанию MaxStackSize = 50. Список (stacklist), максимальное количество элементов в стеке (size) и индекс (top) являются закрытыми членами, а операции — открытыми. top Реализация стека Первоначально стек пуст и top = -1. Элементы вводятся в массив (функция Push) в возрастающем порядке индексов (top = 0, 1, 2) и извлекаются из стека (функция Pop) в убывающем порядке индексов (top = 2, 1, 0). Например, следующий объект является стеком символов (DataType = char). После нескольких операций Push/Pop индекс top = 2, а элемент в вершине стека — это stacklist[top] = С. top- top«=2 Возрастающий индекс Пример 5.1 Данный пример иллюстрирует целый массив из 5 элементов с последовательностью операций Push 10; Push 25; Push 50; Pop; Pop. Индекс top увеличивается на 1 при операции Push и уменьшается на 1 при операции Pop. Пустой стек top = -1 Push 10 top = 0 Push 25 top = 1 Push 50 top = 2 Pop top = 1 Pop top =0
Спецификация класса Stack ОБЪЯВЛЕНИЕ #include <iostream.h> tinclude <stdlib.h> const int MaxStackSize « 50; class Stack { private: // закрытые данные-члены, массив стека и вершина (индекс) DataType stacklist[MaxStackSize]; int top; public: // конструктор; инициализирует вершину Stack (void); // операции модификации стека void Push (const DataType& item); DataType Pop (void); void ClearStack(void); // доступ к стеку DataType Peek (void) const; // методы проверки стека int StackEmpty(void) const; int StackFull(void) const; // реализация массива }; ОПИСАНИЕ Данные в стеке имеют тип DataType, который должен определяться с использованием оператора typedef. Пользователь должен проверять, полный ли стек, перед попыткой поместить в него элемент и проверять, не пустой ли стек, перед извлечением данных из него. Если предусловия для операции push или pop не удовлетворяются, печатается сообщение об ошибке и программа завершается. StackEmpty возвращает 1 (True), если стек пустой, и 0 (False) — в противном случае. Используйте StackEmpty, чтобы определить, может ли выполняться операция Pop. StackFull возвращает l(True), если стек полный, и 0 (False) — в противном случае. Используйте StackFull, чтобы определить, может ли выполняться операция Push. Clear Stack делает стек пустым, устанавливая top — -1. Этот метод позволяет использовать стек для других целей. ПРИМЕР Объявление стека и реализация содержатся в файле astack.h* typedef int DataType; #include astack.h; // включить описание класса Stack Stack S; // объявить объект типа Stack S.Push(10); // поместить в стек S значение 10 cout « S.Peek() « endl; // напечатать 10 // вытолкнуть 10 из стека и оставить стек пустым if (!S.StackEmpty()) temp * S.Pop(); cout « temp « endl; S.ClearStackO; // очистить стек
Реализация класса Stack Конструктор Stack присваивает индексу top значение -1, что эквивалентно условию пустого стека. //инициализация вершины стека Stack::Stack (void) : top(-l) { > Операции стека. Две основные операции стека вставляют (Push) и удаляют (Pop) элемент из стека. Класс содержит операцию Peek, позволяющую клиенту выбирать данные из элемента в вершине стека, не удаляя в действительности этот элемент. Для того, чтобы поместить элемент в стек, увеличьте индекс top на 1 и присвойте новый элемент массиву stacklist. Попытка добавить элемент в полный стек приведет к сообщению об ошибке и завершению программы. // поместить элемент в стек void Stack::Push (const DataType& item) { // если стек полный, завершить выполнение программы if (top == MaxStackSize-1) { cerr « "Переполнение стека!" « endl; exit(l); } // увеличить индекс top и копировать item в массив stacklist top++; stacklist[top] « item; } Перед вставкой элемента После вставки элемента top item top = top + 1 Операция Pop извлекает элемент из стека, копируя сначала значение из вершины стека в локальную переменную temp и затем увеличивая top на 1. Переменная temp становится возвращаемым значением. Попытка извлечь элемент из пустого стека приводит к сообщению об ошибке и завершению программы. Перед операцией Pop После операции Pop item item top top = top - 1 Возвратить // взять элемент из стека DataType Stack::Pop (void) { DataType temp;
// стек пуст, завершить программу if (top == -1) { cerr << "Попытка обращения к пустому стеку! м << endl; exit(l); } // считать элемент в вершине стека temp = stacklist[top] ; // уменьшить top и возвратить значение из вершины стека top—; return temp; } Операция Peek в основном дублирует определение Pop с единственным важным исключением. Индекс top не уменьшается, оставляя стек нетронутым. // возвратить данные в вершине стека DataType Stack::Peek (void) const { // если стек пуст, завершить программу if (top == -1) { cerr << "Попытка считать данные из пустого стека!" << endl; exit(l); } return stacklist[top]; } Условия тестирования стека. Во время своего выполнения операции стека завершают программу при попытках клиента обращаться к стеку неправильно; например, когда мы пытаемся выполнить операцию Peek над пустым стеком. Для защиты целостности стека класс предусматривает операции тестирования состояния стека. Функция StackEmpty проверяет, является ли top равным -1. Если — да, стек пуст и возвращаемое значение — l(True); иначе возвращаемое значение — О (False). // тестирование стека на наличие в нем данных int Stack:-.StackEmpty (void) const { // возвратить логическое top == -1 return top == -1; } Функция StackFull проверяет, равен ли top значению MaxStackSize-1. Если так, то стек заполнен и возвращаемым значением будет 1 (True); иначе, возвращаемое значение — 0 (False). // проверка, заполнен ли стек int Stack::StackFull(void) const { return top == MaxStackSize-1; ) Метод ClearStack переустанавливает вершину стека на -1. Это восстанавливает начальное условие, определенное конструктором. // удалить все элементы из стека void Stack::ClearStack<void) { top = -1; }
Стековые операции Push и Pop используют прямой доступ к вершине стека и не зависят от количества элементов в списке. Таким образом, обе операции имеют время вычисления 0(1). Применение: Палиндромы. Когда DataType является типом char, приложение обрабатывает символьный стек. Приложение определяет палиндромы, являющиеся строками, которые читаются одинаково в прямом и обратном порядке. Пробелы не включаются. Например, dad, sees и madam im adam являются палиндромами, a good — нет. Программа 5.1 использует класс Stack для тестирования входной строки на наличие палиндрома. Программа 5.1. Палиндромы Эта программа читает строку текста, используя функцию cin.getlineQ, и вызывает функцию Deblank для удаления всех пробелов из текста. Функция Deblank копирует непустые символы во вторую строку программы, сканируя строку дважды, проверяет, является ли строка, лишенная пробелов, палиндромом. Во время первого просмотра каждый символ помещается в стек, создается список, содержащий текст в обратном порядке. Во время второго просмотра каждый символ сравнивается с элементом, который удаляется из стека. Просмотр завершается, если два символа не совпадают, в случае чего этот текст не является палиндромом. Если сравнения остаются правильными до тех пор, пока стек не будет пуст, этот текст является палиндромом. Исходная строка = "dad" Палиндром top Исходная строка = "good" Не палиндром top #include <iostream.h> ipragma hdrstop typedef char DataType; // элементы стека - символы #include "astack.h" // создает новую строку void Deblank(char *s, char *t) i // цикл до тех пор, пока не встретится NULL-символ while(*s != NULL) { // если символ - не пробел, копировать в новую строку if (*s != ' ' ) *t++ = *s; s-*- + ; // передвинуться к следующему символу } *t = NULL; // добавить NULL в конец новой строки }
void main (void) { const int True = 1, False = 0; Stack S; char palstring[80], deblankstring[80), c; int i = 0; int ispalindrome = True; // полагаем, что строка - палиндром // считать в полную строку cin.getline(palstring/80,' \n' ); // удалить пробелы из строки и поместить результат в deblankstring Deblank(palstring,deblankstring) ; // поместить символы выражения без пробелов в стек i-0; while(deblankstring[i] != 0) { S.Push(deblankstring[i]); i++; } // сравнение вершины стека с символами из оригинальной строки i- 0; while (!S.StackEmpty()) { с = S. Pop () ; // получить следующий символ из стека // если символы не совпадают, прервать цикл if (с != deblankstring[i]j { ispalindrome = False; // не палиндром break; } i++; } if (ispalindrome) cout « ' \"' « palstring « ' V" « " - палиндром" « endl; else cout « ' \n' « palstring « ' \M/ « " - не палиндром" << endl; } /* Оапуск 1 Программы 5 . 1> madam im adam "madam im adam" - палиндром < Запуск 2 Программы 5. 1> a man a plan a canal panama "a man a plan a canal panama" - палиндром < Запуск 3 Программы 5 . 1> palindrome "palindrome" - не палиндром */
Применение: Вывод данных с различными основаниями. Операторы вывода многих языков программирования печатают числа в десятичном формате как значения по умолчанию. Стек может использоваться для печати чисел с другими основаниями. Бинарные (с основанием 2) числа описаны в главе 2, и мы полагаем, что вы можете применить рассмотренные там принципы для других оснований. Пример 5.2 В этом примере десятичные числа преобразуются в числа с заданным основанием. 1. (Основание 8) 28ю = 3*8+4 = 34в 2. (Основание 4) 72ю = 1*64+0*16+2*4+0 = 1020* 3. (Основание 2) 53ю =1*32+1*16+0*8+1*4+0*2+1 =1101012 Задача отображения на экране числа с недесятичным основанием решается с использованием стека. Мы описываем алгоритм для десятичного числа п, которое печатается как число с основанием В. 1. Крайняя правая цифра п — это п%В. Поместите ее в стек S. 2. Остальные цифры п задаются как п/В. Замените п на п/В. 3. Повторяйте шаги 1-2 до тех пор, пока не будет выполнено л=0 и не останется ни одной значащей цифры. 4. Теперь в стеке имеется новое представление N как числа с базой В. Выбирайте и печатайте символы из S до тех пор, пока стек не будет пуст. На рис. 5.3 показано преобразование п = 3553ю в число с основанием 8. Рисунок отображает рост стека при создании четырех восьмеричных цифр для п. Завершим алгоритм выборкой и затем печатью каждого символа из стека. Выходом является число 6741. п = 355310 Пустой стек п = 3553 п%8 = 1 п/8 = 444 п = 444 п%8 = 4 п/8 = 55 п = 55 п%8=7 п/8 = 6 п = б п%8 = 6 п/8 = 0 п-0 Рис. 5.3. Использование стека для печати числа в восьмеричном формате
Программа 5.2. Печать числа по любому основанию Программа представляет функцию выхода, которая принимает неотрицательное длинное целое num и основание В в диапазоне 2 - 9 и отображает num на экране как число с основанием В. Main-программа запрашивает у пользователя три неотрицательных целых числа и основания, а затем выводит эти числа с соответствующими основаниями. #include <iostream.h> #pragma hdrstop typedef int DataType; #include "astack.h" // печать целого num с основанием В void MultibaseOutput(long num, int B) { Stack S; // извлечение чисел с основанием В справа налево //и помещение их в стек S do { S.Push(int(num % В)); num /= В; } while (num != 0) ; while (!S.StackEmpty()) cout « S.Pop(); } void main(void) { long num; // десятичное число int В; // основание // читать 3 положительных числа и приводить к основанию 2 <= В <= 9 for(int i=0;i < 3;i++) { cout << "Введите неотрицательное десятичное число и основание " << "(2<=В<=9): "; cin >> num >> В; cout << num << " основание " << В << " - "; MultibaseOutput(num, В); cout << endl; } } /* Оапуск программы 5.2> Введите неотрицательное десятичное число и основание (2<-В<=9) : 72 4 72 основание 4 - 1020 Введите неотрицательное десятичное число и основание (2<=В<=9): 53 2 53 основание 2 - 110101 Введите неотрицательное десятичное число и основание (2<=В<=9): 3553 8 3553 основание 8 - 6741 */
5.3. Оценка выражений Электронные калькуляторы иллюстрируют одно из основных применений стека. Пользователь вводит математическое выражение с числами (операндами) и операторы, а калькулятор использует один стек для вычисления числовых результатов. Алгоритм калькулятора предусматривает ввод выражения в определенном числовом формате. Такое выражение, как -8 + (4*12 + 5А2)/3 содержит бинарные операторы (binary operators) (+, *, /, "), операнды (operands) (8, 4, 12, 5, 2, 3) и круглые скобки (parantheses), которые создают подвыражения. Первая часть выражения включает унарный оператор (unary operator) отрицания, который действует на один операнд (например, - 8). Другие операторы называются бинарными, потому что они требуют двух операндов. Оператор Л создает выражение 52 = 25. Выражение записывается в инфиксной (infix) форме, если каждый бинарный оператор помещается между его операндами и каждый унарный оператор предшествует его операнду. Например, -2 + 3*5 является инфиксным выражением. Инфиксный формат является наиболее общим для записи выражений и используется в большинстве языков программирования и калькуляторов. Инфиксное выражение вычисляется алгоритмом, который использует два стека для различных типов данных: один — для хранения операндов, другой — для хранения операторов. Так как класс Stack в astack.h требует единственного определения DataType, мы не можем реализовать вычисление инфиксного выражения в данном разделе. Эта тема описывается в главе 7, где определяются шаблоны и реализуется шаблон класса Stack. Этот класс позволяет использовать два или более объектов типа Stack с различными типами данных. Альтернативной рассмотренной форме является постфиксное (postfix) представление, в котором операнды предшествуют оператору. Этот формат называется также RPN или Польской инверсной нотацией (Reverse Polish Notation). Например, инфиксное выражение "а + Ь" записывается в постфиксной форме как "a b +". В постфиксной форме переменные и числа вводятся по мере их появления, а оператор — там, где имеются два его операнда. Например, в следующем выражении знак * вводится непосредственно после его двух операндов b и с. Знак 4- вводится после того, как имеются его операнды а и (Ь*с). В постфиксном формате используется приоритет операторов. Оператор * появляется перед -К Infix: а + Ь*с = а + (Ь * с) Postfix: a b с * + Скобки в постфиксном формате не обязательны. Далее следует ряд инфиксных выражений и их постфиксные эквиваленты. Инфиксное выражение а*Ь + с a*b*c*d*e*f а + (b*c +d)/e (b*b - 4*а*с)/(2*а) Постфиксное выражение а Ь*с + a b*c*d*e*f* a b c*d + e/+ b b*4a*c* - 2а*/
Постфиксная оценка Постфиксное выражение оценивается алгоритмом, который просматривает выражение слева направо и использует стек. Для этого примера мы полагаем, что все операторы являются бинарными. Унарные операторы мы охватываем в упражнениях. Выражение в постфиксном формате содержит только операнды и операторы. Мы читаем каждый терм и в зависимости от его типа выполняем действия. Если терм является операндом, помещаем его значение в стек. Если терм является оператором <ор>, дважды выполняем выборку из стека для возвращения операндов X и Y. Затем оцениваем выражение, используя оператор <ор> и помещаем результат X<op>Y обратно в стек. После чтения каждого терма в выражении вершина стека содержит результат. Пример 5.3 Инфиксное выражение 4+3*5 записывается в постфиксной форме как 4 3 5*+. Для его оценки требуется пять шагов. Шаги 1-3: Читать операнды 4 3 5 и помещать каждое число в стек. Шаг! Шаг 2 ШагЗ top top top Push 4 Push3 Push 5 Шаг 4: Читать оператор * и оценить выражение выборкой верхних двух операндов 5 и 3 и вычислением 3*5. Результат 15 поместить обратно в стек. top Шаг 4 Шаг 5: Читать оператор + и оценить выражение выборкой операндов 15 и 4 и вычислением 4+15 = 19. Результат 19 поместить обратно в стек, возвратить в качестве результата полученное выражение. top Шаг 5
Применение: постфиксный калькулятор Мы иллюстрируем вычисление постфиксного выражения, моделируя работу с RPN-калькулятором, имеющим операторы +,-,*,/ и л (возведение в степень). Калькулятор принимает данные с плавающей точкой и вычисляет выражения. Данные калькулятора и операции включаются в класс Calculator, а простая main-функция вызывает его методы. Класс Calculator содержит открытые функции-члены, которые вводят выражение и очищают калькулятор. Для оценки выражения используется ряд закрытых функций-членов. Спецификация класса Calculator ОБЪЯВЛЕНИЕ enum Boolean {False, True}; typedef double DataType; // калькулятор принимает числа типа double #include "astack.h" // включить файл с описанием класса Stack class Calculator { private: // закрытые члены: стек калькулятора и операторы Stack S; void Enter{double num); Boolean GetTwoOperands(doubles opndl, doubles opnd2); void Compute(char op); public: // конструктор Calculator(void); // вычислить выражение и очистить калькулятор void Run(void); void Clear(void); }; ОПИСАНИЕ Конструктор умолчания создает пустой стек калькулятора. Так как калькулятор работает постоянно, пользователь должен вызывать Clear для очистки стека калькулятора и обеспечения последующего вычисления нового выражения. Операция Run позволяет вводить выражение в RPN-формате. Ввод символа "=" завершает выражение. Отображается только конечное значение выражения. Сообщение об ошибке Missing operand (Отсутствует операнд) выводится, когда оператор не имеет двух операндов. Попытка разделить на ноль также вызывает сообщение об ошибке. В любом другом случае калькулятор очищает стек и готовится к новому вводу. ПРИМЕР Calculator CALC; //Создает объект CALC CALC.Run(); <Пример работы> 4 3* = 12 // оезультат выражения 4*3
Реализация класса Calculator Функции калькулятора выполняются рядом методов, которые позволяют клиенту вводить число, выполнять вычисление и печатать результат на экране. Определение класса находится в файле calc.h. Calculator::Calculator(void) {} Метод Enter принимает аргумент с плавающей точкой и помещает его в стек. //сохраняем значение данных в стеке void Calculator::Enter(double num) { S.Push(num); } Функция GetTwoOperands используется методом Compute для получения операндов из стека калькулятора и присваивания их параметрам выхода operandi и operand 2. Этот метод выполняет проверку ошибок и возвращает значение, которое указывает, существуют ли оба операнда. // извлекать операнды из стека и назначать из параметрам // печатать сообщение и возвращать False, // если нет двух операндов Boolean Calculator::GetTwoOperands(doubles opndl, doubles opnd2) { if (S.StackEmpty()) // проверить наличие операнда { cerr << "Missing operand!". « endl; return False; } opndl = S.Pop (); // извлечь правый операнд if (S.StackEmpty()) { cerr « "Missing operand.1" « endl; return False; } opnd2 = S.Pop(); // извлечь левый операнд return True; } Все внутренние вычисления управляются методом Compute, который начинается вызовом GetTwoOperands для выборки двух верхних стековых значений. Если GetTwoOperands возвращает False, мы имеем неправильные операнды и метод Compute очищает стек калькулятора. Иначе Compute выполняет операцию, указанную символьным параметром ор ('+', '-', '*', '/\ ,А') и помещает результат в стек. При попытке деления на 0 печатается сообщение об ошибке, и стек калькулятора очищается. Для возведения в степень используется функция double pow(double х, double у); которая вычисляет ху. Она определяется в C++ библиотеке <math.h>. // выполнение операции void Calculator::Compute(char op) { Boolean result; double operandi, operand2; // извлечь два операнда и получить код завершения result = GetTwoOperands(operandi, operand2);
// при успешном завершении, выполнить оператор // и поместить результат в стек // иначе, очистить стек, проверять деление на 0. if (result » True) switch(op) { case '+': S.Push(operand2+operandl); break; case '-': S.Push(operand2-operandl); break/ case '*': S.Push(operand2*operandl); break; case '/': if (operandi »* 0.0) { cerr « "Деление на ноль!" « endl; S,ClearStack(); } else S.Push(operand2/operandi); break; case 'Л': S.Push(pow(operand2,operandi)); break; } else S.ClearStackO; // ошибка! очистить калькулятор } Основное действие калькулятора реализуется открытым методом Run, который выполняет оценку постфиксного выражения. Главный цикл в методе Run читает символы из потока ввода и завершается, когда считывается символ "=". Символы пробела игнорируются. Если символ является оператором (Ч-\ *-'» **\ 7*> '"')» соответствующая операция выполняется вызовом метода Compute. Если символ не является оператором, Run полагает, что проверяется первый символ операнда, поскольку поток должен содержать только операторы и операнды. Run помещает символ обратно в поток ввода, чтобы он мог быть последовательно считан как часть операнда с плавающей точкой. // считывать и оценивать постфиксное выражение // при вводе '=' остановиться void Calculator::Run(void) { char с; double newoperand; while(cin » с, с !~ '■') // читать до символа '«' (Выход) { switch(с) { case '+': // определение нужного оператора case '-': case '*': case '/': case 'Л': Compute(с); // имеется оператор; вычислить его break; default: //не оператор, возможно, операнд; вернуть символ cin.putback(c);
// читать операнд и передавать его в стек cin » newoperand; Enter(newoperand); break; } ) // ответ, сохраняемый в вершине стека печатать с использованием Peek if (!S.StackEmpty()) cout « S.Peek() « endl; } // очистить стек операндов void Calculator::Clear(void) { S.ClearStackO ; } Программа 5.З. Постфиксный калькулятор Объект CALC — это калькулятор. Первый запуск вычисляет длину гипотенузы прямоугольного треугольника со сторонами 6, 8 и 10. Два других запуска иллюстрируют обработку ошибок. #include "calc.h" void main (void) { Calculator CALC; CALC.RunO; } /* Оапуск 1 программы 5.3> 88*66* + .5 Л = 10 Оапуск #2 программ*! 5.3> 3 4 + * Missing operand! 3 4 + 8* = 56 Оапуск 3 программы 5.3> 10/- Деление на 0! */ 5.4. Очереди Очередь (queue) — это структура данных, которая сохраняет элементы в списке и обеспечивает доступ к данным только в двух концах списка (рис. 5.4). Элемент вставляется в конец списка и удаляется из начала списка. Приложения используют очередь для хранения элементов в порядке их поступления.
1-й 2-й 3-й 4-й |ПоследЛ Начало Рис.5.4. Очередь А начало конец А В начало конец А в с начало конец В с начало конец С Конец Добавление А Добавление В Добавление С Удаление А Удаление В начало конец Рис.5.5. Операции очереди Элементы удаляются из очереди в том же порядке, в котором они сохраняются и, следовательно, очередь обеспечивает FIFO (first-in/first-out) или FCFS- упорядочение (first-come/first-served). Обслуживание клиентов в очереди и буферизация задач принтера в системе входных и выходных потоков принтера — это классические примеры очередей. Очередь включает список и определенные ссылки на начальную и конечную позиции (рис. 5.5). Эти позиции используются для вставки и удаления элементов очереди. Подобно стеку, очередь сохраняет элементы параметризованного типа DataType. Подобно стеку, абстрактная очередь не ограничивает количество элементов ввода. Однако, если для реализации списка используется массив, может возникнуть условие полной очереди. ADT Queue данные Список элементов front: позиция первого элемента в очереди rear: позиция последнего элемента в очереди count: число элементов в очереди в любое данное время Операции Конструктор Начальные значения: Нет Процесс: Инициализация начала и конца очереди.
QLength Вход: Нет Предусловия: Нет Процесс: Определение количества элементов в очереди Выход: Возвращать количество элементов в очереди. Постусловия: Нет QEmpty Вход: Нет Предусловия: Нет Процесс: Проверка: пуста ли очередь. Выход: Возвращать 1 (True), если очередь пуста и 0 (False) иначе. Заметьте, что это условие эквивалентно проверке, равна ли QLength 0. Постусловия: Нет QDelete Вход: Нет Предусловия: Очередь не пуста. Процесс: Удаление элемента из начала очереди. Выход: Взвращать элемент, удаляемый из очереди. Постусловия: Элемент удаляется из очереди. Qlnsert Вход: Элемент для сохранения в очереди. Предусловия: Нет Процесс: Запись элемента в конец очереди. Выход: Нет Постусловия: Новый элемент добавляется в очередь QFront Вход: Нет Предусловия: Очередь не пуста. Процесс: Выборка значения элемента в начале очереди. Выход: Возвращать значение элемента в начале очереди. Постусловия: Нет ClearQueue Вход: Нет Предусловия: Нет Процесс: Удаление всех элементов из очереди и восстановление начальных условий. Выход: Нет Постусловия: Очередь пуста. Конец ADT Queue Пример 5.4 На рис. 5.6 показаны изменения в очереди из четырех элементов во время последовательности операций. В каждом случае приводится значение флажка QEmpty. Очереди широко используются в компьютерном моделировании, таком как моделирование очереди клиентов в банке. Многопользовательские операционные системы поддерживают очереди программ, ожидающих выполнения, и заданий, ожидающих печати.
Операция Список очереди Признак пустого списка Qlnsert (A) Qlnsert (В) QDelete О front rear А front rear А В front В rear TRUE FALSE FALSE FALSE front rear Рис.5.6. Изменения в очереди из четырех элементов во время операций 5.5. Класс Queue Класс Queue реализует ADT, используя массив для сохранения списка элементов и определяя переменные, которые поддерживают позиции front и rear. Так как для реализации списка используется массив, класс содержит метод Qfull для проверки, заполнен ли массив. Этот метод будет устранен, в главе 9, где представлена реализация очереди со связанным списком. Спецификация класса Queue ОБЪЯВЛЕНИЕ #include <iostream.h> #include <stdlib.h> // максимальный размер списка очереди const int MaxQSize = 50; class Queue { private: // массив и параметры очереди int front, rear, count; DataType qlist[MaxQSize]; public: // конструктор Queue (void); // initialize integer data members // операции модификации очереди void Qlnsert(const DataTypes item); DataType QDelete(void); void ClearQueue(void); // операции доступа DataType QFront(void) const;
// методы тестирования очереди int QLength(void) const; int QEmpty(void) const; int QFull(void) const; }; ОПИСАНИЕ Параметризованный тип DataType позволяет очереди управлять различными типами данных. Класс Queue содержит список (qlist), максимальный размер которого определяется константой MaxQSize. Данное-член count содержит число элементов в очереди. Это значение также определяет, является ли очередь полной или пустой. Qlnsert принимает элемент item типа DataType и вставляет его в конец очереди, a QDelete удаляет и возвращает элемент в начале очереди. Метод QFront возвращает значение элемента в начале очереди. Очередь следует тестировать при помощи метода QEmpty перед удалением элемента и метода QFull перед вставкой новых данных для проверки, пуста ли очередь или заполнена. Если предусловия для Qlnsert или QDelete нарушаются, программа печатает сообщение об ошибке и завершается. Объявление очереди и реализация содержатся в файле aqueue.h. ПРИМЕР typedef int DataType; ♦include aqueue.h Queue Q; //объявляем очередь Q.Qlnsert(30); //вставляем 30 в очередь Q.Qlnsert(70); //вставляем 70 в очередь cout «Q.QLength()«endl; //печатает 2 cout «Q.QFront () «endl; //печатает 30 if (!Q.QEmpty( )) cout «Q.QDelete( ); //печатает значение 30 cout «Q.QFront ( ) «endl; //печатает 70 Q/ClearQueue( ); //очистка очереди Реализация класса Queue Начало очереди определяется первым клиентом в очереди. Конец очереди — это место непосредственно за последним элементом очереди. Когда очередь полна, клиенты должны идти к другой расчетной очереди. На рис. 5.7 показаны изменения в очереди и некоторые проблемы, которые влияют на реализацию. Предположим, очередь ограничивается четырьмя клиентами. Вид 2 показывает, что после того, как клиента А обслужили, клиенты В и С перемещаются вперед. Вид 3: клиента В обслужили и С перемещается вперед. Вид 4: клиенты D, Е и F встают в очередь, заполняя ее, а клиент G должен встать в другую очередь. Эти виды отражают поведение клиентов в расчетной очереди. После того, как одного клиента обслужили, другие в очереди перемещаются вперед. В терминологии списка, элементы данных смещаются вперед на одну позицию каждый раз, когда какой-либо элемент покидает очередь. Эта модель не обеспечивает эффективную компьютерную реализацию. Предположим, очередь содержит 1000 элементов. Когда один элемент удаляется из начала, 999 элементов должны переместиться влево. Наша реализация очереди вводит круговую модель. Вместо сдвига элементов влево, когда один элемент удаляется, элементы очереди организованы логически в окружность. Переменная front всегда является местоположением первого элемента очереди, и она продвигается вправо по кругу по мере вы-
Вид№1 Ввести клиентов А, В, С Вид №2 Обслужить клиента А Вид №3 Обслужить клиента В Вид №4 Добавить клиентов D, E, F А front В front С front С В С rear D С rear Е rear F front G rear Рис. 5.7. Очередь из четырех элементов полнения удалений. Переменная rear является местоположением, где происходит следующая вставка. После вставки rear перемещается по кругу вправо. Переменная count поддерживает запись количества элементов в очереди, и если счетчик count равен значению MaxQSize, очередь заполнена. На рис. 5.8 показана круговая модель очереди. размер = 4 count » 3 размер = 4 count ■= 2 размер = 4 count » 3 rear front rear rear front front Очередь заполнена размер - 4 count = 4 Удалить А размер • 4 count ■ 3 Вставить D rear front front rear Вставить Е Удалить В Рис, 5.8. Круговая модель очереди
Реализуем круговое движение, используя операцию остатка от деления: Перемещение конца очереди вперед: rear = (rear+l)%MaxQSize; Перемещение начала вперед: front - (front+l)%MaxQSize; Пример 5.5 Используем целый массив qlist (размер = 4) из четырех элементов для реализации круговой очереди. Первоначально count = 0, и индексы front и rear имеют значение 0. На рис. 5.9 показана последовательность вставок и удалений круговой очереди. count * 0 3 Вставить 3 count «= 1 3 Вставить 5 count = 2 5 front = rear « *0 0 Вставить б count - 3 3 5 6 front = 0 rear = 3 Удалить 5 count = 2 6 2 rear * 0 front = 2 Удалить 2 count = 1 8 front = 0 rear = 1 Удалить 3 count * 2 front = 0 rear = 2 Вставить 2 count = 3 5 6 5 6 2 front - 1 rear = 3 rear = 0 front = 1 8 Вставить 8 count = 3 6 2 8 Удалить 6 count = 2 2 rear = 1 front = 2 rear = 1 front = 3 Удалить 8 count = 0 front = 1 rear = 1 front = 0 rear = 1 Рис 5.9. Последовательность вставок и удалений круговой очереди Конструктор Queue. Конструктор инициализирует элементы данных front, rear и count нулевыми значениями. Это задает пустую очередь // инициализация данных-членов: front, rear, count Queue::Queue (void) : front(0), rear(0), count(0) О Операции класса Queue. Для работы с очередью предоставляется ограниченный набор операций, которые добавляют новый (метод Qlnsert) или удаляют (метод Qdelete) элемент. Класс имеет также метод QFront, который позволяет делать выборку первого элемента очереди. Для некоторых приложений эта операция позволяет определять, должен ли элемент удаляться из списка. В этом разделе описываются операции обновления очереди, вставляющие и удаляющие элементы списка. Другие методы имеют модели в стековом классе и их можно найти в программном приложении в файле aqueue.h.
Перед началом процесса вставки индекс rear указывает на следующую позицию в списке. Новый элемент помещается в это место, и переменная count увеличивается на 1. qlist[rear] - item; count++; После помещения элемента в список индекс rear должен быть обновлен для указания на следующую позицию [Рис. 5.10 (А)]. Так как мы используем круговую модель, вставка может появиться в конце массива (qlist[size-l]) с перемещением rear к началу списка [рис. 5.10(B)]. Вычисление выполняется с помощью оператора остатка (%). rear - (rear+1) % MaxQSize; Qlneert // вставить item в очередь void Queue::Qlnsert (const DataType& item) { // закончить программу, если очередь заполнена if (count == MaxQSize) { cerr « "Переполнение очереди!" « endl; exit(1); } // увеличить count, присвоить значение item элементу массива // изменить значение rear count++; qlist[rear] = item; rear = (rear+1) % MaxQSize; } (A) До Qlnsert: count = 2 После Qlnsert: count = 3 ■ t front ■ t rear ■ t front ■ элемент t rear (B) До Qlnsert: count = 2 После Qlnsert: count = 3 ■ t front ■ t rear t rear ■ t front ж элемент Рис 5.10. Метод Qinsert Операция QDelete удаляет элемент из начала очереди, позиции, на которую ссылается индекс front. Мы начинаем процесс удаления, копируя значение во временную переменную и уменьшая счетчик очереди. item = qlist[front]; count—; В круговой модели необходимо перенести front в позицию следующего элемента в списке, используя оператор остатка от деления (%) (рис. 5.11). front = (front + 1) % MaxQSize;
Значение из временной позиции становится возвращаемым значецием. QDelete // удалить элемент из начала очереди // и возвратить его значение DataType Queue::QDelete(void) { DataType temp; // если очередь пуста, закончить программу) { cerr « "Удаление из пустой очереди!" « endl; exit(l); ) // записать значение в начало очереди temp » qlist[front]; // уменьшить count на единицу // продвинуть начало очередии возвратить прежнее значение //из начала count—; front - (front+1) % MaxQSize; return temp; (A) До QDelete: count = 3 После QDelete: count =2 (B) ■ элемент ■ ■ T T front rear До QDelete: count = 3 ■ элемент A A ■ T T т rear front front ■ ■ т front После QDelete: count =2 ■ T rear t rear элемент возвратить в элемент озврати ть Рис. 5.11. Метод QDelete Операции Qlnsert, QDelete и QFront имеют эффективность 0(1)9 поскольку каждый метод имеет прямой доступ к элементу либо в начале, либо в конце списка. Программа 5.4. Партнеры по танцу Танцы организуются в пятницу вечером. По мере того, как мужчины и женщины входят в танцевальный зал, мужчины выстраиваются в один ряд, а женщины — в другой. Когда танец начинается, партнеры выбираются по одному из начала каждого ряда. Если в этих рядах неодинаковое количество людей, лишний человек должен ждать следующего танца.
Данная программа получает имена мужчин и женщин, читая файл dance.dat. Каждый элемент данных файла имеет формат Sex Name где Sex — это один символ F или М. Все записи считываются из файла, и организуются очереди. Партнеры образуются удалением их из каждой очереди. Этот процесс останавливается, когда какая-либо очередь становится пустой. Если есть ожидающие люди, программа указывает, сколько их и печатает имя первого человека, который будет танцевать в следующем танце. iinclude <iostream.h> ♦include <iomanip.h> iinclude <fstream.h> #pragma hdrstop // record that declares a dancer struct Person { char name[203; char sex; // ' F' (женщина) ; 'М' (мужчина) }; // очередь содержит список объектов типа Person typedef Person DataType; iinclude "aqueue.h" void main(void) { // две очереди для разделения на партнеров по танцу Queue maleDancers, femaleDancers; Person p; char blankseparator; // входной файл для танцоров ifstream fin; // открыть файл с проверкой на его существование fin.open("dance.dat"); if (!fin) { cerr « "He возможно открыть файл!" « endl; exit (1); } // считать входную строку, которая включает // пол, имя и возраст while(fin.get(p.sex)) // цикл до конца файла { fin.get(blankseparator); fin.getline(p.name,20,' \n'); // вставить в соответствующую очередь if (p.sex »- ' F') femaleDancers.Qlnsert(p); else maleDancers.Qlnsert(p); } // установить пару танцоров, получением партнеров
//из двух очередей // закончить, когда одна из очередей окажется пустой cout « "Партнеры по танцу: " « endl « endl; while (!femaleDancers.QEmpty() && !maleDancers.QEmptyО) { p * femaleDancers.QDeleteO; cout « p.name « " "; // сообщить имя женщины p « maleDancers.QDelete(); cout « p.name « endl; // сообщить имя мужчины } cout « endl; // если в какой-либо очереди кто-либо остался, // сообщить имя первого (первой) из них if (!femaleDancers.QEmpty()) { cout « "Следующего танца ожидают " « femaleDancers.QLength() « " дамы" « endl; cout « femaleDancers.QFront().name « " первой получит партнера." « endl; } else if (!maleDancers.QEmpty()) { cout « "Следующего танца ожидают " « maleDancers.QLength() << " кавалера." « endl; cout « maleDancers.QFront().name « " первым получит патнера." « endl; } } /* <Файл "dance.dat"> M George Thompson F Jane Andrews F Sandra Williams M Bill Brooks M Bob Carlson F Shirley Granley F Louise Sanderson M Dave Evans M Harold Brown F Roberta Edwards M Dan Gromley M John Gaston <Выполнение программы 5.4> Партнеры по танцу: Jane Andrews George Thompson Sandra Williams Bill Brooks Shirley Granley Bob Carlson Louise Sanderson Dave Evans Roberta Edwards Harold Brown Следующего танца ожидают 2 кавалера. Dan Gromley первым получит патнера. */
Применение: использование очереди для сортировки данных. На заре компьютеризации для упорядочения стопки перфокарт использовался механический сортировщик. Следующая программа моделирует действие этого сортировщика. Для объяснения процесса предположим, что перфокарты содержат числа из двух цифр в диапазоне 00-99, и сортировщик имеет десять бункеров с номерами 0-9. При сортировке выполняются два прохода для обработки сначала по позициям единиц, а затем — десятков. Каждая перфокарта попадает в соответствующий бункер. Такая сортировка называется поразрядной сортировкой (radix sort) и может быть расширена до сортировки чисел любого размера. Начальный список: 91 46 85 15 92 35 31 22 В проходе 1 перфокарты распределяются по позициям единиц. Перфокарты выбираются из бункеров в порядке от 0 до 9. Список после прохода 1: 91 31 92 22 85 15 35 46 В проходе 2 перфокарты распределяются по позициям десятков. Перфокарты выбираются из бункеров в порядке от 0 до 9. Список после прохода 2: 15 22 31 35 46 85 91 92 После двух проходов список становится упорядоченным. Интуитивно проход 1 приводит к тому, что все перфокарты с меньшими единичными цифрами предшествуют перфокартам с большими единичными цифрами. Например, все числа, заканчивающиеся на 1, предшествуют числам, заканчивающимся на 2 и так далее. Для прохода 2 предположим, что две перфокарты имеют значение 3s и 3t при s<t. Они попадают в бункер 3 в таком порядке, что за 3s следует 3t (помните, что все перфокарты, заканчивающиеся на s предшествуют перфокартам, заканчивающимся на t после прохода 1). Так как каждый бункер является очередью, перфокарты покидают бункер 3 в порядке FIFO. Если после прохода 2 перфокарты снова собрать, они будут упорядочены.
Программа 5.5. Поразрядная сортировка Данная программа выполняет поразрядную сортировку чисел из двух цифр. Последовательность из 50 случайных чисел сохраняется в списке, представленном массивом L. int L(50); //содержит 50 случайных целых Массив очередей моделирует 10 сортировочных бункеров; Queue digitQueue[10]; // 10 очередей целых Функции Distribute передается массив чисел, массив очередей digitqueue и определенный пользователем дескриптор ones или tens для указания того, выполняется ли сортировка по позициям единиц (проход 1) или десятков (проход 2). В проходе 1 используется выражение L[i]%10 для доступа к цифре единиц, а затем — полученное значение для передачи L[i] соответствующей очереди. digitQueue[L[i] % 10].Qlnsert(L[i]) В проходе 2 используется выражение L[i]/10 для доступа к цифре десятков, а затем — полученное значение для передачи L[i] соответствующей очереди. digitQueue[L[i] / 10].Qlnsert(L[i]) Функция Collect просматривает массив очередей digitQueue в порядке цифр 0 — 9 и извлекает все элементы из каждой очереди в список L. while (!digitQueue[digit].QEmpty()) L[i++] = digitQueue[digit].QDelete(); Функция Print записывает числа в списке. Числа печатаются по 10 в строке с каждым числом, использующим пять позиций печати. #include <iostream.h> #pragma hdrstop #include "random.h" // объявление генератора случайных чисел typedef int DataType; #include "aqueue.h" enum DigitKind {ones,tens}; void Distribute(int L[],Queue digitQueue[],int n, DigitKind kind) { int i; // цикл для массива из п элементов for {i =0; i < n; i++) if (kind == ones) // вычислить цифру единиц и использовать ее как номер очереди digitQueue[L[i] % 10].Qlnsert(L[i]); else // вычислить цифру десятков и использовать ее как номер очереди digitQueue[L[i] / 10].Qlnsert(L[i]);
} // собрать элементы из очередей в массив void Collect(Queue digitQueue[], int L[]) { int i = 0, digit = 0; // сканировать массив очередей, // используя индексы 0, 1, 2, и т.д. for (digit = 0; digit < 10; digit++) // собирать элементы, пока очередь не опустеет, // копировать элементы снова в массив while (!digitQueue[digit].QEmpty()) L[i++] = digitQueue[digit].QDelete(); } // сканировать массив из п элементов и печатать каждый элемент. // в каждой строке печатать 10 элементов void PrintArray(int L[],int n) { int i = 0; while(i < n) { cout.width(5) ; cout << L[i]; if (++i % 10 == 0) cout « endl; } cout « endl; } void main(void) { // 10 десять очередей для моделирования бункеров Queue digitQueue[10]; // массив из 50 целых int L[50]; int i = 0; RandomNumber rnd; // инициализировать массив случайными числами // в диапазоне 0-99 for (i = 0; i < 50; i++) L[i] =* rnd.Random(100) ; // распределить в 10 бункеров по цифрам единиц; // собрать и распечатать Distribute(L, digitQueue, 50, ones); Collect(digitQueue, L); PrintArray(L,50); // распределить в 10 бункеров по цифрам десятков; // собрать и распечатать отсортированный массив Distribute(L,digitQueue, 50, tens); Collect(digitQueue,L); PrintArray(L,50); } /* Оапуск программы 5.5>
40 62 3 25 88 3 24 52 69 83 70 72 73 16 98 7 25 54 69 84 20 82 33 46 68 11 27 55 70 85 51 82 54 86 69 12 29 59 72 86 11 62 24 36 79 15 33 62 72 88 81 72 84 67 89 16 36 62 73 89 21 52 55 17 29 17 40 63 79 92 12 83 15 27 69 20 46 65 81 97 52 63 65 7 99 21 51 67 82 98 92 23 85 97 59 23 52 68 82 99 */ Каждый проход выполняет О(п) операций, состоящих из деления, вставки в очередь и удаления из нее. Так как выполняется два прохода, порядок алгоритма поразрядной сортировки чисел из двух цифр составляет 0(2п) и также является линейным. Поразрядная сортировка может быть расширена до сортировки п чисел, каждое из которых имеет т цифр. В этом случае сложность составляет О(тп), так как поразрядная сортировка выполняет т проходов, каждый включающий О(п) операций. Этот алгоритм превосходит алгоритмы сортировки, имеющие порядок 0(п \og2n), такие как heapsort и quicksort, которые описываются в главах 13 и 14. Однако, поразрядная сортировка имеет меньшую эффективность по использованию памяти, чем эти in-place-сортиров- ки. Алгоритмы in-place-сортировок сортируют данные в оригинальном массиве и не используют временную память. Поразрядная сортировка требует использования 10 очередей. Каждая очередь имеет свою локальную память для front, rear, queue count и массива. Кроме того, поразрядная сортировка менее эффективна, если числа содержат много цифр, так как при этом возрастает произведение тп. 5.6. Очереди приоритетов Как уже описывалось, очередь — это структура данных, которая обеспечивает FIFO-порядок элементов. Очередь удаляет самый старый элемент из списка. Приложения часто требуют модифицированной версии памяти для очереди, в которой из списка удаляется элемент с высшим приоритетом. Эта структура, называемая очередью приоритетов (priority queue), имеет операции PQInsert и PQDelete. PQInsert просто вставляет элемент данных в список, а операция PQDelete удаляет из списка наиболее важный элемент (с высшим приоритетом), оцениваемый по некоторому внешнему критерию, который различает элементы в списке. Например, предположим, компания имеет централизованную секретарскую группу для обеспечения выполнения персоналом определенных задач. Политика компании рассматривает задание, выданное президентом компании, как имеющее высший приоритет, за которым следуют задания менеджеров, затем — задания супервайзеров и так далее. Должность человека в компании становится критерием, который оценивает относительную важность задания. Вместо управления заданиями на основе порядка first-come/first-served (очередь), секретарская группа выполняет задания в порядке их важности (очередь приоритетов). Очереди приоритетов находят применение в операционной системе, которая записывает процессы в список и затем выполняет их в порядке приоритетов.
Например, большинство операционных систем присваивают более низкий, чем другим процессам, приоритет выполнению печати. Приоритет 0 часто определяется как высший приоритет, а обычный приоритет имеет большее значение, такое как 20. Например, рассмотрим следующий список задач и их приоритетов: Задача № 1 20 Задача № 2 0 Задача № 3 40 Задача № 4 30 Задача № 5 10 Порядок хранения Задачи выполняются в порядке 2, 5, 1, 4 и 3. Задача № 2 Задача № 5 Задача № 1 Задача № 4 Задача № 3 Порядок выполнения В большинстве приложений элементы в очереди приоритетов являются парой ключ-значение (key-value pair), в которой ключ определяет уровень приоритета. Например, в операционной системе каждая задача имеет дескриптор задачи и уровень приоритета, служащий ключом. Уровень приоритета Дескриптор задачи При удалении элемента из очереди приоритетов в списке могут находиться несколько элементов с одним и тем же уровнем приоритета. В этом случае мы можем потребовать, чтобы эти элементы рассматривались как очередь. В результате элементы с одним и тем же приоритетом обслуживались бы в порядке их поступления. В следующем ADT мы не делаем никаких допущений по поводу порядка элементов с одним и тем же уровнем приоритета. Очередь приоритетов описывает список с операциями для добавления или удаления элементов из списка. Имеется серия операций, которая определяет длину списка и указывает, пуст ли список. ADT PQueue Данные Список элементов. Операции Конструктор Начальные значения: Нет Процесс: Инициализация количества элементов списка нулевым значением PQLength Вход: Нет Предусловия: Нет Процесс: Определение количества элементов в списке. Выход: Возвращать количество элементов в списке. Постусловия: Нет PQEmpty Вход: Нет Предусловия: Нет Процесс:- Проверка/ является ли количество элементов списка равным 0.
Выход: Возвращать l(True), если в списке нет элементов, и 0 (False) — иначе. Постусловия: Нет PQInsert Вход: Элемент для сохранения в списке. Предусловия: Нет Процесс: Сохранение элемента в списке. Это увеличивает длину списка на 1. Выход: Нет Постусловия: Список имеет новые элемент и длину. PQDlelete Вход: Нет Предусловия: Очередь приоритетов не пуста. Процесс: Удаление элемента с высшим приоритетом из списка. это уменьшает длину списка на 1. Выход: Возвращать элемент, удаленный из списка. Постусловия: Элемент удаляется из списка, который теперь имеет на один элемент меньше. ClearPQ Вход: Нет Предусловия: Нет Процесс: Удаление всех элементов из очереди приоритетов и восстановление начальных условий. Выход: Нет Постусловия: Очередь приоритетов пуста. Конец ADT PQueue Класс PQueue В этой книге приводятся различные реализации очереди приоритетов. В каждом случае выделяется объект list для сохранения элементов. Мы используем параметр count и методы доступа к списку для вставки и удаления элементов. В этой главе элементы, сохраняемые в массиве, имеют параметризованный тип DataType. В последующих главах используются упорядоченные списки и динамические области для сохранения элементов в очереди приоритетов. Спецификация класса PQueue ОБЪЯВЛЕНИЕ #include <iostream.h> #include <stdlib.h> // максимальный размер массива очереди приоритетов const int MaxPQSize = 50; class PQueue { private: // массив очереди приоритетов и счетчик int count; DataType pqlist[MaxPQSize]; public: // конструктор
PQueue (void); // операции, модифицирующие очередь приоритетов void PQInsert(const DataType& item); DataType PQDelete(void); void ClearPQ(void)/ // тестирующие методы int PQEmpty(void) const; int PQFull(void) const; int PQLength(void) const; }/ ОПИСАНИЕ Константа MaxPQSize определяет размер массива pqlist. Метод PQInsert просто вставляет элементы в список. В спецификации не делается никаких допущений о том, где элемент помещается в списке. Метод PQDelete удаляет элемент с высшим приоритетом из списка. Мы полагаем, что элемент с высшим приоритетом — это элемент с наименьшим значением. Наименьшее значение определяется с использованием оператора сравнения "<", который должен быть определен для DataType. ПРИМЕР typedef int DataType; PQueue PQ; PQ.PQInsert(20); PQ.PQInsert(10); cout « PQ.PQLengthO « endl; // печать 2 N » PQ.PQDelete(); // извлечь N * 10 Реализация класса Pqueue Операции очереди приоритетов. Подобно обычной очереди, очередь приоритетов имеет операцию для вставки элемента. ADT не делает никаких допущений о том, в какое место списка помещается элемент, оставляя этот вопрос в качестве детали реализации в методе PQInsert. В данном случае мы сначала тестируем, заполнен ли список, и завершаем программу при этом условии. В противном случае, новый элемент вставляется в конец списка, местоположение которого указывается с помощью счетчика (count). До PQInsert: count = 4 После PQInsert: count = 5 20 40 10 30 20 40 10 30 50 Вставить 50 \ pqllst[4] PQInsezt // вставить элемент в очередь приоритетов void PQueue::PQInsert (const DataTypeb item) { // если уже све элементы массива pqlist использованы, // закончить завершить программу if (count « MaxPQSize) { cerr « "Переполнение очереди приоритетов!" << endl; exit(l)/
} // поместить элемент в конец списка //и увеличить count на единицу pqlist[count] = item; count++; ) Метод PQDelete удаляет из списка элемент с высшим приоритетом. Это условие не подразумевает, что мы выбираем первый элемент, когда имеются два или более элементов, как и не подразумевает, что элементы сохраняют какой-либо порядок во время процесса удаления. Это все — детали реализации. В данном случае мы сначала определяем, является ли список пустым, и завершаем программу, если условие является равным True. В противном случае, мы ищем минимальное значение и удаляем его из списка, уменьшая длину очереди (count) и заменяя этот элемент последним элементом в списке. Индекс последнего элемента становится новым значением count. В следующем примере минимальное значение (10) имеет элемент с индексом 2. Метод удаляет этот элемент, уменьшая длину списка на 1 и заменяя его последним элементом в списке (pqlist[count]). Затем удаляется 15, который находится в конце списка. count=5 Минимальный элемент с индексом 2 count=4 Минимальный элемент заменить последним Удалить 10 20 40 10 Т pqlistp] 15 50 20 40 50 15 Вставить 50 count=4 Минимальный элемент с индексом 3 count=3 Минимальный элемент заменить последним Удалить 15 20 40 50 Т pqlistP] 15 20 40 50 15 PQDelete // удаляет элемент из очереди приоритетов /У и возвращает его значение DataType PQueue::PQDelete(void) { DataType min; int i, minindex =0; if (count > 0) { // найти минимальное значение и его индекс в массиве pqlist min = pqlist[0]/ // предполагаем, pqlist[0] - это минимум // просмотреть остальные элементы // изменяя минимум и его индекс for (i = 1; i < count; i++) if (pqlist[i] < min) { // новый минимум в элементе pqlist[i]. новый индекс - i min = pqlist[i];
minindex = i; } // переместить хвостовой элемент на место минимального //и уменьшить на единицу count pqlist[minindex] = pqlist[count-1]; count--; } // массив qlist пуст, завершить программу else { cerr << "Удаление из пустой очереди приоритетов!" << endl; exit(l); } // возвратить минимальное значение return min; } Операция PQInsert имеет время вычисления (порядок) 0(1), так как она непосредственно добавляет элемент в конец списка. С другой стороны, операция PQDelete требует начального просмотра списка для определения минимального значения и его индекса. Эта операция имеет время вычисления 0(п)у где п — это текущая длина очереди приоритетов. Данное-член count содержит количество элементов в списке. Это значение используется в реализации методов PQLength, PQEmpty и PQFull. Код этих методов находится в программном приложении в файле apqueue.h. Приложение: службы поддержки компании Сотрудники компании определяются по категориям: менеджер, супервайзер и рабочий. Создав тип enum с различными категориями, мы имеем естественное упорядочение, дающее каждой уровень приоритета при заявке на выполнение работ. //уровень приоритета сотрудника (менеджер = 0, и т.д.) enunv Staff{Manager, Supervisor, Worker}; // Менеджер =0, и т. д. Работа службы поддержки компании выполняется общей секретарской группой. Каждый сотрудник Может сделать заказ на выполнение задания, заполнив форму, которая включает информацию о категории служащего, подающего заявку на выполнение работы, ID-номер задания, и указывая время, за которое будет выполнена работа. Эта информация сохраняется в записи JobRequest. Заявки на выполнение работ вводятся в очередь приоритетов с приоритетом, определяемым по категории сотрудника. Это упорядочение используется, чтобы определить операцию < для объектов типа JobRequest. // структура, определяющая запрос struct JobRequest { Staff staffPerson; int jobid; int joTime; }; // перегрузка оператора < для сравнения // двух объектов JobRequest int operator < (const JobRequest& a, const JobRequest& b) { return a.staffPerson < b.staffPerson; }
Файл job.dat содержит список заявок на выполнение заданий, которые загружаются в очередь приоритетов. Приложение подразумевает, что заявки помещены заранее и ожидают выполнения. Элементы извлекаются из очереди приоритетов и выполняются. Массив jobServicesUse содержит общее количество времени, потраченное на обслуживание каждого из различных типов сотрудников: //время, потраченное на работу для каждой категории сотрудников int jobServicesUse[3] = {0, 0, 0}/ Функции печати PrintJoblnfo и PrintSupportSummary выдают информацию о каждом задании и об общем количестве минут, потраченных на обслуживание каждой категории служащих в компании: // печать одной записи структуры JobRequest viod PrintJoblnfo(JobRequest PR) { switch (PR.staffPerson) { case Manager: cout « Manager ; break; case Supervisor: cout « Supervisor ; break; case Worker: cout « Worker ; break; } #include <iomanip.h> // печать общего времени работы, // выделенного каждой категории служащих void PrintJobSummery(int jobServiceUse[)) { cout « ХпВремя обслуживания по категориями; cout « Manager « setw(3) « cout « jobServicesUse[0] « endl; cout « Supervisor « setw(3) « cout « jobServicesUse[1] << endl; cout « Worker « setw(3) « cout « jobServicesUse[2] << endl; Программа 5.6. Выполнение заданий Каждая заявка на выполнение работы сохраняется как запись в файле job.dat. Эта запись задает категорию сотрудника (*М\ 43 \ 'W') и ID-номер задания, и время для выполнения. Записи читаются, пока не достигается конец файла, и каждая запись вставляется в очередь приоритетов jobPool. На выходе каждое задание извлекается из очереди приоритетов, и его информация печатается функцией PrintJoblnfo. Программа завершается печатью резюме по выполненным заданиям вызовом функции PrintJob- Summary. Структура JobRequest и функции работы с ней находятся в файле job.h. #include <iostream.h> #include <fstream.h> #pragma hdrstop #include "job.h"
// элементы очереди приоритетов имеют тип JobRequest typedef JobRequest DataType; #include "apqueue.h" // включить класс PQueue void main() { // обрабатывает до 25 заданий PQueue jobPool; // требуемые задания читаются из потока // с дескриптором fin ifstream fin; // время облуживания каждой категории служащих int jobServicesUse[3] = {0, 0, 0); JobRequest PR; char ch; // открыть файл job.dat для ввода // при ошибке завершить работу программы fin.open("job.dat", ios::in | ios:inocreate); if (!fin) { cerr « "Невозможно открыть файл job.dat" « endl; exit(l); } // читать файл. // вставлять каждое задание в очередь приоритетов jobPool. // каждая строка начинается с символа, характеризующего // служащего while (fin » ch) { // полю staffPerson присвоить категорию служащего switch(ch) { case 'M' : PR.staffPerson = Manager; break; case 'S': PR.staffPerson = Supervisor; break; case ' W : PR.staffPerson *= Worker; break; default: break; } // читать идентификатор задания //и поле jobTime (время на выполнение задания) fin » PR.jobid; fin >> PR.jobTime; // вставить задание в очередь приоритетов jobPool.PQInsert(PR); } // удалять задания из очереди приоритетов //и печатать эту информацию cout « " Категория Номер Время\п\п"; while (!jobPool.PQEmpty()) { PR = jobPool.PQDeleteO ; PrintJoblnfo(PR); // накапливать время для выполнения заданий
// каждой категории служащих jobServicesUsefint(PR.staffPerson)] += PR.jobTime; } PrintJobSummary(jobServicesUse); } /* <Входной файл job.dat> M 300 20 W 301 30 M 302 40 S 303 10 S 304 40 M 305 70 W 306 20 W 307 20 M 308 60 S 30$ 30 Категория Manager Manager Manager Manager Supervisor Supervisor Supervisor Worker Worker Worker Номер 300 302 308 305 309 303 304 306 307 301 Время 20 40 60 70 30 10 40 20 20 30 Время обслуживания по категориям Manager 190 Supervisor 80 Worker 70 */ 5.7. Практическое применение: управляемое событиями моделирование Задачей моделирования является создание модели реальной ситуации для лучшего ее понимания. Моделирование позволяет вводить различные условия и наблюдать их результаты. Например, имитатор полета побуждает летчика реагировать на неблагоприятные условия и измеряет скорость и соответствие реакции. При изучении рынка сбыта моделирование часто используется для измерения текущей деятельности или оценки расширения бизнеса. В этом разделе рассмотрены модели прихода и ухода клиентов банка по мере того, как они проходят через одну из п>2 очередей к кассиру. В заключении оценивается эффективность обслуживания вычислением среднего времени ожидания каждого клиента и времени занятости каждого кассира в процен-
тах. Используя моделирование, мы можем создать различные модели обслуживания и оценить их с точки зрения стоимости и эффективности. Такой подход выполняет управляемое событиями моделирование (event-driven simulation), которое определяет объекты для представления банковской деятельности. Мы используем вероятностные значения, описывающие различные Ьжидаемые частоты прихода клиентов и различное ожидаемое время обслуживания клиента кассиром. Моделирование позволяет использовать генератор случайных чисел для отражения прихода и ухода клиентов в течение рабочего дня в банке. Моделирование позволяет также изменять параметры и таким образом измерять относительное влияние на обслуживание, если мы изменяем поведение клиента или кассира. Например, предположим, что отделение маркетинга считает, что поощрительные подарки увеличат приход клиентов на 20%. Исследование с использованием моделирования повысит ожидаемые темпы прихода клиентов и эффективность работы кассира. В некоторый момент банку понадобятся дополнительные кассиры для поддержания приемлемого обслуживания, а дополнительный расход может свести к нулю прибыль от поощрительной кампании. Это моделирование снабжает банковского менеджера параметрами для оценки обслуживания клиентов. Если среднее время ожидания будет слишком долгим, менеджер может использовать еще одного кассира. Это моделирование можно повторять снова и снова, просто изменяя условия. Разработка приложения По мере выполнения приложение отслеживает приход и уход отдельных клиентов. Например, предположим, 50-й клиент приходит в банк в 2:30 (приход) для получения вида услуг, требующего 12 минут времени кассира. Данное моделирование подразумевает, что каждый кассир наглядно предоставляет график своей работы, так что новый клиент может определить, кто из кассиров его будет обслуживать и когда может начаться обслуживание. [Занят до 2:40| БАНК Свободен {Кассир 11 | Кассир 21 time(departure) = time(arrival) + servicetime time(departure) = 2:30 + 0:12 = 2:42
Событие Время Приход Уход Время прихода Время ухода Обслуживание Рассмотрим различные обстоятельства для 50-го клиента. Предположим, что два кассира заняты, и клиент должен занять место в очереди и ожидать обслуживания. Если Кассир 1 освобождается в 2:40, а Кассир 2 будет свободен в 2:33, новый клиент выбирает Кассира 2. После 3 минут ожидания и 12 минут совершения операции клиент уходит в 2:45, что близко ко времени, когда освобождается Кассир 2. time(departure) = time(arrival) + waittime + servicetime time(departure) » 2:30 + 0:03 +0:12 = 2:45 Приход Уход Событие Время Время прихода Время ухода ожидание обслуживание В нашем моделировании клиент выбирает кассира, который затем обновляет на окне вывеску следующего свободного времени для обслуживания. Занят до 2:40 БАНК |3анят до 2:33] [Занят до 2:40| БАНК ранят до 2:45| 1 Кассир 1| (Кассир 2| ) ( 1 Кассир Ц | Кассир 2] До прихода клиента После прихода клиента Ключевыми компонентами в этом моделировании обслуживания в банке являются события, включающие как приход, так и уход клиента, а реальное время рассматривается как последовательность случайных событий, которые отражают приход, обслуживание и уход клиента. Событие объявляется как класс C++ с закрытыми данными-членами, которые определяют и клиента, и кассира, а также поддерживают информацию о времени появления события, типе события (приход или уход), продолжительности обслуживания клиента
и количестве времени, которое клиент вынужден провести в очереди, ожидая обслуживания. time etype customerlD tellerlD waittime servicetime Когда клиент входит в банк, мы имеем событие прихода (arrival event). Например, если клиент 50 приходит в 2:30, соответствующей записью будет: Данные события прихода 2:30 Приход 50 — — — time etype customerlD tellerlD waittime servicetime Поля tellerlD, waittime и servicetime не используются для события прихода. Эти поля определяются после того, как генерируется событие ухода. После того, как клиент проверит, свободен ли кассир, мы можем определить событие ухода (departure), которое описывает, когда клиент покинет банк. Этот объект может использоваться для преобразования истории клиента в банковской системе. Например, далее следует событие ухода для клиента, который приходит в 2:30, ожидает 3 минуты Кассира 2 и уходит после 12-минутного обслуживания: Данные события ухода 2:45 Уход 50 2 3 12 time etype customerlD tellerlD waittime servicetime Поля данных объекта описывают всю соответствующую информацию, которая относится к потоку клиентов в системе. Методы класса обеспечивают доступ к полям данных, используемым для сбора информации об общей эффективности обслуживания. Спецификация класса Event ОБЪЯВЛЕНИЕ #include <iostream.h> ♦include random.h enum EventType {arrival, departure}; class Event { private: // данные-члены int time; // время события EventType etype; // тип события int customerlD; // номер клиента int tellerlD; // номер кассира int waittime; // время ожидания int servicetime; // время обслуживания public:
Event(void); Event(int t, EventType et, int en, int tn, int wt, int st); int GetTime(void); EventType GetEventType(void); int GetCustomerlD(void) const; int GetTellerlD(void) const; int GetWaitTime(void)const; int GetServiceTime(void)const; }; ОПИСАНИЕ Конструктор умолчания позволяет объявлять объект Event, чтобы позже можно было инициализировать (присваиванием) его данные-члены. Второй конструктор позволяет задать каждый параметр при объявлении события. Остальные Методы возвращают значения данных-членов. ПРИМЕР Время задается в минутах от начала выполнения моделирования. Event e; // объявление события конструктором умолчания // клиент 3 уходит в 120 минут. После 10 минут ожидания, //клиент затрачивает 5 минут на совершение операции. е = Event(120, departure, 3, 1, 10, 5); cout«e.GetService(); //выводится время обслуживания 5 Информация моделирования Во время выполнения моделирования мы накапливаем информацию о каждом кассире, указывающую общее количество клиентов, которых обслужил кассир, время, которое клиенты провели в ожидании и общее время обслуживания клиентов кассиром в течение дня. Вся эта информация накапливается в записи TellerStats, содержащей также поле finishServi.ee, представляющее значение вывески в окне. Запись TellerStats finishService totalCustomerCount totalCustomerWait totalService //Структура для информации о кассире struct TellerStats { int finishService; //когда кассир может обслуживать int totalCustomerCount; //общее количество обслуженных клиентов int totalCustomerWait; //общее время ожидания обслуживания клиентами int totalService; //общее время обслуживания клиентов }; При моделировании генерируется событие прихода и ухода для каждого клиента. Время всех событий отмечается, и они помещаются в очередь приоритетов. Событие с высшим приоритетом в очереди приоритетов — это событие с самой ранней отметкой времени. Структура списка позволяет удалять события, так чтобы мы перемещались в возрастающей временной последовательности с приходом и уходом клиентов. Моделирование обслуживания в банке использует генератор случайных чисел для определения следующего прихода клиента и времени текущего обслуживания клиента. Генератор обеспечивает то, что любой результат в
диапазоне значений одинаково возможен. Если при моделировании текущее событие прихода возникает в Т минут, следующий приход происходит произвольно в диапазоне от T+arrivalLow до T+arrivalHigh минут, а общее время обслуживания, необходимое для клиента, находится в диапазоне от serv- iceLow до serviceHigh минут. Например, предположим, что некоторый клиент приходит в 2 минуты и диапазон времени прихода следующего клиента составляет от arriveLow=6 до arriveHigh=14 минут. Существует вероятность 1/9 того, что будет наблюдаться любой из результатов б, 7, . . . , 14 минут. Текущий приход Следующий приход arrivalLow=4 arrivalLow=12 Если мы знаем что, последующее событие (приход другого клиента) будет в 9 минут, можно создать это событие и поместить его в очередь приоритетов для будущей обработки. Данные для моделирования и методы, реализующие эту задачу, содержатся в классе Simulation. Данные-члены включают продолжительность (length) моделирования (в минутах), количество кассиров, номер следующего клиента, массив записей TellerStats, содержащих информацию о каждом кассире, и очередь приоритетов, которая содержит список событий. Класс также содержит границы диапазонов для следующего прихода и для текущего обслуживания клиента. Спецификация класса Simulation ОБЪЯВЛЕНИЕ class Simulation { private: // данные для моделирования int simulationLength; // продолжительность моделирования int numTellers; // число кассиров int nextCustomer; // номер следующего клиента int arrivalLow, arrivalHigh; // диапазон прихода нового клиента int serviceLow, serviceHigh; // диапазон обслуживания TellerStats tstat[ll]; // максимальное число кассиров - 10 PQueue pq; // очередь приоритетов RandomNumber rnd; // использовать для времени прихода // обслуживания // закрытые методы, используемые функцией RunSimulation int NextArrivalTime(void); int GetServiceTime(void); int NextAvailableTeller(void); public: // конструктор Simulation(void); void RunSimulation(void); void PrintSimulationResults(void); }; ОПИСАНИЕ
Конструктор инициализирует массив типа TellerStats и член класса пех- tCustomer, который начинается с 1. Индекс 0 в массиве tstat не используется. Таким образом, номер кассира является и индексом в массиве tstat. Конструктор запрашивает пользователя ввести данные для задачи исследовательского моделирования. Данные включают продолжительность моделирования в минутах, количество кассиров и диапазон времени прихода и времени обслуживания. Класс предусматривает до 10 кассиров. Для каждого события прихода мы вызываем метод NextArrivalTime, чтобы определить, когда в банк придет следующий клиент. В то же время мы вызываем GetServiceTime, чтобы определить, как долго будет продолжаться текущее обслуживание клиента кассиром, и метод NextAvailableTeller для определения кассира, который будет обслуживать клиента. Метод RunSimulation выполняет задачу исследовательского моделирования, a PrintSimulationResults выводит окончательную статистику. Установка параметров моделирования Конструктор инициализирует данные и запрашивает у пользователя параметры моделирования. Simulation::Simulation(void) { int i; Event firstevent; // инициализация информационных параметров кассиров for(i - 1; i <« 10; i++) { tstat[i].finishService « 0; tstat[i].totalService - 0; tstat[ij.totalCustomerWait * 0; tstat[ij.totalCustomerCount - 0; ) nextCustomer - 1; cout « "Введите время моделирования в минутах: "; cin » simulationLength; cout « "Введите число кассиров банка; "; cin » numTellers; cout « "Введите диапазон времени приходов в минутах: "; cin » arrivalLow » arrivalHigh; cout << "Введите диапазон времени обслуживания в минутах: "; cin » serviceLow » serviceHigh; // генерить событие первого прихода клиента pq.PQInsert(Event(0,arrival,1,0,0,0)); ) Выполнение задачи моделирования Продолжительность моделирования (simulationLength) используется для определения, следует ли генерировать событие прихода для другого клиента. Если банк будет закрыт в планируемое время прихода нового клиента (arrival time>simulationLengtb), новые клиенты не принимаются, и моделирование завершается обслуживанием клиентов, остающихся в банке. Данное-член nextCustomer — это счетчик, ведущий запись количества клиентов.
Метод NextArrivalTime возвращает интервал времени до прихода следующего клиента. Метод использует генератор случайных чисел и параметры arrivalLow и arrivalHigh для генерирования возвращаемого значения. Сходный метод GetServiceTime использует параметры serviceLow и serviceffigh // определить случайное время следующего прихода int Simulation::NextArrivalTime(void) { return arrivalLow+rnd.Random(arrivalHigh-arrivalLow+l); > // определить случайное время обслуживания клиента int Simulation::GetServiceTime(void) { return serviceLow+rnd.Random(serv^ceHigh-serviceLow+l); } В этой задаче клиент приходит, смотрит на окно кассира и читает вывеску, указывающую, когда каждый кассир будет свободен. Исходя из этой информации, клиент выбирает кассира, который предоставит ему банковские услуги. Значение данных finishService в записи каждого кассира представляет вывеску, висящую в окне. Функция NextAvailableTeller просматривает массив кассиров и возвращает номер кассира с минимальным значением finishService. Если все кассиры будут заняты вплоть до времени закрытия банка, клиенту назначается произвольный кассир. // возвратить номер первого доступного кассира int Simulation: .'NextAvailableTeller (void) { // вначале предполагается, что все кассиры // освобождаются ко времени закрытия банка int minfinish = simulationLength; // назначить случайного кассира клиенту, пришедшему //до закрытия, но имеющему возможность получить обслуживание // после закрытия банка int minfinishindex - rnd.Random(nuraTellers) + 1; // найти кассира, который освобождается первым for (int i = 1; i <== numTellers; i++) if (tstat[i].finishService < minfinish) { minfinish = tstat[i].finishService; minfinishindex = i; } return minfinishindex; } Основная функция в задаче моделирования — это метод RunSimulation, который управляет очередью приоритетов событий. Первоначально очередь имеет единственное событие прихода, возникающее, когда банк открывается. С этого момента и далее процесс является непрекращающимся. Метод Run- Simulation является циклом, который извлекает события из очереди приоритетов. Такой цикл определяется как событийный (event loop), и он завершается при пустой очереди. Кассиры банка продолжают обслуживать клиентов после истечения времени работы банка (time>simulationLength) с учетом того, что клиенты пришли до закрытия банка. В заключительном резюме simulation- Length обновляется, чтобы предоставить время ухода последнего клиента, с учетом того, что этот уход наступает после времени закрытия банка. Если переменная е является объектом Event, содержащим последнее событие дня,
e.GetTime() возвращает время события, а продолжительность задачи моделирования вычисляется с использованием оператора: simulationLength = (e.GetTimeO <=simulationLength) ? simulationLength : e.GetTimeO; ПРИХОД 1. Объект вновь пришедшего клиента отвечает за генерирование следующего события прихода, которое затем помещается в очередь приоритетов для последующей обработки. Если следующий приход должен наступить после завершения задачи моделирования, это событие не учитывается. //вычисление времени следующего прихода, nexttime = e.GetTimeO + NextArrivalTime (); if (nexttime > simulationLength) //обрабатывать события, но новые не генерировать continue; else { //генерировать приход следующего клиента и помещать в queue nextCustomer++; newevent - Event(nexttime, arrival, nextCustomer, 0, 0, 0) ; pq.PQInsert(newevent); } 2. После создания следующего события прихода обновляется информация TellerStats, а затем создается событие ухода. Мы начинаем с инициализации значения servicetime, которое определяет количество времени, необходимого на обслуживание текущего клиента, и затем определяем первого кассира, свободного для обслуживания следующего клиента. //время, затрачиваемое на клиента servicetime = GetServiceTime(); //кассир, который обслуживает клиента tellerlD - nextAvailableTeller(); Теперь рассмотрим поле finishService кассира, который будет работать с клиентом. Если finishService не 0, то это — время, когда кассир освобождается для обслуживания другого клиента. Если значение finishService равно 0, то кассир свободен. В этом случае установим finishService на текущее время, чтобы отразить тот факт, что кассир будет теперь обслуживать клиента. Определим время, в течение которого клиент должен ожидать (waittime), вычитая текущее время из finishService. //если кассир свободен, заменить время на вывеске на текущее время if (tstat[tellerlD].finishService == 0) tstat [tellerlD] . finishService = e.GetTimeO; //вычислить время, когда клиент ожидает, вычитая //текущее время из времени на вывеске кассира waittime = tstat[tellerlD].finishService - e.GetTimeO; Этими значениями мы обновляем информацию TellerStats для кассира, который обслуживает клиента. Поле finishService увеличивается на время обслуживания, необходимое для текущего клиента. Оно теперь содержит время, когда кассир закончит обслуживание всех его (ее) текущих клиентов.
//обновлять статистику кассира tstat[tellerlD].totalCustomerWait +=waittime; tstat[tellerlD].totalCustomerCount++; tstat[tellerlD].totalService +=servicetime/ tstat[tellerlD].finishService +=servicetime; 3. Конечная задача включает определение события ухода и помещение его в очередь приоритетов. Мы имеем все необходимые параметры для создания события. Элемент данных события time etype customerlD tellerlD waittime servicetime Передаваемый параметр tstat[tellerlD].finishService departure e.GetCustomerlDO tellerlD waittime servicetime newevent = Event(tstat[tellerlD] . finishService, departure, e.GetCustomerlD(),tellerlD, waittime, servicetime); pq.PQInsert(newevent); УХОД Событие ухода дает нам доступ к истории деятельности клиента во время нахождения в банке. В задаче исследовательского моделирования эта информация может выводиться на экран. Для события ухода мы должны обновить поле finishService, если у кассира нет других клиентов. Это наступает, если текущее значение finishService равно времени ухода. В этом случае finishService устанавливается на 0. tellerlD = e.GetTellerlDO; //если никто не ждет кассира, отметить, что кассир свободен if (e.GetTimeO -- tstat[tellerlD] .finishService) tstat[tellerlD] .finishService = 0/ Резюме задачи моделирования. Для завершения моделирования вызываем функцию PrintSimulationResults. Она печатает резюме данных о клиентах и отдельных кассирах. Данные собираются из записей TellerStats, содержащих информацию о количестве клиентов, которых обслужил каждый кассир, и суммарное время ожидания клиентами обслуживания. for (i = 1; i<=numTellers; I++) { cumCustomers += tstat[i].totalCustomerCount; cumWait += tstat[i]. totalCustomerWait; } Окончательное резюме дает среднее время ожидания клиента и время занятости каждого кассира в процентах. cout « endl; cout « ******** Simulation Summary ******** « endl; cout « Simulation of « simulationLength << minutes << endl; cout « No. of Customers: « cumCustomers << endl; cout « Average Customer Wait:; avgCustWait = float(cumWait)/cumCustomers + 0.5; cout « avgCustWait « minutes « endl;
for(i*l;i<« numTellers;i++) { cout « n Teller # " « i « % Working: ; //отображать значение в процентах, округленное до ближайшего целого tellerWork * float(tstatfi].totalService)/simulationLength; tellerWorkPercent - tellerWork * 100.0 + 0.5; cout « tellerWorkPercent « endl; } Пример задачи моделирования. Main-программа определяет объект моделирования S и затем выполняет цикл event с использованием метода Run- Simulation. После завершения цикла event вызывается метод PrintSimula- tionResulte. Пример 5.6 Мы следим за задачей моделирования после определения этих параметров: simulationLength * 30 (минут) numTellers= 2 arriveLow » б arriveffigh = 10 serviceLow -» 18 serviceffigh * 20 Клиент 1 приходит в 0 минут Прмиод customerlD 1 tellerlD - waittime - servicetime - Клиент 1 генерирует событие прихода для клиента 2 в 7 минут и генерирует свое собственное событие ухода в 19 минут Приход customerlD 2 tellerlD - waittime - servicetime - Клиент 2 приходит в 7 минут. Уход customerlD 1 tellerlD 1 waittime 0 servicetime 19 19 Уход customerlD 1 tellerlD 1 waittime 0 servicetime 19 19
Клиент 2 генерирует событие прихода для клиента 3 в 16 минут и генерирует свое событие ухода в 25 минут. Приход Уход Уход customerlD 3 tellerlD - waittime - servicetime - customerlD 1 tellerlD 1 waittime 0 servicetime 19 customerlD 2 tellerlD 2 waittime 0 servicetime 18 16 19 25 Клиент З приходит в 16 минут. Клиент 3 генерирует событие прихода, которое наступает после закрытия банка, что прекращает создание событий прихода. Клиент 3 должен ожидать 3 минуты кассира 1 и генерирует свое событие ухода в 37 минут. Уход Уход customerlD 1 tellerlD 1 waittime 0 servicetime 19 customerlD 2 tellerlD 2 waittime 0 servicetime 18 19 25 События ухода удаляются из очереди приоритетов в таком порядке: клиент 1 — в 19 минут, клиент 2 — в 25 минут и клиент 3 — в 37 минут. Уход customerlD 1 tellerlD 1 waittime 0 servicetime 19 Уход customerlD 2 tellerlD 2 waittime 0 servicetime 18 Уход customerlD 3 tellerlD 1 waittime 3 servicetime 18 19 25 37 Банк закрывается после 37 минут обслуживания. Программа 5.7 Код и выполнение Эта программа запускается дважды. Данные первого запуска соответствуют данным в примере 5.6. Во втором запуске используется продолжительность задачи 480 минут (8 часов). Метод вывода PrintSimulation- Results указывает количество постоянных клиентов (patrons), среднее время ожидания для каждого клиента и общее время занятости каждого кассира. Реализация классов Event, TellerStats и Simulation содержится в файле sim.h.
#include "sim.h" void main(void) { // S - объект для моделирования Simulation S; // запустить моделирование S.RunSimulation(); // печатать результаты S.PrintSimulationResults(); } /* <Прогон 1 программы 5.7> Enter the simulation time in minutes: 30 Enter the number of bank tellers: 2 Enter the range of arrival times in minutes: б 10 Enter the range of service times in minutes: 18 20 Time: 0 arrival of customer 1 Time: 7 arrival of customer 2 Time: 16 arrival of customer 3 Time: 19 departure of customer 1 Teller 1 Wait 0 Service 19 Time: 25 departure of customer 2 Teller 2 Wait 0 Service 18 Time: 37 departure of customer 3 Teller 1 Wait 3 Service 18 ******** Simulation Summary ******** Simulation* of 37 minutes No, of Customers: 3 Average Customer Wait: 1 minutes Teller #1 % Working: 100 Teller #2 % Working: 49 <Прогон 2 программы 5.7> Enter the simulation time in minutes 480 Enter the number of bank tellers 4 Enter the range of arrival times in minutes 2 5 Enter the range of service times in minutes 6 20 <arrival and departure of 137 customers> ******** Simulation Summary ******** Simulation of 521 minutes No. of Customers: 137 Average Customer Wait: 2 minutes Teller #1 % Working: 89 Teller #2 % Working: 86 Teller #3 % Working: 83 Teller #4 % Working: 86 */ Письменные упражнения 5.1 Подчеркните правильное. СЩК — это структура, реализующая порядок: (a) first-in/last-out (b) last-in/first-out (с) first-come/first-serve (d) first-in/first-out (e) last-in/last-out
5.2 Напишите два программных приложения для стеков. 5.3 Какой выход имеет следующая последовательность операций стека? (DataType = int): Stack S int x = 5, у = 3; S.Push (8); S.Push(9); S.Push(y); x = S.Pop{); S.Push(18); x = S.Pop(); S.Push(22); while (! S.StackEmpty()) { у = S.Pop(); cout « у « endl; } cout « x <<endl; 5.4 Напишите функцию void StackClear(Stacks S) ; которая очищает стек S. Почему важно, чтобы объект S передавался по ссылке? 5.5 Что выполняет следующая функция? (DataType = int): void Ques5(Stacks S) { int ar'r[64], n = 0, I; int elt; while (!S.StackEmpty()) a[n++] = S.PopO ; for(i=0;i< n;i++) S.Push(a{i] ; } 5.6 Что выполняет следующий сегмент кода? Stack SI, S2, tmp; DataType x; while (!Sl.StackEmpty()) { x = Sl.PopO ; tmp. Push (x) ; } while (!tmp.StackEmpty()) {. x = tmp.Pop(); Sl.Push(x); S2.Push(x); } 5.7 Напишите функцию int StackSize(Stack S); которая использует операции стека, чтобы возвращать количество элементов в стеке S.
5.8 Что выполняет следующая функция Ques8? (DataType = int): void Ques8(Stacks S, int n) { Stack Q/ int I; while(!S.StackEmpty()) { i = S.Pop(); if (I !=n) Q.Push(i); } while(!Q.StackEmpty()) { i * Q.PopO ; S.Push(i); ) } 5.9 Если DataType является int, напишите функцию void SelectItem(Stack& S, int n) ; которая использует операции стека для нахождения первого появления элемента в стеке S и перемещает его в вершину стека. Поддерживайте упорядочение для других элементов. 5.10 Преобразуйте следующие инфиксные выражения в постфиксные: (а) а 4- Ь*с (б) (a+)/(d-e) (в) (Ь2 - 4*а*с)/(2*а) 5.11 Напишите следующие выражения в инфиксной форме: (а) a b + с* (б) a b с + * (в) a b с d e ++**e f - * 5.12 Подчеркните правильное. Очередь — это структура, реализующая порядок: (а) first-in/last-out (б) last-in/first-out (в) first-come/first-serve (г) first-in/first-out (д) last-in/last-out 5.13 Очередь — это структура данных, применимая для (подчеркнуть все возможные варианты) (а) оценки выражений (б) планирования задач операционной системы (в) моделирования очередей ожидания (г) печати списка в обратном порядке. 5.14 Какой выход имеет следующая последовательность операций очереди? (DataType = int):
Queue Q; DataType x - 5, у * 3; Q.QInsert (8); Q.QlnsertO); Q.QInsert(y); x e Q.QDeleteO ; Q.QInsert(18); x * Q.QDeleteO ; Q.QInsert(22); while <!Q.QEmptyO) { у - Q.QDeleteO ; cout « у « endl; } cout « x « endl; 5.15 Что выполняет следующая функция? (DataType = int): void QueslS(Queues Q, int n - 50) { Stack S; int elt; while (IQ.QEmptyO) { elt - Q.QDeleteO ; S.Push<elt); ) while (!S.StackEmpty()) { elt - S.PopO; Q.QInsert(elt); ) ) Почему важно, чтобы объект Q передавался по ссылке? 5.16 Что выполняет следующий сегмент кода? (DataType « int) Queue Ql, Q2; int n « 0, x; • * * while (JQl.QEmptyO) { x «Ql.QDeleteO ; Q2.QInsert (x); n++; } for (int i-0;i <n;i++) { x » Q2.QDelete О; Ql.QInsert(x); Q2.QInsert(x); ) 5.17 Предположим, что список очереди приоритетов содержит целые значения с оператором "меньше, чем" (<), определяющим очередь приоритетов. Текущий список содержит элементы. Записывая методы PQDelete и PQInsert в классе Pqueue, опишите этот список после выполнения каждой из следующих инструкций: 45 15 50 25 65 30
(a) Item = pq.PQDelete() Item * List: (b) pq.PQInsert(20) List: (c) Item « pq.PQDelete() Item = List: (d) Item * pq.PQDelete() Item = List: 5.18 Перепишите подпрограмму PQDelete для обеспечения FIFO-порядка элементов на том же самом уровне приоритетов. Упражнения по программированию 5.1 Добавьте метод void Qprint (void); который приводит к печати очереди, по 8 элементов в строке. Напишите программу, которая вводит 20 значений double из файла pq.dat в очередь. Печатайте элементы. 5.2 Считайте 10 целых в массив, поместив каждый в стек. Напечатайте оригинальный список и затем напечатайте стек, извлекая элементы. Конечно, вторая печать перечисляет элементы в обратном порядке. 5.3 Стек может использоваться для распознавания определенных типов образов. Рассмотрим образец STRING1#STRING2, где никакая строка не содержит "#" и STRING2 должна быть обратна STRING 1. Например, строка "123&~a#a~&321" совпадает с образцом, но строка "a2qd#dq3a" — нет. Напишите программу, которая считывает пять строк и указывает, совпадает ли каждая строка с образцом. 5.4 Во второй модели стека стек растет в направлении уменьшения индексов массива. Первоначально стек пуст и Тор=21. После помещения трех символов в стек индекс Тор — 18, и элемент в вершине стека — это LIST[TOP] = С. top top Убывающий индекс В этой модели индекс Тор уменьшается с каждой операцией Push. Напишите реализацию для класса Stack, используя эту модель. Протестируйте свою работу, запуская программу 5.1. 5.5 Массив может использоваться для сохранения двух стеков, один, растущий с левого конца, второй, уменьшающийся с правого конца. top. top2 Возрастание стека Возрастание стека
(а) Каково условие для того, чтобы Si был пуст? Когда S2 пуст? (б) Каково условие для того, чтобы Si был полным? Когда S2 полный? (в) Реализуйте класс DualStack, объявление которого задается с помощью const int MaxDuaiStackSize = 100 class DualStack { private: int topi, top2; DataType stackStorage[MaxDuaiStackSize]; public: DualStack(void); //помещаем elt в стек п void Push(DataType elt, int n); //извлекаем из стека п DataType Pop(int n); //выборка в стеке п DataType Peek(int n); //стек п пуст? int StackEmpty(int n ); //стек п полный? int StackFull(int n); //очистка стека п void ClearStack(int n); }; (г) Напишите main-программу, считывающую последовательность из 20 целых, помещая все четные целые в один стек, и нечетные — в другой. Печатайте содержимое каждого стека. 5.6 Считайте строку текста, помещая каждый непустой символ и в очередь, и в стек. Проверьте, не является ли текст палиндромом. 5.7 Расширьте постфиксный калькулятор, добавив унарный минус, представленный символом "@". Например, введите выражение 7 @ 12 + <Display>5 5.8 Это упражнение расширяет класс постфиксного калькулятора для включения оценки выражений с переменной. Новое объявление следующее: class Calculator { private: Stack S; struct component //NEW { short type; float value; }; int expsize: //NEW component expComponent[50]; //NEW int GetTwoOperands(doubles operandi, doubles operand2); void Enter(double num); void Compute(char op); public:
Calculator(void); void Run(void); void Clear(void); void Variable(void); //NEW double Eval(double x); //NEW }; Операция Variable позволяет пользователю вводить постфиксное выражение, содержащее переменную х, равно как числа и операторы. Например, введите выражение хх*Зх* + 5 + когда вам необходимо оценить инфиксное выражение х2 + Зх + 5. Когда каждый компонент считан, выполните ввод в массив expComponent. Поле типа каждого элемента ввода устанавливается так: 1= число, 2 — переменная х, 3 = +, 4 = -, 5 = *, 6 = /. Если тип = 1, то число сохраняется в поле значения. Увеличивайте expsize на единицу для каждого нового элемента ввода. Функция-член Eval выполняется циклично для членов expsize массива expComponent и использует стек для оценки выражения. Всякий раз, когда какой-либо элемент имеет поле типа 2, помещайте параметр х в стек. Напишите main-программу, оценивающую выражение (х2 + 1)/ (х4 + 8х2 + 5х + 3) для х =0, 101, 102, 103, . . . , 1(R 5.9 Измените задачу моделирования в программе 5.7, чтобы она включала следующую информацию: (а) Выводить среднее время, которое клиент проводит в банке. Время клиента измеряется от прихода до ухода. (б) В настоящее время все кассиры остаются в банке до тех пор, пока не уходит последний клиент. Это обходится дорого, если кассиры задерживаются после закрытия банка для обслуживания пришедшего поздно клиента. Дайте возможность кассиру уйти, если банк закрывается и нет ожидающих обслуживания клиентов. Совет: добавьте поле к записи TellerStat, которое указывает общее время, которое кассир проводит в банке. Используйте эту переменную для вычисления времени в процентах, в течение которого кассир занят. 5.10 Предположим, в банке создается отдельная очередь к каждому кассиру. Когда клиент приходит, он (или она) выбирает самую короткую очередь, а не оценивает загруженность кассиров. Измените программу моделирования 5.7 для указания среднего времени ожидания клиента и объема работы, выполненной каждым кассиром. Сравните результаты с моделью банковского обслуживания с единственной очередью к кассирам.
глава Абстрактные операторы 6.1. Описание перегрузки операторов 6.2. Система рациональных чисел 6.3. Класс Rational 6.4. Операторы класса Rational как функции-члены 6.5. Операторы потока класса Rational как дружественные функции 6.6. Преобразование рациональных чисел 6.7. Использование рациональных чисел Письменные упражнения Упражнения по программированию
Абстрактный тип данных определяет набор методов для инициализации и управления данными. С классами язык C++ вводит мощные средства для реализации ADT. В этой главе мы расширяем определенные языком операторы (например, +, [] и так далее) для абстрактных типов данных. Процесс, называемый перегрузка оператора (operator overloading), переопределяет стандартные символы операторов для реализации операций для абстрактного типа. Перегрузка оператора — это одна из наиболее интересных возможностей объектно-ориентированного программирования. Эта концепция позволяет использовать стандартные операторы языка с их приоритетом и свойствами ассоциативности как методы класса. Например, предположим, класс Matrix определяет операции сложения и умножения, используя AddMat и MultMat. При перегрузке мы можем использовать знакомые инфиксные операторы "+" и "*". Предположим, что Р, Q, R, S — это объекты типа Matrix: Стандартные методы класса R = P.AddMat (Q); S = R.MultMat (Q.AddMat(P)); Перегруженные операторы R = Р + Q S = R * (Q + Р) Реляционные выражения определяют порядок между элементами. Мы говорим, что целое 3 является положительным или что 10 меньше, чем 15. 3 >=0 10 < 15 Концепция порядка применима к структурам данных, отличным от чисел. В классе Data в главе 3, например, две даты (объекты) могут сравниваться по хронологии года. Date(б, б, 44) < (Date(12, 25,80) //День-Д наступил раньше рождества 1980 Date(4, 1, 99) == Date {"4/1/99") //Два способа создать дату 1 апреля Date(7, 1, 94) < Date("8/1/94") //июль наступает перед августом C++ позволяет выполнять перегрузку большинства присущих ему операторов, включая операторы потока. Программист не может создавать новые символы оператора, а должен перегружать существующие операторы C++. Метод PrintDate в классе Date принимает объект D и выводит его поля в стандартном формате. Тот же метод может быть переписан как оператор "«" и использоваться в потоке cout. Например, Date-объект D(12,31,99) определяет последний день двадцатого века. Чтобы создать выходную строку Последний день 20-го века — 31 декабря 1999 мы можем использовать метод print из главы 3 или перегрузить оператор вывода. PrintDate Method: cout <<"Последний день 20-го века -" D.PrintDate(); Overloaded "«" Method: со1^<<"Последний день 20-го века -"<< D; Перегрузка операторов — это тема, которая описывается в нескольких главах. В данной главе мы разрабатываем класс рациональных чисел для иллюстрации ключевых понятий. Вы, конечно, знакомы с "дробями" и имеете школьный опыт работы с операциями. Класс Rational дает хороший пример арифметических и операций отношения, преобразования типов между целыми значениями и действительными числами и простого определения для перегруженного потока ввода/вывода.
6.1. Описание перегрузки операторов Термин перегрузка операторов (operator overloading) подразумевает, что операторы языка, такие как +,!=,[] и = могут быть переопределены для типа класса. Этот тип класса дает возможность реализации операций с абстрактными типами данных с использованием знакомых символов операций. C++ предоставляет различные методы определения перегрузки операторов, включающие определяемые пользователем внешние функции, члены класса и дружественные функции. C++ требует, чтобы по крайней мере один аргумент перегруженного оператора был объектом или ссылкой на объект. Определяемые пользователем внешние функции В классе SeqList методы Find и Delete требуют определения оператора отношения "==" для DataType. Если оператор не присущ этому типу, пользователь должен явно задать перегруженную версию. Объявление использует идентификатор "operator" и символ "==". Так как результатом операции является True или False, возвращаемое значение будет типа int: int operator==(const DataTypei a, const Datatypes b); Например, предположим, список содержит записи Employee, которые состоят из поля ID и другой информации, включающей имя, адрес и так далее. struct Employee { int ID; • • • } ID Имя Адрес ■ ■ ■ Операция отношения "равно" сравнивает поля Ю: int operator == (const Employees a, const Employees b) { return a.ID ==b.ID; //сравнение ID-полей } Перегруженный оператор должен иметь аргумент типа класс. Заметьте, что следующая попытка перегрузить "==" для строк C++ будет неудачной, потому что никакой аргумент не является объектом или ссылкой на объект. int operator==(char *s, char *t)//неверный оператор { return strcmp(s, t)== 0; } Когда оператор отношения "равно" будет объявлен, пользователь может использовать функции Find или Delete класса SeqList: typedef Employee DataType; //тип данных Employee #include "aseqlist.h" ■ * • SeqList L;
Employee emp; //объявляем объект SeqList. • л т emp.ID=1000; //ищем служащего с ID=1000 if (L.Find(emp)) L.Delete(emp); //если найден, удаляем этого служащего Чтобы использовать класс SeqList, пользователь должен иметь технические знания для определения оператора "==" как внешней функции. Это является дополнительным, но обязательным требованием, поскольку оператор "==" присоединяется к типу данных, а не к классу. Члены класса Арифметические операторы и операторы отношения происходят из систем счисления. Они позволяют программисту объединять операнды в выражениях, используя инфиксную запись. Класс может иметь методы, объединяющие объекты сходным образом. В большинстве случаев методы могут записываться как перегруженные функции-члены с использованием стандартных операторов С4--К Когда левый операнд является объектом, оператор может выполняться как метод, который определяется для этого объекта. Например, двумерные векторы имеют операторы, включающие сложение, отрицание и скалярное произведение векторов. Сложение векторов Сложим два вектора u=(ui,U2) и v=(vi,V2), образуя вектор, компоненты которого являются суммой компонентов каждого вектора (см. часть (а) рис. 6.1). u+v = (ux+v^i^+va) Отрицание векторов Образуем отрицание векторов u=(ui,U2), беря отрицательное значение каждого компонента. Новый вектор имеет ту же величину, но противоположное направление (см. часть (Ь) рис. 6.1). -(ui,u2)=(-iii, -u2) Скалярное произведение векторов Вектор u=(ui,U2), умноженный на вектор u=(vi,V2), является числом, полученным сложением произведений соответствующих компонентов (см. часть (с) рис. 6.1). Скалярное произведение векторов является произведением модулей векторов (magnitude) на косинус угла между ними. v*w=u1v1+u2v2=magnitude(v)*magnitude(w)*cos(q) (VI, V2) (U1, U2) u + v Ul * V1 + U2 * V2 (а) Сложение (б) Вычитание (с) Произведение Рис. 6.1. Векторные операции
Мы объявляем класс векторов Vec2d с координатами х и у как закрытыми данными-членами. Этот класс имеет конструктор, два бинарных оператора (сложение, скалярное произведение) и унарный оператор (отрицание) в качестве функций-членов, а также дружественные функции, определяющие умножение вектора на скаляр и потоковый вывод. Использование дружественных функций описывается в следующем разделе. class Vec2d { private: double xl,x2 //компоненты public: //конструктор со значениями по умолчанию Vec2d(double h=0.G, double v=0.0); //функции-члены Vec2d operator-(void); //вычитание Vec2d operatori-(const Vec2d& V); //сложение double operator*(const Vec2d& V); //скалярное произведение //дружественные функции friend Vec2d operator*(const double c,const Vec2d& V); friend ostreams operator<<(ostream& os, const Vec2d& U); >; Как в функциях-членах в операторах сложения, отрицания и скалярного произведения предполагается, что текущий объект является левым операндом в выражении. Предположим, U и V- это векторы. Сложение. Выражение U + V использует бинарный оператор "+", связанный с объектом U. Метод operator4- вызывается с параметром V. U.operator+(V) //возвращает значение Vec2d(x+V.x, у + V.y) Отрицание. Выражение -U использует унарный оператор "-". Метод operator- выполняется для объекта U. U.operator- () //возвращает значение Vec2d(-x, -у) Скалярное произведение. Подобно сложению, выражение U*V использует бинарный оператор *'*", связанный с объектом U. Произведение вычисляется методом текущего объекта U и параметра V. U.operator*(V) //возвращает значение х * V.x +y * V.y Пример с объектами: Vec2d U(l, 2), V(2, 3); U + V = (1, 2) + (2, 3) « (3, 5) -U - -(1, 2) = (-1, -2) U *V = (1, 2) * (2, 3) = 8 Перегрузка операторов требует соблюдения определенных условий. 1. Операторы должны соблюдать приоритет операций, ассоциативность и количество операндов, диктуемое встроенным определением этого оператора. Например, "*" — это бинарный оператор и должен всегда, когда он перегружается, иметь два параметра. 2. Все операторы в C++ могут быть перегружены, за исключением следующих: , (оператор "запятая") sizeof :: (оператор области действия) ?: (условное выражение)
3. Перегруженные операторы не могут иметь аргументов по умолчанию; другими словами, все операнды для оператора должны присутствовать. Например, следующее объявление оператора является неправильным: double Vec2d::operator* (vec2d V = Vec2d(l,l)); 4. Когда оператор перегружается как функция-член, объект, связанный с этим оператором, всегда является крайним слева операндом. 5. Как функции-члены унарные операторы не принимают никаких аргументов, а бинарные операторы принимают один аргумент. Дружественные функции Обычно объектно-ориентированное программирование требует, чтобы только функции-члены имели доступ к закрытым данным какого-либо класса. Это обеспечивает инкапсуляцию и скрытие информации. Этот принцип следует расширить до перегрузки оператора везде, где возможно. В некоторых ситуациях, однако, использование перегрузки с функцией-членом невозможно или слишком неудобно. Поэтому необходимо использовать дружественные функции (friend functions), которые определяются вне класса, но имеют доступ к закрытым данным-членам. Например, умножение вектора на скаляр является еще одной формой умножения векторов, где каждый компонент вектора умножается на числовое значение. Число (скаляр) с, умноженное на вектор u=(ui,ui), является вектором, компоненты которого образуются умножением каждого компонента и на с: c*u=(cu1,cu2) Это естественная операция перегрузки с использованием оператора '*'. Однако, перегрузка оператора с использованием функции-члена не позволяет помещать скалярное значение С как левый операнд. Левым операндом должен быть вектор. Мы определяем, что '*' является оператором вне класса, имеющим параметры С и U, и имеет доступ к закрытым членам х и у параметра U. Это выполняется определением оператора как дружественного внутри объявления класса. Дружественная функция объявляется помещением ключевого слова friend перед объявлением функции в классе. friend Vec2d operator * (const double с, const Vec2d& U); Так как дружественная функция является внешней для класса, она не находится в области действия класса и реализуется как стандартная функция. Заметьте, что ключевое слово friend не повторяется в объявлении функции. Vec2d operator* (double с, Vec2d U) { return Vec2d(c*U.x, c*U.y); }
При использовании умножения вектора на скаляр оператору '*' передаются оба аргумента, и скаляр должен быть левым параметром. Vec2d 7(8,5), Z; Z = 3.0 * Y; //результатом является (24,15) Использование дружественных функций и права доступа к ним описываются в следующем перечне правил 1. Объявление дружественной функции выполняется помещением объявления функции внутрь класса, которому предшествует ключевое слово friend. Фактическое определение дружественной функции дается вне блока класса. Дружественная функция определяется подобно любой обычной функции C++ и не является членом класса. 2. Ключевое слово friend используется только в объявлении функции в классе, а не с определением функции. 3. Дружественная функция имеет доступ к закрытым членам класса. 4. Дружественная функция получает доступ к членам класса только при передаче ей объектов как параметров и использовании записи с точкой для ссылки на члена. 5. Для перегрузки унарного оператора как друга в качестве параметра передается операнд. При перегрузке бинарного оператора как друга в качестве параметров передаются оба операнда. Например: friend Vec2d operator-(Vec2d X); //унарный "минус" friend Vec2d operator+(Vec2d X, Vec2d Y); //бинарное сложение 6.2. Система рациональных чисел Рациональные числа — это множество частных P/Q, где Р и Q — это целые, a Q * 0. Число Р называется числителем или нумератором (numerator), а число Q — знаменателем или деноминатором (denominator). 2/3 -6/7 8/2 10/1 0/5 5/0 (неверно) Представление рациональных чисел Рациональное число — это отношение числителя к знаменателю, и следовательно, представляет один член коллекции эквивалентных чисел (equivalent numbers). Например: 2/3 = 10/15 = 50/75 //эквивалентные рациональные числа Один член коллекции имеет редуцированную форму (reduced form), в которой числитель и знаменатель не имеют общего делителя. Рациональное число в редуцированной форме является наиболее репрезентативным значением из коллекции эквивалентных чисел. Например, 2/3 — это редуцированная форма в коллекции 2/3, 10/15, 50/75 и так далее. Чтобы создать редуцированную форму какого-либо числа, числитель и знаменатель нужно разделить на их наибольший общий делитель (GCD, greatest common denominator). Например: 10/15 = 2/3 (GCD(10,15) = 5; 10/5 = 2 15/5 = 3) 24/21 = 8/7 (GCD (24,21) = 3; 24/3 = 8 21/3 = 7)
5/9 - 5/9 (GCD(5,9) - 1; рациональное число уже в редуцированной форме) В рациональном числе и числитель, и знаменатель могут быть отрицательными целыми. Мы используем термин нормализованная форма (standardized form) с положительным деноминатором. 2/-3 * -2/3 //-2/3 — это нормализованная форма -2/-3 = 2/3 //2/3 — это нормализованная форма Арифметика рациональных чисел Читатель знаком со сложением, вычитанием и сравнением дробей с использованием общих деноминаторов и правил умножения и деления этих чисел. Далее приводятся несколько примеров, которые предлагают формальные алгоритмы для реализации операций в классе рациональных чисел. Сложение/Вычитание. Сложим (вычтем) два рациональных числа, создав эквивалентную дробь с общим знаменателем. Затем сложим (вычтем) числители. I 5 8*1 3*5 8*1+3*5 23 (+) 3*8~3*8 + 3*8~ 3*8 "24 I _ А - 1 * 8 _ 3*5 _ 1*8-3*5 _ -7 ^ 3 8"з*8 3 * 8 ~ 3*8 ~ 24 Умножение/Деление. Умножим два рациональных числа, умножая числители и знаменатели. Выполним деление, инвертируя второй операнд (меняя местами числитель и знаменатель) и перемножая члены дробей. W 3*8 3*8 24 (/) 1/5 = 1,8=_8_ 1 } 3 8 3 5 15 Сравнение. Все операторы отношения используют один и тот же принцип: Создаем эквивалентные дроби и сравниваем числители: /ч15 1*85*3 (<) 77 < ■=■ эквивалентно отношению ——г- < 38 3*88*3 Данное отношение ВЕРНО (TRUE), так как 1*8 < 3*5 (8<15) Пример 6.1 Этот пример иллюстрирует операции с рациональными числами. Предположим, U = 4/5, V = -3/8 и W = 20/25. l' U + V 5 + 8 40 Ч 40 40 v ) \ ) и „4 * ( 3) ~12 V 5 8 40 QO 1 К U > V, так как ^ > - 4£ (32 > -15) 40 40 0 тт ,„ 4 100 20 100 2. U = = W, таккак 5=-^ ^ = 125
Преобразование рациональных чисел Множество рациональных чисел включает целые как подмножество. В представлении целого как рационального числа используется деноминатор 1. Следовательно, (целое) 25 = 25/1 (рациональное) Более сложное преобразование касается действительных и рациональных чисел. Например, действительное число 4,25 — это 4+1/4, что соответствует рациональному числу 17/4. Алгоритм для преобразования действительного числа в рациональное приводится в разделе 6.6. Обратное преобразование рационального числа в действительное включает простое деление нумератора на деноминатор. Например: 3/4 = 0,75 //делим 3,0/4,0 6.3. Класс Rational Идеи, изложенные в разделе 6.2, могут быть прекрасно реализованы в классе, использующем данные-члены нумератор и деноминатор для описания рационального числа и объявляющем базовые арифметические и операторы отношения как перегруженные функции-члены. Операторы потокового ввода и вывода реализуются с использованием перегрузки дружественной функции. Преобразование между целыми (или действительными) и рациональными числами и преобразование между рациональными и действительными числами творчески использует функции конструктора и операторы преобразования C++. Спецификация класса Rational ОБЪЯВЛЕНИЕ ♦include <iostream.h> ♦include <stdlib.h> class Rational < private: // определяет рациональное число как числитель/знаменатель long num, den; // закрытый коструктор, используемый // арифметическими операторами Rational(long num, long denom); // функции-утилиты void Standardize(void); long gcd(long m, long n) const; public: // конструкторы преобразуют: // int->Rational, double->Rational Rational(int num=0, int denom=l); Rational(double x); // ввод/вывод friend istream& operator» (istreamft istr,
Rational &r); friend ©streams operator« (ostream& ostr, const Rationale d); // бинарные операторы: // сложить, вычесть, умножить, разделить Rational operator+ (Rational r) const/ Rational operator- (Rational r) const; Rational operator* (Rational r) const; Rational operator/ (Rational r) const; // унарный минус (изменяет знак) Rational operator- (void) const; // операторы отношения int operator< (Rational r) const; int operator<= (Rational r) const; int operator== (Rational r) const; int operator!= (Rational r) const; int operator> (Rational r) const; int operators (Rational r) const; // оператор преобразования: Rational -> double operator double(void) const; // методы-утилиты long GetNumerator(void) const; long GetDenominator(void) const; void Reduce(void); ); ОПИСАНИЕ Закрытый метод Standardize преобразует рациональное число в "нормальную форму" с положительным деноминатором. Конструкторы используют Standardize для преобразования числа в нормальную форму. Мы также используем этот метод при чтении числа или при делении двух чисел, поскольку эти операции могут привести к результату с отрицательным деноминатором. Сложение и вычитание не вызывают Standardize, потому что два знаменателя операндов уже являются неотрицательными. Закрытый метод gcd возвращает наибольший общий делитель двух целых тип. Открытая функция Reduce преобразует объект рациональное число в его редуцированную форму вызовом gcd. Два конструктора для этого класса действуют как операции преобразования целого (long int) и действительного (double) числа в рациональное (Rational) число. Они описываются в разделе, посвященном операторам преобразования типа. Для приложений мы используем методы-утилиты GetNumerator и GetDenominator для доступа к данным-членам рационального числа. Оператор ввода « является дружественной функцией, которая считывает рациональное число в форме P/Q. При выводе это число имеет форму P/Q. Попытка ввести рациональное число с деноминатором 0 вызывает ошибку и завершение программы. пример Rational A(-4,5), В, С; // А - это -4/5, В и С - это 0/1 cin » С; // при вводе -12/-18 // Standardize сохраняет 12/18 cout « С.GetNumerator(); // печать числителя 12
С.Reduce О; // привести С к форме 2/3 В = С + А; //В - это 2/3 + (-4/5) = -2/15 cout « -В; // вызвать оператор минус; вывести 2/15 cout « float(A); // конвертировать в действительное число; // вывести -.8 cout « .5 + Rational(3); // преобразовать .5 в 1/2; // вывести сумму: 1/2 + 3/1 = 7/2 6.4. Операторы класса Rational как функции-члены Класс Rational объявляет арифметические операторы и операторы отношения как перегруженные функции-члены. Каждый бинарный оператор имеет параметр, который является правым операндом. Предположим, u, v и w — это объекты типа Rational в выражении: w = u + v Перегруженный оператор + является членом объекта и (левый операнд) и принимает v в качестве параметра. Возвращаемым значением является объект типа Rational, который присваивается w. Технически w — u + v оценивается как w = u. + (v) Для выражения v — -и. C++ выполняет оператор "-" для объекта и (единственный операнд). Возвращаемым значением является объект Rational, который присваивается v. Технически v = -и оценивается как v = u.-() Реализация операторов класса Rational В программном приложении содержится весь код класса Rational. Мы реализуем сложение и деление, реляционное равенство и отрицание для иллюстрации перегрузки членов. Пусть имеются следующие объявления: Rational u(a,b), v(c,d); Чтобы сложить ("+") рациональные числа, найдем общий деноминатор для операндов. Фактическое сложение производится объединением нумераторов. а с а * d Ь * с а * d + b * с (+) ы + у=_+_=__+_=___ Для реализации а и b — это данные-члены num и den левого операнда, который является объектом, связанным с оператором "+". Числа end являются данными-членами v.num и v.den правого операнда: // Rational-суммирование Rational Rational::operator+ (Rational r) const { return Rational(num*r.den + den*r.num, den*r.den); } Деление ("/") выполняется инвертированием правого операнда и затем перемножением членов. Числа а и b соответствуют данным-членам левого операнда. Числа end относятся к данным-членам в объекте v, правом
операнде. Так как результат может иметь отрицательный деноминатор, частное нормализуется. ( } и~Ъ d~Ъ* с"ъ*с II Rational-деление Rational Rational::operator/ (Rational r) const < Rational temp = Rational(num*r.den, den*r.num); // убедиться, что деноминатор - положительный temp.Standardize(); return temp; } Для оценки оператора отношения сравним нумераторы после приведения рациональных чисел к общему деноминатору. Тогда для оператора отношения "равно" ("==") возвращаемым значением является True, когда нумераторы равны. Далее следуют операторы логического эквивалента (<^>). ^^а с а * d b * с <=> = = b * d b * d Протестируйте условие a * d === b * c: II отношение равно int Rational::operator== (Rational r) const { return num*r.den *- den*r.num; } Унарный оператор минус "-" работает с данными-членами единственного операнда, определяющего эту операцию. Вычисление просто делает отрицательным нумератор: // минус для Rational Rational Rational::operator- (void) const { return Rational(-num, den); } 6.5. Операторы потока класса Rational как дружественные функции Файл <iostream.h> содержит объявления для двух классов с именами ostream и istream, которые обеспечивают потоковый вывод и потоковый ввод, соответственно. Потоковая система ввода/вывода предоставляет определения для потоковых операторов ввода/вывода "»" и "«" в случае элементарных типов char, int, short, long, float, double и char*. Например: Ввод Вывод istream & operator»(short v) ; ostream & operator«(short v); istream & operator»(double v) ; ostream & operator«(double v);
Потоковые операторы можно перегружать для реализации ввода/вывода определяемого пользователем типа. Например, с классом Date операторы "«" и "»" могут обеспечивать потоковый ввод/вывод тем же способом, который использовался для простых типов. Например: Date D; cin»D //<ввод>10/5/75 cout«D //<вывод> October 5, 1975 Если бы операторы для класса Date должны были быть перегруженными как функции-члены, их было бы необходимо объявлять явно в <iostream.h>. Классу ostream пришлось бы иметь перегруженную версию "«", которая принимает параметр Date. Это явно непрактично, и поэтому мы используем дружественную перегрузку, которая определяет этот оператор вне класса, но позволяет ему иметь доступ к закрытым данным-членам в классе. Перегрузка потокового оператора использует структурированный формат, который далее показан для общего типа класса CL. class CL { ■ • • public: • • • friend istreamfi operator>>(istream& istr, CL& Variable); friend ostream& operator<<(ostream& ostr, const CL& Value); } Параметр istr представляет поток ввода, такой как cin, и ostr представляет поток вывода, такой как cout. Так как процесс ввода/вывода изменяет состояние потока, параметр должен передаваться по ссылке. Для ввода, элементу данных Variable присваивается значение из потока, поэтому оно передается по ссылке. Функция возвращает ссылку на istream, чтобы оператор мог использоваться в такой последовательности как cin » m » n; В случае вывода Value копируется в поток вывода. Так как данные не изменяются, Value передается как константная ссылка. Это позволяет избежать копирования возможно большого объекта по значению. Функция возвращает ссылку на ostream (output), чтобы оператор мог использоваться в такой последовательности как cout « m « n; Реализация операторов потока класса Rational Ввод и вывод рациональных чисел реализуется перегрузкой потоковых операторов. С вводом мы считываем число в форме P/Q, где Q*0. Программа завершается, если введенный деноминатор равен 0. // перегрузка оператора ввода потока, ввод в форме P/Q istreamfi operator >> (istreamb istr, Rationale r) { char с; // для чтения разделителя ' /' // как друг оператор ">>" имеет доступ
// к номинатору и деноминатору объекта г istr >> r.nura » с >> r.den; // проверка деноминатора на равенство нулю if (r.den === 0) I cerr « "Нулевой деноминатор!\n"; exit(1); ) // приведение объекта г к стандартной форме г.Standardize(); return istr; } Перегруженный потоковый оператор вывода записывает рациональное число в форме P/Q. // перегруженный оператор вывода потока, форма: P/Q ostream& operator « (ostream& ostr, const Rationalb r) { // как друг оператор "»" имеет доступ // к номинатору и деноминатору объекта г ostr << r.num << '/' << r.den; return ostr; } 6.6. Преобразование рациональных чисел Класс Rational иллюстрирует операторы преобразования типа. Программист может реализовать пользовательские операторы преобразования, аналогичные операторам, предусмотренным для стандартных типов. Конкретнее, мы сосредотачиваем внимание на преобразовании между объектами типа класс и соответствующего типа данными C++. Преобразование в объектный тип Конструктор класса может использоваться для построения объекта. Как таковой, конструктор принимает его параметры ввода и преобразует их в объект. Класс Rational имеет два конструктора, которые служат в качестве операторов преобразования типа. Первый конструктор преобразует целое в рациональное число, а второй преобразует число с плавающей точкой в рациональное число. При вызове конструктора с единственным целым параметром num он преобразует целое в эквивалентное рациональное num/1. Например, рассмотрим эти объявления и операторы: Rational P(7), 0(3,5), R, S; // явное преобразование 7 в 7/1 R - Rational(2); // явное преобразование 2 в 2/1 S = 5; // построить Rational(5) //и присвоить новый объект переменной S Объявления Q и R создают объекты Q=3/5 и R=0/1. Присваивание R = Rational(2) явно изменяет значение R на 2/1. Присваивание S = 5 приводит
к преобразованию типа. Компилятор принимает S = 5 как оператор S = Rational(5). // конструктор, рациональное число в форме num/den Rational::Rational(int p, int q): num(p), den(q) { if (den == 0) { cerr « "Нулевой деноминатор!" « endl; exit(1); } > Второй конструктор преобразует действительное число в рациональное. Например, следующие операторы создают рациональные числа А=3/2 и В=16/5: Rational А = Rational(1.5), В; // явное преобразование В = 3.2; // преобразовать 3.2 в Rational(1 б,5) Для преобразования необходим алгоритм, который аппроксимирует произвольное число с плавающей точкой в эквивалентное рациональное. Алгоритм включает сдвиг десятичной точки в числе с плавающей точкой. В зависимости от количества значащих цифр в действительном числе результат может быть только приближенным. После создания рационального числа вызываем функцию Reduce, сохраняющую рациональное число в более читабельной редуцированной форме. // конструктор, преобразует х типа double // к типу Rational Rational::Rational(double x) { double vail, val2; vail = 100000000L*x; val2 = 10000000L*x; num = long(vall-val2); den = 90000000L; Reduce(); } Преобразование из объектного типа Язык C++ определяет явные преобразования между простыми типами. Например, чтобы напечатать символ с в коде ASCII, мы можем использовать код: cout << int(с) << endl; // преобразовать char в int Между типами также определяются неявные преобразования. Например, если I — это целое long, a Y — это double, то оператор Y = I; //у = double (I) преобразует I в double и присваивает результат переменной Y. Класс может содержать одну или более функций-членов, которые преобразуют объект в значение другого типа данных. Для класса CL предположим, что нам необходимо преобразовать объект в тип с именем NewType. Оператор NewTypeQ принимает объект и возвращает значение типа NewType. Искомый тип New- Type часто является стандартным типом, таким как int или float. Являясь
унарным, оператор преобразования не содержит параметр. Так же, оператор преобразования не имеет возвращаемого типа, потому что он находится неявно в имени NewType. Объявление принимает форму: class CL { * • ■ • operator NewType(void); >; Оператор преобразования используется следующим образом: NewType a; CL obj; а - NewType(obj); //явное преобразование а » obj; //неявное преобразование Класс Rational содержит оператор, преобразующий объект в double. Этот оператор позволяет выполнять присваивание рациональных данных переменным с плавающей точкой. Преобразователь принимает рациональное число рЛь делит нумератор на деноминатор и возвращает результат как число с плавающей точкой. Например: 3/4 это 0.75 4/2 это 2.0 // преобразовать: Rational -> double Rational::operator double(void) const { return double(num)/den; } Пример 6.2 Пусть имеются объявления Rational R(l,2), S(3,5) double Y,Z; 1. Оператор Y = double(R) осуществляет явное использование преобразователя. Результат: Y = 0.5 2. Оператор Z = S осуществляет неявное преобразование к рациональному числу. Результат: Z = 0.6. 6.7. Использование рациональных чисел Перед тем, как разрабатывать приложения для рациональных чисел, опишем алгоритм для генерирования редуцированной формы дроби. Этот алгоритм включает нахождение наибольшего общего делителя (gcd) нумератора и деноминатора. Для создания редуцированной формы рационального числа метод Reduce использует закрытую функцию-член gcd, которая принимает два положительных целых параметра и возвращает наибольший из их общих делителей. Функция gcd реализуется в программном приложении. Для ненулевого рационального числа метод Reduce делит нумератор и деноминатор на их gcd:
void Rational::Reduce(void) { long bigdivisor, tempnumerator; // tempnumerator — модуль от num tempnumerator = (num < 0) ? -num : num; if (num == 0) den *= 1; // приведение к 0/1 else { // найти GCD положительных чисел: // tempnumerator и den bigdivisor = gcd(tempnumerator, den), if (bigdivisor > 1) { num /= bigdivisor; den /= bigdivisor; ) } ) Программа 6.1. Использование класса Rational Эта программа иллюстрирует основные возможности класса Rational. Здесь показаны различные методы преобразования, включая преобразование целого числа в рациональное и рационального числа в действительное. Демонстрируется также сложение, вычитание, умножение и деление рациональных чисел. Программа заканчивается неявным преобразованием числа с плавающей точкой в рациональное и наоборот — в число с плавающей точкой. Реализация класса Rational содержится в файле rational.h. ♦include <iostream.h> #pragma hdrstop tinclude "rational.h" // каждая операция сопровождается выводом void main(void) { Rational rl(5), r2, r3; float f; cout << "1. Rational-значение 5 is " << rl « endl; cout « "2. Введите рациональное число: "; cin >> rl; f = float(rl); cout << " Эквивалент с плавающей запятой: " « f « endl; cout « "3. Введите два рациональных числа: "; cin » rl » r2; cout « " Результаты: " « (rl+r2) « " ( + ) " « (rl-r2) « " (-) " « (rl*r2) « и (*) п « (rl/r2) « " (/) " « endl; if (rl < r2) cout « " Отношение (меньше чем) : " « rl « " < " « r2 « endl;
else if (rl == r2) cout « " Отношение (равно): " « rl « " == " << r2 « endl; else cout « " Отношение (больше чем) : " << rl « " > " « r2 « endl; cout << "4. Введите число с плавающей запятой: "; cin >> f; rl = f; cout « " Преобразование к Rational " << rl « endl; f = rl; cout « " Преобразование к float " « f « endl; } /* <Run of Program 6.1> 1. Rational-значение 5 is 5/1 2. Введите рациональное число: -4/5 Эквивалент с плавающей запятой: -0.8 3. Введите два рациональных числа: 1/2 -2/3 Результаты: -1/6 (+) 7/6 (-) -2/6 (*) -3/4 (/) Отношение (больше чем): 1/2 > -2/3 4. Введите число с плавающей запятой: 4.25 Преобразование к Rational 17/4 Преобразование к float 4.25 */ Приложение: Утилиты для рациональных чисел. Читатель научился работать с дробями еще в начальной школе. Для иллюстрации приложения класса Rational описывается ряд функций, выполняющих вычисления с дробями. Функция PrintMixedNumber записывает дробь как смешанное число: Дробь 10/4 -10/4 200/4 Смешанное число 2 1/2 -2 1/2 50 В алгебре основной задачей является решение общего уравнения дроби 2/ЗХ + 2 = 4/5 Процесс включает изоляцию члена X перестановкой 2 в правую часть уравнения: 2/ЗХ = -6/5 Решение получают, разделив обе части уравнения на рациональное число 2/3, коэффициент X. Реализуем этот процесс в функции SolveEquation. х = -6/5 * 3/2 = -18/10 = -9/5 (редуцированное)
Программа 6.2. Утилиты для рациональных чисел Программа использует ряд утилит для рациональных чисел при выполнении некоторых вычислений. PrintMixedNumber // печатать число как смешанное SoiveEquation // решить общее уравнение // (a/b)X + (c/d) - <e/f) Действие каждого оператора явно описывается в операторе вывода. #include <iostream.h> #include <stdlib.h> #pragma hdrstop #include "rational.h" // печатать Rational-число как смешанное: <+/-)N p/q void PrintMixedNumber (Rational x) { // целая часть Rational-числа х int wholepart * int(x.GetNumerator() / x.GetDenominator()); // сохранить дробную часть смешанного числа Rational fractionpart = х - Rational(wholepart); // если дробной части нет, печатать целую if (fractionpart ==* Rational (0)) cout << wholepart « " "; else { // вычислить дробную часть fractionpart.Reduce(); // печатать знак без целой части if (wholepart < 0) fractionpart ■ -fractionpart; if (wholepart !*= 0) cout « wholepart « " " « fractionpart « " "; else cout << fractionpart « " "; } } // решить ax + b - с, где a, b, с — рациональные числа Rational SoiveEquation(Rational a, Rational b, Rational c) { // проверить а на равенство нулю if (a « Rational(0)) { cout « "Уравнение не имеет решений." << endl; // возвратить Rational(0), если решений нет return Rational(0); } else return (c-b)/a; } void main(void) { Rational rl, r2, r3, ans; cout « "Введите коэффициенты для " "'a/b X + c/d = e/f : ";
cin » rl » r2 » r3; cout « "Приведенное уравнение: " « rl «"Xя " « (r3-r2) « endl; ans - SolveEquation(rl,r2,r3); ans.Reduce (); cout « "X » " « ans « endl; cout « "Решение как смешанное число: "; PrintMixedNumber(ans); cout « endl; > Л Оапуск 1 программы 6.2> Введите коэффициенты для 'a/b X + c/d « e/f : 2/3 2/1 4/5 Приведенное уравнение: 2/3 X - -6/5 X - -9/5 Решение как смешанное число: -1 4/5 Оапуск 2 программы 6.2> Введите коэффициенты для ' а/Ь X + c/d = e/f : 2/3 -7/8 -3/8 Приведенное уравнение: 2/3 X * 32/64 X = 3/4 Решение как смешанное число: 3/4 */ Письменные упражнения 6.1 C++ позволяет двум или более функциям в программе иметь одно и то же имя при условии, что их списки аргументов достаточно отличны друг от друга, чтобы компилятор мог различать вызовы функций. Компилятор оценивает параметры в вызывающем блоке и выбирает правильную функцию. Этот процесс называется перегрузкой функции (function overloading). Например, математическая библиотека C++ <math.h> определяет две версии функции sqr, которая возвращает квадрат ее аргумента. integer version: int sqr (long); //выбор целой версии float version: double sqr(double); //выбор версии действительного числа Ряд правил определяет правильную перегрузку операторов. Эти правила следующие: Правило 1: Функции должны иметь отдельный список параметров, независимый от возвращаемого типа, и значения по умолчанию. Правило 2: Перечислимые типы — это отдельные типы с целью перегрузки. Ключевое слово typedef не влияет на перегрузку. Правило 3: Если параметр не совпадает точно с формальным параметром в наборе перегружаемых функций, применяется алгоритм совпадения для определения "наиболее совпадающей" функции. Преобразование выполняется, если необходимо. Например, при передаче переменной short перегруженной функции, которая имеет целые параметры, ком-
пилятор может создавать совпадение, преобразуя эту переменную в int. Компилятор не выполняет преобразование параметра, если выбор приводит к неоднозначности. Для каждого из следующих примеров укажите правило, которое применимо. Если правило нарушается, опишите ошибку. (а) Предположим, что следующие сегменты кода используются для перегрузки функции f. <function 1> int f(int x, int у) { return x*y; ) <function 3 int f(int x=l, int y=7) { return x + у + x*y; } <function 2> double f(int x, int y) { return x*y; } (б) Функция max перегружается четырьмя отдельными определениями <function 1> int max(int x, int y) ( return x>y? x : y; ) <function 3> int шах(int x, int y, int z) { int lmax *x; if(y>lmax) lmax=»y; if (z > lmax) lmax - z; return lmax; } <function 2> double max(double x, double y) { return x>y? x : y; ) <function 4> int max (void) { int a, b; cin »a»b; return abs(a) > abs(b)? a : b; ) (в) Эти три версии функции read предназначены для различения ввода данных целого и перечисляемого типа. <function 1> void read(int& х) { cin»x; > <function 3> typedef int Logical; <function 2> enum Boolean{FALSE,TRUE}; void read(Boolean& x) ( char c; cin» c; x = (c«'T') ? TRUErFALSE; }
const int TRUE=1, FALSE^O; void read(Logical& x) { char c; cin>>c; X=(C«'T') ? TRUE:FALSE; } 6.2 Используйте функцию max из письменного упражнения 6.1(b) и предположите, что пользователь вводит значения т=-29 и п=8. Укажите, какая функция вызывается и какое будет возвращаемое значение. (a) cin » m » n; (б) max(); (в) max(m, -40, 30); max (m, n) ; 6.3 При использовании max из письменного упражнения 6.1(b) каков выход каждого из операторов? int а=5, Ь«99, с=153 int m,n; double hl= .01, h2= .05; long t = 3000, u= 70000, v= -100000; cout «"Максимум а и b равен "<<max(a,b); cout <<"Максимум a, b и с равен "<<ma:-;(a,b,c) ; cout «"1.0 + max (hi, h2) -"«1.0 + max (hi, h2); cout «"Максимум t, u и v равен "« max(t,u,v); 6.4 Являются ли следующие функции различными с целью перегрузки? Почему? (а) enum El{one, two}; (6) type def double scientific; enum E2{three,four); int f(int x, El y) ; double f(double x); int f(int x, E2 y); scientific f(scientific x); 6.5 Напишите перегруженные версии функции Swap, которая может принимать два int, float и string(char*) параметра. void Swap(int& a, int&b); void Swap(float& x, floats y); void Swap(char *s, char *t); 6.6 Объясните различие между перегрузкой оператора с использованием функции-члена и дружественной функции. 6.7 Класс ModClass имеет единственный целый данное-член dataval в диапазоне 0 . . .6. Конструктор принимает любое положительное целое v и присваивает данному-члену dataval остаток от деления на 7. dataval=v% 7 Оператор сложения складывает два объекта, суммируя их данные-члены и находя остаток после деления на 7. Например:
ModClass a(10)/ //dataval в а равен 3; ModClass b(6); //dataval в b равен 6; ModClass c; // c-a + b имеет значение (3+6) % 7 -2 class ModClass { private: int dataval; public: ModClass{int v * o) ; ModClass operator+(const ModClassS x); int GetValue(void) const; }; (а) Реализуйте методы этого класса. (б) Объявите и реализуйте оператор "*" как друга ModClass. Оператор умножает поля значений в двух объектах ModClass и находит остаток после деления на 7. (в) Напишите функцию ModClass Inverse(ModValue& x); которая принимает объект х с ненулевым значением и возвращает значение у, так что х*у=1 (у называется обратным значением х). (Совет: неоднократно умножайте х на объекты со значениями от1 до 6. Один из этих объектов является обратным). (г) Перегрузите потоковый вывод для ModClass и добавьте этот метод к классу. (д) Замените GetValue, перегрузив оператор преобразования int(). Этот оператор преобразует объект ModClass в целое, возвращая dataval. operator int (void); (е) Напишите функцию void Solve(ModClass a, ModClass& x, ModClass b); которая решает уравнение ах = b для х, вызывая метод Inverse. 6.8 Добавьте полный набор операторов отношения в класс Date из главы 3. Две даты необходимо сравнить по хронологии года. Например: Date(5,5,77) > Date(10,24,73) Date(12/25/44) <= Date(9,30,82) Date(3,5,99) !=Date(3,7,99) 6.9 Комплексное число имеет форму х + iy, где i2 = -1. Комплексные числа имеют широкое применение в математике, физике и технике. Они имеют арифметику, управляемую рядом правил, включающих следующие: Пусть u =a+ ib, v = с +id Величина (u) =sqrt(a2+b2) Комплексное число, соответствующее действительному числу f, равно f+iO
Вещественная часть u = a Мнимая часть u=sqrt(a2+b2) u + v = (а + с) + i(b + d) u - v = (а - с) 4- i(b - d) u*v = (ас - bd) + i(ad 4- be) ac + bd /be - adN u/v = — —- + l — —- c2 + d2 c2 + d2 v J -u = -a 4- i(-b) Реализуйте класс Complex, объявление которого является следующим: class Complex { private: double real; double imag; public: Complex (double x = 0.0, double у » 0.0); //бинарные операторы Complex operator* (Complex x) const; Complex operator- (Complex x) const; Complex operator* (Complex x) const; Complex operator/ (Complex x) const; //Отрицание Complex operator- (void) const; //Оператор потокового ввода/вывода //вывод в формате (real, imag) friend ostream& operator« (ostream& ostr,const Complex& x) ; }; 6.10 Добавьте методы GetReal и Getlmag в класс Complex из письменного упражнения 6.9. Они возвращают вещественную и мнимую части комплексного числа. Используйте эти методы для написания функции Distance, которая вычисляет расстояние между двумя комплексными числами. double Distance (const Complex &a, const Complex &b); 6.11 (а) Добавьте преобразователь в класс Rational, который возвращает объект ModClass. (б) Добавьте преобразователь в ModClass, который возвращает объект Rational 6.12 (а) Реализуйте класс Vec2d из раздела 6.1 (б) Добавьте функцию-член в класс Vec2d, который обеспечивает скалярное произведение, где операнд скаляра находится в правой части. Реализуйте оператор и как функцию-член, и как дружественную функцию.
Пример: Vec2d v(3,5); cout«v*2«" "<<2*v«endl; <output> (6,10) (6,10) 6.13 Множество — это коллекция объектов, которые выбраны из группы объектов, называемой универсальным множеством. Множество записывается как список, разделяемый запятыми и заключенный в фигурные скобки. X = {Ii, I2, 1з, . . . , Im} В данной задаче элементы множества выбраны из целых в диапазоне от 0 до 499. Класс поддерживает ряд бинарных операций для множеств X и Y. D Множество Union(isJ) XUY — это множество, содержащее все элементы в X и все элементы bY без дублирования. □ Множество Intersection(O) X О Y — это множество, содержащее все элементы, которые находятся и в X, и в Y. XUY ХПУ X - (0. 3. 20, 55}, Y - {4. 20. 45. 55} X U У - {0. 3. 4. 20. 45. 55} ХП Y - {20. 55} Множество Membership^) nGX является True, если элемент п является членом множества X; иначе оно равно False. X ={0, 3, 20, 55}//20 G X является True, 35 € X является False Спецификация класса Set В этом объявлении множество — это список элементов, выбранных из диапазона целых 0 ...SETSIZE -1, где SETSIZE является 500. Операции ввода/вывода, объединения, пересечения и принадлежности элементов определяют управление множеством. отьявлжниж const int SETSIZE » 500; const int False ■ 0, True ■ 1; class Set { private: // данные-члены класса set int member[SETSIZE]; public: // конструктор, создает пустое множество Set(void);
// конструктор, создает множество с начальными // элементами а[0], . . . , а[п-1] Set(int all, int n) ; // операторы объединения {+), пересечения (*), принадлежности (А) Set operator+ (Set x) const; Set operator* (Set x) const; friend int operator* (int elt, Set x); // методы вставки и удаления void Insert (int n); // добавить элемент п к множеству set void Delete(int); // удалить элемент п // операторы вывода Set friend ostreamfi operator« (ostream& ostr, const Sets x) ; >; ОПИСАНИЕ Первый конструктор просто создает пустое множество. Элементы добавляются во множество с использованием Insert. Второй конструктор присваивает п целых значений в массиве а этому множеству. Каждый элемент проверяется на нахождение в диапазоне. Элемент я находится во множестве, при условии, что соответствующий элемент массива является равным True. п€х если и только если элемент [п] является True Например, множество X ={1, 4, 5, 7} соответствует массиву с элементами member[l], member[4], member[5], member[7] равными True, и другими элементами, равными False. false true false false true true false true false false 0123456789 499 Потоковый оператор вывода должен печатать множества с элементами, разделяемыми запятыми и заключенными в фигурные скобки. Например: int setvalsf ] = {4, 7, 12}; Set S, T(setvals, 3); S.Insert(5); cout « S « endl; cout « T « endl; <output> {5} {4, 7, 12) (а) Реализуйте конструкторы Set. (б) Реализуйте функцию-член множества в ('"'). Возвращается True, если member[n] является равными True; иначе возвращается False. Так как приоритет оператора '"' является относительно низким, выражение, включающее '"' следует заключать в скобки для надежности. Например: Set A; • • * if ({0 Л А) == 0) //проверка на нахождение 0 во множестве А
(в) S — это множество {1, 4, 17, 25} и Т — это множество {1,8,25,33,53,63}. Выполните следующие операции: (I) S + Т (ii) S * Т (ш) 5^S (iv) 4* (S+T) (v) 25Л (S*T) (г) Укажите действие следующей последовательности операторов: int a[] * {1,2,3,5}; int b[] = {2,3,7,8,25}; int n; Set A(a,4), Bib,5), C; С = A+B; cout « C; С « A*B; ccut « C; cin >> n; // введите 55 A.Insert(n); if (пЛА) cout << Удачно « endl; (д) Реализуйте остальные методы Set Упражнения по программированию 6.1 Эта программа использует результат письменного упражнения 6.5. Напишите программу, которая берет два целых и два числа с плавающей точкой, печатает их значения и затем вызывает соответствующую функцию Swap для записи этих значений в обратном порядке. Эта же программа должна вводить две символьные строки, менять местами их значения с использованием Swap и печатать результирующие строки. 6.2 Эта программа тестирует класс ModClass, разработанный в письменном упражнении 6.7. Напишите программу для проверки дистрибутивного закона для объектов ModClass. а*(Ь+с) = а*Ь + а*с Определите три объекта a, b и с, имеющие параметры конструктора 10, 20 и 30, соответственно. Ваша программа должна выводить значение из выражений в каждой части уравнения. 6.3 Используйте ModClass и функцию Solve, разработанные в письменном упражнении 6.7. Сформируйте массив ModClass а[ ] = {ModClass(4), ModClass(10), ModClass(50)}; Программа должна выполнять цикл for(int i=0; i<3; i++) cout « a[i] « " " « int(a[i]) « endl; Используйте функцию Solve для печати решения уравнения ModClass (4) * х = ModClass(3) 6.4 Эта программа использует операторы класса Date из письменного упражнения 6.8. Напишите функцию Date Min(const Date& x, const Dates y);
которая возвращает более раннюю из двух дат. Определите четыре объекта от D1 до D4, которые соответствуют D1 — это 6/6/44 D2 - это день Нового года в 1999 D3 — это Рождество 197 6 D4 — это 4 июля 1976 Протестируйте функцию, сравнивая объекты D1 и D2, D3 и D4. 6.5 Рассмотрим следующие свойства векторов с двумя измерениями: (а) u*(v+w)=u*v + u*w (дистрибутивное свойство) (б) Два вектора перпендикулярны; их скалярное произведение равно 0. (в) c*v=v*c, где с — действительное число. Используя класс Vec2d, разработанный в письменном упражнении 6.12, дайте явные примеры, иллюстрирующие (а) и (с). Эта программа должна также считывать два действительных числа х и у и проверять, чтобы векторы (х, у) и (-у, х) были перпендикулярными. 6.6 Это упражнение использует класс Complex number, разработанный в письменном упражнении 6.9. Программа должна выполнять следующие действия: (а) Проверьте, чтобы -i2 = 1. (б) Напишите функцию f(z), оценивающую комплексную полиномиальную функцию: 2з _ 3z2 + 4z — 2 Вычислите полиномиал для следующих значений z: z = 2+3i, -1+i, 1+i, 1-i, l+0i Заметьте, что последние три значения являются корнями из f. 6.7 Используйте класс Rational и его операторы преобразования для выполнения следующего сравнения действительных и рациональных чисел. Объявите действительное число pi= 3.14159265 и приближение рационального числа Rational(22,7), которое часто используется студентами. Напишите программу, которая выполняет два следующих вычисления и печатает результаты. (а) Вычислите разность между двумя числами как рациональными числами. Rational(pi) — Rational(22,7) (б) Вычислите разность между двумя числами как действительными числами, pi — float(Rational(22,7)) 6.8 В главе 8 описывается класс экстенсивной строковой обработки, использующий указатели и динамическую память. Это упражнение разрабатывает простой строковый класс, который сохраняет данные, используя массив. Рассмотрим объявление Class String I private: char str[256]; public: String(char s[] « " ") int Length(void) const; // длина строки
void CString(char s[) const; // копировать строку в массив C++ // **********потоковый ввод/вывод *********** friend ostrearafi operator« (ostream& ostr, const Strings s); // Читать строки, разделенные пробелами friend istream& operator» (istream& istr, Strings s); // ******* оператор отношения: String « String ***** int operator-* (Strings s) const; // ***** объединение ***** String operator+ (Strings s) const; }; Реализуем класс и протестируем его, запуская следующую программу void main(void) { String SI("Это n), S2("прекрасный день!"), S3; char s[30]; if (SI ==String(3TO a)) cout «"Тестирование на равенство удачное" «endl; else cout «"Тестирование на равенство неудачное, "«endl; cout «"Длина Sl= "«SI. Length () « endl; cout «"Ввод строки S3: "; cin » S3; S3 « SI + S2; cout «"Конкатенация SI и S2 равна" « S3 « endl; S3. Cstring(s); cout «"Строка C++, сохраняемая в S3 следующая" « s «"' "« endl; } 6.9 Это упражнение тестирует класс Set из письменного упражнения 6.13. Рассмотрим множества S = {1,5,7,12,24,36,45,103,355,499} Т = {2,3,5,7,8,9,12,15,36,45,56,103,255,355,498} U - {1,2,3,4,5, ..., 50} Создайте множества S, Т и U. Используйте Insert для инициализации множества U. Выполните следующие вычисления: (1) Вычислите и печатайте S+T. (2) Вычислите и печатайте S*T. (3) Вычислите и печатайте S*U. (4) Удалите 8, 36, 103 и 498 из Т и печатайте это множество. (5) Генерируйте случайное число от 1 до 9, печатайте его и затем проверьте, находится ли оно во множестве Т. 6.10 В этом упражнении используется класс Set для моделирования вероятности получения пяти отдельных случайных чисел в диапазоне от 0 до 4 в пяти последовательных жеребьевках. Математическая вероятность этого события равна 1 • 4/5 • 3/5 • 2/5 • 1/5 - .0384 Напишите функцию
int fillSet(void); которая выполняет эксперимент жеребьевки пяти чисел и вставляет их во множество S. Протестируйте, находятся ли 0 ... 4 в этом множестве, проходя цикл пять раз и используя оператор ,Л\ Если все целые находятся в этом множестве, возвращайте 1, иначе возвращайте О, Напишите main-программу, вызывающую fillSet 100000 раз и записывающую количество случаев, когда выбираются все пять чисел. Разделите результат на 100000 для определения моделируемой вероятности.
гла в а 7 Параметризованные типы данных 7.1. Шаблонные функции 7.2. Шаблонные классы 7.3. Шаблонные классы списков 7.4. Вычисление инфиксных выражений Письменные упражнения Упражнения по программированию
Определения классов SeqList, Stack и Queue предназначены для родового (параметризованного) типа, называемого DataType. Перед использованием какого-либо класса клиент получает конкретный тип для DataType, используя директиву typedef. К сожалению, это ограничивает возможности клиента единственным типом с любым из классов. В приложении не может использоваться стек целых и стек записей в одной и той же программе. Так как использование родового DataType сильно ограничивает клиента, лучшим подходом было бы связать тип данных с объектом, а не с программой. Например: SeqList<int> А; //Список целых Stack<float> В; //Стек действительных чисел Queue<CL> С; //Очередь объектов CL C++ предоставляет такую возможность директивой шаблона (template), которая позволяет использовать параметры общего типа для функций и классов. Использование шаблонов с классом коллекций дает возможность определять родовые параметры и выполнять два или более вызовов функций с параметрами времени исполнения различных типов. Шаблоны обеспечивают мощным средством обобщения структуры данных. Мы излагаем эту тему в данной главе, сначала вводя шаблонные функции и затем расширяя эти понятия до шаблонных классов. Для приложений разрабатывается последовательный поиск как шаблонная функция и использование шаблонов для перезаписи класса Stack. Множественные стеки являются основной структурой данных в изложенном в этой главе практическом применении, описывающем вычисление инфиксных выражений. 7.1. Шаблонные функции Алгоритм часто предназначается для обработки некоторого диапазона типов данных. Например, алгоритм последовательного поиска принимает ключ и просматривает список элементов на предмет совпадения. Этот алгоритм подразумевает, что оператор отношения "==" определяется для некоторого типа данных и может использоваться для просмотра списка целых, символов или объектов. До этого момента в книге мы обсудили несколько приложений для алгоритма последовательного поиска. В каждом случае указывалась конкретная версия функции SeqSearch для типа элементов в списке. Мы уже создали множество версий этой функции для реализации того же самого родового алгоритма. Теперь нам хотелось бы написать родовой код, который может применяться с различными списками. C++ делает это возможным с помощью шаблонов, основные элемепты синтаксиса которых рассматриваются далее. Объявления шаблонной функции начинаются со списка параметров шаблона в форме template <class Tlt class T2, . . . class Tn> За ключевым словом template следует непустой список параметров типов, заключенный в угловые скобки. Типам предшествует ключевое слово class. Идентификатор Ti является общим именем для конкретного типа данных C++, которое передается как параметр при использовании шаблонной функции. Ключевое слово class присутствует только для указания на то, что имя Ti представляет собой тип. Вы можете читать class как "type". При использовании шаблона Ti может быть стандартным типом, таким как int, или определяемым пользователем типом, таким как класс. Например, в следующих списках параметров шаблона имена Т и U ссылаются на типы данных.
template <class T> //T - это тип template <class T, class U> //T и U оба являются типами После определения списка параметров шаблона функция следует обычному формату и имеет доступ ко всем типам в списке. Например, определение SeqSearch как шаблонной функции следующее: // используя ключ, ищет совпадение в массиве из п элементов. // если совпадение обнаружено, возвращает индекс совпадения; // иначе возвращает -1 template<class T> int SeqSearch(T list[ ], int n, T key) { for(int i*0; i<n; i++) if (listfi] -= key) return i; //возвращает индекс совпадающего элемента return -1; //поиск неудачный, возвращает -1 } Когда программа вызывает шаблонную функцию, компилятор указывает типы данных фактических параметров и связывает эти типы с элементами в списке параметров шаблона. Например, в вызове функции SeqSearch компилятор различает целые и действительные параметры, int А[10], Aindex, Mindex; float M[100], fkey « 4.5; Aindex = SeqSearch(A, 10, 25); //поиск 25 в А Mindex - SeqSearch(M, 100, fkey); //поиск fkey 4.5 в М Компилятор создает отдельный экземпляр функции SeqSearch для каждого отдельного списка параметров времени исполнения. В первом случае шаблонный тип Т будет целым (int), и SeqSearch просматривает список целых, используя оператор сравнения целых "==". Во втором случае параметр типа Т будет действительным (float), и используется оператор сравнения чисел с плавающей точкой == . При вызове основанной на шаблоне функции с конкретным типом все операции должны определяться для этого типа. Если функция использует операцию, не являющуюся присущей этому типу, программист должен предоставить свою версию этой операции или использовать нешаблонную версию такой функции, Например, C++ не определяет оператор сравнения "==" для stuct или класса. Параметризованная версия функции SeqSearch не может упорядочивать объекты класса, если только оператор не определяется (перегружается) пользователем. В качестве примера рассмотрим тип записи Student, включающий как целое поле, так и поле с плавающей точкой. Оператор "==" перегружается для применения функции SeqSearch. //запись о студенте, содержащая его ID и средний балл struct Student { int studID; float gpa; }; // перегружает ==, сравнивая id студента int operator ■■ (Student a, Student b) { return a.studID « b.studID; } Объявление записи Student и оператор "==и находятся в файле student.h.
C++ строковый тип char* создает пользователю некоторые проблемы. Оператор "==" сравнивает значения указателей, а не фактические строки посимвольно. Так как char* не является структурой или классом, мы не можем определять перегруженный оператор "==" для этого типа. Пользователь должен использовать нешаблонную версию функции SeqSearch, которая применима к строковому типу C++. // просмотреть массив строк для нахождения совпадения со строкой-ключом int SeqSearch(char *list[ ], int n, char *key) I for (int is=0;i<n;i++) // сравнить, используя строковую библиотечную функцию C++ if (strcmpdist [i], key) == 0) return i; // возвратить индекс совпадающей строки return -1; // при неудаче возвратить -1 } Главная тема этого раздела проиллюстрирована в программе параметризованного поиска. Код для шаблонной функции SeqSearch и специфическая версия для строк C++ содержится в файле utils.h. Программа 7.1. Параметризованный поиск Эта программа иллюстрирует последовательный поиск для трех отдельных типов данных. □ Массив list инициализируется 10-ю целыми значениями. Мы определяем индекс элемента массива со значением 8. D Перегрузка оператора используется с типом записи Student. Запись {1550, 0} используется как ключ для поиска в списке и определения ID студента. Возвращаемый индекс обеспечивает доступ к GPA 2,6. П Для массива строк компилятор использует нешаблонную функцию SeqSearch с типом данных "char*". Ведется поиск строки "two", которая находится в позиции с индексом 2. ♦include <iostream.h> ipragma hdrstop // включить код шаблонной функции SeqSearch и // специфической версии для строк C++ // search function specific to C++ strings #include "utils.h" // объявление структуры Student и операции "=-" для Student linclude "student.h" void main() { // три массива с различными типами данных int listriO] = {5, 9, 1, 3, 4, 8, 2, 0, 7, 6}; Student studlist[3] - {{1000, 3.4),{1555, 2.6},{1625, 3.8}}; char *strlist[5] = {"zero","one","two","three","four"}; int i, index; // ключ для поиска в массиве studlist Student studentKey * {1555t 0);
if ((i = SeqSearchdist, 10,8)) >= 0) cout « "Значение 8 находится в элементе: " « i « endl; else cout « "Элемент со значением 8 не найден" « endl; index - SeqSearch(studlist, 3, studentKey); cout « "Студент с ID, равным 1555, имеет GPA: " « studlist[index].gpa « endl; cout << "Строка 'two' — в элементе: " « SeqSearch(strlist,5,"two") « endl; > /* <Run of Program 7.1> Значение 8 находится в элементе: 5 Студент с ID, равным 1555, имеет GPA: 2.6 Строка 'two' — в элементе: 2 */ Сортировка на базе шаблона Обменная сортировка предоставляет алгоритм для упорядочения элементов в списке с использованием оператора сравнения "<". Этот алгоритм реализуется основанной на шаблоне функцией ExchangeSort, которая использует единственный параметр шаблона Т. Оператор "<" должен определяться для типа данных, который соответствует Т, или в качестве перегруженного оператора пользователя. // сортировка п элементов массива а с использованием обменной сортировки template <class T> void ExchangeSort(T a[], int n) { Т temp; int i, j/ // выполнить n-1 проходов for (i = 0; i < n-1; i++) // наименьшее из a[i+l]...a[n-1] поместить в a[ij for (j » i+1; j < n; j++) if (a[j] < a[i]) { // поменять значения элементов a[i] и a[j] temp ■ a[i]; a[i] = a[j]; a(j] = temp; } } Для удобства функция ExchangeSort находится в файле utils.h. 7.2. Шаблонные классы Обсудим основанный на шаблоне класс Store, содержащий значение данных, которое перед использованием класса должно инициализироваться. В процессе обсуждения мы проиллюстрируем главные концепции основанных на шаблоне классов.
Определение шаблонного класса При определении шаблонного класса (template class) его объявлению предшествует список параметров шаблона. Для объявления элементов данных и функций-членов в определении шаблона используются имена параметризиро- ванного типа. Далее следует объявление шаблонного класса Store: #include <iostream.h> ♦include <stdlib.h> template <class T> class Store { private: T item/ // объект, содержащий данные int haveValue; // флаг, устанавливаемый при инициализации public: // конструктор умолчания Store(void); // операции получения и сохранения данных Т GetElement(void); void PutElement(T x); >; Объявление объектов шаблонного класса Типы этому классу передаются при создании объекта. Объявление связывает тип с экземпляром класса. Следующие объявления создают объекты типа Store: //данное-член в X имеет тип int. Store<int> X; //создает массив из 10 объектов Store с данными char Stdre<char> S[10]; Определение методов шаблонного класса Метод шаблонного класса может быть определен как код in-line или вне тела класса. При внешнем определении метод должен рассматриваться как шаблонная функция со списком параметров шаблона, включенным в определение функции. Все ссылки на класс как тип данных должны включать шаблонные типы, заключенные в угловые скобки. Это применяется к имени класса с оператором области видимости класса: ClassName<T>:: Например, следующий код определяет функцию GetElement для класса Store: // получение элемента, если он инициализирован template <class T> Т Store<T>::GetElement(void) { // прервать программу при попытке доступа к неинициализированным данным if (haveValue *~ 0) { cerr « "Нет элемента!" « endl; exit(1); } return item; // возвратить элемент }
В методе Putltem подразумевается, что присваивание является правильной операцией для элементов типа Т: // сохранить элемент в памяти template <class T> void Store<T>::PutElement(const T&x) { haveValue+4; // haveValue = TRUE item = x; // сохранить х > Для внешнего определения конструктора используется имя класса с оператором области видимости класса и имя метода конструктора. В качестве типа класса используйте параметр шаблона. Например, типом класса является Store<T>, тогда как именем конструктора является просто Store. // объявление Оез инициализации элемента данных template<class T> Store<T>::Store(void):haveValue(0) { ) Объявление и реализация класса Store находится в файле store.h. Программа 7.2. Использование шаблонного класса Store Эта программа использует шаблонный класс Store для объектов типа integer, записи Student и объектов типа double. В первых двух случаях значения данным присваиваются с использованием PutElement и печатаются с использованием GetElement. Когда типом данных является double, делается попытка выборки значения данных, которое не инициализировано, и программа завершается. ♦include <iostream.h> ♦pragma hdrstop ♦include "store.h" ♦include "student.h" void main(void) { Student graduate » {1000, 23}; Store<int> A, B; Store<Student> S; Store<double> D; A.PutElement(3); B.PutElement(-7); cout « A.GetElement () « " " « B.GetElement () « endl; S.PutElement(graduate); cout « "ID студента: " « S.GetElement (). studID « endl; // D не инициализировано cout « "Получение объекта D: " << D.GetElement() « endl; ) Л <Run of Program 7.2>
3 -7 ID студента: 1000 Получение объекта D: Нет элемента! */ 7.3. Шаблонные списковые классы Мы расширяем возможности коллекций в этой книге, используя шаблонные классы. В данном разделе переопределяется класс Stack с шаблонами, который используется в разделе 7.4 для вычисления инфиксного выражения. Шаблонная версия этого класса включена в программное приложение в файл tstack.h. Переопределение класса Stack включает простую шаблонную технику. Начинаем объявление, помещая список параметров шаблона перед объявлением класса и заменяя DataType на Т. Спецификация шаблонного класса Stack ОБЪЯВЛЕНИЕ #include <iostreara.h> #include <stdlib.h> const int MaxStackSize = 50; template <class T> class Stack { private: // закрытые данные-члены T stacklist[MaxStackSize]; int top; public: // конструктор Stack(void); // стековые методы доступа void Push(const T& item); T Pop(void); T Peek(void); // методы тестирования и очистки int StackEmpty(void) const; int StackFull(void) const; void ClearStack(void); }; Реализация класса Stack, основанного на шаблоне Каждый метод класса определяется как внешняя шаблонная функция. Это требует помещения списка параметров шаблона перед каждой функцией и замены класса типа Stack на Stack<Т>. В фактическом определении функции мы должны заменить параметризованный тип DataType на шаблонный тип Т. Следующий листинг задает новое определение для методов Push и Pop.1 1 Данный код взят из программного приложения, так как он значительно отличается от приведенного в оригинале книги. — Прим. ред.
// constructor template <class T> Stack<T>::Stack(void) {} // uses the LinkedList method ClearList to clear the stack template <class T> void Stack<T>::ClearStack(void) { stackList.ClearList(); ) // use the LinkedList method InsertFront to push item template <class T> void Stack<T>::Push(const T& item) { stackList.InsertFront(item); } // use the LinkedList method DeleteFront to pop stack template <class T> T Stack<T>::Pop(void) { // check for an empty linked list if (stackList.ListEmptyO) { cerr « "Popping an empty stack" « endl; exit(l); } // delete first node and return its data value return stackList.DeleteFront(); } // returns the data value of the first first item on the stack template <class T> T Stack<T>::Peek(void) { // check for an empty linked list if (stackList.ListEmpty()) { cerr « "Calling Peek for an empty stack" « endl; exit(l); } // reset to the front of linked list and return value stackList.Reset(); return stackList.Data(); } // use the LinkedList method ListEmpty to test for empty stack template <class T> int Stack<T>::StackEmpty(void) const { return stackList.ListEmpty(); } 7.4. Вычисление инфиксного выражения В главе 5 показано использование стеков при вычислении выражений постфиксной или польской инверсной записи (Reverse Polish Notation, RPN). Тема вычисления инфиксных выражений была умышленно опущена, так как их реализация требует использования двух стеков: одного для операндов, и дру-
гого — для операторов. Поскольку в двух стеках находятся данные различных типов, при инфиксном вычислении эффективно используются шаблоны. В этом разделе разрабатывается алгоритм вычисления инфиксного выражения, который реализуется с шаблонным стековым классом. Вы знакомы с выражениями, объединяющими арифметические операции. Например, следующие выражения объединяют унарный оператор —, бинарные операторы +, -, *, /, скобки и операнды с плавающей точкой: 8.5 + 2 * 3 -7 * (4/3 - 6.25) + 9 Эти выражения используют инфиксную запись с бинарными операторами, расположенными между операндами. Пара скобок создает подвыражение, вычисляемое отдельно. В языках высокого уровня существует порядок выполнения операций (order of precedence) и ассоциативность (associativity) между операторами. Оператор с высоким приоритетом выполняется первым. Если более одного бинарного оператора имеют один и тот же приоритет, первым выполняется крайний слева оператор в случае левой ассоциативности (+, -, *, /) и крайний правый оператор — в случае правой ассоциативности (унарный плюс, унарный минус). Порядок выполнения операций (от низкого к высокому) 1 2 3 Оператор +. - •./ унарный плюс, унарный минус Пример 7.1 1. 8.5 + 2*3= 14.5 // * выполняется перед + 2. (8.5 + 2) * 3 = 31.5 // скобки создают подвыражение 3. 9 6 » 15 // унарный минус имеет высший приоритет Ранг выражения. Алгоритм для вычисления инфиксного выражения использует понятие ранга (rank), который присваивает значение -1, 0, или 1 каждому терму выражения: Ранг операнда с плавающей точкой равен 1 Ранг унарных операторов +, - равен О Ранг бинарных операторов +, -, *, / равен -1. Ранг левой скобки равен 0. Когда мы просматриваем термы в выражении, ранг определяет неправильно расположенные операнды или операторы, которые могут сделать выражение неверным. С каждым термом выражения мы ассоциируем суммарный ранг (cumulative rank), который является суммой ранга отдельных термов от первого символа до данного терма. Суммарный ранг используется для контроля за тем, чтобы каждый бинарный оператор имел два окружающих операнда и чтобы никакие операнды в выражении не существовали без инфиксного оператора. Например, в простом выражении 2 + 3 последовательные значения ранга такие:
Сканирование 2: суммарный ранг = 1 Сканирование +: суммарный ранг = 1 + -1 = О Сканирование 3: суммарный ранг = 1 Правило: Для каждого терма в выражении суммарный ранг выражения должен быть равен 0 или 1. Ранг полного выражения должен быть равен 1. Пример 7.2 Следующие выражения являются неверными, что определяется функцией rank: Выражение 1. 2.5А + 3 2. 2.5 + 1 3 3. 2.5 + 3 - Неверный ранг Ранг 4 = 2 Ранг * - -1 Конечный ранг = 0 Причина Слишком много последовательных операндов Слишком много последовательных операндов Нет одного операнда Алгоритм инфиксного выражения. Алгоритм инфиксного выражения использует стек операндов (operand stack) — стек значений с плавающей точкой для хранения операндов и результатов промежуточных вычислений. Второй стек, называемый стеком операторов (operator stack) содержит операторы и левые скобки и позволяет реализовать порядок приоритетов. При сканировании выражения термы помещаются в соответствующие стеки. Операнд помещается в стек операндов, когда он встречается в процессе сканирования и извлекается (popped) из стека, когда он необходим для операции. Оператор помещается в стек только тогда, когда уже были оценены все операторы с более высоким или равным приоритетом, и освобождается из стека, когда наступает время его выполнения. Это происходит при вводе последующего оператора с более низким или равным приоритетом или в конце выражения. Рассмотрим выражение 2 + 4 — 3*6 Ввод 2: Поместить 2 в стек операндов. Ввод +: Поместить + в стек операторов. Ввод 4: Поместить 4 в стек операндов. Операнд Оператор Ввод -: Оператор - имеет тот же порядок приоритетов, что и оцератор + в стеке. Сначала извлечь 4- из стека операторов, извлечь два операнда и выполнить операцию сложения. Результат (2 + 4 = 6) поместить обратно в стек операндов. Поместить - в стек операторов. Операнд Оператор
Ввод 3: Поместить 3 в стек операндов. Ввод *; Оператор * имеет более высокий приоритет, чем оператор — в стеке. Поместить * в стек операторов. Ввод 6: Поместить 6 в стек операндов. Операнд Оператор Выполнить: Извлечь * и выполнить операцию с операндами 6 и 3 из стека операндов. Поместить результат 18 в стек операндов. Операнд Оператор Извлечь из стека и выполнить операцию — с операндами 18 и 3 из стека операндов. 6 - 18 = -12. Это результат. Приоритет оператора определяется дважды: сначала при вводе оператора, затем, — когда он находится в стеке. Начальное значение, называемое входным приоритетом (input precedence), используется для сравнения относительной важности оператора с операторами в стеке. Как только оператор помещается в стек, ему задается новый приоритет, называемый стековым приоритетом (stack precedence). Различие между входным и стековым приоритетом используется для скобок и правых ассоциативных операторов. В этих случаях стековый приоритет меньше, чем входной приоритет. Когда обнаруживается оператор с тем же приоритетом и ассоциативностью, входной приоритет превышает стековый приоритет оператора в вершине стека. Первый оператор не извлекается, а новый оператор помещается в стек. Порядок вычисления задается справа налево. В таблице 7.1 приводится входной и стековый приоритет и ранг, используемые для вычисления инфиксного выражения. Эти операторы включают скобки и бинарные операторы Н-, -, *, и /. Бинарные операторы являются левыми ассоциативными и имеют равный входной и стековый приоритет. Алгоритм вычисления выражений становится немного сложнее при наличии скобок. Когда обнаруживается левая скобка, она представляет начало подвыражения и, следовательно, должна быть немедленно помещена в стек. Это выполняется присваиванием левой скобке входного приоритета, который больше приоритета любого из операторов. Если левая скобка помещена в стек, она может быть удалена только тогда, когда находится соответствующая ей правая скобка, и подвыражение вычисляется полностью. Приоритет левой скобки в стеке должен быть меньше, чем приоритет любого оператора, чтобы она не извлекалась из стека при вычислении всех термов в подвыражении.
Алгоритм. Алгоритм реализуется с двумя стеками. Стек операндов содержит числа с плавающей точкой, в то время как элементами стека операторов являются объекты класса типа MathOperator. Входной и стековый приоритет с рангом Таблица 7.1 Символ + - (бинарный) V ( ) Приоритет Входной приоритет 1 2 3 0 Стековый приоритет 1 2 -1 0 Ранг -1 -1 0 0 // класс, управляющий операторами в стеке- операторов class MathOperator { private: // оператор и два его значения приоритета char op; int inputprecedence; int stackprecedence; public: // конструктор; включает конструктор умолчания и // конструктор, инициализирующий объект MathOperator(void); MathOperator(char ch); // функции-члены для управления оператором в стеке int operators (MathOperator a) const; void Evaluate (Stack<float> &OperandStack); char GetOp(void); }.; Объект MathOperator сохраняет оператор и значения приоритета, связанные с этим оператором. Конструктор задает как входной, так и стековый приоритет оператора. MathOperator::MathOperator(char ch) { op = ch; switch(op) { // '+' и '-' имеют входной и стековый приоритет 1 case ' +' : case '-': inputprecedence = 1; stackprecedence = 1; break; // '*' и '/' имеют входной и стековый приоритет 2 case '*' : case '/': inputprecedence = 2; stackprecedence « 2; break; // ' (' имеет входной приоритет 3 и стековый приоритет -1 case ' С : inputprecedence = 3; stackprecedence = -1; break; // ')' имеет входной и стековый приоритет О
case ')' : inputprecedence « 0; stackprecedence «■ 0; break; } > Класс MathOperator перегружает C++ оператор ">", используемый для сравнения значений приоритетов. // перегрузить оператор >= сравнением стекового // приоритета текущего объекта и входного приоритета а. // используется при чтении оператора для определения // того, следует ли вычислять операторы из стека перед тем, // как поместить в него новый оператор int MathOperator::operator>= (MathOperator a) const < return stackprecedence >* a.inputprecedence; ) Этот класс содержит функцию-член Evaluate, которая отвечает за выполнение операций и извлекает два операнда. После выполнения операции результат помещается обратно в стек операндов. void MathOperator::Evaluate (Stack<float> &OperandStack) { float operandi = OperandStack.Pop(); // получить левый операнд float operand2 = OperandStack.Pop{); // получить правый операнд // выполнить оператор и поместить результат в стек switch (op) // выбрать операцию { case '+' : OperandStack.Push(operand2 + operandi); break; case '-': OperandStack.Push(operand2 - operandi); break; case '*': OperandStack.Push(operand2 * operandi); break; case '/': OperandStack.Push(operand2 / operandi); break; } } Алгоритм вычисления инфиксного выражения считывает каждый терм выражения, помещает его в соответствующий стек и обновляет суммарный ранг. Ввод завершается в конце выражения или, если ранг находится вне диапазона. Следующие правила применимы при считывании термов: Ввод операнда: Поместить операнд в стек операндов. Ввод оператора: Извлечь из стека все операторы, имеющие приоритет больший или равный входному приоритету текущего оператора. Выполнить сравнение, используя метод класса MathOperator ">=". Когда операторы будут удалены из стека, выполнить оператор, используя метод Evaluate. Ввод правой скобки "/' ' Извлечь и выполнить все операторы в стеке, имеющие стековый приоритет больший или равный входному приоритету скобки ")", который равен 0. Заметьте, что стековый приоритет скобки ")" равен -1, так что процесс останавливается, когда встречается скобка "(". Выполнением является вычисление всех операторов меясду скобками. Если никакой скобки "(" не обнаружено, выражение является неверным ( "нет левой скобки").
В конце выражения очистить стек операторов: Ранг должен быть 1. Если ранг меньше 1, это означает, что не хватает операнда. Если при очистке стека обнаруживается "(", то выражение является неверным ("нет правой скобки"). Когда операторы удаляются из стека, функция Evaluate выполняет каждое вычисление. Конечный результат выражения находят выборкой из стека операндов. Программа 7.3. Вычисление инфиксного выражения Данная программа иллюстрирует вычисление инфиксного выражения. Мы считываем каждый терм выражения, пропускаем все символы пробелов, пока не находим "в". Во время этого процесса выполняется проверка ошибок с печатью специальных сообщений об ошибках. После завершения ввода вычисляются оставшиеся термы выражения и его значение выводится для печати. ♦include <iostream.h> #include <stdlib.h> ♦include <ctype.h> // используется для функции isdigit ♦pragma hdrstop ♦include "tstack.h" ♦include "mathop.h" // проверить: оператор или скобка int isoperator<char ch) { if (ch — '+' || ch ~ '-' || ch — '*' || ch ~ '/' II ch -- ' (') return 1; else return 0; } // проверить: является ли символ пробелом int iswhitespace(char ch) { if (ch -- ' ' || ch — ' \t' II ch — '\n') return 1; else return 0; } // функция сообщений об ошибках void error(int n) { // таблица сообщений об ошибках static char *errormsgs[] ■ { "Отсутствует оператор", "Отсутствует операнд", "Нет левой скобки", "Нет правой скобки", "Неверный ввод" ); // параметр п - это индекс ошибки. // печатать сообщение и закочить программу cerr « errormsgs[n] « endl; exit(1); )
void main(void) { // объявить стек операторов с объектами типа MathOperator Stack<MathOperator> Operatorstack; // объявить стек операндов Stack<float> OperandStack; MathOperator oprl,opr2; int rank - 0; float number; char ch; // выполнять до знака '=' while (cin.get(ch) && ch !« '*=') i // ******** обработка операнда с плавающей точкой ******** if (isdigit(ch) || ch -- ' .' ) { // возвратить знак или '.' и читать число cin.putback(ch); cin >> number; // ранг операнда равен 1. суммарный ранг должен быть равен 1 rank++; if (rank > 1) error(OperatorExpected); // поместить операнд в стек операндов OperandStack.Push(number); > // ********* обработка оператора ********** else if (isoperator(ch)) { // ранг каждого оператора, отличного от 'С, равен -1. // суммарный ранг должен быть равен 0 if (ch !- ' (') // ранг ' (' равен 0 rank—; if (rank < 0) error(OperandExpected); oprl ■ MathOperator(ch); while(!OperatorStack.StackEmpty() && (opr2 * OperatorStack.PeekO ) >■= oprl) { opr2 - OperatorStack.PopO ; opr2.Evaluate(OperandStack); } OperatorStack.Push(oprl); ) // ********* обработка правой скобки ********** else if (ch « rightparenthesis) { oprl » MathOperator(ch); while(!OperatorStack.StackEmpty() && (opr2 * OperatorStack.PeekO) >« oprl) { opr2 = OperatorStack.PopO; opr2.Evaluate(OperandStack); ) if(OperatorStack.StackEmpty()) error(MissingLeftParenthesis); opr2 e OperatorStack.PopO; // get rid of ' (' } // ********* имеем неверный беод ********** else if (liswhitespace(ch))
error(Invalidlnput); } // ранг вычисленного выражения должен быть равен 1 if (rank != 1) error(OperandExpected); // заполнить стек операторов и завершить вычисление выражения, // если найдена левая скобка, а правая отсутствует, while (!OperatorStack.StackEmpty()) { oprl = OperatorStack.Pop(); if (oprl.GetOpO -= leftparenthesis) error(MissingRightParenthesis); oprl.Evaluate(OperandStack); } // значение выражения - в вершине стека операндов cout « "Значение равно: " « OperandStack.Pop() « endl; } /* Оапуск 1 программы 7.3> .2.5 + 6/3 * 4 - 3 - Значение равно: 7.5 Оапуск 2 программы 7. 3> (2 + 3.25) * 4 = Значение равно: 21 Оапуск 3 программы 7.3> (4 + 3) - 7) - Нет левой скобки */ Письменные упражнения 7.1 (а) Напишите код для параметризованной функции Мах, которая возвращает большее из двух значений. (б) Напишите перегруженную версию функции Мах, которая работает со строками C++. Передайте указатели символам как параметры и возвращайте указатель большей строке. 7.2 Напишите шаблонный класс DataStore, который имеет следующие методы: int insert (т eit); Вставляет elt в закрытый массив dataElements, имеющий пять элементов типа Т. Индекс следующей доступной позиции в dataElements задается данным-членом 1ос, который также является количеством значений данных в dataElements. Возвращает 0, если больше не остается места в dataElements. int Find(T eit); Выполняет поиск элемента elt в dataElement и возвращает его индекс, если он найден, и -1, если нет.
int NumEits(void); Возвращает количество элементов, сохраняемых в dataElements. т& GetDatadnt n) / Возвращает элемент в позиции п в dataElements. Генерирует сообщение об ошибке и выходит, если п<0 или п>4. 7.3 Напишите функцию template<class T> int Max(T Arr[ ], int n) ; которая возвращает индекс максимального значения в массиве. 7.4 Реализуйте функцию template<class T> int BinSearch(T A[ ], T key, int low, int high); которая выполняет бинарный поиск ключа в массиве А. 7.5 Напишите функцию template <class T> void InsertOrder(T A[ J, int n, T elem); которая вставляет elem в массив А, так что список сохраняет возрастающий порядок. Заметьте, что, когда позиция находится для elem, вы должны передвинуть все остальные элементы на одну позицию вправо. 7.6 (а) Дайте основанное на шаблоне объявление класса SeqList. (б) Для шаблонного класса реализуйте конструктор и методы DeleteFront и Insert. (в) Перед объявлением объекта SeqList<T> какие операторы должны определяться для типа Т? (г) Объявите S как объект Stack, в котором элементами стека являются объекты SeqList. Упражнения по программированию 7.1 Протестируйте функции Мах из письменного упражнения 7.1, используя их в main-программе. Включите в main-программу две строки C++. 7.2 Напишите шаблонную функцию Сору с объявлением template<class T> void Copy(T А[ ], Т В[ ], int n) ; Эта функция копирует п элементов из массива В в массив А. Напишите main-программу для тестирования функции Сору. Включите, по крайней мере, следующие массивы: (a) int Aint[6], Eint[6] = {1, 3, 5, 7, 9, 11}; (b) struct Student { int fieldl; double field2; ); Student AStudent[3]; Student BStudent[3] - {{1, 3.5}# {3,0}, {5, 5.5}};
7.3 В этом упражнении используется шаблонный класс DataStore, разработанный в письменном упражнении 7.2. Напишите перегруженный оператор "«", который печатает данные объекта DataStore, используя метод GetData. Пусть записью будет Person: struct Person { char name [50]; int age; int height; }; Перегрузите оператор "==" для Person так, чтобы он сравнивал поля имен. Напишите программу, которая вставляет элементы данных типа Person до тех пор, пока объект DataStore не будет заполненным. Включите следующий элемент р типа Person в ваш ввод: "John" 25 72 Выполните поиск р и затем печатайте результат поиска. Используйте "«" для вывода объекта. 7.4 Расширьте программу 7.3 для операции возведения в степень, которая представлена символом "~". Возведение в степень является правым ассоциативным. Например: 2 А 3 - 8 //23 - 8 2Л2Л3«2Л(2Л3)~ 256. Для вычисления аь включите математическую библиотеку <math.h> и используйте функцию pow следующим образом: ab - pow (a, b) 7.5 Во время последовательного поиска выполняется сканирование списка и поиск совпадения с ключом. Для каждого элемента у нас имеется двойная задача: проверка на совпадение и проверка достижения конца списка. Модифицированная версия поиска, называемая быстрый последовательный поиск (fast sequential search), совершенствует алгоритм, добавляя ключ в конец списка. Расширенный список гарантирует наличие совпадения, так как имеется по крайней мере один "ключевой" элемент в списке. Исходный список Ключ А[0] А[1] А{п-1] А[п] В процессе поиска просто выполняется тестирование на совпадение и поиск завершается, когда ключ найден в списке. (а) Напишите код для template <class T> int FastSeqSearch(T A[ ], int n, T key)/ (б) Перепишите программу 7.1, используя FastSeqSearch.
7.6 Mode — это значение, которое чаще всего наблюдается в списке. Чтобы собрать такую информацию для списка произвольных типов данных, определите класс Datalnfo, поддерживающий два поля данных: value и frequency. template <class T> class Datalnfo { private: T data; // значение данных int frequency; // появления значения данных public: // увеличение frequency void Increment(void); // операторы отношения =- и < должны // соответствовать типу Т // сравнение значений int operator— (const DataInfo<T>& w); // сравнение счетчиков частоты int operator< (const DataInfo<T>& w); // операторы потоков << и » должны // соответствовать типу Т friend istreamb operator» (istreamfi is, DataInfo<T> &w) ; friend ostream& operator« (ostreams os, DataInfo<T> &w); }; Этот класс имеет метод Increment, который увеличивает счетчик на 1, и потоковый выходной оператор, который выводит объект в формате "value:count". Запись с начальным значением и начальный счетчик 1 создается выполнением перегруженного потокового входного оператора "»", который считывает значение данных из входного потока. Эти операторы отношения добавляются, чтобы облегчить поиск определенного значения данных в списке объектов Datalnfo и сортировку списка в частотном порядке. (а) Реализуйте Datalnfo в файле datainfo.h. (б) Напишите main-программу, запрашивающую у пользователя количество элементов, которое будет введено в целый список. Когда этот размер будет известен, считайте фактические целые значения и создайте DataInfo<int> — массив с именем dataList. Используйте функцию SeqSearch, чтобы определить, находится ли значение ввода в массиве dataList. Возвращаемое значение -1 указывает на то, что новое значение было считано и, следовательно, должно быть сохранено в массиве dataList. Иначе, вводимый элемент уже имеется в массиве, и его частота должна обновляться. Завершите программу, вызывая ExchangeSort для сортировки dataList, и затем печатайте каждое значение и частоту. 7.7 Модифицируйте упражнение по программированию 7.6 так, чтобы список Datalnfo сохранял порядок, а для определения того, встречалось ли уже какое-либо значение, использовался бинарный поиск. Воспользуйтесь функциями, разработанными в письменных упражнениях 7.4 и 7.5. Конечно, нет необходимости выполнять сортировку.
глава 8 Классы и динамическая память 8.1. Указатели и динамические структуры данных 8.2. Динамически создаваемые объекты 8.3. Присваивание и инициализация 8.4. Надежные массивы 8.5. Класс String 8.6. Сопоставление с образцом 8.7. Целочисленные множества Письменные упражнения Упражнения по программированию
До этого момента в книге мы использовали только статические структуры данных (static data structures) для реализации классов коллекций. Обсуждение включало статические массивы для реализации классов SeqList, Stack и Queue. Использование статического массива имеет свои недостатки, так как его размер определяется на этапе компиляции и может быть изменен во время исполнения приложения. Каждый класс должен запрашивать приемлемо большое количество элементов, чтобы удовлетворять диапазону потребностей пользователя. Поэтому во многих приложениях некоторая часть памяти расходуется напрасно. В некоторых же случаях размер массива может оказаться недостаточным, и пользователь вынужден будет обращаться к исходному коду программы, чтобы увеличить размер массива и перекомпилировать программу. В этой главе мы знакомимся с динамическими структурами данных (dynamic data structures), которые используют память, полученную из системы во время исполнения. У нас имеется некоторый опыт распределения памяти при работе с блочной структурой программ. Компилятор распределяет глобальные данные и создает автоматические переменные, для которых при входе в блок выделяется ресурс памяти, а при выходе из блока — освобождается. Например, в следующей последовательности кода компилятор C++ распределяет глобальные данные, параметры и локальные переменные: int global =8; // переменная доступна для всей программы // резервирование памяти в системном стеке для параметров х и у void subtask (int x, long *y) { int z; // выделение ресурса для локальной переменной z ... // при выходе из блока ресурс памяти освобождается } Тип и размер каждой переменной известен во время компиляции. Компиляторы также предоставляют пользователю возможность создавать динамические данные. Оператор new выделяет ресурс памяти из динамической области для использования во время выполнения программы, а оператор delete возвращает ресурс памяти в динамическую область для последующего выделения. Динамические структуры данных находят важное применение в приложениях, потребности в памяти которых становятся известными только во время исполнения этих приложений. Использование динамических структур является основным в общем изучении коллекций и эффективно снимает ограничения на размеры, возникающие при объявлении статических коллекций. Например, в классе Stack его максимальный размер ограничивается параметром по умолчанию MaxStackSize, а от клиента требуется выполнение проверки условия заполненности стека перед добавлением (помещением) нового элемента. Динамическая память повышает возможности использования класса Stack, поскольку при этом класс запрашивает достаточный ресурс памяти для удовлетворения потребностей приложения. Приложения баз данных часто используют временную память для хранения таблиц и списков, которые создаются по запросу пользователя. Ресурс памяти может выделяться в ответ на запрос и освобождаться после выполнения запроса. Использование динамической памяти имеет ограничения и некоторые неудобства. Приложение может быть предназначено для работы с данными переменного размера, распределяемыми динамически, но при его выполнении может быть сделано достаточно запросов, чтобы в конце концов исчерпать имеющуюся в наличии память. В этом случае пользователь получит сообщение "Out of memory", а приложение может даже вынуждено завершиться. Такая проблема чаще всего возникает при выполнении приложения с графическим
интерфейсом, поскольку обычно диалоговая программа использует ряд окон для отображения данных, установки меню и так далее. Даже если программа хорошо структурирована, пользователь может иметь слишком много активных графических структур, из-за чего в конце концов прекратится выделение имеющейся памяти. При использовании динамической памяти мы должны понимать, что память — это ресурс, который должен эффективно управляться программистом. Память, выделяемая динамически, должна освобождаться, как только в ней отпадает необходимость. Компилятор следует этой политике при выделении памяти для параметров и локальных переменных. Программист должен следовать этой же политике, используя оператор delete. C++ предоставляет ряд методов для обработки динамических данных. Метод, называемый деструктором (destructor) удаляет динамическую память, зарезервированную объектом, при удалении этого объекта. Кроме того, класс может иметь конструктор копирования (copy constructor) и перегруженный оператор присваивания (overloaded assignment operator), которые используются для копирования или присваивания одного объекта другому. Эти методы класса находятся в центре внимания в данной главе. Для введения новых методов в большинстве примеров мы используем простой класс DynamicClass. Динамические массивы позволяют выделять блоки памяти во время исполнения приложений. Для большинства приложений при создании массивов мы знаем необходимые размеры. Однако в особых случаях нам может понадобиться расширить границы массива и изменить его размер. Для обеспечения этой возможности здесь разрабатывается класс Array, который создает списки произвольного размера и реализует границы массива, проверяя и изменяя размер списков. Этот класс предоставляет мощную структуру данных и иллюстрирует использование деструктора, конструктора копирования и перегруженного оператора присваивания. Мы перегружаем индексный оператор [ ], поскольку хотим, чтобы объект Array выглядел подобно стандартному массиву C++. Строки являются основной структурой данных. В действительности, некоторые языки определяют встроенный строковый тип. В этой главе разрабатывается и используется для решения задачи сопоставления с образцом полный класс String, который затем часто применяется в оставшихся главах книги. Множества (sets) очень часто используются в математической теории. В компьютерных приложениях множества являются мощной нелинейной структурой данных, применяемых в таких областях, как текстовый анализ, исправление орфографических ошибок и реализация графов и сетей. Класс Set, сохраняющий данные интегрального типа, разрабатывается с использованием битовых операторов C++. Этот подход обеспечивает превосходную эффективность использования памяти и времени исполнения. Класс Set применяется для реализации известного алгоритма при нахождении простых чисел, называемого решетом Эратосфена (Sieve of Eratosthenes). 8.1. Указатели и динамические структуры данных Указатели как структура данных вводятся в главе 2. В этом разделе переменные-указатели объединяются с операторами C++ new и delete для выделения и освобождения ресурса динамической памяти.
Оператор new для выделения памяти C++ использует оператор new для выделения ресурса памяти данным во время выполнения программы. "Зная" размер данных, оператор запрашивает у системы необходимое количество памяти для сохранения данных и возвращает указатель на начало выделенного участка. Если память не может быть выделена, оператор возвращает О (NULL). В следующем примере оператор new принимает тип данных Т в качестве параметра и резервирует память для переменной типа Т, возвращая адрес памяти. Т *р; // объявление р как указателя на T р = new T; // р указывает на только что // созданный объект типа Т Далее переменные ptrl и ptr2 указывают на данные типа int и long, соответственно. int *ptrl; // размером int является 2 long *ptr2; // размером long является 4 В следующем примере оператор new присваивает адрес int-переменной ptrl и адрес long-переменной ptr2. ptrl = new int; // ptrl указывает на целое в памяти ptr2 = new long; // ptr2 указывает на длинное целое б памяти Системная память целое длинное целое ptrl ptr2 Здесь, ptrl содержит адрес 2-х, a ptr2 — 4-х байтовых целых в памяти. По умолчанию содержимое памяти не имеет начального значения. Если такое значение необходимо, оно должно указываться в качестве параметра при использовании оператора new: р - new T(value); Например, операция ptr2 = new long(100000); резервирует память для длинного целого и присваивает ему значение 100000. Динамическое выделение массива Преимущества динамического выделения памяти особенно очевидны при запросе целого массива. Предположим, что в некотором приложении размер массива становится известен только во время исполнения приложения. Оператор new может резервировать память для массива, используя запись со скобками []. Пусть, р указывает на данные типа Т. Тогда оператор р = new Т [п] ; // выделение массива п элементов типа Т предписывает р указывать на первый элемент массива. Массив, созданный таким способом, не может быть инициализирован.
Пример 8.1 В следующем примере оператор new выделяет память для массива из 50 длинных целых при условии, что имеется достаточно памяти. Если указателю р присваивается значение NULL, оператору new не удалось выделить память и программа завершается. long *p; р * new long [50]; // выделить массив для 50 длинных целых if (p == NULL) { cerr << "Ошибка выделения памяти! "«endl; exit(l); // завершение программы > Оператор delete освобождения памяти Управление памятью является обязанностью программиста. В C++ имеется оператор delete для освобождения (возвращения в системный ресурс) памяти, предварительно выделенной оператором new. Синтаксис delete прост и основывается на том факте, что система времени исполнения C++ сохраняет информацию о каждом вызове оператора new. Предположим, р и q указывают на динамически выделяемую память: Т *р, *q; // р и q являются указателями на тип Т р = new T; // р указывает на один элемент q = new T[n]; // q указывает на массив п элементов Функция delete использует указатель для освобождения памяти. В случае освобождения массива delete применяется с оператором []. delete р; // освобождает память переменной, на которую указывает р delete [] q; // освобождает весь массив, // на который указывает q Пример 8.2 Оператор delete освобождает память, указателем на которую является р: long *p; р = new long[50]; // выделение массива для 50 длинных целых delete [] р; // освобождение памяти 50 длинных целых 8.2. Динамически создаваемые объекты Подобно любой переменной, объект типа класс может объявляться как статическая или создаваемая динамически (с использованием new) переменная. В каждом случае обычно вызывается конструктор для инициализации переменных и динамического выделения памяти для одного или более данных-членов. Синтаксис использования оператора new подобен синтаксису выделения памяти для простых типов и массивов. Оператор new выделяет память для объекта и инициирует вызов конструктора класса, если он существует. Конструктору передаются любые необходимые параметры. Для знакомства с динамическим созданием объектов используется основанный на шаблоне класс DynamicClass, имеющий статические и динамические
данные-члены. Далее следует объявление класса, методы которого разрабатываются в этом и в разделе 8.3. Класс DynamicClass находится в файле dynamic.h программного приложения: frinclude <iostream.h> template <class T> class DynamicClass { private: // переменная типа Т и указатель на тип Т Т member1; Т *member2; public: // конструкторы DynamicClass (const T& ml, const T& m2); DynamicClass(const DynamicClass<T>& obj); // деструктор ^DynamicClass(void)/ // оператор присваивания DynamicClass<T>& operator^ (const DynamicClass<T>& rhs); ); Этот простой класс с его двумя данными-членами иллюстрирует основное действие функций-членов в обработке динамически распределяемых объектов. Класс предназначен только для демонстрационных целей и не имеет реального применения. Конструктор этого класса использует параметр ml для инициализации статического данного-члена member 1. Для данного-члена member2 требуется выделение памяти типа Т и инициализация ее значением т2: // конструктор с параметрами для инициализации данного-члена template <class T> DynamicClass<T>::DynamicClass(const T& ml, const T& m2) { // параметр ml инициализирует статический член класса memberl = ml; // выделение динамической памяти и инициализация ее значением т2 member2 * new T(m2); cout «"Конструктор:" « memberl « '/' « *member2 « endl; } Пример 8.3 Следующие операторы определяют статическую переменную staticObj и указатель-переменную dynamicObj. Объект staticObj имеет параметры 1 и 100, которые инициализируют данные-члены: // объект типа DynamicClass DynamicClass<int> staticObj(1, 100); Объект, на который указывает dynamicObj, создается оператором new. Параметры 2 и 200 передаются конструктору в качестве параметров. При создании объекта *dynamicObj конструктор класса инициализирует данные-члены значениями 2 и 200: // переменная-указатель DynamicClass<int> *dynamicObj; // создание объекта dynamicObj - new DynamicClass<int> (2, 200);
Системная память 100 тетЬег1=1 member1=*2 member2 200 тетЬег2 dynamicObj staticObj Освобождение данных объекта: деструктор Рассмотрим функцию DestroyDemo, которая создает объект DynamicClass, имеющий целые данные. void DestroyDemo (int ml, int iti2) { DynamicClass<int> obj(ml, m2); } При возвращении из DestroyDemo объект obj уничтожается, однако, процесс не освобождает динамическую память, связанную с объектом. Эта ситуация показана на рис. 8.1. До удаления объекта obj Системная память После удаления объекта obj Системная память гл2 member1=m1 m2 member2 meW>er1=m1 \ тетмс2 Рис. 8.1. Необходимость использования деструктора Для эффективного управления памятью необходимо освобождать динамические данные объекта в то же самое время, когда уничтожается объект. Нам следует выполнить действие конструктора в обратном порядке, который первоначально распределял динамические данные. Язык C++ предоставляет функцию-член, называемую деструктором (destructor), которая вызывается при уничтожении объекта. Для DynamicClass деструктор имеет следующее объявление: -DynamicClass(void); Символ "~" представляет "дополнение", поэтому -DynamicClass — это дополнение конструктора. Деструктор никогда не имеет параметра или возвращаемого типа. В данном простом случае деструктор отвечает за освобождение динамических данных для member2: // деструктор.освобождает память, выделенную конструктором template<class T> DynamicClass<T>:: ^DynamicClass(void) { cout«"Деструктор:"« memberl «'/' « *member2 « endl; delete member2; }
Деструктор вызывается всякий раз при уничтожении какого-либо объекта. Когда программа завершается, все глобальные объекты или объекты, объявленные в main-программе, уничтожаются. Для локальных объектов, создаваемых внутри блока, деструктор вызывается при выходе программы из блока. Программа 8.1. Деструктор Эта программа иллюстрирует определение и использование деструктора. Программа тестирования включает три объекта. Obj_l является переменной, объявляемой в main-программе, a Obj__2 ссылается на динамический объект. В программу включена ранее обсуждавшаяся функция Destroy- Demo, которая объявляет локальный объект obj. На рис. 8.2 отмечены различные случаи использования конструкторов и деструкторов объектов. ( void DestroyDemo(int m1( int m2) DynamicClass<int> Obj (m1,m2) ц Конструктор для Obj (3300) | < Деструктор для Obj void main (void) DynamicClass<int>Obj _1(1,100),*Obj_2; < Конструктор для Obj J (1.100) Obj_2 - new DynamicClass<int>(2,200);« Конструктор для *Obj_2 (2.200) DestroyDemo(3,300); delete Obj_2; * Деструктор для Obj_2 | < Деструктор для Obj_1 Рис. 8.2. Инициализация для DynamicClass А(3,5), В = А #include <iostream.h> #pragma hdrstop linclude "dynamic.h" void DestroyDemo(int ml, int m2) { DynamicClass<int> obj(ml,m2); } void main(void) { // создать объект Obj_l с memberl=l и *member2=100 DynamicClass<int> Obj_l(1,100); // объявить указатель на объект DynamicClass<int> *Obj_2; // создать объект с memberl = 2 и *member2 = 200, //на который будет указывать Obj__2 Obj_2 = new DynamicClass<int>(2,200); // вызвать функцию DestroyObject с параметрами 3, 300
DestroyDemo(3,300); // полное удаление Obj_2 delete Obj_2; cout << "Программа готова к завершению" << endl; } /* <Выполнение программы 8.1> Конструктор: 1/100 Конструктор: 2/200 Конструктор: 3/300 Деструктор: 3/300 Деструктор: 2/200 Программа готова к завершению Деструктор: 1/100 */ 8.3. Присваивание и инициализация Присваивание и инициализация являются базовыми операциями, применяемыми к любому объекту. Присваивание Y = X приводит к побитовому копированию данных из объекта X в данные объекта Y. Инициализация создает новый объект, который является копией другого объекта. Эти операции показаны на примерах с объектами X и Y: // создать объекты X и Y типа DynamicClass // данные объекта Y инициализируются данными X DynamicClass Х(20, 50), Y = X; Y -X; // данные объекта Y переписываются из данных X Особое внимание необходимо уделить динамической памяти, чтобы избежать нежелательных ошибок. Мы должны создавать новые методы, управляющие присваиванием и инициализацией объектов. В этом разделе сначала обсуждаются потенциальные проблемы, а затем создаются новые методы класса. Проблемы присваивания Конструктор для DynamicClass инициализирует member 1 и выделяет динамические данные, на которые указывает member2. Например, в объявлении объектов А и В мы создаем два объекта и два связанных блока памяти с использованием оператора new. Системная память Системная память *A.member2 memberl member2 *B.member2 memberl member2 А В
Оператор присваивания В « А приводит к тому, что данные объекта А копируются в В. // копировать статические данные из А в В: // memberl объекта В ■ memberl объекта А // копировать указатель из А в В // member2 объекта В = meraber2 объекта А Так как значению указателя member2 в объекте В присваивается значение указателя member2 в объекте А, оба указателя теперь ссылаются на один и тот же участок памяти, а на динамическую память, первоначально присвоенную В, ссылки теперь нет. Предположим, что оператор присваивания появляется в функции F. void F(void) { DynamicClass<int> A<2,3), B(7,9); • * • В * А; // присваивание объекта А объекту В Неправильное присваивание: В=А *A.member2 *B.member2 member! I member2 memberl member2 При возвращении из функции F все объекты, созданные в блоке, уничтожаются вызовом деструктора класса, освобождающего динамическую память, на которую указывает member2. Предположим, объект В уничтожается первым. Деструктор освобождает память, на которую указывает В.member2 (и одновременно A.member2), При уничтожении объекта А вызывается его деструктор для освобождения памяти, связанной с указателем-переменной A. member2, но этот блок памяти ранее уже был освобожден при уничтожении B, поэтому использование операции delete в деструкторе для А является ошибкой! Во многих случаях это — фатальная ошибка. Проблема заключается в операторе присваивания В = А. Указатель member 2 в А копируется в указатель member2 в В. На самом деле мы хотим, чтобы содержимое, на которое указывает member2 из А, было скопировано в участок памяти, на который указывает member2 из В. Правильное присваивание: В=А *A.member2 копировать содержимое *B.member2 memberl member? memberl member2
Перегруженный оператор присваивания Для правильного выполнения присваивания объектов в случаях, когда это касается динамических данных, C++ позволяет перегружать оператор присваивания = как функцию-член. Синтаксис для перегруженного оператора присваивания в DynamicClass следующий: DynamicClass<T>& operator» (const DynamicClass<T>& rhs); Бинарный оператор реализуется как функция-член с параметром rhs, представляющим операнд в правой части оператора. Например В = А; Перегруженный оператор = выполняется для каждого оператора присваивания, включающего объекты типа DynamicClass. Вместо простого побитового копирования данных-членов из объекта А в В, перегруженный оператор отвечает за явное присваивание всех данных, включая закрытые и открытые данные-члены, а так же данные, на которые указывают эти члены. Параметр rhs передается по константной ссылке. Таким образом, мы избегаем копирования в этот параметр того, что могло бы быть большим объектом в правой части, и не допускаем никакого изменения объекта. Заметим также, что где бы ни использовалось имя шаблонного класса как типа, необходимо добавлять "<Т>" в конец имени класса. Для DynamicClass оператор = должен присваивать значение данных mem- berl объекта rhs значению данных memberl текущего объекта и копировать содержимое, на которое указывает member2 объекта rhs, в участок памяти, на который указывает member2 текущего объекта: // перегруженный оператор присваивания. // возвращает ссылку на текущий объект template<class T> DynaraicClass<T>& DynamicClass<T>::operator=»(const DynamicClass<T>& rhs) { // копирование статического данного-члена из rhs //в текущий объект memberl » rhs.memberl; // содержимое динамической памяти должно быть тем же, // что и содержимое rhs *member2 = *rhs,member2; cout «"Оператор присваивания:" <<memberl«' /' << *member2 << endl; return *this; } Зарезервированное слово this используется для возвращения ссылки на текущий объект и обсуждается в следующем разделе. Операторы, переносящие данные из объекта rhs в текущий объект, гарантируют правильное выполнение оператора присваивания В * А; Поскольку оператор = возвращает ссылку на текущий объект, мы можем эффективно связывать вместе два или более операторов присваивания. Например: С - В - А; // результат (В я А) присваивается С
Указатель this Каждый объект C++ имеет указатель с именем this, определяемый автоматически при создании объекта. Идентификатор является зарезервированным словом и может использоваться только внутри функции-члена класса. Он является указателем на текущий объект, a *this — это сам объект. Например, в объекте А типа DynamicClass // *this — это объект А; // this->memberl — это memberl, значение данных в А // this->member2 — это member2, указатель в А Для оператора присваивания возвращаемое значение является ссылочным параметром. Выражение "return *this" возвращает ссылку на текущий объект. Проблемы инициализации Инициализация объекта — это операция, создающая новый объект, который является копией другого объекта. Подобно присваиванию, когда объект имеет динамические данные, эта операция требует особую функцию-член, называемую конструктором копирования (copy constructor). Мы можем, забегая вперед, обсудить действие конструктора копирования на примере: DynamicClass<int> А{3,5), В * А; // инициализация объекта В данными объекта А Это объявление создает объект А, начальными данными которого являются member 1 = 3 и *member2 = 5, и объект В с двумя данными-членами, которые затем структурируются для сохранения тех же значений данных, которые помещены в А. Процесс инициализации должен включать копирование значения 3 из объекта А в member 1 объекта В, выделение памяти для данных, на которые указывает member2 объекта В, и затем копирование значения 5 из * A. member 2 в динамически распределяемые данные объекта В. Инициализация DynamicClass: В=А *A.member2 memberl member2 №3: копировать содержимое *B.member2 memberl member2 №2: выделить память для *member 2 №1: копировать содержимое Инициализация осуществляется не только при объявлении объектов, но и при передаче объекта функции в качестве параметра по значению, и при возвращении объекта в качестве значения функции. Например, предположим, что функция F имеет передаваемый по значению параметр X типа Dynamic- Class<int>. DynamicClass<int> F(DynamicClass<int> X) // параметр передаваемый //по значению { DynamicClass<int> obj; • • • • return obj; >
Когда вызывающий блок использует объект А как фактический параметр, локальный объект X создается копированием объекта А: DynamicClass<int> A(3,5), В(0,0); // объявление объектов В = F(A); // вызов F копированием А в X При выполнении возврата из F создается копия obj, вызываются деструкторы для локальных объектов X и obj и копия obj возвращается как значение функции. Создание конструктора копирования Чтобы правильно обращаться с классами, которые выделяют динамическую память, C++ предоставляет конструктор копирования для выделения динамической памяти новому объекту и инициализации его значений данных. Мы иллюстрируем эту идею, разрабатывая конструктор копирования для DynamicClass. Конструктор копирования является функцией-членом, которая объявляется с именем класса и одним параметром. Поскольку это — конструктор, он не имеет возвращаемого значения: DynamicClass(const DynamicClass <T>& X); // конструктор копирования Конструктор копирования DynamicClass копирует данные из memberl объекта X в текущий объект. Для динамических данных конструктор копирования выделяет память, на которую указывает member2, и инициализирует ее на значение содержимым *Х. member 2: // конструктор копирования. // инициализирует новый объект теми же данными, что и в X template <class T> DynamicClass<T>::DynamicClass(const DynamicClass<T>& X) { // копировать статический данное-член из X // в текущий объект memberl = X.memberl; // выделить динамическую память и инциализировать ее // значением *X.member2. member2 = new T(*X.member2); cout « "Конструктор копирования: " « memberl « '/' « *member2 « endl; } Если класс имеет конструктор копирования, этот конструктор используется компилятором всякий раз при выполнении инициализации. Конструктор копирования используется только тогда, когда создается объект. Несмотря на свое сходство, присваивание и инициализация являются, несомненно, различными операциями. Присваивание выполняется, когда объект в левой части уже существует. В случае инициализации создается новый объект копированием данных из существующего объекта. Более того, во время процесса инициализации перед копированием динамических данных для выделения памяти должен использоваться оператор new. Параметр в конструкторе копирования должен передаваться по ссылке. Невыполнение этого может привести к катастрофическим последствиям, если компилятор не распознает ошибку. Предположим, что мы объявляем конструктор копирования с передаваемым по значению параметром: DynamicClass(DynamicClass<T> X);
Конструктор копирования вызывается всякий раз, когда параметр функции указывается как передаваемый по значению. Предположим, что объект А в конструкторе копирования передается параметру X по значению. DynamicClassfDynamicClass X) Так как мы А передаем в X по значению, должен вызываться конструктор копирования для выполнения копирования А в X. Этот вызов, в свою очередь, нуждается в конструкторе копирования, и мы имеем бесконечную цепь вызовов конструктора копирования. К счастью, эта потенциальная проблема распознается компилятором, который указывает, что параметр должен передаваться по ссылке. Кроме того, ссылочный параметр X должен объявляться константным, так как мы определенно не хотим изменять объект, который копируем. Программа 8.2. Использование DynamicClass Эта программа иллюстрирует действие функций-членов DynamicClass с использованием целых данных. linclude <iostream.h> finclude "dynamic.h" template <class T> DynamicClass<int> Demo(DynamicClass<T> one, DynamicClass<T>& two, T m) { // вызов конструктора с (memberl»» m, *member2* m) DynamicClass<T> obj(m,m); // копирование для obj выполнено. // возвратить его как значение функции return obj; } void main() { /* A(3,5) вызывает конструктор с (member1=3, *member2=5) В « А вызывает конструктор копирования для инициализации В данными объекта А: (memberl=3, *member2-5) объект С вызывает конструктор с (memberl=0, *member2»0) */ DynamicClass<int> A(3, 5) , В ■= А, С(0,0); /* вызов функции Demo, конструктор копирования создает параметр one (member1=3, *member2-5) копированием из А. параметр two передается по ссылке, поэтому конструктор копирования не вызывается, при возращении создается копия локального объекта obj, который присваивается объекту С */ С * Demo (А, В, 5) ; // остальные объекты удаляются при выходе из программы }
/* < Выполнение программы 8.2> Конструктор: 3/5 Конструктор копирования: 3/5 Конструктор: 0/0 Конструктор копирования: 3/5 Конструктор: 5/5 Конструктор копирования: 5/5 Деструктор: 5/5564 Деструктор: 3/5556 Оператор присваивания: 5/5 Деструктор: 5/5 Деструктор: 5/5 Деструктор: 3/5 Деструктор: 3/5 */ 8.4. Надежные массивы Статический массив — это коллекция, содержащая фиксированное количество элементов, ссылка на которые выполняется индексным оператором. Статические массивы являются основной структурой данных для реализации списков. Несмотря на свою важность, статические массивы создают определенные проблемы. Их размер устанавливается во время компиляции и не может изменяться во время исполнения приложения. В ответ на ограничения, свойственные статическим массивам, мы создаем основанный на шаблоне класс Array, содержащий список последовательных элементов любого типа данных, размер которого может быть изменен во время выполнения приложения. Этот класс содержит методы, реализующие индексацию и преобразование типа указателя. Чтобы был возможен индексный доступ к элементам в списке, мы перегружаем индексный оператор (index operator) []. Более того, мы проверяем, чтобы каждый индекс соответствовал элементу в списке. Это свойство, называемое проверкой границ массива (array bounds checking), генерирует сообщение об ошибке, если индекс находится вне границ. Полученные в результате объекты называются надежными массивами (safe arrays), поскольку мы реагируем на неверные индексные ссылки. Для того, чтобы объект массива мог использоваться с функциями, принимающими стандартные параметры массива, мы определяем общий оператор преобразования указателя (pointer conversion operator) T*, связывающий объект Array с обычным массивом, элементы которого — это элементы типа Т. Класс Array Основанный на шаблоне класс Array поддерживает список элементов любого типа данных. 0 12 3 size-1 alist данные типа Т
Спецификация класса Array ОБЪЯВЛЕНИЕ #include <iostream.h> #include <stdlib.h> #ifndef NULL const int NULL = 0; #endif enum ErrorType {invalidArraySize, memoryAllocationError, indexOutOfRange}; char *errorMsg[] = { "Неверный размер массива", "Ошибка выделения памяти", "Неверный индекс: " }; template <class T> class Array { private: // динамически выделяемый список размером size Т* alist; int size; // метод обработки ошибок void Error(ErrorType error,int badlndex^O) const; public: // конструкторы и деструктор Array(int sz = 50); Array(const Array<T>& A); -Array(void); // присваивание, индексация и преобразование указателя Array<T>& operator= (const Array<T>& rhs); T& operator[](int i); operator T* (void) const; // операции с размером массива int ListSize(void) const; // читать size void Resize(int sz); // обновлять size }; ОБСУЖДЕНИЕ Использование перегруженного индексного ([]) и оператора преобразования позволяет объекту Array функционировать подобно обычному, определенному языком программирования, массиву. Оператор присваивания расширяет возможности массива, реализуя присваивание одного объекта Array другому. Для определенных же языком программирования массивов присваивание является неверной операцией. Метод Resize позволяет изменять размер списка. Если параметр sz больше, чем текущий размер массива (size), старый список сохраняется и к массиву добавляются дополнительные элементы. Если sz меньше, чем текущий размер, сохраняются первые sz элементов в массиве, остальные — удаляются. ПРИМЕР Array<int> A(20); // массив из 20 целых cout « A.SizeO; // вывод текущего размера 20
for(int i«=0/ i<20; i++) // доступ к массиву с использованием [] A[i] = i/ А[25] = 50; // неверный индекс A.Resize(30); // размер массива увеличивается на 30; А[25] = 50; // теперь верный индекс ExchangeSort(а, 30); // преобразование позволяет использовать // параметр Array Выделение памяти для класса Array В этом разделе показаны конструктор, деструктор и конструктор копирования, выполняющие необходимую проверку наличия ошибок. Конструктор класса выделяет динамический массив, элементы которого — это элементы типа Т. Начальный размер массива определяется параметром sz, который имеет значение по умолчанию 50: // конструктор template<class T> Array<T>::Array(int sz) { // проверка на наличие параметра неверного размера if(sz<= 0) Error(invalidArraySize); // присваивание размера и выделение памяти size = sz; alist e new T[size]; // убеждаемся в том, что система выделяет необходимую память, if (alist == NULL) Error(memoryAllocationError); } Деструктор освобождает память, выделенную для массива alist: // деструктор template<class T> Array<T>::-Array(void) { delete [] alist; } Конструктор копирования делает возможными операции, которые недоступны для определенных языком программирования массивов, позволяя инициализировать один массив элементами другого массива (X) и передавать объект Array какой-либо функции по значению. Для этого извлекается размер объекта X, выделяется соответствующий объем динамической памяти и копируются элементы объекта X в текущий объект. текущий объект alist объект X X.alist X.size -1 // конструктор копирования template <class T> Array<T>::Array(const Array<T>& X) {
// получить размер объекта X и присвоить текущему объекту int n - X.size; size - n; // выделить новую память для объекта с проверкой // возможных ошибок alist - new T[n]; // динамически созданный массив if (alist — NULL) Error(memory^llocationError); // копировать элементы массива объекта X в текущий объект Т* srcptr ■ X.alist; // адрес начала X.alist Т* destptr « alist; // адрес начала alist while (n—)// копировать список *destptr++ - *srcptr++; } Проверка фаниц массива и перегруженный оператор [] Ссылка на индекс массива выполняется с использованием оператора [] и появляется в выражении, имеющем форму Р[п) где Р — это указатель на тип Т, а п — это целое выражение. Фактически, это — оператор, называемый в языке C++ оператором индексации массива (array indexing operator). Этот оператор имеет два операнда (Р и п) и возвращает ссылку на данные в позиции Р + п. Р[п] Р[0] Р[1] Р Р+1 Р+п Оператор может быть перегруженным только как функция-член и обеспечивает индексированный доступ к данным объекта. Мы перегружаем индексный оператор для класса Array. Предположим, что А — это объект Array целого типа. Доступ к элементам массива получаем использованием записи А[п]. Например, оператор А[0] - 5 присваивает значение 5 первому элементу в массиве (alist[0] = 5). Объявление функции-члена [ ] принимает форму Т& operator [ ] (int n) ; где Т — это тип данных, хранящихся в объекте, an — это индекс. Тот факт, что перегруженный оператор [] возвращает ссылочный параметр, означает, что оператор индексации может находиться в левой части оператора присваивания. value « А[п]; // переменной value присваивается А[п] A[n] « value/ // элементу п массва А присваивается // значение value Для класса Array перегруженный оператор индексации предоставляет доступ к надежному массиву, проверяя, находится ли индекс п в диапазоне индексов массива (от 0 до size-1). Если он не находится в этом диапазоне,
выводится сообщение об ошибке и программа завершается. Иначе оператор возвращает значение alist[n]. // перегруженный индексный оператор template<class T> Т& Array<T>;:operator[ ] (int n) { // выполнение проверки границ массива if (n<0 n>size-l) Error(indexOutOfRange,n); // возвращается элемент из закрытого списка массива return alist[n]; ) Преобразование объекта в указатель Преобразованием указателя пользователь получает возможность использовать объект Array как параметр времени исполнения в любой функции, определяющей обычный массив. Это выполняется перегрузкой оператора преобразования Т*(), которая преобразует объект в указатель. В данном случае мы преобразуем объект Array в начальный адрес массива alist. size alist Объект А Например, функции ExchangeSort и SeqSearch принимают параметр массива. Для объекта А шаблонного целого типа следующие операторы функции являются верными: //сортировать A (size () элементов) ExchangeSort(A,A.size О)/ // поиск ключа в А index « SeqSearch(A,A.size(), int key); В вызове функции объект А передается формальному параметру Т*агг. Объявление; ExchanqeSort(T*arr,int n) / / Вызов: ExchangeSort (A,A.sizeO); Это приводит к выполнению оператора преобразования и присваиванию указателя alist переменной агт. Переменная агг указывает на массив объекта А: // оператор преобразования указателя template<class T> Array<T>::operator T*(void) const { // возвращает адрес закрытого массива в текущем объекте return alist; } Операторы изменения размера. Класс Array предоставляет метод List Size, возвращающий текущее количество элементов в массиве. Более динамичным является метод Resize, который изменяет количество элементов массива в
объекте. Если требуемое количество элементов sz равно размеру текущего объекта, выполняется простой возврат; иначе выделяется новое пространство памяти. Если размер списка уменьшается (sz<size), первое sz количество элементов копируется в новый массив. Уменьшение размера новый list старый list sz sz -1 X.size -1 Если мы увеличиваем размер массива, старые элементы копируются в новый список и в наличии остается некоторая неиспользованная часть списка. В каждом из двух случаев память для старого массива удаляется. Увеличение размера новый list старый list X.size -1 srcptr = &X.alist[size] // оператор изменения размера (resize-рператор) template <class T> void Array<T>::Resize(int sz) { // проверка нового размера массива; // выход из программы при sz <= О if (sz <= 0) Error(invalidArraySize); // ничего не делать, если размер не изменился if (sz « size) return; // запросить память для нового массива и проверить ответ системы Т* newlist = new T[sz]; if (newlist == NULL) Error(memoryAllocationError); // объявить п и инициализировать значением sz или size int n = (sz <= size) ? sz : size; // копировать п элементов массива из старой в новую память Т* srcptr = alist; // адрес начала alist Т* destptr = newlist; // адрес начала newlist while (n—) // копировать список *destptr++ = *srcptr++;
// удалить старый список delete[] alist; // переустановить alist, чтобы он указывал на newlist // и обновить член класса size alist - newlist; size = sz; } Использование класса Array Реализация класса Array иллюстрирует большинство идей этой главы. Пользователь может использовать класс Array вместо определенных языком программирования массивов и пользоваться преимуществами надежности и гибкости, обеспечиваемыми возможностью изменения размера. Программа 8.3. Изменение размера массива Пусть Array-объект А определяет список из 10 целых, в котором мы сохраняем простые числа: Определение: Простое число — это положительное целое 2, которое делится только на себя и на 1. Данная программа определяет все простые числа в диапазоне 2..N, где N — это предоставляемая пользователем верхняя граница. Так как мы не можем заранее установить размер массива на необходимый, программа проверяет условие "список полный", сравнивая текущее количество простых чисел (primecount) с размером массива. Когда список полный, мы изменяем размер списка и добавляем еще 10 элементов. Программа завершается печатью списка простых чисел по 10 в строке. #include <iostream.h> #include <iomanip.h> #pragma hdrstop #include "array.h" void main(void) { // начальный размер массива А равен 10 Array<int> A(10); // пользователь задает верхнюю границу диапазона поиска int upperlimit, primecount = 0, i, j; cout « "Введите число >= 2 как верхную границу диапазона: "; cin » upperlimit; A[primecount++] -2; // 2 — простое число for(i = 3; i < upperlimit; i++) { // если список простых чисел полный, добавить к нему еще 10 элементов if (primecount == A.ListSize ()) A.Resize(primecount + 10); // четные числа > 2 — непростые. // перейти к следующей итерации
if (i % 2 «■ 0) continue; // проверить нечетные делители 3,5,7,... до i/2 J - 3; while (j <= i/2 && i % j !- 0) j +- 2; // i - простое, если не делится на 3,5,7,... до i/2 if (j > i/2) A[primecount++] - i; } for (i я 0; i < primecount; i++) { cout « setw(5) « A[i]; // вывести новую строку из 10 простых чисел if (U+1) % 10 « 0) cout « endl; } cout « endl; } /* <Выполнение программы 8.3> Введите число >= 2 как верхную границу диапазона: 100 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 */ 8.5. Класс String Строки являются основным компонентом многих нечисловых алгоритмов. Они используются в таких областях, как сопоставление с образцом, компиляция языков программирования и обработка данных. По существу, полезно иметь строковый тип данных, который инкапсулирует разнообразные операции обработки строк и делает возможными расширения. Строковые переменные C++ являются массивами символов с нулевым символом в конце. Каждая система программирования C++ предоставляет библиотеку функций в <string.h> для сопровождения операций обработки строк. В руках опытного программиста функции являются мощным средством реализации эффективных строковых алгоритмов. Однако для многих приложений функции являются чем-то техническим и неудобным для использования. Некоторые языки программирования, подобные языку BASIC, определяют операторы для обработки строк. Например, строковая переменная BASIC заканчивается символом "$и и поддерживает присваивание оператором = и сравнение строк оператором <. Строковый тип является частью определения языка BASIC: NAME$ *» JOE // присваивание IF NAME$ < STUDENTPRES$ THEN ... // сравнение
Для некоторых языков программирования компиляторы предоставляют расширения, которые обеспечивают возможность улучшенной обработки строк. Большинство программистов C++ хотели бы получить такое расширение для более гибкого доступа к строкам. В этом разделе описывается класс String, определяющий строковый тип и предоставляющий мощный набор строковых методов. Объекты используют динамическую память для сохранения строк переменной длины и перегруженные операторы для создания строковых выражений. Класс String предоставляет пользователю альтернативный строковый тип и тем не менее обеспечивает полное взаимодействие со стандартными строками C++ (C++String). Класс String используется в последующих главах этой книги, и вы найдете достойной его простоту. Для иллюстрации использования класса String рассмотрим задачу сравнения и присваивания строк S и Т. Следующие операторы сопоставляют использование библиотеки функций C++ и класса String. Мы присваиваем меньшую строку переменной firststring. Решение строковой библиотеки C++ Решение класса String if ( strcmpt S, T ) < 0 ) firststring = ( S < Т ) ? S : Т; strcpyt firststring, S ); else strcpyt firststring, T ); Этот раздел включает полный листинг объявления класса String. Здесь обсуждается реализация избранных методов и предоставляется программа тестирования. Полный листинг класса String находится в файле strclass.h программного приложения. Раздел 8.6 знакомит с алгоритмом сопоставления с образцом, который широко используется в классе String. Спецификация класса String ОБЪЯВЛЕНИЕ #ifndef STRING_CLASS #define STRING__CLASS #include <iostream.h> #include <string.h> ♦include <stdlib.h> Hfndef NULL const int NULL - 0; #endif // NULL const int outOfMemory « 0, indexError я 1; class String { private: // указатель на динамически создаваемую строку. // длина строки включает NULL-символ char *str; int size; // функция сообщения об ошибках void Error(int errorType, int badlndex = 0) const; public: // конструкторы String(char *s - "");
String(const Strings s) ; // деструктор -String(void); // операторы присваивания // String = String, String = O+String Strings operator= (const Strings s); Strings operator= (char *s); // операторы отношений // String—String, String==C++String, C++String==String int operator== (const Strings s) const; int operator™ (char *s) const; friend int operator== (char *str, const Strings s); // String!=String, String!=C++String, C++String!=String int operator!= (const Strings s) const; int operator!= (char *s) const; friend int operator!3 (char *str, const Strings s); // String<String, String<C++String, C++String<String int operator< (const Strings s) const; int operator< (char *s) const; friend int operator< (char *str, const Strings s); // String<=String, String<=C++String, C++String<=String int operator<= (const Strings s) const; int operator<= (char *s) const; friend int operator<= (char *str, const Strings s); // String>String, String>C++String, C++String>String int operator> (const Strings s) const; int operator> (char *s) const; friend int operator> (char *str, const Strings s); // String>=String, String>=C++String, C++String>=String int operator>~ (const Strings s) const; int operator>= (char *s) const; friend int operator>= (char *str, const Strings s); // операторы String-конкатенации // String+String, String+C++String, C++String+String // String += String, String += C++String String operator+ (const Strings s) const; String operator+ (char *s) const; friend String operator+ (char *str,const Strings s); void operator+= (const Strings s); void operator+= (char *s); // String-функции // начиная с первого индекса, найти положение символа с int Find(char с, int start) const; // найти последнее вхождение символа с int FindLast(char с) const; // выделение подстроки String Substr(int index, int count) const; // вставить объект String объект String void Insert(const Strings s, int index); // вставить строку типа C++String в строку типа String void Insert(char *s, int index); // удалить подстроку void Remove(int index, int count);
// String-индексация chars operator[] (int n); // преобразовать String в C++String operator char* (void) const; // String-ввод/вывод friend ostream& operator« (ostream& ostr, const Strings s); friend istream& operator>> (istreams istr, Strings s); // читать символы до разделителя int ReadString{istreams is=cin, char delimiter='\n'); // дополнительные методы int Length(void) const; int IsEmpty(void) const; void Clear(void); }; ОПИСАНИЕ Объекты класса String могут взаимодействовать со строками C++ (char*). Например, следующие три функции предназначены для конкатенации строковых переменных с использованием оператора +: // String + String String operator* ( const Strings s ); // String + C++String String operator* ( char *s ) ; // C++String + String friend String operator* ( char *str,const Strings s ); Класс String имеет деструктор, конструктор копирования и два перегруженных оператора присваивания, позволяющие пользователю присваивать объект типа String или строку C++ новому объекту String: String S (Hello ) , T = S, R; // T = Hello , R - NULL-строка R = "World!"; Класс реализует разнообразные операторы конкатенадии строк, включая оператор += для конкатенации строки в текущую строку: R = Т + "World!"; // R = "Hello World!" R +« ; R += S; // R = "Hello World! Hello " Ряд операторов сравнения используют упорядочение ASCII для сравнения двух строк: string U("Smith"), V("Smithsonian"), W("Thomas"); if (U>= V) . . . // FALSE if (W « "Thomas") ... // TRUE if ("Tom" !=W) . . . // TRUE Класс String предоставляет несколько мощных и полезных строковых операций, включающих возможность поиска определенного символа в строке, извлечение подстроки, вставку одной строки в другую и удаление подстроки. Для каждого символа в строке имеется индексный доступ как для простого символьного массива. int sindex; String V(Smithsonian); // поиск 's', начиная с позиции с индексом О
sindex - V.FindCs' ,0) ; R - V.Substr(sindex,3); // R - "son" V.Remove (sindex, 6); // V - "Smith" R[0] - 'S'; // R - "Son" R.Insert("ilvert", 1); // R - "Silverton" Оператор ввода » использует пробел для разделения строкового ввода: cin » S » Т » R; <Ввод> Separate by blanks S ="Separate" T * "by"R * "blanks" Метод ReadString считывает символы до ограничительного символа, который заменяется на NULL-символ: R.ReadString(cin); <Ввод> The fox leaped over the big brown dog<newline> R = "The fox leaped over the big brown dog" Программа 8.4. Использование класса String Эта программа иллюстрирует избранные методы класса String. Каждая операция включает оператор вывода, описывающий ее действие. #pragma hdrstop #include "strclass.h" #define TF(b) ((b) ? "TRUE" : "FALSE") void main(void) { String si("STRING "), s2("CLASS"); String s3; int i; char c, cstr[30]; s3 * si + s2; cout « si « "объединена с " « s2 « " = " « s3 « endl; cout « "Длина " « s2 « " = " « s2.Length0 « endl; cout << "Первое вхождение 'S' в " « s2 « " =» " « s2.Find('S' ,0) «endl; cout « "Последнее вхождение 'S' в " « s2 « " — " « s2.FindLast('S') « endl; cout « "Вставить 'OBJECT ' в s3 в позицию 7." « endl; s3.Insert("OBJECT ",1); cout « s3 « endl; si * "FILE1.S"; for(i=0;i < sl.LengthO;i++) { с «■ s 1 [ i ]; if (c >= 'A' && с <= 'Z' ) { с +- 32; // преобразовать в нижний регистр si[i] « с; } ) cout « "Строка 'FILE1.S' преобразована в нижний регистр: ";
cout « si « endl; cout « "Тестирование операций отношения: "; cout « "si » 'ABCDE' s2 = '3CF'" « endl; si » "ABCDE"; s2 - "BCF"; cout « "si < s2 - " « TF(sl < s2) « endl; cout « "si »= s2 - " « TF(sl ** s2) « endl; cout « "Используйте 'operator char* ()' для получения si" " как строки C++: "; strcpy(cstr,si); cout « cstr « endl; } /* ^Выполнение программы 8.4> STRING объединена с CLASS = STRING CLASS Длина CLASS = 5 Первое вхождение 'S' в CLASS = 3 Последнее вхождение 'S' в CLASS — 4 Вставить 'OBJECT ' в s3 в позицию 7. STRING OBJECT CLASS Строка 'FILE1.S' преобразована в нижний регистр: filel.s Тестирование операций отношения: si = 'ABCDE' s2 - 'BCF' si < s2 - TRUE si « s2 - FALSE Используйте 'operator char* О' для получения si как строки C++: ABCDE */ Реализация класса String Данный раздел содержит обзор реализации класса String. Данными-членами являются переменная-указатель str, содержащая адрес строки с нулевым завершающим символом, и size, содержащая длину строки + 1, где дополнительный байт обычно хранит символ NULL. Значение size, таким образом, является фактическим количеством байт памяти, используемым для строки. Если какая-либо операция изменяет размер строки, старая память освобождается и динамически выделяется новая память для сохранения измененной строки. Поле str в классе String является адресом строки C++. Добавление поля size и, конечно, доступ к богатому источнику функций памяти отличают переменную String (объект) от строковой переменной C++ (C++String). Конструкторы и деструктор. Конструктор создает объект типа String, принимая в качестве параметра строку C++. Во время процесса инициализации он присваивает размер, выделяет динамическую память и копирует строку C++ в создаваемый динамически данное-член str. По умолчанию присваивается NULL-строка. Конструктор копирования следует той же процедуре, но копирует строку из начального объекта String, а не из строки C++. Деструктор удаляет символьный массив, который содержит эту строку. // конструктор, выделяет память и копирует в строку C++ String::String(char *s)
{ // длина включает NULL символ size = strlen(s) +1; str - new char [size]; // программа завершается, если память исчерпана. if (str == NULL) Error(outOfMemory); strcpy(str,s); } Перегруженные операторы присваивания. Оператор присваивания позволяет присваивать либо объект String, либо строку C++ объекту String. Например: String S("I am a String variable"), T; // присваивает объект String объекту String Т = S; // присваивает строку C++ объекту String Т= "I am a C++ String"; Для того, чтобы присвоить новый String-объект s текущему объекту, сравнивается длина двух строк. Если они различные, оператор удаляет динамическую память текущего объекта и снова (оператором new) выделяет s.size символов. Затем s.str копируется в новую память. // оператор присваивания: String в String Strings String::operator=(const Strings s) { // если размеры различные, удаление текущей // строки и выделение нового массива if (s.size != size) { delete [] str; str = new char [s.size]; if(str == NULL) Error(outOfMemory); // назначение размера, равного размеру s size = s.size; } // копируется s.str и возвращается ссылка на текущий объект strcpy(str,s.str); return *this; } Операторы сравнения. Класс String предоставляет полный набор операторов сравнения строк в соответствии с кодом ASCII. Эти операторы сравнивают два объекта String или объект String с С++String. Например: String S("Cat")/ T("Dog"); //сравнение строк if(S==T)... // условие FALSE if (T<"Tiger") ... // условие TRUE if ("Aardvark">= T) . . . // условие FALSE Реляционный оператор == проверяет равенство строки C++ объекта типа String. Заметим, что версия =—, которая позволяет строковой переменной C++ появляться в качестве левого операнда, должна быть перегруженной как дружественная функция: // C++String == String, дружественная функция // так как C++String находится в левой части friend int operator== (char *str, const Stringi s) { return strcmp(str, s.str) == 0; }
String-операторы. Этот класс имеет набор функций, используемых для конкатенации строк. Конкатенация выполняется перегрузкой операторов + и •+•=. В первом случае возвращается новая строка. Во втором — происходит добавление к текущей строке. Например, строка "Cool Water" создается с использованием трех версий оператора конкатенации: String SC'Cool"), T("Water"), U, V; U = S + T; // конкатенация двух String V = S + Water; // конкатенация String и C++String S += T; // S теперь - это "Cool Water" Следующий код реализует версию "String + String" оператора конкатенации. Функция возвращает объект String, являющийся конкатенацией текущего объекта String и String справа от +. В этом алгоритме мы сначала создаем String-объект temp, содержащий size-fs.size-1 символов, включая NULL-сим- вол. Заметим, что когда объявляется temp, мы сначала удаляем NULL-строку, созданную конструктором, а затем выделяет память (размером size+s.size-1). Метод копирует символы из текущего объекта в новую строку и конкатенирует символы из s. Строка temp представляет собой возвращаемое значение. // конкатенация: String + String String String:: operator+ (const Strings s) const { // создание новой строки temp с длиной len String temp; int len; // удаление NULL string, созданной при объявлении temp delete [] temp.str; // вычисление длины результирующей строки //и выделение памяти в temp len = size + s.size -1; // только один NULL-символ temp.str = new char [len]; if (temp.str == NULL) Error(outOfMemoryO; // установка размера результирующей строки // и создание строки temp.size = len; strcpy(temp.str,str); // копирование str в temp strcat(temp.str, s.str); // конкатенация return temp; // возвратить temp } String-функции. Метод Substr возвращает подстроку текущей строки, которая начинается с позиции index и имеет длину count: String Substr(int index, int count); Этот оператор широко используется в алгоритмах сопоставления с образцом. Например: String SC'Cool Water"), U; U = S.Substr(1,2); // извлекает 'оо' из Cool Если индекс выходит за позицию последнего строкового символа, функция возвращает NULL-строку. Количество символов в строке от элемента index до конца строки равно size-index-1. Если count превышает это значение, используется хвост строки как подстрока, а параметру count присваивается значение size-index-1. Для реализации этого метода выделяется память для count+1 символов в объекте temp. В эту память копируется count символов объекта String, начиная с позиции index, и нулевой завершающий символ. Данному- члену temp.size присваивается значение count+1, temp возвращается в качестве значения функции.
// возвращает подстроку с позиции index и // длиной count String String:;Substr(int index, int count) const { // число символов от index до конца строки int charsLeft * size-index-1,i; // создать подстроку в temp String temp; char *p, *q; // возвратить NULL-строку, если index слишком велик if (index >« size-1) return temp; // если count > charsLeft, возвращать оставшиеся символы if (count > charsLeft) count » charsLeft; // удалить NULL-строку, созданную при объявлении temp delete [] temp.str; // выделить память для подстроки temp.str = new char [count+1]; if (temp.str == NULL) Error(outOfMemory); // копировать count символов из str в temp.str for(i*0,p~temp.str,q»&str[index];i < count;i++) *p++ » *q++; // последний NULL-символ *p = 0; temp.size • count+1; return temp; } Substr(3,3) Текущий объект char*str; int size; (=7) v. temp char*str; int size; (=4) ъ._^ NULL Функции-члены Find и FindLast выполняют поиск вхождения какого-либо символа в строке. Обе возвращают -1, если этот символ не находится в строке. Метод
int Find(char C, int stsrt) const; начинает с начальной позиции и выполняет поиск первого вхождения символа С. Если символ найден, Find возвращает его позицию в строке. Метод int FindLast(char С) const; выполняет поиск последнего вхождения символа С. Если этот символ найден, FindLast возвращает его позицию в строке. // возвратить индекс последнего вхождения С в строке int String::FindLast(char C) const { int ret; char *p/ // использование библиотечной функции C++ strrchr. // возвращает указатель на последнее вхождение символа С в строке р = strrchr(str,С); if (p l« NULL) ret * int(p-str); // вычисление индекса else ret - -1; // возвратить -1 при неудаче return ret; } Строковый ввод/вывод. Строковые потоковые операторы » и « реализуются использованием операций потокового ввода и вывода для строк C++. Оператор » читает разделяемые пробелами слова текста. Метод ReadString читает строку текста (до 255 символов или до указанного ограничительного символа) из текстового файла и включает ее в объект String. Если не передается никакого файлового параметра, по умолчанию символы принимаются из стандартного потока cin. Ввод завершается на ограничителе, который не сохраняется в строке. В качестве ограничителя по умолчанию используется символ новой строки С\п')« Например, String S, Т; cin» S; // пропускает пробелы; читает следующую лексему T.ReadStringO; // читает до конца строки cout «"Компоненты:" « S « " и " « Т « endl; <Ввод> Super! Grade A // пробел после ! является частью Т <Вывод> Компоненты: Super! и Grade A Метод использует функцию getline для считывания до ограничителя или до 255 символов из входного потока istr в символьный массив tmp. Если getline указывает на конец файла, возвращается -1; иначе удаляется существующий динамический массив str и выделяется другой — размером size=strlen(tmp)+l. Массив tmp копируется в новый массив, и функция возвращает количество считанных символов (size-1). // читать строку текста из потока istr int String::ReadString (istream& istr, char delimiter) { // читать строку в tmp char tmp[256]; // если не конец файла, читать строку до 255 симвлов if (istr.getline(tmp, 256, delimiter)) { // удалить текущую строку и выделить массив для новой delete [] str;
size = strlen(tmp) + 1; str e new char [size]; if (str == NULL) Error(outOfMemory); // копировать tmp. возвращать число считанных символов strcpy(str,tmp); return size-1; } else return -1; // возвратить -1, если конец файла } 8.6. Сопоставление с образцом Общая проблема сопоставления с образцом включает поиск одного или более вхождений строки в текстовом файле. Большинство текстовых редакторов имеют меню Search, содержащее несколько элементов поиска подстроки, таких как Find, Replace и Replace All. Search Find ... ?ir;3 Ac*:.Jx «Hewlett* *г>д h'Xz.-d Aflrain 5iad i& ife»t iriln Go to Top Go to Bottom Go to Line # Процесс Find начинает с текущего местоположения в файле и выполняет поиск следующего вхождения подстроки по направлению вперед или назад. Replace заменяет подстроку, совпавшую при выполнении процесса Find, другой подстрокой. Replace All проходит по файлу и заменяет все вхождения подстроки-образца на подстроку-замену. Процесс Find Рассмотрим процесс Find для простой ситуации. Даны строковые переменные S и Р, начинаем в заданной позиции в S и ищем подстроку Р. Если она существует, возвращаем индекс в строке S первого символа подстроки Р. Если Р не существует в S, возвращаем -1. Например: 1. Дана строка S="aaacabc" и P="abc", подстрока Р расположена в S, начиная с позиции 4. 2. Если S="Blue Bar ranch lies outside the city of the animals" и P="the", то Р появляется в S дважды в позициях с индексами 28 и 40. 3. Подстрока Р="аса" не присутствует в строке S="acbaccacbcbcac".
Программа 8.5 иллюстрирует алгоритм сопоставления с образцом, использующий класс String. Алгоритм сопоставления с образцом Этот алгоритм реализуется функцией FindPat, которая начинает с позиции startindex строки S и выполняет поиск первого вхождения подстроки Р. Мы приводим сначала код, так как наш анализ алгоритма ссылается на переменные этой функции. int FindPat(String S, String P, int startindex) { // первый и последний символы образца и его длина char patStartChar, patEndChar; int patLength; // индекс последнего символа образца int patlnc; // начинать с searchlndex искать совпадение с первым // символом образца, переменной matchStart присвоить // индекс совпавшего символа строки S. проверить, //не совпадает ли символ строки S для индекса matchEnd //с последним символом образца int searchlndex, matchStart, matchEnd; // индекс последнего символа в S. matchEnd должен быть <= // этого значения int lastStrlndex; String insidePattern; patStartChar = P[0]; // первый символ образца patLength = P.LengthO; // длина образца patlnc = patLengtn-1; // индекс последнего символа образца patEndChar = P[patlnc]; // последний символ образца // если длина образца > 2, получить все символы образца, // кроме первого и последнего if (patLength > 2) insidePattern = P.Substr(l,patLength-2); lastStrlndex = S.Length()-1; // индекс последнего символа в S // начать поиск отсюда до совпадения первых символов searchlndex = startindex; // искать совпадение с первым символом образца matchStart « S.Find(patStartChar,searchlndex); // индекс последнего символа возможного совпадения matchEnd = matchStart + patlnc; // повторно искать совпадение первого символа и проверять, // чтобы последний символ не выходил за строку while(matchStart != -1 && matchEnd <= lastStrlndex) { // это первое или последнее совпадение? if (S[matchEnd]==patEndChar) { // если совпадают один или два символа, имеем совпадение
if (patLength о 2) return matchStart; // сравнить все символы, кроме первого и последнего if (S.Substr(matchStart+l,patLength-2) ~ insidePattern) return matchStart; } // образец не найден, продолжать поиск со следующего символа searchlndex * matchStart+1; matchStart - S.Find(patStartChar,searchlndex); matchEnd ■ matchStart+patlnc; } return -1; // совпадение не найдено ) Следующие шаги описывают этот алгоритм в общих чертах. Делаются ссылки на пример строки: S*badcabcabdabc и образец Р ■ a b с 1. Образец — это блок текста с начальным символом patStartChar = Р[0], длиной patLength « P.Length(), приращением patlnc = patLength-1, которое дает индекс последнего символа в образце, и конечным символом patendChar = P[patlnc]. Как мы увидим в шагах 3 и 4, алгоритм сравнивает первый и последний символы текстового блока в S длиной patLength с первым и последним символами в Р. Если длина Р (patLength) превышает 2, извлекаем подстроку символов, которая не включает первый и последний символы (P.Substr(l, patLength-2)) и присваиваем эту строку переменной insidePattern. Р = a b с parStartChar = *а' patlnc * 2 patEndChar = 'с' patLength ■» 3 insidePattern - "b" 2. Чтобы отметить конец строки S, присваиваем переменной lastStrlndex индекс последнего символа (S.Length()-l). Начальный индекс (startln- dex) может быть нулевым (поиск от начала строки) или некоторым положительным значением, если вам необходимо начинать поиск внутри строки. Инициализируем переменную searchlndex значением startln- dex. Эта переменная служит как точка запуска для сопоставления с первым символом образца. 3. Начиная с searchlndex, используем метод Find класса String для выполнения поиска символа в строке, совпадающего с patStartChar. Присваиваем индекс совпадения переменной matchStart. Переменная matchEnd (matchStart+patlnc) — это индекс последнего символа строки S, который может совпадать с patEndChar. Поиск завершается неудачей, если Find не находит совпадения с patStartChar или matchEnd превышает lastStrlndex.
4. Сравниваем patEndChar с последним символом текстового блока в S (S[matchEnd] == patEndChar). Если эти символы не совпадают, мы должны перейти к шагу 5 и выполнить еще ряд сравнений. Сравнение последних символов является оптимизирующей функцией, освобождающей нас от ненужного тестирования для образцов, которые не могут совпадать. Если длина образца равна 1 или 2 (patLength<= 2), лш имеем совпадение и возвращаем индекс matchStart. Иначе сравниваем символы в текстовом блоке, исключая первый и последний (S.Substr(matchStart+l, patLength-2)) со строкой insidePattern. Если они совпадают, возвращаем matchStart. В примере matchStart=l и matchEnd = 3. Строковый блок и образец совпадают на концах. Строки insidePattern ="b" и S.Substr(2,l) = "d" не совпадают. S = b a d с a b с a b d a b с а Ь с matchStart = 1 matchEnd ж 3 5. Повторяем шаги 3 и 4, но на этот раз начинаем в позиции с индексом searchlndex= matchStart+1. В нашем примере следующее совпадение с первым символом Р имеется в позиции с индексом 4. S = b a d c_a bcabdabc а Ь с matchStart * 4 matchEnd « 6 Последний символ Р и последний символ текстового блока совпадают, и совпадают также S.Substr(5,l) и insidePattern. Возвращаем индекс 4 (matchStart). 6. Для нахождения множества вхождений образца вновь вызываем функцию с начальным индексом, большим на единицу, чем индекс, возвращаемый функцией FindPat, Например, продолжение поиска "abc" дает следующие результаты: Поиск возможного совпадения в позиции с индексом 7 неудачен. Start Index = 5 S = b a d с a b с a_b d a b с a b с matchStart = 7 matchEnd = 9 Поиск возможного совпадения в позиции с индексом 10 является удачным. Возвращается значение 10. S = badcabcabd_abc а Ь с matchStart =* 10 matchEnd * 12
Программа 8.5. Поиск подстрок Функция FindPat реализует только что описанный алгоритм сопоставления с образцом и находится в файле findpat.h. Эта программа читает образец строкового объекта pattern и затем начинает чтение строки linestr до тех пор, пока не будет достигнут конец файла. Функция FindPat вызывается для нахождения количества вхождений образца в каждой строке, а затем выводит количество вхождений вместе с номером строки. #include <iostream.h> #pragma hdrstop #include "strclass.h" #include "findpat.h" void main() { // определить строку-образец и строку для поиска String pattern, lineStr; // параметры поиска int lineno = 0, lenLineStr, startSearch, patlndex; // число совпадений в текущей строке int numberOfMatches; cout « "Введите подстроку для поиска: "; pattern.ReadString(); cout « "Введите строку или EOF:" « endl; while(lineStr.ReadString() != -1) { lineno++; lenLineStr = lineStr.Length(); startSearch = 0; numberOfMatches = 0; // поиск до конца строки while(startSearch <= lenLineStr-1 && (patlndex = FindPat(lineStr, pattern,startSearch)) != -1) { numberOfMatches+-i-; // продолжать поиск до следующего вхождения startSearch = patlndex+l; } cout « "Число совпадений: " « numberOfMatches « "в строке: " « lineno « endl; cout << "Введите строку или EOF:" « endl; } } /* ^Выполнение программы 8.5> Введите подстроку для поиска:: iss Введите строку или EOF: Alfred the snake hissed because he missed his Missy. Число совпадений: 3 в строке: 1 Введите строку или EOF:
Mississippi Число совпадений: 2 в строке: 2 Введите строку или EOF: Не blissfully walked down the sunny lane. Число совпадений: 1 в строке: 3 Введите строку или EOF: It is so. Число совпадений: 0 в строке: 4 Введите строку или EOF: */ Анализ алгоритма сопоставления с образцом Предположим, что образец имеет m символов, а строка имеет п символов. Если первые m символов в S совпадают с Р, мы находим совпадение после m сравнений. Оценкой наилучшего случая для алгоритма является О(т). Для определения оценки наихудшего случая предположим, что мы не используем оптимизирующую функцию, в которой сравниваются последние символы. Более того, допустим, что у нас всегда совпадают первые символы, но никогда — образец. Например, это верно, если Р = "abc" и S = "аааааааа" (т = 3, п = 8) В этом примере т = 3 символа образца "abc" должны сравниваться с текстовыми блоками в S в сумме n ~ m+l=6 раз. В общем случае мы должны сравнивать m символов по n-m+1 раз, т.е. всего выполнить m(n-m+l) сравнений. m символов m символов 0 1 2 n-m n-1 n —m+1 блоков, каждый требует m сравнений Так как m(n-m+l) < m(n-m+m) = mn, оценкой наихудшего случая для алгоритма будет О(тп). Сопоставление с образцом — это очень важная тема в компьютерной науке, и она широко изучается в литературе. Например, алгоритм сопоставления с образцом Кнута-Морриса-Прата (Knuth, 1977) имеет вычислительное время 0(т+п), т.е. является более эффективным, чем только что представленный простой алгоритм. 8.7. Целочисленные множества Множество — это группа объектов, выбранная из коллекции, называемой универсальным множеством (universal set). Множество записывается как список, разделяемый запятыми и заключенный в фигурные скобки.
X = {Iifl2> I3» • * * » ^m/ D Объединение множеств ( u ) X u Y — это множество, содержащее все элементы в X и все элементы в Y без дублирования. D Пересечение множеств ( п ) X n Y — это множество, содержащее все элементы, которые находятся в обоих множествах X и Y. XUY XHY X = {0, 3, 20, 55}. Y = {4, 20, 45, 55} X U Y = {0. 3, 4, 20, 45, 55} ХП Y = {20, 55} □ Вхождение во множество (е) nGX равно TRUE, если элемент п является элементом множества X; иначе оно равно FALSE. Х={0, 3, 20, 55} //20 € X равно TRUE, 35 Е X равно FALSE Множества целочисленных типов Целочисленный тип (integer type) — это любой тип, элементы которого представлены целыми значениями. Типы char, int всех размеров и перечисления являются целочисленными типами. Например, набор символов кода ASCII соответствует 8-битовым целым в дипазоне от 0 до 127. Тогда как приложения используют традиционное представление символов 'А', 'В', ..., для их внутреннего хранения применяются целые коды 65, 66 и так далее. Программист имеет возможность выбирать любое представление. char chl - 'A', ch2 « 97, ch3; Ch3 - chl + 4; // ch3 - 'E' cout « ch2 « " " « int('A'); // печать: а 65 В этом разделе разрабатываются множества с элементами целочисленного типа. Универсальное множество имеет соответствие "один-к-одному" с беззнаковыми целыми в диапазоне от 0 до setrange-1, где setrange — это количество элементов множества. Рассмотрим следующие множества: Множество цифр = {0,1,2,3,4,5,6,7,8,9} соответствует диапазону 0 ... 9 Множество символов кода ASCII={. . . , 'А', 'В\ . . . , *Z\ . . . } соответствует диапазону 0 ... 127 enum Color (красный, белый, синий) множество Color соответствует диапазону 0 ... 2
Мы можем реализовать тип Set, с помощью массива значений нулей и единиц (0 и 1). В этом массиве значение в позиции i равно 1 (TRUE), если элемент i находится в данном множестве, или — О (FALSE), если он отсутствует в нем. В письменном упражнении 6.13 описывается метод для реализации множества целых значенией с использованием статического массива. Этот подход использует одно целое значение для каждого возможного элемента множества. Мы можем выделять память для этого массива целых динамически. // объявление множества с элементами в диапазоне от 0 до setrange-1 class Set { private: int *member: // указатель на массив set int setrange; • * • public: // конструктор для распределения массива set Set(int n):setrange (n) { member ■ new int [setrange]; • • • } }; • » • Set S{20); // множество {0, . . . , 19) n£S «■> S.member[n] == 1 // элемент равен 1, если п принадлежит Set Требуемая память значительно уменьшается, если мы поддерживаем массив с использованием побитовых операторов C++. Более того, мы можем обобщить этот подход до обработки любого целого типа, применяя шаблоны. Побитовые операторы C++ Операторы OR (||), AND (&&) и NOT (!) используются в логических выражениях для объединения целочисленных операндов и возвращение результата TRUE (1) или FALSE (0). Одни и те же операнды могут вычисляться с эквивалентными арифметическими операторами, которые применяются к их отдельным битам. Битовые операторы OR(|), AND (&), NOT (-) и EOR С) определены для отдельных битов и возвращают значения 0 и 1. Эти операторы приведены в таблице 8.1. Из них только EOR может оказаться для вас новым. Он возвращает 1, только если оба бита не равны. Таблица 8.1 Битовые операции X 0 0 1 1 У 0 1 0 1 -X 1 1 0 0 х)у 0 1 1 1 х&у 0 0 0 1 Хлу 0 1 1 0 Битовые операции применяются к n-битовым значениям выполнением операций над каждым битом. Допустим, а " an-ian-2 ■ • • а2а1ао Ь ■ bn-!bn_2 . . . Ьзк^Ьо Результат с = a op b задается следующим образом:
с = cn,!Cn_2 • • • CjCiCo ** a^a^ . . . a2a!a0 op bn_xbn_2 . . . ^b^Q где Ci = аА op bi; 0 < i < n-1 и op = ' |', ' &' / ' л' Унарный оператор '-' инвертирует биты операнда. Пример 8.4 8-битовые числа х =11100011 и у = 01110110 используются с операциями а) х OR у, Ь) х AND у, с) х EOR у и d) ~х. а) х 11100011 Ь) х 11100011 с) х 11100011 OR у 01110110 AND у 01110110 EOR у 01110110 11110111 01100010 10010101 d) -х = 00011100 В C++ имеются также операторы сдвига, которые сдвигают биты операнда влево («) или вправо (»). Выражение а « п умножает а на 271, выражение а » п делит а на 2П. Обычно, использование битового оператора ускоряет любое вычисление, включающее умножение или деление целого значения на степень двойки. Битовые операторы обычно используются с беззнаковыми целыми операндами. Мы будем их использовать только для этого. Пример 8.5 Предположим, что переменные х, у и z определяются следующим образом: // каждая переменная — 16 битовая unsigned short x = 10, у = 13r z; Пункты а — d иллюстрируют использование битового оператора. a. z = х | у; // z - ХЪ b. х = х & у; // z = 8 c. z = ~0 « 2; // z = 65532 d. z = ~х & (у » 2) ; // z = 1 Спецификация класса Set В нашем классе Set-объект состоит из списка элементов, взятых в диапазоне 0..setrange-l целых чисел. Целочисленный тип определяется именем шаблонного типа Т. Мы полагаем, что для типа Т определен преобразователь int и что целое значение может быть преобразование явно в тип Т. Например, пусть val будет элементом типа Т, а I — целой переменной: Т val; int I; Если I — это целый эквивалент элемента данных val, то I = int(T), a val = Т(1)
Пример 8.6 a. Char — это целый тип. char с = ' А' ; int i; i = int(с); // i = 65 с - char(i); //с - 'A' b. Перечислимый тип — это целый тип. enum Days {Sun, Mon, Tues, Wed, Thurs, Fri, Sat}; Days day = Thurs; int d; d = int (day) ; //d «= 4 day = Days (d); // day = Thurs Представление элементов множества При определении битовых операторов C++ для эффективной реализации объекта Set используются отдельные биты в слове. Диапазон значений множества (0..setrange-l) хранится в динамическом массиве из 16-битовых беззнаковых целых. Массив с именем member связывает целые числа в диапазоне 0..setsize-l как цепочку битов. Каждый бит представляет один элемент множества, и элемент находится в этом множестве, если соответствующий бит равен 1. Нулевой элемент множества представлен крайним правым битом первого элемента массива, а 15 представляется крайним левым битом первого элемента массива. Далее продолжаем крайним правым битом второго элемента массива, представляющим 16, и так далее. Схема хранения в памяти показана на следующем рисунке. 15 14 13 12 11 10 9 8 7 6 5 4 3 2 10 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 i|o|o|olo|o|i|o|o|oli|o|o|o|o|T| |o|o|o|i|o|o|o|o|o|o|o|o|o|o| 1 Го~|• ■ • member[0] member[1] В этом случае целые значения 0, 5, 9, 15, 17 и 28 принадлежат этому множеству. ОБЪЯВЛЕНИЕ iinclude <iostream.h> #include <stdlib.h> enum ErrorType { InvalidMember, ExpectRightBrace, MissingValue, MissingComma, InvalidChar, MissingLeftBrace, InvalidlnputData, EndOfFile, OutOfMemory, InvalidMemberRef, SetsDifferentSize }; template <class T> class Set { private: // максимальное число элементов множества int setrange; // число байтов битового массива и указатель на массив int arraysize;
unsigned short *member; // обработка ошибок void Error(ErrorType n) const; // реализация распределения элементов множества //по битам внутри 16-битовых целых int Arraylndex(const T& elt) const; unsigned short BitMask(const T& elt) const; public: // конструктор, создает пустое сножество Set(int setrange); // конструктор копирования Set(const Set<T>& x); // деструктор -Set(void); // оператор присваивания Set<T>& operator» (const Set<T>& rhs); // вхождение в текущее множество int isMember(const T& elt); // эквивалентность int operator-» (const Set<T>& x) const; // объединение Set operator+ (const Set<T>& x) const; // пересечение Set operator* (const Set<T>& x) const; // вставка/удаление void Insert(const T& elt); void Delete(const T& elt); // ввод/вывод friend istreamfc operator>> (istream& istr, Set<T>& x); friend ostream& operator« (ostrearafi ostr, const Set<T>& x); }; ОПИСАНИЕ Шаблонный класс Set реализует множество целочисленных значений. Тип Т может быть любым типом, для которого применимы операции i — int(v) и v = T(i), где v и i задаются объявлениями Т v; int i; Конструктор создает пустое множество Set, а арифметические операторы используются для определения операций над множествами: объединение (+), пересечение (*) и равенство (==). Методы Insert и Delete, так же как и оператор присваивания, используются для обновления Set. Операции ввода/вывода вводят и печатают множества, заключаемые в фигурные скобки и разделяемые запятыми. ПРИМЕР: // набор целых чисел в диапазоне 0..24
Set<int> S(25); // набор символов в коде ASCII Set<char> T<128), U(128); cin » S; // ввести {4, 7, 12} S.Insert(5); cout « S « endl; // вывод {4, 5, 7, 12} cin << T; // ввести {a, e, i, о, u, у) U = T; // U =» {a, e, i, о, u, у} T.Delete('y'); cout « T « encll/ // вывод {a, e, i, o, u} if (T*U — T) cout « T « п- подмассив" « U « endl; Код, реализующий класс Set, находится в файле set.h. Класс Set предоставляет клиенту возможность создания множества объектов определяемых пользователем перечислимых типов и стандартных целочисленных типов, таких как int и char. Если требуется ввод/вывод множества для перечислимых типов, потоковые операторы должны быть перегружены. Пример 8.7 Рассмотрим следующий перечислимый тип enum Days {Sun,Mon,Tues,Wen,Thurs,Fri, Sat}; В программном приложении содержится перегруженный оператор « для этого типа. Он находится вместе с main-функцией в файле enumset.cpp. Следующие объявления и операторы иллюстрируют использование этих инструментов. // объявить 4 объекта, которые представляют различные // множества дней Set<Days> weekdays(7), weekend(7), week(7); // массивы wd и we определяют списки дней в неделе. // эти списки инициализируют множества объектов weekdays и weekend Days wd[] * {Mon,Tues,Wen,Thurs, Fri}, we[] *» {Sat, Sun}; // вставить элементы массива в множества for(int i«0; i<5; i++) weekdays.Insert(wd[i]); for(int i*0; i<5; i++) weekend.Insert(we[i]); // печатать множества cout « weekdays « endl/ cout « weekend « endl/ // формировать и печать объединение двух массивов week ■ weekdays + weekend/ cout « week « endl; <Выполнение программы> {Mon, Tues, Wen, Thurs, Fri} {Sat, Sun} {Sun, Mon, Tues, Wen, Thurs, Fri, Sat}
Решето Эратосфена Греческий математик и философ Эратосфен жил в 3 в. до н.э. Он открыл увлекательный метод использования множеств для нахождения всех простых чисел, меньших, чем или равных целому значению п. Этот алгоритм начинается инициализацией множества, содержащего все элементы в диапазоне 2..п. Путем повторяемых проходов по элементам во множестве мы "просеиваем элементы сквозь решето". В конечном счете, остаются только простые числа. Решето начинает действие со своего наименьшего числа m = 2, которое служит ключевым значением. Мы сканируем множество и удаляем все большие и кратные ключу 2*т, 3*т, . . . , к*т, которые остаются в этом множестве. Эти кратные не могут быть простыми числами, так как они делятся на т. Следующее число в решете — это ключ т=3, являющийся простым числом. Как в случае с начальным значением 2, мы удаляем все большие и кратные 3-м, начиная с 6. Так как 6, 12, 18 и так далее уже были удалены как кратные 2, этот проход удаляет 9, 15, 21... Продолжая процесс, переходим к следующему большему числу множества, являющемуся простым числом 5. Помните, что число 4 было удалено как кратное 2-м. В случае с 5 мы проходим по элементам и удаляем всякое большее кратное 5 (25, 35, 55 ...). Процесс продолжается до тех пор, пока пока мы не просканируем все множество и не удалим кратные для каждого ключевого значения. Числа, которые остаются в решете, являются простыми в диапазоне 2..п. Пример 8.8 На этом рисунке показано решето Эратосфена выполняющее поиск всех простых чисел в диапазоне 2 . . 25. Решето Эратосфена: п=25 !ХеыхИ 21 i2|3N5N7N9NnNl3Nl5Nl7N^N2l|X|23N25 Г?аТЛнГ 31 |2|3| |5| |7| |\| \п\ Цз| |Х| |Т7| Ц9| [XI И Щ У=е.ПТГз| Ы |7| | | Н |13| | | н н | i |23| ы 7,11,13,17,19, и 23 не содержат кратных в этом множестве Простые числа {2,3,5,7,11,13,19,23} Решето работает, пока не удалит все числа, не являющиеся простыми. Для того, чтобы проверить это, предположим, что составное (не простое) число m остается в решете. Такое число может быть записано как m = p*k, р>1 где р является простым числом в диапазоне от 2 до т-1. В алгоритме решета р было бы ключевым значением, и m было бы удалено, так как оно является кратным р.
Программа 8.6. Решето Эратосфена Функция PrintPrimes реализует решето. Алгоритм использует функцию оптимизации, проверяя ключевые значения в диапазоне 2 < т < V/Г. Это ограниченное число ключевых значений удаляет всё непростые числа из множества. Для проверки этого факта предположим, что некоторое составное число t = p*q остается. Если бы оба сомножителя (р и q) были больше, чем п, то t = p*q > VJT * V/Г = n и t не находилось бы в этом множестве. Таким образом, один сомножитель р должен быть < Vrc~. Этот меньший сомножитель был бы ключевым значением или кратным ключевому значению и, следовательно, t было бы удалено как кратное ключу. Вместо вычисления корня квадратного от п мы проверяем все числа т, которые m*m < п. #include <iostream.h> #include <iomanip.h> #pragma hdrstop #include "set.h" // использовать класс Set // вычислять и печатать все простые <- п, используя // алгоритм Решето Эратосфена void PrintPrimes(int n) { // множество содержит числа в диапазоне 2..п Set<int> S(n+1); int m, k, count; // вставить все значения из 2..п в это множество for(m=2;m <= n;m++) S.Insert(m); // проверять все числа от 2 до sqrt(n) for(m=2;m*m <= n;m++) // если m в S, удалить все кратные m из множества if(S.IsMember(m)) for(k=m+m;k <= n;k += m) if (S.IsMember(k)) S.Delete(k); // все оставшиеся в S числа — простые. // печатать простые числа по 10 в строке count - 1; for<m=2;m <= n;m++) if (S.IsMember(m)) { cout « setw(3) « m « " "; if (count++ % 10 == 0) cout « endl; } cout « endl; } void main(void) { int n;
cout « "Введите п: и; cin » n; cout « endl; PrintPrimes(n); ) /* <Выполнение программы 8.б> Введите п: 100 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 */ Реализация класса Set Закрытые методы Arraylndex и BitMask реализуют схему хранения в памяти массива целых. Arraylndex определяет элемент массива, которому принадлежит параметр elt простым делением на 16 с использованием эффективного сдвига на 4 бита вправо: template <class T> int Set<T>::Arraylndex(const T& elt) const { // преобразовать elt к типу int и сдвинуть return int(elt) » 4/ } Если обнаруживается правильный индекс массива, BitMask возвращает беззнаковое короткое значение, содержащее 1 в битовой позиции, задаваемой значением elt. Эта маска (mask) может использоваться для задания или очистки бита. // создать беззнаковое короткое целое с 1 в битовой elt-позиции template <class T> unsigned short Set<T>::BitMask(const T& elt) const { // использовать & для нахождения остатка от деления на 16. //0 попадает в крайний правый бит, 15 — крайний левый return 1 « (int(elt) & 15); } Обработка ошибок. Класс реагирует на группу ошибок, вызывая закрытую функцию-член Error. ErrorType типа enum используется для удобного задания наименований возможных ошибок. Функции передается параметр ErrorType, который используется в операторе выбора для определения ошибки и завершения программы. Реализация метода Error приведена в программном приложении. Конструкторы класса Set. Класс имеет два конструктора, создающие объект Set: один — создающий пустое множество, другой — конструктор копирования. Пустое множество создается определением количества беззнаковых коротких элементов массива arraysize, необходимых для представления диапазона значений данных, выделением массива и заполнением его значениями 0.
// конструктор, создает пустое множество template <class T> Set<T>::Set(int sz): setrange(sz) { // число беззнаковых коротких целых для задания множества arraysize = (setrange+15) » 4; // выделить массив member « new unsigned short [arraysize]; if (member == NULL) Error(OutOfMemory); // создать пустое множество, заполняя его нулями for (int i = 0; i < arraysize; i++) member[i] = 0; } Set-операторы. Бинарные операции объединения и пересечения реализуются перегрузкой операторов + и * языка C++. Для оператора объединения (+) создается объект множества tmp (содержащий элементы в диапазоне 0..set- range-1) побитовой операцией OR над элементами массива, представляющими текущее множество, и элементами множества х. Этот новый объект возвращается в качестве значения метода. Заметьте, что мы сообщаем об ошибке, если оба множества (операнды) имеют разные размеры. // формировать и возвращать объединение // текущего множества с множеством х template <class T> Set<T> Set<T>::operator* (const Set<T>& x) const { // множества должны иметь одинаковые размеры if (setrange != x.setrange) Error(SetsDifferentSize); // формировать объединение в tmp Set<T> tmp(setrange); // каждый элемент множества tmp — результат побитового OR for (int i * 0; i < arraysize; i++) tmp.member[i] = member[i] | x.memberti]; // возвратить объединение return tmp; } Подобно объединению операция пересечения (*) создает объект множества tmp, являющийся пересечением, использованием побитовой операции AND над элементами массива текущего множества и множества х. Возвращается новое множество как значение метода. Метод IsMember определяет вхождение в текущее множество и возвращает TRUE, если бит, соответствующий elt, равен 1, и FALSE — в противном случае: template <class T> int Set<T>::IsMember(const T& elt) { // находится ли int(elt) в диапазоне 0..setrange-1 ? if (int(elt) < 0 || int(elt) >= setrange) Error(InvalidMemberRef); // возвратить бит, соответствующий elt return member[Arraylndex(elt)] & BitMask(elt); }
Операции вставки и удаления. Операция Insertion реализуется заданием бита, соответствующего параметру elt: template <class T> void Set<T>::Insert(const T& elt) { // находится ли int(elt) в диапазоне 0..setrange-1 ? if (int(elt) < 0 || int(elt) >= setrange) Error(InvalidMemberRef); // находится ли int(elt) в диапазоне 0. .setrange-1 ? member[Arraylndex(elt)3 |= BitMask(elt); } Операцией удаления убирается бит, соответствующий elt. Метод использует оператор AND и маску, содержащую все 1, кроме заданного elt-бита. Маска создается с использованием операции побитового отрицания (~). // удалить elt из множества template <class T> void Set<T>::Delete(const T& elt) { // находится ли int(elt) в диапазоне 0..setrange-1 ? if (int(elt) < 0 || int(elt) >= setrange) Error(InvalidMemberRef); // очистить бит, соответствующий elt member[Arraylndex(elt)] &= -BitMask(elt); } Ввод/вывод, Потоковые операторы » и « перегружаются для реализации потокового ввода/вывода для типа Set. Оператор ввода (») читает множество х в формате {io, ii, . . . im}. Элементы множества заключены в фигурные скобки и разделены запятыми. Каждое целочисленное значение in представляет элемент множества. Оператор вывода («) записывает множество х в формате {io, ii, . . . im}, где io< ii< . . . <im являются элементами этого множества. Метод Input является наиболее трудным для реализации. Он пропускает пробел, используя функцию get для ввода одиночного символа, а затем проверяет, является ли текущий символ символом "{". Если — нет, вызывается метод error, который выводит сообщение об ошибке и завершает программу. Когда начальная скобка найдена, метод проходит по разделенному запятыми списку целочисленных значений и добавляет каждый элемент в текущее множество. Выполняется проверка того, что запятые правильно размещены и что элементы множества остаются в диапазоне от 0 до setrange-1. Для разделения элементов в списке может использоваться любое количество пробелов. Письменные упражнения 8.1 Объявите массив из 10 целых и указатель на int: int a[10], *р; Рассмотрите следующие операторы: for(i=0; i<2; i++) { p=new int[5]; for(j=0;j<5;j++) a[5*i+j] = *p++ = i+j; }
(а) Укажите выход для операторов: for(i=0; i<10; i++) cout « a[i] « " "; cout « endl; (б) Определите переустанавливает ли оператор р = р - 10; указатель р в начало ранее выделенного динамического массива. (в) Предположим, что q — это указатель на первоначальный динамический массив. Производит ли этот код тот же выход, что и в пункте (а)? for(i=0; i<10; i++) cout « *(q+i) « " "; cout << endl; 8.2 Для каждого объявления используйте оператор new, чтобы динамически выделять указанную память. (а) int* px; Создайте целое, на которое указывает рх, имеющее значение 5. (б) long *а; int n; cin >> п; Создайте динамический массив из длинных целых, на который указывает а. (в) struct DemoC { int one; long two; double three; } DemoC *p; Создайте узел, на который указывает р. Затем задайте поля {1, 500000, 3.14} (г) struct DemoD { int one; long two; double three; char name[30] ; }; DemoD *p; Создайте динамически узел, на который указывает р, и задайте поля {3, 35, 1.78, "Bob C++"). (д) Задайте операторы, которые освобождают память для каждого пункта а) — d). 8.3 Конструктор класса Dynamiclnt использует новый оператор для динамического выделения целого и присваивания его адреса указателю рп. Открытые методы GetVal и SetVal сохраняют и извлекают данные из динамической памяти.
class Dynamiclnt { private: int *pn; public: // конструктор и конструктор копирования Dynamiclnt(int n e 0) ; Dynamiclnt(const Dynamiclntfi x); // деструктор -Dynamiclnt(void); // оператор присваивания Dynamiclnt& operator» (const Dynamiclnt& x); // методы управления данными int GetVal(void); // получить целое значение void SetVal(int n); // установить целое значение // оператор преобразования operator int(void); // возвращает целое // потоковый ввод/вывод friend ostream& operator« (ostream& ostr, const Dynamiclnt& x); friend istream& operator» (istreams istr, Dynamiclnts x); }; (а) Напишите код для реализации методов конструктора и деструктора. (б) Напишите методы, которые перегружают оператор = и реализуют конструктор копирования. (в) Реализуйте GetVal, оператор int, и SetVal. (г) Реализуйте функции потокового ввода/вывода, так чтобы они считывали и записывали значение *рп. 8.4 Используйте объявление Dynamiclnt из письменного упражнения 8.3 для следующих упражнений: (а) Dynamiclnt *р; Задайте объявление для выделения одного объекта с начальным значением 50. (б) Dynamiclnt *p; Выделите массив р с тремя элементами. Каково значение каждого объекта в массиве? (в) Dynamiclnt a[10]; Укажите, как бы вы объявили массив из 10 объектов типа Dynamiclnt и инициализировали каждый элемент значением 100? (г) Задайте операторы delete, освобождающие динамическую память, используемую в упражнениях (а) — (с). 8.5 Запишите класс Dynamiclnt из письменного упражнения 8.3 как шаблонный класс DynamicType. template <class T> class DynamicType { private: T *pt; public: // конструктор и конструктор копирования DynamicType(T value);
DynamicType(const DynamicType<T>& x); // деструктор -DynamicType(void); // оператор присваивания DynamicType<t>& operator* (const DynamicType<T>& x); // методы управления данными T GetVal(void); // получить значение void SetVal(T value); // установить значение // оператор преобразования operator T(void); // возвратить значение // потоковый ввод/вывод friend ostream& operator« (ostream& ostr, const DynamicType<T>& x); friend istream& operator» (istreamfi istr, DynamicType<T>& x); }; 8.6 В этом упражнении используются классы Dynamiclnt и DynamicType, разработанные в письменных упражнениях 8.3 и 8.5. Объявите объект: DynamicType<DynamicInt> D(Dynamiclnt(5)); Каков выход для следующих операторов? cout « D « endl; cout « D.GetValO .GetValO) « endl; cout « int(D.GetValO ) « endl; cout « Dynamiclnt(D) « endl; cout « int(Dynamiclnt(D)) « endl; 8.7 Рассмотрите класс ReadFile со следующим объявлением: class ReadFile { private: // чтение символов из потока fin для динамического // выделения буферного массива размером bufferSize ifstream fin; char *buffer; int bufferSize; public: // конструктор принимает имя файла и размер буфера ReadFile(char *name, int size); // выдача сообщения об ошибке и выход ReadFile(const ReadFilefi f); // удаление буфера и закрытие потока fin -ReadFile(void); void operator* (const ReadFile x); // чтение следующей строки из файла. // возвращает 0, если конец файла int Read(void); // копирование текущей строки в буфер void CopyBuffer(char *buf); // печать текущей строки в поток cout void Printbuffer(void); ); (а) Реализуйте этот класс. (б) Напишите функцию void LineNum(ReadFile& f) ; которая читает f и распечатывает соответствующий файл с номерами строк.
8.8 Класс DynamicType разработан в письменном упражнении 8.5. Предположим следующие объявления: DynamicType<int> *р, Q; DynamicType<char> *c; Ответьте на каждый вопрос: (а) Напишите оператор, создающий объект класса DynamicType со значением 5, на который указывает р. (б) Печатайте значение 5, на которое указывает р, используя три различных метода. (в) Является ли каждый оператор верным? Если да, каково его действие? с * new DynamicType<char> [65]; с = new DynamicType<char> (65); (г) Если вводится число 35, каким будет выход? cin » *р; Q = *р; cout « Q « endl; (д) Используя значение Q из упражнения (d), рпределите, каков выход. DynamicType<int> R(Q); cout « Q << endl; (е) Каков выход? Q = DynamicType<int> (68); с - DynamicType<char> (char(int (Q))); cout « с « char(с) « int(c) « endl; (ж) Напишите операторы, удаляющие объекты *р и *с. Что произойдет, если вы выполните? delete Q; 8.9 (а) Если CL является классом, объясните, почему вы не можете объявить его конструктор копирования следующим образом: CL(const CL х) ; (б) Объясните, почему вообще не нужно объявлять оператор присваивания для CL следующим образом: void operator» (const CL& х); 8.10 (а) Каково значение ключевого слова this? Объясните, почему оно верно только внутри функции-члена. (б) Назовите основное применение ключевого слова this. 8.11 Класс Rational из главы 6 реализует арифметику рациональных чисел. Оператор += должен быть добавлен в этот класс. Объясните, почему следующая реализация является правильной:
Rational& Rational::operator+= (const Rationalu r) { ♦this = *this + r; return *this; } 8.12 Класс ArrCL реализует границы массива, проверяя использование перегруженного оператора индексации массива [] и преобразование указателя. Он содержит массив из 50 элементов и поле длины. При создании объекта типа ArrCL пользователь может указать максимальную длину для списка и передавать это значение конструктору как параметр. const int ARRAYSIZE = 50; template <class T> class ArrCL { private: T arr[ARRAYSIZE] ; int length; public: // конструктор ArrCL9int n = ARRAYSIZE); // получение размера списка int ListSize(void) const; // индексный оператор, реализующий надежный массив Т& operator[] (int n); // преобразование указателя. Возвращает адрес arr. operator T* (void) const; } Размер встроенного списка arr ограничивается значением ARRAY- SIZE = 50. Если пользователь пытается зарезервировать массив большего размера, конструктор устанавливает размер на ARRAYSIZE и выдает предупреждение. (а) Напишите объявления, резервирующие массив А из 20 целых, массив В из 50 элементов типа char и массив С из 25 элементов типа float. (б) Имеется ли ошибка в этом цикле? ArrCL<long> A(30); for(int i=0; i<= 30; i++) A[iJ « 0; (в) Объясните, почему выходом будет 420 420 int Suml (const ArrCKint>& A) { int s = 0; for(int i*0; i<A.ListSize (); i++) s += A[i]; return s; } int Sum2(int *A, int n) { int s = 0; for(int i-0; i<n; i++) s += *A++; return s; } • • * ArrCL<int> arr(20); for(int i=0; i<20; i++) arr[i] = 2*(i+l); cout « Suml(arr) « " " « Sum2(arr, 20) « endl;
(г) Реализуйте этот класс. 8.13 Рассмотрите объявления String A("Have а ", В("nice day!")/ С(A), D=B; (а) Каково значение С? (б) Каково значение D? (в) Укажите значение D = А + В; (г) Укажите значение С+=В. 8.14 Рассмотрите строки: String S{"abcl2xya52cba"), Т; (а) Каково значение S.FindLast(V)? (б) Каково значение S[6]? (в) Каково значение S[3]? (г) Каково значение S[24]? (д) Каково значение Т = S.Substr(5,6)? (е) Каково значение Т после выполнения операторов: Т - S; Т.Insert("ABC", 5); 8.15 Сделайте следующие объявления: #define TF(b) ((b)? "TRUE" : "FALSE") String si("STRING") , s2 ("CLASS"); String s3; int loc, I; char c, cstr[30]; Определите выход каждой последовательности операторов: (а) s3 - si + s2; cout << si « "объединенная с " << s2 « « ш " « 83 « endl; (б) cout « "Длина " « s2 « " = " « s2.Length() « endl; (в) cout « "Первое вхождение 'S' в " « s2 « » = » « s2.Find('S',0) « endl; cout « "Последнее вхождение 'S' в " « s2 « » = " « S2.FindLast('S') « endl; (r) cout « "Вставить 'OBJECT ' в строку s3 в позицию 7." « endl; S3 = si + s2; s3.Insert("OBJECT ", 7); cout « s3 « endl; (д) si = FILE1.S; for(i=0; i < sl.LegthO; i++)
{ с ■ si[i]; if (с >= 'A' && с <« 'Z'} { с +- 32; sl[i] = с; } } cout « si « endl; (e) si = "ABCDE"; s2 ■ "BCF"; cout « "si < s2 * " « TF(sl < s2) « endl; cout « "si « s2 - " « TF(sl «« s2) « endl; (ж) si « "Проверка оператора преобразования указателя"; strcpy(cstr, si); cout « cstr << endl; 8.16 Предположим, что переменные x, у и z определены следующим образом: unsigned short х » 14, у = 11, z; Какое значение присваивается переменной z в результате выполнения каждого следующего оператора? (а) z - х | у; (б) z * х & у; (в) z =* -0 « 4; (г) z * -х & (у » 1); (д) 2 » (1 « 3) & х; 8.17 В этом упражнении представлены четыре функции, выполняющие битовые преобразования. Сопоставьте каждую функцию с одной из следующих описательных фраз: (а) Определение количества битов в int для определенной машины. (б) Возвращение числового значения п битов, начиная с бита в позиции р. (в) Инвертирование п битов, начиная с бита в позиции р. В позиции О находится крайний левый бит целого значения. (г) Битовый сдвиг целого по часовой стрелке. unsigned int one(unsigned int n, int b) { int rightbit; int lshift - three () - 1; int mask = (unsigned int) -0 » 1; while (b—) { rightbit « n & 1; n - (n » 1) & mask; rightbit - rightbit « lshift; n « n | rightbit; } return n; }
unsigned int two(unsigned int x, int p, int n) { unsigned int mask = (unsigned int) ~(~0 << n); return (x » (p-n+1)) & mask; } int three(void) { int i; unsigned int u ■= (unsigned int) ~0; for (i-l;u « u »l;i++); return i; } unsigned int four(unsigned int x, int p, int n) { unsigned int mask; mask = -0; return x л (-(mask » n) » p); } 8.18 Добавьте оператор разности (-) в класс Set. Этот оператор принимает два параметра типа множество, X и Y, и возвращает множество, состоящее из элементов в X, которые не находятся в Y. Необходимо, чтобы два эти множества имели одно и то же количество элементов. х-y X - {0, 3, 7, 9), Y - {4, 7, 8, 10, 15} X - У - {0, 3, 9) template <class T> Set<T> Set<T>::operator - (const Set<T> &x); (Совет: Вычислите diff.member[i] = member[i] & ~x.member[i]) 8.19 Добавьте оператор побитового отрицания (~) в класс Set. Этот унарный оператор возвращает множество, состоящее из всех значений в универсальном множестве, которые не находятся в X. Set<int> Х(10), У(10); for(int i=0; i < 10; i += 2) X.Insert(i); Y - -X; // X - {1,3,5,7,9}
8.20 (а) Используйте оператор побитового отрицания для создания универсального множества в качестве объекта Set. (б) Реализуйте оператор разности из письменного упражнения 8.18 с операторами побитового отрицания и пересечения. Упражнения по программированию 8.1 В этом упражнении используется класс Dynamiclnt, разработанный в письменном упражнении 8.3. (а) Перегрузите оператор < для Dynamiclnt как внешнюю функцию. Она должна быть дружественной этому классу. (б) Напишите функцию Dynamiclnt *Initialize(int n); которая возвращает указатель на массив динамически выделенных объектов. Инициализируйте значения объектов, чтобы они были случайными целыми числами в диапазоне 1..1000. (в) Main-функция должна считывать целое п и использовать Initialize для создания массива объектов типа Dynamiclnt. Используйте основанную на шаблоне обменную сортировку из главы 7 для сортировки списка. Напечатайте результирующий список. 8.2 Данная программа использует класс ReadFile из письменного упражнения 8.7. Используйте функцию LineNum для чтения файла и вывода на экран строк с номерами. 8.3 В этой программе используется класс ReadFile из письменного упражнения 8.7. (а) Напишите функцию void CapLine(ReadFilefc f, char *capline); которая считывает следующую строку из файла f, печатает ее прописными буквами и возвращает эту строку в capline. (б) Используйте эту функцию для чтения файла и печати его в верхнем регистре. 8.4 Это упражнение использует класс ArrCL из письменного упражнения 8.12. Создайте надежный массив из 10 целых и затем запросите пользователя ввести 10 элементов данных. После вызова основанной на шаблоне обменной сортировки (в файле arrsort.h) печатайте упорядоченный список. Попытайтесь обратиться к А[10], чтобы вызвать сообщение об ошибке и завершение программы. 8.5 Это упражнение модифицирует основанный на шаблоне класс Stack, разработанный в главе 7 (stack.h). Замените данное-член статического массива stacklist объектом Array, первоначально содержащим Мах- StackSize элементов. Удалите параметр размера стека из конструктора и перепишите метод Push так, чтобы размер стека при необходимости возрастал. Протестируйте ваши изменения, помещая целые значения 1..100 в стек и затем удаляя их, печатая каждое 10-е значение.
8.6 Считайте файл, используя потоковый метод getline, и сохраните строки в объекте Array, называемом строковым пулом. Вставьте строку в этот пул, выполняя операцию Resize, чтобы добавить достаточное пространство в пул, и поместите начальный индекс этой строки в массив строковых индексов, изменяя при необходимости его размер. Этот массив будет определять начальное положение каждой последующей строки в пуле. Введите целое N и печатайте последние N строк файла. Если файл имеет меньше строк, чем N, печатайте весь файл. Массив строковых индексов indexO indexl I index2 I index3 Строковый пул indexO indexl inoex2 Index3 8.7 Измените программу 8.3, сохраняя список всех простых чисел, которые вы вычислили до этого времени. Для следующего целого п в последовательности проверяйте только простые числа в списке, а не все делители от 3 до п/2. Если п не делится ни на какое простое число, то п — это новое простое число, которое может быть добавлено в список. Этот факт является математическим результатом того, что любое число может быть записано, как произведение его простых делителей. 8.8 (а) Напишите функцию void Replace(Strings S, int pos, const Strings repstr); которая заменяет repstr.Length() символов из S, начиная с индекса рое. Если в хвосте строки S символов меньше, чем repetr.Length, вставьте все символы repstr. 8.8 (а) Напишите функцию void Center(Strings S); которая вызывает Replace для печати S с центрированием внутри 80- символьной строки. Напишите main-функцию, тестирующую функции из пунктов (а) и (Ь). 8.9 Считайте целое п, представляющее количество строк текста в документе. Динамически выделите место для п указателей на объекты String. Читайте п объектов String, используя ReadString. Строки текста могут содержать специальные символы "#" и "&", например: Уважаемый # Ваш счастливый подарок находится в &. Если Вы пойдете в & и укажете Ваше имя, служащий вручит Вам Ваш приз. Спасибо # за Ваш интерес к нашему конкурсу. С уважением, Мистер Стринг
Введите строку poundstr, которая заменяет все вхождения "#" в вашем документе. Введите строку ampstring, которая заменяет все вхождения "&". Пройдите по массиву строковых указателей и выполните подстановки. Печатайте окончательный документ. 8.10 Напишите программу, которая инициализирует два 10-элементных массива intA и intB и использует их для создания множеств А и В. Оператор разности множества (-) определяется в письменном упражнении 8.18. Эта программа должна вычислить А-В и напечатать результат. Программа должна также проверить, что А + В = А-В + В-А + А*В 8.11 Функция template <class>T Set <T> TxclusiveUnion(const Set<T>& X, const Set<T>& Y); возвращает множество, содержащее все элементы, которые находятся либо в X, либо в Y, но не в обоих множествах. (а) Используйте графический аргумент, чтобы показать, что ExclusiveUnion может вычисляться с использованием любой из следующих формул: 1. (X-Y) + (Y-X) 2. X* -Y + ~X*Y Операции разности множества (-) и дополнения (~) определяются в письменных упражнениях 8.18 и 8.19. (б) Реализуйте ExclusiveUnion, используя формулу 2. Протестируйте вашу работу, используя множества: X = {1,3,4,5,7,8,12,20}, Y = {3,5,9,10,12,15,20,25} для которых ExclusiveUnion(X,Y) = {1,4,7,8,10,15,25} 8.12 Два ферзя на шахматной доске должны быть атакующими, если один может переместиться в позицию второго, то есть, если они находятся в одном и том же ряду, столбце или диагонали. На следующем рисунке показан пример атакующих и неатакующих ферзей. Атакующие ферзи Не атакующие ферзи Учитывая позицию каждого ферзя, рассмотрим проблему определения множества всех возможных перемещений каждого из них и, являются ли ферзи атакующими.
Любая позиция на шахматной доске может рассматриваться с использованием номера строки и номера столбца, каждый в диапазоне от О до 7. Каждой клеточке доски может быть присвоен единственный номер между 0 и 63 назначением каждой паре строка/столбец (i,j) целого значения i*8+j. Мы можем теперь рассматривать шахматную доску как множество из 64 целых в диапазоне от 0 до 63. Напишите функцию void ComputeQeenPositions(Set<int>& Board, int rowq, int colq); которая принимает множество Board, позицию ферзя (int rowq, int colq) и присваивает множеству все клеточки, на которые ферзь может переместиться. Напишите функцию void PrintQueenPositions(Set<int> Queen,int Qrow,int Qcol, int QOtherRow,int QOtherCol); Первый ферзь находится в позиции (Qrow, Qcol), a Queen — это множество позиций, на которые этот ферзь может переместиться. Другой ферзь находится в позиции (QOtherRow, QOtherCol). Функция печатает доску, помещая символ "X" в каждую клеточку, на которую может переместиться первый ферзь, и символ " — в другие клеточки. Если один из ферзей находится в клеточке, печатается "Q". Напишите функцию int AttackingQeens(int QlRow, int QlCol, int Q2Row, int Q2Col); которая принимает позицию двух ферзей и определяет, являются ли они атакующими. Это выполняется определением того, находятся ли они в одной и той же строке, столбце или диагонали. Напишите main-функцию, читающую позиции двух ферзей и печатающую множество возможных положений, на которые каждый ферзь может переместиться. Сообщение указывает, являются ли ферзи атакующими.
глава Связанные списки 9.1. Класс Node 9.2. Построение связанных списков 9.3. Разработка класса связанного списка 9.4. Класс LinkedList 9.5. Реализация класса LinkedList 9.6. Реализация коллекций со связанными списками 9.7. Исследовательская задача: Буферизация печати 9.8. Циклические списки 9.9. Двусвязные списки 9.10. Практическая задача: Управление окнами Письменные упражнения Упражнения по программированию
Мы уже определили класс коллекций, которые реализованы на базе массивов. Коллекции включали стеки, очереди и более общий класс SeqList, поддерживающий последовательный список элементов. В этой главе разрабатываются связанные списки, которые предоставляют более гибкие методы добавления и удаления элементов. Массив может использоваться с простыми приложениями сохранения списков. Например, многие авиалинии продают незарезервированные билеты, которые могут быть выкуплены клиентами как посадочный билет. Во время предварительной продажи билетов авиалиния ведет список клиентов, добавляя в него их имена. Перед полетом каждого клиента регистрирует служащий авиакомпании, который удаляет имя из билетного списка и добавляет его в список пассажиров. С помощью таких списков авиакомпания имеет доступ к количеству пассажиров на борту самолета и к количеству клиентов с еще неоплаченными билетами. Списки, основанные на массиве, недостаточно эффективны для приложений, требующих более гибких методов обработки. Рассмотрим пример ресторана, в котором выполняется резервирование мест. Метрдотелю необходимо вводить имена в список с упорядочением по времени и пользоваться несколькими критериями для удаления имени из списка. Немедленное удаление происходит, если клиент звонит по телефону и отменяет заказ или, если клиент приходит и занимает место. Периодически метрдотель должен просматривать список и удалять имена клиентов, которые потеряли резервирование, не появившись в ресторане в течение 15 минут после времени заказа. Ресторан Jones Dolan */ $;45 Banks Dal porto 1Ш Johnson Ресторан 6:30 Jones V' Dolan S 6;45 Banks Dal porto i/ 7:00 Johnson Ресторан 6:30 Jones S Dolan i/ 6;45 Banks i/ Dal porto v 7:00 Johnson Время 6:15 (Dolan - отменил) Время 6:40 (Jones, Dal porto - пришли) Время 7:05 (Banks - удалить) Основанный на массиве список не может эффективно работать с ресторанной системой резервирования. Имена должны вставляться в различные места в списке с учетом различного времени прихода клиентов. Для этого необходимо, чтобы имена были сдвинуты вправо. Удаления требуют, чтобы имена сдвигались влево. Работники ресторана не могут предвидеть количество зарезервированных мест и должны выделять преувеличенный объем памяти для обработки экстренных случаев. Когда начинается обслуживание, список резервирования становится очень динамичным с быстрым движением имен в список и из него. Массивы с их непрерывной памятью не отвечают этим динамическим изменениям. В последующем обсуждении рассматриваются некоторые из этих проблем с массивами и разрабатываются решения, которые используют динамические связанные списки.
Массив — это структура фиксированной длины. Даже динамический массив имеет фиксированный размер после операции изменения размера. Например, массив А создается динамически как массив целых из 6 элементов: А = new int [б]; | 74 | 60 | 25 | 82 | 65 | 23 | Когда массив заполнен, мы можем добавлять элементы только после изменения размера списка — процесс, требующий копирования каждого элемента в новую память. Частое изменение размера больших списков может существенно повлиять на эффективность приложения. Массив сохраняет элементы в смежных ячейках памяти, что делает возможным прямой доступ к элементу, но не позволяет эффективно выполнять добавление и удаление элементов, если только эти операции не выполняются в конце массива. Например, предположим, что вы удаляете элемент 60 из второй позиции в следующем списке: | 74 | 60 | 25 | 82 | 65 | 23 индекс 1 Для сохранения непрерывного порядка элементов в списке мы должны сдвинуть четыре элемента влево. 74 | 25 | 82 | 65 | 23 Предположим, что мы добавляем новый элемент 50 в позицию с индексом 2. Так как массив сохраняет элементы в непрерывном порядке, мы должны освободить участок в списке, сдвигая три элемента на одну позицию вправо: | 74 | 25 | 50 | 82 | 65 | 23 | индекс 2 Для списка из N элементов вставка и удаление элемента в конце списка требуют времени вычисления 0(1). Однако для общего случая ожидаемое количество сдвигов равно N/2, а время вычисления — O(N). Описание связанного1 списка Проблема эффективного управления данными является важнейшей для любой реализации списка. Нам необходимо разработать новую структуру, которая освободит нас от непрерывного порядка сохранения данных. Мы можем использовать модель звеньев в цепи. 1 В литературе часто используется термин "связный список". — Прим. ред.
Длина списка может увеличиваться без ограничений при добавлении новых звеньев. Более того, новые элементы могут быть вставлены в список простым разрывом соединения, добавлением нового звена и восстановлением соединения. До вставки После вставки Элементы удаляются из списка разрывом двух соединений, удалением звена и затем повторным соединением цепи. До удаления После удаления В этой главе разрабатывается структура данных, называемая связанный список (linked list), для реализации последовательного списка. Связанные списки предоставляют эффективные методы обработки и снимают многие ограничения, отмеченные при описании массивов. Обзор главы Независимые элементы в связанном списке называются узлами (nodes). Мы разрабатываем класс Node, определяющий отдельные объекты узла и предоставляющий методы обработки связанных списков. В частности, мы реализуем методы узла, которые вставляют и удаляют узлы после текущего узла, разрабатываем алгоритмы, позволяющие использовать отдельные узлы и создавать связанный список, сканировать узлы и обновлять их значения. Мы создаем класс LinkedList, который инкапсулирует основные алгоритмы узла в структуре класса, и используем этот класс для реализации классов Queue и SeqList, снимая тем самым ограничения списков, основанных на массиве. Этот класс применяется для решения множества интересных проблем, включая удаление всех дублированных данных-значений из списка. Класс LinkedList используется в разработке двух практических задач: для моделирования системы буферизации печати и при построении динамических оконных списков. Разработка списка как круговой системы содержит как концептуальные преимущества, так и преимущества кодирования. Двусвязные списки находят применение, когда нам необходимо выполнять поиск элементов в обоих направлениях. Мы определяем классы CNode и DNode, реализующие круговые и двусвязные списочные структуры.
9.1. Класс Node Узел (node) состоит из поля данных и указателя, обозначающего следующий элемент в списке. Указатель — это соединитель, связывающий вместе отдельные узлы списка. Node Node Node Node data next data next • • • data next • • • data next NULL head currPtr rear Связанный список состоит из множества узлов, первый элемент которого (front), — это узел, на который указывает голова (head). Список связывает узлы вместе от первого до конца или хвоста (rear) списка. Хвост определяется как узел, чье поле указателя имеет значение NULL (0). Списочные приложения проходят по узлам, следуя за каждым указателем на следующий узел. В любой точке сканирования на текущее положение ссылается указатель currPtr. Для списка без узлов head будет содержать значение NULL. head NULL Объявление типа Node Узел с его данными (data) и полями указателей (next) является строительным блоком для связанного списка. Структура узла имеет операции, которые инициализируют данные-члены, и методы управления указателями для доступа к следующему узлу. Далее приведен рисунок, иллюстрирующий базовые операции для обработки узлов. В любом данном узле р мы можем реализовать операцию 1п- sertAfter, которая присоединяет новый узел после текущего. Процесс начинается прерыванием соединения с последующим узлом q, вставкой newNode и восстановлением связей. До вставки data next data next После вставки newNode пересоединить пересоединить Аналогичный процесс описывает операцию DeleteAfter, которая удаляет узел, следующий за текущим. Мы отсоединяем р от следующего за ним узла q и затем соединяем р с узлом, следующим за q.
До удаления data next разъединить После удаления data next пересоединить Структура узлов с операциями вставки и удаления описывает абстрактный тип данных. Для каждого узла эти операции относятся непосредственно к его последующему (следующему) узлу. ADT Nod* Данные Поле данных используется для хранения данных, кроме инициализации, значение не используется ни в какой другой операции. Поле next является указателем на последующий узел. Если next — это NULL, то следующего узла нет. Операции Конструктор Начальные значения: Значение данных и указатель на следующий узел. Процесс: Инициализация двух полей. NextNode Вход: Нет Предусловия: Нет Процесс: Выборка значения поля next Выход: Возвращение значения поля next. Постусловия: Нет InsertAfter Вход: Указатель на новый узел. Предусловия: Нет Процесс: Переустановка значения next для указания на новый узел и установка значения next в новом узле для ссылки на узел, следующий за текущим. Выход: Нет Постусловия: Узел теперь указывает на новый узел DeleteAfter Вход: Нет Предусловия: Нет Процесс: Отсоединение следующего узла и присваивание значения next для ссылки на узел, который следует за следующим узлом. Выход: Указатель на удаленный узел. Постусловия: Узел имеет новое значение next. Конец ADT Node ADT Node описывается основанным на шаблоне классом C++.
Спецификация класса Node ОБЪЯВЛЕНИЕ template <class T> class Node { private: // next указывает на адрес // следующего узла Node<T> *next; public: // открытые данные Т data; // конструктор Node (const T& item, Node<T>* ptrnext ■ NULL); // методы модификации списка void InsertAfter(Node<T> *p); Node<T> *DeleteAfter(void); // получение адреса следующего узла Node<T> *NextNode(void) const; }; ОПИСАНИЕ Значение поля next — это указатель на Node. Класс Node является самоссылающейся (self-referencing) структурой, в которой указатель-член (pointer member) ссылается на объекты своего собственного типа. Мы используем объекты Node для разнообразных классов коллекций, таких как словари и хеш-таблицы, и объявляем поле открытых данных для обеспечения удобного доступа к ним. Такой подход менее затруднителен для пользователя, чем использование пары функций-членов, таких как Get- Data/SetData. Преимущества становятся очевиднее, когда необходимо наличие ссылки на данное-член. Это необходимо при реализации более продвинутых классов, таких как словари. Поле next остается закрытым, и доступ к нему обеспечивается функцией-членом NextNode. Оно изменяется только методами InsertAfter и DeleteAfter. Конструктор для класса Node инициализирует поле открытых данных и поле закрытых указателей. По умолчанию значение next устанавливается на NULL. ПРИМЕР Node<int> t(10); // создание узла t с данными // значене * 10, next = NULL 10 NULL P = &t Node<int> *u; u« new Node<int>(20); //выделяет объект в и //value - 20 и next - NULL Node<char> *p, *q, *r; q - new Node<char>('B'); // q имеет данные 'В' p « new Node<char>('A',q); // объявление узла р с данными 'А' // поле next указывает на q г * new Node<char>('C ); // объявление узла г с данными 'С
q->InsertAfter(r); // вставка г в хвост списка cout « p->data; // печать символа 'А' р = p->NextNodeО; // переход к следующему узлу cout « p->data; // печать символа 'В' cout « endl; г = q->DeleteAfter(); // отделение хвостасписка; // присваивание значения г Реализация класса Node Класс Node содержит как открытый, так и закрытый данные-члены. Открытый данное-член предназначается для того, чтобы пользователь и классы коллекций могли иметь прямой доступ к его значению. Поле next является закрытым, и доступом к этому полю указателя управляют функции-члены. Только методам InsertAfter и DeleteAfter позволяется изменять значение поля next. Если сделать это поле открытым, пользователь получит возможность нарушать связь и уничтожать связанный список. Класс Node содержит функцию-член NextNode, которая позволяет клиенту проходить по связанному списку. // конструктор, инициализация данных и указателя template <class T> Node<T>::Node(const T& item, Node<T>* ptrnext) : data(item), next(ptrnext) {} Списковые операции. Метод NextNode предоставляет клиенту доступ к полю указателя next. Этот метод возвращает значение next и используется для прохождения по списку. // возвратить закрытый член next template <class T> Node<T> *Node<T>::NextNode(void) const { return next; } Функции InsertAfter и DeleteAfter являются основными операциями создания списков. В каждом случае процесс включает только изменения указателей. InsertAfter принимает узел р в качестве параметра и добавляет его в список в качестве следующего узла. Первоначально текущий объект указывает на узел, адресом которого является q (значение в поле next). Алгоритм изменяет два указателя. Поле указателей р устанавливается на q, а полю указателей в текущем объекте присваивается значение р. До После Текущий объект Текущий объект // вставить узел р после текущего узла template <class T>
void Node<T>::InsertAfter(Node<T> *p) { // p указывает на следующий за текущим узел, // а текущий узел — на р. p->next = next; next = р; } Порядок присваиваний указателей очень важен. Предположим, что операторы присваивания имеют обратный порядок. next = р; // Узел, следующий за текущим объектом, // потерян! p->next e next После текущий объект Остаток списка потерян! DeleteAfter удаляет узел, который следует за текущим объектом и связывает его поле указателей со следующим узлом в списке. Если после текущего объекта нет никакого узла (next == NULL), функция возвращает NULL. Иначе, функция возвращает адрес удаленного узла для случая, если программисту необходимо освободить память этого узла. Алгоритм DeleteAfter сохраняет адрес следующего узла в tempPtr. Поле next узла tempPtr определяет узел в списке, на который должен теперь указывать текущий объект. Возвращается указатель на узел tempPtr. Процесс требует присваивания только одного указателя. До После next h—H next г—И next next Wm next hH next текущий объект tempPtr текущий объект tempPtr // удалить узел, следующий за текущим, и возвратить его адрес template <class T> Node<T> *Node<T>::DeleteAfter(void) { // сохранить адрес удаляемого узла Node<T> *tempPtr = next; // если нет следующего узла, возвратить NULL if (next =« NULL) return NULL; // текущий узел указывает на узел, следующий за tempPtr. next = tempPtr->next; // возвратить указатель на несвязанный узел return tempPtr; }
9.2. Создание связанных списков Для создания связанных списков мы используем класс Node. В процессе обсуждения этой темы вводятся основные алгоритмы связанных списков, применяемых в большинстве приложений. Материал этого раздела важен для понимания связанных списков, поскольку здесь вы узнаете, как создавать списки и обращаться к узлам. Мы реализуем алгоритмы связанных списков как независимые функции. Овладение этим фундаментальным материалом поможет вам, когда эти методы будут использоваться для создания общего класса связанных списков. Для простоты в нашем обсуждении подразумевается, что узлы списка содержат целые данные. Связанный список начинается с указателя узла, который ссылается на начало списка. Мы называем этот указатель головой, так как он указывает на начало списка. Первоначально значением головы является NULL для указания пустого списка. Мы можем создать связанный список различными способами. Начнем с изучения случая, когда каждый новый узел помещается в голову списка. Позже мы рассмотрим случай, когда узлы добавляются в хвост списка или в промежуточные позиции. Создание узла Мы реализуем создание узла с использованием основанной на шаблоне функции GetNode, которая принимает начальные данное-значение и указатель и динамически создает новый узел. Если выделение памяти происходит неудачно, программа завершается, иначе, функция возвращает указатель на новый узел. // выделение узла с данным-членом item и указателем nextPtr template <class T> Node<T> *GetNode(const T& item, Node<T> *nextPtr - NULL) { Node<T> *newNode; // выделение памяти при передаче item и NextPtr конструктору. // завершение программы, если выделение памяти неудачно newNode ш new Node<T>(item, nextPtr); if (newNode «- NULL) { cerr << "Ошибка выделения памяти!" « endl; exit(1); } return newNode; ) Вставка узла: InsertFront Операция вставки узла в начало списка требует обновления значения указателя головы, так как список должен теперь иметь новое начало. Проблема сохранения головы списка является основной для управления списками. Если вы потеряете голову, вы потеряете список! Перед началом вставки голова определяет начало списка. После вставки новый узел займет положение в начале списка, а предыдущее начало списка займет вторую позицию. Следовательно, полю указателей нового узла присваивается текущее значение головы, а голове присваивается адрес нового
узла. Назначение выполняется с использованием GetNode для создания нового узла, head - GetNode(item,head); head head указывает на р head Пустой список item item NULL newNode newNode Функция InsertFront принимает текущую голову списка, которая является указателем, определяющим список, а также принимает новое значение данных. Она вставляет значение данных в узел в начале списка. Так как голова будет изменяться этой операцией, она передается как ссылочный параметр. // вставка элемента в начало списка template <class T> void InsertFront(Node<T>* & head, T item) { // создание нового узла, чтобы он указывал на // первоначальную голову списка // изменение головы списка head e GetNode (item, head); ) Эта функция и GetNode находятся в файле nodelib.h. Прохождение по связанному списку Начальной точкой любого алгоритма прохождения является указатель головы, так как он определяет начало списка. При прохождении по списку, мы используем указатель currPtr для ссылки на текущее положение в списке. Первоначально currPtr устанавливается на начало списка: currPtr ■ head; Во время сканирования нам необходим доступ к полю данных в текущем положении. Так как data — это открытый член, у нас есть возможность выполнить выборку значения data или присвоить этому члену класса новое значение. currentDataValue ■» currPtr->data/ currPtr->data « newdata; Например, простой оператор cout может быть включен в алгоритм прохождения списка для печати значения каждого узла: cout « currPtr->data; В процессе сканирования мы непрерывно перемещаем currPtr к следующему узлу до тех лор, пока не достигнем конца списка. currPtr ■ currPtr->nextNode();
Прохождение по списку завершается, когда currPtr становится равным NULL. Например, функция PrintList печатает значение данных каждого узла. Голова передается в качестве параметра для определения списка. Второй параметр пользовательского типа AppendNewline указывает на TOj должны ли следовать за выводом два пробела или символ newline. Функция содержится в файле nodelib.h. enum AppendNewline {noNewline,addNewline}; // печать связанного списка template <class T> void PrintList(Node<T> *head, AppendNewline addnl = noNewline) { // currPtr пробегает по списку, начиная с головы Node<T> *currPtr = head; // пока не конец списка, печатать значение данных // текущего узла while(currPtr != NULL) { if(addnl == addNewline) cout « currPtr->data « endl; else cout « currPtr->data « " "; // перейти к следующему узлу currPtr = currPtr->NextNode(); } } Программа 9.1. Сопоставление с ключом Программа генерирует 10 случайных чисел в диапазоне от 1 до 10 и вставляет эти значения как узлы в голову связанного списка, используя InsertFront. Для отображения списка используется Printlist. Программа содержит код, который подсчитывает количество вхождений ключа в список. У пользователя сначала запрашивается ключ, который при прохождении по списку сравнивается с полем данных в каждом узле списка. Выводится общее количество вхождений этого ключа. #include <iostream.h> #pragma hdrstop #include "node.h" #include "nodelib.h" #include "random.h" void main(void) { // установить голову списка в NULL Node<int> *head = NULL, *currPtr; int i, key, count = 0; RandomNumber rnd; // ввести 10-ть случайный чисел в начало списка for (i=0;i < 10;i++) InsertFront(head, int(1+rnd.Random(10))); // печать исходного списка
cout « "Список: "; PrintList(head,noNewline); cout « endl; // запросить ввод ключа cout « "Введите ключ: "; cin » key; // цикл по списку currPtr = head; while (currPtr != NULL) { // если данные совпадают с ключом, увеличить count if (currPtr->data — key) count++; // перейти к следующему узлу currPtr = currPtr->NextNode(); } cout « "Значение " « key « " появляется " « count « " раз(а) в этом списке" << endl; } /* <Выполнение программы 9.1> Список: 365752459 10 Введите ключ: 5 Значение 5 появляется 3 раз(а) в этом списке */ Вставка узла: InsertRear Помещение узла в хвост списка требует начального тестирования для определения, пуст ли список. Если да, то создаем новый узел с нулевым указателем поля и присваиваем его адрес голове. Операция реализуется функцией Insert- Front. При непустом списке мы должны сканировать узлы для обнаружения хвостового узла (в котором поле next содержит значение NULL). currPtr->NextNode() == NULL; currPtr item NULL newMode Вставка выполняется следующим образом: сначала создается новый узел (GetNode), затем он вставляется после текущего объекта Node (InsertAfter). Так как вставка может изменять значение указателя головы, голова передается как ссылочный параметр: // найти хвост списка и добавить item template <class T> void InsertRear(Node<T>* & head, const T& item)
Node<T> *newNode, *currPtr - head; // если список пуст, вставить item в начало if (currPtr « NULL) InsertFront(head,item) ; else { // найти узел с нулевым указателем while(currPtr->NextNode() !- NULL) currPtr » currPtr->NextNode(); // создать узел и вставить в конец списка // (после currPtr) newNode * GetNode(item); currPtr->InsertAfter(newNode); } } InsertRear содержится в файле nodelib.h. Программа 9.2. Головоломка В этой программе беспорядочно смешиваются буквы слова для создания слова-путаницы. Процесс сканирует каждый символ в строке и произвольно помещает его либо в начало, либо в хвост списка. Для каждого символа вызываем random(2). Если возвращаемое значение является равным 0, вызываем InsertFront, иначе — InsertRear. Например, с вводом j-u-m-b-1-e и последовательностью случайных чисел 0110 01 получаем результирующий список lbjume. Программа читает и записывает четыре слова-головоломки. ♦include <iostream.h> tpragma hdrstop ♦include "random.h" ♦include "strclass.h" ♦include "nodelib.h" void main(void) { // список узлов для символов головоломки (Jumbled) Node<char> *jumbleword = NULL; // входная строка, генератор случайных чисел, счетчики String s; RandomNumber rnd; int i, j; // ввести четыре строки for (i - 0; i < 4; i++) { cin » s; // использование Random(2) для определения направления движения // символа:в начало (value «0), или в конец (value « 1) списка for (j « 0; j < s.LengthO; j++) if (rnd.Random(2)) InsertRear(jumbleword, s[j]); else InsertFront(jumbleword, s[j]);
// печать входной строки и ее перепутанного варианта cout « "String/Jumble: n « s « " "; PrintList(jumbleword); cout « endl « endl; ClearList (jumbleword); } } /* <Выполнение программы 9.2> pepper String/Jumble: pepper r p p e p e hawaii String/Jumble: hawaii i i h a w a jumble String/Jumble: jumble e b m j u 1 C++ String/Jumble: C++ + С + */ Удаление узла. В этом разделе уже обсуждались алгоритмы для сканирования списка и для вставки новых узлов. Третья списочная операция, удаление узла из списка, знакомит с рядом новых проблем. Нам часто бывает необходимо удалять первый узел в списке. Операция требует, чтобы мы изменяли голову списка для указания на последующий узел за бывшим начальным узлом. head next head head-» NextModeO Функция DeleteFront, которой передается голова списка как ссылочный параметр, отцепляет от списка первый узел и освобождает его память: // удалить первый узел списка template <class T> void DeleteFront(Node<T>* & head) { // сохранить адрес удаляемого узла Node<T> *p * head; // убедиться в том, что список не пуст if (head !» NULL) { // передвинуть голову к следующему узлу и удалить оригинал head ■ head->NextNode(); delete p; } } Общая функция удаления проверяет список и удаляет первый узел, чье значение данных совпадает с ключом. Устанавливаем prevPtr на NULL, a currPtr — на голову списка. Затем перемещаем currPtr по списку в поисках совпадения с ключом и сохраняем prevPtr, так чтобы он ссылался на предыдущее значение currPtr.
prevPtr key currPtr Указатели prevPtr и currPtr перемещаются по списку совместно до тех пор, пока currPtr не станет равным NULL или не будет найден ключ (currPtr->data == key). while (curPtr != NULL && currPtr->data !=key) { // перемещение prevPtr вперед к currPtr prevPtr = currPtr; // перемещение currPtr вперед на один узел currPtr = currPtr->NextNode(); } Совпадение возникает, если мы выходим из оператора while с currPtr!=NULL. Тогда мы можем удалить текущий узел. Существует две возможности. Если prevPtr — это NULL, удаляем первый узел в списке. Иначе, удаляем узел, выполняя DeleteAfter для узла prevPtr. if (prevptr == NULL) head = head->NextNode(); else prevPtr->DeleteAfter{); Если ключ не найден, метод Delete просто завершается. Так как удаление в начале списка приводит к изменению головы, мы должны передавать голову по ссылке. // удаление первого элемента, совпадающего с ключем template <class T> void Delete (Node<T>* & head, T key) { Node<T> *currPtr = head, *prevPtr = NULL; // завершить функцию, если список пустой if (currPtr == NULL) return; // прохождение по списку до совпадения с ключем или до конца while (currPtr !- NULL && currPtr->data !- key) { prevPtr * currPtr; currPtr = currPtr->NextNode(); } // если currPtr не равно NULL, ключ в currPtr. if (currPtr != NULL) { // prevPtr == NULL означает совпадение в начале списка if(prevPtr == NULL) head = head->NextNode(); else // совпадение во втором или последующем узле // prevPtr->DeleteAfter() отсоединяет этот узел prevPtr->DeleteAfter(); // удаление узла delete currPtr; } }
Приложение: Список выпускников Запись StudentRecord содержит имя и средний балл кандидата на выпуск из университета. Мы приступаем к созданию списка студентов, которые пройдут через выпускную церемонию. Список кандидатов на выцуск считывается из файла studrecs и вставляется в начало списка. Так как по университетским правилам студент со средним баллом ниже, чем 2.0, не допускается к выпуску, мы просматриваем список и удаляем всех кандидатов, средний балл которых не удовлетворяет минимальным требованиям. Второй список из файла noattend представляет студентов, которые не собираются присутствовать на выпускной церемонии, и используется для удаления дополнительных имен из выпускного списка. Оставшиеся элементы представляют собой список студентов, получивших квалификацию и планирующих принять участие в выпускной церемонии. struct StudentRecord { String name; float gpa; }; Программа 9.З. Список выпускников университета Каждая запись, считываемая из файла studrecs, вставляется в начало связанного списка graduateList с использованием функции InsertFront библиотеки Node. Указатели prevPtr и currPtr используются для сканирования списка и удаления всех студентов, средний балл которых ниже, чем 2.0. После удаления имен студентов, которым не присвоена квалификация, мы считываем имена из файла noattend и удаляем из полученного ранее списка студентов, не принимающих участия в выпускной церемонии. В заключение выводим полученный список. #include <iostream.h> #include <fstream.h> #include <stdlib.h> #include <iomanip.h> tpragma hdrstop #include "node.h" #include "nodelib.h" #include "studinfo.h" void main(void) { Node<StudentRecord> *graduateList=NULL, *currPtr, *prevPtr, *deletedNodePtr; StudentRecord srec; ifstream fin; fin.open("studrecs",ios::in | ios: mocreate); if (!fin) { cerr « "Невозможно открыть файл studrecs." « endl/ exit(1); } // вывод среднего балла (gpa) cout.setf(ios::fixed);
cout.precision(1); cout.setf{ios:rshowpoint); while(fin » srec) { // вставить srec в голову списка InsertFront(graduateList,srec); ) prevPtr « NULL; currPtr « graduateList; while (currPtr !» NULL) { if (currPtr->data.gpa < 2.0) { if (prevPtr ■- NULL) // запись в начале списка? { graduateList * currPtr->NextNode(); deletedNodePtr - currPtr; currPtr ■ graduateList; } else // удалить узел внутри списка { currPtr » currPtr->NextNode(); deletedNodePtr « prevPtr->DeleteAfter(); ) delete deletedNodePtr; ) else { // нет удаления, передвинуть указатели вниз prevPtr - currPtr; currPtr = currPtr->NextNode(); > } fin.close (); fin.open("noattend",ios: :in I ios: mocreate) ; if (!fin) { cerr « " Невозможно открыть файл noattend." « endl; exit(l); > while(srec.name.ReadString(fin) !« -1) Delete(graduateList,srec); cout << "Пристствующие на выпускной церемонии:и « endl; PrintList(graduateList,addNewline); } /* <Файл "studrecs,,> Julie Bailey 1.5 Harold Nelson 2.9 Thomas Frazer 3.5 Bailey Harnes 1.7 Sara Miller 3.9 Nancy Barnes
2.5 Rebecca Neeson 4.0 Shannon Johnson 3.8 <Файл "noattend"> Thomas Frazer Sara Miller <Выполнение программы 9.3> Присутствующие на выпускной церемонии: Shannon Johnson 3.8 Rebecca Neeson 4.0 Nancy Barnes 2.5 Harold Nelson 2.9 V Создание упорядоченного списка Во многих приложениях нам необходимо поддерживать упорядоченный список данных с узлами, приведенными в возрастающем или убывающем порядке. Для этого алгоритм вставки должен сначала сканировать список для определения правильного положения, в которое будет добавляться новый узел. Последующее обсуждение иллюстрирует процесс создания списка с возрастающим порядком. Для ввода значения данных X мы сначала сканируем список и устанавливаем currPtr на первый же узел, значение данных которого больше, чем X. Новый узел со значением X должен вставляться непосредственно слева от currPtr. Во время сканирования указатель prevPtr перемещается совместно с currPtr и сохраняет запись предыдущей позиции. Следующий пример иллюстрирует этот алгоритм. Предположим, что список L первоначально содержит целые 60, 65, 74 и 82. Вставка 50 в список: Поскольку 60 является первым узлом в списке, большим, чем 50, мы вставляем 50 в голову списка. head=currPtr newNode insertFront(head,50); Вставка 70 в список: 74 — это первый узел в списке, больший, чем 70. Указатели prevPtr и currPtr обозначают узлы 65 и 74, соответственно.
:head; prevPtr currPtr newNode newNode = GetNode(70); prevPtr->InsertAfter(newNode); Вставка 90 в список: Мы сканируем весь список и не можем найти узел, больший, чем 90 (currPtr ==NULL). Новое значение больше, или равно всем другим значениям в списке и, следовательно, новое значение должно быть помещено в хвост списка. Когда сканирование завершается, вставляем новый узел после prevPtr. prevPtr currPtr newNode newNode = GetNode(90); prevPtr->InsertAfter(newNode); Следующая функция реализует общий алгоритм упорядоченной вставки. Для списка из п элементов, эффективность наихудшего случая имеет место при вставке нового элемента в конец списка. В этом случае должно быть выполнено п сравнений, поэтому наихудший случай имеет порядок О(п). В среднем ожидается, что для нахождения места вставки мы просматриваем половину списка. В результате средний случай имеет порядок О(п). Конечно, наилучший случай имеет порядок 0(1). // вставить item в упорядоченный список template <class T> void InsertOrder(Node<T>* & head, T item) { // currPtr пробегает по списку Node<T> *currPtr, *prevPtr, *newNode; // prevPtr == NULL указывает на совпадение в начале списка prevPtr = NULL; currPtr = head; // цикл по списку и поиск точки вставки while (currPtr != NULL) { // точка вставки найдена, если item < текущего data if (item < currPtr->data) break; prevPtr = currPtr; currPtr = currPtr->NextNode(); } // вставка
if (prevPtr == NULL) // если prevPtr == NULL, вставлять в начало InsertFront(head,item) ; else { // вставить новый узел после предыдущего newNode = GetNode(item); prevPtr->InsertAfter (newNode) ; } } Приложение: сортировка со связанными списками InsertOrder может использоваться для сортировки коллекции элементов, при условии, что оператор сравнения "<" определяется для типа данных Т. Функция LinkSort принимает массив А из п элементов и вставляет эти элементы в упорядоченный связанный список. Затем выполняется прохождение списка, и уцорядоченные элементы копируются обратно в массив. Вызов функции ClearList освобождает память, ассоциированную с каждым узлом в списке. Функция проходит по списку и для каждого узла записывает его адрес, перемещается к следующему узлу и затем удаляет исходный узел. ClearList входит в файл nodelib.h. // удаление свех узлов в связанном списке template <class T> void ClearList(Node<T> * &head) { Node<T> *currPtr, *nextPtr; currPtr = head; while(currPtr != NULL) { // записать адрес следующего узла, удалить текущий узел nextPtr = currPtr->NextNode(); delete currPtr; currPtr = nextPtr; } // пометить как пустой head = NULL; ) Программа 9.4. Сортировка вставками Данная программа принимает в качестве параметра массив целых А из 10 элементов и сортирует список, используя шаблонную функцию LinkSort. Полученный в результате упорядоченный массив, выводится с помощью PrintArray. #include <iostream.h> #pragma hdrstop ♦include "node.h" ♦include "nodelib.h" template <class T> void LinkSort(T a[], int n)
{ Node<T> *ordlist - NULL, *currPtr; int i; // вставлять элементы из массива в список с упорядочением for (i«0;i < n;i++) InsertOrder(ordlist, a[i]); // сканировать список и копировать данные в массив currPtr e ordlist; i = 0; while(currPtr != NULL) { a[i++] = currPtr->data; currPtr « currPtr->NextNode(); } // удалить все узлы, созданные в упорядоченном списке ClearList(ordlist); } // сканировать массив и печатать его элементы void PrintArray(int a[], int n) { for(int i=0;i < n;i++) cout « a[i] « " "; } void main(void) { // инициализировать массив с 10 целыми значениями int A[10] = {82,65,74,95,60,28,5,3,33,55}; LinkSort(А,10); // сортировать массив cout « " Отсортированный массив: "; PrintArray(А,10); // печать массива cout « endl; > Л <Выполнение программы 9.4> Отсортированный массив: 3 5 28 33 55 60 65 74 82 95 */ При анализе эффективности времени исполнения алгоритма LinkSort следует принимать во внимание начальное упорядочение элементов массива. Наихудший случай соответствует списку, который уже отсортирован по возрастанию. Каждый элемент вставляется в конец этого списка. Первая вставка выполняется без сравнений, вторая вставка выполняется с одним сравнением, третья — с двумя сравнениями и так далее. Общее количество сравнений составляет (п — 1) * п 0 + 1 + 2 + . . . + - y что имеет порядок 0(п2). Другая крайность — список, который отсортирован в убывающем порядке, требует только п — 1 сравнений, так как каждый элемент массива вставляется в начало списка. Поэтому наилучший случай имеет порядок О(п), а наихудший — 0(п2). Интуитивно, средний случай имеет п вставок с j-тым элементом ввода, требующим ожидаемых j/2 сравнений. Общее количество сравнений равно 0(п2). В отличие от сортировки типа in-place
(такой как ExchangeSort), LinkSort требует дополнительной памяти для всех п элементов данных, а также памяти для указателей в связанном списке. 9.3. Разработка класса связанного списка Программист может использовать класс Node вместе с утилитными функциями, находящимися в nodelib.h, для работы с приложениями связанных списков. Такой подход вынуждает программиста создавать каждый узел и выполнять непосредственно списочные операции низкого уровня. Более структурированный подход определяет класс связанных списков, который организует базовые списочные операции как функции-члены. В этом разделе обсуждается тип данных-членов и операций, которые должны быть включены в класс связанных списков, с использованием знаний, приобретенных при разработке алгоритмов с классом Node. Мы также заранее оговариваем тот факт, что наш класс связанных списков будет использоваться для реализации других списочных коллекций, которые включают связанные стеки, очереди и класс SeqList. В следующем разделе мы объявляем класс LinkedList и определяем его функции-члены. Данные-члены связанных списков Связанный список состоит из множества объектов Node, связанных вместе от начала до конца списка. Список имеет начальный узел, называемый головой, который определяет первый узел списка. Последний узел в списке имеет поле указателей со значением NULL, и на него ссылаются как на указатель-хвост. Нам необходимо, чтобы класс связанных списков содержал указатели на начало и на хвост списка, так как это полезно для многих приложений и важно при реализации связанной очереди. Связанный список предоставляет последовательный доступ к элементам данных и использует указатель для задания текущего положения прохождения в списке. Наш связанный список содержит указатель currPtr для ссылки на это текущее положение в терминах его места в списке. Первый элемент в списке имеет позицию (position) 0 со следующим элементом в позиции 1 и так далее. Количество элементов в связанном списке содержится в переменной size. Это позволяет нам определять пустой список или возвращать счетчик количества элементов в списке. В следующем списке текущим положением является элемент со значением 90, расположенный в позиции 3: Адрес prevPtr используется для вставки и удаления узла в текущем положении с использованием Node-методов InsertAfter и DeleteAfter. Например, Данные связанного списка front rear prevPtr currPtr position * 3 size«5 position 60 next W 20 next W 30 next W 90 next Ы 10 NULL prevPtr currPtr
в следующем связанном списке показана вставка с использованием объекта Node, на который ссылается prevPtr: Insertion:prevPtr->InsertAfter(p) До После prevPtr currPtr prevPtr Р currPtr Узел, на который ссылается prevPtr, также используется при удалении элемента из связанного списка: Deletion:prevPtr->DeleteAfter(p) Деление: prevPtr->DeleteAfter(p) prevPtr 1 . currPtr Операции связанных списков Пользователь должен иметь возможность переходить от элемента к элементу в списке. Простой метод с именем Next передвигает текущее значение к следующему узлу. В текущем положении операция Data возвращает ссылку на поле данных узла. Это дает возможность выполнять выборку или изменять поле данных узла без необходимости понимания пользователем того, как эти данные сохраняются классом Node. Для иллюстрации этих операций предположим, что L является связанным списком целых, чье текущее положение находится в узле со значением данных 65. Следующие операторы изменяют значение текущего узла на 67 и значение следующего узла — на 100: Linkedlist<int> L; • • • if (L.Data() < 70) //сравнение значения текущего узла с 70 L.Data () = 67; //если меньше, присваивание значения 67 L.Next (); //продвижение текущего положения к следующему узлу. L.Data() = 100; //вставляет 100 в новое положение До После 60 И 65 И 74 Ь* 60 И 67 И 100 currPtr currPtr
В приложениях нам иногда необходимо устанавливать текущее положение в определенное место в списке. Это выполняет метод Reset. Он принимает параметр pos и перемещает текущее положение списка в эту позицию. Значением по умолчанию параметра pos является 0, которое устанавливает текущее положение на первый элемент списка. От него приложение может сканировать узлы, используя Next. Сканирование списка завершается, когда условие EndOfList является равным TRUE. Например, простой цикл выводит элементы в списке. Перед сканированием списка мы сначала проверяем условие ListEmpty (пустой список): L.Reset О; // установка currPtr в положение первого // элемента списка if (L.ListEmpty()) // пуст ли список? cout « "Empty list\n"; else while(IL.EndOfList()) // сканирование до конца списка { cout « L.Data () « endl; // вывод значения данных L.Next(); // перемещение к следующему узлу } Пользователь имеет доступ к текущей позиции списка при помощи метода CurrentPosition. Метод Reset может применяться для возвращения указателей списка в исходное положение. Эта возможность используется в таких задачах, как нахождение максимального значения в списке и установка списка на этот узел для последующего удаления или вставки. //сохранение текущей позиции int currPos = L.CurrentPosition(); <команды, которые сканируют список вправо от currPos> // переустановка текущего положения в // бывшую позицию currPos. L.Reset(currPos); Добавление и удаление узлов являются основными операциями в связанном списке. Эти операции могут выполняться в начале и хвосте списка или в текущем положении. Операции вставки. Операции вставки создают новый узел с новым полем данных. Затем узел помещается в список в текущее положение или непосредственно после него. Операция InsertAfter помещает новый узел после текущей позиции и присваивает currPtr новому узлу. Эта операция служит той же цели, что и метод InsertAfter в классе Node. Метод InsertAfter помещает новый узел в текущее положение. Новый узел помещается непосредственно перед текущим узлом. Текущая позиция устанавливается на новый узел. Эта операция используется при создании упорядоченного списка. Класс упорядоченных списков имеет операции Insertfront и InsertRear для добавления новых узлов в голову и в хвост списка. Эти операции устанавливают текущую позицию на новый узел. Операции удаления. Операции удаления удаляют узел из списка. DeleteAt удаляет узел в текущей позиции, a DeleteFront удаляет первый узел в списке.
Пример: Linked List<int> L; L.InsertFront(100); // список содержит 100 L.InsertAfter(200); // список содержит 100 200 L.InsertAtpOO); // список содержит 100 300 200 L.InsertRear(50); // список содержит 100 300 200 50 L.Reset(l); // установка currPtr на 300 L.DeleteAt(); // список содержит 100 200 50 L.DeleteAt(); // список содержит 100 50 L.DeleteAtO; // список содержит 100; Другие методы. Класс связанных списков создает динамические данные, поэтому он должен иметь конструктор копирования, деструктор и перегруженный оператор присваивания. Пользователь может явно очистить список с помощью оператора ClearList. 9.4. Класс LinkedList В данном разделе представлен класс LinkedList как простой, но мощный инструмент для динамической обработки списков. Основное внимание уделяется спецификации класса и примерам программ, иллюстрирующим его использование. Объявление класса и его реализация находятся в файле link.h. Спецификация класса LinkedList ОБЪЯВЛЕНИЕ ♦include <icstreara.h> ♦include <stdlib.h> ♦include "node.h" template <class T> class LinkedList { private: // указатели для доступа к началу и концу списка Node<T> *front, *rear; // используются для извлечения, вставки и удаления данных Node<T> *prevPtr, *currPtr; // число элементов в списке int size; // положение в списке, используется методом Reset int position; // закрытые методы создания и удаления узлов Node<T> *GetNode(const T& item,Node<T> *ptrNext=NULL); void FreeNode(Node<T> *p); // копирует список L в текущий список void CopyList(const LinkedList<T>& L); public: // конструктор LinkedList(void); LinkedList(const LinkedList<T>& L) ; // деструктор
-LinkedList(void); // оператор присваивания LinkedList<T>& operator= (const LinkedList<T>& L); // проверка состояния списка int ListSize(void) const; int ListEmpty(void) const; // методы прохождения списка void Reset(int pos = 0); void Next(void); int EndOfList(void) const; int CurrentPosition(void) const; // методы вставки void InsertFront(const T& item); void InsertRear(const T& item); void InsertAt(const T& item); void InsertAfter(const T& item); // методы удаления T DeleteFront(void); void DeleteAt(void); // возвратить/изменить данные T& Data(void); // очистка списка void ClearList(void); }; ОБСУЖДЕНИЕ Класс использует динамическую память, поэтому ему необходимы конструктор копирования, деструктор и перегруженный оператор присваивания. Закрытые методы GetNode и FreeNode выполняют все выделения памяти для этого класса. Если при выделении памяти происходит ошибка, метод GetNode завершает программу. Класс поддерживает размер списка, доступ к которому обеспечивается использованием методов ListSize и ListEmpty. Закрытые данные-члены currPtr и prevPtr поддерживают запись текущегр положения при прохождении списка. Методы вставки и удаления отвечают за изменение этих значений после выполнения операции. Метод Reset явно задает значение currPtr и prevPtr. Класс содержит гибкие методы прохождения, Reset принимает позиционный параметр и устанавливает текущее положение в эту позицию. Он имеет параметр по умолчанию 0 для того, чтобы при его использовании без аргументов, метод устанавливал текущую позицию в голову списка. Метод Next продвигается к следующему узлу списка, a EndOfList указывает, был ли достигнут конец списка. Например, для списка L цикл FOR осуществляет последовательное сканирование списка. for (L.Reset (); !L.EndOfList (); L.NextO) <посещение текущего положения> CurrentPosition возвращает текущее положение при прохождении списка. Для посещения текущего узла позже сохраните возвращаемое значение и впоследствии передайте его как параметр методу Reset.
Вставки могут производиться в любом конце списка с использованием InsertFront и InsertRear. InsertAt вставляет новый узел в текущую позицию в списке, a Insert After вставляет узел после текущей позиции. Если текущее положение находится в конце списка (EndOfList == True), то и InsertAt, и InsertAfter помещают новый узел в хвост списка, DeleteFront удаляет первый элемент из списка, a DeleteAt удаляет узел в текущей позиции списка. Для любого из этих двух методов попытка удаления из пустого списка завершает программу. Метод Data используется для чтения или изменения данных в текущей позиции в списке. Так как Data возвращает ссылку на данные в узле, он может использоваться в правой или левой части оператора присваивания. // выборка данного из текущего узла и увеличение его // значения на 5 L.DataO = L.DataO + 5; ClearList удаляет все узлы списка и помечает список как пустой. ПРИМЕР LinkedList<int> L/ К; // объявление списков целых L, К // добавление 25 целых к этим спискам for(i=0; i<25; i++) { cin » num; L.InsertRear(num); // узлы вставляются в порядке ввода К.InsertFront(num); // узлы в порядке, обратном вводу } // сканирует список L, изменяя каждый узел на его // абсолютное значение for(L.Reset(); !L.EndOfList();L.Next()) // если данное отрицательное, сохранение нового значения if (L.DataO < 0) L.DataO = -L.DataO; К.InsertFront(100); // сохранение 100 в начале К К.InsertAfter (200); //вставка 200 после 100 К.InsertAt(150); //вставка 150 между 100 и 200 // вывод списка L void PrintList(LinkedList<int>& L) { // перемещение в голову списка L и прохождение списка с // выводом каждого элемента for(L.Reset() ; ! L. EndOfList (); L.Next О) cout « L.DataO « " "; ) Перед реализацией класса LinkedList мы иллюстрируем его основные возможности в серии примеров. Функция конкатенации объединяет два списка, присоединяя второй список к хвосту первого. Второй пример реализует версию сортировки выбором связанного списка. Классическая версия алгоритма сортирует массив на месте. В нашей реализации широко используются вставки и удаления со связанными списками. Раздел завершается алгоритмом, который удаляет узлы-дубликаты из списка.
Конкатенация двух списков Функция конкатенирует два списка, выполняя упорядочение узлов во втором списке и вставляя каждое из его значений в хвост первого списка. Функция используется в законченной программе concat.cpp в программном приложении. Функция ConcatLists сканирует второй список и методом Data извлекает из каждого узла значение данных, которое затем используется для добавления нового узла в хвост первого списка с использованием метода InsertRear. template <class T> // добавление списка L2 в конец L1 void ConcatLists(LinkedList<T>& LI, LinkedList<T>& L2) { // переустановка обоих списков на начало LI.Reset(); L2.Reset(); // прохождение L2. вставка каждого значения данных в // хвост L1 while (!L2.End0fList()) { LI. InsertRear(L2.Data()); L2.Next(); } } Сортировка списка Мы реализуем версию сортировки выбором для связанного списка, используя два отдельных связанных списка. Первый список L содержит неотсортированное множество данных. Второй список К создается как копия списка L со значениями в отсортированном порядке. Алгоритм удаляет элементы из списка L в порядке от наибольшего до наименьшего элемента и вставляет их в начало списка К, который становится упорядоченным списком. Сортировка выбором требует многократного сканирования списка. Мы используем функцию FindMax для сканирования списка и установки текущей позиции на максимальный элемент. После выборки значения данных из этой позиции вставляем новый узел с этим значением в начало списка К, используя InsertFront, и затем удаляем этот максимальный узел из списка L, используя DeleteAt. Рассмотрим следующий пример: Список L содержит элементы 57, 40, 74, 20, 62 Cfront Шаг 1: Находим максимальное значение 74. Удаляем из L и добавляем в К. L <front 57 * 40 * 20 62 К <f ront 74 57 40 74 20 62
Шаг 2: Находим максимальное значение 62. Удаляем из L и добавляем в К. L <front 57 40 20 К <f ront. 62 74 Программа 9.5. Сортировка списков выбором Эта программа создает список L с 10 случайными числами в диапазоне от 0 до 99. После вывода начального списка функцией PrintList мы используем алгоритм сортировки выбором для переноса элементов из списка L в список К. В конце программы снова вызывается PrintList для вывода значений из К, который содержит элементы в возрастающем порядке. #include <iostream.h> #pragma hdrstop #include "link.h" linclude "random.h" // установка L на его максимальный элемент template <class T> void FindMax(LinkedList<T> &L) { if (L.ListEmptyO ) { cerr « "FindMax: Список пустой!" « endl; return; } // вернуться на начало списка L.Reset(); // записать первое значение списка как текущий максимум // с нулевой позицией Т max = L.Data(); int maxLoc = 0; // перейти к следующему узлу и сканировать список for (L.Next (); IL.EndOfList(); L.NextO) if (L.DataO > max) { // новый максимум, записать значение и // положение в списке max = L.Data(); maxLoc = L.CurrentPosition(); } L.Reset(maxLoc); } // печатать список L template <class T>
void PrintList(LinkedList<T>& L) { // перейти к началу списка L. // проходить список и печатать данные каждого узла for(L.Reset(); IL.EndOfList(); L.Next О) cout « L.Data() « " "; } void main(void) { // список L размещается в сортированном порядке в списке К LinkedList<int> L, К; RandoraNumber rnd; int i; // L — это список из 10-ти случайных целых в диапазоне 0-99 for(i«0; i < 10; i++) L.InsertRear(rnd.Random(100)); cout « "Исходный список: "; PrintList(L); cout « endl; // удалить данные из L, вставляя их в К while (!L.ListEmpty()) { // найти максимум оставшихся элементов FindMax(L); // вставить максимум в начало К и удалить его из L K.InsertFront(L.Data()); L.DeleteAt(); } cout « "Отсортированный список: "; PrintList(К); cout « endl; } /* <Выполнение программы 9.5> Исходный список: 82 72 62 3 85 33 58 50 91 26 Отсортированный список: 3 26 33 50 58 62 72 82 85 91 */ Удаление дубликатов. Алгоритм удаления дубликатов в списке является интересным приложением класса LinkedList. После создания списка L начинаем сканирование его узлов. В каждом узле записываем позицию узла и его значение данных. Это дает нам ключ для начала поиска дубликатов в оставшемся списке, а также — позицию для возвращения после удаления дубликатов. От текущей позиции сканируем хвост списка, удаляя все узлы, значение данных которых совпадает с ключом, затем переустанавливаем текущее положение сканирования в позицию исходного значения и переходим вперед на один узел для продолжения этого процесса.
текущая позиция 5 Ь-* 7 Н-М 5 4 Ь-* 5 , сканировать подсписок и удалять все узлы со значением 5 | новая текущая позиция 7 4 Программа 9.6. Удаление дубликатов В этой программе используется алгоритм удаления дубликатов. Начальный список имеет 15 случайных значений в диапазоне 1—7. После вывода списка вызываем функцию RemoveDuplicates, удаляющую дубликаты из списка. Получаемый в результате список выводится с использованием функции PrintList, которая включена в link.h. #include <iostream.h> #pragma hdrstop #include "link.h" #include "random.h" // печать списка L template <class T> void PrintList(LinkedList<T>& L) { // перейти к началу списка L. // прохождение списка и печать каждого элемента for (L. Reset () ; IL.EndOfListО; L.NextO) cout « L.Data() « " "; } void RemoveDuplicates(LinkedList<int>& L) { // текущее положение в списке и значение данных int currPos, currValue; // перейти к началу списка L.Reset (); // цикл по списку while(IL.EndOfList()) { // записать данные текущего положения в списке //и это положение currValue = L.DataO; currPos = L.CurrentPositionO; // перейти к узлу справа L.NextO; // двигаться вперед до конца списка, удаляя все появления currValue while(IL.EndOfList()) // если узел удален, текущее положение — это следующий узел if (L.DataO == currValue)
L.DeleteAtO ; else L.Next(); // перейти к следующему узлу // перейти к первому узлу со значением currValue. // идти вперед L.Reset(currPos); L.Next(); } } void main(void) { LinkedList<int> L; int i; RandomNumber rnd; // вставить 15 случайных целых в диапазоне 1-7 и печатать список for(i=0; i < 15; i++) L.InsertRear(1+rnd.Random(7)); cout << " Исходный список: "; PrintList(L); cout « endl; // удалить все значения-дубликаты и печатать новый список RemoveDuplicates(L); cout << " Окончательный список: "; PrintList(L); cout « endl; } /* <Выполнение программы 9. 6> Исходный список: 177151272166364 Окончательный список: 17 5 2 6 3 4 V 9.5. Реализация класса LinkedList Спецификация класса LinkedList ссылается на класс Node. Реализация класса LinkedList использует много методов, описанных разделе 9.1 с классом Node. Алгоритмы, использующемые функциями в nodelib.h, являются основой для разработки класса LinkedList. Однако мы должны знать о дополнительной сложности поддержания указателей front и rear, которые определяют доступ к списку, указателей currPtr и prevPtr, которые сохраняют информацию о текущем положении прохождения, местонахождения данных и size (размера списка). Методы LinkedList отвечают за изменение этих данных всякий раз, когда изменяется состояние списка. Закрытые данные-члены. Класс ограничивает доступ к данным, так как эта информация используется только функциями-членами. Связанный список состоит из множества объектов Node, связанных вместе от начала до хвоста списка. Мы определяем начало или голову списка как данные-члены. Для более легкого выполнения вставок в хвост списка этот класс поддерживает
указатель rear на последний узел списка. Это избавляет от сканирования всего списка для нахождения положения хвоста. Переменная size содержит количество узлов в списке. Это значение используется для определения, является ли список пустым, и для возвращения количества значений данных в списке. Переменная position делает более легкой переустановку текущего положения при прохождении списка методом Reset. Объект LinkedList содержит два указателя, определяющих текущее (currPtr) и предыдущее положение (prevPtr) в списке. Указатель currPtr ссылается на текущий узел списка и используется методом Data и методом вставки InsertAfter. Указатель prevPtr используется методами DeleteAt и InsertAt, действующими для текущего положения. При выполнении вставок и удалений этот класс изменяет поля front, rear, position и size объекта списка. Данные класса LinkedList front rear prevPtr currPtr position - 2 size=4 data next f-W data next Ы data next Ш data NULL Методы выделения памяти. Класс выполняет все вставки и удаления. Конструктор копирования и методы вставки выделяют узлы, тогда как Clear- list и методы удаления уничтожают узлы. Класс LinkedList мог бы использовать операторы new и delete непосредственно в этих методах. Однако функции GetNode и FreeNode предоставляют более структурированный доступ для управления памятью. Метод GetNode делает попытку динамического создания узла с передаваемыми ему значением данных и полем указателя. Если выделение памяти происходит без ошибки, он возвращает указатель на новый узел; иначе, печатается сообщение об ошибке и программа завершается. Метод FreeNode просто удаляет память, занятую узлом. Конструкторы и деструктор. Конструктор создает пустой список со всеми значениями указателей, установленными на NULL. В этом начальном состоянии size устанавливается на 0, а значение position — на -1: // создать пустой список template <class T> LinkedList<T>::LinkedList(void): front(NULL), rear(NULL), prevPtr(NULL),currPtr(NULL), size(O), position(-1) {} Конструктор копирования и оператор присваивания копируют объект L класса LinkedList. Для этой цели класс реализует метод CopyList, проходящий по списку L и вставляющий каждое значение данных в хвост текущего списка. Эта закрытая функция вызывается только тогда, когда текущий список пуст. Она назначает параметры прохождения prevPtr, currPtr и position так, чтобы текущий список был той же конфигурации, что и список L. Таким образом, два эти списка имеют одно и то же состояние прохождения после присваивания или инициализации.
// копировать L в текущий список (предполагается пустым) template <class T> void LinkedList<T>::CopyList(const LinkedList<T>& L) { // p — указатель на L Node<T> *p * L.front; int pos; // вставлять каждый элемент из L в конец текущего объекта while (p !» NULL) { InsertRear(p->data); р - p->NextNode{); } // выход, если список пустой if (position -= -1) return; // переустановить prevPtr и currPtr в новом списке prevPtr = NULL; currPtr « front; for (pos * 0; pos != position; pos++) { prevPtr * currPtr; currPtr * currPtr->NextNode<); } } ClearList проходит связанный список и уничтожает все узлы, используя алгоритм, разработанный в разделе 9.1. Деструктор реализуется простым вызовом Clearlist. template <class T> void LinkedList<T>::ClearList(void) { Node<T> *currPosition, *nextPosition; currPosition = front; while(currPosition !- NULL) { // получить адрес следующего узла и удалить текущий nextPosition = currPosition->NextNode(); FreeNode(currPosition); currPosition = nextPosition; // перейти к следующему узлу } front - rear ~ NULL; prevPtr « currPtr = NULL; size = Opposition « -1; } Методы прохождения списка. Reset устанавливает текущее положение прохождения в позицию, обозначенную параметром pos. В то же время, этот метод изменяет положение как currPtr, так и prevPtr. Если pos не находится в диапазоне 0..size-l, то выводится сообщение об ошибке и программа завершается. Для установки currPtr и prevPtr функция различает случаи, когда pos является головой списка и внутренней позицией в списке. pos == 0: Переустановка текущего положения на начало списка путем установки prevPtr на NULL, currPtr — на front и position — на 0.
pos! = 0: Так как случай, когда pos == 0 уже рассматривался, мы можем предположить, что значение pos должно быть больше 0 и что прохождение списка должно устанавливаться на внутреннюю позицию. Для изменения позиции currPtr начинаем во втором узле списка и перемещаемся к положению pos. template <class T> void LinkedList<T>::Reset(int pos) { int startPos; // если список пустой, выход if (front == NULL) return; // если положение задано не верно, закончить программу if {pos < 0 || pos > size-1) { cerr « "Reset: Неверно задано положение: " « pos « endl; return; } // установить механизм прохождения в pos if(pos == 0) { // перейти в начало списка prevPtr = NULL; currPtr = front; position = 0; } else // переустановить currPtr, prevPtr, и startPos { currPtr = front->NextNode(); prevPtr = front; startPos = 1; // передвигаться вправо до pos for(position=startPos; position != pos; position++) { // передвинуть оба указателя прохождения вперед prevPtr = currPtr; currPtr = currPtr->NextNode(); } ) } Для последовательного сканирования списка мы перемещаемся от элемента к элементу, выполняя метод Next. Функция перемещает prevPtr к текущему узлу, a currPtr — на один узел вперед. Если мы прошли все узлы в списке, переменная position имеет значение size, и currPtr устанавливается на NULL. // переустановить prevPtr и currPtr вперед на один узел template <class T> void LinkedList<T>::Next(void) { // выйти, если конец списка или // список пустой if (currPtr != NULL) { // переустановить два указателя на один узел вперед prevPtr = currPtr;
currPtr = currPtr->NextNode(); position++; } } Доступ к данным. Используйте метод Data для доступа к значению данных в списочном узле. Если список пуст или прохождение достигло конца списка, выводится сообщение об ошибке и программа завершается; иначе, Data возвращает currPtr->data. // возвратить ссылку на данные текущего узла template <class T> Т& LinlcedList<T>: : Data (void) { // ошибка, если список пустой или прохождение закончено if (size == 0 || currPtr == NULL) { cerr « "Data: Неверная ссылка!" « endl; exit (1); ) return currPtr->data; } Методы вставки для списка. Класс LinkedList имеет ряд операций для добавления узла в начало или хвост списка (InsertFront, InsertRear) или относительно текущей позиции (Insert At и Insert After). Методам вставки передается значение данных, используемое для инициализации поля данных нового узла. Insert At вставляет узел со значением данных в текущую позицию в списке. Метод использует GetNode для выделения узла со значением данных item, имеющим адрес newNode, увеличивает размер списка и устанавливает текущее положение на новый узел. Алгоритм должен обрабатывать два случая. Если вставка имеет место в начале списка (prevPtr == NULL), обновляется front для указания на новый узел. Если вставка имеет место внутри списка, новый узел помещается после prevPtr Node-методом InsertAfter. Если элемент вставляется в пустой список или в хвост непустого списка, должна выполняться специальная обработка указателя rear. // вставка item в текущую позицию списка template <class T> void LinkedList<T>::InsertAt(const T& item) { Node<T> *newNode; // два случая: вставка в начало или внутрь списка if (prevPtr == NULL) { // вставка в начало списка, помещает также // узел в пустой список newNode = GetNode(item,front); front = newNode; } else { // вставка внутрь списка, помещает узел после prevPtr newNode = GetNode(item); prevPtr->InsertAfter(newNode); }
InsertAt (пустой список) front rear prevPtr currPtr position (-1) size (0) front rear prevPtr currPtr position (0) size (1) item InsertAt (вставка в конец) front rear prevPtr currPtr position (3) size (4) front rear prevPtr currPtr position (4) size (5) item // при prevPtr *» rear, имеем вставку в пустой список // или в хвост непустого списка; обновляет rear и position if (prevPtr == rear) i rear - newNode; position = size; } // обновить currPtr и увеличить size currPtr = newNode; size++; } Методы удаления. Для удаления узла имеются две операции, которые удаляют узел из начала списка (DeleteFront) или из текущей позиции (DeleteAt). DeleteAt удаляет узел с адресом currPtr. Если currPtr является равным NULL, то список пуст, или весь список уже пройден. В этом случае функция выводит сообщение об ошибке и завершает программу. Иначе, алгоритм обрабатывает два случая. Если удаляется первый узел списка (prevPtr ==NULL), изменяется указатель front. Если это последний узел списка, front становится равным NULL. Второй случай имеет место при prevPtr^NULL, и удаляемый узел находится внутри списка. Используйте метод DeleteAfter класса Node для отсоединения от списка узла, следующего за prevPtr.
Как и в случае с методом InsertAfter, следует обратить особое внимание на указатель rear. Если узел, который мы удаляем, находится в хвосте списка (currPtr —= rear), то новым хвостом теперь будет являться prevPtr, значение position уменьшается, a currPtr становится равным NULL. Во всех других случаях position остается без изменения. Если мы удаляем последний узел в списке, rear становится равным NULL, а значение position изменяется с О на -1. Вызываем FreeNode для удаления узла из памяти и уменьшаем размер списка. DeleteAt (список становится пустым) front rear prevPtr currPtr position (0) size (1) front rear prevPtr currPtr position (-1) size (0) DeleteAt (currPtr - это rear) front rear prevPtr currPtr position (3) size (4) front rear prevPtr currPtr position (2) size (3) // удаление узла в текущей позиции списка template <class T> void LinkedList<T>::DeleteAt(void) { Node<T> *p; // ошибка, если список пустой или конец списка if (currPtr -= NULL) { cerr << "Ошибка удаления!" « endl; exit(l); } // удалаять можно только в начале и внутри списка if (prevPtr « NULL)
{ // сохранить адрес начала, но не связывать его. если это - // последний узел, присвоить front значение NULL р = front; front « front->NextNode(); } else // не связывать внутренний узел после prevPtr. // запомнить адрес р = prevPtr->DeleteAfter(); // если хвост удален, адрес нового хвоста в prevPtr, // a position уменьшается на 1 if (p == rear) { rear = prevPtr; position--; } // установить currPtr на последний удаленный узел. // если р — последний узел в списке, // currPtr становится равным NULL currPtr = p->NextNode(); // освободить узел и уменьшить значение size FreeNode(p); size—; } 9.6. Реализация коллекций со связанными списками До этого раздела в книге мы использовали основанные на массиве реализации классов Stack, Queue и SeqList. В каждом случае список элементов сохраняется в массиве, который определяется как закрытый данное-член этого класса. В этой главе разрабатывается класс LinkedList, который предоставляет мощную структуру динамической памяти вместе с разнообразными методами добавления и удаления элементов и обновления значений данных. Сохраняя элементы в объекте связанного списка, а не в массиве, мы получаем новую стратегию реализации, которая увеличивает возможности и эффективность базовых списочных классов. Используя различные методы в классе LinkedList, мы имеем инструменты для простой и понятной реализации операций stack, queue и list. В данном разделе разрабатываются новые реализации для классов Queue и SeqList с использованием связанных списков. Для класса SeqList мы сравниваем эффективности динамического хранения связанного списка и списка, основанного на массиве. Класс Queue используется в следующем разделе при разработке задачи моделирования буферизации печати (printer spooler). Реализация связанного списка класса Stack опущена как самостоятельное упражнение.
Связанные очереди Объект LinkedList является гибкой структурой памяти для сохранения списка элементов. Класс Queue предоставляет простую реализацию очереди, используя композицию (структуру) для включения объекта LinkedList. Этот объект производит Queue-операции, выполняя эквивалентные операции LinkedList. Например, объект LinkedList позволяет выполнять вставку элемента в хвост списка (InsertRear) и удаление элемента из головы списка (DeleteFront). Переустанавливая текущий указатель на голову списка (Reset), мы можем определить операцию Qfront, извлекающую значение данных из головы списка. Другие Queue-методы оценивают состояние списка — задача, управляемая списочными операциями ListEmpty и ListSize. Очередь очищается простым вызовом списочного метода ClearList. Спецификация класса Queue (с использованием объекта LinkedList) ОБЪЯВЛЕНИЕ #include <iostream.h> #include <stdlib.h> #include "link.h" template <class T> class Queue { private: // объект связанного списка // для хранения элементов очереди LinkedList<T> queueList; public: // конструктор Queue(void); // методы обновления void Qlnsert(const T& elt); T QDelete(void); // доступ к очереди T QFront(void); // методы тестирования int QLength(void) const; int QEmpty(void) const; void QClear(void); }; ОПИСАНИЕ Объект queueList класса LinkedList содержит элементы очереди. Он предоставляет полный набор операций связанного списка, которые используются для реализации открытых методов Queue. Класс Queue не имеет собственных деструктора, конструктора копирования и оператора присваивания. Эти методы не являются обязательными, так как они реализуются для объекта queueList. Компилятор реализует присваивание и инициализацию, выцолняя оператор присваивания или конструктор копирования для объекта типа queueList. Деструктор для queueList вызывается автоматически при уничтожении объекта Queue.
Так как элементы сохраняются в связанном списке, размер очереди не ограничивается константой реализации, такой как MaxQueueSize ПРИМЕР Queue<int> Q1,Q2; // объявление двух очередей целых значений Ql.QInsert(10); // добавление 10, а затем 50 в Q1 Ql.Qlnsert(50); cout « Ql.QFrontO; // вывод значения 10 в начале очереди Q2 * Q1; // использование оператора = для queueList Ql.QClearO; // очистка очереди и освобождение памяти Реализация связанного списка класса Queue находится в файле queue.h. Реализация методов Queue Для иллюстрации реализации методов Queue мы определяем методы модификации очереди Qlnsert и QDelete, а также метод доступа QFront. Каждый из них непосредственно вызывает эквивалентный метод LinkedList. Операция Qlnsert добавляет элемент в хвост очереди, используя LinkedList- операцию InsertRear. // LinkedList-метод вставляет элемент в хвост template<class T> void Queue<T>::Qlnsert(const T& elt) { queueList.InsertRear(elt); } QDelete сначала проверяет состояние очереди и завершает программу при пустом списке. Иначе, при помощи списочной операции DeleteFront выполняется операция отсоединения первого элемента от очереди, удаления памяти и возврата значения данных. // LinkedList-метод DeleteFront удаляет элемент из // головы очереди Т Queue<T>::QDelete(void) { // проверка, пустой ли список, и завершение, если — да if (queueList.ListEmpty()) { cerr « "Qdelete вызвана для пустой очереди!" « endl/ exit (1); } return queueList.DeleteFront(); } Операция QFront выполняет выборку значения данных из первого элемента queueList. Для этого требуется изменение позиции текущего указателя на голову списка и считывание его значения данных. Попытка вызвать эту функцию при пустой очереди вызывает сообщение об ошибке и завершение программы. // возвратить значение данных первого элемента в очереди template <class T> Т Queue<T>::QFront(void) { // если очередь пуста, завершить программу if (queueList.ListEmpty()) { cerr << "QFront вызвана для пустой очереди!" « endl; exit(l);
} // перенастроиться на голову очереди и возвратить данные queueList.Reset{); return queueList.Data(); } Использование объекта LinkedList с классом SeqList Класс SeqList определяет ограниченную структуру памяти, позволяющую вставлять элементы только в хвост списка и удаляющую только первый элемент в списке или элемент, который совпадает с ключом. Пользователь имеет возможность доступа к данным в списке с использованием метода Find или с помощью позиционного индекса для чтения значения данных в узле. Как в случае со связанной очередью, мы можем использовать объект LinkedList для сохранения данных при реализации класса SeqList. Более того, этот объект предоставляет мощный набор операций для реализации методов класса. Спецификация класса SeqList ОБЪЯВЛЕНИЕ tinclude <iostream.h> #include <stdlib.h> ♦include "link.h" template <class T> class SeqList { private: // объект связанного списка LinkedList<T> Hist; public: // конструктор SeqList(void); // методы доступа int ListSize(void) const; int ListEmpty(void) const; int Find (T& item); T GetData(int pos); // методы модификации void Insert(const T& item); void Delete(const T& item); T DeleteFront(void); void ClearList(void); )/ ОПИСАНИЕ Методы этого класса идентичны методам, определенным для основанной на массиве версии в файле aseqlist.fi. Нет необходимости определять деструктор, конструктор копирования и оператор присваивания. Компилятор создает их, используя соответствующие операции в классе LinkedList. Класс находится в файле seqlistl.h. ПРИМЕР SeqList<int>chList; // выделение динамического списка целых
chList.Insert (40); // добавление 40 в конец списка cout « chList.DeleteFront () « endl; // вывод значения 40 Реализация методов доступа к данным класса SeqList Класс SeqList дает возможность пользователю иметь доступ к данным по ключу, с помощью метода Find, или по позиции в списке. В первом случае для сканирования списка и поиска ключа используется механизм прохождения класса LinkedList. // использовать item как ключ для поиска в списке. // возвратить True, если элемент — в списке, и — False //в противном случае. template <class T> int SeqList<T>::Find (T& item) { int result - 0; // поиск item в списке, если — найден, result = True for (Hist.Reset () ; ! Hist. EndOf List () ;llist .Next () ) if (item == Hist.DataO ) { result++; break; } // если result равно True, обновить item и возвратить True; // иначе — возвратить False if (result) item = Hist.DataO; return result; } Метод GetData используется для доступа к элементу данных по его положению в списке. Здесь используется LinkedList-метод Reset для установки механизма прохождения в нужное положение в списке и выполняется метод Data для извлечения данных: // возвратить значение данных элемента в положении pos template <class T> Т SeqList<T>::GetData(int pos) { // контроль правильности pos if (pos < 0 || pos >= Hist .ListSize () ) { cerr << "pos вне диапазона!" « endl; exit(l); } // установить текущее положение связанного // списка в pos и возвратить значение данных Hist. Reset (pos) ; return Hist.DataO; } Приложение: Сравнение реализаций SeqList Основанная на массиве версия класса SeqList требует значительных усилий для удаления элемента, так как все элементы, находящиеся в хвосте списка, должны быть сдвинуты влево. В случае с реализацией связанного списка те
же операции имеют место с простым отсоединением указателей. Для иллюстрации результата использования структуры памяти для хранения связанного списка сравним версию, основанную на массиве, и версию связанного списка класса SeqList. После создания начального списка с 500 членами при тестировании неоднократно удаляется элемент из головы списка и затем вставляется элемент в хвост списка. Процесс повторяется 50 000 раз и представляет собой наихудший случай для объекта основанного на массиве класса SeqList. Мы выполняем две эквивалентные программы на одной и той же компьютерной системе и вычисляем время выполнения 50 000 операций вставки/удаления в секундах. Программа 9.7а. (Класс List — реализация массива) Эта задача тестирования использует основанный на массиве класс SeqList, находящийся в файле aseqlist.h. Только для этой программы константа ARRAYSIZE изменяется так, чтобы список мог содержать 500 элементов. Задача выполняется за 55 секунд. #include <iostream.h> #pragma hdrstop // DataType = int (список хранит целые значения) typedef int DataType; // включение класса SeqList #include "aseqlist.h" void main(void) { // список с 500 целыми SeqList L/ long i; // инициализация списка значениями 0 .. 499 for (i = 0; i < 500; i++) L.Insert(int(i)); // выполнение операций удаления/вставки 50000 раз cout « " Начало программы!" « endl; for (i = 1; i <= 50000L; i++) { L.DeleteFront(); L.Insert(O); } cout « " Программа выполнена! « endl; } /* <Выполнение программы 9.7а> Начало программы! Программа выполнена! // 55 секунд */
Программа 9.7b. (Класс List — реализация связанного списка) Эта программа тестирует версию связанного списка класса SeqList, находящегося в файле seqlistLh. Задача выполняется за 4 секунды! tinclude <iostream.h> #pragma hdrstop // включить реализацию связанного списка (SeqList) ♦include "seqlistl.h" void main(void) { // определить список целых SeqList<int> L; long i; // инициализировать список значениями 0 .. 499 for (i - 0; i < 500; i++) L.Insert(int(i)); // выполнение операций удаления/вставки 50000 раз cout « " Начало программы!" « endl; for (i « 1; i <- 50000L; i++) { L.DeleteFront(); L.Insert(0); } cout « " Программа выполнена!" « endl; } /* <Выполнение программы 9.7b> Начало программы! Программа выполнена! // 4 секунды */ 9.7. Исследовательская задача: Буферизация печати Очереди используются для реализации систем буферизации печати в операционных системах. Система буферизации (буферизатор) печати принимает запрос на печать и вставляет файл, который должен печататься, в очередь. При освобождении принтера буферизатор удаляет задание из очереди и печатает этот файл. Действие буферизатора позволяет осуществлять печать на фоне выполнения пользователями других задач на переднем плане. Анализ проблемы В этой задаче разрабатывается класс Spooler, операции которого моделируют ситуацию добавления пользователем новых заданий в очередь печати и проверки статуса заданий, уже имеющихся в очереди. Задание на печать — это структура, содержащая целый номер задания, имя файла и число страниц.
struct PrintJob { int number; charfilename[20]; int pagesize; }; В задаче моделирования подразумевается, что принтер работает непрерывно со скоростью 8 страниц в минуту. Следующая таблица иллюстрирует ситуацию, когда пользователь направляет в систему буферизации три задания на печать. Задание 45 6 70 Имя Тезисы Письмо Записи Количество страниц 70 5 20 Пользователь добавляет эти три задания в течение 12 минут и дважды запрашивает печать заданий в очереди. Элементы 4, 1, 5 и 2 представляют время между операциями пользователя. Значения 70, 38, 35, 20 и 4 указывают количество страниц, оставшихся для печати, в момент каждой операции пользователя. Добавить Задание 45 Запрос списка заданий Добавить Задание 6 Добавить Задание 70 Запрос списка заданий 70 38 35 20 4 Оставшиеся для печати страницы На рисунке 9.1 перечислены операции системы буферизации печати от 0 до 12 минут. В каждом событии распечатывается количество страниц в бу- феризаторе, общее количество уже распечатанных страниц и очередь печати. Разработка программы Буферизатор печати является списком, в котором сохраняются записи PrintJob. Так как задания обрабатываются на основе порядка first-come/first- served, мы рассматриваем список как очередь с запросами на выполнение заданий, вставляемых в хвост списка, и фактической печатью, управляемой удалением запросов на выполнение заданий из начала списка. В нашем исследовании мы выполняем задачи, которых нет в наличии в формальной коллекции очередей. В списке сканируются запросы на выполнение заданий и распечатывается их статус. При обновлении данных изменяется размер в страницах текущего задания без его удаления из списка. Для того, чтобы иметь гибкий доступ к заданиям на печать, мы реализуем буферизатор как связанный список. Эта исследовательская задача для управления моделированием использует события. Событие может включать добавление задания на печать в систему буферизации, распечатку заданий в буферизаторе и проверку того, остается ли определенное задание для печати. Случайно выбранные блоки времени в диапазоне от 1 до 5 минут отделяют события. Для моделирования непрерывной печати заданий в фоновом режиме используется вхождение события для обновления очереди печати. Информация о продолжительности времени
между событиями сохраняется для того, чтобы можно было узнать, сколько страниц уже распечатано. Допустим, что время, прошедшее с момента последнего события, равно deltaTime, тогда количество страниц, которые были напечатаны, равно pagesPrinted = deltaTime * 8 Время (мин.) Операция Страниц в задании Страниц в буфере Напечатанные страницы О Добавить задание 45 70 70 0 Печатать очередь 45 Тезисы 70 | 4 Запрос списка 38 32 Печатать очередь 45 Тезисы 38 5 Добавить задание 6 5 35 40 Печатать очередь 45 Тезисы 30 6 Письмо 5 10 Добавить задание 70 20 20 75 Печатать очередь 70 Записи 20 12 Запрос списка 4 91 Печатать очередь 70 Записи 4 Рис. 9.1. Отслеживание операций системы буферизации печати Хранение заданий на печать и функции доступа к системе буферизации определяются классом Spooler. Спецификация класса Spooler ОБЪЯВЛЕНИЕ #include <iostream.h> // включить генератор случайных чисел и класс LinkedList #include "random.h" #include "link.h" // скорость печати — 8 страниц в минуту const int PRINTSPEED = 8; // класс буферизатора печати class Spooler { private: // очередь, которая содержит задания LinkedList<PrintJob> jobList; // deltaTime содержит случайное число в диапазоне 1—5 // минут для имитации прошедшего времени int deltaTime; // метод обновления информации о задании void UpdateSpooler(int time); RandomNumber rnd;
public: // констуктор Spooler(void) ; // добавление задания в буферизатор void AddJob(const PrintJob& J); // методы оценивания void ListJobs(void); int Chec)cJob(int jobno); int NumberOfJobs(void); >; ОПИСАНИЕ Закрытый данное-член deltaTime моделирует количество минут, за которые выполнялась печать со времени последнего события в системе буферизации. В начале каждого события вызывается операция UpdateSpooler с deltaTime в качестве параметра. Эта функция изменяет jobList для отражения того факта, что распечатка происходила в фоновом режиме в течение deltaTime минут. Каждый открытый метод отвечает за присваивание нового значения deltaTime. Значение, создаваемое генератором RandomNumber в диапазоне от 1 до 5, указывает количество минут перед следующим событием. Задания на печать добавляются в систему буферизации с использованием метода AddJob. Две операции ListJobs и Check Job предоставляют информацию о статусе задания, находящегося в буферизаторе. В любое время список заданий в буферизаторе распечатывается вызовом ListJobs. Метод Check Job принимает номер задания и возвращает информацию о его статусе в буферизаторе. Он возвращает количество страниц, оставшихся для печати. Объявление PrintJob, PRINTSPEED и реализация класса буферизатора содержатся в файле spooler.h. Реализация метода UPDATE для класса Spooler Процесс обновления удаляет все задания, суммарное количество страниц которых меньше, чем количество распечатанных страниц. Если количество нераспечатанных страниц меньше или равно потенциальному общему количеству страниц печати, то все задания выполнены и очередь пуста. Иначе из очереди могут быть удалены одно или более заданий и распечатаны несколько страниц текущего задания. Обновление оставляет текущее задание не полностью выполненным. // обновление буферизатора. предполагается, что при печати // страниц проходит некоторое время, метод удаляет // законченные задания и изменяет число оставшихся страниц // для задания, выполняемого в текущий момент времени void Spooler::UpdateSpooler(int time) { PrintJob J; // число страниц, которые следует напечатать // в соответствии с time int printedpages = time*PRINTSPEED; // использовать printedpages и сканировать // список заданий в очереди. // обновлять очередь печати jobList.Reset(); while (!jobList.ListEmpty() && printedpages > 0)
{ // найти первое задание J * jobList.Data О; // если напечатанных страниц больше, чем // страниц в задании, обновить счетчик печатаемых // страниц и удалить задание if (printedpages >* J.pagesize) { printedpages -« J.pagesize; j obList.DeleteFront(); } // часть задания выполнена; // изменить оставшееся число страниц else { J.pagesize -= printedpages; printedpages = 0; jobList.Data() ■ J; } } } Методы оценки системы буферизации печати Методы оценки системы буферизации печати дают информацию пользователю в ответ на запрос о заданиях, которые ожидают печати, и статусе отдельного задания. Методы ListJobs и CheckJob выполняют последовательное сканирование списка буферизатора. Мы приводим функцию ListJobs; другие методы читатель может найти в файле spooler,h. // обновление буферизатора и выдача списка всех заданий //в очереди void Spooler::ListJobs(void) { PrintJob J; // обновить очередь UpdateSpooler(deltaTime); // генерировать время до следующего события deltaTime * 1 + rnd.Random(5); // перед сканированием проверить, не пуста ли очередь if (jobList.ListSize() ■- 0) cout « "Очередь печати пуста!\п"; else { // перейти к началу списка и использовать // цикл для сканирования списка, остановиться //в конце списка, выводить информационные поля // для каждого задания for(jobList.Reset(); !jobList.EndOfList(); jobList.Next()) { J - jobList.Data0; cout << "Задание " « J.number « ": " « J.filename; cout « " " « J.pagesize « " страниц осталось" « endl; ) } )
Программа 9.8. Буферизатор печати Эта main-программа определяет Spooler-объект spool и создает интерактивный диалог с пользователем. В каждой итерации пользователю предоставляется меню из четырех пунктов. Пункты 'А' (добавить задание), 'L' (список заданий) и 'С* (информация о задании) обновляют очередь печати и выполняют определенную операцию системы буферизации. Пункт 'Q' завершает программу. Пункты 'I/ и 'С* не выводятся, если очередь печати пуста. #include <iostreara.h> tinclude <ctype.h> #pragma hdrstop ♦include "spooler.h" void main(void) { // объект типа Spooler Spooler spool; int jnum, jobno ■ 0, rempages; char response - 'C; PrintJob J; for (;;) { // выдача меню if (spool.NumberOfJobs() != 0) cout « "Add(A) List(L) Check(C) Quit(Q) «=> "; else cout « "Add(A) Quit(Q) -*> "; cin » response; // преобразовать ответ к верхнему регистру response - toupper(response); // действие, продиктованное ответом switch(response) { // добавить новое задание со следующим номером, // используемым как идентификационный (id); читать // имя файла и число страниц. case 'A': J.number = jobno; jobno++; cout << "Имя файла: "; cin » J.filename; cout « "Число страниц: "; cin » J.pagesize; spool.AddJob(J); break; // Печать информации каждого оставшегося задания case ' I/ : spool.ListJobs(); break; // ввести id-задания; сканировать список с этим ключом. // указать, что задание.выполнено или число оставшихся // для печати страниц case 'С :
cout « "Введите номер задания: "; cin » jnum/ rempages - spool.CheckJob(jnum) ; if (rempages > 0) cout << "Задание в очереди. " « rempages << " страниц осталось напечатать\n"; else cout « "Задание выполнено\п"; break; // выход для ответа ' Q' case 'Q': break; // сообщить о неправильном ответе и // перерисовать меню default: cout « "Неправильная команда!\п"; break; } if (response == ' Q') break; cout << endl; } } /* <Выполнение программы 9.8> Add (A) Quit (Q) ==> a Имя файла: notes Число страниц: 75 Add (A) List(L) Check (C) Quit (Q) «> a Имя файла: paper Число страниц: 25 Add (A) List(L) Check (C) Quit (Q) «> 1 Задание 0: notes 19 страниц осталось Задание 1: paper 25 страниц осталось Add (A) List(L) Check (С) Quit (Q) «> с Введите номер задания: 1 Задание в очереди. 20 страниц осталось напечатать Add (A) List(L) Check (С) Quit (Q) ==> 1 Очередь печати пуста! Add (A) Quit (Q) «»> q */ 9.8. Циклические списки Оканчивающийся NULL-символом связанный список — это последовательность узлов, которая начинается с головного узла и заканчивается узлом, поле указателя next которого имеет значение NULL. В разделе 9.1 разработана библиотека функций для сканирования такого списка и для вставки и удаления узлов. В этом разделе разрабатывается альтернативная модель, называемая циклическим связанным списком (circular linked list), которая упрощает раз-
работку и кодирование алгоритмов последовательных списков. Многие профессиональные программисты используют циклическую модель для реализации связанных списков. Пустой циклический список содержит узел, который имеет неинициализированное поле данных. Этот узел называется заголовком (header) и первоначально указывает на самого себя. Роль заголовка — указывать на первый реальный узел в списке и, следовательно, на заголовок часто ссылаются как на узел-часовой (sentinel). В циклической модели связанного списка пустой список фактически содержит один узел, и указатель NULL никогда не используется. Мы приводим схему заголовка, используя угловые линии в качестве стоны узла. header next Заметьте, что для стандартного связанного списка и циклического связанного списка тесты, определяющие, является ли список пустым, различны. Стандартный связанный список: head == NULL Циклический связанный список: header->next == header При добавлении узлов в список последний узел указывает на заголовочный узел. Мы можем представить циклический связанный список как браслет с заголовочным узлом, служащим застежкой. Заголовок связывает вместе реальные узлы в списке. header next В разделе 9.1 был описан класс Node и использовались его методы для создания связанных списков. В этом разделе мы объявляем класс Cnode, создающий узлы для циклического списка. Этот класс предоставляет конструктор по умолчанию, допускающий неинициализированное поле данных. Конструктор используется для создания заголовка. Спецификация класса CNode ОБЪЯВЛЕНИЕ template <class T> class CNode { private: // циклическая связь для следующего узла CNode<T> *next; public: // данные — открытые Т data; // конструктор CNode(void); CNode (const T& item); // методы модификации списка void InsertAfter(CNode<T> *p); CNode<T> *DeleteAfter(void); // получить адрес следующего узла
CNode<T> *NextNode(void) const; }; ОПИСАНИЕ Этот класс подобен классу Node в разделе 9.1. В действительности все члены этого класса имеют то же имя и те же функции. Детали открытых членов класса приводятся в следующем разделе, в котором описывается реализация класса. Класс CNode содержится в файле cnode.h. Реализация класса CNode Конструкторы инициализируют узел его указанием на самого себя, поэтому каждый узел может служить в качестве заголовка для пустого списка. Указатель на самого себя — это указатель this, и, следовательно, присваивание становится следующим: next = this; Для конструктора по умолчанию поле data не инициализируется. Второй конструктор принимает параметр и использует его для инициализации поля data. Никакой конструктор не требует параметра, определяющего начальное значение для поля next. Все необходимые изменения поля next выполняются с использованием методов Insert After и Delete After. // конструктор, который создает пустой список //и инициализирует данные template<class T> CNode<T>::CNode(const T& item) { // устанавливает узел для указания на самого себя и // инициализирует данные next ■> this; data = item; } Операции класса CNode. Класс CNode предоставляет метод NextNode, который используется для прохождения по списку. Подобно методу класса Node функция NextNode возвращает значение указателя next. Insert After добавляет узел р непосредственно после текущего объекта. Для загрузки узла в голову списка не требуется никакого специального алгоритма, так как мы просто выполняем Insert After (header). До header next next header После next P
// вставка узла р после текущего узла template<class T> void CNode<T>::InsertAfter(CNode<T> *p) { // p указывает на следующий узел за текущим. // текущий узел указывает на р. p->next = next; next = р; } Удаление узла из списка выполняется методом DeleteAfter. DeleteAf ter удаляет узел, следующий непосредственно за текущим узлом, и затем возвращает указатель на удаленный узел. Если next равно this, то в списке нет никаких других узлов, и узел не должен удалять самого себя. В этом случае операция возвращает значение NULL. // удалить узел, следующий за текущим и возвратить его адрес template <class T> CNode<T> *CNode<T>::DeleteAfter(void) { // сохранить адрес удаляемого узла CNode<T> *tempPtr = next; // если в next адрес текущего объекта (this)/ он // указывает сам на себя, возвратить NULL if (next =» this) return NULL; // текущий узел указывает на следующий за tempPtr. next - tempPtr->next; // возвратить указатель на несвязанный узел return tempPtr; } Приложение: Решение задачи Джозефуса Задача Джозефуса является интересной программой, предоставляющей изящное решение циклического связанного списка. Далее следует версия задачи: Агент бюро путешествий выбирает п клиентов для участия в розыгрыше бесплатного кругосветного путешествия. Участники становятся в круг и затем из шляпы выбирается число m (m < n). Розыгрыш производится агентом, который идет по кругу по часовой стрелке и останавливается у каждого т-ного участника. Этот человек удаляется из игры, и агент продолжает счет, удаляя каждого m-ного человека до тех пор, пока не останется только один участник. Этот счастливчик выигрывает кругосветное путешествие. Например, если п = 8, и m = 3, то участники удаляются в следующем порядке: 3, 6, 1, 5, 2, 8, 4 и участник 7 выигрывает круиз. Программа 9.9. Задача Джозефуса Эта программа эмулирует розыгрыш кругосветного путешествия. Функция CreateList создает циклический список 1,2,...п, используя CNode-метод InsertAfter. Процесс выбора управляется функцией Josephus, которая принимает заголовок циклического списка и случайное число т. Она выполняет п-1 итераций, отсчитывая каждый раз m последовательных элементов в списке
и удаляя m-ый элемент. Когда мы продолжаем обходить список по кругу, мы выводим номер каждого участника, который удаляется. При завершении цикла остается один элемент. Main-программа запрашивает количество участников розыгрыша п и использует CreateList для создания циклического списка. Генерируется случайное число m в диапазоне 1..п, и вызывается функция Josephus для определения порядка удаления участников и победителя в розыгрыше круиза. #include <iostream.h> #pragma hdrstop #include "cnode.h" #include "random.h" // создать циклический список с заданным узлом void CreateList(CNode<int> *header, int n) { // начать вставку после головы списка CNode<int> *currPtr = header, *newNodePtr; int i; // построить п-ый элемент списка for(i=l;i <= n;i++) { // создать узел со значением i newNodePtr = new CNode<int>(i); // вставить в конец списка и продвинуть currPtr к концу currPtr->InsertAfter(newNodePtr); currPtr = newNodePtr; } } // для списка из п элементов решить задачу Джозефуса // удалением каждого m-го элемента, пока не останется один void Josephus(CNode<int> *list, int n, int m) { CNode<int> *prevPtr = list, *currPtr = list->NextNode(); CNode<int> *deletedNodePtr; // удалить из списка всех, кроме одного for(int i=0;i < n-l;i++) { // вычисление для currPtr, обработать m элементов. // следует продвигать т-1 раз for(int j=0;j < m-l;j++) { // передвинуть указатели prevPtr = currPtr; currPtr = currPtr->NextNode(); // если currPtr указывает на голову, снова передвинуть указатели if (currPtr == list) { prevPtr = list; currPtr = currPtr->NextNode(); } } cout << "Удалить участника: " « currPtr->data << endl;
// записать удаляемый узел и продвинуть currPtr deletedNodePtr = currPtr; currPtr = currPtr->NextNode(); // удалить узел из списка prevPtr->DeleteAfter(); delete deletedNodePtr; // если currPtr указывает на голову, // снова передвинуть указатели if (currPtr == list) { prevPtr = list; currPtr = currPtr->NextNode(); } } cout << endl « "Участник " « currPtr->data << " выиграл круиз." « endl; // удалить оставшийся узел deletedNodePtr = list->DeleteAfter(); delete deletedNodePtr; } void main(void) { // список участников CNode<int> list; int n, m; RandomNumber rnd; // для генерации случайных чисел cout << "Введите число участников: и; cin >> п; // создать циклический список из участников 1, 2, ... п CreateList(&list,n); m = 1+rnd.Random(n); cout « "Сгенерированное случайное число: " « m « endl; // ришить задачу Джозефуса и напечатать выигравшего круиз Josephus(&list,n,m) ; } Л <Выполнение программы 9.9> Введите число участников: 10 Сгенерированное случайное число: 5 Удалить участника: 5 Удалить участника: 10 Удалить участника: б Удалить участника: 2 Удалить участника: 9 Удалить участника: 8 Удалить участника: 1 Удалить участника: 4 Удалить участника: 7 Участник 3 выиграл круиз. */
9.9. Двусвязные списки Сканирование либо оканчивающегося NULL-символом, либо циклического списка происходит слева направо. Циклический список является более гибким и позволяет начинать сканирование в любом положении в списке и продолжать его до начальной позиции. Эти списки имеют ограничения, так как они не позволяют пользователю возвращать пройденные шаги и выполнять сканирование в обратном направлении. Они неэффективно выполняют простую задачу удаления узла р, так как мы должны проходить по списку и находить указатель на узел, предшествующий р. header Узел перед р Удалить р В некоторых приложениях пользователю необходим доступ к списку в обратном порядке. Например, бейсбольный менеджер ведет список игроков с упорядочением по среднему количеству отбиваний от самого низкого до самого высокого. Чтобы оценить сноровку игроков в отбивании и присудить звание лучшего в этом виде, необходимо прохождение списка в обратном направлении. Это можно выполнить, используя стек, но такой алгоритм не очень удобен. В случаях, когда нам необходимо обращаться к узлам в любом направлении, полезным является двусвязный список (doubly linked list). Узел в двусвязном списке содержит два указателя для создания мощной и гибкой структуры обработки списков. left data right Для двусвязного списка операции вставки и удаления имеются в каждом направлении. Следующий рисунок иллюстрирует проблему вставки узла р справа от текущего. При этом необходимо установить четыре новых связи. Header В двусвязном списке узел может удалить сам себя из списка, изменяя два указателя. На следующем рисунке показаны соответствующие этому изменения: Текущий узел right left left right Класс DNode — это класс обработки узла для циклических двусвязных списков. Объявление класса и функций-членов содержится в файле dnode.h.
Текущий узел right left right left Спецификация класса DNode ОБЪЯВЛЕНИЕ template <class T> class DNode { private: // циклические связи влево и вправо DNode<T> *left/ DNode<T> *right; public: // данные — открытые T data; // конструкторы DNode(void); DNode (const T& item); // методы модификации списка void InsertRight(DNode<T> *p); void InsertLeft(DNode<T> *p); DNode<T> *DeleteNode(void); // получение адреса следующего (вправо и влево) узла DNode<T> *NextNodeRight(void) const; DNode<T> *NextNodeLeft(void) const; }; ОПИСАНИЕ Данные-члены подобны членам односвязного класса CNode, за исключением того, что здесь используются два указателя next. Имеются две операции вставки (по одной для каждого направления) и операция удаления, которая удаляет текущий узел из списка. Значение закрытого указателя возвращается с помощью функций NextNodeRight и NextNodeLeft. ПРИМЕР DNode<int> dlist; // двусвязный список // сканировать список, выводя значения узла, до тех // пор, пока мы не вернемся к заголовочному узлу DNode<int> *p = &dlist; // инициализация указателя р = p->NextNodeRight(); // установка р на первый узел в списке while (р != Slist) { cout « p->data « ; // вывод значения данных р « p->NextNodeRight(); // установка р на следующий узел в списке } DNode<int> *newNodel(10); // создание узлов со значениями DNode<int> *newNode2(20); // 10 и 20 DNode<int> *p - sdlist; // р указывает на заголовочный узел p->InsertRight(newNodel); // вставка в начало списка p->InsertLeft(newNode2); // вставка в хвост списка
Приложение: Сортировка двусвязного списка Функция InsertOrder используется в программе 9.4 для создания отсортированного списка. Алгоритм начинается в головном узле и сканирует список в поисках места для вставки. Имея дело с двусвязным списком, мы можем оптимизировать этот процесс, поддерживая указатель currPtr, определяющий последний узел, который был помещен в список. Для вставки нового элемента мы сравниваем его значение с данным в текущем положении. Если новый элемент меньше, используйте левые указатели для сканирования списка в направлении вниз. Если новый элемент больше, используйте правые указатели для сканирования списка вверх. Например, предположим, что мы только что сохранили 40 в списке dlist. dlist: 10 25 30 40 50 55 60 75 90 Для добавления узла 70 сканируем список по направлению вверх и вставляем 70 справа от 60. Для добавления узла 35 сканируем список по направлению вниз и вставляем 35 слева от 40. Программа 9.10. Сортировка в двусвязных списках DLinkSort использует двусвязный список для сортировки массива из п элементов, создавая упорядоченный список и затем копируя элементы обратно в массив. Функция InsertHigher добавляет новый узел справа от текущей списочной позиции. Симметричная по отношению к InsertHigher функция InsertLower добавляет новый узел слева от текущей позиции. Алгоритм функции DLinkSort: Вставить элемент (item) 1 вызвать InsertRight с головой и сохранить а[0] Вставить элементы 2—10 если item < currPtr->data, вызвать InsertLower если item > currPtr-<data, вызвать InsertHigher В следующей программе с использованием функции DLinkSort сортируется список из 10 целых. Отсортированный список выводится с помощью функции PrintArray. #include <iostream.h> #pragma hdrstop #include "dnode.h" template <class T> void InsertLower(DNode<T> *dheader, DNode<T>* scurrPtr, T item) { DNode<T> *newNode= new DNode<T>(item) , *p; // найти место вставки p «= currPtr; while (p != dheader && item < p->data) p = p->NextNodeLeft(); // вставить элемент p->InsertRight(newNode); // переустановить currPtr на новый узел currPtr = newNode; }
template <class T> void InsertHigher(DNode<T>* dheader, DNode<T>* & currPtr, T item) { DNode<T> *newNode= new DNode<T>(item), *p; // найти место вставки p = currPtr; while (p != dheader && p->data < item) p = p->NextNodeRight(); // вставить элемент p->InsertLeft(newNode); // переустановить currPtr на новый узел currPtr = newNode; } template <class T> void DLinkSort(T a[], int n) { // задать двусвязный список для элементов массива DNode<T> dheader, *currPtr; int i; // вставить первый элемент в список DNode<T> *newNode = new DNode<T>(a[0]); dheader.InsertRight{newNode); currPtr = newNode; // вставить оставшиеся элементы в список for (i=l;i < n;i++) if (a[i] < currPtr->data) InsertLower(&dheader,currPtr,a[i]); else InsertHigher(sdheader,currPtr,a[i]); // сканировать список и копировать данные в массив currPtr = dheader.NextNodeRight(); i = 0; while(currPtr != sdheader) { a[i++] = currPtr->data; currPtr = currPtr->NextNodeRight(); } // удалить все узлы списка while(dheader.NextNodeRight() != &dheader) { currPtr = (dheader.NextNodeRight())->DeleteNode(); delete currPtr; } } // сканировать массив и выводить его элементы void PrintArray(int a[], int n) { for(int i=0;i < n;i++) cout « a[i] « " "; } void main(void) { // инициализированный массив из десяти целых int A[10] = {82,65,74,95,60,28,5,3,33,55};
DLinkSort(A,10)/ // сортировать массив cout « " Отсортированный массив: "; PrintArray{A,10); // печатать массив cout « endl; } /* <Выполнение программы 9.10> Отсортированный массив: 3 5 28 33 55 60 65 74 82 95 */ Реализация класса DNode Конструктор создает пустой список, присваивая адрес узла this обоим (левому и правому) указателям. Если конструктору передается параметр item, данному-члену узла присваивается значение item. // конструктор, который создает пустой // список и инициализирует данные template<class T> DNode<T>::DNode(const T& item) < // установить узел для указания на самого себя и // инициализировать данные left = right = this; data = item; } Списочные операции. Для вставки узла р справа от текущего узла необходимо назначить четыре указателя. На рисунке 9.2 показано соответствие между операторами C++ этими новыми связями. Заметим, что присваивания не могут выполняться в произвольном порядке. Например, если (4) выполняется первым, то связь к узлу, следующему за текущим, теряется. Текущий узел right right (1) р -> right = right; (2) right -> left = p; (3) p -> ieft = this; (4) right = p; Рис. 9.2. Вставка узла справа в циклическом двусвязном списке Читателю следует проверить, что этот алгоритм работает правильно в случае вставки в пустой список. // вставить узел р справа от текущего template <class T> void DNode<T>::InsertRight<DNode<T> *p) { // связать р с его предшественником справа p->right = right; right->left = p; // связать р с текущим узлом p->left = this; right = p; }
Метод InsertLeft выполняет вставку узла слева аналогично вставке справа в алгоритме для InsertRight. // вставить узел р слева от текущего template <class T> void DNode<T>::InsertLeft(DNode<T> *p) { // связать р с его предшественником слева p->left = left; left->right « p; // связать р с текущим узлом p->right = this; left = p; ) Для удаления текущего узла должны быть изменены два указателя, как показано на рис. 9.3. Читателю следует проверить, что алгоритм работает правильно в случае, когда удаляется последний узел списка. Метод возвращает указатель на удаленный узел. // отсоединить текущий элемент от списка и возвратить его адрес template <class T> DNode<T> *DNode<T>::DeleteNode(void) { left->right = right; right->left = left; return this; } left right Текущий узел (1) left -> right = right; (2) right -> left = left; Рис. 9.З. Удаление узла в циклическом двусвязном списке 9.10. Практическая задача: Управление окнами Графический пользовательский интерфейс (Graphical User Interface, GUI) поддерживает на экране многочисленные окна. Они организованы слоями, причем переднее окно считается активным окном (active window). Некоторые приложения ведут список текущих открытых окон. Список доступен из меню и позволяет пользователю выделять наименование окна и делать его передним или активным. Это может быть особенно полезным, когда необходимо активизировать заднее окно, которое в данный момент времени является невидимым. Например, на рис. 9.4 показаны три окна, где (слева) Window_0 — это активное окно. Выбор Window_l из списка меню активизирует это окно и делает его передним.
Select Window 1 Window 0 active Windows Windows_l active Zoora Layer Tile Closo &11 Save All Full P&thnatoe Show Clipboard Window_0 Window 1 Window 2 Рис. 9.4 Списки окон Каждое окно на экране ассоциируется с каким-либо файлом. Окно создается при открытии соответствующего файла и уничтожается, когда этот файл закрывается. Мы используем связанный список для хранения списка окон. С каждой файловой операцией связываем соответствующую операцию в списке окон. Файловая операция New создает переднее окно, которое добавляется в начало списка. Операции Close и Save As применимы к активному окну в начале списка. Общая операция, такая как Close All, может быть реализована удалением окна из списка и закрытием как окна, так и соответствующего ему файла. Рассмотрим практическую задачу, которая ведет список окон для приложения GUI. Приложение поддерживает следующие файловые и списочные операции: New: Вставляет окно с именем Untitled. Close: Удаляет переднее окно. CloseAll: Закрывает все окна, очищая список. Save As: Сохраняет содержимое окна под другим именем и обновляет заголовок в элементе ввода окна. Quit: Завершает приложение. Menu Display: Оконное меню отображает номер и имя каждого окна в порядке расположения окон слоями. Пользователь может вводить номер и активизировать окно, перемещая его в начало списка окон. Список окон В любой момент времени допускается одновременное открытие максимум 10 окон. Каждое открытое окно имеет соответствующий номер в диапазоне от 0 до 9. Когда окно закрывается, этот номер становится доступным для операции New, которая создает новое открытое окно. За управление отдельными окнами и изменение текущего активного окна отвечают методы Close, Close All, Save As и Activate. Каждое окно представлено объектом окна, который определяет имя окна и его номер (index) в списке имеющихся в наличии окон.
Объект окна описывается классом Window, который содержит поля win- dowTitle и windowNumber. Класс имеет функции-члены для изменения заголовка окна, получения номера окна и вывода информации в формате Зато л овок[номер окна] (Title[window number]). Перегруженный оператор равно (==) сравнивает две Window-записи по номеру окон. Спецификация класса Window ОБЪЯВЛЕНИЕ // класс, содержащий информацию об отдельном окне class Window { private: // window-данные включают заголовок окна и // индекс в таблице доступных окнон String windowTitle; int windowNumber; public: // конструкторы Window(void) ; Window(const Strings title, int wnum) ; // методы доступа к данным void ChangeTitle(const Strings title); int GetWindowNumber(void); // перегруженные операторы int operator== (const Windows w); friend ostream& operator« (ostreams ostr, const Windows w); >; Полная реализация класса Window находится в файле windlist.h. Список окон и операции для создания, хранения и активизации объектов окна определяются классом WindowList. Спецификация класса WindowList ОБЪЯВЛЕНИЕ #include "link.h" // структура связанного списка для Window-объектов class WindowList { private: // список активных окон LinkedList<Window> windList; // список доступных окон и число открытых int windAvail[10]; int windCount; // функции получения и освобождения окон int GetWindowNumber(void); int FindAndDelete(Windows wind); // печать списка открытых окон void PrintWindowList(void); public:
// конструктор WindowList(void); // методы window-меню void New(void); void Close(void); // закрыть переднее окно void CloseAll(void); // закрыть все окна void SaveAs(const Strings name); // изменить имя void Activate(int windownum); // активизировать окно // моделировать управление окнами void Selectltem(int& item, Strings name); >; ОПИСАНИЕ Конструктор присваивает окнам начальные данные. Он устанавливает счетчик окон на нуль и отмечает каждое доступное окно в диапазоне 0-9. New получает окно из списка имеющихся в наличии окон и присваивает индекс в качестве номера окна. Окну передается заголовок Untitled. Новое окно вставляется в начало списка открытых окон. Класс WindowList поддерживает дополнительные операции: Close, Close All, Save As и Activate, которые обеспечивают реакцию на соответствующие опции меню. Операция Selectltem реализует меню. Пользователь может ввести с клавиатуры либо буквенный символ N (New), С (Close), A (Close All), S (Save As), Q (Quit), либо цифру 0,1.... Метод возвращает входной выбор и сгенерированный внутри номер пункта, обозначающий операцию. Следующая таблица соотносит номер пункта и выбор: Номер пункта 1 2 3 4 5 6 7 15 Имя New Close Close All Save As Quit WindowName[iol WindowNamefh] . . . WindowNamepg] Символьный код для выбора n(N) с(С) а(А) s(S) q(Q) i0 i1 i9 Пункты меню 2—4 выводятся только в том случае, если имеется, по крайней мере, одно открытое окно. Вывод имен и номеров окон управляется закрытым методом Print WindowList. Пункты с 6-го и далее соответствуют списку текущих открытых окон. Пункт 6 — это переднее окно с номером окна i0; пункт 7 — это второе окно с номером ij и так далее. Окно помещается в начало списка вводом с клавиатуры его номера. Новым окнам дается наименьший имеющийся в наличии номер в диапазоне 0—9. Например, если номера 0, 1, 3 и 5 используются в данный момент и 0-е окно закрывается, следующее новое окно создается с номером окна 0. Функция GetWindowNum- ber осуществляет выполнение алгоритма для распределения номеров окон. ПРИМЕР В следующей таблице приводится последовательность оконных команд. После завершения какой-либо операции мы задаем список окон с первым эле-
ментом ввода, являющимся активным окном. Для команды SaveAs последующий ввод заключается в круглые скобки. Выбор N N S (One) О N С С N А Q Номер пункта 1 1 4 7 1 2 2 1 3 5 Действие новое окно новое окно сохранить как One активизировать 0 новое окно закрыть закрыть новое окно закрыть все выход Список окон Untitled[0] Untitled[1] Untit!ed[0] One[1] Untitled[0] Untitled[0] 0ne[1] Untitled[2] Untitled[0] 0ne[1] Untit!ed[0] One[1] One[1] Untitled[0] 0ne[1] < пусто > завершение работы приложения Реализация класса WindowList Полный листинг методов WindowList содержится в файле windlist.h. Мы приводим здесь код только нескольких функций для иллюстрации использования связанного списка при ведении списка открытых окон и выполнении опций меню. Функция GetWindowNumber проходит массив windAvail в поисках имеющегося в наличии номера окна и возвращает первый найденный номер. В результате все новые окна получают наименьший возможный номер. // получить первое свободное окно из списка доступных окон int WindowList::GetWindowNumber(void) { for(int i«0;i < 10;i++) // если окно доступно, выделить его // сделать его недоступным, if (windAvail[i]) { windAvail[i] = 0; break; } return i; // return window index } Закрытая функция PrintWindowList сканирует список окон и выводит заголовок и номер для каждого окна. Метод реализует простое последовательное сканирование списка, начиная с первого (Reset) и переходя от окна к окну (Next) до достижения конца списка (EndOf List). Оператор « из класса Window выводит данные окна в формате Заголовок[номер] (Title[#]). // вывод оконной информации для всех активных окон void WindowList::PrintWindowList(void) { for(windList.Reset(); !windList.EndOfList(); windList.Next 0) cout « windList.Data() ; } Списочные операции окон. Для создания какого-либо окна метод New присваивает Window-объекту win заголовок Untitled. Вызовом GetWindowNumber этому объекту присваивается номер окна, и он вставляется в список окон; счетчик окон увеличивается.
//получение нового окна и передача ему заголовка 'untitled' void WindowList::New(void) { //проверка, имеется ли в наличии окно, если //нет, просто — возврат if (windCount ==10) { cerr « "Нет больше окон в наличии, пока одно не будет закрыто" « endl; return; } // получить новое окно с заголовком 'Untitled' вызовом // функции getWindowNumber Window win(Untitled, GetWindowNumber()); // сделать его активным, вставляя в начало списка windList.InsertFront(win); windCount++; } Для активизации окна, расположенного за другими окнами, мы должны сначала найти это окно, используя его номер в качестве ключа, а затем удалить этот элемент из списка. При вставке окна в начало списка, оно становится активным. Вызывается закрытый метод FindAndDelete, который сканирует список, выполняя поиск совпадения с номером окна. Когда окно обнаружено, метод отсоединяет его от списка и возвращает данные окна. Эта информация затем используется для создания нового окна, которое вставляется в начало списка. int WindowList::FindAndDelete(Windows wind) { int retval; // цикл по списку для поиска wind for(windList.Reset();!windList.EndOfList();windList.Next()) // window-оператор == сравнивает номера окон. // при совпадении прервать цикл if(wind == windList.Data()) break; // совпадение имеется? if(!windList.EndOfList()) { // присвоить wind значение, удалить запись и возвратить 1 (успешно) wind = windList.Data(); windList.DeleteAt(); retval =1/ } else retval - 0; // возвратить О (неуспешно) return retval; } void WindowList::Activate(int windownum) { Window win("Формальное имя", windownum); if (FindAndDelete(win)) windList.InsertFront(win); else cerr « "Некорректный номер окна.\п"; }
Программа 9.11. Управление списком окон Эта main-программа определяет WindowList-объект windops, содержащий список открытых окон. Цикл событий вызывает функцию Selectltem и выполняет действия, соответствующие значению, возвращаемому функцией. Цикл продолжается до тех пор, пока пользователь не выберет Q (Quit). #include <iostream.h> // включить классы Window и WindowList #include "windlist.h" // очистка входного буфера void ClearEOL(void) { char с; do cin.get(c); while (c != '\n'); } void main(void) { // список доступных программе окон WindowList windops; // window-заголовки String wtitle, itemText; // done = 1, если пользователь ввел символ q int done = 0, item; // моделировать до введения пользователем символа 'q' while(!done) { // выдать меню и принять ответ пользователя windops.Selectltem(item,itemText); // при выборе числа активизировать окно if (item >= б) windops.Activate(itemText[0] — '0'); // иначе выбирать из опций 0—5. // вызвать метод для обработки запроса else switch(item) { case 0: break; case 1: windops.New(); break; case 2: windops.Close(); break; case 3: windops.CloseAll(); break; case 4: cout « "Заголовок нового окна: "; ClearEOL(); wtitle.ReadString(); windops.SaveAs(wtitle); break;
case 5: done * Inbreak; } } ) /* <Выполнение программы 9.11> New Quit: n New Close Close All Save As Quit Untitled[0] : n New Close Close All Save As Quit Untitledfl] Untitled[0]: s Заголовок нового окна: one New Close Close All Save As Quit one[l] Untitled[0]: 0 New Close Close All Save As Quit Untitled[0] one[l]: s Заголовок нового окна: two New Close Close All Save As Quit two[0] one[l]: n New Close Close All Save As Quit Untitled[2] two[0] one[l]:s Заголовок нового окна: three New Close Close All Save As Quit three[2] two[0] one[l]: 0 New Close Close All Save As Quit two[0] three[2] one[l]: с New Close Close All Save As Quit three[2] one[lJ: a New Quit: q */ Письменные упражнения 9.1 Предположим, что выполняется следующая последовательность операторов: Node<int> *pl, *р2; pi * new Node<int>(2); р2 * new Node<int>(3); Что выводится каждым сегментом программы? (а) cout « pl->data « " " « p2->data « endl; (б) pl->data - 5; pl->InsertAfter(p2); cout « pl->data « " " « pl->NextNode()->data « endl; (в) pl->data * 7; p2->data * 9; p2 - pi; cout « pl->data « n n « p2->data « endl; (r) pl->data - 8; p2->data * 15; p2->InsertAfter(pi); cout « pl~>data « n " « p2->NextNode () ->data « endl; (д) pl->data - 77; p2->data ■ 17; pl->InsertAfter<p2); p2->InsertAfter(pl) ; cout « pl->data « " n « p2->NextNode ()->data « endl; 9.2 Имеется следующий связанный список объектов Node и указателей Р1, Р2, РЗ и Р4. Для каждого сегмента кода нарисуйте аналогичный рисунок, указывающий, как изменяется список.
Head (а) P2 = Pl->NextNode(); (б) head = Pl->NextNode(); (в) P3->data = Pl->data; (r) P3->data - Pl->NextNode()->data; (д) Р2 *= Pl->DeleteAfter(); delete P2/ (е) P2->InsertAfter(new Node<int>(3)); (ж) Pl->NextNode()->NextNode()->NextNode()->data » Pl->data; (з) Node<int> *P = PI; while (P !*= NULL) { P->data *= 3; P * P->NextNode(); } (и) Node<int> *P = PI; while(P->NextNode() !* NULL) { P->data *=* 3; P ~ P->NextNode(); } Head 9.3 Напишите сегмент кода, создающий связанный список со значениями данных 1..20. 9.4 Распечатайте содержимое связанного списка после каждого из следующих операторов. Для оператора cout укажите выход. Node<char> *head, *р, *q; head e new Node<char>(*B'); head * new Node<char>(*A',head); q * new Node<char>(yC); p = head; p = p->NextNode(); p->InsertAfter(q); cout « p->data « " " « p->NextNode()->data « endl;
q я p->DeleteAfter(); delete q; q ■ head; head = head->NextNode(); delete q; 9.5 Напишите функцию template <class T> Node<T> *Copy(Node<T> *p); которая создает связанный список, являющийся копией списка, начинающегося с узла р. 9.6 Предположим, что вы пишете метод класса Node и таким образом, можете изменять следующий данное-член. Опишите результат выполнения этих операторов. Так как код встречается в методе, вы имеете доступ к указателям next и this. Node<T> *p; (а) р « next; p->next = next; (б) р = this; next->next « р; (в) next * next->next; (г) р = this; next->next = p->next; 9.7 Вместо использования указателя на голову связанного списка объектов Node мы можем поддерживать заголовочный узел. Его значение данных не считается частью списка, и его следующий данное-член указывает на первый узел данных в этом связанном списке. Заголовок называется узлом-часовым и позволяет избежать пустого списка. Header Filler Data1 Предположим, что ведется связанный список целых с использованием заголовочного узла: Node<int> header(0); (а) Напишите последовательность кода для вставки узла р в начало списка. (б) Напишите последовательность кода для удаления узла из начала списка. 9.8 Это упражнение предполагает, что связанные списки создаются и поддерживаются с использованием класса Node. Два упорядоченных списка: L1 и L2 могут быть объединены в третий список L3 последовательным прохождением двух этих списков и вставкой узлов в третий список. В любом месте вы рассматриваете одно значение из L1 и одно из L2, вставляя меньшее значение в хвост L3. Например, рассмотрим следующую последовательность вставок. Текущие элементы, которые рассматриваются в Ы и L2, обведены кружком. 1 Поскольку значение данных в этом узле не используется, такие данные названы здесь данные-заполнитель. — Прим. ред.
Напишите функцию void MergeLists(Node<T> *L1, Node<T>* L2, Node<T>* &L3); для объединения LI и L2 в L3. 9.9 Каково действие этой последовательности кода? while(currPtr != NULL) { currPtr->data +* 7; currPtr ■ currPtr->NextNode(); } 9.10 Опишите действие функции F: template <class T> void F(Node<T>* &head) { Node<T> *p, *q; if (head ! + NULL&& head->NextNode() !-NULL) { q * head; head = p = head->NextNode(); while(p->NextNode() !» NULL) p ~ p->NextNode(); p->InsertAfter(q); } } 9.11 Напишите функцию template <class T> int CountKey(const Node<T> *head, T key); которая подсчитывает количество вхождений ключа в списке. 9.12 Напишите функцию template <class T> void DeleteKey(Node<T>* shead, T key; которая удаляет из списка все вхождения ключа. Шаг! Шаг 2 ШагЗ Шаг 4
9.13 Измените функцию InsertOrder в разделе 9.2, так чтобы значения данных добавлялись в начало последовательности копий. 9.14 Для каждого из пунктов (а) — (d) каков список, получающийся выполнением заданной последовательности команд: LinkedList<int> L; int i, val; (а) for(i«l;i <- 5;i++) L.InsertFront (*I); (б) for(i«l;i <- 5;i++) L.InsertAfter(2*i)/ (B) for(i=l/i <- 5;i++) L.InsertAt(2*i) (r) for(i-l;i <- 5;i++) { L.InsertAt(i); L.NextO / L.InsertAt(2*i) val » L.DeleteFront(>; } (д) Используя InsertFront, напишите оператор for для создания списка: 50, 40, 30, 20, 10 (е) Повторите пункт (е), используя InsertRear и InsertAfter. 9.15 Предположим следующие объявления: LinkedList<int> L; int i, val/ Допустим, что связанный список L содержит значения 10, 20, 30 ..., 100. Каковы значения данных в списке после выполнения каждого из следующих пунктов? (а) L.Reset(); for(i«l/i <« 5;i+) L.DeleteAtO; (б) L.Reset О; for(i«l;i <« 5;i++) { L.DeleteAtO; L.NextO/ } (в) L. Reset 0 ; for(i-l;i <* 3;i++) { vail - L.DeleteFront(); L.NextO; L.InsertAt(val)/ ) 9.16 Напишите функцию template <class T> void Split(const LinkedList<T>& L, LinkedList<T>& LI, LinkedList<T>& L2)/
которая принимает список L и создает два новых списка L1 и L2* L1 содержит первый, третий, пятый и последующие нечетные узлы. L2 содержит четные узлы. 9.17 Предположим, что L — это список целых. Напишите функцию void OddEven(const LinkedList<int>& L, LinkedList<int>& LI, LinkedList<int>& L2); которая принимает связанный список L и создает два новых списка L1 и L2. Список L1 содержит узлы списка L, значения данных которых являются нечетными числами. L2 содержит узлы, значения данных которых являются четными числами. Используйте итератор. 9.18 Напишите метод класса List int operator* (const List<T>& L); который конкатенирует список L на конец текущего списка. Возвращается 1, если оператор выполнен правильно или 0, если память для новых узлов не могла быть выделена. Не допускайте конкатенацию списка на самого себя. В этом случае должен возвращаться 0. 9.19 Напишите функцию template <class T> void DeleteRear(LinkedList<T>& L); которая удаляет хвост списка L. 9.20 Нарисуйте рисунок связанного стека Stack<int> L; целых данных после серии операций: Push(l), Push<2), Pop, Push(5), Push(7), Pop, Pop. 9.21 Реализуйте класс Stack, включая объект LinkedList с помощью композиции. 9.22 Реализуйте класс Stack, сопровождая связанный список объектов Node. 9.23 Каково действие этой функции? template <class T> void Actions(LinkedList<T>& L) { Stack<T> S; for( L.ResetO; IL.EndOfList (); L.NextO ) S.Push(L.Data()); L.Reset(); While( !S.StackEmpty() ) { L.DataO = S.PopO ; L.NextO; ) } 9.24 Нарисуйте рисунок связанной очереди
Queue<int> Q; целых данных после серии операций Qlnsert(l), QInsert(2), QDelete, QInsert(5), QInsert(7), QDelete, QInsert(9). Обязательно включите указатель rear в рисунок. 9.25 Каково действие этой функции? template <class T> void ActionQ(LinkedList<T>& L, Queue<T>& Q) { Q.QClear(); for( L.Reset(); !L.EndOfList();L.Next() ) Q.Q. Insert (L.DataO ) ; } 9.26 Измените класс Queue в разделе 9.6, так чтобы он содержал функции- члены Т PeekFront(void); Т PeakRear(void); которые возвращают значения данных в начале и в хвосте очереди, соответственно. 9.27 Класс Queue не имеет явного оператора присваивания. Объясните, почему два Queue-объекта Objl и Obj2 могут появиться в операторе Obj2 = Objl; 9.28 Реализуйте функцию Replace, выполняющую поиск значения данных в циклическом связанном списке. template <class T> CNode<T> *Replace(CNode<T> *header, CNode<T> *start, T elem, T newelem); Начиная в узле start, сканируйте список, выполняя поиск elem. Если elem будет найден, замените его новым значением данных newelem и возвратите указатель на совпадающий узел; иначе, возвращайте NULL. Примечание: при сканировании вы можете проходить заголовок. 9.29 Реализуйте функцию template <class T> void InsertOrder(CNode<T> *header, CNode<T> *elem) ; которая вставляет узел elem в циклический список, так чтобы данные были упорядочены. 9.30 Рассмотрите структуру template <class T> struct Clist { CNode<T> header; CNode<T> *rear; >; Она определяет циклический список с указателем rear.
header.next rear Разработайте функции // вставить узел р в начало списка L template <class t> void InsertFront(CList<T>& L, CNode<T> *p); // вставить узел р в хвост списка L template <class t> void InsertRear(CList<T>& L, CNode<T> *p); // удалить первый узел списка L template <class t> CNode<T> *DeleteFront(CList<t>& L); Обеспечьте правильное поддержание rear, так чтобы в любое время он указывал на последний узел списка. 9.31 (а) Напишите функцию template <class T> void Concat(CNode<T>& s, CNode<T>& t); которая конкатенирует циклический список с заголовком t на конец циклического списка с заголовком s. (б) Напишите функцию template <class T> int Lenght(CNode<T>& s)/ которая определяет количество элементов в циклическом списке с заголовком s. (в) Напишите функцию template <class T> CNode<T> *Index(CNode<T>& s, T elem); которая возвращает указатель на первый узел в циклическом списке с заголовком s, содержащий значение данных elem. Возвращайте заголовок, если elem не найден. (г) Напишите функцию template <class T> void Remove(CNode<T>& s, T elem); которая удаляет все узлы из списка s, содержащего значение данных elem. 9.32 Напишите функции-члены DNode<T> *DeleteNodeRight(void); DNode<T> *DeleteNodeLeft(void);
для класса двусвязных узлов. DeleteNodeRight удаляет узел справа от текущего узла, a DeleteNodeLeft удаляет узел слева от него. Упражнения по программированию 9.1 Это упражнение расширяет функции-утилиты для узлов в файле node- lib.h. Напишите функции, имеющие следующие объявления: // удаление хвостового узла в списке; // возврат указателя на удаленный узел template <class T> Node<T> *DeleteRear(Node<T> * & head); // удаление всех вхождений клю^а в списке template <class T> void DeleteRear(Node<T> * & head, const T& key); Напишите программу для тестирования этих функций. □ Введите 10 целых и сохраните их в списке, используя функцию InsertFront. Используйте PrintList для вывода списка. D Введите целое, которое служит в качестве ключа. Используйте DeleteKey для удаления из списка всех вхождений ключа. Распечатайте итоговый список. □ Удалите все узлы списка, используя DeleteRear. Для каждого удаления выводите значение данных. 9.2 Для этого упражнения используйте класс Node. Полем данных для упражнения является структура IntEntry. struct IntEntry { int value; // целое число int count; // вхождения значения }; Введите 10 целых и создайте упорядоченный список IntEntry узлов, использующих поле count для указания дубликатов в списке. Распечатайте итоговую информацию об узлах, которая должна включать отдельные целые значения и количество вхождений каждого целого в списке. Вам следует изменить функцию InsertOrder, чтобы при нахождении дубликата обновлялись значения данных узла. Введите ключ и удалите все узлы, значение данных которых больше, чем ключ» Распечатайте итоговый список. 9.3 Для этого упражнения используйте класс Node. Данные представляют служащего: его имя (name), id-номер (idnumber) и почасовую оплату его труда (hourlypay). struct Emplyee { char name[20]; int indnumber; float hourlypay; };
Перегрузите операторы ввода/вывода « и » для служащего и используйте PrintList для вывода списка. Введите следующие записи данных и сохраните их в связанном списке. 40 9.50 Dennis Williams 10 6.00 Harold Barry 25 8.75 Steve Walker Реализуйте следующие операции обновления, изменяющие список: (а) Считайте id-номер и выполняйте поиск записи данного служащего в списке. Если запись найдена, увеличьте почасовую оплату на $3,00. Выведите итоговый список. (Вам необходимо перегрузить оператор ==.) (б) Сканируйте список и удаляйте всех служащих, получающих более $9,00 в час. Выведите итоговый список. 9.4 Два списка L = {Lo» La, . . . Li} и М = {Mo, Mi, . . . Mj} могут быть объединены по парам для получения списка {Lo,Mo, Li,Mi, . . . Li,Mi, • . . Lj,Mj}, j > i. Напишите программу, используя класс Node, которая генерирует список Lei числами (i < i < 10) и список М с j числами (1 < j < 20). Элементы ввода в списке L являются случайными числами в диапазоне от 0 до 99, а элементы ввода в М — это случайные числа в диапазоне от 100 до 199. Программа выводит начальные списки L и М, объединяет их в новый список N и выводит его. 9.5 Повторите упражнение 9.4, используя два LinkedList-объекта L и М. 9.6 Используя класс Node, напишите программу, вводящую 5 целых в связанный список, используя следующий алгоритм: Каждое вводимое N вставлять в голову списка. Сканировать оставшийся список, удаляя все узлы, которые меньше, чем N. Выполните программу три раза, используя следующие входные данные. Распечатайте итоговый список. 1, 2, 3, 4, 5 5, 4, 3, 2, 1 3, 5, 1, 2, 4 9.7 Класс LinkedList содержит как конструктор копирования, так перегруженный оператор присваивания. Напишите тестирующую программу, которая оценивает правильность этих двух методов. 9.8 Используйте класс String из главы 8 для считывания разделенных пробелами значащих символов текста. Введите строку, которая может включать строки, начинающиеся с '-'. Например: // t и includelist являются опциями run -t -includelist linkdemo В этом примере строки включают опции (символы, начинающиеся с '-') и символы, не являющиеся опциями. Используя класс LinkedList, сохраните незначащие символы в связанном списке tokenlist и символы-опции — в связанном списке optionlist. Распечатайте символы из каждого списка.
9.9 Положительное целое п (п>1) может быть записано единственным образом как произведение простых чисел. Это называется разложением числа на простые числа. Например, 12 = 2*2*3 18 = 2*3*3 11 = 11 Функция LoadPrimes использует структуру записей IntEntry из упражнения по программированию 9.2 для создания связанного списка, который определяет различные числа и количество вхождений простых чисел в разложение числа. Например, в случае с 18 простое число 2 встречается 1 раз, а простое число 3 встречается 2 раза. void LoadPrimes(LinkdList<IntEntry> &L, int n) { int i = 2; int nc =0; // счетчик повторений do { if (n % i == 0) { nc++; n = n/i; } else { if (nc > 0) Оагрузить i и nc как узла в хвост списка> nc » 0; I++; } } while {n > 1); } Напишите программу, которая вводит два целых М и N и использует функцию LoadPrimes для создания связанного списка простых чисел. Сканируйте список и выводите разложение каждого числа на простые числа. Создайте новый список, состоящий из всех простых чисел, общих для списков М и N. Когда вы определите каждое такое простое число, возьмите минимальный счетчик в двух узлах и используйте как счетчик в узле для нового списка. Например, 2 является простым множителем 60-ти, и 2 — это простой множитель 18-ти. 18 =2*3*3 //2 имеет счетчик 1 60 =2*2*3*5 //2 имеет счетчик 2 В новом списке 2 является значением со счетчиком 1 (минимум 1, 2). Результирующее число является наибольшим общим делителем значений М и N, GCD(M,N). 9.10 Полином n-ной степени является выражением в форме f(x) = anxn + an.!Xn+1 + ... + а2х2 + аххх + а0х°
где термы ai называются коэффициентами. Используйте класс LinkedList для этого упражнения с записью данных Term, содержащей коэффициент и показатель степени х для каждого терма. struct Terra { double coeff; int power; }; В программе введите полином как ряд пар коэффициентов и показателей степени. Завершайте программу при вводе коэффициента 0. Сохраните каждую пару (коэффициент/показатель степени) в связанном списке, упорядоченном по показателю степени. (а) Напишите каждый терм результирующего полинома в форме аА * xAi (б) Введите 3 значения х и вызовите функцию double poly(LinkedList<Term>& f); которая вычисляет полином для значения х и выводит результат. 9.11 Это упражнение использует функции, разработанные в письменных упражнениях 9.12 и 9.13. Считайте упорядоченный список из 10 целых значений и распечатайте его. Запросите у пользователя какое-либо значение данных и, используя CountKey, определите, сколько раз это значение встречается в списке. Используйте DeleteKey для удаления из списка всех вхождений этого значения. Выведите новый список. 9.12 Протестируйте функцию MergeLists в письменном упражнении 9.8, используя целые данные. Включите списки L1={1,3,4,6,7,10,12,15} и L2={3,5,6,8,11,12,14,18,22,33,55} в свои тесты. 9.13 Используйте класс CNode для разработки класса Queue. Объявите заголовочный узел в закрытой секции класса и выполните вставки и удаления следующим образом: Qlnsert: InsertAfter текущий узел и перейти к новому узлу. QDelete: Delete After заголовочный узел. Будьте осторожны и выполняйте проверку на удаление из пустой очереди. Пустая очередь front, rear front rear
Протестируйте вашу реализацию, вставляя целые значения 1, 2, ..., 10 в очередь. Сделайте очередь пустой и распечатайте значения. 9.14 Мы можем реализовать класс Set с элементами типа Т, сохраняя уникальные элементы данных в связанном списке. Реализуйте эти две функции template <class T> LinkedList<T> Union(LinkedList<T>& x, LinkedList<T>& y); template <class T> LinkedList<T> Intersection(LinkedList<T>& x, LinkedList<T>& y); Union возвращает связанный список, представляющий множество всех элементов, которые найдены, по крайней мере, в одном х или у. Intersection возвращает связанный список, представляющий множество всех элементов, находящихся как в х, так иву. Пусть А будет множеством целых {1,2,5,8,12,33,55}, а В — множеством {2,8,10,12,33,88,99}. Используйте эти две функции для вычисления и вывода A U В - {1, 2, 5, 8, 10, 12, 33, 55, 88, 99} и АГ1 В - {2, 8, 12, 33} 9.15 Начнем с пустого циклического двусвязного списка целых. Введите 10 целых, вставляя положительные числа непосредственно справа от заголовка, а отрицательные числа — слева. Распечатайте этот список. Разделите список на два односвязных, содержащих положительные и отрицательные числа, соответственно. Распечатайте каждый список. 9.16 Это упражнение изменяет класс Window из раздела 9.10. Добавьте метод Save All. Он должен проходить список окон от переднего окна до заднего и выводить заголовок каждого окна и сообщение Заголовок: <window-3amnoBOK>. Если окно в текущий момент является окном без заголовка (Untitled), выдайте запрос на ввод заголовка.
глава ю Рекурсия 10.1. Понятие рекурсии 10.2. Построение рекурсивных функций 10.3. Рекурсивный код и стек времени исполнения 10.4. Решение задач с помощью рекурсии 10.5. Оценка рекурсии Письменные упражнения Упражнения по программированию
Рекурсия является важным инструментом решения вычислительных и математических задач. Она широко используется для определения синтаксиса языков программирования, а также в структурах данных при разработке алгоритмов сортировки и поиска для списков и деревьев. Математики применяют рекурсию в комбинаторике для разного рода подсчетов и вычисления вероятностей. Рекурсия — важный раздел, имеющий теоретическое и практическое применение в общей теории алгоритмов, моделях исследования операций, теории игр и теории графов. В данной главе мы даем общее введение в рекурсию и иллюстрируем ее применение различными приложениям. В следующих главах рекурсия будет использоваться для изучения деревьев и сортировки. 10.1. Понятие рекурсии Для большинства людей рекурсивное мышление не является характерным. Если вас, допустим, попросят определить степенную функцию хп, где х — действительное число, an — неотрицательное целое, то типичным ответом будет следующий: хп = х * х * х * ... * х * х I I п множителей Вот, например, различные значения степеней двойки: 2° = 1 //по определению 21 = 2 22 . 2 * 2 = 4 23 =2*2*2=8 24 = 2*2*2*2 = 16 Функция S(n) вычисляет сумму первых п положительных целых — задача, которая решается путем многократного сложения. п S(n)=]Ti = l + 2 + 3+...+ n-1 + п 1 Например, для S(10) мы складываем первые 10 целых, чтобы получить ответ 55: ю S(10) = £i = l + 2 + 3+...+9 + 10=55 l Если мы применим этот же алгоритм для вычисления S(ll), процесс повторит все эти сложения. Более практичным подходом было бы использовать предыдущий результат для S(10), а затем добавить к нему 11, чтобы получить ответ S(ll) = 66: 11 S(ll) = £ i = S(10) + 11 = 55 + 11 = 66 l При таком подходе для получения ответа используется предыдущий результат вычислений. Мы называем это рекурсивным процессом.
Вернемся к степенной функции и представим ее с помощью рекурсивного процесса. Подсчитывая последовательные степени двойки, мы заметили, что предыдущее значение может быть использовано для вычисления следующего. Например, 23 =2* 22 =2*4=8 24 * 2 * 23 - 2 * 8 - 16 Поскольку мы имеем начальную степень двойки (2° = 1), последующие ее степени есть всего лишь удвоение предыдущего значения. Процесс использования меньшей степени для вычисления очередной приводит к рекурсивному определению степенной функции. Для действительного х значение хп определяется как ' 1, при п = О хп = | х * х*""1*, при п > О Похожее рекурсивное определение описывает функцию S(n), дающую сумму первых п целых чисел. Для простого случая S(l) сумма равна 1. Сумма S(n) может быть получена из S(n-l): {1, при п = 1 n + S(n-l), при п > 1 Рекурсия имеет место, когда вы решаете какую-то задачу посредством разбиения ее на меньшие подзадачи, выполняемые с помощью одного и того же алгоритма. Процесс разбиения завершается, когда мы достигаем простейших возможных решаемых подзадач. Мы называем эти задачи условиями останова. Рекурсия действует по принципу "разделяй и властвуй". Алгоритм определен рекурсивно, если это определение состоит из 1. Одного или нескольких условий останова, которые могут быть вычислены для определенных параметров. 2. Шага рекурсии, в котором текущее значение в алгоритме может быть определено в терминах предыдущего значения. В конечном итоге шаг рекурсии должен приводить к условиям останова. Например, рекурсивное определение степенной функции имеет единственное условие останова для случая п = 0 (х° =1). Шаг рекурсии описывает общий случай хп = х * х&л\ при п > О Рекурсивные определения В языках программирования применяются разнообразные рекурсивные методы определения синтаксиса. Большинство читателей знакомо с синтаксическими диаграммами. Приведенная ниже диаграмма описывает идентификатор (identifier), который состоит из начальной буквы и необязательно следующей за ней цепочки букв и цифр. Например, class, float и var4 — допустимые идентификаторы, которые могут встречаться в программе на C++, в то время как Х++ и 2team идентификаторами не являются.
буква буква цифра Пройдите эту диаграмму слева направо, двигаясь по стрелкам. Во время движения включайте каждый символ, содержащийся в прямоугольнике. Например, простейший проход по диаграмме заключается в движении от входа к выходу через первый прямоугольник "буква". Это соответствует однобуквен- ному идентификатору, например, A, n, t и т.д. Для каждого из приведенных ниже идентификаторов описан путь по диаграмме. Идентификатор А XY3 7А Путь Войти в прямоугольник "буква (А)", выйти из диаграммы. Войти в прямоугольник "буква (X)", пройти по стрелке через прямоугольник "буква (Y)", пройти через прямоугольник "цифра (3)", выйти из диаграммы. Недопустимый идентификатор; нет выхода из диаграммы. Несмотря на то, что синтаксические диаграммы эффективны для описания некоторых языков, синтаксис фактически всех языков программирования описывается с помощью рекурсивной нотации, называемой формой Бэкуса- Наура (Bakus-Naur Form, BNF). Она была разработана для определения Алгола 60 и состоит из правил подстановки, определяющих, как нетерминальный символ может быть замещен другим нетерминальным или терминальным символом. В правилах подстановки используется знак "|" для разделения альтернативных подстановок. Нетерминальные символы заключены в угловые скобки "о" и представляют собой такие языковые конструкции, как идентификаторы и выражения. Терминальные символы определяют фактические знаки в языке. Например, BNF-определение идентификатора представляется приведенным ниже правилом. Символ "идентификатор" — нетерминальный символ, расположенный слева от оператора "::=". <идентификатор> ::- <буква> | <идентификатор> <буква> | <идентификатор> <цифра> <буква> ::-a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p|q|r|s|t|u|v|w|x|y|z <цифра> ::= 0|1|2|3|4|5|6|7|8|9 Эти правила указывают, что идентификатор есть буква или буква с последующей цепочкой букв или цифр. Обратите внимание, что идентификатор определяется в терминах самого себя, поэтому его определение рекурсивно. Интерпретация рекурсивных определений является естественным применением для рекурсивных функций. Например, некоторые компиляторы используют для трансляции программ в машинный код BNF-определение грамматики языка и алгоритмы рекурсивного спуска. Различные правила подстановки кодируются как функции, которые могут завершаться вызовом самих себя или других правил. Процесс может заключать в себе косвенную рекурсию (inderect recursion), когда, например, правило Р вызывает правило Q, которое завершается вызовом правила Р.
Пример 10.1 Упрощенные арифметические выражения, допускающие только бинарные операторы +, -, * и /, имеют следующую BNF-формулировку: <выражекие> ::= <терм> + <терм> | <терм> - <терм> | <терм> <терм> : :* <множитель> * <множитель> j <множитель> / <множитель> | <множитель> <множитель> ::* (<выражение>) j <буква> | <цифра> Примерами выражений являются: А + 5; В*С + D; 2*(3+4+5); (A+B*C)/D Эти правила применяют косвенную рекурсию. Например, выражение может быть термом, который может быть множителем, который может быть выражением, заключенным в скобки. Следовательно, выражение косвенно определено в терминах самого себя. Рекурсивные задачи Сила рекурсии обеспечивает весьма простые и изящные решения ряда проблем. Приведем обзор задач и методов их решения, использующих рекурсию. Ханойская башня. Любители головоломок долго были увлечены задачей о ханойской башне. Шпиль А Шпиль В Шпиль С Согласно легенде, у жрецов храма Брахмы есть медная платформа с тремя алмазными шпилями. На одном шпиле А нанизано 64 золотых диска, каждый из которых немного меньше того, что под ним. Конец света наступит, когда жрецы переместят диски со шпиля А на шпиль С. Однако задача имеет весьма специфические условия. За один раз можно перемещать только один диск, и при этом ни разу диск большего размера не должен лечь на диск меньшего размера. Несомненно, жрецы все еще работают, так как задача включает в себя 264 — 1 хода. Если тратить по одной секунде на ход, то потребуется 500 миллиардов лет. Ханойская башня — тяжелое испытание для решателя головоломок. Между тем опытный программист видит быстрое рекурсивное решение. Мы проиллюстрируем проблему на шпилях, содержащих шесть дисков. Начнем, сосредо- Шпиль А Шпиль В Шпиль С
точившись на перемещении верхних пяти дисков на шпиль В и последующего перемещения самого большого диска на шпиль С. Осталась более простая задача перемещения только пяти дисков со шпиля В на шпиль С. Применяя тот же самый алгоритм, сосредоточим внимание на верхних четырех дисках и вытащим их из стопки. Затем перенесем самый большой диск со шпиля В на шпиль С. Осталась еще меньшая стопка из четырех дисков. Процесс продолжается до тех пор, пока не останется один диск, который в конечном счете перемещается на шпиль С. Шпиль А Шпиль В Шпиль С Очевидно, что данное решение носит рекурсивный характер. Проблема разбивается на последовательность меньших подзадач одного и того же типа. Условием останова является простая задача перемещения одного диска. Лабиринт. Каждый знаком с проблемой перехода через лабиринт с возможностью бесконечного числа альтернатив, приводящих в тупики и окончательному запутыванию. Психологи используют лабиринт с приманкой в виде сыра для исследования поведенческих моделей у крыс. Мы полагаем, что неудачи зверька в деле поиска сыра следуют из его неспособности мыслить рекурсивно. Рассмотрите рис. 10.1 и обдумайте стратегию, гарантирующую надежный способ пройти до "Конца". Мы представляем важное средство решения задач, называемое возвратами (backtracking). Конец Начало Рис. 10.1 Обход лабиринта
Лабиринт есть связанное множество перекрестков. Каждый перекресток имеет три связанных с ним значения: налево, прямо и направо. Если некоторое значение равно 1, то движение в данном направлении возможно. Нуль показывает, что движение в данном направлении заблокировано. Множество из трех нулей представляет мертвую точку, или тупик. Проход по лабиринту считается успешным, когда мы достигаем точки "Конец". Процесс включает ряд рекурсивных шагов. В каждом перекрестке мы исследуем наши варианты. Если возможно, то сначала идем налево. Достигнув очередного перекрестка, мы снова рассматриваем свои варианты и пытаемся идти налево. Если выбор левого направления заводит в тупик, мы возвращаемся и выбираем движение прямо, если это направление не заблокировано. Если этот выбор заводит в тупик, мы возвращаемся и идем направо. Если все альтернативы движения из данного перекрестка ведут в тупики, мы возвращаемся к предыдущему перекрестку и делаем новый выбор. Эта довольно скучная и консервативная стратегия не побьет рекорды эффективности, но гарантирует, что в конце концов мы найдем выход из лабиринта. Рассмотрим первые 10 вариантов в нашем лабиринте. Комбинаторика. Рекурсия находит широкое применение в комбинаторике — разделе математики, касающемся подсчета объектов. Предположим, мы бросаем три игральные кости и записываем общий итог. Одним из вопросов комбинаторики является количество различных способов, которыми можно набрать 8 очков. Рекурсивный подход пытается свести проблему к постепенно упрощающимся задачам и использовать эти результаты для решения более сложной проблемы. В нашем случае три игральные кости считаются сложной проблемой, и мы фокусируем внимание на более простом случае бросания двух игральных костей. Мы предполагаем, что можем взять две игральные кости и любой результат N в диапазоне от 2 до 12 и определить все различные способы выпадения N. В случае трех костей, дающих в сумме 8, мы бросаем первую кость и записываем значение в таблице вместе со значением N, представляющим оставшееся количество очков, которое должно быть набрано двумя следующими костями. Например, если на первой кости выпало 3, следующие две должны дать в сумме 5. Используя наши навыки с двумя игральными костями, определяем, что есть четыре возможных исхода бросания двух костей, дающих 5. Объединяя эти исходы с тройкой на первой кости, мы имеем как минимум четыре способа получить восьмерку на трех костях. В следующей таблице перечислены все 15 способов выпадения восьмерки на трех игральных костях. Поскольку количество бросаний невелико, мы можем перечислить все варианты, не прибегая к рекурсии. Мы проверим по- хожые случаи, когда решение с помощью таблицы практически неосуществимо. Перекресток Выбор 1: 2: 3: 7: 3: 4: 6: 4: 5: 2: прямо налево налево тупик; вернуться к 3 прямо прямо тупик; вернуться к 4 прямо тупик; вернуться к 4, 3, 2 прямо • ■ • Результирующий перекресток 2 3 7 3 4 б 4 5 2 8
Кость1 1 2 3 4 5 б № 7 б 5 4 3 2 Различные исходы на двух костях (3.3) (4,2) (2,4) (5,1) (1,5) (4,1) (3,2) (1,4) (2,3) (3,1) (2,2) (1,3) (2,1) (1,2) (1.1) Количество 0 5 4 3 2 1 15 Чтобы подсчитать вероятность выпадения восьмерки на трех костях, следует разделить 15 на общее число различных исходов бросания трех костей. Это число равно б3 = 216. Таким образом, вероятность выбросить восьмерку равна 15/216, или 7%. Синтаксические деревья. На этапе нашего изучения стеков вводятся инфиксный и постфиксный (RPN) форматы записи арифметических выражений. Эти форматы можно сравнить между собой, запоминая операторы и операнды в виде узлов бинарного дерева. Операнды помещаются в листовые узлы на конце ветви. Предшествование оператора отражается его уровнем на дереве. Оператор на более глубоком уровне дерева должен быть выполнен до оператора менее глубокого уровня. Например, выражение а * b + c/d представляется соответствующим деревом с семью узлами: Как и в случае с лабиринтом, мы можем разработать методы рекурсивного прохождения, выбирающие те или иные варианты в каждом узле дерева. Предположим, мы руководствуемся следующими правилами: Если возможно, идти по левой ветви. Выписать значение, содержащееся в узле. Если возможно, идти по правой ветви. Поскольку мы выписываем значение узла в между директивами прохождения, назовем это симметричным прохождением (inorder scan). Ниже приводится последовательность прохождения узлов синтаксического дерева1. Условие останова возникает, когда нельзя пройти ни по левой ни по правой ветви. 1 В книге Д.Кнута "Искусство программирования для ЭВМ", т.1, Основные алгоритмы, этот метод называется концевым от оригинального enorder. — Прим. ред.
Действие Начать с корня Идти по левой ветви Идти по левой ветви (нет левой ветви из а) Выписать значение (нет правой ветви из а) (возврат к узлу *; движение влево завершено) Выписать значение Идти по правой ветви (нет левой ветви из Ь) Выписать значение (нет правой ветви из Ь) (возврат к узлу +; движение влево завершено) Выписать значение Идти по правой ветви Идти по левой ветви (нет левой ветви из с) Выписать значение (нет правой ветви из с) (возврат к узлу /; движение влево завершено) Выписать значение Идти по правой ветви (нет левой ветви из d) Выписать значение (нет правой ветви из d) Результирующий узел + * а b / с d Вывод а а* а*Ь а*Ь+ а*Ь+с а*Ь+с/ a*b+c/d После того как все узлы пройдены, проход завершается и мы имеем инфиксную форму выражения. Другой порядок рекурсивного прохождения определяется следующими правилами: Если возможно, идти по левой ветви. Если возможно, идти по правой ветви. Выписать значение, содержащееся в узле. Поскольку выписывание значения узла происходит после обеих директив обхода, назовем это обратным прохождением (postorder scan). В результате узлы будут выписаны в следующем порядке: a b * с d / + Это постфиксная, или RPN-форма записи выражения. Рекурсия является мощным средством определения и прохождения деревьев. Мы будем использовать разнообразные рекурсивные алгоритмы в гл. 11. В гл. 13 разработаем итерационные эквиваленты этих алгоритмов для создания итераторов дерева. 10.2. Построение рекурсивных функций Структура рекурсивной функции иллюстрируется задачей вычисления факториала неотрицательных целых чисел. Мы рассмотрим эту структуру, разработав как рекурсивное, так и итерационное определение функции. Факториал неотрицательных целых чисел, Factorial(N), определяется как произведение всех положительных целых чисел, меньших или равных N. Число, обозначаемое NI, представляется следующим образом:
N! = N * (N-l) * (N-2) * ... * 2 * 1 Например, Factorial (4) =41=4*3*2*1 = 24 Factorial (6) =61=6*5*4*3*2*1 = 720 Factorial (1) = 1! = 1 Factorial(0) = 0! = 1 //по определению Итерационная версия этой функции реализуется посредством возврата 1, если п=0, и циклом перемножения членов последовательности в противном случае. // итерационная форма факториала long Factorial(long n) { int prod =1, i; // для n == 0 вернуть prod = 1, в противном случае // вычислить prod = 1*2*..*n if (n > 0) for (i = 1; i <= n; i++) prod *= i; return prod; } Рассмотрение членов последовательности в различных примерах факториала приводит к рекурсивному определению функции Factorial(N). Для 41 первое число равно 4, а остальные — (3*2*1) — равны 31. То же справедливо и для 61, являющегося произведением 6 и 5!. Рекурсивное определение любого неотрицательного целого п включает в себя как условие останова, так и шаг рекурсии: {1, при п = 0 // условие останова п * (п-1)!, при п > 1 // шаг рекурсии Можно представить себе функцию Factorial(n) как n-машину, вычисляющую п! путем п * (п-1)!. Чтобы машина функционировала, она должна быть связана с рядом других машин, передающих информацию вперед и назад. 0-машине ассистирует другая машина. Опишем необходимые связи и взаимодействие машин для 4-машины, вычисляющей 4!. 4-машина (4*3!) должна запустить 3-машину 3-машина (3*2!) должна запустить 2-машину 2-машина (2*1!) должна запустить 1-машину 1-машина (1*0!) должна запустить 0-машину Работа отдельных машин описывается на рис. 10.2. Как только активизируется 0-машина, мы сразу в результате получаем единицу, которая передается в 1-машину. У 1-машины теперь есть информация, чтобы завершить умножение и передать результат 2-машине. 1*01=1*1=1 Необходимые передаваемые значения становятся доступными последовательно от 1-машины до 4-машины. 1-машина использует значение 1 из 0-машины и вычисляет 1 * 0! = 1 2-машина использует значение 1 из 1-машины и вычисляет 2 * 1! = 2 3-машина использует значение 2 из 2-машины и вычисляет 3 * 2! = 6 4-машина использует значение 6 из 3-машины и вычисляет 4 * 3! = 24
Начать 3! Начать 2! Начать 1! Начать О! ► 4*(4-1)! 4! = 24 3*<3-1)! 3! = б 1 2*(2-1)! 2! = 2 1*(М)! 1! = 1 О! = 1 Рис 10.2. Факториал-машины При вычислении N! нужно четко различать случай 0!, представляющий условие останова, и другие случаи (N>0), представляющие шаги рекурсии. Это различие является фундаментальным для построения рекурсивного алгоритма. Программист реализует распознавание данной ситуации с помощью оператора IF ... ELSE. Блок IF обрабатывает условие останова, а блок ELSE выполняет шаг рекурсии. Для факториала блок IF вычисляет единственное условие останова N = 0 и возвращает единицу. Блок ELSE выполняет шаг рекурсии, вычисляя выражение N * (N-l)I, и возвращает результат. // Рекурсивная форма факториала long Factorial (long n) { // условием останова является п == 0 if (n =*= 0) return 1; else // шаг рекурсии return n * Factorial (п-1); } На рис. 10.3 описана последовательность вызовов функции при вычислении Factorial(4). Предположим, что первоначально функция вызывается из главной программы. Внутри блока функции выполняется оператор ELSE с параметрами 3, 2, 1 и 0. На последнем вызове выполняется оператор IF с п = 0. По достижении условия останова рекурсивная цепочка вызовов прерывается и начина- Параметр 0 Параметр 1 Параметр 2 Параметр 3 Параметр 4 Действие Вычислить: 0! = 1 Действие Вычислить: 1* Factorial (0) Действие Вычислить: 2* Factorial (1) Действие Вычислить: 3* Factorial (2) Действие Вычислить: 4* Factorial (3) Возврат 1 Возврат 1 Возврат 2 Возврат 6 Возврат 24 Передаваемые параметры Возвращаемые значения Главная программа Рис. 10.3. Набор факториалов
ется серия вычислений в порядке 1*1, 2*1, 3*2 и 4*6. Последнее значение 24 возвращается в главную программу. Пример 10.2 Конструкция IF..ELSE различает условие останова и шаг рекурсии при вычислении степенной функции и суммы из раздела 10.1. Для степенной функции значение power(0,0) не определено, и в этом случае выдается сообщение об ошибке. 1. Степенная функция (рекурсивная форма) // вычислить х в степени п, используя рекурсию float power(float x, int n) { // условием останова является п =- 0 if (n «• 0) // 0 в степени 0 не определен if (х — 0) { cerr « "power(0,0) не определено" « endl; exit(1); } else // x в степени 0 равен 1 return 1; else // шаг рекурсии: // power(х, n) = х * power(x, n-1) return x * power(x, n-1); ) 2. Функция суммирования (рекурсивная форма) // вычислить 1+2+ ... +п рекурсивно int S(int n) { // условием останова является п -- 1 if (n ==» 1) return 1; else // шаг рекурсии: S(n) = n + S(n-l) return n + S(n-1); } Программа 10.1. Использование функции Factorial Эта программа иллюстрирует рекурсивную форму факториальной функции. Пользователь вводит четыре целых числа и получает их факториалы. #include <iostream.h> // вычислить п! « п (п-1) (п-2) ... (2) (1) при 0! = 1 рекурсивно long Factorial(long n) { // если n == 0, то 0! = 1; иначе п! *= п*(п-1) ! if (n =»« 0)
return 1; else return n * Factorial(n-1); } void main (void) { int i, n; // ввести 4 положительных числа и вычислить п! для каждого из них cout « "Введите 4 положительных целых числа: "; for (i - 0; i < 4; i++) { cin » n; cout « n « " !- "« Factorial (n) « end; } } /* <Выполнение программы 10.1> Введите 4 положительных целых числа: 0 7 14 0! - 1 7! - 5040 1! = 1 4! = 24 V 10.3. Рекурсивный код и стек времени исполнения Функция — это последовательность инструкций, выполняемых в ответ на ее вызов. Процесс выполнения начинается с того, что вызывающий блок заполняет активизирующую запись (activation record), которая включает список параметров и местоположение следующей инструкции, подлежащей выполнению после возврата из блока. Параметры <фактические параметры> Местоположение <следующая инструкция> Активизирующая запись При вызове функции данные из активизирующей записи заталкиваются в стек, организуемый системой (стек времени исполнения). Данные объединяются с локальными переменными и образуют активизирующий фрейм, доступный функции. Активизирующая запись Локальные переменные Адрес возврата Параметры Активизирующий фрейм
При выходе из функции устанавливается местоположение следующей инструкции (рис. 10.4), а данные в стеке, соответствующие активизирующей записи, уничтожаются. Рекурсивная функция повторно вызывает саму себя, используя всякий раз модифицированный список параметров. При этом последовательность активизирующих записей заталкивается в стек до тех пор, пока не будет достигнуто условие останова. Последовательное выталкивание этих записей и дает нам наше рекурсивное решение. Функция вычисления факториала иллюстрирует использование активизирующих записей. Вызывающий блок Функциональный блок <следующая инструкция> F(<cnncoK параметров>): <Возврат> Рис. 10.4. Вызов функции и возврат Стек времени исполнения С помощью примера вычисления факториала от 4 мы проиллюстрируем использование активизирующих записей и стека, создаваемого во время выполнения рекурсивной функции. Начальный вызов факториала производится из главной программы. После выполнения функции управление возвращается в точку RetLockl, где переменной N присваивается значение 24(4!): void main (void) { int N; // поместить в стек запись с помощью вызова FACTORIAL(4) // RetLockl — адрес присвоения N == FACTORIAL(4) N = FACTORIAL(4); RetLockl * } Рекурсивные вызовы в функции FACTORIAL возвращают управление в точку RetLock2, где вычисляется произведение n * (n-l)I. Результат вычисления запоминается в переменной temp, чтобы помочь читателю проследить код и продемонстрировать стек времени исполнения: long FACTORIAL(long n) { int temp; if (n == 0) return 1; // вытолкнуть из стека активизирующую запись else { // поместить в стек активизирующую запись с помощью вызова FACTORIAL(n-1) // Retlock2 - адрес вычисления п * FACTORIAL(n-1) temp = n * FACTORIAL(n-1); RetLock2 1 return temp; // вытолкнуть из стека активизирующую запись } }
Вызывающий блок FACTORIALO) FACTORIALO) FACTORIALO) FACTORIALO) Главная программа Параметр о Параметр 1 Параметр 2 Параметр 3 Параметр 4 Возврат: RetLock2 Возврат RetLock2 Возврат. RetLock2 Возврат RetLock2 Возврат RetLockl Рис 10.5. Стек времени выполнения Для функции FACTORIAL активизирующая запись имеет два поля. Выполнение FACTORIAL(4) инициирует последовательность из пяти вызовов. На рис. 10.5 показаны активизирующие записи для каждого вызова. Записи входят в стек снизу вверх вместе с вызовом из главной процедуры, занимая нижнюю часть стека. Параметры long n Местоположение <следующая инструкция> Активизирующая запись При обращении к функции FACTORIAL с параметром 0 возникает условие останова, и начинается выполнение последовательности операторов возврата. Когда из стека выталкивается самая верхняя активизирующая запись, управление передается в точку возврата. Очистка стека от активизирующих записей описывается следующими операциями. Параметр 0 1 2 3 4 Адрес возврата RetLoc2 RetLoc2 RetLoc2 RetLoc2 RetLod Инструкции возврата RetLoc2 temp = 1 * 1; // 1 from FACTORIAL(O) return temp; // temp ~ 1; RetLoc2 temp = 2 * 1; // 1 from FACTORIALO) return temp; // temp = 2; RetLoc2 temp = 3 * 2; // 2 from FACTORIAL(2) return temp; // temp = 6; RetLoc2 temp = 4 * 6; // 6 from FACTORIALO) return temp; // temp = 24; RetLoc2 N = FACTORIALS); // возврат к главной процедуре 10.4. Решение задач с помощью рекурсии Многие вычислительные задачи имеют весьма простую и изящную формулировку, которая непосредственно переводится в рекурсивный код. В раз-
деле ЮЛ рассмотрен ряд примеров, включая Ханойскую башню, лабиринт и комбинаторику. В этом разделе мы расширим диапазон примеров и рассмотрим рекурсивное определение алгоритма бинарного поиска, решение некоторых комбинаторных задач, разгадку Ханойской башни, а также сконструируем класс Maze для работы с общими задачами лабиринтного типа. Бинарный поиск При бинарном поиске берется некоторый ключ и просматривается упорядоченный массив из N элементов на предмет совпадения с этим ключом. Функция возвращает индекс совпавшего с ключом элемента или -1 при отсутствии такового. Алгоритм бинарного поиска может быть описан рекурсивно. Допустим, отсортированный список А характеризуется нижним граничным индексом low и верхним — high. Имея ключ, мы начинаем искать совпадение в середине списка (индекс mid). mid - {low+high)/2 Сравнить A[mid] с ключом Если совпадение произошло, мы имеем условие останова, что позволяет нам прекратить поиск и возвратить индекс mid. Если совпадение не происходит, можно воспользоваться тем фактом, что список упорядочен, и ограничить диапазон поиска "нижним подсписком" (слева от mid) или "верхним подсписком" (справа от mid). Если ключ < A[mid], совпадение может произойти только в левой половине списка в диапазоне индексов от low до mid-1. Если ключ > A[mid], совпадение может произойти только в правой половине списка в диапазоне индексов от mid+1 до high. Шаг рекурсии направляет бинарный поиск для продолжения в один из подсписков. Рекурсивный процесс просматривает все меньшие и меньшие списки. В конце концов поиск заканчивается неудачей, если подсписки исчезли. Это происходит тогда, когда верхний предел списка становится меньше чем нижний предел. Условие low>high — второе условие останова. В этом случае алгоритм возвращает -1. Бинарный поиск (рекурсивная форма). В шаблонной версии бинарного поиска в качестве параметров используется массив элементов типа Т, значение ключа, а также верхний и нижний граничные индексы. Оператор IF обрабатывает два условия останова: 1) совпадение произошло; 2) ключевого значения нет в списке. В блоке ELSE оператора IF выполняется шаг рекурсии, который направляет дальнейший поиск в левый (ключ<А[пш1]) или в правый подсписок (ключ>А[п^]). Тот же алгоритм применяется по принципу "разделяй и властвуй" к последовательности все меньших интервалов, пока не произойдет успех (совпадение) или неудача. // рекурсивная версия бинарного поиска ключевого значения //в упорядоченном массиве А template <class T> int BinSearch(T А[], int low, int high, T key) { int mid; T midvalue;
// условие останова: ключ не найден if (low > high) return (-1); // сравнить ключ с элементом в середине списка. // если совпадения нет, разделить на подсписки. // применить процедуру бинарного поиска к подходящему подсписку else { mid* (low+high)/2; midvalue * A[mid]; // условие останова: ключ найден if (key « midvalue) return mid; // ключ найден по индексу mid // просматривать левый подсписок, если key < midvalue; // в противном случае — правый подсписок else if (key < midvalue) // шаг рекурсии return BinSearch(A, low, mid-1, key); else // шаг рекурсии return BinSearcMA, mid+1, high, key); } } Программа 10.2. Тестирование функции бинарного поиска Эта программа считывает список слов из файла vocab.dat в массив Wordlist. Список слов отсортирован в алфавитном порядке. Запрашивается ключевое слово. Если оно будет найдено, печатается его индекс в списке. В противном случае выдается сообщение об отсутствии ключевого слова в данном списке. Функция поиска находится в файле search.h. #include <iostream.h> #include <fstream.h> #include "strclass.h" #include "search.h" void main(void) { // поиск производится в массиве упорядоченных строк из потока fin String wordlist[50]; ifstream fin; String word; int pos, i; // открыть файл vocab.dat, содержащий упорядоченные слова fin.open("vocab.dat"); // читать до конца файла и инициализировать wordlist i = 0; while(fin » wordlist[i]) i++; // запросить слово cout « "Введите слово: "; cin » word;
// бинарный поиск введенного слова if ((pos = BinSearch(wordlist,0,i,word)) != -1) cout « word « " есть в списке по индексу " « pos « endl; else cout « word « " отсутствует в списке." « endl; ) /* <Входной файл vocab.dat> array class file struct template vector <1-й прогон программы 10.2> Введите слово: template template есть в списке по индексу 4 <2-й прогон программы 10.2> Введите слово: mark mark отсутствует в списке. */ Комбинаторика: задача о комитетах К комбинаторным относятся алгоритмы подсчета числа способов наступления того или иного события. В классической задаче о комитетах требуется определить число C(N,K), где N и К — неотрицательные целые, равное количеству способов формирования комитетов по К членов в каждом из общего списка N людей. Исследуем решение общей задачи на примере cN = 5hK = 2. Этот упрощенный случай можно применить к маленькой организации и быстро составить исчерпывающий перечень десяти различных вариантов. Обозначим членов этой организации как А, В, С, D и Е. Возможные комитеты изображены вокруг группы людей. Этот подход не годится для большего числа членов, и нам нужно использовать стратегию "разделяй и властвуй", чтобы разбить задачу на более простые подзадачи. Упростим проблему, исключив член А из общей группы. Теперь осталось четыре человека: В, С, D и Е.
Подзадача 1: Попросите четверку оставшихся сформировать все возможные комитеты по 2 человека. Получается шесть различных подкомитетов. Список 1: (В,С), (B,D), (B,E), (C,D), (C,E), (D,E) Заметьте, что ни один из новых комитетов не включает отсутствующий член А. Подзадача 2: Попросите четырех членов группы сформировать все возможные комитеты по одному человеку. (В), (С), (D), (Е) В каждом из этих комитетов не хватает одного человека. Добавим в них член А. Список 2: (А,В), (А,С), (A,D), (A,E) Требуется, чтобы комитеты из двух человек, сформированные в подзадачах 1 и 2, охватили все возможные комитеты первоначальной задачи. Приведенный ниже рисунок описывает оба случая. Подзадача 1 Комитеты из К = 2 членов Подзадача 2 Комитеты из К = 1 членов Шесть групп в списке 1 представляют все комитеты, не содержащие член А. Четыре группы в списке 2 представляют все комитеты, содержащие член А. Поскольку комитет должен либо содержать, либо не содержать А, десять комитетов в двух списках представляют собой все возможные варианты комитетов по два человека. Разработка алгоритма. В анализе по принципу "разделяй и властвуй" (рекурсивном) мы имеем дело с общей проблемой подсчета количества комитетов из К членов, которые можно сформировать из N людей. Удалим персону А и рассмотрим оставшиеся N-1 человек. Общее число комитетов складывается из числа комитетов по К членов, выбранных из N-1 человек (член А не включается), и числа комитетов по К-1 членов, выбранных из N-1 человек (включая А). Первая группа насчитывает C(N-1,K), а вторая — C(N-1,K-1) вариантов. Меньший комитет из К-1 членов во второй группе расширится до комитета из К членов, если член А присоединится к этой группе. C(N,K) = C(N-1,K-1) + C(N-1,K) // шаг рекурсии Условия останова состоят из нескольких экстремальных случаев, которые можно проанализировать непосредственно. Если К > N, то нет достаточного числа людей для формирования комитетов. Число возможных комитетов по К членов, сформированных из N человек, равно нулю.
Если К — 0, то формируется пустой комитет и это можно сделать лишь одним способом. Если К = N, все должны оказаться в одном комитете. Это может произойти только выбором всех членов списка в этот комитет. C(N,N) = 1 C(N,0) - 1 C(N/K) = 0 при К > N Объединяя условия останова и шаг рекурсии, можно реализовать рекурсивную функцию comm(n,k) = C(n,k). Эта функция расположена в файле comm.h. // определить число комитетов из к членов, // которые можно сформировать из п человек int comm (int n, int k) { // условие останова: слишком мало народа if (k > n) return 0; // условие останова: в комитете все или никого else if (n -« к || к — 0) return 1; else // шаг рекурсии: все комитеты без персоны А // плюс все комитеты с персоной А return comm(n-l,k) + comm(n-l, k-1); } Программа 10.3. Формирование комитетов Пользователь вводит число кандидатов п и число человек в комитете к. На выходе выдается число С(п,к) возможных вариантов комитетов. ♦include <iostream.h> #include "comm.h" void main (void) < int n, k; cout « "Введите число кандидатов и число членов комитета: "; cin » n » к; cout « "Число возможных комитетов: " « comm(n,k) « endl; } /* <Прогон Н»1 программы 10.3> Введите число кандидатов и число членов комитета: 10 4 Число возможных комитетов: 210 <Прогон №2 программы 10.3> Введите число кандидатов и число членов комитета: 9 0 Число возможных комитетов: 1 */
Комбинаторика: перестановки Многие интересные рекурсивные алгоритмы предполагают наличие массивов. В этом разделе мы рассмотрим задачу генерации всех перестановок из N элементов. В алгоритме предусматривается передача массива по значению, а поскольку в C++ все массивы передаются по адресу, должно быть выполнено копирование массива. Перестановка (permutation) из N элементов (1, 2, ..., N) есть упорядоченное расположение этих элементов. Для N = 3 (1 3 2), (3 2 1) (12 3) — различные перестановки. В комбинаторике установлено, что число перестановок равно N!. Это интуитивно понятно, если взглянуть на отдельные позиции в перестановке. Для позиции 1 существуют N вариантов, так как все N элементов доступны. Для позиции 2 имеются N-1 вариантов, так как один элемент используется для позиции 1. Число вариантов уменьшается на единицу по мере продвижения по позициям списка. Число вариантов Поз 1 Поз 2 Поз 3 Поз п - 1 Поз п Общее число перестановок есть произведение числа вариантов в каждой позиции. Permulation(N) = N * (N-1) * (N-2) * ... * 2 * 1 = N! Более интересный рекурсивный алгоритм генерирует перечень всех перестановок из N элементов для N >= 1. Для демонстрационных целей сформируем вручную 24 (4!) перестановки из четырех элементов. Перестановки с одинаковыми первыми элементами записываются в отдельный столбец. Каждый столбец в дальнейшем делится на три пары с одинаковыми вторыми элементами. 1 1234 1243 1324 1342 1423 1432 2 2134 2143 2314 2341 2413 2431 3 3124 3142 3214 3241 3412 3421 4 4123 4132 4213 4231 4312 4321 Алгоритм вычисления числа перестановок иллюстрируется иерархическим деревом, которое содержит упорядоченные пути, соответствующие перестановкам. В исходном положении имеется четыре варианта — 1, 2, 3 и 4 — соответствующие четырем столбцам. По мере продвижения вниз по дереву низ лежащие уровни разделяются на 3, 2 и 1 элемент, соответственно. Общее число путей (перестановок) равно 4*3*2*1 = 4! Алгоритм генерации всех перестановок моделирует проход по путям дерева. Продвигаясь от уровня к уровню, мы тем самым указываем очередную позицию в перестановке. Этот процесс представляет собой шаг рекурсии. Индекс 0:
Итерационный процесс проверяет все возможные первые элементы для индекса 0. В нашем примере существует N=4 возможных первых элемента. Индекс 1: 1 2 3 4 На следующем уровне дерева каждый узел дает начало N-1 узлам, содержащим N-1 отличных от первого элементов. Например, узел 1 соответствует перестановкам, которые начинаются с единицы в первой (индекс 0) позиции. Путь, ведущий к узлу 2 следующего уровня, соответствует перестановкам с 1 и 2 в первых двух позициях и т.д. Можно итерационно определить второй элемент перестановки перебором узлов 2, 3 и 4. Индекс 2: 1 2 1 3 1 4 На следующем уровне дерева каждый узел разветвляется на два пути, которые представляют перестановки из двух элементов. Например, в узле 2 элементами являются [3,4], и их упорядоченными расположениями являются Результирующие перестановки из четырех элементов: Индекс 3: Перестановки на [3,4] Поскольку третий член перестановки зафиксирован, последний элемент определяется однозначно, т.к. перестановка не допускает повторяющиеся значения. Это становится условием останова. Каждый узел завершает отдельную перестановку.
Разработка алгоритма. Программа на C++, реализующая этот алгоритм, запоминает каждую перестановку в виде массива из N элементов. Перед каждым вызовом рекурсивная функция создает копию этого массива, с тем чтобы по возвращении из шага рекурсии значения оставались в тех же самых позициях массива. Помните, что для N = 4 программа должна в конечном итоге создать 24 различных массива. Вначале мы создаем четыре массива, содержащих 1, 2, 3 и 4 в своих первых позициях. На следующем уровне каждый из четырех существующих массивов создает три массива, которые запоминают первый элемент из базового массива и т.д. void copy(int х[], int y[], int n) { for (int i=0; i<n; i++) x[i] = y[i]; } Рекурсивный алгоритм постепенно помещает элементы в массив permlist, по индексам 0, 1, 2, ..., N-1. 1. Условие останова возникает на индексе N-1. В этот момент N-элемент- ная перестановка завершается и массив распечатывается. 2. Шаг рекурсии: На шаге рекурсии происходит продвижение вперед по индексам массива от 0 до N-2. Для индекса k (0<=k<N-l) первые к элементов массива permlist уже сформированы. Мы итерационно просматриваем другие элементы и помещаем их в permlist[k]. Это делается путем обменивания каждого элемента в оставшейся части списка с числом permlist[k]. Допустим, N = 4, к = 1 и permlist[k] = 1. Во время итерационных шагов числа 2, 3 и 4 помещаются по индексу 1. Сдвиг 2 с permlist[1] Сдвиг 3 с permlist[1] Сдвиг 4 с permlist[1] 1 I 1 1 2 2 I 3 3 3 I 2 4 I 4 I 4 I Новый список Новый список Новый список 1 2 3 4 I 1 3 2 4 I 1 4 2 3 После каждого чередования результирующий список копируется во временный список и передается в рекурсивную функцию permute вместе со следующим индексом и параметром N. На рис. 10.6 показан вызов permute с permlist[l] == 2. По индексу 1: поместить 2 12 3 4 Вызвать permute с индексом 2 По индексу 2 L^ 2 : поместить 3 3 4 I По индексу 3: сг 2 3 4 Вызвать permute с индексом 3 Условие останова Напечатать 12 3 4 По индексу 2 1 2 : поместить 4 4 3 | По индексу 3: [ 1 2 4 3 | Вызвать permute с индексом 3 Условие останова Напечатать 12 3 4 Рис. 10.6. Перестановка 1 2 х X
По индексу 1: поместить 3 1 | 3 2 4 | Вызвать permute с индексом 3 По индексу 2 1 3 : поместить 3 2 4 По индексу 3: i 1 3 2 4 Вызвать permute с индексом 3 Условие останова Напечатать 12 3 4 По индексу 2 1 3 : поместить 3 4 2 J По индексу 3: 1 3 4 2 Вызвать permute с индексом 3 Условие останова Напечатать 12 3 4 Рис. 10.7. Перестановки 1 3 х х Перестановки создаются в порядке (1234) и (1243). Рис, 10.7 иллюстрирует вызов permute, когда в permlist[l] помещается 3. Затем на шаге итерации в permlist[l] помещается 4, и рекурсивный процесс продолжается с массивом 1423. Рекурсивная функция Permute // UpperLimit - максимальное число элементов перестановки const int UpperLimit = 5; // копирование п элементов массива у в массив х void copy(int х[], int y[], int n) { for (int i*=0; i<n; i++) x[i] « y[i]; } // permlist есть п-элементный массив целых чисел. // генерировать перестановки элементов, индексы которых находятся в диапазоне // start <= i <= п-1. по заполнении перестановки распечатывать весь массив. .// чтобы переставить все п элементов, начинать с start = О void permute(int permlist[], int start, int n) { int tmparr[UpperLimit]; int temp, i; // условие останова: достигнут последний элемент if (start « n-1) { // распечатать перестановку for (i=0; i<n; i++) cout « permlist[i] « " "; cout « endl; } else // шаг рекурсии: поменять местами permlist[start] и permlist[i]; скопировать // массив в tmparr и переставить элементы tmparr от start+1 до1конца массива, for (i=start; i<n; i++) { // поменять permlist[i] с permlist[start] temp = permlist[i]; permlist[i] = permlist[start]; permlist[start] = temp; // создать новый список и вызвать permute copy (tmparr, permlist, n); permute (tmparr, start+1, n); } }
Программа 10.4. Перестановки Эта задача о перестановках запускается с N = 3. Функции копирования и перестановки хранятся в файле permute.h. #include <iostream.h> #include "permute.h" void main(void) { // permlist содержит п чисел, подлежащих перестановке int permlist[UpperLimit]/ int n, i; cout « "Введите число в диапазоне от 1 до " « UpperLimit « ": "; cin » n; // инициализировать permlist множеством {1,2, 3, . . . ,n} for (i=0/ i<n; i++) permlist[i] = i+1; cout « endl; // распечатать перестановки элементов массива permlist по индексам от 0 до п-1 permute(permlist, 0, п); } /* <Выполнение программы 10.4> Введите число в диапазоне от 1 до 5: 3 12 3 13 2 2 13 2 3 1 3 12 3 2 1 V Ханойская башня. Задача о ханойской башне, рассмотренная в разделе 10.1, является примером рекурсивного алгоритма, который просто решал проблему, не углубляясь в детали. В этом разделе для перекладывания дисков разрабатываются шаги рекурсии и условия останова. Формулировка задачи. На платформе расположены три стержня: начальный (start), средний (middle) и конечный (end). На начальный стержень нанизано N дисков в порядке возрастания размера, т.е. самый большой диск лежит внизу. Цель головоломки — переместить все N дисков с начального стержня на конечный. Каждый раз можно перемещать лишь один диск, и никогда диск большего размера не должен лежать на диске меньшего размера. На рис. 10.8 показаны перемещения дисков для N = 3. Стержни помечены буквами S (начальный), Е (конечный) и М (средний). Мы используем этот относительно простой случай, чтобы определить рекурсивный процесс. Рекурсивный алгоритм дается в терминах N стержней. Алгоритм Hanoi. Пример с тремя дисками может быть обобщен до трех- шагового рекурсивного алгоритма (рис. 10.9). В функции Hanoi стержни
Исходная башня из трех дисков на стержне S Стержень S Стержень М Стержень Е Шаг 1: Переместить маленький диск на стержне Е (S->E) Стержень S Стержень М Стержень Е Шаг 2: Переместить средний диск на стержне М (S->M) Стержень S Стержень М Стержень Е Шаг 3: Переместить маленький диск на стержне М (Е->М) Стержень S Стержень М Стержень Е Шаг 4: Переместить большой диск на стержне Е (S->E) Стержень S Стержень М Стержень Е Шаг 5: Переместить маленький диск на стержне S (M->S) Стержень S Стержень М Стержень Е Рис. 10.8. Ханойская башня (N = 3)
Шаг б: Переместить средний диск на стержне Е (М->Е) Стержень S Стержень М Стержень Е Шаг 4: Переместить маленький диск на стержне Е (S->E). Башня из трех дисков построена Стержень S Стержень М Стержень Е Рис. 10.8. Продолжение объявляются как объекты типа String. В списке параметров порядок переменных следующий: startpeg — middlepeg — endpeg Предполагается, что мы перемещаем диски с начального стержня на конечный, используя средний для временного хранения дисков. Если N = 1, мы имеем специальное условие останова, которое может быть обработано путем перемещения единственного диска с начального стержня на конечный. cout « "переместить " « startpeg « " на " « endpeg « endl; В ином случае мы имеем трехшаговый процесс перемещения N дисков с начального стержня на конечный. На первом шаге алгоритма перемещается N-1 дисков с начального стержня на средний с использованием конечного стержня в качестве промежуточного. Отсюда порядок параметров в рекурсивном вызове функций таков: startpeg, endpeg и middlepeg: // использовать конечный стержень для временного хранения Hanoi (n-1, startpeg, endpeg, middlepeg); На втором шаге самый большой диск просто перемещается с начального стержня на конечный: cout « "переместить " « startpeg « " на " « endpeg « endl; На третьем шаге N-1 дисков перемещаются со среднего стержня на конечный с использованием начального стержня для временного хранения. Отсюда порядок параметров в рекурсивном вызове функций таков: middlepeg, startpeg и endpeg: // использовать начальный стержень для временного хранения Hanoi (n-1, middlepeg, startpeg, endpeg);
Шаг 1: Переместить N-1 дисков с S на М Стержень S Стержень М Стержень Е До Стержень S Стержень М Стержень Е После Шаг 2: Переместить 1 диск с S на Е Стержень S Стержень М До Стержень S Стержень М После Стержень Е Стержень Е Шаг 3: Переместить N-1 диск с М на Е Стержень S Стержень М Стержень Е До Стержень S Стержень М Стержень Е После Рис. 10.9. Перемещение дисков ханойской башни
Программа 10.5. Ханойская башня Три стержня представляются строками "start", "middle" и "end", которые передаются в функцию в качестве параметров. Вначале программа запрашивает число дисков N. Рекурсивная функция Hanoi вызывается для распечатки перечня ходов при перемещении N дисков со стержня "start" на стержень "end". Алгоритм требует 2N - 1 хода. Для 10 дисков, задача решается за 1023 хода. Для нашего теста N = 3 число ходов равняется 23 — 1 — 7. #include <iostream.h> ♦include "strclass.h" // переложить п дисков с начального стержня на конечный, используя // средний как промежуточный void hanoi (int n, String startpeg, String middlepeg, String endpeg) { // условие останова: перемещение одного диска if (n — 1) cout « "переместить " « startpeg « " на " << endpeg « endl; // переместить n-1 дисков на средний стержень, // переместить нижний диск на конечный стержень, затем переместить // п-1 диск со среднего стержня на конечный else { hanoi(n-1, startpeg, endpeg, middlepeg); cout « "переместить " « startpeg « " на " « endpeg << endl; hanoi(n-1, middlepeg, startpeg, endpeg); } } void main() { // Число дисков и названия стержней int n; String startpeg ■ "start ", middlepeg = "middle", endpeg = "end "; // запросить п и решить задачу для п дисков cout « "Введите число дисков: "; cin » п; cout « "Решение для п ■ " « n « endl; hanoi(n, startpeg, middlepeg, endpeg); } /* <Выполнение программы 10.5> Введите число дисков: 3 Решение для п = 3 переместить start на end переместить start на middle переместить end на middle переместить start на end переместить middle на start переместить middle на end переместить start на end */
Прохождение лабиринта Многие рекурсивные алгоритмы используют принцип перебора с возвратами (backtracking). Этот принцип применяется, когда мы сталкиваемся с некоторой проблемой, требующей каких-то шагов и решений. Пытаясь достичь конечной цели, мы шаг за шагом принимаем ряд частичных решений, которые кажутся согласующимися с конечной целью. Если мы выполняем шаг или принимаем решение, которые не согласуются с конечной целью, мы возвращаемся на один или несколько шагов назад к последнему согласующемуся частичному решению. Как по старой поговорке: шаг вперед — два шага назад. Иногда возврат может повлечь за собой один шаг вперед и п шагов назад, где п достаточно велико. В этом разделе мы рассмотрим возвраты в уже знакомом контексте лабиринтов. В нашем анализе подразумевается, что лабиринт не содержит циклов, которые позволили бы нам ходить по кругу. Это ограничение не является обязательным. Возвраты применимы и к лабиринтам с циклами, поскольку мы сохраняем карту, показывающую, какие узлы уже были пройдены. На рис. 10.10 изображен лабиринт с циклом, включающим перекрестки 2, 3, 4 и 5. Рис. 10.10. Лабиринт с циклом Анализ алгоритма. Лабиринт есть множество перекрестков. Двигаясь в некотором направлении, путешественник попадает на перекресток и движется далее по одному из трех путей: налево, прямо или направо. Путь идентифицируется номером следующего перекрестка. Если некоторое направление заблокировано, оно обозначается нулем. Перекресток без выходов является тупиком, или мертвой точкой. Полный авантюризма и готовности к возвратам путешественник входит в лабиринт через начальную точку и смело начинает поиски цели — конечного перекрестка и, следовательно, свободы. Каждый перекресток на его пути представляет собой частичное решение. К сожалению, дух авантюризма может завести в тупик, и тогда потребуется возврат к предыдущему перекрестку. Чтобы как-то организовать выбор вариантов на каждом перекрестке, будем придерживаться рекурсивной стратегии. Сначала попробуем свернуть налево (если это направление не заблокировано) и проложить путь к конечной точке.
Перекресток 1 2 3 4 (тупик) 3 (тупик) 2 5 (тупик) 2 6 Действие Идти прямо Идти налево Идти направо Возврат Возврат Идти прямо Возврат Идти направо Идти налево Результат Перекресток 2 Перекресток 3 Перекресток 4 Перекресток 3 Перекресток 2 Перекресток 5 Перекресток 2 Перекресток 6 Перекресток 7 (конец) Рис. 10.11. Мини-лабиринт Этот вариант становится несовместимым с нашей целью лишь в том случае, если заводит в тупик. Оттуда мы возвращаемся к перекрестку и пытаемся пойти прямо до самого конца. Очередной тупик делает это решение несовместимым, и мы выбираем движение направо. Если и этот выбор заканчивается неудачей, то мы в тупике и должны возвратиться к предыдущему "совместимому" перекрестку. Эту стратегию относительно просто описать и запрограммировать в виде рекурсивной функции. Задача создания итерационной версии этой функции будет ясна из частичного прохода по лабиринту с семью перекрестками, показанному на рис. 10.11. Здесь путь, ведущий к выходу из лабиринта, проходит через перекрестки 1 — 2 — 6 — 7. Стратегия обхода гарантирует, что если путешественник выходит из какого-то перекрестка, то возврата назад не будет до тех пор, пока не будут проверены все возможные альтернативы, возникающие далее вдоль пути, и не встретится тупик. Кроме того, перекресток официально не включается в итоговый маршрут, пока не будет гарантии, что из него существует путь к выходу из лабиринта. Как только путешественник достигает конечной точки, мы можем проследить историю его прогулки и идентифицировать все перекрестки на маршруте. Класс Maze. Класс Maze — это структура, состоящая из данных (перекрестков) и методов, позволяющих формировать лабиринт и совершать прохождение его перекрестков, используя нашу стратегию. Допустим, что каждый перекресток представляется записью с полями, показывающими результаты движения из этой точки налево, прямо или направо. Целое число в поле определяет следующий перекресток при движении в данном направлении. Нуль обозначает, что направление заблокировано. Эта запись реализуется структурой Intersection.
// запись, описывающая соседние перекрестки, на которые вы попадете, // если отправитесь из данной точки налево, прямо или направо struct Intersection { int left; int forward; int right; Класс Maze включает целые значения, показывающие размер лабиринта, конечную точку и список перекрестков, который размещается в динамическом массиве. Доступ ко всем данным обеспечивается конструктором, выполняющим построение лабиринта, и методом, выполняющим прохождение лабиринта. Данные читаются из файла, который содержит количество перекрестков, по три числа на каждый перекресток и номер точки выхода из лабиринта. Точка выхода не считается перекрестком. Например, данные для мини-лабиринта на рис. 10.11 таковы: 6 // число перекрестков 0 2 0 // 1: вперед до перекрестка 2 3 5 6 //2: налево к 3; прямо к 5; направо к 6 0 0 4 // 3: направо к 4 0 0 0 //4: тупик 0 0 0 //5: тупик 7 0 0 //6: налево к точке выхода 7 // номер точки выхода Спецификация класса maze ОБЪЯВЛЕНИЕ #include <iostream.h> #include <fstream.h> #include <stdlib.h> class Maze { private: // число перекрестков лабиринта и номер точки выхода int mazesize; int EXIT; // массив перекрестков лабиринта Intersection *intsec public: // конструктор; чтение данный из файла <filename> Maze(char *filename); // прохождение лабиринта int TraverseMaze(int intsecvalue); >; ОПИСАНИЕ В конструктор передается имя файла, содержащего данные о лабиринте. В этом процессе мы указываем число перекрестков и можем распределить память под динамический массив intsec. TraverseMaze — рекурсивная функция, которая находит выход из лабиринта. Параметр intsecvalue вначале равен 1, показывая тем самым, что путешественник входит в лабиринт в точке 1. Во время рекурсивного процесса эта переменная хранит номер текущего перекрестка. Объявление и реализация класса Maze находятся в файле maze.h.
Реализация класса Maze Конструктор отвечает за формирование лабиринта. Так, например, он открывает входной файл, считывает размер лабиринта, инициализирует массив перекрестков и назначает точку выхода. // построить лабиринт, введя перекрестки и номер точки выхода из файла filename Maze::Maze{char *filename) { ifstream fin; int i; // открыть filename, завершить выполнение, если файл не найден fin.open(filename, ios::in | ios:rnocreate); if (!fin) { cerr « "Невозможно открыть файл описания лабиринта " « filename « endl; exit(l); } // первое число в файле — количество перекрестков fin > mazesize/ // выделить память для массива перекрестков, мы не используем // нулевой индекс и поэтому должны распределить mazesize+1 запись. intsec ■ new Intersection[mazesize+l]; // ввести перекрестки из файла for (i = 1; i <= mazesize; i++) fin » intsec[i].left » intsec[i].forward » intsec[i].right // ввести номер точки выхода и закрыть файл fin » EXIT; fin.close(); } Рекурсивная стратегия управляется методом TraverseMaze, который принимает в качестве параметра число перекрестков (intsecValue). Эта функция вызывается из предыдущего перекрестка и возвращает 1 (TRUE), если из текущего перекрестка существует путь к точке выхода. Если intsec Value = 0, мы наткнулись на стену и немедленно возвращаемся с нулем (FALSE). Сердцевиной метода является дерево решений, позволяющее путешественнику выбрать с помощью некоторых условий то направление, которое завершится точкой выхода. Случай 1. Если intsec Value == EXIT, то цель успешно достигнута. Мы печатаем номер перекрестка и возвращаем TRUE предыдущему перекрестку на этом пути, который ждет результата проверки на успешность. Случай 2. Если это не точка выхода, мы поворачиваем налево и ждем сообщения TRUE или FALSE, указывающего на результат тестирования левого направления. Получив TRUE, мы печатаем номер текущего перекрестка и возвращаем TRUE предыдущему перекрестку. Случай 3. Этот случай похож на случай 2 за исключением того, что попытка повернуть налево не увенчалась успехом. Теперь мы отправляемся прямо и ждем сообщения TRUE или FALSE в качестве резуль-
тата тестирования прямого направления. Если возвращается TRUE, мы печатаем номер текущего перекрестка и возвращаем TRUE предыдущему перекрестку. Случай 4. Этот случай идентичен случаям 2 и 3, но обрабатывает правый поворот. Если ни один из перечисленных случаев не возвратил сообщение TRUE, значит текущий перекресток является тупиком. Чтобы зафиксировать этот факт, возвращается сообщение FALSE. Способность передавать информацию назад в предыдущий перекресток (в предыдущий экземпляр TraverseMaze) обусловлена рекурсивной структурой кода. В конце концов TraverseMaze(l) возвращает TRUE или FALSE в главную программу, показывая тем самым, можно ли пройти данный лабиринт. __!__■ | ■■ Mill .. _ . ■!._■■ .. _. . ■- ■ — —* I I I — // обход с возвратами произвольного лабиринта int Maze::TraverseMaze(int intsecvalue) { // если intsecvalue = 0, то мы в тупике. // в противном случае мы пытаемся найти допустимое направление if (intsecvalue > 0) { // условие останова: обнаружена точка выхода if (intsecvalue == EXIT) { // печатать номер перекрестка и возвратить TRUE cout « intsecvalue << " "; return 1; } // попытка повернуть налево else if (TraverseMaze(intsec[intsecvalue].left)) { // печатать номер перекрестка и возвратить TRUE cout « intsecvalue « " "; return 1; } // поворот налево завел в тупик. Попробуем пойти вперед else if (TraverseMaze(intsec[intsecvalue].forward)) { // печатать номер перекрестка и возвратить TRUE cout « intsecvalue « " "; return 1; } // направления налево и прямо ведут в тупик, попробуем свернуть направо else if (TraverseMaze(intsec[intsecvalue].right)) { // печатать номер перекрестка и возвратить TRUE cout « intsecvalue « " "; return 1; } } // это тупик, возвратить FALSE return 0; }
Программа 10.6. Прохождение лабиринта Проверим программу на мини-лабиринте (рис. 10.11) (входной файл mazel.dat), а затем на лабиринте, изображенном ниже (входной файл maze2.dat). Из этого лабиринта выхода нет, и задача не имеет решения. На последнем прогоне программы совершается обход большого лабиринта, показанного на рис. 10.1 (входной файл bigmaze.dat). В каждом случае маршрут распечатывается в обратном порядке. #include <iostream.h> #include "maze.h" void main (void) { // файл с параметрами лабиринта char filename [32]; cout « "Введите имя файла данных: "; cin » filename; // построить лабиринт Maze M(filename); // решить задачу о лабиринте и напечатать результат if (M.TraverseMaze(l)) cout « endl << "Вы свободны!" << endl; else cout « "Из этого лабиринта нет выхода" << endl; } /* <Прогон №1 программы 10.6> Введите имя файла данных: mazel.dat 7 6 2 1 Вы свободны! <Прогон №2 программы 10.б> Введите имя файла данных: maze2.dat Из этого лабиринта нет выхода
<Прогон №3 программы 10.б> Введите имя файла данных: bigmaze.dat 19 17 16 14 10 9 8 2 1. Вы свободны! */ 10.5. Оценка рекурсии Часто рекурсия не является эффективным методом решения проблемы. Рассмотрим задачу вычисления факториала. Итерационный алгоритм использует цикл, а не повторные вызовы функции. Рекурсия может сыграть злую шутку. Она часто упрощает разработку алгоритма и кода для того лишь, чтобы потерпеть фиаско на этапе выполнения по причине недостаточной эффективности. Этот конфликт иллюстрируется с помощью чисел Фибоначчи: 1, 1, 2, 3, 5, 8, 13, 21, 34, ... Члены этой последовательности F(n) определяются рекурсивно для п >= 1. Первые два члена равны 1 по определению. Начиная с третьего, каждый член последовательности равен сумме двух предыдущих. г 1, если п = 1 или 2 F(n) = « .F(n-l) + F(n-2) если n > 2 Это определение сразу переводится в рекурсивную функцию. Предположим, что F(n) есть n-ый член последовательности Фибоначчи, где п>=1. Тогда Условие останова: F(l) - 1 F(2) = 1 Шаг рекурсии: Для N £. 3, F(n) - F(n-l) + F(n-2); Функция Fib на языке C++ реализует рекурсивную функцию F. Она имеет единственный целочисленный параметр п и возвращает длинный целый результат. // рекурсивная генерация n-го числа Фибоначчи long Fib(int n) { // условие останова: fl = f2 = 1 if (n -» 1 | | n « 2) return 1; // шаг рекурсии: Fib(n) « Fib(n-l) + Fib(n-2) else return Fib(n-l) + Fib(n-2); } Сразу заметно, что функция Fib много раз вызывает сама себя с одним и тем же параметром. Пусть, например, N = 6. Выражение Fib(N-l) + Fib(N-2) формирует иерархическое дерево вызовов функции Fib для N = 5, 4, 3, 2 и 1 (рис. 10.12). Обратите внимание, что Fib(3) вычисляется три раза, a Fib(2) — пять раз. Пятнадцать узлов дерева представляют количество рекурсивных вызовов, требующихся для вычисления Fib(6) — 8.
Рис 10.12. Дерево рекурсивных вызовов при вычислении Fib(6) Пусть NumCall(k) — число рекурсивных вызовов, необходимых для вычисления Fib(k). NumCall(k) - 2 * Fib(k) - 1 Например, NumCall(6) - 2 * Fib (6) -1«2*8-1 = 15 NumCall(35) - 2 * Fib(35) -1 = 2* 9277465 - 1 - 18,554,929 Вычислительная эффективность алгоритма — 0(2n), т.е. время счета растет по экспоненте. Числа Фибоначчи: итерационная форма. При итерационном вычислении n-го числа Фибоначчи используется простой цикл. Эта функция имеет вычислительную эффективность О(п). // вычислить n-е число Фибоначчи с помощью итераций long Fiblter(int n) { long twoback - 1, oneback = 1, current; int i; // Fiblter(l) = Fiblter(2) = 1 if (n « 1 || n « 2) return 1; // current - twoback + oneback, n >= 3 else for (i~3; i<=n; i++) { current - twoback + oneback; twoback * oneback; oneback - current; } return current; } Для k-го числа Фибоначчи (k >= 3) итерационная форма требует k-2 сложений и один вызов функции. Для к = 35 эта форма требует 33 сложения, в то время как рекурсивная функция — более 18,5 миллиона вызовов!
Вычисление чисел Фибоначчи по формуле. Эффективнее всего вычислять числа Фибоначчи непосредственно по формуле, выводимой из рекуррентных соотношений. Вывод этой формулы выходит за рамки данной книги. F(n) = VT ~2 ГТ" 1Л J V ) J Поскольку функции квадратного корня и возведения в степень имеются в библиотеке <math.h> языка C++, n-ое число Фибоначчи может быть вычислено непосредственно с эффективностью 0(1) при помощи кода: #include <math.h> const double sqrtS = sqrt(5.0); // вычислить n-oe число Фибоначчи по алгебраической формуле double FibFormula(int n) { double pi, p2; // библиотечная функция языка C++ // double pow(double x, double y); // вычисляет x в степени у pi *= pow{l+sqrt5)/2.0, n); p2 = pow(l-sqrt5)/2.0, n); return (pi - p2)/sqrt5; } Программа 10.7. Оценка рекурсии (на примере чисел Фибоначчи) Эта программа хронометрирует вычисление 35-го числа Фибоначчи с помощью формулы, итерационной функции и рекурсивной функции из файла fib.h. Нерекурсивные программы выполняются доли секунды, в то время как рекурсивная функция требует более 82 секунд. tinclude <iostream.h> #include "fib.h" void main (void) { int i; // распечатать результат FibFormula в виде десятичного числа // с фиксированной точкой без дробной части cout.setf(ios::fixed); cout.precision(0); // вычислить 35-е число Фибоначчи тремя способами cout « FibFormula (35). « endl; cout « Fiblter(35) « endl; cout « Fib(35) « endl; } /* <Выполнение программы 10.7> 92274 65 <формула заняла менее 1 секунды> 92274 65 <итерационная функция заняла менее 1 секунды> 92274 65 <рекурсивная функция заняла более 82 секунд> */
Издержки рекурсии. Пример с числами Фибоначчи должен подготовить вас к потенциальным проблемам при использовании рекурсии. Избыточность вызовов в простой рекурсивной функции может серьезно ухудшить производительность программы. Еще более серьезно то, что рекурсивный вызов может породить наслоение целой последовательности рекурсивных вызовов, которые выходят из-под контроля программиста и делают запросы, превышающие размер стека. Числа Фибоначчи являются экстремальным случаем. Здесь легко можно реализовать итерационную версию. Со сделанными оговорками рекурсия остается важным инструментом программирования. Многие алгоритмы проще сформулировать и запрограммировать с помощью рекурсии. Они естественным образом адаптируются к рекурсивной реализации, где выделяются условия останова и шаг рекурсии. Например, использование возвратов в задаче о лабиринте облегчается посредством рекурсии. Хотя рекурсия не является объектно-ориентированным понятием, для нее характерны некоторые черты объектно-ориентированного проектирования. Она позволяет программисту управлять ключевыми логическими компонентами алгоритма и скрывать некоторые сложные детали реализации. Не существует жесткого правила, когда можно использовать рекурсию, а когда нельзя. Вы должны взвесить эффективности разработки и выполнения. Используйте рекурсию, когда разработка алгоритма усложняется, а реализация позволяет достичь приемлемого быстродействия и затрат памяти. Задняя рекурсия. Если последним действием функции является рекурсивный вызов, мы говорим, что эта функция использует заднюю рекурсию (tail recursion). Этот рекурсивный вызов требует затрат на создание активизирующей записи и ее запоминание в стеке. Когда рекурсивный процесс доходит до условия останова, мы должны выполнить серию возвратов, которые выталкивают активизирующие записи из стека. Мы просто помещаем записи в стек и вынимаем их оттуда, не используя для существенных вычислений. Исключение задней рекурсии может значительно повлиять на эффективность рекурсивной функции. Эту проблему иллюстрирует простой пример, где предлагается типичное решение. Рассмотрим рекурсивную функцию recfunc, которая распечатывает элементы массива от индекса п до индекса 0. Этот пример не имеет практического значения и выбран для простоты иллюстрации. void recfunc(int А[], int n) { if (n >= 0) // идти вперед, если индекс п не вышел за предел { cout « A[n] « " "; n—; // декремент индекса п recfunc(А, п); } } Пусть массив А[] = {10, 20, 30}. Тогда вызов recfunc(A,2) начинается с п = 2 и создает вывод 10 20 10. Функция recfunc иллюстрирует типичную ситуацию задней рекурсии. Эту проблему можно показать на логической схеме, где п > 0 интерпретируется как условие, требующее дальнейшего рекурсивного вызова.
—* if <рекурсивное условие> < n >■ О Выполнить задачу < вывод А[п] Обновить условие < декремент п ' Вызвать recfunc Эта логическая схема эквивалентна циклу WHILE, проверяющему простое условие п > 0. В функции recfunc управление передается оператору проверки условия с помощью менее эффективной рекурсивной операции. Г while <условие> < п >« 0 Выполнить задачу < вывод А[п] Обновить условие < декремент п В нашем примере рекурсивная функция recfunc может быть заменена функцией iterfunc, которая использует эквивалент оператора WHILE. Проблема исключения задней рекурсии может оказаться немного запутанной. Надежнее будет построить логическую схему для рекурсивной функции и затем создать такую же итерационную схему с использованием WHILE. // итерационная функция, исключающая заднюю рекурсию void iterfunc(int А[), int n) { while (n >- 0) { cout « "While-значение " « A[n) «endl; n—; } } Письменные упражнения 10.1 Объясните, почему выполнение следующей функции может дать неверный результат. long factorial(long n) { if (n ■■ 0 | | n ■■ 1) return 1; else return n * factorial(—n); } Результат зависит от порядка оценивания операндов компилятором. Если п = 3 и левый операнд вычисляется первым, результатом будет 3 * 2! = 6. Если первым вычисляется правый операнд, результатом будет 2 * 2! = 4. 10.2 Какая числовая последовательность порождается следующей рекурсивной функцией? long f(int n) { if (n =« 0 || n « 1) return 1; else return 3*f(n-2) + 2*f(n-l); }
10.3 Какая числовая последовательность порождается следующей рекурсивной функцией? int f(int n) { if (n « 0) return 1; else if (n == 1) return 2; else return 2*f(n-2) + f(n-l); } 10.4 Каким будет результат выполнения следующей программы, если входными данными являются 5 3? #include <iostream.h> long f(int b, int n) { if (n « 0) return 1; else return b*f(b, n-1); } void main(void) { int b, e; cin » b » e; cout « f (b, e) « endl; } 10.5 Каким будет результат выполнения следующей программы, если входными данными является строка "Это перекресток"? #include <iostream> void Q(void) { char с; cin.get(с); if (c !« '\n') QO; cout « c; } void main(void) { cout « "Введите текстовую строку: " « endl; QO; cout « endl/ } 10.6 Требуется рекурсивно вычислить максимальный элемент п-злементного массива. Определите функцию int max (int a[], int у); которая возвращает максимальное из двух целых х и у. Определите функцию
int arraymax(int a[], int n); которая использует рекурсию, чтобы возвратить максимальный элемент массива а. Условие останова: п == 1 Шаг рекурсии: arraymax = max(max(a[0], ... a[n-2]), a[n-l]) 10.7 Напишите рекурсивную функцию float avg{float a[], int n); которая возвращает среднее из п элементов массива чисел с плавающей точкой. Условие останова: п == 1 Шаг рекурсии: avg — ((п-1)/п)*(среднее из п-1 элементов) + (п-ый элемент)/п 10.8 Напишите рекурсивную функцию int rstrlen(char s[]); которая вычисляет длину строки. Условие останова: s[0] == 0 Шаг рекурсии: 14-length(noflCTpoKa, начинающаяся со 2-го символа) 10.9 Напишите рекурсивную функцию, которая проверяет, является ли строка палиндромом. Палиндром — это строка, не содержащая пробелов, которая одинаково читается справа налево и слева направо. Например, dad level did madamimadam1 кабак чинзванмечемнавзнич Используйте следующее объявление int pal(char A[], int s, int e); где pal определяет, является ли палиндромом цепочка символов в А, начинающаяся с индекса s и заканчивающаяся индексом е. Условия останова: s >= е (успех); A[s] != A[e] (неудача) Шаг рекурсии: Является ли строка A[s4-1] ... А[е-1] палиндромом? 10,10 Коэффициенты C(n,k), использующиеся при разложении формулы (х+1)п, называются биномиальными. (х+1)п = СП/П хп + СП/П_! х""1 + Сп,п_2 хп"2 + ... + Сп,2 х2 + СП/1 х1 + СП/0 х° Для любого п С(п,п) = С(п,0) = 1. Рекуррентные соотношения для биномиальных коэффициентов таковы: С(п,0) = 1 С(п,п) - 1 C(n,k) - C(n-l,k-l) + C(n-l,k) Заметьте, что каждый коэффициент C(n,k), 0<k<n, является решением задачи о комитетах, рассмотренной нами выше. Биномиальные коэффициенты C(n,k) определяют, сколькими способами можно выбрать к элементов из п элементов. 1 Madam, Гт Adam. — Прим. перев.
Эти коэффициенты образуют знаменитый треугольник Паскаля. В этом треугольнике столбец 0 содержит все единицы, как и диагональ. Каждый из остальных элементов является суммой двух элементов, расположенных на строку выше в том же столбце и столбце слева. 1 1 1 12 1 13 3 1 14 6 4 1 Напишите функцию, которая строит треугольник Паскаля для заданного п. Выше приведен пример для п = 4. 10.11 Напишите рекурсивную функцию int qcd(int a, int b); для вычисления наибольшего общего делителя положительных целых а и Ь. См. процедуры для итерационной версии этой функции в гл. 6. 10.12 Следующие данные представляют собой входной файл описания лабиринта. Нарисуйте лабиринт и найдите решение, прослеживая рекурсивный алгоритм по шагам. 11 // число перекрестков 0 2 0// перекресток 1: (налево, прямо, направо) 4 3 6 0 0 0 0 0 5 0 0 0 7 0 0 8 11 9 0 0 0 0 0 10 0 0 0 12 0 0 12 // точка выхода Упражнения по программированию 10.1 Используйте функцию arraymax из письменного упражнения 10.6 для выполнения следующих действий: 1. Сгенерируйте 10 случайных целых чисел в диапазоне 1—20000 и сохраните их в массиве. 2. Рапечатайте массив. 3. Примените функцию arraymax и распечатайте результат. Проверьте его правильность. 10.2 Сумма первых п целых чисел находится по формуле 1 + 2 + 3 + ... + n = n(n+l)/2 Занесите в массив А первые 50 чисел. Тогда средним для этих элементов будет значение 51/2 = 25,5. Проверьте свое решение, обработав массив А программой из письменного упражнения 10.7,
10.3 Испытайте свою функцию rstrlen из письменного упражнения 10.8, вводя пять строк с клавиатуры с помощью cin.getline и распечатывая длину строки как посредством rstrlen, так и с помощью библиотечной функции C++ strlen. 10.4 Читайте символьные строки до конца файла с помощью оператора », чтобы получить отдельные "слова". К каждому слову примените функцию pal из письменного упражнения 10.9, чтобы определить, является ли оно палиндромом. Если да, то запишите его в строковый массив. По окончании ввода файла распечатайте все найденные палиндромы по одному в строке. 10.5 Классической арифметической задачей является представление целого значения в различных системах счисления: N = 45 Основание = 2 Выход: 101101 [32 + 8 + 4 + 1] N = 90 Основание = 8 Выход: 132 [1(64) + 3(8) + 2(1)] N = 75 Основание = 5 Выход: 300 [3(52) + 0(5) + 0(1)] В обычном алгоритме преобразования в другую систему счисления используется многократное деление на ее основание. Если N = dn_x dn.2 dn_3 ... dx d0 то последовательность остатков дает цифры числа N в порядке d(0) ... d(n-l). Определите рекурсивную функцию void intout(long N, int В); для печати N в системе счисления по основанию В, подразумевая В <, 10. Испытайте эту функцию в главной процедуре, которая вводит пять пар чисел N,B и распечатывает каждое N в системе счисления по основанию В. 10.6 Введите положительное целое п < 10. Обратитесь к письменному упражнению 10.10 и распечатайте разложение полинома (х+1)п. 10.7 Разработайте рекурсивную функцию для подсчета количества п-разряд- ных двоичных чисел, не имеющих двух идущих подряд единиц. (Подсказка: Число начинается либо с нуля либо с единицы. Если оно начинается с нуля, количество вариантов определяется оставшимися п-1 цифрами. А если с единицы, то какой должна быть следующая цифра?) 10.8 Довольно часто возникает задача нахождения корня действительной функции. Если f(x) — функция, ее корень г есть действительное число такое, что f(r) = 0. В некоторых случаях корни могут быть вычислены по алгебраической формуле. Например, все корни квадратного уравнения f(x) = ах2 + Ьх + с находятся по формуле , , Vb2 - 4ас г = -Ь ± ~ 2а Для общего случая формулы нет, и корни должны находиться численными методами.
f(-1)>0 У = f (x) Корень г f(1)<0 f(-1)>0 У = f (x) Корень г 1(1) < О f(0)M(-1)>0,f(0)M(1)<0. Корень лежит в интервале О < г < 1.0 Если f(a) и f(b) имеют разные знаки (f(a) * f(b) < 0) и f "ведет себя хорошо", то между а и b существует корень г. Метод дихотомии определяется следующим образом. Пусть m = (a+b)/2.0 — средняя точка в интервале а < х < Ь. Если f(m) = 0.0, то корень г = т. Если нет, то либо f(a) и f(m) имеют разные знаки (f(a) * f(m) < 0), либо f(m) и f(b) имеют разные знаки (f(m) * f(b) < 0). Если f(m) * f(b) < 0, то корень г лежит в интервале m < х < Ь; в противном случае он лежит в интервале а < х < т. Теперь выполним этот действие для нового интервала — половины исходного интервала. Процесс продолжается до тех пор, пока интервал не станет достаточно маленьким или пока не будет найдено точное значение корня. Напишите рекурсивную функцию double Bisect(double f(double x), double a, double b, double precision);
вычисляющую и возвращающую баланс после выплачивания простого процента по заданной величине капитала с месячной ставкой nmonths. Используйте метод дихотомии для расчета платежей по ссуде $150000 под 10% годовых на 25 лет. 10.9 Запустите программу 10.6 с данными из письменного упражнения 10.12. Проверьте свое решение этого упражнения.
глава 11 Деревья 11.1. Структура бинарного дерева 11.2. Разработка функций класса TreeNode 11.3. Использование алгоритмов прохождения деревьев 11.4. Бинарные деревья поиска 11.5. Использование бинарных поисковых деревьев 11.6. Реализация класса BinSTree 11.7. Практическая задача: конкорданс Письменные упражнения Упражнения по программированию
Дед Рис. 11.1. Генеалогическое дерево Массивы и связанные списки определяют коллекции объектов, доступ к которым осуществляется последовательно. Такие структуры данных называют линейными (linear) списками, поскольку они имеют уникальные первый и последний элементы и у каждого внутреннего элемента есть только один наследник. Линейный список является общим описанием для таких структур, как массивы, стеки, очереди и связанные списки. Во многих приложениях обнаруживается нелинейный порядок объектов, где элементы могут иметь нескольких наследников. Например, в фамильном дереве родитель может иметь нескольких потомков (детей). На рис. 11.1 показаны три поколения семьи. Подобное упорядочение описывает и управляющий аппарат компании, во главе которой стоит президент, а далее идут начальники отделов и менеджеры (рис. 11.2). Такое упорядочение называют иерархическим, поскольку это название происходит от церковного распределения власти — от епископа к пасторам, дьяконам и т.д. В этой главе мы рассмотрим нелинейную структуру, называемую деревом (tree), которая состоит из узлов и ветвей и имеет направление от корня к внешним узлам, называемым листьями. В гл. 13 представлены графы, описывающие нелинейную структуру, в которой два или более узла могут переходить в один и тот же объект. Эти структуры подобны коммуникационной сети, показанной на рис. 11.3, требуют особых алгоритмов и применяются в специальных приложениях. отец дядя тетя брат ребенок сестра Менеджер по производству Президент Менеджер по сбыту Начальник отдела кадров Снабжение Склад Поставки Рис. 11.2. Иерархическая структура
Станция В Станция А Станция С Станция D Рис. 11.3. Ретрансляционные телефонные станции Терминология деревьев Древовидная структура характеризуется множеством узлов (nodes), происходящих от единственного начального узла, называемого корнем (root). На рис. 11.4 корнем является узел А. В терминах генеалогического дерева узел можно считать родителем (parent), указывающим на 0, 1 или более узлов, называемых сыновьями (children). Например, узел В является родителем сыновей Е и F. Родитель узла Н — узел D. Дерево может представлять несколько поколений семьи. Сыновья узла и сыновья их сыновей называются потомками (descendants), а родители и прародители — предками (ancestors) этого узла. Например, узлы Е, F, I, J — потомки узла В. Каждый некорневой узел имеет только одного родителя, и каждый родитель имеет 0 или более сыновей. Узел, не имеющий детей (Е, G, H, I, J), называется листом (leaf). Рис. 11.4. Дерево общего вида Каждый узел дерева является корнем поддерева (subtree), которое определяется данным узлом и всеми потомками этого узла. Ниже показаны три поддерева дерева на рис. 11.4. Узел F есть корень поддерева, содержащего узлы F, I и J. Узел G является корнем поддерева без потомков. Это определение позволяет говорить, что узел А есть корень поддерева, которое само оказывается деревом. Прохождение от родительского узла к его дочернему узлу и к другим потомкам осуществляется вдоль пути (path). Например, на рис. 11.5 путь
от корня А к узлу F проходит от А к С и от С к F. Тот факт, что каждый некорневой узел имеет единственного родителя, гарантирует, что существует единственный путь из любого узла к его потомкам. Путь от корня к узлу дает меру, называемую уровнем (level) узла. Уровень узла есть длина пути от корня к этому узлу. Уровень корня равен 0. Каждый сын корня является узлом 1-го уровня, следующее поколение — узлами 2-го уровня и т.д. Например, на рис. 11.5 узел F является узлом 2-го уровня с длиной пути 2. Уровень О Уровень 1 Уровень 2 Уровень 3 Рис. 11.5. Уровень узла и длина пути Глубина (depth) дерева есть максимальный уровень любого его узла. Понятие глубины также может быть описано в терминах пути. Глубина дерева есть длина самого длинного пути от корня до узла. На рис. 11.5 глубина дерева равна 3. Бинарные деревья Хотя деревья общего вида достаточно важны, мы сосредоточимся на ограниченном классе деревьев, где каждый родитель имеет не более двух сыновей (рис. 11.6). Такие бинарные деревья (binary trees) имеют унифицированную структуру, допускающую разнообразные алгоритмы прохождения и
(А) Глубина 3 (В) Глубина 4 Рис. 11.6. Бинарные деревья эффективный доступ к элементам. Изучение бинарных деревьев дает возможность решать наиболее общие задачи, связанные с деревьями, поскольку любое дерево общего вида можно представить эквивалентным ему бинарным деревом. Этот вопрос рассматривается в упражнениях. У каждого узла бинарного дерева может быть 0, 1 или 2 сына. По отношению к узлу слева будем употреблять термин левый сын (left child), а по отношению к узлу справа — правый сын (right child). Наименования "левый" и "правый" относятся к графическому представлению дерева. Бинарное дерево является рекурсивной структурой. Каждый узел — это корень своего собственного поддерева. У него есть сыновья, которые сами являются корнями деревьев, называемых левым и правым поддеревьями соответственно. Таким образом, процедуры обработки деревьев естественно рекурсивны. Вот рекурсивное определение бинарного дерева: Бинарное дерево — это такое множество узлов В, что а) В является деревом, если множество узлов пусто (пустое дерево — тоже дерево); б) В разбивается на три непересекающихся подмножества: {R} корневой узел {Li, L2, ..., Lm} левое поддерево R {Ri, R2, ..., Rm} правое поддерево R На любом уровне п бинарное дерево может содержать от 1 до 2П узлов. Число узлов, приходящееся на уровень, является показателем плотности дерева. Интуитивно плотность есть мера величины дерева (число узлов) по отношению к глубине дерева. На рис. 11.6 дерево А содержит 8 узлов при глубине 3, в то время как дерево В содержит 5 узлов при глубине 4. Последний случай является особой формой, называемой вырожденным (degenerate) деревом, у которого есть единственный лист (Е) и каждый нелистовой узел имеет только одного сына. Вырожденное дерево эквивалентно связанному списку. Деревья с большой плотностью очень важны в качестве структур данных, так как они содержат пропорционально больше элементов вблизи корня, т.е. с более короткими путями от корня. Плотное дерево позволяет хранить большие коллекции данных и осуществлять эффективный доступ к элементам.
Левое поддерево Правое поддерево Быстрый поиск — главное, что обусловливает использование деревьев для хранения данных. Вырожденные деревья являются крайней мерой плотности. Другая крайность — законченные бинарные деревья (complete binary tree) глубины N, где каждый уровень 0...N-1 имеет полный набор узлов и все листья уровня N расположены слева. Законченное бинарное дерево, содержащее 2N узлов на уровне N является полным (full). На рис. 11.7 показаны законченное и полное бинарные деревья. Законченное дерево (глубина 3) Полное дерево (глубина 2) Рис 11.7. Классификация бинарных деревьев Законченные и полные бинарные деревья дают интересные математические факты. На нулевом уровне имеется 2° узлов, на первом — 21, на втором — 22 и т.д. На первых к-1 уровнях имеется 2к1 узлов. 1 + 2 + 4 + ... + 2к1 = 2к1 На к-ом уровне количество дополнительных узлов колеблется от 1 до 2к (полное). В полном дереве число узлов равно 1 4- 2 + 4 + ... + 2" + 2к = 2к+1 — 1 Число узлов законченного бинарного дерева удовлетворяет неравенству 2к < N < 2к+1 — 1 < 2к+1 Решая его относительно к, имеем к < log2 (N) < к+1 Например, полное дерево глубины 3 имеет 24 — 1 = 15 узлов
Пример 111 1. Максимальная глубина дерева с 5-ю узлами равна 4 [рис. 11.6 (В)]. Минимальная глубина к дерева с 5-ю узлами равна к < log2 (5) < к+1 log2 (5) - 2,32 и к - 2 2. Глубина дерева есть длина самого длинного пути от корня к узлу. Для вырожденного дерева с N узлами наибольший путь имеет длину N-1. Для законченного дерева с N узлами глубина равна целой части от log2N. Этому же значению равен максимальный путь. Пусть дерево имеет N = 10000 элементов, тогда максимальный путь равен int(log2 10000) - int(13,28) - 13 11.1. Структура бинарного дерева Структура бинарного дерева построена из узлов. Как и в связанном списке, эти узлы содержат поля данных и указатели на другие узлы в коллекции. В этом разделе определяются узлы дерева и операции для его построения и прохождения. Подобно представлению класса Node в гл. 9, объявляется класс TreeNode, а затем разрабатывается ряд функций, использующих узлы дерева для построения бинарного дерева и прохождения индивидуальных узлов. Узел дерева содержит поле данных и два поля с указателями. Поля указателей называются левым указателем (left) и правым указателем (right), поскольку они указывают на левое и правое поддерево, соответственно. Значение NULL является признаком пустого дерева. TreeNode left данные right left данные right Корневой узел определяет входную точку дерева, а поле указателя — узел следующего уровня. Листовой узел содержит NULL в поле правого и левого указателей (рис. 11.8). Проектирование класса TreeNode В этом разделе разрабатывается класс TreeNode, в котором объявляются объекты-узлы бинарного дерева. Узел состоит из поля данных, которое является открытым (public) элементом, т.е. к которому пользователь может обращаться непосредственно. Это позволяет клиенту читать или обновлять данные во время прохождения дерева, а также допускает возвращение ссылки на данные. Последняя особенность используется более сложными структурами данных, такими как словари. Два поля с указателями являются закрытыми (private) элементами, доступ к которым осуществляется посредством функций Left() и Right(). Объявление и определение класса TreeNode содержатся в файле treenode.h.
Дерево left А right left В right left с right left D J right left E right left G right Структура TreeNode Рис. 11.8. Узлы бинарного дерева Спецификация класса TreeNode ОБЪЯВЛЕНИЕ // BinSTree зависит от TreeNode template <class T> class BinSTree; // объявление объекта для узла бинарного дерева template <class T> class TreeNode { private: // указатели левого и правого дочерних узлов TreeNode<T> *left; TreeNode<T> * right; public: // открытый элемент, допускающий обновление Т data; // конструктор TreeNode (const T& item, TreeNode<T> *lptr = NULL, TreeNode<T> *rptr = NULL); // методы доступа к полям указателей TreeNode<T>* Left(void) const; TreeNode<T>* Right(void) const; left u_ right left H I right j
// сделать класс BinSTree дружественным, поскольку необходим // доступ к полям left и right friend class BinSTree<T>; }; ОПИСАНИЕ Конструктор инициализирует поля данных и указателей. С помощью пустого указателя NULL узлы инициализируются как листья. Имея указатель Р объекта TreeNode в качестве параметра, конструктор присоединяет Р как левого или правого сына нового узла. Методы доступа Left и Right возвращают соответствующий указатель. Класс BinSTree объявляется дружественным классу TreeNode и может модифицировать указатели. Другие клиенты должны использовать конструктор для создания указателей и методы Left и Right для прохождения дерева. ПРИМЕР // указатели целочисленных узлов дерева TreeNode<int> *root, *lchild, *rchild; TreeNode<int> *p; // создать листья, содержащие 20 и 30 в качестве данных lchild - new TreeNode<int> (20); rchild » new TreeNode<int> (30); // создать корень, содержащий число 10 и двух сыновей root * new TreeNode<int> (10, lchild, rchild); root->data =50; // присвоить корню 50 Реализация класса TreeNode Класс TreeNode инициализирует поля объекта. Для инициализации поля данных конструктор имеет параметр item. Указатели назначают узлу левого и правого сына (поддерево). При отсутствии сына используется значение NULL. // конструктор инициализирует поля данных и указателей // значение NULL соответствует пустому поддереву template <class T> TreeNode<T>::TreeNode(const T& item, TreeNode<T> *lptr, TreeNode<T> *rptr):data(item), left(lptr), right(rptr) {} Методы Left и Right возвращают значения полей левого и правого указателей. Благодаря этому клиент имеет доступ к левому и правому сыновьям узла. Построение бинарного дерева Бинарное дерево состоит из коллекции объектов TreeNode, связанных посредством своих полей с указателями. Объект TreeNode создается динамически с помощью функции new.
TreeNode<int> *p; // объявление указателя //на целочисленный узел дерева р - new TreeNode(item); // левый и правый указатели равны NULL left Р right Вызов функции new обязательно должен включать значение данных. Если в качестве параметра передается также указатель объекта TreeNode, то об используется вновь созданным узлом для присоединения дочернего узла. Определим функцию GetTreeNode, принимающую данные и ноль или более указателей объекта TreeNode, для создания и инициализации узла бинарного дерева. При недостаточном количестве доступной памяти программа прекращается сразу после выдачи сообщения об ошибке. // создать объект TreeNode с указательными полями lptr и rptr. //по умолчанию указатели содержат NULL. template <class T> TreeNode<T> *GetTreeNode(T item, TreeNode<T> *lptr « NULL, TreeNode<T> *rptr - NULL) { TreeNode<T> *p; // вызвать new для создания нового узла // передать туда параметры lptr и rptr р - new TreeNode<T> (item, lptr, rptr); // если памяти недостаточно, завершить программу сообщением об ошибке if (p «- NULL) { cerr « "Ошибка при выделении памяти!\п"; exit(l); } // вернуть указатель на выделенную системой память return p; } Функция FreeTreeNode принимает указатель на объект TreeNode и освобождает занимаемую узлом память, вызывая функцию C++ delete. // освободить динамическую память, занимаемую данным узлом template <class t> void FreeTreeNode(TreeNode<T> *p) { delete p; } Обе эти функции находятся в файле treelib.h вместе с функциями обработки бинарного дерева, представленными в разделе 11.2. Пример определения дерева. Функция GetTreeNode может быть использована для явного построения каждого узла дерева и, следовательно, всего дерева. Это было продемонстрировано на дереве с тремя узлами, содержащими 10, 20 и 30. Для более крупного экземпляра процесс будет немного утомительным, так как вы должны включить в дерево все значения данных и указателей.
В этой главе создадим функцию MakeCharTree, строящую три дерева, узлы которых содержат символьные элементы данных. Эти деревья будут использоваться для иллюстрации методов TreeNode в следующем разделе. Параметры функции включают в себя ссылку на корень дерева и число п (О < п < 2), которое служит для обозначения дерева. Следующие объявления создают указатель на объект TreeNode, с именем root, и назначают его корнем дерева Тгее_2. TreeNode<char> *root; // объявить указатель на корень MakeCharTree(root,2); // сформировать на этом корне дерево tree_2 На рис. 11.9 показаны три дерева, построенных этим методом. Полный листинг функции MakeCharTree находится в файле treelib.h. Эта функция распространяет технологию из примера 11.2 на деревья с пятью и девятью узлами. Tree О Tree 1 Tree 2 Рис 11.9. Дерево MakCharTree 11.2. Разработка функций класса TreeNode Связанный список — это линейная структура, позволяющая последовательно проходить узлы, используя указатель на следующий элемент. Поскольку дерево является нелинейной структурой, похожего алгоритма прохождения не существует. Мы вынуждены выбрать один из методов прохождения, среди которых наиболее широко используются прямой, симметричный и обратный методы. Каждый из них основывается на рекурсивной структуре бинарного дерева. Алгоритмы прохождения существенно влияют на эффективность использования дерева. В первую очередь мы разработаем методы рекурсивного про-
хождения, а затем на их основе создадим алгоритмы печати, копирования и удаления, а также определения глубины дерева. Мы также рассмотрим поперечный метод (breadth first) прохождения, который использует очередь для запоминания узлов. Этот метод сканирует дерево уровень за уровнем, начиная с корня и передвигаясь к первому поколению сыновей, затем ко второму и т.д. Метод находит важное применение в административных иерархиях, где власть распределяется от главы к другим уровням управления. Наша реализация метода прохождения предусматривает параметр-функцию visit, которая осуществляет доступ к содержащимся в узле данным. Передавая в качестве параметра функцию, можно указать некоторое действие, которое должно выполняться в каждом узле в процессе прохождения дерева. template <class T> void <Метод_прохода> (TreeNode<T> *t, void visit(T& item)); Всякий раз при вызове метода клиент должен передавать имя функции, выполняющей некоторое действие с данными, имеющимися в узле. По мере того как метод перемещается от узла к узлу, вызывается эта функция и выполняется предусмотренное действие. Замечание. Понятие параметра-функции является относительно простым, но требует некоторого пояснения. В общем случае функция может быть аргументом, если указать ее имя, список параметров и возвращаемое ею значение. Пусть, например, функция G имеет параметр-функцию f. В этом параметре указывается имя функции (f), список параметров (int x) и возвращаемый тип (int). Список параметров i 1 int G(int t, int f (int x)) int G(int t, int f(int x) ) // параметр-функция f { // вычислить f (t) с помощью функции f и параметра t. // возвратить произведение этого значения и t return t * f(t)/ } Вызывая функцию G, клиент должен передать функцию для f с той же структурой. Пусть в нашем примере клиент определил функцию XSquared, вычисляющую х2. // XSquared — целочисленная функция с целочисленным параметром х int XSquared(int x) { return x*x; } Клиент вызывает функцию G с целочисленным параметром t и параметром-функцией XSquared. Оператор Y = G<3, XSquared) вызывает функцию G, которая в свою очередь вызывает функцию XSquared с параметром 3. Оператор cout печатает результат 27. cout « G(3.0, XSquared) « endl; возвращаемый тип имя функции список параметров
Рекурсивные методы прохождения деревьев Рекурсивное определение бинарного дерева определяет эту структуру как корень с двумя поддеревьями, которые идентифицируются полями левого и правого указателей в корневом узле. Сила рекурсии проявляется вместе с методами прохождения. Каждый алгоритм прохождения дерева выполняет в узле три действия: заходит в узел, рекурсивно спускается по левому поддереву и по правому поддереву. Спустившись к поддереву, алгоритм определяет, что он находится в узле, и может выполнить те же три действия. Спуск прекращается по достижении пустого дерева (указатель == NULL). Различные алгоритмы рекурсивного прохождения отличаются порядком, в котором они выполняют свои действия в узле. Мы изложим симметричный и обратный методы, в которых сначала осуществляется спуск по левому поддереву, а затем по правому. Другие методы оставляем вам в качестве упражнений. Симметричный метод прохождения дерева Симметричный метод прохождения начинает свои действия в узле спуском по его левому поддереву. Затем выполняется второе действие — обработка данных в узле. Третье действие — рекурсивное прохождение правого поддерева. В процессе рекурсивного спуска действия алгоритма повторяются в каждом новом узле. Итак, порядок операций при симметричном методе следующий: 1. Прохождение левого поддерева. 2. Посещение узла. 3. Прохождение правого поддерева. Мы называем такое прохождение LNR (left, node, right). Для дерева Tree_0 в функции MakeCharTree "посещение" означает печать значения из поля данных узла. Тгее_0 При симметричном методе прохождения дерева Тгее__0 выполняются следующие операции. Действие Печать Замечания Спуститься от А к В: Левый сын узла В равен NULL Посетить В; В Спуститься от В к D: D — листовой узел Посетить D; D Конец левого поддерева узла А Посетить корень А: А Спуститься от А к С: Спуститься от С к Е: Е — листовой узел Посетить Е; Е Посетить С; С Готово!
Узлы посещаются в порядке В D А Е С, Рекурсивная функция сначала спускается по левому дереву [t—>Left()], а затем посещает узел. Второй шаг рекурсии спускается по правому дереву [t—>Right()]. // симметричное рекурсивное прохождение узлов дерева template <class T> void Inorder (TreeNode<T> *t, void visit(T& item)) { // рекурсивное прохождение завершается на пустом поддереве if (t !- NULL) { Inorder(t->Left(), visit); // спуститься по левому поддереву visit(t->data); // посетить узел Inorder(t->Right(), visit); // спуститься по правому поддереву ) } Обратный метод прохождения дерева. При обратном прохождении посещение узла откладывается до тех пор, пока не будут рекурсивно пройдены оба его поддерева. Порядок операций дает так называемое LRN (left, right, node) сканирование. 1. Прохождение левого поддерева. 2. Прохождение правого поддерева. 3. Посещение узла. При обратном прохождении дерева Тгее_0 узлы посещаются в порядке D В Е С А. Замечания Левый сын узла В равен NULL D — листовой узел Все сыновья узла В пройдены Левое поддерево узла А пройдено Е — листовой узел Левый сын узла С Правый сын узла А Готово! Функция сканирует дерево снизу вверх. Мы спускаемся вниз по левому дереву [t->Left()], а затем вниз по правому [t->Right()]. Последней операцией является посещение узла. // обратное рекурсивное прохождение узлов дерева template <class T> void Postorder (TreeNode<T> *t, void visit (T& item) ) { // рекурсивное прохождение завершается на пустом поддереве if (t !* NULL) { Postorder(t->Left(), visit); // спуститься по левому поддереву Postorder(t->Right(), visit); // спуститься по правому поддереву visit(t->data); // посетить узел } } Действие Спуститься от А к В: Спуститься от В к D: Посетить D; Посетить В; Спуститься от А к С: Спуститься от С к Е: Посетить Е; Посетить С; Посетить корень А: Печать D В Е С А
Прямой метод прохождения определяется посещением узла в первую очередь и последующим прохождением сначала левого, а потом правого его поддеревьев (NLR). Ясно, что префиксы pre, in и post в названиях функций показывают, когда происходит посещение узла. В каждом случае сначала осуществлялось прохождение по левому поддереву, а уже потом по правому. Фактически существуют еще три алгоритма, которые выбирают сначала правое поддерево и потом левое. Для печати дерева будем использовать RNL-прохождение. Алгоритмы прохождения посещают каждый узел дерева. Они дают эквивалент последовательного сканирования массива или связанного списка. Функции прямого, симметричного и обратного методов прохождения содержатся в файле treescan.h. Пример 11.2 1. Для символьного дерева Тгее_2 имеет место следующий порядок Тгее_2 посещения узлов. Прямой: ABDGCEHI F Симметричный: DGBAHEI CF Обратный: GDBHI EFCA 2. Результат симметричного прохождения дерева Тгее_2 производится следующими операторами: // функция visit распечатывает поле данных void PrintChar(char& elem) { cout «elem « " n; } TreeNode<char> *root; MakeCharTree(root, 2); // сформировать дерево Tree_2 с корнем root // распечатать заголовок и осуществить прохождение, используя // функцию PrintChar для обработки узла cout < "Симметричное прохождение: "; Inorder (root, PrintChar);
11.3. Использование алгоритмов прохождения деревьев На рекурсивных алгоритмах прохождения основаны многие приложения деревьев. Эти алгоритмы обеспечивают упорядоченный доступ к узлам. В данном разделе демонстрируется использование алгоритмов прохождения для подсчета количества листьев на дереве, глубины дерева и для печати дерева. В каждом случае для посещения узлов мы должны применять ту или иную стратегию прохождения. Приложение: посещение узлов дерева Для многих приложений требуется просто обойти узлы дерева, неважно в каком порядке. В этих случаях клиент волен выбрать любой алгоритм прохождения. В данном приложении функция CountLeaf проходит дерево с целью подсчета его листьев. При распознавании очередного листа происходит приращение параметра count. // эта функция использует обратный метод прохождения. // во время посещения узла проверяется, является ли он листовым template CountLeaf (<TreeNode<T> *t, int& count) { // Использовать обратный метод прохождения if (t != NULL) { CountLeaf(t->Left(), count); // пройти левое поддерево CountLeaf(t->Right (), count); // пройти правое поддерево // Проверить, является ли данный узел листом. // Если да, то произвести приращение переменной count if (t->Left() == NULL && t->Right() == NULL) COunt++; ) } Функция Depth использует обратный метод прохождения для вычисления глубины бинарного дерева. В каждом узле вычисляется глубина его левого и правого поддеревьев. Итоговая глубина на единицу больше максимальной глубины поддеревьев. // эта функция использует обратный метод прохождения для вычисления глубины // левого и правого поддеревьев узла и возвращает результирующее // значение глубины, равное 1 + max(depthLeft, depthRight). // глубина пустого дерева равна -1 template <class T> void Depth (TreeNode<T> *t) { int depthLeft, depthRight, depthval; if (t === NULL) depthval = -1; else { depthLeft = Depth(t->Left()); depthRight = Depth(t->Right()); depthval - 1 + (depthLeft > depthRight?depthLeft:depthRight); } return depthval; }
Программа 11.1. LeafCount и Depth Эта программа иллюстрирует использование функций LeafCount и Depth для прохождения символьного дерева Тгее_2. Итоговые значения LeafCount и Depth распечатываются. #include <iostream.h> // включить класс TreeNode и библиотеку функций #include "treenode.h" #include "treelib.h" void main(void) { TreeNode<char> *root; // использовать дерево Тгее_2 MakeCharTree(root, 2); // переменная, которая обновляется функцией CountLeaf int leafCount =0; // вызвать функцию CountLeaf для подсчета числа листьев CountLeaf(root, leafCount); cout « "Число листьев равно " « leafCount « endl; // вызвать функцию Depth для вычисления глубины дерева cout « "Глубина дерева равна " « Depth(root) « endl; } /* <Выполнение программы 11.1> Число листьев равно 4 Глубина дерева равна 3 V Приложение: печать дерева Функция печати дерева создает изображение дерева, повернутое на 90 градусов против часовой стрелки. На рис. 11.10 показано исходное дерево Тгее_2 (слева) и распечатанное. Поскольку принтер выводит информацию построчно, алгоритм использует RNL-прохождение и распечатывает узлы правого поддерева раньше узлов левого поддерева. Узлы дерева Тгее__2 печатаются в порядке FCIEHABGD. Функция PrintTree распечатывает поле данных узла и уровень узла. Вызывающая программа передает корень с уровнем 0. На каждом рекурсивном вызове функции PrintTree нужно делать отступ для уровня узла. В нашем формате величина отступа вычисляется как indentBlock * level, где indentBlock — константа 6, задающая число пробелов, которое приходятся на один уровень узла. Чтобы распечатать узел, сначала вычисляется число пробелов в отступе, соответствующее уровню этого узла, а затем выводится поле данных. Поскольку функция PrintTree использует стандартный поток cout, для типа Т должен быть определен оператор «. На рис. 11.11 показаны уровни и пробелы, предшествующие каждому узлу дерева Тгее_2.
Tree 2 Printed Tree_2 Рис 11.10. Распечатанное дерево Tree_2 Отступ 0 12 6 18 12 18 0 A 6 18 12 Уровни 1 2 F С E В D 3 I H G Рис. 11.11. Печать дерева Tree_2 Код функции PrintTree находится в файле treeprint.h. // промежуток между уровнями const int indentBlock - 6; // вставить num пробелов на текущей строке void IndentBlanks(int num) { for (int i * 0; i < num; i++) cout « и и; ) // распечатать дерево Ооком, используя RNL-прохождение template <class T> void PrintTree (Treenode<T> *t, int level) { // печатать дерево с корнем t, пока t != NULL if (t !« NULL) { // печатать правое поддерево узла t PrintTree(t->Right(), level+1);
// выровнять текущий уровень и вывести поле данных IndentBlanks(indentBlock * level); cout « t->data « endl; // печатать левое поддерево PrintTree(t->Left(), level+1); } } Приложение: копирование и удаление деревьев Утилиты копирования и удаления всего дерева вводят новые понятия и подготавливают нас к проектированию класса деревьев, который требует деструктор и конструктор копирования. Функция СоруТгее принимает исходное дерево и создает его дубликат. Процедура DeleteTree удаляет каждый узел дерева, включая корень, и высвобождает занимаемую узлами память. Функции, разработанные для бинарных деревьев общего вида, находятся в файле treelib.h. Копирование дерева. Функция СоруТгее использует для посещения узлов обратный метод прохождения. Этот метод гарантирует, что мы спустимся по дереву на максимальную глубину, прежде чем начнем операцию посещения, которая создает узел для нового дерева. Функция СоруТгее строит новое дерево снизу вверх. Сначала создаются сыновья, а затем они присоединяются к своим родителям, как только те будут созданы. Этот подход использовался в функции MakeCharTree. Например, порядок операций для дерева Тгее__0 следующий: Тгее_0 d « GetTreeNode('D'); e « GetTreeNode('E'); b = GetTreeNode('B', NULL, d)/ с = GetTreeNode('С, e, NULL); a =• GetTreeNode {'A' , b, c); root - a; Сначала мы создаем сына D, который затем присоединяется к своему родителю В при создании узла. Создается узел Е и присоединяется к своему родителю С во время рождения (или создания) последнего. Наконец, создается корень и присоединяется к своим сыновьям В и С. Алгоритм копирования дерева начинает с корня и в первую очередь строит левое поддерево узла, а затем — правое его поддерево. Только после этого создается новый узел. Тот же рекурсивный процесс повторяется для каждого узла. Соответственно узлу t исходного дерева создается новый узел с указателями newlptr и newrptr. При обратном методе прохождения сыновья посещаются перед их родителями. В результате в новом дереве создаются поддеревья, соответствующие t->Left() и t->Right(). Сыновья присоединяются к своим родителям в момент создания последних.
Исходное дерево Новое дерево left data right newlptr data newrptr newnode t->Left() t->Right() newnode->Left() newnode->Right() CopyTree(t->Left())) CopyTree(t->Right()) newlptr = CopyTree(t->Left()); newrptr = CopyTree(t->Right()); // создать родителя и присоединить к нему его сыновей newnode = GetTreeNode(t->data, newlptr, newrptr); Суть посещения узла t в исходном дереве заключается в создании нового узла на дереве-дубликате. Символьное дерево Тгее__0 является примером, иллюстрирующим рекурсивную функцию CopyTree. Предположим, что главная процедура определяет корни rootl и root2 и создает дерево Тгее_0. Функция CopyTree создает новое дерево с корнем root2. Проследим алгоритм и проиллюстрируем процесс создания пяти узлов на дереве-дубликате. TreeNode<char> *rootl, *root2; // объявить два дерева MakeCharTree(rootl, 0); // rootl указывает на Tree_0 root2 = CopyTree(rootl); // создать копию дерева Тгее_0 rootl root2 Tree_0 Дубликат 1. Пройти потомков узла А, начиная с левого поддерева в узле В и далее к узлу D, который является правым поддеревом узла В. Создать новый узел с данными, равными D, и левым и правым указателями, равными NULL [рис. 11.12 (А)]. 2. Сыновья узла В пройдены. Создать новый узел с данными, равными В, левым указателем, равным NULL, и правым указателем, указывающим на узел D [рис. 11.12 (В)]. 3. Поскольку левое поддерево узла А пройдено, начать прохождение его правого поддерева и дойти до узла Е. Создать новый узел с данными из узла Е и указательными полями, равными NULL. 4. После обработки Е перейти к его родителю и создать новый узел с данными из С. В поле правого указателя поместить NULL, а левому указателю присвоить ссылку на дочерний узел Е [рис. 11.13 (А)].
rootl root2 rootl root2 Рис. 11.12. Копирование левого поддерева узла А rootl root2 (А) rootl root2 (В) Рис. 11.13. Копирование правого поддерева узла А 5. Последний шаг выполняется в узле А. Создать новый узел с данными из А и присоединить к нему сына В слева и сына С справа [рис. 11.13 (В)]. Копирование дерева завершено. Функция СоруТгее возвращает указатель на вновь созданный узел. Это возвращаемое значение используется родителем, когда тот создает свой собственный узел и присоединяет к нему своих сыновей. Функция возвращает корень вызывающей программе.
// создать дубликат дерева t и возвратить корень нового дерева template <class T> TreeNode<T> *CopyTree(TreeNode<T> *t) { // переменная newnode указывает на новый узел, создаваемый // посредством вызова GetTreeNode и присоединяемый в дальнейшем // к новому дереву, указатели newlptr и newrptr адресуют сыновей // нового узла и передаются в качестве параметров в GetTreeNode TreeNode<T> *newlptr, *newrptr, *newnode; // остановить рекурсивное прохождение при достижении пустого дерева if (t « NULL) return NULL; // CopyTree строит новое дерево в процессе прохождения узлов дерева t. в каждом // узле этого дерева функция CopyTree проверяет наличие левого сына, если он // есть, создается его копия, в противном случае возвращается NULL. CopyTree // создает копию узла с помощью GetTreeNode и подвешивает к нему копии сыновей. if (t->Left() != NULL) newlptr - CopyTree(t->Left ()); else newlptr - NULL; if (t->Right() !=* NULL) newrptr - CopyTree(t->Right()); else newrptr ■ NULL; // построить новое дерево снизу вверх, сначала создавая // двух сыновей, а затем их родителя newnode » GetTreeNode(t->data, newlptr, newrptr); // вернуть указатель на вновь созданное дерево return newnode; ) Удаление дерева. Когда в приложении используется такая динамическая структура, как дерево, ответственность за освобождение занимаемой им памяти ложится на программиста. Для бинарного дерева общего вида разработаем функцию DeleteTree, в которой применяется обратный метод прохождения. Это гарантирует, что мы посетим всех сыновей родительского узла, прежде чем удалим его. Операция посещения заключается в вызове функции FreeTreeNode, удаляющей узел. // использовать обратный алгоритм для прохождения узлов дерева //и удалить каждый узел при его посещении template <class T> void DeleteTree(TreeNode<T> *t) { if (t !« NULL) { DeleteTree(t->Left ()); DeleteTree(t->Right()); FreeTreeNode(t); ) ) Более общая процедура удаления дерева удаляет узлы и сбрасывает корень. Функция ClearTree вызывает DeleteTree для удаления узлов дерева и присваивает указателю на корень значение NULL.
// вызвать функцию DeleteTree для удаления узлов дерева. // затем сбросить указатель на его корень в NULL template <class T> void ClearTree(TreeNode<T> &t) { DeleteTree(t); t = NULL; // теперь корень пуст > Программа 11.2. Тестирование функций СоруТгее и DeleteTree Эта программа использует дерево Тгее_0 и создает копию, которая адресуется указателем root2. Мы осуществляем обратный проход вновь созданного дерева, преобразуя букву в поле данных каждого узла из прописной в строчную. Результат распечатывается с помощью функции PrintTree. ♦include <iostream.h> ♦include <ctype.h> ♦include <stdlib.h> ♦include "treescan.h" ♦include "treelib.h" ♦include "treeprnt.h" // функция преобразования прописной буквы в строчную void Lowercase(char &ch) { ch ■ tolower(ch); } void main(void) { // указатели на исходное дерево и его дубликат TreeNode<char> *rootl, *root2; // создать дерево Tree_0 и распечатать его MakeCharTree(root1, 0); PrintTree (rootl, 0); // копировать дерево cout « endl « "Копия:" « endl; root2 - СоруТгее(rootl); // выполнить обратное прохождение и распечатать дерево Postorder(root2, Lowercase); PrintTree(root2, 0); ) */ <Выполнение программы 11.2> С E A D В Копия: с e a d b */
Приложение: вертикальная печать дерева Функция PrintTree создает повернутое набок изображение дерева. На каждой строке узел распечатывается в позиции, определяемой его уровнем. Хотя такое дерево воспринимается трудно, этот прием позволяет распечатывать большие деревья. На 80-колоночном листе неограниченной длины можно изобразить дерево с216 — 1 =65535 узлами, если промежуток между уровнями indentBlock равен пяти пробелам. Вертикальная распечатка дерева более ограничена, так как элементы данных и межуровневые промежутки будут располагаться по ширине листа. Но для относительно небольших деревьев такая картинка более реалистична и привлекательна. В данном приложении мы разработаем инструменты для реализации функции PrintVTree (см. файл treeprint.h). Функция PrintVTree требует нового алгоритма прохождения, который сканирует дерево уровень за уровнем, начиная с корня на уровне 0. Этот метод, называемый поперечным прохождением или прохождением уровней (level scan), не спускается рекурсивно вдоль поддеревьев, а просматривает дерево поперек, посещая все узлы на одном уровне, и затем переходит на уровень ниже. В отличие от рекурсивного спуска здесь более предпочтителен итерационный алгоритм, использующий очередь элементов. Для каждого узла в очередь помещается всякий непустой левый или правый указатель на сына этого узла. Это гарантия того, что одноуровневые узлы следующего уровня будут посещаться в нужном порядке. Символьное дерево Тгее_2 иллюстрирует этот алгоритм. PrintTree PrintVTree Уровень 0: Уровень 1: Уровень 2: Уровень 3: Посещение А Посещение В, С Посещение D, Е, F Посещение G, Н, I Тгее_2 Алгоритм поперечного прохождения Шаг инициализации: Поместить в очередь корневой узел. Шаги итерации: Прекратить процесс, если очередь пуста. Удалить из очереди передний узел р и распечатать его значение. Использовать этот узел для идентификации его детей на следующем уровне дерева. if (p->Left() != NULL) // проверить наличие левого сына Q.QInsert(p->Left()); if (p->Right() !* NULL) // проверить наличие правого сына Q.QInsert(p->Right());
Пример 11.3 Алгоритм поперечного прохождения иллюстрируется на дереве Тгее_0. Тгее_0 Инициализация: Вставить узел А в очередь. 1: Удалить узел А из очереди. Печатать А. Вставить сыновей узла А в очередь. Левый сын = В Правый сын = С 2: Удалить узел В из очереди. Распечатать В. 3: Удалить узел С из очереди. Левый сын = Е 4: Удалить узел D из очереди. Распечатать D. Узел D не имеет сыновей. 5: Удалить узел Е из очереди. Алгоритм завершается. Очередь пуста. // Прохождение дерева уровень за уровнем с посещением каждого узла template <class T> void LevelScan(TreeNode<T> *t, void visit(T& item)) { // запомнить сыновей каждого узла в очереди, чтобы их // можно было посетить в этом порядке на следующем уровне Queue<TreeNode<T> *> Q; TreeNode<T> *p; // инициализировать очередь, вставив туда корень Q.Qinsert(t); // продолжать итерационный процесс, пока очередь не опустеет while(!Q.QEmpty()) { // удалить первый в очереди узел и выполнить функцию visit р = Q.QDelete() ; visit(p->data); // если есть левый сын, вставить его в очередь if (p->Left() != NULL) Q.Qinsert(p->Left()); // если есть правый сын, вставить его в очередь if (p->Right() != NULL) Q.Qinsert(p->Right{)); } }
Алгоритм PrintVTree. В функцию вертикальной печати дерева передается корень дерева, максимальная ширина данных и ширина экрана: void PrintVTree(TreeNode<T> *t, int dataWidth, int screenWidth) Параметры ширины позволяют организовать экран. Пусть dataWidth = 2 и screenWidth = 64 = 26. Тот факт, что значение ширины равно степени двойки, позволяет описать поуровневую организацию данных. Поскольку мы не знаем структуру дерева, то полагаем, что места должно хватать для полного бинарного дерева. Узлы строятся в координатах (уровень, смещение). Уровень 0: Корень рисуется в точке (0,32). Уровень 1: Поскольку корень смещен на 32 позиции, следующий уровень имеет смещение 32/2 = 16 = screenWidth/22. Два узла первого уровня располагаются в точках (1, 32-смещение) и (1, 324-смещение), т.е. в точках (1,16) и (1,48). Уровень 2: На втором уровне смещение равно screenWidth/23 = 8. Четыре узла второго уровня располагаются в точках (2, 16-смещение), (2, 16+сме- щение), (2, 48-смещение), (2, 48+смещение), т.е. в точках (2,8), (2,24), (2,40) и (2,56). Уровень i: Смещение равно screenWidth/2i+1. Позиция каждого узла данного уровня определяется во время посещения его родителя на уровне i-1. Пусть позиция родителя равна (i-1, parentPos). Если узел i-ro уровня является левым сыном, то его позиция равна (i, parentPos-смещение), а если правым — (i, parentPos4-смещение). Уровень 0 Уровень 1 Уровень 2 Уровень 3 PrintVTree использует две очереди и поперечный метод прохождения узлов дерева. В очереди Q находятся узлы, а очередь QI содержит уровни и позиции печати в форме записей типа Info. Когда узел добавляется в очередь Q, соответствующая ему информация о печати запоминается в QI. Элементы удаляются в тандеме во время посещения узла. // запись для хранения координат (х,у) узла struct Info { int xlndent, yLevel; }; // Очереди для хранения узлов и информации о печати Queue<TreeNode<T> * Q; Queue<Info> QI;
Программа 11.3. Вертикальная печать дерева Эта программа распечатывает символьное дерево Тгее_2 на 30- или на 60-символьном листе. Ширина данных для вывода dataWidth = 1. ♦include <iostream.h> // включить функцию PrintVTree из библиотеки ♦include "treelib.h" ♦include "treeprnt.h" void main (void) { // объявить символьное дерево TreeNode<char> *root; // назначить дереву Tree_2 корень root MakeCharTree(root, 2); cout « "Печать дерева на 30-символьном экране" « endl; PrintVTree(root, 1, 30); cout « endl « endl; cout « "Печать дерева на 60-символьном экране" « endl; PrintVTree(root, 1, 60); } /* <Выполнение программы 11.3> Печать дерева на 30-символьном экране Печать дерева на 60-символьном экране 11.4. Бинарные деревья поиска Обычное бинарное дерево может содержать большую коллекцию данных и все зке обеспечивать быстрый поиск, добавление или удаление элементов. Одним из наиболее важных приложений деревьев является построение классов коллекций. Нам уже знакомы проблемы, возникающие при построении общего класса коллекций из класса SeqList и его реализации с помощью массива или
связанного списка. Главную роль в классе SeqList играет метод Find, реализующий последовательный поиск. Для линейных структур сложность этого алгоритма равна O(N), что неэффективно для больших коллекций. В общем случае древовидные структуры обеспечивают значительно большую производительность, так как путь к любым данным не превышает глубины дерева. Эффективность поиска максимизируется при законченном бинарном дереве и составляет 0(log2N). Например, в списке из 10000 элементов предполагаемое число сравнений при последовательном поиске равно 5000. Поиск же на законченном дереве потребовал бы не более 14 сравнений. Бинарное дерево представляет большие потенциальные возможности в качестве структуры хранения списка. Линейный связанный список 5000 сравнений Бинарное дерево 14 сравнений в худшем случае Чтобы запомнить элементы в виде дерева с целью эффективного доступа, мы должны разработать поисковую структуру, которая указывает путь к элементу. Эта структура, называемая бинарным деревом поиска (binary search tree), упорядочивает элементы посредством оператора отношения "<". Чтобы сравнить узлы дерева, мы подразумеваем, что часть или все поле данных определено в качестве ключа и оператор "<" сравнивает ключи, когда размещает элемент на дереве. Бинарное дерево поиска строится по следующему правилу: Для каждого узла значения данных в левом поддереве меньше, чем в этом узле, а в правом поддереве — больше или равны. На рис. 11.14 показан пример бинарного поискового дерева. Это дерево называется поисковым потому, что в поисках некоторого элемента (ключа) мы можем идти лишь по совершенно конкретному пути. Начав с корня, мы сканируем левое поддерево, если значение ключа меньше текущего узла. В Рис. 11.14. Дерево бинарного поиска
противном случае сканируется правое поддерево. Метод создания дерева позволяет осуществлять поиск элемента по кратчайшему пути от корня. Например, поиск числа 37 требует четырех сравнений, начиная с корня. Текущий узел Действие Корень = 50 Сравнить ключ = 37 и 50 поскольку 37 < 50, перейти в левое поддерево Узел = 30 Сравнить ключ = 37 и 30 поскольку 37 >= 30, перейти в правое поддерево Узел = 35 Сравнить ключ = 37 и 35 поскольку 37 >= 35, перейти в правое поддерево Узел = 37 Сравнить ключ = 37 и 37. Элемент найден. На рис. 11.15 показаны различные бинарные деревья поиска. Ключ в узле бинарного дерева поиска Ключ в поле данных работает как этикетка, с помощью которой можно идентифицировать узел. Во многих приложениях элементы данных являются записями, состоящими из отдельных полей. Ключ — одно из этих полей. Например, номер социальной страховки является ключом, идентифицирующим студента университета. Номер социальной страховки (9-символьная строка) Имя студента (строка) Средний балл (число с плавающей точкой) Ключевое поле struct Student { String ssn; String name; float gpa; } Ключом может быть все поле данных и только его часть. На рис. 11.5 узлы содержат единственное целочисленное значение, которое и является ключом. В этом случае узел 25 имеет ключ 25, и мы сравниваем два узла путем сравнения целых чисел. Сравнение производится с помощью целочисленных операторов отношения "<" и "==". Для студента университета клю- BinSTree 1 BinSTree_2 Рис. 11.15. Примеры деревьев бинарного поиска
чом является ssn, и мы сравниваем две символьные строки. Это делается с помощью перегрузки операций. Например, следующий код реализует отношение "<" для двух объектов Student: int operator < (const Students s, const Students t) { return s.ssn < t.ssn; // сравнить ключи ssn } В наших приложениях мы приводим ряд примеров ключ/данные. В иллюстрациях мы используем простой формат, где ключ и данные — одно и то же. Операции на бинарном дереве поиска Бинарное дерево поиска является нелинейной структурой для хранения множества элементов. Как и любая списковая структура, дерево должно допускать включение, удаление и поиск элементов. Для поискового дерева требуется такая операция включения (вставки), которая правильно располагает новый элемент. Рассмотрим, например, включение узла 8 в дерево BinSTree^. Начав с корневого узла 25, определяем, что узел 8 должен быть в левом поддереве узла 25 (8<25). В узле 10 определяем, что место узла 8 должно быть в левом поддереве узла 10, которое в данный момент пусто. Узел 8 включается в дерево в качестве левого сына узла 10. До вставки узла 8 После вставки узла 8 BinSTree 1 BinSTree 1 До каждого вставляемого в дерево узла существует конкретный путь. Тот же путь может использоваться для поиска элемента. Поисковый алгоритм берет ключ и ищет его в левом или в правом поддереве каждого узла, составляющего путь. Например, поиск элемента 30 на дереве BinSTree__l (рис. 11.15) начинается в корневом узле 25 и переходит в правое поддерево (30 > 25), а затем в левое поддерево. (30 < 37). Поиск прекращается на третьем сравнении, когда ключ совпадает с числом 30, хранящемся в узле. BinSTree 1
В связанном списке операция удаления отсоединяет узел и соединяет его предшественника со следующим узлом. На бинарном дереве поиска подобная операция намного сложнее, так как узел может нарушить упорядочение элементов дерева. Рассмотрим задачу удаления корня 25 из BinSTree_l. В результате появляются два разобщенных поддерева, которым требуется новый корень. На первый взгляд напрашивается решение выбрать сына узла 25 — скажем, 37 — и заменить его родителя. Однако это простое решение терпит неудачу, так как некоторые узлы оказываются не с той стороны корня. Поскольку данное дерево относительно невелико, мы можем установить, что 15 или 30 являются допустимой заменой корневому узлу. Неудачное решение: 30 не на месте Удачное решение BinSTree 1 BinSTree 1 Объявление абстрактного типа деревьев Абстрактный тип данных (ADT) для списка строится по образцу класса SeqList. Тот факт, что бинарное дерево поиска хранит элементы данных в виде нелинейного списка, становится существенной деталью реализации его методов. Заметим, что этот ADT является зеркальным отражением ADT для класса SeqList, но имеет дополнительный метод Update, позволяющий обновлять поле данных, и метод GetRoot, предоставляющий доступ к корневому узлу, а следовательно, и функциям прохождения из treescan.h и к функциям печати из treeprint.h. Обратите внимание, что метод GetData класса SeqList отсутствует, так как он относится к линейному списку. APT для бинарных деревьев поиска Данные Список элементов, хранящийся в виде бинарного дерева, и значение size, определяющее текущее число элементов в списке. Дерево содержит указатель на корень и ссылку на последний обработанный узел — текущую позицию. Операции Конструктор <Тот же, что и в ADT для класса SeqList> ListSize <Тот же, что и в ADT для класса SeqList> ListEmpty <Тот же, что и в ADT для класса SeqList> ClearList <Тот же, что и в ADT для класса SeqList> Find Вход: Ссылка на значение данных Предусловия: Нет Процесс: Осуществить поиск на дереве путем сравнения элемента с данными, хранящимися в узле. Если происходит совпадение, выбрать данные из узла.
Выход: Возвратить 1 (True), если произошло совпадение, и присвоить данные из совпавшего узла параметру. В противном случае возвратить 0 (False). Постусловия: Текущая позиция соответствует совпавшему узлу. Insert Вход: Элемент данных Предусловия: Нет Процесс: Найти подходящее для вставки место на дереве. Добавить новый элемент данных. Выход: Нет Постусловия: Текущая позиция соответствует новому узлу. Delete Вход: Элемент данных Предусловия: Нет Процесс: Найти на дереве первый попавшийся узел, содержащий элемент данных. Удалить этот узел и связать все его поддеревья так, чтобы сохранить структуру бинарного дерева поиска. Выход: Нет Постусловия: Текущая позиция соответствует узлу, заменившему удаленный. Update Вход: Элемент данных Предусловия: Нет Процесс: Если ключ в текущей позиции совпадает с ключом элемента данных, присвоить элемент данных узлу. В противном случае вставить элемент данных в дерево. Выход: Нет Постусловия: В списке может оказаться новое значение. GetRoot Вход: Нет Предусловия: Нет Процесс: Получить указатель на корень. Выход: Возвратить указатель на корень. Постусловия: Не изменяется Конец ADT для бинарных поисковых деревьев Объявление класса BinSTree. Мы реализовали ADT для бинарных поисковых деревьев в виде класса с динамическими списковыми структурами. Этот класс содержит стандартный деструктор, конструктор копирования и перегруженные операторы присваивания, позволяющие инициализировать объекты и играющие роль операторов присваивания. Деструктор отвечает за очистку списка, когда закрывается область действия объекта. Деструктор и операторы присваивания вместе с методом ClearList вызывают закрытый метод DeleteTree. Мы также включили сюда закрытый метод СоруТгее для использования в конструкторе копирования и перегруженном операторе. Спецификация класса BinSTree ОБЪЯВЛЕНИЕ #include <iostream.h> iinclude <stdlib.h> #include "treenode.h"
template <class T> class BinSTree { protected: // требуется для наследования в гл. 12 // указатели на корень и на текущий узел TreeNode<T> *root; TreeNode<T> *current; // число элементов дерева int size; // распределение/освобождение памяти TreeNode<T> *GetTreeNode(const T& item, TreeNode<T> *lptr, TreeNode<T> *rptr); void FreeTreeNode(TreeNode<T> *p); // используется конструктором копирования и оператором присваивания void DeleteTree(TreeNode<T> *t); // используется деструктором, оператором присваивания // и функцией ClearList TreeNode<T> *FindNode(const T& item, TreeNode<T>* & parent) const; public: // конструктор и деструктор BinSTree(void); BinSTree(const BinSTree<T>& tree); -BinSTree(void); // оператор присваивания BinSTree<T>& operator= (const BinSTree<T>& rhs); // стандартные методы обработки списков int Find(T& item); void Insert(const T& item); void Delete(const T& item); void ClearList(void); int ListEmpty(void) const; int ListSize(void) const; // методы, специфичные для деревьев void Update(const T& item); TreeNode<T> *GetRoot(void) const; } ОПИСАНИЕ Этот класс имеет защищенные данные. Они представляют конструкцию наследования, которая обсуждается в гл. 12. Защищенный доступ функционально эквивалентен закрытому доступу для данного класса. Переменная root указывает на корневой узел дерева. Указатель current ссылается на точку последнего изменения в списке. Например, current указывает положение нового узла после операции включения, а метод Find заносит в current ссылку на узел, совпавший с элементом данных. Стандартные операции обработки списков используют те же имена и параметры, что и определенные в классе SeqList. Класс BinSTree содержит две операции, специфические для деревьев. Метод Update присваивает новый элемент данных текущему узлу или включает в дерево новый элемент, если тот не совпадает с данными в текущей позиции. Метод GetRoot предоставляет доступ к корню дерева. Имея корень дерева, пользователь получает доступ к библиотечным функциям из treelib.h,
treescan.h и treeprint.h. Это расширяет возможности класса для привлечения различных алгоритмов обработки деревьев, в том числе распечатки дерева. ПРИМЕР BinSTree<int> T; // дерево с целочисленными данными T.Insert(50); // создать дерево с четырьмя узлами (А) T.Insert(40); T.Insert(70); Т.Insert(45); Т.Delete(40); // удалить узел 40 (В) T.ClearListO; // удалить узлы дерева (А) (В) // дерево univlnfo содержит информацию о студентах. // Поле ssn является ключевым BinSTree<Student> univlnfo/ Student stud; // назначить ключ "9876543789" и найти его на дереве stud.ssn - "9876543789"; if (univlnfo.Find(stud)) ( // студент найден, присвоить новый средний балл и обновить узел stud.gpa = 3.86; univlnfo.Update(stud); } else cout « "Студент отсутствует в базе данных." « endl; 11.5. Использование бинарных деревьев поиска Класс BinSTree — мощная структура данных, которая используется для обработки динамических списков. Практическая задача построения конкорданса1 иллюстрирует типичное применение поисковых деревьев. Мы будем использовать эту структуру в словарях в гл. 14, а в данном разделе рассмотрим ряд простых программ, где применяются деревья поиска. Создание примеров деревьев поиска. В разделе 11.1 функция MakeChar- Tree использовалась для создания ряда бинарных деревьев с символьными данными. Похожая функция MakeSearchTree строит бинарные деревья поиска с целочисленными данными, применяя метод Insert. Например, дерево Sear- 1 Под конкордансом в книге понимается алфавитный список всех слов заданного текста с указателями на места их появлений. — Прим, ред.
chTree_0 использует шесть элементов заранее определенного массива аггО, чтобы сконструировать дерево с помощью объекта Т класса BinSTree. int arr0[6] = {30, 20, 45, 5, 10, 40}; for (i = 0; i < 6; i++) T.Insert(arr0[i]); // добавить элемент к дереву SearchTree 0 MakeSearchTree создает второе восьмиэлементное дерево и дерево с десятью случайными числами из диапазона 10-99 (рис. 11.16). Параметры функции содержат объект класса BinSTree и параметр type (0 < type < 2), служащий для обозначения дерева. Код MakeSearchTree находится в файле makesrch.h. SearchTree 1 SearchTree 2 Рис. 11.16. Деревья, созданные с помощью функции MakeSearchTree Симметричный метод прохождения. При симметричном прохждении бинарного дерева сначала посещается левое поддерево узла, затем — сам узел и наконец правое поддерево. Когда этот метод прохождения применяется к бинарному дереву поиска, узлы посещаются в сортированном порядке. Этот факт становится очевидным, когда вы сравниваете узлы в поддеревьях текущего узла. Все узлы левого поддерева текущего узла имеют меньшие значения, чем текущий узел, и все узлы правого поддерева текущего узла больше или равны текущему узлу. Симметричное прохождение бинарного дерева гарантирует, что для каждого узла, который мы посещаем впервые, меньшие узлы находятся в левом поддереве, а большие — в правом. В результате узлы проходятся в возрастающем порядке.
Программа 11.4. Использование дерева поиска Эта программа использует функцию MakeSearchTree для создания дерева SearchTree_l, содержащего числа 50, 20, 45, 70, 10, 60, 90, 30 С помощью метода GetRoot мы получаем доступ к корню этого дерева, что позволяет вызвать функцию PrintVTree. Метод GetRoot позволяет также распечатать элементы по возрастанию, используя функцию Inorder с параметром-функцией Printlnt. Программа заканчивается удалением элементов 50 и 70 и повторной печатью дерева. ♦include "makesrch.h" // функция MakeSearch ♦include "treescan.h" ♦include "treeprnt.h" // функция PrintVTree ♦include "bstree.h" // функция Inorder // печать целого числа, используется функцией Inorder void Printlnt(int& item) { cout « item « " "; } void main(void) { // объявить целочисленное дерево BinSTree<int> Tree; // создать дерево поиска ♦in распечатать его вертикально // при ширине в 40 символов MakeSearchTree(Tree, l); PrintVTree (Tree.GetRoot (), 2, 40); // симметричное прохождение обеспечивает // посещение узлов по возрастанию // хранящихся в них чисел cout « endl « endl « "Сортированный список: "; Inorder(Tree.GetRoot(), Printlnt); cout « endl; cout « endl « "Удаление узлов 70 и 50." « endl; Tree.Delete(70); Tree.Delete(50); PrintVTree(Tree.GetRoot(), 2, 40); cout « endl; } /* <Выполнение программы 11.4> 50 20 70 10 45 60 90 30 Сортированный список: 10 20 30 45 50 60 70 90 Удаление узлов 70 и 50.
45 20 60 10 30 90 */ Дублированные узлы Бинарное дерево поиска может иметь дублированные узлы. В операции включения мы продолжаем сканировать правое поддерево, если наш новый элемент совпадает с данными в текущем узле. В результате в правом поддереве совпавшего узла возникают дублированные узлы. Например, следующее дерево генерируется из списка 50 70 25 90 30 55 25 15 25. Многие приложения не допускают дублирования узлов, а используют в данных поле счетчика экземпляров элемента. Это — принцип конкорданса, когда отслеживаются номера строк, в которых встречается некоторое слово. Вместо того чтобы несколько раз размещать слово на дереве, мы обрабатываем повторные случаи употребления этого слова путем помещения номеров строк в список. Программа 11.5 иллюстрирует лобовой подход, когда счетчик дубликатов хранится как отдельный элемент данных. Список: 50 70 25 90 30 55 25 15 25 Программа 11.5. Счетчики появлений Запись IntegerCount содержит целую переменную number и поле count, которое используется для запоминания частоты появлений целого числа в списке. Поле number работает в качестве ключа в перегруженных операторах "<" и "==", позволяющих сравнить две записи IntegerCount. Эти операторы используются в функциях Find и Insert. Программа генерирует 100000 случайных чисел в диапазоне 0-9 и связывает каждое число с записью IntegerCount. Метод Find сначала определяет, есть ли уже данное число на дереве. Если есть, то значение поля count увеличивается на единицу и мы обновляем запись. В противном случае новая запись включается в дерево. Программа завершается симметричным прохождением
узлов, в процессе которого происходит печать чисел и их счетчиков. Все генерируемые случайным образом числа от 0 до 9 равновероятны. Следовательно, каждый элемент может появиться приблизительно 10000 раз. Запись IntegerCount и два ее оператора находятся в файле intcount.h. ♦include <iostreara.h> ♦include "random.h" // генератор случайных чисел ♦include "bstree.h" // класс BinSTree ♦include "treescan.h" // функция Inorder ♦include "intcount.h" // запись IntegerCount // вызывается функцией Inorder для распечатки записи IntegerCount void PrintNumberdntegerCounti N) i cout « N. number « ' :' « N. count « endl; } void main(void) { // объявить дерево, состоящее из записей IntegerCount BinSTree<IntegerCount> Tree; // сгенерировать 100000 случайный целых чисел в диапазоне 0..9 for (n - 0; n < 100000L; п++); { // сгенерировать запись IntegerCount со случайным ключом N.number * rnd.Random(10); // искать ключ на дереве if (Tree.Find(N)) { // ключ найден, увеличить count и обновить запись N. count++; Tree.Update(N); ) else { // это число встретилось впервые, вставить его с count=l N. count - 1; Tree.Insert(N); } } // симметричной прохождение для распечатки ключей по возрастанию Inorder(Tree.GetRoot(), PrintNumber); } /* <Выполнение программы 11.5> 0:10116 1:9835 2:9826 3:10028 4:10015 5:9975 6:9983 7:10112 8:10082 9:10028 */
11.6. Реализация класса BinSTree Класс BinSTree описывает нелинейный список и базовые операции включения, удаления и поиска элементов. Помимо методов обработки списков важную роль при реализации класса играет управление памятью. Частные методы СоруТгее и DeleteTree используются конструктором, деструктором и оператором присваивания для размещения и уничтожения узлов списка в динамической памяти. Элементы данных класса BinSTree. Бинарное дерево поиска определяется своим указателем корня, который используется в операциях включения (Insert), поиска (Find) и удаления (Delete). Класс BinSTree содержит элемент данных root, являющийся указателем корня и имеющий начальное значение NULL. Доступ к root осуществляется посредством метода GetRoot, разрешающего вызовы функций прохождения и печати. Второй указатель, current, определяет на дереве место для обновлений. Операция Find устанавливает current на совпавший узел, и этот указатель используется функцией Update для обновления данных. Методы Insert и Delete переустанавливают current на новый узел или на узел, заменивший удаленный. Объект BinSTree является списком, размер которого все время изменяется функциями Insert и Delete. Текущее число элементов в списке хранится в закрытом элементе данных size. // Указатели на корень и на текущий узел TreeNode<T> *root; TreeNode<T> *current; // Число элементов дерева int size; Управление памятью. Размещение и уничтожение узлов для методов Insert и Delete, а также для утилит СоруТгее и DeleteTree выполняется посредством GetTreeNode и FreeTreeNode. Метод GetTreeNode создан по образцу функций из treelib.h. Он распределяет память и инициализирует поля данных и указателей в узле. FreeTreeNode непосредственно вызывает оператор удаления для освобождения памяти. Конструктор, деструктор и оператор присваивания. Класс содержит конструктор, который инициализирует элементы данных. Конструктор копирования и перегруженный оператор присваивания с помощью метода СоруТгее создают новое бинарное дерево поиска для текущего объекта. Алгоритм функции СоруТгее был разработан нами для класса TreeNode в разделе 11.3. В том же разделе мы рассматривали алгоритм удаления узлов дерева, который реализован в классе BinSTree функцией DeleteTree и используется как деструктором, так и методом Clear List. Перегружаемый оператор присваивания копирует объект, стоящий справа, в текущий объект. После проверки того, что объект не присваивается самому себе, функция очищает текущее дерево и с помощью СоруТгее создает дубликат того, что стоит в правой части оператора (rhs). Указателю current присваивается указатель root, копируется размер списка и возвращается ссылка на текущий объект. // оператор присваивания template <class T> BinSTree<T>& BinSTree<T>::operator =* (const BinSTree<T>& rhs)
{ // нельзя копировать дерево в само себя if (this ~ &rhs) return *this; // очистить текущее дерево, скопировать новое дерево в текущий объект ClearList(); root = CopyTree(tree.root); // присвоить текущему указателю значение корня и задать размер дерева current = root; size = tree.size; // возвратить ссылку на текущий объект return *this; } Операции обработки списков Методы Find и Insert начинают с корня и проходят по дереву уникальный путь. Используя определение бинарного дерева поиска, алгоритм идет по правому поддереву, когда ключ или новый элемент больше или равен значению текущего узла. В противном случае прохождение продолжается по левому поддереву. Операция Find (поиск). Операция Find использует закрытый элемент-функцию FindNode, принимающую в качестве параметра ключ и осуществляющую прохождение вниз по дереву. Операция возвращает указатель на совпавший узел и указатель на его родителя. Если совпадение происходит в корневом узле, родительский указатель равен NULL. // искать элемент данных на дереве, если найден, возвратить адрес // совпавшего узла и указатель на его родителя, иначе зозвратить NULL template <class T> TreeNode<T> *BinSTree<T>::FindNode(const T& item, TreeNode<T>* & parent) const { // пробежать по узлам дерева, начиная с корня TreeNode<T> *t = root; //у корня нет родителя parent = NULL; // прерваться на пустом дереве while (t !== NULL) { // остановиться по совпадении if (item == t->data) break; else { // обновить родительский указатель и идти направо или налево parent = t; if (item < t->data) t = t->left; else t = t->right; } } // возвратить указатель на узел; NULL, если не найден return t; }
Информация о родителе используется операцией Delete (удаление). В методе Find нас интересует только установление текущей позиции на совпавший узел и присвоение ссылки на этот узел параметру item. Метод Find возвращает True (1) или False (0), показывая тем самым, удался ли поиск. Для сравнения данных в узлах методу Find требуются операторы отношения "==" и "<". Эти операторы должны быть перегруженными, если они не определены для этого типа данных. // искать item, если найден, присвоить данные узла параметру item template <class T> int BinSTree<T>::Find(T& item) { //мы используем FindNode, который принимает параметр parent TreeNode<T> *parent; // искать item, назначить совпавший узел текущим current = FindNode (item, parent); // если найден, присвоить данные узла и возвратить True if (current != NULL) { item » current->data/ return 1; } else // item не найден, возвратить False return 0; } Операция Insert (вставка). Метод Insert принимает в качестве параметра новый элемент данных и вставляет его в подходящее место на дереве. Эта функция итеративно проходит путь вдоль левых и правых поддеревьев, пока не найдет точку вставки. На каждом шаге этого пути алгоритм сохраняет запись текущего узла (t) и родителя этого узла (parent). Процесс прекращается по достижении пустого поддерева (t == NULL), которое показывает, что мы нашли место для включения нового элемента. В этом месте новый узел включается в качестве сына данного родителя. Например, следующие шаги вставляют число 32 в дерево, изображенное на рис. 11.17. 1. Метод начинает работу в корневом узле и сравнивает 32 с корневым значением 25 [рис. 11.17 (А)]. Поскольку 32 >. 25, переходим к правому поддереву и рассматриваем узел 35. t = узел 35; parent = узел 25 2. Считая узел 35 корнем своего собственного поддерева, сравниваем 32 и 35 и переходим к левому поддереву узла 35 [рис. 11.17 (В)]. t = NULL; parent = 35 3. С помощью GetTreeNode мы можем создать листовой узел, содержащий значение 32, а затем вставить новый узел в качестве левого сына узла 35 [рис. 11.17 (С)]: // присвоение указателю left возможно, // т.к. BinSTree является дружественным TreeNode newNode = GetTreeNode(item, NULL, NULL); parent->left - newNode; Указатели parent и t являются локальными переменными, изменяющимися по мере нашего продвижения по пути в поисках точки вставки.
Parent (A) Шаг 1: Сравнить 32 и 25. Перейти к правому поддереву parent parent (В) (С) Шаг 2: Сравнить 32 и 35. Перейти к левому поддереву Шаг 3: Вставить 32 в качестве левого сына узла parent Рис 11.17. Вставка в дерево бинарного поиска // вставить item в дерево поиска template <class T> void BinSTree<T>::Insert(const T& item) { // t — текущий узел, parent — предыдущий узел TreeNode<T> *t •» root, *parent » NULL, *newNode; // закончить на пустом дереве while(t !* NULL) { // обновить указатель parent и идти направо или налево parent - t; if (item < t->data) t - t->left; else t - t->right; } // если родителя нет, вставить в качестве корневого узла if (parent ~ NULL) root - newNode; // если item меньше родительского узла, вставить в качестве левого сына else if (item < parent-> data) parent->left * newNode; else // если item больше или равен родительскому узлу, // вставить в качестве правого сына parent->right - newNode; // присвоить указателю current адрес нового узла и увеличить size на единицу current - newNode; size++; }
Операция Delete (удаление). Операция Delete удаляет из дерева узел с заданным ключом. Сначала с помощью метода FindNode устанавливается место этого узла на дереве и определяется указатель на его родителя. Если искомый узел отсутствует, операция удаления спокойно завершается. Удаление узла из дерева требует ряда проверок, чтобы определить, куда присоединять сыновей удаляемого узла. Поддеревья должны быть заново присоединены таким образом, чтобы сохранилась структура бинарного дерева поиска. Функция Findnode возвращает указатель DNodePtr на узел D, подлежащий удалению. Второй указатель, PNodePtr, идентифицирует узел Р — родителя удаляемого узла. Метод Delete "пытается" подыскать заменяющий узел R, который будет присоединен к родителю и, следовательно, займет место удаленного узла. Заменяющий узел R идентифицируется указателем RNodePtr. Алгоритм поиска заменяющего узла должен рассмотреть четыре случая, зависящие от числа сыновей удаляемого узла. Заметьте, что если указатель на родителя равен NULL, то удаляется корень. Эта ситуация учитывается нашими четырьмя случаями и тем дополнительным фактором, что корень должен быть обновлен. Поскольку класс BinSTree является другом класса TreeNode, у нас есть доступ к закрытым элементам left и right. Ситуация А: Узел D не имеет сыновей, т.е. является листом. Обновить родительский узел так, чтобы его поддерево оказалось пустым. До После Удалить листовой узел 17: Замена не нужна PNodePtr->left есть DNodePtr PNodePtr->left есть NULL Обновление совершается путем установки RNodePtr в NULL. Когда мы присоединяем NULL-узел, родитель указывает на NULL. RNodePtr = NULL; • • • PNodePtr->left = RNodePtr; Ситуация В: Узел D имеет левого сына, но не имеет правого сына. Присоединить левое поддерево узла D к его родителю. Обновление совершается путем установки RNodePtr на левого сына узла D и последующего присоединения узла R к родителю. RNodePtr ~ DNodePtr->left; PNodePtr->right = RNodePtr;
До После Удалить узел 20, имеющий только левого сына: Узлом R является левый сын Присоединить узел R к родителю Ситуация С: Узел D имеет правого сына, но не имеет левого сына. Присоединить правое поддерево узла D к его родителю. До После Удалить узел 15, имеющий только правого сына: Узлом R является правый сын Присоединить узел R к родителю Как и в ситуации С, обновление может быть совершено путем установки RNodePtr на правого сына узла D и последующего присоединения узла R к родителю. RNodePtr = DNodePtr->right; ■ * * PNodePtr->left = RNodePtr; Ситуация D: Удаление узла с двумя сыновьями. Демонстрационное дерево Удалить узел 30 "Осиротевшие" поддеревья
Узел с двумя сыновьями имеет в своих поддеревьях элементы, которые меньше, больше или равны его собственному ключевому значению. Алгоритм должен выбрать тот заменяющий узел, который сохранит правильный порядок элементов. Рассмотрим следующий пример. Удалив узел 30, мы создали два "осиротевших" поддерева, которые должны быть вновь присоединены к дереву. Для этого требуется стратегия выбора заменяющего узла из оставшейся совокупности узлов. Результирующее дерево должно удовлетворять определению бинарного дерева поиска. Применим мак- симинный (max-min) принцип. Выберите в качестве заменяющего самый правый узел левого поддерева. Это — максимальный из узлов, меньших чем удаляемый. Отсоедините этот узел R от дерева, присоедините его левое поддерево к его родителю, а затем поставьте R на место удаляемого узла. В демонстрационном дереве заменяющим является узел 28. Мы присоединяем его левого сына (26) к его родителю (25) и заменяем удаленный узел (30) заменяющим (28). Для отыскания самого правого узла левого поддерева используется следующий простой алгоритм. Шаг 1: Поскольку заменяющий узел R меньше, чем удаляемый узел D, перейти к левому поддереву узла D. Спуститься к узлу 25. Шаг 2: Поскольку R является максимальным узлом левого поддерева, найти его значение, спустившись вниз по правому поддереву. Во время спуска следите за предшествующим узлом PofRNodePtr. В нашем примере спуститесь к узлу 28. PofRNodePtr указывает на узел 25. Спуск вниз по правому поддереву предполагает два случая. Если правое поддерево пусто, то текущей точкой является заменяющий узел R и PofRNodePtr указывает на удаляемый узел D. Мы присоединяем правое поддерево узла D в качестве правого поддерева узла R, а родителя Р удаляемого узла присоединяем к R. PofRNodePtr = DNodePtr Правое поддерево Правое поддерево RNodePtr->right = DNodePtr->right; PNodePtr->left = RNodePtr; Если правое поддерево непусто, проход завершается листовым узлом или узлом, имеющим только левое поддерево. В любом случае отсоединить узел R от дерева и присоединить сыновей узла R к родительскому узлу PofRNodePtr. В каждом случае правый сын узла PofRNodePtr переустанавливается оператором (**) PofRNodePtr->right = PofRNodePtr->left;
1, R является листом. Отсоединить его от дерева. Поскольку RNodePtr->left равен NULL, оператор (**) устанавливает правого сына узла PofR- NodePtr в NULL. Правое поддерево RofRNodePtr Правое поддерево RofRNodePtr 2. R имеет левое поддерево. Оператор (**) присоединяет это поддерево в качестве правого сына узла PofRNodePtr. Правое поддерево PofRNodePtr PofRNodePtr Правое поддерево Левое поддерево Левое поддерево Алгоритм заканчивается заменой удаляемого узла узлом R. Сначала сыновья узла D присоединяются в качестве сыновей узла R. Затем узел R замещает узел D как корень поддерева, образованного узлом D. RNodePtr->left = DNodePtr->left; RNodePtr->right «= DNodePtr->right;
Завершите присоединение к родительскому узлу Р. // удаление корневого узла, назначение нового корня if (PNodePtr — NULL) root * RNodePtr; // присоединить R к Р с правильной стороны else if (DNodePtr->data < PNodePtr->data) PNodePtr->left - RNodePtr; else PNodePtr->right « RNodePtr; Альтернативным способом замены узла D на узел R является копирование R в D. Однако, если данные занимают много места в памяти, это может быть дорогостоящей операцией. Наш способ предусматривает изменение лишь двух указателей. Метод Delete // если элемент находится на дереве, удалить его template <class T> void BinSTree<T>::Delete(const T& item) { // DNodePtr — указатель на удаляемый узел D // DNodePtr — указатель на родительский узел Р узла D // RNodePtr — указатель узел R, замещающий узел D TreeNode<T> *DNodePtr, *PNodePtr, *RNodePtr; // найти узел, данные в котором совпадают с item. // получить его адрес и адрес его родителя if ((DNodePtr - FindNode (item, PNodePtr)) « NULL return; // если узел D имеет NULL-указатель, то заменяющим // узлом является тот, что находится на другой ветви if (DNodePtr->right =- NULL) RNodePtr * DNodePtr->left; else if (DNodePtr->left « NULL) RNodePtr = DNodePtr->right; // узел D имеет двух сыновей else { // найти и отсоединить заменяющий узел R для узла D. //в левом поддереве узла D найти максимальный узел // из всех узлов, меньших чем узел D. // отсоединить этот узел от дерева.
// PofRNodePtr — указатель на родителя заменяющего узла TreeNode<T> *PofRNodePtr = DNodePtr; // первой возможной заменой является левый сын узла D RNodePtr = DNodePtr->left; // спуститься вниз по правому поддереву левого сына узла D, // сохраняя записи текущего узла и его родителя. // остановившись, мы будем иметь заменяющий узел while (RNodePtr->right != NULL) { PofRNodePtr = RNodePtr; RNodePtr = RNodePtr->right; } if (PofRNodePtr == DNodePtr) // левый сын удаляемого узла является заменяющим // присоединить правое поддерево узла D к узлу R RNodePtr->right = DNodePtr->right; else { // мы спустились вниз по правой ветви как минимум на один узел. // удалить заменяющий узел из дерева, // присоединив его правую ветвь к родительскому узлу PofRNodePtr->right = RNodePtr->left; ) } // завершить присоединение к родительскому узлу. // удалить корневой узел, назначить новый корень, if (RNodePtr == NULL) root * RNodePtr; // присоединить узел R к узлу Р с правильной стороны else if (DNodePtr->data < PNodePtr->data) PNodePtr->left = RNodePtr; else PNodePtr->right = RNodePtr; // удалить узел из памяти и уменьшить размер списка FreeTreeNode(DNodePtr); size—; } Метод Update. После использования метода Find при желании можно обновить поля данных в этом (текущем) узле. Для этого мы предоставляем метод Update, имеющий значение данных в качестве входного параметра. Если текущий узел найден, Update сравнивает значение текущего узла со значением данных и, если они равны, производит обновление узла. Если текущий узел не определен или элемент данных не совпал, новое значение данных включается в дерево. // если текущий узел определен и элемент данных (item) совпал // с данными в этом узле, переписать элемент данных в узел. // иначе включить item в дерево template <class T> void BinSTree<T>::Update( const T& item) { if (current != NULL && current->data == item) current->data = item; else Insert(item); }
11.7. Практическая задача: конкорданс Обычной проблемой анализа текстов является определение частоты и расположения слов в документе. Эта информация запоминается в конкордансе, где различные слова перечислены в алфавитном порядке и каждое слово снабжено ссылками на строки текста, в которых оно встречается. Рассмотрим следующую цитату. Peter Piper picked a peck of pickled peppers. A peck of pickled peppers Peter Piper picked. If Peter Piper picked a peck of pickled peppers, where is the peck that Peter Piper picked? Слово "piper" встречается здесь 4 раза в строках 1, 2 и 3. Слово "pickled" встречается 3 раза в строках 1 и 3. В этой задаче создается конкорданс для текстового файла с помощью следующего проекта: Вход: Открыть документ как текстовый файл и ввести текст по словам, отслеживая текущую строку. Действие: Определить запись, которая состоит из слова, счетчика появлений и списка номеров строк, содержащих это слово. При первой встрече некоторого слова в тексте создать запись и включить ее в дерево. Если слово уже есть на дереве, обновить его частоту и список номеров строк. Выход: После ввода файла распечатать слова в алфавитном порядке вместе со счетчиками частоты и упорядоченными списками строк, где встречается каждое слово. Структуры данных Данными каждого узла является объект Word, содержащий символьную цепочку, счетчик частоты и связанный список номеров строк текста. Объект Word содержит также номер строки, где данное слово встретилось последний раз. Это гарантирует, что мы сможем обработать несколько случаев появлений слова в строке и только один раз занести номер этой строки в список. wordText count LinkedUst<int> LineNumbers LastLineNo Функции-члены класса Word перегружают операторы отношения "==" и "<" и стандартные операторы потокового ввода/вывода. class Word { private: // wordText — слово из текста; count — его частота String wordText/ int count; // счетчик строк разделяется всеми объектами Word static int lineno; // номер последней строки, где встретилось данное слово. // используется для того, чтобы знать, когда вставлять // номер строки в lineNumbers int lastLineNo; LinkedList<int> lineNumbers;
public: // конструктор Word(void); // открытые операции класса void CountWord (void); Strings Key(void); // операторы сравнения, используемые классом BinSTree int operator== (const Word& w) const; int operator< (const Words w) const; // Операторы потока friend istreams operator» (istream& istr, Words w); friend ostreams operator» (ostream& ostr, Words w); >; Реализация класса Word Для каждого слова конструктор устанавливает начальное значение частоты в 0 и номер последней строки в -1. Перегружаемые операторы отношения "<" и "==" непроходимы для операций вставки и поиска на деревьях и реализуются путем сравнения символьных последовательностей двух объектов. Код этих функций находится в файле word.h. В этом классе объявляется статический элемент данных lineno. Это закрытая переменная, доступная только элементам класса и друзьям. Однако под именем Word::lineno она фактически определяется внешней по отношению к классу. Следовательно, она совмесно используется всеми словами-объектами. И это правильно, так как всем этим объектам нужен доступ к номеру текущей строки входного файла. Статические элементы данных допускают совместное использование с одновременным контролем доступа и поэтому более предпочтительны, чем глобальные переменные. Оператор ввода "»". Оператор ввода считывает данные из потока по одному слову за раз. Слово должно начинаться с буквы, за которой необязательно идет последовательность букв и цифр. Ввод слова начинается с чтения и выбрасывания всех небуквенных символов. Это гарантирует, что все межсловные промежутки и знаки пунктуации будут пропущены. Процесс ввода прекращается по достижении конца файла. Если встречается символ конца строки, происходит увеличение переменной lineno на единицу. // пропустить все предшествующие пробелы и небуквенные символы while (istr.get(с) && !isalpha(c)) // если встретился конец строки, увеличить счетчик строк текста if (с — '\п') w.lineno++; Когда распознается начало слова, оператор "»" накапливает символы, читая буквы и цифры до тех пор, пока не встретится неалфавитноцифровой символ. Буквы слова преобразуются в строчные и запоминаются в локальной переменной wd. Это позволяет сделать наш конкорданс нечувствительным к регистру букв, из которых состоит слово. Если после очередного слова следует символ конца строки, он снова помещается в поток и обнаруживается во время ввода следующего слова. Функция завершается копированием переменной wd в wordText, сбросом счетчика count и присвоением переменной lastLineNo значения lineno.
// если не конец файла, ввести слово if (listr.eof()) { // преобразовать первую букву в строчную, занести ее в wd с ■ tolower(с); wd[i++] - с; // последовательно считывать буквы или цифры, преобразуя их в строчные while (istr.get(c) && (isalpha(c) II isdigit(c))) wd[i++] ~ tolower(c); // завершить символьную последовательность нулем wd[i] - '\0'; // если после текущего слова встретился конец строки, // сохранить его для следующего слова if (с »* '\п') istr.putback(c); // заключительные установки w.wordText « wd; w.count ■ 0; w.lastLineNo * w.lineno; } Функция CountWord. После считывания слова из текста вызывается функция CountWord, которая обновляет значение частоты и список номеров строк. Вначале значение счетчика count увеличивается на единицу. Если count =» 1, то слово — новый элемент дерева, и номер строки, где впервые встретилось это слово, добавляется в список. Если слово уже есть в списке, то проверяется, изменился ли номер строки с момента последнего появления данного слова. Если да, то номер текущей строки заносится в список и используется для обновления lastLineNo. // записать случай вхождения слова void Word::CountWord (void) { // увеличить частоту вхождения слова count++; // если это слово встретилось впервые или на новой строке, // вставить его в список и присвоить переменной lastLineNo // номер текущей строки, if (count — 1 || lastLineNo !- lineno) { lineNumbers.InsertRear(lineno) ; lastLineNo e lineno; ) } Оператор вывода "«". Оператор потокового вывода распечатывает слово и частоту, вслед за которыми идет упорядоченный список номеров строк, где это слово встречается. <слово> <частота>: nl, п2, пЗ, ... // вывести объект класса Word в поток ostream& operator« (ostreami ostr, Words w) I // вывести слово ostr « w.wordText;
// вывести выровненный вправо счетчик частоты. // заполнить промежуток точками. ostr.fillC .' ); ostr « setw(25-w.wordText.Length{)) « w.count « ": "; ostr.fillC '); // снова назначить пробел символом-заполнителем // пройтись по списку и распечатать номера строк for(w.lineNumbers.Reset(); Iw.lineNumbers.EndOfList(); w.lineNumbers.Next()) ostr « w.lineNumbers.Data() « " "; ostr « endl; return ostr; } Программа 11.6. Конкорданс В программе определено бинарное дерево поиска concordTree, в котором хранятся объекты класса Word. После открытия текстового файла соп- cord.txt оператор ввода потока считывает слова, пока не встретится конец файла. Каждое слово либо включается в дерево, либо используется для обновления информации о себе, если оно уже встречалось ранее. После того как все слова обработаны, выполняется симметричное прохождение, в процессе которого слова распечатываются в алфавитном порядке. Класс Word находится в файле word.h. iinclude <iostream.h> #include <fstream.h> #include <stdlib.h> #include "word.h" // класс Word #include "bstree.h" // класс BinSTree iinclude "treescan.h" // метод Inorder // используется функцией Inorder void PrintWord(Words w) { cout « w; } void main(void) { // объявить дерево объектов Word; читать из потока fin BinSTree<Word> concordTree; ifstream fin; Word w; // открыть файл concord.txt fin.open("concord.txt", ios::in | ios::nocreate); if (!fin) { cerr « "He могу открыть concord.txt" « endl; exit(1); } // читать объекты Word из потока fin, пока не встретится конец файла while(fin » w) { // найти w на дереве
if (concordTree.Find(w) == 0) { // w нет на дереве, обновить частоту слова и включить его в дерево w.CountWordO ; concordTree.Insert(w); } else { // w на дереве, обновить информацию о слове w.CountWordO ; concordTree.Update(w); } } // распечатать дерево в алфавитном порядке Inorder(concordTree.GetRoot(), PrintWord); } /* <Файл concord.txt> Peter Piper picked a peck of pickled peppers. A peck of pickled peppers Peter Piper picked. If Peter Piper picked a peck of pickled peppers, where is the peck that Peter Piper picked? <Выполнение программы 11.б> a 3: 1 2 if 1: 2 is 1: 3 of 3: 1 2 peck 4: 12 3 peppers 3: 1 2 3 peter 4: 12 3 picked 4: 12 3 pickled 3: 1 3 piper 4: 12 3 that 1: 3 the 1: 3 where 1: 3 */ Письменные упражнения 11.1 Объясните, почему дерево является нелинейной структурой данных. 11.2 Какова минимальная глубина дерева, содержащего а) 15 узлов б) 5 узлов в) 91 узел г) 800 узлов 11.3 а) Нарисуйте бинарное дерево, содержащее 10 узлов и имеющее глубину 5. б) Нарисуйте бинарное дерево, содержащее 14 узлов и имеющее глубину 5. 11.4 Пусть бинарное дерево содержит числа 1 3 7 2 12.
а) Нарисуйте два дерева максимальной глубины, содержащие эти данные, б) Нарисуйте два законченных бинарных дерева, у которых родительский узел больше, чем любой из его сыновей. 11.5 Нарисуйте все возможные деревья, состоящие из трех узлов. 11.6 Действительно ли бинарное дерево с п узлами должно иметь ровно п-1 ребер (ненулевых указателей)? 11.7 Рассмотрим следующее бинарное дерево. а) Если в дерево вставляется число 30, какой узел будет его родителем? б) Если в дерево вставляется число 41, какой узел будет его родителем? в) Осуществите прямой, симметричный и обратный метод прохождения этого дерева. 11.8 Опишите действие функции F. Подразумевается, что F является функцией-членом класса BinSTree. template <class T> void F(TreeNode<T>* & t, T item) { if (t — NULL) t = GetTreeNode(item); else if (item < t->data F(t->left, item); else F(t->right, item); } Почему так важно передавать параметр t по ссылке? 11.9 Нарисуйте бинарное дерево поиска для каждой из приведенных последовательностей символов и осуществите его прохждение прямым, обратным и симметричным методами. а) М, Т, V, F, U, N б) F, L, О, R, I, D, А г) R, О, Т, A, R, Y, С, L, U, В
11.10 Осуществите прохождение каждого дерева из предыдущего упражнения в порядке RLN, RNL и NRL, а также поперечным методом. 11.11 Нарисуйте бинарное дерево поиска для каждой из приведенных числовых последовательностей и осуществите его прохождение прямым, обратным, симметричным и поперечным методами. а) 30, 20, 10, 6, 5, 35, 56, 1, 32, 40, 48 б) 60, 25, 70, 99, 15, 3, 110, 30, 38, 59, 62, 34 в) 30, 20, 25, 22, 24, 23 11.12 Осуществите прохождение каждого дерева из предыдущего упражнения в порядке RLN, RNL и NRL. 11.13 Модифицируйте функцию MakeCharTree таким образом, чтобы она построила следующие деревья в качестве 3-го и 4-го вариантов. Дерево (А) Дерево (В) 11.14 а) В каком порядке будет проходится дерево, если алгоритм поперечного прохождения будет запоминать узлы не в очереди, а в стеке? Проиллюстрируйте свой анализ на дереве Тгее_2 из раздела 11.1. б) Пусть узлы включаются в очередь с приоритетами, определяемыми полями данных. Используя этот алгоритм, покажите порядок прохождения дерева Тгее_2. 11.15 Используйте MakeCharTree как образец для функции MakelntTree, которая строит следующие бинарные деревья поиска. Проследите построение каждого узла. Дерево (А) Дерево (В)
11.16 а) Приведенная ниже числовая последовательность получена путем прямого прохождения бинарного дерева поиска. Постройте это дерево. 50 45 35 15 40 46 65 75 70 б) Постройте бинарное дерево поиска, которое в результате симметричного прохождения давало бы следующую последовательность узлов: 40 45 46 50 65 70 75 11.17 Дано следующее бинарное дерево поиска: Выполните следующие действия, используя каждый раз исходное дерево: а) Покажите дерево после включения узлов 1, 48, 75, 100. б) Удалите узлы 5, 35. в) Удалите узел 45. г) Удалите узел 50. д) Удалите узел 65 и вставьте его снова. 11.18 На основе функции СоруТгее создайте функцию ТСоруТгее, которая имела бы параметр target. Эта функция должна копировать только те узлы, значения которых больше, чем target. Меньшие по значению узлы должны копироваться в качестве листьев. Имейте в виду, что в процессе копирования вам придется удалять все узлы в обоих поддеревьях каждого будущего листа. TreeNode<T> *TCopyTree (TreeNode<T> *t, T target); 11.19 Напишите функцию TreeNode<T> *ReverseCopy(TreeNode<T> *tree); которая копирует дерево, попутно меняя местами все левые и правые указатели. 11.20 Напишите функцию void PostOrder_Right(TreeNode<T> *t, void visit(T& item); которая осуществляет RNL-прохождение дерева. 11.21 Напишите функцию void *InsertOne(BinSTree<T>&t, T item);
которая включает item в бинарное дерево поиска t, если его там еще нет. В противном случае функция завершается, не выполняя включение нового узла. 11.22 Напишите функцию TreeNode *Max(TreeNode *t); которая возвращает указатель на максимальный узел бинарного дерева поиска. Сделайте ее итерационной. 11.23 Напишите функцию TreeNode *Min(TreeNode *t); которая возвращает указатель на минимальный узел бинарного дерева поиска. Сделайте ее рекурсивной. 11.24 Числа 1-9 используются для построения бинарного дерева поиска с 9-ю узлами без дублирования данных. а) Покажите возможное значение корня, если глубина дерева равна 4. б) Сделайте то же самое для глубины 5, 6, 7 и 8. 11.25 Для каждого из приведенных ниже буквенных списков нарисуйте бинарное дерево поиска, которое получается, когда буквы вставляются в указанной последовательности. а) D, A, E, F, В, К б) G, J, L, М, Р, А в) D, Н, Р, Q, Z, L, М г) S, J, К, L, X, F, E, Z 11.26 Напишите итерационную функцию template <class T> int NodeLevel(const BinSTree<T>& T, const T£ elem); которая определяет глубину elem на дереве и возвращает -1, если его нет на дереве. 11.27 а) Пусть в узлах дерева находятся символьные строки. Постройте бинарное дерево поиска, которое получается в результате вставки следующих ключевых слов в данном порядке: for, case, while, class, protected, virtual, public, private, do, template, const, if, int б) Осуществите прохождение этого дерева прямым, обратным и симметричным методами. 11.28 а) Иногда узлы бинарного дерева могут содержать указатель на своего родителя. Модифицировав TreeNode, создайте класс PTreeNode для поддержки этого указателя. б) Напишите функцию template <class T> void PrintAncestors(PTreeNode<T> *t);
которая распечатывает данные из цепочки узлов, начинающейся от узла t и заканчивающейся на корне. в) используя технологию MakeCharTree, постройте дерево Tree_J2, содержащее объекты PTreeNode. 11.29 Некоторые задачи, например компьютерные игры, имеют дело с деревьями общего вида, т.е. такими, узлы которых могут иметь более двух сыновей. Ниже приведено дерево, где максимальное число сыновей равно 3 (тернарное дерево). а) Может ли симметричный метод прохождения быть однозначно определен на дереве общего вида? б) Реализуйте прямой и обратный методы прохождения тернарного дерева. в) Дерево общего вида может быть преобразовано в бинарное дерево с помощью следующего алгоритма: 1) Левый указатель каждого узла бинарного дерева указывает на самого левого сына соответствующего узла на дереве общего вида. 2) Правый указатель каждого узла бинарного дерева указывает на брата (узел, имеющий того же родителя) этого узла на дереве общего вида. Рисуя бинарное дерево, располагайте каждого сына прямо под его родителем, а его братьев располагайте справа. Вот бинарное дерево, соответствующее только что приведенному примеру дерева общего вида:
Если это дерево повернуть на 45 градусов по часовой стрелке, получится белее знакомое изображение: Осуществите прохождение этого дерева прямым, обратным и симметричным методами. Что общего вы находите между ними и соответствующими методами для дерева общего вида? г) Для приведенного ниже дерева общего вида выполните следующее: 1) Осуществите его прохождение прямым и обратным методами. 2) Постройте соответствующее ему бинарное дерево. 3) Осуществиет прохождение бинарного дерева прямым, обратным и симметричным методами. 11.30 Поскольку бинарное дерево с п узлами имеет п+1 нулевых указателей, половина выделенной для указателей памяти тратится впустую. Хороший алгоритм использует эту память. Действительно, пусть при симметричном прохождении каждый левый пустой указатель указывает на своего предшственника, а каждый правый — на преемника. Такая структура называется прошитым деревом (threaded tree), а сами указатели — нитями (threads). Узел прошитого дерева может быть представлен несложным расширением класса TreeNode. Добавьте закрытую логическую переменную rightThread, показывающую, являет-
ся ли соответствующий указатель нитью, и методы LeftThread и Right- Thread, которые возвращают значения этих указателей. Назовем этот новый класс ThreadedTreeNode. Напишите итерационную функцию template <class T> void Threadedlnoraer(ThreadedTree<T> *t); которая осуществляет симметричное прохождение дерева t и распечатывает данные из его узлов. Упражнения по программированию 11.1 Напишите функцию int CountEdges(TreeNode<T> *tree); которая подсчитывает число ребер (ненулевых указателей) бинарного дерева. Испытайте эту функцию на дереве Тгее__1 из treelib.h. 11.2 Напишите функцию void RNL(TreeNode<T> *tree, void visit(T& item)); которая посещает узлы дерева в порядке RNL. Введите 10 целых чисел и разместите их на бинарном поисковом дереве, используя класс Bin- STree. Осуществите RNL-прохождение этого дерева. Как упорядочиваются данные при таком методе? 11.3 Используя функции, разработанные вами в письменном упражнении 11.15 а) и б), напишите main-программу, которая распечатывает эти два дерева. Распечатайте дерево из а), применяя PrintTree, и дерево из б), применяя PrintVTree. 11.4 Возьмите функцию InsertOne из письменного упражнения 11.21. В тестовой программе постройте дерево с восемью узлами. Входные данные должны дублироваться. Распечатайте получившееся дерево с помощью PrintTree. 11.5 В main-программе используйте класс BinSTree для создания дерева с 500 узлами, содержащими случайные целые числа в диапазоне от 1 до 10000. С помощью функций Мах и Min из письменных упражнений 11.22 и 11.23 вычислите максимальный и минимальный узлы. 11.6 Модифицируйте задачу о конкордансе (программа 11.6) таким образом, чтобы число встреч каждого слова в каждой строке распечатывалось в следующем формате: номер строки (число встреч) Например, <Вход> one two one two three <Выход> one 2: 1(2) three 1: 1(1) two 2: 1(2) 11.7 а) Напишите функцию void LinkedSort(Array<int>& A);
которая сортирует массив А путем включения его элементов в упорядоченный связанный список и копирования отсортированных данных обратно в А. б) Напишите функцию void TreeSort(Array<int>& A); которая сортирует массив А путем включения его элементов в бинарное дерево поиска, симметричного прохождения этого дерева и копирования отсортированных данных обратно в А. (Совет: Для симметричного прохождения и присвоения значений элементам массива напишите рекурсивную функцию void InorderAssign(TreeNode<int> *t, Array<int>& A, int i); в) Напишите главную процедуру, создающую массив 10000 случайных целых чисел и использующую системный таймер для определения быстродействия функций а) и б). Обе функции должны сортировать одни и те же данные. 11.8 Арифметическое выражение, включающее бинарные операторы сложения (+), вычитания (-), умножения (*) и деления (/), может быть представлено в виде бинарного дерева, где каждый оператор имеет двух сыновей — операнд или подвыражение. Листовой узел содержит операнд, а не листовой — бинарный оператор. Левое и правое поддеревья оператора описывают подвыражения, которые вычисляются и используются в качестве операндов этого оператора. Например, выражению a+b*c/d-e соответствует следующее бинарное дерево: а) Выполните прямой, симметричный и обратный методы прохождения этого дерева. Какая связь существует между этими методами и префиксной, инфиксной и постфиксной записями данного выражения? б) Для каждого из приведенных ниже арифметических выражений постройте соответствующее бинарное дерево. Осуществив прохождение дерева, выдайте префиксную, инфиксную и постфиксную формы выра- жения* (l)a + b = c*d + e (2) / а - b * с d (3) a b с d / - * (4) * - / + a b с d e
в) Можно разработать рекурсивный алгоритм ввода выражения в префиксной форме и построения соответствующего бинарного дерева. Если элемент выражения является операндом, используйте его значение для создания листового узла, в котором поля указателей содержат NULL. Если элемент выражения является оператором, назначьте его в качестве данных узлу дерева, а затем создайте его левого и правого сыновей. Напишите функцию void BuildExpTree(TreeNode<char> *t, char * & exp); которая строит бинарное дерево по префиксной форме выражения, содержащейся в строке ехр. Предполагается, что операнды являются одно- буквенными идентификаторами в диапазоне от а до z, а операторы представлены символами Ч-\ '-*, **' и */\ г) Напишите main-программу, которая вводит выражение и создает бинарное дерево. Распечатайте дерево вертикально с помощью PrintVtree и выдайте инфиксную и постфиксную формы этого выражения. 11.9 Используя функцию ТСоруТгее из письменного упражнения 11.18, создайте в главной программе бинарное дерево поиска с 10-ю узлами, содержащими целые числа. Задайте значение параметра target и скопируйте узлы дерева с помощью ТСоруТгее. Распечатайте исходное дерево и копию, используя PrintVTree.
лава 12 Наследование и абстрактные классы 12.1. Понятие о наследовании 12.2. Наследование в C++ 12.3. Полиморфизм и виртуальные функции 12.4. Абстрактные базовые классы 12.5. Итераторы 12.6. Упорядоченные списки 12.7. Разнородные списки Письменные упражнения Упражнения по программированию
Наследование — фундаментальное понятие в объектно-ориентированном программировании. В этой главе развиваются ключевые свойства наследования, о которых кратко было упомянуто в гл. 1. Мы сосредоточимся на концепции наследования и ее реализации в C++ с помощью базового класса Shape и семейства производных от него классов геометрических фигур. Полиморфизм и виртуальные функции популярно обсуждаются в разделе 12.3 и применяются в задаче отображения свойств геометрических объектов. В разделе 12,4 развивается концепция абстрактного базового класса. В дополнение к обеспечиваемой им функциональности абстрактный базовый класс обязывает реализовывать свои чистые виртуальные функции в производных от него классах. В качестве примера разрабатывается абстрактный базовый класс линейных и нелинейных списков общего вида. Итератор — это объект, который осуществляет обход таких структур данных, как массивы, связанные списки или деревья. В качестве такового он является абстракцией элемента управления (control abstraction). В разделе 12.5 итераторы разрабатываются путем определения абстрактного базового класса и образования на его основе итераторов для классов SeqList и Array. Итератор Array используется для сортировки слиянием последовательностей — методики, используемой также в гл. 14. В разделе 12.6 наследование применяется для порождения класса упорядоченных списков из версии класса связанных списков SeqList, разработанного в гл. 9. Этот класс используется как фильтр для создания сортированных последовательностей, сливаемых при сортировке внешнего файла. Необязательный для чтения раздел 12.7 показывает, как можно использовать наследование и полиморфизм для разработки массивов и связанных списков, содержащих объекты различных типов. Когда это полезно и приемлемо, наследование используется в последующих главах для разработки структур данных. 12.1. Понятие о наследовании С точки зрения зоологии, наследование описывает общие признаки и особые характеристики видов. Например, пусть класс "Животные" представляет животных, включая обезьян, кошек, птиц и т.д. Несмотря на то что все они имеют признаки животных, существуют различные семейства с особыми характеристиками. Все животные в дальнейшем подразделяются на виды. Например, к семейству кошачьих относятся львы, тигры, гепарды и т.д. Животные Обезьяны Лев Кошачьи Птицы Тигр Гепард Все существующие животные могут быть описаны иерархией от царства до вида. "Семейство кошачьих принадлежит к царству животных", "тигр принадлежит к семейству кошачьих" и т.д. Более высокий уровень обнаруживает признаки, присущие элементам более низкого уровня. Отношения
сохраняются и через несколько уровней — "тигр принадлежит к царству животных". Иерархия наследования существует и в программировании. Можете вновь вернуться к разделу 1.4, где разрабатываются объекты типа Point, Line и Rectangle и устанавливаются отношения между ними с помощью наследования. Эти классы включали в себя методы Draw, которые из базовой точки рисовали на экране фигуры. В данном разделе мы разработаем подобные классы для замкнутых фигур, таких, как окружности, прямоугольники и т.д., а также общие для всех этих классов изолированные методы. У наших геометрических объектов есть общие признаки. Все они являются формами, которые могут быть нарисованы на экране, и каждая фигура имеет базовую точку, фиксирующую ее положение. Например, мы очерчиваем окружность вокруг центра и позиционируем прямоугольник по его левому верхнему углу. Кроме того, каждая фигура заштриховывается (заполняется) по некоторому образцу, определяемому целочисленным значением. В большинстве графических библиотек отсутствие штриховки специфицируется нулем. Например, следующий график предусматривает вычерчивание окружности вокруг точки (xl, yl) со сплошным заполнением. Прямоугольник рисуется из точки (х2, у2) с образцом штриховки в виде кирпичной кладки. Из коллекции геометрических классов мы выделяем общие признаки — базовую точку и образец штриховки — и определяем класс Shape, который содержит эти элементы данных. Этот класс содержит также методы для получения координаты базовой точки, ее позиционирования и для выбора или смены образца заполнения. Системно-зависимый метод Draw инициализирует графическую систему таким образом, что операции построения различных геометрических фигур будут использовать указанный образец заполнения. Метод Draw является виртуальной функцией (virtual function), т.е. специально созданной для того, чтобы переопределяться в виртуальном классе. Виртуальные функции обсуждаются в разделе 12.3. В первой главе мы разработали класс Circle вместе с операциями измерения площади и периметра (длины окружности). Эти операции могут применяться ко всем замкнутым фигурам, и поэтому мы включаем их в класс Shape. Методы в классе Shape не определяются, а служат шаблоном для своего определения в производных классах. Они называются чистыми виртуальными функциями (pure virtual functions), a Shape — абстрактным классом (abstract class). Мы приводим здесь эскиз описания класса Shape, хотя многие концепции развиваются в разделах 12.3 и 12.4. Различные элементы, включенные в описание класса, анонсируют тему данной главы.
class Shape { protected: float x, у; // координаты базовой точки int fillpat; public: // конструктор, действующий по умолчанию Shape (float h*=0, float v=0, int fill=0); // виртуальная функция, вызываемая методом Draw в производном // классе, инициализирует образец заполнения virtual void Draw(void) const; // производные классы должны определять методы // для вычисления площади и периметра virtual float Area(void) const - 0; virtual float Perimeter(void) const = 0; } Мы используем класс Shape в иерархии наследования. В каждом случае наш производный класс использует методы класса Shape и создает свои специфические методы, которые перекрывают родовые методы абстрактного класса. Например, объект типа Circle есть Shape (базовая точка в центре) с радиусом. Он содержит метод Draw для отображения заштрихованной окружности на поверхности чертежа. Этот класс имеет свои особые методы Area и Perimeter, использующие радиус и константу PI. В цепочке наследования Circle является производным от Shape. Аналогично объект типа Rectangle есть Shape (базовая точка в левом верхнем углу) с длиной и шириной. Метод Draw строит прямоугольник, используя его длину и ширину и заштриховывая его внутри по заданному образцу. Формулы area = length * width perimeter «= 2 * (length + width) являются базисом для методов Area и Perimeter. В цепочке наследования Rectangle является производным от Shape. Форма Окружность Прямоугольник Терминология наследования В C++ наследование определяется для классов. Эта концепция предполагает, что производный класс (derived class) наследует данные и операции базового класса (base class). Производный класс сам может являться базовым по отношению к другому слою наследования. Система классов, которая использует наследование, образует иерархию наследования (class hierarchy). Базовый класс Производный/базовый класс Производный класс
Производный класс часто называют подклассом (subclass) соответствующего базового класса, который, в свою очередь, называется также суперклассом (superclass). 12.2. Наследование в C++ Базовый класс, являющийся родоначальником цепочки наследования, имеет обычное объявление. В объявлении производного класса указывается родство с базовым классом. БАЗОВЫЙ КЛАСС // объявление обычного класса для языка C++ class BaseCL { <данные и методы> } ПРОИЗВОДНЫЙ КЛАСС // объявление производного класса со ссылкой на его базовый класс class DerivedCL: public BaseCL { <данные и методы> } Здесь BaseCL — это наименование базового класса, который наследуется классом DerivedCL. Ключевое слово public указывает, что используется открытое наследование. Производный класс в C++ может определяться с открытым (public), закрытым (private) и защищенным (protected) наследованием. В большинстве программных проектов используется открытое наследование. Защищенный тип наследования применяется редко. Закрытый тип рассматривается в упражнениях. Пример 12.1 1. Класс Shape является базовым для производного класса Circle. class Shape {<Элементы>} class Circle: public Shape // Класс Circle наследует класс Shape {<Элементы>} 2. В цепочке наследования Животные-Кошачьи-Тигр объявление классов таково: class Animal {<Элементы>} class Cat: public Animal {<Элементы>} class Tiger: public Cat {<Элементы>} При открытом наследовании закрытые элементы базового класса остаются закрытыми и доступны только функциям-членам базового класса. К открытым
элементам базового класса могут обращаться все функции-члены производного класса и любая программа, использующая производный класс. Кроме того, для открытых и закрытых элементов в C++ определяются защищенные (protected) элементы, которые имеют особое значение в базовом классе. При наследовании базового класса его защищенные элементы доступны только через методы производного класса. class BaseCL { private: {<Элементы>} // доступны только элементам BaseCL protected: {<Элементы>} // доступны как элементам DerivedCL, // так и BaseCL public: {<Элементы>} // доступны всем программам-клиентам } В иерархической цепочке, содержащей несколько производных классов, каждый из них сохраняет доступ ко всем защищенным и общедоступным элементам базовых классов более высокого уровня. На рис. 12.1 показаны один и два производных класса. Стрелки слева указывают доступ элементов производного класса к данным различных видов базового класса. Правая сторона каждой структурйой схемы показывает, что клиент может обращаться только к открытым членам базового и производных классов. Один производный класс BaseCL Private_Members Protected_Members Public Members DerivedCL Private_Members Protected _M em bers Public Members КЛИЕНТ Два производных класса BaseCL Private_Members Protected _ M em bers Public Members DerivedKL Private_Members Protected_Members Public Members Derived2CL Private_Members Protected _M em bers Public Members КЛИЕНТ Рис.12.1. Доступ к элементам данных базового класса при открытом наследовании Конструкторы и производные классы В цепочке наследования производный объект наследует данные и методы базового класса. Мы говорим, что базовый класс является подтипом произ-
водного класса. Ресурсы производного объекта включают в себя ресурсы базового объекта. Данные базового класса т м^^ ма^ ^мм «■» ^пш втлш шшшя ^мм а^вшв tmamm ^шшш ^м Данные производного класса Производный объект При создании производного объекта вызывается его конструктор для инициализации элементов данных производного объекта. Одновременно объект наследует данные базового класса, которые инициализируются конструктором базового класса. Поскольку конструктор вызывается в момент объявления объекта, должно происходить взаимодействие между конструкторами базового и производного классов. Когда объявляется производный объект, сначала выполняется конструктор базового класса, а затем — производного класса. Интуитивно ясно, что начало цепочки наследования должно быть построено базовы- мым классом, так как производный класс часто использует данные базового класса. Если цепочка длиннее, чем два класса, процесс инициализации начинается с самого первого базового класса и распространяется далее по цепочке производных классов: DerivedCL obj; // вызывается конструктор BaseCL; //а затем конструктор DerivedCL Когда конструктору базового класса требуются параметры, конструктор производного класса должен явно вызвать базовый конструктор и передать ему необходимые параметры. Это делается путем размещения имени конструктора и параметров базового класса в списке инициализации параметров конструктора производного класса. Если у базового класса есть конструктор, выполняемый по умолчанию, и предполагаются его значения по умолчанию, то производному классу, в принципе, не нужно явно вызывать такой конструктор. Однако хорошо бы все-таки это делать. Пример 12.2 Пусть конструктор базового класса объявляется следующим образом: BaseCL(int n, char ch); // конструктор, имеющий два параметра В общем случае список параметров для конструктора производного класса включает в себя параметры базового класса. // список параметров конструктора включает в себя как минимум // два параметра конструктора базового класса DerivedCL{int n, char ch, int sz); Производный конструктор должен инициализировать объект базового класса путем явного вызова конструктора базового класса в списке инициализации. Вот пример реализации конструктора производного класса. // вызвать конструктор BaseCL(n, ch) в списке инициализации // Выражение data(sz) — стандартное присвоение значения sz // элементам данных DerivedCL::DerivedCL(int n, char ch, int sz) : BaseCL(n, ch), data(sz) {}
Деструкторы в цепочке наследования вызываются в порядке, обратном вызовам конструкторов. В первую очередь вызывается деструктор для производного класса, затем деструкторы для объектов, затем деструкторы для базовых классов в порядке, обратном появлению этих классов. Интуитивно понятно, что производный объект создается после базового и поэтому должен быть ликвидирован раньше. Если у производного класса нет деструктора, а у базового есть, то деструктор для производного класса генерируется автоматически. Этот деструктор уничтожает элементы производного класса и запускает деструктор базового класса. Разрешение конфликтов имен при наследовании. В цепочке наследования классы могут содержать элементы с идентичными именами. Области действия элемента производного класса и элемента базового класса различны, несмотря на то что имена этих элементов одинаковы. Объявление элемента в производном классе скрывает объявление элемента с тем же именем в базовом классе, но не перегружает его. Для ссылки на метод базового класса с тем же именем, что и в производном, должен использоваться оператор области действия класса "::". Рассмотрим цепочку class BaseCL class DerivedCL: public BaseCL { { public: public: • • • • • • void F(void); void F(void); void G(int x); void G(float x); • • ■ » • • }; ); Предположим, что из функции G в производном классе происходит обращение к функции G базового класса. Тогда в производном классе следует применить оператор BaseCL::, чтобы получить доступ к методу G базового класса. void DerivedCL::G(float x) { • * * BaseCL::G(x) // оператор области действия в функции-члене • • • }; Для программы, использующей производный объект, обращение к F обрабатывается методом F из класса DerivedCL. Вызов функции F базового класса должен сопровождаться оператором области действия. derived OBJ; Вызовы из программы, использующей OBJ 0BJ.FO; // обращение к F производного класса OBJ.base::F(); // обращение к F базового класса Приложение: наследование класса Shape. В разделе 12.1 мы представили класс Shape как пример абстрактного базового класса. Его методы и данные могут использоваться классами геометрических фигур Circle и Rectangle. Проиллюстрируем технические детали наследования, объявив класс Shape в качестве базового, а затем образовав на его основе класс Circle. Производный класс Rectangle рассматривается в разделе 12.3 при обсуждении виртуальных функций.
Спецификация класса Shape ОБЪЯВЛЕНИЕ // это родовой класс, определяющий точку, образец заполнения и методы доступа // к этим параметрам, этот класс наследуется классами геометрических фигур, // которые выдают рисунок фигуры, а также вычисляют ее площадь и периметр, class Shape { protected: // горизонтальная и вертикальная экранные координаты точки, // измеряемые в пикселах, используются методами производных классов float х, у; // образец заполнения для графических функций int fillpat; public: // конструктор Shape(float h=0, float v=0, int fill=0); // методы доступа к координатам базовой точки float GetX(void) const; // возвращает координату х float GetY(void) const; // возвращает координату у void SetPoint(float h, float v); // изменяет базовую точку // чистые виртуальные функции, производный класс обязан // определить свои методы Area и Perimeter virtual float Area(void) const - 0; virtual float Perimeter(void) const « 0; // виртуальная функция, вызываемая методом Draw в производном // классе, инициализирует образец заполнения. virtual void Draw(void) const; > ОПИСАНИЕ По умолчанию конструктор задает базовую точку (0,0) в левом верхнем углу окна. Нулевой образец заполнения обычно предполагает его отсутствие. Методы GetX и GetY возвращают координаты х и у базовой точки. SetPoint позволяет клиенту изменять базовую точку. Похожие методы GetFill и SetFill обеспечивают доступ к образцу заполнения. Метод Draw инициализирует графическую систему таким образом, что фигуры заполняются по образцу fillpat. Предполагается, что клиент сам несет ответственность за открытие графического окна и закрытие поверхности чертежа. Методы Area и Perimeter являются чистыми виртуальными функциями. Они объявляются в классе Shape и ведут себя, как шаблоны. Эти методы должны определяться во всех производных классах, наследующих Shape. Реализация класса Shape Полное объявление класса Shape находится в файле geometry.h. В этом разделе детально описывается конструктор и функция Draw. Конструктору требуются координаты базовой точки и образец заполнения. Клиент может изменять их значения с помощью методов SetPoint и SetFill. // конструктор задает начальные значения координат и образец заполнения Shape::Shape(float h, float v, int fill); x(h), y(v), fillpat(fill) Метод Draw вызывает графическую функцию SetFillStyle. Когда производный класс рисует конкретную фигуру, используется данный стиль заполнения.
void Shape::Draw(void) const { SetFillStyle(fillpat); // вызов функции графической системы } Производный класс Circle В первой главе мы объявили класс Circle, чтобы иметь радиус и методы для вычисления площади и периметра. Радиус передавался конструктору в момент создания объекта и не был доступен. В этом разделе мы расширим класс, включив сюда возможности рисования и методы доступа к радиусу. Методы рисования наследуют базовую точку и образец заполнения из класса Shape. Заполнение f radius (радиус> Базовая точка ОБЪЯВЛЕНИЕ // константа, используемая методами Area и Perimeter const float PI = 3.14159; // объявление класса Circle на основе класса Shape class Circle: public Shape { protected: // если класс Circle становится базовым, то производные // классы могут иметь доступ к радиусу float radius; public: // конструктор, параметрами являются координаты центра, // радиус и образец заполнения Circle(float h=0, float v=0, float radius=0, int fill = 0); // методы доступа к радиусу float GetRadius(void) const; void SetRadius(float r); // метод Draw для окружности вызывает Draw из базового класса virtual void Draw(void) const; // измерительные методы virtual float Area(void) const; virtual float Perimeter(void) const; }; ОПИСАНИЕ Метод Draw рисует окружность вокруг базовой точки (х,у) с радиусом г. Объявление и реализация класса находятся в файле geometry.h. Реализация класса Circle Реализация класса Circle предполагает наличие файла geometry.h, содержащего графические операции нижнего уровня. Конструктору класса Circle передаются параметры для инициализации базового класса Shape и его дан- ное-член — radius.
// конструктор // параметры h и v задают начальное положение базовой // точки в классе Shape. Point(h,v) представляет центр окружности. // параметр fill задает начальный образец заполнения для класса Shape. // параметр г используется исключительно классом Circle. // базовый объект класса Shape инициализируется конструктором // Shape(h, v, fill) в списке инициализации Circle:rCircle(float h, float v, float r, int fill): Shape(h, v, fill), radius(r) {} Операция рисования Вызывайте метод Draw из базового класса (Shape: :Draw), чтобы задавать образец заполнения. Поскольку данные в базовом классе имеют тип доступа protected, метод Draw класса Circle может к ним обращаться. Однако программа, использующая объект, не имеет непосредственного доступа к координатам базовой точки. // нарисовать окружность заданного радиуса с центром (х,у) void Circle::Draw(void) const { Shape::Draw(); // задает образец заполнения DrawCircle(x,у, radius); } Программа 12.1. Вычерчивание окружностей Эта программа демонстрирует применение классов Shape и Circle. После объявления двух Circle-объектов выполняется ряд методов из классов Shape и Circle. #include <iostream.h> #include "graphlib.h" #include "geometry.h" void main(void) { // объявить объекты С с заполнением 7 и D без заполнения Circle С(1.О, 1.0, 0.5, 7), D(2.0, 1.0, 0.33); char eol; // используется для задержки перед рисованием фигур cout « "Координаты С: " « C.GetXO « " и " « C.GetYO « endl; cout << "Периметр С: " « С.Perimeter () « endl; cout « "Площадь С: " « C.AreaO « endl; cout « "Нажмите Enter, чтобы увидеть фигуры: "; cin.get(eol); // ждать нажатия клавиши Enter // системный вызов для инициализации поверхности чертежа InitGraphics(); // нарисовать окружность С с радиусом 0.5 и заполнением 7 С.Draw(); // для окружности D задать центр=(1.5, 1.8), радиус=0.25, // образец заполнения =11 D.SetPoint(1.5, 1.8); D.SetRadius(.25) ;
D.SetFill(ll); D.Draw(); // выдержать паузу и закрыть графическую систему ViewPause()/ ShutdownGraphics(); } /* <Выполнение программы 12.1> Координаты С: 1 и 1 Периметр С: 3.14159 Площадь С: 0.785398 Нажмите Enter, чтобы увидеть фигуры: */ Что нельзя наследовать В то время как производный класс наследует доступ к защищенным данным-членам базового класса, некоторые члены и свойства базового класса по наследству не передаются. Не наследуются конструкторы. Следовательно, они не объявляются как виртуальные методы. Если конструктору базового класса требуются параметры, то производный класс должен иметь свой собственный конструктор, который вызывает конструктор базового класса. Дружественность тоже не наследуется. Если функция F является дружественной для класса А и класс В образован из А, то F не становится автоматически дружественной для В. 12.3. Полиморфизм и виртуальные функции Понятие полиморфизма с нетехнической точки зрения обсуждалось в разделе 1.3, и было бы полезно перечитать тот материал. В данном разделе мы расширим наше представление о полиморфизме и приведем некоторые примеры. Объектно-ориентирование программирование вводит в обращение некое свойство, называемое полиморфизмом (polymorphism). Этот термин взят из древнегреческого и означает "много форм". В программировании полиморфизм означает то, что один и тот же метод может быть определен для объектов различных типов. Конкретное поведение метода будет зависеть от типа объекта. C++ поддерживает полиморфизм с помощью динамического связывания (dynemic binding) и виртуальных функций-членов (virtual member functions). Динамическое связывание позволяет различным объектам в системе реаги-
ровать на одни и те же сообщения в специфической для их типа манере. Приемник сообщения определяется динамически во время исполнения. Чтобы использовать полиморфизм, объявите в базовом классе функцию- член как виртуальную, поставив ключевое слово virtual впереди объявления. Например, в классе BaseCL функции F и G объявлены виртуальными: class BaseCL { private: * • • public: • • • virtual void F(int n); virtual void G(long m); ■ ■ * }; Во время объявления производного класса в него должны быть включены функции-члены F и G с точно такими же списками параметров. В производном классе слово virtual не является обязательным, так как атрибут виртуальности переходит по наследству от базового класса. Тем не менее указание virtual в производном классе приветствуется, чтобы не вынуждать читателя заглядывать в базовый класс для выяснения этого вопроса. class DerivedCL: public BaseCL { private: * • • public: • • • virtual void F(int n); virtual void G(long m); • • • }; Поскольку цепочка наследования и виртуальные функции определены, мы можем обсудить новые условия доступа для наших членов класса. Пусть DObj есть объект типа DerivedCL: DerivedCL DObj; Числовая функция F в производном классе доступна с помощью имени объекта. Функция F в базовом классе доступна с помощью имени объекта и оператора области действия базового класса: DObj.F(n); // функция-член производного класса DObj.BaseCL::F(n); // функция-член базового класса Эти вызовы являются примерами статического связывания (static binding). Компилятор представляет себе, что клиент каждый раз вызывает особую версию F — из базового класса и из производного. Однако полиморфизм проявляется лишь тогда, когда используются указатели или ссылки. Рассмотрим объявление BaseCL *P, *Q; BaseCL BObj; DerivedCL DObj; Поскольку производный класс является подтипом базового, производный объект может быть присвоен базовому. В процессе этого присваивания копируется та часть данных производного объекта, которая присутствует в базовом классе.
BObj = DObj; Данные базового класса Данные производного класса Производный объект В то же время присвоение базового объекта производному недопустимо, так как некоторые элементы данных производного класса могут оказаться неопределенными. В качестве примера рассмотрим следующие операторы присваивания. BObj = DObj; // копирует базовую часть данных в BObj DObj = BObj; // недопустимо, т.к. часть данных, относящаяся // к производному классу остается неопределенной В контексте указателей и ссылок указатель базового класса может указывать на производный объект, поэтому допустимы следующие присваивания: Q *= &Bobj; // присваивает адрес объекта типа BaseCL указателю класса BaseCL Р = &DObj; // присваивает адрес объекта типа DerivedCL указателю класса BaseCL Оператор Q->F(n); // вызов метода F базового класса вызывает функцию F базового класса. Подобный оператор для указателя Р иллюстрирует суть полиморфизма, поскольку он вызывает метод F производного класса, хотя Р является указателем на класс BaseCL. P->F(n); // вызов метода F производного класса Когда доступ осуществляется через указатель или ссылку, C++ определяет, какую версию функции вызывать, основываясь на конкретном объекте, адресуемом данным указателем или ссылкой. Этот процесс называется динамическим связыванием. Каждый объект, имеющий как минимум одну виртуальную функцию, содержит указатель на таблицу виртуальных функций (virtual function table). Эта таблица содержит начальные адреса всех виртуальных функций, объявленных в классе. Когда виртуальная функция вызывается по указателю или по ссылке, система использует адреса объектов для обращения к указателю на таблицу виртуальных функций, отыскивает там адрес функции и вызывает ее. Данные производного класса Базовый объект Копирование членов базового класса class DerivedCL: public BaseCL Таблица виртуальных функций virtual void F(int n); virtual int Gflong m); В нашем примере Р указывает на объект типа DerivedCL, поэтому вызывается версия F, определенная в классе DerivedCL. Это позволяет создать разнообразные объекты, адресуемые указателем базового класса. Во время выполнения виртуальной функции вызывается та ее версия, которая соответствует фактическому типу объекта. Полиморфизм позволяет повторно использовать функции, принимающие в качестве аргументов указатель или ссылку базового класса, вместе с новыми версиями виртуальных функций в производных классах.
Демонстрация полиморфизма Мы начали эту главу простым примером наследования в зоологической иерархии. От царства животных мы перешли к семейству кошачьих и далее к конкретному виду — тигру. Тигр "принадлежит к" кошачьим, кошачьи "принадлежат к" животным. Приведенные ниже классы моделируют эту иерархию с помощью классов Animal, Cat и Tiger. Каждый класс содержит символьную строку, которая инициализируется конструктором и предусмотрена для специфической информации об объекте. Каждый класс имеет метод Identify, распечатывающий эту информацию. Животные Кошачьи Тигр Объявление класса Animal class Animal { private: char animalName[20]; public: Animal(char nma[]) { strcopy (animalName, nma); } virtual void Identify(void) { cout « "Я " « animalName « " животное" « endl; } } Объявление класса Cat class Cat: public Animal { private: char catName[20]; public: Cat(char nmc[], char nma[]): Animal (nma) { strcopy(catName, nmc); } virtual void Identify(void) { Animal::Identify(); cout « "Я " « catName « " кот" « endl; } }
Объявление класса Tiger class Tiger: public Cat { private: char tigerName[20]; public: Tiger(char nmt[], char nmc[], char nma[]): Cat (nmc, nma) { strcopy(tigerName, nmt); } virtual void Identify(void) { Cat::Identify(); cout « "Я п « tigerName « " тигр" « endl; } } Программа 12.2. Полиморфизм класса Animal Эта программа иллюстрирует статическое и динамическое связывание двух функций — Announce 1 и Announce2 — которые вызывают метод Identify для объекта, переданного им в качестве параметра. Используются два различных способа передачи параметров. Передача объекта Animal в функцию Announce 1 по значению void Announcel (Animal a) { // пример статического связывания, компилятор управляет // выполнением метода Identify — члена объекта типа Animal cout « "Вызов функции Identify в статической Announcel:" « endl; a.Identify(); cout « endl/ > Передача объекта Animal в функцию Announce2 по ссылке void Announce2 (Animal *pa) { // пример динамического связывания, вызывается метод Identify // того объекта, на который указывает ра cout << "Вызов функции Identify в динамической Announce2:" « endl; pa->Identify(); cout « endl; } В main-программе объявляется объект А типа Animal, объект С типа Cat и объект Т типа Tiger. С помощью функций Announce иллюстрируется эффект различного способа передачи параметров. Статическое связывание рассматривается на примере передачи в Announcel объекта Т типа Tiger. Полиморфизм демонстрируется тремя отдельными вызовами функции Ап- nounce2, в которую передаются указатели на объекты А, С и Т. Еще одним примером полиморфизма является обращение к методу Identify через указатель класса Animal, указывающий на объект типа Cat. В результате вызывается метод Identify для класса Cat. Оставшаяся часть кода демонстрирует присвоение производного объекта базовому. Данные производного объекта, унаследованные от базового класса, копируются в правую часть. Упомянутые классы и функции Announce находятся в файле animal.h.
#include <iostream.h> #include <string.h> ♦include "animal.h" void main(void) { Animal A("млекопитающее"), *p; Cat С("домашний", "теплокровное"); Tiger T("бенгальский", "дикий", "хищное"); // статическое связывание. Announcel имеет параметр Т типа Tiger // и выполняет метод Identify из Animal Announcel(T); // статическое связывание; вызов метода Animal // примеры полиморфизма, поскольку параметр является указателем, // Announce2 использует динамическое связывание для выполнения // метода Identify фактического параметра-объекта. Announce2(&A); // динамическое связывание; вызов метода Animal Announce2(&C); // динамическое связывание; вызов метода Cat Announce2(&T); // динамическое связывание; вызов метода Tiger // непосредственное обращение к методу Identify класса Animal A.Identify(); // статическое связывание cout << endl; // динамическое связывание, вызов метода Cat р - &С; p->Identify(); cout « endl; // присвоение объекта типа Tiger объекту типа Animal. // копируются данные, наследуемые от Animal А - Т; A.IdentifyO; // cout « endl; } */ <Выполнение программы 12.2> Вызов функции Identify при статическом связывании: Я хищное животное Вызов функции Identify при динамическом связывании: Я млекопитающее животное Вызов функции Identify при динамическом связывании: Я теплокровное животное Я домашний кот Вызов функции Identify при динамическом связывании: Я хищное животное Я дикий кот Я бенгальский тигр Я млекопитающее животное Я теплокровное животное Я домашний кот Я хищное животное
Приложение: геометрические фигуры и виртуальные методы Класс Shape может быть использован в качестве базового для ряда производных геометрических классов, включая Circle и Rectangle. В данном приложении мы дадим спецификацию класса Rectangle и применим его вместе с классом Circle в одной программе для иллюстрации виртуальных функций. length (длина) width (ширина) В классе Rectangle базовой точкой является верхний левый угол объекта. Подобно классу Circle, Rectangle подменяет метод Draw базового класса своим собственным виртуальным методом Draw, отображающим прямоугольник. В классе Rectangle также определяются методы Area (длина * ширина) и Perimeter ( 2 * (длина+ширина)) вместе с методами для получения и изменения значений длины и ширины. Мы представляем объявление класса и отсылаем читателя к файлу geometry.h в программном приложении. // производный класс Rectangle; наследует класс Shape class Rectangle: public Shape { protected: // защищенные элементы данных, описывающие прямоугольник float length, width; public: // конструктор, получающий в качестве параметров // координаты базовой точки, длину, ширину и образец заполнения Rectangle(float h=0, float v=0, float 1=0, float w=0, int fill = 0) ; // методы float GetLength(void) const; void SetLength(float 1) ; float GetWidth(void) const; void SetWidth (float w); // подменить виртуальные функции базового класса virtual void Draw(void) const; // визуальное отображение прямоугольника virtual float Area(void) const; virtual float Perimeter(void) const; };
Программа 12.3. Геометрические классы и виртуальные функции Эта программа иллюстрирует динамическое связывание и полиморфизм для базового класса Shape и производных от него классов Circle и Rectangle. Объект С типа Circle является статическим, а переменные one, two и three — указателями на объекты типа Shape. При использовании статического связывания площадь и длина окружности объекта С вычисляются методами С.Агеа() и C.Perimeter(). Указателям класса Shape присваиваются адреса динамически создаваемых объектов типа Circle и Rectangle. При использовании динамического связывания, площадь и периметр объекта вычисляются методами Area/Perimeter из соответствующего производного класса. Указателю three присваивается адрес Circle-объекта С. Таким образом мы динамически связываемся с соответствующими методами класса Circle при вызове функций для вычисления площади и периметра объекта С. Динамическое связывание позволяет использовать три указателя базового класса, чтобы выполнить метод Draw для трех фигур. #include <iostream.h> #include "graphlib.h" #include "geometry.h" void main(void) { // окружность С с центром в точке (3,1) и радиусом 0.25 // переменная three является указателем типа Shape на окружность С Circle C(3,l/ .25, 11); Shape *one, *two, *three = &С; char eol; // окружность *опе имеет центр в точке (1,1) и радиус 0.5 // прямоугольник *two базируется в точке (2,2) // и имеет длину и ширину, равные 0.5 one = new Circle(1,1, .5, 4); two = new Rectangle(2,2, .5, .5, 6); cout « "Площадь/периметр С и фигур 1—3:" « endl; cout « "С: " « С.Area() « " " « С. Perimeter () « endl; cout « "1: " « one->Area() « " " « one->Perimeter<) << endl; cout << "2: " « two->Area() « " " « two->Perimeter() « endl; cout « "3: " « three->Area() « " " « three->Perimeter() « endl; cout « "Нажмите Enter, чтобы увидеть фигуры: "; cin.get(eol); // ждать нажатия клавиши Enter // инициализация графической системы InitGraphics(); one->Draw<); // нарисовать окружность two->Draw(); // нарисовать прямоугольник three->Draw(); // нарисовать окружность // выдержать паузу и закрыть графическую систему ViewPause(); ShutdownGraphics(); }
/* <Выполнение программы 12.3> Площадь/периметр С и фигур 1—3: С: 0.196349 1.570795 1: 0.785398 3.14159 2: 0.25 2 3: 0.196349 1.570795 Нажмите Enter, чтобы увидеть фигуры: */ Виртуальные методы и деструктор Деструктор класса должен быть определен, когда класс распределяет динамическую память. Если класс будет использоваться в качестве базового, его деструктор должен быть виртуальным. Этот тонкий, но важный момент должен учитываться при сохранении списков объектов по указателям базового класса. Если деструктор базового класса не является виртуальным, то базовый класс, ссылающийся на объект производного класса не будет вызывать деструктор этого класса. Эта проблема иллюстрируется следующей ситуацией. Пусть конструктор базового класса BaseCL динамически размещает массив из семи целых чисел. Для освобождения памяти должен быть разработан деструктор. class BaseCL { public: BaseCL (...); // разместить 7-элементный массив -BaseCL (void); // деструктор (не виртуальный) } Класс DerivedCL наследует BaseCL и выполняет те же действия. class DerivedCL { public: DerivedCL (...); // разместить 7-элементный массив -DerivedCL (void); // деструктор (не виртуальный) } Предположим, что р является указателем класса BaseCL, которому присваивается динамический объект типа DerivedCL, и затем мы вызываем функцию delete: BaseCL *p « new DerivedCL(); // построить новый объект типа DerivedCL delete p; // вызвать деструктор базового класса
Динамические данные, порожденные производным классом, не уничтожаются. Если же деструктор базового класса объявлен виртуальным, вызывается деструктор производного класса. Деструктор базового класса тоже вызывается, но не раньше производного. В общем случае, если класс будет использоваться в качестве базового в иерархии наследования, он обязательно должен иметь виртуальный деструктор, даже если этот деструктор ничего не будет делать. Например, virtual BaseCL::~BaseCL(void) {} 12.4. Абстрактные базовые классы Наше обсуждение наследования привело к использованию виртуальных методов базового класса одновременно с методами, имеющими те же имена, но принадлежащими к производным классам. Поскольку базовый метод определяется как виртуальный, можно использовать динамическое связывание и гарантировать таким образом вызов правильной версии. Например, в классе Shape определяется виртуальный метод Draw, имеющий примитивную задачу установки образца заполнения. Каждый из наших производных геометрических классов имеет свой собственный метод Draw, подменяющий базовый и рисующий конкретную фигуру. В том же классе Shape мы определили виртуальные методы для вычисления площади и периметра. Эти операции не имеют смысла для объектов типа Shape, которые состоят из базовой точки и образца заполнения. Подразумевается, что эти операции будут подменяться в производных геометрических классах. Объявляя эти операции в базовом классе как виртуальные методы, мы гарантируем, что динамическое связывание будет вызывать корректную версию метода для конкретного геометрического объекта. Определим функции, возвращающие 0. // Определение функции-заглушки в базовом классе float Shape::Area(void) const { return 0.0; // площадь точки } float Shape::Perimeter(void) const { return 0.0; // периметр точки } Вместо того чтобы вынуждать программиста создавать подобные заглушки, C++ допускает использование чистых виртуальных функций (pure virtual functions), путем добавления м=0" к определению. Например, virtual float Area(void) const = 0; virtual float Perimeter(void) const = 0; Применение чистой виртуальной функции в базовом классе подразумевает, что немедленной ее реализации не будет. В то же время это объявление предписывает реализацию функции в каждом производном классе. Например, в каждом производном геометрическом классе должны быть определены методы Area и Perimeter. Включая чистые виртуальные функции в класс Shape, мы тем самым гарантируем невозможность создания отдельных объектов типа Shape. Этот класс может лишь служить базовым для другого класса.
Класс с одной или несколькими чистыми виртуальными функциями называется абстрактным (abstract class). Любой класс, образованный от абстрактного класса обязан обеспечить реализацию каждой чистой виртуальной функции, иначе он также будет абстрактным и не сможет порождать объекты. Пример 12.3 Абстрактный класс BaseCL содержит две чистые виртуальные функции и поэтому является абстрактным базовым классом. class BaseCL { • • • public: virtual void F(void) - 0; // чистая виртуальная функция virtual void G(void) = 0; // чистая виртуальная функция >; Производный класс DerivedCL определяет F, но не G и поэтому остается абстрактным. class DerivedCL: public BaseCL < public: // поскольку функция G не определена, нельзя объявить // объект типа DerivedCL. класс остается абстрактным // базовым классом для другого производного класса, //в котором будет определена функция G virtual void F(void); >; Следующее объявление повлечет за собой ошибку компиляции: DerivedCL D; Ошибка: Нельзя создать экземпляр абстрактного класса 'DerivedCL' Абстрактный базовый класс List Абстрактный класс служит шаблоном для своих производных классов. Он может содержать данные и методы, совместно используемые всеми производными классами. С помощью чистых виртуальных функций он обеспечивает объявления общедоступных методов, которые должны быть реализованы производными классами. В качестве примера мы разрабатываем абстрактный класс List как шаблон для списковых коллекций. Этот класс имеет переменную (член класса) size, используемую для определения методов ListSize и ListEmpty. Эти функции доступны каждому производному классу, обеспечивающему корректное сохранение size при включении или удалении элементов, а также при очистке списка. Несмотря на то что методы ListSize и ListEmpty представляются в базовом классе, они могут быть либо подменены в производном классе, либо восприняты по умолчанию. Остальные методы объявляются как чистые виртуальные функции базового класса и должны подменяться в производном классе. Функция Insert зависит от конкретного класса коллекций. В одном образовании Insert может помещать данные в последовательный список, а для бинарного дерева или словаря требуется совершенно иной алгоритм включения.
Спецификация класса List template <class T> class List { protected: // число элементов списка, обновляемое производным классом int size; public: // конструктор List(void); // методы доступа к списку virtual int ListSize(void) const; virtual int ListEmpty(void) const; virtual int Find (T& item) «= 0; // методы модификации списка virtual void Insert (const T& item) - 0; virtual void Delete (const T& item) = 0; virtual void ClearList (void) = 0; }; Реализация методов класса List В любом производном классе методы модификации списка должны поддерживать size — член базового класса. Начальное значение 0 присваивается этой переменной конструктором класса List. // конструктор устанавливает size в 0 template <class T> int List<T>::List(void): size(0) {} Методы ListSize и ListEmpty класса List зависят только от значения size. Они реализуются в базовом классе и затем используются любым производным классом. // возвратить размер списка template <class T> int List<T>::List(void) const { return size; } // проверить, пуст ли список template <class T> int List<T>::ListEmpty(void) const { return size == 0; } Образование класса SeqList из абстрактного базового класса List Первый раз мы представили класс SeqList в гл. 1 и в последующих главах показали реализацию массива и связанного списка. Теперь мы снова рассмотрим SeqList в качестве класса, образованного от абстрактного класса List. Методы DeleteFront и GetData отсутствуют в абстрактном классе, так как они применимы только к последовательному списку.
Спецификация класса SeqList ОБЪЯВЛЕНИЕ template <class T> class SeqList: public List { protected: // связанный список, доступный производным классам LinkedList<T> Hist; public: // конструктор SeqList(void); // методы доступа к списку virtual int Find (T& item); T GetData(int pos); // методы модификации списка virtual void Insert (const T& item); virtual void Delete (const T& item); T DeleteFront(void); virtual void ClearList (void); // для объекта типа SeqListlterator требуется доступ к Hist friend class SeqListIterator<T>; }; ОПИСАНИЕ Являясь наследником абстрактного класса List, класс SeqList должен поддерживать указанные в List операции. Поскольку SeqList реализует последовательный список, в этот производный класс должны быть добавлены метод GetData, принимающий позицию элемента в качестве параметра, и метод DeleteFront, удаляющий первый элемент списка. Методы Insert, Delete и ClearList поддерживают защищенный элемент данных базового класса size, поэтому методы ListSize и ListEmpty подменять не нужно. Прохождение объекта типа SeqList можно выполнить с помощью средства, называемого итератором (iterator). Этот инструмент, объявляемый как объект типа SeqListlterator, должен иметь доступ к Hist, что обеспечивается объявлением класса SeqListlterator дружественным. Итераторы обсуждаются в следующем разделе. Производная версия класса SeqList вкючена в файл seqlist2.h. Реализация производной версии класса SeqList Основная часть работы по реализации этого класса была сделана в гл. 9. Нам необходимо определить функции Insert, Delete, ClearList и Find. Мы повторяем их определения, сделанные в классе LinkedList, но добавляем поддержку значения size из класса List. Например, метод Insert выглядит следующим образом: // использовать метод InsertRear для включения элемента в хвост списка template <class T> void SeqList<T>::Insert{const T& item) { Hist. InsertRear (item) ; size++; // обновить size в классе List } Конструктор производного класса SeqList вызывает конструктор класса List, который обнуляет size.
// конструктор умолчания // инициализация базового класса template <class T> SeqList<T>::SeqList(void): List<T>{) {} 12.5. Итераторы Многие алгоритмы обработки списков предполагают, что мы сканируем элементы и попутно совершаем какое-то действие. Производный от List класс предоставляет методы для добавления и удаления данных, В общем случае в нем отсутствуют методы, специально предназначенные для прохождения списка. Подразумевается, что прохождение осуществляет некий внешний процесс, который поддерживает номер текущей записи списка. В случае массива или объекта L типа SeqList мы можем выполнить прохождение, используя цикл и индекс позиции. Для объекта L типа SeqList доступ к данным класса осуществляется посредством метода GetData. for (pos - 0; pos < ListSizeO; pos++) cout « L.GetData (pos)« " "; Для бинарных деревьев, хешированных таблиц и словарей процесс прохождения списка более сложен. Например, прохождение дерева является рекурсивным и должно выполняться рекурсивным прямым, обратным или симметричным методами. Эти методы могут быть добавлены в класс обработки бинарных деревьев. Однако рекурсивные функции не позволяют клиенту остановить процесс прохождения, выполнить другую задачу и продолжить итерацию. Как мы увидим в гл. 13, итерационное прохождение может быть выполнено путем сохранения указателей на узлы дерева в стеке. Классу деревьев не потребуется содержать итерационную версию для каждого способа прохождения, даже если клиент не может выполнить прохождение дерева или может постоянно использовать один метод прохождения. Предпочтительно отделять абстракцию данных от абстракции управления. Решением проблемы прохождения списка является создание класса итераторов, задачей которого будет прохождение элементов таких структур данных, как связанные списки или деревья. Итератор инициализируется так, чтобы указывать на начало списка (на голову, корень и т.д.). У итератора есть методы Next() и EndOfList(), обеспечивающие продвижение по списку. Объект-итератор сохраняет запись состояния итерации между обращениями к Next. С помощью итератора клиент может приостановить процесс прохождения, проверить содержимое элемента данных, а также выполнить другие задачи. Клиенту дается средство прохождения списка, не требующее сохранения внутренних индексов или указателей. Имея класс, включающий дружественный ему итератор, мы можем связывать с этим классом некоторый подлежащий сканированию объект и обеспечивать доступ к его элементам через итератор. При реализации методов итератора используется структура внутреннего представления списков. В этом разделе дается общее обсуждение итераторов. С помощью виртуальных функций мы объявляем абстрактный базовый класс, используемый в качестве основы для конструирования всех итераторов. Этот абстрактный класс предоставляет общий интерфейс для всех операций итератора, несмотря на то что производные итераторы реализуются по-разному.
Абстрактный базовый класс Iterator Мы определяем абстрактный класс Iterator как шаблон для итераторов списков общего вида. Каждый из представляемых далее итераторов образован из этого класса, который находится в файле iterator.h. Спецификация класса Iterator ОБЪЯВЛЕНИЕ template <class T> class Iterator { protected: // флажок, показывающий, достиг ли итератор конца списка. // должен поддерживаться производными классами int iterationComplete; public: // конструктор Iterator(void); // обязательные методы итератора virtual void Next(void) = 0; virtual void Reset(void) = 0; // методы для выборки/модификации данных virtual T& Data(void) = 0; // проверка конца списка virtual int EndOfList(void) const; }; ОБСУЖДЕНИЕ Итератор является средством прохождения списка. Его основные методы: Reset (установка на первый элемент списка), Next (установка позиции на следующий элемент), EndOfList (обнаружение конца списка). Функция Data осуществляет доступ к данным текущего элемента списка. Реализация класса Iterator Этот абстрактный класс имеет единственный элемент данных, iterationComplete, который должен поддерживаться методами Next и Reset в каждом производном классе. Из функций реализованы только конструктор и метод EndOfList. // конструктор, устанавливает iterationComplete в 0 (False) template <class T> Iterator<T>::Iterator(void): iterationComplete(0) {} Метод EndOfList просто возвращает значение iterationComplete. Этот флажок устанавливается в 1 (True) производным методом Reset, если список пуст. Метод Next в производном классе должен устанавливать iterationComplete в 1 при выходе за верхнюю границу списка. Образование итераторов для списка Класс SeqList широко использовался в этой книге и послужил основой для разработки абстрактного класса List. Ввиду его важности мы начнем с
итератора последовательных списков. Этот итератор хранит указатель listPtr, указывающий на сканируемый в данный момент объект типа SeqList. Поскольку SeqListlterator является дружественным по отношению к производному классу SeqList, допускается обращение к закрытым элементам данных класса SeqList. Спецификация класса SeqListlterator ОБЪЯВЛЕНИЕ // SeqListlterator образован от абстрактного класса Iterator template <class T> class SeqListlterator: public Iterator<T> { private: // локальный указатель на объект SeqList SeqList<T> *listPtr; // по. мере прохода по списку необходимо хранить предыдущую и текущую позицию Node<T> *prevPtr, *currPtr; public: // конструктор SeqListlterator (SeqList<T>& 1st); // обязательные методы прохождения virtual void Next(void); virtual void Reset(void); // методы для выборки/модификации данных virtual T& Data(void); // установить итератор для прохождения нового списка void SetList(SeqList<T>& 1st); } ОБСУЖДЕНИЕ Этот итератор реализует виртуальные функции Next, Reset и Data, которые были объявлены как чистые виртуальные функции в базовом классе Iterator. Метод SetList является специфичным для класса SeqListlterator и позволяет клиенту присваивать итератор другому объекту типа SeqList. Класс SeqList вместе с итератором находятся в файле seqlist2.h. ПРИМЕР SeqList<int> L; // создать список SeqListIterator<int> iter(L); // создать итератор и присоединить к списку L cout « iter.Data (); // распечатать текущее значение данных iter.Next{); // перейти на следующую позицию в списке // цикл, выполняющий проход по списку и распечатывающий его элементы for (iter.Reset(); liter.EndOfList(); iter.NextO ) cout « iter.Data() « " "; Построение итератора SeqList Итератор, создаваемый конструктором, ограничен определенным классом SeqList, и все его операции применимы к последовательному списку. Итератор хранит указатель на объект типа SeqList. После присоединения итератора к списку мы инициализируем iterationCom- plete и устанавливаем текущую позицию на первый элемент списка.
SeqListlterator HstPtr Hist prevPtr currPtr // конструктор, инициализировать базовый класс и локальный указатель SeqList template <class T> SeqListIterator<T>::SeqListlterator(SeqList<T>& 1st): Iterator<T>(), listPtr(&lst) { // выяснить, пуст ли список iterationComplete «= listPtr->llist.ListEmpty(); // позиционировать итератор на начало списка Reset(); } Reset устанавливает итератор в начальное состояние, инициализируя iterationComplete и устанавливая указатели prevPtr и currPtr на свои позиции в начале списка. Класс SeqListlterator является также дружественным по отношению к классу LinkedList и, следовательно, имеет доступ к члену класса front. // перейти к началу списка template <class T> void SeqListIterator<T>::Reset(void) { // переприсвоить состояние итерации iterationComplete « listPtr->llist.ListEmpty<); // вернуться, если список пуст if (listPtr->llist.front -» NULL) return; // установить механизм прохождения списка с первого узла prevPtr * NULL; currPtr - listPtr->llist.front; } Метод SetList является эквивалентом конструктора времени исполнения. Новый объект 1st типа SeqList передается в качестве параметра, и теперь итератор идет по списку 1st. Переназначьте listPtr и вызовите Reset. // сейчас итератор должен проходить список 1st. // переназначьте listPtr и вызовите Reset, template <class T> void SeqListIterator<T>::SetList(SeqList<T>& 1st) { listPtr - &lst; // инициализировать механизм прохождения для списка 1st Reset(); } Итератор получает доступ к данным текущего элемента списка с помощью метода Data(). Эта функция возвращает значение данных текущего элемента списка, используя currPtr для доступа к данным узла LinkedList. Если список пуст или итератор находится в конце списка, выполнение программы прекращается после выдачи сообщения об ошибке.
// возвратить данные, расположенные в текущем элементе списка template <class T> void SeqListIterator<T>::Data(void) { // ошибка, если список пуст или прохождение уже завершено if (listPtr->llist.ListEmpty() I I currPtr «= NULL) { cerr « "Data: недопустимая ссылка!" « endl; exit(l); ) return currPtr->data; } Продвижение от элемента к элементу обеспечивается методом Next. Процесс сканирования продолжается до тех пор, пока текущая позиция не достигнет конца списка. Это событие отражается значением члена iterationComplete класса Iterator, который должен поддерживаться функцией Next. // продвинуться к следующему элементу списка template <class T> void SeqListIterator<T>::Next(void) { // если currPtr == NULL, мы в конце списка if (currPtr ==* NULL) return; // передвинуть указатели prevPtr/currPtr на один узел вперед prevPtr = currPtr; currPtr = currPtr->NextNode(); // если обнаружен конец связанного списка, // установить флажок "итерация завершена" if (currPtr =- NULL) iterationComplete * 1; Программа 12.4. Использование класса SeqListlterator Некая компания ежемесячно создает записи Salesperson, состоящие из личного номера продавца и количества проданного товара. Список salesList содержит накопленные за некоторый отрезок времени записи Salesperson. Во втором списке, idList, хранятся только личные номера служащих. Из файла sales.dat вводится информация о продажах за насколько месяцев, и каждая запись включается в salesList. Поскольку записи охватывают несколько месяцев, одному продавцу может соответствовать несколько записей. Однако в список idList каждый сотрудник включается только единожды. После ввода данных соответствующим спискам назначаются итераторы idlter и saleslter. Сканируя список idList, мы идентифицируем каждого служащего по его личному номеру и передаем этот номер в качестве параметра функции PrintTotalSales. Эта функция сканирует список salesList и подсчитывает суммарное количество товара, проданного сотрудником с данным личным номером. В конце распечатывается личный номер служащего и суммарное количество проданного им товара. #include <iostream.h> #include <fstream.h> finclude "seqlist2.h"
// использовать класс SeqList, наследующий класс List, и SeqListlterator // запись, содержащая личный номер продавца и количество проданного товара struct Salesperson { int idno; int units; ); // оператор =* сравнивает служащих по личному номеру int operator » (const Salesperson &a, const Salesperson &b) { return a.idno « b.idno; } // взять id в качестве ключа и пройти список. // суммировать количество товара, проданное сотрудником с личным номером id // печатать результат void PrintTotalSales(SeqList<SalesPerson> & L, int id) { // объявить переменную типа Salesperson и инициализировать поля записи Salesperson salesP = {id, 0}; // объявить итератор последовательного списка // и использовать его для прохождения списка SeqListIterator<SalesPerson> iter(L); for(iter.Reset О; !iter.EndOfList(); iter.NextO ) // если происходит совпадение с id, прибавить количество товара if (iter.DataO *= salesP) sales.P += (iter.Data()).units; // печатать личный номер и суммарное количество продаж cout « "Служащий " « salesP.idno « " Количество проданного товара " « salesP.units « endl; ) void main(void) { // список, содержащий записи типа Salesperson, //и список личных номеров сотрудников SeqList<SalesPerson> SalesList; SeqList<int> idList; ifstreaiti salesFile; // Входной файл Salesperson salesP; // Переменная для ввода int i; // открыть входной файл salesFile.open("sales.dat", ios::in | ios::nocreate); if (JsalesFile) { cerr « "Файл sales.dat не найден!"; exit(1); } // читать данные в форме "личный номер количество товара" //до конца файла while (!salesFile.eof()) { // ввести поля данных и вставить в список salesList salesFile » salesP.idno >> salesP.units; salesList.Insert(salesP); // если id отсутствует в idList, включить этот id
if (!idList.Find(sales?.idno)) idList.Insert(salesP.idno); } // создать итераторы для этих двух списков SeqListIterator<int> idlter(idList); SeqListIterator<SalesPerson> saleslter(salesList); // сканировать список личных номеров и передавать каждый номер // в функцию PrintTotalSales для добавления количества // проданного товара к общему числу его продаж for(idlter.Reset(); !idlter.EndOfList(); idlter.Next () ) PrintTotalSales(salesList, idlter.Data()); } /* <Файл sales.dat> 300 40 100 45 200 20 200 60 100 50 300 10 400 40 200 30 300 10 <Прогон программы 12.4> Служащий 300 Количество проданного товара 70 Служащий 100 Количество проданного товара 95 Служащий 200 Количество проданного товара 110 Служащий 400 Количество проданного товара 40 V Итератор массива Стремясь привязать итераторы к классам списков, мы, возможно, упустили из виду класс Array. Между тем итератор массивов является весьма полезной абстракцией. Настроив итератор так, чтобы тот начинался и заканчивался на конкретных элементах, можно исключить работу с индексами. Кроме того, один и тот же массив может обрабатываться несколькими итераторами одновременно. Здесь приводится пример использования нескольких итераторов при слиянии двух отсортированных последовательностей, находящихся в одном массиве. Спецификация класса Arraylterator ОБЪЯВЛЕНИЕ #include "iterator.h" template <class T> class Arraylterator: public Iterator<T> { private: // начальная, текущая и конечная точки int startlndex;
int currentIndex; int finishlndex; // адрес объекта типа Array, подлежащего сканированию Array<T> *arr; public: // конструктор Arraylterator(Array<T>& A, int start=0, int finish=-l); // стандартные операции итератора, обусловленные базовым классом virtual void Next(void); virtual void Reset(void); virtual T& Data(void); }; ОБСУЖДЕНИЕ Конструктор связывает объект типа Array с итератором и инициализирует начальный и конечный индексы массива. По умолчанию начальный индекс равен 0 (итератор находится на первом элементе массива), а конечный индекс равен -1 (верхней границей массива является индекс последнего элемента). На любом шаге итерации currlndex является индексом текущего элемента массива. Его начальное значение равно startlndex. Класс Arraylterator находится в файле arriter.h. Класс Arraylterator имеет минимальный набор общедоступных функций- членов, подменяющих чистые виртуальные методы базового класса. ПРИМЕР // массив 50 чисел с плавающей точкой от 0 до 49 Array<double> A(50); // итератор массива сканирует А от 3-го до 10-го индекса ArrayIterator<double> arriter(Arr, 3, 10); // печатать массива с 3-го по 10-й элемент for (arriter.Reset(); !arriter.EndOfList (); arriter.Next () ) cout « arriter.Data() « " "; Приложение: слияние сортированных последовательностей В главе 14 формально изучаются алгоритмы сортировки, включая и внешнюю сортировку слиянием, которая упорядочивает файл данных на диске. Этот алгоритм разделяет список элементов на сортированные подсписки, называемые последовательностями (runs). ОПРЕДЕЛЕНИЕ В списке Х0, Хх, ..., Хп_х последовательностью является подсписок Ха, Xa+i> •••! Хъ, где Xi<Xi+i при a<i<b Xa-i>Xa при а>0 Хь+1<Хъ при Ь<п—1 Например, ПОДСПИСОК Л.2 ••• Л5 есть последовательность в массиве X X: 20 35 15 25 30 65 50 70 10
В процессе слияния последовательности вкладываются друг в друга, создавая тем самым более длинные упорядоченные подсписки до тех пор, пока в результате не получится отсортированный массив. Список А: 3 б 23 35 2 4 6 I I I I Последовательность #1 Последовательность #2 Это приложение реализует лишь очень ограниченную часть полного алгоритма. Предполагается, что данные хранятся в виде двух последовательностей в N-элементном массиве. Первая последовательность заключена в диапазоне от 0 до R-1, вторая — от R до N-1. Например, в семиэлементном массиве А последовательности разделяются на индексе R = 4. Поэлементное слияние порождает сортированный список. Текущая точка прохождения устанавливается на начало каждой последовательности. Значения в текущей точке сравниваются, и наименьшее из них копируется в массив. Когда значение в последовательности обработано, выполняется шаг вперед к следующему числу и сравнение продолжается. Поскольку подсписки изначально упорядочены, элементы копируются в выходной массив в сортированном порядке. Когда одна из последовательностей заканчивается, оставшиеся члены другой последовательности копируются в выходной массив. Этот алгоритм изящно реализуется с помощью трех итераторов: left, right и output. Итератор left проходит первую последовательность, right — вторую, a output используется для записи данных в выходной массив. Пример работы алгоритма показан на рис. 12.2. Программа 12.5. Слияние сортированных последовательностей Функция Merge получает две последовательности, расположенные в массиве А, и сливает их в выходной массив Out. Этот используют итераторы left и right, которые инициализируются параметрами lowlndex, endOfRunlndex и highlndex. Итератор output записывает отсортированные данные в Out. Процесс прекращается по достижении конца одной из последовательностей. Функция Сору дописывает данные, оставшиеся в другой последовательности, в массив Out. После сбрасывания итератора output в начальное состояние отсортированный список копируется обратно в А. Эта программа вводит 20 целых чисел из файла rundata. В процессе ввода мы сохраняем данные в массиве А и распознаем индекс конца последовательности, который потребуется функции Merge. Функция Merge сортирует массив, который затем распечатывается. ♦include <iostream.h> ♦include <fstream.h> ♦include "array.h" ♦include "агг^ег.п" // копирование одного массива в другой с помощью их итераторов void Copy(ArrayIterator<int>& Source, ArrayIterator<int>& Dest)
Шаг! Шаг 2 ШагЗ Шаг 4 Шаг 5 left right left right left right left right left right Шаг 6 Шаг 7 left right left right output output output output output right достиг конца последовательности output оставшиеся члены первой последовательности переписываются в выходной массив output ! Рис 12.2. Слияние сортированных последовательностей { while ( !Source.EndOfList() ) { Dest.DataO = Source.Data() ; Source.Next(); Dest.Next(); } } // слияние сортированных последовательностей в массиве А. // первая последовательность заключена в диапазоне индексов // lowlndex..endOfRunlndex-l, // вторая — в диапазоне endOfRunlndex..highln- dex void Merge(Array<int>& A, int lowlndex, int endOfRunlndex, int highlndex) { // массив, в котором объединяются сортированные последовательности Array<int> Out (A.ListSizeO ); // итератор left сканирует 1-ю последовательность; // итератор right сканирует 2-ю последовательность; ArrayIterator<int> left(A, lowlndex, endOfRunlndex-l); ArrayIterator<int> right(A, endOfRunlndex, highlndex);
// итератор output записывает отсортированные данные в Out ArrayIterator<int> output(Out); // копировать, пока не кончится одна или обе последовательности while (!left.EndOfList() && !right.EndOfList()) { // если элемент "левой" последовательности с итератором left меньше или // равен элемент "правой" последовательности, то записать его в массив Out. // перейти к следующему элементу "левой" последовательности if (left.DataO <= right .Data () ) { output. Data () = left.DataO; left.NextO ; ) // иначе записать в Out элемент "правой" последовательности //и перейти к следующему элементу "правой" последовательности else { output.Data() = right.Data(); right.Next(); } output.Next{); // продвинуть итератор выходного массива } // если одна из последовательностей не обработана до конца, // скопировать этот остаток в массив Out if (!left.EndOfList()) Copy(left, output); else (!right.EndOfList()) Copy(right, output); // сбросить итератор выходного массива и скопировать Out в А output.Reset() ; ArrayIterator<int> final(A); // массив для копирования обратно в А Copy(output, final); } void main(void) { // массив для сортированных последовательностей, введенных из потока fin Array<int> A(20); ifstream fin; int i; int endOfRun = 0; // открыть файл rundata fin.open("rundata", ios::in | ios::nocreate); if (!fin) { cerr « "Нельзя открыть файл rundata", « endl; exit(1); } // читать 20 чисел, представленных в виде двух // сортированных последовательностей fin » А[0]; for (i=l; i<20; i++) { fin » A[i] ; if (A[i] < A[i-1]) endOfRun = i; }
// слияние последовательностей Merge(A, 0, endOfRun, 19); // распечатать отсортированный массив по 10 чисел в строке for (i»0; i<20; i++) { cout « A[iJ « " "; if (i -- 9) cout « endl; } } /* <Файл rundata> 1 3 6 9 12 23 33 45 55 68 88 95 2 8 12 25 33 48 55 75 <Выполнение программы 12.5> 1 2 3 6 8 9 12 12 23 25 33 33 45 48 55 55 68 75 88 95 V Реализация класса Arraylterator Конструктор задает начальное состояние итератора. Он привязывает итератор к массиву и инициализирует три индекса. Если для индексов startlndex и finishlndex используются значения по умолчанию (0 и -1), то итератор проходит через весь массив. // конструктор, инициализирует базовый класс и данные-члены template <class T> ArrayIterator<T>::Arraylterator(Array<T>& A, int start, int finish): arr(&A) { // последний доступный индекс массива int ilast » A.ListSizet) - 1; // инициализировать индексы, если finish ■■ -1, //то сканируется весь массив currentlndex - startlndex - start; finishlndex e finish !« -1 ? finish : ilast; // индексы должны быть в границах массива if (!<(startlndex>«0 && startlndex<-ilast) && (finishIndex>-0 && finishlndex<~ilast) && (startlndex <= finishlndex))) { cerr « "Arraylterator: Неверные параметры индекса!" « endl; exit(1); } } Reset переустанавливает текущий индекс на стартовую точку и обнуляет iterationComplete, показывая тем самым, что начался новый процесс прохождения. // сброс итератора массива template <class T> void ArrayIterator<T>::Reset(void)
{ // установить текущий индекс на начало массива currentlndex « startlndex; // итерация еще не завершена iterationComplete = 0; } Метод Data использует currentlndex для доступа к данным-членам. Если текущая точка прохождения заходит за верхнюю границу списка, генерируется сообщение об ошибке и программа прекращается. // возвратить значение текущего элемента массива template <class T> Т& ArrayIterator<T>::Data(void) { // если весь массив пройден, то вызов метода невозможен if (iterationComplete) { cerr « "Итератор прошел весь список до конца!" « endl; exit(1); } return (*arr) [currentlndex]/ } Если итерация завершается, метод Next просто возвращает управление. В противном случае он увеличивает currentlndex и обновляет логическую переменную базового класса iterationComplete. // перейти к следующему элементу массива template <class T> void ArrayIterator<T>::Next (void) { // если итерация не завершена, увеличить currentlndex // если пройден finishlndex, то итерация завершена if (!iterationComplete) < currentIndex++; if (currentlndex > finishlndex) iterationComplete ■ 1; } } 12.6. Упорядоченные списки Класс SeqList создает список, элементы которого добавляются в хвост. В результате получается неупорядоченный список. Однако во многих приложениях требуется списковая структура с таким условием включения, при котором элементы запоминаются в некотором порядке. В этом случае приложение сможет эффективно определять наличие того или иного элемента в списке, а также выводить элементы в виде отсортированных последовательностей. Чтобы создать упорядоченный список, мы используем класс SeqList в качестве базового и образуем на его основе класс OrderedList, который вставляет элементы в возрастающем порядке с помощью оператора "<". Это пример наследования в действии. Мы переопределяем только метод Insert, поскольку все другие операции не влияют на упорядочение и могут быть унаследованы от базового класса.
Спецификация класса OrderedLlst ОБЪЯВЛЕНИЕ #include "seqlist2.h" template <class T> class OrderedList: public SeqList<T> { public: // конструктор OrderedList(void); // подменить метод Insert для формирования упорядоченного списка virtual void Insert (const t& item) ; }; ОПИСАНИЕ Все операции, за исключением Insert, взяты из SeqList, так как они не влияют на упорядочение. Поэтому должен быть объявлен только метод Insert, чтобы подменить одноименный метод из SeqList. Эта функция сканирует список и включает в него элементы, сохраняя порядок. Класс OrderedList находится в файле ordlist.h. Реализация класса OrderedList В классе OrderedList определяется конструктор, который просто вызывает конструктор класса SeqList. Тем самым инициализируется этот базовый класс, а он в свою очередь инициализирует свой базовый класс List. Мы имем пример трех-классовой иерархической цепи. // конструктор, инициализировать базовый класс template <class T> OrderedList::OrderedList(void): SeqList<T>() {} В этом классе определяется новая функция Insert, которая включает элементы в подходящее место списка. Новый метод Insert использует встроенный в класс LinkedList механизм поиска первого элемента, большего, чем включаемый элемент. Метод InsertAt используется для включения в связанный список нового узла в текущем месте. Если новое значение больше, чем все имеющиеся, оно дописывается в хвост списка. Метод Insert отвечает за обновление переменной size, определенной в базовом классе List. // вставить элемент в список в возрастающем порядке template <class T> void OrderedList::Insert(const T& item) { // использовать механизм прохождения связанных списков // для обнаружения места вставки for( Hist. Reset (); ! Hist. EndOf List () ; Hist.NextO ) if (item < Hist.DataO ) break; // вставить item в текущем месте Hist. InsertAt (item); size++; ) Приложение: длинные последовательности. В программе 12.5 описана часть алгоритма сортировки слиянием, который включал слияние двух сор-
тированных последовательностей в одну, тоже сортированную. В программе предполагалось, что ваши входные данные уже заранее разбиты на две последовательности. Сейчас мы обсудим методику фильтрации (предварительной обработки) данных для получения более длинных последовательностей. Предположим, что большой блок данных хранится в случайном порядке в массиве или на диске. Тогда эти данные можно представить в виде ряда коротких последовательностей. Например, следующее множество из 15-и символов состоит из восьми последовательностей. CharArray: [a k] [g] [с m t] [e n] [1] [с г s] [с b f] Попытка использовать сортировку слиянием для упорядочения этих данных была бы тщетной ввиду значительного числа коротких последовательностей, подлежащих объединению. В нашем примере четыре слияния дают следующие последовательности. [a g к] [с е m t] [с 1 г s] [b с f] Сортировка слиянием предписывает объединить на следующем проходе эти четыре последовательности в две и затем создать полностью отсортированный список. Алгоритм работал бы лучше, если бы изначально последовательности имели разумную длину. Этого можно достичь путем сканирования элементов и объединения их в сортированные подсписки. Алгоритм внешней сортировки должен противостоять относительно медленному времени доступа к диску и часто включает в себя фильтр для предварительной обработки данных. Мы должны постараться, чтобы время, затраченное на фильтрацию данных, повышало бы общую эффективность алгоритма. Упорядоченный список является примером простого фильтра. Предположим, что исходный массив или файл содержит N элементов. Мы вставляем каждую группу из к элементов в некоторый упорядоченный список, а затем копируем этот список обратно в массив. Этот фильтр гарантирует, что последовательности будут иметь длину, по крайней мере, к. Например, пусть к=5, и мы обрабатываем данные массива CharArray. Тогда результат будет таким: {а с g k m] [с е 1 n t] [b с £ г s] Усовершенствованная версия этого фильтра приводится в гл. 13. Программа 12.6. Длинные последовательности Эта программа фильтрует массив 100 случайных целых чисел в диапазоне от 100 до 999 в последовательности, по крайней мере, из 25 элементов, используя упорядоченный список. Каждой новое случайное число вставляется в объект L типа OrderedList. Для каждых 25 элементов функция Сору удаляет эти элементы из списка L и вставляет их обратно в массив А. Программа заканчивается печатью результирующего массива А. #include <iostream.h> #include "ordlist.h" #include "array.h" #include "arriter.h" #include "random.h" // пройти целочисленный массив и распечатать каждый элемент // по 10 чисел в строке
void PrintList(Array<int>& A) { // использовать итератор массива ArrayIterator<int> iter(A); int count; // прохождение и печать списка count * 1; for(iter.Reset(); !iter.EndOfList(); iter.NextO, count++) { cout « iter.Data() « " "; // печатать по 10 чисел в строке if (count % 10 -= 0) cout « endl; } } // удалять элементы из упорядоченного списка L и вставлять, их в массив А. // обновить loadlndex, указывающий следующий индекс в А void Copy(OrderedList<int> &L, Array<int> &A, int &loadIndex) { while (IL.ListEmpty()) A[loadIndex++] =» L.DeleteFront(); ) void main(void) { // создать последовательности в А с помощью упорядоченного списка L Array<int> А(100); OrderedList<int> L; // генератор случайных чисел RandomNumber rnd; int i, loadlndex = 0; // сгенерировать 100 случайных чисел в диапазоне от 100 до 999. // отфильтровать их через 25-элементный упорядоченный список. // после заполнения списка копировать его в массив А for (i-1; i<«100; i++) { L.Insert(rnd.Random(900) + 100); if (i % 25 — 0) Copy(L, A, loadlndex); ) // печатать итоговый массив А PrintList(A); } /* <Выполнение программы 12.б> 110 500 850 205 513 296 725 940 343 641 116 532 903 216 524 375 728 990 368 739 149 578 929 221 604 412 771 991 372 774 152 601 947 243 634 437 799 992 434 784 162 715 958 287 641 457 803 994 443 829 240 730 105 348 730 466 815 101 489 875 345 732 132 350 784 507 859 118 515 883 370 754 139 445 940 550 879 123 529 922 422 815 139 466 969 594 909 155 557 967 492 833 190 507 982 652 915 310 574 972 */
12.7. Разнородные списки Коллекция, хранящая объекты одинакового типа называется однородной (homogeneous). До сих пор мы рассматривали только однородные коллекции. Коллекция, содержащая объекты различных типов, называется разнородной (heterogeneous). Поскольку типы данных в C++ определяются в момент компиляции, мы должны представить новую методику для реализации разнородных коллекций. В данном разделе мы реализуем разнородные массивы и связанные списки, предполагая, что все имеющиеся там объекты образованы от общего для всех них базового класса. Разнородные массивы Всеобъемлющее обсуждение разнородных массивом выходит за рамки данной книги. Мы ограничимся массивами указателей на объекты различных типов. Рассмотрим еще раз пример из гл. 1, иллюстрирующий полиморфизм. Ради удобства сформулируем его снова. Имеется набор базовых операций, необходимых для покраски любого дома. Дома различных типов требуют различной технологии покраски. Например, деревянные стены можно шлифовать, а пластиковую облицовку можно мыть. В контексте объектно-ориентированного программирования эти дома представляют собой различные классы, образованные от базового класса (House) дом, который содержит общие для всех операции покраски. Технология покраски (метод Paint) ассоциируется с каждым классом. Дом (House) Paint Деревянный дом Оштукатуренный дом Дом с пластиковой облицовкой Paint Paint Paint Базовый класс House содержит идентификационную строку "дом" и виртуальный метод Paint, который распечатывает ее. Каждый производный класс подменяет метод Paint и показывает тип дома, подлежащего покраске. // базовый класс в иерархии технологий покраски домов class House { private: String id; // идентификатор дома public: // конструктор, присвоить идентификатору дома значение "дом" House (void) { id - "дом"; }
// виртуальный метод, печатает символьную строку "дом" virtual void Paint(void) { cout « id; } ); Каждый производный класс содержит символьную строку, идентифицирующую тип дома. Виртуальный метод Paint распечатывает эту строку и вызывает базовый метод Paint. Объявление класса WoodFrameHouse приводится в качестве модели. Полное описание классов домов находится в файле houses.h. class WoodFrameHouse: public House { private: // идентификатор дома String id; public: // конструктор. WoodFrameHouse(void): House() { id - "деревянный" } // виртуальный метод, распечатывает id // и вызывает Paint базового класса virtual void Paint(void) < cout « "Покрасить " « id « " "; House::Paint(); } }; Чтобы описать понятие разнородного массива, определим массив contractor List (подрядчики), состоящий из пяти указателей на базовый класс House. Массив инициализируется посредством случайной выборки В массив заносится случайная выборка объектов, имеющих тип WoodFrameHouse (деревянный), StuccoHouse (оштукатуренный) или VinylSidedHouse (пластиковый). Например, такая: contractorList VinylSidedHouse WoodFrameHouse StuccoHouse WoodFrameHouse VinylSidedHouse Можно рассматривать этот массив указателей как список адресов пяти домов подлежащих покраске. Подрядчик распределяет работы по бригадам. В нашем примере подрядчик дает каждой бригаде адрес дома и полагает, что они сообразят, как покрасить дом, когда увидят, какого он типа.
Программа 12.7. Разнородный массив Эта программа проходит массив contractorList и вызывает метод Paint для каждого объекта. Поскольку каждый объект адресуется указателем, динамическое связывание гарантирует, что будет выполнен именно тот Paint, который нужен. Это соответствует выписыванию нарядов на малярные работы. #include <iostream.h> ♦include "random.h" // датчик случайных чисел #include "houses.h" // иерархия покрасочных технологий void main(void) { // динамический список адресов объектов House *contractorList[5]; RandomNumber rnd; // построить список пяти домов, подлежащих покраске for (int i=0; i<5; i++) // выбрать случайным образом дом типа 0, 1 или 2. // создать объект и занести его адрес в contractorList switch(rnd.Random{3)) { case 0: contractorList[i] ■ new WoodFrameHouse; break; case 1: contractorList[i] - new StuccoHouse; break; case 2: contractorList[i] ■ new VinylSidedHouse; break; } // покрасить дома с помощью метода Paint, поскольку он виртуальный, // используется динамическое связывание и вызывается правильный метод for (i-0; i<5; i++) contractorList[i]->Paint(); } /* <Прогон программы 12.7> Покрасить деревянный дом Покрасить оштукатуренный дом Покрасить пластиковый дом Покрасить оштукатуренный дом Покрасить деревянный дом */ Разнородные связанные списки Как и в разнородных массивах, каждый объект в разнородном списке образован от общего для всех базового класса. Каждая базовая составляющая объекта содержит указатель на следующий объект в списке. Благодаря полиморфизму указатель используется для выполнения методов производного объекта, невзирая на его тип. Проиллюстрируем эти понятия на связанном списке геометрических объектов, образованных от варианта класса Shape.
Спецификация класса NodeShape ОБЪЯВЛЕНИЕ #inclucie "graphlib.h" class NodeShape { protected: // координаты базовой точки, образец заполнения //и указатель на следующий узел float х, у; int fillpat; NodeShape *next; public: // конструктор NodeShape(float h=0, float v=0, int fill=0); // виртуальная функция рисования virtual void Draw(void) const; // методы обработки списков void InsertAfter(NodeShape *p); NodeShape *DeleteAfter(void); NodeShape *Next(void); >; ОПИСАНИЕ Координаты (х,у) задают базовую точку для производного объекта, который должен быть нарисован и заштрихован по образцу fillpat. Метод Draw инициализирует образец заполнения в графической системе и указатель next, указывающий на следующий объект типа NodeShape в связанном списке. Методы InsertAfter и DeleteAfter поддерживают кольцевой список посредством включения или удаления узла, следующего за текущим. Метод Next возвращает указатель на следующий узел. Класс NodeShape находится в файле shapelst.h. Реализация класса NodeShape Реализация класса NodeShape сделана по образцу класса CNode (см. гл. 9). Так как предполагается кольцевой список, конструктор должен создать начальный узел, который указывает на самого себя. х, У fillpat next // конструктор, задает начальные значения базовой точки, // образца заполнения и указателя next NodeShape::NodeShape(float h, float v, int fill): x(h), y(v), fillpat(fill) { next = this; }
Образование связанных геометрических классов. Геометрические, классы CircleFigure и RectangleFigure являются производными от класса NodeShape. В дополнение к методам базового класса они содержат метод Draw, перекрывающий виртуальный метод Draw базового класса. Методы Area и Perimeter не включаются. Мы используем класс CircleFigure для иллюстрации понятий. // Класс CircleFigure, образованный от класса NodeShape class CircleFigure: public NodeShape { protected: // радиус окружности float radius; public: // конструктор CircleFigure(float h, float v, float r, int fill); // виртуальная функция рисования окружности virtual void Draw(void) const; }; // конструктор, инициализирует базовый класс и радиус CircleFigure::CircleFigure(float h, float v, float r, int fill): NodeShape(h, v, fill), radius(r) {} // задать образец заполнения посредством вызова базового метода Draw //и нарисовать окружность void CircleFigure::Draw(void) const { NodeShape::Draw(); DrawCircle(x, y, radius); } Мы также включили в файл shapelst.h новый геометрический класс Right- Triangle, который описывает прямоугольный треугольник с помощью координат самой левой точки его гипотенузы, базы и высоты. высота база Чтобы сформировать связанный список, объявим заголовок, имеющий тип NodeShape и имя listHeader. Начиная с этого заголовка, будем динамически создавать узлы и с помощью InsertAfter включать их в список последовательно друг за другом. Например, следующая итерация создает четырехэле- ментный список, в котором чередуются объекты-окружности и объекты-треугольники: listHeader next Объект Shape —► next Объект Circle —► next Объект RightTriangle —► next Объект Circle —► next Объект RightTriangle
// заголовок списка и указатель для создания нового списка NodeShape listHeader, *р; float x, у, radius, height; // установить р на начало списка р = slistHeader; // включить 4 узла в список for (int i-0; i<4; i++) { // координаты базовой точки cout « "Введите х и у: "; cin » х » у; if (i % 2 « 0) // если i четное, добавить окружность { cout « "Введите радиус окружности: "; cin » radius; // включить объект с заполнением i в список p->InsertAfter(new Circle(х,у,radius,i)); } else // если i нечетное, добавить прямоугольный треугольник { cout « "Введите базу и высоту для прямоугольного треугольника: "; cin » base » height; p->InsertAfter(new RightTriangle(x,y,radius,i)); } // передвинуть р на только что созданный узел р - p->Next(); } Динамическое связывание имеет принципиальное значение во время прохождения списка и визуального отображения содержащихся в нем объектов. В приведенном ниже фрагменте кода указатель р указывает либо на объект типа Circle, либо на объект типа RightTriangle. Поскольку Draw является виртуальной функцией, выполняется метод Draw того или иного производного класса. р = listHeader.Next(); while (p != slistHeader) { p->Draw(); p - p->Next(); } Теперь мы готовы поставить задачу создания и управления разнородными списками целиком. Программа 12.8. Разнородные списки Эта программа создает связанный список, состоящий из объектов типа Circle, Rectangle и RightTriangle. Файл figures содержит элементы этого списка в следующем формате: <фигура> <координаты базовой точки> <параметры фигуры> Фигура описывается буквой с (окружность), г (прямоугольник) или t (прямоугольный треугольник). Координатами базовой точки является пара чисел с плавающей точкой. Параметрами являются окружность или стороны. Ниже приводится пример входных записей.
с 0.5 0.5 0.25 // окружность с центром (1/2, 1/2) и радиусом 1/4 г 1.0 0.25 .5 .5 // прямоугольник с базовой точкой (1, 1/4) // и сторонами 1/2, 1/2 t 2.0 0.75 .25 .5 // прямоугольный треугольник с базовой точкой (2, 3/4) // и сторонами 1/4, 1/2 Программа читает файл и формирует связанный список геометрических объектов. В процессе прохождения списка фигуры отображаются визуально. #include <iostream.h> #include <fstream.h> #include <stdlib.h> #include "graphlib.h" #include "shapelst.h" void main(void) { // listHeader — заголовок кольцевого списка форм NodeShape listHeader, *p, *nFig; // фигуры: с (окружность), г (прямоугольник), t (прямоугольный треугольник) char figType; // начальный образец заполнения — нет заполнения int pat = 0; float x, у, radius, length, width, tb, th; // входной поток fin ifstream fin; // открыть файл figures, содержащий описания фигур fin.open("figures", ios::in | ios::nocreate); if (!fin) { cerr « "Нельзя открыть файл figures" « endl; exit(1); } // установить р на начало списка p = slistHeader; // прочитать файл до конца и построить связанный список фигур while (!fin.eof()) { // ввести тип фигуры и координаты базовой точки fin » figType; if (fin.eofO) break; fin » x » y; // построить конкретную фигуру switch(figType) { case 'c': // ввести радиус и включить окружность в список fin » radius; nFig = new CircleFigure(x,у,radius,pat); p->InsertAfter(nFig); break; case 'r': // ввести длину и ширину и включить прямоугольник в список fin » length » width;
nFig ■ new RectangleFigure(x,у,length,width,pat); p->InsertAfter(nFig); break; case 't' : // ввести базу и высоту и включить прямоугольный треугольник fin » tb » th; nFig = new RightTriangleFigure(x,у,tb,th,pat); p->InsertAfter(nFig); break; ) // сменить образец заполнения, продвинуть указатель pat « (pat+1) % 12; р - p->Next(); } // инициализировать графическую систему InitGraphics(); // начиная с 1-й фигуры, пройти по списку и нарисовать каждую фигуру р - listHeader.Next(); while (p Iя* &listHeader) { p->Draw(); р = p->Next(); } // организовать паузу для просмотра фигур и закрыть графическую систему ViewPause(); ShutdownGraphics() ; } /* <Прогон программы 12.8> <см. график> */ Письменные упражнения 12.1 а) По образцу зоологической иерархии из раздела 12.1 постройте иерархическое дерево для следующих понятий:
Транспортное средство, автомобиль, дизель, газ, самолет, электромобиль, турбовинтовой, реактивный. б) Задайте базовые классы для классов "Электромобиль" и "Реактивный". в) Перечислите все классы, являющиеся одновременно и базовыми, и производными. г) Какие классы образованы от класса "Транспортное средство"? 12.2 Пусть даны следующие объявления class BASE { private: Base_Priv; protected: Base_Prot; public: Base_Pub }; class DERIVED: public BASE { private: Derived_Priv; protected: Derived_Prot; public: Derived_Pub }; а) Представленные базовый и производный класс содержат закрытые, защищенные и открытые члены. Заполните приведенную ниже таблицу, показав тем самым права доступа клиента или объекта к этим членам. Крестик в строке говорит о том, что объект имеет доступ к члену класса. Например, представитель производного класса имеет доступ к защищенным членам базового класса, а клиент может обращаться к открытым членам базового класса. BASE DERIVED КЛИЕНТ Base_Priv Base_Prot X Base_Pub X Derived_Priv Derived _Prot Derived_Pub б) Класс может быть образован с использованием закрытого наследования. В этом случае открытые и защищенные члены базового класса являются доступными для производного класса. Однако для клиента производного класса (программы, использующей данный производный класс) открытые члены базового класса считаются закрытыми и недоступны. Этот тип наследование иногда применяется, когда базовый класс служит просто связующим звеном для производных классов. Заполните таблицу для этого типа наследования. BASE DERIVED КЛИЕНТ Base_Priv Base_Prot Base_Pub Derived _Priv Derived_Prot Derived _ Pub
12.3 Дана схема класса Base. Укажите ошибки в объявлениях производных классов. class Base < public: Base (int a, int b); ■ • • }; а) class DerivedCLl: public Base { private: int q; public: DerivedCLl (int z): q(z); {} • • • }; б) class DerivedCL2: public Base { private: • • • r public: // DerivedCL2 не имеет конструктора • • • }; 12.4 Даны следующие схемы базового и производного классов: class BaseCL { protected: int datal; int data2; public: BaseCL(int a, int b=0): datal (a), data2(b) {} BaseCL(void): datal(0), data2(0) {} • • • }; class DerivedCL { private: int data3; public: // Конструктор #1 DerivedCL(int a, int b, int c=0)/ // Конструктор #2 DerivedCL(int a); • * • };
а) Напишите конструктор #1 таким образом, чтобы а предназначалось производному классу, а Ъ и с — базовому. б) Напишите конструктор #2 таким образом, чтобы а предназначалось производному классу и при этом использовался конструктор базового класса, действующий по умолчанию. в) Подразумевая определение конструктора класса DerivedCL, покажите значения datal, data2 и data3 в следующих объектах: DerivedCL obj1(1,2), obj2(3,4/5), obj3(8); 12.5 Следующая программа иллюстрирует порядок выполнения конструкторов и деструкторов в цепочке наследования. У каждого из трех классов Basel, Base2 и Derived есть конструктор и деструктор. Покажите, что выдаст эта программа на выходе. tinclude <iostream.h> class Basel { public: Basel(void) { cout « "Вызван конструктор Basel." « endl; } -Basel(void) { cout « "Вызван деструктор Basel." « endl; } }; class Base2 { public: Base2(void) { cout « "Вызван конструктор Base2." « endl; } ~Base2(void) { cout « "Вызван деструктор Base2." « endl; } }; class Derived: public Basel, public Base2 { public: Derived(void): Basel(), Base2() { cout << "Вызван конструктор Derived." « endl; } -Derived(void) { cout « "Вызван деструктор Derived." « endl; } }; void main(void) { Derived objD;
{ Basel objBl; { Base2 objB2; } } } 12.6 Дана следующая цепочка наследования: class Base { • • • public: void F(void); void G(int x); • • • }; class Derived: public Base { • • * public: void F(void); void G(float x); • • • }; void Derived::G(float x) { • • • Base::G(10); // использование оператора спецификации области действия • • • >; Рассмотрим объявление Derived OBJ; а) Как клиент обращается к функции F базового класса? б) Как клиент обращается к функции F производного класса? в) Как компилятор будет реагировать на оператор OBJ.G(20)? Замечание: Как было показано в разделе 12.5, нежелательно перекрывать невиртуальные функции базового класса. 12.7 В разделе 12.2 была построена цепочка наследования, состоящая из абстрактного базового класса Shape и класса Circle. Приведенная ниже программа использует эти классы. Прочитайте программу и ответьте на вопросы, приведенные далее. #include <iostreara.h> #include "graphlib.h" #include "geometry.h" void main(void) { Circle C; C.SetPoint(l,2);
C.SetRadius(0.5); cout « C.GetXO « " " « C.GetYO « endl; C.SetPoint(C.GetX{), 3) ; cout « C.GetXO « " " « C.GetYO « endl; C.SetFill(ll); InitGraphics О; С.Draw(); ViewPause (); ShutdownGraphics() ; } а) Почему функцию GetX можно вызывать из производного класса? б) Почему к х можно обращаться из метода Draw? в) Что будет на выходе этой программы? г) Почему оператор C.SetPoint(3,5); является допустимым, а операторы С.х = 3; Су « 5; нет? 12.8 Что будет на выходе этой программы? #include <iostream.h> #include <string.h> class Base { private: char msg[30]; protected: int n/ public: Base (char s[], int m=0) : n(m) { strcopy(msg, s); } void output(void) { cout « n « endl « msg « endl; } }; class Derivedl: public Base { private: int n; public: Derivedl(int m«l): Base ("Base", m-1), n(m) {} void output(void) { cout « n « endl; Base::output();
} }; class Derived2: public Derivedl { private: int n; public: Derived2(int m=2): Derivedl (m-1), n(m) {} void output(void) { cout « n « endl; Derivedl::output(); } ); void main(void) { Base B("Base Class", 1); Derived2 D; В.output(); D.output (); } 12,9 Почему методы Area и Perimeter класса Shape являются чистыми виртуальными функциями? 12.10 Даны следующие объявления классов: class Base { private: int x,y; * • • }; class Derived: public Base { private: int z; • * • ); Рассмотрим объявления Base В; Derived D; а) Допустимо ли присвоение В = D; Почему? Проиллюстрируйте свой ответ картинкой. б) Допустимо ли присвоение D = В; Почему? Проиллюстрируйте свой ответ картинкой. 12.11 Даны следующие классы:
class BaseCL { protected: int one/ public: BaseCL(int a): one(a) {} virtual void Identify(void) { cout << one « endl; } ); class DerivedCL: public BaseCL { protected: int two; public: DerivedCL(int a, int b) : BaseCL(a), two(b) {} virtual void Identify(void) { cout « one « " " « two « endl; } }; и функции: void Announce1(BaseCL x) x.Identify(); void Announce2(BaseCL& x) x.Identify(); void АппоипсеЗ(BaseCL *x) x->Identify(); Покажите, что будет на выходе следующего фрагмента кода: BaseCL A<7), *р, *arr[3]; DerivedCL В(3,5), С(2,4); Announce1(А); Announce1(С); Announce2(В); АппоипсеЗ(&С); р = &С; p->Identify(); for (int i=0; i<3; i++) if (i—1) arr[i] = new BaseCL(7); else arr[i] * new DerivedCL(i, i+1); for (i=0; i<3; i++) arr[i]->Identify();
12.12 Объясните, почему деструктор должен объявляться виртуальным в любом классе, который может служить базовым. 12.13 Разработайте абстрактный базовый класс StackBase, в котором объявляются стековые операции Push, Pop, Peek и StackEmpty. Базовый класс должен содержать защищенную целочисленную переменную nu- mElements и метод StackEmpty, возвращающий значение этой переменной. Производный класс Stack должен увеличивать numElements с каждой операцией Push и уменьшать ее с каждой операцией Pop. Реализуйте производный класс Stack двумя разными способами: с помощью массива и с помощью связанного списка. 12.14 Выполните предыдущее упражнение для абстрактного класса Queue- Base, описывающего очередь. Этот класс должен содержать как минимум один метод, не являющийся чистой виртуальной функцией. 12.15 Что такое итератор? Почему итератор часто должен быть дружественным по отношению к классу, элементы которого он обрабатывает? Как понимать то, что итератор является абстракцией управления? 12.16 Разработайте класс Queue, образуя его от абстрактного класса Queue- Base (см. упр. 12.14) и используя объект типа SeqList. Образуйте из класса SeqListlterator класс Queuelterator и сделайте его дружественным по отношению к QueueBase. Для этого нужен лишь конструктор. 12.17 Напишите функцию template <class T> Т GetRear(Queue<T>& q); которая возвращает последний в очереди элемент. Если очередь пуста, выдайте сообщение об ошибке и завершите программу. Используйте Queuelterator, разработанный в предыдущем упражнении. 12.18 Пусть имеется массив символьных строк. С помощью Arraylterator просканируйте массив и замените все символы табуляции четырьмя пробелами. 12.19 Напишите функцию void RemoveDuplicates(Array<int>& A); которая удаляет из массива все дубликаты данных и соответствующим образом изменяет размер объекта. Например, если исходный массив А имеет 20 элементов А - {1, 3, 5, 3, 2, 3, 1, 4, 6, 3, 5, 4, 2, 6, 7, 8, 1, 3, 9, 7}, то после вызова RemoveDuplicates А « {1, 3, 5, 2, 4, 6, 7, 8, 9} (A.ListSizeO = 9) 12.20 Напишите функцию template <class T> Т Max(Iterator<T>& colllter); которая ищет максимальное значение среди данных в той коллекции, для которой существует итератор colllter. Предполагается, что оператор
">" определен для типа Т. Заметьте, что эта функция использует тот факт, что методы итератора являются виртуальными. 12.21 Покажите, как можно использовать виртуальные функции для формирования массива указателей на объекты Circle и Rectangle (разнородный массив), а также для прохождения массива и распечатки площади и периметра фигур. Упражнения по программированию 12.1 Реализуйте цепочку наследования из письменного упражнения 12.1. Каждый конструктор класса должен содержать метод Identify, распечатывающий информацию о своем базовом классе и о себе самом. Напишите тестовую программу, в которой объявляются объекты каждого типа. 12.2 Из прямоугольных поверхностей можно построить короб. длина высота ширина ширина длина Напишите классы Rectangle и Box, которые реализуют эту иерархию. Класс Rectangle имеет методы для вычисления площади и объема, причем в последнем случае всегда возвращается нулевой объем. Класс Box также имеет методы для вычисления площади и объема. Испытайте эти классы в главной процедуре, которая запрашивает тип фигуры и ее размеры. Определите объект каждого типа и распечатайте площадь и объем фигуры. 12.3 Из класса SeqList образуйте производный класс MidList с помощью следующего объявления: template <class T> class MidList: public SeqList<T> { public: <Конструктор> virtual void Insert(const T& elt); virtual void Delete(const T& elt); };
Метод Insert включает elt в середину списка. Метод Delete удаляет элемент из середины списка. При этом подразумевается, что пользователь сам контролирует размер списка и гарантирует, что хотя бы один элемент там есть. Реализуйте класс Midlist и используйте его в следующей тестовой программе: Ввести пять целых чисел и включить их в список с помощью Insert. Распечатать список. Удалить два числа из списка. Еще раз распечатать список и его размер. 12.4 В этом упражнении требуется разработать иерархическую структуру для задачи обработки данных. Класс Employee содержит элементы данных name (имя) и ssn (номер страховки), конструктор и операцию PrintEmployeelnfo, которая распечатывает поля name и ssn. Эти данные связаны с информацией о служащих с постоянными окладами. Производный класс SalaryEmployee содержит поле salary (месячный оклад) и операцию PrintEmployeelnfo, которая распечатывает данные как из базового, так и из производного класса. В дополнение к информации, имеющейся в базовом классе Employee, данные по временным сотрудникам включают в себя почасовую ставку и количество отработанных в данном месяце часов. Эта информация хранится в классе TempEm- ployee, который состоит из элементов hourlypay и hours worked и операции PrintEmployeelnfo. Реализуйте эту иерархию и поместите в файл employee.h. Напишите главную процедуру, в которой объявляются объекты для окладников и почасовиков и для каждого объекта вызывается PrintEmployeelnfo. Класс Employee name ssn Данные Операции Employee Класс SalaryEmployee I PrintEmployeelnfo Класс TempEmployee Данные salary Операции SalaryEmployee PrintEmployeelnfo Данные hourlypay hoursworked Операции TempEmployee PrintEmployeelnfo 12.5 Создайте новую реализацию класса Array как класса, образованного от класса List. При этом вы должны столкнуться с важной проблемой структурирования. Базовый класс List содержит ряд чистых виртуальных методов, которые обязаны перекрываться в производном классе Array. Некоторые из них, возможно, не имеют смысла для производного класса. Следующая таблица показывает проблемные методы и поможет вам при повторном определении.
ListSize Возвращает число элементов объекта Array ListEmpty Возвращает False, поскольку предполагается, что массив никогда не пуст ClearList Ошибка! Операция не имеет смысла для массивов Find Выполняет последовательный поиск элемента данных Insert Ошибка! Массив есть структура прямого доступа. Операция вставки неопределена для массивов. Delete Ошибка! Операция удаления элемента неопределена для массивов. Проверьте свою реализацию, запустив программу 12.5. 12.6 Используйте любую реализацию класса Stack, разработанную в письменном упражнении 12.13, для чтения символьной строки и распознавания палиндрома. 12.7 В этом упражнении используются классы Queue и Queuelterator, разработанные в письменных упражнениях 12.14 и 12.16. В тестовой программе вводите список целых чисел, пока не встретите 0. Попутно вставляйте положительные числа в одну очередь, а отрицательные — в другую. Используйте объекты Queuelterator для сканирования и распечатки обеих очередей. 12.8 Напишите программу для тестирования функции GetRear, разработанной в письменном упражнении 12.17. 12.9 В главной программе введите несколько строк из файла и каждую из них запишите в массив символьных строк. Используйте Array Iterator для сканирования массива, в ходе которого все символы табуляции заменяются четырьмя пробелами. Распечатайте модифицированные строки. Обратите внимание, что в этом упражнении используется результат письменного упражнения 12.18. 12.10 Проверьте функцию RemoveDuplicates, реализованную вами в письменном упражнении 12.19, на следующей главной программе. void main(void) { Array<int> A(20); int data[] = {1, 3, 5, 3, 2, 3, 1, 4, 6, 3, 5, 4, 2, 6, 7, 8, 1, 3, 9, 7); for (int i=0; i<20; i++) A[i] = data[i]; RemoveDuplicates(A); for (i=0; i<A.ListSize(); i++) cout « A[i] « " "; cout « endl; } /* <Прогон программы> 135246789 */ 12.11 Определите объект Array<int>, содержащий целые числа 1..10, и объект SeqList<char>, содержащий буквы 'а\.'е\ С помощью функции
Мах из письменного упражнения 12.20 распечатайте максимальное значение для каждого из списков. 12.12 Добавьте методы Area и Perimeter в классы NodeShape, CircleFigure, RectangleFigure и RightTriangleFigure (см. раздел 12.7). В базовом классе NodeShape определите методы для возврата нуля. По образцу программы 12.8 разработайте программу, которая создает разнородный список производных объектов. Программа должна проходить по этому списку и распечатывать площадь и периметр каждой фигуры. На втором проходе должны быть нарисованы сами фигуры.
глава 13 Более сложные нелинейные структуры 13.1. Бинарные деревья, представляемые массивами 13.2. Пирамиды 13.3. Реализация класса Heap 13.4. Приоритетные очереди 13.5. AVL-деревья 13.6. Класс AVLTree 13.7. Итераторы деревьев 13.8. Графы 13.9. Класс Graph Письменные упражнения Упражнения по программированию
В этой главе мы продолжим изучение бинарных деревьев и познакомимся с новыми нелинейными структурами. В гл. 11 деревья представлялись в виде динамически порождаемых узлов. В настоящей же главе описываются деревья, которые моделируют массивы в виде законченных бинарных деревьев. Они используются в приложениях, связанных с пирамидальными структурами и турнирной сортировкой. Мы подробно остановимся на пирамидах и рассмотрим их применение в пирамидальной сортировке и очередях приоритетов. Деревья бинарного поиска реализуют списки и обеспечивают среднее время поиска порядка 0(log2n). Однако на несбалансированных деревьях эффективность поисковых алгоритмов снижается. Мы рассмотрим новый тип деревьев, называемых сбалансированными или AVL-деревьями1, в которых поддерживаются хорошие поисковые характеристики бинарного дерева. В гл. 12 были представлены итераторы, с помощью которых реализованы классы SeqListlterator и Arraylterator. В данной главе концепция итераторов распространяется на деревья и графы. Это мощное средство сканирования позволяет осуществлять прохождение нелинейных структур с помощью простых методов, применяемых обычно к линейным спискам. Здесь мы разрабатываем симметричный итератор дерева, который расширяет возможности деревьев. Это используется для реализации алгоритма сортировки с помощью дерева. Обобщением иерархической структуры является граф, который состоит из вершин и ребер, соединяющих вершины. Графы — важный раздел дискретной математики. Они играют основную роль в целом ряде классических алгоритмов, широко применяющихся в исследовании операций. Эта глава завершается изложением основ теории графов и разработкой класса Graph, который будет использоваться во многих приложениях. 13.1. Бинарные деревья, представляемые массивами В гл. 11 для построения бинарных деревьев мы используем узлы дерева. Каждый узел имеет поле данных и поля указателей на правое и левое поддеревья данного узла. Пустое дерево представляется нулевым указателем. Вставки и удаления производятся путем динамического размещения узлов и присвоения значений полям указателей. Это представление используется для целой группы деревьев от вырожденных до законченных. В данном разделе вводится последовательное представление деревьев с помощью массивов. При этом данные хранятся в элементах массива, а узлы указываются индексами. Мы выявим очень близкое родство между массивом и законченным бинарным деревом — взаимосвязь, используемую в пирамидах и очередях приоритетов. Вспомним из гл. 11, что законченное бинарное дерево глубины п содержит все возможные узлы на уровнях до п-1, а узлы уровня п располагаются слева направо подряд (без дыр). Массив А есть последовательный список, элементы которого могут представлять узлы законченного бинарного дерева с корнем А[0]; потомками первого уровня А[1] и А[2]; потомками второго уровня А[3], А[4], А[5] и А[6] и т.д. Корневой узел имеет индекс 0, а всем остальным узлам индексы назначаются в порядке, определяемом поперечным (уровень за уровнем) методом прохождения. На рис. 13.1 показано законченное бинарное дерево для массива А из десяти элементов. int А[10] * {5, 1, 3, 9, 6, 2, 4, 7, О, 8} 1 По фамилиям их изобретателей — Г. М. Адельсона-Вельского и Е. М. Ландиса [1]. — Прим. перев.
Рис 13.1. Законченное бинарное дерево для 10-элементного массива А Бинарные деревья Эквивалентное представление в виде массива Несмотря на то, что массивы обеспечивают естественное представление деревьев, возникает проблема, связанная с отсутствующими узлами, которым должны соответствовать неиспользуемые элементы массива. В следующем примере массив имеет четыре неиспользуемых элемента, т.е. треть занимаемого деревом пространства. Вырожденное дерево, имеющее только правые поддеревья, дает в этом смысле еще худший результат. Преимущества представляемых массивами деревьев обнаруживаются тогда, когда требуется прямой доступ к узлам. Индексы, идентифицирующие сыновей и родителя данного узла, вычисляются просто. В таблице 13.1 представлено дерево, изображенное на рис. 13.1. Здесь для каждого уровня указаны узлы, а также их родители и сыновья. Для каждого узла A[i] в N-элементном массиве индекс его сыновей вычисляется по формулам: Индекс левого сына = 2*i (неопределен при 2*i + 1 > N) Индекс правого сына = 2*i + 2 (неопределен при 2*i + 2 > N) Таблица 13.1 | Уровень 0 1 2 3 Родитель 0 1 2 3 4 5 б 7 8 9 Значение А[0] = 5 А[1] = 1 А[2] = 3 А[3] = 9 А[4] = 6 А[5] = 2 А[б] = 4 А[7] = 7 А[8] = 0 А[9] = 8 Левый сын 1 2 5 7 9 11=NULL 13=NULL - - - Правый сын | 2 4 6 8 1CNNULL 12=NULL 14=NULL - - -
Поднимаясь от сыновей к родителю, мы замечаем, что родителем узлов А[3] и А[4] является А[1], родителем А[5] и А[6] — А[2] и т.д. Общая формула для вычисления родителя узла A[i] следующая: Индекс родителя = (i-l)/2 (неопределен при i=0) Пример 13.1 Во время прохождения последовательно представленного дерева можно идти вниз к сыновьям или вверх к родителю. Ниже приводятся примеры путей для следующего дерева: 1. Начиная с корня, выбрать путь, проходящий через меньших сыновей. Путь: А[0] = 7, А[2] = 9, А[6] - 3 2. Начиная с корня, выбрать путь, проходящий через левых сыновей. Путь: А[0] = 7, А[1] - 10, А[3] = 12, А[7] - 3 3. Начиная с А[10], выбрать путь, проходящий через родителей. Путь: А[10] = 2, А[4] = 2, А[1] = 10, А[0] - 7 Приложение: турнирная сортировка Бинарные деревья находят важное применение в качестве деревьев принятия решения, в которых каждый узел представляет ситуацию, имеющую два возможных исхода. В частности, для представления спортивного турнира, проводимого по схеме с выбываниями. Каждый не листовой узел соответствует победителю встречи между двумя игроками. Листовые узлы дают стартовый состав участников и распределение их по парам. Например, победителем теннисного турнира является Дэвид, выигравший финальную встречу с Доном. Оба спортсмена вышли в финал, выиграв предварительные матчи. Дон победил Алана, а Дэвид — Мэнни. Все игры турнира и их результаты могут быть записаны в виде дерева. Дон Алан Мэнни Дэвид Дон Дэвид Дэвид Победитель Победитель Дэвид Дон Дэвид Дон Алан Мэнни Дэвид
В турнире с выбываниями победитель определяется очень скоро. Например, для четырех игроков понадобится всего три матча, а для 24 = 16 участников — 24 - 1 = 15 встреч. Турнир выявляет победителя, но со вторым лучшим игроком пока не все ясно. Поскольку Дон проиграл финал победителю турнира, он может и не оказаться вторым лучшим игроком. Нам нужно дать шанс Мэнни, так как тот играл матч первого круга с, быть может, единственным игроком, способным его победить. Чтобы выявить второго лучшего игрока, нужно исключить Дэвида и реорганизовать турнирное дерево, устроив матч между Доном и Мэнни. Второй Мэнни Третий Дон Мэнни Четвертый Дон Алан Мэнни Как только определится победитель этого матча, мы сможем правильно распределить места. Выиграл Мэнни: Места Дэвид Мэнни Дон Алан Выиграл Дон: Места Дэвид Дон Мэнни Алан Турнирное дерево может использоваться для сортировки списка из N элементов. Рассмотрим эффективный алгоритм, использующий дерево, представленное в виде массива. Пусть имеется последовательно представленное дерево, содержащее N элементов — листовых узлов в нижнем ряду. Эти элементы запоминаются на уровне к, где 2к > N. Предположим, что список сортируется по возрастанию. Мы сравниваем каждую пару элементов и запоминаем меньший из них (победителя) в родительском узле. Процесс продолжается до тех пор, пока наименьший элемент (победитель турнира) не окажется в корневом узле. Например, приведенное ниже дерево задает следующее начальное состояние массива из N = 8 целых чисел. Элементы запоминаются на уровне 3, где 23 = 8. А[8] = {35, 25, 50, 20, 15, 45, 10, 40} Исходное Tree Тгее[7] Тгее[8] Тгее[9] Тгее[10] Тгее[11] Тгее[12] Тгее[13] Тгее[14] Со второго уровня начинаются "игры" — в родительские узлы помещаются наименьшие значения в парах. Например, "игру" между элементами Тгее[7] и Тгее[8] выигрывает меньший из них, и значение 25 записывается в Тгее[3]. Подобные сравнения проводятся также на втором и первом уровнях. В результате последнего сравнения наименьший элемент попадает в корень дерева на урозне 0.
Начальные сравнения Тгее[7] Тгее[8] Тгее[9] Тгее[10] Тгее[11] Тгее[12] Тгее[13] Тгее[14] Как только наименьший элемент оказывается в корневом узле, он удаляется со своего старого места на дереве и копируется в массив. В первый раз в А[0] записывается 10, а затем дерево обновляется для поиска следующего наименьшего элемента. В турнирной модели некоторые матчи должны быть сыграны повторно. Поскольку число 10 изначально было в А[13], проигравший в первом круге А[14] = 40 должен снова участвовать в турнире. А[14] копируется в свой родительский узел А[6], а затем снова проводятся матчи в индексе 6 (15 побеждает 40) и в индексе 2 (15 побеждает 20). В результате 15 попадает в корень и становится вторым наименьшим элементом списка. Корень копируется в А[1], и процесс продолжается. Тгее[7] Тгее[8] Тгее[9] Тгее[10] Тгее[11] Тгее[12] Тгее[13] Тгее[14] Процесс продолжается до тех пор, пока все листья не будут удалены. В нашем примере последний (наибольший) узел играет серию матчей, в которых побеждает всех по умолчанию. После копирования числа 50 в А[7] мы получаем отсортированный список. Тгее[7] Тгее[8] Тгее[9] Тгее[10] Тгее[11] Тгее[12] Тгее[13] Тгее[14] 10 15 20 25 35 40 45 50 ] Вычислительная эффективность. Эффективность турнирной сортировки составляет 0(n log2n). В массиве, содержащем n = 2k элементов, для выявления наименьшего элемента требуется п-1 сравнений. Это становится ясным, когда
мы замечаем, что половина участников выбывает после каждого круга по мере продвижения к корню. Общее число матчей равно 2k-i + 2k"2 + ... + 21 + 1 = n-1 Дерево обновляется, и оставшиеся п-1 элементов.обрабатываются посредством к-1 сравнений вдоль пути, проходящего через родительские узлы. Общее число сравнений равно (п-1) + (к-1)*(п-1) = (п-1) + (n-l)*(log2n-l) = (п-1) log2n Хотя количество сравнений в турнирной сортировке составляет 0(п log2n), использование пустот значительно менее эффективно. Дереву требуется 2 * п-1 узлов, чтобы вместить к-1 кругов соревнования. Алгоритм TournamentSort. Для реализации турнирной сортировки определим класс DataNode и создадим представленное массивом дерево из объектов этого типа. Членами класса являются элемент данных, его место в нижнем ряду дерева и флажок, показывающий, участвует ли еще этот элемент в турнире. Для сравнения узлов используется перегруженный оператор "<=". template <class T> class DataNode { public: // элемент данных, индекс в массиве, логический флажок Т data; int index; int active; friend int operator <= (const DataNode<T> &x, const DataNode<T> &y); }; Сортировка реализуется с помощью функции TournamentSort и утилиты UpdateTree, которая производит сравнения вдоль пути предков. Полный листинг функций и переменных, обеспечивающих турнирную сортировку, находится в файле toursort.h. // сформировать последовательное дерево, скопировать туда элементы массива; // отсортировать элементы и скопировать их обратно в массив template <class T> void TournamentSort (T a[], int n) { DataNode<T> *tree; // корень дерева DataNode<T> item; // минимальная степень двойки, большая или равная п int bottomRowSize; // число узлов в полном дереве, нижний ряд которого // имеет bottomRowSize узлов int treesize; // начальный индекс нижнего ряда узлов int loadindex; int i, j; // определить требуемый размер памяти для нижнего ряда узлов bottomRowSize « PowerOfTwo(n);
// вычислить размер дерева и динамически создать его узлы treesize - 2 * bottomRowSize - 1; tree = new DataNode<T>[treesize]; // скопировать массив в дерево объектов типа DataNode j - 0; for (i=loadindex; i<treesize; i++) { item.index - i; if (j < n) { item.active = 1; item.data = a[j++]; ) else item, active = Octree [i] = item; } // выполнить начальные сравнения для определения наименьшего элемента i = loadindex; while (i > 0) { 3 - i' while (j < 2*i); // обработать пары соревнующихся { // проведение матча, сравнить tree[j] с его соперником tree[j+l] // скопировать победителя в родительский узел if (!tree[j+1].active || tree[j] < tree[j+l]) tree[(j-l)/2] = tree[j]; else treet(j-l)/2] = tree[j+l]; j +* 2; // перейти к следующей паре } // обработать оставшиеся п-1 элементов, скопировать победителя // из корня в массив, сделать победителя неактивным, обновить // дерево, разрешив сопернику победителя снова войти в турнир for (i=0; i<n-l; i++) { a[i] ~ tree[0].data; tree[tree[0].index].active = 0; UpdateTree(tree, tree[0].index); } // скопировать наибольшее значение в массив a[n-l] = tree[0].data; } В функцию UpdateTree передается индекс i, указывающий исходное положение наименьшего текущего элемента в нижнем ряду дерева. Это—удаляемый узел (становится неактивным). Значению, которое "проиграло" предварительный раунд последнему победителю (наименьшему значению), разрешается снова войти в турнир. // параметр i есть начальный индекс текущего наименьшего элемента // в списке (победителя турнира) template <class T> void UpdateTree(DataNode<T> *tree, int i) { int j;
// определить соперника победителя, позволить ему продолжить // турнир, копируя его в родительский узел, if (i % 2 — 0) tree [(i-l)/2] = tree[i-l]; // соперник - левый узел else tree [(i-l)/2] = tree[i+l]; // соперник — правый узел // переиграть те матчи, в которых принимал участие // только что исключенный из турнира игрок i = (i-l)/2; while (i > 0) { // соперником является правый или левый узел? if (i % 2 ==* 0) j - i-1; else j = i+1; // проверить, является ли соперник активным if (!tree[i].active I I !tree[j].active) if (tree[i].active) tree[(i-l)/2] =tree[i]; else tree[(i-l)/2] - treefj]; // устроить соревнование. // победителя скопировать в родительский узел else if <tree[i] < tree[j]) tree[(i-l)/2] = tree[i]; else tree[(i-l)/2] =tree[j]; // перейти к следующему кругу соревнования (родительский уровень) i = (i-l)/2; } // Турнир с новым соперником закончен. // очередное наименьшее значение находится в корневом узле } 13.2. Пирамиды Представляемые массивами деревья находят применение в имеющих большое значение приложениях с пирамидами (heaps), являющимися законченными бинарными деревьями, имеющими упорядочение узлов по уровням. В максимальной пирамиде (maximum heap) родительский узел больше или равен каждому из своих сыновей. В минимальной пирамиде (minimum heap) родительский узел меньше или равен каждому из своих сыновей. Эти ситуации изображены на рис. 13.2. В максимальной пирамиде корень содержит наибольший элемент, а в минимальной — наименьший. В этой книге рассматриваются минимальные пирамиды. Пирамида как список Пирамида является списком, который хранит некоторый набор данных в виде бинарного дерева. Пирамидальное упорядочение предполагает, что каждый узел пирамиды содержит значение, которое меньше или равно значению любого из его сыновей. При таком упорядочении корень содержит наименьшее значение данных. Как абстрактная списковая структура пирамида допускает добавление и удаление элементов. Процесс включения не подразумевает, что новый
элемент занимает конкретное место, а лишь требует, чтобы поддерживалось пирамидальное упорядочение. Однако при удалении из списка выбрасывается наименьший элемент (корень). Пирамида используется в тех приложениях, где клиенту требуется прямой доступ к минимальному элементу. Как список пирамида не имеет операции поиска и осуществляет прямой доступ к минимальному элементу в режиме "только чтение". Все алгоритмы обработки пирамид сами должны обновлять дерево и поддерживать пирамидальное упорядочение. (С) Максимальная пирамида (9 узлов) (D) Максимальная пирамида (4 узла) Рис. 13.2. Максимальные и минимальные пирамиды Пирамида является очень эффективной структурой управления списками, которая пользуется преимуществами полного бинарного дерева. При каждой операции включения или удаления пирамида восстанавливает свое упорядочение посредством сканирования только коротких путей от корня вниз до конца дерева. Важными приложениями пирамид являются очереди приоритетов и сортировка элементов списка. Вместо того чтобы использовать более медленные алгоритмы сортировки, можно включить элементы списка в пирамиду и отсортировать их, постоянно удаляя корневой узел. Это дает чрезвычайно быстрый алгоритм сортировки. Обсудим внутреннюю организацию пирамиды в нашем классе Heap. Алгоритмы включения и исключения элементов представляются в реализации методов Insert и Delete. Пример 13.2 исследует пирамиды и иллюстрирует некоторые операции над ними. Пример 13.2 1. Создание пирамиды. Массив имеет соотвествующее представление в виде дерева. В общем случае это дерево не является пирамидой. Пирамида создается переупорядочением элементов массива. Исходный список: 40 10 30 Пирамида: 10 40 30
2. Вставка элемента. Новый элемент добавляется в конец списка, а затем дерево реорганизуется с целью восстановления пирамидальной структуры. Например, для добавления в список числа 15 производятся следующие действия: Записать 15 в А[3] Переупорядочить дерево 3. Удаление элемента. Удаляется всегда корень дерева (А[0]). Освободившееся место занимает последний элемент списка. Дерево реорганизуется с целью восстановления пирамидальной структуры. Например, для исключения числа 10 производятся следующие действия: Удалить 10 из А[10] Переместить 40 из А[3] Восстановить дерево Класс Heap Как и любой линейный или нелинейный список, класс пирамид имеет операции включения и исключения элементов, а также операции, которые возвращают информацию о состоянии объекта, например, размер списка. Спецификация класса Heap ОБЪЯВЛЕНИЕ #include <iostream.h> #include <stdlib.h> template <class T> class Heap { private: // hlist указывает на массив, который может быть динамически создан // конструктором (inArray == 0) или передан как параметр (inArray == 1) Т *hlist; int inArray; // максимальный и текущий размеры пирамиды int maxheapsize; int heapsize; // определяет конец списка // функция вывода сообщений об ошибке void error(char errmsg[]); // утилиты восстановления пирамидальной структуры void FilterDown(int i); void FilterUp(int i);
public: // конструкторы и деструктор Heap (int maxsize); // создать пустую пирамиду Heap (T arr[], int n); // преобразовать arr в пирамиду Heap (const Heap<T>& H); // конструктор копий -Heap(void); // деструктор // перегруженные операторы: "=м, "[]", "т*" Неар<Т> operator= (const Heap<T>& rhs); const T& operator[] (int i); // методы обработки списков int ListSize(void) const; int ListEmpty(void) const; int ListFull(void) const; void Insert(const T& item); T Delete(void); void ClearList(void); }; ОПИСАНИЕ Первый конструктор принимает параметр size и использует его для динамического выделения памяти под массив. В исходном состоянии пирамида пуста, и новые элементы включаются в нее с помощью метода Insert. Деструктор, конструктор копирования и оператор присваивания поддерживают использование динамической памяти. Второй конструктор принимает в качестве параметра массив и преобразует его в пирамиду. Таким образом, клиент может навязать пирамидальную структуру любому существующему массиву и воспользоваться свойствами пирамиды. Перегруженный оператор индекса "[]" позволяет клиенту обращаться к объекту типа пирамиды как к массиву. Поскольку этот оператор возвращает ссылку на константу, доступ осуществляется лишь в режиме "только чтение". Методы ListEmpty, ListSize и ListFull возвращают информацию о текущем состоянии пирамиды. Метод Delete всегда исключает из пирамиды первый (наименьший) элемент. Метод Insert включает элемент в список и поддерживает пирамидальное упорядочение. ПРИМЕР Heap<int> H(4); // 4-элементная пирамида целых чисел int A[] = {15, 10, 40, 30); // 4-элементный массив Heap<int> К(А, 4); // преобразовать массив А в пирамиду К Н.Insert (85); // вставить 85 в пирамиду Н Н.Insert(40); // вставить 40 в пирамиду Н cout « Н.Delete (); // напечатать 40 — наименьший элемент в Н // распечатать массив, представляющий пирамиду А for (int i=0; i<4; i++) cout « K[i] « " "; // напечатать 10 15 40 30 K[0] = 99; // недопустимый оператор
Программа 13.1. Иллюстрация класса Heap Эта программа начинается с инициализации массива А, а затем преобразует его в пирамиду. А: 50, 20, 60, 65, 15, 25, 10, 30, 4, 45 Массив А Пирамида А Элементы исключаются из пирамиды и распечатываются до тех пор, пока пирамида не опустеет. Поскольку пирамида реорганизуется после каждого исключения, элементы распечатываются по возрастанию. #include <iostream.h> #include "heap.h" // распечатать массив, состоящий из п элементов template <class T> void PrintList (T А[], int n) { for (int i=0; i<n; i++) cout « A[i] « " "; cout « endl; } void main(void) { // исходный массив int A[10] = {50, 20, 60, 65, 15, 25, 10, 30, 4, 45} cout « "Исходный массив:" « endl; PrintList(A, 10); // преобразование А в пирамиду heap<int> H(A,10); // распечатать новую версию массива А cout << "Пирамида:" « endl; PrintList(A, 10); cout « "Удаление элементов из пирамиды:" « endl; // непрерывно извлекать наименьшее значение while {!H.ListEmpty()) cout « Н.Delete() « " "; cout « endl; } /* <Прогон программы 13.1> Исходный массив: 50 20 60 65 15 25 10 30 4 45 Пирамида:
4 15 10 20 45 25 60 30 65 50 Удаление элементов из пирамиды: 4 10 15 20 25 30 45 50 60 65 */ 13.3. Реализация класса Heap Здесь мы подробно обсудим операции вставки и удаления для пирамид, а также методы FilterUp и FilterDown. Эти вспомогательные методы отвечают за реорганизацию пирамиды при ее создании или изменении. Операция включения элемента в пирамиду. Вначале элемент добавляется в конец списка. Однако при этом может нарушиться условие пирамидаль- ности. Если новый элемент имеет значение меньшее, чем у его родителя, узлы меняются местами. Возможные ситуации представлены на следующем рисунке. Родитель Родитель) Новый i элемент) Брат Новый элемент Новый элемент является левым сыном меньшим, чем родительский узел Новый элемент является правым сыном меньшим, чем родительский узел Этот обмен восстанавливает условие пирамидальности для данного родительского узла, однако может нарушить условие пирамидальности для высших уровней дерева. Теперь мы должны рассмотреть нового родителя как сына и проверить условие пирамидальности для более старшего родителя. Если новый элемент меньше, следует переместить его выше. Таким образом новый элемент поднимается вверх по дереву вдоль пути, проходящего через его предков. Рассмотрим следующий пример для 9-элементной пирамиды Н: Н.Insert(8); // вставить элемент 8 в пирамиду Вставить 8 в А[9]. Вставить новый элемент в конец пирамиды. Эта позиция определяется индексом heapsize, хранящим текущее число элементов в пирамиде. Н[9]
Отправить значение 8 по пути предков. Сравнить 8 с родителем 20. Поскольку сын меньше своего родителя, поменять их значения местами (А). Продолжить движение по пути предков. Теперь элемент 8 меньше своего родителя Н[1]=10 и поэтому меняется с ним местами. Процесс завершается, так как следующий родитель удовлетворяет условию пирами дальности. (А) (В) Процесс включения элементов сканирует путь предков и завершается, встретив "маленького" (меньше чем новый элемент) родителя или достигнув корневого узла. Так как у корневого узла нет родителя, новое значение помещается в корень. Чтобы поместить узел в правильную позицию, операция вставки использует метод Filter Up. // утилита для восстановления пирамиды, начиная с индекса i, // подниматься вверх по дереву, переходя от предка к предку. // менять элементы местами, если сын меньше родителя template <class T> void Heap<T>::FilterUp (int i) { int currentpos, parentpos/ T target; // currentpos — индекс текущей позиции на пути предков. // target — вставляемое значение, для которого выбирается // правильная позиция в пирамиде currentpos = i; parentpos = (i-l)/2; target = hlist[i]; // подниматься к корню по пути родителей while (currentpos != 0) { // если родитель <= target, то все в порядке, if (hlist[parentpos] <= target) break; else // поменять местами родителя с сыном и обновить индексы // для проверки следующего родителя { // переместить данные из родительской позиции в текущую. // назначить родительскую позицию текущей. // проверить следующего родителя hlist[currentpos] = hlist[parentpos]; currentpos = parentpos; parentpos = (currentpos-1)/2; } } // правильная позиция найдена, поместить туда target hlist[currentpos] = target; }
Открытый метод Insert проверяет сначала заполненность пирамиды, а затем начинает операцию включения. После записи элемента в конец пирамиды вызывается FilterUp для ее реорганизации. // вставить в пирамиду новый элемент и восстановить ее структуру template <class T> void Heap<T>:-.Insert (const T& item) { // проверить, заполнена ли пирамида и выйти, если да if (heapsize == maxheapsize) error ("Пирамида заполнена"); // записать элемент в конец пирамиды и увеличить heapsize. // вызвать FilterUp для восстановления пирамидального упорядочения hlist[heapsize] = item; FilterUp(heapsize); heapsize++; } Удаление из пирамиды. Данные удаляются всегда из корня дерева. После такого удаления корень остается ничем не занятым и сначала заполняется последним элементом пирамиды. Однако такая замена может нарушить условие пирами дальности. Поэтому требуется пробежать по всем меньшим потомкам и найти подходящее место для только что помещенного в корень элемента. Если он больше любого своего сына, мы должны поменять местами этот элемент с его наименьшим сыном. Движение по пути меньших сыновей продолжается до тех пор, пока элемент не займет правильную позицию в качестве родителя или пока не будет достигнут конец списка. В последнем случае элемент помещается в листовой узел. Например, в приведенной ниже пирамиде удаляется корневой узел 5. Удалить корневой узел 5 и заменить его последним узлом 22. Последний элемент пирамиды копируется в корень. Новый корень может не удовлетворять условию пирами дальности, и требуется отправиться по пути, проходящему через меньших сыновей, чтобы подыскать для нового корня правильную позицию. Удалить 5 Исходная пирамида Заменить корень значением 22 Передвигать число 22 от корня вниз по пути, проходящему через меньших сыновей. Сравнить корень 22 с его сыновьями. Наименьший из двух сын Н[1] меньше, чем 22, поэтому следует поменять их местами (А). Находясь теперь на первом уровне, новый родитель сравнивается со своими сыновьями Н[3] и Н[4]. Наименьший из них имеет значение 11 и поэтому должен поменяться местами со своим родителем (В). Теперь дерево удовлетворяет условию пирамидальности.
(А) (В) Метод Delete. Чтобы поместить узел в правильную позицию, операция удаления использует метод FilterDown. Эта функция получает в качестве параметра индекс i, с которого начинается сканирование. При удалении метод FilterDown вызывается с параметром 0, так как замещающее значение копируется из последнего элемента пирамиды в ее корень. Метод FilterDown используется также конструктором для построения пирамиды. // утилита для восстановления пирамиды, начиная с индекса i, // менять местами родителя и сына так, чтобы поддерево, // начинающееся в узле i, было пирамидой template <class T> void Heap<T>::FilterDown (int i) { int currentpos, childpos; T target; // начать с узла i и присвоить его значение переменной target currentpos = i; target = hlist[i]/ // вычислить индекс левого сына и начать движение вниз по пути, // проходящему через меньших сыновей до конца списка childpos = 2 * i + 1; while (childpos < heapsize) // пока не конец списка { // индекс правого сына равен childpos+1. присвоить переменной // childpos индекс наименьшего из двух сыновей if ((childpos+1 < heapsize) && (hlist[childpos+1] <= hlist[childpos])) childpos = childpos + 1; // если родитель меньше сына, пирамида в порядке, выход if (target <= hlist[childpos]) break; else { // переместить значение меньшего сына в родительский узел. // теперь позиция меньшего сына не занята hlist[currentpos] = hlist[childpos]; // обновить индексы и продолжить сканирование currentpos = childpos; childpos = 2 * currentpos + 1; } } // поместить target в только что ставшую незанятой позицию hlist{currentpos] = target; } Открытый метод Delete копирует значение из корневого узла во временную переменную, а затем замещает корень последним элементом пирамиды. После
этого heapsize уменьшается на единицу. FilterDown реорганизует пирамиду. Значение, сохраненное во временной переменной, возвращается клиенту. // возвратить значение корневого элемента и обновить пирамиду. // попытка удаления элемента из пустой пирамиды влечет за собой // выдачу сообщения об ошибке и прекращение программы template <class T> Т Неар<Т>::Delete(void) { Т tempitem; // проверить, пуста ли пирамида if (heapsize « 0) error ("Пирамида пуста"); // копировать корень в tempitem. заменить корень последним элементом // пирамиды и произвести декремент переменной heapsize tempitem = hlist[0]; hlist[0] = hlist[heapsize-l]; heapsize—; // вызвать FilterDown для установки нового значения корня FilterDown(0); // возвратить исходное значение корня return tempitem; } Преобразование массива в пирамиду. Один из конструкторов класса Heap использует существующий массив в качестве входного списка и преобразует его в пирамиду. Ко всем нелистовым узлам применяется метод FilterDown. Индекс последнего элемента пирамиды равен п-1. Индекс его родителя равен (п - 1) - 1 п - 2 currentpos = - = —-г— и определяет последний нелистовой узел пирамиды. Этот индекс является начальным для преобразования массива. Если применить метод FilterDown ко всем индексам от currentpos до 0, то можно гарантировать, что каждый родительский узел будет удовлетворять условию пирамидальности. В качестве примера рассмотрим целочисленный массив int A[10] = {9, 12, 17, 30, 50, 20, 60, 65, 4, 19} Индексы листьев: 5, 6, ..., 9 Индексы родительских узлов: 4, 3, ..., 0 Исходный список Приведенные ниже рисунки иллюстрируют процесс преобразования пирамиды. Для всех вызовов FilterDown соответствующее поддерево выделено на рисунках треугольником.
FilterDown(4). Родитель Н[4] = 50 больше своего сына Н[9] = 19 и поэтому должен поменяться с ним местами (А). Поставить на место число 50 с помощью FilterDown(4) (А) FilterDown(3). Родитель Н[3] = 30 больше своего сына Н[8] = 19 и поэтому должен поменяться с ним местами (В). Поставить на место число 30 с помощью FilterDownO) (В) На уровне 2 родитель Н[2] = 17 уже удовлетворяет условию пирами- дальности, поэтому вызов FilterDown(2) не производит никаких перестановок. FilterDown(l). Родитель Н[1] = 12 больше своего сына Н[3] = 19 и поэтому должен поменяться с ним местами (С). FilterDown(O). Процесс прекращается в корневом узле. Родитель Н[0] = 9 должен поменяться местами со своим сыном Н[1]. Результирующее дерево является пирамидой. (D)
КОНСТРУКТОР // конструктор преобразует исходный массив в пирамиду. // этот массив и его размер передаются в качестве параметров template <class T> Неар<Т>::Неар(Т arr[], int n) { int j, currentpos; // n <- 0 является недопустимым размером массива if (n <= 0) error ("Неправильная размерность массива"); // использовать п для установки размера пирамиды и максимального размера пирамиды. // копировать массив агг в список пирамиды maxheapsize = п; heapsize = п; hlist » arr; // присвоить переменной currentpos индекс последнего родителя. // вызывать FilterDown в цикле с индексами currentpos..0 currentpos « (heapsize-2)/2; while (currentpos >= 0) { // выполнить условие пирамидальности для поддерева // с корнем hlist[currentpos] FilterDown(currentpos); currentpos—; ) // присвоить флажку inArray значение True inArray * 1; } Приложение: пирамидальная сортировка Пирамидальная сортировка имеет эффективность 0(n log2n). Алгоритм использует тот факт, что наименьший элемент находится в корне (индекс 0) и что метод Delete возвращает это значение. Для осуществления пирамидальной сортировки массива А объявите объект типа Heap с массивом А в качестве параметра. Конструктор преобразует А в пирамиду. Сортировка осуществляется последовательным исключением А[0] и включением его в A[N-1], A[N-2], ..., А[1]. Вспомните, что после исключения элемента из пирамиды элемент, бывший до этого хвостовым, замещает корневой и с этого момента больше не является частью пирамиды. Мы имеем возможность скопировать удаленный элемент в эту позицию. В процессе пирамидальной сортировки очередные наименьшие элементы удаляются и последовательно запоминаются в хвостовой части массива. Таким образом, массив А сортируется по убыванию. В качестве упражнения читателю предлагается построить класс максимальных пирамид, с помощью которого массив сортируется по возрастанию. Пирамидальная сортировка пятиэлементного массива А осуществляется посредством следующих действий: int A[] = {50, 20,75, 35, 25} Исходная пирамида
Удалить 20 и запомнить в А[4] Удалить 25 и запомнить в А[3] Удалить 35 и запомнить в А[2] Удалить 50 и запомнить в А[1] Поскольку единственный оставшийся элемент 75 является корнем, массив отсортирован: А = 75 50 35 25 20. Ниже приводится реализация алгоритма пирамидальной сортировки. Функция HeapSort находится в файле heapsort.h. Функция HeapSort tinclude "heap.h" // класс Heap // отсортировать массив А по убыванию template <class T> void HeapSort (T A[], int n) { // конструктор, преобразующий А в пирамиду Неар<Т> Н(А, п); Т elt; // цикл заполнения элементов А[п-1] ... А[1] for (int i=n-l; i>=l; i—) { // исключить наименьший элемент из пирамиды и запомнить его в A[i] elt = H.DeleteO ; A[i] = elt; } } Вычислительная эффективность пирамидальной сортировки. Массив, содержащий п элементов соответствует законченному бинарному дереву глубиной k = log2n. Начальная фаза преобразования массива в пирамиду требует п/2 операций FilterDown. Каждой из них требуется не более к сравнений. На второй фазе сортировки операция FilterDown выполняется п-1 раз. В худшем случае она требует к сравнений. Объединив обе фазы, получим худший случай сложности пирамидальной сортировки: k*f + k*(n-l) = k*^-l) = log2nxf^-lj Таким образом, сложность алгоритма имеет порядок 0(n log2n). Пирамидальная сортировка не требует никакой дополнительной памяти, поскольку производится на месте. Турнирная сортировка является алгоритмом порядка 0(n log2n), но требует создания последовательно представляемого мае-
сивом дерева из 2(к+1) узлов, где к — наименьшее целое, при котором n < 2к. Некоторые 0(n log2n) сложные сортировки дают 0(п2) в худшем случае. Примером может служить сортировка, рассмотренная в разделе 13.7. Пирамидальная сортировка всегда имеет сложность 0(n log2n) независимо от исходного распределения данных. Программа 13.2. Сравнение методов сортировки Массив А, содержащий 2000 случайных целых чисел, сортируется с помощью функции HeapSort (пирамидальная сортировка). В целях сравнения массивы В и С заполняются теми же элементами и сортируются с помощью функций TournamentSort (турнирная сортировка) и ExchangeSort (обменая сортировка). Функция ExchangeSort находится в файле arrsort.h. Сортировки хронометрируются функцией TickCount, которая возвращает число 1/60 долей секунды, прошедших с момента старта системы. Сортировка обменом, имеющая сложность 0(п2), позволит четко представить быстродействие турнирного и пирамидального методов, имеющих сложность 0(n log2n). Функция PrintFirst_Last распечатывает первые и последние пять элементов массива. Код этой функции не включен в листинг программы. Его можно найти в программном приложении в файле prgl3__2.cpp. #include <iostream.h> #include "random.h" ffinclude "arrsort.h" iinclude "toursort.h" #include "heapsort.h" #include "ticks.h" enum SortType {heap, tournament, exchange}; void TimeSort (int *A, int n, char *sortName, SortType sort) { long tcount; // TickCount — системная функция. // возвращает число 1/60 долей // секунды с момента старта системы cout « "Испытывается " « sortName « ":" « endl; // засечь время, отсортировать массив А. подсчитать затраченное // время в 1/60 долях секунды tcount = TickCount (); switch(sort) { case heap: HeapSort(A,n); break; case tournament: TournamentSort(A, n); break; case exchange: ExchangeSort(A, n); break; ) tcount = TickCount() - tcount; // распечатать 5 первых и 5 последних элементов // отсортированного массива for (int i«0; i<5; i++) cout « A[i] « " "; cout « ".. . ";
for (i=n-5; i<n; i++) cout « A[i] « " "/ cout « endl; cout << "Продолжительность " « tcount « "\n\n"; } void main(void) { // указатели массивов А, В и С int *А, *В, *С; RandomNumber rnd; // динамическое выделение памяти и загрузка массивов А = new int [2000]; В = new int [2000]; С = new int [2000]; // загрузить в массивы одни и те же 2000 случайных чисел for (int i=0; i<2000; i++) A[iJ = B[i] = C[i] = rnd.Random(10000); TimeSort(A/ 2000, "пирамидальная сортировка ", heap); delete [] A; TimeSort(B, 2000, "турнирная сортировка ", heap); delete [] B; TimeSort(C, 2000, "сортировка обменом ", heap); delete [] C; } /* <Прогон программы 13.2> Испытывается пирамидальная сортировка : 9999 9996 9996 9995 9990 ... 11 10 9 6 3 Продолжительность 16 Испытывается турнирная сортировка : 3 6 9 10 11 ... 9990 9995 9996 9996 9999 Продолжительность 36 Испытывается сортировка обменом : 3 6 9 10 11 ... 9990 9995 9996 9996 9999 Продолжительность 818 */ 13.4. Очереди приоритетов Очереди приоритетов рассматривались в гл. 5 и использовались в задаче моделирования событий. Клиенту был предоставлен доступ к оператору вставки и оператору удаления, который удалял из списка элемент с наивысшим приоритетом. В главе 5 для реализации списка, лежащего в основе объекта PQueue, использовался массив. В этом разделе очередь приоритетов реализуется с помощью пирамиды. Поскольку мы используем минимальную пирамиду, предполагается, что элементы имеют возрастающие приоритеты. Операция удаления из пирамиды возвращает наименьший (с наивысшим приоритетом) элемент очереди приоритетов. Пирамидальная реализация обеспечивает высокую эффективность метода PQDelete, так как требует только 0(log2n) сравнений. Это соизмеримо с О(п) сравнениями в реализации с помощью массива.
Данный раздел завершается рассмотрением фильтра, преобразующего массив элементов в длинные последовательности1. Такой фильтр, используя очередь приоритетов, существенно повышает эффективность сортировки слиянием при упорядочении больших наборов данных файла. Эта тема обсуждается в гл. 14. Спецификация класса PQueue (пирамидальная версия) ОБЪЯВЛЕНИЕ #include "heap.h" template <class T> class PQueue { private: // пирамида, в которой хранится очередь Неар<Т> *ptrHeap; public: // конструктор PQueue (int sz) ; // операции модификации очереди приоритетов void PQInsert(const T& item); Т PQDelete(void); void ClearPQ(void); // методы опроса состояния очереди приоритетов int PQEmpty(void) const; int PQFull(void) const; int PQLength(void) const; }; ОПИСАНИЕ В конструктор передается параметр sz, который используется для динамического размещения структуры, адресуемой указателем ptrHeap. Методы реализуются простым вызовом соответствующего метода в классе Heap. Например, PQDelete использует метод исключения элемента из пирамиды. // удалить первый элемент очереди посредством удаления корня // соответствующей пирамиды, возвратить удаленное значение template <class T> Т PQueue<T>::PQDelete(void) { return ptrHeap->Delete (); } Реализация PQeue находится в файле pqueue.h. Приложение: длинные последовательности Сортировка слиянием является основным алгоритмом упорядочения больших файлов. Его эффективность возрастает, если данные фильтруются, т.е. предварительно преобразуются в длинные последовательности. В гл. 12 мы уже видели один такой фильтр, который вводит сразу к элементов данных и сортирует их. В этом случае минимальная длина последовательностей равна 1 Другие названия: серии» отрезки, цепочки. — Прим, пер.
к. В данном приложении используется к-элементная очередь приоритетов и создаются последовательности, длины которых часто существенно превышают к. Алгоритм читает элементы из исходного списка А и пропускает их через фильтр очереди приоритетов. Элементы возвращаются в исходный список в форме длинных последовательностей. Проиллюстрируем алгоритм на примере. Пусть массив А имеет 12 целых чисел, а приоритетная очередь PQ1 является фильтром с к=4 элементами. PQ1 хранит элементы, которые в конечном счете попадут в текущую последовательность. Вторая приоритетная очередь, PQ2, содержит элементы для следующей последовательности. Для сканирования массива используются два индекса. Переменная loadlndex указывает элемент, который вводится в данный момент. Переменная currlndex указывает последний элемент, покинувший очередь PQ1 и вернувшийся в исходный массив. В нашем примере массив А изначально разбит на шесть последовательностей, самая длинная из которых содержит три элемента: А = [13] [6 61 96] [26] [1 72 91] [37] [25 97] [21] После фильтрации получатся три последовательности, самая длинная из которых будет содержать семь элементов. Вначале в PQ1 загружаются элементы А[0]...А[3]. Так как очередь приоритетов удаляет элементы в возрастающем порядке, у нас уже есть средство для сортировки, по крайней мере, четырех элементов последовательности. Но мы поступим даже лучше. Исключим из PQ1 первый элемент (с минимальным значением) и присвоим его A[currIndex] = А[0] = 6. Это число начинает первую последовательность, а в PQ1 остается незаполненное место. Поскольку первые четыре элемента массива А были скопированы в PQ1, продолжим с четвертого элемента (loadlndex = 4). На каждом шаге сравниваются A[currlndex] и A[loadIndex]. Если первый из них больше, он в конце концов попадет в текущую последовательность и поэтому запоминается в PQ1. В противном случае он попадет в следующую последовательность и поэтому запоминается в PQ2. Опишем это действие для каждого элемента в нашем примере. После обработки некоторого элемента мы используем следующий формат для перечисления загруженных элементов в А, элементов которые еще должны считываться, и содержимое обеих очередей приоритетов: А: Олементы, загружаемые в последовательности> A[loadIndex]: Оставшиеся элементы> PQ1: <содержимое текущей последовательности> PQ2: <содержимое следующей последовательности> Выполнить по шагам: Элемент А[4]=26 > A[currlndex]=6. Запомнить 26 в PQ1; и удалить 13 из PQ1; поместить 13 в А[1]. А: 6 13 А[5]...А[11]: 1 72 91 37 25 97 21 PQ1: 61 96 26 PQ2: <пусто> Элемент А[5]=1 < A[currlndex]=13. Поэтому 1 принадлежит следующей последовательности. Запомнить 1 в PQ2; исключить 26 из PQ1; поместить 26 в А[2]. А: 6 13 26 А[6]...А[11]: 72 91 37 25 97 21 PQ1: 61 96 PQ2: 1 Элемент А[6]=72 больше, чем элемент 26 в текущей последовательности. Запомнить А[6] в PQ1; исключить 61 из PQ1. Аналогично, следующий
элемент 91 попадает в PQ1 прежде, чем произойдет исключение элемента 72 и запись его в текущую последовательность по индексу cur- rlndex=4. А: 6 13 26 61 72 А[8]...А[11]: 37 25 97 21 PQ1: 91 96 PQ2: 1 Элементы А[8]=37 и А[9]=25 больше, чем 72 и попадут в следующую последовательность. Они запоминаются в PQ2. Одновременно из PQ1 исключаются два элемента и помещаются в массив, а очередь PQ1 остается пустой, А: 6 13 26 61 72 91 96 А[10]...А[11]: 97 21 PQ1: <пусто> PQ2: 1 37 25 Мы сформировали текущую последовательность и можем начинать следующую. Скопируем PQ2 в PQ1 и исключим наименьший элемент из вновь заполненной очереди PQ1. В нашем примере удаляем 1 из PQ1 и начнем следующую последовательность. А: 6 13 26 61 72 91 96 1 А[10]...А[11]: 97 21 PQ1: 25 37 PQ2: <пусто> Элемент А[10]=97 > 1 и запоминается в PQ1. Затем минимальное значение 25 исключается из PQ1. А: 6 13 26 61 72 91 96 1 25 А[11]: 21 PQ1: 37 97 PQ2: <пусто> Элемент А[11]=21 < 25 и должен ждать следующую последовательность. Он запоминается в PQ2, а 37 исключается из PQ1. А: 6 13 26 61 72 91 96 1 25 37 <весь список пройден> PQ1: 97 PQ2: 21 Сканирование исходного списка завершено. Исключить все элементы из PQ1 и поместить их в текущую последовательность. Затем все элементы из PQ2 поместить в следующую последовательность. Последовательность 1: 6 13 26 61 72 91 96 Последовательность 2: 1 25 37 97 Последовательность 3: 21 Алгоритм Runs. Алгоритм порождения длинных последовательностей реализуется с помощью класса LongRunFilter. Его закрытые данные-члены включают массив и две приоритетные очереди, содержащие текущую и следующую последовательности. Конструктор связывает объект данного класса с массивом и создает соответствующие очереди приоритетов. Алгоритм поддерживается закрытыми методами LoadPQ, который включает элементы массива в очередь PQ1, и CopyPQ, который копирует элементы из PQ2 в PQ1. ОБЪЯВЛЕНИЕ template <class T> class LongRunFilter { private: // указатели, определяющие ключевые параметры в фильтре // список А и две очереди приоритетов — PQ1 и PQ2 Т *А; PQueue<T> *PQ1, *PQ2;
int loadlndex; // размер массива и очередей приоритетов int arraySize; int filterSize; // копирование PQ2 в PQ1 void CopyPQ (void); // загрузка массива А в очередь приоритетов PQ1 void LoadPQ (void); public: // конструктор и деструктор LongRunFilter(T arr[], int n, int sz); ~LongRunFilter(void); // создание длинных последовательностей void LoadRuns(void); // оценка последовательностей void PrintRuns(void) const; int CountRuns(void) const; }; ОПИСАНИЕ Конструктор инициализирует данные-члены и загружает элементы из массива в PQ1, формируя таким образом элементы первой последовательности. Метод LoadRuns является главным алгоритмом, преобразующим элементы массива в длинные последовательности. Методы PrintRuns и CountRuns служат для иллюстрации алгоритма. Они используются для сравнения последовательностей до и после вызова Load- Runs. Полная реализация класса LongRunFilter находится в файле longrun.h. // сканировать массив А и создать длинные последовательности, // пропуская элементы через фильтр template <class T> void LongRunFilter<T>::LoadRuns(void) { T value/ int currIndex; = 0/ if (filterSize == 0) return; // начать с загрузки наименьшего элемента из PQ1 в А A[currlndex] = PQl->PQDelete(); // заполнить PQ1 элементами из А // теперь просмотреть элементы, оставшиеся в А while (loadlndex < arraySize) { // рассмотреть очередной элемент списка value = A[loadLndex++]; // если элемент больше или равен Afcurrlndex], //он принадлежит текущей последовательности //и попадает в PQ1. в противном случае он копируется //в PQ2 и в конечном счете попадает в следующую последовательность if (Afcurrlndex] <= value) PQl->PQInsert(value); else PQl->PQInsert(value);
// если PQ1 пуста, текущая последовательность сформирована. // скопировать PQ2 в PQ1 и начать следующую последовательность if (PQl->PQEmpty()) CopyPQO; // взять элемент из PQ1 и включить его в последовательность if (!PQl->PQEmpty()> A[++currIndex] = PQl->PQDelete; } // удалить элементы из текущей последовательности, //а затем из следующей while (!PQl->PQEmpty()) A[++currIndex] * PQl->PQDelete; while (!PQ2->PQEmpty()) A[++currIndex] = PQ2->PQDelete; } Программа 13.3. Длинные последовательности Эта программа иллюстрирует применение фильтра. В первом примере берется небольшой массив из 15 элементов и фильтруется с помощью 4-элементных очередей приоритетов. На выход выдается перечень последовательностей до и после вызова фильтра. В более практическом примере обрабатывается 10000-элементный массив, который фильтруется с помощью 5-, 50- и 500-элементных очередей приоритетов. В каждом случае распечатывается число итоговых последовательностей. #include <iostream.h> #include "random.h" ♦include "longrun.h" // копирование массива А в массив В void CopyArray(int A[], int B[], int n) { for (int i=0; i<n; i++) B[i] = A[i]; } void main() { // исходный 15-элементный массив для иллюстрации фильтра int demoArray[15]; // большие 10000-элементные массивы для подсчета последовательностей int *А = new int [10000], *В « new int [10000]; RandomNumber rnd; // создать 15 случайных чисел; сформировать фильтр for (i=0; i<15; i++) demoArray[i] = rnd.Random(100); LongRunFilter<int> F(demoArray, 15, 4); // распечатать список до и после создания длинных последовательностей //с помощью 4-элементного фильтра cout « "Исходные последовательности" « endl; F.PrintRuns(); cout « endl; F.LoadRuns();
cout « "Отфильтрованные последовательности" « endl; F.PrintRuns(); cout « endl; // сформировать массив из 10000 случайных чисел for (i=0; К10000; i++) A[i] « rnd.Random(25000); cout << "Последовательности, полученные с помощью 3-х фильтров" « endl; LongRunFilter<int> LR(A, 10000, 0) ; cout « "Число последовательностей в исходном массиве: " « LR.CountRuns() « endl; // тестирование 5- , 50- и 500-элементных фильтров for (i=0; i<3; i++) { CopyArray(A, В, 10000); LongRunFilter<int> LR(B, 10000, filterSize); // создать длинные последовательности LR.LoadRuns{); cout « " Число последовательностей после фильтра " « filterSize « " = " « LR.CountRuns () « endl; // 10-кратное увеличение размера фильтра filtersize *= 10; } } /* <Прогон программы 13.3> Исходные последовательности Отфильтрованные последовательности 22 26 36 44 44 66 79 81 84 86 88 2 19 40 47 Последовательности, полученные с помощью 3-х фильтров Количество последовательностей в исходном массиве: 5077 Число последовательностей после фильтра 5 = 991 Число последовательностей после фильтра 50 = 101 Число последовательностей после фильтра 500 =11 */ 36 22 79 26 84 44 88 44 66 81 19 86 40 2 47 13.5. AVL-деревья Бинарные деревья поиска предназначены для быстрого доступа к данным. В идеале дерево является разумно сбалансированным и имеет высоту порядка 0(log2n). Однако при некоторых данных дерево может оказаться вырожден-
ным. Тогда высота его будет О(п), и доступ к данным существенно замедлится. В этом разделе мы рассмотрим модифицированный класс деревьев, обладающих всеми преимуществами бинарных деревьев поиска и никогда не вырождающихся. Они называются сбалансированными или AVL-деревьями. Под сбалансированностью будем понимать то, что для каждого узла дерева высоты обоих его поддеревьев различаются не более чем на I1. Новые методы включения и исключения в классе AVL-деревьев гарантируют, что все узлы останутся сбалансированными по высоте. На рис. 13.3 показаны эквивалентные представления массива AVL-деревом и бинарным деревом поиска. Верхняя пара деревьев представляет простой пятиэлемент- ный массив А, отсортированный по возрастанию. Нижняя пара деревьев представляет массив В. Бинарное дерево поиска имеет высоту 5, в то время как высота AVL-дерева равна 2. В общем случае высота сбалансированного дерева не превышает 0(log2n). Таким образом, AVL-дерево является мощной структурой хранения, обеспечивающей быстрый доступ к данным. А[5] = {1,2,3,4,5} В[8] - {20, 30, 80, 40, 10, 60, 50, 70} В этом разделе используется подход, принятый в гл. 11, когда поисковое дерево строилось отдельно от своих узлов. Сначала мы разработаем класс AVLTreeNode, а затем используем объекты этого типа для конструирования класса AVLTree. Предметом пристального внимания будут методы Insert и Delete. Они требуют тщательного проектирования, поскольку должны гарантировать, что все узлы нового дерева останутся сбалансированными по высоте. Узлы AVL-дерева AVL-деревья имеют представление, похожее на бинарные деревья поиска. Все операции идентичны, за исключением методов Insert и Delete, которые должны постоянно отслеживать соотношение высот левого и правого поддеревьев узла. Для сохранения этой информации мы расширили определение объекта TreeNode, включив поле balanceFactor (показатель сбалансированности), которое содержит разность высот правого и левого поддеревьев. left data balanceFactor right AVLTreeNode balanceFactor-height(right subtree)- height(left subtree) Если balanceFactor отрицателен, то узел "перевешивает влево", так как высота левого поддерева больше, чем высота правого поддерева. При положительном balanceFactor узел "перевешивает вправо". Сбалансированный по высоте узел имеет balanceFactor = 0. В AVL-дереве показатель сбалансированности должен быть в диапазоне [-1, 1]. На рис. 13.4 изображены AVL-деревья с пометками -1, 0 и +1 на каждом узле, показывающими относительный размер левого и правого поддеревьев. -1: Высота левого поддерева на 1 больше высоты правого поддерева. 0: Высоты обоих поддеревьев одинаковы. 1 Строго говоря, этот критерий нужно называть AVL-сбалансированностью в отличие от идеальной сбалансированности, когда для каждого узла дерева количества узлов в левом и правом поддеревьях различаются не более чем на 1. В этой главе всегда подразумевается AVL-сбалан- сированность. — Прим. перев.
+1: Высота правого поддерева на 1 больше высоты левого поддерева. Бинарное дерево поиска AVL-дерево Бинарное дерево поиска AVL-дерево Рис. 13.3. Представление массива с помощью бинарного дерева поиска и AVL-дерева Используя свойства наследования, можно образовать класс AVLTreeNode на базе класса TreeNode. Объект типа AVLTreeNode наследует поля из класса TreeNode и добавляет к ним поле balanceFactor. Данные-члены left и right класса TreeNode являются защищенными, поэтому AVLTreeNode или другие производные классы имеют к ним доступ. Класс AVLTreeNode и все сопровождающие его программы находятся в файле avltree.h. Спецификация класса AVLTreeNode ОБЪЯВЛЕНИЕ // наследник класса TreeNode template <class T> class AVLTreeNode: public TreeNode<T> { private: // дополнительный член класса int balanceFactor/ // используются методами класса AVLTree и позволяют // избегать "перевешивания" узлов
AVLTreeNode<T>* & Left(void); AVLTreeNode<T>* & Right(void); public: // конструктор AVLTreeNode(const T& item, AVLTreeNode<T> *lptr = NULL, AVLTreeNode<T> *rptr = NULL, int balfac = 0); // возвратить левый/правый указатель узла типа TreeNode, //в качестве указателя узла типа AVLTreeNode; выполнить // приведение типов AVLTreeNode<T> *Left(void) const; AVLTreeNode<T> *Right(void) const; // метод для доступа к новому полю данных int GetBalanceFactor(void); // методы класса AVLTree должны иметь доступ к Left и Right friend class AVLTree<T>; }; ОПИСАНИЕ Элемент данных balanceFactor является закрытым, так как обновлять его должны только сбалансированные операции включения и исключения. Параметры, передаваемые в конструктор, содержат данные для базовой структуры типа TreeNode. По умолчанию параметр balfac равен 0. Доступ к полям указателей осуществляется с помощью методов Left и Right. Новые определения для этих методов обязательны, поскольку они возвращают указатель на стуктуру AVLTreeNode. Основные причины, по которым деструктор объявляется виртуальным, обсуждались в разделе 12.3. Поскольку класс AVLTree образован на базе класса BinSTree, будем использовать деструктор базового класса и ClearList. Эти методы удаляют узлы с помощью оператора delete. В каждом случае указатель ссылается на объект типа AVLTreeNode, а не TreeNode. Если деструктор базового класса TreeNode виртуальный, то при вызове delete используется динамическое связывание и удаляется объект типа AVLTreeNode. ПРИМЕРЫ AVLTreeNode<char> *root; // корень AVL-дерева // эта функция создает AVL-дерево, изображенное ниже. // каждому узлу присваивается показатель сбалансированности void MakeAVLCharTree(AVLTreeNode<char>* &root) { AVLTreeNode<char> *a, *b, *c, *d, *e; e = new AVLTreeNode<char>(/E', NULL, NULL, 0); d = new AVLTreeNode<char>('D', NULL, NULL, 0); с = new AVLTreeNode<char>('C, e, NULL, -1); b = new AVLTreeNode<char>('B', NULL, d, 1) ; a * new AVLTreeNode<char> (' A' , b, c, Ob- root = a; }
Реализация класса AVLTreeNode. Конструктор класса AVLTreeNode вызывает конструктор базового класса и инициализирует balanceFactor. // конструктор, инициализирует balanceFactor и базовый класс. // нулевые начальные значения полей указателей // (по умолчанию) инициализируют узел как лист, template <class T> AVLTreeNode<T>::AVLTreeNode (const T& item, AVLTreeNode<T> *lptr, AVLTreeNode<T> *rptr, int balfac): TreeNode<T>(item, lptr, rptr), balanceFactor(balfac) {} Методы Left и Right в классе AVLTreeNode упрощают доступ к полям данных. При попытке обратиться к левому сыну с помощью базового метода Left возвращается указатель на объект типа TreeNode. Чтобы получить указатель на узел сбалансированного дерева, требуется преобразование типов. Например, AVLTreeNode<T> *р, *q; q = p->Left(); // недопустимая операция q = (AVLTreeNode<T> *)p->Left(); // необходимое приведение типа Во избежание постоянного преобразования типа указателей мы определяем методы Left и Right для класса AVLTreeNode, возвращающие указатели на объекты типа AVLTreeNode. template <class T> AVLTreeNode<T>* AVLTreeNode::Left(void) { return ((AVLTreeNode<T> *)left; } 13.6. Класс AVLTree AVL-дерево представляет списковую структуру, похожую на бинарное дерево поиска, с одним дополнительным условием: дерево должно оставаться сбалансированным по высоте после каждой операции включения или удаления. Поскольку AVL-дерево является расширенным бинарным деревом поиска, класс AVLTree строится на базе класса BinSTree и является его наследником. Методы Insert и Delete должны подменяться для выполнения AVL-условия. Кроме того, в производном классе определяются конструктор копирования и перегруженный оператор присваивания, так как мы строим дерево с большей узловой структурой. Спецификация класса AVLTree ОБЪЯВЛЕНИЕ // Значения показателя сбалансированности узла const int leftheavy = -1; const int balanced =1; const int rightheavy =1; // производный класс поисковых деревьев template <class T> class AVLTree: public BinSTree<T>
{ private: // выделение памяти AVLTreeNode<T> *GetAVLTreeNode(const T& item, AVLTreeNode<T> *lptr, AVLTreeNode<T> *rptr); // используется конструктором копирования и оператором присваивания AVLTreeNode<T> *CopyTree(AVLTreeNode<T> *t) ; // используется методами Insert и Delete для восстановления // AVL-условий после операций включения/исключения void SingleRotateLeft (AVLTreeNode<T>* &p); void SingleRotateRight (AVLTreeNode<T>* &p); void DoubleRotateLeft (AVLTreeNode<T>* &p); void DoubleRotateRight (AVLTreeNode<T>* &p); void UpdateLeftTree (AVLTreeNode<T>* &tree, int &reviseBalanceFactor); void UpdateRightTree (AVLTreeNode<T>* &tree, int SreviseBalanceFactor); // специальные версии методов Insert и Delete void AVLInsert(AVLTreeNode<T>* &tree, AVLTreeNode<T>* newNode, int SreviseBalanceFactor); void AVLDelete(AVLTreeNode<T>* &tree, AVLTreeNode<T>* newNode, int sreviseBalanceFactor); public: // конструкторы AVLTree(void); AVLTree(const AVLTree<T>& tree); // оператор присваивания AVLTree<T>& operator^ (const AVLTree<T>i tree); // стандартные методы обработки списков virtual void Insert(const T& item); virtual void Delete(const T& item); }; ОПИСАНИЕ Константы leftheavy, balanced и rightheavy используются в операциях вставки/удаления для описания показателя сбалансированности узла. Метод GetAVLTreeNode управляет выделением памяти для класса. По умолчанию balanceFactor нового узла равен нулю. В этом классе заново определяется функция СоруТгее для использования с конструктором копирования и перегруженным оператором присваивания. Несмотря на то, что алгоритм идентичен алгоритму для функции СоруТгее класса BinSTree, новая версия корректно создает расширенные объекты типа AVLTreeNode при построении нового дерева. Функции AVLInsert и AVLDelete реализуют методы Insert и Delete, соответственно. Они используют закрытые методы наподобие SingleRotateLeft. Открытые методы Insert и Delete объявлены как виртуальные и подменяют соответствующие функции базового класса. Остальные операции наследуются от класса BinSTree. ПРИМЕР AVLTree<int> avltree; // AVLTree-список целых чисел BinSTree<int> bintгее; // BinSTree-список целых чисел for (int i=l; i<=5; i++)
(А) (В) { bintree.Insert(i); // создать дерево А avltree.Insert(i); // создать дерево В } avltree.Delete(3); // удалить 3 из AVL-дерева // функция AVLPrintTree эквивалентна функции вертикальной распечатки // дерева из гл. 11. кроме собственно данных для каждого узла // распечатываются показатели сбалансированности, дерево (С) есть дерево (В) // без удаленного узла 3. AVLPrintTree находится в файле avltree.h AVLPrintTree((AVLTreeNode<int> *)avltree.GetRoot(), 0); Распределение памяти для AVLTree Класс AVLTree образован от класса BinSTree и наследует большинство его операций. Для создания расширенных объектов типа AVLTreeNode мы разработали отдельные методы выделения памяти и копирования. // разместить в памяти объект типа AVLTreeNode. прервать программу, // если во время выделения памяти произошла ошибка template <class T> AVLTreeNode<T> *AVLTree<T>::GetAVLTreeNode(const T& item, AVLTreeNode<T> *lptr, AVLTreeNode<T> *rptr) { AVLTreeNode<T> *p; p - new AVLTreeNode<T> (item, lptr, rptr); if (p == NULL) { cerr « "Ошибка выделения памяти!" « endl; exit(1); } return p/ } Для удаления узлов AVL-дерева достаточно методов базового класса. Метод DeleteTree из класса BinSTree задействует виртуальный деструктор класса TreeNode.
Метод Insert класса AVLTree. Преимущество AVL-деревьев состоит в их сбалансированности, которая поддерживается соответствующими алгоритмами вставки/удаления. Опишем метод Insert для класса AVLTree, который перекрывает одноименную операцию базового класса BinSTree. При реализации метода Insert для запоминания элемента используется рекурсивная функция AVLInsert. Сначала приведем код метода Insert на C++, а затем сосредоточим внимание на рекурсивном методе AVLInsert, реализующем алгоритм Адельсона-Вельского и Ландиса. template <class T> void AVLTree<T>::Insert(const T& item) { // объявить указатель AVL-дерева, используя метод // базового класса GetRoot. // произвести приведение типов для указателей AVLTreeNode<T> *treeRoot = (AVLTreeNode<T> *)GetRoot()/ *newNode; // флажок, используемый функцией AVLInsert для перебалансировки узлов int reviseBalanceFactor = 0; // создать новый узел AVL-дерева с нулевыми полями указателей newNode = GetAVLTreeNode(item, NULL, NULL); // вызвать рекурсивную процедуру для фактической вставки элемента AVLInsert(treeRoot, newNode, reviseBalancefactor); // присвоить новые значения элементам данных базового класса root = treeRoot; current = newNode; size++; } Ядром алгоритма включения является рекурсивный метод AVLInsert. Как и его аналог в классе BinSTree, этот метод осуществляет прохождение левого поддерева, если item меньше данного узла, и правого поддерева, если item больше или равен данному узлу. Эта закрытая функция имеет параметр с именем tree, в котором находится запись текущего узла при сканировании, новый узел для вставки в дерево, флажок revisebalanceFactor. При сканировании левого или правого поддерева некоторого узла, этот флажок является признаком изменения любого параметра balanceFactor в поддереве. Если да, то нужно проверить, сохранилась ли AVL-сбалансированность всего дерева. Если в результате включения нового узла она оказалась нарушенной, то мы обязаны восстановить равновесие. Данный алгоритм рассматривается на ряде примеров. Алгоритм AVL-вставки. Процесс включения является почти таким же, что и для бинарного дерева поиска. Осуществляется рекурсивный спуск по левым и правым сыновьям, пока не встретится пустое поддерево, а затем производится пробное включение нового узла в этом месте. В течение этого процесса мы посещаем каждый узел на пути поиска от корневого к новому элементу. Поскольку процесс рекурсивный, обработка узлов ведется в обратном порядке. При этом показатель сбалансированности родительского узла можно скорректировать после изучения эффекта от добавления нового элемента в одно из поддеревьев. Необходимость корректировки определяется для каждого узла, входящего в поисковый маршрут. Есть три возможных ситуации.
В первых двух случаях узел сохраняет сбалансированность и реорганизация поддеревьев не требуется, а нужно лишь скорректировать показатель сбалансированности данного узла. В третьем случае расбалансировка дерева требует одинарного или двойного поворотов узлов. Случай 1. Узел на поисковом маршруте изначально является сбалансированным (balanceFactor = 0). После включения в поддерево нового элемента узел стал перевешивать влево или вправо в зависимости от того, в какое поддерево было произведено включение. Если элемент был включен в левое поддерево, показателю сбалансированности присваивается -1, а если в правое, то 1. Например, на пути 40-50-60 каждый узел сбалансирован. После включения узла 55 показатели сбалансированности изменяются. До включения узла 55 После включения узла 55 Случай 2. Одно из поддеревьев узла перевешивает, и новый узел включается в более легкое поддерево. Узел становится сбалансированным. Сравните, например, состояния дерева до и после включения узла 55. До включения узла 55 После включения узла 55 Случай 3. Одно из поддеревьев узла перевешивает, и новый узел включается в более тяжелое поддерево. Тем самым нарушается условие сбалансированности, так как balanceFactor выходит за пределы -1..1. Чтобы восстановить равновесие, нужно выполнить поворот. Рассмотрим пример. Предположим, дерево разбалансировалось слева и мы восстанавливаем равновесие, вызывая одну из функций поворота вправо. Раз- балансировка справа влечет за собой симметричные действия. Сказанное иллюстрируется следующими рисунками. При разработке алгоритма поворота мы включили дополнительные детали. Метод AVLInsert. Продвигаясь вдоль некоторого пути для вставки нового узла, этот рекурсивный метод распознает все три указанных выше случая корректировки. При нарушении условия сбалансированности восстановление равновесия осуществляется с помощью функций UpdateLeftTree и Up- dateRightTree.
До корректировки После корректировки Одинарный поворот До корректировки После корректировки Двойной поворот template <class T> void AVLTree<T>: .-AVLInsert (AVLTreeNode<T>* &tree, AVLTreeNode<T>* newNode, int ^reviseBalanceFactor) { // флажок "Показатель сбалансированности был изменен" int rebalanceCurrNode/ // встретилось пустое поддерево, пора включать новый узел if (tree == NULL) { // вставить новый узел tree = newNode; // объявить новый узел сбалансированным tree->balanceFactor = balanced; // сообщить об изменении показателя сбалансированности reviseBalanceFactor = 1; } // рекурсивно спускаться по левому поддереву, // если новый узел меньше текущего else if (newNode->data < tree->data) { AVLInsert(tree->Left(), newNode, rebalanceCurrNode); // проверить, нужно ли корректировать balanceFactor if (rebalanceCurrNode) { // включение слева от узла, перевешивающего влево, будет нарушено // условие сбалансированности; выполнить поворот (случай 3) if (tree->balanceFactor == leftheavy) UpdateLeftTree(tree, reviseBalanceFactor); // вставка слева от сбалансированного узла. // узел станет перевешивать влево (случай 1) else if (tree->balanceFactor == balanced) { tree->balanceFactor = leftheavy; reviseBalanceFactor = 1; } // вставка слева от узла, перевешивающего вправо. // узел станет сбалансированным (случай 2)
else { tree->balanceFactor = balanced; reviseBalanceFactor = 0; } } else // перебалансировка не требуется, не опрашивать предыдущие узлы reviseBalanceFactor = 0; } // иначе рекурсивно спускаться по правому поддереву else if (newNode->data < tree->data) { AVLInsert(tree->Right(), newNode, rebalanceCurrNode); // проверить, нужно ли корректировать balanceFactor if (rebalanceCurrNode) { // вставка справа от узла, перевешивающего вправо, будет нарушено // условие сбалансированности; выполнить поворот (случай 3) if (tree->balanceFactor == rightheavy) UpdateRightTree(tree, reviseBalanceFactor); // вставка справа от сбалансированного узла. // узел станет перевешивать вправо (случай 1) else if (tree->balanceFactor == balanced) { tree->balanceFactor = rightheavy; reviseBalanceFactor = 1; } // вставка справа от узла, перевешивающего влево. // узел станет сбалансированным (случай 2) else { tree->balanceFactor = balanced; reviseBalanceFactor = 0; } } else // перебалансировка не требуется, не опрашивать предыдущие узлы reviseBalanceFactor = 0; } } Метод AVLInsert распознает случай 3, когда нарушается AVL-условие. Для выполнения перебалансировки используются методы UpdateLeftTree и UpdateRightTree. Они выполняют одинарный или двойной поворот для уравновешивания узла, а затем сбрасывают флажок reviseBalanceFactor. Перед тем как обсудить специфические детали поворотов, приведем программный код функции UpdateLeftTree. template <class T> void AVLTree<T>::UpdateLeftTree(AVLTreeNode<T>* &p, int reviseBalanceFactor) { AVLTreeNode<T> *lc; lc = p->Left(); // перевешивает левое поддерево? if (lc->balanceFactor == leftheavy) { SingleRotateRight(p); // однократный поворот
reviseBalanceFactor = 0; } // перевешивает правое поддерево? else if (lc->balanceFactor == rightheavy) { // выполнить двойной поворот DoubleRotateRight(p); // теперь корень уравновешен reviseBalanceFactor - 0; } } Повороты. Повороты необходимы, когда родительский узел Р становится расбалансированным. Одинарный поворот вправо (single right rotation) происходит тогда, когда родительский узел Р и его левый сын LC начинают перевешивать влево после включения узла в позицию X. В результате такого поворота LC замещает своего родителя, который становится правым сыном. Бывшее правое поддерево узла LC (ST) присоединяется к Р в качестве левого поддерева. Это сохраняет упорядоченность, так как узлы в ST больше или равны узлу LC, но меньше узла Р. Поворот уравновешивает как родителя, так и его левого сына. // выполнить поворот по часовой стрелке вокруг узла р. // сделать 1с новой точкой вращения template <class T> void AVLTree<T>::SingleRotateRight (AVLTreeNode<T>* &p) { // левое, перевешивающее поддерево узла р AVLTreeNode<T> *lc; // назначить 1с левым поддеревом lc = p->Left(); // скорректировать показатель сбалансированности для // родительского узла и его левого сына p->balanceFactor = balanced; lc->balanceFactor = balanced; // правое поддерево узла 1с в любом случае должно оставаться справа // от 1с. выполнить это условие, сделав st левым поддеревом узла р p->Left() = lc->Right(); // переместить р в правое поддерево узла 1с. // сделать 1с новой точкой вращения. lc->Right() = р; р - 1с; }
Попытка включить узел 5 в изображенное ниже AVL-дерево нарушает AVL- условие для узла 30 из равновесия. Одновременно левое поддерево узла 15 (LC) становится перегруженным. Для переупорядочения узлов вызывается процедура SingleRotateRight. В результате родительский узел (30) становится сбалансированным, а узел 10 — перевешивающим влево. Исходное дерево Показатели сбалансированности до поворота После включения узла 5 Двойной поворот вправо (double rigyn rotation) происходит тогда, когда родительский узел (Р) становится перевешивающим влево, а его левый сын (LC) — перевешивающим вправо. NP — корень правого перевешивающего поддерева узла LC. Тогда в результате поворота узел NP замещает родительский узел. На следующих далее рисунках показаны два случая включения нового узла в качестве сына узла NP. В обоих случаях NP становится родительским узлом, а бывший родитель Р становится правым сыном NP. На верхней схеме мы видим сдвиг узла Xlt после того как он был вставлен в левое поддерево узла NP. На нижней схеме изображено перемещение узла Х2 после его включения в правое поддерево NP.
// двойной поворот вправо вокруг узла р template <class T> void AVLTree<T>::DoubleRotateRight (AVLTreeNode<T>* &p) { // два поддерева, подлежащих повороту AVLTreeNode<T> *lc, *np; // узел lc <= узел np < узел р lc = p->Left(); // левый сын узла р пр = lc->Right(); // правый сын узла 1с // обновить показатели сбалансированности в узлах р, 1с и пр if (np->balanceFactor == rightheavy) { p->balanceFactor = balanced; lc->balanceFactor = rightheavy; } else { p->balanceFactor = rightheavy; lc->balanceFactor = balanced; } np->balanceFactor = balanced; // перед тем как заменить родительский узел р, // следует отсоединить его старых детей и присоединить новых lc->Right() =np->Left(); np->Left() = lc; p->Left() = np->Right(); np->Right() = p; p - np; } Двойной поворот иллюстрируется на изображенном ниже дереве. Попытка включить узел 25 разбалансирует корневой узел 50. В этом случае узел 20 (LC) приобретает слишком высокое правое поддерево и требуется двойной поворот. Новым родительским узлом (NP) становится узел 40. Старый родительский узел становится его правым сыном и присоединяет к себе узел 45, который также переходит с левой стороны дерева. До включения узла 25 После включения узла 25 Оценка сбалансированных деревьев Ценность AVL-деревьев зависит от приложения, поскольку они требуют дополнительных затрат на поддержание сбалансированности при включении или исключении узлов. Если в дереве постоянно происходят вставки и удаления элементов, эти операции могут значительно снизить быстродействие. С другой стороны, если ваши данные превращают бинарное дерево поиска в вырожденное, вы теряете поисковую эффективность и вынуждены использовать AVL-дерево.
Для сбалансированного дерева не существует наихудшего случая, так как оно является почти полным бинарным деревом. Сложность операции поиска составляет 0(log2n). Опыт показывает, что повороты требуются примерно в половине случаев включений и удалений. Сложность балансировки обусловливает применение AVL-деревьев только там, где поиск является доминирующей операцией. Программа 13.4. Оценка AVL-деревьев Эта программа сравнивает сбалансированное и обычное бинарное дерево поиска, каждое из которых содержит N случайных чисел. Они хранятся в едином массиве и включаются в оба дерева. Для каждого элемента массива осуществляется его поиск в обоих деревьях. Длины поисковых путей суммируются, а затем подсчитывается средняя длина поиска по каждому дереву. Программа прогоняется на 1000- и на 10000-элементном массивах. Обратите внимание, что на случайных данных поисковые характеристики AVL-дерева несколько лучше. В самом худшем случае вырожденное дерево поиска, содержащее 1000 элементов, имеет среднюю глубину 500, в то время как средняя глубина AVL-дерева всегда равна 9. #include <iostream.h> #include "bstree.h" #include "avltree.h" #include "random.h" // загрузить массив, бинарное поисковое дерево и AVL-дерево // одинаковыми множествами, состоящими из п случайных чисел от 0 до 999 void SetupLists(BinSTree<int> fiTreel, AVLTree<int> &Tree2, int A[], int n) { int i; RandomNumber rnd; // запомнить случайное число в массиве А, а также включить его // в бинарное дерево поиска и в AVL-дерево for (i=0; i<n; i++) { A[i] = rnd.Random(1000); Treel.Insert(A[i]); Tree2.Insert(A[i]); } } // поиск элемента item на дереве t. накапливается суммарная длина поиска template <class T> void PathLength(TreeNode<T> *t, long &totallength, int item) { // возврат, если элемент найден или отсутствует в списке if (t == NULL I| t->data « item) return; else { // перейти на следующий уровень. // увеличить суммарную длину пути поиска totallength++; if (item < t->data)
PathLength(t->Left{), totallength, item); else PathLength(t->Right(), totallength, item); } } void main(void); { // переменные для деревьев и массива BinSTree<int> binTree; AVLTree<int> avlTree; int *A; // суммарные длины поисковых путей элементов массива //в бинарном дереве поиска и в AVL-дереве long totalLengthBintree = 0, totalLengthAVLTree = 0; int n, i; cout « "Сколько узлов на дереве? "; cin » n; // загрузить случайными числами массив и оба дерева SetupLists(binTree, avlTree, A, n); for (i=0; i<n; i++) { PathLength(binTree.GetRoot(), totalLengthBintree, A[i]); PathLength((TreeNode<int> *)avlTree.getRoot(), totalLengthAVLTree, A[i]); } cout « "Средняя длина поиска для бинарного дерева = " « float(totalLengthBintree)/n « endl; cout « "Средняя длина поиска для сбалансированного дерева - " « float(totalLengthAVLTree)/n « endl; > /* <Прогон #1 программы 13.4> Сколько узлов на дереве? 1000 Средняя длина поиска для бинарного дерева = 10.256 Средняя длина поиска для сбалансированного дерева = 7.901 <Прогон #2 программы 13.4> Сколько узлов на дереве? 10000 Средняя длина поиска для бинарного дерева = 12.2822 Средняя длина поиска для сбалансированного дерева = 8.5632 */ 13.7. Итераторы деревьев Мы уже убедились в силе итераторов, применяемых для обхода таких линейных структур, как массивы и последовательные списки. Сканирование узлов дерева более сложно, так как дерево является нелинейной структурой и существует не один порядок прохождения. Утилиты класса TreeNode из гл. 11 реализуют прямой, симметричный и обратный алгоритмы рекурсивного прохождения. Проблема каждого из этих методов прохождения состоит в том, что
до завершения рекурсивного процесса из него невозможно выйти. Нельзя остановить сканирование, проверить содержимое узла, выполнить какие-нибудь операции с данными, а затем вновь продолжить сканирование со следующего узла дерева. Используя же итератор, клиент получает средство сканирования узлов дерева, как если бы они представляли собой линейный список, без обременительных деталей алгоритмов прохождения, лежащих в основе процесса. Итератор симметричного метода прохождения В гл. 12 мы разработали абстрактный класс Iterator для создания множества базовых методов прохождения списков. Класс Iterator задает общий формат для методов прохождения независимо от деталей их реализации в базовом классе. В данном разделе на основе этого базового класса строится итератор симметричного бинарного дерева. Симметричное прохождение бинарного дерева поиска, в процессе которого узлы посещаются в порядке возрастания их значений, является полезным инструментом. Конструирование итераторов прямого, поперечного и обратного методов прохождения предлагается в качестве упражнений. ОБЪЯВЛЕНИЕ // итератор симметричного прохождения бинарного дерева. // использует базовый класс Iterator template <class T> class Inorderlterator: public Iterator<T> { private: // поддерживать стек адресов узлов Stack< TreeNode <T> * > S; // корень дерева и текущий узел TreeNode<T> *root, *current; // сканирование левого поддерева, используется функцией Next TreeNode<T> *GoFarLeft(TreeNode<T> *t); public: // конструктор Inorderlterator(TreeNode<T> *tree); // реализации базовых операций прохождения virtual void Next(void); virtual void Reset(void); virtual T& Data(void); // назначение итератору нового дерева void SetTree(TreeNode<T> *tree); }; ОПИСАНИЕ Класс Inorderlterator построен по общему для всех итераторов образцу. Метод EndOfList определен в базовом классе Iterator. Конструктор инициализирует базовый класс и с помощью GoFarLeft определяет начальный узел сканирования. Класс Inorderlterator находится в файле treeiter.h. ПРИМЕР TreeNode<int> *root; // бинарное дерево Inorderlterator treeiter(root); // присоединить итератор // распечатать начальный узел сканирования.
// для смешанного прохождения это самый левый узел дерева cout << treeiter.Data(); // сканирование узлов и печать их значений for (treeiter.Reset О; !treeiter.EndOfList(); treeiter.Next ()) cout « treeiter.Data () « " ";. Реализация класса Inorderlterator Итерационный симметричный метод прохождения эмулирует рекурсивное сканирование с помощью стека адресов узлов. Начиная с корня, осуществляется спуск вдоль левых поддеревьев. По пути указатель каждого пройденного узла запоминается в стеке. Процесс останавливается на узле с нулевым левым указателем, который становится первым посещаемым узлом в симметричном сканировании. Спуск от узла t и запоминание адресов узлов в стеке выполняет метод GoFarLeft. Вызов этого метода с t=root определяет первый посещаемый узел. Указатель, возвращаемый функцией GoFarLeft // вернуть адрес крайнего узла на левой ветви узла t. // запомнить в стеке адреса всех пройденных узлов template <class T> TreeNode<T> *InorderIterator<T>::GoFArLeft(TreeNode<T> *t) { // если t=NULL, вернуть NULL if (t == NULL) return NULL; // пока не встретится узел с нулевым левым указателем, // спускаться по левым ветвям, запоминая в стеке S // адреса пройденных узлов, возвратить указатель на этот узел while (t->Left() != NULL) { S.Push(t); t = t->Left() ; } return t; } После инициализации базового класса конструктор присваивает элементу данных root адрес корня бинарного дерева поиска. Узел для начала симметричного сканирования получается в результате вызова функции GoFarLeft с root в качестве параметра. Это возвращаемое значение становится текущим для указателя типа TreeNode. // инициализировать флажок iterationComplete. базовый класс // сбрасывает его, но дерево может оказаться пустым, начальным // узлом сканирования является самый крайний слева узел, template <class T> InorderIterator<T>::Inorderlterator(TreeNode<T> *tree): Iterator<T>(), root(tree)
{ iterationComplete = (root == NULL); current = GoFarLeft(root); } Метод Reset по существу является таким же, как и конструктор, за исключением того, что он очищает стек. Перед первым обращением к Next указатель current уже указывает на первый узел симметричного сканирования. Метод Next работает по следующему алгоритму. 1. Если правая ветвь узла не пуста, перейти к его правому сыну и осуществить спуск по левым ветвям до узла с нулевым левым указателем, попутно запоминая в стеке адреса пройденных узлов. 2. Если правая ветвь узла пуста, то сканирование его левой ветви, самого узла и его правой ветви завершено. Адрес следующего узла, подлежащего обработке, находится в стеке. Если стек не пуст, удалить следующий узел. Если же стек пуст, то все узлы обработаны и сканирование завершено. Итерационное прохождение дерева, состоящего из пяти узлов, изображено на следующем рисунке. Обработка узла В Обработка узла Е t Обработка узла D Обработка узла А * Обработка узла С t = NULL Алгоритм завершен template <class T> void InorderIterator<T>::Next(void) { // ошибка, если все узлы уже посещались if (iterationComplete) { cerr « "Next: итератор прошел конец списка!" « endl; exit(l); } // current - текущий обрабатываемый узел. // если есть правое поддерево, спуститься до конца по его левой ветви, // попутно запоминая в стеке адреса пройденных узлов if (current->Right() != NULL) current = GoFarLeft(current->Right()); // правого поддерева нет, но в стеке есть другие узлы, // подлежащие обработке, вытолкнуть из стека новый текущий адрес else if (IS.StackEmpty()) // продвинуться вверх по дереву current = S.PopO;
// нет ни правого поддерева ни узлов в стеке, сканирование завершено else iterationComplete = 1; } Приложение: алгоритм TreeSort Когда объект типа Inorderlterator осуществляет прохождение дерева поиска, узлы проходятся в сортированном порядке и, следовательно, можно построить еще один алгоритм сортировки, называемый TreeSort. Этот алгоритм предполагает, что элементы изначально хранятся в массиве. Поисковое дерево служит фильтром, куда элементы массива копируются в соответствии с алгоритмом включения в бинарное дерево поиска. Осуществляя симметричное прохождение этого дерева и записывая элементы снова в массив, мы получаем в результате отсортированный список. На рис. 13.5 показана сортировка 8-элементного массива. Указанный алгоритм реализуется функцией TreeSort, которая находится в файле treesort.h. Рис 13.5. Алгоритм TreeSort #include "bstree.h" ♦include "treeiter.h" // использование бинарного дерева поиска для сортировки массива template <class T> void TreeSort(T arr[], int n) { // бинарное дерево поиска, в которое копируется массив BinSTree<T> sortTree; int i; // включить каждый элемент массива в поисковое дерево for (i=0; i<n; i++) sortTree.Insert(arr[i]); // объявить итератор симметричного прохождения для sortTree InorderIterator<T> treeSortlter(sortTree.GetRoot()); // выполнить симметричное прохождение дерева. // скопировать каждый элемент снова в массив i = 0; while (!treeSortlter.EndOfList()) { arr[i++] * treeSortlter.Data(); treeSortlter.Next(); } } Эффективность сортировки включением в дерево. Ожидаемое число сравнений, необходимых для включения узла в бинарное дерево поиска, равно
0(log2n). Поскольку в дерево включается п элементов, средняя эффективность должна быть 0(n log2n). Однако в худшем случае, когда исходный список отсортирован в обратном порядке, она составит 0(п2). Соответствующее дерево поиска вырождается в связанный список. Покажем, что худший случай требует 0(п2) сравнений. Первое включение требует 0 сравнений. Второе включение — двух сравнений (одно с корнем и одно для определения того, в какое поддерево следует вставлять данное значение). Третье включение требует трех сравнений, 4-е — четырех, ..., п-е включение требует п сравнений. Тогда общее число сравнений равно: 0 + 2 + 3+ ... +п= {1 + 2 + 3+ ... +п) - 1 =* п(п+1)/2 -1 - 0(п2) Для каждого узла дерева память должна выделяться динамически, поэтому худший случай не лучше, чем сортировка обменом. Когда п случайных значений повторно вставляются в бинарное дерево поиска, можно ожидать, что дерево будет относительно сбалансированным. Наилучшим случаем является законченное бинарное дерево. Для этого случая можно оценить верхнюю границу, рассмотрев полное дерево глубиной d. На i-ом уровне (l<i<Ld) имеется 21 узлов. Поскольку для помещения узла на уровень i требуется i+1 сравнение, сортировка на полном дереве требует (i+1) * 21 сравнений для включения всех элементов на уровень i. Если вспомнить, что п = 2<d+1)-l, то верхняя граница меры эффективности выражается следующим неравенством: d d X (i + x)2i * (d + J) X 2i e (d + l)(2d+1 - 2) - (d + l)(2d+1 - 1 - 1) i=l i=l = (d + l)(n - 1) = (n - 1) log2(n + 1) = 0(n log2n) Таким образом, эффективность алгоритма в лучшем случае составит 0(n log2n). 13.8. Графы Дерево есть иерархическая структура, которая состоит из узлов, исходящих от корня. Узлы соединяются указателями от родителя к сыновьям. В этом разделе мы познакомимся с графами, которые являются обобщенными иерархическими структурами. Граф состоит из множества элементов данных, называемых вершинами (vertices), и множества ребер (edges), соединяющих эти вершины попарно. Ребро Е = (Vi, Vj) соединяет вершины Vi и Vj. Вершины = {V0, Vlf V2, V3, ..., V^} Ребра = {E0, Ei, E2, E3, ..., En.i) Пусть вершины обозначают города, а ребра — дорожное сообщение между ними. Движение по дорогам может происходить в обоих направлениях, и поэ- Солт-Лэйк-Сити Сан-Франциско Сан-Диего Феникс Альбукерк
Направленный граф Ненаправленный граф Вершины V = {A,B,C,D,E} Ребра Е = {(A,C)I(AID),(B,A),(B/D),(D,E),(E,B)} Вершины V = {A,B,C,D,E} Ребра Е = {(A,B),(A,C),(A,D),(B,A),(B,D).(B,E), (C,A),(D,B),(D,E),(E,B),(E,D)} Рис.13.6. Направленный и ненаправленный графы тому ребра графа G не имеют направлений. Такой граф называется ненапрвленным (undirected graph). Если ребра представляют систему связи с однонаправленными информационными потоками, то граф в этом случае становится направленным графом (directed graph), или орграфом (digraph). На рис. 13.6 показаны графы обоих типов. Мы сосредоточим внимание на орграфах. В орграфе ребро задается парой (Vi9 Vj), где VA — начальная вершина, а Vj — конечная вершина. Путь (path) P(VS, VE) есть последовательность вершин VS=VR, VR+1, ..., VR+T=VE, где Vs — начальная вершина, VE — конечная вершина, а каждая пара членов последовательности есть ребро. В орграфе указывается направленный путь от VB к VE, но пути от VE к VB может и не быть. Например, для орграфа на рис. 13.6 Путь(А,В) = {A,D,E,B} Путь(Е,С) = {Е,В,А,С} Путь(В,А) = {В,А} Путь(С,Е) = {} // пути нет Связанные компоненты С понятием пути связано понятие связанности орграфа. Две вершины V* и Vj связаны (connected), если существует путь от Vi к Vj. Орграф является сильно связанным (strongly connected), если в нем существует направленный путь от любой вершины к любой другой. Орграф является слабо связанным (weakly connected), если для каждой пары вершин Vt и Vj существует направленный путь P(Vi, Vj) или P(Vj, Vi). Связанность графов иллюстрируется на рис. 13.7. (А) Не сильно или слабо связанный (В) Сильно связанный (С) Слабо связанный Рис. 13.7. Сильно и слабо связанные компоненты орграфа
Мы расширили понятие сильносвязанных вершин до сильно связанной компоненты (strongly connected component) — максимального множества вершин {VJ, где для каждой пары Vi и Vj существует путь от Vi к Vj и путь от Vj к Vi. Цикл (cycle) — это путь, проходящий через три или более вершины и связывающий некоторую вершину саму с собой. В ориентированном графе (С) на рис. 13.7 существуют циклы для вершин А (А->С->В->А), В и С. Граф, не содержащий циклов, называется ациклическим (acycle). Во взвешенном орграфе (weighted digraph) каждому ребру приписано значение, или вес. На транспортном графе веса могут представлять расстояния между городами. На графе планирования работ веса ребер определяют продолжительность конкретной работы. Прокладка внешнего трубопровода 5 дней 15 дней Прокладка внутреннего трубопровода 1 день Начало 8 дней Возведение стен 3 дня 7 дней 6 дней 2 дня Конец 8 дней 2 дня Бетонирование Подключение канализации Покрытие крыши 13.9. Класс Graph В этом разделе мы опишем структуру данных для взвешенного орграфа. Начнем с математического определения графа как основы абстрактного типа данных (ADT) Graph. Вершины задаются в виде списка элементов, а ребра — в виде списка упорядоченных пар вершин. Объявление абстрактного типа данных Graph Взвешенный орграф состоит из вершин и взвешенных ребер. ADT включает в себя операции, которые будут добавлять или удалять эти элементы данных. Для каждой вершины VA определяются все смежные с ней вершины Vj, которые соединяются с V\ ребрами E(Vi, Vj). ADT Graph Данные Множество вершин {Vt} и ребер {Е±}. Ребро есть пара (Vif Vj), которая указывает на связь вершины V± с вершиной Vj. Приписанный каждому ребру вес определяет стоимость прохождения по этому ребру. Операции Конструктор Вход: Нет Обработка: Создает граф в виде множества вершин и ребер. InsertVertex Вход: Новая вершина.
Предусловия: Нет Обработка: Вставить новую вершину в множество вершин. Выход: Нет Постусловия: Список вершин увеличивается. InsertEdge Вход: Пара вершин VL и Vj с весом W. Предусловия: VL и Vj должны принадлежать множеству вершин, а ребро (Vif Vj) не должно принадлежать множеству ребер. Обработка: Вставить ребро {Vit Vj) с весом W в множество ребер. Выход: Нет Постусловия: Множество ребер увеличивается. DeleteVertex Вход: Ссылка на вершину VD. Предусловия: Входная вершина должна принадлежать множеству вершин. Обработка: Удалить вершину VD из списка вершин. Удалить все входящие и исходящие ребра этой вершины. Выход: Нет Постусловия: Множество вершин и множество ребер модифицируются. DeleteEdge Вход: Пара вершин VA и Vj. Предусловия: Входные вершины должны принадлежать множеству вершин. Обработка: Если ребро {Vif Vj) существует, удалить его из множества ребер. Выход: Нет Постусловия: Множество ребер модифицируется. GetNeighbors Вход: Вершина V. Предусловия: Нет Обработка: Идентифицировать все смежные с V вершины VE, такие, что (V, VE) есть ребро. Выход: Список смежных вершин. Постусловия: Нет GetWeight Вход: Пара вершин Vi и Vj. Предусловия: Входные вершины должны принадлежать множеству вершин. Обработка: Выдать вес ребра (Vif Vj), если оно существует. Выход: Вес ребра или 0, если ребра не существует. Постусловия: Нет Конец ADT Graph Представление графов. Существует много способов представления орграфов. Можно просто хранить вершины в виде последовательного списка Vo, Vi, ..., Vm-l, а ребра задавать квадратной матрицей размером m x m, называемой матрицей смежности (adjcency matrix). Здесь строка i и столбец j соответствуют вершинам Vi и Vj. Каждый элемент (i, j) этой матрицы содержит вес ребра Eij = (Vi, Vj) или 0, если такого ребра нет. Для невзвешенного орграфа элементы матрицы смежности содержат 0 или 1, показывая отсутствие или наличие соответствующего ребра. Ниже приводятся примеры орграфов со своими матрицами смежности.
В другом способе представления графов каждая вершина ассоциируется со связанным списком смежных с ней вершин. Эта динамическая модель хранит информацию лишь о фактически принадлежащих графу вершинах. Для взвешенного орграфа каждый узел связанного списка содержит поле веса. Примеры спискового представления орграфов даны ниже. Вершины Список смежных вершин Вершины Список смежных вершин Класс Graph, рассматриваемый в этом разделе, использует матричное представление ребер. Мы используем статическую модель графа, которая предполагает конечное число вершин. Матричное представление упрощает реализацию класса и позволяет сосредоточиться на целом ряде алгоритмов обработки графов. Реализация на основе связанных списков предлагается в
упражнениях. Основными особенностями класса Graph являются представление ADT Graph, метод ReadGraph и ряд поисковых алгоритмов, осуществляющих прохождение вершин способами "сначала в глубину" и "сначала в ширину". Данный класс включает также итератор списка вершин для использования в приложениях. Спецификация класса Graph ОБЪЯВЛЕНИЕ const int MaxGraphSize =25; template <class T> class Graph { private: // основные данные включают список вершин, матрицу смежности //и текущий размер (число вершин) графа SeqList<T> vertexList; int edge [MaxGraphSize]; int graphsize; // методы для поиска вершины и указания ее позиции в списке int FindVertex(SeqList<T> &L, const T& vertex); int GetVertexPos(const T& vertex); public: // конструктор Graph(void); // методы тестирования графа int GraphEmpty(void) const; int GraphFull(void) const; // методы обработки данных int NumberOfVertices(void) const; int NumberOfEdges(void) const; int GetWeight(const T& vertexl, const T& vertex2); SeqList<T>& GetNeighbors(const T& vertex); // методы модификации графа void InsertVertex(const T& vertex); void InsertEdge(const T& vertexl, const T& vertex2, int weight); void DeleteVertex(const T& vertex); void DeleteEdge(const T& vertexl, const T& vertex2); // утилиты void ReadGraph(char *filename); int MinimumPath(const T& sVertex, const T& sVertex); SeqList<T>& DepthFirstSearch(const T& beginVertex); SeqList<T>& BreadthFirstSearch(const T& beginVertex); // итератор для обхода вершин friend class VertexIterator<T>; }; ОПИСАНИЕ Данные-члены класса включают вершины, хранящиеся в виде последовательного списка, ребра, представленные двумерной целочисленной матрицей смежности, и переменную graphsize, являющуюся счетчиком вершин. Значение graphsize возвращается функцией NumberOfVertices.
Утилита FindVertex проверяет наличие вершины в списке L и используется в поисковых методах. Метод GetVertexPos вычисляет позицию вершины vertex в vertexList. Эта позиция соответствует индексу строки или столбца в матрице смежности. Методу ReadGraph передается в качестве параметра имя файла с входным описанием вершин и ребер графа. Класс Vertexlterator является производным от класса SeqListlterator и позволяет осуществлять прохождение вершин. Итератор упрощает приложения. ПРИМЕР Graph <char> G; // граф с символьными вершинами G.ReadGraph("graph.dat"); // ввести данные из graph.dat // Пример входного описания графа <Количество вершин> 4 ВершинаО А Вершина1 В Вершина2 С ВершинаЗ D <Количество ребер> 5 РеброО ВесО А С 1 Ребро1 Bed A D 1 Ребро2 Вес2 В А 1 РеброЗ ВесЗ С В 1 Ребро4 Вес4 D А 1 VertexIterator<char> viter(G); // итератор для вершин SeqList<char>L; for (viter.Reset(); !viter.EndOfList(); viter.Next()) { cout « "Вершины, смежные с вершиной " « viter.DataO « ": "; L - G.GetNeighbors(viter.DataO); // распечатать смежные вершины SeqListIterator<char> liter(L); // список смежных вершин for (liter.Reset(); Iliter.EndOfListО; liter.Next()) cout « liter.Data() « " "; } Реализация класса Graph Конструктор класса Graph "отвечает" за инициализацию матрицы смежности размера MaxGraphSize x MaxGraphSize и обнуление переменной graphsize. Конструктором обнуляется каждый элемент матрицы для указания на отсутствие ребер. // конструктор, обнуляет матрицу смежности и переменную graphsize template <class T> Graph<T>::Graph(void) { for (int i=0; i<MaxGraphSize; i++) for (int j=0; j<MaxGraphSize; j++) edgefi][j] - 0; graphsize = 0; } Подсчет компонентов графа. Переменная graphsize хранит размер списка вершин. Обращение к этому закрытому члену класса осуществляется посредством метода NumberOfVertices. Оператор GraphEmpty проверяет, пуст ли список.
Доступ к компонентам графа. Компоненты графа содержатся в списке вершин и матрице смежности. Итератор вершин, являясь дружественным по отношению к классу Graph, позволяет сканировать список вершин. Этот итератор — наследник класса SeqListlterator. template ass T> class Vertexlterator: public SeqListIterator<T> { public: Vertexlterator(Graph<T>& G) ; ); Конструктор просто инициализирует базовый класс для прохождения списка вершин vertexList. template ass T> VertexIterator<T>::Vertexlterator(Graph<T>& G): SeqListIterator<T> (G.vertexList) {} Итератор сканирует элементы vertexList и используется для реализации функции GetVertexPos, которая осуществляет сканирование списка вершин и возвращает позицию вершины в этом списке. template ass T> int Graph<T>::GetVertexPos(const T& vertex) { SeqListIterator<T> liter(vertexList); int pos * 0; while(!liter.EndOfList () && liter.Data() !« vertex) { pos++; liter.Next(); } return pos; } Метод GetWeight возвращает вес ребра, соединяющего vertexl и vertex2. Чтобы получить позиции этих двух вершин в списке, а следовательно, и строку со столбцом в матрице смежности, используется функция GetVertexPos. Если любая из двух вершин отсутствует в списке вершин, метод возвращает -1. Метод GetNeighbors создает список вершин, смежных с vertex. Этот список является выходным параметром и может быть просканирован с помощью итератора последовательных списков. Если vertex не имеет смежных вершин, метод возвращает пустой список. // возвратить список смежных вершин template <class T> SeqList<T>fi Graph::GetNeighbors(const T& vertex) { SeqList<T> *L; SeqListIterator<T> viter(vertexList); // создать пустой список L = new SeqList<T>; // позиция в списке, соответствующая номеру строки матрицы смежности int pos ■ GetVertexPos(vertex); // если вершины vertex нет в списке вершин, закончить if (pos -« -1) { cerr << "GetNeighbors: такой вершины нет в графе." « endl;
return *L; // вернуть пустой список } // сканировать строку матрицы смежности и включать в список // все вершины, имеющие ребро ненулевого веса из vertex for (int i=0; Kgraphsize; i++) { if (edge[pos][i] > 0) L->lnsert(viter.Data() ) ; viter.Next(); } return *L; } Обновление вершин и ребер. Чтобы вставить ребро, мы используем GetVertexPos для проверки наличия vertexl и vertex2 в списке вершин. Если какая-либо из них не будет обнаружена, выдается сообщение об ошибке и осуществляется возврат управления. Если позиции posl и pos2 получены, метод InsertEdge записывает вес ребра в элемент (posl, pos2) матрицы смежности. Эта операция выполняется за время О(п), поскольку каждый вызов GetVertexPos требует О(п) времени. Метод Delete Vertex класса Graph удаляет вершину из графа. Если вершины нет в списке, выдается сообщение об ошибке и осуществляется возврат управления. В противном случае удаляются все ребра, соединяющие удаляемую вершину с другими вершинами. При этом в матрице смежности должны быть скорректированы три области. Поэтому эта операция выполняется за время 0(п2), так как каждая область является частью матрицы n x п. pos pos Область I: Сдвинуть индекс столбца влево Область II: Сдвинуть индекс строки вверх и индекс столбца влево Область III: Сдвинуть индекс строки вверх // удалить вершину из списка вершин и скорректировать матрицу // смежности, удалив принадлежащие этой вершине ребра template <class T> void Graph<T>::DeleteVertex(const T& vertex) { // получить позицию вершины в списке вершин int pos = GetVertexPos(vertex); int row, col; // если такой вершины нет, сообщить об этом и вернуть управление if (pos == -1)
{ cerr « "DeleteVertex: вершины нет графы" « endl; return; } // удалить вершину и уменьшить graphsize vertexList.Delete(vertex); graphsize--; // матрица смежности делится на три области for (row=0; row<pos; row++) // область I for (col=pos+l; col<graphsize; col++) edge[row][col-1] = edge[row][col]; for (row=pos+l; row<graphsize; row++) // область II for (col=pos+l; col<graphsize; col++) edge[row-1][col-1] = edge[row][col]; for (row=pos+l; row<graphsize; row++) // область III for {col=0; col<pos; col++) edge[row-1][col] = edge[row][col]; } Удаление ребра производится путем удаления связи между двумя вершинами. После проверки наличия вершин в vertexList метод DeleteEdge присваивает данному ребру нулевой вес, оставляя все другие ребра неизменными. Если такого ребра нет в графе, процедура выдает сообщение об ошибке и завершается. Способы прохождения графов Для прохождения нелинейных структур требуется разработать стратегию доступа к узлам и маркирования узлов после обработки. Поисковые методы для бинарных деревьев имеют свои аналоги для графов. В нисходящем обходе бинарного дерева применяется такая стратегия, при которой сначала выполняется обработка узла, а затем уже продвижение вниз по поддереву. Обобщением прямого метода прохождения для графов является поиск "сначала в глубину" (depth-first). Начальная вершина передается в качестве параметра и становится первой обрабатываемой вершиной. По мере продвижения вниз до тупика смежные вершины запоминаются в стеке, с тем чтобы можно было к ним вернуться и продолжить поиск по другому пути в случае, если еще остались необработанные вершины. Обработанные вершины образуют множество всех вершин, достижимых из начальной вершины. Характерное для деревьев поперечное сканирование начинается с корня, а обход узлов осуществляется уровень за уровнем сверху вниз. Аналогичная стратегия применяется при поиске "сначала в ширину" (breadth-first) на гра-
фах, когда, начиная с некоторой начальной вершины, производится обработка каждой смежной с ней вершины. Затем сканирование продолжается на следующем уровне смежных вершин и т.д. до конца пути. При этом для запоминания смежных вершин используется очередь. Проиллюстрируем оба поисковых алгоритма на следующем графе. В данном случае начальной вершиной является А. Поиск "сначала в глубину". Для хранения обработанных вершин используется список L, а для запоминания смежных вершин — стек S. Поместив начальную вершину в стек, мы начинаем итерационный процесс выталкивания вершины из стека и ее обработки. Когда стек становится пустым, процесс завершается и возвращает список обработанных вершин. На каждом шаге используется следующая стратегия. Вытолкнуть вершину V из стека и проверить по списку L, была ли она обработана. Если нет, произвести обработку этой вершины, а также воспользоваться удобным случаем и получить список смежных с ней вершин. Включить V в список L, чтобы избежать повторной обработки. Поместить в стек те смежные с V вершины, которых еще нет в списке L. В нашем примере предполагалось, что вершина А является начальной. Поиск начинается с выталкивания А из стека и обработки этой вершины. Затем А включается в список обработанных вершин, а смежные с ней вершины В и G помещаются в стек. После обработки А стек S и список L выглядят следующим образом: Стек S Список L Итерация продолжается. Вершина G выталкивается из стека. Так как этой вершины пока нет в списке L, она включается в него, а в стек помещается единственная смежная с ней вершина F: Стек S Список L Вытолкнув из стека вершину F и поместив ее в список L, мы достигаем тупика, поскольку смежная с F вершина А уже находится в L. В стеке остается вершина В, которая была идентифицирована как смежная с А на первой фазе поиска: Стек S Список L Вершины В и С обрабатываются в указанном порядке. Теперь стек содержит вершины D и Е, являющиеся смежными с вершиной С: Стек S Список L У вершины Е две смежные вершины: D и F, и обе они являются подходящими для записи в стек. Однако F уже была обработана на пути A-G-F и
поэтому пропускается. Зато вершина D помещается в стек дважды, поскольку наш алгоритм не "знает", что D достижима из С: Стек S Список L Поиск завершается после обработки вершины D. Второй экземпляр D в стеке игнорируется, так как эта вершина уже находится в списке L: Список L // начиная с начальной вершины, сформировать список вершин, // обрабатываемых в порядке обхода "сначала в глубину" template <class T> SeqList <T> & Graph<T>::DepthFirstSearch{const T& beginVertex) { // стек для временного хранения вершин, ожидающих обработки Stack<T> S; // L - список пройденных вершин. adjL содержит вершины, // смежные с текущей. L создается динамически, поэтому можно // возвратить его адрес SeqList<T> *L, adjL; // iteradjL - итератор списка смежных вершин SeqListIterator<T> iteradjL(adjL); Т vertex; // инициализировать выходной список. // поместить начальную вершину в стек L = new SeqList<T>; S.Push(beginVertex); // продолжать сканирование, пока не опустеет стек while (IS.StackEmpty()) { // вытолкнуть очередную вершину vertex = S.PopO ; // проверить ее наличие в списке L if (!FindVertex(*L, vertex)) { // если нет, включить вершину в L, //а также получить все смежные с ней вершины (*L).Insert(vertex); adjL = GetNeighbors(vertex); // установить итератор на текущий adjL iteradjL.SetList(adjL); // сканировать список смежных вершин. // помещать в стек те из них, которые отсутствуют в списке L for (iteradjL.Reset(); !iteradjL.EndOfList()/ iteradjL.Next()) if (!FindVertex(*L, iteradjL.Data())) S.Push(iteradjL.Data()); } } // возвратить выходной список return *L; }
Поиск "сначала в ширину". Как и в поперечном прохождении бинарного дерева, при поиске "сначала в ширину" для хранения вершин используется очередь, а не стек. Итерационный процесс продолжается до тех пор, пока очередь не опустеет. Удалить вершину V из очереди и проверить ее наличие в списке обработанных вершин. Если вершины V нет в списке L, включить ее в этот список. Одновременно получить все смежные с V вершины и вставить в очередь те из них, которые отсутствуют в списке обработанных вершин. Если применить этот алгоритм к рассмотренному в предыдущем примере графу, то вершины будут обрабатываться в следующем порядке: А В G С F D Е Анализ сложности. В описанных алгоритмах поиска посещение каждой вершины требует времени вычислений О(п). При добавлении вершины в список обработанных вершин для обнаружения смежных с ней вершин проверяется строка матрицы смежности. Каждая строка — это О(п), следовательно общее время вычислений равно п*0(п) = 0(п2). Число сравнений, требующихся в случае матричного представлении графа, не зависит от количества ребер в графе. Даже если в графе относительно мало ребер ("разреженный граф"), мы обязаны произвести п сравнений для каждой вершины. В списковом представлении графа быстродействие алгоритма поиска зависит от плотности ребер в графе. В лучшем случае ребер нет и длина каждого списка смежных вершин равна 1. Тогда время вычислений для каждого поиска будет 0(п+п) = О(п). В худшем случае каждая вершина связана с каждой и длина каждого списка смежных вершин равна п. Тогда алгоритм поиска имеет порядок 0(п2). Приложения Вспомним, что орграф является сильно связанным, если существует направленный путь от любой его вершины к любой другой. Сильная компонента (strong component) есть подмножество вершин, сильно связанных друг с другом. Сильно связанный граф имеет одну сильную компоненту, но всякий граф может быть разбит на ряд сильных компонент. Например, на рис. 13.8 граф разбит на три сильных компоненты. В теории графов для определения сильных компонент используются классические алгоритмы. В данном приложении мы используем для этого поиск "сначала в глубину". Функция PathConnect проверяет существование направленного пути от вершины v к вершине w и возвращает булевские TRUE и FALSE, соответственно. Компоненты А,В,С D,F,G Е
template <class T> int PathConnect (Graph<T> &G, T v, T w) { SeqList<T> L; // найти вершины, связанные с v L = G.DepthFirstSearch(v); // если w в их числе, вернуть TRUE if (L.Find(w)) return 1; else return 0; } Функция ConnectedComponent начинает работу с пустого списка вершин с именем markedList. Этот список все время содержит коллекцию вершин, которые были обнаружены в сильной компоненте. Итератор осуществляет обход вершин графа. Каждая вершина V проверяется на наличие в списке markedList. Если ее там нет, то должна быть построена новая сильная компонента, содержащая V. Список scList, который будет содержать новую сильную компоненту, очищается, и с помощью поиска "сначала в глубину" формируется список L всех вершин, достижимых из V. Для каждой вершины списка L с помощью функции PathConnect проверяется существование пути обратно к V. Если таковой существует, вершина включается в scList и в markedList. Обратите внимание, что вершина вставляется в оба этих списка. Поскольку существует путь от V к каждой вершине из scList и путь от каждой вершины из scList обратно к V, то, следовательно, существует путь между любыми двумя вершинами из scList. Эти вершины и есть очередная сильная компонента. Так как каждая вершина в scList црисутствует также в markedList, она не будет рассматриваться повторно. template <class T> void ConnectedComponent (Graph<T> &G) { VertexIterator<T> viter(G); SeqList<T> markedList, scList, L, K; for (viter.Reset(); !viter.EndOfList(); viter.Next()) { // проверять в цикле каждую вершину viter.Data () if (JmarkedList.Find(viter.Data())) // если не помечен, включить в сильную компоненту { scList.ClearList(); // получить вершины, достижимые из viter.Data() L = G.DepthFirstSearch(viter.Data()); // искать в списке вершины, из которых достижима вершина viter.Data () SeqListIterator<T> liter(L); for (liter.Reset(); Iliter.EndOfList(), liter.Next()) if (PathConnect(G, liter.Data(), viter.Data())) { // вставить вершины в текущую сильную компоненту и в markedList scList.Insert(liter.Data()); markedList.Insert(liter.Data()); } PrintList(scList); // распечатать сильную компоненту cout « endl; } } )
Программа 13.5. Сильные компоненты Эта программа находит сильные компоненты в графе, изображенном на рис. 13.8. Граф вводится из файла sctest.dat с помощью ReadGraph. Функции PathConnect, ConnectedComponent и PrintList находятся в файле conncomp.h. #include <iostream.h> #include <fstream.h> #include "graph.h" #include "conncomp.h" void main(void) { Graph<char> G; G.ReadGraph("sctest.dat"); cout « "Сильные компоненты:" « endl; ConnectedComponent(G); } /* <Прогон программы 13.5> Сильные компоненты: ABC D G F E */ Минимальный путь. Методы прохождения "сначала в глубину" и "сначала в ширину" находят вершины, достижимые из начальной вершины. При этом движение от вершины к вершине не оптимизируется в смысле минимального пути. Между тем во многих приложениях требуется выбрать путь с минимальной "стоимостью", складывающейся из весов ребер, составляющих путь. Для решения этой задачи мы представляем класс Pathlnfo. Объект, порождаемый этим классом, описывает путь, существующий между двумя вершинами, и его стоимость. Объекты типа Pathlnfo запоминаются в очереди приоритетов, которая обеспечивает прямой доступ к объекту с минимальной стоимостью. template <class T> struct Pathlnfo { Т startV, endV; }; template <class T> int operator <= (const PathInfo<T>& a, const PathInfo<T>& b) { return a.cost <= b.cost; } Так как между вершинами графа может существовать несколько разных путей, объекты типа Pathlnfo могут соответствовать одним и тем же вершинам, но иметь разные стоимости. Например, в показанном ниже графе между вершинами А и D существуют три пути с различными стоимостями.
Путь А- С - D А- В - D А- В - С- D Стоимость 13 14 11 Для сравнения стоимостей в классе Pathlnfo определен оператор "<=". Алгоритм проверяет объекты типа Pathlnfo, хранящиеся в очереди приоритетов, и выбирает объект с минимальной стоимостью. Определение минимального пути между начальной (sVertex) и конечной (eVertex) вершинами иллюстрируется на следующем графе. Если между ними нет вообще никакого пути, алгоритм завершается выдачей соответствующего сообщения. Пусть вершина А будет начальной, a D — конечной. Начать с создания первого объекта типа Pathlnfo, соединяющего начальную вершину саму с собой при нулевой начальной стоимости. Включить объект в очередь приоритетов. Очередь приоритетов Для нахождения минимального пути мы следуем итерационному процессу, который удаляет объекты из очереди приоритетов. Если конечная вершина в объекте есть еVertex, то мы имеем минимальный путь, стоимость которого находится в поле cost. В противном случае просматриваем все вершины, смежные с текущей конечной вершиной endV, и в искомый путь, начинающийся из sVertex, включается еще одно ребро. В нашем примере мы хотим найти минимальный путь от А до D. Удаляем единственный объект Pathlnfo, в котором endV = А. Если бы вершина А являлась заданной конечной вершиной еVertex, процесс завершился бы с нулевой минимальной стоимостью. Поскольку вершина А не есть еVertex, она запоминается в списке L, содержащим все вершины, до которых минимальный путь из А известен. Смежными с А вершинами являются вершины В, С и Е. Для каждой из них создается объект типа Pathlnfo, и все эти объекты помещаются в очередь приоритетов. Стоимость пути из А до каждой из этих вершин равна стоимость (A, endV) + sectendV, <смежная вершина>)
Объект Pathlnfo 0А,в 0А,с Од,Е startV А А А endV В С Е Стоимость 4 12 4 Объекты включаются в очередь приоритетов в следующем порядке: Очередь приоритетов На следующем шаге объект 0АВ удаляется из очереди приоритетов. В нем вершина В есть endV с минимальной стоимостью 4. Поскольку вершины В нет в списке L, она включается в него. Ясно, что не существует последующего пути от А к В со стоимостью меньшей, чем 4. Если бы существовал путь А-Х-...-В и смежная с А вершина X находилась бы от нее на расстоянии меньшем, чем 4, то вершина X оказалась бы первой в очереди приоритетов и была бы удалена оттуда раньше вершины В. Смежными с В вершинами являются А, С и D. Так как А уже в списке L, объекты типа Pathlnfo создаются для вершин С и D и включаются в очередь приоритетов. Объект Pathlnfo 0Б.с Ob,d startV В В endV С С Стоимость 10=4+6 12=4+8 Результирующая очередь приоритетов содержит четыре элемента. Обратите внимание, что два разных объекта заканчиваются в вершине С. Минимальный из них, имеющий стоимость 10, был только что добавлен в очередь приоритетов и представляет путь А-В-С. Прямой путь А-С, определенный на первом шаге, имеет стоимость 12. Очередь приоритетов После удаления объекта 0АЕ и установления минимальной стоимости пути от А к Е равной 4 создается объект 0E)D стоимостью 14. Очередь приоритетов После очередного удаления объекта с минимальной стоимостью, которым являлся Овс, вершина С может быть добавлена в список L, так как 10 является минимальной стоимостью пути от А к С. Поскольку конечной вершиной искомого минимального пути является D, мы ожидаем удаления объекта с endV=D. У вершины С есть смежные вершины В и D. Так как В уже обработана, в очередь приоритетов включается только объект Oc,d* Объект Pathlnfo Oc>d startV С endV D Стоимость 24=10+14
После удаления объекта 0А)С из очереди приоритетов он отбрасывается, так как С уже есть в списке. Теперь очередь приоритетов имеет три элемента. Очередь приоритетов Удаляя 0b,d из очереди приоритетов, мы тем самым устанавливаем минимальную стоимость пути от А к D равной 12. template <class T> int Graph<T>::MinimumPath(const T& sVertex, const T& eVertex) { // очередь приоритетов, в которую помещаются объекты, // несущие информацию о стоимости путей из sVertex PQueue< PathInfo<T> > PQ(MaxGraphSize); // используется при вставке/удалении объектов Pathlnfo // в очереди приоритетов PathInfo<T> pathData/ // L — список всех вершин, достижимых из sVertex и стоимость // которых уже учтена. adjL — список вершин, смежных с посещаемой // в данный момент, для сканирования adjL используется итератор // adjLiter SeqList<T> L, adjL; SeqListIterator<T> adjLiter(adjL); T sv, ev; int mincost; // сформировать начальный элемент очереди приоритетов pathData.startV = sVertex; pathData.endV = sVertex; // стоимость пути из sVertex в sVertex равна О pathData.cost = 0; PQ.PQInsert(pathData); // обрабатывать вершины, пока не будет найден минимальный путь // к eVertex или пока не опустеет очередь приоритетов while ( IPQ.PQEmptyO ) { // удалить элемент приоритетной очереди и запомнить // его конечную вершину и стоимость пути от sVertex pathData = PQ.PQDelete(); ev = pathData.endV; mincost = pathData.cost; // если это eVertex, // то минимальный путь от sVertex к eVertex найден if (ev =« eVertex) break; // если конечная вершина уже имеется в L, // не рассматривать ее снова if (!FindVertex(L, ev)) { // Включить ev в список L L.Insert(ev); // найти все смежные с ev вершины. Для тех из них, // которых нет в L, сформировать объекты Pathlnfo с начальными // вершинами, равными ev, и включить их в очередь приоритетов sv = ev; adjL = GetNeighbors(sv);
// новый список adjL сканируется итератором adjLiter adjLiter.SetList(adjL); for (adjLiter.Reset(); ladjLiter.EndOfList(); adjLiter.Next()) { ev = adjLiter.Data(); if (!FindVertex{L,ev)) { // создать новый элемент приоритетной очереди pathData.startV = sv; pathData.endV = ev; // стоимость равна текущей минимальной стоимости // плюс стоимость перехода от sv к ev pathData.cost = mincost + GetWeight (sv, ev); PQ.PQInsert(pathData); } } } } if (ev « eVertex) return mincost; else return -1; } Программа 13.6. Система авиаперевозок Транспортная система авиакомпании имеет список городов на некотором маршруте полетов. Пользователь вводит исходный город, а процедура определения минимального пути выдает кратчайшие расстояния между этим городом и всеми прочими пунктами назначения. Эта авиалиния соединяет главные города на Западе. Солт-Лэйк-Сити Сан-Франциско Сан-Диего Феникс Альбукерк #include <iostream.h> #include <fstream.h> #include "strclass.h" #include "graph.h" // метод MinimumPath void main(void) { // вершины типа символьных строк (названия городов) Graph<String> G; String S;
// ввод описания транспортного графа G.ReadGraph("airline.dat"); // запросить аэропорт отправления cout « "Выдать мин. расстояние при отправлении из "; cin » S; //с помощью итератора пройти список городов и определить // мин. расстояния от точки отправления VertexIterator<String> viter(G); for (viter.Reset(); !viter.EndOfList(); viter.Next()) cout « "Минимальное расстояние от аэропорта " << S « « " до аэропорта " « viter.Data() « " = " « G.MinimumPath(S, viter.Data()) « endl; } /* <Прогон #1 программы 13.6> Выдать минимальное расстояние при отправлении из Солт-Лэйк-Сити Мин. расстояние от аэропорта Солт-Лэйк-Сити до аэропорта Солт-Лэйк-Сити = О Мин. расстояние от аэропорта Солт-Лэйк-Сити до аэропорта Альбукерк =» 604 Мин. расстояние от аэропорта Солт-Лэйк-Сити до аэропорта Феникс =648 Мин. расстояние от аэропорта Солт-Лэйк-Сити до аэропорта Сан-Франциско =752 Мин. расстояние от аэропорта Солт-Лэйк-Сити до аэропорта Сан-Диего = 1003 <Прогон #2 программы 13.б> Выдать мин. расстояние при отправлении из Сан-Франциско Мин. расстояние от аэропорта Сан-Франциско до аэропорта Солт-Лэйк-Сити = 752 Мин. расстояние от аэропорта Сан-Франциско до аэропорта Альбукерк = 1195 Мин. расстояние от аэропорта Сан-Франциско до аэропорта Феникс =763 Мин. расстояние от аэропорта Сан-Франциско до аэропорта Сан-Франциско = 0 Мин. расстояние от аэропорта Сан-Франциско до аэропорта Сан-Диего = 504 */ Достижимость и алгоритм Уоршалла Для каждой пары вершин некоторого графа говорят, что Vj достижима из Vj тогда и только тогда, когда существует направленный путь от Vi к Vj. Это определяет отношение достижимости R (reachability relation R). Для каждой вершины Vi поиск "сначала в глубину" находит все вершины, достижимые из Vie При использовании поиска "сначала в ширину" получается серия списков достижимости, которые образуют отношение R: Vi: <Список достижимости для Vi> V2: <Список достижимости для V2> * • • Vn: <Список достижимости для Vn> Это же отношение можно также описать с помощью матрицы достижимости (reachability matrix) размером n x n, которая содержит 1 в позиции (U), представляя тем самым VA R Vj. В следующем примере показаны списки и матрица достижимости для изображенного здесь графа.
Списки достижимости А: А В С D В: В D С: С В D: Матрица достижимости 1111 0 10 1 0 110 0 0 0 1 Матрицу достижимости можно использовать для проверки существования пути между двумя вершинами. Если элемент (ij) равен 1, то существует минимальный путь между V\ и Vj. Вершины в списке достижимости можно использовать для наращивания ребер в исходном графе. Если существует путь из вершины v к вершине w (w достижима из v), мы добавляем ребро E(v,w), соединяющее эти две вершины. Расширенный граф G1 состоит из вершин V графа G и ребер, связывающих вершины, которые соединены направленным путем. Такой расширенный граф называется транзитивным замыканием (transitive closure). Ниже приводится пример графа и его транзитивного замыкания. Задача нахождения списка достижимости с помощью поиска "сначала в глубину" предлагается читателю в качестве упражнения. Более изящный подход применяется в знаменитом алгоритме Стефана Уоршалла. Матрица достижимости некоторого графа может быть построена путем присвоения 1 каждой паре вершин, связанных общей вершиной. Предположим, мы строим матрицу достижимости R и вершинам а, Ь, с соответствуют индексы i, j, k. Если R[i][j] = 1 и R[i][k] = 1, установить R[i][j] = 1. Алгоритм Уоршалла проверяет все возможные тройки с помощью трех вложенных циклов по индексам i, j и к. Для каждой пары (i,j) добавляется ребро E(vi,Vj), если существует вершина Vk, такая, что ребра E(vi,Vk) и E(vk,Vj) находятся в расширенном графе. Повторяя этот процесс, мы соединяем дополнительными ребрами любую пару достижимых вершин. В результате получается матрица достижимости.
Предположим, что вершины v и w достижимы через направленный путь, связывающий пять вершин. Тогда существует последовательность вершин, формирующих путь v = хь х2, х3, х4, х5 = w Имея путь от v до w, мы должны показать в матрице достижимости, что алгоритм Уоршалла в конце концов даст тот же путь. С помощью трех вложенных циклов мы проверяем все возможные тройки вершин. Допустим, вершины идут в порядке Хх-х5. В процессе просмотра различных троек вершина х2 идентифицируется как общий узел, связывающий хх и х3. Следовательно, согласно алгоритма Уоршалла мы вводим новое ребро Е(х!,х3). Для пары xlf x4 общей связующей вершиной является х3, так как путь, соединяющий X! и х3, был найден на предыдущем шаге итерации. Поэтому мы создаем ребро Е(х!,х4). Таким же образом х4 становится общей вершиной, связывающей хх и х5, и мы добавляем ребро Е(х1,х5) и присваиваем элементу R[l][5] значение 1. Проиллюстрируем алгоритм Уоршалла на следующем графе. Здесь дополнительные ребра, добавленные для формирования транзитивного замыкания, изображены пунктиром. Исходный граф Транзитивное замыкание Матрица достижимости 1110 1 0 110 1 0 0 10 0 11111 0 0 10 1 Алгоритм Уоршалла имеет время вычисления 0(п3). При сканировании матрицы смежности применяются три вложенных цикла. Списковое представление графа также дает сложность 0(п3), Программа 13.7. Использование алгоритма Уоршалла Алгоритм Уоршалла используется для создания и печати матрицы достижимости. #include <iostream.h> #include <fstream.h> #include "graph.h" template <class T> void Warshall(Graph<T>& G) { VertexIterator<T> vi(G), vj(G);
int i, j, k; int WSM[MaxGraphSize][MaxGraphSize]; // матрица Уоршалла int n - G.NumberOfVertices(); // создать исходную матрицу for (vi.Reset(); i=0; !vi.EndOfList(); vi.Next(), i++) for (vj . Reset (); i=0; !vj .EndOfList () ; vj.NextO, j++) if (i == j) WSM[iJ [i] = 1; else WSM[i][j] = G.GetWeight(vi.Data(), vj.DataO); // просмотреть все тройки, записать 1 в WSM, если существует ребро // между vi и vj или существует тройка vi-vk-vj, соединяющая // vi и vj for (i=0; i<n; i++) for <j=0; j<n; j++) for (k=0; k<n; k++) WSM[i][j] |=WSM[iJ[k] &WSM[k]j]; // распечатать каждую вершину и ее строку из матрицы достижимости for {vi.Reset (); i=0; !vi .EndOfList () ; vi.NextO, i++) { cout « vi.DataO « ": "; for (j=0; j<n; j++) cout « WSM[i][j] « " "; cout « endl; } } void main(void) { Graph<char> G; G.ReadGraph("warshall.dat") ; cout « "Матрица достижимости:" << endl; Warshall(G); } /* <Прогон программы 13.7> Матрица достижимости: A: 1 1 1 0 1 В: 0 1 1 0 1 С: 0 0 1 0 0 D: 1 1 1 1 1 Е: 0 0 1 0 1 */ Письменные упражнения 13.1 Нарисуйте законченное дерево для каждого из следующих массивов: а) int А[8] = {5, 9, 3, 6, 2, 1, 4, 7} б) char *B = "array-based tree" 13.2 Для каждого из изображенных ниже деревьев задайте соответствующий массив.
13.3 Напишите функцию, выполняющую а) прямое и б) симметричное прохождение представленного массивом дерева. В качестве образца используйте ранее разработанный код для спискового представления деревьев. 13.4 Пусть А есть массив, состоящий из 70 элементов и представляющий дерево. а) Является ли А[45] листовым узлом? б) Какой индекс у первого листового узла? в) Кто родитель узла А[50]? г) Кто является сыном узла А[10]? д) Имеет ли какой-нибудь узел только одного сына? е) Какова глубина этого дерева? ж) Сколько листьев у этого дерева? 13.5 а) Напишите функцию, которая принимает N-элементный массив типа Т и выполняет поперечное прохождение представляемого им дерева. Для запоминания элементов ваша процедура должна использовать очередь. Распечатайте элементы по уровням (один уровень на строке). б) Сможете ли вы разработать более простую версию поперечного обхода, используя тот факт, что дерево представлено в виде массива? 13.6 Покажите, что в законченном бинарном дереве число листовых узлов больше и равно числу не листовых узлов. Покажите, что в полном бинарном дереве число листовых узлов больше, чем не листовых.
13.7 Законченное бинарное дерево, содержащее 50 узлов, представлено в виде массива. а) Сколько уровеней в этом дереве? б) Сколько узлов являются листовыми? Нелистовыми? в) Какой индекс у родителя узла В[35]? г) Какие индексы у сыновей узла В[20]? д) Какой индекс у первого листового узла? У первого узла, имеющего одного сына? е) Какие индексы у узлов четвертого уровня? 13.8 Распишите по шагам турнирную сортировку массива А = {7, 10, 3, 9, 4, 12, 15, 5, 1, 8}. 13.9 Модифицируйте турнирную сортировку так, чтобы листовые и нелистовые узлы содержали фактические данные из массива. Оформите модифицированный код в виде функции ModTournamentSort. 13.10 Скажите, являются ли приведенные ниже бинарные деревья пирамидами (минимальными или максимальными)? 13.11 Для каждого дерева, не являющегося пирамидой, из предыдущего упражнения создайте минимальную и максимальную пирамиды. Для каждой минимальной (максимальной) пирамиды создайте соответствующую максимальную (минимальную) пирамиду. 13.12 С помощью FiterDown и конструктора сделайте из следующего дерева минимальную пирамиду.
13.13 Даны пирамида А и пирамида В. Над каждой из них последовательно произведите указанные ниже операции. Пирамида А а) вставить 15 б) вставить 35 в) удалить 10 г) вставить 40 д) вставить 10 Пирамида В а) удалить 22 б) вставить 35 в) вставить 65 г) удалить 15 д) удалить 27 е) вставить 5 13.14 а) Каково наибольшее число узлов, которое может существовать в дереве, являющемся минимальной пирамидой и бинарным деревом поиска одновременно? Дублирующиеся значения не допускаются. б) Каково наибольшее число узлов, которое может существовать в дереве, являющемся максимальной пирамидой и бинарным деревом поиска одновременно? Дублирующиеся значения не допускаются. 13.15 Для следующей пирамиды перечислите узлы, составляющие указанный путь. а) Путь родителей, начинающийся в узле 47. б) Путь родителей, начинающийся в узле 71. в) Путь через минимальных сыновей, начинающийся в узле 35. г) Путь через минимальных сыновей, начинающийся в узле 10. д) Путь через минимальных сыновей, начинающийся в узле 40 на первом уровне.
13.16 Постройте минимальную пирамиду, для каждого из перечисленных ниже массивов. а) int A[10] = {40, 20, 70, 30, 90, 10, 50, 100, 60, 80}; б) int А[8] = {3, 90, 45, 6, 16, 45, 3, 88}; в) char *В = "heapify"; г) char *B = "minimal heap"; 13.17 Реализуйте класс очередей приоритетов PQueue с помощью класса Bin- STree. (Совет. Модифицируйте метод PQDelete для поиска и удаления минимального элемента дерева.) 13.18 Постройте AVL-дерево для каждой из указанных последовательностей. а) <int> 30, 50, 25, 70, 60, 40, 55, 45, 20 б) <int> 44, 22, 66, 11, 0, 33, 77, 55, 88, в) <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 г) <String> tree, AVL, insert, delete, find, root, search д) <String> class, object, public, private, derived, base, inherit, method, constructor, abstract 13.19 Разработайте итерационную функцию прямого сканирования Preor- der_I. Она должна эмулировать следующую рекурсивную функцию Preorder. template <class T> void Preorder (TreeNode<T> *t, void visit (T& item)) { while (t != NULL) { visit(t->data); Preorder<t->Left{), visit); t = t->Right(); } } После обработки узла обрабатывается сначала левое, а затем правое поддеревья. Итерационное прямое прохождение должно эмулировать рекурсивное сканирование с помощью стека адресов узлов. Предположим, мы обработали узел А бинарного дерева. Теперь мы должны обработать его левое поддерево и вернуться к предыдущей точке, чтобы обработать его правое поддерево. Чтобы помнить необходимость посещения правого поддерева, мы помещаем правый указатель узла А в стек. После обработки всех, узлов левого поддерева А этот указатель выталкивается из стека, и мы возвращаемся к прохождению правого поддерева. Описанные ситуации показаны на следующих рисунках.
Обработать А. Перейти к левому поддереву Поместить С в стек вершина стека Вытолкнуть С из стека вершина стека Вытолкнуть С из стека и обработать Алгоритм итерационного прохождения состоит из следующих шагов. Начиная с корня, выполнять в цикле 1. Посетить узел 2. Сохранить адрес его правого сына в стеке адресов. 3. Перейти к его левому сыну. Всякий раз, когда в цикле встречается нулевой узел, обрабатываются правые сыновья, адреса которых последовательно выталкиваются из стека. Цикл прекращается, когда встретится нулевой указатель и в стеке больше нет правых сыновей. 13.20 Функция PostorderJ осуществляет восходящее прохождение дерева. Однако задача более сложна, чем просто восходящее или смешанное прохождение. Требуется различать спуск по левому поддереву (состояние 0) и возврат к родительскому узлу (состояние 1). При движении вверх по дереву возможны два действия — посещение правого поддерева узла или обработка самого узла. Состояние хранится в целочисленной переменной state. Если state == 0, происходит движение вниз по дереву. Если state == 0, происходит движение вверх. Когда мы попадаем в узел сверху, родитель этого узла находится на вершине стека. Чтобы определить, пришли ли мы слева, нужно сравнить указатель текущего узла с левым указателем родительского узла. Если они согласуются и у родителя есть правое поддерево, следует перейти в это поддерево. В противном случае нужно обработать узел и продолжить движение вверх. Пройти левое поддерево Обработать узел и подняться вверх Пройти правое поддерево
Нарисуйте несколько диаграмм, как те, что на рис. 13.5 и 13.6, чтобы проиллюстрировать работу этого алгоритма, реализованного следующей программой. #include <iostream.h> #include "treenode.h" #include "treelib.h" #include "stack.h" void printchar(char& item) { cout « item « " "; } template <class T> void Postorder_I(TreeNode<T> *t, void visit(T& item)) { Stack<TreeNode<T> *> S; TreeNode<T> *child; int state = 0, scanOver = 0; while (!scanOver) { if (state == 0) { if (t != NULL) { S.Push(t); t = t->Left(); } else state « 1; } else { if (S.StackEmptyO ) scanOver = 1; else { child = t; t = S.PeekO ; if (child == t->Left() && t->Right() != NULL) { t = t->Right(); state = 0; } else { visit(t->data); S.Pop(); } } } } } void main(void) { TreeNode<char> *root; MakeCharTree(root, 0); PrintTree(root, 0); cout « endl; Postorder_I(root, printchar);
cout « endl; } <Прогон> С A D В D В Е С А 13.21 Для каждого из приведенных ниже графов задайте матрицу смежности и списки смежности. 13.22 Используйте графы из предыдущего упражнения и ответьте на следующие вопросы, касающиеся путей. Если пути не существует, отвечайте: "Пути нет". а) Найти в графе (А) направленный путь от Е к В. б) Найти в графе (В) направленный путь от А к Е. в) Найти в графе (С) направленные пути от В к Е и от Е к В. г) Найти в графе (В) все узлы, смежные с А (с Е). д) Найти в графе (С) такие узлы X, для которых имеется путь Р(А,Х) и Р(Х,А). е) Перечислите связанные компоненты в каждом графе. ж) Какой из этих графов является сильно связанным (слабо связанным)?
13.23 Возьмите графы из письменного упражнения 13.21. Перечислите вершины в порядке их прохождения методом "сначала в глубину" и методом "сначала в ширину". а) В графе (А), начав с вершины А. б) В графе (В), начав с вершины А. в) В графе (С), начав с вершины А. г) В графе (В), начав с вершины В. 13.24 Создайте частичную реализацию класса Graph, основанного на списковом представлении графов. Определите класс Vertexlnfo, содержащий имя вершины и связанный список смежных с ней вершин. Информация о смежных вершинах должна храниться в виде структуры, включающей имя конечной вершины и вес ребра, соединяющего начальную и конечную вершины. Для сравнения наименований вершин следует добавить в класс Vertexlnfo перегруженный оператор сравнения. а) Реализуйте конструктор. б) Реализуйте методы GraphEmpty, NumberOfVertices, GetNeighbors, Insert Vert ex, InsertEdge и DepthFirstSearch. 13.25 а) Модифицируйте функцию MinimumPath, чтобы создать функцию MinimumLength, которая находит путь между двумя вершинами, содержащий минимальное число вершин. Если не существует вообще никакого пути, возвращайте -1. б) Создайте функцию VertexLength, которая принимает в качестве параметров граф G и начальную вершину V и распечатывает вершины графа, а также длины путей до этих вершин от V. Используйте функцию MinimumLength из упражнения а). 13.26 Опишите действие следующей функции: template <class T> SeqList<T> RV(Graph<T> &G) { SeqList<T> L; VertexIterator<T> viter(G); ListIterator<T> liter(L); for (viter.Reset(); !viter.EndOfList(); viter.Next()) { cout « viter.Data() « ": L = G.BreadthFirstSearch(viter.Data()); liter.SetList(L); PrintList(L); cout « endl; } } 13.27 Постройте соответствующий граф по каждой из следующих матриц смежности. Считайте, что вершины являются буквами А, В, С и т.д. а) 0 1 1 1 10 11 110 1 1110
б) 0 110 0 0 0 0 10 10 0 0 1 10 0 10 0 10 10 13.28 Для каждого из следующих графов задайте матрицу достижимости. (В) Упражнения по программированию 13.1 С помощью функций, разработанных вами в письменном упражнении 13.3, определите число листовых узлов, число узлов, имеющих по одному сыну и число узлов, имеющих по два сына. Используйте глобальные переменные nochild, onechild и twochild в программе, которая тестирует и распечатывает следующие глобальные данные: int А[50]; // хранит данные 0, 1, 2, ..., 49 int А[100]; // хранит данные 0, 1, 2, . .., 99 13.2 а) Разработайте тестовую программу для упорядочения с помощью турнирной сортировки следующих символьных строк. Создайте массив объектов типа String и передайте его в функцию TournamentSort. class, object, public, private, derived, base, inherit, method, constructor, abstract б) Создайте массив из 100 случайных чисел от 0 до 999 и отсортируйте его по возрастанию с помощью модифицированной функции турнирной сортировки ModTournamentSort из письменного упражнения 13.3. Распечатайте результирующий массив.
13.3 Модифицируйте класс Heap, приспособив его для создания максимальных пирамид. Вызовите этот новый класс МахНеар и протестируйте его операции с помощью варианта программы 13.3, сортирующей элементы массива. 13.4 Реализуйте функции FilterUp и FilterDown класса Heap рекурсивным способом. Протестируйте их на массиве из 15 случайных чисел в диапазоне от 0 до 99. Используйте функцию HDelete для удаления N элементов из пирамиды и печати их значений. 13.5 В некоторой компьютерной системе каждой работающей программе (процессу) назначается приоритет. Наивысшим приоритетом является О, а самым низким — 39. Когда приходит время выполнить некоторый процесс, в очередь приоритетов вставляется запись запроса на обработку. Когда ЦП свободен, запись удаляется и соответствующий процесс запускается на выполнение. Запись запроса на обработку имеет следующий формат: struct ProcessRequestRecord { int priority; String name; ); Поле name идентифицирует конкретный процесс. Сгенерируйте случайным образом 10 записей запроса на обработку с именами "Process A", "Process В", ..., "Process J" и приоритетами в диапазоне от 0 до 39. Распечатайте каждую запись, а затем включите их в приоритетную очередь. Потом удаляйте из очереди и распечатывайте каждую запись. 13.6 Определите структуру, содержащую значение данных и приоритет. template <class T> struct PriorityData { Т data; int priority; >; Используйте эту структуру и очередь приоритетов для реализации класса Queue. (Совет. Объявите очередь как список записей типа Priority- Data. Элементы запоминаются в очереди приоритетов в порядке, определяемом полем priority каждой записи. Определите целочисленную переменную PL, увеличивающуюся на единицу всякий раз, когда очередной элемент включается в очередь. Пусть значение PL и будет приоритетом очередной включаемой в очередь записи.) Протестируйте свою очередь, введя пять целых чисел и запомнив их в очереди с помощью Qlnsert. Затем последовательно удаляйте и распечатывайте элементы, пока очередь не опустеет. 13.7 Используйте модель из предыдущего упражнения и класс МахНеар из упражнения 13.3 для реализации стека с помощью очереди приоритетов. Испытайте новый класс, введя пять целых чисел и запомнив их в стеке с помощью Push. Затем последовательно удаляйте с помощью Pop и распечатывайте элементы, пока очередь не опустеет.
13.8 Разработайте класс итераторов ArrPreorderlterator для прямого прохождения представленных массивами деревьев. Объявите его следующим образом: template <class T> class ArrPreorderlterator: public Iterator<T> { private: Stack<int> S; T *A; int arraySize; int currents- public: ArrPreorderlterator(T *Arr, int n); virtual void Next(void); virtual void Reset(void); virtual T& Data(void); }; Протестируйте этот класс с помощью дерева, представленного в виде следующего массива: int А[15] = {1, 4, 6, 2, 8, 9, 12, 25, 23, 55, 18, 78, 58, 14, 45); и распечатайте его по уровням и в нисходящем порядке. 13.9 Унарный оператор ++ может быть перегруженным только в качестве функции-члена класса. Итераторная функция Next реализуется с помощью этого оператора естественным образом. Ниже приводится объявление этого оператора и пример его использования. void operator++ (void); Прохождение бинарного дерева поиска. BinSTree<T> *tree; • * • InorderIterator<T> inorderlter(tree.GetRoot()); while (!inorderlter.EndOfList()) { • • • inorderIter++; } Реализуйте метод Next класса Inorderlterator с помощью "++" и испытайте его в программе, которая включает 25 случайных целых чисел в бинарное дерево поиска, а затем сортирует их с помощью функции TreeSort. 13.10 Разработайте класс итераторов Levelorderlterator для поперечного прохождения бинарного дерева поиска. Объявление этого класса выглядит следующим образом: template <class T> class Levelorderlterator: public Iterator<T> { private: Queue<TreeNode<T> *> S; TreeNode<T> *root, *current;
public: Levelorderlterator(TreeNode<T>* 1st); virtual void Next(void); virtual void Reset(void); virtual T& Data(void); }; В главной программе создайте бинарное дерево поиска, содержащее следующие данные: int data[] = {100, 55, 135, 145, 25, 106, 88, 90, 5, 26, 67, 45, 99, 33); Распечатайте это дерево. с помощью PrintTree, а затем осуществите его поперечный обход с помощью Levelorderlterator. 13.11 Разработайте класс Postorderlterator по следующему алгоритму. Инициализировать итератор посредством помещения адресов всех узлов в стек в процессе NRL-обхода. Во время прохождения текущий узел всегда находится на вершине стека, и очередной узел можно получить посредством операции выталкивания из стека.
Испытайте итератор, осуществив прохождение и печать узлов дерева из предыдущего упражнения. 13.12 Разработайте класс Postorderlterator путем модификации алгоритма обратного прохождения из письменного упражнения 13,20. Испытайте итератор, осуществив прохождение и печать узлов дерева из упражнения 13.10. 13.13 С помощью ReadGraph создайте файл данных для графов А и В. Напишите главную программу, которая вводит эти данные, а затем использует функцию VertexLength из письменного упражнения 12.25 для распечатки расстояния каждой вершины от А. Испытайте свою программу на приведенных ниже графах. 13.14 Примените алгоритм Уоршалла к графам из предыдущего упражнения и распечатайте матрицу достижимости для каждого графа.
глава 14 Организация коллекций 14.1. Основные алгоритмы сортировки массивов 14.2. "Быстрая сортировка" 14.3. Хеширование 14.4. Класс хеш-таблиц 14.5. Производительность методов поиска 14.6. Бинарные файлы и операции с данными на внешних носителях 14.7. Словари Письменные упражнения Упражнения по программированию
Мы завершаем нашу книгу общей главой по организации данных. Здесь будет рассмотрен ряд классических алгоритмов сортировки. В предыдущих главах сортировка служила иллюстрацией возможностей списковых структур. Например, поразрядная сортировка была представлена как приложение очередей. Теперь же мы сосредоточимся на сортировке массивов и познакомимся с классическими алгоритмами сортировки посредством выбора, сортировки методом пузырька и сортировки вставками, которые требуют 0(п2) сравнений. Хотя эти алгоритмы на практике не слишком эффективны для большого количества элементов, они иллюстрируют основные подходы к сортировке массивов. Последним будет рассмотрен знаменитый алгоритм "быстрой сортировки". В гл. 4 были представлены алгоритмы последовательного и бинарного поиска, являющиеся базовыми алгоритмами поиска в списках. В настоящей главе мы пополним наше знание о поиске и изучим хеширование, при котором для быстрого доступа к элементам данных используется ключ, а сложность поиска имеет порядок 0(1). Мы разработаем обобщенный класс хеш- таблиц, допускающий произвольные типы данных. В этой книге внимание было сосредоточено на данных, размещаемых в оперативной памяти. Для больших наборов данных, хранимых на диске, требуются методы внешнего доступа. Мы рассмотрим класс BinFile, предназначенный для обработки двоичных файлов, и на его методах проиллюстрируем алгоритм внешнего индексно-последовательного поиска и алгоритм внешней сортировки слиянием. Раздел, посвященный ассоциативным массивам, или словарям, обобщает понятие индекса массива, что позволяет организовать данные с помощью нечисловых индексов. Мы воспользуемся ассоциативными массивами для построения небольшого словаря. 14.1. Основные алгоритмы сортировки массивов Начнем с трех алгоритмов, составляющих основу методики сортировки "на месте" по возрастанию. Для каждого алгоритма оценим его вычислительную эффективность. Сортировка посредством выбора Сортировка выбором моделирует наш повседневный опыт. Воспитатели детского сада часто используют эту методику, чтобы выстроить детей по росту. При этом самый маленький ребенок выбирается из неупорядоченной группы детей и перемещается в шеренгу, выстраиваемую по росту. Этот процесс, иллюстрируемый следующими несколькими рисунками, продолжается до тех пор, пока все дети не окажутся в упорядоченной шеренге. Для компьютерного алгоритма предполагается, что п элементов данных хранятся в массиве А и по этому массиву выполняется п-1 проход. В нулевом проходе выбирается наименьший элемент, который затем меняется местами с А[0]. После этого неупорядоченными остаются элементы А[1] ... А[п-1]. В следующем проходе просматривается неупорядоченная хвостовая часть списка, откуда выбирается наименьший элемент и запоминается в А[1]. В еле-
Выбрать Рона Выбрать Тома Начальная шеренга Том Дебби Майк Рон Том Дебби Майк Упорядоченная шеренга Рон Рон Том Выбрать Майка Выбрать Дэбби Дебби Майк Рон Том Майк Дебби Рон Том Майк Дебби дующем проходе производится поиск наименьшего элемента в подсписке А[2] ... А[д-1]. Найденное значение меняется местами с А[2]. Таким образом выполняется п-1 проход, после чего неупорядоченный хвост списка сокращается до одного элемента, который и является наибольшим. Рассмотрим сортировку посредством выбора на массиве, содержащем пять целых чисел: 50, 20, 40, 75 и 35. I 50 20 40 1 проход 0 1 20 50 40 1 1 проход 1 I 20 35 40 1 ^ 75 75 ' I проход 2 | 20 | 35 40 75 35 \ 35 50 50 j 1 1 проход 3 | 20 35 40 50 75 Проход 0: Выбрать 20 Поменять местами 20 и А[0] Проход 1: Выбрать 35 Поменять местами 35 и А[1] Проход 2: Выбрать 40 Поменять местами 40 и А[2] Проход 3: Выбрать 50 Поменять местами 50 и А[3] Отсортированный список В i-ом проходе сканируется подсписок A[i] ... А[п-1] и переменной smallln- dex присваивается индекс наименьшего элемента в этом подсписке. Затем элементы A[i] и A[smalllndex] меняются местами. Функция SelectionSort и утилита Swap находятся в файле arrsort.h.
// отсортировать n-элементный массив типа Т, //с помощью алгоритма сортировки посредством выбора template <class T> void SelectionSort(T A[], int n) { // индекс наименьшего элемента на каждом проходе int smalllndex; int i, j; for (i=0; i<n-l; i++) { // начать проход с индекса i; установить smalllndex в i smalllndex =i; for (j=i+l; j<n; j++) // обновить smalllndex, если найден меньший элемент if (Alj] < A[smalllndex]) smalllndex = j; //по окончании поместить наименьший элемент в A[i] Swap(A[ij, A[smalllndex]); } } Вычислительная сложность сортировки посредством выбора. Сортировка посредством выбора требует фиксированного числа сравнений, зависящего только от размера массива, а не от начального распределения в нем данных. В i-ом проходе число сравнений с элементами A[i+1] ... А[п-1] равно (п-1) - (i+1) + 1 = п - i - 1 Общее число сравнений равно п-2 п-2 X(n-l)-i = (n-l)^-Xi-(n-l)2-(n"1)2(n"2)=|n(n-l) О О Сложность алгоритма, измеряемая числом сравнений, равна 0(п2), а число обменов равно О(п). Наилучшего и наихудшего случаев не существует, так как алгоритм делает фиксированное число проходов, в каждом из которых сканируется определенное число элементов. Пирамидальная сортировка, имеющая сложность 0(n log2 n), является обобщением метода сортировки посредством выбора. Сортировка методом пузырька В гл. 2 мы познакомились с обменной сортировкой, требующей п-1 проходов и фиксированного числа сравнений в каждом из них. В этом разделе мы обсудим пузырьковую сортировку, при которой также в каждом проходе выполняется ряд обменов. Для сортировки n-элементного массива А методом пузырька требуется до п-1 проходов. В каждом проходе сравниваются соседние элементы, и если первый из них больше или равен второму, эти элементы меняются местами. К моменту окончания каждого прохода наибольший элемент поднимается к вершине текущего подсписка, подобно пузырьку воздуха в кипящей воде. Например, по окончании прохода 0 хвост списка (А[п-1]) отсортирован, а головная часть остается неупорядоченной. Рассмотрим эти проходы подробнее. Переменная lastExchangelndex хранит последний участвующий в обмене индекс и приравнивается нулю в начале каждого прохода. В нулевом проходе сравниваются соседние элементы (А[0],
А[1]), (А[1], А[2]), ...,(А[п-2], А[п-1]). В каждой паре (A[i], A[i+1]) элементы меняются местами при условии, что A[i+1] < A[i], а значение lastExchange- Index становится равным i. В конце этого прохода наибольшее значение оказывается в элементе А[п-1], а значение lastExchangelndex показывает, что все элементы в хвостовой части списка от A[lastExchangeIndex+l] до А[п-1] отсортированы. В очередном проходе сравниваются соседние элементы в подсписке А[0]—A[lastExchangeIndex]. Процесс прекращается при lastExchangelndex = 0. Алгоритм совершает максимум п-1 проход. Рассмотрим пузырьковую сортировку на массиве, содержащем пять целых чисел: 50, 20, 40, 75 и 35. проход 0 50 | 20 20 20 20 20 1 50 1 40 ' 40 40 40 40 1 50 1 50 50 75 75 75 1 75 1 35 35 35 35 35 ! 1 75 Поменять местами 50 и 20 Поменять местами 50 и 40 50 и 75 упорядочены Поменять местами 75 и 35 75 - наибольший элемент LastExchangelndex = 3 Поскольку lastExchangelndex не равен нулю, процесс продолжается. В проходе 1 сканируется подсписок от А[0] до A[3]=A[lastExchangeIndex]. Новым значением lastExchangelndex становится 2. проход 1 20 1 20 20 20 40 1 40 1 40 40 50 50 1 50 1 35 35 35 35 1 50 75 75 75 75 20 и 40 упорядочены 40 и 50 упорядочены Поменять местами 50 и 35 50 - наибольший элемент lastExchangelndex = 0 В проходе 2 сканируется подсписок от А[0] до А[2] и элементы 40 и 35 меняются местами. lastExchangelndex становится равным 1. проход 2 20 1 20 40 1 35 1 35 40 1 50 50 75 75 20 и 40 упорядочены Поменять местами 40 и 35 lastExchangelndex = 1 В проходе 3 выполняется единственное сравнение (20 и 35). Обменов нет, lastExchangelndex = 0, и процесс прекращается. проход 3 20 I 20 35 I 35 40 40 50 50 75 75 20 и 35 упорядочены Упорядоченный список lastExchangelndex = 0
// BubbleSort принимает массив А и его размер п. // сортировка выполняется посредством ряда проходов, пока lastExchangelndex О template <class T> void BubbleSort (T А[], int n) { int i, j; // индекс последнего элемента, участвовавшего в обмене int lastExchangelndex; // i — индекс последнего элемента в подсписке i = п-1; // продолжать процесс, пока не будет произведено ни одного обмена while (1 > 0) { // начальное значение lastExchangelndex lastExchangelndex = 0; // сканировать подсписок A[0]--A[i] for (j=0; j < i; j++) // менять местами соседние элементы и обновить lastExchangelndex if <A[j+l] < A[j]) { Swap(A[j], A[j+1]); lastExchangelndex = j; } // присвоить i значение индекса последнего обмена, продолжить сортировку i = lastExchangelndex; } } Вычислительная сложность сортировки методом пузырька При пузырьковой сортировке сохраняется индекс последнего обмена во избежание избыточного просмотра. Это придает алгоритму заметную эффективность в некоторых особых случаях. Самое замечательное — это то, что пузырьковая сортировка совершает всего один проход по списку, уже упорядоченному по возрастанию. Таким образом, в лучшем случае эффективность равна О(п). Худший случай для пузырьковой сортировки — когда список упорядочен по убыванию. Тогда необходимо выполнять все п-1 проходов. В i-ом проходе производится (п - i - 1) сравнений и (п - i - 1) обменов. Сложность наихудшего случая составляет 0(п2) сравнений и 0(п2) обменов. Анализ общего случая затруднен из-за возможности пропуска некоторых проходов. Можно показать, что среднее количество проходов к равно О(п) и, следовательно, общее число сравнений равно 0(п2). Даже если пузырьковая сортировка выполняется менее чем за п-1 проходов, это требует, как правило, большего числа обменов, чем при сортировке посредством выбора, и поэтому ее средняя производительность меньше. В общем случае сортировка посредством выбора превосходит пузырьковую за счет меньшего числа обменов. Сортировка вставками Сортировка вставками похожа на хорошо всем знакомый процесс тасования карточек с именами. Регистратор заносит каждое имя на карточку размером 127x76, а затем упорядочивает карточки по алфавиту, вставляя карточку в верхнюю часть стопки в подходящее место. Опишем этот процесс на примере нашего пятиэлементного списка А = 50, 20, 40, 75, 35.
50 Обработка 20 Обработка 40 Обработка 75 Обработка 35 Начать с элемента 50 Вставить 20 в позицию 0; передвинуть 50 в позицию 1 Вставить 40 в позицию 1; передвинуть 50 в позицию 2 Элемент 75 на месте Вставить 40 в позицию 1; сдвинуть хвост списка вправо В функцию InsertionSort передается массив А и длина списка п. Рассмотрим i-ый проход (l<i:<n-l). Подсписок А[0]—A[i-1] уже отсортирован по возрастанию. В качестве вставляемого (TARGET) выберем элемент A[i] и будем продвигать его к началу списка, сравнивая с элементами A[i-1], A[i-2] и т.д. Просмотр заканчивается на элементе A[j], который меньше или равен TARGET или находится в начале списка (j = 0). По мере продвижения к началу списка каждый элемент сдвигается вправо (A[j] = A[j-1]). Когда подходящее место для A[i] будет найдено, этот элемент вставляется в точку j. // Сортировка вставками упорядочивает подсписки A[0]...A[i], // 1 <- i <= п-1. Для каждого i A[i] вставляется в подходящую // позицию А[j] template <class T> void InsertionSort(T A[], int n) { int i, j; T temp; // i определяет подсписок А[0]...A[i] for (i=l; i<n; i++) { // индекс j пробегает вниз по списку от A[i] в процессе // поиска правильной позиции вставляемого значения j = i; temp = A[i] ; // обнаружить подходящую позицию для вставки, сканируя подсписок, // пока temp < A[j-1] или пока не встретится начало списка while (j > 0 && temp < A[j-1]) { // сдвинуть элементы вправо, чтобы освободить место для вставки A[j] = A[j-1]; j—; } // точка вставки найдена; вставить temp A[j] = temp; } } Вычислительная эффективность сортировки вставками. Сортировка вставками требует фиксированного числа проходов. В п-1 проходах включаются элементы А[1]—А[п-1]. В i-ом проходе включения производятся в подсписок А[0]—A[i] и требуют в среднем i/2 сравнений. Общее число сравнений равно 1/2 + 2/2 + 3/2 + ... + (n-2)/2 + (n-l)/2 = n(n-l)/4
В отличие от других методов, сортировка вставками не использует обмены. Сложность алгоритма измеряется числом сравнений и равна 0(п2). Наилучший случай — когда исходный список уже отсортирован. Тогда в i-ом проходе вставка производится в точке A[i], а общее число сравнений равно п-1, т.е. сложность составляет 0(п). Наихудший случай возникает, когда список отсортирован по убыванию. Тогда каждая вставка происходит в точке А[0] и требует i сравнений. Общее число сравнений равно п(п-1)/2, т.е. сложность составляет 0(п2). 14.2. "Быстрая сортировка11 К этому моменту мы рассмотрели ряд 0(п2)-сложных алгоритмов сортировки массивов "на месте". Алгоритмы, использующие деревья (турнирная сортировка, сортировка посредством поискового дерева), обеспечивают значительно лучшую производительность 0(n log2n). Несмотря на то, что они требуют копирования массива в дерево и обратно, эти затраты покрываются за счет большей эффективности самой сортировки. Широко используемый метод пирамидальной сортировки также обрабатывает массив "на месте" и имеет эффективность 0(n log2n). Однако "быстрая сортировка", которую изобрел К. Хоар, для большинства приложений превосходит пирамидальную сортировку и является самой быстрой из известных до сих пор. Описание "быстрой сортировки" Как и для большинства алгоритмов сортировки, методика "быстрой сортировки" взята из повседневного опыта. Чтобы отсортировать большую стопку алфавитных карточек по именам, можно разбить ее на две меньшие стопки относительно какой-нибудь буквы, например К. Все имена, меньшие или равные К, идут в одну стопку, а остальные — в другую. Затем каждая стопка снова делится на две. Например, на рис. 14.1 точками разбиения являются буквы F и R. Процесс разбиения на все меньшие и меньшие стопки продолжается. В алгоритме "быстрой сортировки" применяется метод разбиения с определением центрального элемента. Так как мы не можем позволить себе удовольствие разбрасывать стопки по всему столу, как при сортировке алфавитных карточек, элементы разбиваются на группы внутри массива. Рассмотрим алгоритм "быстрой сортировки" на примере, а затем обсудим технические детали. Пусть дан массив, состоящий из 10 целых чисел: А = 800, 150, 300, 600, 550, 650, 400, 350, 450, 700 Точка разбиения = К Точка разбиения = R Точка разбиения = Рис. 14.1. Разбиение для «быстрой сортировки»
Фаза сканирования. Массив простирается от индекса low = 0 до индекса high = 9. Его середина приходится на индекс mid = 4. Первым центральным элементом является A[mid] = 550. Таким образом, все элементы массива А разбиваются на два подсписка: Sj и Sh. Меньший из них (S^ будет содержать элементы, меньшие или равные центральному. Подсписок Sh будет содержать элементы большие, чем центральный. Поскольку заранее известно, что центральный элемент в конечном итоге будет последним в подсписке 8\9 мы временно передвигаем его в начало массива, меняя местами с А[0] (A[low]). Это позволяет сканировать подсписок А[1]—А[9] с помощью двух индексов: scanllp и scanDown. Начальное значение scanUp соответствует индексу 1 (low+1). Эта переменная адресует элементы подсписка S^ Переменная scan- Down адресует элементы подсписка Sh и имеет начальное значение 9 (high). Целью прохода является определение элементов для каждого подсписка. 150 300 600 800 650 400 350 450 700 scanUp scanDown Оригинальность "быстрой сортировки" заключается во взаимодействии двух индексов в процессе сканирования списка. Индекс scanUp перемещается вверх по списку, a scanDown — вниз. Мы продвигаем scanUp вперед и ищем элемент A[scanUp] больший, чем центральный. В этом месте сканирование останавливается, и мы готовимся переместить найденный элемент в верхний подсписок. Перед тем как это перемещение произойдет, мы продвигаем индекс scanDown вниз по списку и находим элемент, меньший или равный центральному. Таким образом, у нас есть два элемента, которые находятся не в тех подсписках, и их можно менять местами. Swap (AfscanUp), A[scanDown]); // менять местами партнеров Этот процесс продолжается до тех пор, пока scanUp и scanDown не зайдут друг за друга (scanUp = 6, scanDown = 5). В этот момент scanDown оказывается в подсписке, элементы которого меньше или равны центральному. Мы попали в точку разбиения двух списков и указали окончательную позицию для центрального элемента. В нашем примере поменялись местами числа 600 и 450, 800 и 350, 650 и 400. 550 I | 150 | 300 | 600 | 800 | 650 | 400 | 350 | 450 | 700 | scanDown scanUp Затем происходит обмен значениями центрального элемента А[0] с A[scan- Down]: Swap (А[0], A[scanDown]); В результате получился подсписок А[0]—А[4], элементы которого меньше элементов подсписка А[6]—А[9]. Центральный элемент (550) разделяет два подсписка, каждый из которых равен примерно половине исходного списка. Оба подсписка обрабатываются по одному и тому же алгоритму. Мы называем это рекурсивной фазой.
400 150 300 450 350 550 650 800 600 700 А[0]-А[4] А[б]-А[9] Рекурсивная фаза. Одним и тем же методом обрабатываются два подсписка: S2 (A[0]—А[4]) и Sh (А[5]—А[9]). Подсписок Si. Подсписок определяется диапазоном от индекса low = 0 до high = 4. Его середина приходится на индекс mid = 2. Центральным элементом является A[mid] = 300. Поменять местами центральный элемент с A[low] и присвоить начальные значения индексам scanUp и scanDown: scanUp = 1 = low + 1 scanDown = 4 = high Сканирование вверх останавливается на индексе 2 (А[2] > центрального элемента) Сканирование вниз останавливается на индексе 1 (А[1] < центрального элемента) Начальные значения После сканирования 300 | | 150 400 450 350 300 | | 150 400 450 350 scanUp scanDown scanDown scanUp Так как scanDown < scanUp, процесс останавливается. При этом scanDown является точкой разделения двух еще меньших подсписков: А[0] и А[2]—А[4]. Завершить обработку, поменяв местами A[scanDown] = 150 и A[low] = 300. Заметьте, что положение центрального элемента дает нам одноэлементный и трехэлементный подсписки. Рекурсивный процесс прекращается на пустом или одноэлементном подсписках. 150 В00 | 400 | 450 | 350 А[0] А[2]-А[4] Подсписок Sh. Диапазон подсписка — от индекса low = 6 до high = 9. Его середина приходится на индекс mid = 7. Центральным элементом является A[mid] = 800. Поменять местами центральный элемент с A[low] и присвоить начальные значения индексам scanUp и scanDown: scanUp = 7 = low + 1 scanDown = 9 = high Сканирование вверх останавливается по достижении конца списка (scanUp=10) scanDown остается на начальной позиции Начальные значения После сканирования 800 650 600 700J 800 650 600 700 конец списка scanUp scanDown scanDown scanUp Так как scanDown < scanUp, процесс останавливается. При этом scanDown является точкой вставки центрального элемента. Завершить обработку, по-
меняв местами A[scanDown] = 700 и A[low] = 800. Обратите внимание, что положение центрального элемента дает нам трехэлементный и пустой подсписки. Рекурсивный процесс прекращается на пустом или одноэлементном подсписках. 700 650 600 800 Завершение сортировки Обработать подсписок 400, 450, 350 (А[2]—А[4]). Центральным элементом является 450. На фазе сканирования элементы выстраиваются в порядке 350, 400, 450. Для двухэлементного подсписка 350, 400 понадобится еще один рекурсивный вызов. Обработать подсписок 700, 650, 600 (А[6]—А[8]). Центральным элементом является 650. После сканирования элементы выстраиваются в порядке 600, 650, 700. Числа 600 и 700 образуют два одноэлементных подсписка. "Быстрая сортировка" завершена. Результатом является следующий сортированный список: А = 150, 300, 350, 400, 450, 550, 600, 650, 700, 800 Алгоритм Quicksort Этот рекурсивный алгоритм разделяет список A[low]—A[high] по центральному элементу, который выбирается из середины списка: pivot = A[mid]; // mid = (high+low)/2 После обмена местами центрального элемента с A[low], задаются начальные значения индексам scanUp = low + 1 и scanDown = high, указывающих на начало и конец списка, соответственно. Алгоритм управляет этими двумя индексами. Сначала scanUp продвигается вверх по списку, пока не превысит значение scanDown или пока не встретится элемент больший, чем центральный. // индекс scanUp пробегает по элементам, // меньшим или равным центральному while (scanUp <e scanDown && A[scanUp] <= pivot) scanup++; // перейти к следующему элементу После позиционирования scanUp индекс scanDown продвигается вниз по списку, пока не встретится элемент, меньший или равный центральному. // сканировать верхний подсписок в обратном направлении. // остановиться, когда scanDown укажет на элемент, меньший // или равный центральному, сканирование должно прекратиться // при A[low] = pivot while (pivot < A[scanDown]) scanDown—; По окончании этого цикла (и при условии что scanUp < scanDown) оба индекса адресуют два элемента, находящихся не в тех подсписках. Эти элементы меняются местами.
// поменять местами больший элемент из нижнего подсписка // с меньшим элементом из верхнего подсписка Swap (A[scanUp], AfscanDown]); Обмен элементов прекращается, когда scanDown становится меньше, чем scanUp. В этот момент scanDown указывает начало левого подсписка, который содержит меньшие или равные центральному элементы. Индекс scanDown становится центральным. Взять центральный элемент из A[low]: Swap(A[low], А[scanDown]); Для обработки подсписков используется рекурсия. После обнаружения точки разбиения мы рекурсивно вызываем Quicksort с параметрами low, mid-1 и mid+1, high. Условие останова возникает, когда в подсписке остается менее двух элементов, так как одноэлементный или пустой массив всегда упорядочен. Функция Quicksort находится в файле arrsort.h. // Quicksort принимает в качестве параметров массив // и предельные значения его индексов template <class T> void Quicksort(T A[], int low, int high) { // локальные переменные, содержащие индекс середины mid, // центральный элемент и индексы сканирования t pivot; int scanUp, scanDown; int mid; // если диапазон индексов не включает в себя как минимум два элемента, вернуться if (high - low <= 0) return; else // если в подсписке два элемента, сравнить их между собой // и поменять местами при необходимости if (high - low == 1) { if (A[high] < A[low]) Swap(A[low], Afhigh]); return; } // получить индекс середины и присвоить указываемое им значение // центральному значению mid = (low + high)/2; pivot « A[mid]; // поменять местами центральный и начальный элементы списка // и инициализировать индексы scanUp и scanDown Swap (A[mid], A[low]); scanUp = low + 1; scanDown = high; // искать элементы, расположенные не в тех подсписках. // остановиться при scanDown < scanUp do { // продвигаться вверх по нижнему подсписку; остановиться, // когда scanUp укажет на верхний подсписок или если // указываемый этим индексом элемент > центрального while (scanUp <= scanDown && A[scanUp] <= pivot) scanUp++;
// продвигаться вниз по верхнему подсписку; остановиться, // если scanDown укажет элемент <= центрального. // остановка на элементе A[low] гарантируется while (pivot < A[scanDown]) scanDown—; // если индексы все еще в своих подсписках, то они указывают // два элемента, находящихся не в тех подсписках. // Поменять их местами if (scanUp < scanDown) { Swap (AfscanUp], AEscanDown]); } } while (scanUp < scanDown); // копировать центральный элемент в точку разбиения A[low] = A[scanDown]; A[scanDown] = pivot; // если нижний подсписок (low...scanDown-1) имеет 2 или более // элементов, выполнить рекурсивный вызов if (low < scanDown-1) Quicksort(A, low, scanDown-1); // если верхний подсписок (scanDown+1...high) имеет 2 или более // элементов, выполнить рекурсивный вызов if (scanDown+1 < high) Quicksort(A, scanDown+1, high); } Вычислительная сложность "быстрой сортировки". Общий анализ эффективности "быстрой сортировки" достаточно труден. Будет лучше показать ее вычислительную сложность, подсчитав число сравнений при некоторых идеальных допущениях. Допустим, что п — степень двойки, n = 2к (к = log2n), а центральный элемент располагается точно посередине каждого списка и разбивает его на два подсписка примерно одинаковой длины. При первом сканировании производится п-1 сравнений. В результате создаются два подсписка размером п/2. На следующей фазе обработка каждого подсписка требует примерно п/2 сравнений. Общее число сравнений на этой фазе равно 2(п/2) = п. На следующей фазе обрабатываются четыре подсписка, что требует 4(п/4) сравнений, и т.д. В конце концов процесс разбиения прекращается после к проходов, когда получившиеся подсписки содержат по одному элементу. Общее число сравнений приблизительно равно п + 2(п/2) 4- 4(п/4) 4- ... + n(n/n) = n + n+... + n = n*k = n* log2n Для списка общего вида вычислительная сложность "быстрой сортировки" равна 0(п \og2n). Идеальный случай, который мы только что рассмотрели, фактически возникает тогда, когда массив уже отсортирован по возрастанию. Тогда центральный элемент попадает точно в середину каждого подсписка. Список отсортированный по возрастанию 10 20 30 40 50 60 70 80 пограничный элемент
Если массив отсортирован по убыванию, то на первом проходе центральный элемент находится в середине списка и меняется местами с каждым элементом как в нижнем, так и в правом подсписке. Результирующий список почти отсортирован, алгоритм имеет сложность порядка 0(n log2n). Список отсортированный по убыванию 80 70 60 50 40 30 20 10 Поменять местами A[low] и A[mid]. Выполнить проход После первого прохода 50 70 60 80 40 30 20 10 40 10 20 30 50 80 60 70 Наихудшим сценарием для "быстрой сортировки" будет тот, при котором центральный элемент все время попадает в одноэлементный подсписок, а все прочие элементы остаются во втором подсписке. Это происходит тогда, когда центральным всегда является наименьший элемент. Рассмотрим последовательность 3, 8, 1, 5, 9. В первом проходе производится п сравнений, а больший подсписок содержит п-1 элементов. В следующем проходе этот подсписок требует п-1 сравнений и дает подсписок из п-2 элементов и т.д. Общее число сравнений равно п + п-1 + п-2 + ... + 2 = п(п+1)/2 - 1 Сложность худшего случая равна 0(п2), т.е. не лучше, чем для сортировок вставками и выбором. Однако этот случай является паталогическим и маловероятен на практике. В общем, средняя производительность "быстрой сортировки" выше, чем у всех рассмотренных нами сортировок. Алгоритм Quicksort выбирается за основу в большинстве универсальных сортирующих утилит. Если вы не можете смириться с производительностью наихудшего случая, используйте пирамидальную сортировку — более устойчивый алгоритм, сложность которого равна 0(n log2n) и зависит только от размера списка. Сравнение алгоритмов сортировки массивов Мы сравнили алгоритмы сортировки, испытав их на массивах, содержащих 4000, 8000, 10000, 15000 и 20000 целых чисел, соответственно. Время выполнения измерено в тиках (1/60 доля секунды). Среди всех алгоритмов порядка 0(п2) время сортировки вставками отражает тот факт, что на i-ом проходе требуется лишь i/2 сравнений. Этот алгоритм явно превосходит все прочие сортировки порядка 0(п2). Заметьте, что самую худшую общую производительность демонстрирует сортировка методом пузырька. Результаты испытаний показаны на рис. 14.2. п = 4000 п = 8000 п = 10000 п = 15000 п = 20000 Обменная сортировка 12.23 49.95 77.47 173.97 313.33 Сортировка выбором 17.30 29.43 46.02 103.00 185.05 Пузырьковая сортировка 15.78 64.03 99.10 223.28 399.47 Сортировка вставками 5.67 23.15 35.43 80.23 143.67
Обменная Выбором Пузырьковая Вставками Рис. 14.2. Сравнение сортировок порядка 0(п2) Для иллюстрации эффективности алгоритмов сортировки в экстремальных случаях используются массивы из 20000 элементов, отсортированных по возрастанию и по убыванию. При сортировке методом пузырька и сортировке вставками выполняется только один проход массива, упорядоченного по возрастанию, в то время как сортировка посредством выбора зависит только от размера списка и выполняет 19999 проходов. Упорядоченность данных по убыванию является наихудшим случаем для пузырьковой, обменной и сортировки вставками, зато сортировка выбором выполняется, как обычно. Сортировки сложности 0(п ) упорядоченных массивов п = 8000 (упорядочен по возрастанию) п = 8000 (упорядочен по убыванию) Обменная сортировка 185.27 526.17 Сортировка выбором 185.78 199.0 Пузырьковая сортировка .03 584.67 Сортировка вставками .05 286.92 В общем случае Quicksort является самым быстрым алгоритмом. Благодаря своей эффективности, равной 0(n log2n), он явно превосходит любой алгоритм порядка 0(п2). Судя по результатам испытаний, приведенных на рис. 14.3, он также быстрее любой из сортировок порядка 0(n log2n), рассмотренных нами в гл. 13. Обратите внимание, что эффективность "быстрой сортировки" составляет 0(n log2n) даже в экстремальных случаях. Зато сортировка посредством поискового дерева становится в этих случаях 0(п2) сложной, так как формируемое дерево является вырожденным. сортировки порядка 0(п2) Время (сек)
Сортировки порядка 0(п 1одгп) п = 4000 п = 8000 п = 10000 п = 15000 п = 20000 п = 8000 (упорядочен по возрастанию) п = 8000 (упорядочен по убыванию) Турнирная сортировка 0.28 0.63 0.90 1.30 1.95 1.77 1.65 Сортировка посредством дерева 0.32 0.68 0.92 1.40 1.88 262.27 275.70 Пирамидальная сортировка 0.13 0.28 0.35 0.58 0.77 0.75 0.80 "Быстрая сортировка" 0.07 0.17 0.22 0.33 0.47 0.23 0.28 Рис. 14.3. Сравнение сортировок порядка 0(п Ьдгп) Программа 14.3. Сравнение сортировок Эта программа осуществляет сравнение алгоритмов сортировки данных, представленных на рис. 14.2 и 14.3. Здесь дается только базовая структура программы, а полный листинг находится в файле prgl4_l.cpp. Хронометраж производится с помощью функции TickCount, возвращающей число 1/60 долей секунды, прошедших с момента старта программы. Эта функция находится в файле ticks.h. tinclude <iostream.h> tinclude "arrsort.h" // перечислимый тип, описывающий начальное // состояние массива данных enum Ordering {randomorder, ascending, descending}; // перечислимый тип, идентифицирующий алгоритм сортировки enum SortType {exchange, selection, bubble, insertion, tournament, tree, heap, quick}; // копировать n-элементный массив у в массив х void Copy(int *x, int *y, int n) { for (int i=0; i<n; i++) •x++ = *y++; } // общая сортирующая функция, которая принимает исходный массив //с заданной упорядоченностью элементов и применяет указанный // алгоритм сортировки void Sort(int a[], int n, SortType type, Ordering order) { long time; cout << "Сортировка " « n; // вывести тип упорядоченности switch(order)
{ case random: cout « " элементов. ■■; break; case ascending: cout « " элементов, упорядоченных по возрастанию. "; break; case descending: cout « " элементов, упорядоченных по убыванию. "; break; } // засечь время time = TickCount(); // вывести тип сортировки и выполнить ее switch(type) { case exchange: ExchangeSort (a, n); cout « "Сортировка обменом: "; break; case selection: SelectionSort(a, n); cout « "Сортировка выбором: "; break; case bubble: case insertion: case tournament: case tree: case heap: case quick: } // подсчитать время выполнения в секундах time = TickCount () - time; cout « time/60.0 « endl; } // запустить сортировки для п чисел, // расположенных в заданном порядке void RunTest(int n, Ordering order) { int i; int *a, *b; SortType stype; RandomNumber rnd; // выделить память для двух n-элементных массивов а и b а = new int [n]; b = new int [n]; // определить тип упорядоченности данных if (order = randomorder) // заполнить массив b случайными числами for (i=0; i<n; i++) { b[i] = rnd.Random(n); } else // данные, отсортированные по возрастанию for (i=0; i<n; i++) { b[i] = i; } else // данные, отсортированные по убыванию for (i=0; i<n; i++) { b[ij - n - 1 - i;
} else // копировать данные в массив а. выполнить каждую сортировку for(stype=exchange; stype<=quick; stype=SortType(stype+1)) { Copy(a, b, n); Sort(a, n, stype, order); } // Удалить оба динамических массива delete [] а; delete [] b; } // сортировать 4000, 8000, 10000, 15000 и 20000 случайных чисел. // затем отсортировать 20000 элементов, упорядоченных по возрастанию, // и 20000 элементов, упорядоченных по убыванию void main(void) { int nelts[5] = {4000, 8000, 10000, 15000, 20000}, i; cout.precision(3); cout.setf(ios::fixed | ios::showpoint); for (i=0; i<5; i++) RunTest(nelts[i], randomorder); RunTest(20000, ascending); RunTest(20000, descending); } 0(n log2n) сложные сортировки Время (сек) Посредством дерева Турнирная Пирамидальная "Быстрая сортировка" 14.3. Хеширование В этой книге мы вывели ряд списковых структур, позволяющих программе-клиенту осуществлять поиск и выборку данных. В каждой такой структуре
метод Find выполняет обход списка и ищет элемент данных, совпадающий с ключом. При этом эффективность поиска зависит от структуры списка. В случае последовательного списка метод Find гарантированно просматривает О(п) элементов, в то время как в случае бинарных поисковых деревьев и при бинарном поиске обеспечивается более высокая эффективность 0(log2n). В идеале нам хотелось бы выбирать данные за время 0(1). В этом случае число необходимых сравнений не зависит от количества элементов данных. Выборка элемента осуществляется за время 0(1) при использовании в качестве индекса в массиве некоторого ключа. Например, блюда из меню в закусочной в целях упрощения бухгалтерского учета обозначаются номерами. Какой-нибудь деликатес типа "бастурма, маринованная в водке" в базе данных обозначается просто #2. Владельцу закусочной остается лишь сопоставить ключ 2 с записью в списке. Ключ 2 Наименование Цена 1 Бастурма, маринованная в водке! $3.50 Продано штук 1 43 GZ. м L2 L3 Ln-2 Ln-1 Элементы меню Мы знаем и другие примеры. Файл клиентов пункта проката видеокассет содержит семизначные номера телефонов. Номер телефона используется в качестве ключа для доступа к конкретной записи файла клиентов. | Номер телефона | Имя клиента, название фильма и т. д. | Ключи не обязательно должны быть числовыми. Например, формируемая компилятором таблица символов (symbol table) содержит все используемые в программе идентификаторы вместе с сопутствующей каждому из них информацией. Ключом для доступа к конкретной записи является сам идентификатор. Ключи и хеш-функция В общем случае ключи не настолько просты, как в примере с закусочной. Несмотря на то, что они обеспечивают доступ к данным, ключи, как правило, не являются непосредственными индексами в массиве записей. Например, телефонный номер может идентифицировать клиента, но вряд ли пункт проката хранит десятимиллионный массив. 0 12 i 9999998 9999999 Запись клиента В большинстве приложений ключ обеспечивает косвенную ссылку на данные. Ключ отображается во множество целых чисел посредством хеш-функции (hash function). Полученное в результате значение затем используется для доступа к данным. Давайте исследуем эту идею. Предположим, есть множество записей с целочисленными ключами. Хеш- функция HF отображает ключ в целочисленный индекс из диапазона О...п-l.
С хеш-функцией связана так называемая хеш-таблица (hash table), ячейки которой пронумерованы от 0 до п-1 и хранят сами данные или ссылки на данные. Хеш-таблица Запись nfdcmon) = i Пример 14.1 Предположим, Key — положительное целое, a HF(Key) — значение младшей цифры числа Key, Тогда диапазон индексов равен 0—9. Например, если Key = 49, HF(Key) = HF(49) = 9. Эта хеш-функция в качестве возвращаемого значение использует остаток от деления на 10. // Хеш-функция, возвращающая младшую цифру ключа int HF(int key) { return key % 10; } Часто отображение, осуществляемое хеш-функцией, является отображением "многие к одному" и приводит к коллизиям (collisions). В примере 14.1 HF(49) = HF(29) = 9. При возникновении коллизии два или более ключа ассоциируются с одной и той же ячейкой хеш-таблицы. Поскольку два ключа не могут занимать одну и ту же ячейку в таблице, мы должны разработать стратегию разрешения коллизий. Схемы разрешения коллизий обсуждаются после знакомства с некоторыми типами хеш-функций. Хеш-функции Хеш-функция должна отображать ключ в целое число из диапазона О...п-l. При этом количество коллизий должно быть ограниченным, а вычисление самой хеш-функции — очень быстрым. Некоторые методы удовлетворяют этим требованиям. Наиболее часто используется метод деления (division method), требующий двух шагов. Сперва ключ должен быть преобразован в целое число, а затем полученное значение вписывается в диапазон О...п-l с помощью оператора получения остатка. На практике метод деления используется в большинстве приложений с хешированием.
Пример 14.2 1. Ключ — пятизначное число. Хеш-функция извлекает две младшие цифры. Например, если это число равно 56389, то HF(56389) = 89. Две младшие цифры являются остатком от деления на 100. int HF(int key) { return key % 100 // метод деления на 100 } Эффективность хеш-функции зависит от того, обеспечивает ли она равномерное рассеивание ключей в диапазоне О...п-l. Если две последние цифры соответствуют году рождения, то будет слишком много коллизий при идентификации подростков, играющих в юношеской бейсбольной лиге. 2. Ключ — символьная строка языка C++. Хеш-функция отображает эту строку в целое число посредством суммирования первого и последнего символов и последующего деления на 101 (размер таблицы). // хеш-функция для символьной строки. // возвращает значение в диапазоне от 0 до 100 int HF(char *key) { int len ** strlen(key), hashf = 0; // если длина ключа равна 0 или 1, возвратить key[0]. // иначе сложить первый и последний символ if (len <= 1) hashf = key[0]; else hashf = key[0] + key[len-l]; return hashf % 101; } Эта хеш-функция приводит к коллизии при одинаковых первом и последнем символах строки. Например, строки "start" и "slant" будут отображаться в индекс 29. Так же ведет себя хеш-функция, суммирующая все символы строки. int HF(char *key) { int hashf * 0; // просуммировать все символы строки и разделить на 101 while (*key) hashf +* *key++; return hashf % 101; } Строки "bad" и "dab" преобразуются в один и тот же индекс. Лучшие результаты дает хеш-функция, производящая перемешивание битов в символах. Пример более удачной хеш-функции для строк представлен вместе с программой 14.2. В общем случае при больших п индексы имеют больший разброс. Кроме того, математическая теория утверждает, что распределение будет более равномерным, если п — простое число.
Другие методы хеширования Метод середины квадрата (midsquare technique) предусматривает преобразование ключа в целое число, возведение его в квадрат и возвращение в качестве значения функции последовательности битов, извлеченных из середины этого квадрата. Предположим, что ключ есть целое 32-битовое число. Тогда следующая хеш-функция извлекает средние 10 бит возведенного в квадрат ключа. // возвратить средние 10 бит произведения key*key int HF(int key); { key *= key; // возвести ключ в квадрат key »= 11; // отбросить 11 младших бит return key % 1024 // возвратить 10 младших бит } При мультипликативном методе (multiplicative method) используется случайное действительное число f в диапазоне 0<f<l. Дробная часть произведения f*key лежит в диапазоне от 0 до 1. Если это произведение умножить на п (размер хеш-таблицы), то целая часть полученного произведения даст значение хеш-функции в диапазоне от 0 до п-1. // хеш-функция, использующая мультипликативный метод; // возвращает значение в диапазоне 0...700 int HF(int key) ; { static RandomNumber rnd; float f; // умножить ключ на случайное число из диапазона 0...1 f = key * rnd.fRandom(); // взять дробную часть f - f - int(f); // возвратить число в диапазоне О...п-l return 701*f; } Разрешение коллизий Несмотря на то, что два или более ключей могут хешироваться одинаково, они не могут занимать в хеш-таблице одну и ту же ячейку. Нам остаются два пути: либо найти для нового ключа другую позицию в таблице, либо создать для каждого значения хеш-функции отдельный список, в котором будут все ключи, отображающиеся при хешировании в это значение. Оба варианта представляют собой две классические стратегии разрешения коллизий — открытую адресацию с линейным опробыванием (linear probe open addressing) и метод цепочек (chaining with separate lists). Мы проиллюстрируем на примере открытую адресацию, а сосредоточимся главным образом на втором методе, поскольку эта стратегия является доминирующей. Открытая адресация с линейным опробыванием. Эта методика предполагает, что каждая ячейка таблицы помечена как незанятая. Поэтому при добавлении нового ключа всегда можно определить, занята ли данная ячейка таблицы или нет. Если да, алгоритм осуществляет "опробывание" по кругу, пока не встретится "открытый адрес" (свободное место). Отсюда и название метода. Если размер таблицы велик относительно числа хранимых там клю-
чей, метод работает хорошо, поскольку хеш-функция будет равномерно рассеивать ключи по всему диапазону и число коллизий будет минимальным. По мере того как коэффициент заполнения таблицы приближается к 1, эффективность процесса заметно падает. Проиллюстрируем линейное опробы- вание на примере семи записей. Пример 14.3 Предположим, что данные имеют тип DataRecord и хранятся в 11- элементной таблице. struct DataRecord { int key; int data; }; В качестве хеш-функции HF используется остаток от деления на 11, принимающий значения в диапазоне 0—10. HF(item) = item.key % 11 В таблице хранятся следующие данные. Каждый элемент помечен числом проб, необходимых для его размещения в таблице. Список: {54,1}, {77,3}, {94,5}, {89,7}, {14,8}, {45,2}, {76,9} Хеш-таблица 77(1)! 3 0 89(1)! 7 1 45(2)! 2 2 14(1)! 8 3 76(6)! 9 4 5 94(1)! 5 6 7 8 9 54(1)! 1 10 Хеширование первых пяти ключей дает пять различных индексов, по которым эти ключи запоминаются в таблице. Например, HF({54,1}) = 10, и этот элемент попадает в ТаЫе[10]. Первая коллизия возникает между ключами 89 и 45, так как оба они отображаются в индекс 1. Элемент данных {89,7} идет первым в списке и занимает позицию ТаЫе[1]. При попытке записать {45,2} оказывается, что место ТаЫе[1] уже занято. Тогда начинается последовательное опробывание ячеек таблицы с целью нахождения свободного места. В данном случае это ТаЫе[2]. На ключе 76 эффективность алгоритма сильно падает. Этот ключ хешируется в индекс 10 — место, уже занятое. В процессе оп- робывания осуществляется просмотр еще пяти ячеек, прежде чем будет найдено свободное место в ТаЫе[4]. Общее число проб для размещения в таблице всех элементов списка равно 13, т.е. в среднем 1,9 проб на элемент. Реализация алгоритма открытой адресации дана в упражнениях. Метод цепочек. При другом подходе к хешированию таблица рассматривается как массив связанных списков или деревьев. Каждая такая цепочка называется блоком (bucket) и содержит записи, отображаемые хеш-функцией в один и тот же табличный адрес. Эта стратегия разрешения коллизий называется методом цепочек.
Хеш-таблица Блок 0 Блок 1 Блок п-1 Ключ Данные • •• Ключ Данные Если таблица является массивом связанных списков, то элемент данных просто вставляется в соответствующий список в качестве нового узла. Чтобы обнаружить элемент данных, нужно применить хеш-функцию для определения нужного связанного списка и выполнить там последовательный поиск. Пример 14.4 Проиллюстрируем метод цепочек на семи записях типа DataRecord и хеш-функции HF из примера 14.3. Список: {54,1}/ {77,3}, {94,5}, {89,7}, {14,8}, {45,2}, {76,9} HF(item) = item.key % 11 Каждый новый элемент данных вставляется в хвост соответствующего связанного списка. В следующей таблице каждое значение данных сопровождается числом проб, требуемых для запоминания этого значения в таблице. нт[о] е НТ[1] С НТ[2] нт[3] Е НТ[4] НТ[5] НТ[б] Е НТ[7] НТ[8] НТ[9] нт[Ю] Е 77<1) 89(1) 14(1) 94(1) 54(1) |з |7| 8 |5 И NULL NULL NULL ► 45(2) I 2 NULL ► 76(2) 9 I NULL Заметьте, что если считать пробой вставку нового узла, то их общее число при включении семи элементов равно 9, т.е. в среднем 1,3 пробы на элемент данных. В общем случае метод цепочек быстрее открытой адресации, так как просматривает только те ключи, которые попадают в один и тот же табличный адрес. Кроме того, открытая адресация предполагает наличие таблицы фиксированного размера, в то время как в методе цепочек элементы таблицы создаются динамически, а длина списка ограничена лишь количеством памяти. Основным недостатком метода цепочек являются дополнительные затраты памяти на поля указателей. В общем случае динамическая структура метода цепочек более предпочтительна для хеширования.
14.4. Класс хеш-таблиц В этом разделе определяется общий класс HashTable, осуществляющий хеширование методом цепочек. Этот класс образуется от базового абстрактного класса List и обеспечивает механизм хранения с очень эффективными методами доступа. Допускаются данные любого типа с тем лишь ограничением, что для этого типа данных должен быть определен оператор ==. Чтобы сравнить ключевые поля двух элементов данных, прикладная программа должна перегрузить оператор ==. Мы также рассмотрим класс HashTablelterator, облегчающий обработку данных в хеш-таблице. Объект типа HashTablelterator находит важное применение при сортировке и печати данных. Объявления и реализации этих классов находятся в файле hash.h. Спецификация класса HashTablelterator ОБЪЯВЛЕНИЕ #include "array.h" #include "list.h" #include "link.h" #include "iterator.h" template <class T> class HashTablelterator; template <class T> class HashTable: public List<T> { protected: // число блоков; представляет размер таблицы int numBuckets; // хеш-таблица есть массив связанных списков Array< LinkedList<T> > buckets; // хеш-функция и адрес элемента данных, //к которому обращались последний раз unsigned long (*hf)(T key); Т *current; public: // конструктор с параметрами, включающими // размер таблицы и хеш-функцию HashTable(int nbuckets, unsigned long hashf(T key)); // методы обработки списков virtual void Insert(const T& key); virtual int Find(T& key); virtual void Delete(const T& key); virtual void ClearList(void); void Update(const T& key); // дружественный итератор, имеющий доступ к // данным-членам friend class HashTableIterator<T> } ОПИСАНИЕ Объект типа HashTable есть список элементов типа Т. В нем реализованы все методы, которые требует абстрактный базовый класс List. Прикладная
программа должна задать размер таблицы и хеш-функцию, преобразующую элемент типа Т в длинное целое без знака. Такой тип возвращаемого значения допускает хеш-функции для широкого диапазона данных. Деление на размер таблицы осуществляется внутри. Методы Insert, Find, Delete и ClearList являются базовыми методами обработки списков. Отдельный метод Update служит для обновления элемента, уже имеющегося в таблице. Методы ListSize и ListEmpty реализованы в базовом классе. Элемент данных current всегда указывает на последнее доступное значение данных. Он используется методом Update и производными классами, которые должны возвращать ссылки. Пример такого класса дан в разделе 14.7. ПРИМЕР Предположим, что NameRecord есть запись, содержащая поле наименования и поле счетчика. struct NameRecord { String name; int count; >; // 101-элементная таблица, содержащая данные типа NameRecord //и имеющая хеш-функцию hash HashTable<NameRecord> HF(101,hash); // вставить запись {"Betsy",1} в таблицу NameRecord rec; // переменная типа NameRecord rec.name = "Betsy"; // присвоение name = "Betsy rec.count =1; //и count = 1 HF.Insert(rec); // Вставить запись cout « HF.ListSize (); // распечатать размер таблицы // выбрать значение данных, соответствующее ключу "Betsy", // увеличить поле счетчика на 1 и обновить запись rec.name = "Betsy"; if (HF.Find(rec) // найти "Betsy" { rec.cout +=1; // обновить поле данных HF.Update(rec); // обновить запись в таблице } else cerr « "Ошибка: \"Ключ Betsy должен быть в таблице.\"\п; Класс HashTablelterator образован из абстрактного класса Iterator и содержит методы для просмотра данных в таблице. Спецификация класса HashTablelterator ОБЪЯВЛЕНИЕ template <class T> class HashTablelterator: public Iterator<T> { private: // указатель таблицы, подлежащей обходу HashTable<T> *HashTable; // индекс текущего просматриваемого блока //и указатель на связанный список int currentBucket;
LinkedList<T> *currBucketPtr; // утилита для реализации метода Next void SearchNextNode(int cb); public: // конструктор HashTablelterator (HashTable<T>& ht); // базовые методы итератора virtual void Next(void); virtual void Reset(void); virtual T& Data(void); // подготовить итератор для сканирования другой таблицы void SetList(HashTable<T>& 1st); }; ОПИСАНИЕ Метод Next выполняет прохождение таблицы список (блок) за списком, проходя узлы каждого списка. Значения данных, выдаваемые итератором, никак не упорядочены. Для обнаружения очередного списка, подлежащего прохождению, метод Next использует функцию SearchNextNode. ПРИМЕР // объявить итератор для объекта HF типа HashTable HashTableIterator<NameRecord> hiter(HF); // сканировать все элементы базы данных for (niter.Reset(); !niter.EndOfList; hiter.Next()) { rec = hiter.Data(); cout « rec.name « ": " « rec.count << endl; } Приложение: частота символьных строк Класс HashTable используется для хранения множества символьных строк и определения частоты их появления в файле. Каждая символьная строка хранится в объекте типа NameRecord, содержащем наименование строки и частоту ее повторяемости. struct NameRecord { String name; int count; }; Хеш-функция перемешивает биты символов строки путем сдвига текущего значения функции на три бита влево (умножая на 8) перед тем, как прибавить следующий символ. Для n-символьной строки СоСх.-.с^Сл.! п-1 hash(s) = X ci 8n"bl i=l Такое вычисление предотвращает проблемы хеширования символьных строк, рассмотренные в примере 14.2. // функция для использования в классе HashTable unsigned long hash (NameRecord elem)
{ unsigned long hashval = 0; // сдвинуть hashval на три бита влево и // сложить со следующим символом for (int i=0; i<elem.Length(); i++) hashval - (hashval << 3) + elem.name [i]; return hashval; } Программа 14.2. Вычисление частот символьных строк Эта программа вводит символьные строки из файла strings.dat и запоминает их в 101-элементной таблице. Каждая символьная строка вводится из файла и, если еще не встречалась ранее, помещается в таблицу. Для дублирующихся строк из хеш-таблицы выбирается соответствующая запись, где и производится увеличение на единицу поля счетчика. В конце программы определяется итератор, который используется для просмотра и печати всей таблицы. Определения NameRecord, хеш-функции и оператора == для данных типа NameRecord находятся в файле strfreq.h. #include <iostream.h> #include <fstream.h> #include <stdlib.h> #include "hash.h" ♦include "strclass.h" ♦include "strfreq.h" void main(void) { // ввести символьные строки из входного потока ifstream fin; NameRecord rec; String token; HashTable<NameRecord> HF(101, hash); fin.open("strings.dat"), ios::in | ios::nocreate); if (!fin) { cerr « "Невозможно открыть V'strings,dat\"!" « endl; exit(1); } while (fin >> rec.name) { // искать строку в таблице, если найдена, обновить поле count if (HF.Find(rec)) { rec.count += 1; HF.Update(rec); } else { rec.count = 1; HF.Insert(rec) ; } }
// печатать символьные строки вместе с частотами HashTableIterator<NameRecord> niter(HF); for(hiter.Reset(); Ihiter.EndOfList(); niter.Next()) { rec = hiter.Data(); cout « rec.name « ": " « rec.count « endl; } } /* <Файл strings.dat> Columbus Washington Napoleon Washington Lee Grant Washington Lincoln Grant Columbus Washington <Прогон программы 14.2> Lee: 1 Washington: 4 Lincoln: 1 Napoleon: 1 Grant: 2 Columbus: 2 */ Реализация класса HashTable Данный класс образован от абстрактного класса List, предоставляющего методы ListSize и ListEmpty. Мы обсудим элементы данных класса HashTable и операции, реализующие чистые виртуальные функции Insert, Find, Delete и ClearList. Ключевым элементом данных класса является объект buckets типа Array, который определяет массив связанных списков, образующих хеш-таблицу. Указатель функции hf определяет хеш-функцию, a numBuckets является размером таблицы. Указатель current идентифицирует последний элемент данных, к которому осуществлялся доступ тем или иным методом класса. Его значение задается методами Find и Insert и используется методом Update для обновления данных в таблице. Методы обработки списков. Метод Insert вычисляет значение хеш-функции (индекс блока) и ищет объект типа LinkedList, чтобы проверить, есть ли уже такой элемент данных в таблице или нет. Если есть, то Insert обновляет этот элемент данных, устанавливает на него указатель current и возвращает управление. Если такого элемента в таблице нет, Insert добавляет его в хвост списка, устанавливает на него указатель current и увеличивает размер списка. template <class T> void HashTable<T>::Insert(const T& key) { // hashval — индекс блока (связанного списка) int hashval = int(hf(key) % numBuckets); // 1st — псевдоним для buckets[hashval]. // помогает обойтись без индексов LinkedList<T>& 1st = buckets[hashval]; for (Ist.ResetO ; ! 1st .EndOfList () ; Ist.NextO) // если ключ совпал, обновить данные и выйти if (Ist.DataO == key)
{ 1st.Data() = key; current = &lst.Data(); return; } // данные, соответствующие этому ключу, не найдены, вставить элемент в список 1st.InsertRear(key); current - &lst.Data(); size++; } Метод Find применяет хеш-функцию и просматривает указанный в результате список на предмет совпадения с входным параметром. Если совпадение обнаружено, метод копирует данные в key, устанавливает указатель current на соответствующий узел и возвращает True. В противном случает метод возвращает False. template <class T> int HashTable<T>::Find(T& key) { // вычислить значение хеш-функции и установить 1st // на начало соответствующего связанного списка int hashval = int(hf(key) % NumBuckets); LinkedList<T>& 1st = buckets[hashval]; // просматривать узлы связанного списка в поисках key for (1st.Reset (); list .EndOf List (■) ; Ist.NextO) // если ключ совпал, получить данные, установить current и выйти if (Ist.DataO == key) { key = Ist.DataO ; current = &lst.Data(); return 1; // вернуть True } return 0; // иначе вернуть False } Метод Delete просматривает указанный список и удаляет узел, если совпадение произошло. Этот метод (вместе с методами ClearList и Update) находится в файле hash.h. Реализация класса HashTablelterator Этот класс должен просматривать данные, разбросанные по хеш-таблице. Поэтому он более интересен и более сложен с точки зрения реализации, чем класс HashTable. Обход элементов таблицы начинается с поиска непустого блока в массиве списков. Обнаружив непустой блок, мы просматриваем все узлы этого списка, а затем продолжаем процесс, взяв другой непустой блок. Итератор заканчивает обход, когда просмотрен последний непустой блок. Итератор должен быть привязан к списку. В данном случае переменной hash- Table присваивается адрес таблицы. Поскольку класс HashTablelterator является дружественным по отношению к HashTable, он имеет доступ ко всем закрытым данным-членам последнего, включая массив buckets и его размер numBuckets. Переменная currentBucket является индексом связанного списка, который просматривается в данный момент, a currBucketPtr — указателем этого списка. Прохождение каждого блока осуществляется итератором, встроенным в класс LinkedList. На рис. 14.4 показано, как итератор проходит таблицу с четырьмя элементами. Метод SearchNextNode вызывается для обнаружения очередного списка, подлежащего прохождению. Просматриваются все блоки, начиная с cb, пока
hashTablelterator hashTable<T> *hashTable; Объект типа HashTable Buckets[0] Buckets[1] Buckets[2] Buckets[3] Buckets[4] hf(x) = x Empty Empty Итератор извлекает 10 2 22 29 Рис. 14.4. Итератор хэш-таблиц не встретится непустой список. Переменной currentBucket присваивается индекс этого списка, а переменной currBucketPtr — его адрес. Если непустых списков нет, происходит возврат с currentBucket = -1. // начиная с cb, искать следующий непустой список для просмотра template <class t> void HashTableIterator<T>::SearchNextNode(int cb) { currentBucket = -1; // если индекс cb больше размера таблицы, прекратить поиск if (cb > hashTable->numBuckets) return; // иначе искать, начиная с текущего списка до конца таблицы, // непустой блок и обновить частные элементы данных for (int i=cb; i<hashTable->numBuckets; i++) if (!hashTable->buckets[i].ListEmptyO) { // перед тем как вернуться, установить currentBucket равным i //ив currBucketPtr поместить адрес нового непустого списка currBucketPtr = &hashTable->buckets[i]; currBucketPtr ->Reset(); currentBucket = i; return; } } Конструктор инициализирует базовый класс Iterator и присваивает закрытому указателю hashTable адрес таблицы. Непустой список обнаруживается с помощью вызова SearchNextNode с нулевым параметром. // конструктор, инициализирует базовый класс и класс HashTable // SearchNextNode идентифицирует первый непустой блок в таблице template <class T> HashTableIterator<T>::HashTablelterator(HashTable<T>& hf): Iterator<T>(hf), HashTable(&hf) { SearchNextNode(0); }
С помощью метода Next осуществляется продвижение вперед по текущему списку на один элемент. По достижении конца списка функция SearchNextNode настраивает итератор на следующий непустой блок. // перейти к следующему элементу данных в таблице template <class T> void HashTableIterator<T>::Next(void) { // продвинуться к следующему узлу текущего списка currBucketPtr->Next(); // по достижении конца списка вызвать SearchNextNode // для поиска следующего непустого блока в таблице if (currBucketPtr->EndOfList()) SearchNextNode(++currentBucket); // установить флажок iterationComplete, если непустых списков // больше нет iterationComplete = currentBucket == -1; ) 14.5. Производительность методов поиска Мы представили в этой книге четыре метода поиска: последовательный, бинарный, поиск на бинарном дереве и хеширование. Быстродействие того или иного метода обычно зависит от среднего числа сравнений, необходимых для обнаружения элемента данных. Мы показали, что эффективность последовательного поиска равна О(п), а бинарного поиска и поиска на дереве — 0(log2n). Анализ производительности хеширования более увлекательный. Здесь производительность зависит от качества хеш-функции и от размера таблицы. Хорошая хеш-функция дает равномерное распределение значений. При относительно большой таблице число коллизий сокращается. Размер таблицы влияет на коэффициент заполнения (load factor) таблицы. Если таблица состоит из m ячеек, п из которых заняты, то коэффициент заполнения X определяется следующим образом: X = n/m Когда таблица пуста, X = 0. По мере того как данные добавляются в таблицу, X растет, как и вероятность коллизий. При открытой адресации X достигает своего максимального значения, равного 1, когда таблица заполнена (т = п). При использовании метода цепочек списки могут быть как угодно длинными, поэтому X может превысить 1. Для оценки сложности хеширования по методу цепочек можно выдвинуть следующие интуитивные соображения. Наихудшим случаем является тот, при котором все элементы данных отображаются в один и тот же табличный адрес. Если связанный список содержит п элементов, время поиска составит О(п), т.е. в худшем случае производительность равна О(п). Для среднего случая при относительно равномерном распределении значений хеш-функции мы ожидаем X = n/m элементов в каждом связанном списке. Следовательно, время поиска в каждом связанном списке равно 0(Х) = 0(n/m). Если предполагается, что количество элементов, размещаемых в таблице, ограничено некоторым числом, скажем R*m, то время поиска в каждом списке равно 0(R*m/m) = O(R) = 0(1), т.е. метод цепочек имеет порядок 0(1). Доступ
к данным в хеш-таблице, реализованной по методу цепочек, производится за фиксированное время, не зависящее от количества данных. Формальный математический анализ хеширования выходит за рамки этой книги. В табл. 14.1 для каждого метода хеширования даны формулы приблизительного расчета числа проб, необходимых при успешном и безуспешном поиске в достаточно большой таблице. Каждая формула есть функция коэффициента заполнения X. Подробное обсуждение этих и других результатов можно найти в [19]. Когда X = 1, успешный поиск требует в среднем т/2 проб, а безуспешный — т проб. Формулы для оценки сложности методов хеширования Таблица 14.1 Открытая адресация Метод цепочек Число проб при успешном поиске 1 1 i « —-—— + ■=:, x*^ 2(1 -А) 2 л X 1 + 2 Число проб при безуспешном поиске 1 1 1 , 2(1-Л)2 + 2'Л*1 е~х + Х Из этой таблицы следует, что метод открытой адресации достаточно хорош при небольшом коэффициенте заполнения. В общем случае метод цепочек лучше. Например, когда m = n (X = 1), то методу цепочек требуется только 1,5 пробы для успешного поиска, в то время как при открытой адресации просматривается вся таблица и требуется в среднем т/2 проб. Когда таблица заполнена наполовину, метод цепочек требует 1,25 проб при успешном поиске, а открытая адресация — 1,5. Очевидно, что хеширование является чрезвычайно быстрым методом поиска. Однако каждый из четырех поисковых методов имеет свое применение. Последовательный поиск эффективен при малом числе элементов и в тех случаях, когда данные не нужно сортировать. Бинарный поиск очень быстр, но требует чтобы массив данных был отсортирован. Этот метод не годится для данных, значения которых определяются во время выполнения программы (например, таблица символов в компиляторе), поскольку упорядоченный массив — далеко не благоприятная среда для операций удаления и вставки. Для этих задач подходят бинарное дерево поиска и хеширование. Бинарное дерево поиска не столь быстрое, но обладает привлекательным эффектом упорядочения данных при выполнении симметричного прохождения. Когда нужен быстрый доступ к неупорядоченным данным, хеширование — лучший метод. 14.6. Бинарные файлы и операции с данными на внешних носителях Во многих приложениях требуется доступ к данным, расположенным в файлах на диске. В этом разделе дается обзор ввода/вывода бинарных файлов с помощью класса fstream (файл fstream.h). Мы рассмотрим класс BinFile, содержащий методы для открытия и закрытия бинарных файлов, для доступа к отдельным записям файла и для блочного ввода/вывода. Большие наборы данных могут содержать миллионы записей, которые невозможно разместить
в памяти одновременно. Для управления ими нужны алгоритмы внешней сортировки и поиска. Мы дадим лишь краткое введение в эту тему, поскольку детальное обсуждение файлов, а также внешней сортировки и поиска выходит за рамки данной книги. Бинарные файлы Текстовый файл содержит строки ASCII-символов, разделенные символами конца строки. Бинарный файл состоит из записей, которые варьируются от одного байта до сложных структур, включающих целые числа, числа с плавающей точкой и массивы. С аппаратной точки зрения записи файла представляют собой блоки данных фиксированной длины, хранящиеся на диске. Блоки, как правило, несмежные. Однако с логической точки зрения записи располагаются в файле последовательно. Файловая система позволяет осуществлять доступ как к отдельным записям, так и ко всему файлу целиком, рассматривая последний как массив записей. Во время ввода/вывода данных система поддерживает файловый указатель (file pointer) — текущую позицию в файле. Файл как структура прямого доступа *о 0 Ri 1 R2 2 R3 3 R4 4 Ri Текущая позиция Rn-2 n-2 Rn-1 n-1 Файл является также последовательной структурой, которая сохраняет файловый указатель в текущей позиции внутри данных. Операции ввода/вывода обращаются к данным в текущей позиции, которая затем продвигается к следующей записи. Файл как структура последовательного доступа Начало Текущая позиция Конец Встроенный в C++ класс fstream описывает файловые объекты, которые могут использоваться как для ввода, так и для вывода. Создавая объект, мы используем метод open для назначения файлу физического имени и режима доступа. Возможные режимы определены в базовом классе ios. Режим in out trunc nocreate binary Действие открыть файл для чтения открыть файл для записи удалить запись до чтения или записи если файл не существует, не создавать пустой файл; возвратить ошибочное состояние потока открыть файл, считая его бинарным (не текстовым)
Пример 14.5 #include <fstream.h> fstream f; // объявление файла // открыть текстовый файл Phone для ввода. // если такого файла нет, сообщить об ошибке f.open("Phone", ios::in | ios: mocreate) ; fstream f; // объявление файла // открыть бинарный файл для ввода f.open("DataBase", ios::in | ios::out | ios:ibinary); Каждый файловый объект имеет ассоциированный с ним файловый указатель, который указывает на текущую позицию для ввода или вывода. Функция tellg() возвращает смещение в байтах от начала файла до текущей позиции во входном файле. Функция tellp() возвращает смещение в байтах от начала файла до текущей позиции в выходном файле. Функции seekg() и seekp() позволяют передвинуть текущий файловый указатель. Все эти функции принимают в качестве параметра смещение, измеряемое числом байтов относительно начала файла (beg), конца файла (end) или текущей позиции в файле (cur). Если файл используется как для ввода, так и для вывода, пользуйтесь функциями tellg и seekg. смещение смещение смещение смещение BEG CUR END Следующий код иллюстрирует действие функций seekg и tellg: // Бинарный файл целых чисел fstream f; f.open("datafile", ios::in | ios::nocreate | ios::binary); // сбросить текущую позицию на начало файла f.seekg(0, ios::beg); // установить текущую позицию на последний элемент данных f.seekg(-sizeof(int), ios:rend); // передвинуть текущую позицию к следующей записи f.seekg(-sizeof(int), ios::cur); • • * // переместиться к концу файла f.seekg(0, ios::end); // распечатать число байтов в файле cout « f.tellg() << endl; // распечатать число элементов данных в файле cout << f.tellg()/sizeof(int); Класс fstream имеет базовые методы read и write, выполняющие ввод/вывод потока байтов. Каждому методу передаются адрес буфера и счетчик пересылаемых байтов. Буфер является массивом символов, в котором данные запоминаются в том виде, каком они принимаются или посылаются на диск. Операции ввода/вывода данных несимвольного типа требуют приведения к типу char. Например, следующие операции передают блок целых чисел:
fstream f; // объявление файла int data = 30, A[20]; // записать число 30 как блок символов длиной sizeof(int) f.write((char*) &data, sizeof(int)); // прочитать 20 чисел из файла f в массив А f.read((char*)A, 20*sizeof(int)); Класс BinFile Файловый ввод/вывод используется во многих приложениях. В этом разделе мы абстрагируем файл от какого бы то ни было приложения и определяем некоторый класс, обеспечивающий общие операции обработки бинарных файлов. Это пример класса, полностью скрывающего от пользователя системные детали нижнего уровня. Поскольку этот класс определен как шаблон, порождаемые им файлы могут содержать различные типы данных. Спецификация класса BinFile ОБЪЯВЛЕНИЕ // системные файлы, содержащие методы для обработки файлов #include <iostream.h> #include <fstream.h> #include <stdlib.h> #include "strclass.h" // тип доступа enuiri Access {IN, OUT, INOUT}; // тип смещения в операциях поиска enum SeekType {BEG, CUR, END}; template <class T> class BinFile { private: // файловый поток со своим именем и типом доступа fstream f; Access accessType; // тип доступа String fname; // физическое имя файла int fileOpen; // файл открыт? // параметры, характеризующие файл как структуру прямого доступа int Tsize; // размер записи int filesize; // число записей // выдает сообщение об ошибке и завершает программу void Error{char *msg); public: // конструкторы и деструктор BinFile(const Strings fileName, Access atype = OUT); -BinFile(void); // конструктор копирования. // объект должен передаваться по ссылке. // завершает программу BinFile(BinFile<T>& bf); // утилиты обслуживания файла void Clear(void); // очистить файл от записей
void Delete(void); // закрыть файл и удалить его void Close(void); // закрыть файл int EndFile(); // проверить условие конца файла long Size(); // вернуть число записей в файле void Reset(void); // установить файл на первую запись // переместить файловый указатель на pos записей относительно // начала файла, текущей позиции или конца файла void Seek(long pos, SeekType mode); // блочное чтение п элементов данных в буфер с адресом А int Read{T *A, int n); // блочная запись п элементов данных из буфера с адресом А void Write(T *А, int n); // Еыбрать запись, расположенную в текущей позиции Т Peek(void); // копировать data в запись, расположенную в позиции pos void Write (const T& data, long pos); // читать запись по индексу pos Т Read (long pos); // записать запись в конец файла void Append(T item); }; ОПИСАНИЕ Конструктор отвечает за открытие файла и инициализацию параметров класса. Создавая объект типа BinFile, программа должна указывать режим доступа к файлу (IN, OUT или INOUT). Если файл открывается в режиме OUT, он очищается. Файл, открывающийся в режиме IN, должен существовать, иначе будет выдано сообщение об ошибке с последующим завершением программы. Если файл объявлен как INOUT, то записи можно как вводить, так и выводить. Открыв такой файл, конструктор устанавливает fileOpen в 1 (True), показывая тем самым, допускается операция чтения. При вызове конструктора копий выдается сообщение об ошибке. В момент создания файловому объекту приписывается физический файл. Допуская копирование файла, можно было бы потребовать, чтобы новый объект обязательно открывал тот же самый файл. Однако на некоторых системах это невозможно, а если и допускается, то может привести к опасной ситуации. Объект типа BinFile должен передаваться по ссылке. Файл может обрабатываться как структура прямого доступа. Методы Read и Write принимают в качестве параметра индекс записи pos и вводят или выводят элемент данных по этой позиции. Методы блочного чтения/записи используются для ввода/вывода сразу нескольких записей. Блочный Read возвращает число прочитанных записей или 0, если встретился конец файла. Возможна ситуация, когда в файле остается менее п записей. Следовательно, возвращаемое значение может быть меньше п. В качестве параметров передаются адрес буфера данных и число записей. Передача начинается с текущей позиции в файле. Метод Peek позволяет выбрать текущую запись, не продвигая файловый указатель. Метод EndFile возвращает логическое значение, сигнализирующее о том, был ли достигнут конец файла. Используйте этот метод только для входных файлов. Для файлов других типов проверяйте в цикле индекс текущей записи и останавливайтесь, когда переменная цикла превысит индекс последней записи. Метод Close закрывает поток, но не удаляет физический файл. Используйте Close, если файл должен быть открыт другим объектом, возможно, в другом режиме. Метод Clear очищает файл от записей, оставляя его открытым и имеющим нулевой размер. Метод Delete закрывает файл и удаляет его с диска. Эти методы сбрасывают флажок fileOpen в 0. После этого любая попытка обратиться к файлу заканчивается прекращением программы. Метод Seek позволяет пере-
местить файловый указатель. Параметр mode указывает базу, относительно которой отсчитывается смещение pos, и соответствует началу файла, текущей позиции или концу файла. ПРИМЕР // файл целых чисел, предназначенный для ввода/вывода; // физическое имя demofile BinFile<int> BF("demofile", INOUT); BinFile<int> BG("outfile", OUT); // файл для вывода целых чисел int i, m = 5, n = 10, A[10]; // переменные целого типа // Эти данные будут выведены в demofile int vals[] = {30,40,50,60,70,80,90,100}; for (i=0; i<5; i++) BF.Write(&i,l); BF.Append(m) ; // записать 5 в конец файла BF.Reset(); // встать на начало файла BF.Write(п,0) // записать 10 в начало файла cout « BF.SizeO « endl; // распечатать размер файла (6) cout « BF.Read(3) « endl; // распечатать третью запись BF.Read(A,2); // ввести два числа в массив А cout « А[0] « " " « А[1]; // и распечатать их BF.Reset(); // встать на начало файла cout « BF.PeekO « endl; // распечатать текущую запись (10) BF.Read(A,4); // ввести четыре числа в массив А BG.Write(А, 4); // вывести А[0]~А[3] в файл BG А[0] *= 2; // удвоить А[0] BG.Write(А[0],0); // вывести новое значение в первую запись BF.Seek(2,beg); // переместиться ко второй записи файла BF BF.Write(vals,8) ; // записать 30..100 в файл, // начиная со второй записи BF.Reset(); // вернуться к началу файла demofile // Читать и распечатывать demofile for (i=0; i<BF.Size(); i++) { BF.Read(&m,1); cout « m « " "; } cout « endl; BF.Delete(); // удалить файл BF BG.Close(); // закрыть outfile BinFile<int> BH("outfile", IN) // открыть outfile для ввода while (IBH.EndFile ()) // читать и распечатывать outfile { BH.ReadUm, 1); cout << m « " "; } cout « endl; BH.CloseO; // закрыть outfile <Результирующая распечатка> б 3 4 5 10 10 1 30 40 50 60 70 80 90 100 20 1 2 3
Реализация класса BinFile Полная реализация класса содержится в файле binfile.h. В этом разделе мы обсудим конструктор, метод прямого чтения, блочный вывод п записей и утилиту Clear, Конструктор отвечает за открытие файла и инициализацию параметров класса. Создавая объект, мы передаем конструктору имя файла и тип доступа. // конструктор, открывает файл с заданным именем и типом доступа template <class T> BinFile<T>::BinFile(const Strings fileName, Access atype) { // операция открытия потока зависит от типа доступа. // для IN файл не создается, если он не существует. // для OUT все существующие в нем данные удаляются. // для INOUT файл пригоден и для ввода, и для вывода if (atype «« IN) f.open(fileName, ios::in 1 ios::trunc | ios::binary; else if (atype == OUT) f.open(fileName, ios::out | ios::trunc ! ios::binary; else f.open(fileName, ios::in | ios::out I ios::binary; if (!f) Error("Конструктор BinFile: не могу открыть файл"); else fileOpen - 1/ accessType * atype; // подсчитать число записей в файле // Tsize — размер типа данных Т (длина записи) Tsize = sizeof(T); if (accessType =- IN I I accessType » INOUT) { // подсчитать число записей во входном файле, переместившись //к его концу, вызвав tellg и затем разделив полученную длину // файла в байтах на длину записи f.seekg(0, ios::end); fileSize * f.tellgO/Tsize; f.seekg(0, ios::beg); } else fileSize =0; // размер для выходного файла // записать имя физического файла в fname fname =« fileName; } Доступ к файлу. С помощью метода seekg файл можно рассматривать как массив прямого доступа. Метод Read имеет параметр pos. Комбинируя размер записи и параметр pos, метод seekg перемещает файловый указатель на конкретную запись и извлекает ее из файла. // метод Read возвращает запись, идущую в файле под номером pos template <class T> Т BinFile<T>::Read (long pos) { // переменная для хранения записи Т data;
if (IfileOpen) Error ("BinFile Read(int роз): файл закрыт"); // метод Read недопустим для выходных файлов if (accessType » OUT) Error("Недопустимая операция доступа к файлу")/ // проверить попадание pos в диапазон 0..fileSize-1 else if (pos < 0 || pos >= fileSize) Error("Недопустимая операция доступа к файлу"); // переместить файловый указатель и извлечь, данные //с помощью метода read класса fstream f.seekg(pos*Tsize, ios::beg); f.read((char *)&data,Tsize); // если файл входной и мы прочитали все записи, // установить флажок конца потока if (accessType « IN) if (f.tellgO/Tsize >- fileSize) f.clear(ios::eofbit); // установить бит eof return data; ) Когда файл используется как устройство последовательного доступа, метод Write можно определить так, чтобы он копировал в файл сразу несколько записей. Адрес выводимых данных и число записей передаются в качестве параметров. Метод write класса fstream копирует поток байтов в выходной файл. Поскольку вывод может начинаться не с начала файла, следует позаботиться о правильном значении fileSize. // выводит п-элементный массив А в файл template <class T> void BinFile<T>::Write(T *A, int n) { long previousRecords; // для входных файлов операция записи недопустима if (accessType «=* IN) Error("Недопустимая операция доступа к файлу"); if (IfileOpen) Error ("BinFile Write(T *A, int n): файл закрыт"); // вычислить новый размер файла, вызвать tellg для подсчета // числа записей, предшествующих точке вывода, определить, // увеличился ли размер файла, если да, увеличить fileSize на // число добавляемых записей previousRecords - f.tellg()/Tsize; if (previousRecords + n > fileSize) fileSize +« previousRecords + n - fileSize; // число выводимых Сайтов равно n * Tsize f.write((char *)A, Tsize*n); ) Утилиты. В данном классе имеется целый ряд полезных методов для управления файлом. Метод Clear удаляет все имеющиеся в файле записи, сперва закрывая файл, а затем вновь открывая его в режиме trunc, используя набор параметров файла, которые хранятся как частные данные- члены класса.
// метод Clear удаляет записи файла, сначала закрывая его, // а затем открывая вновь template <class T> void BinFile<T>::Clear(void) { // входной файл очищать нельзя if {accessType -= IN) Error("Недопустимая операция доступа к файлу"); // закрыть, а затем вновь открыть файл f.close(); if (accessType *» OUT) f.open(fname, ios::out I ios::trunc | ios::binary); else f.open(fname, ios::in | ios::out | ios::trunc I ios::binary)/ if (!f) Error("BinFile Clear: не могу повторно открыть файл"); fileSize - 0; } Внешний поиск Ранее мы рассмотрели ряд внутренних списковых структур хранения данных. Подобный набор структур можно определить для данных, хранящихся в файле. Эффективность методов внешней сортировки и поиска зависит от организации записей файла. Мы распространим концепцию хеширования на файловые структуры и используем методы класса BinFile для доступа к данным. Методика хеширования обеспечивает высокую эффективность поиска и может быть применена к внешним структурам. Хеш-функция ставит в соответствие каждой записи целое число из диапазона О...п-l. Это число может служить индексом в массиве записей, где данные запоминались методом открытой адресации. В более эффективном методе цепочек это число может использоваться как индекс в массиве списков. Оба этих метода хранения можно применить к файлам. В этом разделе мы будем иметь дело с методом цепочек, при котором файл содержит связанные списки записей. Мы создадим в памяти хеш-таблицу и с ее помощью будем обращаться к более медленному дисковому устройству. Пусть запись содержит данные вместе с файловым индексом. data nextlndex FaleOataRecord Эти записи хранятся на диске в виде связанного списка. Поле nextlndex указывает позицию следующей записи файла. Чтобы сформировать связанные списки, создадим в памяти хеш-таблицу, которая ссылается на связанные списки в файле. Хеш-функция отображает каждую запись в табличный индекс. int hashtable[n]; // массив файловых индексов Хеш-таблица представляется в памяти в виде n-элементного массива. Изначально таблица пуста (каждая ячейка содержит -1), показывая тем самым, что записей в файле нет. Как только мы вводим запись из базы данных, хеш-функция определяет индекс в таблице. Если соответствующая ячейка таблицы пуста, мы запоминаем саму запись на диске, а ее позицию в файле — в таблице.
Хеш-таблица (в памяти) Записи FileDataRecord на диске хеш-адрес Данные Запись#3 Записи FileDataRecord на диске Теперь ячейка таблицы содержит дисковый адрес первой отображаемой в эту ячейку записи. Этот адрес можно использовать для доступа к соответствующему связанному списку и вставить туда цовую запись. Процесс вставки заключается в выводе записи на диск и обновлении указателя в поле nextlndex. Проиллюстрируем этот процесс на простом примере, который, тем не менее, выражаетет главные особенности. Пусть наши данные имеют целый тип и запоминаются в файле в виде списка записей FileDataRecord. // узел списка, в котором хранится запись struct FileDataRecord { // в нашем примере поле data есть целое число, на практике // чаще всего поле data является сложной записью int data; int nextlndex; // ссылка на следующую запись в файле >; Хеш-функция отображает каждое значение данных в другое целое число, выражаемое младшей цифрой исходного числа. h(data) = data % 10; // h{456) = 6; h(891) = 1; h(26) = 6 В нашем примере в файле запоминаются следующие данные: 456 64 84 101 144 Первые два числа отображаются в пустые ячейки таблицы и, следовательно, могут быть сразу вставлены в файл в качестве узлов. Первый узел запоминается в позиции 0, а второй — в позиции 1. Номера позиций заносятся в соответствующие ячейки таблицы. Таблица Ячейка таблицы, соответствующая числу 84, содержит 1 — номер записи файла, являющейся первой в некотором связанном списке записей. Новая запись (84) вставляется в начало этого списка.
Таблица После загрузки чисел 101 и 104 файл содержит пять записей FileDataRecord, которые логически представляют собой три связанных списка. Головы этих списков содержатся в известных ячейках таблицы. В данном методе хранения эффективно используется прямой доступ к файлу. Файл формируется путем последовательного добавления записей и обновления соответствующих ячеек таблицы. Часто сама таблица запоминается в виде отдельного файла и загружается оттуда в память, когда требуется поработать с основным файлом. Программа 14.3. Внешнее хеширование Эта программа иллюстрирует ранее рассмотренный алгоритм внешнего хеширования. Функция LoadRecord добавляет в файл новую запись, а функция PrintList распечатывает связанный список записей, соответствующий некоторому хеш-адресу. Каждая запись вставляется в начало своего связанного списка. Исключение дубликатов не производится. Главная процедура включает в файл 50 случайных чисел от 0 до 999. У пользователя запрашивается какой-нибудь хеш-индекс, а затем распечатываются элементы соответствующего связанного списка. #include <iostream.h> #include "random.h" #include "binfile.h" const long Empty = -1; // узел списка, в котором хранится запись struct FileDataRecord { //в нашем примере поле data есть целое число, на практике // чаще всего поле data само является сложной записью int data; int nextIndex; // ссылка на следующую запись в файле }; // startindex — индекс в таблице, передается по ссылке, чтобы // можно было обновлять голову списка void LoadRecord(BinFile<FileDataRecord> &bf, long &startindex, FileDataRecord &dr) { // если таблица не пуста, startindex указывает на первую запись // списка, в противном случае startindex = 1 dr.nextlndex « startindex; startindex = bf.SizeO; // добавить в файл новую запись bf.Append(dr); }
// сканировать узлы списка в файле и распечатывать значения данных void PrintList(BinFile<FileDataRecord> &bf, long &startindex) { // index — индекс первой записи списка long index * startindex; FileDataRecord rec; // index продвигается к концу списка (до index = -1) while (index !=» Empty) { // прочитать запись, распечатать поле данных и перейти к следующей записи rec « bf.Read(index); cout « rec.data « " "; index =* rec.nextlndex; } cout « endl; ) void main(void) { // таблица голов списков записей в файле. // область значений хеш-функции равна 0..9 long HashTable[10]; // генератор случайных чисел и запись с данными RandomNumber rnd; FileDataRecord dr; int i, item, request; // открыть файл DRfile для ввода/вывода BinFile<FileDataRecord> dataFile{"DRfile", INOUT); // инициализировать таблицу пустыми ячейками for (i*»0; i<10; i++) hashTable[i] ■ Empty; // ввести 50 случайных чисел от 0 до 999 for (i*0; i<50; i++) { item » rnd.Random(1000); // сформировать запись и вывести ее в файл dr.data e item; LoadRecord(dataFile, hashTable[item % 10], dr); } // запросить индекс в хеш-таблице // и распечатать соответствующий список cout « "Введите номер ячейки хеш-таблицы: "; cin » request; cout « "Печать элементов данных, хещируемых в число " « request « endl; PrintList(dataFile, hashTable[request]); // удалить файл dataFile.Delete(); ) /* <Прогон программы 14.3> Введите номер ячейки хеш-таблицы: 5 Печать элементов данных, хешируемых в число 5 835 385 205 185 455 5 */
Внешняя сортировка Сортировка данных на внешних носителях составляет специальную проблему, когда файл настолько велик, что не умещается в оперативной памяти. Поскольку все данные нельзя расположить в одном массиве, мы должны использовать для их хранения временные файлы. В этом разделе рассматривается вцешняя сортировка слиянием с помощью трех файлов. Мы обсудим алгоритмы как прямого, так и естественного слияния, использующего длинные последовательности. Эти алгоритмы могут быть расширены до п-путевого слияния, в котором задействовано более чем 3 файла. В гл. 12 мы рассмотрели простое слияние, которое объединяет два упорядоченных списка в один. Сортировка прямым слиянием использует этот подход, объединяя подсписки фиксированной длины. Пусть сортируемые элементы хранятся в файле fC, а файлы fA и fB являются временными и служат для разбиения данных. Тогда алгоритм сортировки можно представить последовательностью следующих шагов: 1. Разбить fC пополам, попеременно записывая его элементы то в fA, то в fB. Таким образом в каждом новом файле создается последовательность одноэлементных подсписков, 2. Сопоставить подсписки. Выбрать один элемент из fA и один элемент из fB. Объединить их в двухэлементный подсписок и записать в fC. Продолжать до тех пор, пока все элементы в обоих файлах не будут снова скопированы в fC. 3. Повторить шаг 1, попеременно записывая двухэлементные подсписки файла fC в файлы fA и fB. 4. Попарно слить все двухэлементные подсписки файлов fА и fВ в четы- рехэлементные подсписки файла fC. 5. Повторять шаг, на котором fC разбивается пополам, образуя четырех-, восьми- и т.д. -элементные подсписки в файлах fA и fB. Затем сливать каждую пару подсписков в восьми-, шестнадцати- и т.д. -элементные подсписки файла fC. Процесс завершается в тот момент, когда в fА и fВ образуется по одному упорядоченному списку, которые окончательно сливаются в отсортированный файл fC. Проиллюстрируем сортировку прямым слиянием на примере двадцати целых чисел. 5 15 35 30 20 45 35 5 65 75 40 50 60 70 30 40 25 10 45 55 Файл f A файл fB Файл fC
На первом шаге fC разбивается на два временных файла по 10 одноэлементных подсписка в каждом. На втором шаге посредством слияния создается файл упорядоченных пар fC. Файл fA Файл fB Файл fC На третьем шаге файл упорядоченных пар fC разбивается пополам на файлы fA и fB, которые затем попарно сливаются в файл упорядоченных четверок fC. Разбиение и последующее попарное слияние файлов происходит еще три раза. В этих проходах в fC создаются упорядоченные 8-, 16- и наконец 20-элементные подсписки. После финального прохода fC становится отсортированным файлом. При создании 8- и 16-элементных подсписков в хвосте файла fA остается "непарный" подсписок, который просто копируется снова в fC. В финальном проходе 16-элементный подсписок в fA сливается с 4- элементным подсписком в fВ и процесс завершается. 8-элементные подсписки 16 -элементные подсписки 20-элементный подсписок Анализ сортировки прямым слиянием. Сортировка прямым слиянием состоит из серии проходов, начинающейся с одноэлементных подсписков. На каждом проходе длина подсписков удваивается, пока не достигнет своего предельного значения s > п. Для этого требуется log2n отдельных проходов, во время которых все п элементов копируются сначала во временные файлы, а затем снова в fC. Таким образом, сортировка прямым слиянием требует 2 * n * log2n обращений к данным, что составляет сложность порядка 0(n log2n).
Сортировка естественным слиянием Сортировка прямым слиянием использует упорядоченные подсписки с начальной длиной 1, удваивающейся на каждом проходе. В конце концов упорядоченные подсписки охватывают весь файл, и сортировка завершается. При этом несоизмеримое количество времени тратится на короткие подсписки — на их разбиения и последующие слияния. Эффективность алгоритма намного возрастает на длинных подсписках, так как требуется меньше проходов и файловые операции не должны выполняться столь часто. В данном разделе мы модифицируем сортировку прямым слиянием таким образом, чтобы она начиналась с относительно длинных подсписков и, следовательно, стала более эффективной. Усовершенствованному алгоритму требуется файл fC и буфер в оперативной памяти для создания упорядоченных подсписков. Данные исходного файла читаются в буфер поблочно. Каждый блок сортируется с помощью какого- нибудь быстрого алгоритма внутренней сортировки (например, Quicksort). Отсортированные блоки попеременно копируются в файлы f А и fB. Слияние начинается с подсписков, которые уже с самого начала имеют большую длину. Чтобы оценить влияние длины исходных подсписков, сравните по табл. 14.2 времена сортировок 30000 случайных чисел при различных размерах блока. Реализация естественного слияния. Функция MergeSort создает два временных файла и осуществляет серию проходов, разбивающих исходный файл на файлы fA и fB, которые затем снова сливаются в исходный файл fC. Это продолжается до тех пор, пока в файле fC не окажется единственная отсортированная последовательность. // функция для сортировки файла fC, использующая последовательности // длиной blockSize. сначала блоки данных вводятся и сортируются с // помощью алгоритма "быстрой сортировки", а затем записываются в качестве // последовательностей во временные файлы fA и fB template <class T> void MergeSort(BinFile<T>& fC, int blockSize) { // временные файлы для разбиения исходного файла BinFile<T> fACfileA", INOUT); BinFile<T> fB("fileB", INOUT); // длина файла и длина блока int size = int (fC.SizeO), n = blockSize; int k = 1, useA = 1, readCount; T *A; // установить файл fC на начало fС.Reset(); // если файл маленький, ввести данные из fC, отсортировать // и скопировать обратно if (size <= blockSize) { // создать буфер для блока данных и выполнить блочное чтение А = new T[size]; if (A == NULL) { cerr << "MergeSort: ошибка распределения памяти" « endl; exittl); } fС.Read(A.size);
// отсортировать блок данных Quicksort(А, 0, (int)size-l); // очистить файл и снова записать туда отсортированные данные fC.ClearO; fC.Write(A, size); // освободить память, выделенную под буфер, и вернуться delete [J A; return; ) else { // создать буфер для блока данных и читать блоки до конца файла А * new Т[blockSize]; if (A «= NULL) { cerr « "MergeSort: ошибка распределения памяти" << endl; exit (1); } while (IfC.EndFileO) { readCount * fC.Read(A, blockSize); if (readCount =» 0) break; // сортировать блоки и попеременно записывать отсортированные // последовательности в файлы fA и fB QuickSort(A, 0, readCount-1); if (useA) fA.Write(A, readCount); else fB.Write(A, readCount); useA * JuseA; } delete [] A; } // слить отсортированные последовательности обратно в файл fC Merge(fA, fB, fC, blockSize); // удвоить размер отсортированных последовательностей к *- 2; п •• к * blockSize; // если п больше или равно длине файла, то в fC только одна // последовательность, т.е. файл отсортирован while (n < size) { // на каждом проходе разбивать последовательности и снова // сливать их в последовательности удвоенной длины Split(fA, fB, fC, k, blockSize); Merge{fA, fB, fC, n); k *« 2; n - k * blockSize; ) // удалить временные файлы fA.Delete(); fВ.Delete(); )
В каждом проходе функция Split сканирует файл f С и поочередно копирует его последовательности в файлы fA и fB. При каждом вызове этой функции размер подсписков уже удвоен и равен k * blockSize. Поскольку blockSize представляет собой длину буфера, подсписок выводится в файл в виде к блоков. Процесс прекращается, когда все последовательности файла fC скопированы во временные файлы. // сканировать файл fC и поочередно копировать его последовательности // в файлы fА и fB. на текущем проходе длина последовательностей // равна k * blockSize template <class T> void Split(BinFile<T> &fA, BinFile<T> &fB, BinFile<T> &fC, int k, int blockSize) < int useA * 1; int i - 0; int readCount/ // для блочного ввода/вывода размер блока равен blockSize Т *А * new T[blockSize]; if (A — NULL) { cerr « "MergeSort: ошибка распределения памяти" « endl; exit(l); } // инициализация файлов перед разбиением f A. Clear О; fБ.Clear(); fС.Reset(); // распределить последовательности файла fC while (JfC.EndFileO) { // ввести блок данных в динамический массив // readCount — число введенных элементов данных readCount - fC.ReadfA, blockSize); // если readCount равен нулю, достигнут конец файла if {readCount « 0) break; // если useA-True, записать блок в fA; иначе — в fВ if (useA) fA.Write(Af readCount); else fB.Write(A, readCount); // сменить выходной файл после вывода к блоков if (++i » k) { i « 0; useA - luseA; } } // освободить динамическую память delete [] A; )
Как только последовательности скопированы во временные файлы, можно начинать их слияние. Этот процесс управляется функцией Merge, которая объединяет пары последовательностей из временных файлов, создавая из каждой пары одну упорядоченную последовательность. Если в файле fA оказывается лишняя последовательность, она просто копируется в fС с помощью CopyTail. // слить последовательности длиной п из файлов fA и fB в файл fC template <class T> void Merge (BinFile<T> &fA, BinFile<T> &fB, BinFile<T> &fC, int n) { // currA и currB — текущие позиции в последовательностях, // взятых из каждого файла int currA = 1, currB - 1; // элементы данных, введенные из fA и fB соответственно. // флажки haveA/haveB показывают, откуда был введен элемент данных Т dataA, dataB; int haveA, haveB; // инициализировать файлы перед слиянием fA.Reset(); fВ.Reset{); fС.Clear(); // взять по одному элементу из каждого файла fA.Read(&dataA, 1); fB.Read(&dataB, 1); for (;;) { // если dataA<=dataB, скопировать dataA в fC и обновить // текущую позицию в текущей последовательности из файла fA if (dataA <= dataB) { fC.WriteUdataA, 1) ; // взять следующий элемент из fA. если элемент не найден, // достигнут конец файла и хвост fB должен быть скопирован // в fС. если текущая позиция больше п, то последовательность // из fA просмотрена и в fC следует скопировать хвост файла fB if ((haveA = fA.Read(&dataA,1)) == 0 I I ++currA > n) { // скопировать dataB в fC. обновить текущую позицию в fB fC.Write(&dataB, 1); currB++; CopyTail(fB, fC, currB, n) ; // размер файла fA больше или равен размеру файла fB // если конец файла fA, дело сделано if (!haveA) break; // иначе новая последовательность, сбросить текущую позицию currA = 1; // взять следующий элемент из fB. если там ничего нет, // то в fA остается только одна последовательность, которую // следует скопировать в fC. скопировать текущий элемент из // файла fA, перед тем как выйти из цикла if ((haveB = fB.Read(&dataB,1)) == 0) { fC.Write UdataA, 1);
currA = 2; break; } // иначе сбросить текущую позицию в последовательности из fB CurrB = 1; } } else { // скопировать dataB в fС и обновить текущую позицию в fB fC.Write(&dataB, 1); // поверить конец последовательности из fB или конец файла fB if ( (haveB = fB.Read(&dataB,1)) ===== 0 | i ++currB > n) { // если конец, записать элемент, который уже прочитан из fA, // обновить его позицию, а затем записать хвост последовательности fC.Write UdataA, 1) currA++; CopyTail(fA, fC, currA, n) ; // если в fB больше нет элементов, сбросить текущую позицию // в fA и подготовиться к копированию последней последовательности // из fA currB = 1; if ((haveA = fA.Read(&dataA, 1) ) == 0) break; currA =1; } } } // скопировать хвост последней последовательности из fA, // если таковой существует if (haveA && lhaveB) CopyTail(fA, fC, currA, n); } Сливая две последовательности, мы достигаем конца одной из них раньше, чем другой. Функция CopyTail копирует в выходной файл хвост другой последовательности . // п - текущий размер последовательности, скопировать хвост // последовательности из файла fX в файл fY. переменная // currRunPos — текущий индекс в последовательности template <class T> void CopyTail (BinFile<T> &fX, BinFile<T> &fY, int &currRunPos, int n) { T data; // копировать каждый элемент, начиная с текущей позиции // до конца последовательности while (currRunPos <= n) { // если вводить больше нечего, достигнут конец файла // и, следовательно, конец последовательности if (fX.Read(&data, 1) == 0) return; // обновить текущую позицию и записать элемент в файл fY currRunPos++; fY.Write(&data,l); } }
Программа 14.4. Тестирование функции MergeSort Эта программа сортирует методом естественного слияния файл, содержащий 1000 случайных чисел, используя 100-элементные последовательности. Файл создается функцией LoadFile. С помощью PrintFile распечатываются первые 45 элементов исходного и отсортированного файлов. ♦include <iostream.h> ♦include <iomanip.h> ♦include "binfile.h" finclude "merge.h" ♦include "random.h" // распечатать элементы файла f по 9 элементов в строке void PrintFile (BinFile<int> &f, long n) // инициализировать п по размеру файла int data; long i; n » (f.SizeO < n) ? f.SizeO : n; // установить файловый указатель на начало файла f.Reset О; // последовательное сканирование файла, читать и распечатывать // каждый элемент, начинать каждый 10-й элемент с новой строки for (i=0; i<n; i++) { if (i % 9 — 0) cout « endl; f .ReadUdata, 1); cout « setw(5) « data « " "; } cout << endl; } // создать файл, содержащий n случайных чисел в диапазоне 0—32767 void LoadFile(BinFile<int> &f, int n) { int i, item; RandomNumber rnd; // инициализировать файл f.Reset(); // заполнить файл случайными числами for (i=*0; i<n; i++) { item =• rnd.Random(32768L) ; f.Write(bitem, 1); } 1 void main(void) < // файл fC заполняется случайными числами и сортируется BinFile<int> fC("fileC", INOUT); // создать файл 1000 случайных чисел LoadFile(fC, 1000);
// распечатать первые 45 элементов исходного файла cout « "Первые 45 элементов исходного файла:" « endl; PrintFile(fC, 45); cout « endl; // выполнить сортировку слиянием MergeSort(fC, 100); // распечатать первые 45 элементов отсортированного файла cout « "Первые 45 элементов отсортированного файла:" « endl; PrintFile(fC, 45); // удалить файл fС.Delete<); ) /* <Прогон программы 14.4> Первые 45 элементов исходного файла: 14879 26060 28442 20710 19366 10959 17112 7880 22963 16103 22910 6789 4976 19024 1470 25654 31721 28709 997 23378 14186 14986 21650 7351 25237 28059 5942 9593 20294 27928 8267 9837 17191 8398 18261 21620 5139 964 10393 16777 15915 18986 22175 2697 20409 Первые 45 элементов отсортированного файла: 19 76 94 98 106 119 188 192 236 259 308 344 346 371 383 424 463 558 570 605 614 714 741 756 794 861 864 891 910 923 964 979 997 1000 1007 1029 1051 1079 1112 1223 1232 1347 1470 1515 1558 */ 14.7. Словари Доступ к элементу массива осуществляется по индексу, который указывает позицию элемента в массиве. Например, если А — массив, то элемент А[п] расположен в n-ой ячейке массива. Индекс не хранится как часть данных. Словарь (таблица, ассоциативный массив) есть индексируемая структура данных, подобная массиву. Однако как индексы, так и сами словарные данные могут быть любого типа. Например, если Common Words — словарь, то Common- Words["decide"] может быть определением сдова "decide". В отличие от массивов, словарный индекс, скорее, связан с элементом данных, чем точно указывает, где этот элемент хранится. Кроме того, число элементов словаря не ограничено. Словарь называют ассоциативной структурой, поскольку он хранит список ключей и ассоциируемых с ними значений данных. Например, толковый словарь является таблицей слов (ключей) и их дефиниций (значений). Словари отличаются от массивов тем, что фактическое расположение их элементов скрыто. Доступ никогда не производится путем прямого указания позиции в списке, а осуществляется только по ключу. Данные запоминаются в словаре в виде множества пар ключ-значение, называемых также ассоциациями. Эти пары могут храниться в связанном списке, дереве или хеш-таблице. Если данные упорядочены по ключам, то говорят, что таблица упорядочена. Рис. 14.5 иллюстрирует смысл всех этих понятий.
"for DicEntryfforj- "is" •what" "and" ] 'but" ■at" I "so" Связанный список, дерево, хеш-таблица, ... Рис. 14.5. Словарь (ассоциативный массив) Чтобы реализовать пригодный для работы словарь, следует сперва разработать методы хранения пар ключ-значение в рамках класса KeyValue. Каждая пара ключ-значение есть объект типа KeyValue с постоянным ключом. Любые два таких объекта можно сравнивать посредством операторов == и <. Сравнение происходит по ключам. Это будет нашим первым шаблоном класса, имеющим два параметра: К — тип ключа и Т — тип значения, ассоциируемого с ключом. Спецификация класса KeyValue ОБЪЯВЛЕНИЕ template <class К, class T> class KeyValue { protected: // после инициализации ключ не может быть изменен const К key; public: // словарные данные являются общедоступными Т value; KeyValue(К KeyValue, T datavalue); // операторы присваивания, не изменяют ключ KeyValue<K,T>& operator^ (const KeyValue<K,T>& rhs) ; // операторы сравнения, сравнивают два ключа int operator— (const KeyValue<K,T>& value) const; int operator— (const K& keyval) const; int operator< (const KeyValue<K,T>& value) const; int operator< (const K& keyval) const; // метод доступа к ключу К Key(void) const; }; ОПИСАНИЕ Конструктор создает пару ключ-значение. Конструктора по умолчанию нет. Если объект создан, ключ изменять нельзя. Оператор присваивания затрагивает только собственно данные (не ключ), а оператор отношения сравнивает ключи (а не сами данные). Метод Key предназначен для чтения ключа. ПРИМЕР Определяется пара ключ-значение, содержащая номер социальной страховки в качестве символьного ключа и запись типа Data в качестве значения.
struct Data { char name[30]; int yearsThisCompany; int jobclass; float salary; }; Data empData « {"Джордж Уильяме", 10, 5, 45000.00}; KeyValue<String, Data> Employee("345789553", empData); Реализация этого класса очень проста, несмотря на довольно изощренную концепцию словаря. Выберем класс для хранения объектов типа Key Value. Для хранения упорядоченных пар ключ-значение подойдут классы OrderedList, BinSTree и AVLTree, а для неупорядоченных — SeqList или HashTable. Все эти классы имеют методы Insert, Delete, Find и т.д. Чтобы создать словарь мы должны дополнить этот набор оператором индексирования []. Этот оператор связывает ключ, указываемый в качестве индекса, с полем данных соответствующего объекта KeyValue. В этой книге мы использовали наследование для выражения отношения "является". Еще одно применение наследования — расширение функциональности базового класса. Мы дополним с помощью наследования коллекцию пар ключ-значение оператором индексирования и другими специфическими словарными операциями. На рис. 14.6 показано, как класс Dictionary может быть образован из нескольких базовых классов. OrderedList BinSTree HashTable ListSize ListEmpty ClearList Find (KeyValue<K,T>& item) lnsert(const KeyValue<K,T& item) Delete(const KeyValue<K,T>& item) ListSize ListEmpty ClearList Find (KeyValue<K,T>& item) lnsert(const KeyValue<K,T& item) Delete(const KeyValue<K,T>& item) T& operator[] (const K& index); int lnDictionary(const K& keyval); void DeleteKeyteonst K& keyval); ListSize ListEmpty ClearList Find (KeyValue<K,T>& item) lnsert(const KeyValue<K,T& item) Delete(const KeyValue<K,T>& item) T& operator[] (const K& index); int lnDictionary(const K& keyval); void DeleteKey(const K& keyval); T& operatorQ (const K& index); int lnDictionary(const K& keyval); void DeleteKey (const K& keyval); Классы упорядоченных словарей Неупорядоченный словарь Рис. 14.6. Расширение коллекции объектов ключ-значение до словаря Спецификация класса Dictionary ОБЪЯВЛЕНИЕ #include "keyval.h" #include "bstree.h" #include "treeiter,h" template <class K, class T> class Dictionary: public BinSTree< KeyValue<K,T> > { // значение, присваиваемое элементу словаря по умолчанию. // используется оператором индексирования, а также методами // InDictionary и DeleteKey private: Т defaultValue;
public: // конструктор Dictionary(const T& defval); // оператор индексирования T& operator!] (const K& index); // дополнительные словарные методы int InDictionary(const K& keyval); void DeleteKey(const K& keyval); }; ОПИСАНИЕ Оператор индексирования выполняет большую часть работы. Он проверяет наличие заданного ключа в словаре. Если словарный элемент с таким ключом существует, оператор возвращает ссылку на этот элемент. Если нет, то создается новая словарная статья и возвращается ссылка на нее. Таким образом, любое создание или обновление словарных данных происходит с помощью оператора индексирования. Поскольку для класса KeyValue не создается конструктор, действующий по умолчанию, ключ и данные должны быть указаны. Поэтому при создании объекта оператор [] должен иметь некоторое значение словарной статьи по умолчанию. Оно передается в конструктору качестве параметра. Это значение следует выбирать с осторожностью, так, чтобы новый словарный элемент мог участвовать в выражениях. Например, словарь может содержать символьный ключ — слово — и символьное значение — дефиницию этого слова. Тогда объект BasicDict типа Dictionary объявляется следующим образом: // словарная статья по умолчанию пуста DictionaryOtring, String> BasicDict (''); Предположим, что следующий оператор употребляется впервые: BasicDict["секстет"] +« "Группа из шести исполнителей"; Оператор [] создает пустую словарную статью с ключом "секстет". Строковый оператор +в сцепляет с пустой строкой строку "Группа из шести исполнителей", создавая тем самым дефиницию слова "секстет". Метод InDictionary проверяет наличие в словаре пары ключ-значение с ключом keyval, а метод DeleteKey удаляет словарную статью, имеющую ключ keyval. Методы ListEmpty, ListSize и Clear List определены в базовом классе. Методы Insert, Find и Delete также могут непосредственно работать с объектами типа KeyValue, но сам факт их использования для словарей является несколько необычным. В большинстве приложений требуется итератор словаря, чтобы собирать данные для вывода. Поскольку объект типа Dictionary образуется из класса BinSTree, класс Dictionarylterator можно вывести иЗ класса Inorderlterator. template <class К, class T> class Dictionarylterator: public InorderIterator<KeyValue<K,T> { public: // конструктор Dictionarylterator(Dictionary<K,T>& diet); // начать итерацию нового словаря void SetList(Dictionary<K,T>& diet); }; // конструктор, diet "расширяет" объект BinSTree и использует его
// общедоступный метод GetRoot для инициализации базового класса // Inorderlterator template <class К, class T> DictionaryIterator<K,T>::DictionaryIterator(Dictionary<K/T>& diet): lnorderIterator< KeyValue<K,T> > (dict.GetRootO ) {} // использовать метод SetTree базового класса template <class K# class T> void DictionaryIterator<K,T>::SetList(Dictionary<K,T> & diet) { SetTree(diet.GetRoot()); ) Реализации классов Dictionary и Dictionarylterator находятся в файле dict.h. Программа 14.5. Построение толкового словаря Эта программа создает объект wordDictionary типа Dictionary со строковым ключом и данными. Значением словарной статьи по умолчанию является пустая строка. Файл defs.dat содержит список слов и их дефиниций. Ключевое слово находится в начале строки и завершается пробелом. Остальная часть строки содержит дефиницию. Слова вводятся в цикле и используются в качестве ключей для добавления своих дефиниций в словарь. Словарный итератор dictlter используется для прохождения словаря, во время которого для каждой найденной пары ключ-значение вызывается функция PrintEntry для распечатки словарной статьи. Эта функция сначала распечатывает ключевое слово и следующее за ним тире, а затем построчно выводит его дефиницию по 65 символов в строке без переносов по слогам. ♦include <fstream.h> ♦include <stdlib.h> ♦include "keyval.h" // итератор, просматривающий объекты KeyValue ♦include "dict.h" // класс Dictionary ♦include "strclass.h" // пары ключ-значение имеют тип String // распечатать объект KeyValue, содержащий ключевое слово word // и его дефиницию(и) void PrintEntry(const KeyValue<String,String>& word) { KeyValue<String,String> w « word; // поскольку после ключевого слова выводится " - ", // дефиниция распечатывается с позиции, равной Length(word) + 3 int linepos * w.Key().Length() + 3; int i; // распечатать слово и " - " cout « w.KeyO " - "; // распечатать дефиницию на 65-символьных строках while (!w.value. IsEmptyO) { // определить, умещается ли еще не распечатанная часть в 65-символьной // строке, вычислить индекс последнего символа в строке if (w.value.Length() > 65-linepos)
{ // текст не умещается в строке, двигаясь в обратном направлении, // найти первый пробел, не переносить слова по слогам. i * 64-linepos; while(w.value[i] != ' ') < д.—; } else // текст умещается в строке i « w.value.Length {) - 1; // вывести часть текста дефиниции, которая умещается в строке cout « w.value.Substr(0, i + 1) « endl; // удалить только что распечатанную часть текста. // приготовиться к переходу на новую строку w.value.Remove(0, i+1); linepos = 0; } } void main(void) { // входной поток данных ifstream fin; String word, definition; // словарь Dictionary<String,String> wordDictionary(""); // открыть файл defs.dat ключевых слов и их дефиниций fin.open("defs.dat", ios::in | ios::nocreate); if (!fin) { cerr « "Файл defs.dat не найден" « endl; exit(l); } // прочитать слово и его дефиницию, с помощью оператора индексирования // включить статью в словарь или обновить существующую дефиницию, // дополнив ее текущей while (fin » word) { if (fin.eofO) break; // прочитать пробел, следующий за ключевым словом definition.ReadString(fin); wordDictionary[word] += definition; } // объявить итератор для нисходящего обхода словаря DictionaryIterator<String,String> dictlter(wordDictionary) ; // просматривать словарь, распечатывать каждое ключевое слово // и его дефиницию(и) cout « "Толковый словарь:" « endl « endl; for (dictlter.Reset(); !dictlter.EndOfList(); dictlter.Next()) { PrintEntry(dictlter.Data()); cout << endl; } wordDictionary.ClearList(); } /* <Файл defs.dat>
Программа Последовательность операций, выполняемых компьютером. Финишировать Заканчивать, завершать. Причина То, из чего следует результат. Секстет Группа из шести исполнителей. Программа Перечень действий, приветственных речей, музыкальных пьес и т.п. Скорость Быстрота, проворность. Скорость Перемещение за единицу времени. Секстет Музыкальная композиция для шести инструментов. Шапка Головной убор. Шапка Газетный заголовок шириной на всю полосу. <Прогон программы 14.5> Толковый словарь: Причина - То, из чего следует результат. Программа - Последовательность операций, выполняемых компьютером. Перечень действий, приветственных речей, музыкальных пьес и т.п. Секстет - Группа из шести исполнителей. Музыкальная композиция для шести инструментов. Скорость - Быстрота, проворность. Перемещение за единицу времени. Финишировать - Заканчивать, завершать. Шапка - Головной убор. Газетный заголовок шириной на всю полосу. */ Реализация класса Dictionary Конструктор инициализирует базовый класс и задает значение словарной статьи по умолчанию. // конструктор, инициализирует базовый класс и задает значение // словарной статьи по умолчанию template <class К, class T> Dictionary<K,T>::Dictionary(const T& defaultval): BinSTree< KeyValue<K,T> >(), defaultValue(defaultval) {) Оператор индексирования создает объект targetKey типа KeyValue с заданным ключом и значением данных по умолчанию и ищет этот ключ на дереве. Если ключ не найден, targetKey вставляется в дерево. Элемент базового класса current устанавливается на только что найденный или вставленный узел. Оператор возвращает ссылку на значение данных в этом узле. // оператор индексирования, здесь делается почти вся работа template <class К, class T> Т& Dictionary<K,T>::operator[] (const K& index) { // определить целевой объект типа KeyValue, содержащий // данные задаваемые по умолчанию KeyValue<K,T> targetKey(index, defaultValue); // искать ключ, если не найден, вставить targetKey if (!Find(targetKey)) Insert(targetKey); // возвратить ссылку на найденные или вставленные данные return current->data.value; }
Функция InDictionary создает объект tmp типа KeyValue с заданным ключом и значением данных по умолчанию, ищет этот ключ на дереве и возвращает результат поиска. // проверить, существует ли объект типа KeyValue //с данным ключом template <class К, class T> int Dictionary<K,T>::InDictionary(const K& keyval) { // определить целевой объект типа KeyValue, содержащий // данные задаваемые по умолчанию KeyValue<K,T> tmp(index, defaultValue); int retval ■ 1; // искать tmp на дереве, вернуть результат if (!Find(tmp)) retval - 0; return retval; } Функция DeleteKey создает объект tmp типа KeyValue с заданным ключом и значением данных по умолчанию и удаляет этот словарный элемент из дерева. // удалить оОъект типа KeyValue с данным ключом из словаря template <с1азз к, class т> void Dictionary<K,T>::DeleteKey(const K& keyval) { KeyValue<K,T> tmp(index, defaultValue); Delete (tmp); > Письменные упражнения 14.1 а) Отсортируйте числовую последовательность 8, 4, 1, 9, 2, 1, 7, 4 посредством выбора. Отображайте состояние списка после каждого прохода. б) Повторите пункт а) для символьной последовательности V, В, L, А, Z, I, С, XI» S, S, В, Н. 14.2 В предыдущем упражнении выполните сортировку вставками. 14.3 Отсортируйте символьную последовательность С, А, М, Т, В, В, A, L методом включения. Проследите каждый шаг сортировки. 14.4 а) Какова эффективность сортировки посредством выбора, в случае массива п одинаковых элементов? б) Ответьте на вопрос а) для случаев сортировки вставками и методом пузырька. 14.5 Отсортируйте массив А, используя метод пузырька. После каждого прохода показывайте сам список и подсписок, подлежащий сортировке. А = 85, 40, 10, 95, 20, 15, 70, 45, 40, 90, 80, 10 14.6 Отсортируйте массив А с помощью "быстрой сортировки". Выбирайте центральный элемент из середины списка. Во время каждого прохода
фиксируйте все обмены элементов между нижним и верхним подсписками. Показывайте состояние последовательности после каждого прохода. А - 790, 175, 284, 581, 374, 799, 852. 685, 486, 347 14.7 В другой версии алгоритма "быстрой сортировки" в качестве центрального элемента выбирается A[low], а не A[mid]. Эта версия тоже является 0(п log2n)-cлoжнoй, но поведение худшего случая изменяется. Как? 14.8 Массив А следует отсортировать посредством включения его элементов в двусвязный список. Вставьте элемент в текущую точку и перемещайте его вперед по списку, если новый элемент больше текущего, или назад, если меньше. Напишите функцию DoubleSort, реализующую этот метод сортировки. template <class T> void DoubleSort (T а[], int n); Голова Хвост 14.9 Оцените алгоритмическую сложность метода сортировки из предыдущего упражнения. Рассмотрите наилучший, наихудший и средний случаи. 14.10 Какой из основных алгоритмов сортировки (выбором, вставками или пузырьковый) наиболее эффективен для обработки уже отсортированного списка? А если этот список отсортирован в обратном порядке? 14.11 В настоящей книге мы рассмотрели следующие методы сортировки: включением в бинарное дерево, пузырьковый, обменный, пирамидальный, вставками, поразрядный, выбором, турнирный. Для каждой из этих сортировок укажите сложность, потребности в памяти и дайте некоторые комментарии по поводу ее эффективности. В комментариях можно отразить следующие моменты: возможность досрочного окончания процесса в случае уже отсортированного списка, вероятность наихудшего случая, число обменов и величину константы пропорциональности (большое О). 14.12 Метод сортировки называют устойчивым (stable), если на любом шаге алгоритма два одинаковых элемента не меняются местами друг относительно друга. Например, в пятиэлементном массиве 5i 55 12 52 33 устойчивая сортировка гарантирует, что результирующая последовательность будет иметь следующий порядок: 5i 52 12 33 55 Классифицируйте методы сортировки из предыдущего упражнения с точки зрения их устойчивости. 14.13 Покажите, что хеш-функция hash(x) ■ х % m неприемлема при четном т. Изменится ли ситуация при нечетном т? (Совет. Рассмотрите распределение четных и нечетных случайных чисел.)
14.14 Предположим, что хеш-функция имеет следующие характеристики: Ключи 257 и 567 отображаются в 3 Ключи 987 и 313 отображаются в 6 Ключи 734, 189 и 575 отображаются в 5 Ключи 122 и 391 отображаются в 8 Ключи вставляются в таблицу в следующем порядке: 257, 987, 122, 575, 189, 734, 567, 313, 391. а) Покажите позиции этих данных в таблице, если коллизии разрешаются методом открытой адресации. НТ О 1 2 3 4 5 6 7 8 9 10 б) Покажите позиции этих данных в таблице, если коллизии разрешаются методом цепочек. НТ 0123456789 10 14.15 Повторите предыдущее упражнение при обратном порядке вставки в таблицу. 14.16 Для отображения данных в табличные индексы используйте хеш-функцию hashf(x) = х % 11. Данные вставляются в таблицу в следующем порядке: 11, 13, 12, 34, 38, 33, 27, 22. а) Постройте хеш-таблицу методом открытой адресации. б) Постройте хеш-таблицу методом цепочек. в) Для обоих методов определите коэффициент заполнения, среднее число проб, необходимое для обнаружения элемента в таблице и среднее число проб для констатации отсутствия элемента в таблице. 14.17 Покажите, что хеш-функция, отображающая символьные строки в целые числа посредством суммирования символов в строке, не является хорошей. Рассмотрите, как с помощью сдвигов можно исправить ситуацию. 14*18 При разработке хеш-функции иногда применяется метод свертки (folding). Ключ разбивается на части, которые затем комбинируются таким образом, чтобы получилось меньшее число. Это число используется в качестве значения хеш-функции или уменьшается еще раз посредством деления. Предположим, в некоторой программе в качестве ключа используется номер социальной страховки. Разобьем ключ на три группы по три цифры и сложим их. Получится некоторое число в диапазоне 0..2997. Например, номер 523456795 даст индекс 523 + 456 + 795 = 1774. Напишите хеш-функцию int hashf(char *ssn);
реализующую этот метод. (Совет. Нужно извлечь подстроки и преобразовать их в целое число.) 14.19 Дана следующая хеш-функция: unsigned short hash(unsigned short key) { return (key » 4) % 256; } а) Каков размер хеш-таблицы? б) Чему равны hashf(16) и hashf(257)? в) Что вообще делает эта хеш-функция? 14.20 Дана следующая хеш-функция: unsigned long hash(unsigned long key) { return (key » 8) % 65536; } а) Каков размер хеш-таблицы? б) Чему равны hashf(16) и hashf( 10000)? в) Что вообще делает эта хеш-функция? 14.21 Проблемой открытой адресации является скопление (clustering) конфликтующих ключей. Скопление Скопление Скопление TableSize-1 Предположим, в таблице есть N ячеек. Если хеш-функция хорошая, то какова вероятность хеширования в индекс р? Если ключ попал в ячейку р, то ячейка р+1 может быть занята ключом, хешированным в р или в р+1, Какова вероятность занятия ячейки р+1? Какова вероятность занятия ячейки р+2? Объясните, почему вообще возникают скопления. 14.22 Если ключ отображается в занятую ячейку таблицы с номером index, метод открытой адресации по схеме линейного опробывания выполняет функцию index * (index+1) % m; // проверить следующий индекс Эта функция называется функцией рехеширования (rehash function), а метод разрешения коллизий — рехешированием (rehashing). Линей-
ное опробывание благоприятствует скоплению ключей. Однако функция рехеширования может рассеивать ключи лучше. Два целых числа р и q называются взаимно простыми, если не имеют общего делителя, иного чем 1. Например, 3 и 10 — взаимно простые числа, как и 18 и 35. Пусть в методе открытой адресации используется следующая функция рехеширования: index « (index + d) % m; где d и m — взаимно простые числа1. Последовательное применение этой функции порождает индексы от 0 до т-1. При линейном опро- бывании d = 1. а) Если d и т не являются взаимно простыми, некоторые ячейки таблицы пропускаются. Покажите, что при d = 3 и m - 93 функция index « (index +3) % 93 попадает лишь в каждую третью ячейку таблицы. б) Покажите, что если m — простое число и d < m, то вся таблица покрывается функцией рехеширования. в) Выполните упражнение 14.16а, используя функцию рехеширования index = (index + 5) % 11 14.23 Хеш-таблицы хорошо подходят для тех приложений, где основной операцией является поиск и выборка. Запись вставляется в таблицу, а затем много раз выбирается оттуда. Однако хеширование методом открытой адресации не слишком удобно для тех приложений, где требуются удаления данных из хеш-таблицы. Рассмотрим следующую таблицу из 101 ячейки и хеш-функцию hashf(key) = key % 11. а) Удалите ключ 304, поместив в ячейку 1 число -1. Что произойдет при поиске ключа 707? Объясните, почему для решения задачи удаления в общем виде недостаточно просто пометить ячейку как незанятую? б) Решение этой проблемы предусматривает запись в ячейку, содержащую удаляемый элемент, ключа DeletedData. При поиске ключа все ячейки, содержащие DeletedData, пропускаются. Для удаленных ключей используйте значение -2. Покажите, что при таком подходе удаление ключа 304 не помешает корректному поиску ключа 707. Операции вставки и выборки в алгоритме открытой адресации должны быть модифицированы с учетом удалений. в) Опишите алгоритм удаления табличного элемента. г) Опишите алгоритм обнаружения элемента в таблице. д) Опишите алгоритм включения элемента в таблицу. 14.24 Еще одним методом разрешения коллизий, который иногда применяется, является связывание в срастающиеся списки. Этот метод подобен 1 Этот метод разрешения коллизий называют еще открытой адресацией по схеме случайного опробывания. — Прим. перев.
открытой адресации, но конфликтующие ключи, которые должны располагаться в таблице ниже и т.д. по кругу, сцепляются вместе с помощью связанного списка. Возможны ситуации, когда цепочка содержит ключи, первоначально хеширующиеся в разные ячейки таблицы. Тогда говорят, что эти списки срастаются. Например, если hashf(x) = х % 7 и в таблицу включаются 12, 3, 5, 20 и 7, мы имеем следующую картину: -1 обозначает пустую ячейку и NULL-указатель а) Выполните упражнение 14.16а, используя метод связывания в срастающиеся списки. б) Как вы думаете, сравним ли данный метод с методами открытой адресации и методом цепочек с точки зрения быстродействия? Классифицируйте все эти методы по быстродействию. в) Проще ли решается проблема удаления, чем в методе открытой адресации? Поясните. 14.25 Даны множество ключей ко, кь ..., kn-i и совершенная хеш-функция (perfect hashing function) H — хеш-функция, не порождающая коллизий. Нет смысла искать совершенную хеш-функцию, если множество ключей не является постоянным. Однако для таблицы символов компилятора (содержащую зарезервированные слова while, template, class и т.д.) совершенная хеш-функция крайне желательна. Тогда для определения того, является ли некоторый идентификатор зарезервированным словом, потребуется лишь одна проба. Найти совершенную хеш-функцию даже для конкретного набора ключей очень сложно. Обсуждение этого предмета выходит за рамки данной книги. Кроме того, если данный набор ключей пополнится новыми ключами, совершенная хеш-функция, как правило, перестает быть совершенной. а) Даны множество целочисленных ключей 81, 129, 301, 38, 434, 216, 412, 487, 234 и хеш-функция Н(х) = (х+18)/63 Является ли данная хеш-функция совершенной? б) Дан набор символьных ключей Bret, Jane, Shirley, Bryce, Michelle, Heather Придумайте совершенную хеш-функцию для 7-элементной таблицы. 14.26 Дано следующее описание класса текстовых файлов, в котором моделируются файловые операции языка Паскаль. Реализуйте этот класс с помощью операций класса fstream языка C++,
enum Access {IN, OUT} // определяет поток данных файла class PascalTextFile private: fstream f; // файловый поток Си++ char fname[64]; // имя файла Access accesstype; // входной или выходной поток int isOpen; // используется методом Reset void Error(char *msg); // используется для печати ошибок public: PascalTextFile(void); // конструктор void Assign(char *filename); // задает имя файла void Reset(void); // открывает файл для ввода void Rewrite(void); // открывает файл для вывода int EndFile(void); // читает флаг конца файла void Close(void); // закрывает файл int PRead(T А[], int n); // читает п символов в А void PWrite(T А[], int n); // записывает п символов в А }; Упражнения по программированию 14.1 Напишите программу, создающую упорядоченный список N случайных чисел из диапазона 0—1000 с помощью алгоритма включения в дву- связный список из письменного упражнения 14.8. Распечатайте отсортированную последовательность. 14.2 Реализуйте следующий алгоритм: Разбить n-элементный список пополам. Отсортировать каждую половину с помощью сортировки выбором, а затем слить обе половины. а) Проанализируйте сложность этой сортировки. б) Используйте этот алгоритм для сортировки 20000 случайных чисел и измерьте время выполнения программы. г) Запустите ту же программу, но использующую обычную сортировку посредством выбора. Какая версия работает быстрее? 14.3 В разделе 14.6 обсуждалась сортировка файлов прямым слиянием. Реализуйте внутреннюю версию этого алгоритма для сортировки п-эле- ментного массива. Отсортируйте с помощью этой программы 1000 случайно сгенерированных чисел с двойной точностью. Распечатайте первые и последние 20 элементов отсортированного массива. 14.4 Дана следующая структура: struct TwoKey { int primary; int secondary; }; Создайте массив из 100 записей этого типа. Поле primary содержит случайное число в диапазоне 0..9, а поле secondary — в диапазоне 0..100. Модифицируйте алгоритм сортировки вставками для упорядочения по двум ключам. Новый алгоритм должен производить сортировку по вторичному ключу для каждого фиксированного значения
первичного ключа. Отсортируйте с его помощью ранее созданный массив. Распечатайте массив в формате primary (secondary). 14.5 Сортировка Шелла, названная так по имени своего изобретателя Дональда Шелла, является простым и довольно эффективным алгоритмом. Она начинается с разбиения исходного n-элементного списка на к подсписков: а[0], a[k+0], а[2к+0], ... а[1], а[к+1], а[2к+1], ... ... а[к-1], а[к+(к-1)], а[2к+(к-1)], ... Подсписок начинается с первого элемента a[i] в диапазоне а[0] ... а[к-1] и включает в себя каждый последующий k-ый элемент. Например, при к = 4 следующий массив разбивается на четыре подсписка: 7586249130 Подсписок #0 7 2 3 Подсписок #1 5 4 0 Подсписок #2 8 9 Подсписок #3 6 1 Отсортируйте каждый подсписок сортировкой вставками. В нашем примере получатся следующие подсписки: Подсписок #0 2 3 7 Подсписок #1 0 4 5 Подсписок #2 8 9 Подсписок #3 16 и частично отсортированный массив 2081349675. Повторите процесс с к = к/3. Продолжайте так до к = 1, при котором список получается отсортированным. Оптимальный выбор начального значения к — задача теории алгоритмов. Алгоритм является успешным, поскольку обмен данных происходит в несмежных сегментах массива. В результате элемент перемещается гораздо ближе к своей окончательной позиции, чем при обмене соседних элементов в сортировке простыми вставками. Создайте в главной процедуре массив 100 случайных целых чисел в диапазоне 0—999. Для сортировки Шелла возьмите начальное значение к = 40. Распечатайте исходный и отсортированный списки по 10 чисел в строке. 14.6 В этом упражнении разрабатывается простая программа орфографического контроля. В программном приложении к этой книге имеется файл words, который содержит 500 наиболее часто употребляющихся слов. Прочитайте этот файл и вставьте все имеющиеся там слова в хеш-таблицу. Прочитайте текстовый документ и разбейте его на отдельные слова с помощью следующей несложной функции: // извлечь слово, начинающееся с буквы и состоящее из букв и цифр int GetWord (ifstream& fin, char w[[])
{ char с; int i - 0; // пропустить все не буквы while (fin.get(с) && !isalpha(c)); // возвратить 0 по окончании файла if (fin.eofO) return 0; // записать первую букву слова w[i++] * с; // собрать буквы и цифры. Завершить слово нулем while (fin.get(с) && (isalpha(c) II isdigit(с))} w[i++] * с; w[i] » 'Nonreturn 1; } Используя хеш-таблицу, распечатайте слова, в которых могут быть орфографические ошибки. 14.7 Разработайте классы OpenProbe и OpenProbelterator, поддерживающие хеш-таблицы, которые используют метод открытой адресации. Ниже дана спецификация класса OpenProbe. Для реализации класса используйте письменное упражнение 14.23. // формат записей таблицы template <class T> struct TableRecord { // доступен (да или нет) int available; Т data; }; template <class T> class OpenProbe: public List<T> { protected: // динамически создаваемая таблица и ее размер TableRecord<T> *table; int tableSize; // хеш-функция unsigned long (*hf) (T key); // индекс ячейки, к которой последний раз было обращение int lastlndex; public: // конструктор, деструктор OpenProbe(int tabsize, unsigned long hashf(T key)); -OpenProbe(void); // стандартные методы обработки списков virtual void Insert(const T& key); virtual void Delete(const T& key); virtual int Find(T& key); virtual void ClearList(void);
// обновить ячейку, к которой последний раз было обращение void Update(const T& key); friend class OpenProbeIterator<T>; }; Используйте эти классы в программе 14.2. 14.8 Поместите объявление класса PascalTextFile из письменного упражнения 14.27 в файл ptf.h. Напишите программу, которая с помощью этого класса читает файл ptf.h, преобразует каждую строчную букву в прописную и записывает их в файл ptf.uc. Используйте соответствующую команду вашей операционной системы для распечатки содержимого файла ptf.uc. 14.9 Используйте класс BinFile для следующих программ. а) Запись Person определяет последовательность полей в некоторой базе данных. struct Person { char first[20]; // имя char last[20]; // фамилия char id[4]; // четырехзначный идентификатор >; Определите функцию DelimRec, параметрами которой являются запись Person и буфер. void DelimRec (const Person &p, char *buffer); Эта функция преобразует каждое поле записи в символьную строку переменной длины, заканчивающуюся разделителем "р. Три поля сцепляются друг с другом в буфере. Например, Person: first Tom last Davis id 6192 Буфер: Tom|Davis|6192| Напишите программу, которая вводит пять записей Person по одному полю в строке и создает файл символов reel.out. Для каждой записи создайте компактный буфер длиной п и выведите в файл размер этого буфера в виде двухбайтового короткого целого и п символов, находящихся в буфере. Распечатайте содержимое reel.out с помощью утилиты шестнадцатеричного дампа, если таковая имеется в вашей системе. б) Напишите программу, которая вводит последовательные записи из файла reel.out, расширяя каждое поле до его фиксированной длины, определенной в записи Person. Если нужно, дополняйте поле пробелами справа. Теперь запись Person имеет длину 44 байта. Выведите новые записи в файл rec2.out. в) Напишите программу поиска заданного четырехзначного идентификатора в файле rec2.out. В случае удачи распечатайте имя и фамилию найденного человека.
14.10 Дан следующий тип записи: struct CharRec { char Key; int count; }; С помощью класса BinFile создайте файл letcount из 26 таких записей, содержащих в поле key буквы от 'А* до 'Z' и 0 в поле count. Прочитайте текстовый файл, преобразуя каждую букву в прописную и обновляя поле count в соответствующей записи бинарного файла letcount. Распечатайте частоту каждой буквы. Отсортируйте записи по полю count методом естественного слияния (см. раздел 14.6) с длиной блока, равной 4. Распечатайте отсортированный файл. 14.11 Образуйте класс упорядоченных словарей из класса OrderedList. Используйте этот класс, а также связанный с ним итератор в программе 14*5. 14.12 Образуйте класс словарей из класса HashTable. Используйте этот класс, а также связанный с ним итератор в программе 14.5. Распечатайте словарные объекты в порядке извлечения итератором. Отсортируйте результаты обхода и распечатайте словарные объекты в алфавитном порядке.
Приложение Ответы на избранные письменные упражнения Глава 1 1.2 a) ADT Cylinder Данные Радиус и высота цилиндра представляются положительными числами с плавающей точкой. Операции Конструктор Начальные значения: Радиус и высота цилиндра. Обработка: Задать начальные значения радиуса и высоты цилиндра. Area Вход: Нет Предусловия: Нет Обработка: Вычислить площадь цилиндра по заданным радиусу и высоте. Выход: Возвратить величину площади Постусловия: Нет Volume Вход: Нет Предусловия: Нет Обработка: Вычислить объем цилиндра по заданным радиусу и высоте. Выход: Возвратить величину объема. Постусловия: Нет Конец ADT Cylinder 1.3 Пусть Cyl и Hole — цилиндры с радиусами R и Rh, а С — окружность с радиусом Rh. а) Результирующий объем геометрического тела равен CyLVolumeQ — Hole.Volume(). б) Площадь этого геометрического тела равна Cyl.Area() + Hole.AreaQ — 4*С.Агеа(). 1.5 const float PI = 3.14159; class Cylinder {
private: float radius, height; public: Cylinder(float r, float h): radius(r), height(h) {} float Area(void) {return 2.0*PI*radius(radius+height);) float Volume(void) {return Pl*radius*radius*height;) ); 1.11 а) Два или более объектов в некоторой иерархии наследования классов имеют одноименные методы, выполняющие разные задачи. Это свойство позволяет объектам различных классов отвечать на одно и то же сообщение. Приемник сообщения определяется динамически в процессе выполнения программы. Глава 2 2.1 а) 5; б) 14; в) 55; г) 127 2.3 а) 26; б) 1055; в) 4332; г) 255; д) 65536; е) 17; ж) 57; з) 73; и) FF 2.4 а) С; б) А6; в) F2; г) BDE3; д) 11000010000; е) 1010111100100000 2.5 а) 32 50 32; б) 32 32 40 2.8 а) 'N'; б) *К';
в) '*':42ю, IOIOIO2 'q':113io, HHOOOI2 <сг>:13ю, 11012 2.9 V 113 8 2.11 а) 6.75 л\ i„ 111 1 , mn eJ .111 ... Ill ... =^ + j + jT+...+ — +... = 1 - -g При стремлении п к бесконечности дробная часть стремится к 1. Следовательно десятичный эквивалент равен 3 + 1 = 4. 2.12 б) 1.001 2.14 а) 4Of00000; г) 29.125 2.17 X = 55, Y = 10, А - {5.3, 6.8, 8.9, 1, 5.5, 3.3} 2.18 а) (1) Для А выделяется 10 байт. (2) &А[3] = 6000 + 2*3 = 6006, &А[1] = 6000 + 2*1 = 6002 б) (1) 33 (2) А = {60, 50000, -10000, 10, 33} (3) &А[3] - 2050 + 4*3 - 2062 2.20 а) 30 * 2 = 60 байт. б) &А[3][2] = 1000 + 3*12 + 2*12 - 1040, &А[1][4] - 1000 + 1*12 + 4*2 = 1020 2.22 а) Ч\ Ч\ NULL; б) Stockton, C.A. March 5, 1994; в) 1; г) 1 2.23 void strinsert (char *s, char *t, int i) { char tmp[128]; // хранит хвост s if (i > strlen(s)) // выход по достижении хвостового нуля return; strcpy(tmp, &s[i]); // скопировать хвост s в tmp strcpy(&s[i], t); // скопировать t на место хвоста strcpy(s, tmp); // сцепить с хвостом из tmp } 2.25 б) void PtoCStr(char *s) { int n = *s++; // взять счетчик байтов while (n—) // передвинуть каждый символ влево *(s-l) = *s++; // на одну позицию *s *= 0; // завершить формирование строки ) 2.27 Complex cadd(Complex& х, Complex& у) { Complex sum = {x.real+y.real, x.imag+y.imag}; return sum;
} Complex cmul(Complexs x, ComplexS y) { Complex product = {x.real*y.real - x.imag*y.imag, x.real*y.imag + x.imag*y.real}; return product; } Глава 3 3.2 6) class Box { private: float length, width, height; public: Box(float 1, float w, float h); float GetLength(void) const; float GetWidth(void) const; float GetHeight(void) const; float Area(void) const; float Volume(void) const; }; Box::Box(float 1, float w, float h): length(1), width(w), height(h) {} float Box::Area(void) { return 2.0 * (l*w + l*h + w*h); } в) Напишите функцию Qualify (Box В), которая возвращает 0, если ящик бракованный, или 1 - в противном случае. if ( (2*(B.GetLength() + B.GetWidth()) + В.GetHeight()) < 100 ) return 1; // и т.д. 3.3 а) private и public должны заканчиваться двоеточием ":". Последняя закрывающая фигурная скобка "}" должна заканчиваться точкой с за- пятой ; . б) Y(int n, int m): p(n), q(m) {} 3.4 а) class x { private: int a, b, c; public: X(int x=l, int y-1, int z=l); int f(void); }; б) X:X(int x, int y, int z): a(x), b(y), c(z) {} 3.5 class Student { ... public: Student(int id, int studgradepts, int studunits): studentid(id), gradepts(studgradepts), units(studunits) {ComputeGPAO ;} • • • }; void Student::UpdateGradeInfо(int newunits, int newgradepts)
{ units +* newunits; gradepts += newgradepts; ComputeGPA(); } 3.8 a) CardDeck::CardDeck(void) { for (int i=0; i<52; i++) cards[i] = i; currentCard = 0; ) void CardDeck::Shuffle(void) { static RandomNumber rnd; int randlndex, tmp; for (int i=0; i<52; i++) { randlndex = i + rnd.Random(52-i); tmp * cards[i]; cards[ij = cards[randlndex]; cards[randlndex] = tmp; } currentCard = 0; } б) В DealHand объявить локальный целочисленный массив размером 52. С помощью GetCard записать в массив п значений карт. Отсортировать массив, используя, например обменную сортировку, которая описана в гл. 2. Распечатать массив в цикле с помощью PrintCard. 3.9 Temperature Average(Temperatures a[], int n) { float avgLow =0.0, avgHigh = 0.0; for (int i=0/ i<n/ i++) { avgLow +~ a[i].GetLowTemp(); avgHigh += a[i].GetHighTemp(); } avgLow /= n; avgHigh = /= n; return Temperature(avgLow, avgHigh); } 3.11 RandomNumber rnd; а) int (rnd.fRandom() <= 0.2) ... б) int weight; weight = 140 + rnd.Random(91); 3.12 а) Замечание. Статический член класса определяется вне класса, но может быть доступен только для функций-членов класса. Все объекты разделяют значение статического элемента данных класса. #include "random.h" class Event { private: int lowTime, highTime; static RandomNumber rnd; public: Event (int low = 0, int high = 1) : lowTime (low), highTime(high) { if (lowTime > highTime) { cerr « "Нижняя граница превышает верхнюю."
« endl; exit(1); } } int GetEvent(void) {return lowTime + rnd.Random(highTime-lowTime+1);} >; // rnd — статический элемент данных класса Event RandomNumber Event::rnd; в) Event A[5] = {Event(10,20), Event(10,20), Event(10,20), Event(10,20), Event(10,20)}; // использовать конструктор по умолчанию Event B[5]; // lowTime = 0; highTime = 1 3.14 "l 2 3~ 0 -7 -8 0 0-4 * x." xx x, - "б" -14 0 6) Determinate - (1) (-7) (-4) = 28 Глава 4 4.2 а) массив; в) стек; д) множество; ж) файл; и) пирамида; к) словарь. 4.4 б) п2 + 6п 4- 7 < п2 + п2 + п2 - 3n2, n > 6 Г) П8 + П2 - 1 ^ П3 + П2 - 1 2 1 ^ 2 ^ ^ о 2 ^1 < = n2 + n <n2 + n< 2n2, n > 1 п +1 п п 4.5 а) п = 10 б) 2П + п3 < 2П + 2П = 2 (2n), n > 10 4.7 К log2n < Kn, n >. 1, поэтому этот алгоритм также имеет порядок О(п). 4.8 б) О(п); в) 0(п2). 4.11 а) (3) п/2, т.е. О(п); б) (1) 1 4.14 Удаляет из списка максимальный элемент. L должен передаваться по ссылке, поэтому внутренний программный стек обновляется.
Глава 5 5.3 <строка 1> 22 <строка 2> 9 <строка 3> 8 <строка 4> 18 5.4 void StackClear(Stacks S) { while (!S.StackEmpty()) S.Pop (); } 5.6 Копирует стек SI в S2 с помощью промежуточного стека tmp. 5.7 int StackSize(Stack S) { int size - 0; // S передается по значению, программный стек не изменяется while (!S.StackEmpty()) { size++; S.Pop(); } return size; } 5.9 void SelectItem(Stack& S, int n) { Stack Q; int i, foundn ■ 0; while (!S.StackEmpty()) { i = S.Pop(); if S.PopO ; { foundn++; break; } Q.Push(i); } while (!Q.StackEmpty()) S.Push(Q.Pop()); if (foundn) S.Push(n); } 5.10 6) a b + d e - / 5.11 6) a*(b + c) 5.14 <строка 1> З <строка 2> 18 <строка 3> 22 <строка 4> 9 5.15 Выстраивает очередь в обратном порядке. Переменная Q должна передаваться по ссылке, чтобы параметр менялся в процессе выполнения программы. 5.18 DataType PQueue::PQDelete(void) { DataType min; int i, minindex » 0; if (count > 0) { min * pqlist[0]; // положить pqlist[0] минимальным // обработать остальные элементы, обновляя значение и индекс // минимального элемента for (i^O; Kcount; i++) if (pqlistfi] < min) // новый минимальный элемент равен pqlistfi] // новый индекс минимального элемента равен i { min e pqlistfi]; minindex = i;
} // сдвинуть влево pqlist[minindex+1]..pqlist[count-1] i = minindex; while (i < count-1) { pqlist[i] = pqlist[i+1]; i++; } count—; // уменьшить счетчик } // pqlist пуст; завершить программу else { cerr « "Попытка удаления из пустой приоритетной очереди!" « endl; exit(1); } return min; // возвратить минимальное значение } Глава 6 ел а) Правило #1. Списки параметров не отличаются друг от друга. Функции 1 и 2 имеют одинаковые списки, и задаваемые по умолчанию параметры функции 3 не перегружаются. б) Правило #1. Списки параметров различны. Перегрузка сделана правильно. в) Правило #2. Функции 1 и 2 допустимы, так как тип enum считается отличающимся от других типов. Однако typedef в функции 3 не оказывает никакого влияния. Компилятор полагает список параметров имеющим форму "int& х" — такую же, что и для функции 1. 6.3 Максимум из а и b равен 99. Максимум из a, b и с равен 153. 1.0 + max(hl, h2) = 1.05. Максимум из t, u и v равен 70000. 6.5 Обмен символьных строк языка C++. void Swap(char *s, char *t) // предполагается, что s и t содержат не более 7 9 символов { char tmp[80]; strcpy(tmp, s); strcpy(s, t); strcpy(t, tmp); } 6.7 а) ModClass::ModClass (int v): dataval(v % 7) {} ModClass ModClass::operator+ (const ModClass& x) { return ModClass(dataval+x.dataval); } б) ModClass::operator* (const ModClass& x, const ModClass& y) { return ModClass(x,dataval * y.dataval); } в) ModClass Inverse(const ModClassfc x) { ModClass prod value; for (int i=0; i<7; i++) { value «■ ModClass (i); prod = x * ModClass(i); if (prod.GetValueO == 1) break; } return value; )
6.9 Complex::Complex(double x, double y): real(x), imag(y) {} Complex Complex::operator+ (Complex x) const { return Complex(real+x.real, imag+x.imag); } Complex Complex: .-operator/ (Complex x) const { double denom - x.real*x.real + x.imag*x.imag; return Complex((real*x.real + imag*x.imag)/denom, (imag*x.real - real*x.imag)/denom); } // вывод в формате (real, imag) ostream& operator« (ostream& ost, const Complex& x) { ostr « ' (' « x.real « ',' « x.imag « ')'; return ostr; } 6.11 а) return ModClass(num/den); б) return Rational(dataval); 6.13 а) Set::Set(int a[], int n) { for (int i*0; KSETSIZE; i++) member[i] = FALSE; for (i=0; i<n; i++) member[a[i]] = TRUE; } б) int operator" (int n, Set x) { if (n<0 || n>=SETSIZE) { cerr « "Оператор А: неверный операнд" « n « endl; exit(l); } return (x.member[n]); } в) (i) {1, 4, 8, 17, 25, 33, 53, 63}; (ii) {1,25}; (iii) 0; (iv) 1 г) <выход> <строка 1> {1,2,3,5,7,9,25} <строка 2> {2,3} <строка 3> 55 <строка 4> 55 is in A Д) Set Set::operator+ (Set x) const { int i; Set tmp; for (i=0; KSETSIZE; i++) tmp.member[i] = member[i] + x.member[i]; return tmp; } void Set::Insert(int n) { if (n<0 || n>=SETSIZE) { cerr « "Insert: неверный параметр " « n « endl; exit(1); } member[n] = TRUE; } Глава 7 7.1 a) template <class T> T Max(const T &x, const T &y) { return (x<y) ? у : x; }
6) char *Max(char* x, char* y) { return (strcmp(x,y) < 0) ? у : x; ) 7.3 template <class T> int Max{T Arr[], int n) { T currMax - Arr[0]; // подразумевается n>0 int currMaxIndex * 0; for (int i*0; i<n; i++) if (curMax < Arr(i]) { currMax « Arrfi]; CurrMaxIndex - i; } return currMaxIndex; } 7.5 template <class T> void InsertOrder(T A[], int n, T elem) { int i - 0; // подразумевается n>0 while (i<n && A[i]<elem) // найти позицию вставки i++; if (i < n) for (int j=n; j>i; j —) // сдвинуть хвост вправо ACj] - A[j-1]; A[i] - elem; } Глава 8 8.1 а) 0123412345 б) Нет. Указателю р присваивается два разных адреса при обращении к новой функции. Чтобы р-10 указывал на начало первого списка десяти целых чисел, новая функция должна выделить память в последовательных блоках. 8.2 а) int *px e new int (5); б) а * new long[n]/ в) р ~ new DemoC; p->two - 500000; p->three » 3.14; г) р - new DemoD; p->one; p->two = 35; p->three * 1.78; strcpy (p->name, "Bob C++"); Д) delete px; delete [] a; delete p; delete p; 8.3 а) Dynamiclnt::DynamicInt(int) {pn - new int(n);) б) Dynamiclnt::Dynamiclnt(const Dynamiclnt &x) (pn e new int(*x.pn);} в) Dynamiclnt::operator int(void) // возвратить целое значение {return *pn;} г) istreams operator» (isteram istr, Dynamiclnt& x) { istr » *(x.pn); return istr; } 8.4 a) p = new Dynamiclnt(50);
б) г = new Dynamiclnt[3]; // каждый элемент имеет нулевое значение в) for (int i=0; i<10; i++) a[i].SetVal(100); // или a[i] = DynamicInt(lOO); r) delete p; delete [] q; 8.8 a) DynamicType<int> *p = new DynamicType<int> (5); б) cout « *p; // используется перегруженный оператор « cout « p->GetValue(); // используется функция-член класса cout « int(p); // используется оператор преобразования в целое в) Выделяет память под массив 65-ти объектов типа PynamicType<char>. Каждый элемент этого массива равен NULL. Выделяет память под один объект типа DynamicType<char> и присваивает ему значение 'А'. г) 35; Д) 35; е) D D 68; ж) delete p; delete с; delate Q; // ошибка: переменная Q не создавалась динамически 8.9 а) Параметр х передается по значению, и, следовательно, будет вызываться конструктор копирования. Конструктор копирования постоянно вызывал бы самого себя в программе, выполняющей бесконечный цикл. б) Вы не могли бы иметь цепочку операторов присваивания С = В = А; 8.11 Оператор '+=' прибавляет правую часть г к текущему объекту Rational, а затем присваивает результат этому же объекту. Текущий объект задается с помощью *this. Значение "*this + г" присваивается текущему объекту (*this) и возвращается в точку вызова. 8.12 а) ArrCL<int> A(20); ArrCL<char> В; ArrCL<float> C(25); б) Класс ArrCL выполняет проверку границ массива. А[30] выходит за границы. в) 20-элементный массив агг содержит элементы 2, 4, 6, 8, ..., 40, сумма которых (20 * 42)/2 = 10 * 42 = 420. Обе функции вычисляют одно и то же значение, 8.13 а) "Have a"; б) "nice day!"; в) "Have a nice day!"; г) "Have a nice day!" 8.14 a) 10; б) у; в) 1; г) Индекс 24 находится вне диапазона. д) хуа52с; ж) abcl2ABCxya52cba
8.16 а) 15; б) 10; в) 65520; г) 1; Д) 8 8.17 (1) соответствует функции three; (4) соответствует функции one 8.19 template <class T> Set<T> Set<T>::operator- (void) const { Set<T> tmp(setrange); for (int i^O; i<arraysize; i++) // сформировать универсальное множество tmp.member[i] = ~tmp.member[i]; // присвоить каждому элементу tmp // значение 0=111...Ill return tmp-*this; // возвратить разность между универсальным // и текущим множествами } 8.20 а) Set<T> UniversalSet(n); UniversalSet = -UniversalSet; б) template <class T> Set<T>Difference(const Set<T>& S, const Set<T>& T) {return S* ~T;} Глава 9 9.1 а) 2 3; б) 5 3; в) 7 7; г) 15 15; Д) 17 17 9.3 Node<int> *head = NULL, *p; for (int i=20; i>0; i—) { p = new Node<int>(i, head); head = p; } p = head; while (p != NULL) { cout « p->data « " "; p = p->NextNode(); } 9.6 б) Следующий узел снова ссылается на р. в) Следующий узел ссылается сам на себя. 9.7 a) template <class T> void InsertFront(Node<T> header, T item) { Node<T> *p - new Node<T>(item) ; header.InsertAfter(p); }
9.9 Начиная с текущего узла, сканировать оставшуюся часть списка и прибавлять 7 к содержимому каждого узла. 9.10 Удаляет из списка первый узел и вставляет его в конец списка. 9.11 template <class T> int CountKey(Node<T> *head, T key) { Node<T> *p = head; int count = 0; while (p != NULL) { if (p->data == key) count++; p = p->NextNode(); } return count; } 9.14 а) 10 8 6 4 2; б) 2 4 6 8 10; в) 10 8 6 4 2; г) 10 8 6 4 2 9.15 а) 60 70 80 90 100; б) 20 40 60 80 100; в) 20 10 30 40 50 60 70 80 90 100 9.17 void OddEven(LinkedList<int>& L, LinkedList<int>& Ll, LinkedList<int>& L2) { L.Reset(); while (!L.EndOfList()) { if {L.DataO % 2 == 1) Ll.InsertAfter(L.Data{)); else L2. InsertAf ter (L.DataO ) ; L.NextO ; } } 9.19 template <class T> void DeleteRear(LinkedList<T>& L) { L.Reset(); for (int i=0; i<L.ListSize()-1; i++) L.NextO ; L.DeleteAtO ; } 9.23 Переставляет элементы связанного списка в обратном порядке, копируя их в промежуточный стек, а оттуда обратно в связанный список. 9.27 Каждая очередь имеет объект типа LinkedList, включенный посредством объединения. Когда объекты очереди присваиваются друг другу, вызывается перегруженный оператор из класса LinkedList и один список копируется в другой. 9.29 template <class T> void InsertOrder(CNode<T> *header, Cnode<T> *newNode) { CNode<T> *curr = header->NextNode(), *prev = header;
while (curr !- header && curr->data < newNode->data) { prev = curr; curr «■ curr->NextNode (); } prev->InsertAfter(newNode); } 9.32 template <class T> DNode<T> *DNode<T>::DeleteNodeRight(void) { DNode<T> *tempPtr « next/ // сохранить адрес узла if (next »- this) return NULL; // указывает на самого себя; выйти! // текущий узел указывает на преемника tempPtr .right - tempPtr->right; // преемник tempPtr снова указывает на текущий узел tempPtr->right->left - this; ) Глава 10 10.1 Результат зависит от того, в каком порядке компилятор вычисляет операнды. Если п=3 и левый операнд вычисляется первым, результатом будет 3*2! = 6. Если в первую очередь вычисляется правый операнд, в результате получается 2*21 = 4. 10.2 1 1 5 13 41 121 365 1093 3281 984 ... 10.4 125 10.5 котсеркереп отЭ 10.7 float avg(float а[], int n) { if (n -- 1) return a[0]; else return float (n-1)/n*avg(a, n-1) + a[n-l]/n; ) 10.8 int rstrlen(char *s) { if (*s — 0) return 0; else return l+rstrlen(s+l); } 10.12 Решение: 1 2 6 7 11 12
Глава 11 11.2 а) 3; б) 2 11.6 Да 11.7 а) 15; б) 42; в) Прямое прохождение: 50 45 35 15 5 40 38 36 42 43 46 65 75 70 85 11.8 Вставляет узлы в бинарное дерево поиска. Передача указателя по ссылке позволяет изменять корень и поля указателей узлов. 11.9 а) Прямое прохождение: М F Т N V U б) Обратное прохождение: A D I R О L F 11.10 а) RNL-прохождение: V U Т N М F б) RLN-прохождение: R О I L A D F в) Поперечное прохождение: ROTARYCUBL 11.14 a)ACFEIHBDG 11.20 template <class T> void PostOrder_Right (TreeNode<T> *t, void visit(T& item)) { if (t != NULL) { // рекурсивное прохождение завершается на пустом поддереве PostOrder_Right(t->Right(), visit); // пройти правое поддерево PostOrder_Right<t->Left(), visit); // пройти левое поддерево visit(t->data); // обработать узел } } 11.22 template <class T> TreeNode<T> *Max(TreeNode<T> *t) { while (t->Right() != NULL) t » t->Right(); return t; ) 11.26 template <class T> int NodeLevel(TreeNode<T> *t, const T& elem) { int level = -1; while (t != NULL) { level++; if (t->data =■» elem) break; else if (elem < t-> data) t » t->Left(); else t « t->Right(); }
if (t == NULL) level = -1; return level; } Глава 12 12.2 BASE DERIVED КЛИЕНТ Base Priv X Base Prot X X Base Pub X X Derived Priv X Derived Prot X Derived _Pub x 1 X 12.3 а) Конструктор производного класса не вызывает конструктор базового класса. 12.4 а) DerivedCL::DerivedCL(int a, int b, int с): data3(a), BaseCL(b,c){} б) DerivedCL::DerivedCL(int a): data3(a), BaseCL() {} в) datal data2 data3 objl 2 0 1 obj2 4 5 3 obj3 0 0 8 12.5 Вызван конструктор Basel. Вызван конструктор Base2. Вызван конструктор Derived. Вызван конструктор Basel. Вызван конструктор Base2. Вызван деструктор Base2. Вызван деструктор Basel. Вызван деструктор Derived. Вызван деструктор Base2. Вызван деструктор Basel. 12.7 а) GetX — открытый метод класса Shape. б) Так как х является защищенным элементом данных в классе Shape, к нему можно обращаться из производных классов, но не из программы-клиента. 12.8 <строка 1> 1 <строка 2> Base Class <строка 3> 2 <строка 4> 1 <строка 5> 0 <строка 6> Base 12.11 <строка 1> 7 <строка 2> 2 <строка 3> 3 5 <строка 4> 2 4 <строка 5> 2 4 <строка 6> 0 1 <строка 7> 7 <строка 8> 2 3 12.13 template <class T> class StackBase { protected: int numElements; public: StackBase(void): numElements(0) {} virtual void Push(const T& item) = 0;
virtual T Pop(void) - 0; virtual T Peek(void) = 0; virtual int StackEmpty(void) { return numElements == 0 } }; Реализуйте производный класс Stack с помощью массива (гл. 5) или связанного списка (гл. 9). 12.19 int LookForMatch(Array<int>& A, int end, int elem) { ArrayIterator<int> aiter(A, 0, end-1); while (!aiter.EndOfList()) { if (aiter.DataO == elem) return 1; aiter.Next(); } return 0; } void RemoveDuplicates(Array<int>& A) { ArrayIterator<int> assign(A), march(A); int assignlndex; if (A.ListSizeO <= 1) return; assign.Next(); march.Next(); assignlndex = 1; while (march.EndOfList()) { if (!LookForMatch(A, assignlndex, march.Data())) ( assign.Data() = march.Data90; assign.Next(); assignlndex++; } march.Next(); } A.Resize(assignlndex); } 12.20 template <class T> int Max(Iterator<T>& colllter, T& maxval) // перейти к первому элементу { colllter.Reset(); // если это конец списка, то список пуст if (colllter.EndOfList()) return 0; // взять первый элемент списка и начать сравнения maxval *= colllter .Data () ; for(colllter.Next(); !colllter.EndOfList(); colllter.Next()) if (colllter.Data() > maxval) maxval = colllter.Data(); return 1; // успешный выход } Глава 13 13.1 Дерево (В): 60 30 80 65 40 5 50 10 90 15 70 13.3 template <class T> void Preorder (T A[], int currindex, int n, void visit(T& item) { if (currindex < n) {
visit (A[currindex]); // обработать узел Preorder(A, 2*currindex+l, n, visit); // обход левого поддерева Preorder(A, 2*currindex+2, n, visit); // обход правого поддерева } } 13.4 а) Да; в) А[24]; д) Да А[34] 13.6 Последний уровень имеет 2П элементов. Число нелистовых узлов равно 1 + 2 + 4 + ... + 2nl « 2*n - 1. 13.7 а) 5; г) 41 и 42; е) 15—30 13.10 Дерево (Ь) является минимальной пирамидой, а дерево (f) — максимальной. 13.13 Начальное состояние пирамиды А: 5 10 20 25 50 Вставить 15: 5 10 15 25 50 20 Вставить 35: 10 15 25 50 20 35 Удалить 5: 10 25 15 35 50 20 Вставить 40: 10 25 15 35 50 20 40 Вставить 10: 10 10 15 25 50 20 40 35 13.15 а) 47 45 40 10; в) 35 40 45 13.16 б) 3, 6, 33, 88, 16, 45, 45, 90; в) "aehpify" 13.21 Для графа (В) Матрица смежности А В С D Е А 0 0 0 0 1 В 0 0 0 0 0 С 1 1 0 0 1 D 1 1 0 0 1 Е 0 0 0 0 0 Представление в виде списков смежности А: С D В: С D С: D: Е: А С D 13.22 б) Пути нет; г) Из А существует путь в С и D. 13.23 б) Прохождение "сначала в глубину": А Е D С; прохождение "сначала в ширину" : А С D Е.
13.26 Проходит граф методом "сначала в ширину", распечатывая попутно каждую вершину. Глава 14 14.1 а) Проход 0: 14892174 Проход 1: 11892474 Проход 2: 11298474 Проход 3: 11248974 Проход 4: 11244978 Проход 5: 11244798 Проход 6: 11244789 14.2 Проход 0: 48192174 Проход 1: 14892174 Проход 2: 14892174 Проход 3: 12489174 Проход 4: 11248974 Проход 5: 11247894 Проход 6: 11244789 14.7 Когда список уже отсортирован по возрастанию или по убыванию, алгоритм имеет порядок 0(п2). 14.13 Если г = х % m и q — частное от х/т, то х = mq + г и г = х - mq. Поскольку m четно, то при четном х значение хеш-функции четно. Все четные ключи хешируются в четные табличные индексы. Хеш- коды недостаточно рассеяны по таблице. 14.14 01 2 3 4 5 6789 10 а) НТ 391 Пусто Пусто 257 567 575 987 189 122 734 313 НТ 5) 012345678910 NULL NULL NULL j NULL | i NULL . NULL NULL 14.19 6) hashf(16) = 1; hashf(257) - 16
14.21 Если хеш-функция хорошая, вероятность хеширования в ячейку i равна 1/N. Когда ключ занял i-ю ячейку таблицы, вероятность заполнения ячейки i+1 равна 2/N. Вероятность заполнения ячейки i+2 равна 3/N. Скопление происходит из-за того, что вероятность попадания в группу смежных ячеек становится больше, чем вероятность попадания в одиночную ячейку таблицы. 14.22 а) Покажем, что если хеширование происходит в индекс i, то последовательное рехеширование снова приводит нас к этому индексу. Пусть i = (i+3k) % 93 для некоторого к. Это значит, что i+3k = 93q+i для некоторого q и, следовательно, 3k = 93q. Минимальным решением этого уравнения являются к=31 и q=l. После 31-й итерации функция рехеширования снова возвращается к i, т.е. покрывает только 1/3 таблицы. б) Если d < m, to d и т — взаимно простые и вся таблица покрывается функцией рехеширования. 14.24 14.25 а) Да; б) int H(char *s) {return (s[2] - 'a') % 7;} 14.26 void PascalTextFile::Assign(char *filename) {strcpy(fname, filename);} void PascalTextFile::Reset(void); { if (isOpen) f.seek(0, ios::beg); else { accesstype * IN; f.open(fname, ios::in | ios:mocreate); if (f !- NULL) isOpen++; else Error("Невозможно открыть файл"); } } void PascalTextFile::PWrite(char A[], int n) { if (accesstype == IN) Error ("Недопустимая операция доступа к файлу"); if (!isOpen) Error ("Файл закрыт"); f.write(A,n); }
Список литературы 1. Адельсон-Вельский Г. М., Ландис Е. М. Один алгоритм организации информации. — Доклады АН СССР. Серия математическая, т. 146, 1962, N 2 — с. 263—266. 2. Aho А. V., Hopcroft J. E., Ullman J. D. Data structures and algorithms. — Reading, MA: Addison-Wesley, 1983. 3. Baase S. Computer algorithms (2nd ed.). — Reading, MA: Addison-Wesley, 1988. 4. Bar-David T. Object oriented design for C++. — Englewood Cliffs, NJ: Prentice Hall, 1993. 5. Booch G. Object oriented design. — Redwood City, CA: Benjamin/Cum- mings, 1991. 6. Budd T. A. Classical data structures in C++. — Reading, MA: Addison- Wesley, 1994. 7. Carrano F. M. Data abstraction and problem solving with C++, walls and mirrors — Redwood City, CA: Benjamin/Cummings, 1995. 8. Collins W. J. Data stuctures, an object-oriented approach. — Reading, MA: Addison-Wesley, 1992. 9. Dale N., Lilly S. C. Pascal plus data structure, algorithms and advanced programming (3rd ed,). — Lexington, MA: D. C. Heath, 1991. 10. Decker R., Hirshfield S. Working classes, data structures and algorithms using C++. — Boston, MA: PWS, 1996. 11. Ellis M. A., Stroustrup B. The annotated C++ reference manual. — Reading, MA: Addison-Wesley, 1992. 12. Flaming B. Practical data structures in C++. — New York: Wiley, 1993. 13. Flaming B. Turbo C++: Step-by-step. — New York: Wiley, 1993. 14. Headington M. R., Riley D. D. Data abstraction and structures using C++. — Lexington, MA: D. C. Heath, 1994. 15. Horowitz E., Sahni S., Mehta D. Fundamentals of data structures in C++. — New York, W. H. Freeman, 1995. 16. Horstman C. S. Mastering object-oriented design in C++. — New York: Wiley, 1995. 17. Knuth D. E. The art of computer programming, vol. 1: Fundamental algorithms (2nd ed.). — Reading, MA: Addison-Wesley, 1973. 18. Knuth D. E. The art of computer programming, vol. 2: Seminumerical algorithms (2nd ed.). — Reading, MA: Addison-Wesley, 1973. 19. Knuth D. E. The art of computer programming, vol. 3: Sorting and searching (2nd ed.). — Reading, MA: Addison-Wesley, 1973. 20. Kruse R. L. Data structures and program design. — Englewood Cliffs, NJ: Prentice Hall, 1994. 21. Lewis T. G. Smith M. Z. Applying data structures (2nd ed.). — Boston: Houghton-Miffin, 1982. 22. Martin R. Designing object-oriented C++ applications using the Booch method. — Englewood Cliffs, NJ: Prentice Hall, 1995.
23. Model M. Data structures, data abstraction. — Englewood Cliffs, NJ: Prentice Hall, 1994. 24. Murray R. B. C++ strategies and tactics. — Reading, MA: Addison- Wesley, 1993. 25. Naps T. L. Introduction to data structures and algorithm analysis. — St. Paul, MN: West, 1992. 26. Pohl I. Object-oriented programming in C++. — Redwood City, CA: Ben- jamin/Cummings, 1993. 27. Pothering G. J., Naps T. L. Introduction to data structures and algorithm analysis with C++. — St. Paul, MN: West, 1995. 28. Schildt H. C++: The complete reference. — Berkeley, CA: Osborne McGraw-Hill, 1991. 29. Sedgewich R. Algorithms in C++. — Reading, MA: Addison-Wesley, 1992. 30. Standish T. A. Data structures, algorithms, and software principles. — Reading, MA: Addison-Wesley, 1994. 31. Stroustrup B. The C++ programming language (2nd ed.). — Reading, MA: Addison-Wesley, 1991. 32. Stubbs D. F., Webre N. W. Data structures with abstract data types and С — Pacific Grove, С A: Brooks/Cole, 1989. 33. Tenenbaum A. M., Langsam Y., Augenstein M. J. Data structures using С — Englewood Cliffs, NJ: Prentice Hall, 1990. 34. Weiss M. A. Data structures and algorithms analysis in C++. — Redwood City, CA: Benjamin/Cummings, 1994. 35. Winder R. Developing C++ software. — New York: Wiley, 1991. 36. Wirth N. Algorithms + data structures = programs. — Englewood Cliffs, NJ: Prentice Hall, 1976. 37. Wirth N. Algorithms and data structures. — Englewood Cliffs, NJ: Prentice Hall, 1986.
Предметный указатель А абстрактный базовый класс abstract base class 48,559 - класс - class 48,541,560 - списковый класс - list class 560-563 - тип данных - data type 2 абстракция элемента управления control abstraction 540 адрес, память address, memory 57 активизирующая запись activation record 443 алгебраическое выражение algebraic expression 193 инфиксное infix 193 постфиксное postfix 193 префиксное prefix 537 алгоритм Уоршалла Warshall algorithm 666 алгоритмы деревьев tree algorithms 489-503 вычисление глубины computing the depth 492 вычисление количества листовых counting leaf nodes 492 узлов горизонтальная печать дерева horizontal tree printing 493 копирование дерева copying a tree 495 обратное сканирование postorder scan 490 поперечное сканирование breadth-first csan 500 по уровневое сканирование level-order scan 500 симметричное сканирование inorder scan 489 удаление дерева deleting a tree 498-499 алгоритмы сортировки sorting algorithms "быстрая" quiksort 690 treesort-сортировка treesort 646 вставками insertion sort 688 двусвязного списка doubly linked list sort 408 методом пузырька bubble sort 686 обменная exchange sort 85 поразрядная radix sort 209 сортировка посредством выбора selection sort 684 со связанными списками linked list sort 369 турнирная tournament sort 602 анализ наилучшего случая best case analysis 157 - наихудшего случая (алгоритма) worst case analysis 157 - сложности (алгоритма) complexity analysis 155-159 алгоритм Уоршалла Warshall algorithm 667 поиск "сначала в ширину" breadth-first search 659 сравнение 0(n log2n)-copTHpoeoK compare 0(n log2n) sorts 697 "быстрая сортировка" quicksort 695 - - AVL-дерево - - AVL tree 628 алгоритм сопоставления с образцом pattern matching algorithm 325 бинарное дерево поиска binary search tree 504 бинарный поиск search 166 законченное бинарное дерево complete binary tree 482 обменная сортировка exchange sort 155 операции с очередью queue operations 206 операции с очередью приоритетов priority queue operations 217
операции со стеком stack operations 189 пирамидальная сортировка heapsort 619 поиск "сначала в глубину" depth-first search 659 поразрядная сортировка radix sort 212 последовательный поиск sequential search 161 сортировка включением в дерево tree sort 646 сортировка вставками isertion sort 689 сортировка методом пузырька bubble sort 688 сортировка посредством выбора selection sort 686 сортировка прямым слиянием straight merge sort 728 сортировка со связанными списками linked list sort 370 сравнение 0(п )-сортировок compare 0(n ) sorts 696 турнирная сортировка tounament sort 604 хеширование hashing 714 числа Фибоначчи Fibonacci numbers 466-468 ассоциативность операций associativity of operators 278 ассоциативные массивы associative arrays 151 Б базовый класс base class 31 байт byte 57 бинарное дерево binary tree 479-482 вертикальная печать upright (vertical) tree printing 500 вырожденное degenerate 481 горизонтальная печать — horizontal tree printing 493 законченное complete 482 класс TreeNode TreeNode class 483 копирование дерева copy a tree 495 левый-правый сын left-right child 481 описание description 479 определение definition 479 полное full 482 поперечное сканирование breadth first scan 500 построение building 485 по уровневое сканирование level scan 500 симметричный метод прохождения inorder traversal 489 (порядок) - - LNR, LRN, etc. 489 структура узла node structure 483 удаление дерева deleting a tree 498 бинарные деревья, представляемые array-based binary trees 600-602 массивами бинарный оператор binary operator 193 - поиск - search 503 неформальный анализ informal analysis 166 рекурсивная форма recursive form 443-445 сравнение последовательного и compare sequential 164 бинарного методов формальный анализ formal analysis 166 биномиальные коэффициенты Binomial coefficients 472 бит bit 57 битовые операции bit operations 327-328 и and 327 исключающее или exclusive or 327 не not 327 или or 327 блок (метод цепочек при хешировании) bucket 705 буферизация печати print spooler 394-400 быстрая сортировка quicksort 690
в верхняя треугольная матрица upper triangular matrix 120 вершина графа vertex of graph 647 - стека top of stack 182 вещественное число real number - - ADT - - ADT 60 мантисса mantissa 60 научный формат scientific notation 60 определение definition 60 порядок (экспонента) exponent 60 представление representation 60 вещественные типы данных - data types 60 взвешенный орграф weighted digraph 649 виртуальная функция virtual function 550-552 деструктор destructor 558 и полиморфизм and polymorphism 550 описание description 49-50,541 таблица table 552 чистая pure 541, 559 внешние структуры данных external data structures 77 внешний файловый поиск - file search 723 внутренние структуры данных internal data structures 77 возврат (прохождение лабиринта) backtracking 436 возможности программного program design features конструирования объектная разработка object design 38 сквозной стуктуированный structured walkthrough 45 контроль структурное дерево structure tree 39 тестирование объектов object testing 40,45 устойчивость к ошибкам robustness 46 вращение AVL-дерева rotation in AVL tree двойное double 639 единичное single 638 входной приоритет input precedence 280 вызов по значению call by value 115 - по ссылке - reference 115 выражение expression - вычисление (оценка) - evaluation 193 - деревья - trees 438 Г глубина дерева depth of a tree 480 голова связанного списка head of a linked list 353, 358 граф graph 647 - ADT - ADT 649 - ациклический - acyclic 649 - вершины - vertices 647 - взвешенный орграф - weighted digraph 649 - матрица достижимости - reachability matrix 666 смежности - adjacency matrix 650 - направленный (орграф) - directed (digraph) 648 - ненаправленный - undirected 648 - приложение: сильные компоненты - application: strong components 659 - путь - path 648 - ребра - edges 647 - связанные вершины - connected vertices 648
- сильно связанный - strongly connected 648 - сильные компоненты - strong components 659 - слабо связанный - weakly connected 648 - транзитивное замыкание - transitive closure 667 - цикл - cycle 649 группа group 153 Д двоичные числа binary numbers 56 двоичный файл - file 715 двумерный массив two-dimensional array определение definition 68 хранение storage 69 двусвязный список doubly linked list 410 приложение: сортировка вставками application: insertion sort 406 дерево tree 479-480 - бинарного поиска binary search tree 503-507 ADT ADT 508 вставка узла inserting a node 517 класс BinStree BinStree class 508 ключ key 505 описание description 503 приложение: конкорданс application: concordance 525 симметричное прохождение inorder traversal (sort) 511 (сортировка) счетчики появлений application: occurrence counts 513 удаление узла deleting a node 519 - бинарное дерево - binary tree 480 - высота, см. глубина - height, see depth - глубина - depth 480 - корень - root 479 - левый сын - left child 481 - лист - leaf 479 - описание - description 479 - определение - definition 479 - поддерево - subtree 479 - правый сын - right child 479 - предки-потомки - ancestors-descendents 479 - путь - path 479 - сын-родитель - children-parent 479 - терминология - terminology 479 - тип коллекции - collection type 152 - узел - node 483 - уровень - level 480 деструктор destructor 295-296, 291 динамический dynamic 408 - выделение массива - array allocation 292 - массив - array 147 - объект - object 293-297 - память - memory 64 - связывание - binding 49 - структуры данных - data structures 291 дискретный тип discrete type 60 длинная последовательность long run 577 сортировка слиянием merge sort 729 доступ, см. прямой доступ, access, see direct access, sequential последовательный доступ access дружественные функции friend functions 244-245
3 заголовочный узел header node 401 задача Джозефуса Josephus problem 403 - о комитетах committee problem 448-450 задняя рекурсия tail recursion 469 закрытое наследование private inheritance 587 запись (как набор данных) record ADT - ADT 77 определение - definition 76 защищенные методы protected members 544 знаковый бит sign bit 57 И изменение состояния state change 25 индекс, массив index, array 65 инициализатор, см. конструктор initializer, see constructor инкапсуляция encapsulation 24 инфиксный infix - вычисление выражения - expression evaluation 277-285 - формат - notation 193 итератор iterator 563 итераторы дерева tree iterators 642 К каркас разработки design framework 39 квадратичное время (0(n2)) quadratic time (0(n )) 160 квадратная матрица square matrix 120 класс class - (в книге): Queue (связанный список) - (in book): Queue (linked list) 388 Animal Animal 553-555 Array Array 303 Calculator Calculator 195 Circle (производный) Circle (derived) 548 CNode CNode 401 Date Date 118 Dice Dice 40 DNode DNode 410 DynamicClass DynamicClass 293 Event Event 223 Heap Heap 612 Iterator (абстрактный) Iterator (abstract class) 564 Line Line 30 LinkedList LinkedList 374 List (абстрактный) List (abstract) 560 MathOperator MathOperator 281 Maze Maze 462 Node Node 353 NodeShape NodeShape 582 OrderedList OrderedList 36 Point Point 29 PQueue PQueue 214 RandomNumber RandomNumber 111-112 Rational Rational 247 Rectangle Rectangle 101 SeqList SeqList 36,168 SeqList (производный) SeqList (derivde) 561 SeqList (связанный список) SeqList (linked list) 391
Shape Shape 546 Simulation Simulation 225 Spooler (для печати) Spooler (print) 396 Stack Stack 184 Stack (шаблонный) Stack (template) 276 String String 310 Temperature Temperature 108 TriMat TriMat 12-129 Vec2d Vec2d 243 Window Window 412 - Array Array class деструктор destructor 305 конструктор constructor 305 конструктор копирования copy constructor 305-306 метод Resize Resize method 308-309 объявление declaration 303 оператор индексации index operator 306 преобразования указателя pointer conversion operator 307-309 - Arraylterator Arraylterator class 574 - AVLTree AVLTree class 631-641 метод UpdateLeftTree UpdateLeftTree method 637 AVLInsert Avllnsert method 636 DoubleRotation DoubleRotation method 639 GetAVLTreeNode - - GetAVLTreeNode method 633 Insert Insert method 634 объявление declaration 631 - AVLTreeNode AVLTreeNode class 629-631 конструктор constructor 631 метод Left Left method 631 объявление declaration 629 реализация implementation 631 - Binfile BinFile class 718 конструктор constructor 721 - метод Clear Clear method 719 EndFile - - EndFile method 719 Peek - - Peek method 719 Read Read method 721 (блочный) (block) method 721 Write - - Write method 722 (блочный) (block) method 722 объявление declaration 718 реализация implementation 721 - BinSTree BinSTree class 508-510, 515-524 конструктор constructor 515 метод Delete Delete method 523 Find - - Find method 516 FindNode - - FindNode method 516 Insert Insert method 517 Update Update method 524 объявление declaration 510 оператор присваивания assignment operator 515 реализация implementation 515 управление памятью memory management 515 - Calculator Calculator class 202 метод Run Run method 197 Compute Compute method 196 объявление declaration 195 реализация implementation 196
- Circle Circle class 548 объявление declaration 548 описание description 548 реализация implementation 548 - CNode CNode class DeleteAfter метод DeleteAfter method 403 InsertAfter метод InsertAfter method 403 конструктор constructor 402 объявление declaration 401 реализация implementation 402 - Date Date class 118 - Dice - class 41 - Dictionary Dictionary class 737 конструктор constructor 741 метод DeleteKey DeleteKey method 742 InDictionary InDictionary method 742 объявление declaration 737 реализация implementation 741 - DNode DNode class 407 метод DeleteNode DeleteNode method 411 реализация implementation 410 конструктор constructor 410 метод InsertLeft InsertLeft method 411 InsertRight InsertRight method 411 - DynamicClass DynamicClass class деструктор destructor 295 конструктор constructor 293-294 конструктор копирования copy constructor 300 оператор присваивания assignment operator 297 - Event Event class 223 - Graph Graph class 652 конструктор constructor 653 метод DeleteVertex DeleteVertex method 655 GetNeighbors GetNeighbors method 654 GetVertexPos GetVertexPos method 654 GetWeight - - GetWeight method 654 InsertEdge InsertEdge method 654 Vertexlterator Vertexlterator class 654 минимальный путь minimum path 661 объявление declaration 652 прохождение "сначала в глубину" depth-first graph traversal 656 "сначала в ширину" (метод) breadth-first traversal 656 реализация implementation 653 - HashTable HashTable class 707 метод Find Find method 712 Insert Insert method 711 объявление declaration 707 реализация implementation 711 - HashTablelterator HashTablelterator class 711 конструктор constructor 713 метод Next Next method 714 SearchNextNode SearchNextNode method 713 объявление declaration 708 реализация implementation 712 - Heap Heap class 609 конструктор constructor 618 метод Delete Delete method 615 FilterDown FilterDown method 615 FilterUp - - FilterUp method 613
Insert Insert method 614 объявление declaration 609 пирамидальная сортировка heapsort 618 реализация implementation 612 - ifstream if stream class 80 - Inorderlterator Inorderlterator class конструктор constructor 644 метод Next Next method 645 объявление declaration 643 реализация implamentation 644 - Ios Ios class 80 - Istream Istream class 80 - Istrtream Istrtream class 81 - KeyValue KeyValue class 736 - Line Line class 30 - LinkedList LinkedList class 371-376, 381-388 конструктор constructor 382 метод InsertAt InsertAt method 385 ClearList - - ClearList method 383 CopyList CopyList method 383 Data Data method 385 DeleteAt - - DeleteAt method 387 Next - - Next method 384 Reset Reset method 384 методы выделения памяти memory allocation methods 382 объявление declaration 374 описание операций describing operations 372-374 приложение: конкатенированные application: concatenating lists 377 списки сортировка выбором selection sort 378 удаление дубликатов removing duplicates 379 проектирование списка designing the class 371 реализация implementation 381 указатель на первый узел front pointer 371 на последний узел rear pointer 371 на предыдущий узел previous pointer (prevPtr) 371 на текущий узел current pointer (currPtr) 371 - List (абстрактный) List class (abstract) 560-563 объявление declaration 561 реализация implementation 561 - MathOperator MathOperator class конструктор constructor 281 метод Evaluate Evaluate method 282 объявление declaration 281 оператор сравнения comparison operator 282 - Maze Maze class объявление declaration 462 реализация imlementation 463 - Node Node class 353-358 конструктор constructor 356 метод Delete After DeleteAfter method 357 InsertAfter InsertAfter method 357 NextNode NextNode method 356 объявление declaration 355 реализация implementation 356 - NodeShape NodeShape class 582 - ofstream ofstream class 80 - OperandList OperandList class 35
метод Insert Insert method 576 объявление declaration 576 реализация implementation 576 - Ostream Ostream class 80 - Ostrstream Ostrstream class 81 - Point Point class 29-30 - Pqueue PQueue class 214-217 метод Pqdelete Pqdelete 216 Pqinsert Pqinsert 215 объявление declaration 214 (пирамидальная версия) (heap version) 622 реализация implementation 215 - Queue (массив) Queue class (array) 199 конструктор constructor 204 метод Qdelete Qdelete method 206 Qinsert Qinsert method 205 объявление declaration 201 реализация implementation 202 (связанный список) (linked linked) 389-392 объявление declaration 389 реализация implementation 390 - RandomNumber RandomNumber class 110-114 конструктор constructor 111-112 метод fRandom fRandom method 112 Random Random method 112 объявление declaration 110 реализация implementation 112 - Rational Rational class 247-258 метод Reduce Reduce method 255 объявление declaration 247 операторы (как дружественные) operators (as friends) 247 (как члены) (as members) 249 (преобразование типа) (type conversion) 252 потоковые операторы stream operators 248 - Rectangle Rectangle class 101-107 - SeqList (массив) SeqList class (array) 168-175 метод Delete Delete method 170 Find Find method 171 GetData GetData method 170 Insert Insert method 169 объявление declaration 168 реализация implementation 168 (производный) (derived) 561-563 объявление declaration 562 реализация implementation 562 (связанный список) (linked list) 391-392 объявление declaration 391 приложение: сравнение application: efficiency 392-394 эффективности comparison реализация implementation 392 - SeqListlterator SeqListlterator class объявление declaration 565 реализация implementation 565 - Shape Shape class - объявление declaration 547 описание description 546 реализация implementation 547 - Simulation Simulation class конструктор constructor 226
метод NextArrvalTime NextArrvalTime method 227 объявление declaration 225 - Spooler (печать) Spooler (print) class объявление declaration 396 реализация implementation 397 - Stack Stack class 184-189 конструктор constructor 187 метод ClearStack ClearStack method 188 Peek - - Peek method 188 Pop - - Pop method 187 Push - - Push method 187 StackEmpty StackEmpty method 188 StackFull - - StackFull method 188 объявление declaration 186 реализация implementation 187 - String String class 310-320 ввод/вывод I/O 319 - - FindLast - - FindLast 319 конкатенация concatenation 314 конструктор constructor 315 метод ReadString ReadString method 319 Substr - - Substr method 318 объявление declaration 311 оператор присваивания assignment operator 316 приложение: тестовая программа application: test program 314 реализация implementation 315 сравнение строк string comparison 316 - Temperature Temperature class 108 - TreeNode TreeNode class 484 конструктор constructor 485 метод FreeTreeNode FreeTreeNode method 486 GetTreeNode - - GetTreeNode method 486 объявление declaration 484 - TriMat TriMat class 124-129 - Vec2d Vec2d class 243 объявление declaration 244 операторы (как дружественные) operators (as friends) 243 (как члены класса) (as members) 262 скалярное произведение scalar multiplication - Window Window class объявление declaration 413 реализация implementation 415 - заголовок class: head 100 - закрытая часть - private part 100 - защищенная часть - protected part 100 - иерархия - hierarchy 542 - инкапсуляция - encapsulation 24 - конструктор - constuctor 101 - методы - methods 24,100 - наследование - inheritance 31 - объект - object 100 - объявление - declaration 25 - оператор разрешения области действия - scope resolution operator 103 - открытая часть - puplic part 100 - реализация - imlementation 25Д02 - скрытие информации - information hiding 24 - список инициализации - member initialization list 103 - члена класса - members 24,100 - тело - body 100
классы животных animal classes 553-555 - итераторов iterator classes - Arraylterator Arraylterator 569 Inorderlterator Inorderlterator 643 SeqListlterator SeqListlterator 565 Vertexlterator Vertexlterator 652 ключ key 82 коллизия collision - определение - definition 704 - разрешение - resolution 704 комбинаторика combinatories 437 композиция объектов composition of objects 28-30 компонента постусловий ADT ADT Postconditions component 21 - предусловий ADT - Preconditions component 21 - процесса ADT - Process component 21 конкатенация списков concatenating lists 377 конкорданс concordance 525-529 конструирование функций узлов дерева tree node function desing 486 конструктор constructor 21 - копирования copy constructor 291,300- 302 - умолчания default constructor 117 корень дерева root of tree 152,479 косвенная рекурсия inderact recursion 434 коэффициент заполнения load factor 714 кубическое время (0(n )) cubic time (0(n )) 160 Л линейная коллекция linear collection 144-152 линейное время - time (o(n)) 160 линейный - - последовательный список - sequential list 148 листовой узел leaf node 479 логарифмическое время (0(log2n), logarithmic time (0(log2n), 0(nlog2n)) 160 0(nlog2n)) M максимальная пирамида maximum heap 608 массив: ADT Array ADT 65 - границы - index bounds 67 - дескриптор - dope vector 67 - проверка границ - bounds checking 303-305 - тип - type 65-71 коллекции - collection type 147 - хранение - storage 66 матрица matrix 120 - достижимости reachability matrix 666 - коэффициентов coefficient matrix 132 - смежности adjacency matrix 650 метод Peek (стековый) Peek (stack) method 188 - вставки insertion method - - BinFile (Write) - - BinFile (Write) 722 - - HastTable (Insert) - - HastTable (Insert) 711 LinkedList (InsertRear) LinkedList (InsertRear) 375 Queue (Qlnsert) Queue (Qlnsert) 205 - - String (Insert) - - String (Insert) 312 - - AVLTree (AVLInsert) - - AVLTree (AVLInsert) 636 (Insert) (Insert) 634
- - BinSTree (Insert) - - BinSTree (Insert) 518 - - Graph (InsertEdge) - - Graph (InsertEdge) 652 (InsertVertex) (InsertVertex) 652 - Heap (Insert) Heap (Insert) 614 - - LinkedList (InsertAfter) - - LinkedList (InsertAfter) 375 (InsertAt) (InsertAt) 385 (InsertFront) (InsertFront) 375 OrderedList (Insert) OrderedList (Insert) 575 - - PQueue (PQInsert) - - PQueue (PQInsert) 215 - - SeqList (Insert) - - SeqList (Insert) 169 - - Set (Insert) - - Set (Insert) 366 - - Stack (Push) - - Stack (Push) 187 String (operator+) String (operator+) 312,317 - деления (хеширование) division method (hashing) 702 - середины квадрата (хеширование) midsquare technique (hashing) 704 - удаления deletion method - - BinSTree (Delete) - - BinSTree (Delete) 523 Dictionary (DeleteKey) Dictionary (DeleteKey) 742 - - Graph (DeleteEdge) - - Graph (DeleteEdge) 652 - - SeqList (Delete) - - SeqList (Delete) 170 - - Set (Delete) - - Set (Delete) 336 - - Stack (Pop) - - Stack (Pop) 187 Graph (DeleteVertex) Graph (DeleteVertex) 655 - - HashTable (Delete) - - HashTable (Delete) 707 - - Heap (Delete) - - Heap (Delete) 615 - - LinkedList (DeleteAt) - - LinkedList (DeleteAt) 387 (DeleteFont) (DeleteFont) 375 PQueue (PQDelete) - - PQueue (PQDelete) 216 - - Queue (QDelete) Queue (QDelete) 206 - цепочек (хеширование) chaining with separate lists 704 методы класса method in a class 24 - поиска retrieval methods - - BinFile (block Read) - - BinFile (block Read) 721 (Read) (Read) 721 - - BinSTree (Find) - - BinSTree (Find) 515 - - NasTable (Find) - - NasTable (Find) 712 Queue (Qfront) - - Queue (Qfront) 201 - - SeqList (Find) - - SeqList (Find) 171 - - Set (IsMember) - - Set (IsMember) 335 - - Stack (Peek) - - Stack (Peek) 188 - - String (Find) - - String (Find) 312 (FindLast) (FindLast) 319 (Substr) (Substr) 317 методы прохождения дерева tree traversals 489 минимальная пирамида minimum heap 608 минимальный путь - path 661 множественное наследование multiple inheritance 37 множественные конструкторы - constructors 117 множество set - Set (integral type) Set class (integral type) 334 operator+ (объединение) operator+ (union) 335 конструктор constructor 329 метод Delete Delete method 330 Insert Insert method 335 IsMember IsMember method 335 объявление declaration 336 описание description 336 потоковые операторы I/O stream operatos 334 ввода/вывода
реализация implementation 336 - класс Set (модель массива) set: Set class (array model) 263 (целочисленный тип) (integral type) 328 - описание (целочисленный тип) - description (integral type) 325-327,329 - решето Эратосфена - application: Sieve of Eratosthenes 332 - тип коллекции - collection type 154 моделирование simulation 220-232 - событие прихода - arrival event 223 ухода - departure event 223 мультипликативный метод (хеширование) multiplicative method (hashing) 704 H набор символов кода ASCII ASCII character set 59 надежные массивы safe arrays 303 наибольший общий делитель GCD (Greatest Common Devisor) 254 направленный граф directed graph 648 наследование inheritance - абстрактный класс - abstract class 541 - базовый класс - base class 542 - виртуальная функция - virtual function 541 функция-член member function 550 - динамическое связывание - dynamic binding 550 - защищенные члены (класса) - protected members 544 - иерархия класса - class hierarchy 542 - концепция - concept 28 - множественное (наследование) - multiple 37 - определение - definition 540 - открытое наследование - public inheritance 543 - подкласс - subclass 543 - полиморфизм - polymorphism 550 - приложение: геометрические фигуры - application: geometric figures 556 - производный класс - derived class 542 - статическое связывание - static binding 551 - суперкласс - superclass 543 - чистая виртуальная функция - pure virtual function 541 начало связанного списка front of linked list 353 нелинейная коллекция nonlinear collection 144,152-155 ненаправленный граф undirected graph 648 нисходящая программная разработка top-down program disign 38 нотация Big-0 Big-0 notation 156 О обменная сортировка exchange sort 85-86 обратная польская запись Reverse Polish notation 193 обратный метод прохождения postorder traversal 490 объект object - как возвращаемое значение - as a return value 115 - как параметр функции - as a function parameter 115 - композиция - composition 28 - наследование - inheritance 28,31-32 - определение - definition 20 - тестирование - testing 45 объектно-ориентированное object-oriented programming программирование виртуальная функция virtual function 49 абстрактный базовый класс abstract base class 48 Бъярн Страуструп Stroustrup, Bjarne 47
динамическое связывание dynamic binding 550 композиция composition 28 множественное наследование multiple inhritance 37 наследование inheritance 28, 31 повторное использование кода reusability of software 35 полиморфизм polimorphism 550 построение программы program design 38-43,45-46 тестирование testing 45 чистая виртуальная функция pure virtual function 49 объекты и передача информации objects and information passing 115 объявление (класса) declaration, class 25 однородный homogeneous - массив - array 65 - список - list 166 оператор delete delete operator 64 описание description 290 определение definition 293 - new new operator описание description 290 определение definition 64 ошибка выделения памяти insufficient memory error 292 - адреса & address operator & 64 - индекса [] index operator [] 303,306 - преобразования типа type conversion operator к типу объекта to object type 252 из объектного типа from object type 253 - присваивания assignment operator 297 операция извлечения (из стека) pop operation метод Stack Stack method 187 описание description 149,182 - помещения в стек push operator метод Stack Stack method 187 описание description 149,182 - разрешения области действия scope resolution operator 103 определение AVL-дерева AVL tree: definition 627 орграф, см. направленный граф digraph, see directed graph открытая адресация с линейным linear probe open addressing 704 опробованием - секция класса public class section 24 открытое наследование - inheritance 543 отладчик на уровне кода source level debugger 46 очередь queue 198-206 - приоритетов priority queue 212-217 - - ADT - - ADT 213 класс Pqueue PQueue class 214 (пирамидальная версия) (heap version) 622 определение definition 212 приложение: длинные applicaion: long runs 622 последовательности моделирование, управляемое event-driven simulation 220 событиями сервисная поддержка support services 217 - ADT queue: ADT 199 - определение - definition 198 - приложение: партнеры по танцу - application: dance partners 206 поразрядная сортировка radix sort 209 - реализация - implementation 202 - тип коллекции - collection type 149
п палиндром palindrome 189 первым вошел-первым вышел first-in-first-out 199 перегрузка overloading - оператора operator overloading 241 потока stream operator overloading 251 - функции function overloading 258 - внешние функции overloading: external function 241 - дружественными функциями - with friend functions 244 - оператор индекса [ ] - index operator [ ] присваивания — - assignment operator e 299 - оператора - operator 241 - операторы потока - stream operators 250-252 преобразования - conversion operators 252-254 - функциями-членами класса - with class members 242 передача сообщения message passing 25 перестановки permutations 451-455 пирамида heap 607-612 - максимальная - maximum 608 - минимальная - minimum 608 - определение - definition 607 - порядок - order 607 пирамидальная сортировка heapsort 618 повторное использование кода reusability of software 35 поддерево subtree 479 поиск "сначала в ширину" breadth-first search 657 полиморфизм polimorfism 48-50 полное бинарное дерево full binary tree 482 поля записи fields of a record 76 поперечное сканирование level-order scan 500 поразрядная сортировка radix sort 209 последним пришел-первым вышел last-in-first-out 183 последовательный доступ sequential access массив array 65 - - файл - - file 77 - поиск - search 82,161-162 алгоритм algorithm 161 быстрый fast 287 сравнение с бинарным compare binary search 164 - список - list 166-175 - - ADT - - ADT 33 класс SeqList SeqList class 36,168,391 описание description 32,166 приложение: хранение application: video store 173 видео-фильмов тип коллекций collection type 146 постоянная единица времени (0(1)) constant time (0(1)) 160 постфиксный postfix - вычисление - evaluation 193 - форма представления - notation 193 поток cerr cerr stream 80 - stream (стандартный вывод) cout stream 80 - ввода cin stream 80 - описание stream, description 78, 150 потоки в C++ C++ streams 77-78 потоковый класс stream class f stream f stream 81 ifstream ifstream 80
ios ios 80 istream istream 80 ofstream ofstream 80 ostream ostream 80 - объект - object cerr (стандартный поток ошибок) cerr 80 cin (стандартный ввод) cin 80 cout (стандартный вывод) cout 80 предусловия, см. ADT precondition, See ADT приоритет операции precedence of perators входной приоритет input precedence 280 стековый приоритет stack precedence 280 производный класс derived class 31 прохождение "сначала в глубину" depth-first graph traversal 656 прошитые деревья threaded trees 535 прямой доступ direct access массив array 147 определение definition 146 - - файл - - file 715 путь в графе path in a graph 648 - в дереве - in a tree 479 P разделяй и властвуй (метод) divide and conquer 433 разнородный heterogeneous - массив - array 579 - список - list 579 - тип - type 77 разработка программного продукта software development методология methodology 38 повторное использование reusability of software 35 ранг rank 278 распределение памяти memory allocation динамическое dynamic 290,292 статическое static 290 рациональное число rational number 245-247 нормализованная форма standardized form 246 представление representation 245 приложение: решение лин. уравнений application: solving linear equations 256 редуцированная форма reduced form 245 ребро графа edge of graph 647 рекурсия recursion 432-439 - бинарный поиск - binary search 446 - биномиальные коэффициенты - binomial coefficients 472 - задача о комитетах - committee problem 448-450 - задняя рекурсия - tail recursion 469 - комбинаторика - combinatorics 438 - лабиринт - maze 436,460 - определение - recursive definition 433 - перестановки - permutations 451-455 - синтаксические деревья - expression trees 438 - стек времени исполнения - runtime stack 443-445 - степенная функция - power function 442 - треугольник Паскаля - Pascal's triangle 473 - факториал - factorial 439 - Ханойская башня - Tower of Hanoi 435,455 - числа Фибоначчи - Fibonacci numbers 466 - шаг рекурсии - recursive step 433 Решето Эратосфена Sieve of Eratosthenes 332
родитель parent 479 родительский узел - node 479 С самоссылающаяся структура self-referencing structure 335 сбалансированное дерево balanced (AVL) tree 628 связанный connected 647 - список linked list 358-361, 363-364 удаление узла deleting a node 364 введение introduction 350 вставка в начало inserting at front 358 голова head 358 описание description 351 приложение: буферизация печати application: print spooler 394 головоломка word jumble 362 список выпускников graduation 365 управление окнами window management 411 прохождение traversal 359 создание узла creating a node 358 упорядоченного списка an ordered list 367 сортировка с использованием sorting using ordered list 369 связанного списка удаление начального элемента deleting a front 363 элементов списка clearing a list 369 - вершины графа connected: graph vertices 647 - граф, сильно связанный strongly 648 слабо связанный weakly 648 связный список: вставка в хвост linked list: inserting at rear 361 печать списка printing a list 360 сеть network 155 сильно связанный граф strongly connected graph 648 символьный тип character type 58-60 симметричный метод прохождения inorder traversal 58 системная эффективность system efficiency 155 скрытие информации information hiding 24 слабо связанный граф weakly connected graph 648 слияние merge - сортировка прямым слиянием - straight merge sort 727 - сортированных последовательностей - sorted runs 570-574 словарь dictionary 735 - ассоциативная структура - association structure 735 - класс Dictionarylterator - Dictionarylterator class 738 - определение - definition 151 - приложение: построение толкового - application: word building 739 словаря случайное число random number seed-значение seed 110 генератор generator 110 класс RandomNumber RandomNumber class 110 событие прихода (событийное arrival event 223 моделирование) - ухода (событийное моделирование) departure event 223 сопоставление с образцом pattern matching 320 сортировка вставками insertion sort 688 - методом пузырька bubble sort 686 - на месте in-place sorting 212 - посредством выбора selection sort 684 - при помощи внешнего файла external file sort 727
спецификатор const (константы) const qualifier 27 список инициализации членов (класса) member initialization list 103 статическая: память static: memory 64 статические: структуры данных - data structures 290 статический static - массив - array 147 статическое: связывание - binding 551 стек stack 182-189 - времени исполнения runtime stack 443 - операнда operand stack 278 - операторов operator stack 279 - ADT stack: ADT 184 - описание - description 182 - определение - definition 182 - полный - full 183 - приложение: вывод (чисел) с - application: multibase output 191 различными основаниями вычисление выражения expression evaluation 193 инфиксное выражение, оценивание infix expression, evaluation 279 палиндром palindrome 189 постфиксный калькулятор — postfix calculator 195 - пустой - empty 183 - тип коллекции - collection type 149 степенная функция (рекурсивная форма) power function (recursive form) 442 строка в C++ String type (C++) 73 - ADT string ADT 72 - описание - description 71 строки в C++ C++ strings 73-74 T текстовый файл text file 77-80 типы данных (определяемые языком) data types (language-defined) - двоичный файл — binary file 80 двумерный массив two dimensional array 68 действительный real 60 запись record 77 массив array 65 перечисления enumerated 62 символьный character 58 строки C++ C++ strings 73 строчный string 72 структура C++ C++ struct 77 текстовый файл text file 79 указатель pointer 64 целочисленный integer 55 бинарное дерево binary tree 152 граф graph 154 запись (как данные) record 148 линейный список linear list 148 массив array 147 множество set 154 очередь queue 149 приоритетов priority queue 150 последовательный список sequential list 36 словарь dictionary 151 случайные числа random numbers 110 стек stack 149 строка string 147 - - файл - - file 150
хеш-таблица hash table 151 типы перечисления enumarated types 62-63 транзитивное замыкание transitive closure 667 треугольные матрицы trangular matrices 120-129 У узел node - ADT - ADT 354 - в дереве - in a tree 152 - в связанном списке - in linked list 351-352 - определение - definition 352 узел-часовой sentinel node 401 узлы AVL-деревьев AVL tree: nodes 628 указатель pointer - this this pointer 300 - ADT pointer: ADT 64 - операция преобразования - conversion operator 303 - определение - definition 63 унарный оператор unary operator 54,193 универсальное множество universal set 325 упорядоченный список ordered list 149 - - ADT - - ADT 34 алгоритм создания creation algorithm 367 приложение: длинные application: long runs 577 последовательности управление окнами window management 411-418 управляемое событиями моделирование, event-driven simulation, see simulation см. моделирование уровень дерева level in a tree 480 условие останова (цикла) stopping condition 433 Ф файл file 77-79 - потоковый метод seekp - seekp stream method 717 tellg - tellg stream method 717 - ADT - ADT 79 - внешнее хеширование - external hashing 725 - внешний поиск search 723 - двоичный файл - binary file 715 - класс BinFile - BinFile class 718 - последовательный - sequential 717 - потоковый метод seekg - seekg stream method 717 tellp - tellp stream method 717 - режим (записи/чтения) - mode 716 - сортировка прямым слиянием - straight merge sort 727 - текстовый файл - text file 80 - тип коллекции - collection type 150 - указатель - pointer 716 - файловый указатель - file pointer 78 фактор сбалансированности AVL-дерева AVL tree: balance factor 628 форма Бэкуса Наура Bakus-Naur form 434 функция (MakeSearchTree building a tree search (MakeSearchTree) 511 - доступа access function 67 к массиву array access function 67 - факториала (рекурсивная форма) factorial function (recursive form) 442 X хвост связанного списка rear of linked list 353
хеширование hashing 700 - класс HashTable - HashTable class 707 - коллизии - collisions 702 - метод деления - division metod 702 - свертки - folding method 744 середины квадрата - midsquare technique 704 цепочек - chaining with separate lists 704 - мультипликативный метод - multiplicative method 704 - описание - description 700 - открытая адресация с линейным - linear probe open addressing 704 опробованием - рехеширование - rehashing 745 - хеш-таблица - hash table 701 - хеш-функция — function 701 хеш-таблица hash table - анализ сложности complexity analysis 714 - итератор iterator 708 - определение definition 700 хеш-функция - function 701 Ц целочисленный тип integer types 54-57 цикл, в графе cycle, in a graph 649 циклический circular 400 - связанный список - linked list 400-403 класс Cnode Cnode class 400 приложение: Джозефус application: Josephus 403 4 числа Фибоначчи Fibonacci numbers 466 число без знака unsigned number 57 - с фиксированной точкой fixed point number 60 чистая виртуальная функция, см. pure virtual function, see virtual виртуальная функция function Ш шаблон template 270-275 - ключевое слово - keyword 270 - метод - method 274 - объект - object 274 - объявление класса - class declaration 274 - синтаксис - syntax 270 - класс Stack - Stack class 276-277 - список параметров - parameter list 270 - функция - function 270-273 шестнадцатеричный hexadecimal 90 Э экземпляр класса instance of a class 102 экспоненциальное время (0(2n)) exponential time (0(2n)) 160 эффективность использования памяти space efficiency 156 Я язык C++ C++ language 47 Бьярн Страуструп Stroustrup, Bjarne 47 историческое развитие historical development 47 определение -— definition 47
Index A abstract base class абстрактный базовый класс 48, 559 - class - класс 48,541,560 - data type - тип данных 2 - list class - списковый класс 560-563 access function функция доступа 67 access, see direct access, sequential доступ, см. прямой доступ, access последовательный доступ activation record активизирующая запись 443 address operator & оператор адреса & 64 - memory адрес, память 57 adjacency matrix матрица смежности 650 ADT Accumulator ADT Accumulator 133 - Array - Array 65 - Binary Search Tree - бинарного дерева 507 - Character - Character 58 - Definition - Definition 20 - Enumerated - Enumerated 62 - File - File 79 - Format - Format 21-22 - Graph - Graph 649 - Input component - Вход 21 - Integer - Integer 55 - Node - Node 354 - OrderedList - OrderedList 35 - Output component - Выход 21 - Pointer - Pointer 64 - Postconditions component компонента постусловий ADT 21 - PQueue ADT PQueue 213 - Preconditions component компонента предусловий ADT 21 - Process component - процесса ADT 21 - Queue ADT Queue 199 - Real - Real 60 - Record - Record 77 - SeqList - SeqList 33 - Stack - Stack 184 - String - String 72 algebraic expression алгебраическое выражение 193 infix инфиксное 193 postfix постфиксное 193 prefix префиксное 537 animal classes классы животных 553-555 array access function функция доступа к массиву 67 Array ADT массив: ADT 65 - class класс Array constructor конструктор 305 copy constructor копирования 305-306 declaration объявление 303 destructor деструктор 305 index operator оператор индексации 306 pointer conversion operator преобразования указателя 307-309
Resize method метод Resize 308-309 array: bounds checking массив: проверка границ 303-305 - collection type - тип коллекции 147 - dope vector - дескриптор 67 - index bounds - границы 67 - storage - хранение 66 - type - тип 65-71 array-based binary trees бинарные деревья, представляемые 600-602 массивами Arraylterator class класс Arraylterator 574 arrival event событие прихода (событийное 223 моделирован ие) ASCII character set набор символов кода ASCII 59 assignment operator оператор присваивания 297 associative arrays ассоциативные массивы 151 associativity of operators ассоциативность операций 278 AVL tree AVL-дерево 627 balance factor фактор сбалансированности AVL-дерева 628 compare with BinSTree AVL tree: сравнение с BinSTree 641 definition определение AVL-дерева 627 nodes узлы AVL-деревьев 628 AVLTree class класс AVLTree 631-641 SingleRotation method AVLTree class: метод SingleRotation 638 Avllnsert method класс AVLTree: метод AVLInsert 636 declaration объявление 631 DoubleRotation method метод DoubleRotation 639 - - GetAVLTreeNode method GetAVLTreeNode 633 Insert method Insert 634 - - UpdateLeftTree method UpdateLeftTree 637 AVLTreeNode class класс AVLTreeNode 629-631 constructor конструктор 631 declaration объявление 629 implementation реализация 631 Left method метод Left 631 В backtracking возврат (прохождение лабиринта) 436 Bakus-Naur form форма Бэкуса Наура 434 balanced (AVL) tree сбалансированное дерево 628 base class базовый класс 31 best case analysis анализ наилучшего случая 157 Big-0 notation нотация Big-0 156 binary file двоичный файл 715 - numbers двоичные числа 56 - operator бинарный оператор 193 - search - поиск 503 tree дерево бинарного поиска 503-507 ADT ADT 508 application: concordance приложение: конкорданс 525 occurrence counts счетчики появлений 513 description описание 503 BinStree class класс BinStree 508 deleting a node удаление узла 519 inorder traversal (sort) симметричное прохождение 511 (сортировка) inserting a node вставка узла 517 key ключ 505 compare sequential бинарный поиск: сравнение 164 последовательного и бинарного методов
formal analysis формальный анализ 166 informal analysis неформальный анализ 166 recursive form рекурсивная форма 443-445 - tree бинарное дерево 479-482 breadth first scan поперечное сканирование 500 complete законченное 482 copy a tree копирование дерева 495 deleting a tree удаление дерева 498 horizontal tree printing горизонтальная печать 493 inorder traversal симметричный метод прохождения 489 left-right child левый-правый сын 481 level scan по уровне вое сканирование 500 LNR, LRN, etc. симметричный метод прохождения 489 (порядок) node structure структура узла 483 TreeNode class класс TreeNode 483 upright (vertical) tree printing вертикальная печать 500 building построение 485 definition определение 479 degenerate вырожденное 481 description описание 479 full полное 482 BinFile class класс Binfile 718 Clear method метод Clear 719 constructor конструктор 721 declaration объявление 718 - - EndFile method - - метод EndFile 719 implementation реализация 721 Peek method метод Peek 719 Read (block) method Read (блочный) 721 method 721 - - Write (block) method Write (блочный) 722 method 722 Binomial coefficients биномиальные коэффициенты 472 BinSTree class класс BinSTree 508-510, 515-524 assignment operator оператор присваивания 515 constructor конструктор 515 declaration объявление 510 Delete method метод Delete 523 - - Find method Find 516 FindNode method FindNode 516 implementation реализация 515 Insert method метод Insert 517 memory management управление памятью 515 Update method метод Update 524 bit бит 57 - operations битовые операции 327-328 - - and - - и 327 exclusive or исключающее или 327 - - not - - не 327 or -— или 327 BNF, see Bakus-Naur form BNF, см. форма Бэкуса Наура breadth-first search поиск "сначала в ширину" 657 bubble sort сортировка методом пузырька 686 bucket блок (метод цепочек при хешировании) 705 building a tree search функция (MakeSearchTree 511 (MakeSearchTree) byte байт 57
с C++ language язык C++ 47 definition определение 47 historical development историческое развитие 47 Stroustrup, Bjarne Бьярн Страуструп 47 - streams потоки в C++ 77-78 - strings строки в C++ 73-74 Calculator class класс Calculator 202 Compute method метод Compute 196 declaration объявление 195 implementation реализация 196 Run method метод Run 197 call by reference вызов по ссылке 115 - by value - по значению 115 cerr stream поток cerr 80 chaining with separate lists метод цепочек (хеширование) 704 Character ADT ADT Character 58 - type символьный тип 58-60 cin stream поток ввода 80 Circle class класс Circle 548 declaration объявление 548 description описание 548 implementation реализация 548 circular циклический 400 - linked list - связанный список 400-403 application: Josephus приложение: Джозефус 403 Cnode class класс Cnode 400 class класс - (in book): Animal - (в книге): Animal 553-555 Array Array 303 Calculator Calculator 195 Circle (derived) Circle (производный) 548 - - CNode - - CNode 401 - - Date - - Date 118 Dice Dice 40 - - DNode - - DNode 410 - DynamicClass DynamicClass 293 Event Event 223 Heap Heap 612 Iterator (abstract class) Iterator (абстрактный) 564 Line Line 30 LinkedList LinkedList 374 List (abstract) List (абстрактный) 560 MathOperator MathOperator 281 Maze Maze 462 - - Node - - Node 353 NodeShape NodeShape 582 OrderedList OrderedList 36 - - Point - - Point 29 PQueue PQueue 214 Queue (linked list) Queue (связанный список) 388 RandomNumber RandomNumber 111-112 - - Rational - - Rational 247 Rectangle Rectangle 101 - - SeqList - - SeqList 36,168 (derived) (производный) 561 (linked list) (связанный список) 391
Shape Shape 546 - Simulation — Simulation 225 Spooler (print) Spooler (для печати) 396 - - Stack - - Stack 184 (template) (шаблонный) 276 - - String - - String 310 - Temperature Temperature 108 - - TriMat - - TriMat 12-129 - - Vec2d - - Vec2d 243 Window Window 412 - body - тело 100 - constuctor - конструктор 101 - declaration - объявление 25 - encapsulation - инкапсуляция 24 - head - заголовок 100 - hierarchy - иерархия 542 - imlementation - реализация 25,102 - information hiding - скрытие информации 24 - inheritance - наследование 31 - member initialization list - список инициализации 103 - members - члена класса 24,100 - methods - методы 24,100 - object - объект 100 - private part - закрытая часть 100 - protected part - защищенная часть 100 - puplic part - открытая часть 100 - scope resolution operator - оператор разрешения области действия 103 CNode class - CNode constructor конструктор 402 declaration объявление 401 DeleteAfter method DeleteAfter метод 403 implementation реализация 402 InsertAfter method InsertAfter метод 403 coefficient matrix матрица коэффициентов 132 collection types типы коллекций array массив 147 binary tree бинарное дерево 152 dictionary словарь 151 - - file - - файл 150 graph граф 154 hash table хеш-таблица 151 linear list линейный список 148 priority queue очередь приоритетов 150 queue очередь 149 random numbers случайные числа 110 record запись (как данные) 148 sequential list последовательный список 36 set множество 154 stack стек 149 string строка 147 collision коллизия - definition - определение 704 - resolution - разрешение 704 combinatories комбинаторика 437 committee problem задача о. комитетах 448-450 complexity analysis анализ сложности (алгоритма) 155-159 - - AVL tree - - AVL-дерево 628 binary search бинарный поиск 166 tree бинарное дерево поиска 504
breadth-first search поиск "сначала в ширину" 659 bubble sort сортировка методом пузырька 688 compare 0(n log2n) sorts сравнение 0(n log2n)-copTnpoBOK 697 О 9 0(n ) sorts 0(n )-сортировок 696 complete binary tree законченное бинарное дерево 482 depth-first search поиск "сначала в глубину" 659 exchange sort обменная сортировка 155 Fibonacci numbers числа Фибоначчи 466-468 hashing хеширование 714 heapsort пирамидальная сортировка 619 isertion sort сортировка вставками 689 linked list sort со связанными списками 370 pattern matching algorithm алгоритм сопоставления с образцом 325 priority queue operations операции с очередью приоритетов 217 queue operations с очередью 206 quicksort "быстрая сортировка" 695 radix sort поразрядная сортировка 212 selection sort сортировка посредством выбора 686 sequential search последовательный поиск 161 stack operations операции со стеком 189 straight merge sort сортировка прямым слиянием 728 tounament sort турнирная сортировка 604 tree sort сортировка включением в дерево 646 Warshall algorithm алгоритм Уоршалла 667 composition of objects композиция объектов 28-30 concatenating lists конкатенация списков 377 concordance конкорданс 525-529 connected связанный 647 - graph vertices - вершины графа 647 strongly - граф, сильно связанный 648 weakly слабо связанный 648 const qualifier спецификатор const (константы) 27 constant time (0(1)) постоянная единица времени (0(1)) 160 constructor конструктор 21 control abstraction абстракция элемента управления 540 copy constructor конструктор копирования 291, 300-302 cout stream поток stream (стандартный вывод) 80 cubic time (0(n3)) кубическое время (0(п3)) 160 cycle, in a graph цикл, в графе 649 D data types (language-defined) типы данных (определяемые языком) array массив 65 binary file двоичный файл 80 C++ strings строки C++ 73 struct структура C++ 77 character символьный 58 enumerated перечисления 62 integer целочисленный 55 pointer указатель 64 real действительный 60 record запись 77 string строчный 72 text file текстовый файл 79 two dimensional array двумерный массив 68 Date class класс Date 118 declaration, class объявление (класса) 25 default constructor конструктор умолчания 117
delete operator оператор delete 64 definition определение 293 description описание 290 deletion method метод удаления - - BinSTree (Delete) - - BinSTree (Delete) 523 Dictionary (DeleteKey) Dictionary (DeleteKey) 742 - - Graph (DeleteEdge) - - Graph (DeleteEdge) 652 (DeleteVertex) (DeleteVertex) 655 - - HashTable (Delete) - - HashTable (Delete) 707 - - Heap (Delete) - - Heap (Delete) 615 - - LinkedList (DeleteAt) - - LinkedList (DeleteAt) 387 (DeleteFont) (DeleteFont) 375 - - PQueue (PQDelete) - - PQueue (PQDelete) 216 Queue (QDelete) Queue (QDelete) 206 - - SeqList (Delete) - - SeqList (Delete) 170 - - Set (Delete) - - Set (Delete) 336 - - Stack (Pop) - - Stack (Pop) 187 departure event событие ухода (событийное моделирование) 223 depth of a tree глубина дерева 480 depth-first graph traversal прохождение "сначала в глубину" 656 derived class производный класс 31 design framework каркас разработки 39 destructor деструктор 291, 295-296 Dice class класс Dice 41 dictionary словарь 735 Dictionary class класс Dictionary 737 constructor конструктор 741 declaration объявление 737 DeleteKey method метод DeleteKey 742 implementation реализация 741 InDictionary method метод InDictionary 742 - application: word building словарь: приложение: построение 739 толкового словаря - association structure - ассоциативная структура 735 - definition - определение 151 - Dictionarylterator class - класс Dictionarylterator 738 digraph, see directed graph орграф, см. направленный граф direct access прямой доступ array массив 147 definition определение 146 - - file - - файл 715 directed graph направленный граф 648 discrete type дискретный тип 60 divide and conquer разделяй и властвуй (метод) 433 division method (hashing) метод деления (хеширование) 702 DNode class класс DNode 407 constructor конструктор 410 DeleteNode method метод DeleteNode 411 implementation реализация 410 InsertLeft method метод InsertLeft 411 InsertRight method InsertRight 411 doubly linked list двусвязный список 410 application: insertion sort приложение: сортировка вставками 406 dynamic динамический 408 - array - массив 147 allocation - выделение массива 292 - binding - связывание 49 - data structures - структуры данных 291
- memory - память 64 - object - объект 293-297 DynamicClass class класс DynamicClass assignment operator оператор присваивания 297 constructor конструктор 293-294 copy constructor копирования 300 destructor деструктор 295 E edge of graph ребро графа 647 encapsulation инкапсуляция 24 enumerated types типы перечисления 62-63 Enumerated ADT Enumerated ADT 62 Event class класс Event 223 event-driven simulation, see simulation управляемое событиями моделирование, см. моделирование exchange sort обменная сортировка 85-86 exponential time (0(2n)) экспоненциальное время (0(2п)) 160 expression выражение - evaluation - вычисление (оценка) 193 - trees - деревья 438 external data structures внешние структуры данных 77 - file search внешний файловый поиск 723 sort сортировка при помощи внешнего файла 727 F factorial function (recursive form) функция факториала (рекурсивная форма) 439 Fibonacci numbers числа Фибоначчи 466 fields of a record поля записи 76 FIFO, see first-in-first-out FIFO, см. первым вошел-первым вышел file файл 77-79 - ADT - ADT 79 - binary file - двоичный файл 715 - BinFile class - класс BinFile 718 - collection type - тип коллекции 150 - external hashing - внешнее хеширование 725 search - внешний поиск 723 - file pointer - файловый указатель 78 - mode - режим (записи/чтения) 716 - pointer - указатель 716 - seekg stream method - потоковый метод seekg 717 - seekp stream method seekp 717 - sequential - последовательный 717 - straight merge sort - сортировка прямым слиянием 727 - tellg stream method - потоковый метод tellg 717 - tellp stream method tellp 717 - text file - текстовый файл 80 first-in-first-out первым вошел-первым вышел 199 fixed point number число с фиксированной точкой 60 friend functions дружественные функции 244-245 front of linked list начало связанного списка 353 full binary tree полное бинарное дерево 482 function overloading перегрузка функции 258 G GCD (Greatest Common Devisor) наибольший общий делитель 254 graph граф 647 Graph class класс Graph 652
breadth-first traversal прохождение "сначала в ширину" 656 (метод) constructor конструктор 653 declaration объявление 652 Delete Vertex method метод Delete Vertex 655 GetNeighbors method GetNeighbors 654 GetVertexPos method GetVertexPos 654 - - GetWeight method GetWeight 654 implementation реализация 653 InsertEdge method метод InsertEdge 654 minimum path минимальный путь 661 Vertexlterator class метод Vertexlterator 654 depth-first graph traversal прохождение "сначала в глубину" 656 graph: acyclic граф: ациклический 649 - adjacency matrix - матрица смежности 650 - ADT - ADT 649 - application: strong components - приложение: сильные компоненты 659 - connected vertices - связанные вершины 648 - cycle - цикл 649 - directed (digraph) - направленный (орграф) 648 - edges - ребра 648 - path - путь 648 - reachability matrix - матрица достижимости 666 - strong components - сильные компоненты 659 - strongly connected - сильно связанный 648 - transitive closure - транзитивное замыкание 667 - undirected - ненаправленный 648 - vertices - вершины 647 - weakly connected — слабо связанный 648 - weighted digraph - взвешенный орграф 649 group группа 153 H hash function хеш-функция 701 - table хеш-таблица complexity analysis - анализ сложности 714 definition - определение 700 iterator - итератор 708 hashing хеширование 700 - chaining with separate lists - метод цепочек 704 - collisions - коллизии 702 - description - описание 700 - division metod - метод деления 702 - folding method свертки 744 - hash function - хеш-функция 701 table - хеш-таблица 701 - HashTable class - класс HashTable 707 - linear probe open addressing - открытая адресация с линейным 704 опробованием - midsquare technique - метод середины квадрата 704 - multiplicative method - мультипликативный метод 704 - rehashing — рехеширование 745 HashTable class класс HashTable 707 declaration объявление 707 Find method метод Find 712 implementation реализация 711 Insert method метод Insert 711 HashTablelterator class - HashTablelterator 711 constructor конструктор 713
declaration объявление 708 implementation реализация 712 Next method метод Next 714 SearchNextNode method SearchNextNode 713 head of a linked list голова связанного списка 353, 358 header node заголовочный узел 401 heap пирамида 607-612 Heap class класс Heap 609 constructor конструктор 618 declaration объявление 609 Delete method метод Delete 615 FilterDown method FilterDown 615 - - Filter Up method FilterUp 613 heapsort пирамидальная сортировка 618 implementation реализация 612 Insert method метод Insert 614 heap: definition пирамида: определение 607 - maximum - максимальная 608 - minimum - минимальная 608 - order - порядок 607 heapsort пирамидальная сортировка 618 heterogeneous разнородный - array - массив 579 - list - список 579 - type - тип 77 hexadecimal шестнадцатеричный 90 homogeneous однородный - array - массив 65 - list однородный: список 166 I ifstream class класс ifstream 80 inderact recursion косвенная рекурсия 434 index operator [] оператор индекса [] 303, 306 - array индекс, массив 65 infix инфиксный - expression evaluation - вычисление выражения 277-285 - notation - формат 193 information hiding скрытие информации 24 inheritance наследование - abstract class - абстрактный класс 541 - application: geometric figures - приложение: геометрические фигуры 556 - base class - базовый класс 542 - class hierarchy - иерархия класса 542 - concept - концепция 28 - definition - определение 540 - derived class - производный класс 542 - dynamic binding - динамическое связывание 550 - multiple - множественное (наследование) 37 - polymorphism - полиморфизм 550 - protected members - защищенные члены (класса) 544 - public inheritance - открытое наследование 543 - pure virtual function - чистая виртуальная функция 541 - static binding - статическое связывание 551 - subclass - подкласс 543 - superclass - суперкласс 543 - virtual function - виртуальная функция 541 member function функция-член 550 initializer, see constructor инициализатор, см. конструктор
inorder traversal симметричный метод прохождения 58 Inorderlterator class класс Inorderlterator constructor конструктор 644 declaration объявление 643 implamentation реализация 644 Next method метод Next 645 in-place sorting сортировка на месте 212 input precedence входной приоритет 280 insertion method метод вставки - - AVLTree (AVLInsert) - - AVLTree (AVLInsert) 636 (Insert) (Insert) 634 - - BinFile (Write) - - BinFile (Write) 722 - - BinSTree (Insert) - - BinSTree (Insert) 517 - - Graph (InsertEdge) Graph (InsertEdge) 652 (InsertVertex) (InsertVertex) 652 - - HastTable (Insert) - - HastTable (Insert) 711 Heap (Insert) Heap (Insert) 614 Linkedlist (InsertAfter) LinkedList (InsertAfter) 375 (InsertAt) (InsertAt) 385 (InsertFront) (InsertFront) 375 (InsertRear) (InsertRear) 375 OrderedLdst (Insert) OrderedList (Insert) 575 - - PQueue (PQInsert) - - PQueue (PQInsert) 215 Queue (Qlnsert) Queue (Qlnsert) 205 SeqList (Insert) SeqList (Insert) 169 - - Set (Insert) - - Set (Insert) 366 - - Stack (Push) - - Stack (Push) 187 - - String (Insert) - - String (Insert) 312 (operator+) (operator*) 312, 317 insertion sort сортировка вставками 688 instance of a class экземпляр класса 102 Integer ADT Integer ADT 55 integer types целочисленный тип 54-57 internal data structures внутренние структуры данных 77 Ios class класс Ios 80 Istream class - Istream 80 Istrtream class - Istrtream 81 iterator итератор 563 Iterator (abstract class) Iterator (абстрактный класс) declaration объявление 564 implementation реализация 564 iterator classes классы итераторов Arraylterator Arraylterator 569 Inorderlterator Inorderlterator 643 SeqListlterator SeqListlterator 565 Vertexlterator Vertexlterator 652 J Josephus problem задача Джозефуса 403 К key ключ 82 KeyValue class класс KeyValue 736 L last-in-first-out последним пришел-первым вышел 183 leaf node листовой узел 479 level in a tree уровень дерева 480
level-order scan поперечное сканирование 500 LIFO, see laat-in-first-out UFO, см. последним пришел-первым вышел Line cites класс Line 30 linear линейный 32 - collection линейная коллекция 144-152 - probe open addressing открытая адресация с линейным 704 опробованием - time (o(n)) линейное время 160 - sequential list линейный: последовательный список 148 linked list связанный список 358-361, 363-364 - application: graduation приложение: список выпускников 365 print spooler буферизация печати 394 window management управление окнами 411 word jumble головоломка 362 clearing a list удаление элементов списка 369 creating a node создание узла 358 an ordered list упорядоченного списка 367 deleting a front удаление начального элемента 363 a node узла 364 description описание 351 head голова 358 inserting at front вставка в начало 358 at rear в хвост 361 introduction введение 350 printing a list печать списка 360 sorting using ordered list сортировка с использованием 369 связанного списка traversal прохождение 359 LinkedList class класс LinkedList 371-376, 381-388 application: concatenating lists приложение: конкатенированные 377 списки removing duplicates удаление дубликатов 379 selection sort сортировка выбором 378 ClearList method метод ClearList 383 constructor конструктор 382 CopyList method метод CopyList 383 current pointer (currPtr) указатель на текущий узел 371 Data method метод Data 385 declaration объявление 374 Delete At method метод DeleteAt 387 describing operations описание операций 372-374 designing the class проектирование списка 371 front pointer указатель на первый узел 371 implementation реализация 381 InsertAt method метод InsertAt 385 memory allocation methods методы выделения памяти 382 Next method метод Next 384 previous pointer (prevPtr) указатель на предыдущий узел 371 rear pointer на последний узел 371 Reset method метод Reset 384 List class (abstract) класс List (абстрактный) 560-563 declaration объявление 561 implementation реализация 561 load factor коэффициент заполнения 714 logarithmic time (O(log2n), 0(nlog2n)) логарифмическое время (0(log2n), 0(nlog2n)) 160 long run длинная последовательность 577 merge sort сортировка слиянием 729
м MathOperator class класс MathOperator comparison operator оператор сравнения 282 constructor конструктор 281 declaration объявление 281 Evaluate method метод Evaluate 282 matrix матрица 120 maximum heap максимальная пирамида 608 Maze class класс Maze declaration объявление 462 imlementation реализация 463 member initialization list список инициализации членов (класса) 103 memory allocation распределение памяти dynamic динамическое 290, 292 static статическое 290 merge слияние - sorted runs - сортированных последовательностей 570-574 - straight merge sort - сортировка прямым слиянием 727 message passing передача сообщения 25 method in a class методы класса 24 midsquare technique (hashing) метод середины квадрата (хеширование) 704 minimum heap минимальная пирамида 608 - path минимальный путь 661 multiple constructors множественные конструкторы 117 - inheritance множественное наследование 37 multiplicative method (hashing) мультипликативный метод (хеширование) 704 N network сеть 155 new operator оператор new definition определение 64 description описание 290 insufficient memory error ошибка выделения памяти 292 node узел Node class класс Node 353-358 constructor конструктор 356 declaration объявление 355 DeleteAfter method метод DeleteAfter 357 implementation реализация 356 Insert After method метод InsertAfter 357 NextNode method NextNode 356 node: ADT узел: ADT 354 - definition - определение 352 - in a tree - в дереве 152 - in linked list - в связанном списке 351-352 NodeShape class класс NodeShape 582 nonlinear collection нелинейная коллекция 144, 152-155 NULL pointer NULL-указатель 353 О object объект - as a function parameter - как параметр функции 115 - as a return value - как возвращаемое значение 115 - composition - композиция 28 - definition - определение 20
- inheritance - наследование 28, 31-32 - testing - тестирование 45 object-oriented programming объектно-ориентированное программирование abstract base class абстрактный базовый класс 48 composition композиция 28 dynamic binding динамическое связывание 550 inheritance наследование 28, 31 multiple inhritance множественное наследование 37 polimorphism полиморфизм 550 program design построение программы 38-43, 45-46 pure virtual function чистая виртуальная функция 49 reusability of software повторное использование кода 35 Stroustrup, Bjarne Бъярн Страуструп 47 testing тестирование 45 virtual function виртуальная функция 49 objects and information passing объекты и передача информации 115 ofstream class класс ofstream 80 operand stack стек операнда 278 OperandList class класс OperandList 35 operator overloading перегрузка оператора 241 - stack стек операторов 279 ordered list упорядоченный список 149 - - ADT - - ADT 34 application: long runs приложение: длинные 577 последовательности creation algorithm алгоритм создания 367 OrderedList class implementation класс OrderedList: реализация 576 Insert method метод Insert 576 declaration объявление 576 Ostream class класс Ostream 80 Ostrstream class - Ostrstream 81 overloading перегрузка - assignment operator = - оператор присваивания — 299 - conversion operators - операторы преобразования 252-254 - external function - внешние функции 241 - index operator [ ] - оператор индекса [ ] - operator - оператора 241 - stream operators - операторы потока 250-252 - with class members - функциями-членами класса 242 - with friend functions - дружественными функциями 244 P palindrome палиндром 189 parent родитель - node родительский: узел 479 path in a graph путь в графе 648 - in a tree - в дереве 479 pattern matching сопоставление с образцом 320 Peek (stack) method метод Peek (стековый) 188 permutations перестановки 451-455 Point class класс Point 29-30 pointer указатель - ADT - ADT 64 - conversion operator - операция преобразования 303 - definition - определение 63 polimorfism полиморфизм 48-50 pop operation операция извлечения (из стека)
description описание 149, 182 Stack method метод Stack 187 postfix постфиксный - evaluation - вычисление 193 - notation - форма представления 193 postorder traversal обратный метод прохождения 490 power function (recursive form) степенная функция (рекурсивная форма) 442 PQueue class класс Pqueue 214-217 declaration объявление 214 (heap version) (пирамидальная версия) 622 implementation реализация 215 Pqdelete метод Pqdelete 216 Pqinsert Pqinsert 215 precedence of perators приоритет операции input precedence входной приоритет 280 stack precedence стековый приоритет 280 precondition, See ADT предусловия, см. ADT print spooler буферизация печати 394-400 priority queue очередь приоритетов 212-217 - - ADT - - ADT 213 applicaion: long runs приложение: длинные 622 последовательности support services сервисная поддержка 217 event-driven simulation приложение: моделирование, 220 управляемое событиями definition определение 212 PQueue class класс Pqueue 214 (heap version) (пирамидальная версия) 622 private inheritance закрытое наследование 587 PRN, see Reverse Polish notation PRN, см обратная польская запись program design features возможности программного конструирования object design объектная разработка 38 testing тестирование объектов 45 robustness устойчивость к ошибкам 46 structure tree структурное дерево 39 structured walkthrough сквозной стуктуированный контроль 45 testing тестирование объектов 40 protected members защищенные методы 544 public class section открытая секция класса 24 - inheritance открытое наследование 543 pure virtual function, see virtual чистая виртуальная функция, см. function виртуальная функция push operator операция помещения в стек description описание 149, 182 Stack method метод Stack 187 Q quadratic time (0(n2)) квадратичное время (0(п2)) 160 queue очередь 198-206 Queue class (array) класс Queue (массив) 199 constructor конструктор 204 declaration объявление 201 implementation реализация 202 Qdelete method метод Qdelete 206 Qinsert method Qinsert 205 (linked linked) (связанный список) 389-392 declaration объявление 389 implementation реализация 390
queue: ADT очередь: ADT 199 - application: dance partners - приложение: партнеры по танцу 206 radix sort поразрядная сортировка 209 - collection type - тип коллекции 149 - definition - определение 198 - implementation - реализация 202 quicksort быстрая сортировка 690 R radix sort поразрядная сортировка 209 random number случайное число generator генератор 110 RandomNumber class класс RandomNumber 110 seed seed-значение 110 RandomNumber class класс RandomNumber 110-114 constructor конструктор 111-112 declaration объявление 110 fRandom method метод fRandom 112 implementation реализация 112 Random method метод Random 112 rank ранг 278 Rational class класс Rational 247-258 declaration объявление 247 operators (as friends) операторы (как дружественные) 247 (as members) (как члены) 249 (type conversion) (преобразование типа) 252 Reduce method метод Reduce 255 stream operators потоковые операторы 248 rational number рациональное число 245-247 application: solving linear приложение: решение линейных 256 equations уравнений reduced form редуцированная форма 245 representation представление 245 standardized form нормализованная форма 246 reachability matrix матрица достижимости 666 real data types вещественные типы данных 60 - number вещественное число - - ADT - - ADT 60 definition определение 60 exponent порядок (экспонента) 60 mantissa мантисса 60 representation представление 60 scientific notation научный формат 60 rear of linked list хвост связанного списка 353 record запись (как набор данных) - ADT ADT 77 - definition определение 76 Rectangle class класс Rectangle 101-107 recursion рекурсия 432-439 - binary search - бинарный поиск 446 - binomial coefficients - биномиальные коэффициенты 472 - combinatorics - комбинаторика 438 - committee problem - задача о комитетах 448-450 - expression trees - синтаксические деревья 438 - factorial - факториал 439 - Fibonacci numbers - числа Фибоначчи 466 - maze - лабиринт 436, 460 - Pascal's triangle - треугольник Паскаля 473 - permutations - перестановки 451-455
- power function - степенная функция 442 - recursive definition - определение 433 step - шаг рекурсии 433 - runtime stack - стек времени исполнения 443-445 - tail recursion - задняя рекурсия 469 - Tower of Hanoi - Ханойская башня 435, 455 retrieval methods методы поиска - - BinFile (block Read) - - BinFile (block Read) 721 (Read) (Read) 721 - - BinSTree (Find) - - BinSTree (Find) 515 - - NasTable (Find) - - NasTable (Find) 712 - - Queue (Qfront) Queue (Qfront) 201 - - SeqList (Find) - - SeqList (Find) 171 - - Set (IsMember) - - Set (IsMember) 335 - - Stack (Peek) - - Stack (Peek) 188 - - String (Find) - - String (Find) 312 (FindLast) (FindLast) 319 (Substr) (Substr) 317 reusability of software повторное использование кода 35 Reverse Polish notation обратная польская запись 193 root of tree корень дерева 152, 479 rotation in AVL tree вращение AVL-дерева - ■ double двойное 639 single единичное 638 runtime stack стек времени исполнения 443 S safe arrays надежные массивы 303 scope resolution operator операция разрешения области действия 103 selection sort сортировка посредством выбора 684 self-referencing structure само ссылающаяся структура 335 sentinel node узел-часовой 401 SeqList class (array) класс SeqList (массив) 168-175 declaration объявление 168 Delete method метод Delete 170 Find method Find 171 GetData method GetData 170 implementation реализация 168 Insert method метод Insert 169 (derived) (производный) 561-563 declaration объявление 562 implementation реализация 562 (linked list) (связанный список) 391-392 application: efficiency приложение: сравнение 392-394 comparison эффективности declaration объявление 391 implementation реализация 392 SeqListlterator class - SeqListlterator - - declaration объявление 565 implementation реализация 565 sequential access последовательный доступ array массив 65 - - file - - файл 77 - list - список 166-175 - - ADT - - ADT 33 application: video store приложение: хранение видео фильмов 173 - - collection type тип коллекций 146 description описание 32,166
SeqList class класс SeqList 36,168, 391 - search - поиск 82, 161-162 algorithm алгоритм 161 compare binary search сравнение с бинарным 164 fast быстрый 287 set множество Set class (integral type) - Set (integral type) 334 constructor конструктор 329 declaration объявление 336 Delete method метод Delete 330 description описание 336 I/O stream operatos потоковые операторы 334 ввода/вывода implementation реализация 336 Insert method метод Insert 335 IsMember method метод IsMember 335 operator* (union) operator* (объединение) 335 set: Set class (array model) - класс Set (модель массива) 263 (integral type) (целочисленный тип) 328 - application: Sieve of Eratosthenes - решето Эратосфена 332 - collection type - тип коллекции 154 - description (integral type) - описание (целочисленный тип) 325-327, 329 Shape class класс Shape declaration объявление 547 description описание 546 implementation реализация 547 Sieve of Eratosthenes Решето Эратосфена 332 sign bit знаковый бит 57 simulation моделирование 220-232 Simulation class класс Simulation constructor конструктор 226 declaration объявление 225 NextArrvalTime method метод NextArrvalTime 227 simulation: arrival event моделирование: событие прихода 223 - departure event ухода 223 software development разработка программного продукта methodology методология 38 reusability of software повторное использование 35 sorting algorithms алгоритмы сортировки bubble sort методом пузырька 686 doubly linked list sort двусвязного списка 408 exchange sort обменная 85 insertion sort вставками 688 linked list sort сортировка со связанными списками 369 quiksort "быстрая" 690 radix sort поразрядная 209 -— selection sort сортировка посредством выбора 684 tournament sort турнирная 602 treesort treesort-сортировка 646 source level debugger отладчик на уровне кода 46 space efficiency эффективность использования памяти 156 Spooler (print) class класс Spooler (печать) declaration объявление 396 implementation реализация 397 square matrix квадратная матрица 120 stack стек 182-189 Stack class класс Stack 184-189
ClearStack method метод ClearStack 188 constructor конструктор 187 declaration объявление 186 implementation реализация 187 Peek method метод Peek 188 - - Pop method Pop 187 - - Push method Push 187 StackEmpty method StackEmpty 188 - - StackFull method StackFull 188 stack precedence стековый приоритет - ADT стек: ADT 184 - application: expression evaluation - приложение: вычисление выражения 193 infix expression, evaluation инфиксное выражение, оценивание 279 multibase output вывод (чисел) с различными 191 основаниями palindrome палиндром 189 postfix calculator постфиксный калькулятор 195 - collection type - тип коллекции 149 - definition - определение 182 - description - описание 182 - empty - пустой 183 - full - полный 183 state change изменение состояния 25 static статический - array - массив 147 - binding статическое: связывание 551 - data structures статические: структуры данных 290 - memory статическая: память 64 stopping condition условие останова (цикла) 433 stream class потоковый класс f stream f stream 81 ifstream ifstream 80 ios ios 80 istream istream 80 ofstream of stream 80 ostream ostream 80 cerr cerr (стандартный поток ошибок) 80 cin cin (стандартный ввод) 80 cout cout (стандартный вывод) 80 - operator overloading перегрузка оператора потока 251 - description поток, описание 78,150 String class класс String 310-320 application: test program приложение: тестовая программа 314 assignment operator оператор присваивания 316 concatenation конкатенация 314 constructor конструктор 315 declaration объявление 311 - - FindLast - - FindLast 319 I/O ввод /вывод 319 implementation реализация 315 ReadString method метод ReadString 319 string comparison сравнение строк 316 Substr method метод Substr 318 - type (C++) строка в C++ 73 string: ADT - ADT 72 - description - описание 71 strongly connected graph сильно связанный граф 648 subtree поддерево 479 system efficiency системная эффективность 155
т tail recursion задняя рекурсия 469 Temperature class класс Temperature 108 template шаблон 270-275 - class declaration - объявление класса 274 - function - функция 270-273 - keyword - ключевое слово 270 - method - метод 274 - object - объект 274 - parameter list - список параметров 270 - Stack class - класс Stack 276-277 - syntax - синтаксис 270 text file текстовый файл 77-80 this pointer указатель this 300 threaded trees прошитые деревья 535 top of stack вершина стека 182 top-down program disign нисходящая программная разработка 38 trangular matrices треугольные матрицы 120-129 transitive closure транзитивное замыкание 667 tree дерево 479-480 - algorithms алгоритмы деревьев 489-503 breadth-first csan поперечное сканирование 500 computing the depth вычисление глубины 492 copying a tree копирование дерева 495 counting leaf nodes вычисление количества листовых 492 узлов deleting a tree удаление дерева 498-499 horizontal tree printing горизонтальная печать дерева 493 inorder scan симметричное сканирование 489 level-order scan по уровневое сканирование 500 postorder scan обратное сканирование 490 - iterators итераторы дерева 642 - node function desing конструирование функций узлов дерева 486 - traversals методы прохождения дерева 489 - ancestors-descendents дерево: предки-потомки 479 - binary tree - бинарное дерево 480 - children-parent - сын-родитель 479 - collection type - тип коллекции 152 - definition - определение 479 - depth - глубина 480 - description - описание 479 - height, see depth - высота, см. глубина - leaf - лист 479 - left child - левый сын 481 - level - уровень 480 - node - узел 483 - path - путь 479 - right child - правый сын 479 - root - корень 479 - subtree - поддерево 479 - terminology - терминология 479 TreeNode class класс TreeNode 484 constructor - конструктор 485 declaration объявление 484 FreeTreeNode method метод FreeTreeNode 486 GetTreeNode method GetTreeNode 486 treesort treesort-сортировка 646 TriMat class класс TriMat 124-129
two-dimensional array двумерный массив definition определение 68 storage хранение 69 type conversion operator оператор преобразования типа from object type из объектного типа 253 to object type к типу объекта 252 U unary operator унарный оператор 54,193 undirected graph ненаправленный граф 648 universal set универсальное множество 325 unsigned number число без знака 57 upper triangular matrix верхняя треугольная матрица 120 V Vec2d class класс Vec2d 243 declaration объявление 244 operators (as friends) операторы (как дружественные) 243 (as members) (как члены класса) 262 scalar multiplication скалярное произведение 262 vertex of graph вершина графа 647 virtual function виртуальная функция 550-552 and polymorphism и полиморфизм 550 description описание 49-50, 541 destructor деструктор 558 pure чистая 541,559 table таблица 552 W Warshall algorithm алгоритм Уоршалла 666 weakly connected graph слабо связанный граф 648 weighted digraph взвешенный орграф 649 Window class класс Window declaration объявление 413 implementation реализация 415 window management управление окнами 411-418 worst case analysis анализ наихудшего случая (алгоритма) 157
Научно-популярное издание Уильям Топп, Уильям Форд Структуры данных в C++ Компьютерная верстка Свиридова К.А. Подписано в печать 15.03.99. Формат 70x100 V\e. Усл. печ. л. 66,3 Гарнитура Школьная. Бумага газетная. Печать офсетная. Тираж 3000 экз. Заказ 100 ЗАО «Издательство БИНОМ», 1999 г. 103473, Москва, Краснопролетарская, 16 Лицензия на издательскую деятельность № 065249 от 26 июня 1997 г. Отпечатано с готового оригинал-макета в типографии ИПО Профиздат 109044, Москва, Крутицкий вал, 18.