Colorindo grafos — ou, como escolher cores para um mapa automaticamente

Imagine que você tenha um mapa com algumas áreas delimitadas (países, estados, etc), e deseja colorir cada área com uma cor diferente das áreas vizinhas.

Para um mapa pequeno (digamos, com no máximo 7 áreas), você pode simplesmente atribuir uma cor para cada área e se dar por satisfeito. Mas para um mapa com muitas áreas, você provavelmente quer usar um número mínimo de cores: muitas cores diferentes vão deixar o mapa confuso.

Esse é um típico problema a ser resolvido com coloração de grafos, uma área da ciência da computação explorada ativamente ainda hoje. Existe uma gama de problemas que podem ser resolvidos com técnicas desse tipo — outro exemplo popular é o quebra-cabeça Sudoku.

Hoje vamos mostrar um exemplo resolvendo esse problema usando um algoritmo simples para achar a configuração de cores mínima.

Veja a representação visual do grafo para colorir um mapa dos países da América do Sul:

Grafo colorido dos países num mapa da América do Sul

Colorização do grafo dos países da América do Sul usando 4 cores

Ao fim desse post, você terá aprendido a gerar colorizações e imagens para grafos como esse. 🙂

Escolhendo uma representação para o grafo

Existem várias maneiras de representar grafos com diferentes relações custo-benefício por operação, você escolhe a mais apropriada dependendo do tipo de grafo e do problema que você vai resolver. As duas representações mais comuns são matriz de adjacências e lista de adjacências — as demais são geralmente variações dessas.

Para o nosso problema, vamos simplesmente usar um dicionário Python mapeando os nós adjacentes:

grafo = {
    'A': ['B', 'C'],
    'B': ['A'],
    'C': ['A'],
}

Essa representação contém um pouco de duplicação, mas é interessante porque deixa trivial obter os nós do grafo fazendo grafo.keys() e consultar os nós adjacentes de um nó com grafo[nó].

Implementando o algoritmo

Vamos usar um algoritmo de colorização de grafos simples: começamos testando uma configuração com N cores, e caso detectamos que não seja possível, tentamos com N+1. Repetimos isso até encontrarmos uma solução ou atingirmos o limite de tentativas válidas.

Veja o código:

def try_coloring(graph, num_colors):
    assert num_colors >= 0, "Invalid number of colors: %s" % num_colors
    colors = {}

    def neighbors_have_different_colors(nodes, color):
        return all(color != colors.get(n) for n in nodes)

    for node, adjacents in graph.items():

        found_color = False

        for color in range(num_colors):
            if neighbors_have_different_colors(adjacents, color):
                found_color = True
                colors[node] = color
                break

        if not found_color:
            return None

    return colors


def find_graph_colors(graph):
    for num_colors in range(1, len(graph)):
        colors = try_coloring(graph, num_colors)
        if colors:
            return colors

Temos duas funções:

try_coloring() recebe um grafo e um número de cores para tentar. Ela tenta construir uma configuração de cores para o grafo, atualizando um dicionário que mapeia as cores para cada nó. Caso encontre uma configuração de cor válida a função devolve o dicionário; caso contrário, devolve None.

find_graph_colors() recebe um grafo e simplesmente aciona a função try_coloring() com diferentes valores para o número de cores, até que encontre uma configuração válida (ou esgote as tentativas). Também devolve a configuração encontrada ou None caso não for possível.

Coloque o código acima em um arquivo graph_coloring.py, e experimente chamar a função try_coloring() usando o shell para o nosso grafo exemplo:

>>> from graph_coloring import *
>>> grafo = {
...     'A': ['B', 'C'],
...     'B': ['A'],
...     'C': ['A'],
... }
>>> try_coloring(grafo, 1)
>>> try_coloring(grafo, 2)
{'A': 0, 'C': 1, 'B': 1}

Repare como a tentativa de colorir com apenas uma cor não funciona, mas a segunda já fornece uma configuração de cores válida para o nosso pequeno grafo. A propósito, a sessão acima está suplicando para ser usada como doctest para a função try_coloring(). 😉

Bem, esse algoritmo é um pouco ingênuo e definitivamente não-otimizado, pois envolve um certo retrabalho a cada vez que tenta uma configuração de cores nova. Isso não é um problema para os grafos que vamos usar, então se preocupar com performance agora é desnecessário, mas é legal perceber onde podemos mexer caso seja necessário melhorá-lo.

Para o caso específico de mapas, existe um teorema afirmando que é sempre possível resolver esse problema usando 4 cores. Isso funciona porque os grafos que representam mapas são sempre grafos planares, isto é, podem ser representados num plano sem nenhuma aresta se cruzando — o que reduz as possibilidades de conexões entre os vértices.

Gerando uma representação visual

Uma maneira interessante de validar o nosso trabalho acima (e mais divertida do que usando testes de unidade) é gerar uma representação visual do grafo com as respectivas cores.

Para isso, vamos usar a suite open source de software para visualização de grafos Graphviz (instale no Debian/Ubuntu/Mint com sudo apt-get install graphviz; há pacotes também para Windows e Mac).

Iniciação ao uso do GraphViz

O Graphviz usa uma linguagem própria para descrever grafos chamada DOT, que você pode explorar usando a aplicação GraphViz Workspace.

Você também pode criar um arquivo.dot manualmente usando seu editor de texto favorito, e testar a saída com o comando dot. Crie um arquivo com o seguinte conteúdo que descreve nosso grafo de exemplo:

graph {
    A -- B;
    A -- C;
}

Compile uma imagem PNG com o comando dot:

dot -Tpng -o resultado.png arquivo.dot

Se você tem o ImageMagick instalado (no Debian/Ubuntu/Mint: sudo apt-get install imagemagick), pode visualizar a imagem diretamente fazendo pipe do comando dot para o comando display:

dot -Tpng arquivo.dot | display

grafo1

Para gerarmos grafos coloridos, vamos gerar uma representação do grafo que lista as conexões/arestas do grafo e imprime a configuração de cores por último, semelhante ao exemplo seguinte:

graph {
    A -- B;
    A -- C;
    A [style="filled",fillcolor="#ffffb2"];
    B [style="filled",fillcolor="#fd5d3c"];
    C [style="filled",fillcolor="#41b6c4"];
}

grafo2

Para uma documentação mais completa sobre como usar a ferramenta dot para desenhar grafos, veja o documento Drawing graphs with dot.

Gerando a representação DOT

Eis a nossa gloriosa função para gerar a representação do nosso grafo usando a linguagem DOT:

PALETTE = ('#8dd3c7', '#ffffb3', '#bebada', '#fb8072', '#80b1d3', '#fdb462',
           '#b3de69', '#fccde5', '#d9d9d9', '#bc80bd', '#ccebc5', '#ffed6f')


def generate_dot(graph, colors=None, palette=PALETTE):
    assert len(set(colors.values())) <= len(palette), (
        "Too few colors in the palette")

    edges = []
    for node, adjacents in graph.items():
        for n in adjacents:
            if not ((node, n) in edges or (n, node) in edges):
                edges.append((node, n))

    result = 'graph {\n'
    result += ''.join('    "{}" -- "{}";\n'.format(*e) for e in edges)

    if colors:
        style = '    "{}" [style="filled",fillcolor="{}",shape=box];\n'
        result += ''.join(style.format(node, palette[color])
                          for node, color in colors.items())
    result += '}\n'

    return result

A função recebe um grafo na representação de dicionário que combinamos no começo, um dicionário mapeando os números das cores para os nós do grafo (opcional) e uma paleta de cores (usada para obter as cores propriamente ditas, indexadas pelos números do dicionário de cores).

Nota: as cores da paleta fornecida são de uma das paletas disponíveis no site do GraphViz baseadas no fantástico trabalho da Cynthia Brewer.

Exemplo de saída da função generate_dot() para o nosso grafo de exemplo, usando uma paleta própria:

>>> from graph_coloring import *
>>> grafo = {
... 'A': ['B', 'C'],
... 'B': ['A'],
... 'C': ['A'],
... }
>>> colors = try_coloring(grafo, 2)
>>> colors
{'A': 0, 'C': 1, 'B': 1}
>>> print(generate_dot(grafo, colors, palette=['red', 'yellow']))
graph {
 "A" -- "B";
 "A" -- "C";
 "A" [style="filled",fillcolor="red",shape=box];
 "C" [style="filled",fillcolor="yellow",shape=box];
 "B" [style="filled",fillcolor="yellow",shape=box];
}

Gerando a imagem com GraphViz para essa mesma saída, obtemos a imagem:

grafo3

Finalizando

Veja o exemplo completo aqui (baixar graph_coloring.py), contendo o código mostrado nesse post mais a geração do grafo do mapa da América Latina mostrado no começo do post.

Desafio: experimente rodar com Python 2 e Python 3, tem uma sutil diferença no resultado. Consegue sacar o quê e por quê? Poste nos comentários. 😉

Deixe uma resposta

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s