Você está na página 1de 6

PROJETO E ANÁLISE DE ALGORITMOS

Trabalho Final - Projeto de Algoritmos


Alice Cabral1 e Juliana Silvestre1
1
Instituto de Ciências Exatas e Informática –
Pontifı́cia Universidade Católica de Minas Gerais (PUCMG)
Belo Horizonte – MG – Brasil

alicecamarques@gmail.com

juliana.silvestresilva@hotmail.com

O presente trabalho apresenta a resolução de duas questões relacionadas à dis-


ciplina de Projeto e Análise de Algoritmos. A primeira questão se trata do problema
de enumerar todos os ciclos em um grafo, e sua resolução é apresentada por meio de
duas abordagens: uma baseada na permutação dos vértices do grafo e outra baseada em
caminhamento no grafo. Além da implementação, é realizada uma análise comparativa
entre essas duas abordagens. A segunda questão aborda as estratégias branch-and-bound
e backtracking no desenvolvimento de algoritmos, incluindo os respectivos conceitos,
quando devem ser utilizadas, exemplos e comparações entre essas estratégias e outras
vistas na disciplina. Para a realização do trabalho, a aluna Juliana ficou responsável pela
primeira questão e a aluna Alice ficou responsável pela segunda questão.

1. Questão 1
Considerando um grafo não direcionado simples, podemos afirmar que um ciclo consiste
em um caminho fechado sem vértices repetidos. Dessa maneira, a primeira questão deste
trabalho visa resolver o problema de se enumerar todos os ciclos existentes em um grafo.
Para isso, foram utilizadas duas abordagens, sendo uma delas baseada na permutação os
vértices do grafo e a outra baseada em caminhamento.
Ambas as resoluções foram implementadas na linguagem de programação Java.
Foi utilizada como base a classe Grafo, a qual armazena informações como a matriz de
adjacência e a lista de adjacência do grafo em questão, bem como a quantidade de vértices
e um vetor com todos os vértices do grafo.

1.1. Resolução Baseada na Permutação dos Vértices do Grafo


Para encontrar todos os ciclos do grafo por meio da permutação dos vértices foi imple-
mentada a classe Permutacao, a qual busca todas as todas as combinações possı́veis, com
comprimento variando de 3 (uma vez que um ciclo deve ter no mı́nimo 3 vértices) ao
número de vértices naquele grafo.
No código em questão, a função permutaKVertices encontra todas as possı́veis
permutações de tamanho k que podem ser obtidas com a partir do array que armazena
todos os vértices do grafo. Assim, utiliza-se uma variável chamada arrayPermt para gerar
recursivamente, todos os possı́veis ciclos do grafo. A fim de testar se aquela permutação
corresponde de fato a um ciclo foi implementada a função VerificaCiclo, a qual utiliza
a lista de adjacência do grafo para verificar a existência de arestas entre os vértices per-
mutados. Finalmente, são escritas todas as permutações que geram um ciclo, ou seja,
tal implementação apresenta o mesmo ciclo mais uma vez visto que são obtidas todas as
permutações possı́veis.

1.2. Resolução Baseada em Caminhamento no Grafo


Para encontrar os ciclos usando caminhamento no grafo utilizou-se uma abordagem
baseada na busca em profundidade. O algoritmo implementado visa portanto encontrar
todos os caminhos possı́veis com comprimento variando de 3 (uma vez que um ciclo deve
ter no mı́nimo 3 vértices) ao número de vértices naquele grafo.
No código implementado a variável vertVisitados armazena todos os vértices do
grafo e indicando se cada um deles foi ou não visitado. Já a função buscaProfundidade
permite encontrar recursivamente todos os caminhos possı́veis de comprimento n-1, em
que n representa o tamanho do ciclo. Para armazenar o caminho que está sendo percorrido
pelo algoritmo, foi criada a variável caminho e a fim de verificar a existência de um ciclo
é verificado se o último vértice é igual ao primeiro, caso o ciclo exista o conteúdo da
variável caminho é escrito.

1.3. Análise Comparativa entre as Resoluções


A seguir é apresentada uma análise comparativa entre as duas resoluções anteriores,
mostrando o custo computacional teórico (em termos de notação O) e prático (em termos
de segundos), visando determinar diferenças no desempenho das mesmas para resolução
do problema.

1.3.1. Custo Computacional Teórico

O algoritmo baseado na permutação dos vértices do grafo possui custo computacional


igual a
O(nn )
em que n representa a quantidade de vértices presentes no grafo. Já o algoritmo baseado
em caminhamento no grafo possui custo computacional igual a

O(n + m)

sendo m o número de arcos do grafo e n a quantidade de vértices presente no grafo. Como


n+m é o tamanho do grafo, o algoritmo é linear. Devido ao fato de o primeiro algoritmo
ter custo exponencial e o segundo algoritmo ter custo linear, é possı́vel concluir que o
algoritmo baseado em caminhamento no grafo mostrou-se mais eficiente.

1.3.2. Custo Computacional Prático

As tabelas abaixo mostram o custo computacional prático, em segundos, das duas


resoluções implementadas. Percebe-se que o tempo de execução da segunda resolução
permanece menor do que o tempo da primeira, mesmo na medida em que o grafo au-
menta.
Table 1. Custo da resolução baseada na permutação dos vértices do grafo
Quantidade de vértices Quantidade de vértices (s)
4 0,014
5 0,309
6 0,4

Table 2. Custo da resolução baseada em caminhamento no grafo


Quantidade de vértices Quantidade de vértices (s)
4 0,003
5 0,057
6 0,067

2. Questão 2
Em discussões sobre desenvolvimento de algoritmos, a escolha da estratégia para re-
solver um problema é fundamental, visto que existem diversos métodos com difer-
entes aplicações e complexidadades. As estratégias abordadas nessa questão, branch-
and-bound e backtracking são relativamente parecidas em conceito, porém possuem
implementações distintas, o que resulta na preferência de uma à outra em determinadas
circunstâncias.

2.1. Branch-and-Bound
Branch-and-bound é um método enumerativo para algoritmos, no qual a solução é obtida
por meio do particionamento do espaço de soluções, ou seja, o problema original é repeti-
damente decomposto em subproblemas menores até que uma solução seja obtida. Isso
permite identificar tal solução como a melhor possı́vel ou ótima dentre todas as soluções
viáveis para o problema. Portanto, o algoritmo baseia-se na idéia de desenvolver uma
enumeração inteligente das soluções candidatas à solução ótima inteira de um problema.
Esse método geralmente é aplicado em problemas de otimização discreta e de otimização
combinatória, como por exemplo problemas de programação inteira, de fluxo de rede, e
de associação de tarefas.
No problema de associação de tarefas, há N tarefas e N trabalhadores. Qualquer
uma das tarefas disponı́veis pode ser atribuı́do a qualquer trabalhador com a condição de
que, se uma tarefa for atribuı́da a um trabalhador, os outros trabalhadores não poderão
aceitar essa tarefa especı́fica. Além disso, cada tarefa tem algum custo associado a ela, o
qual difere de um trabalhador para outro. O objetivo principal é completar todas as tarefas
atribuindo uma tarefa a cada trabalhador, de tal forma que a soma do custo de todas as
tarefas seja minimizada.
Para a resolução deste problema, foi implementado um código na linguagem de
programação C++. A lógica utilizada foi que, para cada trabalhador, a tarefa com custo
mı́nimo da lista de tarefas não atribuı́das é escolhida. Como a primeira parte da técnica
branch-and-bound pede que o algoritmo trate as possı́veis soluções como se estivessem
em uma estrutura de árvore, onde os nós da árvore são utilizados como parte de possı́veis
soluções, foi implementada uma estrutura de Nó. Essa estrutura contém o pai do nó atual,
o custo para nós ancestrais, o custo menos promissor, a identificação do trabalhador e da
tarefa e um array com as informações sobre as tarefas disponı́veis. Também foi criada a
função novoNo(), que aloca um novo nó da árvore de busca. A função calcularCusto()
calcula o custo menos promissor do nó após o trabalhador X ser atribuı́do à tarefa Y. Para
isso, é utilizado um array que armazena o custo das tarefas, passado por parâmetro, e é
criado um outro array para armazenar as tarefas indisponı́veis, o qual vai sendo preenchido
à medida que é feita uma verificação da associação entre os trabalhadores, as tarefas e os
respectivos custos. A função imprimirAssociacao() imprime na tela as associações entre
os trabalhadores e as tarefas e, por fim, há a função acharCustoMinimo(), que encontra
o custo mı́nimo usando a estratégia de branch-and-bound. Para isso, encontra-se um nó
ativo com menor custo, adiciona-se os filhos à fila de prioridade de nós ativos criada nessa
mesma função e, finalmente, ele é excluido da fila. Na função principal, cria-se a matriz
de custo com os trabalhadores e as tarefas, e a função acharCustoMı́nimo() é chamada.
Para testar a implementação, foram feitos testes com quatro matrizes de custo diferentes,
os quais obtiveram sucesso.

2.2. Backtracking
Backtracking é uma estratégia refinada do algoritmo de busca por força bruta que resolve
problemas incremental e recursivamente (tentativa e erro), removendo as soluções parciais
que falham em ajudar na solução para o problema. Ou seja, boa parte das soluções podem
ser eliminadas sem serem explicitamente examinadas.
Esse algoritmo pode ser utilizado em problemas cuja solução pode ser definida
a partir de uma seqüência de decisões e em problemas que podem ser modelados por
uma árvore que representa todas as possı́veis seqüências de decisão. Dessa forma, ele
é aplicável na solução de vários problemas conhecidos, como o problema do caixeiro
viajante, das N rainhas, da geração de permutações e problemas de labirinto.
O problema das N rainhas consiste em dispor N rainhas sobre um tabuleiro de
xadrez de tal modo que elas não se ataquem. No jogo de xadrez, as rainhas podem se
atacar ou se movimentar na horizontal, na vertical e também ao longo das diagonais.
Sendo assim, o objetivo é dispor as N rainhas de modo que elas não compartilhem linhas,
colunas e nem diagonais.
Para a resolução deste problema, foi implementado um código na linguagem de
programação C++. Foram criados três funções: mostrarTabuleiro(), seguro() e executar().
A função mostrarTabuleiro() imprime o tabuleiro na tela, representado por uma matriz,
com todas as rainhas posicionadas. A função seguro() verifica se é seguro colocar a rainha
em uma determinada coluna. Para isso, ela verifica se ocorre ataque na linha, na coluna, na
diagonal principal e na diagonal secundária. E a função executar() é uma função recursivo
que utiliza o backtracking para gerar as soluções. Ela começa pela primeira coluna e
considera as N posições disponı́veis. Para cada uma das posições da primeira coluna, ela
considera as posições da segunda coluna que são válidas. Repete-se esse processo até
preencher as N colunas do tabuleiro. Se chegar à ultima coluna e for possı́vel colocar
as N rainhas, então há uma solução para o problema. Se chegar a alguma coluna onde
não haja nenhuma posição válida para colocar uma rainha, retorna-se à coluna anterior,
procurando pela próxima posição válida. Dessa forma, aplicando esta ideia repetidamente
às colunas restantes, no final, obtém-se o conjunto de todas as soluções possı́veis. Para
testar a implementação, foram feitos testes com 4 e 8 rainhas, os quais obtiveram sucesso.
2.3. Comparação entre Estratégias de Desenvolvimento de Algoritmos

A seguir é apresentada uma comparação entre algumas estratégias de desenvolvimento de


algoritmos, apresentado as principais semelhanças e diferenças.

2.3.1. Backtracking e Branch-and-Bound

• Ambos os algoritmos constroem uma árvore de estados, na qual os nós refletem


uma escolha feita em direção da solução.
• O backtracking é eficiente para problemas de decisão, encontrando todas as
soluções possı́veis disponı́veis para um problema. O branch-and-bound é usado
para resolver problemas de otimização.
• O backtracking identifica uma solução sem preocupar com custo, enquanto o
branch-and-bound identifica estados inválidos e há preocupação com o custo.
• A pesquisa feita na árvore de estados do backtracking utiliza um DFS (Depth
First Search), enquanto o branch-and-bound pode utilizar tanto DFS quanto BFS
(Breadth First Search).
• No backtracking, a árvore de estados é pesquisada até que a solução seja obtida,
sendo mais eficiente. No branch-and-bound, a árvore precisa ser pesquisada com-
pletamente, já que a solução ideal pode estar presente em qualquer lugar, sendo
menos eficiente.

2.3.2. Backtracking, Branch-and-Bound e Abordagem Gulosa

• Assim como o backtracking, a abordagem gulosa também pode não alcançar ime-
diatamente o resultado global ótimo, ao contrário do branch-and-bound.
• Os algoritmos de backtracking e de branch-and-bound costumam ter
implementações recursivas, enquanto os algoritmos gulosos tentam buscar a mel-
hor escolha de forma iterativa.
• Algoritmos gulosos costumam ter resultados mais rápidos do que técnicas de
branch-and-bound para problemas com menos entradas, mas, à medida que a en-
trada cresce, o mesmo pode ajhcabar sendo custoso como o branch-and-bound.

2.3.3. Backtracking, Branch-and-Bound e Programação Dinâmica

• Assim como o branch-and-bound, a programação dinâmica também busca a


solução ótima para o problema, ao contrário do backtracking, que é eficiente para
problemas de decisão.
• Ambos os algoritmos de força bruta possuem conceitos que tem natureza recur-
siva. Entretanto, a programação dinâmica ainda pode ser implementada de forma
iterativa.
• A programação dinâmica tenta minimizar os passos para resolver o problema, evi-
tando subproblemas já resolvidos. Já o branch-and-bound tenta sistematicamente
enumerar todos os candidatos possı́veis na árvore de estados.
2.3.4. Backtracking, Branch-and-Bound e Divisão e Conquista

• Os três algoritmos em questão possuem a natureza de serem recursivos e partem


do mesmo princı́pios de dividir o problema em partes menores para solucionar o
todo.
• Na estratégia de divisão e conquista, a entrada do usuário é dividida, cada sub-
problema é resolvido, para então resolver a combinação desses subproblemas. No
branch-and-bound, o espaço da solução é dividido para o resolver o problema.

Você também pode gostar