Глава 1. Анализ
Лекция 1. Введение
Алгоритмы
В общем случае при создании компьютерной программы мы реализуем метод, который ранее был разработан для решения какой-либо задачи. Часто этот метод не зависит от конкретного используемого компьютера — он примерно одинаково пригоден для многих компьютеров и многих компьютерных языков. именно метод, а не саму программу нужно исследовать для выяснения способа решения задачи. Для метода решения задачи, пригодного для реализации в виде компьютерной программы, в компьютерных науках используется термин алгоритм. Алгоритмы составляют основу компьютерных наук: это основные объекты изучения во многих, если не в большинстве областей этих наук.
В основном представляющие интерес алгоритмы тесно связаны с методами организации данных, участвующих в вычислениях. Созданные таким образом объекты называются структурами данных, и они также являются центральными объектами изучения в компьютерных науках. То есть алгоритмы и структуры данных идут рука об руку. В этом курсе мы покажем, что структуры данных существуют в качестве побочных или конечных продуктов алгоритмов и, следовательно, их нужно изучить, чтобы понять алгоритмы. Простые алгоритмы могут порождать сложные структуры данных и наоборот, сложные алгоритмы могут использовать простые структуры данных. В этокурсе курсе будут изучены свойства множества структур данных, так что курс вполне могла бы называться Алгоритмы и структуры данных на C++.
Когда компьютер используется для решения той или иной задачи, как правило, имеется целый ряд возможных различных подходов. При решении простых задач выбор того или иного подхода вряд ли имеет особое значение, если только выбранный подход приводит к правильному решению. Однако при решении очень сложных задач (или в приложениях, в которых приходится решать очень большое количество простых задач) мы сталкиваемся с необходимостью разработки методов, в которых время или память используются с максимальной эффективностью.
Основная побудительная причина изучения алгоритмов состоит в том, что это позволяет обеспечить огромную экономию ресурсов, вплоть до получения решений задач, которые в противном случае были бы невозможны. В приложениях, в которых обрабатываются миллионы объектов, часто хорошо разработанный алгоритм позволяет ускорить работу программы в миллионы раз. Подобный пример приводится в разделе 1.2 и в множестве других разделов курса. Для сравнения: затраты дополнительных денег или времени для приобретения и установки нового компьютера потенциально позволяет ускорить работу программы всего в 10—100 раз. Тщательная разработка алгоритма — исключительно эффективная часть процесса решения сложной задачи в любой области применения.
При разработке очень большой или сложной компьютерной программы значительные усилия должны затрачиваться на уяснение и формулировку задачи, которая должны быть решена, осознание ее сложности и разбиение ее на менее сложные подзадачи, решения которых можно легко реализовать. Зачастую реализация многих алгоритмов, требуемых после разбиения, тривиальна. Однако в большинстве случаев существует несколько алгоритмов, выбор которых критичен, поскольку для их выполнения требуется большая часть системных ресурсов. Именно этим типам алгоритмов уделяется основное внимание в данном курсе. Мы изучим ряд основополагающих алгоритмов, которые полезны при решении сложных задач во многих областях применения.
Совместное использование программ в компьютерных системах становится все более распространенным, поэтому, хотя можно ожидать, что использовать придется многие из рассмотренных в курсе алгоритмов, одновременно можно надеяться, что реализовывать придется лишь немногие из них. Например, библиотека стандартных шаблонов (Standard Template Library) C++ содержит реализации множества базовых алгоритмов. Однако реализация простых версий основных алгоритмов позволяет лучше их понять и, следовательно, эффективнее использовать и настраивать более совершенные библиотечные версии. И что еще важнее, очень часто возникает необходимость самостоятельной реализации основных алгоритмов. Основная причина состоит в том, что мы очень часто сталкиваемся с совершено новыми вычислительными средами (аппаратными и программными), новые свойства которых не могут наилучшим образом использоваться старыми реализациями. То есть чтобы наши решения были более переносимыми и применимыми, часто приходится реализовывать базовые алгоритмы, приспособленные к конкретной задаче, а не основывающиеся на системных подпрограммах. Другая часто возникающая причина самостоятельной реализации базовых алгоритмов заключается в том, что несмотря на усовершенствования, встроенные в C++, механизмы, используемые для совместного использования программ, не всегда достаточно мощны, чтобы библиотечные программы можно было легко приспособить к эффективному выполнению конкретных задач.
Компьютерные программы часто чрезмерно оптимизированы. Обеспечение наиболее эффективной реализации конкретного алгоритма может не стоить затраченных усилий, если только алгоритм не должен использоваться для решения очень сложной задачи или же многократно. В противном случае вполне сгодится качественная, сравнительно простая реализация: достаточно быть уверенным в ее работоспособности и в том, что, скорее всего, в худшем случае она будет работать в 5—10 раз медленнее наиболее эффективной версии, что может означать увеличение времени выполнения на несколько дополнительных секунд. И напротив, правильный выбор алгоритма может ускорить работу в 100—1000 и более раз, что во время выполнения может сэкономить минуты, часы и даже более того. В этом курсе основное внимание уделяется простейшим приемлемым реализациям наилучших алгоритмов.
Выбор наилучшего алгоритма выполнения конкретной задачи может оказаться сложным процессом, возможно, требующим нетривиального математического анализа. Направление компьютерных наук, занимающееся изучением подобных вопросов, называется анализом алгоритмов. Анализ многих изучаемых алгоритмов показывает, что они имеют прекрасную производительность; о хорошей работе других известно просто из опыта их применения. Наша основная цель — изучение приемлемых алгоритмов выполнения важных задач, хотя значительное внимание будет уделено также сравнительной производительности различных методов. Не следует использовать алгоритм, не имея представления о ресурсах, которые могут потребоваться для его выполнения, поэтому мы стараемся узнать, как могут выполняться используемые алгоритмы.
Пример задачи: связность
Предположим, что имеется последовательность пар целых чисел, в которой каждое целое число представляет объект некоторого типа, а пара p-q означает "p связано с q". Предполагается, что отношение "связано с" является транзитивным: если p связано с q, а q связано с r, то p связано с r. Задача состоит в написании программы для исключения лишних пар из набора: когда программа вводит пару p-q, она должна выводить эту пару только в том случае, если из просмотренных до данного момента пары не следует, что p связано с q. Если в соответствии с ранее просмотренными парами следует, что p связано с q, программа должна игнорировать пару p-q и переходить ко вводу следующей пары. Пример такого процесса показан на рис. 1.1.

Рис. 1.1. Пример связности
При заданной последовательности пар целых чисел, представляющих связи между объектами (слева) алгоритм определения связности должен выводить только те пары, которые определяют новые связи (в центре). Например, пара 2-9 не должна выводиться, поскольку связь 2-3-4-9 определяется ранее указанными связями (подтверждение этого показано справа).
Задача состоит в разработке программы, которая может запомнить достаточный объем информации о просмотренных парах, чтобы решить, связана ли новая пара объектов. Неформально задачу разработки такого метода мы назовем задачей связности. Эта задача возникает в ряде важных приложений. Для подтверждения всеобщего характера этой задачи мы кратко рассмотрим три примера.
Например, целые числа могли бы представлять компьютеры в большой сети, а пары могли бы представлять соединения в сети. Тогда такая программа могла бы использоваться для определения того, нужно ли устанавливать новое прямое соединение между p и q, чтобы они могли обмениваться информацией, или же для установки коммуникационного пути можно использовать существующие соединения. В подобных приложениях может потребоваться обработка миллионов точек и миллиардов или более соединений. Как мы увидим, решить задачу для такого приложения было бы невозможно без эффективного алгоритма. Аналогично, целые числа могли бы представлять контакты в электрической сети, а пары могли бы представлять связывающие их проводники. В этом случае программу можно было бы использовать для определения способа соединения всех точек без каких-либо избыточных соединений, если это возможно. Не существует никакой гарантии, что связей в списке окажется достаточно для соединения всех точек — действительно, вскоре мы увидим, что определение факта, так ли это, может быть основным применением нашей программы.
На рис. 1.2 показаны эти два типа применений на более сложном примере. Изучение этого рисунка дает представление о сложности задачи связности: как можно быстро выяснить, являются ли любые две заданные точки в такой сети связанными?
Еще один пример встречается в некоторых средах программирования, в которых два имени переменных можно объявлять эквивалентными. Задача заключается в возможности определения, являются ли два заданных имени эквивалентными, после считывания последовательности таких объявлений. Это применение — одно из первых, обусловивших разработку нескольких алгоритмов, которые мы рассмотрим ниже. Как будет показано далее, оно устанавливает непосредственную связь между рассматриваемой задачей и простой абстракцией, предоставляющей способ сделать алгоритмы полезными для широкого множества приложений.
Такие приложения, как задача установления эквивалентности имен переменных, описанная в предыдущем абзаце, требует, чтобы с каждым отдельным именем переменной было сопоставлено целое число. Это сопоставление подразумевается также и в описанных приложениях сетевого соединения и соединения в электрической цепи. В лекциях 10—16 мы рассмотрим ряд алгоритмов, которые могут эффективно обеспечить такое сопоставление. Таким образом, в этой лекции без ущерба для общности можно предположить, что имеется N объектов с целочисленными именами от 0 до N — 1 .

Рис. 1.2. Большой пример задачи связности
Объекты, задействованные в задаче связности, могут представлять собой точки соединений, а пары могут быть соединениями между ними — как показано в этом идеализированном примере, который мог бы представлять провода, соединяющие здания в городе или компоненты в компьютерной микросхеме. Это графическое представление позволяет человеку выявить несвязанные узлы, но алгоритм должен работать только с переданными ему парами целых чисел. Связаны ли два узла, помеченные большими черными точками?
Нам требуется программа, которая выполняет конкретную, вполне определенную задачу. Существует множество других связанных с этой задач, решение которых также может потребоваться. Один из первых вопросов, который необходимо решить при разработке алгоритма — это убедиться, что задача определена приемлемым образом. Чем выше требования к алгоритму, тем больше времени и объема памяти может потребоваться для выполнения задачи. Это соотношение невозможно в точности определить заранее, и часто определение задачи приходится изменять, когда выясняется, что ее трудно решить, либо решение требует слишком больших затрат, или же когда, при удачном стечении обстоятельств, выясняется, что алгоритм может предоставить более полезную информацию, чем требовалось от него в исходном определении.
Например, приведенное определение задачи связности требует только, чтобы программа как-либо узнавала, является ли данная пара p-q связанной, но не обязательно демонстрировала любой или все способы соединения этой пары. Добавление в определение такого требования усложнило бы задачу и привело к другому семейству алгоритмов, которое будет кратко рассматриваться в лекция №5 и подробно — в части VII.
Упомянутые в предыдущем абзаце определения требуют больше информации, чем первоначальное; может также требоваться и меньше информации. Например, может потребоваться просто ответить на вопрос: "Достаточно ли M связей для соединения всех N объектов?". Эта задача показывает, что для разработки эффективных алгоритмов часто требуется выполнение умозаключений об абстрактных обрабатываемых объектах на высоком уровне. В данном случае из фундаментальных положений теории графов следует, что все N объектов связаны тогда и только тогда, когда количество пар, образованных алгоритмом решения задачи связности, равно точно N — 1 (см. лекция №5). Иначе говоря, алгоритм решения задачи связности никогда не выводит более N — 1 пар, поскольку как только он выведет N — 1 пару, любая встретившаяся после этого пара будет уже связанной. Соответственно, можно создать программу, отвечающую "да-нет" на только что поставленный вопрос, изменив программу, которая решает задачу связности, на такую, которая увеличивает значение счетчика, а не записывает ранее не связанную пару, отвечая "да", когда значение счетчика достигает N— 1 , и "нет", если это не происходит. Этот вопрос — всего лишь один из множества вопросов, которые могут возникнуть относительно связности. Входной набор пар называется графом (graph), а выходной набор пар — остовным деревом (spanning tree) этого графа, которое связывает все объекты. Свойства графов, остовных деревьев и всевозможные связанные с ними алгоритмы будут рассматриваться в части VII.
Имеет смысл попытаться определить основные операции, которые будут выполняться для решения задачи связности, чтобы любой алгоритм, разрабатываемый для ее решения, был полезен и для ряда аналогичных задач. В частности, при получении каждой новой пары вначале необходимо определить, представляет ли она новое соединение, а затем внедрить информацию об обнаруженном соединении в общую картину о связности объектов для проверки соединений, которые будут наблюдаться в будущем. Мы инкапсулируем эти две задачи в виде абстрактных операций, считая целочисленные вводимые значения представляющими элементы в абстрактных наборах, а затем разработаем алгоритмы и структуры данных, которые могут:
- находить набор, содержащий данный элемент
- замещать наборы, содержащие два данных элемента, их объединением.
Организация алгоритмов посредством этих абстрактных операций, похоже, не отсекает никакие варианты решения задачи связности, и эти операции могут оказаться полезными при решении других задач. Разработка уровней абстракции с еще большими возможностями — важный процесс в компьютерных науках в целом и в разработке алгоритмов в частности, и в этом курсе мы будем обращаться к нему многократно. В данной лекции для разработки программ решения задачи связности мы используем неформальное абстрактное представление; а в лекция №4 эти абстракции будут выражены в коде C++.
Задача связности легко решается с помощью абстрактных операций поиск (find) и объединение (union). После считывания новой пары p-q мы выполняем операцию поиск для каждого члена пары. Если оба члена пары находятся в одном множестве, мы переходим к следующей паре; если нет, то выполняем операцию объединение и записываем пару. Наборы представляют собой связанные компоненты: подмножества объектов, характеризующиеся тем, что любые два объекта в данном компоненте связаны. Этот подход сводит разработку алгоритмического решения задачи связности к задачам определения структуры данных, которая представляет множества, и разработке алгоритмов объединение и поиск, которые эффективно используют эту структуру данных.
Существует много возможных способов представления и обработки абстрактных множеств. В этой лекции основное внимание уделяется поиску представления, которое может эффективно поддерживать операции объединение и поиск, необходимые для решения задачи связности.
Упражнения
1.1. Приведите выходные данные, которые должен выдавать алгоритм связности для входных пар 0-2, 1-4, 2-5, 3-6, 0-4, 6-0 и 1-3.
1.2. Перечислите все различные способы связывания двух различных объектов, показанных в примере на рис. 1.1.
1.3. Опишите простой метод подсчета количества наборов, остающихся после применения операций объединение и поиск для решения задачи связности, как описано в тексте.
Алгоритмы объединения и поиска
Первый шаг в процессе разработки эффективного алгоритма решения данной задачи — реализация простого алгоритма ее решения. Если нужно решить несколько вариантов конкретной задачи, которые оказываются простыми, это может быть выполнено посредством простой реализации. Если требуется более сложный алгоритм, то простая реализация предоставляет возможность проверить правильность работы алгоритма для простых случаев и может служить базовым замером для оценки характеристик производительности. Эффективность важна всегда, но при разработке первой программы решения задачи в первую очередь необходимо убедиться в том, что программа обеспечивает правильное решение.
Первое, что приходит в голову — организовать способ сохранения всех вводимых пар, а затем создать функцию для их просмотра, чтобы попытаться выяснить, связана ли очередная пара объектов. Однако мы используем другой подход. Во-первых, количество пар может быть настолько велико, что не позволит хранить их все в памяти в используемом на практике приложении. Во-вторых, что гораздо важнее, не существует никакого простого метода, который сам по себе позволяет определить, связаны ли два объекта в наборе всех соединений, даже если бы удалось их все сохранить! Базовый метод, использующий этот подход, рассматривается в лекция №5, а методы, которые рассматриваются в этой лекции, проще, поскольку они решают менее сложную задачу, и эффективнее, поскольку не требуют хранения всех пар. Для хранения информации, необходимой для реализации операций объединение и поиск, все эти методы используют массив целых чисел, каждое из которых соответствует отдельному объекту.
Массивы — это элементарные структуры данных, которые подробно будут рассмотрены в лекция №3. Здесь же они используются в простейшей форме: мы объявляем, что собираемся использовать, скажем, 1000 целых чисел, записывая a[1000], а затем обращаемся к i-ому целому числу в массиве с помощью записи a[i] для .
Программа 1.1 — реализация простого алгоритма, называемого алгоритмом быстрого поиска, который решает задачу связности. В основе этого алгоритма лежит использование массива целых чисел, обладающих тем свойством, что p и q связаны тогда и только тогда, когда p-й и q-й элементы массива равны. Вначале i-й элемент массива инициализируется значением i, . Чтобы реализовать операцию объединение для p и q, мы просматриваем массив, заменяя все элементы с тем же именем, что и p, на элементы с тем же именем, что и q. Этот выбор произволен — можно было бы все элементы с тем же именем, что и q, заменять на элементы с тем же именем, что и p.
Изменения в массиве при выполнении операций объединение в примере из рис. 1.1 показаны на рис. 1.3. Для реализации операции поиск достаточно проверить указанные записи массива на предмет равенства — отсюда и название быстрый поиск (quick find). Однако операция объединение требует просмотра всего массива для каждой вводимой пары.
Лемма 1.1. Алгоритм быстрого поиска выполняет не менее M x N инструкций для решения задачи связности при наличии N объектов, для которых требуется выполнение M операций объединения.
Для каждой из M операций объединение цикл for выполняется N раз. Для каждой итерации требуется выполнение, по меньшей мере, одной инструкции (если только проверять, завершился ли цикл).
Программа 1.1. Решение задачи связности методом быстрого поиска
Эта программа считывает из стандартного ввода последовательность пар неотрицательных целых чисел, меньших чем N (интерпретируя пару p q как указание "связать объект p с объектом q"), и выводит пары, соответствующие еще не связанным объектам. В ней используется массив id, содержащий элемент для каждого объекта и характеризующийся тем, что элементы id[p] и id[q] равны тогда и только тогда, когда объекты p и q связаны. Для простоты N определена как константа времени компиляции. Иначе можно было бы считывать ее из ввода и выделять массив id динамически (см. лекция №3).
#include <iostream.h> static const int N = 10000; int main() { int i, p, q, id[N]; for ( i = 0; i < N; i++) id[i] = i; while (cin >> p >> q) { int t = id[p]; if (t == id[q]) continue; for ( i = 0; i < N; i++) if (id[i] == t) id[i] = id[q]; cout << " " << p << " " << q << endl; } }

Рис. 1.3. Пример быстрого поиска (медленное объединение)
Здесь изображено содержимое массива id после обработки каждой пары, приведенной слева, алгоритмом быстрого поиска (программа 1.1). Затененные записи — те, которые изменяются для выполнения операции объединение. При обработке пары p q во все записи со значением id[p] заносится значение из id[q].
Современные компьютеры могут выполнять десятки или сотни миллионов инструкций в секунду, поэтому для малых значений M и N эти затраты не заметны, но в современных приложениях может потребоваться обработка миллиардов объектов и миллионов вводимых пар. Поэтому мы неизбежно приходим к заключению, что подобную проблему нельзя решить приемлемым образом, используя алгоритм быстрого поиска (см. упражнение 1.10). Строгое обоснование этого заключения приведено в лекция №2.
Графическое представление массива, показанного на рис. 1.3, приведено на рис. 1.4. Можно считать, что некоторые объекты представляют множество, к которому они принадлежат, а остальные указывают на представителя их множества. Причина обращения к такому графическому представлению массива вскоре станет понятна. Обратите внимание, что связи между объектами в этом представлении не обязательно соответствуют связям во вводимых парах — они представляют собой информацию, запоминаемую алгоритмом, которая позволяет определить, соединены ли пары, которые будут вводиться в будущем.
А теперь мы рассмотрим дополнительный к предыдущему метод, который называется алгоритмом быстрого объединения. В его основе лежит та же структура данных — индексированный по именам объектов массив — но в нем используется иная интерпретация значений, что приводит к более сложной абстрактной структуре. Каждый объект указывает на другой объект в этом же множестве, образуя структуру, не содержащую циклов. Чтобы определить, находятся ли два объекта в одном множестве, мы следуем указателям для каждого из них до тех пор, пока не будет достигнут объект, который указывает на самого себя. Объекты находятся в одном множестве тогда и только тогда, когда этот процесс приводит от них к одному и тому же объекту. Если они не находятся в одном множестве, процесс завершится на разных объектах (которые указывают на себя). Тогда для образования объединения достаточно связать один объект с другим — отсюда и название быстрое объединение (quick union).
На рис. 1.5 показано графическое представление, которое соответствует рис. 1.4 при выполнении алгоритма быстрого объединения для массива, изображенного на рис. 1.1, а на рис. 1.6 показаны соответствующие изменения в массиве id. Графическое представление структуры данных позволяет сравнительно легко понять действие алгоритма — пары объектов, которые соединены во входных данных, связаны один с другим и в структуре данных. Обратите внимание: здесь, как и ранее, связи в структуре данных не обязательно совпадают со связями, заданными вводимыми парами; вместо этого они создаются алгоритмом так, чтобы обеспечить эффективную реализацию операций объединение и поиск.

Рис. 1.4. Представление быстрого поиска в виде дерева
На этом рисунке показано графическое представление примера, приведенного на рис. 1.3. Связи на этом рисунке не обязательно представляют связи во входных данных. Например, структура, показанная на нижнем рисунке, содержит связь 1-7, которая отсутствует во входных данных, но образуется в результате цепочки связей 7-3-4-9-5-6-1.

Рис. 1.5. Представление быстрого объединения в виде дерева
Этот рисунок — графическое представление примера, показанного на рис. 1.3. Мы проводим линию от объекта i к объекту id[i].

Рис. 1.6. Пример быстрого объединения (не очень быстрый поиск)
Здесь изображено содержимое массива id после обработки каждой из показанных слева пар алгоритмом быстрого поиска (программа 1.1). Затененные элементы — те, которые изменяются для выполнения операции объединения (по одной на каждую операцию). При обработке пары p q мы переходим по указателям из p до записи i, у которой id[i] == i; потом переходим по указателям из q до записи j, у которой id[j] == j; затем, если i и j различны, устанавливаем id[i] = id[j]. При выполнении операции поиска для пары 5-8 (последняя строка) i принимает значения 5 6 9 0 1, а j — значения 8 0 1.
Связанные компоненты, изображенные на рис. 1.5, называются деревьями (tree) — это основополагающие комбинаторные структуры, которые многократно встречаются в курсе. Свойства деревьев будут подробно рассмотрены в лекция №5. Деревья, изображенные на рис. 1.5, удобны для выполнения операций объединение и поиск, поскольку их можно быстро построить, и они характеризуются тем, что два объекта связаны в дереве тогда и только тогда, когда объекты связаны во входных данных. Перемещаясь вверх по дереву, можно легко отыскать корень дерева, содержащего каждый объект, и, следовательно, можно выяснить, связаны они или нет. Каждое дерево содержит только один объект, указывающий сам на себя, называемый корнем (root) дерева. Указатель на себя на диаграммах не показан. Начав с любого объекта дерева и перемещаясь к объекту, на который он указывает, затем следующему указанному объекту и т.д., мы всегда со временем попадаем к корню. Справедливость этого свойства можно доказать методом индукции: это справедливо после инициализации массива, когда каждый объект указывает на себя, и если это справедливо до выполнения любой операции объединение, это безусловно справедливо и после нее.
Диаграммы для алгоритма быстрого поиска, показанные на рис. 1.4, характеризуются теми же свойствами, которые описаны в предыдущем абзаце. Различие состоит в том, что в деревьях быстрого поиска мы достигаем корня из всех узлов, переходя лишь по одной ссылке, а в дереве быстрого объединения для достижения корня может потребоваться переход по нескольким ссылкам.
Программа 1.2 — реализация операций объединение и поиск, образующих алгоритм быстрого объединения для решения задачи связности. Похоже, что алгоритм быстрого объединения работает быстрее алгоритма быстрого поиска, поскольку для каждой вводимой пары ему не нужно просматривать весь массив; но насколько быстрее? В данном случае ответить на этот вопрос труднее, чем в случае быстрого поиска, поскольку время выполнения в большей степени зависит от характера входных данных. Выполнив экспериментальные исследования или математический анализ (см. лекция №2), можно показать, что программа 1.2 значительно эффективнее программы 1.1, и ее можно использовать для решения очень сложных реальных задач. Одно из таких экспериментальных исследований будет рассмотрено в конце этого раздела. А пока быстрое объединение можно считать усовершенствованием, поскольку оно устраняет основной недостаток алгоритма быстрого поиска (тот факт, что для выполнения M операций объединение между N объектами программе требуется выполнение, по меньшей мере, N M инструкций).
Программа 1.2. Решение задачи связности методом быстрого объединения
Если тело цикла while в программе 1.1 заменить этим кодом, мы получим программу, которая соответствует тем же спецификациям, что и программа 1.1, но выполняет меньше вычислений для операции объединение за счет выполнения большего количества вычислений для операции поиск. Циклы for и последующий оператор if в этом коде определяют необходимые и достаточные условия связности p и q в массиве id. Оператор присваивания id[i] = j реализует операцию объединение.
for (i = p; i != id[i]; i = id[i]) ; for (j = q; j != id[j]; j = id[j]) ; if (i == j) continue; id[i] = j; cout << " " << p << " " << q << endl;
Это различие между быстрым объединением и быстрым поиском действительно повышает производительность, однако у быстрого объединения есть недостаток: нельзя гарантировать, что оно будет выполняться существенно быстрее быстрого поиска в каждом случае, поскольку характер входных данных может замедлить операцию поиск.
Лемма 1.2. Для M пар из N объектов, когда M > N , решение задачи связности алгоритмом быстрого объединения может потребовать выполнения более чем MN/2 инструкций.
Предположим, что пары вводятся в следующем порядке: 1-2, 2-3, 3-4 и т.д. После ввода N — 1 таких пар мы получим N объектов, принадлежащих к одному множеству, а сформированное алгоритмом быстрого объединения дерево представляет собой прямую линию, где объект N указывает на объект N — 1 , тот, в свою очередь, — на объект N — 2, тот — на N — 3 и т.д. Чтобы выполнить операцию поиск для объекта N, программа должна перейти по N — 1 указателям. Таким образом, среднее количество указателей, по которым выполняются переходы для первых N пар, равно
(0 + 1 +...+ (N - 1))/N = (N-1)/2
Теперь предположим, что все остальные пары связывают объект N с каким-либо другим объектом. Чтобы выполнить операцию поиск для каждой из этих пар, требуется перейти, по меньшей мере, по (N - 1) указателям. Общий итог для M операций поиск при такой последовательности вводимых пар определенно больше M N/2.
К счастью, можно легко модифицировать алгоритм, чтобы худшие случаи, подобные этому, гарантированно не имели места. При выполнении операции объединение можно не произвольным образом соединять второе дерево с первым, а отслеживать количество узлов в каждом дереве и всегда соединять меньшее дерево с большим. Это изменение требует несколько более объемного кода и наличия еще одного массива для хранения счетчиков узлов, как показано в программе 1.3, но оно ведет к существенному повышению эффективности. Мы будем называть этот алгоритм алгоритмом взвешенного быстрого объединения (weighted quick-union algorithm).
Программа 1.3. Взвешенная версия быстрого объединения
Эта программа — модификация алгоритма быстрого объединения (см. программу 1.2), которая в служебных целях для каждого объекта, у которого id[i] == i, поддерживает дополнительный массив sz, где хранятся количества узлов в соответствующих деревьях, чтобы операция объединение могла связывать меньшее из двух указанных деревьев с большим, тем самым предотвращая разрастание длинных путей в деревьях.
#include <iostream.h> static const int N = 10000; int main() { int i, j, p, q, id[N], sz[N]; for (i = 0; i < N; i++) { id[i] = i; sz[i] = 1; } while ( cin >> p >> q) { for (i = p; i != id[i]; i = id[i]) ; for (j = q; j != id[j]; j = id[j]) ; if (i == j) continue; if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i]; } else { id[j] = i; sz[i] += sz[j]; } cout << " " << p << " " << q << endl; } }
На рис. 1.7 показан лес деревьев, созданных алгоритмом взвешенного поиска для примера входных данных с рис. 1.1. Даже в этом небольшом примере пути в деревьях существенно короче, чем в случае невзвешенной версии, приведенной на рис. 1.5.
На рис. 1.8 демонстрируется, что происходит в худшем случае, когда размеры наборов, которые должны быть объединены в операции объединение, всегда равны (и являются степенью 2). Эти структуры деревьев выглядят сложными, но у них есть простое свойство: максимальное количество указателей, по которым необходимо перейти, чтобы добраться до корня в дереве, состоящем из 2n узлов, равно п. При слиянии двух деревьев, состоящих из 2n узлов, получается дерево, состоящее из 2n+1 узлов, а максимальное расстояние до корня увеличивается до п + 1. Это наблюдение можно обобщить для доказательства того, что взвешенный алгоритм значительно эффективнее невзвешенного.
Лемма 1.3. Для определения того, связаны ли два из N объектов, алгоритм взвешенного быстрого объединения переходит максимум по log N указателям.
Можно доказать, что для операции объединение сохраняется свойство, что количество указателей, проходимых из любого узла до корня в множестве к объектов, не превышает log к. При объединении набора, состоящего из i узлов, с набором, состоящим из j узлов, при количество указателей, которые должны отслеживаться в меньшем наборе, увеличивается на 1, но теперь узлы находятся в наборе размера i + j, и свойство остается справедливым, поскольку
.

Рис. 1.7. Представление взвешенного быстрого объединения в виде дерева
На этой последовательности рисунков демонстрируется результат изменения алгоритма быстрого объединения, когда корень меньшего из двух деревьев связывается с корнем большего. Расстояние от каждого узла до корня его дерева невелико, поэтому операция поиска выполняется эффективно.

Рис. 1.8. Взвешенное быстрое объединение (худший случай)
Наихудшая ситуация для алгоритма взвешенного быстрого объединения — когда каждая операция объединения связывает деревья одинакового размера. Если количество объектов меньше 2n, расстояние от любого узла до корня его дерева меньше п.
Практическая польза леммы 1.3 заключается в том, что количество инструкций, которые алгоритм взвешенного быстрого объединения использует для обработки M ребер между N объектами, не превышает Mlog N, умноженного на некоторую константу (см. упражнение 1.9). Этот вывод резко отличается от вывода, что алгоритм быстрого поиска всегда (а алгоритм быстрого объединения иногда) использует не менее M N/ 2 инструкций. Таким образом, при использовании взвешенного быстрого объединения можно гарантировать решение очень сложных встречающихся на практике задач за приемлемое время (см. упражнение 1.11). Ценой добавления нескольких дополнительных строк кода мы получаем программу, которая при решении очень сложных задач, которые могут встретиться на практике, работает буквально в миллионы раз быстрее, чем более простые алгоритмы.
Из приведенных диаграмм видно, что лишь сравнительно небольшое количество узлов располагаются далеко от корня; действительно, экспериментальное изучение очень сложных задач показывает, что, как правило, для решения практических задач посредством использования алгоритма взвешенного быстрого объединения, реализованного в программе 1.3, требуется линейное время. То есть затраты времени на выполнение алгоритма равны затратам времени на считывание входных данных с постоянным коэффициентом. Вряд ли можно было бы рассчитывать найти более эффективный алгоритм.
Тут же возникает вопрос: можно ли найти алгоритм, обеспечивающий гарантированную линейную производительность. Этот вопрос — исключительно трудный, который уже много лет не дает покоя исследователям (см. лекция №2). Существует множество способов дальнейшего совершенствования алгоритма взвешенного быстрого объединения. В идеале было бы желательно, чтобы каждый узел указывал непосредственно на корень своего дерева, но не хотелось бы расплачиваться за это изменением большого количества указателей, как в алгоритме быстрого объединения. К идеалу можно приблизиться, просто делая все проверяемые узлы указывающими на корень. На первый взгляд этот шаг кажется весьма радикальным, но его легко реализовать, а в структуре этих деревьев нет ничего неприкосновенного, и если их можно изменить, чтобы сделать алгоритм более эффективным, то так и следует сделать. Этот метод, названный сжатием пути (path compression), можно легко реализовать, добавляя еще один проход по каждому пути во время выполнения операции объединение и занося в элемент id, соответствующий каждой встреченной вершине, указатель на корень. В результате деревья становятся почти совершенно плоскими, приближаясь к идеалу, обеспечиваемому алгоритмом быстрого поиска (см. рис. 1.9). Анализ, устанавливающий этот факт, исключительно сложен, но сам метод прост и эффективен. Результат сжатия пути для большого примера показан на рис. 1.11.
Существует множество других способов реализации сжатия пути. Например, программа 1.4 представляет собой реализацию, которая сжимает пути, сдвигая каждую ссылку на следующий узел в пути вверх по дереву (см. рис. 1.10). Этот метод несколько проще реализовать, чем полное сжатие пути (см. упражнение 1.16), но он дает тот же конечный результат. Мы называем этот вариант взвешенным быстрым объединением со сжатием пути делением пополам (weighted quick-union with path compression by halving). Какой из этих методов эффективнее? Оправдывает ли достигаемая экономия время, требующееся для реализации сжатия пути? Существует ли какая-либо иная технология, применение которой следовало бы рассмотреть? Чтобы ответить на эти вопросы, следует внимательнее рассмотреть алгоритмы и их реализации. Мы вернемся к этой теме в лекция №2 в контексте рассмотрения основных подходов к анализам алгоритмов.

Рис. 1.9. Сжатие пути
Пути в деревьях можно сделать еще короче, просто занося во все просматриваемые объекты указатели на корень нового дерева во время операции объединения, как показано в этих двух примерах. В примере на верхнем рисунке показан результат, соответствующий рис. 1.7. В случае коротких путей сжатие пути не оказывает никакого влияния, но после обработки пары 1 6 узлы 1, 5 и 6 указывают на узел 3, в результате чего дерево становится более плоским, чем на рис. 1.7. В примере на нижнем рисунке показан результат, соответствующий рис. 1.8. В деревьях могут появляться пути, которые содержат больше одной-двух связей, но при каждом прохождении они становятся более плоскими. В данном случае после обработки пары 6 8 дерево становится более плоским, а узлы 4, 6 и 8 указывают на узел 0.

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

Рис. 1.11. Большой пример влияния сжатия пути
Здесь отображен результат обработки случайных пар из 100 объектов алгоритмом взвешенного быстрого объединения со сжатием пути. Все узлы этого дерева, кроме двух, находятся на расстоянии одного-двух шагов от корня.
Программа 1.4. Сжатие пути делением пополам
Если циклы for в программе 1.3 заменить этим кодом, длина любого проходимого пути будет уменьшаться в два раза. Конечный результат этого изменения — превращение деревьев в почти совершенно плоские после выполнения длинной последовательности операций.
for (i = p; i != id[i]; i = id[i]) id[i] = id[id[i]]; for (j = q; j != id[j]; j = id[j]) id[j] = id[id[j]];
Конечный результат применения рассмотренных алгоритмов решения задачи связности приближается к наилучшему, на который можно было бы рассчитывать в любом практическом случае. Мы имеем легко реализуемые алгоритмы, время выполнения которых гарантированно пропорционально затратам времени на ввод данных с постоянным коэффициентом. Более того, алгоритмы являются оперативными: они рассматривают каждое ребро только один раз и используют объем памяти, который пропорционален количеству объектов; поэтому какие-либо ограничения на количество обрабатываемых ими ребер отсутствуют. Результаты экспериментального исследования, приведенные в табл. 1.1 таблица 1.1, подтверждают вывод, что программа 1.3 и ее варианты с использованием сжатия пути полезны даже в очень больших практических приложениях. Выбор лучшего из этих алгоритмов требует тщательного и сложного анализа (см. лекция №2.)
N | M | F | U | W | P | H |
---|---|---|---|---|---|---|
1000 | 6206 | 14 | 25 | 6 | 5 | 3 |
2500 | 20236 | 82 | 210 | 13 | 15 | 12 |
5000 | 41913 | 304 | 1172 | 46 | 26 | 25 |
10000 | 83857 | 1216 | 4577 | 91 | 73 | 50 |
25000 | 309802 | 219 | 208 | 216 | ||
50000 | 708701 | 469 | 387 | 497 | ||
100000 | 1545119 | 1071 | 1106 | 1096 |
Обозначения:
F | быстрый поиск (программа 1.1) |
U | быстрое объединение (программа 1.2) |
W | взвешенное быстрое объединение (программа 1.3) |
P | взвешенное быстрое объединение со сжатием пути (упражнение 1.16) |
H | взвешенное быстрое объединение с делением пополам (программа 1.4) |
Эти сравнительные значения времени, затрачиваемого на решение случайных задач связности с использованием алгоритмов объединения-поиска, демонстрируют эффективность взвешенной версии алгоритма быстрого объединения. Дополнительный выигрыш благодаря использованию сжатия пути менее важен. Здесь M — количество случайных соединений, генерируемых до тех пор, пока все N объектов не оказываются связанными. Этот процесс требует значительно больше операций поиск, чем операций объединение, поэтому быстрое объединение выполняется существенно медленнее быстрого поиска. Ни быстрый поиск, ни быстрое объединение не годятся для очень больших N. Время выполнения при использовании взвешенных методов явно пропорционально значению N, поскольку оно уменьшается вдвое при уменьшении N вдвое.
Упражнения
1.4. Приведите содержимое массива id после выполнения каждой операции объединение при использовании алгоритма быстрого поиска (программа 1.1) для решения задачи связности для последовательности 0-2, 1-4, 2-5, 3-6, 0-4, 6-0 и 1-3. Укажите также количество обращений программы к массиву id для каждой вводимой пары.
1.5. Выполните упражнение 1.4, но для алгоритма быстрого объединения (программа 1.2).
1.6. Приведите содержимое массива id после выполнения каждой операции объединение для алгоритма взвешенного быстрого объединения применительно к примерам, соответствующим рис. 1.7 и рис. 1.8.
1.7. Выполните упражнение 1.4, но для алгоритма взвешенного быстрого объединения (программа 1.3).
1.8. Выполните упражнение 1.4, но для алгоритма взвешенного быстрого объединения со сжатием пути делением пополам (программа 1.4).
1.9. Определите верхнюю границу количества машинных инструкций, требующихся для обработки M соединений N объектов при использовании программы 1.3. Например, можно предположить, что для выполнения оператора присваивания C+ + всегда требуется выполнение менее c инструкций, где c — некоторая фиксированная константа.
1.10. Определите минимальное время (в днях), которое потребовалось бы для выполнения быстрого поиска (программа 1.1) для решения задачи с 109 объектов и 106 вводимых пар на компьютере, который может выполнять 109 инструкций в секунду. Считайте, что при каждой итерации внутреннего цикла for должно выполняться не менее 10 инструкций.
1.11. Определите максимальное время (в секундах), которое потребовалось бы для выполнения взвешенного быстрого объединения (программа 1.3) для решения задачи с 1099 объектов и 106 вводимых пар на компьютере, который может выполнять 109 инструкций в секунду. Считайте, что при каждой итерации внешнего цикла while должно выполняться не более 100 инструкций.
1.12. Вычислите среднее расстояние от узла до корня в худшем случае в дереве из 2n узлов, построенном алгоритмом взвешенного быстрого объединения.
1.13. Нарисуйте диаграмму, подобную рис. 1.10, но начав с восьми узлов, а не девяти.
1.14. Приведите последовательность вводимых пар, для которой алгоритм взвешенного быстрого объединения (программа 1.3) создает путь длиной 4.
1.15. Приведите последовательность вводимых пар, для которой алгоритм взвешенного быстрого объединения с сжатием пути делением пополам (программа 1.4) создает путь длиной 4.
1.16. Покажите, как необходимо изменить программу 1.3, чтобы реализовать полное сжатие пути, при котором после каждой операции объединение все обработанные узлы указывают на корень нового дерева.
1.17. Выполните упражнение 1.4, но используйте алгоритм взвешенного быстрого объединения с полным сжатием пути (упражнение 1.16).
1.18. Приведите последовательность вводимых пар, для которой алгоритм взвешенного быстрого объединения с полным сжатием пути (см. упражнение 1.16) создает путь длиной 4.
1.19. Приведите пример, показывающий, что изменения быстрого объединения (программа 1.2) для реализации полного сжатия пути (см. упражнение 1.16) не достаточно, чтобы гарантировать отсутствие длинных путей в деревьях.
1.20. Измените программу 1.3, чтобы в ней для принятия решения, нужно ли устанавливать id[i] = j или id[j] = i, вместо веса использовалась высота деревьев (самый длинный путь от любого узла до корня). Экспериментально сравните этот вариант с программой 1.3.
1.21. Покажите, что лемма 1.3 справедлива для алгоритма, описанного в упражнении 1.20.
1.22. Измените программу 1.4, чтобы она генерировала случайные пары целых чисел в диапазоне от 0 до N— 1 вместо того, чтобы считывать их из стандартного ввода, и выполняла цикл до тех пор, пока не будет выполнено N — 1 операций объединение. Выполните программу для значений N = 103, 104, 105 и 106 и выведите общее количество ребер, генерируемых для каждого значения N.
1.23. Измените программу из упражнения 1.22, чтобы она выводила в виде графика количество ребер, требующихся для соединения N элементов,100 < N< 1000.
1.24. Приведите приближенную формулу для определения количества случайных ребер, требующихся для соединения N объектов, как функции от N.
Перспективы
Каждый из алгоритмов, рассмотренных в разделе 1.3, кажется определенным усовершенствованием предыдущего, но, вероятно, процесс был искусственно упрощен, поскольку разработка самих этих алгоритмов уже была выполнена рядом исследователей в течение многих лет (см. раздел использованной литературы). Реализации просты, а задача четко определена, поэтому мы можем оценить различные алгоритмы непосредственно, экспериментальным путем. Более того, мы можем подкрепить эти исследования, количественно сравнив производительность алгоритмов (см. лекция №2). Не все задачи, рассмотренные в этом курсе, столь же хорошо проработаны, как эта, и мы обязательно встретимся с алгоритмами, которые трудно сравнить, и с математическими задачами, которые трудно решить. Мы стремимся принимать объективные научно обоснованные решения об используемых алгоритмах, изучая свойства реализаций на примере их выполнения применительно к реальным данным, полученным из приложений, или случайным тестовым наборам данных.
Этот процесс — прототип того, как различные алгоритмы решения фундаментальных задач рассматриваются в данном курсе. Когда это возможно, мы предпринимаем те же основные шаги, что и при рассмотрении алгоритмов объединения-поиска, описанных в разделе 1.2, часть из которых перечислена ниже:
- Формулирование полной и частной задачи, включая определение основных абстрактных операций, присущих задаче.
- Тщательная разработка краткой реализации для простого алгоритма.
- Разработка усовершенствованных реализаций путем пошагового улучшения, проверки эффективности идей усовершенствований посредством эмпирического или математического анализа, либо их обоих.
- Определение высокоуровневых абстрактных представлений структур данных или алгоритмов посредством операций, которые позволяют эффективно разрабатывать усовершенствованные версии на высоком уровне.
- Стремление к получению гарантированной производительности в худшем случае, когда это возможно, но принятие хорошей производительности при работе с реальными данными, когда это доступно.
Потенциальная возможность впечатляющего повышения производительности алгоритмов решения реальных задач, подобных рассмотренным в разделе 1.2, делает разработку алгоритмов увлекательной областью исследования; лишь немногие другие области разработки позволяют обеспечить экономию времени в миллионы, миллиарды и более раз.
Что еще важнее, по мере повышения вычислительных возможностей компьютеров и приложений разрыв между быстрыми и медленными алгоритмами увеличивается. Новый компьютер может работать в 10 раз быстрее и может обрабатывать в 10 раз больше данных, чем старый, но при использовании квадратичного алгоритма, наподобие быстрого поиска, новому компьютеру потребуется в 10 раз больше времени для выполнения новой задачи, чем требовалось старому для выполнению старой! Вначале это утверждение кажется противоречивым, но его легко подтвердить простым тождеством (10N)2/10 = 10N2 , как будет показано в лекция №2. По мере того как вычислительные мощности увеличиваются, позволяя решать все более сложные задачи, важность использования эффективных алгоритмов также возрастает.
Разработка эффективного алгоритма приносит моральное удовлетворение и непосредственно окупается на практике. Как видно на примере решения задачи связности, просто сформулированная задача может приводить к изучению множества алгоритмов, которые не только полезны и интересны, но и представляют увлекательную и сложную для понимания задачу. Мы встретимся со многими остроумными алгоритмами, которые были разработаны за долгие годы для решения множества практических проблем. По мере расширения области применения компьютерных решений для научных и экономических проблем возрастает важность использования эффективных алгоритмов для решения известных задач и разработки эффективных решений для новых проблем.
Упражнения
1.25. Допустим, что взвешенное быстрое объединение используется для обработки в 10 раз большего количества соединений на новом компьютере, который работает в 10 раз быстрее старого. На сколько больше времени потребуется для выполнения новой задачи на новом компьютере по сравнению с выполнением старой задачи на старом компьютере?
1.26. Выполните упражнение 1.25 для случая использования алгоритма, который требует выполнения N3 инструкций.
Обзор тем
В этом разделе приведены краткие описания основных частей курса и раскрытых в них отдельных тем с указанием общего подхода к материалу. Выбор тем диктовался стремлением осветить как можно больше базовых алгоритмов. Некоторые из освещенных тем относятся к фундаментальным темам компьютерных наук, которые мы подробно изучаем для изучения основных алгоритмов широкого применения. Другие рассмотренные алгоритмы относятся к специализированным областям компьютерных наук и связанным с ними областям, таким как численный анализ и исследование операций — в этих случаях приведенный материал служит введением в эти области через исследование базовых методов.
В первых четырех частях, включенных в этот том, освещен наиболее широко используемый набор алгоритмов и структур данных — первый уровень абстракции для коллекций объектов с ключами, который может поддерживать широкое множество важных основополагающих алгоритмов. Рассматриваемые алгоритмы — результаты десятков лет исследований и разработки, и они продолжают играть важную роль во все более широком применении компьютеров.
- Анализ (Часть I) в контексте данного курса это основные принципы и методология, используемые для реализации, анализа и сравнения алгоритмов. Материал, приведенный в лекция №1, служит обоснованием изучения разработки и анализа алгоритмов; в лекция №2 рассмотрены основные методы получения информации о количественных показателях производительности алгоритмов.
- Структуры данных (Часть II) тесно связаны с алгоритмами: необходимо получить ясное представление о методах представления данных, которые используются во всех остальных частях курса. Изложение материала начинается с введения в базовые структуры данных в лекция №3: массивы, связанные списки и строки; затем в лекция №5 будут рассмотрены рекурсивные программы и структуры данных — а именно, деревья и алгоритмы для манипулирования ими. В лекция №4 рассмотрены основные абстрактные типы данных (abstract data types — ADT), такие как стеки и очереди, а также реализации с использованием элементарных структур данных.
- Алгоритмы сортировки (Часть III), предназначенные для упорядочения файлов, имеют особую важность. Мы достаточно глубоко рассмотрим ряд базовых алгоритмов, в том числе быструю сортировку, сортировку слиянием и поразрядную сортировку. Попутно будут рассмотрены и несколько связанных задач: очереди приоритетов, выбор и слияние. Многие из этих алгоритмов лягут в основу других алгоритмов, рассматриваемых в последующих частях курса.
- Алгоритмы поиска (Часть IV), предназначенные для поиска конкретных элементов в больших коллекциях элементов, также имеют важное значение. Мы рассмотрим основные и усовершенствованные методы поиска с использованием деревьев и преобразований числовых ключей: деревья бинарного поиска, сбалансированные деревья, хеширование, деревья цифрового поиска и методы, пригодные для очень больших файлов. Мы отметим взаимосвязь между этими методами, приведем статистические данные об их сравнительной производительности и установим соответствие с методами сортировки.
В частях с V по VIII, которые вынесены в отдельные тома, описаны дополнительные применения описанных алгоритмов — второй уровень абстракций, характерный для ряда важных областей применения. Кроме того, в этих частях более глубоко рассмотрены технологии разработки и анализа алгоритмов. Многие из затрагиваемых проблем являются предметом предстоящих исследований.
- Алгоритмы на графах (Часть V) полезны при решении ряда сложных и важных задач. Общая стратегия поиска в графах разрабатывается и применяется к фундаментальным задачам связности, в том числе к задаче отыскания кратчайшего пути, минимального остовного дерева, к задаче о сетевом потоке и к задаче соответствия. Унифицированный подход к этим алгоритмам показывает, что все они основываются на одной и той же процедуре, и что эта процедура основывается на основном абстрактном типе данных очереди приоритетов.
- Алгоритмы обработки строк (часть VI) включают в себя ряд методов обработки последовательностей символов (длинных). Поиск строк требует сравнения с шаблоном, что, в свою очередь, приводит к синтаксическому анализу. В этой же части рассматриваются и технологии сжатия файлов. Опять-таки, введение в более сложные темы выполняется через рассмотрение некоторых простых задач, которые важны и сами по себе.
- Геометрические алгоритмы (Часть VII) — это методы решения задач с использованием точек и линий (и других простых геометрических объектов), которые стали использоваться лишь недавно. Мы рассмотрим алгоритмы для отыскания образующей поверхности, определенной набором точек, определения пересечений геометрических объектов, решения задач отыскания ближайших точек и для выполнения многомерного поиска. Многие из этих методов прекрасно дополняют более элементарные методы сортировки и поиска.
- Дополнительные темы (Часть VIII) устанавливают соответствие между изложенным в курсе материалом и несколькими другими областями. Изложение материала начинается с рассмотрения базовых подходов к разработке и анализу алгоритмов, в том числе алгоритмов типа "разделяй и властвуй", динамического программирования, рандомизации и амортизации. Мы рассмотрим линейное программирование, быстрое преобразование Фурье, NP-полноту и другие дополнительные темы для получения общего представления о ряде областей, интерес к которым порождается элементарными проблемами, рассмотренными в этом курсе.
Изучение алгоритмов представляет интерес, поскольку это новая отрасль (почти все изученные в этом курсе алгоритмы не старше 50 лет, а некоторые были открыты лишь недавно) с богатыми традициями (некоторые алгоритмы известны уже в течение тысяч лет). Постоянно делаются новые открытия, но лишь немногие алгоритмы исследованы полностью. В этом курсе мы рассмотрим замысловатые, сложные и трудные алгоритмы, наряду с изящными, простыми и легко реализуемыми алгоритмами. Наша задача — понять первые и оценить вторые в контексте множества различных потенциально возможных приложений. В процессе этого нам предстоит исследовать ряд полезных инструментов и выработать стиль алгоритмического мышления, который пригодится при решении предстоящих вычислительных задач.
Лекция 2. Принципы анализа алгоритмов
Анализ - это ключ к пониманию алгоритмов в степени, достаточной для их эффективного применения к практическим задачам. Хотя у нас нет возможности проводить исчерпывающие эксперименты и глубокий математический анализ каждой программы, мы можем задействовать и эмпирическое тестирование, и приближенный анализ, которые помогут нам изучить основные характеристики производительности наших алгоритмов, чтобы можно было сравнивать различные алгоритмы и применять их для практических целей.
Сама идея точного описания производительности сложного алгоритма с помощью математического анализа кажется на первый взгляд устрашающей перспективой, поэтому мы будем часто обращаться к исследовательской литературе за результатами подробного математического изучения. Хотя целью данной книги не является обзор методов анализа или их результатов, для нас важно знать, что при сравнении различных методов мы находимся на твердой теоретической почве. Более того, тщательное применение относительно простых методов позволяет получить о многих алгоритмах большой объем подробной информации. Во всей книге мы отводим главное место простым аналитическим результатам и методам анализа, особенно тогда, когда это может помочь нам в понимании внутреннего механизма фундаментальных алгоритмов. В этой главе наша основная цель - обеспечить среду и инструменты, необходимые для работы с алгоритмами.
Пример в лекция №1 демонстрирует многие из базовых концепций анализа алгоритмов, поэтому для конкретизации определенных моментов мы будем часто ссылаться на производительность алгоритмов объединение-поиск. Несколько новых примеров будут подробно рассмотрены в разделе 2.6.
Анализ играет определенную роль в каждой точке процесса разработки и реализации алгоритмов. Прежде всего, как было показано, за счет правильного выбора алгоритма можно сократить время выполнения на три-шесть порядков. Чем эффективнее будут рассматриваемые алгоритмы, тем сложнее будет становиться задача выбора между ними, поэтому необходимо подробнее изучить их свойства. В поиске наилучшего (в некотором точном техническом смысле) алгоритма мы будем находить и практически полезные алгоритмы, и теоретические вопросы, требующие решения.
Полный охват методов анализа алгоритмов сам по себе является предметом книги (см. раздел ссылок), и здесь мы рассмотрим лишь основы, которые позволят
- Проиллюстрировать процесс.
- Описать в одном месте используемые математические соглашения.
- Обеспечить основу для обсуждения вопросов более высокого уровня.
- Выработать понимание научной основы результатов, получаемых при анализе алгоритмов.
Алгоритмы и их анализ зачастую переплетены. В этой книге мы не станем углубляться в сложные математические преобразования, но все же проделаем достаточно выкладок, чтобы понять, что представляют собой наши алгоритмы и как их можно использовать наиболее эффективно.
Реализация и эмпирический анализ
Мы разрабатываем и реализуем алгоритмы, создавая иерархию абстрактных операций, которые помогают понять сущность решаемых вычислительных проблем. Этот процесс достаточно важен, но при теоретическом изучении он может увести очень далеко от проблем реального мира. Поэтому в данной книге мы выражаем все рассматриваемые нами алгоритмы реальным языком программирования - С++. Такой подход иногда оставляет очень туманное различие между алгоритмом и его реализацией, но это лишь небольшая плата за возможность работать с конкретной реализацией и учиться на ней.
Несомненно, правильно разработанные программы на реальном языке программирования представляют собой эффективные методы выражения алгоритмов. В этой книге мы рассмотрим большое количество важных и эффективных алгоритмов, кратко и точно реализованных на С++. Описания на обычном языке или абстрактные высокоуровневые представления зачастую неопределённы и незакончены, а реальные реализации заставляют нас находить экономные представления, позволяющие не завязнуть в деталях.
Мы выражаем алгоритмы на С++ , но эта книга об алгоритмах, а не о программировании на С++. Конечно же, мы будем рассматривать реализации на С++ многих важных задач, и когда существует удобный и эффективный способ решить задачу именно средствами С++, мы воспользуемся этим достоинством. Однако подавляющее большинство выводов о реализации алгоритмов применимо к любой современной среде программирования. Перевод программ из лекция №1 и почти всех других программ из данной книги на другой современный язык программирования - это достаточно простая задача. Если в некоторых случаях какой-либо другой язык обеспечивает более эффективный механизм решения определенных задач, мы будем указывать на это. Наша цель - использовать С++ как средство выражения алгоритмов, а не задерживаться на вопросах, специфичных для языка.
Если алгоритм должен быть реализован как часть большой системы, мы используем абстрактные типы данных или аналогичный механизм, который позволяет изменить алгоритмы или детали реализации после того, как станет ясно, какая часть системы заслуживает наиболее пристального внимания. Однако в самом начале потребуется понимание характеристик производительности каждого алгоритма, так как требования системы к проектированию могут в значительной степени повлиять на производительность алгоритма. Подобные исходные решения при разработке необходимо принимать с осторожностью, поскольку в конце часто оказывается, что производительность всей системы зависит от производительности нескольких базовых алгоритмов наподобие тех, которые обсуждаются в этой книге.
Алгоритмы, приведенные в этой книге, нашли эффективное применение во всем многообразии больших программ, операционных систем и прикладных систем. Мы намереваемся описывать алгоритмы и экспериментально исследовать их динамические свойства. Для одних приложений приведенные реализации могут подойти точно, а для других может потребоваться некоторая доработка. Например, при создании реальных систем требуется более защищенный стиль программирования, нежели используемый в книге. Необходимо следить за появляющимися ошибками и сообщать о них, а, кроме того, программы должны быть разработаны таким образом, чтобы их изменение было несложным делом, чтобы их могли быстро читать и понимать другие программисты, чтобы они хорошо сочетались с различными частями системы, и сохранялась возможность их переноса в другие среды.
Однако при анализе каждого алгоритма мы будем уделять основное внимание существенным характеристикам производительности алгоритма. Предполагается, что главный интерес будут вызывать наиболее эффективные алгоритмы, особенно если они еще и более простые.
В любом случае - если нам нужно решение огромной задачи, которую нельзя решить другим способом, или если требуется эффективная реализация важной части системы - для эффективного использования алгоритмов необходимо понимание характеристик производительности алгоритмов. Формирование такого понимания и является целью анализа алгоритмов.
Один из первых шагов в понимании производительности алгоритмов - это эмпирический анализ. Если есть два алгоритма для решения одной задачи, то все естественно: мы запустим оба и увидим, который из них выполняется дольше! Это концепция может показаться слишком очевидной, чтобы о ней стоило говорить, но ее часто упускают из виду при сравнительном анализе алгоритмов. Трудно не заметить, что один алгоритм в 10 раз быстрее другого, если один выполняется 3 секунды, а другой 30 секунд, однако при математическом анализе эту разницу легко упустить из виду как небольшой постоянный множитель. При замерах производительности тщательно выполненных реализаций алгоритмов для типичных данных мы получаем результаты, которые не только являются прямым показателем эффективности, но и содержат информацию, необходимую для сравнения алгоритмов и обоснования прилагаемых математических результатов (см., например, таблица 1.1). Если эмпирическое изучение начинает поглощать значительное количество времени, на помощь приходит математический анализ. Вряд ли стоит ожидать завершения программы в течение часа или целого дня, чтобы убедиться, что она работает медленно - особенно если тот же результат может дать несложный анализ.
Первая проблема, возникающая в эмпирическом анализе - это разработка корректной и полной реализации. Для некоторых сложных алгоритмов эта проблема может оказаться весьма серьезной. Поэтому обычно до того как тратить усилия на реализацию, требуется либо с помощью анализа, либо по аналогии с похожими программами предварительно прикинуть, насколько эффективной может быть данная программа.
Вторая проблема, с которой мы сталкиваемся при эмпирическом анализе, - это определение природы входных данных и других факторов, оказывающих непосредственное влияние на производимые эксперименты. Обычно существуют три основных возможности: реальные данные, случайные данные и ошибочные данные. Реальные данные позволяют точно измерить параметры используемой программы; случайные данные гарантируют, что эксперименты тестируют алгоритм, а не данные; ошибочные данные гарантируют, что программы могут воспринимать любые поданные на их вход данные. Например, при тестировании алгоритмов сортировки можно запустить их с такими данными, как слова из романа "Моби Дик", сгенерированные случайным образом целые числа и файлы, содержащие одинаковые числа. При анализе алгоритмов возникает и задача определения, какие входные данные необходимо использовать для сравнения алгоритмов.
При сравнении различных реализаций легко допустить ошибки, особенно если приходится использовать разные машины, компиляторы или системы, либо очень большие программы с плохо заданными параметрами. Принципиальная опасность при эмпирическом сравнении программ таится в том, что код для одной реализации может быть написан более тщательно, чем для другой. Изобретатель предлагаемого нового алгоритма, скорее всего, обратит больше внимания на все аспекты его реализации, но не потратит слишком много усилий на классический конкурирующий алгоритм. Чтобы быть уверенным в точности эмпирического сравнения алгоритмов, необходимо быть уверенным, что каждой реализации уделялось одинаковое внимание.
Один из подходов, который часто применяется в этой книге, и который был продемонстрирован в лекция №1, заключается в построении алгоритмов за счет внесения небольших изменений в другие алгоритмы, уже существующие для данной задачи - тогда сравнительное изучение даст достоверные результаты. В более общем смысле, мы пытаемся определить важнейшие абстрактные операции и начинаем сравнение алгоритмов с использования таких операций. Например, эмпирические результаты, приведенные в таблица 1.1, почти не зависят от языков и сред программирования, поскольку в них используются аналогичные программы, оперирующие одним и тем же набором базовых операций. Для любой конкретной среды программирования эти числа можно превратить в реальные времена выполнения. Но чаще всего просто требуется узнать, какая из двух программ будет быстрее, или до какой степени определенное изменение может улучшить требования программы к времени или памяти.
Возможно, наиболее распространенной ошибкой при выборе алгоритма является игнорирование характеристик производительности. Более быстрые алгоритмы, как правило, сложнее, чем прямые решения, и разработчики часто предпочитают более медленные алгоритмы, дабы избежать лишних сложностей. Однако, как было показано на примере алгоритмов объединение-поиск, можно добиться значительных улучшений с помощью даже нескольких строк кода. Пользователи удивительно большого числа компьютерных систем теряют много времени в ожидании решения задачи простыми квадратичными алгоритмами, в то время как доступные алгоритмы сложности или линейные алгоритмы ненамного сложнее, но могут решить задачу значительно быстрее. Но когда мы имеем дело с большими задачами, приходится искать наилучший алгоритм, что и будет показано далее.
Возможно, вторая наиболее распространенная ошибка при выборе алгоритма - слишком большое внимание к характеристикам его производительности. Снижение времени выполнения программы в 10 раз несущественно, если ее выполнение занимает несколько микросекунд. Даже если программа требует нескольких минут, время и усилия, необходимые, чтобы она стала выполняться в 10 раз быстрее, могут не стоить того, в особенности, если программа должна применяться лишь несколько раз. Суммарное время, требуемое для реализации и отладки улучшенного алгоритма, может быть значительно больше времени, необходимого для выполнения более медленной программы - ведь часть работы вполне можно доверить компьютеру. Но еще хуже, если потратить много времени и усилий на реализацию идей, которые должны были бы улучшить программу, но на самом деле так не получается.
Мы не можем провести эмпирические тесты для еще не написанной программы, но можем проанализировать свойства программы и оценить потенциальную эффективность предлагаемого улучшения. Не все возможные усовершенствования дают реальный выигрыш в производительности, поэтому нужно научиться оценивать степень улучшений на каждом шаге. Более того, в реализации алгоритма можно включить параметры, и анализ поможет нам выбрать их правильные значения. Наиболее важно то, что понимание фундаментальных свойств программ и природы их требований к ресурсам дает возможность оценить их эффективность на еще не созданных компьютерах или сравнить их с еще не написанными новыми алгоритмами. В разделе 2.2 кратко описывается методология достижения базового понимания производительности алгоритмов.
Упражнения
2.1. Переведите программу из лекция №1 на другой язык программирования и ответьте на вопросы упражнения 1.22 для вашей реализации.
2.2. Сколько времени займет посчитать до 1 миллиарда (не учитывая переполнение)? Определите количество времени, необходимое программе
int i, j, k, count = 0; for (i = 0; i < N; i ++) for (j = 0; j < N; j ++) for (k = 0; k < N; k ++) count ++;
для выполнения в вашей среде программирования для и
. Если в вашем компиляторе имеются средства оптимизации, предназначенные для повышения эффективности программ, проверьте, дают ли они какой-либо результат для этой программы.
Анализ алгоритмов
В этом разделе мы очертим границы применимости математического анализа для сравнения производительности алгоритмов и заложим фундамент, необходимый для применения основных аналитических результатов к фундаментальным алгоритмам, которые будут изучаться во всей книге. Мы рассмотрим основные математические инструменты, используемые для анализа алгоритмов, которые позволят изучить классические примеры анализа фундаментальных алгоритмов, а также воспользоваться результатами исследовательской литературы, которые помогут в понимании характеристик производительности наших алгоритмов.
Мы будем выполнять математический анализ алгоритмов, в частности:
- Для сравнения разных алгоритмов, предназначенных для решения одной задачи
- Для приблизительной оценки производительности программы в новой среде
- Для установки значений параметров алгоритма
В данной книге вы увидите немало примеров каждой из этих причин. Эмпирический анализ годится для некоторых задач, но, как будет показано, математический анализ может оказаться более информативным (и менее дорогим!).
Анализ алгоритмов - не всегда легкое занятие. Некоторые из алгоритмов, приведенных в этой книге, понятны до той степени, когда известны точные математические формулы для расчета времени выполнения в реальных ситуациях. Эти формулы разработаны путем тщательного изучения программы и определения времени выполнения в зависимости от фундаментальных математических величин, а затем их математического анализа. Однако характеристики производительности других алгоритмов из этой книги не поняты до конца - или потому, что их анализ ведет к нерешенным математическим проблемам, или потому, что известные реализации слишком сложны, чтобы их подробный анализ имел смысл, или же (скорее всего) потому, что невозможно точно охарактеризовать типы входных данных.
Есть несколько важных факторов точного анализа, на которые программист обычно не может влиять. Во-первых, программы на С++ переводятся в машинные коды конкретного компьютера, и может оказаться достаточно сложной задачей определить, сколько времени займет выполнение даже одного оператора С++ (особенно в среде, где возможен совместный доступ к ресурсам, так что одна и та же программа в разное время может иметь различные характеристики производительности). Во-вторых, многие программы чрезвычайно чувствительны ко входным данным, поэтому производительность может меняться в больших пределах в зависимости от них. В-третьих, многие интересующие нас программы еще не поняты до конца, поэтому для них пока не существует специальных математических результатов. И, наконец, две программы могут совершенно не поддаваться сравнению: одна работает более эффективно с определенным типом входных данных, а другая выполняется более эффективно в других условиях.
Но все же часто вполне возможно предсказать, сколько времени займет выполнение определенной программы или же понять, что в определенных ситуациях одна программа будет выполняться эффективнее другой. Более того, зачастую эту информацию можно получить, используя относительно небольшой набор математических инструментов. Задача аналитика алгоритмов - получить как можно больше информации о производительности алгоритмов; задача программиста - использовать эту информацию при выборе алгоритмов для конкретных приложений. В этом и нескольких следующих разделах мы будем уделять основное внимание идеализированному миру аналитика. Чтобы эффективно применять лучшие алгоритмы, иногда просто необходимо посещать такой мир.
Первый шаг при анализе алгоритма состоит в определении абстрактных операций, на которых основан алгоритм, чтобы отделить анализ от реализации. Например, мы отделяем подсчет, сколько раз одна из реализаций алгоритма объединение-поиск запускает фрагмент кода , от выяснения, сколько наносекунд требуется для выполнения этого фрагмента кода на данном компьютере. Для определения реального времени выполнения программы на конкретном компьютере требуются оба этих элемента. Первый из них определяется свойствами алгоритма, а второй - свойствами компьютера. Такое разделение зачастую позволяет сравнивать алгоритмы таким способом, который не зависит от определенной реализации или от определенного типа компьютера.
Количество используемых абстрактных операций может оказаться очень большим, однако производительность алгоритма, как правило, зависит от нескольких величин, причем наиболее важные для анализа величины обычно определить несложно. Один из способов их определения заключается в использовании механизма профилирования (подсчитывает количество выполнений каждой инструкции, доступен во многих реализациях С++) для нахождения наиболее часто исполняемых частей программы по результатам нескольких пробных запусков. Или же, как алгоритмы объединение-поиск из раздела 1.3 лекция №1, наша реализация может быть построена лишь на нескольких абстрактных операциях. В любом случае, анализ сводится к определению частоты исполнения нескольких фундаментальных операций. Принцип нашей работы заключается в том, чтобы отыскать приблизительные оценки этих величин, зная, что для важных программ при необходимости можно будет произвести полный анализ. Более того, как будет показано далее, часто можно достаточно точно предсказать результаты на основе приближенных аналитических результатов в сочетании с эмпирическим изучением.
Кроме того, необходимо изучать данные и моделировать такие их наборы, которые могут быть поданы на вход алгоритма. Чаще всего мы будем рассматривать один из двух подходов к анализу: или предполагаем, что входные данные случайны, и изучаем среднюю производительность программы, или же рассматриваем самые неудобные данные и изучаем наихудшую производительность программы. Процесс описания случайных входных данных для многих алгоритмов достаточно сложен, но для многих других алгоритмов он совсем прост и приводит к аналитическим результатам, дающим полезную информацию. Средний случай может быть просто математической фикцией, не зависящей от данных, для которых используется программа, а наихудший - вычурной последовательностью, которая никогда не встречается на практике, но в большинстве случаев эти виды анализа предоставляют полезную информацию о производительности. Например, мы можем сравнить аналитические и эмпирические результаты (см. раздел 2.1). Если они совпадут, это повысит уверенность в обоих вариантах; а если не совпадут, мы сможем узнать больше об алгоритме и модели, рассмотрев их расхождения.
В последующих трех разделах мы кратко рассмотрим математические инструменты, которыми будем пользоваться во всей книге. Этот материал находится за пределами нашей основной задачи, поэтому читатели с хорошей математической подготовкой или же те, кто не собирается подробно проверять наши математические выражения, связанные с производительностью алгоритмов, могут перейти сразу к разделу 2.6 и обращаться к этому материалу там, где это указано в книге позже. Однако рассматриваемые нами математические основы, в общем-то, не сложны для понимания и настолько близки к базовым вопросам разработки алгоритмов, что их не стоит игнорировать всем, кто стремится эффективно использовать компьютер.
Вначале, в разделе 2.3, рассматриваются математические функции, которые обычно нужны для описания характеристик производительности алгоритмов. Далее, в разделе 2.4, будет рассмотрена О-нотация (О-notation) и понятие пропорционально (is proportional to), которые позволяют опустить детали при математическом анализе. Затем, в разделе 2.5, изучаются рекуррентные соотношения (recurrence relations) - основной аналитический инструмент, используемый для выражения характеристик алгоритма в виде математических равенствах. И в завершение, в разделе 2.6, приводятся примеры, в которых все эти инструменты применяются для анализа конкретных алгоритмов.
Упражнения
- 2.3. Напишите выражение вида с0 + c1N + c2N^2 + c3N3, которое точно описывает время выполнения программы из упражнения 2.2. Сравнить время, получаемое из этого выражения, с реальным при N = 10, 100, 1000.
- 2.4. Напишите выражение, которое точно описывает время выполнения программы 1.1 в зависимости от M и N.
Возрастание функций
Большинство алгоритмов имеют главный параметр , который наиболее сильно влияет на время их выполнения. Параметр
может быть степенью полинома, размером файла при сортировке или поиске, количеством символов в строке или некоторой другой абстрактной мерой размера рассматриваемой задачи: чаще всего он прямо пропорционален объему обрабатываемого набора данных. Когда таких параметров существует более одного (например,
и
в алгоритмах объединение-поиск, которые были рассмотрены в разделе 1.3 лекция №1), мы часто сводим анализ к одному параметру, задавая его как функцию других, или рассматривая одновременно только один параметр (считая остальные постоянными) - то есть без потери общности ограничиваясь рассмотрением только одного параметра
. Нашей целью является выражение требований программ к ресурсам (как правило, это время выполнения) в зависимости от
с использованием математических формул, которые максимально просты и справедливы для больших значений параметров. Алгоритмы в этой книге обычно имеют время выполнения, пропорциональное одной из следующих функций:
1 | Большинство инструкций большинства программ выполняются один или несколько раз. Если все инструкции программы обладают таким свойством, мы говорим, что время выполнения программы постоянно. |
![]() |
Когда время выполнения программы является логарифмическим, программа выполняется несколько медленнее с ростом ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() |
![]() |
Когда время выполнения программы является линейным, это обычно значит, что каждый входной элемент подвергается небольшой обработке. Если ![]() ![]() ![]() ![]() |
![]() |
Время выполнения, пропорциональное ![]() ![]() ![]() ![]() ![]() |
![]() |
Если время выполнения алгоритма является квадратичным, он полезен для практического использования для относительно небольших задач. Квадратичное время выполнения обычно появляется в алгоритмах, которые обрабатывают все пары элементов данных (возможно, в цикле двойного уровня вложенности). Когда ![]() ![]() |
![]() |
Аналогично, эта ситуация характерна для алгоритма, который обрабатывает тройки элементов данных (возможно, в цикле тройного уровня вложенности), имеет кубическое время выполнения и практически применим лишь для малых задач. Если ![]() ![]() |
![]() |
Лишь несколько алгоритмов с экспоненциальным временем выполнения имеют практическое применение, хотя такие алгоритмы возникают естественным образом при попытках прямого решения задачи. Если ![]() ![]() |
Время выполнения определенной программы обычно равно некоторой константе, умноженной на один из этих элементов (главный член) плюс меньшие слагаемые. Значения постоянного коэффициента и остальных слагаемых зависят от результатов анализа и деталей реализации. В первом приближении коэффициент при главном члене связан с количеством инструкций во внутреннем цикле: на любом уровне разработки алгоритма разумно сократить количество таких инструкций. Для больших доминирует эффект главного члена, для малых
или для тщательно разработанных алгоритмов ощутимый вклад дают и другие слагаемые, поэтому сравнение алгоритмов затрудняется. В большинстве случаев мы будем называть время выполнения программ просто "линейным", "N logN", "кубическим" и т.д. Обоснование этого подробно приводится в разделе 2.4.
В итоге, чтобы уменьшить общее время выполнения программы, мы минимизируем количество инструкций во внутреннем цикле. Каждую инструкцию необходимо тщательно рассмотреть и решить, является ли она необходимой. Существует ли более эффективный способ выполнить такую же задачу? Некоторые программисты считают, что средства автоматизации, содержащиеся в современных компиляторах, могут создавать наилучший машинный код; другие утверждают, что оптимальным способом является написание внутренних циклов вручную на машинном языке, или ассемблере. Обычно мы будем останавливаться, доходя до рассмотрения оптимизации на таком уровне, хотя иногда будем указывать, сколько машинных инструкций требуется для выполнения определенных операций. Это потребуется для того, чтобы понять, почему на практике одни алгоритмы могут оказаться быстрее других.
Для малых задач время решения практически не зависит от метода - быстрый современный компьютер все равно выполнит задачу мгновенно. Но по мере увеличения размера задачи числа, с которыми мы имеем дело, становятся огромными, как продемонстрировано в таблица 2.1. Когда количество исполняемых инструкций в медленном алгоритме становится по-настоящему большим, время, необходимое для их выполнения, становится недостижимым даже для самых быстрых компьютеров. На рис. 2.1 приведен перевод большого количества секунд в дни, месяцы, годы и т.д.; в таблица 2.2 показаны примеры того, как быстрые алгоритмы имеют больше возможностей решить задачу за приемлемое время, нежели даже самые быстрые компьютеры.

Рис. 2.1. Перевод секунд
Огромная разница между такими числами, как и
, становится более очевидной, если взять соответствующее количество секунд и перевести в привычные единицы измерения. Мы можем позволить программе выполняться 2,8 часа, но вряд ли мы будем созерцать программу, выполнение которой займет 3,1 года. Поскольку
примерно равно
, этой таблицей можно воспользоваться и для перевода степеней 2. Например,
секунд - это примерно 124 года.
![]() |
![]() | N |
![]() |
![]() |
![]() |
![]() |
---|---|---|---|---|---|---|
3 | 3 | 10 | 33 | 110 | 32 | 100 |
7 | 10 | 100 | 664 | 4414 | 1000 | 10000 |
10 | 32 | 1000 | 9966 | 99317 | 31623 | 1000000 |
13 | 100 | 10000 | 132877 | 1765633 | 1000000 | 100000000 |
17 | 316 | 100000 | 1660964 | 27588016 | 31622777 | 10000000000 |
20 | 1000 | 1000000 | 19931569 | 397267426 | 1000000000 | 1000000000000 |
В этой таблице показаны относительные величины некоторых функций, которые часто встречаются при анализе алгоритмов. Квадратичная функция очевидно доминирует, особенно для больших значений , а различия между меньшими функциями оказываются не такими, как можно было ожидать для малых
. Например,
должно быть больше, чем
для очень больших значений
, однако для малых
наблюдается обратная ситуация. Точное время выполнения алгоритма может быть линейной комбинацией этих функций. Быстрые алгоритмы легко отличить от медленных из-за огромной разницы между, например,
и
или
и
, но различие между двумя быстрыми алгоритмами может потребовать тщательного изучения.
Операций в секунду | Размер задачи 1 миллион | Размер задачи 1 миллиард | ||||
---|---|---|---|---|---|---|
N |
![]() |
![]() | N |
![]() |
![]() | |
![]() | секунды | секунды | недели | часы | часы | никогда |
![]() | мгновенно | мгновенно | часы | секунды | секунды | десятилетия |
![]() | мгновенно | мгновенно | секунды | мгновенно | мгновенно | недели |
Во многих случаях единственным шансом решить очень большую задачу является использование эффективного алгоритма. В этой таблице показано минимальное количество времени, необходимое для решения задач размером 1 миллион и 1 миллиард с использованием линейных, и квадратичных алгоритмов на компьютерах с быстродействием 1 миллион, 1 миллиард и 1 триллион инструкций в секунду. Быстрый алгоритм позволяет решить задачу на медленной машине, но быстрая машина бессильна при использовании медленного алгоритма.
При анализе алгоритмов возникает еще несколько функций. Например, алгоритм с входными данными, имеющий время выполнения
, можно рассматривать, как
алгоритм. Кроме того, некоторые алгоритмы разбиваются на подзадачи в два этапа и имеют время выполнения, пропорциональное
. Из таблица 2.1 видно, что обе эти функции гораздо ближе к
, чем
.
Логарифмическая функция играет особую роль в разработке и анализе алгоритмов, поэтому ее стоит рассмотреть подробнее. Поскольку мы часто имеем дело с аналитическими результатами, которые различаются на постоянный множитель, мы используем запись "log N" без указания основания. Изменение основания логарифма меняет значение логарифма лишь на постоянный множитель, однако в определенных контекстах возникают особые значения основания логарифма.
В математике настолько важным является натуральный логарифм (основание e = 2,71828...), что распространено следующее сокращение: . В вычислительной технике очень важен двоичный логарифм (основание равно 2), поэтому часто используется сокращение
.
Наименьшее целое число, большее , равно количеству битов, необходимых для представления
в двоичном формате; точно так же наименьшее целое, большее
, - это количество цифр, необходимое для представления
в десятичном формате.
Оператор С++
for (lgN = 0; N > 0; lgN++, N /= 2) ;
дает простой способ подсчета наименьшего целого, большего . Вот аналогичный метод для вычисления этой функции:
for (lgN = 0, t = 1; t < N; lgN++, t += t) ;
В нем подчеркивается, что , когда
- это наименьшее целое, большее
.
Иногда бывает нужно вычислить логарифм логарифма, обычно для больших чисел. Например, . Как видно из данного примера, обычно для практических целей выражение
можно считать константой, поскольку оно мало даже для очень больших
.
Кроме того, часто мы сталкиваемся с некоторыми специальными функциями и математическими обозначениями из классического анализа, которые удобны для краткого описания свойств программ. В таблица 2.3 приведены наиболее используемые из этих функций; ниже мы кратко обсудим их и некоторые наиболее важные их свойства.
Функция | Название | Пример | Приближение |
---|---|---|---|
![]() | округление до меньшего |
![]() | x |
![]() | округление до большего |
![]() | x |
![]() | двоичный логарифм |
![]() |
![]() |
![]() | числа Фибоначчи |
![]() |
![]() |
![]() | гармонические числа |
![]() |
![]() |
N! | факториал |
![]() |
![]() |
![]() |
![]() |
![]() |
В этой таблице собраны математические обозначения функций и постоянных, которые часто появляются в формулах, описывающих производительность алгоритмов. Формулы для приближенных значений можно при необходимости уточнить, учтя следующие слагаемые разложения (см. раздел ссылок).
В наших алгоритмах и их анализе часто бывают нужны дискретные единицы, поэтому часто требуются специальные функции, преобразующие действительные числа в целые:
: наибольшее целое, меньшее или равное x
: наименьшее целое, большее или равное х.
Например, и
оба равны 3, а
- это количество битов, необходимое для двоичного представления числа
. Другое важное применение этих функций возникает в том случае, когда необходимо поделить множество
объектов пополам. Этого нельзя сделать точно, если
является нечетным, поэтому для точности мы можем создать одно подмножество, содержащее
объектов, а второе -
объектов. Если
четно, тогда размеры обоих поднаборов равны (
=
); если же
нечетно, то их размер отличается на единицу (
=
). В С++ можно напрямую подсчитать значения этих функций при выполнении операций над целыми числами (например, если $N\geq0$N, тогда
равно
а
равно
), а при операциях над числами с плавающей точкой можно воспользоваться функциями floor и ceil из заголовочного файла math.h.
При анализе алгоритмов часто возникает дискретизированная версия функции натурального логарифма, называемая гармоническими числами. N-е гармоническое число определяется выражением
Натуральный логарифм - это значение площади под кривой
между 1 и
; гармоническое число H_N - это площадь под ступенчатой функцией, которую можно определить, вычисляя значения функции
для целых чисел от 1 до
. Эта зависимость показана на рис. 2.2.

Рис. 2.2. Гармонические числа
Гармонические числа представляют собой приближенные значения площади под кривой 1/х. Постоянная y учитывает разницу между H_N и
Формула
где
(эта константа называется постоянной Эйлера), дает отличное приближение для H_N. В отличие от
и
для вычисления H_N лучше воспользоваться библиотечной функцией log, а не подсчитывать его непосредственно из определения.
Последовательность чисел
определяемая формулой
где
а
и
известна как числа Фибоначчи и имеет множество интересных свойств. Например, отношение двух последовательных чисел приближенно равно золотому сечению (golden ratio)
Более подробный анализ показывает, что
равно значению выражения
округленному до ближайшего целого числа.
При анализе алгоритмов часто встречается также функция факториал . Как и экспоненциальная функция, факториал возникает при лобовом решении задач и растет слишком быстро, чтобы такие решения представляли практический интерес. Он также возникает при анализе алгоритмов, поскольку представляет собой количество способов упорядочения
объектов.
Для аппроксимации используется формула Стирлинга:
.
Например, из формулы Стирлинга следует, что количество битов в представлении числа примерно равно
.
Большинство рассматриваемых в этой книге формул выражается через несколько функций, рассмотренных в этой главе. Однако при анализе алгоритмов может встретиться множество других специальных функций.
Например, классическое биномиальное распределение и распределение Пуассона играют важную роль при разработке и анализе некоторых фундаментальных поисковых алгоритмов, которые будут рассмотрены в лекция №14 и лекция №15. Функции, не приведенные здесь, обсуждаются по мере их появления.
Упражнения
2.5. Для каких значений
справедливо
?
2.6. Для каких значений
выражение
имеет значение в пределах от
до
?
-
2.7. Для каких значений
справедливо
?
-
2.8. Для какого наименьшего значения
справедливо
?
-
2.9. Докажите, что
+ 1 - это количество битов, необходимое для представления числа
в двоичной форме.
-
2.10. Добавьте в таблица 2.2 столбцы для
.
-
2.11. Добавьте в таблица 2.2 строки для
и
инструкций в секунду.
-
2.12. Напишите на С++ функцию, которая подсчитывает
, используя функцию
из стандартной математической библиотеки.
-
2.13. Напишите эффективную функцию на С++, подсчитывающую
Не используйте библиотечную функцию.
- 2.14. Сколько цифр в десятичном представлении числа 1 миллион факториал?
-
2.15. Сколько битов в двоичном представлении числа
?
-
2.16. Сколько битов в двоичном представлении
?
-
2.17. Приведите простое выражение для
.
-
2.18. Приведите наименьшие значения
, для которых
-
2.19. Приведите наибольшее значение
, для которого можно решить задачу, требующую выполнения f(N) инструкций, на машине с быстродействием
операций в секунду для следующих функций
,
,
и
.
О-нотация
Математическая запись, позволяющая отбрасывать детали при анализе алгоритмов, называется О-нотацией. Она определена следующим образом.
Определение 2.1. Говорят, что функция имеет порядок
, если существуют такие постоянные
и
, что
для всех
.
О-нотация используется по трем различным причинам:
- Чтобы ограничить ошибку, возникающую при отбрасывании малых слагаемых в математических формулах.
- Чтобы ограничить ошибку, возникающую при игнорировании частей программы, которые вносят небольшой вклад в анализируемую сумму.
- Чтобы классифицировать алгоритмы по верхним границам их общего времени выполнения.
Третье назначение О-нотации рассматривается в разделе 2.7, а здесь мы обсудим два других.
Постоянные и
, не выраженные явно в О-нотации, часто скрывают практически важные подробности реализации. Очевидно, что выражение "алгоритм имеет время выполнения
" ничего не говорит о времени выполнения при
, меньшем
, а
может иметь большое значение, необходимое для работы в наихудшем случае. Понятно, что лучше иметь алгоритм, время выполнения которого составляет
наносекунд, а не
столетий, но мы не можем сделать такой выбор на основе О-нотации.
Часто результаты математического анализа являются не точными, а приближенными именно в техническом смысле: результат представляет собой сумму убывающих слагаемых. Так же как мы интересуемся в основном внутренним циклом программы, нас интересует больше всего главные члены (наибольшие по величине слагаемые) математического выражения. О-нотация позволяет работать с приближенными математическими выражениями, рассматривая главные члены и опуская меньшие слагаемые, а также записывать краткие выражения, дающие неплохие приближения для анализируемых величин.
Некоторые из основных действий, которые используются при работе с выражениями, содержащими О-нотацию, являются предметом упражнений 2.20 - 2.25. Многие из этих действий интуитивно понятны, а склонные к математике читатели могут с интересом выполнить упражнение 2.21, где требуется доказать верность базовых операций, исходя из определения. По сути, из этих упражнений следует, что в алгебраических выражениях с О-нотацией можно раскрывать скобки так, как будто ее там нет, а затем отбрасывать все слагаемые, кроме наибольшего. Например, если требуется раскрыть скобки в выражении
, то мы получим шесть слагаемых
.
Однако можно отбросить все О-слагаемые, кроме наибольшего из них, и тогда останется приближенное выражение
.
То есть при больших хорошей аппроксимацией этого выражения является N^2. Эти действия интуитивно ясны, но О-нотация позволяет выразить их с математической точностью. Формула с одним О-слагаемым называется асимптотическим выражением (asymptotic expression).
В качестве более конкретного примера предположим, что (после некоторого математического анализа) мы выяснили, что определенный алгоритм имеет внутренний цикл, выполняемый в среднем раз, внешний раздел, выполняемый
раз, и некоторый код инициализации, исполняемый однократно. Далее предположим, что (после тщательного исследования реализации) мы определили, что каждая итерация внутреннего цикла требует
наносекунд, внешний раздел -
наносекунд, а код инициализации -
наносекунд. Тогда среднее время выполнения программы (в наносекундах) равно
.
Поэтому для времени выполнения справедлива следующая формула:
.
Эта более простая формула важна, поскольку из нее следует, что для аппроксимации времени выполнения при больших нет необходимости искать значения величин
и
2
. В общем случае, в точном математическом выражении для времени выполнения может содержаться множество других слагаемых, ряд которых трудно анализировать. О-нотация обеспечивает способ получения приближенного ответа для больших
, не заботясь о подобных слагаемых.
Далее, О-нотация позволяет в данном примере выразить время выполнения через более знакомую функцию . С помощью таблица 2.3 полученное выражение можно приближенно записать как
. Таким образом, асимптотическое выражение для общего времени выполнения алгоритма имеет вид
. То есть при больших
оно будет близко к легко вычисляемому выражению
. Постоянный множитель
зависит от времени выполнения инструкций внутреннего цикла.
Более того, нам не нужно знать значения , чтобы предсказать, что при больших
время выполнения для входных данных размером
будет вдвое больше, чем для входных данных размером
, поскольку

Таким образом, асимптотическая формула позволяет нам делать точные прогнозы, не вдаваясь в подробности реализации или анализа. Отметьте, что такое предсказание не было бы возможным, если бы О-аппроксимация была задана только для главного члена.
Описанный способ рассуждений позволяет ограничиться только главным членом при сравнении или предсказании времени выполнения алгоритмов. Очень часто требуется подсчитать количество выполнения операций с фиксированным временем выполнения и ограничиться только главным членом, подразумевая неявно, что при необходимости можно всегда провести точный анализ наподобие приведенного выше.
Когда функция асимптотически велика по сравнению с другой функцией
(т.е.
при
), иногда в данной книге мы будем использовать термин (конечно, неточный) порядка
, что означает
. Потеря математической точности компенсируется большей наглядностью, так как нас больше интересует производительность алгоритмов, а не математические детали. В таких случаях мы можем быть уверены в том, что при больших (а, может, даже и всех) значениях
исследуемая величина будет близка к
. Например, даже если мы знаем, что некоторая величина равна
, ее можно рассматривать как
. Такой способ выражения результатов более понятен, чем подробный и точный результат, и, к примеру, при
отличается от правильного значения всего лишь на 0,1%. Потеря точности в данном случае намного меньше, чем при распространенном использовании
. При описании производительности алгоритмов мы будем по возможности стараться быть и точными, и краткими.
В похожем ключе мы иногда говорим, что время выполнения алгоритма пропорционально , т.е. можно доказать, что оно равно с
асимптотически мало по сравнению с
. При таком подходе можно предсказать время выполнения для
, если оно известно для
, как в рассмотренном выше примере. На рис. 2.3 рис. 2.3 приводятся значения множителей для таких прогнозов поведения функций, которые часто возникают при анализе алгоритмов. В сочетании с эмпирическим изучением (см. раздел 2.1) данный подход освобождает от определения постоянных величин, зависящих от реализации. Или же, применяя его в обратном направлении, зачастую мы можем выдвинуть гипотезу о функциональной зависимости времени выполнения программы, изучив, как меняется время выполнения при удвоении
.
Различия между О-оценками пропорционально (is proportional to) и порядка (about) проиллюстрированы на рис. 2.4 и рис. 2.5. О-нотация используется, прежде всего, для исследования фундаментального асимптотического поведения алгоритма; пропорционально требуется при экстраполяции производительности на основе эмпирического изучения, а порядка - при сравнении производительности или при предсказании абсолютной производительности.

Рис. 2.3. Влияние удвоения размеров задачи на время выполнения
Нетрудно предсказать влияние удвоения размеров задачи на время выполнения, если время выполнения пропорционально одной из простых функций, указанных в таблице. Теоретически это влияние можно вычислить только для больших , но данный метод на удивление эффективен.
И наоборот, быстрый метод определения функционального роста времени выполнения программы заключается в запуске программы, удвоении объема входных данных для максимально возможного
, а затем оценка функциональной зависимости согласно приведенной таблице.

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

Рис. 2.5. Аппроксимация функций
Когда говорят, что функция пропорциональна функции
(верхний график), то подразумевают, что она растет как
, но, возможно, смещена относительно последней на неизвестный коэффициент. Если задано некоторое значение
, можно предсказать поведение функции при больших
. Когда говорят, что
порядка
(нижний график), то подразумевают, что функцию
можно использовать для достаточно точной оценки значений функции g.
Упражнения
2.20. Докажите, что О(1) - то же самое, что и О(2).
2.21. Докажите, что в выражениях с О-нотацией можно выполнить любое из перечисленных преобразований:

- 2.22. Покажите, что (N + 1)(H_N + O(1)) = N lnN + O(N).
- 2.23. Покажите, что
.
- 2.24. Покажите, что
для любого
и любого постоянного
.
- 2.25. Докажите, что
- 2.26. Предположим, что
. Найдите приближенную формулу, которая выражает
как функцию
.
- 2.27. Предположим, что
. Найдите приближенную формулу, которая выражает k как функцию
.
- 2.28. Известно, что время выполнения одного алгоритма равно
, а другого -
. Что это неявно говорит об относительной производительности алгоритмов?
- 2.29. Известно, что время выполнения одного алгоритма всегда порядка
, а другого -
. Что это неявно говорит об относительной производительности алгоритмов?
- 2.30. Известно, что время выполнения одного алгоритма всегда порядка
, а другого - всегда
. Что это неявно говорит об относительной производительности алгоритмов?
- 2.31. Известно, что время выполнения одного алгоритма всегда пропорционально
, а другого - всегда пропорционально
. Что это неявно говорит об относительной производительности алгоритмов?
- 2.32. Выведите значения множителей, приведенных на рис. 2.3: для каждой функции
, показанной слева, найдите асимптотическую формулу для
.
Простейшие рекурсии
Как мы увидим далее в этой книге, многие алгоритмы основаны на принципе рекурсивного разбиения большой задачи на меньшие, а решения подзадач используются для решения исходной задачи. Эта тема подробно обсуждается в лекция №5, в основном с практической точки зрения, где основное внимание уделено различным реализациям и приложениям. Кроме того, в разделе 2.6 приводится подробный пример. А в этом разделе будут рассмотрены простейшие методы анализа таких алгоритмов и вывод решений нескольких стандартных формул, которые возникают при анализе многих изучаемых алгоритмов. Понимание математических свойств формул в этом разделе дает необходимое понимание свойств производительности алгоритмов.
Рекурсивное разбиение алгоритма напрямую проявляется в его анализе. Например, время выполнения подобных алгоритмов определяется величиной и количеством подзадач, а также временем, необходимым для разбиения задачи. Математически зависимость времени выполнения алгоритма для входных данных от времени выполнения при меньшем количестве данных легко определить с помощью рекуррентных соотношений. Такие формулы точно описывают производительность алгоритмов, и для вычисления времени выполнения необходимо решить эти уравнения. Более строгие рассуждения, связанные со спецификой алгоритмов, встретятся при их непосредственном рассмотрении; здесь же внимание уделяется только формулам.
Формула 2.1. В рекурсивной программе, где при каждой итерации цикла количество входных данных уменьшается на единицу, возникает следующее рекуррентное соотношение:
, где
и
.
Решение: имеет порядок
. Для решения рекуррентного уравнения его можно развернуть, применяя само к себе следующим образом:


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

Точное решение для произвольного зависит от интерпретации
. Если
представляет собой
, то существует очень простое решение: CN - это количество битов в двоичном представлении числа
, т.е. по определению
. Этот вывод немедленно следует из того, что операция отбрасывания правого бита в двоичном представлении любого числа
превращает его в
(см. рис. 2.6).

Рис. 2.6. Целочисленные функции и двоичные представления
Для заданного двоичного представления числа (в центре) отбрасывание правого бита дает
. То есть количество битов в двоичном представлении числа
на единицу больше, чем в представлении числа
. Поэтому количество битов в двоичном представлении числа -
- является решением формулы 2.2 в случае интерпретации N/2 как
.
Формула 2.3. В рекурсивной программе, где объем входных данных уменьшается вдвое, но необходимо проверить каждый элемент, возникает следующее рекуррентное соотношение:
.
Решение: CN имеет порядок 2N. Рекурсия развертывается в сумму:
.
(Как и формуле 2.2, рекуррентное соотношение определено точно только в том случае, если является степенью числа 2). Для бесконечной последовательности сумма простой геометрической прогрессии равна в точности 2N. Однако поскольку используется целочисленное деление, и мы останавливаемся на 1, это значение является приближением к точному ответу. В точном решении используются свойства двоичного представления числа
.
Формула 2.4. В рекурсивной программе, которая должна выполнить линейный проход по входным данным до, в течение или после разбиения их на две половины, возникает следующее рекуррентное соотношение:
.
Решение: CN имеет порядок . Это решение применяется намного чаще, чем остальные из приведенных здесь, поскольку эта рекурсия используется в целом семействе алгоритмов "разделяй и властвуй".

Решение находится почти так же, как это было сделано в формуле 2.2, но с дополнительным приемом на втором шаге - делением обеих частей равенства на , который позволяет развернуть рекурсию.
Формула 2.5. В рекурсивной программе, которая разбивает входные данные пополам, а затем выполняет постоянное количество других операций (см. лекция №5) возникает следующая рекурсия.
.
Решение: C_N имеет порядок . Это решение можно получить так же, как и решение формулы 2.4.
С помощью приведенных методов можно решить различные варианты этих уравнений, имеющие другие начальные условия или небольшие отличия в добавочном члене. Однако необходимо помнить, что некоторые, на первый взгляд похожие, рекурсии могут иметь гораздо более сложные решения. Существует множество дополнительных общих методов для математически строгого решения таких уравнений (см. раздел ссылок). Несколько сложных рекуррентных соотношений встретятся в последующих главах, где и будут приведены их решения.
Упражнения
-
2.33. Составьте таблицу значений
, заданных формулой 2.2 для
, считая, что
означает
.
-
2.34. Выполните упражнение 2.33, но считая, что
означает
.
- 2.35. Выполните упражнение 2.34 для формулы 2.3.
-
2.36. Предположим, что
пропорционально постоянной величине и что
, где
и
- постоянные. Покажите, что
пропорционально
.
- 2.37. Сформулируйте и докажите обобщенные версии формул 2.3 - 2.5, аналогичные обобщенной версии формулы 2.2 в упражнении 2.36.
-
2.38. Составьте таблицу значений
, заданных формулой 2.4 при
для трех следующих случаев: (1)
означает
, (2)
означает
, (3) 2C_N/2 равно
-
2.39. Решите уравнение 2.4 для случая, когда
означает
, используя соответствие двоичному представлению числа
, как это было сделано в доказательстве формулы 2.2. Подсказка: Рассмотрите все числа, меньшие
.
-
2.40. Решите рекуррентное уравнение
при
, если
является степенью числа 2.
-
2.41. Решите рекуррентное уравнение
при
, если
является степенью числа а.
-
2.42. Решите рекуррентное уравнение
при
, если
является степенью числа 2.
-
2.43. Решите рекуррентное уравнение
при
, если
является степенью числа 2.
-
2.44. Решите рекуррентное уравнение
при
, если
является степенью числа 2.
-
2.45. Рассмотрите семейство рекурсий наподобие формулы 2.1, где
может означать
или
, с единственным требованием: рекурсия выполняется при
, а при
имеет место
. Докажите, что решением всех таких рекурсий является формула
.
- 2.46. Выведите обобщенные рекурсии и их решения, как в упражнении 2.45, для формул 2.2-2.5.
Примеры анализа алгоритмов
Вооружившись инструментами, о которых было рассказано в трех предыдущих разделах, мы рассмотрим анализ последовательного поиска и бинарного поиска - двух основных алгоритмов для определения того, входит ли некоторая последовательность объектов в заданное множество объектов. Наша цель - показать, как можно сравнивать алгоритмы, а не подробно описать сами алгоритмы. Для простоты предположим, что все рассматриваемые объекты являются целыми числами. Более общие приложения будут подробно рассмотрены в лекциях 12 - 16 . Простые версии алгоритмов, которые мы сейчас рассмотрим, не только демонстрируют многие аспекты задачи их разработки и анализа, но и имеют практическую ценность.
Например, представим себе компанию, обрабатывающую кредитные карточки и имеющую рискованных или украденных кредитных карточек. При этом компании необходимо проверять, нет ли среди
транзакций какого-либо из этих
плохих номеров. Для большей конкретности будем считать
большим (скажем, порядка
-
), а
- огромным (порядка
-
). Цель анализа заключается в приблизительной оценке времен выполнения алгоритмов, когда параметры принимают значения из указанного диапазона.
В программе 2.1 реализовано прямое решение задачи поиска. Для совместимости с другими вариантами кода для этой задачи, которые мы исследуем в части 4, она оформлена как функция С++, обрабатывающая массив (см. лекция №3). Однако необязательно вдаваться в детали программы для понимания алгоритма: мы сохраняем все объекты в массиве, затем для каждой транзакции мы последовательно просматриваем массив от начала до конца, проверяя, содержится ли в нем искомый номер.
Для анализа алгоритма прежде всего отметим, что время выполнения зависит от того, находится ли требуемый объект в массиве. Если поиск не является успешным, мы можем определить это, только проверив все объектов, но успешный поиск может завершиться на первом, втором или любом другом объекте.
Поэтому время выполнения зависит от данных. Если бы все поиски выполнялись для чисел, которые находятся в первой позиции, то алгоритм был бы быстрым; если бы они выполнялись для чисел, находящихся в последней позиции, тогда алгоритм был бы медленным. В разделе 2.7 мы обсудим разницу между возможностью гарантировать производительность и предсказать производительность. В данном случае лучшее, что мы можем гарантировать - это что будет просмотрено не более чисел.
Однако чтобы сделать прогноз, необходимо какое-то предположение о данных. В данном случае предположим, что все числа выбраны случайным образом.
Программа 2.1. Последовательный поиск
Данная функция проверяет, находится ли число v среди элементов массива a[l] , a[l+1], ..., a[r], путем последовательного сравнения с каждым элементом, начиная с начала. Если по достижении последнего элемента нужное значение не найдено, функция возвращает значение -1. Иначе она возвращает индекс элемента массива, содержащего искомое число.
int search(int a[], int v, int l, int r) { for (int i = l; i <= r; i++) if (v == a[i]) return i; return -1; }
Из этого предположения следует, например, что предметом поиска с одинаковой вероятностью может оказаться каждое число в таблице. После некоторых размышлений мы приходим к выводу, что именно это свойство поиска является наиболее важным, потому что среди случайно выбранных чисел вряд ли найдется нужное нам (см. упражнение 2.48). В некоторых приложениях количество транзакций с успешным поиском может быть высоким, а в других - низким. Чтобы не запутывать модель свойствами приложения, мы разобьем задачу на два случая (успешный и неудачный поиск) и проанализируем их независимо. Данный пример иллюстрирует, что важным моментом эффективного анализа является разработка разумной модели приложения. Наши аналитические результаты будут зависеть от доли успешных поисков; более того, они обеспечат нас информацией, необходимой для выбора различных алгоритмов для разных приложений на основании этого параметра.
Лемма 2.1. Последовательный поиск проверяет чисел при каждом неудачном поиске и в среднем порядка N/ 2 чисел при каждом успешном поиске.
Если объектом поиска с равной вероятностью может быть любое число в таблице, то средняя стоимость поиска равна (1 + 2 + ... + N) / N = (N + 1)/ 2.
Из леммы 2.1 следует, что время выполнения программы 2.1 пропорционально , если средняя стоимость сравнения двух чисел постоянна. Значит, к примеру, можно ожидать, что если удвоить количество объектов, то и время, необходимое для поиска, также удвоится.
Последовательный поиск в случае неудачи можно ускорить, если упорядочить числа в таблице. Сортировка чисел в таблице является предметом рассмотрения глав 6-11. Несколько алгоритмов, которые мы рассмотрим, выполняют эту задачу за время, пропорциональное , которое незначительно по сравнению со стоимостью поиска при очень больших M. В упорядоченной таблице можно прервать поиск сразу по достижении числа, большего, чем искомое. Такое изменение уменьшает стоимость последовательного поиска до N/ 2 чисел, которые необходимо в среднем проверить при неудачном поиске, что совпадает с затратами для успешного поиска.
Лемма 2.2. Алгоритм последовательного поиска в упорядоченной таблице проверяет чисел для каждого поиска в худшем случае и порядка
чисел в среднем.
Здесь все еще необходимо определить модель неудачного поиска. Этот результат следует из предположения, что поиск может с равной вероятностью закончиться на любом из интервалов, задаваемых
числами таблицы, а это непосредственно приводит к выражению
.
Стоимость неудачного поиска, который заканчивается до или после N-ой записи в таблице, такая же: .
Другой способ выразить результат леммы 2.2 - это сказать, что время выполнения последовательного поиска пропорционально для
транзакций и в среднем, и в худшем случае. Если удвоить или количество транзакций, или количество объектов в таблице, то время выполнения удвоится; если мы удвоим обе величины одновременно, то время выполнения увеличится в 4 раза. Этот результат говорит о том, что данный метод не годится для очень больших таблиц. Если для проверки одного числа требуется c микросекунд, а
и
, то время выполнения для всех транзакций будет равно, по крайней мере,
секунд, или, согласно рис. 2.1, около 16c лет, что недопустимо.
Программа 2.2. Бинарный поиск
Эта программа делает то же самое, что и программа 2.1, но гораздо эффективнее.
int search(int a[], int v, int l, int r) { while (r >= l) { int m = (l+r)/2; if ( v == a[ m] ) return m; if (v < a[m]) r = m-1; else l = m+1; } return -1; }
Программа 2.2 представляет собой классическое решение задачи поиска методом, гораздо более эффективным, чем последовательный поиск. Он основан на идее, что если числа в таблице упорядочены, то после сравнения искомого значения с числом из середины таблицы мы можем отбросить половину из них. Если они равны, значит, поиск завершен успешно, если искомое число меньше, то мы применим этот же метод к левой части таблицы, а если больше - то к правой. На рис. 2.7 представлен пример выполнения этого метода на множестве чисел.
Лемма 2.3. Бинарный поиск проверяет не более чисел.
Доказательство данной леммы иллюстрирует применение рекуррентных соотношений при анализе алгоритмов. Пусть - это количество сравнений, необходимое бинарному поиску в худшем случае. Тогда из сведения поиска в таблице размером
к поиску в два раза меньшей таблице непосредственно следует, что
При поиске в таблице размером мы проверяем число посредине, затем производим поиск в таблице размером не более
. Реальная стоимость может быть меньше этого значения, так как сравнение может закончиться успешно или таблица будет иметь размер
(если
четно). Так же, как это было сделано в решении формулы 2.2, легко доказать, что
при
, а затем получить общий результат с помощью индукции.

Рис. 2.7. Бинарный поиск
Чтобы проверить, содержится ли число 5025 в таблице, приведенной в левой колонке, мы сначала сравниваем его с 6504, из чего следует, что дальше необходимо рассматривать первую половину массива. Затем производится сравнение с числом 4548 (середина первой половины), что приводит нас ко второй половине первой половины. Мы продолжаем этот процесс, постоянно работая с подмассивом, в котором может содержаться искомое число, если оно есть в таблице. В заключение мы получаем подмассив с одним элементом, не равным 5025, из чего следует, что 5025 в таблице не содержится.
Лемма 2.3 позволяет решить очень большую задачу поиска в 1 миллионе чисел при помощи 20 сравнений на транзакцию, то есть быстрее, чем требуется для чтения или записи числа на большинстве современных компьютеров. Задача поиска настолько важна, что было разработано несколько еще более быстрых методов, чем приведенный здесь (см. лекции 12 - 16).
В формулировках лемм 2.1 и 2.2 используются операции, наиболее часто выполняемые над данными. Как отмечено в комментарии, следующем за леммой 2.1, мы предполагаем, что каждая операция должна занимать постоянное время, тогда можно заключить, что время выполнения бинарного поиска пропорционально lgN, в отличие от для последовательного поиска. При удвоении
время бинарного поиска несколько увеличивается, но не удваивается, как это имеет место для последовательного поиска. С ростом
разница между двумя методами становится огромной.
Аналитическое доказательство лемм 2.1 и 2.2 можно проверить, написав программу и протестировав алгоритм. Например, в таблица 2.4 показаны времена выполнения бинарного и последовательного поиска для поисков в таблице размером
(включая в случае бинарного поиска и затраты на сортировку таблицы) при различных значениях
и
. Здесь мы не будем рассматривать реализацию программы и проводить эксперименты, поскольку похожие задачи будут подробно рассмотрены в лекция №6 и 11. Кроме того, использование библиотечных и внешних функций и другие детали создания программ из отдельных компонентов, включая и функцию sort, объясняются в лекция №3. Так что пока мы просто подчеркнем, что проведение эмпирического тестирования - это неотъемлемая часть оценки эффективности алгоритма.
Таблица 2.4 подтверждает наше наблюдение, что функциональный рост времени выполнения позволяет предсказать производительность в случае больших значений параметров на основе эмпирического изучения работы алгоритма при малых значениях. Сочетание математического анализа и эмпирического изучения убедительно показывает, что предпочтительным алгоритмом является бинарный поиск.
Данный пример является прототипом нашего общего подхода к сравнению алгоритмов. Математический анализ используется для оценки частоты выполнения абстрактных операций в алгоритме, а затем на основе этих результатов выводится функциональная форма времени выполнения, которая позволяет проверить и расширить эмпирические данные.
При необходимости все более точных алгоритмических решений вычислительных задач и сложного математического анализа характеристик производительности мы будем обращаться за математическими результатами к специальной литературе, чтобы основное внимание в книге уделять самим алгоритмам. Здесь нет возможности производить тщательное математическое и эмпирическое изучение всех алгоритмов, а нашей главной задачей является определение наиболее существенных характеристик производительности. При этом, в принципе, всегда можно разработать научную основу, необходимую для осознанного выбора алгоритмов для важных приложений.
Упражнения
-
2.47. Найдите среднее число сравнений, используемых программой 2.1, если
поисков оказались успешными,
.
-
2.48. Оцените вероятность того, что хотя бы одно из
случайных десятизначных чисел будет содержаться в наборе из
чисел, при
и
.
-
2.49. Напишите вызывающую программу, которая генерирует
целых чисел и помещает их в массив, затем подсчитывает количество
случайных целых чисел, которые совпадают с одним из чисел массива, используя последовательный поиск. Запустите программу при
и
.
- 2.50. Сформулируйте и докажите лемму, аналогичную лемме 2.3 для бинарного поиска.
Приведенные ниже относительные времена выполнения подтверждают наши аналитические результаты: в случае поисков в таблице из
объектов время последовательного поиска пропорционально MN, а время бинарного поиска -
. При удвоении
время последовательного поиска также удваивается, а время бинарного поиска ненамного увеличивается. Последовательный поиск неприменим для очень больших
и
, а бинарный поиск выполняется достаточно быстро даже для огромных таблиц.
N |
![]() |
![]() |
![]() | |||
---|---|---|---|---|---|---|
S | B | S | B | S | B | |
125 | 1 | 1 | 13 | 2 | 130 | 20 |
250 | 3 | 0 | 25 | 2 | 251 | 22 |
500 | 5 | 0 | 49 | 3 | 492 | 23 |
1250 | 13 | 0 | 128 | 3 | 1276 | 25 |
2500 | 26 | 1 | 267 | 3 | 28 | |
5000 | 53 | 0 | 533 | 3 | 30 | |
12500 | 134 | 1 | 1337 | 3 | 33 | |
25000 | 268 | 1 | 3 | 35 | ||
50000 | 537 | 0 | 4 | 39 | ||
100000 | 1269 | 1 | 5 | 47 |
Обозначения:
S последовательный поиск (программа 2.1)
B бинарный поиск (программа 2.2)
Гарантии, предсказания и ограничения
Время выполнения большинства алгоритмов зависит от входных данных. Обычно нашей целью при анализе алгоритмов является устранение каким-либо образом этой зависимости: необходимо иметь возможность сказать что-то о производительности наших программ, что почти не зависит от входных данных, так как в общем случае неизвестно, какими будут входные данные при каждом новом запуске программы. Примеры из раздела 2.6 иллюстрируют два основных подхода, которые применяются в этом случае: анализ производительности в худшем случае и анализ производительности в среднем.
Изучение производительности алгоритмов в худшем случае привлекательно тем, что оно позволяет гарантированно сказать что-либо о времени выполнения программ. Мы говорим, что количество выполнений определенных абстрактных операций меньше, чем определенная функция от объема входных данных, независимо от значений этих данных. Например, лемма 2.3 представляет собой пример такой гарантии для бинарного поиска, а лемма 1.3 - для взвешенного быстрого объединения. Если гарантированное время мало, как в случае с бинарным поиском, то это хорошо: значит, удалось устранить ситуации, когда программа работает медленно. Поэтому программы с хорошими характеристиками в худшем случае являются основной целью разработки алгоритмов.
Однако при анализе производительности в худшем случае существуют и некоторые трудности. Для некоторых алгоритмов может существовать весомая разница между временем, необходимым для решения задачи в случае худших входных данных, и временем, необходимым для данных, которые обычно встречаются на практике. Например, быстрое объединение в худшем случае требует времени выполнения, пропорционального , но лишь
для обычных данных. Часто не удается доказать, что существуют входные данные, для которых время выполнения алгоритма достигает определенного предельного значения; можно лишь доказать, что время выполнения наверняка ниже этого предела. Более того, для некоторых задач алгоритмы с хорошей производительностью в худшем случае гораздо сложнее других алгоритмов. Часто бывает так, что алгоритм с хорошими характеристиками в худшем случае при работе с обычными данными оказывается медленнее, чем более простые алгоритмы, или же при незначительном выигрыше в скорости он требует дополнительных усилий для достижения хороших характеристик в худшем случае. Для многих приложений другие качества - переносимость и надежность - более важны, чем гарантии для худшего случая. Например, как было показано в лекция №1, взвешенное быстрое объединение со сжатием пути обеспечивает лучшую гарантированную производительность, чем взвешенное быстрое объединение, но для типичных данных, встречающихся на практике, эти алгоритмы имеют примерно одинаковое время выполнения.
Изучение средней производительности алгоритмов привлекательно тем, что оно позволяет делать предположения о времени выполнения программ. В простейшем случае можно точно охарактеризовать входные данные алгоритма; например, алгоритм сортировки может выполняться для массива из случайных целых чисел или геометрический алгоритм может обрабатывать набор из
случайных точек на плоскости с координатами между 0 и 1. Затем можно подсчитать, сколько раз в среднем выполняется каждая инструкция, и вычислить среднее время выполнения программы, умножив частоту выполнения каждой инструкции на время ее выполнения и просуммировав по всем инструкциям.
Однако и в анализе средней производительности существуют трудности. Во-первых, модель входных данных может неточно характеризовать данные, встречающиеся на практике, или же естественная модель входных данных может вообще не существовать. Мало кто будет возражать против использования таких моделей входных данных, как "случайно упорядоченный файл" для алгоритма сортировки или "множество случайных точек" для геометрического алгоритма, и для таких моделей можно получить математические результаты, которые будут точно предсказывать производительность программ в реальных приложениях. Но как можно характеризовать входные данные для программы, которая обрабатывает текст на английском языке? Даже для алгоритмов сортировки в определенных приложениях рассматриваются модели, отличные от случайно упорядоченных данных. Во-вторых, анализ может требовать глубоких математических выкладок. Например, сложно выполнить анализ средней производительности для алгоритмов объединение-поиск. Хотя вывод таких результатов обычно выходит за рамки этой книги, мы будем иллюстрировать их природу некоторыми классическими примерами, а также, при необходимости, будем ссылаться на важные результаты (к счастью, анализ большинства алгоритмов можно найти в исследовательской литературе). В третьих, знания среднего значения времени выполнения не всегда достаточно: может понадобиться среднеквадратичное отклонение или другие сведения о распределении времени выполнения, вывод которых может оказаться еще более трудным. В частности, нас будет часто интересовать вероятность того, что алгоритм будет работать значительно медленнее, нежели ожидается.
Во многих случаях на первое возражение можно ответить превращением случайности в достоинство. Например, если случайным образом "взболтать" массив перед сортировкой, то предположение о случайном порядке элементов массива будет выполнено. Для таких алгоритмов, которые называются рандомизированными (randomized), анализ средней производительности приводит к ожидаемому времени выполнения в строгом вероятностном смысле. Более того, часто можно доказать, что вероятность медленной работы такого алгоритма пренебрежимо мала. К подобным алгоритмам относятся быстрая сортировка ( лекция №9), рандомизированные BST (лекция №13) и хеширование (лекция №14).
Вычислительная сложность - это направление анализа алгоритмов, где рассматриваются фундаментальные ограничения, которые могут возникнуть при анализе алгоритмов. Общая цель заключается в определении времени выполнения в худшем случае лучшего алгоритма для данной задачи с точностью до постоянного множителя. Эта функция называется сложностью задачи.
Анализ производительности в худшем случае с использованием О-нотации освобождает аналитика от необходимости включать в рассмотрение характеристики конкретной машины. Выражение "время выполнения алгоритма равно Of(N))" не зависит от входных данных, полезно для распределения алгоритмов по категориям вне зависимости от входных данных и деталей реализации и таким образом отделяет анализ алгоритма от любой конкретной его реализации. В анализе мы, как правило, отбрасываем постоянные множители. В большинстве случаев, если нужно знать, чему пропорционально время выполнения алгоритма - или
- не имеет значения, где будет выполняться алгоритм: на небольшом компьютере или на суперкомпьютере. Не имеет значения даже то, хорошо или плохо реализован внутренний цикл алгоритма.
Когда можно доказать, что время выполнения алгоритма в худшем случае равно , то говорят, что
является верхней границей сложности задачи. Другими словами, время выполнения лучшего алгоритма не больше, чем время любого другого алгоритма для данной задачи.
Мы постоянно стремимся улучшать алгоритмы, но однажды наступает момент, когда никакое изменение не может снизить время выполнения. Для каждой задачи желательно знать, где необходимо остановиться в улучшении алгоритма - то есть мы ищем нижнюю границу сложности. Для многих задач можно доказать, что любой алгоритм решения задачи должен использовать определенное количество фундаментальных операций. Доказательство существования нижней границы - сложное дело, связанное с тщательным созданием модели машины и разработкой замысловатых теоретических конструкций входных данных, которые трудны для любого алгоритма. Мы редко затрагиваем вопрос о нахождении нижних границ, но они представляют собой вычислительные барьеры при разработке алгоритмов, и при необходимости мы будем о них упоминать.
Когда изучение сложности задачи показывает, что верхняя и нижняя границы алгоритма совпадают, тогда можно быть уверенным в том, что нет смысла пытаться разработать алгоритм, который был бы существенно быстрее, чем наилучший из известных. В этом случае остается сконцентрировать внимание на реализации. Например, бинарный поиск является оптимальным в том смысле, что никакой алгоритм, использующий только сравнения, не сможет в худшем случае обойтись меньшим их количеством, чем бинарный поиск.
Верхняя и нижняя границы совпадают также и для алгоритмов объединение-поиск, использующих указатели. В 1975 г. Тарьян (Tarjan) показал, что алгоритм взвешенного быстрого объединения со сжатием пути требует менее чем переходов по указателям в худшем случае, и что любой алгоритм с указателями должен перейти более чем по постоянному числу указателей в худшем случае. Другими словами, нет смысла в поиске какого-либо нового улучшения, которое гарантировало бы решение задачи линейным числом операций
. На практике эта разница едва ощутима, поскольку
очень мало, однако поиск простого линейного алгоритма для этой задачи был темой исследований в течение долгого времени, и найденная Тарьяном нижняя граница направила усилия исследователей на другие задачи. Более того, оказывается, нельзя обойти функции вроде довольно сложной функции
, поскольку они присущи самой задаче.
Многие алгоритмы в этой книге были предметом глубокого математического и эмпирического анализа, слишком сложного, чтобы обсуждать его здесь. Но именно на основе этих исследований мы рекомендуем многие из тех алгоритмов, которые описаны далее.
Не все алгоритмы стоят такого тщательного рассмотрения; ведь при создании алгоритмов желательно работать с приближенными признаками производительности, чтобы не усложнять процесс разработки излишними деталями. По мере усложнения разработки то же самое должно происходить и с анализом, с привлечением более сложных математических инструментов. Часто процесс создания алгоритма приводит к детальному изучению сложности, которые, в свою очередь, ведут к таким теоретическим алгоритмам, которые далеки от каких-либо практических приложений. Распространенная ошибка - считать, что грубый анализ исследований сложности сразу же приведет к эффективному и удобному алгоритму. Такие ожидания завершаются неприятными сюрпризами. С другой стороны, вычислительная сложность - это мощный инструмент, который помогает определить границы производительности при разработке алгоритма и может послужить отправной точкой для сужения промежутка между верхней и нижней границами.
В этой книге мы придерживаемся точки зрения, что разработка алгоритма, тщательная его реализация, математический анализ, теоретические исследования и эмпирический анализ все вместе вносят важный вклад в создание элегантных и эффективных программ. Мы стремимся получить информацию о свойствах программ всеми доступными средствами, а затем, на основе полученной информации, модифицировать их или разработать новые программы. У нас нет возможности выполнять тщательное тестирование и анализ каждого алгоритма во всех программных средах на любой возможной машине, но мы можем воспользоваться известными эффективными реализациями алгоритмов, а затем улучшать и сравнивать их, если потребуется высокая производительность. При необходимости мы будем достаточно подробно рассматриваться наиболее важные методы, чтобы понять, почему они работают так хорошо.
Упражнение
2.51. Известно, что временная сложность одной задачи равна N*log N, а другой - N3. Что следует из данного утверждения об относительной производительности алгоритмов, которые решают эти задачи?
Ссылки к части I
Существует множество начальных учебников по программированию. Стандартный справочник по языку C++ - книга Страуструпа (Stroustup), а наилучшим источником конкретных сведений о C с примерами программ, многие из которых верны и для C++ и написаны в том же духе, что и программы в этой книге, является книга Кернигана и Ричи (Kernigan, Ritchie).
Несколько вариантов алгоритмов для задачи объединение-поиск из лекция №1 собраны и объяснены в статье Ван Левена и Тарьяна (van Leewen, Tarjan).
В книгах Бентли (Bentley) описаны в том же стиле, что и изложенный здесь материал, несколько подробных примеров использования различных подходов при разработке и реализации алгоритмов для решения различных интересных задач.
Классический справочник по анализу алгоритмов на основе измерений асимптотической производительности в худшем случае - это книга Ахо, Хопкрофта и Ульмана (Aho, Hopcroft, Ullman). Книги Кнута (Knuth) содержат более полный анализ средней производительности и являются заслуживающим доверия описанием конкретных свойств многих алгоритмов. Более современные работы - книги Гонне, Баеса-Ятеса (Gonnet, Baeza-Yates) и Кормена, Лейзерсона, Ривеста (Cormen, Leiserson, Rivest). Обе они содержат обширные списки ссылок на исследовательскую литературу.
Книга Грэма, Кнута, Паташника (Graham, Knuth, Patashnik) рассказывает о разделах математики, которые обычно встречаются при анализе алгоритмов; этот же материал разбросан и в упомянутых ранее книгах Кнута. Книга Седжвика и Флажоле (Sedgewick and Flajolet) представляет собой исчерпывающее введение в предмет.
1. A.V Aho, J.E. Hopcroft, and J.D. Ullman, The Design and Analysis of Algorithms, Addison-Wesley, Reading, MA, 1975.
2. J.L. Bentley, Programming Pearls, Addison-Wesley, Reading, MA, 1985; More Programming Pearls, Addison-Wesley, Reading, MA, 1988.
3. R. Baeza-Yates and G.H. Gonnet, Handbook of Algorithms and Data Stru^ures, second edition, Addison-Wesley, Reading,MA, 1984.
4. Томас Х. Кормен, Чарльз И. Лейзерсон, Рональд Л. Ривест, Клиффорд Штайн, Алгоритмы: построение и анализ, 2-е издание, ИД "Вильямс", 2009 г.
5. R.L. Graham, D.E. Knuth, and O. Patashnik, Con^ete Mathemattes, Addison-Wesley, Reading, MA, 1988.
6. Брайан У. Керниган, Деннис М. Ритчи, Язык программирования C (Си), 2-е издание, ИД "Вильямс", 2008 г.
7. Д.Э. Кнут, Искусство программирования, том 1: Основные алгоритмы, 3-е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 2: Получисленные алгоритмы, 3-е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 3. Сортировка и поиск, 2-е издание, ИД "Вильямс", 2008 г.
8. R. Sedgewick and P. Flajolet, An Introdu^on to the Analysis of Algorithms, Addison-Wesley, Reading, MA, 1996.
9. B. Stroustrup, The C+ + Programming Language, third edition, Addison-Wesley, Reading MA, 1997.
10. J. van Leeuwen and R.E. Tarjan, "Worst-case analysis of set-union algorithms", Journal of the ACM, 1984.
Глава 2. Структуры данных
Лекция 3. Элементарные структуры данных
Организация обрабатываемых данных является важным этапом разработки программ. Для реализации многих приложений правильный выбор структуры данных - единственное важное решение: когда выбор сделан, разработка алгоритмов не вызывает затруднений. Для одних и тех же данных различные структуры занимают различный объем памяти. Одни и те же операции с различными структурами данных приводят к алгоритмам различной эффективности. Выбор алгоритмов и структур данных тесно взаимосвязан, и программисты постоянно изыскивают способы повышения быстродействия или экономии памяти за счет оптимального выбора.
Структура данных не является пассивным объектом: необходимо принимать во внимание выполняемые с ней операции (и алгоритмы, используемые для этих операций). Эта концепция формально выражена в понятии типа данных (data type). В данной главе основное внимание уделяется конкретным реализациям базовых принципов, используемых для организации данных. Будут рассмотрены основные методы организации данных и управления ими, изучен ряд примеров, демонстрирующих преимущества каждого подхода, и сопутствующие вопросы, такие как управление памятью. В лекция №4 будут введены абстрактные типы данных, в которых описание типов данных отделено от их реализации.
Мы рассмотрим свойства массивов, связных списков и строк. Эти классические структуры данных имеют широкое применение: вместе с деревьями (см. лекция №5) они составляют основу почти всех алгоритмов, рассматриваемых в данной книге. Мы изучим различные примитивные операции для управления структурами данных и получим базовый набор средств, которые позволят разрабатывать сложные алгоритмы для более трудных задач.
Хранение данных в виде объектов переменных размеров, а также в связных структурах, требует знания, как система управляет памятью, которую она выделяет программам для данных. Эта тема рассматривается не во всех подробностях, поскольку много важных моментов зависит от системы и аппаратных средств. Но мы все же ознакомимся с принципами управления памятью и несколькими базовыми механизмами решения этой задачи. Кроме того, будут рассмотрены конкретные методы, в которых используются механизмы выделения памяти для программ на C++.
В конце главы приводятся несколько примеров составных структур (compound structure), таких как массивы связных списков и массивы массивов. Построение абстрактных механизмов все большей сложности из более простых постоянно встречается в данной книге. Мы рассмотрим ряд примеров, которые в дальнейшем послужат основой для более совершенных алгоритмов.
Изучаемые в этой главе структуры данных - важные строительные блоки, которые можно использовать естественным образом как в C++, так и во многих других языках программирования. В лекция №5 будет рассмотрена еще одна важная структура данных - дерево (tree). Массивы, строки, связные списки и деревья служат базовыми элементами большинства алгоритмов, о которых идет речь в книге. В лекция №4 рассматривается использование конкретных представлений, разработанных на основе абстрактных типов данных. Эти представления могут применяться в различных приложениях. Остальная часть книги посвящена различным модификациям базовых средств, деревьев и абстрактных типов данных для создания алгоритмов, решающих более сложные задачи. Они также могут служить основой для высокоуровневых абстрактных типов данных в различных приложениях.
Строительные блоки
В этом разделе рассматриваются базовые низкоуровневые конструкции, используемые для хранения и обработки информации в языке C++. Все обрабатываемые компьютером данные в конечном счете состоят из отдельных битов. Однако написание программ, обрабатывающих только биты - слишком трудоемкое занятие. Типы позволяют указывать, как будут использоваться определенные наборы битов, а функции позволяют задавать операции, выполняемые над данными. Структуры C++ используются для группирования разнородных частей информации, а указатели (pointer) служат для косвенных ссылок на информацию. В этом разделе будут рассмотрены базовые механизмы языка C++ - чтобы уяснить общий принцип организации программ. Наша главная цель - заложить основу для разработки структур высших уровней ( лекция №4 и лекция №5), на базе которых будет построено большинство алгоритмов, рассматриваемых в данной книге.
Программы обрабатывают информацию, которая происходит из математических или естественных языковых описаний окружающего мира. Поэтому вычислительные среды обеспечивают встроенную поддержку основных строительных блоков подобных описаний - чисел и символов. В C++ программы используют совсем немного базовых типов данных:
- Целые числа (int).
- Числа с плавающей точкой (float).
- Символы (char).
На эти типы часто ссылаются по их именам в языке C++ (int, float и char), хотя часто используются обобщенные термины - целое (integer), число с плавающей точкой и символ (character). Символы чаще всего используются в абстракциях более высокого уровня - например, для создания слов и предложений. Поэтому обзор символьных данных будет отложен до раздела 3.6, а пока обратимся к числам.
Для представления чисел используется фиксированное количество битов. Таким образом, тип int относится к целым числам некоего диапазона, который зависит от количества битов, используемых для их представления. Числа с плавающей точкой содержат приблизительные значения действительных чисел, а используемое для их представления количество битов определяет точность этого приближения. В C++ мы выбираем либо большую точность, либо экономию памяти. Для целых имеются типы int, long int и short int, а для чисел с плавающей точкой - float и double. В большинстве систем эти типы соответствуют готовым аппаратным представлениям. Количество битов, используемое для представления чисел, а, следовательно, и диапазон значений (для целых) или точность (для чисел с плавающей точкой), зависит от компьютера (см. упражнение 3.1), хотя язык C++ предоставляет определенные гарантии. Ради простоты в этой книге обычно используются типы int и float - за исключением случаев, когда необходимо подчеркнуть, что задача требует применения больших чисел.
В современном программировании при выборе типов данных больше ориентируются на потребности программы, чем на возможности компьютера, прежде всего из соображений переносимости приложений. Например, тип short int рассматривается как объект, который может принимать значения от -32767 до 32767, а не 16-битовый объект. Кроме того, в концепцию целых чисел входят и операции, которые могут с ними выполняться: сложение, умножение и т.д.
Определение 3.1. Тип данных - это множество значений и набор операций над ними.
Операции связаны с типами, а не наоборот. При выполнении операции необходимо обеспечить, чтобы ее операнды и результат были нужного типа. Пренебрежение этим правилом - распространенная ошибка программирования. В некоторых случаях C++ выполняет неявное преобразование типов; в других используется приведение (cast), т.е. явное преобразование типов. Например, если x и N целые числа, выражение
((float) x) / N
включает оба типа преобразований: оператор (float) выполняет приведение - величина x преобразуется в значение с плавающей точкой. Затем, в соответствии с правилами C++, выполняется неявное преобразование N, чтобы оба аргумента операции деления были значениями с плавающей точкой.
Многие операции, связанные со стандартными типами данных (такие как арифметические), встроены в язык C++. Другие операции существуют в виде функций, которые определены в стандартных библиотеках функций. Остальные операции реализуются в функциях C++, которые определены в программах (см. программу 3.1). Таким образом, концепция типа данных связана не только со встроенными типами (целые, значения с плавающей точкой и символы). Разработчики часто определяют собственные типы данных, что служит эффективным средством организации программных средств. При определении простой функции C++, по сути, создается новый тип данных. Реализуемая функцией операция добавляется к операциям, определенным для типов данных, которые представлены аргументами функции. В некотором смысле, каждая программа C++ является типом данных - списком множеств значений (встроенных или других типов) и связанных с ними операций (функций). Возможно, эта концепция слишком обобщена, чтобы быть полезной, но мы еще убедимся в ценности рассмотрения программ как типов данных.
При написании программ одна из задач заключается в такой их организации, чтобы они были применимы к как можно большему количеству ситуаций. Тогда старую программу можно применить для решения новых задач, которые иногда совершенно не связаны с первоначальной. Во-первых, осмысление и точное определение используемых программой операций позволяет легко распространить ее на любой тип данных, для которого эти операции могут поддерживаться. Во-вторых, после точного определения действий программы можно добавить выполняемую ей абстрактную операцию к операциям, которые имеются в нашем распоряжении для решения новых задач.
Программа 3.1. Определение функций
Для реализации новых операций с данными в C++ используется механизм определения функций (function definition).
Все функции имеют список аргументов и, возможно, возвращаемое значение (return value). Приведенная функция lg имеет один аргумент и возвращаемое значение, оба типа int. Функция main не принимает аргументов и возвращает значение типа int (по умолчанию - значение 0, которое означает успешное завершение).
Функция объявляется (declare) путем присвоения ей имени и типа возвращаемого значения. Первая строка программы ссылается на библиотечный файл, который содержит объявления cout, << и endl. Во второй строке объявляется функция lg. Если функция определена до ее использования (см. следующий абзац), объявление необязательно. Объявление предоставляет информацию, необходимую другим функциям для вызова данной с использованием аргументов допустимого типа. Вызывающая функция может использовать данную функцию в выражении - наподобие переменных, имеющих тот же тип, что и возвращаемое значение.
Функции определяются (define) посредством кода C++. Все программы на C++ содержат описание функции main, а в данном примере также имеется описание функции lg. Определение функции содержит имена аргументов (называемых параметрами) и описание вычислений с этими именами, как если бы они были локальными переменными. При вызове функции эти переменные инициализируются, принимая значения передаваемых аргументов, после чего выполняется код функции. Оператор return служит для завершения выполнения функции и передачи возвращаемого значения вызывающей функции. Обычно вызывающая функция не испытывает других воздействий, хотя нам встретится много исключений из этого правила.
Разграничение определений и объявлений создает гибкость в организации программ. Например, они могут содержаться в разных файлах (см. текст). Кроме того, в простой программе, подобной приведенной здесь, определение функции lg можно поместить перед определением main и опустить объявление.
#include <iostream.h> int lg(int); int main() { for (int N = 1000; N <= 1000000000; N *= 10) cout << lg(N) << " " << N << endl; } int lg(int N) { for (int i = 0; N > 0; i++, N /= 2) ; return i; }
В программе 3.2 реализованы несложные вычисления с использованием простых типов данных, определенных с помощью операции typedef и функции (которая сама реализована с помощью библиотечной функции). Главная функция ссылается на определяемый тип данных, а не встроенный числовой тип. Не указывая тип чисел, обрабатываемых программой, мы расширяем ее потенциальную область применения. Подобный подход может продлить время жизни программы. Если из-за появления нового приложения, компилятора или компьютера нам придется использовать новый тип чисел, в программе будет достаточно просто изменить тип данных.
Программа 3.2. Типы чисел
Эта программа вычисляет среднее значение ц и среднеквадратичное отклонение ст последовательности целых чисел
x1 x2, ..., xN
, сгенерированных библиотечной процедурой rand. Ниже приводятся математические формулы:
Обратите внимание, что прямая реализация формулы определения требует одного прохода для вычисления среднего и еще одного прохода для вычисления суммы квадратов разностей членов последовательности и среднего значения. Однако преобразование формулы позволяет вычислить
за один проход.
Объявление typedef используется для локализации указания, что данные имеют тип int. Например, typedef и описание функции randNum могут содержаться в отдельном файле (указываемом директивой include). Впоследствии можно будет использовать программу для тестирования случайных чисел другого типа, изменив этот файл (см. текст).
Независимо от типа данных программа использует тип int для индексов и тип float для вычисления среднего значения и среднеквадратичного отклонения. Она сможет работать только если заданы преобразования данных в тип float.
#include <iostream.h> #include <stdlib.h> #include <math.h> typedef int Number; Number randNum() { return rand(); } int main(int argc, char *argv[]) { int N = atoi(argv[1]); float ml = 0.0, m2 = 0.0; for (int i = 0; i < N; i++) { Number x = randNum(); ml += ((float) x)/N; m2 += ((float) x*x)/N; } cout << " Среднее: " << ml << endl; cout << "Ср.-кв.откл.: " << sqrt(m2-m1*m1) << endl; }
Этот пример не является полным решением задачи вычисления средних величин и среднеквадратичных отклонений в программе, не зависящей от типа данных; подобная цель и не ставилась. Например, программа требует преобразования чисел типа Number в тип float, чтобы они могли участвовать в вычислении среднего значения и отклонения. В данном виде программа зависима от приведения к типу float, предусмотренного для встроенного типа int. Вообще говоря, можно явно описать такое приведение для любого типа Number.
При попытке выполнения каких-либо действий, помимо арифметических, вскоре возникнет необходимость добавления к типу данных новых операций. Например, может потребоваться вывод чисел на экран или принтер. При любой попытке разработать тип данных на основе знания об операциях, необходимых для программы, необходимо отыскать компромисс между уровнем обобщения и простотой реализации и использования.
Имеет смысл подумать, как изменять тип данных таким образом, чтобы программа 3.2 работала и с другими типами чисел, скажем, float вместо int. В языке C+ + предусмотрен ряд механизмов, позволяющих воспользоваться тем, что определение типа данных локализовано. Для такой небольшой программы проще всего сделать копию файла, затем изменить объявление typedef на typedef float Number, а тело процедуры randNum на return 1.0*rand()/RAND_MAX; (при этом будут возвращаться случайные числа с плавающей точкой в диапазоне от 0 до 1). Однако даже для такой простой программы этот подход неудобен: теперь у нас две копии программы, и все последующие изменения придется проводить в обеих копиях. В C++ возможно другое решение - поместить описания typedef и randNum в отдельный заголовочный файл (header file) с именем, например, Number.h и заменить их в коде программы 3.2 директивой
#include "Number.h"
Затем можно создать второй заголовочный файл с другими описаниями typedef и randNum и использовать главную программу 3.2 без всяких изменений, меняя лишь имя нужного файла на Number.h.
Третий вариант рекомендован и широко распространен в программировании на C, C++ и других языках. Он предусматривает разбиение программы на три файла:
- Интерфейс, где определяется структура данных и объявляются функции, используемые для управления этой структурой
- Реализация функций, объявленных в интерфейсе
- Клиентская программа, которая использует функции, объявленные в интерфейсе, для работы на более высоком уровне абстракции
Подобная организация позволяет использовать программу 3.2 с целыми и значениями с плавающей точкой, либо расширить ее для обработки других типов данных, просто откомпилировав вместе со специальным кодом для нужного типа данных. Ниже мы рассмотрим изменения, необходимые для данного примера.
Под термином "интерфейс" подразумевается описание типа данных. Это соглашение между клиентской программой и программой реализации. Клиент соглашается обращаться к данным только через функции, определенные в интерфейсе, а реализация соглашается предоставлять необходимые функции.
Для программы 3.2 интерфейс должен включать следующие объявления:
typedef int Number; Number randNum();
Первая строка указывает тип обрабатываемых данных, а вторая - операции, связанные с этим типом. Этот код можно поместить в файл с именем, например, Number.h, где на него будут независимо ссылаться и клиенты, и реализации.
Реализация интерфейса Number.h представляет собой функцию randNum, которая может содержать следующий код:
#include <stdlib.h> #include "Number.h" Number randNum() { return rand(); }
Первая строка содержит ссылку на предоставленный системой интерфейс, в котором описана функция rand(). Вторая строка - ссылка на реализуемый нами интерфейс (она включена для проверки того, что реализуемая и объявленная функции имеют одинаковый тип). Две последних строки содержат код функции. Этот код можно хранить, например, в файле int.c. Реальный код функции rand содержится в стандартной библиотеке времени выполнения C++.
Клиентская программа, соответствующая примеру 3.2, будет начинаться с директив include для интерфейсов, где объявлены используемые функции:
#include <iostream.h> #include <math.h> #include "Number.h"
После этих трех строк может следовать описание функции main из программы 3.2. Этот код может храниться, например, в файле с именем avg.c.
Результатом совместной компиляции программ avg.c и int.c будут те же функциональные возможности, что и реализуемые программой 3.2. Но рассматриваемая реализация более гибкая, поскольку связанный с типом данных код инкапсулирован и может использоваться другими клиентскими программами, а также потому, что программа avg.c без изменений может использоваться с другими типами данных. По-прежнему предполагается, что любой тип, используемый под именем Number, преобразуется в тип float. C++ позволяет описывать это преобразование, а также описывать желаемые встроенные операторы (наподобие += и <<) как часть нового типа данных. Использование одних и тех же имен функций или операторов для различных типах данных называется перегрузкой (overloading).
Помимо рассмотренного варианта "клиент-интерфейс-реализация" существует много других способов поддержки различных типов данных. Эта концепция не зависит от определенного языка программирования, либо метода реализации. Однако имена файлов не являются частью языка, и, возможно, придется изменить рекомендованный выше простой метод для функционирования в конкретной среде C++ (в системах существуют различные соглашения или правила, относящиеся к содержимому файлов заголовков, а некоторые системы требуют определенных расширений, таких как .C или .cxx для файлов программ). Одна из наиболее важных особенностей C++ - понятие классов, которые предоставляют удобный метод описания и реализации типов данных. В этой главе за основу взято простое решение, но впоследствии практически повсеместно будут использоваться классы. лекция №4 посвящена применению классов для создания базовых типов данных, важных для разработки алгоритмов. Кроме того, там будет подробно рассмотрено взаимоотношение между классами C++ и парадигмой "клиент-интерфейс-реализация".
Такие механизмы предназначены в основном для поддержки бригад программистов, решающих задачи создания и сопровождения очень больших приложений. Но для нас данный материал важен потому, что на протяжении книги эти механизмы будут применяться как естественный способ замены усовершенствованных реализаций алгоритмов и структур данных старыми методами, чтобы сравнивать различные алгоритмы решения одних и тех же прикладных задач.
Часто приходится создавать структуры данных, которые позволяют обрабатывать наборы данных. Структуры данных могут быть большими, либо интенсивно используемыми. Поэтому необходимо выделить наиболее важные операции, которые будут выполняться над данными, и иметь методы эффективной реализации этих операций. Выполнение этих задач - первые шаги в процессе последовательного создания абстракций более высокого уровня из абстракций низших уровней. Этот процесс представляет собой удобный способ разработки все более мощных программ. Простейшим механизмом группировки данных в C++ являются массивы (array), которые рассматриваются в разделе 3.2, и структуры (structure), о которых пойдет речь ниже.
Структуры представляют собой сгруппированные типы, используемые для описания наборов данных. Этот подход позволяет управлять всем набором как единым целым, сохраняя при этом возможность ссылаться на отдельные компоненты по их именам. Структуры в языке C++ можно использовать для описания нового типа данных, а также для определения операций с этими данными. Другими словами, со сгруппированными данными можно обращаться примерно так же, как со встроенными типами вроде int и float. Как будет показано ниже, можно присваивать имена переменным и передавать эти переменные в качестве аргументов функций, а также выполнять множество других операций.
Например, при обработке геометрических данных используется абстрактное понятие точки на плоскости. Следовательно, можно написать
struct point { float x; float y; } ;
где имя point будет использоваться для указания пар чисел с плавающей точкой.
Выражение
struct point a, b;
объявляет две переменные этого типа. Можно ссылаться по имени на отдельные члены структуры. Например, операторы
a.x = 1.0; a.y = 1.0; b.x = 4.0; b.y = 5.0;
устанавливают значения переменных таким образом, что a представляет точку (1,1), а b - точку (4,5). Кроме того, структуры можно передавать функциям в качестве аргументов. Например, код
float distance(point a, point b) { float dx = a.x - b.x, dy = a.y - b.y; return sqrt(dx*dx + dy*dy); }
описывает функцию, которая вычисляет расстояние между двумя точками на плоскости. Это демонстрирует естественный способ использования структур для группировки данных в типичных приложениях.
Программа 3.3 представляет собой интерфейс, который содержит описание типа данных для точек на плоскости: в нем используется структура для представления точек и содержится операция вычисления расстояния между двумя точками. Функция, реализующая данную операцию, приведена в программе 3.4. Подобная схема "интерфейс-реализация" используется для описания типов данных при любой возможности, поскольку в ней описание инкапсулировано в интерфейсе, а реализация выражена в прямой и понятной форме. Тип данных используется в клиентской программе за счет включения интерфейса и компиляции реализации совместно с клиентом (либо с помощью средств раздельной компиляции). Программа реализации 3.4 включает интерфейс (программу 3.3), чтобы обеспечить соответствие описания функции потребностям клиента. Смысл в том, чтобы клиентские программы могли работать с точками без необходимости принимать какие-либо допущения об их представлении. В лекция №4 будет показано, как осуществить следующий этап разделения клиента и реализации.
Программа 3.3. Интерфейс типа данных point
Этот интерфейс описывает тип данных, состоящий из набора значений "пары чисел с плавающей точкой" и операции "вычисление расстояния между двумя точками".
struct point { float x; float y; }; float distance(point, point);
Программа 3.4. Реализация структуры данных point
Здесь содержится определение функции distance, объявленной в программе 3.3. Используется библиотечная функция вычисления квадратного корня.
#include <math.h> #include "Point.h" float distance(point a, point b) { float dx = a.x - b.x, dy = a.y - b.y; return sqrt(dx*dx + dy*dy); }
Данный пример структуры point прост и включает два элемента одного типа. Обычно в структурах содержатся различные типы данных. Подобные структуры будут часто встречаться в оставшейся части главы.
Структуры позволяют группировать данные. Кроме того, в языке C++ можно связывать данные с операциями, которые должны с ними выполняться, с помощью механизма классов (class). Подробности определения классов с множеством примеров приводятся в лекция №4. Классы позволяют даже использовать программу, такую как 3.2, для обработки элементов типа point после описания соответствующих арифметических операций и преобразований типов для точек. Эта возможность использования ранее определенных операций высокого уровня абстракции даже для вновь созданных типов данных является одной из важных и выдающихся особенностей программирования на C++. Она основана на способности непосредственного определения собственных типов данных средствами языка. При этом классы позволяют не только описывать компоновку данных в структуре, но и точно задавать операции над данными (а также структуры данных и алгоритмы, которые их поддерживают). Классы формируют основу рассматриваемых в книге реализаций. Однако прежде чем приступить к детальному изучению описания и использования классов (лекция №4), необходимо рассмотреть ряд низкоуровневых механизмов для управления данными и их объединения.
Помимо предоставления основных типов int, float и char, а также возможности встраивать их в составные типы с помощью описателя struct, C++ допускает косвенные манипуляции с данными. Указатель (pointer) - это ссылка на объект в памяти (обычно реализуемая в виде машинного адреса). Чтобы объявить переменную a как указатель на целое значение, используется выражение int *a. На само целое можно ссылаться значение с помощью записи *a. Возможно объявление указателей на любой тип данных. Унарная операция & предоставляет машинный адрес объекта и удобна для инициализации указателей. Например, выражение *&a означает то же, что и a.
Косвенное обращение к объекту через указатель часто удобнее прямого обращения, а также может оказаться более эффективным, особенно для больших объектов. Множество примеров этого преимущества приведено в разделах с 3.3 по 3.7. Как будет показано, еще более важна возможность использования указателей в структурах данных способами, которые поддерживают эффективные алгоритмы обработки данных. Указатели служат основой многих структур данных и алгоритмов.
Простой и важный пример использования указателей связан с описанием функции, которая должна возвращать несколько значений. Например, следующая функция (использующая функции sqrt и atan2 из стандартной библиотеки) преобразует декартовы координаты в полярные:
polar(float x, float y, float *r, float *theta) { *r = sqrt(x*x + y*y); *theta = atan2(y, x); }
Аргументы передаются этой функции по значению - если функция и присвоит новое значение переменной аргумента, эта операция является локальной и скрыта от вызывающей функции. Поэтому функция не может изменять указатели на числа с плавающей точкой r и theta, но способна изменять значения самих чисел с помощью косвенного обращения. Например, если вызывающая функция содержит объявление float a, b, то вызов функции
polar(1.0, 1.0, &a, &b)
приведет к тому, что для a установится значение 1.414 214 ( ), а для b - значение 0.785398 (п/4). Оператор & позволяет передавать адреса a и b в функцию, которая работает с этими аргументами как с указателями.
В языке C++ можно достичь того же результата посредством ссылочных параметров:
polar(float x, float y, floats r, floats theta) { r = sqrt(x*x + y*y); theta = atan2(y, x); }
Запись floats означает "ссылка на float". Ссылки можно рассматривать как встроенные указатели, при каждом использовании которых выполняется автоматический переход. Например, в этой функции ссылка на theta означает ссылку на любую переменную float, используемую во втором аргументе вызывающей функции. Если вызывающая функция содержит объявление float a, b, как в примере из предыдущего абзаца, то в результате вызова функции polar(1.0, 1.0, a, b) переменной a будет присвоено значение 1.414214, а переменной b - значение 0.785398.
До сих пор речь в основном шла об описании отдельных информационных элементов, обрабатываемых программами. Во многих случаях необходимо работать с потенциально крупными наборами данных, и сейчас мы обратимся к основным методам достижения этой цели. Обычно термин структура данных относится к механизму организации информации для обеспечения удобных и эффективных средств управления и доступа к ней. Многие важные структуры данных основаны либо на одном из двух элементарных решений, рассматриваемых ниже, либо на обоих сразу. Массив (array) служит средством организации объектов четко упорядоченным образом, что более удобно для доступа, чем для управления. Список (list) позволяет организовать объекты в виде логической последовательности, что более удобно для управления, чем для доступа.
Упражнения
- 3.1. Найдите наибольшее и наименьшее числа, которые можно представить типами int, long int, short int, float и double в своей среде программирования.
- 3.2. Протестируйте генератор случайных чисел в своей системе. Для этого сгенерируйте N случайных целых чисел в диапазоне от 0 до r - 1 с помощью выражения rand() % r и вычислите среднее значение и среднеквадратичное отклонение для r = 10, 100 и 1000 и N = 103, 104, 105 и 106 .
- 3.3. Протестируйте генератор случайных чисел в своей системе. Для этого сгенерируйте N случайных чисел типа double в диапазоне от 0 до 1, преобразуя их в целые числа диапазона от 0 до r - 1 путем умножения на r и усечения результата. Затем вычислите среднее значение и среднеквадратичное отклонение для r = 10, 100 и 1000 и N = 103, 104, 105 и 106 .
- 3.4. Выполните упражнения 3.2 и 3.3 для r = 2, 4 и 16.
- 3.5. Реализуйте функции, позволяющие применять программу 3.2 для случайных разрядов (чисел, которые могут принимать значения только 0 и 1).
- 3.6. Определите структуру для представления игральных карт.
- 3.7. Напишите клиентскую программу, которая использует типы данных из программ 3.3 и 3.4, для следующей задачи: чтение последовательности точек (пар чисел с плавающей точкой) из стандартного устройства ввода и поиск точки, ближайшей к первой.
- 3.8. Добавьте к типу данных point (программы 3.3 и 3.4) функцию, которая определяет, лежат ли три точки на одной прямой, с допуском 10-4. Считайте, что все точки находятся в единичном квадрате.
- 3.9. Определите тип данных для треугольников, находящихся в единичном квадрате, включая функцию вычисления площади треугольника. Затем напишите клиентскую программу, которая генерирует случайные тройки пар чисел с плавающей точкой от 0 до 1 и вычисляет среднюю площадь сгенерированных треугольников.
Массивы
Возможно, наиболее фундаментальной структурой данных является массив, который определен как примитив в C++ и большинстве других языков программирования. В примерах лекция №1 уже встречалось использование массива в качестве основы разработки эффективного алгоритма. В этом разделе вы увидите еще ряд примеров.
Массив является фиксированным набором однотипных данных, которые хранятся в виде непрерывной последовательности. Доступ к данным осуществляется по индексам. Для ссылки на i-й элемент массива используется выражение a[i]. Перед выборкой элемента массива a[i] программист должен занести туда какое-то значение. Кроме того, в языке C++ программист должен сам следить, чтобы индексы были неотрицательны и меньше размера массива. Пренебрежение этими правилами является распространенной ошибкой программирования.
Фундаментальность массивов, как структур данных, заключается в их прямом соответствии системам памяти почти на всех компьютерах. Для извлечения содержимого слова из памяти машинный язык требует указания адреса. Таким образом, всю память компьютера можно рассматривать как массив, где адреса памяти соответствуют индексам. Большинство процессоров машинного языка транслируют программы, использующие массивы, в эффективные программы на машинном языке, в которых осуществляется прямой доступ к памяти. Можно с уверенностью сказать, что доступ к массиву с помощью выражения a[i] требует лишь нескольких машинных команд.
Простой пример использования массива демонстрируется в программе 3.5, которая распечатывает все простые числа, меньшие 10000. Используемый метод восходит к третьему столетию до н.э. и называется решетом Эратосфена (см. рис. 3.1).
Программа 3.5. Решето Эратосфена
Цель программы заключается в присвоении элементам a[i] значения 1, если i простое число, и значения 0 в противном случае. Сначала значение 1 присваивается всем элементам массива. Затем присваивается значение 0 элементам, индексы которых не являются простыми числами (кратны известным простым числам). Если после этого некоторый элемент a[i] сохраняет значение 1, его индекс является простым числом.
Поскольку массив состоит из элементов простейшего типа, принимающих только значения 0 или 1, для экономии памяти было бы лучше использовать массив битов, а не целых чисел. Кроме того, в некоторых средах программирования требуется, чтобы массив был глобальным, если значение N очень велико, либо можно выделять пространство для массива динамически (см. программу 3.6).
#include <iostream.h> static const int N = 1000; int main() { int i, a[N]; for (i = 2; i < N; i++) a[i] = 1; for (i = 2; i < N; i++) if (a[i]) for (int j = i; j*i < N; j++) a[i*j] = 0; for (i = 2; i < N; i++) if (a[i]) cout << " " << i; cout << endl; }

Рис. 3.1. Решето Эратосфена
Для вычисления простых чисел, меньших 32, сначала во все элементы массива заносится значение 1 (второй столбец). Это указывает, что пока не обнаружено ни одно число, которое не является простым (элементы a[0] и a[1] не используются и не показаны). Затем заносится значение 0 в те элементы массива, индексы которых кратны 2, 3 и 5, поскольку они не являются простыми числами. Индексы элементов массива, в которых сохранилось значение 1, являются простыми числами (крайний правый столбец).
Это типичный алгоритм, где используется возможность быстрого доступа к любому элементу массива по его индексу. Реализация содержит четыре цикла, из которых три выполняют последовательный доступ к массиву от первого до последнего элемента. Четвертый цикл выполняет скачки по массиву, через i элементов за один раз. В некоторых случаях последовательная обработка абсолютно необходима, а в других используется просто поскольку она не хуже других методов. Например, первый цикл программы 3.5 можно заменить на
for (i = N-1; i > 1, i--) a[i] = 1;
что никак не отразится на вычислениях. Подобным же образом можно изменить направление внутреннего цикла, либо изменить последний цикл, чтобы печатать простые числа в порядке убывания. Однако нельзя изменить порядок выполнения внешнего цикла основных вычислений, поскольку перед проверкой элемента a[i] на простоту уже должны быть обработаны все целые числа, меньшие i.
Мы не будем подробно анализировать время выполнения программы 3.5, дабы не углубляться в теорию чисел. Однако очевидно, что время выполнения пропорционально
N + N/2 + N/3 + N/5 + N/7 + N/11 + ...
что меньше, чем
N + N/2 + N/3 + N/4 + ... = NHN ~ Nln N.
Одно из отличительных свойств C++ состоит в том, что имя массива генерирует указатель на первый элемент массива (с индексом 0). Более того, возможны простые арифметические операции с указателями: если p является указателем на объект определенного типа, можно записать код, предполагающий последовательное расположение объектов данного типа. При этом для ссылки на первый объект используется запись *p, на второй - *(p+1), на третий - *(p+2) и т.д. Другими словами, записи *(a+i) и a[i] в языке C++ эквивалентны.
Это обеспечивает альтернативный механизм доступа к объектам массивов, который иногда оказывается удобнее индексации. Он чаще всего используется для массивов символов (строк). Мы вернемся к этому вопросу в разделе 3.6.
Подобно структурам, указатели на массивы важны тем, что позволяют эффективно управлять массивами как высокоуровневыми объектами. В частности, указатель на массив можно передать как аргумент функции - это позволяет функции обращаться к объектам массива без необходимости создания копии всего массива. Такая возможность необходима при написании программ, работающих с очень большими массивами. Например, она используется в функциях поиска, рассмотренных в разделе 2.6 лекция №2. Другие примеры будут приведены в разделе 3.7.
В программе 3.5 предполагается, что размер массива должен быть известен заранее. Чтобы выполнить программу для другого значения N, следует изменить константу N и повторно скомпилировать программу. В программе 3.6 показан другой способ: пользователь может ввести значение N, и программа выведет простые числа, меньшие этой величины. Здесь применены два основных механизма C++, и в обоих массивы передаются в функции в качестве аргументов. Первый механизм обеспечивает передачу аргументов командной строки главным программам в массиве argv с размером argc. Массив argv является составным и включает объекты, которые сами представляют собой массивы (строки). Мы отложим его рассмотрение до раздела 3.7, а пока примем на веру, что переменная N принимает значение, вводимое пользователем при выполнении программы.
Программа 3.6. Динамическое выделение памяти массиву
Для изменения верхнего предела простых чисел, вычисляемых в программе 3.5, необходима повторная компиляция программы. Однако предельное значение можно принимать из командной строки и использовать его для выделения памяти массиву во время выполнения с помощью операции C++ new[]. Например, если скомпилировать программу и ввести в командной строке 1000000, будут получены все целые числа, меньшие миллиона (если компьютер достаточно мощный для таких вычислений). Для отладки программы (с небольшими затратами времени и памяти) достаточно значения 10 0. В дальнейшем мы будем часто использовать этот подход, хотя для краткости не будем выполнять проверку на нехватку памяти.
int main(int argc, char *argv[]) { int i, N = atoi(argv[1]); int *a = new int[N]; if (a == 0) { cout << "не хватает памяти" << endl; return 0; }
Второй базовый механизм - операция new[], которая во время выполнения выделяет под массив нужный объем памяти и возвращает указатель на массив. В некоторых языках программирования динамическое выделение памяти массивам затруднено либо вообще невозможно. А в некоторых других языках этот процесс выполняется автоматически. Динамическое выделение памяти - важный инструмент в программах, работающих с несколькими массивами, особенно если некоторые из них должны иметь большой размер. Если бы в данном случае не было механизма выделения памяти, нам пришлось бы объявить массив с размером, не меньшим любого допустимого входного параметра. В сложной программе, где может использоваться много массивов, сделать так для каждого из них невозможно. В этой книге мы будем обычно использовать код, подобный коду программы 3.6, по причине его гибкости. Однако в некоторых приложениях, где размер массива известен, вполне применимы простые решения, как в программе 3.5. Массивы не только родственны низкоуровневым средствам доступа к данным памяти в большинстве компьютеров. Они широко распространены еще и потому, что прямо соответствуют естественным методам организации данных в приложениях. Например, массивы прямо соответствуют векторам (математический термин для упорядоченных списков объектов).
Стандартная библиотека C++ содержит класс Vector - абстрактный объект, который допускает индексацию подобно массиву (с необязательной автоматической проверкой соответствия диапазону), но в нем предусмотрена возможность увеличения и уменьшения размера. Это позволяет воспользоваться преимуществами массивов, но возлагать задачи проверки допустимости индексов и управления памятью на систему. Поскольку в этой книге много внимания уделяется быстродействию, мы будем избегать неявных затрат и поэтому чаще использовать массивы, указывая, что в коде могут быть применены и векторы (см. упражнение 3.14).
Программа 3.7 - пример программы моделирования, использующей массивы. В ней моделируется последовательность испытаний Бернулли (Bernoulli trials) - известная абстрактная концепция теории вероятностей. Если подбросить монету N раз, вероятность выпадения k решек составляет

Программа 3.7. Имитация подбрасываний монеты
Если подбросить монету N раз, ожидается выпадение N/2 решек, но в принципе это число может быть любым в диапазоне от 0 до N. Данная программа выполняет эксперимент Mраз, принимая аргументы Mи N из командной строки. Она использует массив f для отслеживания частоты выпадений "i решек" для , а затем выводит гистограмму результатов эксперимента. Каждые 10 выпадений обозначаются одной звездочкой.
Основная операция программы - индексация массива по вычисляемым значениям - важный фактор эффективности многих вычислительных процедур.
#include <iostream.h> #include <stdlib.h> int heads() { return rand() < RAND MAX/2; } int main(int argc, char *argv[]) { int i, j, cnt; int N = atoi(argv[1]), M = atoi(argv[2]); int *f = new int[N+1]; for (j = 0; j <= N; j + +) f[j] = 0; for (i = 0; i < M; i++, f[cnt]++) for (cnt = 0, j = 0; j <= N; j++) if (heads()) cnt++; for (j = 0; j <= N; j++) { if (f[j] == 0) cout << "."; for (i = 0; i < f[j]; i += 10) cout << "*"; cout << endl; } }

Рис. 3.2. Имитация подбрасываний монеты
Эта таблица демонстрирует результат выполнения программы 3.7 при = 32 и M = 1000. Имитируется 1000 экспериментов подбрасывания монеты по 32 раза. Количество выпадений решки аппроксимируется нормальной функцией распределения, график которой показан поверх данных.
Здесь используется нормальная аппроксимация - известная кривая в форме колокола. На рис. 3.2 показан результат работы программы 3.7 для 1000 экспериментов подбрасывания монеты по 32 раза. Дополнительные сведения о распределении Бернулли и нормальной аппроксимации можно найти в любом учебнике по теории вероятностей. Эти понятия вновь встретятся нам в лекция №13. А пока нам интересны вычисления, в которых используются числа как индексы массива для определения частоты выпадений. Способность поддерживать этот вид операций - одно из основных достоинств массивов.
В программах 3.5 и 3.7 индексы массива вычисляются по имеющимся данным. При использовании вычисленного значения для доступа к массиву размером N в некотором смысле с помощью единственной операции обрабатывается N возможных вариантов. Это существенно повышает эффективность, и в дальнейшем мы встретим много алгоритмов, где массивы используются подобным образом.
Массивы можно применять для организации объектов различных типов, а не только целых чисел. В языке C++ можно объявлять массивы любых встроенных либо определяемых пользователем типов (другими словами, составных объектов, объявленных как структуры). В программе 3.8 показано использование массива структур для точек на плоскости - с помощью описания структуры из раздела 3.1. Кроме того, демонстрируется типичное использование массивов: организованное хранение данных для обеспечения быстрого доступа к ним в процессе вычислений.
Между прочим, программа 3.8 также интересна в качестве примера квадратичного алгоритма: в ней выполняется проверка всех пар набора из N элементов данных, в результате чего затрачивается время, пропорциональное N2. В этой книге для подобных алгоритмов мы всегда пытаемся найти более эффективную альтернативу, поскольку с увеличением N данное решение становится неподъемным. Для данного случая в разделе 3.7 будет показано использование составных структур данных, что дает линейное время вычисления.
Программа 3.8. Вычисление ближайшей точки
Эта программа демонстрирует использование массива структур и представляет типичный случай, когда элементы сохраняются в массиве для последующей обработки в процессе некоторых вычислений. Подсчитывается количество пар из N сгенерированных случайным образом точек на плоскости, соединяемых прямой, длина которой меньше d. При этом используется тип данных для точек, описанный в разделе 3.1. Время выполнения составляет O(N2) , поэтому программа не может применяться для больших значений N. Программа 3.20 обеспечивает более быстрое решение.
#include <math.h> #include <iostream.h> #include <stdlib.h> #include "Point.h" float randFloat() { return 1.0*rand()/RAND MAX; } int main(int argc, char *argv[]) { float d = atof(argv[2]); int i, cnt = 0, N = atoi(argv[1]); point *a = new point[N]; for (i = 0; i < N; i++) { a[i].x = randFloat(); a[i].y = randFloat(); } for (i = 0; i < N; i++) for (int j = i+1; j < N; j + +) if (distance(a[i], a[j]) < d) cnt++; cout << cnt << " пар в радиусе " << d << endl; }
Подобным образом можно создавать составные типы произвольной сложности: не только массивы структур, но и массивы массивов, либо структуры, содержащие массивы. Эти возможности будут подробно рассматриваться в разделе 3.7. Однако сначала ознакомимся со связными списками, которые служат главной альтернативой массивам при организации коллекций объектов.
Упражнения
- 3.10. Предположим, переменная a объявлена как int a[99]. Определите содержимое массива после выполнения следующих двух операторов:
- for (i = 0; i < 99; i++) a[i] = 98-i;
- for (i = 0; i < 99; i++) a[i] = a[a[i]];
- 3.11. Измените реализацию решета Эратосфена (программа 3.5) для использования массива (1) символов и (2) разрядов. Определите влияние этих изменений на расход памяти и времени, используемых программой.
- 3.12. С помощью решета Эратосфена определите количество простых чисел, меньших N, для N = 103, 104, 105 и 106 .
- 3.13. С помощью решета Эратосфена постройте график зависимости от N количества простых чисел, меньших N, для значений N от 1 до 1000.
- 3.14. Стандартная библиотека C++ в качестве альтернативы массивам содержит тип данных Vector. Узнайте, как использовать этот тип данных в своей системе, и определите его влияние на время выполнения, если заменить в программе 3.5 массив типом Vector.
- 3.15. Эмпирически определите эффект удаления проверки a[i] из внутреннего цикла программы 3.5 для N = 103, 104, 105 и 106 и объясните его.
- 3.16. Напишите программу подсчета количества различных целых чисел, меньших 1000, которые встречаются во входном потоке.
- 3.17. Напишите программу, эмпирически определяющую количество случайных положительных целых, меньших 1000, генерацию которых можно ожидать перед получением повторного значения.
- 3.18. Напишите программу, эмпирически определяющую количество случайных положительных целых, меньших 1000, генерацию которых можно ожидать до получения каждого значения хотя бы один раз.
- 3.19. Измените программу 3.7 для имитации случая, когда решка выпадает с вероятностью p. Выполните 1000 испытаний с 32 подбрасываниями при p = 1/6 для получения выходных данных, которые можно сравнить с рис. 3.2.
-
3.20. Измените программу 3.7 для имитации случая, когда решка выпадает с вероятностью
. Выполните 1000 испытаний с 32 подбрасываниями для получения выходных данных, которые можно сравнить с рис. 3.2. Получается классическое распределение Пуассона.
- 3.21. Измените программу 3.8 для вывода координат пары ближайших точек.
- 3.22. Измените программу 3.8 для выполнения тех же вычислений в в d-мерном пространстве.
Связные списки
Если нам в основном нужен последовательный перебор элементов, их можно организовать в виде связного списка (linked list) - базовой структуры данных, в которой каждый элемент содержит информацию, необходимую для получения следующего элемента. Основное преимущество связных списков перед массивами заключается в возможности эффективного изменения расположения элементов. Эта гибкость достигается за счет скорости доступа к произвольному элементу списка, поскольку единственный способ получения элемента состоит в проходе по ссылкам от начала списка.
Определение 3.2. Связный список - это набор элементов, содержащихся в узлах (node), каждый из которых также содержит ссылку (link) на некоторый узел.
В определении узлов упоминаются ссылки на узлы, поэтому связные списки иногда называют самоссылочыми (self-referent) структурами. Более того, хотя ссылка узла обычно указывает на другой узел, возможны и ссылки на себя, поэтому связные списки могут представлять собой циклические (cyclic) структуры. Последствия этих двух фактов станут ясны при рассмотрении конкретных представлений и применений связных списков.
Обычно под связным списком подразумевается реализация последовательного расположения набора элементов. Начинаем с некоторого узла, который считается первым элементом последовательности. Затем выполняем переход по его ссылке на другой узел, который дает второй элемент последовательности и т.д. Поскольку список может быть циклическим, такая последовательность иногда может оказаться бесконечной. Чаще всего приходится иметь дело со списками, соответствующими простому последовательному расположению элементов, принимая одно из следующих соглашений для ссылки последнего узла:
- Это пустая (null) ссылка, не указывающая на какой-либо узел.
- Ссылка указывает на фиктивный узел (dummy node), который не содержит элементов.
- Ссылка указывает на первый узел, что делает список циклическим.
В каждом случае переходы по ссылкам от первого узла до последнего формируют последовательное расположение элементов. Массивы тоже задают последовательное расположение элементов, но оно реализуется неявно, за счет позиции в массиве. (Массивы также поддерживают произвольный доступ по индексу, что невозможно для списков.)
Сначала рассмотрим узлы с единственной ссылкой. В большинстве приложений используются одномерные списки, где на любой узел, за исключением, возможно, первого и последнего, указывает точно одна ссылка. Это простейшая и наиболее интересующая нас ситуация - связные списки соответствуют последовательностям элементов. В дальнейшем будут рассмотрены и более сложные случаи.
Связные списки являются примитивными конструкциями в некоторых языках программирования, но не в C++. Однако базовые строительные блоки, о которых шла речь в разделе 3.1, хорошо приспособлены для реализации связных списков. Указатели используются для ссылок, а структуры для узлов:
struct node { Item item; node *next; } ; typedef node *link;
Эта пара выражений - ни что иное, как код C++ для определения 3.2. Узлы состоят из элементов (здесь типа Item) и указателей на узлы, которые называются также ссылками. В лекция №4 будут представлены более сложные случаи, которые обеспечивают большую гибкость и более эффективную реализацию определенных операций, но этот простой пример достаточен для понимания основ обработки списков. Подобные соглашения для связных структур будут использоваться на протяжении всей книги.
Для эффективного использования связных списков ключевое значение имеет выделение памяти. Выше описана единственная структура (struct node), но она может существовать во множестве экземпляров - по одному для каждого узла, который придется использовать. Как только понадобится новый узел, для него нужно выделить память. При объявлении переменной типа node для нее резервируется память во время компиляции. Однако часто приходится организовывать вычисления, связанные с резервированием памяти во время выполнения - с помощью вызовов системных операций управления памятью. Например, в строке кода
link x = new node;
содержится операция new, которая резервирует для узла достаточный объем памяти и возвращает указатель на него в переменной x. В разделе 3.5 будет кратко показано, как система резервирует память, поскольку это хороший пример применения связных списков!
В языке C++ широко принято инициализировать память, а не просто выделять ее. Поэтому в каждую описываемую структуру обычно включается конструктор (constructor). Конструктор представляет собой функцию, которая описывается внутри структуры и имеет такое же имя, что и сама структура. Конструкторы подробно рассматриваются влекция №4. Они предназначены для занесения исходных значений в данные структуры. Для этого конструкторы автоматически вызываются при создании экземпляра структуры. Например, если описать узел списка при помощи следующего кода:
struct node { Item item; node *next; node (Item x; node *t) { item = x; next = t; }; }; typedef node *link;
то оператор
link t = new node(x, t);
не только резервирует достаточный для узла объем памяти и возвращает указатель на него в переменной t, но и присваивает полю item узла значение x, а указателю поля - значение t. Конструкторы помогают избегать ошибок, связанных с не инициализированными данными.
Теперь, когда узел списка создан, возникает вопрос: как обращаться к находящейся в нем информации - элементу и ссылке? Мы уже ознакомились с базовыми операциями, необходимыми для выполнения этой задачи: достаточно разыменовать указатель, а затем использовать имена членов структуры. Элемент узла, на который указывает ссылка x (типа Item), имеет вид (*x).item, а ссылка (типа link) - (*x).link. Эти операции так часто используются, что в языке C++ для них предусмотрены эквивалентные сокращения: x->item и x->link. Кроме того, нам так часто будет нужна фраза "узел, на который указывает ссылка x", что мы будем говорить просто "узел x" - ссылка именует узел.
Соответствие между ссылками и указателями C++ имеет большое значение, но следует учитывать, что ссылки являются абстракцией, а указатели - конкретным представлением. Можно разрабатывать алгоритмы, где используются узлы и ссылки, и можно выбрать одну из многочисленных реализаций этой идеи. Например, в конце раздела будет показано, что ссылки можно представлять индексами массивов.
На рис. 3.3 и рис. 3.4 показаны две основные операции, выполняемые со связными списками. Можно удалить любой элемент связного списка, уменьшив его длину на 1; а также вставить элемент в любую позицию списка, увеличив длину на 1. В этих рисунках для простоты предполагается, что списки циклические и никогда не становятся пустыми.
Пустые ссылки, фиктивные узлы и пустые списки будут рассмотрены в разделе 3.4. Как показано на рисунках, и для вставки, и для удаления необходимо лишь два оператора C++. Для удаления узла, следующего за узлом x, используются операторы
t = x->next; x->next = t->next;
или проще:
x->next = x->next->next;
Для вставки в список узла t в позицию, следующую за узлом x, используется операторы
t->next = x->next; x->next = t;

Рис. 3.3. Удаление из связного списка
Для удаления из связного списка узла, следующего за заданным узлом x, в t заносится указатель на удаляемый узел, а затем ссылка в x заменяется на t->next. Указатель t может использоваться для обращения к удаленному узлу. Хотя ссылка этого узла по-прежнему указывает куда-то внутрь списка, обычно такая ссылка не используется после удаления узла из списка - за исключением, возможно, сообщения системе с помощью операции delete о том, что задействованная память больше не нужна.

Рис. 3.4. Вставка в связный список
Для вставки узла t в позицию связного списка, следующую за заданным узлом x (вверху), в t->next заносится значение x->next (в середине), затем в x->next заносится значение t (внизу).
Именно в силу простоты вставки и удаления и существуют связные списки. Соответствующие операции для массивов неестественны и неудобны, поскольку требуют перемещения всего содержимого массива, которое следует после затрагиваемого элемента.
Однако связные списки плохо приспособлены для поиска k-го элемента (по индексу) - операции, которая чрезвычайно эффективна в массивах. В массиве для поиска k-го элемента достаточно записать просто a[k], а в списке для этого необходимо перейти по k ссылкам. Другая операция, неестественная для списков, в которых узлы содержат лишь единственную ссылку - поиск элемента, предшествующего данному.
После удаления узла из связного списка операцией x->next = x->next->next к нему уже невозможно обратиться. Для небольших программ, вроде рассмотренных выше примеров, это не имеет существенного значения, но обычно хорошей практикой программирования считается применение операции delete (противоположной операции new) для любого узла, который уже не нужен. В частности, последовательность операторов
t = x->next; x->next = t->next; delete t;
не только удаляет узел t из списка, но и сообщает системе, что занятую им память можно использовать для других целей. Операции delete следует уделять особое внимание при наличии больших списков либо большого их количества, но до раздела 3.5 мы будем ее игнорировать, чтобы не отвлекаться от оценки преимуществ связных структур.
В последующих главах мы увидим много примеров использования этих и других базовых операций со связными списками. Поскольку в подобных операциях задействовано лишь несколько операторов, часто вместо описания функций вставки, удаления и т. п. используется прямое управление списками.
Программа 3.9. Пример циклического списка (задача Иосифа)
Для представления людей, расставленных в круг, создается циклический связный список, где каждый элемент (человек) содержит ссылку на соседний элемент против часовой стрелки. Целое число i представляет i-го человека в круге. После создания циклического списка из одного узла вставляются узлы от 2 до N, и в результате образуется окружность с узлами от 1 до N. При этом переменная x указывает на узел N. Затем пропускаем M - 1 узлов, начиная с 1-го, и изменяем значение ссылки (M- 1)-го узла так, чтобы пропустить M-ый узел. Продолжаем эту операцию, пока не останется один узел.
#include <iostream.h> #include <stdlib.h> struct node { int item; node* next; node(int x, node* t) { item = x; next = t; } }; typedef node *link; int main(int argc, char *argv[]) { int i, N = atoi(argv[1]), M = atoi(argv[2]); link t = new node(1, 0); t->next = t; link x = t; for (i = 2; i <= N; i++) x = (x->next = new node(i, t)); while (x != x->next) { for (i = 1; i < M; i++) x = x->next; x->next = x->next->next; } cout << x->item << endl; }
В качестве примера рассмотрим следующую программу решения задачи Иосифа Флавия - любопытного аналога решета Эратосфена.
Предположим, N человек решили выбрать главаря. Для этого они встали в круг и стали удалять каждого M-го человека, смыкая круг после каждого удаления. Задача состоит в определении, кто останется последним (чтобы потенциальный лидер с математическими способностями смог заранее выбрать нужную позицию в круге).

Рис. 3.5. Пример задачи Иосифа
На этой диаграмме показан результат выбора по принципу Иосифа, когда группа людей становится в круг, а затем по кругу отсчитывается каждый пятый человек и удаляется из круга, пока не останется один.
Номер выбираемого главаря является функцией от N и M, называемой функцией Иосифа. В более общем случае требуется выяснить порядок удаления людей. В примере, показанном на рис. 3.5 для N = 9 и M = 5, люди удаляются в порядке 5 1 7 4 3 6 9 2, а 8-ой номер становится избранным главарем. Программа 3.9 считывает значения N и M, а затем распечатывает эту последовательность.
Для прямой имитации процесса выбора в программе 3.9 используется циклический связный список. Сначала создается список элементов от 1 до N. Для этого создается циклический список с единственным узлом для участника 1, затем вставляются узлы для участников от 2 до N с помощью операции, показанной на рис. 3.4. Затем в списке отсчитывается M - 1 элемент и удаляется следующий за ним при помощи операции, показанной на рис. 3.3. Этот процесс продолжается до тех пор, пока не останется только один узел (который будет указывать на себя).
Решето Эратосфена и задача Иосифа наглядно иллюстрируют различие между использованием массивов и связных списков для представления наборов последовательно упорядоченных объектов. Использование связного списка вместо массива для построения решета Эратосфена потребует больших затрат, поскольку эффективность этого алгоритма зависит от возможности быстрого доступа к произвольному элементу массива. Использование массива вместо связного списка для решения задачи Иосифа также повысит затраты, поскольку здесь эффективность алгоритма зависит от возможности быстрого удаления элементов. При выборе структуры данных следует учитывать ее влияние на эффективность алгоритма обработки данных. Это взаимодействие структур данных и алгоритмов занимает центральное место в процессе разработки программ и является постоянной темой данной книги.
В языке C++ указатели служат прямой и удобной реализацией абстрактной концепции связных списков, но важность абстракции не зависит от конкретной реализации. Например, на рис. 3.6 показана реализация связного списка для задачи Иосифа с помощью массивов целых чисел. То есть связный список можно реализовать с помощью индексов массива вместо указателей. Связные списки применялись задолго до появления конструкций указателей в языках высокого уровня, таких как C++. И даже в современных системах иногда оказываются удобными реализации на основе массивов.

Рис. 3.6. Представление связного списка с помощью массивов
Здесь показана реализация связного списка для задачи Иосифа (см. рис. 3.5) с помощью индексов массива вместо указателей. Индекс элемента, следующего в списке за элементом с индексом 0, находится в next[0] и т.д. Сначала (три верхних строки) элемент для участника i имеет индекс i-1; циклический список формируется занесением значений i+1 в next[i] для i от 0 до 8, а в next[8] заносится значение 0. Для имитации процесса выбора Иосифа изменяются ссылки (элементы массива next), но элементы участников не перемещаются. Каждая пара строк показывает результат перемещения по списку четырехкратным выполнением x = next[x] с последующим удалением пятого элемента (отображаемого в крайнем левом столбце) путем занесения в элемент next[x] значения next[next[x]].
Упражнения
- 3.23. Напишите функцию, которая возвращает количество узлов циклического списка по заданному указателю на один из узлов списка.
- 3.24. Напишите фрагмент кода, который определяет количество узлов в циклическом списке между узлами, указанными двумя данными указателями x и t.
- 3.25. Напишите фрагмент кода, который по указателям x и t двух раздельных связных списков вставляет список, указываемый t, в список, указываемый x - в позицию после узла x.
- 3.26. Напишите фрагмент кода, который для данных указателей x и t на узлы циклического списка перемещает узел, следующий после t, в позицию после узла x.
- 3.27. При построении списка программа 3.9 устанавливает в два раза больше ссылок, чем нужно, поскольку поддерживает цикличность списка после вставки каждого узла. Измените программу таким образом, чтобы циклический список создавался без выполнения этих лишних операций.
- 3.28. Определите время выполнения программы 3.9 в виде функции от Mи N.
- 3.29. Используйте программу 3.9, чтобы определить значения функции Иосифа для M = 2, 3, 5, 10 и N = 103, 104, 105 и 106 .
- 3.30. Используйте программу 3.9, чтобы построить график зависимости функции Иосифа от N для M = 10 и N от 2 до 1000.
- 3.31. Воспроизведите таблицу на рис. 3.6 для случая, когда элемент i первоначально находится в массиве в позиции N-i.
- 3.32. Разработайте версию программы 3.9, в которой для реализации связного списка используется массив индексов (см. рис. 3.6).
Простые операции со списками
Работа со связными списками заметно отличаются от работы с массивами и структурами. При использовании массивов и структур элемент сохраняется в памяти, после чего на него можно ссылаться по имени (или индексу), что весьма похоже на хранение информации в картотеке либо адресной книге. Метод хранения информации в связных списках усложняет доступ к ней, однако упрощает перемещение. Работа с данными, организованными в виде связных списков, называется обработкой списков.
При использовании массивов возможны ошибки программы, связанные с попыткой доступа к данным вне допустимого диапазона индексов. Наиболее часто встречающаяся ошибка при работе со связными списками аналогична - это неопределенный указатель. Другая распространенная ошибка - использование ошибочно измененного указателя. Одна из причин возникновения такой проблемы - возможность наличия нескольких указателей на один и тот же узел, что программист может не осознавать. В программе 3.9 несколько подобных проблем устраняется за счет использования циклического списка, который никогда не бывает пустым. При этом каждая ссылка всегда указывает на однозначно определенный узел. Кроме того, каждая ссылка может интерпретироваться как ссылка на список.
Искусство разработки корректного и эффективного кода для приложений обработки списков приходит с практикой и терпением. В этом разделе представлены примеры и упражнения, призванные помочь освоить создание кода обработки списков. На протяжении книги будет использоваться множество других примеров, поскольку связные структуры лежат в основе многих эффективных алгоритмов.
Как было сказано в разделе 3.3, для первого и последнего указателей списка применяется ряд различных соглашений. Некоторые из них рассматриваются в этом разделе, хотя мы взяли за правило резервировать термин связные списки для описания простейших ситуаций.
Определение 3.3. Связный список представляет собой либо пустую ссылку, либо ссылку на узел, который содержит элемент и ссылку на связный список.
Это более строгое определение, чем определение 3.2, но оно ближе к ментальной модели, используемой при написании кода обработки списков. Мы не будем исключать все другие соглашения (используя только это определение) и вместо создания специальных определений, соответствующих каждому соглашению, оставляем оба подхода в силе, полагая, что тип используемого связного списка будет ясен из контекста.
Одна из наиболее распространенных операций со списками - обход (traverse). Это последовательный перебор элементов списка и выполнение некоторых операций с каждым из них. Например, если x является указателем на первый узел списка, последний узел содержит пустой указатель, а visit - процедура, которая принимает элемент в качестве аргумента, то обход списка можно реализовать следующим образом:
for (link t = x; t != 0; t = t->next) visit(t->item);
Этот цикл (либо его эквивалентная форма while) постоянно встречается в программах обработки списков, подобно циклу for (int i = 0; i < N; i++) в программах обработки массивов. Программа 3.10 является реализацией простой задачи обработки списка - изменение порядка следования узлов на обратный. Она принимает связный список в качестве аргумента и возвращает связный список, состоящий из тех же узлов, но расположенных в обратном порядке.
Программа 3.10. Обращение списка
Эта функция обращает порядок следования ссылок в списке и возвращает указатель на последний узел, который указывает на предпоследний узел и т.д. Для ссылки в первом узле исходного списка устанавливается значение 0 (пустой указатель). Для выполнения этой задачи необходимо сохранять ссылки на три последовательных узла списка.
link reverse(link x) { link t, y = x, r = 0; while (y != 0) { t = y->next; y->next = r; r = y; y = t; } return r; }

Рис. 3.7. Обращение списка
Для обращения порядка следования элементов списка используются указатель r на уже обработанную часть списка и указатель y на еще не просмотренную часть списка. На данной диаграмме показано изменение указателей каждого узла списка. Указатель узла, следующего за y, сохраняется в переменной t, ссылка y изменяется так, чтобы указывать на r, после чего r перемещается в позицию y, а y - в позицию t.
На рис. 3.7 показано изменение, выполняемое функцией в своем главном цикле для каждого узла. Такие диаграммы упрощают проверку каждого оператора программы на правильность изменения ссылок. Программисты обычно используют подобные диаграммы для наглядного представления операций обработки списков.
Программа 3.11 служит реализацией другой задачи обработки списков: переупорядочение узлов по возрастанию их элементов. Она генерирует N случайных целых чисел, помещает их в список в порядке появления, а затем сортирует узлы по возрастанию элементов и выводит полученную последовательность. Как будет показано в лекция №6, ожидаемое время выполнения программы пропорционально N 2, поэтому она непригодна для больших значений N. Обсуждение темы сортировки также откладывается до лекция №6, поскольку в главах 6-10 будет рассмотрено множество методов сортировки. А сейчас нам просто нужен пример приложения, выполняющего обработку списков.
Списки в программе 3.11 демонстрируют еще одно часто используемое соглашение: в начале каждого списка содержится фиктивный узел, называемый ведущим (head node). Поле элемента ведущего узла игнорируется, а его ссылка всегда содержит указатель на узел с первым элементом списка. В программе используется два списка: один для накопления случайных чисел, добавляемых в первом цикле, а другой для накопления сортированного результата во втором цикле. На рис. 3.8 показаны изменения, вносимые программой 3.11 в течение одной итерации главного цикла. Из входного списка извлекается очередной узел, находится его позиция в выходном списке, и узел вставляется в эту позицию.
Программа 3.11. Сортировка методом вставки в список
Этот код генерирует N случайных целых чисел в диапазоне от 0 до 999, строит связный список, в котором на каждый узел приходится по одному числу (первый цикл for), затем переупорядочивает узлы, чтобы при обходе списка числа следовали по возрастанию (второй цикл for). Для выполнения сортировки используется два списка - входной (несортированный) и выходной (сортированный). В каждой итерации цикла из входного списка удаляется узел и вставляется в нужную позицию выходного списка. Код упрощен благодаря использованию в каждом списке ведущих узлов, содержащих ссылки на первые узлы. В объявлениях ведущих узлов применен конструктор, поэтому их данные инициализируются при создании.
node heada(0, 0); link a = &heada, t = a; for (int i = 0; i < N; i++) t = (t->next = new node(rand() % 1000, 0)); node headb(0, 0); link u, x, b = &headb; for (t = a->next; t != 0; t = u) { u = t->next; for (x = b; x->next != 0; x = x->next) if (x->next->item > t->item) break; t->next = x->next; x->next = t; }

Рис. 3.8. Сортировка связного списка
На этой диаграмме показан один шаг преобразования неупорядоченного связного списка (заданного указателем a) в упорядоченный связный список (заданный указателем b) с использованием сортировки вставками. Сначала берется первый узел неупорядоченного списка, и указатель на него сохраняется в t (вверху). Затем выполняется поиск в b первого узла x, для которого справедливо условие x->next->item > t->item (или x->next = NULL), и t вставляется в список после x (в середине). Эти операции уменьшают на один узел размер списка a и увеличивают на один узел размер списка b, сохраняя список b упорядоченным (внизу). После завершения цикла список a окажется пустым, а список b будет содержать все узлы в упорядоченном виде.
Главная причина использования ведущего узла становится понятной, если рассмотреть процесс добавления первого узла к сортированному списку. Этот узел содержит наименьший элемент входного списка и может находиться в любом его месте. Существуют три возможности:
- Дублировать цикл for, который обнаруживает наименьший элемент, и создавать список из одного узла таким же образом, как в программе 3.9.
- Перед каждой вставкой узла проверять, не является ли список вывода пустым.
- Использовать фиктивный ведущий узел, ссылка которого указывает на первый узел списка, как в данном примере.
Первый вариант некрасив и требует дополнительного кода. Второй вариант тоже некрасив и замедляет работу.
Использование ведущего узла требует дополнительной памяти (на лишний узел). Во множестве приложений можно обойтись и без него. Например, в программе 3.10 присутствуют входной список (исходный) и выходной (обращенный), но нет необходимости использовать ведущий узел, поскольку все вставки выполняются в начало выходного списка. Мы увидим примеры и других приложений, в которых можно упростить код с помощью фиктивного узла в конце списка, вместо пустой ссылки. Не существует жестких правил принятия решения об использовании фиктивных узлов - все зависит от выбранного стиля и соображений быстродействия. Хорошие программисты стараются выбрать соглашение, которое наиболее упрощает задачу. В этой книге мы увидим несколько подобных компромиссов.
Несколько вариантов соглашений о связных списках приведено в таблица 3.1; остальные рассматриваются в упражнениях. Во всех вариантах таблица 3.1 для ссылки на список используется указатель head, и программа управляет ссылками узлов, используя данный код для различных операций. Выделение памяти под узлы и ее освобождение, а также заполнение узлов информацией одинаковы для всех соглашений. Для повышения надежности функций, реализующих те же операции, необходим дополнительный код проверки на ошибки. Таблица же приведена для демонстрации сходства и различия вариантов.
Другая важная ситуация, в которой иногда удобно использовать ведущий узел, возникает, когда необходимо передать указатели на списки в качестве аргументов функций, которые могут изменять список таким же образом, как и в случае массивов. Использование ведущего узла позволяет функции принимать или возвращать пустой список. При отсутствии ведущего узла функция нуждается в механизме сообщения вызывающей функции, что список оставлен пустым. Одно из решений для C++ состоит в передаче параметра-указателя на список по ссылке. Второй механизм - примененный в программе 3.10 - прием функциями обработки списков указателей на входные списки в качестве аргументов и возврат указателей на выходные списки. При этом ведущие узлы не нужны. Кроме того, этот механизм очень удобен для рекурсивной обработки списков, которая часто используется в этой книге (см. раздел 5.1 лекция №5).
В программе 3.12 объявлен набор функций в виде "черного ящика", которые реализуют базовый список операций. Это позволит нам не повторять фрагменты кода в тексте программ и не зависеть от деталей реализации. Программа 3.13 реализует выбор Иосифа (см. программу 3.9), но уже в виде клиентской программы, использующей этот интерфейс. Идентификация важных операций, используемых в вычислениях, и их описание в интерфейсе обеспечивают гибкость, которая позволяет рассматривать различные конкретные реализации важных операций и проверять их эффективность.
В таблица 3.1 приведены реализации базовых операций обработки списков, основанные на пяти часто используемых соглашениях. Такой код используется в простых приложениях обработки списков, непосредственно в коде программы.
Циклический список, всегда непустой | |
первая вставка: | head->next = head; |
вставка t после х: | t->next = x->next; x->next = t; |
удаление после х: | x->next = x->next->next; |
цикл обхода: | t = head; |
do { ... t = t->next; } while (t != head); | |
проверка на наличие лишь одного элемента: | if (head->next == head) |
Указатель на начало, пустой указатель в конце | |
инициализация: | head = 0; |
вставка t после х: | if (x == 0) {head = t; head->next = 0; } |
{ t->next = x->next; x->next = t; } | |
удаление после х: | t = x->next; x->next = t->next; |
цикл обхода: | for (t = head; t != 0; t = t->next) |
проверка на пустоту: | if (head == 0) |
Фиктивный ведущий узел, пустой указатель в конце | |
инициализация: | head = new node; head->next = 0; |
вставка t после х: | t->next = x->next; x->next = t; |
удаление после х: | t = x->next; x->next = t->next; |
цикл обхода: | for (t = head->next; t != 0; t = t->next) |
проверка на пустоту: | if (head->next == 0) |
Фиктивные ведущий и завершающий узлы | |
инициализация: | head = new node; |
z = new node; | |
head->next = z; z->next = z; | |
вставка t после х: | t->next = x->next; x->next = t; |
удаление после х: | x->next = x->next->next; |
цикл обхода: | for (t = head->next; t != z; t = t->next) |
проверка на пустоту: | if (head->next == z) |
Программа 3.12. Интерфейс обработки списков
В этом коде, который можно сохранить в интерфейсном файле list.h, описаны типы узлов и ссылок, а также выполняемые над ними операции. Для выделения памяти под узлы списка и ее освобождения объявляются собственные функции. Функция construct применена для удобства реализации. Эти описания позволяют клиентам использовать узлы и связанные с ними операции без зависимости от подробностей реализации. Как будет показано в лекция №4, несколько отличный интерфейс, основанный на классах C++, может обеспечить независимость клиентских программ от подробностей реализации.
typedef int Item; struct node { Item item; node *next; }; typedef node *link; typedef link Node; void construct(int); Node newNode(int); void deleteNode(Node); void insert(Node, Node); Node remove(Node); Node next(Node); Item item(Node);
Программа 3.13. Организация списка для задачи Иосифа
Эта программа решения задачи Иосифа служит примером клиентской программы, использующей примитивы обработки списков, которые объявлены в программе 3.12 и реализованы в программе 3.14.
#include <iostream.h> #include <stdlib.h> #include "list.h" int main(int argc, char *argv[]) { int i, N = atoi(argv[1]), M = atoi(argv[2]); Node t, x; construct(N); for (i = 2, x = newNode(1); i <= N; i++) { t = newNode(i); insert(x, t); x = t; } while (x != next(x)) { for (i = 1; i < M; i++) x = next(x); deleteNode(remove(x)); } cout << item(x) << endl; return 0; }
В разделе 3.5 рассматривается реализация операций из программы 3.12 (см. программу 3.14), но можно опробовать и другие решения, не изменяя программу 3.13 (см. упражнение 3.51). Эта тема еще будет неоднократно затронута в данной книге. В языке C++ имеется несколько механизмов, специально предназначенных для упрощения разработки инкапсулированных реализаций; речь об этом пойдет в лекция №4.
Некоторые программисты предпочитают инкапсулировать все операции в низкоуровневых структурах данных, таких как связные списки, путем описания функция для каждой низкоуровневой операции в интерфейсах, подобных показанному в программе 3.12. Действительно, как будет продемонстрировано в лекция №4, классы C++ позволяют легко это сделать. Однако такой дополнительный уровень абстракции иногда скрывает факт использования лишь небольшого количества операций низкого уровня. В данной книге при реализации высокоуровневых интерфейсов низкоуровневые операции со связными структурами обычно записываются непосредственно, чтобы были четко видны важные подробности алгоритмов и структур данных. Множество примеров мы увидим в лекция №4.
С помощью дополнительных ссылок можно реализовать возможность обратного перемещения по связному списку. Например, применение двухсвязного списка (double linked list) позволяет выполнять операцию "найти элемент, предшествующий данному". В таком списке каждый узел содержит две ссылки: одна (prev) указывает на предыдущий элемент, а другая (next) - на следующий. При наличии фиктивных узлов либо цикличного двухсвязного списка выражения x, x->next->prev и x->prev->next эквивалентны для каждого узла. На рис. 3.9 и 3.10 показаны основные действия со ссылками, необходимые для реализации операций удалить (remove), вставить после (insert after) и вставить перед (insert before) в двухсвязных списках. Обратите внимание, что для операции удаления не требуется дополнительной информации о предшествующем (либо следующем) узле в списке, как для односвязных списков - эта информация содержится в самом узле.

Рис. 3.9. Удаление в двухсвязном списке
Как видно из диаграммы, для удаления узла в двухсвязном списке достаточно знать указатель на этот узел. Для заданного t в t->next->prev заносится значение t->prev (в середине), а в t->prev->next - значение t->next (внизу).

Рис. 3.10. Вставка в двухсвязном списке
Для вставки узла в двухсвязный список необходимо установить четыре указателя. Новый узел можно вставить после данного узла (как показано на диаграмме), либо перед ним. Для вставки узла t после узла x в t->next заносится значение x->next, а в x->next->prev - значение t (в середине). Затем в x->next заносится значение t, а в t->prev - значение x (внизу).
На самом деле главная особенность двухсвязных списков состоит в возможности удаления узла, когда ссылка на него является единственной информацией об узле. Часто бывает, что ссылка передается при вызове функции в качестве аргумента, а также что узел имеет другие ссылки и сам является частью другой структуры данных. Эта дополнительная возможность требует в два раза больше памяти для ссылок в каждом узле, и в два раза больше манипуляций со ссылками на каждую базовую операцию. Поэтому двухсвязные списки обычно не используются, если этого не требуют условия. Рассмотрение подробных реализаций отложим до обзора нескольких особых случаев, где в этом возникнет необходимость - например, в разделе 9.5 лекция №9.
Связные списки используются в материале книги, во-первых, для основных АТД-реализаций (абстрактные типы данных; см. лекция №4), а во-вторых, в качестве компонентов более сложных структур данных. Для многих программистов связные списки являются первым знакомством с абстрактными структурами данных, которыми разработчик может непосредственно управлять. Как мы неоднократно убедимся, они образуют важное средство разработки высокоуровневых абстрактных структур данных, необходимых для решения множества задач.
Упражнения
3.33. Напишите функцию, которая перемещает наибольший элемент данного списка в конец списка.
3.34. Напишите функцию, которая перемещает наименьший элемент данного списка в начало списка.
3.35. Напишите функцию, которая переупорядочивает связный список так, чтобы узлы в четных позициях следовали после узлов в нечетных позициях, сохраняя относительный порядок четных и нечетных узлов.
3.36. Реализуйте фрагмент кода для связного списка, меняющий местами узлы, которые следуют после узлов, указываемых ссылками t и u.
3.37. Напишите функцию, которая принимает ссылку на список в качестве аргумента и возвращает ссылку на копию списка (новый список, содержащий те же элементы в том же порядке).
3.38. Напишите функцию, принимающую два аргумента - ссылку на список и функцию, принимающую список в качестве аргумента - и удаляет все элементы данного списка, для которых функция возвращает ненулевое значение.
3.39. Выполните упражнение 3.38, но создайте копии узлов, которые прошли проверку и возвратите ссылку на список, содержащий эти узлы, в порядке их следования в исходном списке.
3.40. Реализуйте версию программы 3.10, в которой используется ведущий узел.
3.41. Реализуйте версию программы 3.11, в которой не используются ведущие узлы.
3.42. Реализуйте версию программы 3.9, в которой используется ведущий узел.
3.43. Реализуйте функцию, которая меняет местами два заданных узла в двухсвязном списке.
3.44. Добавьте в таблица 3.1 строку для списка, который никогда не бывает пустым, задается указателем на первый узел, и в котором последний узел содержит указатель на себя.
3.45. Добавьте в таблица 3.1 строку для циклического списка, имеющего фиктивный узел, который служит и первым, и последним узлом.
Выделение памяти под списки
Преимущество связных списков перед массивами состоит в том, что связные списки без труда изменяют свои размеры. Поэтому необязательно заранее знать максимальный размер списка. Одно из важных практических следствий этого свойства состоит в том, что несколько структур данных можно разместить в общей памяти, не беспокоясь об их относительном размере в любой момент времени.
Здесь очень важно представлять, как может быть реализована операция new. Например, при удалении узла из списка наша задача - оформить ссылки таким образом, чтобы узел более не был привязан к списку. Но что делает система с памятью, которую этот узел занимал? Как система утилизирует пространство, чтобы всегда иметь возможность выделять его под новый узел операцией new? За этими вопросами стоят механизмы, которые служат еще одним примером эффективности элементарной обработки списков.
Операция delete противоположна операции new. Когда блок выделенной памяти уже не нужен, мы с помощью операции delete сообщаем системе, что этот блок доступен для дальнейшего использования. Динамическое распределение памяти (dynamic memory allocation) - это процесс управления памятью и действий в ответ на вызовы new и delete из клиентских программ.
При выполнении операции new непосредственно в приложениях, таких как программы 3.9 или 3.11, как правило, запрашиваются блоки памяти одинакового размера. Поэтому метод отслеживания памяти, доступной для распределения, напрашивается сам: достаточно использовать связный список! Все узлы, которые не входят ни в один используемый список, можно совместно содержать в единственном связном списке. Этот список называется свободным (free list). Когда необходимо выделить память под узел, он извлекается - то есть удаляется - из свободного списка. При удалении узла из какого-либо списка он вставляется в свободный список.
Программа 3.14 является реализацией интерфейса, описанного в программе 3.12, включая функции распределения памяти. При совместной компиляции с программой 3.13 она дает такой же результат, что и прямая реализация, с которой мы начали в программе 3.9. Сопровождение свободного списка для узлов фиксированного размера - тривиальная задача при наличии базовых операций вставки и удаления узлов из списка.
Программа 3.14. Реализация интерфейса обработки списков
Эта программа реализует функции, объявленные в программе 3.12, а также демонстрирует стандартное распределение памяти под узлы фиксированного размера. Создается свободный список, который инициализируется максимальным количеством узлов, используемых программой. Все узлы взаимосвязаны. Когда клиентская программа выделяет память для узла, он удаляется из свободного списка. Когда клиентская программа освобождает узел, он добавляется к свободному списку.
По соглашению клиентские программы обращаются к узлам списков только путем объявления переменных типа Node и использования их в качестве аргументов функций, описанных в интерфейсе. Узлы, возвращаемые клиентским программам, имеют ссылки на самих себя. Эти соглашения служат средством защиты от ссылок неопределенными указателями и в какой-то мере гарантируют, что клиент использует интерфейс должным образом. В языке C++ эти соглашения реализуются путем использования классов совместно с конструкторами (см. лекция №4).
#include <stdlib.h> #include "list.h" link freelist; void construct(int N) { freelist = new node[N+1]; for (int i = 0; i < N; i++) freelist[i].next = &freelist[i+1]; freelist[N].next = 0; } link newNode(int i) { link x = remove(freelist); x->item = i; x->next = x; return x; } void deleteNode(link x) { insert(freelist, x); } void insert(link x, link t) { t->next = x->next; x->next = t; } link remove(link x) { link t = x->next; x->next = t->next; return t; } link next(link x) Item item(link x) { return x->item; }
На рис. 3.11 показано разрастание свободного списка по мере удаления узлов в программе 3.13. Для простоты подразумевается реализация связного списка (без ведущего узла), основанная на индексах массива.
Реализация механизма распределения памяти общего назначения в среде C++ намного сложнее, чем подразумевают рассмотренные простые примеры, а реализация операции new в стандартной библиотеке явно не настолько проста, как показано в программе 3.14. Одно из основных различий состоит в том, что функции new приходится обрабатывать запросы на выделение памяти для узлов различного размера - от крохотных до огромных. Для этой цели разработано несколько хитроумных алгоритмов. Другой подход, используемый в некоторых современных системах, состоит в освобождении пользователя от необходимости явно удалять узлы за счет алгоритмов сборки мусора (garbage collection). Эти алгоритмы автоматически удаляют все узлы, на которые не указывает ни одна ссылка. В этой связи также разработано несколько нетривиальных алгоритмов управления памятью. Мы не будем рассматривать их подробно, поскольку их характеристики быстродействия зависят от свойств определенных компьютеров.

Рис. 3.11. Представление связного списка и свободного списка в виде массивов
На этой версии рис. 3.6 приведен результат ведения свободного списка с узлами, удаленными из циклического списка. Слева указан индекс первого узла свободного списка. После завершения процесса свободный список представляет собой связный список, содержащий все удаленные элементы. Переходы по ссылкам, начиная с 1, дают следующий ряд элементов: 2 9 6 3 4 7 1 5 - т.е. в порядке, обратном тому, в котором элементы удалялись.
Программы, использующие конкретные сведения о приложении, часто эффективнее программ общего назначения, решающих те же задачи. Распределение памяти - не исключение из этого правила. Алгоритм обработки запросов на выделение блоков памяти различного размера не может "знать", что всегда будут запрашиваться блоки фиксированного размера, и поэтому не может использовать этот факт.
Парадоксально, но вторая причина отказа от функций библиотек общего назначения заключается в том, что это делает программу более переносимой - так можно защититься от непредвиденных изменений быстродействия при смене библиотеки либо переноса в другую систему. Многие программисты считают, что использование простого механизма распределения памяти, вроде продемонстрированного в программе 3.14 - удачный способ разработки эффективных и переносимых программ, использующих связные списки. Этот подход возможен в ряде алгоритмов, которые будут рассмотрены в данной книге, если в них применяются подобные запросы к системе управления памятью. В остальной части книги для распределения памяти будут применяться стандартные функции C++ new и delete.
Упражнения
- 3.46. Напишите программу, которая удаляет все узлы связного списка (с помощью операции delete с указателем).
- 3.47. Напишите программу, которая удаляет узлы связного списка, находящиеся в позициях с номерами, кратными 5 (пятый, десятый, пятнадцатый и т.д.).
- 3.48. Напишите программу, которая удаляет узлы в четных позициях связного списка (второй, четвертый, шестой и т.д.).
- 3.49. Реализуйте интерфейс программы 3.12 с помощью непосредственных вызовов new и delete в функциях newNode и deleteNode соответственно.
- 3.50. Эмпирически сравните время выполнения функций распределения памяти из программы 3.14 с операторами new и delete (см. упражнение 3.49) для программы 3.13 при M = 2 и N = 103, 104, 105 и 106 .
- 3.51. Реализуйте интерфейс программы 3.12, используя вместо указателей индексы массива (без ведущего узла) таким образом, чтобы результат работы описывался рисунком 3.11.
- 3.52. Предположим, что имеется набор узлов без пустых указателей (каждый узел указывает на себя либо на другой узел набора). Докажите, что при переходах по ссылкам, начиная с любого узла, вы в конце концов попадете в цикл.
- 3.53. При соблюдении условий упражнения 3.52 напишите фрагмент кода, который для заданного указателя узла подсчитывает количество различных узлов, которые будут достигнуты при переходах по ссылкам от данного узла. Модификация любых узлов не допускается. Используйте не более некоторого постоянного объема дополнительной памяти.
- 3.54. При соблюдении условий упражнения 3.53 напишите функцию, которая определяет, достигнут ли одного и того же цикла переходы из двух заданных ссылок.
Строки
В языке C термин "строка" (string) обозначает массив символов переменной длины, определяемый начальной позицией и символом завершения строки. Язык C++ наследует эту структуру данных из C. Кроме того, строки в качестве высокоуровневой абстракции включены в стандартную библиотеку. В этом разделе мы рассмотрим несколько примеров строк в стиле C. Ценность строк в качестве низкоуровневых структур данных обусловлена двумя главными причинами. Во-первых, во многих вычислительных приложениях выполняется обработка текстовых данных, которые могут представляться непосредственно строками. Во-вторых, многие вычислительные системы предоставляют прямой и эффективный доступ к байтам памяти, которые в точности соответствуют символам строк. Таким образом, в подавляющем большинстве случаев абстракция строк связывает потребности приложения с возможностями компьютера.
Абстрактное понятие последовательности символов, завершаемых символом конца строки, может быть реализовано множеством способов. Например, можно использовать связный список, но в этом случае для каждого символа потребуется еще и один указатель.
Язык C++ наследует из C эффективную реализацию на основе массивов, которая рассматривается в этом разделе, а также предоставляет более обобщенную реализацию из стандартной библиотеки. В лекция №4 будут рассмотрены и другие реализации.
Различие между строкой и массивом символов связано с понятием длины. В обоих случаях используется непрерывная область памяти, но длина массива устанавливается в момент его создания, а длина строки может изменяться в процессе выполнения программы. Из этого различия вытекают интересные последствия, которые мы вскоре рассмотрим.
Для строки необходимо зарезервировать память либо во время компиляции, объявив массив символов фиксированной длины, либо во время выполнения, с помощью вызова new[]. После выделения памяти массиву его можно заполнять символами с начала и до символа завершения строки. Без символа завершения строка представляет собой обыкновенный массив символов. Символ завершения строки позволяет применять более высокий уровень абстракции, когда только часть массива (от начала до символа завершения) считается содержащей значимую информацию. Символ завершения имеет значение 0, или '\0'.
Например, чтобы найти длину строки, можно подсчитать количество символов от ее начала до символа завершения. В таблица 3.2 перечислены простые операции, которые часто выполняются со строками. Все они предусматривают просмотр строк от начала и до конца. Многие из этих функций содержатся в библиотеках, объявленных в файле <string.h>. Однако для простых приложений программисты часто используют слегка измененные версии прямо в коде программы. Надежные функции, реализующие те же операции, должны содержать дополнительный код проверки на ошибки. Код в таблица 3.2 представлен не только для иллюстрации его простоты, но и для наглядной демонстрации характеристик производительности.
Одной из наиболее важных является операция сравнения (compare). Она определяет, которая из двух строк должна быть первой в словаре. Для простоты изложения предполагается идеальный словарь (поскольку реальные правила для строк, содержащих знаки пунктуации, буквы нижнего и верхнего регистра, цифры и пр., довольно сложны), и строки сравниваются посимвольно от начала и до конца. Такой порядок называется лексикографическим. Кроме того, функция сравнения используется для определения равенства строк: по соглашению функция сравнения возвращает отрицательное число, если первая строка находится в словаре перед второй строкой, ноль, если строки равны, и положительное число, если первая строка находится в словаре после второй. Важно понимать, что проверка на равенство двух строк - это не определение, равны ли два указателя строк: если два указателя строк равны, то равны и соответствующие строки (это просто одна и та же строка), но различные указатели строк могут указывать на равные строки (идентичные последовательности символов). Хранение информации в строках с последующей обработкой либо доступом к ней путем сравнения строк, применяется во множестве приложений. Поэтому операция сравнения имеет особое значение. Характерный пример содержится в разделе 3.7, а также во многих других местах книги.
Программа 3.15 является реализацией простой задачи обработки строк. Она выводит те позиции внутри длинной строки текста, где содержится короткая строка-образец. Для этой задачи разработано несколько сложных алгоритмов, а данный простой алгоритм демонстрирует несколько соглашений, используемых при обработке строк в C++.
В этой таблице представлены реализации основных операций обработки строк, использующие два различных примитива языка C++. Применение указателей делает код более компактным, а использование индексированного массива служит более естественным методом выражения алгоритмов и делает код более простым для понимания. Операция объединения для версии с указателями совпадает с операцией для версии с индексированным массивом. Операция сравнения первых символов для версии с указателями получается таким же образом, как и для версии с индексированным массивом, поэтому она опущена. Время выполнения всех реализаций пропорционально длине строк.
Версии с индексированным массивом | |
Вычисление длины строки (strlen(a)) | for (i = 0; a[i] != 0; i++) ; return i ; |
Копирование (strcpy(a, b)) | for (i = 0; (a[i] = b[i]) != 0; i++) ; |
Сравнение (strcmp(a, b)) | for (i = 0; a[i] == b[i]; i++) |
if (a[i] == 0) return 0; | |
return a[i] - b[i]; | |
Сравнение (первых символов) (strncmp(a, b, n)) | for (i = 0; i < n && a[i] != 0; i++) |
if (a[i] != b[i]) return a[i] - b[i]; | |
return 0; | |
Присоединение (strcat(a, b)) | strcpy(a+strlen(a), b) |
Эквивалентные версии с указателями | |
Вычисление длины строки (strlen(a)) | b = a; while (*b++) ; return b-a-1; |
Копирование (strcpy(a, b)) | while (*a++ = *b++) ; |
Сравнение (strcmp(a, b)) | while (*a++ = *b++) |
if (*(a-1) == 0) return 0; | |
return *(a-1) - *(b-1); |
Обработка строк служит убедительным примером необходимости сведений о быстродействии библиотечных функций. Дело в том, что библиотечные функции могут работать медленнее, чем мы ожидаем. Например, время определения длины строки пропорционально ее длине. Игнорирование этого факта может привести к серьезным проблемам, связанным с быстродействием. Например, после краткого знакомства с библиотекой можно реализовать сравнение с образцом из программы 3.15 следующим образом:
for (i = 0; i < strlen(a); i++) if (strncmp(&a[i], p, strlen(p)) == 0) cout << i << " ";
К сожалению, время выполнения этого фрагмента пропорционально, как минимум, квадрату длины a независимо от кода тела цикла, поскольку для определения длины строки a каждый раз выполняется ее полный просмотр. Эти затраты времени велики и даже неприемлемы: выполнение программы для проверки наличия определенного слова в данной книге (содержащей более миллиона символов) потребует триллионов операций. Подобные проблемы трудно обнаружить, поскольку программа может хорошо работать с небольшими строками на этапе отладки, но сильно тормозить, если не полностью зависать, при решении реальных задач. Более того, таких проблем можно избежать, только зная о них!
Программа 3.15. Поиск строки
Эта программа обнаруживает все вхождения введенного из командной строки слова в строке текста (предположительно намного большей длины). Строка текста объявляется в виде массива символов фиксированной длины (можно с помощью операции new[], как в программе 3.6). Чтение строки выполняется из стандартного ввода с помощью функции cin.get() . Память для слова (аргумента командной строки) выделяется системой перед вызовом программы, а указатель строки содержится в элементе argv[1]. Для каждой начальной позиции i в строке a выполняется попытка сравнения подстроки, которая начинается с этой позиции, со словом p. Равенство проверяется символ за символом. При достижении конца слова p выводится начальная позиция i вхождения этого слова в текст.
#include <iostream.h> #include <string.h> static const int N = 10000; int main(int argc, char *argv[]) { int i; char t; char a[N], *p = argv[1]; for (i = 0; i < N-1; a[i] = t, i++) if (!cin.get(t)) break; a[i] = 0; for (i = 0; a[i] != 0; i++) { int j; for (j = 0; p[j] != 0; j++) if (a[i+j] ! = p[j]) break; if (p[j] == 0) cout << i << " "; } cout << endl; }
Этот вид ошибок называется потерей быстродействия (performance bug), поскольку код может быть корректным, но его выполнение окажется не настолько эффективным, как ожидалось. Прежде чем приступить к изучению эффективных алгоритмов, необходимо устранить подобные потери быстродействия. Стандартные библиотеки обладают многими достоинствами, однако следует учитывать и потенциальные недостатки их использования для реализации таких простых функций.
Одна из важных концепций, к которой мы время от времени возвращаемся, гласит, что различные реализации одного и того же абстрактного понятия могут существенно различаться по производительности. Например, класс string стандартной библиотеки C++ постоянно отслеживает длину строки и может возвращать это значение за фиксированное время, хотя остальные операции выполняются медленнее. Для разных приложений могут быть более удобными различные реализации.
Довольно часто библиотечные функции не могут гарантировать наилучшее быстродействие для всех приложений. Даже если производительность библиотечной функции хорошо задокументирована (как в случае strlen), нет уверенности, что некоторые будущие реализации не повлекут изменений, которые отрицательно повлияют на быстродействие программ. Это соображение имеет большое значение для разработки алгоритмов и структур данных, потому его следует постоянно учитывать. Другие примеры и дальнейшие вариации будут рассмотрены в лекция №4.
По сути, строки являются указателями на символы. В некоторых случаях понимание этого позволяет создавать компактный код для функций обработки строк. Например, чтобы скопировать одну строку в другую, можно написать:
while (*a++ = *b++) ;
вместо
for (i = 0; a[i] != 0; i++) a[i] = b[i];
либо третьего варианта из таблица 3.2. Оба способа обращения к строкам эквиваленты, но на разных компьютерах получаемый код может иметь различные характеристики производительности. Обычно мы будем использовать версию с массивами для ясности, а версию с указателями - для уменьшения объема кода. Поиск же наилучшего решения мы будем выполнять лишь для отдельных фрагментов часто используемого кода в некоторых приложениях.
Распределение памяти для строк сложнее, чем для связных списков, поскольку строки имеют различный размер. Вообще-то максимально обобщенный механизм резервирования памяти для строк - это просто системные функции new[] и delete[]. Как было сказано в разделе 3.6, для решения этой задачи разработаны различные алгоритмы. Их характеристики производительности зависят от системы и компьютера. Часто распределение памяти при работе со строками является не такой сложной проблемой, как это может показаться, поскольку используются указатели на строки, а не сами символы. И обычно мы не предполагаем, что все строки занимают конкретно выделенные блоки памяти. Как правило, мы считаем, что каждая строка занимает область памяти с неопределенным адресом, но достаточно большую, чтобы вмещать строку и ее символ завершения. При выполнении операций создания либо удлинения строк следует очень внимательно отнестись к выделению памяти. В качестве примера мы рассмотрим в разделе 3.7 программу, которая читает строки и обрабатывает их.
Упражнения
- 3.55. Напишите программу, которая принимает строку в качестве аргумента и выводит таблицу со всеми имеющимися в строке символами и частотой появления каждого из них.
- 3.56. Напишите программу, которая определяет, является ли данная строка палиндромом (одинаково читается в прямом и обратном направлениях), если игнорировать пробелы. Например, программа должна давать положительный ответ для строки "if i had a hifi".
- 3.57. Предположим, что память для строк выделяется индивидуально. Напишите версии функций strcpy и strcat, которые выделяют память и возвращают указатель на новую строку-результат.
- 3.58. Напишите программу, которая принимает строку в качестве аргумента и читает ряд слов (последовательностей символов, разделенных пробелами) из стандартного ввода, выводя те из них, которые входят как подстроки в строку аргумента.
- 3.59. Напишите программу, которая заменяет в данной строке подстроки, состоящие из нескольких пробелов, одним пробелом.
- 3.60. Реализуйте версию программы 3.15, в которой будут использоваться указатели.
- 3.61. Напишите эффективную программу, которая определяет длину самой большой последовательности пробелов в данной строке, просматривая как можно меньшее количество символов. Подсказка: с возрастанием длины последовательности пробелов программа должна выполняться быстрее.
Составные структуры данных
Массивы, связные списки и строки обеспечивают простые методы последовательной организации данных. Они создают первый уровень абстракции, который можно использовать для группировки объектов методами, пригодными для их эффективной обработки. Иерархию подобных абстракций можно использовать для построения более сложных структур. Возможны массивы массивов, массивы списков, массивы строк и т.д. В этом разделе мы рассмотрим примеры подобных структур.
Подобно тому, как одномерные массивы соответствуют векторам, двумерные массивы, с двумя индексами, соответствуют матрицам и широко используются в математических расчетах. Например, следующий код можно применить для перемножения матриц a и b с помещением результата в третью матрицу c.
for (i = 0; i < N; i++) for (j = 0; j < N; j++) c[i][j] = 0.0; for (i = 0; i < N; i++) for (j = 0; j < N; j++) for (k = 0; k < N; k++) c[i][j] += a[i][k]*b[k][j];
Математические расчеты часто естественно выражаются с помощью многомерных массивов.
Помимо математических применений привычный способ структурирования информации состоит в использовании таблиц чисел, организованных в виде строк и столбцов. В таблице оценок можно выделить по одной строке для каждого студента и по одному столбцу для каждого предмета. Такую таблицу можно представить в виде двумерного массива с одним индексом для строк и еще одним - для столбцов. Если студентов 100, а предметов 10, можно объявить массив как grades[100][10], а затем ссылаться на оценку ;-го студента по j-му предмету следующим образом: grade[i][j]. Для вычисления средней оценки по предмету необходимо сложить элементы соответствующего столбца и разделить сумму на количество строк. Чтобы вычислить среднюю оценку определенного студента, нужно сложить элементы строки и разделить сумму на количество столбцов и т.п. Двумерные массивы широко используются в подобных приложениях. В программе часто удобно использовать и более двух измерений. Например, в таблице оценок можно использовать третий индекс для хранения всех оценок за все годы.
Двумерные массивы - это просто удобная запись, поскольку числа хранятся в памяти компьютера, которая, по сути, является одномерным массивом. Во многих языках программирования двумерные массивы хранятся построчно в одномерных массивах. Так, в массиве a[M][N] первые N позиций будут заняты первой строкой (элементы от a[0][0] до a[0][N-1]), следующие N позиций - второй строкой (элементы от a[1][0] до a[1][N-1]) и т.д. При организации хранения в порядке старшинства строк последняя строка кода перемножения матриц из предыдущего абзаца в точности эквивалентна выражению:
c[N*i+j] = a[N*i+k]*b[N*k+j]
Эту схему можно обобщить для массивов с большим количеством измерений. В языке C++ многомерные массивы можно реализовать более общим методом - в виде составных структур данных (массивов массивов). Это обеспечивает гибкость, например, возможны массивы массивов, которые имеют различный размер.
В программе 3.6 был представлен метод динамического выделения памяти массивам, который позволяет использовать программы для задач различных размеров без повторной компиляции. Хотелось бы иметь подобный метод и для многомерных массивов. Как выделять память многомерным массивам, размер которых неизвестен на этапе компиляции? Другими словами, необходима возможность обращаться в программе к элементу массива наподобие a[i][j], но не объявлять массив в виде (например) int a[M][N], поскольку значения Mи N неизвестны. При организации хранения элементов по строкам оператор вида
int* a = malloc(M*N*sizeof(int));
выделяет память под массив целых чисел размером MxN, но это решение приемлемо не для всех случаев. Например, если массив передается в функцию, во время компиляции можно не задавать только его первое измерение. В программе 3.16 приведено более эффективное решение для двумерных массивов, основанное на определении "массивов массивов".
Программа 3.17 демонстрирует использование аналогичной составной структуры - массива строк. Поскольку наше абстрактное понятие строки относится к массиву символов, то, на первый взгляд, массивы строк следовало бы представлять в виде массивов массивов. Однако конкретным представлением строки служит указатель на начало массива символов, поэтому массив строк может быть и массивом указателей. Как показано на рис. 3.12, перемещение строк можно выполнить простым перемещением указателей массива. В программе 3.17 используется библиотечная функция qsort. Реализация подобных функций рассматривается в главах 6-9 вообще и в лекция №7 в частности. Этот пример содержит типичную ситуацию обработки строк: сначала символы считываются в большой одномерный массив, потом расставляются указатели на отдельные строки (ограниченные символами завершения), а затем осуществляется работа с указателями.
Программа 3.16. Выделение памяти под двумерный массив
Эта функция динамически выделяет память двумерному массиву как массиву массивов. Память сначала выделяется для массива указателей, а затем для каждой строки. С помощью этой функции оператор
int **a = malloc2d(M, N); выделяет память массиву целых чисел размером M х N. int **malloc2d(int r, int c) { int **t = new int*[r]; for (int i = 0; i < r; i++) t[i] = new int[c]; return t; }
Программа 3.17. Сортировка массива строк
Эта программа иллюстрирует важную функцию обработки строк: расположение набора строк в упорядоченном виде. Строки считываются в буфер достаточного размера, чтобы вместить их все. Для каждой строки в массиве хранится указатель. Затем указатели переупорядочиваются так, чтобы указатель на самую младшую строку находился в первой позиции массива, указатель на следующую в алфавитном порядке строку - во второй позиции и т.д.
Библиотечная функция qsort, которая в действительности выполняет сортировку, принимает четыре аргумента: указатель на начало массива, количество объектов, размер каждого объекта и функцию сравнения. Независимость от типа сортируемых объектов достигается за счет слепого переупорядочения блоков данных, которые представляют объекты (в данном случае, указатели на строки), а также за счет использования функции сравнения, принимающей в качестве аргумента указатели на void. Программа выполняет обратное приведение этих блоков к типу указателей на указатели на символы для функции strcmp. Чтобы обратиться к первому символу строки для операции сравнения, выполняется разыменование трех указателей: один для получения индекса (который является указателем) элемента массива, один для получения указателя строки (с помощью индекса) и еще один для получения символа (с помощью указателя).
Мы будем использовать другой метод достижения независимости от типа для функций сортировки и поиска (см. лекция №4 и лекция №6).
#include <iostream.h> #include <stdlib.h> #include <string.h> int compare(const void *i, const void *j) { return strcmp(*(char **)i, *(char **)j); } int main() { const int Nmax = 1000; const int Mmax = 10000; char* a[Nmax]; int N; char buf[Mmax]; int M = 0; for (N = 0; N < Nmax; N++) { a[N] = &buf[M]; if (!(cin >> a[N])) break; M += strlen(a[N])+1; } qsort(a, N, sizeof(char*), compare); for (int i = 0; i < N; i++) cout << a[i] << endl; }
Нам уже встречалось другое применение массивов строк: массив argv, используемый для передачи строковых аргументов функции main в программах на C++. Система сохраняет в строковом буфере символы командной строки, введенные пользователем, и передает в процедуру main указатель на массив указателей строк в этом буфере. Для определения чисел, соответствующих некоторым аргументам, используются функции преобразования. Остальные аргументы используются непосредственно как строки.
Создавать составные структуры данных можно также исключительно с помощью ссылок. На рис. 3.13 показан пример мультисписка, узлы которого имеют несколько полей ссылок и принадлежат независимым связным спискам.

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

Рис. 3.13. Мультисписок
С помощью двух полей ссылок узлы можно связывать в два независимых списка: в один список по одному полю, и в другой - по другому. Здесь правое поле связывает узлы в одном порядке (например, в порядке их создания), а левое поле - в другом порядке (в нашем случае это порядок по возрастанию, возможно, в результате сортировки вставками, использующей только левое поле ссылки). Переходя по правым ссылкам от узла a, мы обойдем узлы в порядке их создания. Переходя по левым ссылкам от узла b, мы обойдем узлы в порядке возрастания.
При разработке алгоритмов для построения сложных структур данных часто применяют более одной ссылки для каждого узла - для эффективного управления ими. Например, список с двойными связями является мультисписком, который удовлетворяет ограничению: оба выражения x->l->r и x->r->l эквивалентны x. В лекция №5 рассматривается гораздо более важная структура данных с двумя ссылками для каждого узла.
Если многомерная матрица является разреженной (sparse) (количество ненулевых элементов относительно невелико), для ее представления вместо многомерного массива можно использовать мультисписок. Каждому значению матрицы соответствует один узел, а каждому измерению - по одной ссылке. Такие ссылки указывают на следующий элемент в своем измерении. Эта организация снижает объем необходимой памяти с произведения максимальных значений индексов измерений до пропорционального количеству ненулевых записей. Однако при этом для многих алгоритмов увеличивается время выполнения, поскольку для доступа к отдельным элементам приходится выполнять переходы по ссылкам.
Чтобы увидеть дополнительные примеры составных структур данных и четко понять различия между индексированными и связными структурами данных, рассмотрим структуры данных для представления графов. Граф (graph) - это фундаментальный комбинаторный объект, который определяется набором объектов (называемых вершинами) и набором связей между вершинами (называемых ребрами). Мы уже встречались с понятием графов в задаче связности из лекция №1.
Предположим, что граф с количеством вершин V и количеством ребер E описывается набором из E пар целых чисел в диапазоне от 0 до V-1. Это означает, что вершины обозначены целыми числами 0, 1, ..., V-1, а ребра определяются парами вершин. Как и в лекция №1, пара i-j обозначает связь между вершинами i и j и имеет то же значение, что и пара j-i. Графы, содержащие такие ребра, называются неориентированными (undirected). Другие типы графов рассматриваются в части 7.
Один из простых методов представления графа заключается в использовании двумерного массива, называемого матрицей смежности (adjacency matrix). Она позволяет быстро определять, существует ли ребро между вершинами i и j, просто проверив на неравенство нулю элемент матрицы, находящийся на пересечении строки i и столбца j. Для неориентированных графов, которые мы сейчас рассматриваем, при наличии ненулевого элемента в строке i и столбце j ненулевым должен быть и элемент в строке j и столбце, т.е. матрица симметрична. На рис. 3.14 показан пример матрицы смежности для неориентированного графа.

Рис. 3.14. Представление графа в виде матрицы смежности
Граф представляет собой набор вершин и соединяющих их ребер. Для простоты вершинам присвоены индексы (неотрицательные целые числа по порядку, начиная с нуля). Матрица смежности - это двумерный массив, содержащий бит 1 в строке i и столбце j в том и только том случае, когда между вершинами i и j существует ребро. Этот массив симметричен относительно диагонали. По соглашению все диагональные элементы содержат биты 1 (каждая вершина соединена сама с собой). Например, шестая строка (и шестой столбец) указывает, что вершина 6 соединена с вершинами 0, 4 и 6.
Программа 3.18 демонстрирует создание матрицы смежности для вводимой последовательности ребер.
Другой простой метод представления графа предусматривает использование массива связанных списков, называемых списками смежности (adjacency lists). Каждой вершине соответствует связный список с узлами для всех вершин, связанных с данной. Для неориентированных графов должно выполняться следующее: если существует узел для вершины j в г-ом списке, то должен существовать и узел для вершины i в j-ом списке. На рис. 3.15 показан пример представления неориентированного графа с помощью списков смежности. В программе 3.19 приведен метод создания такого представления для вводимой последовательности ребер.
Программа 3.18. Представление графа в виде матрицы смежности
Эта программа выполняет чтение набора ребер, описывающих неориентированный граф, и создает для него представление в виде матрицы смежности. При этом элементам a[i][j] и a[j][i] присваивается значение 1, если существует ребро из i в j (или из j в j). Иначе эти элементы содержат значение 0. В программе предполагается, что количество вершин V - константа времени компиляции. Иначе пришлось бы динамически выделять память под массив, представляющий матрицу смежности (см. упражнение 3.71).
#include <iostream.h> int main() { int i, j, adj[V][V]; for (i = 0; i < V; i++) for (j = 0; j < V; j++) adj[i][j] = 0; for (i = 0; i < V; i++) adj[i][i] = 1; while (cin >> i >> j) { adj[i][j] = 1; adj[j][i] = 1; } }

Рис. 3.15. Представление графа в виде списков смежности
В этом представлении графа, изображенного на рис. 3.14, используется массив списков. Объем необходимой для данных памяти пропорционален сумме количеств вершин и ребер. Для поиска индексов вершин, связанных с данной вершиной i, анализируется i-я позиция массива, содержащая указатель на связный список, который содержит по одному узлу для каждой связанной с i вершины.
Программа 3.19. Представление графа в виде списков смежности
Эта программа считывает набор ребер, которые описывают граф, и создает его представление в виде списков смежности. Список смежности представляет собой массив списков, по одному для каждой вершины, где j-й список содержит связный список узлов, соединенных с j-ой вершиной.
#include <iostream.h> struct node { int v; node* next; node(int x, node* t) { v = x; next = t; } }; typedef node *link; int main() { int i, j; link adj[V]; for (i = 0; i < V; i++) adj[i] = 0; while (cin >> i >> j) { adj[j] = new node(i, adj[j]); adj[i] = new node(j, adj[i]); } }
Оба представления графа являются массивами более простых структур данных (по одной для каждой вершины), которые описывают ребра, связанные с данной вершиной. Для матрицы смежности более простая структура данных реализована в виде индексированного массива, а для списка смежности - в виде связного списка.
Таким образом, представления графа различными методами являются различными компромиссами между расходами памяти и времени. Для матрицы смежности необходим объем памяти, пропорциональный V2; для списков смежности объем памяти пропорционален V + E. При небольшом количестве ребер (такой граф называется разреженным), представление с использованием списков смежности потребует намного меньшего объема памяти. Если большинство пар вершин соединены ребрами (такой граф называется насыщенным), предпочтительнее использование матрицы смежности, поскольку в них не нужны ссылки. Некоторые алгоритмы более эффективны для представлений матрицей смежности, поскольку требуют постоянных затрат времени для ответа на вопрос "существует ли ребро между вершинами i и j?". Другие алгоритмы более эффективны для представлений списками смежности, поскольку они позволяют обрабатывать все ребра графа за время, пропорциональное V + E, а не V2. Конкретный пример такого выбора продемонстрирован в разделе 5.8 лекция №5. Оба типа представлений можно элементарно распространить на другие типы графов (см., например, упражнение 3.70). Они служат основой большинства алгоритмов обработки графов, которые будут рассмотрены в части 7.
В завершение главы рассмотрим пример, демонстрирующий использование составных структур данных для эффективного решения простой геометрической задачи, о которой шла речь в разделе 3.2. Для данного значения d необходимо узнать количество пар из множества N точек внутри единичного квадрата, которые можно соединить отрезком прямой с длиной, меньшей d. В программе 3.20 используется двумерный массив связных списков, что снижает время выполнения по сравнению с программой 3.8 примерно на коэффициент 1/d2 для достаточно больших значений N. Для этого единичный квадрат разбивается на сетку меньших квадратов одинакового размера. Затем для каждого квадрата создается связный список всех точек, попадающих в квадрат. Двумерный массив обеспечивает непосредственный доступ к набору точек, ближайших к данной точке. Связные списки обладают гибкостью, позволяющей хранить все точки без необходимости знать заранее, сколько точек попадает в каждую ячейку сетки.
Объем используемой программой 3.20 памяти пропорционален 1/d2 + N , но время выполнения составляет O(d2 N2) , что существенно лучше грубой реализации из программы 3.8 при небольших значениях d. Например, для N = 106 и d = 0.001 затраты времени и памяти на решение задачи практически линейно зависят от N, в то время как грубый алгоритм требует неприемлемых затрат времени. Эту структуру данных можно использовать в качестве основы для решения многих других геометрических задач. Например, в сочетании с алгоритмом объединение-поиск из лекция №1 она дает почти линейный алгоритм определения возможности соединения отрезками длиной d набора из N случайных точек на плоскости. Это фундаментальная задача из области проектирования сетей и цепей.
Программа 3.20. Двумерный массив списков
Эта программа демонстрирует эффективность правильного выбора структуры данных на примере геометрических вычислений из программы 3.8. Единичный квадрат разбивается на сетку. Создается двумерный массив связных списков, причем каждой ячейке (квадрату) сетки соответствует один список. Размер ячеек достаточно мал, чтобы все точки в пределах расстояния d от каждой данной точки попали в одну ячейку с ней либо в смежные ячейки. Функция malloc2d подобна одноименной функции из программы 3.16, но она создана для объектов типа link, а не int.
#include <math.h> #include <iostream.h> #include <stdlib.h> #include "Point.h" struct node { point p; node *next; node(point pt, node* t) { p = pt; next = t; } }; typedef node *link; static link **grid; static int G, cnt = 0; static float d; void gridinsert(float x, float y) { int X = x*G+1; int Y = y*G+1; point p; p.x = x; p.y = y; link s, t = new node(p, grid[X][Y]); for (int i = X-1; i <= X+1; i++) for (int j = Y-1; j <= Y+1; j++) for (s = grid[i][j]; s != 0; s = s->next) if (distance(s->p, t->p) < d) cnt+ + ; grid[X][Y] = t; } int main(int argc, char *argv[]) { int i, N = atoi(argv[1]); d = atof(argv[2]); G = 1/d; grid = malloc2d(G+2, G+2); for (i = 0; i < G+2; i++) for (int j = 0; j < G+2; j++) grid[i][j] = 0; for (i = 0; i < N; i++) gridinsert(randFloat(), randFloat()); cout << cnt << " пар в радиусе " << d << endl; }
Как следует из примеров этого раздела, данные различных типов можно объединять (косвенно, либо с помощью явных ссылок) в объекты, а последовательности объектов - в составные объекты. Таким образом из базовых абстрактных конструкций можно строить объекты неограниченной сложности. Хотя, как будет показано в лекция №5, в этих примерах еще не достигнуто полное обобщение структурирования данных. Однако, прежде чем пройти последний этап, мы рассмотрим важные абстрактные структуры данных, которые можно создавать с помощью связных списков и массивов - основных средств достижения следующего уровня общности.
Упражнения
- 3.62. Напишите версию программы 3.16, обрабатывающую трехмерные массивы.
- 3.63. Измените программу 3.17 для индивидуальной обработки вводимых строк (память выделяется каждой строке после считывания ее из ввода). Можно предположить, что длина любой строки не превышает 100 символов.
- 3.64. Напишите программу заполнения двумерного массива значениями 0 или 1: элемент a[i][j] должен содержать значение 1, если наибольший общий делитель
- i и j равен единице, и значение 0 в остальных случаях.
- 3.65. Воспользуйтесь программами 3.20 и 1.4 для разработки эффективной программы, которая определяет, можно ли соединить набор из N точек отрезками длиной меньше d.
- 3.66. Напишите программу преобразования разреженной матрицы из двумерного массива в мультисписок с узлами только для ненулевых значений.
- 3.67. Реализуйте перемножение матриц, представленных мультисписками.
- 3.68. Запишите матрицу смежности, построенную программой 3.18, для введенных пар значений: 0-2, 1-4, 2-5, 3-6, 0-4, 6-0 и 1-3.
- 3.69. Запишите список смежности, построенный программой 3.19, для введенных пар значений: 0-2, 1-4, 2-5, 3-6, 0-4, 6-0 и 1-3.
- 3.70. Ориентированный (directed) граф - это граф, у которого связи между вершинами имеют направление: ребра следуют из одной вершины в другую. Выполните упражнения 3.68 и 3.69 для случая, когда вводимые пары представляют ориентированный граф, а обозначение i-j указывает, что ребро направлено из i в j. Кроме того, нарисуйте этот граф, используя стрелки для указания ориентации ребер.
- 3.71. Измените программу 3.18 таким образом, чтобы она принимала количество вершин в качестве аргумента командной строки, а затем динамически выделяла память под матрицу смежности.
- 3.72. Измените программу 3.19 таким образом, чтобы она принимала количество вершин в качестве аргумента командной строки, а затем динамически выделяла память под массив списков.
- 3.73. Напишите функцию, которая использует матрицу смежности графа для подсчета по заданным вершинам а и b количества таких вершин с, что существуют ребра из а в с и из с в b.
- 3.74. Выполните упражнение 3.73 с использованием списков смежности.
Лекция 4. Абстрактные типы данных
Разработка абстрактных моделей для данных и способов обработки этих данных является важнейшим компонентом в процессе решения задач с помощью компьютера. Примеры этого мы видим и на низком уровне в повседневном программировании (например, при использовании массивов и связных списков, рассмотренных в лекция №3), и на высоком уровне при решении прикладных задач (как при решении задачи связности с помощью леса объединение-поиск в лекция №1). В настоящей лекции рассматриваются абстрактные типы данных (abstract data type, в дальнейшем АТД), позволяющие создавать программы с использованием высокоуровневых абстракций. Абстрактные типы данных позволяют отделять абстрактные (концептуальные) преобразования, которые программы выполняют над данными, от любого конкретного представления структуры данных и любой конкретной реализации алгоритма.
Все вычислительные системы основаны на уровнях абстракции: определенные физические свойства кремния и других материалов позволяют принять абстрактную модель бита, который может принимать двоичные значения 0-1; затем на динамических свойствах значений определенного набора битов строится абстрактная модель машины; далее, на основе принципа работы машины под управлением программы на машинном языке строится абстрактная модель языка программирования; и, наконец, строится абстрактное понятие алгоритма, реализуемое в виде программы на языке C++. Абстрактные типы данных дают возможность продолжать этот процесс дальше и разрабатывать абстрактные механизмы для определенных вычислительных задач на более высоком уровне, чем это обеспечивается системой C++, разрабатывать абстрактные механизмы, ориентированные на конкретные приложения и подходящие для решения задач в многочисленных прикладных областях, а также создавать абстрактные механизмы более высокого уровня, в которых используются эти базовые конструкции. Абстрактные типы данных предоставляют в наше распоряжение расширяемый до бесконечности набор инструментальных средств для решения все новых и новых задач.
С одной стороны, применение абстрактных конструкций освобождает от забот по их детальной реализации; с другой стороны, когда производительность программы важна, необходимо знать затраты на выполнение базовых операций. Мы используем множество базовых абстракций, встроенных в аппаратные средства компьютера и служащих основой для машинных инструкций; реализуем другие абстракции в программном обеспечении; и используем дополнительные абстракции, предоставляемые написанным ранее системным программным обеспечением. Абстрактные конструкции высокого уровня часто создаются на основе более простых конструкций. На всех уровнях действует один и тот же основной принцип: необходимо найти наиболее важные операции в программах и наиболее важные характеристики данных, а затем точно определить те и другие на абстрактном уровне и разработать эффективные конкретные механизмы для их реализации. В настоящей лекции мы рассмотрим множество примеров применения этого принципа.
Для разработки нового уровня абстракции потребуется (1) определить абстрактные объекты, с которыми необходимо манипулировать, и операции, которые должны выполняться над ними; (2) представить данные в некоторой структуре данных и реализовать операции; (3) и (самое главное) обеспечить, чтобы эти объекты было удобно использовать для решения прикладных задач. Эти пункты применимы и к простым типам данных, так что базовые механизмы для поддержки типов данных, которые были рассмотрены в лекция №3, можно адаптировать для наших целей. Однако язык C++ предлагает важное расширение механизма структур, называемое классом (class). Классы исключительно полезны при создании уровней абстракции и поэтому рассматриваются в качестве основного инструмента, который используется для этой цели в оставшейся части книги.
Определение 4.1. Абстрактный тип данных (АТД) - это тип данных (набор значений и совокупность операций для этих значений), доступ к которому осуществляется только через интерфейс. Программу, которая использует АТД, будем называть клиентом, а программу, содержащую спецификацию этого типа данных - реализацией.
Именно слово только делает тип данных абстрактным: в случае АТД клиентские программы не имеют доступа к значениям данных никаким другим способом, кроме операций, описанных в интерфейсе. Представление этих данных и функции, реализующие эти операции, находятся в реализации и полностью отделены интерфейсом от клиента. Мы говорим, что интерфейс является непрозрачным: клиент не может видеть реализацию через интерфейс.
В программах на языке C++ это различие обычно проводится немного четче, так как проще всего создать интерфейс, включив в него представление данных, но указав, что клиентским программам не разрешен прямой доступ к данным. Другими словами, разработчики клиентских программ могут знать представление данных, но никоим образом не могут его использовать.
В качестве примера рассмотрим интерфейс типа данных для точек (программа 3.3) из раздела 3.1 лекция №3. В этом интерфейсе явно объявляется, что точки представлены как структуры, состоящие из пары чисел с плавающей точкой, обозначаемых x и у. Подобное применение типов данных является обычным в больших системах программного обеспечения: мы разрабатываем набор соглашений о представлении данных (а также определяем ряд связанных с ними операций) и делаем эти правила доступными через интерфейс, чтобы ими могли пользоваться клиентские программы, входящие в состав большой системы. Тип данных обеспечивает согласованность всех частей системы с представлением основных общесистемных структур данных. Какой бы хорошей такая стратегия ни была, она имеет один изъян: если необходимо изменить представление данных, то потребуется изменить и все клиентские программы. Программа 3.3 снова дает нам простой пример: одна из причин разработки этого типа данных - удобство работы клиентских программ с точками, и мы ожидаем, что в случае необходимости у клиентов будет доступ к отдельным координатам точки. Но мы не можем перейти к другому представлению данных (скажем, к полярным координатам, или трехмерным координатам, или даже к другим типам данных для отдельных координат) без изменения всех клиентских программ.
В отличие от этого, программа 4.1 содержит реализацию абстрактного типа данных, соответствующего типу данных из программы 3.3, но с использованием класса языка C++, в котором сразу определены как данные, так и связанные с ними операции. Программа 4.2 является клиентской программой, работающей с этим типом данных. Эти две программы выполняют те же самые вычисления, что и программы 3.3 и 3.8. Они иллюстрируют ряд основных свойств классов, которые мы сейчас рассмотрим.
Когда мы пишем в программе определение наподобие int i, мы указываем системе зарезервировать область памяти для данных (встроенного) типа int, к которой можно обращаться по имени i. В языке C++ для подобных сущностей имеется термин объект. При записи в программе такого определения, как POINT p, говорят, что создается объект класса POINT, к которому можно обращаться по имени p. В нашем примере каждый объект содержит два элемента данных, с именами x и у. Как и в случае структур, к ним можно обращаться по именам вроде p.y.
Элементы данных x и у называются данными-членами класса. В классе могут быть также определены функции-члены, которые реализуют операции, связанные с этим типом данных. Например, класс, определенный в программе 4.1, имеет две функции-члена с именами POINT и distance.
Клиентские программы, такие как программа 4.2, могут вызывать функции-члены, связанные с объектом, указывая их имена точно так же, как и имена данных, находящихся в какой-нибудь структуре struct. Например, выражение p.distance(q) вычисляет расстояние между точками p и q (такое же расстояние должен возвращать и вызов q.distance(p)). Функция POINT() - первая функция в программе 4.1 - является особой функцией-членом, называемой конструктором: у нее такое же имя, как и у класса, и она вызывается тогда, когда требуется создать объект этого класса.
Программа 4.1. Реализация класса POINT (точка)
В этом классе определен тип данных, который состоит из набора значений, представляющих собой "пары чисел с плавающей точкой" (предполагается, что они интерпретируются как точки на декартовой плоскости), а также две функции-члена, определенные для всех экземпляров класса POINT: функция POINT() , которая является конструктором, инициализирующим координаты случайными значениями от 0 до 1, и функция distance(POINT), вычисляющая расстояние до другой точки. Представление данных является приватным (private), и обращаться к нему или модифицировать его могут только функции-члены. Сами функции-члены являются общедоступными (public) и доступны для любого клиента. Код можно сохранить, например, в файле с именем POINT.cxx.
#include <math.h> class POINT { private: float x, у; public: POINT() { x = 1.0*rand()/RAND_MAX; у = 1.0*rand()/RAND_MAX; } float distance(POINT a) { float dx = x-a.x, dy = y-a.y; return sqrt(dx*dx + dy*dy); } };
Программа 4.2. Программа-клиент для класса POINT (нахождение ближайшей точки)
Эта версия программы 3.8 является клиентом, который использует АТД POINT, определенный в программе 4.3. Операция new[] создает массив объектов POINT (вызывая конструктор POINT() для инициализации каждого объекта случайными значениями координат). Выражение a[i].distance(a[j]) вызывает для объекта a[i] функцию-член distance с аргументом a[j] .
#include <iostream.h> #include <stdlib.h> #include "POINT.cxx" int main(int argc, char *argv[]) { float d = atof(argv[2]); int i, cnt = 0, N = atoi(argv[1]); POINT *a = new POINT[N]; for (i = 0; i < N; i++) for (int j = i+1; j < N; j++) if (a[i].distance(a[j]) < d) cnt+ + ; cout << cnt << " пар в радиусе " << d << endl; }
Определение POINT p в программе-клиенте приводит к выделению области памяти под новый объект и затем (с помощью функции POINT()) к присвоению каждому из двух его элементов данных случайного значения в диапазоне от 0 до 1.
Этот стиль программирования, который иногда называется объектно-ориентированным программированием, полностью поддерживается конструкцией class языка C++. Класс можно считать расширением понятия структуры, где не только объединяются данные, но и определяются операции с этими данными. Может существовать много разных объектов, принадлежащих одному классу, но все они подобны в том, что их данные-члены могут принимать один и тот же набор значений, и с этими данными-чле-нами может выполняться одна и та же совокупность операций - в общем, это экземпляры одного и того же типа данных. В объектно-ориентированном программировании объекты предназначены для обработки своих данных-членов (в отличие от использования независимых функций для обработки данных, хранимых в объектах).
Мы рассматриваем описанный выше пример небольшого класса просто чтобы познакомиться с основными чертами классов; поэтому он далеко не полон. В реальном коде для класса точки у нас будет намного больше операций. Например, в программе 4.1 отсутствуют даже операции, позволяющие узнавать значения координат x и y. Как мы увидим, добавление этих и других операций - довольно простая задача. В части 5 мы более подробно рассмотрим классы для точки и других геометрических абстракций, например, линий и многоугольников.
В языке C++ (но не в С) у структур также могут быть связанные с ними функции. Ключевое различие между классами и структурами связано с доступом к информации, который характеризуется ключевыми словами private и public. К приватному (private) члену класса можно обращаться только внутри класса, а к общедоступному (public) члену класса может обращаться любой клиент. Приватными членами могут быть как данные, так и функции: в программе 4.1 приватными являются только данные, но далее мы увидим многочисленные примеры классов, в которых приватными будут также функции-члены. По умолчанию члены классов являются приватными, а члены структур - общедоступными.
Например, в клиентской программе, использующей класс POINT, нельзя ссылаться на данные-члены p.x, p.y и т.д., как это можно сделать для структуры POINT, поскольку члены класса x и y являются приватными. Для обработки точек можно лишь воспользоваться общедоступными функциями-членами. Такие функции имеют прямой доступ к данным-членам любого объекта своего класса. Например, при вызове p.distance(q) функции distance из программы 4.1 имя x в операторе dx = x - a.x относится к данному-члену x из точки p (поскольку функция distance была вызвана как функция-член экземпляра p), а имя a.x относится к данному-члену x из точки q (т.к. q - фактический параметр, соответствующий формальному параметру a). Для исключения возможной двусмысленности или путаницы можно было бы записать dx = this->x-a.x - ключевое слово this означает указатель на объект, для которого вызвана функция-член.
Когда к данным-членам применяется ключевое слово static, это, как и в случае обычных членов, означает, что существует только одна копия этой переменной (относящаяся к классу), а не множество копий (относящихся к отдельным объектам). Эта возможность часто используется, например, для отслеживания статистики, касающейся объектов: можно включить в класс POINT переменную static int N, добавить в конструктор N++ - и тогда появится возможность знать количество созданных точек.
Конечно, можно считать, что функция, вычисляющая расстояние между двумя точками, должна иметь не один аргумент (любую из двух точек), а, как в предыдущей реализации, два аргумента (обе точки), что более естественно. В языке C++ этот подход можно реализовать, определив в классе POINT другую функцию определения расстояния:
static float distance(POINT a, POINT b) { float dx = a.x - b.x, dy = a.y - b.y; return sqrt(dx*dx + dy*dy); }
Статическая функция-член может иметь доступ к членам класса, но не должна вызываться для конкретного объекта.
Другой возможный вариант - определить функцию distance как независимую функцию вне объявления класса POINT (используя тот же самый код, что и в предыдущем абзаце, но без ключевого слова static). Поскольку эта версия функции distance должна иметь доступ к приватным данным-членам класса POINT, в объявление класса POINT потребуется включить строку
friend float distance(POINT, POINT);
Дружественная (friend) функция - это функция, которая, не будучи членом класса, имеет доступ к его приватным членам.
Чтобы клиентские программы имели возможность читать значения данных-членов, можно определить функции-члены, возвращающие эти значения:
float X() const { return x; } float Y() const { return y; }
Эти функции объявлены с ключевым словом const, так как они не модифицируют данные-члены объекта, для которого вызываются. Мы часто включаем в классы языка C++ подобные функции. Обратите внимание, что если бы использовалось другое представление данных, например, полярные координаты, то реализовать эти функции было бы труднее, но эта трудность была бы прозрачной для клиента. Преимущества подобной гибкости можно также задействовать в функциях-членах - если в реализации функции distance из программы 4.1 вместо обращений a.x использовать обращения a.X() , то этот код не придется переписывать при изменении представления данных. Помещая эти функции в приватную часть класса, можно обеспечить такую гибкость даже в тех классах, где не требуется доступ клиента к данным.
Во многих приложениях главная цель в создании класса - определение нового типа данных, наиболее соответствующего потребностям приложения. В таких ситуациях часто требуется использовать этот тип данных таким же образом, как и встроенные типы данных языка C++, например, int или float. Эта тема рассматривается более подробно в разделе 4.8. Один важный инструмент в языке С++, помогающий нам достичь этой цели, называется перегрузкой операций (operator overloading) - она позволяет указать, что к объекту класса могут применяться фундаментальные операции, и что в точности эти операции должны делать. Например, предположим, что требуется считать две точки совпадающими, если расстояние между ними меньше чем 0,001. Добавив в класс код
friend int operator==(POINT a, POINT b) { return distance(a, b) < .001; }
можно с помощью операции == проверять, равны ли две точки (согласно приведенному определению).
Другой операцией, для которой обычно желательна перегрузка, является операция << из класса ostream. При программировании на языке C++ обычно предполагается, что эту операцию можно использовать для вывода значений любого объекта; в действительности же так можно делать, только если в классе определена перегруженная операция <<. В классе POINT это можно сделать следующим образом:
ostream& operator<<(ostream& t, POINT p) { cout << "(" << p.X() << "," << P.Y() << ")"; return t; }
Эта операция не является ни функцией-членом, ни даже дружественной: для доступа к данным она использует общедоступные функции-члены X() и Y() .
Как понятие класса языка C++ соотносится с моделью "клиент-интерфейс-реализация" и абстрактными типами данных? Оно обеспечивает непосредственную языковую поддержку, но очень часто для этого существует несколько различных подходов. Общепринято следующее правило: объявления общедоступных функций в классе образуют его интерфейс. Другими словами, представление данных хранится в приватной части класса, где оно недоступно для программ, использующих класс (клиентских программ). Все, что клиентские программы "знают" о классе - это общедоступная информация о его функциях-членах (имя, тип возвращаемого значения и типы аргументов). Чтобы подчеркнуть важность интерфейса (определяемого посредством класса), мы в этой книге сначала рассматриваем интерфейс (наподобие программы 4.3), и уже затем рассматриваем реализацию - в данном случае это программа 4.1. Дело в том, что такой порядок упрощает анализ других реализаций, с другими представлениями данных и другими реализациями функций, а также тестирование и сравнение реализаций, позволяя делать это без каких-либо изменений клиентских программ.
Для многих приложений возможность изменения реализаций является обязательной. Например, предположим, что создается программное обеспечение для компании, которой необходимо обрабатывать списки почтовых адресов потенциальных клиентов.
Программа 4.3. Интерфейс абстрактного типа данных POINT
В соответствии с общепринятым правилом, интерфейс, относящийся к реализации АТД в виде класса, получают путем устранения приватных частей и замены реализаций функций их объявлениями (сигнатурами). Данный интерфейс именно так и получен из программы 4.1. Мы можем использовать различные реализации, имеющие один и тот же интерфейс, без внесения изменений в код клиентских программ, работающих с этим АТД.
class POINT { private: // Программный код, зависящий от реализации public: POINT(); float distance(POINT) const; };
С помощью классов C++ можно определить функции, которые позволяют клиентским программам работать с данными без непосредственного доступа к ним. Для этого создаются функции-члены, возвращающие требуемые данные. Например, можно предоставить клиентским программам интерфейс, в котором определены такие операции, как извлечь имя клиента или добавить запись о клиенте. Самое важное следствие из такой организации заключается в том, что те же самые клиентские программы можно использовать даже в том случае, если потребуется изменить формат списков почтовых адресов. Или, говоря по-другому, можно изменять представление данных и реализацию функций, имеющих доступ к этим данным, без необходимости модификации клиентских программ.
Подобные реализации типов данных в виде классов иногда называют конкретными типами данных (concrete data type). Однако в действительности тип данных, который подчиняется этим правилам, удовлетворяет также и нашему определению абстрактного типа данных (определение 4.1) - различие между ними состоит в тонкостях определения таких слов, как "доступ", "обращаться" и "определять", но мы оставим эти нюансы теоретикам языков программирования. Действительно, в определении 4.1 точно не сформулировано, что такое интерфейс или как должны быть описаны тип данных и операции. Такая неопределенность является неизбежной, поскольку попытка точно выразить эту информацию в наиболее общем виде потребует использования формального математического языка и, в конце концов, приведет к трудным математическим вопросам. Этот вопрос является центральным при проектировании языка программирования. Вопросы спецификации будут рассматриваться позже, после изучения нескольких примеров абстрактных типов данных.
Применение классов языка C++ для реализации абстрактных типов данных в сочетании с соглашением, что интерфейс состоит из определений общедоступных функций, не является идеальным вариантом, поскольку интерфейс и реализация не являются полностью разделенными. При изменении реализации приходится перекомпилировать программы-клиенты. Некоторые альтернативные варианты рассматриваются в разделе 4.5.
Абстрактные типы данных возникли в качестве эффективного механизма поддержки модульного программирования как принципа организации больших современных программных систем. Они позволяют ограничить размеры и сложность интерфейса между (потенциально сложными) алгоритмами и связанными с ними структурами данных, с одной стороны, и программами (потенциально - большим количеством программ), использующими эти алгоритмы и структуры данных, с другой стороны. Этот принцип упрощает понимание больших прикладных программ в целом. Более того, абстрактные типы данных, в отличие от простых типов данных, обеспечивают гибкость, т.е. простоту изменения или расширения фундаментальных структур данных и алгоритмов, используемых в системе. Самое главное, что интерфейс АТД определяет соглашение между пользователями и разработчиками, которое обеспечивает точные правила взаимодействия, причем каждый знает, что можно ожидать от другого.
При наличии тщательно спроектированных АТД отделение программ-клиентов от реализаций можно использовать различными интересными способами. Например, при разработке или отладке реализаций АТД обычно применяются программы-драйверы. Чтобы узнать свойства программ-клиентов, при построении систем в качестве заполнителей часто используются также неполные реализации абстрактных типов данных, называемые заглушками (stub).
В настоящей лекции абстрактные типы данных рассматриваются подробно потому, что они играют важную роль в изучении структур данных и алгоритмов. Вообще-то основной причиной разработки почти всех алгоритмов, рассматриваемых в этой книге, является стремление обеспечить эффективные реализации базовых операций для некоторых фундаментальных АТД, играющих исключительно важную роль при решении многих вычислительных задач. Проектирование абстрактного типа данных - это только первый шаг в удовлетворении потребностей прикладных программ; необходимо также разработать подходящие реализации связанных с ними операций, а также структур данных, лежащих в основе этих операций. Эти задачи и являются темой настоящей книги. Более того, абстрактные модели используются непосредственно для разработки алгоритмов и структур данных и для сравнения их характеристик производительности. Пример этого мы уже видели в лекция №1: как правило, разрабатывается прикладная программа, использующая АТД для решения некоторой задачи, а затем разрабатываются несколько реализаций АТД, и сравнивается их эффективность. В настоящей лекции этот общий процесс рассматривается подробно, с множеством примеров.
Программисты, пишущие на C++, регулярно используют как простые, так и абстрактные типы данных. Когда мы на низком уровне обрабатываем целые числа, используя только операции, имеющиеся в языке C++, мы, по существу, используем абстракцию для целых чисел, определенную в системе. На какой-нибудь новой машине целые числа могут быть представлены, а операции реализованы каким-либо иным способом, но программа, которая использует только операции, определенные для целых чисел, будет корректно работать и на новой машине. В этом случае различные операции языка C++ для целых чисел составляют интерфейс, наши программы являются клиентами, а программное и аппаратное обеспечение системы обеспечивают реализацию. Часто эти типы данных достаточно абстрактны для того, чтобы мы, не изменяя свои программы, могли перейти на новую машину со, скажем, другими представлениями для целых чисел и чисел с плавающей точкой. Однако этот пример иллюстрирует также и тот факт, что такая идеальная ситуация случается не столь часто, как хотелось бы, поскольку клиентские программы могут выбирать информацию о представлении данных, нащупывая предельные значения того или иного ресурса. Например, узнать некоторую информацию о представлении в машине целых чисел можно, скажем, выполняя цикл, в котором некоторое целое число умножается на два до тех пор, пока не появится сообщение об ошибке переполнения.
Ряд примеров в лекция №3 представляет собой программы на C++, написанные в стиле языка С. Программисты на С часто определяют интерфейсы в заголовочных файлах, описывающих наборы операций для некоторых структур данных; при этом реализации находятся в каком-нибудь другом, независимом файле программы. Такой порядок представляет собой соглашение между пользователем и разработчиком и служит основой для создания стандартных библиотек, доступных в средах программирования на языке С. Однако многие такие библиотеки включают в себя операции для определенной структуры данных и поэтому формируют типы данных, но это не абстрактные типы данных. Например, библиотека обработки строк языка С не является абстрактным типом данных, поскольку программы, работающие со строками, знают, как представлены строки (массивы символов) и, как правило, имеют к ним прямой доступ через индексы массива или арифметику указателей.
В отличие от этого, классы языка C++ позволяют не только использовать разные реализации операций, но и создавать их на основе разных структур данных. Повторяю: ключевая характеристика АТД заключается в том, что он позволяет производить изменения, не модифицируя клиентские программы - поскольку доступ к типу данных разрешен только через интерфейс. Ключевое слово private в определении класса блокирует прямой доступ клиентских программ к данным. Например, можно было бы включить в стандартную библиотеку языка C++ реализацию класса string, созданную на базе, скажем, представления строки в виде связного списка, и использовать эту реализацию без изменения клиентских программ.
На протяжении всей настоящей главы мы рассмотрим многочисленные примеры реализаций АТД с помощью классов языка C++ . После четкого уяснения этих понятий в конце главы мы вернемся к обсуждению теоретических и практических следствий из них.
Упражнения
- 4.1. Предположим, что необходимо подсчитать количество пар точек, находящихся внутри квадрата со стороной d. Для решения этой задачи напишите две разные версии клиента и реализации: (1) модифицируйте соответствующим образом функцию-член distance; (2) замените функцию-член distance функциями-членами X и Y.
- 4.2. Добавьте в класс точки (программа 4.3) функцию-член, которая возвращает расстояние до начала координат.
- 4.3. В программе 4.3 модифицируйте реализацию АТД точки таким образом, чтобы точки были представлены полярными координатами.
- 4.4. Напишите клиентскую программу, которая считывает из командной строки целое число N и заполняет массив N точками, среди которых нет двух равных друг другу. Для проверки равенства или неравенства точек используйте перегруженную операцию ==, описанную в тексте настоящей главы.
- 4.5. Используя представление на базе связного списка, наподобие программы 3.14, преобразуйте интерфейс обработки списков из раздела 3.4 лекция №3 (программа 3.12) в реализацию АТД на базе классов. Протестируйте полученный интерфейс, изменив клиентскую программу (программа 3.13) так, чтобы она использовала этот интерфейс; затем перейдите к реализации на базе массивов (см. упражнение 3.52).
Абстрактные объекты и коллекции объектов
Используемые в приложениях структуры данных часто содержат огромное количество разнотипной информации, и некоторые элементы этой информации могут принадлежать нескольким независимым структурам данных. Например, файл персональных данных может содержать записи с именами, адресами и другой информацией о служащих; и вполне возможно, что каждая запись должна принадлежать к структуре данных, используемой для поиска информации об отдельных служащих, и к структуре данных, используемой для ответа на запросы статистического характера, и т.д.
Несмотря на это разнообразие и сложность, в большом классе программ обработки данных производятся обобщенные действия с объектами данных, а доступ к информации, связанной с этими объектами, требуется только в ограниченном числе особых случаев. Многие из этих действий являются естественным продолжением базовых вычислительных процедур, поэтому они востребованы в самых разнообразных приложениях. Многие из фундаментальных алгоритмов, рассматриваемых в настоящей книге, можно успешно применять для построения уровня абстракции, позволяющего клиентским программам эффективно выполнять такие действия. Поэтому мы подробно рассмотрим многочисленные АТД, связанные с подобными манипуляциями. В этих АТД определены различные операции с коллекциями абстрактных объектов, не зависящие от типа самих объектов.
В разделе 3.1 лекция №3, в котором обсуждалось применение простых типов данных для написания программ, не зависящих от типов объектов, для указания типов элементов данных использовалось описание typedef. Этот подход позволяет использовать один и тот же код для, скажем, целых чисел и чисел с плавающей точкой за счет простого изменения typedef. При использовании указателей типы объектов могут быть сколь угодно сложными. При таком подходе часто приходится делать неявные предположения относительно операций, выполняемых с объектами (например, в программе 3.2 предполагается, что для объектов типа Number определены операции сложения, умножения и приведения к типу float), и, кроме того, представление данных не скрывается от клиентских программ. Абстрактные типы данных позволяют сделать явными любые предположения относительно операций, выполняемых с объектами данных.
В данной лекции мы рассмотрим несколько примеров использования классов C++ с целью построения АТД для обобщенных объектов данных. Будет продемонстрировано создание АТД для объектов обобщенного типа Item, позволяющего писать клиентские программы, в которых объекты Item используются точно так же, как и встроенные типы данных. При необходимости мы будем явно определять в классе Item операции, которые нужны для работы с обобщенными объектами в наших алгоритмах. Все эти характеристики объектов будут задаваться без предоставления клиентским программам какой-либо информации о представлении данных.
После реализации класса Item для обобщенных объектов (либо выбора подходящего встроенного класса) мы будем пользоваться механизмом шаблонов языка С++ для написания кода, который является обобщенным относительно типов объектов. Например, операцию обмена для обобщенных элементов можно определить следующим образом:
template <class Item> void exch(Item &x, Item &y) { Item t = x; x = y; y = t; }
Аналогично реализуются и другие простые операции с элементами. С помощью шаблонов можно определять семейства классов, по одному для каждого типа элементов.
Разобравшись с классами обобщенных объектов, можно перейти к рассмотрению коллекций (collection) объектов. Многие структуры данных и алгоритмы, которые будут рассмотрены в данной книге, применяются для реализации фундаментальных АТД, представляющих собой коллекции абстрактных объектов и создаваемых с помощью двух следующих операций:
- вставить новый объект в коллекцию.
- удалить объект из коллекции.
Такие АТД называются обобщенными очередями (generalized queue). Как правило, для удобства в них также явно включаются следующие операции: создать (construct) структуру данных (конструктор) и подсчитать (count) количество объектов в структуре данных (или просто проверить, пуста ли она). Могут также потребоваться операции уничтожить (destroy) структуру данных (деструктор) и копировать (copy) структуру данных (конструктор копирования); эти операции будут рассмотрены в разделе 4.8.
Когда объект вставляется в коллекцию, тут все понятно, но какой объект имеется в виду при удалении объекта из коллекции? В разных АТД для коллекций объектов применяются различные критерии для определения того, какой объект удалять в операции удаления, и устанавливаются различные правила, связанные с этими критериями. Помимо операций вставить и удалить, мы столкнемся с рядом других естественных операций. Многие алгоритмы и структуры данных, рассматриваемые в книге, были спроектированы с целью обеспечения эффективной реализации различных поднаборов этих операций и для разнообразных критериев выполнения операции удаления и других правил. Эти АТД концептуально просты и широко используются в огромном множестве вычислительных задач - поэтому они заслуживают серьезного внимания.
Мы рассмотрим некоторые из этих фундаментальных структур данных, их свойства и примеры применения; в то же самое время мы используем их в качестве примеров, чтобы проиллюстрировать основные механизмы, используемые для разработки АТД. В разделе 4.2 будет рассмотрен стек магазинного типа (pushdown stack), в котором для удаления объектов используется следующее правило: всегда удаляется объект, вставленный последним. В разделе 4.3 будут описаны различные применения стеков, а в разделе 4.4 - реализации стеков, при этом реализации будут отделены от приложений. После обсуждения стеков мы вернемся к процессу создания нового АТД в контексте абстракции объединение-поиск для задачи связности, которая была рассмотрена в лекция №1. А затем мы вернемся к коллекциям абстрактных объектов и рассмотрим очереди FIFO и обобщенные очереди (которые на данном уровне абстракции отличаются от стеков только правилом удаления элементов), а также обобщенные очереди без повторяющихся элементов.
Как было показано в лекция №3, массивы и связные списки обеспечивают основные механизмы, позволяющие вставлять и удалять заданные элементы. Действительно, связные списки и массивы - это структуры данных, лежащие в основе нескольких реализаций рассматриваемых нами обобщенных очередей. Как мы знаем, затраты на вставку и удаление элементов зависят от конкретной структуры и от конкретного вставляемого или удаляемого элемента. В отношении некоторого данного АТД задача заключается в том, чтобы выбрать структуру данных, позволяющую эффективно выполнять требуемые операции. В настоящей лекции подробно рассматриваются несколько примеров абстрактных типов данных, для которых связные списки и массивы обеспечивают подходящие решения. Абстрактные типы данных, обеспечивающие более эффективные операции, требуют более сложных реализаций, что является главной причиной создания многих алгоритмов, рассматриваемых в настоящей книге.
Типы данных, которые состоят из коллекций абстрактных объектов (обобщенные очереди), являются центральным объектом изучения в компьютерных науках, поскольку они непосредственно поддерживают фундаментальную парадигму вычислений. Оказывается, что при выполнении значительного большинства вычислений приходится иметь дело с большим числом объектов, но обрабатывать их можно только поочередно - по одному объекту за раз. Поэтому во время обработки одного объекта требуется где-то хранить остальные. Эта обработка может включать проверку некоторых уже сохраненных объектов, или добавление в коллекцию новых объектов, но основой таких вычислений являются операции сохранения объектов и их извлечения в соответствии с определенным критерием. Как будет показано, этому шаблону соответствуют многие классические структуры данных и алгоритмы.
В языке C++ классы, реализующие коллекции абстрактных объектов, называются контейнерными классами (container class). Некоторые из структур данных, которые будут рассмотрены ниже, реализованы в библиотеке языка C++ или ее расширениях (стандартной библиотеке шаблонов - Standard Template Library). Во избежание путаницы мы будем редко ссылаться на эти классы, и излагать материал, начиная с самых основ.
Упражнения
- 4.6. Дайте определение для класса Item, в котором для проверки равенства чисел с плавающей точкой используется перегруженная операция ==. Считайте два числа с плавающей точкой равными, если абсолютная величина их разности, деленная на большее (по абсолютной величине) из двух чисел, меньше чем 10-6.
- 4.7. Дайте определение класса Item и перегрузите операции == и << так, чтобы их можно было использовать в программе обработки игральных карт.
- 4.8. Перепишите программу 3.1 так, чтобы в ней использовался класс обобщенных объектов Item. Ваша программа должна работать для любого типа объектов класса Item, которые могут выводиться при помощи операции <<, генерироваться случайным образом статической функцией-членом rand(), и для которых определены операции + и /.
АТД для стека магазинного типа
Самый важный тип данных из тех, в которых определены операции вставить и удалить для коллекций объектов, называется стеком магазинного типа.
Стек работает отчасти подобно ящику для студенческих работ у весьма занятого профессора: работы студентов скапливаются стопкой, и каждый раз, когда у профессора появляется возможность просмотреть какую-нибудь работу, он берет ее сверху. Работа студента вполне может застрять на дне стопки на день или два, однако, скорее всего, добросовестный профессор к концу недели управится со всей стопкой и освободит ящик. Ниже мы увидим, что работа компьютерных программ естественно организована именно таким образом. Они часто откладывают некоторые задачи и выполняют в это время другие; более того, зачастую им требуется в первую очередь вернуться к той задаче, которая была отложена последней. Таким образом, стеки магазинного типа являются фундаментальной структурой данных для множества алгоритмов.
Определение 4.2. Стек магазинного типа - это АТД, который включает две основные операции: вставить, или втолкнуть (push), новый элемент и удалить, или вытолкнуть (pop), элемент, вставленный последним.
Когда мы говорим об АТД стека магазинного типа, мы считаем, что существует достаточно хорошее описание операций втолкнуть и вытолкнуть, чтобы клиентская программа могла их использовать, а также некоторая реализация этих операций в соответствии с правилом удаления элементов такого стека: последним пришел, первым ушел (last-in, first-out, сокращенно LIFO).
На рис. 4.1 показано, как изменяется содержимое стека в процессе выполнения серии операций втолкнуть и вытолкнуть. Каждая операция втолкнуть увеличивает размер стека на 1, а каждая операция вытолкнуть уменьшает его на 1. На рисунке элементы стека перечисляются в порядке их помещения в стек, поэтому ясно, что самый правый элемент списка - это элемент, который находится на верхушке стека и будет извлечен из стека, если следующей операцией будет операция вытолкнуть. В реализации элементы можно организовывать любым требуемым способом, однако при этом у программ-клиентов должна сохраняться иллюзия, что элементы организованы именно так.

Рис. 4.1. Пример стека магазинного типа (очереди LIFO)
Здесь представлены результаты выполнения последовательности операций. В левом столбце показаны выполняемые операции (сверху вниз), где буква означает операцию втолкнуть, а звездочка - операцию вытолкнуть. В каждой строке показана выполняемая операция, буква, извлекаемая при операции выталкивания, и содержимое стека после операции (от первой занесенной буквы до последней - слева направо).
Как было сказано в предыдущем разделе, для того чтобы можно было писать программы, использующие абстракцию стека, сначала необходимо определить интерфейс. Поэтому мы объявляем набор общедоступных функций-членов, которые будут использоваться в реализациях класса (см. программу 4.4). Все остальные члены класса объявляются приватными (private), и тем самым обеспечивается, что эти функции будут единственной связью между клиентскими программами и реализациями. В лекция №1 и лекция №3 мы уже видели важность определения абстрактных операций, на которых основаны требуемые вычисления. Сейчас мы рассматриваем механизм, позволяющий записывать программы, в которых применяются эти абстрактные операции. Для реализации такой абстракции используется механизм классов, который позволяет скрыть структуры данных и реализацию от программы-клиента. В разделе 4.3 будут рассмотрены примеры клиентских программ, использующих абстракцию стека, а в разделе 4.4 - соответствующие реализации.
Первая строка кода интерфейса для АТД стека в программе 4.4 добавляет в этот класс шаблон C+ + , позволяющий клиентским программам указывать вид объектов, которые могут заноситься в стек.
Объявление
STACK<int> save(N) указывает, что элементы стека save должны быть типа int (и что стек может вместить не более N элементов). Программа-клиент может создавать стеки, содержащие объекты типа float, или char, или любого другого типа (даже типа STACK) - для этого необходимо просто изменить параметр шаблона в угловых скобках. Мы можем считать, что указанный класс замещает в реализации класс Item везде, где он встречается.
В абстрактном типе данных интерфейс выполняет роль соглашения между клиентом и реализацией. Объявления функций обеспечивают соответствие между вызовами в клиентской программе и определениями функций в реализации. Однако интерфейс не содержит никакой информации о том, как должны быть реализованы функции, или хотя бы как они должны функционировать. Как мы можем объяснить клиентской программе, что такое стек? Для простых структур, подобных стеку, можно было бы открыть код, но ясно, что в общем случае такое решение неэффективно. Чаще всего программисты прибегают к описаниям на "естественном" языке в документации на программу.
Строгая трактовка этой ситуации требует полного описания того, как должны работать функции (с использованием формальной математической нотации). Такое описание иногда называют спецификацией. Разработка спецификации обычно является трудной задачей. Она должна описывать любую программу, реализующую функции, на математическом метаязыке, тогда как мы привыкли определять работу функций с помощью кода, написанного на языке программирования. На практике работа функций представляется в виде описаний на естественном языке. Но давайте пойдем дальше, пока мы не углубились слишком далеко в гносеологические вопросы. В настоящей книге приведены подробные примеры, описания на русском языке и по нескольку реализаций для большинства рассматриваемых АТД.
Чтобы показать, что наша спецификация АТД стека содержит достаточно информации для написания осмысленной клиентской программы, мы до углубления в реализации рассмотрим в разделе 4.3 две клиентские программы, использующие стеки магазинного типа.
Программа 4.4. Интерфейс АТД стека
Используя то же соглашение, что и в программе 4.3, мы определяем АТД стека через объявление общедоступных функций. При этом предполагается, что представление стека и любой другой код, зависящий от реализации, являются приватными, чтобы можно было изменять реализации, не изменяя код клиентских программ. Кроме того, в этом интерфейсе применяется шаблон, что позволяет программам-клиентам использовать стеки, содержащие объекты любых классов (см. программы 4.5 и 4.6), а в реализациях для обозначения типа объектов стека использовать ключевое слово Item (см. программы 4.7 и 4.8). Аргумент конструктора STACK задает максимальное количество элементов, которые можно поместить в стек.
template <class Item> class STACK { private: // Программный код, зависящий от реализации public: STACK(int); int empty() const; void push(Item item); Item pop(); };
Упражнения
-
4.9. В последовательности
E A S * Y * Q U E * * * S T * * * I O * N * * *
буква означает операцию втолкнуть, а звездочка - операцию вытолкнуть. Приведите последовательность значений, возвращаемых операциями вытолкнуть.
- 4.10. Используя те же соглашения, что и в упражнении 4.9, вставьте звездочки в последовательность E A S Y таким образом, чтобы последовательность значений, возвращаемых операциями вытолкнуть, была следующей: (1) E A S Y; (2) Y S A E; (3) A S Y E; (4) A Y E S; либо докажите, что такая последовательность невозможна.
- 4.11. Предположим, что даны две последовательности букв. Разработайте алгоритм, позволяющий определить, можно ли в первую последовательность добавить звездочки так, чтобы эта последовательность, выполненная как последовательность стековых операций (в смысле упражнения 4.10), дала в результате вторую последовательность.
Примеры клиентов, использующих ATД стека
В последующих главах мы увидим огромное количество применений стеков. А сейчас в качестве вводного примера рассмотрим применение стеков для вычисления арифметических выражений. Например, предположим, что требуется найти значение простого арифметического выражения с операциями умножения и сложения целых чисел наподобие
5 * ( ( ( 9 + 8 ) * ( 4 * 6 ) ) + 7 )
При вычислении необходимо сохранять промежуточные результаты: например, если сначала вычисляется 9 + 8, придется сохранить результат 17 на время вычисления 4*6. Стек магазинного типа представляет собой идеальный механизм для сохранения промежуточных результатов в таких вычислениях.
Начнем с рассмотрения более простой задачи, где выражение, которое необходимо вычислить, имеет другую форму: знак операции стоит после двух своих аргументов, а не между ними. Как будет показано ниже, любое арифметическое выражение может быть представлено в такой форме, которая называется постфиксной, в отличие от инфиксной - обычной формы записи арифметических выражений. Вот постфиксное представление выражения из предыдущего абзаца:
5 9 8 + 4 6 * * 7 + *
Форма записи, обратная постфиксной, называется префиксной или польской записью (так как ее придумал польский логик Лукашевич).
При инфиксной записи чтобы отличить, например, выражение
5 * ( ( ( 9 + 8 ) * ( 4 * 6 ) ) + 7 )
от выражения
( ( 5 * 9 ) + 8 ) * ( ( 4 * 6 ) + 7 )
требуются скобки; но в постфиксной (или префиксной) записи скобки не нужны. Чтобы понять, почему это так, можно рассмотреть следующий процесс преобразования постфиксного выражения в инфиксное: все группы из двух операндов со следующим за ними знаком операции заменяются их инфиксными эквивалентами и заключаются в круглые скобки - они означают, что этот результат может рассматриваться как операнд. То есть, группы a b * и a b + заменяются соответственно на группы (a * b) и (a + b). Затем то же самое преобразование выполняется с полученным выражением, и весь процесс продолжается до тех пор, пока не будут обработаны все операции. Вот шаги преобразования для нашего случая:
5 9 8 + 4 6 * * 7 + *
5 ( 9 + 8 ) ( 4 * 6 ) * 7 + *
5 ( ( 9 + 8 ) * ( 4 * 6 ) ) 7 + *
5 ( ( ( 9 + 8 ) * ( 4 * 6 ) ) + 7 ) *
( 5 * ( ( ( 9 + 8 ) * ( 4 * 6 ) ) + 7 ) )
Таким способом в постфиксном выражении можно определить все операнды, связанные с любой операцией, поэтому необходимость в применении скобок отпадает.
А с помощью стека можно выполнить эти операции и вычислить значение любого постфиксного выражения (см. рис. 4.2 рис. 4.2). Перемещаясь слева направо, мы интерпретируем каждый операнд как команду "занести операнд в стек", а каждый знак операции - как команды "извлечь из стека два операнда, выполнить операцию и занести результат в стек". Программа 4.5 является реализацией этого процесса на языке C++. Обратите внимание, что, поскольку АТД стека создан в виде шаблона, один и тот же код пригоден и для создания стека целых чисел в этой программе, и стека символов в программе 4.6.
Постфиксная запись и стек магазинного типа обеспечивают естественный способ организации ряда вычислительных процедур. В некоторых калькуляторах и языках программирования метод вычислений явно базируется на постфиксной записи и стековых операциях - при выполнении любой операции ее аргументы извлекаются из стека, а результат возвращается в стек.
Примером такого языка является язык PostScript, с помощью которого напечатана данная книга. Это завершенный язык программирования, в котором программы пишутся в постфиксном виде и интерпретируются с помощью внутреннего стека, в точности как в программе 4.5. Мы не можем осветить здесь все аспекты этого языка (см. раздел ссылок), но он достаточно прост, чтобы мы рассмотрели некоторые реальные программы и оценили полезность постфиксной записи и абстракции стека магазинного типа. Например, строка 5 9 8 add 4 6 mul mul 7 add mul является PostScript-программой! Программа на языке PostScript состоит из операций (таких, как add и mul) и операндов (например, целые числа). Программа на этом языке интерпретируется так же, как в программе 4.5 - слева направо. Если встречается операнд, он заносится в стек; если встречается знак операции, из стека извлекаются операнды для этой операции (если они нужны), а затем результат (если он есть) заносится в стек.

Рис. 4.2. Вычисление постфиксного выражения
Эта последовательность операций демонстрирует использование стека для вычисления постфиксного выражения 5 9 8 + 4 6 * * 7 + *. Выражение обрабатывается слева направо и если встречается число, оно заносится в стек; если же встречается знак операции, то эта операция выполняется с двумя верхними числами стека, и результат опять заносится в стек.
Программа 4.5. Вычисление постфиксного выражения
Эта программа-клиент для стека магазинного типа считывает любое постфиксное выражение с операциями умножения и сложения целых чисел, затем вычисляет это выражение и выводит полученный результат. Промежуточные результаты она хранит в стеке целых чисел; при этом предполагается, что интерфейс из программы 4.4 реализован в файле STACK.cxx как шаблон класса.
Когда встречаются операнды, они заносятся в стек; когда встречаются знаки операций, из стека извлекаются два верхних элемента, с ними выполняется данная операция, и результат снова заносится в стек. Порядок выполнения двух операций pop() для сложений и умножений в языке C++ не определен, а код для некоммутативных операций, таких как вычитание или деление, был бы более сложным.
В программе неявно предполагается, что целые числа и знаки операций ограничены какими-нибудь другими символами (скажем, пробелами), но программа не проверяет корректность входных данных. Последний оператор if и цикл while выполняют вычисление наподобие функции atoi языка C++, которая преобразует строки в коде ASCII в целые числа, пригодные для вычислений. Когда встречается новая цифра, накопленный результат умножается на 10, и к нему прибавляется эта цифра.
#include <iostream.h> #include <string.h> #include "STACK.cxx" int main(int argc, char *argv[]) { char *a = argv[1]; int N = strlen(a); STACK<int> save(N); for (int i = 0; i < N; i++) { if (a[i] == '+') save.push(save.pop() + save.pop()); if (a[i] == '*') save.push(save.pop() * save.pop()); if ((a[i] >= '0') && (a[i] <= '9')) save.push(0); while ((a[i] >= '0') && (a[i] <= '9')) save.push(10*save.pop() + (a[i++]-'0')); } cout << save.pop() << endl; }
Таким образом, на рис. 4.2 полностью описан процесс выполнения этой программы: после выполнения программы в стеке остается число 2 07 5.
В языке PostScript имеется несколько примитивных функций, которые служат инструкциями для абстрактного графопостроителя; а кроме них, можно определять и собственные функции. Эти функции вызываются с аргументами, расположенными в стеке, таким же способом, как и любые другие функции. Например, следующий код на языке PostScript 0 0 moveto 144 hill 0 72 moveto 72 hill stroke соответствует последовательности действий "вызвать функцию moveto с аргументами 0 и 0, затем вызвать функцию hill с аргументом 144" и т.д. Некоторые операции относятся непосредственно к самому стеку.
Например, операция dup дублирует элемент в верхушке стека; поэтому, например, код 144 dup 0 rlineto 60 rotate dup 0 rlineto означает следующую последовательность действий: вызвать функцию rlineto с аргументами 144 и 0, затем вызвать функцию rotate с аргументом 60, затем вызвать функцию rlineto с аргументами 144 и 0 и т.д. В PostScript-программе, показанной на рис. 4.3, определяется и используется функция hill. Функции в языке PostScript подобны макросам: строка /hill { A } def делает имя hill эквивалентным последовательности операций внутри фигурных скобок. На рис. 4.3 показан пример PostScript-программы, в которой определяется функция и вычерчивается простая диаграмма.

Рис. 4.3. Простая программа на языке PostScript
В верхней части рисунка приведена диаграмма, а в нижней - PostScript-программа, формирующая эту диаграмму. Программа является постфиксным выражением, в котором используются встроенные функции moveto, rlineto, rotate, stroke и dup, а также определяемая пользователем функция hill (см. текст). Графические команды являются инструкциями графопостроителю: команда moveto устанавливает перо в заданную позицию страницы (координаты даются в пунктах, равных 1/72 дюйма); команда rlineto перемещает перо в новую позицию, координаты которой задаются относительно текущей позиции (тем самым к пройденному пути добавляется очередной участок); команда rotate изменяет направление движения пера (поворачивает влево на заданное число градусов); а команда stroke вычерчивает пройденный путь.
В данном контексте наш интерес к языку PostScript объясняется тем, что этот широко используемый язык программирования основан на абстракции стека магазинного типа. Вообще-то в аппаратных средствах многих компьютеров реализованы основные стековые операции, поскольку они являются естественным воплощением механизма вызова функций: при входе в процедуру текущее состояние программной среды сохраняется - заносится в стек; при выходе из процедуры состояние программной среды восстанавливается - извлекается из стека. Как будет показано в лекция №5, эта связь между магазинными стеками и программами, которые организованы в виде функций, обращающихся к другим функциям, является основной парадигмой вычислительного процесса.

Рис. 4.4. Преобразование инфиксного выражения в постфиксное
Данная последовательность демонстрирует использование стека для преобразования инфиксного выражения (5*(((9+8)*(4*6))+7)) в постфиксную форму 5 9 8 + 4 6 * * 7 + *. Выражение обрабатывается слева направо: если встречается число, оно записывается в выходной поток; если встречается левая скобка, она игнорируется; если встречается знак операции, он заносится в стек; и если встречается правая скобка, то в выходной поток выталкивается знак операции, находящийся на верхушке стека.
Возвращаясь к первоначальной задаче, отметим, что, как видно из рис. 4.4, магазинный стек можно также использовать для преобразования инфиксного арифметического выражения с круглыми скобками в постфиксную форму. При выполнении этого преобразования знаки операций заносятся в стек, а сами операнды просто передаются в выходной поток программы. Правая скобка показывает, что два последних числа на выходе программы являются аргументами операции, знак которой занесен в стек последним. Поэтому этот знак операции извлекается из стека и также передается в выходной поток программы.
Программа 4.6 представляет собой реализацию этого процесса. Обратите внимание, что аргументы в постфиксном выражении расположены в том же самом порядке, что и в инфиксном выражении. Любопытно, что левые скобки в инфиксном выражении не нужны. Однако они необходимы, если существуют операции, имеющие разное количество операндов (см. упражнение 4.14).
Программа 4.6. Преобразование из инфиксной формы в постфиксную
Эта программа является еще одним примером программы-клиента для стека магазинного типа. В данном случае стек содержит символы. Для преобразования (A+B) в постфиксную форму A B + левая скобка игнорируется, символ A записывается в выходной поток, знак + запоминается в стеке, символ B записывается в выходной поток, а затем при обнаружении правой скобки знак + извлекается из стека и записывается в выходной поток.
#include <iostream.h> #include <string.h> #include "STACK.cxx" int main(int argc, char *argv[]) { char *a = argv[1]; int N = strlen(a); STACK<char> ops(N); for (int i = 0; i < N; i++) { if (a[i] == ')') cout << ops.pop() << " "; if ((a[i] == '+') || (a[i] == '*')) ops.push(a[i]); if ((a[i] >= '0') && (a[i] <= '9')) cout << a[i] << " "; } cout << endl; }
Помимо того, что алгоритм, разработанный в данном разделе для вычисления инфиксных выражений, предоставляет два разных примера использования абстракции стека, он и сам по себе является упражнением по абстракциям. Во-первых, входные данные преобразуются в промежуточное представление (постфиксное выражение). Во-вторых, для интерпретации и вычисления этого выражения имитируется работа абстрактной машины, функционирующей на основе стека. В целях эффективности и мобильности эта схема применяется во многих современных компиляторах: задача компиляции программы на C++ для конкретного компьютера разбивается на две задачи с промежуточным представлением между ними. Поэтому задача трансляции программы отделяется от задачи выполнения этой программы, точно так же, как это делалось в данном разделе. В разделе 5.7 лекция №5 будет показано похожее, но другое промежуточное представление.
Это приложение также демонстрирует достоинства абстрактных типов данных и шаблонов C++ . Здесь не просто используются два разных стека: один из них содержит объекты типа char (знаки операций), а другой - объекты типа int (операнды). С помощью АТД в виде шаблона класса, определенного в программе 4.4, можно даже объединить две рассмотренных клиентских программы в одну (см. упражнение 4.19). Несмотря на привлекательность этого решения, учтите, что оно может и не быть оптимальным: ведь различные реализации могут отличаться своей производительностью, так что не стоит априори считать, что одна и та же реализация будет хорошо работать в обоих случаях. Вообще-то наш главный интерес - реализации и их производительность, и сейчас мы приступим к рассмотрению этих вопросов применительно к стеку магазинного типа.
Упражнения
-
4.12. Преобразуйте в постфиксное выражение
( 5 * ( ( 9 * 8 ) + ( 7 * ( 4 + 6 ) ) ) ) .
-
4.13. Таким же способом, как на рис. 4.2, покажите содержимое стека при вычислении программой 4.5 выражения
59*8746+*213*+*+*.
- 4.14. Расширьте программы 4.5 и 4.6 таким образом, чтобы они обрабатывали операции - (вычитание) и / (деление).
- 4.15. Расширьте решение упражнения 4.14 таким образом, чтобы оно включало унарные операции - (смена знака) и $ (извлечение квадратного корня). Кроме того, измените механизм абстрактного стека в программе 4.5 так, чтобы можно было использовать числа с плавающей точкой. Например, имея в качестве исходного выражение
(-(-1) + $((-1) * (-1)-(4 * (-1)))) / 2 программа должна выдать число 1.618034.
- 4.16. Напишите на языке PostScript программу которая вычерчивает следующую фигуру:
- 4.17. Методом индукции докажите, что программа 4.5 правильно вычисляет любое постфиксное выражение.
- 4.18. Напишите программу, которая преобразует постфиксное выражение в инфиксное, используя стек магазинного типа.
- 4.19. Объедините программы 4.5 и 4.6 в один модуль, в котором будут использоваться два разных АТД: стек целых чисел и стек (знаков) операций.
-
4.20. Напишите компилятор и интерпретатор для языка программирования, в котором каждая программа состоит из одного арифметического выражения. Выражению может предшествовать ряд операторов присваивания с арифметическими выражениями, состоящими из целых чисел и переменных, обозначаемых одиночными строчными буквами. Например, получив входные данные
(x = 1)
(y = (x + 1))
(((x + y) * 3) + (4 * x))
программа должна вывести число 13.
Реализации АТД стека
В данном разделе рассматриваются две реализации АТД стека: в одной используются массивы, а в другой - связные списки. Эти реализации получаются в результате простого применения базовых средств, рассмотренных в лекция №3. Мы считаем, что они различаются только своей производительностью.
Если для представления стека применяется массив, то все функции, объявленные в программе 4.4, реализуются очень просто - см. программу 4.7. Элементы заносятся в массив в точности так, как показано на рис. 4.1, при этом отслеживается индекс верхушки стека. Выполнение операции втолкнуть означает запоминание элемента в позиции массива, указываемой индексом верхушки стека, а затем увеличение этого индекса на единицу; выполнение операции вытолкнуть означает уменьшение индекса на единицу и извлечение элемента, обозначенного этим индексом.
Программа 4.7. Реализация стека магазинного типа на базе массива
В этой реализации N элементов стека хранятся как элементы массива: s[0], ..., s[N-1], начиная с первого занесенного элемента и завершая последним. Верхушкой стека (позицией, в которую будет занесен следующий элемент стека) является элемент s[N]. Максимальное количество элементов, которое может вмещать стек, программа-клиент передает в виде аргумента в конструктор STACK, размещающий в памяти массив заданного размера. Однако код не проверяет такие ошибки, как помещение элемента в заполненный стек (или выталкивание элемента из пустого стека).
template <class Item> class STACK { private: Item *s; int N; public: STACK(int maxN) { s = new Item[maxN]; N = 0; } int empty() const { return N == 0; } void push(Item item) { s[N++] = item; } Item pop() { return s[ - N]; } };
Операция создать (конструктор) выполняет выделение памяти под массив указанного размера, а операция проверить, пуст ли стек проверяет, равен ли индекс нулю. Скомпилированная вместе с клиентской программой (такой, как программа 4.5 или 4.6), эта реализация обеспечивает эффективный стек магазинного типа.
Известен один потенциальный недостаток применения представления стека (и не только) в виде массива: до использования массива необходимо знать его максимальный размер, чтобы выделить под него память. В рассматриваемой реализации эта информация передается в аргументе конструктора. Это ограничение - результат выбора реализации на базе массива; оно не присуще самому АТД стека. Зачастую бывает трудно определить максимальное число элементов, которое программа будет заносить в стек, и если выбрать слишком большое число, то такая реализация будет неэффективно использовать память, а это может быть нежелательно в тех приложениях, где память является ценным ресурсом. А если выбрать слишком маленькое число, программа может вообще оказаться неработоспособной. Применение АТД дает возможность рассматривать другие варианты и изменять реализацию без изменения кода клиентских программ.
Например, чтобы стек мог свободно увеличиваться и уменьшаться, можно отдать предпочтение связному списку, как в программе 4.8. В таком стеке элементы будут храниться в обратном порядке по сравнению с реализацией на базе массива - начиная с последнего занесенного элемента и завершая первым (см. рис. 4.5). Это позволяет более просто реализовать базовые стековые операции. Чтобы вытолкнуть элемент, удаляется узел из начала списка, и из него извлекается элемент; чтобы втолкнуть элемент, создается новый узел и добавляется в начало списка. Поскольку все операции связного списка выполняются в начале списка, ведущий узел не нужен.

Рис. 4.5. Стек магазинного типа на базе связного списка
Стек представлен указателем head, который указывает на первый (последний вставленный) элемент. Чтобы вытолкнуть элемент из стека (вверху), удаляется элемент из начала списка, а в head заносится ссылка из этого элемента. Чтобы втолкнуть в стек новый элемент (внизу), он присоединяется в начало списка: в его поле ссылки заносится значение head, а в head - указатель на этот новый элемент.
Программа 4.8. Реализация стека магазинного типа на базе связного списка
В этой программе АТД реализуется с помощью связного списка. Представление данных для узлов связного списка организовано традиционным способом (см. лекция №3) и включает конструктор для узлов, который заполняет каждый новый узел заданными элементом и ссылкой.
template <class Item> class STACK { private: struct node { Item item; node* next; node(Item x, node* t) { item = x; next = t; } }; typedef node *link; link head; public: STACK(int) { head = 0; } int empty() const { return head == 0; } void push(Item x) { head = new node(x, head); } Item pop() { Item v = head->item; link t = head->next; delete head; head = t; return v; } };
Программа 4.8 не проверяет такие ошибки, как попытка извлечения элемента из пустого стека, занесение элемента в переполненный стек или нехватка памяти. В отношении двух последних условий имеются две возможности. Их можно трактовать как не связанные между собой ошибки и отслеживать количество элементов в списке, а при каждом занесении в стек проверять, что счетчик не превышает значение, переданное конструктору в качестве аргумента, и что операция new выполнена успешно. Но можно выбрать позицию, когда не требуется заранее знать максимальный размер стека, и, игнорируя аргумент конструктора, сообщать о переполнении стека только при отказе операции new (см. упражнение 4.24).
Программы 4.7 и 4.8 представляют две различные реализации одно и того же АТД. Можно заменять одну реализацию другой, не делая никаких изменений в клиентских программах, подобных тем, которые рассматривались в разделе 4.3. Они отличаются только производительностью. Реализация на базе массива использует объем памяти, необходимый для размещения максимального числа элементов, которые может вместить стек в процессе вычислений; реализация на базе списка использует объем памяти, пропорциональный количеству элементов, но при этом всегда расходует дополнительную память для одной ссылки на каждый элемент, а также дополнительное время на выделение памяти при каждой операции втолкнуть и освобождение памяти при каждой операции вытолкнуть. Если требуется стек больших размеров, который обычно заполняется практически полностью, то, по-видимому, предпочтение стоит отдать реализации на базе массива. Если же размер стека варьируется в широких пределах, и имеются другие структуры данных, которые могут занимать память тогда, когда в стеке находятся лишь несколько элементов, лучше воспользоваться реализацией на базе связного списка.
Мы еще не раз увидим в данной книге, что эти же самые соображения относительно использования оперативной памяти справедливы для многих реализаций АТД. Разработчикам часто приходится выбирать между возможностью быстрого доступа к любому элементу при необходимости заранее указывать максимальное число требуемых элементов (в реализации на базе массивов) и использованием памяти пропорционально количеству используемых элементов, но без возможности быстрого доступа к любому элементу (в реализации на базе связных списков).
Помимо этих основных соображений об использования памяти, обычно нас больше всего интересуют различия в производительности разных реализаций АТД, которые влияют на время выполнения. В данном случае различия между двумя рассмотренными реализациями незначительны.
Лемма 4.1. Используя либо массивы, либо связные списки для АТД стека магазинного типа, можно реализовать операции втолкнуть и вытолкнуть, имеющие постоянное время выполнения.
Этот факт непосредственно следует из внимательного изучения программ 4.7 и 4.8.
То, что в реализациях на базе массива и связного списка элементы стека хранятся в разном порядке, для клиентских программ не имеет никакого значения. В реализациях могут использоваться какие угодно структуры данных, лишь бы они создавали впечатление абстрактного стека магазинного типа. В обоих рассмотренных случаях реализации способны создавать впечатление эффективного абстрактного объекта, который может выполнять необходимые операции с помощью всего лишь нескольких машинных инструкций. В данной книге мы будем разрабатывать структуры данных и эффективные реализации для других важных АТД.
Реализация на базе связного списка создает впечатление стека, который может увеличиваться неограниченно. Однако в реальности такой стек невозможен: рано или поздно, когда запрос на выделение еще некоторого объема памяти не сможет быть выполнен, операция new сгенерирует исключение. Можно также организовать стек на базе массива, который будет увеличиваться и уменьшаться динамически: когда стек заполняется наполовину, размер массива увеличивается в два раза, а когда стек становится наполовину пустым, размер массива уменьшается в два раза. Детали реализации такой стратегии мы оставляем в качестве упражнения в лекция №14, где этот процесс будет подробно рассмотрен для более сложных применений.
Упражнения
- 4.21. Определите содержимое элементов s[0], ..., s[4] после выполнения программой 4.7 операций, показанных нарис. 4.1.
- 4.22. Предположим, что вы заменяете в интерфейсе стека магазинного типа операцию проверить, пуст ли стек на операцию подсчитать, которая возвращает количество элементов, находящихся в данный момент в структуре данных. Реализуйте операцию подсчитать для представления на базе массива (программа 4.7) и представления на базе связного списка (программа 4.8).
- 4.23. Измените реализацию стека магазинного типа на базе массива (программа 4.7) так, чтобы в ней вызывалась функция-член error() при попытке клиентской программы выполнить операцию вытолкнуть, когда стек пуст, или операцию втолкнуть, когда стек переполнен.
- 4.24. Измените реализацию стека магазинного типа на базе связного списка (программа 4.8) так, чтобы в ней вызывалась функция-член error() при попытке клиентской программы выполнить операцию вытолкнуть, когда стек пуст, или операцию втолкнуть, когда отсутствует доступная память при вызове new.
- 4.25. Измените реализацию стека магазинного типа на базе связного списка (программа 4.8) так, чтобы для создания списка в ней использовался массив индексов (см. рис. 3.4).
- 4.26. Напишите реализацию стека магазинного типа на базе связного списка, в которой элементы списка хранятся начиная с первого занесенного элемента и завершая последним занесенным элементом. Потребуется использовать двухсвязный список.
- 4.27. Разработайте АТД, предоставляющий два разных стека магазинного типа. Воспользуйтесь реализацией на базе массива. Один стек расположите в начале массива, а другой - в конце. (Если клиентская программа работает так, что при увеличении одного стека другой уменьшается, эта реализация будет занимать меньший объем памяти, чем другие варианты.)
- 4.28. Реализуйте функцию вычисления инфиксных выражений, содержащих целые числа. Она должна включать программы 4.5 и 4.6 и использовать АТД из упражнения 4.27. Примечание: используйте тот факт, что оба стека содержат элементы одного и того же типа.
Создание нового АТД
В разделах 4.2 - 4.4 приведен пример полного кода программы на C++ для одной из наиболее важных абстракций: стека магазинного типа. В интерфейсе из раздела 4.2 определены основные операции; клиентские программы, вроде приведенных в разделе, 4.3 могут использовать эти операции независимо от их реализации, а реализация из раздела 4.4 обеспечивает конкретное представление и программный код АТД.
Создание нового АТД часто сводится к следующему процессу. Начав с разработки клиентской программы, предназначенной для решения прикладной задачи, определяются операции, которые считаются наиболее важными: какие операции хотелось бы выполнять над данными? Потом определяется интерфейс и записывается код программы-клиента, чтобы проверить, упростит ли использование АТД реализацию клиентской программы. Затем выполняется анализ, можно ли достаточно эффективно реализовать АТД для нужных операций. Если нет, необходимо найти источник неэффективности, а затем изменить интерфейс, поместив в него операции, более подходящие для эффективной реализации. Поскольку модификация влияет и на клиентскую программу, ее потребуется тоже соответствующим образом изменить. После нескольких таких итераций мы получим работающую клиентскую программу и работающую реализацию; тогда интерфейс "замораживается", т.е. мы решаем больше не изменять его. С этого момента разработка клиентских программ и реализаций выполняется раздельно: можно писать другие клиентские программы, использующие тот же самый АТД (скажем, программы-драйверы для тестирования АТД), можно записывать другие реализации, и можно сравнивать производительность различных реализаций.
В других ситуациях можно начать с определения АТД. При таком подходе необходимо решить следующие вопросы: какие базовые операции над имеющимися данными могут потребоваться клиентским программам, и какие операции мы умеем реализовать эффективно? Завершив разработку реализации, можно проверить ее эффективность с помощью клиентских программ. Перед окончательным "замораживанием" интерфейса, возможно, потребуется дополнительная модификация и тестирование.
В лекция №1 был подробно рассмотрен пример, где размышления о том, какой уровень абстракции использовать, помогли отыскать эффективный алгоритм решения сложной задачи. Теперь посмотрим, как обсуждаемый в настоящей лекции обобщенный подход можно применить для инкапсуляции абстрактных операций из лекция №1.
В программе 4.9 определен интерфейс, включающий две операции (не считая операции создать). Похоже, что эти операции описывают алгоритмы связности, рассмотренные в лекция №1, на высоком уровне абстракции. Независимо от базовых алгоритмов и структур данных, необходимо иметь возможность проверки связности двух узлов, а также объявления, что конкретные два узла являются связанными.
Программа 4.10 - это клиентская программа, которая решает задачу связности, используя АТД с интерфейсом, представленным в программе 4.9. Одно из преимуществ этого АТД состоит в наглядности программы, поскольку она написана с использованием абстракций, позволяющих естественно представить процесс вычислений.
Программа 4.11 является реализацией интерфейса объединение-поиск, который определен в программе 4.9. В этой реализации (см. раздел 1.3 лекция №1) применяется лес деревьев, в основе которого лежат два массива, представляющие известную информацию о связях. В разных алгоритмах, рассмотренных в лекция №1, используются различные реализации АТД, причем их можно тестировать независимо друг от друга, не изменяя клиентскую программу.
Программа 4.9. Интерфейс АТД для отношений эквивалентности
Интерфейс АТД построен так, чтобы было удобно записывать код, в точности соответствующий принятому решению выражать алгоритм связности с помощью класса, поддерживающего три абстрактных операции: инициализировать (initialize) абстрактную структуру данных для отслеживания связей между заданным числом узлов; найти (find), являются ли два узла связанными; и соединить (unite) два данных узла и считать их с этого момента связанными.
class UF { private: // Программный код, зависящий от реализации public: UF(int); int find(int, int); void unite(int, int); };
Программа 4.10. Клиент для АТД отношений эквивалентности
АТД из программы 4.9 позволяет отделить алгоритм определения связности от реализации объединение-поиск, делая его более доступным.
#include <iostream.h> #include <stdlib.h> #include "UF.cxx" int main(int argc, char *argv[]) { int p, q, N = atoi(argv[1]); UF info(N); while (cin >> p >> q) if (!info.find(p, q)) { info.unite(p, q); cout << " " << p << " " << q << endl; } }
Программа 4.11. Реализация АТД отношений эквивалентности
Этот код взвешенного быстрого объединения из лекция №1 является реализацией интерфейса из программы 4.9 в форме, удобной для его использования в других приложениях. Перегруженная приватная функция-член find реализует обход дерева вплоть до его корня.
class UF { private: int *id, *sz; int find(int x) { while (x != id[x]) x = id[x]; return x; } public: UF(int N) { id = new int[N]; sz = new int[N]; for (int i = 0; i < N; i++) { id[i] = i; sz[i] = 1; } } int find(int p, int q) { return (find(p) == find(q)); } void unite(int p, int q) { int i = find(p), j = find(q); if (i == j) return; if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i]; } else { id[j] = i; sz[i] += sz[j]; } } };
Программы на основе этого АТД несколько менее эффективны, нежели программа связности из лекция №1, поскольку в нем не учитывается, что в данной программе каждой операции объединение непосредственно предшествует операция поиск. Подобные дополнительные издержки являются платой за переход к более абстрактному представлению. В данном случае существует множество способов устранения этого недостатка, например, за счет усложнения интерфейса или реализации (см. упражнение 4.30). На практике пути бывают очень короткими (особенно, если применяется сжатие пути), так что в данном случае дополнительные издержки окажутся незначительными.
Сочетание программ 4.10 и 4.11 функционально эквивалентно программе 1.3, однако разбиение программы на две части более эффективно, так как:
- позволяет отделить решение задачи высокого уровня (задачи связности) от решения задачи низкого уровня (задачи объединение-поиск) и решать эти две задачи независимо
- предоставляет естественный способ сравнения различных алгоритмов и структур данных, применяемых при решении этой задачи
- определяет с помощью интерфейса способ проверки, что программа работает как задумано
- обеспечивает механизм, позволяющий переходить к новым представлениям (к новым структурам данных или новым алгоритмам) без каких-либо изменений кода клиентских программ
- предоставляет абстракцию, с помощью которой можно конструировать другие алгоритмы
Все эти преимущества характерны для многих задач, с которыми приходится сталкиваться при разработке компьютерных программ, так что эти базовые принципы построения абстрактных типов данных применяются исключительно широко.
В коде программы 4.11 смешаны интерфейс и реализация; поэтому он не допускает отдельную компиляцию клиентских программ и реализаций. Для того чтобы разные реализации могли использовать один и тот же интерфейс, программу можно, как в разделе 3.1 лекция №3, разделить на три файла следующим образом.
Создается заголовочный файл - скажем, с именем UF.h - который будет содержать объявление класса, представление данных и объявления функций, но не определения функций. В рассматриваемом примере этот файл будет содержать код программы 4.9, в который также включается представление данных (приватные объявления из программы 4.11). Определения функций необходимо сохранить в отдельном файле .cxx, который (как и все клиенты) будет также содержать директиву include для файла UF.h. В этом случае появляется возможность раздельной компиляции клиентских программ и реализаций. Вообще-то определение любой функции-члена класса можно поместить в отдельный файл, если эта функция-член объявлена в классе, а в определении функции перед ее именем находится имя класса и знак ::. Например, определение функции find в нашем примере следовало бы записать так:
int UF::find(int p, int q) { return (find(p) == find(q)); }
К сожалению, при раздельной компиляции разные компиляторы налагают различные требования на реализацию шаблонов. Проблема заключается в том, что компилятор не может создать код для функции-члена, не зная типа параметра шаблона, который недоступен, так как определен в главной программе. Определение функций-членов внутри объявлений классов позволяет избежать таких проблем с компиляцией.
Однако такая трехфайловая система все равно не идеальна, поскольку представление данных хранится в одном файле с интерфейсом, хотя в действительности является частью реализации. С помощью ключевого слова private можно закрыть доступ к нему со стороны клиентских программ, но если провести в реализации изменения, которые требуют изменений в представлении данных, то придется изменить .h-файл и перекомпилировать все клиентские программы. Во многих ситуациях разработки программного обеспечения информация о программах-клиентах может отсутствовать, так что это слишком жесткое требование. Но иногда такой подход может иметь смысл. В случае очень большого и сложного АТД можно сначала договориться о представлении данных и интерфейсе, а уже потом усадить команду программистов за разработку различных частей реализации. В этом случае общедоступная часть интерфейса служит соглашением между программистами и клиентами, а приватная часть - соглашением сугубо между программистами. К тому же в ситуациях, когда необходимо найти оптимальный способ решения некоторой задачи при условии, что должна использоваться некая конкретная структура данных, эта стратегия - именно то, что нужно. Таким образом можно повысить производительность, изменив лишь незначительную часть огромной системы.
В языке C++ имеется механизм, специально предназначенный для того, чтобы писать программы с хорошо определенным интерфейсом, который позволяет полностью отделять клиентские программы от реализаций. Этот механизм базируется на понятии производных (derived) классов, посредством которых можно дополнять или переопределять некоторые члены существующего класса. Включение ключевого слова virtual в объявление функции-члена означает, что эта функция может быть переопределена в производном классе; добавление последовательности символов = 0 в конце объявления функции-члена указывает, что данная функция является чисто виртуальной (pure virtual) функцией, которая должна быть переопределена в любом производном классе. Производные классы обеспечивают удобный способ создания программ на основе работ других программистов и являются важным компонентом объектно-ориентированных систем программирования.
Абстрактный (abstract) класс - это класс, все члены которого являются чисто виртуальными функциями. В любом классе, порожденном от абстрактного класса, должны быть определены все функции-члены и любые необходимые приватные данные-члены - таким образом, согласно нашей терминологии, абстрактный класс является интерфейсом, а любой класс, порожденный от него, является реализацией. Программы-клиенты могут использовать интерфейс, а система C++ может обеспечить соблюдение соглашений между клиентами и реализациями, даже когда клиенты и реализации функций компилируются раздельно. Например, в программе 4.12 показан абстрактный класс uf для отношений эквивалентности; если изменить первую строку программы 4.11 следующим образом:
class UF : public class uf
то она будет указывать, что класс UF является производным от класса uf, и поэтому в нем определены (как минимум) все функции-члены класса uf - т.е. класс UF является реализацией интерфейса uf.
К сожалению, использование абстрактных классов влечет за собой значительное увеличение времени выполнения программы, поскольку каждый вызов виртуальной функции требует обращения к таблице указателей на функции-члены. Кроме того, компиляторы сильно ограничены в возможностях при оптимизации кода для абстрактных классов. Рассматриваемые в книге алгоритмы и структуры данных часто задействованы в частях системы, критичных к производительности, поэтому такая цена за гибкость, предоставляемую абстрактными классами, может оказаться непозволительно большой.
Есть еще один способ - применение четырех файлов, когда приватные части хранятся не в интерфейсе, а в отдельном файле. Например, в начало класса из программы 4.9 можно было бы добавить строки
private: #include "UFprivate.h" и поместить в файл UFprivate.h строки int *id, *sz; int find(int);
Эта стратегия позволяет аккуратно разграничить четыре компонента (клиентскую программу, реализацию, представление данных и интерфейс) и обеспечивает максимальную гибкость при экспериментировании со структурами данных и алгоритмами.
Программа 4.12. Абстрактный класс для АТД отношения эквивалентности
Приведенный ниже код формирует интерфейс АТД отношения эквивалентности, который обеспечивает полное разделение клиентов и реализаций (см. текст).
class uf { public: virtual uf(int) = 0; virtual int find(int, int) = 0; virtual void unite(int, int) = 0; };
Гибкость, которую дают производные классы и стратегия "четырех файлов ", сохраняет возможность (может быть, непреднамеренного) нарушения соглашений между клиентами и реализациями в отношении того, каким должен быть АТД. Все эти механизмы гарантируют, что клиентские программы и реализации будут корректно компоноваться; однако они также зависят друг от друга для выполнения таких действий, которые в общем случае нельзя описать формально. Например, предположим, что какой-нибудь неопытный программист не смог разобраться в нашем алгоритме взвешенного быстрого поиска и решил заменить его алгоритмом быстрого объединения (или, что еще хуже, реализацией, которая даже не дает правильного решения). Мы всегда стремились к тому, чтобы такие изменения вносились легко, но в данном случае это может совершенно испортить программу-клиент в важном приложении, которое зависит от производительности реализации для крупных задач. Практика программирования полна подобных примеров, и от них очень трудно защититься.
Однако подобные рассуждения уводят нас к свойствам языков программирования, компиляторов, компоновщиков и сред выполнения программ, что весьма далеко от темы алгоритмов. Поэтому мы обычно будем придерживаться простого разделения программы на два файла, где АТД реализуются в виде классов C++, общедоступные функции-члены составляют интерфейс, а реализация объединена с интерфейсом в отдельном файле, который включается в программы-клиенты и компилируется каждый раз при компиляции клиентов. В основном это связано с тем, что реализация в виде класса - это удобное и компактное средство представления структур данных и алгоритмов. Но если для какого-либо отдельного приложения потребуется большая гибкость, которая может быть обеспечена одним из рассмотренных способов, то ничто не помешает нам соответствующим образом изменить структуры классов.
Упражнения
- 4.29. Измените программу 4.11 так, чтобы в ней использовалось сжатие пути делением пополам.
- 4.30. Устраните упоминаемую в тексте неэффективность программы, добавив в программу 4.9 операцию, которая объединяет операции объединение и поиск, и соответствующим образом изменив программы 4.11 и 4.10.
- 4.31. Измените интерфейс (программа 4.9) и реализацию (программа 4.11) для отношений эквивалентности так, чтобы в них присутствовала функция, возвращающая количество узлов, которые связаны с данным.
- 4.32. Измените программу 4.11 так, чтобы для представления структуры данных в ней вместо параллельных массивов использовался массив структур.
- 4.33. Напишите программу из трех файлов (без шаблонов), вычисляющую значение постфиксного выражения с помощью стека целых чисел. Обеспечьте, чтобы клиентскую программу (аналог программы 4.5) можно было компилировать отдельно от реализации стека (аналог программы 4.7).
- 4.34. Измените решение предыдущего упражнения - отделите представление данных от реализаций функций-членов (программа из четырех файлов). Протестируйте полученный результат, подставив реализацию стека на базе связного списка (аналог программы 4.8) без перекомпиляции программы-клиента.
- 4.35. Создайте полную реализацию АТД отношения эквивалентности на базе абстрактного класса с виртуальными функциями и сравните производительность полученной программы и программы 4.11 на крупных задачах связности (в стиле таблицы 1.1).
Очереди FIFO и обобщенные очереди
Очередь (first-in, first-out - первым пришел, первым ушел, сокращенно FIFO) является еще одним фундаментальным АТД. Она похожа на стек магазинного типа, но с противоположным правилом удаления элемента: из очереди удаляется не последний вставленный элемент, а наоборот - элемент, который был вставлен в очередь раньше всех остальных.
Пожалуй, ящик для студенческих работ у нашего занятого профессора должен бы был функционировать как очередь FIFO, поскольку порядок "первым пришел, первым ушел" интуитивно кажется более справедливым для выбора очередной работы. Однако этот профессор не всегда отвечал на телефонные звонки и даже опаздывал на лекции! В стеке какая-нибудь служебная записка может застрять на дне, но срочные документы обрабатываются сразу же после появления. В очереди FIFO документы обрабатываются по порядку, и каждый должен ожидать своей очереди.
Очереди FIFO часто встречаются в повседневной жизни. Когда мы стоим в цепочке людей, чтобы посмотреть кинокартину или купить продукты, нас обслуживают в порядке FIFO. Аналогично этому в вычислительных системах очереди FIFO часто используются для обслуживания задач, которые необходимо выполнять по принципу "первым пришел, первым обслужен". Другим примером, иллюстрирующим различие между стеками и очередями FIFO, может служить отношение к скоропортящимся продуктам в магазине. Если продавец выкладывает новые товары на переднюю часть полки, и покупатели берут товары также с передней части полки, получается стек. У продавца могут возникнуть неприятности, поскольку товар на задней части полки может стоять очень долго и попросту испортиться. Выкладывая новые товары на заднюю часть полки, продавец гарантирует, что время, в течение которого товар находится на полке, ограничивается временем, необходимым покупателям для приобретения всех товаров, выставляемых на полку. Этот же базовый принцип применяется во множестве подобных ситуаций.
Определение 4.3. Очередь FIFO - это АТД, который содержит две базовых операции: вставить (put - занести) новый элемент и удалить (get - извлечь) элемент, который был вставлен раньше всех остальных.
Программа 4.13 является интерфейсом для АТД очереди FIFO. Этот интерфейс отличается от интерфейса стека, рассмотренного в разделе 4.2, только названиями - для компилятора эти два интерфейса идентичны! Это подчеркивает тот факт, что сама абстракция, которую программисты обычно не определяют формально, является существенным компонентом абстрактного типа данных. Для больших приложений, которые могут содержать десятки АТД, задача их точного определения очень важна. В настоящей книге мы работаем с АТД, представляющими важнейшие понятия, которые определяются в тексте, но не при помощи формальных языков (разве что через конкретные реализации). Чтобы понять природу абстрактных типов данных, потребуется рассмотреть примеры их использования и конкретные реализации.
На рис. 4.6 рис. 4.6 показано, как очередь FIFO изменяется в ходе ряда последовательных операций извлечь и занести. Каждая операция извлечь уменьшает размер очереди на 1, а каждая операция занести увеличивает размер очереди на 1. Элементы очереди перечислены на рисунке в порядке их занесения в очередь, поэтому ясно, что первый элемент списка - это тот элемент, который будет возвращаться операцией извлечь. Опять-таки, в реализации можно организовать элементы любым требуемым способом при условии сохранения иллюзии того, что элементы организованы именно в соответствие с дисциплиной FIFO.

Рис. 4.6. Пример очереди FIFO
На рисунке показан результат выполнения последовательности операций. Операции представлены в левом столбце (порядок выполнения - сверху вниз); здесь буква обозначает операцию put (занести), а звездочка - операцию get (извлечь). Каждая строка содержит операцию, букву, возвращаемую операцией get, и содержимое очереди от первого занесенного элемента до последнего (слева направо).
Программа 4.13. Интерфейс АТД очереди FIFO
Данный интерфейс идентичен интерфейсу стека магазинного типа из программы 4.4, за исключением имен функций. Эти два АТД отличаются только спецификациями, которые не отражаются в коде интерфейса.
template <class Item> class QUEUE { private: // Программный код, зависящий от реализации public: QUEUE(int); int empty(); void put(Item); Item get(); };
В случае реализации АТД очереди FIFO с помощью связного списка, элементы списка хранятся в порядке от первого вставленного до последнего вставленного элемента (см. рис. 4.6). Такой порядок является обратным по отношению к порядку, который применяется в реализации стека; именно такой порядок позволяет создавать эффективные реализации операций с очередями. Как показано на рис. 4.7 и в программе 4.14 (реализация), требуются два указателя на этот список: один на начало списка (для извлечения первого элемента) и второй на его конец (для занесения в очередь нового элемента).
Программа 4.14. Реализация очереди FIFO на базе связного списка
Различие между очередью FIFO и стеком магазинного типа (программа 4.8) состоит в том, что новые элементы вставляются в конец списка, а не в его начало. Поэтому в данном классе хранится указатель tail на последний узел списка, чтобы функция put могла добавлять в список новый узел: этот узел связывается с узлом, на который указывает указатель tail, а затем указатель tail изменяется так, чтобы он указывал уже на новый узел. Функции QUEUE, get и empty повторяют аналогичные функции в реализации стека магазинного типа на базе связного списка из программы 4.8. Поскольку новые узлы всегда вставляются в конец списка, конструктор узлов может обнулять поле указателя каждого нового узла и поэтому принимать только один аргумент.
template <class Item> class QUEUE { private: struct node { Item item; node* next; node(Item x) { item = x; next = 0; } }; typedef node *link; link head, tail; public: QUEUE(int) { head = 0; } int empty() const { return head == 0; } void put(Item x) { link t = tail; tail = new node(x); if (head == 0) head = tail; else t->next = tail; } Item get() { Item v = head->item; link t = head->next; delete head; head = t; return v; } };

Рис. 4.7. Очередь на базе связного списка
В представлении очереди в виде связного списка новые элементы вставляются в конец списка, поэтому элементы связного списка располагаются от первого вставленного элемента (в начале) до последнего (в конце). Очередь представляется двумя указателями: head (начало) и tail (конец), которые указывают, соответственно, на первый и последний элемент. Для извлечения элемента из очереди удаляется элемент в начале очереди - так же, как и в случае стека (см. рис. 4.5). Чтобы занести в очередь новый элемент, в поле ссылки узла, на который ссылается указатель tail, заносится указатель на новый элемент (в середине рисунка), а затем изменяется указатель tail (внизу).
Для реализации очереди FIFO можно использовать и массив, однако при этом необходимо соблюдать осторожность и обеспечить, чтобы время выполнения как операции занести, так и операции извлечь было постоянным. Это условие запрещает пересылать элементы очереди внутри массива, как это можно было бы предположить при буквальной интерпретации рис. 4.6. Поэтому, как и в реализации на базе связного списка, нам потребуются два индекса массива: для начала очереди и для конца. Содержимым очереди считаются элементы между этими двумя индексами. Чтобы извлечь элемент, он удаляется из начала очереди, после чего индекс head увеличивается на единицу; чтобы занести элемент, он добавляется в конец очереди, а индекс tail увеличивается на единицу. Как показано на рис. 4.8, в результате выполнения последовательности операций занести и извлечь все выглядит так, будто очередь движется по массиву. При достижении конца массива выполняется переход к его началу. Соответствующая реализация приведена в коде программы 4.15.
Лемма 4.2. Для АТД очереди FIFO можно реализовать операции извлечь и занести с постоянным временем выполнения, используя либо массивы, либо связные списки.
Это понятно из анализа кода программ 4.14 и 4.15.

Рис. 4.8. Пример очереди FIFO, реализованной на базе массива
Здесь приведена последовательность операций с данными для абстрактного представления очереди из рис. 4.6. Очередь реализована за счет запоминания ее элементов в массиве, сохранения индексов начала и конца очереди и возврата индексов на начало массива при достижении его конца. В данном примере индекс tail возвращается на начало массива, когда вставляется второй символ T, а индекс head - когда удаляется второй символ S.
Соображения относительно использования оперативной памяти, которые были изложены в разделе 4.4, применимы и к очередям FIFO. Представление на базе массива требует резервирования объема памяти, достаточного для запоминания максимально ожидаемого количества элементов очереди. В случае же представления на базе связного списка объем необходимой памяти пропорционален числу элементов в структуре данных, но за счет дополнительного расхода памяти на ссылки и дополнительного времени на выделение и освобождение памяти для каждой операции.
По причине фундаментальной взаимосвязи между стеками и рекурсивными программами (см. лекция №5), стеки встречаются чаще, чем очереди FIFO, но существуют и алгоритмы, для которых естественными базовыми структурами данных являются очереди. Как уже отмечалось, очереди и стеки используются в вычислительных приложениях чаще всего для того, чтобы отложить выполнение того или иного процесса. Хотя многие приложения, использующие очередь отложенных задач, работают корректно вне зависимости от правила удаления элементов, общее время выполнения программы или объем других необходимых ресурсов может зависеть от этого. Когда в подобных приложениях встречается большое количество операций вставить или удалить, выполняемых над структурами данных с большим числом элементов, различия в производительности очень важны. Поэтому в настоящей книге столь существенное внимание уделяется таким АТД. Если бы производительность программ не интересовала нас, можно было бы создать один единственный АТД с операциями вставить и удалить; однако производительность является предельно важным показателем, поэтому каждое правило, в сущности, определяет отдельный АТД. Чтобы оценить эффективность конкретного АТД, требуется учитывать затраты двух видов: затраты, обусловленные реализацией, которые зависят от выбранных алгоритмов и структур данных, и затраты, обусловленные конкретным правилом принятия решений, в смысле его влияния на производительность клиентской программы. Ниже в данном разделе будут описаны несколько таких АТД, которые будут подробно рассмотрены в последующих главах.
Программа 4.15. Реализация очереди FIFO на базе массива
К содержимому очереди относятся все элементы массива, расположенные между индексами head и tail, учитывая возврат к 0 с конца массива. Если индексы head и tail равны, очередь считается пустой, однако если они стали равными в результате выполнения операции put, очередь считается полной. Как обычно, проверка на такие ошибочные ситуации не выполняется, но размер массива задается на 1 больше максимального количества элементов очереди, ожидаемое клиентом. При необходимости программу можно расширить, включив в нее такие проверки.
template <class Item> class QUEUE { private: Item *q; int N, head, tail; public: QUEUE(int maxN) { q = new Item[maxN+1]; N = maxN+1; head = N; tail = 0; } int empty() const { return head % N == tail; } void put(Item item) { q[tail++] = item; tail = tail % N; } Item get() { head = head % N; return q[head++]; } };
И стеки магазинного типа, и очереди FIFO являются частными случаями более общего АТД: обобщенной (generalized) очереди. Частные случаи обобщенных очередей различаются только правилами удаления элементов. Для стеков это правило "удалить элемент, который был вставлен последним", для очередей FIFO - правило "удалить элемент, который был вставлен первым"; существует и множество других вариантов.
Простым, но, тем не менее, мощным вариантом является неупорядоченная очередь (random queue) с правилом "удалить случайный элемент" - программа-клиент может ожидать, что она с одинаковой вероятностью получит любой из элементов очереди. Используя представление на базе массива (см. упражнение 4.48), для неупорядоченной очереди можно реализовать операции с постоянным временем выполнения. Это представление на базе массива требует, как для стеков и очередей FIFO, предварительного выделения оперативной памяти. Однако в данном случае альтернативное представление на базе связного списка менее удобно, чем в случае стеков и очередей FIFO, поскольку эффективная реализация как операции вставки, так и операции удаления является очень трудной задачей (см. упражнение 4.49). На базе неупорядоченных очередей можно создавать рандомизированные алгоритмы, которые с высокой степенью вероятности позволяют избежать вариантов наихудшей производительности (см. раздел 2.7 лекция №2).
В нашем описании стеков и очередей FIFO элементы упорядочены по времени вставки в очередь. Но можно рассмотреть более абстрактную концепцию - последовательный ряд упорядоченных элементов с базовыми операциями вставки и удаления элементов и в начале, и в конце списка. Если элементы вставляются в конец списка и удаляются также с конца, получается стек (как в реализации на базе массива); если элементы вставляются в начало и удаляются в начале, тоже получается стек (как в реализации на базе связного списка). Если же элементы вставляются в конец, а удаляются в начале, то получается очередь FIFO (как в реализации на базе связного списка). Если элементы вставляются в начало, а удаляются с конца, также получается очередь FIFO (этот вариант не соответствует ни одной из реализаций - для его точной реализации можно было бы изменить представление на базе массива, а вот представление на базе связного списка для этой цели не подойдет, т.к. в случае удалении элементов в конце очереди придется сохранять указатель на конец очереди). Эта точка зрения приводит нас к абстрактному типу данных дека (deque - double-ended queue, двухсторонняя очередь), в котором и вставки, и удаления возможны с обеих сторон. Его реализацию мы оставляем в качестве упражнений (см. упражнения 4.43 - 4.47); заметим, что реализация на базе массива окажется простым расширением программы 4.15, а для реализации на базе связного списка потребуется двухсвязный список, иначе удалять элементы дека можно будет только с одной стороны.
В лекция №9 рассматриваются очереди с приоритетами, в которых элементы имеют ключи, а правило удаления элементов имеет вид "удалить элемент с самым маленьким ключом". АТД очереди с приоритетами полезен во множестве приложений, и задача нахождения эффективных реализаций для этого АТД была целью исследований в компьютерных науках в течение многих лет. Важным фактором в исследованиях были распознавание и использование АТД в приложениях: подставив новую реализацию вместо старой в большом и сложном приложении и сравнив результаты, можно сразу же определить, является ли новый алгоритм правильным. Более того, посмотрев, как изменяется общее время выполнения приложения при подстановке новой реализации, можно сразу же определить, является ли новый алгоритм более эффективным, чем старый. Структуры данных и алгоритмы, которые рассматриваются в лекция №9 для решения данной задачи, интересны, необычны и эффективны.
В главах с 12 по 16 будут рассмотрены таблицы символов (symbol table). Это обобщенные очереди, в которых элементы имеют ключи, а правило удаления элементов имеет вид "удалить элемент, ключ которого равен данному, если таковой элемент существует". Этот АТД, пожалуй, самый важный из изучаемых, и мы изучим десятки его реализаций.
Каждый из этих АТД порождает ряд родственных, но разных АТД, которые появились в результате внимательного изучения клиентских программ и производительности различных реализаций. В разделах 4.7 и 4.8 и далее в данной книге будут рассмотрены многочисленные примеры изменений в спецификации обобщенных очередей, ведущие к еще более разнообразным АТД.
Упражнения
- 4.36. Найдите содержимое элементов q[0], ..., q[4] после выполнения программой 4.15 операций,показанных на рис.4.6. Считайте, что maxN, как и на рис. 4.8 рис. 4.8, равно 10.
-
4.37. В последовательности
a S * Y * Q U E * * * S T * * * I O * N * * *
буква означает операцию занести, а звездочка - операцию извлечь. Найдите последовательность значений, возвращаемых операциями извлечь, если эта последовательность операций выполняется над первоначально пустой очередью FIFO.
- 4.38. Измените приведенную в тексте реализацию очереди FIFO на базе массива (программа 4.15) так, чтобы в ней вызывалась функция error(), если клиент пытается выполнить операцию извлечь, когда очередь пуста, или операцию занести, когда очередь заполнена.
- 4.39. Измените приведенную в тексте реализацию очереди FIFO на базе связного списка (программа 4.14) так, чтобы в ней вызывалась функция error(), если клиент пытается выполнить операцию извлечь, когда очередь пуста, или если при выполнении new для операции занести отсутствует доступная память.
-
4.40. В последовательности
E A s + Y + Q U E * * + s t + * + I O * n + + *
прописные буквы означают операцию занести в начале дека, строчные буквы - операцию занести в конце дека, знак плюс означает операцию извлечь в начале, а звездочка - операцию извлечь в конце. Найдите последовательность значений, возвращаемых операциями извлечь, если эта последовательность операций выполняется над первоначально пустым деком.
- 4.41. При тех же условиях, что и в упр. 4.40, расставьте в последовательности E a s Y плюсы и звездочки так, чтобы операции извлечь возвращали следующую последовательность символов: (1) E s a Y, (2) Y a s E, (3) a Y s E, (4) a s Y E; либо в каждом случае докажите, что такая последовательность невозможна.
- 4.42. Приведите алгоритм, позволяющий определить для двух данных последовательностей, можно ли в первую последовательность вставить плюсы и звездочки так, чтобы, интерпретируя ее как последовательность операций над деком в смысле упр. 4.41, получить вторую последовательность.
- 4.43. Напишите интерфейс для АТД дека.
- 4.44. Напишите реализацию для интерфейса дека (упр. 4.43), в которой в качестве базовой структуры данных используется массив.
- 4.45. Напишите реализацию для интерфейса дека (упр. 4.43), в которой в качестве базовой структуры данных используется двухсвязный список.
- 4.46. Напишите реализацию для приведенного в тексте интерфейса очереди FIFO (программа 4.13), в которой в качестве базовой структуры данных используется циклический список.
- 4.47. Напишите программу-клиент для тестирования АТД дека (упр. 4.43), которая считывает из командной строки в качестве первого аргумента строку команд, подобную приведенной в упр. 4.40, после чего выполняет указанные операции. В интерфейс и реализации добавьте функцию-член dump и выводите содержимое дека после каждой операции, как это сделано на рис. 4.6.
- 4.48. Создайте АТД неупорядоченной очереди (напишите интерфейс и реализацию), в котором в качестве базовой структуры данных используется массив. Обеспечьте для каждой операции постоянное время выполнения.
- 4.49. Создайте АТД неупорядоченной очереди (напишите интерфейс и реализацию), в котором в качестве базовой структуры данных используется связный список. Напишите как можно более эффективные реализации операций вставить и удалить, и оцените затраты на их выполнение для наихудшего случая.
- 4.50. Напишите программу-клиент, которая выбирает для лотереи числа следующим образом: заносит в неупорядоченную очередь числа от 1 до 99, а затем выводит результат удаления пяти чисел.
- 4.51. Напишите программу-клиент, которая принимает из первого аргумента командной строки целое число N, а затем выводит результат раздачи карт на N игроков в покер. Для этого она должна занести в неупорядоченную очередь N элементов (см. упр. 4.7) и затем выдавать результат выбора из этой очереди по пять карт за один раз.
- 4.52. Напишите программу решения задачи связности. Для этого она должна вставлять все пары в неупорядоченную очередь, а затем извлекать их из очереди с помощью алгоритма взвешенного быстрого поиска (программа 1.3).
Повторяющиеся и индексные элементы
Во многих приложениях обрабатываемые абстрактные элементы должны быть уникальными. Это качество подводит нас к мысли пересмотреть представления о том, как должны функционировать стеки, очереди FIFO и другие обобщенные АТД. В частности, в данном разделе рассматриваются такие изменения спецификаций стеков, очередей FIFO и обобщенных очередей, которые запрещают в этих структурах данных наличие повторяющихся элементов.
Например, компании, ведущей список рассылки по адресам покупателей, может потребоваться расширить этот список информацией из других списков, собранных из различных источников. Это выполняется с помощью соответствующих операций вставить. При этом не требуется заносить информацию о покупателях, адреса которых уже присутствуют в списке. Далее мы увидим, что этот же принцип применим в самых разнообразных приложениях. В качестве еще одного примера рассмотрим задачу маршрутизации сообщений в сложной сети передачи данных. Можно передавать сообщение одновременно по нескольким маршрутам ("наперегонки"), однако во внутренних структурах данных каждого отдельно взятого узла сети должна находиться не более чем одна копия этого сообщения.
Один из подходов к решению данной проблемы - поручить программам-клиентам следить за тем, чтобы в АТД не было повторяющихся элементов. Наверно, программы-клиенты могли бы выполнять эту задачу, используя какой-нибудь другой АТД. Но поскольку цель создания любого АТД - обеспечить клиенты четкими решениями прикладных задач, то похоже, что обнаружение и устранение дубликатов - это часть задачи, относящаяся к компетенции АТД.
Запрет присутствия повторяющихся элементов приводит к изменению абстракции: интерфейс, имена операций и прочее для такого АТД будут такими же, как и для соответствующего АТД без такого запрета, но поведение реализации изменится кардинально. Вообще говоря, при изменении спецификации какого-нибудь АТД получается совершенно новый АТД - АТД, обладающий совсем другими свойствами. Эта ситуация демонстрирует также ненадежную природу спецификации АТД: обеспечить, чтобы клиентские программы и реализации придерживались спецификации интерфейса, довольно трудно, однако обеспечить применение такого высокоуровневого принципа - совсем другое дело. Тем не менее, в подобных алгоритмах есть необходимость, поскольку клиенты могут использовать такие свойства для решения задач новыми способами, а реализации могут использовать преимущества подобных ограничений для обеспечения более эффективных решений.
На рис. 4.9 проиллюстрирована работа модифицированного АТД стека без дубликатов для случая, показанного на рис. 4.1; на рис. 4.10 приведен результат аналогичных изменений для очереди FIFO.

Рис. 4.9. Стек магазинного типа без дубликатов
Здесь выполняется та же последовательность операций, что и на рис. 4.1, но для стека, в котором запрещены повторяющиеся объекты. Серыми квадратиками отмечены ситуации, когда стек не изменяется, так как в нем уже имеется заносимый элемент. Количество элементов в стеке ограничено числом возможных отличающихся элементов.

Рис. 4.10. Очередь FIFO без дубликатов, с правилом игнорирования нового элемента
Здесь выполняется та же последовательность операций, что и на рис. 4.6, но для очереди, в которой запрещены повторяющиеся объекты. Серыми квадратиками отмечены ситуации, когда очередь не изменяется, так как в ней уже имеется заносимый элемент.
В общем случае нужно принять решение о том, что делать, когда клиент выдает запрос на занесение элемента, уже имеющегося в структуре данных. Продолжать работу так, как будто запроса вообще не было? Или удалить старый элемент и занести новый? Это решение влияет на порядок, в котором, в конечном счете, будут обрабатываться элементы в АТД наподобие стеков и очередей FIFO (см. рис. 4.11 рис. 4.11); такое различие может оказаться очень существенным для клиентских программ. Например, компания, использующая подобный АТД для списка рассылки, скорее предпочтет замену старого элемента новым (поскольку он, возможно, содержит более свежую информацию о клиенте); а коммутационный центр, использующий такой АТД, наверно, проигнорирует новый элемент (поскольку действия по отправке этого сообщения, видимо, уже выполнены). Более того, выбор конкретного принципа влияет на реализации: как правило, принцип "удалить старый элемент" более труден в реализации, нежели принцип "игнорировать новый элемент", поскольку связан с модификацией структуры данных.

Рис. 4.11.
Очередь FIFO без дубликатов, с правилом удаления старого элемента Здесь показан результат выполнения тех же операций, что и на рис. 4.10. Однако тут используется другой, более сложный дляреа-лизации принцип: новый элемент всегда добавляется в конец очереди. Если в очереди уже имеется такой же элемент, он удаляется.
Для реализации обобщенных очередей без дубликатов необходима абстрактная операция для проверки элементов на равенство (как было сказано в разделе 4.1). Помимо такой операции необходимо иметь возможность определять, существует ли уже в структуре данных новый элемент. Этот общий случай предполагает необходимость реализации АТД таблицы символов, поэтому он рассматривается в контексте реализаций, приведенных в лекциях 12 - 15.
Имеется важный частный случай, для которого существует простое решение, приведенное в программе 4.16 для АТД стека магазинного типа. В этой реализации предполагается, что элементы являются целыми числами в диапазоне от 0 до М - 1. Для определения, имеется ли уже в стеке некоторый элемент, в реализации используется второй массив, индексами которого являются сами элементы стека. Когда в стек заносится элемент i, в i-й элемент второго массива заносится 1, а при удалении из стека элемента i туда заносится 0. Во всем остальном для вставки и удаления элементов применяется тот же код, но перед вставкой элемента выполняется проверка, нет ли в стеке такого элемента. Если есть, операция занесения элемента игнорируется. Это решение не зависит от используемого представления стека - на базе массива, связного списка или чего-нибудь еще. Реализация принципа "удалять старый элемент" требует большего объема работы (см. упражнение 4.57).
Итак, один из способов реализации стека без повторяющихся элементов, функционирующего по принципу "игнорировать новый элемент", состоит в использовании двух структур данных. Первая, как и прежде, содержит элементы стека и позволяет отслеживать порядок, в котором были вставлены элементы стека. Вторая структура является массивом, позволяющим определять, какие элементы находятся в стеке; индексами этого массива являются элементы стека. Такое использование массива является частным случаем реализации таблицы символов, которая будет рассмотрена в разделе 12.2 лекция №12. Если известно, что элементы представляют собой целые числа в диапазоне от 0 до М - 1, эту технику можно применять по отношению к любой обобщенной очереди.
Этот частный случай встречается довольно часто. Его наиболее важный пример - когда элементы структуры данных сами являются индексами массива, поэтому такие элементы называются индексными элементами (index item). Обычно имеется набор из М объектов, хранимых в каком-то другом массиве, и в каком-то более сложном алгоритме необходимо передавать эти объекты через структуру обобщенной очереди. Объекты заносятся в очередь по индексам и обрабатываются при удалении, причем каждый объект должен обрабатываться только один раз. Очередь без дубликатов, в которой используются индексы массива, позволяет непосредственно достичь этой цели.
Каждый из таких вариантов (запрещать или нет повторяющиеся элементы; использовать или нет новый элемент) приводит к новому АТД. Различия могут показаться незначительными, однако они заметно влияют на динамическую характеристику АТД с точки зрения клиентских программ, а также на выбор алгоритма и структуры данных для реализации различных операций. Поэтому приходится считать все эти АТД разными. Бывает необходимо учитывать и другие варианты: например, может понадобиться изменить интерфейс, чтобы сообщать клиентской программе о том, что она пытается вставить дубликат уже имеющегося элемента, либо предоставить клиенту возможность выбора: игнорировать новый элемент или удалять старый.
Программа 4.16. Стек индексных элементов без дубликатов
В этой реализации стека магазинного типа предполагается, что класс Item приводим к типу int, который возвращает целые числа в диапазоне от 0 до maxN-1. Поэтому в нем используется массив t, где каждому элементу стека соответствует отличное от нуля значение. Этот массив дает возможность функции push быстро проверять наличие в стеке ее аргумента, и не выполнять занесение, если он там есть. Для каждого элемента массива t достаточно одного бита, поэтому при желании можно сэкономить память, используя вместо целых чисел символы или биты (см. упр. 12.12).
template <class Item> class STACK { private: Item *s, *t; int N; public: STACK(int maxN) { s = new Item[maxN]; N = 0; t = new Item[maxN]; for (int i = 0; i < maxN; i++) t[i] = 0; } int empty() const { return N == 0; } void push(Item item) { if (t[item] == 1) return; s[N++] = item; t[item] = 1; } Item pop() { t[s[--N]] = 0; return s[N]; } };
Когда неформально используется такой термин, как магазинный стек, очередь FIFO, дек, очередь с приоритетами или таблица символов, в общем случае речь идет о семействе абстрактных типов данных, где каждый тип имеет свой набор операций и набор соглашений о значении этих операций; причем для эффективной поддержки этих операций иногда требуются особые, в некоторых случаях довольно сложные, реализации.
Упражнения
- 4.53. Нарисуйте рисунок, аналогичный рис. 4.9, для стека без дубликатов, работающего по правилу "удалять старый элемент".
- 4.54. Измените стандартную реализацию стека на базе массива из раздела 4.4 (программа 4.7), чтобы в ней были запрещены дубликаты по правилу "игнорировать новый элемент". Используйте простейший метод, выполняющий просмотр всего стека.
- 4.55. Измените стандартную реализацию стека на базе массива из раздела 4.4 (программа 4.7), чтобы в ней были запрещены дубликаты по правилу "удалять старый элемент". Используйте простейший метод, выполняющий просмотр всего стека и перемещение его элементов.
- 4.56. Выполните упражнения 4.54 и 4.55 для реализации стека на базе связного списка из раздела 4.4 (программа 4.8).
- 4.57. Разработайте реализацию стека магазинного типа с запретом дубликатов по правилу "удалять старый элемент". Элементами стека являются целые числа в диапазоне от 0 до М - 1, а операции втолкнуть и вытолкнуть должны иметь постоянное время выполнения. Подсказка: возьмите представление стека на базе двухсвязного списка и храните в массиве индексных значений указатели на узлы этого списка, а не значения 0-1.
- 4.58. Выполните упражнения 4.54 и 4.55 для очереди FIFO.
- 4.59. Выполните упражнение 4.56 для очереди FIFO.
- 4.60. Выполните упражнение 4.57 для очереди FIFO.
- 4.61. Выполните упражнения 4.54 и 4.55 для рандомизированной очереди.
- 4.62. Напишите клиентскую программу для АТД, полученного в упражнении 4.61, в которой используется рандомизированная очередь без повторяющихся элементов.
АТД первого класса
Интерфейсы и реализации АТД стека и очереди в разделах с 4.2 по 4.7 выполняют важную функцию: они скрывают от клиентских программ структуру данных, используемую в реализации. Эти АТД повсеместно встречаются и будут служить в качестве основы для множества других реализаций, рассматриваемых в книге.
Однако если такие типы данных использовать в программах аналогично встроенным типам данных, например, int или float, то возможны неприятности. В данном разделе мы научимся конструировать АТД, с которыми можно работать так же, как и со встроенными типами данных, и все-таки скрывать от клиентов детали реализации.
Определение 4.4. Тип данных первого класса - это тип данных, который можно использовать в программах таким же образом, как и встроенные типы данных.
Например, типы данных первого класса можно использовать в объявлениях переменных, в операторах присваивания, в аргументах и возвращаемых значениях функций. В этом определении, равно как и в других, относящихся к типам данных, нельзя достигнуть точности, не углубившись в сложные вопросы семантики операций. Как будет показано, одно дело требовать, чтобы можно было написать a = b, где a и b - объекты определенного пользователем класса, и совсем другое дело точно определить, что означает эта запись.
В идеале хотелось бы, чтобы все типы данных имели некоторый универсальный набор хорошо определенных операций; в действительности же каждый тип данных характеризуется своим собственным набором операций. Это различие между типами данных само по себе препятствует точному определению типа данных первого класса, поскольку оно предполагает необходимость дать определения для каждой операции, заданной для встроенных типов данных, а это бывает редко. Однако чаще всего важны лишь несколько критических операций, и вполне достаточно применять их для собственных типов данных так же, как и для встроенных типов.
Во многих языках программирования создание типов данных первого класса является делом трудным или даже невозможным; но в языке C++ есть все необходимые базовые инструменты - это концепция класса и возможность перегрузки операций. Язык C++ позволяет легко определять классы, которые являются типами данных первого класса; более того, имеется четкий способ модернизации тех классов, которые таковыми не являются.
Метод, который применяется в языке C++ для реализации типов данных первого класса, применим к любому классу: в частности, он применим к обобщенным очередям и, таким образом, дает возможность создания программ, которые оперируют со стеками и очередями FIFO практически так же, как и с другими типами данных C++. При изучении алгоритмов эта возможность достаточно важна, поскольку она предоставляет естественный способ выражения высокоуровневых операций с такими АТД. Например, можно говорить об операциях объединения двух очередей - т.е. создания из них одной очереди. Далее будут рассматриваться алгоритмы, которые реализуют такие операции для АТД очереди с приоритетами ( лекция №9) и АТД таблицы символов (лекция №12.
Если доступ к типу данных первого класса осуществляется только через интерфейс, то это АТД первого класса (см. определение 4.1). Обеспечение возможности работать с экземплярами АТД примерно так же, как со встроенными типами данных, например, int или float - важная цель многих языков программирования высокого уровня, поскольку это позволяет написать любую программу так, чтобы она могла обрабатывать наиболее важные объекты приложения. Это позволяет большому коллективу программистов одновременно работать над большими системами, используя точно определенный набор абстрактных операций. Кроме того, это позволяет реализовывать абстрактные операции самыми разными способами без какого-либо изменения кода приложения (например, для новых компьютеров или сред программирования).
Для начала в качестве примера рассмотрим АТД первого класса, соответствующий абстракции комплексного числа. Наша цель - получить возможность записывать программы, подобные программе 4.17, которая выполняет алгебраические действия над комплексными числами с помощью операций, определенных в АТД. В данной программе объявляются и инициализируются комплексные числа, а также применяются операции *= и <<. Можно было бы воспользоваться и другими операциями, но в качестве примера достаточно рассмотреть только эти две. На практике часто используется класс complex из библиотеки C++ , в котором перегружены все необходимые операции, включая даже тригонометрические функции.
Работа программы 4.17 основана на некоторых математических свойствах комплексных чисел. Cейчас мы немного отклонимся от основной темы и кратко рассмотрим эти свойства. В некотором смысле это даже и не отклонение, поскольку достаточно интересно рассмотреть связь между комплексными числами как математической абстракцией и их представлением в компьютерной программе.
Число i = V-1 является мнимым числом. Хотя
как вещественное число не имеет смысла, мы называем его i и выполняем над ним алгебраические операции, заменяя каждый раз i2 на - 1. Комплексное число состоит из двух частей, вещественной и мнимой, и записывается в виде a + bi, где а и b - вещественные числа. Для умножения комплексных чисел применяются обычные алгебраические правила, только i2 всякий раз заменяется на -1. Например:
(а + bi)(c + di) = ac + bci + adi + bdi2 = (ac - bd) + (ad + bc)i .
Программа 4.17. Драйвер комплексных чисел (корни из единицы)
Эта клиентская программа выполняет вычисления над комплексными числами с использованием АТД, который позволяет проводить вычисления непосредственно с интересующей нас абстракцией. Для этого объявляются переменные типа Complex, которые затем задействуются в арифметических выражениях с перегруженными операциями. Данная программа проверяет реализацию АТД, вычисляя степени корней из единицы. При помощи соответствующего определения перегруженной операции << (см. упражнение 4.70) выводится таблица, приведенная на рис. 4.12.
#include <iostream.h> #include <stdlib.h> #include <math.h> #include "COMPLEX.cxx" int main(int argc, char *argv[]) { int N = atoi(argv[1]); cout << N << " комплексные корни из единицы" << endl; for (int k = 0; k < N; k++) { float theta = 2.0*3.1415 9*k/N; Complex t(cos(theta), sin(theta)), x = t; cout << k << ": " << t << " "; for (int j = 0; j < N-1; j++) x *= t; cout << x << endl; } }
При умножении комплексных чисел вещественные или мнимые части могут сокращаться (принимать значения 0), например:
(1 - i) (1 - i) = 1 - i - i + i2 = - 2i, (1 + i)4 = 4i2 = -4, (1 + i)8 = 16.
Разделив обе части последнего уравнения на 16 = , получим
Вообще говоря, имеется много комплексных чисел, которые при возведении в степень дают 1. Они называются комплексными корнями из единицы. Действительно, для каждого натурального N имеется ровно N комплексных чисел z, для которых справедливо zN = 1. Нетрудно показать, что этим свойством обладают числа
,
для к = 0, 1, ... , N - 1 (см. упражнение 4.68). Например, если в этой формуле взять к = 1 и N = 8, получим только что найденный корень восьмой степени из единицы.
Программа 4.17 вычисляет все корни N-ой степени из единицы для любого данного N и затем возводит их в N-ю степень с помощью операции *=, определенной в данном АТД. Выходные данные программы показаны на рис. 4.12 рис. 4.12. При этом каждое из этих чисел, возведенное в N-ю степень, должно дать один и тот же результат - 1, или 1 + 0i. Полученные мнимые части не равны строго нулю из-за ограниченной точности вычислений. Наш интерес к этой программе объясняется тем, что класс Complex используется в ней точно так же, как встроенный тип данных. Ниже будет подробно показано, почему это возможно.
Даже в таком простом примере важно, чтобы тип данных был абстрактным, поскольку имеется еще одно стандартное представление, которым можно было бы воспользоваться - полярные координаты (см. упражнение 4.67).

Рис. 4.12. Комплексные корни из единицы
Эта таблица содержит выходные данные программы 4.17 для вызова с параметрами a.out 8, а реализация перегруженной операции << выполняет соответствующее форматирование выходных данных (см. упр. 4.70). Восемь комплексных корней из единицы равны: (два левых столбца). При возведении в восьмую степень все эти восемь чисел дают в результате 1 + 0i (два левых столбца).
Программа 4.18 - это интерфейс, который может использоваться такими клиентами, как программа 4.17; а программа 4.19 - это реализация, в которой используется стандартное представление данных (одно число типа float для вещественной части и одно для мнимой).
Когда в программе 4.17 выполняется операция x = t, где x и t являются объектами класса Complex, система выделяет память для нового объекта и копирует в новый объект значения, относящиеся к объекту t. То же самое происходит и при использовании объекта класса Complex в качестве аргумента или возвращаемого значения функции. А когда объект выходит за пределы области видимости, система освобождает связанную с ним память. Например, в программе 4.17 система освобождает память, выделенную объектам t и x класса Complex, после цикла for так же, как и память, выделенную переменной r типа float. В общем, класс Complex используется подобно встроенным типам данных, т.е. он относится к типам данных первого класса.
Программа 4.18. Интерфейс АТД первого класса для комплексных чисел
Этот интерфейс для комплексных чисел дает возможность реализациям создавать объекты типа Complex (инициализированные двумя значениями типа float), обращаться к вещественной и мнимой частям и использовать операцию *=. Хотя это и не указано явно, стандартные системные механизмы, действующие для всех классов, позволяют использовать объекты класса Complex в операторах присваивания, а также в качестве аргументов и возвращаемых значений функций.
class Complex { private: // Программный код, зависящий от реализации public: Complex(float, float); float Re() const; float Im() const; Complex& operator*=(Complex&); };
Программа 4.19. АТД первого класса для комплексных чисел
Этот код реализует АТД, определенный в программе 4.18. Для представления вещественной и мнимой частей комплексного числа используются числа типа float. Это тип данных первого класса, так как в представлении данных отсутствуют указатели. Когда объект класса Complex применяется либо в операторе присваивания, либо в аргументе функции, либо как возвращаемое значение, система создает его копию, размещая в памяти новый объект и копируя данные - в точности как для встроенных типов данных.
Перегруженная операция << в текущей реализации не форматирует выходные данные (см. упр. 4.70).
#include <iostream.h> class Complex { private: float re, im; public: Complex(float x, float y) { re = x; im = y; } float Re() const { return re; } float Im() const { return im; } Complex& operator*=(const Complex& rhs) { float t = Re(); re = Re()*rhs.Re() - Im()*rhs.Im(); im = t*rhs.Im() + Im()*rhs.Re(); return *this; } }; ostream& operator<<(ostream& t, const Complex& c) { t << c.Re() << " " << c.Im(); return t; }
На самом деле в языке C++ к типам данных первого класса относится любой класс, если ни один из его данных-членов не является указателем. При копировании объекта копируется каждый его член; при присваивании объекту значения перезаписывается каждый его член; когда объект выходит за пределы области видимости, занимаемая им память освобождается. В системе существуют стандартные механизмы для каждой из упомянутых ситуаций: в каждом классе имеются стандартные конструктор копирования, операция присваивания и деструктор.
Однако если некоторые данные-члены являются указателями, то эффект выполнения этих стандартных функций окажется совершенно иным. В операции присваивания стандартный конструктор копирования создает копию указателей, но действительно ли это то, что нам требуется? Это важный вопрос семантики копирования, на который необходимо ответить при проектировании любого АТД. Или, если говорить более широко, при разработке программного обеспечения используются АТД вопрос управления памятью является решающим. Сейчас мы рассмотрим пример, который поможет осветить эти вопросы более детально.
Программа 4.20 является примером клиентской программы, которая оперирует с очередями FIFO как с типами данных первого класса. Она моделирует процесс поступления и обслуживания клиентов в совокупности M очередей. На рис. 4.13 показан пример выходных данных этой программы. С помощью данной программы мы покажем, как механизм типов данных первого класса позволяет работать с таким высокоуровневым объектом, как очередь - подобные программы можно использовать для проверки различных методов организации очереди по обслуживанию клиентов и т.п.
Программа 4.20. Клиентская программа, имитирующая обслуживание с очередями
Данная клиентская программа моделирует ситуацию, при которой клиенты, ожидающие обслуживания, случайным образом помещаются в одну из M очередей обслуживания, затем, опять-таки случайным образом, выбирается очередь (возможно, та же) и, если она не пуста, выполняется обслуживание (клиент удаляется из очереди). После выполнения любой из этих операций выводятся номер добавленного клиента, номер обслуженного клиента и содержимое очередей.
Здесь неявно предполагается, что класс QUEUE принадлежит к типу данных первого класса. Эта программа не будет корректно работать с реализациями, предоставленными в программах 4.14 и 4.15, из-за неправильной семантики копирования во внутреннем цикле for.
Конструктор копирования из реализации АТД очереди в программе 4.22 исправляет этот дефект. Во внутреннем цикле for этой реализации каждый раз создается копия соответствующей очереди q, а деструктор позволяет системе освободить память, занятую копиями.
#include <iostream.h> #include <stdlib.h> #include "QUEUE.cxx" static const int M = 4; int main(int argc, char *argv[]) { int N = atoi(argv[1]); QUEUE<int> queues[M]; for (int i = 0; i < N; i++, cout << endl) { int in = rand() % M, out = rand() % M; queues[in].put(i); cout << i << " поступил "; if (!queues[out].empty()) cout << queues[out].get() << " обслужен"; cout << endl; for (int k = 0; k < M; k++, cout << endl) { QUEUE<int> q = queues[k]; cout << k << ": "; while (!q.empty()) cout << q.get() << " "; } } }
Предположим, что используется рассмотренная ранее реализация очереди на базе связного списка из программы 4.14. Когда выполняется операция p = q, где p и q являются объектами класса QUEUE, система выделяет память для нового объекта и копирует в новый объект значения, относящиеся к объекту q. Но это указатели head и tail - сам связный список не копируется! Если впоследствии связный список, относящийся к объекту p, изменяется, тем самым изменяется и связный список, относящийся к объекту q. Безусловно, программа 4.20 не рассчитана на такое поведение. Если использовать объект класса QUEUE как аргумент функции, процесс будет выглядеть так же. В случае встроенных типов данных мы ожидаем, что внутри функции будет свой собственный объект, который можно использовать по своему усмотрению. Это ожидание означает, что в случае структуры данных с указателями требуется создание копии. Однако система не знает, как это сделать - именно мы должны предоставить необходимый код. То же самое справедливо и для возвращаемых значений функций.

Рис. 4.13. Моделирование неупорядоченной очереди
Здесь приведена заключительная часть выходных данных программы 4.20 для вызова из командной строки с аргументом 8 0. Показано содержимое очередей после указанных операций:случайным образом выбирается очередь, и в нее заносится следующий элемент; затем еще раз выбирается очередь (также случайно) и, если она не пуста, из нее извлекается элемент.
Еще хуже обстоят дела, когда объект класса QUEUE выходит за пределы видимости: система освобождает память, относящуюся к указателям, но не всю память, занимаемую собственно связным списком. Действительно, как только указатели прекращают свое существование, исчезает возможность доступа к данной области памяти. Вот вам пример утечки памяти (memory leak) - серьезной проблемы, всегда требующей особого внимания при написании программы, которая имеет дело с распределением памяти.
В языке C++ существуют специальные механизмы, которые упрощают создание классов, имеющих корректную семантику копирования и предотвращающих утечки памяти. Для этого необходимо написать следующие функции-члены:
- Конструктор копирования - для создания нового объекта, который является копией данного объекта.
- Перегруженную операцию присваивания - чтобы объект мог присутствовать в левой части оператора присваивания.
- Деструктор - для освобождения памяти, выделенной под объект во время его создании.
Когда системе необходимо выполнить указанные операции, она использует эти функции-члены. Если таких функций в классе нет, система обращается к стандартным функциям. Они работают так, как это описано для класса Complex, однако ведут к неверной семантике копирования и утечкам памяти, если какие-нибудь данные-члены являются указателями. Программа 4.21 - интерфейс очереди FIFO, в который включены три перечисленных функции. Подобно конструкторам, они имеют отличительные сигнатуры, в которые входит имя класса.
Когда при создании объекту присваивается начальное значение, либо объект передается в качестве параметра или возвращается из функции, система автоматически вызывает конструктор копирования QUEUE(const QUEUE&). Для выполнения операции = вызывается операция присваивания QUEUE& operator=(const QUEUE&), которая и присваивает значение одной очереди другой. Деструктор ~QUEUE() вызывается в том случае, когда необходимо освободить память, выделенную под какую-либо очередь. Если в программе присутствует объявление без установки начального значения, наподобие QUEUE<int> q;, то для создания объекта q система использует конструктор QUEUE(). Но если объект при объявлении инициализируется, наподобие QUEUE<int> q = p (или эквивалентной формы QUEUE<int> q(p)), система использует конструктор копирования QUEUE(const QUEUE&). Эта функция должна создавать новую копию объекта p, а не просто еще один указатель на него. Как обычно для ссылочных параметров, ключевое слово const означает намерение не изменять объект p, а использовать его только для доступа к информации.
Чтобы пользовательский класс, данные-члены которого могут содержать указатели, больше походил на встроенный тип данных, в его интерфейс необходимо включить конструктор копирования, перегруженную операцию присваивания и деструктор, как это сделано в данном расширении простого интерфейса очереди FIFO из программы 4.13.
template <class Item> class QUEUE { private: // Код, зависящий от реализации public: QUEUE(int); QUEUE(const QUEUE&); QUEUE& operator=(const QUEUE&); ~QUEUE(); int empty() const; void put(Item); Item get(); };
Программа 4.22 содержит реализацию конструктора копирования, перегруженной операции присваивания и деструктора для реализации очереди на базе связного списка из программы 4.14. Деструктор проходит по всей очереди и с помощью операции delete освобождает память, выделенную под каждый узел. Если клиентская программа присваивает значения некоторого объекта самому себе, то операция присваивания ничего не делает; иначе вызывается деструктор, а затем с помощью put копируется каждый элемент очереди, стоящей справа от знака операции. Конструктор копирования очищает очередь и затем с помощью операции присваивания выполняет копирование.
За счет помещения в класс конструктора копирования, перегруженной операции присваивания и деструктора (как в программе 4.22) любой класс языка C++ можно превратить в АТД первого класса. Такие функции обычно действуют по принципу простого обхода структур данных. Однако эти дополнительные шаги предпринимаются не всегда, поскольку
- часто используется только один экземпляр объекта определенного класса;
- даже при наличии нескольких экземпляров может оказаться необходимым запрет непреднамеренного копирования огромных структур данных.
В общем, теперь мы знаем о возможности создания типов данных первого класса, но мы знаем и о необходимости компромисса между качеством и ценой, которую приходится платить за его реализацию и использование, в особенности когда дело касается большого объема данных.
Приведенные рассуждения также объясняют, почему библиотека языка C++ включает класс string, несмотря на привлекательность низкоуровневой структуры данных типа строки в стиле языка C. C-строки не принадлежат к типам данных первого класса, поскольку по сути это просто указатели. И многие программисты впервые сталкиваются с некорректной семантикой копирования именно при копировании указателя на C-строку, ожидая, что будет скопирована сама строка. В противоположность этому, класс string языка C++ является типом данных первого класса, поэтому при работе с очень длинными строками следует соблюдать особую осторожность.
Программа 4.22. Реализация очереди первого класса на базе связного списка
Можно модернизировать реализацию класса очереди FIFO из программы 4.14, чтобы превратить этот тип данных в "первоклассный". Для этого в класс потребуется добавить приведенные ниже реализации конструктора копирования, перегруженной операции присваивания и деструктора. Эти функции перекрывают функции, используемые по умолчанию, и вызываются, когда необходимо копировать или уничтожать объекты.
Деструктор ~QUEUE() вызывает приватную функцию-член deletelist, которая проходит по всему связному списку, вызывая для каждого узла функцию delete. Таким образом, при освобождении памяти, связанной с указателями, освобождается и вся память, занимаемая объектом.
Если перегруженная операция присваивания вызывается для присваивания объекта самому себе (например, q = q), никакие действия не выполняются. Иначе вызывается функция deletelist, чтобы очистить память, связанную со старой копией объекта (в результате получается пустой список). Затем для каждого элемента списка из правой части операции присваивания вызывается функция put, и таким образом создается копия этого списка. В обоих случаях возвращаемое значение представляет собой ссылку на объект, которому присвоено значение другого объекта.
Конструктор копирования QUEUE(const QUEUE&) очищает список, а затем с помощью перегруженной операции присваивания создает копию аргумента.
private: void deletelist() { for (link t = head; t != 0; head = t) { t = head->next; delete head; } } public: QUEUE(const QUEUE& rhs) { head = 0; *this = rhs; } QUEUE& operator=(const QUEUE& rhs) { if (this == &rhs) return *this; deletelist(); link t = rhs.head; while (t != 0) { put(t->item); t = t->next; } return *this; } ~QUEUE() { deletelist(); }
В качестве другого примера можно изменить программу 4.20 таким образом, чтобы она периодически выводила лишь несколько первых элементов каждой очереди, и можно было бы отслеживать изменения в очередях даже тогда, когда они становятся очень большими. Однако при наличии очень больших очередей производительность программы существенно снижается, поскольку при инициализации локальной переменной в цикле for вызывается конструктор копирования, который создает копию всей очереди, даже если нужно лишь несколько элементов. А в конце каждой итерации цикла память, занимаемая очередью, освобождается деструктором, т.к. она связана с локальной переменной. Для программы 4.20 в ее нынешнем виде, когда выполняется доступ к каждому элементу копии, дополнительные затраты на выделение и освобождение памяти увеличивают время выполнения только на некоторый постоянный коэффициент. Но если требуется доступ лишь к нескольким элементам очереди, такие затраты неприемлемы. В такой ситуации лучше использовать стандартную реализацию операции копировать, которая копирует только указатели, и добавить в этот АТД операции, обеспечивающих доступ к элементам очереди без ее модификации.
Утечка памяти - это трудно выявляемый дефект, который поражает многие большие системы. Хотя освобождение памяти, занятой некоторым объектом, обычно является в принципе несложным делом, на практике очень тяжело следить за всеми выделенными областями памяти. Механизм деструкторов в языке C++ помогает, однако система не может гарантировать, что обход структур данных выполняется так, как задумано. Когда объект прекращает свое существование, его указатели теряются безвозвратно, и любой указатель, не обработанный деструктором, является потенциальным источником утечки памяти. Один из типичных источников - когда в классе вообще забывают определить деструктор - конечно же, стандартный деструктор вряд ли корректно очистит память. Особое внимание этому вопросу необходимо уделить при использовании абстрактных классов, наподобие класса из программы 4.12.
В ряде систем существует автоматическое распределение памяти - в таких случаях система самостоятельно определяет, какая область памяти больше не используется программами, после чего освобождает ее. Ни одно из этих решений не является полностью приемлемым. Одни полагают, что распределение памяти - слишком важное дело, чтобы поручать его системе; другие же считают, что это слишком важное дело, чтобы его можно было поручать программистам.
Список вопросов, которые могут возникнуть при рассмотрении реализаций АТД, будет очень длинным даже в случае таких простых АТД, как те, которые рассматриваются в настоящей лекции. Нужна ли возможность хранения в одной очереди объектов разных типов? Требуется ли нам в одной клиентской программе использовать разные реализации для очередей одного и того же типа, если известны различия в их производительности? Следует ли включать в интерфейс информацию об эффективности реализаций? Какую форму должна иметь эта информация? Подобные вопросы показывают, насколько важно уметь разбираться в основных характеристиках алгоритмов и структур данных и понимать, каким образом клиентские программы могут эффективно их использовать. В некотором смысле именно это и является темой данной книги. Хотя полные реализации часто представляют собой упражнения по технике программирования, а не по проектированию алгоритмов, мы все же стараемся не забывать об этих существенных вопросах, чтобы разработанные алгоритмы и структуры данных могли послужить фундаментом для создания инструментальных программных средств в самых разнообразных приложениях (см. раздел ссылок).
Упражнения
- 4.63. Перегрузите операции + и += для работы с комплексными числами (программы 4.18 и 4.19).
- 4.64. Преобразуйте АТД отношения эквивалентности (из раздела 4.5) в тип данных первого класса.
- 4.65. Создайте АТД первого класса для использования в программах, оперирующих с игральными картами.
-
4.66. Используя АТД из упражнения 4.65, напишите программу, которая опытным
путем определяет вероятность раздачи различных наборов карт при игре в покер.
-
4.67. Разработайте реализацию для АТД комплексных чисел на базе представления комплексных чисел в полярных координатах (т.е. в формате
).
-
4.68. Воспользуйтесь тождеством
для доказательства того, что
, а N комплексных корней N-ой степени из единицы равны
для к = 0, 1, ... , N - 1.
- 4.69. Перечислите корни N-ой степени из единицы для значений N от 2 до 8.
- 4.70. Используя операции precision и setw из файла iostream.h, создайте реализацию перегруженной операции << для программы 4.19, которая выдаст выходные данные, приведенные на рис. 4.12 рис. 4.12 для программы 4.17.
- 4.71. Опишите точно, что произойдет в результате запуска программы 4.20 моделирования обслуживания с очередями, если использовать простую реализацию вроде программы 4.14 или 4.15.
- 4.72. Разработайте реализацию описанного в тексте АТД первого класса для очереди FIFO (программа 4.21), в которой в качестве базовой структуры данных используется массив.
- 4.73. Напишите интерфейс АТД первого класса для стека.
- 4.74. Разработайте реализацию АТД первого класса для стека магазинного типа из упражнения 4.73, в которой в качестве базовой структуры данных используется массив.
- 4.75. Разработайте реализацию АТД первого класса для стека магазинного типа из упражнения 4.73, в которой в качестве базовой структуры данных используется связный список.
- 4.76. Используя приведенные ранее АТД первого класса для комплексных чисел (программы 4.18 и 4.19), измените программу вычисления постфиксных выражений из раздела 4.3 таким образом, чтобы она вычисляла постфиксные выражения, состоящие из комплексных чисел с целыми коэффициентами. Для простоты считайте, что все комплексные числа имеют ненулевые целые коэффициенты как для вещественной, так и для мнимой частей, и записываются без пробелов. Например, для исходного выражения
1+2i0+1i+1-2i*3+4i+
программа должна вывести результат 8+4i.
- 4.77. Выполните математический анализ процесса моделирования обслуживания с очередями в программе 4.20, чтобы определить (как функцию аргументов N и M) вероятность того, что очередь, выбранная для N-ой операции get, будет пустой, а также ожидаемое количество элементов в этих очередях после N итераций цикла for.
Пример использования АТД
В качестве заключительного примера рассмотрим специализированный АТД, который демонстрирует связь между прикладными областями и алгоритмами и структурами данных, которые обсуждаются в настоящей книге. Этот АТД полинома взят из области символьной математики, в которой компьютеры помогают оперировать с абстрактными математическими объектами. Цель заключается в том, чтобы получить возможность писать программы, которые могут оперировать с полиномами и выполнять вычисления наподобие
Кроме того, необходима возможность вычислять полиномы для заданного значения х. Для x = 0,5 обе стороны приведенного выше уравнения имеют значение 1,1328125. Операции умножения, сложения и вычисления полиномов играют наиболее важную роль во множестве математических вычислений. Программа 4.23 - это простой пример, в котором выполняются символьные операции, соответствующие полиномиальным равенствам
(х + 1)2 = x2 + 2х + 1, (х + 1)3 = x3 + 3x2 + 3х + 1, (х + 1)4 = x4 + 4x3 + 6x2 + 4х + 1, (х + 1)5 = x5 + 5x4 + 10x3 + 10x2 + 5х + 1, ... .
Эти основные идеи можно развить дальше и включить такие операции, как сложение, интегрирование, дифференцирование, сведения о специальных функциях и т.п.
Вначале необходимо определить АТД полинома так, как показано в интерфейсе из программы 4.24. Для такой хорошо понятной математической абстракции, как полином, спецификация настолько ясна, что ее не стоит выражать словесно (как и в случае АТД комплексных чисел, который был рассмотрен в разделе 4.8): необходимо, чтобы экземпляры АТД вели себя в точности так же, как и эта хорошо понятная математическая абстракция.
Программа 4.23. Клиентская программа для АТД полинома (биномиальные коэффициенты)
Эта клиентская программа использует АТД полинома, определенный в интерфейсе программы 4.24, для выполнения алгебраических операций над полиномами с целыми коэффициентами. Она принимает из командной строки целое число N и число с плавающей точкой р, вычисляет (х + 1)N и проверяет результат, вычисляя значение результирующего полинома для х = р.
#include <iostream.h> #include <stdlib.h> #include "POLY.cxx" int main(int argc, char *argv[]) { int N = atoi(argv[1]); float p = atof(argv[2]); cout << "Биномиальные коэффициенты" << endl; POLY<int> x(1,1), one(1,0), t = x + one, y = t; for (int i = 0; i < N; i++) { y = y*t; cout << y << endl; } cout << y.eval(p) << endl; }
Программа 4.24. Интерфейс АТД полинома
Чтобы можно было задавать коэффициенты различных типов, в этом интерфейсе АТД полинома используется шаблон. Здесь перегружены бинарные операции + и *, поэтому клиентские программы могут использовать полиномы в арифметических выражениях. Конструктор, вызванный с аргументами c и N, создает полином, соответствующий выражению cxN.
template <class Number> class POLY { private: // Программный код, зависящий от реализации public: POLY<Number>(Number, int); float eval(float) const; friend POLY operator+(POLY &, POLY &); friend POLY operator*(POLY &, POLY &); };
Для реализации функций, определенных в этом интерфейсе, потребуется выбрать конкретную структуру данных для представления полиномов и затем реализовать соответствующие алгоритмы для работы с этой структурой данных, чтобы все функционировало в соответствии с ожиданиями клиентской программы. Как обычно, выбор структуры данных влияет на эффективность алгоритмов, так что стоит обдумать несколько вариантов. Как и для стеков и очередей, можно воспользоваться представлением на базе связного списка либо представлением на базе массива. Программа 4.25 - это реализация, в которой используется представление на базе массива; реализация на базе связного списка оставлена на самостоятельную проработку (см. упражнение 4.78).
Для сложения (add) двух полиномов складываются их коэффициенты. Если полиномы представлены в виде массивов, то для их сложения, как показано в программе 4.25, достаточно одного цикла по этим массивам. Для умножения (multiply) двух полиномов применяется элементарный алгоритм, основанный на свойстве дистрибутивности. Один полином умножается на каждый член другого, результаты выравниваются так, чтобы степени х соответствовали друг другу, и затем складываются для получения окончательного результата. В следующей таблице кратко показывается этот вычислительный процесс для (1 - х + x2/ 2 - x3/ 6 ) (1 + х + x2 + x3) :

Очевидно, что время, необходимое для умножения таким способом двух полиномов, пропорционально N2. Отыскать более быстрый алгоритм решения данной задачи весьма непросто. Эта тема рассматривается более подробно в части VIII, где будет показано, что время, необходимое для решения такой задачи с помощью алгоритма "разделяй-и-властвуй", пропорционально N3/2, а время, необходимое для ее решения с помощью быстрого преобразования Фурье, пропорционально N lgN.
В реализации функции evaluate (вычислить) из программы 4.25 используется эффективный классический алгоритм, известный как алгоритм Горнера (Horner). Cамая простая реализация этой функции заключается в непосредственном вычислении выражения с использованием функции, вычисляющей х N. При таком подходе требуется время с квадратичной зависимостью от N. В более сложной реализации значения xi запоминаются в таблице и затем используются при непосредственных вычислениях. При таком подходе требуется дополнительный объем памяти, линейно зависящий от N.
Программа 4.25. Реализация АТД полинома на базе массива
В этой реализации АТД для полиномов данные представлены степенью полинома и указателем на массив коэффициентов. Это не АТД первого класса: авторы клиентских программ должны знать о возможности утечек памяти и о выполнении копирования лишь указателей (см. упр. 4.79).
template <class Number> class POLY { private: int n; Number *a; public: POLY<Number>(Number c, int N) { a = new Number[N+1]; n = N+1; a[N] = c; for (int i = 0; i < N; i++) a[i] = 0; } float eval(float x) const { double t = 0.0; for (int i = n-1; i >= 0; i - ) t = t*x + a[i]; return t; } friend POLY operator+(POLY &p, POLY &q) { POLY t(0, p.n>q.n ? p.n-1 : q.n-1); for (int i = 0; i < p.n; i++) t.a[i] += p.a[i]; for (int j = 0; j < q.n; j++) t.a[j] += q.a[j]; return t; } friend POLY operator*(POLY &p, POLY &q) { POLY t(0, (p.n-1)+(q.n-1)); for (int i = 0; i < p.n; i++) for (int j = 0; j < q.n; j++) t.a[i+j] += p.a[i]*q.a[j]; return t; } };
Алгоритм Горнера - это прямой оптимальный линейный алгоритм, основанный на следующем использовании круглых скобок:
.
Алгоритм Горнера часто представляют как ловкий прием, экономящий время, но в действительности это первый выдающийся пример элегантного и эффективного алгоритма, который сокращает время, необходимое для выполнения этой важной вычислительной задачи, с квадратичного до линейного. Разновидностью алгоритма Горнера является преобразование строк с ASCII-символами в целые числа, выполняемое в программе 4.5. Мы еще встретимся с алгоритмом Горнера в лекция №14, где на нем основаны важные вычисления, связанные с некоторыми реализациями таблиц символов и поиска строк.
В ходе выполнения перегруженных операций + и * создаются новые полиномы, поэтому данная реализация является источником утечки памяти. Ее нетрудно ликвидировать, добавив в реализацию конструктор копирования, перегруженную операцию присваивания и деструктор. Так следовало бы сделать в случае полиномов очень больших размеров, обработки огромного количества небольших полиномов, а также создания АТД для использования в каком-нибудь приложении (см. упражнение 4.79).
Как обычно, применение в реализации АТД полинома представления на базе массива - это лишь одна из возможностей. Если показатели степени очень большие, а членов в полиномах немного, то более подходящим будет представление на базе связного списка. Например, не стоит применять программу 4.25 для выполнения такого умножения:
(1 + х1000000)(1 + х2000000) = 1+ х1000000 + х2000000 + хЗОООООО
поскольку в ней будет использоваться память под массив с миллионами неиспользуемых коэффициентов. Более подробно вариант со связным списком рассматривается в упражнении 4.78.
Упражнения
- 4.78. Напишите реализацию для приведенного в тексте АТД полинома (программа 4.24), в которой в качестве базовой структуры данных используются связные списки. Списки не должны содержать узлов, соответствующих членам с нулевыми коэффициентами.
- 4.79. Устраните утечку памяти в программе 4.25, добавив в нее конструктор копирования, перегруженную операцию присваивания и деструктор.
- 4.80. Добавьте перегруженные операции += и *= в АТД полинома из программы 4.25.
- 4.81. Расширьте приведенный в лекции АТД полинома, включив в него операции интегрирования и дифференцирования полиномов.
- 4.82. Измените полученный АТД полинома из упражнения 4.81 так, чтобы в нем игнорировались все члены со степенями, большими или равными целому числу M, которое передается из клиента во время инициализации.
- 4.83. Расширьте АТД полинома из упражнения 4.81 так, чтобы он включал деление и суперпозицию полиномов.
- 4.84. Разработайте АТД, который позволяет клиентским программам выполнять сложение и умножение целых чисел произвольной точности.
- 4.85. Используя АТД, разработанный в упражнении 4.84, измените программу вычисления постфиксных выражений из раздела 4.3 так, чтобы она могла вычислять постфиксные выражения, содержащие целые числа произвольной точности.
- 4.86. Напишите клиентскую программу, которая с помощью АТД полинома из упражнения 4.83 вычисляет интегралы, используя разложение функций в ряды Тейлора и оперируя с ними в символьной форме.
- 4.87. Разработайте АТД, который позволяет клиентским программам выполнять алгебраические операции с векторами чисел с плавающей точкой.
- 4.88. Разработайте АТД, который позволяет клиентским программам выполнять алгебраические операции с матрицами абстрактных объектов, для которых определены операции сложения, вычитания, умножения и деления.
- 4.89. Напишите интерфейс для АТД символьных строк, который включает операции создания строк, сравнения двух строк, конкатенации двух строк, копирования одной строки в другую и получения длины строки. Примечание: полученный интерфейс должен быть похож на интерфейс, доступный в стандартной библиотеке C++.
- 4.90. Напишите реализацию для интерфейса из упражнения 4.89, используя там, где это необходимо, библиотеку обработки строк C++.
- 4.91. Напишите реализацию для полученного в упражнении 4.89 интерфейса строки, используя представление на базе связного списка. Проанализируйте время выполнения каждой операции для наихудших случаев.
- 4.92. Напишите интерфейс и реализацию АТД индексного множества, в котором обрабатываются множества целых чисел в диапазоне от 0 до M - 1 (где M - заданная константа) и имеются операции создания множества, объединения двух множеств, пересечения двух множеств, дополнения множества, разности двух множеств и вывода содержимого множества. Для представления каждого множества используйте массив из M - 1 элементов, принимающих значения 0-1.
- 4.93. Напишите клиентскую программу, которая тестирует АТД, созданный в упражнении 4.92.
Перспективы
Приступая к изучению алгоритмов и структур данных, необходимо хорошо разобраться в фундаментальных понятиях, лежащих в основе АТД. На это есть три главных причины:
- АТД являются важными и повсеместно используемыми инструментами разработки программного обеспечения, и многие из изучаемых алгоритмов служат реализациями широко применяемых фундаментальных АТД.
- АТД помогают инкапсулировать разрабатываемые алгоритмы, чтобы один и тот же код программы мог служить нам для самых разных целей.
- АТД предоставляют собой удобный механизм, который используется в процессе разработки алгоритмов и сравнения характеристик, связанных с их производительностью.
АТД должны воплощать простой (и здравый) принцип: необходимо точно описывать способы обработки данных. Для этого в языке C++ имеется удобный механизм "кли-ент-интерфейс-реализация", который был подробно рассмотрен в настоящей лекции; он позволяет получать на языке C++ код, обладающий рядом нужных свойств. Во многих современных языках имеются специальные средства поддержки, позволяющие разрабатывать программы с подобными свойствами, однако существует независимый от разных языков подход - при отсутствии специальной языковой поддержки устанавливаются определенные правила программирования, обеспечивающие требуемое разделение на клиенты, интерфейсы и реализации.
По мере рассмотрения постоянно растущего круга возможностей для описания характеристик абстрактных типов данных мы сталкиваемся с непрерывно расширяющимся кругом проблем, связанных с созданием эффективных реализаций. Многочисленные рассмотренные ранее примеры показывают пути преодоления этих трудностей. Мы всегда стараемся эффективно реализовать все операции, но вряд ли достигнем этой цели для всех наборов операций в реализации общего назначения. Эта ситуация противоречит тем принципам, которые являются основными причинами создания абстрактных типов данных, так как во многих случаях разработчикам АТД необходимо знать свойства клиентских программ, чтобы определять, какие реализации АТД будут функционировать наиболее эффективно. А разработчикам клиентских программ нужна информация о характеристиках производительности различных реализаций, чтобы осмысленно решить, какой реализации отдавать предпочтение в том или ином приложении. Как всегда, необходимо достичь некоторого баланса. В настоящей книге рассматриваются многочисленные подходы к реализации различных вариантов фундаментальных АТД, каждый из которых имеет важное применение.
Один АТД можно создавать на базе другого. Абстракции, подобные указателям и структурам, определенным в языке C++, использовались для построения связных списков. Далее абстракции связных списков и массивов, доступных в C++, применялись при построении стеков магазинного типа. А стеки магазинного типа использовались для организации вычислений арифметических выражений. Понятие АТД позволяет создавать большие системы на основе разных уровней абстракции, от машинных инструкций до разнообразных возможностей языка программирования, вплоть до сортировки, поиска и других возможностей высокого уровня, которые обеспечиваются алгоритмами, рассматриваемыми в частях III и IV Некоторые приложения требуют еще более высокого уровня абстракции, о которых речь идет в частях V - VIII. Абстрактные типы данных - лишь один этап в бесконечном процессе создания все более и более мощных абстрактных механизмов, в чем и заключается суть эффективного использования компьютеров для решения современных задач.
Лекция 5. Рекурсия и деревья
Рекурсия - фундаментальное понятие в математике и компьютерных науках. В языках программирования рекурсивной программой называют программу, которая обращается к самой себе (подобно тому, как в математике рекурсивной функцией называют функцию, которая определена через себя же). Рекурсивная программа не может вызывать себя до бесконечности, поскольку в этом случае она никогда не завершится (точно так же рекурсивная функция не может всегда определяться через себя, поскольку тогда определение стало бы циклическим). Последовательно, вторая важная особенность рекурсивной программы - наличие условия завершения, позволяющего программе прекратить вызывать себя (а в математике это условие, при выполнении которого функция перестает определяться через себя). Все практические вычисления можно представить рекурсивными процессами.
Изучение рекурсии неразрывно связно с изучением рекурсивно определяемых структур, называемых деревьями. Деревья используются как для упрощения понимания и анализа рекурсивных программ, так и в качестве явных структур данных. В лекция №1 мы уже встречались с применением деревьев (хотя и не рекурсивным). Взаимосвязь между рекурсивными программами и деревьями лежит в основе значительной части материала книги. Деревья используются для упрощения понимания рекурсивных программ, рекурсивные программы используются для построения деревьев, а фундаментальная взаимосвязь между ними (и рекуррентные отношения) позволяет выполнять анализ алгоритмов. Рекурсия помогает разрабатывать изящные и эффективные структуры данных и алгоритмы для самых разнообразных применений.
Основная цель этой главы заключается в изучении рекурсивных программ и структур данных как практических инструментов. Вначале мы обсудим взаимосвязь между математической рекурсией и простыми рекурсивными программами, а также рассмотрим ряд примеров применения рекурсии. Затем мы познакомимся с фундаментальной рекурсивной схемой, известной под названием "разделяй и властвуй ", которая используется для решения задач общего характера в нескольких последующих разделах этой книги. Потом будет рассмотрен общий подход к реализации рекурсивных программ, называемый динамическим программированием, который предоставляет эффективные и элегантные решения для обширного класса задач. Затем мы подробно изучим деревья, их математические свойства и связанные с ними алгоритмы, в том числе базовые методы обхода дерева, которые лежат в основе рекурсивных программ обработки деревьев. И в завершение мы рассмотрим тесно связанные с рекурсией алгоритмы обработки графов - в частности, особое внимание будет уделено фундаментальной рекурсивной программе поиска в глубину, которая служит основой для многих алгоритмов обработки графов.
Как будет показано далее, многие интересные алгоритмы легко реализуются через рекурсивные программы, а многие разработчики алгоритмов предпочитают выражать методы рекурсивно. Однако мы будем подробно рассматривать и нерекурсивные методы реализации. Часто можно не только создать простые алгоритмы с использованием стеков, которые по сути эквивалентны рекурсивным, но и найти нерекурсивные альтернативы, которые приводят к такому же конечному результату через иную последовательность вычислений. Рекурсивная формулировка задачи предоставляет структуру, в рамках которой можно найти и более эффективные альтернативы.
Исчерпывающее исследование рекурсии и деревьев могло бы послужить темой отдельной книги, поскольку эти концепции находят применение во множестве компьютерных приложений, а также выходят далеко за рамки компьютерных наук. Фактически можно было бы сказать, что вся эта книга посвящена освещению рекурсии и деревьев, поскольку с фундаментальной точки зрения данные темы присутствуют в каждой главе.
Рекурсивные алгоритмы
Рекурсивный алгоритм - это алгоритм, решающий задачу путем решения одного или нескольких меньших вариантов той же задачи. Для реализации рекурсивных алгоритмов в C++ используются рекурсивные функции - функции, которые вызывают сами себя. Рекурсивные функции в C++ соответствуют рекурсивным определениям математических функций. Изучение рекурсии мы начнем с исследования программ, которые непосредственно вычисляют математические функции. Как мы увидим, базовые механизмы можно расширить, что приведет к обобщенной парадигме программирования.
Рекуррентные отношения (см. лекция №2) являются рекурсивно определенными функциями. Рекуррентное отношение задает функцию, областью допустимых значений которой являются неотрицательные числа, определяемую либо некоторыми начальными значениями, либо (рекурсивно) множеством ее собственных, но меньших, значений. Наверно, наиболее известной из таких функций является функция вычисления факториала, которая определяется рекуррентным отношением
N!=N\cdot(N -1)!, для }N\geq1, причём 0!=1
Это определение непосредственно соответствует рекурсивной функции C++ в программе 5.1.
Программа 5.1 эквивалентна простому циклу. Например, такое же вычисление выполняет следующий цикл for:
for ( t = 1, i = 1; i <= N; i++) t *= i;
Программа 5.1. Функция вычисления факториала (рекурсивная реализация)
Эта рекурсивная функция вычисляет функцию N!, используя стандартное рекурсивное определение. Она возвращает правильное значение, когда вызывается с неотрицательным и достаточно малым аргументом N, чтобы N! можно было представить типом int.
int factorial(int N) { if (N == 0) return 1; return N*factorial(N -1); }
Как будет показано, рекурсивную программу всегда можно преобразовать в нерекурсивную, которая выполняет такое же вычисление. И наоборот, рекурсия позволяет реализовать любое вычисление с циклами, не прибегая к циклам.
Рекурсия используется потому, что зачастую она позволяет выразить сложные алгоритмы в компактной форме без ущерба для эффективности. Например, рекурсивная реализация функции вычисления факториала избавляет от необходимости использования локальных переменных. В системах программирования, поддерживающих обращения к функциям, затраты на рекурсивную реализацию определяются механизмами, которые используют эквивалент встроенного стека. Большинство современных систем программирования имеют тщательно разработанные механизмы для выполнения такой задачи. Но, как мы сейчас увидим, все -таки можно запросто написать рекурсивную функцию, которая окажется весьма неэффективной, и поэтому необходимо соблюдать осторожность, чтобы не пришлось возиться с плохо поддающимися исправлениям реализациями.
Программа 5.1 иллюстрирует базовые особенности рекурсивной программы: она вызывает саму себя (с меньшим значением аргумента) и содержит условие завершения, при выполнении которого непосредственно вычисляет результат. Чтобы убедиться в правильности работы программы, можно применить метод математической индукции:
- Программа вычисляет 0! (база индукции)
- Если допустить, что программа вычисляет к! для к < N (индуктивный переход), то она вычисляет и N! .
Подобные рассуждения помогают быстро разрабатывать алгоритмы для решения сложных задач.
В языках программирования, подобных C++, существует очень мало ограничений для видов создаваемых программ, но мы постараемся ограничиваться использованием только таких рекурсивных функций, которые позволяют проверять правильность своей работы методом математической индукции, как было показано выше. В этой книге не рассматриваются формальные подтверждения правильности, однако нам потребуется объединять сложные программы для выполнения сложных задач, и поэтому нужна определенная уверенность в правильности решения задач. Механизмы наподобие рекурсивных функций могут дать такую уверенность, в то же время обеспечивая компактные реализации. Если следовать правилам математической индукции, необходимо удостовериться, что создаваемые рекурсивные функции удовлетворяют двум основным условиям:
- Они должны явно решать задачу для базового случая.
- В каждом рекурсивном вызове должны использоваться меньшие значения аргументов.
Эти утверждения являются спорными - ведь они равнозначны тому, что мы должны располагать допустимой индуктивной проверкой для каждой создаваемой рекурсивной функции. Тем не менее, они служат полезным наставлением при разработке реализаций.
Программа 5.2 - занятный пример, иллюстрирующий необходимость наличия индуктивного аргумента. Она представляет собой рекурсивную функцию, нарушающую правило, в соответствии с которым каждый рекурсивный вызов должен использовать меньшие значения аргументов, и поэтому для ее проверки нельзя использовать метод математической индукции. Действительно, неизвестно, завершается ли это вычисление для каждого значения N, поскольку значение N не имеет никаких пределов. Для меньших целочисленных значений, которые могут быть представлены значениями типа int, можно проверить, что программа прерывается (см. рис. 5.1 и упражнение 5.4), но для больших целочисленных значений (скажем, для 64 -разрядных слов), неизвестно, уходит ли эта программа в бесконечный цикл.
Программа 5.2. Сомнительная рекурсивная программа
Если аргумент N является нечетным, эта функция вызывает саму себя с аргументом, равным 3N+1. Если N является четным, она вызывает себя с аргументом, равным N/2. Невозможно доказать по индукции, что программа гарантированно завершится, поскольку не каждый рекурсивный вызов использует аргумент, меньший предыдущего.
int puzzle(int N) { if (N == 1) return 1; if (N % 2 == 0) return puzzle(N/2); else return puzzle(3*N+1); }
Программа 5.3 - компактная реализация алгоритма Евклида для отыскания наибольшего общего делителя для двух целых чисел. Алгоритм основывается на наблюдении, что наибольший общий делитель двух целых чисел х и у (х > у) совпадает с наибольшим общим делителем чисел у и х mod у (остатка от деления х на у). Число t делит и х и у тогда, и только тогда, когда оно делит и у, и х mod у (х по модулю у), поскольку х равно х mod у плюс число, кратное у. Рекурсивные вызовы, выполненные в примере выполнения этой программы, показаны на рис. 5.2. Для алгоритма Евклида глубина рекурсии зависит от арифметических свойств аргументов (она связана с ними логарифмической зависимостью).

Рис. 5.1. Пример цепочки рекурсивных вызовов
Эта вложенная последовательность вызовов функции в конце концов завершается, однако нельзя гарантировать, что рекурсивная функция, используемая в программе 5.2, не будет иметь достаточную глубину вложенности для какого-либо аргумента. Мы будем использовать рекурсивные программы, которые всегда вызывают себя с меньшими значениями аргументов.

Рис. 5.2. Пример применения алгоритма Эвклида
Эта вложенная последовательность вызовов функции иллюстрирует работу алгоритма Эвклида, показывая, что числа 314159 и 271828 являются взаимно простыми.
Программа 5.3. Алгоритм Евклида
Этот один из наиболее древних алгоритмов, разработанный свыше 2000 лет назад - рекурсивный метод отыскания наибольшего общего делителя двух целых чисел.
int gcd(int m, int n) { if (n == 0) return m; return gcd(n, m % n); }
Программа 5.4 - пример с несколькими рекурсивными вызовами. Это еще один способ вычисления выражения, который работает примерно так же, как и программа 4.2, но с префиксными (а не постфиксными) выражениями и с заменой явного стека рекурсией. В этой главе будут встречаться множество других примеров использования рекурсивных программ и эквивалентных программ, в которых задействуются стеки. И мы подробно изучим взаимосвязь между несколькими парами таких программ.
Пример обработки префиксного выражения программой 5.4 показан на рис. 5.3. Многочисленные рекурсивные вызовы скрывают сложную последовательность вычислений. Подобно большинству рекурсивных программ, работу этой программы проще всего понять с помощью индукции: полагая, что она правильно обрабатывает простые выражения, можно убедиться, что это справедливо и для сложных выражений. Программа представляет собой простой пример рекурсивного нисходящего анализатора - аналогичный процесс используется для преобразования программ C++ в машинные коды.
Конечно, строгое индуктивное доказательство правильности вычисления выражения программой 5.4 - гораздо более сложная задача, нежели доказательства для рассмотренных выше функций с целочисленными аргументами, и в этой книге встретятся еще более сложные рекурсивные программы и структуры данных. Поэтому мы не ставим перед собой идеалистичной задачи предоставления полных индуктивных доказательств правильности каждой создаваемой рекурсивной программы. В данном случае "знание " программы, как следует разделять операнды в соответствии с заданной операцией, вначале кажется непостижимым (возможно, потому, что не сразу понятно, как выполнить это разделение на верхнем уровне). Однако в действительности вычисление достаточно понятно (поскольку нужная ветвь программы при каждом вызове функции однозначно определяется первым символом выражения).
Программа 5.4. Рекурсивная программа для вычисления префиксных выражений
Для вычисления префиксного выражения либо осуществляется преобразование числа из ASCII в двоичную форму (в цикле while в конце программы), либо выполняется операция, указанная первым символом выражения, с двумя операндами, которые вычисляются рекурсивно. Эта функция является рекурсивной, однако использует глобальный массив, содержащий выражение и индекс текущего символа выражения. Индекс увеличивается после вычисления каждого подвыражения.
char *a; int i; int eval() { int x = 0; while (a[i] == ' ') i++; if (a[i] == ' + ') { i++; return eval() + eval(); } if (a[i] == '*') { i++; return eval() * eval(); } while ((a[i] >= '0') && (a[i] <= '9')) x = 10*x + (a[i++] -'0'); return x; }
В принципе, любой цикл for можно заменить эквивалентной рекурсивной программой. Часто рекурсивная программа предоставляет более естественный способ выражения вычисления, чем цикл for, поэтому можно воспользоваться преимуществами механизма, предоставляемого системой с поддержкой рекурсии. Однако следует помнить, что здесь имеются скрытые издержки. Как должно быть понятно из примеров, приведенных на рис. 5.3 при выполнении рекурсивной программы вызовы функций вкладываются один в другой, пока не будет достигнута точка, где вместо рекурсивного вызова выполняется возврат. В большинстве сред программирования такие вложенные вызовы функций реализуются с помощью эквивалента встроенных стеков. В данной главе мы рассмотрим сущность подобного рода реализаций. Глубина рекурсии - это максимальная степень вложенности вызовов функций в ходе вычисления. В общем случае глубина зависит от входных данных. Например, глубина рекурсии в примерах, приведенных на рис. 5.2 и рис. 5.3, составляет, соответственно, 9 и 4. бедует учитывать, что для работы рекурсивной программы среда программирования задействует стек, размер которого пропорционален глубине рекурсии. При решении сложных задач необходимый для этого стека объем памяти может заставить отказаться от использования рекурсивного решения.

Рис. 5.3. Пример вычисления префиксного выражения
Эта вложенная последовательность вызовов функций иллюстрирует работу рекурсивного алгоритма вычисления префиксного выражения. Для простоты здесь показаны аргументы выражения. Сам по себе алгоритм никогда явно не определяет длину строки своих аргументов: вся необходимая информация содержится в начале строки.
Огруктуры данных, построенные из узлов с указателями, рекурсивны по своей природе. Например, рекурсивным является определение связных списков в лекция №3 (определение 3.3). Cледовательно, рекурсивные программы обеспечивают естественные реализации для многих часто используемых функций, работающих с этими структурами данных. Программа 5.5 содержит четыре таких примера. Подобные реализации в книге используются часто - в основном потому, что их гораздо проще понять, чем их нерекурсивные аналоги. Однако при обработке очень больших списков рекурсивные программы, подобные программе 5.5, следует использовать с осторожностью, поскольку глубина рекурсии для таких функций может быть пропорциональна длине списков и, соответственно, требуемый для рекурсивного стека объем памяти может оказаться слишком большим.
Некоторые среды программирования автоматически выявляют и исключают концевую рекурсию (tail recursion), когда последним действием функции является рекурсив -
ный вызов, т.к. в этом случае увеличение глубины рекурсии вовсе не обязательно. Это усовершенствование преобразовало бы функции подсчета, обхода и удаления, используемые в программе 5.5, в циклы, но оно не применимо к функции обхода в обратном направлении.
В разделах 5.2 и 5.3 будут рассмотрены два семейства рекурсивных алгоритмов, представляющие важные вычислительные парадигмы. Затем, в разделах 5.4 - 5.7, мы познакомимся с рекурсивными структурами данных, служащими основой для большой группы алгоритмов.
Программа 5.5. Примеры рекурсивных функций для связных списков
Эти рекурсивные функции для выполнения простых задач обработки списков легко записать, но они могут оказаться бесполезными для очень больших списков, поскольку глубина рекурсии может быть пропорциональна длине списка.
Первая функция, count, подсчитывает количество узлов в списке. Вторая, traverse, вызывает функцию visit для каждого узла списка, с начала до конца. Обе функции легко реализуются и с помощью цикла for или while. Третья функция, traverseR, не имеет простого итеративного аналога. Она вызывает функцию visit для каждого узла списка, но в обратном порядке.
Четвертая функция, remove, удаляет из списка все узлы с заданным значением элемента. Реализация этой функции основана на изменении ссылки x = x ->next в узлах, предшествующих удаляемым, что возможно благодаря использованию ссылочного параметра. Структурные изменения для каждой итерации цикла while совпадают с показанными на рис. 3.3, но в данном случае и x, и t указывают на один и тот же узел.
int count(link x) { if (x == 0) return 0; return 1 + count(x ->next); } void traverse(link h, void visit(link)) { if (h == 0) return; visit(h); traverse(h ->next, visit); } void traverseR(link h, void visit(link)) { if (h == 0) return; traverseR(h ->next, visit); visit(h); } void remove(link& x, Item v) { while (x != 0 && x ->item == v) { link t = x; x = x ->next; delete t; } if (x != 0) remove(x ->next, v); }
Упражнения
5.1. Напишите рекурсивную программу для вычисления lg(N!).
5.2. Измените программу 5.1 для вычисления N! mod M без риска вызвать переполнение. Попробуйте выполнить программу для M = 997 и N = 103, 104, 105 и 106 , чтобы увидеть, как используемая система программирования обрабатывает рекурсивные вызовы с большой глубиной вложенности.
5.3. Приведите последовательности значений аргумента, получаемых в результате вызова программы 5.2 для каждого из целых чисел от 1 до 9.
5.4. Найдите значение N < 106 , при котором программа 5.2 выполняет максимальное количество рекурсивных вызовов.
5.5.Создайте нерекурсивную реализацию алгоритма Евклида.
5.6. Приведите рисунок, соответствующий рис. 5.2, для результата выполнения алгоритма Евклида с числами 89 и 55.
5.7. Определите глубину рекурсии алгоритма Евклида для двух последовательных чисел Фибоначчи (FN и FN+1) .
5.8. Приведите рисунок, соответствующий рис. 5.3, для результата вычисления префиксного выражения + * * 12 12 12 144.
5.9. Напишите рекурсивную программу для вычисления постфиксных выражений.
5.10. Напишите рекурсивную программу для вычисления инфиксных выражений, в которых операнды всегда заключены в круглые скобки.
5.11. Напишите рекурсивную программу, которая преобразует инфиксные выражения в постфиксные.
5.12. Напишите рекурсивную программу, которая преобразует постфиксные выражения в инфиксные.
5.13. Напишите рекурсивную программу для решения задачи Иосифа Флавия (см. лекция №3).
5.14. Напишите рекурсивную программу, которая удаляет последний узел из связного списка.
5.15. Напишите рекурсивную программу для изменения порядка следования узлов в связном списке на обратный (см. программу 3.7). Совет: используйте глобальную переменную.
Разделяй и властвуй
Во множестве рассматриваемых в книге программ используются два рекурсивных вызова, каждый из которых работает приблизительно с половиной входных данных. Эта рекурсивная схема - вероятно, наиболее важный случай хорошо известного метода "разделяй и властвуй ", который служит основой для разработки важнейших алгоритмов.
В качестве примера рассмотрим задачу отыскания максимального из N элементов массива a[0], ..., a[N -1]. Эту задачу можно легко выполнить за один проход по массиву:
for (t = a[0], i = 1; i < N; i++) if (a[i] > t) t = a[i];
Рекурсивное решение типа "разделяй и властвуй ", приведенное в программе 5.6 - также простой (хотя и совершенно иной) алгоритм решения той же задачи; он приведен только для иллюстрации концепции "разделяй и властвуй ".
Часто подход "разделяй и властвуй " обеспечивает более быстрые решения, чем простые итерационные алгоритмы (в конце раздела мы рассмотрим несколько примеров); кроме того, данный подход заслуживает внимательного изучения, поскольку помогает понять суть некоторых фундаментальных вычислений.

Рис. 5.4. Рекурсивный способ отыскания максимума
Эта последовательность вызовов функций иллюстрирует динамику отыскания максимума с помощью рекурсивного алгоритма.
На рис. 5.4 показаны рекурсивные вызовы, выполняемые при запуске программы 5.6 для некоторого массива. Структура последовательности вызовов кажется сложной, но обычно об этом можно не беспокоиться - для доказательства правильности работы программы мы полагаемся на метод математической индукции, а для анализа ее производительности используется рекуррентное соотношение.
Как обычно, сам код предлагает проверку правильности вычисления методом индукции:
- Он явно и немедленно находит максимальный элемент массива, размер которого равен 1.
- Для N > 1 код разделяет массив на два, размер каждого из которых меньше N, исходя из индуктивного предложения, находит максимальные элементы в обеих частях и возвращает большее из этих двух значений, которое должно быть максимальным значением для всего массива.
Более того, рекурсивную структуру программы можно использовать для исследования характеристик ее производительности.
Программа 5.6. Применение принципа "разделяй и властвуй " для отыскания максимума
Эта функция делит массив a[l] , ..., a[r] на массивы a[l] , ..., a[m] и a[m+1], ..., a[r], находит (рекурсивно) максимальные элементы в обеих частях и возвращает больший из них в качестве максимального элемента всего массива. Предполагается, что Item - тип первого класса, для которого операция определена >. Если размер массива является четным числом, обе части имеют одинаковые размеры, а если нечетным, эти размеры различаются на 1.
Item max(Item a[], int l, int r) { if (l == r) return a[l] ; int m = (l+r) /2; Item u = max(a, l, m); Item v = max(a, m+1, r); If (u > v) return u; else return v; }
Лемма 5.1. Рекурсивная функция, которая делит задачу размера N на две независимые (непустые) части и рекурсивно решает их, вызывает себя менее N раз.
Если одна часть имеет размер к, а другая - N - k, то общее количество рекурсивных вызовов используемой функции равно
TN = Tk + TN - k + 1, при ; T1 = 0
Решение
TN = N - 1
можно получить непосредственно методом индукции. Если сумма размеров частей меньше N, доказательство того, что количество вызовов меньше чем N - 1, вытекает из тех же индуктивных рассуждений. Аналогичными рассуждениями можно подтвердить справедливость данного утверждения и для общего случая (см. упражнение 5.20).
Программа 5.6 - типичный пример для многих алгоритмов вида "разделяй и властвуй ", имеющих совершенно одинаковую рекурсивную структуру, но другие примеры могут отличаться от приведенного в двух аспектах. Во-первых, программа 5.6 выполняет одинаковый объем вычислений для каждого вызова функции, и поэтому ее общее время выполнения линейно. Как мы увидим, другие алгоритмы "разделяй и властвуй " могут выполнять различный объем вычислений для различных вызовов функций, и поэтому для определения общего времени выполнения требуется более сложный анализ. Время выполнения таких алгоритмов зависит от конкретного способа разделения на части. Во -вторых, программа 5.6 - типичный пример алгоритмов "разделяй и властвуй ", для которых сумма размеров частей равна размеру общей задачи. Другие алгоритмы "разделяй и властвуй " могут делить задачу на части, сумма размеров которых меньше или больше размера исходной задачи. Эти алгоритмы тоже относятся к рекурсивным алгоритмам "разделяй и властвуй ", поскольку каждая часть меньше целого, но анализировать их труднее, чем программу 5.6. Мы подробно проанализируем каждый из этих видов алгоритмов по мере их рассмотрения.
Например, алгоритм бинарного поиска, приведенный в разделе 2.6 лекция №2, является алгоритмом вида "разделяй и властвуй ", который делит задачу пополам, а затем работает только с одной из этих половин. Рекурсивная реализация бинарного поиска будет рассмотрена в лекция №12.
На рис. 5.5 показано содержимое внутреннего стека, который используется средой программирования для реализации вычислений, изображенных на рис. 5.4. Приведенная на рисунке модель является идеализированной, но она позволяет понять структуру вычисления по методу "разделяй и властвуй ". Если в программе имеются два рекурсивных вызова, то во время ее выполнения внутренний стек содержит сначала один элемент, соответствующий первому вызову функции во время выполнения (он содержит значения аргументов, локальные переменные и адрес возврата), а затем аналогичный элемент для второго вызова функции. На рис. 5.5 показан другой подход - помещение в стек сразу двух элементов с сохранением всех подстеков, которые должны явно создаваться в стеке. Такая организация проясняет структуру вычислений и закладывает основу для более общих вычислительных схем, подобных тем, о которых пойдет речь в разделах 5.6 и 5.8.
На рис. 5.6 показана структура алгоритма "разделяй и властвуй " для поиска максимального значения. Эта структура является рекурсивной: верхний узел содержит размер входного массива; структура левого подмассива изображена слева, а правого - справа. Формальное определение и описание структур деревьев такого вида приведены в разделах 5.4 и 5.5. Они облегчают понимание структуры любых программ, в которых используются вложенные вызовы функций, но особенно рекурсивных программ.

Рис. 5.5. Пример динамики внутреннего стека
Здесь изображено идеализированное представление содержимого внутреннего стека во время вычисления примера из рис. 5.4. Обработка начинается с занесения в стек левого и правого индексов всего подмассива. Каждая строка представляет результат занесения в стек двух индексов и, если они не равны, вталкивания четырех индексов, которые ограничивают левый и правый подмасивы после разделения вытолкнутого подмассива на две части. На практике вместо таких действий система хранит в стеке адреса возврата и локальные переменные, однако эта модель вполне адекватно описывает вычисление.

Рис. 5.6. Рекурсивная структура алгоритма поиска максимума
Алгоритм "разделяй и властвуй "разбивает задачу размером 11 на задачи с размерами 6 и 5, задачу размером 6 - на две задачи с размерами 3 и 3 - и т.д., пока не будет получена задача размером 1 (вверху). Каждый кружок на этих диаграммах означает вызов рекурсивной функции для расположенных непосредственно под ней узлов, связанных с ней линиями (квадратики означают вызовы, в которых рекурсия завершается). На диаграмме в центре показано значение индекса в середине разбиения файла; на нижней диаграмме показано возвращаемое значение.
На рис. 5.6 внизу показано такое же дерево, но в нем каждый узел содержит значение, возвращаемое соответствующим вызовом функции. Процесс создания связных структур, которые представляют подобные деревья, рассматривается в разделе 5.7.
Ни одно рассмотрение рекурсии не будет полным без рассмотрения старинной задачи о ханойских башнях. Имеется три стержня и N дисков, которые помещаются на трех стержнях. Диски различаются размерами и вначале размещаются на одном из стержней от самого большого (диск N) внизу до самого маленького (диск 1) вверху. Задача состоит в перемещении дисков на соседнюю позицию (стержень) при соблюдении следующих правил: (1) одновременно можно переложить только один диск; и (2) ни один диск нельзя положить на диск меньшего размера. Легенда гласит, что конец света наступит тогда, когда некая группа монахов выполнит такую задачу для 40 золотых дисков на трех алмазных стержнях.
Программа 5.7 предоставляет рекурсивное решение этой задачи. Она указывает диск, который должен быть перемещен на каждом шагу, и направление его перемещения (+ означает перемещение на один стержень вправо, или на крайний левый, если текущий стержень крайний справа, а - означает перемещение на один стержень влево, или на крайний правый, если текущий стержень крайний слева). Рекурсия основана на следующей идее: для перемещения N дисков вправо на один стержень нужно вначале верхние N - 1 дисков переместить на один стержень влево, затем переложить диск N на один стержень вправо, и потом переложить N - 1 дисков еще на один стержень влево (поверх диска N). Правильность этого решения можно доказать по индукции. На рис. 5.7 показаны перемещения для N = 5 и рекурсивные вызовы для N = 3. Структура алгоритма достаточно очевидна; давайте рассмотрим его подробно.
Во-первых, рекурсивная структура этого решения непосредственно обуславливает количество необходимых перемещений.
Лемма 5.2. Рекурсивный алгоритм "разделяй и властвуй " для задачи о ханойских башнях дает решение, приводящее к 2N - 1 перемещениям.
Как обычно, из кода немедленно следует, что количество перемещений удовлетворяет условию рекуррентности. В данном случае рекуррентная формула похожа на формулу 2.5:
TN = 2TN -1 + 1, при, T1 = 1.
Предсказанный результат можно непосредственно проверить методом индукции: мы имеем T(1) = 21 - 1 = 1; и, если для к < N верно T(к) = 2k - 1, то T(N) = 2 (2N -1 - 1) + 1 = 2N - 1.
Программа 5.7. Решение задачи о ханойских башнях
Чтобы переместить башню из дисков вправо, мы перемещаем (рекурсивно) все диски, кроме нижнего, влево, потом перекладываем нижний диск вправо, а затем перемещаем (рекурсивно) башню поверх нижнего диска.
void hanoi(int N, int d) { if (N == 0) return; hanoi(N -1, -d); shift(N, d); hanoi(N -1, -d); }

Рис. 5.7. Ханойские башни
На этой схеме показано решение задачи о ханойских башнях с пятью дисками. Мы перемещаем четыре верхних диска влево на одну позицию (левый столбец), затем перекладываем диск 5 вправо, а затем верхние четыре диска перемещаем влево на одну позицию (правый столбец). Приведенная ниже последовательность вызовов функций образует вычисление для трех дисков. Вычисленная последовательность перемещений - +1 -2 +1 +3 +1 -2 +1 - появляется в решении четыре раза (для примера показаны первые семь перемещений).

Если монахи перекладывают по одному диску в секунду, то для завершения работы им потребуется, по меньшей мере, 348 столетий (см. рис. 2.1) - разумеется, если они не допускают ошибок. Скорее всего, конец света наступит даже позже этого срока, поскольку, по -видимому, монахи не пользуются программой 5.7 для быстрого выяснения, какой диск нужно переложить следующим. А теперь давайте проанализируем метод, который ведет к простому (не рекурсивному) решению, упрощающему принятие решения. Не хочется помогать монахам, однако этот метод имеет большое значение для множества практически важных алгоритмов.
Чтобы понять решение задачи о ханойских башнях, рассмотрим простую задачу рисования меток на линейке. Каждые 1/2 дюйма на линейке отмечаются черточкой, каждые 1/4 дюйма отмечаются несколько более короткими черточками, 1/8 дюйма - еще более короткими и т.д. Задача состоит в создании программы для рисования этих меток для любого заданного разрешения, при условии, что в нашем распоряжении имеется процедура mark(x, h) для рисования метки высотой h условных единиц в позиции x.
Если требуемое разрешение равно 1/2n дюйма, мы перейдем к другой задаче: поместить метки в каждой точке в интервале от 0 до 2n, за исключением конечных точек. Тогда средняя метка должна иметь высоту n единиц, метки в середине левой и правой половин должны иметь высоту n - 1 единиц и т.д. Программа 5.8 - простой алгоритм "разделяй и властвуй " для выполнения этой задачи; его работа на небольшом примере проиллюстрирована на рис. 5.8. Рекурсивный метод состоит в следующем. Для помещения меток на интервале он вначале делится на две равные половины. Затем создаются (рекурсивно) более короткие метки в левой половине, в середине помещается длинная метка, и создаются (рекурсивно) более короткие метки в правой половине. На рис. 5.8 видно, что с помощью этого метода метки создаются по порядку, слева направо (то есть итеративно) - и проблема заключается в вычислении длин меток. Дерево рекурсии, приведенное на рисунке, помогает понять вычисления: просматривая его сверху вниз, мы видим, что длина меток уменьшается на 1 для каждого рекурсивного вызова функции. Если просматривать дерево в поперечном направлении, мы получаем метки в порядке нанесения, поскольку для каждого данного узла вначале рисуются метки, связанные с вызовом функции слева, затем метка, связанная с данным узлом, а затем метки, связанные с вызовом функции справа.
Программа 5.8. Применение алгоритма "разделяй и властвуй " для рисования линейки
Для отрисовки меток на линейке мы рисуем метки в левой половине, затем самую длинную метку в середине, а затем метки в правой половине. Данная программа предназначена для использования со значением r - l, равным степени 2 - и это свойство сохраняется в ее рекурсивных вызовах (см. упражнение 5.27).
void rule(int l, int r, int h) { int m = (l+r)/2; if (h > 0) { rule(l, m, h -1); mark(m, h); rule(m, r, h -1); } }
Cразу видно, что последовательность длин в точности совпадает с последовательностью перемещаемых дисков при решении задачи о ханойских башнях. Действительно, простым доказательством их идентичности является идентичность рекурсивных программ. То есть для определения перекладываемого диска наши монахи могли бы воспользоваться метками на линейке.
Более того, и решение задачи о ханойских башнях в программе 5.7, и программа рисования линейки в программе 5.8 являются вариантами общей схемы "разделяй и властвуй ", представленной программой 5.6. Все три программы решают задачу размера 2n, разбивая ее на две задачи размера 2n -1. При отыскании максимума время получения решения линейно зависит от размера входного массива; при рисовании линейки и при решении задачи о ханойских башнях время линейно зависит от размера выходного массива. Обычно считается, что время выполнения задачи о ханойских башнях экспоненциально, хотя объем задачи измеряется количеством дисков, т.е. п.

Рис. 5.8. Вызовы функции, рисующие метки на линейке
Эта последовательность вызовов функции вычисляет длины меток для рисования линейки длиной 8, в результате чего наносятся метки 1, 2, 1, 3, 1, 2 и 1.
Рисование меток на линейке с помощью рекурсивной программы не представляет особой сложности, но, может быть, существует более простой способ вычисления длины i -ой метки для любого данного значения i? На рис. 5.9 показан еще один простой вычислительный процесс, дающий ответ на этот вопрос. Оказывается, i -е число, выводимое и программой решения задачи о ханойских башнях, и программой рисования линейки - просто количество оконечных нулевых разрядов в двоичном представлении i. Это утверждение можно доказать методом индукции по соответствию с формулировкой метода "разделяй и властвуй " для процесса вывода таблицы " -разрядных чисел: достаточно напечатать таблицу (п - 1) -разрядных чисел, каждому из которых предшествует 0, а затем напечатать таблицу (п - 1) -разрядных чисел, каждому из которых предшествует 1 (см. упражнение 5.25).
Применительно к задаче о ханойских башнях соответствие n -разрядным числам дает простой алгоритм решения задачи. Можно переместить стопку дисков на один стержень вправо, повторяя до завершения следующие два шага:
- Перемещение маленького диска вправо, если п нечетно (влево, если четно).
- Выполнение единственного разрешенного перемещения, не затрагивающего маленький диск.
То есть после перекладывания маленького диска на остальных двух стержнях находятся сверху два диска, один из которых меньше другого.
Единственное разрешенное перемещение, не затрагивающее маленький диск - перемещение меньшего диска поверх большего. Каждое второе перемещение выполняется с маленьким диском по той же причине, по которой каждое второе число является нечетным, а каждая вторая метка на линейке является самой короткой. Вероятно, наши монахи знают этот секрет, поскольку трудно представить, как иначе они могли бы определять нужные перемещения.
Формальное доказательство методом индукции того, что в решении задачи о ханойских башнях каждое второе перемещение является перекладыванием маленького диска (им же все начинается и заканчивается), весьма поучительно: Для n = 1 существует только одно перемещение, затрагивающее маленький диск, следовательно, утверждение подтверждается. При n > 1 из предположения, что утверждение справедливо для n - 1, следует его справедливость и для n: первое решение для n - 1 начинается перекладыванием маленького диска, а второе решение для n - 1 завершается перекладыванием маленького диска - следовательно, решение для n начинается и завершается перекладыванием маленького диска. Перемещение, не затрагивающее маленький диск, вставляется между двумя перемещениями, которые его затрагивают (перекладыванием, завершающим первое решение для n - 1, и перекладыванием, начинающим второе решение для n - 1) - следовательно, свойство, что каждое второе перемещение является перекладыванием маленького диска, остается в силе.

Рис. 5.9. Двоичный подсчет и функция рисования линейки
Вычисление функции рисования линейки эквивалентно подсчету количества оконечных нулей в четных N -разрядных числах.
Программа 5.9 - альтернативный способ рисования линейки, на который натолкнуло соответствие с двоичными числами (см. рис. 5.10). Эту версию алгоритма называют восходящей (bottom -up) реализацией. Она не является рекурсивной, но определенно навеяна рекурсивным алгоритмом. Эта связь между алгоритмами "разделяй и властвуй " и двоичными представлениями чисел часто помогает найти решение при анализе и разработке усовершенствованных версий, таких как восходящие подходы. Мы будем рассматривать данную возможность, чтобы понять и, возможно, усовершенствовать каждый рассматриваемый алгоритм вида "разделяй и властвуй ".
Восходящий подход предполагает изменение порядка выполнения вычислений при рисовании линейки. На рис. 5.11 показан еще один пример, в котором изменен порядок следования трех вызовов функций в рекурсивной реализации. Этот пример соответствует рекурсивному рисованию первоначально описанным способом: нанесение средней метки, затем левой половины, а затем правой. Последовательность нанесения меток выглядит сложной, но является результатом простой перемены мест двух операторов в программе 5.8. Как будет показано в разделе 5.6, взаимосвязь между рис. 5.8 и рис. 5.11 сродни различию между постфиксными и префиксными арифметическими выражениями.
Программа 5.9. Нерекурсивная программа для рисования линейки
В отличие от программы 5.8, линейку можно нарисовать, вначале изобразив все метки длиной 1, затем все метки длиной 2 и т.д. Переменная t представляет длину меток, а переменная j - количество меток между двумя последовательными метками длиной t. Внешний цикл for увеличивает значение t при сохранении соотношения j = 2 -1. Внутренний цикл for рисует все метки длиной t.
void rule(int l, int r, int h) { for (int t = 1, j = 1; t <= h; j += j, t++) for (int i = 0; l+j+i <= r; i += j+j) mark(l+j+i, t); }
Возможно, нанесение меток в порядке, показанном на рис. 5.8, более удобно по сравнению с вычислениями в измененном порядке, которые содержатся в программе 5.9 и приведены на рис. 5.11, поскольку в этом случае можно нарисовать линейку произвольной длины. Достаточно представить себе графическое устройство, которое просто непрерывно перемещается от одной метки к следующей. Аналогично, при решении задачи о ханойских башнях мы ограничены последовательностью перемещений, которая должна быть выполнена. Вообще говоря, многие рекурсивные программы основываются на решениях подзадач, которые должны быть выполнены в конкретном порядке. Для других вычислений (см., например, программу 5.6) порядок решения подзадач роли не играет. Для таких вычислений единственным ограничением служит необходимость решения подзадач перед тем, как можно будет решить главную задачу. Понимание того, когда можно изменять порядок вычисления, не только служит ключом к успешной разработке алгоритма, но и во многих случаях оказывает непосредственное практическое влияние. Например, этот вопрос исключительно важен при реализации алгоритмов для работы на параллельных процессорах.
Восходящий подход соответствует общему методу разработки алгоритмов, при котором задача решается путем решения вначале элементарных подзадач с последующим объединением этих решений для получения решения несколько больших подзадач и т.д., пока вся задача не будет решена. Этот подход можно было бы назвать "объединяй и властвуй ".
Лишь небольшой шаг отделяет рисование линеек от рисования двумерных узоров, похожих на показанный на рис. 5.12. Этот рисунок показывает, как простое рекурсивное описание может приводить к сложным на вид вычислениям (см. упражнение 5.30).

Рис. 5.10. Рисование линейки в восходящем порядке
Для рисования линейки нерекурсивным методом вначале рисуются все метки длиной 1 и пропускаются позиции, затем рисуются метки длиной 2 и пропускаются остающиеся позиции, затем рисуются метки длиной 3 с пропуском остающихся позиций и т.д.

Рис. 5.11. Вызовы функций для рисования линейки (версия с использованием прямого обхода)
Эта последовательность отображает результат нанесения меток перед рекурсивными вызовами, а не между ними.

Рис. 5.12. Двумерная фрактальная звезда
Этот фрактал - двумерная версия рис. 5.10. Очерченные квадраты на нижнем рисунке демонстрируют рекурсивную структуру вычисления.
Рекурсивно определенные геометрические узоры, наподобие показанного на рис. 5.12, иногда называют фракталами. При использовании более сложных примитивов рисования и более сложных рекурсивных функций (особенно рекурсивно определенных функций на вещественной оси и комплексной плоскости) можно получить поразительно разнообразные и сложные узоры. На рис. 5.13 приведен еще один пример - звезда Коха, которая определяется рекурсивно следующим образом: звезда Коха порядка 0 - простой выступ, показанный на рис. 4.3, а звезда Коха n -го порядка - это звезда Коха порядка п - 1, в которой каждый отрезок заменен звездой порядка 0 соответствующего размера.
Подобно решениям задач рисования линейки и ханойских башен, эти алгоритмы линейны по отношению к количеству шагов, но это количество связано экспоненциальной зависимостью с максимальной глубиной рекурсии (см. упражнения 5.29 и 5.33). Они также могут быть непосредственно связаны с последовательностью натуральных чисел в соответствующей системе счисления (см. упражнение 5.34).

Рис. 5.13. Рекурсивная PostScript -программа для рисования фрактала Коха
Это изменение программы PostScript, приведенной на рис. 4.3, преобразует результат ее работы в фрактал (см. текст).
Задача о ханойских башнях, рисование линейки и фракталы весьма занимательны, а связь с двоичным числами поражает, однако все эти вопросы интересуют нас прежде всего потому, что облегчают понимание одного из основных методов разработки алгоритмов - деление задачи пополам и независимое решение одной или обеих половин задачи. Возможно, это наиболее важная из всех технологий, рассматриваемых в книге. В таблица 5.1 подробно описаны бинарный поиск и сортировка слиянием, которые не только являются важными и широко используемыми на практике алгоритмами, но и служат типичными примерами разработки алгоритмов вида "разделяй и властвуй ".
Бинарный поиск (см. лекция №2 и лекция №12) и сортировка слиянием (см. лекция №8) - типичные алгоритмы "разделяй и властвуй ", которые обеспечивают гарантированную оптимальную производительность, соответственно, поиска и сортировки. Рекуррентные соотношения демонстрируют сущность вычислений методом "разделяй и властвуй " для каждого алгоритма. (Вывод решений, приведенных в правом столбце, см. в разделах 2.5 лекция №2 и 2.6.) При бинарном поиске задача делится пополам, выполняется одно сравнение, а затем рекурсивный вызов для одной из половин. При сортировке слиянием задача делится пополам, затем выполняется рекурсивная обработка обеих половин, после чего программа выполняет N сравнений. В книге будет рассмотрено множество других алгоритмов, разработанных с применением этих рекурсивных схем.
рекуррентное соотношение | приближенное решение | |
---|---|---|
Бинарный поиск | ||
количество сравнений | CN = CN/2 + 1 | lg N |
Сортировка слиянием | ||
количество рекурсивных вызовов | AN = 2AN/2 + 1 | N |
количество сравнений | CN = 2CN/2 + N | NlgN |
Быстрая сортировка (см. лекция №7) и поиск в бинарном дереве (см. лекция №12) представляют важную разновидность базового подхода "разделяй и властвуй ", в которой задача разбивается на подзадачи размеров k - 1 и N - k для некоторого k, определенного во входных данных. При случайных входных данных эти алгоритмы разбивают задачи на подзадачи, размеры которых в среднем вдвое меньше исходного (как в сортировке слиянием или бинарном поиске). Влияние этого различия будет рассмотрено при изучении этих алгоритмов.
Заслуживают внимания и следующие разновидности основной темы: разбиение на части различных размеров, разбиение более чем на две части, разбиение на перекрывающиеся части и выполнение различного объема вычислений в нерекурсивной части алгоритма. В общем случае алгоритмы "разделяй и властвуй " требуют выполнения вычислений для разбиения входного массива на части, либо для объединения результатов обработки двух независимо решенных частей исходной задачи, либо для упрощения задачи после того как половина входного массива обработана. То есть, код может находиться перед, после и между двумя рекурсивными вызовами. Естественно, подобные вариации приводят к созданию более сложных алгоритмов, чем бинарный поиск или сортировка слиянием, и эти алгоритмы труднее анализировать. В данной книге будут рассмотрены многочисленные примеры; к более сложным приложениям и способам анализа мы вернемся в части VIII.
Упражнения
5.16. Напишите рекурсивную программу, которая находит максимальный элемент в массиве, выполняя сравнение первого элемента с максимальным элементом остальной части массива (найденным рекурсивно).
5.17. Напишите рекурсивную программу, которая находит максимальный элемент в связном списке.
5.18 Измените программу "разделяй и властвуй " для отыскания максимального элемента в массиве (программа 5.6), чтобы она делила массив размера N на две части, одна из которых имеет размер k=2risN - 1, а вторая - N - к (чтобы размер хотя бы одной части был степенью 2).
5.19. Нарисуйте дерево, которое соответствует рекурсивным вызовам, выполняемым программой из упражнения 5.18 при размере массива 11.
5.20. Методом индукции докажите, что количество вызовов функции, выполняемых любым алгоритмом "разделяй и властвуй ", который делит задачу на части, в сумме составляющие задачу в целом, а затем решает части рекурсивно, линейно относительно размера задачи.
5.21. Докажите, что рекурсивное решение задачи о ханойских башнях (программа 5.7) является оптимальным. То есть покажите, что любое решение требует по меньшей мере 2N - 1 перекладываний.
5.22. Напишите рекурсивную программу, которая вычисляет длину i -ой метки на линейке с 2n - 1 метками.
5.23. Проанализируйте таблицы " -разрядных чисел наподобие приведенной на рис. 5.9 и определите свойство i -го числа, определяющего направление i -го перемещения (указанного знаковым битом на рис. 5.7) при решении задачи о ханойских башнях.
5.24. Напишите программу, которая выдает решение задачи о ханойских башнях путем заполнения массива, содержащего все перемещения, как сделано в программе 5.9.
5.25. Напишите рекурсивную программу, которая заполняет массив размером n х 2n нулями и единицами таким образом, чтобы массив представлял все n -разрядные числа, как показано на рис. 5.9.
5.26. Приведите результаты использования рекурсивной программы рисования линейки (программа 5.8) для следующих значений аргументов: rule(0, 11, 4) , rule(4, 20, 4) и rule(7, 30, 5).
5.27. Докажите следующее свойство программы рисования линейки (программа 5.8): если разность между ее первыми двумя аргументами является степенью 2, то оба ее рекурсивных вызова также обладают этим свойством.
5.28. Напишите функцию, которая эффективно вычисляет количество завершающих нулей в двоичном представлении целого числа.
5.29. Сколько квадратов изображено на рис. 5.12 (включая и те, которые скрыты большими квадратами)?
5.30. Напишите рекурсивную программу на C++, результатом которой будет PostScript -программа в форме списка вызовов функций x y r box, которая вычерчивает нижнюю диаграмму на рис. 5.12; функция box рисует квадрат rxr в точке с координатами (x, y). Реализуйте функцию box в виде команд PostScript (см. лекция №4).
5.31. Напишите восходящую нерекурсивную программу (аналогичную программе 5.9), которая вычерчивает верхнюю часть рис. 5.12 способом, описанным в упражнении 5.30.
5.32. Напишите PostScript -программу, вычерчивающую нижнюю часть рис. 5.12.
5.33. Сколько прямолинейных отрезков содержит звезда Коха " -го порядка?
5.34. Вычерчивание звезды Коха n -го порядка сводится к выполнению последовательности команд вида "повернуть на а градусов, затем прочертить отрезок длиной 1/3" ". Найдите связь с системами счисления, которая позволяет вычертить звезду путем увеличения значения счетчика и последующего вычисления угла а из этого значения.
5.35. Измените программу рисования звезды Коха, приведенную на рис. 5.13, для создания другого фрактала, на основе фигуры, состоящей из 5 линий нулевого порядка, вычерчиваемых смещениями на одну условную единицу в восточном, северном, восточном, южном и восточном направлениях (см. рис. 4.3).
5.36. Напишите рекурсивную функцию "разделяй и властвуй " для отображения аппроксимации прямолинейного отрезка в пространстве целочисленных координат для заданных конечных точек. Считайте, что все координаты имеют значения от 0 до M. Совет: вначале поставьте точку вблизи середины сегмента.
Динамическое программирование
Основная характеристика алгоритмов вида "разделяй и властвуй ", рассмотренных в разделе 5.2 - разбиение ими задачи на независимые подзадачи. Если подзадачи не являются независимыми, ситуация усложняется, в первую очередь потому, что непосредственная рекурсивная реализация даже простейших алгоритмов этого типа может требовать неприемлемых затрат времени. В данном разделе рассматривается систематический подход, который позволяет избежать этой опасности в некоторых случаях.
Например, программа 5.10 - непосредственная рекурсивная реализация рекуррентного соотношения, определяющего числа Фибоначчи (см. лекция №3). Не используйте эту программу - она весьма неэффективна. Действительно, количество рекурсивных вызовов для вычисления FN равно FN+1. Но FN приближенно равно фN, где ф " 1,618 - пропорция золотого сечения. Как это ни удивительно, но для программы 5.10 время этого элементарного вычисления определяется экспоненциальной зависимостью. На рис. 5.14, на котором приведены рекурсивные вызовы для небольшого примера, наглядно демонстрируется требуемый объем повторных вычислений.

Рис. 5.14. Структура рекурсивного алгоритма для вычисления чисел Фибоначчи
Из схемы рекурсивных вызовов для вычисления F8 с помощью стандартного рекурсивного алгоритма видно, как рекурсия с перекрывающимися подзадачами может привести к экспоненциальному возрастанию затрат. В данном случае второй рекурсивный вызов игнорирует вычисление, выполненное во время первого вызова, что приводит к значительным повторным вычислениям, поскольку эффект нарастает в геометрической прогрессии. Рекурсивные вызовы для вычисления F6 = 8 (показаны в правом поддереве корня и в левом поддереве левого поддерева корня) приведены ниже.

И напротив, используя обычный массив, можно легко вычислить первые N чисел Фибоначчи за время, пропорциональное N:
F[0] = 0; F[1] = 1; for (i = 2; i <= N; i++) F[i] = F[i -1] + F[i -2];
Числа возрастают экспоненциально, поэтому массив не должен быть большим; например, F45 = 1836311903 - наибольшее число Фибоначчи, которое может быть представлено 32 -разрядным целым, поэтому достаточно использовать массив с 46 элементами.
Этот подход дает непосредственный способ получения численных решений для любых рекуррентных соотношений. В случае с числами Фибоначчи можно обойтись даже без массива и ограничиться только последними двумя значениями (см. упражнение 5.37); однако во многих других случаях часто встречающихся рекуррентных соотношений (см., например, упражнение 5.40) необходим массив для хранения всех известных значений.
Программа 5.10. Числа Фибоначчи (рекурсивная реализация)
Эта программа выглядит компактно и изящно, однако неприменима на практике, поскольку время вычисления FN экспоненциально зависит от N. Время вычисления FN+1 в ф " 1.6 раз больше времени вычисления FN. Например, поскольку ф9 > 60, если для вычисления Fn компьютеру требуется около секунды, то для вычисления FN+9 потребуется более минуты, а для вычисления FN+18 - более часа.
int F(int i) { if (i < 1) return 0; if (i == 1) return 1; return F(i -1) + F(i -2); }
Рекуррентное соотношение - это рекурсивная функция с целочисленными значениями. Рассуждения, приведенные в предыдущем абзаце, подсказывают, что любую такую функцию можно вычислить, вычисляя все значения функции, начиная с наименьшего, и используя на каждом шаге ранее вычисленные значения для подсчета текущего значения. Эта техника называется восходящим динамическим программированием (bottom -up dynamic programming). Она применима к любому рекурсивному вычислению при условии, что есть возможность хранить все ранее вычисленные значения. Такая техника разработки алгоритмов успешно используется для решения широкого круга задач. Так что обратите внимание на эту простую технологию, которая может изменить время выполнения алгоритма с экспоненциального на линейное!
Нисходящее динамическое программирование (top -down dynamic programming) - еще более простая техника, которая позволяет автоматически выполнять рекурсивные функции при том же (или меньшем) количестве итераций, что и в восходящем динамическом программировании. При этом рекурсивная программа должна (заключительным действием) сохранять каждое вычисленное ей значение и (первым действием) проверять эти значения во избежание повторного вычисления любого из них. Программа 5.11 - результат механического преобразования программы 5.10, в которой нисходящее динамическое программирование позволило уменьшить время выполнения до линейного.
На рис. 5.15 демонстрируется радикальное уменьшение количества рекурсивных вызовов, достигнутое этим простым автоматическим изменением. Иногда нисходящее динамическое программирование называют также мемоизацией (memoization).
В качестве более сложного примера рассмотрим задачу о ранце: вор, грабящий сейф, находит в нем N видов предметов различных размеров и ценности, но имеет только небольшой ранец емкостью M, в котором может унести награбленное. Задача заключается в том, чтобы определить комбинацию предметов, которые вор должен уложить в ранец, чтобы общая стоимость похищенного оказалась наибольшей.
Программа 5.11. Числа Фибоначчи (динамическое программирование)
Сохранение вычисляемых значений в статическом массиве (элементы которого в C++ инициализируются 0) позволяет явно исключить любые повторные вычисления. Эта программа вычисляет Fn за время, пропорциональное N, что существенно отличается от времени 0(фN), которое требуется для вычислений программе 5.10.
int F(int i) { static int knownF[maxN]; if (knownF[i] != 0) return knownF[i]; int t = i; if (i < 0) return 0; if (i > 1) t = F(i -1) + F(i -2); return knownF[i] = t; }

Рис. 5.15. Применение нисходящего динамического программирования для вычисления чисел Фибоначчи
Из этой схемы рекурсивных вызовов, выполненных для вычисления F 8 методом нисходящего динамического программирования, видно, как сохранение вычисленных значений снижает затраты с экспоненциального (см. рис. 5.14) до линейного.

Рис. 5.16. Пример задачи о ранце
Входными данными задачи о ранце (вверху) являются емкость ранца и набор предметов различных размеров (которые представлены значениями на горизонтальной оси) и стоимости (значения на вертикальной оси). На этом рисунке показаны четыре различных способа заполнения ранца, размер которого равен 17; два из этих способов дают максимальную суммарную стоимость, равную 24.
Например, при наличии типов предметов, представленных на рис. 5.16, вор, располагающий ранцем, размер которого равен 17, может взять только пять (но не шесть) предметов A общей стоимостью 20, или предметы D и E суммарной стоимостью 24, или одно из множества других сочетаний. Наша цель - найти эффективный алгоритм для определения оптимального решения при любом заданном наборе предметов и вместимости ранца.
Решения задачи о ранце важны во многих приложениях. Например, транспортной компании может понадобиться определение наилучшего способа загрузки грузовика или транспортного самолета. В подобных приложениях могут встречаться и другие варианты этой задачи: например, может быть ограничено количество элементов каждого вида или могут быть доступны два грузовика. Многие из таких вариантов можно решить с помощью того же подхода, который будет рассмотрен ниже для решения только что сформулированной базовой задачи, однако бывают и гораздо более сложные варианты. Существует тонкая грань между решаемыми и нерешаемыми задачами этого типа, но об этом речь пойдет в части 8.
В рекурсивном решении задачи о ранце при каждом выборе предмета мы предполагаем, что можем (рекурсивно) определить оптимальный способ заполнения оставшегося места в ранце. Если объем ранца равен cap, то для каждого доступного элемента i определяется общая стоимость элементов, которые можно было бы унести, укладывая i -й элемент в ранец при оптимальной упаковке остальных элементов. Эта оптимальная упаковка - просто упаковка, которая определена (или будет определена) для меньшего ранца объемом cap -items[i].size. Здесь используется следующий принцип: оптимальные принятые решения в дальнейшем не требуют пересмотра. Когда установлено, как оптимально упаковать ранцы меньших размеров, эти задачи не требуют повторного исследования независимо от следующих элементов.
Программа 5.12 - прямое рекурсивное решение, которое основано на приведенных рассуждениях. Эта программа также неприменима для решения реальных задач, поскольку из -за большого объема повторных вычислений (см. рис. 5.17) время решения связано с количеством элементов экспоненциально. Но для решения задачи можно автоматически задействовать нисходящее динамическое программирование - и получить программу 5.13. Как и ранее, эта техника исключает все повторные вычисления (см. рис. 5.18).
Программа 5.12. Задача о ранце (рекурсивная реализация)
Как и в случае рекурсивного вычисления чисел Фибоначчи, не следует использовать эту программу, поскольку для ее выполнения потребуется экспоненциальное время и поэтому, возможно, не удастся получить решение даже небольшой задачи. Тем не менее, программа представляет компактное решение, которое легко можно усовершенствовать (см. программу 5.13). В ней предполагается, что элементы являются структурами с размером и стоимостью, которые определены как
typedef struct { int size; int val; } Item;
и имеется массив N элементов типа Item. Для каждого возможного элемента вычисляется (рекурсивно) максимальная стоимость, которую можно было бы получить, включив этот элемент в выборку, а затем выбирается максимальная из всех стоимостей.
int knap(int cap) { int i, space, max, t; for (i = 0, max = 0; i < N; i++) if ((space = cap -items[i].size) >= 0) if ((t = knap(space) + items[i].val) > max) max = t; return max; }
Программа 5.13. Задача о ранце (динамическое программирование)
Эта механическая модификация программы 5.12 снижает время выполнения с экспоненциального до линейного. Мы просто сохраняем любые вычисленные значения функции, а затем вместо выполнения рекурсивных вызовов выбираем сохраненные значения, когда они требуются (используя специальный признак для представления неизвестных значений). Индекс элемента сохраняется, поэтому при желании всегда можно восстановить содержимое ранца после вычисления: itemKnown[M] находится в ранце, остальное содержимое совпадает с оптимальной упаковкой ранца размера M -itemKnown[M]. size, следовательно, в ранце находится itemKnown[M -items[M].size] и т.д.
int knap(int M) { int i, space, max, maxi = 0, t; if (maxKnown[M] != unknown) return maxKnown[M]; for (i = 0, max = 0; i < N; i++) if ((space = M -items[i].size) >= 0) if ((t = knap(space) + items[i].val) > max) { max = t; maxi = i; } maxKnown[M] = max; itemKnown[M] = items[maxi]; return max; }

Рис. 5.17. Рекурсивная структура алгоритма решения задачи о ранце
Это дерево представляет структуру рекурсивных вызовов простого рекурсивного алгоритма решения задачи о ранце, реализованного в программе 5.12. Число в каждом узле означает оставшееся свободное место в ранце. Недостатком алгоритма является то же экспоненциальное время выполнения из -за большого объема повторных вычислений, требуемых для решения перекрывающихся подзадач, что и при вычислении чисел Фибоначчи (см. рис. 5.14).

Рис. 5.18. Применение метода нисходящего динамического программирования для реализации алгоритма решения задачи о ранце
Как и в случае вычисления чисел Фибоначчи, техника сохранения известных значений уменьшает затраты алгоритма с экспоненциального (см. рис. 5.17) до линейного.
Динамическое программирование принципиально исключает все повторные вычисления в любой рекурсивной программе, при условии, что есть возможность хранить значения функции для аргументов, меньших аргумента текущего вызова.
Лемма 5.3. Динамическое программирование снижает время выполнения рекурсивной функции до не более чем суммарного времени, необходимого на вычисление функции для всех аргументов, меньших или равных данному аргументу, при условии, что затраты на рекурсивный вызов постоянны.
См. упражнение 5.50.
Применительно к задаче о ранце из леммы следует, что время выполнения пропорционально произведению NM. Таким образом, задача о ранце легко поддается решению, когда емкость ранца не очень велика; для очень больших емкостей время и требуемый объем памяти могут оказаться недопустимо большими.
Восходящее динамическое программирование также применимо к задаче о ранце. Вообще -то метод восходящего программирования можно применять во всех случаях, когда применим метод нисходящего программирования, только при этом необходимо обеспечить вычисление значений функции в соответствующем порядке, чтобы каждое значение уже было вычислено, когда оно потребуется. Для функций, имеющих только один целочисленный аргумент, подобных рассмотренным, можно просто выполнять вычисления в порядке увеличения аргумента (см. упражнение 5.53), однако для более сложных рекурсивных функций определение правильного порядка может оказаться сложной задачей.
Например, совсем не обязательно ограничиваться рекурсивными функциями только с одним целочисленным аргументом. При наличии функции с несколькими целочисленными аргументами решения меньших подзадач можно сохранять в многомерных массивах, по одному измерению для каждого аргумента. В других ситуациях можно обойтись вообще без целочисленных аргументов и использовать абстрактную дискретную формулировку задачи, позволяющую разбить задачу на менее сложные. Примеры таких задач рассмотрены в частях V - VIII.
При использовании нисходящего динамического программирования известные значения сохраняются; при использовании восходящего динамического программирования они вычисляются заранее. В общем случае нисходящее динамическое программирование предпочтительней восходящего, поскольку
- оно представляет собой механическую трансформацию естественного решения задачи;
- порядок решения подзадач определяется сам собой;
- может не потребоваться решение всех подзадач.
Приложения, в которых применяется динамическое программирование, различаются по сущности подзадач и объему сохраняемой для них информации.
Однако необходимо учитывать следующий важный момент: динамическое программирование становится неэффективным, когда количество возможных значений функции, которые могут потребоваться, столь велико, что мы не можем себе позволить их сохранять (при нисходящем программировании) или вычислять предварительно (при восходящем программировании). Например, если в задаче о ранце объем ранца и размеры элементов - 64 -разрядные величины или числа с плавающей точкой, значения уже невозможно сохранять путем их индексирования в массиве. Это не просто небольшое неудобство, это принципиальная трудность. Для подобных задач пока не известно ни одного приемлемого решения; как будет показано в части 8, имеются веские причины считать, что эффективного решения нет вообще.
Динамическое программирование - это техника разработки алгоритмов, которая рассчитана в первую очередь на решение сложных задач того вида, который будет рассмотрен в частях V - VIII. Большинство алгоритмов, рассмотренных в частях II - IV, представляют собой реализацию методов "разделяй и властвуй " с не перекрывающимися подзадачами, и основное внимание было уделено скорее субквадратичной или сублинейной производительности, чем субэкспоненциальной. Однако нисходящее динамическое программирование является базовой техникой разработки эффективных реализаций рекурсивных алгоритмов, которая присутствует в арсенале средств любого, кто принимает участие в создании и реализации алгоритмов.
Упражнения
5.37. Напишите функцию, которая вычисляет FN mod M, используя для промежуточных вычислений постоянный объем памяти.
5.38. Каково наибольшее значение N, для которого FN может быть представлено в виде 64 -разрядного целого числа?
5.39. Нарисуйте дерево, которое соответствует рис. 5.15 для случая, когда рекурсивные вызовы в программе 5.11 поменяны местами.
5.40. Напишите функцию, которая использует восходящее динамическое программирование для вычисления значения PN, определяемого рекуррентным соотношением
Pn = LN/ 2j + P Ln/ 2j + P n2i, для N > 1, при P0 = 0.
Нарисуйте график зависимости PN - N IgN/ 2 от N для 0 < N < 1024.
5.41. Напишите функцию, в которой восходящее динамическое программирование используется для решения упражнения 5.40.
5.42. Нарисуйте дерево, которое соответствует рис. 5.15 для функции из упражнения 5.41 при вызове с аргументом N = 23.
5.43. Нарисуйте график зависимости от N количества рекурсивных вызовов, выполняемых функцией из упражнения 5.41 для вычисления PN при 0 < N < 1024. (При этом для каждого значения N программа должна запускаться заново.)
5.44. Напишите функцию, в которой восходящее динамическое программирование используется для вычисления значения CN, определяемого рекуррентным соотношением
CN = N + N ? (кj + CN_k), для N > 1, при C0 = 1.
N 1< к < N
5.45. Напишите функцию, в которой нисходящее динамическое программирование применяется для решения упражнения 5.44.
5.46. Нарисуйте дерево, которое соответствует рис. 5.15 для функции из упражнения 5.45 при вызове с аргументом N = 23.
5.47. Нарисуйте график зависимости от N количества рекурсивных вызовов, выполняемых функцией из упражнения 5.45 для вычисления CN при 0 < N < 1024. (При этом для каждого значения N программа должна запускаться заново.)
5.48. Приведите содержимое массивов maxKnown и itemKnown, вычисленное программой 5.13 для вызова knap(17) с элементами, приведенными на рис. 5.16.
5.49. Приведите дерево, соответствующее рис. 5.18, если элементы рассматриваются в порядке уменьшения их размеров.
5.50. Докажите лемму 5.3.
5.51. Напишите функцию, решающую задачу о ранце, с помощью варианта программы 5.12, в котором применяется восходящее динамическое программирование.
5.52. Напишите функцию, которая решает задачу о ранце методом нисходящего динамического программирования, но используя при этом рекурсивное решение, основанное на вычислении оптимального количества конкретного элемента, который должен быть помещен в ранец, когда (рекурсивно) известен оптимальный способ упаковки ранца без этого элемента.
5.53. Напишите функцию, которая решает задачу о ранце с помощью варианта рекурсивного решения, описанного в упражнении 5.52, в котором применяется восходящее динамическое программирование.
5.54. Воспользуйтесь динамическим программированием для решения упражнения 5.4. Наблюдайте за общим количеством сохраняемых вызовов функций.
5.55. Напишите программу для вычисления биномиального коэффициента I I с помощью нисходящего динамического программирования, исходя из рекуррентного соотношения
Деревья
Деревья - это математическая абстракция, играющая главную роль при разработке и анализе алгоритмов, поскольку
- деревья используются для описания динамических свойств алгоритмов;
- часто создаются и используются структуры данных, которые являются конкретными реализациями деревьев.
Мы уже встречались с примерами обоих применений деревьев. В лекция №1 были разработаны алгоритмы для решения задачи связности, которые основаны на древовидных структурах, а в разделах 5.2 и 5.3 структура вызовов рекурсивных алгоритмов была описана с помощью древовидных структур.
Мы часто встречаемся с деревьями в повседневной жизни - это основное понятие очень хорошо знакомо. Например, многие люди графически обозначают связь предков и наследников в виде генеалогического дерева; как мы увидим, значительная часть терминов заимствована именно из этой области. Еще один пример - организация спортивных турниров; в частности, исследованием этого применения занимался Льюис Кэрролл. В качестве третьего примера можно привести организационную диаграмму большой корпорации; это применение напоминает иерархическое разделение, характерное для алгоритмов "разделяй и властвуй ". Четвертым примером служит дерево синтаксического разбора предложения английского (или любого другого языка) на составляющие его части; такие деревья тесно связаны с обработкой компьютерных языков, как описано в части V. Типичный пример дерева - в данном случае описывающего структуру глав этой книги - показан на рис. 5.19. Далее в книге нам встретится и множество других примеров применения деревьев.
Одно из наиболее известных применений древовидных структур в компьютерных приложениях - организация файловых систем. Файлы хранятся в каталогах (иногда называемых также папками), которые рекурсивно определяются как последовательности каталогов и файлов. Это рекурсивное определение снова отражает естественное рекурсивное разбиение на составляющие и идентично определению определенного типа дерева.
Существует множество различных типов деревьев, и важно понимать различие между абстракцией и конкретным представлением, с которым выполняется работа для данного приложения. Поэтому мы подробно рассмотрим различные типы деревьев и их представления. Рассмотрение начнется с определения деревьев как абстрактных объектов и с введения большей части основных связанных с ними терминов. Мы неформально рассмотрим различные необходимые нам типы деревьев в порядке сужения этого понятия:
- Деревья
- Деревья с корнем
- Упорядоченные деревья
- M -арные и бинарные деревья
После этого неформального рассмотрения мы перейдем к формальным определениям и рассмотрим различные представления и применения. На рис. 5.20 показаны многие из этих базовых концепций, которые будут сначала рассмотрены, а затем и определены.
Дерево (tree) - это непустая коллекция вершин и ребер, удовлетворяющих определенным требованиям. Вершина (vertex) - это простой объект (называемый также узлом (node)), который может иметь имя и содержать другую связанную с ним информацию; ребро (edge) - это связь между двумя вершинами. Путь (path) в дереве - это список отдельных вершин, в котором последовательные вершины соединены ребрами дерева. Определяющее свойство дерева - существование только одного пути, соединяющего любые два узла. Если между какой -либо парой узлов существует более одного пути, или если между какой -либо парой узлов путь отсутствует, то это граф, а не дерево. Несвязанное множество деревьев называется лесом (forest).
Дерево с корнем (rooted) - это дерево, в котором один узел назначен корнем (root) дерева. В компьютерных науках термин дерево обычно применяется к деревьям с корнем, а термин свободное дерево (free tree) - к более общим структурам, описанным в предыдущем абзаце. В дереве с корнем любой узел является корнем поддерева, состоящего из него и расположенных под ним узлов.

Рис. 5.19. Дерево
Это дерево описывает части, главы и соответствующие им разделы данной книги. Каждый элемент представлен узлом. Каждый узел связан нисходящими связями с составляющими его частями и восходящей связью - с большей частью, к которой он принадлежит.

Рис. 5.20. Типы деревьев
На этих схемах приведены примеры бинарного дерева (вверху слева), тернарно го дерева (вверху справа), дерева с корнем (внизу слева) и свободного дерева (внизу справа).
Существует только один путь между корнем и каждым из других узлов дерева. Данное определение никак не определяет направление ребер; и в зависимости от конкретной ситуации можно считать, что все ребра указывают или от корня, или к корню. Обычно деревья рисуются с корнем вверху (хотя поначалу это соглашение кажется неестественным) и говорят, что узел у располагается под узлом x (а x располагается над у), если x находится на пути от у к корню (т.е., у находится под х, как нарисовано на странице, и соединяется с х путем, который не проходит через корень). Каждый узел (за исключением корня) имеет только один узел над ним, который называется его родительским узлом (parent); узлы, расположенные непосредственно под данным узлом, называются его дочерними узлами (child). Иногда аналогия с генеалогическими деревьями распространяется дальше, и тогда говорят о " бабушках " (grand parent) или " сестрах " (sibling) данного узла.
Узлы, не имеющие дочерних узлов, называются листьями (leaf) или терминальными (оконечными, terminal) узлами. И, соответственно, узлы, имеющие хотя бы один дочерний узел, иногда называются нетерминальными (nonterminal) узлами. В этой главе мы уже встречались с различным применением этих типов узлов. В деревьях, которые использовались для представления структуры вызовов рекурсивных алгоритмов (например, на рис. 5.14), нетерминальные узлы (кружки) представляют вызовы функций с рекурсивными вызовами, а терминальные узлы (квадратики) представляют вызовы функций без рекурсивных вызовов.
В некоторых приложениях способ упорядочения дочерних узлов каждого узла имеет значение; в других это не важно. Упорядоченное (ordered) дерево - это дерево с корнем, в котором определен порядок следования дочерних узлов каждого узла. Упорядоченные деревья - естественное представление: ведь при рисовании дерева дочерние узлы размещаются в определенном порядке. Действительно, многие другие конкретные представления имеют аналогично предполагаемый порядок; например, обычно это различие имеет значение при работе с компьютерными представлениями деревьев.
Если каждый узел должен иметь конкретное количество (M) дочерних узлов, расположенных в конкретном порядке, мы имеем M -арное дерево. В таком дереве часто можно определить специальные внешние узлы, которые не имеют дочерних узлов. Тогда внешние узлы можно задействовать в качестве фиктивных, чтобы на них ссылались узлы, не имеющие должного количества дочерних узлов. В частности, простейшим типом M -арного дерева является бинарное дерево. Бинарное дерево (binary tree) - это упорядоченное дерево, состоящее из узлов двух типов: внешних узлов без дочерних узлов и внутренних узлов, каждый из которых имеет ровно два дочерних узла. Поскольку два дочерних узла каждого внутреннего узла упорядочены, можно говорить о левом дочернем узле (left child) и правом дочернем узле (right child) внутренних узлов. Каждый внутренний узел должен иметь и левый, и правый дочерние узлы, хотя один из них или оба могут быть внешними узлами. Лист в M -арном дереве - это внутренний узел, все дочерние узлы которого являются внешними.
Все это общая терминология. Далее рассматриваются формальные определения, представления и приложения в порядке расширения понятий:
- бинарные и M -арные деревья
- упорядоченные деревья
- деревья с корнем
- свободные деревья
Начнем с наиболее специфической абстрактной структуры - как мы увидим, это позволит подробно рассмотреть конкретные представления.
Определение 5.1. Бинарное дерево - это либо внешний узел, либо внутренний узел, связанный с (упорядоченной - прим. перев.) парой бинарных деревьев, которые называются левым и правым поддеревьями этого узла.
Из этого определения понятно, что само бинарное дерево - абстрактное математическое понятие. При работе с компьютерным представлением мы работаем лишь с одной конкретной реализацией этой абстракции. Эта ситуация не отличается от представления действительных чисел значениями типа float, целых чисел значениями типа int и т.д. Когда мы рисуем дерево с узлом в корне, связанным ребрами с левым поддеревом, расположенным слева, и с правым поддеревом, расположенным справа, мы выбираем удобное конкретное представление. Существует множество различных способов представления бинарных деревьев (см., например, упражнение 5.62), которые поначалу несколько шокируют, но если учесть абстрактный характер определения, они вполне допустимы.
При реализации программ, использующих и обрабатывающих бинарные деревья, чаще всего применяется следующее конкретное представление - структура с двумя ссылками (левой и правой) для внутренних узлов (см. рис. 5.21). Эти структуры похожи на связные списки, но имеют по две ссылки в каждом узле, а не по одной. Пустые ссылки соответствуют внешним узлам. Так что мы просто добавили ссылку в стандартное представление связного списка, приведенное в разделе 3.3 лекция №3:
struct node { Item item; node *l, *r; } typedef node *link;
Это просто код C++ для определения 5.1. Узлы состоят из элементов и пар указателей на узлы; указатели на узлы называются также ссылками. Так, например, абстрактная операция переход к левому поддереву реализуется с помощью обращения через указатель наподобие x = x ->l.
Это стандартное представление позволяет построить эффективную реализацию операций, в которых нужны перемещения по дереву вниз от корня, но не перемещения по дереву вверх от дочернего узла к его родительскому узлу. Для алгоритмов, где требуются такие операции, можно добавить в каждый узел третью ссылку, направленную к его родительскому узлу. Эта альтернатива аналогична двухсвязным спискам. Как и в случае со связными списками (см. рис. 3.6), в некоторых ситуациях удобнее хранить узлы дерева в массиве и использовать в качестве ссылок индексы, а не указатели. Конкретный пример такой реализации будет рассмотрен в разделе 12.7 лекция №12. Для определенных специальных алгоритмов используются и другие представления бинарных деревьев - мы будем обращаться к ним в основном в лекция №9.

Рис. 5.21. Представление бинарного дерева
В стандартном представлении бинарного дерева используются узлы с двумя ссылками: левая указывает на левое поддерево, а правая - на правое поддерево. Пустые ссылки соответствуют внешним узлам.
Из -за наличия такого множества различных возможных представлений можно было бы разработать АТД бинарного дерева, который позволяет инкапсулировать важные для нас операции и отделить использование от реализации этих операций. В данной книге такой подход не используется, поскольку
- чаще всего будет использоваться представление с двумя ссылками;
- деревья будут применяться для реализации АТД более высокого уровня, и мы хотим сосредоточить внимание на этих АТД;
- эффективность алгоритмов может зависеть от конкретного представления - это обстоятельство может быть упущено в АТД.
Это те же причины, по которым мы используем знакомые конкретные представления массивов и связных списков. Представление бинарного дерева, показанное на рис. 5.21 - один из фундаментальных инструментов, который теперь добавлен к этому краткому списку.
Изучение связных списков мы начали с рассмотрения элементарных операций вставки и удаления узлов (см. рис. 3.3 и рис. 3.4). При использовании стандартного представления бинарных деревьев такие операции совсем не обязательно будут элементарными из -за наличия второй ссылки. Если нужно удалить узел из бинарного дерева, то возникает принципиальная проблема наличия двух дочерних узлов, с которыми нужно что -то делать после удаления узла, и только одного родительского. Существуют три естественных операции, для которых подобное осложнение не возникает: вставка нового узла в нижнюю часть дерева (замена пустой ссылки ссылкой на новый узел), удаление листа (замена ссылки на него пустой ссылкой) и объединение двух деревьев посредством создания нового корня, левая ссылка которого указывает на одно дерево, а правая - на другое. Эти операции интенсивно используются при работе с бинарными деревьями.
Определение 5.2. M -арное дерево - это либо внешний узел, либо внутренний узел, связанный с упорядоченной последовательностью M деревьев, которые также являются M -арными деревьями.
Обычно узлы в M -арных деревьях представляются либо в виде структур с M именованными ссылками (как в бинарных деревьях), либо в виде массивов M ссылок. Например, в главе 15 лекция №15 рассмотрены 3 -арные (или тернарные) деревья, в которых используются структуры с тремя именованными ссылками (левой, средней и правой), каждая из которых имеет специальное значение для связанных с этими деревьями алгоритмов. В остальных случаях вполне годится хранение ссылок в массивах, поскольку значение M фиксировано - хотя, как мы увидим, при использовании такого представления нужно внимательно следить за объемом использованной памяти.
Определение 5.3. Дерево (называемое также упорядоченным деревом) - это узел (называемый корнем), связанный с последовательностью несвязанных деревьев. Такая последовательность называется лесом.
Различие между упорядоченными деревьями и M -арными деревьями состоит в том, что узлы в упорядоченных деревьях могут иметь любое количество дочерних узлов, а узлы в M -арных деревьях должны иметь точно M дочерних узлов. Иногда, если нужно различать упорядоченные и M -арные деревья, используется термин обобщенное дерево (general tree).
Поскольку каждый узел в упорядоченном дереве может иметь любое количество ссылок, для хранения ссылок на дочерние узлы естественно использовать связный список, а не массив. Пример такого представления приведен на рис. 5.22. Из этого примера видно, что каждый узел содержит две ссылки: одну для связного списка, соединяющего его с сестринскими узлами, и вторую для связного списка его дочерних узлов.

Рис. 5.22. Представление дерева
Представление упорядоченного дерева с помощью связного списка дочерних узлов каждого узла эквивалентно его представлению в виде двоичного дерева. На схеме справа вверху показано представление в виде связного списка дочерних узлов для дерева, показанного слева вверху. Этот список реализован в правых ссылках узлов, а левая ссылка каждого узла указывает на первый узел в связном списке его дочерних узлов. На схеме справа внизу приведена несколько измененная версия верхней схемы; она представляет бинарное дерево, изображенное слева внизу. Таким образом, бинарное дерево можно рассматривать в качестве представления обобщенного дерева.
Лемма 5.4. Существует взаимно однозначное соответствие между бинарными деревьями и упорядоченными лесами.
Это соответствие показано на рис. 5.22. Любой лес можно представить в виде бинарного дерева, в котором левая ссылка каждого узла указывает на его левый дочерний узел, а правая ссылка каждого узла - на сестринский узел, расположенный справа.
Определение 5.4. Дерево с корнем (или неупорядоченное дерево) - это узел (называемый корнем), связанный с мультимножеством деревьев с корнем. (Такое мультимножество называется неупорядоченным лесом.)
Деревья, с которыми мы встречались в лекция №1, посвященной задаче связности, являются неупорядоченными деревьями. Такие деревья могут быть определены как упорядоченные деревья, в которых порядок расположения дочерних узлов узла неважен. Неупорядоченные деревья можно определить и в виде множества отношений "роди -тельский -дочерний " между узлами. Может показаться, что этот вариант имеет слабое отношение к рассматриваемым рекурсивным структурам, но, возможно, такое конкретное представление наиболее соответствует абстрактному понятию дерева.
Неупорядоченное дерево можно представить в компьютере упорядоченным деревом; нужно лишь осознавать, что одно и то же неупорядоченное дерево может быть представлено несколькими различными упорядоченными деревьями. Хотя обратная задача - определение того, представляют ли два различные упорядоченные дерева одно и то же неупорядоченное дерево (задача изоморфизма деревьев) - подается решению с трудом.
Наиболее общим видом деревьев является дерево, в котором не выделен корневой узел. Например, этим свойством обладают остовные деревья, полученные в результате работы алгоритмов связности из лекция №1. Для правильного определения неупорядоченных деревьев без корня, т.е. свободных деревьев, потребуется начать с определения графов (graph). Определение 5.5. Граф - это множество узлов вместе с множеством ребер, которые соединяют пары отдельных узлов (причем любая пара узлов соединяется не более чем одним ребром).
Из любого узла можно перейти вдоль ребра до другого узла, от этого - к следующему и т.д. Последовательность ребер, ведущая от одного узла до другого, когда ни один узел не посещается дважды, называется простым путем (simple path). Граф является связным (connected), если для любой пары узлов существует связывающий их простой путь. Простой путь, у которого первый и последний узел совпадают, называется циклом (cycle).
Каждое дерево является графом, а какие же графы являются деревьями? Граф считается деревом, если он удовлетворяет любому из следующих четырех условий:
- Граф имеет N - 1 ребер и ни одного цикла.
- Граф имеет N - 1 ребер и является связным.
- Каждую пару вершин в графе соединяет только один простой путь.
- Граф является связным, но перестает быть таковым при удалении любого ребра.
Любое из этих условий - необходимое и достаточное условие для выполнения остальных трех. Формально для определения свободного дерева следует выбрать одно из них; но мы, отбросив формальности, считаем определением все условия вместе.
Свободное дерево представляется просто как коллекция ребер. Если представлять свободное дерево неупорядоченным, упорядоченным или даже бинарным деревом, то необходимо учитывать, что в общем случае существует множество различных способов представления любого свободного дерева.
Абстракция дерева используется часто, и рассмотренные в этом разделе различия важны, поскольку знание различных абстракций деревьев зачастую существенно влияет на выбор эффективного алгоритма и соответствующих структур данных для решения данной задачи. Бывает, что приходится работать непосредственно с конкретными представлениями деревьев без учета конкретной абстракции, но зачастую имеет смысл поработать с подходящей абстракцией дерева, а затем рассмотреть различные конкретные представления. В данной книге приведено множество примеров этого процесса.
Прежде чем вернуться к алгоритмам и реализациям, мы рассмотрим ряд основных математических свойств деревьев; эти свойства будут использоваться при разработке и анализе алгоритмов на деревьях.
Упражнения
5.56. Приведите представления свободного дерева, показанного на рис. 5.20, в форме дерева с корнем и бинарного дерева.
5.57. Определите количество различных способов представления свободного дерева, показанного на рис. 5.20, в форме упорядоченного дерева.
5.58. Нарисуйте три упорядоченных дерева, которые изоморфны упорядоченному дереву, показанному на рис. 5.20. Это значит, должна существовать возможность преобразования всех четырех деревьев одного в другое путем обмена дочерних узлов.
5.59. Допустим, деревья содержат элементы, для которых определена операция ==. Напишите рекурсивную программу, которая удаляет в бинарном дереве все листья, содержащие элементы, равные данному (см. программу 5.5).
5.60. Измените функцию вида "разделяй и властвуй " для поиска максимального элемента в массиве (программа 5.6), чтобы она делила массив на к частей, размер которых различался бы не более чем на 1, рекурсивно находила максимум в каждой части и возвращала наибольший из этих максимумов.
5.61. Нарисуйте 3 -арные и 4 -арные деревья для случаев к = 3 и к = 4 в рекурсивной конструкции, предложенной в упражнении 5.60, для массива, состоящего из 11 элементов (см. рис. 5.6).
5.62. Бинарные деревья эквивалентны двоичным строкам, в которых нулевых битов на 1 больше, чем единичных, при соблюдении дополнительного ограничения, что в любой позиции к количество нулевых битов слева от к не больше количества единичных битов слева от к. Бинарное дерево - это либо строка, состоящая только из нуля, либо две таких строки, объединенные вместе, которым предшествует 1. Нарисуйте бинарное дерево, соответствующее строке
1110010110001011000
5.63. Упорядоченные деревья эквивалентны согласованным строкам из пар круглых скобок: упорядоченное дерево - это либо пустая строка, либо последовательность упорядоченных деревьев, заключенных в круглые скобки. Нарисуйте упорядоченное дерево, соответствующее строке
( ( ( ) ( ( ) ( ) ) ( ) ) ( ( ) ( ) ( ) ) )
5.64. Напишите программу для определения того, представляют ли два массива N целых чисел от 0 до N - 1 изоморфные неупорядоченные деревья, если интерпретировать их (как в лекция №1) как ссылки из родительских узлов на дочерние в дереве с узлами, пронумерованными от 0 до N - 1. То есть программа должна определять, существует ли способ изменения нумерации узлов в одном дереве, чтобы представление в виде массива одного дерева было идентичным представлению в виде массива другого дерева.
5.65. Напишите программу для определения того, являются ли два бинарных дерева изоморфными неупорядоченными деревьями.
5.66. Нарисуйте все упорядоченные деревья, которые могли бы представлять дерево, определенное набором ребер 0 -1, 1 -2, 1 -3, 1 -4, 4 -5.
5.67. Докажите, что если в связном графе, состоящем из N узлов, удаление любого ребра влечет за собой разъединение графа, то в нем N - 1 ребер и ни одного цикла.
Математические свойства бинарных деревьев
Прежде чем приступить к рассмотрению алгоритмов обработки деревьев, продолжим математическое исследование базовых свойств деревьев. Мы сосредоточим внимание на бинарных деревьях, поскольку они используются в книге чаще других. Понимание их основных свойств послужит фундаментом для понимания характеристик производительности различных алгоритмов, с которыми мы встретимся - не только тех, в которых бинарные деревья используются в качестве явных структур данных, но и рекурсивных алгоритмов "разделяй и властвуй ", и других аналогичных применений.
Лемма 5.5. Бинарное дерево с N внутренними узлами имеет N + 1 внешних узлов. Эта лемма доказывается методом индукции: бинарное дерево без внутренних узлов имеет один внешний узел, следовательно, для N = 0 лемма справедлива. Для N > 0 любое бинарное дерево с N внутренними узлами имеет к внутренних узлов в левом поддереве и N - 1 - к внутренних узлов в правом поддереве для некоторого к в диапазоне между 0 и N - 1, поскольку корень является внутренним узлом. В соответствии с индуктивным предположением левое поддерево имеет к + 1 внешних узлов, а правое поддерево - N - к внешних узлов, что в сумме составляет N + 1. ¦
Лемма 5.6. Бинарное дерево с N внутренними узлами имеет 2N ссылок: N - 1 ссылок на внутренние узлы и N + 1 ссылок на внешние узлы.
В любом дереве с корнем каждый узел, за исключением корня, имеет единственный родительский узел, и каждое ребро соединяет узел с его родительским узлом; следовательно, существует N - 1 ссылок, соединяющих внутренние узлы. Аналогично, каждый из N + 1 внешних узлов имеет одну ссылку на свой единственный родительский узел.
Характеристики производительности многих алгоритмов зависят не только от количества узлов в связанных с ними деревьях, но и от различных структурных свойств.
Определение 5.6. Уровень (level) узла в дереве - число, на единицу большее уровня его родительского узла (корень размещается на уровне 0). Высота (height) дерева - максимальный из уровней узлов дерева. Длина пути (path length) дерева - сумма уровней всех узлов дерева. Длина внутреннего пути (internal path length) бинарного дерева - сумма уровней всех внутренних узлов дерева. Длина внешнего пути (external path length) бинарного дерева - сумма уровней всех внешних узлов дерева.
Удобный способ вычисления длины пути дерева заключается в суммировании произведений k на число узлов на уровне к для всех натуральных чисел k.
Для этих величин существуют также простые рекурсивные определения, вытекающие непосредственно из рекурсивных определений деревьев и бинарных деревьев. Например, высота дерева на 1 больше максимальной высоты поддеревьев его корня, а длина пути дерева с N узлами равна сумме длин путей поддеревьев его корня плюс N - 1. Приведенные величины также непосредственно связаны с анализом рекурсивных алгоритмов. Например, для многих рекурсивных вычислений высота соответствующего дерева в точности равна максимальной глубине рекурсии, то есть размеру стека, необходимого для поддержки вычисления.
Лемма 5.7. Длина внешнего пути любого бинарного дерева, имеющего N внутренних узлов, на 2N больше длины внутреннего пути.
Эту лемму можно было бы доказать методом индукции, но существует другое, более наглядное, доказательство (которое применимо и для доказательства леммы 5.6). Обратите внимание, что любое бинарное дерево может быть создано при помощи следующего процесса. Начинаем с бинарного дерева, состоящего из одного внешнего узла. Затем повторяем N раз следующее: выбираем внешний узел и заменяем его новым внутренним узлом с двумя дочерними внешними узлами. Если выбранный внешний узел находится на уровне к, длина внутреннего пути увеличивается на к, но длина внешнего пути увеличивается на к + 2 (удаляется один внешний узел на уровне к, но добавляются два на уровне к + 1). Этот процесс начинается с дерева, длина внутреннего и внешнего путей которого равны 0, и на каждом из N шагов длина внешнего пути увеличивается на 2 больше, чем длина внутреннего пути.
Лемма 5.8. Высота бинарного дерева с N внутренними узлами не меньше lgN и не больше N - 1.
Худший случай - вырожденное дерево, имеющее только один лист и N - 1 ссылок от корня до этого листа (см. рис. 5.23). В лучшем случае мы имеем уравновешенное дерево с 2i внутренними узлами на каждом уровне i, за исключением самого нижнего (см. рис. 5.23). Если его высота равна h, то должно быть справедливо соотношение

поскольку существует N + 1 внешних узлов. Из этого неравенства и следует данная лемма: в лучшем случае высота равна lgN, округленному до ближайшего целого числа.
Лемма 5.9. Длина внутреннего пути бинарного дерева с N внутренними узлами не меньше чем N lg(N/ 4) и не превышает N(N - 1)/ 2.
Худший и лучший случай соответствуют тем же деревьям, которые упоминались при доказательстве леммы 5.8 и показаны на рис. 5.23. В худшем случае длина внутреннего пути дерева равна 0 + 1 + 2 + ... + (N - 1) = N(N - 1)/2. В лучшем случае дерево имеет N + 1 внешних узлов при высоте, не превышающей . Перемножив эти значения и применив лемму 5.7, получим предельное значение
.
Как мы увидим, бинарные деревья часто используются в компьютерных приложениях, и максимальная производительность достигается тогда, когда бинарные деревья полностью (или почти) сбалансированы. Например, деревья, которые использовались для описания алгоритмов "разделяй и властвуй ", подобных бинарному поиску и сортировке слиянием, полностью сбалансированы (см. упражнение 5.74).
В лекция №9 и лекция №13 будут рассмотрены структуры данных, основанные на уравновешенных деревьях.
Эти основные свойства деревьев предоставляют информацию, необходимую для разработки эффективных алгоритмов решения многих практических задач. Более подробный анализ нескольких особых алгоритмов, с которыми придется встретиться, требует сложных математических выкладок, хотя часто полезные приближенные оценки можно получить с помощью простых индуктивных рассуждений, подобных использованным в этом разделе. В последующих главах мы продолжим рассмотрение математических свойств деревьев по мере возникновения необходимости. А пока можно вернуться к теме алгоритмов.

Рис. 5.23. Три бинарных дерева с 10 внутренними узлами
Бинарное дерево, показанное вверху, имеет высоту 7, длину внутреннего пути 31 и длину внешнего пути 51. Полностью сбалансированное бинарное дерево (в центре) с 10 внутренними узлами имеет высоту 4, длину внутреннего пути 19 и длину внешнего пути 39 (ни одно двоичное дерево с 10узлами не может иметь меньшее значение любого из этих параметров). Вырожденное дерево (внизу) с 10 внутренними узлами имеет высоту 10, длину внутреннего пути 45 и длину внешнего пути 65 (ни одно бинарное дерево с 10 узлами не может иметь большее значение любого из этих параметров).
Упражнения
5.68. Сколько внешних узлов существует в M -арном дереве с N внутренними узлами? Используйте свой ответ для определения объема памяти, необходимого для представления такого дерева, если считать, что каждая ссылка и каждый элемент занимает одно слово памяти.
5.69. Приведите верхнее и нижнее граничные значения высоты M -арного дерева с N внутренними узлами.
5.70. Приведите верхнее и нижнее граничные значения длины внутреннего пути M -арного дерева с N внутренними узлами.
5.71. Приведите верхнее и нижнее граничные значения количества листьев в бинарном дереве с N узлами.
5.72. Покажите, что если уровни внешних узлов в бинарном дереве различаются на константу, то высота дерева составляет O(logN).
5.73. Дерево Фибоначчи высотой n > 2 - это бинарное дерево с деревом Фибоначчи высотой n - 1 в одном поддереве и дерево Фибоначчи высотой n - 2 - в другом. Дерево Фибоначчи высотой 0 - это единственный внешний узел, а дерево Фибоначчи высотой 1 - единственный внутренний узел с двумя внешними дочерними узлами (см. рис. 5.14). Выразите высоту и длину внешнего пути для дерева Фибоначчи высотой n в виде функции от N (количество узлов в дереве).
5.74. Дерево вида "разделяй и властвуй ", состоящее из N узлов - это бинарное дерево с корнем, обозначенным N, деревом "разделяй и властвуй " из узлов в одном поддереве и деревом "разделяй и властвуй " из
узлов в другом. (Дерево "разделяй и властвуй " показано на рис. 5.6.) Нарисуйте дерево "разделяй и властвуй " с 11, 15, 16 и 23 узлами.
5.75. Докажите методом индукции, что длина внутреннего пути дерева вида "разделяй и властвуй " находится в пределах между N lgN и N lgN + N.
5.76. Дерево вида "объединяй и властвуй ", состоящее из N узлов - это бинарное дерево с корнем, обозначенным N, деревом "объединяй и властвуй " из узлов в одном поддереве и деревом "объединяй и властвуй " из
узлов в другом (см. упражнение 5.18). Нарисуйте дерево "объединяй и властвуй " с 11, 15, 16 и 23 узлами.
5.77. Докажите методом индукции, что длина внутреннего пути дерева вида "объединяй и властвуй " находится в пределах между N lgN и N lgN + N.
5.78. Полное (complete) бинарное дерево - это дерево, в котором заполнены все уровни, кроме, возможно, последнего, который заполняется слева направо, как показано на рис. 5.24. Докажите, что длина внутреннего пути полного дерева с N узлами лежит в пределах между N lgN и N lgN + N.

Рис. 5.24. Полные бинарные деревья с семью и десятью внутренними узлами
Если количество внешних узлов является степенью 2 (верхний рисунок), то все внешние узлы в полном бинарном дереве находятся на одном уровне. В противном случае (нижний рисунок) внешние узлы располагаются на двух уровнях, причем внутренние узлы находятся слева от внешних узлов предпоследнего уровня.
Обход дерева
Прежде чем приступить к изучению алгоритмов, в которых создаются бинарные деревья и деревья общего вида, рассмотрим алгоритмы для реализации самой основной функции обработки деревьев - обхода дерева (tree traversal): имея указатель на дерево, требуется систематически обработать все узлы в дереве. В связном списке переход от одного узла к другому выполняется по единственной ссылке; однако в случае деревьев придется принимать решения, поскольку может существовать несколько ссылок для перехода.
Начнем рассмотрение с обхода бинарных деревьев.
В случае со связными списками имелись две основные возможности (см. программу 5.5): обработать узел, а затем перейти по ссылке (посещение узлов в прямом порядке), или перейти по ссылке связи, а затем обработать узел (в этом случае узлы посещаются в обратном порядке). Узлы бинарных деревьев содержат две ссылки и, следовательно, возможны три основных порядка посещения узлов:
- Прямой обход (сверху вниз), при котором посещается узел, а затем левое и правое поддеревья
- Поперечный обход (слева направо), при котором посещается левое поддерево, потом узел, а затем правое поддерево
- Обратный обход (снизу вверх), при котором посещаются левое и правое поддеревья, а затем узел.
Эти методы можно легко реализовать с помощью рекурсивной программы (программа 5.14), которая является непосредственным обобщением программы 5.5 обхода связного списка. Для реализации обходов в другом порядке достаточно соответствующим образом переставить вызовы функций в программе 5.14. Примеры посещения узлов дерева при использовании каждого из порядков обхода показаны на рис. 5.26. На рис. 5.25 приведена последовательность вызовов функций, которые выполняются при вызове программы 5.14 для дерева из рис. 5.26.

Рис. 5.25. Вызовы функций при прямом обходе
Эта последовательность вызовов функций определяет прямой обход для примера дерева, показанного на рис. 5.26.
С этими базовыми рекурсивными процессами, на которых основываются различные методы обхода дерева, мы уже встречались в рекурсивных программах вида "разделяй и властвуй " (см. рис. 5.8 и 5.11) и в арифметических выражениях. Например, выполнение прямого обхода соответствует рисованию вначале метки на линейке, а затем выполнению рекурсивных вызовов (см. рис. 5.11); выполнение поперечного обхода соответствует перекладыванию самого большого диска в решении задачи о ханойских башнях между рекурсивными вызовами, которые перемещают все остальные диски; выполнение обратного обхода соответствует вычислению постфиксных выражений и т.д.
Программа 5.14. Рекурсивный обход дерева
Эта рекурсивная функция принимает в качестве аргумента ссылку на дерево и вызывает функцию visit для каждого из узлов дерева. В приведенном виде функция реализует прямой обход; если поместить вызов visit между рекурсивными вызовами, получится поперечный обход; а если поместить обращение к visit после рекурсивных вызовов - то обратный обход.
void traverse(link h, void visit(link)) { if (h == 0) return; visit(h); traverse(h ->l, visit); traverse(h ->r, visit); }

Рис. 5.26. Порядки обхода дерева
Эти последовательности показывают порядок посещения узлов для прямого (слева), поперечного (в центре) и обратного (справа) обхода дерева.
Эти соответствия позволяют гораздо лучше понять суть механизмов, лежащих в основе обхода дерева. Например, известно, что при поперечном обходе каждый второй узел является внешним - по той же причине, по какой при решении задачи о ханойских башнях каждое второе перемещение является перекладыванием маленького диска.
Полезно также рассмотреть нерекурсивные реализации, в которых используется явный стек. Для простоты мы начнем с рассмотрения абстрактного стека, который может содержать как узлы, так и деревья, и вначале содержит дерево, которое нужно обойти. Затем выполняется цикл, в котором выталкивается и обрабатывается верхний элемент стека, и который продолжается, пока стек не опустеет. Если вытолкнутое содержимое является элементом, мы посещаем его; а если это дерево, мы выполняем последовательность операций вталкивания, которая зависит от требуемого порядка:
- При прямом обходе заносится правое поддерево, затем левое поддерево, а затем узел.
- При поперечном обходе заносится правое поддерево, затем узел, а затем левое поддерево.
- При обратном обходе заносится узел, затем правое поддерево, а затем левое поддерево.
Пустые деревья в стек не заносятся. На рис. 5.27 показано содержимое стека при использовании каждого из этих трех методов для обхода дерева, приведенного на рис. 5.26. Нетрудно убедиться (методом индукции), что для любого бинарного дерева этот метод приводит к такому же результату, как и рекурсивный метод.
В предыдущем абзаце описана концептуальная схема, охватывающая три метода обхода дерева, однако реализации, используемые на практике, несколько проще.

Рис. 5.27. Содержимое стека для алгоритмов обхода дерева
Эти последовательности отражают содержимое стека при прямом (слева), поперечном (в центре) и обратном (справа) обходе дерева (см. рис. 5.26) для идеализированной модели вычислений, аналогичной использованной в примере на рис. 5.5, когда элемент и два его поддерева помещаются в стек в указанном порядке.
Например, при выполнении прямого обхода узлы заносить в стек не обязательно (поскольку вначале посещается корень каждого выталкиваемого дерева). Поэтому можно воспользоваться простым стеком, который содержит элементы только одного типа (ссылки на деревья), как это сделано в нерекурсивной реализации в программе 5.15. Системный стек, поддерживающий работу рекурсивной программы, содержит адреса возврата и значения аргументов, а не элементы или узлы, но фактическая последовательность выполнения вычислений (посещения узлов) для рекурсивного метода и метода с использованием стека остается одинаковой.
Четвертая естественная стратегия обхода - просто посещение узлов дерева в порядке, в котором они нарисованы на странице - сверху вниз и слева направо. Этот метод называется обходом по уровням (level -order), поскольку все узлы каждого уровня посещаются вместе, по порядку. Посещение узлов дерева, показанного на рис. 5.26, при обходе по уровням показано на рис. 5.28.

Рис. 5.28. Обход по уровням
Эта последовательность показывает результат посещения узлов дерева в порядке сверху вниз и слева направо.
Интересно, что обход по уровням можно получить, заменив в программе 5.15 стек на очередь, что демонстрирует программа 5.16. Для реализации прямого обхода используется структура данных типа "последним вошел, первым вышел " (LIFO); для реализации обхода по уровням используется структура данных типа "первым вошел, первым вышел " (FIFO). Эти программы заслуживают внимательного изучения, поскольку они представляют существенно различающиеся подходы к организации оставшейся невыполненной работы. В частности, обход по уровням не соответствует рекурсивной реализации, связанной с рекурсивной структурой дерева.
Программа 5.15. Прямой обход (нерекурсивная реализация)
Эта нерекурсивная функция с использованием стека функционально эквивалентна ее рекурсивному аналогу - программе 5.14.
void traverse(link h, void visit(link)) { STACK<link> s(max); s.push(h); while (!s.empty()) { visit(h = s.pop()); if (h ->r != 0) s.push(h ->r); if (h ->l != 0) s.push(h ->l); } }
Программа 5.16. Обход по уровням
Замена структуры данных, лежащей в основе прямого обхода (см. программу 5.15), со стека на очередь дает обход по уровням.
void traverse(link h, void visit(link)) { QUEUE<link> q(max); q.put(h); while (!q.empty()) { visit(h = q.get()); if (h ->l != 0) q.put(h ->l); if (h ->r != 0) q.put(h ->r); } }
Прямой обход, обратный обход и обход по уровням можно определить и для лесов. Чтобы определения были единообразными, представьте себе лес в виде дерева с воображаемым корнем. Тогда правило для прямого обхода формулируется следующим образом: "посетить корень, а затем каждое из поддеревьев "; а правило для обратного обхода - "посетить каждое из поддеревьев, а затем корень ". Правило для обхода по уровням то же, что и для бинарных деревьев. Непосредственные реализации этих методов - примитивные обобщения программ прямого обхода с использованием стека (программы 5.14 и 5.15) и программы обхода по уровням с использованием очереди (программа 5.16) для бинарных деревьев, которые мы только что рассмотрели. Конкретные реализации не приводятся, поскольку в разделе 5.8 будет рассмотрена более общая процедура.
Упражнения
5.79. Приведите порядок посещения узлов для прямого, поперечного, обратного и обхода по уровням для следующих бинарных деревьев:

5.80. Приведите содержимое очереди во время обхода по уровням (программа 5.16) дерева с рис. 5.28 в стиле рис. 5.27.
5.81. Покажите, что прямой обход леса эквивалентен прямому обходу соответствующего бинарного дерева (см. лемму 5.4), а обратный обход леса эквивалентен поперечному обходу бинарного дерева.
5.82. Приведите нерекурсивную реализацию поперечного обхода.
5.83. Приведите нерекурсивную реализацию обратного обхода.
5.84. Напишите программу, которая принимает на входе прямой и поперечный обходы бинарного дерева и генерирует обход дерева по уровням.
Рекурсивные алгоритмы для бинарных деревьев
Алгоритмы обхода дерева, рассмотренные в разделе 5.6, наглядно демонстрируют необходимость изучения рекурсивных алгоритмов для бинарных деревьев, что обусловлено рекурсивной структурой этих деревьев. Для решения многих задач можно непосредственно применять рекурсивные алгоритмы вида "разделяй и властвуй ", которые, по сути, обобщают алгоритмы обхода деревьев. Обработка дерева сводится к обработке корневого узла и (рекурсивно) его поддеревьев, и вычисление можно выполнять перед, между или после рекурсивных вызовов (или же использовать все три метода).
Часто требуется определить различные структурные параметры дерева, имея только ссылку на него. Например, программа 5.17 содержит рекурсивные функции для вычисления количества узлов и высоты заданного дерева. Эти функции написаны непосредственно исходя из определения 5.6. Ни одна из этих функций не зависит от порядка обработки рекурсивных вызовов: они обрабатывают все узлы дерева и возвращают одинаковый результат для любого порядка рекурсивных вызовов. Не все параметры дерева вычисляются так легко: например, программа для эффективного вычисления длины внутреннего пути бинарного дерева более сложна (см. упражнения 5.88 - 5.90).
Еще одна функция, которая бывает нужна при создании программ, обрабатывающих деревья - функция, которая выводит структуру дерева или вычерчивает его. Например, программа 5.18 представляет собой рекурсивную процедуру, выводящую дерево в формате, приведенном на рис. 5.29. Эту же базовую рекурсивную схему можно использовать для вычерчивания более сложных представлений деревьев, подобных приведенным на рисунках в этой книге (см. упражнение 5.85).

Рис. 5.29. Вывод дерева (при поперечном и прямом обходе)
Левая часть получена в результате работы программы 5.18 с деревом, приведенным на рис. 5.26. В ней приведена структура дерева, подобная графическому представлению, которое используется в данной книге, но повернутая на 90 градусов. Правая часть получена в результате выполнения этой же программы, где оператор вывода перемещен в начало программы; здесь показана структура дерева в привычном схематическом формате.
Программа 5.18 выполняет поперечный обход, а если выводить элемент перед рекурсивными вызовами, получится прямой обход; этот вариант также приведен на рис. 5.29.
Программа 5.17. Вычисление параметров дерева
Для выяснения базовых структурных свойств дерева можно использовать такие рекурсивные процедуры.
int count(link h) { if (h == 0) return 0; return count(h ->l) + count(h ->r) + 1; } int height(link h) { if (h == 0) return -1; int u = height(h ->l), v = height(h ->r); if (u > v) return u+1; else return v+1; }
Программа 5.18. Функция быстрого вывода дерева
Эта рекурсивная программа отслеживает высоту дерева и использует эту информацию для подсчета отступов при выводе представления, которое можно использовать для отладки программ обработки деревьев (см. рис. 5.29). Здесь предполагается, что элементы в узлах имеют тип Item, для которого определена перегруженная операция <<.
void printnode(Item x, int h) { for (int i = 0; i < h; i++) cout << " "; cout << x << endl; } void show(link t, int h) { if (t == 0) { printnode('*', h); return; } show(t ->r, h+1); printnode(t ->item, h); show(t ->l, h+1); }
Такой формат применяется для вывода генеалогического дерева, списка файлов в древовидной файловой структуре или при создании структуры печатного документа. Например, прямой обход дерева, приведенного на рис. 5.19, выводит оглавление этой книги.
Первым примером программы, которая строит древовидную структуру, будет приложение определения максимума, которое было рассмотрено в разделе 5.2. Наша цель - построение турнира, т.е. бинарного дерева, в котором элементом каждого внутреннего узла является копия большего из элементов его двух дочерних элементов. Тогда элемент в корне является копией наибольшего элемента в турнире. Элементы в листья (узлах без дочерних узлов) содержат исследуемые данные, а остальная часть дерева - структура данных, которая позволяет эффективно находить наибольший из элементов.
Программа 5.19 - рекурсивная программа, которая строит турнир из элементов массива. Будучи расширенной версией программы 5.6, она использует стратегию "разделяй и властвуй ": чтобы построить турнир для единственного элемента, программа создает лист, содержащий этот элемент, и возвращает этот элемент. Чтобы построить турнир для N > 1 элементов, программа делит все множество элементов пополам, строит турнир для каждой половины и создает новый узел со ссылками на эти два турнира и с элементом, который является копией большего элемента в корнях обоих турниров.
На рис. 5.30 приведен пример древовидной структуры, построенной программой 5.19. Иногда построение таких рекурсивных структур бывает предпочтительнее отыскания максимума простым перебором данных, как это было сделано в программе 5.6, поскольку древовидная структура обеспечивает возможность выполнения других операций. Важным примером служит и сама операция, использованная для построения турнира. При наличии двух турниров их можно объединить в один турнир, создав новый узел, левая ссылка которого указывает на один турнир, а правая на другой, и приняв больший из двух элементов (в корнях двух данных турниров) в качестве наибольшего элемента объединенного турнира. Можно также рассмотреть алгоритмы добавления и удаления элементов и выполнения других операций. Здесь мы не станем рассматривать такие операции, поскольку аналогичные структуры данных с подобными функциями будут рассмотрены в лекция №9.
Вообще -то реализации с использованием деревьев для нескольких из АТД обобщенных очередей (рассмотренных в разделе 4.6 лекция №4) являются основной темой обсуждения в значительной части этой книги. В частности, многие из алгоритмов, приведенных в главах 12 - 15, основываются на деревьях бинарного поиска (binary search tree) - это деревья, соответствующие бинарному поиску, аналогично тому, как структура на рис. 5.30 соответствует рекурсивному алгоритму отыскания максимума (см. рис. 5.6). Сложность реализации и использования таких структур заключается в обеспечении их эффективности после выполнения большого числа операций вставить, удалить и т.п.
Вторым примером программы создания бинарного дерева служит измененная версия программы вычисления префиксного выражения из раздела 5.1 (программа 5.4), которая не просто вычисляет префиксное выражение, а создает представляющее его дерево (см. ррис. 5.31). В программе 5.20 используется та же рекурсивная схема, что и в программе 5.4, но рекурсивная функция возвращает не значение, а ссылку на дерево.
Программа создает новый узел дерева для каждого символа в выражении: узлы, которые соответствуют операциям, содержат ссылки на свои операнды, а листовые узлы содержат переменные (или константы), которые являются входными данными выражения.
Программа 5.19. Построение турнира
Данная рекурсивная функция делит массив a[1], ..., a[r] на две части a[1], ..., a[m] и a[m+1], ..., a[r], строит (рекурсивно) турниры для этих двух частей и создает турнир для всего массива, установив ссылки в новом узле на рекурсивно построенные турниры и поместив в него копию большего элемента из корней двух рекурсивно построенных турниров.
struct node { Item item; node *l, *r; node(Item x) { item = x; l = 0; r = 0; } }; typedef node* link; link max(Item a[], int l, int r) { int m = (l+r)/2; link x = new node(a[m]); if (l == r) return x; x ->l = max(a, l, m); x ->r = max(a, m+1, r); Item u = x ->l ->item, v = x ->r ->item; if (u > v) x ->item = u; else x ->item = v; return x; }

Рис. 5.30. Дерево для отыскания максимума (турнир)
На этом рисунке показана структура дерева, созданная программой 5.19 из входных данных A M P L E. Элементы данных находятся в листьях. Каждый внутренний узел содержит копию большего из элементов в двух дочерних узлах, так что по индукции наибольший элемент находится в корне.
В программах трансляции наподобие компиляторов часто используются такие внутренние представления программ, поскольку деревья удобны по многим соображениям. Например, можно представить операнды, которые соответствуют принимающим значения переменным, и сгенерировать машинный код для вычисления выражения, представленного в виде дерева с обратным обходом. А можно использовать дерево с поперечным обходом для вывода выражения в инфиксной форме или дерево с обратным обходом - для вывода выражения в постфиксной форме.
В этом разделе было рассмотрено несколько примеров, демонстрирующих тезис о возможности создания и обработки связных древовидных структур с помощью рекурсивных программ. Чтобы этот подход стал эффективным, потребуется учесть производительность различных алгоритмов, альтернативные представления, нерекурсивные варианты и ряд других нюансов. Однако мы отложим более подробное изучение программ обработки деревьев до лекция №12, поскольку в лекциях 7 - 11 деревья используются в основном в описательных целях. К явным реализациям деревьев мы вернемся в лекция №12, поскольку они служат основой для многих алгоритмов, рассматриваемых в лекциях 12 - 15 .
Программа 5.20. Создание дерева синтаксического анализа
Используя ту же стратегию, которая была задействована для вычисления префиксных выражений (см. программу 5.4), эта программа создает из префиксного выражения дерево синтаксического анализа. Для простоты предполагается, что операндами являются одиночные символы. Каждый вызов рекурсивной функции создает новый узел, передавая в него в качестве лексемы следующий символ из входных данных. Если лексема представляет собой операнд, программа возвращает новый узел, а если операцию, то устанавливает левый и правый указатели на деревья, построенные (рекурсивно) для двух аргументов.
char *a; int i; struct node { Item item; node *l, *r; node(Item x) { item = x; l = 0; r = 0; } }; typedef node* link; link parse() { char t = a[i++]; link x = new node(t); if ((t == '+') || (t == '*')) { x ->l = parse(); x ->r = parse(); } return x; }

Рис. 5.31. Дерево синтаксического анализа
Это дерево создано программой 5.20 для префиксного выражения * + a * * b c + d e f. Оно представляет собой естественный способ представления выражения: каждый операнд размещается в листе (который показан здесь в качестве внешнего узла), а каждая операция должна выполняться с выражениями, которые представлены левым и правым поддеревьями узла, содержащего операцию.
Упражнения
5.85. Измените программу 5.18, чтобы она выводила PostScript -программу, которая вычерчивает дерево в формате как на рис. 5.23, но без квадратиков, представляющих внешние узлы. Для вычерчивания линий используйте функции moveto и lineto, а для отрисовки узлов - пользовательскую операцию
/node {newpath moveto currentpoint 4 0 360 arc fill} def
После инициализации этого определения вызов node приводит к помещению черной точки с координатами, которые находятся в стеке (см. лекция №4).
5.86. Напишите программу, которая подсчитывает листья в бинарном дереве.
5.87. Напишите программу, которая подсчитывает количество узлов в бинарном дереве с одним внешним и одним внутренним дочерними узлами.
5.88. Напишите рекурсивную программу, которая вычисляет длину внутреннего пути бинарного дерева, используя определение 5.6.
5.89. Определите количество вызовов функций, выполненных программой при вычислении длины внутреннего пути бинарного дерева. Докажите ответ методом индукции.
5.90. Напишите рекурсивную программу, которая вычисляет длину внутреннего пути бинарного дерева за время, пропорциональное количеству узлов в дереве.
5.91. Напишите рекурсивную программу, которая удаляет из турнира все листья с заданным ключом (см. упражнение 5.59).
Обход графа
В качестве заключительного примера в этой главе рассмотрим одну из наиболее важных рекурсивных программ: рекурсивный обход графа, или поиск в глубину (depth -first search). Этот метод систематического посещения всех узлов графа представляет собой непосредственное обобщение методов обхода деревьев, рассмотренных в разделе 5.6, и служит основой для многих базовых алгоритмов обработки графов (см. часть 7). Это простой рекурсивный алгоритм. Начиная с любого узла v, мы
- посещаем v;
- (рекурсивно) посещаем каждый (не посещенный) узел, связанный с v.
Если граф является связным, со временем будут посещены все узлы. Программа 5.21 является реализацией этой рекурсивной процедуры.
Программа 5.21. Поиск в глубину
Для посещения в графе всех узлов, связанных с узлом к, мы помечаем его как посещенный, а затем (рекурсивно) посещаем все не посещенные узлы в списке смежности узла к.
void traverse(int k, void visit(int)) { visit(k); visited[k] = 1; for (link t = adj[k]; t != 0; t = t ->next) if (!visited[t ->v]) traverse(t ->v, visit); }
Например, предположим, что используется представление в виде списков связности, приведенное для графа на рис. 3.15. На рис. 5.32 приведена последовательность вызовов, выполненных при поиске в глубину в этом графе, а последовательность прохождения ребер графа показана в левой части рис. 5.33. При прохождении каждого из ребер графа возможны два варианта: если ребро приводит к уже посещенному узлу, мы игнорируем его; если оно приводит к еще не посещенному узлу, мы проходим по нему с помощью рекурсивного вызова. Множество всех пройденных таким образом ребер образует остовное дерево графа.

Рис. 5.32. Вызовы функции поиска в глубину
Эта последовательность вызовов функций реализует поиск в глубину для графа, приведенного на рис. 3.15. Дерево, которое описывает структуру рекурсивных вызовов (вверху), называется деревом поиска в глубину.
Различие между поиском в глубину и общим обходом дерева (см. программу 5.14) состоит в том, что необходимо явно исключить посещение уже посещенных узлов. В дереве такие узлы не встречаются. Действительно, если граф является деревом, рекурсивный поиск в глубину, начинающийся с корня, эквивалентен прямому обходу.
Лемма 5.10. Время, необходимое для поиска в глубину в графе с V вершинами и E ребрами, пропорционально V + E, если используется представление графа в виде списков смежности.
В представлении в виде списков смежности каждому ребру графа соответствует один узел в списке, а каждой вершине графа соответствует один указатель на начало списка. Поиск в глубину использует каждый из них не более одного раза.
Поскольку время, необходимое для построения представления в виде списков смежности из последовательности ребер (см. программу 3.19), также пропорционально V + E, поиск в глубину обеспечивает решение задачи связности из лекция №1 с линейным временем выполнения. Однако для очень больших графов решения вида объединение -поиск могут оказаться предпочтительнее, поскольку для представления всего графа нужен объем памяти, пропорциональный E, а для решений объединение -поиск - пропорциональный только V.
Как и в случае обхода дерева, можно определить метод обхода графа, при котором используется явный стек (см. рис. 5.34). Можно представить себе абстрактный стек, содержащий двойные элементы: узел и указатель на список смежности для этого узла. Если вначале стек содержит начальный узел, а указатель указывает на первый элемент списка смежности для этого узла, алгоритм поиска в глубину эквивалентен входу в цикл, в котором сначала посещается узел из верхушки стека (если он еще не был посещен); потом сохраняется узел, указанный текущим указателем списка смежности; затем ссылка из списка смежности сдвигается на следующий узел (с выталкиванием элемента, если достигнут конец списка смежности); и, наконец, в стек заносится элемент для сохраненного узла со ссылкой на первый узел его списка смежности.

Рис. 5.33. Поиск в глубину и поиск в ширину
При поиске в глубину (слева) перебираются все узлы, с возвратом к предшествующему узлу, когда все связи данного узла проверены. При поиске в ширину (справа) сначала перебираются все связи данного узла, а затем выполняется переход к следующему узлу.
Или же, как это было сделано для обхода дерева, можно создать стек, содержащий только ссылки на узлы. Если вначале стек содержит начальный узел, мы входим в цикл, в котором посещаем узел на верхушке стека (если он еще не был посещен), затем заносим в стек все узлы, смежные с этим узлом. На рис. 5.34 видно, что для нашего примера графа оба метода эквивалентны поиску в глубину, причем эквивалентность сохраняется и в общем случае.
Алгоритм посещения верхнего узла и занесения всех его соседей - простая формулировка поиска в глубину, но из рис. 5.34 понятно, что этот метод может оставлять в стеке нескольких копий каждого узла. Это происходит даже в случае проверки, посещался ли каждый узел, который должен быть помещен в стек, и если да, то отказа от занесения в стек такого узла. Во избежание этой проблемы можно воспользоваться реализацией стека, которая устраняет дублирование с помощью правила "забыть старый элемент ": поскольку ближайшая к верхушке стека копия всегда посещается первой, все остальные копии просто выталкиваются из стека.
Динамика состояния стека для поиска в глубину, показанная на рис. 5.34, основана на том, что узлы каждого списка смежности появляются в стеке в том же порядке, что и в списке. Для получения этого порядка для данного списка смежности при занесении узлов по одному необходимо втолкнуть в стек сначала последний узел, затем предпоследний и т.д.

Рис. 5.34. Динамика стека при поиске в глубину
Стек, обеспечивающий поиск в глубину, можно представить себе как содержащий элементы, каждый из которых состоит из узла и ссылки на список смежности для этого узла (показан узлом в кружке) (слева). Таким образом, обработка начинается с находящегося в стеке узла 0 со ссылкой на первый узел в его списке смежности - узел 7. Каждая строка отражает результат выталкивания из стека, занесения ссылки на следующий узел в списке посещенных узлов и занесения в стек элемента для не посещенных узлов. Этот процесс можно также представить себе в виде простого занесения в стек всех узлов, смежных с любым не посещенным узлом (справа).
Более того, чтобы ограничить размер стека числом вершин и все -таки посещать узлы в том же порядке, как и при поиске в глубину, необходимо использовать стек с забыванием старого элемента. Если посещение узлов в том же порядке, что и при поиске в глубину, не имеет значения, обоих этих осложнений можно избежать и непосредственно сформулировать нерекурсивный метод обхода графа с использованием стека, который выглядит так. После первоначального занесения в стек начального узла мы входим в цикл, в котором посещаем узел на верхушке стека, затем обрабатываем его список смежности, помещая в стек каждый узел (если он еще не был посещен); здесь должна использоваться реализация стека, которая запрещает дублирование по правилу "игнорировать новый элемент ". Этот алгоритм обеспечивает посещение всех узлов графа аналогично поиску в глубину, но не является рекурсивным.
Алгоритм, описанный в предыдущем абзаце, заслуживает внимания, поскольку вместо стека можно использовать любой АТД обобщенной очереди и все же посетить каждый из узлов графа (плюс сгенерировать развернутое дерево). Например, если вместо стека задействовать очередь, то получится поиск в ширину, который аналогичен обходу дерева по уровням. Программа 5.22 - реализация этого метода (при условии, что используется реализация очереди наподобие программы 4.12); пример этого алгоритма в действии показан на рис. 5.35. В части 6 будет рассмотрено множество алгоритмов обработки графов, основанных на более сложных АТД обобщенных очередей.
И при поиске в ширину, и при поиске в глубину посещаются все узлы графа, но в совершенно различном порядке (см. рис. 5.36). Поиск в ширину подобен армии разведчиков, разосланных по всей территории; поиск в глубину соответствует единственному разведчику, который проникает как можно дальше вглубь неизведанной территории, возвращаясь только в случае, если наталкивается на тупик. Это базовые методы решения задач, играющих существенную роль во многих областях компьютерных наук, а не только в поиске в графах.
Программа 5.22. Поиск в ширину
Чтобы посетить в графе все узлы, связанные с узлом к, узел к помещается в очередь FIFO, затем выполняется цикл, в котором из очереди выбирается следующий узел и, если он еще не был посещен, он посещается, а в стек заталкиваются все не посещенные узлы из его списка смежности. Этот процесс продолжается до тех пор, пока очередь не опустеет.
void traverse(int k, void visit(int)) { QUEUE<int> q(V*V); q.put(k); while (!q.empty()) if (visited[k = q.get()] == 0) { visit(k); visited[k] = 1; for (link t = adj[k]; t != 0; t = t ->next) if (visited[t ->v] == 0) q.put(t ->v); } }

Рис. 5.35. Динамика очереди при поиске в ширину
Обработка начинается с узла 0 в очереди, затем мы извлекаем узел 0, посещаем его и помещаем в очередь узлы 7 5 2 1 6 из его списка смежности, причем именно в этом порядке. Затем мы извлекаем узел 7, посещаем его и помещаем в очередь узлы из его списка смежности, и т.д. В случае запрета дублирования по правилу "игнорировать новый элемент " (справа) мы получаем такой же результат без лишних элементов в очереди.

Рис. 5.36. Деревья обхода графов
На этой схеме показаны поиск в глубину (в центре) и поиск в ширину (внизу), выполненные наполовину в большом графе (вверху). При поиске в глубину обход выполняется от одного узла к следующему, так что большинство узлов связано только с двумя другими.
А при поиске в ширину посещаются все узлы, связанные с данным, прежде чем двигаться дальше; поэтому некоторые узлы связаны с множеством других.
Упражнения
5.92. Построив диаграммы, соответствующие рис. 5.33 (слева) и 5.34 (справа), покажите, как происходит посещение узлов при рекурсивном поиске в глубину в графе, построенном для последовательности ребер 0 -2, 1 -4, 2 -5, 3 -6, 0 -4, 6 -0 и 1 -3 (см. упражнение 3.70).
5.93. Построив диаграммы, соответствующие рис. 5.33 (слева) и 5.34 (справа), покажите, как происходит посещение узлов при поиске в ширину (с использованием стека) в графе, построенном для последовательности ребер 0 -2, 1 -4, 2 -5, 3 -6, 0 -4, 6 -0 и 1 -3 (см. упражнение 3.70).
5.94. Построив диаграммы, соответствующие рис. 5.33 (слева) и 5.35 (справа), покажите, как происходит посещение узлов при поиске в ширину (с использованием очереди) в графе, построенном для последовательности ребер 0 -2 , 1 -4 , 2 -5, 3 -6, 0 -4, 6 -0 и 1 -3 (см. упражнение 3.70).
5.95. Почему время выполнения, упоминаемое в лемме 5.10, пропорционально V + E, а не просто E ?
5.96. Построив диаграммы, соответствующие рис. 5.33 (слева) и 5.35 (справа), покажите, как происходит посещение узлов при поиске в глубину (с использованием стека и правила "забыть старый элемент ") в графе, приведенном на рис. 3.15.
5.97. Построив диаграммы, соответствующие рис. 5.33 (слева) и 5.35 (справа), покажите, как происходит посещение узлов при поиске в глубину (с использованием стека и правила "игнорировать новый элемент ") в графе, приведенном на рис. 3.15.
5.98. Реализуйте поиск в глубину с использованием стека для графов, которые представлены списками смежности.
5.99. Реализуйте рекурсивный поиск в глубину для графов, которые представлены списками смежности.
Перспективы
Рекурсия лежит в основе ранних теоретических исследований природы вычислений. Рекурсивные функции и программы играют главную роль в математических исследованиях, в которых предпринимается попытка разделения задач на поддающиеся решению на компьютере и на непригодные для этого.
В ходе столь краткого рассмотрения просто невозможно полностью осветить столь обширные темы, как деревья и рекурсия. Многие самые удачные примеры рекурсивных программ будут постоянно встречаться нам на протяжении всей книги - к ним относятся алгоритмы "разделяй и властвуй " и рекурсивные структуры данных, которые успешно применяются для решения широкого спектра задач. Для многих приложений нет смысла выходить за рамки простой непосредственной рекурсивной реализации; для других будет рассмотрен вывод нерекурсивных и восходящих реализаций.
В этой книге основное внимание уделено практическим аспектам построения рекурсивных программ и структур данных. Наша цель - применение рекурсии для создания изящных и эффективных реализаций. Для достижения этой цели необходимо особо учитывать опасности использования простых программ, которые ведут к экспоненциальному увеличению количества вызовов функций или недопустимо большой глубине вложенности. Несмотря на этот недостаток, рекурсивные программы и структуры данных весьма привлекательны, поскольку часто они наводят на индуктивные рассуждения, которые помогают убедиться в правильности и эффективности разработанных программ.
В данной книге деревья используются как для упрощения понимания динамических свойств программ, так и в качестве динамических структур данных. В главах 12 - 15 особенно большое внимание уделяется работе с древовидными структурами. Свойства, описанные в этой главе, предоставляют базовую информацию, которая требуется для эффективного применения древовидных структур.
Несмотря на центральную роль в разработке алгоритмов, рекурсия - вовсе не панацея на все случаи жизни. Как было показано при изучении алгоритмов обхода деревьев и графов, алгоритмы с использованием стека (которые рекурсивны по своей природе) - не единственная возможность при необходимости управлять сразу несколькими вычислительными задачами. Эффективная техника разработки алгоритмов для решения многих задач заключается в использовании реализаций обобщенных очередей, отличающихся от стеков; такие очереди позволяют выбирать следующую задачу в соответствии с каким -либо более субъективным критерием, нежели простой выбор самой последней. Структуры данных и алгоритмы, которые эффективно поддерживают такие операции - основная тема лекция №9, а со многими примерами их применения мы встретимся во время изучения алгоритмов обработки графов в части 7.
Ссылки для части II
Существует множество учебников для начинающих, посвященных структурам данных. Например, в книге Стендиша (Standish) темы связных структур, абстракций данных, стеков и очередей, распределения памяти и создания программ освещаются более подробно, чем здесь. Конечно, классические книги Кернигана и Ритчи (Kernighan -Ritchie) и Страуструпа (Stroustrup) - бесценные источники подробной информации по реализациям на С и С++. Книги Мейерса (Meyers) также содержат полезную информацию о реализациях на C++.
Разработчики PostScript, вероятно, не могли даже и предполагать, что разработанный ими язык будет представлять интерес для людей, которые изучают основные алгоритмы и структуры данных. Сам по себе этот язык не представляет особой сложности, а справочное руководство по нему основательно и доступно.
Парадигма "клиент -интерфейс -реализация " подробно и с множеством примеров описывается в книге Хэнсона (Hanson). Эта книга - замечательный справочник для тех программистов, которые намерены писать надежный и переносимый код для больших систем.
Книги Кнута (Knuth), в особенности 1 -й и 3 -й тома, остаются авторитетным источником информации по свойствам элементарных структур данных. Книги Баеcа -Ятеса (Baeza -Yates) и Гонне (Gonnet) содержат более свежую информацию, подкрепленную внушительным библиографическим перечнем. Книга Седжвика и Флажоле (Sedgewick and Flajolet) подробно освещает математические свойства деревьев.
1. Adobe Systems Incorporated, PostScript Language Reference Manual, second edition, Addison -Wesley, Reading, MA, 1990.
2. R. Baeza -Yates and G. H. Gonnet, Handbook of Algorithms and Data Structures, second edition, Addison -Wesley, Reading, MA, 1984.
3. D. R. Hanson, C Interfaces and Implementations: Techniques for Creating Reusable Software, Addison -Wesley, 1997.
4. Брайан У. Керниган, Деннис М. Ритчи, Язык программирования C (Си), 2 -е издание, ИД "Вильямс", 2008 г.
5. Д.Э. Кнут, Искусство программирования, том 1: Основные алгоритмы, 3 -е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 2: Получисленные алгоритмы, 3 -е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 3. Сортировка и поиск, 2 -е издание, ИД "Вильямс", 2008 г.
6. S. Meyers, Effective C++, second edition, Addison -Wesley, Reading, MA, 1996.
7. S. Meyers, More Effective C++, Addison -Wesley, Reading, MA, 1996.
8. R. Sedgewick and P Flajolet, An Introduction to the Analysis of Algorithms, Addison -Wesley, Reading, MA, 1996.
9. T. A. Standish, Data Structures, Algorithms, and Software Principles in C, Addison -Wesley, 1995.
10. B. Stroustrup, The C++ Programming Language, third edition, Addison -Wesley, Reading MA, 1997.
Глава 3. Сортировка
Лекция 6. Элементарные методы сортировки
В качестве первого экскурса в область алгоритмов сортировки мы рассмотрим несколько элементарных методов, которые удобны для сортировки небольших файлов либо файлов со специальной структурой. Для подробного изучения этих простых алгоритмов сортировки имеется несколько причин. Во-первых, они предоставляют контекст, в рамках которого можно изучить терминологию и базовые механизмы алгоритмов сортировки, что позволит создать предпосылки для изучения более сложных алгоритмов. Во-вторых, эти простые методы во многих приложениях сортировки показали себя более эффективными, чем мощные универсальные методы. В-третьих, некоторые из простых методов можно расширить в более эффективные универсальные методы или же применить для повышения эффективности более сложных методов сортировки.
Цель настоящей главы — не только в ознакомлении читателя с элементарными методами сортировки, но и в создании среды, облегчающей изучение сортировки в последующих главах. Мы рассмотрим различные важные ситуации, которые могут возникнуть при применении алгоритмов сортировки, различные виды входных файлов, а также различные способы сравнения методов сортировки и изучения их свойств.
Мы начнем с рассмотрения простой программы-драйвера для тестирования методов сортировки — она обеспечит контекст, позволяющий выработать соглашения, которым мы будем следовать в дальнейшем. Мы также проанализируем базовые свойства методов сортировки, на основании которых можно оценить применимость алгоритмов для конкретных приложений. Затем мы подробно рассмотрим реализацию трех элементарных методов: сортировки выбором, сортировки вставками и пузырьковой сортировки. После этого будут исследованы характеристики производительности этих алгоритмов. Далее мы рассмотрим сортировку Шелла, которой не очень-то подходит эпитет " элементарная " , однако она достаточно просто реализуется и имеет много общего с сортировкой вставками. После изучения математических свойств сортировки Шелла мы займемся темой разработки интерфейсов типов данных и реализаций — в стиле материала глав 3 и 4 — чтобы расширить применимость алгоритмов для различных видов файлов данных, которые встречаются на практике. Затем мы рассмотрим методы сортировки косвенных ссылок на данные, а также сортировку связных списков. Завершается глава обсуждением специализированного метода, который применим, если ключи принимают значения из ограниченного диапазона.
Во многих применениях сортировки часто бывают удобнее простые алгоритмы. Во-первых, очень часто программа сортировки используется лишь один или небольшое количество раз. После " решения " задачи сортировки для некоторого набора данных приложение обработки этих данных выполняет другие действия. Если элементарная сортировка работает не медленнее других частей приложения — например, ввода или вывода данных — то не стоит искать более быстрые методы. Если число сортируемых элементов не очень большое (скажем, не превышает нескольких сотен элементов), можно воспользоваться простым методом и не морочиться с интерфейсом для системной сортировки или с реализацией и отладкой сложного метода. Во-вторых, элементарные методы всегда удобны для файлов небольших размеров (скажем, из нескольких десятков элементов) — сложным алгоритмам в общем случае присущи дополнительные затраты, что делает их работу для маленьких файлов более медленной, чем элементарных методов. Эта проблема становится существенной, только если возникает необходимость сортировки большого числа маленьких файлов, однако приложения с подобными требованиями встречаются не так уж редко. Сортировка выполняется легко и для файлов, которые уже почти (или полностью) отсортированы или содержат большое число одинаковых ключей. Мы увидим, что некоторые простые методы особенно эффективны при сортировке таких весьма структурированных файлов.
Как правило, на сортировку случайно упорядоченных N элементов элементарные методы, рассматриваемые в данной главе, затрачивают время, пропорциональное N2. Если N невелико, то время выполнения сортировки может оказаться вполне приемлемым. Как только что было отмечено, при сортировке файлов небольших размеров и в ряде других специальных случаев эти методы часто работают быстрее более сложных методов. Однако методы, описанные в настоящей главе, не годятся для сортировки больших случайно упорядоченных файлов, поскольку время их сортировки будет недопустимо большим даже на самых быстрых компьютерах. Заметным исключением является сортировка Шелла (см. раздел 6.6), которой при больших N требуется гораздо меньше, чем N 2 шагов. Похоже, этот метод является одним из лучших для сортировки файлов средних размеров и для ряда других специальных случаев.
Правила игры
Прежде чем перейти к изучению конкретных алгоритмов, полезно рассмотреть общую терминологию и основные положения алгоритмов сортировки. Мы будем рассматривать методы сортировки файлов (file), которые состоят из элементов (item), обладающих ключами (key). Эти понятия являются естественными абстракциями в современных средах программирования. Ключи, которые являются лишь частью (зачастую очень небольшой частью) элементов, используются для управления сортировкой. Цель метода сортировки заключается в перемещении элементов таким образом, чтобы их ключи были упорядочены по некоторому заданному критерию (обычно это числовой или алфавитный порядок). Конкретные характеристики ключей и элементов в разных приложениях могут существенно отличаться друг от друга, однако абстрактное понятие размещения ключей и связанной с ними информации в определенном порядке и представляет собой суть задачи сортировки.
Если сортируемый файл полностью помещается в оперативной памяти, то метод сортировки называется внутренним. Сортировка файлов, хранящихся на магнитной ленте или диске, называется внешней. Основное различие между этими двумя методами заключается в том, что при внутренней сортировке возможен легкий доступ к любому элементу, а при внешней сортировке возможен только последовательный перебор элементов или, по крайней мере, большими блоками. Некоторые методы внешней сортировки рассматриваются в лекция №11, однако большая часть рассматриваемых алгоритмов относится к внутренней сортировке.
Мы будем рассматривать и массивы, и связные списки, поскольку при разработке алгоритмов для некоторых базовых задач будет удобнее последовательное размещение элементов, а для других задач — связные структуры. Некоторые из классических методов настолько абстрактны, что их можно эффективно реализовать с помощью как массивов, так и связных списков; но есть и такие, для которых гораздо удобнее один из методов. Иногда могут появиться и другие виды ограничения доступа.
Начнем мы с сортировки массивов. Программа 6.1 демонстрирует многие соглашения, которым мы будем следовать в наших реализациях. По сути это программа-драйвер, т.е. управляющая программа. Она заполняет массив, считывая целые числа из стандартного ввода либо генерируя случайные целые значения (режим задается целочисленным аргументом), затем вызывает функцию сортировки, чтобы упорядочить элементы массива, и в завершение выводит результат сортировки.
Как было описано в лекция №3 и лекция №4, существуют многочисленные механизмы, которые позволяют применять наши реализации сортировки для других типов данных. Подробности использования таких механизмов будут рассмотрены в разделе 6.7. Функция sort из программы 6.1 представляет собой шаблонную реализацию, которая обращается к сортируемым элементам только через первый аргумент и нескольких простых операций с данными. Как обычно, такой подход позволяет использовать один и тот же программный код для сортировки элементов разных типов. Например, если код функции main в программе 6.1 изменить так, чтобы генерация, хранение и вывод случайных ключей выполнялись не для целых чисел, а для чисел с плавающей точкой, то функцию sort можно оставить без каких-либо изменений. Для достижения такой гибкости (и в то же время явной идентификации переменных для хранения сортируемых элементов) наши реализации должны быть параметризованы для работы с типом данных Item. Пока тип данных Item можно считать типом int или float, а в разделе 6.7 будут подробно рассмотрены реализации типов данных, которые позволят использовать наши реализации сортировки для произвольных элементов с ключами в виде чисел с плавающей точкой, строк и т.п., используя механизмы, описанные в лекция №3 и лекция №4.
Функцию sort можно заменить любой реализацией сортировки массива из данной главы или глав 7—10. В каждой из них выполняется сортировка элементов типа Item, и каждая использует три аргумента: массив и левую и правую границы подмассива, подлежащего сортировке. В них также применяется операция < для сравнения ключей элементов и функции exch или compexch, выполняющие обмен элементов. Чтобы различать методы сортировки, мы будем присваивать различным программам сортировки разные имена. В клиентской программе, наподобие программы 6.1, достаточно переименовать одну из этих программ, изменить драйвер или задействовать указатели на функции для переключения с одного алгоритма на другой — без внесения изменений в программную реализацию сортировки.
Эти соглашения позволят нам изучить естественные и компактные реализации многих алгоритмов сортировки массивов. В разделах 6.7 и 6.8 рассматривается драйвер, на примере которого будет показано применение реализаций сортировок в более общих контекстах, а также различные реализации типов данных. Мы всегда будем обращать внимание на конкретные детали, однако основные усилия будут направлены на алгоритмические вопросы, к рассмотрению которых мы сейчас и переходим.
Функция сортировки в программе 6.1 является одним из вариантов сортировки вставками, которая будет подробно рассмотрена в разделе 6.3. Так как в ней используются только операции сравнения и обмена, она является примером неадаптивной (nonadaptive) сортировки: последовательность выполняемых операций не зависит от упорядоченности данных. И наоборот, адаптивная (adaptive) сортировка выполняет различные последовательности операций в зависимости от результатов сравнения (вызовов операции <). Неадаптивные методы сортировки интересны тем, что они достаточно просто реализуются аппаратными средствами (см. лекция №11), однако большинство универсальных алгоритмов сортировки, которые мы рассмотрим, являются адаптивными.
Программа 6.1. Пример сортировки массива с помощью программы-драйвера
Данная программа служит иллюстрацией наших соглашений, касающихся реализации базовых алгоритмов сортировки массивов. Функция main — драйвер, который инициализирует массив целыми значениями (случайными либо из стандартного ввода), вызывает функцию sort для сортировки заполненного массива, после чего выводит упорядоченный результат.
Шаблоны позволяют использовать эту реализацию для сортировки элементов любого типа данных, для которого определены операции сравнения и присваивания. В данном случае функция sort представляет собой вариант сортировки вставками (см. раздел 6.3, в котором приводится подробное описание, пример и улучшенный вариант реализации). Она использует шаблонную функцию, которая сравнивает два элемента и при необходимости производит обмен их местами, чтобы второй элемент был не меньше первого.
Мы можем изменять программу-драйвер для сортировки любых типов данных, для которых определена операция <, совершенно не меняя функцию sort (см. раздел 6.7).
#include <iostream.h> #include <stdlib.h> template <class Item> void exch(Item &A, Item &B) { Item t = A; A = B; B = t; } template <class Item> void compexch(Item &A, Item &B) { if (B < A) exch(A, B); } template <class Item> void sort(Item a[], int l, int r) { for (int i = l+1; i <= r; i++) for (int j = i; j > l; j-- ) compexch(a[j-1], a[j]); } int main(int argc, char *argv[]) { int i, N = atoi(argv[1]), sw = atoi(argv[2]); int *a = new int[N]; if (sw) for (i = 0; i < N; i++) a[i] = 1000*(1.0*rand()/RAND_MAX); else { N = 0; while (cin >> a[N]) N++; } sort(a, 0, N-1); for (i = 0; i < N; i++) cout << a[i] << " "; cout << endl; }
Как обычно, из всех характеристик производительности алгоритмов сортировки нас в первую очередь интересует время их выполнения.
Как будет показано в разделе 6.5, для выполнения сортировки N элементов методом выбора, методом вставок и пузырьковым методом, которые будут рассматриваться в разделах 6.2—6.4, требуется время, пропорциональное N2. Более совершенные методы, о которых речь пойдет в главах 7—10, могут упорядочить N элементов за время, пропорциональное N logN, однако эти методы не всегда столь же эффективны, как рассматриваемые здесь методы, для небольших значений N, а также в некоторых особых случаях. В разделе 6.6 будет рассмотрен более совершенный метод (сортировка Шелла), который может потребовать время, пропорциональное N3/2 или даже меньше, а в разделе 6.10 приводится специализированный метод (распределяющая сортировка), которая для некоторых типов ключей выполняется за время, пропорциональное N.
Аналитические результаты, изложенные в предыдущем абзаце, получены на основе подсчета базовых операций (сравнений и обменов), которые выполняет алгоритм. Как было сказано в разделе 2.2 лекция №2, следует также учитывать затраты на выполнение этих операций. Однако в общем случае мы считаем, что основное внимание следует уделить наиболее часто используемым операциям (внутренний цикл алгоритма). Наша цель заключается в том, чтобы разработать эффективные и несложные реализации эффективных алгоритмов. Поэтому мы будем не только избегать излишних включений во внутренние циклы алгоритмов, но и по возможности пытаться удалять команды из внутренних циклов. В общем случае лучший способ снижения затрат в приложении — это переключение на более эффективный алгоритм, а второй лучший способ — поджатие внутреннего цикла. Для алгоритмов сортировки мы будем неоднократно использовать оба эти способа.
Вторым по важности фактором, который мы будем рассматривать, является объем дополнительной памяти, используемой алгоритмом сортировки. По этому критерию все методы можно разбить на три категории: те, которые выполняют сортировку на месте и не требуют дополнительной памяти, за исключением, возможно, небольшого стека или таблицы; те, которые используют представление в виде связного списка или каким-то другим способом обращаются к данным с помощью N указателей или индексов массивов, для которых нужна дополнительная память; и те, которые требуют дополнительной памяти для размещения еще одной копии сортируемого массива.
Часто применяются методы сортировки элементов с несколькими ключами — иногда даже требуется упорядочение одного и того же набора элементов в разные моменты по разным ключам. В таких случаях очень важно знать, обладает ли выбранный метод сортировки следующим свойством:
Определение 6.1. Говорят, что метод сортировки устойчив, если он сохраняет относительный порядок размещения в файле элементов с одинаковыми ключами.
Например, если имеется список учеников, упорядоченный по алфавиту и году выпуска, то устойчивый метод сортировки выдаст список учеников, распределенный по классам, в том же алфавитном порядке, а неустойчивый метод, скорее всего, выдаст список без следов первоначальной упорядоченности. Когда люди, не знакомые с понятием устойчивости, впервые сталкиваются с подобного рода ситуацией, они часто удивляются, до какой степени неустойчивый алгоритм может перемешать данные.
Некоторые (но отнюдь не все) простые методы сортировки, которые рассматриваются в данной главе, являются устойчивыми. А многие сложные алгоритмы сортировки (тоже не все), которые будут рассмотрены в нескольких последующих главах, неустойчивы. Если устойчивость важна, ее можно обеспечить, добавив перед сортировкой к каждому ключу небольшой индекс или как-то по-другому расширив ключ сортировки. Выполнение этой дополнительной работы равносильно использованию при сортировке обоих ключей (см. рис 6.1), поэтому лучше задействовать устойчивый алгоритм. Однако очень немногие сложные алгоритмы, которые будут рассматриваться в последующих главах, обеспечивают устойчивость без существенных дополнительных затрат памяти или времени.

увеличить изображение
Рис. 6.1. Пример устойчивой сортировки
Сортировку представленных здесь записей можно выполнить по любому из двух ключей. Предположим, что вначале записи были отсортированы по первому ключу (вверху). Неустойчивая сортировка по второму ключу не сохраняет этот порядок для записей с повторяющимися ключами (в центре), а устойчивая сортировка сохраняет этот порядок (внизу).
Как уже было сказано, программы сортировки обычно осуществляют доступ к элементам одним из двух способов: либо доступ к ключам для их сравнения, либо доступ полностью к элементам для их перемещения. Если сортируемые элементы имеют большой размер, лучше не перемещать их в памяти, а выполнять косвенную (indirect) сортировку: переупорядочиваются не сами элементы, а массив указателей (или индексов) так, что первый указатель указывает на наименьший элемент, следующий — на наименьший из оставшихся и т.д. Ключи можно хранить либо вместе с самими элементами (если ключи большие), либо с указателями (если ключи малы). После сортировки можно переупорядочить и сами элементы, но часто в этом нет необходимости, т.к. имеется возможность (косвенного) обращения к ним в отсортированном порядке. Косвенные методы сортировки рассматриваются в разделе 6.8.
Упражнения
6.1. Детская игрушка состоит из i карт, отверстие в которых подходит к колышку в i-ой позиции, причем i принимает значения от 1 до 5. разработайте метод для помещения карт на колышки, считая, что по виду карты невозможно сказать, подходит ли она к тому или иному колышку (обязательно нужно попробовать).
6.2. Для выполнения карточного трюка нужно, чтобы колода карт была упорядочена по мастям (пики, потом червы, трефы и бубны), а внутри каждой масти — по старшинству. Попросите нескольких своих друзей выполнить эту задачу (перед каждой попыткой перетасуйте карты!) и запишите методы, которыми они пользовались.
6.3. Объясните, как вы будете сортировать колоду карт при условии, что карты необходимо укладывать в ряд лицевой стороной вниз, и допускается только проверка значений двух карт и (при необходимости) обмен этих карт местами.
6.4. Объясните, как вы будете сортировать колоду карт при условии, что карты должны находиться в колоде, и допускаются лишь проверка значений двух верхних карт в колоде, обмен этих карт местами и перемещение верхней карты вниз.
6.5. Приведите все последовательности из трех операций сравнения-обмена для упорядочения трех элементов.
о 6.6. Приведите последовательность из пяти операций сравнения-обмена, которая упорядочивает четыре элемента.
6.7. Напишите клиентскую программу, которая проверяет устойчивость используемой подпрограммы сортировки.
6.8. Проверка упорядоченности массива после выполнения функции sort не доказывает, что сортировка работает. Почему?
6.9. Напишите клиентскую программу-драйвер для замера производительности сортировки, которая многократно вызывает функцию sort для файлов различных размеров, замеряет время каждого выполнения и выводит (в виде текста или графика) среднее время выполнения.
6.10. Напишите учебную клиентскую программу-драйвер, которая вызывает функцию sort для сложных или патологических случаев, которые могут встретиться в реальных ситуациях. Примерами могут служить уже упорядоченные файлы, файлы, представленные в обратном порядке, файлы, все записи которых имеют одни и те же ключи, файлы, содержащие только два отличных друг от друга значения, файлы размерами 0 или 1.
Сортировка выбором
Один из самых простых алгоритмов сортировки работает следующим образом. Сначала находится наименьший элемент массива и меняется местами с элементом, стоящим первым в сортируемом массиве. Потом находится второй наименьший элемент и меняется местами с элементом, стоящим вторым в исходном массиве. Этот процесс продолжается до тех пор, пока весь массив не будет отсортирован. Данный метод называется сортировкой выбором (selection sort), поскольку он все время выбирает наименьший элемент из числа не отсортированных. На рис 6.2 представлен пример работы этого метода.

Рис. 6.2. Пример сортировки выбором
В этом примере первый проход ничего не меняет, поскольку в массиве нет элемента, меньшего самого левого элемента А. На втором проходе наименьшим среди оставшихся оказался другой элемент А, поэтому он меняется местами с элементом S, занимающим вторую позицию. На третьем проходе элемент Е из середины массива обменивается с О в третьей позиции, на четвертом проходе еще один элемент Е меняется местами с R в четвертой позиции и т.д.
Программа 6.2 — реализация сортировки выбором, в которой выдержаны все принятые нами соглашения. Внутренний цикл содержит только сравнение текущего элемента с наименьшим выявленным на данный момент элементом (плюс код, необходимый для сдвига индекса текущего элемента и проверки, что он не выходит за границы массива). Проще не придумаешь. Действия по перемещению элементов находятся вне внутреннего цикла: каждая операция обмена элементов устанавливает один из них в окончательную позицию, так что всего необходимо N— 1 таких операций (для последнего элемента обмен не нужен). Поэтому время выполнения определяется в основном количеством сравнений. В разделе 6.5 мы покажем, что это количество пропорционально N 2, научимся предсказывать общее время выполнения и узнаем, как сравнивать сортировку выбором с другими элементарными методами.
Программа 6.2. Сортировка выбором
Для каждого i от l до r-1 элемент a[i] меняется местами с минимальным элементом из a[i], ..., a[r]. По мере продвижения индекса i слева направо элементы слева от него занимают свои окончательные позиции в массиве (и больше не перемещаются), поэтому, когда i достигнет правого конца, массив будет полностью отсортирован.
template <class Item> void selection(Item a[], int l, int r) { for (int i = l; i < r; i++) { int min = i; for (int j = i+1; j <= r; j++) if (a[j] < a[min]) min = j; exch(a[i], a[min]); } }
Недостаток сортировки выбором заключается в том, что время ее выполнения почти не зависит от упорядоченности исходного файла. Процесс поиска минимального элемента за один проход файла дает очень мало сведений о том, где может находиться минимальный элемент на следующем проходе этого файла. Неискушенный пользователь будет немало удивлен, когда увидит, что на сортировку уже отсортированного файла или файла со всеми одинаковыми ключами требуется столько же времени, сколько и на сортировку случайно упорядоченного файла! Как мы убедимся позже, другие методы лучше используют упорядоченность исходного файла.
Несмотря на простоту и очевидный примитивизм, сортировка выбором превосходит более совершенные методы в одном важном случае: когда элементы очень велики, а ключи очень малы. В подобных приложениях стоимость перемещения данных намного превосходят стоимость сравнения, а никакой алгоритм не способен упорядочить файл с заметно меньшим числом перемещений данных, чем сортировка выбором (см. лемму 6.5 в разделе 6.5).
Упражнения
6.11. Покажите в стиле рис. 6.2 процесс упорядочения файла E A S Y Q U E S T I O N методом выбора.
6.12. Каково максимальное количество обменов любого конкретного элемента в процессе сортировки выбором? Чему равно среднее количество обменов, приходящееся на один элемент?
6.13. Приведите пример файла из N элементов, в котором во время выполнения сортировки выбором условие a[j] < a[min] неверно максимальное количество раз (когда min изменяет значение).
6.14. Является ли сортировка выбором устойчивой?
Сортировка вставками
Картежники часто упорядочивают карты в руках следующим образом: выбирают по очереди каждую карту и вставляют ее в нужное место среди уже отсортированных элементов. В компьютерной реализации потребуется освободить место для вставляемого элемента, сдвинув большие элементы на одну позицию вправо и переместив на освободившееся место вставляемый элемент. Функция sort из программы 6.1 является программной реализацией этого метода, получившего название сортировка вставками (insertion sort).
Как и в случае сортировки выбором, элементы слева от текущего индекса уже упорядочены, однако они еще не находятся в окончательных позициях, т.к. могут быть сдвинуты для освобождения места под меньшие элементы, которые будут обнаружены позже. Массив будет полностью отсортирован, когда индекс достигнет правого конца. Пример работы этого метода показан на рис. 6.3.
Реализация сортировки вставками, представленная в программе 6.1, проста, но не эффективна. Сейчас мы рассмотрим три способа ее усовершенствования, иллюстрирующих один общий принцип: хотелось бы получить компактный, понятный и эффективный код, однако часто эти цели противоречат друг другу, поэтому приходится искать компромисс. Для этого мы сначала разработаем естественную реализацию, а потом усовершенствуем ее серией преобразований, проверяя эффективность (и правильность) каждого такого преобразования.
Прежде всего, можно отказаться от выполнения операций compexch, когда встречается ключ, не больший ключа вставляемого элемента, поскольку подмассив, находящийся слева, уже отсортирован. А именно, при выполнении условия a[j-1] < a[j] можно выполнить команду break, чтобы выйти из внутреннего цикла for в функции sort программы 6.1. Это изменение превращает реализацию в адаптивную сортировку и примерно вдвое ускоряет работу программы для случайно упорядоченных ключей (см. лемму 6.2).
После усовершенствования, описанного в предыдущем абзаце, получилось два условия прекращения выполнения внутреннего цикла — для наглядности можно заменить его на оператор while. Менее очевидное улучшение реализации следует из того факта, что условие j > l обычно оказывается излишним: ведь оно выполняется только когда вставляемый элемент является наименьшим из просмотренных к этому моменту и поэтому доходит до начала массива.

Рис. 6.3. Пример выполнения сортировки вставками
Во время первого прохода сортировки вставками элемент S во второй позиции больше A, так что перемещать его не надо. На втором проходе элемент O в третьей позиции меняется местами с S, так что получается упорядоченная последовательность A O S и т.д. Не заштрихованные и обведенные кружками элементы — это те, которые были сдвинуты на одну позицию вправо.
Часто применяется альтернативный способ: сортируемые ключи хранятся в элементах массива от a[1] до a[N], а в a[0] заносится сигнальный ключ (sentinel key), значение которого по крайней мере не больше наименьшего ключа в сортируемом массиве. Тогда проверка, найден ли меньший ключ, проверяет сразу оба условия, и в результате внутренний цикл становится меньше, а быстродействие программы повышается.
Сигнальные ключи не всегда удобны: иногда бывает трудно определить значение минимально возможного ключа, а иногда в вызывающей программе нет места для дополнительного ключа. В программе 6.3 предлагается один из способов обойти сразу обе эти проблемы: сначала выполняется отдельный проход по массиву, который помещает в первую позицию элемент с минимальным ключом. Затем сортируется остальной массив, а первый, он же наименьший, элемент служит в качестве сигнального ключа. Обычно мы будем избегать употребления в коде сигнальных ключей, поскольку часто легче понять код с явными проверками. Но мы будем обязательно отмечать ситуации, когда сигнальные ключи могут оказаться полезными для упрощения программы и повышения ее эффективности.
Третье улучшение, которое мы сейчас рассмотрим, также связано с удалением лишних команд из внутреннего цикла. Оно следует из того факта, что последовательные обмены с одним и тем же элементом неэффективны. При двух или более обменах вначале выполняется
t = a[j]; a[j] = a[j-1]; a[j-1] = t;
затем
t = a[j-1]; a[j-1] = a[j-2]; a[j-2] = t
и т.д. Значение t между этими двумя последовательностями не изменяется, но происходит бесполезная трата времени на его запоминание и тут же на чтение для следующего обмена. Программа 6.3 сдвигает большие элементы вправо без выполнения обменов, тем самым избегая напрасной траты времени.
Программа 6.3. Сортировка вставками
Это усовершенствованный вариант функции sort из программы 6.1, поскольку он (1) помещает в первую позицию наименьший элемент массива, чтобы использовать его как сигнальный ключ; (2) во внутреннем цикле выполняет лишь одну операцию присваивания, а не обмена; и (3) прекращает выполнение внутреннего цикла, когда вставляемый элемент уже находится в требуемой позиции. Для каждого i упорядочиваются элементы a[l], ..., a[i] с помощью сдвига на одну позицию вправо элементов a[l], ..., a[i-1] из отсортированного списка, которые больше a[i], и занесения a[i] в освободившееся место.
template <class Item> void insertion(Item a[], int l, int r) { int i; for (i = r; i > l; i--) compexch(a[i-1], a[i]); for (i = l+2; i <= r; i++) { int j = i; Item v = a[i]; while (v < a[j-1]) { a[j] = a[j-1]; j--; } a[j] = v; } }
Реализация сортировки вставками в программе 6.3 значительно эффективнее реализации в программе 6.1 (в разделе 6.5 мы увидим, что она работает почти вдвое быстрее). В данной книге нас интересуют как элегантные и эффективные алгоритмы, так и элегантные и эффективные реализации этих алгоритмов. В данном случае алгоритмы несколько отличаются друг от друга — правильнее назвать функцию sort из программы 6.1 неадаптивной (nonadaptive) сортировкой вставками. Глубокое понимание свойств алгоритма — лучшее руководство для разработки его реализации, которая может эффективно использоваться в различных приложениях.
В отличие от сортировки выбором, время выполнения сортировки вставками зависит главным образом от исходного порядка ключей во входных данных. Например, если файл большой, а ключи уже упорядочены (или почти упорядочены), то сортировка вставками выполняется быстро, а сортировка выбором — медленно. Более полное сравнение алгоритмов сортировки приведено в разделе 6.5.
Упражнения
6.15. Покажите в стиле рис. 6.3 процесс упорядочения файла E A S Y Q U E S T I O N методом вставок.
6.16. Приведите реализацию сортировки вставками, в которой внутренний цикл оформлен с помощью оператора while с завершением по одному из двух условий, описанных в тексте.
6.17. Для каждого из условий цикла while в упражнении 6.16 приведите пример файла из N элементов, для которого в момент выхода из цикла это условие всегда ложно.
о 6.18. Является ли сортировка вставками устойчивой?
6.19. Приведите неадаптивную реализацию сортировки выбором, основанную на поиске минимального элемента с помощью кода наподобие первого цикла for из программы 6.3.
Пузырьковая сортировка
Многие начинают знакомство с сортировкой с исключительно простого метода пузырьковой сортировки (bubble sort). В процессе своей работы она выполняет проходы по файлу с обменом местами соседних элементов, нарушающих порядок, до тех пор, пока файл не станет отсортирован. Основным достоинством пузырьковой сортировки является легкость ее реализации, хотя в этом она сравнима с методом вставок и методом выбора. В общем случае пузырьковый метод работает медленнее, однако мы рассмотрим его для полноты картины.
Предположим, что мы всегда передвигаемся по файлу справа налево. Когда на первом проходе попадается минимальный элемент, он меняется местами с каждым элементом слева от него, пока не займет место на левом краю массива. Затем на втором проходе на свою позицию попадает второй по величине элемент и т.д. Значит, для полной упорядоченности файла достаточно выполнить N проходов. Пузырьковую сортировку можно рассматривать как разновидность сортировки выбором, хотя она затрачивает больше работы для помещения каждого элемента в нужную позицию. Программа 6.4 представляет собой реализацию этого алгоритма, а на рис. 6.4 показан пример его работы.
Программа 6.4. Пузырьковая сортировка
Для каждого i от l до r-1 внутренний цикл (j) по элементам a[i], ..., a[r] помещает минимальный элемент в a[i], перебирая элементы справа налево и выполняя сравнение с обменом соседних элементов. Наименьший элемент перемещается при всех таких сравнениях и " всплывает " в начало файла. Как и в сортировке выбором, индекс i перемещается по файлу слева направо, а элементы слева от него находятся в окончательных позициях.
template <class Item> void bubble(Item a[], int l, int r) { for (int i = l; i < r; i++) for (int j = r; j > i; j-- ) compexch(a[j-1], a[j]); }
Быстродействие программы 6.4 можно повысить, тщательно оптимизировав внутренний цикл примерно так же, как это было сделано в разделе 6.3 для сортировки вставками (см. упражнение 6.25). В самом деле, сравните коды: программа 6.4 практически идентична неадаптивной сортировке вставками из программы 6.1. Они отличаются только тем, что в сортировке вставками внутренний цикл for перебирает левую (отсортированную) часть массива, а в пузырьковой сортировке — правую (не обязательно упорядоченную) часть массива.
Программа 6.4 использует только инструкции compexch и поэтому не является адаптивной, однако можно повысить ее эффективность для почти упорядоченных файлов, проверяя после каждого прохода, были ли выполнены перестановки (т.е. файл полностью отсортирован, и можно выйти из внешнего цикла). Это усовершенствование ускоряет пузырьковую сортировку на некоторых типах файлов, однако, как будет показано в разделе 6.5, в общем случае оно все равно не так эффективно, как выход из внутреннего цикла сортировки вставками.

Рис. 6.4. Пример выполнения пузырьковой сортировки
В пузырьковой сортировке ключи с малыми значениями постепенно сдвигаются влево. Поскольку проходы выполняются справа налево, каждый ключ меняется местами с ключом слева до тех пор, пока не будет обнаружен ключ с меньшим значением. На первом проходе E меняется местами с L, P и M и останавливается справа от A; затем A продвигается к началу файла, пока не остановится перед другим A, который уже находится на свом месте. Как и в случае сортировки выбором, после i-го прохода i-й по величине ключ устанавливается в окончательное положение, но при этом другие ключи также приближаются к своим окончательным позициям.
Упражнения
6.20. Покажите в стиле рис. 6.4 процесс упорядочения файла E A S Y Q U E S T I O N методом пузырьковой сортировки.
6.21. Приведите пример файла, для которого пузырьковая сортировка выполняет максимально возможное количество перестановок элементов.
6.22. Является ли пузырьковая сортировка устойчивой?
6.23. Объясните, почему пузырьковая сортировка лучше неадаптивной версии сортировки выбором, описанной в упражнении 6.19.
6.24. Экспериментально определите количество сэкономленных проходов для случайных файлов из N элементов, если добавить в пузырьковую сортировку проверку упорядоченности файла.
6.25. Разработайте эффективную реализацию пузырьковой сортировки с минимально возможным числом операторов во внутреннем цикле. Проверьте, что ваши " усовершенствования " не снижают быстродействие программы!
Характеристики производительности элементарных методов СОРТИРОВКИ
Сортировка выбором, сортировка вставками и пузырьковая сортировка являются квадратичными по времени алгоритмами как в худшем, так и в среднем случае; все они не требуют дополнительной памяти. Поэтому время их выполнения отличается лишь на постоянный коэффициент, хотя принципы работы существенно различаются (см. рис. 6.5, рис. 6.6 и рис. 6.7).

увеличить изображение
Рис. 6.5. Динамические характеристики сортировок выбором и вставками
Эти снимки процесса сортировки вставками (слева) и выбором (справа) случайной последовательности иллюстрируют выполнение сортировки обоими методами. Упорядоченность массива показана в виде графика зависимости a[i] от индекса i. Перед началом сортировки график представляет равномерно распределенную случайную величину, а по окончании сортировки он выглядит диагональю из левого нижнего угла в правый верхний угол. Сортировка вставками никогда не забегает вперед за текущую позицию в массиве, а сортировка выбором никогда не возвращается назад.

увеличить изображение
Рис. 6.6. Операции сравнения и обмена в элементарных методах сортировки
На этой диаграмме показаны различия в способах упорядочения файла сортировкой вставками, сортировкой выбором и пузырьковой сортировкой. Сортируемый файл представлен отрезками, которые сортируются по углу наклона. Черные отрезки означают элементы, к которым выполнялся доступ на каждом проходе сортировки; серые отрезки означают не задействованные элементы. В процессе сортировки вставками (слева) вставляемый элемент перемещается на каждом проходе примерно на полпути назад по упорядоченной части. Сортировка выбором (в центре) и пузырьковая сортировка (справа) перебирают на каждом проходе всю неотсортированную часть массива, чтобы найти в ней следующий наименьший элемент. Различие между этими методами заключается в том, что пузырьковая сортировка меняет местами каждую попавшуюся неупорядоченную пару соседних элементов, а сортировка выбором сразу помещает минимальный элемент в окончательную позицию. Это различие проявляется в том, что по мере выполнения пузырьковой сортировки неотсортированная часть массива становится все более упорядоченной.

увеличить изображение
Рис. 6.7. Динамические характеристики двух пузырьковых сортировок
Стандартная пузырьковая сортировка (слева) похожа на сортировку выбором тем, что на каждом проходе один элемент занимает свою окончательную позицию, но одновременно она асимметрично привносит упорядоченность в остальную часть массива. Поочередная смена направления просмотра массива (т.е. просмотр от начала до конца массива меняется на просмотр от конца до начала и наоборот) дает новую разновидность пузырьковой сортировки, получившей название шейкерной сортировки (справа), которая заканчивается быстрее (см. упражнение 6.30).
В общем случае время выполнения алгоритма сортировки пропорционально количеству операций сравнения, выполняемых этим алгоритмом, количеству перемещений или обменов элементов, а, возможно, и тому, и другому сразу. Для случайно упорядоченных данных сравнение элементарных методов сортировки сводится к изучению постоянных коэффициентов, на которые отличаются количества выполняемых операций сравнений и обменов, а также постоянных коэффициентов, на которые отличаются длины внутренних циклов. В случае входных данных с особыми характеристиками времена выполнения различных видов сортировок могут отличаться и более чем на постоянный коэффициент. В данном разделе будут подробно рассмотрены аналитические результаты, на которых основано это заключение.
Лемма 6.1. Сортировка выбором выполняет порядка N2/ 2 сравнений и N обменов элементов.
Данное свойство легко проверить на примере данных, приведенных на рис. 6.2. Это таблица размером N х N, в которой незаштрихованные буквы соответствуют сравнениям. Примерно половина элементов этой таблицы не заштрихована, эти элементы расположены над диагональю. Каждый из N — 1 элементов на диагонали (кроме последнего) соответствует операции обмена. Точнее, исследование кода показывает, что для каждого i от 1 до N — 1 выполняется один обмен и N — i сравнений, так что всего выполняется N — 1 операций обмена и (N — 1) + (N — 2) + ... + 2 + 1 = N (N — 1) / 2 операций сравнения. Эти соображения не зависят от природы входных данных; единственная часть сортировки выбором, которая зависит от характера входных данных — это количество присваиваний переменной min новых значений. В худшем случае эта величина может оказаться квадратичной, однако в среднем она имеет порядок O (N logN) (см. раздел ссылок), поэтому можно сказать, что время выполнения сортировки выбором не чувствительно к природе входных данных.
Лемма 6.2. Сортировка вставками выполняет в среднем порядка N2/ 4 сравнений и N2/ 4 полуобменов (перемещений), а в худшем случае в два раза больше.
Сортировка, реализованная в программе 6.3, выполняет одинаковое количество сравнений и перемещений. Как и в случае леммы 6.1, эту величину легко наглядно увидеть на диаграмме размером N х N, которая демонстрирует подробности работы алгоритма (см. рис. 6.3). Здесь ведется подсчет элементов под главной диагональю — в худшем случае всех. Для случайно упорядоченных входных данных можно ожидать, что каждый элемент проходит в среднем примерно половину пути назад, поэтому необходимо учитывать только половину элементов, лежащих ниже диагонали.
Лемма 6.3. Пузырьковая сортировка выполняет порядка N2/ 2 сравнений и N2/ 2 обменов — как в среднем, так и в худшем случае.
На i-ом проходе пузырьковой сортировки нужно выполнить N — i операций сравнения-обмена, поэтому лемма доказывается так же, как и случае сортировки выбором. Если алгоритм усовершенствован так, что его выполнение прекращается при обнаружении упорядоченности файла, то время его выполнения зависит от входных данных. Если файл уже отсортирован, то достаточно лишь одного прохода, однако в случае обратной упорядоченности i-й проход требует выполнения N— i сравнений и обменов. Как отмечалось ранее, производительность сортировки для обычных данных ненамного выше, чем для худшего случая, однако доказательство этого факта достаточно сложно (см. раздел ссылок).
Хотя понятие частично отсортированного файла по своей природе довольно неточно, сортировка вставками и пузырьковая сортировка хорошо работают с файлами, не обладающими произвольной организацией, которые довольно часто встречаются на практике. Применение в таких случаях универсальных методов сортировки нецелесообразно. Например, рассмотрим выполнение сортировки вставками для уже упорядоченного файла. Для каждого элемента сразу выясняется, что он находится на своем месте, и общее время выполнения оказывается линейным. Это же справедливо и в отношении пузырьковой сортировки, однако для сортировки выбором трудоемкость остается квадратичной.
Определение 6.2. Инверсией называется пара ключей, которые нарушают порядок в файле.
Для подсчета количества инверсий в файле необходимо для каждого элемента просуммировать число элементов слева, которые больше его (мы будем называть это значение количеством инверсий, соответствующих данному элементу). Но это число есть в точности расстояние, на которое должны переместиться элементы во время сортировки вставками. В частично отсортированном файле меньше инверсий, чем в произвольно упорядоченном файле.
Существуют такие типы частично отсортированных файлов, в которых каждый элемент находится близко к своей окончательной позиции. Например, некоторые игроки сортируют имеющиеся у них на руках карты, сначала группируя их по мастям — тем самым помещая близко к окончательным позициям — а затем упорядочивают карты каждой масти по старшинству. Далее мы рассмотрим ряд методов сортировки, которые работают примерно так же: на начальных этапах они размещают элементы вблизи окончательных позиций, в результате чего образуется частично упорядоченный файл, где каждый элемент расположен недалеко от той позиции, которую он должен занять в конечном итоге. Сортировка вставками и пузырьковая сортировка (но не сортировка выбором) эффективно сортируют такие файлы.
Лемма 6.4. Сортировка вставками и пузырьковая сортировка выполняют линейное количество сравнений и обменов в файлах с не более чем постоянным числом инверсий, приходящихся на каждый элемент.
Как было только что сказано, время выполнения сортировки вставками прямо пропорционально количеству инверсий в сортируемом файле. Для пузырьковой сортировки (здесь имеется в виду программа 6.4, где выполнение прекращается, как только файл становится упорядоченным) доказательство требует более тонких рассуждений (см. упражнение 6.29). Для любого элемента каждый проход пузырьковой сортировки уменьшает количество элементов справа, меньших его, в точности на 1 (если оно не было равным 0). Следовательно, для рассматриваемых типов файлов пузырьковая сортировка выполняет не более чем постоянное количество проходов, а значит, количество сравнений и обменов не более чем линейно.
Наиболее часто встречаются другие виды частично упорядоченных файлов, когда к уже отсортированному файлу добавляются нескольких элементов, либо в сортированном файле изменены ключи нескольких элементов. Для таких файлов наиболее эффективна сортировка вставками; сортировка выбором и пузырьковая сортировка не так эффективны.
Для файлов небольших размеров сортировка вставками и сортировка выбором работают примерно в два раза быстрее пузырьковой сортировки, однако время выполнения любого из этих видов квадратично зависит от размера файла (если размер файла увеличивается в 2 раза, то время его сортировки возрастает в 4 раза). Ни один из этих методов не следует использовать для сортировки больших случайно упорядоченных файлов — например, для сортировки Шелла (см. раздел 6.6) показатели, соответствующие приведенным в таблице, не превышают 2. При большой трудоемкости сравнений — например, когда ключи представлены в виде строк — сортировка вставками работает гораздо быстрее, чем два других способа, т.к. в ней выполняется гораздо меньше сравнений. Здесь не рассмотрена ситуация, когда трудоемкими являются операции обмена; в таких случаях лучшей является сортировка выбором.
N | 32-разрядные целочисленные ключи | Строковые ключи | ||||||
---|---|---|---|---|---|---|---|---|
S | I* | I | B | B* | S | I | B | |
1000 | 5 | 7 | 4 | 11 | 8 | 13 | 8 | 19 |
2000 | 21 | 29 | 15 | 45 | 34 | 56 | 31 | 78 |
4000 | 85 | 119 | 62 | 182 | 138 | 228 | 126 | 321 |
Обозначения:
S | Сортировка выбором (программа 6.2) |
I* | Сортировка вставками на основе операций обмена (программа 6.1) |
I | Сортировка вставками (программа 6.3) |
B | Пузырьковая сортировка (программа 6.4) |
B* | Шейкерная сортировка (упражнение 6.30) |
Лемма 6.5. Сортировка вставками использует линейное количество сравнений и обменов для файлов с не более чем постоянным количеством элементов, которые имеют более чем постоянное число соответствующих инверсий.
Время выполнения сортировки вставками зависит от общего числа инверсий в файле и не зависит от характера распределения этих инверсий в файле.
Чтобы сделать выводы о времени выполнения на основании лемм 6.1—6.5, необходимо проанализировать относительную стоимость операций сравнения и обмена, а этот фактор, в свою очередь, зависит от размера элементов и ключей (см. таблицу 6.1). Например, если элементы представляют собой ключи из одного слова, то операция обмена (требующая четырех доступов к массиву) должна быть в два раза более трудоемкой, чем операции сравнения. В такой ситуации время выполнения сортировки вставками и сортировки выбором примерно соизмеримы, а пузырьковая сортировка работает медленнее. Но если элементы гораздо больше ключей, то лучшей является сортировка выбором.
Лемма 6.6. Время выполнения сортировки выбором линейно для файлов с большими элементами и малыми ключами.
Пусть M — отношение размера элемента к размеру ключа. Тогда можно предположить, что сравнение выполняется за 1 единицу времени, а обмен — за M единиц времени. Сортировка выбором затрачивает на операции сравнения порядка
N2/ 2
единиц времени и порядка NM единиц времени на операции обмена. Если M больше постоянного кратного N, то произведение NM превосходит N2, поэтому время выполнения сортировки пропорционально произведению NM, которое, в свою очередь, пропорционально времени, необходимому для перемещения всех данных.
Пусть, например, требуется отсортировать 1000 элементов, каждый из которых состоит из ключа длиной 1 слово и данных длиной 1000 слов, и нужно фактически переупорядочить эти элементы. Тогда трудно найти что-либо лучше сортировки выбором, поскольку время выполнения будет в основном определяться стоимостью перемещения всего миллиона слов данных. В разделе 6.8 будут рассмотрены альтернативы переупоря-дочиванию данных.
Упражнения
6.26. Какой из трех элементарных методов (сортировка выбором, сортировка вставками и пузырьковая сортировка) выполняется быстрее для файла со всеми одинаковыми ключами?
6.27. Какой из трех элементарных методов выполняется быстрее для файла, упорядоченного по убыванию?
6.28. Приведите пример файла из 10 элементов (используйте ключи от A до J), в процессе сортировки которого пузырьковая сортировка выполняет меньше сравнений, чем метод вставок — либо докажите, что такой файл не существует.
6.29. Покажите, что для любого элемента каждый проход пузырьковой сортировки уменьшает количество элементов слева, больших его, в точности на 1 (если оно не было равно 0).
6.30. Реализуйте вариант пузырьковой сортировки, который попеременно выполняет проходы по данным слева направо и справа налево. Этот (более быстродействующий, но и более сложный) алгоритм называется шейкерной сортировкой (shaker sort) .
6.31. Покажите, что лемма 6.5 не выполняется для шейкерной сортировки (см. упражнение 6.30).
6.32. Реализуйте сортировку выбором на PostScript (см. раздел 4.3 лекция №4) и воспользуйтесь полученной реализацией для построения рисунков вида 6.5—6.7. Можно применить рекурсивную реализацию или прочитать в руководстве по PostScript о циклах и массивах.
Сортировка Шелла
Сортировка вставками работает медленно, поскольку в ней выполняются обмены только соседних элементов, и любой элемент может сдвинуться лишь на одну позицию за один шаг. Например, если элемент с наименьшим ключом оказался в конце массива, потребуется N шагов, чтобы он занял нужное место. Сортировка Шелла (shellsort) представляет собой простое расширение метода вставок, быстродействие которого достигается за счет возможности обмена далеко отстоящих друг от друга элементов.
Идея заключается в переупорядочении файла таким образом, чтобы совокупность его h-ых элементов (начиная с любого) образовывала отсортированный файл. Такой файл называется h-упорядоченным (h-sorted). Другими словами, h-упорядоченный файл представляет собой h независимых, упорядоченных и перемежающихся файлов. В процессе h-сортировки при больших значениях h могут меняться местами элементы массива, расположенные далеко друг от друга — это облегчает последующую h-сортировку при меньших значениях h. Использование такой процедуры для любой последовательности значений h, которая заканчивается значением 1, дает упорядоченный файл. В этом и заключается суть сортировки Шелла.
Один из способов реализации сортировки Шелла заключается в независимой сортировке вставками каждого из h подфайлов для каждого h. Несмотря на очевидную простоту этого процесса, возможен еще более простой подход — именно благодаря независимости подфайлов. В процессе h-сортировки файла каждый элемент просто вставляется среди предшествующих элементов в соответствующем h-подфайле, сдвигая большие элементы вправо (см. рис. 6.8). Для этого применяется сортировка вставками, только в ней перемещение по файлу выполняется увеличением или уменьшением индекса на h, а не на 1. Тогда реализация сортировки Шелла сводится к обычным проходам сортировки вставками, как в программе 6.5, но для ряда приращений h. Работа программы показана на рис. 6.9.
А вот какую последовательность шагов следует использовать? В общем случае на этот вопрос трудно найти правильный ответ.
Программа 6.5. Сортировка Шелла
Если отказаться от использования сигнальных ключей и заменить в сортировке вставками каждое " 1 " на " h " , то полученная программа будет выполнять h-сортировку файла. Добавление внешнего цикла, изменяющего значение шага, дает компактную реализацию сортировки Шелла, в которой используется последовательность шагов 1 4 13 4 0 121 364 1093 3280 9841 . . .
template <class Item> void shellsort(Item a[], int l, int r) { int h; for (h = 1; h <= (r-l)/9; h = 3*h+1) ; for ( ; h > 0; h /= 3) for (int i = l+h; i <= r; i++) { int j = i; Item v = a[i]; while (j >= l+h && v < a[j-h]) { a[j] = a[j-h]; j -= h; } a[j] = v; } }
В литературе опубликованы результаты исследований различных последовательностей шагов, некоторые из них хорошо зарекомендовали себя на практике, однако возможно, что наилучшая последовательность еще не найдена. Обычно на практике используются убывающие последовательности шагов, близкие к геометрической прогрессии, так что число шагов логарифмически зависит от размера файла. Например, если размер следующего шага равен примерно половине предыдущего, то для сортировки файла из 1 миллиона элементов потребуется примерно 20 шагов, если отношение примерно равно четверти, то достаточно 10 шагов.

Рис. 6.8. Перемежающиеся 4-сортировки
В верхней части данной диаграммы показан процесс 4-сортировки файла из 15 элементов. Сначала выполняется сортировка вставками подфайла в позициях 0, 4, 8, 12, затем сортировка вставками подфайла в позициях 1, 5, 9, 13, потом сортировка вставками подфайла в позициях 2, 6, 10, 14 и, наконец, сортировка вставками подфайла в позициях 3, 7, 11. Но все четыре подфайла независимы друг от друга, так что тот же результат можно получить, вставляя каждый элемент в соответствующую позицию в его подфайле и перемещаясь назад шагами через четыре элемента (внизу). Нижняя диаграмма получается из первых рядов каждой части верхней диаграммы, затем из вторых рядов каждой части и т.д.

Рис. 6.9. Пример сортировки Шелла
Сортировка файла при помощи 13-сортировки (сверху), затем 4-сортировки (в центре) и 1-сортировки (внизу) не требует выполнения большого количества сравнений (судя по количеству неза-штрихованных элементов). Завершающий проход — обычная сортировка вставками, но при этом ни один из элементов не перемещается далеко, в силу упорядоченности, внесенной двумя первыми проходами.
Использование минимального числа шагов — важное требование, но необходимо учитывать различные арифметические соотношения между шагами, такие как величина их общих делителей и другие свойства. Практически хорошая последовательность шагов может повысить быстродействие алгоритма процентов на 25, но сама задача представляет собой увлекательную загадку — пример неожиданно сложного аспекта у вроде бы простого алгоритма. Последовательность шагов 1 4 13 40 121 364 1093 3280 9841 . . . , используемая в программе 6.5, с отношением соседних шагов примерно в одну треть, была рекомендована Кнутом в 1969 г. (см. раздел ссылок). Она просто вычисляется (начав с 1, следующее значение шага равно утроенному предыдущему плюс 1) и обеспечивает довольно эффективную сортировку даже в случае относительно больших файлов (см. рис. 6.10).
Многие другие последовательности шагов позволяют получить еще более эффективную сортировку, однако программу 6.5 трудно ускорить более чем на 20% даже в случае сравнительно больших значений N. Одной из таких последовательностей является 1 8 2 3 77 281 1073 4193 16577 . . . , т.е. последовательность 4i+1 + 3 • 2i + 1 для i > 0 , которая, похоже, работает быстрее в худшем случае (см. лемму 6.10). На рис. 6.11 показано, что эта последовательность — а также последовательность Кнута и многие другие последовательности шагов — обладают похожими динамическими характеристиками для файлов больших размеров. Вполне возможно, что существуют лучшие последовательности. Несколько идей по улучшению последовательностей шагов рассматриваются в упражнениях.
Но существуют и плохие последовательности шагов: например, 1 2 4 8 16 32 64 128 256 512 1024 2048 ... (первоначальная последовательность, предложенная Шеллом еще в 1959 г. (см. раздел ссылок)), обычно приводит к низкой производительности, поскольку элементы в нечетных позициях не сравниваются с элементами в четных позициях вплоть до последнего прохода. Этот эффект заметен для случайно упорядоченных файлов и становится катастрофическим в худшем случае: время выполнения вырождается до квадратичного, если, например, половина элементов с меньшими значениями находится в четных позициях, а половина элементов с большими значениями — в нечетных позициях (см. упражнение 6.36).
Программа 6.5 вычисляет следующий шаг, разделив текущий шаг на 3 после инициализации, гарантирующей использование одной и той же последовательность шагов. Другой вариант — начать сортировку с h = N/3 или с какой-то другой функции от N. Но лучше избегать таких стратегий, т.к. некоторые значения N могут дать плохие последовательности шагов наподобие описанной выше.
Наше описание эффективности сортировки Шелла не отличается особой точностью, поскольку никто не смог выполнить анализ данного алгоритма. Этот пробел в наших знаниях затрудняет не только вычисление различных последовательностей шагов, но и аналитическое сравнение сортировки Шелла с другими методами.

Рис. 6.10. Сортировка Шелла случайно распределенной перестановки
Каждый проход сортировки Шелла вносит упорядоченность в файл как целое. Сначала выполняется 40-сортировка файла, затем 13-сортировка, потом 4-сортировка, и, наконец, 1-сортировка. Каждый проход приближает файл к окончательному состоянию.
Не известно даже функциональное выражение для времени выполнения сортировки Шелла (более того, это выражение зависит от выбора последовательности шагов). Кнут обнаружил, что неплохо описывают ситуацию функциональные формы
N (logN)2 и N1,25
, а дальнейшие исследования показали, что некоторые виды последовательностей описываются более сложными выражениями вида .
В завершение текущего раздела отвлечемся от основной темы и рассмотрим некоторые известные результаты исследования сортировки методом Шелла. Наша основная цель — показать, что даже простые с виду алгоритмы могут обладать сложными свойствами, а анализ алгоритмов не только имеет практическое значение, но и может представлять собой интересную научную задачу. Приведенная ниже информация может оказаться полезной читателям, которых заинтересовал поиск новых, более удачных последовательностей шагов сортировки Шелла; остальные могут сразу перейти к разделу 6.7.
Лемма 6.7. В результате h-сортировки k-упорядоченного файла получается h- и k-упорядоченный файл.
Доказать этот вроде бы очевидный факт совсем не просто (см. упражнение 6.47).
Лемма 6.8. Сортировка Шелла выполняет менее N(h — 1)(k — 1)/g сравнений при g-сортировке h- и k-упорядоченного файла, если h и k взаимно просты.
Причина этого утверждения показана на рис. 6.12. Ни один элемент, расположенный дальше (h — 1) (k — 1) позиций слева от любого заданного элемента х, не может быть больше х, если h и k взаимно просты (см. упражнение 6.43). При g-сортировке проверяется не более одного из каждых g таких элементов.
Лемма 6.9. Сортировка Шелла выполняет менее
O(N3/2) сравнений для последовательности шагов
1 4 13 40 121 364 1093 3280 9841 . . .
Для больших шагов имеются h подфайлов размером N/h, и в худшем случае трудоемкость равна примерно
N2/h
. При малых шагах из леммы 6.8 следует, что трудоемкость составляет приблизительно Nh. Доказательство следует из применения лучшего из этих значений для каждого шага. Лемма справедлива для любой экспоненциально возрастающей последовательности со взаимно простыми членами.

увеличить изображение
Рис. 6.11. Динамические характеристики сортировки Шелла (две различные последовательности шагов)
Представленный на рисунке процесс выполнения сортировки Шелла можно сравнить с закрепленной в углах резиновой лентой, стягивающей все точки ленты к диагонали. Здесь показаны две последовательности шагов: 121 40 13 4 1 (слева) и 209 109 41 19 5 1 (справа). Вторая последовательность требует на один проход больше, но выполняется быстрее, поскольку каждый ее проход более эффективен.

Рис. 6.12. 4- и 13-упорядоченный файл
Нижний ряд изображает массив, где заштрихованные квадратики обозначают элементы, которые должны быть меньше или равны крайнему правому элементу, если массив 4- и 13-упорядочен. 4 верхних ряда показывают происхождение нижнего ряда. Если правый элемент находится в массиве в позиции i, то 4-упорядочение означает, что элементы массива в позициях i — 4, i — 8, i — 12, ... меньше или равны ему (верхний ряд). 13-упорядочение означает, что меньше или равен ему элемент i — 13, а вместе с ним, в силу 4-упорядочения, и элементы i — 17, i — 21, i — 25, ... (второй ряд сверху). Аналогично меньше или равен ему элемент в позиции i — 26, а вместе с ним, в силу 4-упорядочения, и элементы i — 30, i — 34, i — 38, ... (третий ряд сверху), и т.д. Оставшиеся незаштрихованными квадратики — те, которые могут быть больше, чем элемент слева; здесь их не более 18 (самый дальний элемент находится в позиции i — 36). Поэтому для сортировки вставками 13- и 4-упорядоченного файла из N элементов нужно выполнить не более 18N сравнений.
Лемма 6.10. Сортировка Шелла выполняет менее O (N4/3) сравнений для последовательности шагов 1 8 23 77 281 1073 4193 16577 . . .
Доказательство этой леммы практически не отличается от доказательства леммы 6.9. Из леммы, аналогичной лемме 6.8, следует, что трудоемкость сортировки для небольших шагов имеет порядок
Nh1/2
. Доказательство этой леммы требует привлечения аппарата теории чисел, что выходит за рамки данной книги (см. раздел ссылок).
Последовательности шагов, которые рассматривались до сих пор, эффективны потому, что в них соседние элементы взаимно просты. Другое семейство последовательностей шагов эффективно именно благодаря тому, что такие элементы не являются взаимно простыми.
В частности, из доказательства леммы 6.8 следует, что в процессе завершающей сортировки вставками 2- и 3-упорядоченного файла каждый элемент перемещается не более чем на одну позицию. Это значит, что такой файл можно упорядочить одним проходом пузырьковой сортировки (а дополнительный цикл сортировки вставками не нужен). Далее, если файл 4-упорядочен и 6-упорядочен, то каждый элемент также перемещается максимум на одну позицию при его 2-сортировке (поскольку каждый подфайл 2- и 3-упорядочен). И если файл 6-упорядочен и 9-упорядочен, то каждый элемент перемещается не более чем на одну позицию при его 3-сортировке. Продолжая эти рассуждения, мы приходим к идее, которую опубликовал Пратт в 1971 г. (см. раздел ссылок).
Метода Пратта основан на использовании треугольника шагов, причем каждое входящее в этот треугольник число в два раза больше числа, стоящего сверху справа, и в три раза больше числа, стоящего в треугольнике сверху слева.

Рис. .
Если мы используем эти числа снизу вверх и справа налево как последовательность шагов в сортировке Шелла, то каждому шагу х в нижнем ряду предшествуют значения 2х и 3х. Поэтому каждый подфайл оказывается 2-упорядочен и 3-упорядочен, и в процессе всей сортировки ни один элемент не передвигается больше чем на одну позицию!
Лемма 6.11. Сортировка Шелла выполняет менее O(N (logN)2 ) сравнений для последовательности шагов 1 2 3 4 6 9 8 12 18 27 16 24 36 54 81 . . .
Число шагов из треугольника, которые меньше N, определенно меньше (log2N)2.
Шаги, предложенные Праттом, на практике обычно работают хуже других, поскольку их слишком много. Но этот же принцип позволяет строить последовательности шагов из любых двух взаимно простых чисел h и k. Такие последовательности шагов показывают хорошие результаты, поскольку границы для худших случаев, соответствующие лемме 6.11, дают завышенную оценку трудоемкости для произвольно упорядоченных файлов.
Задача построения хорошей последовательности шагов для сортировки Шелла представляет собой прекрасный пример сложного поведения простых алгоритмов. разумеется, мы не сможем выполнять такой же подробный анализ всех рассматриваемых алгоритмов: и место в книге ограничено, и, как и в случае сортировки Шелла, может понадобиться математический аппарат, выходящий за рамки книги; возможны даже незавершенные исследовательские задачи. Однако многие алгоритмы, рассматриваемые в данной книге, появились в результате интенсивных аналитических и эмпирических исследований, выполненных многими исследователями за несколько последних десятилетий, и мы просто воспользуемся плодами их трудов. Эти исследования показывают, что задача повышения эффективности сортировки может как представлять собой интересную научную проблему, так и давать практическую отдачу, даже в случаях простых алгоритмов. В таблица 6.2 приведены эмпирические данные, которые показывают, что некоторые способы построения последовательностей шагов хорошо работают на практике; относительно короткая последовательность 1 8 23 77 281 1073 4193 16577 . . . — одна из самых простых среди используемых в реализациях сортировки Шелла.
Сортировка Шелла работает в несколько раз быстрее других элементарных методов сортировки, даже если шаги являются степенями 2, а некоторые специальные последовательности шагов ускоряют ее в 5 и более раз. Три лучших последовательности, приведенные в данной таблице, существенно различаются по их построению. Сортировка Шелла вполне пригодна для практического применения даже в случае больших файлов — в отличие от сортировки выбором, сортировки вставками и пузырьковой сортировки (см. таблицу 6.1).
N | O | K | G | S | P | I |
---|---|---|---|---|---|---|
12500 | 16 | 6 | 6 | 5 | 6 | 6 |
25000 | 37 | 13 | 11 | 12 | 15 | 10 |
50000 | 102 | 31 | 30 | 27 | 38 | 26 |
100000 | 303 | 77 | 60 | 63 | 81 | 58 |
200000 | 817 | 178 | 137 | 139 | 180 | 126 |
Обозначения:
O | 1 2 4 8 16 32 64 128 256 512 1024 2048 . . . | |
K | 1 4 13 40 121 364 1093 3280 9841 . . . | (лемма 6.9) |
G | 1 2 4 10 23 51 113 249 548 1207 2655 5843 . . . | (упражнение 6.40) |
S | 1 8 23 77 281 1073 4193 16577 . . . | (лемма 6.10) |
P | 1 7 8 49 56 64 343 392 448 512 2401 2744 . . . | (упражнение 6.44) |
I | 1 5 19 41 109 209 505 929 2161 3905 . . . | (упражнение 6.45) |
Из рис. 6.13 видно, что сортировка Шелла достаточно быстро работает на различных видах файлов, а не только на случайно упорядоченных. Построить файл, на котором сортировка Шелла работает медленно для заданной последовательности шагов — достаточно сложная задача (см. упражнение 6.42). Как уже было сказано, существуют плохие последовательности шагов, при использовании которых сортировка Шелла может выполнить квадратичное количество сравнений в худшем случае (см. упражнение 6.36), однако доказано, что для большого количества других последовательностей этот показатель намного ниже. После выполнения нескольких проходов все эти файлы упорядочены одинаково — значит, время выполнения сортировки не очень чувствительно к виду входных данных.

Рис. 6.13. Динамические характеристики сортировки методом Шелла различных типов файлов
На представленных диаграммах показана работа сортировки Шелла с последовательностью шагов 2 09 109 41 19 5 1 для равномерного распределения, нормального распределения, почти упорядоченного файла, почти обратно упорядоченного файла и случайно упорядоченного файла с 10 различными значениями ключей (слева направо, сверху). Время выполнения каждого прохода зависит от степени упорядоченности файла перед началом прохода.
Сортировка Шелла хорошо работает во многих приложениях, поскольку она упорядочивает за приемлемое время даже довольно большие файлы и реализуется в виде компактной и легко отлаживаемой программы. В последующих нескольких главах мы ознакомимся с методами сортировки, которые более эффективны, но работают, допустим, лишь в два раза быстрее (а то и меньше) при небольших значениях N и существенно сложнее. В общем, если нужно быстрое решение задачи сортировки, но нет желания возиться с интерфейсом системной сортировки, воспользуйтесь сортировкой Шелла, а потом решите, стоит ли напрягаться, чтобы заменить его более совершенным методом.
Упражнения
6.33. Устойчива ли сортировка Шелла?
6.34. Покажите, как следует реализовать сортировку Шелла с последовательностью шагов 1 8 23 77 281 1073 4193 16577 . . . с непосредственным вычислением последовательных шагов — примерно как в коде для последовательности Кнута.
6.35. Приведите диаграммы, соответствующие рис. 6.8 и рис. 6.9 для ключей E A S Y Q U E S T I O N.
6.36. Определите время выполнения сортировки Шелла с последовательностью шагов 1 2 4 8 16 32 64 128 264 512 1024 2048 . . . для упорядочения файла, состоящего из целых чисел 1, 2, ..., N в нечетных позициях и N + 1, N + 2, ..., 2N в четных позициях.
6.37. Напишите программу-драйвер для сравнения последовательностей шагов сортировки Шелла. Введите последовательности из стандартного ввода (по одной в строке); затем используйте их для сортировки 10 файлов с произвольной организацией длиной N = 100, 1000 и 10000. Подсчитывайте количество сравнений или замеряйте фактическое время выполнения.
6.38. Экспериментально определите, позволяет ли добавление или удаление какого-то шага улучшить последовательность шагов 1 8 23 77 281 1073 4193 16577 . . . для N = 10 000.
6.39. Экспериментально определите значение х, которое обеспечивает минимальное время сортировки случайно упорядоченных файлов, если заменить на х шаг 13 в последовательности 1 4 13 40 121 364 1093 3280 9841 . . . для N= 10000.
6.40. Экспериментально определите значение а, которое обеспечивает минимальное время сортировки случайно упорядоченных файлов для последовательности шагов 1, ,
,
,
, ... и N = 10 000.
6.41. Найдите последовательность из трех шагов, которая выполняет минимально возможное количество сравнений для случайно упорядоченных файлов, содержащих 1000 элементов.
6.42. Подберите файл из 100 элементов, для которого сортировка Шелла с последовательностью шагов 1 8 2 3 77 выполняет максимальное количество операций сравнения.
6.43. Докажите, что если h и k — взаимно простые числа, то любое число, большее или равное (h — 1)(k — 1), можно представить в виде линейной комбинации h и k с неотрицательными коэффициентами. Совет: покажите, что если любые два из первых h — 1 чисел, кратных k, при делении на h дают при деления на h одинаковый остаток, то h и k должны иметь общий делитель.
6.44. Экспериментально определите значения h и k, которые обеспечивают минимальное время сортировки случайно упорядоченных файлов из 10 000 элементов, если в качестве шагов сортировки используется последовательность, подобная последовательности Пратта, построенная для этих h и k.
6.45. Последовательность шагов 1 5 19 41 109 209 505 929 2161 3905 . . . построена с помощью слияния последовательностей и
для i > 0. Сравните результаты использования этих последовательностей по отдельности с результатом использования их слияния на примере сортировки 10 000 элементов.
6.46. Последовательность шагов 1 3 7 21 48 112 336 861 1968 4592 13776 . . . получена из последовательности, состоящей из взаимно простых чисел, скажем,
1371641101,с последующим построением треугольника на манер последовательности Пратта. Только теперь i-й ряд треугольника получается умножением первого элемента в (i — 1)-ом ряду на i-й элемент базовой последовательности и умножением каждого элемента в (i — 1)-ом ряду на (i + 1)-й элемент базовой последовательности. Найдите экспериментально базовую последовательность, которая превосходит указанную выше при сортировке 10 000 элементов.
6.47. Завершите доказательства лемм 6.7 и 6.8.
6.48. Напишите реализацию, основанную на алгоритме шейкерной сортировки (упражнение 6.30), и сравните ее со стандартным алгоритмом. Совет: последовательности шагов должны существенно отличаться от последовательностей, применяемых в стандартных алгоритмах.
Сортировка других типов данных
Большинство алгоритмов сортировки вполне можно рассматривать как упорядочение массивов чисел в числовом порядке или упорядочение символов в алфавитном порядке. Действительно, эти алгоритмы обычно не зависят от типа сортируемых элементов, и поэтому их нетрудно распространить на более общие случаи. Ранее мы подробно рассмотрели вопросы разбиения программ на отдельные независимые модули, что позволяет реализовать нужные типы данных, а также абстрактные типы данных (см. лекция №3 и лекция №4). В данном разделе обсуждаются способы применения этих понятий для создания реализаций, интерфейсов и клиентских программ для алгоритмов сортировки. А именно, будут рассматриваться интерфейсы для:
- элементов или обобщенных сортируемых объектов
- массивов элементов.
Тип данных " элемент " позволяет использовать программы сортировки для любых типов данных, для которых определены необходимые базовые операции. Такой подход позволяет эффективно создавать реализации как для простых, так и для абстрактных типов данных. Интерфейс " массив " менее критичен для нашей задачи; мы используем его в качестве примера работы с многомодульной программой, которая имеет дело с несколькими типами данных. Здесь будет рассмотрена только одна (примитивная) реализация интерфейса массива.
Программа 6.6 является клиентской программой с теми же обобщенными возможностями, что и функция main из программы 6.1, но с дополнительным кодом для работы с массивами и элементами, инкапсулированными в отдельных модулях. Это позволяет, в частности, тестировать различные программы сортировки на различных типах данных путем замены одних модулей на другие, не внося при этом никаких изменений в клиентскую программу. Программа 6.6 обращается также к интерфейсу, где описаны операции exch и compexch, используемые в реализациях алгоритмов сортировки. Можно было бы включить их в интерфейс Item.h, однако реализации в программе 6.1 имеют легко понятную семантику при определенных операциях = и <, поэтому проще содержать их в одном модуле, которым могут пользоваться все реализации сортировки для всех типов элементов. Чтобы завершить реализацию, необходимо вначале точно определить интерфейсы с типами массива и элемента.
Программа 6.6. Драйвер сортировки массивов
Данный драйвер для основных алгоритмов сортировки массивов использует три явных интерфейса: (1) для типа данных, который инкапсулирует операции для обобщенных элементов; (2) несколько более высокий уровень для функций exch и compexch, используемых в реализациях; и (3) для функций, которые инициализируют и выводят (а также сортируют!) массивы. Такое разбиение драйвера на модули позволяет без каких-либо изменений применять каждую реализацию сортировки для упорядочения различных типов данных, совместно использовать реализации операций обмена и сравнения-обмена, а также компилировать функции для массивов отдельно (возможно, для их использования в других драйверах).
#include <stdlib.h> #include "Item.h" #include "exch.h" #include "Array.h" main(int argc, char *argv[]) { int N = atoi(argv[1]), sw = atoi(argv[2]); Item *a = new Item[N]; if (sw) rand(a, N); else scan(a, N); sort(a, 0, N-1); show(a, 0, N-1); }
Интерфейс программы 6.7 определяет примеры высокоуровневых операций, которые могут понадобиться при работе с массивами. Необходима возможность инициализировать массив либо случайными ключами, либо ключами из стандартного ввода, необходима возможность сортировать элементы (естественно!), и необходима возможность вывода содержимого массива. Это лишь несколько примеров; в конкретном приложении может понадобиться определить и другие операции (примером подобного интерфейса может служить класс Vector из библиотеки стандартных шаблонов). Программа 6.7 позволяет подставлять различные реализации разных операций без внесения изменений в клиентскую программу, которая использует этот интерфейс — в данном случае, в функцию main программы 6.6. Реализациями функции sort могут служить различные рассматриваемые нами реализации сортировок. В программе 6.8 приведены простые реализации других функций. Модульная организация позволяет подставлять другие реализации, нужные в конкретных приложениях. Например, может понадобиться реализация функции show, которая выводит только часть массива при тестировании работы на очень больших массивах.
Подобным же образом, чтобы иметь возможность работать с конкретными типами элементов и ключей, мы определим их типы и объявим все необходимые операции над ними в отдельном интерфейсе, а затем приведем реализации этих операций, определенных в этом интерфейсе. Например, пусть имеется некоторое бухгалтерское приложение, где целочисленный ключ соответствует номеру счета клиента, а число с плавающей точкой — балансу счета этого клиента. Программа 6.9 представляет собой пример интерфейса, который определяет тип данных для подобных приложений. Код этого интерфейса объявляет операцию <, которая нужна для сравнения ключей, а также функции, которые генерируют случайные ключи, считывают ключи и выводят значения ключей. В программе 6.10 содержатся реализации функций для данного простого примера. Разумеется, эти реализации можно приспособить под конкретные приложения.
Программа 6.7. Интерфейс для типа данных " массив "
Данный интерфейс Array.h определяет высокоуровневые функции для массивов абстрактных элементов: инициализация случайными значениями, инициализация значениями из стандартного ввода, вывод содержимого и сортировку содержимого.
template <class Item> void rand(Item a[], int N); template <class Item> void scan(Item a[], int &N); template <class Item> void show(Item a[], int l, int r); template <class Item> void sort(Item a[], int l, int r);
Программа 6.8. Реализация типа данных " массив "
Данный код представляет собой реализацию функций, определенных в программе 6.7. Здесь используются типы данных и базовые функции для работы с ними, которые определены в отдельном интерфейсе (см. программу 6.9).
#include <iostream.h> #include <stdlib.h> #include "Array.h" template <class Item> void rand(Item a[], int N) { for (int i = 0; i < N; i++) rand(a[i]); } template <class Item> void scan(Item a[], int &N) { for (int i = 0; i < N; i++) if (!scan(a[i])) break; N = i; } template <class Item> void show(Item a[], int l, int r) { for (int i = l; i <=r; i++) show(a[i]); cout << endl; }
Программа 6.9. Пример интерфейса для типа данных " элемент "
Файл Item.h, включенный в программу 6.6, дает определение типа данных для сортируемых элементов. В этом примере элементами являются небольшие записи, состоящие из целочисленных ключей и информации в виде числа с плавающей точкой. Мы объявляем, что перегруженная операция < будет реализована отдельно, равно как и три функции: scan (считывает Item в свой аргумент), rand (сохраняет случайный Item в своем аргументе) и show (выводит Item).
typedef struct record { int key; float info; } Item; int operator<(const Item&, const Item&); int scan(Item&); void rand(Item&); void show(const Item&);
Программа 6.10. Пример реализации типа данных " элемент "
Этот код является реализацией перегруженной операции < и функций scan, rand и show, которые объявлены в программе 6.9. Поскольку записи представляют собой небольшие структуры, в функции exch можно использовать встроенный оператор присваивания, не беспокоясь о затратах на копирование элементов.
#include <iostream.h> #include <stdlib.h> #include "Item.h" int operator<(const Item& A, const Item& B) { return A.key < B.key; } int scan(Item& x) { return (cin >> x.key >> x.info) != 0; } void rand(Item& x) { x.key = 100 0*(1.0*rand()/RAND MAX); x.info = 1.0*rand()/RAND MAX; } void show (const Item& x) { cout << x.key << " " << x.info << endl; }
Например, тип Item может быть абстрактным типом данных, определенным в виде класса С++, а ключи могут быть функциями-членами класса, а не членами структуры. Такие АТД рассматриваются в лекция №12.
Программы 6.6—6.10 вместе с любыми подпрограммами сортировки (без изменений) из разделов 6.2—6.6, выполняют проверку сортировки на небольших записях. Написав подобные интерфейсы и реализации для других типов данных, мы сможем применять наши реализации для сортировки различных видов данных — таких как комплексные числа (см. упражнение 6.50), векторы (см. упражнение 6.55) или полиномы (см. упражнение 6.56) — без каких-либо изменений в кодах программ сортировки. Для более сложных типов элементов придется написать более сложные интерфейсы и реализации, однако эта задача полностью отделена от вопросов построения алгоритмов сортировки, которые нас здесь интересуют. Одни и те же механизмы можно задействовать для большинства методов сортировки, которые рассмотрены в данной главе, а также будут изучаться в лекциях 7—9 . В разделе 6.10 мы подробно проанализируем одно существенное исключение — оно дает начало целому семейству важных алгоритмов сортировки, которые должны иметь совсем другое оформление; о них речь пойдет в лекция №10.
Рассмотренный в этом разделе подход находится где-то посередине между программой 6.1 и готовым к практическому применению полностью абстрактным набором реализаций вместе с проверкой ошибок, управлением памятью и даже более универсальными возможностями. Подобные вопросы оформления приобретают все большую актуальность в некоторых современных областях программирования и приложений. Ряд вопросов придется оставить без ответа — ведь наша основная цель заключается в том, чтобы, рассматривая сравнительно простые механизмы, продемонстрировать широкую применимость наших реализаций сортировок.
Упражнения
6.49. Напишите версии программ 6.9 и 6.10, в которых вместо функций scan и show используются перегруженные операции << и >>, а также измените программу 6.8, чтобы учесть изменения в интерфейсе.
6.50. Напишите интерфейс и реализацию для обобщенного типа данных элемента, который позволит сортировать комплексные числа х + iy, используя в качестве ключа модуль . Совет: эффективность можно повысить, игнорируя квадратный корень.
6.51. Напишите интерфейс, который определяет абстрактный тип данных первого класса для обобщенных элементов (см. раздел 4.8 лекция №4), и реализацию, в которой, как и в предыдущем упражнении, элементами являются комплексные числа. Протестируйте полученную программу с помощью программ 6.3 и 6.6.
6.52. Добавьте в тип данных массива в программах 6.8 и 6.7 функцию check, которая проверяет, упорядочен ли массив.
6.53. Добавьте в тип данных массива в программах 6.8 и 6.7 функцию testinit, которая генерирует тестовые данные с распределениями, похожими на приведенные на рис. 6.13. У этой функции должен быть целочисленный аргумент, через который клиентская программа сможет задать нужное распределение.
6.54. Измените программы 6.7 и 6.8, чтобы реализовать абстрактный тип данных. (Ваша реализация должна размещать массив в памяти и сопровождать его так, как в реализациях стеков и очередей из лекция №3)
6.55. Напишите интерфейс и реализацию для обобщенного типа данных элемента, чтобы известные методы сортировки могли упорядочивать многомерные векторы из d целых чисел по первой компоненте, векторы с равными первыми компонентами по второй компоненте, векторы с равными первыми и вторыми компонентами по третьей компоненте и т.д.
6.56. Напишите интерфейс и реализацию для обобщенного типа данных элемента, чтобы известные методы сортировки могли упорядочивать полиномы (см. раздел 4.9 лекция №4). Правило упорядочивания определите самостоятельно.
Сортировка индексов и указателей
Разработка реализации типа данных строки, наподобие программ 6.9 и 6.10, представляет особый интерес, поскольку строки символов широко используются как ключи сортировки. Строки могут иметь различные длины, в том числе и очень большие, поэтому создание, удаление и сравнение строк могут потребовать значительных затрат, и следует проявить особую осторожность, чтобы в реализации не было излишних операций этого вида.
С этой целью мы применяем такое представление данных, которое состоит из указателя (на массив символов) — стандартное представление строки в стиле языка С. А первая строка программы 6.9 изменяется на
typedef struct { char *str; } Item;
что превращает ее в интерфейс для строк. Указатель помещается в структуру потому, что C++ не позволяет перегружать операцию < для встроенных типов, к которым относятся указатели. Подобные ситуации не являются чем-то необычным в C++: класс (или структура), который приспосабливает интерфейс для другого типа данных, называется классом-оболочкой (wrapper class). В данном случае мы не требуем от класса-оболочки слишком многого, но в некоторых случаях возможны более сложные реализации. Вскоре будет рассмотрен еще один пример.
Программа 6.11 представляет собой реализацию для строковых элементов. Перегружен ная операция < легко реализуется с помощью функции сравнения строк из библиотеки С, но реализовать функцию scan (и rand) труднее, поскольку нужно учитывать распределение памяти для строк. Программа 6.11 использует метод, который был рассмотрен в лекция №3 (программа 3.17) — использование буфера в реализации типа данных. Другие варианты — динамическое выделение памяти для каждой строки, использование реализации класса вроде класса String из библиотеки стандартных шаблонов, либо использование буфера в клиентской программе. Для сортировки строк символов можно воспользоваться любым из этих подходов (с соответствующими интерфейсами), используя любую реализацию сортировки из рассмотренных нами.
Мы сталкиваемся с необходимостью подобного управления памятью всякий раз, когда разбиваем программу на модули. Кто будет отвечать за управление памятью, соответствующее конкретной реализации какого-то объекта — клиентская программа, реализация типа данных или система? На этот вопрос нет готового однозначного ответа (хотя некоторые разработчики языков программирования являются ярыми приверженцами одного из вариантов). В некоторых современных системах программирования (включая некоторые реализации С++) имеются встроенные механизмы автоматического управления памятью. Мы еще вернемся к этому вопросу в лекция №9, при обсуждении реализации более сложных абстрактных типов данных.
Программа 6.11 представляет собой пример сортировки указателей (pointer sort), и мы вскоре рассмотрим этот принцип в более общем виде. Другой простой подход к сортировке без (непосредственных) перемещений элементов заключается во введении индексного массива, когда доступ к ключам элементов выполняется только для их сравнения.
Программа 6.11. Реализация типа данных для строковых элементов
Эта реализация позволяет упорядочивать строки в стиле языка С, используя наши программы сортировки. Для представления данных используется структура, которая содержит указатель на символ (см. в тексте), благодаря чему сортировка выполняется для массива указателей на символы, переупорядочивая их таким образом, что строки, на которые они указывают, выстраиваются в алфавитно-цифровом порядке. Чтобы наглядно продемонстрировать процесс управления памятью, здесь определен буфер памяти фиксированного размера, где хранятся символы строк; наверно, удобнее было бы динамическое распределение памяти. Реализация функции rand опущена.
#include <iostream.h> #include <stdlib.h> #include <string.h> #include "Item.h" static char buf[100000]; static int cnt = 0; int operator<(const Item& a, const Item& b) { return strcmp(a.str, b.str) < 0; } void show(const Item& x) { cout << x.str << " "; } int scan(Item& x) { int flag = (cin >> (x.str = &buf[cnt])) != 0; cnt += strlen(x.str)+1; return flag; }
Предположим, что сортируемые элементы находятся в массиве data[0], ..., data[N-1], и по каким-то причинам мы не хотим перемещать их (возможно, из-за огромных размеров). Тогда для упорядочения используется второй массив а с индексами сортируемых элементов. Первоначально его элементы a[i] инициализируются значениями i для i = 0, ..., N-1. То есть вначале a[0] содержит индекс первого элемента данных, a[1] — второго элемента данных и т.д. А сама сортировка сводится к такому переупорядочению массива индексов, что a[0] будет содержать индекс элемента данных с наименьшим значением ключа, a[1] — индекс следующего элемента данных с минимальным значением ключа и т.д. Тогда эффект упорядочения достигается доступом к ключам через индексы — например, так можно вывести массив в порядке возрастания его ключей.
В этом случае нужно указать, что сортировка выполняет упорядочивание индексов массива, а не просто целых чисел. Тип Index следует определить так, чтобы можно было перегрузить операцию < следующим образом:
int operator<(const Index& i, const Index& j) { return data[i] < data[j]; }
Теперь при наличии массива a объектов типа Index любая из наших функций сортировки переупорядочит индексы в массиве a так, что значение a[i] будет равно числу ключей, меньших, чем ключ элемента data[i] (индекс a[i] в отсортированном массиве). (Для простоты здесь предполагается, что данные представляют собой просто ключи, а не элементы в полном объеме; этот принцип можно распространить на более крупные и сложные элементы — нужно лишь изменить операцию < для доступа конкретно к ключам таких элементов, либо воспользоваться функцией-членом класса для вычисления ключей.) Для определения типа Index используется класс-оболочка:
struct intWrapper { int item; intWrapper(int i = 0) { item = i; } operator int() const { return item; } }; type def intWrapper Index;
Конструктор в данной структуре преобразует любое значение типа int в Index, а операция приведения типа int() преобразует любое значение Index обратно в int — значит, объекты типа Index можно использовать везде, где возможно применение объектов встроенного типа int.
Пример индексации, где одни и те же элементы сортируются по двум различным ключам, представлен на рис. 6.14. Одна клиентская программа может определить операцию < для работы с одним ключом, а другая — для использования другого ключа, но обе они могут воспользоваться одной и той же программой сортировки для построения массива индексов, который обеспечит доступ к элементам в порядке возрастания их ключей.

Рис. 6.14. Пример индексной сортировки
Работая с индексами, а не с самими записями, можно упорядочить массив одновременно по нескольким ключам. В этом примере данные могут быть фамилиями студентов и их оценками, второй столбец представляет собой результат индексной сортировки по именам, а третий столбец — результат индексной сортировки по убыванию оценок. Например, Wilson — фамилия, идущая по алфавиту последней, и ей соответствует десятая оценка, а фамилия Adams является первой по алфавиту, и ей соответствует шестая оценка. Переупорядочение N различных неотрицательных целых чисел, меньших N, в математике называется перестановкой, т.е. индексная сортировка выполняет перестановку. В математике перестановки обычно определяются как переупорядочения целых чисел от 1 до N; мы же будем употреблять числа от 0 до N— 1, чтобы подчеркнуть прямую связь между перестановками и индексами массивов в С++.
Такой поход с использованием массива индексов вместо реальных элементов работает в любом языке программирования, который поддерживает массивы. Другая возможность заключается в использовании указателей, как в реализации недавно рассмотренного строкового типа данных (программа 6.11). В случае сортировки массива элементов фиксированного размера сортировка указателей практически эквивалентна индексной сортировке, только к каждому индексу прибавляется адрес массива. Однако сортировка указателей — гораздо более общий вид сортировки, т.к. указатели могут указывать на что угодно, а сортируемые элементы не обязательно должны иметь фиксированный размер. Как и в случае индексной сортировки, если а есть массив указателей на ключи, то результатом вызова функции sort будет такое переупорядочение указателей, что последовательный доступ к ним означает доступ к отсортированным ключам. Операции сравнения реализуются с помощью обращений по указателям, а операции обмена реализуются как обмен указателей.
Функция qsort из стандартной библиотеки C представляет собой сортировку указателей (см. программу 3.17), которая принимает функцию сравнения в качестве аргумента (а не использует перегруженную операцию <, как мы обычно делаем). У этой функции четыре аргумента: массив, количество сортируемых элементов, размер элементов и указатель на функцию, которая выполняет сравнение двух элементов, обращаясь к ним через заданные указатели. Например, если тип Item определен как char*, то приведенный ниже код реализует сортировку строк в соответствии с принятыми нами соглашениями:
int compare (void *i, void *j) { return strcmp(*(Item *)i, *(Item *)j); } void sort (Item a[], int l, int r) { qsort(a, r-l+1, sizeof(Item), compare); }
В этом интерфейсе не указан работающий алгоритм, хотя на практике широко применяется быстрая сортировка см. лекция №7. В лекция №7 мы рассмотрим многие причины, почему это так. В данной главе, а также в главах 7—11, мы постепенно разберемся, почему в некоторых конкретных случаях более удобны другие методы сортировки. Кроме того, мы рассмотрим возможности ускорения вычислений в тех случаях, когда время выполнения сортировки является критическим фактором в приложении.
В обычных приложениях указатели используются для доступа к записям, которые могут иметь несколько ключей. Например, записи, содержащие фамилии студентов с оценками или фамилии людей с их возрастами можно определить следующим образом:
struct record { char[30] name; int num; }
Вполне может потребоваться выполнить их сортировку, используя в качестве ключа любое из этих полей. Программы 6.12 и 6.13 могут служить примерами интерфейса и реализации сортировки указателей, которые позволяют сделать это. В них используются массив указателей на записи и различные реализации операции < для различных применений сортировки.
Программа 6.12. Интерфейс типа данных для элементов в виде записей
Записи содержат два ключа: ключ строкового типа (например, фамилия) в первом поле и целое число (например, оценка) — во втором поле. Мы считаем, что эти записи слишком большие, чтобы их копировать, поэтому Item определяется как структура, содержащая указатель на запись.
struct record { char name[30]; int num; }; typedef struct { record *r; } Item; int operator<(const Item&, const Item&); void rand(Item&); void show(const Item&); int scan(Item&);
Программа 6.13. Реализация типа данных для элементов в виде записей
Приведенные ниже реализации функций scan и show для записей работают в стиле реализации строкового типа данных из программы 6.11: они выделяют память для хранения строк и работают с этой памятью. Реализация операции < находится в отдельном файле, чтобы подставлять различные реализации и таким образом менять ключи сортировки без изменения остального кода.
static record data[maxN]; static int cnt = 0; void show(const Item& x) { cout << x.r->name << " " << x.r->num << endl; } int scan(Item& x) { x.r = &data[cnt++]; return (cin >> x.r->name >> x.r->num) != 0; }
Например, если откомпилировать программу 6.13 вместе с файлом, содержащим код
#include "Item.h" int operator<(const Item &a, const Item &b) { return a.r->num < b.r->num); }
то получим тип данных элементов, для которых любая из наших реализаций функции sort выполнит сортировку указателей по целочисленному полю; а если откомпилировать программу 6.13 вместе с файлом, содержащим код
#include "Item.h" include <string.h> int operator<(const Item &a, const Item &b) { return strcmp(a.r->name, b.r->name) < 0; }
то получим тип данных элементов, для которых любая из наших реализаций функции sort выполнит сортировку указателей по строковому полю.
Индексы или указатели используются в основном для того, чтобы не перемещать сортируемые данные. Можно " отсортировать " файл, даже если разрешено только его чтение. Более того, используя несколько массивов индексов или указателей, можно сортировать один и тот же файл по нескольким ключам (см. рис. 6.14). Такая возможность обработки данных без их изменения полезна во многих ситуациях.
Другая причина работы с индексами заключается в том, что это позволяет избежать расходов на перемещения записей целиком. В случае больших записей (и маленьких ключей) достигается значительная экономия, поскольку для выполнения сравнения нужен доступ лишь к небольшой части записи, а большая часть записи в процессе сортировки вообще не затрагивается. При таком косвенном подходе стоимость операции обмена примерно равна стоимости операции сравнения в общем случае, когда сравниваются записи произвольной длины (за счет дополнительного объема памяти для индексов или указателей). В самом деле, если ключи длинные, то затраты на обмен могут быть даже меньше, чем на сравнение. При оценке времени выполнения различных методов сортировки файлов целых чисел часто предполагается, что стоимости операций сравнения и обмена примерно одинаковы. Выводы, полученные на основе такого предположения, справедливы для широкого класса приложений, в которых применяется сортировка указателей или индексов.
Во многих случаях нет необходимости физического переупорядочения данных в соответствии с порядком размещения соответствующих индексов, и данные можно выбирать по порядку с помощью индексного массива. Если такой подход почему-то не годится, то возникнет обычная задача классического программирования: каким образом переупорядочить записи файла, если для него выполнена индексная сортировка? Код
for (i=0; i < N; i++) datasorted[i] = data[a[i]];
тривиален, однако требует дополнительной памяти, достаточной для размещения еще одной копии массива. А что делать, если для второй копии не хватает места? Ведь нельзя же просто записать data[i] = data[a[i]], ибо тогда предыдущее значение data[i] окажется затертым — вполне возможно, что преждевременно.
На рис. 6.15 показан способ решения этой проблемы за один проход по файлу. Чтобы переместить первый элемент на то место, где он должен быть, мы переносим элемент из этой позиции на его место и т.д. В процессе этих перемещений однажды попадется элемент, который нужно переместить в первую позицию, после чего на своих местах окажется некоторый цикл элементов. Далее мы перейдем ко второму элементу и выполним те же операции для его цикла, и так далее (любые элементы, которые уже находятся в своих окончательных позициях (a[i] = i), принадлежат циклам длиной 1 и не перемещаются).

Рис. 6.15. Обменная сортировка
Чтобы упорядочить массив на месте, мы просматриваем его слева направо, циклически перемещая элементы, которые находятся не на своих местах. Врассмат-риваемом примере имеются четыре цикла, причем первый и последний являются вырожденными циклами из одного элемента. Второй цикл начинается с позиции 1. Элемент S запоминается во временной переменной, оставляя в позиции 1 пустое место. Перемещение второго А приводит к появлению пустого места в позиции 10. Это пустое место заполняется элементом Р, который оставляет пустое место в позиции 12. Это пустое место должно быть заполнено элементом в позиции 1, поэтому туда переносится запомненный элемент S, тем самым завершая цикл 1 10 12, который помещает эти элементы в окончательные позиции. Аналогично выполняется цикл 2 8 6 13 4 7 11 3 14 9, который и завершает сортировку.
Конкретно, для каждого значения i сохраняется значение data[i], и в индексную переменную k заносится значение i. Теперь можно считать позицию i свободной и найти элемент, который должен заполнить это место. Таким элементом является data[a[k]], и операция присваивания data[k] = data[a[k]] перемещает это пустое место в a[k]. Теперь пустое место появилось в позиции data[a[k]], и в k заносится значение a[k]. Повторяя эти действия, мы в конце концов оказываемся в ситуации, когда пустое место должно быть заполнено предварительно сохраненным значением data[i]. Когда мы помещаем элемент в какую-либо позицию, мы вносим соответствующие изменения в массив а. Для любого элемента, который уже занимает свою позицию, a[i] равно i, и вышеописанный процесс сводится к пустой операции. Перемещаясь по массиву и начиная новый цикл всякий раз, когда встречается элемент, который еще не находится на своем месте, мы перемещаем каждый элемент не более одного раза. Программа 6.14 представляет реализацию рассмотренного процесса.
Этот процесс называется перестановкой на месте (in situ permutation) или обменным упорядочением (in-place rearrangement) файла. Отметим еще раз: хотя сам по себе алгоритм весьма интересен, во многих приложениях в нем нет необходимости, ибо вполне достаточно косвенного доступа к элементам. Кроме того, если записи слишком велики относительно их количества, наиболее эффективным может оказаться вариант упорядочения обыкновенной сортировкой выбором (см. лемму 6.5).
Косвенная сортировка требует дополнительной памяти для размещения массива индексов или указателей и дополнительного времени для выполнения косвенных сравнений. Во многих случаях эти затраты являются вполне оправданной ценой за возможность вообще не затрагивать записи. Мы практически всегда будем пользоваться косвенной сортировкой для упорядочивания файлов, состоящих из больших записей, а во многих приложениях часто нет необходимости перемещать сами данные. В этой книге обычно применяется прямой доступ к данным. Однако в некоторых случаях мы будем по уже изложенным причинам использовать массивы индексов или указателей, чтобы не перемещать данные.
Программа 6.14. Обменная сортировка
Массив data[0], ..., data[N-1] должен быть упорядочен на месте в соответствии с массивом индексов a[0], ..., a[N-1]. Любой элемент, для которого a[i] == i, уже находится на своем месте, и с ним ничего не надо делать. Иначе значение data[i] сохраняется в v, и в цикле перемещаются элементы a[i], a[a[i]], a[a[a[i]]] и т.д., пока индекс i не встретится опять. Далее этот процесс повторяется для следующего элемента, который находится не на месте, и все это продолжается в том же духе, пока не будет упорядочен весь файл, причем каждая запись будет перемещена не более одного раза.
template <class Item> void insitu(Item data[], Index a[], int N) { for (int i = 0; i < N; i++) { Item v = data[i]; int j, k; for (k = i; a[k] != i; k = a[j], a[j] = j) { j = k; data[k] = data[a[k]]; } data[k] = v; a[k] = k; } }
Упражнения
6.57. Приведите реализацию типа данных для элементов, которые представляют собой записи, а не указатели на записи. Такая организация данных может оказаться удобной для работы программ 6.12 и 6.13 с небольшими записями. (Учтите, что язык C++ поддерживает присваивание структур.)
6.58. Покажите, как можно использовать функцию qsort для решения задач сортировки, на которые ориентированы программы 6.12 и 6.13.
6.59. Приведите массив индексов, который получается при индексной сортировке ключей E A S Y Q U E S T I O N.
6.60. Приведите последовательность перемещений данных, необходимых для перестановки ключей E A S Y Q U E S T I O N на месте после выполнения индексной сортировки (см. упражнение 6.59).
6.61. Опишите перестановку размера N (набор значений для массива а), для которой условие a[i] != i в процессе работы программы 6.14 выполняется максимальное число раз.
6.62. Докажите, что в процессе перемещения ключей и пустых мест в программе 6.14 мы обязательно вернемся к ключу, с которого начат цикл.
6.63. Реализуйте программу, аналогичную программе 6.14, для сортировки указателей, если указатели указывают на массив из N записей типа Item.
Сортировка связных списков
Как мы знаем из лекция №3, массивы и связные списки представляют собой два самых основных способа структурирования данных. Кроме того, в качестве примера обработки списков мы уже рассмотрели реализацию сортировки вставками связных списков (см. программу 3.11 в разделе 3.4 лекция №3). Во всех реализациях сортировок, рассмотренные к этому моменту, предполагается, что сортируемые данные представлены в виде массива. Эти реализации нельзя использовать непосредственно, если для организации данных используются связные списки. Сами алгоритмы могут оказаться полезными, но только если они выполняют последовательную обработку данных, которую могут эффективно поддерживать связные списки.
Программа 6.15 задает интерфейс типа данных связного списка, похожий на приведенный в программе 6.7. Программа 6.15 позволяет написать драйвер, соответствующий программе 6.6, в одной строке:
main(int argc, char *argv[]) { showlist(sortlist(scanlist(atoi(argv[1])))); }
Большая часть работы (включая и распределение памяти) ложится на реализации связного списка и функции sort. Как и в случае драйвера для массива, необходимо иметь возможность инициализировать этот список (из стандартного ввода либо случайными значениями), выводить его содержимое и, разумеется, сортировать его. Как обычно, в качестве типа данных сортируемых элементов используется Item — как и в разделе 6.7. Код, реализующий подпрограммы для этого интерфейса, стандартен для связных списков, которые были подробно рассмотрены в лекция №3, и поэтому оставлен в качестве упражнения.
Программа 6.15. Определение интерфейса для типа связного списка
Данный интерфейс для связных списков похож на интерфейс для массивов, представленный в программе 6.7. Функция randomlist строит список случайно упорядоченных элементов (выделяя для них память). Функция showlist выводит ключи из этого списка. Программы сортировки используют перегруженную операцию < для сравнения элементов и работы с указателями при упорядочении элементов. Представление данных для узлов реализовано обычным способом (см. лекция №3) и включает конструктор узлов, который заполняет каждый новый узел заданным значением и пустой ссылкой.
struct node { Item item; node* next; node(Item x) { item = x; next = 0; } }; typedef node *link; link randlist(int); link scanlist(int&); void showlist(link); link sortlist(link);
Программа 6.15 — это низкоуровневый интерфейс, в котором нет различий между ссылкой (указателем на узел) и связным списком (указатель, который либо равен 0, либо указывает на узел, содержащий указатель на список). Конечно, для списков и реализаций можно было бы выбрать АТД первого класса, который в точности определяет все соглашения по фиктивным узлам и т.д. Однако мы выбрали низкоуровневый подход, поскольку он позволяет больше сосредоточиться на работе со ссылками, которые характеризуют сами алгоритмы и структуры данных — ведь именно они являются предметом изучения настоящей книги.
Существует фундаментальное правило, которое касается работы со связными структурами и критично для многих приложений, но не всегда четко просматривается в наших кодах. В более сложных средах указатели на узлы списка, с которыми мы работаем, могут изменяться другими частями прикладной системы (т.е., они принадлежат мультиспискам). Тот факт, что на узлы, к которым мы обращаемся через указатели, могут влиять части приложения вне программы сортировки, означает, что наши программы могут менять в узлах только ссылки и не должны изменять ключи или другую информацию. Например, если нужно выполнить обмен, то вроде бы проще обменять значения элементов (что мы и делали при сортировке массивов). Но в таком случае любое обращение к любому из этих узлов по какой-то другой ссылке обнаружит, что значение изменилось, а это недопустимо. Необходимо изменить только ссылки таким образом, чтобы при просмотре списка по доступным нам ссылкам узлы были упорядочены, но чтобы сохранился прежний порядок при обращениях по любым другим ссылкам. Реализации при этом усложняются, но обычно это необходимо.
Для работы со связными списками можно приспособить сортировку вставками, сортировку выбором и пузырьковую сортировку, хотя в каждом случае возникают интересные проблемы. Сортировка выбором реализуется достаточно просто: имеется входной список (в котором находятся исходные данные) и выходной список (в котором собирается результат сортировки), и выполняются просмотры списка, чтобы найти максимальный элемент, который затем удаляется из входного списка и помещается в начало выходного (см. рис. 6.16). Реализация этой операции является несложным упражнением по обработке связных списков и представляет собой полезный метод сортировки коротких списков. Эта реализация приведена в программе 6.16. Другие методы оставлены для проработки в качестве упражнений.

Рис. 6.16. Сортировка выбором связного списка
На этой диаграмме показан один шаг сортировки выбором связного списка. Имеется входной список, на который указывает h->next, и выходной список, на который указывает out (вверху). Входной список просматривается так, чтобы t указывал на узел, содержащий максимальный элемент, а max указывал на предшествующий узел. Эти указатели необходимы для исключения t из входного списка (с уменьшением его длины на 1) и помещения его в начало выходного списка (с увеличением его длины на 1), сохраняя упорядоченность выходного списка (внизу). Процесс завершается, когда будет исчерпан весь входной список, а выходной список будет содержать упорядоченные элементы.
Программа 6.16. Сортировка выбором связного списка
Сортировка выбором связного списка достаточно проста, но несколько отличается от сортировки массива тем же методом, поскольку помещение элемента в начало списка выполняется проще. Используются входной список (на него указывает h->next) и выходной список (на который указывает out). Если входной список не пуст, выполняется его просмотр, чтобы найти максимальный элемент, который затем удаляется из входного и помещается в начало выходного списка. В данной реализации используется вспомогательная подпрограмма findmax, возвращающая ссылку на узел, ссылка которого указывает на максимальный элемент в списке (см. упражнение 3.34).
link listselection(link h) { node dummy(0); link head = &dummy, out = 0; head->next = h; while (head->next != 0) { link max = findmax(head), t = max->next; max->next = t->next; t->next = out; out = t; } return out; }
В некоторых задачах обработки списков вообще нет необходимости в явной реализации сортировки. Например, мы решили всегда поддерживать упорядоченность списка и включать новые узлы в список как при сортировке вставками. Такой подход требует незначительных дополнительных затрат, если вставки производятся сравнительно редко, либо если список имеет небольшие размеры, а также в ряде других случаев. Например, перед вставкой новых узлов может понадобиться по какой-то другой причине просмотреть весь список (возможно, чтобы убедиться в отсутствии дубликатов). В лекция №14 нам встретится алгоритм, который использует упорядоченные связные списки, а в лекция №12 и лекция №14будут рассмотрены многочисленные структуры данных, эффективность которых достигается благодаря наличию порядка.
Упражнения
6.64. Приведите содержимое входного и выходного списков при упорядочении программой 6.15 ключей A S O R T I N G E X A M P L E.
6.65.разрабо тайтереализациюинтерфейсадлятипасвязно го списка,приведенно го впро грамме6.15.
6.66. Напишите клиентскую программу-драйвер для вызова программ сортировки связных списков (см. упражнение 6.9).
6.67. Разработайте АТД первого класса для связных списков (см. раздел 4.8 лекция №4), который включает конструктор для инициализации случайными значениями, конструктор для инициализации с помощью перегруженной операции <<, вывод данных с помощью перегруженной операции >>, деструктор, конструктор копий и функцию-член sort. Для реализации функции sort используйте алгоритм сортировки выбором с приватной функцией-членом findmax.
6.68. Напишите реализацию пузырьковой сортировки для связных списков. Внимание: обмен местами двух соседних элементов в связном списке — более сложная операция, чем может показаться на первый взгляд.
6.69. Оформите программу сортировки вставками 3.11 таким образом, чтобы она обладала теми же возможностями, что и программа 6.16.
6.70. Для некоторых входных файлов вариант сортировки вставками, использованный в программе 3.11, выполняет сортировку связных списков значительно медленнее, чем сортировку массивов. Опишите один из таких файлов и объясните, в чем заключается проблема.
6.71. Напишите реализацию варианта сортировки Шелла для связных списков, которая не потребует существенно большего объема памяти или времени при сортировке больших случайно упорядоченных файлов, нежели вариант для сортировки массивов. Совет: используйте пузырьковую сортировку.
6.72. Реализуйте АТД для последовательностей, который позволит использовать одну и ту же клиентскую программу для отладки реализаций сортировки как связных списков, так и массивов. То есть клиентские программы должны иметь возможность генерировать последовательности из N элементов (случайно сгенерированных или из стандартного ввода), сортировать последовательности и выводить их содержимое. Например, АТД в файле SEQ.cxx должен работать со следующим программным кодом:
#include "Item.h" #include "SEQ.cxx" main(int argc, char *argv[]) { int N = atoi(argv[1], sw = atoi(argv[2]); if (sw) SEQrand(N); else SEQscan(); SEQsort(); SEQshow(); }
Напишите одну реализацию, в которой используется представление в виде массива, и другую, где используется представление в виде связного списка. Воспользуйтесь сортировкой выбором.
6.73. Расширьте реализацию из упражнения 6.72 так, чтобы она стала АТД первого класса. Совет: поищите решение в библиотеке стандартных шаблонов.
Метод распределяющего подсчета
Некоторые алгоритмы сортировки достигают повышения эффективности, используя особые свойства ключей. Вот, например, такая задача: требуется выполнить сортировку файла из N элементов, ключи которых принимают различные значения в диапазоне от 0 до N — 1. Эту проблему можно решить одним оператором, если воспользоваться временным массивом b:
for (i = 0; i < N; i++) b[key(a[i])] = a[i];
Здесь ключи используются как индексы, а не как абстрактные элементы, которые можно только сравнивать друг с другом. В этом разделе мы ознакомимся с элементарным методом, который использует индексацию по ключам для повышения эффективности сортировки, если ключами служат целые числа из небольшого диапазона.
Если все ключи равны 0, то сортировка тривиальна. Теперь предположим, что возможны два различных значения ключа — 0 и 1. Такая задача может возникнуть, когда требуется выделить элементы файла, удовлетворяющие некоторому (возможно, сложному) критерию: допустим, ключ, равный 0, означает, что элемент следует " принять " , а равный 1 — что элемент должен быть " отвергнут " . Один из способов сортировки состоит в том, что сначала подсчитывается количество нулевых ключей, затем выполняется второй подход по исходному массиву a, распределяющий его элементы во временном массиве b. Для этого используется массив из двух счетчиков. Вначале в cnt[0] заносится 0, а в cnt[1] — количество нулевых ключей в файле. Это означает, что в исходном файле нет ключей, меньших 0, и имеется cnt[1] ключей, меньших 1. Понятно, что массив b можно заполнить следующим образом: в начало массива записываются нулевые ключи (начиная с b[[cnt[0]], т.е. с b[0]), а потом единичные, начиная с b[cnt[1]]. Таким образом, код
for (i = 0; i < N; i++) b[cnt[a[i]]++] = a[i];
правильно распределяет элементы из a в массиве b. Здесь опять ускорение сортировки достигается за счет использования ключей в качестве индексов (для выбора между cnt[0] и cnt[1]).
Этот подход нетрудно распространить на общий случай. Ведь чаще встречается подобная, но более реальная задача: отсортировать файл, состоящий из N элементов, ключи которого принимают целые значения в диапазоне от 0 до M — 1. Базовый метод, описанный в предыдущем параграфе, можно расширить до алгоритма, получившего название распределяющего подсчета (key-indexed counting), который эффективно решает эту задачу для не слишком больших M. Как и в случае двух ключей, идея состоит в том, чтобы подсчитать количество ключей с каждым конкретным значением, а затем использовать счетчики для перемещения в соответствующие позиции во время второго прохода по сортируемому файлу. Сначала подсчитывается число ключей для каждого значения, а затем вычисляются частичные суммы, эквивалентные количеству ключей, меньших или равных каждому такому значению. Далее, как и в случае двух значений ключа, эти числа используются как индексы при распределении ключей. Для каждого ключа величина связанного с ним счетчика рассматривается как индекс, указывающий на конец блока ключей с тем же значением. Этот индекс используется при размещении ключей в массиве b, после чего выполняется его сдвиг на одну позицию вправо. Описанный процесс изображен на рис. 6.17, а реализация приведена в программе 6.17.

Рис. 6.17. Сортировка методом распределяющего подсчета
Сначала для каждого значения определяется количество ключей в файле, имеющих это значение. В данном примере имеется шесть значений 0, четыре значения 1, два значения 2 и три значения 3. Затем подсчитываются частичные суммы, т.е. количества ключей со значениями меньше данного значения: 0 ключей меньше 0, 6 ключей меньше 1, 10 ключей меньше 2 и 12 ключей меньше 3 (таблица в середине). Потом эти частичные суммы используются в качестве индексов для записи ключей в соответствующие позиции: 0 из начала файла помещается в позицию 0, после чего указатель для 0 увеличивается на единицу и указывает на позицию, куда будет записан следующий 0. Затем 3 из следующей позиции исходного файла помещается в позицию 12 (поскольку в файле имеются 12 ключей со значением, меньшим 3), и соответствующий счетчик увеличивается на 1, и т.д.
Лемма 6.12. Метод распределяющего подсчета представляет собой сортировку с линейным временем выполнения при условии, что диапазон, в котором находятся значения ключей, превышает размер файла не более чем в постоянное количество раз.
Каждый элемент перемещается дважды: один раз в процессе распределения и один раз при возврате в исходный файл; обращение к каждому ключу также выполняется дважды: один раз при подсчете, другой раз во время распределения. Два других цикла for в алгоритме используются при накоплении счетчиков и несущественно влияют на время выполнения, если количество счетчиков существенно не превосходит размер файла.
При сортировке очень больших файлов вспомогательный файл b может привести к проблеме нехватки памяти. Программу 6.17 можно изменить так, чтобы она выполняла сортировку на месте (т.е., без необходимости построения вспомогательного файла), используя метод, похожий на применяемый в программе 6.14. Эта операция тесно связана с базовыми методами, которые будут рассматриваться в лекция №7 и 10, так что мы отложим ее изучение до упражнений 12.16 и 12.17 из раздела 12.3 лекция №12. Как будет показано в лекция №12, эта экономия памяти достигается за счет устойчивости алгоритма, из-за чего область применения этого алгоритма существенно сужается. Ведь в приложениях, использующих большое количество повторяющихся ключей, часто используются другие ключи, относительный порядок которых должен быть сохранен. Исключительно важный пример такого приложения приведен в лекция №10.
Программа 6.17. Распределяющий подсчет
Первый цикл for выполняет начальное обнуление всех счетчиков. Второй цикл for подсчитывает во втором счетчике количество 0, в третьем счетчике — количество 1 и т.д. Третий цикл for складывает все эти числа, после чего каждый счетчик содержит количество ключей, меньших или равных соответствующему ключу. Теперь эти числа представляют собой индексы концов тех частей результирующего файла, к которым эти ключи принадлежат. Четвертый цикл for перемещает ключи во вспомогательный массив b в соответствии со значениями этих индексов, а последний цикл возвращает отсортированный файл в файл a. Для работы этого кода необходимо, чтобы ключи были целыми значениями, не превышающими M, хотя его можно легко изменить так, чтобы извлекать ключи из элементов с более сложной структурой (см. упражнение 6.77).
void distcount(int a[], int l, int r) { int i, j, cnt[M]; static int b[maxN]; for (j = 0; j < M; j + +) cnt[j] = 0; for (i = l; i <= r; i++) cnt[a[i]+1]+ + ; for (j = 1; j < M; j + +) cnt[j] += cnt[j-1]; for (i = l; i <= r; i++) b[cnt[a[i]]++] = a[i]; for (i = l; i <= r; i++) a[i] = b[i]; }
Упражнения
6.74. Напишите специализированную версию метода распределяющего подсчета для сортировки файлов, элементы которых могут принимать только одно из трех значений (a, b или c).
6.75. Предположим, что выполняется сортировка вставками случайно упорядоченного файла, элементы которого принимают одно из трех возможных значений. Каков порядок времени выполнения сортировки: линейный, квадратичный или где-то между ними?
6.76. Покажите процесс сортировки файла A B R A C A D A B R A методом распределяющего подсчета.
6.77. Реализуйте сортировку распределяющим подсчетом для элементов, которые представляют собой потенциально большие записи с целочисленными ключами из небольшого диапазона.
6.78. Реализуйте сортировку распределяющим подсчетом в виде сортировки указателей.
Лекция 7. Быстрая сортировка
Темой настоящей главы является алгоритм сортировки, который, возможно, используется гораздо чаще любых других - алгоритм быстрой сортировки (quicksort). Первоначальный вариант этого алгоритма был изобретен в 1960 г. Ч. Хоаром (C.A.R. Hoare) и с той поры исследовался многими специалистами (см. раздел ссылок). Быстрая сортировка популярна прежде всего потому, что ее нетрудно реализовать, она хорошо работает на различных видах входных данных и во многих случаях требует меньше ресурсов по сравнению с другими методами сортировки.
Алгоритм быстрой сортировки обладает и другими привлекательными особенностями: он принадлежит к категории обменных сортировок (использует лишь небольшой вспомогательный стек), на упорядочение N элементов в среднем затрачивает время, пропорциональное N log N, и имеет исключительно короткий внутренний цикл. Его недостатком является то, что он неустойчив, выполняет в худшем случае N 2 операций и ненадежен в том смысле, что простая ошибка в реализации может остаться незамеченной, но существенно понизить производительность на некоторых видах файлов.
Работа быстрой сортировки проста для понимания. Алгоритм был подвергнут тщательному математическому анализу, и существуют точные оценки его эффективности. Этот анализ был подтвержден обширными эмпирическими экспериментами, а сам алгоритм отшлифован до такой степени, что ему отдается предпочтение в широком диапазоне практических применений сортировки. Поэтому эффективной реализации алгоритма быстрой сортировки стоит уделить гораздо большее внимание, чем реализациям других алгоритмов. Аналогичные методы реализации пригодны и для других алгоритмов; но для быстрой сортировки их можно применять с полной уверенностью, поскольку точно известно их влияние на эффективность сортировки.
Многие пытаются разработать способы улучшения быстрой сортировки: ускорение алгоритмов сортировки играет роль "изобретения велосипеда" в компьютерных науках, а быстрая сортировка представляет собой почтенный метод, который так и хочется улучшить. Его усовершенствованные версии стали появляться в литературе практически с момента опубликования Хоаром. Предлагалось и анализировалось множество идей, но при оценке этих улучшений легко ошибиться, поскольку данный алгоритм настолько удачно сбалансирован, что небольшое усовершенствование одной части программы может привести к резкому ухудшению работы другой ее части. Мы подробно изучим три модификации, которые существенно повышают эффективность быстрой сортировки.
Тщательно настроенная версия быстрой сортировки обычно работает быстрее любого другого метода сортировки на большинстве компьютеров, поэтому быстрая сортировка широко используется как библиотечная программа сортировки и в других серьезных приложениях сортировки. Утилита сортировки из стандартной библиотеки С++ называется qsort, т.к. ее различные реализации обычно основаны на алгоритме быстрой сортировки. Однако время выполнения быстрой сортировки зависит от организации входных данных и колеблется между линейной и квадратичной зависимостью от количества сортируемых элементов, и пользователи иногда бывают неприятно удивлены неожиданно неудовлетворительной работой сортировки на некоторых видах входных данных, особенно при использовании хорошо отлаженных версий этого алгоритма. Если приложение работает настолько плохо, что возникает подозрение в наличии дефектов в реализации быстрой сортировки, то более надежным выбором может оказаться сортировка Шелла, хорошо работающая при меньших затратах на реализацию. Однако в случае особо крупных файлов быстрая сортировка обычно выполняется в пять-десять раз быстрее сортировки Шелла, а для некоторых видов файлов, часто встречающихся на практике, ее можно адаптировать для еще большего повышения эффективности.
Базовый алгоритм
Быстрая сортировка функционирует по принципу " разделяй и властвуй " . Она разбивает сортируемый массив на две части, затем сортирует эти части независимо друг от друга. Как будет показано далее, точное положение точки разбиения зависит от первоначального порядка элементов во входном файле. Суть метода заключается в процессе разбиения, который переупорядочивает массив таким образом, что выполняются следующие три условия:
- Элемент a[i] для некоторого i занимает свою окончательную позицию в массиве.
- Ни один из элементов a[i], ..., a[i-1] не является большим a[i].
- Ни один из элементов a[i+1], ..., a[r] не является меньшим a[i].
Полная упорядоченность достигается разбиением файла на подфайлы с последующим рекурсивным применением к ним этого же метода (см. рис. 7.1). Поскольку процесс разбиения всегда помещает хотя бы один элемент в окончательную позицию, нетрудно вывести по индукции формальное доказательство того, что этот рекурсивный метод достигает нужного результата. Программа 7.1 содержит рекурсивную реализацию этой идеи.

Рис. 7.1. Пример работы быстрой сортировки
Быстрая сортировка выполняет процесс рекурсивного разбиения. Вначале некоторый элемент (центральный элемент) помещается на свое место, а остальные элементы переупорядочиваются таким образом, что меньшие элементы находятся слева от центрального элемента, а большие - справа. Затем выполняется рекурсивная сортировка левой и правой частей массива. На этой диаграмме каждая строка отображает результат разбиения показанного подфайла вокруг элемента, обведенного кружком. По завершении процесса получается полностью упорядоченный файл.
Программа 7.1. Быстрая сортировка
Если массив содержит один или ноль элементов, то ничего делать не надо. Иначе массив обрабатывается процедурой partition (см. программу 7.2), которая помещает на свое место элемент a[i] для некоторого i между l и r включительно и переупорядочивает остальные элементы таким образом, что рекурсивные вызовы этой процедуры завершают сортировку.
template <class Item> void quicksort(Item a[], int l, int r) { if (r <= l) return; int i = partition(a, l, r); quicksort(a, l, i-1); quicksort(a, i+1, r); }
Мы будем использовать следующую общую стратегию реализации разбиения. Сначала в качестве центрального элемента (partitioning element) произвольно выбирается элемент a[r] - тот, который будет помещен в окончательную позицию.
Далее выполняется просмотр с левого конца массива, пока не будет найден элемент, больший центрального, а затем выполняется просмотр с правого конца массива, пока не будет найден элемент, меньший центрального. Два элемента, на которых остановился просмотр, очевидно, находятся не на своих местах в разбитом массиве, и потому они меняются местами. Продолжаем в том же духе, пока не убедимся в том, что ни один элемент слева от левого указателя не больше центрального элемента, и ни один элемент справа от правого указателя не меньше центрального элемента, как показано на следующей диаграмме:

Рис. .
Здесь v - значение центрального элемента, i - левый индекс, а j - правый индекс. Как показано на этой диаграмме, лучше остановить просмотр слева для элементов, больших или равных центральному элементу, и просмотр справа - для элементов, меньших или равных центральному элементу, даже если это правило породит ненужные перестановки с элементами, равными центральному (мы обсудим причины появления этого правила ниже в этом разделе). После перекрещивания индексов все, что остается сделать, чтобы завершить процесс разбиения - это обменять элемент a[r] с крайним левым элементом правого подфайла (элемент, на который указывает левый индекс). Программа 7.2 является реализацией этого процесса, а примеры приведены на рис. 7.2 и рис. 7.3.
Внутренний цикл быстрой сортировки увеличивает индекс на единицу и сравнивает элементы массива с постоянной величиной. Именно эта простота и делает быструю сортировку быстрой: трудно представить себе более короткий внутренний цикл в алгоритме сортировки.
Программа 7.2 использует явную проверку прекращения просмотра, если центральный элемент является наименьшим элементом массива. Чтобы избежать этой проверки, можно использовать сигнальное значение: внутренний цикл быстрой сортировки настолько мал, что даже одна лишняя проверка может оказать заметное влияние на производительность. Сигнальное значение не требуется в данной реализации, если центральный элемент является наибольшим элементом в массиве, т.к. тогда он сам находится на правом краю массива и останавливает просмотр.

Рис. 7.2. Разбиение при работе быстрой сортировки
Разбиение, выполняемое в процессе быстрой сортировки, начинается с (произвольного) выбора центрального элемента. В программе 7.2 для этого берется самый правый элемент E. Затем слева пропускаются все меньшие элементы, а справа - все большие, выполняется обмен элементов, остановивших просмотр, и т.д. до встречи индексов. Вначале выполняется просмотр массива слева, который останавливается на элементе S, потом выполняется просмотр справа, который останавливается на элементе A, а затем элементы S и A меняются местами. Потом просмотр продолжается: слева - до остановки на элементе O, а справа - на элементе E, после чего O и E меняются местами. После этого индексы просмотра перекрещиваются: просмотр слева останавливается на R, а просмотр справа останавливается на E. Для завершения процесса центральный элемент (левая E) обменивается с R.
Программа 7.2. Разбиение
Переменная v содержит значение центрального элемента a[r], а i и j - соответственно левый и правый индексы просмотра. Цикл разбиения увеличивает i и уменьшает j на 1, соблюдая условие, что ни один элемент слева от i не больше v и ни один элемент справа от j не больше v. После встречи указателей процедура разбиения завершается перестановкой a[r] и a[i], при этом в a[i] заносится значение v, и справа от v не останется меньших его элементов, а слева - больших. Цикл разбиения реализован в виде бесконечного цикла с выходом по break после перекрещивания указателей. Проверка j==1 вставлена на случай, если центральный элемент окажется наименьшим в файле.
template <class Item> int partition(Item a[], int l, int r) { int i = l-1, j = r; Item v = a[r]; for (;;) { while (a[++i] < v) ; while (v < a[-j]) if (j == l) break; if (i >= j) break; exch(a[i], a[j]); } exch(a[i], a[r]); return i; }
Другие реализации разбиения, которые встретятся далее в этом разделе и в ряде мест главы, не обязательно прекращают просмотр на ключах, равных центральному элементу - в таких реализациях, возможно, потребуется добавить проверку индекса, чтобы он не вышел за правую границу массива. А усовершенствование быстрой сортировки, которое будет рассмотрено в разделе 7.5, хорошо еще и тем, что не нуждается ни в проверке, ни в сигнальном значении.
Процесс разбиения неустойчив, поскольку во время любой перестановки любой ключ может быть перемещен через большое количество равных ему ключей (еще не проверенных). Простые способы сделать быструю сортировку массива устойчивой пока не известны.
Реализацию процедуры разбиения следует выполнять с особой осторожностью. В частности, наиболее простой способ гарантировать завершение рекурсивной программы заключается в том, чтобы она (1) не вызывала себя для файлов размером 1 или менее и (2) вызывала себя только для файлов, размер которых строго меньше размеров входного файла. Эти правила кажутся очевидными, однако легко упустить из виду такое свойство входных данных, которое повлечет за собой катастрофическую ошибку.
Например, обычная ошибка при реализации быстрой сортировки заключается в отсутствии проверки того, что элемент всегда помещается на окончательное место, и если центральный элемент оказывается наибольшим или наименьшим элементом в файле, программа уходит в бесконечный рекурсивный цикл.
При наличии повторяющихся ключей фиксация момента перекрещивания индексов сопряжена с определенными трудностями. Процесс разбиения можно слегка усовершенствовать, заканчивая просмотр при i < j, а затем используя j вместо i-1, для определения правого конца левого подфайла в первом рекурсивном вызове. В этом случае лучше допустить лишнюю итерацию, т.к. если циклы просмотра прекращаются, когда j и i указывают на один тот же элемент, то в результате два элемента находятся в окончательных позициях: элемент, остановивший оба просмотра и поэтому равный центральному элементу, и сам центральный элемент. Подобная ситуация могла бы возникнуть, например, если бы на рис. 7.2 вместо R было E. Это изменение стоит внести в программу, т.к. иначе программа в том виде, в каком она здесь представлена, оставляет запись с ключом, равным центральному, в a[r], что приводит к вырожденному первому разбиению в вызове quicksort(a, i+1, r), поскольку его правый ключ оказывается наименьшим. Однако реализация разбиения в программе 7.2, несколько проще для понимания, так что в дальнейшем мы будем считать ее базовым методом разбиения. Если в файле может быть значительное число повторяющихся ключей, то на передний план выступают другие факторы, которые будут рассмотрены ниже.
Возможны три основных стратегии обработки ключей, равных центральному элементу: останавливать оба индекса на таких ключах (как в программе 7.2); останавливать один индекс, а другим пропустить их при просмотре; или пропустить их обоими индексами. Вопрос о том, какая из этих стратегий лучше, детально изучен математически, и результаты показывают, что лучше останавливать оба индекса - главным образом потому, что эта стратегия лучше балансирует разбиения при наличии большого количества повторяющихся ключей, в то время как две другие для некоторых файлов приводят к плохо сбалансированным разбиениям. В разделе 7.6 мы рассмотрим несколько более сложный и гораздо более эффективный способ работы с повторяющимися ключами.
В конечном счете эффективность сортировки зависит от качества разбиения файла, которое, в свою очередь, зависит от значения центрального элемента.

Рис. 7.3. Динамические характеристики разбиения при работе быстрой сортировки
Процесс разбиения делит файл на два подфайла, которые можно упорядочить независимо. Ни один из элементов левее левого индекса не больше центрального элемента, поэтому нет точек левее и выше его. Аналогично ни один из элементов правее правого индекса не меньше центрального элемента, поэтому нет точек правее и ниже его. Как видно из этих двух примеров, разбиение случайно упорядоченного массива делит его на два меньших случайно упорядоченных массива и один (центральный) элемент, находящийся на диагонали.
На рис. 7.2 видно, что разбиение делит большой случайно упорядоченный файл на два меньших случайно упорядоченных файла, но реально точка раздела может оказаться в любом месте файла. Лучше было бы выбирать элемент, который разделит файл близко к середине, однако необходимая для этого информация отсутствует. Если файл случайно упорядочен, то выбор элемента a[r] в качестве центрального эквивалентен выбору любого другого конкретного элемента; и в среднем разбивает файл близко к середине. В разделе 7.4 мы проведем анализ алгоритма, который позволит сравнить этот выбор с идеальным выбором, а в разделе 7.5 увидим, как этот анализ поможет нам при выборе центрального элемента, повышающем эффективность алгоритма.
Упражнения
7.1. Покажите в стиле вышеприведенного примера, как быстрая сортировка сортирует файл E A S Y Q U E S T I O N.
7.2. Покажите, как производится разбиение файла 1 0 0 1 1 1 0 0 0 0 0 1 0 1 0 0, используя для этой цели программу 7.2 и поправки, предложенные в тексте.
7.3. Реализуйте разбиение без использования операторов break или goto.
7.4. Разработайте устойчивую быструю сортировку для связных списков.
7.5. Какое максимальное количество раз может быть перемещен наибольший элемент во время выполнения быстрой сортировки файла из N элементов?
Характеристики производительности быстрой СОРТИРОВКИ
Несмотря на все ее ценные качества, базовая программа быстрой сортировки обладает определенным недостатком: она крайне неэффективна на некоторых простых файлах, которые могут встретиться на практике. Например, если она применяется для сортировки уже отсортированного файла размером N, то все разбиения будут вырожденными, и программа вызовет себя N раз, удаляя только по одному элементу за каждый вызов.
Лемма 7.1. Быстрая сортировка в худшем случае выполняет порядка N2/2 сравнений.
Как только что было показано, количество сравнений, выполняемых при сортировке уже упорядоченного файла, равно
N + (N - 1) + (N - 2) + ... + 2 + 1 = (N + 1) N/ 2.
Все разбиения также являются вырожденными и для обратно упорядоченных файлов, и для других редко встречающихся на практике файлов (см. упражнение 7.6).
Это поведение означает не только то, что время выполнения будет порядка N2/2 , но также и то, что объем памяти, требуемой для обслуживания рекурсии, будет порядка N (см. раздел 7.3), что неприемлемо для больших файлов. К счастью, существуют сравнительно простые способы резко уменьшить вероятность того, что этот наихудший случай встретится в типичных применениях программы.
Наилучшим для быстрой сортировки является случай, когда на каждой стадии разбиения файл делится точно пополам. Тогда количество операций сравнения, выполняемых быстрой сортировкой, удовлетворяет рекуррентному соотношению типа " разделяй и властвуй " .
CN = 2CN/2 + N
Член
2CN/2
соответствует затратам на сортировку двух подфайлов, а N соответствует затратам на проверку каждого элемента при использовании того или другого индекса разбиения. Мы уже знаем из лекция №5, что это рекуррентное уравнение имеет решение
Хотя не всегда все так удачно складывается, все-таки в среднем разбиение попадает в середину файла. Если учитывать точную вероятность каждой позиции разбиения, то это усложнит рекуррентное уравнение и его решение, но конечный результат будет похожим.
Лемма 7.2. Быстрая сортировка в среднем выполняет порядка 2NlnN сравнений. Точная рекуррентная формула для количества сравнений, выполняемых во время быстрой сортировки N случайно распределенных различных элементов, имеет вид
при С1 = С0 = 0 . Член N + 1 учитывает затраты на выполнение операций сравнения центрального элемента с каждым из остальных (и еще двумя, когда индексы пересекаются); остальное следует из того факта, что каждый элемент k может быть центральным с вероятностью 1/k, после чего остаются случайно упорядоченные файлы с размерами k - 1 и N- k.
Это рекуррентное уравнение, хоть и выглядит сложным, на самом деле решается легко, в три шага. Во-первых,
С0 + С1 + ... + CN-1
равно
CN-1 + CN-2 + ... + С0
, следовательно,
Во-вторых, можно избавиться от суммы, умножив обе части равенства на N и вычтя эту же формулу для N- 1:
NCN - (N - 1)CN-1 = N (N + 1) - (N - 1)N + 2CN-1 .
Эта формула упрощается до рекуррентного соотношения
NCN = (N + 1)CN-1 + 2N .
В третьих, разделив обе части на N (N + 1), получим соотношение, которое далее сворачивается:

Этот точный ответ почти равен сумме, легко аппроксимируемой интегралом (см. раздел 2.3 лекция №2):
откуда и вытекает нужный результат. Обратите внимание, что , так что среднее количество операций сравнения приблизительно лишь примерно на 39% больше, чем в лучшем случае.
Данный анализ предполагает, что сортируемый файл содержит случайно упорядоченные записи с различными ключами, однако, как показано на рис. 7.4, реализации в программах 7.1 и 7.2 могут работать медленно в тех случаях, когда ключи не обязательно различны и не обязательно расположены в случайном порядке (рис. 7.4). Если программа сортировки будет использоваться многократно или будет применяться для очень большого файла (или, в особенности, если она должна использоваться как стандартная библиотечная сортировка, которая будет применяться для сортировки файлов с неизвестными характеристиками), тогда следует рассмотреть несколько усовершенствований, предлагаемых в разделах 7.5 и 7.6, которые значительно уменьшают вероятность появления на практике неудачных случаев, а также уменьшают среднее время выполнения сортировки примерно на 20%.

Рис. 7.4. Динамические характеристики быстрой сортировки при обработке различных видов файлов
Выбор произвольного центрального элемента в быстрой сортировке приводит к различным вариантам разбиения для различных файлов. На данных диаграммах приведены начальные части процессов разбиения для случайного файла с равномерным распределением, случайного файла с нормальным распределением, почти упорядоченного файла, почти упорядоченного по убыванию и случайно упорядоченного с 10 различными значениями ключей (слева направо). Используется относительное большое значение отсечения для подфайлов небольшого размера. Элементы, не задействованные в разбиении, оказываются расположенными близко к диагонали, и остается массив, который легко упорядочить сортировкой вставками. Для почти упорядоченных файлов требуется очень большое количество разбиений.
Упражнения
7.6. Приведите шесть файлов из 10 элементов, для которых быстрая сортировка (программа 7.1) выполняет такое же количество сравнений, что и в худшем случае (когда все элементы упорядочены).
7.7. Напишите программу для расчета точного значения CN и сравните это точное значение с аппроксимацией 2N lnN для N = 103, 104, 105 и 106 .
7.8. Сколько примерно операций сравнения выполнит быстрая сортировка (программа 7.1) при сортировке файла из N одинаковых элементов?
7.9. Сколько примерно операций сравнения выполнит быстрая сортировка (программа 7.1) при сортировке файла, состоящего из N элементов, ключи которых могут иметь только два различных значения (к элементов с одним значением и N- к элементов с другим)?
7.10. Напишите программу, генерирующую файл, наилучший для быстрой сортировки, т.е. такой файл из N различных элементов, что каждое его разбиение порождает подфайлы, отличающиеся по размеру не более чем на 1.
Размер стека
Так же как и в лекция №3, для быстрой сортировки можно применить явный стек магазинного типа, используя его для хранения информации о еще не обработанных подфайлах, которые ожидают сортировки. Каждый раз, когда нужно обработать подфайл, он выталкивается из стека. При разбиении файла получаются два не обработанных подфайла, и они помещаются в стек. В рекурсивной реализации, представленной программой 7.1, эту информацию содержит стек, поддерживаемый системой.
Для случайно упорядоченных файлов максимальный раздел стека пропорционален log N (см. раздел ссылок), но в вырожденных случаях стек может вырасти до размера, пропорционального N, что показано на рис. 7.5. Ведь наихудший случай - это когда входной файл уже отсортирован. Потенциальная возможность роста стека до размера, пропорционального размеру входного файла представляет собой не очевидную, но вполне реальную проблему для рекурсивной реализации быстрой сортировки: стек, пусть и неявно, используется всегда, а вырожденный файл большого размера может стать причиной аварийного завершения программы из-за нехватки памяти. Конечно, для библиотечной программы сортировки такое поведение недопустимо. (На самом деле программе скорее не хватит времени, чем памяти.)

Рис. 7.5. Размер стека при работе быстрой сортировки
Рекурсивный стек для быстрой сортировки не бывает большим при обработке случайно упорядоченных файлов, но в вырожденных случаях он может занимать очень большой объем памяти. Здесь приведены графики размеров стека для двух случайно упорядоченных файлов (слева и в центре) и для частично упорядоченного файла (справа).
Трудно гарантированно исключить такое поведение программы, но в разделе 7.5 мы увидим, что несложно предусмотреть специальные средства, которые сведут вероятность возникновения таких вырожденных случаев почти к нулю.
Программа 7.3 представляет собой нерекурсивную реализацию, которая решает эту проблему, проверяя размеры обоих подфайлов и первым помещая в стек больший из них. На рис. 7.6 показана эта стратегия. Сравнивая данный пример с приведенным на рис. 7.1, мы видим, что это правило не изменяет подфайлы, меняется лишь порядок их обработки. Так что мы экономим память без изменения времени выполнения.
Программа 7.3. Нерекурсивная быстрая сортировка
Данная нерекурсивная реализация (см. лекция №5) быстрой сортировки использует явный стек магазинного типа, заменяя рекурсивные вызовы помещением в стек параметров, а рекурсивные вызовы процедур и выходы из них - циклом, выталкивающим параметры из стека и обрабатывающим их, пока стек не пуст. Больший из двух подфайлов помещается в стек первым, чтобы максимальная глубина стека при сортировке N элементов не превосходила lgN (см. свойство 7.3).
#include "STACK.cxx" inline void push2(STACK<int> &s, int A, int B) { s.push(B); s.push(A); } template <class Item> void quicksort(Item a[], int l, int r) { STACK<int> s(50)); push2(s, l, r); while (!s.empty()) { l = s.pop(); r = s.pop(); if (r <= l) continue; int i = partition(a, l, r); if (i-1 > r-i) { push2(s, l, i-1); push2(s, i+1, r); } else { push2(s, i+1, r); push2(s, l, i-1); } } }

Рис. 7.6. Пример работы быстрой сортировки (вначале упорядочивается меньший подфайл)
Порядок обработки подфайлов не влияет ни на корректность работы алгоритма быстрой сортировки, ни на время выполнения, однако он может повлиять на размер стека, необходимого для поддержки рекурсивной структуры. Здесь после каждого разбиения вначале обрабатывается меньший из двух подфайлов.
Правило, согласно которому больший из двух подфайлов помещается в стек первым, обеспечивает, что каждый элемент стека имеет размер, не больший половины элемента под ним, так что стеку нужна память только для порядка lgN элементов. Это максимальное заполнение стека имеет место, если разбиение всегда попадает в центр файла. Для случайно упорядоченных файлов реальный максимальный размер стека гораздо меньше; для вырожденных файлов он обычно мал.
Лемма 7.3. Если при быстрой сортировке файла из N элементов меньший из двух подфайлов сортируется первым, то размер стека никогда не превышает lgN элементов.
В худшем случае размер стека должен быть меньше TN, где TN удовлетворяет рекуррентному соотношению при
T1 = T0 = 0
. Это стандартное соотношение из рассмотренных в лекция №5 (см. упражнение 7.13).

Рис. 7.7. Дерево разбиений быстрой сортировки
Если свернуть диаграммы разбиений на рис. 7.1 и рис. 7.6, соединив каждый центральный элемент с центральными элементами из двух его подфайлов, то получится такое статическое представление процесса разбиения (в обоих случаях). В данном бинарном дереве каждый подфайл представлен своим центральным элементом (или целиком подфайлом, если его размер равен 1), а поддеревья каждого узла представляют подфайлы после их разбиения. Чтобы не загромождать рисунок, пустые подфайлы здесь не показаны, хотя наши рекурсивные версии алгоритма выполняют рекурсивные вызовы при r < l, т.е. когда центральный элемент является наименьшим или наибольшим в файле. Вид дерева не зависит от порядка разбиения подфайлов. Наша рекурсивная реализация быстрой сортировки соответствует посещению узлов дерева в прямом порядке, а нерекурсивная реализация соответствует правилу посещения вначале меньшего поддерева.
Этот метод не обязательно будет работать в настоящей рекурсивной реализации, поскольку он зависит от освобождения стека перед выходом или после выхода (end-или tail-recursion removal). Если последним действием какой-либо процедуры является вызов другой процедуры, то некоторые системы программирования удаляют локальные переменные из стека до вызова, а не после. Без освобождения стека перед выходом невозможно гарантировать, что размер стека, используемого быстрой сортировкой, будет мал. Например, вызов быстрой сортировки для уже отсортированного файла размером N породит рекурсивный вызов для такого же файла, но размером N - 1, который, в свою очередь, породит рекурсивный вызов для файла размером N - 2 и т.д., и наконец нарастит глубину стека пропорционально N. Это наблюдение подталкивает к использованию нерекурсивной реализации, не допускающей чрезмерный рост стека. C другой стороны, некоторые компиляторы C++ автоматически вставляют освобождение стека перед выходом, и многие машины имеют аппаратную поддержку вызовов функций - поэтому в таких средах нерекурсивная реализация из программы 7.3 может оказаться более медленной, чем рекурсивная реализация из программы 7.1.
На рис. 7.7 еще раз проиллюстрирован тот факт, что для любого файла нерекурсивный метод обрабатывает те же подфайлы (но в другом порядке), что и рекурсивный метод. На нем показана древовидная структура с центральным элементом в корне и его левым и правым поддеревьями, соответствующими левому и правому подфайлам. Использование рекурсивной реализации быстрой сортировки соответствует обходу этих узлов в прямом порядке; нерекурсивная реализация соответствует правилу просмотра сначала наименьшего поддерева.
При явном использовании стека, как в программе 7.3, удается избежать некоторых затрат, присущих рекурсивной реализации, хотя современные системы программирования обеспечивают минимум затрат для таких простых программ. Программа 7.3 может быть еще улучшена. Например, она помещает в стек оба подфайла только для того, чтобы тут же вытолкнуть верхний из них; можно улучшить ее, присваивая значения переменным l и r напрямую. Далее, проверка выполняется после выталкивания подфайлов из стека, тогда как эффективнее вообще не помещать такие файлы в стек (см. упражнение 7.14). Может показаться, что это не важно, однако рекурсивный характер быстрой сортировки на самом деле приводит к тому, что значительная часть подфайлов в процессе сортировки имеет размеры 0 или 1. Ниже мы рассмотрим важное усовершенствование быстрой сортировки, которое использует эту идею - обрабатывать все подфайлы небольшого размера максимально экономично - для увеличения ее эффективности.
Упражнения
7.11. В стиле рис. 5.5 приведите содержимое стека после каждой пары операций поместить в стек и вытолкнуть из стека, если программа 7.3 используется для сортировки файла с ключами E A S Y Q U E S T I O N.
7.12. Выполните упражнение 7.11 для случая, когда в стек всегда сначала помещается правый подфайл, а затем левый подфайл (как это принято в рекурсивной реализации).
7.13. Завершите доказательство по индукции леммы 7.3.
7.14. Внесите в программу 7.3 такие изменения, чтобы она никогда не помещала в стек подфайлы с .
7.15. Приведите максимальный размер стека, требуемый программой 7.3 при N = 2n .
7.16. Приведите максимальные размеры стека, требуемые программой 7.3 при N = 2n - 1 и N = 2n + 1 .
7.17. Стоит ли для нерекурсивной реализации быстрой сортировки использовать вместо стека очередь? Обоснуйте ваш ответ.
7.18. Определите, выполняется ли в вашей системе программирования освобождение стека перед выходом.
7.19. Определите эмпирическим путем средний размер стека, используемого базовым рекурсивным алгоритмом быстрой сортировки для случайно упорядоченных файлов из N элементов, для N = 103, 104, 105 и 106 .
7.20. Определите среднее количество подфайлов размера 0, 1 и 2, если быстрая сортировка используется для сортировки случайно упорядоченного файла из N элементов.
Подфайлы небольшого размера
Быструю сортировку можно значительно улучшить, заметив, что рекурсивная программа многократно вызывает сама себя для подфайлов небольших размеров. Следовательно, для их обработки должен использоваться наилучший метод. Один такой очевидный способ - замена оператора return в проверке в начале рекурсивной программы на вызов сортировки вставками:
if (r-1 <= M) insertion(a, l, r);
Здесь M - некоторый параметр, точное значение которого зависит от реализации. Оптимальное значение M можно определить либо аналитически, либо эмпирическими исследованиями. Обычно такие исследования показывают, что время выполнения мало изменяется для M в диапазоне примерно от 5 до 25, при этом оно процентов на 10 меньше, чем для элементарного выбора M = 1 (см. рис.7.8).

Рис. 7.8. Отсечение небольших подфайлов
Выбор оптимального значения для размера отсекаемых небольших подфайлов дает в среднем примерно 10-процентное снижение времени выполнения. Выбор точного значения не критичен, т.к. примерно одинаковый выигрыш дают значения из широкого диапазона (примерно от 5 до 20). Жирная линия (вверху) получена экспериментально, а тонкая (внизу) выведена аналитически.
Несколько более простой и чуть более эффективный по сравнению с сортировкой вставками способ обработки небольших подфайлов состоит в замене проверки в начале программы на
if ( r-1 <= M) return;
То есть при разбиении небольшие подфайлы просто игнорируются. В нерекурсивной реализации это можно сделать, не помещая в стек все файлы с размером, меньшим M, либо игнорируя все такие файлы, обнаруженные в стеке. По окончании разбиения получается почти отсортированный файл. Как отмечалось в разделе 6.5 лекция №6, для таких файлов наилучшим методом является сортировка вставками. То есть сортировка вставками работает с таким файлом примерно так же хорошо, как и с набором небольших файлов, которые получаются при непосредственном ее применении. Этот метод следует применять осторожно, т.к. сортировке вставками придется работать, даже если алгоритм быстрой сортировки содержит фатальную ошибку, из-за которой она просто не сможет работать. Единственным признаком, что что-то не так, может быть значительное увеличение времени сортировки.
На рис. 7.9 проиллюстрирован этот процесс на примере большего файла. Даже при сравнительно большом количестве отсеченных небольших файлов сам процесс быстрой сортировки выполняется быстро, т.к. в разбиениях участвует относительно небольшое количество элементов. Сортировка вставками, завершающая работу, также выполняется быстро, поскольку она начинает с почти упорядоченного файла.
Этот метод с большой пользой можно применять всякий раз, когда мы имеем дело с рекурсивным алгоритмом. В силу самой их сути понятно, что все рекурсивные алгоритмы основную часть времени тратят на обработку небольших частей задачи. И т.к. для работы с небольшими вариантами задачи обычно имеется простой и экономичный способ, то часто можно улучшить общее время выполнения, применив гибридный алгоритм.

Рис. 7.9. Сравнения в быстрой сортировке
Подфайлы обрабатываются в быстрой сортировке независимо. На этом рисунке показан результат разбиения каждого подфайла при сортировке 200 элементов с отсечением файлов размером 15 и меньше. Грубую оценку количества сравнений можно получить, подсчитав количество отмеченных элементов в вертикальных столбцах. В данном случае каждая позиция массива участвует на протяжении сортировки лишь в шести или семи подмассивах.
Упражнения
7.21. Нужны ли сигнальные ключи, если сортировка вставками вызывается непосредственно из быстрой сортировки?
7.22. Измените программу 7.1 так, чтобы можно было подсчитать процент операций сравнения, используемых при разбиении файлов размером меньше 10, 100 и 1000. Напечатайте эти проценты для сортировки случайно упорядоченных файлов из N элементов для N = 103, 104, 105 и 106 .
7.23. Реализуйте рекурсивный вариант быстрой сортировки с отсечением для сортировки вставками подфайлов с менее чем M элементами. Эмпирически определите значение M, при котором программа 7.4 на вашей вычислительной системе достигает максимального быстродействия при сортировке случайно упорядоченных файлов из N элементов для N = 103, 104, 105 и 106 .
7.24. Выполните упражнение 7.23, используя нерекурсивную реализацию.
7.25. Выполните упражнение 7.23, для случая, когда сортируемые записи содержат ключ и b указателей на другую информацию (но не используя сортировку указателей).
7.26. Напишите программу, выводящую гистограмму (см. программу 3.7) размеров подфайлов, передаваемых сортировке вставками, при выполнении быстрой сортировки файла размером N с отсечением подфайлов с размерами, меньшими M. Выполните эту программу для M = 10, 100 и 1000 и N = 103, 104, 105 и 106 .
7.27. Определите эмпирическим путем средний размер стека, используемого быстрой сортировкой с отсечением подфайлов размера M, для сортировки случайно упорядоченных файлов из Nэлементов, при M = 10, 100 и 1000 и N =103, 104, 105 и 106 .
Разбиение по медиане из трех
Другим усовершенствованием быстрой сортировки является использование центрального элемента, который с большей вероятностью делит файл близко к середине. Есть несколько возможностей сделать это. Достаточно надежный способ избежать худшего случая состоит в использовании случайного элемента массива в качестве центрального. Тогда вероятность наихудшего выбора пренебрежимо мала. Этот метод является простым примером вероятностного алгоритма (probabilistic algorithm) - алгоритма, который использует случайный выбор для достижения хорошей производительности с высокой вероятностью, независимо от упорядоченности входных данных. Далее в этой книге мы увидим многочисленные примеры использования случайного выбора при разработке алгоритмов, особенно если возможна регулярность входных данных. На практике использование генератора случайных чисел для быстрой сортировки может оказаться излишним: достаточно эффективен простой произвольный выбор.
Другой распространенный способ определения лучшего центрального элемента заключается в выборке трех элементов из файла и использовании в качестве центрального элемента их медианы. Если выбрать за эти три элемента левый, правый и средний элементы массива, то заодно можно включить в схему и сигнальные элементы: сортируем три выбранных элемента (используя метод трех обменов, описанный в лекция №6), потом обмениваем средний из них с элементом a[r-1], и затем выполняем алгоритм разбиения для a[l+1], ..., a[r-2]. Это усовершенствование называется методом медианы из трех (median-of-three).
Метод медианы из трех повышает эффективность сортировки тремя способами. Во-первых, он существенно снижает вероятность появления наихудшего случая при сортировке любого реального файла. Чтобы сортировка выполнилась за время порядка N2 , два из трех выбранных элементов должны быть наибольшими или наименьшими элементами в файле, и это событие должно постоянно повторяться для большинства разбиений. Во-вторых, он устраняет необходимость в сигнальном элементе, т.к. эту функцию берет на себя один из трех элементов, проверенных перед разбиением. В-третьих, он уменьшает общее среднее время выполнения алгоритма примерно на 5%.
Сочетание метода медианы из трех с отсечением небольших подфайлов может улучшить время выполнения быстрой сортировки по сравнению с элементарной рекурсивной реализацией на 20-25%. Программа 7.4 представляет собой реализацию, вобравшую в себя все эти усовершенствования.
Можно было бы продолжить усовершенствование программы, удалив рекурсию, заменив вызовы подпрограмм непосредственной вставкой их кода, используя сигнальные значения и т.п. Однако в современных машинах такие вызовы процедур вполне эффективны, и они находятся вне внутреннего цикла. Более важно то, что применение отсечения небольших подфайлов компенсирует возможные дополнительные затраты (вне внутреннего цикла). Основной причиной использования нерекурсивной реализации с явным стеком является гарантированное ограничение размера стека (см. рис. 7.10).

Рис. 7.10. Размер стека для усовершенствованного варианта быстрой сортировки
Сортировка вначале меньшего подфайла гарантирует, что даже в худшем случае размер стека будет логарифмически зависеть от размера файла. Здесь приведены графики размеров стека для тех же файлов, что и на рис. 7.5, но при сортировке вначале меньшего подфайла (слева) и с добавлением модификации медианы из трех (справа). Эти диаграммы никак не связаны с временем выполнения, которое зависит скорее от объема файлов в стеке, чем от их количества. Например, для третьего (частично упорядоченного) файла не нужен большой стек, однако сортировка выполняется медленно, т.к. обрабатываемые подфайлы обычно имеют большой размер.
Возможны и дальнейшие улучшения алгоритма (например, можно использовать медиану из пяти или более элементов), но для случайно упорядоченных файлов сэкономленное время будет незначительным. Можно получить значительную экономию времени, переписав внутренние циклы (или всю программу) на ассемблере или в машинных кодах. Эти предложения были многократно проверены специалистами в солидных приложениях, использующих сортировку (см. раздел ссылок).
Программа 7.4. Усовершенствованная быстрая сортировка
Выбор медианы из первого, среднего и последнего элементов в качестве центрального элемента и отсечение рекурсии для небольших подфайлов может значительно повысить производительность быстрой сортировки. Данная реализация осуществляет разбиение по медиане из первого, среднего и последнего элементов массива (и иначе не включает их в процесс разбиения). Файлы длиной 11 и меньше игнорируются при разбиениях, а затем для завершения сортировки используется метод insertion из лекция №6.
static const int M = 10; template <class Item> void quidcksort(Item a[], int l, int r) { if (r-1 <= M) return; exch(a[(l+r)/2], a[r-1]); comexch(a[l], a[r-1]); comexch(a[l], a[r]); comexch(a[r-1], a[r]); int i = partition(a, l+1, r-1); quicksort(a,l, i-1); quicksort(a, i+1, r); } template <class Item> void hybridsort(Item a[], int l, int r) { quicksort(a, l, r); insertion(a, l, r); }
Для случайно упорядоченных файлов первая перестановка в программе 7.4 не нужна. Она оставлена потому, что приводит к оптимальному разбиению уже упорядоченных файлов, а также потому, что служит защитой от нештатных ситуаций, которые могут встретиться на практике (см., например, упражнение 7.33). На рис. 7.11 иллюстрируется эффективность использования среднего элемента при определении места разбиения для файлов с различным начальным распределением ключей.

Рис. 7.11. Динамические характеристики быстрой сортировки с выбором медианы из трех для различных видов файлов
Вариант выбора медианы из трех (особенно при выборе среднего элемента файла) значительно повышает устойчивость процесса разбиения. Вырожденные виды файлов, показанные на рис. 7.4, обрабатываются очень хорошо. Другой вариант, достигающий той же цели - использование случайного центрального элемента.
Метод медианы из трех представляет собой специальный случай общего принципа: можно случайным образом выбрать записи из неизвестного файла и использовать свойства этой выборки для оценки свойств всего файла. В случае быстрой сортировки для получения сбалансированного разбиения нужно оценить медиану случайной выборки. Алгоритм таков, что нам не нужна очень точная оценка (или оценка вообще не нужна, если она требует больших вычислительных затрат); мы лишь хотим избежать очень неудачной оценки. Если для оценки используется случайная выборка лишь из одного элемента, получается вероятностный алгоритм, который почти наверняка выполняется быстро, независимо от входных данных. Если случайно выбрать из файла три или пять элементов, и затем использовать для разбиения их медиану, получится лучшее разбиение, но это усовершенствование будет достигнуто ценой выполнения и оценки выборки.
Быстрая сортировка применяется очень широко, поскольку она хорошо работает в различных ситуациях. В некоторых специальных случаях более подходящими могут оказаться другие методы, но быстрая сортировка успешно решает гораздо большее число задач, связанных с сортировкой, чем многие другие методы, а ее быстродействие зачастую гораздо выше, чем у альтернативных вариантов. В таблица 7.1 представлены эмпирические результаты, подтверждающие некоторые из этих комментариев.
На больших случайно упорядоченных файлах быстрая сортировка (программа 7.1) работает почти в три раза быстрее, чем сортировка Шелла (программа 6.6). Отсечение небольших подфайлов и разбиение по медиане из трех (программа 7.4) еще более сокращают время сортировки.
N | Шелла | Базовая быстрая сортировка | Быстрая сортировка с разбиением по медиане из трех | ||||
---|---|---|---|---|---|---|---|
M = 0 | M = 10 | M = 20 | M = 0 | M = 10 | M = 20 | ||
12500 | 6 | 2 | 2 | 2 | 3 | 2 | 3 |
25000 | 10 | 5 | 5 | 5 | 5 | 4 | 6 |
50000 | 26 | 11 | 10 | 10 | 12 | 9 | 14 |
100000 | 58 | 24 | 22 | 22 | 25 | 20 | 28 |
200000 | 126 | 53 | 48 | 50 | 52 | 44 | 54 |
400000 | 278 | 116 | 105 | 110 | 114 | 97 | 118 |
800000 | 616 | 255 | 231 | 241 | 252 | 213 | 258 |
Упражнения
7.28. В реализации метода медианы из трех элементы, составляющие случайную выборку, не принимают участие в процессе разбиения. Одна из причин - они могут быть использованы в качестве сигнальных ключей. Приведите еще одну причину.
7.29. Реализуйте быструю сортировку, основанную на разбиении по медиане случайной выборки из пяти элементов файла. Элементы выборки не должны принимать участия в разбиении (см. упражнение 7.28). Сравните производительность полученного алгоритма с методом медианы из трех для больших случайно упорядоченных файлов.
7.30. Выполните программу из упражнения 7.29 для больших файлов со специальной организацией - например, для отсортированных файлов, файлов в обратном порядке или файлов с одинаковыми ключами. Как отличается ее производительность для этих файлов от производительности для случайно упорядоченных файлов?
7.31. Реализуйте быструю сортировку с использованием случайной выборки из 2k - 1
элементов. Сначала отсортируйте выборку, затем рекурсивной программой разбейте файл по медиане выборки, а оставшиеся половины выборки поместите в каждый подфайл так, чтобы они использовались в этих подфайлах без дальнейшей сортировки. Такой метод сортировки называется сортировкой методом случайной выборки (samplesort).
7.32. Определите эмпирическим путем наилучший размер выборки для сортировки
методом случайной выборки (см. упражнение 7.31) для N = 103, 104, 105 и 106 . Имеет ли значение, какой вид сортировки используется для упорядочения самой выборки: быстрая сортировка или сортировка методом случайной выборки?
7.33. Покажите, что если в программе 7.4 убрать первую перестановку и пропускать ключи, равные центральному, то время ее выполнения для обратно упорядоченных файлов будет квадратичным.
Повторяющиеся ключи
Файлы с большим количеством повторяющихся ключей довольно часто встречаются в различных приложениях. Например, может потребоваться сортировка большого файла с персональными данными по году рождения или даже сортировка для разделения персонала на женщин и мужчин.
Если в сортируемом файле имеется много повторяющихся ключей, рассмотренные нами реализации быстрой сортировки не снижают производительность до неприемлемо низкого уровня, но все же их можно существенно улучшить. Например, файл, который состоит только из одинаковых ключей (одно и то же значение), вообще не нуждается в дальнейшей сортировке, однако наши реализации будут разбивать его до мелких подфайлов, вне зависимости от размера файла (см. упражнение 7.8). Если во входном файле присутствует большое количество повторяющихся ключей, быстрая сортировка, в силу своей рекурсивной природы, обязательно породит много подфайлов, содержащих элементы с одним и тем же ключом, так что здесь имеется возможность для значительного усовершенствования алгоритма.
Идея, которая первой приходит на ум, заключается в разбиении файла на три части, по одной для ключей, меньших, равных и больших центрального элемента:

Выполнение такого разбиения сложнее, чем разбиение на две части, которым мы пользовались ранее. Для решения этой задачи было предложено множество различных методов. Это классическое упражнение по программированию, известное благодаря Дейкстре (Dijkstra) как задача о национальном флаге Дании (Dutch National Flag problem), т.к. трем возможным категориям ключей можно поставить в соответствие три цвета этого флага (см. раздел ссылок). Для быстрой сортировки мы добавим еще одно ограничение: вся работа должна быть выполнена за один проход по файлу - алгоритм, использующий два прохода по данным, замедлит быструю сортировку вдвое, даже если в файле вообще нет повторяющихся ключей.
Интересный метод трехчастного разбиения (three-way partitioning) был предложен в 1993 году Бентли и Макилроем (Bentley and McIlroy). Он представляет собой следующую модификацию стандартной схемы разбиения: помещаем ключи из левого подфай-ла, равные центральному элементу, в левый конец файла, а ключи из правого подфайла, равные центральному элементу - в правый конец файла. Во время процесса разбиения постоянно поддерживается следующая ситуация:

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

Рис. 7.12. Трехчастное разбиение
На этой диаграмме показан процесс помещения в окончательные позиции всех ключей, равных центральному элементу. Как и на рис. 7.2, выполняется просмотр с левого конца, пока не будет найден ключ, не меньший центрального, и с правого конца, пока не будет найден ключ, не больший центрального, а затем эти элементы обмениваются. Если после обмена левый элемент равен центральному элементу, он обменивается с элементом на левом конце массива; аналогично и справа. После перекрещивания индексов центральный элемент помещается на свое место как и раньше (предпоследняя строка), а затем выполняется обмен всех ключей, равных ему, чтобы они заняли свои места с любой стороны от центрального элемента (нижняя строка).
На рис. 7.12 показана работа алгоритма трехчастного разбиения на демонстрационном файле, а программа 7.5 является реализацией быстрой сортировки, основанной на описанном методе. Эта реализация требует добавления всего лишь двух операторов if в цикле перестановки и двух циклов for для завершения разбиения, т.е. перемещения ключей, равных центральному элементу, в окончательные позиции. Похоже, что этот метод трехчастного разбиения требует меньшего кода, чем другие альтернативы. И более того, он не только максимально эффективно обрабатывает повторяющиеся ключи, но и минимизирует издержки в случае, когда повторяющихся ключей нет.
Программа 7.5. Быстрая сортировка с трехчастным разбиением
В основу программы положено разбиение массива на три части: на элементы, меньшие центрального элемента (в a[1], ..., a[j]), элементы, равные центральному элементу (в a[j+1], ..., a[i-1]), и элементы, большие центрального элемента (в a[i], ..., a[r]). После этого сортировка завершается двумя рекурсивными вызовами.
Для достижения этой цели программа хранит ключи, равные центральному элементу, слева между позициями l и p и справа между q и r. В цикле разбиения, после остановки индексов просмотра и перестановки элементов в позициях i и j , выполняется проверка каждого из этих элементов на равенство центральному. Если левый из них равен центральному элементу, он переставляется в левую часть массива; если правый из них равен центральному элементу, он переставляется в правую часть массива.
После перекрещивания индексов элементы массива, равные центральному, переставляются с концов в свои окончательные позиции, после чего их можно исключить из подфайлов, обрабатываемых рекурсивными вызовами.
template <class Item> int operator==(const Item &A, const Item &B) { return !less(A, B) && !less(B, A); } template <class Item> void quicksort(Item a[], int l, int r) { int k; Item v = a[r]; if (r <= l) return; int i = l-1, j = r, p = l-1, q = r; for (;;) { while (a[++i] < v) ; while (v < a[--j]) if (j == l) break; if (i >= j) break; exch(a[i],a[j]); if (a[i] == v) { p++; exch(a[p],a[i]); } if (v == a[j]) { q-; exch(a[q],a[j]); } } exch(a[i], a[r]); j = i-1; i = i+1; for (k = l ; k <= p; k++, j -) exch(a[k],a[j]); for (k = r-1; k >= q; k--, i++) exch(a[k],a[i]); quicksort(a, l, j); quicksort(a, i, r); }
Упражнения
7.34. Поясните, что произойдет, если программа 7.5 будет запущена для случайно упорядоченного файла (1) с двумя различными значениями ключей и (2) с тремя различными значениями ключей.
7.35. Измените программу 7.1 так, чтобы выполнялась команда return, если все ключи в подфайле равны. Сравните эффективность полученной программы с программой 7.1 для больших случайно упорядоченных файлов с ключами, принимающими t различных значений для t = 2, 5 и 10.
7.36. Предположим, в программе 7.2 просмотр не останавливается на ключах, равных центральному, а пропускает их. Покажите, что в этом случае время выполнения программы 7.1 будет квадратичным.
7.37. Докажите, что время выполнения программы из упражнения 7.36 квадратично для всех файлов с 0(1) различными значениями ключей.
7.38. Напишите программу для определения количества различных ключей, встречающихся в файле. Воспользуйтесь полученной программой для подсчета различных ключей в случайно упорядоченных файлах, содержащих N целых чисел из диапазона от 0 до M- 1 для M = 10, 100 и 1000 и для N = 103, 104, 105 и 106 .
Строки и векторы
Когда ключами сортировки являются строки, можно воспользоваться реализацией типа данных наподобие программы 6.11 совместно с реализациями быстрой сортировки из данной главы. В результате получится корректная и эффективная реализация (для больших файлов быстрее любого другого рассмотренного нами метода), но в ней будут присутствовать неявные издержки, которые стоит рассмотреть подробнее.
Проблема заключается в затратах на сравнение строк. Обычно строки сравниваются слева направо, проверяя на равенство символ за символом и затрачивая на это время, пропорциональное количеству начальных символов, совпадающих в обеих строках. На заключительных стадиях разбиения быстрой сортировки, когда ключи близки по значению, сравнение может быть относительно долгим. Как обычно, в силу рекурсивного характера быстрой сортировки, почти все затраты алгоритма сосредоточены в его завершающих стадиях, так что попытки усовершенствования алгоритма вполне оправданы.
Например, рассмотрим подфайл размером 5, содержащий ключи discreet, discredit, discrete, discrepancy и discretion. Все сравнения при сортировке этих ключей проверяют по меньшей мере семь символов, в то время как достаточно начать с седьмого символа - если бы дополнительно было известно, что первые шесть символов совпадают.
Процедура трехчастного разбиения, рассмотренная в разделе 7.6, обеспечивает элегантный способ использовать это соображение. На каждой стадии разбиения проверяется лишь один символ (скажем, символ в позиции d), считая, что сортируемые ключи совпадают в позициях с 0 по d-1. Мы выполняем трехчастное разбиение, помещая те ключи, у которых d-й символ меньше d-го символа центрального элемента, слева, ключи, d-й символ которых равен d-му символу центрального элемента, в середине; а ключи, d-й символ которых больше d-го символа центрального элемента, справа. Далее продолжаем как обычно, за исключением того, что мы сортируем средний подфайл, начиная с d+1-го символа. Нетрудно видеть, что этот метод обеспечивает корректную сортировку и к тому же оказывается очень эффективным (см. таблица 7.2). Это убедительный пример мощи рекурсивного мышления (и программирования).
В таблица 7.2 представлены относительные затраты для нескольких различных вариантов быстрой сортировки на примере упорядочения первых N слов из книги Г Мелвилла " Моби Дик " . Использование сортировки вставками непосредственно для небольших подфайлов или их игнорирование с последующей сортировкой вставками являются эквивалентными по эффективности стратегиями, но снижение затрат здесь несколько ниже, чем для целочисленных ключей (см. таблица 7.1), т.к. сравнение строк более трудоемко. Если во время разбиения файлов не останавливаться на повторяющихся ключах, тогда время сортировки файла со всеми одинаковыми ключами квадратично. Эта неэффективность видна в приведенном примере, т.к. в тексте есть много слов, встречающихся с большой частотой. По этой причине эффективно трехчастное разбиение, которое на 30-35% быстрее системной сортировки.
N | V | I | M | Q | X | T |
---|---|---|---|---|---|---|
12500 | 8 | 7 | 6 | 10 | 7 | 6 |
25000 | 16 | 14 | 13 | 20 | 17 | 12 |
50000 | 37 | 31 | 31 | 45 | 41 | 29 |
100000 | 91 | 78 | 76 | 103 | 113 | 68 |
Обозначения: | |
---|---|
V | Быстрая сортировка (программа 7.1) |
I | Сортировка вставками для подфайлов небольших размеров |
M | Игнорирование небольших подфайлов с последующей сортировкой вставками |
Q | Системная сортировка qsort |
X | Пропуск повторяющихся ключей (квадратичное время для всех равных ключей) |
T | Трехчастное разбиение (программа 7.5) |
Для реализации этого вида сортировки требуется более общий абстрактный тип данных, обеспечивающий доступ к отдельным символам ключей. Способ обработки строк в C++ позволяет исключительно просто реализовать этот метод. Однако мы отложим детальное обсуждение этой реализации до лекция №10, в которой рассмотрим различные методы сортировки, использующие то обстоятельство, что ключи сортировки могут быть легко расчленены на более мелкие части.
Этот подход можно распространить на многомерные сортировки, где ключами сортировки являются векторы, а записи должны быть отсортированы таким образом, что ключи упорядочены по первой компоненте, а записи с равными первыми компонентами ключей упорядочены по второй компоненте и т.д. Если компоненты не содержат повторяющихся ключей, задача сводится к сортировке по первой компоненте; однако в типичных приложениях каждая из компонент может принимать лишь нескольких различных значений, и поэтому применимо трехчастное разбиение (переходящее к следующей компоненте в среднем подфайле). Этот случай имеет важное практическое применение, рассмотренное Хоаром (Hoare) в его оригинальной работе.
Упражнения
7.39. Рассмотрите возможность усовершенствования сортировки выбором, сортировки вставками, пузырьковой сортировки и сортировки Шелла для упорядочения строк.
7.40. Сколько символов проверяет стандартный алгоритм быстрой сортировки (программа 7.1, использующая строковый тип из программы 6.11) при сортировке файла, состоящего из одинаковых N строк длиной t ? Ответьте на этот же вопрос для модификации, предложенной в тексте.
Выборка
Одним из важных приложений, связанных с сортировкой, но не требующим полной упорядоченности, является операция нахождения медианы некоторого множества чисел. В статистических, а также в других приложениях обработки данных, это весьма распространенный вид вычислений. Один из способов решения этой задачи заключается в том, что числа упорядочиваются, и выбирается число из середины, но можно сделать лучше, воспользовавшись процессом разбиения быстрой сортировки.
Операция нахождения медианы представляет собой частный случай операции выборки (selection): нахождения к-го наименьшего числа из некоторого множества чисел. (Не спутайте эту выборку со случайной выборкой (sample), введенной в разделе 7.5 - прим. перев.) Поскольку алгоритм не может гарантировать, что конкретный элемент является к-ым наименьшим, без проверки к - 1 меньших элементов и N - к больших элементов, большинство алгоритмов выборки может возвратить все к наименьших элементов файла без существенных дополнительных вычислений.
Выборки часто применяются при обработке экспериментальных и других данных. Широко практикуется использование медианы и других характеристик положения (order statistics) для деления файла на меньшие группы. Нередко для дальнейшей обработки нужно сохранить лишь небольшую часть большого файла; в таких случаях программа, способная выбрать, скажем, 10% наибольших элементов файла, может оказаться предпочтительнее полной сортировки. Другим важным примером является использование разбиения по медиане в качестве первого шага во многих алгоритмах вида " разделяй и властвуй " .
Мы уже знакомы с алгоритмом, который может быть приспособлен непосредственно для выборки. Если к очень мало, то хорошо будет работать сортировка выбором (см. лекция №6), с затратами времени, пропорциональными N к: сначала находится наименьший элемент, затем наименьший из оставшихся элементов после первого выбора и т.д. Для несколько больших к в главе 9 лекция №9 мы ознакомимся с методами, которые можно адаптировать, чтобы время их выполнения было пропорциональным N log к.
Метод выборки, выполняющийся в среднем за линейное время для всех значений к, следует непосредственно из процедуры разбиения, используемой в быстрой сортировке. Вспомним, что этот метод переупорядочивает массив a[1], ..., a[r] и возвращает целое i такое, что элементы a[1], ..., a[i-1] меньше или равны a[i], а элементы a[i+1], ..., a[r] больше или равны a[i]. Если k равно i, то задача решена. Иначе, если k < i, нужно продолжать обработку левого подфайла, а если k > i, нужно продолжать обработку правого подфайла. Этот подход непосредственно приводит к рекурсивной программе нахождения выборки, т.е. к программе 7.6. Пример работы этой процедуры для небольшого файла показан на рис. 7.13.

Рис. 7.13. Выборка медианы
Для ключей из данного примера выборка разбиением требует только три рекурсивных вызова на поиск медианы. В первом вызове ищется восьмой наименьший элемент в файле из 15 элементов, а разбиение дает четвертый наименьший (элемент E). Поэтому во втором вызове ищется четвертый наименьший элемент в файле из 11 элементов (элемент R). И поэтому в третьем вызове ищется четвертый наименьший элемент в файле из 7 элементов - и он находится (элемент M). Файл переупорядочивается таким образом, что медиана находится на своем месте, меньшие элементы - слева от нее, а большие - справа (равные элементы могут находиться с любой стороны), но полной упорядоченности нет.
Программа 7.6. Выборка
Данная процедура разбивает массив по (k-1-му наименьшему элементу (который находится в a[k]): она переупорядочивает массив так, что a[1], ..., a[k-1] меньше или равны a[k], а a[k+1], ..., a[r] больше или равны a[k].
Например, можно вызвать select(a,0,N-1,N/2) для разбиения массива по медиане так, чтобы медиана осталась в a[N/2].
template <class Item> void select(Item a[], int l, int r, int k) { if (r <= l) return; int i = partition(a, l, r); if (i > k) select(a, l, i-1, k); if (i < k) select(a, i+1, r, k); }
Программа 7.7 является нерекурсивной версией, непосредственно вытекающей из рекурсивной версии в программе 7.6. Поскольку программа 7.6 всегда завершается одиночным вызовом самой себя, мы просто переустанавливаем параметры и возвращаемся в начало. Таким образом мы избавились от рекурсии, не используя стек, а также избавились от вычислений, использующих k, храня его как индекс массива.
Лемма 7.4. Выборка, основанная на быстрой сортировке, в среднем выполняется за линейное время.
Как и в случае быстрой сортировки, можно (грубо) предположить, что для очень больших файлов каждое разбиение делит массив приблизительно пополам, так что весь процесс потребует примерно N + N/2 + N/4 + N/8 + ... = 2N операций сравнения. И так же, как и для быстрой сортировки, это грубое приближение недалеко от истины. Анализ, подобный приведенному в разделе 7.2 для быстрой сортировки, но гораздо более сложный (см. раздел ссылок), приводит к тому, что порядок среднего количества сравнений равен выражению
2 N + 2kln (N/ k) + 2(N - k) ln (N / (N - k)) ,
которое линейно для любого допустимого значения к. При к = N/ 2 из этой формулы следует, что для нахождения медианы требуется порядка (2 + 2 ln 2) N сравнений.
Программа 7.7. Нерекурсивная выборка
Нерекурсивная реализация выборки просто разбивает массив, затем смещает левый индекс, если разбиение попало слева от искомой позиции, или смещает правый индекс, если разбиение попало справа от искомой позиции.
template <class Item> void select(Item a[], int l, int r, int k) { while (r > l) { int i = partition(a, l, r); if (i >= k) r = i-1; if (i <= k) l = i+1; } }
Пример того, как этот метод находит медиану в большом файле, приведен на рис. 7.14. Здесь имеется лишь один подфайл, размер которого при каждом вызове уменьшается в одно и то же число раз, так что эта процедура завершается за O(log N) шагов. Программу можно ускорить, введя в нее разбиение по случайной выборке, но при этом следует соблюдать осторожность (см. упражнение 7.45).

Рис. 7.14. Выборка медианы с помощью разбиений
В процессе выборки выполняется разбиение подфайла, который содержит искомый элемент. В зависимости от того, куда попадает разбиение, левый индекс перемещается вправо или правый индекс перемещается влево.
Наихудший случай здесь почти тот же, что и для быстрой сортировки: использование этого метода для нахождения наименьшего элемента уже упорядоченного файла приводит к квадратичному времени выполнения. Можно модифицировать процедуру выборки, основанную на быстрой сортировке так, что время ее выполнения будет гарантировано линейным. Однако эти модификации, будучи важными теоретически, очень сложны и неприемлемы на практике.
Упражнения
7.41. Оцените среднее количество операций сравнения, требуемое для нахождения наименьшего из N элементов при использовании метода select.
7.42. Оцените среднее количество операций сравнения, требуемое для нахождения aN-го наименьшего элемента при использовании метода select, для a = 0.1, 0.2, ... , 0.9.
7.43. Сколько потребуется операций сравнения в худшем случае для нахождения медианы из N элементов при использовании метода select?
7.44. Напишите эффективную программу, переупорядочивающую файл так, чтобы все элементы с ключами, равными медиане, оказались в окончательной позиции, меньшие элементы находились слева, а элементы, большие медианы - справа.
7.45. Обдумайте идею усовершенствования выборки с помощью оценки по случайной выборке. Совет: использование медианы помогает не всегда.
7.46. Реализуйте алгоритм выборки на базе трехчастного разбиения для больших случайно упорядоченных файлов с ключами, принимающими t различных значений, для t = 2, 5 и 10.
Лекция 8. Слияние и сортировка слиянием
Семейство алгоритмов быстрой сортировки, рассмотренное в лекция №7, основано на операции выборки, т.е. на нахождении k-го минимального элемента в файле. Мы убедились в том, что выполнение операции выборки аналогично делению файла на две части: часть, содержащую к меньших элементов, и часть, содержащую N- k больших элементов. В этой главе мы рассмотрим семейство алгоритмов сортировки, основанных на противоположном процессе, слиянии, т.е. объединении двух отсортированных файлов в один файл большего размера. На слиянии основан простой алгоритм сортировки вида "разделяй и властвуй" (см. лекция №5), а также его двойник - алгоритм восходящей сортировки слиянием, при этом оба алгоритма достаточно просто реализуются.
Выборка и слияние - противоположные операции в том смысле, что выборка разбивает файл на два независимых файла, а слияние объединяет два независимых файла в один. Контраст между этими двумя операциями становится очевидным при применении принципа " разделяй и властвуй " для создания конкретных методов сортировки. Можно переупорядочить файл таким образом, что если отсортировать обе части файла, становится упорядоченным и весь файл; и наоборот, можно разбить файл на две части, отсортировать их, а затем объединить упорядоченные части и получить весь файл в упорядоченном виде. Мы уже видели, что получается в первом случае: это быстрая сортировка, состоящая из процедуры выборки, за которой следуют два рекурсивных вызова. В этой главе мы рассмотрим сортировку слиянием (mergesort), которая является противоположностью быстрой сортировки, поскольку состоит из двух рекурсивных вызовов с последующей процедурой слияния.
Одним из наиболее привлекательных свойств сортировки слиянием является тот факт, что она сортирует файл, состоящий из N элементов, за время, пропорциональное N logN, независимо от характера входных данных. В лекция №9 мы познакомимся с еще одним алгоритмом, время выполнения которого гарантированно пропорционально NlogN; этот алгоритм носит название пирамидальной сортировки (heapsort). Основной недостаток сортировки слиянием заключается в том, что простые реализации этого алгоритма требуют объема дополнительной памяти, пропорционального N. Это препятствие можно преодолеть, однако способы сделать это настолько сложны, что практически неприменимы, особенно если учесть, что можно воспользоваться пирамидальной сортировкой. Кодирование сортировки слиянием не труднее кодирования пирамидальной сортировки, а длина ее внутреннего цикла находится между аналогичными показателями быстрой сортировки и пирамидальной сортировки; так что сортировка методом слияния достойна внимания, если важно быстродействие, недопустимо ухудшение производительности на " неудобных " входных данных и доступна дополнительная память.
Гарантированное время выполнения, пропорциональное NlogN, может быть и недостатком. Например, в лекция №6 были описаны методы, которые могут быть адаптированы таким образом, что в некоторых особых ситуациях время их выполнения может быть линейным - например, при достаточно высокой упорядоченности файла либо при наличии лишь нескольких различных ключей. В противоположность этому время выполнения сортировки слиянием зависит главным образом от числа ключей входного файла и практически не чувствительно к их упорядоченности.
Сортировка слиянием является устойчивой, и это склоняет чашу весов в ее пользу в тех приложениях, в которых устойчивость важна. Конкурирующие с ней методы, такие как быстрая сортировка или пирамидальная сортировка, не относятся к числу устойчивых. Различные приемы, обеспечивающие устойчивость этих методов, обычно требуют дополнительной памяти; поэтому если на первый план выдвигается устойчивость, то требование дополнительной памяти для сортировки слиянием становится менее важным.
У сортировки слиянием есть еще одно, иногда очень важное, свойство: она обычно реализуется таким образом, что обращается к данным в основном последовательно (один элемент за другим). Поэтому сортировка слиянием удобна для упорядочения связных списков, для которых применим только метод последовательного доступа. По тем же причинам, как мы увидим в лекция №11, на слиянии часто основываются методы сортировки для специализированных и высокопроизводительных машин, поскольку в таких вычислительных средах последовательный доступ к данным является самым быстрым.
Двухпутевое слияние
При наличии двух упорядоченных входных файлов их можно объединить в один упорядоченный выходной файл, просто повторяя цикл, в котором меньший из двух элементов, наименьших в своих файлах, переносится в выходной файл; и так до исчерпания обоих входных файлов. В этом и следующем разделах будут рассмотрены несколько реализаций этой базовой абстрактной операции. Время выполнения линейно зависит от количества элементов в выходном файле, если на каждую операцию поиска следующего наименьшего элемента в любом входном файле затрачивается постоянное время, а это верно, если отсортированные файлы представлены структурой данных, поддерживающей последовательный доступ за постоянное время, наподобие связного списка или массива. Эта процедура представляет собой двухпутевое слияние (two-way merging); в лекция №11 мы подробно изучим многопутевое слияние, в котором принимают участие более двух файлов. Наиболее важным приложением многопутевого слияния является внешняя сортировка, которая подробно рассматривается там же.
Для начала предположим, что имеются два отдельных упорядоченных массива целых чисел a[0], ..., a[N-1] и b[0], ..., b[M-1], которые нужно объединить в третий массив c[0], ..., c[N+M-1]. Легко реализуемая очевидная стратегия заключается в том, чтобы последовательно выбирать в с наименьший элемент из оставшихся в a и b, как показано в программе 8.1. Эта простая реализация обладает двумя важными характеристиками, которые мы сейчас рассмотрим.
Во-первых, в данной реализации предполагается, что массивы не пересекаются. В частности, если a и b - большие массивы, то для размещения выходных данных необходим третий, тоже большой, массив c. Было бы хорошо не задействовать дополнительную память, пропорциональную размеру выходного файла, а применить такой метод,
Программа 8.1. Слияние
Чтобы объединить два упорядоченных массива a и b в упорядоченный массив c, используется цикл for, который на каждой итерации помещает в массив c очередной элемент. Если массив a исчерпан, элемент берется из b; если исчерпан b, то элемент берется из a; если же элементы есть и в том, и в другом массиве, то в c переносится наименьший из оставшихся элементов в a и b. Предполагается, что оба входных массива упорядочены, и что массив c не пересекается (т.е. не перекрывает и не использует совместную память) с массивами a и b.
template <class Item> void mergeAB(Item c[], Item a[], int N, Item b[], int M ) { for (int i = 0, j = 0, k = 0; k < N+M; k++) { if (i == N) { c[k] = b[j++]; continue; } if (j == M) { c[k] = a[i++]; continue; } c[k] = (a[i] < b[j]) ? a[i++] : b[j + +]; } }
который объединяет два упорядоченных файла a[1], ..., a[m] и a[m+1], ..., a[r] в один упорядоченный файл, просто перемещая элементы a[1], ..., a[r] без использования существенного объема дополнительной памяти. Здесь стоит остановиться и подумать о том, как это можно сделать. На первый взгляд кажется, что эту задачу решить просто, однако на самом деле все известные до сих пор решения достаточно сложны, особенно по сравнению с программой 8.1. Оказывается, довольно трудно разработать алгоритм обменного (т.е. на том же месте) слияния, который обошел бы по производительности альтернативные обменные сортировки. Мы еще вернемся к этому вопросу в разделе 8.2.
Слияние, как операция, имеет свою собственную область применения. Например, в типичной среде обработки данных может оказаться нужным использовать большой (упорядоченный) файл данных, в который регулярно добавляются новые элементы. Один из подходов заключается в пакетном добавлении новых элементов в главный (намного больший) файл и последующей сортировке всего файла. Однако эта ситуация как будто специально создана для слияния: гораздо эффективнее отсортировать (небольшой) пакет новых элементов и потом слить полученный небольшой файл с большим главным файлом. Слияние используется во многих аналогичных приложениях, так что изучать его, несомненно, стоит. Поэтому основное внимание в данное главе будет уделяться методам сортировки, в основу которых положено слияние.
Упражнения
8.1. Предположим, что упорядоченный файл размером N нужно объединить с неупорядоченным файлом размером M, причем M намного меньше N. Допустим, что у нас имеется программа сортировки, которая упорядочивает файл размером N за
с1 NlgN
секунд, и программа слияния, которая может слить файл размером N с файлом размером M за
c2(N + M)
секунд, при . Во сколько раз быстрее, чем сортировка заново, работает предложенный метод, основанный на слиянии, если его рассматривать как функцию от M, при
N = 103, 106 и 109
?
8.2. Сравните сортировку всего файла методом вставок и два метода, представленные в упражнении 8.1. Считайте, что меньший файл упорядочен случайным образом, так что каждая вставка проходит примерно полпути в большом файле, а время выполнения сортировки имеет порядок c3 M N/ 2 , при этом константа с3 примерно равна другим константам.
8.3. Опишите, что произойдет, если попробовать воспользоваться программой 8.1 для обменного слияния с помощью вызова merge(a, a, N/2, a+N/2, N-N/2) для ключей A E Q S U Y E I N O S T.
8.4. Верно ли, что программа 8.1, вызванная так, как описано в упражнении 8.3, дает правильный результат тогда и только тогда, когда оба входных подмассива отсортированы? Обоснуйте свой ответ или приведите контрпример.
Абстрактное обменное слияние
Хотя реализация слияния требует дополнительной памяти, все же абстракция обменного слияния полезна при реализации изучаемых здесь методов сортировки. В нашей следующей реализации слияния это будет подчеркнуто с помощью сигнатуры функции merge (a, l, m, r), что означает, что подпрограмма merge помещает результат слияния a[1], ..., a[m] и a[m+1], ..., a[r] в объединенный упорядоченный массив a[1], ..., a[r]. Эту программу слияния можно было бы реализовать, сначала скопировав все входные данные во вспомогательный массив и затем применив базовый метод из программы 8.1, однако пока мы не будем делать этого, а сначала внесем в данный подход одно усовершенствование. И хотя выделения дополнительной памяти для вспомогательного массива, по-видимому, на практике не избежать, в разделе 8.4 мы рассмотрим дальнейшие улучшения, которые позволят избежать дополнительных затрат времени на копирование массива.
Вторая заслуживающая внимания основная характеристика базового слияния заключается в том, что внутренний цикл содержит две проверки для определения, исчерпаны ли входные массивы. Понятно, что обычно эти проверки дают отрицательный результат, и так и напрашивается использование сигнальных ключей, позволяющих отказаться от них. То есть если в конец массива a и массива aux добавить элементы с ключами, большими значений всех других ключей, эти проверки можно удалить: если массив a (b) будет исчерпан, сигнальный ключ заставляет выбирать следующие элементы и помещать их в массив c только из массива b (a), вплоть до окончания слияния.
Однако, как было показано в лекция №6 и лекция №7 , сигнальными ключами не всегда удобно пользоваться: либо потому, что не всегда легко определить наибольшее значение ключа, либо потому, что сигнальный ключ трудно вставить в массив. В случае слияния существует достаточно простое средство, которое показано на рис. 8.1.

Рис. 8.1. Слияние без сигнальных ключей
Для слияния двух упорядоченных по возрастанию файлов они копируются в другой файл, причем второй файл копируется сразу за первым в обратном порядке. Тогда можно следовать следующему простому правилу: в выходной файл выбирается левый или правый элемент - тот, у которого ключ меньше. Максимальный ключ выполняет роль сигнального для обоих файлов, где бы он ни находился. На данном рисунке показано слияние файлов A R S T и G I N.
Для слияния двух упорядоченных по возрастанию файлов они копируются в другой файл, причем второй файл копируется сразу за первым в обратном порядке. Тогда можно следовать следующему простому правилу: в выходной файл выбирается левый или правый элемент - тот, у которого ключ меньше. Максимальный ключ выполняет роль сигнального для обоих файлов, где бы он ни находился. На данном рисунке показано слияние файлов A R S T и G I N.
В основу этого метода положена следующая идея: если при реализации обменной абстракции все-таки не отказываться от копирования массивов, то при копировании второго файла можно просто изменить порядок его элементов (без дополнительных затрат) - чтобы связанный с ним индекс перемещался справа налево. При таком упорядочении наибольший элемент, в каком бы он файле не находился, служит сигнальным ключом для другого массива. Программа 8.2 является эффективной реализацией абстрактного обменного слияния, основанной на этой идее, и служит отправной точкой для разработки алгоритмов сортировки, которые рассматриваются далее в этой главе. В ней все же используется вспомогательный массив с размером, пропорциональным выходному файлу, но она более эффективна, чем примитивная реализация, поскольку в ней не нужны проверки на окончание сливаемых массивов.
Последовательность ключей, которая сначала увеличивается, а затем уменьшается (или сначала уменьшается, а затем увеличивается), называется битонической (bitonic) последовательностью. Сортировка битонической последовательности эквивалентна слиянию, но иногда удобно представить задачу слияния в виде задачи битонической сортировки; рассмотренный метод, позволяющий избежать вставки сигнальных ключей, как раз и служит простым примером этого.
Одно из важных свойств программы 8.1 заключается в том, что реализуемое ею слияние устойчиво: оно сохраняет относительный порядок элементов с одинаковыми ключами. Эту характеристику нетрудно проверить, и при реализации абстрактного обменного слияния часто имеет смысл убедиться в сохранении устойчивости, т.к., как будет показано в разделе 8.3, устойчивое слияние приводит к устойчивым методам сортировки. Свойство устойчивости не всегда просто сохранить: например, программа 8.2 не обеспечивает устойчивости (см. упражнение 8.6). Это обстоятельство еще больше усложняет проблему разработки алгоритма по-настоящему обменного слияния.
Программа 8.2. Абстрактное обменное слияние
Данная программа выполняет слияние двух файлов без использования сигнальных ключей, для чего второй массив копируется во вспомогательный массив aux в обратном порядке, сразу за концом первого массива (т.е. устанавливая в aux битонический порядок). Первый цикл for пересылает первый массив и оставляет i равным l, т.е. готовым для начала слияния. Второй цикл for пересылает второй массив, после чего j равно r. Затем в процессе слияния (третий цикл for) наибольший элемент служит сигнальным ключом независимо от того, в каком массиве он находится. Внутренний цикл этой программы достаточно короткий (пересылка в aux, сравнение, пересылка обратно в a, увеличение значения i или j на единицу, увеличение и проверка значения k).
template <class Item> void merge(Item a[], int l, int m, int r) { int i, j; static Item aux[maxN]; for (i = m+1; i > l; i—) aux[i-1] = a[i-1]; for (j = m; j < r; j++) aux[r+m-j] = a[j + 1]; for (int k = l; k <= r; k++) if (aux[j] < aux[i]) a[k] = aux[j--]; else a[k] = aux[i++]; }
Упражнения
8.5. Покажите в стиле диаграммы 8.1, как программа 8.1 выполняет слияние ключей A E Q S U Y E I N O S T.
8.6. Объясните, почему программа 8.2 не является устойчивой, и разработайте устойчивую версию этой программы.
8.7. Что получится, если программу 8.2 применить к ключам E A S Y Q U E S T I O N?
8.8. Верно ли, что программа 8.2 правильно сливает входные массивы тогда и только тогда, когда они отсортированы? Обоснуйте ваш ответ или приведите контрпример.
Нисходящая сортировка слиянием
Имея в своем распоряжении процедуру слияния, нетрудно положить ее в основу рекурсивной процедуры сортировки. Чтобы отсортировать заданный файл, нужно разделить его на две части, рекурсивно отсортировать обе половины и затем слить их. Реализация этого алгоритма представлена в программе 8.3; а пример показан на рис. 8.2 рис. 8.2. Как было сказано в лекция №5, этот алгоритм является одним из самых известных примеров использования принципа " разделяй и властвуй " для разработки эффективных алгоритмов.
Нисходящая сортировка слиянием аналогична принципу управления сверху вниз, при котором руководитель разбивает большую задачу на подзадачи, которые должны независимо решать его подчиненные. Если каждый руководитель будет просто разбивать свою задачу на две равные части, а потом объединять решения, полученные его подчиненными, и передавать результат своему начальству, то получится процесс, аналогичный сортировке слиянием. Работа практически не продвигается, пока не получит свою задачу кто-то, не имеющий подчиненных (в рассматриваемом случае это слияние двух файлов размером 1); но потом руководство выполняет значительную работу, объединяя результаты работы подчиненных.
Сортировка слиянием играет важную роль благодаря простоте и оптимальности заложенного в нее метода (время ее выполнения пропорционально N log N), который допускает возможность устойчивой реализации. Эти утверждения сравнительно нетрудно доказать.
Программа 8.3. Нисходящая сортировка слиянием
Эта базовая реализация сортировки слиянием является примером рекурсивной программы, основанной на принципе " разделяй и властвуй " . Для упорядочения массива a[1], ..., a[r] он разбивается на две части a[1], ..., a[m] и a[m+1], ..., a[r], которые сортируются независимо друг от друга (через рекурсивные вызовы) и вновь сливаются для получения отсортированного исходного файла. Функции merge может потребоваться вспомогательный файл, достаточно большой для помещения копии входного файла, однако эту абстрактную операцию удобно рассматривать как обменное слияние (см. текст).
template <class Item> void mergesort(Item a[], int l, int r) { if (r <= l) return; int m = (r+l)/2; mergesort(a, l, m); mergesort(a, m+1, r); merge(a, l, m, r); }

Рис. 8.2. Пример нисходящей сортировки слиянием
В каждой строке показан результат вызова функции merge при выполнении нисходящей сортировки слиянием. Вначале сливаются A и S, и получается A S; потом сливаются O и R, и получается O R. Затем сливаются O R и A S, и получается A O R S. После этого сливаются I T и G N, и получается G I N T, потом этот результат сливается с A O R S, и получается A G I N O R S T, и т.д. Метод рекурсивно объединяет меньшие упорядоченные файлы в большие.
Как было показано в лекция №5 (и для быстрой сортировки в лекция №7), для визуализации структуры рекурсивных вызовов рекурсивного алгоритма можно воспользоваться древовидными структурами, которые позволяют лучше понять все варианты рассматриваемого алгоритма и провести его анализ. Для сортировки слиянием структура рекурсивных вызовов зависит только от размера входного массива. Для любого заданного N определяется дерево, получившее название дерева " разделяй и властвуй " , которое описывает размер подфайлов, обрабатываемых во время выполнения программы 8.3 (см. упражнение 5.73): если N равно 1, то это дерево состоит из одного узла с меткой 1; иначе дерево состоит из корневого узла, содержащего файл размером N, поддерева, представляющего левый подфайл размером и поддерева, представляющего правый подфайл размером
. Таким образом, каждый узел этого дерева соответствует вызову метода mergesort, а его метка показывает размер задачи, соответствующей этому рекурсивному вызову.
Если N равно степени 2, это построение дает полностью сбалансированное дерево со степенями 2 во всех узлах и единицами во всех внешних узлах. Если N не является степенью 2, вид дерева усложняется. На рис. 8.3 рис. 8.3 представлены примеры обоих случаев. Ранее мы уже сталкивались с такими деревьями - при изучении в лекция №5 алгоритма с такой же структурой рекурсивных вызовов, как и у сортировки слиянием.
Структурные свойства деревьев " разделяй и властвуй " имеют непосредственное отношение к анализу сортировки слиянием. Например, общее количество сравнений, выполняемых алгоритмом, в точности равно сумме всех меток узлов.
Лемма 8.1. Для сортировки любого файла из N элементов сортировка слиянием выполняет порядка NlgN сравнений.
В реализациях, описанных в разделах 8.1 и 8.2, для каждого слияния двух под-массивов размером N/2 нужно N сравнений (это значение может отличаться 1 или 2, в зависимости от способа использования сигнальных ключей). Следовательно, общее количество сравнений для всей сортировки может быть описано стандартным рекуррентным соотношением вида " разделяй и властвуй " : , при
M1 = 0
. Это рекуррентное соотношение описывает также сумму меток узлов и длину внешнего пути дерева " разделяй и властвуй " с N узлами (см. упражнение 5.73). Данное утверждение нетрудно проверить, когда N является степенью числа 2 (см. формулу 2.4), и доказать методом индукции для произвольного N. Непосредственное доказательство содержится в упражнениях 8.12-8.14.

Рис. 8.3. Деревья " разделяй и властвуй "
На этих диаграммах показаны размеры подзадач, создаваемых нисходящей сортировкой слиянием. В отличие от, скажем, деревьев, соответствующих быстрой сортировке, эти структуры зависят только от размера первоначального файла и не зависят от значений ключей. На верхней диаграмме показана сортировка файла из 32 элементов. Сначала выполняется (рекурсивное) упорядочение двух файлов из 16 элементов, а затем их слияние. Файлы из 16 элементов сортируются (рекурсивно) с помощью (рекурсивной) сортировки файлов из 8 элементов и т.д. Для файлов, размер которых не равен степени двух, получается более сложная структура, пример которой приведен на нижней диаграмме.
Лемма 8.2. Сортировке слиянием нужен объем дополнительной памяти, пропорциональный N.
Это факт очевиден из обсуждения в разделе 8.2. Можно кое-что сделать для уменьшения размера дополнительной памяти - за счет существенного усложнения алгоритма (см., например, упражнение 8.21). Как будет показано в разделе 8.7, сортировка слиянием эффективна и в том случае, если сортируемый файл организован в виде связного списка. В этом случае данное свойство выполняется, но нужна дополнительная память для ссылок. В случае массивов, как было сказано в разделе 8.2 и будет сказано в разделе 8.4, можно выполнять слияние на месте (обсуждение этой темы будет продолжено в разделе 8.4), однако эта стратегия вряд ли применима на практике.
Лемма 8.3. Сортировка слиянием устойчива, если устойчив используемый при этом метод слияния.
Это утверждение легко проверить методом индукции. Для реализации метода слияния, наподобие предложенного в программе 8.1, легко показать, что относительное расположение повторяющихся ключей не нарушается. Однако, чем сложнее алгоритм, тем выше вероятность того, что эта устойчивость будет нарушена (см. упражнение 8.6).
Лемма 8.4. Требования к ресурсам сортировки слиянием не зависят от исходной упорядоченности входных данных.
В наших реализациях от входных данных зависит только порядок, в котором элементы обрабатываются во время слияний. Каждый проход требует памяти и числа шагов, пропорциональных размеру подфайла, из-за пересылки данных во вспомогательный массив. Две ветви оператора if из-за особенностей компиляции могут выполняться за слегка различное время, что может привести к некоторой зависимости времени выполнения от характера входных данных, однако число сравнений и других операций над входными данными не зависит от того, как упорядочен входной файл. Обратите внимание на то, что это отнюдь не эквивалентно утверждению, что алгоритм не адаптивный (см. лекция №6) - ведь последовательность сравнений зависит от упорядоченности входных данных.
Упражнения
8.9. Приведите последовательность слияний, выполняемых программой 8.3 при сортировке ключей E A S Y Q U E S T I O N.
8.10. Начертите деревья " разделяй и властвуй " для N = 16, 24, 31, 32, 33 и 39.
8.11. Реализуйте рекурсивную сортировку слиянием для массивов, используя идею трехпутевого, а не двухпутевого слияния.
8.12. Докажите, что все узлы с метками 1 в деревьях " разделяй и властвуй " расположены на двух нижних уровнях.
8.13. Докажите, что метки в узлах на каждом уровне сбалансированного дерева размером N в сумме дают N, за исключением, возможно, нижнего уровня.
8.14. Используя упражнения 8.12 и 8.13, докажите, что количество сравнений, необходимых для выполнения сортировки слиянием, находится в пределах между NlgN и N lgN + N.
8.15. Найдите и докажите зависимость между количеством сравнений, используемых сортировкой слиянием, и количеством битов в -разрядных положительных числах, меньших N.
Усовершенствования базового алгоритма
Как мы уже видели на примере быстрой сортировки, большую часть рекурсивных алгоритмов можно усовершенствовать, обрабатывая файлы небольших размеров специальным образом. В силу рекурсивного характера функции часто вызываются именно для небольших файлов, поэтому улучшение их обработки приводит к улучшению всего алгоритма. Следовательно, как и для быстрой сортировки, переключение на сортировку вставками подфайлов небольших размеров даст улучшение времени выполнения типичной реализации сортировки слиянием на 10—15%.
Следующее полезное усовершенствование - это устранение времени копирования данных во вспомогательный массив, используемый слиянием. Для этого следует так организовать рекурсивные вызовы, что на каждом уровне процесс вычисления меняет ролями входной и вспомогательный массивы. Один из способов реализации такого подхода заключается в создании двух вариантов программ: одного для входных данных в массиве aux и выходных данных в массиве a, а другого - для входных данных в массиве a и выходных данных в массиве aux, обе эти версии поочередно вызывают одна другую. Другой подход продемонстрирован в программе 8.4, которая вначале создает копию входного массива, а затем использует программу 8.1 и переключает аргументы в рекурсивных вызовах, устраняя таким образом операцию явного копирования массива. Вместо нее программа поочередно переключается между выводом результата слияния то во вспомогательный, то во входной файл. (Это достаточно хитроумная программа.)
Программа 8.4. Сортировка слиянием без копирования
Данная рекурсивная программа сортирует массив b, помещая результат сортировки в массив a. Поэтому рекурсивные вызовы написаны так, что их результаты остаются в массиве b, а для их слияния в массив a используется программа 8.1. Таким образом, все пересылки данных выполняются во время слияний.
template <class Item> void mergesortABr(Item a[], Item b[], int l, int r) { if (r-l <= 10) { insertion(a, l, r); return; } int m = (l+r)/2; mergesortABr(b, a, l, m); mergesortABr(b, a, m+1, r); mergeAB(a+l, b+l, m-l+1, b+m+1, r-m); } template <class Item> void mergesortAB(Item a[], int l, int r) { static Item aux[maxN]; for (int i = l; i <= r; i++) aux[i] = a[i]; mergesortABr(a, aux, l, r); }
Данный метод позволяет избежать копирования массива ценой возвращения во внутренний цикл проверок исчерпания входных файлов. (Вспомните, что устранение этих проверок в программе 8.2 преобразовало этот файл во время копирования в бито-нический.) Положение можно восстановить с помощью рекурсивной реализации той же идеи: нужно реализовать две программы как слияния, так и сортировки слиянием: одну для вывода массива по