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

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