Алгоритмы на С++
Седжвик Роберт

Содержание


Глава 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.)

Таблица 1.1. Результаты экспериментального исследования алгоритмов объединения-поиска
N M F U W P H
10006206 1425653
25002023682210131512
500041913304 1172 462625
10000 8385712164577917350
25000309802219208216
50000708701469387497
100000154511910711106 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 инструкций.

Обзор тем

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

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

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

Изучение алгоритмов представляет интерес, поскольку это новая отрасль (почти все изученные в этом курсе алгоритмы не старше 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, приводятся примеры, в которых все эти инструменты применяются для анализа конкретных алгоритмов.

Упражнения

Возрастание функций

Большинство алгоритмов имеют главный параметр , который наиболее сильно влияет на время их выполнения. Параметр может быть степенью полинома, размером файла при сортировке или поиске, количеством символов в строке или некоторой другой абстрактной мерой размера рассматриваемой задачи: чаще всего он прямо пропорционален объему обрабатываемого набора данных. Когда таких параметров существует более одного (например, и в алгоритмах объединение-поиск, которые были рассмотрены в разделе 1.3 лекция №1), мы часто сводим анализ к одному параметру, задавая его как функцию других, или рассматривая одновременно только один параметр (считая остальные постоянными) - то есть без потери общности ограничиваясь рассмотрением только одного параметра . Нашей целью является выражение требований программ к ресурсам (как правило, это время выполнения) в зависимости от с использованием математических формул, которые максимально просты и справедливы для больших значений параметров. Алгоритмы в этой книге обычно имеют время выполнения, пропорциональное одной из следующих функций:

1 Большинство инструкций большинства программ выполняются один или несколько раз. Если все инструкции программы обладают таким свойством, мы говорим, что время выполнения программы постоянно.
Когда время выполнения программы является логарифмическим, программа выполняется несколько медленнее с ростом . Такое время выполнения обычно присуще программам, которые сводят большую задачу к набору меньших задач, уменьшая на каждом шаге размер задачи в некоторое постоянное количество раз. В интересующей нас области время выполнения можно считать небольшой константой. Основание логарифма изменяет константу, но не намного: когда - тысяча, равно 3, если основание равно 10, или порядка 10, если основание равно 2; когда равно миллиону, значения только удвоятся. При удвоении величина увеличивается на постоянную величину, а удваивается лишь тогда, когда достигает N^2.
Когда время выполнения программы является линейным, это обычно значит, что каждый входной элемент подвергается небольшой обработке. Если равно миллиону, то время выполнения равно некоторой величине. Когда удваивается, то же происходит и со временем выполнения. Эта ситуация оптимальна для алгоритма, который должен обработать входных данных (или выдать выходных данных).
Время выполнения, пропорциональное , возникает тогда, когда алгоритм решает задачу, разбивая ее на меньшие подзадачи, решая их независимо и затем объединяя решения. Из-за отсутствия подходящего прилагательного ("линерифмический"?) мы просто говорим, что время выполнения такого алгоритма равно . Если равно 1 миллиону, примерно равно 20 миллионам. При удвоении время выполнения более чем (но не сильно) удваивается.
Если время выполнения алгоритма является квадратичным, он полезен для практического использования для относительно небольших задач. Квадратичное время выполнения обычно появляется в алгоритмах, которые обрабатывают все пары элементов данных (возможно, в цикле двойного уровня вложенности). Когда равно 1 тысяче, время выполнения равно 1 миллиону. При удвоении время выполнения увеличивается вчетверо.
Аналогично, эта ситуация характерна для алгоритма, который обрабатывает тройки элементов данных (возможно, в цикле тройного уровня вложенности), имеет кубическое время выполнения и практически применим лишь для малых задач. Если равно 100, время выполнения равно 1 миллиону. При удвоении время выполнения увеличивается в восемь раз.
Лишь несколько алгоритмов с экспоненциальным временем выполнения имеют практическое применение, хотя такие алгоритмы возникают естественным образом при попытках прямого решения задачи. Если равно 20, время выполнения равно 1 миллиону. При удвоении время выполнения возводится в квадрат!

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

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

Для малых задач время решения практически не зависит от метода - быстрый современный компьютер все равно выполнит задачу мгновенно. Но по мере увеличения размера задачи числа, с которыми мы имеем дело, становятся огромными, как продемонстрировано в таблица 2.1. Когда количество исполняемых инструкций в медленном алгоритме становится по-настоящему большим, время, необходимое для их выполнения, становится недостижимым даже для самых быстрых компьютеров. На рис. 2.1 приведен перевод большого количества секунд в дни, месяцы, годы и т.д.; в таблица 2.2 показаны примеры того, как быстрые алгоритмы имеют больше возможностей решить задачу за приемлемое время, нежели даже самые быстрые компьютеры.

 Перевод секунд


Рис. 2.1.  Перевод секунд

Огромная разница между такими числами, как и , становится более очевидной, если взять соответствующее количество секунд и перевести в привычные единицы измерения. Мы можем позволить программе выполняться 2,8 часа, но вряд ли мы будем созерцать программу, выполнение которой займет 3,1 года. Поскольку примерно равно , этой таблицей можно воспользоваться и для перевода степеней 2. Например, секунд - это примерно 124 года.

Таблица 2.1. Значения часто встречающихся функций
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

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

Таблица 2.2. Время для решения гигантских задач
Операций в секунду Размер задачи 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 приведены наиболее используемые из этих функций; ниже мы кратко обсудим их и некоторые наиболее важные их свойства.

Таблица 2.3. Специальные функции и постоянные
Функция Название Пример Приближение
округление до меньшего = 3 x
округление до большего = 4 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.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. Докажите, что в выражениях с О-нотацией можно выполнить любое из перечисленных преобразований:

Простейшие рекурсии

Как мы увидим далее в этой книге, многие алгоритмы основаны на принципе рекурсивного разбиения большой задачи на меньшие, а решения подзадач используются для решения исходной задачи. Эта тема подробно обсуждается в лекция №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.

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

Упражнения

Примеры анализа алгоритмов

Вооружившись инструментами, о которых было рассказано в трех предыдущих разделах, мы рассмотрим анализ последовательного поиска и бинарного поиска - двух основных алгоритмов для определения того, входит ли некоторая последовательность объектов в заданное множество объектов. Наша цель - показать, как можно сравнивать алгоритмы, а не подробно описать сами алгоритмы. Для простоты предположим, что все рассматриваемые объекты являются целыми числами. Более общие приложения будут подробно рассмотрены в лекциях 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 подтверждает наше наблюдение, что функциональный рост времени выполнения позволяет предсказать производительность в случае больших значений параметров на основе эмпирического изучения работы алгоритма при малых значениях. Сочетание математического анализа и эмпирического изучения убедительно показывает, что предпочтительным алгоритмом является бинарный поиск.

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

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

Упражнения

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

Таблица 2.4. Эмпирическое исследование последовательного и бинарного поиска
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++ программы используют совсем немного базовых типов данных:

На эти типы часто ссылаются по их именам в языке 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) позволяет организовать объекты в виде логической последовательности, что более удобно для управления, чем для доступа.

Упражнения

Массивы

Возможно, наиболее фундаментальной структурой данных является массив, который определен как примитив в 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. Однако сначала ознакомимся со связными списками, которые служат главной альтернативой массивам при организации коллекций объектов.

Упражнения

Связные списки

Если нам в основном нужен последовательный перебор элементов, их можно организовать в виде связного списка (linked list) - базовой структуры данных, в которой каждый элемент содержит информацию, необходимую для получения следующего элемента. Основное преимущество связных списков перед массивами заключается в возможности эффективного изменения расположения элементов. Эта гибкость достигается за счет скорости доступа к произвольному элементу списка, поскольку единственный способ получения элемента состоит в проходе по ссылкам от начала списка.

Определение 3.2. Связный список - это набор элементов, содержащихся в узлах (node), каждый из которых также содержит ссылку (link) на некоторый узел.

В определении узлов упоминаются ссылки на узлы, поэтому связные списки иногда называют самоссылочыми (self-referent) структурами. Более того, хотя ссылка узла обычно указывает на другой узел, возможны и ссылки на себя, поэтому связные списки могут представлять собой циклические (cyclic) структуры. Последствия этих двух фактов станут ясны при рассмотрении конкретных представлений и применений связных списков.

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

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

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

Связные списки являются примитивными конструкциями в некоторых языках программирования, но не в 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.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 будет содержать все узлы в упорядоченном виде.

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

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

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

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

Другая важная ситуация, в которой иногда удобно использовать ведущий узел, возникает, когда необходимо передать указатели на списки в качестве аргументов функций, которые могут изменять список таким же образом, как и в случае массивов. Использование ведущего узла позволяет функции принимать или возвращать пустой список. При отсутствии ведущего узла функция нуждается в механизме сообщения вызывающей функции, что список оставлен пустым. Одно из решений для C++ состоит в передаче параметра-указателя на список по ссылке. Второй механизм - примененный в программе 3.10 - прием функциями обработки списков указателей на входные списки в качестве аргументов и возврат указателей на выходные списки. При этом ведущие узлы не нужны. Кроме того, этот механизм очень удобен для рекурсивной обработки списков, которая часто используется в этой книге (см. раздел 5.1 лекция №5).

В программе 3.12 объявлен набор функций в виде "черного ящика", которые реализуют базовый список операций. Это позволит нам не повторять фрагменты кода в тексте программ и не зависеть от деталей реализации. Программа 3.13 реализует выбор Иосифа (см. программу 3.9), но уже в виде клиентской программы, использующей этот интерфейс. Идентификация важных операций, используемых в вычислениях, и их описание в интерфейсе обеспечивают гибкость, которая позволяет рассматривать различные конкретные реализации важных операций и проверять их эффективность.

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

Таблица 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.

Упражнения

Строки

В языке C термин "строка" (string) обозначает массив символов переменной длины, определяемый начальной позицией и символом завершения строки. Язык C++ наследует эту структуру данных из C. Кроме того, строки в качестве высокоуровневой абстракции включены в стандартную библиотеку. В этом разделе мы рассмотрим несколько примеров строк в стиле C. Ценность строк в качестве низкоуровневых структур данных обусловлена двумя главными причинами. Во-первых, во многих вычислительных приложениях выполняется обработка текстовых данных, которые могут представляться непосредственно строками. Во-вторых, многие вычислительные системы предоставляют прямой и эффективный доступ к байтам памяти, которые в точности соответствуют символам строк. Таким образом, в подавляющем большинстве случаев абстракция строк связывает потребности приложения с возможностями компьютера.

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

Язык C++ наследует из C эффективную реализацию на основе массивов, которая рассматривается в этом разделе, а также предоставляет более обобщенную реализацию из стандартной библиотеки. В лекция №4 будут рассмотрены и другие реализации.

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

Для строки необходимо зарезервировать память либо во время компиляции, объявив массив символов фиксированной длины, либо во время выполнения, с помощью вызова new[]. После выделения памяти массиву его можно заполнять символами с начала и до символа завершения строки. Без символа завершения строка представляет собой обыкновенный массив символов. Символ завершения строки позволяет применять более высокий уровень абстракции, когда только часть массива (от начала до символа завершения) считается содержащей значимую информацию. Символ завершения имеет значение 0, или '\0'.

Например, чтобы найти длину строки, можно подсчитать количество символов от ее начала до символа завершения. В таблица 3.2 перечислены простые операции, которые часто выполняются со строками. Все они предусматривают просмотр строк от начала и до конца. Многие из этих функций содержатся в библиотеках, объявленных в файле <string.h>. Однако для простых приложений программисты часто используют слегка измененные версии прямо в коде программы. Надежные функции, реализующие те же операции, должны содержать дополнительный код проверки на ошибки. Код в таблица 3.2 представлен не только для иллюстрации его простоты, но и для наглядной демонстрации характеристик производительности.

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

Программа 3.15 является реализацией простой задачи обработки строк. Она выводит те позиции внутри длинной строки текста, где содержится короткая строка-образец. Для этой задачи разработано несколько сложных алгоритмов, а данный простой алгоритм демонстрирует несколько соглашений, используемых при обработке строк в C++.

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

Таблица 3.2. Элементарные операции со строками
Версии с индексированным массивом
Вычисление длины строки (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 программу, которая читает строки и обрабатывает их.

Упражнения

Составные структуры данных

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

Подобно тому, как одномерные массивы соответствуют векторам, двумерные массивы, с двумя индексами, соответствуют матрицам и широко используются в математических расчетах. Например, следующий код можно применить для перемножения матриц 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, в этих примерах еще не достигнуто полное обобщение структурирования данных. Однако, прежде чем пройти последний этап, мы рассмотрим важные абстрактные структуры данных, которые можно создавать с помощью связных списков и массивов - основных средств достижения следующего уровня общности.

Упражнения

Лекция 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++ . После четкого уяснения этих понятий в конце главы мы вернемся к обсуждению теоретических и практических следствий из них.

Упражнения

Абстрактные объекты и коллекции объектов

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

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

В разделе 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.2. Стек магазинного типа - это АТД, который включает две основные операции: вставить, или втолкнуть (push), новый элемент и удалить, или вытолкнуть (pop), элемент, вставленный последним.

Когда мы говорим об АТД стека магазинного типа, мы считаем, что существует достаточно хорошее описание операций втолкнуть и вытолкнуть, чтобы клиентская программа могла их использовать, а также некоторая реализация этих операций в соответствии с правилом удаления элементов такого стека: последним пришел, первым ушел (last-in, first-out, сокращенно LIFO).

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

 Пример стека магазинного типа (очереди LIFO)


Рис. 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();
  };
        

Упражнения

Примеры клиентов, использующих 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-программы, в которой определяется функция и вычерчивается простая диаграмма.

 Простая программа на языке 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). Несмотря на привлекательность этого решения, учтите, что оно может и не быть оптимальным: ведь различные реализации могут отличаться своей производительностью, так что не стоит априори считать, что одна и та же реализация будет хорошо работать в обоих случаях. Вообще-то наш главный интерес - реализации и их производительность, и сейчас мы приступим к рассмотрению этих вопросов применительно к стеку магазинного типа.

Упражнения

Реализации АТД стека

В данном разделе рассматриваются две реализации АТД стека: в одной используются массивы, а в другой - связные списки. Эти реализации получаются в результате простого применения базовых средств, рассмотренных в лекция №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.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++, общедоступные функции-члены составляют интерфейс, а реализация объединена с интерфейсом в отдельном файле, который включается в программы-клиенты и компилируется каждый раз при компиляции клиентов. В основном это связано с тем, что реализация в виде класса - это удобное и компактное средство представления структур данных и алгоритмов. Но если для какого-либо отдельного приложения потребуется большая гибкость, которая может быть обеспечена одним из рассмотренных способов, то ничто не помешает нам соответствующим образом изменить структуры классов.

Упражнения

Очереди 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.

 Пример очереди 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.

 Пример очереди FIFO, реализованной на базе массива


Рис. 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 и далее в данной книге будут рассмотрены многочисленные примеры изменений в спецификации обобщенных очередей, ведущие к еще более разнообразным АТД.

Упражнения

Повторяющиеся и индексные элементы

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

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

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

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

На рис. 4.9 проиллюстрирована работа модифицированного АТД стека без дубликатов для случая, показанного на рис. 4.1; на рис. 4.10 приведен результат аналогичных изменений для очереди FIFO.

 Стек магазинного типа без дубликатов


Рис. 4.9.  Стек магазинного типа без дубликатов

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

 Очередь FIFO без дубликатов, с правилом игнорирования нового элемента


Рис. 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.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.

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

Список вопросов, которые могут возникнуть при рассмотрении реализаций АТД, будет очень длинным даже в случае таких простых АТД, как те, которые рассматриваются в настоящей лекции. Нужна ли возможность хранения в одной очереди объектов разных типов? Требуется ли нам в одной клиентской программе использовать разные реализации для очередей одного и того же типа, если известны различия в их производительности? Следует ли включать в интерфейс информацию об эффективности реализаций? Какую форму должна иметь эта информация? Подобные вопросы показывают, насколько важно уметь разбираться в основных характеристиках алгоритмов и структур данных и понимать, каким образом клиентские программы могут эффективно их использовать. В некотором смысле именно это и является темой данной книги. Хотя полные реализации часто представляют собой упражнения по технике программирования, а не по проектированию алгоритмов, мы все же стараемся не забывать об этих существенных вопросах, чтобы разработанные алгоритмы и структуры данных могли послужить фундаментом для создания инструментальных программных средств в самых разнообразных приложениях (см. раздел ссылок).

Упражнения

Пример использования АТД

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

Кроме того, необходима возможность вычислять полиномы для заданного значения х. Для 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.

Упражнения

Перспективы

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

АТД должны воплощать простой (и здравый) принцип: необходимо точно описывать способы обработки данных. Для этого в языке 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 иллюстрирует базовые особенности рекурсивной программы: она вызывает саму себя (с меньшим значением аргумента) и содержит условие завершения, при выполнении которого непосредственно вычисляет результат. Чтобы убедиться в правильности работы программы, можно применить метод математической индукции:

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

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

Как обычно, сам код предлагает проверку правильности вычисления методом индукции:

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

Программа 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).

Рекурсивная PostScript -программа для рисования фрактала Коха


Рис. 5.13.  Рекурсивная PostScript -программа для рисования фрактала Коха

Это изменение программы PostScript, приведенной на рис. 4.3, преобразует результат ее работы в фрактал (см. текст).

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

Бинарный поиск (см. лекция №2 и лекция №12) и сортировка слиянием (см. лекция №8) - типичные алгоритмы "разделяй и властвуй ", которые обеспечивают гарантированную оптимальную производительность, соответственно, поиска и сортировки. Рекуррентные соотношения демонстрируют сущность вычислений методом "разделяй и властвуй " для каждого алгоритма. (Вывод решений, приведенных в правом столбце, см. в разделах 2.5 лекция №2 и 2.6.) При бинарном поиске задача делится пополам, выполняется одно сравнение, а затем рекурсивный вызов для одной из половин. При сортировке слиянием задача делится пополам, затем выполняется рекурсивная обработка обеих половин, после чего программа выполняет N сравнений. В книге будет рассмотрено множество других алгоритмов, разработанных с применением этих рекурсивных схем.

Таблица 5.1. Основные алгоритмы типа "разделяй и властвуй "
рекуррентное соотношение приближенное решение
Бинарный поиск
количество сравнений 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. Далее в книге нам встретится и множество других примеров применения деревьев.

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

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

После этого неформального рассмотрения мы перейдем к формальным определениям и рассмотрим различные представления и применения. На рис. 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 -арном дереве - это внутренний узел, все дочерние узлы которого являются внешними.

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

Начнем с наиболее специфической абстрактной структуры - как мы увидим, это позволит подробно рассмотреть конкретные представления.

Определение 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).

Каждое дерево является графом, а какие же графы являются деревьями? Граф считается деревом, если он удовлетворяет любому из следующих четырех условий:

Любое из этих условий - необходимое и достаточное условие для выполнения остальных трех. Формально для определения свободного дерева следует выбрать одно из них; но мы, отбросив формальности, считаем определением все условия вместе.

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

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

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

Упражнения

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 будут рассмотрены структуры данных, основанные на уравновешенных деревьях.

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

Три бинарных дерева с 10 внутренними узлами


Рис. 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, мы

Если граф является связным, со временем будут посещены все узлы. Программа 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. При большой трудоемкости сравнений — например, когда ключи представлены в виде строк — сортировка вставками работает гораздо быстрее, чем два других способа, т.к. в ней выполняется гораздо меньше сравнений. Здесь не рассмотрена ситуация, когда трудоемкими являются операции обмена; в таких случаях лучшей является сортировка выбором.

Таблица 6.1. Эмпирическое исследование элементарных алгоритмов сортировки
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 шагов.

 Перемежающиеся 4-сортировки


Рис. 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 (справа). Вторая последовательность требует на один проход больше, но выполняется быстрее, поскольку каждый ее проход более эффективен.

 4- и 13-упорядоченный файл


Рис. 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).

Таблица 6.2. Эмпирическое сравнение последовательностей шагов для сортировки Шелла
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, т.к. ее различные реализации обычно основаны на алгоритме быстрой сортировки. Однако время выполнения быстрой сортировки зависит от организации входных данных и колеблется между линейной и квадратичной зависимостью от количества сортируемых элементов, и пользователи иногда бывают неприятно удивлены неожиданно неудовлетворительной работой сортировки на некоторых видах входных данных, особенно при использовании хорошо отлаженных версий этого алгоритма. Если приложение работает настолько плохо, что возникает подозрение в наличии дефектов в реализации быстрой сортировки, то более надежным выбором может оказаться сортировка Шелла, хорошо работающая при меньших затратах на реализацию. Однако в случае особо крупных файлов быстрая сортировка обычно выполняется в пять-десять раз быстрее сортировки Шелла, а для некоторых видов файлов, часто встречающихся на практике, ее можно адаптировать для еще большего повышения эффективности.

Базовый алгоритм

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

Полная упорядоченность достигается разбиением файла на подфайлы с последующим рекурсивным применением к ним этого же метода (см. рис. 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) еще более сокращают время сортировки.

Таблица 7.1. Эмпирическое сравнение алгоритмов быстрой сортировки
NШеллаБазовая быстрая сортировкаБыстрая сортировка с разбиением по медиане из трех
M = 0 M = 10 M = 20 M = 0 M = 10 M = 20
125006222323
2500010555546
500002611101012914
10000058242222252028
200000126534850524454
40000027811610511011497118
800000616255231241252213258

Упражнения

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% быстрее системной сортировки.

Таблица 7.2. Эмпирическое сравнение вариантов быстрой сортировки строковых ключей
NVIMQXT
125008761076
25000161413201712
50000373131454129
10000091787610311368
Обозначения:
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 преобразовало этот файл во время копирования в бито-нический.) Положение можно восстановить с помощью рекурсивной реализации той же идеи: нужно реализовать две программы как слияния, так и сортировки слиянием: одну для вывода массива по возрастанию, а другую - для вывода массива по убыванию. Это позволяет снова использовать битоническую стратегию и устранить необходимость в сигнальных ключах во внутреннем цикле.

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

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

Упражнения

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

8.17. Выполните сортировку слиянием больших случайно упорядоченных файлов и экспериментально определите среднюю длину другого подфайла на момент исчерпания первого подфайла как функцию от N (сумма длин двух сливаемых подфайлов).

8.18. Предположим, программа 8.3 модифицирована так, что не вызывает метод merge при a[m] < a[m+1]. Сколько сравнений экономится в этом случае, если сортируемый файл уже упорядочен?

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

8.20. Допустим, что сортировка слиянием должна быть выполнена на h-сортированном файле для небольшого значения h. Какие изменения нужно внести в подпрограмму merge, чтобы воспользоваться этим свойством входных данных? Поэкспериментируйте с гибридами сортировки методом Шелла и сортировки слиянием, основанными на этой подпрограмме.

8.21. Разработайте реализацию слияния, уменьшающую требование дополнительной памяти до max(M, N/M) за счет следующей идеи. Разбейте массив на N/M блоков размером M (для простоты предположим, что N кратно M). Затем, (1) рассматривая эти блоки как записи, первые ключи которых являются ключами сортировки, отсортируйте их с помощью сортировки выбором, и (2) выполните проход по массиву, сливая первый блок со вторым, затем второй блок с третьим и так далее.

8.22. Докажите, что метод, описанный в упражнении 8.21, выполняется за линейное время.

8.23. Реализуйте битоническую сортировку слиянием без копирования.

Восходящая сортировка слиянием

Как было сказано в лекция №5, у каждой рекурсивной программы имеется нерекурсивный аналог, который хотя и выполняет эквивалентные действия, но может делать это в другом порядке. Нерекурсивные реализации сортировки слиянием заслуживают детального изучения в качестве образцов философии алгоритмов " разделяй и властвуй " .

Рассмотрим последовательность слияний, выполняемую рекурсивным алгоритмом. Из примера, приведенного на рис. 8.2, видно, что файл размером 15 сортируется следующей последовательностью слияний:

  1-и-1 1-и-1 2-и-2 1-и-1 1-и-1 2-и-2 4-и-4
 1-и-1 1-и-1 2-и-2 1-и-1 2-и-1 4-и-3 8-и-7.

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

   1-и-1 1-и-1 1-и-1 1-и-1 1-и-1 1-и-1 1-и-1
  2-и-2 2-и-2 2-и-2 2-и-1 4-и-4 4-и-3 8-и-7.

 Пример восходящей сортировки слиянием


Рис. 8.4.  Пример восходящей сортировки слиянием

В каждой строке показан результат вызова метода merge при выполнении восходящей сортировки слиянием. Вначале выполняются слияния 1-и-1: при слиянии A и S получается A S; при слиянии O и R получается O R и т.д. Из-за нечетности размера файла последнее E не принимает участие в слиянии. На втором проходе выполняются слияния 2-и-2: A S сливается с O R, и получается A O R S и т.д., до последнего слияния 2-и-1. После этого выполняются слияния 4-и-4, 4-и-3 и завершающее 8-и-7.

В обоих случаях выполняются семь слияний 1-и-1, три слияния 2-и-2 и по одному слиянию 2-и-1, 4-и-4, 4-и-3 и 8-и-7, но они выполняются в различном порядке. Восходящая стратегия предлагает сливать наименьшие из оставшихся файлов, проходя по массиву слева направо.

Последовательность слияний, выполняемая рекурсивным алгоритмом, определяется деревом " разделяй и властвуй " , показанным на рис. 8.3: мы просто выполняем обратный проход по этому дереву. Как было показано в лекция №3, можно разработать нерекурсивный алгоритм, использующий явный стек, который даст ту же последовательность слияний. Однако совсем не обязательно ограничиваться только обратным порядком: любой проход по дереву, при котором обход поддеревьев узла завершается перед посещением самого узла, дает правильный алгоритм. Единственное ограничение заключается в том, что сливаемые файлы должны быть предварительно отсортированы. В случае сортировки слиянием удобно сначала выполнять все слияния 1-и-1, затем все слияния 2-и-2, затем все 4-и-4, и так далее. Такая последовательность соответствует обходу дерева по уровням, который поднимается по дереву снизу вверх.

В лекция №5 мы уже видели на нескольких приме -рах, что при рассуждении в стиле снизу-вверх имеет смысл переориентировать мышление в сторону стратегии " объединяй и властвуй " , когда сначала решаются небольшие подзадачи, а затем они объединяются для получения решения большей задачи. В частности, нерекурсивный вариант вида " объединяй и властвуй " сортировки слиянием в программе 8.5 получается следующим образом: вначале все элементы файла рассматриваются как упорядоченные подсписки длиной 1. Потом для них выполняются слияния 1-и-1, и получаются упорядоченные подсписки размером 2, затем выполняется серия слияний 2-и-2, что дает упорядоченные подсписки размером 4, и так далее до упорядочения всего списка. Если размер файла не является степенью 2, то последний подсписок не всегда имеет тот же размер, что и все другие, но его все равно можно слить.

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

Если размер файла не равен степени 2, восходящий алгоритм дает другое множество слияний, как показано на рис. 8.5. Восходящий алгоритм соответствует дереву " объединяй и властвуй " (см. упражнение 5.75), отличному от дерева " разделяй и властвуй " , которое соответствует нисходящему алгоритму. Можно сделать так, чтобы последовательность слияний, выполняемых рекурсивным методом, была такой же, как и для нерекурсивного метода, однако для этого нет особых причин, поскольку разница в производительности невелика по отношению к общим затратам.

 Размеры файлов при восходящей сортировке слияением


Рис. 8.5.  Размеры файлов при восходящей сортировке слияением

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

Леммы 8.1-8.4 справедливы и для восходящей сортировки слиянием, при этом имеют место следующие дополнительные леммы:

Лемма 8.5. Все слияния на каждом проходе восходящей сортировки слиянием манипулируют файлами, размер которых равен степени 2, за исключением, возможно, размера последнего файла.

Это факт легко доказать методом индукции.

Лемма 8.6. Количество проходов при восходящей сортировке слиянием по файлу из N элементов в точности равно числу битов в двоичном представлении N (без ведущих нулей). Размер подсписков после к проходов равен 2k, т.к. на каждом проходе восходящей сортировки слиянием размер упорядоченных подфайлов удваивается. Значит, количество проходов, необходимое для сортировки файла из N элементов, есть наименьшее к такое, что , что в точности равно , т.е. количеству битов в двоичном представлении N. Этот результат можно доказать и методом индукции или с помощью анализа структурных свойств деревьев " объединяй и властвуй " . ¦

Программа 8.5. Восходящая сортировка слиянием

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

  inline int min(int A, int B)
    { return (A < B) ? A : B; }
  template <class Item>
  void mergesortBU(Item a[], int l, int r)
    { for (int m = 1; m <= r-l; m = m+m)
      for (int i = l; i <= r-m; i += m+m)
        merge(a, i, i+m-1, min(i+m+m-1, r));
    }
        

 Восходящая сортировка слиянием


Рис. 8.6.  Восходящая сортировка слиянием

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

Процесс выполнения восходящей сортировки слиянием большого файла показан на рис. 8.6. Сортировка 1 миллиона элементов выполняется за 20 проходов по данным, 1 миллиарда - за 30 проходов и т.д.

Подводя итоги, отметим, что нисходящая и восходящая сортировки - это два простых алгоритма сортировки, основанных на операции слияния двух упорядоченных подфайлов в результирующий объединенный упорядоченный файл. Оба алгоритма тесно связаны между собой и даже выполняют одно и то же множество слияний, если размер исходного файла является степенью 2, но они отнюдь не идентичны. На рис. 8.7 демонстрируются различия динамических характеристик алгоритмов на примере большого файла. Каждый алгоритм может использоваться на практике, если экономия памяти не важна, и желательно гарантированное время выполнения в худшем случае. Оба алгоритма представляют интерес как прототипы общих принципов построения алгоритмов: " разделяй и властвуй " и " объединяй и властвуй " .

 Сравнение восходящей и нисходящей сортировки слиянием


Рис. 8.7.  Сравнение восходящей и нисходящей сортировки слиянием

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

Упражнения

8.24. Покажите, какие слияния выполняет восходящая сортировка слиянием (программа 8.5) для ключей E A S Y Q U E S T I O N.

8.25. Реализуйте восходящую сортировку слиянием, которая начинает с сортировки вставками блоков по M элементов. Определите эмпирическим путем значение M, для которого разработанная программа быстрее всего сортирует произвольно упорядоченные файлы из N элементов, при N = 103, 104, 105 и 106 .

8.26. Нарисуйте деревья, которые отображают слияния, выполняемые программой 8.5 для N = 16, 24, 31, 32, 33 и 39.

8.27. Напишите программу рекурсивной сортировки слиянием, выполняющую те же слияния, что и восходящая сортировка слиянием.

8.28. Напишите программу восходящей сортировки слиянием, выполняющую те же слияния, что и нисходящая сортировка слиянием. (Это упражнение намного труднее, чем упражнение 8.27).

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

8.30. Докажите, что количество проходов, выполняемых нисходящей сортировкой слиянием, также равно количеству битов в двоичном представлении числа N (см. лемму 8.6).

Производительность сортировки слиянием

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

Помимо усовершенствований, рассмотренных в разделе 8.2, можно добиться дальнейшего повышения производительности, поместив наименьшие элементы обоих массивов в простые переменные или машинные регистры процессора и избежав таким образом лишних обращений к массивам. Тогда внутренний цикл сортировки слиянием можно свести к сравнению (с условным переходом), увеличению на единицу значений двух счетчиков (k и либо i, либо j) и проверке условия завершения цикла с условным переходом. Общее количество команд в таком внутреннем цикле несколько больше, чем для быстрой сортировки, но эти команды выполняются всего лишь Nlg N раз, в то время как команды внутреннего цикла быстрой сортировки выполняются на 39% чаще (или на 29% для варианта с вычислением медианы из трех). Для более точного сравнения этих двух алгоритмов в различных средах нужна их тщательная реализация и подробный анализ. Однако точно известно, что внутренний цикл сортировки слиянием несколько длиннее внутреннего цикла быстрой сортировки.

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

Представленные здесь относительные временные показатели различных видов сортировки для случайно упорядоченных файлов чисел с плавающей точкой различного размера N показывают, что: стандартная быстрая сортировка примерно в два раза быстрее стандартной сортировки слиянием; добавление отсечения небольших файлов снижает время выполнения и нисходящей, и восходящей сортировок слиянием примерно на 15%; для указанных в таблице размеров файлов быстродействие нисходящей сортировки слиянием приблизительно на 10% выше, чем восходящей; даже если устранить затраты на копирование файла, то и в этом случае сортировка слиянием случайно упорядоченных файлов на 50-60% медленнее обычной быстрой сортировки (см. таблица 8.1).

Таблица 8.1. Эмпирическое сравнение алгоритмов сортировки слиянием
NQНисходящаяВосходящая
TT*OBB*
12500254454
2500051288119
50000112320172623
100000245343375953
200000521119278127110
400000109237198168267232
800000241524426358568496
Обозначения:
QСтандартная быстрая сортировка (программа 7.1)
TСтандартная нисходящая сортировка слиянием (программа 8.1)
T*Нисходящая сортировка слиянием с отсечением небольших файлов
OНисходящая сортировка слиянием с отсечением и без копирования массива
BСтандартная восходящая сортировка слиянием (программа 8.5)
B*Восходящая сортировка слиянием с отсечением небольших файлов

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

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

Эти моменты уже были рассмотрены в лекция №5, однако привлекательность преждевременной оптимизации настолько сильна, что уместно напоминать об этом каждый раз при детальном изучении методов улучшения производительности. В случае сортировки слиянием можно чувствовать себя вполне спокойно, поскольку леммы 8.1—8.4 описывают наиболее важные характеристики производительности и верны для всех рассмотренных нами реализаций: время их выполнения пропорционально Nlog N при любой организации входных данных (см. рис. 8.8), они используют дополнительную память и могут быть реализованы с сохранением устойчивости. Сохранение этих свойств в процессе оптимизации обычно не является трудной задачей.

 Упорядочение различных видов файлов с помощью восходящей сортировки слиянием


Рис. 8.8.  Упорядочение различных видов файлов с помощью восходящей сортировки слиянием

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

Упражнения

8.31. Реализуйте восходящую сортировку слиянием без копирования массивов.

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

Реализации сортировки слиянием для связных списков

Раз уж для практической реализации сортировки слиянием все равно требуется дополнительная память, то можно рассмотреть и реализацию для связных списков. То есть вместо использования дополнительной памяти на вспомогательный массив можно применить ее для хранения ссылок. А кто-то может сразу столкнуться с проблемой сортировки связного списка (см. лекция №6). Оказывается, сортировка слиянием очень удобна для связных списков. Полная реализация метода сортировки слиянием связных списков представлена в программе 8.6. Обратите внимание, что код самого слияния почти так же прост, как и для слияния массивов (программа 8.2).

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

Программа 8.6. Слияние связных списков

Данная программа сливает список, на который указывает a, со списком, на который указывает b, с помощью вспомогательного указателя с. Операция сравнения ключей в функции merge включает и равенство, так что слияние будет устойчивым, если по условию список b следует за списком a. Для простоты здесь принято, что все списки завершаются пустой ссылкой, но пригодны и другие соглашения относительно окончания списков (см. таблица 3.1). Что еще важнее, в коде не используются ведущие узлы списков, которых иначе было бы очень много.

link merge(link a, link b)
  { node dummy(0); link head = &dummy, c = head;
    while ((a != 0) && (b != 0))
      if ( a->item < b->item)
        { c->next = a; c = a; a = a->next; }
      else
      { c->next = b; c = b; b = b->next; }
    c->next = (a == 0) ? b : a;
    return head->next;
  }
        

Программа 8.7. Сортировка списков слиянием сверху вниз

Для выполнения сортировки эта программа разбивает список, на который указывает c, на две части, на которые указывают, соответственно, указатели a и b, рекурсивно сортирует эти части и получает окончательный результат с помощью функции merge (программа 8.6). Входной список должен заканчиваться пустой ссылкой (следовательно, так же должен заканчиваться и список b), а в конец списка a пустую ссылку заносит специальный оператор c->next = 0.

link mergesort(link c)
  { if (c == 0 || c->next == 0) return c;
    link a = c, b = c->next;
    while ((b != 0) && (b->next != 0))
      { c = c->next; b = b->next->next; }
    b = c->next; c->next = 0;
    return merge(mergesort(a), mergesort(b));
  }
        

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

Сам собой напрашивается интересный вариант восходящей сортировки слиянием связных списков: помещаем элементы списка в циклический список, после чего проходим по списку, сливая пары упорядоченных подфайлов до полного завершения. Этот метод концептуально прост, однако (как это часто бывает в случае низкоуровневых программ обработки связных списков) его реализация может оказаться весьма запутанной (см. упражнение 8.36). Другой вариант восходящей сортировки слиянием связных списков, основанный на той же идее, представлен в программе 8.8: в нем все сортируемые списки хранятся в АТД очереди.

Программа 8.8. Восходящая сортировка слиянием связных списков

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

link mergesort(link t)
  { QUEUE<link> Q(max);
    if (t == 0 || t->next == 0) return t;
    for (link u = 0; t != 0; t = u)
      { u = t->next; t->next = 0; Q.put(t); }
    t = Q.get();
    while (!Q.empty())
      { Q.put(t); t = merge(Q.get(), Q.get()); }
    return t;
  }
        

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

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

Упражнения

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

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

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

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

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

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

8.39. Нарисуйте дерево " объединяй и властвуй " , которое отображает слияния, выполняемые программой 8.8, для N = 16, 24, 31, 32, 33 и 39.

8.40. Нарисуйте дерево " объединяй и властвуй " , которое отображает слияния, выполняемые сортировкой слиянием циклического списка (упражнение 8.38), для N = 16, 24, 31, 32, 33 и 39.

8.41. Проведите эмпирические исследования, и на их основе выдвиньте гипотезу о количестве упорядоченных подфайлов в файле из N случайных 32-разрядных двоичных целых чисел.

8.42. Экспериментально определите количество проходов, необходимых для выполнения естественной сортировки слиянием случайных 64-разрядных двоичных ключей, при N = 103, 104, 105 и 106 . Подсказка: для выполнения этого упражнения не обязательно реализовать сортировку (и даже генерировать полные 64-разрядные ключи).

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

8.44. Реализуйте естественную сортировку слиянием для массивов.

Возврат к рекурсии

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

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

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

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

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

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

Упражнения

8.45. Допустим, сортировка слиянием реализована таким образом, что разбиение файла выполняется в произвольном месте, а не точно в середине файла. Сколько в среднем сравнений выполнит этот метод для упорядочения N элементов?

8.46. Проведите анализ эффективности сортировки слиянием при упорядочении строк. Сколько в среднем сравнений символов выполняется при сортировке большого файла?

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

Лекция 9. Очереди с приоритетами и пирамидальная сортировка

Рассмотрены структуры данных, состоящие из элементов с ключами (очереди с приоритетами)и методы их сортировки.

Во многих приложениях требуется обработка записей в порядке возрастания их ключей, но не обязательно в строгом порядке и не обязательно всех сразу. Часто записи накапливаются в некотором наборе, затем обрабатывается запись с максимальным ключом, после чего, возможно, продолжается накопление записей, потом обрабатывается запись с наибольшим текущим ключом и т.д. Соответствующая структура данных в такой среде поддерживает операции вставки нового элемента и удаления наибольшего элемента. Такая структура данных называется очередью с приоритетами (priority queue). Использование очередей с приоритетами похоже на использование обычных очередей (удаляется самый старый элемент) и стеков (удаляется самый новый элемент), однако эффективно реализовать их гораздо труднее. Очередь с приоритетами является наиболее важным примером АТД обобщенной очереди, который обсуждался в лекция №4. Фактически очередь с приоритетами представляет собой обобщение стека и очереди, поскольку эти структуры данных можно реализовать через очереди с приоритетами, используя соответствующие правила назначения приоритетов (см. упражнения 9.3 и 9.4).

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

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

Любую очередь с приоритетами можно использовать в качестве основы алгоритма сортировки, поместив в очередь все записи, а затем последовательно выбирая из нее записи с наибольшим ключом — получится обратно упорядоченная последовательность записей. Далее в этой книге мы увидим, как можно использовать очередь с приоритетами в качестве строительных блоков для более сложных алгоритмов. В части V мы разработаем алгоритм сжатия файлов, основанный на подпрограммах из данной главы, а в лекция №7 увидим, как абстракция очередей с приоритетами облегчает понимание взаимоотношений между множеством фундаментальных алгоритмов поиска в графах. Это лишь несколько примеров той важной роли, которую играют очереди с приоритетами как базовые инструментальные средства при разработке алгоритмов.

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

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

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

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

Очередь с приоритетами является прототипом абстрактного типа данных (АТД, см. лекция №4): это четко определенный набор операций над данными, который является удобной абстракцией, позволяющей отделить прикладные программы (клиенты) от различных реализаций, которые будут рассмотрены в настоящей главе. Интерфейс, заданный программой 9.1, определяет самые основные операции над очередями с приоритетами; более полный интерфейс будет рассмотрен в разделе 9.5. Строго говоря, различные подмножества разных операций, которые мы предпочтем включить в свой рабочий набор, приводят к различным абстрактным структурам данных, но для очередей с приоритетами наиболее характерны операции извлечь наибольший и вставить, поэтому основное внимание будет уделено именно им.

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

Сначала это положение будет проиллюстрировано в разделе 9.1 при обсуждении нескольких элементарных структур данных для реализации очередей с приоритетами. Далее, в разделах 9.2—9.4, наше внимание будет сосредоточено на рассмотрении классической структуры данных, получившей название пирамидальное дерево, которая обеспечивает эффективную реализацию всех операций, кроме операции объединить. Кроме того, в разделе 9.4 будет рассмотрен важный алгоритм сортировки, естественным образом вытекающий из этих реализаций.

Программа 9.1. Базовый АТД очереди с приоритетами

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

template <class Item>
class PQ
  { private :
      // Код, зависящий от реализации 
    public:
      PQ(int);
    int empty() const;
    void insert(Item);
    Item getmax();
  };
      

В разделах 9.5 и 9.6 будут более подробно проанализированы некоторые проблемы, связанные с разработкой полных АТД очереди с приоритетами. И в завершение, в разделе 9.7 мы рассмотрим более сложную структуру данных, получившую название биномиальной очереди, которая позволяет реализовать все операции (в том числе и операции объединить) с логарифмическим временем выполнения в худшем случае.

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

Упражнения

9.1. В последовательности

р r I о * R * * I * T * Y * * * Q U E * * * U * E

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

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

( ( ( P R I O ) + ( R * I T * Y * ) ) * * * ) + ( Q U E * * * U * E )

9.3. Объясните, как использовать АТД очереди с приоритетами для реализации АТД стека.

9.4. Объясните, как использовать АТД очереди с приоритетами для реализации АТД очереди.

Элементарные реализации

Базовые структуры данных, рассмотренные в лекция №3, предоставляют множество различных возможностей для реализации очередей с приоритетами. Программа 9.2 является реализацией, основанной на использовании неупорядоченного массива. Операция извлечь наибольший реализуется следующей последовательностью действий: сначала, просмотром всего массива, находится наибольший элемент, затем на его место копируется последний элемент, и размер очереди уменьшается на единицу. На рис. 9.1 показано содержимое такого массива при выполнении последовательности операций. Эта базовая реализация соответствует реализациям из лекция №4 для стеков и очередей (см. программы 4.4 и 4.11) и пригодна для очередей небольших размеров. Основные различия между ними связаны с их производительностью. Для стеков и очередей можно было разработать реализации всех операций, которые выполняются за постоянное время. Что же касается очередей с приоритетами, то легко написать такие реализации, в которых за постоянное время выполняется одна из функций вставить или извлечь наибольший, однако найти реализацию с быстрым выполнением обеих операций гораздо труднее — это и будет темой данной главы.

Программа 9.2. Реализация очереди с приоритетами на базе массива

Эта реализация, которую можно сравнить с реализациями стеков и очередей на базе массивов, рассмотренными в лекция №4 (см. программы 4.4 и 4.11), содержит элементы в неупорядоченном массиве. Элементы добавляются в конец массива и удаляются из конца массива, как и в стеке.

  template <class Item>
  class PQ
    { private:
      Item *pq;
      Int N;
      public:
        PQ (int maxN)
        { pq = new Item[maxN]; N = 0; }
      int empty() const
        {return N == 0; }
      void insert (Item item)
        { pq[N++] = item; }
      Item getmax( )
        { int max = 0;
for (int j = 1; j < N; j++)
  if (pq[max] < pq[j]) max = j;
exch(pq[max], pq[N-1]);
return pq[—N];
        }
    };
        

 Пример очереди с приоритетами (реализация на базе неупорядоченного массива)


Рис. 9.1.  Пример очереди с приоритетами (реализация на базе неупорядоченного массива)

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

Можно использовать упорядоченные и неупорядоченные последовательности, реализованные в виде связных списков или массивов. Выбор, оставить ли элементы несортированными или упорядочить их, определяется тем, что упорядочение элементов позволяет извлечь наибольший или найти наибольший за постоянное время, но может означать необходимость прохода по всему списку, чтобы вставить элемент, в то время как неупорядоченная последовательность элементов позволяет вставить элемент за постоянное время, а чтобы извлечь наибольший или найти наибольший элемент, потребуется просмотр всего списка. Неупорядоченная последовательность — это прототип ленивого подхода к решению задачи, когда выполнение работы откладывается до тех пор, пока это не станет необходимым (в данном случае — поиск наибольшего элемента); упорядоченная последовательность представляет собой прототип энергичного подхода, когда заранее выполняется максимально возможный объем работы (поддержка отсортирован-ности списка при вставках), для обеспечения максимальной эффективности последующих операций. В любом из этих случаев можно использовать представление данных и в виде массива, и в виде связного списка; основной компромисс при этом состоит в том, что (дву-) связный список позволяет выполнять операцию извлечь (и объединить, если данные неупорядочены) за постоянное время, но требует дополнительной памяти для хранения ссылок. Затраты на выполнение различных операций в худшем случае (с точностью до постоянного коэффициента) для различных реализаций очереди с приоритетами сведены в таблица 9.1.

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

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

Как видно из данной таблицы, содержащей временные показатели различных методов для худшего случая (с точностью до постоянного множителя для больших N), показатели производительности реализаций АТД очереди с приоритетами колеблются в широких пределах. Элементарные методы (первые четыре строки) требуют для выполнения некоторых операций постоянного времени и линейного времени для остальных; более совершенные методы гарантируют для большинства или даже всех операций логарифмическое или постоянное время.

Таблица 9.1. Затраты на операции над очередями с приоритетами в худшем случае
вставитьизвлечь наибольшийизвлечьнайти наибольшийизменить приоритетобъединить
Упорядоченный массивN1N1NN
Упорядоченный списокN111NN
Неупорядоченный массив1N1N1N
Неупорядоченный список1N1N11
Пирамидальное деревоlgNlgNlgN1lgNN
Биномиальная очередьlgNlgNlgNlgNlgNlgN
Теоретически наилучший1lgNlgN111

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

Упражнения

9.5. В чем недостаток следующей идеи: для реализации операции найти наибольший отслеживать максимальное значение, включенное в очередь с приоритетами на текущий момент, а затем возвращать это значение как результат операции?

9.6. Приведите содержимое массива после выполнения последовательности операций, показанной на рис. 9.1.

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

9.8. Напишите реализацию базового интерфейса очереди с приоритетами, основанную на использовании неупорядоченного связного списка. Совет: см. программы 4.8 и 4.14.

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

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

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

9.12. Напишите клиентскую программу-драйвер для измерения производительности, которая использует функцию insert для заполнения очереди с приоритетами, затем в течение 1 секунды выполняет операции getmax и insert — и так многократно, используя случайные последовательности ключей различной длины. Программа должна измерять время, затраченное на каждое выполнение, и вывести таблицу или график среднего количества операций getmax, которое удалось выполнить за секунду.

9.13. Воспользуйтесь клиентской программой из упражнения 9.12 для сравнения реализации с помощью неупорядоченного массива из программы 9.2 с реализацией на основе неупорядоченного списка из упражнения 9.8.

9.14. Воспользуйтесь клиентской программой из упражнения 9.12 для сравнения реализаций на основе упорядоченного массива и упорядоченного списка из упражнений 9.7 и 9.9.

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

9.16.( За этим упражнением на самом деле стоят 24 упражнения.) Докажите правильность границ, приведенных в таблица 9.1 для четырех элементарных реализаций, используя реализации операций вставить и извлечь наибольший из программы 9.2 и упражнений 9.7—9.9, а также неформальное описание методов реализации других операций. Считайте, что для операций извлечь, изменить приоритет и объединить имеется возможность прямого доступа к указываемому объекту.

Пирамидальная структура данных

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

Определение 9.2. Дерево называется пирамидально упорядоченным (heap-ordered), если ключ в каждом его узле больше или равен ключам всех дочерних узлов этого узла (если они есть). Эквивалентная формулировка: ключ в каждом узле пирамидально упорядоченного дерева меньше или равен ключу узла, который является родителем данного узла (если он есть).

Лемма 9.1. Никакой узел пирамидально упорядоченного дерева не может иметь ключ больший, чем ключ в корне дерева.

Ограничение пирамидальной упорядоченности можно наложить на любое дерево. Однако удобнее всего пользоваться полным бинарным деревом (complete binary tree). В лекция №3 было показано, что такую структуру можно начертить, начав с корневого узла вверху страницы, а затем передвигаясь по странице вниз и слева направо, присоединяя к каждому узлу предыдущего уровня два узла текущего уровня до размещения всех N узлов. Полное бинарное дерево можно представить в виде массива, поместив корневой узел в позицию 1, его дочерние узлы в позиции 2 и 3, узлы следующего уровня в позиции 4, 5, 6 и 7 и т.д., как показано на рис. 9.2.

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

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

В разделе 9.3 мы увидим, что пирамидальные деревья позволяют реализовать все операции над очередями с приоритетами (за исключением операции объединить) таким образом, что на свое выполнение они потребуют логарифмическое время в худшем случае. Все такие реализации оперируют узлами вдоль некоторого пути в пирамидальном дереве (от родителя вниз к дочерним узлам или от дочернего узла вверх к родителю, но без смены направления). Как было показано в лекция №3, все пути в полном дереве, состоящем из N узлов, содержат порядка lg N узлов: примерно N/2 узлов находятся на самом нижнем уровне, N/4 узлов — узлы с дочерними узлами на нижнем уровне, N/8 узлов — " внуки " которых занимают нижний уровень и т.д. Каждое поколение узлов содержит приблизительно вдвое меньше узлов, чем последующее, а всего может быть максимум lg N поколений.

 Представление полного бинарного дерева с пирамидальной упорядоченностью в виде массива


Рис. 9.2.  Представление полного бинарного дерева с пирамидальной упорядоченностью в виде массива

Ситуация, когда элемент в позиции массива является родителем элемента в позиции i, для (или, что то же самое, когда i-й элемент является родителем 2 i -го и (2 i + 1)-го элементов), соответствует удобному представлению элементов массива в виде дерева.

Это соответствие эквивалентно нумерации узлов полного бинарного дерева (с заполнением нижнего уровня слева) по уровням Дерево пирамидально упорядочено (heap-ordered), если ключ в любом узле больше или равен ключам его дочерних узлов. Пирамидальное дерево (heap) является представлением полного пирамидально упорядоченного бинарного дерева в виде массива. Его i-й элемент больше или равен и 2 i -му, и (2 i + 1)-му.

Для разработки эффективных реализаций операций над очередями с приоритетами можно также воспользоваться явными связными представлениями древовидных структур. В качестве примеров можно привести полные пирамидально упорядоченные деревья с тремя ссылками (см. раздел 9.5), турниры (см. программу 5.19) и биномиальные очереди (см. раздел 9.7). Как и в случае простых стеков и очередей, одна из главных причин, побуждающих рассматривать связные представления, заключается в том, что в этом случае нет необходимости заранее знать максимальный размер очереди, что требуется в случае представления в виде массива. В некоторых случаях гибкость, обеспечиваемая связными структурами, позволяет разработать эффективные алгоритмы. Читателям, которые не имеют достаточного опыта использования явных древовидных структур, прежде чем иметь дело со связными представлениями деревьев, рассматриваемых в упражнениях этой главы и в разделе 9.7, рекомендуется прочитать лекция №12 и ознакомиться с базовыми методами реализации еще более важных абстрактных типов данных — таблиц символов. Однако капитальное изучение связных структур можно оставить до второго чтения, поскольку основной темой настоящей главы является пирамидальное дерево (представление с помощью массива без ссылок в виде полного пирамидально упорядоченного дерева).

Упражнения

9.17. Является ли массив, отсортированный в нисходящем порядке, пирамидальным деревом?

9.18. Наибольший элемент пирамидального дерева должен находиться в позиции 1, а второй наибольший элемент должен занимать позицию 2 или 3. Приведите список позиций в пирамидальном дереве из 15 элементов, в которых к-й наибольший элемент (1) может появиться и (2) не может появиться, для к = 2, 3, 4 (значения всех элементов различны).

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

9.20. Выполните упражнения 9.18 и 9.19 для к-го наименьшего элемента.

Алгоритмы на пирамидальных деревьях

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

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

Если же пирамидальность дерева была нарушена из-за того, что ключ какого-либо узла стал меньше одного или обоих ключей его потомков, можно попытаться устранить это нарушение, обменяв узел с большим из его двух потомков. Такой обмен может вызвать нарушение пирамидальности дерева на узле-потомке; его можно устранить таким же способом и двигаться вниз по дереву до достижения либо узла, оба потомка которого меньше его самого, либо нижнего уровня дерева. Пример этого процесса показан на рис. 9.4. В коде опять используется тот факт, что потомки узла пирамидального дерева в позиции k находятся в позициях 2 k и 2k+1.

Программа 9.3. Восходящее восстановление пирамидальности

Чтобы восстановить пирамидальную структуру после повышения приоритета какого-либо узла, мы продвигаемся вверх по дереву, обменивая при необходимости узел в позиции k с его родителем (в позиции k/2), и продолжаем этот процесс, пока выполняется условие a[k/2] <a[k], или до достижения корня дерева.

  template <class Item>
  void fixUp(Item a[], int k)
    { while (k > 1 && a[k/2] < a[k])
        { exch (a[k], a[k/2]); k = k/2; }
    }
        

 Восходящее восстановление пирамидальности


Рис. 9.3.  Восходящее восстановление пирамидальности

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

 Нисходящее восстановление пирамидальности


Рис. 9.4.  Нисходящее восстановление пирамидальности

Дерево, показанное вверху, пирамидально упорядочено, за исключением корня. Обмен элемента O с большим из его дочерних узлов (X) поправляет пирамидальный порядок, за исключением поддерева с корнем O. Обмен элемента O с большим из его дочерних узлов продолжается до достижения нижнего уровня дерева или момента, когда O будет больше обоих своих дочерних узлов — тогда будет восстановлена пирамидальность всего дерева. На основе этой процедуры можно построить операцию извлечь наибольший: для этого нужно заменить элемент в корне самым правым элементом на нижнем уровне, а потом восстановить пирамидальность.

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

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

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

Программа 9.4. Нисходящее восстановление пирамидальности

Чтобы восстановить пирамидальную структуру после понижения приоритета какого-либо узла, мы двигаемся вниз по дереву, обменивая при необходимости узел в позиции k с большим из двух его дочерних узлов, и останавливаемся, когда узел в позиции k не меньше обоих своих дочерних узлов или когда достигнут нижний уровень. Обратите внимание, что если N четно и k равно N/2, то у узла в позиции k только один дочерний узел — этот случай требует особого подхода!

У внутреннего цикла в этой программе два отдельных выхода: один для случая, когда достигнут нижний уровень, а другой для случая, когда условие пирамидальности выполняется где-то внутри дерева. Это характерный пример необходимости применения конструкции break.

template <class Item>
  void fixDown(Item a[ ], int k, int N)
    { while (2*k <= N)
        { int j = 2*k;
if (j < N && a[j] < a[j + 1]) j++;
if (!(a[k] < a[j])) break;
exch (a[k], a[j]); k = j;
        }
    }
        

Эти две основные операции позволяют эффективно реализовать базовый АТД очереди с приоритетами — см. программу 9.5. Если очередь с приоритетами представлена как пирамидально упорядоченный массив, то операция вставить сводится к добавлению нового элемента в конец массива и перемещению этого элемента вверх по массиву для восстановления пирамидальности; а операция извлечь наибольший сводится к удалению наибольшего элемента из вершины дерева с последующей пересылкой элемента из конца дерева в его вершину и перемещением его вниз по массиву для восстановления пирамидальной структуры.

Лемма 9.2. Операции вставить и извлечь наибольший для абстрактного типа данных очереди с приоритетами могут быть реализованы с помощью пирамидально упорядоченных деревьев таким образом, что для очереди из N элементов операция вставить потребует не более lg N сравнений, а операция извлечь наибольший — не более 2 lg N сравнений.

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

Программа 9.5. Очередь с приоритетами на базе пирамидального дерева

При выполнении операции вставки (insert) мы увеличиваем N на 1, добавляем новый элемент в конец дерева, а затем вызываем процедуру fixUp для восстановления пирамидальности. При извлечении наибольшего элемента (getmax) мы выбираем в качестве возвращаемого значения значение pq[1], потом уменьшаем размер дерева на 1, перенеся значение pq[N] в pq[1], а затем вызываем процедуру fixDown для восстановления пирамидальности. Реализации конструктора и функции empty тривиальны. Первая позиция pq[0] массива здесь не используется и может быть задействована в других реализациях в качестве сигнального ключа.

  template <class Item>
  class PQ
    { private:
        Item *pq;
        Int N;
      public:
        PQ(int maxN)
        { pq = new Item[maxN+1]; N = 0; }
      int empty() const
        { return N == 0; }
      void insert (Item, item)
        { pq[++N] = item; fixUp(pq, N); }
      Item getmax()
        { exch(pq[1], pq[n]);
fixDown(pq, 1, N-1);
return pq[N--];
        }
    };
        

На рис. 9.5 и рис. 9.6 показан пример построения пирамидального дерева последовательными вставками элементов в первоначально пустое пирамидальное дерево. В используемом нами представлении пирамидального дерева в виде массива этот процесс соответствует пирамидальному упорядочению массива: при каждой вставке нового элемента размер дерева увеличивается на 1, а для восстановления пирамидальности используется процедура fixUp.

 Нисходящее построение пирамидального дерева


Рис. 9.5.  Нисходящее построение пирамидального дерева

Эта последовательность диаграмм демонстрирует вставку ключей A S O R T I N G в первоначально пустое пирамидальное дерево. Новые элементы добавляются на нижний уровень дерева, справа налево. Каждая вставка затрагивает только узлы на пути между точкой вставки и корнем, поэтому в худшем затраты пропорциональны логарифму размера пирамидального дерева.

 Нисходящее построение пирамидального дерева (продолжение)


Рис. 9.6.  Нисходящее построение пирамидального дерева (продолжение)

Здесь показана вставка ключей E X A M P L E в пирамидальное дерево, заполнение которого начато на рис. 9.5. Общая стоимость построения пирамидального дерева размера N не превосходит lg1 + lg 2 + . . . + lgN, а эта величина меньше N lgN.

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

Базовые процедуры fixUp и fixDown из программ 9.3 и 9.4 также позволяют получить прямую реализацию операций изменить приоритет и извлечь. Чтобы изменить приоритет элемента, находящегося где-то в середине пирамидального дерева, применяется процедура fixUp для перемещения вверх по дереву, если приоритет элемента увеличивается, либо процедура fixDown для перемещения вниз по дереву, если приоритет уменьшается. Полная реализация таких операций, обращающихся к конкретным элементам данных, имеет смысл, только если для каждого элемента имеется дескриптор, указывающий место этого элемента в структуре данных. Соответствующие реализации будут подробно рассмотрены в разделах 9.5—9.7.

Лемма 9.3. Операции изменить приоритет, извлечь и заменить наибольший для АТД очереди с приоритетами могут быть реализованы с помощью пирамидально упорядоченных деревьев таким образом, что для выполнения любой из этих операций в очереди из N элементов потребуется не более 2 lgN сравнений.

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

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

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

На рис. 9.5 и рис. 9.6. приведен пример первого этапа (процесс создания), в котором используется реализация очереди с приоритетами на базе пирамидального дерева; на рис. 9.7 и рис. 9.8 показан второй этап (который будем называть процессом нисходящей сортировки — sortdown). Для практических целей этот метод выглядит не очень элегантно, т.к. он без особой необходимости копирует сортируемые элементы (в очередь с приоритетами). Да и выполнение N последовательных вставок — не самый эффективный способ построения пирамидального дерева, состоящего из N элементов.

Сейчас мы рассмотрим эти два положения и выведем классический алгоритм пирамидальной сортировки.

Программа 9.6. Сортировка с помощью очереди с приоритетами

Чтобы упорядочить подмассив a[1], ..., a[r] с помощью АТД очереди с приоритетами, следует все элементы поместить в очередь при помощи функции insert, а затем функцией getmax извлечь их в порядке убывания. Этот алгоритм сортировки выполняется за время, пропорциональное N lgN, но требует дополнительного объема памяти, пропорционального количеству сортируемых элементов N (для очереди с приоритетами).

  #include "PQ.cxx"
  template <class Item>
  void PQsort(Item a[], int l, int r)
    { int k;
      PQ<Item> pq(r-l+1);
      for (k = l; k <= r; k++) pq.insert(a[k]);
      for (k = r; k >= l; k--) a[k] = pq.getmax();
    }
        

Упражнения

9.21. Приведите пирамидальное дерево, которое получается после вставки ключей E A S Y Q U E S T I O N в первоначально пустое дерево.

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

P r I о * R * * I * T * Y * * * Q U E * * * U * E

в первоначально пустом дереве.

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

9.24. Почему в функции fixDown не используется сигнальный ключ, чтобы не выполнять проверку j < N?

9.25. Добавьте в реализацию очереди с приоритетами на базе пирамидального дерева в программе 9.5 операцию заменить наибольший. Специально рассмотрите случай, когда добавляемое значение больше всех остальных значений в очереди. Совет: элегантное решение можно получить, задействовав элемент pq[0].

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

9.27. Какое минимальное количество ключей требуется переместить при выполнении в пирамидальном дереве трех последовательных операций извлечь наибольший? Приведите пример пирамидального дерева из 15 элементов, для которого достигается этот минимум.

 Сортировка элементов из очереди с приоритетами


Рис. 9.7.  Сортировка элементов из очереди с приоритетами

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

 Сортировка элементов из очереди с приоритетами (продолжение)


Рис. 9.8.  Сортировка элементов из очереди с приоритетами (продолжение)

Эта последовательность демонстрирует извлечение остальных ключей из пирамидального дерева на рис. 9.7. Даже если каждый элемент пройдет весь путь до самого нижнего уровня, общая стоимость этапа сортировки меньше, чем lgN + . . . + lg 2 + lg1, что меньше, чем N logN.

Пирамидальная сортировка

Идею, лежащую в основе программы 9.6, можно модифицировать так, чтобы сортировка массива не требовала дополнительной памяти — организовав пирамидальное дерево внутри сортируемого массива. Другими словами, перейдя к задаче сортировки, мы не будем скрывать очередь с приоритетами, и вместо того чтобы держаться в рамках интерфейса АТД очереди с приоритетами, будем непосредственно вызывать функции fixUp и fixDown.

 Восходящее построение пирамидального дерева


Рис. 9.9.  Восходящее построение пирамидального дерева

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

Использование программы 9.5 непосредственно в программе 9.6 соответствует проходу по массиву слева направо, с использованием функции fixUp для организации элементов, располагающихся слева от индекса просмотра, в виде пирамидально упорядоченного полного дерева. Затем, во время выполнения нисходящей сортировки, мы помещаем наибольший элемент на место, которое освобождается при сжатии пирамидального дерева. То есть процесс нисходящей сортировки подобен сортировке выбором, однако в нем используется более эффективный способ определения наибольшего элемента в несортированной части массива. Вместо построения пирамидального дерева последовательными вставками, как показано на рис. 9.5 и рис. 9.6, эффективнее создать его с помощью прохода в обратном направлении, формируя меньшие поддеревья снизу верх, как показано на рис. 9.9. При этом каждая позиция массива рассматривается как корень небольшого поддерева, и используется тот факт, что функция fixDown работает на таких поддеревьях так же хорошо, как и на большом дереве. Если оба потомка узла являются пирамидальными деревьями, то после вызова fixDown поддерево с корнем в этом узле также становится пирамидальным деревом. Проходя по дереву в обратном направлении и вызывая fixDown в каждом узле, мы по индукции устанавливаем пирамидальный порядок. Обратный проход вдоль массива лучше начать с полпути, поскольку поддеревья, состоящие из одного узла, можно пропустить.

Полная реализация классического алгоритма пирамидальной сортировки (heapsort) приведена в программе 9.7. Циклы в этой программе выполняют, на первый взгляд, совершенно разные задачи (первый строит пирамидальное дерево, а второй разрушает его в процессе нисходящей сортировки). Однако оба они основаны на одной и той же фундаментальной процедуре, которая восстанавливает дерево, в котором уже установлен пирамидальный порядок, за исключением, возможно, корня, и использует при этом представление полного дерева в виде массива. На рис. 9.10 показано содержимое массива для примера, соответствующего рис. 9.9рис. 9.9.

Программа 9.7. Пирамидальная сортировка

Непосредственное применение функции fixDown позволяет построить классический алгоритм пирамидальной сортировки. Цикл for выполняет построение пирамидального дерева; затем цикл while меняет местами наибольший элемент с последним элементом массива и восстанавливает пирамидальную упорядоченность, продолжая этот процесс до полного исчерпания дерева. Указатель pq с адресом a[l-1] позволяет программе рассматривать переданный ей подфайл как массив, содержащий полное дерево, с первым элементом в позиции 1 (см. рис. 9.2). В некоторых средах программирования это невозможно.

  template <class Item>
  void heapsort(Item a[], int l, int r)
    { int k, N = r-1 + 1;
      Item *pq = a+l-1;
      for (k = N/2; k >= 1; k-- )
        fixDown (pq, k, N) ;
      while (N > 1)
        { exch(pq[1], pq[N]);
        fixDown (pq, 1, —N); }
    }
        

Лемма 9.4. Для восходящего построения пирамидального дерева требуется линейное время.

Это утверждение основано на том факте, что большинство обрабатываемых пирамидальных деревьев имеет небольшие размеры. Например, для создания пирамидального дерева из 127 элементов нужно построить 32 пирамидальных дерева размером 3, 16 деревьев размером 7, 8 деревьев размером 15, 4 деревьев размером 31, два пирамидальных дерева размером 63 и одно дерево размером 127. Поэтому в худшем случае требуется повышений (и в два раза больше сравнений). Для N = 2N — 1 верхняя граница количества повышений равна

Аналогичное доказательство можно выполнить и для случая, когда N + 1 не является степенью 2.

Эта лемма не имеет особого значения для пирамидальной сортировки, поскольку основная доля времени ее выполнения приходится на Nlog N — время, затрачиваемое на выполнение нисходящей сортировки. Однако она играет важную роль для других применений очередей с приоритетами, в которых линейная по времени операция создать приводит к алгоритму с линейным временем выполнения. Как сказано в подписи к рис. 9.6, построение пирамидального дерева с помощью N последовательных операций вставить требует в совокупности N log N шагов в худшем случае (хотя для случайно упорядоченных файлов это количество оказывается в среднем линейным).

 Пример выполнения пирамидальной сортировки


Рис. 9.10.  Пример выполнения пирамидальной сортировки

Пирамидальная сортировка представляет собой эффективный алгоритм, основанный на выборе элементов. Сначала непосредственно в сортируемом массиве восходящим способом создается пирамидальное дерево. Верхние 8 строк на этом рисунке соответствуют рис. 9.9. Затем из построенного дерева многократно извлекается наибольший элемент. Незаштрихованные части нижних строк соответствуют рис. 9.7 и рис. 9.8, а заштрихованные части содержат растущий упорядоченный файл.

Лемма 9.5. Пирамидальная сортировка использует менее 2N lgN сравнений для сортировки N элементов.

Чуть более высокая граница 3 N lg N непосредственно следует из леммы 9.2. Приведенная здесь граница следует из более точного расчета на основе леммы 9.4.

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

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

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

Один способ заключается в построении пирамидального дерева с помощью менее чем 2N сравнений (по лемме 9.4) с последующим извлечением к наибольших элементов, используя 2 к lg N или меньше сравнений (см. лемму 9.2) — итого 2 N + 2 к lg N сравнений. Другой способ связан с построением пирамидального дерева для наименьших элементов размером к и выполнением для остальных элементов операции заменить наименьший (replace the minimum) (операции вставить и извлечь наименьший), что дает в общем не более чем 2 к + 2 (N— к) lg к сравнений (см. упражнение 9.35). Этот метод использует объем дополнительной памяти, пропорциональный к, и особенно удобен для нахождения к наибольших из N элементов при малых к и больших (или не известных заранее) N. Если к мало по сравнению с N, то для случайно упорядоченных ключей и в других типичных ситуациях верхняя граница lg к для операций на пирамидальном дереве по второму методу обычно равна О(1) (см. упражнение 9.36).

Исследованы различные способы дальнейшего улучшения пирамидальной сортировки. Одна из идей, предложенная Флойдом (Floyd), состоит в следующем: элемент, повторно вставляемый в процессе нисходящей сортировки, обычно проходит весь путь до нижнего уровня — поэтому можно сэкономить время, устранив проверку достижения этим элементом своей позиции, просто обменивая его с большим из двух потомков до достижения нижнего уровня, а затем возвращаясь вверх по дереву до нужной позиции. Благодаря этой идее количество сравнений сокращается асимптотически в 2 раза и приближается к , что является абсолютным минимумом для любого алгоритма сортировки (см. часть 8). Этот метод требует дополнительных вычислений и может быть практически полезным только при сравнительно больших затратах на операции сравнения (например, при сортировке записей со строковыми ключами или с другими видами длинных ключей).

Другая идея заключается в построении пирамидальных деревьев, основанных на представлении в виде массивов полных пирамидально упорядоченных тернарных деревьев, в которых узел в позиции к больше или равен узлам в позициях 3к — 1, 3к и 3к + 1 и меньше или равен узлу в позиции для всех позиций от 1 до N в массиве из N элементов. Снижение затрат за счет меньшей высоты дерева уравновешивается более высокой стоимостью выбора наибольшего из трех потомков для каждого узла. Выигрыш в данном случае зависит от деталей реализации (см. упражнение 9.30). Дальнейшее увеличение количества дочерних узлов для каждого узла не дает никаких улучшений.

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

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

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

 Динамические характеристики пирамидальной сортировки


Рис. 9.11.  Динамические характеристики пирамидальной сортировки

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

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

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


Рис. 9.12.  Динамические характеристики пирамидальной сортировки для различных видов файлов

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

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

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

Таблица 9.2. Эмпирическое сравнение алгоритмов пирамидальной сортировки
NQ32-битовые целые ключиCтроковые ключи
MPQHFQHF
12500254348118
25000711988162520
500001324221819366049
100000275247424688143116
20000058111 106 100107
400000122238245232246
800000261520643542566
Обозначения:
Q Быстрая сортировка, стандартная реализация (программа 7.1)
M Сортировка слиянием, стандартная реализация (программа 8.1)
PQ Пирамидальная сортировка на базе очереди с приоритетами (программа 9.5)
H Пирамидальная сортировка, стандартная реализация (программа 9.6)
F Пирамидальная сортировка с усовершенствованием Флойда

Упражнения

9.28. Покажите, что пирамидальная сортировка не является устойчивой.

9.29. Определите эмпирическим путем процент времени, затрачиваемого пирамидальной сортировкой на этап построения для N = 103, 104, 105 и 106.

9.30. Реализуйте версию пирамидальной сортировки, основанную на полных пирамидально упорядоченных тернарных деревьях, как описано в тексте. Сравните количество использованных операций сравнения, полученное эмпирическим путем, с аналогичным показателем стандартной реализации для N = 103, 104, 105 и 106.

9.31. Продолжая упражнение 9.30, определите эмпирическим путем, будет ли метод Флойда эффективен для тернарных деревьев.

9.32. Учитывая только стоимость сравнений и считая, что для определения наибольшего из t элементов требуется t операций сравнения, найдите значение t, которое сводит к минимуму коэффициент при Nlog N, присутствующий при подсчете операций сравнения, если в пирамидальной сортировке используется t-арное пирамидальное дерево. Сначала воспользуйтесь прямым обобщением программы 9.7, а затем учтите, что метод Флойда позволяет сэкономить одно сравнение во внутреннем цикле.

9.33. Для N = 32 найдите последовательность ключей, требующую выполнения пирамидальной сортировкой максимального количества сравнений.

9.34. Для N = 32 найдите последовательность ключей, требующую выполнения пирамидальной сортировкой минимального количества сравнений.

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

9.36. Реализуйте обе версии выборки на базе пирамидальной сортировки, о которых шла речь при обсуждении леммы 9.6, воспользовавшись методом, описанным в упражнении 9.25. Определите эмпирическим путем количество используемых ими сравнений и сравните его с аналогичным показателем метода на базе быстрой сортировки из лекция №7, для N = 106 и при к = 10, 100, 1000, 104, 105 и 106.

9.37. Реализуйте версию пирамидальной сортировки, основанную на идее представления пирамидально упорядоченного дерева в прямом порядке, а не по уровням. Проведите эмпирическое сравнение количества операций сравнения, используемых этой версией, с количеством сравнений в стандартной реализации для случайно упорядоченных ключей при N = 103, 104, 105 и 106.

АТД очереди с приоритетами

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

Если из очереди с приоритетами нужно извлечь элемент, то как указать, который элемент надо извлечь? Если нужно использовать несколько очередей с приоритетами, то как следует организовать реализации, чтобы иметь возможность манипулировать очередями с приоритетами подобно другим типам данных? Подобные вопросы были рассмотрены в лекция №4. Программа 9.8 представляет обобщенный интерфейс для очередей с приоритетами в том виде, который был введен в разделе 4.8 лекция №4.

Программа 9.8. Полный АТД очереди с приоритетами

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

  template <class Item>
  class PQ
    { private:
      // Программный код, зависящий от реализации
      public:
        // Определение дескриптора, зависящее от реализации
      PQ(int);
      int empty() const;
      handle insert(Item);
      Item getmax();
      void change(handle, Item);
      void remove(handle);
      void join(PQ<Item>&);
    };
        

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

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

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

Программа 9.9. Неупорядоченная очередь с приоритетамив виде двухсвязного списка

Данная реализация содержит операции создать, проверить на наличие и вставить, взятые из интерфейса программы 9.8 (см. программу 9.10, в которой содержатся реализации четырех других функций). В ней используется простой неупорядоченный список с головным и хвостовым узлами. Структура node (узел) описана как узел двухсвязного списка (элемент и две ссылки). Приватными полями данных являются лишь ссылки головного и хвостового узлов.

  Template <class Item>
  class PQ
    { private:
        struct node
{ Item item; node *prev, *next;
  node(Item v)
    { item = v; prev = 0; next = 0; }
};
        typedef node* link;
        link head, tail;
      public:
        typedef node* handle;
        PQ(int = 0)
{ head = new node(0); tail = new node(0);
  head->prev = tail; head->next = tail;
  tail->prev = head; tail->next = head;
}
        int empty() const
{ return head->next->next == head; }
        handle insert(Item v)
{ handle t = new node(v);
  t->next = head->next; t->next->prev = t;
  t->prev = head; head->next = t;
  return t;
}
        Item getmax();
        void change(handle, Item);
        void remove(handle);
        void join(PQ<Item>&);
     };
        

Как упоминалось в разделе 9.1, реализация, приведенная в программах 9.9 и 9.10, пригодна для приложений с небольшой очередью с приоритетами и нечастым выполнением операций извлечь наибольший или найти наибольший; в остальных случаях лучше использовать реализации на базе пирамидальной сортировки. Реализация процедур fixUp и fixDown для пирамидально упорядоченных деревьев с явными ссылками и сохранением согласованности дескрипторов — достаточно сложная задача, которая вынесена в упражнения, поскольку в разделах 9.6 и 9.7 мы подробно рассмотрим два альтернативных подхода.

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

Программа 9.10. Очередь с приоритетами в виде двухсвязного списка (продолжение)

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

При необходимости сюда можно добавить деструктор, конструктор копирования и перегруженный оператор присваивания, чтобы еще больше усовершенствовать это приложение и получить АТД первого класса (см. раздел 4.8 лекция №4). Обратите внимание, что реализация функции join (объединить) выбирает узлы списков из параметра, который должен быть включен в результат, но при этом не копирует их.

  Item getmax( )
    { Item max; link x = head->next;
      for (link t = x; t->next != head; t = t->next)
        if (x->item < t->item) x = t;
      max = x->item;
      remove(x);
      return max;
    }
  void change (handle x, Item v)
    { x->key = v; }
  void remove(handle x)
    { x->next->prev = x->prev;
      x->prev->next = x->next;
      delete x;
    }
  void join(PQ<Item>& p)
    { tail->prev->next = p.head->next;
      p.head->next->prev = tail->prev;
      head->prev = p.tail;
      p.tail->next = head;
      delete tail; delete p.head;
      tail = p.tail;
    }
        

Могут быть удобными и небольшие изменения интерфейсов. Например, может потребоваться функция, возвращающая значение ключа с наибольшим приоритетом в очереди, а не способ обращения к этому ключу и связанной с ним информации. Кроме того, выплывают вопросы управления памятью и семантики копирования, которые были рассмотрены в разделе 4.8 лекция №4. Мы не рассматриваем операции уничтожить или копировать и выбрали лишь один из нескольких возможных вариантов операции объединить (см. упражнения 9.43 и 9.44).

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

Упражнения

9.38. Какая реализация очереди с приоритетами лучше подходит для того, чтобы найти 100 наименьших чисел в наборе из 106 случайных чисел? Обоснуйте свой ответ.

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

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

9.41. Напишите реализации операций вставить, извлечь наибольший, изменить приоритет и извлечь (интерфейс очереди с приоритетами из программы 9.8), воспользовавшись пирамидально упорядоченными деревьями с явными ссылками. Примечание: Поскольку клиент может запоминать дескрипторы элементов этой структуры данных, это упражнение сложнее упражнения 9.40 — не только потому, что узлы должны быть трехсвязными, но также и потому, что программы могут изменять в узлах только ссылки (но не ключи).

9.42. Добавьте (примитивную) реализацию операции объединить в реализацию из упражнения 9.41.

9.43. Добавьте в программу 9.8 объявления деструктора, конструктора копирования и перегруженной операции присваивания, чтобы превратить ее в АТД первого класса, включите соответствующие реализации в программы 9.9 и 9.10 и напишите программу-драйвер, которая протестирует полученные интерфейс и реализацию.

9.44. Измените интерфейс и реализацию операции объединить в программах 9.9 и 9.10, чтобы она возвращала PQ (результат объединения аргументов) и уничтожала свои аргументы.

9.45. Напишите интерфейс очереди с приоритетами и реализацию, которая поддерживает операции создать и извлечь наибольший, используя для этого турнир (см. раздел 5.7 лекция №5). В качестве основы для операции создать используйте программу 5.19.

9.46. Преобразуйте решение упражнения 9.45 в АТД первого класса.

9.47. Добавьте в решение упражнения 9.45 операцию вставить.

Очереди с приоритетами для индексных элементов

Предположим, что записи, обрабатываемые в очереди с приоритетами, находятся в некотором массиве. В этом случае программам обработки очередей с приоритетами имеет смысл обращаться к элементам по индексам массива. Более того, использование индексов массива в качестве дескрипторов позволяет реализовать все операции, выполняемые над очередями с приоритетами. Интерфейс, соответствующий этому описанию, приведен в программе 9.11. На рис. 9.13 показан небольшой пример применения такого подхода. Можно получить очередь с приоритетами, содержащую некоторое подмножество записей, и обойтись без копирования и специальных изменений в записях. Использование индексов существующего массива — вполне естественное решение, однако оно ведет к реализациям с ориентацией, противоположной программе 9.8.

Программа 9.11. Интерфейс АТД очереди с приоритетами для индексных элементов

Вместо построения различных структур данных из самих элементов данных, этот интерфейс позволяет построить очередь с приоритетами, используя индексы элементов клиентского массива. Подпрограммы, реализующие операции вставить, извлечь наибольший, изменить приоритет и извлечь, используют дескрипторы в виде индексов массива, а клиентская программа перегружает операцию < так, чтобы стало возможным сравнение двух элементов массива. Например, клиентская программа может определить операцию < таким образом, что значением неравенства i < j становится результат сравнения data[i].grade и data[j].grade.

  template <class Item>
  class PQ
    { private:
        // Программный код, зависящий
        // от реализации
      public:
        PQ(int);
        int empty() const;
        void insert(Index);
        Index getmax();
        void change(Index);
        void remove(Index);
    };
        

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


Рис. 9.13.  Структуры данных индексного пирамидального дерева

Работа не с записями, а с индексами этих записей позволяет создать очередь с приоритетами из подмножества записей, хранящихся в массиве. В этом примере пирамидальное дерево размера 5 в массиве pq содержит индексы студентов с пятью наивысшими оценками. То есть data[pq[1]].name содержит фамилию студента с наивысшей оценкой — Smith и т.д. Обратный массив qp позволяет подпрограммам обработки очередей с приоритетами работать с индексами массива как с дескрипторами. Например, если понадобится изменить оценку Смита на 85, нужно будет изменить значение data[3].grade, а затем вызвать PQchange(3). Реализация очереди с приоритетами обращается к записи как pq[qp[3]] (то есть pq[1], поскольку qp[3]=1,), а к новому ключу как data[pq[1]].name (то есть data[3].name, поскольку pq[1]=3).

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

При разработке реализации применяется в точности такой же подход, что и для индексной сортировки в разделе 6.8 лекция №6. Мы работаем с индексами и перегружаем операцию < таким образом, что сравнения обращаются к элементам клиентского массива. При этом возникают некоторые трудности, поскольку программе обработки очереди с приоритетами приходится отслеживать объекты, чтобы она могла отыскать их, если клиентская программа обращается к ним по дескриптору (индексу массива). C этой целью добавляется второй индексный массив, с помощью которого отслеживается положение ключей в очереди с приоритетами. Для локализации использования этого массива необходимо дать соответствующее определение операции exch и перемещать данные только с ее помощью.

Программа 9.12 является полной реализацией этого подхода на базе пирамидальных деревьев. Она лишь незначительно отличается от программы 9.5, но достойна специального изучения, поскольку очень полезна в практических ситуациях. Будем называть структуру данных, построенную этой программой, индексным пирамидальным деревом (index heap). Данная программа послужит строительным блоком для других алгоритмов в частях V—VII. Как обычно, здесь не включен код проверок на ошибки, и мы предполагаем (например), что индексы никогда не выходят за пределы их диапазона, а пользователь не делает попыток вставить что-либо в полную очередь или удалить что-либо из пустой очереди. Добавление подобного кода не вызовет особых затруднений.

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

Возможны другие подходы к построению очереди с приоритетами из готовых данных, хранящихся в массиве, когда клиентская программа создает записи, состоящие из ключа и индекса массива в поле информации, или использует индексный ключ с операцией <, перегруженной в клиентской программе. Тогда если реализация использует представление в виде связного списка, как в программах 9.9 и 9.10 или в упражнении 9.41, то объем памяти, используемой очередью с приоритетами, будет пропорционален максимальному количеству элементов в очереди за все время. Такие методы могут быть предпочтительнее программы 9.12, если нужно экономить память и если очередь с приоритетами должна содержать лишь небольшую часть массива данных.

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

Программа 9.12. Очередь с приоритетами на базе индексного пирамидального дерева

Эта реализация программы 9.11 использует pq в качестве массива индексов для клиентского массива. Например, если в клиентской программе операция < определена для аргументов типа Index, как указано в комментарии перед программой 9.11, то при выполнении сравнения pq[j] с pq[k] в подпрограмме fixUp на самом деле, как и задумано, сравниваются data.grade[pq[j]] и data.grade[pq[j]]. Здесь предполагается, что Index — это класс-оболочка, объект которого может индексировать массивы, так что позицию в пирамидальном дереве, соответствующую значению индекса k, можно хранить в qp[k]. А это позволяет реализовать операции изменить приоритет и извлечь (см. упражнение 9.49 и рис. 9.14). Для всех k пирамидального дерева справедлив инвариант pq[qp[k]] = qp [pq [k] ] = k (см. рис. 9.13).

template <class Item>
class PQ
  { private:
      int N; Index* pq; int* qp;
      void exch(Index i, Index j)
        { int t;
t = qp[i]; qp[i] = qp[j]; qp[j] = t;
pq[qp[i]] = i; pq[qp[j]] = j;
        }
      void fixUp(Index a[], int k);
      void fixDown(Index a[], int k, int N);
    public:
        PQ(int maxN)
        { pq = new Index[maxN+1];
        qp = new int[maxN+1]; N = 0; }
    int empty() const
        { return N == 0; }
    void insert(Index v)
        { pq[++N] = v; qp[v] = N; fixUp(pq, N); }
    Index getmax()
        { exch(pq[1], pq[N]);
fixDown(pq, 1, N-1);
        return pq[N--]; }
    void change(Index k)
        { fixUp(pq, qp[k]); fixDown(pq, qp[k], N); }
  };
        

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

Упражнения

9.48. Предположим, что массив заполнен ключами E A S Y Q U E S T I O N. Приведите содержимое массивов pq и qp после занесения этих ключей программой 9.12 в первоначально пустое пирамидальное дерево.

9.50. Реализуйте АТД очереди с приоритетами для индексных элементов (см программу 9.11), используя представление очереди с приоритетами в виде упорядоченного массива.

9.51. Реализуйте АТД очереди с приоритетами для индексных элементов (см. программу 9.11), используя представление очереди с приоритетами в виде неупорядоченного массива.

9.52. Пусть имеется массив a из N элементов. Рассмотрим полное бинарное дерево из 2N элементов (представленное в виде массива pq), содержащее индексы из этого массива и обладающее следующими свойствами: (1) для i от 0 до N-1 справедливо равенство pq[N+i]=i и (2) для i от 1 до N-1 справедливо , если , и в противном случае. Такая структура называется турниром индексного пирамидального дерева (index heap tournament), поскольку сочетает свойства индексных пирамидальных деревьев и турниров (см. программу 5.19). Приведите турнир индексного пирамидального дерева, соответствующий ключам E A S Y Q U E S T I O N.

9.53. Реализуйте АТД очереди с приоритетами для индексных элементов (см. программу 9.11), воспользовавшись турниром индексного пирамидального дерева (см. упражнение 9.52).

 Изменение приоритета узла в пирамидальном дереве


Рис. 9.14.  Изменение приоритета узла в пирамидальном дереве

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

Биномиальные очереди

Ни одна из рассмотренных выше реализаций не может обеспечить эффективное выполнение в худшем случае сразу всех операций объединить, извлечь наибольший и вставить. Неупорядоченные связные списки быстро выполняют операции объединить и вставить, но медленно — извлечь наибольший; упорядоченные связные списки быстро выполняют операцию извлечь наибольший, но медленно — объединить и вставить; в пирамидальном дереве быстро выполняются вставить и извлечь наибольший, но медленно — операция объединить и т.д. (см. таблицу 9.1). Для приложений, в которых характерны частые или объемные операции объединить, нужны более совершенные структуры данных.

Здесь под термином " эффективная " понимается, что операции должны выполняться в худшем случае за не более чем логарифмическое время. Это ограничение сразу исключает представление в виде массива, поскольку очевидно, что два массива можно объединить только путем пересылки всех элементов по крайней мере одного из массивов. Представление в виде неупорядоченного двухсвязного списка из программы 9.9 выполняет операцию объединить за постоянное время, но требует просмотра всего списка при выполнении операции извлечь наибольший. Использование двухсвязных упорядоченных списков (см. упражнение 9.39) позволяет выполнять за постоянное время операцию извлечь наибольший, однако требует линейного времени для выполнения слияния списков операцией объединить.

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

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

Даже для связных представлений деревьев требование его пирамидальной упорядоченности и требование полноты пирамидально упорядоченного бинарного дерева являются чрезмерно жесткими для получения эффективной реализации операции объединить. Если имеются два пирамидально упорядоченных дерева, то как слить их в одно? Например, если одно из этих деревьев содержит 1023 узла, а другое — 255 узлов, то как слить их, чтобы получить дерево из 1278 узлов и не затронуть более 10—20 узлов? Похоже, что задача слияния пирамидально упорядоченных деревьев вообще неразрешима, если эти деревья должны быть пирамидально упорядоченными и полными. Поэтому были предложены различные более совершенные структуры данных, способные ослабить требования пирамидальной упорядоченности и сбалансированности и придать структурам данных большую гибкость, необходимую для эффективной реализации операции объединить. Сейчас мы рассмотрим оригинальное решение этой проблемы, получившее название биномиальной очереди (binomial queue) и предложенное Вильемином (Vuillemin) в 1978 г.

Вначале следует отметить, что операция объединить тривиальна для одного специального типа дерева с ослабленным требованием пирамидальной упорядоченности.

Определение 9.4. Бинарное дерево, состоящее из узлов с ключами, называется левосторонне пирамидально упорядоченным (left-heap-ordered), если ключ каждого узла больше или равен всем ключам левого поддерева этого ключа (если оно есть).

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

Биномиальные деревья и пирамидальные деревья степени 2 — это одно и то же. Мы будем работать с обоими представлениями, поскольку биномиальные деревья легче отобразить визуально, а простое представление деревьев степени 2 приводит к более простой реализации. Особенно важны следующие факторы, непосредственно следующие из определений:

Биномиальные алгоритмы обработки биномиальных очередей основаны на тривиальной операции объединения двух пирамидальных деревьев степени 2 с одинаковым количеством узлов. В результате объединения получается пирамидальное дерево с вдвое большим количеством узлов, которое совсем нетрудно построить (см. рис. 9.15). Корневой узел с большим ключом становится корнем результирующего дерева, корень другого дерева при этом становится левым потомком корня результирующего дерева, а его левое поддерево становится правым поддеревом другого корневого узла. При связном представлении деревьев операция объединения выполняется за постоянное время: для этого нужно лишь настроить две ссылки вверху дерева. Реализация этой операции приведена в программе 9.13. Эта базовая операция является основой общего решения задачи реализации очереди с приоритетами без медленных операций, которое было предложено Вильемином.

Программа 9.13. Объединение двух пирамидальных деревьев степени 2 одинакового размера

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

  static link pair(link p, link q)
    { if (p->item < q->item)
        { p->r = q->l; q->l = p; return q; }
      else
        { q->r = p->l; p->l = q; return p; }
    }
        

Определение 9.6. Биномиальная очередь — это множество пирамидальных деревьев степени 2 с попарно различными размерами. Структура биномиальной очереди определяется числом узлов этой очереди в соответствии с двоичным представлением целых чисел.

Биномиальная очередь из N элементов содержит по одному пирамидальному дереву на каждый бит двоичного представления числа N. Например, биномиальная очередь из 13 узлов содержит одно пирамидальное дерево с 8 узлами, одно с 4 узлами и одно с 1 узлом, как показано на рис. 9.16. Биномиальная очередь размера N содержит максимум lg N пирамидальных деревьев степени 2, и высота каждого не больше lg N.

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

  struct node
    { Item item; node *l, *r;
      node(Item v)
        { item = v; l = 0; r = 0; }
    };
    typedef node *link;
    link* bq;
        

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

 Объединение двух пирамидальных деревьев степени 2 одинакового размера


Рис. 9.15.  Объединение двух пирамидальных деревьев степени 2 одинакового размера

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

 Биномиальная очередь размера 13


Рис. 9.16.  Биномиальная очередь размера 13

Биномиальная очередь размера N — это список левосторонне пирамидально упорядоченных деревьев степени 2, по одному на каждый бит двоичного представления числа N. Таким образом, биномиальная очередь размера 13 = 11012 состоит из одного 8-узлового пирамидального дерева, одного 4-узлового и одного 1-узлового деревьев. На рисунке показано представление одной и той же биномиальной очереди в виде левосторонне пирамидально упорядоченного дерева степени 2 (сверху) и в виде биномиального пирамидально упорядоченного дерева (внизу).

Сначала рассмотрим операцию вставить. Процесс вставки нового элемента в биномиальную очередь в точности отображает процесс увеличения двоичного числа на единицу. Чтобы увеличить двоичное число на единицу, мы двигаемся справа налево, выполняя перенос заменой 1 на 0, т.к. 1 + 1 = 10<sub>2</sub>, пока не обнаружим самый правый 0, который заменяем единицей. Аналогичным образом, чтобы добавить в биномиальную очередь новый элемент, мы продвигаемся справа налево, сливая деревья, соответствующие битам 1, с деревом переноса, пока не дойдем до самой правой пустой позиции, в которую и помещаем дерево переноса.

В частности, для вставки нового элемента в биномиальную очередь мы превращаем этот элемент в пирамидальное 1-дерево. Далее, если N четно (самый правый разряд равен 0), мы просто помещаем это 1-дерево в самую правую пустую позицию биномиальной очереди. Если N нечетно (самый правый разряд равен 1), мы объединяем 1-дерево, соответствующее новому элементу, с 1-деревом в самой крайней правой позиции биномиальной очереди и получаем 2-дерево переноса. Если позиция, соответствующая 2 в биномиальной очереди, пуста, то дерево переноса помещается в эту позицию, иначе 2-дерево переноса сливается с 2-деревом из биномиальной очереди, образуя при этом 4-дерево переноса — и так до тех пор, пока в биномиальной очереди не встретится пустая позиция. Этот процесс показан на рис. 9.17, а его реализация приведена в программе 9.14.

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


Рис. 9.17.  Вставка нового элемента в биномиальную очередь

Добавление элемента в биномиальную очередь из семи узлов аналогично выполнению арифметической операции двоичного сложения 1112 + 1 = 10002 с переносом в каждом разряде. В результате получается биномиальная очередь, показанная внизу, которая состоит из 8-дерева, а 4-, 2- и 1-деревья отсутствуют.

Программа 9.14. Вставка в биномиальную очередь

Для вставки узла в биномиальную очередь его надо сначала преобразовать в 1-дерево и считать его 1-деревом переноса, а затем повторять в цикле следующий процесс, начиная с i = 0. Если в биномиальной очереди нет 2i-дерева, 2i переноса просто помещается в эту очередь. Если в биномиальной очереди такое дерево есть, оно объединяется с новым деревом (с помощью функции pair из программы 9.13), и получается 2i+1 , после чего значение i увеличивается на 1, и процесс продолжается до обнаружения в биномиальной очереди пустой позиции.

  handle insert(Item v)
    { link t = new node(v), c = t;
      for (int i = 0; i < maxBQsize; i++)
        { if (c == 0) break;
        if (bq[i] == 0) { bq[i] = c; break; }
      c = pair(c, bq[i]); bq[i] = 0;
    }
    return t;
    }
        

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

Допустим, у нас есть (эффективный) способ выполнения операции объединить, предназначенный для слияния очереди с приоритетами, которая задана ссылкой во втором операнде функции, с очередью с приоритетами, которая задана ссылкой в первом операнде (результат помещается в первый операнд). С помощью этой функции операцию вставить можно реализовать, вызвав функцию объединить, в которой один из операндов представляет собой биномиальную очередь размера 1 (см. упражнение 9.63).

С помощью одного вызова операции объединить можно реализовать и операцию извлечь наибольший. Чтобы найти в биномиальной очереди максимальный элемент, нужно просмотреть деревья степени 2 в этой очереди. Каждое такое дерево является левосторонне пирамидально упорядоченным деревом, поэтому его максимальный элемент находится в корне. Больший из корневых элементов является наибольшим элементом биномиальной очереди. Поскольку в биномиальной очереди не может быть более lg N деревьев, общее время поиска наибольшего элемента меньше lg N.

Для выполнения операции извлечь наибольший отметим, что удаление корня из левосторонне упорядоченного 2k приводит к появлению к левосторонне упорядоченных деревьев степени 2 — 2k-1 -дерево, 2k-2 -дерево и т.д. — которые легко собрать в биномиальную очередь размера 2k — 1 , как показано на рис. 9.18. Затем, чтобы завершить операцию извлечь наибольший, можно воспользоваться операцией объединить для объединения этой биномиальной очереди с остатком исходной очереди. Эта реализация приведена в программе 9.15.

 Удаление наибольшего элемента из пирамидального дерева степени 2


Рис. 9.18.  Удаление наибольшего элемента из пирамидального дерева степени 2

Удаление корня приводит к появлению леса деревьев степени 2; все они левосторонне пирамидально упорядочены, а их корни берутся из правого ствола дерева. Эта операция позволяет выполнить удаление наибольшего элемента из биномиальной очереди: убираем корень дерева степени 2, которое содержит наибольший элемент, затем выполняем операцию объединить для слияния полученной биномиальной очереди с остальными деревьями степени 2 исходной биномиальной очереди.

Каким образом объединяются две биномиальных очереди? Прежде всего заметим, что эта операция тривиальна, если в этих очередях нет деревьев степени 2 одинакового размера (см. рис. 9.19): мы просто сливаем пирамидальные деревья из обеих биномиальных очередей и получаем одну биномиальную очередь. Очередь размера 10 (состоящая из 8-дерева и 2-дерева) и очередь размера 5 (состоящая из 4-дерева и 1-дерева) путем простого слияния образуют очередь размера 15 (состоящую из 8-дерева, 4-дерева, 2-дерева и 1-дерева). Способ, рассчитанный на более общие случаи, основан на прямой аналогии с выполнением сложения с переносом двух двоичных чисел (см. рис. 9.20).

Например, если очередь размера 7 (состоящую из 4-дерева, 2-дерева и 1-дерева) добавить к очереди размера 3 (состоящую из 2-дерева и 1-дерева), получится очередь размером 10 (состоящая из 8-дерева и 2-дерева). Для такого добавления потребуется слить 1-деревья и выполнить перенос 2-дерева, затем слить 2-деревья и выполнить перенос 4-дерева, затем слить 4-деревья и получить в результате 8-дерево — точно так же, как выполняется двоичное сложение 0112 + 1112 = 10102 . Пример, представленный на рис. 9.19, проще примера на рис. 9.20, поскольку он аналогичен операции сложения без переносов 10102 + 01012 = 11112 .

Эта прямая аналогия с двоичной арифметикой приводит к естественной реализации операции объединить (см. программу 9.16). Для каждого разряда возможны восемь случаев, в зависимости от значений каждого из 3 разрядов (перенос и два разряда в операндах).

Программа 9.15. Извлечение наибольшего элемента из биномиальной очереди

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

  Item getmax()
    { int i, max; Item v = 0;
      link* temp = new link[maxBQsize];
      for (i = 0, max = -1; i < maxBQsize; i++)
        if (bq[i] != 0)
if ((max == -1) || (v < bq[i]->item))
  { max = i; v = bq[max]->item; }
      link x = bq[max]->l;
      for (i = max; i < maxBQsize; i++) temp[i] = 0;
      for ( i = max; i > 0; i-- )
        { temp[i-1] = x; x = x->r; temp[i-1]->r = 0; }
      delete bq[max]; bq[max] = 0;
      BQjoin (bq, temp);
      delete temp;
      return v;
    }
        

 Объединение двух биномиальных очередей (без переноса)


Рис. 9.19.  Объединение двух биномиальных очередей (без переноса)

Если две объединяемые биномиальные очереди содержат только деревья степени 2 попарно различных размеров, то операция объединить сводится к простому слиянию. Выполнение этой операции аналогично сложению двух двоичных чисел, когда нет сложения битов 1 + 1 (т.е. без переносов). В рассматриваемом случае биномиальная очередь из 10 узлов сливается с очередью из 5 узлов, и в результате получается биномиальная очередь из 15 узлов, соответствующая операции 10102 + 01012 = 11112 .

 Объединение двух биномиальных очередей


Рис. 9.20.  Объединение двух биномиальных очередей

В результате добавления биномиальной очереди из 3 узлов к биномиальной очереди из 7 узлов получается очередь из 10 узлов — аналогично операции сложения 0112+1112=10102 в двоичной арифметике. Добавление N к E дает пустое 1-дерево и 2-дерево переноса, содержащее узлы N и E. Последующее сложение трех 2-деревьев оставляет одно из них в итоговой очереди, а в перенос попадает 4-дерево с узлами T N E I. Это 4-дерево добавляется к другому 4-дереву, образуя биномиальную очередь, которая показана в нижней части диаграммы. В описанном процессе принимает участие лишь небольшое количество узлов.

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

Программа 9.16. Объединение (слияние) двух биномиальных очередей

Данная программа имитирует операцию сложения двух двоичных чисел, продвигаясь справа налево с первоначально нулевым разрядом переноса. Здесь необходимо рассматривать восемь различных случаев (все возможные значения операндов и разряда переноса). Например, случай 3 соответствует тому, что биты обоих операндов равны 1, а бит переноса — 0. В этом случае результатом будет 0, а бит переноса становится равным 1 (в результате сложения разрядов операндов).

Как и функция pair, данная функция является приватной функцией-членом реализации, которая вызывается функциями getmax и join. Функция абстрактного типа данных join (PQ<Item>& p) реализована в виде вызова BQjoin(bq, p.bq).

  static inline int test(int C, int B, int A)
    { return 4*C + 2*B + 1*A; }
  static void BQjoin (link *a, link *b)
    { link c = 0;
      for (int i = 0; i < maxBQsize; i++)
        switch(test(c != 0; b[i] != 0, a[i] != 0))
        { case 2: a[i] = b[i]; break;
case 3: c = pair(a[i], b[i]); a[i] = 0; break;
case 4: a[i] = c, c = 0; break;
case 5: c = pair(c, a[i]); a[i] = 0; break;
case 6:
  case 7: c = pair(c, b[i]); break;
        }
    }
        

Лемма 9.7. Все операции для АТД очереди с приоритетами могут быть реализованы с помощью биномиальной очереди таким образом, что для выполнения любой из них в очереди из N элементов потребуется 0(lg N) шагов.

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

Для простоты наши реализации перебирают в цикле все деревья, поэтому время их выполнения пропорционально логарифму максимального размера биномиальной очереди. Можно обеспечить соответствие граничным значениям и в тех случаях, когда фактический размер очереди намного меньше ее максимального размера: для этого нужно отслеживать размер очереди или использовать сигнальное значение, по достижении которого циклы должны прекращаться (см. упражнения 9.61 и 9.62). Во многих ситуациях результат такие изменения не стоят затраченных на них усилий, поскольку максимальный размер очереди экспоненциально превосходит максимальное количество итераций циклов. Например, если максимальный размер очереди равен 216, а очередь обычно содержит лишь тысячи элементов, то более простые реализации будут повторять цикл 15 раз, а более сложные — 11—12 раз, хотя отслеживание размера очереди или сигнальное значение требует дополнительных ресурсов. Но если указать наобум большой максимальный размер, то маленькие очереди будут обрабатываться медленнее, чем в более экономном варианте.

Лемма 9.8. Построение биномиальной очереди с помощью N вставок в первоначально пустую очередь требует в худшем случае выполнения O(N) сравнений.

Для половины вставок (при четном размере очереди и отсутствии 1-деревьев) операции сравнения вообще не требуются; для половины оставшихся вставок (при отсутствии 2-деревьев) требуется лишь одна операция сравнения; если нет 4-деревьев, требуется только 2 операции сравнения и т.д. Следовательно, общее число сравнений меньше, чем 0 • N / 2 + 1 • N / 4 + 2 • N / 8 + ... < N. Что касается леммы 9.7, то для получения в худшем случае времени выполнения, не превышающего линейного, нужен один из тех вариантов, которые рассматриваются в упражнениях 9.61 и 9.62.

Как было сказано в разделе 4.8 лекция №4, в реализации операции объединить в программе 9.16 не рассматривались вопросы распределения памяти. Из-за этого там имеется утечка памяти, так что в некоторых ситуациях она становится непригодной. Для исправления этого дефекта потребуется внимательно рассмотреть вопрос выделения памяти под аргументы и возвращаемое значение функции, которая реализует операцию объединить (см. упражнение 9.65).

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

Упражнения

9.54. Нарисуйте биномиальную очередь размера 29, используя представление биномиальным деревом.

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

9.56. Приведите биномиальную очередь, которая получится, если вставить ключи E A S Y Q U E S T I O N в первоначально пустую биномиальную очередь.

9.57. Приведите биномиальную очередь, которая получится, если вставить в первоначально пустую биномиальную очередь ключи E A S Y, и биномиальную очередь, которая получится, если вставить в первоначально пустую очередь ключи Q U E S T I O N. Затем выполните в обеих очередях операцию извлечь наибольший и приведите результат. И, наконец, покажите, что получится после применения к полученным очередям операции объединить.

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

P r I o 1 R * * I * T * Y * * * Q U E * * * U * E

в первоначально пустой биномиальной очереди.

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

( ( ( P R I O * ) + ( R * I T * Y * ) ) * * * ) + ( Q U E * * * U * E )

в первоначально пустой биномиальной очереди.

9.60. Докажите, что биномиальное дерево с 2n узлами имеет узлов на i-м уровне, для . (Отсюда и пошло название биномиальное дерево).

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

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

9.63. Реализуйте операцию вставить для биномиальных очередей с помощью явного использования только операции объединить.

9.64. Реализуйте операции изменить приоритет и извлечь для биномиальных очередей. Совет: Потребуется добавить в узлы третью ссылку, указывающую наверх.

9.65. Добавьте в реализации биномиальной очереди (программы 9.13—9.16) деструктор, конструктор копирования и перегруженную операцию присваивания, приведенные в тексте книги, чтобы получить АТД первого класса из упражнения 9.43. Напишите программу-драйвер для тестирования полученных интерфейса и реализации.

9.66. Сравните эмпирически биномиальные очереди и пирамидальные деревья в качестве инструмента для сортировки, как в программе 9.6, случайно упорядоченных ключей при N = 1000, 104, 105 и 106 . Совет: см. упражнения 9.61 и 9.62.

Лекция 10. Поразрядная сортировка

Рассмотрены методы сортировки, основанные на обработке части ключей.

Во многих приложениях сортировки ключи, используемые для упорядочения записей в файлах, могут быть весьма сложными. Представьте, например, насколько сложны ключи, используемые в телефонной книге или в библиотечном каталоге. Чтобы отделить все эти сложности от наиболее важных свойств изучаемых методов сортировки, в главах 6—9 мы почти везде ограничивались использованием только базовых операций сравнения двух ключей и обмена двух записей (скрыв в этих функциях все детали работы с ключами) как абстрактным интерфейсом между методами сортировки и приложениями. В данной главе мы рассмотрим другую абстракцию для ключей сортировки. Например, часто нет необходимости в полной обработке ключей на каждом этапе: при поиске в телефонном справочнике, чтобы найти страницу с номером какого-либо абонента, достаточно проверить лишь несколько первых букв его фамилии. Чтобы добиться такой же эффективности алгоритмов сортировки, мы перейдем от абстрактной операции сравнения ключей к абстракции, в которой ключи разбиваются на последовательность частей фиксированного размера — байтов. Двоичные числа представляют собой последовательности битов, строки — последовательности символов, десятичные числа — последовательности цифр, аналогично могут рассматриваться и многие другие (хотя и не все) типы ключей. Методы сортировки, основанные на обработке ключей по частям, называются поразрядными методами сортировки (radix sort). Эти методы не просто сравнивают ключи, а обрабатывают и сравнивают части ключей.

Алгоритмы поразрядной сортировки интерпретируют ключи как числа, представленные в системе счисления с основанием R, при различных значениях R (основание системы счисления — radix), и работают с отдельными цифрами этих чисел. Например, когда почтово-сортировочная машина обрабатывает пачку конвертов, каждый из которых помечен 5-значным десятичным числом, она распределяет эту пачку на десять отдельных пачек: в одной находятся пакеты, номера которых начинаются с 0, в другой находятся пакеты с номерами, начинающимися с 1, в третьей — с 2 и т.д. Каждая пачка может быть обработана отдельно с помощью того же метода, по следующей цифре, или более простым способом, если в пачке всего лишь несколько пакетов. Если теперь собрать пакеты из пачек в порядке от 0 до 9 и в таком же порядке внутри каждой пачки, то они окажутся упорядоченными. Эта процедура представляет собой поразрядную сортировку с R = 10, и такой метод удобен в приложениях, использующих сортировку, где ключами являются десятичные числа, содержащие от 5 до 10 цифр — наподобие почтовых кодов, телефонных номеров или идентификационных номеров. Этот метод будет подробно рассмотрен в разделе 10.3.

Для различных приложений лучше подходят различные основания системы счисления R. В этой главе мы будем рассматривать в основном ключи, представленные в виде целых чисел или строк, для которых широко применяются методы поразрядной сортировки. Для целых чисел, представляемых в компьютерах в виде двоичных чисел, мы чаще будем работать с R = 2 или с одной из степеней 2, поскольку это позволяет разбивать ключи на независимые части. Для ключей, содержащих строки символов, мы используем R = 128 или R = 256, чтобы согласовать основание системы счисления с размером байта. Вообще-то, помимо таких прямых применений, в виде двоичных чисел можно рассматривать практически все, что записано в компьютере. Это позволяет переориентировать многие приложения сортировки, которые используют другие типы ключей, на использование поразрядной сортировки, которая упорядочивает ключи, представленные в виде двоичных чисел.

Алгоритмы поразрядной сортировки основаны на абстрактной операции " извлечь i-ю цифру ключа " . К счастью, в С++ есть низкоуровневые операции, позволяющие реализовать такие действия просто и эффективно. Это очень важно, поскольку некоторые языки программирования (например, Pascal), поощряя машинно-независимое программирование, намеренно затрудняют написание программ, зависящих от способа представления чисел в конкретной машине. В таких языках трудно реализовать многие виды битовых операций, удобные для выполнения на большинстве компьютеров. В частности, жертвой этой " прогрессивной " философии стала и поразрядная сортировка. Однако создатели С и С++ поняли, что прямые операции с битами часто бывают весьма полезны, и при реализации поразрядной сортировки мы сможем воспользоваться низкоуровневыми языковыми средствами.

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

 Поразрядная сортировка


Рис. 10.1.  Поразрядная сортировка

Хотя в данном списке находятся 11 чисел от 0 до 1 (слева), содержащих в совокупности 99 цифр, их можно упорядочить (в центре), проанализировав лишь 22 цифры (справа).

Существуют два принципиально различных базовых подхода к поразрядной сортировке. Первый класс методов составляют алгоритмы, анализирующие цифры в ключах в направлении слева направо, при этом первыми обрабатываются наиболее значащие цифры. Все эти методы вместе называются MSD-сортировками (most significant digit radix sort — поразрядная сортировка сначала по старшей цифре). MSD-сортировки привлекательны тем, что они анализируют минимальный объем информации, необходимый для выполнения сортировки (рис. 10.1). Методы MSD-сортировки являются обобщением быстрой сортировки, поскольку они разбивают сортируемый файл в соответствии со старшими цифрами ключей, а затем рекурсивно применяют тот же метод к полученным подфайлам. В самом деле, при основании системы счисления, равном 2, реализация MSD-сортировки похожа на быструю сортировку. Во втором классе методов поразрядной сортировки используется другой принцип: они анализируют цифры ключей в направлении справа налево, работая сначала с наименее значащими цифрами. Все эти методы вместе называются LSD-сортировками (least significant digit radix sort — поразрядная сортировка сначала по младшей цифре). LSD-сортировка в какой-то степени противоречит интуиции, поскольку часть процессорного времени затрачивается на обработку цифр, которые не могут повлиять на результат, однако данная проблема легко решается, и этот почтенный метод годится для работы во многих приложениях сортировки.

Биты, байты и слова

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

Определение 10.1. Байт — это последовательность битов фиксированной длины, строка — это последовательность байтов переменной длины, слово — это последовательность байтов фиксированной длины.

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

У типичной машины могут быть 8-разрядные байты и 32- и 64-разрядные слова (конкретные значения приведены в заголовочном файле <limits.h>), однако бывает удобно рассматривать и некоторые другие размеры байтов и слов (обычно небольшие кратные или части встроенных машинных размеров). Итак, в качестве числа разрядов в слове и числа разрядов в байте будут использоваться зависящие от архитектуры машины и свойств приложения константы, например:

  const int bitsword = 32;
  const int bitsbyte = 8;
  const int bytesword = bitsword/bitsbyte;
  const int R = 1 << bitsbyte;
      

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

В большинстве компьютеров имеются битовые операции И и сдвиг, которые позволяют извлекать из слов отдельные байты (и биты тоже — прим. перев.).

В C++ можно прямо написать операцию извлечения B-го байта из двоичного слова A следующим образом:

  inline int digit(long A, int B)
    { return (A >> bitsbyte*(bytesword-B-1) & (R-1); }
      

Эта макрокоманда может, например, извлечь байт 2 (третий байт) 32-разрядного числа, сдвинув его вправо на 32 — 3 • 8 = 8 позиций и наложив маску 00000000000000000000000011111111

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

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

  inline int digit(char* A, int B)
    { return A[B]; }
      

При использовании структуры-оболочки, наподобие рассмотренной в разделе 6.8 лекция №6, можно записать:

  inline int digit(Item& A, int B)
    { return A.str[B]; }
      

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

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

На машинах, предназначенных для высокопроизводительных численных расчетов, это вычисление может выполняться для произвольного значения R так же быстро, как и для R = 2.

Еще одна точка зрения — рассматривать ключи как числа в диапазоне от 0 до 1, с неявной десятичной точкой слева, как показано на рис. 10.1. В этом случае b-ой цифрой числа a будет .

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

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

Определение 10.2. Ключ — это число в системе счисления с основанием R, цифры которого пронумерованы слева направо (начиная с 0).

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

Мы предполагаем, что ключи достаточно длинные, так что операция извлечения из них битов имеет смысл. Если же ключи короткие, то можно воспользоваться методом распределяющего подсчета из лекция №6. Напомним, что этот метод позволяет отсортировать N ключей, представляющие собой целые числа в диапазоне от 0 до R — 1, за линейное время, используя для этой цели одну вспомогательную таблицу размером R для подсчета и другую таблицу, размером N, для переупорядочения записей. Значит, если есть возможность использовать таблицу размером 2w, то сортировку w-разрядных ключей легко выполнить за линейное время. На самом деле метод распределяющего подсчета лежит в основе базовых методов MSD- и LSD-сортировок. Поразрядная сортировка нужна лишь тогда, когда ключи настолько длинны (скажем, w = 64), что использование таблицы размером 2w невозможно.

Упражнения

10.1. Сколько цифр нужно для представления 32-разрядного числа в системе счисления с основанием 256? Опишите, как можно извлечь каждую цифру этого числа. Ответьте на этот же вопрос, если основанием системы счисления будет число 216.

10.2. Для N = 103, 106 и 109 приведите наименьший размер байта, позволяющий представить любое число в диапазоне от 0 до N в виде 4-байтового слова.

10.3. Напишите перегруженную операцию <, используя абстракцию digit (например, чтобы выполнять эмпирические сравнения алгоритмов из глав 6 и 9 с методами, описанными в данной главе, для одних и тех же данных).

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

10.5. Напишите программу, которая для заданного набора случайных десятичных чисел (R = 10), равномерно распределенных на интервале от 0 до 1, будет вычислять количество операций сравнения цифр, необходимых для их сортировки в смысле рис. 10.1. Выполните эту программу для N = 103, 104, 105 и 106 .

10.6. Выполните упражнение 10.5 для R = 2, используя случайные 32-разрядные числа.

10.7. Выполните упражнение 10.5 для нормально распределенных чисел (распределение Гаусса).

Бинарная быстрая сортировка

Предположим, что мы можем переупорядочить записи в файле таким образом, что все ключи, начинающиеся с бита 0, будут расположены перед ключами, начинающимися с бита 1. Тогда можно воспользоваться рекурсивным методом сортировки, который является одним из вариантов быстрой сортировки (см. лекция №7): разбиваем файл этим способом и потом независимо сортируем оба подфайла. Для переупорядочения файла мы просматриваем его слева до обнаружения ключа, который начинается с бита 1, затем просматриваем справа до обнаружения ключа, который начинается с бита 0, обмениваем их и продолжаем этот процесс до перекрещивания указателей. В литературе (включая и более ранние издания данной книги) этот метод часто называют поразрядной обменной сортировкой, а здесь мы будем называть его бинарной быстрой сортировкой (binary quicksort), чтобы подчеркнуть, что это лишь простой вариант алгоритма, изобретенного Хоаром, хотя он был открыт раньше быстрой сортировки (см. раздел ссылок).

В программе 10.1 представлена полная реализация этого метода. Применяемый в ней процесс разбиения по существу тот же, что и в программе 7.2, только в качестве центрального элемента используется число 2b, а не некоторый ключ из файла. Поскольку в файле может и не быть числа 2b, то нет и гарантии того, что в процессе разбиения хотя бы один элемент попадет в свою окончательную позицию. Данный алгоритм отличается и от обычной быстрой сортировки, поскольку рекурсивные вызовы выполняются для ключей размером на 1 бит меньше. Это различие существенно влияет на эффективность алгоритма. Например, если попадется вырожденное разбиение файла из N элементов, то будет выполнен рекурсивный вызов для подфайла размером N, но для ключей длиной на 1 разряд меньше. Поэтому число таких вызовов ограничено количеством разрядов в ключе. А последовательное использование центральных элементов, отсутствующих в файле, может ввести обычную быструю сортировку в бесконечный рекурсивный цикл.

Программа 10.1. Бинарная быстрая сортировка

Эта программа разбивает файл по ведущим битам ключей и затем рекурсивно сортирует полученные подфайлы. Переменная d содержит позицию анализируемого бита, начиная с 0 (самого левого). Разбиение завершается при j, равном i, после чего у всех элементов справа от a[i] в d-ой позиции находится 1, а у всех элементов слева от a[i] в d-ой позиции находится 0. Сам элемент a[i] содержит в d-ой позиции 1, кроме того случая, когда все ключи файла содержат в d-ой позиции нули. На этот случай сразу после цикла разбиения вставлена дополнительная проверка.

  template <class Item>
  void quicksortB(Item a[], int l, int r, int d)
    { int i = l, j = r;
      if (r <= l || d > bitsword) return;
      while (j != i)
        { while (digit(a[i], d) == 0 && (i < j)) i++;
          while (digit(a[j], d) == 1 && (j > i)) j--;
          exch(a[i], a[j]);
        }
      if (digit(a[r], d) == 0) j++;
      quicksortB(a, l, j-1, d+1);
      quicksortB(a, j, r, d+1);
    }
  template <class Item>
  void sort(Item a[], int l, int r)
    { quicksortB(a, l, r, 0); }
      

Как и в случае стандартной быстрой сортировки, возможны различные варианты реализации внутреннего цикла. В программе 10.1 проверка, не пересеклись ли значения индексов, включена в оба внутренних цикла. Такая проверка приводит к лишнему обмену в случае, когда i = j; этого можно избежать с помощью оператора break, как сделано в программе 7.2, хотя там обмен элемента a[i] сам с собой вполне безобиден. Другой способ — использовать сигнальный ключ.

На рис. 10.2 показано выполнение программы 10.1 на небольшом файле, которое можно сравнить с рис. 7.1 для быстрой сортировки. Этот рисунок показывает, как перемещаются данные, но не объясняет, почему производятся те или иные перемещения — это зависит от двоичного представления ключей. Более подробное представление данного примера дано на рис. 10.3. Здесь считается, что буквы закодированы простым 5-разрядным кодом, в котором i-я буква алфавита представлена двоичным представлением числа i. Такая кодировка представляет собой упрощенную версию настоящих символьных кодировок, в которых для представления большего количества символов (буквы верхнего и нижнего регистров, цифры и специальные символы) используется большее число битов (7, 8 или даже 16).

 Пример бинарной быстрой сортировки


Рис. 10.2.  Пример бинарной быстрой сортировки

Разбиение по старшему разряду еще не гарантирует того, что хотя бы одно значение займет свое окончательное место; оно лишь обеспечивает, что все ключи с 0 в старшем разряде предшествуют ключам с 1 в старшем разряде. Можно сравнить эту диаграмму с рис. 7.1 для быстрой сортировки, хотя принцип разбиений совершенно непонятен, если ключи не представлены в двоичном виде. На рис. 10.3 рис. 10.3 приведены все детали, которые проясняют, по каким позициям производится разбиение.

Для ключей в виде полных слов, состоящих из случайных битов, программа 10.1 должна начинать работу с самого левого бита слова, т.е. нулевого. В общем случае начальный бит напрямую зависит от приложения, от количества битов в машинном слове и от внутреннего представления целых (в том числе отрицательных) чисел. Для однобуквенных 5-разрядных ключей с рис. 10.2 и рис. 10.3 на 32-битовой машине начальным должен быть бит 27.

Этот пример показывает потенциальную проблему, которая может возникнуть при использовании бинарной быстрой сортировки в реальных ситуациях: довольно часто могут встречаться вырожденные разбиения (когда все ключи имеют одно и то же значение разряда, по которому производится разбиение). Сортировка небольших чисел (в которых много нулевых старших разрядов), как в рассмотренных нами примерах, не является чем-то необычным. Эта же проблема возникает и для символьных ключей: допустим, мы строим 32-разрядные ключи, объединяя четыре символа, каждый из которых представлен в стандартной 8-битовой кодировке. Тогда весьма вероятно появление вырожденных разбиений в начальной позиции каждого символа, поскольку, например, все буквы нижнего регистра начинаются с одних и тех же битов. С этой проблемой часто приходится сталкиваться при сортировке кодированных данных; аналогичные проблемы возникают и в других видах поразрядной сортировки.

 Пример бинарной быстрой сортировки (с битовым представлением ключей)


Рис. 10.3.  Пример бинарной быстрой сортировки (с битовым представлением ключей)

Эта диаграмма построена на основании рис. 10.2, только здесь значения ключей приведены в двоичном представлении, а таблица сжата так, чтобы показать сортировку независимых подфайлов, как будто они выполняются параллельно, и для удобства транспонирована. На первом этапе файл разбивается на подфайл, все ключи которого начинаются с 0, и подфайл, все ключи которого начинаются с 1. Затем первый подфайл разбивается на подфайл, все ключи которого начинаются с 00, и подфайл, все ключи которого начинаются с 01; независимо от этого в произвольный момент времени второй подфайл разбивается на подфайл, все ключи которого начинаются с 10, и подфайл, все ключи которого начинаются с 11. Этот процесс прекращается при исчерпании разрядов (для повторяющихся ключей в данном примере) или когда размер подфайлов станет равным 1.

После того как ключ можно отличить по его левым разрядам от всех остальных, никакие другие разряды больше не анализируются. Это свойство в одних ситуациях является достоинством, в других — недостатком. Если ключи представляют собой действительно случайные совокупности битов, то в каждом ключе анализируются только lg N битов, что обычно намного меньше числа битов в ключах. Этот факт рассматривается в разделе 10.6, а также в упражнении 10.5 и на рис. 10.1. Например, сортировка файла из 1000 записей со случайными ключами может потребовать анализа всего лишь 10 или 11 битов каждого ключа (даже если ключи, скажем, 64-разрядные). А вот для одинаковых ключей проверяются все биты. Поразрядная сортировка не способна хорошо работать на файлах с большим числом длинных повторяющихся ключей. Бинарная быстрая сортировка и стандартный метод работают достаточно быстро, если сортируемые ключи состоят из абсолютно случайных битов (различие между ними состоит в основном в разнице затрат на операции извлечения битов и сравнения), но стандартный алгоритм быстрой сортировки легче приспособить к обработке неслучайных последовательностей ключей, а трехпутевая быстрая сортировка идеально подходит для случаев с преобладанием повторяющихся ключей.

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

 Дерево разбиения для бинарной быстрой сортировки


Рис. 10.4.  Дерево разбиения для бинарной быстрой сортировки

Это дерево описывает структуру разбиения для бинарной быстрой сортировки, соответствующую рис. 10.2 и 10.3. Поскольку элементы не обязательно занимают свои окончательные позиции, ключи соответствуют внешним узлам дерева. Такая структура обладает следующим свойством: если на пути от корня к любому ключу обозначить разветвления влево за 0, а вправо — за 1, то получим значения старших разрядов этого ключа. Именно эти значения и отличают во время сортировки данный ключ от всех остальных. Черные квадратики означают пустые части разбиения (когда все ключи переходят в другую часть, поскольку значения их старших разрядов совпадают). В данном примере это происходит только на нижних уровнях дерева, но в принципе возможно и выше: например, если бы среди ключей не было I или X, то их узлы на рисунке были бы заменены пустым узлом. Обратите внимание, что повторяющиеся ключи (A и E) не могут быть разделены (сортировка поместит их в один подфайл только после исчерпания всех их битов).

В случае бинарной быстрой сортировки известно, что ключи попадают в свои окончательные позиции лишь тогда, когда размер подфайлов дошел до 1, либо когда исчерпаны все разряды ключа; так что эти ключи помещаются на нижний уровень дерева. Такая структура называется бинарным trie-деревом (binary trie); ее свойства будут подробно рассмотрены в лекция №15. Например, одно из важных и интересных свойств заключается в том, что структура trie-дерева полностью определяется значениями ключей, а не порядком их следования.

 Динамические характеристики бинарной быстрой сортировки на большом файле


Рис. 10.5.  Динамические характеристики бинарной быстрой сортировки на большом файле

Разбиения на части при бинарной быстрой сортировке менее чувствительны к упорядоченности ключей, чем в стандартной быстрой сортировке. Здесь показан процесс упорядочения двух различных случайно упорядоченных файлов с 8-разрядными ключами; профили их разбиений практически совпадают.

Разделы разбиений в быстрой сортировке зависят от двоичного представления диапазона ключей и количества сортируемых элементов. Например, если файлы представляют собой случайные перестановки целых чисел, меньших 171 = 101010112 , то разбиение по первому биту эквивалентно разбиению по значению 128, и подфайлы получаются разновеликими (размер одного — 128, а другого — 43). Ключи на рис. 10.5 являются случайными 8-разрядными значениями, поэтому здесь такой эффект не наблюдается, но знать о нем надо, чтобы не было неприятных сюрпризов при столкновении с ним на практике.

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

Упражнения

10.8. Начертите, в стиле рис. 10.2, trie-дерево, соответствующее процессу разбиений при поразрядной быстрой сортировке ключей E A S Y Q U E S T I O N.

10.9. Сравните количество обменов, используемых бинарной быстрой сортировкой, с количеством обменов, используемых обычной быстрой сортировкой, для 3-разрядных двоичных чисел 001, 011, 101, 110, 000, 001, 010, 111, 110, 010.

о 10.10. Почему сортировка меньшего из двух под-файлов первым менее важна в бинарной быстрой сортировке, чем в обычной быстрой сортировке?

о 10.11. Опишите, что происходит на втором уровне разбиения (когда разбивается левый подфайл и когда разбивается правый подфайл) при использовании бинарной быстрой сортировки для упорядочения случайных перестановок неотрицательных целых чисел, меньших 171.

10.12. Напишите программу, которая за один предварительный проход определяет число старших разрядов, одинаковых у всех ключей, а затем вызывает бинарную быструю сортировку, модифицированную так, что она игнорирует эти разряды. Сравните время выполнения этой программы со временем выполнения стандартной реализации для N = 103, 104, 105 и 106 , если входными данными являются 32-разрядные слова следующего формата: крайние правые 16 битов — равномерно распределенные случайные значения, а крайние левые 16 битов равны 0, кроме единицы в i-ой позиции, если в правой половине имеется i единиц.

10.13. Внесите в бинарную быструю сортировку явную проверку ситуаций, когда все ключи равны. Сравните время выполнения этой программы с аналогичным показателем для стандартной реализации при N = 103, 104, 105 и 106 и входных данных, описанных в упражнении 10.12.

Поразрядная MSD-сортировка

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



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

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

Динамические характеристики поразрядной MSD-сортировки


Рис. 10.6.  Динамические характеристики поразрядной MSD-сортировки

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

Как было сказано в разделе 10.2, одной из наиболее привлекательных особенностей поразрядной сортировки является интуитивный и прямолинейный характер ее адаптации к приложениям сортировки, в которых ключами являются символьные строки. Это особенно ярко проявляется в С++ и других средах программирования с прямой поддержкой обработки строк. Для MSD-сортировки просто используется основание системы счисления, соответствующее размеру байта. Чтобы извлечь цифру, достаточно загрузить байт, а для перехода к следующей цифре нужно увеличить на единицу указатель строки. Пока мы рассматриваем ключи фиксированной длины, а чуть позже увидим, что эти же базовые механизмы позволяют работать и с ключами в виде строк переменной длины.

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

 Пример поразрядной MSD-сортировки


Рис. 10.7.  Пример поразрядной MSD-сортировки

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

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

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

Как мы уже убедились на примере быстрой сортировки в лекция №7 и разделе 10.2, а также сортировки слиянием в лекция №8, производительность большинства рекурсивных программ можно повысить, используя для сортировки небольших файлов простой алгоритм. Использование другого метода для сортировки небольших файлов (контейнеров с небольшим количеством элементов) в поразрядной сортировке имеет большое значение, ведь их так много!

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

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

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

Этот процесс реализован в программе 10.2. Ее рекурсивная структура обобщает структуру быстрой сортировки, так что все вопросы, рассмотренные в разделе 7.3 лекция №7, необходимо рассмотреть и здесь. Нужно ли обрабатывать больший из подфайлов последним, во избежание излишней глубины рекурсии? Скорее всего, нет, поскольку глубина рекурсии ограничена длиной ключей. Нужно ли применять для сортировки небольших подфайлов простые методы, вроде сортировки вставками? Конечно, поскольку таких подфайлов будет очень много.

Программа 10.2. Поразрядная MSD-сортировка

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

  #define bin(A) l+count[A]
  template <class Item>
  void radixMSD(Item a[], int l, int r, int d)
    { int i, j, count[R+1];
      static Item aux[maxN];
      if (d > bytesword) return;
      if (r-l <= M) { insertion(a, l, r); return; }
      for (j = 0; j < R; j++) count[j] = 0;
      for (i = l; i <= r; i++)
        count[digit(a[i], d) + 1]+ + ;
      for (j = 1; j < R; j++)
        count[j] += count[j-1];
      for (i = l; i <= r; i++)
        aux[l+count[digit(a[i], d)]++] = a[i];
      for (i = l; i <= r; i++) a[i] = aux[i];
      radixMSD(a, l, bin(0)-1, d+1);
      for (j = 0; j < R-1; j++)
        radixMSD(a, bin(j), bin(j + 1)-1, d+1);
    }
      

Для выполнения разбиения в программе 10.2 используется вспомогательный массив, размер которого равен размеру сортируемого файла. В качестве альтернативы можно воспользоваться обменным методом распределяющего подсчета (см. упражнения 10.17 и 10.18). Особенно большое внимание следует уделить использованию памяти, поскольку рекурсивные обращения могут расходовать очень много памяти для размещения локальных переменных. В программе 10.2 временный буфер для перемещения ключей (aux) может быть глобальным, но массив, в котором хранятся счетчики и позиции точек разбиения (count) должен быть локальным.

Использование дополнительной памяти для вспомогательного массива — не самый серьезный вопрос во многих приложениях, использующих поразрядную сортировку для длинных ключей или записей, поскольку для работы с такими данными обычно применяются указатели. Поэтому дополнительная память нужна только для переупорядочения указателей, и ее объем мал по сравнению с памятью, отведенной под сами ключи и записи (хотя все-таки заметен). Если памяти достаточно, и важно в первую очередь быстродействие (обычная ситуация при использовании поразрядной сортировки), можно также сэкономить время на копирование массива с помощью рекурсивных переключений направления просмотра, как это было сделано для сортировки слиянием в разделе 8.4 лекция №8.

 Пример поразрядной MSD-сортировки (с пустыми контейнерами) Уже на втором этапе сортировки небольших файлов получается множество пустых корзин


Рис. 10.8.  Пример поразрядной MSD-сортировки (с пустыми контейнерами) Уже на втором этапе сортировки небольших файлов получается множество пустых корзин

При сортировке случайных ключей количество ключей в каждом контейнере (размер подфайлов) после первого прохода в среднем будет равно N/R. На практике ключи могут не быть случайными (например, если ключи — это строки, представляющие собой слова на русском языке, то мы знаем, что лишь немногие из них начинаются с буквы й и совсем нет слов, начинающихся с буквы ъ), так что многие контейнеры окажутся пустыми, а некоторые из непустых контейнеров будут содержать больше ключей, чем остальные (см. рис. 10.8). Несмотря на этот эффект, процесс многопутевого разбиения в общем случае будет эффективно разбивать большие сортируемые файлы на множество меньших под-файлов.

Другой естественный путь реализации MSD-сортировки — использование связных списков. Каждый контейнер представляется отдельным связным списком: на первом проходе по сортируемым элементам каждый элемент вставляется в соответствующий связный список, определяемый старшей цифрой. Далее подсписки сортируются, после чего объединяются в единое отсортированное целое. Такой подход представляет собой достаточно трудную задачу программирования (см. упражнение 10.36). Объединение связных списков требует отслеживания начала и конца каждого из этих списков, и, естественно, специальной обработки пустых списков, которых, очевидно, будет много.

Чтобы добиться высокой производительности поразрядной сортировки в каком-либо приложении, нужно ограничить число пустых контейнеров с помощью выбора соответствующего значения как основания системы счисления, так и размера отсекаемых небольших подфайлов. В качестве конкретного примера предположим, что требуется отсортировать 224 (около шестнадцати миллионов) 64-разрядных целых чисел. Чтобы использовать таблицу счетчиков, меньшую по размеру, чем размер файла, можно выбрать основание R = 216, соответствующее проверке 16 разрядов ключа. Но после первого разбиения средний размер файла составит всего лишь 228, а основание системы счисления 216 слишком велико для таких небольших файлов. Что еще хуже, таких файлов может быть очень много: в рассматриваемом случае порядка 216. Для каждого из этих 216 файлов процедура сортировки обнуляет 216 счетчиков, затем проверяет, какие из них не равны нулю и так далее — выполняя по меньшей мере 232 арифметических операций. Программа 10.2, реализованная в предположении, что большая часть контейнеров не пуста, выполняет достаточно большое число арифметических операций для каждого пустого контейнера (например, она выполняет рекурсивные вызовы для всех пустых контейнеров), так что для рассматриваемого примера время выполнения окажется очень большим. Более подходящим основанием для второго уровня может быть 28 или 24. Короче говоря, для MSD-сортировки небольших файлов не стоит использовать большие основания систем счисления. Этот вопрос будет подробно рассмотрен в разделе 10.6, при изучении производительности различных методов.

Если положить R = 256 и отказаться от рекурсивного вызова для контейнера 0, то программа 10.2 становится эффективным методом сортировки строк в стиле С. Если также известно, что длина всех строк не превышает некоторой фиксированной величины, то можно ввести специальную переменную bytesword для хранения этой величины, либо отказаться от сравнения с bytesword и выполнять сортировку обычных символьных строк переменной длины. Для сортировки строк мы обычно будем реализовывать абстрактную операцию digit в виде единственной ссылки на массив, как описано в разделе 10.1. С помощью подбора значений R и bytesword (и их проверки) программу 10.2 можно легко модифицировать для работы со строками символов из нестандартных алфавитов, со строками нестандартных форматов, с учетом ограничений по длине или других вариантов.

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

Функция MSD-сортировки выполняет разбиение файла по первой цифре ключей, затем рекурсивно вызывает себя для обработки подфайлов, соответствующих каждому значению. На рис. 10.9 рис. 10.9 представлена структура рекурсивных вызовов MSD-сортировки для примера, показанного на рис. 10.8. Структура вызовов соответствует многопутевому trie-дереву — прямому обобщению структуры trie-дерева для бинарной быстрой сортировки, показанной на рис. 10.4. Каждый узел соответствует рекурсивному вызову MSD-сортировки некоторого подфайла. Например, поддерево корня с буквой o в корне соответствует сортировке подфайла из трех ключей: of, on и or.

Из этих рисунков легко видеть, что в процессе MSD-сортировки строк появляется значительное число пустых контейнеров. В разделе 10.4 мы рассмотрим способ решения этой проблемы, а в главе 15 лекция №15 изучим явное использование trie-структур в приложениях, применяющих обработку строк. В общем случае мы будем работать с компактными представлениями trie-структур, которые не содержат узлов, соответствующих пустым контейнерам, и в которых метки перенесены с ребер на их нижние узлы (как на рис. 10.10) — т.е. со структурой, соответствующей структуре рекурсивных вызовов (с игнорированием пустых контейнеров) на рис. 10.7, где показан пример MSD-сортировки трехбуквенных ключей. Например, поддерево корня с буквой j в корне соответствует сортировке контейнера, содержащего четыре ключа: jam, jay, jot и joy. Подробно свойства таких trie-деревьев будут рассмотрены в лекция №15.

Основная трудность в достижении на практике максимальной эффективности MSD-сортировки ключей, представляющих собой длинные строки, состоит в недостаточной случайности сортируемых данных. Часто эти ключи содержат длинные последовательности одинаковых или ненужных элементов, либо их части находятся в узком диапазоне. Например, приложение, обрабатывающее записи с данными о студентах, может иметь дело с ключами, поля которых содержат год выпуска (четыре байта, но только одно из четырех значений), название штата (максимум 10 байтов, но только одно из 50 возможных значений) и пол (1 байт с одним из двух возможных значений), а также поле фамилии студента (это больше похоже на случайную строку, но оно не обязательно короткое, буквы не распределены равномерно и, если длина фиксирована, то в конце может быть много пробелов). Все эти ограничения приводят к образованию в процессе MSD-сортировки большого количества пустых контейнеров (см. упражнение 10.23).

 Рекурсивная структура поразрядной MSD-сортировки


Рис. 10.9.  Рекурсивная структура поразрядной MSD-сортировки

Это дерево соответствует выполнению рекурсивной MSD-сортировки из программы 10.2 для примера упорядочения двухбуквенных ключей методом MSD-сортировки, представленного на рис. 10.8. Если размер файла равен 0 или 1, рекурсивные вызовы не выполняются. В остальных случаях выполняются 26 вызовов, по одному на каждое возможное значение текущего байта.

 Рекурсивная структура поразрядной MSD-сортировки (с игнорированием пустых подфайлов)


Рис. 10.10.  Рекурсивная структура поразрядной MSD-сортировки (с игнорированием пустых подфайлов)

Данное представление рекурсивной структуры MSD-сортировки более компактно, чем представление на рис. 10.9. Каждый узел этого дерева помечен значением (i — 1)-ой цифры соответствующего ключа, где i — расстояние от узла до корня. Каждый путь от корня до нижнего уровня дерева соответствует какому-либо ключу, который получается слитной записью меток узлов. Данное дерево соответствует представленному на 10.7примеру MSD-сортировки трехбуквенных ключей.

Один из практических способов решения этой проблемы заключается в разработке более сложной реализации абстрактной операции доступа к байтам, которая учитывает любые специальные сведения о сортируемых строках. Другой метод, который достаточно просто реализуется и называется эвристикой распределения контейнеров (bin-span heuristics), заключается в отслеживании на стадии подсчета начального и конечного значений диапазона значений непустых контейнеров, и использовании в дальнейшем только контейнеров, попадающих в этот диапазон (возможно, включая некоторые специальные случаи, вроде нулевых или пробельных ключей). Такое усовершенствование удобно для ситуаций, описанных в предыдущем абзаце. Например, в случае алфавитноцифровых данных с основанием системы счисления 256 можно работать в одном разделе с числовыми ключами и получить лишь 10 непустых контейнеров, соответствующих цифрам, а в другом разделе — с заглавными английскими буквами и получить 26 непустых контейнеров, соответствующих этим буквам.

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

Упражнения

10.14. Начертите компактную trie-структуру (без пустых контейнеров и с ключами в узлах, как на рис. 10.10), соответствующую рис. 10.9.

10.15. Сколько узлов содержит полное trie-дерево, соответствующее рис. 10.10?

10.16. Покажите, как выполняется разбиение MSD-сортировкой набора ключей now is the time for all good people to come the aid of their party.

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

10.18. Напишите программу, которая решает задачу обобщенного R-путевого разбиения с помощью метода, описанного в общих чертах в упражнении 10.17.

10.19. Напишите программу, которая генерирует случайные 80-байтовые ключи, а потом сортирует их методом поразрядной MSD-сортировки для N = 103, 104, 105 и 106 . Добавьте возможность вывода общего количества байтов, проверенных в процессе каждой сортировки.

10.20. Чему равно ожидаемое крайнее правое положение байта ключа, к которому обратится программа из упражнения 10.19 для каждого заданного значения N? Если вы сделали упражнение 10.19, вставьте в программу вывод этого значения и сравните результаты теоретических расчетов с результатами, полученными опытным путем.

10.21. Напишите генератор ключей, который порождает ключи путем перетасовки случайной 80-байтной последовательности. Воспользуйтесь полученной реализацией для генерации N случайных ключей, затем упорядочьте их методом MSD-сортировки для N = 103, 104, 105 и 106 . Сравните полученную производительность с аналогичным результатом для действительно случайных ключей (см. упражнение 10.19).

10.22. Чему равно ожидаемое крайнее правое положение байта ключа, к которому обратится программа из упражнения 10.21 для каждого значения N? Если вы сделали упражнение 10.21, сравните результаты теоретических расчетов с результатами, полученными опытным путем.

10.23. Напишите генератор ключей, который порождает случайные 30-байтовые строки, состоящие из четырех полей: 4-байтовое поле, содержащее одну из 10 заданных строк; 10-байтовое поле, содержащее одну из 50 заданных строк; 1-байтовое поле, содержащее одно из двух возможных значений; и 15-байтовое поле, содержащее случайную выровненную влево строку длиной от 4 до 15 символов (с равной вероятностью). Воспользуйтесь полученной реализацией для генерации N случайных ключей, а затем упорядочьте их методом MSD-сортировки для N = 103, 104, 105 и 106 . Добавьте в генератор возможность вывода общего количества проверенных байтов ключей. Сравните достигнутую производительность с аналогичным результатом для действительно случайных ключей (см. упражнение 10.19).

10.24. Реализуйте в программе 10.2 эвристику распределения контейнеров. Проверьте работу программы на данных из упражнения 10.23.

Трехпутевая поразрядная быстрая сортировка

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

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

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

Программа 10.3. Трехпутевая поразрядная быстрая сортировка

Данная MSD-сортировка по существу эквивалентна коду быстрой сортировки с трехпутевым разбиением (программа 7.5), но отличается от нее следующим: (1) обращения к ключам заменены обращениями к байтам ключей, (2) текущая позиция байта передается рекурсивной программе в виде параметра, и (3) рекурсивные вызовы для среднего подфайла переходят к следующему байту. Чтобы индексы не выходили за пределы строк, перед рекурсивными вызовами с переходом к следующему байту выполняется проверка, равно ли 0 центральное значение. Если центральное значение равно 0, то левый подфайл пуст, средний под-файл соответствует найденным равным ключам, а правый подфайл соответствует более длинным строкам, которые требуют дальнейшей обработки.

  #define ch(A) digit(A, d)
  template <class Item>
  void quicksortX(Item a[], int l, int r, int d)
    { int i, j, k, p, q; int v;
      if (r-l <= M) { insertion(a, l, r); return; }
      v = ch(a[r]); i = l-1; j = r; p = l-1; q = r;
      while ( i < j )
        { while (ch(a[ + + i]) < v) ;
          while (v < ch(a[-- j ] ) ) if (j == l) break;
          if (i > j) break;
          exch(a[i], a[j]);
          if (ch(a[i]) == v) { p++; exch(a[p], a[i]); }
          if (v == ch(a[j])) { q—; exch(a[j], a[q]); }
        }
      if (p == q)
       { if (v != '\0') quicksortX(a, l, r, d+1); return; }
      if (ch(a[i]) < v) i++;
      for (k = l; k <= p; k+ + , j--) exch(a[k], a[j]);
      for (k = r; k >= q; k--, i++) exch(a[k], a[i]);
      quicksortX(a, l, j, d);
      if ((i == r) && (ch(a[i]) == v)) i+ + ;
      if (v != '\0') quicksortX(a, j + 1, i-1, d+1);
      quicksortX(a, i, r, d);
    }
      

 Трехпутевая поразрядная быстрая сортировка


Рис. 10.11.  Трехпутевая поразрядная быстрая сортировка

Файл делится на три части: слова, начинающиеся с букв от a до i, слова, начинающиеся c буквы j, и слова, начинающиеся с букв от к до z. Затем сортировка рекурсивно продолжается.

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

На рис. 10.11 приведен пример действия этого метода при сортировке трехбуквенных слов, представленных на рис. 10.7, а на рис. 10.12 показана структура рекурсивных вызовов. Каждый узел соответствует в точности трем рекурсивным вызовам: для ключей с меньшим значением первого байта (левый потомок), для ключей с тем же значением первого байта (средний потомок) и для ключей с большим значением первого байта (правый потомок).

Если сортируемые ключи соответствуют абстракции из раздела 10.2, стандартную быструю сортировку и все другие методы сортировки, рассмотренные в главах 6—9, можно рассматривать как MSD-сортировку, поскольку функция сравнения осуществляет доступ сначала к наиболее значащей части ключа (см. упражнение 10.3).

 Рекурсивная структура трехпутевой поразрядной быстрой сортировки


Рис. 10.12.  Рекурсивная структура трехпутевой поразрядной быстрой сортировки

Данная комбинация обычного и trie-дерева соответствует замене 26-путевыхузлов в trie-дереве, изображенном на рис. 10.10, на тернарные деревья бинарного поиска, как показано на рис. 10.13. Любой путь от корня вниз, который оканчивается средней связью, определяет ключ файла — с помощью символов, указанных средними ссылками на этом пути. На рис. 10.10 имеется 1035 не показанных пустых ссылок, на данном рисунке показаны все 155 пустых ссылок этого дерева. Каждая пустая ссылка соответствует пустому контейнеру, так что это различие показывает, как существенно трехпутевоеразбиение может сократить число пустых контейнеров, которые появляются при MSD-сортировке.

 Пример узлов trie-дерева трехпутевой поразрядной быстрой сортировки


Рис. 10.13.  Пример узлов trie-дерева трехпутевой поразрядной быстрой сортировки

Трехпутевая поразрядная быстрая сортировка решает проблему пустых корзин, характерную для MSD-сортировки, с помощью выполнения трехпутевого разбиения: значение одного байта устраняется, а остальные обрабатываются дальше. Это действие соответствует замене каждого M-путевого узла trie—дерева, описывающего рекурсивную структуру вызовов MSD-сортировки (см. рис.10.9), на троичное дерево, в котором каждому непустому контейнеру соответствует внутренний узел. Для полных узлов (слева) такое изменение требует затрат времени и почти не экономит память, но для пустых узлов (справа) затраты времени минимальны, а экономия памяти значительна.

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

Рассмотрим случай, когда ключи имеют большую длину (для простоты — фиксированную), но большинство старших байтов совпадают. В такой ситуации время выполнения обычной быстрой сортировки пропорционально длине слова, помноженной на 2 Nln N, тогда как время выполнения поразрядной версии пропорционально N, умноженному на длину слова (чтобы обнаружить все равные между собой старшие байты) плюс 2 Nln N (для сортировки оставшихся коротких ключей). Иначе говоря, этот метод может работать в ln N раз быстрее, чем обычная быстрая сортировка, если учитывать только затраты на сравнения. На практике в приложениях сортировки подобные характеристики ключей не являются чем-то необычным (см. упражнение 10.25).

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

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

Трехпутевая быстрая сортировка успешно применяется и в тех случаях, когда сортируемыми ключами являются векторы (или в математическом смысле, или в смысле стандартной библиотеки шаблонов C++). Другими словами, если ключи составлены из независимых компонентов (каждый из которых сам является абстрактным ключом), можно упорядочить записи таким образом, что они будут располагаться в порядке возрастания первых компонентов ключей и в порядке возрастания вторых компонентов ключей, если равны их первые компоненты и т.д. Сортировку векторов можно рассматривать как обобщение поразрядной сортировки, в котором R может быть произвольно большим. После соответствующей модификации программа 10.3 будет называться многомерной быстрой сортировкой (multikey quicksort).

Упражнения

10.25. Пусть ключи состоят из d байтов (d > 4), причем последние 4 байта принимают случайные значения, а все остальные равны 0. Оцените количество просмотренных байтов при упорядочении с помощью трехпутевой поразрядной быстрой сортировки (программа 10.3) и стандартной быстрой сортировки (программа 7.1) больших файлов размером N, и вычислите отношение значений времени выполнения сортировок.

10.26. Определите опытным путем размер байта, для которого время выполнения трехпутевой сортировки случайных 64-разрядных ключей минимально, при N = 103, 104, 105 и 106 .

10.27. Разработайте реализацию трехпутевой поразрядной быстрой сортировки для связных списков.

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

10.29. Используя генератор ключей из упражнения 10.19, выполните трехпутевую поразрядную быструю сортировку для N = 103, 104, 105 и 106 . Сравните ее производительность с производительностью MSD-сортировки.

10.30. Используя генератор ключей из упражнения 10.21, выполните трехпутевую поразрядную быструю сортировку для N = 103, 104, 105 и 106 . Сравните ее производительность с производительностью MSD-сортировки.

10.31. Используя генератор ключей из упражнения 10.23, выполните трехпутевую поразрядную быструю сортировку для N = 103, 104, 105 и 106 . Сравните ее производительность с производительностью MSD-сортировки.

Поразрядная LSD-сортировка

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

 Пример поразрядной LSD-сортировки


Рис. 10.14.  Пример поразрядной LSD-сортировки

Метод LSD-сортировки упорядочивает трехбуквенные слова за три прохода (слева направо).

Файл сначала сортируется по последней букве (методом распределяющего подсчета), потом по средней букве, а затем по первой.

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

Иначе говоря, если w — i еще не просмотренных байтов какой-либо пары ключей идентичны, то любое различие между этими ключами определяется уже просмотренными i байтами, так что эти ключи должным образом упорядочены, и сохранят этот порядок в силу свойства устойчивости. Если же w — i еще не просмотренных байтов различны, то уже просмотренные i байтов не играют никакой роли, и какой-то из следующих проходов правильно упорядочит эту пару по одному из более значащих байтов.

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

Метод LSD-сортировки использовался в старых машинах для сортировки перфокарт. Такие машины могли распределять колоду перфокарт по 10 карманам в соответствии с отверстиями, пробитыми в каком-то столбце. Если в некотором наборе столбцов пробиты определенные числа, оператор может отсортировать перфокарты, пропустив их через машину по крайней правой цифре, затем, собрав все перфокарты в одну колоду, снова пропустить их через машину, по предпоследней цифре, и т.д. Физическая сортировка перфокарт представляет собой устойчивый процесс, который и имитирует сортировка методом распределяющего подсчета. Эта версия LSD-сортировки не только широко использовалась в коммерческих целях в пятидесятых и шестидесятых годах прошлого столетия, но ей пользовались и многие осторожные программисты, которые пробивали в последних колонках перфокарт их порядковые номера в колоде — чтобы можно было механически упорядочить перфокарты, если колода случайно рассыплется.

Программа 10.4. Поразрядная LSD-сортировка

Данная программа реализует распределяющий подсчет по байтам слов, но продвигаясь справа налево. Реализация метода распределяющего подсчета должна быть устойчивой. Если R равно 2 (то есть bytesword и bitwords равны), данная программа является прямой поразрядной сортировкой, т.е. поразрядной сортировкой с просмотром битов справа налево (см. рис. 10.15).

  template <class Item>
  void radixLSD(Item a[], int l, int r)
    { static Item aux[maxN];
      for (int d = bytesword-1; d >= 0; d—)
        { int i, j, count[R+1];
          for (j = 0; j < R; j + +) count[j] = 0;
          for (i = l; i <= r; i++)
            count[digit(a[i], d) + 1]++;
          for (j = 1; j < R; j++)
            count[j] += count[j-1];
          for (i = l; i <= r; i++)
            aux[count[digit(a[i], d)]++] = a[i];
          for (i = l; i <= r; i++) a[i] = aux[i];
        }
    }
      

 Пример (бинарной) LSD-сортировки (показаны разряды ключей)


Рис. 10.15.  Пример (бинарной) LSD-сортировки (показаны разряды ключей)

На данной диаграмме показано выполнение поразрядной сортировки справа налево на нашем бессменном примере. i-й столбец вычисляется из (i — 1)-го столбца путем извлечения (сохраняя устойчивость) всех ключей с 0 в i-ом разряде, а затем всех ключей с 1 в i-ом разряде. Если перед операцией извлечения (i — 1)-й столбец был упорядочен по (i — 1) последним разрядам ключей, то после операции i-й столбец будет упорядочен по i последним разрядам ключей. Здесь явно показано перемещение ключей на третьем этапе.

На рис. 10.15 показана работа бинарной LSD-сортировки на примере упорядочения тех же ключей, что и на рис. 10.3. Для рассматриваемых 5-разрядных ключей полное упорядочение достигается за пять проходов по ключам справа налево. Сортировка записей с одноразрядным ключом сводится к разбиению файла таким образом, что все записи с ключом 0 находятся перед записями с ключом 1. Как уже было сказано, стратегия разбиения, рассмотренная в начале данной главы в программе 10.1, не может быть использована в силу ее неустойчивости, хотя она вроде бы решает ту же задачу. Имеет смысл рассмотреть поразрядную сортировку с основанием 2, поскольку во многих случаях ее удобно реализовать на высокопроизводительных машинах или с помощью специальной аппаратуры (см. упражнение 10.38). В программах мы используем максимально возможное число разрядов, чтобы уменьшить число проходов, и ограничены только размерами массива счетчиков (см. рис. 10.16).

 Динамические характеристики поразрядной LSD-сортировки


Рис. 10.16.  Динамические характеристики поразрядной LSD-сортировки

На этой диаграмме показаны этапы LSD-сортировки случайных 8-разрядных ключей, по основанию 2 (слева) и 4 (справа); последняя включает в себя каждый второй этап из диаграммы для основания 2. Например, когда остаются два разряда (второй этап с конца на левой диаграмме, предпоследний на правой диаграмме), сортируемый файл состоит из четырех перемежающихся отсортированных подфайлов, которые состоят из ключей, начинающихся с 00, 01, 10 и 11.

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

Упражнения

10.32. Используя генератор ключей из упражнения 10.19, выполните LSD-сортировку для N = 103, 104, 105 и 106 . Сравните показатели этой сортировки с параметрами MSD-сортировки.

10.33. Используя генератор ключей из упражнений 10.21 и 10.23, выполните LSD-сортировку для N = 103, 104, 105 и 106 . Сравните показатели этой сортировки с параметрами MSD-сортировки.

10.34. Приведите (несортированный) результат выполнения LSD-сортировки на основе разбиения бинарной быстрой сортировки, для примера, представленного на рис.10.15.

10.35. Приведите результат применения LSD-сортировки для упорядочения по двум первым символам совокупности ключей now is the time for all good people to come the aid of their party.

10.36. Разработайте реализацию LSD-сортировки для связных списков.

10.37. Найдите эффективный метод, который (1) переупорядочивает записи файла таким образом, что записи, начинающиеся с бита 0, находятся перед записями, начинающимися с бита 1, (2) использует объем дополнительной памяти, пропорциональный квадратному корню из количества записей (или менее), и (3) устойчив.

10.38. Реализуйте метод, который сортирует массив 32-разрядных слов, используя лишь следующую абстрактную операцию: для заданной позиции i и указателя на элемент массива a[k] упорядочить (с сохранением устойчивости) элементы a[k], a[k+1], ..., a[k+63] таким образом, чтобы слова с битом 0 в i-ой позиции находились перед словами с битом 1 в i-ой позиции.

Характеристики производительности поразрядных сортировок

Время выполнения LSD-сортировки при упорядочении N записей с ключами, состоящими из w байтов, пропорционально Nw, поскольку алгоритм совершает w проходов по всем N ключам. Как показано на рис.10.17, это свойство не зависит от характера входных данных.

Для случая длинных ключей и коротких байтов это время сравнимо с Nlg N: например, если бинарная LSD-сортировка используется для сортировки 1 миллиарда 32-разрядных ключей, то и w, и lg N примерно равны 32. Для более коротких ключей и более длинных байтов время выполнения сравнимо с N: например, если 64-разрядные ключи рассматриваются по 16-разрядному основанию системы счисления, то w равно небольшой константе — 4.

 Динамические характеристики поразрядной LSD-сортировки для различных видов файлов


Рис. 10.17.  Динамические характеристики поразрядной LSD-сортировки для различных видов файлов

На данных диаграммах представлены этапы LSD-сортировки для файлов с 200 записями: случайных с равномерным распределением, случайных с нормальным распределением, почти упорядоченных, почти обратно упорядоченных и случайно упорядоченных с 10 различными ключами (слева направо). Время выполнения не зависит от исходного порядка входных данных. Три файла с одним и тем же набором ключей (первый, третий и четвертый файлы — перестановки целых чисел от 1 до 200) имеют на завершающих этапах сортировки похожий вид.

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

Лемма 10.1. В худшем случае поразрядная сортировка выполняет проверку всех байтов во всех ключах.

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

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

Лемма 10.2. При сортировке ключей, состоящих из случайных битов, бинарная быстрая сортировка в среднем проверяет Nlg N разрядов.

Если размер файла равен степени 2, а биты принимают случайные значения, то можно ожидать, что одна половина старших битов равна 0, а другая половина — 1, поэтому, как и в случае быстрой сортировки в лекция №7, данная ситуация описывается рекуррентным соотношением CN = 2CN/2 + N . Опять-таки, это описание ситуации недостаточно точно, поскольку точка разбиения попадает в середину файла только в среднем (и поскольку количество битов в ключе конечно). Однако для бинарной быстрой сортировки вероятность попадания точки разбиения в окрестность центра выше, чем для стандартной быстрой сортировки, поэтому старший член выражения, определяющего время выполнения сортировки, тот же, что и для идеальных разбиений. Подробный анализ, доказывающий этот результат, впервые выполнен Кнутом в 1973 г. и представляет собой классический пример анализа алгоритмов (см. раздел ссылок).

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

Лемма 10.3. MSD-сортировка с основанием системы счисления R требует выполнения по меньшей мере 2N + 2R шагов для упорядочения файла размером N.

MSD-сортировка требует выполнения по меньшей мере одного прохода распределяющего подсчета, а распределяющий подсчет состоит из не менее двух проходов по записям (один для подсчета, другой для распределения) — это минимум 2N шагов, и еще двух проходов по счетчикам (один для их обнуления в начале, а другой для определения концов подфайлов) — еще минимум 2R шагов.

Данное свойство выглядит тривиальным, но оно играет весьма важную роль в понимании MSD-сортировки. В частности, из него следует, что нельзя утверждать, что время выполнения сортировки снижается при уменьшении N, поскольку R может быть намного больше, чем N. Короче говоря, для сортировки небольших файлов следует использовать другие методы. Этот вывод является решением проблемы пустых контейнеров, которая была рассмотрена в конце раздела 10.3. Например, если R равно 256, а N равно 2, то MSD-сортировка будет в 128 раз медленнее, чем более простой метод с обычным сравнением элементов. Рекурсивная структура MSD-сортировки приводит к тому, что рекурсивная программа будет многократно вызывать себя для большого количества небольших файлов. Поэтому игнорирование проблемы пустых контейнеров в рассматриваемом примере может привести к замедлению всей поразрядной сортировки в 128 раз. Что касается промежуточных ситуаций (например, если R равно 256, а N равно 64), то затраты будут не столь катастрофичными, но все же существенными. Использовать сортировку вставками не стоит, поскольку ее N2/4 сравнений — это слишком медленно; игнорировать проблему пустых контейнеров не стоит, поскольку их очень много. Простейший путь решения этой проблемы состоит в использовании основания системы счисления, которое меньше размера сортируемого файла.

Лемма 10.4. Если основание системы счисления всегда меньше размера файла, то число шагов, выполняемых MSD-сортировкой, равно в среднем N logRN с небольшим постоянным множителем (для ключей, состоящих из случайных байтов), а в худшем случае — количеству байтов в ключе с небольшим постоянным множителем.

Результат для худшего случая непосредственно следует из предыдущих рассуждений, а оценка для среднего случая следует из обобщения анализа, выполненного для леммы 10.2. При больших R множитель logRN мал, поэтому для практических целей можно считать, что общее время пропорционально N. Например, если R = 216, то logRN меньше 3 для всех N < 248 , что охватывает все практически возможные размеры файлов.

Как и лемма 10.2, лемма 10.4 позволяет сделать важный для практических приложений вывод о том, что MSD-сортировка случайных ключей достаточно большой длины фактически является сублинейной функцией от общего количества разрядов в ключах. Например, при сортировке 1 миллиона 64-разрядных случайных ключей потребуется проверка 20—30 старших разрядов ключей, т.е. менее половины всех данных.

Лемма 10.5. При упорядочении N ключей (произвольной длины) трехпутевая поразрядная быстрая сортировка выполняет в среднем 2N lnN сравнений байтов.

Возможны два поучительных толкования этого результата. Во-первых, если считать рассматриваемый метод эквивалентным разбиению быстрой сортировкой по старшему разряду, и затем (рекурсивному) использованию этого метода к полученным подфайлам, то неудивительно, что общее число операций примерно такое же, как и в случае нормальной быстрой сортировки — но это сравнения отдельных байтов, а не полных ключей. Во вторых, рассматривая этот метод с точки зрения, показанной на рис. 10.13, можно ожидать, что время выполнения NlogRN из свойства 10.3 следует умножить на 2 lnR, поскольку для упорядочения R байтов требуется 2R lnR шагов быстрой сортировки — в отличие от R шагов для тех же байтов в trie-дереве. Полное доказательство мы опускаем (см. раздел ссылок).

Лемма 10.6. LSD—сортировка может упорядочить N записей с w-разрядными ключами за w / lg R проходов, используя дополнительную память для R счетчиков (и буфера для переупорядочения файла).

Доказательство этого факта непосредственно вытекает из реализации. В частности, если взять R = 2w/4 , получится четырехпроходная линейная сортировка.

Упражнения

10.39. Предположим, что входной файл состоит из 1000 копий каждого из чисел от 1 до 1000, каждое в виде 32-разрядного слова. Опишите, как воспользоваться этими сведениями, чтобы получить быстрый вариант поразрядной сортировки.

10.40. Предположим, что входной файл состоит из 1000 копий каждого из тысячи различных 32-разрядных чисел. Опишите, как воспользоваться этими сведениями, чтобы получить быстрый вариант поразрядной сортировки.

10.41. Каково общее количество байтов, проверяемых в худшем случае трехпутевой поразрядной быстрой сортировкой при упорядочении строк байтов фиксированной длины?

10.42. Эмпирически сравните количество байтов, проверяемых трехпутевой поразрядной быстрой сортировкой при упорядочении длинных строк для N = 103, 104, 105 и 106 , с количеством сравнений в случае стандартной быстрой сортировки тех же файлов.

о 10.43. Приведите количество байтов, проверяемых при выполнении MSD-сортировки и трехпутевой поразрядной быстрой сортировки для файла с N ключами А, АА, ААА, АААА, ААААА, АААААА, ... .

Сортировки с сублинейным временем выполнения

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

Реализация LSD-сортировки, приведенная в разделе 10.5, выполняет bytesword проходов по файлу. Увеличив R, мы получим эффективный метод сортировки — для достаточно больших N и при наличии достаточной памяти для R счетчиков. Как отмечалось при доказательстве свойства 10.5, лучше сделать так, чтобы значение lg R (количество битов в байте) было равно примерно четверти размера слова — тогда поразрядная сортировка будет состоять из четырех проходов распределяющей сортировки. Проверяется каждый бит каждого ключа, но каждый ключ содержит всего четыре цифры. Этот пример является прямой аналогией архитектуры многих компьютеров: в типичной организации используются 32-разрядные слова, состоящие из четырех 8-разрядных байтов. Мы извлекаем из слов байты, а не биты, и на многих компьютерах этот подход гораздо более эффективен. Теперь каждый проход распределяющего подсчета линеен по времени, а поскольку их всего четыре, то и вся сортировка линейна — вряд ли для сортировки можно надеяться на лучший результат.

На самом деле оказывается, что можно обойтись всего лишь двумя проходами распределяющего подсчета. Это следует из того, что файл будет почти отсортирован уже после использования лишь w/2 старших разрядов w-разрядных ключей. Как и в случае быстрой сортировки, после этого можно эффективно завершить упорядочение, выполнив сортировку всего файла методом вставок. Этот метод является тривиальной модификацией программы 10.4.

Чтобы выполнить сортировку слава направо по старшей половине ключей, надо просто начать внешний цикл со значения byteword/2-1, а не с byteword-1. Затем полученный почти упорядоченный файл обрабатывается сортировкой вставками. рис. 10.3 и 10.18 представляют собой убедительное доказательство того, что файл, отсортированный по старшим разрядам, достаточно хорошо упорядочен. Для упорядочения файла, изображенного в четвертом столбце на рис. 10.3, сортировка вставками выполняет всего лишь шесть перестановок, а на рис. 10.18 рис. 10.18 показано, что сортировка вставками позволяет эффективно упорядочивать и большие файлы, отсортированные только по старшей половине разрядов.

 Динамические характеристики LSD-сортировки по MSD-разрядам


Рис. 10.18.  Динамические характеристики LSD-сортировки по MSD-разрядам

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

Для некоторых размеров файлов, возможно, имеет смысл использовать дополнительную память (которая иначе была бы отведена под вспомогательный массив), чтобы попытаться обойтись всего лишь одним проходом распределяющего подсчета, выполняя переупорядочение на месте. Например, сортировка 1 миллиона 32-разрядных случайных ключей может быть выполнена за один проход распределяющего подсчета по 20 старшим разрядам с последующей сортировкой вставками. Для этого требуется память только для (1 миллиона) счетчиков — значительно меньше, чем нужно для вспомогательного массива. Использование этого метода равносильно использованию стандартной MSD-сортировки при R=220, хотя для сортировки таким методом небольших файлов важно применять небольшие основания системы счисления (см. обсуждение леммы 10.4).

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

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

Алгоритмы общего назначения, такие как быстрая сортировка, нашли более широкое применение, чем поразрядная сортировка, поскольку они могут быть адаптированы к более широкому диапазону приложений. Основная причина такого положения дел заключается в том, что абстракция ключа, на которой построена поразрядная сортировка, обладает меньшей универсальностью, чем та, которая использовалась в главах 6—9 (с функцией сравнения). Например, один из широко распространенных способов взаимодействия со служебной программой сортировки заключается в том, что клиент сам обеспечивает функцию сравнения. Примером может служить интерфейс, используемый программой qsort из библиотеки программ на C++. Это соглашение не только годится в ситуациях, когда клиент может, воспользовавшись специальными сведениями о составных ключах, реализовать быстрое сравнение, но и позволяет выполнять сортировку, используя отношения порядка, которые могут вообще обходиться без ключей. Один такой алгоритм будет рассмотрен в лекция №21.

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

Приведенные относительные временные показатели различных видов поразрядной сортировки случайно упорядоченных файлов из N 32-разрядных чисел (во всех применяется переход на сортировку вставками для N, меньших 16) показывают, что при аккуратном применении поразрядные сортировки входят в число самых быстрых. При использовании очень большого основания системы счисления для маленьких файлов все преимущества MSD-сортировки будут сведены на нет, но выбор основания, меньшего размера файла, использует все ее достоинства. Самым быстрым методом сортировки целых ключей является LSD-сортировка по старшей половине разрядов, быстродействие которой можно повысить еще больше с помощью тщательной оптимизации внутреннего цикла (см. упражнение 10.45).

Таблица 10.1. Эмпирическое сравнение поразрядных сортировок (целочисленные ключи)
NQ4-разрядные байты8-разрядные байты16-разрядные байты
MLMLL*MLM*
12500271128425258
2500051421298454815
5000010494335189581539
100000217792473918673077
200000491331857281392965698
40000010227837758116988119398110297
800000223919732606432820315324922192309
Обозначения:
Q Быстрая сортировка, стандартная (программа 7.1)
M MSD-сортировка, стандартная (программа 10.2)
L LSD-сортировка (программа 10.4)
M* MSD-сортировка, с адаптацией основания системы счисления под размер файла
L* LSD-сортировка по MSD-разрядам.
Таблица 10.2. Эмпирическое сравнение поразрядных сортировок (строковые ключи)
NQTMFRXX*
125007699865
2500014121819151110
5000034263949342524
100000836187114715754
Обозначения:
QБыстрая сортировка, стандартная (программа 7.1)
TБыстрая сортировка с трехпутевым разбиением (программа 7.5)
MСортировка слиянием (программа 8.2)
F Пирамидальная сортировка с усовершенствованием Флойда (см. раздел 9.4 лекция №9)
RMSD-сортировка (программа 10.2)
XТрехпутевая поразрядная быстрая сортировка (программа 10.3)
X*Трехпутевая поразрядная быстрая сортировка (с отсечениями)

Приведенные относительные временные показатели различных сортировок первых N слов из книги " Моби Дик " (во всех применяется переход на сортировку вставками для N, меньших 16) показывают эффективность подхода " сначала MSD " для строковых данных. Отсечение малых подфайлов в трехпутевой поразрядной быстрой сортировке дает меньший выигрыш, чем в других методах, и совсем бесполезно, если не исключить в сортировке вставками просмотр старших разрядов ключей (см. упражнение 10.46).

Упражнения

10.44. Каков основной недостаток выполнения LSD-сортировки по старшим битам ключей с последующей " зачисткой " сортировкой вставками?

10.45. Разработайте реализацию LSD-сортировки по 32-разрядным ключам с минимально возможным количеством инструкций во внутреннем цикле.

10.46. Реализуйте трехпутевую поразрядную быструю сортировку таким образом, чтобы сортировка вставками небольших файлов не выполняла сравнения старших байтов, о которых известно, что они равны.

10.47. Пусть имеется 1 миллион 32-разрядных ключей. Определите такой размер байта, для которого время выполнения сортировки будет минимальным, если используется метод, использующий LSD-сортировку по двум первым байтам с последующей " зачисткой " сортировкой вставками.

10.48. Выполните упражнение 10.47 для 1 миллиарда 64-разрядных ключей.

10.49. Выполните упражнение 10.48 для трехпроходной LSD-сортировки.

Лекция 11. Специальные методы сортировки

Рассмотрены примеры методов сортировки, разработанных для эффективного применения на различных типах машин.

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

Проблема поддержки эффективных методов сортировки рано или поздно встает перед любой новой компьютерной архитектурой. В самом деле, исторически сложилось так, что сортировка служит своеобразным испытательным полигоном при разработке новой архитектуры, поскольку она и практически важна, и легка для понимания. Хочется знать не только то, какой из известных алгоритмов и в силу каких причин выполняется на новой машине лучше других, но также и то, можно ли при разработке нового алгоритма воспользоваться конструктивными особенностями конкретной машины. Чтобы разработать новый алгоритм, мы определяем некоторую абстрактную машину, инкапсулирующую основные свойства реальной машины, разрабатываем и анализируем алгоритмы для этой абстрактной машины, затем реализуем, тестируем и усовершенствуем как сами алгоритмы, так и модель машины. Конечно, при этом мы опираемся на предыдущий опыт, в том числе и множество методов для машин общего назначения, изложенных в главах 6-10, но ограничения абстрактных машин помогают учесть реальные затраты и убедиться в том, что для разных машин годятся различные алгоритмы.

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

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

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

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

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

Нечетно-четная сортировка слиянием Бэтчера

Для начала мы рассмотрим метод сортировки, основанный на всего лишь двух абстрактных операциях: операции сравнения-обмена (compare-exchange) и операции идеального тасования (perfect shuffle) вместе с ее антиподом, операцией идеального обратного тасования (perfect unshuffle). Алгоритм, разработанный Бэтчером в 1968 г., известен как нечетно-четная сортировка слиянием Бэтчера (Batcher’s odd-even mergesort). Реализовать алгоритм, используя операции тасования, сравнения-обмена и двойной рекурсии, несложно; гораздо труднее понять, почему алгоритм работает, и распутать все перетасовки и рекурсии, чтобы понять, как он работает на нижнем уровне.

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

Определение 11.1. Неадаптивный алгоритм сортировки - это алгоритм, в котором последовательность выполняемых операций зависит только от количества входных данных, а не от значений ключей.

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

  compexch(a[0], a[1])
  compexch(a[1], a[2])
  compexch(a[0], a[1])
   

является линейной программой сортировки трех элементов. Для удобства и компактной записи алгоритма используются циклы, тасования и другие операции высокого уровня, однако основная цель при разработке алгоритма состоит в том, чтобы для каждого N определить фиксированную последовательность операций compexch, которая способна выполнить сортировку любого набора из N ключей. Без потери общности можно предположить, что ключи принимают целочисленные значения от 1 до N (см. упражнение 11.4). Чтобы убедиться в том, что линейная программа работает правильно, необходимо доказать, что она сортирует каждую из возможных перестановок этих значений (см., например, упражнение 11.5).

 Идеальное тасование и идеальное обратное тасование


Рис. 11.1.  Идеальное тасование и идеальное обратное тасование

Для выполнения идеального тасования (слева) берется первый элемент файла, затем первый элемент из второй половины файла, затем второй элемент файла, второй элемент из второй половины файла и т.д. Если перенумеровать элементы сверху вниз, начиная с 0, то элементы из первой половины попадут в четные позиции, а элементы из второй половины - в нечетные позиции. Для выполнения идеального обратного тасования (справа) выполняется обратная процедура: элементы из четных позиций попадают в первую половину файла, а элементы из нечетных позиций - во вторую половину.

Лишь немногие из алгоритмов, рассмотренных в главах 6-10, являются неадаптивными: все они используют операцию <, либо как-то по-другому анализируют ключи, после чего выполняют действия в зависимости от значений ключей. Одним из исключений является пузырьковая сортировка (см. лекция №6), использующая только операции сравнения-обмена. Еще одним примером неадаптивного метода сортировки служит версия Пратта сортировки Шелла (см. лекция №6).

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

Программа 11.1. Идеальное тасование и идеальное обратное тасование

Функция shuffle переупорядочивает подмассив a[l], ..., a[r] с помощью деления этого подмассива пополам и чередования элементов из каждой половины массива: элементы из первой половины заносятся в четные позиции результирующего массива, а элементы из второй половины - в нечетные позиции. Функция unshuffle выполняет обратное действие: элементы из четных позиций заносятся в первую половину результирующего массива, а элементы из нечетных позиций - во вторую половину. Мы будем применять эти функции только к подмассивам, содержащим четное число элементов.

  template <class Item>
  void shuffle(Item a[], int l, int r)
 { int i, j, m = (l+r)/2;
   static Item aux[maxN];
   for (i = l, j = 0; i <= r; i += 2, j++)
  { aux[i] = a[l+j]; aux[i+1] = a[m+1+j]; }
   for (i = l; i <= r; i++) a[i] = aux[i];
 }
  template <class Item>
  void unshuffle(Item a[], int l, int r)
 { int i, j, m = (l+r)/2;
   static Item aux[maxN];
   for (i = l, j = 0; i <= r; i += 2, j++)
  { aux[l+j] = a[i]; aux[m+1+j] = a[i+1]; }
   for (i = l; i <= r; i++) a[i] = aux[i];
 }
   

Идеальное обратное тасование выполняет обратную процедуру: карты попеременно откладываются в верхнюю и нижнюю половину колоды.

Сортировка Бэтчера почти точно совпадает с нисходящей сортировкой слиянием из лекция №8; различие состоит лишь в том, что вместо одной из адаптивных реализаций слияния из лекция №8 она использует нечетно-четное слияние Бэтчера - неадаптивное нисходящее рекурсивное слияние. Программа 8.3 сама по себе вообще не обращается к данным, а поскольку используется неадаптивное слияние, то и сама сортировка будет неадаптивной.

На протяжении этого раздела и раздела 11.2 неявно предполагается, что количество сортируемых элементов является степенью 2. Поэтому всегда можно использовать выражение " N/2 " , не опасаясь того, что N - нечетное. Конечно, это предположение практически нереально: рассматриваемые нами программы и примеры работают с файлами любых размеров, однако оно существенно упрощает рассуждения. Мы еще вернемся к этому вопросу в конце раздела 11.2.

Слияние Бэтчера само по себе является рекурсивным методом " разделяй и властвуй " . Для выполнения слияния 1-и-1 нужна только одна операция сравнения-обмена. Во всех прочих случаях, для выполнения слияния N-и-N эта задача сводится обратным тасованием к двум задачам слияния N/2-и-N/2, после рекурсивного решения которых получаются два отсортированных файла. Тасованием этих файлов получается почти отсортированный файл; все, что теперь нужно - это один проход для выполнения N/2 - 1 независимых операций сравнения-обмена между элементами 2i и 2i + 1, для i от 1 до N/2 - 1. Соответствующий пример показан на рис. 11.2. Из этого описания непосредственно следует программа 11.2.

Программа 11.2. Нечетно-четное слияние Бэтчера (рекурсивная версия)

Данная рекурсивная программа реализует абстрактное обменное слияние, используя для этой цели операции shuffle и unshuffle из программы 11.1, хотя это и не обязательно - программа 11.3 представляет собой нерекурсивную версию данной программы, в которой тасование не используется. Самое интересное здесь то, что если размер файла является степенью 2, то эта реализация является компактным описанием алгоритма Бэтчера.

  template <class Item>
  void merge(Item a[], int l, int m, int r)
 { if (r == l+1) compexch(a[l], a[r]);
   if (r < l+2) return;
   unshuffle(a, l, r);
   merge(a, l, (l+m)/2, m);
   merge(a, m+1, (m+1+r)/2, r);
   shuffle(a, l, r);
   for (int i = l+1; i < r; i += 2)
  compexch(a[i], a[i+1]);
 }
   

 Пример выполнения нисходящего нечетночетного слияния Бэтчера


Рис. 11.2.  Пример выполнения нисходящего нечетночетного слияния Бэтчера

Чтобы слить ключи A G I N O R S T с ключами A E E L M P X Y, мы начинаем с выполнения операции обратного тасования, порождающей две независимые задачи слияния наполовину меньших массивов (показаны во второй строке): теперь нужно слить A I O S с A E M X (в первой половине массива) и G N R T с E L P Y (во второй половине массива). После рекурсивного решения этих подзадач мы тасуем результирующие массивы, (показаны в предпоследней строке) и завершаем сортировку выполнением операций сравнения-обмена E с A, G с E, L с I, N с M, P с O, R с S и T с X.

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

Лемма 11.1. (Принцип нулей и единиц.) Если неадаптивная программа выдает отсортированный результат для входных данных, состоящих только из 0 и 1, то она праавильно выполнит сортировку входных данных с произвольными ключами.

См. упражнение 11.7.

Лемма 11.2. Нечетно-четное слияние Бэтчера (программа 11.2) правильно выполняет слияние.

Используя принцип нулей и единиц, достаточно проверить, правильно ли выполняются слияния, когда входными ключами являются только нули и единицы. Предположим, что в первом подфай-ле содержатся i нулей, а во втором подфайле - j нулей. Для доказательства этого свойства нужно рассмотреть четыре случая, в зависимости от того, являются ли i и j четными или нечетными числами. Если обе они четные, то одна подзадача слияния выполняется над файлом с i/2 нулями, а другая - над файлом с j/2 нулями, и вместе получается (i + j) / 2 нулей. После тасования получится сортированный файл типа 0-1.

Файл типа 0-1 также будет отсортирован после тасования и в тех случаях, когда i четно, а j нечетно, и когда i нечетно, а j четно. Но если и i, и j нечетны, то в завершение тасуется файл, содержащий (i + j) / 2 + 1 нулей, с файлом, содержащим (i + j) / 2 - 1 нулей, поэтому полученный после тасования файл содержит i + j - 1 нулей, единицу, ноль и N- i -j - 1 единиц (см. рис. 11.3), и на завершающем этапе один из компараторов заканчивает сортировку.

На самом деле нет необходимости тасовать данные. И действительно, программы 11.2 и 8.3 могут быть переделаны таким образом, чтобы получилась линейная сортирующая программа для любого N - для этого необходимо скорректировать реализации compexch и shuffle, чтобы они поддерживали индексы и косвенный доступ к данным (см. упражнение 11.12). Либо можно сделать так, чтобы программа генерировала последовательность команд сравнения-обмена, которую можно применить к исходному входному файлу (см. упражнение 11.13). Эти приемы можно использовать для любого неадаптивного метода сортировки, переупорядочивающего данные при помощи операций обмена, тасования или им подобных. Что касается слияния Бэтчера, то структура этого алгоритма настолько проста, что, как будет показано в разделе 11.2, для него можно непосредственно разработать восходящую реализацию.

 Четыре случая слияния типа 0-1


Рис. 11.3.  Четыре случая слияния типа 0-1

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

Упражнения

11.1. Приведите результат тасования и обратного тасования ключей E A S Y Q U E S T I O N.

11.2. Обобщите программу 11.1 для реализации h-путевых тасования и обратного тасования. Обеспечьте работоспособность примененной стратегии для случая, если размер файла не кратен h.

11.3. Реализуйте операции тасования и обратного тасования без использования вспомогательного массива.

11.4. Покажите, что линейная программа, которая сортирует N различных ключей, сможет отсортировать и N не обязательно различных ключей.

11.5. Покажите, как линейная программа, приведенная в тексте, сортирует каждую из шести перестановок чисел 1, 2 и 3.

11.6. Приведите пример линейной программы, которая выполняет сортировку четырех элементов.

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

11.8. Покажите, в стиле диаграммы на рис.11.2, как программа 11.2 выполняет слияние ключей A E Q S U Y E I N O S T.

11.9. Выполните упражнение 11.8 для ключей A E S Y E I N O Q S T U.

11.10. Выполните упражнение 11.8 для ключей 1 0 0 1 1 1 0 0 0 0 0 1 0 1 0 0.

11.11. Эмпирически сравните время выполнения сортировки слиянием Бэтчера с временем выполнения стандартной нисходящей сортировки слиянием (программы 8.3 и 8.2) для N = 103, 104, 105 и 106 .

11.12. Приведите такие реализации функций compexch, shuffle и unshuffle, что программы 11.2 и 8.3 будут работать в режиме косвенной сортировки (см. лекция №6).

11.13. Приведите такие реализации функций compexch, shuffle и unshuffle, что программы 11.2 и 8.3 будут выводить для заданного N линейную программу сортировки N элементов. Для отслеживания значений индексов можно воспользоваться вспомогательным глобальным массивом.

11.14. Если переставить элементы второго сливаемого файла в обратном порядке, получится битоническая последовательность (см. определение в лекция №8). После изменения заключительного цикла программы 11.2 так, чтобы он начинался с l, а не с l+1, получится программа, сортирующая битонические последовательности. Покажите, в стиле диаграммы на рис. 11.2, как с помощью этого метода выполняется слияние ключей A E S Q U Y T S O N I E.

11.15. Докажите, что модификация программы 11.2, описанная в упражнении 11.14, способна отсортировать любую битоническую последовательность.

Сортирующие сети

Простейшей моделью для изучения неадаптивных алгоритмов сортировки является абстрактная машина, которая обращается к данным только с помощью операций сравнения-обмена. Такая машина называется сортирующей сетью (sorting network). Сортирующая сеть построена из простых неделимых модулей сравнения-обмена (compare-exchange module), или компараторов (comparator), соединенных между собой таким образом, что возможно выполнение полной сортировки общего вида.

 Сортирующая сеть


Рис. 11.4.  Сортирующая сеть

Ключи перемещаются по линиям сортирующей сети слева направо. По пути им встречаются компараторы, которые при необходимости обменивают ключи, в результате чего меньший из двух ключей поднимается на верхнюю линию. В данном примере на двух верхних линиях обмениваются ключи B и C, потом на двух нижних линиях обмениваются A и D, затем обмениваются A и B и т.д., и в конце ключи оказываются упорядоченными сверху вниз. В этом примере обмен ключей выполняют все компараторы, за исключением четвертого. Такая сеть способна отсортировать любые перестановки из четырех ключей.

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

До построения реальной сортирующей машины, работающей по этой схеме, нужно решить ряд вопросов. Например, не описан метод кодирования входных данных. Один из способов - рассматривать каждую линию связи на рис. 11.4 как группу линий, передающих по одному биту данных, так что все биты ключа распространяются по линии одновременно. Другой подход заключается в том, что данные поступают на входы компараторов по одной линии бит за битом (сначала наиболее значащие биты). Кроме того, совершенно не затронут вопрос синхронизации: должны быть предусмотрены механизмы, препятствующие срабатыванию компараторов до готовности входных данных. Сортирующие сети - это полезные абстракции, поскольку они позволяют нам отделять детали реализации от проектных решений более высокого уровня наподобие минимизации количества компараторов. Более того, как мы убедимся в разделе 11.5, абстракция сортирующей сети может быть полезной и в приложениях, отличных от построения непосредственно электронных схем.

Еще одним важным применением сортирующих сетей является использование их в качестве модели параллельных вычислений. Если два компаратора не используют для ввода данных одни и те же линии, то, очевидно, они могут работать одновременно. Например, сеть, изображенная на рис. 11.4, показывает, что четыре элемента могут быть отсортированы за три параллельных шага. На первом шаге могут одновременно работать компаратор 0-1 и компаратор 2-3, после чего на втором шаге могут одновременно работать компаратор 0-2 и компаратор 1-3, и на третьем шаге компаратор 2-3 завершает сортировку. Для любой заданной сети компараторы нетрудно сгруппировать в последовательность параллельных каскадов (parallel stage), состоящих из компараторов, которые могут работать одновременно (см. упражнение 11.17). Для наибольшей эффективности параллельных вычислений нужно разрабатывать сети с минимально возможным числом параллельных каскадов.

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

 Сети нечетно-четного слияния Бэтчера


Рис. 11.5.  Сети нечетно-четного слияния Бэтчера

Показанные на этом рисунке различные представления сетей на четыре (вверху), восемь (в центре) и 16 (внизу) линий демонстрируют рекурсивную структуру, на которой основаны сети. Слева показаны непосредственные представления построения сети размера N с помощью двух копий сетей размера N/2 (одна для линий с четными номерами, другая для линий с нечетными номерами) плюс каскад компараторов между линиями 1 и 2, 3 и 4, 5 и 6 и т.д. Справа показаны более простые сети, полученные из сетей, изображенных слева, путем группирования компараторов одинаковой длины. Такое группирование возможно потому, что компараторы на нечетных линиях можно перемещать независимо от компараторов на нечетных линиях.

Программа 11.3. Нечетно-четное слияние Бэтчера (нерекурсивная версия)

Данная реализация нечетно-четного слияния Бэтчера (в которой предполагается, что размер файла N является степенью 2) компактна, но непонятна. Понять, как она выполняет слияние, можно, сравнив ее с рекурсивной версией (см. программу 11.2 и рис. 11.5). Она совершает слияние за lgN проходов, состоящих из единообразных и независимых инструкций сравнения-обмена.

  template <class Item>
  void merge(Item a[], int l, int m, int r)
 { int N = r-l+1; // предполагается,
   // что N/2 равно m-l+1
   for (int k = N/2; k > 0; k /= 2)
  for (int j = k % (N/2); j+k < N; j += k+k)
    for (int i = 0; i < k; i++)
   compexch(a[l+j+i], a[l+j+i+k]);
 }
   

Для создания сливающей сети размера N мы воспользуемся двумя копиями сети размером N/2: одна для линий с четными номерами, а другая - с нечетными. Поскольку эти два множества компараторов не пересекаются, их можно распределить таким образом, чтобы обе сети чередовались. И в завершение мы расставим компараторы между линиями 1 и 2, 3 и 4 и т.д. Чередование нечетных и четных линий заменяет идеальное тасование из программы 11.2. Доказательство того, что эти сети выполняют слияние правильно, аналогично доказательствам свойств 11.1 и 11.2 с помощью принципа нулей и единиц. На рис. 11.6 показан пример выполнения такого слияния.

 Пример восходящего слияния Бэтчера


Рис. 11.6.  Пример восходящего слияния Бэтчера

Если удалить все операции тасования, то для выполнения слияния Бэтчера в данном примере требуется 25 изображенных здесь операций слияния-обмена. Их можно разбить на четыре фазы выполнения независимых операций сравнения-обмена с фиксированным сдвигом каждой фазы.

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

На рис. 11.7 показана нечетно-четная сортирующая сеть Бэтчера, построенная на основе сетей слияния, которые показаны на рис. 11.5, с помощью стандартного построения рекурсивной сортировки слиянием. Все построение дважды рекурсивно: один раз для сливающих сетей, другой - для сортирующих сетей. Они не оптимальны (мы вскоре рассмотрим оптимальные сети), но тем не менее они эффективны.

 Нечетно-четные сортирующие сети Бэтчера


Рис. 11.7.  Нечетно-четные сортирующие сети Бэтчера

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

Лемма 11.3. Нечетно-четные сортирующие сети Бэтчера используют порядка N (lgN)2 / 4 компараторов и могут выполнить сортировку за (lgN)2 / 2 параллельных шагов.

Сливающие сети требуют выполнения порядка lgN параллельных шагов, а сортирующим сетям нужно 1 + 2 + ... + lgN, или порядка (lgN)2 / 2 , параллельных шагов. Подсчет компараторов оставлен читателю в качестве упражнения (см. упражнение 11.23).

Использование функции слияния из программы 11.3 в стандартной рекурсивной сортировке слиянием, представленной программой 8.3, дает компактный обменный метод сортировки, который является неадаптивным и использует O (N (lgN)2) операций сравнения-обмена. С другой стороны, из сортировки слиянием можно удалить рекурсию и непосредственно реализовать полную восходящую версию, как показано в программе 11.4. Как и в случае программы 11.3, эту программу легче понять, если рассматривать ее как альтернативное представление сети, показанной на рис. 11.7.

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

Программа 11.4. Нечетно-четная сортировка Бэтчера (нерекурсивная версия)

Данная реализация нечетно-четной сортировки Бэтчера непосредственно соответствует представлению сети на рис. 11.7. Она разбивается на фазы, индексируемые переменной p. Последняя фаза, где p равно N, является нечетно-четным слиянием Бэтчера. Предпоследняя фаза, где p равно N/2, является нечетно-четным слиянием с первым каскадом, в котором удалены компараторы, пересекающие N/2; третья с конца фаза, где p равно N/4, является нечетно-четным слиянием с двумя первыми каскадами, в котором удалены компараторы, пересекающие N/4, и т.п.

  template <class Item>
  void batchersort(Item a[], int l, int r)
 { int N = r-l+1;
   for (int p = 1; p < N; p += p)
  for (int k = p; k > 0; k /= 2)
    for (int j = k%p; j+k < N; j += (k+k))
   for (int i = 0; i < N-j-k; i++)
     if ((j+i)/(p+p) == (j+i+k)/(p+p))
    compexch(a[l+j+i], a[l+j+i+k]);
 }
   

Как это часто бывает с методами " разделяй и властвуй " , в случае, когда N не равно степени 2, имеется две возможности (см. упражнения 11.24 и 11.21). Можно поделить файл пополам (нисходящий вариант), либо поделить по максимальной степени 2, меньшей N (восходящий вариант). Последний вариант для сортирующих сетей несколько удобнее, поскольку он эквивалентен построению полной сети для минимальной степени 2, большей или равной N, с последующим использованием только первых N линий и компараторов, подключенных к этим линиям обоими концами. Доказать, что это построение корректно, довольно просто. Предположим, что на неиспользуемые линии поданы сигнальные ключи, которые больше любых других ключей сети. Тогда компараторы на этих линиях никогда не производят операций обмена, и их можно спокойно удалить. Вообще-то можно воспользоваться любым набором из N смежных линий большей сети: достаточно считать, что на игнорируемых линиях в верхней части имеются сигнальные ключи с малыми значениями, а на игнорируемых линиях в нижней части - сигнальные ключи с большими значениями. Все эти сети содержат порядка N (lgN)2 / 4 компараторов.

Теория сортирующих сетей развивалась достаточно интересно (см. раздел ссылок). Задача построения сетей с минимально возможным числом компараторов была поставлена Бозе (Bose) еще до 1960 г., впоследствии она получила название задачи Бозе-Нельсона (Bose-Nelson). Сети Бэтчера были первым достаточно приемлемым решением этой задачи, и некоторое время даже считалось, что сети Бэтчера оптимальны. Сливающие сети Бэтчера оптимальны, и поэтому любая сортирующая сеть с существенно меньшим числом компараторов может быть построена только с помощью подхода, отличного от рекурсивной сортировки слиянием. Задача нахождения оптимальных сортирующих сетей не исследовалась до 1983 г., когда Аджтай (Ajtai), Комлос (Komlos) и Шемереди (Szemeredy) доказали существование сетей с O (Nlog N) компараторами. Однако сети AKS (Ajtai-Kolmos-Szemeredy) - это всего лишь математические построения, не имеющие практического применения, и сети Бэтчера все еще входят в число наиболее подходящих практических методов.

Связь между идеальным тасованием и сетями Бэтчера позволяет удивительным образом завершить рассмотрение сортирующих сетей анализом еще одной версии рассматриваемого алгоритма. Если перетасовать линии нечетно-четного слияния Бэтчера, то получатся сети, в которых все компараторы соединяют смежные линии. На рис. 11.8 показана сеть, которая соответствует реализации тасования, приведенной в программе 11.2. Такое переплетение соединений иногда называется сеть-бабочка (butterfly network). На этом рисунке дано еще одно представление той же линейной программы, обеспечивающее еще более однотипное переплетение; в нем используются только операции полного тасования.

 Тасование в нечетно-четном слиянии Бэтчера


Рис. 11.8.  Тасование в нечетно-четном слиянии Бэтчера

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

На рис. 11.9 показана еще одна интерпретация рассматриваемого метода, которая вскрывает его базовую структуру.

 Слияние методом разбиения и чередования


Рис. 11.9.  Слияние методом разбиения и чередования

Начав с двух отсортированных файлов в первом ряду, мы сливаем их с помощью повторения следующей операции:разбиваем каждый ряд на две равные части, чередуем полученные половины (слева) и выполняем операции сравнения-обмена над смежными по вертикали элементами из разных рядов (справа). Сначала было 16 столбцов и 1 ряд, затем 8 столбцов и 2 ряда, 4 столбца и 4 ряда, 2 столбца и 8 рядов и, наконец, 16 рядов и один столбец, который к этому моменту отсортирован.

Сначала мы записываем один файл под другим, далее сравниваем смежные по вертикали элементы и при необходимости выполняем их обмен так, чтобы меньший элемент находился выше большего. Затем мы делим каждый ряд на две равные части и чередуем половины этих рядов, после чего выполняем те же операции сравнения-обмена над данными во второй и третьей строках. В сравнении других пар рядов нет необходимости, поскольку они уже были отсортированы. Операция разбиения-чередования сохраняет упорядочение как рядов, так и столбцов. Эта операция сохраняет упорядоченность и всего файла: каждый шаг удваивает число рядов, сокращает наполовину число столбцов и сохраняет упорядоченность рядов, и в конечном итоге получается один полностью отсортированный столбец из N рядов. Связь между таблицей, представленной на рис. 11.9, и сетью, изображенной в нижней части рис. 11.8, заключается в том, что если развернуть таблицы по столбцам (за элементами первого столбца следуют элементы второго столбца и т.д.) то мы увидим, что преобразование, необходимое для перехода с одного шага на другой, и есть идеальное тасование.

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

На рис. 11.11 показаны динамические характеристики как восходящего метода, так и версии нечетно-четного слияния Бэтчера с полным тасованием.

Тасование - это важная абстракция, описывающая движение данных в алгоритмах " разделяй и властвуй " , и она возникает в различных задачах, не связанных с сортировкой. Например, если квадратная матрица размером 2n х 2n хранится в памяти по строкам, то n идеальных тасований транспонируют эту матрицу (преобразуют в упорядоченную по столбцам). Более важные примеры - быстрые преобразования Фурье и вычисление полиномов (см. часть 8). Каждая из этих задач может быть решена при помощи циклической машины идеального тасования, подобной показанной на рис. 11.10, но с более мощными процессорами. Можно даже обдумать вариант с использованием универсальных процессоров, способных выполнять прямое и обратное тасование (некоторые из машин этого типа были даже реально построены); к таким параллельным машинам мы еще вернемся в разделе 11.5.

 Машина идеального тасования


Рис. 11.10.  Машина идеального тасования

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

 Динамические характеристики нечетночетного слияния


Рис. 11.11.  Динамические характеристики нечетночетного слияния

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

Упражнения

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

11.17. Напишите программу для подсчета количества параллельных шагов, необходимых для выполнения любой линейной программы. Совет: Воспользуйтесь следующей стратегией присвоения меток. Пометьте входные линии как принадлежащие к каскаду 0, затем для каждого компаратора выполните следующие действия: пометьте обе выходные линии как входные для каскада i + 1, если метка одной из входных линий равна i, а метка другой линии не больше чем i.

11.18. Сравните время выполнения программы 11.4 с временем выполнения программы 8.3 для случайно упорядоченных ключей при N = 103, 104, 105 и 106.

11.19. Начертите сеть Бэтчера для выполнения слияния 10-и-11.

11.20. Докажите существование зависимости между рекурсивным тасованием и обратным тасованием (см. рис. 11.8).

11.21. Из изложенного в тексте следует, что на рис. 11.7 неявно представлено 11 сетей для упорядочения 21 элемента. Начертите ту из них, которая содержит минимальное количество компараторов.

11.22. Приведите количество компараторов в нечетно-четных сортирующих сетях Бэтчера для , если при N, не равном степени 2, сети строятся из первых N линий сети, построенной для наименьшей степени 2, большей N.

11.23. Выведите точное выражение для количества компараторов, используемых в нечетно-четных сетях сортировки Бэтчера при N = 2n . Совет: Сверьте свой ответ с рис. 11.7, на котором показано, что для N, равного 2, 4, 8, 16 и 32, в сетях имеется, соответственно, 1, 3, 9, 25 и 65 компараторов.

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

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

11.26. Найдите 16-линейную сортирующую сеть, которая использует меньшее число компараторов, чем сеть Бэтчера.

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

11.28. Начертите сортирующие сети, соответствующие сортировке Шелла с последовательностью шагов Пратта (лекция №6) для N = 32.

11.29. Приведите таблицу, содержащую количество компараторов в сетях, описанных в упражнении 11.28, и количество компараторов в сетях Бэтчера для N = 16, 32, 64, 128 и 256.

11.30. Создайте сортирующие сети, способные выполнять сортировку 3-упорядочен-ных и 4-упорядоченных файлов из N элементов.

11.31. Воспользуйтесь сетями из упражнения 11.30 для разработки схемы, подобной алгоритму Пратта, с использованием множителей 3 и 4. Начертите полученную сеть для N = 32 и решите упражнение 11.29 применительно к этой сети.

11.32. Начертите версию нечетно-четной сортирующей сети Бэтчера для N = 16, в которой между каскадами независимых компараторов, соединяющих смежные линии, выполняются идеальные тасования (четырьмя последними каскадами этой сети должны быть каскады из сети слияния в нижней части рис.11.8).

11.33. Напишите программу слияния для машины, которая изображена на рис. 11.10, соблюдая следующие соглашения. Каждая инструкция является последовательностью из 15 битов, где i-й бит, при , показывает (если он равен 1), что процессор i и процессор i - 1 должны выполнить операцию сравнения-обмена. Программа представляет собой последовательность инструкций; между каждыми двумя инструкциями машина выполняет идеальное тасование.

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

Внешняя сортировка

Теперь мы переходим к рассмотрению другой абстрактной задачи сортировки. Она возникает, когда сортируемый файл настолько велик, что не помещается целиком в оперативной памяти компьютера. Для описания таких ситуаций используется термин внешняя сортировка (external sorting). Существует множество различных видов устройств внешней сортировки, которые накладывают различные ограничения на элементарные операции, применяемые при их реализации. Все же будет полезно изучить методы сортировки, использующие две примитивные базовые операции: операцию считать данные из внешнего запоминающего устройства в оперативную память и операцию записать данные из оперативной памяти на внешнее запоминающее устройство. Предполагается, что стоимость этих двух операций настолько выше стоимости примитивных вычислительных операций, что последние можно полностью игнорировать. Например, в этой абстрактной модели полностью игнорируются затраты на сортировку в оперативной памяти! При очень большой оперативной памяти и неэффективных методах сортировки это предположение может оказаться неверным, но при расчете общей стоимости внешней сортировки в практических ситуациях эти затраты обычно можно учесть дополнительными коэффициентами.

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

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

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

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

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

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

Пометим внешнее устройство, на котором находится файл входных данных, меткой 0, а остальные внешние устройства - метками 1, 2, ..., 2P - 1. Цель сортировки заключается в том, чтобы поместить записи на устройство 0 в отсортированном виде. Как мы вскоре увидим, существует зависимость между P и общим временем выполнения сортировки, и было бы хорошо получить явное выражение этой зависимости, чтобы иметь возможность сравнивать различные стратегии.

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

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

Простейшая стратегия сортировки-слияния, называемая сбалансированное многопутевое слияние (balanced multiway merging), показана на рис. 11.12. Этот метод состоит из прохода, выполняющего начальное распределение (initial distribution), за которым следуют несколько проходов многопутевого слияния (multiway merging pass).

Во время начального прохода входные данные распределяются по внешним устройствам P, P + 1, ..., 2P - 1 в виде отсортированных блоков данных по M записей (за исключением, возможно, последнего блока, который меньше остальных, если N не кратно M). Такое распределение нетрудно выполнить: мы считываем первые M записей с входного устройства, сортируем их и записываем упорядоченный блок на устройство P ; затем считываем с входного устройства следующие M записей, сортируем их и записываем упорядоченный блок на устройство P + 1 и т.д. Если мы достигли устройства 2P - 1, и еще есть необработанные данные (т.е. если N > PM), мы записываем на устройство P второй отсортированный блок, затем второй блок на устройство P + 1 и т.д., до исчерпания всех входных данных. По завершении распределения количество отсортированных блоков, размещенных на каждом устройстве, равно N/PM, округленному до ближайшего целого числа в ту или другую сторону. Если N кратно M, то размеры всех блоков равны M (если это не так, то размер M имеют все блоки, за исключением последнего). Для небольших N количество блоков может оказаться меньше P, тогда одно или несколько устройств будут пустыми.

На первом проходе многопутевого слияния устройства в интервале от P до 2P - 1 рассматриваются как входные, а устройства в интервале от 0 до P - 1 как выходные.

 Пример трехпутевого сбалансированного слияния


Рис. 11.12.  Пример трехпутевого сбалансированного слияния

На проходе начального распределения мы выбираем из входных данных элементы A S O, сортируем их и записываем упорядоченную последовательность A O S на первое выходное устройство. Далее мы выбираем из входных данных элементы R T I, сортируем их и записываем упорядоченную последовательность I R T на второе выходное устройство. Продолжая таким образом и циклически переключаясь между выходными устройствами, мы окончательно получаем 15 отрезков: по пять на каждом выходном устройстве. На первом этапе слияния сливаются отрезки A O S, I R T и A G N, и получается последовательность A A G I N O R S T, которую мы записываем на первое выходное устройство, затем выполняется слияние вторых отрезков на входных устройствах, и получается последовательность D E G G I M N N R, которую мы записываем на второе выходное устройство и т.д.; теперь получилось сбалансированное распределение данных на трех устройствах. После еще двух проходов слияния сортировка завершается.

Мы выполняем P-путевое слияние блоков данных размером M, помещенных на входные устройства, и получаем отсортированные блоки данных размером PM, которые максимально сбалансированно размещаем на выходных устройствах. Сначала сливаются первые блоки с каждого входного устройства, а результат этого слияния записывается на устройство 0, затем сливаются вторые блоки с каждого входного устройства и записываются на устройство 1 и т.д. После использования устройства P - 1 вторые блоки данных записываются на устройство 0, затем на устройство 1 и т.д. Этот процесс продолжается до тех пор, пока все входные данные не будут исчерпаны. После распределения количество отсортированных блоков на каждом устройстве равно N/PM, округленному до ближайшего целого числа в ту или другую сторону. Если N кратно PM, то все блоки имеют размер PM, иначе последний блок меньше остальных. Если N не больше PM, то остается один отсортированный блок (на устройстве 0), и на этом сортировка заканчивается.

В противном случае мы повторяем этот процесс и выполняем второй проход многопутевого слияния, рассматривая устройства 0, 1, ..., P - 1 в качестве входных, а устройства P, P + 1, ..., 2P - 1 в качестве выходных. В результате P-путевого слияния отсортированных блоков размером PM, размещенных на входных устройствах, получаются блоки размером P2M, которые размещаются на выходных устройствах. Сортировка заканчивается по завершении второго прохода (результат на устройстве P), если N не больше P2M.

Продолжая таким образом и переключаясь между устройствами от 0 до P - 1 и устройствами от P до 2P - 1, каждое P-путевое слияние увеличивает размер блоков в P раз, пока в конечном итоге не получится один блок, на устройстве 0 или на устройстве P. Последнее слияние на каждом проходе может не быть полным P-путевым слиянием, если это не так, то процесс хорошо сбалансирован. Этот процесс проиллюстрирован на рис. 11.13, где указаны только количество и относительные размеры отрезков. Оценку стоимости слияния можно получить, выполнив умножения, указанные в представленной на рисунке таблице, просуммировав результаты (без нижней строки) и поделив сумму на число первоначальных отрезков. Эти вычисления выражают затраты через количество проходов по данным.

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



Рис. 11.13. 

Распределение отрезков в сбалансированном 3-путевом слиянии

В процессе начального распределения сбалансированной трехпутевой сортировки-слияния файла, размер которого в 15раз больше размера оперативной памяти, на устройства 3, 4 и 5 записываются по 5 отрезков, размер которых в относительных единицах равен 1, а устройства 0, 1 и 2 остаются пустыми. На первом этапе слияния два отрезка размером 3 записываются на устройства 0 и 1, и один отрезок размером 3 на устройство 2, после чего устройства 3, 4 и 5 остаются пустыми. Далее выполняется слияние отрезков, находящихся на устройствах 0, 1 и 2, а результаты записываются снова на устройства 3, 4 и 5 и т.д., пока не останется только один отрезок на устройстве 0. Общее количество обработанных записей равно 60: четыре прохода по 15 записям.

Лемма 11.4. При наличии 2P внешних устройств и оперативной памяти, достаточной для размещения Mзаписей, сортировка-слияние на основе P-путевого сбалансированного слияния выполняет порядка проходов.

Один проход нужен для распределения. Если N = MPk , то блоки имеют размер MP после первого слияния, MP2 после второго, MP3 после третьего и т.д. Сортировка заканчивается после проходов. В противном случае, если MPk-1k < N < MPk , наличие неполных и пустых блоков приводит ближе к концу процесса к появлению различий в размерах блоков, но он все равно завершается после выполнения проходов.

Например, если нужно отсортировать 1 миллиард записей, используя шесть внешних устройств и оперативную память, позволяющую разместить 1 миллион записей, это можно сделать с помощью трехпутевой сортировки-слияния, выполнив всего восемь проходов по данным: один проход начального распределения и проходов слияния. После начального распределения получаются отсортированные отрезки данных, содержащие по 1 миллиону записей, после первого слияния - по 3 миллиона записей, после второго слияния - по 9 миллионов записей, после третьего слияния - по 27 миллионов записей в каждом блоке и т.д. Можно подсчитать, что на сортировку файла уйдет в 9 раз больше времени, чем на его копирование.

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

Упражнения

11.35. В стиле примера, представленного на рис. 11.12, покажите, как ключи E A S Y Q U E S T I O N W I T H P L E N T Y O F K E Y S сортируются с помощью 3-путе-вого сбалансированного слияния.

11.36. Как отразится на количестве проходов многопутевого слияния удвоение количества внешних устройств?

11.37. Как отразится на количестве проходов многопутевого слияния увеличение объема доступной оперативной памяти в 10 раз?

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

11.39. Пользуясь интерфейсом из упражнения 11.38, напишите программу обращения порядка записей в файле максимально допустимого в вашей системе размера.

11.40. Как выполнить идеальное тасование всех записей на внешнем устройстве?

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

11.42. Разработайте способ внешней сортировки, основанный на разбиении, подобном используемому в быстрой сортировке или MSD-сортировке, проанализируйте его и сравните с многопутевой сортировкой. Можно использовать высокий уровень абстракции, как при описании сортировки-слияния в этом разделе, однако нужно стремиться к тому, чтобы можно было спрогнозировать время выполнения для заданного числа устройств и заданного объема оперативной памяти.

11.43. Как отсортировать содержимое внешнего устройства, если недоступны любые другие внешние устройства (кроме оперативной памяти)?

11.44. Как отсортировать содержимое внешнего устройства, если доступно лишь еще одно устройство (а также оперативная память)?

Реализации сортировки-слияния

Общая стратегия сортировки-слияния, описанная в разделе 11.13, доказала свою эффективность на практике. В данном разделе мы рассмотрим два усовершенствования этой стратегии, позволяющих снизить затраты. Первое из них, метод выборки с замещением (replacement selection), оказывает на время выполнения тот же эффект, что и увеличение объема используемой оперативной памяти; следующий метод, полифазное слияние (polyphase merging), дает тот же эффект, что и увеличение числа используемых устройств.

В разделе 11.3 обсуждалось применение очереди с приоритетами для P-путевого слияния, но при этом было сказано, что P настолько мало, что повышение быстродействия очереди практически ничего не дает. Однако на этапе начального распределения можно воспользоваться быстрыми очередями с приоритетами, чтобы получить отсортированные отрезки, размеры которых больше объема оперативной памяти. Идея заключается в том, чтобы пропустить (неупорядоченные) входные данные через большую очередь с приоритетами - как и раньше, выбирая из очереди с приоритетами наименьший элемент и заменяя его следующим элементом из входных данных, с одним дополнительным условием: если новый элемент меньше, чем только что выбранный из очереди, то, поскольку он может и не стать частью текущего сортируемого блока, мы помечаем его как принадлежащий следующему блоку и считаем, что он больше всех остальных элементов текущего блока. Когда помеченный элемент доходит до вершины очереди с приоритетами, мы начинаем новый блок. Работа этого метода показана на рис. 11.14.

 Выбор с замещением


Рис. 11.14.  Выбор с замещением

Эта последовательность показывает, как из последовательности A S O R T I N G E X A M P L E можно получить два отрезка данных - A I N O R S T X и A E E G L M P - длиной соответственно 8 и 7, используя для этой цели сортирующее дерево размером 5.

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

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

Для случайно упорядоченных файлов практический результат применения выборки с замещением состоит в возможном уменьшении числа проходов на один: вместо того, чтобы начать с отсортированных отрезков с размером, примерно равным объему оперативной памяти, мы можем начать с отрезков в два раза большего размера. Для P = 2 такая стратегия экономит точно один проход слияния, для больших значений P эффект менее заметен. Однако мы знаем, что на практике сортировка случайно упорядоченных файлов встречается редко, и при наличии некоторой упорядоченности использование выборки с замещением может дать отрезки очень больших размеров. Например, если ни одному из ключей в файле не предшествуют более M больших его ключей, то этот файл может быть полностью отсортирован во время прохода выборки с замещением, и слияние вообще не понадобится! Эта возможность служит наиболее веским аргументом в пользу практического применения выборки с замещением.

Главным недостатком сбалансированной многопутевой сортировки является то, что во время слияний активно используется лишь примерно половина внешних устройств: P входных устройств и устройство, на которое записываются выходные данные. Альтернативой этому является выполнение (2P - 1)-путевых слияний с записью на устройство 0 и с распределением данных на другие устройства после каждого прохода слияния. Но этот способ не повышает эффективность, поскольку он удваивает количество проходов распределения данных. Сбалансированное многопутевое слияние, похоже, требует либо дополнительного числа внешних устройств, либо выполнения дополнительных копирований. Разработано несколько хитроумных алгоритмов, которые обеспечивают занятость всех внешних устройств с помощью замены способа, которым сливаются небольшие отсортированные блоки. Простейший из этих методов называется полифазное слияние.

Идея, лежащая в основе полифазного слияния, заключается в неравномерном распределении упорядоченных блоков, которые получаются в результате выборки с замещением, на доступные ленты (оставляя одну ленту пустой) с последующим применением стратегии " сливать до исчерпания " (merge-until-empty): поскольку сливаемые отрезки неодинаковы по длине, одна лента закончится раньше остальных, после чего она может быть использована как выходная. То есть мы меняем ролями выходную ленту (на которой размещено некоторое количество отсортированных блоков) и исчерпанную входную ленту, и продолжаем этот процесс, пока не останется только один блок. Соответствующий пример показан на рис. 11.15.

Стратегия " сливать до исчерпания " работает для произвольного количества лент, как показано на рис. 11.16.

 Пример полифазного слияния


Рис. 11.15.  Пример полифазного слияния

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

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

В примере, представленном на рис. 11.16, мы рассуждаем следующим образом: нужно закончить слияние с 1 отрезком на устройстве 0. Следовательно, перед последним слиянием устройство 0 должно быть свободным, а на устройствах 1, 2 и 3 должно быть по одному отрезку. Далее мы определяем, каким должно быть распределение отрезков до выполнения предпоследнего слияния, чтобы получить требуемое распределение. Одно из устройств 1, 2 или 3 должно быть пустым (чтобы его можно было использовать в качестве выходного для предпоследнего слияния) - пусть это будет устройство 3. То есть предпоследнее слияние сливает по одному отрезку с каждого из устройств 0, 1 и 2 и записывает результат на устройство 3. Поскольку предпоследнее слияние оставляет 0 отрезков на устройствах 0 и 1, оно должно начаться с одним отрезком на устройстве 0 и двумя отрезками на каждом из устройств 1 и 2. Аналогичные рассуждения приводят нас к заключению, что слияние, предшествующее только что рассмотренному, должно начинаться с 2, 3 и 4 отрезками на устройствах 3, 0 и 1 соответственно. Продолжая в том же духе, можно построить таблицу распределения отрезков: для получения очередного ряда выбираем из следующего за ним ряда максимальное число, заменяем его нулем и добавляем его к каждому из оставшихся чисел. Это соглашение соответствует определению в предыдущем ряду слияния максимального порядка, которое порождает текущий ряд. Такая техника работает с любым количеством устройств (не менее трех). Возникающие при этом числа являются обобщенными числами Фибоначчи, которые обладают множеством интересных свойств. Если количество отрезков не есть обобщенное число Фибоначчи, то вводятся фиктивные отрезки, которые необходимы для заполнения таблицы.

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

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

 Распределение отрезков для полифазного трехпутевого слияния


Рис. 11.16.  Распределение отрезков для полифазного трехпутевого слияния

При начальном распределении для полифазного трехпутевого слияния файла, размер которого в 17 раз больше объема оперативной памяти, мы помещаем 7 отрезков на устройство 0, 4 отрезка на устройство 2 и 6 отрезков на устройство 3. Затем на первой фазе мы выполняем слияния до исчерпания устройства 2, при этом на устройстве 0 остаются 3 отрезка размером 1, на устройстве 3 - 2 отрезка размером 1, и на устройстве 1 - вновь созданные 4 отрезка размером 3. Для файла, в 15 раз большего объема оперативной памяти, мы вначале помещаем на устройство 0 два фиктивных отрезка (см. рис. 11.15). Общее количество обработанных в процессе полного слияния отрезков равно 59, т.е. на один меньше, чем в примере сбалансированного слияния (см. рис. 11.13), но при этом используется на 2устройства меньше (см. также упражнение 11.50).

Лемма 11.6. При наличии трех внешних устройств и оперативной памяти, достаточной для размещения M записей, сортировка-слияние, основанная на выборке с замещением с последующим двухпутевым полифазным слиянием, выполняет в среднем порядка эффективных проходов.

Общий анализ полифазного слияния, выполненный Кнутом (Knuth) и другими исследователями в шестидесятых-семидесятых годах - сложное и пространное исследование, выходящее за рамки данной книги. Для P = 3 используются числа Фибоначчи, отсюда и появление коэффициента ф. Для больших P появляются другие константы. Коэффициент 1/ф отражает тот факт, что на каждой фазе используется лишь часть данных. Мы считаем количеством " эффективных проходов " количество прочитанных данных, деленное на общее количество данных. Некоторые результаты общего анализа вызывают удивление. К примеру, оптимальный метод распределения фиктивных отрезков по устройствам использует дополнительные фазы и большее количество фиктивных отрезков, чем можно бы было предположить, поскольку некоторые отрезки используются в слияниях намного чаще других (см. раздел ссылок).

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

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

 Сравнение затрат на сбалансированное и полифазное слияние


Рис. 11.17.  Сравнение затрат на сбалансированное и полифазное слияние

Количество проходов, выполняемых сбалансированным слиянием с 4 устройствами (вверху), всегда больше, чем количество эффективных проходов, выполняемых полифазным слиянием с 3 лентами (внизу). Представленные графики получены для функций из свойств 11.4 и 11.6, для N/M от 1 до 100. Из-за наличия фиктивных отрезков истинная производительность полифазного слияния имеет более сложный характер, чем показано данной ступенчатой функцией.

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

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

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

Упражнения

11.45. Покажите, какие отрезки порождаются выборкой с замещением, использующей очередь с приоритетами размером 4, для ключей E A S Y Q O U E S T I O N.

11.46. Как отразится использование выборки с замещением для файла, порожденного с помощью выборки с замещением из заданного файла?

11.47. Определите эмпирически среднее число отрезков, порожденных выборкой с замещением, в которой используется очередь с приоритетами размером 1000, для случайно упорядоченных файлов размером N = 103, 104, 105 и 106 .

11.48. Каким будет количество отрезков в худшем случае при использовании выборки с замещением для генерации начальных отрезков из файла, содержащего N записей, с использованием очереди с приоритетами размером M, при M < N?

11.49. Покажите, в стиле рис. 11.15, как с помощью полифазного слияния выполняется сортировка ключей E A S Y Q U E S T I O N W I T H P L E N T Y O F K E Y S.

11.50. В примере полифазного слияния на рис. 11.15 два фиктивных отрезка были помещены на магнитную ленту с 7 отрезками. Найдите другие способы распределения фиктивных отрезков по лентам и выберите среди них такой, который обеспечивает минимальную стоимость слияния.

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

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

11.53. Напишите программу, вычисляющую количество проходов, выполняемых многопутевым слиянием, и эффективное количество проходов, выполняемых поли-фазным слиянием для заданного количества устройств и заданного количества начальных блоков. Используйте полученную программу для вывода таблицы этих затрат при работе каждого метода, для P = 3, 4, 5, 10 и 100 и N = 103, 104, 105 и 106 .

11.54. Напишите программу, последовательно назначающую начальные отрезки устройствам перед выполнением P-путевого полифазного слияния. Если количество отрезков есть обобщенное число Фибоначчи, отрезки должны быть распределены по устройствам так, как требует алгоритм; ваша задача заключается в отыскании удобного способа последовательного распределения отрезков.

11.55. Реализуйте выборку с замещением, воспользовавшись интерфейсом, определенным в упражнении 11.38.

11.56. Реализуйте сортировку-слияние, комбинируя решения упражнений 11.38 и 11.55. Используйте полученную программу для сортировки файла максимально возможного в вашей системе размера, воспользовавшись полифазным слиянием. При возможности определите, как отражается на времени выполнения программы увеличение числа устройств.

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

11.58. Если на вашем компьютере функционирует подходящая система виртуальной памяти, выполните эмпирическое сравнение быстрой сортировки, LSD-сортировки, MSD-сортировки и пирамидальной сортировки для очень больших файлов. Используйте размер файла, максимально возможный в вашей системе.

11.59. Разработайте реализацию рекурсивной многопутевой сортировки слиянием, основанной на k-путевом слиянии, которую можно использовать для сортировки очень больших файлов в среде с виртуальной памятью (см. упражнение 8.11).

11.60. Если на вашем компьютере функционирует подходящая система виртуальной памяти, эмпирически определите значение к, при котором достигается минимальное время выполнения реализации из упражнения 11.59. Используйте размер файла, максимально возможный в вашей системе.

Параллельная сортировка-слияние

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

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

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

Присвоим этим процессорам метки 0, 1, ..., P - 1 и будем полагать, что входной файл находится в локальной памяти процессоров (то есть каждый процессор содержит N/P записей). Цель сортировки заключается в переупорядочении записей таким образом, чтобы N/P наименьших записей находились в памяти процессора 0, N/P следующих наименьших записей находились в памяти процессора 1 и так далее, по возрастанию. Как мы увидим далее, существует зависимость между P и общим временем выполнения - и хотелось бы получить количественную оценку этой зависимости, чтобы иметь возможность сравнивать различные стратегии.

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

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

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

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

Эту операцию нетрудно реализовать: достаточно слить два входных файла и передать на выход первую и вторую половину полученного файла.

Лемма 11.7. Сортировку файла размером N можно выполнить, поделив его на N/M блоков размером M, отсортировав каждый файл, и воспользовавшись после этого сортирующей сетью, построенной из сливающих компараторов.

Существует хитроумное доказательство этого утверждения, основанное на принципе нулей и единиц (см. упражнение 11.61), однако можно убедиться в правильности этого утверждения, рассмотрев пример вроде рис. 11.18.

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

 Пример поблочной сортировки


Рис. 11.18.  Пример поблочной сортировки

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

Лемма 11.8. Поблочная сортировка на P процессорах с использованием сортировки Бэтчера со сливающими компараторами может упорядочить N записей за порядка (lgP)2 / 2 параллельных шагов.

Под параллельными шагами в данном контексте понимается набор раздельных сливающих компараторов. Свойство 11.8 является прямым следствием лемм 11.3 и 11.7.

Для реализации сливающего компаратора на двух процессорах нужно, чтобы они могли обмениваться копиями их блоков данных, выполнять слияние (параллельно), и хранить половины наборов ключей: с меньшими значениями и с большими значениями. Если пересылка блоков происходит медленно по сравнению с быстродействием самих процессоров, то общее время, необходимое для сортировки, можно оценить, умножив время пересылки одного блока на (lgP)2/ 2. Эта оценка предполагает большое число допущений - например, предполагается, что пересылки блоков данных могут производиться параллельно без дополнительных затрат, что редко наблюдается в реальных компьютерах с параллельными процессорами. И все же она представляет собой отправную точку для понимания того, чего можно ожидать от практической реализации.

Если стоимость пересылки блока данных сравнима с быстродействием отдельного процессора (еще одна идеальная цель, к которой реальные машины лишь приближаются), то тогда нужно принимать в расчет время на начальную сортировку. Каждый процессор для начальной сортировки N/P блоков выполняет (N/P) lg (N/P) сравнений (параллельно), и затем примерно P2(lg P) / 2 этапов слияния N/P-и-N/P. Если стоимость сравнения равна , а стоимость слияния на одну запись равна , то общее время выполнения приблизительно равно .

Для очень больших N и малых P эта производительность является лучшей из того, на что можно рассчитывать, если использовать метод параллельной сортировки, основанной на сравнениях, поскольку в этом случае затраты равны приблизительно , то есть оптимальны: любая сортировка требует N lg N сравнений и лучшее, что можно сделать в этом случае - выполнять P сравнений одновременно. В случае больших значений P преобладает второе слагаемое, и затраты приблизительно равны , что субоптимально, но вполне конкурентоспособно. Например, при сортировке 1 миллиарда элементов на 64 процессорах вклад второго слагаемого составляет , а вклад первого слагаемого - .

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

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

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

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

Упражнения

11.61. Докажите лемму 11.7, воспользовавшись принципом нулей и единиц (лемма 11.1).

11.62. Реализуйте последовательную версию поблочной сортировки с нечетно-четным слиянием Бэтчера: (1) для сортировки блоков данных используйте стандартную сортировку слиянием (программы 8.3 и 8.2), (2) для реализации сливающих компараторов используйте стандартное абстрактное обменное слияние (программа 8.2), а (3) для реализации поблочной сортировки используйте восходящее нечетно-четное слияние Бэтчера (программа 11.3).

11.63. Оцените время выполнения программы, описанной в упражнении 11.62, как функции от N и M, для больших N.

11.64. Выполните упражнения 11.62 и 11.63, но в обоих случаях замените восходящее нечетно-четное слияние Бэтчера (программа 11.3) на программу 8.2.

11.65. Приведите значения P, для которых (N/P) lg N = NP lgP, для N = 103, 106, 109 и 1012 .

11.66. Приведите приближенные выражения вида для количества сравнений элементов данных, используемых параллельной поблочной сортировкой Бэтчера для P = 1, 4, 16, 64 и 256.

11.67. Сколько параллельных шагов потребуется для сортировки 1015 записей, распределенных на 1000 дисках, с помощью 100 процессоров?

Ссылки для части III

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

По вопросам сортировки имеется обширная литература. Опубликованная Кнутом и Райвестом (Rivest) в 1973 г. библиография содержит сотни ссылок на статьи, благодаря которым можно понять, как разрабатывались многие рассмотренные нами классические методы. Более поздние ссылки, с обширной библиографией, охватывающей последние работы, можно найти в книге Баеса-Ятеса и Гонне (Baeza-Yates and Gonnet). Обзор состояния наших знаний о сортировке Шелла можно найти в статье Седжвика (Sedgewick) за 1996 г.

Что касается быстрой сортировки, то наилучшей ссылкой может служить пионерская статья Хоара (Hoare), вышедшая в свет в 1962 г., в которой рассматриваются все наиболее важные варианты, в том числе и использование выборки, упомянутое в лекция №7. Множество деталей, касающихся математического анализа и практических эффектов многих модификаций и усовершенствований, появившихся после широкого распространения алгоритма, можно найти в статье Седжвика за 1978 г. Бентли и Мак-Илрой (Bently and McIlroy) дали его современную трактовку. Материал по трехпутевому разбиению в лекция №7 и трехпутевой поразрядной быстрой сортировке в лекция №10 основан на этой статье и статье Бентли и Седжвика за 1997 г. Первый алгоритм, использующий разбиения (бинарная быстрая сортировка или поразрядно-обменная сортировка), появился в статье Хильдебрандта и Исбица (Hildebrandt and Isbitz) в 1959 г.

Структура данных биномиальной очереди Вильемина (Vuillemin) в том виде, в каком она была реализована и исследована Брауном (Brown), элегантно и эффективно поддерживает все операции над очередями с приоритетами. Парные пирамидальные деревья, описанные Фредменом (Fredman), Седжвиком, Слитором (Sleator) и Тарьяном (Tarjan), являются усовершенствованиями базового понятия и представляют немалый практический интерес.

В статье, появившейся в 1993 г., Мак-Илрой, Бостик (Bostic) и Мак-Илрой описывают положение дел с реализациями поразрядной сортировки.

Список литературы

1. R. Baeza-Yates and G.H. Gonnet, Handbook of Algorithms and Data Structures, second edition, Addison-Wesley, Reading, MA, 1984.

2. J.L. Bentley and M.D. McIlroy, "Engineering a sort function," Software-Practice and Experience 23, 1 (January, 1993).

3. J.L. Bentley and R. Sedgewick, "Sorting and searching strings," Eighth Symposium on Discrete Algorithms, New Orleans, January, 1997.

4. M.R. Brown, "Implementation and analysis of binomial queue algorithms," SIAM Journal of Computing 7, 3 (August, 1978).

5. M.L. Fredman, R. Sedgewick, D.D. Sleator, and R.E. Tarjan, "The pairing heap: a new form of self-adjusting heap," Algorithmica 1, 1 (1986).

6. P. Hildebrandt and H. Isbitz, "Radix exchange - an internal sorting method for digital computers," Journal of the ACM, 6, 2 (1959).

7. C.A.R. Hoare, "Quicksort," Computer Journal, 5, 1 (1962).

8. Д.Э. Кнут, Искусство программирования, том 3. Сортировка и поиск, 2-е издание, ИД "Вильямс", 2008 г.

9. PM. McIlroy, K. Bostic, and M.D. McIlroy, "Engineering radix sort," Computing Systems 6, 1 (1993).

10. R.L. Rivest and D.E. Knuth, "Bibliography 26: Computing Sorting," Computing Reviews, 13 6 (June, 1972).

11. R. Sedgewick, "Implementing quicksort programs," Communications of the ACM 21, 10 (October 1978).

12. R. Sedgewick, "Analysis of shellsort and related algorithms," Fourth European Symposium on Algorithms, Barcelona, September, 1996.

13. J. Vuillemin, "A data structure for manipulating priority queues," Communications of the ACM 21, 4 (April 1978).

Глава 4. Поиск

Лекция 12. Таблицы символов и деревья бинарного поиска

Получение конкретного фрагмента или фрагментов информации из больших объемов ранее сохраненных данных - это основная операция, называемая поиском и присущая многим вычислительным задачам. Как и в случае алгоритмов сортировки, описанных в главах 6-11, и, в частности, очередей с приоритетами из главы 9 лекция №9, мы работаем с данными, разделенными на части, или элементами (item), каждый из которых имеет ключ (key), используемый при поиске. Цель поиска - отыскание элементов с ключами, которые соответствуют заданному ключу поиска. Обычно поиск проводится для доступа к содержащейся в элементе информации (а не просто к ключу), чтобы выполнить ее обработку.

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

Определение 12.1. Таблица символов (или таблица имен, или, реже, таблица идентификаторов) - это структура данных элементов с ключами, которая поддерживает две базовые операции: вставку нового элемента и возврат элемента с заданным ключом.

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

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

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

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

Как и при изучении методов сортировки в лекция №6, в этой главе мы начнем изучать методы поиска с рассмотрения некоторых элементарных методов, пригодных для небольших таблиц и в других особых ситуациях, которые иллюстрируют базовые приемы, используемые более продвинутыми методами. Затем, в остальной части главы, мы будем рассматривать дерево бинарного поиска - фундаментальную и широко применяемую структуру данных, в которой возможно применение быстрых алгоритмов поиска.

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

Абстрактный тип данных таблицы символов

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

Нас будут интересовать следующие операции:

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

В общем случае мы будем использовать термин " алгоритм поиска " в значении " реализация АТД таблицы символов " , хотя этот термин скорее предполагает определение и построение структуры данных для таблицы символов и, в дополнение к поиску, реализацию операций абстрактного типа данных. Таблицы символов так важны для стольких компьютерных приложений, что во многих средах программирования они доступны как высокоуровневые абстракции. Стандартная библиотека C содержит программу bsearch - реализацию алгоритма бинарного поиска, описанного в разделе 12.4, а библиотека стандартных шаблонов C++ предоставляет множество таблиц символов, называемых " ассоциативными контейнерами " . Как обычно, реализации " вообще " трудно выполнить требования, предъявляемые к производительности специализированных приложений. Так что целью изучения многих оригинальных методов, разработанных для реализации абстракции таблицы символов, будет выработка понимания, которое поможет принять решение, когда использовать готовую реализацию, а когда разработать специальную, предназначенную для конкретного приложения.

Как и в случае с сортировкой, мы будем изучать методы без определения типов обрабатываемых элементов. Столь же подробно, как в лекция №6, будут рассматриваться реализации, использующие интерфейс, в котором определены тип Item и базовые абстрактные операции с данными. Мы ознакомимся с методами как на основе сравнений, так и поразрядные, где в качестве индексов используются ключи или части ключей. Чтобы подчеркнуть различие ролей, которые играют при поиске элементы и ключи, мы расширим понятие элемента, которое использовалось в главах 6-11: сейчас элементы типа Item содержат ключи типа Key. Поскольку теперь требуется (слегка) больше элементов, чем было необходимо для ознакомления с алгоритмами сортировки, будем считать, что они оформлены как абстрактные типы данных, реализованные с помощью классов C++, как показано в программе 12.1. Функция-член key() предназначена для извлечения ключей из элементов, а перегруженная операция ==проверяет равенство двух ключей. В этой главе и лекция №13 также перегружается операция < для сравнения значений двух ключей, что бывает полезно при поиске; алгоритмы поиска, описанные в лекция №14 и лекция №15, основываются на извлечении частей ключей с помощью базовых поразрядных операций, которые использовались в главе 10 лекция №10. Кроме того, предполагается, что элементы инициализируются пустыми (null) значениями, и что клиенты имеют доступ к функции null() , которая может проверить, является ли элемент пустым.

Программа 12.1. Пример реализации АТД элемента

Это определение класса элементов, которые представляют собой небольшие записи, состоящие из целочисленных ключей и связанной с ними информации (значения с плавающей точкой), иллюстрирует основные соглашения в отношении элементов таблиц символов. Наши реализации таблиц символов являются клиентскими программами, в которых сравнение ключей выполняют операции == и <, а функции-члены key() и null() позволяют, соответственно, получить значение ключа и проверить, является ли элемент пустым.

В определения типа элемента включены также функции scan (чтение Item), rand (генерация случайного Item) и show (вывод Item), которые будут использоваться драйверами. Это позволяет создавать и тестировать различные реализации таблиц символов, состоящие из различных типов элементов.

#include <stdlib.h>
#include <iostream.h>
static int maxKey = 1000;
typedef int Key;
class Item
  { private:
      Key keyval;
      float info;
    public:
      Item()
        { keyval = maxKey; }
      Key key()
        { return keyval; }
      int null()
        { return keyval == maxKey; }
      void rand()
        { keyval = 100 0*::rand()/RAND MAX;
        info = 1.0*::rand()/RAND MAX; }
      int scan(istream& is = cin)
        { return (is >> keyval >> info) != 0; }
      void show(ostream& os = cout)
        { os << keyval << " " << info << endl; }
  };
ostream& operator<<(ostream& os, Item& x)
  { x.show(os); return os; }
      

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

Чтобы использовать при поиске интерфейсы и реализации для чисел с плавающей точкой, строк и более сложных элементов, описанных в лекция №6, нужно только обеспечить нужные определения для Key, key(), null() и операций == и <, а также сделать функции rand, scan и show, функциями-членами, которые правильно обращаются к ключам.

Программа 12.2 представляет собой интерфейс, определяющий базовые операции таблицы символов (за исключением операции объединить). Этот интерфейс будет использоваться в этой и нескольких следующих главах как интерфейс между клиентскими программами и всеми реализациями поиска. Мы не будем использовать АТД первого класса в смысле лекция №4 (см. упражнение 12.6), поскольку в большинстве программ используется только одна таблица, а добавление конструкторов копирования, перегруженных операций присваивания и деструкторов, хоть это и несложная задача в большинстве реализаций, все же отвлекало бы от важных характеристик алгоритмов. В программе 12.2 можно было бы также определить версию интерфейса для работы с дескрипторами элементов, как в программе 9.8 (см. упражнение 12.7), но обычно это излишне усложняет программу, если можно манипулировать элементом с помощью ключа. В интерфейсе не указан способ определения элемента, который нужно удалить. В большинстве реализаций используется интерпретация " удалить элемент с ключом, равным заданному " , при этом подразумевается предварительный поиск. В других реализациях, где используются дескрипторы и можно проверить идентичность элемента, поиск перед удалением не обязателен, и поэтому в них возможны более быстрые алгоритмы. А при изучении алгоритмов для операции объединить - в приложениях, в которых обрабатываются несколько таблиц символов - хорошо бы использовать реализации АТД первого класса для таблицы символов, где сведены к минимуму затраты времени и памяти (см. раздел 12.9).

В некоторых алгоритмах не предполагается наличие какого-либо определенного порядка ключей, и поэтому для сравнения ключей в них используется только операция ==(без <), однако во многих реализациях таблиц символов задействуется отношение порядка ключей, используемое операцией < для структурирования данных и управления поиском. Кроме того, абстрактные операции выбрать и сортировать явно используют упорядоченность ключей. Функция сортировать выполняет лишь вывод всех элементов в выходной поток по порядку, но не обязательно сортирует их. Ее можно легко обобщить до функции, которая перебирает элементы в порядке их ключей и, возможно, применяет к каждому из них процедуру, переданную в аргументе. Функции сортировать, используемые для таблиц символов, мы называем show (вывести), поскольку наши реализации выполняют вывод содержимого таблицы символов в порядке возрастания. В алгоритмах, в которых операция < не используется, нет необходимости сравнивать ключи друг с другом, поэтому такие алгоритмы могут не поддерживать операции выбрать и сортировать.

Программа 12.2. АТД таблицы символов

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

template <class Item, class Key>
class ST
  { private:
    // Код, зависящий от реализации 
    public:
      ST(int);
    int count();
    Item search(Key) ;
    void insert(Item);
    void remove(Item);
    Item select(int);
    void show(ostream&);
  };
      

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

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

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

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

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

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

Программа 12.3. Пример клиента для таблицы символов

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

#include <iostream.h>
#include <stdlib.h>
#include "Item.cxx"
#include "ST.cxx"
int main(int argc, char *argv[])
  { int N, maxN = atoi(argv[1]), sw = atoi(argv[2]);
    ST<Item, Key> st(maxN); 
    for (N = 0; N < maxN; N++)
      { Item v;
        if (sw) v.rand();
        else if (!v.scan()) break;
        if (!(st.search(v.key())).null()) continue;
        st.insert(v);
      }
    st.show(cout);
    cout << endl; cout << N << " ключей" << endl;
    cout << st.count() << " различных ключей" << endl;
  }
      

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

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

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

Упражнения

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

12.2. Напишите реализацию класса Item (аналогичную программе 12.1), который позволит в реализациях таблиц символов обрабатывать элементы, состоящие только из строковых ключей в стиле C. Класс должен содержать буфер для строк, как в программе 6.11.

12.3. Используя АТД таблицы символов из программы 12.2, реализуйте АТД стека и очереди.

12.4. Используя АТД таблицы символов из программы 12.2, реализуйте АТД очереди с приоритетами, который поддерживает операции удаления как максимального, так и минимального элементов.

12.5. Используя АТД таблицы символов из программы 12.2, реализуйте сортировку массива, совместимую с реализациями из глав 6-10.

12.6. Добавьте в программу 12.2 объявления деструктора, конструктора копирования и перегруженной операции присваивания, чтобы преобразовать ее в АТД первого класса (см. лекция №4 и 9.5).

12.7. Определите интерфейс АТД таблицы символов, позволяющий клиентским программам удалять заданные дескрипторами элементы и изменять ключи (см. лекция №4 илекция №9).

12.8. Приведите интерфейс и реализацию для элементов с двумя полями: 16-битным целочисленным ключом и строкой в стиле C, которая содержит информацию, связанную с этим ключом.

12.9. Укажите среднее количество различных ключей, которые найдет программа-драйвер (программа 12.3) среди N случайных положительных целых чисел, меньших 1000, для N = 10, 102, 103, 104 и 105 . Найдите ответ эмпирически, аналитически или обоими методами.

Распределяющий поиск

Предположим, что значения ключей представляют собой различные небольшие числа, как, например, в программе 12.4. В этом случае простейший алгоритм поиска основывается на хранении элементов в массиве, индексированном значениями ключей - так сделано в реализации, приведенной в программе 12.4. Ее код весьма прост: оператор new[] заносит во все элементы значение nullItem, затем можно вставить элемент со значением ключа k, просто записав его в st[k], и найти элемент со значением ключа k, выбрав его из st[k]. Чтобы удалить элемент со значением ключа k, в st[k] записывается значение nullItem. Реализации операций выбрать, сортировать и подсчитать в программе 12.4 используют линейный просмотр массива с пропуском пустых элементов. Данная реализация оставляет клиенту решение задачи обработки элементов с повторяющимися ключами и проверку таких условий, как выполнение операции удалить для ключа, отсутствующего в таблице. Эта реализация служит отправной точкой для всех реализаций таблиц символов, которые рассматриваются в этой главе и лекциях 13-15.

Программа 12.4. Таблица символов, основанная на индексируемом значениями ключей массиве

В данной реализации предполагается, что значения ключей - положительные целые числа, меньшие сигнального значения M - используются в качестве индексов массива. Конструктор Item создает элементы со значениями ключей, равными сигнальному значению, чтобы конструктор ST мог найти в пустом элементе значение M. Основные затраты этого метода - объем памяти, необходимый при большом размере сигнального значения, и время, необходимое конструктору ST, когда значение N мало по сравнению с M.

template <class Item, class Key>
class ST
  { private:
      Item nullItem, *st;
      int M;
    public:
      ST(int maxN)
        { M = nullItem.key(); st = new Item[M]; }
      int count()
        { int N = 0; 
for (int i = 0; i < M; i++)
  if (!st[i].null()) N+ + ;
return N;
        }
      void insert(Item x)
        { st[x.key()] = x; }
      Item search(Key v)
        { return st[v]; }
      void remove(Item x)
        { st[x.key()] = nullItem; }
      Item select(int k)
        { for (int i = 0; i < M; i++)
  if (!st[i].null())
    if (k- == 0) return st[i];
return nullItem;
        }
      void show(ostream& os)
        { for (int i = 0; i < M; i++)
if (!st[i].null()) st[i].show(os); }
  };
      

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

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

Если элементы вообще отсутствуют (имеются только ключи), можно использовать битовую таблицу. В этом случае таблица символов называется таблицей существования (existence table), поскольку ее к-й разряд можно рассматривать как признак существования значения к в множестве ключей таблицы. Например, используя на 32-разрядном компьютере таблицу из 313 слов, этот метод позволяет быстро выяснить, используется ли уже конкретный 4-значный номер телефонного коммутатора (см. упражнение 12.14).

Лемма 12.1. Если значения ключей - положительные целые числа, меньшие M, и элементы имеют различные ключи, то тип данных таблицы символов может быть реализован с помощью индексированных значениями ключей массивов так, что для выполнения операций вставить, найти и удалить потребуется постоянное время; а время выполнения операций инициализировать, выбрать и сортировать будет пропорционально M - для любой из операций в таблице, содержащей N элементов.

Это свойство очевидно после ознакомления с кодом. Обратите внимание, что ключи должны удовлетворять условию N < M.

Программа 12.4 не обрабатывает повторяющиеся ключи, и в ней предполагается, что значения ключей лежат в пределах между 0 и M-1. Для хранения элементов с одинаковыми ключами можно использовать связные списки или один из подходов, перечисленных в разделе 12.1, а перед использованием ключей в качестве индексов можно выполнять их простые преобразования (см. упражнение 12.13). Но мы отложим подробное рассмотрение таких случаев до лекция №14, посвященной хешированию, где для реализации таблиц символов для любых ключей используется этот подход - преобразование ключей из потенциально широкого диапазона в узкий и выполнение специальной обработки элементов с повторяющимися ключами. Пока будем считать, что старый элемент с ключом, равным ключу вставляемого элемента, может быть либо молча проигнорирован (как в программе 12.4), либо считаться ошибкой (см. упражнение 12.10).

Реализация операции подсчитать в программе 12.4 - пример " ленивого " подхода, когда действия выполняются только при вызове функции count. Альтернативный ( " энергичный " ) подход заключается в использовании локальной переменной для счетчика непустых позиций таблицы с увеличением значения этой переменной при вставке в позицию таблицы, содержащую nullItem, и с уменьшением счетчика при удалении из позиции таблицы, не содержащей nullItem (см. упражнение 12.11). " Ленивый " подход предпочтительнее, если операция подсчитать используется редко (или вообще не используется), а количество возможных значений ключей мало; в остальных случаях предпочтительнее " энергичный " подход. Для подпрограммы библиотеки общего назначения лучше использовать " энергичный " подход, поскольку он обеспечивает оптимальную производительность в худшем случае при небольшом постоянном коэффициенте увеличения затрат на выполнение операций вставить и удалить. Для внутреннего цикла в приложении с очень большим количеством операций вставить и удалить, но незначительным количеством операций подсчитать " ленивый " подход удобнее, поскольку обеспечивает наиболее быструю реализацию часто выполняемых операций. Как мы уже неоднократно убеждались, подобная дилемма типична для разработки АТД, которые должны поддерживать различные наборы операций.

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

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

Упражнения

12.10. Реализуйте АТД таблицы символов первого класса (см. упражнение 12.6), используя динамически размещаемые массивы с индексированием по ключам.

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

12.12. Измените реализацию из упражнения 12.10, чтобы обеспечить " энергичную " реализацию функции count (см. упражнение 12.11).

12.13. Разработайте версию программы 12.4, в которой используется функция h(Key), преобразующая ключи в неотрицательные целые числа, меньшие M - так, чтобы никакие два ключа не отображались одним и тем же целым числом. (Это усовершенствование делает реализацию полезной, если ключи лежат в узком диапазоне (не обязательно начинающемся с 0) и в других простых случаях.)

12.14. Разработайте версию программы 12.4 для случая, если элементы представляют собой ключи, являющиеся положительными целыми числами, меньшими M (без какой-либо связанной информации). В этой реализации используйте динамически размещаемый массив, состоящий приблизительно из M/bitword слов, где bitword - количество битов в одном слове в используемой компьютерной системе.

12.15. Используйте реализацию из упражнения 12.14 для экспериментального определения среднего значения и среднеквадратичного отклонения количества различных целых чисел в случайной последовательности N неотрицательных целых чисел, меньших N, для N, близкого к объему памяти, который доступен программе в используемом компьютере, и выраженному в количестве битов (см. программу 12.3)

Последовательный поиск

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

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

Программа 12.5. Таблица символов (упорядоченная) на основе массива

Подобно программе 12.4, в этой реализации используется массив элементов, но здесь не требуется, чтобы ключи были небольшими целыми числами. Упорядоченность массива обеспечивается тем, что при вставке нового элемента большие элементы сдвигаются, освобождая место, как при сортировке вставками. Потом функция search выполняет просмотр массива, когда нужно найти элемент с заданным ключом. Если просмотр дошел до элемента с большим ключом, возвращается значение nullItem. Реализации функций select и sort тривиальны, а реализация функции remove оставлена в качестве упражнения (см. упражнение 12.16).

template <class Item, class Key>
class ST
  { private:
      Item nullItem, *st;
      int N;
    public:
      ST(int maxN)
        { st = new Item[maxN+1]; N = 0; }
      int count()
        { return N; }
      void insert(Item x)
        { int i = N++; Key v = x.key();
while (i > 0 && v < st[i-1].key())
  { st[i] = st[i-1]; i--; }
st[i] = x;
        }
      Item search(Key v)
        { for (int i = 0; i < N; i++)
  if (!(st[i].key() < v)) break;
if (v == st[i].key()) return st[i];
return nullItem;
        }
      Item select(int k)
        { return st[k]; }
      void show(ostream& os)
        { int i = 0; while (i < N) st[i++].show(os); }
  } ;
      

Можно использовать другой подход и создать реализацию, в которой упорядоченность элементов в массиве не обязательна. При вставке новый элемент помещается в конец массива; во время поиска осуществляется последовательный просмотр массива. Характерная особенность этого подхода состоит в том, что операция вставить выполняется быстро, а операции выбрать и сортировать требуют значительно большего объема работы (для обеих требуется один из методов, описанных в лекциях 7-10) . Удаление элемента с заданным ключом можно выполнить, найдя его, а затем переместив в его позицию последний элемент массива и уменьшив размер массива на 1; удаление всех элементов с заданным ключом реализуется повторением этой операции. Если доступен дескриптор, позволяющий определить индекс элемента в массиве, то поиск не требуется, и операция удалить выполняется за постоянное время.

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

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

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

Программа 12.6. Таблица символов (неупорядоченная) на основе связного списка

В данной реализации операций создать, подсчитать, найти и вставить используется односвязный список, каждый узел которого содержит элемент с ключом и ссылкой. Функция insert помещает новый элемент в начало списка и тратит на это постоянное время. Для выполнения просмотра списка функция-член search использует приватную рекурсивную функцию searchR.

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

#include <stdlib.h>
template <class Item, class Key>
class ST
  { private:
    Item nullItem;
    struct node
      { Item item; node* next;
        node(Item x, node* t)
{ item = x; next = t; }
        } ;
        typedef node *link;
        int N;
        link head;
        Item searchR(link t, Key v)
{ if (t == 0) return nullItem;
  if (t->item.key() == v) return t->item;
  return searchR(t->next, v);
}
public:
  ST(int maxN)
  { head = 0; N = 0; } int count()
      { return N; }
    Item search(Key v)
      { return searchR(head, v); }
    void insert(Item x)
      { head = new node(x, head); N++; }
  };
      

Чтобы подробнее проанализировать последовательный поиск для случайных ключей, сначала рассмотрим затраты на вставку новых ключей, отдельно для случаев успешного и неудачного поиска. Первый часто называют попаданием при поиске, а второй - промахом при поиске. Нас интересуют затраты как для попаданий, так и для неудач, в среднем и худшем случаях. Вообще-то в реализации с использованием упорядоченного массива (см. программу 12.5) каждый элемент проверяется двумя операциями сравнения (== и <). В главах 12-16 в целях анализа мы будем считать эту пару одним сравнением, поскольку обычно их можно эффективно объединить с помощью низкоуровневой оптимизации.

Лемма 12.2. Последовательный поиск в таблице символов с N элементами требует выполнения порядка N/2 сравнений при успешном поиске (в среднем).

См. лемму 2.1. Доказательство применимо к массивам или связным спискам, упорядоченным или неупорядоченным.

Лемма 12.3. Последовательный поиск в таблице символов с N неупорядоченными элементами требует постоянного количества шагов для выполнения вставок и N сравнений при неудачном поиске (всегда).

Эти утверждения справедливы для представлений как массивами, так и связными списками, и следуют непосредственно из реализаций (см. упражнение 12.20 и программу 12.6).

Лемма 12.4. Последовательный поиск в таблице символов из N упорядоченных элементов требует порядка N/2 операций для вставки, успешного поиска и неудачного поиска (в среднем).

См. лемму 2.2. И опять эти утверждения справедливы для представлений как массивами, так и связными списками, и следуют непосредственно из реализаций (см. программу 12.5 и упражнение 12.21).

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

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

Помимо учета этих различий, приходится, как обычно, идти на компромисс: для реализаций с использованием связных списков требуется дополнительный объем памяти для ссылок, а для реализаций с использованием массивов необходимо заранее знать максимальный размер таблицы или же предусмотреть увеличение таблицы во время работы (см. лекция №14). Кроме того, как было сказано в разделе 12.9, использование связных списков дает гибкость, позволяющую эффективно реализовать другие операции наподобие объединить и удалить.

Эти результаты во взаимосвязи с другими алгоритмами, рассматриваемыми далее в этой главе и лекция №13 и лекция №14, сведены в таблицу 12.1. В разделе 12.4 будет рассмотрен бинарный поиск, сводящий время поиска до lg N, и поэтому широко используемый при работе со статическими таблицами (когда вставки выполняются сравнительно редко).

Таблица 12.1. Затраты на вставку и поиск в таблицах символов
Худший случайВ среднем
вставитьнайтивыбратьвставитьуспешный поискнеудачный поиск
Распределяющий массив11M111
Упорядоченный массивNN1 N/2 N/2 N/2
Упорядоченный связный списокNNN N/2 N/2 N/2
Неупорядоченный массив1N Nlg 1 N/2 N
Неупорядоченный связный список1N Nlg 1 N/2 N
Бинарный поискN lgN 1 N/2 lgN lgN
Дерево бинарного поискаNNN lgN lgN lgN
Красно-черное дерево lgN lgN lgN lgN lgN lgN
Рандомизированное деревоN*N*N* lgN lgN lgN
Хеширование1N* Nlg 111

В каждой ячейке этой таблицы представлено (с точностью до постоянного множителя) время выполнения как функция от количества элементов в таблице N и размера таблицы M (если он отличен от N) для реализаций, в которых новые элементы можно вставить независимо от наличия в таблице элементов с таким ключом. Элементарные методы (первые четыре строки) требуют постоянного времени выполнения для некоторых операций и линейного времени для остальных; более продвинутые методы гарантируют логарифмическое или постоянное время выполнения для большинства или всех операций. Значения NlgN в столбце операции выбрать представляют затраты на сортировку элементов - линейная по времени операция выбрать для неупорядоченного набора элементов возможна лишь теоретически, но не на практике (см. лекция №7). Звездочками помечены значения для маловероятных худших случаев.

В разделах 12.5-12.9 мы рассмотрим деревья бинарного поиска, которые обеспечивают время поиска и вставки, пропорциональное lgN, но только в среднем. В лекция №13 будут рассмотрены красно-черные деревья и рандомизированные деревья бинарного поиска, которые, соответственно, гарантируют логарифмическую производительность либо существенно увеличивают ее вероятность. В лекция №14 мы познакомимся с хешированием, которое обеспечивает поиск и вставку за постоянное время в среднем, но не позволяет эффективно выполнять операцию сортировать и некоторые другие операции. В лекция №15 будут изучаться методы поразрядного поиска, аналогичные методам поразрядной сортировки из лекция №10; в лекция №16 исследуются методы, применимые к файлам на внешних носителях.

Упражнения

12.16. Добавьте операцию удалить в реализацию таблицы символов на основе упорядоченного массива (программа 12.5).

12.17. Для таблиц символов на основе списка (программа 12.6) и массива (программа 12.5) реализуйте функции searchinsert. Они должны искать в таблице символов элемент с ключом, равным ключу заданного элемента, и при неудачном поиске вставить этот элемент.

12.18. Реализуйте операцию выбрать для реализации таблицы символов на основе списка (программа 12.6).

12.19. Приведите количество сравнений, необходимых для помещения ключей E A S Y Q U E S T I O N в первоначально пустую таблицу с использованием АТД, реализованных с помощью одного из четырех элементарных подходов: упорядоченный или неупорядоченный массив или список. Пусть для каждого ключа выполняется поиск, и в случае неудачи выполняется вставка, как в упражнении 12.17.

12.20. Для интерфейса таблицы символов из программы 12.2 реализуйте операции создать, найти и вставить, используя для представления таблицы символов неупорядоченный массив. Характеристики производительности программы должны соответствовать таблица 12.1.

12.21. Для интерфейса таблицы символов из программы 12.2 реализуйте операции создать, найти и вставить, используя для представления таблицы символов упорядоченный связный список. Характеристики производительности программы должны соответствовать таблица 12.1.

12.22. Измените реализацию таблицы символов на основе списка (программа 12.6) на двусвязный список, чтобы она поддерживала клиентские дескрипторы элементов (см. упражнение 12.7); добавьте деструктор, конструктор копирования и перегруженную операцию присваивания (см. упражнение 12.6); добавьте операции удалить и объединить; и напишите программу-драйвер, тестирующую полученные интерфейс и реализацию АТД первого класса таблицы символов.

12.23. Напишите программу-драйвер измерения производительности, которая использует функцию insert для заполнения таблицы символов, а затем функции select и remove для ее опустошения; эти операции должны многократно повторяться для случайных последовательностей ключей различной длины, от малой до большой. Программа должна замерять время каждого выполнения и выводить средние значения в виде текста или графика.

12.24. Напишите программу-драйвер проверки производительности, которая использует функцию insert для заполнения таблицы символов, а затем функцию search (в среднем 10 раз успешное выполнение и примерно столько же - неудачное); эти операции должны многократно повторяться для случайных последовательностей ключей различной длины, от малой до большой. Программа должна замерять время каждого выполнения и выводить средние значения в виде текста или графика.

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

12.26. Какую реализацию таблицы символов лучше использовать для приложения, в котором в произвольном порядке выполняется 102 операций вставить, 103 операций найти и 104 операций выбрать? Обоснуйте свой ответ.

12.27.( В действительности это упражнение состоит из пяти упражнений). Выполните упражнение 12.26 для пяти других вариантов сочетания операций и частоты их использования.

12.28. Алгоритм самоорганизующегося поиска - это алгоритм, который изменяет порядок элементов так, чтобы часто запрашиваемые элементы встречались в начале поиска. Измените реализацию операции найти для упражнения 12.20 так, чтобы при каждом успешном поиске она помещала найденный элемент в начало списка, сдвигая на одну позицию вправо все элементы от начала списка до освободившейся позиции. Эта процедура называется эвристикой перемещения вперед (move-to-front).

12.29. Приведите порядок ключей после того, как элементы с ключами E A S Y Q U E S T I O N помещаются в первоначально пустую таблицу с помощью операции найти и последующей вставить в случае неудачного поиска, с использованием эвристики самоорганизующегося поиска перемещением вперед (см. упражнение 12.28).

12.30. Напишите программу-драйвер для методов самоорганизующегося поиска, в которой таблица символов заполняется N ключами с помощью функции insert, а затем выполняется 10N успешных поисков в соответствии с известным распределением вероятности.

12.31. Воспользуйтесь решением упражнения 12.30 для сравнения времен выполнения реализации из упражнения 12.20 и времени выполнения реализации из упражнения 12.28 для N = 10, 100 и 1000, используя распределение вероятности, при котором операция найти выполняется для i-го наибольшего ключа с вероятностью 1/2i при .

12.32. Выполните упражнение 12.31 для распределения вероятности, при котором операция найти выполняется для i-го наибольшего ключа с вероятностью HN/i при . Это распределение называется законом Зипфа.

12.33. Сравните эвристику перемещения вперед с оптимальной организацией для распределений из упражнений 12.31 и 12.32 - а именно с хранением ключей в порядке возрастания (в порядке уменьшения ожидаемой частоты обращения к ним). То есть в упражнении 12.31 вместо решения из упражнения 12.20 воспользуйтесь программой 12.5.

Бинарный поиск

В реализации последовательного поиска в массиве большого количества элементов общее время поиска можно существенно сократить, используя процедуру поиска, основанную на стандартном принципе " разделяй и властвуй " (см. лекция №5): делим множество элементов на две части, определяем, к какой из двух частей принадлежит искомый ключ, и затем продолжаем поиск в этой части. Разумный способ разделения множества элементов на части состоит в поддержании упорядоченности элементов и использовании индексов в отсортированном массиве для определения той части массива, с которой нужно продолжать работать. Такая технология поиска называется бинарным поиском (binary search). Программа 12.7 представляет собой рекурсивную реализацию этой фундаментальной стратегии. В программе 2.2 показана нерекурсивная реализация, в которой стек не нужен, поскольку рекурсивная функция в программе 12.7 завершается рекурсивным вызовом.

На рис. 12.1 показаны подфайлы, проверяемые в ходе бинарного поиска в небольшой таблице; на рис. 12.2 приведен больший пример. Каждая итерация отбрасывает чуть больше половины таблицы, поэтому количество требуемых итераций мало.

 Бинарный поиск


Рис. 12.1.  Бинарный поиск

Для нахождения искомого ключа L в этом файле с помощью бинарного поиска достаточно только трех итераций. В первом вызове алгоритм сравнивает L с ключом в середине файла - G. Поскольку L больше этого ключа, в следующей итерации используется правая половина файла. Затем, поскольку L меньше M, находящегося в середине правой половины, в ходе третьей итерации рассматривается подфайл, состоящий из трех элементов - H, I и L. После выполнения еще одной итерации размер подфайла становится равным 1, и алгоритм находит ключ L.

 Бинарный поиск


Рис. 12.2.  Бинарный поиск

Для нахождения записи в файле из 200 элементов бинарный поиск требует только семь итераций. Размеры подфайлов описываются последовательностью 200, 99, 49, 24, 11, 5, 2, 1; то есть каждая из исследуемых частей несколько меньше половины предыдущей.

Программа 12.7. Бинарный поиск (в таблице символов на основе массива)

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

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

        private:
Item searchR(int l, int r, Key v)
  { if (l > r) return nullItem;
    int m = (l+r)/2;
    if (v == st[m].key()) return st[m];
    if (l == r) return nullItem;
    if (v < st[m].key())
      return searchR(l, m-1, v);
    else
    return searchR(m+1, r, v);
  }
        public:
Item search(Key v)
  { return searchR(0, N-1, v); }
      

Лемма 12.5. При бинарном поиске выполняется не более чем сравнений (и при успешном, и при неудачном).

См. лемму 2.3. Интересно отметить, что максимальное количество сравнений, используемых для бинарного поиска в таблице размером N, в точности равно количеству битов в двоичном представлении числа N, поскольку операция сдвига на один бит вправо преобразует двоичное представление N в двоичное представление числа (см. рис. 2.6 рис. 2.6).

Поддержание таблицы в отсортированном виде, как при сортировке вставками, приводит к квадратичной зависимости времени выполнения от количества операций вставить, но эти затраты можно считать приемлемыми или даже пренебрежимыми при очень большом количестве операций найти. В типичной ситуации, когда все элементы (или большая их часть) доступны до начала поиска, можно создать таблицу с помощью конструктора, который принимает в качестве параметра массив и во время инициализации использует один из стандартных методов сортировки, описанных в лекциях 6-10. После этого обновления таблицы могут выполняться различными способами. Например, можно поддерживать упорядоченность во время вставок, как в программе 12.5 (см. также упражнение 12.21), либо накопить их отдельно, выполнить сортировку и слить с существующей таблицей (как описано в упражнении 8.1). Всякое обновление может быть связано со вставкой элемента, ключ которого меньше ключа любого из элементов таблицы, и тогда для освобождения места может потребоваться сдвиг всех элементов.

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

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

Если в таблице могут быть повторяющиеся ключи, бинарный поиск можно расширить, включив операции для подсчета количества элементов с данным ключом или возврата их в виде группы. Несколько элементов, ключи которых совпадают с искомым, образуют в таблице непрерывный блок (поскольку таблица упорядочена), и в программе 12.7 успешный поиск завершится где-то внутри этого блока. Если приложению требуется доступ ко всем таким элементам, в программу можно добавить код для выполнения просмотра в обоих направлениях от точки завершения поиска и возврата двух индексов, ограничивающих элементы с ключами, равными искомому. В этом случае время выполнения поиска пропорционально lgN плюс количество найденных элементов. Аналогичный подход используется для решения более общей задачи поиска в диапазоне, которая состоит в нахождении всех элементов, ключи которых попадают в указанный интервал. Мы рассмотрим подобные расширения базового набора операций с таблицами символов в части 6.

Последовательность сравнений, выполняемых алгоритмом бинарного поиска, предопределена: конкретная используемая последовательность зависит от значения искомого ключа и значения N. Ее можно описать в виде структуры бинарного дерева, подобной приведенной на рис. 12.3. Это дерево похоже на дерево из лекция №8, используемое для описания размеров под-файлов во время сортировки слиянием ( рис. 8.3). Но в бинарном поиске используется один путь в дереве, тогда как при сортировке слиянием - все пути. Это дерево является статическим и неявным; в разделе 12.5 будут рассмотрены алгоритмы, в которых для выполнения поиска используется динамическая, явно построенная структура бинарного дерева.

 Последовательность сравнений при бинарном поиске


Рис. 12.3.  Последовательность сравнений при бинарном поиске

На этих диаграммах в виде деревьев " разделяй и властвуй " показана последовательность индексов для сравнений при бинарном поиске. Эти последовательности зависят только от размера исходного файла, но не от значений ключей в файле. Такие деревья несколько отличаются от деревьев, соответствующих сортировке слиянием и аналогичным алгоритмам ( рис. 5.6 и 8.3), поскольку элемент, находящийся в корне, в поддеревья не включается.

На верхней диаграмме показан поиск в файле из 15 элементов, проиндексированных от 0 до 14. Анализируется средний элемент (с индексом 7), затем (рекурсивно) левое поддерево, если искомый элемент меньше его, или правое поддерево, если искомый элемент больше корня. Каждый поиск соответствует пути от корня до низа дерева: например, поиск элемента, значение которого находится между 10 и 11, проходит по пути 7, 11, 9 и 10. Для файлов, размер которых не равен степени 2 минус 1, структура не настолько регулярна - пример одной из них (для 12 элементов) приведен на нижней диаграмме.

Одно из возможных усовершенствований бинарного поиска - более точное предположение о положении ключа поиска в текущем интервале (вместо тупого сравнения его на каждом шаге со средним элементом). Эта тактика имитирует способ поиска имени в телефонном справочнике или слова в словаре: если нужная запись начинается с буквы, находящейся в начале алфавита, мы открываем книгу ближе к началу, а если она начинается с буквы из конца алфавита, поиск выполняется в конце книги. Для реализации данного метода, называемого интерполяционным поиском (interpolation search), нужно заменить в программе 12.7 оператор

m = (l+r)/2
      

на оператор

m = l+(v-[l].key())*(r-l)/(a[r].key()-a[l].key());
      

Для обоснования этого изменения отметим, что выражение (l + r) / 2 равнозначно выражению : мы вычисляем середину интервала, добавляя к левой границе половину размера интервала. Использование интерполяционного поиска сводится к замене в этой формуле коэффициента оценкой положения ключа - а именно , где kl и kr соответственно означают a[l].key() и a[r].key(). При этом предполагается, что значения ключей являются числовыми и равномерно распределенными.

Можно показать, что при интерполяционном поиске в файлах со случайными ключами для каждого поиска (успешного или неудачного) используется менее lg lg N + 1 сравнений. Доказательство этого утверждения выходит далеко за рамки этой книги. Эта функция растет очень медленно, и на практике ее можно считать постоянной: если N равно 1 миллиарду, то lg lg N < 5. Таким образом, любой элемент можно найти, выполнив лишь несколько обращений (в среднем) - это существенное достижение по сравнению с бинарным поиском. Для ключей, которые распределены не вполне случайно, производительность интерполяционного поиска еще выше. А его граничным случаем является метод распределяющего поиска, описанный в разделе 12.2.

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

Упражнения

12.34. Приведите нерекурсивную реализацию функции бинарного поиска (см. программу 12.7).

12.35. Нарисуйте деревья, соответствующие рис. 12.3 для N = 17 и N = 24.

12.36. Найдите значения N, для которых бинарный поиск в таблице символов размером N становится в 10, 100 и 1000 раз быстрее последовательного поиска. Предскажите значения аналитически и проверьте их экспериментально.

12.37. Пусть вставки в динамическую таблицу символов размера N реализованы как в сортировке вставками, но для выполнения операции найти используется бинарный поиск. Предположим, что поиск выполняется в 1000 раз чаще, чем вставки. Определите в процентах долю времени, затрачиваемую на вставки, для N = 103, 104, 105 и 106 .

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

12.39. Добавьте " ленивое " удаление в реализацию из упражнения 12.38.

12.40. Ответьте на вопрос упражнения 12.37 для реализации из упражнения 12.38.

12.41. Реализуйте функцию, аналогичную бинарному поиску (программа 12.7), которая возвращает количество элементов в таблице символов с ключами, равными данному.

12.42. Напишите программу, которая при заданном значении N создает последовательность N макрокоманд вида compare(l, h), проиндексированных от 0 до N-1, где i-я макрокоманда в списке означает " сравнить ключ поиска со значением в таблице по индексу i; затем при равенстве сообщить, что ключ найден; если он меньше, выполнить l-ю инструкцию, и если больше - h-ю инструкцию " (индекс 0 зарезервируйте для индикации неудачного поиска). Любой поиск с помощью этой последовательности должен выполнять те же сравнения, что и бинарный поиск на этом наборе данных.

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

12.44. Пусть a[i] == 10*i для значений i в интервале от 1 до N. Сколько позиций в таблице просматриваются интерполяционным поиском при неудачном поиске значения 2k - 1?

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

Деревья бинарного поиска

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

Мы уже рассматривали деревья в лекция №5, а сейчас просто вспомним терминологию. Определяющее свойство дерева (tree) заключается в том, что на каждый узел указывает только один другой узел, называемый родительским (parent). Определяющее свойство бинарного дерева (binary tree) - наличие у каждого узла обязательно двух ссылок, называемых левой и правой. Ссылки могут указывать на другие двоичные деревья или на внешние (external) узлы, которые не имеют ссылок. Узлы с двумя ссылками называются также внутренними (internal) узлами. Для выполнения поиска каждый внутренний узел содержит элемент со значением ключа; ссылки на внешние узлы называются пустыми (null) ссылками (то есть внешние узлы - это фиктивные узлы, которых на самом деле нет - прим. перев.). Процесс поиска зависит от результатов сравнения ключа поиска с ключами во внутренних узлах.

Определение 12.2. Дерево бинарного поиска (binary search tree - BST) - это бинарное дерево, с каждым из внутренних узлов которого связан ключ, причем ключ в любом узле больше или равен ключам во всех узлах левого поддерева этого узла и меньше или равен ключам во всех узлах правого поддерева этого узла.

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

При наличии этой структуры рекурсивный алгоритм поиска ключа в BST-дереве становится очевидным: если дерево пусто, поиск неудачен; если ключ поиска равен ключу в корне, поиск успешен. Иначе выполняется (рекурсивно) поиск в соответствующем поддереве. В программе 12.8 этот алгоритм непосредственно реализуется функцией searchR. Начиная с корня дерева и искомого ключа, мы вызываем рекурсивную функцию, которая принимает дерево в качестве первого параметра и ключ в качестве второго. На каждом шаге гарантируется, что никакие части дерева, кроме текущего поддерева, не могут содержать элементы с искомым ключом. Подобно тому, как в бинарном поиске при каждой итерации размер интервала уменьшается чуть более чем в два раза, текущее поддерево в дереве бинарного поиска также меньше предшествующего (в идеальном случае приблизительно вдвое). Процедура завершается либо когда будет найден элемент с искомым ключом (успешный поиск), либо когда текущее поддерево станет пустым (неудачный поиск).

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

Программа 12.8. Таблица символов на основе дерева бинарного поиска

В этой реализации функции search и insert используют приватные рекурсивные функции searchR и insertR, которые непосредственно отражают рекурсивное определение BST-деревьев. Обратите внимание на передачу аргумента по ссылке в функции insertR (см. текст). Ссылка head указывает на корень дерева.

        template <class Item, class Key>
        class ST
{ private:
    struct node
      { Item item; node *l, *r;
        node(Item x)
{ item = x; l = 0; r = 0; }
      };
      typedef node *link;
      link head;
      Item nullItem;
      Item searchR(link h, Key v)
        { if (h == 0) return nullItem;
Key t = h->item.key();
if (v == t) return h->item;
if (v < t)
  return searchR(h->l, v);
else
  return searchR(h->r, v);
        }
        void insertR(link& h, Item x)
{ if (h == 0) { h = new node(x); return; }
  if (x.key() < h->item.key())
    insertR(h->l, x);
  else
    insertR(h->r, x);
}
      public:
        ST(int maxN)
{ head = 0; }
        Item search(Key v)
{ return searchR(head, v); }
        void insert(Item x)
{ insertR(head, x); }
  };
      

Для представления внешних узлов в программе 12.8 используются нулевые ссылки, а приватный член данных head указывает на корень дерева. Для создания пустого BST-дерева в head заносится нулевое значение. Можно также использовать фиктивный узел в корне и еще один для представления всех внешних узлов, как описано в различных вариантах для связных списков в таблица 3.1 (см. упражнение 12.53).

Поиск в программе 12.8 выполняется так же просто, как и обычный бинарный поиск; существенная особенность BST-деревьев заключается в том, что операцию вставить реализовать так же легко, как и операцию найти. Логика рекурсивной функции insertR, вставляющей новый элемент в BST-дерево, аналогична логике функции searchR: если дерево пусто, в h заносится ссылка на новый узел, содержащий этот элемент; если ключ поиска меньше ключа в корне, то элемент вставляется в левое поддерево, иначе элемент вставляется в правое поддерево. То есть аргумент, передаваемый по ссылке, изменяется лишь в последнем рекурсивном вызове, при вставке нового элемента. В разделе 12.8 и в лекция №13 будут рассмотрены более сложные древовидные структуры, которые естественным образом представляются с помощью этой же рекурсивной схемы, но которые чаще изменяют значение аргумента.

 Поиск и вставка в дереве бинарного поиска


Рис. 12.4.  Поиск и вставка в дереве бинарного поиска

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

На рис. 12.5 и рис. 12.6 продемонстрировано создание BST-дерева с помощью вставок последовательности ключей в первоначально пустое дерево. Новые узлы присоединяются к пустым ссылкам в нижней части дерева, а в остальном структура дерева никак не изменяется. Поскольку каждый узел имеет две ссылки, дерево растет скорее в ширину, нежели в высоту.

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

Программа 12.9. Сортировка с помощью BST-дерева

При поперечном обходе BST-дерева элементы посещаются в порядке возрастания их ключей. В этой реализации для вывода элементов в порядке возрастания их ключей используется функция-член show.

private:
  void showR(link h, ostream& os)
    { if (h == 0) return;
      showR(h->l, os);
      h->item.show(os);
      showR(h->r, os);
    }
public:
  void show(ostream& os)
    { showR(head, os); }
      

 Создание дерева бинарного поиска


Рис. 12.5.  Создание дерева бинарного поиска

Эта последовательность демонстрирует результат вставки ключей A S E R C H I N в первоначально пустое BST-дерево. Каждая вставка следует за неудачным поиском в нижней части дерева.

 Создание дерева бинарного поиска (продолжение)


Рис. 12.6.  Создание дерева бинарного поиска (продолжение)

Эта последовательность демонстрирует вставку ключей G X M P L в BST-дерево, создание которого было начато на рис. 12.5.

При необходимости последовательного посещения всех элементов таблицы символов мы будем обращаться к обобщенной операции посетить для таблиц символов. Элементы BST-дерева можно посетить в порядке возрастания их ключей, заменив в только что приведенном описании слово " вывести " на " посетить " и, возможно, обеспечив передачу в качестве параметра функции, выполняющей посещение элемента (см. лекция №5).

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

В функциях BST-дерева в программе 12.8 нет явных проверок на наличие элементов с повторяющимися ключами. При вставке нового узла, ключ которого равен какому-либо ключу, уже вставленному в дерево, узел помещается справа от присутствующего в дереве узла. Одним из побочных эффектов подобного соглашения является то, что узлы с равными ключами не являются соседями в дереве (см. рис. 12.7). Однако их можно найти, продолжив поиск с точки, в которой функция search находит первое совпадение, пока не встретится пустая ссылка. Как было сказано в лекция №9, существуют и другие возможности обработки элементов с одинаковыми ключами.

Деревья бинарного поиска - аналог быстрой сортировки. Узел в корне дерева соответствует центральному элементу при быстрой сортировке (ключи слева от него не могут быть больше, а ключи справа не могут быть меньше его). В разделе 12.6 будет показано, как это наблюдение связано с анализом свойств деревьев.

Программа 12.10. Вставка в BST-дерево (нерекурсивная)

Вставка элемента в BST-дерево эквивалентна выполнению неудачного поиска этого элемента с последующим присоединением нового узла с этим элементом вместо пустой ссылки в месте завершения поиска. Присоединение нового узла требует запоминания родительского узла p текущего узла q при перемещении вниз по дереву. При достижении нижней части дерева p указывает на узел, ссылка которого должна указывать на новый вставленный узел.

void insert(Item x)
  { Key v = x.key();
    if (head == 0)
      { head = new node(x); return; }
    link p = head;
    for (link q = p; q != 0; p = q ? q : p)
      q = (v < q->item.key()) ? q->l : q->r;
    if (v < p->item.key())
      p->l = new node(x);
    else
    p->r = new node(x);
  }
      

 Повторяющиеся ключи в деревьях бинарного поиска


Рис. 12.7.  Повторяющиеся ключи в деревьях бинарного поиска

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

Упражнения

12.46. Нарисуйте BST-дерево, образованное вставками элементов с ключами E A S Y Q U T I O N в первоначально пустое дерево.

12.47. Нарисуйте BST-дерево, образованное вставками элементов с ключами E A S Y Q U E S T I O N в первоначально пустое дерево.

12.48. Приведите количество сравнений, необходимых для помещения ключей E A S Y Q U E S T

I O N в первоначально пустую таблицу символов на основе BST-дерева. Считайте, что для каждого ключа выполняется операция найти, и затем, если поиск неудачен, операция вставить, как в программе 12.3.

12.49. Вставка ключей A S E R H I N G C в первоначально пустое дерево также дает дерево, показанное вверху рис. 12.6. Приведите десять других вариантов порядка этих ключей, которые дадут тот же результат.

12.50. Реализуйте функцию searchinsert для BST-деревьев (программа 12.8). Она должна искать в таблице символов элемент с таким же ключом, как и у данного элемента, а затем вставлять элемент, если такой ключ не найден.

12.51. Напишите функцию, которая возвращает количество элементов в BST-дереве с ключом, равным данному.

12.52. Предположим, что заранее известна частота обращения к ключам поиска в бинарном дереве.

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

12.53. Упростите код поиска и вставки в реализации BST-дерева в программе 12.8 с помощью двух фиктивных узлов: узла head, содержащего элемент с сигнальным ключом, который меньше всех остальных ключей, и правая ссылка которого указывает на корень дерева; и узла z, содержащего элемент с сигнальным ключом, который больше всех остальных ключей, и обе ссылки которого указывает на него самого, причем он представляет все внешние узлы (внешние узлы являются ссылками на z). (См. таблица 3.1).

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

12.55. В нерекурсивной процедуре вставки, приведенной в программе 12.10, для определения того, какую ссылку узла p необходимо заменить новым узлом, используется лишнее сравнение. Приведите реализацию, в которой это сравнение исключено.

Характеристики производительности деревьев бинарного поиска

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

Можно также надеяться, что время поиска в среднем также будет логарифмическим, поскольку первый вставляемый элемент становится корнем дерева: если N ключей должны быть вставлены в произвольном порядке, то этот элемент должен поделить ключи пополам (в среднем), что дает логарифмическое время поиска (рассуждая аналогично по всем поддеревьям). Действительно, возможен случай, когда BST-дерево приводит в точности к тем же сравнениям, что и бинарный поиск (см. упражнение 12.58). Этот случай был бы наилучшим для данного алгоритма, гарантируя логарифмическое время выполнения для любого поиска. В действительно произвольной ситуации корнем может быть любой ключ, поэтому идеально сбалансированные деревья встречаются исключительно редко, и сохранять дерево полностью сбалансированным после каждой вставки нелегко. Однако полностью несбалансированные деревья для случайных ключей также встречаются редко, поэтому в среднем деревья достаточно хорошо сбалансированы. В этом разделе мы детализируем это наблюдение.

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

Лемма 12.6. В дереве бинарного поиска, образованном N случайными ключами, для успешного поиска в среднем требуется около сравнений.

Как было сказано в разделе 12.3, мы считаем последовательные операции ==и < одной операцией сравнения. Количество сравнений, нужных для успешного поиска, завершающегося в данном узле, равно 1 плюс расстояние от этого узла до корня. Просуммировав эти расстояния по всем узлам дерева, мы получим его внутреннюю длину пути. Таким образом, интересующая нас величина равна 1 плюс средняя длина внутреннего пути BST-дерева, которую можно проанализировать с помощью уже знакомых рассуждений: если CN - средняя длина внутреннего пути BST-дерева, состоящего из N узлов, то верно следующее рекуррентное соотношение:

,

при C1 = 1. Член N - 1 учитывает, что корень увеличивает длину пути для каждого из остальных N - 1 узлов на 1; остальная часть выражения следует из того, что ключ в корне (вставленный первым) с равной вероятностью может быть k-ым по величине ключом, разбивая дерево на случайные поддеревья размерами k - 1 и N-k. Это рекуррентное соотношение почти идентично тому, которое было решено влекция №7 для быстрой сортировки, и его можно решить тем же способом, получив искомый результат.

Лемма 12.7. В дереве бинарного поиска, образованном N случайными ключами, для вставок и неудачного поиска в среднем требуется около сравнений.

Поиск произвольного ключа в дереве, содержащем N узлов, с равной вероятностью может завершиться неудачей в любом из N + 1 внешних узлов. Это свойство в сочетании с тем фактом, что разница длин внешнего и внутреннего пути в любом дереве равна просто 2N (см. лемму 5.7), и дает искомый результат. В любом BST-дереве среднее количество сравнений, необходимых для выполнения вставки или неудачного поиска, приблизительно на 1 больше среднего количества сравнений, необходимых для успешного поиска.

В соответствии с леммой 12.6 следует ожидать, что затраты на поиск для BST-деревьев должны быть приблизительно на 39% выше затрат для бинарного поиска для случайных ключей. Но в соответствии с леммой 12.7 эти дополнительные затраты вполне окупаются, поскольку новый ключ может быть вставлен почти при тех же затратах - бинарному поиску подобная гибкость недоступна. На рис. 12.8 показано BST-дерево, полученное из длинной последовательности случайных перестановок. Оно содержит несколько длинных и несколько коротких путей, но все-таки его можно считать хорошо сбалансированным: для выполнения любого поиска требуется менее 12 сравнений, а среднее количество сравнений, необходимых для успешного поиска произвольного элемента, равно 7,00, при 5,74 для бинарного поиска.

Леммы 12.6 и 12.7 определяют производительность в среднем при условии, что ключи расположены в произвольном порядке. Если это не так, производительность алгоритма может ухудшиться.

Лемма 12.8. Для поиска в дереве бинарного поиска с N ключами в худшем случае может потребоваться N сравнений.

На рис. 12.9 и 12.10 показаны два примера худших случаев BST-деревьев. Для этих деревьев поиск с использованием бинарного дерева ничем не лучше последовательного поиска в односвязных списках.

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

 Пример дерева бинарного поиска


Рис. 12.8.  Пример дерева бинарного поиска

В этом BST-дереве, которое было построено вставками около 200 произвольных ключей в первоначально пустое дерево, ни один поиск не использует более 12 сравнений. Средняя стоимость успешного поиска приблизительно равна 10.

 Худший случай дерева бинарного поиска


Рис. 12.9.  Худший случай дерева бинарного поиска

Если ключи вставляются в BST-дерево в порядке возрастания, дерево вырождается в форму, эквивалентную односвязному списку, что приводит к квадратичному времени создания дерева и к линейному времени поиска.

 Еще один худший случай дерева бинарного поиска


Рис. 12.10.  Еще один худший случай дерева бинарного поиска

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

В главе 13 лекция №13 будут рассмотрены способы превращения этого худшего случая в крайне маловероятный, полного исключения худшего случая и превращения всех деревьев в деревья как для лучшего случая, где длины всех путей гарантированно логарифмические.

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

Упражнения

12.56. Напишите рекурсивную программу, которая вычисляет максимальное количество сравнений, требуемых для любого поиска в данном BST-дереве (высоту дерева).

12.57. Напишите рекурсивную программу, которая вычисляет среднее количество сравнений, требуемых для успешного поиска в данном BST-дереве (длину внутреннего пути дерева, деленную на N).

12.58. Приведите такую последовательность вставок ключей E A S Y Q U E S T I O N в первоначально пустое BST-дерево, чтобы созданное при этом дерево было эквивалентно бинарному поиску - в том смысле, что последовательность сравнений, выполняемых при поиске любого ключа в BST-дереве, совпадали бы с последовательностью сравнений, выполняемых при бинарном поиске на том же множестве ключей.

12.59. Напишите программу, которая вставляет набор ключей в первоначально пустое BST-дерево так, чтобы созданное дерево было эквивалентно бинарному поиску, в смысле, описанном в упражнении 12.58.

12.60. Нарисуйте все различные по структуре BST-деревья, которые могут образоваться после вставки N ключей в первоначально пустое дерево, для .

12.61. Для каждого из деревьев из упражнения 12.60 определите вероятность того, что оно получится в результате вставки N произвольных различных элементов в первоначально пустое дерево.

12.62. Сколько бинарных деревьев, состоящих из N узлов, имеют высоту N? Сколько существует различных способов вставки N различных ключей в первоначально пустое дерево, приводящих к образованию BST-дерева с высотой N ?

12.63. Докажите методом индукции, что разница между длинами внешнего и внутреннего путей в любом бинарном дереве составляет 2N (см. лемму 5.7).

12.64. Определите эмпирически среднее значение и среднеквадратичное отклонение количества сравнений при успешных и неудачных поисках в BST-дереве, созданном вставкой N случайных ключей в первоначально пустое дерево, для N = 103, 104, 105 и 106 .

12.65. Напишите программу, которая строит t BST-деревьев вставкой N случайных ключей в первоначально пустое дерево и вычисляет максимальную высоту дерева (максимальное количество сравнений, необходимых для неудачного поиска при вставке в любом из этих t деревьев), для N = 103, 104, 105 и 106 при t = 10, 100 и 1000.

Индексные реализации таблиц символов

Во многих приложениях необходимо выполнять поиск в структуре, чтобы просто найти элемент, но не перемещать его. Например, может существовать массив элементов с ключами, для которого требуется метод поиска, определяющий индекс элемента в массиве, который соответствует заданному ключу. Может также требоваться удаление элемента с данным индексом из структуры поиска, но с сохранением в массиве для какого-либо другого применения. В лекция №9 были рассмотрены преимущества обработки индексированных элементов в очередях с приоритетами, где выполняется косвенное обращение к данным клиентского массива. Применительно к таблицам символов эта же концепция приводит к уже знакомым индексам - внешней по отношению к набору элементов поисковой структуре, которая обеспечивает быстрый доступ к элементам с данным ключом. В лекция №16 будет рассматриваться случай, когда элементы и, возможно, даже индексы хранятся во внешней памяти; в этом разделе мы кратко ознакомимся со случаем, когда и элементы, и индексы находятся в оперативной памяти.

Деревья бинарного поиска можно определить таким образом, чтобы индексы строились в точности так же, как при обеспечении косвенной сортировки в лекция №6 и для пирамидальных деревьев в лекция №9: мы используем оболочку Index для определения элементов BST-дерева и обеспечим извлечение ключей из элементов, как обычно, через функцию-член key. А для ссылок можно задействовать параллельный массив, как это было сделано для связных списков в лекция №3. Мы будем использовать три массива: для элементов, левых ссылок и правых ссылок. Ссылки являются (целочисленными) индексами массивов, и обращения вроде

x = x->l
      

во всем коде заменяются на обращения

x = l[x]
      

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

Такой способ реализации BST-деревьев как средства упрощения поиска в больших массивах элементов иногда весьма полезен, поскольку исключает дополнительные затраты на копирование элементов во внутреннее представление АТД и излишние действия по их размещению и созданию операцией new. Использование массивов не годится, когда объем памяти играет первостепенную роль, а таблица символов увеличивается и уменьшается в значительных пределах. В особенности это актуально, если заранее трудно оценить максимальный размер таблицы символов. В таком случае неиспользуемые ссылки в массиве элементов могут привести к напрасному расходу памяти.

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

Программа 12.11. Пример индексирования текстовой строки

В этой программе считается, что в файле Item.cxx определены представление данных char* для строковых ключей в элементах, перегруженная операция <, которая использует функцию strcmp, перегруженная операция ==, которая использует функцию strncmp, и оператор преобразования из Item в char* (см. текст). Главная программа считывает текстовую строку из указанного файла и использует таблицу символов для построения индекса из строк, начинающихся в каждой позиции текстовой строки. Затем она считывает из стандартного ввода запрашиваемые строки и выводит позицию, в которой они найдены в тексте (или выводит строку не найдено). При реализации таблицы символов на основе BST-дерева поиск выполняется быстро даже для очень больших строк.

#include <iostream.h>
#include <fstream.h>
#include "Item.cxx"
#include "ST.cxx"
static char text[maxN];
int main(int argc, char *argv[])
  { int N = 0; char t;
    ifstream corpus; corpus.open(*++argv);
    while (N < maxN && corpus.get(t)) text[N++] = t;
    text[N] = 0;
    ST<Item, Key> st(maxN);
    for (int i = 0; i < N; i++) st.insert(&text[i]);
    char query[maxQ]; Item x, v(query);
    while (cin.getline(query, maxQ))
      if ((x = st.search(v.key())).null())
        cout << "не найдено: " << query << endl;
    else
    cout << x-text << ": " << query << endl;
  }
      

 Индексирование текстовой строки


Рис. 12.11.  Индексирование текстовой строки

В этом примере индекса строки строковый ключ определен так, чтобы он начинался с каждого слова в тексте; затем строится BST-дерево с помощью обращения к ключам по их индексам в строке. В принципе, ключи имеют произвольную длину, но на практике обычно просматриваются только несколько начальных символов. Например, для определения того, встречается ли в этом тексте фраза never mind, она сравнивается с call... в корне (индекс 0), затем с me... в правом дочернем узле корня (индекс 5), затем с some... в правом дочернем узле этого узла (индекс 16), а затем в левом дочернем узле предпоследнего узла (индекс 31) обнаруживается и never mind.

Программа 12.11 последовательно считывает запросы из стандартного ввода, вызывает функцию search для определения присутствия запрашиваемых строк в тексте и выводит позицию первого совпадения с запросом. Если таблица символов реализована на основе BST-дерева, то в соответствии с леммой 12.6 можно ожидать, что для поиска потребуется порядка 2NlnN сравнений. Например, после построения индекса любую фразу в тексте, состоящем приблизительно из 1 миллиона символов, можно найти с помощью около 30 операций сравнения строк. Это приложение равносильно индексированию, поскольку указатели C-строк являются индексами массива символов: если x указывает на text[i], то разность двух указателей x-text равна i.

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

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

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

Таблица 12.2. Эмпирическое сравнение реализаций таблиц символов
NСозданиеУспешный поиск
ALBTT*ALBTT*
125015610613011
250002124212752111
500008710143111211223
125006457321297091398789
25000255129172420285958811521
5000061503848
100000154122104122
200000321275200272
Обозначения:
A Неупорядоченный массив (упражнение 12.20)
L Упорядоченный связный список (упражнение 12.21)
B Бинарный поиск (программа 12.7)
T Дерево бинарного поиска, стандартное (программа 12.8)
T* Индексное дерево бинарного поиска (упражнение 12.67)

Упражнения

12.66. Измените реализацию BST-дерева из программы 12.8, чтобы использовать индексированный массив элементов, а не выделенную память. Сравните производительность полученной программы с производительностью стандартной реализации, воспользовавшись драйвером из упражнения 12.23 или упражнения 12.24.

12.67. Измените реализацию BST-дерева из программы 12.8, чтобы она поддерживала АТД символьной таблицы с клиентскими дескрипторами элементов (см. упражнение 12.7), используя параллельные массивы. Сравните производительность полученной программы с производительностью стандартной реализации, воспользовавшись драйвером из упражнения 12.23 или упражнения 12.24.

12.68. Измените реализацию BST-дерева из программы 12.8 следующим образом: используйте массив элементов с ключами и массив ссылок (по одной для каждого элемента) в узлах дерева. Левая ссылка в BST-дереве соответствует перемещению в следующую позицию в массиве в узле дерева, а правая ссылка в BST-дереве соответствует перемещению в другой узел дерева.

12.69. Приведите пример текстовой строки, где количество строковых сравнений для этапа создания индекса в программе 12.11 квадратично зависит от длины строки.

12.70. Измените реализацию индексирования строки (программа 12.11), чтобы для построения индекса использовались только ключи, начинающиеся на границах слов (см. рис. 12.11). (Для книги " Моби Дик " это изменение уменьшает размер индекса более чем в пять раз.)

12.71. Реализуйте версию программы 12.11, в которой используется бинарный поиск в массиве указателей на строки с помощью реализации из упражнения 12.38.

12.72. Сравните время выполнения вашей реализации из упражнения 12.71 с программой 12.11 при построении индекса для случайной текстовой строки из N символов, для N = 103, 104, 105 и 106, и при выполнении 1000 (неудачных) поисков для случайных ключей в каждом индексе.

Вставка в корень в деревьях бинарного поиска

В стандартной реализации BST-деревьев каждый вновь вставленный узел попадает куда-то в нижнюю часть дерева, заменяя некоторый внешний узел. Это не обязательное требование, а лишь следствие естественного алгоритма с использованием рекурсивных вставок. В этом разделе рассматривается другой метод вставки, при котором каждый новый элемент вставляется в корень, и поэтому недавно вставленные узлы находятся вблизи вершины дерева. Построенные таким образом деревья обладают рядом интересных свойств, но главная причина изучения этого метода в том, что он играет важную роль в двух усовершенствованных алгоритмах, которые будут рассмотрены в лекция №13.

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

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

К счастью, существует простое рекурсивное решение этой проблемы, основанное на ротации (rotation) - фундаментальном преобразовании деревьев. По существу, ротация позволяет менять местами роль корня и одного из его потомков, сохраняя BST-упорядоченность ключей в узлах дерева. Ротация вправо затрагивает корень и его левый дочерний узел (см. рис. 12.12). Эта ротация перемещает корень вправо, изменяя на обратное направление левой ссылки корня: перед ротацией она указывает от корня на левый дочерний узел, а после ротации - от старого левого потомка (нового корня) на старый корень (правый дочерний узел нового корня). Основная часть, которая обеспечивает работу ротации - копирование правой ссылки левого потомка, чтобы она стала левой ссылкой старого корня. Эта ссылка указывает на все узлы с ключами между двумя узлами, участвующими в ротации. После этого нужно изменить ссылку на старый корень так, чтобы она указывала на новый корень. Описание ротации влево аналогично вышеприведенному, только везде слово " правый " должно быть заменено на " левый " и наоборот (см. рис. 12.13).

 Ротация вправо в BST-дереве


Рис. 12.12.  Ротация вправо в BST-дереве

На этой диаграмме показан результат (внизу) ротации вправо в узле S BST-дерева, приведенного вверху. Узел, содержащий ключ S, перемещается в дереве вниз и становится правым дочерним узлом своего прежнего левого дочернего узла.

Для выполнения ротации мы получаем ссылку на новый корень E из левой ссылки узла S, копируем в левую ссылку S правую ссылку E, в правую ссылку E - указатель на S и заменяем ссылку из A на S указателем на E. Эффект ротации заключается в перемещении узла E и его левого поддерева на один уровень вверх, а узла S и его правого поддерева - на один уровень вниз. Остальная часть дерева остается неизменной.

 Ротация влево в BST-дереве


Рис. 12.13.  Ротация влево в BST-дереве

На этой диаграмме показан результат (внизу) ротации вправо в узле A BST-дерева, приведенного вверху. Узел, содержащий ключ A, перемещается в дереве вниз и становится левым дочерним узлом своего прежнего правого дочернего узла.

Для выполнения ротации мы получаем ссылку на новый корень E из правой ссылки узла A, копируем в правую ссылку A левую ссылку E, в левую ссылку E - указатель на A и заменяем ссылку на A (верхняя ссылка дерева) указателем на E.

Ротация - это локальное изменение, затрагивающее только три ссылки и два узла; оно позволяет перемещать узлы по деревьям без изменения глобальных свойств упорядоченности, которые и делают BST-дерево полезной для поиска структурой (см. программу 12.12). Ротации применяются для перемещения конкретных узлов по дереву и предотвращения разба-лансировки деревьев. В разделе 12.9 с помощью ротаций будут реализованы операции удалить, объединить и другие операции АТД; в лекция №13 они будут применяться для построения деревьев, дающих почти оптимальную производительность.

Программа 12.12. Ротации в BST-деревьях

Эти две симметричные процедуры выполняют операцию ротация в BST-дереве. Ротация вправо делает старый корень правым поддеревом нового корня (старого левого поддерева корня); ротация влево делает старый корень левым поддеревом нового корня (старого правого поддерева корня). Для реализаций, где в узлах содержится поле счетчика (например, для поддержки операции выбрать в лекция №14), необходимо также пересчитывать значений этих полей для участвующих в ротации узлах (см. упражнение 12.75).

void rotR(link& h)
  { link x = h->l; h->l = x->r; x->r = h; h = x; }
void rotL(link& h)
  { link x = h->r; h->r = x->l; x->l = h; h = x; }
      

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

 Вставка в корень BST-дерева


Рис. 12.14.  Вставка в корень BST-дерева

Здесь показан результат вставки узла G в BST-дерево, приведенное на верхнем рисунке, с (рекурсивной) ротацией после вставки, которая перемещает вставленный узел G в корень. Этот процесс эквивалентен вставке G с последующим выполнением последовательности ротаций для перемещения его в корень.

Программа 12.13. Вставка в корень BST-дерева

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

        private:
void insertT(link& h, Item x)
  { if (h == 0) { h = new node(x); return; }
    if (x.key() < h->item.key())
      { insertT(h->l, x); rotR(h); }
    else
      { insertT(h->r, x); rotL(h); }
  }
        public:
void insert(Item item)
  { insertT(head, item); }
      

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

На рис. 12.15 и рис. 12.16показано создание BST-дерева вставкой последовательности ключей в первоначально пустое дерево с использованием метода вставки в корень. Если последовательность ключей случайна, созданное таким образом BST-дерево обладает в точности теми же стохастическими свойствами, что и BST-дерево, созданное стандартным методом. Например, леммы 12.6 и 12.7 справедливы и для BST-деревьев, построенных вставками в корень. На практике преимущество метода вставки в корень состоит в том, что недавно вставленные ключи располагаются вблизи вершины. Следовательно, затраты на удачный поиск недавно вставленных ключей будут, скорее всего, ниже, чем при стандартном методе. Это важное свойство, поскольку многим приложениям присуща именно такая динамическая смесь операций найти и вставить. Таблица символов может содержать довольно большое количество элементов, но значительная часть поисков может относиться к самым последним вставленным элементам. Например, в системе обработки коммерческих транзакций активные транзакции могут оставаться вблизи вершины и обрабатываться быстро без обращения к старым потерянным транзакциям. Метод вставки в корень автоматически придает структуре данных это и аналогичные свойства.

 Построение BST-дерева вставками в корень


Рис. 12.15.  Построение BST-дерева вставками в корень

Эта последовательность демонстрирует результат вставки ключей A S E R C H I в корень первоначально пустого BST-дерева. После вставки в корень каждого нового узла изменяются ссылки, расположенные вдоль его пути поиска, чтобы получилось правильное BST-дерево.

 Построение BST-дерева вставками в корень (продолжение)


Рис. 12.16.  Построение BST-дерева вставками в корень (продолжение)

Эта последовательность демонстрирует вставку ключей N G X M P L в BST-дерево, построение которого начато на рис. 12.15.

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

Как и для ряда других методов, упомянутых в этой главе, для реальных приложений трудно точно сравнить производительность метода вставки в корень со стандартным методом вставки, поскольку производительность настолько зависит от смеси различных операций с таблицей символов, что ее трудно проанализировать аналитически. Невозможность проанализировать алгоритм не обязательно должна удерживать от использования вставки в корень, когда известно, что основная масса поисков будет связана с недавно вставленными данными, однако мы всегда пытаемся найти гарантированные показатели производительности. Методы построения BST-деревьев, которые могут предоставить такие гарантии, являются основной темой лекция №13.

Упражнения

12.73. Нарисуйте BST-дерево, образованное вставками элементов с ключами E A S Y Q U E S T I O N в корень первоначально пустого дерева.

12.74. Приведите последовательность из 10 ключей (используя буквы от A до J), которая требует максимального количества сравнений при создании дерева вставками в корень первоначально пустого дерева. Укажите количество используемых сравнений.

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

12.76. Разработайте нерекурсивную реализацию вставки в корень BST-дерева (см. программу 12.13).

12.77. Эмпирически определите среднее значение и среднеквадратичное отклонение количества сравнений, выполняемых при успешных и неудачных поисках в BST-дереве, которое построено вставками N случайных ключей в первоначально пустое дерево. После построения в этом дереве выполняется последовательность N произвольных поисков N/10 самых последних вставленных ключей для N = 103, 104, 105 и 106 . Проведите эксперименты и для стандартного метода вставки, и для метода вставки в корень, а затем сравните полученные результаты.

Реализации других функций АТД с помощью BST -ДЕРЕВА

Рекурсивные реализации из раздела 12.5 для основных операций найти, вставить и сортировать, использующие структуру бинарных деревьев, достаточно просты. В этом разделе мы рассмотрим реализации функций выбрать, объединить и удалить. Одна из них, выбрать, также допускает естественную рекурсивную реализацию, однако для других это может оказаться трудной задачей, приводящей к потере производительности. Операцию выбрать важно рассмотреть потому, что возможность эффективной поддержки операций выбрать и сортировать - одна из причин, по которой для многих приложений BST-деревья оказываются удобнее других структур. Хотя некоторые программисты стараются не использовать BST-деревья, чтобы не возиться с операцией удалить. В этом разделе рассматривается компактная реализация всех этих операций, использующая технику ротации к корню из раздела 12.8.

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

Для реализации операции выбрать можно использовать рекурсивную процедуру, аналогичную методу выборки на основе быстрой сортировки, описанному в лекция №7. Для отыскания в BST-дереве элемента с k-ым наименьшим ключом проверяется количество узлов в левом поддереве. Если там к узлов, возвращается корневой элемент. Иначе, если левое поддерево содержит более к узлов, в нем (рекурсивно) отыскивается к-й наименьший узел. Если неверно ни одно из этих условий, то левое поддерево содержит t элементов при t < k, и k-й наименьший элемент в BST-дереве является (k - t - 1)- ым наименьшим элементом в правом поддереве. Программа 12.14 является непосредственной реализацией этого метода. Как обычно, поскольку каждое выполнение функции завершается максимум одним рекурсивным вызовом, очевидна и нерекурсивная версия (см. упражнение 12.78).

Программа 12.14. Выборка с помощью BST-дерева

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

private:
  Item selectR(link h, int k)
  { if (h == 0) return nullItem;
    int t = (h->l == 0) ? 0: h->l->N;
    if (t > k) return selectR(h->l, k);
    if (t < k) return selectR(h->r, k-t-1);
    return h->item;
  }
public:
  Item select(int k)
  { return selectR(head, k); }
      

Реализация операции выбрать - основная алгоритмическая причина включения поля размера поддерева во все узлы BST-дерева. С помощью этого поля можно также обеспечить тривиальную " энергичную " реализацию операции подсчитать (возврат значения поля счетчика в корневом узле); в лекция №13 будет продемонстрировано еще одно применение. Недостатки присутствия поля счетчика заключаются в использовании дополнительной памяти для каждого узла и необходимости обновления поля каждой функцией, изменяющей дерево. Использование поля размера поддерева может не окупаться в некоторых приложениях, в которых основным операциями являются вставить и найти, но эта плата может оказаться незначительной, если в динамической таблице символов важна поддержка операции выбрать.

Эту реализацию операции выбрать можно преобразовать в операцию разбить (на части - partition), которая реорганизует дерево для помещения к-го наименьшего элемента в корень, используя точно такую же рекурсивную технику, которая использовалась для вставки в корень в разделе 12.8: если мы (рекурсивно) помещаем требуемый узел в корень одного из поддеревьев, его затем с помощью единственной ротации можно сделать корнем всего дерева. Программа 12.15 содержит реализацию этого метода. Подобно ротациям, разбиение не является операцией АТД, поскольку эта функция преобразует конкретное представление таблицы символов и должна быть прозрачной для клиентов. Скорее, это вспомогательная процедура, которую можно использовать для реализации операций АТД либо для повышения их эффективности. На рис. 12.17 приведен пример, показывающий, аналогично трис. 12.14, что этот процесс эквивалентен спуску по пути от корня до требуемого узла дерева, а затем подъему обратно с выполнением ротаций для перемещения этого узла в корень.

Программа 12.15. Разбиение BST-дерева

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

void partR(link& h, int k)
  { int t = (h->l == 0) ? 0 : h->l->N;
    if (t > k)
      { partR(h->l, k); rotR(h); }
    if (t < k)
      { partR(h->r, k-t-1); rotL(h); }
  }
      

 Разбиение BST-дерева


Рис. 12.17.  Разбиение BST-дерева

Здесь показан результат (внизу) разбиения BST-дерева (вверху) по медианному ключу; при этом выполняется (рекурсивно) ротация - точно так же, как и при вставке в корень.

Чтобы удалить из BST-дерева узел с заданным ключом, вначале необходимо проверить, находится ли он в одном из поддеревьев. Если да, мы заменяем это поддерево результатом (рекурсивного) удаления из него данного узла. Если удаляемый узел находится в корне, дерево заменяется результатом объединения двух поддеревьев в одно. Для выполнения такого объединения существует несколько возможностей. Один из возможных подходов проиллюстрирован на рис. 12.18, а реализация представлена в программе 12.16.

 Удаление корня в BST-дереве


Рис. 12.18.  Удаление корня в BST-дереве

Здесь показан результат (внизу) удаления корня из BST-дерева (вверху). Вначале после удаления корневого узла остаются два поддерева (второй сверху рисунок). Затем мы разбиваем правое поддерево для помещения его наименьшего элемента в корень (третий сверху рисунок) - при этом левая ссылка указывает на пустое поддерево.

И, наконец, мы заменяем эту ссылку указателем на левое поддерево исходного дерева (внизу).

Программа 12.16. Удаление узла с заданным ключом из BST-дерева

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

private:
  link joinLR(link a, link b)
  { if (b == 0) return a;
    partR(b, 0); b->l = a;
    return b;
  }
void removeR(link& h, Key v)
  { if (h == 0) return;
    Key w = h->item.key();
    if (v < w) removeR(h->l, v);
    if (w < v) removeR(h->r, v);
    if (v == w)
      { link t = h; h = joinLR(h->l, h->r);
      delete t; }
  }
public:
  void remove(Item x)
    { removeR(head, x.key()); }
      

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

 Удаление узла из BST-дерева


Рис. 12.19.  Удаление узла из BST-дерева

Здесь показан результат удаления узлов с ключами L, H и E из BST-дерева, показанного на верхнем рисунке. Вначале L просто удаляется, поскольку он расположен внизу. Затем H заменяется его правым дочерним узлом I, поскольку левый дочерний узел I пуст. И, наконец, E заменяется своим потомком G.

Этот подход асимметричен и в одном отношении произволен: почему в качестве корня нового дерева используется наименьший ключ второго дерева, а не наибольший ключ первого дерева? Другими словами, почему удаляемый узел заменяется следующим узлом в поперечном обходе дерева, а не предыдущим? Возможны и другие подходы. Например, если у удаляемого узла левая ссылка пуста, почему бы просто не сделать новым корнем его правый дочерний узел, а не узел с наименьшим ключом в правом поддереве? Было предложено много аналогичных модификаций базовой процедуры удаления. К сожалению, всем им присущ один и тот же недостаток: после удаления дерево перестает быть случайным, даже если оно было случайным до этого. Кроме того, было показано, что если дерево подвергается большому количеству случайных пар операций удаления-вставки, то программа 12.16 склонна оставлять дерево слегка несбалансированным (средняя высота пропорциональна ) (см. упражнение 12.84).

Эти различия могут быть не заметны в реальных приложениях, если только N не очень велико. Тем не менее, такое сочетание не очень элегантного алгоритма с неудовлетворительными характеристиками производительности не радует. В лекция №13 будут рассмотрены два различных способа исправления этой ситуации.

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

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

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

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

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

Последняя операция для АТД таблиц символов, которую мы рассмотрим - операция объединить. В реализации на основе BST-дерева она сводится к слиянию двух деревьев. Как объединить два дерева бинарного поиска в одно? Существуют различные алгоритмы для выполнения этой задачи, но каждому из них присущи определенные недостатки. Например, можно выполнить обход первого BST-дерева, вставляя каждый из его узлов во второе BST-дерево (этот алгоритм можно записать одной строкой: в параметра подпрограммы обхода первого BST-дерева нужно передавать функцию вставки во второе BST-дерево). Время выполнения подобного решения не линейно, поскольку для каждой вставки может требоваться линейное время. Другой вариант - обход обоих BST-деревьев, занесение всех элементов в массив, их объединение и затем построение нового BST-дерева. Эту операцию можно выполнить за линейное время, но для нее нужен потенциально большой массив.

Программа 12.17 - компактная рекурсивная реализация операции объединить с линейным временем выполнения. Вначале мы вставляем корень первого BST-дерева во второе BST-дерево, используя метод вставки в корень. Эта операция дает два поддерева, ключи которых меньше этого корня, и два поддерева, ключи которых больше этого корня, поэтому требуемый результат получается (рекурсивным) объединением первой пары в левое поддерево корня, а второй пары - в правое поддерево корня (!). При каждом рекурсивном вызове каждый узел может оказаться корневым максимум один раз, поэтому общее время линейно. Пример работы этого алгоритма показан на рис. 12.20. Как и удаление, этот процесс асимметричен и может приводить к не очень сбалансированным деревьям, однако, как будет показано в лекция №13, эта проблема легко устраняется рандомизацией. Обратите внимание, что в худшем случае количество сравнений, использованных для выполнения операции объединить, должно быть по крайней мере линейным; иначе можно было бы разработать алгоритм сортировки с менее чем NlgN сравнений, применяя такой подход, как восходящая сортировка слиянием (см. упражнение 12.88).

Программа 12.17. Объединение двух BST-деревьев

Если одно из BST-деревьев пустое, второе является результатом. Иначе два BST-дерева объединяются путем (произвольного) выбора корня первого дерева в качестве результирующего корня, вставки этого корня в корень второго дерева, а затем (рекурсивного) объединения пары левых поддеревьев и пары правых поддеревьев.

private:
  link joinR(link a, link b)
  { if (b == 0) return a;
    if (a == 0) return b;
    insertT(b, a->item);
    b->l = joinR(a->l, b->l);
    b->r = joinR(a->r, b->r);
    delete a; return b;
  }
public:
  void join(ST<Item, Key>& b)
    { head = joinR(head, b.head); }
      

В программу не включен код, необходимый для поддержки полей счетчиков в узлах BST-дерева во время выполнения операций объединить и удалить - он может понадобиться в приложениях, где требуется и операция выбрать (программа 12.14). Концептуально эта задача проста, однако требует определенных усилий. Один из стандартных способов ее выполнения - реализация небольшой вспомогательной процедуры, которая устанавливает значение поля счетчика в узле на единицу больше, чем сумма полей счетчиков в его дочерних узлах, а затем вызов этой процедуры для каждого узла, у которого изменены ссылки. В частности, это можно выполнить для обоих узлов в процедурах rotL и rotR из программы 12.12, что достаточно для преобразований в программах 12.13 и 12.15, поскольку они преобразуют деревья исключительно путем ротаций. Для функций joinLR и removeR в программе 12.16 и join в программе 12.17 достаточно вызвать процедуру обновления счетчика для возвращаемого узла непосредственно перед оператором return.

 Объединение двух BST-деревьев


Рис. 12.20.  Объединение двух BST-деревьев

Здесь показан результат (внизу) объединения двух BST-деревьев (вверху). Вначале мы вставляем корень G первого дерева во второе дерево, используя вставку в корень (второй сверху рисунок). У нас остаются два поддерева, ключи которых меньше G, и два поддерева с ключами, большими G. Объединение обеих пар (рекурсивно) дает конечный результат (внизу).

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

Несмотря на всю полезность, существует два основных недостатка использования BST-деревьев в приложениях. Во-первых, они требуют существенного дополнительного объема памяти под ссылки. Часто ссылки и записи имеют практически одинаковые размеры (скажем, одно машинное слово) - если это так, реализация с использованием BST-дерева использует две трети выделенного для него объема памяти под ссылки и только одну треть под ключи. Этот эффект менее важен в приложениях с большими записями и более важен в средах, в которых указатели велики. Если же память играет первостепенную роль, лучше вместо BST-деревьев предпочесть один из методов хеширования с открытой адресацией, описанных в лекция №14.

Второй недостаток использования BST-деревьев - возможность того, что деревья могут стать плохо сбалансированными и в результате ухудшить производительность. В лекция №13 будут рассмотрены несколько подходов, гарантирующих хорошую производительность. При наличии достаточного объема памяти под ссылки эти алгоритмы делают BST-деревья весьма привлекательными в качестве основы для реализации АТД таблиц символов, поскольку обеспечивают гарантированно высокую производительность для большого набора полезных операций АТД.

Упражнения

12.78. Реализуйте нерекурсивную функцию выбрать для BST-дерева (см. программу 12.14).

12.79. Нарисуйте BST-дерево, образованное вставками элементов с ключами E A S Y Q U T I O N в первоначально пустое дерево и последующим удалением Q.

12.80. Нарисуйте BST-дерево, образованное вставками элементов с ключами E A S Y в первоначально пустое дерево, вставками элементов с ключами Q U E S T I O N в другое первоначально пустое дерево и последующего объединения результатов.

12.81. Реализуйте нерекурсивную функцию удалить для BST-дерева (см. программу 12.16).

12.82. Реализуйте версию операции удалить для BST-деревьев (программа 12.16), которая удаляет все узлы дерева с ключами, равными данному.

12.83. Измените реализации таблиц символов, основанные на BST-дереве, чтобы они поддерживали клиентские дескрипторы элементов (см. упражнение 12.7); добавьте реализации деструктора, конструктора копирования и перегруженной операции присваивания (см. упражнение 12.6); добавьте операции удалить и объединить; воспользуйтесь программой-драйвером из упражнения 12.22 для проверки полученных интерфейса и реализации АТД первого класса для таблицы символов.

12.84. Экспериментально определите увеличение высоты BST-дерева при выполнении длинной последовательности чередующихся случайных операций вставки и удаления в случайном дереве с N узлами, для N = 10, 100 и 1000, если для каждого значения N выполняется до N2 пар вставок-удалений.

12.85. Реализуйте версию функции remove (см. программу 12.16), которая принимает случайное решение, заменять ли удаляемый узел его узлом-предком или узлом-потомком в дереве. Проведите экспериментальное исследование этой версии, как описано в упражнении 12.84.

12.86. Реализуйте версию функции remove, которая использует рекурсивную функцию для перемещения удаляемого узла в нижнюю часть дерева при помощи ротации, подобно вставке в корень (программа 12.13). Нарисуйте дерево, образованное в результате удаления этой программой корня из полного дерева, содержащего 31 узел.

12.87. Экспериментально определите увеличение высоты BST-дерева при многократной вставке элемента из корня в дерево, образованное объединением поддеревьев корня в случайное дерево из N узлов, для N = 10, 100 и 1000.

12.88. Реализуйте версию восходящей сортировки слиянием, основанной на операции объединить. Начните с помещения ключей в N деревьев, состоящих из одного узла, затем объедините эти деревья в пары для получения N/2 деревьев из двух узлов, далее объедините их для получения N/4 деревьев из четырех узлов и т.д.

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

Лекция 13. Сбалансированные деревья

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

В идеальном случае можно было бы постоянно держать деревья полностью сбалансированными, подобно дереву, показанному на рис. 13.1. Эта структура соответствует бинарному поиску и, следовательно, гарантирует, что любой поиск может быть выполнен за менее чем lgN+1 сравнений, но в этом случае поддержка динамических вставок и удалений сопряжена с большими затратами. Высокая производительность поиска гарантирована для любого BST-дерева, в котором все внешние узлы расположены на одном или, в крайнем случае, на двух нижних уровнях. Существует множество таких BST-деревьев, поэтому в поддержке сбалансированности дерева имеется некоторая свобода. Если нас устраивают и деревья, близкие к оптимальным, эта свобода еще больше увеличивается.

 Большое полностью сбалансированное BST-дерево


Рис. 13.1.  Большое полностью сбалансированное BST-дерево

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

Например, существует очень много BST-деревьев, высота которых меньше 2lgN . Если можно смягчить стандарт, но при этом гарантировать, что алгоритмы будут строить только такие BST-деревья, то можно избежать снижения производительности для худших случаев, которые могут встретиться в реальных приложениях, работающих с динамическими структурами данных. При этом производительность в среднем также увеличивается.

Один из подходов к повышению сбалансированности BST-деревьев — их регулярная явная балансировка. Действительно, используя рекурсивный метод, показанный в программе 13.1, большинство BST-деревьев можно полностью сбалансировать за линейное время (см. упражнение 13.4). Скорее всего, такая балансировка повысит производительность для случайных ключей, но она не гарантирует исключения квадратичного времени выполнения операций в динамической таблице имен для худшего случая. С одной стороны, между операциями балансировки время вставки для последовательности ключей может квадратично зависеть от длины этой последовательности; с другой стороны, явную балансировку крупных деревьев нежелательно выполнять слишком часто, поскольку для выполнения каждой такой операции требуется время, по меньшей мере, линейно зависящее от размера дерева. Это взаимосвязь затрудняет использование глобальной балансировки для гарантирования высокой производительности в динамических BST-деревьях. Во всех рассматриваемых далее алгоритмах при обходе дерева выполняются локальные операции улучшения структуры, которые совместно увеличивают сбалансированность всего дерева, но при этом, в отличие от программы 13.1, не обходят все узлы.

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

При использовании рандомизации принятие случайного решения выполняется в самом алгоритме, что радикально уменьшает вероятность возникновения худшего случая (независимо от входных данных). Мы уже видели применение такого подхода, когда в алгоритме быстрой сортировки в качестве центрального использовался случайный элемент. В разделах 13.1 и 13.5 мы рассмотрим рандомизированные BST-деревья и слоеные списки — два простых способа использования рандомизации в таблицах символов для увеличения эффективности реализаций всех операций АТД таблицы символов.

Программа 13.1. Балансировка BST-дерева

Используя функцию разбиения partR из программы 12.15, данная рекурсивная функция полностью балансирует BST-дерево за линейное время. Разбиение помещает средний узел в корень, а затем (рекурсивно) выполняет то же самое в поддеревьях.

  void balanceR(link& h)
    { if ((h == 0) || (h->N == 1)) return;
      partR(h, h->N/2);
      balanceR(h->l);
      balanceR(h->r);
    }
    

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

Амортизационный подход заключается в однократном выполнении дополнительных действий во избежание выполнения большего объема работы впоследствии, чтобы обеспечить гарантированный верхний предел средних затрат на одну операцию (общих затрат на все операции, разделенных на количество операций). В разделе 13.2 рассматривается скошенное дерево — вариант BST-дерева, который можно использовать для обеспечения такой гарантии в реализациях таблиц символов. Разработка этого метода послужила одним из стимулов разработки концепции амортизации (см. раздел ссылок). Этот алгоритм является очевидным расширением метода вставки в корень, рассмотренного в лекция №12, но аналитическое обоснование предельных значений его производительности довольно сложно.

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

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

Упражнения

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

13.2. Измените стандартную функцию вставки в BST-дерево, приведенную в программе 12.8, чтобы ее можно было использовать в программе 13.1 для выполнения балансировки дерева каждый раз, когда количество элементов в таблице символов достигает числа, равного степени 2. Сравните время выполнения этой программы с временем выполнения программы 12.8 при выполнении задач (1) построения дерева из N случайных ключей и (2) поиска N случайных ключей в полученном дереве, для N = 103, 104, 105 и 106 .

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

13.4. Покажите, что для вырожденного дерева время выполнения программы 13.1 пропорционально NlgN. Затем приведите самый слабый вариант условия, накладываемого на структуру дерева, при котором время выполнения программы будет линейным.

13.5. Измените стандартную функцию вставки в BST-дерево, приведенную в программе 12.8, чтобы в ней выполнялось разбиение по медиане для любого узла, который в одном из своих поддеревьев содержит менее четверти своих узлов. Сравните время выполнения этой программы с временем выполнения программы 12.8 при выполнении задач (1) построения дерева из N случайных ключей и (2) поиска N случайных ключей в полученном дереве, для N = 103, 104, 105 и 106 .

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

13.7. Расширьте реализацию из упражнения 13.5, чтобы она выполняла балансировку и при выполнении операции удалить. Экспериментально определите, возрастает ли высота дерева при выполнении длиной последовательности чередующихся случайных вставок и удалений в случайном дереве из N узлов при N = 10, 100 и 1000 и для N2 пар вставок-удалений для каждого N.

Рандомизированные BST-деревья

Чтобы проанализировать средние затраты при работе с BST-деревьями, было сделано предположение, что элементы вставляются в случайном порядке (см. лекция №12). Применительно к BST-алгоритму основное следствие из этого предположения заключается в том, что каждый узел дерева с равной вероятностью может оказаться корневым, причем это же справедливо и по отношению к поддеревьям. Интересно, что случайность можно включить в алгоритм, чтобы это свойство сохранялось без каких-либо допущений относительно порядка вставки элементов. Идея проста: при вставке нового узла в дерево из N узлов вероятность появления нового узла в корне должна быть равна 1/(N + 1), поэтому нужно просто принять случайное решение использовать вставку в корень с этой вероятностью. Иначе рекурсивно выполняется вставка новой записи в левое поддерево, если ключ записи меньше ключа в корне, и в правое поддерево, если он больше. Реализация этого метода приведена в программе 13.2.

Программа 13.2. Вставка в рандомизированное BST-дерево

Эта функция принимает случайное решение о том, использовать ли метод вставки в корень из программы 12.13 или стандартный метод вставки из программы 12.8. В рандомизированном BST-дереве каждый из узлов с равной вероятностью может быть корнем; поэтому, помещая новый узел в корень дерева размером N с вероятностью 1/(N + 1), мы получаем рандомизированное дерево.

  private:
    void insertR(link& h, Item x)
      { if (h == 0) { h = new node(x); return; }
        if (rand() < RAND_MAX/(h->N+1))
          { insertT(h, x); return; }
        if (x.key() < h->item.key())
          insertR(h->l, x);
        else
          insertR(h->r, x);
        h->N++;
      }
  public:
    void insert(Item x)
      { insertR(head, x); }
      

С нерекурсивной точки зрения выполнение рандомизированной вставки эквивалентно выполнению стандартного поиска вставляемого ключа с принятием на каждом шаге случайного решения о том, продолжить ли поиск или прервать его и выполнить вставку в корень. Таким образом, как показано на рис. 13.2, новый узел может быть вставлен в любое место на пути поиска. Это простое вероятностное объединение стандартного BST-алгоритма с методом вставки в корень обеспечивает гарантированную производительность в вероятностном смысле.

Лемма 13.1. Построение рандомизированного BST-дерева эквивалентно построению стандартного BST-дерева из случайной перестановки исходных ключей. Для создания рандомизированного BST-дерева из N элементов используется около 2NlnN сравнений (независимо от порядка вставки элементов), а для поиска в таком дереве требуется приблизительно 2 lnN сравнений.

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

 Вставка в рандомизированное BST-дерево


Рис. 13.2.  Вставка в рандомизированное BST-дерево

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

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

На рис. 13.3 рис. 13.3 показано построение рандомизированного дерева для некоторого набора ключей. Поскольку решения, принимаемые алгоритмом, являются случайными, то, скорее всего, последовательность деревьев при каждом выполнении алгоритма будет иной. На рис. 13.4 показано, что рандомизированное дерево, построенное из набора элементов, упорядоченных по возрастанию ключей, обладает теми же свойствами, что и стандартное BST-дерево, построенное из случайно упорядоченных элементов (сравните с рис. 12.8 рис. 12.8).

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

Лемма 13.2. Вероятность того, что затраты на создание рандомизированного BST-дерева превышают усредненные затраты в а раз, меньше .

Этот результат и аналогичные ему следуют из общего решения вероятностных рекуррентных соотношений, которые были выведены Карпом (Karp) в 1995 г. (см. раздел ссылок).

Например, для построения рандомизированного BST-дерева из 100 000 узлов требуется около 2,3 миллиона сравнений, но вероятность того, что количество сравнений превысит 23 миллиона, значительно меньше 0,01%. Подобная гарантия производительности более чем удовлетворяет практическим требованиям, предъявляемым к обработке реальных наборов данных такого размера. При использовании стандартного BST-дерева такая гарантия для этой задачи невозможна: например, производительность снизится, если данные в значительной степени упорядочены, что маловероятно для случайных данных, но по множеству причин достаточно часто бывает с реальными данными.

По тем же соображениям утверждение, аналогичное лемме 13.2, справедливо и для времени выполнения быстрой сортировки. Но в данном случае это более важно, поскольку отсюда следует еще и то, что затраты на поиск в дереве близки к средним. Независимо от дополнительных затрат при построении деревьев, стандартную реализацию BST-дерева можно использовать для выполнения операций найти при затратах, которые зависят только от формы деревьев, и при отсутствии каких-либо дополнительных затрат на балансировку. Это свойство важно в обычных приложениях, в которых операции найти встречаются гораздо чаще, чем любые другие. Например, описанное в предыдущем абзаце BST-дерево из 100 000 узлов могло бы содержать телефонный справочник и использоваться для выполнения миллионов поисков. Можно быть почти уверенным, что каждый поиск потребует затрат, которые отличаются от среднего значения, равного приблизительно 23 сравнениям, лишь небольшим постоянным коэффициентом. Поэтому на практике можно не беспокоиться, что для большого количества поисков потребуется порядка 100 000 сравнений, в то время как при использовании стандартных BST-деревьев для беспокойства были бы основания.

Один из главных недостатков рандомизированных вставок — затраты на генерацию случайных чисел в каждом из узлов во время каждой вставки.

 Построение рандомизированного BST-дерева


Рис. 13.3.  Построение рандомизированного BST-дерева

На этих рисунках показан процесс рандомизированных вставок ключей A B C D E F G H I в первоначально пустое BST-дерево. Дерево на нижнем рисунке выглядит так же, как если бы оно было построено с применением стандартного алгоритма BST-дерева при вставке этих же ключей в случайном порядке.

 Большое рандомизированное BST-дерево


Рис. 13.4.  Большое рандомизированное BST-дерево

Это BST-дерево является результатом рандомизированных вставок 200 элементов в порядке возрастания их ключей в первоначально пустое дерево. Дерево выглядит так, как если бы оно было построено из случайно упорядоченных ключей (см. рис. 12.8).

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

Еще один потенциальный недостаток рандомизированных BST-деревьев — необходимость наличия в каждом узле поля количества узлов в его поддереве. В больших деревьях дополнительный объем памяти для размещения этого поля может оказаться чрезмерной платой. С другой стороны, как было показано в лекция №12, это поле может требоваться и по ряду других причин — например, для поддержки операции выбрать или для обеспечения проверки целостности структуры данных. В подобных случаях рандомизированные BST-деревья не требуют дополнительных затрат памяти, и их использование становится весьма привлекательным.

Основной принцип сохранения случайности в деревьях приводит также к эффективным реализациям операций удалить, объединить и других операций АТД таблицы символов, обеспечивая при этом создание случайных деревьев.

Для объединения дерева из N узлов с деревом из M узлов используется базовый метод, описанный в лекция №12, за исключением принятия случайного решения о выборе корня по принципу: корень объединенного дерева выбирается из дерева с N узлами с вероятностью N/(M + N), а из дерева с М узлами — с вероятностью M/ (M + N). В программе 13.3 приведена реализация этой операции.

Аналогично произвольное решение можно заменить случайным и в алгоритме операции удалить, как показано в программе 13.4. Этот метод соответствует варианту удаления узлов в стандартных BST-деревьях, который не был нами рассмотрен, поскольку без рандомизации он приводил бы к несбалансированным деревьям (см. упражнение 13.21).

Программа 13.3. Объединение рандомизированных BST-деревьев

В данной функции используется тот же подход, что и в программе 12.17, за исключением того, что в ней принимается не произвольное, а случайное решение о том, какой узел использовать в качестве корня объединенного дерева, исходя из равной вероятности помещения в корень любого узла. Приватная функция-член fixN заносит в b->N значение, которое на 1 больше суммы соответствующих полей в поддеревьях (0 для пустых деревьев).

  private:
    link joinR(link a, link b)
      { if (a == 0) return b;
        if (b == 0) return a;
        insertR(b, a->item);
        b->l = joinR(a->l, b->l);
        b->r = joinR(a->r, b->r);
        delete a; fixN(b); return b;
      }
  public:
    void join(ST<Item, Key>& b)
      { int N = head->N;
        if (rand()/(RAND MAX/(N+b.head->N)+1) < N)
          head = joinR(head, b.head);
        else
        head = joinR(b.head, head);
      }
      

Программа 13.4. Удаление в рандомизированном BST-дереве

Для удаления используется та же функция remove, что и для стандартных BST-деревьев (см. программу 12.16), но функция joinLR заменена приведенной здесь функцией. В ней принимается не произвольное, а случайное решение, заменить ли удаляемый узел предком или потомком, исходя из того, что каждый узел в результирующем дереве с равной вероятностью может быть его корнем. Чтобы счетчики узлов содержали правильные значения, в качестве последнего оператора в функции removeR нужен вызов функции fixN (см. программу 13.3) для h.

  link joinLR(link a, link b)
    { if (a == 0) return b;
      if (b == 0) return a;
      if (rand()/(RAND_MAX/(a->N+b->N)+1) < a->N)
        { a->r = joinLR(a->r, b); return a; }
      else
        { b->l = joinLR(a, b->l); return b; }
    }
      

Лемма 13.3. Создание дерева с помощью произвольной последовательности случайных операций вставить, удалить и объединить эквивалентно построению стандартного BST-дерева из случайной перестановки ключей дерева.

Как и в случае с леммой 13.1, для доказательства этого утверждения требуется тщательный вероятностный анализ (см. раздел ссылок).

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

Упражнения

13.8. Нарисуйте рандомизированное BST-дерево, образованное вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево, если реализован плохой метод рандомизации, выполняющий вставку в корень каждый раз при нечетном размере дерева.

13.9. Напишите программу-драйвер, которая 1000 раз выполняет следующий эксперимент для N = 10 и 100: используя программу 13.2, вставляет ключи от 0 до N — 1 (по порядку) в первоначально пустое рандомизированное BST-дерево, а затем выводит -распределение для предположения, что вероятность попадания каждого ключа в корень равна 1/N (см. упражнение 14.5).

13.10. Приведите вероятность попадания ключа F в каждую из позиций, показанных на рис. 13.2.

13.11. Напишите программу вычисления вероятности того, что рандомизированная вставка завершается в одном из внутренних узлов заданного дерева, для каждого из узлов на пути поиска.

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

13.13. Реализуйте нерекурсивную версию функции рандомизированной вставки, приведенной в программе 13.2.

13.14. Нарисуйте рандомизированное BST-дерево, образованное вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево при использовании версии программы 13.2, в которой в выражении, принимающем решение о применении вставки в корень, вызов rand() заменен проверкой (111 % h.N) == 3.

13.15. Выполните упражнение 13.9 для версии программы 13.2, в которой в выражении, принимающем решение о применении вставки в корень, вызов rand() заменен проверкой (111 % h.N) == 3.

13.16. Приведите последовательность случайных решений, которая привела бы к построению вырожденного дерева (все ключи упорядочены, а левые ссылки являются пустыми) из ключей E A S Y Q U T I O N. Какова вероятность возникновения этого события?

13.17. Может ли любое BST-дерево, содержащее ключи E A S Y Q U T I O N, быть построено с помощью какой-либо последовательности случайных решений, если эти ключи вставляются в указанном порядке в первоначально пустое дерево? Обоснуйте свой ответ.

13.18. Определите эмпирическим путем среднее значение и среднеквадратичное отклонение количества сравнений, используемых для успешных и неудачных поисков в рандомизированном BST-дереве, построенном вставками N случайных ключей в первоначально пустое дерево, при N = 103, 104, 105 и 106 .

13.19. Нарисуйте BST-дерево, образованное в результате удаления программой 13.4 ключа Q из дерева, построенного в упражнении 13.14, если для принятия решения об объединении с помещением ключа a в корень используется проверка (111 % (a.N + b.N)) < a.N.

13.20. Нарисуйте BST-дерево, образованное вставками элементов с ключами E A S Y в первоначально пустое дерево, вставками элементов с ключами Q U E S T I O N в другое первоначально пустое дерево и последующим объединением результатов программой 13.3 с проверкой, описанной в упражнении 13.19.

13.21. Нарисуйте BST-дерево, образованное вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево и последующим удалением ключа Q программой 13.4, если используется плохой генератор случайных чисел, всегда возвращающий 0.

13.22. Экспериментально определите рост высоты BST-дерева при выполнении длинной последовательности чередующихся случайных вставок и удалений с помощью программ 13.2 и 13.3 в дереве из N узлов, при N = 10, 100 и 1000 и при выполнении N2 пар вставок-удалений для каждого N.

13.23. Сравните результаты, полученные в упражнении 13.22, с результатом удаления и повторной вставки наибольшего ключа в рандомизированном дереве из N узлов с помощью программ 13.2 и 13.3, для N = 10, 100 и 1000 и при выполнении N2 пар вставок-удалений для каждого N.

13.24. Добавьте в программу из упражнения 13.22 возможность определения среднего количества вызовов функции rand() при удалении одного элемента.

Скошенные деревья бинарного поиска

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

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

Скошенная вставка (splay insertion) перемещает вновь вставленные узлы в корень, применяя трансформации, показанные на рис. 13.5 (стандартная вставка в корень, если ссылки от корня к узлу-внуку на пути поиска имеют различную ориентацию) и в правой части рис. 13.6 (две ротации в корне, если ссылки от корня к узлу-внуку на пути поиска имеют одинаковую ориентацию). Построенные таким образом BST-деревья называются скошенными BST-деревьями (splay BST). Программа 13.5 является рекурсивной реализацией скошенной вставки; пример одиночной вставки приведен на рис. 13.7, а пример построения дерева показан на рис. 13.8. Различие между скошенной и стандартной вставками в корень может показаться несущественным, но оно достаточно важно: операция скоса исключает худший случай квадратичного времени выполнения — главный недостаток стандартных BST-деревьев.

 Двойная ротация в BST-дереве (ориентации различны)


Рис. 13.5.  Двойная ротация в BST-дереве (ориентации различны)

В приведенном дереве (вверху) в результате ротации влево в узле G, за которой следует ротация вправо в узле L, узел I помещается в корень (внизу). Эти ротации могут завершать процесс вставки в стандартном или скошенном BST-дереве.

Лемма 13.4. Количество сравнений, используемых при построении скошенного дерева N вставками в первоначально пустое дерево, равно O (N lgN).

Это утверждение — следствие более жесткой леммы 13.5, которая будет рассмотрена ниже.

Константа, подразумеваемая в O-нотации, равна 3. Например, для построения BST-дерева из 100 000 узлов с помощью скошенных вставок всегда требуется менее 5 миллионов сравнений. Это не гарантирует, что полученное дерево поиска будет хорошо сбалансировано или что каждая операция будет эффективной, но очень важна полученная гарантия общего времени выполнения; на практике фактическое время выполнения, скорее всего, окажется еще меньше.

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

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

Лемма 13.5. Количество сравнений, требуемых для любой последовательности M операций вставить или найти в скошенном BST-дереве из N узлов, равно

O ((N + M) lg(N + M)).

Доказательство этого утверждения, приведенное Слитором (Sleator) и Тарьяном (Tarjan) в 1985 г., является классическим примером амортизационного анализа алгоритмов (см. раздел ссылок). Подробно оно будет рассмотрено в части VIII.

 Двойная ротация в BST-дереве (ориентации одинаковы)


Рис. 13.6.  Двойная ротация в BST-дереве (ориентации одинаковы)

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

Лемма 13.5 представляет собой гарантию амортизированной производительности: это эффективность не каждой операции, а средних затрат всех выполненных операций. Это среднее значение не является вероятностным; скорее утверждается, что общие затраты будут гарантированно низкими. Для многих приложений такой гарантии достаточно, но для некоторых других приложений этого может оказаться мало. Например, при использовании скошенных BST-деревьев нельзя гарантировать время ответа для каждой операции, поскольку время выполнения некоторых операций может быть линейным. Если какая-либо операция выполняется за линейное время, то тогда другие операции будут выполняться гораздо быстрее, но это слабое утешение для вынужденного ожидать клиента.

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

Программа 13.5. Скошенная вставка в BST-дерево

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

Программа проверяет четыре варианта для двух шагов пути поиска от корня и выполняет соответствующие ротации:

  private:
    void splay(link& h, Item x)
      { if (h == 0)
          { h = new node(x, 0, 0, 1); return; }
        if (x.key() < h->item.key())
          { link& hl = h->l; int N = h->N;
            if (hl == 0)
              { h = new node(x, 0, h, N+1); return; }
            if (x.key() < hl->item.key())
              { splay(hl->l, x); rotR(h); }
            else
              { splay(hl->r, x); rotL(hl); }
            rotR(h);
          }
        else
          { link &hr = h->r; int N = h->N;
            if (hr == 0)
              { h = new node(x, h, 0, N+1); return; }
            if (hr->item.key() < x.key())
              { splay(hr->r, x); rotL(h); }
            else
              { splay(hr->l, x); rotR(hr); }
            rotL(h);
          }
        }
    public:
      void insert(Item item)
        { splay(head, item); }
      

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

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

 Скошенная вставка


Рис. 13.7.  Скошенная вставка

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

 Построение скошенного дерева


Рис. 13.8.  Построение скошенного дерева

Здесь показана последовательность скошенных вставок записей с ключами A S E R C H I N G в первоначально пустое дерево.

 Балансировка худшего случая скошенного дерева с помощью серии поисков


Рис. 13.9.  Балансировка худшего случая скошенного дерева с помощью серии поисков

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

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

Если в дереве могут быть повторяющиеся ключи, то операция скоса может привести к тому, что элементы с ключами, равными ключу в данном узле, попадут по обе стороны от этого узла (см. упражнение 13.38). Из этого следует, что найти все элементы с данным ключом будет не так легко, как в случае стандартных BST-деревьев. Необходимо либо проверять наличие равных ключей в обоих поддеревьях, либо воспользоваться каким-либо альтернативным методом обработки повторяющихся ключей из описанных в лекция №12.

Упражнения

13.25. Нарисуйте скошенное BST-дерево, образованное скошенными вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево.

13.26. Сколько ссылок дерева должно быть изменено для выполнения двойной ротации? Сколько ссылок действительно изменяется при выполнении каждой из двойных ротаций в программе 13.5?

13.27. Добавьте в программу 13.5 реализацию операции найти со скосом.

13.28. Реализуйте нерекурсивную версию функции скошенной вставки из программы 13.5.

13.29. Используйте программу-драйвер из упражнения 12.30 для определения эффективности скошенных BST-деревьев как самоорганизующихся структур поиска, сравнив их со стандартными BST-деревьями для распределения поисковых запросов, определенных в упражнениях 12.31 и 12.32.

13.30. Нарисуйте все структурно различные BST-деревья, которые могут быть получены скошенными вставками N ключей в первоначально пустое дерево, для 2 < N < 7.

13.31. Определите вероятность того, что каждое из деревьев в упражнении 13.30 образовано вставками N случайных различных элементов в первоначально пустое BST-дерево.

13.32. Определите эмпирически среднее значение и среднеквадратичное отклонение количества сравнений, используемых при успешном и неудачном поиске в BST-дереве, построенном скошенными вставками N случайных ключей в первоначально пустое дерево, при N = 103, 104, 105 и 106 . Не следует выполнять сами операции поиска: просто постройте деревья и вычислите длину их путей. Являются ли скошенные BST-деревья более сбалансированными, чем произвольные BST-деревья, или менее, или одинаково?

13.33. Добавьте в программу из упражнения 13.32 выполнение N случайных (скорее всего, неудачных) поисков со скосом в каждом из созданных деревьев. Как влияет скос на среднее количество сравнений при неудачном поиске?

13.34. Добавьте в программы из упражнений 13.32 и 13.33 возможность измерения времени их выполнения вместо подсчета количества сравнений. Проведите те же эксперименты. Объясните любые изменения в выводах, получаемых из экспериментальных результатов.

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

13.36. Определите экспериментально среднее количество сравнений при успешном поиске в скошенном BST-дереве, построенном вставками произвольных ключей, при N = 103, 104, 105 и 106 .

13.37. Проверьте экспериментально идею использования скошенных вставок, а не стандартных вставок в корень, для рандомизированных BST-деревьев.

13.38. Нарисуйте скошенное BST-дерево, образованное вставками элементов с ключами 0 0 0 0 0 0 0 0 0 0 0 0 1 в указанном порядке в первоначально пустое дерево.

Нисходящие 2-3-4-деревья

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

Для гарантии сбалансированности создаваемых BST-деревьев используемые структуры деревьев должны обладать определенной гибкостью. Для получения такой гибкости предположим, что узлы в наших деревьях могут содержать более одного ключа. А именно, мы допустим существование 3-узлов и 4-узлов, которые могут содержать, соответственно, два и три ключа. 3-узлы содержат три ссылки: одна на все элементы, ключи которых меньше обоих его ключей, одна на все элементы, ключи которых имеют значения между двумя его ключами, и одна на все элементы, ключи которых больше обоих его ключей. Аналогично, 4-узел имеет четыре ссылки: по одной для каждого из интервалов, определенных его тремя ключами. Тогда узлы в стандартном BST-дереве можно было бы называть 2-узлами: они содержат один ключ и две ссылки. Позже мы рассмотрим эффективные способы определения и реализации базовых операций с этими расширенными узлами; пока же будем считать, что есть удобные способы работы с ними, и посмотрим, как они позволяют формировать деревья.

Определение 13.1. 2-3-4-дерево поиска — это либо пустое дерево, либо дерево, содержащее три типа узлов: 2-узлы — с одним ключом, левой ссылкой на дерево с меньшими ключами и правой ссылкой на дерево с большими ключами; 3-узлы — с двумя ключами, с левой ссылкой на дерево с меньшими ключами, средней ссылкой на дерево, ключи которых имеют значения между значениями ключей данного узла, и правой ссылкой на дерево с большими ключами; и 4-узлы с тремя ключами и четырьмя ссылками на деревья, значения ключей которых определены диапазонами, образованными ключами узла.

Определение 13.2. Сбалансированное 2-3-4-дерево поиска — это 2-3-4-дерево поиска, все ссылки на пустые деревья которого расположены на одинаковом расстоянии от корня.

В этой главе термин 2-3-4-дерево будет применяться к сбалансированным 2-3-4-деревьям поиска (в других контекстах он означает более общую структуру). Пример 2-3-4-дерева приведен на рис. 13.10.

Алгоритм поиска ключей в таком дереве представляет собой обобщение алгоритма поиска для BST-деревьев. Чтобы выяснить, находится ли ключ в дереве, мы сравниваем его с ключами в корне: если он равен любому из них, поиск успешен; в противном случае мы переходим по ссылке от корня к поддереву, соответствующему множеству значений ключей, к которому принадлежит искомый ключ, и затем рекурсивно выполняем поиск в этом дереве. Существует ряд способов представления 2-, 3- и 4-узлов и организации поиска соответствующей ссылки; мы отложим рассмотрение этих решений до раздела 13.4, где будет рассмотрено очень удобное решение.

Для вставки нового узла в 2-3-4-дерево можно было бы, как в BST-деревьях, выполнить неудачный поиск, а затем присоединить узел, но при этом новое дерево оказалось бы несбалансированным. Основная причина важности 2-3-4-деревьев состоит в том, что они позволяют выполнять вставки, всегда сохраняя полную сбалансированность дерева. Например, легко видеть, что делать, если поиск заканчивается на 2-узле: достаточно преобразовать его в 3-узел. Аналогично, если поиск заканчивается на 3-узле, его достаточно преобразовать в 4-узел. Но что делать, если поиск прерывается на 4-узле? Решение состоит в том, что можно найти место для нового ключа, сохраняя сбалансированность дерева, вначале разделив 4-узел на два 2-узла, и затем передав средний узел вверх к родительскому узлу. Эти три описанных случая показаны на рис. 13.11.

А что делать, если необходимо разбить 4-узел, родительский узел которого также является 4-узлом? Одним из возможных выходов было бы разбиение и родительского узла, но узел-предок также может оказаться 4-узлом и т.д. — возможно, пришлось бы разделять узлы на всем пути вверх по дереву. Более простой подход — обеспечить, чтобы путь поиска не завершался в 4-узле, разбивая любой 4-узел, попадающийся при следовании вниз по дереву.

А именно, каждый раз, когда встречается 2-узел с дочерним 4-узлом, такая пара преобразуется в 3-узел с двумя дочерними 2-узлами; а когда встречается 3-узел с дочерним 4-узлом, такая пара преобразуется в 4-узел с двумя дочерними 2-узлами (см. рис. 13.12). Разбиение 4-узлов возможно потому, что можно перемещать не только ключи, но и ссылки. Два 2-узла имеют столько же (четыре) ссылок, что и 4-узел, поэтому разбиение можно выполнить, не внося никаких изменений ниже (или выше) разбиваемого узла. 3-узел не преобразуется в 4-узел одним лишь добавлением еще одного ключа; требуется еще одна ссылка (в данном случае — дополнительная ссылка, созданная разбиением). Очень важно, что эти преобразования являются чисто локальными: не нужно проверять или изменять никакую часть дерева, кроме показанной на рис. 13.12. Каждое преобразование передает один из ключей 4-узла в его родительский узел и соответствующим образом преобразует ссылки.

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

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

 2-3-4-дерево


Рис. 13.10.  2-3-4-дерево

На этом рисунке изображено 2-3-4-дерево, содержащее ключи A S R C H I N G E X M P L.

В таком дереве ключ можно отыскать, используя ключи в корневом узле для нахождения ссылки на нужное поддерево, с последующим рекурсивным продолжением поиска. Например, для поиска ключа P в этом дереве нужно пройти по правой ссылке от корня, поскольку P больше I, затем — по средней ссылке от правого дочернего узла корня, поскольку P находится между N и R, и, наконец, завершить успешный поиск в 2-узле, содержащем ключ P.

 Вставка в 2-3-4-дерево


Рис. 13.11.  Вставка в 2-3-4-дерево

2-3-4-дерево, состоящее только из 2-узлов, аналогично BST-дереву (вверху). Ключ C можно вставить, преобразовав 2-узел, в котором прерывается поиск C, в 3-узел (второй сверху рисунок). Аналогично можно вставить ключ H, преобразовав 3-узел, в котором прерывается его поиск, в 4-узел (третий сверху рисунок). Но вставка ключа I выполняется сложнее, поскольку его поиск прерывается в 4-узле. Мы разбиваем 4-узел, передаем его средний ключ родительскому узлу, и преобразуем этот узел в 3-узел (четвертый сверху рисунок в рамке). Такое преобразование создает допустимое 2-3-4-дерево, в нижней части которого появляется место для I . И, наконец, мы вставляем I в 2-узел, на котором теперь прерывается поиск, и преобразуем этот узел в 3-узел (нижний рисунок).

 Разбиение 4-узлов в 2-3-4-дереве


Рис. 13.12.  Разбиение 4-узлов в 2-3-4-дереве

В 2-3-4-дереве любой 4-узел, который не является дочерним узлом 4-узла, можно разбить на два 2-узла, передав его среднюю запись родительскому узлу. 2-узел с дочерним 4-узлом (вверху слева) становится 3-узлом с двумя дочерними 2-узлами (вверху справа), а 3-узел с дочерним 4-узлом (внизу слева) становится 4-узлом с двумя дочерними 2-узлами (внизу справа).



Рис. 13.13. 

Построение 2-3-4-дерева

Здесь показан результат вставки элементов с ключами A S E R C H I N G X в первоначально пустое 2-3-4-дерево. Каждый встречающийся по пути поиска 4-узел разбивается, обеспечивая свободное место для нового элемента в нижней части дерева.

На рис. 13.13 показано построение 2-3-4-дерева последовательной вставкой набора ключей. В отличие от стандартных BST-деревьев, которые разрастаются вниз от вершины, эти деревья растут снизу вверх. Поскольку 4-узлы разбиваются на пути от вершины вниз, такие деревья называются нисходящими 2-3-4-деревьями. Этот алгоритм важен, поскольку он создает практически идеально сбалансированные деревья поиска, хотя в процессе прохождения по дереву выполняется всего лишь несколько локальных преобразований.

Лемма 13.6. При поиске в 2-3-4-деревьях из N узлов посещается максимум lgN+1 узлов.

Каждый внешний узел находится на одинаковом расстоянии от корня: выполняемые преобразования не оказывают никакого влияния на расстояние между любым узлом и корнем, за исключением случая, когда выполняется разбиение корня (в этом случае расстояние между всеми узлами и корнем увеличивается на 1). Если все узлы являются 2-узлами, приведенное утверждение справедливо, поскольку такое дерево подобно полному бинарному дереву; если в дереве присутствуют 3- и 4-узлы, высота может быть только меньше.

Лемма 13.7. Для вставок в 2-3-4-деревьях из N узлов требуется разбиение менее lg N + 1 узлов в худшем случае и, скорее всего, менее одного разбиения узла в среднем.

В самом худшем случае все узлы на пути к точке вставки являются 4-узлами, и все потребуется разбить. Но в дереве, построенном из случайной перестановки N элементов, маловероятен не только этот худший случай, но и в среднем, вероятно, потребуется очень мало операций разбиения, поскольку 4-узлы в деревьях встречаются не так часто. Например, в большом дереве на рис. 13.14 рис. 13.14 все 4-узлы, кроме двух, расположены на нижнем уровне. До сих пор специалистам не удавалось аналитически точно проанализировать производительность 2-3-4-деревьев, но из эмпирически полученных результатов видно, что для балансировки деревьев используется очень мало разбиений. Худший случай равен лишь lg N, а в практических ситуациях и он недостижим.

Приведенного описания достаточно для определения алгоритма поиска с использованием 2-3-4-деревьев, который гарантирует достаточно высокую производительность в худшем случае. Однако мы находимся лишь на полпути к реализации. Можно написать алгоритмы, действительно выполняющие преобразования с различными типами данных, представляющими 2-, 3- и 4-узлы, но в большинстве встречающихся задач реализация такого непосредственного представления не очень удобна. Как и в случае скошенных BST-деревьев, дополнительные расходы на обработку более сложных узлов могут сделать алгоритмы более медленными, чем стандартный поиск по BST-дереву. Главное назначение балансировки — страховка от худшего случая, но хотелось бы, чтобы затраты, связанные с этим, были низкими, и чтобы не было дополнительных затрат при каждом выполнении алгоритма. К счастью, как будет показано в разделе 13.4, существует довольно простое представление 2-, 3- и 4-узлов, которое позволяет выполнять преобразования однотипным способом при небольших дополнительных затратах по сравнению со стандартным поиском в бинарном дереве.

 Большое 2-3-4-дерево


Рис. 13.14.  Большое 2-3-4-дерево

Это 2-3-4-дерево — результат 200 случайных вставок в первоначально пустое дерево. Все пути поиска в дереве содержат не более шести узлов.

Описанный алгоритм — всего лишь один из возможных способов поддержания баланса в 2-3-4-деревьях поиска. Разработаны и некоторые другие методы, позволяющие достичь таких же результатов.

Например, можно выполнять балансировку снизу вверх. Вначале в дереве выполняется поиск расположенного в нижней части дерева узла, которому должен принадлежать вставляемый элемент. Если этот узел является 2-узлом или 3-узлом, он преобразуется в 3-узел или 4-узел, как описывалось ранее. Если это 4-узел, он разбивается, как и ранее (со вставкой нового элемента в один из результирующих 2-узлов в нижней части), и средний элемент вставляется в родительский узел, если тот является 2- или 3-узлом. Если родительский узел является 4-узлом, он также разбивается (со вставкой среднего узла с нижнего уровня в соответствующий 2-узел), а средний элемент вставляется в его родительский узел, если тот является 2- или 3-узлом. Если узел-дед также является 4-узлом, мы продолжаем этот подъем по дереву, разбивая 4-узлы до тех пор, пока на пути поиска не встретится 2-узел или 3-узел.

Такой вид восходящей балансировки можно выполнять в деревьях, которые содержат только 2- или 3-узлы (и не имеют 4-узлов). Это подход ведет к большему количеству операций разбиения узлов во время выполнения алгоритма, но его проще программировать, поскольку приходится учитывать меньше случаев. Еще один подход уменьшает количество разбиений узлов, отыскивая перед разбиением 4-узла его родственные узлы, не являющиеся 4-узлами.

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

Упражнения

13.39. Нарисуйте сбалансированное 2-3-4-дерево поиска, образованное нисходящими вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево.

13.40. Нарисуйте сбалансированное 2-3-4-дерево поиска, образованное восходящими вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево.

13.41. Какова минимальная и максимальная возможная высота сбалансированных 2-3-4-деревьев, содержащих N узлов?

13.42. Какова минимальная и максимальная возможная высота сбалансированных 2-3-4-деревьев бинарного поиска, содержащих N узлов?

13.43. Нарисуйте все структурно различные сбалансированные 2-3-4-деревья бинарного поиска, содержащие N ключей, для .

13.44. Найдите вероятность того, что каждое из деревьев, нарисованных в упражнении 13.43, является результатом вставки N случайных различных элементов в первоначально пустое дерево.

13.45. Составьте таблицу, содержащую количество изоморфных деревьев из упражнения 13.43 для каждого значения N — в том смысле, что они могут быть преобразованы одно в другое путем обмена поддеревьев в узлах.

13.46. Опишите алгоритмы поиска и вставки в сбалансированные 2-3-4-5-6-деревья поиска.

13.47. Нарисуйте несбалансированное 2-3-4-дерево поиска, образованное вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево с использованием следующего метода. Если поиск завершается в 2-или 3-узле, он преобразовывается в 3- или 4-узел, как в сбалансированном алгоритме; если поиск завершается в 4-узле, соответствующая ссылка в этом 4-узле заменяется новым 2-узлом.

RB-деревья

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

Основная идея заключается в представлении 2-3-4-деревьев в виде стандартных BST-деревьев (содержащих только 2-узлы), но с добавлением в каждом узле дополнительного информационного бита для кодирования 3-узлов и 4-узлов. Мы будем считать, что ссылки могут быть двух различных типов: красные ссылки (R-ссылки), которые объединяют небольшие бинарные деревья, образующие 3-узлы и 4-узлы, и черные ссылки (B-ссылки), которые объединяют 2-3-4-дерево. А именно, как показано на рис. 13.15, 4-узлы представляются тремя 2-узлами, соединенными R-ссылками, а 3-узлы — двумя 2-узлами, соединенными одной R-ссылкой. R-ссылка в 3-узле может быть левой или правой, следовательно, каждый 3-узел может быть представлен двумя способами.

В любом дереве на каждый узел указывает одна ссылка, значит, окрашивание ссылок эквивалентно окрашиванию узлов. Поэтому мы используем по одному дополнительному разряду в каждом узле для хранения цвета ссылки, указывающей на этот узел. 2-3-4-деревья, представленные таким образом, называются красно-черными (или RB-) деревьями бинарного поиска. Ориентация каждого 3-узла определяется динамикой алгоритма, который будет описан ниже. Можно было бы выдвинуть правило, чтобы все 3-узлы были ориентированы одинаково, но это ни к чему. Пример RB-дерева показан на рис. 13.16. Если в нем исключить R-ссылки и свернуть соединяемые ими узлы, в результате получится 2-3-4-дерево, показанное на рис. 13.10.

RB-деревья обладают двумя важными свойствами: (1) стандартный метод найти для BST-деревьев работает без всяких изменений; (2) существует прямое соответствие между RB-деревьями и 2-3-4-деревьями, поэтому, используя и сохраняя это соответствие, можно реализовать алгоритм обработки сбалансированного 2-3-4-дерева. Мы возьмем лучшее из обоих подходов: простой метод поиска по стандартному BST-дереву и простой метод вставки-балансировки в 2-3-4-дереве поиска.

 3-узлы и 4-узлы в RB-деревьях


Рис. 13.15.  3-узлы и 4-узлы в RB-деревьях

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

Метод поиска не проверяет поле, представляющее цвет узла, поэтому механизм балансировки не увеличивает время выполнения основной процедуры поиска. Каждый ключ вставляется только один раз, но в типичном приложении его поиск может выполняться многократно, поэтому общее время поиска сокращается (поскольку деревья сбалансированы) за счет сравнительно небольших затрат (т.к. во время поиска балансировка не выполняется). Невелики и дополнительные затраты, связанные со вставкой: действия по балансировке нужно выполнять только при обнаружении 4-узлов, а в дереве их количество невелико, поскольку они всегда разбиваются. Внутренним циклом процедуры вставки является код продвижения вниз по дереву (как и для операций вставки или поиска-вставки в стандартных BST-деревьях), с добавлением одной дополнительной проверки: если узел имеет два дочерних R-узла, он является частью 4-узла. Столь небольшие дополнительные затраты — основной фактор, определяющий эффективность RB-деревьев бинарного поиска.

Рассмотрим теперь RB-представление двух преобразований, выполнение которых может потребоваться при обнаружении 4-узла. 2-узел с дочерним 4-узлом становится 3-узлом с двумя дочерними 2-узлами, а 3-узел с дочерним 4-узлом становится 4-узлом с двумя дочерними 2-узлами. Когда в нижнюю часть дерева добавляется новый узел, его можно представить в виде 4-узла, который должен быть разбит с передачей среднего узла вверх для вставки в тот нижний узел, в котором завершился поиск — нисходящий процесс гарантирует, что это либо 2-узел, либо 3-узел. Преобразование, требуемое при обнаружении 2-узла с дочерним 4-узлом, выполняется без труда, и такое же преобразование работает применительно к 3-узлу, " правильно " присоединенному к 4-узлу, как показано в двух первых примерах на рис. 13.17.

Остаются еще две ситуации, которые могут возникнуть при обнаружении 3-узла с дочерним 4-узлом — они показаны в последних двух примерах на рис. 13.17 рис. 13.17. (На самом деле существует четыре таких ситуации, поскольку другая ориентация 3-узлов может дать еще два зеркальных изображения.) В этих случаях простое разбиение 4-узла приводит к образованию двух последовательных R-ссылок, т.е. результирующее дерево не является 2-3-4-деревом в соответствии с принятыми соглашениями. Эта ситуация не так уж плоха, поскольку имеются три узла, соединенных R-ссылками: достаточно преобразовать дерево так, чтобы R-ссылки исходили из одного и того же узла.

К счастью, уже знакомые нам операции ротации — именно то, что необходимо для достижения требуемого эффекта. Начнем с более простого из двух оставшихся случаев — третьего примера на рис. 13.17, где 4-узел, присоединенный к 3-узлу, разбивается с порождением двух идущих друг за другом одинаково ориентированных R-ссылок. Эта ситуация не возникла бы, если бы 3-узел был ориентирован по-другому — значит, нужно изменить структуру дерева, переключив ориентацию 3-узла и сведя тем самым этот случай ко второму, когда достаточно простого разбиения 4-узла. Изменение структуры дерева для переориентации 3-узла достигается выполнением единственной ротации с дополнительным требованием изменения цвета двух задействованных узлов. Осталось рассмотреть случай, когда 4-узел, соединенный с 3-узлом, разбивается и оставляет две идущие подряд R-ссылки, которые ориентированы по-разному. Выполнением ротации можно свести этот случай к случаю с одинаково ориентированными ссылками, который затем обрабатывается, как было описано выше. Это преобразование сводится к выполнению тех же операций, что и при выполнении двойных ротаций влево-вправо и вправо-влево, которые использовались в разделе 13.2 для скошенных BST-деревьев, хотя нужны кое-какие дополнительные действия для правильной переустановки цветов. Примеры операций вставки в RB-деревья приведены на рис. 13.18 и 13.19.

 RB-дерево


Рис. 13.16.  RB-дерево

На этом рисунке изображено RB-дерево, содержащее ключи A S R C H I N G E X M P L. Ключ в таком дереве можно найти при помощи стандартного поиска в BST-деревьях. В этом дереве любой путь от корня до внешнего узла содержит три B-ссылки. Если свернуть узлы, соединенные R-ссылками, получится 2-3-4-дерево, показанное на рис. 13.10.

 Разбиение 4-узлов в RB-дереве


Рис. 13.17.  Разбиение 4-узлов в RB-дереве

В RB-дереве операция разбиения 4-узла, который не имеет родителем 4-узел, изменяет цвета узлов дерева, образующих 4-узел с последующим возможным выполнением одной или двух ротаций. Если родительский узел является 2-узлом (верхний рисунок) или 3-узлом с подходящей ориентацией (второй сверху рисунок), ротации не требуются. Если 4-узел располагается на центральной ссылке 3-узла (нижний рисунок), необходима двойная ротация; иначе достаточно одиночной ротации (третий сверху рисунок).

 Вставка в RB-дерево


Рис. 13.18.  Вставка в RB-дерево

На этом рисунке показан результат (внизу) вставки записи с ключом I в RB-дерево (вверху). В этом случае процесс вставки состоит из разбиения 4-узла C с изменением цвета (в центре), последующим добавлением нового 2-узла в нижней части и преобразованием узла, содержащего ключ H, в 3-узел.

 Вставка в RB-дерево с использованием ротаций


Рис. 13.19.  Вставка в RB-дерево с использованием ротаций

На этом рисунке показан результат (внизу) вставки записи с ключом G в RB-дерево (вверху). Для этого выполняется разбиение 4-узла с ключом I с изменением цвета (второй сверху рисунок), затем добавление нового узла в нижнюю часть (третий сверху рисунок) и, наконец, выполнение (с возвратом к каждому узлу на пути поиска после вызовов рекурсивных функций) ротации влево в узле C и ротации вправо в узле R, которые завершают процесс разбиения 4-узла.

Программа 13.6 является реализацией операции вставить для RB-деревьев, которая выполняет преобразования, приведенные на рис. 13.17. Рекурсивная реализация позволяет изменять цвета 4-узлов при продвижении вниз по дереву (перед рекурсивными вызовами), а затем выполнять ротации при продвижении вверх по дереву (после рекурсивных вызовов). Эту программу было бы трудно понять без двух уровней абстракции, разработанных для ее реализации. Несложно убедиться, что рекурсивный подход реализует ротации, изображенные на рис. 13.17; затем можно убедиться, что программа действительно реализует высокоуровневый алгоритм для 2-3-4-деревьев — разбивает 4-узлы при продвижении вниз по дереву, а затем вставляет новый элемент в 2- или 3-узел там, где путь поиска завершается в нижней части дерева.

Программа 13.6. Вставка в RB-деревья бинарного поиска

Данная функция реализует вставку в 2-3-4-деревья, используя их RB-представления. В тип node добавлен бит цвета red (и соответствующим образом расширен его конструктор): 1 означает, что узел красный, а 0 — черный. При продвижении вниз по дереву (перед рекурсивным вызовом) обнаруженные 4-узлы разбиваются путем изменения разрядов цвета во всех трех узлах. По достижении нижней части дерева для вставляемого элемента создается новый R-узел, и возвращается ссылка на него. При продвижении вверх по дереву (после рекурсивного вызова) проверяется, необходима ли ротация. Если путь поиска содержит две одинаково ориентированных R-ссылки, выполняется единственная ротация от верхнего узла, а затем разряды цвета изменяются так, чтобы получился правильный 4-узел. Если путь поиска содержит две по-разному ориентированных R-ссылки, выполняется единственная ротация от нижнего узла, в результате чего этот случай сводится к предыдущему, но уровнем выше.

  private:
    int red(link x)
      { if (x == 0) return 0; return x->red; }
    void RBinsert(link& h, Item x, int sw)
      { if (h == 0)
          { h = new node(x); return; }
        if (red(h->l) && red(h->r))
          { h->red = 1; h->l->red = 0; h->r->red = 0; }
        if (x.key() < h->item.key())
          { RBinsert(h->l, x, 0);
            if (red(h) && red(h->l) && sw) rotR(h);
            if (red(h->l) && red(h->l->l))
              { rotR(h); h->red = 0; h->r->red = 1; }
          }
       else
        { RBinsert(h->r, x, 1);
          if (red(h) && red(h->r) && !sw) rotL(h);
          if (red(h->r) && red(h->r->r))
            { rotL(h); h->red = 0; h->l->red = 1; }
        }
      }
   public:
      void insert(Item x)
        { RBinsert(head, x, 0); head->red = 0; }
      

На рис. 13.20 рис. 13.20, который можно считать более подробной версией рис. 13.13, показано, как программа 13.6 строит RB-деревья, представляющие сбалансированные 2-3-4-деревья, с помощью вставки последовательности ключей. На рис. 13.21 изображено дерево, построенное для большей последовательности; среднее количество узлов, проверяемых во время поиска случайного ключа в этом дереве, равно лишь 5,81. Сравните это значение со значением 7,00 для дерева, построенного из этих же ключей в лекция №12, и с 5,74 — наименьшим возможным для идеально сбалансированного дерева. Ценой лишь нескольких ротаций мы получаем дерево, сбалансированное гораздо лучше любого другого из приведенных в этой главе и состоящего из этих же ключей. Программа 13.6 — эффективный и сравнительно компактный алгоритм вставки, использующий структуру бинарного дерева, который гарантирует логарифмическое количество шагов для всех операций поиска и вставки. Это одна из немногих реализаций таблиц символов, обладающих подобным свойством, и ее стоит использовать в качестве библиотечной реализации, когда точно неизвестны свойства обрабатываемой последовательности ключей.

 Построение RB-дерева


Рис. 13.20.  Построение RB-дерева

Здесь показана последовательность вставок записей с ключами A S E R C H I N X в первоначально пустое RB-дерево.

Лемма 13.8. Для поиска в RB-дереве с N узлами требуется менее 2lgN+2 сравнений.

Ротация в RB-дереве требуется только для разбиений, которые в 2-3-4-дереве соответствуют 3-узлу с последующим 4-узлом; таким образом, эта лемма — следствие леммы 13.2. Худший случай возникает тогда, когда путь к точке вставки состоит из чередующихся 3-узлов и 4-узлов.

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

Лемма 13.9. Для поиска в RB-дереве с N узлами, построенном из случайных ключей, в среднем требуется около 1,002 lgN сравнений.

Константа 1,002, установленная с помощью частичного анализа и моделирования (см. раздел ссылок), достаточно мала, чтобы считать RB-деревья оптимальными для практического применения, но вопрос о том, действительно ли RB-деревья являются асимптотически оптимальными, остается открытым. Равна ли 1 эта константа в предельном случае?

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

Для приложения, которое хранит в дереве и другую информацию, операция ротации может оказаться дорогостоящей: возможно, придется обновлять информацию во всех узлах поддеревьев, затрагиваемых ротацией. Для таких приложений можно обеспечить, чтобы каждая вставка выполняла не более одной ротации, используя RB-деревья для реализации восходящих 2-3-4-деревьев поиска, которые описаны в конце раздела 13.3.

 Большое RB-дерево бинарного поиска


Рис. 13.21.  Большое RB-дерево бинарного поиска

Это RB-дерево — результат вставки случайно упорядоченных ключей в первоначально пустое дерево. Для выполнения неудачных поисков в этом дереве требуется от 6 до 12 сравнений.

Вставка в эти деревья вызывает разбиение 4-узлов на пути поиска, которое требует изменений цвета, но не выполнения ротаций в RB-представлении, за которыми следует одна одиночная или двойная ротация (один из случаев, показанных на рис. 13.17), если первый 2-узел или 3-узел встречается выше на пути поиска (см. упражнение 13.59).

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

Как было сказано в конце раздела 13.3, RB-представления 2-3-4-деревьев входят в число нескольких схожих стратегий, которые были предложены для реализации сбалансированных бинарных деревьев (см. раздел ссылок). Как было показано, балансировка деревьев достигается операциями ротации: мы рассмотрели специфическое представление деревьев, которое упрощает принятие решения о моменте выполнения ротации. Другие представления деревьев ведут к другим алгоритмам, часть из которых мы сейчас кратко рассмотрим.

Старейшая и наиболее изученная структура данных для сбалансированных деревьев — сбалансированное по высоте, или AVL-дерево, исследованное Адельсоном-Вельским и Ландисом. Для этих деревьев характерно, что высоты двух поддеревьев каждого узла различаются максимум на 1. Если вставка приводит к тому, что высота одного из поддеревьев какого-либо узла увеличивается на 1, условие баланса может нарушиться. Однако в любом случае одна одиночная или двойная ротация восстановит баланс. Основанный на этом наблюдении алгоритм аналогичен методу восходящей балансировки 2-3-4-деревьев: выполняется рекурсивный поиск узла, затем, после рекурсивного вызова, выполняется проверка разбаланса и, при необходимости, одиночная или двойная ротация для восстановления баланса (см. упражнение 13.61). Для принятия решения о том, какие ротации нужно выполнять (если нужно), требуется знать, является ли высота каждого узла на 1 меньше, равна или на 1 больше высоты его родственного узла. Для прямого кодирования этой информации требуется по два бита на каждый узел, хотя, используя RB-абстракцию, можно обойтись и без дополнительной памяти (см. упражнения 13.62 и 13.65).

Поскольку 4-узлы не играют никакой специальной роли в алгоритме с использованием 2-3-4-деревьев, можно строить сбалансированные деревья по существу так же, но используя только 2-узлы и 3-узлы. Построенные таким образом деревья называются 2-3-деревьями; они были открыты Хопкрофтом (Hopcroft) в 1970 г. 2-3-деревья не обладают гибкостью, достаточной для построения удобного алгоритма нисходящей вставки. Кроме того, RB-структура может упростить реализацию, но восходящие 2-3-деревья не дают особых преимуществ по сравнению с восходящими 2-3-4-деревьями, поскольку для поддержания баланса по-прежнему требуются одиночные и двойные ротации. Восходящие 2-3-4-деревья несколько лучше сбалансированы и обладают тем преимуществом, что для каждой вставки требуется максимум одна ротация.

В лекция №16 будет рассмотрен еще один важный тип сбалансированных деревьев — расширение 2-3-4-деревьев, называемое B-деревьями. B-деревья допускают существование до M ключей в одном узле для больших значений M и широко используются в приложениях поиска, работающих с очень большими файлами.

Мы уже определили RB-деревья их соответствием 2-3-4-деревьям. Интересно также сформулировать непосредственные структурные определения.

Определение 13.3. RB-дерево бинарного поиска — это дерево бинарного поиска, в котором каждый узел помечен как красный (R) либо черный (B), с наложением дополнительного ограничения, что никакие два красных узла не могут появляться друг за другом на любом пути от внешней ссылки до корня.

Определение 13.4. Сбалансированное RB-дерево бинарного поиска — это RB-дерево бинарного поиска, в котором все пути от внешних ссылок до корня содержат одинаковое количество черных узлов.

А теперь рассмотрим альтернативный подход к разработке алгоритма с использованием сбалансированного дерева. В нем полностью игнорируется абстракция 2-3-4-дерева и формулируется алгоритм вставки, который сохраняет основное свойство сбалансированных RB-деревьев бинарного поиска с помощью ротаций. Например, использование восходящего алгоритма соответствует присоединению нового узла в нижней части пути поиска с помощью R-ссылки, затем продвижению вверх по пути поиска с выполнением ротаций или изменений цвета, как это делалось в случаях, представленных на рис. 13.17, для разбиения любой встретившейся пары последовательных R-ссылок. Основные выполняемые при этом операции — те же, что и в программе 13.6 и в ее восходящем аналоге, но при этом имеются незначительные различия, поскольку 3-узлы могут быть ориентированы в любом направлении, операции могут выполняться в ином порядке и с равным успехом могут приниматься различные решения о выполнении ротаций.

Подведем итоги: используя RB-деревья для реализации сбалансированных 2-3-4-деревьев, можно разработать таблицу символов, в которой операция найти для ключа в файле, состоящем, скажем, из 1 миллиона элементов, может быть выполнена путем сравнения этого ключа приблизительно с 20 другими ключами. В худшем случае требуется не более 40 сравнений. Более того, с каждым сравнением связаны лишь небольшие накладные расходы, и поэтому быстрое выполнение операции найти гарантировано даже в очень больших файлах.

Упражнения

13.48. Нарисуйте RB-дерево бинарного поиска, образованное нисходящими вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево.

13.49. Нарисуйте RB-дерево бинарного поиска, образованное восходящими вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево.

13.50. Нарисуйте RB-дерево, образованное в результате вставки по порядку латинских букв от A до K в первоначально пустое дерево, а затем опишите, что обычно происходит при построении дерева в процессе вставки возрастающей последовательности ключей.

13.51. Приведите последовательность вставок, в результате которой будет создано RB-дерево, изображенное на рис. 13.16.

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

13.53. Сколько различных RB-деревьев соответствуют 2-3-4-дереву, содержащему t3-узлов?

13.54. Нарисуйте все структурно различные RB-деревья поиска, содержащие N ключей, для .

13.55. Для каждого из деревьев в упражнении 13.43 определите вероятность того, что оно является результатом вставки N случайных различных элементов в первоначально пустое дерево.

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

13.57. Покажите, что в RB-дереве, состоящем из N узлов, в худшем случае длина почти всех путей от корня к внешнему узлу равна 2lgN .

13.58. Сколько ротаций требуется в худшем случае для вставки в RB-дерево, состоящее из N узлов?

13.59. Используя RB-представление и тот же рекурсивный подход, что и в программе 13.6, реализуйте операции создать, найти и вставить для таблиц символов, основанных на использовании восходящих сбалансированных 2-3-4-деревьев. Совет: код может быть похож на программу 13.6, но должен выполнять операции в другом порядке.

13.60. Используя RB-представление и тот же рекурсивный подход, что и в программе 13.6, реализуйте операции создать, найти и вставить для таблиц символов, основанных на использовании восходящих сбалансированных 2-3-деревьев.

13.61. Используя тот же рекурсивный подход, что и в программе 13.6, реализуйте операции создать, найти и вставить для таблиц символов, основанных на использовании сбалансированных по высоте (AVL-) деревьев.

13.62. Измените реализацию из упражнения 13.61, чтобы использовать RB-деревья (содержащие по 1 биту на узел) для кодирования информации о балансе высоты.

13.63. Реализуйте сбалансированные 2-3-4-деревья, используя представление RB-дерева, в котором 3-узлы всегда наклонены вправо. Примечание: это изменение позволяет исключить из внутреннего цикла операции вставить одну битовую проверку.

13.64. Для сохранения сбалансированности 4-узлов программа 13.6 выполняет ротации. Разработайте использующую представление в виде RB-дерева реализацию сбалансированных 2-3-4-деревьев, в которой 4-узлы могут быть представлены любыми

тремя узлами, соединенными двумя R-ссылками (полностью сбалансированными или несбалансированными).

13.65. Не используя дополнительную память для хранения бита цвета, реализуйте для RB-деревьев операции создать, найти и вставить, воспользовавшись следующим приемом. Чтобы окрасить узел в красный цвет, поменяйте местами две его ссылки. А чтобы проверить, является ли узел красным, проверьте, больше ли его левый дочерний узел, чем правый. Для обеспечения возможного обмена ссылок придется модифицировать функции сравнения. Этот прием заменяет сравнения битов сравнениями ключей, что может потребовать больших затрат, однако он демонстрирует, что в случае необходимости можно избавиться от дополнительного поля в узлах.

13.66. Реализуйте нерекурсивную функцию вставки в RB-дерево бинарного поиска (см. программу 13.6), соответствующую вставке в сбалансированное 2-3-4-дерево за один проход. Совет: введите ссылки gg, g и p, которые указывают, соответственно, на прадеда, деда и родителя текущего узла в дереве. Все эти ссылки могут потребоваться для выполнения двойной ротации.

13.67. Напишите программу, которая вычисляет долю B-узлов в заданном RB-дереве бинарного поиска. Протестируйте программу, вставив N случайных ключей в первоначально пустое дерево, для N = 103, 104, 105 и 106 .

13.68. Напишите программу, которая вычисляет долю элементов, находящихся в 3-узлах и 4-узлах заданного 2-3-4-дерева поиска. Протестируйте программу, вставив N случайных ключей в первоначально пустое дерево, для N = 103, 104, 105 и 106 .

13.69. Используя по одному биту на узел для представления цвета, можно представлять 2-, 3- и 4-узлы. Сколько битов на узел потребовалось бы для представления бинарным деревом 5-, 6-, 7- и 8-узлов?

13.70. Эмпирически вычислите среднее значение и среднеквадратичное отклонение количества сравнений, используемых при успешном и неудачном поиске в RB-дереве, построенном вставками N случайных узлов в первоначально пустое дерево, для N = 103, 104, 105 и 106 .

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

13.72. Воспользуйтесь программой-драйвером из упражнения 12.30 для сравнения самоорганизующегося поиска в скошенных BST-деревьях с гарантированной производительностью, обеспечиваемой в худшем случае RB-деревьями бинарного поиска, и со стандартными BST-деревьями для распределений запросов на поиск, определенных в упражнениях 12.31 и 12.32 (см. упражнение 13.29).

13.73. Реализуйте функцию найти для RB-деревьев, которая выполняет ротации и изменяет цвета узлов при продвижении вниз по дереву, чтобы обеспечить, что узел в нижней части пути поиска не является 2-узлом.

13.74. Воспользуйтесь решением упражнения 13.73 для реализации функции удалить для RB-деревьев. Найдите узел, который должен быть удален, продолжите поиск до нахождения 3-узла или 4-узла в нижней части пути и переместите узел-наследник из нижней части, чтобы заменить удаленный узел.

Слоеные списки

В этом разделе мы рассмотрим способ разработки быстрых реализаций операций с таблицами символов, который на первый взгляд кажется совершенно не похожим на рассмотренные методы на базе деревьев, хотя в действительности они очень тесно связаны. Этот подход основан на рандомизированной структуре данных и практически наверняка обеспечивает почти оптимальную производительность всех базовых операций для АТД таблицы символов. Лежащая в основе алгоритма структура данных, которая была разработана Пуфом (Pugh) в 1990 г. (см. раздел ссылок), называется слоеным списком (skip list, часто переводится как " список пропусков " ). В ней используются дополнительные ссылки в узлах связного списка для пропуска больших частей списка во время поиска.

На рис. 13.22 приведен простой пример, в котором каждый третий узел в упорядоченном связном списке содержит дополнительную ссылку, которая позволяет пропустить три узла списка. Эти дополнительные ссылки можно использовать для ускорения операции найти: сначала выполняется просмотр верхнего списка до тех пор, пока не будет найден ключ или узел с меньшим ключом, содержащий ссылку на узел с большим ключом; затем используются нижние ссылки для проверки двух промежуточных узлов. Этот метод ускоряет выполнение операции найти в три раза, поскольку при успешном поиске k-го узла в списке проверяется лишь около к/3 узлов.

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

Определение 13.5. Слоеный список — это упорядоченный связный список, в котором каждый узел содержит различное количество ссылок, причем i-е ссылки в узлах образуют односвязные списки, пропускающие узлы с менее чем i ссылками.

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

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

 Двухуровневый связный список


Рис. 13.22.  Двухуровневый связный список

Каждый третий узел в этом списке содержит вторую ссылку, поэтому можно " скакать " по списку с почти в три раза большей скоростью по сравнению с использованием только первых ссылок. Например, до двенадцатого узла в списке (P), можно добраться из начала списка, пройдя лишь по пяти ссылкам: по вторым ссылкам на C, G, L, N, а затем по первой ссылке узла N на P.

 Поиск и вставка в слоеном списке


Рис. 13.23.  Поиск и вставка в слоеном списке

Добавляя дополнительные уровни к структуре, показанной на рис. 13.22, и позволяя ссылкам пропускать различное количество узлов, мы получаем пример обобщенного списка пропусков. Для поиска ключа в этом списке процесс начинается с самого верхнего уровня с переходом вниз при каждой встрече ключа, который не меньше ключа поиска. Вот как выполняется (вверху) поиск ключа L: начав с уровня 3, следуем по первой ссылке, затем спускаемся по G (считая пустые ссылки ссылками на сигнальные узлы), затем до I, спускаемся на уровень 2, поскольку S больше чем L, затем спускаемся на уровень 1, поскольку M больше L. Для вставки узла L с тремя ссылками мы связываем его с тремя списками там, где при поиске были обнаружены ссылки на большие ключи.

Управление памятью — вероятно, наиболее сложный аспект использования слоеных списков. Объявления типов и код для выделения памяти под новые узлы будут рассмотрены при обсуждении алгоритма вставки. Пока же достаточно отметить, что доступ к узлу, который следует за узлом t на (к + 1)-ом уровне слоеного списка, обеспечивается выражением t->next[k]. Рекурсивная реализация в программе 13.7 демонстрирует, что поиск в слоеных списках не только является очевидным обобщением поиска в односвязных списках, но и подобен бинарному поиску или поиску в BST-деревьях. Вначале проверяется, содержится ли ключ поиска в текущем узле; если нет, ключ в текущем узле сравнивается с ключом поиска. Если он больше, выполняется один рекурсивный вызов, а если меньше — другой.

Программа 13.7. Поиск в слоеных списках

Для k, равного 0, этот код эквивалентен программе 12.6, выполняющей поиск в односвязных списках. Для общего случая k мы переходим к следующему узлу списка на уровне k, если его ключ меньше ключа поиска, и вниз на уровень k-1, если его ключ не меньше.

  private:
    Item searchR(link t, Key v, int k)
    { if (t == 0) return nullItem;
      if (v == t->item.key()) return t->item;
      link x = t->next[k];
      if ((x == 0) || (v < x->item.key()))
        { if (k == 0) return nullItem;
          return searchR(t, v, k-1);
        }
      return searchR(x, v, k);
    }
  public:
    Item search(Key v)
    { return searchR(head, v, lgN); }
      

Первой задачей, с которой мы сталкиваемся при необходимости вставки нового узла в слоеный список, является определение количества ссылок, которые должен содержать узел. Все узлы содержат по меньшей мере одну ссылку; следуя интуитивному представлению, отображенному на рис. 13.22, на втором уровне можно пропускать сразу по t узлов, если один из каждых t узлов содержит по меньшей мере две ссылки; продолжая далее, мы приходим к заключению, что один из каждых tj узлов должен содержать по меньшей мере j + 1 ссылок.

Для создания узлов с таким свойством мы выполняем рандомизацию с помощью функции, которая возвращает значение j + 1 с вероятностью 1/tj . Имея j, мы создаем новый узел с j ссылками и вставляем его в слоеный список, применяя рекурсивную схему, которая используется для операции найти, как показано на рис. 13.23. После достижения уровня j мы включаем новый узел в список при каждом спуске на уровень ниже. К этому моменту уже установлено, что элемент в текущем узле меньше ключа поиска и указывает (на уровне j) на узел, не меньший ключа поиска.

 Построение слоеного списка


Рис. 13.24.  Построение слоеного списка

Здесь показан процесс вставки элементов с ключами A S E R C H I N G в первоначально пустой слоеный список. Узлы содержат j ссылок с вероятностью 1/2j .

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

На рис. 13.24 показано построение слоеного списка из набора ключей, вставляемых в случайном порядке; а на рис. 13.25 приведен более объемный пример. На рис. 13.26 показано построение слоеного списка для тех же ключей, что и на рис. 13.24, но вставляемых в порядке возрастания. Как и для рандомизированных BST-деревьев, стохастические свойства слоеных списков не зависят от порядка вставки ключей.

Лемма 13.10. Для поиска и вставки в рандомизированный слоеный список с параметром t в среднем требуется порядка сравнений.

Мы ожидаем, что слоеный список должен иметь порядка уровней, поскольку больше наименьшего значения j, для которого tj = N . На каждом уровне мы ожидаем, что на предыдущем уровне было пропущено примерно t узлов, а перед спуском на следующий уровень придется перебрать приблизительно половину из них. Как видно из примера на рис. 13.25, количество уровней мало, но точное аналитическое обоснование этого свойства довольно сложно (см. раздел ссылок).

Программа 13.8. Структуры данных и конструктор слоеного списка

Узлы в слоеных списках содержат массив ссылок, поэтому конструктор класса node должен выделить памяти под этот массив и обнулить все его ссылки. Константа lgNmax — максимальное количество уровней, которое разрешено в списке: ее значение можно задать равным пяти для совсем маленьких списков или 30 — для огромных. Переменная N, как обычно, содержит количество элементов в списке, а lgN — количество уровней. Пустой список является ведущим узлом с lgNmax пустыми ссылками, при этом N и lgN должны быть равны 0.

  private:
    struct node
      { Item item; node **next; int sz;
        node(Item x, int k)
          { item = x; sz = k; next = new node*[k];
            for (int i = 0; i < k; i++) next[i] = 0;
          }
      };
    typedef node *link;
    link head;
    Item nullItem;
    int lgN;
  public:
    ST(int)
      { head = new node(nullItem, lgNmax); lgN = 0; }
      

Программа 13.9. Вставка в слоеные списки

Мы генерируем новый j-связный узел с вероятностью 1 / 2j , затем перемещаемся по пути поиска точно так же, как в программе 13.7, но включаем новый узел при спуске на каждый из j нижних уровней.

  private:
    int randX()
      { int i, j, t = rand();
        for (i = 1, j = 2; i < lgNmax; i++, j += j)
          if (t > RAND MAX/j) break;
        if (i > lgN) lgN = i;
        return i;
      }
  void insertR(link t, link x, int k)
    { Key v = x->item.key(); link tk = t->next[k];
      if ((tk == 0) || (v < tk->item.key()))
        { if (k < x->sz)
            { x->next[k] = tk; t->next[k] = x; }
          if (k == 0) return;
          insertR(t, x, k-1); return;
        }
      insertR(tk, x, k);
    }
  public:
    void insert(Item v)
      { insertR(head, new node(v, randX()), lgN); }
      

 Большой слоеный список


Рис. 13.25.  Большой слоеный список

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

Лемма 13.11. Слоеные списки содержат в среднем (t / (t — 1)) N ссылок.

Имеется N ссылок на нижнем уровне, N/t ссылок на первом уровне, около N/t2 ссылок на втором уровне и т.д., что в сумме дает примерно ссылок во всем списке.

Выбор подходящего значения t приводит нас к обычному балансу между временем выполнения и требуемым объемом памяти. При t = 2 в слоеных списках требуется в среднем около lg N сравнений и 2N ссылок — показатель, сравнимый с лучшей производительностью при использовании BST-деревьев. Для больших значений t время поиска и вставки увеличивается, но объем дополнительной памяти, требуемой для ссылок, уменьшается. Продифференцировав выражение из свойства 13.10, можно определить, что ожидаемое количество сравнений, требуемое для выполнения поиска в слоеном списке, минимально при t = e.

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

t2e34816
lg t 1,00 1,44 1,58 2,00 3,00 4,00
t / lg t 2,00 1,88 1,89 2,00 2,67 4,00

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

Поскольку время выполнения поиска является логарифмическим, перерасход памяти можно уменьшить (если объем памяти ограничен), чтобы он не слишком превышал объем памяти, требуемый для односвязных списков — увеличив значение t. Точная оценка времени выполнения зависит от распределения относительных затрат на переходы по ссылкам в списках и на спуск на следующий уровень. В лекция №16 мы вернемся к этому балансу между временем и памятью при рассмотрении задачи индексирования очень больших файлов.

Реализация других функций таблицы символов с помощью слоеных списков очевидна. Например, в программе 13.10 приведена реализация операции удалить, в которой применяется та же рекурсивная схема, что и для операции вставить в программе 13.9. Для удаления узла он удаляется из всех списков (в которые он был включен операцией вставить), и после удаления узла из нижнего списка он освобождается (в отличие от его создания перед просмотром списка для вставки). Операция объединить реализуется с помощью объединения списков (см. упражнение 13.78); для реализации операции выбрать в каждый узел добавляется поле, содержащее количество узлов, пропущенных ссылкой на него самого высокого уровня (см. упражнение 13.77).

 Построение списка пропусков, содержащего упорядоченные ключи


Рис. 13.26.  Построение списка пропусков, содержащего упорядоченные ключи

Здесь показан процесс вставки элементов с ключами A C E G H I N R S в первоначально пустой слоеный список. Стохастические свойства списка не зависят от порядка вставки ключей.

Программа 13.10. Удаление в слоеных списках

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

  private:
    void removeR(link t, Key v, int k)
      { link x = t->next[k];
        if (!(x->item.key() < v))
          { if (v == x->item.key())
              { t->next[k] = x->next[k]; }
            if (k == 0) { delete x; return; }
            removeR(t, v, k-1); return;
          }
        removeR(t->next[k], v, k);
      }
  public:
    void remove(Item x)
      { removeR(head, x.key(), lgN); }
      

Слоеные списки легко обобщить в качестве систематического способа быстрого перемещения по связному списку, однако важно понимать, что лежащая в их основе структура данных — всего лишь альтернативное представление сбалансированного дерева. Например, на рис. 13.27 приведено представление в виде слоеного списка сбалансированного 2-3-4-дерева с рис. 13.10.

Алгоритмы для сбалансированного 2-3-4-дерева из раздела 13.3 можно реализовать, используя абстракцию слоеного списка, а не абстракцию RB-дерева из раздела 13.4. Результирующий код получается при этом несколько сложнее кода рассмотренных представлений (см. упражнение 13.80). В лекция №16 мы еще вернемся к этой взаимосвязи между слоеными списками и сбалансированными деревьями.

Идеальный слоеный список, показанный на рис. 13.22, является жесткой структурой, которую трудно поддерживать при вставке нового узла (как и упорядоченный массив для реализации бинарного поиска), поскольку вставка требует изменения всех ссылок во всех узлах, следующих за вставленным. Один из способов сделать структуру более гибкой заключается в построении списков, в которых каждая ссылка пропускает одну, две или три ссылки находящегося под ней уровня: как видно из рис. 13.27, эта организация соответствует 2-3-4-деревьям. Еще одним эффективным способом уменьшения жесткости структуры является рандомизированный алгоритм, рассмотренный в этом разделе; другие альтернативы будут рассмотрены в лекция №16.

 Представление 2-3-4-дерева в виде слоеного списка


Рис. 13.27.  Представление 2-3-4-дерева в виде слоеного списка

Здесь представлено 2-3-4-дерево с рис. 13.10 в виде слоеного списка. В общем случае слоеные списки соответствуют сбалансированным многопутевым деревьям с одной или более ссылкой на узел (допускаются и 1-узлы без ключей и с 1 ссылкой). Для построения слоеного списка, соответствующего дереву, мы присваиваем каждому узлу количество ссылок, равное его высоте в дереве, а затем связываем узлы по горизонтали. Для построения дерева, соответствующего слоеному списку, мы группируем пропущенные узлы и рекурсивно связываем их с узлами на следующем уровне.

Упражнения

13.75. Нарисуйте слоеный список, образованный вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустой список, если функция randX возвращает последовательность значений 1, 3, 1, 1, 2, 2, 1, 4, 1 и 1.

13.76. Нарисуйте слоеный список, образованный вставками элементов с ключами E A I N O Q S T U Y в указанном порядке в первоначально пустой список, если функция randX возвращает такие же значения, как и в упражнении 13.75.

13.77. Реализуйте операцию выбрать для таблицы символов на основе слоеного списка.

13.78. Реализуйте операцию объединить для таблицы символов на основе слоеного списка.

13.79. Измените реализации операций найти и вставить, приведенные в программах 13.7 и 13.9, так, чтобы списки заканчивались не пустыми ссылками, а сигнальными узлами.

13.80. С помощью слоеных списков реализуйте операции создать, найти и вставить для таблиц символов, использующих абстракцию сбалансированного 2-3-4-дерева.

13.81. Сколько случайных чисел требуется в среднем для построения слоеного списка с параметром t, если используется функция randX из программы 13.9?

13.82. Для t = 2 измените программу 13.9 так, чтобы в функции randX исключить цикл for. Совет: последние j разрядов в двоичном представлении числа t принимают значение любого отдельного разряда j с вероятностью 1/2j.

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

13.84. Разработайте реализацию слоеного списка, в которой узлы содержат сами ссылки, а не ссылку на массив ссылок, как в программах 13.7 — 13.10. Совет: поместите массив в конец узла.

Характеристики производительности

Как для конкретного приложения осуществить выбор между рандомизированными BST-деревьями, скошенными BST-деревьями, RB-деревьями бинарного поиска и слоеными списками? До сих пор наше внимание было сосредоточено на различной природе гарантирования производительности, которую обеспечивают эти алгоритмы. Главными определяющими факторами всегда являются время и память, но необходимо учитывать и ряд других аспектов. В этом разделе мы кратко рассмотрим вопросы реализации, эмпирические исследования, оценки времени выполнения и требования к памяти.

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

Из трех основанных на деревьях алгоритмов проще всего реализовать рандомизированные BST-деревья. Здесь главные требования — надежность генератора случайных чисел и не слишком большие затраты времени на генерацию случайных битов. Скошенные деревья несколько более сложны, но являются очевидным обобщением стандартного алгоритма вставки в корень. RB-деревья бинарного поиска требуют еще немного большего кодирования, поскольку в них нужно проверять и изменять биты цвета. Одно из преимуществ RB-деревьев по сравнению с двумя другими алгоритмами — возможность использования битов цвета для проверки логики при отладке и для обеспечения быстрого поиска в любой момент времени на протяжении жизни дерева. Рассматривая скошенное BST-дерево, невозможно выяснить, все ли необходимые преобразования выполнил создавший его код; программная ошибка может приводить (только!) к проблемам, связанным с производительностью. Аналогично, ошибка в генераторе случайных чисел, используемом для рандомизированных BST-деревьев или слоеных списков, может привести к не замеченным в противном случае проблемам производительности.

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

В таблица 13.1 приведены экспериментальные данные по производительности четырех рассмотренных в этой главе методов, а также элементарных реализаций BST-деревьев, описанных в лекция №12, для 32-разрядных целых ключей. Приведенные в этой таблице данные подтверждают аналитические результаты, полученные в разделах 13.2, 13.4 и 13.5. RB-деревья работают со случайными ключами гораздо быстрее, чем другие алгоритмы. Пути в них на 35% короче, чем в рандомизированных или скошенных BST-деревьях, а в их внутренних циклах выполняется меньше действий. Рандомизированные деревья и слоеные списки требуют генерации по меньшей мере одного случайного числа для каждой вставки, а скошенные BST-деревья выполняют ротацию в каждом узле для каждой вставки и каждого поиска. Дополнительные затраты при использовании RB-деревьев бинарного поиска заключаются в проверке значений двух битов в каждом узле во время вставки, а иногда приходится выполнять и ротацию. При неравномерном доступе скошенные BST-деревья могут обеспечить более короткие пути, но эта экономия, скорее всего, будет перекрыта тем, что и для поиска, и для вставки потребуются ротации в каждом узле во внутреннем цикле, за исключением, быть может, крайних случаев.

Таблица 13.1. Экспериментальное сравнение реализаций сбалансированных деревьев
NПостроениеНеудачные поиски
BTRSCLBTRSCL
1250013212110002
2500246314111213
500047148510333327
1250011234324162810999718
250002751101503257191926211643
500006311422011774133484960463698
100000159277447282177310118 106 13211284229
200000347621996636411670235234294247193523
Обозначения:
BСтандартное BST-дерево (программа 12.8)
TBST-дерево, построенное вставками в корень (программа 12.13)
RРандомизированное BST-дерево (программа 13.2)
SСкошенное BST-дерево (упражнение 13.33 и программа 13.5)
CRB-дерево бинарного поиска (программа 13.6)
LСлоеный список (программы 13.7 и 13.9)

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

Скошенные BST-деревья не требуют использования дополнительной памяти под информацию о балансе; RB-деревья бинарного поиска требуют 1 дополнительный бит, а рандомизированные BST-деревья требуют наличия поля счетчика. Во многих приложениях поле счетчика используется и для других целей, поэтому для рандомизированных BST-деревьев оно может и не вызывать дополнительных затрат. На самом деле добавление этого поля может потребоваться и при использовании скошенных BST-деревьев, RB-деревьев бинарного поиска или слоеных списков. При необходимости RB-деревья бинарного поиска можно сделать столь же эффективными по памяти, как и скошенные BST-деревья, исключив бит цвета (см. упражнение 13.65). В современных приложениях объем памяти не столь важен, как когда-то, однако аккуратный программист всегда избегает напрасных затрат. Например, необходимо помнить, что некоторые системы для небольшого поля счетчика или 1-разрядного поля цвета в узле могут использовать целое 32-разрядное слово, а некоторые другие системы могут упаковывать поля в памяти так, что их распаковка требует значительного дополнительного времени. Если объем памяти ограничен, слоеные списки с большим параметром t могут уменьшить объем памяти, требуемый для ссылок, почти в два раза — ценой более медленного (но все же логарифмического) поиска. Некоторые приемы позволяют реализовать основанные на деревьях методы с использованием лишь одной ссылки на узел (см. упражнение 12.68).

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

Кроме специфичных использований в приложениях, это множество решений задачи разработки эффективных реализаций АТД таблицы символов важно также и потому, что оно иллюстрирует фундаментальные подходы к разработке алгоритмов, которые можно использовать и для решения других задач. При постоянной потребности в простых оптимальных алгоритмах мы часто сталкиваемся с почти оптимальными алгоритмами, подобными рассмотренным в этой главе. Вообще-то, как можно было заметить на примере сортировки, алгоритмы, основанные на сравнениях — начало, но далеко не конец. Переходя к абстракциям более низкого уровня, в которых возможна обработка фрагментов ключей, можно разрабатывать реализации, которые работают даже быстрее, чем рассмотренные в этой главе, что и будет продемонстрировано в лекция №14 и лекция №15.

Упражнения

13.85. Разработайте реализацию таблицы символов, использующую рандомизированные BST-деревья, которая содержит деструктор, конструктор копирования и перегруженную операцию присваивания и поддерживает операции создать, подсчитать, найти, вставить, удалить, объединить, выбрать и сортировать для АТД таблицы символов первого класса с поддержкой клиентских дескрипторов элементов (см. упражнения 12.6 и 12.7).

13.86. Разработайте реализацию таблицы символов, использующую слоеные списки, которая содержит деструктор, конструктор копирования и перегруженную операцию присваивания и поддерживает операции создать, подсчитать, найти, вставить, удалить, объединить, выбрать и сортировать для АТД таблицы символов первого класса с поддержкой клиентских дескрипторов элементов (см. упражнения 12.6 и 12.7).

Лекция 14. Хеширование

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

Алгоритмы поиска, использующие хеширование, состоят из двух отдельных частей. Первый шаг — вычисление хеш-функции (hash function), которая преобразует ключ поиска в адрес в таблице. В идеале различные ключи должны были бы отображаться на различные адреса, но часто два или более различных ключа могут дать один и тот же адрес в таблице. Поэтому вторая часть поиска методом хеширования — процесс разрешения коллизий (collision resolution), который обрабатывает такие ключи. В одном из методов разрешения конфликтов, который мы рассмотрим в этой главе, используются связные списки, поэтому он находит непосредственное применение в динамических ситуациях, когда трудно заранее предугадать количество ключей поиска. В других двух методах разрешения коллизий достигается высокая производительность поиска, поскольку элементы хранятся в фиксированном массиве. Мы рассмотрим способ усовершенствования этих методов, позволяющий использовать их и в тех случаях, когда нельзя заранее предсказать размеры таблицы.

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

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

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

Хеш-функции

Прежде всего необходимо решить задачу вычисления хеш-функции, преобразующей ключи в адреса таблицы. Обычно реализация этого арифметического вычисления не представляет сложности, но все же необходимо соблюдать осторожность, чтобы не нарваться на различные малозаметные подводные камни. При наличии таблицы, которая может содержать M элементов, нужна функция, преобразующая ключи в целые числа в диапазоне [0, M — 1]. Идеальная хеш-функция должна легко вычисляться и быть похожей на случайную функцию: для любых аргументов результаты в некотором смысле должны быть равновероятными.

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

Вероятно, простейшей является ситуация, когда ключами являются числа с плавающей точкой из фиксированного диапазона. Например, если ключи — числа, большие 0 и меньшие 1, их можно просто умножить на M, округлить результат до меньшего целого числа и получить адрес в диапазоне между 0 и M — 1; такой пример показан на рис. 14.1. Если ключи больше s и меньше t, их можно масштабировать, вычтя s и разделив на t—s, в результате чего они попадут в диапазон значений между 0 и 1, а затем умножить на M и получить адрес в таблице.

 Мультипликативная хеш-функция для ключей с плавающей точкой


Рис. 14.1.  Мультипликативная хеш-функция для ключей с плавающей точкой

Для преобразования чисел с плавающей точкой в диапазоне между 0 и 1 в индексы таблицы, размер которой равен 97, выполняется умножение этих чисел на 97. В данном примере произошло три коллизии: для индексов, равных 17, 53 и 76. Хеш-значения определяются старшими разрядами ключа, младшие разряды не играют никакой роли. Одна из целей разработки хеш-функции — устранение такого дисбаланса, чтобы во время вычисления учитывался каждый разряд.

Если ключи являются w-разрядными целыми числами, их можно преобразовать в числа с плавающей точкой и разделить на 2w для получения чисел с плавающей точкой в диапазоне между 0 и 1, а затем умножить на M, как в предыдущем абзаце. Если операции с плавающей точкой занимают много времени, а числа не столь велики, чтобы привести к переполнению, этот же результат может быть получен с помощью целочисленных арифметических операций: нужно ключ умножить на M, а затем выполнить сдвиг вправо на w разрядов для деления на 2w (или, если умножение приводит к переполнению, выполнить сдвиг, а затем умножение). Такие методы бесполезны для хеширования, если только ключи не распределены по диапазону равномерно, поскольку хеш-значение определяется только ведущими цифрами ключа.

Более простой и эффективный метод для w-разрядных целых чисел — один из, пожалуй, наиболее часто используемых методов хеширования — выбор в качестве размера M таблицы простого числа и вычисление остатка от деления к на M, т.е. h(k) = k mod M для любого целочисленного ключа k. Такая функция называется модульной хеш-функцией. Ее очень просто вычислить (k % M в языке C++), и она эффективна для достижения равномерного распределения значений ключей между значениями, меньшими M. Небольшой пример показан на рис. 14.2.

 Модульная хеш-функция для целочисленных ключей


Рис. 14.2.  Модульная хеш-функция для целочисленных ключей

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

v % 97 (слева)

v % 100 (в центре) и

(int) (a * v) % 100 (справа),

где a = .618033. Размеры таблицы для этих функций соответственно равны 97, 100 и 100. Значения выглядят случайными (поскольку случайны ключи). Вторая функция (v % 100) использует лишь две крайние правые цифры ключей и поэтому для неслучайных ключей может показывать низкую производительность.

Модульное хеширование применимо и к ключам с плавающей точкой. Если ключи принадлежат небольшому диапазону, можно масштабировать их в числа из диапазона между 0 и 1, 2w для получения w-разрядных целочисленных значений, а затем использовать модульную хеш-функцию. Другой вариант — просто использовать в качестве операнда модульной хеш-функции двоичное представление ключа (если оно доступно).

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

Основная причина выбора в качестве размера M хеш-таблицы простого числа для модульного хеширования показана на рис. 14.3. В этом примере символьных данных с 7-разрядным кодированием ключ трактуется как число с основанием 128 — по одной цифре для каждого символа в ключе. Слово now соответствует числу 1816567, которое может быть также записано как

поскольку в ASCII-коде символам n, o и w соответствуют числа 1568 = 110, 1578 = 111 и 1678 = 119. Выбор размера таблицы M = 64 для этого типа ключа неудачен, поскольку добавление к х значений, кратных 64 (или 128), не меняет значение х mod 64 — для любого ключа значением хеш-функции является значение последних 6 разрядов этого ключа. Безусловно, хорошая хеш-функция должна учитывать все разряды ключа, особенно для символьных ключей. Аналогичные ситуации могут возникать, когда M содержит множитель, являющийся степенью 2. Простейший способ избежать этого — выбрать в качестве M простое число.

 Модульные хеш-функции для кодированных символов


Рис. 14.3.  Модульные хеш-функции для кодированных символов

В каждой строке этой таблицы приведены: 3-буквенное слово, представление этого слова в ASCII-коде как 21-битовое число в восьмеричной и десятичной формах и стандартные модульные хеш-функции для размеров таблиц 64 и 31 (два крайних справа столбца). Размер таблицы 64 приводит к нежелательным результатам, поскольку для получения хеш-значения используются только самые правые разряды ключа, а буквы в словах обычного языка распределены неравномерно. Например, всем словам, оканчивающимся на букву у, соответствует хеш-значение 57. И, напротив, простое значение 31 вызывает меньше коллизий в таблице более чем вдвое меньшего размера.

Модульное хеширование очень просто реализовать, за исключением того, что размер таблицы должен быть простым числом. Для некоторых приложений можно довольствоваться небольшим известным простым числом или же поискать в списке известных простых чисел такое, которое близко к требуемому размеру таблицы. Например, числа равные 2t — 1, являются простыми при t = 2, 3, 5, 7, 13, 17, 19 и 31 (и ни при каких других значениях t < 31): это известные простые числа Мерсенна. Чтобы динамически распределить таблицу нужного размера, нужно вычислить простое число, близкое к этому значению. Такое вычисление нетривиально (хотя для этого и существует остроумный алгоритм, который будет рассмотрен в части 5), поэтому на практике обычно используют таблицу заранее вычисленных значений (см. рис. 14.4). Использование модульного хеширования — не единственная причина, по которой размер таблицы стоит сделать простым числом; еще одна причина рассматривается в разделе 14.4.

 Простые числа для хеш-таблиц


Рис. 14.4.  Простые числа для хеш-таблиц

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

Другой вариант обработки целочисленных ключей — объединение мультипликативного и модульного методов: нужно умножить ключ на константу в диапазоне между 0 и 1, а затем выполнить деление по модулю M. Другими словами, необходимо использовать функцию . Между значениями , M и эффективным основанием системы счисления ключа существует взаимосвязь, которая теоретически могла бы привести к аномальному поведению, но если использовать произвольное значение a, в реальном приложении вряд ли возникнет какая-либо проблема. Часто в качестве a выбирают значение ф = 0,618033... (золотое сечение).

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

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

В 7-разрядном ASCII-коде этому слову соответствует 84-разрядное число \begin{align*} 97 \cdot 128^{11} &+ 118 \cdot 128^{10} + 101 \cdot 128^{9} + 114 \cdot 128^{8} + 121 \cdot 128^{7}\\ &+ 108 \cdot 128^{6} + 111 \cdot 128^{5} + 110 \cdot 128^{4} + 103 \cdot 128^{3}\\ &+ 107 \cdot 128^{2} + 101 \cdot 128^{1} + 121 \cdot 128^{0}, \end{align*},

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

Чтобы вычислить модульную хеш-функцию для длинных ключей, они преобразуются фрагмент за фрагментом. Можно воспользоваться арифметическими свойствами функции модуля и использовать алгоритм Горнера (см. раздел 4.9 лекция №4). Этот метод основан на еще одном способе записи чисел, соответствующих ключам. Для рассматриваемого примера запишем следующее выражение: \begin{align*} ((((((((((97 \cdot 128^{11} &+ 118) \cdot 128^{10} + 101) \cdot 128^{9} + 114) \cdot 128^{8} + 121) \cdot 128^{7}\\ &+ 108) \cdot 128^{6} + 111) \cdot 128^{5} + 110) \cdot 128^{4} + 103) \cdot 128^{3}\\ &+ 107) \cdot 128^{2} + 101) \cdot 128^{1} + 121. \end{align*}

То есть десятичное число, соответствующее символьной кодировке строки, можно вычислить при просмотре ее слева направо, умножая накопленное значение на 128, а затем добавляя кодовое значение следующего символа. В случае длинной строки этот способ вычисления в конце концов приведет к числу, большему того, которое вообще можно представить в компьютере. Однако это число и не нужно, поскольку требуется только (небольшой) остаток от его деления на M. Результат можно получить, даже не сохраняя большое накопленное значение, т.к. в любой момент вычисления можно отбросить число, кратное M — при каждом выполнении умножения и сложения нужно хранить только остаток от деления по модулю M. Результат будет таким же, как если бы у нас имелась возможность вычислить длинное число, а затем выполнять деление (см. упражнение 14.10). Это наблюдение ведет к непосредственному арифметическому способу вычисления модульных хеш-функций для длинных строк — см. программу 14.1. В этой программе используется еще одно, последнее ухищрение: вместо основания 128 в ней используется простое число 127. Причина этого изменения рассматривается в следующем абзаце.

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

Программа 14.1. Хеш-функция для строковых ключей

Данная реализация хеш-функции для строковых ключей использует одно умножение и одно сложение для каждого символа в ключе. Если константу 127 заменить на 128, программа просто вычисляла бы методом Горнера остаток от деления числа, соответствующего 7-разрядному ASCII-представлению ключа, на размер таблицы. Простое основание, равное 127, помогает избежать аномалий, которые возникают, если размер таблицы является степенью 2 или кратным 2.

  int hash(char *v, int M)
    { int h = 0, a = 127;
      for (; *v != 0; v++)
        h = (a*h + *v) % M;
      return h;
    }
      

 Хеш-функции для символьных строк


Рис. 14.5.  Хеш-функции для символьных строк

На этих диаграммах показано распределение для набора английских слов (первые 1000 различных слов романа Мел-вилла " Моби Дик " ) при использовании программы 14.1 с

M = 96 и a = 128 (вверху),

M = 97 и a = 128 (в центре) и

M = 96 и a = 127 (внизу)

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

В программе 14.1 показан один из способов сделать это: использование простого основания вместо степени 2 и целого числа, соответствующего ASCII-представлению строки. На рис. 14.5 рис. 14.5 показано, как это изменение улучшает распределение для типичных строковых ключей. Теоретически хеш-значения, созданные программой 14.1, могут давать плохие результаты для размеров таблицы, которые кратны 127 (хотя на практике это, скорее всего, будет почти незаметно); для создания рандомизированного алгоритма можно было бы выбрать значение множителя наугад. Еще более эффективный подход — использование случайных значений коэффициентов в вычислении и различных случайных значений для каждой цифры ключа. Такой подход дает рандомизированный алгоритм, называемый универсальным хешированием (universal hashing).

Теоретически идеальная универсальная хеш-функция — это функция, для которой вероятность коллизии между двумя различными ключами в таблице размером M в точности равна 1/M. Можно доказать, что использование в качестве коэффициента а в программе 14.1 не фиксированного произвольного значения, а последовательности случайных различных значений преобразует модульное хеширование в универсальную хеш-функцию. Однако затраты на генерирование нового случайного числа для каждого символа в ключе обычно неприемлемы. На практике можно достичь компромисса, показанного в программе 14.1, не храня массив различных случайных чисел для каждого символа ключа, а варьируя коэффициенты с помощью генерации простой псевдослучайной последовательности.

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

Непосредственная реализация

  inline int hash(Key v, int M)
    { return (int) M*(v-s)/(t-s); }
      

выполняет эту задачу для ключей с плавающей точкой со значениями между s и t; для целочисленных ключей можно просто вернуть значение v % M. Если M не является простым числом, хеш-функция может возвращать

  (int) (.616161 * (float) v) % M
      

или результат аналогичного целочисленного выражения, вроде

  (16161 * (unsigned) v) % M
      

Все эти функции, включая программу 14.1 для работы со строковыми ключами, проверены временем; они равномерно распределяют ключи и служат программистам в течение многих лет. Универсальный метод, представленный в программе 14.2 — заметное усовершенствование для строковых ключей, которое обеспечивает случайные хеш-значения при небольших дополнительных затратах; аналогичные рандомизированные методы можно применять и к целочисленным ключам (см. упражнение 14.1).

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

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

Программа 14.2. Универсальная хеш-функция (для строковых ключей)

Эта программа выполняет те же вычисления, что и программа 14.1, однако для аппроксимации вероятности возникновения конфликтов для двух несовпадающих ключей до значения 1/M вместо фиксированных оснований системы счисления применяются псевдослучайные значения коэффициентов. Для минимизации нежелательных затрат времени при вычислении хеш-функции используется грубый генератор случайных чисел.

  int hashU(char *v, int M)
    { int h, a = 31415, b = 27183;
      for (h = 0; *v != 0; v++, a = a*b % (M-1))
        h = (a*h + *v) % M;
      return (h < 0) ? (h + M) : h;
    }
      

Такие операции часто являются источниками ошибок, особенно при переносе программы со старого компьютера на новый, с другим количеством разрядов в слове или прочими отличиями в точности выполнения операций. Во-вторых, весьма вероятно, что во многих приложениях вычисление хеш-функции будет выполняться во внутреннем цикле, и время ее выполнения может в значительной степени определять общее время выполнения. В подобных случаях важно убедиться, что функция сводится к эффективному машинному коду. Подобные операции — известные источники неэффективности; например, разница во времени выполнения простого модульного метода для целых чисел и версии, в которой вначале выполняется умножение ключа на 0,61616, может быть существенной. Наиболее быстрый метод для многих компьютеров — принять M равным степени 2 и воспользоваться хеш-функцией

  inline int hash(Key v, int M)
    { return v & (M-1); }
      

Эта функция использует только lg M — 1 младших разрядов ключей. Для устранения нежелательных эффектов плохого распределения ключей можно применять операцию побитового " И " , которая выполняется существенно быстрее и проще, чем другие операции.

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

Для проверки гипотезы, что хеш-функция создает случайные значения, можно использовать критерий (см. упражнение 14.5), но, возможно, это требование слишком жесткое. Вообще вполне достаточно, если метод хеширования выдает каждое значение одинаковое количество раз — такое поведение соответствует значению %2, равному 0, и совсем не является случайным. Однако следует с подозрением относиться и к очень большим значениям %2. На практике, вероятно, достаточно проверить, что значения распределены так, что ни одно из них не доминирует (см. упражнение 14.15). По этим же соображениям хорошо разработанная реализация таблицы символов, основанная на универсальном хешировании, могла бы периодически проверять, являются ли хеш-значения равномерно распределенными. А клиентскую программу можно информировать о том, что имело место либо маловероятное событие, либо ошибка в хеш-функции. Подобного рода проверка оказалась бы разумным дополнением к любому реальному рандомизированному алгоритму.

Упражнения

14.1. Используя абстракцию digit из лекция №10 для обработки машинного слова как последовательности байтов, реализуйте рандомизированный метод хеширования для ключей, представленных битами в машинных словах.

14.2. Проверьте, требуется ли время на преобразование 4-байтового ключа в 32-разрядное целое число в используемой вами программной среде.

о 14.3. Разработайте функцию хеширования для строковых ключей, основанную на идее одновременной загрузки 4 байтов с последующим выполнением арифметических операций сразу над 32 битами. Сравните время выполнения этой функции с временем выполнения программы 14.1 для 4-, 8-, 16- и 32-байтовых ключей.

14.4. Напишите программу для определения значений а и M при минимально возможном значении M, чтобы хеш-функция a*x % M выдавала различные (несовпадающие) значения для ключей, представленных на рис. 14.2. Полученный результат является примером совершенной хеш-функции.

о 14.5. Напишите программу для вычисления критерия для хеш-значений N ключей при размере таблицы, равном M. Это число определяется равенством

где fi — количество ключей с хеш-значением i. Если хеш-значения являются случайными, значение этой функции для N > cM должно быть равно с вероятностью 1 — 1/с.

14.6. Воспользуйтесь программой из упражнения 14.5 для вычисления хеш-функции 618033*x % 10000 для ключей, которые являются случайными положительными целыми числами, меньшими 106.

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

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

14.9. Рассмотрите идею реализации модульного хеширования для целочисленных ключей с помощью выражения (a*x) % M, где а — произвольное фиксированное простое число. Приводит ли это изменение к достаточному перемешиванию разрядов, чтобы можно было использовать не простое значение M?

14.10. Докажите, что (((ax) mod M) + b) mod M = (ax + b) mod M, при условии, что a, b, x и M — неотрицательные целые числа.

14.11. Если в упражнении 14.7 использовать слова из текстового файла, например книги, то вряд ли удастся получить хороший критерий . Объясните, почему это так.

14.12. Воспользуйтесь программой из упражнения 14.5 для вычисления хеш-функции 97*x % M для всех размеров таблицы в диапазоне от 100 до 200, используя в качестве ключей 103 случайных положительных целых чисел, меньших 106.

14.13. Воспользуйтесь программой из упражнения 14.5 для вычисления хеш-функции 97*x % M для всех размеров таблицы в диапазоне от 100 до 200, используя в качестве ключей целые числа в диапазоне от 102 до 103.

14.14. Воспользуйтесь программой из упражнения 14.5 для вычисления хеш-функции 100*x % M для всех размеров таблицы в диапазоне от 100 до 200, используя в качестве ключей 103 случайных положительных целых чисел, меньших 106.

14.15. Выполните упражнения 14.12 и 14.14, но используйте более простой критерий отбрасывания хеш-функций, которые выдают любое значение более 3N/ M раз.

Цепочки переполнения

Рассмотренные в разделе 14.1 функции хеширования преобразуют ключи в адреса таблицы; второй компонент алгоритма хеширования — определение обработки случаев, когда два ключа преобразуются в один и тот же адрес. Первое, что приходит на ум — построить для каждого адреса таблицы связный список элементов, ключи которых отображаются на этот адрес. Этот подход непосредственно приводит к обобщению метода элементарного поиска в списке (см. лекция №12) в программе 14.3, в которой вместо единственного списка используются M списков.

Такой метод традиционно называется цепочками переполнения (separate chaining), поскольку конфликтующие элементы объединяются в отдельные связные списки-цепочки. Пример таких цепочек приведен на рис. 14.6. Как и в случае элементарного последовательного поиска, эти списки можно хранить упорядоченными или оставить неупорядоченными. Здесь присутствует тот же основной компромисс, что и описанный в лекция №12, но для цепочек переполнения более важна не экономия времени (поскольку списки невелики), а экономия памяти (поскольку списков много).

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

 Хеширование с цепочками переполнения


Рис. 14.6.  Хеширование с цепочками переполнения

Здесь показан результат вставки ключей A S E R C H I N G X M P L в первоначально пустую хеш-таблицу с цепочками переполнения (неупорядоченные списки); используются хеш-значения, приведенные вверху. A попадает в список 0, затем S попадает в список 2, E — в список 0 (в его начало, чтобы время вставки было постоянным), R — в список 4 и т.д.

Программа 14.3. Хеширование с цепочками переполнения

Данная реализация таблицы символов основана на замене конструктора ST и функций search и insert в таблице символов с применением связных списков из программы 12.6 на приведенные здесь функции, а также на замене ссылки head на массив ссылок heads. Здесь используются те же рекурсивные функции поиска и удаления в списке, что и в программе 12.6, но при этом используются M списков с ведущими ссылками в heads, с использованием хеш-функции для выбора одного из списков. Конструктор устанавливает M так, что каждый список будет содержать около пяти элементов; поэтому для выполнения остальных операций требуется всего несколько проверок.

  private:
    link* heads;
    int N, M;
  public:
    ST(int maxN)
      { N = 0; M = maxN/5;
        heads = new link[M];
        for (int i = 0; i < M; i++) heads[i] = 0;
      }
    Item search(Key v)
      { return searchR(heads[hash(v, M)], v); }
    void insert(Item item)
      { int i = hash(item.key(), M);
        heads[i] = new node(item, heads[i]); N++;
      }
      

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

Лемма 14.1. Цепочки переполнения уменьшают количество сравнений, выполняемых при последовательном поиске, в M раз (в среднем) и используют дополнительный объем памяти для M ссылок.

Средняя длина списков равна N/M. Как было описано в лекция №12, можно ожидать, что успешные поиски будут доходить приблизительно до середины какого-либо списка. Неудачные поиски будут доходить до конца списка, если списки неупорядочены, и до половины списка, если они упорядочены.

Чаще всего для цепочек переполнения используются неупорядоченные списки, поскольку этот подход прост в реализации и эффективен: операция вставить выполняется за постоянное время, а операция найти — за время, пропорциональное N/M. Если ожидается очень большое количество неудачных поисков, их обнаружение можно ускорить в два раза, храня списки в упорядоченном виде, но за счет замедления операции вставить.

Лемма 14.1 тривиальна, поскольку средняя длина списков равна N/M независимо от распределения элементов по спискам. Например, предположим, что все элементы попадают в первый список. Тогда средняя длина списков равна (N + 0 + 0 + ... + 0)/M = N/M. Истинная причина практической пользы хеширования заключается в том, что очень высока вероятность наличия около N/M элементов в каждом списке.

Лемма 14.2. В хеш-таблице с цепочками переполнения, содержащей M списков и N ключей, вероятность того, что количество ключей в каждом списке отличается от N/M на небольшой постоянный коэффициент, очень близка к 1.

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

Здесь выбираются k из N элементов: эти к элементов попадают в данный список с вероятностью 1/M, а остальные N — k элементов не попадают в данный список с вероятностью 1 — 1/M. Обозначив , это выражение можно переписать как

что, согласно классической аппроксимации Пуассона, меньше чем

.

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

.

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

Приведенный анализ — пример классической задачи о размещении: N шаров случайным образом вбрасываются в одну из M урн, и анализируется распределение шаров по урнам. Классический математический анализ этих задач дает и много других интересных фактов, имеющих отношение к изучению алгоритмов хеширования. Например, в соответствии с аппроксимацией Пуассона количество пустых списков близко к . Хотя более интересен факт, что среднее количество элементов, вставленных до первой коллизии, равно приблизительно . Этот результат — решение классической задачи о дне рождения. Например, в соответствии с этими же рассуждениями, при M= 365 среднее количество людей, среди которых найдутся двое с одинаковыми датами рождения, приблизительно равно 24. В соответствии со вторым классическим результатом среднее количество элементов, вставленных прежде, чем в каждом списке окажется по меньшей мере по одному элементу, приблизительно равно MHM Этот результат — решение классической задачи коллекционера карточек. Например, аналогичный анализ утверждает, что при M = 1280 нужно собрать около 9898 бейсбольных карточек (купонов), прежде чем удастся заполучить по одной карточке для каждого из 40 игроков каждой из 32 команд. Данные результаты весьма показательны для рассмотренных свойств хеширования. Практически они означают, что цепочки переполнения можно успешно использовать, если хеш-функция выдает значения, близкие к случайным (см. раздел ссылок).

Обычно в реализациях цепочек переполнения значение M выбирают достаточно малым, чтобы не тратить понапрасну большие непрерывные участки памяти с пустыми ссылками, но достаточно большим, чтобы последовательный поиск в списках был наиболее эффективным методом. Гибридные методы (вроде использования бинарных деревьев вместо связных списков), вряд ли стоят рассмотрения. Как правило, можно выбирать значение M равным приблизительно одной пятой или одной десятой от ожидаемого количества ключей в таблице, чтобы каждый из списков в среднем содержал порядка 5—10 ключей. Одно из достоинств цепочек переполнения состоит в том, что этот выбор не критичен: при наличии большего, чем ожидалось, количества ключей поиски будут требовать несколько больше времени, чем если бы заранее был выбран больший размер таблицы; при наличии в таблице меньшего количества ключей поиск будет сверхбыстрым при (скорее всего) небольшом дополнительном расходе памяти. Если памяти хватает, значение M можно выбрать достаточно большим, чтобы время поиска было постоянным; если же объем памяти критичен, все-таки можно повысить производительность в M раз, выбрав максимально возможное значение M.

Приведенные в предыдущем абзаце рассуждения относятся к времени поиска. На практике для цепочек переполнения обычно применяются неупорядоченные списки, по двум основным причинам. Во-первых, как уже упоминалось, исключительно быстро выполняется операция вставить: вычисляем хеш-функцию, выделяем память для узла и связываем его с началом соответствующего списка. Во многих приложениях шаг распределения памяти не требуется (поскольку элементами, вставляемыми в таблицу символов, могут быть существующие записи с доступными полями ссылок), и для выполнения операции вставить остается выполнить всего три—четыре машинные инструкции. Второе важное преимущество использования в программе 14.3 реализации с использованием неупорядоченных списков заключается в том, что все списки работают подобно стекам, поэтому можно легко удалить последние вставленные элементы, которые размещаются в начале списков (см. упражнение 14.21). Эта операция важна при реализации таблицы символов с вложенными областями видимости (например, в компиляторе).

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

В общем случае хеширование не подходит для использования в приложениях, в которых требуются реализации операций АТД сортировать и выбрать. Однако хеширование часто используется в типичных ситуациях, когда необходимо использовать таблицу символов с потенциально большим количеством операций найти, вставить и удалить с последующим однократным выводом элементов в порядке их ключей. Одним из примеров такого приложения является таблица символов в компиляторе; другой пример — программа удаления повторяющихся ключей, наподобие программы 12.11. Для обработки этой ситуации в реализации цепочек переполнения в виде неупорядоченных списков нужно воспользоваться одним из методов сортировки, описанных в лекциях 6—10. В реализации с использованием упорядоченных списков сортировку можно выполнить слиянием всех списков (см. упражнение 14.23) за время, пропорциональное Nlg M.

Упражнения

14.16. Сколько времени может потребоваться в худшем случае для вставки N ключей в первоначально пустую таблицу с цепочками переполнения в виде (1) неупорядоченных списков и (2) упорядоченных списков?

14.17. Приведите содержимое хеш-таблицы, образованной вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустую таблицу из M = 5 списков при использовании цепочек переполнения в виде неупорядоченных списков. Для преобразования k-ой буквы алфавита в индекс таблицы используйте хеш-функцию 11k mod M.

14.18. Выполните упражнение 14.17, но для случая упорядоченных списков. Зависит ли ответ от порядка вставки элементов?

14.19. Напишите программу, которая вставляет N случайных целых чисел в таблицу размером N/100 с цепочками переполнения, а затем определяет длину самого короткого и самого длинного списков, при N = 103, 104, 105 и 106 .

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

14.21. Измените программу 14.3, включив в нее для каждого элемента целочисленное поле, значение которого устанавливается равным количеству элементов в таблице на момент вставки элемента. Затем реализуйте функцию, удаляющую все элементы, для которого значение этого поля больше заданного целого числа N.

14.22. Измените реализацию функции search в программе 14.3, чтобы она выводила все элементы с ключами, равными заданному ключу, так же, как это сделано в функции show.

14.23. Разработайте реализацию таблицы символов с цепочками переполнения в виде упорядоченных списков, которая включает деструктор, конструктор копирования и перегруженную операцию присваивания и поддерживает операции создать, подсчитать, найти, вставить, удалить, объединить, выбрать и сортировать для АТД первого класса таблицы символов при поддержке клиентских дескрипторов (см. упражнения 12.6 и 12.7).

Линейное опробование

Если можно заранее оценить количество элементов, которые должны быть помещены в хеш-таблицу, и имеется достаточно большая непрерывная область памяти, в которой можно поместить все ключи и оставить еще свободные участки, то тогда в хеш-таблице, вероятно, вообще не стоит использовать какие-либо ссылки. Существует несколько методов хранения N элементов в таблице размером M > N, при которых разрешение коллизий основано на использовании пустых мест в таблице. Такие методы называются методами хеширования с открытой адресацией (open-addressing).

Простейший метод открытой адресации называется линейным опробованием (linear probing): при возникновении коллизии (когда хеширование дает адрес в таблице, который уже занят элементом с ключом, не совпадающим с ключом поиска) мы просто проверяем следующую позицию в таблице. Обычно такую проверку (определяющую, содержит ли данная позиция таблицы элемент с ключом, равным ключу поиска) называют пробой (probe). При линейном опробовании определяется один из трех возможных исходов пробы: если позиция таблицы содержит элемент, ключ которого совпадает с искомым, то поиск завершился успешно; в противном случае (если позиция таблицы содержит элемент, ключ которого не совпадает с искомым) мы просто проверяем позицию таблицы с большим индексом, продолжая этот процесс (с возвратом к началу таблицы при достижении ее конца) до тех пор, пока не будет найден искомый ключ или пустая позиция таблицы. Если элемент, содержащий искомый ключ, должен быть вставлен после неудачного поиска, он помещается в пустое место таблицы, где был завершен поиск. Программа 14.4 является реализацией АТД таблицы символов, использующей этот метод. Процесс построения хеш-таблицы с использованием линейного опробования для некоторого набора ключей показан на рис. 14.7.

 Хеширование методом линейного опробования


Рис. 14.7.  Хеширование методом линейного опробования

На этой диаграмме показан процесс вставки ключей A S E R C H I N G X M P в первоначально пустую хеш-таблицу с открытой адресацией, размер которой равен 13. Используются показанные вверху хеш-значения и разрешение коллизий методом линейного опробования. Вначале A попадает в позицию 7, затем S попадает в позицию 3, E — в позицию 9. Потом, после коллизии в позиции 9, R попадает в позицию 10 и т.д. При достижении правого конца таблицы опробование продолжается с левого конца: например, последний вставленный ключ P хешируется в позицию 8, но после коллизий в позициях 8—12 и 0—4 попадает в позицию 5. Неопробованные позиции таблицы затенены.

Как и в случае цепочек переполнения, производительность методов с открытой адресацией зависит от коэффициента , но при этом он интерпретируется иначе.

Программа 14.4. Линейное опробование

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

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

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

  private:
    Item *st;
    int N, M;
    Item nullItem;
  public:
    ST(int maxN)
      { N = 0; M = 2*maxN;
        st = new Item[M];
        for (int i = 0; i < M; i++) st[i] = nullItem;
      }
    int count() const
      { return N; }
    void insert(Item item)
      { int i = hash(item.key(), M);
        while (!st[i].null()) i = (i+1) % M;
        st[i] = item; N++;
      }
    Item search(Key v)
      { int i = hash(v, M);
        while (!st[i].null())
          if (v == st[i].key())
            return st[i];
          else
            i = (i+1) % M;
        return nullItem;
    }
      

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

В случае разреженной таблицы (значение мало) очевидно, что для большинства операций поиска пустая позиция будет найдена после всего нескольких проб. В случае почти полной таблицы (значение близко к 1) для выполнения поиска может потребоваться очень большое количество проб, а при полностью заполненной таблице поиск может даже привести к бесконечному циклу. Как правило, чтобы время поиска не было слишком большим, при использовании линейного опробования нужно не допускать заполнения таблицы. То есть вместо того, чтобы использовать дополнительную память для ссылок, она используется для создания дополнительного места в хеш-таблице, что позволяет сократить последовательности проб. При использовании линейного опробования размер таблицы больше, чем с цепочками переполнения, т.к. необходимо соблюдение условия M > N, но общий объем используемой памяти может быть меньше, поскольку не используются ссылки. Вопросы сравнения используемого объема памяти будут подробно рассмотрены в разделе 14.5; а пока проанализируем время выполнения линейного опробования как функцию от .

Средние затраты на выполнение линейного опробования зависят от того, как элементы при их вставке объединяются в непрерывные группы занятых ячеек таблицы, называемые кластерами (cluster). Рассмотрим следующие два крайних случая заполненной наполовину (M = 2N) таблицы линейного опробования. В лучшем случае позиции таблицы с четными индексами будут пустыми, а с нечетными — занятыми (или наоборот — прим. перев.). В худшем случае первая (вообще-то любая непрерывная — прим. перев.) половина позиций таблицы будет пустой, а вторая — заполненной. Средняя длина кластеров в обоих случаях равна N/(2N) = 1/2, но среднее количество проб при неудачном поиске равно 1 (нужна по меньшей мере одна проба) плюс

в лучшем случае и 1 плюс

в худшем случае.

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

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

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

и

соответственно для успешного и неудачного поиска.

Несмотря на сравнительно простую форму этих результатов, точный анализ линейного опробования представляет собой сложную задачу. Его вывод, полученный Кнутом (Knuth) в 1962 г., явился значительной вехой в анализе алгоритмов (см. раздел ссылок).

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

коэффициент загрузки ( ) 1/2 2/3 3/4 9/10
успешный поиск 1,5 2,0 3,0 5,5
неудачный поиск 2,5 5,0 8,5 55,5

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

 Удаление в хеш-таблице с линейным опробованием


Рис. 14.8.  Удаление в хеш-таблице с линейным опробованием

На этой диаграмме показан процесс удаления ключа X из таблицы, показанной на рис. 14.7. Во второй строке показан результат простого удаления X из таблицы, что неприемлемо, поскольку в этом случае M и P оказываются отрезаны от своиххеш-позиций пустой позицией, оставшейся после X. Поэтому ключи M, S, H и P (справа от X в этом же кластере) повторно вставляются в указанном порядке с использованием хеш-значений, указанных сверху, и с разрешением коллизий с помощьюли-нейного опробования. M заполняет свободное место, оставленное ключом X, потом в таблицу без коллизий вставляются S и H, а затем в позицию 2 вставляется P.

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

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

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

Программа 14.5. Удаление из хеш-таблицы с линейным опробованием

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

  void remove(Item x)
    { int i = hash(x.key(), M), j;
      while (!st[i].null())
        if (x.key() == st[i].key())
          break;
        else
          i = (i+1) % M;
      if (st[i].null()) return;
      st[i] = nullItem; N--;
      for (j = i+1; !st[j].null(); j = (j + 1) % M, N--)
        { Item v = st[j]; st[j] = nullItem; insert(v); }
    }
      

Упражнения

14.24. Какое время может потребоваться в худшем случае для вставки N ключей в первоначально пустую таблицу при использовании линейного опробования?

14.25. Приведите содержимое хеш-таблицы, образованной вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустую таблицу размером M= 16, использующую линейное опробование. Для преобразования k-ой буквы алфавита в индекс таблицы используйте хеш-функцию 11k mod М.

14.26. Выполните упражнение 14.25 для М = 10.

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

14.28. Напишите программу, которая вставляет N/2случайных целых чисел в таблицу размером N, использующую линейное опробование, а затем на основании длин кластеров вычисляет средние затраты на неудачный поиск в результирующей таблице, для N= 103, 104, 105 и 106 .

14.29. Напишите программу, которая вставляет N/2случайных целых чисел в таблицу размером N, использующую линейное опробование, а затем вычисляет средние затраты на успешный поиск в результирующей таблице, для N= 103, 104, 105 и 106 . Не выполняйте поиск всех ключей после построения таблицы (отслеживайте затраты на ее построение).

14.30. Определите экспериментальным путем, изменяются ли средние затраты на успешный и неудачный поиск в случае выполнения длинной последовательности чередующихся случайных вставок и удалений с помощью программ 14.4 и 14.5 в хеш-таблице размером 2N, содержащей N ключей, для N = 10, 100 и 1000 и до N2 пар вставок-удалений для каждого значения N.

Двойное хеширование

Основной принцип линейного опробования (а, вообще-то, и любого метода хеширования) —гарантирование того, что при поиске конкретного ключа мы просматриваем каждый ключ, который отображается в тот же адрес в таблице (в частности, сам ключ, если он есть в таблице). Однако при использовании схемы с открытой адресацией, как правило, просматриваются и другие ключи, особенно когда заполнение таблицы велико. В примере, приведенном на рис. 14.7, при поиске ключа N просматриваются ключи C, E, R и I, ни один из которых не имеет такого же хеш-значения. Что еще хуже, вставка ключа с одним хеш-значением может существенно увеличить время поиска ключей с другими хеш-значениями: на рис. 14.7 вставка ключа M приводит к увеличению времени поиска для позиций 7-12 и 0-1. Это явление называется кластеризацией (clustering), поскольку оно связано с процессом образования кластеров. Для почти заполненных таблиц оно может значительно замедлять линейное опробование.

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

Программа 14.6. Двойное хеширование

Двойное хеширование аналогично линейному опробованию, за исключением того, что здесь используется вторая хеш-функция — для определения шага поиска, используемого после каждой коллизии. Шаг поиска должен быть ненулевым, а размер таблицы и шаг поиска должны быть взаимно простыми числами. Функция remove для линейного опробования (см. программу 14.5) не работает с двойным хешированием, поскольку любой ключ может присутствовать во многих различных последовательностях проб.

  void insert(Item item)
    { Key v = item.key();
      int i = hash(v, M), k = hashtwo(v, M);
      while (!st[i].null()) i = (i+k) % M;
      st[i] = item; N+ + ;
    }
  Item search(Key v)
    { int i = hash(v, M), k = hashtwo(v, M);
      while (!st[i].null())
      if (v == st[i].key())
        return st[i];
      else
        i = ( i+k) % M;
      return nullItem;
    }
      

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

  inline int hashtwo(Key v) { return (v % 97) + 1; }
      

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

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

 Двойное хеширование


Рис. 14.9.  Двойное хеширование

На этой диаграмме показан процесс вставки ключей A S E R C H I N G X M P L в первоначально пустую хеш-таблицу с открытой адресацией с использованием хеш-значений, приведенных вверху, и разрешением коллизий с помощью двойного хеширования. Первое и второе хеш-значения каждого ключа приведены в двух строках под этим ключом. Как и на рис. 14.7, проверяемые позиции таблицы выделены белым цветом. Ключ A попадает в позицию 7, затем S попадает в позицию 3, E — в позицию 9, как и на рис. 14.7. Но ключ R после коллизии в позиции 9 попадает в позицию 1; его второе хеш-значение, равное 5, используется в качестве шага последовательности проб после коллизии. Аналогично, ключ P окончательно попадает в позицию 6 после коллизий в позициях 8, 12, 3, 7, 11 и 2 при использовании в качестве шага его второго хеш-значения, равного 4.

 Кластеризация


Рис. 14.10.  Кластеризация

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

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

соответственно для успешного и неудачного поиска.

Эти формулы — результат глубокого математического анализа, выполненного Гиба (Guibas) и Шемереди (Szemeredi) (см. раздел ссылок). Доказательство основывается на том, что двойное хеширование почти эквивалентно более сложному алгоритму случайного хеширования, при котором используется зависящая от ключей последовательность позиций опробования, обеспечивающая равную вероятность попадания каждой пробы в каждую позицию таблицы. Этот алгоритм всего лишь аппроксимирует двойное хеширование, по многим причинам: например, очень трудно гарантировать, что при двойном хешировании каждая позиция таблицы проверяется хотя бы один раз, но при случайном хешировании одна и та же позиция таблицы может проверяться и более одного раза.

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

При случайном хешировании средние затраты на неудачный поиск определяются равенством

Выражение слева — сумма вероятностей, что при неудачном поиске используются более к проб, при к = 0, 1, 2, ... (на основании элементарной теории вероятностей она равна средней вероятности). При поиске всегда используется одна проба, затем с вероятностью N/M требуется вторая проба, с вероятностью (N/M)2 — третья проба и т.д. Эту же формулу можно использовать для вычисления следующего приближенного значения средней стоимости успешного поиска в таблице, содержащей N ключей:

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

Теперь эту сумму можно упростить и вычислить, умножив числители и знаменатели всех дробей на M:

и, упрощая далее, получаем
поскольку .

Точная природа взаимосвязи между производительностью двойного хеширования и идеальным случаем случайного хеширования, установленной Гиба и Шемереди — асимптотическое приближение, которое не обязательно должно быть справедливо для используемых на практике размеров таблиц. Кроме того, полученные результаты основываются на предположении, что хеш-функции возвращают случайные значения. Но все же асимптотические формулы из свойства 14.5 на практике позволяют достаточно точно предсказать производительность двойного хеширования, даже при использовании такой просто вычисляемой второй хеш-функции, как (v % 97)+1. Как и соответствующие формулы для линейного опробования, при приближении значения а к 1 эти формулы стремятся к бесконечности, но гораздо медленнее.

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

коэффициент загрузки () 1/2 2/3 3/4 9/10
успешный поиск 1,4 1,6 1,8 2,6
неудачный поиск 1,5 2,0 3,0 5,5

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

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

 Затраты на выполнение поиска с открытой адресацией


Рис. 14.11.  Затраты на выполнение поиска с открытой адресацией

На этих графиках показаны затраты на построение хеш-таблицы размером 1000 вставками ключей в первоначально пустую таблицу с помощью линейного опробования (вверху) и двойного хеширования (внизу). Каждый вертикальный столбец представляет затраты на вставку 20 ключей. Серые кривые — теоретически предсказанные затраты (см. леммы 14.4 и 14.5).

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

Установите значения выражений для неудачного поиска в свойствах 14.4 и 14.5 равными t и решите уравнения относительно .

Например, чтобы среднее количество проб при поиске было меньшим 10, при использовании линейного опробования необходимо сохранять таблицу пустой не менее чем на 32%, а при использовании двойного хеширования — лишь на 10%. Чтобы можно было выполнить неудачный поиск менее чем за 10 проб при обработке 105 элементов, нужно свободное место лишь для 104 дополнительных элементов. В то же время при использовании цепочек переполнения потребовалась бы дополнительная память для более чем 105 ссылок, а при использовании деревьев бинарного поиска — еще вдвое больший объем памяти.

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

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

Упражнения

14.31. Приведите содержимое хеш-таблицы, образованной вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустую таблицу размером М = 16 при использовании двойного хеширования. Воспользуйтесь хеш-функцией 11k mod М для первой пробы и второй хеш-функцией (k mod 3) + 1 для шага поиска (если ключ является k-ой буквой алфавита).

14.32. Выполните упражнение 14.31 для М = 10.

14.33. Реализуйте удаление для двойного хеширования с использованием сигнального элемента.

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

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

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

14.37. Реализуйте алгоритм, который аппроксимирует случайное хеширование, используя ключ в качестве исходного значения для встроенного в программу генератора случайных чисел (как в программе 14.2).

14.38. Пусть таблица, размер которой равен 106, заполнена наполовину, причем занятые позиции распределены случайным образом. Оцените вероятность того, что заняты все позиции, индексы которых кратны 100.

14.39. Допустим, что в коде реализации двойного хеширования присутствует ошибка, приводящая к тому, что одна или обе хеш-функции всегда возвращают одно и то же (ненулевое) значение. Опишите, что происходит в каждой из следующих ситуаций: (1) когда ошибочна первая функция, (2) когда ошибочна вторая функция, (3) когда ошибочны обе функции.

Динамические хеш-таблицы

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

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

Программа 14.7. Динамическая вставка в хеш-таблицу (для линейного опробования)

Данная реализация операции insert для линейного опробования (см. программу 14.4) обрабатывает произвольное количество ключей, удваивая размер таблицы при каждом заполнении таблицы наполовину (этот же подход может быть использован для двойного хеширования или цепочек переполнения). Удвоение требует распределения памяти для новой таблицы и повторного хеширования в нее всех ключей, а затем освобождения памяти, занимаемой старой таблицей. Функция-член init используется для построения или повторного построения таблицы, заполненной пустыми элементами указанных размеров; она реализована так же, как конструктор ST в программе 14.4, поэтому ее код опущен.

  private:
    void expand()
      { Item *t = st;
        init(M+M);
        for (int i = 0; i < M/2; i++)
          if (!t[i].null()) insert(t[i]);
        delete t;
      }
  public:
    ST(int maxN)
      { init(4); }
    void insert(Item item)
      { int i = hash(item.key(), M);
        while (!st[i].null()) i = (i+1) % M;
        st[i] = item;
        if (N++ >= M/2) expand();
      }
      

 Динамическое расширение хеш-таблицы


Рис. 14.12.  Динамическое расширение хеш-таблицы

На этой диаграмме показан процесс вставки ключей A S E R C H I N G X M P L в динамическую хеш-таблицу, которая расширяется удвоением размера, с использованием хеш-значений, приведенных вверху, и разрешением коллизий с помощью линейного опробования. В четырех строках под ключами приводятся хеш-значения для размеров таблицы, равных 4, 8, 16 и 32. Начальный размер таблицы равен 4, затем, перед вставкой E, он удваивается до 8, перед вставкой C — до 16 и перед вставкой G — до 32. При каждом удвоении размера таблицы для всех ключей выполняются повторные хеширование и вставка. Все вставки выполняются в разреженные таблицы (заполненные менее чем на одну четверть для повторной вставки и от четверти до половины в остальных случаях), поэтому коллизий возникает мало.

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

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

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

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

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

Линейное опробование с удвоением таблицы применяется тогда, когда операция вставить приводит к заполнению ключами половины таблицы; уменьшение размера таблицы вдвое используется тогда, когда операция удалить приводит к заполнению ключами таблицы на одну восьмую. В обоих случаях после изменения размера таблицы до значения N она содержит N/4ключей. После этого до следующего удвоения размера таблицы должно быть выполнено N/4операций вставить (повторной вставкой N/2ключей в таблицу размером 2N), а до следующего " ополовинивания " таблицы — N/8операций удалить (повторной вставкой N/8ключей в таблицу размером N/2). В обоих случаях количество повторно вставляемых ключей не превышает двукратного количества операций, выполненных до момента перестройки таблицы, поэтому общие затраты остаются линейными. Таблица всегда заполнена от одной восьмой до одной четверти (см. рис. 14.13), следовательно, по свойству 14.4, среднее количество проб для каждой операции меньше 3.

 Динамическое хеширование


Рис. 14.13.  Динамическое хеширование

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

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

Упражнения

14.40. Приведите содержимое хеш-таблицы, образованной вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустую таблицу с начальным размером М = 4, которая удваивает размер при заполнении наполовину, с разрешением коллизий методом линейного опробования. Для преобразования k-ой буквы алфавита в индекс таблицы воспользуйтесь хеш-функцией 11k mod М.

14.41. Будет ли более экономичным увеличивать хеш-таблицу, утраивая (а не удваивая) ее размер при заполнении таблицы наполовину?

14.42. Будет ли более экономичным увеличивать хеш-таблицу, утраивая ее размер при заполнении таблицы на одну треть (вместо удвоения при заполнении наполовину)?

14.43. Будет ли более экономичным увеличивать хеш-таблицу, удваивая ее размер при заполнении таблицы на три четверти (а не наполовину)?

14.44. Добавьте в программу 14.7 функцию удалить, которая удаляет элемент, как в программе 14.4, но затем сжимает таблицу, вдвое уменьшая ее размер, если после удаления таблица остается пустой на семь восьмых.

14.45. Реализуйте версию программы 14.7 для цепочек переполнения, которая в 10 раз увеличивает размер таблицы каждый раз, когда средняя длина списка становится равной 10.

14.46. Измените программу 14.7 и реализацию из упражнения 14.44, чтобы в них использовалось двойное хеширование с " ленивым " удалением (см. упражнение 14.33). Нужно, чтобы программа подсчитывала количество фиктивных объектов и пустых позиций для принятия решения о необходимости расширения или сужения таблицы.

14.47. Разработайте реализацию таблицы символов с использованием линейного опробования в динамических таблицах, которая содержит деструктор, конструктор копирования и перегруженную операцию присваивания и поддерживает операции АТД первого класса таблицы символов создать, подсчитать, найти, вставить, удалить и объединить при поддержке клиентских дескрипторов (см. упражнения 12.6 и 12.7).

Перспективы

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

Таблица 14.1. Экспериментальное сравнение реализаций хеш-таблиц
NСозданиеНеудачный поиск
RHPhP*RHPhP*
12501053011010
25003134211000
50006144321010
1250014655561221
250003497811165343
5000074181112223615888
100000182352123478445232115
15000054403613899895221
1600005843441471151336623
1700006855451361212268525
18000065615015213344912527
19000079 106 59155144219426130
2000004078415918615633
Обозначения:
RRB-дерево бинарного поиска (программы 12.8 и 13.6)
HЦепочки переполнения (программа 14.3 при размере таблицы 20000)
PЛинейное опробование (программа 14.4 при размере таблицы 200000)
DДвойное хеширование (программа 14.6 при размере таблицы 200000)
PЛинейное опробование с расширением путем удвоения (программа 14.7)

Эти относительные значения времени построения таблиц символов из случайных последовательностей 32-битовых целых чисел и поиска в них подтверждают, что для легко хешируемых ключей хеширование работает значительно быстрее, чем поиск по дереву. Из всех методов хеширования двойное хеширование в разреженных таблицах работает медленнее, чем метод цепочек переполнения и линейное опробование (из-за затрат на вычисление второй хеш-функции), но значительно быстрее линейного опробования в почти заполненной таблице; кроме того, этот метод — единственный, который может обеспечить быстрый поиск с использованием лишь небольшого объема дополнительной памяти. Динамические хеш-таблицы, построенные с использованием линейного опробования и расширения удвоением, требуют больших затрат времени на создание, чем другие хеш-таблицы, из-за распределения памяти и повторного хеширования, но несомненно обеспечивают наиболее быстрый поиск. Этот метод удобен тогда, когда чаще всего выполняется поиск и заранее нельзя точно предвидеть количество ключей.

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

Сравнение линейного опробования и двойного хеширования с методом цепочек переполнения выполнить сложнее, поскольку необходимо точно учитывать использование памяти. Цепочки переполнения используют дополнительную память под ссылки; методы с открытой адресацией неявно используют дополнительную память внутри таблицы для завершения последовательностей проб. Следующий конкретный пример иллюстрирует эту ситуацию. Предположим, что имеется таблица М списков, построенная хешированием с цепочками переполнения, что средняя длина списков равна 4, и что каждый элемент и каждая ссылка занимают по одному машинному слову. Предположение, что элементы и ссылки занимают одинаковый объем памяти, оправданно во многих ситуациях, поскольку очень большие элементы обычно заменяются ссылками на них. В этом случае таблица занимает 9М слов памяти (4М для элементов и 5М для ссылок), и требует для выполнения поиска в среднем 2 пробы. Но при линейном опробовании для 4М элементов в таблице размером 9М требуется всего пробы для успешного поиска, что на 30% меньше, чем с цепочками переполнения при том же объеме используемой памяти; а при линейном опробовании для 4М элементов в таблице размером 6М для успешного поиска требуется (в среднем) 2 пробы и, следовательно, используется на 33% меньше памяти, чем с цепочками переполнения при том же времени выполнения. Кроме того, можно использовать динамический метод, наподобие программы 14.7, для сохранения небольшого коэффициента загрузки таблицы с помощью увеличения ее размера.

Приведенные в предыдущем абзаце рассуждения показывают, что обычно выбор цепочек переполнения вместо открытой адресации по соображениям производительности не оправдан. Однако на практике цепочки переполнения с фиксированным значением М часто выбирают по ряду других причин: их легко реализовать (особенно операцию удалить); они требуют небольшого дополнительного объема памяти для элементов с уже выделенными полями ссылок, пригодными для использования таблицей символов и другими АТД, которые могут в них нуждаться. Хотя производительность этого метода снижается с увеличением количества элементов в таблице, это снижение вполне терпимо и происходит так, что вряд ли повредит приложению, поскольку производительность все равно в М раз выше, чем при последовательном поиске.

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

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

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

Таблица символов с быстрым неудачным поиском и несколько более медленным успешным поиском может использоваться для реализации словаря исключений (exception dictionary). Например, система текстовой обработки может содержать алгоритм для разбиения слов на слоги, который успешно работает для большинства слов, но не работает в отдельных случаях (вроде слова " безыскусный " ). Скорее всего, в словаре исключений будет найдено лишь небольшое количество слов очень большого документа, поэтому почти все поиски будут неудачными.

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

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

Например, предположим, что имеется словарь исключений, состоящий из 1000 элементов, и 1 миллион элементов, которые необходимо искать в словаре, поэтому почти все поиски должны быть неудачными. Такая ситуация может возникнуть, если все элементы были бы необычными словами или случайными 32-разрядными целыми числами. Один из подходов — хеширование всех слов, скажем, в 15-разрядные значения (размер таблицы будет около 216). 1000 исключений занимают 1/64 часть таблицы, и большинство из 1 миллиона операций поиска сразу завершатся неудачей, обнаружив пустую позицию таблицы при первой же пробе. Но если таблица содержит 32-разрядные слова, задачу можно выполнить значительно эффективней, преобразовав ее в битовую таблицу исключений и используя 20-разрядные хеш-значения. При неудачном поиске (в большинстве случаев) поиск завершается проверкой одного бита; при удачном поиске требуется выполнение второй проверки в меньшей таблице. Исключения занимают 1/1000 часть таблицы; неудачный поиск — наиболее вероятная операция; и задача выполняется с помощью 1 миллиона битовых проверок с прямой индексацией. Это решение основывается на идее, что хеш-функция создает короткий сертификат, представляющий ключ — эта важная концепция полезна и в приложениях, отличных от реализаций таблиц символов.

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

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

Упражнения

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

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

14.50. Реализуйте АТД таблицы символов с быстрым неудачным поиском, как описано в тексте, используя для второй проверки цепочки переполнения.

Лекция 15. Поразрядный поиск

Некоторые методы поиска не сравнивают на каждом шаге полные значения ключей поиска, а просматривают ключи небольшими фрагментами. Эти методы носят название поразрядного поиска (radix search) и работают совершенно аналогично методам поразрядной сортировки, рассмотренным в лекция №10. Они удобны, когда ключи поиска легко разбиваются на фрагменты, и могут обеспечить эффективные решения для многих реальных задач, применяющих поиск.

В поразрядном поиске применяется та же абстрактная модель, которая использовалась в лекция №10: в зависимости от контекста ключ может быть словом (последовательностью байтов фиксированной длины) или строкой (последовательностью байтов переменной длины). Ключи, являющиеся словами, рассматриваются как числа, представленные в системе счисления с основанием R при различных значениях R (основание системы счисления), и обрабатываются отдельные цифры этих чисел. Строки можно рассматривать как числа переменной длины, ограничиваемые специальным символом, чтобы для ключей как фиксированной, так и переменной длины можно было создавать алгоритмы, основываясь на абстрактной операции " извлечь i-ю цифру ключа " вместе с соглашением об обработке ситуации, когда ключ содержит менее i цифр.

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

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

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

Обычно при поразрядном поиске вначале рассматриваются старшие цифры ключей. Многие методы непосредственно соответствуют MSD-методам поразрядной сортировки — так же, как BST-поиск соответствует быстрой сортировке. В частности, мы рассмотрим аналоги методов сортировки с линейным временем выполнения из лекция №10 — линейные по времени методы поиска, основанные на том же принципе.

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

Деревья цифрового поиска

Простейший метод поразрядного поиска основан на использовании деревьев цифрового поиска (digital search trees — DST), которые мы в дальнейшем будем называть DST-деревьями. Алгоритмы операций найти и вставить аналогичны поиску и вставке в бинарном дереве, за исключением одного различия: ветвление в дереве выполняется не по результату сравнения полных ключей, а в соответствии с выбранными битами ключа. На первом уровне используется ведущий бит; на втором уровне используется бит, следующий за ведущим и т.д., пока не встретится внешний узел. Программа 15.1 является реализацией операции найти; аналогично можно реализовать и операцию вставить. Вместо использования операции < для сравнения ключей мы будем считать, что доступна функция digit, обеспечивающая доступ к отдельным битам ключей. Этот код практически совпадает с кодом поиска в бинарном дереве (см. программу 12.8), но, как будет показано, имеет существенно иные характеристики производительности.

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

Программа 15.1. Бинарное DST-дерево

Для разработки реализации таблицы символов с использованием DST-деревьев мы изменили в стандартной реализации BST-дерева реализации операций найти и вставить (см. программу 12.8) — здесь приведен пример операции найти. Для принятия решения о том, следует ли переходить влево или вправо, вместо сравнения полных ключей выполняется проверка единственного (ведущего) бита ключа. В рекурсивных вызовах функции содержится третий параметр, позволяющий смещать вправо позицию проверяемого бита при спуске вниз по дереву. Для проверки битов используется функция digit, описанная в лекция №10. Эти же изменения проведены и в реализации операции вставить; в остальном используется код из программы 12.8.

  private:
    Item searchR(link h, Key v, int d)
      { if (h == 0) return nullItem;
        if (v == h->item.key()) return h->item;
        if (digit(v, d) == 0)
          return searchR(h->l, v, d+1);
        else
          return searchR(h->r, v, d+1);
      }
  public:
    Item search(Key v)
    { return searchR(head, v, 0); }
      

На рис. 15.1 приведены двоичные представления однобуквенных ключей, используемых в остальных рисунках этой главы. На рис. 15.2 показан пример вставки в DST-дерево, а на рис. 15.3 — процесс вставки ключей в первоначально пустое дерево.

Разряды ключей управляют поиском и вставкой, но обратите внимание, что DST-деревья не обладают свойством упорядоченности, характерным для BST-деревьев. То есть ключи в узлах слева от данного не обязательно меньше, а ключи в узлах справа от данного не обязательно больше ключей данного узла, как это было бы в BST-дереве с различными ключами. Ключи слева от данного узла действительно меньше ключей справа от него — если узел находится на уровне к, все они совпадают в первых к разрядах, а следующий разряд равен 0 для ключей слева и 1 для ключей справа — но сам ключ узла может быть наименьшим, наибольшим или любым в диапазоне всех ключей из поддерева этого узла.

DST-деревья характеризуются тем, что каждый ключ находится где-то на пути, определяемом разрядами ключа (слева направо). Этого свойства достаточно для правильной работы реализаций операций найти и вставить в программе 15.1.

 Двоичные представления односимвольных ключей


Рис. 15.1.  Двоичные представления односимвольных ключей

Как и в лекция №10, в небольших примерах, приведенных на рисунках этой главы, для представления i-ой буквы алфавита используется 5-разрядное двоичное представление числа i, что и продемонстрировано здесь на примере нескольких ключей. Биты нумеруются слева направо от 0 до 4.

 DST-дерево и вставка


Рис. 15.2.  DST-дерево и вставка

В этом DST-дереве (вверху) при неудачном поиске ключа M = 01101 мы переходим из корня влево (поскольку первый бит в двоичном представлении ключа равен 0), потом вправо (поскольку второй бит равен 1), затем вправо, влево и завершаем поиск на пустой ссылке под ключом N. Для вставки ключа M (внизу) мы заменяем пустую ссылку в месте завершения поиска ссылкой на новый узел, как это делается при вставке в BST-дерево.

 Построение DST-дерева


Рис. 15.3.  Построение DST-дерева

На этой последовательности рисунков показан результат вставки ключей A S E R C H I N G в первоначально пустое DST-дерево.

Предположим, что ключи являются словами фиксированной длины, состоящими из w битов. Из требования различия ключей следует, что , и обычно предполагается, что N значительно меньше ; в противном случае лучше было бы использовать распределяющий поиск (см. лекция №12). Этому условию удовлетворяет множество реальных задач. Например, использование DST-деревьев вполне подходит для таблицы символов, содержащей вплоть до 10 записей с 32-разрядными ключами (но, скорее всего, не 106 записей), или любое количество записей с 64-разрядными ключами. DST-деревья работают также и с ключами переменной длины; но мы отложим подробное рассмотрение этого случая до раздела 15.2, где будет рассмотрен и ряд других вариантов.

Производительность в худшем случае для деревьев, построенных с помощью поразрядного поиска, значительно выше производительности в худшем случае для BST-деревьев — если количество ключей велико, а длина ключей мала по сравнению с их количеством. Во многих приложениях длина самого длинного пути в DST-дереве чаще всего оказывается сравнительно небольшой (например, если ключи образованы случайными значениями разрядов). В частности, самый длинный путь наверняка ограничен длиной самого длинного ключа; а если ключи имеют фиксированную длину, то время поиска ограничено этой длиной. Сказанное иллюстрируется на рис. 15.4.

 DST-дерево для худшего случая


Рис. 15.4.  DST-дерево для худшего случая

На этой последовательности рисунков показаны результаты вставки ключей P = 10000, H = 01000, D = 00100, B = 00010 и A = 00001 впер-воначально пустое DST-дерево. Последовательность деревьев кажется вырожденной, но длина пути ограничена длиной двоичного представления ключей. Ни один 5-разрядный ключ, за исключением 00000, не приведет к дальнейшему увеличению высоты дерева.

Лемма 15.1. Для выполнения поиска или вставки в DST-дереве, построенном из N случайных ключей, требуется околоlgN сравнений в среднем и около 2 lgN сравнений в худшем случае. Количество сравнений никогда не превышает количество разрядов в ключе поиска.

Вышеуказанные результаты в среднем и в худшем случае можно доказать для случайных ключей при помощи рассуждений, аналогичных приведенным, для более естественной задачи в следующем разделе, поэтому это доказательство вынесено туда в упражнение (см. упражнение 15.30). Доказательство основывается на интуитивном ожидании, что непросмотренная часть случайного ключа с равной вероятностью может начинаться с 0 или 1, поэтому с обеих сторон любого ключа их должно быть поровну. При каждом перемещении вниз по дереву используется один бит ключа, поэтому ни один поиск в DST-дереве не может потребовать больше сравнений, чем разрядов в ключе поиска. Для типичного случая, когда используются w-разрядные слова и количество ключей N значительно меньше общего возможного количества ключей 2w, длины путей близки кlgN. Поэтому для случайных ключей количество сравнений значительно меньше количества разрядов в ключах.

На рис. 15.5 показано большое DST-дерево, образованное случайными 7-разрядными ключами. Это дерево почти идеально сбалансировано. Использование DST-деревьев удобно во многих реальных приложениях, поскольку эти деревья обеспечивают практически оптимальную производительность даже для очень больших задач, требуя лишь минимальных усилий на реализацию. Например, DST-дерево, построенное из 32-разрядных ключей (или четырех 8-битовых символов), гарантировано требует менее 32 сравнений, а DST-дерево, построенное из 64-разрядных ключей (или восьми 8-битовых символов), гарантировано требует менее 64 сравнений, даже при наличии миллиардов ключей. Для больших N эти гарантии сравнимы с теми, которые обеспечивают RB-деревья, но для их реализации требуется лишь примерно столько же усилий, как и для реализации стандартных BST-деревьев (которые могут гарантировать только производительность, пропорциональную N2). Это свойство делает DST-деревья привлекательной альтернативой использованию сбалансированных деревьев для практической реализации операций таблицы символов найти и вставить — при условии наличия эффективного доступа к разрядам ключей.

 Пример DST-дерева


Рис. 15.5.  Пример DST-дерева

Это DST-дерево, построенное вставкой около 200 случайных ключей, так же хорошо сбалансировано, как и его аналоги из главы 15.

Упражнения

15.1. Нарисуйте DST-дерево, образованное вставками элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево при использовании двоичной кодировки, приведенной на рис. 15.1.

15.2. Приведите последовательность вставок ключей A B C D E F G, приводящую к образованию полностью сбалансированного DST-дерева, одновременно являющегося допустимым BST-деревом.

15.3. Приведите последовательность вставки ключей A B C D E F G, приводящую к образованию полностью сбалансированного DST-дерева, в котором каждый узел имеет ключ, меньший ключей всех узлов в его поддереве.

15.4. Нарисуйте DST-дерево, образованное вставками элементов с ключами 01010011 00000111 00100001 01010001 11101100 00100001 10010101 01001010 в указанном порядке в первоначально пустое дерево.

15.5. Можно ли в DST-деревьях хранить записи с повторяющимися ключами, как в BST-деревьях? Обоснуйте свой ответ.

15.6. Экспериментально сравните высоту и длину внутреннего пути DST-дерева, построенного вставками N случайных 32-разрядных ключей в первоначально пустое дерево, с этими же характеристиками стандартного BST-дерева и RB-дерева (см. лекция №13), построенных из этих же ключей, при N = 103, 104, 105 и 106 .

15.7. Приведите полную характеристику длины внутреннего пути для худшего случая DST-дерева, содержащего N различных w-разрядных ключей.

15.8. Реализуйте операцию удалить для таблицы символов на основе DST-дерева.

15.9. Реализуйте операцию выбрать для таблицы символов на основе DST-дерева.

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

Trie-деревья

В этом разделе мы рассмотрим деревья поиска, которые позволяют использовать разряды ключей для проведения поиска подобно DST-деревьям, но ключи которых упорядочены, что позволяет поддерживать рекурсивные реализации операции сортировать и других операций таблиц символов, как для BST-деревьев. Основная идея заключается в хранении ключей только в нижней части дерева, в листьях. Результирующая структура данных обладает рядом полезных свойств и служит основой для нескольких эффективных алгоритмов поиска. Впервые эта структура была создана Брианде (Briandais) в 1959 г., и поскольку она оказалась удобной для выборки (retrieval), в 1960 г. Фредкин (Fredkin) дал ей специальное название trie. Обычно это слово произносится как " трайи " или " трай " (похоже на try — попытка, англ.), чтобы отличать его от " tree " (дерево). Наверно, в соответствии с принятой в книге терминологией следовало бы ввести термин " trie-деревья бинарного поиска " , но термин trie-дерево повсеместно используется и всем понятен. В этом разделе рассматривается базовая бинарная версия, в разделе 15.3 — ее важная модификация, а в разделах 15.4 и 15.5 — базовая многопутевая версия trie-деревьев и их варианты.

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

В trie-дереве ключи хранятся в листьях бинарного дерева. Вспомните, что было сказано в лекция №5: лист в дереве — это узел, не имеющий дочерних узлов, что отличает его от внешнего узла, который интерпретируется как пустой дочерний узел. В бинарном дереве под листом понимается внутренний узел с пустыми левой и правой ссылками. Хранение ключей в листьях, а не во внутренних узлах позволяет использовать разряды ключей для управления поиском, как для DST-деревьев в разделе 15.1, сохраняя при этом свойство, что все ключи, текущий разряд которых равен 0, попадают в левое поддерево, а все ключи, текущий разряд которых равен 1 — в правое.

Определение 15.1. Trie-дерево — это бинарное дерево с ключами, связанными с каждым из его листьев, которое рекурсивно определяется следующим образом. Trie-дерево из пустого множества ключей представляет собой пустую ссылку. Trie-дерево из единственного ключа — это лист, содержащий данный ключ. И, наконец, trie-дерево из множества ключей мощностью более 1 — это внутренний узел, левая ссылка которого указывает на trie-дерево с ключами, начинающимися с бита 0, а правая — на trie-дерево с ключами, начинающимися с бита 1, если для построения поддеревьев удалить ведущий бит.

Каждый ключ в trie-дереве хранится в листе, который находится на пути, заданном последовательностью ведущих разрядов ключа. И наоборот, каждый лист в trie-дереве содержит единственный ключ, который начинается с разрядов, определенных путем из корня к этому листу. Пустые ссылки в не листовых узлах соответствуют последовательностям ведущих разрядов, которые не присутствуют ни в одном ключе trie-дерева. Следовательно, для поиска ключа в trie-дереве нужно всего лишь пройти по нему в соответствии с разрядами ключа, как в DST-деревьях, но при этом не нужно выполнять сравнения во внутренних узлах. Поиск начинается с левого разряда ключа и с верхушки дерева и проходит по левой ссылке, если текущий разряд равен 0, и по правой — если 1, перебирая разряды ключа по одному слева направо. Поиск, закончившийся на пустой ссылке, неудачен; поиск, закончившийся в листе, может быть завершен одним сравнения с ключом, поскольку этот узел содержит единственный ключ в дереве, который может быть равен искомому. Реализация этого процесса приведена в программе 15.2.

Для вставки ключа в trie-дерево вначале, как обычно, выполняется поиск. Если поиск завершается на пустой ссылке, она, как обычно, заменяется ссылкой на новый лист, содержащий ключ. Но если поиск заканчивается в листе, необходимо продолжить перемещение вниз по дереву, добавляя внутренний узел для каждого разряда, значение которого совпадает для искомого и найденного ключей; завершится этот процесс тем, что оба ключа в листьях, являющихся дочерними узлами внутреннего узла, будут соответствовать первому разряду, в котором они отличаются. Пример поиска и вставки в trie-дереве показан на рис. 15.6; процесс построения trie-дерева вставками ключей в первоначально пустое дерево представлен на рис. 15.7. Полная реализация алгоритма вставки приведена в программе 15.3.

Программа 15.2. Поиск в trie-дереве

В этой функции разряды ключа используются для управления переходами при перемещении вниз по дереву, так же, как и в программе 15.1 для DST-деревьев. Возможны три варианта: если поиск доходит до листа (с обеими пустыми ссылками), то это единственный узел trie-дерева, который может содержать запись с ключом v. В этом случае выполняется проверка, действительно ли этот узел содержит v (успешный поиск) или какой-то другой ключ, ведущие разряды которого совпадают с v (неудачный поиск). Если поиск доходит до пустой ссылки, то вторая ссылка родительского узла не должна быть пустой и, следовательно, в trie-дереве существует какой-то другой ключ, отличающийся от искомого текущим разрядом, т.е. поиск неудачен. В программе предполагается, что ключи различны и (если ключи могут иметь различную длину) ни один ключ не является префиксом другого ключа. Член item не используется в не листовых узлах.

private:
  Item searchR(link h, Key v, int d)
    { if (h == 0) return nullItem;
      if (h->l == 0 && h->r == 0)
        { Key w = h->item.key();
          return (v == w) ? h->item : nullItem;
        }
      if (digit(v, d) == 0)
        return searchR(h->l, v, d+1);
      else
        return searchR(h->r, v, d+1);
    }
public:
  Item search(Key v)
    { return searchR(head, v, 0); }
      

 Поиск и вставка в trie-дереве


Рис. 15.6.  Поиск и вставка в trie-дереве

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

При успешном поиске ключа H = 01000 в этом дереве (вверху) мы переходим из корня влево (поскольку первый бит в двоичном представлении ключа равен 0), затем вправо (поскольку второй бит равен 1), где и обнаруживаем H — единственный ключ в дереве, начинающийся с битов 01. Ни один из присутствующих в дереве ключей не начинается с 101 или 11, и эти последовательности битов приводят в trie-дереве к двум пустым не листовым ссылкам.

Чтобы вставить ключ I (внизу), придется добавить три не листовых узла: один — соответствующий 01, с пустой ссылкой, соответствующей 011; один — соответствующий 010, с пустой ссылкой, соответствующей 0101; и один — соответствующий 0100 с ключом H = 01000 в листе слева от него и с ключом I = 01001 в листе справа.

Программа 15.3. Вставка в trie-дерево

Для вставки нового узла в trie-дерево вначале, как обычно, выполняется поиск. В случае неудачного поиска возможны два варианта.

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

Если неудачный поиск завершен в листе, используется функция split, создающая по одному новому внутреннему узлу для каждой битовой позиции, в которой искомый и найденный ключ совпадают. Этот процесс завершается созданием одного внутреннего узла для самого левого разряда, в котором эти ключи различаются. Оператор switch в функции split преобразует два проверяемых разряда в число для переключения на один из четырех возможных случаев. Если разряды одинаковы (случай 002 = 0 или 112 = 3 ), разбиение продолжается; если разряды различны (случай 012 = 1 или 102 = 2 ), разбиение прекращается.

  private:
    link split(link p, link q, int d)
      { link t = new node(nullItem); t->N = 2;
         Key v = p->item.key(); Key w = q->item.key();
        switch(digit(v, d)*2 + digit(w, d))
          { case 0: t->l = split(p, q, d+1); break;
            case 1: t->l = p; t->r = q; break;
            case 2: t->r = p; t->l = q; break;
            case 3: t->r = split(p, q, d+1); break;
          }
        return t;
      }
    void insertR(link& h, Item x, int d)
      { if (h == 0) { h = new node(x); return; }
        if (h->l == 0 && h->r == 0)
          { h = split(new node(x), h, d); return; }
        if (digit(x.key(), d) == 0)
          insertR(h->l, x, d+1);
        else
          insertR(h->r, x, d+1);
      }
  public:
    ST(int maxN)
      { head = 0; }
    void insert(Item item)
      { insertR(head, item, 0); }
      

Поскольку алгоритм не обращается к пустым ссылкам в листьях и не хранит элементы в не листовых узлах, можно сократить объем используемой памяти с помощью конструкции union или пары производных классов, определив узлы как принадлежащие к одному из этих двух типов (см. упражнения 15.20 и 15.21). Но пока мы пойдем более простым путем, используя единственный тип узлов, который применялся в BST-деревьях, DST-деревьях и других структурах бинарных деревьев: внутренние узлы характеризуются пустыми ключами, а листья — пустыми ссылками; однако мы будем помнить, что при необходимости можно сэкономить память, теряемую из-за этого упрощения. В разделе 15.3 будет рассмотрено усовершенствование алгоритма, исключающее потребность в нескольких типах узлов, а в главе 16 лекция №16 приводится реализация, в которой используется конструкция union. А теперь рассмотрим основные свойства trie-деревьев, вытекающие из определения и приведенных примеров.

 Построение trie-дерева


Рис. 15.7.  Построение trie-дерева

На этой последовательности рисунков показан результат вставки ключей A S E R C H I N в первоначально пустое trie-дерево.

Лемма 15.2. Структура trie-дерева не зависит от порядка вставки ключей: для каждого данного множества различных ключей существует уникальное trie-дерево.

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

Левое поддерево trie-дерева содержит все ключи, ведущий разряд которых равен 0, а правое поддерево — все ключи, ведущий разряд которых равен 1.

Это свойство trie-деревьев обусловливает прямое соответствие с поразрядным поиском: поиск по бинарному trie-дереву разбивает файл совершено так же, как при бинарной быстрой сортировке (см. лекция №10). Такое соответствие становится очевидным при сравнении trie-дерева, показанного на рис. 15.6, с диаграммой разбиения для бинарной быстрой сортировки на рис. 10.4 (не считая незначительного различия в ключах); это аналогично соответствию между поиском по бинарному дереву и быстрой сортировкой, отмеченному в лекция №12.

В частности, в отличие от DST-деревьев, trie-деревья обладают свойством упорядоченности ключей и поэтому позволяют элементарно реализовать операции сортировать и выбрать в таблице символов (см. упражнения 15.17 и 15.18). Более того, trie-деревья столь же хорошо сбалансированы, как и DST-деревья.

Лемма 15.3. Для выполнения вставки или поиска случайного ключа в trie-дереве, построенном из N случайных (различных) битовых строк, требуется в среднем около lgN сравнений разрядов. В худшем случае количество битовых сравнений ограничено только количеством битов в искомом ключе.

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

Тогда производительность для среднего случая можно вычислить, исходя из следующих вероятностных рассуждений. Вероятность того, что каждый из N ключей в случайном trie-дереве отличается от случайного ключа поиска по меньшей мере в одном из t ведущих разрядов, равна .

Вычитание этого значения из 1 дает вероятность того, что один из ключей в trie-дереве совпадает во всех t ведущих разрядах с ключом поиска. То есть

— это вероятность того, что для выполнения поиска потребуется более t сравнений разрядов. Из элементарной теории вероятностей известно, что для t > 1 сумма вероятностей того, что случайная переменная будет больше t, равна среднему значению этой случайной переменной, поэтому средние затраты на поиск определяются выражением

Воспользовавшись элементарной аппроксимацией , находим, что затраты на поиск должны быть приблизительно равны

Значения приблизительно lgN членов этой суммы, для которых 2t значительно меньше N, очень близки к 1; значения всех членов, для которых 2t значительно больше N, близки к 0; и значения нескольких членов, для которых , лежат в интервале между 0 и 1. Поэтому вся сумма приблизительно равна lgN. Для более точного определения этого значения требуется выполнение очень сложных математических вычислений (см. раздел ссылок). В приведенном анализе предполагается, что значение w достаточно велико, чтобы во время поиска всегда было достаточно разрядов; но учет действительного значения w лишь уменьшит значение затрат.

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

Еще один подход к анализу trie-деревьев заключается в обобщении способа анализа BST-деревьев (см. лемму 12.6). Вероятность того, что к ключей начинаются с бита 0, а N — k ключей начинаются с бита 1, равна

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

Это рекуррентное соотношение похоже на рекуррентное соотношение для быстрой сортировки, которое было решено в лекция №7, но решить его значительно труднее. Как ни удивительно, решением является выражение для средних затрат на поиск, полученное на основании леммы 15.3, умноженное в точности на N (см. упражнение 15.26). Исследование самого рекуррентного соотношения позволяет понять, почему trie-деревья лучше сбалансированы, чем BST-деревья: вероятность того, что разбиение произойдет вблизи середины дерева, гораздо выше, чем для любого другого места. Поэтому это рекуррентное соотношение больше напоминает соотношение для сортировки слиянием (приблизительное решение которого равно NlgN), чем соотношение для быстрой сортировки (приблизительное решение 2NlgN).

Неприятное свойство trie-деревьев, также отличающее их от других рассмотренных типов деревьев поиска — однонаправленные пути для ключей с одинаковыми разрядами. Например, ключи, которые различаются только в последнем разряде, всегда требуют пути, длина которого равна длине ключа, независимо от количества ключей в дереве (см. рис. 15.8). Количество внутренних узлов может быть даже больше, чем количество ключей.

 Худший случай trie-дерева


Рис. 15.8.  Худший случай trie-дерева

На этих рисунках показан результат вставки ключей H = 01000 и I = 01001 в первоначально пустое trie-дерево. Как и в DST-дереве (см. рис. 15.4), длина пути ограничена длиной двоичного представления ключей; однако, как видно из этого примера, пути могут иметь такую длину даже при наличии в trie-дереве всего двух ключей.

Лемма 15.4. Trie-дерево, построенное из N случайных w-разрядных ключей, содержит в среднем около узлов.

Изменив рассуждения в лемме 15.3, можно записать выражение для среднего количества узлов в trie-дереве с N ключами (см. упражнение 15.27):

.

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

Полученные результаты можно проверить эмпирически. Например, на рис. 15.9 показано большое дерево, имеющее на 44% больше узлов, чем BST-дерево или DST-дерево, построенное из этого же множества ключей. Тем не менее, оно хорошо сбалансировано, и затраты на поиск в нем почти оптимальны.

 Пример trie-дерева


Рис. 15.9.  Пример trie-дерева

Это trie-дерево, построенное в результате вставки около 200 случайных ключей, хорошо сбалансировано, но из-за однонаправленного ветвления содержит на 44 процента больше узлов, чем было бы необходимо в ином случае. (Пустые ссылки в листьях не показаны.)

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

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

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

Для достаточно длинных ключей, состоящих из случайных разрядов, утверждения для среднего случая, приведенные в леммах 15.2 и 15.3, по-прежнему справедливы. В худшем случае высота trie-дерева по-прежнему ограничена количеством разрядов в самых длинных ключах. Эти затраты могут оказаться весьма существенными, если ключи имеют очень большую длину и, возможно, некоторое сходство, что вполне может быть в случае закодированных символьных данных. В следующих двух разделах рассматриваются методы снижения затрат в trie-деревьях с длинными ключами. Один из способов сокращения путей в trie-деревьях — свертывание однонаправленных ветвей в единые ссылки (изящный и эффективный метод выполнения этой задачи будет приведен в разделе 15.3). Другой способ уменьшения длин путей в trie-деревьях допускает существование более двух ссылок для каждого узла; этот подход является темой раздела 15.4.

Упражнения

15.11. Нарисуйте результат вставки элементов с ключами E A S Y Q U T I O N в указанном порядке в первоначально пустое trie-дерево.

15.12. Что происходит, если программа 15.3 применяется для вставки записи, ключ которой равен какому-либо ключу, уже присутствующему в trie-дереве?

15.13. Нарисуйте результат вставки элементов с ключами 01010011 00000111 00100001 01010001 11101100 00100001 10010101 01001010 в первоначально пустое trie-дерево.

15.14. Эмпирически сравните высоту, количество узлов и длину внутреннего пути trie-дерева, построенного вставками N случайных 32-разрядных ключей в первоначально пустое дерево, с этими же характеристиками стандартного BST-дерева и RB-дерева (лекция №13), построенных из тех же ключей, для N = 103, 104, 105 и 106 (см. упражнение 15.6).

15.15. Приведите полную характеристику длины внутреннего пути для худшего случая trie-дерева, содержащего N различных w-разрядных ключей.

15.16. Реализуйте операцию удалить для реализации таблицы символов на основе trie-дерева.

15.17. Реализуйте операцию выбрать для реализации таблицы символов на основе trie-дерева.

15.18. Реализуйте операцию сортировать для реализации таблицы символов на основе trie-дерева.

15.19. Напишите программу, которая выводит все ключи trie-дерева, имеющие те же начальные t разрядов, что и заданный ключ.

15.20. Воспользуйтесь конструкцией union языка C++ для реализации операций найти и вставить на основе trie-деревьев с не листовыми узлами, которые содержат ссылки, но не содержат элементы, и с листьями, которые содержат элементы, но не содержат ссылки.

15.21. Воспользуйтесь парой производных классов для реализации операций найти и вставить на основе trie-деревьев с не листовыми узлами, которые содержат ссылки, но не содержат элементы, и с листьями, которые содержат элементы, но не содержат ссылки.

15.22. Измените программы 15.3 и 15.2 так, чтобы ключ поиска хранился в машинном регистре и при спуске по trie-дереву на уровень вниз для выборки следующего разряда выполнялся сдвиг на один разряд.

15.23. Измените программы 15.3 и 15.2 так, чтобы они использовали таблицу из 2r trie-деревьев для фиксированной константы r. Первые r разрядов ключа должны использоваться для индексации в таблице, а по остальным разрядам ключа должны применяться стандартные алгоритмы доступа в trie-дереве. Это изменение позволяет сэкономить около r шагов, если только таблица не содержит большого количества пустых записей.

15.24. Какое значение r нужно выбрать в упражнении 15.23 при наличии N случайных ключей (которые достаточно длинны, чтобы их можно было считать различными)?

15.25. Напишите программу для вычисления количества узлов в trie-дереве, соответствующих данному множеству различных ключей фиксированной длины, с помощью их сортировки и сравнения соседних ключей в отсортированном списке.

15.26. Докажите по индукции, что — это решение рекуррентного соотношения наподобие быстрой сортировки, приведенного после леммы 15.3, для длины внешнего пути в случайном trie-дереве.

15.27. Получите выражение, приведенное в лемме 15.4 для среднего количества узлов в случайном trie-дереве.

15.28. Напишите программу для вычисления среднего количества узлов в случайном trie-дереве, состоящем из N узлов, и вывода этого значения с точностью до 10-3, для N = 103, 104, 105 и 106 .

15.29. Докажите, что высота trie-дерева, построенного из N случайных битовых строк, приблизительно равна 2 lgN. Совет: воспользуйтесь решением задачи о дне рождения (см. лемму 14.2).

15.30. Докажите, что средние затраты на поиск в DST-дереве, построенном из случайных ключей, асимптотически равны lgN (см. леммы 15.1 и 15.2).

15.31. Измените программы 15.2 и 15.3 так, чтобы они обрабатывали битовые строки переменной длины с единственным ограничением: в структуре данных не должны храниться записи с повторяющимися ключами. В частности, решите, какое значение возвращать при вызове bit(v, d) для случая, когда d больше длины v.

15.32. Воспользуйтесь trie-деревом для построения структуры данных, которая может поддерживать АТД таблицы существования для w-разрядных целых чисел. Программа должна поддерживать операции создать, вставить и найти при условии, что вставить и найти принимают целочисленные аргументы, а найти возвращает nullItem.key() при неудачном поиске и полученный аргумент в случае успешного поиска.

Patricia-деревья

Основанный на trie-деревьях поиск, который описан в разделе 15.2, обладает двумя недостатками. Во-первых, однонаправленные ветвления приводят к созданию дополнительных, но по сути необязательных, узлов в trie-дереве. Во-вторых, trie-деревья содержат два различных типа узлов, что усложняет алгоритмы (см. упражнения 15.20 и 15.21). В 1968 г. Моррисон (Morrison) нашел способ устранить обе эти проблемы с помощью применения метода, который он назвал patricia ( " practical algorithm to retrieve information coded in alphanumeric " — " практический алгоритм получения информации, закодированной алфавитно-цифровыми символами " ). Моррисон разработал свой алгоритм для приложений, индексирующих строки, наподобие рассмотренных в разделе 15.5, но он также эффективен и для реализации таблицы символов. Подобно DST-деревьям, patricia-деревья позволяют выполнять поиск N ключей в дереве, содержащем всего N узлов; подобно trie-деревьям, они требуют для одного поиска выполнения всего лишь около lgN сравнений разрядов и одного сравнения полного ключа, а также поддерживают другие операции АТД. Более того, эти характеристики производительности не зависят от длины ключей, и структура данных пригодна для ключей переменой длины.

Взяв структуру данных стандартного trie-дерева, мы устраняем однонаправленные пути с помощью простого приема: в каждый узел помещается индекс разряда, который должен проверяться для выбора пути из этого узла. Таким образом, мы сразу переходим к разряду, в котором должно приниматься важное решение, пропуская сравнения разрядов в узлах, в которых все ключи в поддереве имеют одинаковые разряды. А внешние узлы исключаются при помощи еще одного простого приема: данные хранятся во внутренних узлах, а ссылки на внешние узлы заменяются ссылками, которые указывают в обратном направлении вверх на нужный внутренний узел в trie-дереве. Эти два изменения позволяют представлять trie-деревья как бинарные деревья, состоящие из узлов с ключом и двумя ссылками (а также дополнительным полем под индекс); такие деревья называются patricia-деревьями (patricia trie). В patricia-деревьях ключи хранятся в узлах, как в DST-деревьях, а обход дерева выполняется в соответствии с разрядами искомого ключа, но ключи в узлах не используются для управления поиском при спуске вниз по дереву. Они хранятся там просто для возможного обращения к ним впоследствии, при достижении нижней части дерева.

Как было отмечено в предыдущем абзаце, понять работу алгоритма проще, если сначала заметить, что стандартные trie-деревья и patricia-деревья можно считать различными представлениями одной и той же абстрактной структуры trie-дерева. Например, trie-деревья, показанные на рис. 15.10 и вверху на рис. 15.11, где показаны поиск и вставка в patricia-деревьях, представляют ту же абстрактную структуру, что и trie-деревья на рис. 15.6. В алгоритмах поиска и вставки для patricia-деревьев используется, создается и поддерживается конкретное представление абстрактной структуры данных trie-дерева, которое отличается от используемого в алгоритмах поиска и вставки из раздела 15.2; но лежащая в их основе абстракция остается той же самой.

Программа 15.4 является реализацией алгоритма поиска в patricia-дереве. Используемый в ней метод отличается от поиска в trie-дереве тремя аспектами: нет явных пустых ссылок, в ключе проверяется не следующий разряд, а указанный, и поиск завершается сравнением ключа в точке, где происходит переход вверх по дереву. Указывает ли ссылка вверх, проверить легко, т.к. индексы разрядов в узлах (по определению) увеличиваются по мере перемещения вниз по дереву. Поиск начинается с корня и проходит вниз по дереву, используя в каждом узле индекс разряда для определения проверяемого разряда в искомом ключе — если этот разряд равен 1, выполняется переход вправо, а если 0 — влево.

 Поиск в patricia-дереве


Рис. 15.10.  Поиск в patricia-дереве

При успешном поиске ключа R = 10010 в этом patricia-дереве выполняется переход вправо (поскольку нулевой бит равен 1), затем влево (поскольку бит 4 равен 0), что приводит к ключу R (единственному ключу в дереве, начинающемуся с последовательности 1***0).

При спуске по дереву выполняется проверка только тех разрядов ключа, которые указаны цифрами над узлами (ключи в узлах игнорируются).

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

При неудачном поиске ключа I = 01001 выполняется переход влево от корня (поскольку нулевой бит равен 0), затем по правой (направленной вверх) ссылке (поскольку первый бит равен 1), и выясняется, что ключ H (единственный ключ в дереве, начинающийся с последовательности 01) не равен I .

Программа 15.4. Поиск в patricia-дереве

Рекурсивная функция searchR возвращает уникальный узел, который может содержать запись с ключом v. Она спускается вниз по trie-дереву, используя биты дерева для управления поиском, но в каждом встреченном узле проверяет только один бит — указанный в поле bit. Функция прерывает поиск, встретив внешнюю ссылку, указывающую вверх. Функция поиска search вызывает функцию searchR, а затем проверяет ключ в этом узле для определения того, был ли поиск успешным или неудачным.

  private:
    Item searchR(link h, Key v, int d)
      { if (h->bit <= d) return h->item;
        if (digit(v, h->bit) == 0)
          return searchR(h->l, v, h->bit);
        else
        return searchR(h->r, v, h->bit);
      }
  public:
    Item search(Key v)
      { Item t = searchR(head, v, -1);
        return (v == t.key()) ? t : nullItem;
      }
       

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

На рис. 15.10 показан поиск в patricia-дереве. Если в trie-дереве поиск завершается неудачей тогда, когда он обнаруживает пустую ссылку, то соответствующий поиск в patricia-дереве следует несколько иным путем, нежели поиск в стандартном trie-дереве, т.к. при спуске по дереву разряды, соответствующие однонаправленным путям, вообще не проверяются. Поиск в trie-дереве завершается в листе, а поиск в patricia-дереве завершается сравнением с тем же ключом, что и при поиске в trie-дереве, но без проверки разрядов, соответствующих однонаправленному пути в trie-дереве.

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

 Вставка в patricia-дереве


Рис. 15.11.  Вставка в patricia-дереве

Чтобы вставить ключ I в приведенное на рис. 15.10patricia-дерево, мы добавляем новый узел для проверки бита 4, поскольку ключи H = 01000 и I = 01001 отличаются только этим разрядом (вверху). В последующих поисках в trie-дереве, которые дойдут до нового узла, необходимо проверить ключ H (левая ссылка), если 4 разряд ключа поиска равен 0; а если этот разряд равен 1 (правая ссылка), то следует проверить ключ I. Для вставки ключа N = 01110 (внизу) между ключами H и I добавляется новый узел для проверки бита 2, поскольку именно этот бит отличает N от H и I.

Поиск в patricia-деревьях всегда завершается сравнением ключа, и этот ключ содержит всю нужную информацию. Мы находим самый левый разряд, которым отличаются искомый ключ и ключ, прервавший поиск, затем снова выполняем поиск в дереве, сравнивая позицию этого разряда с позициями разрядов в узлах на пути поиска. Если попадется узел, задающий более старший разряд, чем тот, которым различаются искомый и найденный ключи, то это говорит о пропуске во время поиска в particia-дереве разряда, который должен был бы привести к пустой ссылке при аналогичном поиске в trie-дереве — поэтому необходимо добавить новый узел, который обеспечит проверку этого разряда. Если не удается отыскать узел, задающий более старший разряд, чем тот, которым различаются искомый и найденный ключи, значит, поиск в patricia-дереве соответствует поиску в trie-дереве, завершившемуся в листе. В таком случае добавляется новый узел, который различает искомый ключ и ключ, прервавший поиск. Всегда добавляется только один узел, задающий самый левый разряд, которым отличаются ключи, в то время как при использовании стандартного trie-дерева для достижения этого разряда могло бы потребоваться добавление нескольких узлов, формирующих однонаправленный путь. Помимо функции различения разрядов, новый узел будет использоваться также и для хранения нового элемента. Пример начальных этапов построения trie-дерева показан на рис. 15.12.

 Построение patricia-дерева


Рис. 15.12.  Построение patricia-дерева

Эта последовательность рисунков отражает результат вставки ключей A S E R C H в первоначально пустое patricia-дерево. На рис. 15.11 показан результат вставки ключей I и N в дерево, показанное на нижнем рисунке.

Программа 15.5 является реализацией алгоритма вставки в patricia-дерево. Ее код вытекает непосредственно из описания, приведенного в предыдущем абзаце, с одним дополнением: мы считаем, что ссылки на узлы, содержащие индексы разрядов, не большие, чем индекс текущего разряда — это ссылки на внешние узлы. Код вставки просто проверяет это свойство ссылок, но он не должен перемещать ключи или ссылки. На первый взгляд направленные вверх ссылки в patricia-деревьях выглядят загадочно, но выбор ссылок, которые должны использоваться при вставке каждого узла, удивительно прост. А использование одного типа узла вместо двух существенно упрощает код.

По построению все внешние узлы, расположенные ниже узла с индексом к, начинаются с тех же самых к разрядов (иначе для различения этих узлов понадобился бы узел с индексом, меньшим к). Следовательно, patricia-дерево можно преобразовать в стандартное trie-дерево, создав соответствующие внутренние узлы между узлами, в которых были пропущены разряды, и заменив ссылки вверх на ссылки, указывающие на внешние узлы (см. упражнение 15.48). Однако свойство 15.2 выполняется для patricia-деревьев не полностью, т.к. присваивание ключей внутренним узлам зависит от порядка вставки ключей. Структура внутренних узлов зависит от порядка вставки ключей, а внешние ссылки и размещение значений ключей — нет.

Программа 15.5. Вставка в patricia-дерево

Процесс вставки ключа в patricia-дерево начинается с поиска. Функция searchR из программы 15.5 приводит к уникальному ключу в дереве, который должен отличаться от вставляемого. Мы находим самый левый бит, которым отличаются этот и искомый ключи, а затем при помощи рекурсивной функции insertR спускаемся вниз по дереву и вставляем новый узел, содержащий v в этой позиции.

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

private:
  link insertR(link h, Item x, int d, link p)
    { Key v = x.key();
      if ((h->bit >= d) || (h->bit <= p->bit))
        { link t = new node(x); t->bit = d;
          t->l = (digit(v, t->bit) ? h : t);
          t->r = (digit(v, t->bit) ? t : h);
          return t;
        }
      if (digit(v, h->bit) == 0)
        h->l = insertR(h->l, x, d, h);
      else
      h->r = insertR(h->r, x, d, h);
      return h;
    }
public:
  void insert(Item x)
    { Key v = x.key(); int i;
      Key w = searchR(head->l, v, -1).key();
      if (v == w) return;
      for (i = 0; digit(v, i) == digit(w, i); i++) ;
      head->l = insertR(head->l, x, i, head);
    }
  ST(int maxN)
    { head = new node(nullItem);
      head->l = head->r = head;
    }
      

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

Patricia-деревья — наиболее показательный вариант метода поразрядного поиска: он позволяет находить разряды, которые различают ключи поиска, и встраивать их в структуру данных (без лишних узлов), быстро приводящую от любого искомого ключа к единственному ключу в структуре, который может быть равен искомому. На рис. 15.13 показано patricia-дерево, образованное теми же ключами, что и для построения trie-дерева на рис. 15.9: patricia-дерево не только содержит на 44% узлов меньше по сравнению со стандартным trie-деревом, но и почти идеально сбалансировано.

 Пример patriciu-дерева


Рис. 15.13.  Пример patriciu-дерева

Это patricia-дерево, построенное в результате вставки примерно 200 случайных ключей, эквивалентно trie-дереву, приведенному на рис. 15.9, в котором удалены однонаправленные пути. Результирующее дерево почти идеально сбалансировано.

Программа 15.6. Сортировка в patricia-дереве

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

  private:
    void showR(link h, ostream& os, int d)
      { if (h->bit <= d) { h->item.show(os); return; }
        showR(h->l, os, h->bit);
        showR(h->r, os, h->bit);
      }
  public:
    void show(ostream& os)
      { showR(head->l, os, -1); }
      

Лемма 15.5. Вставка или поиск случайного ключа в patricia-дереве, построенном из N случайных битовых строк, требует приблизительно lgN битовых сравнений в среднем и приблизительно 2 lgN битовых сравнений в худшем случае. Количество битовых сравнений никогда не превышает длины ключа.

Эта лемма непосредственно следует из леммы 15.3, поскольку длина путей в patricia-деревьях не превышает длину путей в соответствующих trie-деревьях. Точный анализ среднего случая в patricia-дереве сложен; из него следует, что в среднем в patricia-дереве требуется на одно сравнение меньше, чем в стандартном trie-дереве (см. раздел ссылок).

В таблица 15.1 приведены экспериментальные данные, подтверждающие вывод, что в случае целочисленных ключей DST-деревья, стандартные trie-деревья и patricia-деревья имеют сравнимую производительность (а также обеспечивают время поиска, которое сравнимо или меньше времени поиска методами на основе сбалансированных деревьев из лекция №13. Поэтому данные методы, несомненно, следует рассматривать в качестве возможных реализаций таблиц символов даже при использовании ключей, которые представимы в виде коротких битовых строк, с учетом ряда упомянутых очевидных компромиссов.

Таблица 15.1. Экспериментальное сравнение реализаций trie-деревьев
NСозданиеУспешный поиск
BDTPBDTP
125011110110
250022431121
500045773232
12500181520188797
250004036444120172017
500008180999043414736
1000001761672692421038510192
200000411360544448228179211182
Обозначения:
BRB-дерево бинарного поиска (программы 12.8 и 13.6)
DDST-дерево (программа 15.1)
Ttrie-дерево (программы 15.2 и 15.3)
Ppatricia-дерево (программы 15.4 и 15.5)

Эти сравнительные значения времени построения и поиска в таблицах символов, содержащих случайные последовательности 32-разрядных целых чисел, подтверждают, что поразрядные методы могут конкурировать с методами, использующими сбалансированные деревья, даже в случае ключей, состоящих из случайных битов. Различия в производительности более заметны, если ключи являются длинными и не обязательно случайными (см. таблица 15.2), или когда возможен эффективный доступ к разрядам ключа (см. упражнение 15.22).

Обратите внимание, что затраты на поиск, приведенные в лемме 15.5, не возрастают с увеличением длины ключа. И напротив, затраты на поиск в стандартном trie-дереве, как правило, зависят от длины ключей: позиция первого разряда, которым различаются два заданных ключа, может находиться сколь угодно далеко. Все рассмотренные ранее методы поиска, основанные на сравнениях, также зависят от длины ключа: даже если два ключа различаются только самым правым разрядом, для их сравнения требуется время, пропорциональное длине ключей. А в методах хеширования всегда требуется время, пропорциональное длине ключа — на вычисление хеш-функции. Однако patricia-деревья сразу обращаются к значимым разрядам и обычно проверяют менее lgN из них. В связи с этим patricia-метод (или поиск по trie-дереву со свернутыми однонаправленными путями) является рекомендуемым методом поиска при наличии длинных ключей.

Например, предположим, что используется компьютер, обеспечивающий эффективный доступ к 8-разрядным байтам данных, и требуется выполнять поиск среди миллионов 1000-разрядных ключей. В этом случае patricia-методу для выполнения поиска будет нужен доступ лишь приблизительно к 20 байтам искомого ключа плюс одна 125-байтовая операция проверки на равенство. А при использовании хеширования потребовался бы доступ ко всем 125 байтам искомого ключа для вычисления хеш-функции (плюс несколько проверок на равенство), а методы, основанные на сравнениях, потребовали бы от 20 до 30 полных сравнений ключей. Конечно, сравнения ключей, особенно на ранних этапах поиска, требуют проверки всего нескольких байтов, но на последующих этапах, как правило, необходимо сравнение значительно большего количества байтов. В разделе 15.5 мы снова сравним производительность различных методов поиска для длинных ключей.

Patricia-ялгоритм не накладывает какие-либо ограничения на длину искомых ключей. Этот алгоритм особенно эффективен в приложениях с потенциально очень длинными ключами переменной длины, наподобие рассматриваемых в разделе 15.5. При использовании patricia-деревьев обычно можно надеяться, что количество проверок разрядов, необходимых для поиска среди N записей, даже с очень длинными ключами, будет приблизительно пропорционально lgN.

Упражнения

15.33. Что происходит при использовании программы 15.5 для вставки записи, ключ которой равен какому-либо ключу, уже присутствующему в patricia-дереве?

15.34. Нарисуйте patricia-дерево, образованное вставками ключей E A S Y Q U T I O N в указанном порядке в первоначально пустое дерево.

15.35. Нарисуйте patricia-дерево, образованное вставками ключей 01010011 00000111 00100001 01010001 11101100 00100001 10010101 01001010 в указанном порядке в первоначально пустое дерево.

15.36. Нарисуйте patricia-дерево, образованное вставками ключей 01001010 10010101 00100001 11101100 01010001 00100001 00000111 01010011 в указанном порядке в первоначально пустое дерево.

15.37. Экспериментально сравните высоту и длину внутреннего пути patricia-дерева, построенного вставками N случайных 32-разрядных ключей в первоначально пустое дерево, с этими же характеристиками стандартного BST-дерева и RB-дерева (лекция №13), построенных из этих же ключей, для N = 103, 104, 105 и 106 (см. упражнения 15.6 и 15.14).

15.38. Приведите полную характеристику длины внутреннего пути для худшего случая patricia-дерева, содержащего N различных w-разрядных ключей.

15.39. Реализуйте операцию выбрать для таблицы символов на основе patricia-дерева.

15.40. Реализуйте операцию удалить для таблицы символов на основе patricia-дерева.

15.41. Реализуйте операцию объединить для таблицы символов на основе patricia-дерева. о 15.42. Напишите программу, которая выводит все ключи patricia-дерева, имеющие те же начальные t разрядов, что и заданный ключ.

15.43. Измените стандартные поиск и вставку в trie-дереве (программы 15.2 и 15.3), чтобы исключить однонаправленные пути, как в patricia-деревьях. Если вы выполнили упражнение 15.20, начните с полученной в нем программы.

15.44. Измените поиск и вставку в patricia-дереве (программы 15.4 и 15.5), чтобы использовать таблицу, содержащую 2r trie-деревьев, как описано в упражнении 15.23.

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

15.46. Измените программу поиска в patricia-дереве (программа 15.4), чтобы она сравнивала ключи при спуске вниз по дереву, для повышения производительности успешного поиска. Экспериментально оцените эффективность этого изменения (см. упражнение 15.45).

15.47. Воспользуйтесь patricia-деревом для построения структуры данных, которая может поддерживать АТД таблицы существования для w-разрядных двоичных целых чисел (см. упражнение 15.32).

15.48. Напишите программу, которая преобразует patricia-дерево в стандартное trie-дерево с теми же ключами, и наоборот.

Многопутевые trie-деревья и TST-деревья

Мы уже видели, что производительность поразрядной сортировки можно существенно увеличить, рассматривая одновременно более чем один разряд. То же самое справедливо и в отношении поразрядного поиска: сравнивая одновременно по r разрядов, скорость поиска можно увеличить в r раз. Однако здесь есть скрытая опасность, из-за которой эту идею следует применять более осторожно, чем в случае поразрядной сортировки. Проблема заключается в том, что одновременное сравнение r разрядов соответствует использованию узлов дерева с R = 2r ссылками, а это может привести к значительным излишним затратам памяти на неиспользуемые ссылки.

В (бинарных) trie-деревьях, описанных в разделе 15.2, узлы, соответствующие разрядам ключей, имеют две ссылки: одну для нулевого разряда ключа, и вторую — для единичного. Естественно обобщить их до R-путевых trie-деревьев, в которых цифрам ключа соответствуют узлы с R ссылками, по одной для каждого возможного значения цифры. Ключи хранятся в листьях (узлах со всеми пустыми ссылками). Поиск в R-путевом trie-дереве начинается с корня и с самой левой цифры ключа, и цифры ключа используются для управления спуском по дереву. Если значение цифры равно i, выполняется переход по i-ой ссылке (и на следующую цифру). Если обнаружен лист, он содержит единственный ключ в trie-дереве, ведущие цифры которого соответствуют пройденному пути, поэтому для определения того, успешно или неудачно завершился поиск, остается сравнить этот ключ с искомым. При достижении пустой ссылки понятно, что поиск неудачен, поскольку эта ссылка соответствует последовательности ведущих цифр, не найденной ни в одном ключе trie-дерева. На рис. 15.14 показано 10-путевое trie-дерево, представляющее некоторое множество десятичных чисел.

 R-путевое trie-дерево для десятичных чисел


Рис. 15.14.  R-путевое trie-дерево для десятичных чисел

На этом рисунке показано trie-дерево, которое позволяет различать набор чисел (см. рис. 12.1). Каждый узел имеет 10 ссылок (по одной для каждой возможной цифры). Ссылка 0 в корне указывает на trie-дерево для ключей, первая цифра которых равна 0 (есть только одно такое число); ссылка 1 указывает на trie-дерево для ключей с первой цифрой 1 (таких деревьев два) и т.д. Ни одно из этих чисел не начинается с цифр 4, 7, 8 или 9, поэтому соответствующие ссылки остаются пустыми. В дереве присутствует только по одному числу, первая цифра которого равна 0, 2 и 5, поэтому для каждой из этих цифр имеется лист, содержащий соответствующее число. Остальная часть структуры построена рекурсивно, переходя каждый раз на одну цифру вправо.

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

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

Определение 15.2. Trie-дерево существования, соответствующее множеству ключей, рекурсивно определяется следующим образом: trie-дерево для пустого множества ключей — это пустая ссылка; а trie-дерево для непустого множества ключей — это внутренний узел со ссылками, указывающими на trie-дерево для каждой возможной цифры ключа, причем для построения поддеревьев ведущая цифра удаляется.

Для простоты в этом определении предполагается, что ни один ключ не является префиксом другого. Обычно это ограничение достигается при условии, что все ключи различны и либо имеют фиксированную длину, либо содержат завершающий символ со значением NULLdigit — сигнальный символ, который не используется ни для каких других целей. Суть данного определения в том, что таблицы существования можно реализовать с помощью trie-деревьев существования, не храня внутри trie-дерева никакой информации. Вся информация неявно определяется структурой trie-дерева. Каждый узел содержит R + 1 ссылку (по одной для каждой возможной цифры плюс одна ссылка для NULLdigit) и не содержит никакой другой информации. Для управления спуском по trie-дереву во время поиска используются цифры ключа. Если ссылка на NULLdigit встретилась одновременно с завершением цифр ключа, то поиск успешен, иначе — неудачен. Для вставки нового ключа поиск выполняется до тех пор, пока не встретится пустая ссылка, а затем добавляются узлы для каждого из оставшихся символов ключа. На рис. 15.15 показан пример 27-путевого trie-дерева; программа 15.7 содержит реализацию базовых процедур поиска и вставки в (многопутевом) trie-дереве существования.

Если ключи имеют фиксированную длину и различны, можно обойтись без конечного символа и прекращать поиск при достижении конца ключа (см. упражнение 15.55). Мы уже встречались с примером подобного trie-дерева, когда использовали trie-деревья для описания MSD-сортировки ключей фиксированной длины (см. рис. 10.10).

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

Определение 15.3. Многопутевое trie-дерево — это многопутевое дерево, которое имеет связанные с каждым из его листьев ключи и рекурсивно определяется следующим образом: trie-дерево для пустого множества ключей представляет собой пустую ссылку; trie-дерево для единственного ключа — это лист, содержащий этот ключ; и trie-дерево для множества ключей мощностью более 1 — это внутренний узел со ссылками на trie-деревья для ключей с каждым из возможных значений цифр, причем для построения поддеревьев ведущая цифра ключа удаляется.

Предполагается, что ключи в структуре данных различны, и ни один ключ не является префиксом другого. При выполнении поиска в стандартном многопутевом trie-дереве цифры ключа используются для управления поиском при спуске по дереву, при этом возможны три варианта. Если достигнута пустая ссылка, значит, поиск неудачен; если достигнут лист, содержащий ключ поиска, то поиск успешен; и если достигнут лист, содержащий другой ключ — поиск неудачен. Все листья имеют R пустых ссылок, поэтому, как было сказано в разделе 15.2, узлы-листья и не листовые узлы удобно представить по-разному. Такая реализация будет рассмотрена в лекция №16, а в этой главе предлагается другой подход. В любом случае можно обобщить аналитические результаты из раздела 15.3 и получить представление о характеристиках производительности стандартных многопутевых деревьев.

 Поиск и вставка в R-путевом trie-дереве существования


Рис. 15.15.  Поиск и вставка в R-путевом trie-дереве существования

26-путевое trie-дерево для слов now, is и the (вверху) имеет девять узлов: корень плюс по одному узлу для каждой буквы. Здесь узлы помечены буквами, но в этой структуре данных не нужны явные метки узлов, поскольку метка каждого узла может быть получена, исходя из позиции его ссылки в массиве ссылок родительского узла. При вставке ключа time в существующем узле для буквы t создается новая ветка, и добавляются новые узлы для букв i, m и e (в центре); для вставки ключа for создается новая ветка от корня, и добавляются новые узлы для букв f, o и r.

Лемма 15.6.Для выполнения поиска или вставки в стандартном R-арном trie-дереве, построенном из N случайных строк байтов, в среднем требуется выполнение около logRN сравнений байтов. Количество ссылок в R-арном trie-дереве, построенном из N случайных ключей, приблизительно равно R N/ lnR. Количество сравнений байтов, необходимое для выполнения поиска или сравнения, не превышает количества байтов в искомом ключе.

Эти результаты обобщают леммы 15.3 и 15.4. Их можно получить, подставив в доказательствах этих свойств R вместо 2. Однако, как уже упоминалось, для выполнения точного математического анализа требуются исключительно сложные математические выкладки.

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

Программа 15.7. Поиск и вставка в R-путевом trie-дереве существования

В данной реализации операций найти и вставить АТД таблицы существования для многопутевых trie-деревьев ключи хранятся неявно внутри структуры trie-дерева. Каждый узел содержит R указателей на следующий, более низкий уровень trie-дерева. Если t-я цифра ключа равна i, происходит переход на уровне t по i-ой ссылке. Функция поиска возвращает фиктивный элемент, содержащий переданный в аргументе ключ, если он присутствует в таблице, или nullItem в противном случае. В качестве альтернативы можно было бы изменить интерфейс, чтобы в нем использовался только тип Key, или в созданном классе элементов реализовать преобразование типа из Item в Key.

  private:
    struct node
    { node **next;
      node()
        { next = new node*[R];
          for (int i = 0; i < R; i++) next[i] = 0;
        }
    };
  typedef node *link;
  link head;
  Item searchR(link h, Key v, int d)
    { int i = digit(v, d);
      if (h == 0) return nullItem;
      if (i == NULLdigit)
        { Item dummy(v); return dummy; }
      return searchR(h->next[i], v, d+1);
    }
    void insertR(link& h, Item x, int d)
      { int i = digit(x.key(), d);
        if (h == 0) h = new node;
        if (i == NULLdigit) return;
        insertR(h->next[i], x, d+1);
      }
    public:
      ST(int maxN)
      { head = 0; }
    Item search(Key v)
      { return searchR(head, v, 0); }
    void insert(Item x)
      { insertR(head, x, 0); }
      

Предположим, например, что используется типичное значение R = 256 и имеется N случайных 64-разрядных ключей. В соответствии с леммой 15.6 для выполнения поиска потребуется lgN / 8 сравнений символов (максимум 8), и при этом будет задействовано менее 47 N ссылок. Если объем доступной памяти не ограничен, этот метод является весьма эффективной альтернативой. А взяв в этом примере R = 65536, можно сократить затраты на выполнение поиска до 4 сравнений символов, однако при этом потребуется более 5900 N ссылок.

Мы вернемся к стандартным многопутевым деревьям в разделе 15.5. Далее до конца этого раздела рассматривается альтернативное представление trie-деревьев, построенных программой 15.7: trie-дерево тернарного поиска (ternary search trie — TST), или просто TST-дерево, полная форма которого показана на рис. 15.16.

 Структуры trie-деревьев существования


Рис. 15.16.  Структуры trie-деревьев существования

На этих рисунках показаны три различных реализации trie-дерева существования для 16 слов call me ishmael some years ago never mind how long precisely having little or no money: 26-путевое trie-дерево существования (вверху), абстрактное trie-дерево с удаленными пустыми ссылками (в центре) и представление TST-деревом (внизу). 26-путевое trie-дерево содержит слишком много ссылок, но TST-дерево служит эффективным представлением абстрактного trie-дерева.

В двух верхних trie-деревьях предполагается, что ни один ключ не является префиксом другого. Например, добавление ключа not привело бы к потере ключа no. Для решения этой проблемы в конец каждого ключа можно добавить нулевой символ, как показано в TST-дереве на нижнем рисунке.

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

Этот подход эквивалентен реализации узлов trie-дерева в виде BST-деревьев, в которых в качестве ключей используются символы, соответствующие непустым ссылкам. В стандартных trie-деревьях существования из программы 15.7 узлы trie-дерева представляются R + 1 ссылками, и символы, представленные каждой непустой ссылкой, определяются их индексами. В соответствующем TST-дереве существования все символы, соответствующие непустым ссылкам, явно присутствуют в узлах: мы находим символы, соответствующие ключам, только проходя по средним ссылкам.

Алгоритм поиска для реализации АТД таблицы существования на основе TST-деревьев настолько прост, что его нетрудно написать самостоятельно. Алгоритм вставки несколько сложнее, но в точности соответствует вставке в trie-деревьях существования. В начале поиска первый символ ключа сравнивается с символом в корне. Если он меньше, поиск продолжается по левой ссылке, если больше — по правой, а если равен, поиск проходит по средней ссылке, и выполняется переход к следующему символу ключа. В любом случае алгоритм продолжается рекурсивно. Поиск завершается неудачно, если встретилась пустая ссылка или ключ поиска закончился раньше, чем в дереве встретился символ NULLdigit. Поиск завершается успешно, если происходит переход по средней ссылке с символом NULLdigit. Для вставки нового ключа выполняется поиск, а затем добавляются новые узлы для символов в заключительной части ключа — точно так же, как и в trie-деревьях. Подробности реализации этих алгоритмов приведены в программе 15.8, а на рис. 15.17 показаны TST-деревья, соответствующие trie-деревьям на рис. 15.15.

 TST-деревья существования


Рис. 15.17.  TST-деревья существования

TST-дерево существования содержит по одному узлу для каждой буквы, но каждый узел имеет только 3 дочерних узла, а не 26. Деревья на трех верхних рисунках — это TST-деревья, соответствующие примеру вставки на рис. 15.15 рис. 15.15, за исключением того, что к каждому ключу дописан завершающий символ. Это позволяет снять ограничение, что ни один ключ не может быть префиксом другого. Теперь можно, например, вставить ключ theory (рисунок внизу).

Продолжая использовать соответствие между деревьями поиска и алгоритмами сортировки, мы видим, что TST-деревья соответствуют трехпутевой поразрядной сортировке, так же, как BST-деревья соответствуют быстрой сортировке, trie-деревья — бинарной быстрой сортировке, а М-путевые trie-деревья — М-путевой поразрядной сортировке. Структура рекурсивных вызовов для трехпутевой поразрядной сортировки, показанная на рис. 10.12, представляет собой TST-дерево для этого набора ключей. Проблема пустых ссылок, присущая trie-деревьям, соответствует проблеме пустых контейнеров поразрядной сортировки; трехпутевое ветвление обеспечивает эффективное решение обеих этих проблем.

Программа 15.8. Поиск и вставка в TST-дереве существования

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

  private:
    struct node
    { Item item; int d; node *l, *m, *r;
      node(int k)
        { d = k; l = 0; m = 0; r = 0; }
    };
  typedef node *link;
  link head;
  Item nullItem;
  Item searchR(link h, Key v, int d)
    { int i = digit(v, d);
      if (h == 0) return nullItem;
      if (i == NULLdigit)
        { Item dummy(v); return dummy; }
      if (i < h->d) return searchR(h->l, v, d);
      if (i == h->d) return searchR(h->m, v, d+1);
      if (i > h->d) return searchR(h->r, v, d);
    }
    void insertR(link& h, Item x, int d)
      { int i = digit(x.key(), d);
        if (h == 0) h = new node(i);
        if (i == NULLdigit) return;
        if (i < h->d) insertR(h->l, x, d);
        if (i == h->d) insertR(h->m, x, d+1);
        if (i > h->d) insertR(h->r, x, d);
      }
    public:
      ST(int maxN)
      { head = 0; }
    Item search(Key v)
      { return searchR(head, v, 0); }
    void insert(Item x)
      { insertR(head, x, 0); }
      

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

Лемма 15.7. Для выполнения поиска или вставки в полное TST-дерево требуется время, пропорциональное длине ключа. Количество ссылок в TST-дереве не превышает утроенного количества символов во всех ключах.

В худшем случае каждый символ ключа соответствует полному несбалансированному R-арному узлу, вытянутому в виде односвязного списка. Вероятность возникновения этого худшего случая в случайном дереве крайне мала. Скорее можно ожидать выполнения 1nR или менее сравнений на первом уровне (поскольку корневой узел ведет себя подобно BST-дереву, состоящему из R различных значений байтов) и, возможно, на нескольких других уровнях (если существуют ключи с общим префиксом и содержащие до R различных значений байтов в символе, следующем за префиксом). Для большинства же символов нужно будет выполнять лишь несколько сравнений байтов (поскольку большинство узлов trie-дерева содержат мало непустых ссылок). Для неудачного поиска, вероятнее всего, потребуется лишь несколько сравнений байтов, завершающихся на пустой ссылке уже на одном из верхних уровней дерева. Для успешного поиска потребуется приблизительно по одному сравнению байта на каждый символ ключа поиска, поскольку большинство из них расположено в узлах однонаправленных путей в нижней части trie-дерева.

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

 Пример строковых ключей (номеров вызовов библиотечных функций)


Рис. 15.18.  Пример строковых ключей (номеров вызовов библиотечных функций)

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

Главное достоинство TST-деревьев заключается в том, что они аккуратно приспосабливаются к неоднородностям в ключах, которые весьма вероятны в реальных приложениях. Это проявляется в виде двух основных эффектов. Во-первых, ключи в реальных приложениях берутся из больших символьных наборов, а использование конкретных символов из набора далеко от однородного — например, в конкретном наборе строк, скорее всего, будет использоваться лишь небольшая часть возможных символов. Используя TST-деревья, можно применять 256-символьную ASCII-кодировку или даже 65536-символьный Unicode, не беспокоясь о лишних затратах в узлах с 256- или 65536-путевым ветвлением и не задумываясь, какие наборы символов действительно применяются. Unicode-строки символов алфавитов, отличных от латинского, могут содержать тысячи символов — TST-деревья особенно подходят для строковых ключей, состоящих из таких символов. Во-вторых, в реальных приложениях ключи часто имеют структурированный формат, различный в разных приложениях, когда в одной части ключа используются только буквы, в другой — только цифры, а в качестве разделителей используются специальные символы (см. упражнение 15.72). Например, на рис. 15.18 приведен список номеров вызовов онлайновой библиотечной базы данных. В случае таких ключей некоторые из узлов trie-дерева могут быть представлены унарными узлами в TST-дереве (там, где все ключи содержат разделители), другие могут быть представлены BST-деревьями, состоящими из 10 узлов (там, где все ключи содержат цифры), а третьи — BST-деревьями из 26 узлов (там, где все ключи содержат буквы). Эта структура создается автоматически, без какого-либо специального анализа ключей.

Второе практическое достоинство поиска, основанного на TST-деревьях, по сравнению с множеством других алгоритмов заключается в том, что неудачные поиски обычно исключительно эффективны даже при длинных ключах. Часто для завершения неудачного поиска алгоритм использует лишь несколько сравнений байтов (и проходит по нескольким ссылкам). Как было показано в разделе 15.3, для неудачного поиска в хеш-таблице, содержащей N ключей, требуется время, пропорциональное длине ключа (для вычисления хеш-функции), а в дереве поиска требуется не менее lgN сравнений ключей. Даже в patricia-дереве для неудачного поиска случайного ключа требуетсяlgN сравнений разрядов.

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

Таблица 15.2. Экспериментальное сравнение поиска строковых ключей
NСозданиеНеудачный поиск
BHTT*BHTT*
125044552221
2500871095532
50001916212010864
125004848549729271514
250001189918815667593630
500002301913332551371137065
Обозначения:
BСтандартное BST-дерево (программа 12.8)
HХеширование с цепочками переполнения (M = N/5) (программа 14.3)
TTST-дерево (программа 15.8)
T*TST-дерево с R 2-путевым ветвлением в корне (программы 15.11 и 15.12)

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

Третья причина привлекательности TST-деревьев заключается в том, что они поддерживают более общие операции, чем рассмотренные операции таблиц символов. Например, в программе 15.9 можно не указать отдельные символы ключа поиска, и она выведет все ключи в структуре данных, которые соответствуют указанным цифрам искомого ключа. Пример такого поиска показан на рис. 15.19. Очевидно, что, внеся небольшие изменения, эту программу можно приспособить для перебора всех соответствующих ключей (как для операции сортировать), а не просто для их вывода (см. упражнение 15.58).

TST-деревья позволяют легко решать еще несколько аналогичных задач. Например, можно посетить все ключи в структуре данных, которые отличаются от ключа поиска не более чем одним цифровым символом (см. упражнение 15.59). В других реализациях таблиц символов подобные операции требуют больших затрат или вовсе невозможны. Эти и многие другие задачи обнаружения нестрогого соответствия со строкой поиска будут рассмотрены в части 5.

Patricia-деревья предоставляют несколько аналогичных преимуществ; основное практическое преимущество TST-деревьев по сравнению с patricia-деревьями заключается в том, что они обеспечивают доступ к байтам или символам, а не к разрядам ключей. Одна из причин, почему это различие считается преимуществом, связана с тем, что предназначенные для этого машинные операции реализованы во многих компьютерах, а C++ обеспечивает непосредственный доступ к байтам символьных строк в стиле C. Другая причина состоит в том, что в некоторых приложениях работа с байтами или символами в структуре данных естественным образом соответствует байтовой структуре самих данных — например, в задаче поиска частичного соответствия, описанной в предыдущем абзаце (хотя, как будет показано в лекция №18, поиск частичного соответствия можно ускорить и с помощью продуманного использования доступа к разрядам).

Для устранения однонаправленных путей в TST-деревьях заметим, что большинство однонаправленных путей соответствует концам ключей, что несущественно, если применяется реализация таблицы символов, в которой записи хранятся в листьях, помещенных на самом верхнем уровне дерева различения ключей. Можно также использовать индексацию байтов, как в patricia-деревьях (см. упражнение 15.65), однако для простоты мы опустим это изменение. Сочетание многопутевого ветвления и представления в виде TST-дерева и само по себе достаточно эффективно во многих приложениях, но свертывание однонаправленных путей в стиле patricia-деревьев еще больше повышает производительность в тех случаях, когда ключи часто совпадают во многих символах подряд (см. упражнение 15.72).

 Поиск частичного соответствия в TST-деревьях


Рис. 15.19.  Поиск частичного соответствия в TST-деревьях

Чтобы найти все ключи в TST-дереве, которые соответствуют шаблону i* (вверху), мы выполняем поиск i в BST-дереве для первого символа. В данном примере после двух однопутевых разветвлений найдено слово is — единственное слово, соответствующее шаблону. Чтобы найти соответствия более общему шаблону наподобие *o* (внизу), в BST-дереве посещаются все узлы, соответствующие первому символу, но поиск продолжается только там, где есть o во втором символе — окончательно это дает слова for и now.

Еще одно простое усовершенствование поиска, основанного на использовании TST-деревьев — использование большого явного многопутевого узла в корне. Для этого проще всего хранить таблицу R TST-деревьев: по одному для каждого возможного значения первой буквы в ключах. Если значение R невелико, можно использовать первые две буквы ключей (и таблицу размером R2 ). Чтобы этот метод был эффективен, ведущие цифры ключей должны быть распределены достаточно равномерно. Результирующий гибридный алгоритм поиска соответствует тому, как человек мог бы искать фамилии в телефонном справочнике. Вначале принимается многопутевое решение ( " Так, фамилия начинается на А " ), а затем, вероятно, принимается несколько двухпутевых решений ( " Она находится перед Анискин, но после Азазель " ), после чего символы сравниваются последовательно ( " Алгонавт... Нет, Алгоритмиста здесь нет, поскольку ни одно слово не начинается с Алгор! " ).

Программы 15.10—15.12 включают в себя основанную на TST-дереве реализацию операций таблицы символов найти и вставить, в которой используется R-путевое ветвление в корне и хранение элементов в листьях (поэтому здесь нет однонаправленных путей, если ключи различны).

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

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

Лемма 15.8. Для выполнения поиска или вставки в TST-дереве, содержащем элементы в листьях (не имеющем однонаправленных путей в нижней части дерева) и с Rt-путевым ветвлением в корне, требуется приблизительно ln N — t ln R обращений к байтам для N случайных строковых ключей. При этом количество требуемых ссылок равно Rt (для корневого узла) плюс небольшая константа, умноженная на N.

Эти грубые оценки непосредственно следуют из леммы 15.6. При оценке затрат времени мы принимаем, что все узлы на пути поиска, за исключением небольшого постоянного количества узлов у вершины, выступают по отношению к R значениям символов как случайные BST-деревья. Поэтому затраты времени просто умножаются на lnR. При оценке затрат памяти предполагается, что узлы на нескольких первых уровнях заполнены R значениями символов, а узлы на нижних уровнях содержат только постоянное количество символов.

Например, при наличии 1 миллиарда случайных строковых ключей, при R = 256 и при использовании на верхнем уровне таблицы размером R2 = 65536 , для выполнения типичного поиска потребуется около сравнений байтов. Использование таблицы в верхней части дерева уменьшает затраты на поиск в два раза.

Если ключи действительно случайны, этой производительности можно достичь с помощью более непосредственных алгоритмов, использующих ведущие байты ключа и таблицу существования, как было описано в лекция №14. Однако TST-деревья позволяют получить такую же производительность и при менее случайной структуре ключей.

Программа 15.10. Определения типов узлов в гибридном TST-дереве

Этот код определяет структуры данных, используемые в программах 15.11 и 15.12, которые предназначены для реализации таблицы символов с помощью TST-деревьев. Здесь используется R-путевое ветвление в корне: корень представляет собой массив heads, состоящий из R ссылок и индексированный первой цифрой ключей. Каждая ссылка указывает на TST-дерево, построенное из всех ключей, которые начинаются с соответствующей цифры. Этот гибрид сочетает в себе преимущества trie-деревьев (быстрый поиск с помощью индексации в корне) и TST-деревьев (эффективное использование памяти: один узел для каждого символа кроме корня).

  struct node
    { Item item; int d; node *l, *m, *r;
      node(Item x, int k)
        { item = x; d = k; l = 0; m = 0; r = 0; }
      node(node* h, int k)
        { d = k; l = 0; m = h; r = 0; }
      int internal()
        { return d != NULLdigit; }
    };
    typedef node *link;
    link heads[R];
    Item nullItem;
      

Интересно сравнить TST-деревья без многопутевого ветвления в корне со стандартными BST-деревьями при использовании случайных ключей. В соответствии с леммой 15.8 для выполнения поиска в TST-дереве требуется около lnN сравнений байтов, в то время как в стандартных BST-деревьях требуется около lnN сравнений ключей. В верхней части BST-дерева сравнения ключей можно выполнить с помощью сравнения всего одного байта, но в нижней части для выполнения сравнения ключа может потребоваться много байтовых сравнений. Но не это различие в производительности является решающим. Причины, по которым при использовании строковых ключей TST-деревья предпочтительнее стандартных BST-деревьев, таковы: они обеспечивают быстрый неудачный поиск; они непосредственно годятся для многопутевого ветвления в корне; и (что наиболее важно) они хорошо подходят для строковых ключей, не являющихся случайными, поэтому в TST-дереве длина поиска никогда не превышает длину ключа.

Программа 15.11. Вставка в гибридное TST-дерево для АТД таблицы символов

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

  link split(link p, link q, int d)
    { int pd = digit(p->item.key(), d),
      qd = digit(q->item.key(), d);
      link t = new node(nullItem, qd);
      if (pd < qd)
        { t->m = q; t->l = new node(p, pd); }
      if (pd == qd)
        { t->m = split(p, q, d+1); }
      if (pd > qd)
        { t->m = q; t->r = new node(p, pd); }
      return t;
    }
    link newext(Item x)
      { return new node(x, NULLdigit); }
    void insertR(link& h, Item x, int d)
      { int i = digit(x.key(), d);
        if (h == 0)
          { h = new node(newext(x), i); return; }
        if (!h->internal())
          { h = split(newext(x), h, d); return; }
        if (i < h->d) insertR(h->l, x, d);
        if (i == h->d) insertR(h->m, x, d+1);
        if (i > h->d) insertR(h->r, x, d);
      }
    public:
      ST(int maxN)
      { for (int i = 0; i < R; i++) heads[i] = 0; }
    void insert(Item x)
      { insertR(heads[digit(x.key(), 0)], x, 1); }
      

Программа 15.12. Поиск в гибридном TST-дереве для АТД таблицы символов

Данная реализация операции найти для TST-деревьев (построенных программой 15.11) похожа на поиск в многопутевом trie-дереве, но в ней каждый узел (за исключением корня) содержит только три, а не R, ссылки. Цифры ключа используются при спуске вниз по дереву, который завершается либо на пустой ссылке (неудачный поиск), либо в листе, содержащем ключ, который или равен (успешный поиск), или не равен (неудачный поиск) искомому ключу.

  private:
    Item searchR(link h, Key v, int d)
    { if (h == 0) return nullItem;
      if (h->internal())
        { int i = digit(v, d), k = h->d;
          if (i < k) return searchR(h->l, v, d);
          if (i == k) return searchR(h->m, v, d+1);
          if (i > k) return searchR(h->r, v, d);
        }
      if (v == h->item.key()) return h->item;
      return nullItem;
    }
  public:
    Item search(Key v)
    { return searchR(heads[digit(v, 0)], v, 1); }
      

Некоторые приложения не могут воспользоваться преимуществом R-путевого ветвления в корне — например, все ключи в примере с библиотечными номерами на рис. 15.18 рис. 15.18 начинаются с буквы L или W. Для других приложений может требоваться более высокий коэффициент ветвления в корне: например, как было сказано, если бы ключи были случайными целыми числами, пришлось бы использовать максимально большую таблицу. Подобную зависимость от приложения можно использовать при настройке алгоритма на максимальную производительность, но не следует забывать о том, что одно из наиболее привлекательных свойств TST-деревьев — возможность не беспокоиться о зависимости от приложений и обеспечение достаточно высокой производительности без каких-либо настроек.

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

Упражнения

15.49. Нарисуйте trie-дерево существования, образованное вставками слов now is the time for all good people to come the aid of their party в первоначально пустое дерево. Используйте 27-путевое ветвление.

15.50. Нарисуйте TST-дерево существования, образованное вставками слов now is the time for all good people to come the aid of their party в первоначально пустое дерево.

15.51. Нарисуйте 4-путевое trie-дерево, образованное вставками элементов с ключами 01010011 00000111 00100001 01010001 11101100 00100001 10010101 01001010 в первоначально пустое дерево, в котором используются 2-разрядные байты.

15.52. Нарисуйте TST-дерево, образованное вставками элементов с ключами 01010011 00000111 00100001 01010001 11101100 00100001 10010101 01001010 в первоначально пустое дерево, в котором используются 2-разрядные байты.

15.53. Нарисуйте TST-дерево, образованное вставками элементов с ключами 01010011 00000111 00100001 01010001 11101100 00100001 10010101 01001010 в первоначально пустое дерево, в котором используются 4-разрядные байты.

15.54. Нарисуйте TST-дерево, образованное вставками элементов с ключами библиотечных номеров на рис. 15.18 в первоначально пустое дерево.

15.55. Измените реализацию поиска и вставки в многопутевом trie-дереве, приведенную в программе 15.7, так, чтобы она работала для ключей фиксированной длины, которые являются w-байтовыми словами (т.е. не требуется указание конца ключа).

15.56. Измените реализацию поиска и вставки в TST-дереве, приведенную в программе 15.8, так, чтобы она работала для ключей фиксированной длины, которые являются w-байтовыми словами (т.е. не требуется указание конца ключа).

15.57. Экспериментально сравните время и объем памяти, требуемые для 8-путевого trie-дерева, построенного из случайных целых чисел с использованием 3-разрядных байтов, для 4-путевого trie-дерева, построенного из случайных целых чисел с использованием 2-разрядных байтов, и для бинарного trie-дерева, построенного из тех же ключей, при N = 103, 104, 105 и 106 (см. упражнение 15.14).

15.58. Измените программу 15.9 так, чтобы она посещала все узлы, соответствующие искомому ключу (аналогично операции сортировать).

15.59. Напишите функцию, которая для заданного целочисленного значения k выводит все ключи в TST-дереве, отличающиеся от искомого не более чем в k позициях.

15.60. Приведите полную характеристику длины внутреннего пути худшего случая R-путевого trie-дерева с N различными w-разрядными ключами.

15.61. Разработайте реализацию таблицы символов на основе многопутевых trie-деревьев, которая включает в себя деструктор, конструктор копирования и перегруженную операцию присваивания, а также поддерживает операции создать, подсчитать, найти, вставить, удалить и объединить для АТД первого класса таблицы символов, поддерживающей клиентские дескрипторы (см. упражнения 12.6 и 12.7).

15.62. Разработайте реализацию таблицы символов на основе многопутевых TST-деревьев, которая включает в себя деструктор, конструктор копирования и перегруженную операцию присваивания, а также поддерживает операции создать, подсчитать, найти, вставить, удалить и объединить для АТД первого класса таблицы символов, поддерживающей клиентские дескрипторы (см. упражнения 12.6 и 12.7).

15.63. Напишите программу, которая выводит все ключи в R-путевом trie-дереве, имеющие те же первые t байтов, что и заданный ключ поиска.

15.64. Измените реализацию поиска и вставки в многопутевом trie-дереве, приведенную в программе 15.7, чтобы исключить однонаправленные пути, как в patricia-деревьях.

15.65. Измените реализацию поиска и вставки в TST-дереве, приведенную в программе 15.8, чтобы исключить однонаправленные пути, как в patricia-деревьях.

15.66. Напишите программу, которая балансирует BST-деревья, представляющие внутренние узлы TST-дерева (реорганизует их так, чтобы все их внешние узлы располагались на одном или двух уровнях).

15.67. Напишите версию операции вставить для TST-деревьев, которая поддерживает представление всех внутренних узлов в виде сбалансированных деревьев (см. упражнение 15.66).

15.68. Приведите полную характеристику длины внутреннего пути худшего случая TST-дерева, содержащего N различных w-разрядных ключей.

15.69. Напишите программу, генерирующую случайные 80-байтовые строковые ключи (см. упражнение 10.19). Воспользуйтесь этим генератором для построения 256-пу-тевого trie-дерева, содержащего N случайных ключей при N = 103, 104, 105 и 106 , применяя операцию найти, а после неудачного поиска — операцию вставить. Программа должна выводить общее количество узлов в каждом дереве и общее время построения каждого дерева.

15.70. Выполните упражнение 15.69 для TST-деревьев. Сравните полученные характеристики производительности с характеристиками trie-деревьев.

15.71. Напишите программу, которая генерирует ключи, тасуя случайную 80-байтовую последовательность (см. упражнение 10.21). Воспользуйтесь полученным генератором ключей для построения 256-путевого trie-дерева, содержащего N случайных ключей при N = 103, 104, 105 и 106 . Для вставки применяйте операцию найти, а после неудачного поиска — операцию вставить. Сравните полученные характеристики производительности с характеристиками для случайных ключей из упражнения 15.69.

о 15.72. Напишите программу, которая генерирует 30-байтовые случайные строки из четырех полей: 4-байтового поля, содержащего одну из 10 заданных строк; 10-байтового поля, содержащего одну из 50 заданных строк; 1-байтового поля, содержащего одно из двух заданных значений; и 15-байтового поля, содержащего случайные буквенные выровненные влево строки, длина которых с равной вероятностью может составлять от 4 до 15 символов (см. упражнение 10.23). Воспользуйтесь этим генератором ключей для построения 256-путевого trie-дерева, содержащего N случайных ключей при N = 103, 104, 105 и 106 . Для вставки применяйте операцию найти, а после неудачного поиска — операцию вставить. Обеспечьте возможность вывода общего количества узлов в каждом trie-дереве и общего времени, затраченного на построение каждого trie-дерева. Сравните полученные характеристики производительности с характеристиками для случайных ключей (см. упражнение 15.69).

15.73. Выполните упражнение 15.72 для случая TST-деревьев. Сравните полученные характеристики производительности с характеристиками для trie-деревьев.

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

15.75. Нарисуйте 27-путевое DST-дерево (см. упражнение 15.74), образованное вставками элементов с ключами now is the time for all good people to come the aid of their party в первоначально пустое дерево.

15.76. Разработайте реализацию поиска и вставки в многопутевом trie-дереве, в котором для представления узлов trie-дерева используются связные списки (в отличие от используемого для TST-деревьев представления в виде BST-дерева). Определите экспериментальным путем, что эффективнее использовать: упорядоченные или неупорядоченные списки, и сравните эту реализацию с реализацией на основе TST-деревьев.

Алгоритмы индексирования текстовых строк

В лекция №12 был рассмотрен процесс построения индекса строк, где для определения, присутствует ли в длинном тексте заданная ключевая строка, использовалось BST-дерево с указателями на подстроки. В этом разделе мы рассмотрим более сложные реализации этого АТД, использующие многопутевые trie-деревья, но отправная точка остается той же. Каждая позиция в тексте считается началом строкового ключа, который простирается до конца текста. Из этих ключей строится таблица символов, содержащая указатели на строки. Все ключи различны (хотя бы потому, что все они имеют различную длину), и почти все они очень велики. Цель поиска состоит в определении, является ли заданный искомый ключ префиксом одного из ключей в индексном указателе, что эквивалентно определению того, присутствует ли искомый ключ где-либо в текстовой строке.

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

На рис. 15.20 приведены примеры строковых индексов, построенных с использованием BST-деревьев, patricia-деревьев и TST-деревьев (с листьями). В этих индексах используются только ключи, которые начинаются на границах слов; индексирование, начинающееся с границ символов, позволило бы построить более сложный индекс, но при этом потребовало бы гораздо больше памяти.

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

 Примеры индексов текстовых строк


Рис. 15.20.  Примеры индексов текстовых строк

Здесь показаны индексы текстовых строк, построенные из текста call me ishmael some years ago never mind how long precisely... с использованием BST-дерева (вверху), patricia-дерева (в центре) и TST-дерева (внизу). Узлы, содержащие указатели на строки, отмечены первыми четырьмя символами указываемых строк.

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

Patricia-деревья изначально разрабатывались для приложений строкового индексирования. Для использования программ 15.5 и 15.4 потребуется лишь обеспечить реализацию функции bit, чтобы при заданном указателе на строку и целочисленном значении i она возвращала i-й бит строки (см. упражнение 15.82). На практике высота patricia-дерева, реализующего индекс текстовой строки, будет логарифмической. Кроме того, patricia-дерево обеспечивает быстрые реализации неудачного поиска, т.к. в нем нет необходимости проверять все байты ключа.

TST-деревья обеспечивают некоторые преимущества в производительности, характерные для patricia-деревьев, легко реализуются и используют встроенные операции доступа к байтам, обычно присутствующие в современных компьютерах. Кроме того, они допускают простые реализации, подобные программе 15.9, которые могут решать и задачи, более сложные, чем поиск полного соответствия с искомым ключом. Для построения строкового индекса на основе TST-дерева необходимо удалить код, обрабатывающий конечные части ключей в структуре данных, поскольку ни одна строка гарантированно не является префиксом другой и, следовательно, никогда не придется сравнивать строки вплоть до их конца. При этом нужно изменить определение операции == в интерфейсе типа элемента, чтобы две строки считались равными, если одна из них является префиксом другой, как это было сделано в разделе 12.7 лекция №12, поскольку мы будем сравнивать ключ поиска (короткий) с текстовой строкой (длинной), начиная с некоторой позиции внутри текстовой строки. Третье удобное изменение — хранение в каждом узле не символов, а их индексов в строке, чтобы каждый узел в дереве ссылался на позицию в текстовой строке (позицию, которая следует за первым вхождением строки, определенной символами на ветвях равенства от корня до этого узла). Реализация перечисленных изменений — интересное и поучительное упражнение, ведущее к созданию гибкой и эффективной реализации индекса текстовых строк (см. упражнение 15.81).

Несмотря на все описанные преимущества, важно помнить, что в обычных приложениях, использующих индексирование текста с помощью DST-деревьев, patricia-деревьев или TST-деревьев, сам текст фиксирован, и поэтому нет необходимости использовать динамические операции вставить. То есть, как правило, индекс строится один раз, а затем без каких-либо изменений используется для выполнения очень большого количества поисков. Следовательно, динамические структуры данных типа BST-деревьев, patricia-деревьев или TST-деревьев могут оказаться вообще ненужными: достаточно базового алгоритма бинарного поиска. Индекс представляет собой набор указателей на строки, а формирование индекса эквивалентно сортировке этих указателей. Основное преимущество бинарного поиска по сравнению с динамическими структурами данных заключается в экономии памяти. Для индексирования текстовой строки в N позициях при помощи бинарного поиска требуется лишь N указателей на строки; а для индексирования строки в N позициях с помощью метода, основанного на каком-либо дереве, требуется, по меньшей мере 3N указателей (один указатель на строку и еще две ссылки на поддеревья). Как правило, индексные указатели текста имеют очень большой размер, поэтому бинарный поиск может оказаться более удобным, т.к. он гарантирует логарифмическое время поиска, но при этом использует менее трети памяти, используемой методами на основе деревьев. Но при наличии достаточного объема доступной памяти TST- или trie-деревья позволяют для многих приложений реализовать более быстрые операции найти, т.к., в отличие от бинарного поиска, перемещение по ключам выполняется без возвратов.

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

Упражнения

15.77. Нарисуйте 26-путевое DST-дерево, образованное в результате индексирования текстовой строки из слов now is the time for all good people to come the aid of their party.

15.78. Нарисуйте 26-путевое trie-дерево, образованное в результате индексирования текстовой строки из слов now is the time for all good people to come the aid of their party.

15.79. Нарисуйте TST-дерево, образованное в результате индексирования текстовой строки из слов now is the time for all good people to come the aid of their party в стиле рис. 15.20.

15.80. Нарисуйте TST-дерево, образованное в результате индексирования текстовой строки из слов now is the time for all good people to come the aid of their party. Используйте описанную в тексте реализацию, в которой TST-дерево содержит в каждом узле указатели на символы строк.

15.81. Измените реализации поиска и вставки в TST-дерево, приведенные в программах 15.11 и 15.12, чтобы обеспечить индексирование строк на основе TST-дерева.

15.82. Реализуйте интерфейс, позволяющий с помощью patricia-деревьев обрабатывать строковые ключи в стиле C (т.е. массивы символов), как если бы они были битовыми строками.

15.83. Нарисуйте patricia-дерево, образованное в результате индексирования текстовой строки из слов now is the time for all good people to come the aid of their party при использовании 5-разрядного двоичного кодирования, когда i-я буква алфавита кодируется двоичным представлением числа i.

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

15.85. Найдите в вашей системе большой (не менее 106 байтов) текстовый файл и сравните высоту и длину внутреннего пути стандартного BST-дерева, patricia-дерева и TST-дерева, полученных в результате построения индексного указателя для данного файла.

15.86. Экспериментально сравните высоту и длину внутреннего пути стандартного BST-дерева, patricia-дерева и TST-дерева, полученных в результате построения индексного указателя для текстовой строки, состоящей из N случайных символов 32-символьного алфавита при N = 103, 104, 105 и 106 .

15.87. Напишите эффективную программу для определения самой длинной повторяющейся последовательности в очень длинной текстовой строке.

15.88. Напишите эффективную программу для определения 10-символьной последовательности, чаще всего встречающейся в очень длинной текстовой строке.

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

15.90. Опишите текстовую строку, состоящую из N символов, для которой индексирование, основанное на применении TST-дерева, работает особенно плохо. Оцените затраты на индексирование этой же строки с помощью BST-дерева.

15.91. Пусть нужно проиндексировать случайную N-разрядную строку для позиций разрядов, кратных 16. Экспериментально определите, какие размеры байтов (1, 2, 4, 8 или 16) ведут к наименьшему времени индексирования с помощью TST-дерева, при N = 103, 104, 105 и 106 .

Лекция 16. Внешний поиск

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

Подобно алгоритмам из лекция №11, алгоритмы, рассматриваемые в этой главе, пригодны для множества различных типов аппаратных и программных сред. Поэтому мы будем стремиться к формулировке алгоритмов на более абстрактном уровне, чем программы на языке C++. Однако приведенные далее алгоритмы также непосредственно обобщают знакомые методы поиска, и их удобно записывать в виде С++-программ, полезных во многих ситуациях. Эта глава будет не похожа на лекция №11: мы тщательно разработаем конкретные реализации, рассмотрим их основные характеристики производительности, а затем обсудим способы применения базовых алгоритмов в реальных ситуациях. Вообще-то название этой главы не совсем верно, поскольку в ней алгоритмы будут представлены в виде С++-программ, взаимозаменяемых с другими реализациями таблиц символов, которые были рассмотрены в лекциях 12—15. В таком виде они вообще не являются " внешними " методами. Тем не менее, они построены в соответствии с простой абстрактной моделью, что превращает их в подробное описание того, как можно строить методы поиска для конкретных внешних устройств.

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

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

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

Массив информации, которая должна обрабатываться компьютером, называется базой данных (database). Методам построения, сопровождения и использования баз данных посвящены многочисленные исследования. Большая часть этой работы проводится в области разработки абстрактных моделей и реализаций для поддержки операций найти с более сложным критерием, чем рассмотренное простое " равенство отдельному ключу " . В базе данных поиски могут основываться на критерии частичного соответствия, который может содержать несколько ключей и возвращать большое количество элементов. Методы этого типа будут рассмотрены в частях V и VI. Запросы на поиск общего вида достаточно сложны, поэтому зачастую приходится выполнять последовательный поиск по всей базе данных, проверяя каждый элемент на соответствие критерию. И все же быстрый поиск в огромном файле крошечных фрагментов данных, удовлетворяющих заданному критерию — основная возможность в любой системе управления базами данных, и многие современные базы данных построены на основе описанных в этой главе механизмов.

Правила игры

Как и в лекция №11, мы будем считать, что последовательный доступ к данным требует значительно меньших затрат, чем не последовательный. Рабочей моделью будет любое запоминающее устройство, которое можно применить для реализации таблицы символов, разбитой на страницы (page) — непрерывные блоки информации, к которым возможен эффективный доступ дисковых устройств. Каждая страница обычно содержит множество элементов, и задача заключается в организации элементов внутри страниц таким образом, чтобы к любому элементу можно было обратиться, прочитав всего нескольких страниц. Мы будем предполагать, что время ввода/вывода, требуемое для считывания страницы, значительно больше времени, требуемого для доступа к конкретным элементам или для выполнения любых других вычислений в пределах этой страницы. Во многих отношениях эта модель слишком упрощена, но она сохраняет характеристики внешних запоминающих устройств, необходимые для рассмотрения фундаментальных методов.

Определение 16.1. Страница — это непрерывный блок данных. Проба — это первое обращение к странице.

Нас интересуют реализации таблиц символов, использующие небольшое количество проб. Мы будем избегать конкретных предположений по поводу размеров страницы и отношения времени, необходимого для пробы, ко времени, которое впоследствии потребуется для доступа к элементам внутри блока. Ожидается, что эти значения должны быть порядка 100—1000; большая точность не требуется, поскольку алгоритмы не очень чувствительны к этим значениям.

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

Эта модель непосредственно применима и к системе виртуальной памяти, где мы просто работаем с огромным объемом памяти и предоставляем системе самой сохранять часто используемую информацию в запоминающем устройстве с быстрым доступом (таком, как внутренняя память), а редко используемую информацию — в медленном запоминающем устройстве (таком, как диск). Многие компьютерные системы имеют сложные механизмы страничной обработки, которые реализуют виртуальную память, храня недавно использовавшиеся страницы в кэше (cash), который допускает быстрое обращение. Системы страничной организации памяти основаны на той же рассматриваемой абстракции: они делят диск на блоки и считают, что затраты на первое обращение к блоку существенно превышают затраты на доступ к данным внутри блока.

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

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

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

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

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

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

Индексно-последовательный доступ

Прямой подход к построению индекса заключается в сохранении массива c ключами и ссылками на элементы, упорядоченного по ключам, с последующим использованием бинарного поиска (см. лекция №12) для реализации операции найти. Для N элементов этот метод потребовал бы lgN проб, даже в случае очень большого файла. Наша базовая модель немедленно приводит нас к рассмотрению двух модификаций этого простого метода. Во-первых, индекс и сам по себе очень велик и обычно не помещается на одной странице. Поскольку доступ к страницам можно получить только через ссылки на страницы, вместо этого можно построить явное полностью сбалансированное бинарное дерево с ключами и указателями на страницы во внутренних узлах и с ключами и указателями на элементы во внешних. Во-вторых, затраты на доступ к M записям таблицы не отличаются от затрат на доступ к двум записям, поэтому можно воспользоваться M-арным деревом при таких же затратах на каждый узел, как и для бинарного дерева. Такое усовершенствование уменьшает количество проб до пропорционального приблизительно logMN. Как было показано в лекция №10 и лекция №15, это значение можно считать практически постоянным. Например, если M= 1000, то logMN меньше 5 для всех N, меньших 1 триллиона.

На рис. 16.1 приведен пример набора ключей, а на рис. 16.2 — пример такой структуры дерева для этих ключей. Чтобы примеры были достаточно понятными, приходится использовать сравнительно небольшие значения M и N, но все же из этих примеров видно, что деревья для большого значения M будут плоскими.

 Двоичное представление восьмеричных ключей


Рис. 16.1.  Двоичное представление восьмеричных ключей

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

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

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

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

Поскольку метод индексации, показанный на рис. 16.2, сочетает последовательную организацию ключей с индексным доступом, по историческим причинам он называется индексно-последовательным доступом (indexed sequential access). Этот метод удобен для приложений, в которых изменения в базе данных выполняются редко. Иногда сам индекс называют каталогом (directory). Недостаток использования индексно-последовательного доступа заключается в больших затратах на изменение каталога. Например, для добавления единственного ключа может потребоваться перестройка буквально всей базы данных с присвоением новых позиций многим ключам и новых значений индексам. Для преодоления этого недостатка и обеспечения возможности небольшого увеличения базы данных в ранних системах на дисках резервировались страницы переполнения, а в страницах — области переполнения; но все равно в динамических ситуациях такие технологии были не очень эффективны (см. упражнение 16.3). Методы, которые будут рассматриваться в разделах 16.3 и 16.4, обеспечивают надежные и эффективные альтернативы таким элементарным схемам.

Лемма 16.1. Для выполнения поиска в индексно-последовательном файле требуется выполнение лишь постоянного количества проб, однако вставка может вызвать перестройку всего индекса.

В данном случае (и вообще в этой главе) термин постоянный используется нестрого и обозначает величину, пропорциональную logMN для больших M. Как уже было сказано, это оправдано для размеров реальных файлов. На рис. 16.3 показаны дополнительные примеры. Даже при наличии 128-битного ключа поиска, пригодного для указания неимоверно огромного количества (2128) различных элементов, при 1000-путевом ветвлении элемент с заданным ключом можно найти при помощи всего 13 проб.

 Структура индексно-последовательного файла


Рис. 16.2.  Структура индексно-последовательного файла

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

 Примеры размеров наборов данных


Рис. 16.3.  Примеры размеров наборов данных

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

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

Упражнения

16.1. Составьте таблицу значений logM N для M= 10, 100 и 1000 и N= 103, 104, 105 и 106 .

16.2. Нарисуйте структуру индексно-последовательного файла для ключей 516, 177, 143, 632, 572, 161, 774, 470, 411, 706, 461, 612, 761, 474, 774, 635, 343, 461, 351, 4 30, 664, 127, 34 5, 171 и 357 при M= 5 и N= 6.

16.3. Предположим, что мы строим структуру индексно-последовательного файла для N элементов, со страницами емкостью M, но оставляем на каждой странице к свободных мест для возможного расширения. Приведите формулу для определения количества проб, необходимых для выполнения поиска, в виде функции от N, M и к. Используйте эту формулу для определения количества проб, необходимых для выполнения поиска при k = M/10, M= 10, 100 и 1000 и N= 103, 104, 105 и 106 .

16.4. Предположим, что затраты на одну пробу равны приблизительно а единиц времени, а средние затраты на поиск элемента на странице составляют приблизительно PM единиц времени. Найдите значение M, при котором затраты на поиск в структуре индексно-последовательного файла минимальны, при и N= 103, 104, 105 и 106 .

B-деревья

Для построения структур поиска, которые могут быть эффективны в динамических ситуациях, мы будем строить многопутевые деревья, но при этом откажемся от ограничения, что каждый узел должен содержать точно M записей. Вместо этого выдвинем условие, что каждый узел должен иметь не более M записей, чтобы они помещались на странице, но узлы могут иметь и меньше записей. Чтобы гарантировать, что узлы имеют достаточное количество записей для обеспечения ветвления, необходимого для предотвращения увеличения длины путей, мы также потребуем, чтобы все узлы имели не менее (допустим) M/2 записей — за исключением, быть может, корня, который должен иметь не менее одной записи (двух ссылок). Причина этого исключения для корня станет понятна при подробном рассмотрении алгоритма построения. Байер (Bayer) и МакКрейт (McCreight) первыми (в 1970 г.) исследовали возможность использования многопутевых сбалансированных деревьев для внешнего поиска и назвали такие деревья B-деревьями. Термин B-дерево часто используется для описания именно той структуры данных, которая строится алгоритмом, предложенным Байером и МакКрейтом; мы же будем использовать его в качестве общего термина для обозначения семейства похожих алгоритмов.

Мы уже встречались с реализацией B-дерева: из определений 13.1 и 13.2 видно, что B-деревья четвертого порядка, в которых каждый узел содержит не более 4 и не менее 2 ссылок, являются ни чем иным, как сбалансированными 2-3-4-деревьями, описанными в лекция №13. Лежащая в их основе абстракция допускает непосредственное обобщение, так что B-деревья можно реализовать, обобщив реализации нисходящего 2-3-4-дерева, описанные в лекция №13. Однако различия между внешним и внутренним поиском, упомянутые в разделе 16.1, приводят к ряду различий в реализациях.

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

На рис. 16.4 показано абстрактное 4-5-6-7-8-дерево, являющееся обобщением 2-3-4-дерева из лекция №13. Обобщение очевидно: 4-узлы имеют три ключа и четыре ссылки, 5-узлы — четыре ключа и пять ссылок и т.д.: по одной ссылке для каждого возможного интервала ключей. Поиск начинается с корня и проходит от одного узла к другому, определяя в текущем узле интервал, который соответствуюет искомому ключу, и переходя к следующему узлу по соответствующей ссылке. Поиск завершается успешно, если ключ поиска находится в любом из рассмотренных узлов, и неудачно, если он дошел до низа дерева, не обнаружив искомый ключ. Как и в случае 2-3-4-деревьев, новый ключ можно вставить после выполнения поиска в нижнюю часть дерева, если при спуске вниз по дереву выполняется разбиение заполненных узлов: если корень является 8-узлом, он заменяется 2-узлом, связанным с двумя 4-узлами. Затем, каждый раз, когда встречается k-узел с присоединенным 8-узлом, он заменяется (к+1)-узлом с двумя присоединенными 4-узлами. Это правило гарантирует наличие места для вставки нового узла по достижении нижней части дерева.

Или же, как и в лекция №13 применительно к 2-3-4-деревьям, разбиение можно выполнять снизу вверх: после выполнения поиска новый ключ вставляется в нижний узел, если только тот не является 8-узлом — в этом случае он разбивается на два 4-узла со вставкой среднего ключа и двух ссылок в его родительский узел. Восходящее разбиение выполняется до тех пор, пока не встретится узел-предок, отличный от 8-узла.

Замена в предыдущих двух абзацах 4 на M/2, а 8 — на M позволяет преобразовать приведенные описания в описания поиска и вставки в М/2-...-М-деревьях для любого положительного четного M (см. упражнение 16.9).

 4-5-6-7-8-дерево


Рис. 16.4.  4-5-6-7-8-дерево

На рисунке показано обобщение 2-3-4-деревьев, которое построено из узлов, содержащих от 4 до 8ссылок (и соответственно от 3 до 7ключей). Как и в случае 2-3-4-деревьев, мы поддерживаем высоту деревьев постоянной, разбивая встречающиеся 8-узлы при работе нисходящего или восходящего алгоритма вставки. Например, для вставки в это дерево еще одного ключа J нужно сначала разбить 8-узел на два 4-узла, а затем вставить ключ M в корень, преобразовав его в 6-узел. При разбиении корня возможно лишь создание нового корня, который будет 2-узлом — поэтому корень не подчиняется общему правилу, согласно которому узлы должны содержать не менее четырех ссылок.

Определение 16.2. B-дерево порядка M — это дерево, которое либо пусто, либо состоит из k-узлов с к — 1 ключами и к ссылками на деревья, представляющими каждый из к ограниченных ключами интервалов, и обладает следующими структурными свойствами: к должно находиться в интервале между 2 и M в корне и между M/2 и M в любом другом узле; все ссылки на пустые деревья должны находиться на равном расстоянии от корня.

Алгоритмы B-деревьев построены на основе этого базового набора абстракций. Как и в лекция №13, существует значительная свобода в выборе конкретных представлений таких деревьев. Например, можно использовать расширенное RB-представление (см. упражнение 13.69). Для внешнего поиска мы используем еще более простое представление в виде упорядоченного массива, выбирая достаточно большое значение M, чтобы M-узлы заполняли всю страницу. Коэффициент ветвления равен по меньшей мере M/2, поэтому, как следует из текста после леммы 16.1, количество проб, необходимое для выполнения любого поиска или вставки, практически постоянно.

Вместо реализации только что описанного метода рассмотрим вариант, обобщающий стандартный индексный указатель, рассмотренный в разделе 16.1. Будем хранить ключи со ссылками на элементы во внешних страницах в нижней части дерева, а копии ключей со ссылками на страницы — во внутренних страницах. Мы вставляем новые элементы в нижнюю часть, а затем используем базовую абстракцию M/2-...-M дерева. Если страница содержит M записей, мы разбиваем ее на две страницы с M/2 записями в каждой и вставляем в родительскую страницу ссылку на новую страницу. При разбиении корня мы создаем новый корень с двумя дочерними узлами, тем самым увеличивая высоту дерева на 1.

На рис. 16.5 рис. 16.7—16.7 показан процесс построения B-дерева вставками ключей, показанных на рис. 16.1 (в приведенном порядке) в первоначально пустое дерево при M= 5.

 Построение B-дерева, часть 1


Рис. 16.5.  Построение B-дерева, часть 1

Здесь показаны шесть вставок в первоначально пустое B-дерево, построенное из страниц, которые могут содержать пять ключей и ссылок, при использовании 3-значных восьмеричных ключей (9-разрядных двоичных чисел). Ключи в страницах хранятся по порядку. Шестая вставка приводит к разбиению на два внешних узла с тремя ключами в каждом и внутренний узел, служащий в качестве индекса: его первая запись указывает на страницу, содержащую все ключи, которые больше или равны 000, но меньше 601, а вторая запись — на страницу, содержащую все ключи, которые больше или равны 601.

 Построение B-дерева, часть 2


Рис. 16.6.  Построение B-дерева, часть 2

После вставки четырех ключей 742, 373, 524 и 766 в самое правое B-дерево на рис. 16.5 обе внешние страницы оказываются заполненными (слева). Затем, при вставке ключа 2 7 5, первая страница разбивается с передачей ссылки на новую страницу (вместе с ее наименьшим ключом 37 3) вверх по индексу (в центре). Далее, при вставке ключа 7 37 разбивается нижняя страница, также с передачей ссылки на новую страницу вверх по индексу (справа).

Построение B-дерева, часть 3


Рис. 16.7.  Построение B-дерева, часть 3

Продолжая наш пример, мы вставляем 13 ключей 574, 434, 641, 207, 001, 277, 061, 736, 526, 562, 017, 107 и 147 в самое правое B-дерево на рис. 16.6. Разбиения узлов выполняются при вставке ключей 277 (слева), 526 (в центре) и 107 (справа). Разбиение узла, вызванное вставкой ключа 52 6, приводит также к разбиению индексной страницы и увеличению высоты дерева на единицу.

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

В программе 16.1 приведены определения типов для узлов нашей реализации B-дерева. Мы не указываем подробно структуру узлов, как в рабочей реализации, т.к. для этого потребовались бы ссылки на конкретные страницы диска. Для простоты мы используем один тип узлов, каждый узел состоит из массива записей, а каждая запись содержит элемент, ключ и ссылку. Каждый узел содержит также счетчик активных записей. Мы не обращаемся к элементам во внутренних узлах, не обращаемся к ссылкам во внешних узлах, и не обращаемся к ключам в элементах дерева. При использовании конкретной структуры данных в реальном приложении можно сэкономить память с помощью конструкций наподобие union или производных классов. Можно было бы также сэкономить память ценой увеличения времени выполнения, используя везде в дереве вместо ключей ссылки на элементы. Такие конструктивные решения достигаются очевидными изменениями кода и зависят от конкретного характера ключей, элементов и ссылок в приложении.

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

Программа 16.3 является реализацией операции вставить для B-деревьев; в ней также применяется рекурсивный подход, используемый во многих других реализациях деревьев поиска из глав 13 и 15. Эта реализация является восходящей, т.к. проверка, нужно ли разбивать узел, выполняется после рекурсивного вызова — поэтому первым разбивается внешний узел. Разбиение требует передачи новой ссылки наверх к родительскому узлу разбиваемого узла, который, в свою очередь, может нуждаться в разбиении и передаче ссылки его родительскому узлу и т.д. — возможно, вплоть до корня дерева (при разбиении корня создается новый корень с двумя дочерними поддеревьями).

В противоположность этому в реализации 2-3-4-дерева в программе 13.6 проверка, нужно ли разбивать узел, выполняется перед рекурсивным вызовом, и поэтому разбиение выполняется при спуске вниз по дереву. Для B-деревьев можно воспользоваться и нисходящим подходом (см. упражнение 16.10). Во многих приложениях, использующих B-деревья, это различие между нисходящим и восходящим подходами несущественно, поскольку такие деревья являются очень плоскими.

Код разбиения узла приведен в программе 16.4. В нем переменная M должна иметь четное значение, и каждый узел дерева может содержать только M — 1 элемент. Этот подход позволяет вставлять M-й элемент в узел перед разбиением этого узла и значительно упрощает код, не оказывая особого влияния на затраты (см. упражнения 16.20 и 16.21). Для простоты аналитических выкладок, приведенных далее в этом разделе, мы вводим ограничение, что количество элементов каждого узла должно быть не больше M; реальные различия несущественны. В нисходящей реализации эта технология не нужна, т.к. в ней наличие свободного места в каждом узле для вставки нового ключа обеспечивается автоматически.

Лемма 16.2. Для выполнения поиска или вставки в B-дереве порядка M, содержащем N элементов, требуется от logMN до logM/2N проб — на практике это число можно считать постоянным.

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

Программа 16.1. Определения типов узлов B-дерева

Каждый узел в B-дереве содержит массив и счетчик количества активных записей в этом массиве. Каждая запись массива представляет собой ключ, элемент и ссылку на узел. Во внутренних узлах используются только ключи и ссылки; во внешних узлах используются только ключи и элементы. Новые узлы создаются пустыми, для чего обнуляется поле счетчика.

  template <class Item, class Key>
  struct entry
    { Key key; Item item; struct node *next; };
  struct node
    { int m; entry<Item, Key> b[M];
      node() { m = 0; }
    };
  typedef node *link;
      

Программа 16.2. Поиск в B-дереве

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

private:
  Item searchR(link h, Key v, int ht)
    { int j;
      if ( ht == 0)
        for (j = 0; j < h->m; j++)
          { if (v == h->b[j].key)
            return h->b[j].item;
          }
      else
        for (j = 0; j < h->m; j++)
          if ((j + 1 == h->m) || (v < h->b[j + 1].key))
            return searchR(h->b[j].next, v, ht-1);
      return nullItem;
    }
public:
  Item search(Key v)
    { return searchR(head, v, HT); }
      

Программа 16.3. Вставка в B-дерево

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

private:
  link insertR(link h, Item x, int ht)
    { int i, j; Key v = x.key(); entry<Item, Key> t;
      t.key = v; t.item = x;
      if (ht == 0)
        for (j = 0; j < h->m; j++)
          { if (v < h->b[j].key) break; }
      else
        for (j = 0; j < h->m; j++)
          if ((j+1 == h->m) || (v < h->b[j+1].key))
            { link u;
              u = insertR(h->b[j++].next, x, ht-1);
              if (u == 0) return 0;
              t.key = u->b[0].key; t.next = u; break;
            }
      for (i = h->m; i > j; i—) h->b[i] = h->b[i-1];
      h->b[j] = t;
      if (++h->m < M) return 0; else return split(h);
    }
public:
  ST(int maxN)
    { N = 0; HT = 0; head = new node; }
  void insert(Item item)
    { link u = insertR(head, item, HT);
      if (u == 0) return;
      link t = new node(); t->m = 2;
      t->b[0].key = head->b[0].key;
      t->b[1].key = u->b[0].key;
      t->b[0].next = head;
      t->b[1].next = u;
      head = t; HT++;
    }
      

Программа 16.4. Разбиение узла B-дерева

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

  link split(link h)
    { link t = new node();
      for (int j = 0; j < M/2; j++)
        t->b[j] = h->b[M/2+j];
      h->m = M/2; t->m = M/2;
      return t;
    }
      

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

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

Лемма 16.3. B-дерево порядка M, построенное из N случайных элементов, предположительно имеет около 1,44 N/M страниц.

Яо (Yao) доказал этот факт в 1979 г., прибегнув к математическому анализу, который выходит за рамки этой книги (см. раздел ссылок). Этот анализ основывается на анализе простой вероятностной модели, описывающей рост дерева. После вставки первых M/2 узлов в любой заданный момент времени имеется tt внешних страниц с i элементами, для и tM/2 + ... + tM = N . Поскольку вероятность попадания случайного ключа в любой интервал между узлами одинакова, вероятность его попадания в узел с i элементами равна ti /N . В частности, для i < M эта величина равна вероятности того, что количество внешних страниц с i элементами уменьшается на 1, а количество внешних страниц с i + 1 элементами увеличивается на 1. Для i = 2M эта величина равна вероятности того, что количество внешних страниц с 2M элементами уменьшается на 1, а количество внешних страниц с M элементами увеличивается на 2. Такой вероятностный процесс называется цепью Маркова. Результат, полученный Яо, основывается на анализе асимптотических свойств этой цепи.

Лемму 16.3 можно также проверить, написав программу для моделирования вероятностного процесса (см. упражнение 16.11 и рис. 16.8 и 16.9). Конечно, можно также просто строить случайные B-деревья и измерять их структурные характеристики. Вероятностное моделирование выполнить проще, чем провести математический анализ или создать полную реализацию, кроме того, оно служит важным инструментом изучения и сравнения вариантов алгоритмов (см., например, упражнение 16.16).

Реализации других операций такой таблицы символов аналогичны соответствующим реализациям для других ранее рассмотренных представлений с использованием деревьев и оставлены в качестве упражнений (см. упражнения 16.22—16.25). В частности, реализации операций выбрать и сортировать очевидны, но, как обычно, правильная реализация операции удалить может оказаться сложной задачей. Подобно операции вставить, большинство операций удалить сводятся к простому удалению элемента из внешней страницы и уменьшению значения ее счетчика; но что делать, если необходимо удалить элемент из узла, который имеет только M/2 элементов? Естественный подход — найти для заполнения свободного места элементы в родственных узлах (возможно, с уменьшением количества узлов на единицу), но тогда реализация усложняется, поскольку приходится отслеживать ключи, связанные со всеми перемещаемыми по узлам элементами.

 Рост большого B-дерева


Рис. 16.8.  Рост большого B-дерева

Здесь показано моделирование процесса вставок элементов со случайными ключами в первоначально пустое B-дерево со страницами, которые могут содержать девять ключей и ссылок. Каждая линия соответствует всем внешним узлам, а каждый отдельный узел изображается отрезком этой линии, длина которого пропорциональна количеству элементов в данном узле. Большинство вставок выполняется в незаполненных внешних узлах, что приводит к увеличению их размера на 1. Если вставка выполняется в заполненном внешнем узле, он разбивается на два узла половинного размера.

 Рост большого B-дерева с индикацией заполнения страниц


Рис. 16.9.  Рост большого B-дерева с индикацией заполнения страниц

В этой версии рис. 16.8 показано заполнение страниц во время роста B-дерева. Как и в предыдущем случае, большинство вставок приходится на не заполненные страницы, лишь увеличивая их заполнение на 1. Когда вставка приходится на полную страницу, страница разбивается на две полупустые страницы.

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

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

Упражнения 16.13—16.16 посвящены такому методу, который при работе со случайными ключами позволяет уменьшить излишние затраты дисковой памяти с 44 до 23%. Как обычно, выбор того или иного варианта зависит от свойств приложения. В силу наличия множества различных ситуаций, в которых применимы B-деревья, мы не будем подробно рассматривать эти вопросы. Мы не сможем также рассмотреть подробности реализаций, поскольку это потребовало бы учета слишком многих факторов, зависящих от устройств и систем. Как обычно, детальная разработка таких приложений — рискованное дело, и в современных системах желательно избегать наличия столь прихотливого и не переносимого кода, особенно если базовый алгоритм работает вполне успешно.

Упражнения

16.5. Приведите содержимое 3-4-5-6-дерева, образованного вставками ключей E A S Y Q U E S T I O N W I T H P L E N T Y O F K E Y S в указанном порядке в первоначально пустое дерево.

16.6. Постройте рисунки наподобие рис. 16.7, иллюстрирующие процесс вставки ключей 516, 177, 143, 632, 572, 161, 774, 470, 411, 706, 461, 612, 761, 474, 774, 635, 343, 461, 351, 430, 664, 127, 345, 171 и 357 в указанном порядке в первоначально пустое дерево при M= 5.

16.7. Приведите высоту B-деревьев, образованных вставками ключей из упражнения 16.6 в указанном порядке в первоначально пустое дерево для каждого значения M > 2.

16.8. Нарисуйте B-дерево, образованное вставками 16 одинаковых ключей в первоначально пустое дерево при M= 4.

16.9. Нарисуйте 1-2-дерево, образованное вставками ключей E A S Y Q U E S T I O N в первоначально пустое дерево. Объясните, почему 1-2-деревья не представляют практического интереса как сбалансированные деревья.

16.10. Измените реализацию вставки в B-дерево, приведенную в программе 16.3, чтобы разбиение в ней выполнялось при спуске вниз по дереву, аналогично реализации вставки в 2-3-4-дерево (программа 13.6).

16.11. Напишите программу для вычисления среднего количества внешних страниц B-дерева порядка M, построенного N случайными вставками в первоначально пустое дерево при использовании вероятностного процесса, описанного после леммы 16.1. Выполните программу для M= 10, 100 и 1000 и N= 103, 104, 105 и 106 .

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

16.13. Рассмотрите эвристику родственного разбиения B-деревьев (или B*-дерево): когда требуется разбить узел, содержащий M записей, мы объединяем этот узел с его родственным узлом. Если родственный узел содержит к записей, при к < M— 1, мы перераспределяем элементы, помещая и в родственный, и в полный узел приблизительно по (M + к)/2 записей. В противном случае мы создаем новый узел и помещаем в каждый узел дерева приблизительно по 2M/3 записей. Кроме того, мы позволяем корню расти до размера около 4M/3, разбивая его и создавая новый корневой узел с двумя записями, когда его размер достигает этого предельного значения. Укажите границы количества проб, используемых для выполнения поиска или вставки в B*-дереве порядка M, содержащем N элементов. Сравните эти границы с соответствующими границами для B-деревьев (см. лемму 16.2) при M= 10, 100 и 1000 и N= 103, 104, 105 и 106 .

16.14. Разработайте реализацию операции вставить для B*-дерева (основанную на

эвристике родственного разбиения) (см. упражнение 16.13).

16.15. Создайте рисунок, аналогичный рис. 16.8, для иллюстрации эвристики родственного разбиения (см. упражнение 16.13).

16.16. Выполните вероятностное моделирование (см. упражнение 16.11) для определения среднего количества страниц, задействованных при использовании эвристики родственного разбиения (см. упражнение 16.13) для построения B*-дерева порядка M вставками случайных узлов в первоначально пустое дерево, при M= 10, 100 и 1000 и N= 103, 104, 105 и 106 .

16.17. Напишите программу для восходящего построения индекса B-дерева, начиная с массива ссылок на страницы, содержащих от M до 2M упорядоченных элементов.

16.18. Можно ли построить индекс со всеми заполненными страницами, как на рис. 16.2, с помощью алгоритма вставки в B-дерево, рассмотренного в тексте (программа 16.3)? Обоснуйте свой ответ.

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

16.20. Измените реализацию B-дерева в программах 16.1—16.3, чтобы один узел дерева мог содержать M элементов.

16.21. Постройте таблицу значений разностей log999N и logi000N для N= 103, 104, 105 и 106 .

16.22. Реализуйте операцию сортировать для таблицы символов на основе B-дерева.

16.23. Реализуйте операцию выбрать для таблицы символов на основе B-дерева.

16.24. Реализуйте операцию удалить для таблицы символов на основе B-дерева.

16.25. Реализуйте операцию удалить для таблицы символов на основе B-дерева, используя простой метод, когда указанный элемент удаляется из внешней страницы (возможно, количество элементов в этой странице станет меньше M/2), но изменение не распространяется вверх по дереву, за исключением, возможно, коррекции значений ключей, если удаленный элемент был наименьшим в своей странице.

16.26. Измените программы 16.2 и 16.3, чтобы внутри узлов применялся бинарный поиск (см. программу 12.6). Определите значение M, которое минимизирует время, затрачиваемое программой на построение таблицы символов вставками N элементов со случайными ключами в первоначально пустую таблицу, при N= 103, 104, 105 и 106 . Сравните полученные значения времени с соответствующими значениями для RB-деревьев (программа 13.6).

Расширяемое хеширование

Альтернатива B-деревьям, распространяющая применение алгоритмов поразрядного поиска на внешний поиск, была разработана в 1978 г. Фагином (Fagin), Нивергельтом (Nievergelt), Пиппенгером (Pippenger) и Стронгом (Strong). Их метод, названный расширяемым хешированием (extendible hashing), приводит к реализации операции найти, требующей в типичных приложениях всего одной-двух проб. Для соответствующей реализации операции вставить также (почти всегда) нужны лишь одна-две пробы.

Расширяемое хеширование сочетает свойства методов хеширования, алгоритмов на основе многопутевых trie-деревьев и методов последовательного доступа. Подобно методам хеширования, описанным в лекция №14, расширяемое хеширование представляет собой рандомизированный алгоритм — поэтому сначала необходимо определить хеш-функцию, которая преобразует ключи в целые числа (см. раздел 14.1 лекция №14). Для простоты в этом разделе мы будем просто считать, что ключи являются случайными битовыми строками фиксированной длины. Подобно алгоритмам с использованием многопутевых trie-деревьев из лекция №15, расширяемое хеширование начинает поиск, используя ведущие разряды ключей в качестве индексных указателей в таблице, размер которой равен степени 2. Подобно алгоритмам на основе B-деревьев, при использовании расширяемого хеширования элементы хранятся в страницах, которые при заполнении разбиваются на две части. Подобно методам индексно-последовательного доступа, расширяемое хеширование поддерживает каталог, указывающий, где можно найти страницу, в которой находятся соответствующие искомому ключу элементы. Сочетая эти знакомые свойства в одном алгоритме, расширяемое хеширование как нельзя более подходит для завершения знакомства с алгоритмами поиска.

Предположим, что количество доступных страниц диска является степенью 2 — скажем, 2d. Тогда можно поддерживать каталог 2d различных ссылок на страницы, использовать d разрядов ключей для индексного доступа к этому каталогу и хранить в одной и той же странице все ключи, первые к разрядов которых совпадают (см. рис. 16.10). Как и в случае B-деревьев, элементы на страницах хранятся упорядоченными, и, найдя страницу, которая соответствует элементу с заданным искомым ключом, мы выполняем в ней последовательный поиск.

На рис. 16.10 приведены две базовых концепции, лежащие в основе расширяемого хеширования. Во-первых, совсем не обязательно поддерживать 2d страниц. То есть можно иметь несколько записей каталога со ссылками на одну и ту же страницу, объединив на одной странице ключи с различными значениями, первые d разрядов которых совпадают.

 Индексы страниц каталога


Рис. 16.10.  Индексы страниц каталога

Каталог, состоящий из восьми записей, позволяет содержать до 40 ключей, храня все записи с первыми 3 совпадающими разрядами на одной странице, обратиться к которой можно через ссылку, хранящуюся в каталоге (слева). Запись 0 каталога содержит ссылку на страницу, которая содержит все ключи, начинающиеся с 000; запись 1 таблицы содержит ссылку на страницу, которая содержит все ключи, начинающиеся с 001; запись 2 таблицы содержит ссылку на страницу, которая содержит все ключи, начинающиеся с 010, и т.д. Если некоторые страницы заполнены не полностью, количество требуемых страниц можно уменьшить, используя несколько ссылок на одну и ту же страницу. В данном примере (слева) ключ 3 73 находится на той же странице, что и ключи, начинающиеся с 2; эта страница определена как содержащая элементы с ключами, первые два разряда которых равны 01.

Если удвоить размер каталога и скопировать каждую ссылку, то получим структуру, которую можно индексировать первыми 4 разрядами искомого ключа (справа). Например, последняя страница по-прежнему определяется как содержащая элементы с ключами, первые три разряда которых равны 111, и она будет доступна через каталог для искомых ключей с первыми 4 разрядами 1110 или 1111. Этот больший каталог допускает увеличение таблицы.

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

В частности, структура данных, которая применяется для расширяемого хеширования, значительно проще используемой в B-деревьях. Она состоит из страниц, содержащих до M элементов, и каталога с 2d ссылками на страницы (см. программу 16.5). Ссылка в ячейке каталога x указывает на страницу, содержащую все элементы с ведущими d разрядами, равными х. Значение d выбирается достаточно большим, чтобы в каждой странице гарантированно хранилось менее M элементов. Реализация операции найти проста: мы используем ведущие d разрядов ключа для индексного доступа к каталогу, что обеспечивает доступ к странице, которая содержит все элементы с соответствующими ключами, а затем выполняем последовательный поиск такого элемента на данной странице (см. программу 16.6).

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

Программа 16.5. Структуры данных для расширяемого хеширования

Расширяемая хеш-таблица — это каталог ссылок на страницы (подобно внешним узлам в B-деревьях), которые содержат до 2M элементов. Каждая страница содержит также счетчик (m) количества элементов в странице и целое число (k), задающее количество ведущих разрядов, которые должны совпадать в ключах элементов. Как обычно, N — количество элементов в таблице. Переменная d задает количество разрядов, которые используются для индексации в каталоге, а D — количество записей в каталоге; таким образом, D = 2d . Вначале таблица создается соответствующей каталогу размером 1, со ссылкой на пустую страницу.

template <class Item, class Key>
class ST
  { private:
      struct node
        { int m; Item b[M]; int k;
          node() { m = 0; k = 0; }
        } ;
      typedef node *link;
      link* dir;
      Item nullItem;
      int N, d, D;
     public:
       ST(int maxN)
         { N = 0; d = 0; D = 1;
           dir = new link[D];
           dir[0] = new node;
         }
  } ;
      

Программа 16.6. Поиск в таблице расширяемого хеширования

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

  private:
    Item search(link h, Key v)
    { for (int j = 0; j < h->m; j++)
      if (v == h->b[j].key()) return h->b[j];
      return nullItem;
    }
  public:
    Item search(Key v)
      { return search(dir[bits(v, 0, d)], v); }
      

Например, в примере, приведенном на рис. 16.10, нельзя использовать d = 2, поскольку некоторые страницы окажутся переполненными, и нельзя использовать d = 5, поскольку слишком много страниц будут пустыми. Как обычно, наибольший интерес вызывает поддержка операции вставить для АТД таблицы символов, чтобы, например, структура могла постепенно разрастаться по мере выполнения ряда чередующихся операций найти и вставить. Принятие такой точки зрения соответствует уточнению первого вопроса:

Например, в примере на рис. 16.10 нельзя вставить элемент, ключ которого начинается с 5 или 7, поскольку соответствующие страницы заполнены.

Определение 16.3. Расширяемая хеш-таблица порядка d представляет собой каталог из 2d ссылок на страницы, содержащие до M элементов с ключами. Первые к разрядов элементов на каждой странице совпадают, а каталог содержит 2d-k указателей на страницы, начинающихся с разрядов, равных ведущим к разрядам ключей в этих страницах.

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

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

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

На рис. 16.13—16.13 показано построение расширяемой хеш-таблицы для набора 25 восьмеричных ключей, рассматриваемого в этой главе. Как и в B-деревьях, большинство вставок не приводят к переполнению: они просто добавляют ключ в страницу. Поскольку процесс начинается с одной страницы, а завершается восемью, можно сделать вывод, что семь вставок вызвали разбиение страниц. Поскольку в начале размер каталога равен 1, а в конце — 16, можно сделать вывод, что четыре вставки привели к разбиению каталога.

Лемма 16.4. Расширяемая хеш-таблица, построенная из набора ключей, зависит только от значений этих ключей и не зависит от порядка их вставки.

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

 Построение расширяемой хеш-таблицы, часть 1


Рис. 16.11.  Построение расширяемой хеш-таблицы, часть 1

Как и в B-деревьях, первые пять вставок в расширяемую хеш-таблицу приходятся на одну страницу (слева). Затем, при вставке ключа 773, выполняется разбиение на две страницы (одна содержит все ключи, начинающиеся с бита 0, другая — все ключи, начинающиеся с бита 1), а размер каталога удваивается, чтобы он содержал по одному указателю на каждую из страниц (в центре). Ключ 742 вставляется в нижнюю страницу (т.к. он начинается с бита 1), а ключ 373 — в верхнюю страницу (т.к. он начинается с бита 0), но затем нижнюю страницу приходится разбить, чтобы было куда поместить ключ 52 4. Для выполнения этого разбиения все элементы, ключи которых начинаются с битов 10, помещаются на одну страницу, а все элементы, ключи которых начинаются с битов 11 — на другую, и размер каталога снова удваивается, чтобы в нем могли поместиться указатели на обе эти страницы (справа). Каталог содержит две ссылки на страницу, содержащую элементы с ключами, которые начинаются с бита 0: одна для ключей, которые начинаются с битов 00, и другая для ключей, которые начинаются с битов 01.

Программа 16.7 является реализацией операции вставить для расширяемой хеш-таблицы. Сначала, как и при поиске, с помощью единственного обращения к каталогу мы находим страницу, которая может содержать искомый ключ. Затем мы вставляем в нее новый элемент, как это делалось для внешних узлов в B-деревьях (см. программу 16.2). Если в результате этой вставки в узле оказывается M элементов, вызывается функция разбиения — опять же, как и для B-деревьев, правда, на этот раз она сложнее. Каждая страница содержит число к ведущих разрядов, которые совпадают в ключах всех элементов на этой странице, и, поскольку разряды нумеруются слева направо, начиная с 0, к задает также индекс разряда, который необходимо проверить для определения способа разбиения элементов.

 Построение расширяемой хеш-таблицы, часть 2


Рис. 16.12.  Построение расширяемой хеш-таблицы, часть 2

Ключи 766 и 275 вставляются в крайнее правое B-дерево, показанное на рис. 16.11, без разбиения узлов (слева). Затем, при вставке ключа 737, нижняя страницаразбивается, и, поскольку существует только одна ссылка на нижнюю страницу, это приводит кразбиению каталога (в центре). Затем выполняется вставка ключей 574, 434, 641 и 207, после чего необходимо разбиение верхней страницы (справа).

 Построение расширяемой хеш-таблицы, часть 3


Рис. 16.13.  Построение расширяемой хеш-таблицы, часть 3

Продолжая пример, приведенный на рис. 16.11 и 16.12, мы вставляем 5 ключей 526, 562, 017, 107 и 147 в крайнее правое B-дерево, показанное на рис. 16.6. Разбиение узлов происходит при вставке ключей 526 (слева) и 107 (справа).

Программа 16.7. Вставка в расширяемую хеш-таблицу

Чтобы вставить элемент в расширяемую хеш-таблицу, мы выполняем его поиск, а затем вставляем элемент в найденную страницу (и разбиваем ее, если вставка вызывает переполнение). Общая схема не отличается от используемой для B-деревьев, но алгоритмы поиска и разбиения иные. Функция разбиения создает новый узел, затем проверяет к-й бит (считая слева) ключа каждого элемента: если этот бит равен 0, элемент остается в старом узле; если 1 — перемещается в новый. В поле " количество одинаковых ведущих разрядов " обоих узлов после разбиения заносится значение к + 1. Если этот процесс не приводит к наличию по меньшей мере одного ключа в каждом узле, разбиение выполняется снова, пока элементы не станут разделены. И, наконец, мы вставляем ссылку на новый узел в каталог.

  private:
    void split(link h)
      { link t = new node;
        while (h->m == 0 || h->m == M)
          { h->m = t->m = 0; 
            for (int j = 0; j < M; j++)
              if (bits(h->b[j].key(), h->k, 1) == 0)
                h->b[h->m++] = h->b[j];
            else
              t->b[t->m++] = h->b[j];
            t->k = ++(h->k);
          }
        insertDIR(t, t->k);
      }
    void insert(link h, Item x)
    { int j; Key v = x.key();
      for (j = 0; j < h->m; j++)
        if (v < h->b[j].key()) break;
      for (int i = (h->m)++; i > j; i—-)
        h->b[i] = h->b[i-1];
      h->b[j] = x;
      if (h->m == M) split(h);
    }
  public:
    void insert(Item x)
      { insert(dir[bits(x.key(), 0, d)], x); }
      

Следовательно, для разбиения страницы мы создаем новую страницу, затем помещаем все элементы, для которых данный разряд равен 0, в старую страницу, а все элементы, для которых он равен 1 — в новую страницу, а затем для обеих страниц устанавливаем значение счетчика разрядов равным к + 1. Вообще-то возможен случай, когда в полном узле все ключи имеют одинаковое значение разряда к. В этом случае нужно просто перейти к следующему разряду и продолжать этот процесс до тех пор, пока каждая страница не будет содержать по меньшей мере один элемент. Однажды этот процесс должен завершиться, если только не имеется M значений одного и того же ключа. Вскоре мы рассмотрим и этот случай.

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

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

Если более M элементов имеют одинаковые ключи, таблица переполняется, и программа 16.7 зацикливается, пытаясь различить эти ключи. Есть еще одна похожая проблема: каталог может оказаться неоправданно большим, если ключи имеют очень много совпадающих ведущих разрядов. Эта ситуация похожа на излишнее время, требуемое для выполнения MSD-сортировки файлов с большим количеством одинаковых ключей или длинными последовательностями разрядов, в которых они совпадают. Решение этих проблем зависит от рандомизации, обеспечиваемой хеш-функцией (см. упражнение 16.43). Но даже при использовании хеширования в случае большого количества одинаковых ключей приходится предпринимать специальные действия, поскольку хеш-функции отображают равные ключи в равные хеш-значения. Повторяющиеся ключи могут сделать каталог неестественно большим, а при наличии большего количества равных ключей, чем помещается на одной странице, алгоритм просто перестает работать. Следовательно, при использовании этого кода необходимо добавить проверки, предотвращающие возникновение таких условий (см. упражнение 16.35).

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

Программа 16.8. Вставка в каталог расширяемого хеширования

Этот с виду простой код лежит в основе процесса расширяемого хеширования. Вначале имеется ссылка t на узел, содержащий элементы с совпадающими первыми к разрядами, которую нужно вставить в каталог. В простейшем случае, если d и k равны, достаточно просто записать t в d[x], где x — значение первых d разрядов t->b[0] (и всех остальных элементов на странице). Если k больше d, размер каталога нужно удваивать, пока d и k не станут равны. Если k меньше d, необходимо записать более одной ссылки — в первом цикле for вычисляется количество необходимых ссылок ( 2d-k ), а во втором выполняется собственно установка.

  void insertDIR(link t, int k)
    { int i, m, x = bits(t->b[0].key(), 0, k);
      while (d < k)
        { link *old = dir;
          d += 1; D += D;
          dir = new link[D];
          for (i = 0; i < D; i++) dir[i] = old[i/2];
          if (d < k) dir[bits(x, 0, d)A1] = new node;
        }
      for (m = 1; k < d; k++) m *= 2;
      for (i = 0; i < m; i++) dir[x*m+i] = t;
    }
      

Лемма 16.5. Для реализации расширяемого хеширования в случае файла, содержащего N элементов, и страниц, которые могут содержать M элементов, в среднем требуется около 1,44 N/M страниц. Ожидаемое количество записей в каталоге приблизительно равно 3,92(N1/M/M)(N/M) .

Этот (достаточно глубокий) результат дополняет анализ trie-деревьев, кратко рассмотренный в предыдущей главе (см. раздел ссылок). Точные значения констант для количества страниц и размера каталога соответственно равны lg e = 1/ln 2 и e lg e = e / ln 2, хотя точные значения величин колеблются вокруг указанных средних значений. Это неудивительно, поскольку, например, нужно принимать во внимание, что размер каталога должен являться степенью 2.

Обратите внимание, что размер каталога возрастает быстрее чем линейно относительно N, особенно при малых M. Однако для значений N и M, встречающихся на практике, значение N1/M достаточно близко к 1, поэтому реально можно ожидать, что каталог будет иметь около 4 N/M записей.

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

Как и в случае B-деревьев, реализация всех остальных операций таблицы символов оставлена в качестве упражнений (см. упражнения 16.38—16.41). И так же, как и в случае B-деревьев, правильная реализация операции удалить представляет собой сложную задачу, но можно разрешить наличие мало заполненных страниц — эта легко реализуемая альтернатива может оказаться эффективной во многих возникающих на практике ситуациях.

Упражнения

16.27. Сколько страниц будут пусыми, если на рис. 16.10 использовать каталог, размер которого равен 32?

16.28. Нарисуйте рисунки наподобие рис. 16.13, иллюстрирующие процесс вставки ключей 562, 221, 240, 771, 274, 233, 401, 273 и 201 в указанном порядке в первоначально пустое дерево, при M= 5.

16.29. Нарисуйте рисунки наподобие рис. 16.13, иллюстрирующие процесс вставки ключей 562, 221, 240, 771, 274, 233, 401, 273 и 201 в указанном порядке в первоначально пустое дерево, при M= 3.

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

16.31. Напишите программу, которая строит расширяемую хеш-таблицу из массива упорядоченных элементов, выполняя два прохода по элементам: один для определения размера каталога (см. упражнение 16.30) и второй — для распределения элементов по страницам и заполнения каталога.

16.32. Приведите набор ключей, для которого соответствующая расширяемая хеш-таблица имеет каталог размером 16, если в одной странице помещается 8 ссылок.

16.33. Создайте рисунок наподобие рис. 16.8 для расширяемого хеширования.

16.34. Напишите программу для вычисления среднего количества внешних страниц и среднего размера каталога для расширяемой хеш-таблицы, построенной N случайными вставками в первоначально пустое дерево, при емкости страницы M. Вычислите процент неиспользуемой памяти при M= 10, 100 и 1000 и N= 103, 104, 105 и 106 .

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

16.36. Измените реализацию расширяемого хеширования, приведенную в программах 16.5—16.7, так, чтобы в ней использовался двухуровневый каталог, в каждом узле которого содержится не более M указателей. Обратите особое внимание на выбор действий, когда каталог впервые разрастается с одного до двух уровней.

16.37. Измените реализацию расширяемого хеширования, приведенную в программах 16.5—16.7, чтобы в структуре данных в одной странице могло находиться M элементов.

16.38. Реализуйте операцию сортировать для расширяемой хеш-таблицы.

16.39. Реализуйте операцию выбрать для расширяемой хеш-таблицы.

16.40. Реализуйте операцию удалить для расширяемой хеш-таблицы.

16.41. Реализуйте операцию удалить для расширяемой хеш-таблицы, используя метод, описанный в упражнении 16.25.

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

16.43. Экспериментально определите количество случайных чисел, которые будут сгенерированы, прежде чем встретятся более M чисел с одинаковыми d начальными разрядами, при M= 10, 100 и 1000 и при .

16.44. Измените алгоритм хеширования с цепочками переполнения (программа 14.3), чтобы в нем использовалась хеш-таблица размером 2M и элементы хранились в страницах размером 2M. Другими словами, когда страница заполняется, к ней привязывается новая пустая страница, чтобы каждая запись хеш-таблицы указывала на связный список страниц. Экспериментально определите среднее количество проб, необходимых для выполнения поиска после построения таблицы из N элементов со случайными ключами, при M= 10, 100 и 1000 и N= 103, 104, 105 и 106 .

16.45. Измените алгоритм двойного хеширования (программа 14.6), чтобы в нем использовались страницы размером 2M, а обращения к полным страницам интерпретировались как " коллизии " . Экспериментально определите среднее количество проб, необходимое для выполнения поиска после построения таблицы из N элементов со случайными ключами, при M= 10, 100 и 1000 и N= 103, 104, 105 и 106 , при начальном размере таблицы равном 3N/2M.

Перспективы

Наиболее важное применение рассмотренных в этой главе методов — это построение индексов для очень больших баз данных, хранимых во внешней памяти, например, в дисковых файлах. Хотя рассмотренные фундаментальные алгоритмы обладают большими возможностями, разработка реализации файловой системы, основанной на использовании B-деревьев или расширяемого хеширования, представляет собой сложную задачу. Во-первых, приведенные в этом разделе С++-программы нельзя использовать непосредственно — они должны быть модифицированы для считывания и обращения к дисковым файлам. Во-вторых, необходимо быть уверенным, что параметры алгоритма (например, размеры страницы и каталога) подобраны с учетом характеристик конкретного используемого аппаратного обеспечения. В-третьих, следует уделить внимание надежности, а также выявлению и исправлению ошибок. Например, необходимо иметь возможность убедиться в целостности структуры данных и принять решение о том, как исправить любую из множества возможных ошибок. Подобные системные факторы являются критичными и выходят за рамки этой книги.

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

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

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

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

Упражнения

16.46. Разработайте реализацию таблицы символов с использованием B-деревьев, которая включает в себя деструктор, конструктор копирования и перегруженную операцию присваивания и поддерживает операции создать, подсчитать, найти, вставить, удалить и объединить для АТД таблицы символов с поддержкой клиентских дескрипторов (см. упражнения 12.6 и 12.7).

16.47. Разработайте реализацию таблицы символов с использованием расширяемого хеширования, которая включает в себя деструктор, конструктор копирования и перегруженную операцию присваивания и поддерживает операции создать, подсчитать, найти, вставить, удалить и объединить для АТД таблицы символов с поддержкой клиентских дескрипторов (см. упражнения 12.6 и 12.7).

16.48. Измените реализацию B-дерева, приведенную в разделе 16.3 (программы 16.1—16.3), чтобы в ней для обращений к страницам использовался абстрактный тип данных.

16.49. Измените реализацию расширяемого хеширования, приведенную в разделе 16.4 (программы 16.5—16.8), чтобы в ней для обращений к страницам использовался абстрактный тип данных.

16.50. Оцените среднее количество проб, затрачиваемое на каждый поиск в B-дереве, при выполнении S случайных поисков в типичной кэш-системе, хранящей в памяти T страниц, к которым выполнялись последние обращения (т.е. они не увеличивают значение счетчика проб). Считайте, что S значительно больше T.

16.51. Оцените среднее количество проб на каждый поиск в расширяемой хеш-таблице для модели кэш-памяти, описанной в упражнении 16.50.

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

16.53. Реализуйте АТД очереди с приоритетами, поддерживающий операцию создать для очень большого количества элементов, вслед за которой выполняется очень много операций вставить и удалить наибольший (см. лекция №9

16.54. Разработайте АТД внешней таблицы символов, основанной на представлении B-деревьев в виде слоеного списка (см. упражнение 13.80).

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

16.56. Измените реализацию B-дерева, приведенную в разделе 16.3 (программы 16.1—16.3), чтобы она работала в среде, в которой таблица размещается на внешнем запоминающем устройстве. Если применяемая файловая система допускает произвольный доступ к файлам, поместите всю таблицу в один (очень большой) файл, и в структуре данных вместо ссылок используйте смещения внутри файла. Если система допускает непосредственное обращение к страницам на внешних устройствах, то в структуре данных вместо ссылок используйте адреса страниц. Если система допускает оба вида доступа, выберите подход, который, по вашему мнению, наиболее подходит для реализации очень большой таблицы символов.

16.57. Измените реализацию таблицы расширяемого хеширования, приведенную в разделе 16.4 (программы 16.5—16.8), чтобы она работала в среде, в которой таблица размещается на внешнем запоминающем устройстве. Объясните причины выбранного вами способа распределения каталога и страниц по файлам (см. упражнение 16.56).

Ссылки для части IV

Основные источники для этого раздела — книги Кнута (Knuth); Баеса-Ятеса (Baeza-Yates) и Гонне (Gonnet); Мельхорна (Mehlhorn); и Кормена (Cormen), Ляйзерзона (Leiserson) и Райвеста (Rivest). В этих книгах подробно рассматриваются многие из приведенных в этой части алгоритмов, вместе с математическим анализом и предложениями по практическому применению. Классические методы подробно изложены в книге Кнута; более поздние методы описаны в остальных книгах, в них же приводятся ссылки на другую литературу. В этих четырех источниках, а также в книге Седжвика (Sedgewick) и Флажоле (Flajolet) описан практически весь материал, который упоминался как "выходящий за рамки этой книги".

Материал, приведенный в лекция №13, взят из статьи Роура (Roura) и Мартинеса (Martinez) за 1996 г., статьи Слитора (Sleator) и Тарьяна (Tarjan) за 1985 г. и статьи Гиба (Guibas) и Седжвика за 1978 г. Как видно из дат публикации этих статей, исследование сбалансированных деревьев продолжается. Перечисленные источники содержат подробные доказательства свойств RB-деревьев и аналогичных им структур, а также ссылки на более современные работы.

Трактовка trie-деревьев, приведенная в лекция №15, является классической (хотя в литературе редко можно встретить реализации на языке C++). Материал по TST-деревьям взят из статьи Бентли (Bentley) и Седжвика за 1997 г.

B-деревья впервые рассматриваются в статье Байера (Bayer) и Мак-Крейта (McCreight) за 1972 г., а алгоритм расширяемого хеширования, представленный в главе 16, взят из статьи Фагина (Fagin), Нивергельта (Nievergelt), Пиппенгера (Pippenger) и Стронга (Strong), опубликованной в 1979 г. Аналитические результаты по расширяемому хешированию были получены Флажоле в 1983 г. С этими статьями следует обязательно ознакомиться всем, кто желает получить более подробную информацию по методам внешнего поиска. Практическое применение этих методов обусловлено распространенностью систем управления базами данных. С введением в эту область можно ознакомиться, например, в книге Дейта (Date).

1. R. Baeza-Yates and G. H. Gonnet, НаМЬоок of Algorithms and Data Structures, second edition, Addison-Wesley, Reading, MA, 1984.

2. J. L. Bentley and R. Sedgewick, "Sorting and searching strings," Eighth Symposium on Discrete Algorithms, New Orleans, January, 1997.

3. R. Bayer and E. M. McCreight, "Organization and maintenance of large ordered indexes," Acta Informatica 1, 1972.

4. Томас Х. Кормен, Чарльз И. Лейзерсон, Рональд Л. Ривест, Клиффорд Штайн, Алгоритмы: построение и анализ, 2-е издание, ИД "Вильямс", 2009 г.

5. К. Дж. Дейт, Введение в системы баз данных, 7-е издание, ИД "Вильямс", 2001 г.

6. R. Fagin, J. Nievergelt, N. Pippenger and H. R. Strong, "Extendible hashing—a fast access method for dynamic files," ACM Transactions on Database Systems 4, 1979. 7. P Flajolet, "On the performance analysis of extendible hashing and trie search," Acta Informatica 20, 1983.

8. L. Guibas and R. Sedgewick, "A dichromatic framework for balanced trees," in 19th Annual Symposium on Foundations of Computer Science, IEEE, 1978. Также в A Decade of Progress 1970-1980, Xerox PARC, Palo Alto, CA.

9. Д.Э. Кнут, Искусство программирования, том 3. Сортировка и поиск, 2-е издание, ИД "Вильямс", 2008 г.

10. K. Mehlhorn, Data Structures and Algorithms 1: Sorting and Searching, Spiinger-Vferlag, Berlin, 1984.

11. S. Roura and C. Martinez, "Randomization of search trees by subtree size," Fourth European Symposium on Algorithms, Barcelona, September, 1996.

12. R. Sedgewick and P Flajolet, An Introduction to the Analysis of Algorithms, Addison-Wesley, Reading, MA, 1996.

13. D. Sleator and R. E. Tarjan, "Self-adjusting binary search trees," Journal of the ACM 32, 1985.

Глава 5. Алгоритмы на графах

Лекция 17. Виды графов и их свойства

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

Для моделирования таких ситуаций мы будем пользоваться объектами, которые называются графами (graph). В данной главе мы подробно рассмотрим основные свойства графов и заложим основу для изучения всевозможных алгоритмов, которые помогут ответить на вопросы, подобные сформулированным выше. Эти алгоритмы часто используют различные вычислительные средства, рассмотренные в частях I—IV. Они также служат тем фундаментом, без которого невозможно подступиться ко многим важным задачам, и решение которых нельзя представить без привлечения солидной алгоритмической технологии.

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

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

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

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

Нам уже приходилось сталкиваться с графами в части I. Ведь самые первые рассмотренные нами алгоритмы -алгоритмы объединения-поиска, описанные в лекция №1 -представляют собой простейшие алгоритмы на графах. В лекция №3 графы послужили иллюстрацией применений двумерных массивов и связных списков, а в лекция №5 графы применялись для демонстрации связи между рекурсивными программами и фундаментальными структурами данных. Любую связную структуру данных можно представить в виде графа, а некоторые известные алгоритмы обработки деревьев и других связных структур представляют собой частные случаи алгоритмов на графах. Данная глава обеспечит контекст для изучения алгоритмов на графах -от простейших, приведенных в части I, до очень сложных, описываемых в лекциях 18—22.

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

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

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

Глоссарий

С графами связана обширная терминология. Большинство употребляемых терминов имеют простые определения, и для удобства ссылок лучше рассмотреть их в каком-то одном месте -а именно, здесь. Некоторые из этих понятий мы уже употребляли при изучении базовых алгоритмов в лекция №1, а другие понадобятся лишь при изучении сложных алгоритмов в лекциях 18—22.

Определение 17.1. Граф (graph) -это некоторое множество вершин (vertex) и некоторое множество ребер (edge), соединяющих пары различных вершин (одну пару вершин может соединять максимум одно ребро).

Если граф состоит из V вершин, то мы будем помечать их числами от 0 до V-1. Основная причина выбора этой системы обозначений заключается в том, что она обеспечивает быстрый доступ к информации, соответствующей каждой вершине, путем индексирования векторов. В разделе 17.6 будет рассмотрена программа, которая использует таблицу символов для установления взаимно-однозначного соответствия V произвольных имен вершин с V целыми числами от 0 до V-1. Пользуясь этой программой, мы можем (для удобства обозначений) употреблять без потери общности индексы как имена вершин. Иногда мы будем предполагать, что множество вершин определено неявно, с помощью множества ребер, учитывая только те вершины, которые упомянуты хотя бы в одном ребре. Во избежание громоздких выражений наподобие " граф из 10 вершин со следующим набором ребер " , мы часто не будем указывать явно количество вершин, если оно понятно из контекста. Далее будем придерживаться соглашения: количество вершин в заданном графе всегда обозначается буквой V, а количество ребер -буквой E.

Мы примем определение 17.1 в качестве стандартного определения графа (мы уже сталкивались с ним в лекция №5), но учтите, что в нем использованы два технических упрощения. Во-первых, в нем запрещены одинаковые ребра (математики иногда называют такие ребра параллельными, а граф, который может содержать такие ребра -мультиграфом (multigraph)). Во-вторых, в нем запрещены ребра, соединяющие вершины с собой; такие ребра называются петлями (self-loop). Графы, в которых нет параллельных ребер или петель, иногда называют простыми графами (simple graph).

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

Лемма 17.1. Граф, состоящий из Vвершин, содержит не более V(V- 1)/2 ребер.

Доказательство. Общее количество возможных пар вершин равно V2, из них V петель, а ребра между различными вершинами учитываются дважды, следовательно, максимальное количество ребер не превосходит значения (V2 -V)/2 = V(V-1)/2 .

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

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

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

Математики употребляют термины вершина (vertex) и узел (node) как эквивалентные, но мы будем обычно использовать термин вершина при изучении графов и термин узел при обсуждении представлений графов -например, структур данных в C++. Как правило, мы полагаем, что вершина может иметь имя и другую связанную с ней информацию. Аналогично, для соединений двух вершин математиками широко используются слова дуга (arc), ребро (edge) и связь (link), однако мы всегда будем употреблять термин ребро при изучении графов и термин ссылка при обсуждении структур данных в C++.

Если имеется ребро, соединяющее две вершины, будем говорить, что обе эти вершины смежны (adjacent) друг с другом, а ребро инцидентно (incident) этим вершинам. Степень (degree) вершины -это количество ребер, инцидентных этой вершине. Ребро, соединяющее вершины v и w, мы будем обозначать v-w, либо эквивалентной записью w-v.

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

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

 Три различных представления одного и того же графа


Рис. 17.1.  Три различных представления одного и того же графа

Граф определяется его вершинами и ребрами, но не способом его графического изображения. Оба приведенных чертежа, а также список ребер (внизу) изображают один и тот же граф -при наличии информации, что рассматриваемый граф содержит 13 вершин, помеченных номерами от 0 до 12.

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

В некоторых приложениях -например, представляющих в виде графа географические карты или электрические схемы -чертеж графа может содержать существенную информацию, поскольку вершины соответствуют точкам на плоскости, а расстояния между ними должны быть выдержаны в определенном масштабе. Такие графы называются евклидовыми (Euclidean graph). Во множестве других приложений графы могут представлять зависимости или расписания событий, и тогда они просто содержат информацию о связности, не предъявляя никаких требований к геометрическому расположению вершин. Мы рассмотрим примеры алгоритмов, которые используют геометрическую информацию евклидовых графов, в лекция №20 и 21, но в основном мы будем работать с алгоритмами, которые вообще не используют геометрическую информацию. Еще раз подчеркиваем: обычно графы не зависят от конкретного представления в виде чертежа или данных в компьютере.

Если сосредоточиться только на связях, то метки вершин можно считать просто удобными обозначениями, а два графа считать одинаковыми, если они отличаются друг от друга только метками вершин. Два графа называются изоморфными (isomorphic), если можно поменять метки вершин в одном из них так, чтобы множество ребер этого графа стало эквивалентным множеству ребер другого графа. Определение изоморфизма двух графов представляет собой сложную вычислительную задачу (см. рис. 17.2 и упражнение 17.5). Ее сложность объясняется тем, что существует V! способов обозначения вершин -слишком много, чтобы перепробовать все возможности.

 Примеры изоморфизма графов


Рис. 17.2.  Примеры изоморфизма графов

Два верхних графа изоморфны, поскольку можно переобозначить вершины таким образом, что оба множества ребер станут идентичными (чтобы сделать граф в центре таким же, как и верхний граф, нужно поменять 10 на 4, 7 на 3, 2 на 5, 3 на 1, 12 на 0, 5 на 2, 9 на 11, 0 на 12, 11 на 9, 1 на 7 и 4 на 10). Нижний граф не изоморфен двум другим, поскольку не существует такого способа переименования его вершин, чтобы множество его ребер стало идентично множествам ребер двух первых графов.

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

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

Определение 17.2. Путь (path) в графе -это последовательность вершин, в которой каждая следующая вершина (после первой), является смежной с предыдущей вершиной на этом пути. Простой путь (simple path) -это путь, все вершины и ребра, составляющие, различны. Циклом (cycle) называется путь, у которого первая и последняя вершина одна и та же.

Иногда используется термин циклический путь (cyclic path) -для обозначения пути, у которого совпадают первая и последняя вершина (но который в других отношениях не обязательно является простым); а термин контур (tour) употребляется для циклического пути, который включает каждую вершину. Путь можно определить и как последовательность ребер, которая соединяет соседние вершины. Мы подчеркиваем это в наших обозначениях, соединяя имена вершин в пути так же, как и в обозначениях ребер. Например, на рис. 17.1 имеются простые пути 3-4-6-0-2 и 9-11-12 и циклы 0-6-4-3-5-0 и 5-4-3-5. Длина (length) пути или цикла определяется как количество составляющих их ребер.

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

Два простых пути называются непересекающимися (disjoint), если они не содержат общих вершин, кроме, возможно, концевых. Это условие несколько слабее, чем требование полного отсутствия в обоих путях общих вершин, и оно более полезно, поскольку в этом случае мы можем соединить простые непересекающиеся пути из s в t и из t в u и получить простой непересекающийся путь из s в u, если вершины s и u различны (и получить цикл, если s и u совпадают). Иногда используется термин непересекающиеся по вершинам (vertex disjoint), чтобы отличить эту ситуацию от более сильного условия непе-ресекающихся по ребрам (edge disjoint) путей, когда пути не должны иметь общих ребер.

Определение 17.3. Граф называется связным графом (connected graph), если существует путь из каждой вершины в любую другую вершину графа. Не связный граф состоит из некоторого множества связных компонентов, которые представляют собой максимальные связные подграфы.

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

Определение 17.4. Ациклический связный граф называется деревом (tree) (см. лекция №5). (Ациклическим называется граф, в котором отсутствуют циклы -прим. перев.) Множество деревьев называется лесом (forest) или бором. Остовное дерево (spanning tree) связного графа -это подграф, который содержит все вершины этого графа и представляет собой единое дерево. Остовный лес (spanning forest) графа -это лес, который содержит все вершины этого графа.

Например, граф на рис. 17.1 с тремя связными компонентами может иметь остовный лес 7-8 9-10 9-11 9-12 0-1 0-2 0-5 5-3 5-4 4-6 (существует и множество других остовных лесов). На рис. 17.3 эти и некоторые другие свойства показаны на большем графе.

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

Любое из указанных выше условий необходимо и достаточно для доказательства остальных трех, и из них можно вывести другие свойства деревьев (см. упражнение 17.1). Формально следовало бы выбрать одно из этих условий в качестве определения; но неформально они могут служить определениями все вместе и свободно заменять, например, слова " ациклический связный граф " в определении 17.4.

Графы, у которых присутствуют все ребра (т.е. между каждыми двумя вершинами -прим. перев.), называются полными графами (complete graph) -см. рис. 17.4. Дополнение (complement) графа G определяется так: берется полный граф с тем же множеством вершин, что и исходный графа G, и из него удаляются все ребра графа G.

 Терминология, употребляемая в теории графов


Рис. 17.3.  Терминология, употребляемая в теории графов

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

 Полные графы


Рис. 17.4.  Полные графы

Здесь показаны полные графы, в которых каждая вершина соединена с любой другой вершиной. Они содержат, соответственно, 10, 15, 21, 28 и 36 ребер (снизу вверх). Каждый граф, содержащий от 5 до 9 вершин (существует более 68 миллиардов таких графов), является подграфом одного из этих графов.

Объединением (union) двух графов является граф, порожденный объединением множеств ребер этих графов. Объединение графа и его дополнения дает полный граф. Все графы, имеющие V вершин, являются подграфами полного графа с V вершинами. Общее количество различных графов с V вершинами равно 2<sup>V(V- 1)/2</sup> (количество различных подмножеств из V( V-1)/2 возможных ребер). Полный подграф называется кликой (clique).

Большинство встречающихся на практике графов содержат лишь небольшую часть всех возможных ребер. Для численного выражения этой концепции определим насыщенность (density) графа равной среднему значению степеней его вершин, т.е. 2E/V. Граф называется насыщенным (dense graph), если средняя степень его вершин близка к V; разреженный граф (sparse graph) есть граф, дополнение которого насыщенно. Другими словами, мы считаем граф насыщенным, если количество его ребер E имеет порядок V2, и разреженным в противном случае. Такое " асимптотическое " определение не очень точно характеризует графы, однако различие очевидно: можно с уверенностью утверждать, что граф, состоящий из миллионов вершин и десятков миллионов ребер, является разреженным, а граф, состоящий из нескольких тысяч вершин и миллионов ребер, является насыщенным. Можно браться за обработку разреженного графа с миллиардами вершин, но насыщенный граф с миллиардами вершин содержит несметное количество ребер.

Информация о том, с каким графом мы имеем дело -с насыщенным или разреженным -обычно является главным фактором выбора эффективного алгоритма обработки графа. Например, для решения какой-то задачи мы можем разработать два алгоритма, и первому из них для ее решения понадобится V2 шагов, а другому -E lgE шагов. Эти формулы показывают, что второй алгоритм лучше подходит для разреженных алгоритмов, а первый алгоритм следует применять для насыщенных графов. Например, насыщенный граф с миллионами ребер может иметь всего лишь несколько тысяч вершин: в этом случае величины V2 и E имеют один порядок, а быстродействие алгоритма сложности V2 в 20 раз выше, чем быстродействие алгоритма сложности ElgE. С другой стороны, разреженный граф с миллионами ребер будет иметь и миллионы вершин, следовательно, алгоритм сложности E lgE будет в миллионы раз быстрее алгоритма со сложностью V2. Подробное изучение этих формул может привести к различным вариантам, но для практических целей обычно вполне достаточно терминов разреженный и насыщенный, чтобы можно было получить представление об основных характеристиках производительности.

При анализе алгоритмов обработки графов мы полагаем, что значение V/E ограничено сверху небольшой константой, и поэтому такие выражения, как V(V+E), можно упростить до VE. Это предположение нарушается только если количество ребер гораздо меньше количества вершин, что бывает крайне редко. Как правило, количество ребер намного превосходит количество вершин (V/E намного меньше 1).

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

 Двудольный граф


Рис. 17.5.  Двудольный граф

Все ребра данного графа соединяют вершины с нечетными номерами с вершинами с четными номерами -т.е. это двудольный граф. Дву-дольность наглядно видна на нижней диаграмме.

Графы, которые мы рассматривали до сих пор, носят название неориентированных (undirected) графов. В ориентированных (directed) графах, известных еще как орграфы (digraph), ребра имеют направления: пара вершин, определяющая любое ребро, рассматривается как упорядоченная пара, которая определяет направленную смежность -в том смысле, что можно перейти из первой вершины во вторую, но не из второй вершины в первую. Многие приложения (например, графы, представляющие всемирную компьютерную сеть, или расписания действий, или телефонные звонки) естественным образом описываются как раз орграфами.

Ребра в орграфах называются ориентированными ребрами (directed edge), хотя обычно это свойство понятно из контекста (некоторые авторы называют ориентированные ребра дугами -arc). Первая вершина ориентированного ребра называется началом (source), а вторая вершина концом (destination). (Некоторые авторы употребляют, соответственно, термины хвост (tail) и голова (head), чтобы подчеркнуть, что это вершины ориентированного графа, однако мы будем избегать таких обозначений, поскольку употребляем эти же термины в реализациях структур данных.) На диаграммах ориентированные ребра изображаются в виде стрелок, направленных из начала в конец, и часто говорим, что ребро указывает (point) на конечную вершину. Обозначение w-v в орграфе означает, что это ребро, которое указывает из w на v, и оно отличается от ребра v-w, которое указывает из v на w. Мы также говорим о степени выхода (outdegree) и степени захода (indegree) вершины -это, соответственно, количество ребер, для которых она служит началом, и количество ребер, для которых она служит концом.

Иногда неориентированный граф удобнее рассматривать как орграф, у которого вместо каждого неориентированного ребра имеются два ориентированных ребра (по одному в каждом направлении); в других случаях неориентированный граф бывает лучше рассматривать просто как совокупность связей. Как правило, и для ориентированных, и для неориентированных графов мы используем одно и то же представление (см. рис. 17.6), о чем подробно пойдет речь в разделе 17.4. То есть обычно для неориентированных графов мы поддерживаем два представления каждого ребра -по одному для каждого направления -чтобы иметь возможность сразу же ответить на вопросы типа " Какие вершины связаны с вершиной v? "

Изучению структурных свойств орграфов посвящена лекция №19; обычно они более сложны, чем соответствующие свойства неориентированных графов. Направленный цикл (directed cycle) в орграфе -это цикл, в котором пары смежных вершин появляются в порядке, указываемом ребрами графа. DAG-граф (Directed Acyclic Graph -ориентированный ациклический граф) представляет собой орграф, который не содержит направленных циклов. DAG-граф (ациклический орграф) не эквивалентен дереву (ациклическому неориентированному графу). Иногда мы рассматриваем базовый неориентированный граф (underlying undirected graph) орграфа, то есть неориентированный граф, определяемый тем же множеством ребер, только эти ребра не рассматриваются как ориентированные.

Главы 20—22 посвящены в основном анализу алгоритмов решения различных вычислительных задач, связанных с использованием графов, в вершинах и ребрах которых хранится дополнительная информация. В случае взвешенного графа (weighted graph) с каждым ребром связано число (weight -вес), которое обычно интерпретируется как расстояние либо стоимость. Можно также присвоить вес каждой вершине, либо несколько весов каждой вершине и каждому ребру. В лекция №20 мы будем изучать взвешенные неориентированные графы, а в лекция №21 и лекция №22 -взвешенные орграфы, которые называют сетями (network). Алгоритмы, рассматриваемые в лекция №22, решают классические задачи, которые возникают при особой интерпретации сетей, известной как сетевые потоки (flow network).

 Два орграфа


Рис. 17.6.  Два орграфа

Верхний рисунок является представлением графа, приведенного на рис. 17.1, который интерпретируется как ориентированный граф; при этом ребра считаются упорядоченными парами и изображаются в виде стрелок, ведущих из первой вершины во вторую. Этот граф является также и DAG-графом. Нижний рисунок -это изображение неориентированного графа с рис. 17.1, которое обычно выбирается нами для представления неориентированных графов: в виде орграфа, в котором каждой связи соответствуют два ребра (по одному в каждом направлении).

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

Упражнения

17.1. Докажите, что любой ациклический связный граф с V вершинами имеет V-1 ребро.

17.2. Приведите все связные подграфы графа 0-1 0-2 0-3 1-3 2-3.

17.3. Составьте список неизоморфных циклов графа, представленного на рис. 17.1. Например, если в списке содержится цикл 3-4-5-3, то в нем не могут находиться циклы 3-5-4-3 , 4-5-3-4 , 4-3-5-4 , 5-3-4-5 или 5-4-3-5 .

17.4. Пусть дан граф 4-7 1-4 7-8 0-5 5-2 3-8 2-9 0-6 4-9 2-6 6-4

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

17.5.

Пусть даны графы, определяемые следующими четырьмя наборами ребер:

0-10-20-31-31-42-52-93-64-74-85-85-96-76-97-8

0-10-20-30-31-42-52-93-64-74-85-85-96-76-97-8

0-11-21-30-30-42-52-93-64-74-85-85-96-76-97-8

4-17-96-27-35-00-20-81-63-96-32-81-59-84-54-7

Какие из этих графов изоморфны друг другу? Какие из них планарны?

17.6. Какой процент из более чем 68 миллиардов графов, о которых говорится в подписи к рис. 17.4, состоит из менее чем девяти вершин?

17.7. Сколько различных подграфов имеется в заданном графе с V вершинами и E ребрами?

17.8. Приведите максимально точные верхние и нижние границы количества связных компонентов графа с V вершинами и E ребрами.

17.9. Сколько существует неориентированных графов, содержащих V вершин и E ребер?

17.10. Сколько существует различных графов, содержащих V вершин и E ребер, если считать графы различными, только когда они не изоморфны?

17.11. Сколько графов, содержащих V вершин, являются двудольными?

АТД графа

Для разработки алгоритмов обработки графов нам понадобится абстрактный тип данных (АТД, см. лекция №4), который позволит нам формулировать фундаментальные задачи. Программа 17.1 представляет собой интерфейс этого АТД, а базовые представления и реализации графа для этого АТД рассматриваются в разделах 17.3—17.5. Далее в этой книге всякий раз, когда мы будем встречаться с новыми задачами обработки графов, мы будем рассматривать алгоритмы их решения и реализации этих алгоритмов в контексте клиентских программ и абстрактных типов данных, которые обращаются к графам через этот интерфейс. Такая схема позволит решать разнообразные задачи обработки графов, от элементарного сопровождения до сложных решений трудных задач.

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

Программа 17.1. Интерфейс АТД графа

Этот интерфейс -отправная точка для реализации и тестирования алгоритмов на графах. Он определяет два типа данных: тривиальный тип Edge (ребро) с функцией конструктора, которая создает ребро из двух заданных вершин, и тип GRAPH, определенный в соответствии со стандартной методологией независимости интерфейса АТД от представления (см. лекция №4).

Конструктор GRAPH принимает два аргумента: целое число, задающее количество вершин, и логическое значение, указывающее, является ли граф ориентированным или неориентированным (орграф) -по умолчанию граф неориентированный.

Базовые операции, необходимые для обработки графов и орграфов -функции АТД для их создания и уничтожения, подсчета количества вершин и ребер, а также добавления и удаления ребер. Класс итератора adjlterator позволяет клиентам выполнить обработку всех вершин, смежных с заданной. Его использование демонстрируется в программах 17.2 и 17.3.

    struct Edge
      { int v, w;
        Edge(int v = -1, int w = -1) : v(v), w(w) { }
      };
    class GRAPH
      { private:
        // Код, зависящий от реализации
      public:
          GRAPH(int, bool);
        ~GRAPH();
        int V() const;
        int E() const;
        bool directed() const;
        int insert(Edge);
        int remove(Edge);
        bool edge(int, int);
        class adjIterator
          { public:
            adjIterator(const GRAPH &, int);
            int beg();
            int nxt();
            bool end();
          };
        };
      

АТД в программе 17.1 -это просто средство, которое позволяет разрабатывать и тестировать алгоритмы, но не универсальный интерфейс. Как обычно, мы работаем с простейшим интерфейсом, поддерживающим базовые операции обработки графов, которые нам могут понадобиться. Определение такого интерфейса для использования в практических приложениях требует тщательно продуманного компромисса между простотой, эффективностью и универсальностью. Ниже мы рассмотрим несколько таких компромиссов; а далее в этой книге будут рассмотрены и многие другие, которые мы будем анализировать в контексте конкретных реализаций и приложений.

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

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

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

Программа 17.2 содержит функцию, которая демонстрирует применение класса итератора из АТД графа. Эта функция извлекает из заданного графа множество его ребер и возвращает их в переменной типа vector из библиотеки STL (Standard Template Library -стандартная библиотека шаблонов) C++. По сути граф есть просто множество ребер, и довольно часто требуется получить граф именно в таком виде, независимо от его внутреннего представления.

Программа 17.2. Пример клиентской функции обработки графов

Данная функция демонстрирует один из способов использования АТД графа для реализации базовой операции обработки графов, не зависимой от представления. Она возвращает все ребра графа в виде вектора.

Эта реализация служит иллюстрацией основного способа работы большинства программ, которые мы будем рассматривать: для перебора всех ребер графа перебираются все вершины, смежные с каждой вершиной этого графа. Функции beg, end и nxt обычно не вызываются никаким другим способом, кроме продемонстрированного в этой программе -это позволяет лучше оценить характеристики производительности наших реализаций (см. раздел 17.5).

  template <class Graph>
  vector <Edge> edges(Graph &G)
    { int E = 0;
      vector <Edge> a(G.E());
      for (int v = 0; v < G.V(); v++)
        { typename Graph::adjIterator A(G, v);
          for (int w = A.beg(); !A.end(); w = A.nxt())
            if (G.directed() || v < w)
              a[E++] = Edge(v, w);
        }
      return a;
    }
      

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

Программа 17.3 -другой пример использования класса итератора из АТД графа, она предназначена для вывода таблицы вершин, смежных с каждой вершиной (см. рис. 17.7 рис. 17.7). Коды этих двух примеров довольно похожи, как похожи и многие другие программные реализации многочисленных алгоритмов обработки графов. Даже удивительно, что все алгоритмы, изучаемые в данной книге, можно построить, как и приведенные функции, на этой базовой абстракции обработки всех вершин, смежных с каждой вершиной (что эквивалентно обработке всех ребер в графе).

Как сказано в разделе 17.5, мы часто оформляем логически взаимосвязанные функции в отдельный класс. Программа 17.4 представляет собой интерфейс такого класса. В ней содержится определение функции show из программы 17.3, а также двух других функций, которые вставляют в граф ребра, считанные из стандартного ввода (реализации этих функций см. в упражнении 17.12 и программе 17.14).

Задачи обработки графов, рассматриваемые в этой книге, обычно принадлежат к одной из трех обширных категорий:

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

 Формат списка смежности


Рис. 17.7.  Формат списка смежности

Эта таблица служит еще одним способом представления графа, приведенного на рис. 17.1; с каждой вершиной связывается множество смежных с ней вершин (которые соединены с ней одним ребром). Каждое ребро принадлежит двум множествам: для каждого ребра u-v графа вершина u содержится в множестве, связанном с вершиной v, а вершина v содержится в множестве, связанном с вершиной u.

Программа 17.3. Клиентская функция вывода графа

Данная реализация функции show для класса IO из программы 17.4 использует АТД графа для вывода таблицы вершин, смежных с каждой вершиной графа. Порядок перечисления вершин в таблице зависит от представления графа и реализации АТД (см. рис. 17.7).

  template <class Graph>
  void IO<Graph>::show(const Graph &G)
    { for (int s = 0; s < G.V(); s++)
        { cout.width(2); cout << s << ":";
          typename Graph::adjIterator A(G, s);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            { cout.width(2); cout << t << " "; }
          cout << endl;
        }
    }
      

Программа 17.4. Интерфейс ввода/вывода для обработки графов

Этот класс демонстрирует оформление схожих функций в единый класс. Он определяет функции, выполняющие вывод графа (см. программу 17.3), вставку ребер, определяемых парами целых чисел из стандартного ввода (см. упражнение 17.12) и вставку ребер, определяемых парами символов из стандартного ввода (см. программу 17.14).

  template <class Graph>
  class IO
    { public:
      static void show(const Graph &);
      static void scanEZ(Graph &);
      static void scan(Graph &);
    };
      

Для решения таких задач мы будем строить абстрактные типы данных, которые являются клиентами базового АТД из программы 17.1, и которые, в свою очередь, позволяет отделить клиентские программы, требуемые для решения реальных задач, от их реализаций. Например, программа 17.5 представляет собой интерфейс для АТД связности графа. Мы можем написать клиентские программы, использующие этот АТД для создания объектов, которые вычисляют количество связных компонентов в графе и которые могут проверить, находятся ли любые две вершины в одном и том же связном компоненте. Описание реализаций такого АТД и характеристики их производительности будут даны в лекция №18. Подобные АТД мы будем разрабатывать на протяжении всей книги. Как правило, такие АТД содержат общедоступную функцию-член, выполняющую предобработку (обычно это конструктор), приватные члены данных, которые содержат информацию, полученную во время предобработки, и общедоступные функции-члены обслуживания запросов, которые используют эту информацию для предоставления клиентам информации о графе.

В этой книге мы будем работать главным образом со статическими (static) графами, которые содержат фиксированное количество вершин V и ребер E.

Программа 17.5. Интерфейс связности

Данный интерфейс АТД демонстрирует типичную парадигму, которую мы используем для реализации алгоритмов обработки графов. Он позволяет клиентам создавать объекты, которые выполняют обработку графа для определения ответов на запросы, касающиеся связности этого графа. Функция-член count возвращает количество связных компонентов графа, а функция-член connect проверяет, связаны ли две заданные вершины. Реализация этого интерфейса приведена в программе 18.4.

  template <class Graph>
  class CC
    { private:
      // Код, зависящий от реализации 
      public:
        CC(const Graph &);
        int count();
        bool connect(int, int);
    };
      

Обычно мы строим графы с помощью E вызовов функции insert, а затем обрабатываем их -либо вызвав соответствующую функцию АТД, которая принимает граф в качестве аргумента и возвращает некоторую информацию о графе, либо используя объекты наподобие вышеуказанных для предварительной обработки графа, которые позволяют затем эффективно отвечать на запросы, касающиеся графа. В любом случае, после изменения графа функциями insert и remove необходима повторная обработка графа. Динамические задачи, где обработка графов может чередоваться с добавлением или удалением вершин и ребер графа, принадлежат к онлайновым алгоритмам (on-line algorithm), известных также как динамические алгоритмы (dynamic algorithm), с которыми связаны другие сложные задачи. Например, задача связности, которую мы решали с помощью алгоритма объединения-поиска в лекция №1, представляет собой пример интерактивного алгоритма, поскольку мы можем получать информацию о связности графа в процессе включения ребер в этот граф. АТД из программы 17.1 поддерживает операции вставить ребро и удалить ребро, и клиенты могут использовать их для внесения изменений в графы. Однако некоторые последовательности операций могут снизить производительность. Например, алгоритмы объединения-поиска могут потребовать повторной обработки всего графа, если клиент удалил из него ребро. В большинстве задач обработки графов, которые мы будем рассматривать, добавление или удаление нескольких ребер может кардинально изменить вид графа, а, значит, понадобится его повторная обработка.

Одна из наиболее важных задач обработки графов заключается в получении четких характеристик производительности и гарантировании, что они правильно используются клиентскими программами. Как и в случае более простых задач, которые рассматривались в частях I—IV, наша методика использования АТД позволяет системно решать эти задачи.

Пример клиентской программы обработки графов приведен в программе 17.6. Она использует базовый АТД из программы 17.1, класс ввода/вывода из программы 17.4 для считывания графа из стандартного ввода и его вывода на стандартное устройство вывода, а также класс связности из программы 17.5 для определения количества его связных компонентов. Аналогичные, хотя и более сложные, клиентские программы мы будем использовать для построения других видов графов, тестирования алгоритмов, изучения других свойств графов и использования графов для решения других задач. Эту базовую схему можно применять в любых приложениях обработки графов.

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

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

Программа 17.6. Пример клиентской программы обработки графов

Эта программа демонстрирует использование абстрактных типов данных, описанных в настоящем разделе и использующих соглашения об АТД, которые были сформулированы в лекция №4. Она создает граф с V вершинами, вставляет в него ребра, получаемые из стандартного ввода, выводит его, если граф не слишком велик, и вычисляет (и выводит) количество связных компонентов. Предполагается, что программы 17.1, 17.4 и 17.5 (с реализациями) содержатся соответственно в файлах GRAPH.cc, IO.cc и CC.cc.

  #include <iostream.h>
  #include <stdlib.h>
  #include "GRAPH.cc"
  #include "IO.cc"
  #include "CC.cc"
  main(int argc, char *argv[])
    { int V = atoi(argv[1]);
      GRAPH G(V);
      IO<GRAPH>::scan(G);
      if (V < 20) IO<GRAPH>::show(G);
      cout << G.E() << " ребер ";
      CC<GRAPH> Gcc(G);
      cout << Gcc.count() << " компонентов" << endl;
    }
      

Например, в качестве базы для реализации АТД можно рассмотреть представление в виде вектора ребер (см. упражнение 17.16). Это прямое представление несложно реализовать, однако оно не позволяет эффективно выполнять базовые операции обработки графов, к изучению которых мы вскоре приступим. Как мы увидим, большинство приложений обработки графов могут неплохо работать с одним из двух элементарных классических представлений, которые ненамного сложнее представления вектором ребер -матрица смежности и списки смежности. Эти представления графов, которые мы подробно изучим в разделах 17.3 и 17.4, основаны на элементарных структурах данных (и мы их рассматривали в лекция №3 и 5 в качестве примеров применения последовательного и связного распределения). Выбор одного из этих представлений зависит главным образом от того, является ли граф насыщенным или разреженным, хотя, как обычно, важную роль при принятии решения играет также характер выполняемых операций.

Упражнения

17.12. Разработайте реализацию функции scanEZ из программы 17.4: напишите функцию, которая строит граф, считывая ребра (пары целых чисел в диапазоне от 0 до V-1) из стандартного ввода.

17.13. Напишите клиентскую программу для АТД графа, которая добавляет ребра из заданного вектора в заданный граф.

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

17.15. Разработайте реализацию для АТД связности из программы 17.5, используя алгоритм объединения-поиска (см. лекция №1)

17.8. Приведите реализацию функций из программы 17.1, которая использует для представления графа вектор ребер. Используйте примитивную реализацию функции, которая для удаления ребра v-w просматривает вектор, находит в нем ребро v-w или w-v и затем меняет местами найденное ребро с последним ребром вектора. Используйте аналогичный просмотр для реализации итератора. Примечание: предварительное чтение раздела 17.3 упростит вашу задачу.

Представление графа в виде матрицы смежности

Представление графа в виде матрицы смежности (adjacency matrix) -это матрица булевских значений размером Vх V, элемент которой, стоящий на пересечении v-ой строки и w-го столбца, равен 1, если в графе имеется ребро, соединяющее вершину v с вершиной w, и 0 в противном случае. Пример матрицы смежности приведен на рис. 17.8.

 Представление графа матрицей смежности


Рис. 17.8.  Представление графа матрицей смежности

Данная булева матрица является еще одним представлением графа с рис. 17.1 рис. 17.1. На пересечении строки v и столбца w этой матрицы находится 1 (true), если в графе имеется ребро, соединяющее вершину v с вершиной w, и 0 (false), если такого ребра нет. Эта матрица симметрична относительно главной диагонали. Например, шестая строка (и шестой столбец) показывает, что вершина 6 соединена с вершинами 0 и 4. В некоторых случаях мы считаем, что каждая вершина соединена сама с собой, и ставим единицы на главной диагонали. Большие нулевые области в верхнем правом и нижнем левом углах матрицы обусловлены выбранной нумерацией вершин для данного примера, но не свойствами рассматриваемого графа (за исключением того, что они указывают на разреженность графа).

Программа 17.7 является реализацией интерфейса АТД графа, в которой используется непосредственное представление этой матрицы в виде вектора векторов (см. рис. 17.9). Это двумерная таблица существования, элемент adj[v][w] которой равен true, если в графе существует ребро, соединяющее вершину v с вершиной w, и false в противном случае. В случае неориентированного графа каждое ребро должно быть представлено двумя элементами: ребро v-w представлено значением true как в adj[v][w], так и в adj[w][v], что соответствует ребру w-v.

Имя DenseGRAPH в программе 17.7 подчеркивает, что эта реализация больше подходит для насыщенных, чем для разреженных графов, и отличает ее от других реализаций. Клиентская программа может воспользоваться определением типа typedef, чтобы сделать этот тип эквивалентным типу GRAPH или же явно воспользоваться именем DenseGRAPH.

В матрице смежности, которая представляет граф G, строка v есть вектор, представляющий собой таблицу существования, i-й элемент которого равно true, если вершина i смежна с v (в графе G имеется ребро v-i). Значит, чтобы дать клиентам возможность обрабатывать вершины, смежные с v, нужен код просмотра этого вектора, который находит значения true, как в программе 17.8. Понятно, что такая реализация обработки всех вершин, смежных с заданной вершиной, требует (по меньшей мере) времени, пропорционального количеству вершин V графа, независимо от количества смежных вершин.

 Структура данных для матрицы смежности


Рис. 17.9.  Структура данных для матрицы смежности

На этом рисунке изображено представление графа с рис. 17.1 в виде вектора векторов языка C++.

Программа 17.7. Реализация АТД графа (матрицей смежности)

Данный класс представляет собой непосредственную реализацию интерфейса из программы 17.1, основанную на представлении графа в виде вектора булевых векторов (см. рис. 17.9). Вставка и удаление ребер выполняется за постоянное время. Запросы на вставку уже существующих ребер (функция insert) молча игнорируются, хотя клиенты могут проверить наличие какого-либо ребра с помощью функции edge. Для построения графа требуется время, пропорциональное V2.

  class DenseGRAPH
    { int Vcnt, Ecnt; bool digraph;
      vector <vector <bool> > adj;
    public:
      DenseGRAPH(int V, bool digraph = false) :
        adj(V), Vcnt(V), Ecnt(0), digraph(digraph)
          { for (int i = 0; i < V; i++)
            adj[i].assign(V, false);
          }
      int V() const { return Vcnt; }
      int E() const { return Ecnt; }
      bool directed() const { return digraph; }
      void insert(Edge e)
        { int v = e.v, w = e.w;
          if (adj[v][w] == false) Ecnt++;
          adj[v][w] = true;
          if (!digraph) adj[w][v] = true;
        }
      void remove(Edge e)
        { int v = e.v, w = e.w;
          if (adj[v][w] == true) Ecnt—;
          adj[v][w] = false;
          if (!digraph) adj[w][v] = false;
        }
      bool edge(int v, int w) const
        { return adj[v][w]; }
      class adjlterator;
      friend class adjlterator;
    } ;
      

Программа 17.8. Итератор для представления матрицей смежности

Данная реализация итератора для программы 17.7 использует индекс i для пропуска элементов, равных false, в строке v матрицы смежности (adj[v]). Чтобы получить последовательность вершин, смежных с вершиной v графа G в порядке возрастания индексов вершин, необходим вызов функции beg(), а за ним последовательность вызовов функций xt() (с проверкой на false значения end() перед каждым таким вызовом).

  class DenseGRAPH::adjIterator
    { const DenseGRAPH &G;
      int i, v;
    public:
      adjIterator(const DenseGRAPH &G, int v) :
        G(G), v(v), i(-1) { }
      int beg()
        { i = -1; return nxt(); }
      int nxt()
        { for (i++; i < G.V(); i++)
          if (G.adj[v][i] == true) return i;
          return -1;
        }
      bool end()
        { return i >= G.V(); }
    };
      

Как было сказано в разделе 17.2, наш интерфейс требует, чтобы в момент инициализации графа клиенту было известно количество вершин. При необходимости можно разрешить вставку и удаление вершин (см. упражнение 17.21). Главное в конструкторе в программе 17.7 -это то, что при инициализации графа он заносит во все элементы матрицы значения false. Следует иметь в виду, что эта операция требует для своего выполнения время, пропорциональное V2, независимо от количества ребер в графе. Для краткости в программу 17.7 не включены проверки на нехватку памяти -перед использованием программы нужно добавить такие проверки (см. упражнение 17.24).

Чтобы добавить в граф ребро, в указанный элемент матрицы заносится значение true (одно для орграфов, два для неориентированных графов). Такое представление не допускает параллельных ребер: если в граф нужно вставить ребро, для которого соответствующий элемент матрицы уже равен 1, то программа ничего не изменяет. В некоторых вариантах АТД может потребоваться информировать клиент о попытке включить параллельное ребро (возможно, с помощью кода возврата функции insert). Однако петли в данном представлении возможны: ребро v-v представляется ненулевым значением элемента a[v][v].

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

При обработке больших графов, или большого количества маленьких графов, или в других случаях, когда ощущается нехватка памяти, существует несколько способов экономии памяти. Например, матрицы смежности, представляющие неориентированные графы, симметричны: a[v][w] всегда равно a[w][v]. Значит, можно сэкономить память, храня только половину симметричной матрицы (см. упражнение 17.22). Другой способ экономии значительного объема памяти заключается в использовании битовой матрицы (если этого не делает функция vector<bool>). Тогда, например, мы можем хранить представление графа, состоящего примерно из 64000 вершин в примерно 64 миллионах 64-битовых слов (см. упражнение 17.23). Эти реализации связаны с небольшим усложнением проверки существования ребра (см. упражнение 17.20). (В наших реализациях такая операция не используется, поскольку проверка, существует ли ребро v-w, сводится к проверке значения a[v][w].) Подобные методы экономии памяти эффективны, но связаны с дополнительными расходами, которые могут утяжелить внутренний цикл приложения, для которого критично время выполнения.

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

Использование матриц смежности зависит от назначения в качестве имен вершин целых чисел в диапазоне от 0 до V-1. Такое назначение можно выполнить множеством различных способов; например, в разделе 17.6 будет рассмотрена программа, которая выполняет эту процедуру. Поэтому конкретная матрица значений 0-1, которую мы представили в виде вектора векторов языка C++, не является единственно возможным представлением матрицы смежности любого заданного графа, т.к. другая программа может присвоить другие имена вершин индексам, которые мы используем для указания строк и столбцов. Две совершенно различные на первый взгляд матрицы на самом деле могут представлять один и тот же граф (см. упражнение 17.17). Это наблюдение -просто другая формулировка задачи изоморфизма графов: несмотря на необходимость определения, являются ли две различные матрицы представлением одного и того же графа, еще никто не изобрел алгоритм, который всегда мог бы эффективно решать данную задачу. Эта трудность носит фундаментальный характер. Например, наши возможности найти эффективное решение различных важных задач обработки графов полностью зависят от способа нумерации вершин (см., например, упражнение 17.26).

Программа 17.3 из раздела 17.2 выводит таблицу вершин, смежных с каждой вершиной графа. Когда она используется совместно с реализацией в программе 17.7, она выводит список вершин в порядке возрастания индексов их вершин, как на рис. 17.7. Однако учтите, что требование перебора вершин в порядке возрастания их индексов не входит в определение класса adjlterator, поэтому разработка клиента АТД, который выводит матрицу смежности, представляющую граф -нетривиальная задача (см. упражнение 17.18). Выходные данные, выводимые этими программами, сами являются представлениями графа, которые наглядно демонстрируют основные компромиссы относительно производительности алгоритма. Для вывода матрицы на странице нужно место, достаточное для размещения всех V 2 элементов; для вывода списков нужно место, достаточное для размещения V + E чисел. В случае разреженных графов, когда V2 гораздо больше, чем V + E, предпочтительнее списки, а в случае насыщенных графов, когда E и V2 сравнимы, удобнее матрица. Вскоре мы увидим необходимость такого же выбора, когда будем сравнивать представление графа матрицей смежности и его основной альтернативой -явным представлением списками смежности.

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

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

Упражнения

17.17. Приведите представления трех графов, изображенных на рис. 17.2, в виде матриц смежности.

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

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

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

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

17.22. Измените программу 17.7, расширенную как описано в упражнении 17.20, чтобы массив не содержал элементы a[v][w], у которых w больше, чем v -это должно снизить ее требования к памяти примерно наполовину.

17.23. Измените программу 17.7, расширенную как описано в упражнении 17.20, чтобы на компьютере со словами из B битов граф с V вершинами был представлен примерно V2/B (а не V2) словами. Эмпирически определите влияние упаковки битов в слова на время выполнения операций АТД.

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

17.25. Разработайте версию программы 17.7, которая использует единственный вектор, содержащий V2 элементов.

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

Представление графа в виде списков смежности

Стандартное представление графа, которое обычно выбирают для ненасыщенных графов, называется представлением списками смежности (adjacency lists).

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

Программа 17.9 представляет собой реализацию интерфейса АТД из программы 17.1, основанную на данном подходе, а на рис. 17.10 приведен соответствующий пример. Чтобы добавить в это представление графа ребро, соединяющее вершины v и w, мы добавляем w в список смежности вершины v, а v -в список смежности вершины w. Таким образом, добавление новых ребер выполняется за постоянное время, однако общий объем занимаемой при этом памяти пропорционален сумме количества вершин и количества ребер (в отличие от пропорциональности квадрату количества вершин для представления графа матрицей смежности). Ребра неориентированных графов опять фигурируют в двух различных местах, т.к. ребро, соединяющее вершину v с w, представлено узлами в обоих списках смежности. Оба включения обязательны, иначе мы не сможем эффективно отвечать на простые вопросы вроде " Какие вершины смежны с вершиной v? " Программа 17.10 реализует итератор, который дает ответ клиентам, задающим такие вопросы, за время, пропорциональное количеству таких вершин.

 Структура данных списков смежности


Рис. 17.10.  Структура данных списков смежности

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

Реализации в программах 17.9 и 17.10 являются низкоуровневыми. В качестве альтернативы можно реализовать каждый связный список с помощью контейнера list из библиотеки STL (см. упражнение 17.30). Недостаток такого подхода заключается в том, что реализациям list из STL приходится поддерживать гораздо большее количество операций, чем нужно нам, а это обычно означает излишние затраты, которые могут ухудшить производительность всех разрабатываемых нами алгоритмов (см. упражнение 17.31). Вообще-то все наши алгоритмы обработки графов используют интерфейс АТД Graph, так что эта реализация -вполне подходящее место для инкапсуляции всех низкоуровневых операций. Так мы достигнем нужной эффективности, не затрагивая другие программы. Другое преимущество представления графа связными списками заключается в том, что оно обеспечивает базу для оценки производительности наших приложений. Однако учтите, что реализация связных списков в программах 17.9 и 17.10 неполна, в ней нет деструктора и конструктора копирования. Во многих случаях это может привести к неожиданным результатам или серьезному снижению производительности. Эти функции являются прямыми расширениями функций из реализации очереди первого класса в программе 4.22 (см. упражнение 17.29).

Программа 17.9. Реализация АТД графа (списками смежности)

Данная реализация интерфейса из программы 17.1 использует вектор связных списков, каждый из которых соответствует одной вершине. Она эквивалентна представлению в программе 3.15, где ребро v-w представлено узлом вершины w в списке вершины v и узлом вершины v в списке вершины w.

Реализации функций remove и edge, а также конструктора копирования и деструктора оставлены в качестве самостоятельных упражнений. Код функции insert обеспечивает постоянное время вставки ребра за счет отказа от проверки параллельности ребер. Общий объем используемой памяти пропорционален V + E, т.е. это представление больше подходит для разреженных мультиграфов.

Клиентские программы могут воспользоваться конструкцией typedef, чтобы сделать этот тип эквивалентным типу GRAPH, или явно использовать класс SparceMultiGRAPH.

  class SparseMultiGRAPH
    { int Vcnt, Ecnt; bool digraph;
      struct node
        { int v; node* next;
          node(int x, node* t) { v = x; next = t; }
        } ;
      typedef node* link;
      vector <link> adj;
    public:
      SparseMultiGRAPH(int V, bool digraph = false) :
        adj(V), Vcnt(V), Ecnt(0), digraph(digraph)
        { adj.assign(V, 0); }
      int V() const { return Vcnt; }
      int E() const { return Ecnt; }
      bool directed() const { return digraph; }
      void insert(Edge e)
        { int v = e.v, w = e.w;
          adj[v] = new node(w, adj[v]);
          if (!digraph) adj[w] = new node(v, adj[w]);
          Ecnt++;
        }
      void remove(Edge e);
      bool edge(int v, int w) const;
      class adjlterator;
      friend class adjlterator;
      } ;
      

В данной книге мы полагаем, что объекты SparceMultiGRAPH содержат их. В этом смысле STL-контейнер list гораздо удобнее низкоуровневых однонаправленных списков, он снимает необходимость в дополнительном кодировании, поскольку соответствующий деструктор и конструктор копирования определены автоматически. Например, объекты DenseGRAPH, построенные в программе 17.7, правильно уничтожаются и копируются клиентскими программами, так как они построены из объектов библиотеки STL.

В отличие от программы 17.7, программа 17.9 строит мультиграфы, т.к. она не удаляет параллельные ребра. Для выявления повторяющихся ребер в структуре списков смежности необходим просмотр списков со временем, пропорциональным V. Кроме того, в программе 17.9 отсутствует реализация операции удалить ребро и проверить наличие ребра. Добавление реализаций этих функций не представляет труда (см. упражнение 17.28), но каждая такая операция может потребовать время, пропорциональное V, на поиск в списках узлов, представляющих ребра. Из-за этих затрат представление списками смежности может оказаться неприемлемым для приложений, выполняющих обработку очень больших графов, в которых недопустимы параллельные ребра, или для приложений, в которых интенсивно используются операции удалить ребро или проверить наличие ребра. В разделе 17.5 будут рассмотрены реализации списками смежности, которые обеспечивают выполнение операций удалить ребро и проверить наличие ребра за постоянное время.

Если в качестве имен вершин графа используются обозначения, отличные от целых чисел, то (как и в случае матриц смежности) две разные программы могут связать имена вершин с целыми числами в диапазоне от 0 до V-1 двумя различными способами, а это приведет к образованию двух различных структур списков смежности (см., например, программу 17.15). Из-за сложности задачи изоморфизма графов трудно рассчитывать на то, что мы сумеем определить, представляют ли различные структуры один и тот же граф.

Программа 17.10. Итератор для представления списками смежности

Данная реализация итератора для программы 17.9 использует ссылку t для обхода связного списка, присоединенного к вершине v. Чтобы получить последовательность вершин, смежных с вершиной v графа G, необходим вызов функции beg() , а за ним последовательность вызовов функций nxt() (с проверкой на false значения end() перед каждым таким вызовом).

  class SparseMultiGRAPH::adjIterator
    { const SparseMultiGRAPH &G;
      int v;
      link t;
    public:
      adjIterator(const SparseMultiGRAPH &G, int v) :
        G(G), v(v) { t = 0; }
      int beg()
        { t = G.adj[v]; return t ? t->v : -1; }
      int nxt()
        { if (t) t = t->next; return t ? t->v : -1; }
      bool end()
        { return t == 0; }
    };
      

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

Основное преимущество представления списками смежности по сравнению с представлением матрицей смежности заключается в том, что оно всегда требует объем памяти, пропорциональный V + E, а не V2. А основной недостаток состоит в том, что проверка наличия конкретных ребер может потребовать время, пропорциональное V -в отличие от постоянного времени для матрицы смежности. Эти различия, по сути, возникают из-за различия в использовании связных списков и векторов для представления множеств вершин, инцидентных каждой вершине.

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

Упражнения

17.27. В стиле рисунка 17.10 приведите структуру списков смежности, полученную при вставке программой 17.9 ребер

3-71-47-80-55-23-82-90-64-92-66-4(вуказанно мпо рядке)вперво начально пусто йграф.

17.28. Напишите реализации функций remove и edge для класса графов, представленных списками смежности (программа 17.9). Примечание', в случае параллельных ребер достаточно удалить любое ребро, соединяющее заданные вершины.

17.29. Добавьте конструктор копирования и деструктор в класс графов, представленных списками смежности (программа 17.9). Совет, см. программу 4.22.

17.30. Измените реализацию класса SparceMultiGRAPH в программах 17.9 и 17.10, используя вместо связного списка STL-контейнер list для представления каждого списка смежности.

17.31. Эмпирически сравните реализацию класса SparceMultiGRAPH из упражнения 17.30 с реализацией, приведенной в тексте. На примере специально подобранного множества значений V сравните значения времени работы клиентской программы, которая строит полные графы с V вершинами, после чего перебирает их ребра с помощью программы 17.2.

17.32. Приведите простой пример представления графа списками смежности, которое невозможно построить многократной вставкой ребер с помощью программы 17.9.

17.33. Сколько различных представлений графа в виде списков смежности представляют один и тот же граф, показанный на рис. 17.10 рис. 17.10?

17.34. Добавьте в АТД графа (программа 17.1) объявление общедоступной функции-члена, которая удаляет петли и параллельные ребра. Напишите тривиальную реализацию этой функции для класса на базе матрицы смежности (программа 17.7) и для класса на базе списков смежности (программа 17.9), которая использует время, пропорциональное E, и объем памяти, пропорциональный V.

17.35. Напишите версию программы 17.9, которая блокирует добавление параллельных ребер (просматривая список смежности при каждой вставке ребра) и петель. Сравните полученную реализацию с реализацией из упражнения 17.34. Какая из них лучше подходит для работы со статическими графами? Примечание, оптимальную реализацию см. в упражнении 17.49.

17.36. Напишите клиентскую программу АТД графа, которая возвращает результат удаления петель, параллельных ребер и вершин степени 0 (изолированных вершин) из заданного графа. Примечание, Время выполнения вашей программы должно линейно зависеть от размера представления графа.

17.37. Напишите клиентскую программу АТД графа, которая возвращает результат удаления из заданного графа петель и сворачиваемых путей, т.е. состоящих исключительно из вершин степени 2. А именно, каждая вершина степени 2 в графе без параллельных ребер принадлежит некоторому пути u-...-w, где вершины u и w могут иметь степени, не равные 2. Замените каждый такой путь ребром u-w, а затем удалите все промежуточные вершины степени 2 как в упражнении 17.37. Примечание, Эта операция может привести к появлению петель и параллельных ребер, но она сохраняет степени вершин, которые не были удалены.

17.38. Приведите (мульти)граф, полученный преобразованием из упражнения 17.37 графа, показанного нарис. 17.1.

Вариации, расширения и затраты

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

Реализации, приведенные в программах 17.7 и 17.9, могут строить орграфы, если при вызове конструктора указать второй аргумент, равный true. Как показано на рис. 17.11, каждое ребро входит в представление графа только один раз.

Ребро v-w в орграфе представлено единицей в элементе матрицы смежности, расположенном на пересечении строки v и столбца w или вершиной w в списке смежности вершины v в представлении графа списками смежности. Эти представления проще представлений, которые были выбраны для неориентированных графов, однако присущая им асимметрия делает их более сложными комбинаторными объектами, чем неориентированные графы, в чем мы убедимся в лекция №19. Например, стандартное представление графа списками смежности не обеспечивает простого способа определения всех ребер, входящих в заданную вершину орграфа, и если требуется поддержка этой операции, придется использовать другие представления.

В случае взвешенных графов (weighted graph) и сетей (network) матрица смежности наполняется структурами с информацией о ребрах (включая данные об их наличии или отсутствии), которые заменяют соответствующие булевские значения; в представлении списками смежности эта информация содержится в элементах списков смежности.

 Представления орграфа


Рис. 17.11.  Представления орграфа

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

Часто возникает необходимость привязывать к вершинам или ребрам графа еще больше информации, чтобы графы могли моделировать более сложные объекты. Эту дополнительную информацию можно связать с каждым ребром, расширив тип Edge из программы 17.1, а затем используя экземпляры этого типа в матрицах смежности или в узлах списков смежности. Или, поскольку именами вершин являются целые числа в диапазоне от 0 до V—1, можно воспользоваться векторами, индексированными этими именами, чтобы привязать к вершинам дополнительную информацию -возможно, с помощью соответствующих АТД. Мы рассмотрим такие АТД в лекциях 20—22 . А можно воспользоваться отдельным АТД таблицы символов для привязки дополнительной информации к каждой вершине и к каждому ребру (см. упражнение 17.48 и программу 17.15).

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

В качестве примера предположим, что нужно узнать, является ли вершина v графа изолированной. Равна ли степень вершины v нулю? При представлении графа списками смежности эту информацию можно получить немедленно, просто проверив, на равенство нулю значение adj[v]. Однако в случае представления матрицей смежности придется проверить все элементы в строке или столбце v, чтобы убедиться, что вершина не соединена ни с какой другой вершиной. А в случае представления графа вектором ребер остается только просмотреть все E ребер, чтобы проверить, содержат ли какие-либо ребра вершину v. Необходимо оградить клиенты от подобных длительных вычислений. Как было сказано в разделе 17.2, один из способов заключается в том, чтобы определить клиентский АТД для задачи, как это сделано в программе 17.11. Эта реализация после предварительной обработки графа за время, пропорциональное размеру его представления, позволит клиентам определить степень любой вершины за постоянное время. Такой способ не дает никакого выигрыша, если клиенту нужно узнать степень только одной вершины, но обеспечивает существенную экономию ресурсов тем клиентам, которые хотят определять значения степеней многих вершин. Существенное различие в производительности алгоритмов решения достаточно простой задачи характерно для обработки графов.

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

Программа 17.11. Реализация класса для определения степеней вершин

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

Подобные классы используются в данной книге при разработке объектно-ориентированных реализаций функций обработки графов как клиентов класса GRAPH.

  template <class Graph>
  class DEGREE
    { const Graph &G;
      vector <int> degree;
    public:
      DEGREE(const Graph &G) : G(G), degree(G.V(), 0)
        { for (int v = 0; v < G.V(); v++)
          { typename Graph::adjIterator A(G, v);
            for (int w = A.beg(); !A.end(); w = A.nxt())
              degree[v]++;
          }
        }
      int operator[](int v) const
        { return degree[v]; }
    };
     

Существует много других способов разработки на основе интерфейсов в C++. Одно из направлений дальнейших действий состоит в простом добавлении общедоступных функций-членов (и любых других приватных данных и функций-членов, которые могут потребоваться) в определение базового АТД GRAPH. Такой подход обладает всеми достоинствами, расхваленными в лекция №4, но ему свойственны и серьезные недостатки, поскольку сфера обработки графов намного шире, чем виды базовых структур данных, которые были рассмотрены в лекция №4. Вот основные из этих недостатков,

Такие интерфейсы называются " толстыми " (fat). В книге, посвященной алгоритмам обработки графов, подобные интерфейсы и в самом деле выглядят " толстыми " .

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

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

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

Таблица 17.1. Затраты на выполнение операций обработки графов в худшем случае
Массив реберМатрица смежностиСписки смежности
ПамятьE V2 V+E
Инициализация пустого объекта1 V2 V
КопированиеE V2 E
Уничтожение1VE
Вставка ребра111
Поиск/удаление ребраE1V
Вершина v изолирована?EV1
Существует ли путь от u к v?E lg*V V2 V+E

Иногда удается модифицировать представление графа, чтобы повысить эффективность простых операций, но при этом нужно следить, чтобы не увеличить стоимость других простых операций. Например, значение в таблице, соответствующее строке " Уничтожение " и столбцу " Матрица смежности " , следует из выбранного нами представления двухмерной матрицы в виде вектора векторов (см. лекция №3). Эти затраты нетрудно снизить до постоянной величины (см. упражнение 17.25). Но если ребра графа представляют собой достаточно сложные структуры, для которых требуется хранение указателей в элементах матрицы, то операция уничтожить для матрицы смежности потребует затрат, пропорциональных V2.

Операции найти ребро и удалить ребро часто используются в обычных приложениях, и поэтому мы их рассматриваем более подробно. В частности, операция найти ребро нужна для удаления или блокировки добавления параллельных ребер. Как было показано в лекция №13, эти операции тривиальны, если использовать представление матрицей смежности -достаточно просто проверить или изменить значение элемента матрицы, который допускает прямую индексацию. Но как обеспечить эффективную реализацию этих операций для представления списками смежности? В языке C++ можно воспользоваться библиотекой STL; здесь мы опишем базовые механизмы, чтобы получить представление о проблемах обеспечения эффективности. Один из подходов описан ниже, а другой -в упражнении 17.50. Оба подхода основаны на использовании реализаций таблицы символов. Например, если мы используем реализации динамической хеш-таблицы (см. лекция №14), то оба подхода потребуют объем памяти, пропорциональный E, и позволяют выполнять обе операции за постоянное время (в среднем, амортизированный подсчет).

В частности, для реализации операции найти ребро при использовании списков смежности можно воспользоваться вспомогательной таблицей символов для ребер. Каждому ребру v-w можно назначить целочисленный ключ v*V+w и воспользоваться контейнером map из библиотеки STL или любой реализацией таблицы символов из части IV (Для неориентированных графов ребрам v-w и w-v можно присваивать одни и те же ключи.) Каждое ребро можно заносить в таблицу символов после предварительной проверки, было ли оно занесено раньше. Можно выбрать как блокировку включения параллельных ребер (см. упражнение 17.49), так и сохранение повторяющихся записей для параллельных ребер в таблице символов (см. упражнение 17.50). Сейчас эта техника интересует нас в основном тем, что она делает возможной реализацию операции найти ребро с постоянным временем выполнения для представления списками смежности.

Чтобы иметь возможность удалять ребра, в записи таблицы символов для каждого ребра необходим указатель на его представление в структуре списков смежности. Но даже этой информации недостаточно для удаления ребра за постоянное время, если только списки не являются дважды связными (см. лекция №3). А в случае неориентированных графов нельзя ограничиться лишь удалением узла из списка смежности, поскольку каждое ребро содержится в двух различных списках. Одним из решений является помещение в таблицу символов обоих указателей; другое основано на связывании двух узлов, соответствующих конкретному ребру (см. упражнение 17.46). Любое из этих решений обеспечивает удаление ребра за постоянное время.

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

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

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

Можно также рассмотреть альтернативные реализации, которые изменяют базовые структуры данных для экономии памяти или времени выполнения при обработке больших графов (или большого количества маленьких). Например, можно существенно повысить производительность алгоритмов обработки больших статических графов, представленных списками смежности, заменив представление множества вершин, инцидентных каждой конкретной вершине, со списков смежности на векторы переменной длины. Это позволит представить граф всего лишь 2E целыми числами, что меньше V, и еще V целыми числами, что меньше V2 (см. упражнения 17.52 и 17.54). Подобные представления удобны для обработки больших статических графов.

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

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

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

В худшем случае простой алгоритм выбора пути, описанный в разделе 17.7 (а также несколько других методов, которые будут рассмотрены влекция №18), просматривает все E ребер графа. Данные в среднем и правом столбцах нижней строки таблицы 17.1 показывают, соответственно, что этот алгоритм может проверить все V2 элементов представления матрицей смежности, либо все V ведущих узлов списков и все E узлов в списках в случае представления списками смежности. Из этого следует, что время выполнения алгоритма линейно зависит от размера представления графа, однако имеются два исключения из этого правила, в худшем случае время выполнения перестает быть линейным. Это происходит, если использовать матрицу смежности для разреженного графа или любое представление для очень разреженного графа (с большим количеством изолированных вершин). Чтобы больше не останавливаться на этих исключениях, в дальнейшем мы полагаем, что размер используемого представления графа пропорционален количеству ребер этого графа. В большинстве практических приложений это предположение весьма спорно, поскольку в них часто выполняется обработка очень больших разреженных графов и, следовательно, удобнее представление списками смежности.

Значение в нижней строке левого столбца таблицы 17.1 получено для алгоритмов объединения-поиска, описанных в лекция №1 (см. упражнение 17.15). Этот метод привлекателен тем, что необходимый для него объем памяти пропорционален лишь V, однако он не способен находить пути. Этот элемент таблицы 17.1 подчеркивает важность полного и точного описания задач обработки графов.

Даже после того как все эти факторы будут учтены, остается еще одна из наиболее важных и трудных задач, с которыми нам приходится сталкиваться при разработке практических алгоритмов обработки графов -оценка того, насколько результаты анализа этих алгоритмов для худшего случая (наподобие значений в таблице 17.1) переоценивают потребность во времени и в памяти для реальных графов. В статьях по алгоритмам на графах обычно описывается производительность, гарантируемая в худшем случае. Эта информация полезна для отсеивания алгоритмов с заведомо неприемлемыми характеристиками, но она не всегда может подсказать, какая из нескольких простых программ наиболее подходит в конкретном случае. Эта ситуация усугубляется трудностями разработки полезных моделей средней производительности алгоритмов на графах, и нам остаются (без каких-либо гарантий) только эмпирическое тестирование и (возможно, слишком консервативные) гарантии производительности в худшем случае. Например, все методы поиска на графах, которые рассматриваются в лекция №18, представляют собой эффективные линейные по времени алгоритмы для поиска пути между двумя заданными вершинами, однако их характеристики производительности существенно различаются в зависимости от вида обрабатываемого графа и его представления. При использовании алгоритмов обработки графов приходится постоянно балансировать между гарантиями для худшего случая, которые можно доказать, и реальной ожидаемой производительностью. С этой темой мы будем постоянно сталкиваться на протяжении всей книги.

Упражнения

17.39. Разработайте представление матрицей смежности для насыщенных мультиграфов и напишите реализацию АТД для использующей его программы 17.1.

17.40. Почему не стоит использовать прямое представление графов (структура данных, которая точно моделирует граф с объектами-вершинами, содержащими списки смежности со ссылками на эти вершины)?

17.41. Почему программа 17.11 не увеличивает на единицу оба значения deg[v] и deg[w], когда она обнаруживает, что вершина v смежна с w?

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

17.43. Выполните упражнение 17.43 для представления списками смежности.

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

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

17.46. Воспользуйтесь двусвязными списками смежности с перекрестными ссылками (см. текст) для реализации функции remove, выполняющей операцию удалить ребро за постоянное время для реализации АТД графа, в которой используются списки смежности (программа 17.9).

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

17.48. Измените решение упражнения 17.16, чтобы в нем использовалась динамическая хеш-таблица (см. описание в тексте), и операции вставить ребро и удалить ребро выполнялись за постоянное (в среднем) время.

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

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

17.51. Разработайте АТД графа, ориентированный на статические графы, в котором конструктор принимает в качестве аргумента вектор ребер и использует для построения графов базовый АТД графа. (Такая реализация может оказаться полезной для сравнения производительности с реализациями из упражнений 17.52—17.55.)

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

  struct node { int cnt; vector <int> edges; };
  struct graph { int V; int E; vector <node> adj; };
      

Граф есть совокупность счетчика вершин, счетчика ребер и вектора вершин. Вершина содержит счетчик ребер и вектор с одним индексом вершины для каждого смежного ребра.

17.53. Добавьте в решение упражнения 17.52 функцию для удаления петель и параллельных ребер, как в упражнении 17.34.

17.54. Разработайте реализацию АТД статического графа, описанного в упражнении 17.51, которая использует для представления графа только два вектора, один -вектор E вершин, второй -вектор V индексов или указателей на элементы первого вектора. Реализуйте функцию io::show для этого представления.

17.55. Добавьте в решение упражнения 17.54 функцию для удаления петель и параллельных ребер, как в упражнении 17.34.

17.56. Разработайте интерфейс АТД графа, который связывает с каждой вершиной координаты (x, y), что позволит работать с чертежами графов. Включите в интерфейс функции drawV и drawE для вычерчивания, соответственно, вершин и ребер.

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

17.58. Разработайте реализацию интерфейса из упражнения 17.56, генерирующую PostScript-программу для вычерчивания графов (см. лекция №4).

17.59. Найдите графический интерфейс, подходящий для реализации интерфейса из упражнения 17.56, который позволит непосредственно выводить чертежи графов в специальном окне.

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

Генераторы графов

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

Для конкретизации примеров мы представим их в виде клиентских функций программы 17.1 -тогда мы сможем непосредственно применять их для тестирования рассматриваемых реализаций алгоритмов на графах. Кроме того, мы рассмотрим реализацию функции io::scan из программы 17.4, которая считывает последовательность пар произвольных имен из стандартного ввода и строит граф с вершинами, соответствующими именам, и ребрами, соответствующими парам.

Реализации, которые будут рассмотрены в этом разделе, основаны на интерфейсе из программы 17.1, так что теоретически они должны правильно работать для любого представления графа. Хотя на практике, как мы увидим, некоторые сочетания интерфейса и представлений не могут обеспечить приемлемой производительности.

 Два случайных графа


Рис. 17.12.  Два случайных графа

Оба приведенных здесь графа содержат по 50 вершин. Разреженный граф в верхней части рисунка содержит 50 ребер, а насыщенный граф в нижней части рисунка -500ребер. Разреженный граф не является связным, поскольку каждая его вершина соединена только с небольшим количеством других вершин; насыщенный граф, несомненно, является связным, т.к. каждая его вершина связана в среднем с 20 другими вершинами. Эти диаграммы демонстрируют сложность разработки алгоритмов вычерчивания произвольных графов (на рисунке вершины размещены в случайно выбранных местах).

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

Случайные ребра. Реализация этой модели довольно проста -см. генератор, представленный в программе 17.12. Для заданного количества вершин V генерируются произвольные ребра, т.е. пары случайных чисел от 0 до V—1. Результатом, скорее всего, будет произвольный мультиграф с петлями. Любая пара может содержать два одинаковых числа (т.е. возможны петли); и любая пара может повториться несколько раз (т.е. возможны параллельные ребра). Программа 17.12 генерирует ребра до тех пор, пока не наберется E ребер; решение об удалении параллельных ребер остается за реализацией. Если удалять параллельные ребра, то в насыщенных графах количество генерируемых ребер будет значительно больше, чем количество использованных ребер (E) (см. упражнение 17.62 и рис. 17.12); поэтому данный метод обычно используется для разреженных графов.

Случайный граф. Классическая математическая модель случайных графов -включение в граф каждого возможного ребра с одинаковой вероятностью p. Если нужно, чтобы ожидаемое количество ребер графа было равно E, следует выбрать p = 2E/V(V-1). Функция в программе 17.13 использует эту модель для генерации случайных графов.

Программа 17.12. Генератор случайных графов (случайные ребра)

Данная функция добавляет в граф случайные ребра. Для этого она генерирует E случайных пар целых чисел, интерпретируя числа как метки вершин, а пары меток вершин как ребра. Решение о том, как поступать с параллельными ребрами и петлями, возлагается на функцию-член insert класса Graph. Обычно этот метод не годится для генерации очень больших и насыщенных графов из-за большого количества параллельных ребер.

    static void randE(Graph &G, int E)
      { for (int i = 0; i < E; i++)
        { int v = int(G.V()*rand()/(1.0+RAND MAX));
          int w = int(G.V()*rand()/(1.0+RAND MAX));
          G.insert(Edge(v,w));
        }
      }
      

Параллельные ребра не допускаются, а количество ребер в графе равно E только в среднем. Эта реализация удобна для генерации насыщенных, а не разреженных графов, поскольку за время, пропорциональное V(V-1)/2, она генерирует E = pV(V-1)/2 ребер. То есть для разреженных графов время работы программы 17.13 квадратично зависит от размера графа (см. упражнение 17.68).

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

Программа 17.13. Генератор случайных графов (случайный граф)

Как и в программе 17.12, данная функция генерирует случайные пары целых чисел от 0 до V—1 и добавляет их в граф как ребра, однако она использует другую вероятностную модель, по условиям которой каждое возможное ребро появляется независимо от других с вероятностью p. Значение p вычисляется таким образом, чтобы ожидаемое количество ребер (pV(V—1)/2) было равно E. Количество ребер в каждом конкретном графе, сгенерированном этой программой, близко к E, однако вряд ли точно равно E. Этот метод пригоден в основном для насыщенных графов, поскольку время его выполнения пропорционально V2.

  static void randG(Graph &G, int E)
    { double p = 2.0*E/G.V()/(G.V()-1);
      for (int i = 0; i < G.V(); i++)
        for (int j = 0; j < i; j++)
          if (rand() < p*RAND MAX)
            G.insert(Edge(i, j));
    }
      

k-соседний граф. Граф, изображенный в верхней части рис. 17.13, получен в результате простого изменения в генераторе графов со случайными ребрами, мы выбираем случайным образом первую вершину v, затем случайным образом выбираем следующую вершину из тех, индексы которых отстоят от v не более чем на постоянное число к (с возвратом от V-1 до 0, если вершины упорядочены в виде окружности, как на рис. 17.13). Такие графы легко генерируются и, безусловно, обладают свойством локальности, которое не характерно для случайных графов.

 Случайные графы с соседними связями


Рис. 17.13.  Случайные графы с соседними связями

Здесь приведены примеры двух моделей разреженных графов. Граф с соседними связями в верхней части рисунка содержит 33 вершины и 99ребер, и каждое ребро может соединять одну вершину с другой, если их индексы отличаются не более чем на 10 (по модулю V). Евклидов граф с соседними связями в нижней части рисунка моделирует графы, которые встречаются в приложениях, где вершины привязаны к конкретным геометрическим точкам. Для вершин выбраны случайные точки на плоскости, а ребра соединяют любую пару вершин, расстояние между которыми не превышает d.

Этот граф относится к категории разреженных (177вершин и 1001 ребро). Изменяя значения d, можно построить граф любой степени насыщенности.

Евклидов граф с соседними связями. Граф, показанный в нижней части рис. 17.13, вычерчен генератором, который выбирает на плоскости V точек со случайными координатами от 0 до 1, а затем генерирует ребра, соединяющие любые две точки, расстояние между которыми не превышает d. Если d невелико, то граф получается разреженным, а если d большое, то граф насыщенный (см. упражнение 17.74). Такой граф моделирует графы, с которыми мы сталкиваемся при работе с картами, электронными схемами или другими приложениями, где вершины привязаны к определенным геометрическим точкам. Их нетрудно представить наглядно, они позволяют наглядно увидеть свойства алгоритмов, характерные для подобных приложений.

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

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

 Граф транзакций


Рис. 17.14.  Граф транзакций

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

Граф транзакций. На рис. 17.14 показан всего лишь небольшой фрагмент графа, который можно обнаружить в компьютерах телефонной компании. В этом графе каждому телефонному номеру соответствует вершина, а каждое ребро, соединяющее пару i и j, соответствует телефонному звонку от i к j в течение некоторого фиксированного промежутка времени. Это множество ребер представляет собой мультиграф огромных размеров. Он, естественно, разрежен, поскольку каждый абонент звонит лишь в мизерную часть всех доступных телефонов. Этот граф характерен и для многих других приложений. Аналогичную информацию может, например, содержать кредитная карточка финансового учреждения и записи кредитной истории.

Граф вызовов функций. Граф можно поставить в соответствие любой компьютерной программе: функции будут в нем вершинами графа, а ребро будет соединять вершину X с вершиной Y всякий раз, когда функция X вызывает функцию Y. Программу можно даже оснастить средством построения таких графов (или переложить эту задачу на компилятор). Нас интересуют два абсолютно различных графа: статическая версия, когда ребра создаются на этапе компиляции на основе вызовов функций, которые находятся в коде каждой функции; и динамическая версия, когда ребра создаются во время выполнения программы, при фактическом выполнении вызовов. Статические графы вызовов функций нужны для изучения структуры программы, а динамические -для изучения поведения программы. Обычно это большие разреженные графы.

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

В некоторых приведенных выше примерах вершины представляют собой естественные имена объектов, а ребра -пары именованных объектов. Например, граф транзакций может быть построен из последовательности пар телефонных номеров, а евклидов граф -из последовательности пар населенных пунктов. Программа 17.14 представляет собой реализацию функции scan из программы 17.4, которой можно воспользоваться для построения графов в общих ситуациях. Для удобства клиентских программ она использует в качестве определения графа множество ребер и определяет множество имен вершин графа на основании их присутствия в ребрах. А именно, программа считывает последовательность пар символов из стандартного ввода, использует таблицу символов для связывания этих символов с номерами вершин от 0 до V-1 (где V -количество различных символов во входных данных) и строит граф путем вставки ребер, как в программах 17.12 и 17.13. Для потребности программы 17.14 можно приспособить любую реализацию таблицы символов -например, программу 17.15, в которой используются TST-деревья (см. лекция №14). Эти программы упрощают тестирование алгоритмов на реальных графах, которые невозможно точно описать какой бы то ни было вероятностной моделью.

Программа 17.14. Построение графа из пар символов

Данная реализация функции scan из программы 17.4 использует таблицу символов для построения графа на основе пар символов, считываемых из стандартного ввода. Функция index АТД таблицы символов ставит в соответствие каждому символу целое число: если поиск в таблице размера N заканчивается неудачно, она добавляет в таблицу символ с привязанным к нему целым числом N+1; а если поиск завершается успешно, она просто возвращает целое число, которое ранее было связано с этим символом. Годится любой метод работы с таблицами символов из рассмотренных в части IV -например, программа 17.15.

  #include "ST.cc"
  template <class Graph>
  void IO<Graph>::scan(Graph &G)
    { string v, w;
      ST st;
      while (cin >> v >> w)
        G.insert(Edge(st.index(v), st.index(w)));
    }
      

Программа 17.15 важна еще и потому, что она обосновывает наше допущение, которое было сделано во всех разрабатываемых нами алгоритмах: что имена вершин являются целочисленными значениями от 0 до V-1. Если у какого-то графа имеется другое множество имен вершин, то перед построением представления графа нужно выполнить программу 17.15, чтобы переобозначить имена вершин целыми числами из диапазона от 0 до V-1.

Программа 17.15. Символьная индексация имен вершин

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

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

  #include <string>
  class ST
    { int N, val;
      struct node
        { int v, d; node* l, *m, *r;
          node(int d) : v(-1), d(d), l(0), m(0), r(0) {}
        } ;
      typedef node* link;
      link head;
      link indexR(link h, const string &s, int w)
        { int i = s[w];
          if (h == 0) h = new node(i);
          if (i == 0)
            { if (h->v == -1) h->v = N++;
              val = h->v;
              return h;
            }
          if (i < h->d) h->l = indexR(h->l, s, w);
          if (i == h->d) h->m = indexR(h->m, s, w+1);
          if (i > h->d) h->r = indexR(h->r, s, w);
          return h;
        }
    public:
      ST() : head(0), N(0) { }
      int index(const string &key)
        { head = indexR(head, key, 0); return val; }
    } ;
      

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

Граф со степенями разделения. Рассмотрим некоторую совокупность подмножеств из V элементов и определим граф следующим образом: каждому элементу объединения подмножеств соответствует одна вершина, а ребро между двумя вершинами существует в том случае, если обе вершины принадлежат одному подмножеству (см. рис. 17.15). Это может быть и мультиграф, в котором ребра помечены именами соответствующих подмножеств. Говорят, что все элементы, инцидентные данному элементу v, отделены от него одной степенью разделения (degree of separation). Иначе все элементы, инцидентные какому-либо элементу, который отделен i степенями разделения от вершины v (о которых еще не известно, сколькими степенями разделения они отделены от вершины v —i или меньше), отделены i + 1 степенями разделения от вершины v. Это построение развлекало многих людей, от математиков (числа Эрдеша (Erdos)) до любителей кинофильмов ( " шесть шагов до Кевина Бэкона " ).

 Граф со степенями разделения


Рис. 17.15.  Граф со степенями разделения

Граф в нижней части рисунка определяется группами, показанными в верхней части: каждому имени соответствует вершина, а ребра соединяют вершины с именами, попадающими в одну и ту же группу. Кратчайшие длины путей в графе соответствуют степеням разделения. Например, Фрэнк отделен от Алисы и Боба тремя степенями разделения.

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

Граф де Брюйна. Предположим, что V равно степени 2. Мы определяем орграф следующим образом: каждому неотрицательному целому числу, меньшему V, соответствует одна вершина графа, а ребра соединяют каждую вершину i с вершинами 2i mod V и (2i + 1) mod V. Эти графы удобны для изучения последовательностей значений, которые возникают в сдвиговых регистрах фиксированной длины при выполнении последовательностей сдвигов всех разрядов на одну позицию влево, когда самый левый разряд отбрасывается, а самый правый разряд заполняется нулем или единицей. На рис. 17.16 изображены графы де Брюйна (de Bruijn) с 8, 16, 32 и 64 вершинами.

 Графы де Брюйна


Рис. 17.16.  Графы де Брюйна

Орграф де Брюйна порядка n содержит 2n вершин и ребра, соединяющие вершины i с вершинами 2i mod 2n и (2i + 1) mod 2n для всех i . Здесь показаны неориентированные графы, соответствующие орграфам де Брюйна порядка 6, 5, 4 и 3 (сверху вниз).

Различные виды графов, описанные в этом разделе, обладают различными характеристиками. Однако для наших программ они все одинаковы: это просто множества ребер. Как мы уже убедились в главе 1 лекция №1, определение даже простейших свойств графов может оказаться сложной вычислительной проблемой. В этой книге будут описаны различные хитроумные алгоритмы, которые разработаны для решения практических задач, связанных со многими видами графов.

Примеры, приведенные выше в данном разделе, показывают, что графы -это сложные комбинаторные объекты, намного сложнее лежащих в основе других алгоритмов, которые были рассмотрены в частях I—IV. Во многих случаях графы, которые приходится обрабатывать в приложениях, трудно или даже невозможно как-то охарактеризовать. Алгоритмы, которые хорошо работают на случайных графах, часто неприменимы к реальным ситуациям ввиду того, что зачастую трудно генерировать случайные графы с такими же структурными характеристиками, что и реальные графы. Обычно в таких ситуациях разрабатываются алгоритмы, которые хорошо работают в худшем случае. Этот подход годится в одних случаях, но не оправдывает ожиданий в других (в силу своей консервативности).

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

Упражнения

17.61. Какую часть генерируемых ребер составляют петли, если для построения случайных графов с насыщенностью aV используется программа 17.12?

17.62. Вычислите ожидаемое количество полученных параллельных ребер, если для генерации случайных графов с V вершинами и насыщенностью а используется программа 17.12. Воспользуйтесь полученным результатом для построения графиков зависимости доли параллельных ребер от a при V= 10, 100 и 1000.

17.63. Воспользуйтесь контейнером map из библиотеки STL для разработки альтернативной реализации класса ST из программы 17.15.

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

17.65. Напишите программу, которая генерирует разреженные случайные графы для специально подобранных наборов значений V и E и выводит объем памяти, необходимый для представления графа, и время на его построение. Протестируйте программу с помощью класса разреженного графа (программа 17.9) и генератора случайных графов (программа 17.12), чтобы иметь возможность выполнять обоснованные эмпирические исследования графов, построенных на базе этой модели.

17.66. Напишите программу, которая генерирует насыщенные случайные графы для специально подобранных наборов значений V и E и выводит объем памяти, необходимый для представления графа, и время на его построение. Протестируйте программу с помощью класса насыщенного графа (программа 17.7) и генератора случайных графов (программа 17.13), чтобы иметь возможность выполнять обоснованные эмпирические исследования графов, построенных на базе этой модели.

17.67. Приведите среднеквадратичное отклонение количества ребер, генерируемых программой 17.13.

17.68. Напишите программу, которая строит каждый возможный граф с точно такой же вероятностью, что и программа 17.13, но затрачивает время и объем памяти, пропорциональные V+E, а не V2. Протестируйте программу, как описано в упражнении 17.65.

17.69. Напишите программу, которая строит каждый возможный граф с точно такой же вероятностью, что и программа 17.12, но затрачивает время и объем памяти, пропорциональные E, даже для насыщенности, близкой к 1. Протестируйте программу, как описано в упражнении 17.66.

17.70. Напишите программу, которая генерирует с равной вероятностью каждый возможный граф с Vвершинами и E ребрами (см. упражнение 17.9). Протестируйте программу, как описано в упражнении 17.65 (для ненасыщенных графов) и в упражнении 17.66 (для насыщенных).

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

17.72. Напишите программу, которая генерирует случайные орграфы, соединяя вершины, которые упорядочены на сетке размером , с соседними вершинами, при этом каждое возможное ребро появляется с вероятностью p (см. рис. 1.2). Определите, каким должно быть p, чтобы ожидаемое количество ребер было равно E. Протестируйте программу, как описано в упражнении 17.65.

17.73. Внесите в программу из упражнения 17.72 возможность добавления R дополнительных случайных ребер, вычисленных как в программе 17.12. Для больших R сожмите решетку настолько, чтобы общее количество ребер оставалось примерно равным V.

17.74. Напишите программу, которая генерирует V случайных точек на плоскости, а потом строит граф из ребер, соединяющих все пары точек, удаленных друг от друга на расстояние, не превышающее d (см. рис. 17.13 рис. 17.13 и программу 3.20). Определите, какое значение d следует выбрать, чтобы ожидаемое количество ребер было равно E. Протестируйте программу, как описано в упражнении 17.65 (для ненасыщенных графов) и в упражнении 17.66 (для насыщенных).

17.75. Напишите программу, которая генерирует в единичном интервале V случайные интервалы длиной d, а затем строит соответствующий интервальный граф. Определите, какое значение d следует выбрать, чтобы ожидаемое количество ребер было равно E. Протестируйте программу, как описано в упражнении 17.65 (для ненасыщенных графов) и в упражнении 17.66 (для насыщенных). Указание. Воспользуйтесь BST-деревом.

17.76. Напишите программу, которая случайным образом выбирает V вершин и E ребер из реального графа, найденного в упражнении 17.64. Протестируйте программу, как описано в упражнении 17.65 (для ненасыщенных графов) и в упражнении 17.66 (для насыщенных).

17.77. Один из способов определения транспортной системы -с помощью множества последовательностей вершин, причем каждая такая последовательность определяет путь, соединяющий вершины. Например, последовательность 0-9-3-2 определяет ребра 0-9, 9-3 и 3-2. Напишите программу, которая строит граф по данным из входного файла, содержащего в каждой строке одну последовательность символьных имен. Подготовьте входные данные, которые позволят использовать эту программу для построения графа, соответствующего схеме московского метро.

17.78. Добавьте в решение упражнения 17.77 возможность ввода координат вершин в стиле упражнении 17.60, чтобы можно было работать с графическими представлениями графов.

17.79. Примените преобразования, описанные в упражнениях 17.34—17.37, к различным графам (см. упражнения 17.63—17.76) и сведите в таблицу количество вершин и ребер, удаленных при каждом таком преобразовании.

17.80. Реализуйте конструктор для программы 17.1, который позволит клиентам строить граф разделения без необходимости вызова функции для каждого неявного ребра. То есть количество вызовов функции, необходимых клиенту для построения графа, должно быть пропорционально сумме размеров групп. Разработайте эффективную реализацию этого измененного АТД (на основе структур данных с использованием групп, но без неявных ребер).

17.81. Приведите строгую верхнюю границу количества ребер любого графа разделения для N различных групп с к человек в каждой группе.

17.82. Начертите графы в стиле рис. 17.16, которые содержат V вершин, пронумерованных от 0 до V—1, и ребра, соединяющие вершину i с вершиной , для V= 8, 16 и 32.

17.83. Измените интерфейс АТД из программы 17.1, чтобы позволить клиентам использовать символьные имена вершин и ребра в виде пар экземпляров обобщенного типа Vertex. Полностью скройте от клиентских программ представление, использующее индексацию именами вершин и АТД таблицы символов.

17.84. Добавьте в интерфейс АТД из упражнения 17.83 функцию, которая поддерживает операцию объединить (графы), и напишите реализации для представлений матрицей смежности и списками смежности. Примечание: В результирующем графе должны присутствовать все вершины и ребра каждого исходного графа, но вершины, имеющиеся в обоих графах, должны присутствовать только один раз. Кроме того, нужно удалять параллельные ребра.

Простые, эйлеровы и гамильтоновы пути

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

Эти задачи уводят нас от локальных свойств, таких как существование конкретных ребер или определение степеней вершин, к глобальным свойствам, которые могут кое-что сказать о структуре графа. Наиболее фундаментальное свойство графа -связность двух его вершин. Если они связаны, то хотелось бы найти простейший путь, который их связывает.

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

Программа 17.16 представляет собой непосредственное решение этой задачи. В ее основу положен поиск в глубину (depth-first search) -фундаментальный принцип обработки графов, на котором мы кратко останавливались в лекция №3 и 5, и который будет подробно рассмотрен в лекция №18.

Программа 17.16. Поиск простого пути

Этот класс использует рекурсивную функцию поиска в глубину searchR, которая находит простой путь, соединяющий две заданные вершины графа, и предоставляет функцию-член exists, позволяющую клиенту проверить, существует ли путь между вершинами. Для двух заданных вершин v и w функция searchR проверяет каждое ребро v-t, смежное с v, может ли оно быть первым ребром на пути к w. Вектор visited, индексированный именами вершин, предотвращает повторное использование любой вершины, то есть находятся только простые пути.

  template <class Graph>
  class sPATH
    { const Graph &G;
      vector <bool> visited;
      bool found;
      bool searchR(int v, int w)
        { if ( v == w) return true;
          visited[v] = true;
          typename Graph::adjIterator A(G, v);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (!visited[t])
              if (searchR(t, w)) return true;
          return false;
        }
    public:
      sPATH(const Graph &G, int v, int w) :
        G(G), visited(G.V(), false)
        { found = searchR(v, w); }
      bool exists() const
        { return found; }
   };
      

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

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

Одно лишь первое изменение приводит к тому, что путь из v в w будет выведен в обратном порядке: если вызов searchR(t, w) находит путь из t в w (и выводит составляющие его ребра в обратном порядке), то для вывода пути из v в w остается вывести путь t-v. Второе изменение меняет порядок: чтобы вывести ребра, составляющие путь из v в w, нужно вывести путь из w в v в обратном порядке. (Этот прием годится только для неориентированных графов.) Такую стратегию можно применить и для реализации функции АТД, которая вызывает клиентскую функцию для каждого ребра пути (см. упражнение 17.88).

На рис. 17.17 приведен пример динамики рекурсии. Как и в случае любой другой рекурсивной программы (вообще-то любой программы с вызовами функций), получить подобную трассировку нетрудно. Чтобы внести такую возможность в программу 17.16, можно добавить переменную depth для отслеживания глубины рекурсии (ее значение увеличивается на 1 при входе и уменьшается на 1 при выходе), а затем вставить в начало рекурсивной функции код вывода depth пробелов перед нужной информацией (см. упражнения 17.86 и 17.87).

 Трассировка поиска простого пути


Рис. 17.17.  Трассировка поиска простого пути

Данная трассировка показывает, как работает рекурсивная функция из программы 17.16при вызове searchR(G, 2.6) для поиска простого пути из вершины 2 в вершину 6 на графе, который показан в верхней части рисунка. Для каждого рассматриваемого ребра выводится отдельная строка с отступом на один уровень больше для каждого рекурсивного вызова. Для проверки ребра 2-0 нужен вызов searchR(G,0,6). Для его завершения необходимо проверить ребра 0-1, 0-2 и 0-5. Для проверки ребра 0-1 нужен вызов searchR(G, 1.6) -для его завершения необходимо проверить ребра 1-0 и 1-2, которые не приводят к рекурсивным вызовам, поскольку вершины 0 и 2 уже помечены. В этом примере функция находит путь 2-0-5-4-6.

Лемма 17.2. Путь, соединяющий две заданных вершины графа, можно найти за линейное время.

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

Когда в контексте алгоритмов на графах мы используем термин линейное (linear), это означает, что количественное значение не превосходит величины V+E (размер графа), умноженной на некоторый постоянный коэффициент. Как было сказано в конце раздела 17.5, такое значение также обычно не превосходит размера представления графа, умноженного на некоторый постоянный коэффициент. Формулировка свойства 17.2 позволяет, как обычно, использовать представление списками смежности для разреженных графов и представление матрицей смежности для насыщенных графов. Термин " линейный " нельзя применять для описания алгоритма, который использует матрицу смежности и выполняется за время, пропорциональное V2 (даже если он линеен по отношению к размеру представления графа) -кроме случаев, когда граф является насыщенным. Вообще-то при представлении разреженного графа матрицей смежности линейный по времени алгоритм невозможен для любой задачи обработки графов, в которой нужно перебрать все ребра.

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

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

 Гамильтонов цикл


Рис. 17.18.  Гамильтонов цикл

В графе, приведенном вверху, имеется гамильтонов цикл 0-6-4-2-1-3-5-0, который проходит через каждую вершину точно один раз и возвращается в первоначальную вершину. В нижнем графе такого цикла нет.

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

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

Лемма 17.3. Рекурсивный поиск гамильтонова цикла может потребовать экспоненциального времени.

Доказательство. Рассмотрим граф, у которого (V-1)-я вершина изолирована, а ребра, связывающие остальные V—1 вершин, образуют полный граф. Программа 17.17 не найдет гамильтонов путь, но по индукции легко видеть, что она перебирает все (V-1)! путей в полном графе, каждый из которых требует V—1 рекурсивных вызовов (рис. 17.19). Следовательно, общее количество рекурсивных вызовов равно V! или примерно (V/e)V , что больше любой константы в степени V.

 Трассировка поиска гамильтонова цикла


Рис. 17.19.  Трассировка поиска гамильтонова цикла

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

Полученные нами реализации -программа 17.16 для поиска простых путей, и программа 17.17 для поиска гамильтоновых путей -очень похожи друг на друга. При отсутствии путей выполнение обеих программ прекращается, когда все элементы вектора visited становятся равными true. Но почему времена выполнения этих программ так разительно отличаются? Программа 17.16 гарантированно выполняется за короткое время, поскольку она заносит true по крайней мере в один элемент вектора visited при каждом вызове searchR. А программа 17.17 может снова сбрасывать элементы вектора visited, поэтому гарантировать ее быстрое выполнение невозможно.

При поиске простых путей программой 17.16 мы знаем, что если существует путь из v в w, то его можно найти, выбрав одно из ребер v-t, исходящих из v; то же самое верно и в отношении гамильтоновых путей. Но на этом сходство заканчивается.

Программа 17.17. Гамильтонов путь

Данная рекурсивная функция отличается от функции из программы 17.16 всего лишь двумя моментами, во-первых, она принимает длину искомого пути в качестве третьего аргумента и завершается успешно, только если находит путь длины V; во-вторых, при неудачном завершении она сбрасывает маркер visited.

Если заменить этой функцией рекурсивную функцию в программе 17.16 и добавить третий аргумент G.V()-1 в вызов функции searchR, то будет найден гамильтонов путь. Однако не рассчитывайте, что поиск завершится для любых графов, кроме самых маленьких (см. текст).

  bool searchR(int v, int w, int d)
    { if (v == w) return (d == 0) ;
      visited[v] = true;
      typename Graph::adjIterator A(G, v);
      for (int t = A.beg(); !A.end(); t = A.nxt())
        if (!visited[t])
          if (searchR(t, w, d-1)) return true;
      visited[v] = false;
      return false;
    }
      

Если невозможно найти простой путь из t в w, то это значит, что простого пути из v в w, проходящего через t, не существует; но в процессе поиска гамильтонова пути ситуация иная. Может случиться так, что в графе нет гамильтонова пути в вершину w, который начинается с ребра v-t, но есть путь, который начинается с v-x-t для некоторой вершины x. И придется выполнить рекурсивные вызовы из t, соответствующие каждому пути, который ведет в нее из вершины v. Короче говоря, нам может понадобиться проверить каждый путь в графе.

Задумайтесь, насколько медленно работает алгоритм с факториальным временем выполнения. Если, к примеру, граф с 15 вершинами можно обработать за 1 секунду, то обработка графа с 19 вершинами будет длиться целые сутки, более года для 21 вершины и 6 столетий, если граф содержит 23 вершины. Увеличение быстродействия компьютера практически не помогает. Если повысить быстродействие компьютера в 200 000 раз, то для решения рассматриваемой задачи с 23 вершинами ему потребуется больше суток. Но затраты на обработку графа со 100 или 1000 неимоверно велики, не говоря уже о графах, с которыми нам приходится сталкиваться на практике. Потребуются многие миллионы страниц этой книги, чтобы только записать количество веков, необходимых для обработки графа, содержащего миллионы вершин.

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

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

 Примеры эйлерового цикла и пути


Рис. 17.20.  Примеры эйлерового цикла и пути

Граф в верхней части рисунка содержит эйлеров цикл 0-1-2-0-6-4-3-2-4-5-0 , который использует все ребра в точности один раз. Граф в нижней части рисунка не содержит такого цикла, однако содержит эйлеров путь 1-2-0-1-3-4-2-3-5-4-6-0-5.

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

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

 Кенигсбергские мосты


Рис. 17.21.  Кенигсбергские мосты

Широко известная задача, которую изучал Эйлер, связана с городом Кенигсберг, где на разветвлении реки Прегель находится остров, соединенный с берегами семью мостами (вверху). Существует ли способ пройти семь мостов во время непрерывной прогулки по городу, не проходя ни по одному из них дважды? Если обозначить остров цифрой 0, берега реки -цифрами 1 и 2, а промежуток между рукавами реки -цифрой 3 и определить ребра, соответствующие каждому мосту, то получится мультиграф, показанный внизу. Требуется найти такой путь, который использует каждое ребро точно один раз.

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

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

Лемма 17.4. Граф содержит эйлеров цикл тогда и только тогда, когда он связен и все его вершины имеют четную степень.

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

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

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

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

Следствие. Граф содержит эйлеров путь тогда и только тогда, когда он связный и в точности две его вершины имеют нечетную степень.

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

 Частичные циклы


Рис. 17.22.  Частичные циклы

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

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

Как было установлено в разделе 17.5, все степени вершин можно найти за время, пропорциональное E для представления списками смежности или множеством ребер, либо за время, пропорциональное V2 для представления графа матрицей смежности -или же в составе представления графа можно использовать вектор, индексированный именами вершин, который содержит степени вершин (см. упражнение 17.42). При наличии такого вектора можно проверить, выполняется ли лемма 17.4, за время, пропорциональное V. Программа 17.18 реализует эту стратегию и показывает, что проверка, имеется ли в заданном графе эйлеров цикл, представляет собой достаточно простую вычислительную задачу. Это важно, потому что интуитивно совсем непонятно, проще ли эта задача, чем определение, существует ли гамильтонов путь в заданном графе.

Программа 17.18. Существование эйлерова цикла

Этот класс позволяет клиентским программам проверить существование эйлерова цикла в графе. Вершины v и w рассматриваются как приватные члены данных, чтобы клиенты могли вывести путь с помощью функции-члена show (которая использует приватную функцию-член tour) (см. программу 17.19).

Для выполнения проверки используются следствие из леммы 17.4 и программа 17.11. Она выполняется за время, пропорциональное V, не считая времени на предварительную обработку, когда выполняется проверка связности и построение таблицы степеней вершин типа DEGREE.

  template <class Graph>
  class ePATH {
    Graph G;
    int v, w;
    bool found;
    STACK <int> S;
    int tour(int v);
  public:
    ePATH(const Graph &G, int v, int w) :
      G(G), v(v), w(w)
      { DEGREE<Graph> deg(G);
        int t = deg[v] + deg[w];
        if ((t % 2) != 0) { found = false; return; }
        for (t = 0; t < G.V(); t++)
          if ((t != v) && (t != w))
            if ((deg[t] % 2) != 0)
              { found = false; return; }
        found = true;
      }
    bool exists() const
      { return found; }
    void show();
  } ;
      

А теперь предположим, что требуется найти сам эйлеров цикл. Прямая рекурсивная реализация (поиск пути с помощью проверки ребра с последующим рекурсивным вызовом для поиска пути в остальной части графа) обеспечивает ту же факториальную производительность, что и программа 17.17. Но с такой производительностью мириться не хочется, ведь проверить существование такого пути довольно легко, и мы попробуем отыскать более приемлемый алгоритм. Можно избежать факториальной зависимости с помощью проверки за фиксированное время, можно ли использовать конкретное ребро (в отличие от неизвестных затрат при рекурсивных вызовах), но мы оставим этот подход на самостоятельную проработку (см. упражнения 17.96 и 17.97).

Другой подход вытекает из доказательства леммы 17.4. Можно пройти по циклическому пути, удаляя все использованные ребра и помещая в стек все встреченные вершины, чтобы можно было (1) проследить свой путь с текущей точки до начала и вывести его ребра, и (2) проверить каждую вершину на наличие боковых путей (которые можно включить в главный путь). Этот процесс показан на рис. 17.23.

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

 Поиск эйлерова цикла методом удаления циклов


Рис. 17.23.  Поиск эйлерова цикла методом удаления циклов

Здесь на простом графе показано, как программа 17.19 находит эйлеров цикл с началом и концом в вершине 0. Жирные ребра -те, которые входят в цикл, содержимое стека показано под каждой диаграммой, а списки смежности для ребер, не попавших в цикл, показаны слева от диаграмм.

Сначала программа добавляет в искомый цикл ребро 0-1 и удаляет его из списков смежности (из двух мест) (верхняя левая диаграмма, списки слева). Затем она точно так же добавляет в цикл ребро 1-2 (вторая сверху левая диаграмма). Потом она поворачивает назад в 0, но продолжает строить цикл 0-5-4-6-0 и возвращается в вершину 0, у которой больше не осталось инцидентных ребер (вторая сверху правая диаграмма). Затем она выталкивает из стека изолированные вершины 0 и 6, так что вверху стека остается вершина 4, и начинает цикл с 4 (третья сверху правая диаграмма), проходит через вершины 3, 2 и возвращается в 4, после чего из стека выталкиваются все уже изолированные вершины 4 , 2 , 3 и т.д. Последовательность вытолкнутых из стека вершин определяет эйлеров цикл 0-6-4-2-3-4-5-0-2-1-0 для всего графа.

Лемма 17.5. Если в графе существует эйлеров цикл, то его можно найти за линейное время.

Полное доказательство этой леммы методом индукции мы оставляем на самостоятельную проработку (см. упражнение 17.100). По существу, после первого вызова функции path в стеке содержится путь от v до w, а оставшаяся часть графа (после удаления изолированных вершин) состоит из связных компонентов меньших размеров (имеющих по крайней мере одну общую вершину с найденным на текущий момент путем), которые также содержат эйлеровы циклы. Изолированные вершины выталкиваются из стека, и с помощью функции path продолжается аналогичный поиск эйлеровых циклов, которые содержат неизолированные вершины. Каждое ребро графа заталкивается в стек (и выталкивается из него) в точности один раз, поэтому общее время выполнения пропорционально E.

Программа 17.19. Поиск эйлерова пути с линейным временем выполнения

Данная реализация функции show для класса из программы 17.18 выводит эйлеров путь между двумя заданными вершинами, если он существует. В отличие от многих других наших реализаций, этот код основан на реализации АТД Graph с конструктором копирования, поскольку он создает копию графа, а потом уничтожает эту копию, удаляя ребра из графа при выводе пути. При наличии линейной по времени реализации функции remove (см. упражнение 17.46) функция show выполняется за линейное время. Приватная функция-член tour проходит по ребрам циклического пути, удаляет их и помещает вершины в стек, чтобы выявить наличие боковых циклов (см. текст). Главный цикл вызывает функцию tour до тех пор, пока существуют боковые циклы.

  template <class Graph>
  int ePATH<Graph>::tour(int v)
    { while (true)
        { typename Graph::adjIterator A(G, v);
          int w = A.beg();
          if (A.end()) break;
          S.push(v);
          G.remove(Edge(v, w));
          v = w;
        }
      return v;
   }
  template <class Graph>
  void ePATH<Graph>::show()
    { if (found) return;
      while (tour(v) == v && !S.empty())
        { v = S.pop(); cout << "-" << v; }
      cout << endl;
    }
      

Хотя эйлеровы циклы позволяют систематически обойти все ребра и вершины, они довольно редко используются на практике, т.к. лишь немногие графы содержат такие циклы. Вместо этого для исследования графов обычно применяется поиск в глубину, который подробно рассматривается в лекция №18. Вообще-то, как мы убедимся позже, поиск в глубину на неориентированном графе эквивалентен вычислению двунаправленного эйлерова цикла (two-way Euler tour) -пути, который проходит по каждому ребру в точности дважды, по одному разу в каждом направлении.

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

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

Упражнения

17.85. Покажите в стиле упражнения 17.17 трассу рекурсивных вызовов (и пропущенные вершины) при поиске программой 17.16 пути из вершины 0 в вершину 5 в графе 3-7 1-4 7-8 0-5 5-2 3-8 2-9 0-6 4-9 2-6 6-4.

17.86. Добавьте в рекурсивную функцию из программы 17.16 возможность вывода трассы, как на рис. 17.17, используя для этого глобальную переменную, как описано в тексте.

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

17.88. Используя метод, описанный в тексте, напишите реализацию класса sPATH с общедоступной функцией-членом, которая вызывает клиентскую функцию для каждого ребра на пути из v в w, если такой путь существует.

17.89. Измените программу 17.16 так, чтобы она принимала третий аргумент d и проверяла существование пути, соединяющего вершины u и v, длина которого больше d. А именно, значение search(v, v, 2) должно быть ненулевым тогда и только тогда, когда v содержится в некотором цикле.

17.90. Эмпирически определите вероятность того, что программа 17.16 найдет путь между двумя наугад выбранными вершинами в различных графах (см. упражнения 17.63—17.76) и вычислите среднюю длину пути, найденного для различных видов графов.

17.91. Рассмотрим графы, заданные следующими четырьмя наборами ребер:

0-10-20-31-31-42-52-93-64-74-85-85-96-76-97-8

0-10-20-31-30-32-55-63-64-74-85-85-96-76-98-8

0-11-21-30-30-42-52-93-64-74-85-85-96-76-97-8

4-17-96-27-35-00-20-81-63-96-32-81-59-84-54-7

Какие из этих графов содержат эйлеровы циклы? Какие из них содержат гамильтоновы циклы?

17.92. Сформулируйте необходимые и достаточные условия существования в ориентированном графе (ориентированного) эйлерова цикла.

17.93. Докажите, что каждый связный неориентированный граф содержит двунаправленный эйлеров цикл.

17.94. Измените доказательство леммы 17.4, чтобы оно годилось и для графов с параллельными ребрами и петлями.

17.95. Покажите, что если добавить еще один мост, то задача о Кенигсбергских мостах будет иметь решение.

17.96. Докажите, что в связном графе имеется эйлеров путь из v в w только в том случае, если он содержит ребро, инцидентное v, удаление которого не нарушает связности графа (если не учитывать возможной изоляции вершины v).

17.97. Воспользуйтесь упражнением 17.96 для разработки эффективного рекурсивного метода поиска эйлерова цикла в графе, если такой цикл существует. Помимо функций базового АТД графа, можно воспользоваться классами, рассматриваемыми в данной главе, которые определяют степени вершин (см. программу 17.11) и проверяют, существует ли путь между двумя заданными вершинами (см. программу 17.16). Реализуйте и протестируйте полученную программу как на разреженных, так и на насыщенных графах.

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

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

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

17.101. Найдите количество содержащих эйлеров цикл графов с V вершинами для максимального числа V, для которого вы можете выполнять реальные вычисления.

17.102. Эмпирически определите для различных графов среднюю длину пути, найденного первым вызовом функции path в программе 17.19 (см. упражнения 17.63—17.76). Вычислите вероятность того, что этот путь является циклом.

17.103. Напишите программу, которая вычисляет последовательность из 2n + n -1 битов, в которой никакие две последовательности из n следующих подряд битов не совпадают. (Например, для n = 3 таким свойством обладает последовательность 0001110100.) Примечание: Найдите эйлеров цикл в орграфе де Брюйна.

17.104. Покажите в стиле рис. 17.19 трассу рекурсивных вызовов (и пропущенные вершины) при поиске программой 17.16 гамильтонова цикла в графе

3-71-47-80-55-23-82-90-64-92-66-4.

17.105. Добавьте в программу 17.17 возможность вывода гамильтонова цикла, если он будет найден.

17.106. Найдите гамильтонов цикл в графе

1-22-54-22-60-83-01-33-61-01-44-04-66-52-6

6-99-03-14-39-24-96-97-95-09-77-34-50-57-8,

либо докажите, что он не существует.

17.107. Найдите количество содержащих гамильтонов цикл графов с V вершинами для максимального значения V, для которого вы можете выполнять реальные вычисления.

Задачи обработки графов

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

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

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

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

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

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

Легкая задача обработки графа -это задача, которую можно решить с помощью компактных, элегантных и эффективных программ, к виду которых мы уже успели привыкнуть в частях I—IV Время выполнения таких программ зачастую линейно в худшем случае или ограничено полиномами низких степеней от количества вершин и/или ребер. Обычно, как мы делали в других областях, можно установить, что проблема относится к категории легких, если можно разработать примитивное решение, которое, будучи слишком медленным для крупных графов, вполне приемлемо для графов небольших, а иногда и средних размеров. Затем, зная, что задача имеет легкое решение, мы ищем эффективные решения, которыми можно воспользоваться на практике, и пытаемся выбрать наилучшее из них. Ярким примером легких задач может служить задача поиска эйлерова цикла, рассмотренная в разделе 17.7, а в лекциях 18—22 мы познакомимся с множеством других таких задач. Ниже приведены наиболее яркие примеры таких задач.

Разрешимая (tractable) задача обработки графов -это задача, для которой известен алгоритм решения, а его требования к времени и памяти ограничены полиномиальной функцией от размера графа ( V+E). Все легкие задачи разрешимы, однако мы проводим различия между ними, поскольку для многих разрешимых задач разработка эффективных и практичных программ их решения представляет собой исключительно трудную, если не невозможную, проблему. Такие решения могут оказаться слишком сложным, чтобы приводить их в данной книге, поскольку их реализации могут содержать сотни и даже тысячи строк кода. Ниже приведены два примера наиболее важных задач этого класса.

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

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

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

Трудноразрешимая (intractable) задача обработки графов -это задача, для которой не известен алгоритм, гарантирующий ее решение за приемлемый промежуток времени. Для многих таких задач характерно то, что для ее решения можно использовать примитивный метод, когда мы пытаемся вычислить решение, перебирая все варианты, а трудноразрешимыми они считаются потому, что таких вариантов слишком много. Этот очень широкий класс задач включает в себя многие важные задачи, решение которых хотелось бы знать. Для описания задач этого класса применяется термин NP-трудный (NP-hard). Многие специалисты уверены, что эффективных алгоритмов решения этих задач не существует. В части VIII мы более подробно рассмотрим, что послужило причиной для такой уверенности и этого термина. Хрестоматийным примером NP-трудной задачи обработки графов является задача поиска гамильтонова цикла, рассмотренная в разделе 17.7, а также задачи из приведенного ниже списка.

Эти задачи сформулированы как задачи существования -нужно определить, существует или не существует подграф конкретного типа. В некоторых задачах требуется определить размер наибольшего подграфа конкретного типа, а это можно сделать, сведя задачу существования к проверке существования подграфа размера к с нужным свойством с последующим бинарным поиском наибольшего из них. Однако на практике часто бывает нужно отыскать полное решение, которое в общем случае найти гораздо труднее. Например, известная теорема четырех красок (four color theorem) утверждает, что можно воспользоваться четырьмя цветами для раскраски всех вершин планарного графа таким образом, что ни одно ребро не будет соединять две вершины одного и того же цвета. Однако эта теорема ничего не говорит о том, как это сделать для конкретного плоского графа: знание о том, что такая раскраска существует, ничем не может помочь в поиске полного решения задачи. Другой известный пример -задача коммивояжера (traveling salesperson problem), в которой требуется определить путь обхода вершин взвешенного графа минимальной длины. Эта задача относится к тому же классу задач, что и задача поиска гамильтонова цикла, и нисколько не легче ее: если мы не можем найти эффективное решение задачи поиска гамильтонова пути, то не можем рассчитывать и на то, что найдем решение задачи коммивояжера. Как правило, сталкиваясь с трудными задачами, мы работаем с простейшими вариантами, которые в состоянии решить. Задачи существования в принципе соответствуют этому правилу, но, как мы увидим в части VIII, они играют важную роль в теории.

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

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

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

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

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

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

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

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

Таблица 17.2. Трудность классификации задач обработки графов
ЛРТ?
Неориентированные графы
Связностьv
Общая связностьv
Эйлеров циклv
Гамильтонов циклv
Двудольное сопоставлениеv
Максимальное сопоставлениеv
Планарностьv
Максимальная кликаv
Раскраска 2 цветамиv
Раскраска 3 цветамиv
Кратчайшие путиv
Самые длинные путиv
Вершинное покрытиеv
Изоморфизмv
Орграфы
Транзитивное замыканиеv
Сильная связностьv
Цикл нечетной длиныv
Цикл четной длиныv
Взвешенные графы
Минимальное остовое деревоv
Задача коммивояжераv
Сети
Кратчайшие пути (неотрицательные веса)v
Кратчайшие пути (отрицательные веса)v
Максимальный потокv
Распределениеv
Поток минимальной стоимостиv
Обозначения:
ЛЛегкая -известен эффективный классический алгоритм решения
РРазрешимая -решение существует (трудно получить реализацию)
ТТрудноразрешимая -эффективное решение неизвестно (NP-трудная задача)
?Неизвестно, существует ли решение

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

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

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

Упражнения

17.108. Докажите, что ни один из графов, изображенных на рис. 17.24, не может быть планарным.

17.109. Напишите клиентскую функцию АТД графа, которая выясняет, содержит ли заданный граф один из графов, показанных на рис. 17.24. Для этой цели воспользуйтесь примитивным алгоритмом, который проверяет все возможные подмножества из пяти вершин для клики и все возможные подмножества из шести вершин для полного двудольного графа. Примечание: Этой проверки недостаточно для доказательства планарности графа, поскольку она игнорирует условие, что удаление вершин степени 2 в некоторых подграфах может дать один из двух запрещенных подграфов.

17.110. Начертите граф

3-71-47-80-55-23-02-90-64-92-6

6-41-58-29-08-34-52-31-63-57-6,

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

17.111. Найдите такой способ назначить один из трех цветов каждой вершине графа

3-71-47-80-55-23-02-90-64-92-6

6-41-58-29-08-34-52-31-63-57-6,

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

17.112. Решите задачу независимого множества для графа

3-71-47-80-55-23-02-90-64-92-6

6-41-58-29-08-34-52-31-63-57-6.

17.113. Каков размер максимальной клики в графе де Брюйна порядка п?

Лекция 18. Поиск на графе

Часто изучение свойств графа выполняется с помощью систематического просмотра каждой его вершины и каждого ребра. Определение некоторых простых свойств — например, вычисление степеней всех его вершин — выполняется просто перебором всех ребер (в любом порядке). Многие другие свойства графа связаны с путями на графах, поэтому они естественно определяются с помощью переходов от одной вершины к другой вдоль ребер графа. Эту базовую абстрактную модель используют почти все рассматриваемые нами алгоритмы обработки графов. В данной главе мы рассматриваем фундаментальные алгоритмы поиска на графах (graph search), которые используются для перемещения по графам с попутным изучением его структурных свойств.

Поиск на графе в таком виде эквивалентен исследованию лабиринта: коридоры лабиринта соответствуют ребрам графа, а места пересечения коридоров соответствуют вершинам графа. Когда программа меняет значение переменной с вершины v на вершину w из-за наличия ребра v-w, такое изменение можно рассматривать как переход в лабиринте из точки v в точку w. Мы начинаем данную главу с изучения систематического обхода лабиринтов. Это поможет нам наглядно представить, как базовые алгоритмы поиска на графах проходят через каждое ребро и каждую вершину графа.

В частности, рекурсивный алгоритм поиска в глубину в точности соответствует стратегии исследования лабиринта, описанной в разделе 18.1. Поиск в глубину представляет собой классический гибкий алгоритм, который применяется для решения задачи связности и множества других задач обработки графов. Возможны две реализации этого базового алгоритма: одна в виде рекурсивной процедуры и другая — с использованием явного стека. Замена стека очередью FIFO приводит к другому классическому алгоритму — поиску в ширину — который используется для решения других задач обработки графов, связанных с нахождением кратчайших путей.

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

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

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

Исследование лабиринта

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

Исследование лабиринта


Рис. 18.1.  Исследование лабиринта

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

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

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

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

Лемма 18.1. При обходе лабиринта методом Тремо мы зажигаем все лампы и открываем все двери в лабиринте и завершаем обход там, где его начали.

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

Из подробного примера, представленного на рис. 18.2 и рис. 18.3, мы видим, что при выборе очередного коридора возможны четыре различные ситуации:

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

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

 Пример применения метода Тремо для конкретного лабиринта


Рис. 18.2.  Пример применения метода Тремо для конкретного лабиринта

На этой диаграмме места, которые мы еще не посетили, заштрихованы (темные), а те места, в которых мы уже были, не заштрихованы (светлые). Мы полагаем, что на перекрестках горит свет, и что когда двери открыты с обоих концов коридора, этот коридор освещен. Исследование лабиринта мы начинаем с перекрестка 0 и выбираем коридор к перекрестку 2 (вверху слева). Далее мы продвигаемся по маршруту 6, 4, 3 и 5, по мере продвижения открывая двери в коридоры, зажигая свет на перекрестках и разматывая нить (слева). Открыв дверь, которая ведет из 5 в 0, мы видим, что перекресток 0 освещен, и поэтому игнорируем этот коридор (вверху справа). Аналогично, мы пропускаем коридор от 5 к 4 (справа, вторая диаграмма сверху), и нам остается только вернуться из 5 в 3 и далее в 4, сматывая нить в клубок. Когда мы откроем дверь коридора, ведущего из 4 в 5, мы видим через открытую дверь на другом конце коридора, что перекресток 5 освещен, и поэтому пропускаем этот коридор (справа внизу). Мы не прошли по коридору, соединяющему перекрестки 4 и 5, но мы осветили его, открыв двери с обоих концов.

 Пример применения метода Тремо для конкретного лабиринта (продолжение)


Рис. 18.3.  Пример применения метода Тремо для конкретного лабиринта (продолжение)

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

 Разбиение лабиринта


Рис. 18.4.  Разбиение лабиринта

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

Упражнения

18.1. Предположим, что из лабиринта, показанного на рис. 18.2 и рис. 18.3, удалены перекрестки 6 и 7 (а также все ведущие к ним коридоры), зато добавлен коридор, который соединяет перекрестки 1 и 2. Покажите обход полученного лабиринта методом Тремо в стиле рис. 18.2 и рис. 18.3.

18.2. Какая из представленных ниже последовательностей не может быть последовательностью включения ламп при обходе методом Тремо лабиринта, представленного на рис. 18.2 и 18.3?

0-7-4-5-3-1-6-2

0-2-6-4-3-7-1-5

0-5-3-4-7-1-6-2

0-7-4-6-2-1-3-5

18.3. Сколько существует различных путей обхода методом Тремо лабиринта, показанного на рис. 18.2 и рис. 18.3?

Поиск в глубину

Метод Тремо интересен тем, что он непосредствен -но приводит к классическому рекурсивному методу обхода графов: посетив конкретную вершину, мы помечаем ее как посещенную, а затем рекурсивно посещаем все смежные с ней непомеченные вершины. Такой метод уже был кратко рассмотрен в лекция №3 и лекция №5 и использовался для решения задачи нахождения путей в лекция №17 — он называется поиск в глубину (DFS — depth-first search). Это один из наиболее важных алгоритмов из применяемых нами. Метод DFS с виду прост, поскольку основан на знакомой идее, и его нетрудно реализовать, но на самом деле это очень гибкий и мощный алгоритм, который можно применять для решения множества трудных задач обработки графов.

Программа 18.1 содержит класс DFS, который посещает все вершины и просматривает все ребра связного графа. Подобно функциям поиска простого пути, рассмотренным в лекция №17, он основан на рекурсивной функции, которая использует приватный вектор для пометки пройденных вершин. В этой реализации используется вектор целых чисел ord, в котором сохраняется порядок посещения вершин. Трассировка на рис. 18.5 показывает, в каком порядке программа 18.1 обходит ребра и вершины, для примера, показанного на рис. 18.2 и рис. 18.3 (см. также рис. 18.17), при использовании реализации графа DenseGRAPH матрицей смежности из лекция №17. На рис. 18.6 изображен процесс исследования лабиринта в виде стандартного чертежа графа.

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

При обходе лабиринта каждый коридор встречается нам дважды, по одному разу с каждого конца. В графе каждое ребро также встречается дважды — по одному разу в каждой его вершине. При исследовании лабиринта методом Тремо мы открываем двери с обоих концов коридора. При поиске в глубину на неориентированном графе мы проверяем оба представления каждого ребра. Если мы встречаем ребро v-w, то либо выполняем рекурсивный вызов (если вершина w не помечена), либо пропускаем это ребро (если w помечена). Когда мы встретим это же ребро во второй раз, на этот раз как w-v, мы его игнорируем, поскольку вершину назначения v мы уже точно посещали (когда в первый раз встретились с этим ребром).

Программа 18.1. Поиск в глубину связного компонента

Класс DFS соответствует методу Тремо. Конструктор помечает как посещенные все вершины связного компонента, к которому принадлежит v; для этого он вызывает рекурсивную функцию searchC, которая посещает все вершины, смежные с v, проверяя их и вызывая себя для каждого ребра, которое ведет из v в непомеченную вершину. Клиенты могут воспользоваться функцией count для определения количества посещенных вершин и перегруженным оператором [] для определения последовательности посещения вершин алгоритмом.

  #include <vector>
  template <class Graph> class cDFS
    { int cnt;
      const Graph &G;
      vector <int> ord;
      void searchC(int v)
        { ord[v] = cnt++;
          typename Graph::adjIterator A(G, v);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (ord[t] == -1) searchC(t);
        }
    public:
      cDFS(const Graph &G, int v = 0) :
        G(G), cnt(0), ord(G.V(), -1)
        { searchC(v); }
      int count() const { return cnt; }
      int operator[](int v) const { return ord[v]; }
    };
      

 Трасса работы DFS


Рис. 18.5.  Трасса работы DFS

Здесь показан порядок, в котором алгоритм поиска в глубину проверяет ребра и вершины в представлении матрицей смежности графа, изображенного на рис. 18.2 и рис. 18.3(вверху), и содержимое вектора ord (справа) при выполнении поиска (звездочки означают -1 для не посещенных вершин). Каждому ребру графа соответствуют две строки, по одной на каждое направление. Величина отступа определяет уровень рекурсии.

 Поиск в глубину


Рис. 18.6.  Поиск в глубину

Данные диаграммы — графическое представление процесса, изображенного на рис. 18.5, в виде дерева рекурсивных вызовов при работе DFS. Ребра графа, выделенные жирными линиями, соответствуют ребрам в дереве DFS, показанном справа от каждой диаграммы. Ребра, выделенные серым — кандидаты на добавление в дерево на следующих шагах. На ранних стадиях (слева) дерево растет вниз в виде прямой линии, что соответствует рекурсивным вызовам для вершин 0, 2, 6 и 4. Затем выполняются рекурсивные вызовы для вершины 3, потом для вершины 5 (две верхних правых диаграммы), потом возврат из этих вызовов с последующими рекурсивными вызовами для вершины 7 из 4 (справа, вторая снизу) и для 1 из 7 (справа внизу).

Между поиском в глубину, реализованным в программе 18.1, и методом Тремо, изображенным на рис. 18.2 и рис. 18.3, имеется отличие, которое стоит рассмотреть, хотя во многих контекстах оно не играет никакой роли. При перемещении из вершины v в вершину w мы не проверяем элементы матрицы связности, которые соответствуют ребрам, ведущим из вершины w в другие вершины графа. В частности, мы знаем, что существует ребро из v в w, и оно будет проигнорировано, когда мы на него выйдем (поскольку v помечена как посещенная вершина). Это решение принимается в момент, отличный от решения в методе Тремо: там мы открываем дверь, соответствующую ребру из v в w, когда впервые переходим в вершину w из v. Если бы мы закрывали эти двери при входе и открывали при выходе (отметив коридор протянутой нитью), то тогда поиск в глубину точно соответствовал бы методу Тремо.

На рис. 18.6 показано дерево, соответствующее процессу рекурсивных вызовов на рис. 18.5. Это дерево рекурсивных вызовов, которое называется деревом DFS, представляет собой структурное описание процесса поиска. Как будет показано в разделе 18.4, слегка усовершенствованное дерево DFS может служить полным описанием не только структуры вызовов, но и динамики поиска.

Порядок обхода вершин зависит не только от графа, но и от его представления и реализации АТД. Например, на рис. 18.7 показана динамика поиска для реализации SparseMultiGRAPH списками смежности из лекция №17. В случае представления матрицей смежности ребра, инцидентные каждой вершине, просматриваются в числовой последовательности. В случае же представления списками смежности они просматриваются в порядке, в котором занесены в список. Это различие приводит к совершенно другой динамике рекурсивного поиска. К аналогичному отличию приводит и последовательность ребер в списках (например, из-за построения одного и того же графа вставками ребер в различном порядке). Кстати, наличие параллельных ребер никак не влияет на поиск в глубину: любое ребро, параллельное уже пройденному ребру, игнорируется, поскольку его конечная вершина уже отмечена как посещенная.

 Трасса DFS (списки смежности)


Рис. 18.7.  Трасса DFS (списки смежности)

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

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

Упражнения

18.4. Добавьте в программу 18.1 общедоступную функцию-член, которая возвращает размер связного компонента, просматриваемого конструктором.

18.5. Напишите клиентскую программу наподобие программы 17.6, которая просматривает граф, введенный из стандартного ввода, использует программу 18.1 для выполнения поиска из каждой вершины и выводит представление родительскими ссылками каждого остовного леса. Воспользуйтесь реализацией DenseGRAPH АТД графа из лекция №17.

18.6. В стиле рис. 18.5 представьте трассу вызовов рекурсивной функции, выполненных при построении объекта DFS<DenseGRAPH для графа

0-20-51-23-44-53-5.

Начертите соответствующее дерево рекурсивных вызовов DFS.

18.7. В стиле рис. 18.6 продемонстрируйте процесс поиска для примера из упражнения 18.6.

Функции АТД поиска на графе

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

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

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

Реализация в программе 18.2 демонстрирует все эти возможности. На рис. 18.8 показано влияние процесса посещения всех вершин на вектор ord любого производного класса. Как правило, производные классы, которые мы будем рассматривать, исследуют и все ребра, инцидентные каждой посещенной вершине. В таких случаях знание, что мы посетили все вершины, говорит, что мы посетили и все ребра, как в методе Тремо.

Программа 18.2. Поиск на графе

Данный базовый класс предназначен для обработки графов, которые могут быть несвязными. Производные классы должны содержать определение функции searchC, которая, будучи вызванной с петлей вершины v в качестве второго аргумента, заносит в ord[t] значение cnt++ для каждой вершины t, содержащейся в том же связном компоненте, что и v. Обычно конструкторы в производных классах вызывают функцию search, которая, в свою очередь, вызывает searchC для каждого связного компонента графа.

  template <class Graph>
  class SEARCH
    { protected: const Graph &G;
      int cnt;
      vector <int> ord;
      virtual void searchC(Edge) = 0;
      void search()
        { for (int v = 0; v < G.V(); v++)
          if (ord[v] == -1) searchC(Edge(v, v));
        }
    public:
      SEARCH (const Graph &G) : G(G),
        ord(G.V(), -1), cnt(0) { }
      int operator[](int v)
        const { return ord[v]; }
    };
      

 Поиск на графе


Рис. 18.8.  Поиск на графе

Таблица в нижней части рисунка содержит метки вершин (содержимое вектора ord) во время обычного поиска на графе, приведенном в верхней части рисунка. Сначала функция GRAPHsearch из программы 18.2 снимает пометки со всех вершин, т.е. присваивает им метки -1 (в таблице они представлены звездочками). Затем она вызывает функцию search для фиктивного ребра 0-0, которая помечает все вершины, содержащиеся в том же компоненте, что и 0 (вторая строка таблицы) — для этого она назначает им неотрицательные значения (в таблице проставлены 0). В данном примере вершины 0, 1, 4 и 9 помечаются значениями от 0 до 3 в этом же порядке. Далее просмотр слева направо обнаруживает не помеченную вершину 2 и вызывает функцию search для фиктивного ребра 2-2 (третья строка таблицы). Функция search помечает семь вершин, содержащихся в том же компоненте, что и 2. При продолжении просмотра слева направо вызывается search для ребра 8-8, и в результате помечаются вершины 8 и 11 (нижняя строка таблицы). После этого функция GRAPHsearch проверяет, что все вершины от 9 до 12 помечены, и завершает поиск.

Программа 18.3 — пример, который показывает, как можно получить класс DFS для определения остовного леса, производный от базового класса SEARCH из программы 18.2. В производный класс добавлен приватный вектор st для хранения представления дерева родительскими ссылками, которое инициализируется в конструкторе. Кроме того, в этом классе определена функция searchC, которая совпадает с функцией searchC из программы 18.1, за исключением того, что она принимает в качестве аргумента ребро v-w и заносит в st[w] значение v. И, наконец, добавлена общедоступная функция-член, которая позволяет клиентам определять родителя любой вершины. Остовные леса применяются во многих приложениях, но в этой главе они нужны нам в основном для понимания динамического поведения DFS, о чем пойдет речь в разделе 18.4.

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

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

Доказательство. Методом индукции по количеству связных компонентов.

Программа 18.3. Производный класс для поиска в глубину

Данный код демонстрирует порождение производного от DFS класса для построения остовного дерева на основе базового класса, определенного в разделе 18.2. Конструктор строит представление леса в векторе st (родительские ссылки) и в векторе ord (из базового класса). Клиенты могут использовать объект DFS для определения родителя любой заданной вершины леса (ST) или позиции любой заданной вершины при прямом обходе леса (перегруженный оператор []). Свойства таких лесов и их представлений будут рассмотрены в разделе 18.4.

  template <class Graph>
  class DFS : public SEARCH<Graph>
    { vector<int> st;
      void searchC(Edge e)
        { int w = e.w;
          ord[w] = cnt++; st[e.w] = e.v;
          typename Graph::adjIterator A(G, w);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (ord[t] == -1) searchC(Edge(w, t));
        }
    public:
      DFS(const Graph &G) : SEARCH<Graph>(G),
        st(G.V(), -1) { search(); }
      int ST(int v) const { return st[v]; }
    };
      

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

Лемма 18.3. Поиск в глубину на графе, представленном матрицей смежности, требует времени, пропорционального V2.

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

Лемма 18.4. Поиск в глубину на графе, представленном списками смежности, требует времени, пропорционального V + E.

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

Основной вывод из лемм 18.3 и 18.4 заключается в том, что время выполнения поиска в глубину линейно зависит от размера структуры данных, используемой для представления графа. В большинстве ситуаций можно считать, что время выполнения DFS линейно зависит от размеров самого графа: в случае насыщенного графа (число ребер которого пропорционально V2 ) этот результат справедлив для любого представления, а в случае разреженного графа предполагается представление списками смежности. Вообще-то обычно считается, что время выполнения DFS линейно зависит от E. Формально это утверждение неверно — для разреженных графов, представленных матрицей смежности, или для крайне разреженных графов, для которых E << Vи большая часть вершин изолирована; однако обычно нетрудно избежать первой ситуации и удалить изолированные вершины во второй ситуации (см. упражнение 17.34).

Как будет показано ниже, эти рассуждения применимы к любому алгоритму, для которого выполняются некоторые основные свойства DFS. Если алгоритм помечает каждую вершину и проверяет все инцидентные ей вершины (и выполняет другую работу, на выполнение которой для одной вершины требуется время, ограниченное некоторой константой), то для него эти свойства выполняются. В более общей формулировке это звучит так: если время, затрачиваемое на обработку каждой вершины ограничено некоторой функцией f (V, E), то время поиска гарантированно пропорционально E + f (V, E). В разделе 18.8 мы увидим, что поиск в глубину является одним из алгоритмов семейства, которому присущи как раз такие свойства; в лекциях 19—22 мы увидим, что алгоритмы этого семейства лежат в основе значительной части программ, которые рассматриваются в данной книге.

Многие изучаемые нами программы обработки графов представляют собой реализации АТД для каких-то конкретных задач; в них мы разрабатываем класс, выполняющий базовый поиск для вычисления структурной информации в других векторах, индексированных именами вершин. Такой класс можно породить от класса из программы 18.2 или, в более простых случаях, просто заново реализовать поиск. Многие из наших классов обработки графов имеют такой характер, поскольку, при выполнении поиска на графе мы, как привило, получаем представление и о его структуре. Обычно мы добавляем код в функцию поиска, которая выполняется после пометки всех вершин, вместо того чтобы работать с более общим алгоритмом поиска (например, который вызывает указанную функцию при каждом посещении вершины) — просто чтобы сделать код компактным и замкнутым. Построение более общего механизма АТД, который позволяет клиентам обрабатывать все вершины с помощью клиентских функций, является очень полезной практикой (см. упражнения 18.13 и 18.14).

В разделах 18.5 и 18.6 мы познакомимся с многочисленными функциями обработки графов, основанными на DFS. В разделах 18.7 и 18.8 мы рассмотрим другие реализации функции search и ряд других функций обработки графов, основанных на этих реализациях. Мы не встраиваем такой уровень абстракции в наш код, но мы стараемся четко показать базовую стратегию поиска на графе, которая лежит в основе разрабатываемого алгоритма. Например, мы применяем термин класс DFS для любой реализации, основанной на рекурсивной схеме DFS. Примерами классов DFS могут служить класс программ для поиска простого пути (программа 17.16) и класс остовного леса (программа 18.3).

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

При написании функций поиска на графах мы будем придерживаться следующего соглашения: вначале все векторы, индексируемые именами вершин, очищаются значением — 1, а функции поиска будут заносить во все элементы, соответствующие посещенным вершинам, неотрицательные значения. Любой такой вектор может играть роль вектора ord (пометка вершин как посещенных) в программах 18.2 и 18.3. Если функция поиска на графе основана на использовании или вычислении вектора, индексируемого именами вершин, то зачастую мы просто реализуем поиск и используем этот вектор для пометки вершин, а не порождаем класс от базового класса SEARCH и не задействуем вектор ord.

Конкретные результаты поиска на графе зависят не только от природы функции поиска, но и от представления графа и даже от порядка просмотра вершин функцией search. Для определенности в примерах и упражнениях в данной книге мы будем употреблять термин стандартный DFS по спискам смежности (standard adjacency-lists DFS) для обозначения процесса вставки последовательности ребер в АТД графа, реализованный на основе представления списками смежности (программа 17.9) с последующим выполнением DFS — например, с помощью программы 18.3. В случае представления матрицей смежности порядок вставки ребер не влияет на динамику поиска, но мы будем использовать параллельный термин стандартный DFS по матрице смежности (standard adjacency-matrix DFS) для обозначения процесса вставки последовательности ребер в АТД графа, реализованный на основе представления графа матрицей смежности (программа 17.7) с последующим выполнением DFS — например, с помощью программы 18.3.

Упражнения

18.8. В стиле рис. 18.5 рис. 18.5 приведите трассу вызовов рекурсивной функции, выполняемых при стандартном DFS по матрице смежности для графа

3-71-47-80-55-23-82-90-64-92-66-4.

18.9. В стиле рис. 18.7 рис. 18.7 приведите трассу вызовов рекурсивной функции, выполняемых при стандартном DFS по матрице смежности для графа

3-71-47-80-55-23-82-90-64-92-66-4.

18.10. Измените реализацию АТД графа в виде матрицы смежности (программе 17.7), чтобы в ней использовалась фиктивная вершина, связанная со всеми другими вершинами. Напишите реализацию упрощенного DFS, используя это изменение.

18.11. Выполните упражнение 18.10 для реализации АТД списками смежности (программа 17.9).

18.12. Существует 13! различных перестановок вершин в графе, показанном на рис. 18.8. Какая часть этих перестановок может описывать порядок посещения вершин графа программой 18.2?

18.13. Напишите реализацию клиентской функции АТД графа, которая вызывает указанную клиентом функцию для каждой вершины графа.

18.14. Напишите реализацию клиентской функции АТД графа, которая вызывает указанную клиентом функцию для каждого ребра графа. Такая функция может оказаться приемлемой альтернативой функции GRAPHedges (см. программу 17.2).

Свойства лесов DFS

Как было сказано в разделе 18.2, деревья, которые описывают рекурсивную структуру вызовов функции DFS, позволяют понять, как выполняется поиск в глубину. В данном разделе мы ознакомимся со свойствами этого алгоритма, изучая свойства деревьев DFS.

Если добавить в дерево DFS дополнительные узлы для пропусков рекурсивных вызовов в случае уже посещенных вершин, то получится компактное представление о динамике поиска в глубину, как на рис. 18.9. Эта древовидная структура заслуживает подробного изучения. Она является представлением графа: каждой вершине дерева соответствует вершина графа, а каждому его ребру — ребро графа. Можно показывать или оба представления обрабатываемого ребра (по одному в каждом направлении), как показано в левой части рис. 18.9, или только одно представление, как показано в центральной и правой частях рисунка. Первый вариант наглядно показывает, что алгоритм обрабатывает каждое ребро, а второй — что дерево DFS представляет собой просто еще одно представление графа. Прямой обход внутренних узлов дерева в прямом порядке посещает вершины в порядке их просмотра при поиске в глубину; более того, порядок посещения ребер при прямом обходе дерева совпадает с порядком просмотра ребер в графе алгоритмом DFS.

Вообще-то дерево DFS, показанное на рис. 18.9, содержит ту же информацию, что и трасса на рис. 18.5 или пошаговая иллюстрация обхода методом Тремо на рис. 18.2 и рис. 18.3. Ребра, ведущие к внутренним узлам, соответствуют ребрам (коридорам) к еще не посещенным вершинам (перекресткам); ребра, ведущие к внешним узлам, соответствуют случаям, когда DFS проверяет ребра, ведущие к уже посещенным вершинам (перекресткам); а заштрихованные узлы соответствуют ребрам к вершинам, для которых в данный момент выполняется рекурсивный поиск в глубину (когда мы открываем дверь в коридор, в котором дверь на противоположном конце уже открыта). При такой интерпретации прямой обход дерева сообщает нам ту же информацию, что и подробный способ обхода лабиринта.

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

При изучении деревьев DFS для орграфов в лекция №19 мы рассмотрим и другие типы ребер — не только для учета направления ребер, но и потому, что в графе возможны ребра, которые идут поперек дерева и соединяют узлы, не являющиеся в этом дереве ни предками, ни потомками.

 Различные представления дерева DFS


Рис. 18.9.  Различные представления дерева DFS

Если добавить в дерево рекурсивных вызовов DFSребра, которые мы проверяем, но не проходим, то получится полное описание процесса DFS (слева). У каждого узла дерева имеются потомки, представляющие все смежные с ним узлы в порядке их перебора алгоритмом DFS. Прямой обход дерева дает ту же информацию, что и рис. 18.5: сначала мы проходим ребро 0-0, затем 0-2, далее пропускаем 2-0, затем проходим по 2-6, пропускаем 6-2, потом проходим по 6-4, 4-3 и т.д. Вектор ord определяет порядок посещения вершин дерева при прямом обходе, он совпадает с порядком посещения вершин графа алгоритмом DFS. Вектор st является представлением родительскими ссылками дерева рекурсивных вызовов DFS (см. рис. 18.6).

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

Поскольку существуют два представления каждого ребра графа, и каждое ребро соответствует ссылке в дереве DFS, мы разобьем все ссылки дерева на четыре класса, воспользовавшись прямыми номерами (preorder number) и родительскими ссылками (соответственно, в массивах ord и st), которые вычисляет код поиска в глубину. Будем называть ссылку из вершины v на w в дереве DFS, которая представляет ребро дерева, так:

а ссылку из v на w, которая представляет обратное ребро, так:

Каждое древесное ребро в графе соответствует древесной ссылке и родительской ссылке в дереве DFS, а каждое обратное ребро в графе соответствует обратной ссылке и нисходящей ссылке в дереве DFS.

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

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

Приведенные выше определения предоставляют достаточно информации, чтобы провести различие между ссылкой древовидной структуры, родительской ссылкой, обратной ссылкой и нисходящей ссылкой в реализации класса DFS. Учтите, что условие ord[w] < ord[v] выполняется и для родительских, и для обратных ссылок, т.е., чтобы узнать, что v-w представляет собой обратную ссылку, необходимо проверить, что st[w] не равно v. На рис. 18.10 представлена распечатка результатов классификации ссылок дерева DFS для всех ребер некоторого графа в порядке их рассмотрения алгоритмом DFS. Это еще одно полное представление базового процесса поиска, которое может служить промежуточным этапом между рисунками 18.5 и 18.9.

 Трасса поиска в глубину (классификация ссылок дерева)


Рис. 18.10.  Трасса поиска в глубину (классификация ссылок дерева)

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

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

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

Поиск на графе является обобщением обхода дерева. На дереве алгоритм DFS в точности эквивалентен рекурсивному обходу дерева; на графах он соответствует обходу остовного дерева этого графа, которое строится при выполнении поиска. Как мы уже знаем, конкретный вид дерева обход зависит от представления графа. Поиск в глубину соответствует прямому обходу дерева. В разделе 18.6 мы познакомимся с алгоритмом поиска на графе, который аналогичен обходу дерева по уровням, и выясним, как он соотносится с алгоритмом DFS, а в разделе 18.7 мы рассмотрим общую схему, которая охватывает все методы обхода.

При обходе графов с помощью DFS мы использовали вектор ort для присвоения вершинам прямых номеров в порядке начала их обработки. Вершинам можно присвоить и обратные номера (postorder numbers), т.е. номера в порядке завершения их обработки (непосредственно перед выходом из функции рекурсивного поиска). В процессе обработки графа выполняется не просто обход вершин — как мы вскоре увидим, прямая и обратная нумерация предоставляет сведения о глобальных свойствах графа, которые помогают справиться с решением некоторых задач. Для алгоритмов, рассматриваемых в данной главе, достаточно прямой нумерации, а обратная нумерация пригодится нам в последующих лекциях.

Мы описываем динамику поиска в глубину на неориентированных графах общего вида с помощью леса DFS (DFS forest), в котором каждое дерево DFS представляет один связный компонент графа. Пример леса DFS показан на рис. 18.11.

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

 Лес DFS


Рис. 18.11.  Лес DFS

Лес DFS в верхней части рисунка соответствует поиску в глубину на графе, представленном матрицей смежности в нижней правой части рисунка. Граф состоит из трех связных компонентов, поэтому и лес содержит три дерева. Вектор ord содержит прямую нумерацию узлов дерева (порядок, в котором они просматриваются алгоритмом DFS), а вектор st содержит представление леса родительскими ссылками. Вектор cc связывает каждый компонент с индексом связного компонента (см. программу 18.4). Как и на рис. 18.9, ребра, ведущие к кружкам, — это древесные ребра, а ребра, ведущие к квадратикам — обратные ребра; заштрихованные узлы указывают, что инцидентное ребро было обнаружено раньше, при поиске в другом направлении.

 Другой лес DFS


Рис. 18.12.  Другой лес DFS

Данный лес описывает поиск в глубину на том же графе, что и на рис. 18.11, но здесь используется представление графа списками смежности, что меняет порядок поиска, поскольку он определяется порядком узлов в списках смежности. Вообще-то этот порядок виден из самого леса: это порядок, в каком перечислены потомки каждого узла дерева. Например, узлы списка смежности для вершины 0 расположены в порядке 5 2 1 6, для вершины 4 — в порядке 6 5 3 и т.д. Как и раньше, при поиске просматриваются все вершины и ребра графа, в порядке, который точно описан прямым обходом дерева. Векторы ord и st зависят от представления графа и динамики поиска (поэтому они отличаются от приведенных на рис. 18.11), но вектор cc зависит только от свойств графа, поэтому он не изменился.

Особенности структуры конкретного леса позволяют понять, как ведет себя DFS на том или ином графе, но большая часть важных свойств DFS определяется свойствами графа, которые не зависят от структуры леса. Например, оба леса, показанные на рис. 18.11 и рис. 18.12, содержат по три дерева (как и любой другой лес DFS того же графа), поскольку это просто различные представления одного и того же графа, состоящего из трех связных компонентов. Ведь из доказательства того, что поиск в глубину посещает все узлы и ребра графа (см. леммы 18.2—18.4), следует, что число связных компонентов графа равно числу деревьев в лесе DFS. Этот пример демонстрирует основное применение поиска на графе в данной книге: огромное множество реализаций классов обработки графов основано на изучении свойств графа путем обработки конкретного его представления (леса, соответствующего поиску).

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

На рис. 18.13 показано дерево DFS крупного графа, на котором видны базовые характеристики динамики поиска в глубину. Это высокое и узкое дерево демонстрирует несколько свойств просматриваемого графа и процесса поиска в глубину.

Такое поведение типично для поиска в глубину, хотя эти характеристики и не гарантируются для всех графов. Проверка подобных фактов для интересующих нас моделей графов и различных видов графов, возникающих на практике, требует тщательных исследований. Но все-таки этот пример позволяет почувствовать специфику алгоритмов на основе DFS, которая часто подтверждается на практике. рис. 18.13 и аналогичные иллюстрации других алгоритмов поиска на графах (см. рис. 18.24 и рис. 18.29) помогают лучше понять различия в их поведении.

 Поиск в глубину


Рис. 18.13.  Поиск в глубину

Здесь показан процесс поиска в глубину в случайном евклидовом графе с соседними связями (слева). На рисунке показаны вершины и ребра дерева DFS в графе в моменты, когда процедура поиска просмотрела 1/4, 1/2, 3/4 и все вершины графа (сверху вниз). Дерево DFS (только древесные ребра) показано справа. Как видно из этого примера, деревья поиска в глубину для таких графов (да и для многих других видов графов, часто встречающихся на практике) обычно имеют узкую и длинную форму. Как правило, еще не просмотренная вершина находится поблизости.

Упражнения

18.15. Нарисуйте лес DFS, который получается при работе стандартного DFS на графе, заданном матрицей смежности:

3-71-47-80-55-23-82-90-64-92-66-4.

18.16. Нарисуйте лес DFS, который получается при работе стандартного DFS на графе, заданном списками смежности:

3-71-47-80-55-23-82-90-64-92-66-4.

18.17. Напишите программу трассировки поиска в глубину, которая в стиле рис. 18.10 выводит характеристику каждого из двух представлений всех ребер графа: древесная ссылка, родительская ссылка, обратная ссылка или нисходящая ссылка в дереве DFS.

18.18. Напишите программу, которая вычисляет представление родительскими ссылками полного дерева DFS (включая внешние узлы) с помощью вектора из E целых чисел от 0 до V— 1. Указание. Первые V компонентов этого вектора должны совпадать с компонентами вектора st, описанного в тексте.

18.19. Добавьте в класс DFS остовного леса (программа 18.3) функции-члены (и соответствующие члены данных), которые возвращают высоту самого высокого дерева леса, количество обратных ребер и процент ребер, обработанных для просмотра всех вершин.

18.20. Определите эмпирически средние значения величин из упражнения 18.19 для графов различных размеров, построенных на основе различных моделей графов (см. упражнения 17.64—17.76).

18.21. Напишите функцию, выполняющую построение графа вставками в первоначально пустой граф ребер, выбранных случайным образом из заданного вектора. Используя эту функцию вместе с реализацией АТД графа списками смежности, определите эмпирически свойства распределения величин, описанных в упражнении 18.19, для всех представлений списками смежности различных крупных графов, построенных на основе различных моделей графов (см. упражнения 17.64—17.76).

Алгоритмы DFS

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

Обнаружение циклов. Имеются ли в заданном графе циклы? (Является ли граф лесом?) Эта задача легко решается с помощью поиска в глубину, поскольку любое обратное ребро дерева DFS принадлежит циклу, состоящему из этого ребра и пути в дереве, соединяющего две вершины ребра (см. рис. 18.9). Таким образом, поиск в глубину позволяет непосредственно выявлять циклы: граф является ациклическим тогда и только тогда, когда во время выполнения поиска в глубину не встречаются обратные ссылки (или нисходящие!). Например, для проверки этого условия в программе 18.1 достаточно добавить в оператор if предложение else, в котором проверяется равенство t и v. Если имеет место равенство, это означает, что обнаружена родительская ссылка w-v (второе представление ребра v-w, которое привело нас в w). Если равенства нет, то w-t замыкает цикл в дереве DFS, состоящий из ребер от t до w. Более того, нет необходимости проверять все ребра: мы должны либо найти цикл, либо завершить поиск, не обнаружив его, прежде чем проверим V ребер, ведь любой граф с V или большим числом ребер должен содержать цикл. Следовательно, мы можем проверить, является ли рассматриваемый граф ациклическим, за время, пропорциональное V, в случае представления списками смежности, хотя если граф задан в виде матрицы смежности, может понадобиться время, пропорциональное V2 (чтобы найти ребра).

Простой путь. Существует ли путь в графе, который связывает две заданных вершины? В лекция №17 мы видели, что нетрудно построить класс DFS, который способен решить эту задачу за линейное время.

Простая связность. Как было сказано в разделе 18.3, алгоритм DFS позволяет за линейное время определить, является ли граф связным. Ведь выбранная нами стратегия основана на вызове функции поиска для каждого связного компонента. При проведении поиска в глубину граф является связным тогда и только тогда, когда функция поиска на графе вызывает рекурсивную функцию DFS только один раз (программа 18.2). Количество связных компонентов в графе равно как раз количеству вызовов рекурсивной функции из функции GRAPHsearch — значит, количество связных компонентов графа можно определить простым подсчетом таких вызовов.

Программа 18.4 содержит класс DFS для более общего случая. Он позволяет получать за постоянное время ответы на запросы, касающиеся связности, после этапа препроцес-сорной обработки в конструкторе, которая выполняется за линейное время. Порядок посещения вершин тот же, что и в программе 18.3. Рекурсивная функция в качестве второго аргумента принимает вершину, а не ребро, поскольку ей не нужно знать родительский узел. Каждому дереву леса DFS соответствует связный компонент графа, так что мы быстро можем определить, содержатся ли две вершины в одном и том же компоненте, включив в представление графа вектор, индексированный именами вершин, который заполняется при поиске в глубину и используется для ответов на запросы о связности. В рекурсивной функции DFS текущее значение счетчика компонентов присваивается элементу вектора, соответствующему каждой посещенной вершине. Тогда две вершины принадлежат одному компоненту графа тогда и только тогда, когда равны соответствующие им элементы этого вектора. Здесь данный вектор снова отображает структурные свойства графа, а не особенности представления графа или динамики поиска.

Программа 18.4. Связность графа

Конструктор CC вычисляет за линейное время количество связных компонентов заданного графа и сохраняет индекс компонента, которому принадлежит каждая вершина, в приватном векторе id, индексированном именами вершин. Клиенты могут использовать объект CC для определения за постоянное время количества связных компонентов (count) или для проверки (connect), является ли связанной какая-либо пара вершин.

  template <class Graph>
  class CC
    { const Graph &G;
      int ccnt;
      vector <int> id;
      void ccR(int w)
        { id[w] = ccnt;
          typename Graph::adjIterator A(G, w);
          for (int v = A.beg(); !A.end(); v = A.nxt())
            if (id[v] == -1) ccR(v);
        }
    public:
      CC(const Graph &G) : G(G), ccnt(0), id(G.V(), -1)
        { for (int v = 0; v < G.V(); v++)
          if (id[v] == -1) { ccR(v); ccnt+ + ; }
        }
      int count() const { return ccnt; }
      bool connect(int s, int t) const
        { return id[s] == id[t]; }
    };
      

Программа 18.4 типизирует базовый подход, который мы будем использовать при решении различных задач обработки графов. Мы разрабатываем класс, ориентированный на решение конкретной задачи, чтобы клиенты могли создавать объекты, решающие эту задачу. Как правило, мы затрачиваем некоторое время на предварительную обработку конструктором, который вычисляет приватные данные, описывающие нужные структурные свойства графа. Эти данные помогают обеспечить эффективную реализацию общедоступных функций для обработки запросов. В данном случае конструктор выполняет предварительную обработку с помощью поиска в глубину (за линейное время) и заполняет приватный член данных (вектор id, индексированный именами вершин), который позволяет отвечать на запросы о связности за постоянное время. В случаях других задач обработки графов конструкторы и функции обработки запросов могут затрачивать больше памяти и/или времени на предварительную обработку и на ответы на запросы. Как обычно, основное внимание мы уделяем минимизации этих затрат, хотя зачастую сделать это весьма непросто. Например, большая часть лекция №19 посвящена решению задачи связности для орграфов, для которых очень трудно добиться линейного времени на предварительную обработку и постоянного времени на обработку запросов о связности, как в программе 18.4.

Как соотносится определение связности графа на базе DFS, реализованное в программе 18.4, с алгоритмом объединения-поиска, который был рассмотрен в лекция №1, если граф задан списком ребер? Теоретически поиск в глубину работает быстрее, поскольку он, в отличие от объединения-поиска, гарантирует постоянное время выполнения, однако на практике это редко играет роль. В конечном итоге, алгоритм объединения-поиска выполняется быстрее, поскольку в нем не обязательно строится полное представление графа. Что еще важнее, алгоритм объединения-поиска работает в оперативном режиме (в любой момент мы можем проверить, связаны ли какие-либо две вершины, за почти постоянное время), а решение на базе DFS должно выполнить предварительную обработку, чтобы ответить на запрос о связности за постоянное время. Поэтому мы, например, предпочитаем алгоритм объединения-поиска, если требуется определить связность графа лишь один раз или при наличии множества запросов, но вперемешку с операциями вставки ребер. Однако решение на базе DFS будет более подходящим для АТД графа, поскольку оно эффективно использует существующую инфраструктуру. Ни тот, ни другой подход не способен работать эффективно в случае смеси большого количества вставок ребер, удалений ребер и запросов определения связности; оба подхода требуют отдельных поисков в глубину для вычисления пути. Эти рассуждения показывают, с какими трудностями приходится сталкиваться при анализе алгоритмов на графах; подробнее мы рассмотрим их в разделе 18.9.

Двухсторонний эйлеров цикл. Программа 18.5 представляет собой класс для поиска пути с помощью поиска в глубину, который использует все ребра графа в точности два раза — по одному в каждом направлении (см. лекция №17). Этот путь соответствует методу Тремо: мы разматываем нить там, куда мы идем, проверяем, есть ли нить в коридоре, а не включаем свет (поэтому приходится проходить по коридорам, ведущим к уже пройденным перекресткам), и вначале проходим туда и сюда по каждой обратной ссылке (при первой встрече с обратным ребром), после чего игнорируем нисходящие ссылки (при второй встрече каждого обратного ребра). Можно также игнорировать обратные ссылки (при первой встрече) и проходить назад и вперед по нисходящим ссылкам (при второй встрече) (см. упражнение 18.25 и рис. 18.14).

Программа 18.5. Двухсторонний эйлеров цикл

Этот класс DFS выводит каждое ребро дважды, по одному в каждом направлении, в порядке обхода двухстороннего эйлерова цикла. Мы проходим назад и вперед по обратным ребрам и игнорируем нисходящие ребра (см. текст). Этот класс порожден от базового класса SEARCH из программы 18.2.

  template <class Graph>
  class EULER : public SEARCH<Graph>
    { void searchC(Edge e)
        { int v = e.v, w = e.w;
          ord[w] = cnt+ + ;
          cout <<    << w;
          typename Graph::adjIterator A(G, w);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (ord[t] == -1)
              searchC(Edge(w, t));
            else if (ord[t] < ord[v])
              cout << "-" << t << "-" << w;
          if (v != w) cout << "-" << v; else cout << endl;
        }
    public:
      EULER(const Graph &G) : SEARCH<Graph>(G)
        { search(); }
   };
      

 Двухсторонний эйлеров цикл


Рис. 18.14.  Двухсторонний эйлеров цикл

Поиск в глубину позволяет исследовать любой лабиринт, проходя коридоры в обоих направлениях. Мы вносим изменения в метод Тремо: разматываем нить всюду, куда идем, и проходим туда и назад по коридорам, в которых нет нити и которые ведут к посещенным перекресткам. На этом рисунке показан порядок обхода, который отличается от изображенного на рисунках 18.2 и 18.3 — главным образом тем, что путь обхода можно нарисовать без пересечения себя. Такой порядок может быть, например, если при построении представления графа списками смежности ребра обрабатывались в каком-то другом порядке, либо при явном изменении алгоритма DFS, чтобы учесть геометрическое расположение узлов (см. упражнение 18.26). Двигаясь по нижнему пути из 0 через 2, 6, 4 и 7, мы пробуем пройти из 7 в 0 и возвращаемся назад,, поскольку ord[0] меньше ord[7]. Затем мы идем в 1, назад в 7, назад в 4, в 3, в 5, из 5 в 0 и назад, потом из 5 в 4 и назад, далее назад в 3, назад в 4, назад в 6, назад в 2 и назад в 0. Такой путь может быть получен с помощью прямого и обратного рекурсивного обхода дерева DFS (игнорируя заштрихованные вершины, которые означают вторую встречу с ребром), когда выводится имя соответствующей вершины, рекурсивно просматриваются поддеревья, затем снова выводится имя этой вершины.

Остовный лес. В заданном связном графе с V вершинами требуется найти множество из V— 1 ребер, соединяющих эти вершины. Если граф состоит из C связных компонентов, то нужно найти остовный лес (с V— C ребрами). Мы уже знаем класс DFS, который решает эту задачу — это программа 18.3.

Поиск вершин. Сколько вершин находится в том же компоненте, что и заданная вершина? Эту задачу можно легко решить, начав поиск в глубину с указанной вершины и подсчитывая количество помеченных вершин. В насыщенном графе этот процесс можно существенно ускорить, остановив поиск в глубину после пометки V вершин — в этот момент мы уже знаем, что никакое ребро не приведет нас в еще не помеченную вершину, поэтому остальные ребра можно игнорировать. Это усовершенствование позволит посетить все вершины за время, пропорциональное VlogV , а не E (см. раздел 18.8).

Раскраска двумя цветами, двудольные графы, нечетные циклы. Существует ли способ покрасить каждую вершину одним из двух цветов таким образом, чтобы ни одно из ребер не соединяло вершины одинакового цвета? Является ли данный граф двудольным (см. лекция №17)? Содержит ли он цикл нечетной длины? Все эти три задачи эквивалентны: первые две — просто различные названия одной и той же задачи; любой граф, содержащий нечетный цикл, не допускает раскраску в два цвета, а программа 18.6 показывает, что любой граф, в котором нет нечетных циклов, может быть раскрашен двумя цветами. Эта программа представляет собой реализацию функции АТД на базе DFS, которая проверяет, является ли заданный граф двудольным, раскрашиваемым двумя цветами и не содержащим нечетные циклы. Эту рекурсивную функцию можно рассматривать как схему доказательства по индукции, что программа может раскрасить в два цвета любой граф без нечетных циклов (или обнаружить в графе нечетный цикл как доказательство того, что граф с нечетными циклами невозможно раскрасить двумя цветами). Чтобы раскрасить граф двумя цветами, нужно начать с раскраски вершины v одним цветом, а затем закрасить другим цветом все вершины, смежные с v. Этот процесс эквивалентен раскраске дерева DFS, спускаясь по уровням вниз и проверяя обратные ребра на соответствие цветов (см. рис. 18.15). Любое обратное ребро, соединяющее вершины одного цвета, является свидетельством наличия в графе нечетного цикла.

Программа 18.6. Раскраска графа в два цвета (двудольность)

Конструктор этого DFS-класса заносит в DK значение true тогда и только тогда, когда может заполнить значениями 0 и 1 вектор vc, индексированный именами вершин, так, что для каждого ребра v-w графа значения vc[v] и vc[w] различны.

  template <class Graph>
  class BI
    { const Graph &G;
      bool OK;
      vector <int> vc; 
      bool dfsR(int v, int c)
        { vc[v] = (c+1) % 2;
          typename Graph::adjIterator A(G, v);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (vc[t] == -1)
              { if (!dfsR(t, vc[v])) return false; }
            else if (vc[t] != c)
              return false;
          return true;
        }
    public:
      BI(const Graph &G) : G(G), OK(true), vc(G.V(), -1)
        { for (int v = 0; v < G.V(); v++)
            if (vc[v] == -1)
              if (!dfsR(v, 0)) { OK = false; return; }
        }
      bool bipartite() const { return OK; }
      int color(int v) const { return vc[v]; }
    };
      

 Раскраска дерева DFS двумя цветами


Рис. 18.15.  Раскраска дерева DFS двумя цветами

Чтобы раскрасить граф двумя цветами, мы меняем цвет при спуске по дереву DFS и проверяем обратные ребра на совместимость. В дереве DFS для графа с рис. 18.9, изображенном в верхней части рисунка, обратные ребра 5-4 и 7-0 показывают, что рассматриваемый граф невозможно закрасить двумя цветами из-за наличия циклов нечетной длины 4-3-5-4 и 0-2-6-4-7-0 . В дереве DFS для двудольного графа с рис. 17.5 (внизу), таких несоответствий нет; возможная раскраска показана штриховкой.

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

Упражнения

18.22. Реализуйте класс проверки наличия циклов на базе DFS, который выполняет в конструкторе предварительную обработку графа за время, пропорциональное V, и обеспечивает работу функций-членов, определяющих наличие в графе каких-либо циклов и выводящих найденные циклы.

18.23. Опишите семейство графов с V вершинами, в котором стандартный DFS по матрице смежности для обнаружения циклов выполняется за время, пропорциональное V2.

18.24. Реализуйте класс определения связности из программы 18.4, производный от класса поиска на графе наподобие программы 18.3.

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

18.26. Измените программу 18.5, чтобы она вычисляла двусторонний эйлеров цикл, который можно нарисовать без пересечений себя ни в одной вершине (как на рис. 18.14). Например, если бы поиск, представленный на рис. 18.14, прошел сначала по ребру 4-3, а уже потом по ребру 4-7, то цикл пересек бы сам себя. Нужно, чтобы алгоритм не допускал таких пересечений.

18.27. Разработайте версию программы 18.5, которая сортирует все ребра в порядке двухстороннего эйлерова цикла. Программа должна возвратить вектор ребер, который соответствует двухстороннему эйлерову циклу.

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

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

18.30. Большую часть графов невозможно раскрасить двумя цветами, и поиск в глубину обычно быстро обнаруживает это. Эмпирически определите количество ребер, просмотренных программой 18.6, для графов различных размеров и построенных по различным моделям (см. упражнения 17.64—17.76).

18.31. Докажите, что в каждом связном графе имеются вершины, удаление которых не нарушает связность графа, и напишите функцию DFS, которая обнаруживает такие вершины. Указание. Рассмотрите листья дерева DFS.

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

Разделимость и двусвязность

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

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

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

Определение 18.1. Мостом (bridge) в графе называется ребро, после удаления которого связный граф распадается на два не связанных между собой подграфа. Граф, у которого нет мостов, называется реберно-связным (edge-connected).

Когда мы говорим об удалении ребра, мы имеем в виду удаление этого ребра из множества ребер, которое определяет граф, даже если после такого удаления одна или обе вершины этого ребра станут изолированными. Реберносвязный граф остается связным при удалении из него любого одного ребра. В некоторых контекстах естественнее говорить о разделении графа, а не о возможности графа оставаться связным. Поэтому мы будем свободно пользоваться альтернативной терминологией, которая делает акцент на таких моментах: граф, который не является реберно-связным, назовем реберно-разделимым (edge-separable), а мосты назовем ребрами разделения (separation edge).

 Реберно-разделимый граф


Рис. 18.16.  Реберно-разделимый граф

Этот граф не является реберно-связным. Ребра 0-5, 6-7 и 11-12 (заштрихованы) представляют собой ребра разделения (мосты). Граф содержит четыре реберно-связных компонента: один включает вершины 0, 1, 2 и 6; другой — вершины 3, 4, 9 и 11; третий — вершины 7, 8 и 10; последний состоит из единственной вершины 12.

Если в реберно-разделимом графе удалить все мосты, он распадется на реберно-связные компоненты (edge-connected components) или компоненты, связанные мостами (bridge-connected components) — максимальные подграфы, не содержащие мостов. На рис. 18.16 показан небольшой пример, иллюстрирующий эти понятия.

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

Лемма 18.5. В любом дереве DFS древесное ребро v-w является мостом тогда и только тогда, когда не существуют обратные ребра, соединяющие одного из потомков w с предком w.

Доказательство. Если такое ребро существует, то v-w не может быть мостом. С другой стороны, если v-w не есть мост, то в графе должен быть другой путь из w в v, отличный от w-v. Каждый такой путь должен содержать одно из таких ребер.

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

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

 Дерево DFS для поиска мостов


Рис. 18.17.  Дерево DFS для поиска мостов

Здесь приведено дерево DFS для графа с рис. 18.16. Узлы 5, 7 и 12 обладают тем свойством, что никакое обратное ребро не соединяет потомка с предком, и этим свойством не обладают никакие другие узлы. Поэтому удаление ребра между одним из этих узлов и его родительским узлом отделит поддерево с корнем в этом узле от остальной части графа. То есть ребра 0-5, 11-12 и 6-7 являются мостами. Массив low, индексированный именами вершин, используется для отслеживания минимального прямого номера (значение ord), на который указывает любое обратное ребро в поддереве, корнем которого является эта вершина. Например, low[9] содержит значение 2, т.к. одно из обратных ребер в поддереве с корнем в 9 указывает на 4 (вершина с прямым номером 2), и никакое другое обратное ребро не указывает на более высокую вершину в этом дереве. Для узлов 5, 7 и 12 значение low равно значению ord.

Программа 18.7. Реберная связность

Этот класс DFS подсчитывает количество мостов в графе. Клиент может использовать объект EC для определения количества реберно-связных компонентов. Добавление функции-члена, проверяющей, содержатся ли какие-либо две вершины в одном и том же реберно-связном компоненте, предлагается как самостоятельное упражнение (см. упражнение 18.36). Вектор low содержит минимальные прямые номера, которые могут быть достигнуты из каждой вершины через некоторую последовательность древесных ребер, за которой следует одно обратное ребро.

  template <class Graph>
  class EC : public SEARCH<Graph>
    { int bcnt;
      vector <int> low;
      void searchC(Edge e)
        { int w = e.w;
          ord[w] = cnt+ + ; low[w] = ord[w];
          typename Graph::adjIterator A(G, w);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (ord[t] == -1)
              { searchC(Edge(w, t));
                if (low[w] > low[t]) low[w] = low[t];
                if (low[t] == ord[t])
                  bcnt++; // w-t является мостом
              }
            else if (t != e.v)
              if (low[w] > ord[t]) low[w] = ord[t];
        }
    public:
      EC(const Graph &G) : SEARCH<Graph>(G),
        bcnt(0), low(G.V(), -1)
          { search(); }
      int count() const { return bcnt+1; }
    };
      

Вычисления для каждой вершины достаточно просты: мы просматриваем список смежности, отслеживая минимальные номера, которых можно достичь, проходя по каждому ребру. Для древесных ребер вычисления выполняются рекурсивно; для обратных ребер используется прямой номер смежной вершины. Если вызов рекурсивной функции для ребра w-t не находит путь к узлу с меньшим прямым номером, чем прямой номер узла t, то ребро w-t является мостом.

Лемма 18.6. Мосты графа можно найти за линейное время

Доказательство. Программа 18.7 представляет собой разновидность поиска в глубину с несколькими дополнительными проверками, которые выполняются за постоянное время. Поэтому из лемм 18.3 и 18.4 непосредственно следует, что поиск мостов в графе выполняется за время, пропорциональное V2 для представления матрицей смежности и V + E для представления списками смежности.

В программе 18.7 для исследования свойств графа используется поиск в глубину. Разумеется, представление графа влияет на порядок поиска, но оно никак не влияет на результаты, т.к. мосты — характеристика графа, а не способа его представления или поиска на нем. Как всегда, любое дерево DFS — это просто еще одно представление графа, поэтому все такие деревья обладают одними и теми же свойствами связности. Корректность алгоритма зависит от этого фундаментального факта. Например, на рис. 18.18 показан другой поиск на том же графе; он начинается с другой вершины, но, естественно, обнаруживает те же самые мосты. Несмотря на лемму 18.6, при исследовании различных деревьев DFS одного и того же графа стоимость поиска может зависеть не только от свойств графа, но и от свойств дерева DFS. Например, объем памяти, необходимой для стека, который обеспечивает поддержку рекурсивных вызовов, больше для примера на рис. 18.18, чем для примера на рис. 18.17 рис. 18.17.

 Другое дерево DFS, используемое для поиска мостов


Рис. 18.18.  Другое дерево DFS, используемое для поиска мостов

На этой диаграмме показано дерево DFS для графа с рис. 18.16, которое отлично от дерева, изображенного на рис. 18.17 — там поиск начинается с другого узла. Узлы и ребра просматриваются в совершенно другом порядке, но мосты находятся (естественно) одни и те же. В этом дереве вершины 0, 7 и 11 — это вершины, для которых значение low равно значению ord, поэтому ребра, соединяющие каждую из этих вершин с их родителями (соответственно, 12-11, 5-0 и 6-7) являются мостами.

Как и в случае обычной связности в программе 18.4, можно воспользоваться программой 18.7 для построения класса, который сможет проверить, является ли заданный граф реберно-связным, либо подсчитать количество реберно-связных компонентов. При желании, как и для программы 18.4, можно предоставить клиентам возможность создавать (за линейное время) объекты, способные за постоянное время отвечать на запросы, находятся ли две заданные вершины в одном и том же реберно-связном компоненте (см. упражнение 18.36).

Мы завершим этот раздел рассмотрением других обобщений понятия связности, включая задачу определения конкретных вершин, критичных для сохранения связности графа. Таким образом мы соберем в одном месте базовый материал, необходимый для изучения более сложных алгоритмов, которые будут рассматриваться в лекция №22. Если вы впервые сталкиваетесь с задачами связности графов, вы можете просто перейти к разделу 18.7 и вернуться сюда перед чтением лекция №22.

 Терминология разделимости графа


Рис. 18.19.  Терминология разделимости графа

Этот граф состоит из двух реберно-связных компонентов и одного моста. К тому же реберно-связный компонент, расположенный над мостом, является двусвязным, а компонент ниже моста состоит из двух двусвязных компонентов, соединенных точкой сочленения.

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

Определение 18.2. Точка сочленения (articulation point) графа — это вершина, при удалении которой связный граф распадается по меньшей мере на два непересекаю-щихся подграфа.

Точки сочленения графа мы будем также называть вершинами разделения (separation vertex) или разрезающими вершинами (cut vertex). Граф, в котором нет вершин разделения, можно было бы назвать " вершинно-связным " , но мы воспользуемся другой, хотя в конечном счете и эквивалентной, терминологией.

Определение 18.3. Граф называется двусвязным (biconnected), если каждая пара его вершин соединена двумя непересекающимися путями.

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

Лемма 18.7. Граф двусвязен тогда и только тогда, когда он не содержит вершин разделения (точек сочленения).

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

 Точки сочленения (вершины разделения)


Рис. 18.20.  Точки сочленения (вершины разделения)

Данный граф не является двусвязным. Вершины 0, 4, 5, 6, 7 и 11 (обведены) — точки сочленения. Граф содержит пять двусвязных компонентов: один состоит из ребер 4-9, 9-11 и 4-11, другой — из ребер 7-8, 8-10 и 7-10; еще один — из ребер 0-1, 1-2, 2-6 и 6-0; следующий — из ребер 3-5 , 4-5 и 3-4; и еще имеется одиночная вершина 12. Добавление в граф ребра, соединяющего вершину 12 с вершиной 7, 8 или 10, делает граф двусвязным.

Мы уже видели, что множество ребер несвязного графа можно разбить на несколько связных подграфов, и что ребра графа, который не является реберно-связным, можно разбить на множество мостов и реберно-связных подграфов (соединенных между собой мостами). Аналогично, любой граф, который не является двусвязным, можно разбить на множество мостов и двусвязных компонентов (biconnected components), каждый из которых представляет собой двусвязный подграф. Двусвязные компоненты и мосты нельзя рассматривать как подходящее разбиение графа, поскольку точки сочленения могут входить в несколько связных компонентов (см., например, рис. 18.20). Двусвязные компоненты соединяются в точках сочленения, которые, возможно, входят в состав мостов.

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

Для определения двусвязности графа и выявления точек сочленения можно использовать тот же подход на основе DFS, что и в программе 18.7. Мы не будем рассматривать соответствующий код, поскольку он почти идентичен программе 18.7, за исключением дополнительной проверки, является ли корень дерева DFS точкой сочленения (см. упражнение 18.43). Хорошим упражнением может послужить разработка кода вывода двусвязных компонентов графа, который лишь ненамного сложнее, чем соответствующий код определения реберной связности графа (см. упражнение 18.44).

Лемма 18.8. Точки сочленения и двусвязные компоненты графа можно найти за линейное время.

Доказательство. Как и в случае леммы 18.7, этот факт непосредственно следует из того, что решения упражнений 18.43 и 18.44 добавляют в поиск в глубину лишь небольшие изменения — несколько проверок для каждого ребра, выполняемых за постоянное время.

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

Определение 18.4. Граф называется k-связным (k-connected), если каждую пару его вершин соединяют по меньшей мере k путей без общих вершин. Вершинная связность (vertex connectivity) графа — это минимальное количество вершин, которые нужно удалить, чтобы разделить этот граф на две части.

В этой терминологии " 1-связный " означает просто " связный " , а " 2-связный " — это то же, что и " двусвязный " . Граф с точкой сочленения обладает вершинной связностью, равной 1 (или 0), поэтому из леммы 18.7 следует, что граф 2-связен тогда и только тогда, когда значение его вершинной связности не меньше 2. Это частный случай классического результата теории графов — теоремы Уитни (Whitney’s theorem), которая гласит, что граф k-связен в том и только том случае, когда его вершинная связность не меньше k. Теорема Уитни непосредственно следует из теоремы Менгера (Menger’s theorem) (см. лекция №22), согласно которой минимальное количество вершин, удаление которых разъединяет две вершины в графе, равно максимальному количеству путей без общих вершин между этими двумя вершинами (для доказательства теоремы Уитни нужно применить теорему Менгера к каждой паре вершин).

Определение 18.5. Граф называется k-реберно-связным (k-edge-connected), если существуют по меньшей мере k путей без общих ребер, соединяющих каждую пару вершин графа. Реберная связность (edge connectivity) графа — это минимальное число ребер, которые нужно удалить, чтобы разделить этот граф на две части.

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

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

st-связность. Каково минимальное количество ребер, удаление которых приведет к разъединению двух вершин s и t заданного графа? Каково минимальное количество вершин, удаление которых приведет к разъединению двух вершин s и t заданного графа?

Общая связность. Является ли заданный граф k-связным? Является ли заданный граф k-реберно-связным? Чему равны реберная связность и вершинная связность заданного графа?

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

Упражнения

18.33. Если граф является лесом, все его ребра являются ребрами разделения. А какие вершины являются вершинами разделения?

18.34. Рассмотрим граф

3-71-47-80-55-23-82-90-64-92-66-4.

Начертите стандартное дерево DFS для списков смежности. Найдите с его помощью мосты и реберно-связные компоненты.

18.35. Докажите, что каждая вершина любого графа принадлежит в точности одному реберно-связному компоненту.

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

18.37. Рассмотрим граф

3-71-47-80-55-23-82-90-64-92-66-4.

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

18.38. Выполните предыдущее упражнение, воспользовавшись стандартным деревом DFS для матрицы смежности.

18.39. Докажите, что каждое ребро графа либо является мостом, либо принадлежит в точности одному двусвязному компоненту.

18.40. Докажите, что любой граф без точек сочленения является двусвязным. Указание. Если заданы пара вершин s и t и соединяющий их путь, воспользуйтесь тем фактом, что ни одна из вершин этого пути не является точкой сочленения для построения двух непересекающихся путей, которые соединяют вершины s и t.

18.41. Напишите производный от программы 18.2 класс для определения, является ли граф двусвязным. Воспользуйтесь примитивным алгоритмом, который выполняется за время, пропорциональное V(V + E). Указание: Если перед началом поиска какая-то вершина отмечена как уже просмотренная, то это по сути удаляет ее из графа.

18.42. На основе решения упражнения 18.41 постройте класс, который определяет, является ли заданный граф 3-связным. Выведите формулу для приближенного количества просмотров каждого ребра графа в виде функции от V и E.

18.43. Докажите, что корень дерева DFS является точкой сочленения тогда и только тогда, когда у него имеется два или более (внутренних) дочерних узлов.

18.44. Пользуясь программой 18.2, напишите производный класс, который выводит двусвязные компоненты графа.

18.45. Чему равно минимальное количество ребер в любом двусвязном графе с V вершинами?

18.46. Измените программу 18.7, чтобы упростить задачу определения, является ли заданный граф реберно-связным (которая завершает работу сразу при обнаружении моста, если граф не реберно-связный), и добавьте возможность отслеживать количество просмотренных ребер. Эмпирически определите затраты для графов различных размеров, сгенерированных на основе различных моделей графов (упражнения 17.64-17.76).

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

18.48. Эмпирически определите средние значения величин, описанных в упражнении 18.47, для графов различных размеров, сгенерированных на основе различных моделей графов (упражнения 17.64—17.76).

18.49. Определите реберную связность и вершинную связность графа

0-10-20-82-12-88-13-83-73-63-53-44-64-55-66-77-8.

Поиск в ширину

Предположим, что нам нужно найти кратчайший путь между двумя конкретными вершинами графа — такой путь, соединяющий эти вершины, что никакой другой путь между этими вершинами не содержит меньшее число ребер. Классический метод решения этой задачи, получивший название поиска в ширину (BFS — breadth-first search), также лежит в основе многочисленных алгоритмов обработки графов; ему и посвящен данный раздел. Поиск в глубину мало пригоден для решения этой задачи, поскольку порядок прохождения им графа никак не связан с поиском кратчайших путей. А вот поиск в ширину очень удобен для этого. Поиск кратчайшего пути от вершины v к вершине w мы начнем с того, что попытаемся найти вершину w среди всех вершин, в которые можно перейти по одному ребру из вершины v, затем проверим все вершины, в которые можно перейти по двум ребрам и т.д.

Когда во время просмотра графа мы попадаем в вершину, из которой исходят более одного ребра, мы выбираем одно из них и запоминаем остальные для последующего просмотра. В поиске в глубину для этой цели применяется стек магазинного типа (которым управляет система при вызовах рекурсивной функции поиска). Применение правила LIFO (Last In First Out — последним пришел, первым вышел), которое характеризует работу стека магазинного типа, соответствует исследованию соседних коридоров в лабиринте: из всех еще не исследованных коридоров выбирается последний обнаруженный. В поиске в ширину необходимо исследовать вершины в порядке их удаления от исходной точки. В случае реального лабиринта для такого исследования может потребоваться целая бригада; однако в компьютерной программе эта цель достигается намного проще: вместо стека используется очередь FIFO (First In First Out — первым пришел, первым вышел).

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

На рис. 18.21 показано выполнение поиска в ширину на конкретном примере.

 Поиск в ширину


Рис. 18.21.  Поиск в ширину

Здесь показаны шаги поиска в ширину на примере графа. Мы начинаем его со всех ребер, смежных с находящейся в очереди исходной вершиной (вверху слева). Потом мы переносим ребро 0-2 из очереди в дерево и обрабатываем инцидентные ему ребра 2-0 и 2-6 (вторая диаграмма сверху слева). Мы не помещаем ребро 2-0 в очередь, поскольку вершина 0 уже содержится в дереве.

Затем мы переносим из очереди в дерево ребро 0-5; одно из ребер, инцидентных вершине 5, также не приводит в новую вершину, однако мы добавляем в очередь ребра 5-3 и 5-4 (третья диаграмма сверху слева). После этого мы добавляем в дерево ребро 0-7 и заносим в очередь ребро 7-1 (внизу слева).

Ребро 7-4 не выделено серым цветом: его можно не заносить в очередь, т.к. другое ребро уже привело нас в вершину 4, и она уже помещена в очередь. Для завершения поиска мы удаляем оставшиеся ребра из очереди, полностью игнорируя при этом серые ребра, когда они подходят к началу очереди (справа). Ребра заносятся в очередь и выбираются из нее в порядке их удаленности от вершины 0.

Программа 18.8. Поиск в ширину

Данный класс поиска на графе при посещении вершины просматривает все инцидентные ей ребра и помещает ребра, ведущие в непосещенные вершины, в очередь вершин, ожидающих посещения. Маркировка вершин в порядке их посещения хранится в векторе ord. Функция search, вызываемая конструктором, строит явное представление дерева BFS родительскими ссылками (ребра, которые впервые приводят нас в каждый узел) в другом векторе st, который затем можно использовать для решения базовой задачи поиска кратчайшего пути (см. текст).

  #include "QUEUE.cc"
  template <class Graph>
  class BFS : public SEARCH<Graph>
    { vector<int> st;
      void searchC(Edge e)
        { QUEUE<Edge> Q;
          Q.put(e);
          while (!Q.empty())
            if (ord[(e = Q.get()).w] == -1)
              { int v = e.v, w = e.w;
                ord[w] = cnt++; st[w] = v;
                typename Graph::adjIterator A(G, w);
                for (int t = A.beg(); !A.end(); t = A.nxt())
                  if (ord[t] == -1) Q.put(Edge(w, t));
              }
        }
    public:
      BFS(Graph &G) : SEARCH<Graph>(G), st(G.V(), -1)
        { search(); } 
      int ST(int v) const { return st[v]; }
   };
      

Как было показано в разделе 18.4, поиск в глубину подобен исследованию лабиринта одним человеком. Поиск в ширину можно сравнить с исследованием группой людей, которые расходятся веером по всем направлениям. Методы DFS и BFS отличаются друг от друга во многих отношениях, но между этими двумя методами существует глубинная связь — о ней было сказано в кратком анализе методов в лекция №5. В разделе 18.8 мы рассмотрим обобщенный метод поиска на графе, который можно настроить так, чтобы он включал в себя и оба эти алгоритма, и множество других. Каждый алгоритм обладает особыми динамическими характеристиками, которые мы используем для решения соответствующих задач обработки графов. В случае поиска в ширину нас наиболее интересует расстояние каждой вершины от исходной вершины (длина соединяющего их кратчайшего пути).

Лемма 18.9. В процессе поиска в ширину вершины заносятся в очередь FIFO и выбираются из нее в порядке их расстояния от исходной вершины.

Доказательство. Справедливо более сильное условие: очередь всегда содержит ноль или более вершин, удаленных на расстояние k от исходной вершины, за которой следует ноль или более вершин, удаленных на расстояние k + 1 от исходной точки, где k — некоторое целое значение. Это более сильное условие легко доказывается методом индукции.

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

Лемма 18.10. Для любого узла w в дереве BFS с корнем в вершине v путь из v в w соответствует кратчайшему пути из v в w в соответствующем графе.

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

Как показано на рис. 18.21 и сказано в лекция №5, нет необходимости помещать в очередь ребро с такой же конечной вершиной, что и у хотя бы одного ребра, которое уже находится в очереди, поскольку правило FIFO гарантирует обработку ребра, которое уже находится в очереди (и посещение соответствующей вершины), раньше, чем алгоритм доберется до нового ребра. Один из способов реализации этого правила заключается в использовании реализации АТД очереди, в которой одинаковые элементы запрещены принципом игнорирования новых элементов (см. лекция №4). Другой способ — применение для этой цели глобального вектора пометки вершин: вместо пометки вершины в момент ее извлечения из очереди как посещенной, это делается при занесении ее в очередь. Проверка, помечена ли вершина (изменилось ли значение соответствующего элемента с начального сигнального значения), как раз и запрещает включение в очередь других ребер, которые указывают на эту вершину. Это изменение, показанное в программе 18.9, позволяет получить реализацию поиска в ширину, в очереди которой не бывает более V ребер (в каждую вершину ведет не более одного ребра).

 Дерево BFS


Рис. 18.22.  Дерево BFS

Это дерево представляет собой компактное описание динамических характеристик поиска в глубину, аналогично дереву, которое показано на рис. 18.9. Обход дерева по уровням показывает, как выполняется поиск: сначала мы посещаем вершину 0, потом вершины 2, 5 и 7, затем, находясь в 2, мы выясняем, что в вершине 0 мы уже были, и направляемся в 6 и т.д. У каждого узла дерева имеются дочерние узлы, которые представляют узлы, смежные с этим узлом, в том порядке, в каком они рассматриваются алгоритмом BFS. Как и на рис. 18.9, ссылки дерева BFS соответствуют ребрам графа: если заменить ребра, ведущие во внешние узлы, на линии, ведущие в заданный узел, то мы получим чертеж графа. Ссылки, ведущие во внешние узлы, представляют собой ребра, которые не были помещены в очередь, потому что они ведут в помеченные узлы: это либо родительские ссылки, либо перекрестные ссылки, которые указывают на узел, находящийся на том же уровне или на уровне, более близком к корню дерева.

Вектор st являетсяпредставлени-ем дерева родительскими ссылками, которое можно использовать для поиска кратчайшего пути из любого узла в корень. Например, 3-5-0 — путь в графе из 3 в 0, поскольку st[3] равно 5, а st[5] равно 0. Более короткого пути из 3 в 0 не существует.

Лемма 18.11. Поиск в ширину посещает все вершины и ребра графа за время, пропорциональное V2, для представления матрицей смежности и за время, пропорциональное V + E, для представления списками смежности.

Доказательство. Так же, как при доказательстве аналогичных свойств DFS, анализ кода показывает, что каждый элемент строки матрицы смежности или списка смежности проверяется в точности один раз для каждой посещаемой вершины — следовательно, достаточно показать, что BFS посещает каждую вершину. Для каждого связного компонента алгоритм сохраняет следующий инвариант: все вершины, в которые можно попасть из исходной вершины, (1) включены в дерево BFS, (2) занесены в очередь или (3) достижимы из одной из вершин, занесенных в очередь. Каждая вершина перемещается из (3) в (2) и в (1), а количество вершин в (1) увеличивается при каждой итерации цикла; значит, в конечном итоге дерево BFS будет содержать все вершины, достижимые из исходной вершины. Это дает, как и в случае DFS, основание утверждать, что алгоритм BFS выполняется за линейное время.

Программа 18.9. Усовершенствованный BFS

Чтобы очередь, используемая при выполнении BFS, содержала не более V элементов, мы помечаем вершины в момент занесения их в очередь.

  void searchC(Edge e)
    { QUEUE<Edge> Q;
      Q.put(e); ord[e.w] = cnt++;
      while (!Q.empty())
        { e = Q.get(); st[e.w] = e.v;
          typename Graph::adjIterator A(G, e.w);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (ord[t] == -1)
              { Q.put(Edge(e.w, t)); ord[t] = cnt++; }
        }
     }
      

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

Кратчайший путь. Нужно найти кратчайший путь на графе из v в w. Эту задачу можно выполнить, запустив процесс поиска в ширину, который создает в векторе st представление родительскими ссылками дерева поиска из v и останавливается по достижении вершины w. Путь вверх по дереву из w в v и является кратчайшим. Например, после построения экземпляра bfs класса BFS<Graph> клиент может использовать следующий код для вывода пути из w в v:

  for (t = w; t != w; t = bfs.ST(t)) cout << t << "-";
  cout << v << endl;
      

Чтобы получить путь из v в w, нужно заменить в этом коде операции cout операциями занесения в стек и добавить цикл, который выводит индексы этих вершин после выталкивания их из стека. Либо можно запустить поиск из w и остановить его по достижении v.

Кратчайшие пути из одного источника. Нужно найти кратчайшие пути, соединяющие заданную вершину v со всеми другими вершинами графа. Эту задачу позволяет решить полное дерево BFS с корнем в вершине v: путь из каждой вершины в корень является кратчайшим путем в корень. Поэтому для решения этой задачи необходимо выполнить поиск в ширину из вершины v до полного завершения. Полученный при этом вектор st является представлением дерева BFS родительскими ссылками, а код из предыдущего абзаца позволяет получить кратчайший путь в любую другую вершину w.

Кратчайшие пути для всех пар вершин. Нужно найти кратчайшие пути, соединяющие каждую пару вершин графа. Эту задачу можно выполнить с помощью класса BFS, который решает задачу с одним источником для каждой вершины графа и использует функции-члены, которые могут эффективно обрабатывать большое количество запросов на определение кратчайших путей, сохраняя длины путей и представления деревьев родительскими ссылками для каждой вершины (см. рис. 18.23). Такая предварительная обработка требует времени, пропорционального VE, и объема памяти, пропорционального V2, что делает невозможной обработку больших разреженных графов. Однако она позволяет строить АТД с оптимальной производительностью: после затраты времени на предварительную обработку (и памяти для сохранения результатов этой обработки) можно вычислять длины кратчайших путей за постоянное время, а сами пути — за время, пропорциональное их длине (см. упражнение 18.55).

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

Базовые характеристики динамики поиска существенно отличаются от аналогичных характеристик поиска в глубину — см. пример для большого графа на рис. 18.24 и сравните его с рис. 18.13. Дерево имеет небольшую глубину, зато большую ширину. Оно демонстрирует множество отличий поиска в ширину на графе от поиска в глубину. Например:

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

 Пример определения кратчайших путей для всех пар вершин


Рис. 18.23.  Пример определения кратчайших путей для всех пар вершин

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

В левой матрице элемент на пересечении строки v и столбца w содержит длину кратчайшего пути из v в w (глубину v в дереве w). Каждая строка правой матрицы содержит массив st для соответствующего поиска. Например, кратчайший путь из 3 в 2 состоит из трех ребер, как показывает элемент левой матрицы, расположенный на пересечении строки 3 и столбца 2. Третье сверху слева дерево BFS говорит, что это путь 3-4-6-2 — данная информация закодирована в строке 2 правой матрицы. При наличии нескольких кратчайших путей матрица не обязательно должна быть симметричной, поскольку найденные пути зависят от порядка выполнения поиска в ширину. Например, дерево BFS, показанное внизу слева, и строка 3 правой матрицы говорят, что кратчайшим путем из 2 в 3 является 2-0-5-3.

 Поиск в ширину


Рис. 18.24.  Поиск в ширину

Данный рисунок показывает, что поиск в ширину выполняется в случайном евклидовом графе с близкими связями (слева) в том же стиле, что и на рис. 18.13. Как видно из этого примера, дерево BFS для таких графов (а также для многих других видов графов, которые часто встречаются на практике) обычно имеет малую глубину и большую ширину. То есть вершины обычно соединены между собой довольно короткими путями. Различие между формами деревьев DFS и BFS свидетельствуют о существенном различии динамических характеристик этих алгоритмов.

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

Главное для понимания алгоритмов обработки графов — уяснить не только то, что существуют различные стратегии поиска как эффективное средство изучения различных свойств графов, но и то, что многие из них можно реализовать стандартным путем. Например, поиск в глубину, показанный на рис. 18.13, обнаруживает, что в графе имеется длинный путь, а поиск в ширину, изображенный на рис. 18.24, говорит о том, что в графе присутствуют многочисленные короткие пути. Но, несмотря на упомянутые различия в динамике, алгоритмы DFS и BFS имеют много общего. Они существенно различаются лишь структурой данных, которая используется для хранения еще не исследованных ребер (и случайной возможностью использовать рекурсивную реализацию DFS с системной поддержкой неявного стека). Теперь мы обратимся к обобщенным алгоритмам поиска на графах, которые охватывают как DFS и BFS, так и множество других полезных стратегий, и могут служить основой для решения разнообразных классических задач обработки графов.

Упражнения

18.50. Начертите лес BFS, построенный стандартным BFS по спискам смежности для графа

3-71-47-80-55-23-82-90-64-92-66-4.

18.51. Начертите лес BFS, построенный стандартным BFS по матрице смежности для графа

3-71-47-80-55-23-82-90-64-92-66-4.

18.52. Измените программы 18.8 и 19.9, чтобы использовать в них контейнер queue из библиотеки STL вместо АТД из лекция №4.

18.53. Приведите реализацию BFS (на основе программы 18.9), которая использует очереди вершин (см. программу 5.22). Включите в нее проверку, не допускающую занесения в очередь одинаковых вершин.

18.54. Приведите матрицы всех кратчайших путей (в стиле рис. 18.23) для графа 3-7 1-4 7-8 0-5 5-2 3-8 2-9 0-6 4-9 2-6 6-4,

представленного матрицей смежности.

18.55. Разработайте класс поиска кратчайших путей, который после предварительной обработки отвечает на запросы о кратчайших путях. А именно, определите двухмерную матрицу как приватный член данных и напишите конструктор, который присваивает значения всем ее элементам, как показано на рис. 18.23. Затем напишите две функции запросов: функцию length(v, w), которая возвращает длину кратчайшего пути между вершинами v и w, и функцию path(v, w), которая возвращает вершину, смежную с v на кратчайшем пути между v и w.

18.56. Что можно узнать из дерева BFS о расстоянии между вершинами v от w, когда ни одна из них не является корнем этого дерева?

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

18.58. Приведите простой и оптимальный рекурсивный алгоритм вычисления диаметра дерева (см. упражнение 18.57).

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

18.60. Эмпирически определите средние значения величин, описанных в упражнении 18.59, для графов различных размеров и построенных на основе различных моделей (упражнения 17.64—17.76).

Обобщенный поиск на графах

Алгоритмы DFS и BFS — фундаментальные и важные методы, лежащие в основе многочисленных алгоритмов обработки графов. Зная их основные свойства, мы можем перейти на более высокий уровень абстракции, на котором видно, что оба метода представляют собой частные случаи обобщенной стратегии перемещения по графу — той, которая была предложена в реализацию поиска в ширину (программа 18.9).

Основной принцип прост: мы снова обращаемся к описанию поиска в ширину из раздела 18.6, только вместо понятия очередь (queue) используем обобщенный термин накопитель (fringe) — множество ребер-кандидатов для следующего включения в дерево поиска. Мы сразу же приходим к общей стратегии поиска связного компонента графа. Начав с петли исходной вершины в накопителе и пустого дерева, выполняем следующую операцию, пока накопитель не станет пустым:

Перенесите какое-либо ребро из накопителя в дерево. Если вершина, в которую оно ведет, еще не посещалась, посетите эту вершину и поместите в накопитель все ребра, которые ведут из этой вершины в еще не посещенные вершины.

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

Если для реализации накопителя использовать очередь, получится поиск в ширину, описанный в разделе 18.6. Если для реализации накопителя использовать стек, получится поиск в глубину. Это явление подробно представлено на рис. 18.25, который полезно сравнить с рисунками 18.6 и 18.21.

Доказательство эквивалентности рекурсивного DFS и DFS на базе стека представляет собой интересное упражнение по удалению рекурсии, в процессе которого стек, лежащий в основе рекурсивной программы, фактически преобразовывается в стек, реализующий накопитель (см. упражнение 18.63). Порядок просмотра в DFS, показанный на рис. 18.25, отличается от порядка просмотра на рис. 18.6 только тем, что из-за дисциплины LIFO ребра, инцидентные каждой вершине, проверяются в порядке, обратном порядку в матрице смежности (или в списках смежности). Однако главное не меняется: если изменить структуру данных в программе 18.8 с очереди на стек (что легко сделать, поскольку интерфейсы АТД этих двух структур данных отличаются только именами функций), то программа будет выполнять поиск не в ширину, а в глубину.

Как было сказано в разделе 18.7, этот обобщенный метод может оказаться не таким эффективным, как хотелось бы, поскольку накопитель оказывается загроможден ребрами, указывающими на вершины, которые уже были перенесены в дерево, когда данное ребро находилось в накопителе. В случае очередей FIFO этого удается избежать благодаря пометке конечных вершин в момент занесения их в очередь. Мы игнорируем ребра, которые ведут в вершины, находящиеся в накопителе, поскольку знаем, что они не будут использованы: старое ребро (и посещенная вершина) извлекается из очереди раньше, чем новое (см. программу 18.9). В реализации со стеком все наоборот: когда в накопитель нужно поместить ребро с той же конечной вершиной, что и уже хранящееся ребро, мы знаем, что не будет использовано старое ребро, поскольку новое ребро (и посещенная вершина) извлекается из стека раньше старого. Чтобы охватить эти два крайних случая и обеспечить возможность реализации накопителя, которая может воспользоваться какой-нибудь другой стратегией, блокирующей наличие в накопителе ребер, которые указывают на ту же вершину, мы скорректируем нашу обобщенную схему следующим образом:

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

 Алгоритм поиска в глубину с использованием стека


Рис. 18.25.  Алгоритм поиска в глубину с использованием стека

Вместе срис. 18.21 данный рисунок демонстрирует, что поиски в ширину и в глубину отличаются друг от друга только рабочей структурой данных. Для поиска в ширину используется очередь, а для поиска в глубину — стек. Выполнение начинается с просмотра всех вершин, смежных с исходной вершиной (вверху слева). Затем мы перемещаем ребро 0-7 из стека в дерево и заталкиваем в стек инцидентные вершине 7 ребра 7-1, 7-4 и 7-6, которые ведут в еще не включенные в дерево вершины (вторая диаграмма сверху слева). Дисциплина LIFO предполагает, что при помещении ребра в стек другие ребра, ведущие в ту же вершину, считаются неактуальными и игнорируются, когда поднимаются в верхушку стека. Эти ребра заштрихованы на рисунке серым цветом. После этого мы переносим ребро 7-6 из стека в дерево и заносим инцидентные ему ребра в стек (третья диаграмма сверху слева). Потом мы извлекаем из стека ребро 4-6 и заносим инцидентные ему ребра, два из которых приводят нас к новым вершинам (внизу слева). В завершение поиска мы извлекаем из стека оставшиеся ребра, игнорируя " серые " ребра, когда они поднимаются в верхушку стека (справа).

Стратегия блокировки одинаковых конечных вершин в накопителе позволяет отказаться от проверки, была ли посещена конечная вершина извлеченного из очереди ребра. В случае поиска в ширину используется реализация очереди с правилом игнорирования новых элементов, а для поиска в глубину нужен стек с правилом игнорирования старых элементов. Однако любая обобщенная очередь в сочетании с любым правилом блокировки также даст эффективный метод просмотра всех вершин и ребер графа за линейное время с использованием объема дополнительной памяти, пропорционального V. Схематическая иллюстрация этих различий приведена на рис. 18.27. Так что в нашем распоряжении имеется целое семейство стратегий поиска на графе, которое содержит и BFS, и DFS, и члены которого отличаются друг от друга только реализацией обобщенной очереди. Как мы увидим ниже, в это семейство входят и многие другие классические алгоритмы обработки графов.

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

 Терминология поиска на графе


Рис. 18.26.  Терминология поиска на графе

Для выполнения поиска на графе мы используем дерево поиска (черные линии) и накопитель (серые линии), содержащие ребра, которые являются кандидатами на следующее включение в дерево. Любая вершина либо занесена в дерево (черные), либо находится в накопителе (серые), либо еще не просмотрена (белые). Вершины дерева соединены древесными ребрами, а каждая вершина из накопителя соединена ребром с некоторой вершиной дерева.

 Стратегии поиска на графе


Рис. 18.27.  Стратегии поиска на графе

Здесь показаны различные возможности при выборе следующего шага поиска, показанного на рис. 18.26. Мы переносим вершину из накопителя в дерево (из центра колеса, вверху справа), проверяем все ее ребра и помещаем ребра, ведущие в непроверенные вершины, в накопитель. При этом используется правило замещения, которое определяет обработку ребер, указывающих на вершины, которые уже присутствуют в накопителе и указывают на ту же самую вершину: нужно ли пропустить такое ребро или заменить им ребро из накопителя. В поиске в глубину мы всегда заменяем старые ребра, а в поиске в ширину — всегда игнорируем новые ребра; в других стратегиях мы заменяем одни ребра и пропускаем другие.

Программа 18.10. Обобщенный поиск на графе

Данный класс поиска на графе обобщает алгоритмы BFS и DFS и поддерживает другие алгоритмы обработки графов (см. лекция №21, в котором обсуждаются эти алгоритмы и альтернативные реализации). В нем используется обобщенная очередь ребер, которая называется накопителем (fringe). Вначале в накопитель заносится петля исходной вершины; затем, пока накопитель не пуст, мы переносим из него ребро e в дерево (с началом в вершине e.v) и просматриваем список смежности вершины e.w, помещая в накопитель непросмотренные вершины и вызывая функцию update при появлении новых ребер, указывающих на вершины, которые уже занесены в накопитель.

Этот код использует векторы ord и st, чтобы никакие два ребра в накопителе не указывали на одну и те же вершину. Вершина v является конечной вершиной ребра, помещенного в накопитель, тогда и только тогда, когда она помечена (значение ord[v] не равно -1), но еще не находится в дереве (st[v] равно -1).

  #include "GQ.cc"
  template <class Graph>
  class PFS : public SEARCH<Graph>
    { vector<int> st;
      void searchC(Edge e)
        { GQ<Edge> Q(G.V());
          Q.put(e); ord[e.w] = cnt++;
          while (!Q.empty())
            { e = Q.get(); st[e.w] = e.v;
              typename Graph::adjIterator A(G, e.w);
              for (int t = A.beg(); !A.end(); t = A.nxt())
                if (ord[t] == -1)
                  { Q.put(Edge(e.w, t)); ord[t] = cnt++; }
              else
                if (st[t] == -1) Q.update(Edge(e.w, t));
            }
        }
    public:
      PFS(Graph &G) : SEARCH<Graph>(G), st(G.V(), -1)
        { search(); }
      int ST(int v) const { return st[v]; }
    } ;
      

Лемма 18.12. Обобщенный поиск на графе посещает все вершины и ребра графа за время, пропорциональное V2, для представления матрицей смежности и за время, пропорциональное V + E для представления списками смежности плюс, в худшем случае, время на V операций вставки, V операций удаления и E операций обновления в обобщенной очереди размера V.

Доказательство. Доказательство леммы 18.11 не зависит от реализации очереди и поэтому применимо и здесь. Указанные дополнительные затраты времени на операции с обобщенной очередью следуют непосредственно из программной реализации.

Существует множество других заслуживающих внимания эффективных моделей АТД накопителя. Например, как в случае нашей первой реализации BFS, можно придерживаться нашей первой общей схемы: просто поместить все ребра в накопитель, а при извлечении из накопителя игнорировать те их них, которые ведут в вершины, уже включенные в дерево. Недостаток такого подхода, как и в случае BFS, состоит в том, что максимальный размер очереди должен быть равен E, а не V. Либо можно выполнять обновления неявно в реализации АТД, просто указав, что никакие два ребра с одной и той же конечной вершиной не могут находиться в очереди одновременно. Однако простейший способ сделать это в реализации АТД по сути эквивалентен использованию вектора, индексированного именами вершин (см. упражнения 4.51 и 4.54), поэтому такая проверка больше вписывается в клиентские программы, выполняющие поиск на графе.

Сочетание программы 18.10 с абстракцией обобщенной очереди дает универсальный и гибкий механизм поиска на графе. Для иллюстрации этого утверждения мы кратко рассмотрим две интересных и полезных альтернативы поискам в глубину и ширину.

Первая альтернативная стратегия основана на использовании рандомизированной очереди (randomized queue, см. лекция №4). Из рандомизированных очередей элементы извлекаются в случайном порядке: любой элемент такой структуры данных может быть выбран с равной вероятностью. Программа 18.11 представляет собой реализацию, которая обеспечивает такое поведение. Если использовать этот код для реализации АТД обобщенной очереди, то получится алгоритм случайного поиска на графе, где любая вершина, находящаяся в накопителе, с равной вероятностью может стать кандидатом на включение в дерево. Выбор ребра (ведущего в эту вершину) для добавления в дерево зависит от реализации операции обновить. Реализация в программе 18.11 не выполняет никаких обновлений, и каждая вершина из накопителя добавляется в дерево вместе с ребром, которое послужило причиной ее занесения в накопитель. Но можно, наоборот, выполнять все обновления (тогда в дерево будет добавляться самое последнее встреченное ребро из всех, которые ведут в каждую вершину, помещенную в накопитель), либо использовать случайный выбор.

Программа 18.11. Реализация рандомизированной очереди

При извлечении элемента из этой структуры данных с равной вероятностью выбирается любой из находящихся в ней элементов. Этот код можно использовать для реализации АТД обобщенной очереди для выполнения " случайного " поиска на графе (см. текст).

template <class Item>
class GQ
  { private:
      vector<Item> s; int N;
    public:
      GQ(int maxN) : s(maxN+1), N(0) { }
      int empty() const 
        { return N == 0; }
      void put(Item item)
        { s[N++] = item; }
      void update(Item x) { }
      Item get()
        { int i = int(N*rand()/(1.0+RAND MAX));
          Item t = s[i];
          s[i] = s[N-1];
          s[N-1] = t;
         return s[--N]; }
  };
      

 Размеры накопителя при работе поиска в глубину, рандомизированного поиска и поиска в ширину


Рис. 18.28.  Размеры накопителя при работе поиска в глубину, рандомизированного поиска и поиска в ширину

Эти графики размеров накопителя во время поисков, представленных на рис. 18.13, рис. 18.24 и рис. 18.29, показывают, какое огромное влияние оказывает на поиск на графе выбор структуры данных для накопителя. При использовании стека в DFS (вверху) накопитель заполняется в самом начале поиска, поскольку на каждом шаге мы находим новые узлы, а затем извлекаем из накопителя все его содержимое. При использовании рандомизированной очереди (в центре) максимальный размер очереди намного меньше. При использовании очереди FIFO в BFS (внизу) максимальный размер очереди еще меньше, а новые узлы обнаруживаются в процессе поиска.

Другая стратегия играет исключительно важную роль в изучении алгоритмов обработки графов, поскольку лежит в основе целого ряда классических алгоритмов, которые будут рассмотрены в лекциях 20—22 — это использование для накопителя АТД очереди с приоритетами (priority queue, см. лекция №9. Каждому ребру в накопителе присваивается определенное значение приоритета, которое затем может изменяться, и выбираем для очередного включения в дерево ребро с наивысшим приоритетом. Подробный анализ этого алгоритма будет проведен в главе 20 лекция №20. Операции по работе с очередью с приоритетами требуют больших затрат, чем аналогичные операции для стеков и очередей, поскольку для них необходимы неявные операции сравнения элементов очереди, но зато они могут поддерживать значительно более широкий класс алгоритмов поиска на графах. Как мы увидим ниже, некоторые из наиболее важных задач обработки графов можно решить, просто выбрав необходимый способ назначения приоритетов в реализации обобщенного поиска на графе на базе очереди с приоритетами.

Все обобщенные алгоритмы поиска на графах просматривают каждое ребро всего один раз и в худшем случае требуют дополнительного объема памяти, пропорционального V; однако они все же различаются некоторыми показателями производительности. Например, на рис. 18.28 показано, как меняется размер накопителя в процессе выполнения поиска в глубину, в ширину и рандомизированного поиска; на рис. 18.29 показано дерево, вычисленное с помощью рандомизированного поиска для примера, представленного на рис. 18.13 и рис. 18.24. Для рандомизированного поиска не характерны ни длинные пути, как в DFS, ни узлы с большими степенями, как в BFS. Формы этих деревьев и графиков размеров накопителя зависят от структуры конкретного графа, на котором производится поиск, но они все-таки характеризуют различные алгоритмы.

Поиск на графах можно обобщить и далее, если работать с лесом (а не только с деревом). Мы уже вплотную пошли к этому уровню абстракции, однако отложим рассмотрение ряда таких алгоритмов до лекция №20.

 Граф рандомизированного поиска


Рис. 18.29.  Граф рандомизированного поиска

Здесь показан процесс рандомизированного поиска на графе (слева) в том же стиле, что и на рисунках 18.13 и 18.24. Форма дерева поиска находится где-то между поиском в глубину и поиском в ширину. Динамические характеристики этих трех алгоритмов, которые отличаются только структурой данных, необходимой для выполнения работы, разительно отличаются.

Упражнения

18.61. Проанализируйте преимущества и недостатки реализации обобщенного поиска на графе на базе следующего правила: " Перенесите ребро из накопителя в дерево. Если вершина, в которую оно ведет, не посещалась, посетите эту вершину и занесите в накопитель все инцидентные ей ребра " .

18.62. Разработайте реализацию АТД графа, представленного списками смежности, которая содержит ребра (а не только их конечные вершины) в списках. Затем реализуйте поиск на графе, основанный на стратегии из упражнения 18.61, который посещает каждое ребро, но разрушает граф, воспользовавшись тем, что ребра всех вершин можно перемещать в накопитель с помощью изменения лишь одной ссылки.

18.63. Докажите, что рекурсивный поиск в глубину (программа 18.3) эквивалентен обобщенному поиску на графе с использованием стека (программа 18.10) в том смысле, что обе программы посещают все вершины любого графа в одном и том же порядке тогда и только тогда, когда эти программы просматривают списки смежности в разных направлениях.

18.64. Приведите три различных порядка обхода при рандомизированном поиске на графе

3-71-47-80-55-23-82-90-64-92-66-4.

18.65. Может ли рандомизированный поиск посетить вершины графа 3-7 1-4 7-8 0-5 5-2 3-8 2-9 0-6 4-9 2-6 6-4.

в порядке возрастания их индексов? Обоснуйте свой ответ.

18.66. Воспользуйтесь библиотекой STL для построения реализации обобщенной очереди ребер графа, которая блокирует занесение ребер с одинаковыми вершинами по правилу игнорирования новых элементов.

18.67. Разработайте алгоритм рандомизированного поиска на графе, который с равной вероятностью выбирает из накопителя любое ребро. Указание. См. программу 18.8.

18.68. Опишите стратегию обхода лабиринта, которая соответствует использованию обычного стека магазинного типа для обобщенного поиска на графе (см. раздел 18.1).

18.69. Добавьте в обобщенный поиск на графе (см. программу 18.10) возможность вывода значений высоты дерева и процента обработанных ребер для каждой просматриваемой вершины.

18.70. Эмпирически определите средние значения величин, описанных в упражнении 18.69, для обобщенного поиска на графе с использованием случайной очереди в графах различных размеров и построенных на основе различных моделей (см. упражнения 17.64—17.76).

18.71. Реализуйте производный класс, который строит динамические графические анимации обобщенного поиска на графах, в которых с каждой вершиной связаны координаты (x, у) (см. упражнения 17.55—17.59). Протестируйте полученную программу на случайных евклидовых графах с соседними связями, используя столько точек, сколько сможете обработать за приемлемый промежуток времени. Ваша программа должна строить изображения вроде диаграмм на рис. 18.13, рис. 18.24 и рис. 18.29, хотя для обозначения вершин и ребер, которые находятся в дереве, или в накопителе, или еще не просмотрены, вместо оттенков серого можно использовать различные цвета.

Анализ алгоритмов на графах

Мы уже можем рассмотреть широкий набор задач обработки графов и методов их решения, чтобы в дальнейшем не всегда сравнивать различные многочисленные алгоритмы решения одной и той же задачи. Хотя всегда полезно набраться опыта работы с алгоритмами, проверяя их на реальных данных или же на сгенерированных данных, которые понятны нам и обладают теми же свойствами, что и реальные данные.

Как было кратко отмечено в лекция №2, мы стремимся (в идеальном случае) получить естественные модели входных данных, обладающие тремя важными свойствами:

При наличии этих трех компонентов можно приступать к циклу проектирования, анализа, реализации и тестирования, который позволяет создавать эффективные алгоритмы для решения практических задач.

В таких областях, как сортировка и поиск, подобные циклы позволили нам добиться значительных успехов в частях 3 и 4. Мы можем анализировать алгоритмы, генерировать случайные экземпляры задач и совершенствовать программные реализации, чтобы получать высокоэффективные программы для использования в самых разнообразных практических ситуациях. В ряде других областей могут возникнуть различные трудности. Например, математический анализ на приемлемом для нас уровне невозможен при решении многих геометрических задач, а разработка точной модели входных данных — серьезная проблема для многих алгоритмов обработки строк (поскольку сама такая разработка требует существенных вычислений). Вот и алгоритмы на графах приводят нас к ситуации, когда зачастую приходится балансировать между тремя вышеупомянутыми свойствами:

Из-за сложности графов часто не удается в полной мере оценить существенные свойства графов, с которыми приходится сталкиваться на практике, или искусственных графов, которые мы (возможно) можем генерировать и анализировать.

Вообще-то ситуация не так уж безнадежна, по одной простой причине: многие из рассматриваемых нами алгоритмов на графах оптимальны в худшем случае, и поэтому прогнозирование производительности алгоритмов не представляет трудностей. Например, программа 18.7 находит мосты, просмотрев лишь один раз каждое ребро и каждую вершину. Эти затраты совпадают с затратами на построение структуры данных графа, и мы можем уверенно предсказать, например, что удвоение количества ребер приведет к удвоению времени выполнения, независимо от вида обрабатываемых графов.

Однако если время выполнения алгоритма зависит от структуры входного графа, прогнозы становятся не таким простым делом. Но если понадобится обработать очень много больших графов, то нам нужны эффективные алгоритмы по той же причине, по какой они требуются и в любой другой проблемной области. Поэтому мы продолжим изучение основных свойств алгоритмов и их применений и постараемся выявить методы, наиболее удобные для обработки графов, которые могут встретиться на практике.

Для иллюстрации некоторых из этих вопросов мы вернемся к изучению свойства связности графа — т.е. к задаче, которую мы начали рассматривать еще в главе 1 лекция №1. Связность случайных графов привлекала к себе внимание математиков в течение многих лет, и данной теме посвящена обширная литература. Эта литература выходит за рамки настоящей книги, однако она представляет собой фон, который оправдывает использование этой задачи как основы для некоторых экспериментальных исследований, углубляющих наше понимание базовых алгоритмов и изучаемых видов графов.

Например, постепенное построение графов добавлением случайных ребер во множество первоначально изолированных вершин (по существу, этот процесс выполняется в программе 19.12) представляет собой хорошо изученный процесс, лежащий в основе классической теории случайных графов. Известно, что при возрастании количества ребер такой граф сливается в один гигантский компонент. Литература по случайным графам дает обширную информацию о природе этого процесса. Например:

Лемма 18.13. Если (при положительном ), то случайный граф с V вершинами и E ребрами состоит из одного связного компонента и изолированных вершин, среднее количество которых не превышает , с вероятностью, приближающейся к 1 при бесконечном возрастании V.

Доказательство. Этот факт был установлен в пионерской работе Эрдеша (Erdos) и Реньи (Renyi) в 1960 г. Само доказательство выходит за рамки данной книги (см. раздел ссылок).

На основании этой леммы можно ожидать, что крупные неразреженные случайные

графы являются связными. Например, если V> 1000 и E > 10V, то , и среднее количество вершин, не содержащихся в гигантском компоненте, (почти наверняка) меньше, чем . Если сгенерировать миллион случайных графов с 1000 вершинами и плотностью, большей 10, то среди них могут оказаться несколько графов с одной изолированной вершиной, но остальные графы будут связными.

На рис. 18.30 обычные случайные графы сравниваются со случайными графами с соседними связями, в которых разрешены лишь ребра, соединяющие только такие вершины, индексы которых различаются не более чем на некоторую небольшую константу. Модель графа с соседними связями порождает графы, которые по своим характеристикам существенно отличаются от случайных графов. В конечном итоге мы все-таки получим гигантский компонент, но он появится внезапно, при слиянии двух крупных компонентов.

Из таблицы 18.1 видно, что эти структурные различия между случайными графами и графами с соседними связями сохраняются и тогда, когда V и E находятся в пределах, представляющих практический интерес. Конечно, такие структурные различия могут отразиться на производительности алгоритмов.

 Связность в случайных графах


Рис. 18.30.  Связность в случайных графах

Здесь показаны 10 этапов эволюции двух видов случайных графов при добавлении 2E ребер в первоначально пустые графы. Каждый график представляет собой гистограмму количества вершин в компонентах размером от 1 до V— 1 (слева направо). Вначале все вершины содержатся в компонентах размером 1, а в конце практически все вершины входят в один гигантский компонент. Графики слева соответствуют обычному случайному графу: гигантский компонент формируется быстро, а все другие компоненты малы. Графики справа соответствуют случайному графу с соседними связями: компоненты различных размеров сохраняются в течение более длительного времени.

Таблица 18.1. Связность на примере двух моделей случайного графа
EСлучайные ребраСлучайные ребра из 10 соседних
CLCL
1000990005990033
2000980004980104
5000950006950755
10000900008903007
200008000216813819
500005000317015798627
100000162367963328721151
20000018879804938186797
5000004999971999979
100000011000001100000
Обозначения:
CКоличество связных компонентов
LРазмер наибольшего связного компонента

В этой таблице показано количество связных компонентов и размер максимального связного компонента в графах, содержащих 100 000 вершин и полученных из двух различных распределений. Для модели случайного графа эксперименты подтверждают известный факт, что если среднее значение степени вершин превышает некоторую небольшую константу, то граф с высокой вероятностью состоит в основном из одного гигантского компонента. В двух правых столбцах приведены экспериментальные данные при наличии ограничения: ребра могут соединять каждую вершину лишь с одной из 10 указанных соседних вершин.

В таблицу 18.2 сведены эмпирические значения затрат на определение количества связных компонентов случайного графа с помощью различных алгоритмов. Прямое сравнение этих алгоритмов не вполне уместно, поскольку они разрабатывались для решения различных задач, однако эксперименты все же подтверждают некоторые наши выводы.

Во-первых, из таблицы ясно, что не следует использовать представление матрицей смежности для больших разреженных графов (и невозможно для очень больших) — не только из-за неподъемных затрат на инициализацию матрицы, но и потому, что алгоритм просматривает каждый элемент матрицы, вследствие чего время его выполнения пропорционально размеру матрицы (V2), а не количеству единиц в ней (E). Например, из таблицы следует, что в случае использования матрицы смежности на обработку графа с 1000 ребрами нужно примерно столько же времени, что и на обработку графа, содержащего 100 000 ребер.

Во-вторых, из таблицы 18.2 также ясно, что затраты на выделение памяти под узлы списков смежности для крупных разреженных графов довольно велики. Затраты на построение таких списков превосходят затраты на обход более чем в пять раз. В типичной ситуации, когда после построения графа нужно выполнить много разных поисков, такая цена приемлема. Иначе лучше рассмотреть альтернативные реализации, позволяющие снизить эти затраты.

Таблица 18.2. Эмпирическое сравнение алгоритмов поиска на графе
EUU*Матрица смежностиСписки смежности
IDD*IDD*BB*
5000 вершин
5001025531235610001
10000125531135410001
50001225831235322121
100003325831435852121
50000126270315202256456
10000023728631418152921011
500000117547824811126754165647
100000 вершин
5000533872424
10000456772424
5000018182612122828
10000034355128243434
5000001331372598889
Обозначения:
UВзвешенное быстрое объединение со сжатием пути делением пополам (программа 1.4)
IНачальное создание представления графа
DРекурсивный DFS (программа 18.3)
BBFS (программа 18.9)
*Выход сразу при установлении полной связности графа

В этой таблице показаны относительные значения времени определения различными алгоритмами количества связных компонентов (и размера наибольшего из них) для графов с различным количеством вершин и ребер. Как и ожидалось, алгоритмы, которые используют представление матрицей смежности, медленно работают на разреженных графах, но вполне конкурентоспособны на насыщенных графах. В случае данной задачи алгоритмы объединения-поиска, которые были рассмотрены в лекция №1, работают быстрее всего, т.к. они строят структуру данных, предназначенную специально для решения данной задачи, а поэтому не нуждаются в другом представлении графа. Но если структура данных, представляющая граф, уже построена, то алгоритмы DFS и BFS оказываются быстрее и гибче. Добавление проверки для прекращения работы, когда уже понятно, что граф представляет собой единый связный компонент, существенно повышает быстродействие поиска в глубину и объединения-поиска (но не поиска в ширину) при обработке насыщенных графов.

В-третьих, весьма показательно отсутствие чисел в столбцах DFS для крупных разреженных графов. Такие графы заводят рекурсию в бездонную глубину, которая (в конечном итоге) приводит к аварийному завершению программы. Если мы хотим использовать поиск в глубину для таких графов, необходимо применять нерекурсивные версии программ, описанные в разделе 18.7.

В-четвертых, эта таблица показывает, что метод на основе объединения-поиска, описанный в лекция №1, работает быстрее поисков в глубину и в ширину — главным образом потому, что ему не нужно представление всего графа. Однако, не имея такого представления, мы не можем дать ответ на такие простые запросы, как " Существует ли ребро, соединяющее вершины v и w? " . Поэтому методы на основе объединения-поиска не годятся, если мы хотим получить больше, чем то, для чего они предназначены (например, ответы на запросы типа " Существует ли путь, соединяющий вершины v и w? " вперемешку с добавлением ребер). Если внутреннее представление графа уже построено, не стоит трудиться над реализацией алгоритма объединения-поиска только для того, чтобы узнать, связен граф или нет, поскольку и DFS, и BFS могут дать ответ так же быстро.

При эмпирических сравнениях для составления подобных таблиц могут потребоваться объяснения различных аномалий. Например, на многих компьютерах архитектура кэша и другие свойства системы управления памятью могут существенно повлиять на производительность алгоритма на крупных графах. Повышение производительности критических приложений может потребовать, помимо всех рассматриваемых здесь факторов, и подробного изучения архитектуры машины.

Внимательное изучение этих таблиц позволяет обнаружить больше свойств этих алгоритмов, чем мы способны рассмотреть. Однако наша цель состоит не в тщательном анализе, а в демонстрации, что несмотря на множество проблем при сравнении различных алгоритмов на графах, мы можем и должны проводить эмпирические исследования и использовать любые доступные аналитические результаты, чтобы получить представление об основных особенностях алгоритмов и прогнозировать их производительность.

Упражнения

18.72. Составьте на основе эмпирических исследований таблицу наподобие таблицы 18.2 для задачи определения, является ли граф двудольным (может ли быть раскрашен двумя красками).

18.73. Составьте на основе эмпирических исследований таблицу наподобие таблицы 18.2 для задачи определения, является ли граф двусвязным.

18.74. Определите эмпирически ожидаемый размер второго по величине связного компонента разреженных графов различных размеров, построенных на основе различных моделей (см. упражнения 17.64—17.76).

18.75. Напишите программу для построения графиков наподобие рис. 18.30 и протестируйте ее на графах различных размеров, построенных на основе различных моделей (см. упражнения 17.64—17.76).

18.76. Измените программу из упражнения 18.75, чтобы она строила аналогичные гистограммы для размеров реберно-связных компонентов.

18.77. Числа в таблицах, приведенных в данном разделе, получены при исследовании только одной выборки. Можно подготовить аналогичную таблицу, каждый элемент которой будет получен на основе 1000 экспериментов, и подсчитать среднее значение и среднеквадратичное отклонение для каждой выборки (хотя таблица станет слишком большой). Будет ли такой подход более эффективен? Обоснуйте свой ответ.

Лекция 19. Орграфы и DAG-графы

Если учитывать порядок, в котором задаются две вершины каждого ребра графа, получится совершенно другой комбинаторный объект — ориентированный граф (directed graph), или орграф (digraph). Пример орграфа приведен на рис. 19.1. В орграфах обозначение s-t описывает ребро, ведущее из вершины s в вершину t, но оно не дает никакой информации о том, существует ли ребро, ведущее из t в s. Любые две вершины орграфа могут быть связаны одним из четырех видов отношений: ребер между ними нет; имеется ребро s-t, ведущее из s в t; имеется ребро t-s, ведущее из t в s; и имеются два ребра s-t и t-s, которые означают связь в обоих направлениях. Требование однонаправленности естественно для многих приложений, оно легко реализуется и на вид вполне безобидно. Однако оно означает неявное наличие дополнительной комбинаторной структуры, которая существенно влияет на наши алгоритмы и делает работу с орграфами весьма непохожей на работу с неориентированными графами. Обработка орграфов похожа на езду по городу, в котором все улицы имеют одностороннее движение, а направление движения не задается каким-то общим принципом. Можно себе представить, с какими трудностями можно столкнуться в таком городе, если понадобится проехать из одной его точки в другую.



Рис. 19.1. 

Ориентированный граф (орграф)

Орграф определяется списком узлов и ребер (внизу), где каждое ребро направлено из первой вершины во вторую. На чертежах орграфа для изображения ориенти-рованныхребер используются стрелки (вверху).

Направления ребер в графе можно интерпретировать различными способами. Например, в графе телефонных вызовов ребро может быть направлено от вызывающего абонента к вызываемому. В бухгалтерских транзакциях ребро может представлять собой денежные суммы, товары или информацию, передаваемые из одного места в другое.

Более современный пример для этой классической модели — интернет, где вершины представляют веб-страницы, а ребра — ссылки между страницами. В разделе 19.4 мы рассмотрим другие примеры для более абстрактных ситуаций.

Довольно часто направление ребер выражает отношение предшествования. Например, орграф может моделировать производственную линию: вершины обозначают этапы выполняемой работы, а ребро из вершины s в вершину t означает, что этап, соответствующий вершине s, должен быть выполнен до выполнения этапа, соответствующего вершине t. Другой способ моделирования той же ситуации заключается в использовании диаграммы PERT (Project Evaluation and Review Technique — сетевое планирование и управление): здесь уже ребра представляют этапы работы, а вершины неявно задают отношение предшествования (все работы, сходящиеся в конкретной вершине, должны быть завершены до начала работ, исходящих из этой вершины). Как узнать, когда нужно приступать к выполнению каждого из этих этапов, чтобы не нарушить ни одно из отношений предшествования? Это так называемая задача составления расписания (scheduling problem). Она не имеет смысла, если в орграфе имеется цикл, и в таких ситуациях мы работаем с DAG-графами (directed acyclic graph — ориентированный ациклический граф). В разделах 19.5—19.7 мы рассмотрим основные свойства DAG-графов и алгоритмы решения простой задачи составления расписания — топологической сортировки (topological sorting). На практике в задачах составления расписаний обычно учитываются веса вершин или ребер, которые моделируют время или стоимость выполнения каждого этапа. Такие задачи будут изучаться в лекция №21 и лекция №22.

Число возможных орграфов поистине огромно. Каждое из возможных V2 ориентированных ребер (считая и петли) может присутствовать или отсутствовать — таким образом, общее количество разных орграфов равно . С увеличением количества вершин это число стремительно возрастает (см. рис. 19.2) уже при небольших значениях V, даже по сравнению с количеством различных неориентированных графов. Как и в случае неориентированных графов, количество изоморфных друг другу графов (вершины одного из изоморфных графов можно переименовать таким образом, чтобы получить другой) намного меньше, однако воспользоваться этим сокращением невозможно, т.к. нам неизвестен эффективный алгоритм выявления изоморфизма графов.

 Подсчет количества графов


Рис. 19.2.  Подсчет количества графов

Количество различных неориентированных графов с Vвершинами огромно, даже при малых значениях V, но количество различных орграфов с Vвершинами гораздо больше. Количество неориентированных определяется формулой 2<sup>V(V+1)/2</sup>, а количество орграфов — формулой 2v2.

Разумеется, любая программа обрабатывает лишь мизерную часть возможных орграфов; эти числа так велики, что практически наверняка ни один конкретный орграф не окажется в числе обработанных любой заданной программой. В общем случае трудно как-то охарактеризовать орграфы, с которыми приходится сталкиваться на практике, поэтому приходится разрабатывать алгоритмы так, чтобы они могли справиться с любым заданным орграфом. С одной стороны, мы уже сталкивались с подобными ситуациями (например, ни одна программа сортировки не обрабатывала все 1000! перестановок 1000 элементов). С другой стороны, совсем не воодушевляет тот факт, что, например, даже если все электроны вселенной были бы суперкомпьютерами, способными обрабатывать 1010 графов за секунду в течение всей жизни Вселенной, то эти суперкомпьютеры не просмотрят и 10-100 процента орграфов, состоящих из 10 вершин (см. упражнение 19.9).

Это небольшое отступление про подсчет графов выделяет несколько моментов, которым мы уделяем особое внимание, когда проводим анализ алгоритмов, и показывает их непосредственное отношение к изучению орграфов. Стоит ли разрабатывать алгоритмы так, чтобы они хорошо работали в худшем случае, если встретить такой орграф практически невозможно? Есть ли смысл выбирать алгоритмы на основе анализа работы в среднем, или все это чисто математические выкрутасы? Если нам нужно получить программную реализацию, которая эффективно работает на реально встречающихся орграфах, то тогда возникает проблема описания таких орграфов. Разработка математических моделей, достаточно точно описывающих реальные орграфы, является еще более трудным делом, чем для неориентированных графов.

В этой главе мы еще раз рассмотрим подмножество фундаментальных задач обработки графов, которые были исследованы в лекция №17, но уже в контексте орграфов, а также несколько задач, характерных только для орграфов. А именно, мы рассмотрим поиск в глубину и его применения: обнаружение циклов (является ли данный граф DAG-графом), задача топологической сортировки (например, для решения задачи составления расписаний для DAG-графов), а также вычисление транзитивных замыканий и сильных компонентов (которые решают фундаментальную задачу поиска ориентированного пути между двумя заданными вершинами). Как и в других областях обработки графов, сложность этих алгоритмов может быть любой — от тривиальных до исключительно хитроумных. Они основываются на сложных комбинаторных структурах орграфов и одновременно помогают изучать эти структуры.

Упражнения

19.1. Найдите в интернете пример крупного орграфа — возможно, графа транзакций в какой-то онлайновой системе или орграфа, определяемого ссылками веб-страниц.

19.2. Найдите в интернете пример крупного DAG-графа — возможно, графа определения функций в крупной программной системе или ссылок в каталоге крупной файловой системы.

19.3. Постройте таблицу, аналогичную представленной на рис. 19.2, но без учета графов и орграфов с петлями.

19.4. Каково количество орграфов, содержащих V вершин и E ребер?

19.5. Сколько орграфов соответствует каждому неориентированному графу, содержащему V вершин и E ребер?

19.6. Сколько цифр содержит десятичное число, равное количеству орграфов с V вершинами и E ребрами?

19.7. Начертите неизоморфные орграфы, содержащие три вершины.

19.8. Каково количество различных орграфов, содержащих V вершин и E ребер, если считать орграфы различными только если они не изоморфны.

19.9. Вычислите верхнюю границу процентного отношения орграфов, содержащих 10 вершин, которые могут быть просмотрены каким-либо компьютером в условиях, описанных в тексте, и с учетом того, что во вселенной имеется менее 1080 электронов, а возраст Вселенной не превышает 1020 лет.

Глоссарий и правила игры

Наши определения орграфов (как и некоторые используемые нами алгоритмы и программы) почти идентичны определениям неориентированных графов из лекция №17, однако мы все же приведем их полностью. Небольшие различия в формулировках, касающиеся направлений ребер, влекут появление структурных свойств, которые и будут исследоваться в данной главе.

Определение 19.1. Ориентированный граф (или орграф) — это множество вершин плюс множество ориентированных ребер, которые соединяют упорядоченные пары вершин (при отсутствии одинаковых ребер). Мы говорим, что ребро направлено из первой вершины во вторую вершину.

Как и в случае неориентированных графов, в этом определении исключается наличие одинаковых ребер, но мы будем допускать их в различных приложениях и реализациях, если так будет удобнее. Мы явно разрешаем присутствие петель в орграфах (и обычно допускаем, что такая петля имеется у каждой вершины), поскольку они играют важную роль в фундаментальных алгоритмах.

Определение 19.2. Ориентированный путь (directedpath) в орграфе — это список вершин, в котором имеется (ориентированное) ребро орграфа, соединяющее каждую вершину списка со следующим элементом этого списка. Мы говорим, что вершина t достижима (reachable) из вершины s, если существует ориентированный путь из s в t.

Мы считаем, что каждая вершина достижима сама из себя, и обычно реализуем это свойство, включая в представление орграфов петли.

Для понимания многих алгоритмов, описываемых в данной главе, требуется понимание свойств связности орграфов и влияния этих свойств на базовый процесс перемещения от одной вершины к другой по ребрам графа. Достичь такого понимания для орграфов сложнее, чем для неориентированных графов. Например, иногда с первого взгляда видно, является ли небольшой неориентированный граф связным или содержит ли он циклы, однако в орграфах эти свойства не так очевидны, что демонстрирует типичный пример на рис. 19.3.

Конечно, подобные примеры подчеркивают различия, однако важно помнить, что задача, трудная для человека, может быть, а может и не быть трудной для программы — к примеру, написание класса DFS, предназначенного для поиска циклов в орграфе, не труднее задачи поиска циклов в неориентированном графе. И все же орграфы и неориентированные графы во многом существенно различаются. Например, то, что в каком-то орграфе вершина t достижима из вершины s, ничего не говорит о том, достижима ли s из t. Это различие очевидно, но, как мы увидим, весьма существенно.

Как было сказано в лекция №17, представления, используемые для орграфов, по существу те же, что и представления, используемые для неориентированных графов. Вообще-то они даже более наглядны, поскольку каждое ребро в них представлено только один раз — см. рис. 19.4. В представлении списками смежности ребро s-t представляется узлом, содержащим t в связном списке, который соответствует вершине s.

 Решетчатый орграф


Рис. 19.3.  Решетчатый орграф

Этот небольшой орграф похож на крупную решетчатую сеть, которая была рассмотрена в лекция №1 — только теперь на каждой линии сетки имеется ориентированное ребро случайно выбранного направления. Даже для такого графа с небольшим количеством вершин его свойства связности совсем не очевидны. Существует ли ориентированный путь из верхнего левого угла в нижний правый?

В представлении матрицей смежности приходится использовать полную матрицу размером V х V, и ребро представляется в ней единицей на пересечении строки s и столбца t. Единицы на пересечении строки t и столбца s нет, если в графе нет ребра t-s. В общем случае матрица смежности для орграфа не симметрична относительно главной диагонали.

В этих представлениях нет различий между неориентированными графами и ориентированными графами с петлей в каждой вершине и двумя ориентированными ребрами для каждого ребра, соединяющего разные вершины неориентированного графа (по одному в каждом направлении). Поэтому алгоритмы, которые мы разработаем в этой главе для орграфов, можно использовать и для обработки неориентированных графов — естественно, при соответствующей интерпретации результатов. Кроме того, в качестве основы для программ обработки орграфов мы будем использовать некоторые программы из лекция №17: программы 17.7—17.10, реализующие классы DenseGRAPH и SparseMultiGRAPH, строят орграфы, если конструктору передается во втором аргументе значение true.

 Представления орграфа


Рис. 19.4.  Представления орграфа

В представлениях орграфа матрицей смежности и списками смежности каждое ребро фигурирует только один раз, как видно из представления орграфа, показанного на рис. 19.1, в виде матрицы смежности (сверху) и в виде списков смежности (внизу). Оба эти представления содержат в каждой вершине петли, которые обычно используются при обработке орграфов.

Степень захода (indegree) вершины орграфа есть количество ориентированных ребер, которые ведут в эту вершину. Степень выхода (outdegree) вершины орграфа есть количество ориентированных ребер, которые ведут из этой вершины. Никакая вершина орграфа не достижима из вершины со степенью выхода 0 — такая вершина называется сток (sink); вершина со степенью захода 0 называется исток (source), она не достижима ни из какой вершины орграфа. Орграф, в котором разрешены петли, и степень выхода каждой вершины равна 1, называется отображением (map) (функция из множества целых чисел от 0 доV— 1 на это же множество). Используя векторы, индексированные именами вершин, можно подсчитать степени захода и выхода для каждой вершины и найти истоки и стоки, потратив на это линейное время и объем памяти, пропорциональный V (см. упражнение 19.19).

Обращение (reverse) орграфа — это орграф, который получается при изменении направлений всех ребер орграфа на обратные. На рис. 19.5 показано обращение орграфа с рис. 19.1 и его представления. Обращения орграфов используются в алгоритмах тогда, когда нужно знать, откуда исходят ребра, поскольку стандартные представления показывают нам только куда они идут. При обращении орграфа степени выхода и захода меняются местами.

В случае представления матрицей смежности обращение можно вычислить, создав копию матрицы и транспонировав ее (поменяв местами строки и столбцы).

 Обращение орграфа


Рис. 19.5.  Обращение орграфа

Изменение направлений ребер орграфа на обратные соответствует транспонированию матрицы смежности, но требует перестроения списков смежности (см. рис. 19.1 и рис. 19.4).

Если известно, что граф в дальнейшем не будет изменяться, то можно использовать его обращение без каких-либо дополнительных вычислений — просто переставляя вершины в обращениях к ребрам при работе с обращенным графом. Например, ребро s-t орграфа G задано значением 1 в элементе adj[s][t]. Значит, если бы мы вычислили обращение R орграфа G, оно содержало бы 1 в элементе adj[t][s]. Но ведь это можно и не делать: если в реализации клиента выполняется проверка ребра edge(s,t), то для работы с обращением достаточно заменить каждую такую ссылку на edge(t,s). Несмотря на очевидность, этой возможностью часто пренебрегают. Однако в случае представления списками смежности обращение представляет собой совершенно другую структуру данных, и, как видно из программы 19.1, для ее построения нужно время, пропорциональное количеству ребер орграфа.

Еще один способ, который будет применяться в лекция №22 — использование двух представлений каждого ребра, как в случае неориентированных графов (см. лекция №17), но с дополнительным битом, указывающим направление ребра. Например, в представлении орграфа списками смежности ребро s-t будет представлено узлом t в списке смежности вершины s (бит направления указывает прямой проход по ребру из s в t) и узлом s в списке смежности вершины t (бит направления указывает обратный проход по ребру из t в s). Такое представление поддерживает алгоритмы, где необходимы проходы по ребрам орграфа в обоих направлениях. В таких случаях обычно удобно добавлять указатели, соединяющие оба представления ребра. Мы отложим подробное исследование такого представления до лекция №22, где оно играет существенную роль.

Программа 19.1. Обращение орграфа

Данная функция добавляет ребра орграфа, переданного в первом аргументе, в орграф, указанный во втором аргументе. Она использует два спецификатора шаблона, поэтому графы могут иметь различные представления.

  template <class inGraph, class outGraph>
  void reverse(const inGraph &G, outGraph &R)
    { for (int v = 0; v < G.V(); v++)
        { typename inGraph::adjIterator A(G, v);
          for (int w = A. beg() ; !A.end() ;
            w = A.nxt())
          R.insert(Edge(w, v));
        }
    }
      

В орграфах, по аналогии с неориентированными графами, мы говорим об ориентированных циклах (ориентированные пути, ведущие от вершины к ней самой) и о простых ориентированных путях и циклах (в которых все вершины и ребра различны). Обратите внимание, что в орграфе возможен цикл s-t-s длиной 2, но циклы в неориентированных графах должны содержать минимум три различных вершины. Во многих приложениях орграфы не должны содержать циклы, и мы имеем дело с другим видом комбинаторного объекта.

Определение 19.3. Ориентированный ациклический граф (directed acyclic graph, DAG) — это орграф, не содержащий направленных циклов.

DAG-графы могут встретиться, например, в приложениях, где орграфы моделируют отношения предшествования. DAG-графы естественно возникают не только в этих и других важных приложениях, но и при изучении структур орграфов общего вида. Пример DAG-графа показан на рис. 19.6.

 Ориентированный ациклический граф (DAG-граф)


Рис. 19.6.  Ориентированный ациклический граф (DAG-граф)

В этом графе нет циклов, хотя это не очевидно ни из списка ребер, ни даже из чертежа графа.

Направленные циклы крайне важны для понимания связности орграфов, не являющихся DAG-графами. Неориентированный граф связен, если существует путь из каждой его вершины в любую другую вершину; но для орграфов используется несколько другое определение:

Определение 19.4. Орграф называется сильно связным (strongly connected), если каждая его вершина достижима из любой другой вершины (рис. 19.7).

 Терминология орграфов


Рис. 19.7.  Терминология орграфов

На подобных чертежах орграфов легко обнаружить истоки (вершины, в которые не ведет ни одно ребро) и стоки (вершины, из которых не выходит ни одно ребро), однако направленные циклы и сильно связные компоненты обнаружить намного труднее. Где самый длинный направленный цикл в данном орграфе?Сколько в нем сильно связных компонентов с более чем одной вершиной?

Граф, изображенный на рис. 19.1, не является сильно связным, поскольку в нем нет, например, ориентированных путей из вершины 9 через вершину 12 к любым другим вершинам графа.

Определение сильно означает, что каждая пара вершин связана более сильным отношением, чем достижимость. Мы говорим, что вершины s и t любого графа является сильно связанными (strongly connected), или взаимно достижимыми (mutually reachable), если существует ориентированный путь из s в t и ориентированный путь из t в s. (Из нашего соглашения, что каждая вершина достижима из самой себя, следует, что каждая вершина сильно связана сама с собой.) Орграф сильно связен тогда и только тогда, когда сильно связаны все его пары вершин. То есть сильно связные орграфы определяет свойство, само собой разумеющееся в случае связных неориентированных графов: если существует путь из s в t, то существует и путь из t в s.

В случае неориентированных графов это свойство выполняется автоматически: один и тот же путь, пройденный в обратном направлении, отвечает всем требованиям определения, но в случае орграфа это уже будет другой путь.

О сильной связности пары вершин можно сказать и по-другому: они принадлежат некоторому ориентированному циклическому пути. Напомним, что мы используем термин циклический путь (cyclic path), а не просто цикл (cycle), чтобы показать, что путь не обязательно должен быть простым. Например, на рис. 19.1 вершины 5 и 6 сильно связаны, поскольку вершина 6 достижима из вершины 5 по ориентированному пути 5-4-2-0-6, а 5 достижима из 6 по ориентированному пути 6-4-3-5. Из существования этих путей следует, что вершины 5 и 6 принадлежат ориентированному циклическому пути 5-4-2-0-6-4-3-5, но не принадлежат никакому (простому) ориентированному циклу. Понятно, что ни один DAG-граф, содержащий более одной вершины, не может быть сильно связным.

Как и простая связность в неориентированных графах, это отношение транзитивно: если s сильно связана с t, а t сильно связана с u, то s сильно связана с u. Сильная связность является отношением эквивалентности, разбивающим вершины графа на классы эквивалентности, в каждом из которых вершины сильно связаны друг с другом. (Отношения эквивалентности подробно рассматриваются в разделе 19.4). Опять-таки, сильная связность в орграфах — свойство, само собой разумеющееся при наличии связности в неориентированных графах.

Лемма 19.1. Орграф, не являющийся сильно связным, состоит из множества сильно связных компонентов (strongly connected component) (или, для краткости, сильных компонентов — strong component), которые представляют собой максимальные сильно связные подграфы, и множества ориентированных ребер, ведущих из одного компонента в другой.

Доказательство. Подобно компонентам неориентированных графов, сильные компоненты в орграфах являются подграфами, которые индуцированы подмножествами вершин: каждая вершина — это в точности один сильный компонент. Чтобы доказать этот факт, сначала заметим, что каждая вершина принадлежит как минимум одному сильному компоненту, который содержит (хотя бы) саму вершину. Далее отметим, что каждая вершина принадлежит максимум одному сильному компоненту: если бы какая-либо вершина принадлежала сразу двум различным компонентам, то существовал бы путь, проходящий через эту вершину и соединяющий вершины этих компонентов друг с другом в обоих направлениях, что противоречит предположению о максимальности обоих компонентов.

Например, орграф, который состоит из единого направленного цикла, содержит в точности один сильный компонент. Но ведь и каждая вершина в DAG-графе — это сильный компонент, поэтому каждое ребро в DAG-графе ведет из одного компонента в другой. В общем случае не все ребра орграфа принадлежат сильным компонентам. Это отличается от ситуации для связных компонентов в неориентированных графах — в них каждая вершина, а также каждое ребро принадлежит некоторому связному компоненту — но аналогично ситуации для реберно-связных компонентов в неориентированных графах. Сильные компоненты в орграфах соединены между собой ребрами, которые ведут из вершины одного компонента в вершину другого без обратных ребер.

Лемма 19.2. Если для заданного орграфа D определить другой орграф K(D), в котором каждая вершина соответствует одному из сильных компонентов орграфа D, а каждое ребро соответствует одному из ребер орграфа D, которое соединяет вершины различных сильных компонентов (соединяет вершины в K, соответствующие сильным компонентам, которые он соединяет в D), то K(D) будет DAG-графом (который мы будем называть коренным DAG-графом (kernel DAG), или ядерныым DAG, графа D).

Доказательство. Если бы K(D) содержал направленный цикл, то вершины двух различных сильных компонентов орграфа D принадлежали бы одному направленному циклу, а это противоречит условию.

На рис.19.8 показаны сильные компоненты и коренной DAG-граф для некоторого орграфа. Мы рассмотрим алгоритмы поиска сильных компонентов и построения коренных DAG-графов в разделе 19.6.

 Сильные компоненты и коренной DAG


Рис. 19.8.  Сильные компоненты и коренной DAG

Рассматриваемый орграф (вверху) состоит из четырех сильных компонентов, которые определяются массивом id (в центре), индексированным именами вершин (в качестве индексов взяты произвольные целые числа). Компонент 0 содержит вершины 9, 10, 11 и 12; компонент 1 состоит из единственной вершины 1; компонент 2 содержит вершины 0 , 2 , 3, 4, 5 и 6, а компонент 3 состоит из вершин 7 и 8. Если начертить граф, определяемый ребрами между различными компонентами, то получится DAG-граф (внизу).

Из этих определений, лемм и примеров ясно, что нужно быть предельно точным при использовании путей в орграфе. Необходимо рассмотреть, по меньшей мере, три следующих ситуации:

Связность. Термин связный мы оставим для неориентированных графов. В случае орграфов можно говорить, что две вершины связаны, если они связаны в неориентированном графе, полученном при игнорировании направления ребер, но мы постараемся избегать такой трактовки.

Достижимость. Мы говорим, что вершина t орграфа достижима из вершины s, если существует ориентированный путь из s в t. При работе с неориентированными графами мы постараемся не употреблять термин достижимый (reachable), хотя его можно считать эквивалентным понятию связный, поскольку идея достижимости одной вершины из другой интуитивно понятна в некоторых неориентированных графах (в частности, в графах, представляющих лабиринты).

Сильная связность. Две вершины орграфа сильно связаны, если они взаимно достижимы; в неориентированных графах из связности двух вершин следует существование путей из одной вершины в другую. Сильная связность в орграфах в некоторых отношениях подобна реберной связности в неориентированных графах.

Нам потребуется поддержка операций АТД орграфа, которые принимают в качестве аргументов две вершины s и t и позволяют проверить

Какие ресурсы мы готовы выделить для выполнения этих операций? Как было показано в разделе 17.5 лекция №17, простое решение задачи связности в неориентированных графах обеспечивалось поиском в глубину. При этом требовалось время, пропорциональное V, но при готовности выполнить предварительную обработку за время, пропорциональное V + E, и выделить объем памяти, пропорциональный V, можно отвечать на запросы о связности графа за постоянное время. Ниже в этой главе мы изучим алгоритмы выявления сильной связности, которые имеют такие же характеристики производительности.

Однако главная трудность в том, что ответы на запросы о достижимости в орграфах получить намного сложнее, чем на запросы о связности или сильной связности. В этой главе мы изучим классические алгоритмы, для выполнения которых требуется время, пропорциональное VE, и объем памяти, пропорциональный V2, разработаем реализации с постоянным временем и линейным расходом памяти и времени на предварительную обработку при запросах о достижимости на орграфах определенного типа, и рассмотрим трудности достижения этой оптимальной производительности для всех орграфов.

Упражнения

19.10. Приведите структуру списков смежности, построенных программой 17.9 для орграфа

3-71-47-80-55-23-82-90-64-92-66-4.

19.11. Напишите программу, генерирующую случайные разреженные орграфы для указанного набора значений Vи E, чтобы использовать их для адекватных эмпирических тестов на орграфах, построенных на основе модели со случайными ребрами.

19.12. Напишите программу, генерирующую случайные разреженные орграфы для указанного набора значений V и E, чтобы использовать их для адекватных эмпирических тестов на графах, построенных на основе модели со случайными ребрами.

19.13. Напишите программу, которая генерирует орграфы, соединяя вершины, расположенные на решетке , с соседними вершинами случайно направленными ребрами (см. рис. 19.3).

19.14. Добавьте в программу из упражнения 19.13 возможность добавления R дополнительных случайных ребер (все возможные ребра выбираются с равной вероятностью). Для больших значений R сожмите решетку так, чтобы общее количество ребер оставалось примерно равным V. Протестируйте полученную программу, как описано в упражнении 19.11.

19.15. Измените программу из упражнения 19.14, чтобы каждое дополнительное ребро из вершины s в вершину t появлялось с вероятностью, обратно пропорциональной евклидову расстоянию между s и t.

19.16. Напишите программу, которая генерирует в единичном интервале V случайных интервалов длиной d каждый, после чего строит орграф с ребром из интервала s в интервал t тогда и только тогда, когда хотя бы одна из граничных точек s попадает в t (см. упражнение 17.75). Определите, как выбрать d, чтобы ожидаемое количество ребер было равно E. Протестируйте свою программу, как описано в упражнении 19.11 (для разреженных орграфов) или в упражнении 19.12 (для насыщенных орграфов).

19.17. Напишите программу, которая выбирает V вершин и E ребер из реального орграфа, найденного в упражнении 19.1. Протестируйте свою программу, как описано в упражнении 19.11 (для разреженных орграфов) или 19.12 (для насыщенных орграфов).

19.18. Напишите программу, которая с одинаковой вероятностью строит любой из возможных орграфов с V вершинами и с E ребрами (см. упражнение 17.70). Протестируйте свою программу, как описано в упражнении 19.11 (для разреженных орграфов) или 19.12 (для насыщенных орграфов).

19.19. Реализуйте класс, который предоставляет клиентам возможность определять степени захода и выхода любой заданной вершины орграфа за постоянное время после предварительной обработки в конструкторе за линейное время. Затем добавьте функции-члены, которые возвращают за постоянное время количество истоков и стоков.

19.20. Воспользуйтесь программой из упражнения 19.19, чтобы найти среднее количество истоков и стоков в различных видах орграфов (см. упражнения 19.11—19.18).

19.21. Покажите структуру списков смежности, которая получится при построении обращения орграфа

3-71-47-80-55-23-82-90-64-92-66-4

с помощью программы 19.1.

19.22. Опишите обращение отображения.

19.23. Разработайте класс орграфа, который предоставляет клиентам возможность обращаться как к орграфам, так и к их обращениям, и напишите реализацию для любого представления, которое поддерживает запросы к функции edge.

19.24. Напишите альтернативную реализацию класса из упражнения 19.23, которая поддерживает обе ориентации ребер в списках смежности.

19.25. Опишите семейство сильно связных орграфов с V вершинами без (простых) направленных циклов длиной больше 2.

19.26. Приведите сильные компоненты и коренной DAG для орграфа

3-71-47-80-55-23-82-90-64-92-66-4.

19.27. Приведите коренной DAG решетчатого орграфа с рис. 19.3.

19.28. Сколько графов имеют V вершин, каждая из которых имеет степень выхода, равную k?

19.29. Каково ожидаемое количество различных представлений случайного орграфа списками смежности? Указание. Разделите общее количество возможных представлений на общее количество орграфов.

Анатомия DFS в орграфах

Код DFS для неориентированных графов из лекция №18 можно использовать для посещения каждого ребра и каждой вершины орграфа. Основной принцип этого рекурсивного алгоритма остается прежним: чтобы посетить каждую вершину, достижимую из данной вершины, мы помечаем эту вершину как посещенную, а затем (рекурсивно) посещаем все вершины, в которые можно попасть из каждой вершины, находящейся в ее списке смежности.

В неориентированных графах имеются два представления каждого ребра, но второе представление, которое встречается при поиске в глубину, всегда приводит в помеченную вершину и поэтому игнорируется (см. лекция №18). В орграфе имеется только одно представление каждого ребра, поэтому можно было бы надеяться на упрощение алгоритмов DFS в орграфах. Однако орграфы сами являются более сложными комбинаторными объектами, так что эти надежды не оправдываются. Например, деревья поиска, которые мы используем для понимания функционирования алгоритма, имеют в случае орграфов более сложную структуру, чем для неориентированных графов. И это усложняет разработку алгоритмов для работы с орграфами. Как мы вскоре убедимся, сделать какие-то выводы об ориентированных путях в орграфах гораздо труднее, чем о путях в неориентированных графах.

Как и в лекция №18, мы воспользуемся двумя терминами. Термин стандартный поиск в глубину по спискам смежности (standard adjacency-lists DFS) будет обозначать процесс вставки последовательности ребер в АТД орграфа, представленного списками смежности (второй аргумент конструктора из программы 17.9 имеет значение true), с последующим выполнением поиска в глубину — например, с помощью программы 18.3. Параллельный термин стандартный поиск в глубину по матрице смежности (standard adjacency-matrix DFS) будет обозначать процесс вставки последовательности ребер в АТД орграфа, представленного матрицей смежности (второй аргумент конструктора из программы 17.7 имеет значение true), с последующим выполнением поиска в глубину — например, с помощью той же программы 18.3.

К примеру, на рис. 19.9 показано дерево рекурсивных вызовов, которое описывает выполнение алгоритма стандартного поиска в глубину по спискам смежности для орграфа с рис. 19.1. Как и в случае неориентированных графов, подобные деревья содержат внутренние узлы, которые соответствуют вызовам рекурсивной функции DFS для каждой вершины, при этом ссылки на внешние узлы соответствуют ребрам, которые ведут в уже просмотренные вершины. Классификация узлов и связей дает информацию о поиске (и о самом орграфе), однако такая классификация для орграфов существенно отличается от классификации для неориентированных графов.

 Лес DFS для орграфов


Рис. 19.9.  Лес DFS для орграфов

Данный лес описывает стандартный поиск в глубину по спискам смежности орграфа с рис. 19.1. Внешние узлы представляют уже посещенные внутренние узлы с теми же метками; во всем остальном этот лес является представлением орграфа, где все ребра направлены вниз. Существуют четыре типа ребер: (1) древесные ребра, ведущие во внутренние узлы; (2) обратные ребра, ведущие во внешние узлы, которые представляют предшественников (серые кружки); (3) нисходящие ребра, ведущие во внешние узлы, которые представляют потомков (серые квадратики); и (4) поперечные ребра, ведущие во внешние узлы, не являющиеся ни предшественниками, ни потомками (светлые квадратики). Тип ребер, ведущих в посещенные узлы, можно определить, сравнивая прямые и обратные номера их начальных и конечных узлов (внизу):

ПрямойОбратныйПримерТип ребра
<> 4-2 Нисходящее
>< 2-0 Обратное
>> 7-6 Поперечное

Например, ребро 7-6 является поперечным, поскольку и прямой, и обратный номера узла 7 больше, чем аналогичные номера узла 6 .

В неориентированных графах каждая ссылка в дереве DFS относится к одному из четырех классов. Это зависит от того, соответствует ли она ребру графа, которое ведет к рекурсивному вызову, и соответствует ли она первому или второму представлению ребра, встреченному алгоритмом поиска.

В орграфах имеется взаимно однозначное соответствие между ссылками дерева и ребрами графа, которые попадают в один из четырех различных классов:

Древесные ребра ведут в непосещенные вершины и соответствуют рекурсивному вызову при поиске в глубину. Обратные, поперечные и нисходящие ребра ведут в посещенные вершины. Чтобы определить тип заданного ребра, мы используем прямую и обратную нумерацию (очередность посещения узлов, соответственно, при прямом и обратном обходе леса).

Лемма 19.3. В лесе DFS, соответствующем орграфу, ребро, которое ведет в посещенную вершину, является обратным ребром, если оно ведет в узел с большим обратным номером; либо поперечным ребром, если оно ведет в узел с меньшим прямым номером; либо нисходящим ребром, если оно ведет в узел с большим прямым номером.

Доказательство. Все эти утверждения следуют непосредственно из определений. Узлы-предки в дереве DFS имеют меньшие прямые номера и большие обратные; узлы-потомки имеют большие прямые номера и меньшие обратные. Верно также и то, что оба эти номера меньше в ранее посещенных узлах других деревьев DFS, и оба эти номера больше в тех узлах, которые предстоит посетить в других деревьях.

Программа 19.2 представляет собой класс DFS, который определяет вид каждого ребра орграфа, а на рис. 19.10 приведен пример ее работы для орграфа с рис. 19.1. Во время поиска проверка, ведет ли какое-либо ребро в узел с большим обратным номером, эквивалентна проверке, присвоен ли уже обратный номер. Любой узел, которому уже присвоен прямой номер, но еще не присвоен обратный номер, является предком в дереве DFS и поэтому его обратный номер больше обратного номера текущего узла.

 Трассировка орграфа поиска в глубину


Рис. 19.10.  Трассировка орграфа поиска в глубину

Здесь приведены выходные результаты программы 19.2 для орграфа с рис. 19.1. Они в точности соответствуют прямому обходу дерева DFS, показанному на рис. 19.9.

Как было сказано в лекция №17 для неориентированных графов, виды ребер зависят скорее от динамики поиска, чем от самого графа. Различные леса DFS для одного и того же графа могут существенно различаться по характеру (см. рис. 19.11). Даже количество деревьев леса DFS может зависеть от начальной вершины.

Однако несмотря на все эти различия, некоторые классические алгоритмы обработки орграфов способны определить свойства орграфов, выполняя соответствующие действия при встрече различных видов ребер во время поиска в глубину.

 Лес DFS на орграфе


Рис. 19.11.  Лес DFS на орграфе

Эти леса описывают поиск в глубину на том же графе, что и на рис. 19.9, где функция поиска на графе проверяет вершины (и вызывает рекурсивную функцию для непосещен-ных вершин) в порядке s, s + 1, ..., V-1, 0, 1, ... , s-1 для каждого s. Структура леса определяется как динамикой поиска, так и структурой самого графа. У каждого узла одни и те же дочерние узлы (узлы в его списке смежности в порядке их следования) в любом лесу. Крайнее левое дерево в каждом лесу содержит все узлы, достижимые из его корня, однако выводы о достижимости из других вершин усложняются из-за наличия обратных, поперечных и нисходящих ребер. Даже количество деревьев в лесе зависит от выбора начальной вершины, поэтому нет прямого соответствия между деревьями в лесе и сильными компонентами, как в неориентированных графах. Например, в данном случае все вершины достижимы из вершины 8 , только если начать поиск из вершины 8.

Программа 19.2. DFS на орграфе

Данный класс DFS использует прямую и обратную нумерацию, чтобы показать роль каждого ребра графа в поиске в глубину (см. рис. 19.10).

  template <class Graph>
  class DFS
    { const Graph &G;
      int depth, cnt, cntP;
      vector<int> pre, post;
      void show(char *s, Edge e)
        { for (int i = 0; i < depth; i++) 
        cout << " ";
          cout << e.v << "-" << e.w << s << endl;
        }
      void dfsR(Edge e)
        { int w = e.w; show(" древесное", e);
          pre[w] = cnt++; depth++;
          typename Graph::adjIterator A(G, w);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            { Edge x( w, t) ;
              if (pre[t] == -1) dfsR(x);
              else if (post[t] == -1) show(" обратное", x);
              else if (pre[t] > pre[w]) show(" нисходящее", x);
              else show(" поперечное", x);
            }
          post[w] = cntP++; depth--;
        }
    public:
      DFS(const Graph &G) : G(G), cnt(0), cntP(0),
        pre(G.V(), -1), post(G.V(), -1)
        { for (int v = 0; v < G.V(); v++)
            if (pre[v] == -1) dfsR(Edge(v, v));
        }
    };
      

Например, рассмотрим следующую фундаментальную задачу.

Обнаружение направленного цикла. Содержит ли заданный орграф направленные циклы? (Является ли он DAG-графом?) В неориентированных графах любое ребро, ведущее в посещенную вершину, означает наличие цикла; но в орграфах следует обращать внимание только на обратные ребра.

Лемма 19.4. Орграф является DAG-графом тогда и только тогда, когда проверка всех ребер с помощью поиска в глубину не обнаруживает обратные ребра.

Доказательство. Любое обратное ребро принадлежит некоторому направленному циклу, который состоит из этого ребра и еще пути в дереве, соединяющего концы ребра — поэтому поиск в глубину на DAG не встретит обратных ребер. Чтобы доказать обратное утверждение, мы покажем, что если в орграфе имеется цикл, то поиск в глубину встретит обратное ребро. Допустим, v — первая из вершин цикла, которую посещает DFS. Эта вершина имеет наименьший прямой номер из всех вершин цикла. Поэтому указывающее на нее ребро будет обратным ребром: оно встретится при рекурсивном вызове для вершины v (доказательство этого см. в лемме 19.5), и оно указывает из одного из узлов цикла на v, т.е. на узел с меньшим прямым номером (см. лемму 19.3).

Любой орграф можно преобразовать в DAG, выполнив поиск в глубину и удалив все ребра графа, которые соответствуют обратным ребрам в DFS. Например, из рис. 19.9 рис. 19.9 видно, что удаление ребер 2-0, 3-5, 2-3, 9-11, 10-12, 4-2 и 7-8 преобразовывает орграф с рис 19.1 в DAG-граф. Конкретный вид полученного DAG-графа зависит от представления исходного графа и от динамических свойств поиска в глубину (см. упражнение 19.37). Этот метод представляет собой удобный способ генерации больших случайных DAG-графов (см. упражнение 19.76) для тестирования алгоритмов обработки DAG-графов.

Сравнение только что описанного решения с решением, приведенным в лекция №18 для неориентированных графов, дает основание трактовать эти два вида графов как различные комбинаторные объекты, даже если их представления подобны, а некоторые программы работают для обоих видов. Судя по нашим определениям, мы вроде бы используем для решения этой задачи тот же метод, что и для обнаружения циклов в неориентированных графах (поиск обратных ребер), однако наша реализация для неориентированных графов на орграфе работать не будет. Например, в лекция №18 мы внимательно подходили к различиям между родительскими ссылками и обратными ссылками, поскольку существование родительской ссылки не означает наличия цикла (циклы в неориентированных графах должны содержать не меньше трех вершин). Однако нельзя игнорировать обратные ссылки на родительские узлы в орграфах, ведь пары вершин со взаимными ссылками в орграфах считаются циклами. В принципе, можно было бы определить обратные ребра в неориентированных графах так же, как и здесь, однако тогда потребовалось бы явное исключение для случая с двумя вершинами. Хотя более важно то, что в неориентированных графах мы можем обнаруживать циклы за время, пропорциональное V (см. лекция №18), однако для обнаружения цикла в орграфе может понадобиться время, пропорциональное E (см. упражнение 19.32).

По существу, DFS — это систематический способ посещения всех вершин и всех ребер графа. То есть он дает фундаментальный подход к решению задачи достижимости в орграфах, хотя эта задача и сложнее, чем для неориентированных графов.

Достижимость из одного истока. До каких вершин заданного орграфа можно добраться из заданной начальной вершины s? Сколько имеется таких вершин?

Лемма 19.5. Рекурсивный поиск в глубину, начинающийся в вершине s, позволяет решить задачу достижимости из одного истока s за время, пропорциональное количеству ребер в подграфе, индуцированном достижимыми вершинами.

Доказательство. Это доказательство, по сути, повторяет доказательство леммы 18.1, однако мы еще раз подчеркиваем различие между достижимостью в орграфах и связностью в неориентированных графах. Очевидно, что данная лемма справедлива для орграфа, который состоит из одной вершины и не содержит ребер. Для любого орграфа, содержащего более одной вершины, предположим, что эта лемма справедлива для всех орграфов, состоящего из меньшего количества вершин. Первое ребро, которое мы выберем из вершины s, разбивает рассматриваемый орграф на два подграфа, индуцированных двумя подмножествами вершин (см. рис. 19.12): (1) вершины, достижимые по ориентированным путям, которые начинаются с этого ребра и далее не содержат s; и (2) вершины, которых невозможно достичь по какому-либо ориентированному пути, начинающемся с этого ребра, без возврата в s. Применим к этим подграфам индуктивное предположение, учитывая, что не существуют ориентированные ребра из каких-либо вершин первого подграфа в вершины второго подграфа, кроме s (наличие такого ребра противоречило бы построению, т.к. его конечная вершина должна находиться в первом подграфе); что направленные ребра, ведущие в s, будут проигнорированы, поскольку прямой номер этой вершины меньше, чем у любой вершины второго подграфа; и что прямые номера всех вершин первого подграфа меньше, чем у вершин второго подграфа. Поэтому все направленные ребра из вершин второго подграфа в первый подграф будут проигнорированы.

 Декомпозиция орграфа


Рис. 19.12.  Декомпозиция орграфа

Доказательство методом индукции того, что поиск в глубину приводит нас во все места орграфа, достижимые из заданного узла, по существу ничем не отличается от доказательства для метода Тремо. Здесь приведен пример в виде лабиринта (вверху), аналогичный рис. 18.4. Мы разбиваем граф на две меньшие части (внизу), индуцированные двумя множествами вершин: вершинами, которые могут быть достигнуты, если пройти по первому ребру из начальной вершины без ее дальнейшего посещения (нижняя часть), и вершинами, которые остаются недостижимыми, если пройти по первому ребру и не возвращаться в исходную вершину (верхняя часть). Любое ребро, исходящее из вершины первого множества в исходную вершину, пропускается во время поиска, т.к. исходная вершина уже помечена. Любое ребро, исходящее из вершины второго множества в какую-либо вершину первого множества, пропускается потому, что все вершины первого множества помечены еще до начала поиска во втором подграфе.

В отличие от неориентированных графов, DFS на орграфе не дает полной информации о достижимости из любой вершины, кроме исходной, поскольку ребра дерева являются ориентированными, а поисковые структуры содержат поперечные ребра. Когда мы уходим из какой-либо вершины вниз по дереву, мы не можем быть уверены в том, что существует обратный путь в эту вершину по ребрам орграфа; и в общем случае такого пути действительно нет. Например, после выбора древесного ребра дерева 4-11 уже невозможно вернуться в вершину 4. А при игнорировании поперечных и обратных ребер (поскольку они ведут в уже посещенные вершины) игнорируется и вся связанная с ними информация (множество вершин, достижимых из конечной вершины). Например, проход по ребру 6-9 на рис. 19.9 — единственный способ обнаружить, что вершины 10, 11 и 12 достижимы из вершины 6.

Чтобы определить вершины, достижимые из другой вершины, видимо, нужно выполнить новый поиск в глубину из этой вершины (см. рис. 19.11). Можно ли воспользоваться информацией из предыдущих поисков, чтобы повысить эффективность этого процесса? Мы рассмотрим подобные вопросы в разделе 19.7.

При определении связности в неориентированных графах используется тот факт, что вершины соединены со своими предками в дереве DFS посредством (по крайней мере) пути в этом дереве. Однако в орграфах все не так: ориентированный путь из вершины орграфа к ее предку существует только в том случае, если существует обратное ребро из какого-либо ее потомка к этому или еще более дальнему предку. Далее, связность в неориентированных графах для каждой вершины полностью описывается деревом DFS с корнем в этой вершине; а в орграфах поперечные ребра могут увести нас в любую уже посещенную часть поисковой структуры, даже в другое дерево леса DFS. В неориентированных графах мы могли воспользоваться этим свойством связности, чтобы ассоциировать каждую вершину с каким-либо связным компонентом за один проход DFS, а затем использовать эту информацию для определения за постоянное время, являются ли любые две вершины связными. В случае орграфа, как мы уже смогли убедиться в этой главе, все не так легко.

В этой и в предыдущей главах мы видели, что различные способы выбора непосе-щенных вершин приводят к различным динамикам DFS. В случае орграфов структурная сложность деревьев DFS приводит к различиям в динамике поиска, которые еще ярче выражены, чем в неориентированных графах. Например, на рис. 19.11 продемонстрированы существенные различия для орграфов даже просто при изменении порядка просмотра вершин высокоуровневыми функциями. На этом рисунке приведена лишь мизерная часть таких возможностей — ведь в принципе каждый из V! различных порядков просмотра вершин может приводить к различным результатам. В разделе 19.7 мы рассмотрим один важный алгоритм, в котором учитывается эта гибкость, и непосе-щенные вершины обрабатываются на верхнем уровне (корни деревьев DFS) в особом порядке, который сразу же выявляет сильные компоненты.

Упражнения

19.30. Начертите лес DFS, который получается при выполнении поиска в глубину на орграфе

3-71-47-80-55-23-82-90-64-92-66-4,представленно мспискамисмежно сти.

19.31. Начертите лес DFS, который получается при выполнении поиска в глубину на орграфе

3-71-47-80-55-23-82-90-64-92-66-4,представленно мматрицейсмежно сти.

19.32. Опишите семейство орграфов с Vвершинами и E ребрами, для которых стандартный поиск в глубину по спискам смежности находит циклы за время, пропорциональное E.

19.33. Покажите, что при работе DFS на орграфе никакое ребро не соединяет какой-либо узел с другим узлом, прямой и обратный номера которого меньше соответствующих номеров первого узла.

19.34. Покажите все возможные леса DFS для орграфа

0-10-20-31-32-3,

и сведите в таблицу количество древесных, обратных, поперечных и нисходящих ребер для каждого леса.

19.35. Если обозначить количества древесных, обратных, поперечных и нисходящих ребер, соответственно, через t, b, с и d, то для любого поиска в глубину на любом орграфе с V вершинами и E ребрами будут верны соотношения t + b + с + t = E и t < V. Какие другие соотношения между этими переменными можно обнаружить? Какие из этих значений зависят только от свойств графов, а какие — от динамических свойств DFS?

19.36. Докажите, что каждый исток в орграфе должен быть корнем некоторого дерева в лесе, соответствующем любому поиску в глубину на этом орграфе.

19.37. Постройте связный DAG-граф, удалив в графе, который изображен на рис. 19.1, пять ребер (см. р рис. 19.11).

19.38. Реализуйте класс орграфа, который предоставляет клиенту возможность проверять, что заданный орграф на самом деле является DAG-графом, и напишите реализацию на основе DFS.

19.39. Воспользуйтесь решением из упражнения 19.38 для (эмпирической) оценки вероятности того, что случайный орграф с Vвершинами и E ребрами представляет собой DAG, для различных видов орграфов (см. упражнения 19.11—19.18).

19.40. Эмпирически определите относительное процентное содержание древесных, обратных, поперечных и нисходящих ребер при выполнении поиска в глубину на различных видах орграфов (см. упражнения 19.11—19.18).

19.41. Опишите, как построить последовательность ориентированных ребер между V вершинами, для которых при стандартном поиске в глубину на представлении списками смежности не будет ни поперечных, ни нисходящих ребер, а количество обратных ребер пропорционально V2.

19.42. Опишите, как построить последовательность ориентированных ребер V вершин, для которых при стандартном поиске в глубину на представлении списками смежности не будет ни обратных, ни нисходящих ребер, а количество поперечных ребер пропорционально V2.

19.43. Опишите, как построить последовательность ориентированных ребер V вершин, для которых при стандартном поиске в глубину на представлении списками смежности не будет ни обратных, ни поперечных ребер, а количество нисходящих ребер пропорционально V2.

19.44. Приведите правила, соответствующие обходу методом Тремо лабиринта, в котором все коридоры являются односторонними.

19.45. Добавьте в решения, полученные в упражнениях 17.56—17.60, возможность вывода стрелок на ребрах (в качестве примеров используйте рисунки из данной главы).

Достижимость и транзитивное замыкание

Чтобы получить эффективные решения задач достижимости в орграфах, начнем со следующего фундаментального определения.

Определение 19.5. Транзитивное замыкание (transitive closure) орграфа — это орграф с теми же вершинами, но в котором ребро из s в t существует тогда и только тогда, когда существует ориентированный путь из s в t в заданном орграфе.

Другими словами, ребра в транзитивном замыкании соединяют каждую вершину со всеми вершинами, достижимыми из этой вершины в исходном орграфе. Понятно, что транзитивное замыкание содержит в себе всю информацию, необходимую для решения задач достижимости. Небольшой пример приведен на рис. 19.13.

 Транзитивное замыкание


Рис. 19.13.  Транзитивное замыкание

Исходный орграф (вверху) содержит лишь восемь ориентированных ребер, но его транзитивное замыкание (внизу) показывает, что существуют ориентированные пути, соединяющие 19 из 30 пар вершин. Транзитивное замыкание отражает структурные свойства орграфа. Например, строки 0, 1 и 2 матрицы смежности в транзитивном замыкания идентичны (как и столбцы 0, 1 и 2), поскольку эти вершины содержатся в ориентированном цикле исходного орграфа.

Один из привлекательных способов понять транзитивное замыкание основан на представлении орграфа матрицей смежности и на следующей фундаментальной вычислительной задаче.

Перемножение булевых матриц. Булевой матрицей (Boolean matrix) называется матрица, элементы которой принимают двоичные значения, т.е. 0 или 1. Для заданных булевых матриц A и B можно вычислить произведение C, используя вместо арифметических операций сложения и умножения, соответственно, логические операции И и ИЛИ.

Классический алгоритм вычисления произведения двух матриц размером V х V вычисляет для каждого s и t скалярное произведение строки s первой матрицы и строки t второй матрицы:

  for (s = 0; s < V; s++)
    for (t = 0; t < V; t++)
      for (i = 0, C[s][t] = 0; i < V; i++)
        C[s][t] = A[s][i] * B[i][t];
      

В матричной системе обозначений такая операция записывается просто как C = A * B. Эта операция определена для матриц, состоящих из любых типов элементов, для которых определены операции 0, + и *. В частности, если a + b интерпретируется как логическая операция ИЛИ, а операция a * b — как логическая операция И, то получается умножение булевых матриц. В языке C++ можно воспользоваться таким вариантом:

  for (s = 0; s < V; s++)
    for (t = 0; t < V; t++)
      for (i = 0, C[s][t] = 0; i < V; i++)
        if (A[s][i] && B[i][t]) C[s][t] = 1;
      

Чтобы вычислить элемент C[s][t] произведения, вначале мы обнуляем его, а затем присваиваем ему значение 1, если находится значение i, для которого как A[s][i], так и B[i][t] равны 1. Это эквивалентно занесению в C[s][t] значения 1 тогда и только тогда, когда результат побитовой операции И над строкой s матрицы A и столбцом t матрицы B не равен нулю.

Теперь пусть A — матрица смежности орграфа A, и мы используем приведенный выше код для вычисления (только нужно заменить идентификатор B на A). В этом случае для каждой пары вершин s и t матрица C содержит ребро из s в t тогда и только тогда, когда имеется такая вершина i, для которой в A существует как путь из s и i, так и путь из i и t. То есть ориентированные ребра в A2 в точности соответствуют ориентированным путям в A длиной 2. Если учитывать петли в каждой вершине A, то A2 будет содержать ребра из матрицы A, иначе их там не будет. Эта зависимость между умножением булевых матриц и путями в орграфе продемонстрирована на рис. 19.14. Она немедленно приводит к элегантному методу вычисления транзитивного замыкания любого орграфа.

Лемма 19.6. Транзитивное замыкание орграфа можно вычислить, построив матрицу смежности A этого графа, добавив петли для каждой вершины и вычислив AV. Доказательство. Продолжим рассуждения из предыдущего абзаца: A3 содержит ребра для каждого пути орграфа длиной меньше или равной 3, в матрице A4 содержится каждый путь орграфа, длина которого меньше или равна 4, и т.д. Нет необходимости рассматривать пути, длина которых больше V: любой такой путь

хотя бы один раз должен повторно пройти через одну из вершин (поскольку в графе всего V вершин), поэтому он не добавляет какую-то новую информацию в транзитивное замыкание. Ведь оба конца такого пути уже соединены ориентированным путем длиной меньше V (который можно получить, удалив цикл в повторно посещенную вершину).

На рис. 19.15 показаны различные степени матрицы смежности для одного и того же орграфа в процессе вычисления транзитивного замыкания. Данный метод выполняет V матричных умножений, каждое из которых выполняется за время, пропорциональное V3, что в конечном итоге составляет V4. Но на самом деле транзитивное замыкание для любого орграфа можно вычислить с помощью лишь операций умножения булевых матриц A2, A4, A8, ..., пока показатель степени не станет больше или равен V. Как показано в доказательстве леммы 19.6, At = AV для любого t > V; так что в результате этого вычисления, требующего на свое выполнение времени, пропорционального V3lgV , получится транзитивное замыкание AV.

Только что описанный подход привлекателен своей простотой, однако существует еще более простой метод. Транзитивное замыкание можно вычислить с помощью лишь одной такой операции, которая строит транзитивное замыкание матрицы смежности, заменяя саму матрицу:

  for (i = 0; i < V; i++)
    for (s = 0; s < V; s++)
      for (t = 0; t < V; t++)
        if (A[s][i] && A[i][t]) A[s][t] = 1;
      

Этот классический метод, предложенный С. Уоршеллом (S. Warshall) в 1962 г., наиболее удобен для вычисления транзитивных замыканий насыщенных орграфов. Данный код похож на код возведения в квадрат булевой матрицы: различие (важное!) заключается в порядке выполнения циклов for.

 Вычисление квадрата матрицы смежности


Рис. 19.14.  Вычисление квадрата матрицы смежности

Если обнулить главную диагональ матрицы смежности орграфа, то квадрат такой матрицы будет представлять собой граф с ребрами, соответствующими каждому пути длиной 2 (вверху). Если заполнить главную диагональ единицами, то квадрат такой матрицы будет представлять собой граф с ребрами, соответствующими каждому пути длиной 1 или 2 (внизу).

 Степени матрицы смежности и ориентированные пути


Рис. 19.15.  Степени матрицы смежности и ориентированные пути

Здесь показана последовательность из первой, второй, третьей и четвертой степени (справа, сверху вниз) матрицы смежности, изображенной справа вверху. Эта последовательность порождает графы с ребрами для каждого из путей длиной меньше, соответственно, 1, 2, 3 и 4 (слева, сверху вниз) в графе, представленном этой матрицей. Граф в нижней части рисунка представляет собой транзитивное замыкание для этого примера, поскольку в данном случае не существует путей длиной больше 4, которые соединяют вершины, не соединенные более короткими путями.

Лемма 19.7. С помощью алгоритма Уоршелла (рис. 19.16) можно вычислить транзитивное замыкание орграфа за время, пропорциональное V3.

Доказательство. Оценка времени выполнения непосредственно следует из структуры кода. Докажем, что он вычисляет транзитивное замыкание, методом индукции по i. После первого выполнения тела цикла матрица содержит 1 на пересечении строки s и столбца t тогда и только тогда, когда существуют пути s-t или s-0-t. Вторая итерация проверяет все пути между s и t, которые содержат вершину 1 и, возможно, 0 — такие как s-1-t, s-1-0-t и s-0-1-t. Мы приходим к следующему индуктивному предположению: i-я итерация цикла заносит 1 в ячейку матрицы на пересечении строки s и столбца t тогда и только тогда, когда в орграфе существует ориентированный путь из s в t, который не содержит вершин с индексами, большими i (за исключением, возможно, конечных точек s и t). Как только что было доказано, это условие выполняется, когда i равно 0. Если это условие верно для i-ой итерации цикла, то путь из s в t, который не содержит вершин с индексами больше i + 1, существует тогда и только тогда, когда: (1) существует путь из s в t, который не содержит вершин с индексами, большими i — в этом случае в A[s][t] занесено 1 на предыдущей итерации цикла (в соответствии с индуктивным предположением); либо (2) существует путь из s в i+1 и путь из i+1 в t, и ни один из них не содержит вершин с индексами, большими i (за исключением конечных точек) — в этом случае в A[s][i + 1] и A[i+1][t] были ранее записаны 1 (по индуктивному предположению). Следовательно, внутренний цикл заносит 1 в A[s][t].

 Алгоритм Уоршелла


Рис. 19.16.  Алгоритм Уоршелла

Здесь показан процесс формирования транзитивного замыкания (внизу) для простого орграфа (вверху) с помощью алгоритма Уоршелла. Первая итерация цикла (левый столбец, вверху) добавляет ребра 1-2 и 1-5, поскольку существуют пути 1-0-2 и 1-0-5, которые содержат вершину 0 (но не содержат вершины с большими номерами). Вторая итерация (левый столбец, вторая сверху) добавляет ребра 2-0 и 2-5, поскольку существуют пути 2-1-0 и 2-1-0-5, которые содержат вершину 1 (но не содержат вершины с большими номерами). Третья итерация (левый столбец, внизу) добавляет ребра 0-1, 3-0, 3-1 и 3-5, поскольку существуют пути 0-2-1, 3-2-1-0, 3-2-1 и 3-2-1-0-5, которые содержат вершину 2 (но не содержат вершины с большими номерами). В правом столбце показаны ребра, добавленные при просмотре путей через вершины 3, 4 и 5. Последняя итерация (правый столбец, внизу) добавляет в вершину 4 ребра из вершин 0, 1 и 2, т.к. все ориентированные пути из этих вершин в вершину 4 содержат 5 — вершину с наибольшим номером.

Производительность алгоритма Уоршелла можно повысить с помощью простого преобразования кода: проверку элемента A[s][i] можно вынести из внутреннего цикла, поскольку при изменении t его значение не меняется. Это позволит вообще не выполнять t-й цикл, когда A[s][i] равен нулю. Достигаемая экономия зависит от конкретного орграфа и зачастую весьма существенна (см. упражнения 19.53 и 19.54). Программа 19.3 содержит это усовершенствование и позволяет клиентам сначала выполнить предварительную обработку орграфа (вычислить транзитивное замыкание), а затем получать за постоянное время ответ на любой запрос о достижимости.

Программа 19.3. Алгоритм Уоршелла

Конструктор класса TC вычисляет транзитивное замыкание графа G в приватном члене данных T, чтобы клиентские программы могли использовать объекты TC для проверки, достижима ли заданная вершина орграфа из любой другой вершины. Конструктор инициализирует T копией графа G, добавляет петли, и затем использует алгоритм Уоршелла. Класс tcGraph должен содержать реализацию проверки существования ребра edge.

  template <class tcGraph, class Graph>
    class TC
      { tcGraph T;
      public:
        TC(const Graph &G) : T(G)
        { for (int s = 0; s < T.V(); s++)
          T.insert(Edge(s, s));
          for (int i = 0; i < T.V(); i++)
            for (int s = 0; s < T.V(); s++)
              if (T.edge(s, i))
                for (int t = 0; t < T.V(); t++)
                  if (T.edge(i, t))
                    T.insert(Edge(s, t));
        }
      bool reachable(int s, int t) const
        { return T.edge(s, t); }
      } ;
      

Было бы неплохо иметь более эффективные решения, особенно для разреженных графов — к примеру, сократить как время, так и объем памяти, необходимые для предварительной обработки, поскольку оба эти фактора делают алгоритм Уоршелла неподъемным при обработке крупных разреженных орграфов.

В современных приложениях абстрактные типы данных позволяют выделить из любой конкретной реализации идею какой-либо операции, а затем сосредоточить свои усилия на повышении эффективности ее реализации. В случае транзитивного замыкания можно заметить, что для предоставления клиентам абстракции транзитивного замыкания не обязательно вычислять всю матрицу. Транзитивное замыкание вполне может оказаться крупной разреженной матрицей, и лучше использовать представление списками смежности, поскольку хранение графа в матричном виде чересчур накладно. Даже в случае насыщенного транзитивного замыкания клиентские программы могут проверять только мизерную часть возможных пар вершин, и полная матрица просто не нужна.

Мы будем использовать термин абстрактное транзитивное замыкание (abstract transitive closure) для обозначения АТД, который позволяет клиентам выполнять проверки после предварительной обработки графа, как в программе 19.3. В этом контексте необходимо оценивать алгоритмы не только по затратам на вычисление транзитивного замыкания (стоимость предварительной обработки), но и по объему памяти и времени ответа на запросы. То есть мы предлагаем следующую формулировку леммы 19.7:

Лемма 19.8. Можно обеспечить проверки достижимости в заданном орграфе (абстрактное транзитивное замыкание) за постоянное время, используя на предварительную обработку объем памяти, пропорциональный V2, и время, пропорциональное V3.

Доказательство. Эта лемма непосредственно следует из базовых рабочих характеристик алгоритма Уоршелла.

В большинстве случаев нам нужно не только быстро вычислять транзитивное замыкание орграфа, но и отвечать за постоянное время на запросы относительно абстрактного транзитивного замыкания с гораздо меньшими затратами памяти и времени на предварительную обработку, чем указано в лемме 19.8. Можно ли найти реализацию, которая позволит создавать клиентские программы, способные выполнять обработку таких графов? Мы вернемся к этому вопросу в разделе 19.8.

Существует внутренняя связь между задачей вычисления транзитивного замыкания орграфа и рядом других фундаментальных вычислительных задач, и эта связь может помочь оценить трудность данной задачи. Два примера таких задач будут рассмотрены в конце этого раздела.

Сначала мы рассмотрим взаимосвязь между транзитивным замыканием и задачей определения кратчайших путей для всех пар вершин (all-pairs shortest-paths). Для орграфов задача заключается в том, чтобы для каждой пары вершин найти ориентированный путь с минимальным количеством ребер.

Для заданного орграфа мы инициализируем целочисленную матрицу A размером

  V х V: элемент A[s][t] содержит 1, если в орграфе существует ребро из s в t, 
или сигнальное значение V, если такого ребра нет. 
Эту задачу выполняет следующий код:
    for (i = 0; i < V; i++)
      for (s = 0; s < V; s++)
        for (t = 0; t < V; t++)
          if (A[s][i] + A[i][t] < A[s][t])
            A[s][t] = A[s][i] + A[i][t];
      

Данный код отличается от алгоритма Уоршелла, приведенного непосредственно перед леммой 19.7, только оператором if во внутреннем цикле. В самом деле, в соответствующей абстрактной форме эти вычисления эквивалентны (см. упражнения 19.55 и 19.56). Несложно преобразовать доказательство леммы 19.7 в прямое доказательство того, что этот метод делает то, что нужно. Данный метод является частным случаем алгоритма Флойда (Floid) поиска кратчайших путей во взвешенных графах (см. лекция №21). Решение для ориентированных графов на основе поиска в ширину, рассмотренное в лекция №18, также может (после соответствующей модификации) отыскивать кратчайшие пути в орграфах. Кратчайшие пути будут рассматриваться в лекция №21, поэтому мы отложим детальное сравнение рабочих характеристик до этой главы.

Далее. Как мы видели, задача транзитивного замыкания также тесно связана с задачей перемножения булевых матриц. Рассмотренные выше базовые алгоритмы решения обеих задач на основе аналогичной вычислительной схемы требуют времени, пропорционального V3. Умножение булевых матриц является сложной вычислительной задачей: известны алгоритмы, асимптотически более быстрые, чем простые методы, однако получаемая выгода обычно не оправдывает усилий на их реализацию. Этот факт имеет большое значение в данном контексте, поскольку мы могли бы воспользоваться быстрым алгоритмом умножения булевых матриц для разработки быстрого алгоритма транзитивного замыкания (медленнее алгоритма умножения лишь в lgV раз), используя метод многократного возведения в квадрат, показанный на рис. 19.15. И наоборот, можно оценить нижнюю границу сложности вычисления транзитивного замыкания.

Лемма 19.9. Алгоритм транзитивного замыкания можно использовать для вычисления произведения двух булевых матриц с разницей времени вычисления не более чем в постоянное количество раз.

Доказательство. Пусть даны булевы матрицы A и B размером V х V. Построим следующую матрицу размером 3Vх 3V:

Здесь 0 означает нулевую матрицу размером V х V, все элементы которой равны 0, а I — единичную матрицу размером V х V, все элементы которой равны 0, за исключением элементов, стоящих на главной диагонали, которые равны 1. Будем рассматривать эту матрицу как матрицу смежности орграфа, и вычислим его транзитивное замыкание повторными возведениями в квадрат. Для этого потребуется лишь одно действие:

Матрица в правой части этого равенства является транзитивным замыканием, поскольку последующие умножения дают эту же матрицу. Однако в верхнем правом углу этой матрицы содержится произведение A * B. Любой алгоритм вычисления транзитивного замыкания можно применить для перемножения булевых матриц с теми же затратами (в пределах постоянного множителя).

Важность этой леммы определяется убежденностью экспертов в сложности задачи умножения булевых матриц: математики десятилетиями пытаются точно оценить ее сложность, но решения пока нет; наилучшие известные результаты говорят, что время выполнения умножения должно быть пропорционально примерно V2,5(см. раздел ссылок). Но если мы сможем найти линейное по времени решение задачи транзитивного замыкания (т.е. пропорциональное V2 ), то мы получим и линейное решение задачи перемножения булевых матриц. Подобная зависимость между задачами называется сведением (reduction): мы говорим, что задача перемножения булевых матриц сводится (reduce) к задаче транзитивного замыкания (см. раздел 21.6 лекция №21 и часть 8). На самом деле приведенное выше доказательство показывает, что умножение булевых матриц сводится к нахождению в орграфе путей длиной 2.

Несмотря на интенсивные исследования многих специалистов, никто не смог найти линейный алгоритм умножения булевых матриц — значит, мы не сможем предложить простого линейного алгоритма транзитивного замыкания. Но ведь и никто не доказал, что такие алгоритмы не существуют, так что возможность появления такого алгоритма не исключается. В общем, лемму 19.9 можно трактовать так: если не будет прорыва в исследованиях, то не следует ожидать, что время выполнения любого алгоритма транзитивного замыкания будет пропорционально V2 в худшем случае. Однако возможны быстрые алгоритмы для отдельных классов орграфов. Например, мы уже упоминали простой метод вычисления транзитивного замыкания, который работает для разреженных графов гораздо быстрее алгоритма Уоршелла.

Лемма 19.10. Алгоритм DFS позволяет отвечать за постоянное время на запросы относительно абстрактного транзитивного замыкания орграфа, используя на предварительную обработку (вычисление транзитивного замыкания) объем памяти, пропорциональный V2, и время, пропорциональное V(E + V).

Доказательство. Как было сказано в предыдущем разделе, поиск в глубину по представлению списками смежности позволяет найти все вершины, достижимые из исходной, за время, пропорциональное E (лемма 19.5 и рис. 19.11). И если выполнить поиск в глубину V раз, считая каждую вершину исходной, то можно вычислить множество вершин, достижимых из каждой вершины, т.е. транзитивное замыкание, за время, пропорциональное V (E + V). Это же рассуждение верно для любого обобщенного поиска, выполняющегося за линейное время (см. лекция №18 и упражнение 19.66).

Программа 19.4 содержит реализацию алгоритма транзитивного замыкания на основе алгоритма поиска. Этот класс реализует тот же интерфейс, что и программа 19.3. Результат работы программы на орграфе с рис. 19.1 показан первым деревом каждого леса на рис. 19.11.

В случае разреженных орграфов этот подход, основанный на поиске, удобнее всего. Например, если E пропорционально V, то программа 19.4 вычисляет транзитивное замыкание за время, пропорциональное V2. Но как она это делает, учитывая сведение к умножению булевых матриц? Ответ таков: данный алгоритм транзитивного замыкания действительно является оптимальным способом умножения определенных типов булевых матриц (с количеством ненулевых элементов O(V)). Нижняя граница показывает, что не стоит надеяться найти алгоритм транзитивного замыкания, который выполняется за время, пропорциональное V2, для всех орграфов — однако это не исключает, что можно найти подобные алгоритмы, которые работают быстрее на некоторых классах орграфов.

Программа 19.4. Вычисление транзитивного замыкания на основе поиска в глубину

Данный класс DFS реализует тот же интерфейс, что и программа 19.3. Он вычисляет транзитивное замыкание T, выполняя поиск в глубину для каждой вершины графа G и вычисляя множество узлов, достижимых из этой вершины. Каждый вызов рекурсивной функции добавляет ребро из начальной вершины и выполняет рекурсивные вызовы, чтобы заполнить соответствующую строку в матрице транзитивного замыкания. Эта матрица используется также для пометки посещенных вершин при работе поиска в глубину, поэтому необходимо, чтобы класс Graph поддерживал проверку существования ребер.

  template <class Graph>
  class tc
    { Graph T; const Graph &G;
      void tcR(int v, int w)
        { T.insert(Edge(v, w));
          typename Graph::adjIterator A(G, w);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (!T.edge(v, t)) tcR(v, t);
        }
    public:
      tc(const Graph &G) : G(G), T(G.V(), true)
        { for (int v = 0; v < G.V(); v++) tcR(v, v); }
      bool reachable(int v, int w)
        { return T.edge(v, w); }
    };
      

Если это как раз такие графы, которые нам потребовалось обрабатывать, то взаимосвязь между транзитивным замыканием и умножением булевых матриц не будет иметь особого значения.

Методы, описанные в данном разделе, легко расширить так, чтобы клиентские программы смогли находить конкретные пути между двумя вершинами, используя дерево поиска, как описано в лекция №17. Мы рассмотрим подобные специализированные реализации АТД в контексте более общих задач поиска кратчайших путей в лекция №21.

В таблице 19.1 приведены эмпирические результаты сравнения элементарных алгоритмов транзитивного замыкания, описанных в данном разделе. Реализация решения на основе поиска по представлению списками смежности является наиболее быстрым методом для разреженных графов. Все реализации вычисляют матрицу смежности (размера V2 ), поэтому ни одна из них не годится для обработки крупных разреженных графов.

Таблица 19.1. Эмпирическое сравнение алгоритмов транзитивного замыкания
Разреженные (10V ребер) Насыщенные (250 вершин)
VWW*ALEWW*AL
250010500028920317723
5031211000030021418438
12535242342500030922620097
2502751811781350000315232218337
50022221438148154100000326246235784
Обозначения:
WАлгоритм Уоршелла (раздел 19.3)
W*Усовершенствованный алгоритм Уоршелла (программа 19.3)
ADFS по матрице смежности (программы 19.4 и 17.7)
LDFS по спискам смежности (программа 17.9)

В данной таблице приведены значения времени выполнения различных алгоритмов вычисления транзитивного замыкания для случайных орграфов, как насыщенных, так и разреженных. Для всех алгоритмов, кроме DFS по спискам смежности, при удвоении V время выполнения возрастает в 8 раз — это подтверждает, что время пропорционально V3. DFS по спискам смежности требует для своего выполнения время, пропорциональное VE. Поэтому время выполнения этого алгоритма возрастает в примерно в 4 раза при удвоении и V, и E (разреженные графы), и примерно в 2 раза при удвоении E (насыщенные графы) — кроме случаев снижения производительности при обходе сильно насыщенных графов.

В случае разреженных графов, транзитивные замыкания которых тоже разрежены, можно использовать реализацию замыкания списками смежности, поэтому размер ее выходных данных пропорционален количеству ребер в транзитивном замыкании. Конечно, это число служит нижней границей стоимости вычисления транзитивного замыкания, которое можно получить для различных видов орграфов с помощью различных алгоритмических технологий (см. упражнения 19.64 и 19.65). Однако в общем случае мы считаем, что результат транзитивного замыкания является насыщенным. Тогда можно воспользоваться реализацией наподобие DenseGRAPH, которая способна легко отвечать на запросы о достижимости, и мы рассматриваем алгоритмы, которые вычисляют матрицу транзитивного замыкания за время, пропорциональное V2, как оптимальные, поскольку время их выполнения пропорционально размеру их выходных данных.

Если матрица смежности симметрична, она эквивалентна неориентированному графу, и тогда поиск транзитивного замыкания эквивалентен поиску связных компонентов: транзитивное замыкание представляет собой объединение полных графов для вершин в связных компонентах (см. упражнение 19.48). Алгоритмы определения связности, представленные в лекция №18, эквивалентны вычислению абстрактного транзитивного замыкания для симметричных орграфов (неориентированных графов), используют объем памяти, пропорциональный V, и также способны отвечать на запросы о достижимости за постоянное время. Возможно ли такое в случае орграфов общего вида? Для каких видов орграфов можно вычислить транзитивное замыкание за линейное время? Для ответа на эти вопросы нам нужно подробнее ознакомиться со структурой орграфов, и в первую очередь — со структурой DAG-графов.

Упражнения

19.46. Как выглядит транзитивное замыкание орграфа, который состоит лишь из направленного цикла с V вершинами?

19.47. Сколько ребер содержит транзитивное замыкание орграфа, который состоит лишь из простого ориентированного пути с V вершинами?

19.48. Приведите транзитивное замыкание неориентированного графа

3-71-47-80-55-23-82-90-64-92-66-4.

19.49. Покажите, как можно построить орграф с V вершинами и E ребрами, такой, что количество ребер в транзитивном замыкании пропорционально t, для любого t от E до V2. Как обычно, предполагается, что E > V.

19.50. Приведите формулу количества ребер в транзитивном замыкании орграфа, представляющего собой ориентированный лес, в виде функции структурных свойств леса.

19.51. Представьте в стиле рис. 19.15 процесс вычисления транзитивного замыкания орграфа

3-71-47-80-55-23-82-90-64-92-66-4

многократным возведением в квадрат.

19.52. Представьте в стиле рис. 19.16 процесс вычисления транзитивного замыкания орграфа

3-71-47-80-55-23-82-90-64-92-66-4 с помощью алгоритма Уоршелла.

19.53. Опишите семейство разреженных орграфов, для которых усовершенствованный вариант алгоритма Уоршелла для вычисления транзитивного замыкания (программа 19.3) выполняется за время, пропорциональное VE.

19.54. Найдите разреженный орграф, для которого усовершенствованный вариант алгоритма Уоршелла для вычисления транзитивного замыкания (программа 19.3) выполняется за время, пропорциональное V3.

19.55. Разработайте базовый класс для порождения производных классов, которые реализуют как алгоритм Уоршелла, так и алгоритм Флойда. (Это упражнение — вариант упражнения 19.56 для тех, кто лучше знаком с абстрактными типами данных, чем с абстрактной алгеброй).

19.56. Воспользуйтесь аппаратом абстрактной алгебры для разработки обобщенного алгоритма, который заключает в себе как алгоритм Уоршелла, так и алгоритм Флойда. (Это упражнение — вариант упражнения 19.55 для тех, кто лучше ознакомлен с абстрактной алгеброй, чем с абстрактными типами данных).

19.57. Представьте в стиле рис. 19.16 этапы вычисления матрицы всех кратчайших путей алгоритмом Флойда для графа, изображенного на этом рисунке.

19.58. Является ли произведение двух симметричных булевых матриц симметричным? Обоснуйте ваш ответ.

19.59. Добавьте в программы 19.3 и 19.4 общедоступную функцию-член, которая позволит клиентам использовать объекты tc для определения количества ребер в транзитивном замыкании.

19.60. Разработайте способ ведения счетчика количества ребер в транзитивном замыкании, изменяя его при добавлении и удалении ребер. Определите стоимость добавления и удаления ребер в соответствии с вашей схемой.

19.61. Добавьте в программы 19.3 и 19.4 общедоступную функцию-член, которая возвращает вектор, индексированный именами вершин, который позволяет определить вершины, достижимые из данной вершины.

19.62. Эмпирически определите количество ребер в транзитивном замыкании различных видов орграфов (см. упражнения 19.11—19.18).

19.63. Рассмотрите представление графа битовой матрицей, описанное в упражнении 17.23. Какой из методов можно ускорить с его помощью в B раз (где B есть количество битов в слове вашего компьютера) — алгоритм Уоршелла или алгоритм на основе поиска в глубину? Проверьте ответ, разработав соответствующую программную реализацию.

19.64. Приведите программу, способную вычислить транзитивное замыкание орграфа, который представляет собой ориентированный лес, за время, пропорциональное количеству ребер в транзитивном замыкании.

19.65. Реализуйте алгоритм абстрактного транзитивного замыкания для разреженных графов, который использует объем памяти, пропорциональный T, и может отвечать на запросы о достижимости за постоянное время после предварительной обработки за время, пропорциональное VE + T, где T — количество ребер в транзитивном замыкании. Указание. Воспользуйтесь динамическим хешированием.

19.66. Напишите версию программы 19.4, которая основана на обобщенном поиске на графе (см. лекция №18), и эмпирически определите, влияет ли выбор алгоритма поиска на графе на ее производительность.

Отношения эквивалентности и частичные порядки

В данном разделе излагаются фундаментальные понятия теории множеств и их взаимосвязь с алгоритмами абстрактного транзитивного замыкания. Они позволят нам рассмотреть изучаемые идеи в более широком контексте и продемонстрировать всю широту применения уже знакомых нам алгоритмов. Читатели с хорошей математической подготовкой, знакомые с теорией множеств, могут сразу перейти к разделу 19.5, поскольку излагаемый здесь материал элементарен (хотя может оказаться полезным краткий обзор терминологии). А читателям, не знакомым с теорией множеств, возможно, было бы полезно сначала ознакомиться с элементарными понятиями дискретной математики, так как наше изложение будет весьма лаконичным. Связь между орграфами и фундаментальными математическими понятиями достаточно важна, чтобы не игнорировать ее.

Пусть задано некоторое множество. Отношение (relation) между его объектами определяется как множество упорядоченных пар этих объектов. Не считая некоторых деталей, таких как параллельные ребра и петли, это определение совпадает с определением орграфа: отношения и орграфы — просто различные представления одной и той же абстракции. Математический вариант несколько более универсален, поскольку множества могут быть бесконечными, а все компьютерные программы работают с конечными множествами, но пока мы не будем обращать внимание на эти различия.

Обычно отношение обозначается символом R, а выражение sRt означает утверждение " упорядоченная пара (s, t) находится в отношении R " . Например, символ " < " используется для представления отношения " меньше чем " . Пользуясь этой терминологией, можно описывать различные свойства отношений. Например, отношение R называется симметричным, если для всех s и t из sRt следует tRs; отношение называется рефлексивным, если для всех s справедливо sRs. Симметричные отношения соответствуют неориентированным графам. Рефлексивные отношения соответствуют графам, в которых в каждой вершине есть петли; отношения, соответствующие графам, в которых ни у одной из вершин нет петель, называются нерефлексивными.

Говорят, что отношение транзитивно, когда из sRt и tRu следует sRu для всех s, t и и. Имеется и определение для транзитивного замыкания (transitive closure) отношения; однако мы не будем давать его определение в контексте теории множеств, а воспользуемся определением для орграфов из раздела 19.3. Любое отношение эквивалентно некоторому орграфу, а транзитивное замыкание отношения эквивалентно соответствующему транзитивному замыканию орграфа. Транзитивное замыкание любого отношения само транзитивно.

В контексте алгоритмов на графах для нас особо важны два специальных транзитивных отношения, которые определяются дополнительными ограничениями. Эти два вида широко распространенных отношений известны как отношения эквивалентности (equivalence relation) и частичные порядки (partial order).

Отношение эквивалентности (=) есть транзитивное отношение, которое к тому же рефлексивно и симметрично. Вообще-то симметричное и транзитивное отношение, которое помещает каждый объект в некоторую упорядоченную пару, должно быть и отношением эквивалентности: если s = t, то t = s (в силу симметричности), откуда s = s (в силу транзитивности). Отношение эквивалентности разбивает объекты множества на подмножества — классы эквивалентности (equivalence class). Два объекта s и t содержатся в одном и том же классе эквивалентности тогда и только тогда, когда s = t. Ниже приведены типичные примеры отношений эквивалентности:

Модульная арифметика. Любое положительное целое к определяет на множестве целых чисел отношение эквивалентности: s = t (mod к) тогда и только тогда, когда остаток от деления s на к равен остатку от деления t на к. Очевидно, что это отношение симметрично, а несложное рассуждение показывает, что оно еще и транзитивно (см. упражнение 19.67) — следовательно, оно является отношением эквивалентности.

Связность в графах. Отношение между вершинами " содержится в том же связном компоненте, что и... " есть отношение эквивалентности, поскольку оно симметрично и транзитивно. Классы эквивалентности соответствуют связным компонентам в графах.

При создании АТД графа, который позволяет клиентам проверять, находятся ли две вершины в одном и том же связном компоненте, мы реализуем АТД отношения эквивалентности, который предоставляет клиентам возможность проверки эквивалентности двух объектов. На практике это соответствие имеет большое значение, поскольку граф является сжатым представлением отношения эквивалентности (см. упражнение 19.71). Фактически, как мы видели в лекция №1 и 18, для построения такого АТД достаточно использовать единственный вектор, индексированный именами вершин.

Частичный порядок (partial order) есть транзитивное нерефлексивное отношение. Нетрудно доказать, что из нерефлексивности и транзитивности следует, что частичные порядки асимметричны: если и , то (в силу транзитивности), а это противоречит нерефлексивности, т.е. невозможно одновременно и . Продолжая эти рассуждения, можно показать, что частичный порядок не может содержать циклы, такие как , и . Ниже приведены примеры типичных частичных порядков:

Включение подмножеств. Отношение " включает, но не равно " (), определенное для подмножеств заданного множества, является частичным порядком — конечно, он не рефлексивен, и если и , то .

Пути в DAG-графах. Отношение " достижим по непустому пути из... " является частичным порядком на вершинах DAG-графа без петель, поскольку оно транзитивно и нерефлексивно. Подобно отношениям эквивалентности и неориентированным графам, этот конкретный частичный порядок важен для многих приложений, поскольку DAG — это сжатое неявное представление частичного порядка.

Например, на рис. 19.17 приведены DAG-графы частичных порядков включения подмножеств, количество ребер которых составляет лишь небольшую часть мощности частичного порядка (см. упражнение 19.73).

 DAG-граф включения множеств


Рис. 19.17.  DAG-граф включения множеств

В DAG-графе, показанном в верхней части рисунка, индексы вершин представляют подмножества множества из трех элементов, которые приведены в таблице в нижней части рисунка. Транзитивное замыкание этого графа представляет собой частичный порядок включения подмножеств: ориентированный путь между двумя узлами существует в том и только том случае, когда подмножество, представленное первым узлом, содержится в подмножестве, представленном вторым узлом.

Вообще-то частичные порядки редко определяются перечислением всех упорядоченных пар, поскольку таких пар очень много. Вместо этого обычно определяется нерефлексивное отношение (DAG) и рассматривается его транзитивное замыкание. Это и есть основная причина изучения реализаций АТД для абстрактных транзитивных замыканий DAG-графов. Работа с DAG-графами и примеры частичных порядков будут рассматриваться в разделе 19.5.

Полный порядок (total order) T — это частичный порядок, в котором для всех s Ф t выполняется либо sTt, либо tTs. Знакомыми нам примерами полного порядка являются отношение " меньше чем " на множествах целых или вещественных чисел или лексикографическое упорядочение строк символов. Наши алгоритмы сортировки и поиска в частях III и IV основываются на реализации АТД полного порядка множеств. В полном порядке существует один и только один способ упорядочить элементы множества так, чтобы выполнялось отношение sTt, если s предшествует t; а в частичном порядке, который не является полным, может быть много способов такого упорядочения. Алгоритмы решения этой задачи будут рассмотрены в разделе 19.5.

Резюмируя, можно сказать, что приведенные ниже соответствия между множествами и моделями графов помогают лучше понять, насколько важны фундаментальные алгоритмы на графах и как широко они применяются на практике:

Этот список упорядочивает виды изучаемых нами графов и алгоритмов и является дополнительным стимулом изучения базовых свойств DAG-графов и алгоритмов их обработки.

Упражнения

19.67. Покажите, что отношение " имеет тот же остаток при делении на k " транзитивно (и, следовательно, является отношением эквивалентности) на множестве целых чисел.

19.68. Покажите, что отношение " в том же реберно-связном компоненте, что и... " является отношением эквивалентности на множестве вершин любого графа.

19.69. Покажите, что отношение " в том же двусвязном компоненте, что и. " не является отношением эквивалентности на множестве вершин любого графа.

19.70. Докажите, что транзитивное замыкание отношения эквивалентности также является отношением эквивалентности, и что транзитивное замыкание частичного порядка также является частичным порядком.

19.71. Мощность отношения — это количество его упорядоченных пар. Покажите, что мощность отношения эквивалентности равна сумме квадратов мощностей классов эквивалентности этого отношения.

19.72. Используя онлайновый словарь, постройте граф, который представляет отношение эквивалентности " имеет к общих букв с. " на множестве слов. Определите количество классов эквивалентности для к = 1, ..., 5.

19.73. Мощность частичного порядка равна количеству его упорядоченных пар. Какова мощность частичного порядка включения подмножеств для множества из n элементов?

19.74. Покажите, что частичный порядок " является делителем " представляет собой частичный порядок на множестве целых чисел.

DAG -графы

В этом разделе мы рассмотрим различные приложения DAG-графов (directed acyclic graph — ориентированный ациклический граф). На то имеются две причины. Во-первых, они служат неявными моделями частичных порядков, и зачастую в приложениях мы имеем дело непосредственно с DAG-графами — поэтому нужны эффективные алгоритмы обработки таких графов. Во-вторых, такие приложения помогают лучше понять сущность DAG-графов, а понимание DAG-графов важно для понимания орграфов вообще.

Поскольку DAG-графы являются частным видом орграфов, все задачи обработки DAG-графов элементарно сводятся к задачам обработки орграфов. Естественно ожидать, что обрабатывать DAG-графы проще, чем обрабатывать орграфы общего вида, однако понятно, что если мы столкнемся с задачей, которая трудно решается на DAG-графе, то решение этой же задачи на орграфах общего вида вряд ли будет проще. Как мы увидим, задача вычисления транзитивного замыкания принадлежит как раз к этой категории. Оценка сложности обработки DAG-графов также важна, ведь каждый орграф содержит коренной DAG (см. лемму 19.2), т.е. DAG-графы встречаются даже при работе с орграфами, не являющимися DAG-графами.

Классическое приложение, в котором непосредственно возникают DAG-графы, называется составлением расписания (scheduling). В общем случае решение задачи составления расписаний заключается в организации выполнения некоторого множества задач (task) при наличии множества ограничений (constraint) т.е. в указании, когда и как должны выполняться эти задачи. Ограничениями могут быть некоторые функции от времени выполнения или от других ресурсов, потребляемых задачами. Наиболее важный вид ограничений — ограничения предшествования (precedence constraints), которые определяют, что одни задачи должны быть обязательно выполнены перед выполнением других задач, т.е. задают на множестве задач частичный порядок. Различные виды дополнительных ограничений порождают различные виды задач составления расписаний разной степени сложности. Изучены уже буквально тысячи различных задач, и для многих из них исследователи продолжают поиск более совершенных алгоритмов.

Возможно, простейшую нетривиальную задачу составления расписания можно сформулировать следующим образом:

Составление расписаний. Пусть дано множество задач, требующих выполнения, и частичный порядок, определяющий, что решение некоторых задач должно быть завершено прежде, чем начнется выполнение некоторых других задач. Как составить график выполнения задач, чтобы выполнить их все с учетом частичного порядка?

В этом базовом виде задача составления расписания называется топологической сортировкой (topological sorting). Ее решение несложно, и в следующем разделе мы рассмотрим два алгоритма. В более сложных практических приложениях могут понадобиться дополнительные ограничения на планирование выполнения задач, и тогда задача усложняется. Например, задачи могут соответствовать курсам лекций в расписании занятий студентов, а частичный порядок может определяться требованиями подготовки к каждому курсу. Топологическая сортировка способна найти расписание занятий, удовлетворяющее требованиям подготовки, но она может не справиться с другими видами ограничений, которые бывает нужно добавить в модель: конфликты курсов, ограничения на количество слушателей курса и т.д. Еще один пример: задачи могут быть частями некоторого производственного процесса, а частичный порядок может задавать цепочки конкретных процессов. Топологическая сортировка позволяет планировать задачи, но, возможно, существует другой способ, требующий меньше времени, денег или других ресурсов, не учтенных в модели. Различные варианты задачи составления расписаний, которые учитывают подобные более общие ситуации, будут рассмотрены в лекция №21 и лекция №22.

Часто сначала нужно проверить, действительно ли заданный DAG-граф не содержит направленных циклов. Как было показано в 19.2, нетрудно реализовать класс, который позволяет клиентам проверять за линейное время, является ли орграф общего типа DAG-графом — для этого нужно выполнить стандартный поиск в глубину и проверить, что в лесе DFS нет обратных ребер (см. упражнение 19.75). Для реализации алгоритмов обработки DAG-графов мы реализуем специальные клиентские классы нашего стандартного АТД GRAPH, которые предназначены для обработки орграфов без циклов, а проверку отсутствия циклов должны выполнять клиентские программы. При этом возможно, что алгоритм обработки DAG-графов выдаст полезные результаты, даже если запустить его на орграфе с циклами, и иногда это бывает нужно. Разделы 19.6 и 19.7 посвящены реализациям классов топологической сортировки (DAGts) и классов определения достижимости в DAG-графах (DAGts и DAGreach); программа 19.13 представляет собой пример клиента такого класса.

В некотором смысле DAG — это частично дерево и частично граф. Конечно, мы воспользуемся этими особенностями структуры для их обработки. Например, при желании можно рассматривать DAG как дерево. Предположим, что необходимо выполнить обход вершин DAG-графа D, как будто это дерево с корнем в вершине w — чтобы, например, результат обхода двух DAG-графов на рис. 19.18 рис. 19.18 с помощью этой программы был одним и тем же. Следующая простая программа выполняет эту задачу так же, как это сделал бы рекурсивный обход дерева:

  void traverseR(Dag D, int v)
    { visit(v);
      typename Dag::adjIterator A(D, v);
      for (int t = A.beg(); !A.end(); t = A.nxt())
        traverseR(D, t);
    }
      

Однако мы редко будем выполнять подобный полный обход, поскольку обычно будем пользоваться преимуществами DAG-графов, которые позволяют экономить память и время при их обходе (например, пометка посещенных вершин при обычном поиске в глубину). Та же идея применяется и к поиску, где рекурсивный вызов выполняется только для одной ссылки, инцидентной каждой вершине. В таком алгоритме затраты на поиск в DAG-графе и дереве оказываются одними и теми же, но DAG использует гораздо меньше памяти.

Поскольку DAG-графы обеспечивают компактное представление деревьев, в которых имеются идентичные поддеревья, мы часто используем их вместо деревьев для представления различных вычислительных абстракций. В контексте построения алгоритмов различия между представлением выполнения программы в виде DAG являются существенным компонентом динамического программирования (см., например, рис. 19.18 и упражнение 19.78). Кроме того, DAG-графы широко используются в компиляторах в качестве промежуточных представлений арифметических выражений и программ (см., например, рис. 19.19) и в системах проектирования электронных схем в качестве промежуточных представлений комбинационных элементов.

 Модель DAG вычислений чисел Фибоначчи


Рис. 19.18.  Модель DAG вычислений чисел Фибоначчи

Дерево в верхней части описывает зависимость вычисления очередного числа от вычисления двух его предшественников. DAG-граф в нижней части демонстрирует ту же зависимость, хотя использует лишь часть узлов.

При работе с бинарными деревьями часто возникает важная ситуация. На DAG-графы можно наложить те же ограничения, что и на деревья при определении бинарных деревьев.

Определение 19.6. Бинарный DAG (binary DAG) — это ориентированный ациклический граф с двумя ребрами, исходящими из каждого узла (левое и правое ребро), каждое из которых или оба сразу могут быть пустым

 Представление арифметического выражение с помощью DAG-графа


Рис. 19.19.  Представление арифметического выражение с помощью DAG-графа

Оба изображенных здесь DAG-графа являются представлениями одного и того же арифметического выражения (c*(a+b))-((a+b)*(a+b)+e)). В бинарном дереве грамматического разбора (слева) листы представляют операнды, а все внутренние узлы — операции, выполняемые над выражениями, представленными двумя их поддеревьями (см. рис. 5.31). DAG-граф в правой части — это более компактное представление того же дерева. Важно то, что значение выражения можно вычислить за время, пропорциональное размеру DAG, который обычно значительно меньше, чем размер дерева (см. упражнения 19.112 и 19.113).

Различие между бинарными DAG-графами и бинарными деревьями заключается в том, что в бинарном DAG на конкретный узел может указывать более чем одна ссылка. Как и наше определение бинарных деревьев, это определение моделирует естественное представление, где каждый узел представляет собой структуру с левой ссылкой и с правой ссылкой, которые указывают на другие узлы (либо пусты), и действует лишь глобальное ограничение: не разрешены направленные циклы. Бинарные DAG-графы имеют большое значение, поскольку они обеспечивают компактный способ представления бинарных деревьев в некоторых приложениях. Например, как показано на рис. 19.20 и в программе 19.5, trie-дерево существования можно сжать до бинарного DAG без изменения реализации поиска.

 Сжатие бинарного дерева


Рис. 19.20.  Сжатие бинарного дерева

Таблица из девяти пар целых чисел в левой части рисунка является компактным представлением бинарного DAG (внизу справа), который представляет собой сжатую версию бинарного дерева, показанного вверху. В этой структуре данных метки узлов не хранятся в явном виде: таблица представляет восемнадцать ребер 1-0, 1-0, 2-1, 2-1, 3-1, 3-2 и т.д., однако содержит только конечные вершины левых и правых ребер каждого узла (как в бинарном дереве), а их начальные вершины неявно задаются индексом таблицы.

Алгоритм, который зависит только от формы дерева, эффективно работает и на DAG-графах. Пусть, например, дерево является trie-деревом существования для двоичных ключей, соответствующих листовым узлам, т.е. оно представляет ключи 0000, 0001, 0010, 0110, 1100 и 1101.

Успешный поиск ключа 1101 проходит вправо, вправо, влево и вправо, и завершается в листовом узле. В DAG-графе такой же поиск шел бы от 9 через 8, 7 и 2 в 1.

Эквивалентный вариант — рассматривать ключи trie-дерева как соответствующие строкам в таблице истинности булевской функции, для которых эта функция возвращает истинное значение (см. упражнения 18.84—18.87). Бинарный DAG есть модель экономичной схемы, которая вычисляет эту функцию. В таком приложении бинарные DAG-графы называются деревьями решений (binary decision diagram — BDD).

Программа 19.5. Представление бинарного дерева в виде бинарного DAG-графа

Этот кодовый фрагмент реализует обратный обход, который выполняет построение бинарного DAG, соответствующего структуре бинарного дерева (см. главу 12 лекция №12), выявляя общие поддеревья. В нем применяется класс индексирования наподобие ST из программы 17.15 (измененный для работы с парами целых чисел, а не строковых ключей), чтобы присваивать уникальные целочисленные значения каждой отдельной древовидной структуре и применять их в представлении DAG вектором структур из двух целых чисел (см. рис. 19.20 рис. 19.20). Пустому дереву (пустая ссылка) присваивается индекс 0 , дереву с единственным узлом (и с двумя пустыми ссылками) — индекс 1 и т.д.

Индекс, соответствующий каждому поддереву, вычисляется рекурсивно. Затем создается ключ — такой, что каждый узел с такими же поддеревьями будет иметь тот же индекс, и этот индекс возвращается после заполнения ссылок ребра (поддерева) DAG-графа.

  int compressR(link h)
    { STx st;
      if (h == NULL) return 0;
      l = compressR(h->l);
      r = compressR(h->r);
      t = st. index(l, r) ;
      adj[t].l = l; adj[t].r = r;
      return t;
    }
      

В двух последующих разделах мы начнем изучение алгоритмов обработки DAG-графов. Эти алгоритмы не только приводят к реализациям эффективных и полезных функций АТД для DAG-графов, но и позволяют оценить сложность обработки орграфов. Но мы увидим и то, что хотя DAG-графы значительно проще орграфов общего вида, некоторые фундаментальные задачи решить для них нисколько не проще.

Упражнения

19.75. Реализуйте класс DFS, который может использоваться клиентами для проверки, что DAG-граф не содержит циклов.

19.76. Напишите программу, которая генерирует случайные DAG-графы: для этого она строит случайные орграфы, выполняет поиск в глубину из случайной исходной точки и отбрасывает обратные ребра (см. упражнение 19.40). Экспериментально определите параметры программы для получения DAG-графов с приблизительно E ребрами при заданном V.

19.77. Сколько узлов содержит дерево и DAG-граф, соответствующие рис. 19.18, для вычисления N-го числа Фибоначчи (FN)?

19.78. Приведите DAG-граф, соответствующий примеру динамического программирования, для модели задачи о ранце из лекция №5 (см. рис. 5.17).

19.79. Разработайте АТД для бинарных DAG-графов.

19.80. Можно ли любой DAG представить в виде бинарного DAG (см. лемму 5.4)?

19.81. Напишите функцию, которая выполняет поперечный обход бинарного DAG-графа с одним источником. То есть эта функция должна посетить все вершины, которые можно достичь через левое ребро, затем посетить исходную вершину, а потом посетить все вершины, которые можно достичь через правое ребро.

19.82. В стиле рис. 19.20 нарисуйте trie-дерево существования и соответствующий бинарный DAG для ключей 01001010 10010101 00100001 11101100 01010001 00100001 00000111 01010011.

19.83. Реализуйте АТД, основанный на построении trie-дерева существования из набора 32-битовых ключей, сжатии его до бинарного DAG и использовании этой структуры данных для ответов на запросы о существовании.

19.84. Начертите дерево решений для таблицы истинности функции от четырех переменных, которая возвращает 1 тогда и только тогда, когда количество переменных, равных 1, нечетно.

19.85. Напишите функцию, которая принимает в качестве аргумента 2n-разрядную таблицу истинности и возвращает соответствующее дерево решений. Например, для аргумента 1110001000001100 программа должна вернуть представление бинарного DAG-графа с рис. 19.20.

19.86. Напишите функцию, которая принимает в качестве аргумента 2n таблицу истинности, вычисляет все перестановки аргументов и, пользуясь решением упражнения 19.85, возвращает перестановку, которая приводит к наименьшему дереву решений.

19.87. Эмпирически определите эффективность стратегии из упражнения 19.85 для различных булевых функций, как стандартных, так и случайно сгенерированных.

19.88. Напишите программу, аналогичную программе 19.5, которая поддерживает удаление общих подвыражений: для заданного бинарного дерева, представляющего арифметическое выражение, нужно вычислить бинарный DAG-граф, представляющий то же выражение, из которого удалены общие подвыражения.

19.89. Начертите все неизоморфные DAG-графы с двумя, тремя, четырьмя и пятью вершинами.

19.90. Сколько существует различных DAG-графов с Vвершинами E ребрами?

19.91. Сколько существует различных DAG-графов с Vвершинами E ребрами, если считать два DAG различными только если они не изоморфны?

Топологическая сортировка

Цель топологической сортировки заключается в обеспечении обработки вершин DAG-графа так, чтобы каждая вершина была обработана до всех вершин, на которые она указывает. Существуют два естественных и, по существу, эквивалентных способа определения этой базовой операции. В любом случае выполняется перестановка целых чисел от 0 до V— 1, которые, как обычно, находятся в векторах, индексированных именами вершин.

Топологическая сортировка (перенумерация). Нужно перенумеровать вершины заданного DAG-графа так, чтобы каждое ориентированное ребро вело из вершины с меньшим номером в вершину с большим номером (см. рис. 19.21).

Топологическая сортировка (переупорядочение). Нужно переупорядочить вершины заданного DAG-графа по горизонтали так, чтобы все ориентированные ребра были направлены слева направо (рис. 19.22).

 Топологическая сортировка (перенумерация)


Рис. 19.21.  Топологическая сортировка (перенумерация)

Для заданного произвольного DAG-графа (вверху), топологическая сортировка позволяет переименовать его вершины так, что каждое ребро будет вести из вершины с меньшим номером в вершину с большим номером (внизу). В этом примере номера вершин 4, 5, 7 и 8 заменяются, соответственно, на 7, 8 , 5 и 4, как показано в массиве tsI. Существует много возможных способов перенумерации, позволяющих получить нужный результат.

Легко установить (см. рис. 19.22), что перенумерация и переупорядочение перестановок обратны по отношению друг к другу: при наличии переупорядочения перенумерацию можно получить, присвоив номер 0 первой вершине списка, 1 второй вершине списка и т.д. Например, если в векторе ts вершины размещены в порядке топологической сортировки, то цикл

  for (i = 0; i < V; i++) tsI[ts[i]] = i;
      

определяет перенумерацию в векторе tsI, индексированном именами вершин. И можно получить переупорядочение из перенумерации с помощью цикла

  for (i = 0; i < V; i++) ts[tsI[i]] = i;
      

который помещает первой в список вершину с номером 0, потом вершину с номером 1 и т.д. Чаще всего мы будем называть топологической сортировкой (topological sort) вариант задачи с переупорядочением. Обратите внимание, что ts не есть вектор, индексированный именами вершин. Обычно порядок вершин, устанавливаемый топологической сортировкой, не уникален. Например,

8 7 0 1 2 3 6 4 9 10 11 12 5

0 1 2 3 8 6 4 9 10 11 12 5 7

0 2 3 8 6 4 7 5 9 10 1 11 12

8 0 7 6 2 3 4 9 5 1 11 12 10

— верные топологические сортировки для примера, представленного на рис. 19.6 (существует и множество других). При составлении расписаний подобная ситуация возникает всякий раз, когда одна из задач не находится в прямой или косвенной зависимости от другой задачи, и поэтому может выполняться либо до, либо после этой задачи (или даже одновременно). При увеличении количества таких пар задач количество возможных расписаний возрастает экспоненциально.

Как уже было сказано, иногда полезно рассматривать ребра орграфа по-другому: если ребро направлено из s в t, это означает, что вершина s " зависит " от вершины t. Например, вершины могут представлять определения терминов в некоторой книге — тогда ребро направлено из s в t, если в определении s используется определение t. В этом случае нужно найти упорядочение, при котором определение каждого термина дается перед тем, как оно будет использовано в другом определении. Подобное упорядочение соответствует такому выстраиванию вершин в ряд, что все ребра будут направлены справа налево — это обратная топологическая сортировка (reverse topological sort). На рис. 19.23 показана обратная топологическая сортировка на нашем примере DAG.

Но, оказывается, мы уже знакомы с алгоритмом обратной топологической сортировки: это наш старый знакомый — стандартный рекурсивный поиск в глубину! Если входным графом является DAG, то обратная нумерация размещает вершины в обратном топологическом порядке. То есть последним действием рекурсивная функция DFS нумерует каждую вершину так же, как в векторе post в программе 19.2. Как видно из рис. 19.24, эта нумерация эквивалентна обратной нумерации узлов в лесе DFS и обеспечивает топологическую сортировку: вектор post, индексированный именами вершин, выполняет перенумерацию, а обратный ему вектор (см. рис. 19.23) — обратную топологическую сортировку DAG-графа.

 Топологическая сортировка (переупорядочение)


Рис. 19.22.  Топологическая сортировка (переупорядочение)

Эта диаграмма позволяет по-другому взглянуть на топологическую сортировку, представленную на рис. 19.21: в ней определяется способ переупорядочения, а не перенумерации вершин. Если разместить вершины в порядке, указанном в массиве ts, слева направо, то все ориентированные ребра будут направлены вправо. Инверсия перестановки ts есть перестановка tsI, которая определяет перенумерацию, приведенную на рис. 19.21.

 Обратная топологическая сортировка


Рис. 19.23.  Обратная топологическая сортировка

В этой обратной топологической сортировке нашего демонстрационного орграфа все ребра направлены справа налево. Нумерация вершин, заданная обратной перестановкой tsI, порождает граф, в котором каждое ребро направлено из вершины с большим номером в вершину с меньшим номером.

 Лес DFS для DAG-графа


Рис. 19.24.  Лес DFS для DAG-графа

Лес DFS заданного орграфа не имеет обратных ребер (ребер, ведущих в узлы с большими обратными номерами) тогда и только тогда, когда этот орграф является DAG-графом. Ребра, не принадлежащие деревьям в этом лесе DFS для DAG-графа с рис. 19.21 — это либо нисходящие ребра (серые квадратики), либо поперечные ребра (белые квадратики). Последовательность посещения вершин при обратном обходе леса, показанная внизу, представляет собой обратную топологическую сортировку (см. рис. 19.23).

Лемма 19.11. Обратная нумерация при поиске в глубину порождает обратную топологическую сортировку для любого DAG-графа.

Доказательство. Предположим, что s и t — две вершины, такие, что s появляется раньше t в обратной нумерации, хотя в графе имеется направленное ребро s-t. Поскольку в момент присвоения номера вершине s для нее уже выполнен рекурсивный DFS, то, в частности, проверено и ребро s-t. Но если бы s-t было древесным, нисходящим или поперечным ребром, рекурсивный DFS для t был бы уже выполнен, и вершина t имела бы меньший номер. Однако s-t не может быть обратным ребром, поскольку это означало бы наличие цикла в графе. Полученное противоречие доказывает невозможность существования ребра s-t.

Итак, стандартный поиск в глубину можно легко адаптировать для выполнения топологической сортировки, как показано в программе 19.6. Эта реализация выполняет обратную топологическую сортировку: она вычисляет перестановку обратной нумерации и ее инверсию, чтобы клиенты могли как перенумеровывать, так и переупорядочивать вершины.

Программа 19.6. Обратная топологическая сортировка

Этот класс DFS вычисляет обратную нумерацию леса DFS (обратная топологическая сортировка). Клиенты могут использовать объект TS для перенумерации вершин DAG-графа, чтобы каждое ребро вело из вершины с большим номером в вершину с меньшим номером, либо переупорядочить вершины так, чтобы начальная вершина каждого ребра появлялась после конечной вершины (см. рис. 19.23).

  template <class Dag>
  class dagTS
    { const Dag &D;
      int cnt, tcnt;
      vector<int> pre, post, postI;
      void tsR(int v)
        { pre[v] = cnt++;
          typename Dag::adjIterator A(D, v);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (pre[t] == -1) tsR(t);
          post[v] = tcnt; postI[tcnt++] = v;
        }
    public:
      dagTS(const Dag &D) : D(D), tcnt(0), cnt(0),
        pre(D.V(), -1), post(D.V(), -1), postI(D.V(), -1)
        { for (int v = 0; v < D.V(); v++)
          if (pre[v] == -1) tsR(v);
        }
      int operator[](int v) const { return postI[v]; }
      int relabel(int v) const { return post[v]; }
    };
      

С вычислительной точки зрения различие между обычной и обратной топологической сортировкой невелико. Достаточно просто изменить операцию [] так, чтобы она возвращала значение postI[G.V()-1-v], либо изменить реализацию одним из следующих способов:

Доказательство того, что эти изменения позволяют получить правильное топологическое упорядочение, оставляем на самостоятельную проработку (см. упражнение 19.97).

Для реализации первого из перечисленных вариантов для разреженных графов (представленных списками смежности) может потребоваться программа 19.1 для вычисления обратного графа. Это удваивает объем необходимой памяти, что нежелательно в случае крупных графов. Что касается насыщенных графов (представленных матрицей смежности), то, как было сказано в разделе 19.1, можно выполнить поиск в глубину на обратном графе без дополнительной памяти и лишней работы — просто заменив строки на столбцы при обращении к матрице смежности (см. программу 19.7).

Программа 19.7. Топологическая сортировка

Если использовать данную реализацию функции tsR в программе 19.6, то конструктор вычислит топологическую сортировку, но не ее обращение (для любой реализации DAG, которая поддерживает функцию edge), поскольку она заменяет вызов edge(v, w) при поиске в глубину на edge(w, v), т.е. обрабатывает обратный граф (см. текст).

  void tsR(int v)
    { pre[v] = cnt++;
      for (int w = 0; w < D.V(); w++)
        if (D.edge(w, v))
          if (pre[w] == -1) tsR(w);
      post[v] = tcnt; postI[tcnt++] = v;
    }
      

А теперь мы рассмотрим альтернативный классический метод топологической сортировки, который больше похож на поиск в ширину (BFS, см. лекция №18). Он основан на следующем свойстве DAG-графов.

Лемма 19.12. У каждого DAG-графа имеется по меньшей мере один исток и по меньшей мере один сток.

Доказательство. Предположим, что существует DAG-граф без стоков. Тогда, начав с любой вершины, можно построить ориентированный путь произвольной длины, перейдя из этой вершины вдоль любого ребра в любую другую вершину (существует хотя бы одно такое ребро, поскольку в DAG-графе нет стоков), потом вдоль любого ребра из этой вершины и т.д. Но, в соответствии с принципом картотечного ящика, посетив V + 1 вершину, мы должны попасть в ориентированный цикл (см. лемму 19.6), что противоречит предположению о том, что граф является DAG-графом. Значит, в каждом DAG-графе имеется как минимум один сток. Отсюда также следует, что в каждом DAG-графе имеется как минимум один исток: это просто обращение стока.

Из этого факта можно вывести алгоритм топологической сортировки: пометим любой исток наименьшей неиспользованной меткой, затем удалим его и пометим остальную часть DAG-графа, используя тот же алгоритм. На рис. 19.25 рис. 19.25 показана трасса работы этого алгоритма на нашем демонстрационном DAG-графе.

Эффективная реализация этого алгоритма — классическое упражнение по проектированию структур данных (см. раздел ссылок). Во-первых, истоков может быть несколько, и понадобится очередь для их хранения (подойдет любая обобщенная очередь). Во-вторых, необходимо находить истоки в DAG-графе после удаления какого-либо истока. Эту задачу можно выполнить с помощью вектора, индексированного именами вершин, в котором учитываются степени захода каждой вершины. Вершины со степенью захода 0 являются истоками, поэтому можно инициализировать очередь одним просмотром DAG-графа (используя поиск в глубину или любой другой метод, который просматривает все ребра). Затем, пока очередь истоков не опустеет, мы выполняем следующие операции:

Программа 19.8 содержит реализацию этого метода, в которой применяется очередь FIFO, а на рис. 19.26 показан пример ее работы на нашем демонстрационном DAG-графе, который раскрывает дополнительные подробности динамики примера на рис. 19.25.

 Топологическая сортировка DAG-графа методом удаления истоков


Рис. 19.25.  Топологическая сортировка DAG-графа методом удаления истоков

Вершина 0 является истоком (на нее не указывает ни одно ребро) и поэтому может оказаться первой при топологической сортировке этого графа (слева вверху). Если удалить вершину 0 (и все ребра из нее в другие вершины), то истоками в полученном DAG-графе станут вершины 1 и 2 (слева, вторая диаграмма сверху), и этот граф можно сортировать при помощи того же алгоритма. На данном рисунке показано действие программы 19.8, которая выбирает один из истоков (серые узлы на каждой диаграмме), используя дисциплину FIFO, хотя на каждом шаге может быть выбран любой из источников. На рис. 19.26 представлено содержимое структур данных, управляющих выбором действий алгоритма. Результатом топологической сортировки, показанной на этом рисунке, является следующий порядок узлов: 0 8 2 1 7 3 6 5 4 9 11 10 12.

Программа 19.8. Топологическая сортировка с очередью истоков

Этот класс реализует тот же интерфейс, что и программы 19.6 и 19.7. В нем используется очередь истоков и таблица для хранения степеней захода всех вершин DAG-графа, индуцированного вершинами, которые еще не удалены из очереди.

При удалении истока из очереди мы уменьшаем значения степеней захода, соответствующих каждой вершине в его списке смежности (и заносим в очередь все вершины, соответствующие нулевым элементам таблицы). Вершины извлекаются из очереди в порядке топологической сортировки.

    #include "QUEUE.cc"
    template <class Dag>
    class dagTS
      { const Dag &D;
        vector<int> in, ts, tsI;
      public:
        dagTS(const Dag &D) : D(D), in(D.V(), 0), ts(D.V(), -1), tsI(D.V(), -1)
        { QUEUE<int> Q;
          for (int v = 0; v < D.V(); v++)
            { typename Dag::adjIterator A(D, v);
              for (int t = A.beg(); !A.end(); t = A.nxt())
                in[t]+ + ;
            }
          for (int v = 0; v < D.V(); v++)
            if (in[v] == 0) Q.put(v);
          for (int j = 0; !Q.empty(); j++)
            { ts[j] = Q.get(); tsI[ts[j]] = j;
              typename Dag::adjIterator A(D, ts[j]);
              for (int t = A.beg(); !A.end(); t = A.nxt())
                if (—in[t] == 0) Q.put(t);
            }
        }
        int operator[](int v) const { return ts[v]; }
        int relabel(int v) const { return tsI[v]; }
      };
      

 Таблица степеней захода и содержимое очереди


Рис. 19.26.  Таблица степеней захода и содержимое очереди

Здесь показаны содержимое таблицы степеней захода (слева) и очередь истоков (справа) во время выполнения программы 19.8 на DAG-графе, соответствующем рис. 19.25. В любой заданный момент времени очередь истоков содержит узлы с нулевыми степенями захода. Просматривая сверху вниз, мы извлекаем из очереди истоков самый левый узел, уменьшаем на единицу степени захода элементов, соответствующих каждому ребру, исходящему из этого узла, и заносим в очередь истоков все вершины, элементы которых стали равными 0. Например, вторая строка таблицы отражает результат удаления вершины 0 из очереди истоков, с последующим (поскольку DAG содержит ребра 0-1, 0-2, 0-3, 0-5 и 0-6) уменьшением на единицу элементов, соответствующих вершинам 1, 2, 3, 5 и 6, и занесением вершин 2 и 1 в очередь истоков (поскольку значения их степеней захода стали равными 0). Считывание самых левых элементов в очереди истоков сверху вниз дает топологическое упорядочение рассматриваемого графа.

Очередь истоков не опустеет, пока не будут помечены все вершины DAG-графа, т.к. подграф, индуцированный еще не помеченными вершинами, всегда является DAG-графом, а у каждого DAG-графа имеется хотя бы один исток. Вообще-то этот алгоритм можно использовать для проверки, является ли заданный граф DAG-графом: ведь если очередь опустеет до того, как все вершины будут помечены, то в подграфе, индуцированном еще непомеченными вершинами, должен существовать цикл (см. упражнение 19.104).

Обработка вершин в порядке, заданном топологической сортировкой, лежит в основе обработки DAG-графов. Классическим примером может служить задача вычисления длины самого длинного пути в DAG-графе. Рассматривая вершины в обратном топологическом порядке, легко вычислить самый длинный путь, начинающийся в каждой вершине v. Для этого нужно добавить единицу к максимальной из длин самых длинных путей, которые начинаются в каждой из вершин, достижимых из v через одно ребро. Благодаря топологической сортировке все такие длины известны во время обработки вершины v, поэтому никакие другие пути из v после этого выявлены не будут.

Например, просматривая слева направо обратную топологическую сортировку, показанную на рис. 19.23, можно быстро вычислить следующую таблицу длин максимальных путей, начинающихся в каждой вершине демонстрационного графа с рис. 19.21: 5 12 11 10 9 4 6 3 2 1 0 7 8 0 0 1 0 2 3 4 4 5 0 6 5 6

Например, значение 6, соответствующее 0 (третий столбец справа), означает, что существует путь длиной 6, начинающийся в 0. Ведь существует ребро 0-2, а ранее мы определили, что длина самого длинного пути из вершины 2 равна 5, и что ни одно из ребер, исходящих из 0, не ведет в узел, имеющий более длинный путь.

При использовании топологической сортировки для подобных приложений необходимо реализовать один из следующих способов разработки:

Все эти методы используются в реализациях, выполняющих обработку DAG — важно только знать, что все они эквивалентны. Другие приложения топологической сортировки будут рассмотрены в упражнениях 19.111 и 19.114 и в разделах 19.7 и 21.4.

Упражнения

19.92. Напишите функцию, которая проверяет, является ли заданная перестановка вершин DAG-графа верной топологической сортировкой этого графа.

19.93. Сколько возможно различных топологических сортировок для DAG-графа, изображенного на рис. 19.6?

19.94. Приведите лес DFS и обратную топологическую сортировку, которые получаются в результате выполнения стандартного поиска в глубину по спискам смежности (с обратной нумерацией) следующего DAG-графа:

3-71-47-80-55-23-82-90-64-92-66-44-32-3.

19.95. Приведите лес DFS и обратную топологическую сортировку, которые получаются при построении стандартного представления списками смежности для DAG-графа

3-71-47-80-55-23-82-90-64-92-66-44-32-3

с последующим обращением с помощью программы 19.1 и выполнением поиска в глубину по спискам смежности с обратной нумерацией.

19.96. В программе 19.6 для выполнения обратной топологической сортировки используется обратная нумерация. Почему нельзя воспользоваться прямой нумерацией? Обоснуйте свой ответ на примере графа с тремя вершинами.

19.97. Докажите правильность каждого из трех предложенных в тексте модификаций поиска в глубину с обратной нумерацией — то есть что вычисляется именно прямая, а не обратная топологическая сортировка.

19.98. Приведите лес DFS и топологическую сортировку, которые получаются в результате выполнения стандартного поиска в глубину с неявным обращением на представлении списками смежности (и обратной нумерацией) для следующего DAG-графа:

3-71-47-80-55-23-82-90-64-92-66-44-32-3(см.про грамму19.7).

19.99. Пусть задан некоторый DAG-граф. Существует ли топологическая сортировка, которую нельзя получить алгоритмом на основе поиска в глубину, независимо от порядка выбора вершин, смежных с текущей? Обоснуйте свой ответ.

19.100. Покажите в стиле рис. 19.26 процесс топологической сортировки DAG-графа

3-71-47-80-55-23-82-90-64-92-66-44-32-3 с помощью алгоритма, использующего очередь истоков (программа 19.8).

19.101. Приведите топологическую сортировку, которая получится, если в примере, представленном на рис. 19.25, в качестве структуры данных использовать стек, а не очередь.

19.102. Пусть задан некоторый DAG-граф. Существует ли топологическая сортировка, которую нельзя получить алгоритмом на основе поиска в глубину, независимо от дисциплины, реализуемой очередью? Обоснуйте свой ответ.

19.103. Измените алгоритм топологической сортировки с очередью истоков, чтобы в нем использовалась обобщенная очередь. Воспользуйтесь модифицированным алгоритмом с очередью LIFO, стеком и рандомизированной очередью.

19.104. Воспользуйтесь программой 19.8 для реализации класса, проверяющего отсутствие циклов в заданном DAG-графе (см. упражнение 19.75).

19.105. Преобразуйте алгоритм топологической сортировки с очередью истоков в алгоритм с очередью стоков для выполнения обратной топологической сортировки.

19.106. Напишите программу, которая генерирует все возможные топологические упорядочения заданного DAG-графа, либо, если количество таких упорядочений превышает границу, заданную в качестве аргумента, просто выводит это число.

19.107. Напишите программу, которая преобразует любой орграф с V вершинами и E ребрами в DAG-граф, выполнив топологическую сортировку на основе DFS и изменяя направление каждого встреченного обратного ребра. Докажите, что эта стратегия всегда приводит к созданию DAG-графа.

19.108. Напишите программу, которая генерирует с равной вероятностью один из возможных DAG-графов с V вершинами и E ребрами (см. упражнение 17.70).

19.109. Сформулируйте необходимые и достаточные условия существования для конкретного DAG-графа только одного возможного топологического упорядочения его вершин.

19.110. Эмпирически сравните алгоритмы топологической сортировки, приведенные в этом разделе, для различных DAG-графов (см. упражнения 19.2, 19.76, 19.107 и 19.108). Протестируйте свою программу, как описано в упражнении 19.11 (для разреженных графов) и в упражнении 19.12 (для насыщенных графов).

19.111. Измените программу 19.8 так, чтобы она могла вычислять количество различных простых путей из любого истока в каждую вершину DAG-графа.

19.112. Напишите класс, который вычисляет DAG-графы, представляющие арифметические выражения (см. рис. 19.19 рис. 19.19). Для хранения значений, соответствующих каждой вершине, используйте вектор, индексированный именами вершин. Предполагается, что значения, соответствующие листьям, заданы заранее.

19.113. Опишите семейство арифметических выражений, таких, что размер дерева выражения экспоненциально больше, чем размер соответствующего DAG-графа (и поэтому время выполнения программы из упражнения 19.112 для этого DAG пропорционально логарифму времени выполнения для дерева).

19.114. Разработайте метод поиска простого ориентированного пути максимальной длины в DAG-графе за время, пропорциональное V. Используйте этот метод для реализации класса, выполняющего вывод гамильтонова пути в заданном DAG-графе, если он существует.

Достижимость в DAG-графах

В завершение нашего изучения DAG-графов мы рассмотрим задачу вычисления транзитивного замыкания DAG-графа. Можно ли разработать алгоритмы для DAG-графов, более эффективные, чем алгоритмы для обобщенных орграфов, которые были рассмотрены в разделе 19.3?

Любой метод топологической сортировки может служить основой алгоритмов транзитивного замыкания DAG: мы просматриваем вершины в обратном топологическом порядке, вычисляя при этом вектор достижимости для каждой вершины (то есть строку матрицы транзитивного замыкания) из строк, соответствующих смежным с ней вершинам. Обратная топологическая сортировка гарантирует, что все эти строки уже вычислены тогда, когда они нужны. Мы проверяем каждый из V элементов вектора, соответствующих конечным вершинам каждого из E ребер, на что в общем нужно время, пропорциональное VE. Этот метод легко реализовать, но он обрабатывает DAG-графы ничуть не эффективнее, чем орграфы общего вида.

При использовании стандартного поиска в глубину для топологической сортировки (см. программу 19.7) можно повысить ее производительность для некоторых видов DAG-графов, как показано в программе 19.9. Поскольку в DAG-графах не может быть циклов, то при поиске в глубину не может быть обратных ребер. Однако важнее то, что как поперечные, так и нисходящие ребра ведут в узлы, в которых DFS уже завершен. Чтобы воспользоваться этим, мы разработаем рекурсивную функцию, которая вычисляет все вершины, достижимые из заданной исходной вершины, но (как обычно при поиске в глубину) без рекурсивных вызовов для вершин, для которых уже вычислено множество достижимых вершин. В этом случае достижимые вершины представлены одной из строк транзитивного замыкания, а рекурсивная функция выполняет операцию логического ИЛИ над всеми строками, соответствующими смежным вершинам. В случае древесных ребер выполняется рекурсивный вызов для вычисления этой строки; в случае поперечных ребер можно пропустить рекурсивный вызов, т.к. мы знаем, что эта строка уже была вычислена в результате предыдущего рекурсивного вызова; в случае нисходящих ребер можно пропустить все вычисление, поскольку любые достижимые узлы, которые могут быть при этом добавлены, уже учтены в множестве достижимых узлов для конечной вершины (ниже и раньше в дереве DFS).

 Транзитивное замыкание DAG-графа


Рис. 19.27.  Транзитивное замыкание DAG-графа

Данная последовательность векторов-строк представляет транзитивное замыкание DAG-графа с рис. 19.21. Эти строки вычислены в обратном топологическом порядке последним действием рекурсивной функции DFS (см. программу 19.9). Каждая строка является результатом логической операции ИЛИ над строками для смежных вершин, которые находятся раньше в списке. Например, чтобы вычислить строку для вершины 0, мы выполняем логическую ИЛИ над строками для вершин 5, 2, 1 и 6 (и заносим значение 1, соответствующее самой вершине 0), т.к.ребра 0-5, 0-2, 0-1 и 0-6 приводят нас из вершины 0 в вершины, достижимые из каждой из этих вершин. Нисходящие ребра можно игнорировать, поскольку они не добавляют новой информации. Например, мы игнорируем ребро, ведущее из 0 в 3, потому что вершины, достижимые из 3, уже учтены в строке, соответствующей вершине 2.

Использование этого варианта поиска в глубину можно считать применением динамического программирования для вычисления транзитивного замыкания, т.к. мы используем уже вычисленные (и сохраненные в строках матрицы смежности, которые соответствуют ранее обработанным вершинам) результаты, чтобы не выполнять ненужных рекурсивных вызовов. На рис. 19.27 показано вычисление транзитивного замыкания DAG-графа с рис. 19.6.

Лемма 19.13. С помощью динамического программирования и поиска в глубину можно обеспечить постоянное время ответа на запросы относительно абстрактного транзитивного замыкания DAG-графа, затратив на предварительную обработку (вычисление транзитивного замыкания) объем памяти, пропорциональный V2, и время, пропорциональное V2 + VX , где X —количество поперечных ребер в лесе DFS.

Доказательство. Доказательство непосредственно следует по индукции из рекурсивной функции, приведенной в программе 19.9. Мы посещаем вершины в обратном топологическом порядке. Каждое ребро указывает на вершину, для которой уже вычислено множество всех достижимых вершин — поэтому мы можем вычислить множество достижимых вершин для любой вершины путем слияния множеств достижимых вершин, связанных с конечными вершинами каждого ребра. Завершает это слияние логическое ИЛИ над указанными строками матрицы смежности. Мы обращаемся к строке размером V каждого древесного ребра и каждого поперечного ребра. В рассматриваемом случае обратные ребра отсутствуют, а нисходящие ребра можно игнорировать, поскольку все вершины, в которые они приводят, уже учтены ранее — при обработке предшественников обеих вершин этих ребер.

Программа 19.9. Транзитивное замыкание DAG-графа

Конструктор этого класса вычисляет транзитивное замыкание DAG при помощи одного поиска в глубину. Он рекурсивно вычисляет вершины, достижимые из всех потомков каждой вершины в дереве DFS.

  template <class tcDag, class Dag>
  class dagTC
    { tcDag T; const Dag &D;
      int cnt;
      vector<int> pre;
      void tcR(int w)
        { pre[w] = cnt++;
          typename Dag::adjIterator A(D, w);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            { T.insert(Edge(w, t));
              if (pre[t] > pre[w]) continue;
              if (pre[t] == -1) tcR(t);
              for (int i = 0; i < T.V(); i++)
                if (T.edge(t, i)) T.insert(Edge(w, i));
            }
        }
    public:
      dagTC(const Dag &D) : D(D), cnt(0),
        pre(D.V(), -1), T(D.V(), true)
        { for (int v = 0; v < D.V(); v++)
          if (pre[v] == -1) tcR(v);
        }
      bool reachable(int v, int w) const
        { return T.edge(v, w); }
    } ;
      

Если в DAG-графе нет нисходящих ребер (см. упражнение 19.42), время выполнения программы 19.9 пропорционально VE — т.е. она ничем не лучше алгоритмов транзитивного замыкания для орграфов общего вида из раздела 19.3 (см., например, программу 19.4) или подхода на основе топологической сортировки, которая описана в начале данного раздела. Однако при большом количестве нисходящих ребер (или, что одно и то же, при малом количестве поперечных ребер) программа 19.9 работает значительно быстрее этих методов.

Задача поиска оптимального алгоритма (гарантирующего выполнение за время, пропорциональное V2 ) вычисления транзитивного замыкания для насыщенных DAG-графов все еще не решена. Лучшая известная граница производительности для худшего случая равна VE. Но, конечно, лучше использовать алгоритм, который работает быстрее для большого класса DAG-графов — например, программу 19.9 — чем алгоритм, который, как программа 19.4, всегда выполняется за время, пропорциональное VE. В разделе 19.9 мы убедимся, что такое повышение производительности для DAG-графов оказывает непосредственное влияние и на нашу способность вычислять транзитивные замыкания орграфов общего вида.

Упражнения

19.115. Покажите в стиле рис. 19.27 векторы достижимости, если для вычисления транзитивного замыкания DAG-графа

3-71-47-80-55-23-82-90-64-92-66-44-32-3

использовать программу 19.9.

19.116. Разработайте такую версию программы 19.9, которая использует представление транзитивного замыкания без поддержки функции проверки edge и которая выполняется за время, пропорциональное , где суммирование проводится по всем ребрам DAG-графа, а v(e) — количество вершин, достижимых из конечной вершины ребра е. Для некоторых видов разреженных DAG-графов такие затраты существенно меньше, чем VE (см. упражнение 19.65).

19.117. Реализуйте класс абстрактного транзитивного замыкания для DAG-графов, который использует дополнительный объем памяти, не более чем пропорциональный V (и пригодный для работы с крупными DAG-графами). Воспользуйтесь топологической сортировкой для получения быстрого ответа, если вершины не связаны, а также реализацией очереди истоков, чтобы возвращать длину пути, когда вершины связаны.

19.118. Разработайте реализацию транзитивного замыкания на основе обратной топологической сортировки с очередью стоков (см. упражнение 19.105).

19.119. Требует ли ваше решение задачи 19.118 просмотра всех ребер DAG-графа или игнорирует некоторые ребра — например, нисходящие ребра в DFS? Приведите пример, когда необходим просмотр всех ребер, или опишите ребра, которые можно пропустить.

Сильные компоненты в орграфах

Неориентированные графы и DAG-графы — более простые структуры данных, чем орграфы общего вида, в силу структурной симметрии, которая характеризует отношения достижимости среди вершин: из того, что в неориентированном графе существует путь из s в t, следует, что существует путь из t в s; если в DAG-графе существует путь из s в t, то мы знаем, что путь из t в s не существует. В случае орграфов общего вида тот факт, что вершина t достижима из s, не несет никакой информации о достижимости s из t.

Чтобы понять структуру орграфов, мы рассмотрим сильную связность (strong connectivity), обладающую интересующей нас симметрией. Если s и t являются сильно связанными вершинами (каждая из них достижима из другой), то, по определению, таковыми являются и t и s. Как было сказано в разделе 19.1, из такой симметрии следует, что вершины орграфа разбиваются на классы сильных компонентов, состоящие из взаимно достижимых вершин. В этом разделе мы рассмотрим три алгоритма поиска сильных компонентов в орграфах.

Мы будем использовать тот же интерфейс, что и для задачи связности в алгоритмах поиска на неориентированных графах общего вида (см. программу 18.4). Назначение наших алгоритмов в том, чтобы присвоить номера компонентов каждой вершине в векторе, индексированном именами вершин, используя для обозначения сильных компонентов числа 0, 1, ... . Наибольший из присваиваемых номеров равен числу, на единицу меньшему количества сильных компонентов. Эти номера компонентов можно использовать для проверки за постоянное время, входят ли две заданные вершины в один и тот же сильный компонент.

Нетрудно разработать примитивный алгоритм решения этой задачи. С помощью АТД абстрактного транзитивного замыкания проанализируем каждую пару вершин s и t: достижима ли t из s и достижима ли s из t. Затем определим неориентированный граф с ребрами для каждой такой пары: связные компоненты этого графа соответствуют сильным компонентам орграфа. Этот алгоритм нетрудно описать и реализовать, а время его выполнения определяется в основном реализацией абстрактно-транзитивного замыкания, как сказано, скажем, в лемме 19.10.

Алгоритмы, которые мы рассмотрим в данном разделе — воплощение последних достижений в области построения алгоритмов. Они могут найти сильные компоненты любого графа за линейное время, т.е. в Vраз быстрее примитивного алгоритма. Для графов, содержащих 100 вершин, эти алгоритмы работают в 100 раз быстрее примитивного алгоритма, а для 1000 вершин — в 1000 раз быстрее, и мы можем решать подобные задачи для графов с миллионами вершин. Эта задача является характерным примером хорошего проектирования алгоритма, и мощным стимулом пристального изучения алгоритмов на графах. Где еще можно сэкономить используемые ресурсы в миллион и более раз за счет выбора элегантного алгоритма решения практически важной задачи?

История этой задачи достаточно поучительна (см. раздел ссылок). В пятидесятые и шестидесятые годы математики и специалисты по вычислительной технике приступили к серьезному изучению алгоритмов на графах. В это время как раз развивался сам анализ алгоритмов как область исследований. Во время бурного развития компьютерных систем, языков и формирования понятия эффективных вычислений приходилось рассматривать разнообразные алгоритмы обработки графов, однако многие такие задачи оставались нерешенными. Постепенно компьютерные специалисты стали постигать многие базовые принципы анализа алгоритмов и стали понимать, какие задачи на графах могут быть решены эффективно, а для каких задач это невозможно, и начали разрабатывать все более эффективные алгоритмы решения для первого набора задач. Р. Тарьян (R. Tarjan) предложил линейные по времени алгоритмы решения задачи сильной связности и других задач на графах в 1972 году, и в этом же году Р Карп (R. Karp) доказал невозможность эффективного решения задачи коммивояжера и многих других задач на графах. Долгое время с алгоритма Тарьяна начинались многие продвинутые курсы по анализу алгоритмов, поскольку он решает важную практическую задачу, используя простые структуры данных. В 1980-х годах Р Косарайю (R. Kosaraju) рассмотрел эту задачу с другой стороны и разработал новое решение; позднее оказалось, что статья с описанием этого же метода была опубликована в советской научной литературе намного раньше — в 1972 г. Позже, в 1999 г., Г. Габову (H. Gabov) удалось получить простую реализацию одного из первых подходов, предложенных в шестидесятых годах, то есть третий линейный по времени алгоритм для этой задачи.

Суть всего этого не только в том, что трудные задачи обработки графов могут иметь простые решения, но и в том, что абстракции, которыми мы пользуемся (поиск в глубину и списки смежности), гораздо мощнее, чем мы можем предполагать. Так что не стоит удивляться, что по мере освоения этих и других подобных средств будут обнаруживаться новые простые решения и других важных задач на графах. Исследователи продолжают поиски подобных компактных реализаций для многих других важных алгоритмов на графах, и много таких алгоритмов еще предстоит открыть.

Метод Косарайю прост для понимания и реализации. Чтобы найти сильные компоненты графа, сначала выполняется поиск в глубину на его обращении, который вычисляет перестановку вершин, определенных обратной нумерацией. (Такой процесс представляет собой топологическую сортировку, если орграф является DAG-графом.) Затем выполняется еще один DFS на этом графе, но чтобы найти следующую вершину для поиска (при вызове рекурсивной функции поиска — в самом начале и при каждом возврате управления рекурсивной функцией в функцию поиска более высокого уровня), берется непосещенная вершина с максимальным обратным номером.

Фокус в этом алгоритме заключается в том, что при такой проверке непосещенных вершин в соответствии с топологической сортировкой деревья в лесе DFS определяют сильные компоненты — так же, как деревья в лесе DFS определяют связные компоненты в неориентированных графах: две вершины принадлежат одному и тому же сильному компоненту тогда и только тогда, когда они принадлежат одному и тому же дереву в этом лесе. На рис. 19.28 демонстрируется этот факт для нашего примера, а доказательство будет приведено чуть ниже. Поэтому можно пронумеровать компоненты, как в случае неориентированных графов, увеличивая номер компонента на единицу при каждом возврате рекурсивной функции на более высокий уровень. В программе 19.10 приведена полная реализация этого метода.

 Вычисление сильных компонентов (алгоритм Косарайю)


Рис. 19.28.  Вычисление сильных компонентов (алгоритм Косарайю)

Чтобы вычислить сильные компоненты орграфа, изображенного внизу слева, мы сначала выполняем поиск в глубину на его обращении (вверху слева), вычисляя вектор обратных номеров, который присваивает вершинам индексы в порядке завершения рекурсивных DFS (вверху). Этот порядок эквивалентен обратному обходу леса DFS (вверху справа). Затем с помощью обращения этого порядка выполняется поиск в глубину на исходном графе (внизу). Сначала мы проверяем все узлы, доступные из вершины 9, затем просматриваем вектор справа налево и находим, что самая правая непосещенная вершина — это 1, поэтому мы выполняем рекурсивный вызов для вершины 1 и т.д. Деревья в лесе DFS, полученные в результате этого процесса, определяют сильные компоненты: все вершины каждого дерева имеют одинаковые значения в векторе id, индексированном именами вершин (внизу).

Программа 19.10. Сильные компоненты (алгоритм Косарайю)

Клиенты могут использовать объекты этого класса для определения количества сильных компонентов орграфа (count) и проверок на сильную связность (stronglyreachable). Конструктор SC сначала создает обратный орграф и вычисляет с помощью DFS обратную нумерацию. Далее он выполняет поиск в глубину на исходном орграфе, используя обращение обратного порядка из первого поиска в глубину в цикле поиска, в котором выполняются вызовы рекурсивной функции. Каждый рекурсивный вызов во втором DFS посещает все вершины сильного компонента.

  template <class Graph>
  class SC
    { const Graph &G;
      int cnt, scnt;
      vector<int> postI, postR, id;
      void dfsR(const Graph &G, int w)
        { id[w] = scnt;
          typename Graph::adjIterator A(G, w);
          for (int t = A.beg(); !A.end(); t = A.nxt())
            if (id[t] == -1) dfsR(G, t);
          postI[cnt++] = w;
        }
    public:
      SC(const Graph &G) : G(G), cnt(0), scnt(0),
        postI(G.V()), postR(G.V()), id(G.V(), -1)
        { Graph R(G.V(), true);
          reverse(G, R);
          for (int v = 0; v < R.V(); v++)
            if (id[v] == -1) dfsR(R, v);
          postR = postI; cnt = scnt = 0;
          id.assign(G.V(), -1);
          for (int v = G.V()-1; v >= 0; v--)
            if (id[postR[v]] == -1)
              { dfsR(G, postR[v]); scnt++; }
        }
      int count() const { return scnt; }
      bool stronglyreachable(int v, int w) const
        { return id[v] == id[w]; }
    };
      

Лемма 19.14. Метод Косарайю находит сильные компоненты графа с линейными затратами времени и памяти.

Доказательство. Этот метод состоит из двух процедур DFS с небольшими изменениями, поэтому время его выполнения, как обычно, пропорционально V2 для насыщенных графов и V + E для разреженных графов (в случае представления списками смежности). Чтобы доказать, что он правильно вычисляет сильные компоненты, необходимо доказать, что во втором поиске две вершины s и t находятся в одном и том же дереве леса DFS тогда и только тогда, когда они взаимно достижимы.

Если вершины s и t взаимно достижимы, они обязательно находятся в одном и том же дереве DFS: когда первая из них посещена, вторая еще не посещена и достижима из первой. Поэтому она будет просмотрена до завершения рекурсивного вызова из корня.

Чтобы доказать обратное утверждение, предположим, что s и t находятся в одном и том же дереве, и пусть r — корень этого дерева. Из достижимости s из r (через ориентированный путь, состоящий из древесных ребер) следует, что в обратном орграфе существует ориентированный путь из s в r. В обратном орграфе должен существовать и путь из r в s, поскольку r имеет больший обратный номер, чем s (т.к. при втором поиске в глубину r была выбрана первой, когда обе вершины еще не были посещены), и существует путь из s в r. Ведь если бы пути из s в r не было, то путь из s в r в обратном орграфе оставил бы вершине s больший обратный номер. Итак, существуют ориентированные пути из s в r и из r в s — как в орграфе, так и в его обращении — то есть s и r сильно связаны. Аналогичные рассуждения доказывают, что t и r сильно связаны, и поэтому s и t также сильно связаны.

Реализация алгоритма Косарайю для орграфа, представленного матрицей смежности, даже проще, чем программа 19.10, поскольку в этом случае не нужно явно вычислять обратный граф. Решение этой задачи мы оставляем на самостоятельную проработку (см. упражнение 19.125).

Программа 19.10 представляет собой оптимальное решение задачи сильной связности, аналогичное решениям задачи связности, рассмотренным в лекция №18. В разделе 19.9 будет рассмотрено расширение этого решения для вычисления транзитивного замыкания и решения задачи достижимости (абстрактного транзитивного замыкания) в орграфах.

Но вначале мы рассмотрим алгоритм Тарьяна и алгоритм Габова — хитроумные методы, требующие лишь небольших изменений в нашей базовой процедуре поиска в глубину. Они удобнее алгоритма Косарайю, поскольку используют только один проход по графу и не требуют обращения разреженных графов.

Алгоритм Тарьяна похож на программу из лекция №17 для поиска мостов в неориентированных графах (см. программу 18.7). Этот метод основан на двух наблюдениях, которые были сделаны в других контекстах. Во-первых, мы рассматриваем вершины в обратном топологическом порядке, чтобы в конце работы рекурсивной функции для вершины мы знали, что не встретим ни одной вершины из того же сильного компонента (потому что все вершины, достижимые из этой, уже обработаны). Во-вторых, обратные ссылки в дереве обеспечивают второй путь из одной вершины в другую и связывают сильные компоненты.

Для поиска достижимой вершины с максимальным номером (через обратную ссылку) из любого потомка каждой вершины в рекурсивной функции DFS выполняются те же вычисления, что и в программе 18.7. В ней также используется вектор, индексированный именами вершин, для отслеживания сильных компонентов и стек для хранения текущего пути поиска. Имена вершин заносятся в стек при входе в рекурсивную функцию, а после посещения последнего элемента каждого сильного компонента они выталкиваются из стека, при этом назначаются номера компонентам. Алгоритм основан на возможности определять этот момент при помощи простой проверки в конце рекурсивной процедуры (для этого отслеживается предок с максимальным номером, достижимый по одной восходящей ссылке из всех потомков каждого узла). Эта проверка сообщает, что все вершины, встреченные с момента входа (за исключением тех, которые уже приписаны какому-либо компоненту), принадлежат тому же сильному компоненту.

Реализация в программе 19.11 представляет собой полное описание рассматриваемого алгоритма, со всеми подробностями, которых нет в вышеприведенном общем описании. На рис. 19.29 показана работа алгоритма на нашем демонстрационном орграфе с рис. 19.1.

Программа 19.11. Сильные компоненты (алгоритм Тарьяна)

Данный класс DFS — еще одна реализация того же интерфейса, что и в программе 19.10. Он использует стек S для хранения каждой вершины до тех пор, пока не выяснится, что все вершины в верхней части стека до определенного уровня принадлежат одному и тому же сильному компоненту. Вектор low, индексированный именами вершин, отслеживает вершину с наименьшим прямым номером, достижимую из каждого узла через ряд нисходящих ссылок, за которыми следует одна восходящая ссылка (см. текст).

  #include "STACK.cc"
  template <class Graph>
  class SC
    { const Graph &G;
      STACK<int> S;
      int cnt, scnt;
      vector<int> pre, low, id;
      void scR(int w)
        { int t;
          int min = low[w] = pre[w] = cnt+ + ;
          S.push(w);
          typename Graph::adjIterator A(G, w);
          for (t = A.beg(); !A.end(); t = A.nxt())
            { if (pre[t] == -1) scR(t);
              if (low[t] < min) min = low[t];
            }
          if (min < low[w]) { low[w] = min; return; }
          do
            { id[t = S.pop()] = scnt; low[t] = G.V(); }
          while ( t ! = w) ;
          scnt++;
       }
    public:
      SC(const Graph &G) : G(G), cnt(0), scnt(0),
        pre(G.V(), -1), low(G.V()), id(G.V())
        { for (int v = 0; v < G.V(); v++)
            if (pre[v] == -1) scR(v);
        }
      int count() const { return scnt; }
      bool stronglyreachable(int v, int w) const
        { return id[v] == id[w]; }
    };
      

 Вычисление сильных компонентов (алгоритмы Тарьяна и Габова)


Рис. 19.29.  Вычисление сильных компонентов (алгоритмы Тарьяна и Габова)

В основе алгоритма Тарьяна лежит рекурсивный DFS с дополнительным помещением вершин в стек. Он вычисляет индекс компонента для каждой вершины в векторе id, индексированном именами вершин, используя вспомогательные векторы pre и low (в центре). Дерево DFS для нашего демонстрационного графа показано в верхней части рисунка, а трассировка ребер — внизу слева. В центре нижней части приведено содержимое главного стека: в него заносятся вершины, достижимые через древесные ребра. Используя DFS для перебора вершин в обратном топологическом порядке, мы вычисляем для каждой вершины v максимальную точку, достижимую через обратную ссылку из предшественника (low[v]). Когда для вершины v выполняется pre[v]= low[v] (в данном случае это вершины 11, 1, 0 и 7), мы выталкиваем из стека ее и все вершины выше нее, и присваиваем им всем номер следующего компонента.

В алгоритме Габова мы заносим вершины в главный стек — как и в алгоритме Тарьяна — но параллельно заносим во второй стек (внизу справа) вершины, лежащие на пути поиска, о которых известно, что они находятся в других сильных компонентах, и выталкиваем все вершины после достижения каждого обратного ребра. Когда мы завершаем обработку вершины v (v находится на верхушке второго стека — на рисунке заштриховано), мы знаем, что все вершины, расположенные над v в главном стеке, находятся в одном и том же сильном компоненте.

Лемма 19.15. Алгоритм Тарьяна находит сильные компоненты орграфа за линейное время.

Схема доказательства. Если у вершины s нет потомков или восходящих ссылок в дереве DFS, либо если у нее есть потомок в дереве DFS с восходящей ссылкой, которая указывает на s, и нет потомков с восходящими ссылками, которые направлены в более высокую часть дерева, то она и все ее потомки (кроме вершин, которые удовлетворяют тому же свойству, и их потомков) составляют сильный компонент. Чтобы установить этот факт, заметим, что у каждого потомка t вершины s, который не удовлетворяет указанному свойству, имеется потомок с восходящей ссылкой, указывающей на вершину, которая находится в дереве выше t.

В дереве имеется нисходящий путь из s в t, а путь из t в s можно найти следующим образом: спускаемся по дереву из t в вершину с восходящей ссылкой, которая указывает выше t, затем продолжаем такой же процесс из этой вершины, пока не дойдем до s.

Как обычно, этот метод линеен по времени, поскольку он состоит из стандартного DFS с несколькими дополнительными операциями, выполняющимися за постоянное время.

В 1999 году Габов разработал версию алгоритма Тарьяна, приведенную в программе 19.12. Этот алгоритм использует такой же стек вершин и тем же способом, что и алгоритм Тарьяна, но он использует второй стек (вместо вектора прямых номеров, индексированного именами вершин) — для определения момента, когда нужно выталкивать из главного стека все вершины каждого сильного компонента. Этот второй стек содержит вершины, входящие в путь поиска. Когда обратное ребро показывает, что последовательность таких вершин целиком принадлежит одному и тому же сильному компоненту, мы выталкиваем содержимое этого стека, оставляя в нем только конечную вершину обратного ребра, которая находится ближе к корню дерева, чем любая другая вершина. После обработки всех ребер каждой вершины (выполняя рекурсивные вызовы всех ребер дерева, выталкивая из стека пути для обратных ребер и игнорируя прямые ребра), мы проверяем, находится ли текущая вершина в верхушке стека пути. Если это так, то она и все вершины над ней в главном стеке составляют сильный компонент — мы выталкиваем их из стека и присваиваем им номер следующего сильного компонента, как и в алгоритме Тарьяна.

Пример на рис. 19.29 показывает содержимое этого второго стека. То есть эта диаграмма может служить иллюстрацией работы алгоритма Габова.

Программа 19.12. Сильные компоненты (алгоритм Габова)

Данная альтернативная реализация рекурсивной функции-члена из программы 19.11 использует вместо вектора low, индексированного номерами вершин, второй стек path, чтобы определять, когда нужно выталкивать из главного стека вершины каждого сильного компонента (см. текст).

  void scR(int w)
    { int v; 
      pre[w] = cnt++;
      S.push(w); path.push(w);
      typename Graph::adjIterator A(G, w);
      for (int t = A.beg(); !A.end(); t = A.nxt())
        if (pre[t] == -1)
          scR(t);
        else if (id[t] == -1)
          while (pre[path.top()] > pre[t]) path.pop();
      if (path.top() == w) path.pop(); else return;
      do { id[v = S.pop()] = scnt; } while (v != w);
      scnt++;
    }
      

Лемма 19.16. Алгоритм Габова находит сильные компоненты орграфа за линейное время.

Формализация вышеизложенного рассуждения и доказательство взаимосвязи содержимого стеков, на котором оно основано — полезное упражнение для математически подготовленных читателей (см. упражнение 19.132). Этот метод также линеен по времени, поскольку он состоит из стандартного DFS с несколькими дополнительными операциями, выполняющимися за постоянное время.

Все рассмотренные в данном разделе алгоритмы выявления сильных компонентов оригинальны и обманчиво просты. Мы рассмотрели все три алгоритма, поскольку они демонстрируют мощь фундаментальных структур данных и тщательно разработанных рекурсивных программ. С практической точки зрения время выполнения всех этих алгоритмов пропорционально количеству ребер орграфа, и различия в производительности больше зависят от деталей реализации. Например, внутренний цикл алгоритмов Тарьяна и Габова составляют операции АТД стека магазинного типа. В нашей реализации используются простейшие реализации класса стека из лекция №4; реализации, использующие контейнеры stack из библиотеки STL, которые выполняют разные проверки и других действия, могут работать медленнее. Реализация алгоритма Косарайю — пожалуй, простейшая из всех трех, но она содержит небольшой недостаток (для разреженных графов): в ней выполняются три прохода по ребрам (один проход для построения обратного графа и два прохода поиска в глубину).

Теперь мы рассмотрим ключевое приложение, вычисляющее сильные компоненты: построение АТД эффективной достижимости (абстрактное транзитивное замыкание) в орграфах.

Упражнения

19.120. Опишите, что произойдет, если воспользоваться алгоритмом Косарайю для поиска сильных компонентов DAG-графа.

19.121. Опишите, что произойдет, если воспользоваться алгоритмом Косарайю для поиска сильных компонентов орграфа, который состоит из одного цикла.

19.122. Можно ли не вычислять обращение орграфа в версии метода Косарайю для представления списками смежности (программа 19.10), используя одну из трех технологий, описанных в разделе 19.4, которые позволяют не вычислять обращение при выполнении топологической сортировки? Для каждой технологии либо докажите, что она работает, либо приведите контрпример, показывающий, что она не работает.

19.123. Покажите в стиле рис. 19.28 леса DFS и содержимое вспомогательных векторов, индексированных именами вершин, которые получаются при использовании алгоритма Косарайю для вычисления сильных компонентов обращения орграфа с рис. 19.5. (Должны получиться те же самые сильные компоненты.)

19.124. Покажите в стиле рис. 19.28 леса DFS и содержимое вспомогательных векторов, индексированных именами вершин, которые получаются при использовании алгоритма Косарайю для вычисления сильных компонентов обращения орграфа

3-71-47-80-55-23-82-90-64-92-66-4.

19.125. Реализуйте алгоритм Косарайю для поиска сильных компонентов орграфа в представлении, которое поддерживает проверку существования ребра. Реализация не должна содержать явное вычисление обращения графа. Указание. Рассмотрите возможность использования двух различных рекурсивных функций DFS.

19.126. Опишите, что произойдет, если использовать алгоритм Тарьяна для поиска сильных компонентов DAG-графа.

19.127. Опишите, что произойдет, если использовать алгоритм Тарьяна для поиска сильных компонентов орграфа, который состоит из одного цикла.

19.128. Покажите в стиле рис. 19.28 лес DFS, содержимое стека во время выполнения алгоритма и окончательное содержимое вспомогательных векторов, индексированных именами вершин, которые получаются при использовании алгоритма Тарьяна для вычисления сильных компонентов обращения орграфа с рис. 19.5. (Должны получиться те же самые сильные компоненты.)

19.129. Покажите в стиле рис. 19.28 лес DFS, содержимое стека во время выполнения алгоритма и окончательное содержимое вспомогательных векторов, индексированных именами вершин, которые получаются при использовании алгоритма Тарья-на для вычисления сильных компонентов орграфа

3-71-47-80-55-23-82-90-64-92-66-4.

19.130. Измените реализации алгоритма Тарьяна в программе 19.11 и алгоритма Габова в программе 19.12, чтобы они могли использовать сигнальные значения и не выполнять явную проверку поперечных ссылок.

19.131. Покажите в стиле рис. 19.29 лес DFS, содержимое обоих стеков во время выполнения алгоритма и окончательное содержимое вспомогательных векторов, индексированных именами вершин, которые получаются при использовании алгоритма Габова для вычисления сильных компонентов орграфа

3-71-47-80-55-23-82-90-64-92-66-4.

19.132. Приведите полное доказательство леммы 19.16.

19.133. Разработайте версию алгоритма Габова, которая находит мосты и реберносвязные компоненты в неориентированных графах.

19.134. Разработайте версию алгоритма Габова, который находит точки сочленения и двусвязные компоненты в неориентированных графах.

19.135. Составьте таблицу в стиле таблица 18.1 для изучения сильных компонентов в случайных графах (см. таблица 19.2). Пусть S — множество вершин в наибольшем сильном компоненте. Изменяя размер множества S, проанализируйте процент ребер в следующих четырех классах: соединяющие две вершины внутри S, указывающие на вершины вне S, указывающие на вершины внутри S, и соединяющие две вершины вне S.

19.136. Эмпирически сравните примитивный метод вычисления сильных компонентов, описанный в начале этого раздела, с алгоритмом Косарайю, с алгоритмом Тарьяна и с алгоритмом Габова для различных типов орграфов (см. упражнения 19.11-19.18).

19.137. Разработайте линейный по времени алгоритм для задачи сильной 2-связности: нужно определить, остается ли сильно связанный орграф сильно связным после удаления любой вершины (и всех инцидентных ей ребер).

Еще раз о транзитивном замыкании

Объединив результаты двух предыдущих разделов, можно разработать алгоритм вычисления абстрактного транзитивного замыкания для орграфов, который работает не лучше DFS в худшем случае, но обеспечивает оптимальное решение во многих других случаях.

В основе этого алгоритма лежит предварительная обработка — построение коренного DAG-графа исходного орграфа (см. лемму 19.2). Алгоритм эффективен, если размер коренного DAG-графа меньше размеров исходного орграфа. Если исходный граф сам является DAG-графом (и поэтому идентичен коренному DAG) или если он содержит лишь несколько небольших циклов, экономия будет незначительной. Но если орграф содержит большие циклы или большие сильные компоненты (т.е. небольшой коренной DAG), то можно разработать оптимальные либо почти оптимальные алгоритмы. Для определенности предположим, что коренной DAG достаточно мал, чтобы представить его матрицей смежности, хотя основная идея применима и для больших коренных DAG.

Для реализации абстрактного транзитивного замыкания мы выполним следующую предварительную обработку графа:

Для поиска сильных компонентов можно воспользоваться алгоритмом Косарайю, Тарьяна или Габова; затем выполнить один проход по ребрам для построения коренного DAG (как описано в следующем абзаце); и выполнить поиск в глубину (программа 19.9) для вычисления транзитивного замыкания. После этой предварительной обработки можно непосредственно получать информацию, необходимую для определения достижимости.

При наличии вектора, индексированного именами вершин, с сильными компонентами орграфа построение представления его коренного DAG в виде матрицы смежности не представляет труда. Вершины DAG-графа являются номерами компонентов орграфа. Для каждого ребра s-t исходного графа нужно просто занести в D->adj[sc[s]] [sc[t]] значение 1. Если бы мы воспользовались представлением списками смежности, нам пришлось бы решать проблему одинаковых ребер в коренном DAG, а в матрице смежности повторяющиеся ребра просто соответствуют занесению 1 в элемент матрицы, который уже равен 1. Этот нюанс очень важен, т.к. в рассматриваемом приложении количество одинаковых ребер в принципе может быть огромным (по сравнению с размером коренного DAG).

Лемма 19.17. Пусть s и t — две вершины в орграфе D, а sc(s) и sc(t) — соответствующие им вершины в коренном DAG-графе K орграфа D. Тогда t достижима из s в D тогда и только тогда, когда sc(t) достижима из sc(s) в K.

Этот простой факт следует из определений. В частности, в данной лемме предполагается, что любая вершина достижима сама из себя (все вершины имеют петли). Если две вершины находятся в одном и том же сильном компоненте (sc(s) = sc(t)), то они взаимно достижимы.

Для определения, достижима ли вершина t из заданной вершины s, мы воспользуемся тем же способом, что и для построения коренного DAG. С помощью вектора, индексированного именами вершин, который вычисляется алгоритмом определения сильных компонентов, мы получим (за постоянное время) номера компонентов sc(s) и sc(t), которые будем рассматривать как индексы абстрактных вершин (abstract vertex) в коренном DAG. Их использование в качестве индексов вершин для транзитивного замыкания коренного DAG даст искомый результат.

Программа 19.13 воплощает эти идеи в реализации АТД абстрактного транзитивного замыкания. Кроме того, мы используем интерфейс абстрактного транзитивного замыкания и в коренном DAG. Время выполнения этой реализации зависит не только от количества вершин и ребер орграфа, но и от свойств его коренного DAG. Для анализа алгоритма предположим, что коренной DAG представляется матрицей смежности, поскольку мы ожидаем, что коренной DAG является небольшим и, скорее всего, насыщенным.

Лемма 19.18. На запросы относительно абстрактного транзитивного замыкания орграфа можно отвечать за постоянное время, при затратах памяти, пропорциональных V + v2, и времени, пропорциональных E + v2 + vx, на предварительную обработку (вычисление транзитивного замыкания). Здесь v означает количество вершин в коренном DAG, а x — количество поперечных ребер в его лесе DFS.

Доказательство. Непосредственно следует из леммы 19.13.

Если орграф сам является DAG-графом, то вычисление сильных компонентов не дает никакой новой информации, а этот алгоритм делает то же, что и программа 19.9; однако в орграфах общего вида, содержащих циклы, этот алгоритм обычно работает гораздо быстрее, чем алгоритм Уоршелла или решение на основе DFS. Например, из леммы 19.18 сразу вытекает следующий результат.

Лемма 19.19. Можно отвечать за постоянное время на запросы относительно транзитивного замыкания любого графа, в коренном DAG которого содержится менее вершин, при затратах объема памяти, пропорционального V, и времени, пропорционального E + V, на предварительную обработку.

Доказательство. Достаточно подставить в лемму 19.18 и учесть, что x < v2.

Программа 19.13. Транзитивное замыкание на основе сильных компонентов

В данном классе реализован интерфейс (абстрактного) транзитивного замыкания орграфов путем вычисления сильных компонентов (с помощью, скажем, программы 19.11), коренного DAG и транзитивного замыкания коренного DAG (программой 19.9). Предполагается, что в классе SC имеется общедоступная функция-член ID, которая возвращает индекс сильного компонента (из массива id) для любой заданной вершины. Эти числа представляют собой индексы вершин в коренном DAG. Вершина t орграфа достижима из вершины s тогда и только тогда, когда ID(t) достижима из ID(s) в коренном DAG.

  template <class Graph>
  class TC
    { const Graph &G; DenseGRAPH *K;
      dagTC<DenseGRAPH, Graph> *Ktc;
      SC<Graph> *Gsc;
    public:
      TC(const Graph &G) : G(G)
        { Gsc = new SC<Graph>(G);
          K = new DenseGRAPH(Gsc->count(), true);
          for (int v = 0; v < G.V(); v++)
            { typename Graph::adjIterator A(G, v);
              for (int t = A.beg(); !A.end(); t = A.nxt())
                K->insert(Edge(Gsc->ID(v), Gsc->ID(t)));
            }
          Ktc = new dagTC<DenseGRAPH, Graph>(*K);
        }
    ~TC() { delete K; delete Ktc; delete Gsc; }
    bool reachable(int v, int w)
        { return Ktc->reachable(Gsc->ID(v), Gsc->ID(w)); }
    } ;
      

Можно рассмотреть и другие варианты этих границ. Например, если мы можем выделить объем памяти, пропорциональный E, то можно достичь того же времени, когда коренной DAG содержит до вершин. Более того, эти временные границы обычно завышены, поскольку они предполагают, что коренной DAG насыщен поперечными ребрами — конечно, это совсем не обязательно.

Главный ограничивающий фактор применимости этого метода — это размер коренного DAG. Чем больше рассматриваемый орграф похож на DAG-граф (чем больше его коренной DAG), тем труднее вычислить его транзитивное замыкание. Обратите внимание: мы (конечно) не нарушили нижней границы, установленной в лемме 19.9, поскольку для насыщенных DAG-графов алгоритм выполняется за время, пропорциональное V3. Мы просто значительно расширили класс графов, для которых производительность выше, чем в худшем случае. Вообще-то сложно построить модель случайного орграфа для генерации орграфов, на которых рассматриваемый алгоритм работает медленно (см. упражнение 19.142).

В таблица 19.2 приведены результаты эмпирических сравнений; она показывает, что случайные орграфы имеют небольшие коренные графы даже при средней насыщенности и даже в моделях с серьезными ограничениями на расположение ребер. Гарантий для худшего случая нет, но на практике обычно встречаются большие орграфы с малыми коренными DAG. При работе с такими орграфами можно получить эффективную реализацию АТД абстрактного транзитивного замыкания.

Таблица 19.2. Свойства случайных орграфов
EСлучайные ребраСлучайные из 10 соседей
veve
1000 вершин
1000983981916755
20004246217131039
50001313156313
1000011817
200001111
10000 вершин
500001441501324150
1000001161123
2000001111
Обозначения:
vКоличество вершин в коренном DAG
eКоличество ребер в коренном DAG

В данной таблице показано количество вершин и ребер в коренных DAG для случайных орграфов, построенных на базе двух различных моделей (ориентированные версии моделей из таблицы 18.1). В обоих случаях при увеличении насыщенности коренной DAG-граф получается небольшим (и разреженным).

Упражнения

19.138. Разработайте версию реализации абстрактного транзитивного замыкания для орграфов с представлением коренного DAG в виде разреженного графа. Основная сложность состоит в устранении из списка одинаковых ребер без излишних затрат времени или памяти (см. упражнение 19.65).

19.139. Приведите коренной DAG, вычисленный программой 19.13, и его транзитивное замыкание для орграфа

3-71-47-80-55-23-82-90-64-92-66-4.

19.140. Преобразуйте реализацию абстрактного транзитивного замыкания на основе вычисления сильных компонентов (программа 19.13) в эффективную программу, которая вычисляет матрицу смежности транзитивного замыкания орграфа, представленного матрицей смежности, используя алгоритм Габова для вычисления сильных компонентов и усовершенствованный алгоритм Уоршелла для вычисления транзитивного замыкания DAG-графа.

19.141. Эмпирически оцените ожидаемый размер коренного DAG для различных видов орграфов (см. упражнения 19.11—19.18).

19.142. Разработайте модель случайных орграфов, которая генерирует орграфы, имеющие коренные DAG большого размера. Генератор должен порождать ребра по одному, но не должен использовать какие-либо структурные свойства полученного графа.

19.143. Разработайте реализацию абстрактного транзитивного замыкания орграфа, в которой находятся сильные компоненты и строится коренной DAG — после этого можно утвердительно отвечать на запросы о достижимости, если две заданные вершины находятся в одном и том же сильном компоненте, либо, если это не так, определять достижимость с помощью поиска в глубину.

Перспективы

В данной главе мы рассмотрели алгоритмы решения задач топологической сортировки, транзитивного замыкания и нахождения кратчайшего пути для орграфов и для DAG-графов, в том числе и фундаментальные алгоритмы поиска циклов и сильных компонентов в орграфах. Эти алгоритмы широко применяются сами по себе, а также лежат в основе решения более сложных задач, включая взвешенные графы, которые мы будем изучать в двух последующих главах. Значения времени выполнения этих алгоритмов в худшем случае приведены в таблица 19.3.

На протяжении данной главы постоянно упоминалось решение задачи абстрактного транзитивного замыкания, где используется АТД, который после предварительной обработки может определить, существует ли ориентированный путь из одной заданной вершины в другую. Хотя нижняя граница затрат на предварительную обработку в худшем случае значительно больше V2, метод, рассмотренный в разделе 19.7, сводит базовые методы из этой главы к простому решению, которое обеспечивает оптимальную производительность для многих типах орграфов; заметное исключение составляют лишь насыщенные DAG-графы. Нижняя граница означает, что трудно достичь более высокой гарантированной производительности на всех графах, однако для графов, встречающихся на практике, рассмотренные методы позволяют получить неплохую производительность.

Таблица 19.3. Трудоемкость операций обработки орграфов в худшем случае
ЗадачаЗатратыАлгоритм
Орграфы
Обнаружение цикловEDFS
Транзитивное замыкание V(E+V) DFS из каждой вершины
Кратчайшие пути из одного источникаEDFS
Все кратчайшие пути V(E+V) DFS из каждой вершины
Сильные компонентыEКосарайю, Тарьяна или Габова
Транзитивное замыкание E+v(v+x) Коренной DAG
DAG-графы
Проверка на отсутствие цикловEDFS или очередь истоков
Топологическая сортировкаEDFS или очередь истоков
Транзитивное замыкание V(E+V) DFS
Транзитивное замыкание V(E+X) DFS / динамическое программирование

Здесь приведены данные о затратах (время выполнения в худшем случае) на выполнение различных задач обработки орграфов, рассмотренных в этой главе — для случайных графов и графов, в которых ребра случайным образом соединяют каждую вершину с одной из 10 заданных соседних вершин. Все затраты определялись для представления списками смежности. В случае представления матрицей смежности вместо E элементов появляются V2 элементов, и тогда, например, затраты на вычисление всех кратчайших путей пропорциональны V3. Линейные по времени алгоритмы являются оптимальными, поэтому затраты позволяют с достаточной уверенностью предсказывать время выполнения для любых входных данных; оценки для других могут быть слишком осторожными, и время выполнения для некоторых видов графов может оказаться меньшим. Характеристики производительности самого быстрого алгоритма вычисления транзитивного замыкания орграфа зависят от структуры орграфа — в частности, от размера его коренного DAG.

Разработка алгоритмов с характеристиками производительности, аналогичными характеристикам алгоритмов объединения-поиска из лекция №1, для насыщенных орграфов остается призрачной мечтой. В идеальном случае хотелось бы определить АТД, позволяющий добавлять ориентированные ребра или проверять достижимость одной вершины из другой, и разработать реализацию с выполнением всех операций за постоянное время (см. упражнения 19.153—19.155). Как было сказано в лекция №1, можно довольно близко подойти к этой цели в случае неориентированных графов, но аналогичные решения для орграфов или DAG-графов до сих пор не известны. (Учтите, что удаление ребер — сложная задача даже для неориентированных графов.) Эта задача динамической достижимости (dynamic reachability) не только интересует математиков и имеет практическое применение, но и играет исключительно важную роль в разработке алгоритмов на более высоком уровне абстракции. Например, достижимость лежит в основе задачи реализации сетевого симплексного алгоритма для определения потоков с минимальной стоимостью — эту широко применяемую модель решения задач мы рассмотрим в лекция №22.

Множество других алгоритмов обработки орграфов и DAG-графов важны на практике и детально исследуются, но многие задачи обработки орграфов еще ждут разработки эффективных алгоритмов. Ниже приведен список характерных задач.

Доминаторы. Пусть задан DAG-граф, все вершины которого достижимы из одного истока г. Говорят, что вершина v доминирует (dominate) над вершиной t, если каждый путь из г в t содержит s. (В частности, каждая вершина доминирует сама над собой.) Каждая вершина v, отличная от источника, имеет непосредственный доминатор, который доминирует над вершиной v, но не доминирует над другим доминатором v и над самим собой. Множество непосредственных доминаторов представляет собой дерево, которое охватывает все вершины, достижимые из источника. Эта структура имеет важное значение для построения компиляторов. Доминаторное дерево может быть вычислено за линейное время с помощью подхода на основе DFS, который использует несколько вспомогательных структур данных, хотя обычно на практике применяется несколько более медленная версия.

Транзитивное приведение. Для заданного орграфа требуется найти орграф, который имеет то же транзитивное замыкание и минимальное количество ребер из всех таких орграфов. Эта задача разрешима (см. упражнение 19.150); однако при дополнительном ограничении — чтобы результатом был подграф исходного графа — она является NP-трудной.

Ориентированный евклидов путь. Существует ли в заданном орграфе путь, соединяющий две заданных вершины, который содержит каждое ребро орграфа в точности один раз? Эта задача легко решается с помощью примерно тех же рассуждений, что и соответствующая задача для неориентированных графов, которая была рассмотрена в лекция №17 (см. упражнение 17.92).

Ориентированный маршрут почтальона. В заданном орграфе нужно найти ориентированный цикл с минимальным количеством ребер, который использует каждое ребро графа минимум один раз (каждое ребро можно использовать многократно). Как мы увидим в лекция №22, эта задача сводится к задаче поиска потоков минимальной стоимости, и поэтому она разрешима.

Ориентированный гамильтонов путь. Нужно найти в орграфе простой ориентированный путь максимальной длины. Эта задача относится к числу NP-трудных, однако легко решается, если граф представляет собой DAG (см. упражнение 19.114).

Односвязный подграф. Говорят, что граф односвязен (uniconnected), если между любой парой вершин существует не более чем один ориентированный путь. Если задан орграф и целое число к, нужно определить, существует ли односвязный подграф с не менее чем к ребрами. Для произвольного к эта задача является NP-трудной.

Множество вершин обратной связи. Требуется определить, содержит ли заданный орграф подмножество из максимум к вершин, которое содержит не менее одной вершины из каждого направленного цикла в G. Эта задача является NP-трудной.

Четный цикл. Требуется определить, содержит ли заданный орграф цикл четной длины. Как было сказано в лекция №17, это задача не является трудноразрешимой, однако никому не удалось создать практически полезный алгоритм.

Как и в случае неориентированных графов, было изучено огромное множество задач, но зачастую даже определение того, является ли задача легко решаемой или трудноразрешимой, само по себе является сложной проблемой (см. лекция №17). Как подчеркивалось на протяжении всей этой главы, некоторые обнаруженные нами сведения об орграфах являются выражениями более общих математических закономерностей, а многие наши алгоритмы применимы и на других уровнях абстракции. С одной стороны, понятие трудноразрешимой задачи говорит, что в поисках алгоритмов, обеспечивающих эффективное решение некоторых задач, мы можем столкнуться с фундаментальными препятствиями. С другой стороны, классические алгоритмы, описанные в данной главе, имеют фундаментальное значение и широко применяются на практике, поскольку обеспечивают эффективные решения задач, которые часто возникают в реальной жизни и не могут быть легко решены другим способом.

Упражнения

19.144. Используйте программы 17.18 и 17.19 для реализации функции АТД, которая выводит эйлеров путь в орграфе, если он существует. Поясните назначение каждого изменения и дополнения, которые понадобится внести в код.

19.145. Начертите дерево доминаторов орграфа

3-71-47-80-55-23-02-90-64-92-6

6-41-58-29-08-34-52-31-63-57-6

19.146. Напишите класс, использующий поиск в глубину для создания представления родительскими ссылками для доминаторного дерева заданного орграфа (см. раздел ссылок).

19.147. Найдите транзитивное приведение орграфа

3-71-47-80-55-23-02-90-64-92-66-41-58-29-08-34-52-31-63-57-6

19.148. Найдите подграф графа

3-71-47-80-55-23-02-90-64-92-6

6-41-58-29-08-34-52-31-63-57-6

имеющий то же транзитивное замыкание и минимальное количество ребер среди всех таких подграфов.

19.149. Докажите, что у каждого DAG-графа имеется уникальное транзитивное приведение, и напишите эффективную реализацию функции АТД для вычисления транзитивного приведения DAG-графа.

19.150. Напишите эффективную функцию АТД для орграфов, которая вычисляет транзитивное приведение.

19.151. Приведите алгоритм, который определяет, является ли заданный орграф односвязным. Ваш алгоритм должен выполняться в худшем случае за время, пропорциональное VE.

19.152. Найдите наибольший односвязный подграф в орграфе

3-71-47-80-55-23-02-90-64-92-6

6-41-58-29-08-34-52-31-63-57-6

19.153. Напишите класс орграфа, который поддерживает операции вставки ребра, удаления ребра и проверки, находятся ли две вершины в одном и том же сильном компоненте. Операции построения, вставки ребра и удаления должны выполняться за линейное время, а ответы на запросы о сильной связности — за постоянное время.

19.154. Выполните упражнение 19.153 так, чтобы вставка ребра, удаление ребра и запросы относительно сильных компонентов выполнялись за время, пропорциональное log V в худшем случае.

19.155. Выполните упражнение 19.153 так, чтобы вставка ребра, удаление ребра и запросы относительно сильных компонентов выполнялись за почти постоянное время (как в алгоритмах поиска-объединения для определения связности в неориентированных графах).

Лекция 20. Минимальные остовные деревья

Модели графов, в которых с каждым ребром связаны веса (weight) или стоимости (cost), используются во многих приложениях. В картах авиалиний, в которых ребрами отмечены авиарейсы, такие веса означают расстояния или стоимости билетов. В электронных схемах, где ребра представляют проводники, веса могут означать длину проводника, его стоимость или время прохода сигнала. В задачах календарного планирования веса могут представлять время или трудоемкость либо выполнения задачи, либо ожидания ее завершения.

В таких ситуациях естественно возникают вопросы, касающиеся минимизации затрат. Мы рассмотрим алгоритмы для двух таких задач: (1) поиск пути наименьшей стоимости, соединяющего все точки, и (2) поиск пути наименьшей стоимости, соединяющего две заданных точки. Первый алгоритм применяется для решения задач на неориентированных графах, которые представляют такие объекты как электрические цепи, и находит минимальное остовное дерево; это дерево является основной темой данной главы. Второй алгоритм применяется для решения задач на орграфах, которые представляют такие объекты, как карты авиарейсов, и определяет кратчайшие пути — он будет темой лекция №21. Эти алгоритмы находят широкое применение не только в приложениях, связанных с электрическими схемами и картами, но и при решении задач на взвешенных графах.

Когда мы изучаем алгоритмы обработки взвешенных графов, то часто интуитивно отождествляем веса с расстояниями, употребляя выражения наподобие " вершина, ближайшая к x ". И термин " кратчайший путь " поддерживает этот способ мышления. Однако, несмотря на многочисленные приложения, которые действительно работают с расстояниями, и несмотря на все преимущества геометрической наглядности для понимания базовых алгоритмов, важно помнить, что веса не обязательно должны быть пропорциональны расстоянию: они могут представлять время, затраты или какую-то другую величину. А в лекция №21 мы увидим, что веса в задачах поиска кратчайшего пути могут быть даже отрицательными.

Используя интуитивное понимание при описании алгоритмов и примеров, но все-таки не теряя общности, мы будем использовать неоднозначную терминологию, говоря то о длинах, то о весах. " Короткое " ребро будет означать ребро с низким весом и т.д. В большей части примеров этой главы мы будем использовать веса, пропорциональные расстояниям между вершинами, как на рис. 20.1. Такие графы удобнее в качестве примеров, поскольку в них можно не указывать метки ребер, и все-таки сразу видеть, что более длинные ребра имеют большие веса по сравнению с короткими ребрами.

 Взвешенный неориентированный граф и его MST


Рис. 20.1.  Взвешенный неориентированный граф и его MST

Взвешенный неориентированный граф представляет собой множество взвешенных ребер. MST-дерево есть множество ребер минимального общего веса, которые соединяют все вершины (в списке ребер выделены черным, утолщенные ребра на чертеже). В рассматриваемом графе веса пропорциональны расстояниям между вершинами, однако базовые алгоритмы, которые мы будем изучать, предназначены для обработки графов общего вида и не используют никаких предположений о весах (см. рис. 20.2).

Когда веса представляют расстояния, алгоритмы можно рассматривать c учетом геометрических свойств графов (разделы 20.7 и 21.5). Но вообще-то алгоритмы, которые мы будем изучать, просто обрабатывают ребра, никак не используя неявную геометрическую информацию (см. рис. 20.2).

Задача поиска минимального остовного дерева в произвольном взвешенном неориентированном графе применяется во многих важных ситуациях, и алгоритмы ее решения известны по меньшей мере с двадцатых годов прошлого столетия. Однако эффективности ее реализаций существенно различаются, а исследователи до сих пор пытаются найти более совершенные методы. В этом разделе мы рассмотрим три классических алгоритма, которые легко понять на концептуальном уровне; в разделах 20.3—20.5 мы подробно изучим их реализации, а в разделе 20.6 сравним эти фундаментальные подходы и их основные усовершенствования.

Определение 20.1. Минимальное остовное дерево (minimal spanning tree — MST, другие варианты перевода — минимальный остов, минимальный каркас, минимальный скелет) взвешенного графа есть остовное дерево, вес которого (сумма весов его ребер) не превосходит вес любого другого остовного дерева.

Если все веса положительны, достаточно определить MST-дерево как множество ребер с минимальным общим весом, которые соединяют все вершины, поскольку такое множество как раз образует остовное дерево. Однако условие остовного дерева в определении допускает применение и к графам, в которых ребра могут иметь отрицательные веса (см. упражнение 20.2 и 20.3).

Если ребра могут иметь равные веса, минимальное остовное дерево может не быть единственным. Например, на рис. 20.2 показан граф, который имеет два различных MST-дерева. Возможность равных весов усложняет описание и доказательство правильности некоторых наших алгоритмов. Следует внимательно рассматривать случаи с равными ребрами, т.к. они довольно часто встречаются на практике, а нужно, чтобы наши алгоритмы работали правильно и в таких случаях.

Проблема не только в существовании сразу нескольких MST: это название не отражает достаточно четко то, что минимизируется вес, а не само дерево. Поэтому многие авторы употребляют термин остовное дерево с минимальным весом. Но аббревиатура MST широко распространена и довольно четко отражает базовое понятие.

Однако во избежание путаницы при описании алгоритмов на сетях, в которых могут быть ребра с равными весами, следует аккуратно относиться к терминологии: слово " минимальный " будет обозначать " ребро минимального веса " (среди всех ребер некоторого множества), а " максимальный " — " ребро максимального веса " . То есть если ребра различны, то минимальным ребром является (единственное) самое короткое ребро; но если существует несколько ребер минимального веса, то минимальным может быть любое из них.

В данной главе мы будем работать исключительно с неориентированными графами. Задача поиска ориентированного остового дерева с минимальным весом для орграфов — другая, более трудная задача.

Для решения задачи MST были разработаны несколько классических алгоритмов — как старые и широко известные, так и современные алгоритмы, которые вместе с современными структурами данных позволяют получать компактные и эффективные программные реализации. Такие реализации представляют собой убедительные примеры эффективности тщательного проектирования АТД и правильного выбора фундаментальных структур данных АТД и реализаций алгоритмов для решения все более сложных алгоритмических задач.

 Произвольные веса


Рис. 20.2.  Произвольные веса

В этом примере веса ребер выбраны произвольно и не имеют никакого отношения к геометрии изображенного здесь представления графа. Этот пример также демонстрирует, что MST-дерево не обязательно уникально, если веса ребер могут быть равными: мы получаем одно MST, используя ребро 3-4 (показано на рисунке), и другое MST, используя вместо него 0-5 (хотяребро 7-6 с тем же весом не присутствует ни в одном MST).

Упражнения

20.1. Предположим, что веса в графе положительны. Докажите, что можно изменить их вес, добавив к каждому из них константу или умножив на константу, и при этом MST-деревья не изменятся, если все полученные веса останутся положительными.

20.2. Покажите, что если веса ребер положительны, то множество ребер, соединяющих все вершины, суммарный вес которых не больше суммы весов любого другого множества ребер, соединяющих все эти вершины, составляет MST-дерево.

20.3. Покажите, что свойство, сформулированное в упражнении 20.2, справедливо и для графов с отрицательными весами, в которых нет циклов, все ребра которых имеют неположительные веса.

20.4. Как найти максимальное остовное дерево взвешенного графа?

20.5. Покажите, что если все ребра графа имеют различные веса, то MST-дерево уникально.

20.6. Проанализируйте утверждение, что граф обладает уникальным MST-деревом, только когда веса его ребер различны. Докажите его или приведите контрпример.

20.7. Предположим, что граф имеет t < Vребер с равными весами, а веса всех других ребер различны. Приведите верхнюю и нижнюю границы количества различных MST-деревьев, которые могут быть у графа.

Представления

В этой главе мы займемся взвешенными неориентированными графами — самой естественной средой для задачи MST. Наверно, проще всего начать с расширения базовых представлений графа из лекция №17: в представлении матрицей смежности вместо логических значений могут быть проставлены веса ребер; в представлении списками смежности в элементы списка, представляющие ребра, можно добавить поле веса. Этот классический подход привлекает своей простотой, но мы будем пользоваться другим методом, который не намного сложнее, зато позволит использовать наши программы в более общих условиях. Сравнительные характеристики производительности этих подходов будут проведены ниже в данной главе.

Чтобы рассмотреть все вопросы, возникающие при переходе от графов, где нас интересует только наличие или отсутствие ребер, к графам, в которых нам важна информация, связанная с ребрами, удобно представить ситуации, где вершины и ребра представляют собой объекты неизвестной сложности. Возможно, они являются какой-то частью крупной базы данных клиентов, которая создана и используется другим приложением. Например, бывает нужно рассматривать улицы, дороги и автострады в географической базе данных как абстрактные ребра, где веса представляют их длины. Но записи базы данных могут содержать и другую информацию — например, название дороги и ее тип, подробное описание ее физических свойств, интенсивность движения и т.п. Клиентская программа может выбрать нужную информацию, построить граф, обработать его, а затем интерпретировать полученные результаты в контексте базы данных — но это может оказаться сложным и трудоемким процессом. В частности, для этого нужна (по меньшей мере) копия графа.

В клиентских программах удобнее определить тип данных EDGE, а в реализациях — работать с указателями на ребра. В программе 20.1 описаны абстрактные типы данных EDGE, GRAPH и итератора, которые нужны для этого подхода. Клиенты легко могут удовлетворить минимальные требования типа данных EDGE, позволяя при этом определять типы данных для использования в контекстах других приложений. Наши реализации могут работать с указателями на ребра и использовать интерфейс для извлечения необходимой им информации из объектов EDGE независимо от их представления. Пример клиентской программы, использующей этот интерфейс, приведен в программе 20.2.

При тестировании алгоритмов и в базовых приложениях мы будем использовать класс EDGE (ребро), содержащий два приватных члена данных типа int и один типа double, которые инициализируются аргументами конструктора и возвращаются, соответственно, функциями-членами v(), w() и wt() (см. упражнение 20.8). Для единообразия в этой главе и в главе 21 лекция №21 мы будем представлять веса ребер типом данных double. В наших примерах в качестве весов ребер будут использоваться вещественные числа от 0 до 1. Это решение не противоречит различным вариантам, которые могут встретиться в приложениях, поскольку всегда можно явно или неявно масштабировать веса, чтобы они соответствовали этой модели (см. упражнения 20.1 и 20.10). Например, если весами являются положительные целые числа, меньшие известного максимального значения, то поделив значения весов на это максимальное значение, мы преобразуем их в вещественные числа из диапазона от 0 до 1.

При желании можно было бы разработать более общий интерфейс АТД и использовать для весов ребер любой тип данных, который поддерживает операции сложения, вычитания и сравнения, поскольку мы не только накапливаем суммы весов и принимаем решения в зависимости от их значений. В алгоритмах, которые будут рассмотрены в лекция №22, понадобятся сравнения линейных комбинаций весов ребер, а время выполнения некоторых алгоритмов зависит от арифметических свойств весов, поэтому мы перейдем на целочисленные веса, чтобы упростить анализ алгоритмов.

Программы 20.2 и 20.3 реализуют АТД взвешенного графа из программы 20.1, представленного матрицей смежности. Как и раньше, для вставки ребра в неориентированный граф нужно занести указатели на него в двух местах матрицы — по одному для каждого направления ребра. Как обычно для алгоритмов, работающих с представлением неориентированных графов в виде матрицы смежности, их время выполнения пропорционально V2 (на инициализацию матрицы) или выше.

Программа 20.1. Интерфейс АТД для графов со взвешенными ребрами

Этот код определяет интерфейс для графов с весами и другой информацией, связанной с ребрами. Он содержит интерфейс АТД ребра EDGE и шаблонный интерфейс графа GRAPH, которые можно использовать в любой реализации интерфейса EDGE. Реализации GRAPH работают с указателями на ребра (они берутся в клиентах из функции insert), а не самими ребрами. Класс ребер содержит также функции-члены, которые предоставляют информацию об ориентации ребра: либо e->from(v) истинно, e->v() равно v, а e->other(v) содержит e->w(); либо e->from(v) ложно, e->w() равно v, а e->other(v) содержит e->v().

  class EDGE {
    public:
      EDGE(int, int, double);
      int v() const;
      int w( ) const;
      double wt() const;
      bool from(int) const;
      int other(int) const;
  };
  template <class Edge>
  class GRAPH {
    public:
      GRAPH(int, bool);
      ~GRAPH();
      int V() const;
      int E() const;
      bool directed() const;
      int insert(Edge *);
      int remove(Edge *);
      Edge *edge(int, int);
      class adjIterator {
        public:
          adjIterator(const GRAPH &, int);
          Edge *beg();
          Edge *nxt();
          bool end();
       };
  };
      

Программа 20.2. Пример клиентской функции обработки графа

Данная функция демонстрирует применение интерфейса взвешенного графа из программы 20.1. При любой реализации этого интерфейса функция edges возвращает вектор, содержащий указатели на все ребра графа. Как и в главах 17—19, обычно функции итератора используются только так, как показано здесь.

  template <class Graph, class Edge>
  vector <Edge *> edges(const Graph &G)
    { int E = 0;
      vector <Edge *> a(G.E());
      for (int v = 0; v < G.V(); v++)
        { typename Graph::adjIterator A(G, v);
          for (Edge* e = A.beg(); !A.end(); e = A.nxt())
            if (e->from(v)) a[E++] = e;
        }
      return a;
    }
      

Проверка на существование ребра v-w при таком представлении сводится к проверке, является ли пустым указатель на пересечении строки v и столбца w. Иногда можно избежать таких проверок с помощью сигнальных значений для весов, однако в наших реализациях сигнальные значения использоваться не будут.

Программа 20.5 содержит детали реализации АТД взвешенного графа с указателями на ребра в представлении списками смежности. Вектор, индексированный именами вершин, ставит в соответствие каждой вершине связный список инцидентных ей ребер. Каждый узел списка содержит указатель на ребро. Как и в случае матрицы смежности, при желании можно сэкономить память, храня в узлах списков конечные вершины и веса (с неявным указанием исходной вершины), но за счет усложнения итератора (см. упражнения 20.11 и 20.14).

Программа 20.3. Класс взвешенного графа (матрица смежности)

Для насыщенных взвешенных графов мы используем матрицу указателей на данные типа Edge с указателем на ребро v-u в строке v и столбце w. Для неориентированных графов заносится еще один указатель на ребро — в строке w и столбце v. Пустой указатель означает отсутствие ребра; для удаления ребра функция remove() удаляет указатель на него. Данная реализация не выполняет проверку на наличие параллельных ребер, хотя клиенты могут воспользоваться для этого функцией edge.

  template <class Edge>
  class DenseGRAPH
    { int Vcnt, Ecnt; bool digraph;
      vector <vector <Edge *> > adj;
    public:
      DenseGRAPH(int V, bool digraph = false) :
        adj(V), Vcnt(V), Ecnt(0), digraph(digraph)
        { for (int i = 0; i < V; i++)
            adj[i].assign(V, 0);
        }
      int V() const { return Vcnt; }
      int E() const { return Ecnt; }
      bool directed() const { return digraph; }
      void insert(Edge *e)
        { int v = e->v(), w = e->w ();
          if (adj[v][w] == 0) Ecnt++;
          adj [ v] [ w] = e;
          if (!digraph) adj[w][v] = e;
        }
      void remove(Edge *e)
        { int v = e->v(), w = e->w ();
          if (adj[v][w] != 0) Ecnt —;
          adj[v][w] = 0;
          if (!digraph) adj[w][v] = 0;
        }
      Edge* edge(int v, int w) const
        { return adj[v][w]; }
      class adjlterator;
      friend class adjlterator;
    };
      

Программа 20.4. Класс итератора для представления матрицей смежности

Этот код, возвращающий указатели на ребра, является простой адаптацией программы 17.8.

  template <class Edge>
  class DenseGRAPH<Edge>::adjIterator
    { const DenseGRAPH<Edge> &G;
      int i, v;
    public:
      adjIterator(const DenseGRAPH<Edge> &G, int v) :
        G(G), v(v), i(0) { }
      Edge *beg()
        { i = -1; return nxt(); }
      Edge *nxt()
        { for (i++; i < G.V(); i++)
            if (G.edge(v, i)) return G.adj[v][i];
          return 0;
        }
      bool end() const
        { return i >= G.V(); }
    } ;
      

Программа 20.5. Класс взвешенного графа (списки смежности)

Данная реализация интерфейса из программы 20.1 основана на представлении графа в виде списков смежности и поэтому удобна для разреженных взвешенных графов. Как и в случае невзвешенных графов, каждое ребро представляется узлом списка, но здесь каждый узел содержит указатель на соответствующее ребро, а не просто на конечную вершину. Класс итератора представляет собой непосредственную адаптацию программы 17.10 (см. упражнение 20.13).

  template <class Edge>
  class SparseMultiGRAPH
    { int Vcnt, Ecnt; bool digraph;
      struct node
        { Edge* e; node* next;
          node(Edge* e, node* next): e(e), next(next) {}
        };
      typedef node* link;
      vector <link> adj;
    public:
      SparseMultiGRAPH(int V, bool digraph = false) :
        adj(V), Vcnt(V), Ecnt(0), digraph(digraph) { }
      int V() const { return Vcnt; }
      int E() const { return Ecnt; }
      bool directed() const { return digraph; }
      void insert(Edge *e)
        { adj[e->v()] = new node(e, adj[e->v()]);
          if (!digraph)
            adj[e->w()] = new node(e, adj[e->w()]);
          Ecnt++;
        }
      class adjIterator;
      friend class adjIterator;
    };
      

На этом этапе полезно сравнить эти представления с простыми представлениями, о которых шла речь в начале этого раздела (см. упражнения 20.11 и 20.12). Если строить граф с нуля, то, конечно, использование указателей потребовало бы большего объема памяти. Память нужна не только для размещения указателей, но и для индексов (имен вершин), которые в простых реализациях представлены неявно. Чтобы использовать указатели на ребра в представлении матрицей смежности, требуется дополнительный объем памяти для размещения V2 указателей на ребра и E пар индексов. Аналогично, чтобы использовать указатели на ребра в представлении списками смежности, требуется дополнительный объем памяти для размещения E указателей на ребра и E индексов.

Однако использование указателей на ребра зачастую позволяет получить более быстрый код, поскольку скомпилированный код клиентской программы получает непосредственный доступ к весу через один указатель — в отличие от простой реализации, где сначала создается элемент типа Edge (ребро), а затем выполняется обращение к его полям. Если память критична, то применение минимальных представлений (и, возможно, оптимизация итераторов для экономии времени) может быть разумным вариантом, но в остальных случаях гибкость, которую дает применение указателей, стоит дополнительных затрат памяти.

Однако на всех рисунках будут применяться простые представления, чтобы не загромождать их. То есть мы будем показывать не матрицы указателей на структуры ребер (пара целых чисел и вес), а просто матрицы весов, и вместо узлов списков с указателями на структуры ребер мы будем показывать узлы, содержащие конечные вершины ребер. Представления нашего демонстрационного графа в виде матрицы смежности и списков смежности приведены на рис. 20.3.

Что касается реализаций неориентированных графов, то ни в одной из реализаций не выполняется проверка на наличие параллельных ребер.

 Представления взвешенного графа (неориентированного)


Рис. 20.3.  Представления взвешенного графа (неориентированного)

Два стандартных представления взвешенных неориентированных графов содержат веса в каждом представлении ребра. Здесь показаны представления графа, изображенного на рис. 20.1, в виде матрицы смежности (слева) и в виде списков смежности (справа). Для простоты веса на этих рисунках показаны непосредственно в элементах матрицы и в узлах списков; в наших программах мы используем указатели на ребра, построенные клиентскими программами. Матрица смежности симметрична, а списки смежности содержат по два узла для каждого ребра, как и в случае невзвешенных ориентированных графов. Отсутствующие ребра представлены в матрице пустыми указателями (на рисунке — звездочками), а в списках их просто нет. В обоих представлениях нет петель, поскольку алгоритмы MSTбез них оказываются проще — хотя они используются другими алгоритмами обработки взвешенных графов (см. лекция №21

В зависимости от приложения можно изменить представление матрицей смежности так, чтобы сохранять параллельные ребра с наименьшим или наибольшим весом, либо сливать параллельные ребра в единое ребро с весом, равным сумме весов параллельных ребер. В представлении списками смежности параллельные ребра могут присутствовать в структуре данных, хотя можно построить более совершенные структуры данных, позволяющие отказаться от них с помощью одного из правил, описанных для матриц смежности (см. упражнение 17.49).

А как представить само MST-дерево? MST-дерево графа G — это подграф графа G, который сам по себе является деревом, поэтому возможны различные варианты, основными из которых являются:

На рис. 20.4 показаны эти варианты для MST-дерева с рис. 20.1. Еще один вариант — определить и использовать АТД для деревьев.

Одно и то же дерево может иметь различные представления в любой из указанных выше схем. В каком порядке следует хранить ребра в представлении списком ребер? Какой узел выбрать в качестве корня в представлении родительскими ссылками (см. упражнение 20.21)? Вообще-то конкретное представление MST-дерева, которое получается при выполнении алгоритма MST, зависит только от используемого алгоритма и не отражает никаких важных свойств MST-дерева.

 Представления MST-дерева


Рис. 20.4.  Представления MST-дерева

Здесь показаны различные представления MST-дерева с рис. 20.1. Наиболее простым является список его ребер в произвольном порядке (слева). MST-дерево — это разреженный граф, и его можно представить списками смежности (в центре). Наиболее компактным является представление родительскими ссылками: одна из вершин выбирается в качестве корня, и используются два вектора, индексированные именами вершин: один содержит родительский узел для каждой вершины дерева, а второй — вес ребра, ведущего из данной вершины к ее родителю (справа). Ориентация дерева (выбор корневой вершины) произвольна и не является свойством MST-дерева. Любое из этих представлений можно преобразовать в любое другое за линейное время.

Выбор представления MST-дерева не оказывает заметного влияния на алгоритм, поскольку каждое из этих представлений можно легко преобразовать в любое другое. Чтобы преобразовать представление MST-дерева в виде графа в вектор ребер, можно воспользоваться функцией GRAPHedges из программы 20.2. Чтобы преобразовать представление в виде родительских ссылок, хранящихся в векторе st (с весами в отдельном векторе wt), в вектор указателей на ребра mst, можно воспользоваться циклом

  for (k = 1; k < G.V(); k++)
    mst[k] = new EDGE (k, st[k], wt[k]);
      

Здесь приведен типичный случай, когда в качестве корня MST-дерева выбирается вершина 0, а фиктивное ребро 0-0 не помещается в список ребер MST-дерева.

Оба эти преобразования тривиальны, но как преобразовать представление в виде вектора указателей на ребра в представление родительскими ссылками? В нашем распоряжении имеются средства, позволяющие легко решить и эту задачу: можно преобразовать представление графа с помощью цикла вроде вышеприведенного (только с вызовом функции insert для каждого ребра), а затем выполнить поиск в глубину из любой вершины, чтобы вычислить за линейное время представление дерева DFS родительскими ссылками.

В общем, хотя представление MST-дерева можно выбирать как удобно, мы оформим все наши алгоритмы в класс обработки графов MST, который вычисляет приватный вектор mst указателей на ребра. В зависимости от потребностей приложений для данного класса можно реализовать функции-члены, которые возвращают этот вектор или предоставляют клиентским программам другую информацию об MST-дереве, но мы не будем вдаваться в дальнейшие детали этого интерфейса. Отметим лишь наличие функции-члена show, которая вызывает аналогичную функцию для каждого ребра MST-дерева (см. упражнение 20.8).

Упражнения

20.8. Напишите класс WeightedEdge (взвешенное ребро), который реализует интерфейс EDGE из программы 20.1 и содержит функцию-член show, которая выводит ребра и их веса в формате, используемом в рисунках этой главы.

20.9. Реализуйте класс io для взвешенных графов, содержащий функции-члены show, scan и scanEZ (см. программу 17.4).

20.10. Постройте АТД графа, который использует целочисленные веса, отслеживает минимальный и максимальный вес в графе и содержит функцию АТД, которая всегда возвращает веса, представленные числами из диапазона от 0 до 1.

20.11. Приведите интерфейс наподобие программы 20.1 для работы клиентов и реализаций с переменными типа Edge (а не с указателями на них).

20.12. Разработайте реализацию интерфейса из упражнения 20.11, в которой используется минимальное представление матрицей весов, а функция итератора nxt использует информацию, неявно содержащуюся в индексах строк и столбцов, для создания переменных типа Edge и возврата его значения клиентской программе.

20.13. Реализуйте класс итератора для использования в программе 20.5 (см. программу 20.4).

20.14. Разработайте реализацию интерфейса из упражнения 20.11, в которой используется минимальное представление списками смежности, узлы списка содержат вес и конечную вершину (но не начальную вершину), а функция итератора nxt использует неявную информацию для создания переменных типа Edge и возврата значений клиентской программе.

20.15. Внесите в генератор разреженных случайных графов из программы 17.12 возможность присваивания ребрам случайных весов (от 0 до 1).

20.16. Внесите в генератор насыщенных случайных графов из программы 17.13 возможность присваивания ребрам случайных весов (от 0 до 1).

20.17. Напишите программу, которая генерирует случайные взвешенные графы, соединяя вершины решетки размером W х W с соседними вершинами (как на рис. 19.3, но для неориентированных графов) и присваивая каждому ребру случайный вес (от 0 до 1).

20.18. Напишите программу генерации случайных полных графов с нормально распределенными весами ребер.

20.19. Напишите программу, которая генерирует V случайных точек на плоскости, затем строит взвешенный граф, соединяя каждую пару точек, расположенных друг от друга на расстоянии не более d, ребрами с весом, равным этому расстоянию (см. упражнение 17.74). Определите, каким должно быть d, чтобы ожидаемое количество ребер было равно E.

20.20. Найдите в интернете крупный взвешенный граф — возможно, карту с расстояниями, список телефонных переговоров с их стоимостями или расписание авиарейсов с ценами на билеты.

20.21. Составьте матрицу размером 8 х 8, содержащую представления родительскими ссылками для всех ориентаций MST-дерева для графа с рис. 20.1. Поместите в i-ю строку этой матрицы представление родительскими ссылками для дерева с корнем в вершине i.

20.22. Предположим, что конструктор класса MST генерирует представление MST-дерева в виде вектора указателей на ребра с элементами от mst[1] до mst[V]. Добавьте функцию-член ST (например, как в программе 18.3) — такую, что ST(v) возвращает в клиентскую программу родителя вершины v в этом дереве (или саму v, если это корень).

20.23. Для условий из упражнения 20.22 напишите функцию-член, которая возвращает суммарный вес MST-дерева.

20.24. Предположим, что конструктор класса MST генерирует представление MST-дерева в виде родительских ссылок в векторе st. Напишите код, который необходимо добавить в конструктор для вычисления представления этого дерева в виде вектора указателей на ребра в элементах приватного вектора mst с индексами 1, ..., V.

20.25. Определите класс TREE (дерево). Затем для условий из упражнения 20.22 напишите функцию-член, которая возвращает результат типа TREE.

Основные принципы алгоритмов построения MST -дерева

Построение MST-дерева — одна из наиболее изученных задач среди описанных в этой книге. Основные принципы ее решения были известны задолго до разработки современных структур данных и современных технологий анализа производительности алгоритмов, еще в те времена, когда поиск MST для графа, скажем, с тысячей ребер, представлял собой крайне сложную задачу. Как мы увидим, некоторые новые алгоритмы построения MST отличаются от старых главным образом способами использования и реализации алгоритмов и структур данных для основных задач, которые (наряду с мощью современных компьютеров) позволяют вычислять MST-деревья с миллионами и даже миллиардами ребер.

Одно из определяющих свойств дерева (см. лекция №5) заключается в том, что добавление в дерево любого ребра порождает уникальный цикл. Это свойство лежит в основе доказательства двух фундаментальных теорем об MST-деревьях, которые мы сейчас рассмотрим. Все описанные ниже алгоритмы основаны на этих теоремах или хотя бы одной из них.

Первая из этих теорем, которую мы далее будем называть свойством сечения, относится к идентификации ребер, которые должны входить в MST-дерево заданного графа. Однако для краткой формулировки этого свойства нам понадобятся несколько основных терминов из теории графов.

Определение 20.2. Сечение (cut) графа есть разбиение множества всех вершин графа на два непересекающихся множества. Перекрестное ребро (crossing edge) — это ребро, которое соединяет вершину одного множества с вершиной другого множества.

Иногда мы описываем сечение графа, просто задав множество вершин графа, при этом неявно подразумевается, что сечение содержит это множество и его дополнение. Обычно мы будем использовать такие сечения графа, когда оба множества не пусты — иначе перекрестных ребер не будет.

Лемма 20.1. (Свойство сечения). При любом сечении графа каждое минимальное перекрестное ребро принадлежит некоторому MST-дереву, и каждое MST-дерево содержит минимальное перекрестное ребро.

Доказательство. Проведем доказательство от противного. Пусть e — минимальное перекрестное ребро, которое не принадлежит ни одному MST, и пусть T — некоторое MST-дерево; либо пусть T — MST-дерево, которое не содержит минимального перекрестного ребра, а e — любое минимальное перекрестное ребро. В любом случае T является MST-деревом, которое не содержит минимального перекрестного ребра e. Теперь рассмотрим граф, полученный добавлением ребра e в T. В этом графе имеется цикл, который содержит ребро e, и этот цикл должен содержать по крайней мере еще одно перекрестное ребро — скажем, f — с весом, равным или большим, чем вес e (в силу минимальности e). Если удалить f и добавить e, получится остовное дерево такого же или меньшего веса, что противоречит условию минимальности T или предположению, что e не содержится в T.

Если веса ребер графа различны, его MST уникально, а свойство сечения утверждает, что кратчайшее перекрестное ребро для каждого сечения должно входить в MST-дерево. При наличии равных весов может оказаться несколько минимальных секущих ребер. По крайней мере одно из них входит в состав любого заданного MST-дерева, остальные же могут входить, а могут и не входить.

На рис. 20.5 представлены несколько примеров свойства сечения. Учтите, что минимальное ребро не обязательно должно быть единственным ребром MST, которое соединяет два множества; в случае обычных сечений существует несколько ребер, соединяющих вершину одного множества с вершиной другого. Если бы существовало лишь одно такое ребро, можно было бы разработать алгоритмы " разделяй и властвуй " на основе походящего выбора множеств, однако это не так.

 Свойство сечения


Рис. 20.5.  Свойство сечения

Эти четыре примера служат иллюстрацией леммы 20.1. Если вершины одного множества закрасить серым цветом, а для другого — белым, то самое короткое ребро, соединяющее серую вершину с белой, принадлежит MST-дереву.

На свойстве сечения основаны алгоритмы вычисления MST-деревьев; кроме того, оно может служить условием оптимальности, которое характеризует MST-деревья. В частности, из него следует, что каждое ребро MST-дерева есть минимальное перекрестное ребро, которое определяется вершинами двух поддеревьев, соединенных этим ребром.

Вторая теорема — свойство цикличности — применяется для выявления ребер, которые не должны входить в MST графа. То есть игнорирование этих ребер не помешает отыскать MST.

Лемма 20.2. (Свойство цикла). Рассмотрим граф G', который получается добавлением к графу G ребра е. Добавление ребра е в MST графа G и удаление максимального ребра из полученного цикла дает MST графа G'.

Доказательство. Если ребро е длиннее всех других ребер цикла, то согласно лемме 20.1 оно не должно входить в MST-дерево графа G': удаление е из любого такого MST-дерева разделит его на две части, а е не будет самым коротким ребром, соединяющим вершины каждой из полученных двух частей, поскольку это должно быть какое-то другое ребро цикла. Иначе пусть t — максимальное ребро цикла, полученного добавлением ребра е в MST-дерево графа G. Удаление ребра t разобьет первоначальное MST-дерево на две части, а ребра графа G, соединяющие эти две части, не короче t; следовательно, е является минимальным ребром в G', которое соединяет вершины этих двух частей. Подграфы, индуцированные этими двумя подмножествами вершин, идентичны G и G', поэтому MST-дерево для G'состоит из ребра е и из MST-деревьев для этих двух подмножеств. Обратите внимание, что если ребро е — максимальное ребро в цикле, то мы показали, что существует MST-дерево графа G', которое не содержит е (MST графа G ).

Иллюстрация свойства цикла приведена на рис. 20.6. Если взять произвольное остовное дерево, добавить в него ребро, образующее цикл, а затем удалить из этого цикла максимальное ребро, то получится остовное дерево, вес которого меньше или равен весу исходного остовного дерева. Вес нового дерева будем меньше веса исходного в том и только том случае, когда добавляемое ребро короче одного из ребер цикла.

 Свойство цикла


Рис. 20.6.  Свойство цикла

После добавления ребра 1-3 в граф с рис. 20.1 его MST перестает быть деревом (вверху). Чтобы найти MST нового графа, мы добавляем в MST старого графа новое ребро, которое порождает цикл (в центре). После удаления из цикла самого длинного ребра (4-7) получается MST нового графа (внизу). Один из способов проверить минимальность остовного дерева заключается в проверке, что каждое ребро, не входящее в это MST, имеет наибольший вес в цикле, который оно образует с ребрами дерева. Например, на нижней диаграмме ребро 4-6 имеет максимальный вес в цикле 4-6-7-1-3-4.

Свойство цикла лежит в основе условия оптимальности, которое характерно для MST-деревьев: из него следует, что каждое ребро любого графа, не входящее в заданное MST, является максимальным ребром цикла, который оно образует с ребрами MST.

На свойствах сечения и цикла основаны классические алгоритмы поиска MST-дерева, которые мы будем рассматривать. Мы рассматриваем ребра по одному, проверяя с помощью свойства сечения их пригодность для MST-дерева или с помощью свойства цикла — возможность их отбрасывания. Такие алгоритмы различаются по способам идентификации сечений и циклов.

Первый подход к поиску MST-дерева, который мы подробно рассмотрим — постепенное добавление ребер в MST: сначала выбираем произвольную вершину и рассматриваем ее как MST-дерево, состоящее из одной вершины, затем добавляем к нему V— 1 вершин, каждый раз выбирая минимальное ребро, которое соединяет вершину, уже включенную в MST-дерево, с вершиной, которая еще не содержится в MST. Этот метод известен как алгоритм Прима (Prim), и он будет рассмотрен в разделе 20.3.

Лемма 20.3. Алгоритм Прима вычисляет MST-дерево любого связного графа.

Доказательство. Как подробно описано в разделе 20.2, рассматриваемый метод представляет собой обобщенный метод поиска на графе. Из доказательства леммы 18.12 следует, что выбранные ребра образуют остовное дерево. Чтобы показать, что это MST-дерево, применим свойство сечения: вершины, входящие в MST, образуют первое подмножество, а вершины, не входящие в MST — второе подмножество.

Еще один способ вычисления MST-дерева — многократное применение свойства цикла: мы добавляем ребра по одному в заготовку MST-дерева, а если при этом образуется цикл, удаляем из него максимальное ребро (см. упражнения 20.33 и 20.71). Этот метод применяется реже, чем другие рассматриваемые нами алгоритмы — из-за трудности поддержки структуры данных, которая обеспечивает эффективную реализацию операции " удалить из цикла самое длинное ребро " .

Второй подход поиска MST-дерева, который мы подробно рассмотрим — обработка ребер в порядке возрастания их длин (вначале самые короткие) с добавлением в MST каждого ребра, которое не образует цикл с ранее включенными ребрами; процесс останавливается после добавления V— 1 ребер. Этот метод известен как алгоритм Крускала (Kruskal), который будет рассмотрен в разделе 20.4.

Лемма 20.4. Алгоритм Крускала вычисляет MST любого связного графа.

Доказательство. Покажем методом индукции, что этот алгоритм поддерживает лес MST-поддеревьев. Если следующее рассматриваемое ребро приводит к образованию цикла, то это максимальное ребро в цикле (поскольку все меньшие ребра уже были выбраны). Поэтому его можно проигнорировать, а MST-дерево все равно сохраняется, согласно свойству цикла. Если следующее рассматриваемое ребро не приводит к образованию цикла, применяем свойство сечения — для сечения, определенного множеством вершин, которые связаны с одним из концов этого ребра ребрами MST-дерева (и его дополнением). Поскольку ребро не образует цикла, это просто перекрестное ребро, а поскольку ребра выбираются в порядке возрастания весов, это ребро минимальное и поэтому принадлежит MST-дереву. Основанием для индукции служат V отдельных вершин; после выбора V— 1 ребер получается одно дерево (MST). Ни одно из еще не просмотренных ребер не короче ребер из MST, а все вместе они образуют цикл — тогда по свойству цикла можно игнорировать остальных ребра, и получится MST-дерево.

Третий подход к построению MST-дерева, который мы подробно изучим в разделе 20.4 — алгоритм Борувки (Boruvka). На первом шаге к MST-дереву добавляются ребра, которые соединяют каждую вершину с ее ближайшим соседом. Если веса ребер различны, этот шаг порождает лес MST-поддеревьев (мы докажем этот факт и рассмотрим усовершенствование, которое позволяет работать даже при наличии ребер с равными весами). Затем мы добавляем в MST ребра, которые соединяют каждую вершину с ее ближайшим соседом (минимальное ребро, соединяющее вершину одного дерева с вершиной другого), и повторяем этот процесс, пока не останется только одно дерево.

Лемма 20.5. Алгоритм Борувки вычисляет MSTдля любого связного графа.

Сначала предположим, что веса всех ребер различны. В этом случае у каждой вершины имеется единственный ближайший сосед, MST-дерево уникально, и мы знаем, что каждое добавленное ребро — это ребро MST (согласно свойству сечения это самое короткое ребро, пересекающее сечение из рассматриваемой вершины во все остальные вершины). Поскольку каждое ребро выбрано из уникального MST-дерева, циклов не может быть, каждое добавленное ребро соединяет два дерева из леса в большее дерево, и процесс продолжается, пока не останется единственное дерево — MST.

При наличии одинаковых ребер ближайших соседей может быть несколько, и при добавлении ребра к ближайшему соседу возможно появление цикла (см. рис. 20.7). Другими словами, мы можем выбрать для некоторой вершины два ребра из множества минимальных секущих ребер, хотя MST-дереву принадлежит лишь одно. Чтобы избежать подобных ситуаций, необходимо подходящее правило разрыва связей. Одно из них — выбор из множества минимальных соседей вершины с наименьшим номером. Тогда любой цикл приводит к противоречию: если v — вершина с наибольшим номером в цикле, то ни одна из вершин, соседних с v, не выберет ее в качестве ближайшего соседа, и вершина v должна выбрать только одного из своих соседей с минимальным номером.

 Циклы в алгоритме Борувки


Рис. 20.7.  Циклы в алгоритме Борувки

В данном графе с четырьмя вершинами все четыре ребра имеют одинаковую длину. Перед соединением каждой вершины с ближайшим соседом необходимо решить, какое ребро выбрать из множества минимальных ребер. В верхнем примере мы выбираем 1 из вершины 0, 2 из 1, 3 из 2 и 0 из 3, что приводит к образованию цикла в заготовке MST-дерева. Каждое из ребер входит в некоторое MST-дерево, но не все они входят в каждое MST. Поэтому мы используем правило разрыва связей (внизу): выбираем минимальное ребро в вершину с наименьшим индексом. Тогда из 1 мы выбираем 0, 0 из 1, 1 из 2 и 0 из 3, что и дает MST-дерево. Цикл разорван, т.к. вершина с максимальным индексом 3 не выбрана ни из одного из ее соседей (2 или 1), а она может выбрать только одного из них (0).

Все эти алгоритмы — специальные случаи общей парадигмы, которой по сей день пользуются разработчики новых алгоритмов построения MST. А именно, мы можем в произвольном порядке применять свойство сечения для выбора того или иного ребра в качестве ребра MST или свойство цикла для отбрасывания ребра и продолжать так, пока не будет возможности увеличить количество принятых или отброшенных ребер. Тогда для любого разбиения множества вершин графа на два подмножества в MST имеется соединяющее их ребро (то есть применение свойства сечения не увеличит количество ребер в MST). И все циклы графа содержат, по меньшей мере, одно ребро, не входящее в MST (то есть применение свойства цикла не увеличит количество ребер, не входящих в MST). Оба эти свойства вместе позволяют вычислить полное MST-дерево.

Точнее, все три алгоритма, которые мы рассмотрим ниже, можно свести к одному обобщенному алгоритму. Этот алгоритм начинается с выбора леса MST-поддеревьев, состоящих из одиночных вершин (и не содержащих ребер), затем выполняется шаг добавления в MST минимального ребра, соединяющего два любых поддерева леса, и эти шаги повторяются V— 1 раз, пока не останется единственное MST-дерево. Согласно свойству сечения, ни одно из ребер, порождающих цикл, не стоит рассматривать в качестве кандидата на включение в MST-дерево, поскольку ранее одно из ребер уже было минимальным ребром, пересекающим некоторое сечение между MST-поддеревьями, содержащими его вершины. В алгоритме Прима ребра добавляются по одному к единственному дереву; алгоритмы Крускала и Борувки объединяют деревья в лесе.

Согласно описанию, приведенному в этом разделе и в классической литературе, для выполнения данных алгоритмов необходимы высокоуровневые абстрактные операции, такие как:

Наша задача заключается в разработке алгоритма и структур данных для эффективной реализации этих операций. К счастью, эта задача позволяет использовать базовые алгоритмы и структуры данных, разработанные ранее в этой книге.

Алгоритмы поиска MST-деревьев имеют долгую и интересную историю, которая еще не закончена; мы будем излагать эту историю по мере изучения конкретных алгоритмов. Развитое за много лет понимание различных методов реализации базовых абстрактных операций не позволяет объективно оценить зарождение этих алгоритмов. Вообще-то они были впервые описаны в двадцатых годах прошлого столетия — то есть до появления компьютеров в современном виде и до появления фундаментальных алгоритмов сортировки и многих других алгоритмов. Как нам теперь известно, выбор базовых алгоритмов и структур данных существенно влияет на производительность, даже при реализации простейших схем вычислений. В последние годы исследования задачи поиска MST-деревьев в основном касаются таких вопросов реализации, где используются все те же классические схемы. Для последовательности и ясности изложения мы будем называть базовые подходы по приведенным здесь именам, хотя их абстрактные версии были рассмотрены намного раньше, и современные реализации используют алгоритмы и структуры данных, разработанные намного позже этих методов.

Алгоритм построения MST-деревьев за линейное время не найден до сих пор. Как мы увидим, многие наши реализации выполняются за линейное время в большом множестве реальных ситуаций, однако в худшем случае эта зависимость уже не линейна. Разработка алгоритмов с гарантированным линейным временем выполнения на разреженных графах все еще остается недостижимой целью.

Помимо естественного поиска алгоритма решения этой фундаментальной задачи, изучение алгоритмов поиска MST подчеркивает важность понимания основных характеристик производительности фундаментальных алгоритмов. Программисты используют алгоритмы и структуры данных все более высокого уровня абстракции, и такие ситуации встречаются все чаще. Наши реализации АТД обладают различным быстродействием, и по мере использования высокоуровневых АТД в качестве компонентов алгоритмов решения задач еще более высокого уровня количество вариантов увеличивается. Алгоритмы, основанные на использовании MST и подобных им абстракций (благодаря эффективным реализациям, которые будут рассмотрены ниже в этой главе), часто используются для решения других задач на еще более высоком уровне абстракции.

Упражнения

20.26. Пронумеруйте (по порядку) от 0 до 5 следующие точки на плоскости:

(1,3) (2,1) (6,5) (3,4) (3,7) (5,3).

Принимая длину ребер в качестве их весов, приведите MST-дерево для графа, заданного множеством ребер

1-03-55-23-45-10-30-44-22-3.

20.27. Предположим, что все ребра графа имеют различные веса. Должно ли самое короткое его ребро принадлежать MST-дереву? Докажите это или приведите контрпример.

20.28. Выполните упражнение 20.27 для самого длинного ребра графа.

20.29. Приведите контрпример, демонстрирующий ошибочность следующей стратегии поиска MST: " Начните с любой вершины, считая ее MST-деревом с одной вершиной, а затем добавьте к этому дереву V— 1 вершину, всегда выбирая следующим минимальное ребро, инцидентное последней включенной в MST вершине " .

20.30. Предположим, что все ребра заданного графа имеют различные веса. Должно ли минимальное в каждом цикле ребро принадлежать MST-дереву? Докажите это утверждение или приведите контрпример.

20.31. Пусть задано MST-дерево для графа G, а затем из графа G удалено некоторое ребро. Опишите, как найти MST для нового графа за время, пропорциональное количеству ребер графа G.

20.32. Постройте MST-дерево, которое получается после многократного применения свойства цикла к графу, изображенному на рис. 20.1, если выбирать ребра в указанном порядке.

20.33. Докажите, что многократное применение свойства цикла приводит к построению MST-дерева.

20.34. Опишите, как (при необходимости) адаптировать каждый алгоритм из описанных в данном разделе для решения задачи поиска минимального остовного леса для взвешенного графа (объединение MST-деревьев его связных компонентов).

Алгоритм Прима и поиск по приоритету

Алгоритм Прима, похоже, наиболее прост для реализации из всех алгоритмов поиска MST и рекомендуется для насыщенных графов. В нем используется сечение графа, состоящее из древесных вершин (выбранных для MST) и недревесных вершин (еще не выбранных в MST-дерево). Вначале мы выбираем в качестве MST-дерева произвольную вершину, затем помещаем в MST минимальное перекрестное ребро (которое превращает недревесную вершину MST-дерева в древесную) и повторяем эту же операцию V— 1 раз, пока все вершины не окажутся в дереве.

Из этого описания непосредственно следует примитивная реализация алгоритма Прима. Чтобы найти очередное ребро для включения в MST, необходимо просмотреть все ребра, которые выходят из древесной вершины в недревесную вершину, а затем выбрать из них самое короткое и включить его в MST. Мы не будем рассматривать соответствующую программную реализацию из-за ее крайней неэффективности (см. упражнения 20.35—20.37). Этот алгоритм можно упростить и ускорить с помощью простых структур данных, позволяющих устранить повторные вычисления.

Единичным шагом в алгоритме Прима является добавление вершины в MST-дерево, и прежде чем приступать к реализации, стоит хорошенько разобраться в его сути. Здесь главное — найти кратчайшее расстояние от каждой недревесной вершины до дерева. При присоединении вершины v к дереву единственным возможным изменением для недревесных вершин w является приближение w к дереву. То есть не нужно проверять расстояние от вершины w до всех вершин дерева: достаточно знать минимальное расстояние в каждый момент и проверять, изменяет ли добавление вершины v в дерево это минимальное расстояние.

Для реализации этой идеи нам потребуются такие структуры данных, которые предоставляют следующую информацию:

Простейшей реализацией каждой из этих структур данных будет вектор, индексированный именами вершин (этот вектор можно использовать для хранения древесных ребер, выполняя индексацию по вершинам при их добавлении в дерево). Программа 20.6 представляет собой реализацию алгоритма Прима для насыщенных графов. Она использует для этих трех структур данных векторы mst, fr и wt.

После включения в дерево нового ребра (и вершины) нужно выполнить еще две задачи:

Реализация в программе 20.6 решает обе эти задачи с помощью одного просмотра недревесных вершин. Сначала она обновляет содержимое векторов wt[w] и fr[w], если v-w приближает w к дереву, после чего изменяется текущий минимум, если wt[w] (длина fr[w]) показывает, что w ближе к дереву, чем любая другая недревесная вершина с меньшим индексом.

Программа 20.6. Алгоритм Прима, реализующий построение MST-дерева

Данная реализация алгоритма Прима рекомендуется для работы с насыщенными графами и может применяться для любого представления графа, в котором возможна проверка существования указанного ребра. Внешний цикл наращивает MST-дерево, выбирая минимальные ребра, которые пересекают сечение между вершинами, входящими в MST, и вершинами, не входящими в него. Цикл по w отыскивает минимальное ребро, сохраняя (если w не содержится в MST) условие, что ребро fr[w] является самым коротким (с весом wt[w]) ребром из w в MST

Результатом вычислений является вектор указателей на ребра. Первый указатель (mst[0]) не используется, остальные (от mst[1] до mst[G.V()]) содержат MST-дерево связного компонента графа, которому принадлежит вершина 0.

  template <class Graph, class Edge>
  class MST
    { const Graph &G;
      vector<double> wt;
      vector<Edge *> fr, mst;
    public:
      MST(const Graph &G) : G(G),
        mst(G.V()), wt(G.V(), G.V()), fr(G.V())
        { int min = -1;
          for (int v = 0; min != 0; v = min)
            { min = 0;
              for (int w = 1; w < G.V(); w++)
                if (mst[w] == 0)
                  { double P; Edge* e = G.edge(v, w);
                    if (e)
                      if ((P = e->wt()) < wt[w])
                        { wt[w] = P; fr[w] = e; }
                    if (wt[w] < wt[min]) min = w;
                 }
              if (min) mst[min] = fr[min];
            }
        }
      void show()
        { for (int v = 1; v < G.V(); v++)
          if (mst[v]) mst[v]->show();
        }
    };
      

Лемма 20.6. Алгоритм Прима позволяет найти MST для насыщенного графа за линейное время.

Доказательство. Анализ программы 20.6 показывает, что время ее выполнения пропорционально V2 и поэтому линейно для случаев насыщенных графов.

На рис. 20.8 показан пример построения MST-дерева с помощью алгоритма Прима, а на рис. 20.9 показано развертывание MST для более крупного графа.

Программа 20.6 основана на следующем наблюдении: можно чередовать операции поиска минимального ребра и обновления в одном цикле, в котором просматриваются все недревесные ребра. В насыщенных графах количество ребер, которые, возможно, придется просмотреть для обновления расстояния от недревесных вершин до дерева, пропорционально V.

 Алгоритм Прима для вычисления MST


Рис. 20.8.  Алгоритм Прима для вычисления MST

Первым шагом вычисления MST-дерева по алгоритму Прима в это дерево заносится вершина 0. Затем мы находим все ребра, которые соединяют 0 с другими вершинами (еще не включенными в это дерево), и выбираем из них самое короткое (слева вверху). Ребра, соединяющие древесные вершины с недревесными (накопитель), заштрихованы и перечислены под каждым чертежом графа. Для простоты ребра из накопителя перечисляются в порядке возрастания их длины, то есть самое короткое ребро — первое в этом списке. В различных реализациях алгоритма Прима используются различные структуры данных для хранения этого списка и определения минимального ребра. Вторым шагом самое короткое ребро 0-2 переносится (вместе с его конечной вершиной) из накопителя в дерево (вторая диаграмма сверху слева). На третьем шаге ребро 0-7 переносится из накопителя в дерево, в накопителеребро 0-1 заменяется на 7-1, ребро 0-6 на 7-6 (поскольку включение вершины 7 в дерево приближает к дереву вершины 1 и 6), а ребро 7-4 заносится в накопитель (поскольку добавление вершины 7 в дерево превращает 7-4 в ребро, которое соединяет древесную вершину с недревесной) (третья диаграмма сверху слева). Далее, мы переносим в дерево ребро 7-1 (слева внизу). В завершение вычислений мы исключаем из очереди ребра 7-6, 7-4, 4-3 и 3-5, обновляя накопитель после каждой вставки для отражения обнаруженных более коротких или новых путей (справа, сверху вниз).

Ориентированный чертеж растущего MST показан справа от каждого чертежа графа. Ориентация является следствием алгоритма: само MST-дерево обычно рассматривается как неупорядоченное множество неориентированных ребер.

Алгоритм Прима для вычисления MST-дерева


Рис. 20.9.  Алгоритм Прима для вычисления MST-дерева

Эта последовательность демонстрирует рост MST-дерева при обнаружении алгоритмом Прима 1/4, 1/2, 3/4 и всех ребер MST-дерева (сверху вниз). Ориентированное представление полного MST-дерева показано справа.

Поэтому поиск ближайшего к дереву недревесного ребра не слишком трудоемок. Но в разреженном графе для выполнения каждой из этих операций может понадобиться значительно менее V шагов. Самое главное при этом — множество ребер-кандидатов для включения в MST, которое мы называем накопителем (fringe). Количество ребер в накопителе обычно существенно меньше количества недревесных ребер, поэтому можно скорректировать описание алгоритма следующим образом. Начинаем с петли исходной вершины в накопителе и до тех пор, пока накопитель не опустеет, выполняем следующие операции:

Переносим минимальное ребро из накопителя в дерево. Посещаем вершину, в которую оно ведет, и помещаем в накопитель все ребра, которые ведут из этой вершины в одну из недревесных вершин, отбрасывая ребро большей длины, если два ребра в накопителе указывают на одну и ту же вершину.

Из этой формулировки ясно, что алгоритм Прима есть ни что иное, как обобщенный поиск на графе (см. лекция №18), в котором накопитель представлен очередью с приоритетами на основе операции извлечь минимальное (см. лекция №9. Мы будем называть обобщенный поиск на графе с очередями с приоритетами поиском по приоритету (priority-first search — PFS). Если в качестве приоритетов использовать веса ребер, то поиск по приоритету реализует алгоритм Прима.

Эта формулировка учитывает важное замечание, которое мы сделали выше в лекция №18 в связи с реализацией поиска в ширину. Еще более простой общий подход — просто хранить все ребра, инцидентные древесным вершинам дерева, чтобы механизм очереди с приоритетами находил самое короткое ребро и игнорировал более длинные (см. упражнение 20.41). Как мы убедились в случае поиска в ширину, этот подход неудобен тем, что структура данных накопителя без необходимости загромождается ребрами, которые никогда не попадут в MST. Размер накопителя может возрасти пропорционально E (вместе с затратами на содержание накопителя такого размера), в то время как поиск по приоритету гарантирует, что накопитель не будет содержать более V вершин.

Как и в случае реализации общего алгоритма, имеется целый ряд возможных подходов для взаимодействия с АТД очереди с приоритетами. Один из подходов использует очередь с приоритетами для ребер так же, как в обобщенном поиске на графах из программы 18.10. Реализация в программе 20.7 по существу эквивалентна программе 18.10, но ориентирована на работу с вершинами, чтобы использовать индексированную очередь с приоритетами (см. лекция №9). (Полная реализация конкретного интерфейса очереди с приоритетами, используемого программой 20.7, приведена в программе 20.10 в конце данной главы.) Будем называть краевыми вершинами (fringe vertex) подмножество недревесных вершин, которые соединены ребрами из накопителя с вершинами дерева, и будем использовать те же векторы, индексированные именами вершин — mst, fr и wt — которые применялись в программе 20.6. Очередь с приоритетами содержит индекс каждой краевой вершины, а этот элемент очереди обеспечивает доступ к самому короткому ребру, соединяющему краевую вершину с деревом и содержит длину этого ребра (во втором и третьем векторах).

Первый вызов функции pfs (поиск по приоритету) в конструкторе программы 20.7 находит MST в связном компоненте, содержащем вершину 0, а последующие вызовы находят MST в других связных компонентах. Таким образом, в не связных графах этот класс фактически находит минимальные остовные леса (см. упражнение 20.34).

Лемма 20.7. Реализация алгоритма Прима с поиском по приоритету, в котором для реализации очереди с приоритетами применяется пирамидальное дерево, позволяет вычислить MST за время, пропорциональное E lg V.

Доказательство. Этот алгоритм напрямую реализует обобщенную идею алгоритма Прима (каждый раз добавлять в MST-дерево минимальное ребро, которое соединяет вершину из MST с вершиной, не входящей в MST). Каждая операция очереди с приоритетами требует выполнения менее lgV шагов. Каждая вершина выбирается операцией извлечь минимальное; в худшем случае каждое ребро может потребовать выполнения операции изменить приоритет.

Программа 20.7. Поиск по приоритету

Функция pfs выполняет обобщенный поиск на графе с использованием накопителя в качестве очереди с приоритетами (см. лекция №18). Приоритет P определяется так, чтобы данный класс реализовал алгоритм Прима для вычисления MST; другие определения приоритетов приведут к другим алгоритмам. Главный цикл переносит ребро с максимальным приоритетом (с наименьшим весом) из накопителя в дерево, а затем проверяет все ребра, смежные с только что занесенной в дерево вершиной, чтобы узнать, потребуются ли изменения в накопителе. Ребра, ведущие к вершинам, которые отсутствуют в накопителе и в дереве, заносятся в накопитель, при этом самые короткие ребра в вершины накопителя заменяют соответствующие ребра в накопителе.

Класс PQi представляет собой косвенный интерфейс очереди с приоритетами (см. лекция №9), в котором добавлена возможность передачи в конструктор ссылки на массив приоритетов, вызов delmax заменен на getmin, а вызов change — на lower. Реализация этого интерфейса приведена в программе 20.10.

  template <class Graph, class Edge>
  class MST
    { const Graph &G;
      vector<double> wt;
      vector<Edge *> fr, mst;
      void pfs(int s)
        { PQi<double> pQ(G.V(), wt);
          pQ.insert(s);
          while (!pQ.empty())
            { int v = pQ.getmin();
              mst[v] = fr[v];
              typename Graph::adjIterator A(G, v);
              for (Edge* e = A.beg(); !A.end(); e = A.nxt())
                { double P = e->wt(); int w = e->other(v) ;
                  if (fr[w] == 0)
                    { wt[w] = P; pQ.insert(w); fr[w] = e; }
                  else if (mst[w] == 0 && Р < wt[w])
                    { wt[w] = P; pQ.lower(w); fr[w] = e; }
                }
            }
        }
    public:
      MST(Graph &G) : G(G),
        fr(G.V()), mst(G.V()), wt(G.V() , -1)
        { for (int v = 0; v < G.V(); v++)
            if (mst[v] == 0) pfs(v);
        }
    };
      

Поиск по приоритету — подходящее обобщение поиска ширину и поиска в глубину, поскольку эти методы также могут быть получены за счет соответствующей настройки приоритета. Например, мы можем (правда, не вполне естественно) использовать переменную cnt для присвоения уникального приоритета cnt++ каждой вершине при помещении ее в очередь с приоритетами. Если определить, что P эквивалентно cnt, мы получим прямую нумерацию узлов и поиск в глубину, поскольку недавно занесенные узлы имеют наивысший приоритет. Если мы определим, что P эквивалентно V-cnt, мы получим поиск в ширину, поскольку в этом случае наивысший приоритет имеют старые узлы. Такие назначения приоритетов приводят к тому, что очередь с приоритетами ведет себя, соответственно, как стек и как обычная очередь. Подобная эквивалентность представляет чисто академический интерес, поскольку для поиска в глубину и для поиска в ширину операции очереди с приоритетами не обязательны. Кроме того, как было сказано в лекция №18, формальное доказательство эквивалентности требует особого внимания к правилам замены, чтобы получить ту же последовательность вершин, что и при работе классических алгоритмов.

Как мы увидим, поиск по приоритетам охватывает не только поиск в глубину, поиск в ширину и алгоритм Прима для вычисления MST-дерева, но и несколько других классических алгоритмов. Все эти алгоритмы отличаются только функциями вычисления приоритетов. Поэтому время их выполнения зависит от производительности АТД очереди с приоритетами. Известен общий результат, который охватывает не только две рассмотренные нами реализации алгоритма Прима, но также и широкий класс фундаментальных алгоритмов обработки графов.

 Реализация поиска по приоритету для построения MST алгоритмом Прима Используя поиск по приоритету, алгоритм Прима обрабатывает только вершины и ребра, наиболее близкие к MST-дереву (показаны серым цветом)


Рис. 20.10.  Реализация поиска по приоритету для построения MST алгоритмом Прима Используя поиск по приоритету, алгоритм Прима обрабатывает только вершины и ребра, наиболее близкие к MST-дереву (показаны серым цветом)

Лемма 20.8. Для всех графов и функций вычисления приоритетов метод поиска по приоритету позволяет вычислить остовное дерево за линейное время плюс время, пропорциональное времени выполнения V операций вставить, V операций извлечь минимальное и E операций уменьшить ключ в очереди с приоритетами, размер которой не превышает V.

Доказательство. Из доказательства леммы 20.7 следует и этот более общий результат. Нам нужно просмотреть все ребра графа; отсюда следует часть с линейным временем. Алгоритм никогда не увеличивает приоритет (поскольку изменяет приоритет лишь в сторону уменьшения). Более точно указав, что нам нужно от АТД очереди с приоритетами (уменьшить ключ, а не обязательно изменить приоритет), мы уточняем описание производительности алгоритма.

В частности, использование реализации очереди с приоритетами с помощью неупорядоченного массива дает оптимальное решение для насыщенных графов с такой же производительностью в худшем случае, что и классическая реализация алгоритма Прима (программа 20.6). То есть леммы 20.6 и 20.7 представляют собой частные случаи леммы 20.8; на протяжении этой книги нам встретятся и другие многочисленные алгоритмы, которые отличаются друг от друга только выбором функций вычисления приоритетов и реализациями очередей с приоритетами.

Свойство 20.7 представляет собой важный общий результат: устанавливаемая им временная граница есть верхняя граница для худшего случая, которая для широкого класса задач обработки графов гарантирует производительность, отличающуюся от оптимальной (линейное время) не более чем в lgV раз. Однако этот результат несколько пессимистичен для многих видов графов, с которыми нам приходится сталкиваться на практике — по двум причинам. Во-первых, граница lgV для операций с очередями с приоритетами справедлива только тогда, когда количество вершин в накопителе пропорционально V, и даже в таком случае это лишь верхняя граница. В случае реального графа из практического приложения накопитель может быть весьма небольшим (см. рис. 20.10 и рис. 20.11), а некоторые операции для очередей с приоритетами требуют гораздо менее lgV шагов.

Этот эффект заметен, но обычно увеличивает время выполнения лишь на небольшой постоянный коэффициент. Например, доказательство того, что накопитель не сможет содержать более вершин, улучшит граничное значение только в два раза. Но более важно то, что обычно количество операций уменьшить ключ намного меньше E, поскольку эти операции выполняются только при обнаружении ребра в узел, содержащийся в накопителе, которое короче кратчайшего до этого ребра такого же типа. Это происходит довольно редко: большая часть ребер никак не влияет на очередь с приоритетами (см. упражнение 20.40). Поиск по приоритету вполне можно считать линейным — кроме случаев, когда V lgV значительно больше E.

 Размер накопителя при работе алгоритма Прима, использующего поиск по приоритету


Рис. 20.11.  Размер накопителя при работе алгоритма Прима, использующего поиск по приоритету

Нижний график показывает размеры накопителя при работе PFS для примера с рис. 20.10. Выше для сравнения приведены графики для поиска в глубину, рандомизированного поиска и поиска с рис. 18.28.

АТД очереди с приоритетами и абстракции обобщенного поиска на графах позволяют понять взаимосвязь между различными алгоритмами. Поскольку эти абстракции (и программные механизмы, обеспечивающие поддержку их использования) были разработаны спустя много лет после базовых методов, соответствие алгоритмов их классическим описаниям может интересовать разве что историков. Но все же знание всех основных исторических фактов полезно, когда мы сталкиваемся с описаниями алгоритмов построения MST в исследовательских работах или в других текстах, а понимание того, как эти немногочисленные простые абстракции связывают работы многих исследователей, разделенные многими десятилетиями, убедительно доказывает их ценность и мощь. Поэтому мы кратко рассмотрим происхождение этих алгоритмов.

Реализация MST для насыщенных графов, которая фактически эквивалентна программе 20.6, была впервые опубликована Примом (Prim) в 1961 г. и, несколько позже и независимо от него, Дейкстрой (Dijkstra). Обычно она называется алгоритмом Прима, хотя формулировка Дейкстры была более общей — поэтому некоторые ученые считают алгоритм вычисления MST специальным случаем алгоритма Дейкстры. Однако основная идея была высказана Ярником (Jarnik) еще в 1939 г., так что некоторые авторы называют этот метод алгоритмом Ярника, считая, что Прим (а также Дейкстра) просто разработал эффективную реализацию алгоритма для насыщенных графов. После распространения АТД очередей с приоритетами в начале 1970-х годов применение этого алгоритма для вычисления MST на разреженных графах уже не представляло трудности. Широко известный факт, что MST для разреженных графов можно вычислить за время, пропорциональное E lgV, не связан с именем какого-либо исследователя. Как будет показано в разделе 20.6, с тех пор многие исследователи направили свои усилия на поиск эффективных реализаций как ключевого момента отыскания эффективных алгоритмов построения MST для разреженных графов.

Упражнения

20.35. Оцените производительность примитивной реализации алгоритма Прима, описанной в начале данного раздела, для графа с Vвершинами. Указание. При решении этой задачи может пригодиться комбинаторная сумма:

20.36. Выполните упражнение 20.35 для графов, у которых все вершины имеют одну и ту же фиксированную степень t.

20.37. Выполните упражнение 20.35 для разреженных графов общего вида с V вершинами и E ребрами. Поскольку время выполнения зависит от весов ребер и степеней вершин, проведите анализ для худшего случая. Приведите семейство графов, для которого действительна ваша оценка для худшего случая.

20.38. Представьте в стиле рис. 20.8 результаты вычисления MST-дерева с помощью алгоритма Прима для сети, определенной в упражнении 20.26.

20.39. Опишите семейство графов c Vвершинами и E ребрами, для которого достигается оценка времени выполнения алгоритма Прима с поиском по приоритетам в худшем случае.

20.40. Разработайте походящий генератор случайных графов с V вершинами и E ребрами, чтобы время выполнения алгоритма Прима с поиском по приоритетам (программа 20.7) было нелинейным.

20.41. Измените программу 20.7, чтобы она работала так же, как и программа 18.8, то есть хранила в накопителе все ребра, инцидентные древесным вершинам. Эмпирически сравните полученную реализацию с программой 20.7 для различных взвешенных графов (см. упражнения 20.9-20.14).

20.42. Выведите из интерфейса, определенного в программе 9.12, реализацию очереди с приоритетами для использования в программе 20.7 (чтобы было возможно использование любой реализации этого интерфейса).

20.43. Воспользуйтесь контейнером priority_queue из библиотеки STL для реализации интерфейса очереди с приоритетами, который применяется в программе 20.7.

20.44. Предположим, что вы используете реализацию очереди с приоритетами, в которой применяется сортированный список. Каким будет время выполнения в худшем случае для графа с V вершинами и с E ребрами (с точностью до постоянного множителя)? В каких случаях этот метод можно будет применять, и можно ли вообще? Обоснуйте свой ответ.

20.45. Ребро MST, удаление которого из графа приводит к увеличению веса MST, называется критическим ребром. Покажите, как найти все критические ребра в графе за время, пропорциональное E lgV.

20.46. Эмпирически сравните производительность программы 20.6 с производительностью программы 20.7, используя реализацию очереди с приоритетами в виде неупорядоченного массива для различных взвешенных графов (см. упражнения 20.9-20.14).

20.47. Эмпирически определите эффект использования в программе 20.7 реализации очереди с приоритетами на основе турнира индексного пирамидального дерева (см. упражнение 9.53) вместо программы 9.12 для различных взвешенных графов (см. упражнения 20.9-20.14).

20.48. Эмпирически проанализируйте веса деревьев (см. упражнение 20.23) как функции от V для различных взвешенных графов (см. упражнения 20.9-20.14).

20.49. Эмпирически проанализируйте максимальный размер накопителя как функции от V для различных взвешенных графов (см. упражнения 20.9-20.14).

20.50. Эмпирически проанализируйте высоту дерева как функции от V для различных взвешенных графов (см. упражнения 20.9-20.14).

20.51. Эмпирически определите зависимость результатов выполнения упражнений 20.49 и 20.50 от выбора исходной вершины. Будет ли лучше, если выбирать ее случайно?

20.52. Напишите клиентскую программу, которая выполняет динамическую графическую анимацию алгоритма Прима. Программа должна строить изображения наподобие рис. 20.10 рис. 20.10 (см. упражнения 17.56-17.60). Проверьте работу программы на случайных евклидовых графах с соседними связями и на решетчатых графах (см. упражнения 20.17 и 20.19), используя столько точек, сколько можно обработать за приемлемое время.

Алгоритм Крускала

Алгоритм Прима строит минимальное остовное дерево по одному ребру, находя на каждом шаге ребро, которое присоединяется к единственному растущему дереву. Алгоритм Крускала также строит MST, добавляя к нему по одному ребру, но в отличие от алгоритма Прима, он отыскивает ребро, которое соединяет два дерева в лесу, образованном растущими MST-поддеревьями. Построение начинается с вырожденного леса из V деревьев (каждое состоящее из одной вершины), а затем выполняется операция объединения двух деревьев (самыми короткими ребрами), пока не останется единственное дерево — MST.

На рис. 20.12 показан пример пошагового выполнения алгоритма Крускала; рис. 20.13 демонстрирует динамические характеристики этого алгоритма на более крупном примере. Разобщенный лес MST-поддеревьев постепенно объединяется в единственное дерево.

 Алгоритм Крускала вычисления MST


Рис. 20.12.  Алгоритм Крускала вычисления MST

Пусть задан список ребер графа в произвольном порядке (левый список ребер). На первом шаге алгоритма Крускала они сортируются по весам (правый список ребер). Затем мы просматриваем ребра этого списка в порядке возрастания их весов, добавляя в MST ребра, которые не создают в нем циклов. Сначала мы добавляем ребро 5-3 (самое короткое ребро), потом 7-6 (слева), затем 0-2 (справа вверху) и 0-7 (справа, вторая диаграмма сверху). Ребро 0-1 со следующим по величине весом создает цикл и поэтому не добавляется в дерево. Ребра, которые не включаются в MST, выделены в отсортированном списке серым цветом. Затем мы добавляем ребро 4-3 (справа, третья диаграмма сверху). Далее мы отбрасываем ребро 5-4, поскольку оно образует цикл, и потом добавляем 7-4 (справа внизу). Когда MST-дерево готово, любое ребро с большим весом образует цикл и поэтому будет отброшено (алгоритм останавливается, когда в MST будут включены V— 1 ребер). В отсортированном списке эти ребра помечены звездочками.

Ребра добавляются в MST-дерево в порядке возрастания их длины — таким образом, лес содержит вершины, соединенные друг с другом относительно короткими ребрами. В любой момент выполнения алгоритма каждая вершина расположена ближе к некоторой вершине своего поддерева, чем к любой другой вершине, не входящей в это дерево.

Алгоритм Крускала прост в реализации — при наличии базовых алгоритмических инструментов, рассмотренных ранее в данной книге. Можно использовать любую сортировку из описанных в части 3 для упорядочения ребер по весу и любой из алгоритмов решения задачи связности из лекция №1 для удаления циклообразующих ребер. Программа 20.8 содержит соответствующую реализацию функции построения MST для АТД графа, которая функционально эквивалентна другим реализациям MST, рассмотренным в данной главе. Эта реализация не зависит от представления графа: она вызывает клиентскую программу GRAPH, чтобы получить вектор, содержащий ребра графа, а затем на основе этого вектора строит MST-дерево.

Обратите внимание, что существуют два способа окончания работы алгоритма Крускала. Если мы найдем V- 1 ребер, то мы уже построили остовное дерево и можем остановиться. Если мы просмотрим все вершины и не найдем V— 1 древесных ребер, то это означает, что граф не является связным — точно так же, как это сделано в лекция №1.

 Алгоритм Крускала вычисления MST


Рис. 20.13.  Алгоритм Крускала вычисления MST

Эта последовательность показывает 1/4, 1/2, 3/4 и полное MST по мере его роста.

Программа 20.8. Алгоритм Крускала вычисления MST

Для отыскания MST эта реализация использует АТД сортировки из лекция №6 и АТД объединения-поиска из лекция №4, рассматривая ребра в порядке возрастания их весов и отбрасывая те ребра, которые образуют циклы, пока не будут найдены V— 1 вершин, составляющих остовное дерево.

Здесь не показан класс-оболочка EdgePtr, позволяющий функции sort сравнивать указатели на ребра с помощью перегруженной операции <, как описано в лекция №6, и вариант программы 20.2 с третьим шаблонным аргументом.

  template <class Graph, class Edge, class EdgePtr>
  class MST
    { const Graph &G;
      vector<EdgePtr> a, mst;
      UF uf;
    public:
      MST(Graph &G) : G(G), uf(G.V()), mst(G.V())
        { int V = G.V(), E = G.E();
          a = edges<Graph, Edge, EdgePtr>(G);
          sort<EdgePtr>(a, 0, E-1);
          for (int i = 0, k = 1; i < E && k < V; i++)
            if (!uf.find(a[i]->v, a[i]->w))
              { uf.unite(a[i]->v, a[i]->w);
                mst[k++] = a[i];
              }
        }
    };
      

Анализ времени выполнения алгоритма Крускала не представляет трудностей, т.к. известно время выполнения составляющих его операций АТД.

Лемма 20.9. Алгоритм Крускала вычисляет MST-дерево графа за время, пропорциональное ElgE.

Доказательство. Эта лемма является следствием более общего факта: время выполнения программы 20.8 пропорционально затратам на сортировку E чисел плюс затратам на выполнение E операций найти и V— 1 операций объединить. Если использовать стандартные реализации АТД, такие как сортировка слиянием и взвешенный алгоритм поиска-объединения с делением пополам, то основные затраты приходятся на сортировку.

Сравнение производительности алгоритмов Крускала и Прима будет выполнено в разделе 20.6. А пока учтите, что время выполнения, пропорциональное E lgE, не обязательно хуже, чем ElgV: т.к. E не превышает V2, то lgE не превосходит 2 lgV Различия в производительности для конкретных графов обусловлены особенностями реализации и тем, приближается ли фактическое время выполнения к граничным значениям для худшего случая.

На практике можно воспользоваться быстрой сортировкой или быстрой системной сортировкой (которая обычно основана на быстрой сортировке). Теоретически такой подход может быть непривлекательным из-за квадратичной трудоемкости сортировки в худшем случае, однако обычно при этом время выполнения уменьшается. Хотя вообще-то можно воспользоваться поразрядной сортировкой, чтобы выполнить упорядочение ребер за линейное время (при определенных ограничениях на веса ребер) — тогда будут превалировать затраты на выполнение E операций найти. Это позволит изменить формулировку леммы 20.9: при выполнении заданных ограничений на веса ребер время выполнения алгоритма Крускала не превышает с некоторым постоянным коэффициентом (см. лекция №2). Напомним, что функция равна количеству итераций двоичной логарифмической функции, прежде чем результат станет меньше единицы; это значение меньше 5, если E меньше 265536. То есть такая корректировка делает алгоритм Крускала по сути линейным в большинстве практических ситуаций.

Обычно трудоемкость вычисления MST с помощью алгоритма Крускала даже меньше стоимости обработки всех ребер, поскольку построение завершается задолго до того, как будет просмотрена значительная часть всех ребер (длинного) графа. Этот факт позволяет существенно уменьшить время выполнения во многих практических ситуациях, если вообще не включать в сортировку все ребра, длина которых превышает длину самого длинного ребра MST. Один из самых простых способов достижения этой цели — использование очереди с приоритетами, в реализации которой имеется операция создать с линейным временем выполнения и операция извлечь минимальное с логарифмическим временем.

Например, таких характеристик производительности можно достичь с помощью стандартной реализации пирамидального дерева, используя восходящее построение (см. лекция №9). При этом в программу 20.8 нужно внести следующие изменения: вызов sort заменить вызовом pq.construct(), чтобы строить пирамидальное дерево за время, пропорциональное E, добавить во внутренний цикл отбор из очереди с приоритетами самых коротких ребер, для которых e = pq.delmin(), и заменить все обращения к a[i] на e.

Лемма 20.10. Вариант алгоритма Крускала на основе очереди с приоритетами вычисляет MST графа за время, пропорциональное E + X lgV, где X — количество ребер графа, не превосходящих по длине самое длинное ребро в MST.

Доказательство. Приведенное выше рассуждение показывает, что трудоемкость алгоритма состоит из затрат на построение очереди с приоритетами размером E плюс стоимость выполнения X операций извлечь минимальное, X операций найти и V- 1 операций объединить. Обратите внимание, что если X не больше E / lgV, то основная доля затрат приходится на построение очереди с приоритетами (а алгоритм линеен по времени).

Эта же идея позволяет получить аналогичные преимущества и в реализации на основе быстрой сортировки. Рассмотрим, что произойдет, если использовать прямую рекурсивную быструю сортировку, где выполняется разбиение по i-му элементу с последующей рекурсивной сортировкой подфайла слева от i и подфайла справа от i. В силу построения алгоритма после завершения первого рекурсивного вызова первые i элементов уже упорядочены (см. программу 9.2). Этот очевидный факт позволяет получить быструю реализацию алгоритма Крускала: если поместить проверку, порождает ли ребро a[i] цикл, между рекурсивными вызовами, то получится алгоритм, который, по построению, после завершения первого рекурсивного вызова уже проверил первые i ребер (в порядке возрастания весов)! Если добавить проверку на прекращение работы после выявления V- 1 ребер MST-дерева, то получается алгоритм, который сортирует лишь столько ребер, сколько их необходимо для вычисления MST-дерева, плюс несколько дополнительных этапов разбиения с участием больших элементов (см. упражнение 20.57). Как и в случае простых реализаций сортировки, этот алгоритм может потребовать квадратичного времени выполнения в худшем случае, однако имеется вероятностная гарантия того, что время выполнения в худшем случае не будет близким к такому пределу. Кроме того, подобно простым реализациям сортировки, эта программа из-за более короткого внутреннего цикла обычно работает быстрее реализации на основе пирамидального дерева.

Если же граф не является связным, то версия алгоритма Крускала на основе частичной сортировки не дает никаких преимуществ, поскольку в этом случае приходится просматривать все ребра графа. Даже в случае связного графа самое длинное ребро может оказаться в MST-дереве, и тогда любая реализация метода Крускала должна просмотреть все ребра. Например, граф может состоять из плотных кластеров вершин, соединенных внутри короткими ребрами, и только одна из вершин кластера соединена с " внешней " вершиной длинным ребром. Несмотря на возможность таких нетипичных вариантов, подход с применением частичной сортировки достоин внимания, поскольку дает существенный выигрыш и не требует больших дополнительных затрат (или вовсе никаких).

Интересны и поучительны и исторические сведения. Крускал предложил свой алгоритм в 1956 г., однако, опять-таки, в течение многих лет не были подробно изучены соответствующие реализации АТД. Поэтому характеристики производительности реализаций, таких как версия программы 20.8 с очередью с приоритетами, не получили надлежащей оценки вплоть до 1970-х годов. Еще один интересный исторический факт: в статье Крускала упоминалась версия алгоритма Прима (см. упражнение 20.59), а Борувка описал в своей статье оба эти подхода. Эффективные реализации метода Крускала для разреженных графов появились раньше реализаций метода Прима для разреженных графов — потому что АТД поиска-объединения (и сортировки) стали применяться раньше, чем АТД очереди с приоритетами. По существу, прогресс в современном состоянии алгоритма Крускала, как и реализации алгоритма Прима, обусловлен главным образом повышением производительности АТД. С другой стороны, возможность применения абстракции поиска-объединения в алгоритме Крускала и возможность применения абстракции очереди с приоритетами в алгоритме Прима стали для многих исследователей основным стимулом для поиска более совершенных реализаций этих АТД.

Упражнения

20.53. Покажите в стиле рис. 20.12 результат вычисления алгоритмом Крускала MST-дерева для сети, определенной в упражнении 20.26.

20.54. Эмпирически определите для различных видов взвешенных графов длину самого длинного ребра MST-дерева и количество ребер графа, длина которых не превосходит длины этого ребра (см. упражнения 20.9—20.14).

20.55. Разработайте реализацию АТД объединения-поиска, в которой операция найти выполняется за постоянное время, а операция объединить — за время, пропорциональное lgV.

20.56. Эмпирически сравните для различных видов взвешенных графов реализацию АТД из упражнения 20.55 со взвешенным объединением-поиском с делением пополам (программа 1.4), когда алгоритм Крускала является клиентской программой (см. упражнения 20.9—20.14). Выделите затраты на сортировку ребер отдельно, чтобы можно было изучать влияние замены на общие затраты и на часть расходов, связанных с АТД объединения-поиска.

20.57. Разработайте реализацию на основе описанной в тексте идеи: интеграции алгоритма Крускала с быстрой сортировкой, чтобы проверять каждое ребро на принадлежность MST-дереву сразу после проверки всех ребер с меньшими весами.

20.58. Добавьте в алгоритм Крускала реализации двух функций АТД, которые заполняют вектор, индексированный именами вершин, который поставляется клиентской программой. Этот вектор разбивает вершины на к таких кластеров, что ни одно ребро с длиной, большей d, не соединяет две вершины из различных кластеров. Первая функция принимает в качестве аргумента к и возвращает d, а вторая принимает в качестве аргумента d и возвращает к. Протестируйте полученную программу для различных к и d на случайных евклидовых графах с соседними связями и на решетчатых графах (см. упражнения 20.17 и 20.19) различных размеров.

20.59. Разработайте реализацию алгоритма Прима, основанного на предварительной сортировке ребер.

20.60. Напишите клиентскую программу, которая выполняет динамическую графическую анимацию алгоритма Крускала (см. упражнение 20.52). Проверьте полученную программу на случайных евклидовых графах с соседними связями и на решетчатых графах (см. упражнения 20.17 и 20.19) , используя столько точек, сколько можно обработать за приемлемое время.

Алгоритм Борувки

Следующий алгоритм вычисления MST, который мы рассмотрим, также является самым старым. Подобно алгоритму Крускала, мы строим MST, добавляя ребра в расширяющийся лес MST-поддеревьев, но делаем это поэтапно, добавляя в MST по нескольку ребер. На каждом этапе мы отыскиваем наиболее короткое ребро, которое соединяет каждое MST-поддерево с каким-то другим, а затем включаем все такие ребра в MST.

И опять разработанный в лекция №1 АТД объединения-поиска позволяет получить эффективную реализацию. Для рассматриваемой задачи лучше расширить интерфейс этого АТД, чтобы операция найти была доступна в клиентских программах. Мы будем пользоваться этой функцией для присваивания индекса каждому поддереву, чтобы можно было быстро определить, к какому поддереву принадлежит заданная вершина. Это позволит нам эффективно реализовать все операции, необходимые для работы алгоритма Борувки.

Вначале мы строим вектор, индексированный именами вершин, который для каждого MST-поддерева определяет ближайшего соседа. Затем для каждого ребра графа мы выполняем следующие операции:

После просмотра всех ребер графа вектор ближайших соседних вершин содержит информацию, которая нужна для соединения поддеревьев. Для каждого индекса вершины мы выполняем операцию объединить, чтобы соединить ее с ближайшей соседней вершиной. На следующем этапе мы отбрасываем все более длинные ребра, которые соединяют другие пары вершин в уже соединенных MST-поддеревьях. Работа нашего алгоритма продемонстрирована на рис. 20.14 и 20.15.

Программа 20.9 представляет собой непосредственную реализацию алгоритма Борувки. Ее эффективность обусловлена тремя следующими главными факторами:

 Алгоритм Борувки вычисления MST


Рис. 20.14.  Алгоритм Борувки вычисления MST

На верхней диаграмме показаны ориентированные ребра, проведенные из каждой вершины к ближайшему соседу. Из этой диаграммы следует, что ребра 0-2, 1-7 и 3-5 являются самыми короткими ребрами, инцидентными обеим их вершинам, 6-7 — кратчайшее ребро для вершины 6, а 4-3 — кратчайшее ребро для вершины 4. Все эти ребра принадлежат MST и образуют лес MST-поддеревьев (в центре), вычисляемый на первом этапе выполнения алгоритма Борувки. На втором этапе алгоритм завершает вычисление MST-поддеревьев (внизу). Для этого добавляет-сяребро 0-7 — кратчайшее ребро, инцидентное каждой вершине тех поддеревьев, которые оно соединяет; и ребра 4-7 — кратчайшее ребро, инцидентное каждой вершине нижнего поддерева.

 Массив объединения-поиска в алгоритме Борувки


Рис. 20.15.  Массив объединения-поиска в алгоритме Борувки

Здесь показано содержимое массива объединения-поиска, соответствующего примеру с рис. 20.14. Первоначально каждый элемент содержит свой собственный индекс, что означает лес изолированных вершин. После выполнения первого этапа мы получаем три компонента, представленные вершинами 0, 1 и 3 (для такого маленького примера все деревья объединения-поиска являются плоскими). По окончании второго этапа остается единственный компонент, представленный вершиной 1.

Программа 20.9. Алгоритм Борувки вычисления MST

Эта реализация алгоритма Борувки вычисления MST использует версию АТД объединения-поиска из лекция №4 (в интерфейс добавлена функция find с одним аргументом), которая связывает индексы с MST-поддеревьями по мере их построения. На каждом этапе проверяются все оставшиеся ребра, и те, которые соединяют отдельные поддеревья, сохраняются до следующего этапа. Массив a содержит еще не отброшенные ребра, которые пока не включены в MST-поддеревья. Индекс N используется для хранения ребер, отложенных до следующего этапа (в конце каждого этапа в E заносится значение N), а индекс h используется для доступа к следующему проверяемому ребру. Ближайший сосед каждого компонента хранится в массиве b с find номерами компонентов в качестве индексов. В конце каждого этапа каждый компонент соединяется с ближайшим соседним, а ребра ближайшей соседней вершины добавляются в дерево MST.

  template <class Graph, class Edge>
  class MST
    { const Graph &G;
      vector<Edge *> a, b, mst;
      UF uf;
    public:
      MST(const Graph &G): G(G), uf(G.V()), mst(G.V()+1)
        { a = edges<Graph, Edge>(G);
          int N, k = 1;
          for (int E = a.size(); E != 0; E = N)
            { int h, i, j;
              b.assign(G.V(), 0);
              for (h = 0, N = 0; h < E; h++)
                { Edge *e = a[h];
                  i = uf.find(e->v()), j = uf.find(e->w());
                  if (i == j) continue;
                  if (!b[i] || e->wt() < b[i]->wt()) b[i] = e;
                  if (!b[j] || e->wt() < b[j]->wt()) b[j] = e;
                  a[N++] = e;
                }
              for (h = 0; h < G.V(); h++)
                if (b[h])
                  if (!uf.find(i = b[h]->v(), j = b[h]->w()))
                    { uf.unite(i, j); mst[k++] = b[h]; }
             }
        }
    };
      

Все эти факторы трудно оценить точно, однако нетрудно установить следующую границу.

Лемма 20.11. Время вычисления MSTзаданного графа алгоритмом Борувки равно .

Доказательство. Поскольку на каждом этапе количество деревьев в лесе уменьшается по крайней мере наполовину, количество этапов не превышает значения lgV. Время выполнения каждого этапа не более чем пропорционально трудоемкости E операций найти, что меньше E lg*E, то есть практически линейно.

Оценка времени, приведенная в лемме 20.11, представляет собой осторожную верхнюю границу, поскольку в ней не учитывается существенное уменьшение количества ребер на каждом этапе. На ранних этапах операция найти выполняется за постоянное время, а на последних этапах остается совсем немного ребер. Для многих графов количество ребер экспоненциально убывает в зависимости от количества вершин, а общее время выполнения пропорционально E. Например, как показано на рис. 20.16, рассматриваемый алгоритм находит MST нашего большого демонстрационного графа всего за четыре этапа.

 Алгоритм Борувки вычисления MST


Рис. 20.16.  Алгоритм Борувки вычисления MST

Для построения MST в данном примере достаточно всего четырех этапов (сверху вниз).

Множитель можно устранить, и тогда теоретическая граница времени выполнения алгоритма Борувки еще уменьшится и станет пропорциональна E lgV. Для этого вместо использования операций объединить и найти необходимо представить MST-поддеревья в виде двухсвязных списков. Однако это усовершенствование гораздо сложнее для реализации, а возможное повышение производительности не настолько заметно, чтобы стоило применять его на практике (см. упражнения 20.66 и 20.67).

Как было сказано, алгоритм Борувки — самый старый алгоритм из рассматриваемых нами: его идея впервые была выдвинута в 1926 г. для управления распределением электроэнергии. Затем он был заново открыт Сойеном (Sollin) в 1961 г.; а позже он привлек к себе внимание как основа для алгоритмов вычисления MST с эффективной асимптотической производительностью и как основа для алгоритмов параллельного построения MST.

Упражнения

20.61. Покажите в стиле рис. 20.14 результат вычисления алгоритмом Борувки MST для сети, определенной в упражнении 20.26.

20.62. Почему программа 20.9 выполняет проверку операции найти перед выполнением операции объединить? Указание. Рассмотрите ребра одинаковой длины.

20.63. Объясните, почему в программе 20.9 в проверке, защищающей операцию найти, значение b(h) может быть пустым.

20.64. Опишите семейство графов с Vвершинами и E ребрами, для которых количество ребер, которые остаются после каждого этапа алгоритма Борувки, достаточно велико для того, чтобы время выполнения было равно времени для худшего случая.

20.65. Разработайте реализацию алгоритма Борувки, основанную на предварительной сортировке ребер.

20.66. Разработайте реализацию алгоритма Борувки, который использует для представления MST-поддеревьев двухсвязные кольцевые списки, чтобы на каждом этапе можно было выполнять слияние и переименование поддеревьев за время, пропорциональное E (тогда АТД отношения эквивалентности не нужен).

20.67. Эмпирически сравните реализацию алгоритма Борувки из упражнения 20.66 с реализацией, приведенной в тексте (программа 20.9) для различных взвешенных графов (см. упражнения 20.9—20.14).

20.68. Составьте на основе эмпирических данных таблицу с количеством этапов и количеством ребер, обрабатываемых на каждом этапе в алгоритме Борувки для различных взвешенных графов (см. упражнения 20.9—20.14).

20.69. Разработайте реализацию алгоритма Борувки, которая на каждом этапе строит новый граф (по одной вершине для каждого дерева в лесе).

20.70. Напишите клиентскую программу, которая выполняет динамическую графическую анимацию алгоритма Борувки (см. упражнения 20.52 и 20.60). Проверьте полученную программу на случайных евклидовых графах с соседними связями и на решетчатых графах (см. упражнения 20.17 и 20.19) , используя столько точек, сколько можно обработать за приемлемое время.

Сравнения и усовершенствования

Значения времени выполнения рассмотренных базовых алгоритмов вычисления MST приведены в таблице 20.1, а в таблице 20.2 собраны результаты эмпирического сравнения этих алгоритмов. Эти данные показывают, что реализация алгоритма Прима для матрицы смежности лучше подходит для насыщенных графов, что все другие методы отличаются по производительности от наилучшего результата лишь небольшими постоянными множителями (время на извлечение ребер) для графов средней насыщенности, и что метод Крускала сводит задачу к сортировке для разреженных графов.

В общем, для практических целей задачу вычисления MST можно считать " решенной " . Для большинства графов трудоемкость нахождения MST-дерева лишь ненамного превышает затраты на извлечение ребер графа. Это правило не распространяется на крупные очень разреженные графы, но даже и в этом случае производительность может быть повышена примерно в 10 раз по сравнению с лучшими из других известных алгоритмов. Результаты, приведенные в таблице 20.2, зависят от модели, использованной для генерирования графов, хотя они характерны и для многих других моделей (см., например, упражнение 20.80). Однако теоретические результаты не отрицают существования алгоритмов с гарантированным линейным временем выполнения на всех графах. Здесь мы просто ознакомимся с обширными исследованиями по совершенствованию реализаций этих методов.

Здесь приведены значения трудоемкости (в худшем случае) различных алгоритмов вычисления MST-дерева для алгоритмов, рассмотренных в данной главе. Все формулы выведены в предположении, что MST существует (то есть E > V— 1), и имеются Xребер, длина которых не больше длины самого длинного ребра в MST (см. лемму 20.10). Эти граничные значения для худшего случая могут оказаться слишком осторожными для прогнозирования трудоемкости обработки реальных графов. В большом количестве реальных ситуаций время выполнения алгоритмов почти линейно.

Таблица 20.1. Трудоемкость алгоритмов вычисления MST
АлгоритмЗатраты в худшем случаеПримечание
Прима (стандартный) V2 Оптимален для насыщенных графов.
Прима (PFS, пирамидальное дерево) E lgV Осторожная верхняя граница.
Прима (PFS, пирамидальное d-дерево) Линейное время выполнения на всех графах, кроме очень разреженных.
Крускала E lgE Превалируют затраты на сортировку.
Крускала (частичная сортировка) E + X lgV Затраты зависят от веса самого длинного ребра.
Борувки E lgE Осторожная верхняя граница.

Прежде всего, обширные исследования привели к разработке более совершенных реализаций очереди с приоритетами. Расширение биномиальной очереди — пирамидальное дерево Фибоначчи (Fibonacci heap) — достигает теоретически оптимальной производительности, выполняя операции уменьшить ключ за постоянное время и операции извлечь минимальное за логарифмическое время, что, в соответствие с леммой 20.8, приводит к выполнению алгоритма Прима за время, пропорциональное E + VlgV Пирамидальные деревья Фибоначчи более сложны, чем биномиальные очереди, и не совсем удобны для работы, а некоторые из простых реализации очередей с приоритетами имеют похожие характеристики производительности (см. раздел ссылок).

Один из эффективных подходов — применение поразрядных методов в реализации очереди с приоритетами. По производительности такие методы обычно эквивалентны методу Крускала с поразрядной сортировкой или даже применению поразрядной быстрой сортировки для метода с частичным упорядочением, который был рассмотрен в разделе 20.4.

Одним из наиболее эффективных является другой давно известный простой подход, предложенный Д. Джонсоном (D. Jonhson) в 1977 г.: реализация очереди с приоритетами для алгоритма Прима с помощью d-арных пирамидальных деревьев, а не стандартных бинарных пирамидальных деревьев (см. рис. 20.17).

Здесь приведены относительные значения времени выполнения различных алгоритмов вычисления MST для случайных взвешенных графов разной насыщенности. При малых плотностях лучше работает алгоритм Крускала, поскольку в нем задействована быстрая сортировка. При большой насыщенности лучшей является классическая реализация алгоритма Прима, поскольку в ней нет обработки списков. Для графов средней плотности реализация алгоритма Прима с поиском по приоритету выполняется за время, необходимое для проверки каждого ребра графа, с небольшим постоянным коэффициентом.

Таблица 20.2. Эмпирическое сравнение алгоритмов вычисления MST
EVCHJPK K* e/E B e/E
Насыщенность 2
200001000022227911 1,00 14 3,3
5000025000869842431 1,00 38 3,3
10000050000151692034966 1,00 89 3,8
20000010000030389478108142 1,00 189 3,6
Насыщенность 20
2000010002542065 0,20 9 4,2
5000025001212131301615 0,28 25 4,6
10000050001427283431 0,30 55 4,6
200000100002961617368 0,35 123 5,0
Насыщенность 100
1000001000141717243019 0,06 51 4,6
25000025003644441308153 0,05 143 5,2
5000005000739393181113 0,06 312 5,5
100000010000151204198377218 0,06 658 5,6
Насыщенность V/2,5
40000010006160592013778 0,02 188 4,5
250000025005974094001281056687 0,01 1472 5,5
Обозначения:
CИзвлекается всего ребер.
HАлгоритм Прима (списки смежности и индексированное пирамидальное дерево).
JВерсия Джонсона алгоритма Прима (очередь с приоритетами на основе а-арного пирамидального дерева.
PАлгоритм Прима (представление матрицей смежности).
KАлгоритм Крускала.
K* Версия алгоритма Крускала с частичной сортировкой.
BАлгоритм Борувки.
eПросмотренные ребра (операции объединить).

Программа 20.10 представляет собой полную реализацию используемого нами интерфейса очереди с приоритетами, которая основана на этом методе. При такой реализации очереди с приоритетами операция уменьшить ключ выполняется менее чем за logdV шагов, а операция извлечь минимальное — за время, пропорциональное . Тогда, согласно лемме 20.8, время выполнения алгоритма Прима будет пропорционально , т.е. линейно для не разреженных графов.

Лемма 20.12. Пусть задан граф с V вершинами и E ребрами, а d означает его насыщенность E/ V. Если d < 2, то время выполнения алгоритма Прима пропорционально VlgV. Иначе можно уменьшить время выполнения в худшем случае в lg(E/V) раз, используя очередь с приоритетами на основе -арного пирамидального дерева.

Доказательство. Продолжая рассуждения из предыдущего абзаца, получим, что количество шагов равно , поэтому время выполнения не более чем пропорционально .

Если E пропорционально , то из леммы 20.12 следует, что время выполнения в худшем случае пропорционально , а это значение линейно при любом значении константы . Например, если количество ребер пропорционально V3/2, то затраты меньше 2E; если количество ребер пропорционально V4/3, то затраты меньше 3E; а если количество ребер пропорционально V5/4, то затраты не превышают 4E. Для графа с одним миллионом вершин и насыщенностью не более 10 затраты меньше 6E.

Соблазн минимизировать таким способом граничное значение времени выполнения в худшем случае упирается в понимание, что часть никуда девать не удастся (для операции извлечь минимальное необходимо просмотреть d потомков при спуске по пирамидальному дереву), хотя часть вряд ли будет достижима (т.к. большая часть ребер не требует обновления очереди с приоритетами, как было показано при обсуждении вслед за леммой 20.8).

Для типичных графов, наподобие задействованных в экспериментах для таблицы 20.2, уменьшение d не влияет на время выполнения, а использование больших значений d может слегка замедлить реализацию. Однако элементарная защита от худшей производительности делает целесообразной реализацию этого метода в силу ее простоты. В принципе, можно настроить реализацию так, чтобы можно было выбирать оптимальное значение d для некоторых видов графов (выбирайте наибольшее значение, которое не замедляет алгоритм), но вполне подойдет и небольшое фиксированное значение (например, 3, 4 или 5), за исключением, разве что, отдельных крупных классов графов с нетипичными характеристиками.

 2-, 3- и 4-арные пирамидальные деревья


Рис. 20.17.  2-, 3- и 4-арные пирамидальные деревья

Если хранить стандартное бинарное пирамидально упорядоченное полное дерево в массиве (вверху), то для перехода из узла i вниз по дереву в его дочерние узлы 2i и 2i + 1 и вверх по дереву к его предку i/2 используются неявные ссылки. В 3-арном пирамидальном дереве (в центре) неявными ссылками узла i являются ссылки на дочерние узлы 3i — 1, 3i и 3i + 1 и на родительский узел . Наконец, в 4- арном пирамид-лальном дереве (внизу) неявными ссылками узла i являются ссылки на дочерние узлы 4i — 2, 4i — 1, 4i и 4 i + 1 и на родительский узел . Увеличение коэффициента ветвления в реализации неявного пирамидального дерева может оказаться полезным в приложениях, подобных алгоритму Прима, где требуется выполнение большого количества операций уменьшить ключ.

Программа 20.10. Реализация очереди с приоритетами на основе многопутевого пирамидального дерева

Этот класс использует многопутевые пирамидальные деревья для реализации косвенного интерфейса очереди с приоритетами, который используется в данной книге. В его основе лежат следующие изменения, внесенные в программу 9.12: конструктор принимает ссылку на вектор приоритетов, вместо delmax и change реализованы функции getmin и lower, и обобщены функции fixUp и fixDown, чтобы они могли поддерживать пирамидальное d-дерево. В силу последнего изменения операция извлечь минимальное выполняется за время, пропорциональное , но операция уменьшить ключ выполняется менее чем за шагов.

  template <class keyType>
  class PQi
    { int d, N;
      vector<int> pq, qp;
      const vector<keyType> &a;
      void exch(int i, int j)
        { int t = pq[i]; pq[i] = pq[j]; pq[j] = t;
          qp[pq[i]] = i; qp[pq[j]] = j;
        }
      void fixUp(int k)
        { while (k > 1 && a[pq[(k+d-2)/d]] > a[pq[k]])
            { exch(k, (k+d-2)/d); k = (k+d-2)/d; }
        }
      void fixDown(int k, int N)
        { int j;
          while ((j = d*(k-1)+2) <= N)
            { for (int i = j+1; i < j+d && i <= N; i++)
              if (a[pq[j]] > a[pq[i]]) j = i;
              if (!(a[pq[k]] > a[pq[j]])) break;
              exch(k, j); k = j;
            }
        }
    public:
      PQi(int N, const vector<keyType> &a, int d = 3) :
        a(a), pq(N+1, 0), qp(N+1, 0), N(0), d(d) { }
      int empty() const { return N == 0; }
      void insert(int v)
        { pq[ + +N] = v; qp[v] = N; fixUp(N); }
      int getmin()
        { exch(1, N); fixDown(1, N-1); return pq[N--]; }
      void lower(int k)
        { fixUp(qp[k]); }
    };
      

Использование пирамидальных d-деревьев неэффективно для разреженных графов, поскольку d должно быть целым числом, большим или равным 2, из чего следует, что мы не можем получить асимптотическое время выполнения меньше VlgV. Если плотность графа принимает небольшое постоянное значение, то линейный по времени алгоритм вычисления MST будет выполняться за время, пропорциональное V.

Цель разработки практических алгоритмов вычисления MST для разреженных графов за линейное время все еще не достигнута. Интенсивно изучались различные варианты алгоритма Борувки как основы для алгоритмов вычисления MST для сильно разреженных графов за почти линейное время (см. раздел ссылок). Такие исследования позволяют надеяться на получение в будущем линейного по времени алгоритма, пригодного для практических целей; было даже доказано существование рандомизированного линейного по времени алгоритма. Такие алгоритмы обычно довольно сложны, но упрощенные версии некоторых из них могут оказаться вполне работоспособными. А пока в большинстве практических ситуаций мы можем использовать рассмотренные здесь базовые алгоритмы для вычисления MST-дерева за линейное время — возможно, с дополнительным множителем lgV для некоторых разреженных графов.

Упражнения

20.71.[ В. Высоцкий] Разработайте реализацию алгоритма, описанного в разделе 20.2, который строит MST, добавляя в него ребра по одному за раз и удаляя самые длинные ребра из образующихся циклов (см. упражнение 20.33). Воспользуйтесь представлением леса MST-поддеревьев в виде родительских ссылок. Указание. При обходе путей в деревьях меняйте направления указателей на обратные.

20.72. Эмпирически сравните время выполнения реализации из упражнения 20.71 и времени выполнения алгоритма Крускала для различных видов взвешенных графах (см. упражнения 20.9—20.14). Проверьте, влияет ли на результаты рандомизация порядка просмотра ребер.

20.73. Опишите, как можно найти MST для такого большого графа, что в памяти одновременно могут находиться лишь V ребер.

20.74. Разработайте реализацию очереди с приоритетами, в которой операции извлечь минимальное и найти минимальное выполняются за постоянное время, а время выполнения операции уменьшить ключ пропорционально логарифму размера очереди с приоритетами. Сравните полученную реализацию с пирамидальными 4-деревьями, когда для вычисления MST-дерева разреженных графов используется алгоритм Прима, для различных видов взвешенных графов (см. упражнения 20.9—20.14).

20.75. Эмпирически сравните производительность различных реализаций очереди с приоритетами при использовании алгоритма Прима для различных видов взвешенных графов (см. упражнения 20.9—20.14). Рассмотрите пирамидальные d-деревья для различных значений d, биномиальные очереди, контейнер priority_queue из библиотеки STL, сбалансированные деревья и любые другие структуры данных, которые вы сочтете эффективными.

20.75. Разработайте реализацию, которая позволяет использовать в алгоритме Борувки обобщенную очередь, содержащую лес MST-поддеревьев. (Применение программы 20.9 соответствует использованию очереди FIFO.) Поэкспериментируйте с другими реализациями обобщенных очередей для различных видов взвешенных графов (см. упражнения 20.9—20.14).

20.77. Разработайте генератор случайных связных кубических графов (каждая вершина которого имеет степень 3) с ребрами случайных весов. Подстройте рассмотренные нами алгоритмы вычисления MST для этого случая, а затем определите, какой из них работает быстрее.

20.78. Для V = 106 начертите график зависимости отношения верхней границы трудоемкости алгоритма Прима с пирамидальным d-деревом к E от насыщенности d, для а из диапазона от 1 до 100.

20.79. Из таблицы 20.2 следует, что стандартная реализация алгоритма Крускала работает значительно быстрее, чем реализация с частичным упорядочением, для графов малой плотности. Объясните это явление.

20.80. Эмпирически определите в стиле таблицы 20.2 результаты для случайных полных графов с нормально распределенными весами (см. упражнение 20.18).

Евклидово MST-дерево

Пусть даны N точек на плоскости, и нужно найти кратчайшее множество линий, соединяющих все эти точки. Эта геометрическая задача называется задачей поиска евклидова MST-дерева (Euclidian MST) (см. рис. 20.18). Один из способов ее решения — построение полного графа с N вершинами и N(N — 1)/2 ребрами — каждую пару вершин соединяет одно ребро, а вес этого ребра равен расстоянию между его вершинами. Затем при помощи алгоритма Прима можно найти MST-дерево за время, пропорциональное N2.

Обычно это решение работает слишком медленно. Евклидова задача несколько отличается от задач на графах, которые мы рассматривали выше: в ней все ребра определены неявно. Объем входных данных пропорционален N, так что наш первый вариант решения этой задачи является квадратичным. Исследования показывают, что возможны лучшие решения. Геометрическая структура подразумевает, что большая часть ребер полного графа не понадобится при решении задачи, и большая часть этих ребер не нужна для построения минимального остовного дерева.

 Евклидово MST-дерево


Рис. 20.18.  Евклидово MST-дерево

Евклидово MST-дерево для заданного множества N точек на плоскости (вверху) — это кратчайшее множество соединяющих их линий (внизу). Эта задача выходит за рамки задач обработки графов, поскольку для ее решения необходимо использовать глобальную геометрическую информацию о точках на плоскости, чтобы не обрабатывать все N2 неявных ребер, соединяющих эти точки.

Лемма 20.13. Евклидово MST-дерево для N точек можно найти за время, пропорциональное N logN.

Это утверждение непосредственно следует из двух важных фактов, касающихся точек на плоскости, которые будут рассмотрены в части 7. Во-первых, граф, известный как триангуляция Делоне (Delauney triangulation), по определению содержит MST-дерево. Во-вторых, триангуляция Делоне представляет собой планарный граф, количество ребер которого пропорционально N.

В принципе, триангуляцию Делоне можно вычислить за время, пропорциональное N logN, а затем вычислить евклидово MST-дерево за время, пропорциональное N logN, с помощью либо алгоритма Крускала, либо метода поиска по приоритету. Однако написание программы вычисления триангуляции Делоне — задача не из легких даже для опытного программиста, поэтому на практике такой подход может оказаться слишком трудным для решения подобных задач.

Другие подходы вытекают из геометрических алгоритмов, которые будут рассматриваться в части 7. Для случайно распределенных точек можно поделить плоскость на квадраты таким образом, чтобы каждый квадрат содержал примерно lgN/2 точек, как в программе 3.20 для поиска ближайшей точки. Даже если включить в граф только ребра, соединяющие каждую точку с точками из соседних квадратов, то, скорее всего (хотя и не обязательно), у нас будут все ребра, принадлежащие минимальному остовному дереву; после этого можно эффективно завершить построение с помощью алгоритма Крускала или реализации алгоритма Прима с поиском по приоритету. Примеры на рис. 20.10, 20.13, 20.16 и других подобных рисунках, были построены с использованием именно так (рис. 20.19). А можно разработать специальную версию алгоритма Прима с использованием алгоритмов поиска ближайшего соседа, чтобы не обрабатывать дальние вершины.

Имея такой набор инструментов и возможности линейных алгоритмов для решения общей задачи вычисления MST, важно помнить о существовании простой нижней границы наших возможностей.

 Евклидовы графы ближайших соседей


Рис. 20.19.  Евклидовы графы ближайших соседей

Один из способов вычисления евклидова MST-дерева состоит в построении графа, ребра которого соединяют каждую пару точек, расположенных друг от друга на расстоянии, не превышающем d, подобно графу на рис. 20.8 и т.д. Однако при слишком больших d этот метод дает слишком много ребер (вверху), а если d меньше длины самого длинного ребра в MST, то нельзя гарантировать, что будут доступны все нужные ребра (внизу).

Лемма 20.14. Вычисление евклидова MST-дерева для N точек не проще, чем сортировка N чисел.

Доказательство. Пусть задан список чисел, подлежащих сортировке. Преобразуем этот список в список точек, в котором в качестве координаты x берется соответствующее число из исходного списка, а координата у равна 0. Найдем MST для полученного списка точек. Далее (как в алгоритме Крускала) передадим точки в АТД графа и выполним поиск в глубину для построения остовного дерева, начиная с точки с минимальной координатой х. Это основное дерево эквивалентно упорядочению чисел в связном списке — то есть мы решили задачу сортировки чисел.

Точные интерпретации этой нижней границы довольно сложны, поскольку базовые операции, используемые для решения этих двух задач (сравнение координат в задаче сортировки и сравнение расстояний в задаче построения MST), различны и поскольку можно использовать методы наподобие поразрядной сортировки и решеточных методов. Однако можно интерпретировать эту границу так: при выполнении сортировки следует считать алгоритм вычисления евклидова MST-дерева с NlgN операциями сравнения оптимальным, если не использовать числовые свойства координат — в этом случае можно надеяться на линейное время выполнения (см. раздел ссылок).

Существует интересная связь между графами и геометрическими алгоритмами, которая вытекает из задачи вычисления евклидова MST-дерева. Многие задачи, которые часто встречаются на практике, могут быть сформулированы либо как геометрические задачи, либо как задачи на графах. Если важно физическое расположение объектов, то можно пользоваться геометрическими алгоритмами, описанными в части VII; но если важнее взаимосвязи между объектами, то обычно удобнее алгоритмы на графах, рассмотренные в данном разделе.

Евклидово MST-дерево находится примерно посередине между двумя этими подходами (на входе геометрические данные, а на выходе — взаимосвязи), и поэтому разработка простых методов вычисления евклидова MST-дерева остается трудной задачей. В лекция №21 мы столкнемся с еще одной подобной задачей, которая также находится в этом промежутке, но там евклидов подход допускает гораздо более быстрые алгоритмы, чем соответствующие задачи на графах.

Упражнения

20.81. Приведите контрпример, показывающий, почему не работает следующий метод поиска евклидова MST-дерева: " Упорядочьте точки по их координатам х, потом найдите минимальные основные деревья для первой половины и для второй половины, а затем найдите соединяющие их кратчайшие ребра " .

20.82. Разработайте быструю версию алгоритма Прима для вычисления евклидова MST-дерева для множества равномерно распределенных случайных точек на плоскости, которая основана на игнорировании отдаленных точек до тех пор, пока к ним не приблизится само дерево.

20.83. Разработайте алгоритм, который для заданного множества N точек на плоскости находит множество ребер с мощностью, пропорциональной N, которое наверняка содержит MST и которое достаточно просто вычисляется, чтобы разработать компактную и эффективную реализацию алгоритма.

20.84. Пусть дано множество случайных N точек, равномерно распределенных в единичном квадрате. Эмпирически определите с точностью до двух десятичных цифр значение d, такое, что множество ребер, образованных всеми парами точек на расстоянии не более d, содержит MST с вероятностью 99%.

20.85. Выполните упражнение 20.84 для точек, каждая координата которых получена из гауссова распределения со средним значением 0.5 и со среднеквадратичным отклонением 0.1.

20.86. Опишите, как можно повысить производительность алгоритмов Крускала и Борувки для случая разреженных евклидовых графов.

Лекция 21. Кратчайшие пути

Каждому пути во взвешенном орграфе можно сопоставить вес пути (path weight) - величину, равную сумме весов ребер, составляющих этот путь. Эта важная мера позволяет сформулировать задачи наподобие " найти путь между двумя заданными вершинами, имеющий минимальный вес " . Эти задачи о кратчайших путях (shortest-path problem) и являются темой данной главы. Такие задачи не только естественно возникают во многих приложениях, но и знакомят нас с областью алгоритмов решения общих задач, применимых к широкому кругу реальных приложений.

Некоторые из рассматриваемых в данной главе алгоритмов непосредственно связаны с алгоритмами, изученными в лекциях 17-20. Здесь полностью применима наша базовая парадигма поиска на графе, а в основе решения задач о кратчайших путях лежат некоторые специальные механизмы, которые были использованы в лекция №17 и лекция №19 для решения задач связности в графах и орграфах.

Для краткости мы будем называть взвешенные орграфы сетями (network). На рис. 21.1 показан пример сети и ее стандартные представления. В лекция №20 уже был разработан интерфейс АТД сети с представлениями матрицей смежности и списками смежности. При вызове конструктора достаточно задать второй аргумент равным true, и класс будет содержать лишь одно представление каждого ребра - как при получении представлений орграфов в лекция №19 из представлений неориентированных графов из главы 17 лекция №17 (см. программы 20.1-20.4).

Как было подробно разъяснено в лекция №20, при работе со взвешенными орграфами мы используем указатели на абстрактные ребра, чтобы расширить применимость полученных реализаций. У этого подхода для орграфов имеются некоторые отличия по сравнению с неориентированными графами (см. лекция №20). Во-первых, имеется только одно представление каждого ребра, и поэтому при использовании итератора не нужна функция from из класса edge (см. программу 20.1): в орграфе значение e->from(v) истинно для любого указателя на ребро e, возвращаемого итератором для v. Во-вторых, как было показано в лекция №19, часто при обработке орграфа полезно иметь возможность работать с обратным ему графом, но сейчас нам потребуется подход, отличный от принятого в программе 19.1, поскольку там при создании обратного графа создаются ребра, а здесь предполагается, что клиенты АТД графа, предоставляющие указатели на ребра, не должны сами создавать ребра (см. упражнение 21.3).

В приложениях или системах, где могут потребоваться любые типы графов, нетрудно определить такой АТД сети, от которого можно породить АТД для невзвешенных неориентированных графов из глав 17 и 18, невзвешенных орграфов из лекция №19 или взвешенных неориентированных графов из лекция №20 (см. упражнение 21.10).

 Пример сети и ее представления


Рис. 21.1.  Пример сети и ее представления

Здесь показана сеть (взвешенный орграф) и четыре ее представления: список ребер, графический вид, матрица смежности и списки смежности (слева направо). Как и для алгоритмов вычисления MST, веса указываются в элементах матрицы и в узлах списков, но в программах используются указатели на ребра. На рисунках длины ребер часто изображаются пропорциональными их весам (как и для алгоритмов вычисления MST), но мы не настаиваем на этом правиле, поскольку большинство алгоритмов поиска кратчайших путей могут работать с произвольными неотрицательными весами (хотя отрицательные веса могут усложнить ситуацию). Матрица смежности несимметрична, а списки смежности содержат по одному узлу для каждого ребра (как в невзвешенном орграфе). Несуществующие ребра представляются пустыми указателями в матрице (незаполненные места на рисунке) и вообще отсутствуют в списках. Петли нулевой длины введены для упрощения реализации алгоритмов поиска кратчайших путей. Они не представлены в списке ребер слева - для экономии места и чтобы продемонстрировать типичную ситуацию, когда они добавляются при создании представления матрицей смежности или списками смежности.

При работе с сетями обычно удобно оставлять петли во всех представлениях. При таком соглашении для указания, что никакая вершина не достижима из себя самой, в алгоритмах можно использовать сигнальный вес (с максимальным значением). В наших примерах мы будем использовать петли нулевого веса, хотя зачастую имеют смысл петли с положительным весом. Во многих приложениях также требуется наличие параллельных ребер - возможно, с различными весами. Как было сказано в лекция №20, в различных приложениях удобны разные варианты игнорирования или объединения таких ребер. Однако в этой главе для простоты ни в одном из наших примеров параллельные ребра не применяются, в представлении матрицей смежности параллельные ребра не допускаются, и мы выполняем проверку наличия параллельных ребер или их удаление в списках смежности.

Все свойства связности ориентированных графов, рассмотренные в лекция №19, справедливы и для сетей. Только там мы выясняли, возможно ли достижение одной вершины из другой, а в этой главе мы учитываем веса и пытаемся найти наилучший путь из одной вершины в другую.

Определение 21.1. Кратчайшим путем между двумя вершинами s и t в сети называется такой направленный простой путь из s в t, что никакой другой путь не имеет меньшего веса.

Лаконичность этого определения скрывает некоторые важные моменты. Во-первых, если t не достижима из s, то не существует вообще никакого пути, поэтому нет и кратчайшего пути. Для удобства рассматриваемые алгоритмы часто трактуют этот случай как наличие между s и t пути с бесконечным весом. Во-вторых, как и в случае алгоритмов вычисления MST, в наших примерах сетей веса ребер пропорциональны их длинам,

хотя определение этого не требует, и в алгоритмах (кроме одного в разделе 21.5) это не предполагается. Вообще-то алгоритмы поиска кратчайшего пути запросто находят неочевидные обходы - например, путь между двумя вершинами, который проходит через несколько других вершин, но имеет общий вес меньше веса ребра, непосредственно соединяющего эти вершины. В-третьих, может существовать несколько путей из одной вершины в другую с одним и тем же весом - обычно достаточно найти один из них. На рис. 21.2 показан пример, иллюстрирующий эти моменты.

В определении требуется, чтобы путь был простым, хотя для сетей, содержащих ребра с неотрицательными весами, это несущественно, поскольку в такой сети можно удалить из пути любой цикл и получить не более длинный путь (даже более короткий, если цикл не состоит из ребер с нулевым весом). Но в случае сетей с ребрами, которые могут иметь отрицательный вес, необходимость ограничиться простыми путями понятна: ведь если в сети есть цикл с отрицательным весом, то понятие кратчайшего пути теряет смысл. Например, предположим, что ребро 3-5 в сети на рис. 21.1 имеет вес -0.38, а ребро 5-1 - вес -0.31. Тогда вес цикла 1-4-3-5-1 равен 0.32 + 0.36 - 0.38 - 0.31 = -0.01. Можно кружить по этому циклу, порождая все более короткие пути. Учтите, что не обязательно, как и в данном примере, чтобы все ребра в таком цикле имели отрицательные веса; значение имеет лишь сумма весов ребер. Для краткости ориентированные циклы с общим отрицательным весом мы будем называть отрицательными циклами.

Предположим, что в определении одна из вершин на пути из s в t принадлежит также некоторому отрицательному циклу. В этом случае существование (непростого) кратчайшего пути из s в t бессмысленно, поскольку можно использовать цикл для создания пути с весом, меньшим любого заданного значения. Чтобы избежать таких моментов, в данном определении мы ограничиваемся простыми путями - тогда понятие кратчайшего пути можно строго определить для любой сети. До раздела 21.7 мы не будем рассматривать отрицательные циклы в сетях, поскольку, как мы увидим, они представляют собой принципиальное препятствие на пути решения задачи поиска кратчайших путей.

Чтобы найти кратчайшие пути во взвешенном неориентированном графе, можно построить сеть с теми же вершинами и с двумя ребрами (по одному в каждом направлении), которые соответствуют каждому ребру в исходном графе.

 Деревья кратчайших путей


Рис. 21.2.  Деревья кратчайших путей

Дерево кратчайших путей (SPT) определяет наиболее короткие пути из корня в другие вершины (см. определение 21.2). В общем случае различные пути могут иметь одинаковую длину, поэтому может существовать несколько SPT, определяющих кратчайшие пути из заданной вершины. В сети, приведенной слева, все кратчайшие пути из 0 являются подграфами DAG-графа, показанного справа от сети. Дерево с корнем в 0 является остовом этого DAG тогда и только тогда, когда оно является SPT-деревом для вершины 0. Два таких дерева приведены справа.

Существует взаимно однозначное соответствие между простыми путями в сети и простыми путями в графе, и стоимости этих путей совпадают; значит, совпадают и задачи поиска кратчайших путей для них. При построении стандартного представления списками смежности или матрицей смежности для взвешенного неориентированного графа (см., например, рис. 20.3) получается точно такая же сеть. Это построение бесполезно, если веса могут быть отрицательными, поскольку в сети при этом получаются отрицательные циклы, а мы не знаем, как решать задачи поиска кратчайших путей в сетях с отрицательными циклами (см. раздел 21.7). В остальных случаях алгоритмы для сетей, которые рассматриваются в этой главе, работают и для взвешенных неориентированных графов.

В некоторых приложениях удобно вместо ребер либо наряду с ребрами присваивать веса вершинам; можно также рассмотреть более сложные задачи, где имеют значение как количество ребер в пути, так и общий вес пути. Подобные задачи можно решать, приводя их к сетям со взвешенными ребрами (см., например, упражнение 21.4) или несколько расширяя базовые алгоритмы (см., например, упражнение 21.52).

Поскольку из контекста и так все понятно, мы не вводим специальную терминологию, позволяющую отличать кратчайшие пути во взвешенных графах от кратчайших путей в графах без весов (где вес пути просто равен количеству составляющих его ребер - см. лекция №17). Общепринятая терминология относится к сетям (со взвешенными ребрами), как в данной главе, т.к. особые случаи для неориентированных или невзвешенных графов легко решаются с помощью тех же алгоритмов, что и для сетей.

Мы будем рассматривать те же базовые задачи, которые были определены в лекция №18 для неориентированных и невзвешенных графов. Мы вновь формулируем их здесь, обращая внимание на то, что определение 21.1 неявно обобщает их, учитывая веса в сетях.

Кратчайший путь из истока в сток. Для заданных начальной вершины s и конечной вершины t нужно найти кратчайший путь в графе из s в t. Начальную вершину мы называем истоком (source), а конечную вершину - стоком (sink), за исключением тех ситуаций, где такое использование терминов противоречит другому определению истоков (вершин без входящих ребер) и стоков (вершин без исходящих ребер) в орграфах.

Кратчайшие пути из одного истока. Для заданной начальной вершины s нужно найти кратчайшие пути из s во все остальные вершины графа.

 Все кратчайшие пути


Рис. 21.3.  Все кратчайшие пути

В этой таблице приведены все кратчайшие пути в сети с рис. 21.1 вместе с их длинами. Поскольку данная сеть является сильно связной, в ней существуют пути, соединяющие каждую пару вершин. Цель алгоритма поиска кратчайшего пути из истока в сток - вычисление одного из элементов этой таблицы; цель алгоритма поиска кратчайшего пути из одного истока - вычисление одной из строк этой таблицы; а цель алгоритма поиска кратчайших путей между всеми парами вершин - вычисление всей таблицы. Обычно мы используем более компактные представления, которые содержат по существу ту же информацию и позволяют клиентам обойти любой путь за время, пропорциональное количеству его ребер (см.рис. 21.8).

Кратчайшие пути между всеми парами вершин. Нужно найти кратчайшие пути, соединяющие каждую пару вершин в графе. Иногда для краткости это множество V2 путей мы будем называть термином все кратчайшие пути.

Если имеется несколько кратчайших путей, соединяющих любую заданную пару вершин, мы выбираем любой из них. Поскольку пути имеют различное количество ребер, наши реализации предоставляют функции-члены, которые позволяют клиентам обходить пути за время, пропорциональное длинам путей. Любой кратчайший путь неявно содержит и собственную длину, но наши реализации выдают длины явно. Итак, уточним: в приведенных выше постановках задач выражение " найти кратчайший путь " означает " вычислить длину кратчайшего пути и способ обхода конкретного пути за время, пропорциональное его длине " .

На рис. 21.3 показаны кратчайшие пути для сети с рис. 21.1. В сетях с V вершинами для решения задачи с одним истоком необходимо указать V путей, а для решения задачи для всех пар вершин - V2 путей. В наших реализациях мы используем более компактное представление, чем эти списки путей; об этом уже было упомянуто в лекция №18 и будет подробно рассказано в разделе 21.1.

В реализациях на C++ мы строим алгоритмические решения этих задач в виде реализаций АТД, позволяющих создавать эффективные клиентские программы, которые могут решать разнообразные практические задачи обработки графов. Например, как показано в разделе 21.3, мы реализуем решения задач кратчайших путей для всех пар вершин в виде конструкторов внутри классов, которые отвечают на запросы кратчайшего пути за постоянное (линейное) время. Мы также построим классы для решения задачи с одним истоком, чтобы клиенты, которым нужно вычислить кратчайшие пути из заданной вершины (или небольшого множества вершин) могли не вычислять кратчайшие пути для других вершин. Внимательное рассмотрение таких моментов и надлежащее использование изучаемых здесь алгоритмов может преодолеть разницу между эффективным решением и настолько трудоемким решением, что никакой клиент не сможет воспользоваться им.

Задачи о кратчайших путях в различных вариантах возникают в широком спектре приложений. Многие приложения допускают наглядную геометрическую интерпретацию, но многие другие обрабатывают структуры с произвольными стоимостями. Как и в случае минимальных остовных деревьев (MST, см. лекция №20), мы иногда будем обращаться к геометрической интерпретации, чтобы облегчить понимание алгоритмов решения этих задач - постоянно помня, что наши алгоритмы способны работать и в более общих условиях. В разделе 21.5 мы рассмотрим специализированные алгоритмы для евклидовых сетей. А в разделах 21.6 и 21.7 будет показано, что базовые алгоритмы эффективны для многочисленных приложений, в которых сети представляют абстрактную модель вычислений.

Дорожные карты. Во многих дорожных картах есть замечательная вещь - таблицы с расстояниями между всеми парами главных городов. Мы считаем, что составитель карты позаботился о том, чтобы эти расстояния кратчайшими, но это предположение не всегда верно (см., например, упражнение 21.11). Обычно такие таблицы составляются для неориентированных графов, которые можно рассматривать как сети с двунаправленными ребрами, соответствующими каждой дороге, хотя в них можно включать улицы с односторонним движением на карте города и некоторые другие аналогичные моменты. Как будет показано в разделе 21.3, нетрудно предоставлять и другую полезную информацию - например, таблицу с инструкцией о прохождении кратчайших путей (см. рис. 21.4). Современные встроенные системы в автомобилях и других транспортных системах предоставляют такие возможности. Карты являются евклидовыми графами, и в разделе 21.4 мы рассмотрим алгоритмы поиска кратчайших путей, которые при поиске кратчайших путей учитывают позицию вершины.

Авиарейсы. Карты маршрутов и расписания для авиалиний или других транспортных систем могут быть представлены в виде сетей, для которых важны различные задачи о кратчайших путях. Например, может потребоваться минимизировать время перелета между двумя городами, или же стоимость путешествия. Стоимости в таких сетях могут включать функции от времени, финансовых затрат или других интегральных ресурсов. Например, из-за преобладающих ветров перелеты между двумя городами обычно занимают больше времени в одном направлении, чем в другом. Авиапассажиры также знают, что стоимость перелета не обязательно является простой функцией расстояния между городами - очень часто встречаются ситуации, когда окольный маршрут (с пересадками) дешевле прямого перелета. Такие усложнения могут быть обработаны базовыми алгоритмами поиска кратчайших путей, которые мы рассматриваем в этой главе; эти алгоритмы предназначены для работы с любыми положительными стоимостями.

 Расстояния и пути


Рис. 21.4.  Расстояния и пути

Дорожные карты обычно содержат таблицы расстояний наподобие приведенной в центрери-сунка для небольшого подмножества французских городов. Соединяющие их автострады показаны в виде графа в верхней части рисунка. Может быть полезна, хотя и редко встречается на картах, таблица наподобие приведенной внизу: в ней отмечены пункты, лежащие на кратчайшем пути. Например, из этой таблицы видно, как добраться из Парижа в Ниццу: первым на пути следования должен быть Лион.

Фундаментальные вычисления кратчайших путей, предлагаемые этими приложениями - это лишь небольшая часть области применимости алгоритмов для вычисления кратчайших путей. В разделе 21.6 мы рассмотрим задачи из прикладных областей, которые с виду не имеют отношения к данной тематике. В этом нам поможет сведение - формальный механизм для доказательства связи между задачами. Мы решаем задачи для этих приложений, трансформируя их в абстрактные задачи поиска кратчайших путей, которые не имеют очевидной геометрической связи с описанными задачами. Некоторые приложения приводят к задачам о кратчайших путях в сетях с отрицательными весами. Такие задачи могут оказаться намного более трудными, чем задачи, где отрицательные веса невозможны. Задачи о кратчайших путях для таких приложений не только заполняют промежуток между элементарными алгоритмами и алгоритмически неразрешимыми задачами, но и приводят нас к мощным и общим механизмам принятия решений.

Как и в случае алгоритмов вычисления MST из лекция №20, мы часто смешиваем понятия веса, стоимости и расстояния. Здесь мы обычно также используем естественную геометрическую наглядность, даже работая с более общими постановками и с произвольными весами ребер. Поэтому мы говорим о " длине " путей и ребер вместо " веса " , и говорим, что один путь " короче " другого, вместо выражения " имеет меньший вес " . Мы также можем сказать, что вершина v находится " ближе " к s, чем w, вместо " ориентированный путь наименьшего веса из s в v имеет меньший вес, чем вес ориентированного пути наименьшего веса из s в w " , и т.д. Это проявляется и в стандартном использовании термина " кратчайшие пути " и выглядит естественно, даже если веса не связаны с расстояниями (см. рис. 21.2). Однако в разделе 21.6, при расширении наших алгоритмов на отрицательные веса, от этого придется отказаться.

Эта глава организована следующим образом. После знакомства с фундаментальными принципами в разделе 21.1, в разделах 21.2 и 21.3 мы рассмотрим базовые алгоритмы для задач поиска кратчайших путей из одного истока и между всеми парами вершин. После этого в разделе 21.4 мы изучим ациклические сети (или, короче, взвешенные DAG-графы), а в разделе 21.5 - методы использования геометрических свойств для решения задачи с одним истоком и стоком в евклидовых графах. Затем в разделах 21.6 и 21.7 мы переключимся на более общие задачи, где алгоритмы поиска кратчайших путей (возможно, на сетях с отрицательными весами) будут использованы как высокоуровневое средство решения задач.

Упражнения

21.1. Пометьте следующие точки на плоскости цифрами от 0 до 5, соответственно:

(1, 3) (2, 1) (6, 5) (3, 4) (3, 7) (5, 3).

Считая длины ребер весами, рассмотрите сеть, определяемую ребрами

1-03-55-23-45-10-30-44-22-3.

Нарисуйте сеть и приведите структуру списков смежности, которая формируется программой 20.5.

21.2. Покажите в стиле рис. 21.3 все кратчайшие пути в сети, определенной в упражнении 21.1.

21.3. Разработайте реализацию класса сети, который представляет обращение взвешенного орграфа, который определяется вставленными ребрами. Включите в реализацию конструктор " обратного копирования " , который принимает в качестве аргумента граф и использует все ребра этого графа для построения обращения.

21.4. Покажите, что для вычисления кратчайших путей в сетях с неотрицательными весами и в вершинах, и на ребрах (где вес пути определяется как сумма весов вершин и ребер на этом пути), достаточно построения АТД сети с весами только на ребрах.

21.5. Найдите в инернете какую-либо доступную большую сеть, содержащую расстояния или стоимости - возможно, географическую базу данных с информацией о дорогах, соединяющих города, либо расписания движения самолетов или поездов.

21.6. Напишите генератор разреженных случайных сетей на основе программы 17.12. Для присваивания весов ребрам определите АТД ребер случайного веса и напишите две реализации: одну для генерации равномерно распределенных весов, а другую - для нормально распределенных. Напишите клиентские программы, генерирующие разреженные случайные сети для обоих распределений весов с таким множеством значений V и E, чтобы их можно было использовать для выполнения эмпирических тестов на графах.

21.7. Напишите генератор насыщенных случайных сетей на основе программы 17.13 и генератора ребер случайного веса из упражнения 21.6. Напишите клиентские программы, генерирующие случайные сети для обоих распределений весов с таким множеством значений V и E, чтобы их можно было использовать для выполнения эмпирических тестов на графах.

21.8. Реализуйте независимую от представления сети клиентскую функцию, которая строит сеть, получая ребра с весами (пары целых чисел из диапазона от 0 до V- 1 с весами от 0 до 1) из стандартного ввода.

21.9. Напишите программу, которая генерирует V случайных точек на плоскости, затем строит сеть с (двунаправленными) ребрами, соединяющими все пары точек, расстояние между которыми не превышает заданное значение d (см. упражнение 17.74), и устанавливает вес каждого ребра равным расстоянию между концами этого ребра. Определите, каким должно быть d, чтобы ожидаемое количество ребер было равно E.

21.10. Напишите базовый класс и производные классы, реализующие абстрактные типы данных для графов, которые могут быть неориентироваными или ориентированными, взвешенными или невзвешенными и насыщенными или разреженными.

21.11. Приведенная ниже таблица из опубликованной дорожной карты содержит длины кратчайших маршрутов, соединяющих города. Найдите в таблице ошибку и исправьте ее. Составьте также таблицу в стиле рис. 21.4, которая показывает, как проследовать по кратчайшему маршруту.

ПровиденсВестерлиНью-ЛондонНорвич
Провиденс-53 54 48
Вестерли53 -18 101
Нью-Лондон54 18 -12
Норвич48 101 12 -

Основные принципы

Наши алгоритмы поиска кратчайших путей основаны на простой операции, которая называется релаксация (relaxation). В начале работы алгоритма поиска кратчайших путей известны только ребра сети и их веса. По мере продвижения мы собираем сведения о кратчайших путях, которые соединяют различные пары вершин. Все наши алгоритмы пошагово обновляют эти сведения и делают новые выводы о кратчайших путях на основе информации, полученной к текущему моменту. На каждом шаге мы проверяем, можно ли найти путь, более короткий, чем некоторый известный путь. Термин " релаксация " (ослабление) обычно относится к этому шагу, который ослабляет ограничения на кратчайший путь. Представьте себе резиновую ленту, плотно натянутую на путь, соединяющий две вершины: успешная операция релаксации позволяет ослабить натяжение этой резиновой ленты вдоль более короткого пути.

Наши алгоритмы основываются на многократном применении одной из двух операций релаксации:

Релаксация ребра представляет собой частный случай релаксации пути; однако мы рассматриваем эти операции по отдельности, поскольку используем их по отдельности (первая операция - в алгоритмах для одного истока, а вторая - в алгоритмах для всех пар вершин). В обоих случаях главное требование к структурам данных, которые используются для представления текущего состояния знаний о кратчайших путях сети, состоит в том, чтобы можно было легко обновлять их - для отображения изменений, выполненных операцией релаксации.

 Релаксация ребра


Рис. 21.5.  Релаксация ребра

Здесь показана операция релаксации, лежащая в основе наших алгоритмов поиска кратчайших путей из одного истока. Мы прослеживаем известные кратчайшие пути из истока s в каждую вершину и проверяем, образует ли ребро v-w более короткий путь в w. В верхнем примере это не так, поэтому ребро игнорируется. В нижнем примере получается более короткий путь - значит, нужно обновить структуры данных, чтобы указать, что лучший известный путь из s в w проходит через v, завершаясь ребром v-w.

Сначала рассмотрим релаксацию ребра (см. рис. 21.5). Все рассматриваемые нами алгоритмы поиска кратчайших путей из одного истока основаны на следующем шаге: приводит ли данное ребро к рассмотрению более короткого пути из истока к конечной вершине этого ребра?

Структуры данных, необходимые для выполнения этих действий, просты. Во-первых, наша основная задача состоит в вычислении длины кратчайших путей из истока в каждую из прочих вершин. Условимся сохранять длины известных кратчайших путей из истока в каждую из вершин в векторе wt, индексированном именами вершин. Во-вторых, для записи самих путей продвижения от вершины к вершине мы будем придерживаться того же соглашения, что и в других алгоритмах поиска на графах в лекциях 18-20: мы используем индексированный именами вершин вектор spt для записи последнего ребра на кратчайшем пути из истока в данную индексированную вершину. Эти ребра составляют дерево.

Теперь реализация релаксации ребра выполняется просто.

При поиске кратчайших путей из одного истока следующий код выполняет релаксацию вдоль ребра e, направленного от v к w:

  if (wt[w] > wt[v] + e->wt())
    { wt[w] = wt[v] + e->wt(); spt[w] = e; }
      

Этот кодовый фрагмент и прост, и выразителен; и мы включаем его в наши реализации, не определяя релаксацию в виде высокоуровневой абстрактной операции,.

Определение 21.2. Пусть задана сеть и некоторая вершина s. Деревом кратчайших путей (Shortest-Paths Tree - SPT) для s является подсеть, содержащая s и все вершины, достижимые из s, которая образует такое направленное дерево с корнем в s, что каждый путь в дереве является кратчайшим путем в сети.

Возможно существование нескольких путей одной и той же длины, соединяющих заданную пару узлов, так что SPT-деревья не обязательно уникальны. Вообще, как видно из рис. 21.2, если выбрать кратчайшие пути из вершины s в каждую вершину, достижимую из s в сети, и из подсети, порожденной ребрами этих путей, то можно получить DAG. Различные кратчайшие пути, соединяющие пары узлов, могут рассматриваться как подпути некоторого более длинного пути, содержащего оба узла. Поэтому мы обычно ограничиваемся вычислением любого SPT для данного орграфа и начальной вершины.

Элементы вектора wt в наших алгоритмах обычно инициализируются сигнальным значением. Это значение должно быть достаточно малым, чтобы его добавление при проверке релаксации не привело к переполнению, но достаточно большим, чтобы никакой простой путь не имел больший вес. Например, если веса ребер находятся между 0 и 1, то в качестве сигнального значения можно выбрать V. Когда сигнальные значения используются в сетях, где могут быть отрицательные веса, следует очень тщательно проверить наши допущения. Например, если обе вершины имеют сигнальные значения, то вышеприведенный код релаксации не выполнит никаких действий, если e.wt не отрицательно (как обычно и бывает в большинстве реализаций), однако изменит wt[w] и spt[w], если этот вес отрицателен.

В качестве индекса для сохранения ребер SPT (spt[w]->w() == w) наш код всегда использует конечную вершину. Для краткости и согласованности с главами 17-19 мы применяем для ссылки на вершину spt[w]->v() обозначение st[w] (в тексте и особенно на рисунках), чтобы подчеркнуть, что вектор spt на самом деле является представлением родительскими ссылками дерева кратчайших путей (см. рис. 21.6). Для вычисления кратчайшего пути из s в t нужно подняться по дереву от t к s; при этом мы проходим по ребрам в направлении, противоположном их ориентации в сети, и посещаем вершины пути в обратном порядке (t, st[t], st[st[t]] и т.д.).

 Деревья кратчайших путей


Рис. 21.6.  Деревья кратчайших путей

Кратчайшие пути из 0 к другим узлам в этой сети - это, соответственно, 0-1, 0-5-4-2, 0-5-4-3, 0-5-4 и 0-5. Эти пути определяют остовное дерево, которое приведено в центре в трех представлениях: серые ребра в графическом изображении сети, ориентированное дерево и родительские ссылки с весами. Ссылки в представлении родительскими ссылками (которое мы обычно вычисляем), ориентированы противоположно ссылкам в орграфе, поэтому мы иногда работаем с обратным орграфом. Остовное дерево, определяемое кратчайшими путями из 3 в каждый из других узлов в обращении сети, показано справа. Представление этого дерева родительскими ссылками дает кратчайшие пути из каждого из других узлов в узел 2 в исходном графе. Например, кратчайший путь 0-5-4-3 из 0 в 3 можно найти, пройдя по ссылкам st[0] = 5, st[5] = 4 и st[4] = 3.

Один из способов получения ребер пути из SPT в прямом порядке (от истока к стоку) использует стек. Например, следующий код выводит путь из истока в заданную вершину w:

  stack <EDGE *> P; EDGE *e = spt[w];
  while (e) { P.push(e); e = spt[e->v()]); }
  if (P.empty()) cout << P.top()->v();
  while (!P.empty())
    { cout << "-" << P.top()->w(); P.pop(); }
      

В реализации класса можно использовать аналогичный код, чтобы клиент получил вектор, содержащий ребра пути.

Если необходимо просто вывести или как-то по-другому обработать ребра пути, проход по всему пути в обратном порядке для достижения первого ребра может оказаться нежелательным. Один из способов обхода этого затруднения - работа с обратной сетью, как показано на рис. 21.6. В задачах с одним истоком мы используем обратный порядок и релаксацию ребер, поскольку SPT дает компактное представление кратчайших путей из истока во все другие вершины в векторе, который содержит лишь V элементов.

 Релаксация пути


Рис. 21.7.  Релаксация пути

Здесь продемонстрирована операция релаксации, на которой основаны наши алгоритмы поиска кратчайших путей для всех пар вершин. Мы отслеживаем лучший известный путь между всеми парами вершин и проверяем, может ли вершина i улучшить путь из s в t. В верхнем примере это не так, а в нижнем путь можно улучшить. Когда встречается такая вершина i, что сумма длины известного кратчайшего пути из s в i и длины известного кратчайшего пути из i в t меньше длины известного кратчайшего пути из s в t, мы обновляем наши структуры данных, чтобы указать, что теперь известен более короткий путь из s в t (в направлении к i).

Теперь мы рассмотрим релаксацию пути - основу некоторых наших алгоритмов для всех пар вершин: дает ли прохождение через данную вершину более короткий путь, соединяющий две другие заданные вершины? Например, предположим, что имеются три вершины s, x и t, и нужно узнать, что лучше: пройти из s в x, а затем из x в t, или сразу пройти из s в t, не заходя в x. Для прямолинейных соединений в евклидовом пространстве неравенство треугольника утверждает, что маршрут через x не может быть более коротким, чем прямой маршрут из s в t, но для путей в сети это вполне возможно (см. рис. 21.7). Чтобы определить, как именно обстоят дела, необходимо знать длины путей из s в x, из x в t и из s в t (без захода в x). Затем мы просто проверяем, меньше ли сумма длин первых двух путей, чем длина третьего; если это так, нужно соответствующим образом обновить данные.

Релаксация пути входит в решение задачи для всех пар вершин, где мы храним длины найденных на данный момент кратчайших путей между всеми парами вершин. А именно, в коде вычисления кратчайших путей для всех пар вершин мы используем вектор векторов d - такой, что значение d[s][t] содержит длину кратчайшего пути из s в t, и вектор векторов p - такой, что p[s][t] содержит следующую вершину на кратчайшем пути из s в t. Первый вектор называется матрицей расстояний, а второй - матрицей путей.

На рис. 21.8 показаны эти матрицы для нашей демонстрационной сети. Главной целью вычислений является матрица расстояний, а матрица путей используется потому, что она явно более компактна, хотя и содержит ту же информацию, что и полный список путей, приведенный на рис. 21.3.

 Все кратчайшие пути


Рис. 21.8.  Все кратчайшие пути

Две матрицы справа - компактные представления всех кратчайших путей для демонстрационной сети, приведенной слева. Они содержат ту же информацию, что и список на рис. 21.3. Матрица расстояний слева содержит длины кратчайших путей: значение элемента на пересечении строки s и столбца t равно длине кратчайшего пути из s в t. Матрица путей справа содержит информацию, необходимую для обхода пути: элемент на пересечении строки s и столбца t содержит следующую вершину на пути из s в t.

При наличии этих структур данных релаксация пути выражается следующим кодом:

  if (d[s][t] > d[s][x] + d[x][t])
    { d[s][t] = d[s][x] + d[x][t]; p[s][t] = p[s][x]; }
      

Как и в случае релаксации ребра, этот код можно считать точной записью данного выше неформального описания, и мы вставляем его непосредственно в наши реализации. А более формально релаксация пути отражает следующее.

Лемма 21.1. Если вершина x лежит на кратчайшем пути из s в t, то этот путь состоит из кратчайшего пути из s в x, за которым следует кратчайший путь из x в t.

Доказательство. Если бы это было не так, мы могли бы воспользоваться любым более коротким путем из s в x или из x в t для построения более короткого пути из s в t.

Мы сталкивались с операцией релаксации пути при рассмотрении алгоритмов транзитивного замыкания в лекция №19. Если веса ребер и путей либо равны 1, либо бесконечны (т.е. вес пути равен 1 только если все его ребра имеют единичный вес), то релаксация пути - это операция, которая использовалась в алгоритме Уоршалла (если существуют пути из s в x и из x в t, то существует и путь из s в t). Если определить вес пути как количество ребер в этом пути, то алгоритм Уоршалла обобщает алгоритм Флойда для нахождения всех кратчайших путей в невзвешенном орграфе. Как будет показано в разделе 21.3, его можно обобщить еще дальше - применительно к сетям.

С математической точки зрения важно, что все эти алгоритмы можно привести к унифицированному алгебраическому виду, который помогает их пониманию. С точки зрения программиста важно, что каждый из этих алгоритмов можно реализовать, используя абстрактную операцию + (для вычисления веса пути из весов ребер) и абстрактную операцию < (для вычисления минимального значения из множества весов путей) - исключительно в контексте операции релаксации (см. упражнения 19.55 и 19.56).

Из леммы 21.1 следует, что кратчайший путь из s в t содержит кратчайшие пути из s в каждую другую вершину на пути в t. Большинство алгоритмов поиска кратчайших путей также вычисляют кратчайшие пути из s в каждую вершину, которая находится ближе к s, чем к t (независимо от того, находится ли эта вершина на пути из s в t), хотя это и не обязательно (см. упражнение 21.8). Решение задачи о кратчайших путях из истока в сток с помощью такого алгоритма, когда t является наиболее удаленной от s вершиной, эквивалентно решению задачи о кратчайших путях из одного истока s. И наоборот, решение задачи о кратчайших путях из одного истока s можно использовать для нахождения вершины, наиболее удаленной от s.

Матрица путей, применяемая в наших реализациях задачи для всех пар вершин, является также представлением деревьев кратчайших путей для каждой вершины. Мы определили p[s][t] как вершину, которая следует за s на кратчайшем пути из s в t. Ну или как вершину, которая в обратной сети предшествует s на кратчайшем пути из t в s. Другими словами, столбцом t в матрице путей сети является индексированный именами вершин вектор, который в обращении сети представляет SPT для вершины t. И наоборот, можно построить матрицу путей для сети, заполнив каждый столбец деревом кратчайших путей, которое представлено индексированным именами вершин вектором для соответствующей вершины в обращении сети. Это соответствие показано на рис. 21.9.

Итак, релаксация предоставляет базовые абстрактные операции, необходимые для построения алгоритмов поиска кратчайших путей. Основная сложность здесь - выбор, указывать ли начальное или конечное ребро в кратчайшем пути. Например, алгоритмы для одного истока более естественно выражаются при задании пути конечным ребром: тогда для реконструкции пути потребуется только один индексированный именами вершин вектор, поскольку все пути ведут обратно к истоку. Этот выбор не представляет принципиальной трудности, поскольку можно либо использовать обратный граф, либо добавить функции-члены, которые скрывают разницу от клиентов. Например, в интерфейсе можно определить функцию-член, которая возвращает ребра кратчайшего пути в векторе (см. упражнения 21.15 и 21.16).

Поэтому для простоты все наши реализации в этой главе содержат функцию-член dist, которая возвращает длину кратчайшего пути и либо функцию-член path, возвращающую начальное ребро в кратчайшем пути, либо функцию-член pathR, которая возвращает конечное ребро в кратчайшем пути. К примеру, наши программы для одного истока, в которых применяется релаксация ребра, обычно реализуют эти функции так:

  Edge *pathR(iirt w) const { return spt[w]; }
  double dist(int v) { return wt[v]; }
      

Аналогично, реализации поиска всех путей, которые используют релаксацию пути, обычно реализуют эти функции так:

  Edge *path(int s, int t) { return p[s][t]; }
  double dist(int s, int t) { return d[s][t]; }
      

В некоторых ситуациях стоило бы построить интерфейсы, стандартизующие один, или другой, или оба этих варианта, но мы будем выбирать для каждого конкретного алгоритма наиболее естественный вариант.

 Все кратчайшие пути в сети


Рис. 21.9.  Все кратчайшие пути в сети

Здесь показаны SPT-деревья для каждой вершины в обращении сети с рис. 21.8 (от 0 до 5, сверху вниз): в виде поддеревьев сети (слева), ориентированных деревьев (в центре) и представления родительскими ссылками, вместе с индексированным именами вершин массивом длин путей (справа). Объединение этих массивов формирует матрицы путей и расстояний (где каждый массив становится столбцом) и дает решение задачи о кратчайших путях для всех пар вершин, которая показана на рис. 21.8.

Упражнения

21.12. Нарисуйте SPT из вершины 0 для сети, определенной в упражнении 21.1, и для ее обращения. Приведите представление родительскими ссылками для обоих деревьев.

21.13. Считайте ребра в сети, определенной в упражнении 21.1, неориентированными ребрами - когда каждому ребру в сети соответствуют два разнонаправленных ребра равного веса. Выполните упражнение 21.12 для этой сети.

21.14. Измените на рис. 21.2 направление ребра 0-3. Нарисуйте два различных SPT с корнем в вершине 2 для этой измененной сети.

21.15. Напишите функцию, которая использует функцию-член pathR из реализации задачи для одного истока и помещает в контейнер vector из библиотеки STL указатели на ребра пути из истока v в заданную вершину w.

21.16. Напишите функцию, которая использует функцию-член path из реализации задачи для всех путей и помещает в контейнер vector из библиотеки STL указатели на ребра пути из заданной вершины v в другую заданную вершину w.

21.17. Напишите программу, которая использует функцию из упражнения 21.16 для вывода всех путей в стиле рис. 21.3.

21.18. Приведите пример, который показывает, как можно найти кратчайший путь из s в t, не зная длину более короткого пути из s в х для некоторого х.

Алгоритм Дейкстры

В лекция №20 был рассмотрен алгоритм Прима для нахождения минимального остовного дерева (MST) взвешенного неориентированного графа: мы строим его, добавляя на каждом шаге кратчайшее ребро, которое соединяет вершину из MST с вершиной, еще не входящей в MST. Для вычисления SPT можно воспользоваться почти такой же схемой. Вначале мы заносим в SPT исток, а затем строим SPT, добавляя на каждом шаге одно ребро, которое формирует кратчайший путь из истока в вершину, не включенную в SPT. То есть вершины заносятся в SPT в порядке их расстояния (по SPT-дереву) от начальной вершины. Этот метод называется алгоритмом Дейкстры (Dijkstra).

Как обычно, необходимо различать неформальное описание алгоритма на некотором уровне абстракции и различные конкретные реализации (как в программе 21.1). Эти реализации различаются в основном представлением графа и реализациями очереди с приоритетами, даже если такие различия не всегда приводятся в литературе. Когда мы убедимся, что алгоритм Дейкстры корректно вычисляет кратчайшие пути из одного истока, мы рассмотрим другие реализации алгоритма и обсудим их связь с программой 21.1.

Лемма 21.2. Алгоритм Дейкстры решает задачу поиска кратчайших путей из одного истока в сетях с неотрицательными весами.

Доказательство. Для заданной вершины-истока s необходимо убедиться, что пути из корня s в каждую вершину х в дереве, вычисленному алгоритмом Дейкстры, соответствуют кратчайшим путям в графе из s в х. Это можно доказать по индукции. Допустим, что вычисленное на данный момент поддерево обладает этим свойством - нам нужно лишь доказать, что добавление новой вершины х приводит к добавлению кратчайшего пути в эту вершину. Однако все другие пути, ведущие в х, должны начинаться путем в дереве и завершаться ребром в вершину, которая не находится в дереве. По построению все такие пути являются более длинными, чем рассматриваемый путь из s в х.

Таким же образом можно показать, что алгоритм Дейкстры решает задачу поиска кратчайших путей из истока в сток, если начать с истока и остановиться, когда сток покинет очередь с приоритетами.

Доказательство не срабатывает, если веса ребер могут быть отрицательными, поскольку в нем предполагается, что при добавлении ребер к пути длина пути не уменьшается. В сети с отрицательными весами ребер это предположение недействительно, поскольку любое встреченное ребро, ведущее в некоторую вершину дерева и имеющее достаточно большой отрицательный вес, может дать более короткий путь в эту вершину, чем путь в дереве. Этот момент будет рассмотрен в разделе 21.7 (см. рис. 21.28).

На рис. 21.10 показано развертывание SPT при вычислении по алгоритму Дейкстры, а на рис. 21.11 показан рисунок большего ориентированного SPT-дерева. Хотя алгоритм Дейкстры отличается от алгоритма Прима для вычисления MST только выбором приоритета, SPT-деревья обладают характерными особенностями, отличающими их от MST. Их корень расположен в начальной вершине, а все ребра направлены в сторону от корня, тогда как в MST нет ни корня, ни направлений. При использовании алгоритма Прима мы иногда представляем MST-деревья как направленные деревья с корнем, но такие структуры все равно существенно отличаются от SPT (сравните ориентированное изображение на рис. 20.9 с изображением на рис. 21.11). Вообще-то природа SPT отчасти зависит и от выбора начальной вершины (см. рис. 21.12).

Первоначальная реализация Дейкстры, которая годится для насыщенных графов, в точности соответствует алгоритму Прима для вычисления MST. Мы просто заменяем присваивание приоритета P в программе 20.6

  P = e->wt()
    (вес ребра) на
    P = wt[v] + e->wt()
      

(расстояние от истока до конечной вершины ребра). Это изменение приводит к классической реализации алгоритма Дейкстры: на каждом шаге к SPT добавляется одно ребро, и каждый раз обновляется расстояние до дерева для всех вершин, смежных с конечной вершиной этого ребра. Одновременно просматриваются все вершины, не включенные в дерево, чтобы найти для включения в дерево ребро, конечная вершина которого не принадлежит дереву и находится на минимальном расстоянии от истока.

Лемма 21.3. С помощью алгоритма Дейкстры можно найти любое SPT в насыщенной сети за линейное время.

Доказательство. Как и в случае алгоритма Прима для вычисления MST, после просмотра кода программы 20.6 очевидно, что время выполнения пропорционально V2, т.е. линейно для насыщенных графов.

В случае разреженных графов возможно усовершенствование. Алгоритм Дейкстры можно рассматривать как обобщенный метод поиска на графе, который отличается от поиска в глубину (DFS), от поиска в ширину (BFS) и от алгоритма Прима для вычисления MST только правилом добавления ребер в дерево. Как и в лекция №20, ребра, которые соединяют вершины дерева с вершинами, не включенными в дерево, содержатся в обобщенной очереди, называемой накопителем (fringe). Для реализации этой обобщенной очереди используется очередь с приоритетами и механизм изменения приоритетов, что позволяет объединить в одной реализации алгоритмы DFS, BFS и Прима (см. лекция №20). Эта схема поиска по приоритетам (PFS) включает и алгоритм Дейкстры. То есть замена оператора присваивания P в программе 20.7 на

  P = wt[v] + e->wt()
      

(расстояние от истока до конечной вершины ребра) дает реализацию алгоритма Дейкстры, которая удобна для обработки разреженных графов.

 Алгоритм Дейкстры


Рис. 21.10.  Алгоритм Дейкстры

Эта последовательность показывает построение алгоритмом Дейкстры остовного дерева кратчайших путей для сети с корнем в вершине 0. Жирными черными линиями на диаграммах сетей показаны ребра дерева, а жирными серыми - ребра в накопителе. Ориентированные изображения дерева в процессе его роста показаны в центре, а справа приведены списки ребер в накопителе. Вначале в дерево заносится вершина 0, а в накопитель - выходящие из него ребра 0-1 и 0-5 (рисунок вверху). На втором шаге мы переносим самое короткое из этих ребер, 0-5, из накопителя в дерево и проверяем ребра, исходящие из него:ребро 5-4 заносится в накопитель, а ребро 5-1 отбрасывается, поскольку оно не является частью более короткого пути из 0 в 1, чем известный путь 0-1 (второй рисунок сверху). Приоритет ребра 5-4 в накопителе равен длине представленного им пути 0-5-4 из вершины 0. На третьем шаге мы переносим ребро 0-1 из накопителя в дерево, заносим в на-копитель ребро 1-2 и отбрасываем 1-4 (третий сверху рисунок). На четвертом шаге мы переносим 5-4 из накопителя в дерево, заносим в накопитель 4-3 и заменяем 1-2 на 4-2, т.к. путь 0-5-4-2 короче, чем 0-1-2 (четвертый сверху). Мы держим в накопителе не более одного ребра, ведущего к каждой вершине, и выбираем из них то, которое лежит на кратчайшем пути от 0. Вычисление завершается переносом из накопителя в дерево ребра 4-2, а затем 4-3 (рисунок внизу).

 Остовное дерево кратчайших путей


Рис. 21.11.  Остовное дерево кратчайших путей

Здесь показана работа алгоритма Дейкстры для решения задачи поиска кратчайших путей из одного истока в случайном евклидовом орграфе с соседними связями (с двунаправленными ребрами, соответствующими каждой начерченной линии) - в том же стиле, что и рис. 18.13, рис. 18.24 и рис. 20.9 Дерево поиска похоже на BFS, т.к. вершины обычно соединяются друг к другом короткими путями, но оно слегка более вытянутое и менее широкое, поскольку использование расстояний приводит к несколько более длинным путям, чем использование длин путей.

 Примеры SPT-деревьев


Рис. 21.12.  Примеры SPT-деревьев

Эти три примера демонстрируют рост SPT для трех различных положений истока: левое ребро (вверху), верхний левый угол (в центре), и центр (внизу).

Программа 21.1. Алгоритм Дейкстры (поиск по приоритетам)

Этот класс реализует АТД для поиска кратчайших путей из одного истока с линейным временем предварительной обработки, приватными данными, занимающими объем памяти, пропорциональный V, и функциями-членами с постояннным временем выполнения, которые возвращают длину кратчайшего пути и конечную вершину пути из истока в любую заданную вершину. Конструктор реализует алгоритм Дейкстры, используя для вычисления SPT очередь с приоритетами вершин (в порядке их удаления от истока). Интерфейс очереди с приоритетами - тот же, который используется в программе 20.7 и реализован в программе 20.10.

Конструктор выполняет обобщенный поиск на графе, который реализует другие алгоритмы PFS с другими назначениями приоритета P (см. текст). Оператор обнуления веса вершин дерева необходим для общей реализации PFS, но не для алгоритма Дейкстры, т.к. приоритеты вершин, добавляемых в SPT, являются неубывающими.

  template <class Graph, class Edge>
  class SPT
    { const Graph &G;
      vector<double> wt;
      vector<Edge *> spt;
    public:
      SPT(const Graph &G, int s) : G(G),
        spt(G.V()), wt(G.V(), G.V())
        { PQi<double> pQ(G.V(), wt);
          for (int v = 0; v < G.V(); v++) pQ.insert(v);
          wt[s] = 0.0; pQ.lower(s);
          while (!pQ.empty())
            { int v = pQ.getmin(); // wt[v] = 0.0;
              if (v != s && spt[v] == 0) return;
              typename Graph::adjIterator A(G, v);
              for (Edge* e = A.beg(); !A.end(); e = А.пх^))
                { int w = e->w();
                  double P = wt[v] + e->wt();
                  if (P < wt[w])
                    { wt[w] = P; pQ.lower(w); spt[w] = e; }
                }
            }
        }
    Edge *pathR(int v) const { return spt[v]; }
    double dist(int v) const { return wt[v]; }
    };
      

Программа 21.1 является альтернативной реализацией PFS для разреженных графов; она несколько проще программы 20.7 и непосредственно соответствует неформальному описанию алгоритма Дейкстры в начале этого раздела. Она отличается от программы 20.7 тем, что вначале все вершины сети заносятся в очередь с приоритетами, и те вершины в этой очереди, которые не находятся ни в дереве, ни в накопителе, помечаются сигнальными значениями (невидимые вершины с сигнальными значениями). А программа 20.7 содержит в очереди с приоритетами только вершины, достижимые из дерева через одно ребро. Хранение всех вершин в очереди упрощает код, хотя для некоторых графов может слегка ухудшить эффективность (см. упражнение 21.31).

Общие значения производительности поиска по приоритетам (PFS), рассмотренные в лекция №20, дают нам конкретную информацию об эффективности этих реализаций алгоритма Дейкстры для разреженных графов (программа 21.1 и программа 20.7, с соответствующими изменениями). Для удобства мы приведем их еще раз, но уже в текущем контексте. Поскольку доказательства не зависят от функции вычисления приоритета, их можно применить без изменений. Это результаты для наихудшего случая и обеих программ, хотя программа 20.7 может оказаться более эффективной для многих классов графов, поскольку она использует меньший накопитель.

Лемма 21.4. Для всех сетей и всех функций приоритетов можно вычислить остовное дерево с помощью PFS за время, пропорциональное времени, необходимому для выполнения V операций вставить, V операций удалить минимальное и E операций уменьшить ключ в очереди с приоритетами размером не более V.

Доказательство. Этот факт следует непосредственно из реализаций программ 20.7 и 21.1, основанных на очередях с приоритетами. Он дает слишком осторожную верхнюю границу, поскольку размер очереди с приоритетами часто намного меньше V, особенно в программе 20.7.

Лемма 21.5. С помощью реализации алгоритма Дейкстры на основе PFS, использующей пирамидальное дерево для реализации очереди с приоритетами, можно вычислить любое SPT за время, пропорциональное E lgV.

Доказательство. Этот результат является прямым следствием леммы 21.4. ¦ Лемма 21.6. Пусть задан граф с V вершинами, E ребрами и насыщенностью d = E/V. Если d < 2, то время выполнения алгоритма Дейкстры пропорционально VlgV Иначе реализация очереди с приоритетами на основе -арного пирамидального дерева позволяет улучшить время выполнения в худшем случае не менее чем в lg(E/V) раз - до (что линейно, если E равно по крайней мере ).

Доказательство. Этот результат непосредственно вытекает из леммы 20.12 и реализации очереди с приоритетами на основе многопутевых пирамидальных деревьев, которая рассмотрена после этой леммы.

В таблице 21.1 сведена информация о рассмотренных нами четырех основных алгоритмах PFS. Они отличаются только используемой функцией вычисления приоритета, но эта разница приводит к совершенно различным остовным деревьям (как и должно быть). Например, на рисунках, упоминаемых в таблице (и для многих других графов), дерево DFS является высоким и узким, дерево BFS - низким и широким; SPT похоже на дерево BFS, но не такое низкое и широкое, а MST - не низкое и широкое, но и не высокое и узкое.

Все эти четыре классических алгоритма обработки графов могут быть реализованы при помощи PFS - т.е. обобщенного поиска на графе, основанного на очереди с приоритетами, который строит остовные деревья, добавляя в них по одному ребру. Детали динамики поиска зависят от представления графа, реализации очереди с приоритетами, а также реализации PFS, однако деревья поиска обычно характеризуют различные алгоритмы (как показано на рисунках, указанных в четвертом столбце).

Таблица 21.1. Алгоритмы поиска по приоритетам
АлгоритмПриоритетРезультатРисунок
DFSОбращение прямого обходаДерево рекурсии18.13
BFSПрямой обходSPT (ребра)18.24
ПримаВес ребраMST20.8
ДейкстрыВес путиSPT21.9

Мы уже рассмотрели четыре различных реализации PFS. Первая - это классическая реализация обхода насыщенного графа, которая охватывает алгоритм Дейкстры и алгоритм Прима для вычисления MST (программа 20.6). Три другие реализации предназначены для разреженных графов и отличаются содержимым очереди с приоритетами:

Первая реализация имеет в основном познавательную ценность, вторая наиболее совершенна, а третья, пожалуй, самая простая из всех. На самом деле эта схема описывает 16 различных реализаций классических алгоритмов поиска на графах, а если учесть различные реализации очередей с приоритетами, то возможных вариантов будет еще больше. Это разнообразие сетей, алгоритмов и реализаций подчеркивает важность общих формулировок производительности в леммах 21.4-21.6, которые также сведены в таблицу 21.2.

В этой таблице приведены затраты (время выполнения в худшем случае) различных реализаций алгоритма Дейкстры. При соответствующих реализациях очередей с приоритетами алгоритм выполняется за линейное время (пропорциональное V2 для насыщенных сетей и пропорциональное E для разреженных сетей), за исключением очень разреженных сетей.

Таблица 21.2. Трудоемкость реализаций алгоритма Дейкстры
АлгоритмТрудоемкость в худшем случаеПримечание
Классический V2 Оптимален для насыщенных графов
PFS, полное пирамидальное дерево E lgV Самая простая реализация
PFS, пирамидальное дерево с накопителем E lgV Очень осторожная верхняя граница
PFS, пирамидальное d-дерево Линеен, если граф не слишком разрежен

Как и в случае алгоритмов вычисления MST, фактическое время поиска кратчайших путей обычно меньше границ для худшего случая - прежде всего потому, что большинство ребер не требуют выполнения операции уменьшить ключ. На практике, за исключением крайне разреженных графов, время выполнения можно считать линейным.

Название алгоритм Дейкстры обычно используется для обозначения как абстрактного метода построения SPT пошаговым добавлением вершин в порядке их расстояния от истока, так и его реализации в виде алгоритма со временем выполнения, пропорциональным V2, для представления матрицей смежности, поскольку Дейкстра представил и то, и другое в своей статье, опубликованной в 1959 г. (а также показал, что этим методом можно вычислить и MST). Повышение производительности на разреженных графах связано с последующими усовершенствованиями в технологии АТД и реализациях очереди с приоритетами, которые не предназначены для задач поиска кратчайших путей. Более высокая производительность алгоритма Дейкстры - одно из наиболее важных свойств этой технологии (см. раздел ссылок). Как и в случае с MST, для указания конкретных сочетаний мы применяем термины вроде " реализация алгоритма Дейкстры на основе PFS с использованием пирамидальных d-деревьев " .

В лекция №18 было показано, что при использовании в невзвешенных неориентированных графах прямой нумерации для приоритетов очередь с приоритетами действует как очередь FIFO и приводит к поиску в ширину (BFS). Алгоритм Дейкстры дает другую реализацию BFS: когда все веса ребер равны 1, он посещает вершины в порядке нумерации ребер на кратчайшем пути к начальной вершине. В этом случае очередь с приоритетами действует не совсем так, как очередь FIFO, т.к. элементы с одинаковыми приоритетами не обязательно извлекаются из очереди в том порядке, в котором они были занесены в нее.

Каждая из этих реализаций помещает ребра SPT-дерева из вершины 0 в индексированный именами вершин вектор spt, а в индексированный именами вершин вектор wt - длины кратчайших путей в каждую вершину SPT, и содержит функции-члены, обеспечивающие клиентам доступ к этим данным. Как обычно, на основе этих первичных данных можно построить различные функции и классы обработки графов (см. упражнения 21.21-21.28).

Упражнения

21.19. Покажите в стиле рис. 21.10 результат вычисления алгоритмом Дейкстры SPT для сети, определяемой в упражнении 21.1, с начальной вершиной 0.

21.20. Как найти второй кратчайший путь в сети из s в t ?

21.21. Напишите клиентскую функцию, которая использует объект SPT для поиска вершины, наиболее удаленной от заданной вершины s (вершина, кратчайший путь в которую из s является наиболее длинным).

21.22. Напишите клиентскую функцию, которая использует объект SPT для вычисления среднего значения длины кратчайших путей из заданной вершины в каждую из вершин, достижимых из нее.

21.23. Разработайте на основе программы 21.1 класс с функцией-членом path, которая возвращает значение типа vector из библиотеки STL, содержащее указатели на ребра кратчайшего пути, соединяющего s и t в направлении от s к t.

21.24. Напишите клиентскую функцию, которая использует класс из упражнения 21.23 для вывода кратчайших путей из заданной вершины в остальные вершины заданной сети.

21.25. Напишите клиентскую функцию, которая использует объект SPT, чтобы найти все вершины, лежащие в пределах заданного расстояния d от заданной вершины в заданной сети. Время выполнения функции должно быть пропорционально размеру подграфа, индуцированного этими и инцидентными им вершинами.

21.26. Разработайте алгоритм поиска ребра, удаление которого вызывает максимальное возрастание длины кратчайшего пути из одной заданной вершины в другую в заданной сети.

21.27. Реализуйте класс, который использует объекты SPT для анализа чувствительности ребер сети по отношению к заданной паре вершин s и t. Вычислите такую матрицу размером V х V, что для каждого u и v элемент на пересечении строки u и столбца v равен 1, если в сети существует ребро u-v, увеличение веса которого не приводит к увеличению длины кратчайшего пути из s в t, и равен 0 в противном случае.

21.28. Реализуйте класс, который использует объекты SPT для поиска кратчайшего пути, соединяющего одно заданное множество вершин с другим заданным множеством вершин в заданной сети.

21.29. Воспользуйтесь решением из упражнения 21.28 для реализации клиентской функции, которая находит кратчайший путь от левого ребра к правому ребру в случайной решетчатой сети (см. упражнение 20.17).

21.30. Покажите, что MST неориентированного графа эквивалентно SPT-дереву критических ребер (bottleneck SPT) этого графа: для каждой пары вершин v и w оно дает соединяющий их путь, наиболее длинное ребро которого имеет минимально возможную длину.

21.31. Эмпирически сравните производительность двух версий алгоритма Дейкстры для разреженных графов, которые описаны в этом разделе (программа 21.1 и программа 20.7, с соответствующим определением приоритета), для различных сетей (см. упражнения 21.4-21.8). Воспользуйтесь реализацией очереди с приоритетами на основе стандартного пирамидального дерева.

21.32. Эмпирически найдите наилучшее значение d для реализации очереди с приоритетами в виде пирамидального d-дерева (см. программу 20.10) для каждой из трех рассмотренных реализаций алгоритма PFS (программы 18.10, 20.7 и 21.1) и для различных сетей (см. упражнения 21.4-21.8).

21.33. Эмпирически определите эффект использования реализации очереди с приоритетами на основе турнира индексного пирамидального дерева (см. упражнение 9.53) в программе 21.1 для различных сетей (см. упражнения 21.4-21.8).

21.34. Эмпирически проанализируйте высоту и среднюю длину пути в SPT для различных сетей (см. упражнения 21.4-21.8).

21.35. Разработайте класс для задачи поиска кратчайших путей из истока в сток, основанный на коде наподобие программы 21.1, но в котором вначале в очередь с приоритетами заносится и исток, и сток. При этом SPT-деревья растут из обеих вершин; ваша основная задача - решить, что делать при их встрече.

21.36. Опишите семейство графов с V вершинами и E ребрами, для которых время работы алгоритма Дейкстры достигает границы для худшего случая.

21.37. Разработайте приемлемый генератор случайных графов с V вершинами и E ребрами, для которых время работы PFS-реализации алгоритма Дейкстры с использованием пирамидального дерева суперлинейно.

21.38. Напишите клиентскую программу для выполнения динамической графической анимации работы алгоритма Дейкстры. Программа должна создавать изображения наподобие рис. 21.11 (см. упражнения 17.56-17.60). Протестируйте программу на случайных евклидовых сетях (см. упражнение 21.9).

Кратчайшие пути между всеми парами вершин

В этом разделе мы рассмотрим два класса, решающие задачу поиска кратчайших путей для всех пар вершин. Алгоритмы, которые мы реализуем, непосредственно обобщают два базовых алгоритма, которые были рассмотрены в лекция №19 для задачи транзитивного замыкания. Первый метод заключается в выполнении алгоритма Дейкстры из каждой вершины для получения кратчайших путей из этой вершины во все остальные. Если реализовать очередь с приоритетами с помощью пирамидального дерева, то при таком подходе время выполнения в худшем случае будет пропорционально VE lgV, а использование d-арного пирамидального дерева позволяет улучшить эту границу для многих типов сетей до VE. Второй метод, который позволяет напрямую решить данную задачу за время, пропорциональное V3, является расширением алгоритма Уоршалла, и называется алгоритм Флойда (Floyd).

Оба класса реализуют интерфейс АТД абстрактных кратчайших путей для нахождения кратчайших расстояний и путей. Этот интерфейс, который показан в программе 21.2, является обобщением интерфейса абстрактного транзитивного замыкания взвешенного орграфа для выяснения связности в орграфах, который был изучен в лекция №19. В реализациях обоих классов конструктор решает задачу поиска кратчайших путей для всех пар вершин и сохраняет результат в приватных членах данных. Эти данные используются функциями ответов на запросы, которые возвращают длину кратчайшего пути из одной заданной вершины в другую, а также первое или последнее ребро в пути. Основное назначение реализации такого АТД - его практическое использование для реализации алгоритмов поиска кратчайших путей для всех пар вершин.

Программа 21.3 представляет собой пример клиентской программы, которая вычисляет взвешенный диаметр (weighted diameter) сети с помощью интерфейса АТД для поиска всех кратчайших путей. Она просматривает все пары вершин и находит такую пару, для которой длина кратчайшего пути максимальна, а затем обходит путь, ребро за ребром. На рис. 21.13 показан путь, вычисленный этой программой для нашей демонстрационной евклидовой сети.

Программа 21.2. АТД кратчайших путей для всех пар вершин

Все наши решения задачи поиска кратчайших путей для всех пар вершин имеют вид классов с конструктором и двумя функциями ответов на запросы. Первая функция, dist, возвращает длину кратчайшего пути из первого аргумента во второй, а вторая - одна из двух возможных функций вычисления пути: либо path, возвращающая указатель на первое ребро в кратчайшем пути, либо pathR, возвращающая указатель на последнее ребро в кратчайшем пути. Если такой путь не существует, то функция path возвращает 0, а результат dist не определен.

Мы используем функции path или pathR в зависимости от того, что удобнее для рассматриваемого алгоритма; возможно, на практике одну из них (или обе) следует поместить в интерфейс, а в реализациях использовать различные вспомогательные функции, как описано в разделе 21.1 и в упражнениях в конце этого раздела.

  template <class Graph, class Edge> class SPall
    { public:
      SPall(const Graph &);
      Edge *path(int, int) const;
      Edge *pathR(int, int) const;
      double dist(int, int) const;
    };
      

Цель алгоритмов, представленных в этом разделе, состоит в обеспечении постоянного времени выполнения функций ответов на запросы. Обычно ожидается огромное количество таких запросов, поэтому лучше затратить значительно больше ресурсов на вычисление приватных членов данных и предварительную обработку в конструкторе, чтобы затем быстро отвечать на запросы. Оба рассматриваемых нами алгоритма требуют объем памяти для приватных членов данных, пропорциональный V2.

 Диаметр сети


Рис. 21.13.  Диаметр сети

Диаметр сети - это наибольший элемент в матрице всех кратчайших путей сети, т.е. длина наиболее длинного из кратчайших путей, показанного здесь для нашей демонстрационной евклидовой сети.

Основной недостаток этого общего подхода состоит в том, что для огромной сети может не хватить объема доступной памяти (или времени, необходимого на предварительную обработку). В принципе, наш интерфейс позволяет выбирать между затратами времени и памяти на предварительную обработку и затратами памяти при ответе на запрос.

Если ожидается лишь несколько запросов, то можно обойтись без предварительной обработки, и просто выполнять для каждого запроса алгоритм для одного истока; однако в наиболее распространенных ситуациях нужны более совершенные алгоритмы (см. упражнения 21.48-21.50). Эта задача обобщает задачу, которой посвящена большая часть лекция №19 - поддержка быстрых запросов достижимости при ограничении на доступную память.

Первая рассматриваемая нами реализация функции АТД поиска кратчайших путей для всех пар вершин решает эту задачу, используя алгоритм Дейкстры для решения задачи с одним истоком для каждой вершины. C++ позволяет записать этот метод непосредственно (см. программу 21.4): для решения задачи с одним истоком для каждой вершины создается вектор объектов SPT. Этот метод обобщает метод на основе BFS для невзвешенных неориентированных графов, который был рассмотрен в лекция №17. Он также похож на использование DFS в программе 19.4, где вычисляется транзитивное замыкание невзвешенного орграфа с началом в каждой вершине.

Программа 21.3. Вычисление взвешенного диаметра сети

Эта клиентская функция демонстрирует использование интерфейса из программы 21.2. Она находит самый длинный из кратчайших путей в данной сети, выводит путь и возвращает его вес (т.е. диаметр сети).

  template <class Graph, class Edge>
  double diameter(Graph &G)
    { int vmax = 0, wmax = 0;
      allSP<Graph, Edge> all(G);
      for (int v = 0; v < G.V(); v++)
        for (int w = 0; w < G.V(); w++)
          if (all.path(v, w))
            if (all.dist(v, w) > all.dist(vmax, wmax))
              { vmax = v; wmax = w; }
      int v = vmax; cout << v;
      while (v != wmax)
        { v = all.path(v, wmax)->w(); cout <<    << v; }
      return all.dist(vmax, wmax);
    }
      

Лемма 21.7. С помощью алгоритма Дейкстры можно найти все кратчайшие пути в сети с неотрицательными весами за время, пропорциональное , где d = 2, если E < 2 V и d = E/V в противном случае.

Доказательство. Непосредственно следует из леммы 21.6.

Как и в задачах о кратчайших путях из одного истока и задачах вычисления MST, эта граница слишком осторожна; для типичных графов время выполнения обычно равно VE.

Для сравнений этой реализации с другими полезно рассмотреть матрицы, неявно образованные структурой вектора векторов приватных членов данных. Векторы wt образуют как раз матрицу расстояний, рассмотренную в разделе 21.1: элемент этой матрицы на пересечении строки s и столбца t содержит длину кратчайшего пути из s в t. Как показано на рис. 21.8 и 21.9, векторы spt образуют транспонированную матрицу путей: элемент на пересечении строки s и столбца t указывает на последнее ребро в кратчайшем пути из s в t.

Для насыщенных графов можно было бы использовать представление матрицей смежности и избежать вычисления обратного графа с помощью неявного транспонирования матрицы (поменяв местами индексы строк и столбцов), как в программе 19.7. Разработка такой реализации представляет собой интересное упражнение по программированию и приводит к компактной реализации (см. упражнение 21.43); однако другой подход, который будет рассмотрен ниже, допускает еще более компактную реализацию.

Рекомендуемый метод решения задачи поиска кратчайших путей для всех пар вершин в насыщенных графах был разработан Р. Флойдом (R. Floyd). Он точности повторяет метод Уоршалла, только вместо использования логической операции ИЛИ для отслеживания существования путей он проверяет расстояния для каждого ребра, чтобы определить, является ли это ребро частью нового, более короткого пути.

Программа 21.4. Алгоритм Дейкстры для поиска всех кратчайших путей

Этот класс использует алгоритм Дейкстры, чтобы построить SPT для каждой вершины. Это позволяет выполнять функции pathR и dist для любой пары вершин.

  #include "SPT.cc"
  template <class Graph, class Edge>
  class allSP
    { const Graph &G;
      vector< SPT<Graph, Edge> *> A;
    public:
      allSP(const Graph &G) : G(G), A(G.V())
        { for (int s = 0; s < G.V(); s++)
          A[s] = new SPT<Graph, Edge>(G, s);
        }
      Edge *pathR(int s, int t) const
        { return A[s]->pathR(t); }
      double dist(int s, int t) const
        { return A[s]->dist(t); }
    };
      

Как уже было сказано, в соответствующей абстрактной постановке алгоритмы Флойда и Уоршалла идентичны (см. лекция №19 и 21.1).

Программа 21.5 содержит функцию АТД поиска кратчайших путей для всех пар вершин, которая реализует алгоритм Флойда. В ней явно используются матрицы из раздела 21.1 как приватные члены данных: вектор векторов d размером V х V для матрицы расстояний и еще один вектор векторов p размером V х V для таблицы путей. Для каждой пары вершин s и t конструктор заносит в d[s][t] длину кратчайшего пути из s в t (которая возвращается функцией-членом dist), а в p[s][t] - индекс следующей вершины на кратчайшем пути из s в t (который возвращается функцией-членом path). Реализация основана на операции релаксации пути, рассмотренной в разделе 21.1.

Программа 21.5. Алгоритм Флойда для поиска всех кратчайших путей

Эта реализация интерфейса из программы 21.2 использует алгоритм Флойда - обобщение алгоритма Уоршалла (см. программу 19.3), которое находит кратчайшие пути между каждой парой точек, а не просто проверяет их существование.

После первоначального занесения ребер графа в матрицы расстояний и путей выполняется последовательность операций релаксации для вычисления кратчайших путей. Алгоритм прост для реализации, хотя проверка того, что он вычисляет кратчайшие пути, более сложна (см. текст).

  template <class Graph, class Edge>
  class allSP
    { const Graph &G;
      vector <vector <Edge *> > p;
      vector <vector <double> > d;
    public:
      allSP(const Graph &G) : G(G), p(G.V()), d(G.V())
        { int V = G.V();
          for (int i = 0; i < V; i++)
            { p[i].assign(V, 0); d[i].assign(V, V); }
          for (int s = 0; s < V; s++)
            for (int t = 0; t < V; t++)
              if (G.edge(s, t))
                { p[s][t] = G.edge(s, t);
                  d[s][t] = G.edge(s, t)->wt();
                }
          for (int s = 0; s < V; s++) d[s][s] = 0;
          for (int i = 0; i < V; i++)
            for (int s = 0; s < V; s++) if (p[s][i])
              for (int t = 0; t < V; t++) if (s != t)
                if (d[s][t] > d[s][i] + d[i][t])
                  { p[s][t] = p[s][i];
                    d[s][t] = d[s][i] + d[i][t];
                  }
        }
      Edge *path(int s, int t) const
        { return p[s][t]; }
      double dist(int s, int t) const
        { return d[s][t]; }
    };
      

Лемма 21.8. С помощью алгоритма Флойда можно найти все кратчайшие пути в сети за время, пропорциональное V3.

Доказательство. Время выполнения очевидно из структуры кода. Доказательство корректности алгоритма мы проведем по индукции точно так же, как для алгоритма Уоршалла. i-я итерация цикла вычисляет кратчайший путь в сети из s в t, который не содержит вершин с индексами больше i (кроме, возможно, конечных вершин s и t). Полагая, что это утверждение истинно для i-ой итерации цикла, покажем, что оно истинно и для (i + 1)-ой итерации. Любой кратчайший путь из s в t, который не содержит вершин с индексами, большими i + 1, либо (1) является путем из s в t, который найден в предыдущей итерации цикла и по индуктивному предположению имееет длину d[s][t] и не содержит вершин с индексами больше i; либо (2) составлен из путей из s в i и из i в t, ни один из которых не содержит вершин с индексами больше i, и в этом случае внутренний цикл устанавливает d[s][t].

На рис. 21.14 показана подробная трасса выполнения алгоритма Флойда для нашей демонстрационной сети. Если преобразовать каждый пустой элемент в 0 (указание на отсутствие ребра), а каждый непустой элемент - в 1 (указание на наличие ребра), то эти матрицы описывают работу алгоритма Уоршалла точно так же, как на рис. 19.15. В случае алгоритма Флойда непустые элементы не только указывают существование пути, но и дают информацию об известном кратчайшем пути. Элемент в матрице расстояний содержит длину известного кратчайшего пути, который соединяет вершины, соответствующие данной строке и столбцу; соответствующий элемент в матрице путей дает следующую вершину на этом пути. По мере заполнения матриц непустыми элементами алгоритм Уоршалла просто перепроверяет, соединяют ли новые пути пары вершин, которые уже соединены известными путями. В отличие от этого, алгоритм Флойда должен проверить (и при необходимости обновить) каждый новый путь, чтобы убедиться, что он приводит к более коротким путям.

Сравнивая оценки времени выполнения в худшем случае для алгоритмов Дейкстры и Флойда, можно получить тот же результат для алгоритмов поиска кратчайших путей для всех пар вершин, что и для соответствующих алгоритмов транзитивного замыкания влекция №19. Понятно, что для разреженных сетей более подходит выполнение алгоритма Дейкстры из каждой вершины, поскольку время работы близко к VE. По мере возрастания насыщенности графа с ним все более конкурирует алгоритм Флойда, который всегда требует времени, пропорционального V3 (см. упражнение 21.67); он широко используется ввиду простоты реализации.

Более существенное различие между этими алгоритмами, которое подробно рассматривается в разделе 21.7, состоит в том, что алгоритм Флойда эффективен даже на сетях, где возможны отрицательные веса (при условии, что нет отрицательных циклов). Как было сказано в разделе 21.2, метод Дейкстры в таких графах не обязательно находит кратчайшие пути.

Классические решения описанной задачи поиска кратчайших путей для всех пар вершин предполагают, что имеется объем памяти, достаточный для хранения матриц расстояний и путей. Огромные разреженные графы, матрицы V х V которых слишком велики для хранения в памяти, представляют еще одну интересную и перспективную область. Как было показано в лекция №19, до сих пор является открытой задача сведения затрат памяти до величины, пропорциональной V, при сохранении поддержки линейного времени ответов на запросы длины кратчайших путей.

 Алгоритм Флойда


Рис. 21.14.  Алгоритм Флойда

Эта последовательность показывает построение матриц кратчайших путей для всех пар вершин с помощью алгоритма Флойда. Для i от 0 до 5 (сверху вниз), мы рассматриваем для всех s и t все пути из s в t, в которых нет промежуточных вершин, больших i (заштрихованные вершины). Вначале единственными такими путями являются ребра сети, поэтому матрица расстояний (в центре) представляет собой матрицу смежности графа, а в матрицу путей (справа) для каждогоребра s-t заносится p[s][t]= t. Для вершины 0 (вверху) алгоритм находит, что путь 3-0-1 короче сигнального значения, которое означает отсутствие ребра 3-1, и соответствующим образом обновляет матрицы. Это не делается для путей наподобие 3-0-5, который не короче известного пути 3-5. Далее алгоритм рассматривает пути, проходящие через 0 и 1 (второй ряд сверху) и находит новые более короткие пути 0-1-2, 0-1-4, 3-0-1-2, 3-0-1-4 и 5-1-2. В третьем ряду сверху показаны обновления, соответствующие более коротким путям через 0, 1, 2 и т.д.

Черные числа, записанные поверх серых в матрицах, указывают на ситуации, где алгоритм находит более короткий путь, чем найденный раньше. Например, в строке 3 и столбце 2 в нижнем ряду поверх 1.37 записано 0.91, поскольку алгоритм обнаружил, что путь 3-5-4-2 короче пути 3-0-1-2.

Мы выяснили, что имеется аналогичная трудноразрешимая проблема даже для более простой задачи достижимости (где достаточно было за линейное время узнать, имеется ли вообще какой-либо путь, соединяющий данную пару вершин), поэтому нельзя ожидать простого решения задачи поиска кратчайших путей для всех пар вершин. Вообще-то количество различных кратчайших длин путей в общем случае пропорционально V2 даже для разреженных графов. Эта величина в некотором смысле служит мерой объема информации, которую требуется обработать, и, возможно, указывает, что если существует ограничение на объем памяти, то следует ожидать большего времени обработки каждого запроса (см. упражнения 21.48-21.50).

Упражнения

21.39. Оцените с точностью до порядка наибольший размер (количество вершин) графа, который ваш компьютер и система программирования могут обработать за 10 секунд, если для вычисления кратчайших путей использовать алгоритм Флойда.

21.40. Оцените с точностью до порядка наибольший размер (количество ребер) графа с насыщенностью 10, который ваш компьютер и система программирования могут обработать за 10 секунд, если для вычисления кратчайших путей использовать алгоритм Дейкстры.

21.41. Покажите в стиле рис. 21.9 результат применения алгоритма Дейкстры для вычисления всех кратчайших путей в сети, определенной в упражнении 21.1.

21.42. Покажите в стиле рис. 21.14 результат применения алгоритма Флойда для вычисления всех кратчайших путей в сети, определенной в упражнении 21.1.

21.43. Объедините программу 20.6 с программой 21.4 для реализации интерфейса АТД поиска кратчайших путей для всех пар вершин (на основе алгоритма Дейкстры) в насыщенных сетях, который поддерживает вызовы функции path, но не вычисляет явно обратную сеть. Не определяйте отдельную функцию для решения задачи с одним истоком - поместите код из программы 20.6 непосредственно во внутренний цикл и храните результаты непосредственно в приватных членах данных d и p, как в программе 21.5.

21.44. Эмпирически сравните в стиле таблицы 20.2 алгоритм Дейкстры (программа 21.4 и упражнение 21.43) и алгоритм Флойда (программа 21.5) для различных сетей (см. упражнения 21.4-21.8).

21.45. Эмпирически определите, сколько раз алгоритмы Флойда и Дейкстры обновляют значения в матрице расстояний для различных сетей (см. упражнения 21.4-21.8).

21.46. Приведите матрицу, в которой элемент в строке s и столбце t равен количеству различных простых направленных путей, соединяющих s и t на рис. 21.1.

21.47. Реализуйте класс, конструктор которого вычисляет матрицу количества путей, описанную в упражнении 21.46, чтобы общедоступная функция-член могла выдавать это количество за постоянное время.

21.48. Разработайте реализацию класса для абстрактного АТД поиска кратчайших путей для разреженных графов, в которой необходимый объем памяти сокращается до величин, пропорциональных V, а время запроса увеличивается до значений, пропорциональных V.

21.49. Разработайте реализацию абстрактного класса АТД поиска кратчайших путей для разреженных графов, которая использует объем памяти, существенно меньший O(V2), но поддерживает запросы за время, намного меньшее O(V). Указание. Вычислите все кратчайшие пути для подмножества вершин.

21.50. Разработайте реализацию абстрактного класса АТД поиска кратчайших путей для разреженных графов, которая использует объем памяти, существенно меньший O( V2), и поддерживает (с помощью рандомизации) запросы за линейное ожидаемое время.

21.51. Разработайте реализацию абстрактного класса АТД поиска кратчайших путей, в которой используется ленивый подход применения алгоритма Дейкстры: SPT-дерево (и связанный с ним вектор расстояний) для вершины s строится при первом запросе клиентом кратчайшего пути из s, а при последующих запросах выбирается готовая информация.

21.52. Измените АТД кратчайших путей и алгоритм Дейкстры, чтобы вычислять кратчайшие путей в сетях, в которых веса имеют и вершины, и ребра. Не переделывайте представление графа (метод описан в упражнении 21.4), а измените код.

21.53. Постройте небольшую модель авиамаршрутов и времен перелета - возможно, на основе ваших путешествий. Воспользуйтесь решением упражнения 21.52 для вычисления наиболее быстрого пути от одного из пунктов к другому. Затем протестируйте полученную программу на реальных данных (см. упражнение 21.5).

Кратчайшие пути в ациклических сетях

В лекция №19 мы обнаружили, что вопреки нашим ожиданиям, что обработка DAG-графов должна быть проще, чем обработка обычных орграфов, разработка алгоритмов с существенно большей скоростью работы для DAG-графов по сравнению с обычными орграфами является труднодостижимой целью. Однако в случае задач о кратчайших путях действительно существуют алгоритмы для DAG, которые проще и быстрее, чем методы на основе очереди с приоритетами для обычных орграфов. В частности, в этом разделе мы рассмотрим алгоритмы для ациклических сетей, которые:

В первых двух случаях мы устраняем из времени выполнения логарифмический множитель, который присутствует в наших лучших алгоритмах для разреженных сетей; в третьем случае имеются простые алгоритмы решения задач, которые трудноразрешимы для сетей общего вида. Все эти алгоритмы являются прямыми расширениями алгоритмов решения задач достижимости и транзитивного замыкания для DAG-графов, которые рассматривались в лекция №19.

Поскольку в ациклических сетях нет циклов вообще, то нет и отрицательных циклов, поэтому отрицательные веса не вызывают трудностей в задачах поиска кратчайших путей на DAG-графах. Соответственно, в материале этого раздела мы не накладываем ограничения на значения весов ребер.

Есть одно замечание, касающееся терминологии: ориентированные графы с весами на ребрах и без циклов можно называть либо взвешенными DAG, либо ациклическими сетями.

Мы будем применять оба термина - как для того, чтобы подчеркнуть их эквивалентность, так и для того, чтобы избежать путаницы при обращении к литературе, где широко используются оба термина. Иногда удобно использовать первый из них, чтобы обратить внимание на отличие от невзвешенного DAG (взвешенность), а второй - чтобы подчеркуть отличие от обычных сетей (ацикличность).

Четыре базовых концепции из лекция №19, которые позволили получить эффективные алгоритмы для невзвешенных DAG-графов, оказываются даже более эффективными для взвешенных DAG:

Эти методы решают задачу с одним истоком за время, пропорциональное E, и задачу для всех пар вершин - за время, пропорциональное VE. Все они эффективны в силу топологического упорядочения, которое позволяет вычислять кратчайшие пути для каждой вершины без перепроверки предыдущих решений. В этом разделе мы рассмотрим по одной реализации для каждой задачи; остальные оставлены читателям на самостоятельную проработку (см. упражнения 21.62-21.65).

Начнем с небольшого отступления. Каждый DAG имеет по крайней мере один исток, а может, и несколько, поэтому вполне естественно рассмотреть следующую задачу о кратчайших путях.

Кратчайшие пути из нескольких истоков. При заданном наборе начальных вершин для каждой из остальных вершин w нужно найти самый короткий путь среди всех кратчайших путей из каждой начальной вершины до w.

Эта задача по существу эквивалентна задаче о кратчайших путях из одного истока. Задачу для нескольких истоков можно свести к задаче для одного истока, добавив фиктивную вершину-исток с ребрами нулевой длины, ведущими в каждый исток исходной сети. Можно и наоборот: свести задачу с одним истоком к задаче с несколькими истоками; для этого нужно рассмотреть подсеть, индуцированную всеми вершинами и ребрами, достижимыми из исходного истока. Мы редко создаем такие подсети явно, поскольку наши алгоритмы обрабатывают их автоматически, когда мы считаем начальную вершину единственным истоком в сети (даже если это не так).

Топологическая сортировка позволяет непосредственно решить задачу кратчайших путей с несколькими истоками и множество других задач. Мы используем индексированный именами вершин вектор wt, который содержит веса известных кратчайших путей из любого истока в каждую вершину. Чтобы решить задачу поиска кратчайших путей с несколькими истоками, мы вначале заносим в вектор wt нулевые значения для истоков и большое сигнальное значение для всех остальных вершин. Затем мы обрабатываем эти вершины в топологическом порядке. Чтобы обработать вершину v, для каждого исходящего из нее ребра v-w мы выполняем операцию релаксации, которая обновляет кратчайший путь в w, если v-w дает более короткий путь из истока в w (через v). В процессе выполнения этих операций проверяются все пути из любого истока в каждую вершину графа; операция релаксации отслеживает минимальную длину таких путей, а топологическая сортировка гарантирует обработку вершин в нужном порядке.

Этот метод можно реализовать непосредственно одним из двух способов. Первый заключается в добавлении нескольких строк кода к коду топологической сортировки в программе 19.8: сразу после извлечения из исходной очереди вершины v мы выполняем для каждого из ее ребер указанную операцию релаксации (см. упражнение 21.56). Второй способ - топологическое упорядочение вершин и последующий проход по ним с выполнением операций релаксации так, как описано в предыдущем абзаце.

Подобным же образом (с другими операциями релаксации) можно решить многие задачи обработки графов. Например, программа 21.6 представляет собой реализацию второго подхода (упорядочение с последующим просмотром) для решения задачи поиска наиболее длинных путей из нескольких истоков: для каждой вершины сети нужно найти наиболее длинный путь из некоторого истока в эту вершину. При этом элемент wt, связанный с каждой вершиной, содержит длину самого длинного известного пути из любого истока в эту вершину, вначале мы обнуляем все веса и изменяем смысл сравнения в операции релаксации. На рис. 21.15 показана трасса выполнения программы 21.6 на примере ациклической сети.

Программа 21.6. Наиболее длинные пути в ациклической сети

Для нахождения наиболее длинных путей в ациклической сети мы рассматриваем вершины в топологическом порядке, и, выполняя для каждого ребра шаг релаксации, сохраняем в индексированном именами вершин векторе wt вес наиболее длинного известного пути в каждую вершину. Вектор lpt определяет остовный лес наиболее длинных путей (с корнями в истоках), а вызов path(v) возвращает последнее ребро в наиболее длинном пути, ведущем в v.

  #include "dagTS.cc"
  template <class Graph, class Edge>
  class LPTdag
    {
      const Graph &G;
      vector<double> wt;
      vector<Edge *> lpt;
    public:
      LPTdag(const Graph &G) : G(G), lpt(G.V()), wt(G.V(), 0)
        {
          int j, w;
          dagTS<Graph> ts(G);
          for (int v = ts[j = 0]; j < G.V(); v = ts[++j])
            {
              typename Graph::adjIterator A(G, v);
              for (Edge* e = A.beg(); !A.end(); e = A.nxt())
                if (wt[w = e->w()] < wt[v] + e->wt())
                  {
                    wt[w] = wt[v] + e->wt();
                    lpt[w] = e;
                  }
            }
        }
    Edge *pathR(int v) const { return lpt[v]; }
    double dist(int v) const { return wt[v]; }
    } ;
      

 Вычисление наиболее длинных путей в ациклической сети


Рис. 21.15.  Вычисление наиболее длинных путей в ациклической сети

В этой сети каждое ребро имеет вес, связываемый с его исходной вершиной (их список приведен вверху слева). У стоков есть ребра в фиктивную вершину 10, которая на рисунках не показана. Массив wt содержит длину наиболее длинного известного пути в каждую вершину из некоторого истока, а массив st хранит предыдущую вершину на наиболее длинном пути. Этот рисунок иллюстрирует действие программы 21.6, которая выбирает один из истоков (затененные узлы на каждой диаграмме) по правилу FIFO, хотя на каждом шаге можно выбирать любые истоки. Вначале мы извлекаем вершину 0, просматриваем каждое инцидентное ей ребро и обнаруживаем однореберные пути длины 0.41, ведущие в 1, 7 и 9. Затем мы извлекаем вершину 5 и записываем однореберный путь из 5 в 10 (слева, второй ряд сверху). Потом извлекаем вершину 9 и записываем пути 0-9-4 и 0-9-6 длины 0.70 (слева, третий ряд сверху). Мы продолжаем эти действия, изменяя массивы каждый раз, когда находим более длинные пути. Например, после извлечения вершины 7 (слева, второй ряд снизу) мы записываем пути длины 0.73, ведущие в 8 и 3; затем, после извлечения вершины 6, мы записываем более длинные пути (длины 0.91) в 8 и 3 (справа вверху). Цель вычислений - найти наиболее длинный путь к фиктивному узлу 10. В данном случае результатом является путь 0-9-6-8-2 длины 1.73.

Лемма 21.9. Задачу поиска кратчайших путей с несколькими истоками и задачу поиска наиболее длинных путей с несколькими истоками в ациклических сетях можно решить за линейное время.

Доказательство. Одно и то же доказательство применимо для наиболее длинного пути, кратчайшего пути и многих других свойств пути. Ориентируясь на программу 21.6, проведем доказательство для наиболее длинных путей. Покажем методом индукции по переменной цикла i, что для всех вершин v = ts[j] с уже обработанными номерами j < i элемент wt[v] содержит длину наиболее длинного пути из истока в v. Пусть v = ts[i], а t - вершина, предшествующая v на некотором пути из истока в v. Поскольку вершины в векторе ts топологически упорядочены, вершина t уже обработана. По предположению индукции, wt[t] содержит длину наиболее длинного пути, ведущего в t, и шаг релаксации в программе проверяет, будет ли более длинным путь в v через t. Из предположения индукции также следует, что при обработке v будут проверены все пути, ведущие в v.

Эта важная лемма говорит нам, что обработка ациклических сетей выполняется значительно проще, чем обработка сетей с циклами. Для кратчайших путей этот способ быстрее алгоритма Дейкстры на коэффициент, пропорциональный стоимости операций обработки очереди с приоритетами в алгоритме Дейкстры. В случае задачи о наиболее длинных путях мы получаем линейный алгоритм для ациклических сетей, но трудноразрешимую задачу в случае сетей общего вида. Кроме того, здесь отрицательные веса не представляют дополнительной трудности, однако являются труднопреодолимым барьером в алгоритмах для сетей общего вида, о чем будет сказано в разделе 21.7.

Описанный метод зависит только от того факта, что вершины обрабатываются в топологическом порядке. Следовательно, любой алгоритм топологической сортировки можно адаптировать для решения задач о кратчайших и наиболее длинных путях и других задач этого типа (см., например, упражнения 21.56 и 21.62).

Как мы знаем из лекция №19, абстракция DAG используется во многих приложениях. Например, в разделе 21.6 мы увидим приложение, которое с виду не связано с сетями, но для которого можно непосредственно задействовать программу 21.6.

А теперь мы переходим к задаче о кратчайших путях для всех пар вершин в ациклических сетях. Как и в лекция №19, один из методов решения этой задачи - выполнение алгоритма для одного истока для каждой вершины (см. упражнение 11.65). Не менее эффективный подход, который мы рассмотрим здесь, заключается в применении единственного DFS с динамическим программированием, как при вычислении транзитивного замыкания DAG в лекция №19 (см. программу 19.9). Если рассматривать вершины в конце выполнения рекурсивной функции, то они будут обрабатываться в обратном топологическом порядке, и мы сможем получить вектор кратчайших путей для каждой вершины из векторов кратчайших путей для каждой смежной вершины, просто используя каждое ребро на шаге релаксации.

Программа 21.7 является реализацией этого подхода. Работа данной программы на примере взвешенного DAG показана на рис. 21.16. Кроме добавления релаксации, имеется одно важное различие между этим вычислением и вычислением транзитивного замыкания для DAG: в программе 19.9 можно игнорировать ребра в дереве DFS, которые не несут новой информации о достижимости, а в программе 21.7 приходится рассматривать все ребра, поскольку любое ребро может привести к более короткому пути.

Программа 21.7. Все кратчайшие пути в ациклической сети

Эта реализация интерфейса из программы 21.2 для взвешенного DAG получена добавлением соответствующих операций релаксации в функцию транзитивного замыкания из программы 19.9, основанной на динамическом программировании.

  template <class Graph, class Edge>
  class allSPdag
    { const Graph &G;
      vector <vector <Edge *> > p;
      vector <vector <double> > d;
      void dfsR(int s)
        { typename Graph::adjIterator A(G, s);
          for (Edge* e = A.beg(); !A.end(); e = A.nxt())
            { int t = e->w(); double w = e->wt();
              if ( d[s][t] > w)
                { d[s][t] = w; p[s][t] = e; }
              if (p[t][t] == 0) dfsR(t);
              for (int i = 0; i < G.V(); i++)
                if (p[t][i])
                  if (d[s][i] > w + d[t][i])
                    { d[s][i] = w + d[t][i]; p[s][i] = e; }
            }
        }
    public:
      allSPdag(const Graph &G) : G(G), p(G.V()), d(G.V())
        { int V = G.V();
          for (int i = 0; i < V; i++)
            { p[i].assign(V, 0); d[i].assign(V, V); }
          for (int s = 0; s < V; s++)
            if (p[s][s] == 0) dfsR(s);
        }
       Edge *path(int s, int t) const
        { return p[s][t]; }
       double dist(int s, int t) const
        { return d[s][t]; }
    } ;
      

Лемма 21.10. Задачу поиска кратчайших путей для всех пар вершин в ациклических сетях можно решить с помощью одного поиска в глубину за время, пропорциональное VE.

Доказательство. Это утверждение вытекает непосредственно из стратегии решения задачи с одним истоком для каждой вершины (см. упражнение 21.65). Его можно также доказать методом индукции из программы 21.7. После рекурсивных вызовов для вершины v мы знаем, что вычислены все кратчайшие пути для каждой вершины из списка смежности v - поэтому мы можем найти кратчайшие пути из v в каждую вершину, проверив все ребра, инцидентные v. Для каждого ребра выполняется Vшагов релаксации, а в сумме получается VE шагов релаксации.

Таким образом, топологическая сортировка для ациклических сетей позволяет избежать затрат на работу очереди с приоритетами в алгоритме Дейкстры. Подобно алгоритму Флойда, программа 21.7 также решает задачи, более общие, чем те, которые решаются алгоритмом Дейкстры, т.к., в отличие от алгоритма Дейкстры (см. раздел 21.7), этот алгоритм работает правильно даже при наличии ребер с отрицательными весами. Если изменить знаки всех весов в ациклической сети, то этот алгоритм найдет все наиболее длинные пути (см. рис. 21.17). Наиболее длинные пути можно найти и по-другому - изменив проверку неравенства в алгоритме релаксации, как в программе 21.6.

Остальные алгоритмы поиска кратчайших путей в ациклических сетях, которые были упомянуты в начале этого раздела, обобщают методы из лекция №19 подобно другим алгоритмам, которые рассматривались в этой главе. Их самостоятельная реализация позволит закрепить ваше понимание как DAG-графов, так и кратчайших путей (см. упражнения 21.62-21.65). Все эти методы выполняются в худшем случае за время, пропорциональное VE, а реальная трудоемкость зависит от структуры DAG. В принципе, для некоторых разреженных взвешенных DAG-графов можно привести даже лучшую оценку (см. упражнение 19.117).

 Кратчайшие пути в ациклической сети


Рис. 21.16.  Кратчайшие пути в ациклической сети

Здесь показано вычисление матрицы всех кратчайших расстояний (внизу справа) для примера взвешенного DAG (вверху слева). Каждая строка этой матрицы вычисляется последним действием рекурсивной функции DFS из строк для смежных вершин, которые находятся раньше в списке, поскольку строки вычисляются в обратном топологическом порядке (обратный обход дерева DFS, которое изображено внизу слева).

Массив вверху справа содержит строки матрицы в порядке их вычисления. Например, чтобы вычислить каждый элемент строки для вершины 0, мы добавляем 0.41 к соответствующему элементу в строке для вершины 1 (чтобы получить расстояние до нее из 0 после рассмотрения ребра 0-1), затем добавляем 0.45 к соответствующему элементу в строке для вершины 3 (чтобы получить расстояние до нее из 0 после рассмотрения 0-3), а затем выбираем меньшее из этих двух значений. Эти вычисления по сути совпадают с вычислением транзитивного замыкания DAG (см., например, рис. 19.23). Наиболее значительное различие состоит в том, что алгоритм вычисления транзитивного замыкания может игнорировать некоторые ребра (например, 1-2 в этом примере), поскольку они ведут к вершинам, достижимость которых уже установлена; а алгоритм кратчайших путей должен проверять, являются ли пути, связываемые с нисходящими ребрами, более короткими, чем уже известные пути. Если игнорировать ребро 1-2 в этом примере, то не будут найдены кратчайшие пути 0-1-2 и 1-2.

 Все наиболее длинные пути в ациклической сети


Рис. 21.17.  Все наиболее длинные пути в ациклической сети

Наш метод для вычисления всех кратчайших путей в ациклических сетях работает даже при наличии отрицательных весов. Поэтому он позволяет вычислить наиболее длинные пути: для этого нужно вначале изменить знаки всех весов, как показано здесь для сети с рис. 21.16. Наиболее длинным простым путем в этой сети является 0-1-5-4-2-3 с весом 1.73 .

Упражнения

21.54. Приведите решения задач поиска кратчайших и наиболее длинных путей с несколькими истоками для сети, определенной в упражнении 21.1, с обращенными направлениями ребер 2-3 и 1-0.

21.55. Измените программу 21.6, чтобы она решала задачу поиска кратчайших путей с несколькими истоками для ациклических сетей.

21.56. Реализуйте класс с тем же интерфейсом, что и в программе 21.6, на основе кода топологической сортировки с очередью истоков из программы 19.8, который выполняет операцию релаксации для каждой вершины сразу после извлечения этой вершины из очереди истоков.

21.57. Определите АТД для операции релаксации, напишите его реализацию и измените программу 21.6 так, чтобы полученный АТД можно было использовать аналогично программе 21.6 для решения задачи о кратчайших путях с несколькими истоками, задачи о наиболее длинных путях с несколькими истоками и других задач, просто измененяя реализацию операции релаксации.

21.58. Воспользуйтесь обобщенной реализацией из упражнения 21.57 для разработки класса с функциями-членами, которые возвращают длину наиболее длинного пути из любого истока в любую другую вершину DAG, длину кратчайшего такого пути и количество вершин, достижимых путями, длины которых принадлежат заданному интервалу.

21.59. Определите свойства релаксации, чтобы изменить доказательство леммы 21.9 для абстрактной версии программы 21.6 (наподобие описанной в упражнении 21.57).

21.60. Продемонстрируйте в стиле рис. 21.16 вычисление матриц кратчайших путей для всех пар вершин в сети, определенной в упражнении 21.54, с помощью программы 21.7.

о 21.61. Приведите верхнюю границу количества весов ребер, просмотренных в программе 21.7, в виде функции базовых структурных свойств сети. Напишите программу вычисления этой функции и воспользуйтесь ей для оценки точности границы VE в различных ациклических сетях (добавьте нужные веса в моделях из лекция №19).

21.62. Напишите основанное на DFS решение задачи о кратчайших путях с несколькими истоками для ациклических сетей. Будет ли это решение правильно работать при наличии отрицательных весов ребер? Обоснуйте свой ответ.

21.63. Расширьте решение упражнения 21.62, чтобы получить реализацию интерфейса АТД кратчайших путей для всех пар вершин для ациклических сетей, которая формирует матрицы всех путей и всех расстояний за время, пропорциональное VE.

21.64. Продемонстрируйте в стиле рис. 21.9 вычисление всех кратчайших путей в сети, определенной в упражнении 21.54, с помощью метода на основе DFS из упражнения 21.63.

21.65. Измените программу 21.6, чтобы она решала задачу поиска кратчайших путей из одного истока в ациклических сетях, а затем используйте ее для разработки реализации интерфейса АТД кратчайших путей для всех пар вершин в ациклических сетях, который формирует матрицы всех путей и всех расстояний за время, пропорциональное VE.

21.66. Выполните упражнение 21.61 для реализации АТД кратчайших путей для всех пар вершин на основе DFS (упражнение 21.63) и на основе топологической сортировки (упражнение 21.65). Какие выводы можно сделать, сравнивая трудоемкости этих трех методов?

21.67. Эмпирически сравните в стиле таблицы 20.2 три реализации класса для задачи поиска кратчайших путей для всех пар вершин, описанные в этом разделе (см. программу 21.7, упражнение 21.63 и упражнение 21.65), для различных ациклических сетей (добавьте нужные веса в моделях из лекция №19).

Евклидовы сети

В приложениях, где сети моделируют топографические карты, часто бывает нужно найти наилучший маршрут из одного места в другое. В данном разделе мы рассмотрим стратегию решения этой задачи - быстрый алгоритм для задачи поиска кратчайшего пути с несколькими истоками и стоками в евклидовых сетях (Euclidean network), вершины которых являются точками на плоскости, а веса ребер определяются геометрическими расстояниями между их вершинами.

Эти сети обладают двумя важными свойствами, которые не обязательно присущи весам ребер в общем случае. Во-первых, расстояния удовлетворяют неравенству треугольника: расстояние от s до d никогда не больше, чем расстояние от s до x плюс расстояние от x до d. Во-вторых, координаты вершин дают нижнюю границу длины пути: не существует пути из s в d, который был бы короче расстояния между s и d по прямой. Алгоритм поиска кратчайших путей для задачи с одним истоком и стоком, который мы рассмотрим в этом разделе, использует эти два свойства для повышения его эффективности.

Зачастую евклидовы сети симметричны, т.е. их ребра являются двунаправленными. Как было сказано в начале этой главы, такие сети возникают, например, если интерпретировать представление взвешенного неориентированного евклидового графа матрицей смежности или списками смежности (см. лекция №20) как взвешенный орграф (сеть). Когда мы рисуем неориентированную евклидову сеть, мы имеем в виду как раз такую интерпретацию, чтобы не плодить стрелки на рисунках.

Основная идея проста: поиск по приоритетам предоставляет общий механизм для поиска путей в графах. С помощью алгоритма Дейкстры мы рассматриваем пути в порядке увеличения их расстояния от начальной вершины. Это упорядочение гарантирует, что по достижении стока будут просмотрены все более короткие пути в графе, ни один из которых не привел в сток. Но в евклидовом графе имеется дополнительная информация: если мы ищем путь из истока s в сток d и встречаем третью вершину v, то мы знаем, что нам не только необходимо учесть путь, найденный из s в v, но и что лучшее, что можно сделать при проходе из v в d - это сначала пройти по ребру v-w, а затем найти путь, длина которого равна расстоянию от w до d по прямой (см. рис. 21.18). Поиск по приоритетам позволяет легко учесть эту дополнительную информацию для повышения эффективности вычислений. Мы используем стандартный алгоритм, но в качестве приоритета для каждого ребра v-w принимаем сумму следующих трех величин: длина известного пути из s в v, вес ребра v-w, и расстояние от w до t. Если всегда выбирать ребро с наименьшей такой величиной, то по достижении вершины t мы будем уверены, что в данном графе не существует более короткого пути из s в t. Кроме того, в типичных сетях мы приходим к этому заключению после выполнения гораздо меньшего объема работы, чем при использовании алгоритма Дейкстры.

 Релаксация ребра (евклидова)


Рис. 21.18.  Релаксация ребра (евклидова)

Вычисляя кратчайшие пути в евклидовом графе, в операции релаксации можно учитывать расстояния до конечной вершины. В данном примере можно сделать вывод, что показанный путь из s в v с добавленным ребром v-w не может привести к более короткому пути из s в d, чем уже найденный, т.к. длина любого такого пути должна быть не меньше длины пути из s в v плюс длина v-w и плюс расстояние от w до d по прямой, что больше длины известного пути из s в d. Подобные проверки могут существенно уменьшить количество рассматриваемых путей.

Для реализации данного подхода мы используем стандартную алгоритма Дейкстры на основе PFS (см. программу 21.1, поскольку евклидовы графы обычно разрежены, а также упражнение 21.73) с двумя изменениями. Во-первых, вместо обнуления элементов wt[s] в начале поиска они заполняются величинами distance(s, d), где distance() - функция, возвращающая расстояние между двумя вершинами. Во-вторых, мы определяем приоритет P как функцию

  (wt[v] + e->wt() + distance(w, d) - distance(v, d))
      

вместо функции (wt[v] + e->wt0), которая использовалась в программе 21.1 (вспомните, что v и S - это локальные переменные, в которые заносятся, соответственно, величины e->v() и e->w()). Эти изменения, которые мы будем называть евклидовой эвристикой (Euclidean heuristic), сохраняют неизменным свойство, что величина wt[v] - distance(v, d) является длиной кратчайшего пути в сети из s в v для каждой вершины v (следовательно, wt[v] содержит нижнюю границу длины самого короткого из всех возможных путей из s в d через v). Величина wt[w] вычисляется добавлением к этой величине веса ребра (расстояние до w) плюс расстояние от w до стока d.

Лемма 21.11. Поиск по приоритетам с евклидовой эвристикой решает задачу кратчайших путей из истока в сток в евклидовых графах.

Доказательство. Здесь применимо доказательство леммы 21.2. В момент включения вершины x в дерево добавление расстояния между x и d к приоритету не влияет на то, что путь в дереве из s в x является кратчайшим путем в графе из s в x, поскольку к длине всех путей, ведущих в x, добавляется одна и та же величина.

Когда вершина d включена в дерево, мы знаем, что никакой другой путь из s в d не короче, чем путь по дереву, т.к. любой такой путь должен состоять из некоторого пути в дереве, за которым следует ребро, ведущее в некоторую вершину w вне дерева, а в конце находится путь из w в d (длина которого не может быть короче расстояния между w и d). Однако по построению мы знаем, что длина пути из s в w плюс расстояние от w до d не меньше длины пути по дереву из s в d.

В разделе 21.6 мы обсудим другой простой способ реализации евклидовой эвристики. Сначала мы перебираем все ребра графа, изменяя веса ребер: для каждого ребра v-w мы добавляем к весу величину distance(w, d) -distance(v, d). Затем выполняем стандартный алгоритм поиска кратчайшего пути, начиная с s (элемент wt[s] содержит значение distance(s,d)), и останавливаемся по достижении d. Этот метод в вычислительном отношении эквивалентен уже описанному методу (который вообще-то при своей работе вычисляет те же веса) и является конкретным примером базовой операции, которая называется перевзве-шивание (reweighting) сети. Перевзвешивание играет важную роль при решении задач поиска кратчайших путей с отрицательными весами; мы обсудим его подробно в разделе 21.6.

 Кратчайший путь в евклидовом графе


Рис. 21.19.  Кратчайший путь в евклидовом графе

Во время поиска кратчайшего пути в конечную вершину можно ограничить поиск вершинами внутри относительно небольшого эллипса вокруг пути, как показано в этих трех примерах, на которых изображены SPT-поддеревья для примеров с рис. 21.12.

Евклидова эвристика влияет на эффективность, но не на правильность алгоритма Дейкстры для вычисления кратчайших путей из истока в сток. Как было сказано в доказательстве леммы 21.2, применение стандартного алгоритма для решения задачи с одним истоком и стоком означает построение SPT, в котором все вершины ближе к началу, чем сток d. При использовании евклидовой эвристики SPT содержит только те вершины, для которых длина пути из s плюс расстояние до d меньше длины кратчайшего пути из s в d. Мы надеемся, что для многих приложений это дерево будет значительно меньшим, поскольку эвристика отбрасывает существенное количество длинных путей. Точный выигрыш зависит от структуры графа и геометрии расположения вершин. На рис. 21.19 показано действие евклидовой эвристики на нашем демонстрационном графе, где выигрыш существенен. Мы называем этот метод эвристикой, поскольку выигрыш не гарантирован: всегда возможен случай, когда имеется единственный длинный путь, который может уйти произвольно далеко в сторону от истока и лишь затем направиться к стоку (см. упражнение 21.80).

На рис. 21.20 наглядно демонстрируется геометрический смысл евклидовой эвристики: если длина кратчайшего пути из s в d равна z, то вершины, просматриваемые этим алгоритмом, лежат приблизительно внутри эллипса, определяемого как геометрическое место точек х, для которых расстояние от s до x плюс расстояние от х до d равно z. Для типичных евклидовых графов ожидаемое количество вершин в этом эллипсе намного меньше, чем количество вершин в круге радиуса z с центром в истоке (которые просматриваются алгоритмом Дейкстры).

 Границы трудоемкости евклидовой эвристики


Рис. 21.20.  Границы трудоемкости евклидовой эвристики

При продвижении к конечной вершине во время поиска кратчайшего пути можно ограничиться вершинами внутри эллипса, описанного вокруг пути, вместо круга с центром в истоке s, как в алгоритме Дейкстры. Радиус круга и форма эллипса определяются длиной кратчайшего пути.

Точный анализ получаемой экономии - трудная аналитическая задача, которая зависит от вида как множества случайных точек, так и случайных графов (см. раздел ссылок). В типичных ситуациях мы ожидаем, что если стандартный алгоритм при вычислении кратчайшего пути из истока в сток рассматривает X вершин, то евклидова эвристика сократит трудоемкость до величины, пропорциональной , и тогда ожидаемое время выполнения будет пропорционально V для насыщенных графов и для разреженных. Этот пример показывает, что трудность разработки подходящей модели или анализа связанных с ней алгоритмов не должна удерживать нас от использования значительного выигрыша, который возможен во многих приложениях, особенно если реализация тривиальна.

Доказательство леммы 21.11 применимо к любой функции, которая дает нижнюю границу расстояния от каждой вершины до d. Существуют ли другие функции, для которых алгоритм будет рассматривать еще меньше вершин, чем евклидова эвристики? Данный вопрос изучался в общей постановке применительно к широкому классу алгоритмов комбинаторного поиска. Вообще-то евклидова эвристика является одним из вариантов алгоритма, называемого A* (произносится " эй-стар " ). Эта теория утверждает, что оптимальной будет функция, которая дает наилучшую возможную нижнюю границу; другими словами, чем лучше эта граничная функция, тем более эффективен поиск. В данном случае оптимальность A* говорит о том, что евклидова эвристика наверняка просмотрит не больше вершин, чем алгоритм Дейкстры (который представляет собой A* с нулевой нижней границей). Результаты аналитических исследований дают более точную информацию для конкретных моделей случайных сетей.

Свойства евклидовых сетей можно использовать и для облегчения создания абстрактных АТД поиска кратчайших путей, более эффективно реализующих компромисс между используемым временем и памятью, чем для сетей общего вида (см. упражнения 21.48-21.50). Такие алгоритмы важны в приложениях наподобие обработки географических карт, где возникают огромные разреженные сети. Например, предположим, что требуется разработать навигационную систему, определяющую кратчайшие пути на карте с миллионами дорог. Возможно, саму карту можно хранить непосредственно в небольшом бортовом компьютере, однако матрицы расстояний и путей зачастую слишком велики для этого (см. упражнения 21.39 и 21.40); поэтому алгоритмы поиска всех путей из раздела 21.3 не применимы. Алгоритм Дейкстры также может не обеспечить достаточно быстрых ответов для огромных карт. В упражнениях 21.77 и 21.78 рассматриваются стратегии рационального соотношения между объемом предварительной обработки и объемом памяти, которые обеспечивают быструю реакцию на запросы о кратчайших путях из истока в сток.

Упражнения

21.68. Найдите в инернете большой евклидов граф - возможно, карту с таблицей расстояний между пунктами, телефонную сеть со стоимостями переговоров или расписание авиарейсов с указанными стоимостями билетов.

21.69. Используя стратегии, описываемые в упражнениях 17.71-17.73, напишите программы, которые генерируют случайные евклидовы графы, соединяя вершины, расположенные на решетке .

21.70. Покажите, что частичное SPT, вычисленное евклидовой эвристикой, не зависит от значения, которое вначале заносится в wt[s]. Объясните, как вычислять длины кратчайших путей из начального значения.

2l.71. Покажите в стиле рис. 21.10 результат применения евклидовой эвристики для вычисления кратчайшего пути из 0 в 6 в сети из упражнения 21.1.

21.72. Опишите, что произойдет, если функция distance(s, t), используемая в евклидовой эвристике, возвращает фактическую длину кратчайшего пути из s в t для всех пар вершин.

21.73. Разработайте реализацию класса для поиска кратчайших путей в насыщенных евклидовых графах, основанную на представлении графа, которое поддерживает функцию edge и реализацию алгоритма Дейкстры (программа 20.6 с соответствующей функцией вычисления приоритета).

21.74. Эмпирически проверьте эффективность евклидовой эвристики поиска кратчайших путей для различных евклидовых сетей (см. упражнения 21.9, 21.68, 21.69 и 21.80). Для каждого графа сгенерируйте V/10 случайных пар вершин и выведите таблицу со следующими величинами: среднее расстояние между вершинами; средняя длина кратчайшего пути между вершинами; среднее отношение количества вершин, просматриваемых евклидовой эвристикой, к количеству вершин, просматриваемых алгоритмом Дейкстры; и среднее отношение площади эллипса, соответствующего евклидовой эвристике, к площади круга, соответствующего алгоритму Дейкстры.

21.75. Разработайте реализацию класса для задачи поиска кратчайших путей из истока в сток на евклидовых графах, которая основывается на двунаправленном поиске, описанном в упражнении 21.35.

21.76. Воспользуйтесь геометрической интерпретацией для оценки отношения количества вершин в SPT, создаваемом алгоритмом Дейкстры для задачи с истоком и стоком, к количеству вершин в SPT, создаваемом двунаправленной реализацией из упражнения 21.75.

21.77. Разработайте реализацию класса для поиска кратчайших путей в евклидовых графах, которая выполняет в конструкторе следующий шаг предварительной обработки: накладывает на район карты сетку , а затем при помощи алгоритма Флойда поиска кратчайших путей для всех пар вершин вычисляет матрицу , элемент которой на пересечении строки i и столбца j содержит длину кратчайшего пути, соединяющего любую вершину из квадрата сетки i с любой вершиной из квадрата j. Затем используйте эти длины кратчайших путей в качестве нижних границ для усовершенствования евклидовой эвристики. Проведите эксперименты для нескольких различных значений W, которые дают небольшое ожидаемое количество вершин в квадрате решетки.

21.78. Разработайте реализацию АТД поиска кратчайших путей для всех пар вершин в евклидовых графах, которая объединяет идеи, изложенные в упражнениях 21.75 и 21.77.

21.79. Эмпирически сравните эффективность эвристик, описанных в упражнениях 21.75-21.78, для различных евклидовых сетей (см. упражнения 21.9, 21.68, 21.69 и 21.80).

21.80. Расширьте эмпирические исследования, включив в них евклидовы графы, которые получаются удалением всех вершин и ребер из круга радиуса r в центре, для r = 0.1, 0.2, 0.3 и 0.4. (Эти графы обеспечивают серьезную проверку евклидовой эвристики.)

21.81. Приведите прямую реализацию алгоритма Флойда, которая позволит реализовать АТД сети для неявных евклидовых графов, определяемых N точками на плоскости с ребрами, которые соединяют друг с другом точки на расстоянии не больше d. Не представляйте эти графы явным образом; вместо этого для заданных двух вершин вычислите расстояние между ними, чтобы определить, существует ли ребро, и если да, то чему равна его длина.

21.82. Разработайте реализацию, описанную в упражнении 21.81, которая строит граф с соседними связями, а затем применяет к каждой вершине алгоритм Дейкстры (см. программу 21.1).

21.83. Эмпирически сравните время выполнения и объем памяти, необходимые для алгоритмов из упражнений 21.81 и 21.82, для d = 0.1, 0.2, 0.3 и 0.4.

21.84. Напишите клиентскую программу, которая выполняет динамическую графическую анимацию работы евклидовой эвристики. Программа должна создать изображения наподобие рис. 21.19 (см. упражнение 21.38). Протестируйте программу на различных евклидовых сетях (см. упражнения 21.9, 21.68, 21.69 и 21.80).

Сведение

Задачи о кратчайших путях - особенно общий случай, где допустимы отрицательные веса (тема раздела 21.7) - представляют обобщенную математическую модель, которую можно использовать для решения множества других задач, с виду не имеющих отношения к обработке графов. Эта модель является первой среди ряда таких общих моделей, с которыми мы еще встретимся. По мере перехода к более трудным задачам и все более общим моделям возникает одна из сложностей - точное описание взаимосвязей между различными задачами. Для каждой новой задачи требуется ответить на вопрос: возможно ли ее простое решение с помощью преобразования в задачу, которую мы уже умеем решать? Станет ли решение более легким, если наложить на задачу некоторые ограничения? Чтобы отвечать на подобные вопросы, в этом разделе мы ненадолго отклонимся от темы и обсудим термины, используемые для описания таких видов взаимосвязи между задачами.

Определение 21.3. Мы говорим, что некоторая задача A сводится к (reduces to) другой задаче B, если алгоритм решения задачи B можно использовать для разработки алгоритма решения задачи A с общим временем выполнения в худшем случае, которое не более чем в постоянное число раз превышает выполнения алгоритма решения задачи B в худшем случае. Мы говорим, что две задачи эквивалентны, если они сводятся одна к другой.

Отложим до части VIII строгое определение того, что означают слова " использовать" один алгоритм для " разработки " другого. Для большинства приложений достаточно следующего простого приема. Мы показываем, что A сводится к B, если покажем возможность решения любого экземпляра задачи A за три шага:

Если мы сможем эффективно выполнить эти преобразования (и решить задачу B), то мы сможем эффективно решить и задачу A. Для демонстрации такой методики доказательства рассмотрим два примера.

Лемма 21.12. Задача транзитивного замыкания сводится к задаче поиска кратчайших путей для всех пар вершин с неотрицательными весами.

Доказательство. Мы уже отмечали прямую связь между алгоритмами Уоршалла и Флойда. Возможен другой способ установить их взаимосвязь в настоящем контексте: представьте, что требуется вычислить транзитивное замыкание орграфов с помощью библиотечной функции поиска всех кратчайших путей в сетях. Для этого мы добавим в орграф петли, если их нет, а затем построим сеть непосредственно из матрицы смежности орграфа с произвольным весом (скажем, 0.1), соответствующим каждому единичному элементу и сигнальным весом, соответствующим каждому нулевому элементу. Затем вызовем функцию поиска кратчайших путей для всех пар вершин. Из вычисленной матрицы кратчайших путей для всех пар вершин можно легко найти транзитивное замыкание: для любых двух заданных вершин u и v путь в орграфе из u в v существует тогда и только тогда, когда длина пути в сети из u в v не равна нулю (см. рис. 21.21).

 Сведение транзитивного замыкания


Рис. 21.21.  Сведение транзитивного замыкания

Матрицу смежности (с петлями) заданного орграфа (слева) можно преобразовать в матрицу смежности, представляющую сеть, присвоив каждому ребру произвольный вес (матрица слева). Как обычно, пустые элементы матрицы означают сигнальные значения, указывающие на отсутствие ребра. Если имеется матрица длин кратчайших путей для всех пар вершин этой сети (матрица в центре), то транзитивное замыкание орграфа (матрица справа) - это просто матрица, образованная заменой сигнальных значений на 0, а всех других элементов на 1.

Эта лемма является формальным утверждением, что задача транзитивного замыкания является не более трудной, чем задача кратчайших путей для всех пар вершин. Поскольку мы знакомы с алгоритмами транзитивного замыкания, которые работают даже быстрее, чем известные нам алгоритмы для задач поиска кратчайших путей для всех пар вершин, это утверждение не удивляет. Сведение представляет интерес тогда, когда оно используется для установки взаимосвязи между задачами, решение которых нам не известно, или между такими задачами и задачами, которые мы умеем решать.

Лемма 21.13. В сетях без ограничений на веса ребер задачи поиска наиболее длинного пути и кратчайшего пути (из одного истока или для всех пар вершин) эквивалентны.

Доказательство. Для заданной задачи поиска кратчайшего пути изменим знаки всех весов. Наиболее длинный (т.е. с наибольшим весом) путь в модифицированной сети является кратчайшим путем в исходной сети. Аналогичное рассуждение показывает, что задача поиска кратчайшего пути сводится к задаче поиска наиболее длинного пути.

Это доказательство тривиально, но данная лемма также показывает, что при утверждении и доказательстве сведения нужно быть аккуратным, поскольку легко посчитать сведение само собой разумеющимся, когда это не так. Например, утверждение, что задачи о наиболее длинном и кратчайшем пути эквивалентны в сетях с неотрицательными весами, не соответствует действительности.

В начале этой главы было схематично показано, что задача поиска кратчайших путей в неориентированных взвешенных графах сводится к задаче нахождения кратчайших путей в сетях, поэтому наши алгоритмы для сетей можно использовать для решения задач поиска кратчайших путей в неориентированных взвешенных графах. В этом контексте стоит рассмотреть два дополнительных момента, касающихся сведения. Во-первых, обратное утверждение неверно: умение находить кратчайшие пути в неориентированных взвешенных графах не поможет находить их в сетях. Во-вторых, мы уже видели неточность в рассуждении: если веса ребер могут быть отрицательными, сведение приведет к сети с отрицательными циклами, а мы не умеем находить в них кратчайшие пути. Но даже если сведение не помогает, все-таки можно найти кратчайшие пути в неориентированных взвешенных графах без отрицательных циклов с помощью неожиданно сложного алгоритма (см. раздел ссылок). Поскольку эта задача не сводится к ориентированному варианту, данный алгоритм бесполезен при решении задачи поиска кратчайшего пути в сетях общего вида.

По существу, концепция сведения описывает процесс использования одного АТД для реализации другого, как это обычно делается современными системными программистами. Если две задачи эквивалентны, и мы умеем эффективно решать какую-то из них, мы можем эффективно решить и другую. Мы часто находим простые взаимно однозначные соответствия, как, например, в лемме 21.13, где показана эквивалентность двух задач. В этом случае, даже до обсуждения способа решения каждой из задач, важно знать, что если удастся найти эффективное решение одной задачи, то его можно применить и для решения другой. Еще один пример был приведен в лекция №17: когда мы рассматривали задачу определения, содержит ли граф нечетный цикл, мы установили, что эта задача эквивалентна определению, можно ли раскрасить граф двумя цветами.

Сведение имеет два основных применения в проектировании и анализе алгоритмов. Во-первых, оно помогает классифицировать задачи по их сложности на соответствующем абстрактном уровне, без разработки и анализа полных реализаций. Во-вторых, сведение часто помогает установить нижние границы трудности решения различных задач, чтобы знать, где следует прекращать поиск лучших алгоритмов. Мы видели примеры такого применения в лекция №19 и лекция №20; другие примеры будут приведены ниже в этом разделе.

Помимо такой непосредственной практической пользы, принцип сведения также широко применяется в теории вычислений. Эти результаты важны потому, что позволяют обрести понимание при решении все более трудных задач. Данная тема будет кратко рассмотрена в конце настоящего раздела и во всех подробностях - в части VIII.

Естественно, трудоемкость преобразований обычно не должна быть больше трудоемкости решения. Однако во многих случаях сведение можно использовать даже при доминировании стоимости преобразования. Одно из наиболее важных применений сведения заключается в преобразовании к уже изученной задаче, для которой известно эффективное решение, что обеспечивает эффективные решения таких задач, которые иначе считались бы трудноразрешимыми. Сведение A к B, даже если это преобразование обойдется намного дороже, чем решение B, может дать намного более эффективный алгоритм решения, чем созданные другими способами. Существует множество различных вариантов. Возможно, нам более важна ожидаемая трудоемкость, а не трудоемкость в худшем случае. Возможно, для решения A потребуется решить две задачи, B и C. Может быть, потребуется многократно решить задачу B. Мы отложим дальнейшее обсуждение таких вариаций до части 8, поскольку все примеры, которые мы будем рассматривать до этого, не сложнее только что рассмотренных.

В частности, когда мы решаем задачу A, упрощая другую задачу B, мы знаем, что A сводится к B, но не обязательно наоборот. Например, выборка сводится к сортировке, поскольку наименьший k-й элемент в файле можно найти, отсортировав файл и затем перейдя по индексу (или последовательно) в его k-ю позицию; однако отсюда, конечно же, не следует, что сортировка сводится к выборке. В данном контексте и задача поиска кратчайших путей для взвешенного DAG, и задача поиска кратчайших путей для сетей с положительными весами сводится к общей задаче вычисления кратчайших путей. Такое использование сведения соответствует естественному пониманию, что одна задача является более общим случаем, чем другая. Любой алгоритм сортировки решает любую задачу выборки и, если мы можем решить задачу поиска кратчайших путей в сетях общего вида, то, конечно, можно воспользоваться этим решением для сетей с различными ограничениями. Однако обратное утверждение не обязательно верно.

Такое применение сведения удобно, однако эта концепция более полезна, если использовать ее для получения информации о взаимосвязях между задачами из различных областей. Например, рассмотрим следующие задачи, которые на первый взгляд кажутся далекими от обработки графов. Сведение помогает выявить определенные взаимоотношения между этими задачами и задачей поиска кратчайших путей.

Календарное планирование. Пусть необходимо выполнить большой набор работ разной продолжительности. Мы можем одновременно выполнять любое количество работ, однако существует множество отношений предшествования в виде пар работ, которые указывают, какая из работ должна быть завершена до начала выполнения другой работы. Каково минимальное время, которое необходимо для завершения всех работ, при условии выполнения всех ограничений предшествования? Конкретнее, для данного множества работ (с их длительностями) и множества ограничений предшествования требуется составить такой график их выполнения (найти момент начала для каждой работы), который позволяет достичь этого минимума.

На рис. 21.22 приведен пример задачи календарного планирования. Здесь используется естественное представление в виде сети, которое в данном случае послужит инструментом сведения. Этот вариант задачи является, возможно, наиболее простым из буквально сотен изученных вариантов, которые содержат другие характеристики работ и другие ограничения - например, назначение работам персонала или других ресурсов, других расходов, конечных сроков выполнения и т.п. В этом контексте описанный выше вариант обычно называется календарным планированием с ограничениями предшествования и неограниченным параллелизмом. Для краткости в дальнейшем мы будем применять термин календарное планирование.

Для упрощения разработки алгоритма, решающего задачу планирования работ, мы рассмотрим следующую задачу, которая представляет интерес и сама по себе.

 Календарное планирование


Рис. 21.22.  Календарное планирование

В этой сети вершины представляют работы, которые требуется выполнить (с весами, указывающими необходимое время), а ребра - отношения предшествования между работами. Например, ребра из 7 в 8 и в 3 означают, что работа 7 должна быть завершена до начала работы 8 и работы 3. Каково минимальное время, необходимое для завершения всех работ?

Разностные ограничения. Нужно присвоить неотрицательные значения множеству переменных x0, ..., xn , которые минимизируют значение xn, удовлетворяя множеству разностных ограничений на переменные, каждое из которых указывает, что разность между какими-то двумя переменными должна быть больше или равна заданной константе.

На рис. 21.23 показан пример такой задачи. Это исключительно абстрактная математическая формулировка, которая может служить основой для решения многих практических задач (см. раздел ссылок).

Задача разностных ограничений является частным случаем гораздо более общей задачи, где в выражениях допускаются произвольные линейные комбинации переменных.

Линейное программирование. Нужно присвоить неотрицательные значения множеству переменных x0, ..., xn , которые минимизируют значение заданной линейной комбинации переменных при наличии множества ограничений на переменные, каждое из которых указывает, что заданная линейная комбинация переменных должна быть больше или равна некоторой заданной константе.

Линейное программирование является широко распространенным общим способом решения широкого класса задач оптимизации, подробное обсуждение которых мы отложим до части VIII. Очевидно, что, как и многие другие задачи, задача разностных ограничений сводится к линейному программированию. В данный момент нас интересует взаимосвязь между разностными ограничениями, календарным планированием и задачами поиска кратчайших путей.

Лемма 21.14. Задача календарного планирования сводится к задаче разностных ограничений.

Доказательство. Для каждой работы добавим фиктивную работу и такое ограничение предшествования, что данная работа должна закончиться до начала фиктивной работы. Для этой задачи календарного планирования определим систему разностных неравенств, где каждой работе i соответствуют переменная xi и ограничение, согласно которому работа j не может начаться до окончания работы i в соответствии с неравенством , где ci, - продолжительность работы i. Решение задачи разностных ограничений дает как раз решение задачи календарного планирования, при этом значение каждой переменной задает время начала соответствующей работы.

На рис. 21.23 приведена система разностных неравенств, которая сформирована сведением задачи календарного планирования с рис. 21.22. Практическое значение этого сведения состоит в том, что для решения задачи календарного планирования можно воспользоваться любым алгоритмом, позволяющим решать задачи разностных ограничений.

 Разностные ограничения


Рис. 21.23.  Разностные ограничения

Нахождение неотрицательных значений переменных, которые минимизируют значение x10 с учетом данного множества неравенств, эквивалентно задаче календарного планирования работ, которая приведена на рис. 21.22. Например, неравенство означает, что работа 8 не может начаться до завершения работы 7.

Полезно подумать, можно ли провести аналогичное построение в обратном направлении: если имеется алгоритм решения задачи календарного планирования, то можно ли воспользоваться им для решения задачи разностных ограничений? Ответ на этот вопрос таков: формулировка леммы 21.14 не означает, что задача разностных ограничений сводится к задаче календарного планирования, поскольку системы разностных неравенств, которые получаются из задачи календарного планирования, имеют свойство, которое не обязательно справедливо для каждой задачи разностных ограничений. А именно: если в двух неравенствах совпадают вторые переменные, то совпадают и константы.

Следовательно, алгоритм для календарного планирования работ непосредственно не позволяет решать системы разностных неравенств, если в них имеются два таких неравенства: и , где . При доказательстве возможности сведения следует иметь в виду ситуации, подобные следующей: в доказательстве, что A сводится к B, необходимо показать, что алгоритм решения задачи B позволяет решить любой вариант задачи A.

Константы в задачах разностных ограничений, создаваемые при доказательстве леммы 21.14, всегда неотрицательны. Этот факт имеет существенное значение.

Лемма 21.15. Задача разностных ограничений при положительных константах эквивалентна задаче поиска наиболее длинных путей из одного истока в ациклической сети.

Доказательство. Пусть задана система разностных неравенств. Построим сеть, где каждая переменная xt соответствует вершине i, а каждое неравенство соответствует ребру i-j веса с. Например, назначение каждому ребру в орграфе на рис. 21.22 веса его начальной вершины дает сеть, соответствующую множеству разностных неравенств с рис. 21.23 рис. 21.23. Добавим к сети фиктивную вершину с ребрами нулевого веса, направленными в каждую из остальных вершин. При наличии в сети цикла система разностных неравенств не имеет решения (поскольку из положительности весов следует, что значения переменных, соответствующих каждой вершине, строго уменьшаются при движении вдоль пути, и тогда наличие цикла означает, что некоторая переменная меньше себя самой), и остается сообщить об этом. Если же в сети нет циклов, мы решим задачу поиска наиболее длинных путей из одного истока - фиктивной вершины. Наиболее длинный путь существует для каждой вершины, т.к. сеть является ациклической (см. раздел 21.4). Присвоим каждой переменной длину наиболее длинного пути из фиктивной вершины в соответствующую вершину сети. Для каждой переменной существование этого пути означает, что значение такой переменной удовлетворяет ограничениям, но никакое меньшее значение ограничениям не удовлетворяет.

В отличие от доказательства леммы 21.14, данное доказательство можно расширить, чтобы показать эквивалентность двух задач, поскольку построение работает в обоих направлениях. Здесь нет ограничения, что два неравенства с одинаковыми вторыми переменными в неравенстве должны иметь одинаковые константы, и нет требования, что ребра, исходящие из любой заданной вершины в сети, должны иметь одинаковые веса. Для любой заданной ациклической сети с положительными весами такое же соответствие дает систему разностных ограничений с положительными константами, решение которой непосредственно дает решение задачи поиска наиболее длинных путей из одного истока в сети. Детали этого доказательства оставляем на самостоятельную проработку (см. упражнение 21.90).

Сеть на рис. 21.22 демонстрирует это соответствие для нашей типовой задачи, а на рис. 21.15 показано вычисление наиболее длинных путей в сети с помощью программы 21.6 (фиктивная исходная вершина неявно фигурирует в реализации). График выполнения работ, вычисленный этим способом, приведен на рис. 21.24.

Программа 21.8 представляет собой реализацию, которая демонстрирует практическое применение данной теории. Она преобразовывает любую задачу календарного планирования в задачу поиска наиболее длинного пути в ациклических сетях, а затем использует программу 21.6 для ее решения.

Мы неявно предполагаем, что решение существует для любой постановки задачи календарного планирования работ; однако если во множестве ограничений предшествования имеется цикл, то невозможно составить расписание, удовлетворяющее таким ограничениям. Перед чем приступить к поиску наиболее длинных путей, следует проверить выполнение данного условия - для этого необходимо проверить, содержит ли соответствующая сеть цикл (см. упражнение 21.100). Поскольку подобная ситуация встречается часто, для ее описания обычно используется специальный технический термин.

 График выполнения работ


Рис. 21.24.  График выполнения работ

Этот рисунок демонстрирует решение задачи календарного планирования с рис. 21.22, полученное из соответствия между наиболее длинными путями во взвешенном DAG и календарным планированием. Длины наиболее длинных путей в массиве wt, вычисленном алгоритмом поиска наиболее длинных путей в программе 21.6 (см. рис. 21.15), в точности равны моментам времени начала работ (правый столбец вверху). Работы 0 и 5 начинаются в момент 0.00 , работы 1, 7 и 9 - в момент 0.41, работы 4 и 6 - в момент 0.70 и т.д.

Определение 21.4. Экземпляр задачи, для которого не существует решение, называется неразрешимым (infeasible).

Другими словами, для задач календарного планирования вопрос определения, является ли некоторый экземпляр задачи планирования разрешимым, сводится к задаче определения, является ли орграф ациклическим. По мере продвижения ко все более сложным задачам вопрос разрешимости становится все более важной (и все более трудной!) частью вычислительных затрат.

К данному моменту мы рассмотрели три взаимосвязанных задачи. Можно было бы просто показать, что задача планирования работ сводится к задаче поиска наиболее длинных путей из одного истока в ациклических сетях; однако мы также показали, что аналогично можно решить любую задачу разностных ограничений (с положительными константами) (см. упражнение 21.94), а также любую другую задачу, которая сводится к задаче разностных ограничений или задаче календарного планирования. Либо можно разработать алгоритм для решения задачи разностных ограничений и воспользоваться им для решения других задач, но мы не показали, что решение задачи календарного планирования может дать способ решения других задач.

Эти примеры демонстрируют использование сведения, которое позволяет расширить область применения проверенных реализаций. При построении реализаций в современном системном программировании подчеркивается необходимость многократного использования программного обеспечения с помощью разработки новых интерфейсов и использования существующих программных ресурсов. Этот важный процесс, который иногда называется библиотечным программированием (library programming), является практическим воплощением концепции сведения.

Библиотечное программирование чрезвычайно важно в практическом отношении, однако это не единственное применение сведения. Чтобы понять, что это значит, рассмотрим следующую версию задачи календарного планирования.

Календарное планирование с конечными сроками. В задаче планирования работ допускается дополнительный вид ограничений, которые определяют, что работа должна начаться до истечения заданного промежутка времени относительно другой работы. (Условные конечные сроки отсчитываются относительно начала всех работ.) Такие ограничения обычно требуются в критичных ко времени производственных процессах и во множестве других приложений; они могут существенно затруднить решение задачи календарного планирования.

Программа 21.8. Календарное планирование

Эта реализация читает из стандартного ввода список работ с их длительностями, за которым следует список ограничений предшествования, а затем выводит в стандартный вывод список моментов начала работ, которые удовлетворяют данным ограничениям. Используя леммы 21.14 и 21.15, а также программу 21.6, она решает задачу календарного планирования сведением ее к задаче поиска наиболее длинных путей для ациклических сетей.

  #include "GRAPHbasic.cc"
  #include "GRAPHio.cc"
  #include "LPTdag.cc"
  typedef WeightedEdge EDGE;
  typedef DenseGRAPH<EDGE> GRAPH;
  int main(int argc, char *argv[])
    { int i, s, t, N = atoi(argv[1]);
      double duration[N];
      GRAPH G(N, true);
      for (int i = 0; i < N; i++)
        cin >> duration[i];
      while (cin >> s >> t)
        G.insert(new EDGE(s, t, duration[s]));
      LPTdag<GRAPH, EDGE> lpt(G);
      for (i = 0; i < N; i++)
        cout << i << " " << lpt.dist(i) << endl;
    }
      

Предположим, что в пример, представленный на рис. 21.24, нужно добавить ограничение, состоящее в том, что работа 2 должна начаться не позже заданного количества единиц времени с после начала работы 4. Если с больше 0.53, то полученное расписание укладывается в заданные рамки, поскольку оно предписывает начать работу 2 в момент 1.23, что соответствует задержке на 0.53 после окончания работы 4 (которая начинается в момент 0.70). Если с меньше 0.53, то можно удовлетворить это ограничение, сдвинув начало работы 4 на более позднее время. Если работа 4 длится долго, то это изменение может увеличить время завершения всего графика. Хуже, если существуют другие ограничения на работу 4 , и мы не можем переместить время ее начала. Вообще-то бывают ограничения, которым не может удовлетворить ни одно расписание. Например, в нашем примере не удастся удовлетворить таким ограничениям, при которых работа 2 должна начаться раньше чем через d единиц времени после начала работы 6, для d меньшего 0.53, т.к. из ограничений, что работа 2 должна следовать за 8, а 8 за 6, следует, что работа 2 должна начаться позже, чем через 0.53 единицы времени после начала работы 6.

Если добавить в наш пример оба ограничения, рассмотренные в предыдущем абзаце, то, в зависимости от значений с и d, каждое ограничение повлияет на момент начала работы 4 в расписании, на время завершения всего графика, а также на то, существует ли выполнимый календарный график. Добавление дополнительных подобных ограничений увеличивает количество возможностей и превращает легкую задачу в трудную. Так что мы имеем все основания искать способ сведения этой задачи к некоторой известной задаче.

Лемма 21.16. Задача календарного планирования с конечными сроками сводится к задаче поиска кратчайших путей (с возможностью существования отрицательных весов).

Доказательство. Преобразуем ограничения предшествования в неравенства, используя то же сведение, что и описанное в свойстве 21.14. Для каждого ограничения конечного срока добавим неравенство , что эквивалентно , где dj - положительная константа. Преобразуем множество неравенств в сеть, применив то же сведение, что и описанное в лемме 21.15. Изменим знаки всех весов. Такое же построение, что и в доказательстве леммы 21.15, позволяет показать, что любое дерево кратчайших путей в сети с корнем в 0 будет соответствовать графику.

Это сведение подводит нас к вопросу о кратчайших путях с отрицательными весами. Оно утверждает, что если можно найти эффективное решение задачи поиска кратчайших путей с отрицательными весами, то можно найти эффективное решение задачи планирования работ с конечными сроками. (Опять-таки, соответствие в доказательстве леммы 21.16 не означает наличие обратного утверждения (см. упражнение 21.91).)

Добавление конечных сроков в задачу планирования работ соответствует допущению отрицательных констант в задаче разностных ограничений и отрицательных весов в задаче о кратчайших путях. (Это изменение требует также изменения задачи разностных ограничений, чтобы правильно обрабатывать аналог отрицательных циклов в задаче о кратчайших путях.) Эти более общие варианты задач сложнее для решения, чем рассмотренные вначале варианты, но, скорее всего, они и более полезны как обобщенные модели. Похоже, что приемлемый способ решения всех их следует искать в эффективном решении задачи поиска кратчайших путей с отрицательными весами.

К сожалению, с этим подходом связана принципиальная трудность, которая демонстрирует другую сторону вопроса применения сведения, позволяющую оценить относительную трудность задач. Мы уже использовали сведение в положительном смысле - для расширения применимости решений к общим задачам, однако его можно использовать и в отрицательном смысле - чтобы показать пределы такого расширения.

Трудность заключается в крайней сложности решения общей задачи поиска кратчайших путей. Далее мы увидим, как концепция сведения помогает сформулировать данное утверждение точно и обоснованно. В лекция №17 было рассмотрено множество задач, известных как NP-трудные, которые считаются трудноразрешимыми, т.к. все известные алгоритмы их решения в худшем случае требуют экспоненциального времени. Сейчас мы покажем, что в общем случае задача поиска кратчайших путей является NP-трудной.

Как кратко упомянуто в лекция №17 и будет подробно рассмотрено в части VIII, обычно мы считаем, что если задача является NP-трудной, то это означает не только то, что не известно эффективного алгоритма, гарантирующего решение этой задачи, но и то, что все-таки имеется некоторая надежда найти такой алгоритм. В этом контексте мы используем термин эффективный по отношению к алгоритмам, время выполнения в худшем случае которых ограничено некоторой полиномиальной функцией от размера входных данных. Открытие эффективного алгоритма решения любой NP-трудной задачи должно быть ошеломляющим научным достижением. Концепция NP-трудности является важной для идентификации трудноразрешимых задач, т.к. часто легко доказать, что задача NP-трудна, используя следующую методику.

Лемма 21.17. Задача является NP-трудной, если существует эффективное сведение к ней любой NP-трудной задачи.

Эта лемма зависит от строгого определения эффективного сведения одной задачи A к другой задаче B. Мы отложим такие определения до части VIII (обычно применяются два различных определения). А пока мы просто используем этот термин для описания случая, когда существуют эффективные алгоритмы как для преобразования задачи A в задачу B, так и для преобразования решения B в решение A.

Теперь предположим, что имеется эффективное сведение NP-трудной задачи A к конкретной задаче B. Проведем доказательство от противного: если существует эффективный алгоритм для B, то мы могли бы воспользоваться им для решения с помощью сведения любого экземпляра задачи A за полиномиальное время (преобразуем данный экземпляр A в экземпляр B, решаем эту задачу, а затем преобразуем полученное решение). Однако не существует известного алгоритма, который гарантировал бы такое решение для A (поскольку A является NP-трудной), поэтому предположение о существовании алгоритма с полиномиальным временем для задачи B неверно, т.е. B также является NP-трудной задачей.

Эта методика является чрезвычайно важной, поскольку она позволяет показать, что огромное множество задач относятся к NP-трудным, предоставляя на выбор широкий круг задач, с помощью которых строились доказательства NP-трудности новой задачи. Например, в лекция №17 мы столкнулись с одной из классических NP-трудных задач - с задачей поиска гамильтонова пути, где нужно определить, существует ли простой путь, содержащий все вершины данного графа. Она была одной из первых задач, в отношении которой было показано, что она является NP-трудной (см. раздел ссылок). Ее нетрудно сформулировать в виде задачи поиска кратчайших путей, и поэтому из леммы 21.17 следует, что задача кратчайших путей относится к NP-трудным:

Лемма 21.18. В сетях, в которых веса ребер могут быть отрицательными, задачи поиска кратчайших путей являются NP-трудными.

Доказательство. Наше доказательство основано на сведении задачи о гамильтоновом пути к задаче о кратчайших путях. То есть мы покажем, что для решения задачи о гамильтоновом пути можно использовать любой алгоритм, позволяющий найти кратчайшие пути в сетях с отрицательными весами ребер. Для заданного неориентированного графа построим сеть с ребрами в обоих направлениях, соответствующими каждому ребру в графе, и присвоим каждому ребру вес -1. Кратчайший (простой) путь, начинающийся в любой вершине этой сети, имеет длину 1 - V тогда и только тогда, когда в графе существует гамильтонов путь. Обратите внимание, что эта сеть изобилует отрицательными циклами. Не только каждый цикл в графе соответствует отрицательному циклу в сети, но и каждое ребро в графе соответствует циклу с весом -2 в данной сети.

Из этого построения следует, что задача поиска кратчайших путей является NP-трудной, поскольку если бы мы могли разработать эффективный алгоритм для задачи поиска кратчайших путей в сетях, то мы имели бы эффективный алгоритм для задачи поиска гамильтонова пути в графах.

Если мы обнаруживаем, что данная задача является NP-трудной, естественно начать искать те варианты этой задачи, которые можно решить. Для задач поиска кратчайших путей мы имеем массу эффективных алгоритмов для ациклических сетей или для сетей, в которых веса ребер не являются отрицательными, и отсутствие хорошего решения для сетей, которые могут содержать циклы и отрицательные веса. Существуют ли какие-либо другие виды сетей, которые нас могут заинтересовать? Эта тема будет рассмотрена в разделе 21.7. Там, например, будет показано, что задача календарного планирования с конечными сроками сводится к варианту задачи поиска кратчайших путей, которую можно эффективно решить. Это обычная ситуация: при переходе ко все более трудным вычислительным задачам мы пытаемся выявить такие варианты данных задач, для которых мы надеемся найти решение.

Как показывают эти примеры, сведение является простой технологией, которая полезна при разработке алгоритмов, и поэтому мы часто прибегаем к нему. При этом мы либо сможем решить новую задачу, доказав, что она сводится к задаче с известным решением, либо сможем доказать, что новая задача будет сложной, показав, что задача, о которой известно, что она сложная, сводится к рассматриваемой задаче.

В таблице 21.3 приведен более подробный перечень различных выводов из сведения между четырьмя общими классами задач, которые были рассмотрены в лекция №17. Обратите внимание на несколько случаев, когда сведение не дает новой информации: например, хотя выборка и сводится к сортировке, а задача поиска наиболее длинных путей в ациклических сетях сводится к задаче поиска кратчайших путей в сетях общего вида, эти факты не проливают свет на относительную трудность задачи. В других случаях сведение либо может, либо нет, предоставить новую информацию; еще в некоторых случаях выводы действительно глубоки. Для развития этих концепций требуется строгое и формальное описание концепции сведения, но об этом мы подробно поговорим в части 8, а здесь просто подведем неформальный итог наиболее важных практических применений сведения вместе с уже знакомыми нам примерами.

В этой таблице приведены некоторые выводы из сведения задачи A к другой задаче B, с примерами, которые были рассмотрены в этом разделе. Глубокие выводы из случаев 9 и 10 ведут настолько далеко, что мы в общем случае предполагаем, что обосновать такие сведения невозможно (см. часть VIII). Сведение наиболее полезно в случаях 1, 6, 11 и 16 - для изучения нового алгоритма для A или обоснования нижней границы для B; 13-15 - для изучения новых алгоритмов для A; 12 - чтобы оценить сложность B.

Таблица 21.3. Выводы из сведения
AB Вывод из сведения Пример
1 ПростаяПростаяНовая нижняя граница B Сортировка
2 ПростаяРазрешимаяНет
3 ПростаяТрудноразрешимаяНет
4 ПростаяРешение не известноНет
5 РазрешимаяПростаяA простая
6 РазрешимаяРазрешимаяНовое решение A
7 РазрешимаяТрудноразрешимаяНет
8 РазрешимаяРешение не известноНет
9 ТрудноразрешимаяПростаяГлубокий
10 ТрудноразрешимаяРазрешимаяГлубокий
11 ТрудноразрешимаяТрудноразрешимаяТак же, как 1 или 6
12 ТрудноразрешимаяРешение не известноB неразрешимая
13 Решение не известноПростаяA простая
14 Решение не известноРазрешимаяA разрешимая
15 Решение не известноТрудноразрешимаяA разрешимая
16 Решение не известноРешение не известноТак же, как 1 или 6
Обозначения:
EMSTминимальное евклидово остовное дерево
TCтранзитивное замыкание
APSPкратчайшие пути для всех пар вершин
SSSPкратчайшие пути из одного истока
SSLPнаиболее длинные пути из одного истока
(+)(в сетях с неотрицательными весами)
(±)(в сетях с весами, которые могут быть отрицательными)(DAG)(в ациклических сетях)
DCразностные ограничения
HPгамильтоновы пути
JS(WD)планирование заданий (с конечными сроками)

Верхние границы. Если существует эффективный алгоритм решения задачи B, и можно доказать, что A сводится к B, то существует эффективный алгоритм и для решения задачи A. Возможно, существует другой, лучший, алгоритм для A, однако эффективность B является верхней границей того, чего можно достичь для A. Например, доказательство того, что задача календарного планирования сводится к задаче поиска наиболее длинных путей в ациклических сетях, превращает наш алгоритм решения второй задачи в эффективный алгоритм решения первой задачи.

Нижние границы. Если известно, что любой алгоритм для задачи A требует определенных ресурсов, и можно доказать, что A сводится к B, то мы знаем, что B имеет, по крайней мере, такие же требования к ресурсам, т.к. из существования лучшего алгоритма для B следовало бы существование лучшего алгоритма для A (если стоимость сведения меньше стоимости алгоритма B). То есть эффективность A равна нижней границе того, что можно получить в лучшем случае для B. Например, эта технология применялась в лекция №19, чтобы показать, что вычисление транзитивного замыкания имеет такую же трудоемкость, как умножение логических матриц, и в лекция №20 - чтобы показать, что вычисление евклидова MST так же трудоемко, как сортировка.

Трудноразрешимость. В частности, можно доказать, что некоторая задача трудноразрешима, показав, что к ней сводится другая трудноразрешимая задача. Например, из леммы 21.18 следует, что задача поиска кратчайших путей трудноразрешима, поскольку к ней сводится задача поиска гамильтонова пути, которая является трудноразрешимой.

Кроме этих общих выводов, ясно, что более детальная информация об эффективности конкретных алгоритмов для решения конкретных задач может непосредственно относиться к другим задачам, которые сводятся к первым. Когда мы находим верхнюю границу, мы можем анализировать соответствующий алгоритм, проводить эмпирические исследования и т.п., чтобы определить, получено ли лучшее решение задачи. Во время разработки качественного универсального алгоритма можно затратить усилия на разработку и тестирование качественной реализации и затем создать соответствующие АТД, которые расширяют ее сферу применения.

Мы используем сведение в качестве базового инструментального средства в этой и следующей лекциях. Мы будем делать упор на общей применимости рассматриваемых задач и общей применимости решающих их алгоритмов - за счет сведения к ним других задач. Важно также иметь представление об иерархической структуре все более общих формулировок задач. Например, линейное программирование является общей формулировкой, которая важна не только потому, что к ней сводятся многие задачи, но и потому, что известно, что эта задача не относится к NP-трудным. Другими словами, нет известного способа сведения общей задачи поиска кратчайших путей (или любой другой NP-трудной задачи) к линейному программированию. Подобные вопросы будут рассмотрены в части VIII.

Не все задачи являются разрешимыми, но уже разработаны хорошие общие модели, подходящие для широкого класса задач, для которых известны методы решения. Наш первый пример такой модели - кратчайшие пути в сетях. Продвигаясь ко все более общим задачам, мы попадем в область исследования операций, которая изучает математические методы принятия решений, и главной целью которой является разработка и изучение таких моделей. Одна важная проблема в исследовании операций - поиск модели, которая наиболее удобна для решения задачи, а затем подгонка задачи под эту модель. Эта область исследований известна также как математическое программирование (название дано еще до компьютерной эпохи и новой трактовки слова " программирование " ).

Сведение - это современная концепция, которая по сути аналогична математическому программированию и является основой нашего понимания трудоемкости вычислений для широкого круга приложений.

Упражнения

21.85. При помощи сведения, описанного в лемме 21.12, разработайте реализацию транзитивного замыкания (с тем же интерфейсом, что и в программах 19.3 и 19.4), которая использует АТД поиска кратчайших путей для всех пар вершин из раздела 21.3.

21.86. Покажите, что задача вычисления количества сильных компонентов в орграфе сводится к задаче поиска кратчайших путей для всех пар вершин с неотрицательными весами.

21.87. Приведите задачи разностных ограничений и кратчайших путей, которые соответствуют (аналогично построению лемм 21.14 и 21.15) задаче календарного планирования, где работы 0-7 имеют, соответственно, длины

0.40.20.30.40.20.50.1

и ограничения

5-14-66-03-26-16-2.

21.88. Приведите решение задачи календарного планирования из упражнения 21.87.

21.89. Предположим, что работы из упражнения 21.87 имеют дополнительные ограничения: работа 1 должна начаться до окончания работы 6, а работа 2 должна начаться до окончания работы 4 . Приведите задачу поиска кратчайших путей, к которой сводится эта задача, воспользовавшись построением из доказательства леммы 21.16.

21.90. Покажите, что задача поиска наиболее длинных путей для всех пар вершин в ациклических сетях с положительными весами сводится к задаче разностных ограничений с положительными константами.

21.91. Объясните, почему соответствие в доказательстве леммы 21.16 нельзя расширить для доказательства того, что задача поиска кратчайших путей сводится к задаче календарного планирования с конечными сроками.

21.92. Усовершенствуйте программу 21.8, чтобы работам можно было присваивать не только целочисленные номера, но и символические имена (см. программу 17.10).

21.93. Разработайте интерфейс АТД, который дает возможность клиентам ставить и решать задачи разностных ограничений.

21.94. Напишите класс, который реализует интерфейс из упражнения 21.93 на основе сведения задачи разностных ограничений к задаче поиска кратчайших путей в ациклических сетях.

21.95. Разработайте реализацию класса, который решает задачу поиска кратчайших путей из одного истока в ациклических сетях с отрицательными весами с помощью сведения к задаче разностных ограничений и интерфейса из упражнения 21.93.

21.96. Решение задачи поиска кратчайших путей в ациклических сетях из упражнения 21.95 предполагает существование реализации, которая решает задачу разностных ограничений. Что произойдет, если использовать реализацию из упражнения 21.94, которая предполагает существование реализации для задачи поиска кратчайших путей в ациклических сетях?

21.97. Докажите эквивалентность любых двух NP-трудных задач (т.е. выберите две таких задачи и докажите, что они сводятся друг к другу).

21.98. Сформулируйте явное построение, которое сводит задачу поиска кратчайших путей в сетях с целочисленными весами к задаче поиска гамильтонова пути.

21.99. Пользуясь сведением, разработайте класс, который использует АТД сети и решает задачу поиска кратчайших путей из одного истока, чтобы решить следующую задачу. Для заданного орграфа, вектора положительных весов, индексированного именами вершин, и начальной вершины v нужно найти такие пути из v в каждую другую вершину, чтобы сумма весов вершин в пути была минимальной.

21.100. Программа 21.8 не проверяет, разрешима ли задача календарного планирования, которую она выбирает в качестве входной (существует ли цикл). Охарактеризуйте графики, которые программа будет выводить для неразрешимых задач.

21.101. Разработайте интерфейс АТД, который позволяет клиентам ставить и решать задачи календарного планирования. Напишите класс, который реализует полученный интерфейс на основе сведения задачи календарного планирования работ к задаче поиска кратчайших путей в ациклических сетях (как в программе 21.8).

21.102. Добавьте в класс из упражнения 21.101 функцию (вместе с реализацией), которая выводит наиболее длинный путь в графике. (Такой путь обычно называется критическим путем.)

21.103. Напишите клиент для интерфейса из упражнения 21.101, который генерирует PostScript-программу для прорисовки календарного графика в стиле рис. 21.24 (см. лекция №43).

21.104. Разработайте модель для генерации задач календарного планирования. Воспользуйтесь полученной моделью для тестирования реализаций из упражнений 21.101 и 21.103 на приемлемом множестве задач разных размеров.

21.105. Напишите класс, который реализует интерфейс из упражнения 21.101, решая задачу календарного планирования работ с помощью ее сведения к задаче разностных ограничений.

21.106. Диаграмма PERT (Project Evaluation and Review Technique - сетевое планирование и управление) - это сеть, которая представляет задачу календарного планирования с ребрами в качестве работ (см. рис. 21.25). Напишите класс, реализующий интерфейс календарного планирования из упражнения 21.101 на основе диаграмм PERT.

21.107. Сколько вершин содержит диаграмма PERT для задачи планирования с V работами и E ограничениями?

21.108. Напишите программы для взаимного преобразования задач календарного планирования работ, представленных ребрами (диаграммы PERT, см. упражнение 21.106), и представленных вершинами (см. рис. 21.22).

 Диаграмма PERT


Рис. 21.25.  Диаграмма PERT

Диаграмма PERT - это сетевое представление задачи календарного планирования, где работы представлены ребрами. Сеть в верхней части рисунка является представлением задачи календарного планирования с рис. 21.22, где работы 0-9 представлены, соответственно, ребрами 0-1, 1-2, 2-3, 4-3, 5-3, 0-3, 5-4, 1-4, 4-2 и 1-5. Критическим путем в этом графике является наиболее длинный путь в данной сети.

Отрицательные веса

Теперь обратимся к проблеме обработки отрицательных весов в задачах поиска кратчайших путей. Возможно, отрицательные веса ребер покажутся неестественными, поскольку на протяжении большей части этой главы мы на наглядных примерах привыкли, что веса представляют расстояния или стоимости. Однако, как было показано в разделе 21.6, отрицательные веса ребер естественно возникают, когда к задаче поиска кратчайших путей сводятся другие задачи. Отрицательные веса - это не просто математическая абстракция; напротив, они существенно расширяют применимость задач поиска кратчайших путей в качестве модели для решения других задач. Эта потенциальная польза побуждает нас искать эффективные алгоритмы для решения сетевых задач, в которых возможны отрицательные веса.

На рис. 21.26 показан небольшой пример, который демонстрирует влияние введения отрицательных весов в задаче о наиболее коротких путях в сети. Возможно, наиболее важный эффект состоит в том, что при наличии отрицательных весов наиболее короткие пути с малыми весами часто содержат больше ребер, чем пути с более высокими весами. В случае положительных весов мы старались искать пути напрямик; однако при наличии отрицательных весов мы ищем обходные пути, которые содержат столько ребер с отрицательными весами, сколько мы сможем отыскать. Этот эффект переворачивает наше наглядное понимание поиска " коротких " путей, которое необходимо для понимания алгоритмов, так что нам потребуется преодолеть этот интуитивный барьер и рассмотреть данную задачу на базовом абстрактном уровне.

Взаимосвязь между кратчайшими путями в сетях и гамильтоновыми путями в графах, показанная при доказательстве леммы 21.18, перекликается с наблюдением, что поиск путей низкого веса (которые мы называем " короткими " ) равноценен поиску путей с большим количеством ребер (которые можно считать " длинными " ). При наличии отрицательных весов мы ищем скорее длинные пути, нежели короткие.

Первая мысль, которая приходит на ум, чтобы исправить ситуацию - это найти наименьший (самый отрицательный) вес ребра, затем добавить абсолютное значение этого числа ко всем весам ребер, чтобы получить сеть без отрицательных весов. Этот наивный способ вообще не работает из-за того, что кратчайшие пути в новой сети никак не связаны с кратчайшими путями в прежней сети.

 Пример сети с отрицательными ребрами


Рис. 21.26.  Пример сети с отрицательными ребрами

Это та же демонстрационная сеть, что и сеть с рис. 21.1, только веса ребер 3-5 и 5-1 являются отрицательными. Естественно, данное изменение существенно влияет на структуру кратчайших путей, что легко увидеть, сравнивая матрицы расстояний и путей справа с соответствующими матрицами на рис. 21.9. Например, кратчайший путь из 0 в 1 в этой сети - это путь 0-5-1, который имеет длину 0, а кратчайшим путем из 2 в 1 является путь 2-3-5-1 с длиной -0.17.

Например, в сети, приведенной на рис. 21.26, кратчайший путь из 4 в 2 - это путь 4-3-5-1-2. Если увеличить веса всех ребер в графе на 0.38, чтобы сделать их все положительными, вес этого пути увеличится с 0.20 до 1.74. Однако вес 4-2 возрастет с 0.32 лишь до 0.70, так что кратчайшим путем из 4 в 2 станет это ребро. Чем больше ребер содержит путь, тем больше он " потяжелеет " от такого преобразования, поэтому результат с точки зрения предыдущего абзаца как раз противоположен тому, что требуется. Но даже если этот наивный подход не работает, идея преобразования сети в эквивалентную без отрицательных весов, но с теми же кратчайшими путями, вполне достойна внимания; в конце раздела мы рассмотрим алгоритм на основе этой идеи.

Все наши алгоритмы поиска кратчайших путей до сих пор содержали одно из двух ограничений на задачу кратчайших путей, которые позволяли получить эффективное решение: они запрещали либо циклы, либо отрицательные веса. Существуют ли менее строгие ограничения, которые можно было бы наложить на сети, содержащие как циклы, так и отрицательные веса, и все-таки получить разрешимые задачи кратчайших путей? Мы коснулись ответа на этот вопрос в начале главы, когда нам понадобилось добавить требование, что для того, чтобы задача имела бы смысл в случае наличия отрицательных циклов, пути должны быть простыми. Возможно, нам следует ограничиться рассмотрением сетей, которые не имеют таких циклов?

Кратчайшие пути в сетях без отрицательных циклов. Для заданной сети, которая может содержать ребра с отрицательными весами, но не содержит циклов отрицательного веса, требуется решить одну из следующих задач: найти кратчайший путь, соединяющий две заданных вершины (задача поиска кратчайшего пути), найти кратчайшие пути из заданной вершины во все другие вершины (задача с одним истоком) или найти кратчайшие пути, соединяющие все пары вершин (задача для всех пар вершин).

Доказательство леммы 21.18 оставляет возможность построения эффективных алгоритмов решения этой задачи, поскольку оно теряет смысл в отсутствие отрицательных циклов. Чтобы решить задачу поиска гамильтонова пути, необходимо иметь возможность решать задачи поиска кратчайших путей в сетях с огромным количеством отрицательных циклов.

Кроме того, многие практические задачи сводятся в точности к задаче поиска кратчайших путей в сетях, которые не содержат отрицательных циклов. Мы уже видели один такой пример.

Лемма 21.19. Задача календарного планирования с конечными сроками сводится к задаче поиска кратчайших путей в сетях, которые не содержат отрицательных циклов.

Доказательство. Рассуждения из доказательства леммы 21.15 показывают, что построение в доказательстве леммы 21.16 приводит к сетям, не содержащим отрицательных циклов. Из задачи календарного планирования мы создаем задачу разностных ограничений с переменными, которые соответствуют моментам начала работ, а из задачи разностных ограничений мы создаем сеть. Затем мы меняем знаки всех весов, чтобы перейти от задачи поиска наиболее длинных путей к задаче поиска кратчайших путей - преобразование, которое обращает знаки всех неравенств. Любой простой путь в сети из i в j соответствует последовательности неравенств, включающих переменные. Свертывая эти неравенства, получаем из существования такого пути, что , где wij - сумма весов на пути из i в j. Отрицательный цикл соответствует 0 в левой части этого неравенства и отрицательному значению в правой части, т.е. такой цикл не может существовать.

Как мы отмечали, когда впервые обсуждали задачу календарного планирования в разделе 21.6, это утверждение неявно предполагает, что наши задачи календарного планирования являются разрешимыми (т.е. имеют решение). На практике не следует делать подобное допущение, и часть вычислительных затрат уйдет на определение разрешимости задачи календарного планирования с конечными сроками. В построении доказательства леммы 21.19 отрицательный цикл в сети означает, что задача невыполнима, поэтому данная задача соответствует следующей.

Обнаружение отрицательных циклов. Содержит ли данная сеть отрицательный цикл? Если да, то нужно найти один такой цикл.

С одной стороны, эта задача не обязательно простая (простые алгоритмы проверки циклов для орграфов неприменимы); с другой стороны, она не обязательно сложная (неприменимо и сведение из леммы 21.16 из задачи поиска гамильтонова пути). Поэтому вначале нужно разработать алгоритм решения этой задачи.

В приложении календарного планирования с конечными сроками отрицательные циклы соответствуют ошибочным условиям, которые, видимо, не должны встречаться часто, но наличие которых все же необходимо проверять. Мы могли бы даже разработать алгоритмы, которые удаляют ребра, чтобы разорвать отрицательный цикл, и делают так, пока не останется ни одного. В других приложениях обнаружение отрицательных циклов является основной целью, как в следующем примере.

Арбитражные операции (валютные спекуляции). Многие газеты печатают таблицы курсов мировых валют (см., например, рис. 21.27). Такие таблицы можно рассматривать как представления матрицами смежности для полных сетей. Ребро s-t с весом x означает, что можно конвертировать одну единицу валюты s в x единиц валюты t. Пути в сети задают многошаговые конверсии. Например, если существует также ребро t-w с весом у, то путь s-t-w представляет способ, позволяющий конвертировать одну единицу валюты s в xy единиц валюты w. Можно было бы ожидать, что xy равно весу ребра s-w во всех случаях, однако подобные таблицы являются сложной динамичной системой, где такая последовательность отнюдь не гарантирована. Если найдется вариант, где xy меньше, чем вес s-w, то мы сможем перехитрить систему. Предположим, что вес w-s равен z, и xyz > 1, тогда цикл s-t-w-s позволяет конвертировать одну единицу валюты s в более чем одну единицу (xyz) валюты s. То есть можно получить доход в 100(xyz - 1) процентов, конвертировав s в t, затем в w и снова в s. Данная ситуация является примером арбитражных операций (arbitrage), которые позволили бы нам получать безграничные доходы, если бы не существовало сил вне модели - как, например, ограничения на размер сделок. Чтобы преобразовать эту задачу в задачу поиска кратчайших путей, прологарифмируем все числа так, чтобы веса путей соответствовали сумме весов ребер вместо их умножения, а затем изменим их знаки, чтобы обратить неравенства. При этом веса ребер могут оказаться отрицательными или положительными, а кратчайший путь из s в t дает наилучший способ конвертации валюты s в валюту t. Цикл с наименьшим весом указывает лучшую возможность для арбитражных операций, хотя выгоден любой отрицательный цикл.

 Арбитражные операции


Рис. 21.27.  Арбитражные операции

Таблица в верхней части содержит переводные коэффициенты одной валюты в другую. Например, второй элемент в верхнем ряду означает, что за 1 доллар можно купить 1,631 единиц валюты P. Конвертация $1000 в валюту P и обратно должна дать $1000*1,631*0,613 = $999, т.е. потеря составляет $1. Однако конвертация $1000 в валюту P, затем в валюту Yи обратно в доллары дает $1000*1,631*0,411*1,495 = $1002 , т.е. прибыль 0,2%. Если составить новую таблицу (внизу) из отрицательных логарифмов всех чисел, то ее можно считать матрицей смежности для полной сети с весами ребер, которые могут быть как положительными, так и отрицательными. В этой сети узлы соответствуют валютам, ребра - конвертациям, а пути - последовательностям конвертаций. Описанная конверсия соответствует циклу $-P-Y-$ в графе с весом -0,489 + 0,890 - 0,402 = -0,002. Лучшая возможность для арбитражных операций соответствует наиболее короткому циклу в графе.

Можно ли обнаружить в сети отрицательные циклы или найти кратчайшие пути в сетях, которые не содержат отрицательных циклов? Существование эффективных алгоритмов для решения этих задач не противоречит NP-трудности общей задачи, которая была доказана в лемме 21.18, поскольку сведение задачи поиска гамильтонова пути к любой из этих задач не известно. А именно, сведение леммы 21.18 говорит о том, чего мы не можем сделать: создать алгоритм, который может гарантированно и эффективно найти путь с наименьшим весом в любой заданной сети, если в ней допускаются отрицательные веса ребер. Такая постановка задачи представляется слишком общей. Однако можно решить ограниченные версии этой задачи, хотя это и не так легко, как для других ограниченных версий данной задачи (положительные веса и ациклические сети), которые мы изучили выше в этой главе.

В общем случае, как было сказано в разделе 21.2, алгоритм Дейкстры не работает при наличии отрицательных весов, даже если ограничиться рассмотрением сетей, в которых нет отрицательных циклов. На рис. 21.28 продемонстрировано это утверждение. Основное затруднение состоит в том, что в этом алгоритме пути рассматриваются в порядке возрастания их длины. Доказательство правильности алгоритма (см. лемму 21.2) предполагает, что добавление ребра к пути делает этот путь более длинным.

 Отказ алгоритма Дейкстры (отрицательные веса)


Рис. 21.28.  Отказ алгоритма Дейкстры (отрицательные веса)

В этом примере алгоритм Дейкстры решает, что кратчайшим путем из 4 в 2 является путь 4-2 (длиной 0.32), и упускает более короткий путь 4-3-5-1-2 (длиной 0.20).

Алгоритм Флойда (Floyd) не нуждается в таком предположении и эффективен даже тогда, когда веса ребер могут быть отрицательными. Если нет отрицательных циклов, он вычисляет кратчайшие пути; примечательно, что в случае существования отрицательных циклов этот аглоритм обнаруживает по крайней мере один из них.

Лемма 21.20. Алгоритм Флойда ( рис. 21.29) решает задачу обнаружения отрицательного цикла и задачу поиска кратчайших путей для всех пар вершин в сетях, которые содержат отрицательные циклы, за время, пропорциональное V3.

Доказательство. Доказательство леммы 21.8 не зависит от того, могут ли веса ребер быть отрицательными, однако при наличии ребер с отрицательными весами нужна другая интерпретация результатов. Каждый элемент в матрице свидетельствует о том, что алгоритм обнаружил путь этой длины. В частности, любой отрицательный элемент на диагонали матрицы расстояний означает наличие по крайней мере одного отрицательного цикла. При наличии отрицательных циклов мы не можем непосредственно делать какие-либо дальнейшие выводы, т.к. пути, которые алгоритм неявно проверяет, не обязательно просты: ведь некоторые из них могут содержать один и более витков по одному отрицательному циклу или нескольким. Однако если отрицательных циклов нет, то пути, которые вычисляются алгоритмом, просты, поскольку любой путь с циклом означает существование такого пути, который соединяет те же две точки, но содержит меньше ребер и имеет не больший вес (тот же путь с удаленным циклом).

Доказательство леммы 21.20 не дает конкретного рецепта, как найти конкретный отрицательный цикл, исходя из матриц расстояний и путей, вычисляемых по алгоритму Флойда. Мы оставляем это задание в качестве самостоятельного упражнения (см. упражнение 21.122).

Алгоритм Флойда решает задачу поиска кратчайших путей для всех пар вершин в графах, которые не содержат отрицательных циклов (см. рис. 21.29). Учитывая отказ алгоритма Дейкстры в сетях, которые могут содержать отрицательные веса, алгоритм Флойда позволяет решить задачу для всех пар вершин в разреженных сетях без отрицательных циклов за время, пропорциональное V3. Если в таких сетях нужно решить задачу с одним истоком, можно применить это решение сложности V3 для всех пар вершин. Хотя при этом выполняется много лишней работы, это лучшее, что мы пока знаем для задачи с одним истоком. Можно ли разработать более быстрые алгоритмы для этих задач - такие, время выполнения которых сравнимо с алгоритмом Дейкстры для положительных весов ребер (E lg V для кратчайших путей из одного истока и VE lg V для кратчайших путей для всех пар вершин)? Мы можем ответить утвердительно на этот вопрос в отношении задачи для всех пар вершин; и можно снизить трудоемкость в худшем случае до VE для задачи с одним истоком.

 Алгоритм Флойда (отрицательные веса)


Рис. 21.29.  Алгоритм Флойда (отрицательные веса)

Эта последовательность демонстрирует построение матриц всех кратчайших путей для орграфа с отрицательными весами с помощью алгоритма Флойда. Первый шаг совпадает с приведенным на рис. 21.14. На втором шаге в игру вступает отрицательное ребро 5-1, и находятся пути 5-1-2 и 5-1-4. Алгоритм выполняет точно такую же последовательность шагов релаксации для любых весов, однако результаты отличаются.

А вот вопрос преодоления барьера VE для общей задачи поиска кратчайших путей из одного истока является по-прежнему открытым.

Следующий подход, разработанный Р. Беллманом (R. Bellman) и Л. Фордом (L. Ford) в конце 1950-х годов, предоставляет простую и эффективную основу для решения задач поиска кратчайших путей из одного истока в сетях без отрицательных циклов. Чтобы вычислить кратчайшие пути из вершины s, мы (как обычно) используем индексированный именами вершин вектор wt, такой, что wt[t] содержит длину кратчайшего пути из s в t. В wt[s] заносим 0, а во все другие элементы wt - большое сигнальное значение, после чего вычисляем кратчайшие пути следующим образом:

Просматриваем ребра сети в любом порядке и выполняем релаксацию вдоль каждого ребра. Выполняем V таких действий.

Общий метод выполнения V проходов по ребрам с просмотром ребер в любом порядке мы будем называть алгоритмом Беллмана-Форда (Bellman-Ford). Некоторые авторы применяют этот термин для описания еще более общего метода (см. упражнение 21.130).

Например, для графа, представленного списками смежности, алгоритм Беллмана-Форда поиска кратчайших путей из начальной вершины s можно реализовать так. Сначала занесем в элементы wt значения, большие, чем любая длина пути, а в элементы spt - пустые указатели, и затем выполним следующий код:

  wt[s] = 0;
  for (i = 0; i < G->V(); i++)
    for (v = 0; v < G->V(); v++)
      { if (v != s && spt[v] == 0) continue;
        typename Graph::adjIterator A(G, v);
        for (Edge* e = A.beg(); !A.end(); e = A.nxt())
          if (wt[e->w()] > wt[v] + e->wt())
            { wt[e->w()] = wt[v] + e->wt(); st[e->w()] = e; }
          }
      

Этот код демонстрирует простоту базового метода. Однако на практике он не используется: как вскоре будет показано, его простые модификации дают реализации, которые более эффективны для большинства графов.

Лемма 21.21. С помощью алгоритма Беллмана-Форда можно решить задачу поиска кратчайшего пути из одного истока в сетях без отрицательных циклов за время, пропорциональное VE.

Доказательство. Мы делаем V проходов по всем E ребрам, поэтому полное время будет пропорционально VE. Чтобы доказать, что это вычисление приведет к нужному результату, покажем методом индукции по i, что для всех вершин v после

i-го прохода значение wt[v] не больше, чем длина кратчайшего пути из s в v, который содержит i или менее ребер. Очевидно, это утверждение верно для i, равного 0. Полагая, что оно истинно для i, рассмотрим два возможных случая для каждой данной вершины v: среди путей из s в v с i + 1 или меньшим количеством ребер либо может, либо не может существовать кратчайший путь с i + 1 ребрами. Если самый короткий из путей с i+1 или менее ребрами из s в v имеет длину i или меньше, то wt[v] не изменится и останется допустимым. Иначе существует путь из s в v с i+1 ребрами, более короткий, чем любой путь из s в v с i или менее ребрами. Этот путь должен состоять из пути с i ребрами из s в некоторую вершину w с добавленным ребром w-v. По предположению индукции, wt[w] содержит верхнюю границу наиболее короткого расстояния из s в w, а (i+1)-й проход проверяет каждое ребро, является ли оно последним ребром в новом кратчайшем пути к конечной вершине ребра. В частности, проверяется ребро w-v.

После V- 1 итераций wt[w] содержит нижнюю границу длины любого кратчайшего пути из s в v с V- 1 или менее ребрами, для всех вершин v. Мы можем остановиться после V- 1 итераций, т.к. любой путь с V или более ребрами должен содержать цикл (с положительной или нулевой стоимостью), и, удалив этот цикл, можно отыскать путь с V- 1 или менее ребрами, который имеет такую же или меньшую длину. Поскольку wt[w] есть длина некоторого пути из s в v, это значение является также верхней границей длины кратчайшего пути, и таким образом, должно быть равно длине кратчайшего пути.

Хотя это и не было указано явно, то же самое доказательство показывает, что вектор spt содержит указатели на ребра в дереве кратчайших путей с корнем в s.

Для типичных графов проверка каждого ребра на каждом проходе достаточно расточительна. Действительно, можно сразу легко определить, что многие ребра не приводят к успешной релаксации на любом проходе. Фактически, что-то изменить могут только ребра, исходящие из вершины, значение которой изменилось на предыдущем проходе.

Программа 21.9 является прямой реализацией описанного алгоритма с использованием очереди FIFO, содержащей эти ребра, которые будут проверяться на каждом проходе. На рис. 21.30 приведен пример работы этого алгоритма.

Программа 21.9. Алгоритм Беллмана-Форда

Эта реализация алгоритма Беллмана-Форда использует очередь FIFO всех вершин, для которых может оказаться эффективной релаксация по исходящему ребру. Мы выбираем вершину из очереди и выполняем релаксацию по всем ее ребрам. Если любое из них приводит к более короткому пути в некоторую вершину, мы заносим ее в очередь. Сигнальное значение G->V отделяет текущую серию вершин (которые изменились на последней итерации) от следующей серии (которые изменяются на данной итерации) и позволяет остановиться после G->V проходов.

  SPT(Graph &G, int s) : G(G),
    spt(G.V()), wt(G.V(), G.V())
    { QUEUE<int> Q; int N = 0;
      wt[s] = 0.0;
      Q.put(s); Q.put(G.V());
      while (!Q.empty())
        { int v;
          while ((v = Q.get()) == G.V())
            { if (N++ > G.V()) return; Q.put(G.V()); }
          typename Graph::adjIterator A(G, v);
          for (Edge* e = A.beg(); !A.end(); e = A.nxt())
            { int w = e->w();
              double P = wt[v] + e->wt();
              if ( P < wt[w])
                { wt[w] = P; Q.put(w); spt[w] = e; }
            }
        }
    }
      

 Алгоритм Беллмана-Форда (с отрицательными весами)


Рис. 21.30.  Алгоритм Беллмана-Форда (с отрицательными весами)

На этом рисунке показан результат поиска алгоритмом Беллмана-Форда кратчайших путей из вершины 4 в сети с рис. 21.26. Алгоритм выполняет проходы, в которых проверяет все ребра, исходящие из всех вершин в очереди FIFO. Содержимое очереди показано под каждым чертежом графа, где затененные элементы представляют содержимое очереди, оставшееся с предыдущего прохода.

Когда мы находим ребро, которое может уменьшить длину пути из вершины 4 в конечную вершину этого ребра, мы выполняем операцию релаксации, которая помещает конечную вершину в очередь, а ребро - в SPT. Серые ребра на чертежах графа образуют SPT после каждого этапа, которое показано также в ориентированной форме в центре (все ребра ориентированы вниз). Мы начинаем с пустого SPT и вершины 4 в очереди (вверху). На втором проходе мы выполняем релаксацию вдоль ребер 4-2 и 4-3 и оставляем в очереди вершины 2 и 3.

На третьем проходе мы проверяем, но не выполняем релаксацию вдоль 2-3, а затем выполняем релаксацию вдоль 3-0 и 3-5, оставив в очереди вершины 0 и 5. На четвертом проходе мы выполняем релаксацию вдоль 5-1 и затем проверяем, но не выполняем релаксацию вдоль 1-0 и 1-5, оставив в очереди вершину 1. На последнем проходе (внизу) мы выполняем релаксацию вдоль 1-2. Алгоритм вначале действует как BFS, однако в отличие от всех других методов поиска на графах, он может изменять ребра дерева, как на последнем шаге.

Программа 21.9 эффективно решает задачи поиска кратчайших путей в реальных сетях с одним истоком, однако быстродействие в худшем случае все-таки остается пропорциональным VE. Для плотных графов время выполнения не лучше, чем для алгоритма Флойда, который находит все кратчайшие пути, а не только исходящие из одного истока. Для разреженных графов реализация алгоритма Беллмана-Форда в программе 21.9 может работать быстрее алгоритма Флойда вплоть до в V раз. Однако она работает почти в V раз медленнее, чем алгоритм Дейкстры в худшем случае на сетях без ребер с отрицательными весами (см. таблицу 19.2).

Были исследованы и другие вариации алгоритма Беллмана-Форда, и некоторые из них быстрее работают для задачи с одним истоком, чем версия с очередью FIFO в программе 21.9, но все они в худшем случае требуют времени, пропорционального по крайней мере VE (см., например, упражнение 21.132). Базовый алгоритм Беллмана-Форда был разработан десятки лет тому назад и, несмотря на впечатляющие достижения в повышении эффективности для множества других задач на графах, до сих пор не были открыты алгоритмы с лучшей эффективностью в худшем случае на сетях с отрицательными весами.

Кроме того, алгоритм Беллмана-Форда более эффективен, чем алгоритм Флойда, для обнаружения в сетях отрицательных циклов.

Лемма 21.22. С помощью алгоритма Беллмана-Форда можно решить задачу обнаружения отрицательного цикла за время, пропорциональное VE.

Доказательство. Основные положения доказательства леммы 21.21 справедливы даже при наличии отрицательных циклов. Если мы выполняем V-ю итерацию алгоритма, и один из шагов релаксации завершается успешно, то мы обнаружили кратчайший путь с V ребрами, который соединяет вершину s с некоторой вершиной в сети. Любой такой путь должен содержать цикл (соединяющий некоторую вершину w с собой), и в соответствии с предположением индукции этот цикл должен быть отрицательным: ведь чтобы w была включена в путь второй раз, путь из s во второе вхождение w должен быть короче пути из s в первое вхождение w. Этот цикл будет также присутствовать в дереве, поэтому обнаружить циклы можно и с помощью периодической проверки ребер из spt (см. упражнение 21.134).

Сказанное верно только для тех вершин, которые находятся в том же сильно связном компоненте, что и исток s. Чтобы обнаружить отрицательные циклы вообще, можно также вычислить сильно связные компоненты и обнулить веса для одной вершины в каждом компоненте (см. упражнение 21.126) или добавить фиктивную вершину с ребрами в каждую из остальных вершин (см. упражнение 21.127).

В заключение этого раздела рассмотрим задачу поиска кратчайших путей для всех пар вершин. Можно ли улучшить алгоритм Флойда, который выполняется за время, пропорциональное V3 ? Использование алгоритма Беллмана-Форда для решения задачи для всех пар вершин с помощью решения для каждой вершины задачи с одним истоком даст время выполнения в худшем случае, пропорциональное V2E . Мы не рассматриваем это решение более подробно, поскольку существует способ, гарантирующий решение задачи поиска всех путей за время, пропорциональное VE log К Он основан на идее, рассмотренной в начале этого раздела: преобразование исходной сети в сеть, которая содержит только неотрицательные веса и имеет такую же структуру кратчайших путей.

Фактически, у нас имеется большая свобода при преобразовании одной сети в другую с другими весами ребер, но с теми же кратчайшими путями. Предположим, что индексированный именами вершин вектор wt содержит произвольное присваивание весов вершинам сети G. Для этих весов определим операцию перевзвешивания (reweighting) графа следующим образом:

Например, следующий простой код перевзвешивает сеть в соответствии с принятыми соглашениями:

  for (v = 0; v < G->V(); v++)
    { typename Graph::adjIterator A(G, v);
      for (Edge* e = A.beg(); !A.end(); e = A.nxt())
        e->wt() = e->wt() + wt[v] - wt[e->w()]
        }
      

Эта операция - простой линейный по времени процесс, который определен для всех сетей, независимо от весов. При этом кратчайшие пути в полученной сети остаются теми же, что и кратчайшие пути в исходной сети.

Лемма 21.23. Перевзвешивание сети не изменяет ее кратчайшие пути.

Доказательство. Для любых двух заданных вершин s и t перевзвешивание изменяет вес любого пути из s в t в точности на разность весов s и t. Это утверждение легко доказать методом индукции по длине пути. При перевзвешивании сети вес каждого пути из s в t изменяется на одно и то же значение, каким бы путь ни был - длинным или коротким. В частности, из этого факта непосредственно следует, что длина кратчайшего пути между любыми двумя вершинами в преобразованной сети совпадает с длиной кратчайшего пути между ними в исходной сети.

Поскольку пути между различными парами вершин могут быть перевзвешены по-разному, перевзвешивание может повлиять на такие задачи, в которых выполняется сравнение длин кратчайших путей (например, вычисление диаметра сети). В таких случаях после вычисления кратчайших путей, но перед использованием результатов, следует обратить перевзвешивание.

Перевзвешивание бесполезно в случае сетей с отрицательными циклами: эта операция не изменяет вес ни одного цикла, поэтому она не может удалить отрицательные циклы. Но для сетей без отрицательных циклов можно попытаться найти такое множество весов вершин, что перевзвешивание приведет к неотрицательным весам ребер, независимо от того, какими были первоначальные веса. С неотрицательными весами ребер мы можем затем решить задачу поиска кратчайших путей для всех пар вершин с помощью версии алгоритма Дейкстры для всех пар вершин. Например, на рис. 21.31 приведен такой пример для нашей демонстрационной сети, а на рис. 21.32 - вычисление кратчайших путей алгоритмом Дейкстры в преобразованной сети без отрицательных ребер. Следующая лемма показывает, что такое множество весов можно найти всегда.

Лемма 21.24. В произвольной сети без отрицательных циклов выберем любую вершину s и присвоим каждой вершине v вес, равный длине кратчайшего пути в v из s. Перевзвешивание сети с весами этих вершин дает в результате неотрицательные веса ребер для каждого ребра, которое соединяет вершины, достижимые из s.

Доказательство. Для любого заданного ребра v-w вес v равен длине кратчайшего пути в v, а вес w равен длине кратчайшего пути в w. Если v-w - конечное ребро на кратчайшем пути в w, то разность между весом w и весом v в точности равна весу v-w. Другими словами, перевзвешивание ребра даст нулевой вес. Если кратчайший путь через w не проходит через v, то вес v плюс вес v-w должен быть больше или равен весу w. То есть перевзвешивание ребра даст положительный вес.

 Перевзвешивание сети


Рис. 21.31.  Перевзвешивание сети

Для любого распределения весов на вершинах (вверху) можно выполнить перевзвешивание всех ребер в сети, добавив к весу каждого ребра разность весов его начальной и конечной вершин. Перевзвешивание не влияет на кратчайшие пути, поскольку оно одинаково изменяет веса всех путей, соединяющих каждую пару вершин. Вот, например, путь 0-5-4-2-3: его вес в первоначальной сети равен 0.29 + 0.21 + 0.32 + 0.50 = 1.32, а в перевзвешенной сети - 1.12 + 0.19 + 0.12 + 0.34 = 1.77; эти веса отличаются на 0.45 = 0.81 - 0.36, т.е. на разность весов вершин 0 и 3. Веса всех путей между 0 и 3 изменились на одну и ту же величину.

Как в случае применения алгоритма Беллмана-Форда для обнаружения отрицательных циклов, существуют два способа сделать вес каждого ребра неотрицательным в произвольной сети без отрицательных циклов. Либо можно начать с истока в каждом сильно связном компоненте, либо добавить фиктивную вершину с ребром нулевой длины в каждую вершину сети. В любом случае получится остовный лес кратчайших путей, которым можно воспользоваться для присвоения весов вершинам (вес пути из корня в данную вершину в ее SPT).

Например, значения весов, выбранные на рис. 21.31, в точности равны длинам кратчайших путей из вершины 4, поэтому ребра в дереве кратчайших путей с корнем в 4 имеют нулевые веса в перевзвешенной сети.

Итак, мы можем решить задачу поиска кратчайших путей для всех пар вершин в сетях, которые содержат отрицательные веса ребер, но не имеют отрицательных циклов, следующим образом:

После этих вычислений матрица путей содержит кратчайшие пути в обеих сетях, а матрица расстояний - длины путей в перевзвешенной сети. Эта последовательность шагов иногда называется алгоритмом Джонсона (Johnson) (см. раздел ссылок).

Лемма 21.25. С помощью алгоритма Джонсона можно решить задачу поиска кратчайших путей для всех пар вершин в сетях без отрицательных циклов за время, пропорциональное , где d = 2, если E < 2V, и d = E/V в противном случае.

Доказательство. См. леммы 21.22-21.24 и резюме, подведенное в предыдущем абзаце. Граница времени выполнения в худшем случае непосредственно вытекает из лемм 21.7 и 21.22.

 Все кратчайшие пути в перевзвешенной сети


Рис. 21.32.  Все кратчайшие пути в перевзвешенной сети

Здесь приведены SPT-деревья для каждой вершины в обращении перевзвешенной сети с рис. 21.31, которые можно получить с помощью алгоритма Дейкстры, вычислив кратчайшие пути в исходной сети с рис. 21.26 рис. 21.26. Эти пути совпадают с путями сети до перевзвешивания.

Как на рис. 21.9, векторы st на этих диаграммах представляют собой столбцы матрицы путей из рис. 21.26. Векторы wt в этой схеме являются столбцами матрицы расстояний, но необходимо отменить пере-взвешивание для каждого элемента, вычтя вес начальной вершины и прибавив вес конечной вершины пути (см. рис. 21.31). Например, из третьей строки снизу можно видеть, что в обеих сетях кратчайшим путем из 0 в 3 будет 0-5-1-4-3, а его длина равна 1.13 в приведенной здесь перевзвешенной сети. Сравнивая с рис. 21.31, можно вычислить его длину в исходной сети: вычтя вес 0 и прибавив вес 3, получим результат 1.13 - 0.81 + 0.36 = 0.68, т.е. элемент в строке 0 и столбце 3 матрицы расстояний с рис. 21.26. Все кратчайшие пути, ведущие в вершину 4 в этой сети, имеют нулевцю длину, поскольку они использовались для перевзвешивания.

Для реализации алгоритма Джонсона мы объединяем реализацию из программы 21.9, код перевзвешивания, приведенный перед леммой 21.23, и реализацию алгоритма Дейкстры поиска кратчайших путей для всех пар вершин из программы 21.4 (или из программы 20.6 в случае плотных графов). Как было сказано в доказательстве леммы 21.22, для сетей, которые не являются сильно связными, придется соответствующим образом подправить алгоритм Беллмана-Форда (см. упражнения 21.135-21.137). Чтобы завершить реализацию интерфейса поиска кратчайших путей для всех пар вершин, можно либо вычислить истинные длины путей, вычитая вес начальной и прибавляя вес конечной вершины (т.е. отменить операцию перевзвешивания для путей) при копировании двух векторов в матрицы расстояний и путей в алгоритме Дейкстры, либо поместить эти вычисления в функцию GRAPHdist в реализации АТД.

Для сетей без отрицательных весов задача выявления циклов решается более просто, чем задача вычисления кратчайших путей из одного истока до всех других вершин; а последняя задача решается проще, чем задача вычисления кратчайших путей, соединяющих все пары вершин. Все это соответствует нашим интуитивным представлениям. Но вот аналогичные факты для сетей, которые содержат отрицательные веса, кажутся противоестественными: алгоритмы, изученные в этом разделе, показывают, что для сетей с отрицательными весами лучшие из известных алгоритмов решения трех рассмотренных задач имеют похожие характеристики производительности в худшем случае. Например, в худшем случае определить, содержит ли сеть единственный отрицательный цикл, примерно столь же сложно, как и найти все кратчайшие пути в сети того же размера без отрицательных циклов.

Упражнения

21.109. Измените генераторы случайных сетей из упражнений 21.6 и 21.7, чтобы с помощью масштабирования выдавать веса из диапазона от а до b (где а и b принимают значения между -1 и 1).

21.110. Измените генераторы случайных сетей из упражнений 21.6 и 21.7, чтобы порождать отрицательные веса, изменяя знаки фиксированного процента (значение которого задается клиентом) весов ребер.

21.111. Разработайте клиентские программы, которые используют генераторы из упражнений 21.109 и 21.110 для генерации сетей, содержащих большой процент отрицательных весов, но не более чем несколько отрицательных циклов, для как можно большего интервала значений V и E.

21.112. Найдите (в интернете или газетах) таблицу конвертации валют. Воспользуйтесь ей для построения таблицы арбитражных операций. Примечание: не берите таблицы, которые выводятся/рассчитываются из небольшого количества значений и которые поэтому не дают достаточно точной информации о курсах. Дополнительный совет: порвите всех на валютной бирже!

21.113. Постройте последовательность арбитражных таблиц, используя источник, найденный в упражнении 21.112 (каждый источник периодически публикует различные таблицы). Найдите в таблицах все возможности для совершения арбитражных операций и попытайтесь определить их характерные особенности. Например, сохраняются ли удобные возможности на протяжении нескольких дней, или же они быстро восстанавливаются сразу после возникновения?

21.114. Разработайте модель для генерации случайных задач арбитражных операций. Цель - генерировать таблицы, которые как можно более похожи на таблицы, используемые в упражнении 21.113.

21.115. Разработайте модель для генерации случайных задач календарного планирования с конечными сроками. Цель - генерировать нетривиальные, но обычно выполнимые задачи.

21.116. Измените интерфейс и реализации из упражнения 21.101, чтобы дать клиентам возможность ставить и решать задачи календарного планирования с конечными сроками, используя сведение к задаче поиска кратчайших путей.

21.117. Найдите ошибку в следующем рассуждении. Задача поиска кратчайших путей сводится к задаче разностных ограничений с помощью построения, используемого в доказательстве леммы 21.15, а задача разностных ограничений тривиально сводится к линейному программированию - поэтому, согласно лемме 21.17, линейное программирование является NP-трудным.

21.118. Сводится ли задача поиска кратчайших путей в сетях без отрицательных циклов к задаче календарного планирования с конечными сроками? (Эквивалентны ли эти две задачи?) Обоснуйте свой ответ.

21.119. Найдите цикл с наименьшим весом (лучшую возможность для совершения арбитражной операции) в примере, показанном на рис. 21.27.

21.120. Докажите, что задача поиска цикла наименьшего веса в сети, которая может содержать ребра с отрицательными весами, является NP-трудной.

21.121. Покажите, что алгоритм Дейкстры работает правильно для сети, в которой ребра, исходящие из истока, являются единственными ребрами с отрицательными весами.

21.122. Разработайте класс, основанный на алгоритме Флойда, который предоставляет клиентам возможность проверить наличие в сети отрицательных циклов.

21.123. Воспользовавшись алгоритмом Флойда, покажите в стиле рис. 21.29 вычисление всех кратчайших путей для сети, определенной в упражнении 21.1, но с отрицательными весами ребер 5-1 и 4-2.

21.124. Является ли алгоритм Флойда оптимальным для полных сетей (с V2 ребрами)? Обоснуйте свой ответ.

21.125. Покажите в стиле рис. 21.32 вычисление с помощью алгоритма Белл-мана-Форда всех кратчайших путей сети, определенной в упражнении 21.1, но с отрицательными весами ребер 5-1 и 4-2.

21.126. Разработайте класс, основанный на алгоритме Беллмана-Форда, который позволяет клиентам проверить наличие в сети отрицательных циклов, используя метод отдельного истока в каждом сильно связном компоненте.

21.127. Разработайте класс, основанный на алгоритме Беллмана-Форда, который позволяет клиентам проверить наличие в сети отрицательных циклов, используя фиктивную вершину с ребрами во все вершины сети.

о 21.128. Приведите семейство графов, для которых программа 21.9 затрачивает на поиск отрицательных циклов время, пропорциональное VE.

21.129. Покажите расписание, которое вычисляется программой 21.9 для задачи календарного планирования с конечными сроками из упражнения 21.89.

21.130. Докажите, что следующий обобщенный алгоритм решает задачу поиска кратчайших путей с одним истоком: " Выполнить релаксацию произвольного ребра; продолжать так, пока существуют ребра, для которых можно выполнить релаксацию " .

21.131. Измените реализацию алгоритма Беллмана-Форда в программе 21.9, используя в ней рандомизированную очередь вместо очереди FIFO. (Корректность этого метода доказана в упражнении 21.130.)

21.132. Измените реализацию алгоритма Беллмана-Форда в программе 21.9, чтобы вместо очереди FIFO использовать дек, в который ребра помещаются в соответствии со следующим правилом: если ребро уже находилось в деке, поместить его в начало (как в стеке), а если оно встречается в первый раз, то поместить его в конец (как в очереди).

21.133. Эмпирически сравните производительность реализаций из упражнений 21.131 и 21.132 с программой 21.9 на различных общих сетях (см. упражнения 21.109-21.111).

21.134. Измените реализацию алгоритма Беллмана-Форда в программе 21.9, чтобы реализовать функцию, которая возвращает индекс произвольной вершины из любого отрицательного цикла или -1, если сеть не содержит отрицательных циклов. При наличии отрицательного цикла эта функция должна также построить такой вектор spt, что переходы по ссылкам в этом векторе (начиная с возвращаемого значения) выполняют обход цикла.

21.135. Измените реализацию алгоритма Беллмана-Форда в программе 21.9, чтобы установить веса вершин так, как нужно для алгоритма Джонсона, воспользовавшись следующим методом. Каждый раз, когда очередь становится пустой, в векторе spt ищется вершина, вес которой еще не установлен, и алгоритм запускается повторно с этой вершиной в качестве истока (для установки весов всех вершин, находящихся в том же сильно связном компоненте, что и новый исток). Такая процедура повторяется, пока не будут обработаны все сильно связные компоненты.

21.136. Разработайте реализацию интерфейса АТД поиска кратчайших путей для всех пар вершин в разреженных сетях (основанную на алгоритме Джонсона), внеся соответствующие изменения в программы 21.9 и 21.4.

21.137. Разработайте реализацию интерфейса АТД поиска кратчайших путей для всех пар вершин в насыщенных сетях (основанную на алгоритме Джонсона) (см. упражнения 21.136 и 21.43). Эмпирически сравните полученную реализацию с алгоритмом Флойда (программа 21.5) для различных сетей общего вида (см. упражнения 21.109-21.111).

21.138. Добавьте в решение упражнения 21.137 функцию-член, которая позволяет клиенту уменьшить стоимость ребра. Она должна возвращать флаг, который указывает, создает ли это действие отрицательный цикл. Если нет, то необходимо обновить матрицы путей и расстояний, чтобы они содержали новые кратчайшие пути. Функция должна выполняться за время, пропорциональное V 2.

21.139. Добавьте в решение упражнения 21.138 функции-члены, которые позволяют клиентам вставлять и удалять ребра.

21.140. Разработайте алгоритм, который преодолевает барьер VE для задачи поиска кратчайших путей в общих сетях с одним истоком для частного случая, когда известно, что абсолютные значения весов ограничены некоторой константой.

Перспективы

В таблице 21.4 перечислены алгоритмы, рассмотренные в настоящей главе, и характеристики их производительности в худшем случае. Эти алгоритмы широко применяются, поскольку, как было сказано в разделе 21.6, задачи поиска кратчайших путей связаны с большим количеством других задач во вполне конкретном техническом смысле, который непосредственно приводит к эффективным алгоритмам для решения всего класса задач или, по крайней мере, указывает на существование таких алгоритмов.

В этой таблице сравниваются трудоемкости (время выполнения в худшем случае) различных алгоритмов поиска кратчайших путей, которые были рассмотрены в настоящей главе. Границы для худшего случая, обозначенные как осторожные, не всегда полезны для прогнозирования производительности на реальных сетях - особенно это относится к алгоритму Беллмана-Форда, который обычно выполняется за линейное время.

Таблица 21.4. Трудоемкости алгоритмов поиска кратчайших путей
Ограничение на весаАлгоритмТрудоемкостьПримечание
С одним истоком
НеотрицательныеДейкстры V2 Оптимальный (насыщенные сети)
НеотрицательныеДейкстры (PFS) E lgV Осторожная граница
Без цикловОчередь истоковEОптимальный
Без отрицательных цикловБеллмана-Форда VE Можно усовершенствовать?
НетВопрос открыт?NP-трудный
Для всех пар вершин
НеотрицательныеФлойда V3 Одинаково для всех сетей
НеотрицательныеДейкстры (PFS) VE lgV Осторожная граница
Без цикловDFS VE Одинаково для всех сетей
Без отрицательных цикловФлойда V3 Одинаково для всех сетей
Без отрицательных цикловДжонсона VE lgV Осторожная граница
НетВопрос открыт?NP-трудный

Общая задача поиска кратчайших путей в сетях, в которых веса ребер могут принимать отрицательные значения, является трудноразрешимой. Задачи поиска кратчайших путей представляют собой хорошую иллюстрацию той границы, которая часто отделяет трудноразрешимые задачи от легких, поскольку имеются многочисленные алгоритмы для решения различных вариантов этой задачи с различными ограничениями на сети: ребра только с положительными весами, или ацикличность, или даже подзадачи, где возможны отрицательные веса ребер, но нет отрицательных циклов. Некоторые алгоритмы оптимальны или почти оптимальны, хотя имеются существенные разрывы между наилучшей известной нижней границей, лучшим известным алгоритмом для задачи с одним истоком в сетях, которые не содержат отрицательных циклов, и для задачи всех пар вершин в сетях с неотрицательными весами.

Все эти алгоритмы основаны на небольшом количестве абстрактных действий и могут быть приведены в общей постановке. Конкретнее, единственные операции, которые выполняются над весами ребер - это сложение и сравнение: любая постановка, в которой эти операции имеют смысл, может служить платформой для алгоритмов поиска кратчайших путей. Как уже было сказано, эта точка зрения объединяет алгоритмы для вычисления транзитивного замыкания орграфа с алгоритмами поиска кратчайших путей в сетях. Сложность, привносимая отрицательными весами ребер, соответствует свойству монотонности для этих абстрактных операций: если можно гарантировать, что сумма двух весов никогда не меньше любого из весов, то можно использовать алгоритмы из разделов 21.2-21.4. Если же подобную гарантию дать нельзя, необходимо использовать алгоритмы из раздела 21.7. Инкапсуляция этих соображений в АТД не составляет особого труда и расширяет применимость данных алгоритмов.

Задачи поиска кратчайших путей выводят нас на перепутье между элементарными алгоритмами обработки графов и задачами, которые мы не можем решить. Это первые в ряду других классов аналогичных задач, в числе которых находятся задачи о сетевых потоках и линейное программирование. Как и при поиске кратчайших путей, в этих областях имеется четкая грань между легкими и трудноразрешимыми задачами. Имеются не только многочисленные эффективные алгоритмы, пригодные при соответствующих ограничениях, но и многочисленные возможности для поиска лучших алгоритмов - а также случаи, когда встречаются однозначно NP-трудные задачи.

Многие такие задачи были подробно рассмотрены как задачи из области исследования операций еще до появления компьютеров и соответствующих им алгоритмов. Исторически исследование операций занималось общими математическими и алгоритмическими моделями, а компьютерные науки - конкретными алгоритмическими решениями и базовыми абстракциями, которые могут как вылиться в эффективные реализации, так и помочь построению общих решений. И модели из исследования операций, и базовые алгоритмические абстракции компьютерных наук применялись для разработки компьютерных реализаций, которые могут решать практические задачи большого размера. Поэтому в некоторых областях трудно провести четкую границу между исследованием операций и компьютерными науками. Например, в обеих областях исследователи до сих пор ищут эффективные решения задач вроде поиска кратчайших путей. По мере рассмотрения все более трудных задач можно разрабатывать классические методы, опираясь на результаты обеих этих областей.

Лекция 22. Потоки в сетях

Графы, орграфы и сети - это лишь математические абстракции, однако эти абстракции приносят практическую пользу, поскольку позволяют решать множество важных задач. В этой главе мы расширим модель решения сетевых задач, чтобы включить динамические ситуации, где по сети как бы движутся материальные потоки, а различным маршрутам назначены различные стоимости. Такие расширения позволяют решать удивительно широкие классы задач с обширным спектром применений.

Мы увидим, что с этими задачами и приложениями можно справиться с помощью нескольких естественных моделей, сводимых одна к другой. Существует несколько различных, хотя технически эквивалентных, способов формулирования базовых задач. Для реализации алгоритмов решения любых этих задач мы выберем две конкретные задачи, разработаем эффективные алгоритмы их решения, а затем разработаем алгоритмы для решения других задач с помощью сведения их к уже известным задачам.

В реальной жизни мы не всегда располагаем свободой выбора, как в этом идеализированном сценарии, поскольку сводимость одной задачи к другой доказана не для каждой пары задач и поскольку известно немного оптимальных алгоритмов решения любой из этих задач. Бывает, что еще не найдено прямое решение заданной задачи, и бывает, что еще не найдено эффективное сведение для заданной пары задач. Формулировка задачи о сетевых потоках, которой мы займемся в данной главе, имеет успех не только потому, что к ней легко сводятся многие практические задачи, но и потому, что было разработано множество эффективных алгоритмов решения базовых задач о потоках в сетях.

Приведенные ниже примеры демонстрируют диапазон задач, которые можно решить с помощью моделей сетевых потоков, алгоритмов и реализаций. Их можно разбить на общие категории, известные как задачи распределения (distribution), сопоставления (matching) и сечения (cut); и мы поочередно рассмотрим каждую из них. Мы не будем заострять внимание на конкретных деталях этих примеров, а выделим несколько различных взаимосвязанных задач. Ниже в этой главе, когда мы дойдем до разработки и реализации алгоритмов, будут даны строгие формулировки многих из упомянутых здесь задач.

В задачах распределения выполняется перемещение объектов из одного места сети в другое. Распределяются ли гамбургеры и цыплята в экспресс-закусочные, или игрушки и одежда в магазины уцененных товаров по автомагистралям страны, или программные продукты по компьютерам, или же биты информации по сетям связи для отображения на мониторах во всех уголках света - по сути, это одна и та же задача. Задачи распределения имеют дело с проблемами, возникающими при выполнении масштабных и сложных операций. Алгоритмы их решения широко применяются и играют важную роль в многочисленных приложениях.

Распределение товаров. У компании имеются фабрики, где производятся товары, оптовые базы для временного хранения товаров и розничные торговые точки, в которых товары продаются. Компания должна регулярно поставлять товары с фабрик в розничные торговые точки через оптовые базы, используя каналы распределения, которые обладают различными пропускными способностями и различными стоимостями доставки. Можно ли так организовать доставку товаров со складов в розничные торговые точки, чтобы везде удовлетворить спрос? Как определить маршрут наименьшей стоимости? Пример задачи распределения приведен на рис. 22.1.

На рис. 22.2 показана транспортная задача (transportation problem) - частный случай задачи распределения товаров, из которой убраны центры распределения и пропускные способности каналов. Эта версия задачи распределения товаров важна сама по себе и представляет интерес (как мы увидим в разделе 22.7) не только в плане непосредственного применения, но потому, что она не является каким-то " специальным случаем " - вообще-то по сложности решения она эквивалентна общей версии задачи.

Обмен данными. Коммуникационная сеть получает некоторое множество запросов на передачу сообщений между серверами, подсоединенными к этой сети через каналы связи (абстрактные кабели), которые передают данные с различной скоростью. Какова максимальная скорость передачи информации между двумя конкретными серверами в такой сети? Если с каждым каналом передачи данных связана некоторая стоимость передачи, то каков самый дешевый способ передачи данных с заданной скоростью, которая меньше максимально возможной?

Транспортные потоки. Городское управление должно разработать план аварийной эвакуации людей из города. Каково минимальное время для эвакуации города, если мы можем регулировать для этого транспортные потоки? Ответственные за планирование дорожного движения могут ставить подобные вопросы, обумывая решение, какие новые дороги, мосты или туннели могут смягчить проблему уличного движения в часы пик или в выходные дни.

В задаче сопоставления сеть представляет возможные способы соединения пар вершин. Необходимо выбрать такие возможные соединения (в соответствии с указанным критерием), чтобы не выбрать ни одну из вершин дважды. Другими словами, выбранное множество ребер определяет способ попарного соединения вершин. Можно сопоставлять колледжи и студентов, вакантные рабочие места и соискателей, предметы и свободные часы в школе или членов Конгресса США (Государственной Думы России, Верховной Рады Украины) и различные комитеты. В каждой из таких ситуаций возможны самые разнообразные критерии, определяющие характер выбора.

Задача о трудоустройстве. Служба трудоустройства организует интервью для группы студентов с некоторым количеством компаний, в результате чего появляется ряд предложений работы. Если считать, что интервью с последующим предложением работы представляет взаимную заинтересованность студента и компании, то для обеих сторон выгодно добиваться максимального трудоустройства. Из примера на рис. 22.3 видно, что эта задача может оказаться весьма сложной.

Задача сопоставления с минимальным расстоянием. Пусть даны два множества, содержащие по N точек; и нужно найти множество отрезков, соединяющих попарно точки из обоих множеств, чтобы суммарная длина отрезков была минимальна. Одним из приложений этой чисто геометрической задачи является радарная система слежения. Каждый поворот радиолокатора дает множество точек, соответствующих самолетам. Мы полагаем, что самолеты находятся достаточно далеко друг от друга, и тогда решение этой задачи позволяет связать положение каждого самолета при одном обороте радиолокатора с его позицией на следующем обороте, что дает возможность получить пути передвижения всех самолетов. К этой схеме сводятся некоторые другие приложения, связанные с выборкой данных.

 Задача распределения


Рис. 22.1.  Задача распределения

В этом примере задачи распределения имеются три вершины предложения (от 0 до 2), четыре распределительных пункта (от 3 до 6), три вершины потребления (от 7 до 9) и двенадцать каналов. У каждой вершины предложения своя скорость производства, у каждой вершины потребления своя скорость потребления, а у каждого канала своя максимальная пропускная способность и стоимость доставки единицы продукции. Нужно так минимизировать стоимость доставки по каналам (без превышения пропускной способности каналов), чтобы общая скорость извлечения материалов из каждой вершины предложения была равна скорости производства; чтобы общая скорость поставки в вершины потребления была равна скорости его потребления; и чтобы общая скорость поступления в каждый распределительный пункт была равна скорости вывоза.

 Транспортная задача


Рис. 22.2.  Транспортная задача

Транспортная задача похожа на задачу распределения, только в ней нет ограничений на пропускные способности каналов и распределительных пунктов. В данном примере имеются пять вершин снабжения (от 0 до 4), пять вершин потребления (от 5 до 9) и двенадцать каналов. Нужно найти самый дешевый способ распределения материала по каналам, чтобы предложения соответствовали спросу.

В частности, нужно присвоить каналам такие веса (скорость распределения), чтобы сумма весов исходящих ребер была равна поставкам в каждую снабжающую вершину; сумма весов входящих ребер равна суммарной потребности каждой потребляющей вершины; а общая стоимость (сумма произведений веса на стоимость для всех ребер) должна быть минимальной при всех таких вариантах присваивания.

 Вопросы трудоустройства


Рис. 22.3.  Вопросы трудоустройства

Предположим, что шесть студентов ищут работу, а шесть компаний намерены принять на работу по одному студенту. Эти два списка (один упорядочен по студентам, а другой по компаниям) образуют список предложений работы, который отражает взаимный интерес в сопоставлении студентов и предлагаемых работ. Можно ли так сопоставить студентов и вакансии, чтобы каждое вакантное место было заполнено, а каждый студент получил работу? Если нельзя, то каково максимальное количество таких сопоставлений?

В задаче сечения (см. пример на рис. 22.4) удаляются ребра, чтобы разбить сеть на две или большее количество частей. Задачи сечения непосредственно связаны с фундаментальными вопросами связности графа, которые были рассмотрены в лекция №18. В этой главе мы познакомимся с центральной теоремой, которая демонстрирует удивительную взаимосвязь задачи сечения и задачи о сетевых потоках, которая существенно расширяет применимость алгоритмов вычисления сетевых потоков.

 Отсечение линий снабжения


Рис. 22.4.  Отсечение линий снабжения

Здесь представлена система дорог, соединяющих базу снабжения армии (верху) с войсками (внизу). Черные точки означают план бомбовых ударов противника, которые должны отделить войска от снабжения. Противник стремится минимизировать стоимость бомбардировок (возможно, исходя из предположения, что стоимость отсечения ребра пропорциональна его ширине), а армия стремится разработать такую сеть дорог, чтобы максимизировать минимальную стоимость бомбардировок. Эта же модель полезна для повышения надежности сетей связи и во множестве других приложений.

Надежность сети. Телефонную сеть можно представить в виде некоторого множества кабелей, которые соединяют телефонные аппараты с помощью коммутаторов так, что возможен коммутируемый путь через междугородные линии связи, соединяющий любые два заданных телефона. Каково максимальное количество междугородных линий, которые можно отключить без нарушения связи между любой парой телефонных коммутаторов?

Отсечение линий снабжения. Страна ведет военные действия и осуществляет снабжение войск через систему взаимосвязанных дорог. Противник может прервать снабжение войск, нанося по дорогам бомбовые удары, причем количество бомб пропорционально ширине дороги. Какое минимальное количество бомб понадобится противнику, чтобы лишить войска снабжения?

С каждым из указанных выше приложений непосредственно связано множество сопутствующих вопросов, и имеются другие родственные модели, такие как задачи календарного планирования, описанные в лекция №21. В этой главе мы рассмотрим и другие примеры, но детально изучим лишь малую часть важных взаимосвязанных практических задач.

Изучаемая в данной главе модель сетевых потоков важна не только потому, что она ставит две четко сформулированные задачи, к которым сводятся многие другие практические задачи, но и потому, что существуют эффективные алгоритмы решения этих двух задач. Эта широта охвата привела к разработке многочисленных алгоритмов и реализаций. Решения, которые мы рассмотрим, демонстрируют противоречие между поисками универсальных приложений и поисками эффективных решений конкретных задач. Изучение алгоритмов вычисления сетевых потоков - увлекательное занятие, поскольку оно подводит нас значительно ближе к компактным и элегантным реализацям, которые достигают обеих целей.

В рамках модели сетевых потоков мы рассмотрим две конкретные задачи: задачу о максимальном потоке и задачу о потоке минимальной стоимости. Мы увидим взаимосвязь этих задач с моделью кратчайшего пути из лекция №21, с моделью линейного программирования из части VIII, а также с множеством моделей конкретных задач, в том числе и описанных выше.

На первый взгляд может показаться, что многие из этих задач не имеют ничего общего с задачами о потоках в сетях. Выявление связи с известными задачами часто является наиболее важным шагом при разработке решения задачи. Более того, этот шаг часто важен еще и потому, что перед разработкой реализаций необходимо (как обычно в случае алгоритмов на графах) определить тонкую грань, которая отделяет тривиальные задачи от трудноразрешимых. Внутренняя структура задач и взаимосвязь между задачами, которые мы рассмотрим в этой главе, обеспечивают удобный контекст для ответов на подобные вопросы.

В грубой классификации алгоритмов, начатой еще в лекция №17, алгоритмы, которые будут изучаться в этой главе, относятся к " легким " , т.к. существуют простые реализации, которые гарантированно выполняются за время, пропорциональное полиному от размера сети. Другие реализации не гарантируют полиномиального времени выполнения в худшем случае, однако они компактны, элегантны и доказали на практике свою пригодность для решения широкого круга практических задач, наподобие описанных выше. Мы рассмотрим их подробно в силу их несомненной полезности. Исследователи по-прежнему заняты поисками более быстродействующих алгоритмов, которые позволят решать более крупные задачи, а также снизить затраты в критических приложениях. Идеальные оптимальные алгоритмы решения задач о сетевых потоках еще не открыты.

С другой стороны, известно, что некоторые задачи, которые сводятся к задачам о сетевых потоках, более эффективно решаются с помощью специализированных алгоритмов. В принципе, мы могли бы заняться реализацией и совершенствованием таких особых алгоритмов. В отдельных случаях такой поход вполне удобен, однако эффективные алгоритмы решения множества других задач (отличных от сведения к сетевым потокам) не существуют. Но даже в тех случаях, когда известны специализированные алгоритмы, разработка реализаций, способных превзойти удачные программы обработки сетевых потоков, может оказаться очень сложной. А кроме того, исследователи постоянно со-вершенствовуют алгоритмы обработки сетевых потоков, и они вполне могут превзойти известные специальные методы решения конкретной практической задачи.

С другой стороны, задачи о сетевых потоках представляют собой специальные случаи еще более общих задач линейного программирования (LP), о которых речь пойдет в части VIII. Для решения задач о сетевых потоках в сетях можно воспользоваться алгоритмом решения LP-задач (и многие так и делают), но все же рассматриваемые ниже алгоритмы обработки сетевых потоков намного проще и эффективнее, чем построенные на решении LP-задач. Однако исследователи продолжают совершенствовать программы решения LP-задач, и не исключена возможность того, что качественный алгоритм решения LP-задачи, будучи использованным для решения задач о сетевых потоках, сможет оказаться эффективнее алгоритмов, которые мы рассматриваем в данной главе.

Классические решения задач о сетевых потоках тесно связаны с другими, уже изученными нами, алгоритмами на графах, и мы можем написать на удивление компактные программы их решения, воспользовавшись разработанными ранее алгоритмическими инструментами. Как мы уже не раз видели, качественные алгоритмы и структуры данных могут существенно снизить время выполнения программ. Поиск методов разработки более совершенных реализаций классических обобщенных алгоритмов продолжается, и время от времени появляются новые подходы.

В разделе 22.1 мы рассмотрим основные свойства транспортных сетей, где веса ребер интерпретируются как пропускные способности, и свойства потоков, представленных вторым множеством весов ребер, которые удовлетворяют некоторым естественным условиям. Далее мы рассмотрим задачу о максимальном потоке, в которой вычисляется лучший (в некотором специальном техническом смысле) поток. В разделах 22.2 и 22.3 мы рассмотрим два подхода к решению задачи о максимальном потоке и несколько различных реализаций ее решения. Многие из алгоритмов и структур данных, которые мы рассматривали ранее, непосредственно связаны с разработкой эффективного решения задачи о максимальном потоке. У нас еще нет наилучшего алгоритма решения задачи о максимальном потоке, так что мы пока рассмотрим конкретные решения, используемые на практике. Для демонстрации применимости задачи о максимальном потоке в разделе 22.4 мы рассмотрим другие ее формулировки, а также другие варианты сведения.

Алгоритмы и реализации для задачи о максимальном потоке подготавливают нас к обсуждению еще более важной и более общей задачи о потоке минимальной стоимости, где ребрам присваиваются стоимости (еще одно множество весов ребер), и определяются стоимости потоков, а затем ищется решение задачи о максимальном потоке, обладающее минимальной стоимостью. Мы рассмотрим классическое общее решение задачи о потоке минимальной стоимости, известное как алгоритм вычеркивания циклов, а затем, в разделе 22.6, дадим конкретную реализацию алгоритма вычеркивания циклов, известную как сетевой симплексный алгоритм. В разделе 22.7 мы обсудим сведения к задаче о потоке минимальной стоимости, которые включают, помимо прочих, все только что описанные приложения.

Алгоритмы вычисления сетевых потоков - удобная тема для завершения данной книги по нескольким причинам. Это как бы вознаграждение за изучение базовых алгоритмических инструментов, таких как связные списки, очереди с приоритетами и общие методы поиска на графах. Классы обработки графов, представленные в этой книге, приводят к компактным и эффективным реализациям классов для решения задач о сетевых потоках. Эти реализации еще больше расширяют возможности решения задач и могут быть непосредственно использованы в многочисленных практических приложениях. А изучение их возможностей и ограничений позволяет установить контекст для поиска более совершенных алгоритмов решения еще более сложных задач - именно этим вопросам посвящена часть VIII.

Транспортные сети

Наше описание алгоритмов вычисления сетевых потоков мы начнем с идеализированной физической модели, которая наглядно продемонстрирует несколько фундаментальных понятий. Допустим, что имеется система взаимосвязанных нефтепроводных труб различных диаметров, а на соединениях труб установлены вентили, управляющие движением потоков, как показано на рис. 22.5. Далее мы будем предполагать, что в системе трубопроводов имеется единственный исток (допустим, нефтяное месторождение) и единственный сток (к примеру, нефтеперерабатывающий завод), в который попадают все потоки. В каждом соединении потоки нефти находятся в равновесии: объем поступающей нефти равен объему вытекающей нефти. Мы будем измерять потоки и пропускную способность труб в одних и тех же единицах (например, в галлонах в секунду).

Если общая пропускная способность труб, входящих в каждый вентиль, равна общей пропускной способности выходящих труб, то решать вообще нечего: нужно просто заполнить по максимуму все трубы. Иначе будут заполнены не все трубы, но нефть будет течь по трубам в соответствии с настройками вентилей и свойством, что объем нефти, поступающей в каждое соединение, равен объему вытекающей нефти. А из баланса в соединениях следует и общий баланс в сети: в лемме 22.1 будет показано, что объем нефти, втекающей в сток, равен объему нефти, вытекающей из истока. А, как показано на рис. 22.6, настройки вентилей на соединениях могут оказывать нетривиальное влияние на потоки в сети. И вот нас интересует следующий вопрос: какие настройки вентилей обеспечат максимальный поток нефти из истока в сток?

Эту ситуацию можно непосредственно смоделировать при помощи сети (взвешенный орграф, согласно определению из лекция №21) с одним истоком и одним стоком. Ребра сети соответствуют трубам, вершины соответствуют соединениям труб с вентилями, которые регулируют потоки нефти в исходящие ребра, а веса ребер соответствуют пропускной способности труб. Мы считаем ребра ориентированными, т.е. нефть в каждой трубе может течь только в одном направлении. По каждой трубе проходит определенный поток, который меньше или равен ее пропускной способности, и каждая вершина удовлетворяет условию баланса: входной поток равен выходному потоку.

 Сетевые потоки


Рис. 22.5.  Сетевые потоки

Транспортная сеть - это взвешенная сеть, в которой веса ребер интерпретируются как пропускные способности (вверху). Требуется вычислить второе множество весов ребер, ограниченное пропускными способностями, которые мы называем потоками. Внизу показаны наши соглашения относительно вычерчивания транспортных сетей. Ширина каждого ребра пропорциональна его пропускной способности; объем потока в каждом ребре показан заштрихованной частью; поток на наших рисунках всегда направлен из единственного истока вверху в единственный сток внизу; пересечения (как между ребрами 1-4 и 2-3 в рассматриваемом примере) не представляют вершин, если не обозначены как вершины. За исключением истока и стока, в каждой вершине входной поток равен выходному потоку: например, в вершину 2 входят 2 единицы потока (из вершины 0) и выходят 2 единицы потока (1 единица в вершину 3 и 1 единица в вершину 4).

 Управление потоками в сети


Рис. 22.6.  Управление потоками в сети

Открыв вентили вдоль пути 0-1-3-5, можно инициировать в этой сети поток мощностью 2 единицы (вверху); открыв вентили вдоль пути 0-2-4-5, получим еще 1 единицу потока (в центре). Звездочками отмечены заполненные ребра. Поскольку ребра 0-1, 2-4 и 3-5 заполнены, прямого способа увеличить поток из 0 в 5 не существует, но если еще открыть вентиль в вершине 1, чтобы направить часть потока в ребро 1-4 , то будет задействовано ребро 4-5, благодаря чему будет получен максимальный поток в данной сети (внизу).

Такая абстракция транспортной сети представляет собой полезную модель решения задач, которая непосредственно применима к широкому кругу ситуаций, а косвенно - к еще более широкому кругу. Иногда для наглядности мы представляем нефть, текущую по нефтепроводу, но можно представлять и товары, перемещаемые по каналам распределения, и другие ситуации.

Потоковая модель непосредственно применима к задаче распределения - при этом значения потоков интерпретируются как их интенсивность, и транспортная сеть описывает потоки товаров точно так же, как и потоки нефти. Например, потоки на рис. 22.5 можно интерпретировать так: два элемента в единицу времени пересылаются из вершины 0 в 1 и из 0 в 2, один элемент в единицу времени - из 0 в 2, один элемент в единицу времени - из 1 в 3 и из 1 в 4 и т.д.

Другой способ интерпретации потоковой модели для задачи распределения - потоки интерпретируются как объемы товаров, и тогда сеть описывает только одноразовую рассылку товара. Например, потоки на рис. 22.5 можно интерпретировать, как трехэтапную поставку четырех единиц товара из вершины 0 в 5: сначала пересылаются две единицы товара из 0 в 1 и две единицы из 0 в 2, так что в каждой из этих вершин остается по две единицы товара. Далее выполняется пересылка по одной единице товара из 1 в 3, из 1 в 4 , из 2 в 3 и из 2 в 4 , и в каждой из вершин 3 и 4 остается по две единицы товара. Рассылка завершается доставкой двух единиц товара из вершины 3 в 5 и двух единиц из 4 в 5.

Как и в случае использования расстояния в алгоритмах поиска кратчайшего пути, мы вполне можем, когда это удобно, отказаться от любой физической интерпретации, т.к. все рассматриваемые нами определения, леммы и алгоритмы основаны исключительно на абстрактной модели, которая не обязана подчиняться физическим законам. Вообще-то основная причина нашего интереса к модели сетевых потоков в том, что она позволяет решать методом сведения множество других задач - в этом мы убедимся в разделах 22.4 и 22.6. В силу такой универсальности целесообразно дать точные определения терминов и понятий, с которыми мы ознакомились неформально.

Определение 22.1. Сеть с вершиной s, выбранной в качестве истока, и с вершиной t, выбранной в качестве стока, называется st-сетью.

В этом определении слово " выбранный " означает, что вершина s не обязательно должна быть истоком (вершина без входящих ребер), а вершина t - стоком (вершина без исходящих ребер), но мы будем рассматривать их именно так, игнорируя в наших рассуждениях (и алгоритмах) ребра, входящие в s, и ребра, исходящие из t. Во избежание путаницы в наших примерах мы будем рассматривать сети с одним истоком и стоком, а более общие ситуации рассмотрим в разделе 22.4. Вершины s и t мы будем называть, соответственно, " истоком " и " стоком " st-сети, поскольку они выполняют в сети именно эти роли. Остальные вершины сети мы будем называть внутренними вершинами.

Определение 22.2. Транспортная сеть (flow network) - это st-сеть с положительными весами ребер, которые мы будем называть пропускными способностями (capacity). Поток (flow) в транспортной сети - это множество неотрицательных весов ребер, которые называются реберными потоками (edge flow) и удовлетворяют условиям: поток в любом ребре не превышает пропускную способность этого ребра, а суммарный поток, входящий в каждую внутреннюю вершину, равен суммарному потоку, выходящему из этой вершины.

Мы будем называть суммарный поток в вершину притоком (inflow) этой вершины, а суммарный поток из вершины - оттоком (outflow) этой вершины. По соглашению потоки в ребрах, входящих в исток, и потоки в ребрах, исходящих из стока, равны нулю, а в лемме 22.1 будет показано, что отток истока всегда равен притоку стока, и этот поток мы будем называть мощностью (value) сети. Имея эти определения, нетрудно дать формальное определение нашей основной задачи.

Максимальный поток. Для заданной st-сети необходимо найти такой поток, что никакой другой поток из s в t не имеет большего значения. Для краткости будем называть такой поток максимальным потоком (maxflow), а задачу вычисления такого потока в сети будем называть задачей о максимальном потоке. В некоторых приложениях достаточно знать только величину максимального потока, но обычно требуется знать структуру потока (величины каждого реберного потока), обеспечивающего эту величину.

На ум тут же приходят различные варианты этой задачи. Можно ли рассматривать сеть с несколькими истоками и стоками? А как насчет сетей без истоков и стоков? Может ли поток проходить по ребрам в обоих направлениях? Возможны ли ограничения на пропускную способность вершин вместо ограничений на ребра (или наряду с ними)? Для алгоритмов на графах характерно то, что отделение тривиальных ограничений от ограничений, влекущих далеко идущие последствия, само по себе может оказаться трудной задачей. После того, как мы рассмотрим алгоритмы решения фундаментальной задачи, в разделах 22.2 и 22.3 мы исследуем эту задачу и приведем примеры сведения к задаче о максимальном потоке множества других с виду различных задач.

Характерным свойством потоков является условие локального баланса: в каждой внутренней вершине приток равен оттоку. На пропускные способности ребер такое ограничение не накладывается, и вообще-то именно нарушение баланса между суммарной пропускной способностью входящих ребер и суммарной пропускной способностью исходящих ребер и характеризует задачу о максимальном потоке. Условие баланса должно выполняться в каждой внутренней вершине, но оказывается, что это локальное свойство определяет и глобальное перемещение по сети. Однако пока это гипотеза, которую еще нужно доказать.

Лемма 22.1. Любой st-поток обладает тем свойством, что отток из вершины s равно притоку в вершину t.

Доказательство. (Термин st-поток (st-flow), означает " поток в st-сети " .) Добавим в сеть ребро из фиктивной вершины в вершину s, с потоком и пропускной способностью, равными оттоку вершины s, и ребро из вершины t в другую фиктивную вершину, с потоком и пропускной способностью, равными притоку вершины t. Теперь по индукции можно доказать более общее свойство: приток равен оттоку для любого множества вершин (без фиктивных вершин).

По условию локального баланса это свойство верно для любой одиночной вершины. Предположим теперь, что это свойство выполняется для некоторого множества вершин S, и добавим в это множество одиночную вершину v - получим множество S' = S U {v}. При вычислении притока и оттока для S' обратим внимание на то, что каждое ребро, ведущее из вершины v в некоторую вершину из S, уменьшает отток (из вершины v) на ту же величину, на какую оно уменьшает приток (в S); каждое ребро, ведущее в v из некоторой вершины множества S, уменьшает приток (в вершину v) на ту же величину, на какую уменьшает отток (из S); все другие ребра обеспечивают приток или отток для множества S' тогда и только тогда, когда они делают это для S или для v. Таким образом, для S' приток равен оттоку, а величина потока равна сумме величин потоков S и v, минус сумма потоков в ребрах, соединяющих v с вершинами из S (в любом направлении).

Применяя это свойство к множеству всех вершин сети, получим, что приток в исток из связанной с ней фиктивной вершины (который равен оттоку истока) равен оттоку из стока в связанную с ним фиктивную вершину (который равен притоку стока).

Следствие. Величина потока объединения двух множеств вершин равна сумме величин каждого из потоков этих двух множеств минус сумма весов ребер, соединяющих вершину одного множества с вершиной другого множества.

Доказательство. Приведенное выше доказательство для множества S и вершины v работает и в том случае, когда мы заменяем вершину v некоторым множеством T (которое не пересекается с S). Иллюстрация этой леммы приведена на рис. 22.7.

В доказательстве леммы 22.1 можно обойтись и без фиктивных вершин, добавить в любую транспортную сеть ребро из t в s с пропускной способностью, равной мощности сети, и знать, что приток равен оттоку для любого множества узлов этой расширенной сети. Такой поток называется циркуляцией (circulation), а подобное построение показывает, что задача о максимальном потоке сводится к задаче поиска такой циркуляции, которая обеспечивает максимальный поток в заданном ребре.

 Баланс потоков


Рис. 22.7.  Баланс потоков

Это иллюстрация сохранения баланса потоков при объединении множеств вершин. Два меньших овала соответствуют двум произвольным непересекающимся множествам вершин, а буквы представляют потоки в множествах ребер: A - величина потока в левое множество, кроме притока из правого множества, х - величина потока в левое множество из правого множества и т.д. Если имеет место баланс потоков между двумя множествами, то должно выполняться равенство A + x = B + у для левого множества и C + y = D + x для правого множества. Суммируя эти два равенства и исключая сумму x + у, мы получим A + C = B + D, - т.е. для объединения двух множеств приток равен оттоку.

В некоторых ситуациях подобная формулировка упрощает наши рассуждения. Например, как показано на рис. 22.8, она приводит к интересному альтернативному представлению потоков как множества циклов.

Если задано множество циклов и величина потока для каждого цикла, то легко вычислить соответствующую циркуляцию: нужно пройти по каждому циклу и добавить указанную величину потока к каждому ребру. Обратное свойство более интересно: можно найти множество циклов (и величину потока для каждого из них), которое эквивалентно любой заданной циркуляции.

Лемма 22.2. (Теорема разложения потока). Любую циркуляцию можно представить в виде потока в некотором множестве из не более чем E направленных циклов.

Доказательство. Этот результат можно получить с помощью простого алгоритма: будем повторять следующий процесс, пока имеются ребра, по которым проходят потоки. Начнем с произвольного ребра с ненулевым потоком, пройдем по любому ребру с ненулевым потоком, исходящему из конечной вершины первого ребра, и продолжаем эту процедуру, пока не попадем в уже посещенную вершину (обнаружен цикл). Выполним обратный обход по обнаруженному циклу, найдем ребро с минимальным потоком, а затем уменьшим потоки в каждом ребре цикла на это значение. Каждое повторение этого процесса обнуляет поток по меньшей мере в одном ребре, поэтому в сети может быть не более E циклов.

Процесс, описанный в доказательстве, показан на рис. 22.9. В случае st-потоков применение этой леммы к циркуляции, полученной добавлением ребра из s в t, позволяет сделать вывод, что любой st-поток можно представить в виде потока вдоль множества не более E ориентированных путей, каждый из которых есть либо путь из s в t, либо цикл.

Следствие. Для максимального потока любой st-сети подграф, индуцированный ненулевыми потоками, является ациклическим.

Доказательство. Циклы, не содержащие ребро t- s, не меняют величину потока, поэтому можно обнулить поток в любом таком цикле, не меняя величину общего потока.

Следствие. Максимальный поток любой st-сети можно представить в виде потока вдоль множества не более чем E ориентированных путей из s в t.

Доказательство. Очевидно.

 Представление в виде циклических потоков


Рис. 22.8.  Представление в виде циклических потоков

Здесь показано, что приведенную слева циркуляцию можно разложить на четыре цикла 1-3-5-4-1, 0-1-3-5-4-2-0, 1-3-5-4-2-1 и 3-5-4-3 с весами, соответственно, 2, 1, 1 и 3. Ребра каждого цикла показаны в соответствующем столбце, а суммирование весов каждого ребра в каждом цикле, в который оно входит (по соответствующей строке), дает его вес в циркуляции.

 Процесс разложения потока на циклы


Рис. 22.9.  Процесс разложения потока на циклы

Чтобы разложить произвольную циркуляцию на множество циклов, мы многократно выполняем следующий процесс: проходим по любому пути, пока не встретим какой-либо узел второй раз, затем находим минимальный вес в обнаруженном цикле, после чего вычитаем минимальный вес из весов каждого ребра обнаруженного цикла и удаляем каждое ребро, вес которого стал нулевым. Например, на первой итерации мы проходим по пути 0-1-3-5-4-1 и обнаруживаем цикл 1-3-5-4-1, потом вычитаем 1 из весов всех ребер цикла, и в результате удаляем из цикла ребро 4-1, так как его вес стал нулевым. На второй итерации мы удаляем ребра 0-1 и 2-0, на третьей - 1-3, 4-2 и 2-1, а на последней итерации удаляем 3-5, 5-4 и 4-3.

Эти свойства позволяют получить представление о природе потоков, что будет полезно при разработке и анализе алгоритмов вычисления максимальных потоков.

С одной стороны, мы можем рассмотреть более общую формулировку задачи вычисления максимального потока в сети с несколькими истоками и стоками. Это позволит использовать наши алгоритмы для более широкого спектра приложений. С другой стороны, мы можем рассмотреть специальные случаи, например, ациклические сети. Это может облегчить решение задачи. Хотя, как мы убедимся в разделе 22.4, по трудности решения эти варианты эквивалентны рассматриваемой нами версии. Поэтому в первом случае мы можем приспособить наши алгоритмы и приложения для более широкой области приложений, а во втором случае вряд ли найдется более легкое решение. В приведенных здесь иллюстрациях мы используем ациклические графы, поскольку легче понять такие примеры, когда направление потока естественное - сверху вниз, но наши реализации допускают существование сетей с циклами.

Для реализации алгоритма вычисления максимального потока мы воспользуемся классом GRAPH из лекция №20, но с указателями на более сложный класс EDGE. Вместо одного веса, как это было в лекция №20 и лекция №21, мы используем приватные члены данных pcap и pflow (пропускная способность и величина потока), а также общедоступные функции-элементы cap() и flow(), которые возвращают их значения. Хотя сети являются ориентированными графами, наши алгоритмы будут проходить по ребрам в обоих направлениях, поэтому мы используем представление в виде неориентированного графа из лекция №20 и функцию-член from, которая позволяет отличить ребро u-v от ребра v-u .

Такой подход позволяет отделить абстракцию, необходимую нашим алгоритмам (ребра идут в обоих направлениях), от конкретной структуры клиентских данных и ставит перед этими алгоритмами простую цель: присвоить элементам flow в ребрах клиента такие значения, которые максимизируют поток через сеть. Критическим моментом наших реализаций является замена абстракции сети, которая зависит от значений потока и реализуется функциями-членами класса EDGE. Мы рассмотрим реализацию класса EDGE в разделе 22.2 (программа 22.2).

Поскольку транспортные сети обычно разрежены, мы воспользуемся классом GRAPH на основе представления списками смежных вершин, как в реализации класса SparceMultiGRAPH из программы 20.5. Но более важно то, что типичные транспортные сети могут содержать параллельные ребра (различной пропускной способности), соединяющие две вершины. Класс SparceMultiGRAPH не требует специальных мер в подобных ситуациях, но в случае представления графа матрицей смежности клиентам придется объединить такие ребра в одно ребро.

В представлениях сетей, описанных в лекция №20 и 21, применялось соглашение, что веса представляются вещественными числами от 0 до 1. В этой главе мы полагаем, что веса (пропускные способности и величины потоков) представляют собой да-разрядные целые числа (от 0 до 2m - 1 ). Это сделано по двум основным причинам. Во-первых, нам часто придется проверять на равенство различные линейные комбинации весов, а в случае чисел с плавающей точкой это не всегда удобно. Во-вторых, время выполнения алгоритмов может зависеть от относительных значений весов, а параметр M = 2m удобен для ограничения значений весов. Например, отношение наибольшего веса к наименьшему ненулевому весу меньше M. Использование целочисленных весов - лишь один из многочисленных возможных вариантов (см., например, упражнение 20.8) при решении подобных задач.

Иногда удобно рассматривать ребра с неограниченной пропускной способностью, что означает, что мы не сравниваем поток с пропускной способностью такого ребра. В такой ситуации можно воспользоваться сигнальным значением с заведомо большим значением, чем величина любого потока.

Программа 22.1 представляет собой клиентскую функцию, которая проверяет, удовлетворяют ли потоки условию баланса в каждом узле, и если удовлетворяют, то возвращает значение мощности потока. Вызов этой функции можно включить в алгоритм вычисления максимального потока в качестве заключительной операции. Несмотря на все доверие к лемме 22.1, программистская осторожность требует также проверить, равен ли поток, вытекающий из истока, потоку, втекающему в сток. Иногда имеет смысл проверить, что ни в одном ребре поток не превосходит пропускную способность этого ребра и что структуры данных внутренне непротиворечивы (см. упражнение 22.12).

Программа 22.1. Проверка и вычисление мощности потока

Вызов flow(G, v) вычисляет разность между входным и выходным потоками в вершине v сети G. Вызов flow(G, s, t) проверяет величины сетевых потоков из истока (s) в сток (t). Если входной поток в некотором внутреннем узле не равен выходному потоку или если величина какого-либо потока меньше нуля, то возвращается 0; иначе возвращается величина потока.

template <class Graph, class Edge>
class check {
  public:
 static int flow(Graph &G, int v)
   { int x = 0; typename Graph::adjIterator A(G, v);
  for (Edge* e = A.beg(); !A.end(); e = A.nxt())
    x += e->from(v) ? e->flow() : -e->flow();
  return x;
   }
 static bool flow(Graph &G, int s, int t)
   { for (int v = 0; v < G.V(); v++)
    if ((v != s) && (v != t))
   if (flow(G, v) != 0) return false;
  int sflow = flow(G, s);
  if (sflow < 0) return false;
  if (sflow + flow(G, t) != 0) return false;
  return true;
   }
  };
   

 Транспортная сеть для упражнений


Рис. 22.10.  Транспортная сеть для упражнений

Эта транспортная сеть используется в нескольких упражнениях данной главы.

 Транспортная сеть с циклами


Рис. 22.11.  Транспортная сеть с циклами

Данная транспортная сеть похожа на сеть, изображенной на рис. 22.10, только направления двух ребер заменены на обратные, вследствие чего появлились два цикла. Эта транспортная сеть также рассматривается в нескольких упражнениях данной главы.

Упражнения

22.1. Найдите два различных максимальных потока в транспортной сети, изображенной на рис. 22.10.

22.2. Пусть все пропускные способности выражаются положительными целыми числами, меньшими M. Каков максимально возможный поток для произвольной st-сети с V вершинами и E ребрами? Дайте два ответа, в зависимости от того, допускаются ли параллельные ребра.

22.3. Приведите алгоритм решения задачи о максимальном потоке для случая, когда сеть образует дерево, если удалить сток.

22.4. Приведите семейство сетей с E ребрами и циркуляцией, в которых процесс, описанный в доказательстве леммы 22.2, порождает E циклов.

22.5. Напишите класс EDGE, представляющий пропускные способности и потоки в виде вещественных чисел от 0 до 1 с d цифрами после десятичной точки, где d - фиксированная константа.

22.6. Напишите программу, которая строит транспортную сеть, считывая из стандартного ввода ребра (пары целых чисел в интервале от 0 до V- 1) с целочисленными пропускными способностями. Считайте, что верхняя граница пропускной способности M не превосходит 220.

22.7. Добавьте в решение упражнения 22.6, возможность присваивать вершинам символические имена, а не номера (см. программу 17.10).

22.8. Найдите в интернете крупную транспортную сеть, которую можно использовать для тестирования алгоритмов вычисления потоков. Это могут быть сети перевозки материалов (автомобильные, железнодорожные, авиа), сети связи (телефонные или компьютерные сети передачи данных) или распределительные сети. Если пропускные способности не известны, продумайте подходящую модель для их добавления. Напишите программу, использующую интерфейс программы 22.2 для реализации транспортных сетей с вашими данными - возможно, на основе решения упражнения 22.7. При необходимости разработайте дополнительные приватные функции для очистки данных (см. упражнения 17.33-17.35).

22.9. Напишите генератор случайных разреженных сетей с пропускными способностями от 0 до 220, взяв за основу программу 17.7. Воспользуйтесь отдельным классом для пропускных способностей и разработайте две реализации, одна из которых генерирует равномерно распределенные пропускные способности, а другая - нормально распределенные. Разработайте клиентские программы, которые генерируют случайные сети для обоих типов распределений весов с подобранными значениями V и E, чтобы использовать их для выполнения эмпирических тестов на графах, полученных из различных распределений весов ребер.

22.10. Напишите генератор случайных насыщенных сетей с пропускными способностями от 0 до 220, взяв за основу программу 17.8 и генераторы пропускных способностей ребер из упражнения 22.9. Напишите клиентские программы генерации случайных сетей для обоих распределений весов с подобранными значениями V и E, чтобы использовать их для выполнения эмпирических тестов на графах, полученных из этих моделей.

22.11. Напишите программу, которая генерирует на плоскости V случайных точек, затем строит транспортную сеть с (двунаправленными) ребрами, соединяющими все пары точек, расположенных на расстоянии не более d одна от другой (см. программу 3.20), и устанавливает пропускную способность каждого ребра с помощью одной из случайных моделей, описанных в упражнении 22.9. Определите, как выбрать d, чтобы ожидаемое количество ребер было равно E.

22.12. Добавьте в программу 22.1 проверку, что величина потока каждого ребра не больше его пропускной способности.

22.13. Найти все максимальные потоки в сети, показанной на рис. 22.11. Приведите представление циклами для каждого из них.

22.14. Напишите функцию, которая считывает величины потоков и циклы (по одному в строке, в формате, представленном на рис. 22.8) и строит сеть с соответствующим потоком.

22.15. Напишите клиентскую функцию, которая находит представление циклами для потока сети, используя метод, описанный в доказательстве леммы 22.2, и выводит величины потоков и циклы (по одному в строке, в формате, представленном на рис. 22.8).

22.16. Напишите функцию, которая удаляет циклы из st-потока сети.

22.17. Напишите программу, которая назначает целочисленные потоки каждому ребру в любом заданном орграфе без истоков и стоков, чтобы получилась транспортная сеть, представляющая собой циркуляцию.

22.18. Пусть поток представляет собой товары, которые перевозят на грузовых машинах между городами, при этом под потоком в ребре u-v понимается количество товара, которое нужно доставить из города u в город v в течение дня. Напишите клиентскую функцию, которая выводит график работы для водителей грузовиков: где и сколько погрузить и где и сколько разгрузить. Считайте, что количество грузовиков неограничено, и распределительные пункты не начинают отгрузку, пока не получат весь товар.

Алгоритмы поиска максимального потока расширением пути

Эффективный способ решения задачи о максимальном потоке был разработан Л.Р. Фордом (L.R. Ford) и Д.Р Фалкерсоном (D.R. Fulkerson) в 1962 г. Это обобщенный метод с постепенным увеличением потоков вдоль путей от истока к стоку, на котором основано целое семейство алгоритмов. В классической литературе он известен под названием метода Форда-Фалкерсона; широкое распространение получил также более образный термин - метод расширения пути (augmenting path method).

Рассмотрим произвольный ориентированный путь (не обязательно простой) из истока в сток st-сети. Пусть x есть минимальное значение неиспользованной пропускной способности ребер на этом пути. Мы можем увеличить поток в сети по крайней мере на x, увеличив на данную величину поток во всех ребрах этого пути. Повторяя это действие, мы выполним первую попытку вычисления потока в сети: находим другой путь, увеличиваем поток вдоль этого пути и продолжаем, пока в каждом из путей, ведущих из истока в сток, не окажется хотя бы одно заполненное ребро (и мы не сможем увеличивать потоки этим способом). В одних случаях такой алгоритм вычисляет максимальный поток, но в других случаях это ему не удается. На рис. 22.6. показан неудачный случай.

Для исправления алгоритма, чтобы он всегда находил максимальный поток, мы рассмотрим более общий способ увеличения потока вдоль произвольного пути из истока в сток в неориентированном графе, соответствующем данной сети. Ребра любого такого пути - это либо прямые (forward) ребра, направления которых совпадают с направлением потока (при следовании по пути из истока в сток мы проходим по ребру из его начальной вершины в конечную вершину), либо обратные (backward) ребра, которые направлены против потока (при следовании из истока в сток мы проходим по ребру из его конечной вершины в начальную вершину). Тогда, если существует какой-либо путь без заполненных прямых ребер и без пустых обратных ребер, можно увеличить поток в сети, увеличивая потоки в прямых ребрах и уменьшая потоки в обратных ребрах. Величина, на которую можно увеличить поток, ограничена минимумом из неиспользованных пропускных способностей и потоков в обратных ребрах. На рис. 22.12 показан соответствующий пример. В новом потоке либо заполняется одно из прямых ребер пути, либо становится пустым одно из обратных ребер этого пути.

 Расширение потока по пути


Рис. 22.12.  Расширение потока по пути

Эта последовательность демонстрирует расширение потока в сети вдоль пути из прямых и обратных ребер. Начав с потока, изображенного на левой диаграмме, и выполняя операции слева направо, мы увеличиваем поток в ребре 0-2, а затем в ребре 2-3 (дополнительные потоки показаны черным цветом). Далее мы уменьшаем поток в ребре 1-3 (показан белым цветом), отводя его в ребро 1-4, а затем в 4-5 - в результате получается поток, показанный на правой диаграмме.

Описанный процесс лежит в основе классического алгоритма Форда-Фалкерсона вычисления максимального потока (метод расширения пути). Вот его краткая формулировка:

Начинаем с нулевого потока в любом месте сети. Увеличиваем поток вдоль произвольного пути из истока в сток, содержащего незаполненные ребра или непустые обратные ребра, и продолжаем так, пока в сети не останется ни одного такого пути.

Интересно, что этот метод всегда находит максимальный поток независимо от способа выбора путей. Подобно методу вычисления MST-дерева из лекция №20 и методу Беллмана-Форда поиска кратчайших путей из лекция №21, это обобщенный алгоритм, который полезен тем, что обосновывает правильность целого семейства более специальных алгоритмов. Для выбора путей годится любой метод.

На рис. 22.13 приведено несколько различных последовательностей расширяющих путей, каждая из которых приводит к максимальному потоку в сети. Ниже в этом разделе мы рассмотрим несколько алгоритмов, которые вычисляют последовательности расширяющих путей, и все они находят максимальный поток. Эти алгоритмы различаются по количеству расширяющих путей, которые они вычисляют, по длине путей и трудоемкости их поиска, однако все они реализуют алгоритм Форда-Фалкерсона и находят максимальный поток.

 Последовательности расширяющих путей


Рис. 22.13.  Последовательности расширяющих путей

На этих трех примерах мы расширяем поток вдоль различных последовательностей расширяющих путей, пока не сможем найти новые расширяющие пути. В каждом случае находится максимальный поток. Важная классическая теорема из теории сетевых потоков утверждает, что мы получаем максимальный поток в любой сети, независимо от того, какую последовательность путей мы используем (см. лемму 22.5).

Чтобы показать, что любой поток, вычисленный с помощью любой реализации алгоритма Форда-Фалкерсона, и в самом деле имеет максимальную величину, мы покажем, что этот факт эквивалентен другому ключевому факту, известному как теорема о максимальном потоке и минимальном сечении (maxflow-mincut theorem). Понимание этой теоремы - очень важный шаг к пониманию алгоритмов обработки транспортных сетей. Как следует из названия, теорема основана на непосредственной взаимосвязи между потоками и сечениями в сетях, поэтому мы начнем с определения терминов, которые имеют отношение к сечениям.

Напомним, что в соответствии с определением, данным в лекция №20, сечение, или разрез (cut), графа есть разбиение множества вершин графа на два не-пересекающихся подмножества, а перекрестное ребро (crossing edge) - это ребро, соединяющее вершину одного подмножества с вершиной другого подмножества. Применительно к транспортным сетям мы уточним эти определения следующим образом (см. рис. 22.14).

 Терминология, используемая при описании st-сечений


Рис. 22.14.  Терминология, используемая при описании st-сечений

В st-сети имеется один исток s и один сток t. st-сечение - это разбиение вершин на множество, содержащее вершину s (белые кружки), и множество, содержащее вершину t (черные кружки). Ребра, связывающие вершины одного множества с вершинами другого множества (выделены серым цветом), называются секущим множеством. Прямое ребро ведет из вершины множества, содержащего s, в вершину множества, содержащего t, а обратное ребро имеет противоположное направление. В показанном здесь секущем множестве имеются четыре прямых ребра и два обратных ребра.

Определение 22.3. st-сечение - это сечение, которое помещает вершину s в одно из множеств, а вершину t - в другое множество.

Каждое перекрестное ребро, соответствующее st-сечению, есть либо st-ребро, которое ведет из вершины, входящей во множество, где находится вершина s, в вершину, входящую во множество, где находится вершина t, либо ts-ребро, которое ведет в обратном направлении. Иногда множество перекрестных ребер называется секущим множеством (cut set). Пропускная способность st-сечения в транспортной сети есть сумма пропускных способностей ребер этого st-сечения, а поток через st-сечение есть разность между суммой потоков st-ребер и суммой потоков ts-ребер этого сечения. Удаление секущего множества делит связный граф на два связных компонента, удаляя все пути, соединяющие какую-либо вершину одного множества с вершиной другого множества. После удаления всех ребер, входящих в st-сечение, в сети не остается путей, соединяющих вершины s и t в соответствующем неориентированном графе, но возврат любого из них может воссоздать такой путь.

Сечения являются удобной абстракцией для приложений, перечисленных в начале текущей главы, где транспортная сеть описывает движение боеприпасов с базы в войска. Чтобы полностью прервать снабжение войск самым экономичным образом, противник должен решить следующую задачу.

Минимальное сечение. Для заданной st-сети требуется найти такое st-сечение, что пропускная способность любого другого сечения не меньше найденного. Для краткости мы будем называть такое сечение минимальным сечением (mincut), а задачу вычисления такого сечения в сети - задачей о минимальном сечении (mincut problem).

Задача о минимальном сечении представляет собой обобщение задач о связности графов, которые были кратко рассмотрены в лекция №18. Мы подробно рассмотрим конкретные взаимосвязи в разделе 22.4.

В формулировке задачи о минимальном сечении нет упоминания о потоках, и может показаться, что эти определения находятся в стороне от наших обсуждений алгоритма вычисления расширяющих путей. На первый взгляд, вычисление минимального сечения (множества ребер) выглядит более легкой задачей, чем вычисление максимального потока (назначение весов всем ребрам). Однако ключевым фактом этой главы является тесная связь задачи о максимальном потоке и задачи о минимальном сечении. Доказательством служит сам метод расширяющих путей в сочетании с двумя фактами о потоках и сечениях.

Лемма 22.3. Для любого st-потока поток через произвольное st-сечение равен величине этого потока.

Доказательство. Эта лемма непосредственно следует из обобщения леммы 22.1, которое упоминалось в доказательстве этой леммы (см. рис. 22.7). Добавим ребро t-s , поток в котором равен такому потоку, когда приток равен оттоку для любого множества вершин. Тогда для любого st-сечения имеется множество вершин Cs, содержащее вершину s, и множество вершин Ct, содержащее вершину t. Приток в Cs равен притоку в вершину s (величина потока) плюс сумма потоков в обратных перекрестных ребрах, а отток из Cs равен сумме потоков в прямых перекрестных ребрах. Приравнивание этих двух величин и дает искомый результат.

Лемма 22.4. Величина st-потока не может превышать пропускной способности никакого st-сечения.

Доказательство. Понятно, что поток через сечение не может быть больше пропускной способности этого сечения, так что результат непосредственно следует из леммы 22.3.

Другими словами, сечения представляют собой узкие места сети. Если в задаче армейского снабжения противник не может полностью отрезать вражеские войска от их снабжения, то он хотя бы будет уверен, что снабжение будет ограничено пропускной способностью любого заданного сечения. Естественно считать, что трудоемкость создания сечения в этом приложении пропорциональна его пропускной способности, и вражеской армии придется искать решение задачи о минимальном сечении. Но более важно то, что из этих фактов, в частности, следует, что ни один поток не может превышать пропускную способность любого минимального сечения.

Лемма 22.5. (Теорема о максимальном потоке и минимальном сечении). Максимальный поток из всех st-потоков в сети равен минимальной пропускной способности из всех st-сечений (рис. 22.15).

Доказательство. Достаточно найти такой поток и такое сечение, чтобы величина потока была равна пропускной способности сечения. Этот поток должен быть максимальным, т.к. величина никакого другого потока не может быть больше пропускной способности сечения, а сечение должно быть минимальным, т.к. пропускная способность никакого другого сечения не может быть меньше величины потока (лемма 22.4). Алгоритм Форда-Фалкерсона находит как раз такой поток и такое сечение: после завершения его работы нужно найти первое заполненное прямое или пустое обратное ребро в каждом пути из вершины s в вершину t графа. Пусть Cs - множество всех вершин, достижимых из s через неориентированный путь, который не содержит заполненного прямого или пустого обратного ребра, и пусть Ct - множество всех остальных вершин. Тогда вершина t должна принадлежать Ct, и (Cs , Ct) есть st-сечение, которое состоит исключительно из заполненных прямых или пустых обратных ребер. Поток через это сечение равен его пропускной способности (поскольку прямые ребра заполнены, а обратные ребра пусты), то есть величине потока сети (согласно лемме 22.3).

Это доказательство также явно утверждает, что алгоритм Форда-Фалкерсона находит максимальный поток. Независимо от метода поиска расширяющего пути, а также независимо от того, какие пути мы найдем, он всегда находит сечение, поток которого равен его пропускной способности, и поэтому равен величине потока сети, который, следовательно, должен быть максимальным потоком.

Другое следствие правильности алгоритма Форда-Фалкерсона заключается в том, что для любой транспортной сети с целочисленными пропускными способностями ребер существует максимальный поток, в котором потоки всех ребер также принимают целочисленные значения. Каждый расширяющий путь увеличивает сетевой поток на некоторое положительное целое число (минимум из незадействованных пропускных способностей в прямых ребрах и потоков в обратных ребрах, а это всегда целые положительные значения). Отсюда и наше решение рассматривать только целочисленные пропускные способности и потоки. Можно рассматривать максимальные потоки с нецелыми величинами, даже когда все пропускные способности заданы целыми числами (см. упражнение 22.23), однако в этом нет необходимости. Это важное ограничение: обобщения, где пропускные способности и потоки измеряются вещественными числами, могут привести к неприятным аномалиям.

 Все st-сечения


Рис. 22.15.  Все st-сечения

В этом списке для сети, изображенной слева, представлены все st-сечения, вершины множества, содержащего вершину s, вершины множества, содержащего вершину t, прямые ребра, обратные ребра и пропускная способность (сумма пропускных способностей прямых ребер). Для любого потока поток через все сечения (потоки в прямых ребрах минус потоки в обратных ребрах) один и тот же. Например, в случае самой левой сети поток через сечение, разделяющее вершины 0 1 3 и 2 4 5, имеет величину 2 + 1 + 2 (потоки в ребрах 0-2, 1-4 и 3-5 соответственно) минус 1 (поток в ребре 2-3), то есть 4. Эти подсчеты дают значение 4 для любого другого сечения в сети, а поток максимален, поскольку его величина равна пропускной способности минимального сечения (см. лемму 22.5). В рассматриваемой сети имеются два минимальных сечения.

Например, алгоритм Форда-Фалкерсона может привести к появлению бесконечной последовательности расширяющих путей, которая даже не сходится к величине максимального потока (см. раздел ссылок).

Обобщенный алгоритм Форда-Фалкерсона не описывает конкретный метод определения расширяющих путей. Возможно, естественнее всего использовать обобщенную стратегию поиска на графе из лекция №18. Для этого нам понадобится следующее определение.

Определение 22.4. Пусть задана транспортная сеть и поток в ней; остаточная сеть (residual network) для данного потока содержит те же вершины, что и исходная сеть, и одно или два ребра на каждое ребро исходной сети, которые определяются следующим образом. Пусть f - поток, а с - пропускная способность произвольного ребра v-w из исходной сети. Если f положительно, то в остаточную сеть включается ребро w-v с пропускной способностью f; и если f меньше с, то в остаточную сеть включается ребро v-w с пропускной способностью с -f.

Если ребро v-w пусто (f = 0), то в остаточной сети существует одно ребро, соответствующее v-w, с пропускной способностью с; если ребро v-w заполнено ( f = с), то в остаточной сети существует единственное ребро с пропускной способностью f, соответствующее ребру v-w; а если v-w ни заполнено, ни пусто, то в остаточную сеть входят оба ребра v-w и w-v с соответствующими пропускными способностями.

Программа 22.2 определяет класс EDGE, который мы используем для реализации абстракции остаточной сети. При такой реализации мы все так же работаем исключительно с указателями на клиентские ребра. Наши алгоритмы работают с остаточной сетью, но на самом деле они (через указатели на ребра) проверяют пропускные способности и изменяют потоки ребер в клиентских ребрах. Функции-члены from и other позволяют производить обработку ребер с любой ориентацией: функция e.other(v) возвращает конечную вершину е, отличную от v. Функции-члены capRto и addflowRto(v) реализуют остаточную сеть: если е - указатель на ребро v-w с пропускной способностью с и потоком f, то e->capRto(w) содержит значение c-f, а e->capRto(v) содержит f, e->addflowRto(w, d) увеличивает поток на величину d, а e->addflowRto(v, d) уменьшает его на величину d.

Программа 22.2. Ребра транспортной сети

Для реализации транспортной сети мы используем класс GRAPH неориентированного графа из лекция №20, который позволяет оперировать указателями на ребра, реализованные в данном интерфейсе. Ребра ориентированы, но функции-члены реализуют абстракцию остаточной сети, которая охватывает оба направления каждого ребра (см. текст).

class EDGE
  { int pv, pw, pcap, pflow;
  public:
 EDGE(int v, int w, int cap) :
   pv(v), pw(w), pcap(cap), pflow(0) { }
 int v() const { return pv; }
 int w() const { return pw; }
 int cap() const { return pcap; }
 int flow() const { return pflow; }
 bool from (int v) const
   { return pv == v; }
 int other(int v) const
   { return from(v) ? pw : pv; }
 int capRto(int v) const
   { return from(v) ? pflow : pcap - pflow; }
 void addflowRto(int v, int d)
   { pflow += from(v) ? -d : d; }
  };
   

Остаточные сети позволяют использовать для поиска расширяющего пути любой обобщенный поиск на графе (см. лекция №18), поскольку любой путь из истока в сток в остаточной сети непосредственно соответствует расширяющему пути в исходной сети. Увеличение потока вдоль пути приводит к изменениям в остаточной сети: например, по меньшей мере одно ребро в остаточной сети меняет направление или исчезает (однако использование абстрактной остаточной сети означает, что мы проверяем наличие положительной пропускной способности, но нам не нужно фактически вставлять и удалять ребра). На рис. 22.16 показан пример последовательности расширяющих путей и соответствующие остаточные сети.

Программа 22.3 представляет собой реализацию на основе очереди с приоритетами, которая охватывает все эти возможности, используя слегка измененную версию реализации поиска по приоритету на графах из программы 21.1, которая приведена в программе 22.4. Эта реализация позволяет выбирать одну из нескольких различных классических реализаций алгоритма Форда-Фалкерсона, просто указывая различные приоритеты, которые дают различные структуры данных для накопителя.

Программа 22.3. Реализация вычисления максимального потока расширением путей

Данный класс реализует общий алгоритм вычисления максимального потока расширением путей (алгоритм Форда-Фалкерсона). Он использует поиск по приоритету для нахождения пути из истока в сток в остаточной сети (см. программу 22.4), затем добавляет в этот путь максимально возможный поток и повторяет этот процесс, пока не останется ни одного такого пути. Построение объекта этого класса устанавливает в ребрах заданной сети такие величины потоков, при которых поток из истока в сток максимален. Вектор st содержит остовное дерево PFS: в st[v] содержится указатель на ребро, которое соединяет вершину v с ее родителем. Функция ST возвращает родителя для вершины, заданной в ее аргументе. Функция augment использует ST для обхода пути, чтобы вычислить его пропускную способность и затем увеличить поток.

template <class Graph, class Edge>
class MAXFLOW
  { const Graph &G; int s, t;
 vector<int> wt;
 vector<Edge *> st;
 int ST(int v) const { return st[v]->other(v); }
 void augment(int s, int t)
   { int d = st[t]->capRto(t);
  for (int v = ST(t); v != s; v = ST(v))
    if (st[v]->capRto(v) < d)
   d = st[v]->capRto(v);
  st[t]->addflowRto(t, d);
  for (int v = ST(t); v != s; v = ST(v))
    st[v]->addflowRto(v, d);
   }
 bool pfs();
  public:
 MAXFLOW(const Graph &G, int s, int t) : G(G),
   s(s), t(t), st(G.V()), wt(G.V())
   { while (pfs()) augment(s, t); }
  };
   

 Остаточные сети (расширяющие пути)


Рис. 22.16.  Остаточные сети (расширяющие пути)

Вычисление расширяющих путей в транспортной сети эквивалентно поиску ориентированных путей в остаточной сети, определяемой потоком. Для каждого ребра исходной сети мы создаем в остаточной сети по ребру в каждом направлении: одно в направлении потока с весом, равным незадействованной пропускной способности, а другое - в обратном направлении с весом, равным потоку. Ребра с нулевыми весами не включаются. Первоначально (вверху) остаточная сеть совпадает с транспортной сетью, но веса ребер равны их пропускным способностям.

При расширении потока вдоль пути 0-1-3-5 (второйряд) мы заполняем ребра 0-1 и 3-5 до их пропускной способности, чтобы они поменяли направление в остаточной сети, уменьшаем вес ребра 1-3, чтобы он соответствовал оставшемуся потоку, и добавляем ребро 3-1 с весом 2. Аналогично, при расширении вдоль пути 0-2-4-5 мы заполняем ребро 2-4 до его пропускной способности, чтобы оно сменило направление на обратное, и получаем разнонаправленные ребра между вершинами 0 и 2 и между вершинами 4 и 5, представляющие поток и неиспользованную пропускной способность. После расширения потока вдоль пути 0-2-3-1-4-5 (внизу) в остаточной сети не остается ни одного ориентированного пути из истока в сток, поэтому нет расширяющих путей.

Как было показано в лекция №21, использование очереди с приоритетами для реализации стека, очереди или рандомизированной очереди в качестве накопителя увеличивает трудоемкость операций с накопителем на множитель lgV Этого можно избежать, используя в реализациях АТД обобщенной очереди наподобие программы 18.10 прямые реализации - поэтому при анализе алгоритмов мы полагаем, что трудоемкость операций с накопителем в рассматриваемых случаях постоянна. Используя единственную реализацию в программе 22.3, мы демонстрируем наличие непосредственных связей между различными реализациями алгоритма Форда-Фалкерсона.

Несмотря на обобщенный характер, программа 22.3 не охватывает всех реализаций алгоритма Форда-Фалкерсона (см. например, упражнения 22.36 и 22.38). Исследователи продолжают разрабатывать новые способы реализации этого алгоритма. Однако семейство алгоритмов, охватываемых программой 22.3, получило широкое распространение, помогает понять процесс вычисления максимальных потоков и знакомит с несложными реализациями, которые хорошо работают на обычно встречающихся сетях.

Программа 22.4. Реализация поиска по приоритетам для нахождения расширяющих путей

Данная реализация поиска по приоритетам получена из реализации, которую мы использовали для алгоритма Дейкстры (программа 21.1), но теперь в ней веса принимают целочисленные значения, обрабатываются ребра остаточной сети, и выполнение прекращается при достижении стока или возврата false, если не существует пути из истока в сток. Заданное здесь определение приоритета P позволяет получить расширяющий путь до максимальной пропускной способности (отрицательные величины сохраняются в очереди с приоритетами в соответствии с интерфейсом программы 20.10). Другие определения P приводят к различным алгоритмам вычисления максимального потока.

  template <class Graph, class Edge>
  bool MAXFLOW<Graph, Edge>::pfs()
 { PQi<int> pQ(G.V(), wt);
   for (int v = 0; v < G.V(); v++)
  { wt[v] = 0; st[v] = 0; pQ.insert(v); }
   wt[s] = -M; pQ.lower(s);
   while (!pQ.empty())
  { int v = pQ.getmin(); wt[v] = -M;
    if (v == t || (v != s && st[v] == 0)) break;
    typename Graph::adjIterator A(G, v);
    for (Edge* e = A.beg(); !A.end(); e = A.nxt())
   { int w = e->other(v);
     int cap = e->capRto(w);
     int P = cap < -wt[v] ? cap : -wt[v];
     if (cap > 0 && -P < wt[w])
    { wt[w] = -P; pQ.lower(w); st[w] = e; }
   }
  }
  return st[t] != 0;
 }
   

Как мы вскоре убедимся, эти базовые алгоритмические средства дают простые (и, как правило, полезные) решения задачи о транспортных потоках. Однако исчерпывающий анализ для выбора наилучшего метода сам является сложной задачей, поскольку время выполнения этих методов зависит от:

Эти величины могут изменяться в широких пределах, в зависимости от вида обрабатываемой сети и стратегии поиска на графе (структура данных накопителя).

Видимо, простейшая реализация алгоритма Форда-Фалкерсона использует кратчайший расширяющий путь (кратчайший по количеству ребер в этом пути, а не по потоку или по пропускной способности). Этот метод был предложен Эдмондсом (Edmonds) и Карпом (Karp) в 1972 г. В его реализации в качестве накопителя используется очередь - либо с помощью кольцевого счетчика для P, либо на основе АТД очереди вместо АТД очереди с приоритетами из программы 22.3. В этом случае поиск расширяющего пути превращается в поиск в ширину (BFS) в остаточной сети, в точности как описано в разделах 18.8 лекция №18 и рис. 22.2. На рис. 22.17 показан пример работы этой реализации метода Форда-Фалкерсона. Мы будем называть этот метод алгоритмом вычисления максимального потока с помощью кратчайших расширяющих путей (shortest-augumenting-path). Как показано на этом рисунке, длины расширяющих путей образуют неубывающую последовательность. Анализ этого метода в лемме 22.7 показывает, что это свойство верно всегда.

 Кратчайшие расширяющие пути


Рис. 22.17.  Кратчайшие расширяющие пути

Здесь показан процесс поиска максимального потока с помощью реализации метода Форда-Фалкерсона на основе кратчайших расширяющих путей. По мере работы алгоритма длины путей увеличиваются: первые четыре пути в верхнем ряду имеют длину 3, последний путь в верхнем ряду и все пути во втором ряду имеют длину 4; первые два пути в нижнем ряду имеют длину 5, и в конце находятся два пути длиной 7, каждый из которых содержит обратное ребро.

В другой реализация алгоритма Форда-Фалкерсона, предложенной Эдмондсоном и Карпом, выполняется расширение пути, который увеличивает поток на наибольшую величину. Реализацию этого метода обеспечивает значение приоритета P, использованное в программе 22.3. При этом алгоритм выбирает из накопителя такие ребра, чтобы мощность потока, который будет добавлен в прямое ребро или удален из обратного ребра, была максимальной. Этот метод мы будем называть алгоритмом вычисления максимального потока с помощью расширяющий путей с максимальной пропускной способностью (maximum-capacity-augmenting-path). На рис. 22.18 показана работа этого алгоритма на той же транспортной сети, что и на рис. 22.17.

Это всего лишь два примера (которые мы можем проанализировать!) реализаций алгоритма Форда-Фалкерсона. В конце этого раздела мы познакомимся и с другими реализации. Но сначала мы рассмотрим задачу анализа методов на основе расширения путей с тем, чтобы исследовать их свойства и затем определить метод, который обеспечивает наивысшую производительность.

Пытаясь выбрать один из семейства алгоритмов, представленных программой 22.3, мы опять попадаем в знакомую ситуацию. Следует ли обращать внимание на производительность, гарантированную в худшем случае, или это лишь математическая абстракция, не имеющая отношения к сетям, которые встречаются на практике? Этот вопрос особо актуален в данном контексте, поскольку классические границы производительности, которые мы можем определить для худшего случая, намного выше реальной производительности при работе с типичными графами.

Ситуацию осложняют и многие другие факторы. Например, время выполнения в худшем случае для некоторых версий зависит не только от V и E, но и от значений пропускных способностей ребер в сети. Разработка алгоритма вычисления максимального потока с высокой гарантированной производительностью почти полвека привлекала к себе внимание многих исследователей, которые предложили множество методов. Оценка всех этих методов для различных видов реально встречающихся сетей с достаточной степенью точности позволяет сделать правильный выбор, однако она более расплывчата, чем оценки в других ситуациях, которые мы изучали ранее - например, для алгоритмов сортировки или поиска.

 Расширяющие пути с максимальной пропускной способностью


Рис. 22.18.  Расширяющие пути с максимальной пропускной способностью

Здесь показан процесс поиска максимального потока с помощью реализации метода Форда-Фалкерсона на основе расширяющих путей с максимальной пропускной способностью. По мере работы алгоритма пропускные способности путей уменьшаются, но их длина может как увеличиваться, так и уменьшаться. Этому методу потребовалось лишь девять расширяющих путей, чтобы вычислить тот же максимальный поток, что и на рис. 22.17.

Учитывая эти трудности, рассмотрим теперь классические результаты, касающиеся производительности метода Форда-Фалкерсона в худшем случае: одну общую границу и две границы для частных случаев - для каждого из рассмотренных нами алгоритмов расширения пути. Эти результаты позволят нам скорее понять характеристики алгоритмов, чем точно прогнозировать производительность этих алгоритмов для осмысленного сравнения. Эмпирическое сравнение этих методов будет приведено в конце этого раздела.

Лемма 22.6. Пусть M - максимальная пропускная способность ребер в сети. Количество расширяющих путей, используемых любой реализацией алгоритма Форда-Фалкерсона, не превышает VM.

Доказательство. Любое сечение содержит максимум Vребер с пропускной способностью M, что дает общую пропускную способность VM. Каждый расширяющий путь увеличивает поток через каждое сечение по меньшей мере на 1, следовательно, выполнение алгоритма прекратится после VM проходов, т.к. после стольких расширений все сечения точно будут заполнены до их пропускной способности.

Как уже было сказано, в обычных ситуациях от такой границы мало проку, т.к. M может быть очень большим числом. Что еще хуже, легко описать ситуации, в которых количество итераций пропорционально максимальной пропускной способности ребра. Например, предположим, что мы используем алгоритм использования самого длинного расширяющего пути (возможно, основанного на соображении, что чем длиннее путь, тем больший поток можно добавить в ребра сети). Поскольку мы ведем подсчет итераций, мы на время игнорируем затраты на вычисление такого пути. Классический пример, представленный на рис. 22.19, показывает сеть, для которой количество итераций алгоритма с использованием самого длинного расширяющего пути равно максимальной пропускной способности ребер. Этот пример показывает, что необходимы более подробные исследования, чтобы выяснить, требуют ли другие конкретные реализации существенное меньше итераций по сравнению с оценкой из леммы 22.6.

 Два сценария для алгоритма Форда-Фалкерсона


Рис. 22.19.  Два сценария для алгоритма Форда-Фалкерсона

Данная сеть демонстрирует, что количество итераций, выполняемых алгоритмом Форда-Фалкер-сона, зависит от пропускных способностей ребер и от последовательности путей, выбираемых реализацией. Сеть состоит из четырех ребер с пропускной способностью X и одного ребра с пропускной способностью 1. В верхней части рисунка показано, что реализация, которая попеременно использует цепочки 0-1-2-3 и 0-2-1-3 в качестве расширяющих путей (например, та, где выбираются длинные пути), потребует X пар итераций - наподобие двух пар, показанных на рисунке, и каждая такая пара увеличивает общих поток на 2. В нижней части рисунка показано, что реализация, которая выбирает в качестве расширяющих путей цепочку 0-1-3, а затем 0-2-3 (например, та, где выбираются короткие пути), вычисляет максимальный поток всего за две итерации. Если пропускные способности ребер выражены, скажем, 32-разрядными целыми числами, то быстродействие верхней реализации будет в миллиарды раз меньше быстродействия нижней реализации.

Для разреженных сетей и сетей с небольшими целочисленными значениями пропускных способностей лемма 22.6 на самом деле дает полезную верхнюю границу времени выполнения любой реализации алгоритма Форда-Фалкерсона.

Следствие. Время, необходимое для вычисления максимального потока, равно O(VEM), то есть O(V2M) для разреженных сетей.

Доказательство. Это утверждение непосредственно следует из фундаментального утверждения, что трудоемкость обобщенного поиска на графе линейно зависит от размера представления графа (лемма 18.12). Как уже было сказано, при реализации накопителя в виде очереди с приоритетами трудоемкость увеличивается на коэффициент lgV

Это доказательство фактически утверждает, что множитель M можно заменить отношением наибольшей и наименьшей пропускной способности в сети (см. упражнение 22.35). Если это отношение мало, любая реализация алгоритма Форда-Фалкерсона находит максимальный поток за время, пропорциональное худшему времени решения (к примеру) задачи поиска всех кратчайших путей. Существует множество ситуаций, когда пропускные способности весьма небольшие, и множитель M можно не учитывать. Соответствующий пример будет приведен в разделе 22.4.

Если M велико, граница VEM для худшего случая также велика; но она слишком завышена, поскольку получена перемножением границ для худших случаев, которые получены из придуманных примеров. Фактические затраты для сетей, встречающихся на практике, обычно намного ниже.

С теоретической точки зрения наша первая цель - определить с помощью грубой субъективной классификации из лекция №17, разрешима ли задача о максимальном потоке в сетях с большими целочисленными весами (т.е. существует ли алгоритм с полиномиальным временем выполнения). Только что найденные границы не дают ответа на этот вопрос, т.к. максимальный вес M = 2m может возрастать экспоненциально с увеличением V и E. Для практических целей нужны более точные гарантии производительности. Возьмем типичный практический пример: пусть для представления весов используются 32-разрядные целые числа (m = 32). В графе с сотнями вершин и тысячами ребер следствие из леммы 22.6 утверждает, что алгоритму на базе расширения пути может понадобиться выполнить сотни триллионов операций. Если сеть содержит миллионы вершин, то ничего сказать вообще невозможно, поскольку не только невозможно выразить веса с величинами порядка 21000000, но и значения V3 и VE настолько велики, что любые границы при этом теряют всякий смысл. А нам нужна полиномиальная граница для ответа на вопрос о разрешимости и более точные границы для ситуаций, с которыми мы можем столкнуться на практике.

Лемма 22.6. носит общий характер и поэтому применима к любой реализации алгоритма Форда-Фалкерсона. Универсальная природа этого алгоритма позволяет рассматривать целый ряд простых реализаций, позволяющих улучшить рабочие характеристики алгоритма. Мы надеемся, что конкретные реализации могут иметь более низкие границы для худших случаев. Вообще-то это одна из главных причин, чтобы сразу же приступить к их рассмотрению! Теперь, как мы могли убедиться, реализовать и использовать большой класс таких реализаций совсем не сложно: для этого нужно просто подставлять в программу 22.3 различные реализации обобщенной очереди или определения приоритетов. Анализ различий поведения в худшем случае представляет собой еще более трудную задачу, что подтверждается классическими результатами для двух простых реализаций алгоритма расширения путей, которые мы сейчас рассмотрим.

Сначала мы проанализируем алгоритм с использованием кратчайшего расширяющего пути. Этот метод не относится к задаче, представленной на рис. 22.19. Его можно использовать для замены множителя M для времени выполнения в худшем случае на выражение VE/2, тем самым заявив, что задача вычисления сетевых потоков является разрешимой. Вообще-то ее можно считать даже легкой (решаемой в реальных ситуациях за полиномиальное время с помощью небольшой, но хитроумной реализации).

Лемма 22.7. Количество расширяющих путей, необходимых в реализации алгоритма Форда-Фалкерсона с использованием кратчайших расширяющих путей, не превышает VE/2.

Доказательство. Сначала, как понятно из примера на рис. 22.17, ни один из кратчайших путей не короче предыдущего. Для доказательства этого факта мы покажем методом от противного, что справедливо еще более сильное свойство: никакой расширяющий путь не может уменьшить длину кратчайшего пути из вершины s в любую другую вершину остаточной сети. Предположим, что некоторый расширяющий путь может это сделать, а вершина v - первая вершина на таком пути. При этом возможны два случая: либо никакая вершина на новом более коротком пути из s в v не присутствует в этом расширяющем пути, либо некоторая вершина w на новом кратчайшем пути из s в v присутствует в расширяющем пути где-то между v и t. Обе ситуации противоречат условию минимальности расширяющего пути.

Теперь каждый расширяющий путь содержит, по построению, по крайней мере одно критическое ребро - ребро, которое удалено из остаточной сети, поскольку оно соответствует либо заполненному до пропускной способности прямому ребру, либо опустошенному обратному ребру. Предположим, что ребро u-v есть критическое ребро для расширяющего пути P длиной d. Следующий расширяющий путь, в котором это ребро также будет критическим, должен иметь длину не менее d + 2, т.к. он должен проходить из s до v, потом по ребру u-v, а затем из u в t. Первая часть имеет длину по крайней мере на 1 больше, чем расстояние от s до u в P, а последняя часть имеет длину по крайней мере на 1 больше, чем расстояние от v до t в P, поэтому длина этого пути по крайней мере на 2 больше, чем длина P. Поскольку длины расширяющих путей не могут быть больше V, то из этих фактов следует, что каждое ребро может быть критическим в не более чем V/2 расширяющих путях - значит, общее количество расширяющих путей не может быть больше EV/2.

Следствие. Время, необходимое для отыскания максимального потока в разреженной сети, равно O(V3 ) .

Доказательство. Время, необходимое для отыскания расширяющего пути, равно O(E ), поэтому общее время равно O(EV2 ) . Отсюда следует указанная граница.

Величина V3 достаточно велика и поэтому не может гарантировать высокую производительность на крупных сетях. Но это не должно удерживать нас от применения алгоритма на крупных сетях, т.к. полученная оценка производительности в худшем случае вряд ли будет полезной для прогнозирования производительности алгоритма в практических ситуациях. Например, как было сказано выше, максимальная пропускная способность M (или максимальное отношение пропускных способностей) может быть намного меньше V, и тогда лемма 22.6 определяет более точную границу. Вообще-то в лучшем случае количество расширяющих путей, необходимых для метода Форда-Фалкерсона, меньше степени выхода вершины s или степени захода вершины t, которые, в свою очередь, могут быть намного меньше V. При таком разбросе производительности в лучшем и худшем случаях не стоит сравнивать алгоритмы нахождения расширяющих путей только на основе границ для худшего случая.

Однако существуют другие реализации, которые почти столь же просты, как и метод использования кратчайших расширяющих путей, но имеют более точные границы или хорошо зарекомендовали себя на практике (или и то, и другое). Допустим, в примере, представленном на рис. 22.17 и рис. 22.18, алгоритм с использованием максимального расширяющего пути использует намного меньше путей для отыскания максимального потока, чем метод с использованием кратчайшего расширяющего пути. Теперь мы проведем анализ худшего случая для рассматриваемого алгоритма.

Во-первых, как и в случае алгоритма Прима и алгоритма Дейкстры (см. лекция №20 и рис. 21.2), мы можем реализовать очередь с приоритетами таким образом, что для выполнения одной итерации алгоритма в худшем случае понадобится время, пропорциональное V2 (для насыщенных графов) или (E+ V) logV (для разреженных графов). Однако эти оценки слишком пессимистичны, т.к. алгоритм сразу же останавливается при достижении стока. Мы также знаем, что можно немного повысить производительность, воспользовавшись специальными структурами данных. Однако более важной, хотя и более трудной, задачей является определение необходимого количества расширяющих путей.

Лемма 22.8. Количество расширяющих путей, необходимых в реализации алгоритма Форда-Фалкерсона с использованием максимальных расширяющих путей, не превышает 2E lgM.

Доказательство. Пусть дана некоторая сеть с максимальным потоком F. Пусть - значение потока в некоторый момент выполнения алгоритма, перед поиском расширяющего пути. Применение леммы 22.2 к остаточной сети позволяет разложить поток на максимум E ориентированных путей, что дает в сумме , поэтому поток по крайней мере в одном из путей равен не менее . Теперь либо мы найдем максимальный поток до использования следующих 2E расширяющих путей, либо величина расширяющего пути после использования этой последовательности 2E путей станет меньше , а это меньше половины максимума до использования последовательности 2E путей. То есть для уменьшения мощности пути в два раза в худшем случае нужна последовательность из 2E путей. Первое значение количества путей не превышает M, которое придется уменьшать в два раза максимум lgM раз, что дает в конечном итоге максимум lgM последовательностей из 2E путей.

Следствие. Время, необходимое для вычисления максимального потока в разреженной сети, составляет O(V2 lgM lgV) .

Доказательство. Это утверждение непосредственно следует из использования реализации очереди с приоритетами на основе пирамидального дерева, как при доказательстве лемм 20.7 и 21.5.

Для значений M и V, которые обычно встречаются на практике, эта граница значительно ниже границы O(V3) , определяемой леммой 22.7. Во многих практических ситуациях алгоритм поиска максимального расширяющего пути использует значительно меньше итераций, чем алгоритм поиска кратчайшего расширяющего пути, за счет некоторого увеличения затрат на поиск каждого пути.

Можно рассмотреть и множество других вариантов, которые описаны в литературе по алгоритмам вычисления максимальных потоков. Продолжают появляться новые алгоритмы с более точными границами для худшего случая, однако существование нетривиальной нижней границы до сих пор не доказано, т.е. возможность открытия простого алгоритма с линейным временем выполнения остается. Многие алгоритмы разработаны в основном для снижения границ худшего случая для насыщенных графов, поэтому они не обеспечивают сущетвенно лучшую производительность по сравнению с алгоритмом поиска с использованием максимального расширяющего пути для различных видов разреженных сетей, с которыми мы сталкиваемся на практике. Однако остается еще много вариантов, которые могут привести к более эффективным практическим алгоритмам вычисления максимальных потоков. Ниже мы изучим еще два алгоритма расширения путей, а в разделе 22.3 рассмотрим другое семейство алгоритмов.

В одном простом алгоритме с помощью расширения путей используется значение уменьшающегося счетчика или стековая реализация для обобщенной очереди из программы 22.3 - при этом поиск расширяющих путей выполняется аналогично поиску в глубину. На рис. 22.20 показан поток, вычисленный этим алгоритмом для нашего небольшого примера. Интуитивно понятно, что этот метод быстро работает, легок в реализации и действительно находит максимальный поток. Как мы увидим, его производительность может быть всякой, от очень низкой для одних видов сетей до вполне приемлемой для других.

 Поиск расширяющего пути с помощью стека


Рис. 22.20.  Поиск расширяющего пути с помощью стека

Здесь показано использование стека как основы для обобщенной очереди в нашей реализации метода Форда-Фалкерсона, когда поиск путей выполняется так же, как поиск в глубину. В этом случае рассматриваемый метод работает почти так же успешно, как и поиск в ширину, но его анализ не проводился - из-за несколько неустойчивого поведения и ощутимой зависимости от представления сети.

Другой альтернативой является использование в качестве обобщенной очереди реализации рандомизированной очереди, когда поиск расширяющего пути становится рандомизированным. На рис. 22.21 показан поток, вычисленный этим алгоритмом для нашего небольшого примера. Он также быстро работает и легок в реализации; а кроме того, как было сказано в лекция №18, он может сочетать в себе достоинства как поиска в ширину, так и поиска в глубину. Рандомизация - это мощный инструмент в разработке алгоритмов, и в данной ситуации она вполне уместна.

В завершение данного раздела мы еще ближе рассмотрим изученные методы, чтобы понять сложность их сравнения между собой или попыток прогнозирования производительности в практических ситуациях.

Чтобы получить начальное представление о возможных количественных различиях, мы воспользуемся двумя моделями транспортных сетей, построенных на основе модели евклидовых графов, которые мы уже применяли для сравнения других алгоритмов на графах. В обеих моделях используется граф из V точек на плоскости со случайными координатами от 0 до 1 и ребер, соединяющих точки, расположенные в пределах фиксированного расстояния друг от друга. Эти модели различаются пропускными способностями, назначаемыми ребрам.

Первая модель просто присваивает каждой пропускной способности одно и то же постоянное значение. Как будет показано в разделе 22.4, такая задача о сетевых потоках решается легче, чем общая задача. В рассматриваемых нами евклидовых графах потоки ограничены степенью выхода истока и степенью захода стока, поэтому в каждом алгоритме требуется найти лишь несколько расширяющих путей. Однако, как мы вскоре увидим, эти пути существенно различаются для разных алгоритмов.

Вторая модель назначает случайные пропускные способности из некоторого фиксированного диапазона значений. Эта модель генерирует типы сетей, которые люди обычно представляют себе при обдумывании задачи, а производительность различных алгоритмов на таких сетях дает богатую пищу для размышлений.

 Рандомизированный поиск расширяющих путей


Рис. 22.21.  Рандомизированный поиск расширяющих путей

Здесь показан результат использования рандомизированной очереди для структуры данных накопителя при поиске расширяющих путей методом Форда-Фалкерсона. В этом примере мы случайно натолкнулись на короткий путь с высокой пропускной способностью, благодаря чему потребовалось относительно небольшое количество расширяющих путей. Хотя прогнозирование характеристик производительности этого метода представляет собой трудную задачу, во многих ситуациях он работает вполне хорошо.

Обе эти модели проиллюстрированы на рис. 22.22 - вместе с потоками, вычисленными четырьмя различными методами на двух сетях. Пожалуй, в этих примерах больше всего бросается в глаза существенное различие самих потоков. Все они имеют одну и ту же величину, но в рассматриваемых сетях имеется много максимальных потоков, и различные алгоритмы выбирают различные пути при их вычислении. Данная ситуация часто встречается на практике. Можно попытаться наложить дополнительные ограничения на вычисляемые потоки, но такие изменения в задаче усложняют ее решение. Задача отыскания потока минимальной стоимости, которую мы рассмотрим в разделах 22.5-22.7, представляет собой один из способов формализации подобных ситуаций.

В таблице 22.1 приведены более подробные количественные результаты относительно всех четырех методов вычисления потоков в сетях, показанных на рис. 22.22. Производительность алгоритма с использованием расширяющих путей зависит не только от количества расширяющих путей, но и от длины таких путей и затрат на их поиск. В частности, время выполнения пропорционально количеству ребер, просмотренных во внутреннем цикле программы 22.3. Как обычно, это количество может меняться в широких пределах - даже для одного и того же графа - и зависит от свойств его представления, но для каждого вида алгоритма все-таки имеются общие характеристики. Например, на рис. 22.23 и 22.24 показаны деревья поиска, соответственно, для алгоритма с использованием путей максимальной пропускной способности и алгоритма с использованием кратчайшего пути. Эти примеры подтверждают общий вывод, что метод поиска кратчайшего пути более трудоемок при отыскании расширяющих путей с меньшим потоком, чем алгоритм с использованием максимальной пропускной способности, и помогают понять, почему предпочтение отдается последнему.

В этой таблице представлены параметры производительности различных алгоритмов вычисления сетевых потоков с помощью расширения путей на примере евклидовой сети с соседними связями (со случайными значениями пропускной способности и с величиной максимального потока, равной 286) и с единичными пропускными способностями (при величине максимального потока, равной 1). Алгоритм использования путей с максимальной пропускной способностью превосходит другие алгоритмы на обоих типах сетей. Алгоритм со случайным поиском находит расширяющие пути, которые ненамного длиннее кратчайшего пути, и проверяет меньшее количество узлов. Алгоритм на основе стека очень хорошо работает для случайных весов. Хотя он находит очень длинные пути, он вполне пригоден для работы на сетях с единичными весами.

Таблица 22.1. Эмпирическое сравнение алгоритмов расширения путей
Количество путейСредняя длинаОбщее количество ребер
Случайные пропускные способности от 1 до 50
Кратчайшие пути3710,876394
С максимальной пропускной способностью719,315660
Поиск в глубину286123,5631392
Случайный выбор3512,858016
Пропускная способность, равная 1
Кратчайшие пути610,513877
С максимальной пропускной способностью614,710736
Поиск в глубину6110,812291
Случайный выбор612,211223

 Случайные транспортные сети


Рис. 22.22.  Случайные транспортные сети

Здесь представлены вычисления максимальных потоков в случайных евклидовых графах, построенных на базе двух различных моделей пропускных способностей. Слева всем ребрам присвоена единичная пропускная способность, а справа - случайные пропускные способности. Исток находится в середине верхней части сети, а сток - в середине нижней части. На диаграммах показаны направленные сверху вниз потоки, которые вычислены с помощью алгоритма, использующего соответственно кратчайшие пути, пути с максимальной пропускной способностью, стек и рандомизированный выбор. Поскольку вершины не имеют высоких степеней, а пропускные способности принимают малые целочисленные значения, для этих примеров существует много различных потоков, которые приводят к максимальному.

Степень захода стока равна 6, поэтому все алгоритмы вычисляют поток в модели с единичными пропускными способностями (слева) с помощью шести расширяющих путей. Методы находят расширяющие пути, поведение которых существенно отличается от модели со случайными весами, представленной в правой части. В частности, метод с использованием стека находит длинные пути с малым весом и даже приводит к появлению потока с разорванным циклом.

 Расширяющие пути с максимальной пропускной способностью (более крупный пример)


Рис. 22.23.  Расширяющие пути с максимальной пропускной способностью (более крупный пример)

На этом рисунке показаны расширяющие пути, вычисленные алгоритмом с использованием путей максимальной пропускной способности для евклидовой сети со случайными весами, которая изображена на рис. 22.22 рис. 22.22. Здесь же показаны (серым цветом) ребра остовного дерева поиска на графе. Полученный поток показан внизу справа.

Пожалуй, основной урок, который можно вынести из подробного исследования конкретных сетей, состоит в огромной разнице между оценками верхней границы, которые даны в леммах 22.6-22.8, и фактическим количеством расширяющих путей, которые требуются алгоритмам в реальных приложениях. Например, в транспортной сети, представленной на рис. 22.23, имеется 177 вершин и 2000 ребер с пропускной способностью не более 100 единиц. Поэтому значение 2E lgM в лемме 22.8 больше 25000, но алгоритм с использованием путей с максимальной пропускной способностью находит максимальный поток, перебрав лишь семь расширяющих путей. Аналогично, значение VE/2 для этой сети, определяемое в лемме 22.7, равно 177000, однако алгоритму с использованием кратчайших путей достаточно перебрать лишь 37 путей.

Как уже было сказано, эти различия между теоретической и реальной производительностью в данном случае объясняются относительно низкими степенями узлов и локальностью связей. Можно вывести более точные границы производительности, учитывающие эти детали, однако такие различия между моделями транспортных сетей и реальными сетями являются правилом, а не исключением. С одной стороны, эти результаты можно считать свидетельством недостаточно общего характера сетей, которые встречаются на практике; но с другой стороны, видимо, анализ худших случаев еще более удален от практики, чем все эти виды сетей.

Подобные значительные расхождения побуждают исследователей искать более низкие границы для худших случаев. Существует множество других еще не исследованных возможных реализаций алгоритмов, использующих расширение путей, которые могут привести к более заметному повышению производительности в худшем случае или на реальных сетях, чем рассмотренные нами методы (см. упражнения 22.56-22.60). В специальной литературе приведены многочисленные более сложные методы, с улучшенной производительностью в худшем случае (см. раздел ссылок).

 Кратчайшие расширяющие пути (более крупный пример)


Рис. 22.24.  Кратчайшие расширяющие пути (более крупный пример)

На этом рисунке показаны расширяющие пути, вычисленные алгоритмом с использованием кратчайших путей для евклидовой сети со случайными весами, которая изображена на рис. 22.22. Здесь же показаны (серым цветом) ребра остовного дерева поиска на графе. В этом случае алгоритм работает гораздо медленнее, чем алгоритм с использованием путей максимальной пропускной способности (см. рис. 22.23), т.к. он требует больше расширяющих путей (здесь показаны лишь первые 12 путей из всех 37, а остовные деревья имеют больший размер (обычно содержат почти все вершины).

При изучении множества других задач, сводящихся к задаче о максимальном потоке, может возникнуть важное осложнение. Полученные в результате сведения транспортные сети могут иметь специальную структуру, которой могут воспользоваться специализированные алгоритмы для повышения производительности. Например, в разделе 22.8 мы рассмотрим сведение, которое дает транспортную сеть с единичными пропускными способностями всех ребер.

Даже если ограничиться только алгоритмами с использованием расширения путей, мы увидим, что изучение алгоритмов вычисления максимального потока - это и наука, и искусство. Искусство проявляется в выборе стратегии, наиболее эффективной в данной практической ситуации, а наука - в понимании природы задачи. Существуют ли новые структуры данных и алгоритмы, которые могут решить задачу вычисления максимального потока за линейное время, либо можно доказать, что такое решение не существует? В разделе 22.3 будет показано, что никакой алгоритм с использованием расширения путей не может иметь линейную производительность в худшем случае; там же мы рассмотрим другое обобщенное семейство алгоритмов, которые могут это сделать.

Упражнения

22.19. Покажите в стиле рис. 22.13 столько различных последовательностей расширяющих путей, сколько вы их сможете найти в транспортной сети, показанной на рис. 22.10

22.20. Покажите в стиле рис. 22.15 все сечения транспортной сети, показанной на рис. 22.10, их секущие множества и пропускные способности.

22.21. Найдите минимальное сечение в транспортной сети, показанной на рис. 22.11.

22.22. Допустим, в некоторой транспортной сети достигнут баланс пропускных способностей (для каждого внутреннего узла общая пропускная способность входящих ребер равна общей пропускной способности исходящих ребер). Использует ли алгоритм Форда-Фалкерсона обратные ребра? Докажите, что использует, либо приведите контрпример.

22.23. Определите максимальный поток для транспортной сети с рис. 22.5, в которой величина по крайней одного потока не является целым числом.

22.24. Разработайте реализацию алгоритма Форда-Фалкерсона, в которой вместо очереди с приоритетами используется обобщенная очередь (см. лекция №18).

22.25. Докажите, что количество расширяющих путей, необходимых для любой реализации алгоритма Форда-Фалкерсона, не более чем в V раз больше наименьшего целого числа, которое больше отношения наибольшей пропускной способности ребра к наименьшей пропускной способности.

22.26. Докажите справедливость нижней границы линейного времени для задачи поиска максимального потока: покажите, что в сети с любыми значениями V и E любой алгоритм вычисления максимального потока должен просмотреть каждое ребро.

22.27. Приведите пример сети наподобие рис. 22.19, для которой алгоритм с использованием кратчайших расширяющих путей обладает наихудшим поведением, как на рис. 22.19.

22.28. Приведите представление списками смежности для сети, изображенной на рис. 22.19, для которой наша реализация поиска с помощью стека (программа 22.3, в которой в качестве обобщенной очереди используется стек) обладает наихудшим поведением.

22.29. Покажите в стиле рис. 22.16 транспортную и остаточную сети после каждого расширения пути, когда алгоритм с использованием кратчайших расширяющих путей отыскивает максимальный поток в транспортной сети с рис. 22.10. Включите также деревья поиска на графе для каждого расширяющего пути. Если можно выбрать более одного пути, покажите тот, который выбирается реализациями, рассмотренными в этом разделе.

22.30. Выполните упражнение 22.29 для алгоритма с использованием расширяющего пути с максимальной пропускной способностью.

22.31. Выполните упражнение 22.29 для алгоритма с выборкой расширяющего пути из стека.

22.32. Приведите семейство сетей, для которых алгоритм с использованием максимального расширяющего пути перебирает 2E lgM расширяющих путей.

22.33. Можно ли так упорядочить ребра, чтобы рассмотренные нами реализации тратили на поиск пути в упражнении 22.32 время, пропорциональное E? При необходимости измените пример, чтобы достичь этой цели. Опишите представление графа списками смежности, построенное для рассматриваемого примера. Объясните, как можно получить наихудший случай.

22.34. Эмпирически определите количество расширяющих путей и отношения времени выполнения к V для каждого из четырех алгоритмов, описанных в этом разделе, для различных видов сетей (см. упражнения 22.7-22.12).

22.35. Разработайте и протестируйте реализацию метода расширения путей, который использует эвристику кратчайшего пути из истока в сток для евклидовых сетей из лекция №21.

22.36. Разработайте и протестируйте реализацию метода расширения путей, основанного на чередующемся росте деревьев поиска с корнями в истоке и стоке (см. упражнения 21.35 и 21.75).

22.37. Реализация программы 22.3 останавливает поиск на графе, как только находит первый расширяющий путь из истока в сток, расширяет поток, а затем начинает поиск с самого начала. Другой способ - продолжить поиск и найти другой путь, пока не будут помечены все вершины. Разработайте и протестируйте второй подход.

22.38. Разработайте и протестируйте метод расширения путей, который использует не простые пути.

22.39. Приведите последовательность простых расширяющих путей, которые создают поток с циклом в сети, показанной на рис. 22.11.

22.40. Приведите пример, показывающий, что не все максимальные потоки можно получить расширением потоков вдоль некоторой последовательности простых путей из истока в сток, начиная с пустой сети.

22.41.[ Габов] Разработайте реализацию вычисления максимального потока, которая использует m = lgM этапов, где i-й этап решает задачу о максимальном потоке, используя i первых разрядов в значениях пропускных способностей. Начните с нулевого потока во всей сети; после первого этапа увеличьте вдвое поток, вычисленный на предыдущем этапе. Эмпирически сравните этот метод с базовыми методами на различных сетях (см. упражнения 22.7-22.12).

22.42. Докажите, что время выполнения алгоритма, описанного в упражнении 22.41, равно O(VE lgM).

22.43. Поработайте с гибридными методами, которые вначале используют один метод расширения путей, а затем переключаются на другой метод расширения путей до завершения работы (часть задачи - выбор подходящего критерия для определения момента переключения). Эмпирически сравните этот метод с базовыми методами на различных сетях (см. упражнения 22.7-22.12), обращая больше внимания на те, которые показывают лучшие результаты.

22.44. Поработайте с гибридными методами, которые попеременно используют два или более различных методов расширения путей. Эмпирически сравните этот метод с базовыми методами на различных сетях (см. упражнения 22.7-22.12), обращая больше внимания на те, которые показывают лучшие результаты.

22.45. Поработайте с гибридными методами, которые случайным образом переключаются между двумя или более различными методами расширения путей. Эмпирически сравните этот метод с базовыми методами на различных сетях (см. упражнения 22.7-22.12), обращая больше внимания на те, которые показывают лучшие результаты.

22.46. Напишите клиентскую функцию для транспортной сети, которая для заданного целого числа с находит ребро, увеличение пропускной способности которого в с раз увеличивает максимальный поток до максимальной величины. Эта функция может считать, что клиент уже вычислил максимальный поток с помощью функции MAXFLOW.

22.47. Пусть задано минимальное сечение некоторой сети. Облегчает ли эта информация вычисление максимального потока? Разработайте алгоритм, который использует заданное минимальное сечение для существенного ускорения поиска расширяющих путей с максимальными пропускными способностями.

22.48. Напишите клиентскую программу для графической анимации динамики алгоритмов поиска расширяющих путей. Эта программа должна создавать изображения, подобные рис. 22.17 и другим рисункам из этого раздела (см. упражнения 17.55-17.59). Протестируйте полученную реализацию на евклидовых сетях из упражнений 22.7-22.12.

Алгоритмы определения максимальных потоков проталкиванием напора

В этом разделе мы рассмотрим другой способ решения задачи о максимальном потоке. Используя обобщенный метод, который называется методом проталкивания напора (preflow-push), мы будем постепенно проталкивать поток по ребрам, исходящим из вершин, в которых приток превышает отток. Метод проталкивания напора был разработан Гольдбергом (A. Goldberg) и Тарьяном (R.E.Tarjan) в 1986 г. на основе различных уже известных алгоритмов и получил широкое распространение благодаря своей простоте, гибкости и эффективности.

Как сказано в разделе 22.1, поток должен удовлетворять условию баланса: отток из истока должен быть равен притоку в сток, а в каждом внутреннем узле приток равен оттоку. Мы называем такой поток допустимым (feasible) потоком. Алгоритм расширения путей всегда поддерживает допустимость потока, наращивая поток вдоль расширяющих путей, пока не будет достигнут максимальный поток. В отличие от этого, алгоритмы проталкивания напора, которые будут рассмотрены в этом разделе, работают с не допустимыми потоками, и в некоторых вершинах приток может превышать отток: они проталкивают поток через эти вершины, пока поток не станет допустимым (когда не останется таких вершин).

Определение 22.5. Напор (preflow) в транспортных сетях - это множество положительных потоков в ребрах, удовлетворяющих условиям: поток в каждом ребре не превосходит пропускную способность этого ребра, и приток в каждой внутренней вершине не меньше оттока. Активная (active) вершина - это внутренняя вершина, приток в которой больше оттока (по соглашению исток и сток не считаются активными).

Разность между притоком и оттоком активной вершины называется ее избытком (excess). Чтобы изменить множество активных вершин, мы выбираем одну из них и проталкиваем (push) ее избыток в исходящее ребро, а если пропускной способности этого ребра не хватает, то избыток выталкивается назад по входящему ребру. Если проталкивание балансирует приток и отток, вершина перестает быть активной; поток, протолкнутый в другую вершину, может активировать ее. Метод проталкивания напора представляет собой систематический способ многократного выталкивания избытка из активных вершин; по окончании процесса получается максимальный поток, и нет активных вершин. Активные вершины содержатся в обобщенной очереди. Это решение позволяет получить обобщенный алгоритм, который охватывает целое семейство специальных алгоритмов.

На рис. 22.25 приведен небольшой пример базовых операций, используемых методом проталкивания напора, где предполагается, что поток может идти только вниз. Мы либо проталкиваем избыточный поток из активной вершины вниз по исходящему ребру, либо воображаем, что активная вершина временно перемещается вверх, и мы можем протолкнуть избыточный поток как бы вниз по входящему ребру.

Пример проталкивания напора


Рис. 22.25.  Пример проталкивания напора

В алгоритме проталкивания напора используется список активных узлов, в которых входящие потоки больше исходящих (показаны под каждой диаграммой сети). Одна из версий алгоритма представляет собой цикл, который выбирает из этого списка активный узел и проталкивает поток по исходящим ребрам, пока узел не перестанет быть активным. При этом возможно появление других активных узлов. В данном примере мы проталкиваем поток по ребру 0-1, после чего узел 1 становится активным. После этого мы проталкиваем поток по ребрам 1-2 и 1-3, и узел 1 перестает быть активным, зато активируются узлы 2 и 3. Затем мы проталкиваем поток по ребру 2-4, и 2 становится неактивным узлом. Однако пропускной способности ребра 3-4 недостаточно, чтобы протолкнуть поток по нему и сделать узел 3 неактивным, поэтому поток проталкивается по ребру 3-1 обратно в вершину 1, которая снова активируется. Теперь можно протолкнуть поток по ребру 1-2, а затем по 2-4, после чего все узлы становятся неактивными, и мы получаем максимальный поток.

На рис. 22.26 показан пример, который показывает, почему метод проталкивания напора может оказаться лучше метода расширения путей. В рассматриваемой сети любой метод расширения путей многократно добавляет в длинный путь крошечные порции потока, медленно наполняя ребра этого пути, пока не будет достигнут максимальный поток. В отличие от этого, метод проталкивания напора заполняет ребра этого длинного пути при первом проходе по нему, а затем распределяет этот поток непосредственно в сток без повторного прохода по длинному пути.

Как и в случае алгоритмов с использованием расширяющих путей, мы используем остаточные сети (см. определение 22.4), чтобы выявлять ребра, через которые можно проталкивать поток. Каждое ребро остаточной сети представляет собой потенциальное место, где можно протолкнуть поток. Если ребро остаточной сети имеет то же направление, что и соответствующее ребро транспортной сети, мы увеличиваем поток, а если противоположное, то уменьшаем. Если увеличение потока заполняет ребро или уменьшение потока опустошает ребро, то соответствующее ребро исчезает из остаточной сети. В алгоритмах проталкивания напора мы используем дополнительный механизм, который позволяет решить, какие ребра в остаточной сети помогут устранить активные вершины.

Неудобный случай для алгоритма Форда-Фалкерсона


Рис. 22.26.  Неудобный случай для алгоритма Форда-Фалкерсона

Эта сеть представляет семейство сетей с V вершинами, для которых любой алгоритм расширения путей требует V/2 путей длиной V/2 (поскольку каждый расширяющий путь должен содержать длинный вертикальный путь), а для этого потребуется время, пропорциональное V2. Алгоритмы проталкивания напора находят максимальный поток в таких сетях за линейное время.

Определение 22.6. Функция высоты (height function) для данного потока в транспортной сети - это множество неотрицательных весов вершин h(0) ... h(V - 1), такое, что h(t) = 0 для стока t, и для каждого ребра u-v из остаточной сети этого потока. Подходящее ребро (eligible edge) - это такое ребро u-v остаточной сети, что h(u) = h(v) + 1.

Тривиальная функция высоты, для которой не существует подходящих ребер, имеет вид h(0) = h(1) = ... = h(V - 1) = 0. Если задать h(s) = 1, то любое ребро, исходящее из истока и содержащее поток, соответствует подходящему ребру в остаточной сети.

Можно дать более интересное определение функции высоты, присвоив каждой вершине длину кратчайшего пути от нее до стока (расстояние до корня в любом дереве BFS для обратной сети с корнем в t - см. рис. 22.27). Эта функция высоты верна потому, что h(t) = 0, и для любой пары вершин u и v, соединенных ребром u-v, любой кратчайший путь, ведущий в t и начинающийся ребром u-v, имеет длину h(v) + 1; таким образом, длина кратчайшего пути из u в t, т.е. h(u), должна быть меньше или равна этому значению. Эта функция играет особую роль, т.к. она помещает каждую вершину на максимально возможную высоту. Обратные рассуждения показывают, что вершина t должна находиться на высоте 0; единственными вершинами, которые могут находиться на высоте 1, являются вершины, которым соответствуют ребра, направленные в t в остаточной сети; на высоте 2 могут находиться только вершины, ребра из которых направлены в вершины, которые могут быть на высоте 1 и т.д.

Первоначальная функция высоты


Рис. 22.27.  Первоначальная функция высоты

Справа изображено дерево BFS с корнем в вершине 5 обращения сети, приведенной слева. Массив h, индексированный именами вершин, содержит расстояние от каждой вершины сети до корня и представляет собой адекватную функцию высоты: для каждого ребра u-v сети значение h[u] меньше или равно h[v]+1.

Лемма 22.9. Для любого потока и связанной с ним функции высоты высота любой вершины не превосходит длины кратчайшего пути из этой вершины в сток в остаточной сети.

Доказательство. Пусть для любой заданной вершины u значение d есть длина кратчайшего пути из u в t, и пусть u = u1, u2, ..., ud = t - кратчайший путь. Тогда

Смысл функции высоты в следующем: если высота активного узла меньше, чем высота истока, то, возможно, можно как-то протолкнуть поток из этого узла в сток; если высота активного узла больше, чем высота истока, нужно избыток этого узла вытолкнуть обратно в исток. Чтобы обосновать последний факт, мы изменим направление леммы 22.9, где длина кратчайшего пути выступает в качестве верхней границы высоты; вместо этого будем рассматривать высоту как нижнюю границу длины кратчайшего пути.

Следствие. Если высота вершины больше V, то в остаточной сети не существует пути из этой вершины в сток.

Доказательство. Если существует путь из этой вершины в сток, то из леммы 22.9 следует, что длина пути будет больше V, но этого не может быть, т.к. в сети имеется всего V вершин.

Теперь, когда мы понимаем эти базовые механизмы, нетрудно описать обобщенный алгоритм проталкивания напора. Мы начнем с любой функции высоты и назначим нулевой поток всем ребрам, за исключением исходящих из истока, которые заполним до пропускной способности. Затем будем повторять следующее действие до тех пор, пока не останется активных вершин:

Выбираем активную вершину. Проталкиваем поток через некоторое подходящее ребро, исходящее из этой вершины (если оно есть). Если таких ребер нет, увеличиваем на 1 высоту вершины.

Мы не конкретизируем, как выглядит исходная функция высоты, как выбирается активная вершина, как выбирается подходящее ребро или какая величина потока выталкивается. Мы будем называть этот обобщенный метод как реберный (edge-based) алгоритм проталкивания напора.

Этот алгоритм зависит от выбора функции высоты, позволяющей находить подходящие ребра. Мы также используем функцию высоты как для доказательства того, что этот алгоритм вычисляет максимальный поток, так и для анализа производительности. Поэтому необходимо обеспечить, чтобы функция высоты оставалась адекватной на протяжении всей работы алгоритма.

Лемма 22.10. Реберный алгоритм проталкивания напора сохраняет адекватность функции высоты.

Доказательство. Мы увеличиваем значение h(u) только при отсутствии таких ребер u-v, что h(u) = h(v) + 1. То есть до увеличения h(u) для всех ребер u-v справедливо h(u) < h(v) + 1, а после него справедливо . Для любого входящего ребра w-u увеличение h(u) на 1 наверняка сохранит справедливость неравенства . Увеличение h(u) не влияет на неравенства для любых других ребер, и мы никогда не увеличиваем h(t) (или h(s)). Из этих двух наблюдений следует результат.

Весь излишек потока берет начало в истоке. Образно говоря, обобщенный алгоритм проталкивания напора пытается протолкнуть избыточный поток в сток, а если это ему не удается, он проталкивает избыточный поток обратно в исток. Он работает так потому, что узлы с избытком всегда остаются связанными с истоком в остаточной сети.

Лемма 22.11. Во время выполнения алгоритма проталкивания напора на транспортной сети существует (ориентированный) путь в соответствующей остаточной сети из каждой активной вершины в исток, и не существуют (ориентированные) пути из истока в сток остаточной сети.

Доказательство. Выполняется методом индукции. Вначале поток протекает только по ребрам, исходящим из истока, которые заполнены до их пропускной способности, так что активными вершинами в сети являются только конечные вершины этих ребер. Поскольку эти ребра заполнены до пропускной способности, в остаточной сети существует ребро, ведущее из каждой из этих вершин в исток, но там нет ребер, исходящих из истока. Поэтому указанное свойство справедливо для первоначального потока.

Исток достижим из любой активной вершины, т.к. единственный способ увеличения множества активных вершин состоит в проталкивании потока из активной вершины вниз по подходящему ребру. По индуктивному предположению эта операция оставляет в остаточной сети ребро, ведущее из принимающей вершины в активную вершину, из которой достижим исток.

Вначале никакой другой узел остаточной сети не доступен из истока. Впервые некоторый узел u становится доступным из истока тогда, когда поток выталкивается в обратном направлении по ребру u-s (из-за чего в остаточную сеть добавляется ребро s-u). Но это возможно только когда h(u) больше h(s), а это может случиться только после увеличения h(u), т.к. в остаточной сети нет ребер, ведущих в вершины с меньшей высотой. Аналогичное рассуждение показывает, что все узлы, достижимые из истока, имеют большую высоту. Но поскольку высота стока всегда равна 0, он не может быть достижим из истока.

Следствие. Во время выполнения алгоритма проталкивания напора высоты вершин всегда меньше 2V.

Доказательство. Достаточно рассмотреть только активные вершины, поскольку высота неактивных вершин либо имеет то же значение, либо на 1 больше значения, которое вершина имела, когда последний раз была активной. Такие же рассуждения, как и в доказательстве леммы 22.9 показывают, что из наличия пути из заданной активной вершины в исток следует, что высота этой вершины максимум на V- 2 больше высоты истока (этот путь не может проходить через t ). Высота истока никогда не меняется и первоначально не может быть больше V. Следовательно, высота активных вершин не может превышать значения 2V- 2, и ни одна из вершин не может иметь высоту 2V или больше.

Обобщенный алгоритм проталкивания напора легко сформулировать и реализовать. Возможно, пока непонятно, почему он вычисляет максимальный поток. Функция высоты - ключевой фактор, позволяющий доказать это.

Лемма 22.12. Алгоритм проталкивания напора вычисляет максимальный поток.

Доказательство. Вначале нужно доказать, что алгоритм однажды останавливается. Должен наступить момент, когда не останется ни одной активной вершины. Как только мы вытолкнем весь избыточный поток из некоторой вершины, эта вершина не может снова стать активной, пока какая-то часть этого потока не будет вытолкнута назад, а это может случиться только при увеличении высоты этой вершины. Если имеется последовательность активных вершин неограниченной длины, хотя бы одна из вершин должна появиться в этой последовательности неограниченное число раз, а это может случиться только при неограниченном возрастании ее высоты, что противоречит лемме 22.9.

Если активных вершин нет, то поток допустим. А поскольку, согласно лемме 22.11, в остаточной сети пути из истока в сток нет, то рассуждения, использованные в доказательстве леммы 22.5, показывают, что поток в сети является максимальным.

Можно уточнить доказательство обязательного завершения алгоритма и получить границу времени выполнения в худшем случае O(V2E ). Детали этого доказательства мы оставляем читателям на самостоятельную проработку (см. упражнения 22.66 и 22.67), а здесь отдадим предпочтение более простому доказательству леммы 22.13, которое относится к более специальной версии алгоритма. А именно, рассматриваемые нами реализации будут основаны на более конкретных повторяющихся инструкциях:

Выбираем активную вершину. Увеличиваем поток в подходящем ребре, исходящем из этой вершины (по возможности заполняем его), и продолжаем так, пока вершина не перестанет быть активной или не останется подходящих ребер. В последнем случае увеличиваем высоту вершины.

То есть после выбора вершины мы выталкиваем из нее как можно больший поток. Если в вершине все равно останется избыток потока, но не останется подходящих ребер, мы увеличиваем на 1 высоту этой вершины. Мы будем называть этот метод вершинным (vertex-based) алгоритмом проталкивания напора. Это специальный случай реберного обобщенного алгоритма, где одна и та же вершина выбирается до тех пор, пока она не перестанет быть активной, либо пока не будут использованы все подходящие ребра, исходящие из нее. Доказательство леммы 22.12 верно для любой реализации реберного обобщенного алгоритма, а это значит, что вершинный алгоритм вычисляет максимальный поток.

Программа 22.5 представляет собой реализацию обобщенного вершинного алгоритма, который использует обобщенную очередь для хранения активных вершин. Эта непосредственная реализация только что описанного метода представляет собой семейство алгоритмов, которые отличаются только исходными функциями высоты (см. например, упражнение 22.52) и реализацией АТД обобщенной очереди. В ней предполагается, что обобщенная очередь отбрасывает повторяющиеся вершины; в противном случае в программу 22.5 можно добавить код, запрещающий занесение дубликатов в очередь (см. упражнения 22.61 и 22.62).

Программа 22.5. Реализация вычисления максимального потока методом проталкивания напора

В данной реализации обобщенного вершинного алгоритма проталкивания напора для вычисления максимального потока используется обобщенная очередь, которая не принимает дубликаты активных вершин. Вектор wt содержит избыточный поток каждой вершины и таким образом неявно определяет множество активных вершин. Здесь вершина s первоначально активна, но никогда не возвращается в очередь, а t никогда не бывает активной. Основной цикл выбирает активную вершину v, затем проталкивает поток через каждое из его подходящих ребер (и при необходимости заносит вершины, принимающие потоки, в список активных вершин) до тех пор, пока либо v станет неактивной, либо будут перебраны все подходящие ребра. В последнем случае высота v увеличивается, а сама она возвращается в очередь.

  template <class Graph, class Edge>
  class MAXFLOW
 { const Graph &G;
   int s, t;
   vector<int> h, wt;
   void initheights();
 public:
   MAXFLOW(const Graph &G, int s, int t) : G(G),
  s(s), t(t), h(G.V()), wt(G.V(), 0)
  { initheights();
    GQ gQ(G.V());
    gQ.put(s); wt[t] = -(wt[s] = M*G.V());
    while (!gQ.empty())
   { int v = gQ.get();
     typename Graph::adjIterator A(G, v);
     for (Edge* e = A.beg(); !A.end(); e = A.nxt())
    { int w = e->other(v);
      int cap = e->capRto(w);
      int P = cap < wt[v] ? cap : wt[v];
      if (P > 0 && v == s || h[v] == h[w]+1)
     { e->addflowRto(w, P);
       wt[v] -= P; wt[w] += P;
       if ((w != s) && (w != t)) gQ.put(w);
     }
    }
     if (v != s && v != t && wt[v] > 0)
    { h[v]+ + ; gQ.put(v); }
   }
  }
 } ;
   

Пожалуй, наиболее простой структурой данных для хранения активных вершин является очередь FIFO. На рис. 22.28 рис. 22.28 показана работа этого алгоритма на демонстрационной сети. Из рисунка видно, что последовательность активных вершин удобно разбить на последовательность этапов. Этап - содержимое очереди после обработки всех вершин из предыдущего этапа. Это помогает ограничить общее время выполнения алгоритма.

Лемма 22.13. Время выполнения реализации алгоритма проталкивания напора на базе очереди FIFO в худшем случае пропорционально V2E .

Доказательство. Мы ограничим количество этапов, воспользовавшись для этой цели функцией потенциала. Наши рассуждения представляют собой простой пример применения мощной технологии анализа алгоритмов и структур данных, которые будут более подробно рассмотрены в части 8.

Определим величину ф равной 0, если в сети нет активных вершин, и равной максимальной высоте активных вершин в противном случае. Теперь рассмотрим, как влияет каждый этап на величину ф. Пусть h0(s) - начальная высота истока. Вначале ф = h0(s) , а в конце ф = 0.

Прежде всего отметим, что количество этапов, в которых высота некоторых вершин возрастает, не превосходит значения 2V2 - h0(s) , поскольку, согласно следствию из леммы 22.11, высоту каждой из V вершин можно увеличить не более чем до 2V . Поскольку ф может возрастать только при увеличении высоты каких-либо вершин, количество этапов, при которых ф возрастает, не может быть больше, чем 2V2 - h0(s) .

А если на каком-то этапе высота ни одной из вершин не увеличена, то значение ф должно быть уменьшено не менее чем на 1, поскольку этап проталкивает весь избыточный поток из каждой активной вершины в вершины с меньшей высотой. Из всех этих фактов следует, что количество этапов не должно быть больше 4V2 : значение ф вначале равно h0(s) и может быть увеличено максимум 2V2 - h0(s) раз, т.е. оно может быть уменьшено максимум 2V2 раз. Худший случай для каждого этапа возникает, когда все вершины находятся в очереди, и все ребра просмотрены, что и дает сформулированную границу для общего времени выполнения.

Эта граница жесткая. На рис. 22.29 рис. 22.29 изображено семейство транспортных сетей, для которых количество этапов, использованных алгоритмом проталкивания напора, пропорционально V2.

Поскольку наши реализации используют неявное представление остаточной сети, они просматривают ребра, исходящие из вершины, даже если их нет в остаточной сети (чтобы проверить, находятся они там или нет). Можно показать, что границу V2E из леммы 22.13 можно снизить до V3, если использовать реализацию, в которой используется явное представление остаточной сети. Хотя эта теоретическая граница - наименьшая из тех, с которыми нам приходилось сталкиваться для задачи вычисления максимального потока, она вряд ли заслуживает нашего внимания, особенно для разреженных графов, которые часто встречаются на практике (см. 22.63-22.65).

Как обычно, границы для худшего случая слишком осторожны и поэтому редко пригодны для прогнозирования производительности алгоритма на реальных сетях (хотя разница и не так велика, как в случае алгоритмов с использованием расширяющих путей). Например, алгоритм FIFO находит поток в сети, представленной на рис. 22.30, за 15 этапов, а оценка из леммы 22.13 утверждает лишь, что количество этапов не превышает 182.

 Остаточная сеть (очередь FIFO и проталкивание напора)


Рис. 22.28.  Остаточная сеть (очередь FIFO и проталкивание напора)

На этом рисунке показаны транспортные сети (слева) и остаточные сети (справа) для каждого этапа алгоритма проталкивания напора на базе очереди FIFO. Содержимое очередей показано под диаграммами транспортных сетей, а метки расстояний - под диаграммами остаточных сетей. На начальном этапе мы проталкиваем поток по ребрам 0-1 и 0-2, что делает вершины 1 и 2 активными. На втором этапе мы проталкиваем поток через эти две вершины в 3 и 4, после чего они становятся активными, а вершина 1 перестает быть активной (вершина 2 остается активной, а метка ее расстояния увеличивается). На третьем этапе мы проталкиваем поток через вершины 3 и 4 в вершину 5, что делает их неактивными (вершина 2 все еще активна, а ее метка расстояния снова увеличивается). На четвертом этапе 2 остается единственным активным узлом, а ребро 2-0 становится приемлемым из-за увеличения метки расстояния, и одна единица потока проталкивается обратно по ребру 2-0, после чего вычисление завершается.

 Худший случай для алгоритма проталкивания напора с использованием очереди FIFO


Рис. 22.29.  Худший случай для алгоритма проталкивания напора с использованием очереди FIFO

Данная сеть представляет семейство сетей с Vвер-шинами, на которых общее время работы алгоритма проталкивания напора пропорционально V2. Она состоит из ребер с единичной пропускной способностью, исходящих из истока (вершина 0), и горизонтальных ребер с пропускной способностью v - 2, ведущих слева направо в сторону стока (вершина 10). На начальном этапе алгоритма проталкивания напора (вверху) мы проталкиваем одну единицу потока из каждого ребра, ведущего из истока, после чего становятся активными все вершины, кроме истока и стока. В стандартном представлении списками смежности они попадают в очередь FIFO активных вершин в обратном порядке, как показано в строке под диаграммой сети. На втором этапе (в центре) мы выталкиваем единицу потока из 9 в 10, сделав (временно) 9 неактивной; затем выталкиваем единицу потока из 8 в 9, сделав 8 (временно) неактивной, а 9 активной; затем выталкиваем единицу потока из вершины 7 в 8, сделав 7 (временно) неактивной, а 8 активной и т.д. Только вершина 1 остается неактивной. На третьем этапе (внизу) мы выполняем аналогичный процесс, чтобы сделать неактивной вершину 2. Этот процесс продолжается на протяжении V- 2 этапов.

Чтобы повысить производительность, можно попробовать использовать в программе 22.5 стек, рандомизированную очередь или любой другой вид обобщенной очереди. Один из подходов, хорошо зарекомендовавших себя на практике, заключается в такой реализации обобщенной очереди, чтобы функция GQGet возвращала максимальную активную вершину. Мы будем называть этот метод алгоритмом вычисления максимального потока методом проталкивания напора из вершины с максимальной высотой (highest-vertex-preflow-push). Эту стратегию можно реализовать с помощью очереди с приоритетами, но если воспользоваться конкретными особенностями высот, то можно обеспечить выполнение операций на обобщенной очереди за постоянное время. Было доказано, что граница времени выполнения этого алгоритма в худшем случае равна (для разреженных графов V5/2 ) (см. раздел ссылок). Как обычно, эта оценка слишком осторожна. Было предложено и множество других вариантов проталкивания напора, и некоторые из них понижают границу времени для худшего случая до значения, близкого к VE (см. раздел ссылок).

В таблице 22.2 сведены значения производительности, показанные алгоритмами проталкивания напора, которые соответствуют приведенным в таблице 22.1 результатам алгоритмов с использованием расширяющих путей, для двух моделей сетей из раздела 22.2. Эти эксперименты для различных алгоритмов проталкивания напора показывают гораздо меньшие различия в производительности, чем в случае различных методов расширения путей.

 Алгоритм проталкивания напора (с очередью FIFO)


Рис. 22.30.  Алгоритм проталкивания напора (с очередью FIFO)

Данная последовательность диаграмм показывает, как реализация метода проталкивания напора с использованием очереди FIFO находит максимальный поток в демонстрационной сети. Он работает поэтапно. Сначала он проталкивает максимально возможный поток из истока по исходящим из него ребрам (слева вверху). Затем он проталкивает поток из каждого такого узла и продолжает так, пока не будет достигнут баланс для всех узлов.

В этой таблице приведены значения производительности (количество вершин, где величина потока увеличивалась, и количество просмотренных узлов из списков смежности) для различных алгоритмов проталкивания напора на нашем примере евклидовой сети с соседними связями (со случайными значениями пропускных способностей и максимальным потоком 286), и с единичными пропускными способностями (максимальный поток равен 6). Различия в результатах этих методов для обоих видов сетей минимальны. Для случайных пропускных способностей количество проверяемых ребер примерно то же, что и для алгоритма расширения путей (см. таблицу 22.1). Для единичных пропускных способностей алгоритмы с использованием расширяющих путей просматривают в этих сетях значительно меньше ребер.

Таблица 22.2. Эмпирическое сравнение алгоритмов проталкивания напора
ВершиныРебра
Случайные пропускные способности от 1 до 50
Кратчайшие пути245057746
Поиск в глубину247658258
Случайный поиск236355470
Единичные пропускные способности
Кратчайшие пути119228356
Поиск в глубину123429040
Случайный поиск139033018

Существует множество способов изучения реализаций алгоритмов проталкивания напора. Мы уже рассмотрели три основных варианта:

Можно рассмотреть несколько других возможностей и множество вариаций каждой из них, которые порождают множество различных алгоритмов (см., например, упражнения 22.56-22.60). Зависимость производительности алгоритмов от характеристик входной сети еще больше увеличивает количество возможностей.

Два рассмотренных нами обобщенных алгоритма (расширением путей и проталкиванием напора) входят в число важнейших алгоритмов, описанных в исследовательской литературе по алгоритмам вычисления максимальных потоков. Поиск более совершенных алгоритмов вычисления максимальных потоков все еще остается перспективной областью для дальнейших исследований. Исследователи разрабатывают и исследуют новые алгоритмы и реализации, надеясь получить более быстрые алгоритмы для практических проблем, а то и простой линейный алгоритм для задачи вычисления максимального потока. Но пока такого алгоритма нет, можно спокойно работать с рассмотренными нами алгоритмами и реализациями: многочисленные исследования показывают, что они вполне эффективны для широкого класса практических задач, требующих вычисления максимальных потоков.

Упражнения

22.49. Опишите работу алгоритма проталкивания напора в сети со сбалансированными пропускными способностями.

22.50. Воспользуйтесь концепциями, описанными в данном разделе (функции высоты, подходящие ребра и проталкивание потока через ребра), для описания алгоритмов вычисления максимального потока расширением путей.

22.51. Покажите в стиле рис. 22.28 потоки и остаточные сети после каждого этапа работы алгоритма проталкивания напора с очередью FIFO для отыскания максимального потока в транспортной сети с рис. 22.10.

22.52. Реализуйте функцию initheights() для программы 22.5 с помощью поиска в ширину от стока.

22.53. Выполните упражнение 22.51 для алгоритма проталкивания напора из вершины с максимальной высотой.

22.54. Измените программу 22.5, чтобы реализовать алгоритм проталкивания напора из вершины с максимальной высотой, реализовав обобщенную очередь в виде очереди с приоритетами. Проведите эмпирическое тестирование, чтобы добавить в таблицу 22.2 строку для этого варианта алгоритма.

22.55. Начертите графики зависимости количества активных вершин, а также количества вершин и ребер в остаточной сети по мере выполнения алгоритма проталкивания напора с очередью FIFO для конкретных примеров различных сетей (см. упражнения 22.7-22.12).

22.56. Реализуйте обобщенный реберный алгоритм проталкивания напора, который использует обобщенную очередь подходящих ребер. Эмпирически сравните его работу для различных сетей (см. упражнения 22.7-22.12) с базовыми методами, уделяя особое внимание наиболее быстрым реализациям обобщенных очередей.

22.57. Добавьте в программу 22.5 периодический пересчет высот вершин, равных кратчайшим путям в сток в остаточной сети.

22.58. Обдумайте идею равномерного распределения избыточного потока из вершин в исходящие ребра, а не заполнения одних до насыщения, оставляя другие пустыми.

22.59. Эмпирически определите оправданность вычисления кратчайших путей для первоначального задания функции высоты в программе 22.5, сравнив ее производительность для различных сетей (см. упражнения 22.7-22.12) с производительностью при первоначальном обнулении высот вершин.

22.60. Поэкспериментируйте с гибридными методами, содержащими различные сочетания описанных выше идей. Эмпирически сравните различные виды сетей (см. упражнения 22.7-22.12) с базовыми методами, уделяя особое внимание наиболее быстрым вариантам.

22.61. Добавьте в программу 22.5 явное отбрасывание повторяющихся вершин перед занесением в обобщенную очередь. Эмпирически определите для различных сетей (см. упражнения 22.7-22.12), как влияют эти изменения на реальное время выполнения.

22.62. Как повлияет разрешение заносить повторяющиеся вершины в обобщенную очередь на границу времени выполнения в худшем случае из леммы 22.13?

22.63. Измените реализацию программы 22.5, чтобы использовать явное представление остаточной сети.

о 22.64. Уточните границу O (V3) из леммы 12.13 для реализации из упражнения 22.63. Указание. Приведите отдельные границы для количества проталкиваний, которые соответствуют удалению ребер из остаточной сети, и для количества проталкиваний, в результате которых не появляются заполненные или пустые ребра.

22.65. Эмпирически определите для различных сетей (см. упражнения 22.7-22.12) влияние использования явных представлений остаточной сети (см. упражнение 22.63) на реальные значения времени выполнения.

22.66. Докажите, что в реберном обобщенном алгоритме проталкивания напора количество проталкиваний, которые соответствуют удалению ребра из остаточной сети, меньше 2VE . Считайте, что в реализации используется явное представление остаточной сети.

22.67. Докажите, что в реберном обобщенном алгоритме проталкивания напора количество проталкиваний, которые не соответствуют удалению ребра из остаточной сети, меньше 4V2( V+E). Указание. Используйте сумму высот активных вершин в качестве функции потенциала.

22.68. Эмпирически определите фактическое количество проверяемых ребер и отношение времени прогона к V для различных версий алгоритма проталкивания напора и для различных сетей (см. упражнения 22.7-22.12). Рассмотрите различные алгоритмы, описанные в тексте и в предыдущих упражнениях, уделяя особое внимание тем, которые лучше работают на больших разреженных сетях. Сравните ваши результаты с результатами из упражнения 22.34.

22.69. Напишите клиентскую программу для графической анимации динамики алгоритма проталкивания напора. Эта программа должна создавать изображения, подобные рис. 22.30 и другим рисункам из этого раздела (см. упражнение 22.48). Протестируйте полученную реализацию на евклидовых сетях из упражнений 22.7-22.12.

Сведения к вычислению максимального потока

В этом разделе мы рассмотрим ряд задач, сводящихся к задаче вычислении максимального потока, и покажем, что алгоритмы из разделов 22.2 и 22.3 важны в более широком контексте. Мы можем снимать различные ограничения на сети и решать другие сетевые задачи, мы можем решать другие задачи обработки сетей и графов, и мы можем решать задачи, которые не относятся к сетевым. В этом разделе будут рассмотрены примеры такого использования - вычисление максимального потока в качестве общей модели решения задачи.

Мы также изучим взаимосвязь между вычислением максимального потока и более сложными задачами, чтобы сформировать контекст для рассмотрения этих задач в дальнейшем. В частности, задача о максимальном потоке представляет собой специальный случай задачи отыскания потока минимальной стоимости, о которой пойдет речь в разделах 22.5 и 22.6. Кроме того, мы покажем, как формулировать задачи вычисления максимального потока в виде задач линейного программирования, которые мы будем рассматривать в части 8. Задача отыскания потока минимальной стоимости и линейное программирование представляют собой более общие модели решения задач, чем модель максимального потока. И хотя обычно решение задач о максимальном потоке с помощью специальных алгоритмов, описанных в разделах 22.2 и 22.3, менее трудоемко, чем с помощью алгоритмов для решения более общих задач, важно знать о взаимосвязи между моделями решения задач при переходе к более сложным моделям.

Мы будем употреблять термин стандартная задача о максимальном потоке (standard maxflow problem) для версии задачи, которую мы изучали до сих пор (максимальный поток в st-сетях с ограниченной пропускной способностью ребер). Этот термин будет применяться исключительно для облегчения ссылок в данном разделе. Вначале мы рассмотрим сведения, на примере которых убедимся, что ограничения в стандартной задаче о максимальном потоке несущественны, т.к. к стандартной задаче сводятся или эквивалентны несколько других задач о потоках. Любую из эквивалентных задач можно принять в качестве " стандартной " задачи. Простым примером такой задачи, который уже упоминался как следствие из леммы 22.1, является задача отыскания циркуляции в сетях, позволяющая получить максимальный поток в заданном ребре. Затем мы рассмотрим другие способы постановки задачи, в каждом случае отмечая их связь со стандартной задачей.

Максимальный поток в сетях общего вида. Нужно найти поток в сети, который максимизирует суммарный отток из ее истоков (и, следовательно, приток в ее стоки). По соглашению, в сети, в которой нет истоков и/или стоков, поток считается нулевым.

Лемма 22.14. Задача о максимальном потоке в сетях общего вида эквивалентна задаче о максимальном потоке в st-сетях.

Доказательство. Ясно, что алгоритм вычисления максимального потока для сетей общего вида будет работать и на st-сетях, поэтому нужно лишь проверить, что общая задача сводится к задаче об st-сетях. Для этого сначала найдем истоки и стоки (пользуясь, например, методом для инициализации очереди из программы 19.8) и завершим работу с кодом возврата 0, если нет ни того, ни другого. Затем добавим фиктивную вершину-исток s и ребра из нее во все истоки сети (пропускная способность каждого такого ребра равна оттоку из его конечной вершины), а также фиктивную вершину-сток t и ребра из каждого стока сети в нее (пропускная способность каждого такого ребра равна оттоку его начальной вершины). Это сведение показано на рис. 22.31. Любой максимальный поток в st-сети непосредственно соответствует максимальному потоку в исходной сети.

 Сведение задачи с несколькими истоками и стоками


Рис. 22.31.  Сведение задачи с несколькими истоками и стоками

В верхней сети имеются три истока (0, 1 и 2) и два стока (5 и 6). Чтобы найти поток, который максимизирует суммарный поток из истоков и в стоки, мы вычисляем максимальный поток в st-сети, представленной внизу. Это копия исходной сети, в которую добавлены исток 7 и сток 8. Из вершины 7 в каждый исходный исток проводим ребро, пропускная способность которого равна суммарной пропускной способности ребер, исходящих из этого истока. А в вершину 8 ведут ребра из каждого исходного стока сети, пропускная способность которых равна суммарной пропускной способности ребер, входящих в стоки исходной сети.

Ограничения на пропускную способность вершин. Пусть задана некоторая транспортная сеть, и необходимо вычислить максимальный поток, удовлетворяющий дополнительным ограничениям: поток через каждую вершину не должен превышать некоторую фиксированную пропускную способность.

Лемма 22.15. Задача вычисления максимального потока в транспортной сети с ограничениями на пропускную способность вершин эквивалентна стандартной задаче о максимальном потоке.

Доказательство. Здесь также можно воспользоваться любым алгоритмом, который решает задачу с ограничениями на пропускные способности, для решения стандартной задачи (установив пропускную способность в каждой вершине больше, чем ее приток или ее отток), поэтому достаточно доказать сводимость к стандартной задаче. Для заданной транспортной сети с ограниченными пропускными способностями построим стандартную транспортную сеть с двумя вершинами u и u* , соответствующими каждой исходной вершине u. При этом все ребра, входящие в исходную вершину, идут в u, все исходящие ребра выходят из u* , а пропускная способность ребра u-u* равна пропускной способности исходной вершины. Это построение показано на рис. 22.32. Потоки в ребрах вида u*-v при любом максимальном потоке в преобразованной сети дают максимальный поток в исходной сети, который должен удовлетворять ограничениям на пропускную способность вершин из-за наличия ребер вида u-u*.

 Удаление пропускных способностей вершин


Рис. 22.32.  Удаление пропускных способностей вершин

Чтобы решить задачу вычисления максимального потока в сети, показанной вверху, и чтобы поток через каждую вершину не превосходил пропускной способности, заданной в массиве capV, индексированном именами вершин, мы строим стандартную сеть, показанную внизу. Для каждого ребра u-v соединяем новую вершину u* (где u* означает v+V) с каждой вершиной u, добавляем ребро, пропускная способность которого равна пропускной способности вершины u, и включаем ребро u*-v. Пары u-u* обведены овалами. Любой поток в нижней сети непосредственно соответствует потоку в верхней сети, который удовлетворяет ограничениям на пропускные способности вершин.

Допуская наличие нескольких стоков и истоков или накладывая ограничения на пропускные способности, мы вроде бы обобщаем задачу о максимальном потоке; однако смысл лемм 22.14 и 22.15 в том, что эти задачи нисколько не сложнее стандартной задачи. Теперь рассмотрим версию задачи, которая поначалу кажется более легкой для решения.

Ациклические сети. Нужно найти максимальный поток в ациклической сети. Осложняет ли наличие циклов в транспортной сети задачу вычисления максимального потока?

Мы уже сталкивались со многими примерами задач обработки орграфов, которые существенно усложняются при наличии в орграфах циклов. Пожалуй, самый наглядный из них - задача вычисления кратчайших путей во взвешенных орграфах, когда веса ребер могут принимать отрицательные значения (см. лекция №21) - которая легко решается за линейное время при отсутствии циклов и NP-трудна, если циклы допускаются. Как ни странно, задача о максимальном потоке в ациклических сетях не становится проще.

Лемма 22.16. Задача вычисления максимального потока в ациклических сетях эквивалентна стандартной задаче о максимальном потоке.

Доказательство. Здесь также достаточно показать, что стандартная задача сводится к ациклической задаче. Для любой исходной сети с V вершинами и E ребрами построим сеть с 2V + 2 вершинами и E + 3V ребрами, которая не только не содержит циклов, но и обладает простой структурой.

Пусть u* означает u + V. Построим двудольный граф, в котором каждой вершине u исходной сети сответ-ствуют две вершины u и u* , а каждому ребру u-v исходной сети соответствует ребро u-v* с такой же пропускной способностью. Затем добавим в этот двудольный граф исток s и сток t, а для каждой вершины u исходного графа - ребро s-u и ребро u*-t , пропускная способность каждого из которых равна суммарной пропускной способности ребер, исходящих из вершины u в исходной сети. Кроме того, добавим ребра из u в u* с пропускной способностью X + 1, где X - суммарная пропускная способность ребер исходной сети. Это построение показано на рис. 22.33.

Чтобы показать, что любой максимальный поток в исходной сети соответствует максимальному потоку в преобразованной сети, рассмотрим вместо потоков сечения. Для любого заданого st-сечения размера с в исходной сети мы покажем, как построить st-сечение размера c + X в преобразованной сети. А для любого заданного минимального сечения размера c + X в преобразованной сети мы покажем, как построить st-сечение размера с в исходной сети. Таким образом, если задано минимальное сечение в преобразованной сети, то соответствующее ему сечение в преобразованной сети также является минимальным. Более того, наше построение дает поток, величина которого равна пропускной способности минимального сечения, т.е. это максимальный поток.

Для любого заданного сечения исходной сети, которое отделяет исток от стока, пусть S - множество вершин истока, а T - множество вершин стока. Построим сечение преобразованной сети, помещая вершины из S в множество, содержащее вершину s, вершины из T - в множество, содержащее вершину t, и помещая для всех u вершины u и u* в одну и ту же часть сечения, как показано на рис. 22.33. Для каждой вершины u в множестве ребер сечения содержится либо s-u , либо u*-t , а u-v* содержится во множестве ребер сечения тогда и только тогда, когда ребро u-v содержится в множестве ребер сечения исходной сети. Следовательно, общая пропускная способность сечения равна пропускной способности сечения в исходной сети плюс Х.

Пусть задано любое минимальное st-сечение преобразованной сети, и пусть S* есть множество вершины s, а T* - множество вершины t. Нам нужно построить сечение с той же пропускной способностью, и чтобы вершины u и u* входили в одно и то же множество для всех u - тогда соответствие из предыдущего абзаца даст сечение в исходной сети, что и завершит доказательство. Во-первых, если u содержится в S* , а t содержится в T* , то u-u* должно быть перекрестным ребром, но это невозможно: u-u* не может принадлежать никакому минимальному сечению, поскольку сечение, состоящее из всех ребер, которые соответствуют ребрам исходного графа, имеет меньшую стоимость. Во-вторых, если u содержится в T* , а u* содержится в S* , то s-u должно принадлежать этому сечению, т.к. это единственное ребро, соединяющее s и u. Но мы можем построить сечение той же стоимости, заменив все ребра, направленные из u, на s-u и переместив u в S* .

Если в преобразованной сети задан какой-либо поток величины с + X, то мы просто помещаем в каждое соответствующее ребро исходной сети поток величины с. Преобразование сечения, описанное в предыдущем абзаце, не влияет на это, т.к. оно работает с ребрами, в которых поток равен нулю.

 Сведение к ациклической сети


Рис. 22.33.  Сведение к ациклической сети

Каждая вершина u верхней сети соответствует двум вершинам u и u* (где u* означает u+V) нижней сети, а каждое ребро u-v верхней сети соответствует ребру u-v* нижней сети. Кроме того, в нижней сети имеются ребра u-u* без пропускных способностей, источник s с ребрами, ведущими в каждую вершину, не отмеченную звездочкой, и сток t, в который ведут ребра из каждой вершины, помеченной звездочкой. Серые и белые вершины (и ребра, соединяющие серые вершины с белыми) иллюстрируют прямую взаимосвязь сечений в обеих сетях (см. текст).

В результате такого сведения мы получаем не только ациклическую сеть, но и простую двудольную структуру. Данное сведение означает, что эти сети с более простой структурой при желании можно принять в качестве стандартных вместо сетей общего вида. Может показаться, что такая специальная структура позволяет создать более быстрые алгоритмы вычисления максимального потока. Однако описанное сведение показывает, что любой алгоритм, разработанный для таких специальных ациклических сетей, можно использовать для решения задач о максимальном потоке в сетях общего вида ценой небольших дополнительных затрат. Вообще-то классические алгоритмы вычисления максимального потока используют гибкость общей сетевой модели: оба рассмотренных нами подхода - алгоритм расширения путей и проталкивания напора - используют концепцию остаточной сети, предусматривающий добавление циклов в сеть. Когда приходится решать задачу о максимальном потоке в ациклической сети, для ее решения обычно используется стандартный алгоритм, ориентированный на сети общего вида.

Довольно сложное построение в лемме 22.16 показывает, что доказательства сводимости требуют особого внимания и даже изобретательности. Такие доказательства важны потому, что не все версии задачи о максимальном потоке эквивалентны стандартной задаче, а нам нужно знать пределы применимости наших алгоритмов. Математики продолжают исследования в этой области, т.к. сведения различных практических задач не всегда известны, как показывают следующий пример.

Максимальный поток в неориентированных сетях. Неориентированная транспортная сеть - это взвешенный граф с целочисленными весами ребер, которые интерпретируются как пропускные способности. Циркуляция в таких сетях - это назначение весов и

направлений ребрам, которое удовлетворяет условию: поток в каждом ребре не превышает его пропускную способность, а суммарный поток в каждую вершину равен суммарному потоку из этой вершины. Задача о неориентированном максимальном потоке (рис. 22.34) состоит в том, чтобы найти циркуляцию, которая максимизирует поток в заданном направлении в заданном ребре (т.е. из некоторой заданной вершины s в другую заданную вершину t). Пожалуй, эта задача более естественно, чем стандартная задача, соответствует модели трубопровода для перекачки жидкостей: она позволяет протекать жидкости по трубам в обоих направлениях.

 Сведение задачи о неориентированных сетях


Рис. 22.34.  Сведение задачи о неориентированных сетях

Чтобы решить задачу о максимальном потоке в неориентированной сети, ее можно рассматривать как ориентированную сеть с ребрами в обоих направлениях. Обратите внимание, что каждому ребру неориентированной сети соответствует четыре ребра в соответствующей ей остаточной сети.

Лемма 22.17. Задача о максимальном потоке для неориентированных st-сетей сводится к задаче о максимальном потоке для st-сетей.

Доказательство. Пусть дана неориентированная сеть. Построим ориентированную сеть с теми же вершинами, в которой каждому ребру исходной сети соответствуют два разнонаправленных ребра с пропускными способностями, равными пропускной способности неориентированного ребра. Понятно, что любой поток в исходной сети соответствует потоку такой же мощности, что и в преобразованной сети. Обратное также верно: если в неориентированной сети через ребро u-v протекает поток f, а через ребро v-u протекает поток g, то если , можно поместить поток величины f- g в ребро u-v ориентированной сети, а в противном случае поместить поток величины g -f в ребро v-u. Следовательно, любой максимальный поток в ориентированной сети является максимальным потоком в неориентированной сети: это построение дает поток, а любой поток большей величины в ориентированной сети будет соответствовать некоторому потоку большей величины в неориентированной сети; однако такого потока не существует.

Это доказательство не утверждает, что задача о максимальном потоке в неориентированной сети эквивалентна стандартной задаче. То есть не исключено, что вычисление максимальных потоков в неориентированных сетях может иметь меньшую трудоемкость, чем вычисление максимальных потоков в стандартных сетях (см. упражнение 22.81).

Итак, мы можем обрабатывать сети с несколькими стоками и истоками, неориентированные сети, сети с ограниченными пропускными способностями вершин и многие другие виды сетей (см., например, упражнение 22.79), используя алгоритмы вычисления максимального потока для st-сетей, рассмотренные в двух предыдущих разделах. Более того, лемма 22.16 утверждает, что все эти задачи можно решить даже при помощи алгоритма, который работает только на ациклических сетях.

Теперь мы рассмотрим задачу, которая не является задачей именно о максимальном потоке, но ее можно свести к задаче о максимальном потоке и решить с помощью алгоритмов вычисления максимального потока. Это один из способов формализовать базовую версию задачи распределения товаров, которая была описана в начале данной главы.

Допустимый поток. Предположим, что каждой вершине транспортной сети присвоен вес, который можно рассматривать как предложение (если он положителен) или как спрос (если отрицателен), а сумма весов всех вершин сети равна нулю. Будем считать поток допустимым (feasible), если разность между притоком и оттоком каждой вершины равна весу этой вершины (предложению, если разность положительна, и спросу, если отрицательна). Пусть дана такая сеть, и нужно определить, возможны ли допустимые потоки. На рис. 22.35 рис. 22.35 приведен пример задачи о допустимом потоке.

Вершины с предложением соответствуют складам в задаче о распределении товаров, вершины со спросом соответствуют розничным торговым точкам, а ребра соответствуют дорогам на маршрутах грузоперевозок. Задача о допустимом потоке отвечает на следующий основной вопрос: можно ли найти такой способ доставки товаров, при котором везде предложение соответствует спросу.

Лемма 22.18. Задача о допустимом потоке сводится к задаче о максимальном потоке.

Доказательство. Пусть поставлена задача о допустимом потоке. Построим сеть с теми же вершинами и ребрами, но без весов у вершин. Вместо этого добавим вершину истока s, ребра из которой ведут в каждую вершину предложения с весом, равным предложению соответствующей вершины, и добавим вершину стока t, в которую ведет ребро из каждой вершины со спросом (так что вес такого ребра положителен). Решим задачу о максимальном потоке на этой сети. В исходной сети имеется допустимый поток тогда и только тогда, когда все ребра, исходящие из истока, и все ребра, ведущие в сток, заполнены при таком потоке до их пропускных способностей. На рис. 22.36 приведен пример такого сведения.

 Допустимый поток


Рис. 22.35.  Допустимый поток

В задаче о допустимом потоке в дополнение к пропускным способностям ребер заданы величины предложения и спроса. Необходимо найти такой поток, при котором отток равен сумме поставки и притока в вершинах предложения, и приток равен сумме оттока и спроса в вершинах спроса. Слева представлена задача о допустимом потоке, а правее - три ее решения.

Разработка классов, которые реализуют сведения рассмотренного выше вида, может оказаться сложной задачей для программистов - главным образом потому, что обрабатываемые объекты представлены сложными структурами данных. Нужно ли строить новую сеть, чтобы свести еще одну задачу к стандартной задаче о максимальном потоке? Некоторые из задач требуют для своего решения дополнительных данных - таких как пропускные способности вершин или предложение или спрос - поэтому построение стандартной сети без этих данных может оказаться оправданным. Использование указателей на ребра играет здесь важную роль: если скопировать ребра сети, а затем вычислить максимальный поток, то что потом делать с результатом? Перенос вычисленного потока (вес каждого ребра) из одной сети в другую, когда обе сети представлены списками смежности - отнюдь не тривиальное действие. Когда используются указатели на ребра, новая сеть содержит копии указателей, а не ребер, что позволяет переслать значения потоков непосредственно в сеть клиента. Программа 22.6 представляет собой реализацию, которая демонстрирует некоторые из этих моментов в классе для решения задач поиска подходящего потока с помощью сведения согласно лемме 22.16.

 Сведение задачи о допустимом потоке


Рис. 22.36.  Сведение задачи о допустимом потоке

Это стандартная сеть, построенная для задачи о допустимом потоке с рис. 22.35 с помощью добавления ребер из нового истока в вершины с предложением (пропускные способности каждого такого ребра равны величине предложения) и ребер в новый сток из вершин со спросом (пропускные способности каждого такого ребра равны величине спроса). В сети на рис. 22.35 допустимый поток имеется тогда и только тогда, когда в этой сети имеется поток (максимальный поток), который заполняет все ребра, исходящие из стока, и все ребра, входящие в исток.

Каноническим примером задачи о потоках, которую невозможно решить с помощью модели максимального потока, и которая будет рассмотрена в разделах 22.5 и 22.6, является расширение задачи о допустимом потоке. Добавим второе множество весов ребер, которые будут интерпретироваться как стоимости, определим через них стоимости потоков и поставим задачу найти допустимый поток минимальной стоимости. Эта модель формализует общую задачу распределения товаров. Мы хотим не только знать, можно ли транспортировать товары, но и какова минимальная стоимость развозки.

Все задачи, рассмотренные в данном разделе, имеют одну и ту же цель (вычисление потоков в транспортной сети), поэтому не удивительно, что мы можем решать их с помощью модели, предназначенной для решения задач транспортной сети. Как утверждает теорема о максимальном потоке и минимальном сечении, алгоритмы вычисления максимального потока позволяют решать задачи обработки графов, которые с виду имеют мало общего с потоками. Теперь мы обратимся к примерам такого рода.

Двудольное сопоставление с максимальной мощностью. Пусть задан двудольный граф. Нужно найти такое множество ребер максимальной мощности, что каждая вершина соединена не более чем с одной другой вершиной.

Для краткости мы будем далее называть эту задачу просто задачей двудольного сопоставления (bipartite matching), кроме случаев, в которых нужно отличить ее от других подобных задач. Она формализует задачу трудоустройства, которая была упомянута в начале данной главы. Вершины соответствуют соискателям и работодателям, а ребра - отношению " взаимной заинтересованности в трудоустройстве". Решение задачи двудольного сопоставления приводит к максимально возможному трудоустройству. На рис. 22.37 показан двудольный граф, моделирующий задачу с рис. 22.3.

Без графов очень трудно искать прямое решение задачи о двудольном сопоставлении. Например, эта задача сводится к следующей комбинаторной головоломке: " найти максимальное подмножество из множества пар целых чисел (взятых из непересекающихся множеств), чтобы ни в одной паре не было одинаковых чисел " . Пример, приведенный на рис. 22.37, соответствует решению этой головоломки для пар 0-6, 0-7, 0-8, 1-6 и т.д. Эта задача на первый взгляд кажется простой, однако, как видно на примере задачи поиска гамильтонова пути из лекция №17 (и многих других задач), какой-либо примитивный систематический перебор пар до возникновения противоречия может потребовать экспоненциального времени. То есть существует слишком много подмножеств пар, чтобы можно было проверить все варианты; решение этой задачи должно быть достаточно разумным, чтобы использовать лишь немногие из них. Решение головоломок сопоставления вроде только что описанной или разработка алгоритмов, которые способны эффективно решать любую такую головоломку - очень непростые задачи, позволяющие продемонстрировать возможности и пользу модели сетевых потоков, которая предоставляет разумный способ построения двудольного сопоставления.

Программа 22.6. Решение задачи о допустимом потоке сведением к задаче о максимальном потоке

Данный класс решает задачу о допустимом потоке с помощью сведения ее к задаче о максимальном потоке, используя построение, представленное на рис. 22.36. Конструктор принимает в качестве аргументов сеть и вектор sd, индексированный именами вершин. Если значение sd[i] положительно, то оно представляет предложение в вершине i, а если отрицательно, то спрос.

Как показано на рис. 22.36, конструктор строит новый граф с теми же ребрами, но с двумя дополнительными вершинами s и t. Ребра из s ведут в узлы с предложением, а ребра из вершин со спросом ведут в вершину t. Затем он находит максимальный поток и проверяет, заполнены ли все дополнительные ребра до их пропускных способностей.

  #include "MAXFLOW.cc"
  template <class Graph, class Edge>
  class FEASIBLE
 { const Graph &G;
   void freeedges(const Graph &F, int v)
  { typename Graph::adjIterator A(F, v);
    for (EDGE* e = A.beg(); !A.end(); e = A.nxt())
   delete e;
  }
 public:
   FEASIBLE(const Graph &G, vector<int> sd) : G(G)
  { Graph F(G.V()+2);
    for (int v = 0; v < G.V(); v++)
   { typename Graph::adjIterator A(G, v);
     for (EDGE* e = A.beg(); !A.end(); e = A.nxt())
    F.insert(e);
   }
    int s = G.V(), t = G.V()+1;
    for (int i = 0; i < G.V(); i++)
   if (sd[i] >= 0)
     F.insert(new EDGE(s, i, sd[i]));
   else
     F.insert(new EDGE(i, t, -sd[i]));
    MAXFLOW<Graph, Edge>(F, s, t);
    freeedges(F, s); freeedges(F, t);
  }
 } ;
   

 Двудольное сопоставление


Рис. 22.37.  Двудольное сопоставление

Этот пример задачи двудольного сопоставления служит формальным представлением задачи трудоустройства с рис. 22.3. Отыскание наилучшего способа, позволяющего студентам получить рабочие места, эквивалентно выявлению в этом двудольном графе максимального количества ребер с не связанными между собой вершинами.

Лемма 22.19. Задача о двудольном сопоставлении сводится к задаче о максимальном потоке.

Доказательство. Для заданной задачи двудольного сопоставления построим экземпляр задачи о максимальном потоке: проведем все ребра из одного множества в другое, добавим исток, из которого ребра ведут во все элементы одного множества, и сток, в который ведут ребра из элементов другого множества. Чтобы преобразовать полученный орграф в сеть, назначим каждому ребру пропускную способность, равную 1. Это построение показано на рис. 22.38.

Теперь любое решение задачи о максимальном потоке для этой сети дает решение соответствующей задачи о двудольном сопоставлении. Сопоставление в точности соответствует тем ребрам, соединяющим вершины обоих множеств, которые заполнены до пропускной способности алгоритмом вычисления максимального потока. Во-первых, сетевой поток всегда дает допустимое сопоставление: поскольку у каждой вершины имеется входящее (из истока) или исходящее (в сток) ребро с единичной пропускной способностью, то через каждую вершину может пройти только единица потока - откуда, в свою очередь, следует, что каждая вершина может быть включена только в одно сопоставление. Во-вторых, ни одно сопоставление не может иметь больше ребер, поскольку любое такое сопоставление может непосредственно привести к потоку, большему того, который получен алгоритмом вычисления максимального потока.

Например, на рис. 22.38 алгоритм вычисления максимального потока с использованием расширяющих путей может использовать пути s-0-6-t, s-1-7-t, s-2-8-t, s-4-9-t, s-5-10-t и s-3-6-0-7-1-11-t для вычисления сопоставления 0-7, 1-11, 2-8, 3-6, 4-9 и 5-10. Следовательно, существует способ трудоустройства всех студентов в задаче, показанной на рис. 22.3.

 Сведение задачи о двудольном сопоставлении


Рис. 22.38.  Сведение задачи о двудольном сопоставлении

Чтобы найти максимальное сопоставление в двудольном графе (вверху) мы строим st-сеть (внизу), направляем все ребра из верхнего ряда в нижний ряд, добавляем новый исток с ребрами в каждую вершину верхнего ряда, добавляем новый сток, в который ведут ребра из каждой вершины нижнего ряда, и назначаем всем ребрам единичную пропускную способность. При любом потоке можно заполнить не более одного ребра из всех ребер, исходящих из каждой вершины верхнего ряда, а также не более одного ребра из всех ребер, входящих в каждую вершину нижнего ряда, поэтому решение задачи о максимальном потоке для этой сети дает максимальное сопоставление для двудольных графов.

Программа 22.7 является клиентской программой, которая считывает задачу о двудольном сопоставлении из стандартного ввода и решает ее при помощи сведения, описанного в этом доказательстве. Каким будет время выполнения этой программы для очень больших сетей? Разумеется, время выполнения зависит от используемого алгоритма вычисления максимального потока и его реализации. Кроме того, следует учесть, что получаемые при этом сети имеют специальную структуру (двудольные транспортные сети с единичной пропускной способностью ребер). Поэтому время выполнения различных известных нам алгоритмов вычисления максимальных потоков не приближается к границам для худшего случая; более того, можно существенно снизить эти границы. Например, нетрудно улучшить оценку для первой рассмотренной нами границы - для обобщенного алгоритма с использованием расширяющих путей.

Следствие. Время вычисления сопоставления максимальной мощности для двудольного графа равно O(VE).

Доказательство. Непосредственно следует из леммы 22.6.

Работу алгоритмов расширения путей на двудольной сети с единичной пропускной способностью ребер описать нетрудно. Каждый расширяющий путь наполняет одно ребро, ведущее из истока, и одно ребро, ведущее в сток. Эта ребра никогда не используются как обратные, поэтому может быть не более V расширяющих путей. Для каждого алгоритма, который находит расширяющие пути за время, пропорциональное E, действительна верхняя граница, пропорциональная VE.

Программа 22.7. Поиск двудольного сопоставления сведением к задаче вычисления максимального потока

Этот клиент считывает из стандартного ввода задачу о двудольном сопоставлении с V + V вершинами и E ребрами, а затем строит транспортную сеть, соответствующую этой задаче, находит максимальный поток в сети и использует полученное решение для вывода максимального двудольного сопоставления.

  #include "GRAPHbasic.cc"
  #include "MAXFLOW.cc"
  int main(int argc, char *argv[])
 { int s, t, N = atoi(argv[1]);
   GRAPH<EDGE> G(2*N+2);
   for (int i = 0; i < N; i++)
  G.insert(new EDGE(2*N, i, 1));
   while (cin >> s >> t)
  G.insert(new EDGE(s, t, 1));
   for (int i = N; i < 2*N; i++)
  G.insert(new EDGE(i, 2*N+1, 1));
   MAXFLOW<GRAPH<EDGE>, EDGE>(G, 2*N, 2*N+1);
   for (int i = 0; i < N; i++)
  { GRAPH<EDGE>::adjIterator A(G, i);
    for (EDGE* e = A.beg(); !A.end(); e = A.nxt())
   if (e->flow() == 1 && e->from(i))
     cout << e->v() << "-" << e->w() << endl;
  }
 }
   

В таблице 22.3 показаны значения производительности для методов решения задач двудольного сопоставления с использованием различных алгоритмов вычисления расширяющих путей. Из этой таблицы видно, что фактические значения времени решения этой задачи ближе к значению VE для худшего случая, чем к оптимальному (линейному) времени. Разумный выбор и соответствующая настройка реализации, вычисляющей максимальный поток, позволяет увеличить быстродействие этого метода в раз (см. упражнения 22.91 и 22.92).

Здесь представлены показатели производительности (количество расширенных вершин и количество затронутых узлов в списках смежности) для различных алгоритмов вычисления максимального потока методом расширения путей при вычислении максимального двудольного сопоставления для графов с 2000 парами вершин и 500 ребрами (вверху), а также 4000 ребрами (внизу). Для этой задачи наиболее эффективным оказался поиск в глубину.

Таблица 22.3. Эмпирические данные для двудольного сопоставления
ВершиныРебра
500 ребер, мощность сопоставления 347
Кратчайший путь1071945599
Максимальная пропускная способность1067868407
Поиск в глубину1073477601
Случайный поиск1073644070
4000 ребер, мощность сопоставления 971
Кратчайший путь34838280585
Максимальная пропускная способность68576573560
Поиск в глубину341091266146
Случайный поиск35694310656

Эта задача - характерный случай, с которым мы чаще сталкиваемся при изучении новых задач и более общих моделей решения задач, и который демонстрирует эффективность сведения как практического средства решения задач. Если мы сможем найти сведение к известной общей задаче - например, к задаче о максимальном потоке - мы обычно рассматриваем это как существенное продвижение на пути к практическому решению. Ведь оно, по меньшей мере, указывает не только на разрешимость задачи, но и на существование многочисленных эффективных алгоритмов решения рассматриваемой задачи. Во многих случаях для решения такой задачи проще использовать существующий класс вычисления максимального потока и перейти к следующей задаче. Если производительность критична, можно опробовать различные алгоритмы вычисления максимальных потоков и их реализации или разработать на основе их поведения более совершенный алгоритм специального назначения. Общая модель решения задачи предоставляет верхнюю границу, которую можно либо принять, либо попытаться улучшить, и множество реализаций, которые продемонстрировали свою эффективность при решении многих других задач.

Далее мы обсудим задачи, имеющие отношение к связности графов. Но прежде чем рассматривать использование алгоритмов вычисления максимального потока для решения задач связности, мы исследуем применение теоремы о максимальном потоке и минимальном сечении и завершим то, что начали в лекция №18 - доказательства базовых теорем, относящихся к путям и сечениям в неориентированных графах. Эти доказательства еще раз подчеркивают фундаментальное значение теоремы о максимальном потоке и минимальном сечении.

Лемма 22.20. (Теорема Менгера). Минимальное количество ребер, удаление которых разъединяет две вершины в орграфе, равно максимальному количеству не пересекающихся по ребрам путей между этими двумя вершинами.

Доказательство. Для заданного графа определим транспортную сеть с теми же вершинами и ребрами, при этом пропускные способности всех ребер равны 1. Согласно лемме 22.2 любую st-сеть можно представить в виде множества не пересекающихся по ребрам путей из истока в сток, а количество таких путей равно величине потока. Пропускная способность любого st-сечения равна мощности этого сечения. Из всех этих фактов и теоремы о максимальном потоке и минимальном сечении вытекает нужный результат.

Соответствующие результаты для неориентированных графов и для вершинной связности в орграфах и неориентированных графах получаются с помощью сведений, подобных рассмотренным в тексте, и оставлены на самостоятельную проработку (упражнения 22.94-22.96).

А теперь обратимся к алгоритмическим следствиям из непосредственной взаимосвязи между потоками и связностью, которая устанавливается теоремой о максимальном потоке и минимальном сечении. Пожалуй, одним из важнейших алгоритмических следствий является лемма 22.5 (задача о минимальном сечении сводится к задаче о максимальном потоке), однако обратное утверждение не доказано (см. упражнение 22.47). Вроде бы информация о минимальном сечении должна облегчить задачу вычисления максимального потока, но никому еще не удалось показать, как это сделать. Этот простой пример подчеркивает необходимость осторожности при сведении одних задач к другим.

Однако алгоритмы вычисления максимального потока все-таки можно использовать для решения различных задач о связности. Например, они помогают решить первые нетривиальные задачи, с которыми мы сталкивались в лекция №18.

Реберная связность. Каково минимальное количество ребер, которые необходимо удалить, чтобы разделить заданный граф на две части? Найти множество ребер минимальной мощности, обеспечивающее такое разделение.

Вершинная связность. Каково минимальное количество вершин, которые необходимо удалить, чтобы разделить заданный граф на две части? Найти множество вершин минимальной мощности, обеспечивающее такое разделение.

Эти задачи формулируются и для орграфов, поэтому всего требуется рассмотреть четыре задачи. Как и в случае теоремы Менгера, мы подробно рассмотрим одну из них (реберная связность в неориентированных графах), а остальные оставим читателям на самостоятельную проработку.

Лемма 22.21. Время, необходимое для определения реберной связности в неориентированном графе, равно O(E2).

Доказательство. Мы можем вычислить минимальный размер любого сечения, которое разделяет две заданные вершины - для этого нужно вычислить максимальный поток в st-сети, построенной из графа с назначением единичной пропускной способности каждому ребру. Реберная связность равна минимальному из этих значений на всех парах вершин.

Однако нет необходимости выполнять вычисления для всех пар вершин. Пусть s* - вершина графа с минимальной степенью. Ее степень не может быть больше 2E/V Рассмотрим произвольное минимальное сечение графа. По определению, количество ребер в сечении равно реберной связности графа. Вершина s* принадлежит одному из множеств вершин сечения, а в другое множество должна входить некоторая вершина t, поэтому размер любого минимального сечения, разделяющего вершины s* и t, должен быть равен реберной связности графа. Следовательно, если мы решим V- 1 задач о минимальном потоке (с s* в качестве истока и любой другой вершиной в качестве стока), то полученная величина минимального потока будет равна реберной связности сети.

Далее, любой алгоритм вычисления максимального потока с использованием расширяющих путей для вершины s* в качестве истока использует максимум 2E/V путей. Значит, если мы используем любой метод, требующий не более E шагов для определения расширяющего пути, то понадобится не более (V- 1)(2E/V)E шагов для определения реберной связности, откуда и следует искомый результат.

Этот метод, в отличие от всех других примеров из этого раздела, не является непосредственным сведением одной задачи к другой, но он дает практический алгоритм для вычисления реберной связности. Опять-таки, тщательная настройка реализации вычисления максимального потока для этой конкретной задачи позволяет повысить производительность, и можно решить эту задачу за время, пропорциональное VE (см. раздел ссылок). Доказательство леммы 22.21 служит примером более общего понятия эффективного (выполняемого за полиномиальное время) сведения, с которым мы впервые столкнулись в лекция №21, и которое играет существенную роль в теории алгоритмов, о которой пойдет речь в части 8. Такое сведение не только доказывает разрешимость задачи, но и предлагает алгоритм для ее решения, а это важные первые шаги при решении новой комбинаторной задачи.

Мы завершим данный раздел анализом строгой математической формулировки задачи о максимальном потоке в терминах линейного программирования (см. лекция №21). Это упражнение полезно тем, что оно помогает увидеть связи с другими задачами, которые можно сформулировать аналогично.

Формулировка этой задачи тривиальна: имеется система неравенств, в которой каждому ребру соответствует одна переменная, каждому ребру соответствуют два неравенства, и каждой вершине соответствует одно равенство. Значение переменной - это величина потока в ребре, неравенства указывают, что величина потока в ребре должна быть в пределах от нуля до пропускной способности этого ребра, а равенства указывают, что суммарный поток в ребрах, ведущих в каждую вершину, должен быть равен суммарному потоку в ребрах, исходящих из этой вершины.

Пример такого построения приведен на рис. 22.39. Аналогично любую задачу о максимальном потоке можно преобразовать в задачу линейного программирования (LP-задачу). Линейное программирование - универсальный способ решения комбинаторных задач, и многие рассматриваемые нами задачи можно сформулировать как LP-задачи. Тот факт, что задачи о максимальном потоке решаются легче, чем LP-задачи, можно объяснить тем, что в LP-формулировке задач о максимальном потоке ограничения имеют особую структуру, которая характерна не для всех LP-задач.

 Формулировка задачи о максимальном потоке в терминах линейного программирования


Рис. 22.39.  Формулировка задачи о максимальном потоке в терминах линейного программирования

Эта LP-задача эквивалентна задаче о максимальном потоке для сети с рис. 22.5. Имеется по одному неравенству для каждого ребра (которое указывает, что поток в этом ребре не может превосходить его пропускной способности) и по одному равенству для каждой вершины (которое указывает, что приток должен быть равен оттоку). В этой сети используется фиктивное ребро из стока в исток, которое предназначено для перехвата сетевого потока (см. обсуждение после леммы 22.2).

Даже если LP-задача в общем виде намного сложнее, чем задачи специального вида, такие как задача о максимальном потоке, имеются мощные алгоритмы, которые могут эффективно решать LP-задачи. Время выполнения этих алгоритмов в худшем случае наверняка превосходит время выполнения в худшем случае рассмотренных нами специальных алгоритмов, однако громадный опыт, накопленный за последние несколько десятилетий, показал их эффективность при решении задач, которые часто возникают на практике.

Построение, представленное на рис. 22.39, служит доказательством того, что задача о максимальном потоке сводится к LP-задаче, если не настаивать, чтобы величины потоков были целыми числами. Мы будем подробно рассматривать LP-задачи в части VIII, и там опишем, как справиться с тем, что формулировка задачи о максимальном потоке в терминах линейного программирования не содержит требования, что результаты должны иметь целые значения.

Этот контекст дает четкую математическую основу, которая позволяет рассматривать даже более общие задачи и создавать еще более мощные алгоритмы решения этих задач. Задача о максимальном потоке легко поддается решению и сама по себе довольно универсальна, о чем говорят примеры, приведенные в данном разделе. Далее мы рассмотрим более сложную задачу (но не такую сложную, как линейное программирование), которая охватывает еще более широкий класс практических задач. Мы обсудим варианты построения решений для этих все более общих моделей в конце этой главы, заложив основу для их полного анализа в части 8.

Упражнения

22.70. Определите класс для вычисления циркуляции с максимальным потоком в заданном ребре. Разработайте реализацию, использующую функцию MAXFLOW.

22.71. Определите класс для вычисления максимального потока без ограничения на количество истоков и стоков. Разработайте реализацию, использующую функцию MAXFLOW.

22.72. Определите класс для вычисления максимального потока в неориентированной сети. Разработайте реализацию, использующую функцию MAXFLOW.

22.73. Определите класс для вычисления максимального потока в сети с ограничениями на пропускные способности вершин. Разработайте реализацию, использующую функцию MAXFLOW.

22.74. Разработайте класс для решения задач о допустимом потоке, который включает функции-члены, позволяющие клиентам устанавливать значения предложения, а затем проверять правильность выбора значений потоков для каждой вершины.

22.75. Выполните упражнение 22.18 для случая, когда в каждой точке распределения имеется ограничение на пропускную способность (т.е. существует предел количества товаров, которые могут храниться в этой точке в любой заданный момент времени).

22.76. Покажите, что задача о максимальном потоке сводится к задаче о допустимом потоке (т.е. эти задачи эквивалентны).

22.77. Найдите допустимый поток для транспортной сети, представленной на рис. 22.10, при наличии дополнительных ограничений: вершины 0, 2 и 3 являются вершинами предложения с весом 4, а вершины 1, 4 и 5 являются вершинами предложения с весами, соответственно, 1, 3 и 5.

22.78. Напишите программу, которая принимает в качестве входных данных расписание соревнований некоторой спортивной лиги и текущее положение команд и определяет, выбыла ли заданная команда из турнира. Считайте, что связи отсутствуют. Указание. Сведите данную задачу к задаче о допустимом потоке с одним истоком, величина предложения которого равна суммарному количеству игр, которые осталось сыграть в текущем сезоне; с узлами стока, соответствующими каждой паре команд, величина спроса в которых равна количеству оставшихся игр между этой парой; и с узлами распределения, соответствующими каждой команде. Ребра должны соединять узлы предложения с узлами распределения каждой команды (с пропускными способностями, равными количеству игр, которые команда должна выиграть, чтобы выиграть у X, если X выиграет все свои оставшиеся игры). И должно существовать ребро (без пропускной способности), соединяющее узел распределения каждой команды с каждым узлом спроса, имеющим отношение к этой команде.

22.79. Докажите, что задача о максимальном потоке для сетей с низкими пропускными способностями ребер сводится к стандартной задаче о максимальном потоке.

22.80. Докажите, что для сетей с низкими пропускными способностями ребер задача вычисления минимального потока (с учетом пропускных способностей) сводится к задаче о максимальном потоке (см. упражнение 22.79).

22.81. Докажите, что задача о максимальном потоке в st-сетях сводится к задаче о максимальном потоке в неориентированных сетях, либо найдите алгоритм вычисления максимального потока для неориентированных сетей, время выполнения которого в худшем случае значительно лучше, чем для алгоритмов из разделов 22.2 и 22.3.

22.82. Найдите все сопоставления с пятью ребрами для двудольного графа, представленного на рис. 22.37.

22.83. Усовершенствуйте программу 22.7, чтобы вершинам можно было присваивать символические имена, а не номера (см. программу 17.10).

22.84. Докажите, что задача двудольного сопоставления эквивалентна задаче вычисления максимального потока в сети с единичными пропускными способностями всех ребер.

22.85. Пример на рис. 22.3 можно интерпретировать как описание заявок студентов на работу и заявок работодателей на студентов, которые не обязательно взаимны. Применимо ли сведение, описанное в тексте, к ориентированной задаче двудольного сопоставления, вытекающей из этой интерпретации, где ребра двудольного графа направлены (в любом направлении) из одного множества в другое? Докажите это или приведите контрпример.

22.86. Постройте семейство задач двудольного сопоставления, где средняя длина расширяющих путей, используемых любым алгоритмом расширения путей для решения соответствующей задачи о максимальном пути, пропорциональна E.

22.87. Покажите в стиле рис. 22.28 работу алгоритма проталкивания напора с использованием очереди FIFO на сети двудольного сопоставления с рис. 22.3838.

22.88. Добавьте в таблицу 22.3 данные для различных алгоритмов проталкивания напора.

22.89. Предположим, что в задаче двудольного сопоставления два множества имеют размеры S и T, причем S << T. Приведите как можно более точную границу времени решения этой задачи в худшем случае для сведения, описанного в лемме 22.19, и реализации алгоритма Форда-Фалкерсона с использованием максимальных расширяющих путей (см. лемму 22.8).

22.90. Выполните упражнение 22.89 для реализации алгоритма проталкивания напора с использованием очереди FIFO (см. лемму 22.13).

22.91. Добавьте в таблицу 22.3 данные для реализаций, которые используют метод построения всех расширяющих путей, описанный в упражнении 22.37.

22.92. Докажите, что время выполнения метода, описанного в упражнении 22.91, равно O(JV E) для поиска в ширину.

22.93. На основе эмпирического исследования постройте график зависимости ожидаемого количества ребер в максимальном сопоставлении в случайных двудольных графах с V + V вершинами и E ребрами для разумного множества значений V и значений E, достаточных для получения гладкой кривой от ноля до V.

22.94. Докажите теорему Менгера (лемма 22.20) для неориентированных графов.

22.95. Докажите, что минимальное количество вершин, удаление которых разъединяет две вершины в орграфе, равно максимальному количеству путей между этими двумя вершинами, не пересекающихся по вершинам. Указание. Воспользуйтесь раз-щеплением вершин, как на рис. 22.32.

22.96. Распространите ваше доказательство из упражнения 22.95 на неориентированные графы.

22.97. Реализуйте класс реберной связности для АТД графа из лекция №17, конструктор которого использует алгоритм, описанный в этом разделе, для обеспечения работы общедоступной функции-члена, которая возвращает значение связности графа.

22.98. Добавьте в решение упражнения 22.97 занесение в пользовательский вектор минимального множества ребер, которое разделяет граф. Какой размер этого вектора должен предусмотреть пользователь?

22.99. Разработайте алгоритм вычисления реберной связности орграфов (минимальное количество ребер, удаление которых нарушает сильную связность орграфа). На основе этого алгоритма реализуйте класс для АТД орграфа из главы 19 лекция №19.

22.100. На основе решений упражнений 22.95 и 22.96 разработайте алгоритмы для вычисления вершинной связности орграфов и неориентированных графов. На базе этих алгоритмов реализуйте классы, соответственно, для АТД орграфа из лекция №19, и для АТД графа из лекция №17 (см. упражнения 22.97 и 22.98).

22.101. Опишите, как вычислить вершинную связность орграфа путем решения VlgV задач о максимальном потоке в сети с единичными пропускными способностями. Указание. Воспользуйтесь теоремой Менгера и двоичным поиском.

22.102. Пользуясь решением упражнения 22.97, эмпирически определите реберную связность различных графов (см. упражнения 17.63-17.76).

22.103. Сформулируйте в терминах линейного программирования задачу о максимальном потоке в транспортной сети, представленную на рис. 22.10.

22.104. Сформулируйте в терминах линейного программирования задачу двудольного сопоставления, представленную на рис. 22.37.

Потоки минимальной стоимости

В наличии многочисленных решений для конкретной задачи о максимальном потоке нет ничего удивительного. Но при этом возникает вопрос: можно ли ввести дополнительные критерии, чтобы выбрать один из них? Например, понятно, что существует множество решений для задач вычисления потоков в сети с единичными пропускными способностями, представленных нарис. 22.22. Возможно, для нас лучше решение, использующее наименьшее количество ребер или кратчайшие пути. А возможно, нам нужно знать, существует ли решение, использующее непересекающиеся пути. Эти задачи сложнее, чем стандартная задача о максимальном потоке, они описываются более общей моделью, известной как задача о потоке минимальной стоимости (mincost flow problem).

Как и в случае задачи о максимальном потоке, существует множество эквивалентных способов постановки задачи о потоке минимальной стоимости. В этом разделе мы подробно рассмотрим стандартную формулировку этой задачи, а различные сведения к другим задачам изучим в разделе 22.7.

Конкретно, мы воспользуемся моделью максимального потока минимальной стоимости (mincost-maxflow): введем тип ребра, который содержит целочисленную стоимость, используем стоимость ребра для естественного определения стоимости потока, а затем поставим задачу определения максимального потока минимальной стоимости. Мы не только получим эффективный алгоритм для этой задачи, но и построим модель ее решения, получившую широкое применение.

Определение 22.8. Стоимость потока (flow cost) через ребро в транспортной сети со стоимостями ребер равна произведению потока в этом ребре на стоимость. Стоимость (cost) потока есть сумма стоимостей потоков в ребрах этого потока.

Мы все так же считаем, что пропускные способности выражаются положительными целыми числами, меньшими M, а стоимости ребер - неотрицательными числами, меньшими C. (Отказ от использования отрицательных стоимостей мотивируется главным образом соображениями удобства, о чем будет сказано в разделе 22.7.) Как и ранее, мы присваиваем этим граничным значениям специальные обозначения, т.к. от них зависит время выполнения некоторых алгоритмов. С учетом этих простых предположений формулировка задачи, которую мы намереваемся решить, не представляет трудностей.

Максимальный поток минимальной стоимости. Пусть задана транспортная сеть со стоимостями ребер. Нужно найти такой максимальный поток, стоимость которого не больше стоимости любого другого максимального потока.

На рис. 22.40 показаны различные максимальные потоки в транспортной сети со стоимостями, в том числе и максимальный поток минимальной стоимости. Разумеется, трудоемкость минимизации стоимости не уступает поиску максимального потока, о котором речь шла в разделах 22.2 и 22.3. Стоимость добавляет новое измерение, а это существенно усложняет дело. Но все-таки мы можем справиться с этими трудностями с помощью обобщенного алгоритма, который похож на алгоритм расширения путей для задачи о максимальном потоке.

Есть много других задач, которые сводятся к задаче о максимальном потоке минимальной стоимости или эквивалентны ей. Например, интересна следующая постановка задачи, т.к. она охватывает задачу распределения товаров, которая была рассмотрена в начале данного раздела.

Допустимый поток минимальной стоимости. Напомним, что поток в сети, содержащей вершины с весами (предложение, если вес положительный, и спрос, если вес отрицательный), называется допустимым (feasible), если сумма весов вершин отрицательна, а разность между притоком и оттоком в каждой вершине равна нулю. Нужно найти в такой сети допустимый поток минимальной стоимости.

Чтобы описать сетевую модель для решения задачи о допустимом потоке минимальной стоимости, мы для краткости используем термин распределительная сеть (distribution network), который будет означать " транспортная сеть с пропускными способностями ребер, стоимостями ребер и весами предложения или спроса в вершинах " .

В приложениях распределения товаров вершины с предложением соответствуют складам, вершины со спросом - розничным торговым точкам, ребра - маршрутам перевозок, величины предложения или спроса соответствуют количеству поставляемого или получаемого материала, а пропускные способности ребер - количеству и грузоподъемности автомобилей на соответствующих маршрутах. Естественной интерпретацией стоимости каждого ребра является стоимость перевозки единицы товара по этому ребру (стоимость пробега грузовой машины при перевозке единицы товара по соответствующему маршруту). Если задан поток, то стоимость потока через ребро составляет некоторую часть стоимости продвижения потока в сети, которую можно сопоставить этому ребру. Если задано количество материала, который должен быть доставлен по некоторому заданному ребру, то можно вычислить стоимость доставки, умножив стоимость доставки единицы товара на его количество. Выполнив такое вычисление для каждого ребра и сложив полученные произведения, мы получим общую стоимость доставки товара, которую хотелось бы минимизировать.

 Максимальные потоки в транспортных сетях со стоимостями


Рис. 22.40.  Максимальные потоки в транспортных сетях со стоимостями

Все эти потоки имеют одну и ту же (максимальную) величину, но их стоимости (сумма произведений потоков в ребрах на стоимости этих ребер) различны. Максимальный поток в центре имеет минимальную стоимость (ни один максимальный поток не имеет меньшей стоимости).

Лемма 22.22. Задача о допустимом потоке минимальной стоимости и задача о максимальном потоке минимальной стоимости эквивалентны.

Доказательство. Непосредственно следует из такого же соответствия, как и в лемме 22.18 (см. также упражнение 22.76).

Вследствие этой эквивалентности и в силу того, что задача о допустимом потоке минимальной стоимости непосредственно моделирует задачи распределения товаров и многие другие приложения, мы употребляем для обозначения обеих задач термин поток минимальной стоимости (minicost flow) - даже в контекстах, где возможно упоминание любой из них. Сведения к другим задачам будут рассмотрены в разделе 22.7.

Для реализации стоимостей ребер в транспортных сетях добавим в класс EDGE из раздела 22.1 целочисленный приватный член данных pcost и функцию-элемент cost(), возвращающую значение стоимости клиенту. Программа 22.8 представляет собой клиентскую функцию, которая вычисляет стоимость потока в графе, построенном из указателей на такие ребра. Как и при работе с максимальными потоками, целесообразно реализовать функцию проверки, что значения притока и оттока согласованы в каждой вершине, а структуры данных непротиворечивы (см. упражнение 22.12).

Программа 22.8. Вычисление стоимости потока

Эту функцию, которая возвращает стоимость сетевого потока, можно включить в программу 22.1. Она суммирует произведения стоимостей и потоков для всех ребер с положительной пропускной способностью, позволяя рассматривать ребра без пропускной способности как фиктивные.

  static int cost(Graph &G)
 { int x = 0;
   for (int v = 0; v < G.V(); v++)
  { typename Graph::adjIterator A(G, v);
    for (Edge* e = A.beg(); !A.end(); e = A.nxt())
   if (e->from(v) && e->costRto(e->w()) < C)
     x += e->flow()*e->costRto(e->w());
  }
   return x;
 }
   

Первым шагом при разработке алгоритмов для вычисления потока минимальной стоимости необходимо добавить стоимость ребер в определение остаточных сетей.

Определение 22.9. Пусть задан поток в транспортной сети со стоимостями ребер. Остаточная сеть (residual network) для этого потока содержит те же вершины, что и исходная сеть, и одно или два ребра для каждого ребра исходной сети и определяется следующим образом. Пусть для каждого ребра u-v в исходной сети f - его поток, с - его пропускная способность, а Х - стоимость. Если f положительно, в остаточную сеть включается ребро v-u с пропускной способностью f и стоимостью -х; если f меньше с, в остаточную сеть включается ребро u-v с пропускной способностью с - f и стоимостью х.

Это определение почти идентично определению 22.4, но имеет очень важное отличие. Ребра в остаточной сети, представляющие обратные ребра, имеют отрицательную стоимость. Чтобы реализовать это соглашение, мы используем следующую функцию-член в классе ребра:

  int costRto(int v)
 { return from(v) ? -pcost : pcost; }
   

Проход по обратным ребрам соответствует удалению потока из соответствующего ребра исходной сети, поэтому стоимость изменяется на соответствующую величину. Из-за отрицательных стоимостей ребер в этих сетях могут существовать циклы с отрицательной стоимостью. Понятие отрицательных циклов, которое выглядело надуманным при нашем знакомстве с ним в контексте алгоритмов вычисления кратчайшего пути, играет важную роль в алгоритмах вычисления потока минимальной стоимости, и в этом мы сейчас убедимся. Мы рассмотрим два алгоритма, основанных на следующем условии оптимальности.

Лемма 22.23. Максимальный поток является максимальным потоком минимальной стоимости тогда и только тогда, когда его остаточная сеть не содержит (ориентированный) цикл с отрицательной стоимостью.

Доказательство. Предположим, что имеется максимальный поток минимальной стоимости, остаточная сеть которого содержит цикл с отрицательной стоимостью. Пусть х - пропускная способность ребра с минимально пропускной способностью в этом цикле. Увеличим поток, добавляя х в ребра в потоке, соответствующие ребрам с положительной стоимостью в остаточной сети (прямые ребра) и вычитая х из ребер, соответствующих ребрам с отрицательной стоимостью в остаточной сети (обратные ребра). Эти модификации не изменяют разность между притоком и оттоком любой вершины, т.е. поток остается максимальным. Но они меняют стоимость сети на стоимость цикла, умноженную на х - отрицательное значение. Это противоречит предположению, что стоимость исходного потока минимальна.

Чтобы доказать обратное утверждение, предположим, что существует максимальный поток F без циклов отрицательной стоимости, стоимость которого не является минимальной, и рассмотрим любой максимальный поток M минимальной стоимости. Рассуждения из теоремы о разложении потоков (лемма 22.2) позволяют найти не более E ориентированных циклов таких, что добавление этих циклов к потоку F даст поток M. Но, поскольку F не содержит отрицательных циклов, эта операция не может снизить стоимость потока F, т.е. получено противоречие. Другими словами, мы должны быть иметь возможность преобразовать F в M, увеличивая потоки в циклах, однако это невозможно, т.к. нет циклов отрицательной стоимости, которые можно было бы использовать для снижения стоимости потока.

Из этой леммы непосредственно следует простой обобщенный алгоритм решения задачи о потоке минимальной стоимости, получивший название алгоритма вычеркивания циклов (cycle-canceling algorithm):

Найдите максимальный поток. Увеличьте поток в произвольном цикле с отрицательной стоимости в остаточной сети, и продолжайте эту процедуру, пока не останется ни одного такого цикла.

Этот метод объединяет механизмы, которые были разработаны на протяжении всей этой главы и предыдущей главы и обеспечивает построение эффективных алгоритмов решения широкого класса задач, вписывающихся в модель потока минимальной стоимости. Подобно нескольким другим уже знакомым нам обобщенным методам, он допускает несколько различных реализаций, поскольку методы отыскания начального максимального потока и отыскания циклов с отрицательной стоимости не описаны. На рис. 22.41 показан пример вычисления максимального потока минимальной стоимости, где используется вычеркивание циклов.

Поскольку мы уже разработали алгоритмы для вычисления максимального потока и поиска отрицательных циклов, мы сразу же получаем реализацию алгоритма вычеркивания циклов, представленную в программе 22.9. Мы используем любую реализацию вычисления максимального потока для нахождения начального максимального потока и алгоритм Беллмана-Форда для поиска отрицательных циклов (см. упражнение 22.108). К двум этим реализациям остается только добавить цикл увеличения потоков в циклах на графе.

Из алгоритма вычеркивания можно убрать вычисление начального максимального потока циклов, добавив фиктивное ребро из истока в сток со стоимостью, превосходящей стоимость любого пути из истока в сток в сети (например, VC), и поток, величина которого больше величины максимального потока (например, больше, чем отток из истока). В этом случае алгоритм вычеркивания циклов выкачивает максимально возможный поток из фиктивного ребра, в результате чего полученный поток является максимальным.

Программа 22.9. Вычеркивание циклов

Этот класс решает задачу вычисления максимального потока минимальной стоимости методом вычеркивания отрицательных циклов. Он использует класс MAXFLOW для поиска максимального потока и приватную функцию-член negcyc (см. упражнение 22.108) для нахождения отрицательных циклов. Если отрицательный цикл существует, этот программный код находит такой цикл, вычисляет максимальную величину потока, который нужно протолкнуть через него, и делает это. Функция augment взята из программы 22.3 и была (предусмотрительно!) разработана для правильной работы в случаях, когда путь оказывается циклом.

  template <class Graph, class Edge>
  class MINCOST
 { const Graph &G;
   int s, t;
   vector<int> wt;
   vector <Edge *> st;
   int ST(int v) const;
   void augment(int, int);
   int negcyc(int);
   int negcyc();
 public:
   MINCOST(const Graph &G, int s, int t) : G(G),
  s(s), t(t), st(G.V()), wt(G.V())
  { MAXFLOW<Graph, Edge>(G, s, t);
    for (int x = negcyc(); x != -1; x = negcyc())
   { augment(x, x); }
  }
 };
   

 Остаточные сети (вычеркивание циклов)


Рис. 22.41.  Остаточные сети (вычеркивание циклов)

Каждый из показанных здесь потоков представляет собой максимальный поток в транспортной сети в верхней части рисунка, но только поток, изображенный внизу, является максимальным потоком минимальной стоимости. Чтобы найти его, мы начинаем с произвольного максимального потока и увеличиваем поток по отрицательным циклам. Стоимость начального максимального потока (второй сверху) равна 22, но он не является максимальным потоком минимальной стоимости, т.к. остаточная сеть (показана справа) содержит три отрицательных цикла.

В этом примере мы увеличиваем поток по циклу 4-1-0-2-4 и получаем максимальный поток со стоимостью 21 (третий сверху), который еще содержит один отрицательный цикл. Увеличение потока по этому циклу дает поток минимальной стоимости (внизу). Обратите внимание, что увеличение потока по циклу 3-2-4-1-3 дало бы максимальный поток минимальной стоимости всего за один шаг.

Эта технология вычисления потока минимальной стоимости показана на рис. 22.42. На этом рисунке используется начальный поток, равный максимальному потоку - чтобы было ясно, что этот алгоритм просто вычисляет другой поток той же величины, но более низкой стоимости (в общем случае мы не знаем величину этого потока, поэтому после завершения работы в фиктивном ребре еще остается некоторый поток, который мы игнорируем). Из рисунка понятно, что некоторые расширяющие циклы содержат фиктивное ребро и увеличивают поток в сети, а другие циклы не содержат его и снижают стоимость. В конечном итоге мы получаем максимальный поток, и тогда все расширяющие циклы уменьшают стоимость, не меняя величины потока, как если бы мы начали сразу с максимального потока.

Первоначальное заполнение фиктивным потоком перед вычеркиванием циклов технически не более и не менее универсально, чем первоначальное заполнение максимальным потоком. Первый способ охватывает все алгоритмы вычисления максимального потока с помощью расширения путей, однако алгоритм расширения путей может вычислить не все максимальные потоки (см. упражнение 22.40). С одной стороны, эта технология может привести в утрате всех преимуществ сложного алгоритма вычисления максимального потока. С другой стороны, возможно, лучше снижать стоимость в процессе построения максимального потока. На практике широко используется инициализация фиктивным потоком из-за простоты ее реализации.

Как и в случае вычисления максимальных потоков, существование этого обобщенного алгоритма гарантирует, что каждая задача о потоке минимальной стоимости (с целочисленными пропускными способностями и стоимостями) имеет решение, где все потоки выражаются целыми числами, и данный алгоритм позволяет получить это решение (см. упражнение 22.107). На основе этого факта легко определить верхнюю границу времени, необходимого для выполнения произвольного алгоритма вычеркивания циклов.

Лемма 22.24. Количество расширяющих циклов, необходимых для выполнения обобщенного алгоритма вычеркивания циклов, меньше ECM.

Доказательство. В худшем случае каждое ребро в начальном максимальном потоке имеет пропускную способность M, стоимость C и заполнено. Каждый цикл уменьшает эту стоимость не менее чем на 1.

Следствие. Время решения задачи о потоке минимальной стоимости в разреженной сети равно O(V3 CM).

Доказательство. Непосредственно следует из умножения количества расширяющих циклов в худшем случае на затраты на их поиск алгоритмом Беллмана-Форда в худшем случае (см. лемму 21.22).

Как и для методов расширения путей, эта оценка чрезвычайно завышена, т.к. она рассчитана не только на худший случай, где нужно громадное количество циклов для минимизации стоимости, но и на еще один худший случай, когда для нахождения каждого цикла требуется перебрать огромное количество ребер. Во многих практических ситуациях используется сравнительно небольшое количество циклов, которые относительно нетрудно найти, поэтому алгоритм вычеркивания циклов работает вполне эффективно.

Можно разработать стратегию, которая находит циклы отрицательной стоимости и гарантирует, что количество использованных циклов отрицательной стоимости меньше VE (см. раздел ссылок). Этот результат имеет важное значение, т.к. он утверждает, что задача о потоке минимальной стоимости имеет решение (а заодно и все сводящиеся к ней задачи). Однако на практике часто выбирают реализации, которые допускают (теоретически) высокую границу для худшего случая, но реально используют при решении задач значительно меньше итераций.

 Вычеркивание циклов без начального максимального потока


Рис. 22.42.  Вычеркивание циклов без начального максимального потока

Здесь показано вычисление максимального потока минимальной стоимости, начиная с первоначально нулевого потока, с помощью алгоритма вычеркивания циклов, который использует фиктивное ребро из стока в исток в остаточной сети с неограниченной пропускной способностью и неограниченной отрицательной стоимостью. Фиктивное ребро превращает любой расширяющий путь из 0 в 5 в отрицательный цикл (хотя он игнорируется при увеличении потока и вычислении его стоимости). Добавление по этому пути ведет к увеличению потока, как в алгоритмах расширения путей (три верхних ряда). При отсутствии циклов, в которые входит фиктивное ребро, в остаточной сети отсутствуют пути из истока в сток - значит, поток максимален (третий ряд сверху). С этого момента увеличение потока по отрицательному циклу снижает стоимость потока без изменения его величины (внизу).

В этом примере сначала вычисляется максимальный поток, а затем уменьшается его стоимость; но это не обязательно. Например, на втором шаге алгоритм мог бы увеличить поток в отрицательном цикле 1-4-5-3-1, а не в цикле 0-1-4-5-0. Поскольку каждое добавление либо увеличивает поток, либо уменьшает стоимость, алгоритм в любом случае продвигается к максимальному потоку минимальной стоимости.

Задача о потоке минимальной стоимости представляет собой наиболее общую модель решения задач из рассмотренных нами, и тем более удивительно, что ее можно решить с помощью столь простой реализации. Ввиду важности этой модели были разработаны и подробно изучены другие многочисленные реализации метода вычеркивания циклов и многие другие методы. Программа 22.9 является удивительной простой и эффективной базовой реализацией, однако в ней имеются два слабых места, которые могут ухудшить ее производительность. Во-первых, каждый поиск отрицательного цикла начинается с самого начала. Можно ли сохранить промежуточную информацию, полученную при поиске одного отрицательного цикла, которая может оказаться полезной при поиске следующего цикла? Во-вторых, программа 22.9 использует первый же отрицательный цикл, который находит алгоритм Беллмана-Форда. Можно ли мы направить поиск на выявление отрицательных циклов с особыми свойствами? В разделе 22.6 мы рассмотрим усовершенствованную реализацию, которая, оставаясь обобщенной, отвечает на оба эти вопроса.

Упражнения

22.105. Добавьте стоимости в класс допустимых потоков из упражнения 22.74. Для решения задачи о допустимых потоках минимальной стоимости воспользуйтесь классом MINCOST.

22.106. Приведите более точную верхнюю границу, чем ECM, для стоимости максимального потока в транспортной сети, не все ребра которой имеют максимальную пропускную способность и максимальную стоимость.

22.107. Докажите, что если все пропускные способности и стоимости выражены целыми числами, то задача о минимальной стоимости имеет решение, в котором все потоки имеют целочисленные значения.

22.108. Реализуйте функцию negcyc() для программы 22.9, воспользовавшись алгоритмом Беллмана-Форда (см. упражнение 21.134).

22.109. Замените в программе 22.9 инициализацию вычислением потока на инициализацию потоком в фиктивном ребре.

22.110. Приведите все возможные последовательности расширяющих циклов, которые могли бы быть показаны на рис. 22.41.

о 22.111. Приведите все возможные последовательности расширяющих циклов, которые могли бы быть показаны на рис. 22.42.

22.112. Покажите в стиле рис. 22.41 поток и остаточные сети после каждого расширения при использовании программы 22.9 (реализация алгоритма вычеркивания циклов) для отыскания потока минимальной стоимости в транспортной сети, изображенной на рис. 22.10. Ребра 0-2 и 0-3 имеют стоимость 2, ребра 2-5 и 3-5 - стоимость 3, ребро 1-4 - стоимость 4, а все остальные ребра - единичную стоимость. Предполагается, что максимальный поток вычисляется с помощью алгоритма, который использует кратчайшие расширяющие пути.

22.113. Выполните упражнение 22.112 в предположении, что программа начинает работу с максимальным потоком в фиктивном ребре из истока в сток, как показано на рис. 22.42.

22.114. Добавьте в решения упражнений 22.6 и 22.7 обработку стоимостей в транспортных сетях.

22.115. Добавьте в решения упражнений 22.9-22.11 обработку стоимостей в сетях. Назначьте каждому ребру стоимость, приблизительно пропорциональную евклидовому расстоянию между его вершинами.

Сетевой симплексный алгоритм

Время выполнения алгоритма вычеркивания циклов зависит не только от количества циклов отрицательной стоимости, которые используются для снижения стоимости потока, но и от времени поиска каждого такого цикла. В этом разделе мы рассмотрим базовый подход, который не только резко снижает трудоемкость выявления отрицательных циклов, но и позволяет использовать приемы уменьшения количества итераций. Эта реализация алгоритма вычеркивания циклов называется сетевым симплексным (network simplex) алгоритмом. Он основан на использовании древовидной структуры данных и на переназначении стоимостей, что позволяет быстро выявлять отрицательные циклы.

 Классификация ребер


Рис. 22.43.  Классификация ребер

По отношению к любому потоку ребро может быть пустым, полным или частично заполненным (ни пустое, ни полное). В этом потоке ребро 1-4 пусто; ребра 0-2, 2-3, 2-4, 3-5 и 4-5 заполнены, а ребра 0-1 и 1-3 частично заполнены. Наши графические соглашения дают два способа обозначения состояний ребер: в столбце потока цифры 0 означают пустые ребра, звездочки означают заполненные ребра, а остальные ребра - это частично заполненные ребра. В остаточной сети (внизу) пустые ребра появляются только в левом столбце, полные - в правом столбце, а частично заполненные ребра могут находиться в обоих столбцах.

Прежде чем перейти к описанию сетевого симплексного алгоритма, мы заметим, что по отношению к любому потоку каждое ребро u-v сети может находиться в одном из трех состояний (см. рис. 22.43):

С этой классификацией мы уже сталкивались при использовании остаточных сетей в данной главе. Если ребро u-v пустое, то u-v входит в остаточную сеть, а v-u не входит; если ребро u-v заполнено, то v-u входит в остаточную сеть, а u-v не входит; а если ребро u-v заполнено частично, то в остаточную сеть входят и u-v, и v-u .

Определение 22.10. Пусть имеется максимальный поток без циклов из частично заполненных ребер. Тогда допустимым остовным деревом (feasible spanning tree) этого максимального потока является любое остовное дерево сети, которое содержит все частично заполненные ребра.

В этом контексте направления ребер в остовном дереве игнорируются. То есть любое множество V- 1 ориентированных ребер, которые соединяют V вершин сети между собой (без учета направлений ребер), составляет остовное дерево, а остовное дерево допустимо, если все ребра, не входящие в дерево, являются полными или пустыми.

Первый шаг сетевого симплексного алгоритма заключается в построении остовного дерева. Один из способов его построения - вычисление максимального потока, разбиение циклов из частично заполненных ребер (расширение потока, чтобы заполнить или опустошить одно из его ребер), и последующее добавление пустых или полных ребер к остальным частично заполненным ребрам с целью построения остовного дерева. Пример такого процесса показан на рис. 22.44. В другом варианте на первом шаге вычисляется максимальный поток в фиктивном ребре из истока в сток. Затем это ребро является единственно возможным частично заполненным ребром, и мы можем построить остов-ное дерево для данного потока с помощью любого поиска на графе. Пример такого остовного дерева приводится на рис. 22.45.

Добавление в остовное дерево любого недревесного ребра приводит к образованию цикла. Механизм, на котором основан сетевой симплексный алгоритм - это множество весов вершин, которое обеспечивает немедленное выявление тех ребер, которые при включении в остовное дерево создают циклы отрицательной стоимости в остаточной сети. Мы будем называть эти веса вершин потенциалами (potential), а потенциал, связанный с вершиной v, будем обозначать ф(v). В зависимости от контекста мы будем рассматривать потенциал то как функцию, определенную на вершинах, то как множество целочисленных весов (предполагая, что каждой вершине присвоен такой вес), то как вектор, индексированный именами вершин (поскольку в реализациях мы храним их именно в таком виде).

 Остовное дерево максимального потока


Рис. 22.44.  Остовное дерево максимального потока

Если имеется максимальный поток (вверху), то с помощью двухэтапного процесса, представленного в данном примере, можно построить максимальный поток с таким остовным деревом, что ни одно из ребер, не включенных в остовное дерево, не является частично заполненным ребром. Сначала мы разрываем циклы из частично заполненных ребер: в данном случае мы разрываем цикл 0-2-4-1-0, проталкивая по нему поток величиной 1. Таким способом всегда можно заполнить или опустошить по крайней мере одно ребро; в данном случае мы опустошаем ребро 1-4 и заполняем ребра 0-2 и 2-4 (в центре). Затем мы включаем пустые и полные ребра в множество частично заполненных ребер, чтобы получить остовное дерево; в данном случае мы добавляем ребра 0-2, 1-4 и 3-5 (внизу).

 Остовное дерево для фиктивного максимального потока


Рис. 22.45.  Остовное дерево для фиктивного максимального потока

Если начать с потока по фиктивному ребру из истока в сток, то оно оказывается единственно возможным частично заполненным ребром. Поэтому для построения остовного дерева для потока можно использовать любое остовное дерево из остальных ребер. В данном примере ребра 0-5, 0-1, 0-2, 1-3 и 1-4 составляют остовное дерево для исходного максимального потока. Все не включенные в дерево ребра остаются пустыми.

Определение 22.11. Пусть задан поток в транспортной сети со стоимостями ребер, а c(u, v) означает стоимость ребра u-v остаточной сети для этого потока. Для любой функции потенциала ф приведенную стоимость (reduced cost) ребра u-v остаточной сети относительно ф мы будем обозначать как c*(u,v) и определим как значение c(u, v) - (ф (u) - ф(v)).

Иначе говоря, приведенная стоимость каждого ребра есть разность между фактической стоимостью ребра и разностью потенциалов вершин этого ребра. В ситуации распределения товаров потенциал узла имеет физический смысл: если интерпретировать потенциал ф(u) как затраты на приобретение единицы товара в узле u, то полная стоимость c*(u, v) + ф (u) - ф (v) означает затраты на закупку товара в узле u, доставку в v и реализацию товара в v.

Мы будем хранить потенциалы вершин в векторе phi, индексированном именами вершин, и вычислять приведенную стоимость ребра v-w, вычитая из стоимости ребра значение (phi[v] -phi[w]). То есть не нужно где-то хранить приведенную стоимость ребра, поскольку ее очень легко вычислить.

В сетевом симплексном алгоритме мы используем допустимые остовные деревья для определения потенциалов вершин таким образом, чтобы приведенные стоимости ребер с учетом этих потенциалов давали прямую информацию о циклах с отрицательной стоимостью. А именно, мы используем при выполнении алгоритма допустимое остовное дерево и устанавливаем такие значения потенциалов вершин, чтобы все древесные ребра имели нулевую приведенную стоимость.

Лемма 22.25. Будем говорить, что множество потенциалов вершин допустимо (valid) по отношению к остовному дереву, если все древесные ребра имеют нулевую приведенную стоимость. Всем допустимым потенциалам вершин любого остовного дерева соответствуют одинаковые приведенные стоимости каждого ребра сети.

Доказательство. Пусть имеются две различные функции потенциала ф и ф', допустимые по отношению к заданному остовному дереву. Покажем, что они различаются на аддитивную константу, т.е. что ф(u) = ф'(u) + А для всех u и некоторой константы А. Тогда ф(u) - ф(v) = ф'(u) - ф'(v) для всех u и v. Отсюда следует, что для двух функций потенциала все приведенные стоимости одинаковы.

Для любых двух вершин u и v, которые связаны между собой древесным ребром, должно выполняться равенство ф (v) = ф (u) - c (u, v), исходя из следующих соображений. Если u-v - древесное дерево, то ф (v) должна быть равно ф (u) - c (u, v), чтобы приведенная стоимость c (u, v) - ф (u) + ф (v) была равна нулю; если v-u - древесное ребро, то ф (v) должна быть равна ф (u) + c (v, u) = ф (u) - c (u, v), чтобы приведенная стоимость c (v, u) - ф (v) + ф (u) была равна нулю. Аналогичные рассуждения для ф' показывают, что ф'(v) = ф'(u) - c (u, v).

Вычтя из одного равенства другое, находим, что ф(v) - ф'(v) = ф(u) - ф'(u) для любых u и v, соединенных древесным ребром. Обозначив эту разность для любой вершины через А, и применив это равенство к ребрам любого дерева поиска по остовному дереву, получаем для всех u.

Есть другой способ представить процесс определения некоторого множества допустимых потенциалов вершин. Сначала мы фиксируем одно значение, потом вычисляем значения для всех вершин, соединенных с исходной вершиной древесными ребрами, затем вычисляем эти значения для всех вершин, соединенных с теми вершинами и т.д. Независимо от того, в какой вершине начат этот процесс, разность потенциалов между любыми двумя вершинами остается одной и той же и определяется только структурой дерева. Пример приведен на рис. 22.46 рис. 22.46. Мы подробно рассмотрим задачу вычисления потенциалов после изучения зависимости между приведенными стоимостями недревесных ребер и циклами отрицательной стоимости.

Лемма 22.26. Назовем недревесное ребро подходящим (eligible), если цикл, создаваемый им с древесными ребрами, является циклом отрицательной стоимости в остаточной сети. Ребро является подходящим тогда и только тогда, когда оно представляет собой полное ребро с положительной приведенной стоимостью или пустое ребро с отрицательной приведенной стоимостью.

Доказательство. Предположим, что ребро u-v создает цикл t1-t2-t3-...-td-t1 с древесными ребрами t1-12, t2-13, ... , где v есть t1 , а u есть td. Определения приведенной стоимости каждого ребра дают следующие равенства:

\begin{align*} c (u, v) &= c^{*}(u, v) +\phi (u) -\phi (t_{1})\\ c(t_{1}, t_{2}) &=\phi (t_{1}) -\phi (t_{2})\\ c (t_{2}, t_{3}) &=\phi (t_{2}) -\phi (t_{3})\\ &\hdots\\ c (t_{d-1}, u) &=\phi(t_{d-1}) -\phi(u) \end{align*}.

Сумма левых частей этих уравнений дает общую стоимость цикла, а сумма правых частей сокращается до c*(u, v). Другими словами, приведенная стоимость ребра дает стоимость цикла, поэтому только описанные ребра могут порождать циклы с отрицательной стоимостью.

 Потенциалы вершин


Рис. 22.46.  Потенциалы вершин

Потенциалы вершин определяются структурой остовного дерева и первоначальным значением потенциала одной любой вершины. Слева приведено множество ребер, которые образуют остовное дерево, состоящее из десяти вершин от 0 до 9. В представлении этого дерева в центре в качестве корня выбрана вершина 5, вершины, соединенные с 5, находятся на уровень ниже и т.д. Присваивание корню нулевого значения потенциала определяет уникальное назначение потенциалов для других узлов, при котором разность потенциалов вершин каждого ребра равна его стоимости. Справа приведено другое представление того же дерева, в котором в качестве корня выбрана вершина 0. После присвоения вершине 0 нулевого значения потенциала будут получены другие потенциалы, которые отличаются от потенциалов на центральной диаграмме на некоторое постоянное значение. Во всех наших вычислениях используется лишь разность потенциалов. Она остается одной и той же для любой пары потенциалов, независимо от начальной вершины (и независимо от присвоенного ей значения) - поэтому выбор исходной вершины и начального значения не играет роли.

Лемма 22.27. Если имеются поток и допустимое остовное дерево, в котором нет подходящих ребер, то поток является потоком минимальной стоимости.

Доказательство. Если подходящие ребра отсутствуют, то в остаточной сети отсутствуют циклы с отрицательной стоимостью, и из условия оптимальности, сформулированного в лемме 22.23, следует, что поток является потоком минимальной стоимости.

Эквивалентная формулировка утверждает, что если имеются поток и множество потенциалов вершин, такое, что все приведенные стоимости древесных ребер равны нулю, все полные недревесные ребра неотрицательны, а пустые недревесные ребра неположительны, то поток является потоком минимальной стоимости.

При наличии подходящих ребер можно выбрать одно из них и выполнить расширение по циклу, который оно образует с древесными ребрами, чтобы получить поток более низкой стоимости. Аналогично реализации вычеркивания циклов из раздела 22.5, пройдем по циклу, чтобы определить максимальный поток, который можно протолкнуть, а затем еще раз пройдем по циклу, чтобы протолкнуть этот поток, который заполнит или опустошит по меньшей мере одно ребро. Если это подходящее ребро, которое было использовано для построения цикла, оно перестает быть подходящим (его приведенная стоимость не изменяется, но оно превращается из полного в пустое или из пустого в полное). Во всех других случаях оно становится частично заполненным. Присоединение его к дереву и удаление из цикла полного или пустого ребра сохраняет инвариантное условие: никакое недревесное ребро не может быть частично заполненным, а само дерево является допустимым остовным деревом. Как и ранее, мы рассмотрим механику этого вычисления ниже в этом разделе.

Итак, допустимые остовные деревья дают нам потенциалы вершин, которые дают приведенные стоимости, которые дают допустимые ребра, которые дают циклы отрицательной стоимости. Расширение по циклам отрицательной стоимости снижает стоимость потока и заодно мененяет структуру дерева. Из-за изменений в структуре дерева меняются потенциалы вершин, из-за изменений потенциалов вершин меняются приведенные стоимости ребер, а из-за изменений приведенных стоимостей меняется множество подходящих ребер. Выполнив все эти изменения, можно выбрать другое подходящее ребро и начать процесс снова. Эта обобщенная реализация алгоритма вычеркивания циклов для решения задачи о потоке минимальной стоимости и называется сетевым симплексным алгоритмом:

Построим допустимое остовное дерево, используя такие потенциалы вершины, чтобы все вершины дерева имели нулевую приведенную стоимость. Добавим в дерево подходящее ребро, расширим поток в цикле, который оно образует с древесными ребрами, и удалим из дерева ребро, которое оказалось полным или пустым - и будем продолжать этот процесс до тех пор, пока не останется ни одного подходящего ребра.

Это описание является обобщенным, т.к. начальный выбор остовного дерева, способ вычисления потенциалов вершин и выбор подходящих ребер не определены. Стратегия выбора подходящих ребер определяет количество итераций и выбирается с учетом затрат на реализацию различных стратегий такого выбора и на пересчет потенциалов вершин.

Лемма 22.28. Если общий сетевой симплексный алгоритм завершает работу, то он вычисляет поток минимальной стоимости.

Доказательство. Если алгоритм прекращает работу, то это потому, что в остаточной сети не осталось циклов отрицательной стоимости, а согласно лемме 22.23 полученный максималный поток обладает минимальной стоимостью.

Алгоритм может и не завершить работу - из-за того, что расширение в некотором цикле может заполнять или опустошать сразу несколько ребер, и в дереве могут оставаться ребра, через которые невозможно протолкнуть никакой поток. Если мы не можем протолкнуть поток, то не можем снизить стоимость и можем попасть в бесконечный цикл добавления и удаления ребер в фиксированной последовательности остовных деревьев. Было найдено несколько способов избавиться от этой проблемы; мы рассмотрим их ниже в этом разделе, после того как более подробно рассмотрим реализации.

Первое, с чем нужно определиться при разработке сетевого симплексного алгоритма - это представление остовного дерева. С помощью дерева нам понадобится выполнять следующие действия:

Каждая из этих задач сама по себе представляет интересное упражнение по разработке структур данных и алгоритмов. Мы могли бы рассмотреть несколько структур данных и множество алгоритмов с различными характеристиками производительности. Вначале мы изучим, пожалуй, простейшую доступную нам структуру данных (с которой мы познакомились еще в лекция №11) - это представление дерева родительскими ссылками. Мы рассмотрим алгоритмы и реализации, которые основаны на этом представлении для перечисленных задач и опишем их применение для работы сетевого симплексного алгоритма, а затем обсудим лругие структуры данных и алгоритмы.

Как и в нескольких других реализациях, рассмотренных в данной главе, мы начнем с реализации вычисления максимального пути расширением путей, пользуясь не просто индексами в представлении дерева, а ссылками в сетевое представление, чтобы иметь доступ к значениям потоков, сохраняя доступ и к именам вершин.

Программа 22.10 представляет собой реализацию, которая присваивает потенциалы вершинам за время, пропорциональное V. В ее основе лежит следующая идея (см. рис. 22.47). Мы начинаем с произвольной вершины и рекурсивно вычисляем потенциалы ее предшественников, поднимаясь по родительским ссылкам до самого корня, которому по соглашению присваивается нулевой потенциал. Затем мы выбираем другую вершину и с помощью родительских сссылок рекурсивно вычисляем потенциалы ее предшественников. Рекурсия заканчивается при достижении предшественника с уже известным потенциалом, а затем мы возвращаемся по тому же пути, вычисляя потенциал каждого узла на основе потенциала родительского узла. Этот процесс продолжается до тех пор, пока не будут вычислены все значения потенциалов. Если мы уже прошли по какому-либо пути, то мы больше не посещаем ни одно из его ребер, следовательно, этот процесс выполняется за время, пропорциональное V.

 Вычисление потенциалов с использованием родительских ссылок


Рис. 22.47.  Вычисление потенциалов с использованием родительских ссылок

Мы начнем с вершины 0, проходим по пути в корень, обнуляем pt[5], а затем возвращаемся тем же путем назад. В вершине 6 устанавливаем такое значение, чтобы pt[6] - pt[5] было равно стоимости ребра 6-5, затем устанавливаем значение pt[3], чтобы pt[3] - pt[6] было равно стоимости ребра 3-6 и т.д. (слева). После этого мы начинаем с вершины 1 и проходим по родительским ссылкам, пока не встретим вершину с известным потенциалом (в данном случае это 6), и возвращаемся по этому пути, вычислив потенциалы вершин 9 и 1 (в центре). Потом мы начинаем с вершины 2 - мы можем рассчитать ее потенциал из потенциала ее родителя (справа). Перейдя к вершине 3, мы обнаруживаем, что ее потенциал уже известен, и т.д. В данном примере для каждой вершины после 1 мы либо видим, что ее потенциал уже подсчитан, либо можем его вычислить из потенциала ее родителя. Мы никогда не проходим по одному и тому же ребру дважды, независимо от структуры дерева, поэтому общее время этих вычислений линейно.

Программа 22.10. Вычисление потенциалов вершин

Рекурсивная функция phiR поднимается по родительским ссылкам дерева, пока не встретит допустимый потенциал (потенциал корня всегда считается допустимым), а затем вычисляет потенциалы, спускаясь по пройденному пути. Она помечает каждую вершину, потенциал которой вычисляет, текущим значением функции valid.

  int phiR(int v)
 { if (mark[v] == valid) return phi[v];
   phi[v] = phiR(ST(v)) - st[v]->costRto(v);
   mark[v] = valid;
   return phi[v];
 }
   

Для двух заданных вершин их ближайший общий предок (БОП, least common ancestor - LCA) определяется как корень минимального поддерева, которое содержит обе эти вершины. Цикл, который образуется при добавлении ребра, соединяющего две вершины, состоит из этого ребра и ребер двух путей, ведущих из двух этих узлов к их БОП. Расширяющий цикл, образованный добавлением ребра v-w, проходит через v-w в w, затем вверх по дереву к БОП вершин v и w (скажем, r), затем вниз по дереву в v. Поэтому на этих двух путях необходимо рассматривать ребра различной ориентации.

Как и раньше, мы расширяем поток в цикле, вначале пройдя по путям, чтобы выявить максимальную величину потока, которую можно протолкнуть через их ребра, а затем еще раз пройдя по этим путям, чтобы протолкнуть через них поток. Не обязательно рассматривать ребра в порядке их расположения в цикле, достаточно просто рассмотреть каждое из них (в любом направлении). Поэтому можно просто пройти каждый путь из этих узлов до их БОП. Чтобы расширить поток в цикле, образованном при добавлении ребра v-w, мы проталкиваем поток из v в w, из v по пути к ближайшему общему предку r и из w по пути до г, но против направления ребер. Программа 22.11 реализует эту идею в виде функции, которая расширяет поток в цикле и заодно возвращает ребро, которое становится пустым или полным из-за этого расширения.

В программе 22.11 используется простая технология, позволяющая избежать затрат на инициализацию всех меток, которую нужно выполнять при каждом вызове программы. Мы храним эти метки как глобальные переменные, которые вначале обнулены. При каждом поиске БОП мы увеличиваем на 1 глобальный счетчик и помечаем вершины, сохраняя в соответствующем элементе вектора, индексированного именами вершин, значение этого счетчика. После инициализации этот метод позволяет выполнять вычисления за время, пропорциональное длине цикла. В типичных задачах расширение может выполняться для множества небольших циклов, поэтому экономия времени может быть весьма существенной. Как мы убедимся далее, этот метод экономии времени можно применять и в других частях реализации.

Программа 22.11. Расширение потока по циклу

Чтобы найти ближайшего общего предка двух вершин, мы синхронно поднимаемся из них по дереву, помечая встреченные узлы. БОП - первый обнаруженный помеченный узел (возможно, корень дерева). Для расширения потока используется функция, похожая на функцию из программы 22.3; она сохраняет пути по дереву в векторе st и возвращает ребро, которое стало при расширении пустым или полным (см. текст).

  int lca(int v, int w)
 { mark[v] = ++valid; mark[w] = valid;
   while (v != w)
  { if (v != t) v = ST(v);
    if (v != t && mark[v] == valid) return v;
    mark[v] = valid;
    if (w != t) w = ST(w);
    if (w != t && mark[w] == valid) return w;
    mark[w] = valid;
  }
   return v;
 }
 Edge *augment(Edge *x)
   { int v = x->v(), w = x->w(); int r = lca(v, w);
  int d = x->capRto(w);
  for (int u = w; u ! = r; u = ST(u))
    if (st[u]->capRto(ST(u)) < d)
   d = st[u]->capRto(ST(u));
  for (int u = v; u ! = r; u = ST(u))
    if (st[u]->capRto(u) < d)
   d = st[u]->capRto(u);
  x->addflowRto(w, d); Edge* e = x;
  for (int u = w; u ! = r; u = ST(u))
    { st[u]->addflowRto(ST(u), d);
   if (st[u]->capRto(ST(u)) == 0) e = st[u];
    }
  for (int u = v; u ! = r; u = ST(u))
    { st[u]->addflowRto(u, d);
   if (st[u]->capRto(u) == 0) e = st[u];
    }
  return e;
   }
   

Наша третья задача обработки дерева - замена ребра u-v другим ребром в цикле, который образован этим другим ребром с ребрами дерева. В программе 22.12 реализована функция, выполняющая эту задачу для представления родительскими ссылками. Здесь снова играет важную роль БОП вершин u и v, т.к. удаляемое ребро лежит либо на пути из u в БОП, либо на пути из v в БОП. Удаление ребра отсоединяет от дерева всех его потомков, но это можно исправить, обратив направления ссылок между ребром u-v и удаленным ребром, как показано на рис. 22.48.

Эти три реализации позволяют выполнять базовые операции, на которых основан сетевой симплексный алгоритм: (1) выбор подходящего ребра с помощью прсмотра приведенных стоимостей и потоков; (2) расширение потока в отрицательном цикле, образованном ребрами дерева и выбранным подходящим ребром, пользуясь представлением родительскими ссылками для остовного дерева; и (3) изменение дерева и пересчет потенциалов. Эти операции показаны на примере транспортной сети на рис. 22.49 и рис. 22.50.

На рис. 22.49 показана инициализация структур данных с помощью фиктивного ребра с максимальным потоком в нем, как и на рис. 22.42. На нем показано исходное допустимое остовное дерево, представленное родительскими ссылками, соответствующие потенциалы вершин, приведенные стоимости недревесных ребер и исходное множество подходящих ребер.

 Замена остовного дерева


Рис. 22.48.  Замена остовного дерева

Этот пример демонстрирует базовые операции с деревом в сетевом симплексном алгоритме для представления родительскими ссылками. Слева изображено дерево, в котором все ссылки направлены вверх, что показывает вектор родительских ссылок ST. (В нашем коде функция ST вычисляет родителя заданной вершины по указателю на ребро, хранящемуся в векторе st, индексированном именами вершин.) Добавление ребра 1-2 образует цикл, в который входят пути из вершин 1 и 2 к их БОП - вершине 11. После удаления одного из этих ребер, скажем, 0-3, структура остается древовидной. Чтобы отразить это изменение в массиве родительских ссылок, мы меняем направления всех ссылок от 2 до 3 (в центре). Дерево справа - то же самое дерево, в котором позиции узлов изменены так, чтобы все ссылки были направлены вверх в соответствии со значениями массива родительских ссылок, представляющего рассматриваемое дерево (справа внизу).

 Инициализация в сетевом симплексном алгоритме


Рис. 22.49.  Инициализация в сетевом симплексном алгоритме

Чтобы выполнить инициализацию структур данных для сетевого симплексного алгоритма, мы начинаем с нулевого потока во всех ребрах (слева), затем добавляем фиктивное ребро 0-5 из истока в сток, величина потока в котором не меньше величины максимального потока (здесь для ясности мы используем значение, равное величине максимального потока). Стоимость 9 фиктивного ребра больше стоимости любого цикла сети; в этой реализации используется значение CV. Фиктивное ребро в транспортной сети не показано, однако включено в остаточную сеть (в центре). Остовное дерево формируется из стока в качестве корня, истока в качестве его единственного потомка, и дерева поиска на графе, индуцированном остальными узлами остаточной сети. Эта реализация использует представление дерева родительскими ссылками в массиве st и функцию ST. На наших рисунках изображена эта реализация и две других: реализация с корнем, показанная справа, и множество заштрихованных ребер в остаточной сети.

Потенциалы вершин содержатся в массиве pt и вычисляются на основе структуры дерева таким образом, чтобы разность потенциалов вершин каждого древесного ребра была равна его стоимости.

В столбце " привед. стоим. " (в центре) показаны приведенные стоимости недревесных ребер, которые вычислены для каждого ребра сложением разности потенциалов вершин и его стоимости. Приведенные цены древесных ребер равны нулю и в столбце не указаны. Пустые ребра с отрицательными приведенными стоимостями и полные ребра с положительными приведенными стоимостями (подходящие ребра) отмечены звездочками.

Программа 22.12. Замена остовного дерева

Функция update добавляет ребро в остовное дерево и удаляет из образовавшегося при этом цикла некоторое ребро. Удаляемое ребро находится на пути из одной из вершин добавленного ребра к их БОП. Эта реализация использует функцию onpath для поиска удаляемого ребра и функцию reverse для изменения направления ребра на пути между данным и добавляемым ребром.

 bool onpath(int a, int b, int c)
   { for (int i = a; i ! = c; i = ST(i))
    if (i == b) return true;
  return false;
   }
 void reverse(int u, int x)
   { Edge *e = st[u];
  for (int i = ST(u); i != x; i = ST(i))
    { Edge *y = st[i]; st[i] = e; e = y; }
   }
 void update(Edge *w, Edge *y)
   { int u = y->w(), v = y->v(), x = w->w ();
  if (st[x] != w) x = w->v();
  int r = lca(u, v);
  if (onpath(u, x, r))
    { reverse(u, x); st[u] = y; return; }
  if (onpath(v, x, r))
    { reverse(v, x); st[v] = y; return; }
   }
   

Вместо вычисления величины максимального потока в реализации используется величина оттока из истока, которая гарантированно не меньше величины максимального потока; в данном случае используется величина максимального потока, чтобы было легче проследить работу алгоритма.

 Остаточные сети и остовные деревья (сетевой симплексный алгоритм)


Рис. 22.50.  Остаточные сети и остовные деревья (сетевой симплексный алгоритм)

Каждый ряд этого рисунка соответствует одной итерации сетевого симплексного алгоритма после инициализации, представленной на рис. 22.49. На каждой итерации алгоритм выбирает подходящее ребро, расширяет поток по циклу и изменяет структуры данных следующим образом. Сначала выполняется расширение потока, включая соответствующие изменения в остаточной сети. После этого в древовидную структуру ST добавляется подходящее ребро, и из цикла, которое оно образует с ребрами дерева, удаляется ребро. Потом изменения в структуре дерева отображаются в таблице потенциалов phi. Затем изменяются приведенные стоимости недревесных ребер (столбец " привед. стоим. " в центре), чтобы отобразить изменения значений потенциалов, и эти значения используются для выявления пустых ребер с отрицательной приведенной стоимостью и полных ребер с положительной приведенной стоимостью как подходящих ребер (помечены звездочками около приведенных стоимостей). Реализации не обязательно должны выполнять именно такие вычисления (они просто должны вычислить изменения потенциалов и приведенных стоимостей, чего достаточно для выявления подходящих ребер), однако мы приводим здесь все числа, чтобы дать полное представление об алгоритме.

Последнее расширение в этом примере вырождено. Оно не увеличивает поток, но и не выявляет подходящих ребер - это гарантирует, что данный поток является максимальным потоком минимальной стоимости.

На рис. 22.50 показаны изменения в структурах данных для каждой последовательности подходящих ребер и расширения потока по циклам отрицательной стоимости. Эта последовательность не отражает какой-либо конкретный метод выбора подходящих ребер; в ней представлены решения, которые делают расширяющие пути такими, как на рис. 22.42. На этих рисунках изображены все потенциалы вершин и все приведенные стоимости после каждого цикла расширения, даже если многие из этих значений определены неявно и не обязательно должны вычисляться типовыми реализациями. Эти рисунки демонстрируют общее продвижение алгоритма и состояние структур данных при переходах алгоритма от одного допустимого остовного дерева к другому, когда в дерево добавляется подходящее ребро и из образовавшегося цикла удаляется одно из ребер.

Один из критических фактов, представленных на рис. 22.50, заключается в том, что алгоритм может просто не завершить работу, т.к. пустые или полные ребра остовного дерева могут помешать протолкнуть поток по найденному отрицательному циклу. То есть можно найти походящее ребро и отрицательный цикл, который оно образует с ребрами остовного дерева, но максимальная величина потока, которую можно протолкнуть через этот цикл, может оказаться равной 0. В этом случае мы заменим подходящим ребром одно из ребер цикла, однако снизить стоимость потока не удастся. Чтобы алгоритм завершал свою работу, необходимо сделать так, чтобы алгоритм не начал выполнять бесконечную последовательность расширений потока на нулевую величину.

Если расширяющий цикл содержит более одного полного или пустого ребра, то подстановка алгоритма из программы 22.12 всегда удаляет из дерева то ребро, которое расположено ближе других к БОП двух вершин подходящего ребра. К счастью, доказано, что такая стратегия выбора ребра для удаления из цикла обеспечивает завершение работы алгоритма (см. раздел ссылок).

И последний выбор, который нам придется сделать при разработке реализации сетевого симплексного алгоритма - это стратегия выявления подходящих ребер и выбора одного из них для включения в дерево. Нужна ли специальная структура данных для хранения подходящих ребер? Если нужна, то насколько сложная? Ответ на эти вопросы в какой-то степени зависит от приложения и динамических характеристик решения конкретных задач. Если общее количество подходящих ребер невелико, то лучше использовать специальную структуру данных, а если большая часть ребер обычно являются подходящими, то такая структура не нужна. Дополнительная структура данных позволяет снизить затраты на поиск подходящих ребер, но может также потребовать выполнения трудоемких операций по обновлению данных. Каков критерий выбора нужного подходящего ребра? Как обычно, у нас богатый выбор.

Мы рассмотрим примеры в наших реализациях, а затем исследуем возможные альтернативы. Например, программа 22.13 содержит функцию, которая находит подходящее ребро минимальной приведенной стоимости: никакое другое ребро не дает цикл, расширение потока по которому приведет к большему снижению суммарной стоимости.

Программа 22.14 представляет собой полную реализацию сетевого симплексного алгоритма, которая использует стратегию выбора подходящего ребра, дающего отрицательный цикл с максимальным абсолютным значением стоимости. Эта реализация использует функции работы с деревьями и поиск подходящих ребер из программ 22.10-22.13, но здесь применимо замечание к нашей первой реализации вычеркивания циклов (программа 22.9): удивительно, что такой небольшой кодовый фрагмент позволяет получать полезные решения в контексте общей модели решения задач о потоках минимальной стоимости.

Программа 22.13. Поиск подходящих ребер

Данная функция находит подходящее ребро минимальной приведенной стоимости. Эта примитивная реализация выполняет обход всех ребер в сети.

 int costR(Edge *e, int v)
   { int R = e->cost() + phi[e->w()] - phi[e->v()];
  return e->from(v) ? R : -R;
   }
 Edge *besteligible()
   { Edge *x = 0; 
  for (int v = 0, min = C*G.V(); v < G.V(); v++)
    { typename Graph::adjIterator A(G, v);
   for (Edge* e = A.beg(); !A.end(); e = A.nxt())
     if (e->capRto(e->other(v)) > 0)
    if (e->capRto(v) == 0) if (costR(e, v) < min)
      { x = e; min = costR(e, v) ; }
    }
  return x;
   }
   

Граница производительности в худшем случае для программы 22.14 по меньшей мере в V раз меньше, чем граница для реализации с вычеркиванием циклов из программы 22.9, т.к. время, необходимое на одну итерацию, равно просто E (чтобы найти подходящее ребро), а не VE (чтобы найти отрицательный цикл). Можно подумать, что применение максимального расширения приведет к меньшему количеству расширений, чем выбор первого попавшегося отрицательного цикла, который находит алгоритм Беллмана-Форда, однако этот способ ничего не улучшает. Конкретные границы количества расширяющих циклов трудно вычислить, и обычно эти границы намного выше, чем величины, которые наблюдаются на практике. Как уже было сказано, имеются теоретические результаты, показывающие, что некоторые стратегии гарантируют, что количество расширяющих циклов ограничено полиномом от количества ребер, хотя в практических реализациях обычно используются варианты с экспоненциальным худшим случаем.

В свете всех этих соображений существует множество возможностей для повышения производительности рассматриваемых алгоритмов. Например, еще одна реализация сетевого симплексного алгоритма приведена в программе 22.15. Примитивная реализация в программе 22.14 всегда требует времени, пропорционального V, на обновление потенциалов дерева, и всегда тратит время, пропорциональное E, на поиск подходящего ребра с максимальной приведенной стоимостью. Реализация в программе 22.15 ликвидирует эти затраты в типичных сетях.

Во-первых, даже если выбор максимального ребра приводит к меньшему количеству итераций, затраты на проверку каждого ребра при поиске максимального ребра могут оказаться излишними. За время, необходимое на просмотр всех ребер, можно выполнить много расширений потоков по коротким циклам. Поэтому имеет смысл рассмотреть стратегию использования произвольного подходящего ребра, а не тратить время на поиск какого-то особенного.

Программа 22.14. Сетевой симплексный алгоритм (базовая реализация)

Данный класс использует сетевой симплексный алгоритм для решения задачи о потоке минимальной стоимости. Он использует стандартную функцию поиска в глубину dfsR на исходном дереве (см. упражнение 22.117), затем входит в цикл, в котором использует функции из программ 22.10-22.13 для вычисления потенциалов всех вершин, просмотра всех ребер и отыскания такого, которое образует отрицательный цикл минимальной стоимости, и расширения потока по этому циклу.

  template <class Graph, class Edge>
  class MINCOST
 { const Graph &G; int s, t; int valid;
   vector<Edge *> st; vector<int> mark, phi;
   void dfsR(Edge);
   int ST(int);
   int phiR(int);
   int lca(int, int); Edge *augment(Edge *);
   bool onpath(int, int, int);
   void reverse(int, int);
   void update(Edge *, Edge *);
   int costR(Edge *, int); Edge *besteligible();
 public:
   MINCOST(Graph &G, int s, int t) : G(G), s(s), t(t)
  st(G.V()), mark(G.V(), -1), phi(G.V())
  { Edge *z = new EDGE(s, t, M*G.V(), C*G.V());
    G.insert(z);
    z->addflowto(t, z->cap());
    dfsR(z);
    for (valid = 1; ; valid++ )
   { phi[t] = z->costRto(s); mark[t] = valid;
     for (int v = 0; v < G.V(); v++)
    if (v != t) phi[v] = phiR(v);
     Edge *x = besteligible();
     if (costR(x, x->v()) == 0) break;
     update(augment(x), x);
   }
    G.remove(z); delete z;
 }
   

Программа 22.15. Сетевой симплексный алгоритм (улучшенная реализация)

Замена ссылок на phi обращениями к phiR в функции R и замена цикла for в конструкторе программы 22.14 данным кодом дает реализацию сетевого симплексного алгоритма, которая экономит время на каждой итерации, вычисляя потенциалы только когда это необходимо, и выбирая первое обнаруженное подходящее ребро.

  int old = 0;
  for (valid = 1; valid != old; )
 { old = valid;
   for (int v = 0; v < G.V(); v++)
  { typename Graph::adjIterator A(G, v);
    for (Edge* e = A.beg(); !A.end(); e = A.nxt())
   if (e->capRto(e->other(v)) > 0)
     if (e->capRto(v) == 0)
    { update(augment(e), e); valid++; }
  }
 }
   

В худшем случае при поиске подходящего ребра придется проверить все ребра или их большую часть, но обычно нужно проверить сравнительно небольшое количество ребер, чтобы отыскать подходящее. Один из подходов - каждый раз начинать все с начала; другой подход - случайный выбор исходной точки (см. упражнение 22.126). Такое использование случайности делает маловероятным появление неестественно длинных последовательностей расширяющих путей.

Во-вторых, для вычисления потенциалов мы принимаем " отложенный " подход. Вместо того чтобы вычислять все потенциалы в векторе phi, индексированном именами вершин, а потом выбирать их по мере необходимости, мы вызываем функцию phiR, чтобы получить каждое значение потенциала; она поднимается по дереву до обнаружения допустимого потенциала, а затем вычисляет все необходимые потенциалы на этом пути. Чтобы реализовать такой подход, мы просто заменяем обращение к массиву phi[u] на вызов функции phiR(u). В худшем случае мы вычисляем все потенциалы так же, как и раньше, однако при просмотре лишь нескольких подходящих ребер мы вычисляем только те потенциалы, которые необходимы для выявления этих ребер.

Такие изменения никак не влияют на производительность алгоритма в худшем случае, но они, несомненно, ускоряют его работу в реальных ситуациях. Несколько других соображений по повышению производительности сетевого симплексного алгоритма анализируются в упражнениях (см. упражнения 22.i26-22.i30), и это лишь незначительная часть того, что было предложено.

Как мы неоднократно подчеркивали в этой книге, задача анализа и сравнения алгоритмов на графах сложна сама по себе. С появлением сетевого симплексного алгоритма эта задача еще больше усложнилась из-за различных подходов к реализации и большого разнообразия приложений, с которыми нам приходится сталкиваться (см. раздел 22.5). Какая из реализаций лучше? Стоит ли сравнивать реализации по известным границам для худших случаев? Насколько точно можно выразить различие в производительности различных реализаций для конкретных приложений? Следует ли использовать различные реализации, приспособленные для конкретных случаев?

Мы рекомендуем читателям поэкспериментировать с различными реализациями сетевого симплексного алгоритма и найти ответы на некоторые из предложенных вопросов, проведя эмпирические исследования, подобные тем, которые мы настоятельно рекомендовали на протяжении всей книги. При поиске решения задач о потоке минимальной стоимости мы сталкиваемся со знакомыми фундаментальными проблемами, но опыт, приобретенный при решении все более трудных задач на протяжении этой книги, сформирует у вас солидный фундамент для разработки эффективных реализаций, которые способны эффективно решать широкий спектр важных практических задач. Некоторые из этих исследований описаны в упражнениях в конце этого и следующего разделов, но эти упражнения следует рассматривать только как отправную точку. Каждый читатель может провести свои эмпирические исследования, которые проливают свет на некоторые интересные моменты реализаций и/или приложений.

Возможность существенно повысить производительность критических приложений за счет правильного использования классических структур данных и алгоритмов (либо разработки новых) для базовых задач делает изучение реализаций сетевого симплексного алгоритма благодатной областью исследований (поэтому существует обширная литература по его реализациям). Когда-то прогресс в этой области произвел настоящий переворот, т.к. помог снизить огромные затраты на решение сетевых симплексных задач. Пользователи предпочитают решать такие задачи с помощью тщательно отлаженных библиотек, и это оправдано во многих случаях. Однако таким библиотекам трудно постоянно соответствовать современным исследованиям и приспосабливаться к задачам, которые возникают в новых ситуациях. Быстродействие и размеры современных компьютеров, а также доступные реализации наподобие программ 22.12 и 22.13 могут послужить отправными точками для разработки эффективных средств решения задач для многочисленных приложений.

Упражнения

22.116. Приведите максимальный поток с соответствующим допустимым остовным деревом для транспортной сети с рис. 22.10.

22.117. Реализуйте функцию dfsR для программы 22.14.

22.118. Реализуйте функцию, которая удаляет циклы из частично заполненных ребер из потока заданной сети и строит допустимое остовное дерево для полученного потока, как показано на рис. 22.44. Оформите созданную функцию так, чтобы ее можно было использовать для построения исходного дерева в программе 22.14 или в программе 22.15.

22.119. Покажите, как изменятся таблицы потенциалов на рис. 22.46 при изменении ориентации ребра, соединяющего вершины 6 и 5.

22.120. Постройте транспортную сеть и приведите такую последовательность расширяющих ребер, что обобщенный сетевой симплексный алгоритм не остановится.

22.121. Покажите в стиле рис. 22.47 процесс вычисления потенциалов для дерева с корнем в вершине 0 с рис. 22.46.

22.122. Покажите в стиле рис. 22.50 процесс вычисления максимального потока минимальной стоимости в транспортной сети с рис. 22.10, начав с базового максимального потока и связанного с ним базового остовного дерева, которые найдены в упражнении 22.116.

22.123. Пусть все недревесные ребра пусты. Напишите функцию, которая вычисляет потоки в древесных ребрах и помещает поток в ребро, соединяющее вершину v с ее родителем в дереве, в v-й элемент вектора flow.

22.124. Выполните упражнение 22.123 для случая, когда некоторые недревесные ребра могут быть полными.

22.125. Воспользуйтесь программой 22.12 в качестве основы для алгоритма построения MST-дерева. Эмпирически сравните полученную реализацию с тремя базовыми алгоритмами вычисления MST из главы 20 лекция №20 (см. упражнение 20.66).

22.126. Опишите, что нужно изменить в программе 22.15, чтобы она каждый раз начинала поиск подходящего ребра со случайно выбранного ребра, а не с самого начала.

22.127. Измените решение упражнения 22.126, чтобы каждый поиск подходящего ребра программа начинала с того места, где был завершен предыдущий поиск.

22.128. Измените приватные функции-члены из данного раздела, чтобы использовать трехсвязную древовидную структуру со ссылками на родительский узел, самый первый дочерний узел и следующий сестринский узел (см. лекция №5). Функции расширения потока по циклу и замены ребра в дереве подходящим ребром должны выполняться за время, пропорциональное длине расширяющего цикла, а функция вычисления потенциалов должна выполняться за время, пропорциональное размеру меньшего из двух поддеревьев, образованных при удалении древесного ребра.

22.129. Измените приватные функции-члены из данного раздела, чтобы они, кроме базового вектора родительских ребер в дереве, использовали бы два других вектора, индексированных именами вершин: один содержит расстояние от каждой вершины до корня, а другой - потомка каждой вершины в дереве DFS. Функции расширения потока по циклу и замены ребра в дереве подходящим ребром должны выполняться за время, пропорциональное длине расширяющего цикла, а функция вычисления потенциалов должна выполняться за время, пропорциональное размеру меньшего из двух поддеревьев, образованных при удалении древесного ребра.

22.130. Обдумайте идею использования обобщенной очереди подходящих ребер. Рассмотрите различные реализации обобщенной очереди и различные усовершенствования, позволяющие снизить трудоемкость вычислений стоимостей ребер - например, рассматривать только подмножество подходящих ребер, чтобы ограничить размер очереди, или, может быть, позволить некоторым не подходящим ребрам оставаться в очереди.

22.131. Эмпирически определите количество итераций, количество вычислений потенциалов вершин и отношение времени выполнения к E для нескольких версий сетевого симплексного алгоритма и для различных видов сетей (см. упражнения 22.7-22.12). Рассмотрите различные алгоритмы, описанные в тексте и в предыдущих упражнениях, уделяя особое внимание тем, которые лучше работают на больших разреженных сетях.

22.132. Напишите клиентскую программу для графической анимации динамики сетевых симплексных алгоритмов. Эта программа должна создавать изображения, подобные рис. 22.50 и другим рисункам из этого раздела (рис. 22.48). Протестируйте полученную реализацию на евклидовых сетях из упражнений 22.7-22.12.

Сведения к задаче о потоке минимальной стоимости

Поток минимальной стоимости представляет собой общую модель решения, которая может охватывать множество полезных практических задач. В данном разделе мы обоснуем это утверждение, доказав сводимость разнообразных задач к задаче о потоке минимальной стоимости.

Очевидно, что задача о потоке минимальной стоимости имеет более общий характер, чем задача о максимальном потоке, поскольку любой максимальный поток минимальной стоимости является приемлемым решением задачи о максимальном потоке. Действительно, если в построении, показанном на рис. 22.42, назначить фиктивному ребру единичную стоимость, а другим ребрам нулевую, то любой максимальный поток минимальной стоимости минимизирует поток в фиктивном ребре и, следовательно, максимизирует поток в исходной сети. Поэтому все задачи, рассмотренные в разделе 22.4, которые сводятся к задаче о максимальном потоке, сводятся и к задаче о потоке минимальной стоимости. К этому множество задач относятся, в частности, задачи о двудольном сопоставлении, о допустимом потоке и о минимальном сечении.

Однако более интересно то, что свойства полученных нами алгоритмов решения задачи о потоке минимальной стоимости позволяют разработать новые обобщенные алгоритмы решения задачи о максимальном потоке. Мы уже отмечали, что обобщенный алгоритм вычеркивания циклов для решения задачи о максимальном потоке минимальной стоимости позволяет получить обобщенный алгоритм для задачи о максимальном потоке с использованием расширяющих путей. А именно, этот подход приводит к реализации, которая находит расширяющие пути без выполнения поиска в сети (см. упражнения 22.133 и 22.134). С другой стороны, этот алгоритм может порождать расширяющие пути с нулевым потоком, что затрудняет оценку его производительности (см. раздел ссылок).

Задача о потоке минимальной стоимости имеет также более общий характер, чем задача поиска кратчайшего пути, согласно следующему простому сведению.

 Сведение задачи поиска кратчайших путей


Рис. 22.51.  Сведение задачи поиска кратчайших путей

Поиск дерева кратчайших путей из одного истока в сети, изображенной вверху, эквивалентно решению задачи о максимальном потоке минимальной стоимости в транспортной сети, показанной внизу.

Лемма 22.29. Задача о кратчайших путях из одного истока (в сетях без отрицательных циклов) сводится к задаче о допустимом потоке минимальной стоимости.

Доказательство. Пусть имеется задача поиска кратчайших путей из одного истока (сеть с истоком в вершине s). Построим транспортную сеть с теми же вершинами, ребрами и стоимостями ребер и назначим ребрам неограниченную пропускную способность. Добавим новый исток с ребром, которое ведет в s и имеет нулевую стоимость и пропускную способность V - 1, а также новый сток с ребрами, в который ведут ребра из всех других вершин с нулевыми стоимостями и единичными пропускными способностями. Это построение показано на рис. 22.51.

Решим задачу о допустимом потоке минимальной стоимости для этой сети. При необходимости удалим из решения циклы, чтобы получить решение в виде ос-товного дерева. Это остовное дерево непосредственно соответствует остовному дереву кратчайших путей для исходной сети. Подробное доказательство этого факта оставляем читателям в качестве упражнения (см. упражнение 22.138).

Таким образом, все задачи, рассмотренные в лекция №21, которые сводятся к задаче кратчайших путей из одного истока, также сводятся к задаче о потоке минимальной стоимости. Это множество задач, в частности, включает задачу календарного планирования работ с конечными сроками и с разностными ограничениями.

Как мы убедились при изучении задач о максимальном потоке, имеет смысл проанализировать детали работы сетевого симплексного алгоритма при решении задачи поиска кратчайших путей с помощью сведения, описанного в лемме 22.29. В этом случае алгоритм использует остовное дерево с корнем в истоке - примерно как алгоритмы на основе поиска, которые были рассмотрены в главе 21 лекция №21 - но потенциалы и приведенные стоимости увеличивают гибкость при разработке методов выбора следующего ребра для включения в дерево.

Обычно мы не используем тот факт, что задача о потоке минимальной стоимости является естественным обобщением и задачи о максимальном потоке, и задачи поиска кратчайших путей, т.к. для обеих этих задач существуют специальные алгоритмы, гарантирующие более высокую производительность. Однако если таких реализаций нет, то качественная реализация сетевого симплексного алгоритма вполне может обеспечить быстрое решение конкретных экземпляров обеих задач. Разумеется, при использовании или построении систем обработки сетей, в которых применяются такие сведения, следует избегать циклов сведения. Например, реализация алгоритма вычеркивания циклов в программе 22.9 использует для решения задачи о потоке минимальной стоимости как алгоритмы вычисления максимального потока, так и алгоритмы поиска кратчайших путей (см. упражнение 21.96).

Теперь мы рассмотрим несколько эквивалентных сетевых моделей. Вначале покажем, что предположение о неотрицательности стоимостей не ограничивает возможность сведения, т.к. сети с отрицательными стоимостями можно преобразовать в сеть без таковых.

Лемма 22.30. В задачах о потоке минимальной стоимости без потери общности можно считать, что стоимости ребер неотрицательны.

Доказательство. Мы докажем это утверждение для допустимых потоков минимальной стоимости в распределительных сетях. Это результат верен и для максимальных потоков минимальной стоимости в силу эквивалентности этих двух задач согласно лемме 22.22 (см. упражнения 22.143 и 22.144).

Пусть имеется некоторая распределительная сеть. Заменим любое ребро u-v, имеющее стоимость x < 0 и пропускную способность с, на ребро v-u с той же пропускной способностью, но со стоимостью -х (положительной). Теперь можно уменьшить на с значение предложения-спроса вершины u и увеличить на с значение предложения-спроса вершины v. Эта операция соответствует проталкиванию с единиц потока из u в v с соответствующей подстройкой сети.

Если в случае ребер с отрицательной стоимостью решение задачи о потоке минимальной стоимости для преобразованной сети помещает в ребро v-u поток f, то мы помещаем в ребро u-v исходной сети поток с -f. В случае ребер с положительной стоимостью преобразованная сеть содержит те же потоки, что и исходная. Такое распределение потоков сохраняет ограничения на предложение и спрос во всех вершинах.

Поток в ребре u-v преобразованной сети добавляет к стоимости величину fx, а поток в ребре v-u исходной сети - величину - сх + fx. Первый член этого выражения не зависит от потока, значит, стоимость любого потока в преобразованной сети равна стоимости соответствующего потока в исходной сети плюс сумма произведений пропускных способностей на стоимости всех ребер с отрицательными стоимостями (отрицательная величина). Поэтому любой поток минимальной стоимости в преобразованной сети является потоком минимальной стоимости в исходной сети.

Это сведение показывает, что можно рассматривать только положительные стоимости, хотя на практике мы обычно так не делаем, т.к. наши реализации из разделов 22.5 и 22.6 имеют дело исключительно с остаточными сетями и без проблем обрабатывают отрицательные стоимости. Иногда важно иметь какую-то нижнюю границу стоимостей, но эта граница не должна быть нулевой (см. упражнение 22.145).

Далее мы покажем, что, как и в случае задачи о максимальном потоке, при желании можно ограничиться рассмотрением ациклических сетей. Более того, можно также считать, что ребра не имеют пропускных способностей (не существует верхней границы для величины потока в таких ребрах). Сочетание этих двух вариантов приводит к следующей классической формулировке задачи о потоке минимальной стоимости.

Транспортная задача. Нужно решить задачу о потоке минимальной стоимости для двудольной распределительной сети, все ребра которой направлены из вершины с предложением в вершину со спросом и имеют неограниченную пропускную способность. Как было сказано в начале данной главы (см. рис. 22.2), обычно эта задача моделирует распределение товаров из складов (вершины с предложением) в торговые точки (вершины со спросом) по каналам распределения (ребра) с конкретной стоимостью доставки единицы товара.

Лемма 22.31. Транспортная задача эквивалентна задаче о потоке минимальной стоимости.

Доказательство. Пусть имеется некоторая транспортная задача. Ее можно решить, назначив каждому ребру пропускную способность, более высокую, чем значения предложения или спроса в вершинах этог ребра, и решив полученную задачу нахождения допустимого потока минимальной стоимости для полученной распределительной сети. Значит, остается лишь доказать сводимость стандартной задачи к транспортной задаче.

Для разнообразия опишем новое преобразование, которое линейно только на разреженных сетях. Построение, подобное использованному при доказательстве леммы 22.16, доказывает этот результат и для не разреженных сетей (см. упражнение 22.148).

Пусть имеется стандартная распределительная сеть с V вершинами и E ребрами. Построим транспортную сеть с V вершинами предложения, E вершинами спроса и 2E ребрами следующим образом. Для каждой вершины исходной сети включим в двудольную сеть вершину с величиной предложения или спроса, равной исходному значению плюс сумма пропускных способностей исходящих ребер. А для каждого ребра u-v исходной сети с пропускной способностью c включим в двудольную сеть вершину с величиной предложения или спроса - c (обозначим эту вершину как [u-v]). Для каждого ребра u-v в исходной сети включим в двудольную сеть два ребра: одно с той же стоимостью и ведет из u в [u-v], а другое с нулевой стоимостью и ведет из v в [u-v].

Следующее взаимно однозначное соответствие сохраняет стоимости потоков в обеих сетях неизменными: ребро u-v исходной сети содержит поток величины f тогда и только тогда, когда ребро u-[u-v] содержит поток величины f, а ребро v-[u-v] двудольной сети содержит поток величины c -f (в силу ограничения на предложение-спрос в вершине [u-v] эти два потока должны давать в сумме значение c). Следовательно, любой поток минимальной стоимости в одной сети соответствует потоку минимальной стоимости в другой сети.

Поскольку мы не рассматривали прямые алгоритмы решения транспортной задачи, это сведение представляет собой лишь академический интерес. Чтобы воспользоваться им, необходимо преобразовать полученную задачу к (другой) задаче вычисления потока минимальной стоимости, воспользовавшись простым сведением, упомянутым в начале доказательства леммы 22.31. Возможно, на практике такие сети допускают более эффективные решения, а возможно, и нет. Цель изучения эквивалентности транспортной задачи и задачи о потоке минимальной стоимости в том, чтобы уяснить, что отказ от пропускных способностей и ограничение двудольными сетями не может существенно упростить задачу вычисления потока минимальной стоимости, как это может показаться.

В этом контексте нам придется рассмотреть другую классическую задачу. Она обобщает задачу о двудольном сопоставлении, которая подробно рассмотрена в разделе 22.4, и так же обманчиво проста.

Задача о назначениях. Для заданного взвешенного двудольного графа нужно найти множество ребер минимальной общей стоимости, чтобы каждая вершина была соединена в точности с одной другой вершиной.

Например, можно обобщить рассмотренную ранее задачу о трудоустройстве, если добавить возможность количественной оценки желания компании заполучить каждого претендента на рабочее место (скажем, присваивая каждому претенденту целочисленные баллы, при этом лучшим претендентам присваиваются более низкие баллы) и количественной оценки желания претендента получить рабочее место в каждой компании. Тогда решение задачи о назначениях предлагает способ разумного учета предпочтений обеих сторон.

Лемма 22.32. Задача о назначениях сводится к задаче вычисления потока минимальной стоимости.

Доказательство. Это утверждение можно доказать простым сведением к транспортной задаче. Пусть имеется задача о назначениях, и нужно построить транспортную задачу с теми же вершинами и ребрами. При этом все вершины из одного множества считаются вершинами с предложением и значением 1, а все вершины из другого множества считаются вершинами со спросом и значением 1. Назначим каждому ребру единичную пропускную способность и стоимость, соответствующую весу этого ребра в задаче о назначениях. Любое решение этого экземпляра транспортной задачи - это просто множество ребер минимальной общей стоимости, где каждое ребро соединяет вершину с предложением с вершиной со спросом, то есть оно непосредственно соответствует решению исходной задачи о назначениях.

Сведение этого экземпляра транспортной задачи к задаче вычисления минимального потока минимальной стоимости дает построение, по существу эквивалентное построению, которое мы использовали для сведения задачи двудольного сопоставления к задаче вычисления максимального потока (см. упражнение 22.158).

Это отношение не является эквивалентностью, поскольку способ сведения общей задачи о потоке минимальной стоимости к задаче о назначениях пока не известен. Аналогично задаче поиска кратчайших путей из одного истока и задаче вычисления максимального потока, задача о назначениях кажется более легкой для решения, чем задача вычисления потока минимальной стоимости: ведь алгоритмы ее решения имеют более высокую асимптотическую производительность, чем лучшие известные алгоритмы для задачи вычисления потока минимальной стоимости. Однако сетевой симплексный алгоритм настолько отлажен, что хорошая программная реализация вполне годится для решения задачи о назначениях. Более того, как и в задачах поиска максимального потока и кратчайших путей, можно приспособить сетевой симплексный алгоритм для более эффективного решения задачи о назначениях (см. раздел ссылок).

Следующее сведение к задаче вычисления потока минимальной стоимости возвращает нас к базовой задаче поиска путей в графах, вроде тех, которые были впервые рассмотрены в лекция №17. Как и в случае задачи вычисления эйлерова пути, нужно найти путь, который содержит все ребра графа. Но поскольку не все графы содержат такой путь, мы ослабляем ограничение, требующее, чтобы каждое ребро входило в путь только один раз.

Задача о почтальоне. Пусть имеется некоторая сеть (взвешенный орграф), и нужно найти замкнутый путь минимального веса, содержащий каждое ребро не менее одного раза (см. рис. 22.52). Напомним, что основные определения в лекция №17 различают циклические пути (которые могут неоднократно проходить по вершинам и ребрам) и циклы (состоящие из различных вершин, за исключением совпадающих первой и последней).

 Задача о почтальоне


Рис. 22.52.  Задача о почтальоне

Поиск кратчайшего пути, который включает каждое ребро по меньшей мере один раз - трудная задача даже для такой простой сети, но она эффективно решается с помощью сведения к задаче о потоке минимальной стоимости.

Решение этой задачи описывает наилучший маршрут почтальона (который должен пройти все улицы на своем пути). Решение этой задачи может также описывать маршрут снегоочистителя во время метели; существует и множество других аналогичных применений.

Задача о почтальоне представляет собой расширение задачи поиска эйлерова цикла, рассмотренной в лекция №17: решение упражнения 17.92 дает простую проверку на существование в орграфе эйлерова пути, а программа 17.14 является эффективным способом поиска эйлерова цикла в таких орграфах. Этот путь решает задачу о почтальоне, т.к. он содержит каждое ребро в точности один раз - никакой другой путь не может иметь более низкий вес. Задача усложняется, если степени захода и выхода не обязательно равны. В общем случае некоторые ребра приходится проходить более одного раза, и тогда задача заключается в том, чтобы минимизировать общий вес многократно посещаемых ребер.

Лемма 22.33. Задача о почтальоне сводится к задаче вычисления потока минимальной стоимости.

Доказательство. Пусть имеется экземпляр задачи о почтальоне (взвешенный орграф). Нужно определить распределительную сеть с теми же вершинами и ребрами, где значения предложения и спроса во всех вершинах равны 0, стоимости ребер равны весам соответствующих ребер, пропускные способности ребер не ограничены сверху, но все пропускные способности ребер должны быть больше 1. Величина f потока в ребре u-v означает, что всего почтальон должен пройти f раз по ребру u-v.

Найдем поток минимальной стоимости для этой сети, воспользовавшись преобразованием, описанным в упражнении 22.146, чтобы снять ограничение снизу на пропускные способности ребер. Теорема разложения потока утверждает, что поток можно представить в виде множества циклов. Значит, из этого потока можно построить циклический путь точно так же, как мы строили эйлеров цикл на эйлеровом графе: проходим по любому циклу, выходя из него для обхода другого цикла всякий раз, когда встречается вершина, принадлежащая другому циклу.

Тщательный анализ задачи о почтальоне еще раз демонстрирует тонкую грань между тривиальными и трудноразрешимыми задачами в области алгоритмов на графах. Предположим, что мы рассматриваем двунаправленный вариант этой задачи, когда сеть неориентированная, а почтальон должен пройти по каждому ребру в обоих направлениях. Тогда, как было сказано в разделе 18.5 лекция №18, поиск в глубину (или любой другой поиск на графе) даст нам немедленное решение. Но если достаточно одного прохода по ребру в каком-либо направлении, то решение задачи оказывается намного сложнее, чем просто сведение к задаче о потоке минимальной стоимости, с которым мы только что ознакомились, хотя задача все еще разрешима. Если некоторые ребра ориентированы, а другие нет, задача становится NP-трудной (см. раздел ссылок).

Это лишь немногие из десятков практических задач, которые можно сформулировать в виде задач о потоке минимальной стоимости. Задача о потоке минимальной стоимости более универсальна, чем задача вычисления максимального потока или построения кратчайшего пути, а сетевой симплексный алгоритм эффективно решает все задачи, охватываемые этой моделью.

Так же, как при изучении максимального потока, мы можем выяснить, как представить любую задачу о минимальном потоке в виде LP-задачи (см. рис. 22.53). Ее формулировка является простым расширением формулировки задачи о максимальном потоке: мы добавляем уравнения, в которых фиктивной переменной приравнивается стоимость потока, затем стараемся минимизировать эту переменную. LP-модели позволяют добавить произвольные (линейные) ограничения. Некоторые из этих ограничений могут привести к задачам, все равно эквивалентным задачам о потоках минимальной стоимости, другие - нет. То есть многие задачи не сводятся к задачам о потоках минимальной стоимости: например, линейное программирование охватывает гораздо более широкое множество задач. Задача о потоке минимальной стоимости представляет собой следующий шаг к той обобщенной модели решения задачи, которая будет рассматриваться в части VIII.

 LP-формулировка задачи о максимальном потоке минимальной стоимости


Рис. 22.53.  LP-формулировка задачи о максимальном потоке минимальной стоимости

Эта линейная программа эквивалентна задаче о максимальном потоке минимальной стоимости для сети с0 рис. 22.40. Равенства для вершин и неравенства для ребер те же, что и на рис.22.39, но цель здесь другая. Переменная c представляет собой общую стоимость, которая является линейной комбинацией других переменных. В данном случае с = -9x50 + x01 + x02 + x13 + x14 + 4x23 + 2x24 + 2x35 + x45 .

Существуют другие модели, которые имеют еще более общий характер, чем LP-модель; однако линейное программирование обладает дополнительным достоинством: хотя LP-задачи в общем случае более сложны, чем задачи о потоке минимальной стоимости, для их решения разработаны эффективные алгоритмы. Возможно, наиболее важный из этих алгоритмов - симплекс-метод, и сетевой симплексный метод представляет собой специализированный вариант симплекс-метода, применимый к подмножеству LP-задач, которые соответствуют задачам о потоке минимальной стоимости. Понимание сетевого симплексного алгоритма облегчает понимание алгоритма симплекс-метода.

Упражнения

22.133. Покажите, что когда сетевой симплексный алгоритм вычисляет максимальный поток, остовное дерево представляет собой объединение ребра t-s, дерева, содержащего вершину s, и дерева, содержащего вершину t.

22.134. Разработайте реализацию, вычисляющую максимальный поток на основе упражнения 22.133. Выбирайте подходящее ребро случайным образом.

22.135. Покажите в стиле рис. 22.50 процесс вычисления максимального потока в транспортной сети, изображенной на рис. 22.10, используя описанное в тексте сведение и реализацию сетевого симплексного алгоритма из программы 22.14.

22.136. Покажите в стиле рис. 22.50 процесс построения кратчайших путей из вершины 0 в транспортной сети, изображенной на рис. 22.10, используя описанное в тексте сведение и реализацию сетевого симплексного алгоритма из программы 22.14.

22.137. Докажите, что все ребра остовного дерева, описанного в доказательстве леммы 22.29, принадлежат путям, ведущим из истока к листьям.

22.138. Докажите, что остовное дерево, описанное в доказательстве леммы 22.29, соответствует дереву кратчайших путей в исходной сети.

22.139. Предположим, что вы используете сетевой симплексный алгоритм для решения задачи, полученной в результате сведения задачи о кратчайших путях из одного истока, как описано в доказательстве леммы 22.29. (1). Докажите, что этот алгоритм никогда не использует расширяющий путь нулевой стоимости. (2) Покажите, что удаляемое из цикла ребро всегда является родителем конечной вершины ребра, добавленного в цикл. (3) Из упражнения 22.138 следует, что сетевой симплексный алгоритм не обязан использовать потоки в ребрах. Представьте полную реализацию, в которой используется этот факт. Выбирайте новое древесное ребро случайным образом.

22.140. Пусть каждое ребро сети имеет положительную стоимость. Докажите, что задача построения дерева кратчайших путей из одного истока минимальной стоимости сводится к задаче о максимальном потоке минимальной стоимости.

22.141. Изменим задачу планирования с конечными сроками из лекция №21 так, что работы могут нарушать сроки завершения, если при этом они получают некоторую положительную стоимость. Покажите, что эта модифицированная задача сводится к задаче о максимальном потоке минимальной стоимости.

22.142. Реализуйте класс, который находит максимальные потоки минимальной стоимости в распределительных сетях с отрицательными стоимостями. Воспользуйтесь полученным классом для решения упражнения 22.105 (где все стоимости неотрицательны).

22.143. Пусть стоимости ребер 0-2 и 1-3 на рис. 22.40 равны -1, а не 1. Покажите, как найти максимальный поток минимальной стоимости путем преобразования заданной сети в сеть с положительными стоимостями с последующим вычислением максимального потока минимальной стоимости в новой сети.

22.144. Реализуйте класс, который находит максимальные потоки минимальной стоимости в сетях с отрицательной стоимостью. Воспользуйтесь классом MINCOST (в котором считается, что все стоимости неотрицательны).

22.145. Зависят ли реализации из разделов 22.5 и 22.6 существенным образом от того, что цены неотрицательны? Если зависят, то объясните, как. Если не зависят, покажите, какие нужны (если нужны) изменения, чтобы они работали для сетей с отрицательными стоимостями, либо объясните, почему такие изменения невозможны.

22.146. Добавьте в АТД допустимого потока из упражнения 22.74 возможность указания нижних границ пропускных способностей ребер. Реализуйте класс, который вычисляет максимальный поток минимальной стоимости с учетом этих границ (если он возможен).

22.147. Приведите результат использования сведения транспортной сети, описанной в упражнении 22.112, к транспортной задаче.

22.148. Покажите, что задача о максимальном потоке минимальной стоимости сводится к транспортной задаче со всего лишь V дополнительными вершинами и ребрами, если выполнить построение, аналогичному тому, что приведено в доказательстве леммы 22.16.

22.149. Реализуйте класс для решения транспортной задачи, основанный на простом сведении задачи о потоке минимальной стоимости, описанном в доказательстве леммы 22.30.

22.150. Разработайте реализацию класса для решения задачи вычисления потока минимальной стоимости на основе сведения к транспортной задаче, описанного в доказательстве леммы 22.31.

22.151. Разработайте реализацию класса для решения задачи вычисления потока минимальной стоимости на основе сведения к транспортной задаче, описанного в упражнении 22.148.

22.152. Напишите программу, генерирующую случайные экземпляры транспортной задачи, и используйте их для эмпирических тестов различных алгоритмов и реализаций, решающих эту задачу.

22.153. Найдите в интернете пример крупной транспортной задачи.

22.154. Эмпирически сравните два различных метода сведения произвольных задач о потоках минимальной стоимости к транспортной задаче, которые приведены в доказательстве леммы 22.31.

22.155. Напишите программу, генерирующую случайные экземпляры задачи о назначениях, и используйте их для эмпирических тестов различных алгоритмов и реализаций, решающих эту задачу.

22.156. Найдите в интернете пример крупной задачи о назначениях.

22.157. Задача о трудоустройстве, описанная в тексте, дает большее преимущество работодателям (максимизируется их общий вес). Сформулируйте версию этой задачи, где претенденты на рабочие места также могут высказывать свои пожелания. Объясните, как решить такую задачу.

22.158. Эмпирически сравните производительность двух реализаций сетевого симплексного алгоритма из раздела 22.6 при решении случайных вариантов задачи о назначениях (см. упражнение 22.155) с Vвершинами и E ребрами и разумным выбором значений V и E.

22.159. Понятно, что задача о почтальоне не имеет решения для сетей, которые не являются сильно связными (почтальон может посетить только вершины, содержащиеся в том сильном компоненте, где он начинает свой обход), однако это не оговаривается в сведении, которое описано в лемме 22.33. Что произойдет, если применить сведение к сети, которая не является сильно связной?

22.160. Эмпирические определите среднюю длину пути почтальона для различных взвешенных графов (см. упражнения 21.4-21.8).

22.161. Приведите прямое доказательство того, что задача о кратчайшем пути из одного истока сводится к задаче о назначениях.

22.162. Приведите формулировку произвольной задачи о назначениях в виде LP-задачи.

22.163. Выполните упражнение 22.18 для случая, когда стоимость каждого ребра равна - 1 (чтобы свести к минимуму пустое место в грузовых машинах).

22.164. Найдите такую модель стоимостей для упражнения 22.18, чтобы ее решением был максимальный поток, требующий минимальное количество дней.

Перспективы

Существуют четыре причины, по которым мы завершаем изучение алгоритмов на графах алгоритмами вычисления сетевых потоков. Во-первых, модель сетевых потоков демонстрирует практическую пользу абстракции графа в бесчисленных приложениях. Во-вторых, изученные нами алгоритмы вычисления максимальных потоков и потоков минимальной стоимости естественно расширяют алгоритмы на графах, рассмотренные для простых задач. В третьих, реализации этих алгоритмов показывают важную роль фундаментальных алгоритмов и структур данных для достижения высокой производительности. В четвертых, модели максимальных потоков и потоков минимальной стоимости демонстрируют пользу разработки все более общих моделей решения задач и их использования для решения широких классов задач. Наша способность разрабатывать эффективные алгоритмы для решения таких задач дает возможность разрабатывать более общие модели и алгоритмы для решения таких задач.

Прежде чем перейти к более подробному рассмотрению этих вопросов, мы перечислим важные задачи, которые нам не удалось изучить в этой главе, хотя они тесно связаны с известными задачами.

Задача о максимальном сопоставлении (maximum matching). В графах со взвешенными ребрами нужно найти подмножество ребер, в котором ни одна из вершин не появляется больше одного раза, и никакое другое множество ребер не имеет большего суммарного веса. Задачу о сопоставлении максимальной мощности (maximum cardinality matching) в невзвешенных графах можно непосредственно свести к этой задаче, присвоив всем ребрам единичные веса.

Задача о назначениях и задачи о двудольном сопоставлении максимальной мощности сводятся к задаче максимального сопоставления для графов общего вида. С другой стороны, максимальное сопоставление не сводится к потокам минимальной стоимости, поэтому и рассмотренные выше алгоритмы не применимы к нему. Эта задача разрешима, хотя трудоемкость ее решения в случае крупных графов весьма велика. Описание многочисленных методов, предложенных для решения задачи сопоставления на графах общего вида, заняло бы целый том: эта задача относится к числу наиболее интенсивно изучаемых в теории графов. В этой книге мы закончили изложение потоками минимальной стоимости, но мы еще вернемся к задаче о максимальном сопоставлении в части VIII.

Задача о многопродуктовом потоке (multicommodity flow). Предположим, что нам нужно вычислить второй поток, такой, что сумма обоих потоков в ребре ограничена пропускной способностью ребра, оба потока сбалансированы, а их суммарная стоимость минимальна. Такое изменение моделирует наличие двух различных типов материалов в задаче о распределении товаров. Например, следует ли загрузить больше гамбургеров или картошки в грузовик, который едет в ресторан быстрого питания? Это изменение существенно усложняет задачу и требует для ее решения более сложных алгоритмов, чем рассмотренные в этой книге. К примеру, для такого общего случая не известен какой-либо аналог теоремы о максимальном потоке минимальной стоимости. Формулировка этой задачи в виде LP-задачи - простое расширение примера с рис. 22.53, следовательно, эта задача разрешима (т.к. разрешима LP-задача).

Выпуклые и нелинейные стоимости. Рассмотренные нами простые функции стоимости представляют собой линейные комбинации переменных, а наши алгоритмы для их решения существенно зависят от простой математической структуры этих функций. Однако во многих приложениях возникают более сложные функции. Например, если нужно минимизировать расстояние, появляются суммы квадратов стоимостей. Такие задачи невозможно сформулировать в виде LP-задач, поэтому они требуют специальных, еще более мощных, моделей решения задач. Многие из этих задач являются неразрешимыми.

Календарное планирование. Мы рассмотрели несколько примеров таких задач. Но это лишь небольшая часть из нескольких сотен различных задач календарного планирования. Исследовательская литература содержит массу статей по исследованию отношений между этими задачами и по разработке алгоритмов и реализаций для их решения (см. раздел ссылок). Вообще-то мы могли бы использовать алгоритмы составления расписаний вместо алгоритмов вычисления сетевых потоков, чтобы продемонстрировать определение обобщенных моделей решения задач и реализацию сведения конкретных задач (то же можно сказать и о задаче сопоставления). Многие задачи календарного планирования сводятся к модели потока минимальной стоимости.

Возможности комбинаторных вычислений поистине велики, поэтому изучение подобных задач, несомненно, будет продолжаться еще многие годы. Мы вернемся к некоторым из этих задач в части VIII, при рассмотрении трудноразрешимых задач.

Мы ознакомились лишь с небольшой частью исследованных алгоритмов решения задач о максимальном потоке и о потоке минимальной стоимости. Как указывалось в упражнениях на протяжении данной главы, комбинации многочисленных разновидностей различных частей множества обобщенных алгоритмов порождают огромное количество разнообразных алгоритмов. Алгоритмы и структуры данных для базовых вычислительных задач играют важную роль в обеспечении эффективности многих из этих подходов. И некоторые рассмотренные нами важные алгоритмы общего назначения были разработаны в попытках эффективной реализации алгоритмов вычисления сетевых потоков. Эта тема все еще интенсивно изучается. Конечно, разработка более совершенных алгоритмов для решения задач вычисления сетевых потоков зависит от правильного использования базовых алгоритмов и структур данных.

Широкая применимость алгоритмов вычисления сетевых потоков и интенсивное использование сведения к другим задачам для еще большего расширения этой области заставляют нас рассмотреть здесь некоторые следствия из понятия сводимости. Для обширного класса комбинаторных алгоритмов эти задачи представляют собой водораздел - между необходимостью изучать эффективные алгоритмы решения конкретных задач и изучением обобщенных моделей решения задач. Имеются серьезные аргументы в пользу и того, и другого направления.

И мы пришли к разработке максимально обобщенной модели, т.к. чем выше уровень общности модели, тем больше задач она охватывает, а это повышает полезность алгоритма, который может решить любую задачу, сводимую к этой модели. Разработка такого алгоритма может оказаться сложным, а то и невозможным делом. Даже при отсутствии алгоритма с гарантированно приемлемой производительностью обычно существует алгоритм, который хорошо работает для специальных классов задач, которые нас интересуют. Отдельные аналитические выкладки зачастую дают очень грубые оценки производительности, но нередко имеются убедительные эмпирические исследования. Практики обычно вначале пробуют наиболее общую доступную модель (или ту, для которой разработан отлаженный пакет решений) и не ищут ничего больше, если модель работает достаточно быстро. И все же не следует увлекаться слишком обобщенными моделями, которые тратят кучу времени на решение тех задач, для которых эффективно работают более специализированные модели.

Часто нам приходится искать более совершенные алгоритмы для важных специальных задач, особенно для крупных задач или большого количества мелких задач, когда вычислительные ресурсы становятся узким местом. Как мы могли убедиться на многочисленных примерах в этой книге и в частях I-IV, часто можно найти хитроумный алгоритм, который позволяет снизить затраты ресурсов в сотни и тысячи раз и даже больше, что исключительно важно при измерении затрат в часах или долларах. Общий подход, описанный в лекция №2, который мы успешно применяли во многих областях, исключительно полезен в таких ситуациях, и мы ожидаем появления более совершенных алгоритмов в области алгоритмов на графах и комбинаторных алгоритмов. Пожалуй, существенный недостаток слишком большого упора на специальные алгоритмы заключается в том, что часто небольшое изменение в модели приводит к непригодности алгоритма. А при использовании слишком обобщенной модели и алгоритма, обеспечивающего решение задачи, мы меньше подвержены такому влиянию.

Программные библиотеки, охватывающие множество рассмотренных нами алгоритмов, имеются во многих средах программирования. Такие библиотеки, несомненно, представляют собой важные инструменты, наличие которых следует учитывать при решении конкретных задач. Но иногда такие библиотеки трудны в использовании, слишком стары или не очень соответствуют текущей задаче. Опытные программисты знают важность компромисса между использованием библиотечного ресурса и чрезмерной зависимостью от этого ресурса (даже без учета морального старения). Некоторые из рассмотренных нами реализаций эффективны, просты для разработки и широко используются. Адаптация и настройка таких реализаций для решения текущих программ во многих ситуациях представляют собой разумный подход.

Разрыв между теоретические исследованиями, которые ограничены тем, что можно доказать, и эмпирическими исследованиями, которые имеют отношение только к текущим задачам, все более заметен по мере возрастания трудности задач. Теория обеспечивает понимание сути задачи, а практический опыт подсказывает, что требуется для разработки реализаций. А опыт, приобретенный при решении практических задач, открывает новые направления развития теории, что, в свою очередь, расширяет класс практических задач, которые мы можем решать.

Но какой бы подход мы ни выбрали, цель остается прежней: нам нужен широкий спектр моделей для решения задач, эффективные алгоритмы решения задач в рамках этих моделей, а также эффективные реализации этих алгоритмов, позволяющие решать практические задачи. Разработка все более универсальных моделей решения задач (таких как задачи вычисления кратчайших путей, максимальных потоков и потоков минимальной стоимости) и все более мощных алгоритмов общего характера (таких как алгоритм Беллмана-Форда для задачи вычисления кратчайших путей, алгоритм расширения путей для задачи о максимальном потоке и сетевой симплексный алгоритм для задачи о максимальном потоке минимальной стоимости) позволили нам существенно продвинуться в направлении этой цели. Значительная часть этой работы была выполнена в пятидесятые и шестидесятые годы. А позже появились фундаментальные структуры данных (части I-IV) и алгоритмы, обеспечивающие эффективные реализации этих общих методов (данная книга), ставшие мощной силой, которая привела нас к возможности решать такой широкий класс крупных задач.

Ссылки для части V

Перечисленные ниже учебники по алгоритмам охватывают большую часть алгоритмов обработки графов из глав 17-21. Эти книги - основные справочники, в которых всесторонне описаны фундаментальные и дополнительные алгоритмы обработки гра-форв с обширными ссылками на современную литературу. В книге Ивена (Even) и монографии Тарьяна (Tarjan) всесторонне освещены многие рассмотренные нами темы. Стоит прочитать и первоначальную статью Тарьяна о применении поиска в глубину для решении задачи сильной связности и других задач. Реализация топологической сортировки с очередью истоков из лекция №19 взята из книги Кнута. Ниже приведен список литературы по некоторым другим рассмотренным нами алгоритмам.

Алгоритмы для вычисления минимальных остовных деревьев в насыщенных графах из лекция №20 имеют солидный возраст, но все-таки интересно прочитать исходные статьи Дейкстры (Dijkstra), Прима (Prim) и Крускала (Kruskal). Обзор Грэма и Хелла (Graham and Hell) содержит исчерпывающую и увлекательную историю предмета. Статья Шазель (Chazelle) - шедевр в области линейных алгоритмов построения MST-дерева.

Книга Агуджи, Магнанти и Орлина (Ahuja, Magnanti and Orlin) - исчерпывающий текст по алгоритмам вычисления сетевых потоков (и алгоритмов поиска кратчайших путей). В этой книге имеется более подробная информация практически по каждой теме из глав 21 и 22. Еще один источник дополнительного материала - классическая книга Пападимитриу и Штайглица (Papadimitriou and Steiglitz). Хотя большая часть книги посвящена более сложным темам, в ней тщательно описаны и многие рассмотренные нами алгоритмы. Обе книги содержат обширную и подробную информацию об исходном материале из исследовательской литературы. Классическая работа Форда и Фалкерсона (Ford and Fulkerson) до сих пор достойна чтения, т.к. содержит введение во многие фундаментальные понятия.

Мы кратко упомянули множество дополнительных тем из (пока не выпущенной) части VIII - например, сводимость, разрешимость и линейное программирование. Данный список ссылок в основном содержит подробное изложение материала повышенной сложности. Многие из них упоминаются в текстах алгоритмов, а в книге Пападимитриу и Штайглица приведено исчерпывающее введение. По этим темам имеется и множество других книг и обширная исследовательская литература.

1. R. K. Ahuja, T. L. Magnanti, and J. B. Orlin, Network Flows: Theory, Algorithms, and Applications, Prentice Hall, 1993.

2. Chazelle, "A minimum spanning tree algorithm with inverse-Ackermann type complexity," Journal of the ACM, 47 (2000).

3. Томас Х. Кормен, Чарльз И. Лейзерсон, Рональд Л. Ривест, Клиффорд Штайн, Алгоритмы: построение и анализ, 2-е издание, ИД "Вильямс", 2009 г.

4. E. W. Dijkstra, "A note on two problems in connexion with graphs," Numerische Mathematik, 1 (1959).

5. P. Erdos and A. Renyi, "On the evolution of random graphs," Magyar Tud. Akad. Mat. Kutato Int Kozl, 5 (1960).

6. S. Even, Craph Algorithms, Computer Science Press, 1979.

7. L. R. Ford and D. R. Fulkerson, Flows in Networks, Princeton University Press, 1962.

8. H. N. Gabow, "Path-based depth-first search for strong and bicon-nected components," Information Processing Letters, 74 (2000).

9. R. L. Graham and P. Hell, "On the history of the minimum spanning tree problem," Annals of the History of Computing, 7 (1985).

10. D. B. Johnson, "Efficient shortest path algorithms," Journal of the ACM, 24(1977).

11. Д.Э. Кнут, Искусство программирования, том 1: Основные алгоритмы, 3-е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 2: Получисленные алгоритмы, 3-е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 3. Сортировка и поиск, 2-е издание, ИД "Вильямс", 2008 г.

12. J. R. Kruskal Jr., "On the shortest spanning subtree of a graph and the traveling salesman problem," Proceedings AMS, 7, 1 (1956).

13. K. Mehlhorn, Data Structures and Algorithms 2: NP-Completeness and Graph Algorithms, Springer-Verlag, 1984.

14. H. Papadimitriou and K. Steiglitz, Combinatorial Optimization: Algorithms and CompleXity, Prentice-Hall, 1982.

15. R. C. Prim, "Shortest connection networks and some generalizations," Bell System Technical Journal, 36 (1957).

16. R. E. Tarjan, "Depth-first search and linear graph algorithms," SIAM Journal on Computing, 1, 2 (1972).

17. R. E. Tarjan, Data Structures and Network Algorithms, Society for Industrial and Applied Mathematics, Philadelphia, PA, 1983.

Дополнения


Литература