Лекция 1 Метод перебора с возвратом

advertisement
Лекция 1
Метод перебора с возвратом
Метод перебора с возвратом позволяет решать практически бесчисленное
множество задач, для которых неизвестны другие алгоритмы. Несмотря на такое
большое многообразие переборных задач, в основе их решения есть нечто общее,
позволяющее применить данный метод. Таким образом, перебор можно считать
практически универсальным методом решения задач.
Для изложения дальнейшего материала введем несколько простых определений:
Ход - отдельная часть решения, мини-задача, из решения которых складывается
решение полной задачи.
Вариант (хода) - возможный ход.
Подпрограмма, которая перебирает всевозможные варианты ходов для поиска
решения, складывающегося из этих ходов, называется подпрограммой перебора с
возвратами (backtracking).
Определение переборной подпрограммы следует понимать следующим образом:
для всех первых ходов, будут перебраны всевозможные вторые ходы, затем для
вторых ходов будут перебраны всевозможные третьи ходы и так далее, пока не будут
перебраны все варианты ходов. Таким образом, ищется первый подходящий вариант,
затем для этого варианта хода второй и так далее, пока для очередного хода не будут
перебраны все варианты. Можно сравнить это с построением некой цепочки ходов.
Поскольку для каждого хода есть несколько вариантов следующих ходов, образуется
своеобразное дерево рекурсивных вызовов перебора с возвратами. Отсюда следует и
оценка времени выполнения переборных программ - KN, где K - количество вариантов
следующих ходов для данного хода, n - количество ходов, то есть время выполнения
программы растет экспоненциально от количества различных вариантов для данного
варианта. Это значит, что чисто переборные решения не эффективны, и их стоит
применять только, если не было найдено подходящего полиномиального алгоритма.
Все задачи, для решения которых можно применить перебор с возвратами, можно
условно разделить на несколько больших групп:
 Поиск одного решения
 Поиск всех решений
 Поиск оптимального решения
Для решения некоторых задач требуется найти одно решение, для этого можно
просто перебрать все варианты и решением будет первый подходящий вариант. В
задачах поиска всех решений дело обстоит несколько иначе: необходимо перебрать
все варианты и выбрать все подходящие. Третий класс задач похож на предыдущий:
нам также надо будет найти все решения, и затем выбрать из них оптимальное.
Обычно выбор оптимального решения происходит в процессе самого перебора.
Рассмотрим алгоритм перебора с возвратом (backtracking) на примере задачи о
прохождении лабиринта (поиск всех решений).
Дан лабиринт, оказавшись внутри которого нужно найти выход наружу.
Перемещаться можно только в горизонтальном и вертикальном направлениях. На
рисунке показаны все варианты путей выхода из центральной точки лабиринта.
Для получения программы решения этой задачи нужно решить две проблемы:
• как организовать данные;
• как построить алгоритм.
Информацию о форме лабиринта будем хранить в квадратной матрице LAB
символьного типа размером N x N, где N — нечетное число (чтобы была центральная
точка). На профиль лабиринта накладывается сетка так, что в каждой ее ячейке
находится либо стена, либо проход.
Матрица отражает заполнение сетки: элементы, соответствующие проходу, равны
пробелу, а стене — какому-нибудь символу (например, букве m).
Путь движения по лабиринту будет отмечаться символами +.
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
Исходные данные — профиль лабиринта (исходная матрица LAB без крестиков);
результат — все возможные траектории выхода из центральной точки лабиринта (для
каждого пути выводится матрица LAB с траекторией, отмеченной крестиками).
Алгоритм перебора с возвратом еще называют методом проб.
Суть метода:
1. Из каждой очередной точки траектории просматриваются возможные
направления движения в одной и той же последовательности (например, вверх-внизвправо-влево); шаг производится в первую же обнаруженную свободную соседнюю
клетку; клетка, в которую сделан шаг, отмечается крестиком.
2. Если из очередной клетки дальше пути нет (тупик), то следует возврат на один
шаг назад и просматриваются еще не испробованные пути движения из этой точки; при
возвращении назад покинутая клетка отмечается пробелом.
3. Если очередная клетка, в которую сделан шаг, оказалась на краю лабиринта (на
выходе), то на печать выводится найденный путь.
Процедура GO пытается сделать шаг в клетку с координатами х, у. Если эта
клетка оказывается на выходе из лабиринта, то пройденный путь выводится на печать.
Если нет, то в соответствии с установленной выше последовательностью делается шаг
в соседнюю клетку. Если клетка тупиковая, то выполняется шаг назад. Из сказанного
выше следует, что процедура носит рекурсивный характер.
Запишем сначала общую схему процедуры без детализации:
void go(…)
{
if клетка свободна
{
шаг в клетку
if край лабиринта
печатаем путь
else
{
Попытки сделать шаг в соседние клетки в определенной последовательности
}
возвращение назад на 1 шаг
}
}
Для вывода найденных траекторий составляется процедура printlab.
В окончательном виде программа будет выглядеть так:
#include <iostream>
#include <string>
#include <iomanip>
using namespace std;
int n,m;
void printlab(char **&lab)
{
int i,j;
cout<<"--------------------------------"<<'\n';
for (i=0;i<n;i++)
{
for (j=0;j<m;j++)
cout<<lab[i][j];
cout<<'\n';
}
}
void go(char **&lab, int x,int y)
{
if (lab[x][y]==' ') //клетка свободна
{
lab[x][y]='+'; //шаг в клетку
if ((x==0)||(x==n-1)||(y==0)||(y==m-1)) //край
printlab(lab);
else
{
go(lab,x+1,y);
go(lab,x-1,y);
go(lab,x,y+1);
go(lab,x,y-1);
}
lab[x][y]=' '; //возвращение назад
}
}
int main()
{
int j,i;
string s;
cin>>n>>m;
char **lab;
lab=new char *[n];
for (i=0;i<n;i++)
lab[i]=new char[m];
cin.ignore(); //необходим, чтобы пропустить в конце предыдущей строки символ //перехода
на новую строку, чтобы он не считывался командой getline
for (i=0;i<n;i++)
{
getline(cin,s);
for (j=0;j<m;j++)
lab[i][j]=s[j];
}
go(lab,n/2,m/2);
}
Для лабиринта
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
+
m
m
m
m
m
+
m
m
m
m
m
+
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
получим два решения
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
m
+
+
+
m
m
m
m
m
m
m
m
+
m
+
m
+
+
m
+
m
m
+
m
m
Для лабиринта
m
m
m
m
m
m
m
m
m
m
M
M
M
M
m
получим три решения (самостоятельно).
Схема алгоритма данной программы типична для метода перебора с возвратом.
По аналогичным алгоритмам решаются, например, популярные задачи об обходе
шахматной доски фигурами или о расстановке фигур на доске так, чтобы они «не били»
друг друга; множество задач оптимального выбора (задачи о коммивояжере, о рюкзаке,
об оптимальном строительстве дорог и т.п.).
Задача. Заданы N различных натуральных чисел. Выбрать из этих чисел такие,
чтобы их сумма равнялась заданному числу Z. Вывести все возможные решения.
Например, N=6: 1, ,9, 3, 2, 5, 6. При Z=10:
19
136
325
#include <iostream>
using namespace std;
int n,z,s;
int a[20];
char d[20];
void printrez()
{
int i;
for (i=1;i<=n;i++)
if (d[i]=='+')
cout<<a[i]<<' ';
cout<<'\n';
cout<<"----------------"<<'\n';
}
void poisk(int k)
{
int i;
s=s+a[k];
d[k]='+';
if (s==z)
printrez();
else
if (s<z)
for (i=k+1;i<=n;i++)
poisk(i);
s=s-a[k];
d[k]=' ';
}
int main()
{
int j,s;
cin>>n;
for (j=1;j<=n;j++)
{
cin>>a[j];
d[j]=' ';
}
cin>>z;
s=0;
for (j=1;j<=n;j++)
poisk(j);
}
Динамическое программирование
Существует ряд задач, для решения которых могут использоваться методы
перебора. Но в некоторых случаях гораздо эффективнее применять метод
динамического программирования, так как это позволяет значительно уменьшить
временную сложность алгоритма. Суть метода заключается в том, что для отыскания
решения поставленной задачи сначала решается похожая задача, но более простая. В
ходе решения более простой задачи осуществляется переход к еще более простым, и
так далее, пока не доходят до тривиальной. Затем на основе решения более простых
задач постепенно получаются решения более сложных и т.д. Это происходит до тех
пор, пока не будет получено решение искомой задачи.
Динамическое программирование полезно, если на разных путях многократно
встречаются одни и те же подзадачи; основной технический приём — запоминать
решения встречающихся подзадач на случай, если та же подзадача встретится вновь.
То есть одной из особенностей динамического программирования является то, что
каждая подзадача решается только один раз.
В типичном случае динамическое программирование применяется к задачам
оптимизации. У такой задачи может быть много возможных решений, но требуется
выбрать оптимальное решение, при котором значение некоторого параметра будет
минимальным или максимальным.
Из предыдущих рассуждений видно, что решение можно оформить рекурсивно.
Действительно, многие задачи динамического программирования используют рекурсию.
Но простое применение этого приема очень легко может привести к переполнению
стека. Необходимо позаботиться об оптимизации рекурсивных проходов и не вычислять
одно и то же значение несколько раз, сделать так называемое отсечение. В
большинстве случаев можно вообще отказаться от рекурсии и решать задачу
"наоборот" — прежде "решить" тривиальные случаи, а затем переходить ко все более
сложным.
Для решения задач оптимизации существует специальная теория, Большая
заслуга в ее создании принадлежит Ричарду Беллману. Само понятие «динамическое
программирование» появилось в 1950-х годах.
Рассмотрим некоторые классические задачи динамического программирования.
Числа Фибоначчи
Существует много способов решения этой задачи.
Самый очевидный способ “решения” задачи состоит в написании рекурсивной
функции, но при этом где-то в середине четвертого десятка программа “подвешивает”
самый быстрый компьютер. Почему так происходит? Для вычисления F(40) мы сначала
вычисляем F(39) и F(38). Причем F(38) мы считаем “по новой”, “забывая”, что уже
вычислили его, когда считали F(39). Фактически мы заново проходим почти по всем
уровням рекурсии. То есть основная ошибка заключается в том, что значение функции
при одном и том же значении аргумента считается несколько раз (чем больше значение
N, тем больше раз приходится вычислять одни и те же значения). Если исключить
повторный счет, то функция станет заметно эффективней. Для этого приходится
завести массив, в котором хранятся значения нашей функции:
long int d[50];
Но здесь срабатывает золотой закон программирования — выигрывая в скорости,
проигрываем в памяти. Сначала массив заполняется значениями, которые заведомо не
могут быть значениями нашей функции (чаще всего, это “минус единица”, но в нашей
задаче вполне годится для этих целей “ноль”). При попытке вычислить какое-то
значение, программа смотрит, не вычислялось ли оно ранее, и если да, то берет
готовый результат. Функция принимает следующий вид:
#include <iostream>
#include <iomanip>
using namespace std;
long int d[50];
int fib(int k)
{
if (d[k]==0)
if ((k==1)||(k==2)) d[k]=1;
else d[k]=fib(k-1)+fib(k-2);
return d[k];
}
void main()
{
int i,n;
cin>>n;
for (i=1;i<=n;i++) d[i]=0;
cout<<fib(n);
}
Можно еще более упростить решение, убрав рекурсию вообще. Для этого
необходимо сменить нисходящую логику рассуждения (от того, что надо идти от
сложной задачи к тривиальной) на восходящую (соответственно наоборот). В этой
задаче такой переход очевиден и описывается простым циклом:
d[1]= 1; d[2]= 1;
For (i=3; i<=n; i++) d[i] = d[i-1] + d[i-2];
Чаще всего такой способ раза в три быстрее. Но иногда безрекурсивная
(итеративная) форма оказывается чрезвычайно сложной и малопонятной.
Черепашка
На квадратной доске расставлены целые неотрицательные числа. Черепашка,
находящаяся в левом верхнем углу, мечтает попасть в правый нижний. При этом она
может переползать только в клетку справа или снизу и хочет, чтобы сумма всех чисел,
оказавшихся у нее на пути, была бы максимальной. Определить эту сумму.
Полный перебор всех вариантов – универсальный способ решения. В этом случае,
например, для таблицы размером 4х4 будет 20 путей, что потребует 100 операций
сложения при подсчете сумм чисел (длина пути равна 6), для таблицы размером 8х8 –
3432 пути (44616 операций), для таблицы размером 31х31 - -1017 путей и -59х1017
операций сложения (что потребует несколько десятков лет вычислений).
Рассмотрим другой способ решения задачи. Определим подзадачу как ту же
самую задачу, но для таблицы меньшего размера. Например, для таблицы размером
3х4 подзадача – это решение для таблиц с размерами 1х2, 2х1, 2x2, 1x3, 2x3, 3x2 и т.д.
Для таблиц размеров 1x2, 1x3, 1x4 движение Черепашки происходит только вправо,
поэтому сумма стоимости клеток считается однозначно. Аналогично для таблиц
размером 2х1, 3х1.
Пусть у нас есть таблица A:
2
5
8
9
3
4
7
1
2
3
4
5
Для запоминания промежуточных результатов будем строить вторую таблицу B,
для каждой клетки которой будет хранить максимальную длину пути до нее.
Тогда первая строка и первый столбец этой таблицы будут выглядеть следующим
образом:
2
7
15
24
5
7
Рассмотрим теперь таблицу размером 2х2, являющуюся фрагментом исходной
таблицы. Для трех клеток максимальный путь уже вычислен. Очевидно, что у
Черепашки есть два способа попадания в правую нижнюю клетку – слева или сверху.
Выбираем тот, который дает максимальную сумму, то есть получим значение
4+max(5,7)=11. Далее аналогично будем рассматривать таблицы размером 2х3
(7+max(11,15))=22 и т.д. В итоге, получим
2
5
7
11
15
22
24
25
7
14
25
31
Результат – длина пути равна 31.
#include <iostream>
#include <fstream>
using namespace std;
int n,m;
void Solve(int **a,int **&b)
{
int i,j,max;
b[0][0]=a[0][0];
for (i=1;i<n;i++) b[i][0]=b[i-1][0]+a[i][0];
for (j=1;j<m;j++) b[0][j]=b[0][j-1]+a[0][j];
for (i=1;i<n;i++)
for (j=1;j<m;j++)
{
max=(b[i-1][j]>b[i][j-1])?b[i-1][j]:b[i][j-1];
b[i][j]=max+a[i][j];
}
}
void main()
{
cin>>n>>m;
int i,j;
int **pole1,**pole2;
pole1=new int *[n];
pole2=new int *[n];
for (i=0;i<n;i++)
pole1[i]=new int[m];
for (i=0;i<n;i++)
pole2[i]=new int[m];
fstream f1,f2;
f1.open("a.txt",ios::in);
f2.open("b.txt",ios::out);
for (i=0;i<n;i++)
for (j=0;j<m;j++)
f1>>pole1[i][j];
Solve(pole1,pole2);
for (i=0;i<n;i++)
{
for (j=0;j<m;j++)
f2<<pole2[i][j]<<' ';
f2<<'\n';
}
f1.close();
f2.close();
}
Временная сложность решения O(n*m), так как для вычисления каждого значения
B требуется максимум две операции – сравнение и сложение. Так что даже для
таблицы размером 300х300 с этой задачей менее чем за одну секунду справится даже
компьютер со скоростью работы всего 1 миллион операций в секунду.
К-ичные числа
Требуется вычислить количество N-значных чисел в системе счисления с
основанием K, таких что их запись не содержит двух подряд идущих нулей.
Ограничения: 2 <= K <= 10, N + K <= 18.
Все числа можно разделить на три класса:
1) числа, в которых нет двух подряд идущих нулей и в конце стоит не 0;
2) числа, в которых нет двух подряд идущих нулей и в конце стоит один 0;
3) числа, в которых есть два подряд идущих нуля.
При добавлении очередной цифры к числу 1 класса мы получим либо число 1
класса (если это был не 0), либо число 2 класса (если добавим 0).
При добавлении очередной цифры к числу 2 класса мы получим либо число 1
класса (в конце не 0), либо число 3 класса (добавим еще один 0)
При добавлении любой цифры к числу 3 класса получим снова число 3 класса.
Обнозначим nz – количество чисел 1 класса, oz – количество чисел 2 класса, tz –
количество чисел 3 класса
Пусть n=1. Тогда nz=k, oz=1, tz=0;
Пусть нам известно количество чисел в каждом классе для (n-1) цифры. Тогда для
вычисления количества n-значных чисел в трех классах воспользуемся следующими
рассуждениями.
Количество чисел в 1 классе для n-значных чисел будет равно количеству чисел 1
класса для (n-1) значных чисел, умноженное на (k-1) (количество ненулевых цифр в kичной системе счисления). К этому же количеству добавится количество (n-1)-значных
чисел 2 класса, умноженное на (k-1).
Количество чисел во 2 классе для n-значных чисел будет равно количеству (n-1)значных чисел 1 класса (к каждому из них добавим 0, но количество чисел от этого не
изменится)
Количество чисел во 3 классе для n-значных чисел будет равно количеству (n-1)значных чисел 3 класса, умноженное на k и плюс количество (n-1)-значных чисел 2
класса (к каждому из них добавить еще по одному нулю).
#include <iostream>
using namespace std;
void main()
{
int i,k,n,nz,_nz,oz,_oz,tz,_tz;
cin>>k;
cin>>n;
nz=k-1;
oz=1;
tz=0;
for (i=2;i<=n;i++)
{
_nz=nz;
_oz=oz;
_tz=tz;
nz=_nz*(k-1)+_oz*(k-1);
oz=_nz;
tz=_tz*k+_oz;
}
cout<<nz+oz<<'\n';
cout<<tz;
}
Download