DEV Community

Cover image for Priority Queue em Ruby
Yury Cavalcante
Yury Cavalcante

Posted on • Edited on

Priority Queue em Ruby

Estive estudando o básico de estrutura de dados e algoritmos novamente durante minhas streams, meus estudos acabaram casando com um vídeo do Augusto Galego sobre otimizar uma solução de um desafio Leetcode utilizando a estrutura de dados Heap.

No vídeo o Augusto resolve usando a própria lib nativa do Python. Em Ruby não existe Heap nativo, porém, existe essa lib de algoritmos do Kanwei. Nela temos as estruturas abstratas e concretas: Priority Queues, Heap (min e max), Stack e etc.

Decidi fazer minha implementação de Priority Queue usando Min Heap/Max Heap, mas antes de sairmos falando sobre a implementação vamos definir o que são essas estruturas que estamos tanto falando, Heap pra lá, Priority Queue pra cá...

Ah, e todo esse processo foi gravado durante uma stream. Se te interessar está aqui os links.

Priority Queue

Uma estrutura de dados abstrata. Quase idêntica ao conceito de Queues (FIFO), porém, a saída de um elemento da Priority Queue respeita uma prioridade contida em cada elemento da fila.

Heap

A estrutura de dados que vamos utilizar na Priority Queue. Ela é uma árvore binária balanceada, e podemos representar ela com um array pra tornar as funções da fila possíveis. Dada a seguinte árvore binária:

Image description

Representamos com o seguinte array:
[2, 10, 12, 122, 13, 100]

Nota-se que os números azuis na imagem é o index dos arrays.
E a imagem também nos leva ao próximo tópico sobre Heaps...

Min/Max Heap

Uma heap pode ordenar sua árvore do menor para o maior como na imagem do tópico acima, chamamos de Min Heap. Mas também pode ser possível ordená-la de forma crescente:

Image description

Acima, o exemplo de uma Max Heap, que ficaria assim num array
[122, 13, 100, 2, 10, 12]

Inserindo elementos na Heap

Sempre que formos inserir um novo elemento na árvore (ou no array se preferir), temos que rebalancear a árvore caso necessário. Se inserirmos um elemento 6 (com prioridade 6), ela ficaria desbalanceada. O que nos leva a necessidade de compararmos o elemento pai do recém inserido, fazendo um processo chamado de bubble up (ou sift up, heap up:

  • Elemento pai maior?
    • Sim? Trocamos de posição o elemento inserido com o pai. (Fazer esse passo até resposta negativa)
    • Não? Finalizamos o processo, temos uma árvore balanceada pós push.

Removendo elementos da heap

Quando removemos um item da heap, vamos rebalancear da mesma forma. Trocamos o elemento da raiz da Heap com o último elemento (index 0 com o último index do array), removemos o último elemento do array e então vamos para o processo de descer o elemento da raíz até o seu devido lugar na árvore chamado de bubble down (ou sift down, heap down):

  • Elementos filhos esquerdo ou direito menor?
    • Sim? Priorizar o filho que tiver menor valor contido. Repetir esse passo até resposta negativa.
    • Não? Processo finalizado e temos uma árvore balanceada.

Implementação

Esse com certeza não é o código mais performático de uma Priority Queue, estou compartilhadno e usando tanto este post quanto minha stream como forma de documentar o aprendizado. Pretendo melhorar o código e ele está disponível no repositório de estudos de algorimos no meu github. Isso obviamente não quer dizer que não estou sucinto a comentários sugerindo melhorias, todos são muito bem vindos! :)

Os elementos

Criei uma classe que representaria os elementos da Heap, e nele temos duas importantes coisas a se notar.

class Element
  attr_accessor :data, :priority

  def initialize(data, priority = data)
    @data, @priority = data, priority
  end

  def <=>(elem)
    @priority <=> elem.priority
  end
end
Enter fullscreen mode Exit fullscreen mode
  • É possível inicializar um elemento com uma prioridade customizável, mas caso não é inicializada com a prioridade explícita, usaremos o próprio dado como prioridade. Isso vai nos ajudar na criação de uma Heap com inteiros por exemplo: um Element.new(10) já define o elemento 10 com prioridade 10.
  • Uma forte característica da Heap é que estaremos comparando o tempo todo os elementos (maior que, menor que). Criamos uma função que sobreescreve o spaceship operator (<=>). O que ele faz é simples:
a <=> b
#  se a < b retorna -1
#  se a = b retorna 0
#  se a > b retorna return  1
#  se a e b não são comparáveis, retorna nil
Enter fullscreen mode Exit fullscreen mode

Quando utilizarmos element1 <=> element2 ele vai nos retornar um desses 4 valores. Esse valor vai determinar a posição de um elemento após ser removido da fila, ou enfileirado. Isso vai nos ajudar na lógica de bubble up ou bubble down

A Priority Queue

Vamos por partes...

class PriorityQueue
  attr_accessor :elements

  def initialize(elements = [], order = -1)
    @elements = elements
    @compare = lambda { |left_el, right_el| (left_el <=> right_el) == order}
  end

  def size
    @elements.size
  end
end
Enter fullscreen mode Exit fullscreen mode

E aqui é onde o spaceship operator entra em ação. A lambda utilizando o order com 1 negativo quer dizer que sempre que compararmos um valor menor com um maior, retornará true. Isso nos garante que ao compararmos o elemento com um pai por exemplo, ele vai retornar true caso precise trocar

Enfileirando...

Ao enfileirar um elemento na Priority Queue, veremos se é necessário o processo de bubble up. A vantagem de traduzirmos a árvore binária para um array é que conseguimos computar facilmente o parente daquele elemento inserido. Existe a fórmula pronta pra conseguir o index do parente, do filho da esquerda e da direita. No caso de enfileiras vamos usar só a fórmula do parente.

PARENT = (TARGET_EL_INDEX - 1) / 2

  def <<(element)
    # Insert new element into array
    @elements << element

    # Get the inserted element index
    target_index = size - 1

    # Get the parent from the inserted index
    parent_index = (target_index - 1) / 2

    # Here, we use compare lambda to make sure we always swap the parent with the inserted element, to respect the min heap property (The child priority must be greater than parent's priority)
    while @compare.call(@elements[target_index], @elements[parent_index])
      # parent greater than child? swap it
      swap(target_index, parent_index)

      # update the respective indexes if next @compare.call succeeds
      target_index = parent_index

      parent_index = (target_index - 1) / 2
    end
  end
Enter fullscreen mode Exit fullscreen mode

Nota-se que a order da PriorityQueue vai seguir a padrão (-1), ou seja, uma Min Heap.

Pop, pop, pop

Para o processo de bubble down, vamos usar as duas fórmulas pra encontrar o filho da esquerda e o filho da direita do elemento que precisa descer na árvore.

LEFT_CHILD = 2 * (TARGET_EL_INDEX) + 1
RIGHT_CHILD = 2 * (TARGET_EL_INDEX) + 2

  def pop
    # swap the binary tree root with the last element
    swap(0, size - 1)

    # remove last element from queue
    @elements.pop

    # computations to reach the left/right child
    target_index = 0
    left_index = 2 * target_index + 1
    right_index = 2 * target_index + 2

    # here we start the bubbling up procedure and we dont stop until our childs elements is greater than its parents
    while (left_index < size && @compare.call(@elements[left_index], @elements[target_index])) || (right_index < @elements.size && @compare.call(@elements[right_index], @elements[target_index]))
      # if right and left child holds the same priority, then we use the data contained into it to decide which is smaller
      smallest_index = if right_index >= size || @elements[left_index].data < @elements[right_index].data
                         left_index
                       else
                         right_index
                       end

      # we swap it boooyyy
      swap(target_index, smallest_index)

      # update indexes in case this was not the last swap we needed it
      target_index = smallest_index
      left_index = 2 * target_index + 1
      right_index = 2 * target_index + 2
    end
  end
Enter fullscreen mode Exit fullscreen mode

Amostras & conclusões

Vamos lá...
Criei um método inspect dentro da class PriorityQueue pra ficar fácil o debugging.

  def inspect
    res = []
    for el in @elements
      res.push(el.data)
    end

    res
  end
Enter fullscreen mode Exit fullscreen mode

E voilà:

irb >>
min_heap = []

min_heap << Element.new(4)
min_heap << Element.new(8)
min_heap << Element.new(6)
min_heap << Element.new(9)
min_heap << Element.new(12)
min_heap << Element.new(7)
min_heap << Element.new(10)

pq = PriorityQueue.new(min_heap)
p pq.inspect()
=> [4, 8, 6, 9, 12, 7, 10]

pq << Element.new(5)
p pq.inspect()
=> [4, 5, 6, 8, 12, 7, 10, 9]

pq.pop()
p pq.inspect()
=> [5, 8, 6, 9, 12, 7, 10]

pq.pop()
p pq.inspect()
=> [6, 8, 7, 9, 12, 10]

pq.pop()
pq.pop()
pq.pop()
p pq.inspect()
=> [9, 12, 10]
Enter fullscreen mode Exit fullscreen mode

Na stream procurei fazer os testes e esse debugging usando excalidraw pra facilitar o entendimento dos resultados.

Pretendo melhorar esse código, colocar novos métodos (por exemplo um pop que tem index como argumento). Se te interessar, pode ficar ligado no meu github e nas streams. Até mais ver. o/

Top comments (0)