Voltar ao início

Estruturas de Dados com Golang - Parte 5

11 min de leitura
Cover Image for Estruturas de Dados com Golang - Parte 5
Lucas LemosLucas Lemos

Introdução

Na parte 1 cobrimos Listas, na parte 2 cobrimos Árvores, na parte 3 cobrimos Hash Tables e na parte 4 cobrimos Heaps e Priority Queues. Agora entramos em uma das estruturas mais versáteis da ciência da computação: Grafos.

Grafos modelam relacionamentos. Redes sociais, mapas de estradas, árvores de dependência, sistemas de recomendação e até a própria internet são grafos. Quando você precisa representar conexões entre coisas, grafos são a escolha natural.

Como mencionado no final da parte 4, grafos se conectam naturalmente com priority queues porque muitos algoritmos de grafo (como o caminho mais curto de Dijkstra) dependem delas.

Como nos artigos anteriores, mantemos a mesma interface base Container:

type Container interface {
  Empty() bool
  Size() int
  Clear()
  Values() []interface{}
  String() string
}

O que é um Grafo?

Um Grafo é uma coleção de vértices (também chamados de nós) conectados por arestas. Diferente de árvores, grafos não têm hierarquia estrita e podem conter ciclos.

graph LR
  A((A)) --- B((B))
  A --- C((C))
  B --- D((D))
  C --- D
  D --- E((E))

Terminologia principal:

  • Vértice (Nó): uma entidade no grafo
  • Aresta: uma conexão entre dois vértices
  • Direcionado vs Não-direcionado: arestas podem ter direção (A → B) ou ser bidirecionais (A — B)
  • Ponderado vs Não-ponderado: arestas podem carregar um valor de custo/distância
  • Adjacente: dois vértices conectados por uma aresta
  • Grau: número de arestas conectadas a um vértice

Tipos de Grafos

Grafo Não-direcionado

Arestas não têm direção. Se A conecta com B, então B conecta com A.

graph LR
  A((A)) --- B((B))
  B --- C((C))
  A --- C

Grafo Direcionado (Dígrafo)

Arestas têm direção. A → B não implica B → A.

graph LR
  A((A)) --> B((B))
  B --> C((C))
  C --> A

Grafo Ponderado

Arestas carregam um custo, distância ou peso.

graph LR
  A((A)) ---|5| B((B))
  B ---|3| C((C))
  A ---|10| C

Representação de Grafos

Existem duas formas comuns de representar um grafo em memória.

Lista de Adjacência

Cada vértice armazena uma lista dos seus vizinhos. Isso é eficiente em memória para grafos esparsos (poucas arestas em relação aos vértices).

flowchart LR
  subgraph AdjList["Lista de Adjacência"]
    A["A: [B, C]"]
    B["B: [A, D]"]
    C["C: [A, D]"]
    D["D: [B, C, E]"]
    E["E: [D]"]
  end
  • Espaço: O(V + E) onde V = vértices, E = arestas
  • Verificar se aresta existe: O(grau) em média
  • Iterar vizinhos: O(grau)

Matriz de Adjacência

Uma matriz 2D onde matrix[i][j] indica uma aresta entre o vértice i e o vértice j. Melhor para grafos densos.

flowchart TD
  subgraph Matrix["Matriz de Adjacência"]
    M["    A B C D E
    A [ 0 1 1 0 0 ]
    B [ 1 0 0 1 0 ]
    C [ 1 0 0 1 0 ]
    D [ 0 1 1 0 1 ]
    E [ 0 0 0 1 0 ]"]
  end
  • Espaço: O(V²)
  • Verificar se aresta existe: O(1)
  • Iterar vizinhos: O(V)

Quando usar qual

Operação Lista de Adjacência Matriz de Adjacência
Espaço O(V + E) O(V²)
Adicionar aresta O(1) O(1)
Remover aresta O(grau) O(1)
Verificar aresta O(grau) O(1)
Iterar vizinhos O(grau) O(V)

Use lista de adjacência para grafos esparsos (a maioria dos grafos do mundo real). Use matriz de adjacência quando precisar de verificação rápida de arestas e o grafo for denso.

Implementação prática em Go (Graph)

Vamos implementar uma representação por lista de adjacência com suporte a arestas ponderadas.

type Edge struct {
  To     string
  Weight int
}

type Graph struct {
  vertices map[string][]Edge
  directed bool
}

func NewGraph(directed bool) *Graph {
  return &Graph{
    vertices: make(map[string][]Edge),
    directed: directed,
  }
}

func (g *Graph) Empty() bool {
  return len(g.vertices) == 0
}

func (g *Graph) Size() int {
  return len(g.vertices)
}

func (g *Graph) Clear() {
  g.vertices = make(map[string][]Edge)
}

func (g *Graph) Values() []interface{} {
  result := make([]interface{}, 0, len(g.vertices))
  for v := range g.vertices {
    result = append(result, v)
  }
  return result
}

func (g *Graph) String() string {
  return fmt.Sprintf("%v", g.vertices)
}

func (g *Graph) AddVertex(v string) {
  if _, exists := g.vertices[v]; !exists {
    g.vertices[v] = []Edge{}
  }
}

func (g *Graph) AddEdge(from, to string, weight int) {
  g.AddVertex(from)
  g.AddVertex(to)

  g.vertices[from] = append(g.vertices[from], Edge{To: to, Weight: weight})

  if !g.directed {
    g.vertices[to] = append(g.vertices[to], Edge{To: from, Weight: weight})
  }
}

func (g *Graph) Neighbors(v string) []Edge {
  return g.vertices[v]
}

func (g *Graph) HasEdge(from, to string) bool {
  for _, edge := range g.vertices[from] {
    if edge.To == to {
      return true
    }
  }
  return false
}

func (g *Graph) Vertices() []string {
  keys := make([]string, 0, len(g.vertices))
  for k := range g.vertices {
    keys = append(keys, k)
  }
  return keys
}

Import usado:

import "fmt"

Travessia de Grafos

Travessia significa visitar todos os vértices de um grafo sistematicamente. As duas abordagens fundamentais são BFS e DFS.

Busca em Largura (BFS)

BFS explora o grafo nível por nível. Visita todos os vizinhos do vértice atual antes de ir mais fundo. Usa uma fila.

flowchart TD
  A["Começa na origem"] --> B["Adiciona na fila"]
  B --> C["Enquanto fila não vazia"]
  C --> D["Remove vértice da fila"]
  D --> E["Processa vértice"]
  E --> F["Enfileira vizinhos não visitados"]
  F --> C

BFS é ideal para:

  • Encontrar caminho mais curto em grafos não-ponderados
  • Travessia por níveis
  • Encontrar todos os vértices a k arestas de distância
func (g *Graph) BFS(start string) []string {
  visited := make(map[string]bool)
  result := []string{}
  queue := []string{start}

  visited[start] = true

  for len(queue) > 0 {
    vertex := queue[0]
    queue = queue[1:]

    result = append(result, vertex)

    for _, edge := range g.vertices[vertex] {
      if !visited[edge.To] {
        visited[edge.To] = true
        queue = append(queue, edge.To)
      }
    }
  }

  return result
}

Busca em Profundidade (DFS)

DFS explora o mais fundo possível antes de voltar. Segue um caminho até não poder continuar, então retorna para explorar outros ramos. Usa uma pilha (ou recursão).

flowchart TD
  A["Começa na origem"] --> B["Marca como visitado"]
  B --> C["Processa vértice"]
  C --> D["Para cada vizinho não visitado"]
  D --> E["DFS recursivamente"]
  E --> D

DFS é ideal para:

  • Detectar ciclos
  • Ordenação topológica
  • Encontrar componentes conexos
  • Resolver labirintos
func (g *Graph) DFS(start string) []string {
  visited := make(map[string]bool)
  result := []string{}

  g.dfsRecursive(start, visited, &result)

  return result
}

func (g *Graph) dfsRecursive(vertex string, visited map[string]bool, result *[]string) {
  visited[vertex] = true
  *result = append(*result, vertex)

  for _, edge := range g.vertices[vertex] {
    if !visited[edge.To] {
      g.dfsRecursive(edge.To, visited, result)
    }
  }
}

Versão iterativa usando pilha explícita:

func (g *Graph) DFSIterative(start string) []string {
  visited := make(map[string]bool)
  result := []string{}
  stack := []string{start}

  for len(stack) > 0 {
    vertex := stack[len(stack)-1]
    stack = stack[:len(stack)-1]

    if visited[vertex] {
      continue
    }

    visited[vertex] = true
    result = append(result, vertex)

    neighbors := g.vertices[vertex]
    for i := len(neighbors) - 1; i >= 0; i-- {
      if !visited[neighbors[i].To] {
        stack = append(stack, neighbors[i].To)
      }
    }
  }

  return result
}

BFS vs DFS

Aspecto BFS DFS
Estrutura de dados Fila Pilha/Recursão
Complexidade de espaço O(V) pior caso O(V) pior caso
Caminho mais curto (não-ponderado) Sim Não
Uso de memória Maior para grafos largos Maior para grafos profundos
Caso de uso Travessia por nível, caminho mais curto Detecção de ciclo, ordenação topológica

Caminho Mais Curto: Algoritmo de Dijkstra

O algoritmo de Dijkstra encontra o caminho mais curto de um vértice origem para todos os outros vértices em um grafo ponderado com pesos não-negativos.

É aqui que as priority queues da parte 4 entram. Dijkstra usa um min-heap para sempre processar o vértice com a menor distância conhecida.

flowchart TD
  A["Inicializa distâncias como infinito"] --> B["Define distância da origem como 0"]
  B --> C["Adiciona origem na priority queue"]
  C --> D["Enquanto fila não vazia"]
  D --> E["Extrai vértice com menor distância"]
  E --> F["Para cada vizinho"]
  F --> G{"nova distância < atual?"}
  G -->|Sim| H["Atualiza distância"]
  H --> I["Adiciona na fila"]
  I --> F
  G -->|Não| F
  F --> D

Por que funciona

Dijkstra é um algoritmo guloso. Ele sempre escolhe o vértice não visitado com a menor distância. Como todos os pesos de aresta são não-negativos, uma vez que um vértice é processado, sua menor distância é final.

Implementação prática em Go

type DijkstraResult struct {
  Distance map[string]int
  Previous map[string]string
}

type pqItem struct {
  vertex   string
  distance int
}

type minHeap []pqItem

func (h minHeap) Len() int           { return len(h) }
func (h minHeap) Less(i, j int) bool { return h[i].distance < h[j].distance }
func (h minHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *minHeap) Push(x interface{}) {
  *h = append(*h, x.(pqItem))
}

func (h *minHeap) Pop() interface{} {
  old := *h
  n := len(old)
  item := old[n-1]
  *h = old[:n-1]
  return item
}

func (g *Graph) Dijkstra(start string) DijkstraResult {
  dist := make(map[string]int)
  prev := make(map[string]string)

  for v := range g.vertices {
    dist[v] = math.MaxInt
  }
  dist[start] = 0

  pq := &minHeap{{vertex: start, distance: 0}}
  heap.Init(pq)

  processed := make(map[string]bool)

  for pq.Len() > 0 {
    item := heap.Pop(pq).(pqItem)
    u := item.vertex

    if processed[u] {
      continue
    }
    processed[u] = true

    for _, edge := range g.vertices[u] {
      alt := dist[u] + edge.Weight

      if alt < dist[edge.To] {
        dist[edge.To] = alt
        prev[edge.To] = u
        heap.Push(pq, pqItem{vertex: edge.To, distance: alt})
      }
    }
  }

  return DijkstraResult{Distance: dist, Previous: prev}
}

func (r DijkstraResult) PathTo(target string) []string {
  path := []string{}
  current := target

  for current != "" {
    path = append([]string{current}, path...)
    current = r.Previous[current]
  }

  return path
}

Imports adicionais:

import (
  "container/heap"
  "math"
)

Complexidade

  • Tempo: O((V + E) log V) com um heap binário
  • Espaço: O(V) para os maps de distância e predecessor

Limitações

Dijkstra não funciona com pesos de aresta negativos. Para isso, você precisaria de Bellman-Ford (que é mais lento com O(V * E)).

Detecção de Ciclos

Detectar ciclos é uma operação comum em grafos, especialmente importante para resolução de dependências e detecção de deadlock.

Em Grafos Direcionados

Use DFS com três estados: não visitado, visitando (no caminho atual), visitado (completamente processado).

func (g *Graph) HasCycle() bool {
  white := make(map[string]bool) // não visitado
  gray := make(map[string]bool)  // visitando
  black := make(map[string]bool) // visitado

  for v := range g.vertices {
    white[v] = true
  }

  for v := range g.vertices {
    if white[v] {
      if g.hasCycleDFS(v, white, gray, black) {
        return true
      }
    }
  }

  return false
}

func (g *Graph) hasCycleDFS(v string, white, gray, black map[string]bool) bool {
  white[v] = false
  gray[v] = true

  for _, edge := range g.vertices[v] {
    if gray[edge.To] {
      return true
    }
    if white[edge.To] && g.hasCycleDFS(edge.To, white, gray, black) {
      return true
    }
  }

  gray[v] = false
  black[v] = true

  return false
}

Casos de uso reais

  • Redes sociais: amigos, seguidores, conexões
  • Mapas e navegação: estradas, rotas, caminhos mais curtos
  • Gerenciamento de dependências: dependências de pacotes, ordem de build
  • Web crawling: páginas e links
  • Sistemas de recomendação: relacionamentos usuário-item
  • Roteamento de rede: roteadores e conexões
  • IA de jogos: pathfinding em mapas de jogos

Escolhendo o algoritmo certo

Problema Algoritmo
Visitar todos os nós BFS ou DFS
Caminho mais curto (não-ponderado) BFS
Caminho mais curto (ponderado, não-negativo) Dijkstra
Caminho mais curto (pesos negativos) Bellman-Ford
Detectar ciclos DFS com coloração
Ordenação topológica DFS
Árvore geradora mínima Prim ou Kruskal

Conclusão

Grafos são uma das estruturas mais flexíveis para modelar relacionamentos. Seu poder vem da variedade de algoritmos que operam sobre eles de forma eficiente.

Neste artigo cobrimos representação de grafos (lista de adjacência vs matriz), travessia (BFS e DFS), caminho mais curto (Dijkstra) e detecção de ciclos. A priority queue da parte 4 apareceu naturalmente no algoritmo de Dijkstra.

Isso conclui nossa série de estruturas de dados. Passamos por Listas básicas, Árvores, Hash Tables, Heaps e agora Grafos. Cada estrutura otimiza padrões de acesso e operações diferentes. A habilidade chave é reconhecer qual estrutura se encaixa no seu problema.