Estrutura de Dados I

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

Busca exaustiva (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 bits. Para são sequências: 000, 001, 010, ..., 111.

Um vetor vet de tamanho é preenchido posição por posição. Em cada posição 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.

sequencias.cC

A árvore abaixo mostra as chamadas para : cada nível decide um bit e cada folha é uma sequência completa. Toda combinação de bits é válida, então nenhum ramo é cortado: a árvore inteira é percorrida.

Árvore binária de três níveis: a raiz [_, _, _] tem filhos [0, _, _] e [1, _, _], e as oito folhas são as sequências de 000 a 111
Árvore de chamadas de geraSequencias para n = 3: cada nível decide uma posição e as 2³ = 8 folhas são as sequências.

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 elementos. Pense em arrumar uma mochila: para cada item candidato, você decide se ele vai ou não.

subconjuntos.cC

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.

Árvore binária de três níveis com raiz vazia; cada nível decide se A, B ou C entra no subconjunto, e as oito folhas são os subconjuntos de {A, B, C}
Árvore de chamadas de geraSubconjuntos para {A, B, C}: mesma forma da árvore das sequências binárias.
Questão

Um conjunto de elementos tem subconjuntos. Se o programa acima recebesse elementos, quantas linhas ele imprimiria?

Poda

Soma de subconjuntos

Considere agora o problema de listar todos os subconjuntos de valores não negativos cuja soma não ultrapassa um limite (a mochila aguenta um peso máximo).

A solução direta seria gerar os subconjuntos e conferir a soma de cada um no final. Mas, se a soma de um subconjunto parcial já passou do limite, não faz sentido continuar incluindo elementos nele: qualquer continuação só aumenta a soma. A resposta para aquele ramo já está decidida bem antes da folha. Conferir essa condição a cada chamada e abandonar o ramo assim que ela falha é o que chamamos de poda.

Poda

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.

soma_subconjuntos.cC

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.

Árvore de decisão para os valores 5, 3, 8 e 2 com limite 10; os ramos {5,3,8}, {5,8} e {3,8} são podados porque a soma passa de 10, e as dez folhas verdes são os subconjuntos válidos
Soma de subconjuntos para os valores 5, 3, 8 e 2, com limite 10. Os três nós vermelhos são podados assim que a soma passa do limite, junto com tudo o que viria abaixo deles.

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.

RecalculandoSoma como parâmetro
Chamadas recursivasmesma árvoremesma árvore
Trabalho por chamada
Trabalho total (pior caso)

As duas continuam exponenciais, mas eliminar o recálculo tira um fator do total. Passar adiante o que a chamada anterior já sabe é um truque que aparece em quase todo backtracking.

Questão

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

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 brutaBacktracking
Verifica a validade antes da folhaNãoSim (poda)
Ramos percorridosSempre todosPode cortar ramos inteiros
Pior casoExponencial (ou pior)O mesmo, mas em geral bem mais rápido na prática
ExemplosSequências binárias, subconjuntosSoma 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)
Por que desfazer?

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.

Árvore de estados com raiz azul e três ramos: o da esquerda termina em um nó vermelho no segundo nível, o do meio em um nó vermelho no terceiro e o da direita em um nó verde, a solução; setas azuis descem (ida) e setas vermelhas tracejadas sobem (volta)
Árvore de estados de um backtracking: setas azuis aplicam uma escolha (ida) e setas vermelhas tracejadas a desfazem (volta).
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.

O problema

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:

TemplateNo labirinto
Estadoposição atual
Escolhasas 4 direções
Podaposição fora do mapa, parede ou já visitada
Aplicarmarcar a posição como visitada (v)
Desfazerdesmarcar 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. 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. 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. 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 o E.

    bool eSaida = (mapa[l][c] == 'E');
    mapa[l][c] = 'v';
    if (eSaida) {
        achouSaida = true;
        return;
    }
  4. 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.

labirinto.cC
Labirinto 8 por 10 com o caminho encontrado: sai de S para a esquerda, sobe até a primeira linha, percorre a borda superior até a última coluna e desce até E
O caminho encontrado por caminha() no mapa de exemplo. Ele passa pela borda, que é livre.
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.

E se não marcássemos como visitada?

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.

rastreio.cC

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 para caminhos de passos. O limite é válido, mas frouxo: o algoritmo nunca volta pela célula de onde veio (ela está marcada), então em quase toda chamada há no máximo 3 direções novas, o que dá .

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 passos cresce como , onde é a chamada constante conectiva da grade quadrada. Continua exponencial.

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:

OperadorEfeitoExemplo
<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.

sequencias.cC
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.

soma_subconjuntos.cC
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

labirinto.cC
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

labirinto.cC
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

labirinto.cC
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 folhas, não termina a tempo. Pense em qual poda usar.

Subconjuntos com soma exataC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

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 sem paredes, com S e E em cantos opostos, há 12 caminhos.

Quantos caminhos?C
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

Referências

Nesta página