Введение

Алгоритмы, рекурсивно решающие подзадачи. В информатике метод "разделяй и властвуй" является парадигмой разработки алгоритмов. Алгоритм "разделяй и властвуй" рекурсивно разбивает задачу на две или более подзадачи одного или связанных типов, пока они не станут достаточно простыми для непосредственного решения. Затем решения подзадач объединяются для получения решения исходной задачи. Метод "разделяй и властвуй" лежит в основе эффективных алгоритмов для многих задач, таких как сортировка (например, быстрая сортировка, сортировка слиянием), умножение больших чисел (например, алгоритм Карацубы), поиск ближайшей пары точек, синтаксический анализ (например, нисходящие парсеры) и вычисление дискретного преобразования Фурье (FFT). Разработка эффективных алгоритмов "разделяй и властвуй" может быть сложной задачей. Как и в математической индукции, часто необходимо обобщить задачу, чтобы сделать её подходящей для рекурсивного решения. Корректность алгоритма "разделяй и властвуй" обычно доказывается методом математической индукции, а его вычислительная сложность часто определяется путём решения рекуррентных соотношений.

Разделяй и властвуй

Парадигма "разделяй и властвуй" часто используется для нахождения оптимального решения задачи. Её основная идея заключается в разложении данной задачи на две или более похожих, но более простых подзадач, решении их последовательно и комбинировании их решений для решения исходной задачи. Задачи достаточной простоты решаются напрямую. Например, для сортировки заданного списка из n натуральных чисел, разделите его на два списка примерно по n/2 чисел в каждом, отсортируйте каждый из них последовательно и объедините оба результата соответствующим образом, чтобы получить отсортированную версию исходного списка (см. рисунок). Этот подход известен как алгоритм сортировки слиянием. Название "разделяй и властвуй" иногда применяется к алгоритмам, которые сводят каждую задачу только к одной подзадаче, например, к алгоритму бинарного поиска для нахождения элемента в отсортированном списке (или к его аналогу в численных вычислениях – алгоритму бисекции для поиска корня). Эти алгоритмы могут быть реализованы более эффективно, чем общие алгоритмы "разделяй и властвуй"; в частности, если они используют хвостовую рекурсию, их можно преобразовать в простые циклы. Однако, при таком широком определении, любой алгоритм, использующий рекурсию или циклы, можно рассматривать как алгоритм "разделяй и властвуй". Поэтому некоторые авторы считают, что название "разделяй и властвуй" следует использовать только тогда, когда каждая задача может породить две или более подзадач. Для класса задач, сводящихся к одной подзадаче, вместо этого было предложено название decrease and conquer. Важным применением принципа "разделяй и властвуй" является оптимизация, где, если пространство поиска уменьшается ("обрезается") на каждом шаге на постоянный фактор, общая сложность алгоритма совпадает с асимптотической сложностью шага обрезки, при этом постоянный множитель зависит от фактора обрезки (путем суммирования геометрической прогрессии); это известно как обрезка и поиск.

Ранние исторические примеры

Ранние примеры этих алгоритмов прежде всего относятся к стратегии «разделяй и властвуй» — исходная проблема последовательно разбивается на отдельные подзадачи и, фактически, может быть решена итеративно. Бинарный поиск, алгоритм «разделяй и властвуй», где подзадачи примерно вдвое меньше первоначального размера, имеет долгую историю. Хотя четкое описание алгоритма для компьютеров появилось в 1946 году в статье Джона Мокли, идея использования отсортированного списка элементов для упрощения поиска восходит, по крайней мере, к Вавилону в 200 году до н.э. Он не анализировал количество операций количественно, и быстрое преобразование Фурье (FFT) не получило широкого распространения, пока не было заново открыто более века спустя. Ранним алгоритмом «разделяй и властвуй» с двумя подзадачами, специально разработанным для компьютеров и должным образом проанализированным, является алгоритм сортировки слиянием, изобретенный Джоном фон Нейманом в 1945 году. Другой заметный пример — алгоритм, изобретенный Анатолием А. Карацубой в 1960 году, который позволяет умножать два n-значных числа за операций (в нотации «Большое О»). Этот алгоритм опроверг предположение Андрея Колмогорова 1956 года о том, что для этой задачи потребуется операций. В качестве еще одного примера алгоритма «разделяй и властвуй», который изначально не был связан с компьютерами, Дональд Кнут приводит метод, который обычно используется почтовыми отделениями для маршрутизации почты: письма сортируются в отдельные мешки для разных географических регионов, каждый из этих мешков, в свою очередь, сортируется на партии для более мелких подрегионов и так далее, пока они не будут доставлены. Это связано с поразрядной сортировкой, описанной для машин сортировки перфокарт еще в 1929 году. Более того, алгоритмы «разделяй и властвуй» могут быть разработаны для важных алгоритмов (например, сортировки, FFT и умножения матриц) как оптимальные алгоритмы, нечувствительные к кэшу — они используют кэш, вероятно, оптимальным образом, в асимптотическом смысле, независимо от размера кэша. В отличие от этого, традиционный подход к использованию кэша — это блокировка, как в оптимизации вложенных циклов, где проблема явно разделяется на блоки подходящего размера — это также может оптимально использовать кэш, но только когда алгоритм настроен на конкретные размеры кэша конкретной машины. То же преимущество существует и в отношении других иерархических систем хранения, таких как NUMA или виртуальная память, а также для нескольких уровней кэша: как только подзадача становится достаточно малой, ее можно решить на заданном уровне иерархии, не обращаясь к более высоким (медленным) уровням.

Контроль за замыканием

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

Рекурсия

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

Явный стек

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

Размер стека

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

Выбор базовых случаев

В любом рекурсивном алгоритме существует значительная свобода в выборе базовых случаев – небольших подзадач, которые решаются непосредственно для завершения рекурсии. Выбор наименьшего или самого простого возможного базового случая более элегантен и обычно приводит к более простым программам, поскольку приходится рассматривать меньше случаев, и их легче решить. Например, алгоритм FFT может останавливать рекурсию, когда на вход подается один элемент, а алгоритм быстрой сортировки списка – когда на вход подается пустой список; в обоих примерах нужно рассмотреть только один базовый случай, который не требует обработки. С другой стороны, эффективность часто повышается, если рекурсия останавливается на относительно больших базовых случаях, которые решаются нерекурсивно, что приводит к гибридному алгоритму. Эта стратегия позволяет избежать накладных расходов на рекурсивные вызовы, выполняющие мало или вообще никакой работы, а также может позволить использовать специализированные нерекурсивные алгоритмы, которые для этих базовых случаев более эффективны, чем явная рекурсия. Общая процедура для простого гибридного рекурсивного алгоритма – это короткое замыкание базового случая, также известное как рекурсия на расстоянии вытянутой руки. В этом случае перед вызовом функции проверяется, приведет ли следующий шаг к базовому случаю, избегая ненужного вызова функции. Например, в дереве вместо рекурсивного обращения к дочернему узлу с последующей проверкой на null, проверка на null выполняется перед рекурсивным обращением, что позволяет избежать половины вызовов функций в некоторых алгоритмах для двоичных деревьев. Поскольку алгоритм «разделяй и властвуй» в конечном итоге сводит каждую проблему или подпроблему к большому количеству базовых экземпляров, они часто доминируют в общей стоимости алгоритма, особенно когда накладные расходы на разделение/объединение невелики. Важно отметить, что эти соображения не зависят от того, реализуется ли рекурсия компилятором или с помощью явного стека. Таким образом, например, многие библиотечные реализации быстрой сортировки переключаются на простой алгоритм сортировки вставками на основе цикла (или аналогичный), как только количество элементов для сортировки становится достаточно малым. Следует отметить, что если бы пустой список был единственным базовым случаем, сортировка списка из *n* элементов потребовала бы максимально *n* рекурсивных вызовов быстрой сортировки, которые бы просто немедленно возвращались. Увеличение базовых случаев до списков размером 2 или меньше устранит большинство этих бесполезных вызовов, и, в более общем смысле, базовый случай размером больше 2 обычно используется для уменьшения доли времени, затрачиваемого на накладные расходы на вызовы функций или манипулирование стеком. В качестве альтернативы можно использовать большие базовые случаи, которые по-прежнему используют алгоритм «разделяй и властвуй», но реализуют алгоритм для предопределенного набора фиксированных размеров, где алгоритм может быть полностью развернут в код, не содержащий рекурсии, циклов или условных операторов (что связано с техникой частичной оценки). Например, этот подход используется в некоторых эффективных реализациях FFT, где базовые случаи представляют собой развернутые реализации алгоритмов FFT «разделяй и властвуй» для набора фиксированных размеров. Методы генерации исходного кода могут использоваться для создания большого количества отдельных базовых случаев, необходимых для эффективной реализации этой стратегии.

Динамическое программирование для перекрывающихся подзадач

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