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

 

     К этой же категории относятся задачи типа "можно ли сложить

данную фигуру из пентамино" и им подобные. В  них  важно  умелое

сокращение  перебора (вовремя распознать, что имеющееся располо-

жение фигурок уже противоречит требованиям, и по этой ветви  по-

иск не продолжать).

Hosted by uCoz