Algoritmos Gulosos

27/03/2021

Um algoritmo guloso pode ser utilizado para resolver problemas que não possuem um algoritmo que resolveria de forma rápida.

Esse algoritmo, em cada iteração, irá fazer a escolha que parece ser a melhor no momento para chegar na melhor solução final que ele consegue.

Implementação

Um exemplo básico, você possui um tempo limite para completar uma série de tarefas e cada tarefa possui um tempo para ser completada.

Diagram das tarefas

Esse é um problema que um algoritmo guloso pode resolver. Você precisa escolher o máximo de tarefas que consegue completar dentro desse limite.

tasks = [1, 3, 6, 7, 10]
max_time = 10
current_time = 0
current_tasks = 0

for task_time in tasks:
    current_time += task_time
    
    if current_time > max_time:
        break
    
    current_tasks += 1

Veja que a lista de tarefas está ordenada e a cada iteração o algoritmo irá adicionar o tempo da tarefa task_time ao tempo atual current_time e fazer uma verificação, caso o tempo não tenha atingido o limite, o algoritmo irá incrementar o número de tarefas para fazer current_tasks, caso contrário o loop é encerrado.

Esse algoritmo em nenhum momento volta atrás de uma decisão, ele apenas escolhe a tarefa pensando que ela é a ideal para o momento.

E a resposta final desse algoritmo é 3 tarefas.

Algoritmo de Dijkstra

O algoritmo de Dijkstra também é um ótimo exemplo de um algoritmo guloso, ele é usado para calcular o caminho mínimo entre um vértice inicial e todos os outros vértices em um grafo com pesos não negativos.

Diagrama de um grafo ponderado

Esse algoritmo é considerado guloso pois a cada iteração ele irá tentar fazer o movimento que tem o menor peso de acordo com as possibilidades atuais e o algoritmo nunca voltará atrás de uma decisão que ele tenha feito.

Implementação

Vou utilizar apenas algumas partes do código para explicar, mas o código completo você pode ver aqui: dijkstra.py

Para inicializar, precisamos definir os vértices e os pesos das arestas do nosso grafo.

Essa é a representação do diagrama mostrado acima:

graph = Graph()

# Vértices
graph.add_node('a')
graph.add_node('b')
graph.add_node('c')
graph.add_node('d')
graph.add_node('e')

# Arestas com os pesos
graph.add_edge('a', 'b', 2)
graph.add_edge('a', 'c', 6)

graph.add_edge('b', 'd', 1)
graph.add_edge('b', 'e', 10)

graph.add_edge('c', 'e', 1)

graph.add_edge('d', 'e', 2)

O primeiro passo do algoritmo é definir o ponto inicial e nesse caso vai ser o A e como este ponto é o início, ele vai ter custo 0 pois para chegar até ele não existe nenhum outro passo. Já os outros vértices receberão o custo como infinito pois como não sabemos ainda como chegar neles precisamos definir o maior custo possível.

O algoritmo então irá procurar o vértice que tenha o menor custo e que ainda não foi visitado. Todos os outros vértices possuem o custo definido como infinito, então o que terá o menor custo será o vértice A e esse é o ponto inicial do algoritmo.

Diagrama do passo inicial do algoritmo de Dijkstra

Para cada movimento a partir de A, o algoritmo irá atualizar os custos para se chegar até eles, somando o custo de A que é 0 com o peso da distância, se essa soma for menor que o custo atual do vértice, então esse valor será atualizado. Lembrando que definimos todos os vértices com o custo infinito.

# Busca o menor vértice ainda não visitado
node = graph.find_lowest_cost_node()

while node:
    # Recupera o custo desse vértice
    cost = graph.costs.get(node)
    
    # Recupera todas as arestas do vértice
    edges = graph.edges.get(node) if graph.edges.get(node) else []
    
    for edge in edges:
        distance = graph.distances.get((node, edge))
        new_cost = cost + distance
        
        current_edge_cost = graph.costs.get(edge)

        # Para cada aresta, realiza a soma do custo com a distância
        # E caso essa soma for menor que o custo atual, atualize o custo
        if new_cost < current_edge_cost:
            graph.update_node_cost(edge, new_cost)
            graph.update_node_parent(edge, node)

    # Marca o vértice como visitado
    graph.visit_node(node)

    # Busca o menor vértice ainda não visitado e continua o loop
    node = graph.find_lowest_cost_node()

O algoritmo marca o A como visitado e começa a iteração novamente, procurando o próximo vértice não visitado com o menor peso.

Nesse caso será o B e ele irá fazer os mesmos cálculos mostrados acima.

Diagrama do segundo passo do algoritmo de Dijkstra


Agora para D:

Diagrama do terceiro passo do algoritmo de Dijkstra


O vértice E é o próximo, mas não possui nenhuma aresta, então o algoritmo irá apenas marcar como visitado.


Por último o algoritmo passa por C.

Note que C tem caminho para ir até E, porém ele não atualiza o custo pois é maior do que o custo atual em E:

Diagrama do quarto passo do algoritmo de Dijkstra


O resultado final é:

a -> b -> d -> e

E esse é o melhor caminho para E iniciando em A. Com o custo total de 5.

O código completo você pode ver aqui: dijkstra.py

Algoritmo de Bellman-Ford

Para grafos ponderados e com pesos negativos o algoritmo de Dijkstra não irá funcionar, porém existe o Algoritmo de Bellman-Ford que pode ser utilizado para isso.

Links Úteis