Artigo original: How to Solve the Tower of Hanoi Problem - An Illustrated Algorithm Guide

Tradução realizada em português europeu

Antes de começarmos, vamos discutir o que é o problema da Torre de Hanói. Bem, é um simples puzzle onde o objetivo é mover um bloco completo de discos de uma posição inicial para outra posição. São seguidas três regras simples:

  1. Apenas pode ser movido um disco de cada vez.
  2. Cada movimento consiste em pegar no disco mais acima na pilha e colocá-lo no topo de outra pilha. Por outras palavras, um disco apenas pode ser movido se for o disco que está mais acima na pilha.
  3. Não pode ser colocado um disco maior sobre um menor.

Agora, vamos tentar imaginar um cenário. Vamos supor que temos uma pilha com três discos. O nosso objetivo é mover esta pilha da origem A para o destino C. Como fazemos isto?

Antes de lá chegarmos, vamos imaginar que existe um ponto intermédio B.

0_UB4f9VNg1RRs4k93
Três discos

Podemos utilizar B como ajuda para completar essa tarefa. Agora, estamos prontos para avançar. Vamos analisar cada um dos passos:

  1. Mover o primeiro disco de A para C
  2. Mover o primeiro disco de A para B
  3. Mover o primeiro disco de C para B
  4. Mover o primeiro disco de A para C
  5. Mover o primeiro disco de B para A
  6. Mover o primeiro disco de B para C
  7. Mover o primeiro disco de A para C

Resolvemos o nosso problema!

1_fLOJ9bbxmHuFgYCeeRslhA
Torre de Hanói com 3 discos. Wikipédia

Podes observar a imagem animada acima para uma compreensão melhor do problema.

Agora, vamos tentar criar o algoritmo para resolver o problema. Espera, temos aqui uma nova palavra: "Algoritmo". O que é isso? Alguma ideia? Sem problema, vamos ver.

0_B4f6VtfIxmB04Od1
Fotografia criada por bruce mars, extraída do Unsplash

O que é um algoritmo?

Um algoritmo é um dos conceitos mais importantes para um programador de software. Na verdade, acho que não é apenas importante para desenvolvimento de software ou programação, mas para toda a gente. Os algoritmos afetam-nos na nossa vida do dia a dia. Vamos ver como.

Vamos supor que trabalhas num escritório. Então, todas as manhãs fazes uma série de tarefas em sequência: primeiro acordas, depois vais à casa de banho, tomas o pequeno-almoço, preparas-te para o trabalho, sais de casa e, depois, podes apanhar um táxi ou autocarro, ou começar a andar em direção ao escritório e, depois de um determinado tempo, chegas ao escritório. Podes dizer que todos esses passos formam um algoritmo.

De forma simples, um algoritmo é um conjunto de tarefas. Espero que não te tenhas esquecido dos passos que realizamos para mover os três discos da pilha A para a C. Também podes dizer que esses passos são o algoritmo para resolver o problema da Torre de Hanói.

Em matemática e ciências da computação, um algoritmo é uma especificação ambígua de como resolver uma classe de problemas. Os Algoritmos podem realizar cálculo, processamento de dados e tarefas de raciocínio automatizado. — Wikipédia

Se observares estes passos, podes ver que fizemos a mesma tarefa várias vezes — mover discos de uma pilha para outra. A esses passos dentro de passos podemos chamar recursividade.

Recursividade

1_fsYHEgadIdn0fJt-cBXDHQ
Recursão – giphy

Recursividade é chamar a mesma ação a partir dessa ação, tal como na imagem acima.

Portanto, existe um regra para quando fazemos algum trabalho recursivo: deve existir uma condição para parar a execução da ação. Espero que tenhas compreendido os conceitos básicos sobre recursividade.

Agora, vamos tentar criar um procedimento que nos ajuda a resolver o problema da Torre de Hanói. Estamos a tentar criar a solução utilizando pseudocódigo. Pseudocódigo é um método de escrita de código utilizando a língua inglesa.

tower(disk, source, intermediate, destination)
{

}

Esse é o esqueleto da nossa solução. Recebemos o número total de discos como um argumento. Depois temos de passar a origem, local intermédio e destino de modo a podermos compreender o mapa que vamos utilizar para completar a tarefa.

Agora, precisamos de encontrar um estado terminal. O estado terminal é o estado onde vamos deixar de chamar a função.

IF disk is equal 1

No nosso caso, este seria o nosso estado terminal. Quando existir apenas um disco na pilha, é simples realizar este último passo e, depois disso, a nossa tarefa estará concluída. Não te preocupes se isto não for claro para ti. Quando chegarmos ao final, este conceito será mais claro.

Bem, encontramos o nosso ponto de estado terminal onde movemos o nosso disco para o destino, deste modo:

move disk from source to destination

Agora, chamamos a nossa função novamente ao passar estes argumentos. Neste caso, dividimos a pilha de discos em duas partes. O disco maior (enésimo disco) está numa parte e todos os outros discos (n-1) estão na segunda parte. Aí, chamamos o método duas vezes para (n-1).

tower(disk - 1, source, destination, intermediate)

Tal como indicamos, passamos total_disks_on_stack — 1 como argumento. Então, movemos novamente o nosso disco, deste modo:

move disk from source to destination

Depois disso, chamamos novamente o nosso método, desta forma:

tower(disk - 1, intermediate, source, destination)

Vamos observar o nosso pseudocódigo completo:

tower(disk, source, inter, dest)

IF disk is equal 1, THEN
      move disk from source to destination
   ELSE
      tower(disk - 1, source, destination, intermediate)   // Step 1
      move disk from source to destination                 // Step 2
      tower(disk - 1, intermediate, source, destination)   // Step 3
   END IF
   
END

Esta é a árvore para três discos:

1_LEkUpm8-CoxGko2f84gjOg
Árvore da Torre de Hanói (3 discos)

Este é o código completo em Ruby:

def tower(disk_numbers, source, auxilary, destination)
  if disk_numbers == 1
    puts "#{source} -> #{destination}"
    return
  end
  tower(disk_numbers - 1, source, destination, auxilary)
  puts "#{source} -> #{destination}"
  tower(disk_numbers - 1, auxilary, source, destination)
  nil
end

Chama tower(3, 'source','aux','dest')

Resultado:

source -> dest
source -> aux
dest -> aux
source -> dest
aux -> source
aux -> dest
source -> dest

Foram necessários sete passos para os três discos chegarem ao destino. Chamamos a isto um método recursivo.

0_VXmzOesqL7l18gAr
Fotografia criada por Aron Visuals, extraída do Unsplash

Cálculo de complexidade de tempo e de espaço

Quando executamos código ou uma aplicação na nossa máquina demora tempo — ciclos de CPU. Não é igual em todos os computadores, no entanto. Por exemplo, o tempo de processamento de um Core I7 e de um dual core é diferente. Para resolver este problema, existe um conceito utilizado na ciência da computação chamado complexidade de tempo.

Complexidade de tempo é um conceito em ciências da computação que lida com a quantificação da quantidade de tempo gasto por um conjunto de código ou algoritmo para processar ou executar como uma função da quantidade do input.

Por outras palavras, complexidade de tempo é essencialmente eficiência, ou quanto tempo uma função do programa demora a processar um determinado input. — techopedia

A complexidade de tempo dos algoritmos é geralmente expressada utilizando a notação de O grande. É uma notação assimptótica para representar a complexidade de tempo.

Agora, o tempo necessário para mover n discos é T(n). Existem duas chamadas recursivas para (n-1). Existe uma operação de tempo constante para mover um disco de um ponto inicial para um destino, que seja m1. Portanto:

T(n) = 2T(n-1) + m1    ..... eq(1)

E

T(0) = m2, a constant   ...... eq(2)
From eq (1)
T(1) = 2T(1-1) + m1
     = 2T(0)+m1
     = 2m2 + m1 ..... eq(3) [From eq 2]
T(2) = 2T(2-1) + m1
     = 2T(1) + m1
     = 4m2 + 2m1 + m1 .... eq(4) [From eq(3)]
T(3) = 2T(3-1) + m1
     = 2T(2) + m1
     = 8m2 + 4m1 + 2m1 + m1  [From eq(4)]

A partir destes padrões — eq(2) até ao último — podemos dizer que a complexidade de tempo deste algoritmo é O(2^n) ou O(a^n), onde a é uma constante maior do que 1. Por isso, tem complexidade de tempo exponencial. Para um único aumento no tamanho do problema, o tempo necessário é o dobro do anterior. Isto é computacionalmente muito caro. A maior parte dos programas recursivos recebem tempo exponencial, e é por isso que é muito difícil escrevê-los de maneira iterada.

Depois da explicação da análise da complexidade de tempo, acho que consegues adivinhar o que é isso. É o cálculo do espaço necessário em memória RAM para executar código ou uma aplicação.

No nosso caso, o espaço para o parâmetro por cada chamada é independente de n, o que significa que é constante. Que seja J. Quando realizamos a segunda chamada recursiva, a primeira termina. Isso significa que podemos reutilizar o espaço depois de finalizar a primeira. Portanto:

T(n) = T(n-1) + k .... eq(1)
T(0) = k, [constant] .... eq(2)
T(1) = T(1-1) + k
     = T(0) + k
     = 2K
T(2) = 3k
T(3) = 4k

Então, a complexidade de espaço é O(n).

Depois destas análises, podemos ver que a complexidade de tempo deste algoritmo é exponencial, mas a complexidade de espaço é linear.

Conclusão

Depois deste artigo, espero que agora consigas compreender melhor o puzzle da Torre de Hanói e como resolvê-lo. Além disso, tentei fornecer algum conhecimento básico sobre algoritmos, a sua importância, recursividade, pseudocódigo, complexidade de tempo e complexidade de espaço. Se quiseres obter informações mais detalhadas sobre estes tópicos, aqui estão os links para alguns cursos on-line bastante conhecidos:

  1. Algoritmos, Parte I, da Coursera
  2. Algoritmos, Parte II, da Coursera
  3. O Curso da Google da Udacity
  4. Certificação em Algoritmos e Estruturas de Dados em JavaScript (300 horas)

Podes visitar o repositório do autor sobre estruturas de dados e algoritmos para ver as outras soluções dele para diversos problemas.

O autor pode ser encontrado nestas redes sociais: GitHub | Twitter | LinkedIn