Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Estruturas de dados

Filas de prioridade

Uma fila de prioridades é uma estrutura de dados abstrata (TAD — Tipo Abstrato de Dados) que mantém um conjunto de elementos, cada um associado a uma chave (prioridade). Os elementos podem ser inseridos e removidos do conjunto, sendo que a operação de remoção sempre extrai o elemento com menor chave (elemento mínimo).

Interface da fila de prioridades

O TAD fila de prioridades oferece as seguintes operações:

A seguir, apresentamos duas implementações dessa interface, mostrando como a escolha afeta a complexidade dos algoritmos que as utilizam.


Implementação com arranjo simples

A implementação mais direta de uma fila de prioridades é usar um arranjo dinâmico, onde cada entrada é um par (element, key):

Quando acessamos Q[i], obtemos um par. Para extrair o elemento, usamos Q[i].element; para acessar a chave, Q[i].key.

Para suportar as operações eficientemente, mantemos dois arrays auxiliares:

Dessa forma, operações como membros e atualização de chave têm acesso direto (O(1)) ao elemento.

Pseudocódigo para arranjo simples

build-priority-queue(S)
// argumentos:
// S: conjunto de n elementos com chaves já atribuídas

  Q.size = n
  for i = 1 to n
    Q[i] = (S[i], S[i].key)
    pos[S[i]] = i
    inQ[S[i]] = true
  return Q
insert(Q, x, k)
// argumentos:
// Q: fila de prioridades (arranjo)
// x: elemento a inserir
// k: chave do elemento

  Q.size = Q.size + 1
  Q[Q.size] = (x, k)
  pos[x] = Q.size
  inQ[x] = true
member(Q, x)
// argumentos:
// Q: fila
// x: elemento

  return inQ[x]
decrease-key(Q, x, k)
// argumentos:
// Q: fila
// x: elemento
// k: nova chave (menor que a atual)

  i = pos[x]
  Q[i].key = k
extract-min(Q)
// argumentos:
// Q: fila

  min_index = 1
  for i = 2 to Q.size
    if Q[i].key < Q[min_index].key
      min_index = i
  x = Q[min_index].element
  Q[min_index] = Q[Q.size]
  pos[Q[min_index].element] = min_index
  inQ[x] = false
  Q.size = Q.size - 1
  return x

Análise de complexidade

OperaçãoCusto
build-priority-queueO(n)O(n)
insertO(1)O(1)
memberO(1)O(1)
decrease-keyO(1)O(1)
extract-minO(n)O(n)

Implementação com heap binário mínimo

Um heap binário é uma estrutura de dados que mantém uma propriedade de ordem em uma árvore binária implícita (representada como um arranjo contíguo). Especificamente, um min-heap satisfaz: o valor de cada nó é menor ou igual ao valor de seus filhos. Essa propriedade garante que o elemento mínimo sempre está na raiz (posição 1 do arranjo).

As operações da TAD são implementadas de forma especializada nesta estrutura, com prefixo heap- para distinguir a implementação concreta:

Estrutura da implementação

Assim como na implementação com arranjo simples, cada entrada no heap é um par (element, key). O heap armazena esses pares em um arranjo contíguo, mantendo a propriedade de min-heap nas chaves.

Mantemos dois arrays auxiliares para suportar as operações:

Adicionalmente, temos:

Com esses auxiliares, operações como x in Q, heap-decrease-key e heap-extract-min conseguem manter a eficiência.

Estrutura e acesso na árvore binária implícita

Para um nó na posição ii do arranjo:

parent(i)
// argumentos:
// i: índice no arranjo

  return floor(i / 2)

left(i)
// argumentos:
// i: índice no arranjo

  return 2 * i

right(i)
// argumentos:
// i: índice no arranjo

  return 2 * i + 1

Pseudocódigo para heap binário mínimo

min-heapify(Q, i)
// argumentos:
// Q: arranjo (heap)
// i: índice do nó a restaurar a propriedade de min-heap

  l = left(i)
  r = right(i)
  smallest = i
  if l <= Q.size and Q[l].key < Q[i].key
    smallest = l
  if r <= Q.size and Q[r].key < Q[smallest].key
    smallest = r
  if smallest != i
    swap(Q, i, smallest)
    pos[Q[i].element] = i
    pos[Q[smallest].element] = smallest
    min-heapify(Q, smallest)
heap-build(S)
// argumentos:
// S: conjunto de n elementos com chaves já atribuídas
// implementação heap da operação TAD build-priority-queue

  Q.size = n
  for i = 1 to n
    Q[i] = (S[i], S[i].key)
    pos[S[i]] = i
    inQ[S[i]] = true
  for i = floor(n / 2) down to 1
    min-heapify(Q, i)

Assim como no heapsort (capítulo de ordenação), a construção de um heap a partir de um arranjo arbitrário custa Θ(n)\Theta(n), apesar dos muitos chamamentos recursivos ao min-heapify. Isso porque a maioria dos nós está próxima das folhas (altura pequena) e realiza pouquíssimas comparações.

heap-insert(Q, x, k)
// argumentos:
// Q: heap
// x: elemento a inserir
// k: chave inicial
// implementação heap da operação TAD insert

  Q.size = Q.size + 1
  i = Q.size
  Q[i] = (x, k)
  pos[x] = i
  inQ[x] = true
  while i > 1 and Q[parent(i)].key > Q[i].key
    swap(Q, i, parent(i))
    pos[Q[i].element] = i
    pos[Q[parent(i)].element] = parent(i)
    i = parent(i)

Diferentemente de heap-build, que constrói eficientemente em O(n)O(n) a partir de elementos com chaves já atribuídas, heap-insert adiciona um único elemento com uma chave especificada.

heap-decrease-key(Q, x, k)
// argumentos:
// Q: heap
// x: elemento
// k: nova chave (menor que a atual)
// implementação heap da operação TAD decrease-key

  i = pos[x]
  Q[i].key = k
  while i > 1 and Q[parent(i)].key > Q[i].key
    swap(Q, i, parent(i))
    pos[Q[i].element] = i
    pos[Q[parent(i)].element] = parent(i)
    i = parent(i)
heap-extract-min(Q)
// argumentos:
// Q: heap
// implementação heap da operação TAD extract-min

  x = Q[1].element
  Q[1] = Q[Q.size]
  pos[Q[1].element] = 1
  inQ[x] = false
  Q.size = Q.size - 1
  if Q.size > 0
    min-heapify(Q, 1)
  return x

Análise de complexidade

Operação (TAD)Implementação heapCusto
build-priority-queueheap-buildO(n)O(n)
insertheap-insertO(log⁡n)O(\log n)
x in Q(array inQ)O(1)O(1)
decrease-keyheap-decrease-keyO(log⁡n)O(\log n)
extract-minheap-extract-minO(log⁡n)O(\log n)

Comparação de implementações

Operação TADArranjo simplesHeap binário
build-priority-queueO(n)O(n)heap-build: O(n)O(n)
insertO(1)O(1)heap-insert: O(log⁡n)O(\log n)
x in QO(1)O(1)O(1)O(1)
decrease-keyO(1)O(1)heap-decrease-key: O(log⁡n)O(\log n)
extract-minO(n)O(n)heap-extract-min: O(log⁡n)O(\log n)

A escolha entre arranjo simples e heap depende do padrão de operações. Se o algoritmo realiza muitas operações extract-min e decrease-key, o heap é preferível (operações logarítmicas). Se as operações decrease-key são raras e dominam extract-min, o arranjo simples pode ser mais eficiente (operações constantes para aquelas operações).


Conjuntos disjuntos

Estrutura de dados abstrata (TAD) que mantém uma coleção de conjuntos dinâmicos disjuntos dois a dois: cada elemento pertence a no máximo um conjunto. Cada conjunto é identificado por um representante — um de seus próprios membros, que funciona como o “nome” do conjunto nas consultas. O representante só perde essa função quando seu conjunto é fundido a outro.

Os conjuntos disjuntos são base para algoritmos sobre grafos como o Kruskal (capítulo de grafos): para cada aresta, o algoritmo testa se as extremidades já pertencem à mesma componente com find-set e, se não, une as duas componentes com union. Outras aplicações clássicas incluem o cálculo de componentes conexas e a detecção de ciclos.

Interface dos conjuntos disjuntos

O TAD de conjuntos disjuntos oferece as seguintes operações:


Implementação com listas ligadas

Cada conjunto é representado por uma lista ligada de seus elementos. Um objeto de conjunto aponta para o primeiro elemento da lista (head), para o último (tail) e mantém o tamanho da lista (length). Cada elemento xx, por sua vez, aponta para o próximo da lista (x.next) e de volta para o objeto do conjunto a que pertence (x.set). O representante é o primeiro elemento da lista, apontado por head.

Pseudocódigo para listas ligadas (encadeada)

make-set(x)
// argumentos:
// x: elemento a colocar em um novo conjunto unitário

  S = new set-object
  S.head = x
  S.tail = x
  S.length = 1    // usado apenas pela heurística de união ponderada
  x.next = NIL
  x.set = S
find-set(x)
// argumentos:
// x: elemento cujo conjunto queremos identificar

  return x.set.head    // o representante é o primeiro elemento da lista
union(x, y)
// argumentos:
// x: elemento do primeiro conjunto
// y: elemento do segundo conjunto

  S_x = x.set
  S_y = y.set
  if S_x == S_y
    return    // já estão no mesmo conjunto; nada a fazer
  S_x.tail.next = S_y.head    // emenda a lista de y no fim da lista de x
  S_x.tail = S_y.tail
  z = S_y.head
  while z != NIL
    z.set = S_x    // os elementos absorvidos passam a apontar para o conjunto de x
    z = z.next

Análise de complexidade

OperaçãoCusto
make-setO(1)O(1)
find-setO(1)O(1)
unionO(n)O(n)

Uma heurística clássica reduz ainda mais o custo das fusões: a união ponderada usa o atributo length para anexar sempre a lista menor ao fim da maior. Assim, o ponteiro de um elemento só é reescrito quando o conjunto que o contém pelo menos dobra de tamanho — e um conjunto dobra no máximo log⁡2n\log_2 n vezes. Logo, cada elemento tem seu ponteiro reescrito no máximo ⌊log⁡2n⌋\lfloor \log_2 n \rfloor vezes em toda a execução.

Isso motiva uma forma útil de medir custos: o custo amortizado de uma operação é a média do custo total de uma sequência de operações — e não o pior caso de uma operação isolada. Uma fusão isolada pode até ser cara, desde que a média das demais fique baixa. Considerando uma sequência de mm operações sobre no máximo nn elementos (cada um criado por um make-set), a união ponderada garante custo total O(m+nlog⁡n)O(m + n \log n): um custo amortizado de O(log⁡n)O(\log n) por operação.

As listas mantêm find-set em tempo constante, mas as fusões ainda reescrevem ponteiros de muitos elementos. A implementação a seguir atinge custo quase constante amortizado para todas as operações, representando cada conjunto como uma árvore com raiz.


Implementação com florestas com ponteiros

Cada conjunto é representado por uma árvore com raiz, em uma floresta. Cada elemento xx é um nó com um atributo-ponteiro x.p para seu pai; a raiz aponta para si mesma e é o representante do conjunto. Assim:

Sem cuidado, a floresta degenera: se link sempre pendura a mesma árvore sob a outra, uma sequência de fusões produz uma árvore-caminho de altura Θ(n)\Theta(n), e find-set passa a custar Θ(n)\Theta(n). Duas otimizações clássicas resolvem isso.

Union by rank e path compression

Cada heurística isoladamente já melhora o desempenho; juntas, o custo amortizado por operação (a média do custo total de uma sequência) cresce tão lentamente com nn que, na prática, é como se fosse constante: tempo quase constante amortizado.

Pseudocódigo para florestas com rank e compressão de caminho

O pseudocódigo a seguir já incorpora as duas otimizações: make-set inicializa o rank, link aplica union by rank e find-set aplica a compressão de caminho.

make-set(x)
// argumentos:
// x: elemento a colocar em um novo conjunto unitário

  x.p = x      // a árvore nova tem um único nó, que é a própria raiz
  x.rank = 0   // subárvore de altura zero
union(x, y)
// argumentos:
// x: elemento do primeiro conjunto
// y: elemento do segundo conjunto

  link(find-set(x), find-set(y))
link(x, y)
// argumentos:
// x: raiz da árvore do primeiro conjunto
// y: raiz da árvore do segundo conjunto
// une as duas árvores seguindo union by rank

  if x.rank > y.rank
    y.p = x    // a árvore mais baixa é pendurada sob a mais alta
  else
    x.p = y
    if x.rank == y.rank
      y.rank = y.rank + 1    // alturas iguais: a árvore resultante cresce uma unidade
find-set(x)
// argumentos:
// x: elemento cujo conjunto queremos identificar

  if x != x.p    // não é a raiz?
    x.p = find-set(x.p)    // a raiz vira o pai (path compression)
  return x.p    // retorna a raiz

Análise de complexidade

OperaçãoCusto amortizado
make-setO(1)O(1)
unionquase constante
find-setquase constante