Глава 3. Обход дерева. Перебор с возвратами.
Глава 3. Обход дерева. Перебор с возвратами.
3.1. Ферзи, не бьющие друг друга: обход дерева позиций
В предыдущей главе мы рассматривали несколько задач одного
и того же типа: "перечислить все элементы некоторого множества
A". Схема решения была такова: на множестве A вводился порядок и
описывалась процедура перехода от произвольного элемента мно-
жества A к следующему за ним (в этом порядке). Такую схему не
всегда удается реализовать непосредственно, и в этой главе мы
рассмотрим другой полезный прием перечисления всех элементов не-
которого множества. Его называют "поиск с возвратами", "метод
ветвей и границ", "backtracking". На наш взгляд наиболее точное
название этого метода - обход дерева.
3.1.1. Перечислить все способы расстановки n ферзей на шах-
матной доске n на n, при которых они не бьют друг друга.
Решение. Очевидно, на каждой из n горизонталей должно сто-
ять по ферзю. Будем называть k-позицией (для k = 0, 1,...,n)
произвольную расстановку k ферзей на k нижних горизонталях (фер-
зи могут бить друг друга). Нарисуем "дерево позиций": его корнем
будет единственная 0-позиция, а из каждой k-позиции выходит n
стрелок вверх в (k+1)-позиции. Эти n позиций отличаются положе-
нием ферзя на (k+1)-ой горизонтали. Будем считать, что располо-
жение их на рисунке соответствует положению этого ферзя: левее
та позиция, в которой ферзь расположен левее.
Дерево позиций для
n = 2
Среди позиций этого дерева нам надо отобрать те n-позиции, в ко-
торых ферзи не бьют друг друга. Программа будет "обходить дере-
во" и искать их. Чтобы не делать лишней работы, заметим вот что:
если в какой-то k-позиции ферзи бьют друг друга, то ставить
дальнейших ферзей смысла нет. Поэтому, обнаружив это, мы будем
прекращать построение дерева в этом направлении.
Точнее, назовем k-позицию допустимой, если после удаления
верхнего ферзя оставшиеся не бьют друг друга. Наша программа бу-
дет рассматривать только допустимые позиции.
Дерево допустимых
позиций для n = 3
Разобьем задачу на две части: (1) обход произвольного дере-
ва и (2) реализацию дерева допустимых позиций.
Сформулируем задачу обхода произвольного дерева. Будем счи-
тать, что у нас имеется Робот, который в каждый момент находится
в одной из вершин дерева (вершины изображены на рисунке кружоч-
ками). Он умеет выполнять команды:
вверх_налево (идти по самой левой
из выходящих вверх стрелок)
вправо (перейти в соседнюю справа
вершину)
вниз (спуститься вниз на один уро-
вень)
вверх_налево
вправо
вниз
и проверки, соответствующие возможности выполнить каждую из ко-
манд, называемые "есть_сверху", "есть_справа", "есть_снизу"
(последняя истинна всюду, кроме корня). Обратите внимание, что
команда "вправо" позволяет перейти лишь к "родному брату", но не
к "двоюродному".
Так команда "вправо"
НЕ действует!
Будем считать, что у Робота есть команда "обработать" и что
его задача - обработать все листья (вершины, из которых нет
стрелок вверх, то есть где условие "есть_сверху" ложно). Для на-
шей шахматной задачи команде обработать будет соответствовать
проверка и печать позиции ферзей.
Доказательство правильности приводимой далее программы ис-
пользует такие определения. Пусть фиксировано положение Робота в
одной из вершин дерева. Тогда все листья дерева разбиваются на
три категории: над Роботом, левее Робота и правее Робота. (Путь
из корня в лист может проходить через вершину с Роботом, свора-
чивать влево, не доходя до нее и сворачивать вправо, не доходя
до нее.) Через (ОЛ) обозначим условие "обработаны все листья ле-
вее Робота", а через (ОЛН) - условие "обработаны все листья ле-
вее и над Роботом".
Нам понадобится такая процедура:
procedure вверх_до_упора_и_обработать
| {дано: (ОЛ), надо: (ОЛН)}
begin
| {инвариант: ОЛ}
| while есть_сверху do begin
| | вверх_налево
| end
| {ОЛ, Робот в листе}
| обработать;
| {ОЛН}
end;
Основной алгоритм:
дано: Робот в корне, листья не обработаны
надо: Робот в корне, листья обработаны
{ОЛ}
вверх_до_упора_и_обработать
{инвариант: ОЛН}
while есть_снизу do begin
| if есть_справа then begin {ОЛН, есть справа}
| | вправо;
| | {ОЛ}
| | вверх_до_упора_и_обработать;
| end else begin
| | {ОЛН, не есть_справа, есть_снизу}
| | вниз;
| end;
end;
{ОЛН, Робот в корне => все листья обработаны}
Осталось воспользоваться следующими свойствами команд Робота
(сверху записаны условия, в которых выполняется команда, снизу -
утверждения о результате ее выполнения):
(1) {ОЛ, не есть_сверху} (2) {ОЛ}
обработать вверх_налево
{ОЛН} {ОЛ}
(3) {есть_справа, ОЛН} (4) {не есть_справа, ОЛН}
вправо вниз
{ОЛ} {ОЛН}
3.1.2. Доказать, что приведенная программа завершает работу
(на любом конечном дереве).
Решение. Процедура вверх_налево завершает работу (высота
Робота не может увеличиваться бесконечно). Если программа рабо-
тает бесконечно, то, поскольку листья не обрабатываются повтор-
но, начиная с некоторого момента ни один лист не обрабатывается.
А это возможно, только если Робот все время спускается вниз.
Противоречие. (Об оценке числа действий см. далее.)
3.1.3. Доказать правильность следующей программы обхода де-
рева:
var state: (WL, WLU);
state := WL;
while есть_снизу or (state <> WLU) do begin
| if (state = WL) and есть_сверху then begin
| | вверх;
| end else if (state = WL) and not есть_сверху then begin
| | обработать; state := WLU;
| end else if (state = WLU) and есть_справа then begin
| | вправо; state := WL;
| end else begin {state = WLU, not есть_справа, есть_снизу}
| | вниз;
| end;
end;
Решение. Инвариант цикла:
state = WL => ОЛ
state = WLU => ОЛН
Доказательство завершения работы: переход из состояния ОЛ в ОЛН
возможен только при обработке вершины, поэтому если программа
работает бесконечно, то с некоторого момента значение state не
меняется, что невозможно.
3.1.4. Решить задачу об обходе дерева, если мы хотим, чтобы
обрабатывались все вершины (не только листья).
Решение. Пусть x - некоторая вершина. Тогда любая вершина y
относится к одной из четырех категорий. Рассмотрим путь из корня
в y. Он может:
(а) быть частью пути из корня в x (y ниже x);
(б) свернуть налево с пути в x (y левее x);
(в) пройти через x (y над x);
(г) свернуть направо с пути в x (y правее x);
В частности, сама вершина x относится к категории (в). Условия
теперь будут такими:
(ОНЛ) обработаны все вершины ниже и левее;
(ОНЛН) обработаны все вершины ниже, левее и над.
Вот как будет выглядеть программа:
procedure вверх_до_упора_и_обработать
| {дано: (ОНЛ), надо: (ОНЛН)}
begin
| {инвариант: ОНЛ}
| while есть_сверху do begin
| | обработать
| | вверх_налево
| end
| {ОНЛ, Робот в листе}
| обработать;
| {ОНЛН}
end;
Основной алгоритм:
дано: Робот в корне, ничего не обработано
надо: Робот в корне, все вершины обработаны
{ОНЛ}
вверх_до_упора_и_обработать
{инвариант: ОНЛН}
while есть_снизу do begin
| if есть_справа then begin {ОНЛН, есть справа}
| | вправо;
| | {ОНЛ}
| | вверх_до_упора_и_обработать;
| end else begin
| | {ОЛН, не есть_справа, есть_снизу}
| | вниз;
| end;
end;
{ОНЛН, Робот в корне => все вершины обработаны}
3.1.5. Приведенная только что программа обрабатывает верши-
ну до того, как обработан любой из ее потомков. Как изменить ее,
чтобы каждая вершина, не являющаяся листом, обрабатывалась дваж-
ды: один раз до, а другой раз после всех своих потомков? (Листья
по-прежнему обрабатываются по разу.)
Решение. Под "обработано ниже и левее" будем понимать "ниже
обработано по разу, слева обработано полностью (листья по разу,
останые по два)". Под "обработано ниже, левее и над" будем пони-
мать "ниже обработано по разу, левее и над - полностью".
Программа будет такой:
procedure вверх_до_упора_и_обработать
| {дано: (ОНЛ), надо: (ОНЛН)}
begin
| {инвариант: ОНЛ}
| while есть_сверху do begin
| | обработать
| | вверх_налево
| end
| {ОНЛ, Робот в листе}
| обработать;
| {ОНЛН}
end;
Основной алгоритм:
дано: Робот в корне, ничего не обработано
надо: Робот в корне, все вершины обработаны
{ОНЛ}
вверх_до_упора_и_обработать
{инвариант: ОНЛН}
while есть_снизу do begin
| if есть_справа then begin {ОНЛН, есть справа}
| | вправо;
| | {ОНЛ}
| | вверх_до_упора_и_обработать;
| end else begin
| | {ОЛН, не есть_справа, есть_снизу}
| | вниз;
| | обработать;
| end;
end;
{ОНЛН, Робот в корне => все вершины обработаны полностью}
3.1.6. Доказать, что число операций в этой программе по по-
рядку равно числу вершин дерева. (Как и в других программах, ко-
торые отличаются от этой лишь пропуском некоторых команд "обра-
ботать".)
Указание. Примерно каждое второе действие при исполнении
этой программы - обработка вершины, а каждая вершина обрабатыва-
ется максимум дважды.
Теперь реализуем операции с деревом позиций. Позицию будем
представлять с помощью переменной k: 0..n (число ферзей) и мас-
сива c: array [1..n] of 1..n (c [i] - координаты ферзя на i-ой
горизонтали; при i > k значение c [i] роли не играет). Предпола-
гается, что все позиции допустимы (если убрать верхнего ферзя,
остальные не бьют друг друга).
program queens;
| const n = ...;
| var
| k: 0..n;
| c: array [1..n] of 1..n;
|
| procedure begin_work; {начать работу}
| begin
| | k := 0;
| end;
|
| function danger: boolean; {верхний ферзь под боем}
| | var b: boolean; i: integer;
| begin
| | if k <= 1 then begin
| | | danger := false;
| | end else begin
| | | b := false; i := 1;
| | | {b <=> верхний ферзь под боем ферзей с номерами < i}
| | | while i <> k do begin
| | | | b := b or (c[i]=c[k]) {вертикаль}
| | | | or (abs(c[[i]-c[k]))=abs(i-k)); {диагональ}
| | | | i := i+ 1;
| | | end;
| | | danger := b;
| | end;
| end;
|
| function is_up: boolean {есть_сверху}
| begin
| | is_up := (k < n) and not danger;
| end;
|
| function is_right: boolean {есть_справа}
| begin
| | is_right := (k > 0) and (c[k] < n);
| end;
| {возможна ошибка: при k=0 не определено c[k]}
|
| function is_down: boolean {есть_снизу}
| begin
| | is_up := (k > 0);
| end;
|
| procedure up; {вверх_налево}
| begin {k < n}
| | k := k + 1;
| | c [k] := 1;
| end;
|
| procedure right; {вправо}
| begin {k > 0, c[k] < n}
| | c [k] := c [k] + 1;
| end;
|
| procedure down; {вниз}
| begin {k > 0}
| | k := k - 1;
| end;
|
| procedure work; {обработать}
| | var i: integer;
| begin
| | if (k = n) and not danger then begin
| | | for i := 1 to n do begin
| | | | write ('<', i, ',' , c[i], '> ');
| | | end;
| | | writeln;
| | end;
| end;
|
| procedure UW; {вверх_до_упора_и_обработать}
| begin
| | while is_up do begin
| | | up;
| | end
| | work;
| end;
|
begin
| begin_work;
| UW;
| while is_down do begin
| | if is_right then begin
| | | right;
| | | UW;
| | end else begin
| | | down;
| | end;
| end;
end.
3.1.7. Приведенная программа тратит довольно много времени
на выполнение проверки есть_сверху (проверка, находится ли
верхний ферзь под боем, требует числа действий порядка n). Изме-
нить реализацию операций с деревом позиций так, чтобы все три
проверки есть_сверху/справа/снизу и соответствующие команды тре-
бовали бы количества действий, ограниченного не зависящей от n
константой.
Решение. Для каждой вертикали, каждой восходящей и каждой
нисходящей диагонали будем хранить булевское значение - сведения
о том, находится ли на этой линии ферзь (верхний ферзь не учиты-
вается). (Заметим, что в силу допустимости позиции на каждой из
линий может быть не более одного ферзя.).
3.2. Обход дерева в других задачах.
3.2.1. Использовать метод обхода дерева для решения следу-
ющей задачи: дан массив из n целых положительных чисел
a[1]..a[n] и число s; требуется узнать, может ли число s быть
представлено как сумма некоторых из чисел массива a. (Каждое
число можно использовать не более чем по одному разу.)
Решение. Будем задавать k-позицию последовательностью из k
булевских значений, определяющих, входят ли в сумму числа
a[1]..a[k] или не входят. Позиция допустима, если ее сумма не
превосходит s.
Замечание. По сравнению с полным перебором всех (2 в степе-
ни n) подмножеств тут есть некоторый выигрыш. Можно также пред-
варительно отсортировать массив a в убывающем порядке, а также
считать недопустимыми те позиции, в которых сумма отброшенных
членов больше, чем разность суммы всех членов и s. Последний
приём называют "методом ветвей и границ". Но принципиального
улучшения по сравнению с полным перебором тут не получается (эта
задача, как говорят, NP-полна, см. подробности в книге Ахо,
Хопкрофта и Ульмана "Построение и анализ вычислительных алгорит-
мов"). Традиционное название этой задачи - "задача о рюкзаке"
(рюкзак общей грузоподъемностью s нужно упаковать под завязку,
располагая предметами веса a[1]..a[n]). См. также в главе 7
(раздел о динамическом программировании) алгоритм её решения,
полиномиальный по n+s.
3.2.2. Перечислить все последовательности из n нулей, еди-
ниц и двоек, в которых никакая группа цифр не повторяется два
раза подряд (нет куска вида XX).
3.2.3. Аналогичная задача для последовательностей нулей и
единиц, в которых никакая группа цифр не повторяется три раза
подряд (нет куска вида XXX).
К этой же категории относятся задачи типа "можно ли сложить
данную фигуру из пентамино" и им подобные. В них важно умелое
сокращение перебора (вовремя распознать, что имеющееся располо-
жение фигурок уже противоречит требованиям, и по этой ветви по-
иск не продолжать).
[ Назад ]
[ Оглавление ]
[ Далее ]