Backtracking
Busca exaustiva, poda e backtracking. Gerar todas as possibilidades, cortar cedo as que não servem e desfazer escolhas, com o labirinto como estudo de caso.
Este texto continua o de Recursão: tudo aqui parte da mesma ideia de caso base e passo recursivo, aplicada a um tipo específico de problema, aquele em que precisamos procurar uma resposta entre muitas possibilidades.
O assunto é construído em camadas. Primeiro, a forma mais simples de resolver esse tipo de problema: gerar e testar todas as possibilidades (força bruta). Depois, como abandonar cedo os caminhos que claramente não vão dar certo (poda). Com esses dois ingredientes chegamos à definição de backtracking e a um estudo de caso: encontrar a saída de um labirinto.
Força bruta
Uma busca exaustiva, ou busca por força bruta, gera todas as soluções candidatas de um problema e, a partir delas, escolhe a melhor, conta quantas existem ou simplesmente as lista. Costuma ser fácil de implementar e sempre dá a resposta certa; o problema é que, se o número de candidatas cresce rápido demais, ela fica lenta demais para ser útil.
Sequências binárias
Começando do jeito mais simples possível: gerar todas as sequências de 000, 001, 010, ..., 111.
Um vetor vet de tamanho i existem exatamente duas escolhas, 0 ou 1, e a função tenta as
duas, uma de cada vez. Quando todas as posições estão preenchidas
(i == n), a sequência está completa e é impressa.
A árvore abaixo mostra as chamadas para
Subconjuntos
Agora, em vez de 0 e 1, interprete cada escolha como deixar de fora
ou incluir um elemento. O mesmo código passa a gerar todos os
subconjuntos de um conjunto de
Compare a árvore abaixo com a das sequências binárias: é a mesma árvore,
só com rótulos diferentes ("sem A" e "com A" no lugar de 0 e 1). No
fundo, os dois problemas são um só, interpretado de duas formas.
Um conjunto de
Poda
Soma de subconjuntos
Considere agora o problema de listar todos os subconjuntos de limite (a mochila aguenta
um peso máximo).
A solução direta seria gerar os
Uma poda é uma condição verificada durante a busca que permite descartar um ramo inteiro da árvore de chamadas sem explorá-lo até o fim.
Esta função tenta primeiro incluir o elemento e depois deixá-lo de fora, por isso a árvore abaixo tem a inclusão à esquerda: as folhas, da esquerda para a direita, aparecem na mesma ordem da saída.
Repare que geraSubconjuntos recebe a soma como parâmetro. Uma
primeira versão poderia recalculá-la a cada chamada, percorrendo pertence
do início:
long long calculaSoma(int ate) {
long long soma = 0;
for (int j = 0; j < ate; j++)
if (pertence[j])
soma += valores[j];
return soma;
}
void geraSubconjuntos(int i) {
if (calculaSoma(i) > limite)
return;
/* ... resto igual, sem o parâmetro soma ... */
}As duas versões fazem exatamente as mesmas chamadas: é a mesma árvore, com a mesma poda. A diferença é o trabalho de cada chamada.
| Recalculando | Soma como parâmetro | |
|---|---|---|
| Chamadas recursivas | mesma árvore | mesma árvore |
| Trabalho por chamada | ||
| Trabalho total (pior caso) |
As duas continuam exponenciais, mas eliminar o recálculo tira um fator
Em soma_subconjuntos.c, a poda if (soma > limite) return; vem antes
de if (i == n). O que aconteceria se ela viesse depois?
Backtracking
O que backtracking não é
Antes da definição, um contraexemplo: o Fibonacci recursivo também se
ramifica em duas chamadas, como geraSequencias e geraSubconjuntos.
int fib(int n) {
if (n == 0 || n == 1)
return n;
return fib(n - 1) + fib(n - 2);
}Mas fib não gera soluções distintas para listar, contar ou escolher a
melhor: ela combina (soma) os resultados das duas chamadas em um único
valor. Não há um espaço de soluções sendo percorrido, nem escolhas a fazer e
desfazer. Ter várias chamadas recursivas não basta para ser busca exaustiva,
muito menos backtracking.
Definição
Backtracking é uma forma de fazer busca exaustiva com recursão, construindo a solução uma escolha de cada vez. A cada escolha, verificamos se ela ainda pode levar a uma solução (poda); se puder, aplicamos a escolha e continuamos a partir do novo estado; se, mais à frente, o caminho se mostrar sem saída, desfazemos a escolha e tentamos a próxima alternativa.
Backtracking é busca exaustiva: no pior caso, ainda pode visitar o espaço de busca inteiro. A diferença é cortar os ramos que já se mostraram inúteis. Por isso o nome tentativa e erro, às vezes usado como sinônimo, descreve bem o mecanismo: tenta, e se não der certo, desfaz e tenta a próxima.
| Força bruta | Backtracking | |
|---|---|---|
| Verifica a validade antes da folha | Não | Sim (poda) |
| Ramos percorridos | Sempre todos | Pode cortar ramos inteiros |
| Pior caso | Exponencial (ou pior) | O mesmo, mas em geral bem mais rápido na prática |
| Exemplos | Sequências binárias, subconjuntos | Soma de subconjuntos, labirinto |
O template
Os detalhes mudam de problema para problema, mas o esqueleto é sempre este:
backtrack(estado):
se estado é solução:
registra a solução
retorna
para cada escolha possível a partir do estado:
se a escolha é válida:
aplica a escolha (muda o estado)
backtrack(novo estado) (tenta continuar daqui)
desfaz a escolha (volta ao estado anterior)Desfazer permite que, ao voltar de uma tentativa, o algoritmo experimente a próxima escolha a partir do mesmo estado de antes, e não de um estado alterado pela tentativa anterior. É a mesma ida e volta de qualquer função recursiva; a novidade é que, na volta, além de (talvez) devolver um valor, desfazemos uma mudança feita na ida.
Em geraSubconjuntos, o desfazer está implícito: a soma é um
parâmetro, então a chamada de cima continua com a sua própria soma
intacta, e pertence[i] é sobrescrito pela próxima escolha antes de ser
lido de novo.
Cada chamada de backtrack é um nó de uma árvore, como nas figuras
anteriores. A diferença é que alguns ramos terminam antes de chegar ao fundo:
ou porque a poda os cortou, ou porque uma solução foi encontrada.
Na figura, o ramo da esquerda para no segundo nível e os outros vão até o terceiro. O que isso significa no problema?
Que a escolha feita no ramo da esquerda já se mostrou inviável depois de poucos passos (por exemplo, a soma já passou do limite, ou o caminho bateu em uma parede). A poda o cortou ali, sem gastar chamadas com o que viria abaixo. Os outros ramos só se revelaram sem saída (ou com solução) mais fundo. Quanto mais perto da raiz acontece a poda, mais chamadas ela evita.
Estudo de caso: o labirinto
Agora o espaço de busca não é mais um vetor de escolhas binárias, e sim um mapa.
Dado um mapa retangular de caracteres, onde # é parede, S marca a
posição inicial, E marca a saída e . é caminho livre, descobrir se
existe um caminho de S até E, movendo-se só para cima, baixo, esquerda
ou direita, sem passar duas vezes pela mesma célula.
Um exemplo de entrada (8 linhas, 10 colunas):
8 10
..........
.#####.##.
.S...#..E.
.###.#.##.
.#...#....
.#.#######
.#........
..........Repare que a borda é livre: o mapa não é cercado por paredes, e um caminho pode passar por ela. Isso tem uma consequência importante no código, como veremos no passo 2.
No vocabulário do template:
| Template | No labirinto |
|---|---|
| Estado | posição atual |
| Escolhas | as 4 direções |
| Poda | posição fora do mapa, parede ou já visitada |
| Aplicar | marcar a posição como visitada (v) |
| Desfazer | desmarcar a posição |
Construindo caminha() passo a passo
O mapa fica em uma matriz alocada linha por linha, como visto em
Alocação de memória. A função
caminha(l, c) tenta continuar o caminho a partir da posição
- 1
Poda: já achou a saída?#
Se outro ramo já encontrou a saída, não há o que fazer aqui. Não é um caso base no sentido usual: é uma poda que corta tudo o que ainda estava na pilha.
if (achouSaida) return; - 2
Poda: a posição é válida?#
Fora do mapa, parede ou já visitada: retorna. Como a borda pode ser livre, o algoritmo vai tentar andar para fora do mapa, e a checagem de limites é obrigatória. Ela também precisa vir antes do acesso a
mapa[l][c].if (l < 0 || c < 0 || l >= nl || c >= nc) return; if (mapa[l][c] != '.' && mapa[l][c] != 'S' && mapa[l][c] != 'E') return; - 3
Aplica a escolha. É a saída?#
Marca a posição como visitada. Se ela era o
E, achou. O teste é feito antes de marcar, porque a marca apaga oE.bool eSaida = (mapa[l][c] == 'E'); mapa[l][c] = 'v'; if (eSaida) { achouSaida = true; return; } - 4
Tenta as 4 direções e desfaz#
Tenta continuar por cada vizinha. Se nenhuma levou à saída, desmarca a posição: ela não faz parte do caminho.
caminha(l, c - 1); /* esquerda */ caminha(l, c + 1); /* direita */ caminha(l - 1, c); /* cima */ caminha(l + 1, c); /* baixo */ if (!achouSaida) mapa[l][c] = '.';
A main lê o mapa, procura o S e chama caminha a partir dele. No final,
as células marcadas com v formam o caminho encontrado.
O que aconteceria se a checagem de limites viesse depois de mapa[l][c] != '.'?
Ao tentar andar para fora do mapa, mapa[l][c] seria lido com l ou c
fora da matriz (por exemplo, mapa[2][-1]): acesso inválido de memória. Com
este mapa isso acontece logo no primeiro passo, ao tentar a esquerda a partir
da coluna 0. O AddressSanitizer acusa o erro; sem ele, o programa poderia
"funcionar" por acaso, ler lixo ou travar. Veja o Programa 3
dos exercícios.
Por que a marca só é desfeita se achouSaida continuar falso? E se ela fosse sempre desfeita?
Quando a saída é encontrada, as células marcadas com v são exatamente o
caminho de S até E, e queremos mantê-las para imprimir. Se a marca fosse
sempre desfeita, cada chamada apagaria a sua célula na volta e, no final,
sobraria só o E marcado (ele é marcado e a chamada retorna antes do
desfazer). O programa diria que achou a saída, mas o mapa não mostraria
caminho nenhum.
Sem mapa[l][c] = 'v', a função pode ir e voltar entre duas células
vizinhas para sempre, e a pilha de chamadas cresce até estourar (o stack
overflow da aula de memória). A marca de visitada não serve só para
evitar trabalho repetido: é ela que garante que a recursão termina.
Veja o Programa 4.
Vendo o backtracking acontecer
A versão abaixo imprime cada passo, com um recuo por nível de recursão:
visita quando uma célula é marcada e recua quando a marca é desfeita. O
mapa é menor, com um beco sem saída logo à direita do S.
O algoritmo tenta a esquerda (fora do mapa), depois a direita, entra no
beco, chega ao fim dele e recua célula por célula até o S. Só então
tenta subir, que é a direção que leva à saída.
Complexidade
Como as marcas são desfeitas na volta, uma mesma célula pode ser visitada
várias vezes, uma para cada caminho diferente que chega até ela. No pior caso
(por exemplo, quando E não é alcançável), caminha percorre todos os
caminhos sem repetição de células que partem de S.
Cada célula tem até 4 vizinhas, o que sugere um limite de
O que o algoritmo enumera, no pior caso, são os caminhos que não repetem
células, conhecidos em combinatória como passeios autoevitantes
(self-avoiding walks). Em uma grade infinita, a quantidade deles com
E se, em vez de uma ordem fixa, cada chamada tentasse primeiro a direção que aproxima de E?
Uma heurística assim pode ajudar muito em mapas onde a direção "certa"
realmente leva à saída: como a busca para no primeiro caminho, apontar na
direção certa reduz as chamadas. Mas ela tem custo (avaliar e ordenar as
direções a cada chamada) e não muda o pior caso: para qualquer regra
fixa existe um mapa que a engana, com uma região grande e sem saída
"apontando" para o E. A busca entra nela, testa e desfaz tudo lá dentro, e
só então tenta a direção que parecia pior.
Rodando no seu computador
Para testar no terminal, não é preciso digitar o mapa toda vez: salve-o em
um arquivo e use redirecionamento. Todo programa nasce ligado a três
canais: a entrada padrão (stdin, de onde scanf lê, normalmente o
teclado), a saída padrão (stdout, para onde printf escreve,
normalmente a tela) e a saída de erro (stderr, também na tela). O
terminal permite trocar cada um deles por um arquivo, sem mudar o programa:
| Operador | Efeito | Exemplo |
|---|---|---|
< | lê a entrada padrão de um arquivo | ./labirinto < mapa.txt |
> | grava a saída padrão em um arquivo (sobrescreve) | ./labirinto < mapa.txt > saida.txt |
>> | acrescenta a saída padrão ao fim do arquivo | ./labirinto < mapa.txt >> log.txt |
2> | grava só a saída de erro em um arquivo | ./labirinto < mapa.txt 2> erros.txt |
O redirecionamento é feito pelo terminal: o programa não sabe (nem
precisa saber) se está lendo do teclado ou de um arquivo. Cada scanf
simplesmente consome o próximo trecho do arquivo, na ordem, como se ele
estivesse sendo digitado.
Com o redirecionamento, dá para ver uma animação do backtracking. A versão
abaixo redesenha o mapa a cada célula marcada ou desmarcada, com uma pausa
entre um desenho e outro. Ela usa system("clear") e usleep, que não
funcionam aqui na página: copie-a para o seu computador, junto com as funções
de labirinto.c, e execute com ./labirinto < mapa.txt.
#include <unistd.h> /* usleep */
void desenhaEPausa(const char *mensagem, int l, int c) {
system("clear"); /* no Windows sem WSL: system("cls") */
printf("%s (%d, %d)\n\n", mensagem, l, c);
imprime(mapa, nl, nc);
usleep(150000); /* pausa de 150 ms */
}
void caminha(int l, int c) {
if (achouSaida)
return;
if (l < 0 || c < 0 || l >= nl || c >= nc)
return;
if (mapa[l][c] != '.' && mapa[l][c] != 'S' && mapa[l][c] != 'E')
return;
bool eSaida = (mapa[l][c] == 'E');
mapa[l][c] = 'v';
desenhaEPausa("Visitando", l, c);
if (eSaida) {
achouSaida = true;
return;
}
caminha(l, c - 1);
caminha(l, c + 1);
caminha(l - 1, c);
caminha(l + 1, c);
if (!achouSaida) {
mapa[l][c] = '.';
desenhaEPausa("Recuando de", l, c);
}
}Por que redesenhar só quando uma célula é marcada ou desmarcada, e não a cada chamada de caminha?
A maioria das chamadas retorna logo nas primeiras verificações (fora do mapa, parede, já visitada) sem mudar nada. Redesenhar nesses casos mostraria o mesmo mapa várias vezes, deixando a animação lenta e confusa, sem mostrar nada novo.
Exercícios
Encontre e corrija o erro
Cada programa tem um erro. Execute, leia a saída e as mensagens, corrija e execute de novo.
Programa 1
Deveria imprimir as 8 sequências de 3 bits.
Resposta
A segunda escolha é aplicada (vet[i] = 1), mas falta a chamada
geraSequencias(i + 1) depois dela. Cada nível só explora o ramo do 0, e a
árvore vira um único caminho até a folha 000. Para cada escolha possível, é
preciso aplicá-la e continuar a busca a partir dela.
Programa 2
Deveria listar os subconjuntos com soma no máximo 10.
Resposta
Ao incluir o elemento, a chamada passa soma em vez de
soma + valores[i]. A soma nunca cresce, a poda nunca acontece e todos os 16
subconjuntos são impressos, inclusive { 5 3 8 2 }, que soma 18. Quando o
estado viaja como parâmetro, aplicar a escolha é justamente atualizar o
parâmetro na chamada.
Programa 3
Resposta
A checagem de limites vem depois do acesso mapa[l][c]. Logo no primeiro
passo, a tentativa de ir para a esquerda a partir da coluna 0 lê
mapa[2][-1], fora da linha alocada: o AddressSanitizer acusa
heap-buffer-overflow. As duas verificações precisam ser trocadas de lugar.
Com um mapa cercado de paredes, o erro ficaria escondido, porque o algoritmo
nunca tentaria sair da matriz.
Programa 4
Resposta
Falta marcar a célula como visitada (mapa[l][c] = 'v';) antes de tentar as
4 direções. Sem a marca, caminha(2, 1) chama caminha(2, 0), que chama
caminha(2, 1) de volta, e assim por diante: recursão infinita, até o
stack-overflow.
Programa 5
Resposta
O programa diz que não existe solução, mas existe. A célula é marcada com v
antes do teste mapa[l][c] == 'E'; quando a busca chega ao E, ele já
foi apagado pela marca, e o teste nunca é verdadeiro. É preciso guardar se a
célula é a saída antes de marcá-la, como faz a variável eSaida.
Implemente
Subconjuntos com soma exata. Conte quantos subconjuntos de um vetor de
inteiros positivos têm soma exatamente igual a alvo. Por exemplo, para
os valores 5 3 8 2 e alvo 10, a resposta é 2: {5, 3, 2} e {8, 2}. Os
testes maiores têm até 50 elementos: a força bruta, com
Quantos caminhos? Conte quantos caminhos diferentes, sem repetir
células, levam de S até E. Agora a busca não para no primeiro
caminho: ao chegar em E, conte e volte para tentar os outros. Por isso o
desfazer é obrigatório, e o mapa precisa terminar exatamente como começou
(inclusive o S). Em um mapa S e E em
cantos opostos, há 12 caminhos.
Referências
- P. Feofiloff. Algoritmos de enumeração. IME-USP.
- Backtracking. Wikipedia.
- Self-avoiding walk. Wikipedia.
Recursão
Funções que chamam a si mesmas. Caso base, passo recursivo, o caminho de ida e de volta na pilha de chamadas e exemplos com números e vetores.
Busca sequencial e binária
Os dois algoritmos básicos de busca em vetores, com sentinela, versões recursivas, primeira e última ocorrência, busca binária na resposta e os erros mais comuns.