Tipos de Algoritmos

Antes de começar nosso estudo de algoritmos, precisamos entender alguns conceitos fundamentais sobre o tema. Cobriremos aqui dois desses conceitos: tipos de algoritmos, e tipos de estratégias de projeto de algoritmo.

Nesta seção classificaremos algoritmos em dois grupos: algoritmos recursivos, e algoritmos iterativos. É importante saber as distinções entre esses tipos de algoritmos, bem como os pontos fortes e fracos de cada um, pois frequentemente precisamos escolher quando implementar um algoritmo de forma iterativa ou de forma recursiva. Sabendo as nuances de cada tipo de algoritmo, poderemos escolher qual o melhor tipo a ser usado em cada situação.

Algoritmos Recursivos

Agora suponha, por um breve momento, que você, cansado da rotina diária, decida caminhar pelas colinas verdejantes próximas à sua casa. Nessa caminhada, você encontra um gênio da lâmpada que, como de costume, lhe diz que você pode fazer três pedidos.

Genio

Você decide então testar esse gênio para ver se ele é um gênio dos bons. Para testá-lo, você lhe faz a seguinte pergunta: como eu faço para construir um prédio de três andares?, ao que o gênio responde: para construir um prédio de três andares, basta você construir um prédio de dois andares e colocar mais um andar em cima dele.

Você, ao ouvir essa resposta, decide voltar para casa e seguir com sua vida, sem saber que o gênio não estava de todo errado, ele só estava te ensinando a construir um prédio de três andares de forma recursiva. Como veremos adiante, com mais duas perguntas (ou uma, dependendo de como você vê a situação), você teria conseguido aprender a construir um prédio de três andares. Na verdade, você seria capaz de construir prédios de tantos andares quanto desejasse!

Apesar de simplória, a história acima nos mostra um aspecto importante de definições recursivas corretas. Como veremos a seguir, essa história nos ensina a criar definições recursivas que nos permitem resolver problemas aparentemente muito difíceis em termos de problemas menores e mais fáceis.

Definições Recursivas

Chega de historinhas, agora a coisa fica séria! Para mostrar o quão séria a coisa fica, vamos começar falando dos aspectos mais abstratos de recursão. Mas não se assuste, eles não são tão abstratos assim e são importantes para entendermos os detalhes técnicos de recursão.

Uma forma bem resumida de explicarmos recursão é dizermos que recursão é auto-referência. Recursão ocorre quando algo é definido em termos de si mesmo ou de suas variações, ou seja, recursão envolve estruturas aninhadas.

Nós humanos, por exemplo, estamos acostumados com os seguintes cenários recursivos: alguém que escreve um livro sobre escrita, uma pessoa que está assistindo a um filme que mostra outra pessoa assistindo a um filme, uma imagem que contém uma cópia de dela prória (talvez em menor escala). Este último caso é mostrado na figura abaixo, criada por Paul Noth para o The New Yorker.

Genio

Dissemos acima que recursão ocorre quando algo é definido em termos de si mesmo ou de suas variações, mas como definir algo recursivamente de forma útil e precisa? Se uma coisa é definida unicamente em termos de si própria, existe uma circularidade inerente que não nos permite concluir nada sobre o que está sendo definido. Um exemplo desse tipo de definição circular seria a seguinte:

Definição (inútil): Um procedimento recursivo é um procedimento que usa recursão.

A definição acima é um exemplo de como não fazer uma definição recursiva. Ela define um procedimento recursivo valendo-se da própria palavra sendo definida (recursão). Se o leitor não souber o que é recursão, a definição não vale de nada. E se o leitor souber o que é recursão, a definição é desnecessária.

Cálculo do Fatorial de um Número

Um outro exemplo de definição recursiva formulada de forma errada (incompleta) é a definição abaixo:

Definição (incompleta): O fatorial de um número $n$, denotado por $n!$, é calculado como a seguir:

  • $n! = n \times (n - 1)!$

A definição acima é incompleta porque não dos diz como calcular o fatorial de nenhum número específico. Se tentarmos aplicá-la ao cálculo do fatorial de algum número, nunca seremos capazes de parar. Continuaremos calculando indefinidamente.

Essa definição é um pouco parecida com a forma com que o gênio descreveu como construir um prédio de três andares. Ele só disse que antes seria necessário construir um prédio de dois andares. Entretanto, uma informação que falta na definição acima (quando parar de calcular), não acontece no caso do gênio, pois sabemos que só seria preciso fazer perguntas ao gênio até que ele explicasse como construir um prédio de um andar. No caso de definições recursivas, também precisamos saber quando parar.

Definições recursivas (quando propriamente formuladas) nunca definem algo unicamente em termos de si mesmas, mas sempre em termos de versões mais simples de si mesmas, até que seja possível parar, ou seja, até chegarmos a um caso tão simples que seja possível resolvê-lo muito facilmente. Um exemplo disso é a definição matemática de fatorial, agora feita de modo correto.

Definição (correta): O fatorial de um número $n$, denotado por $n!$, é calculado como a seguir:

  • $0! = 1$

  • $n! = n \times (n - 1)!$

Com a adição de mais uma regra, sabemos que, ao chegar no zero, conseguiremos calcular o fatorial e poderemos usar esse resultado no cálculo do fatorial dos demais números, até chegarmos ao fatorial do número desejado inicialmente. O procedimento não executará indefinidamente.

É importante pararmos um pouco para entendermos (em termos simples) o que a definição acima nos diz. O que ela está nos dizendo é: para calcular o fatorial de 100 (por exemplo), tudo que você precisa é saber como calcular o fatorial de 99, pegar esse resultado, e multiplicá-lo por 100. Simples assim! Aí você se pergunta: e como eu faço para calcular o fatorial de 99? É simples também: basta você calcular o fatorial de 98 e multiplicar o resultado por 99. E a coisa continua assim, até você se perguntar: como eu faço para calcular o fatorial de 0? Milagrosamente, a resposta é que o fatorial de 0 é 1. Agora, basta você pegar esse resultado e ir multiplicando…​ até chegar ao ponto em que consegue calcular o fatorial de 100.

Se seguirmos a definição recursiva dada anteriormente e o raciocínio que acabamos de explicar, teremos o cálculo abaixo do fatorial de 5:

$ \begin{equation} \begin{split} fatorial(5) & = 5 \times fatorial(4) \\ & = 5 \times (4 \times fatorial(3)) \\ & = 5 \times (4 \times (3 \times fatorial(2))) \\ & = 5 \times (4 \times (3 \times (2 \times fatorial(1)))) \\ & = 5 \times (4 \times (3 \times (2 \times (1 \times fatorial(0))))) \\ & = 5 \times (4 \times (3 \times (2 \times (1 \times 1)))) \\ & = 5 \times (4 \times (3 \times (2 \times 1))) \\ & = 5 \times (4 \times (3 \times 2)) \\ & = 5 \times (4 \times 6) \\ & = 5 \times 24 \\ & = 120 \\ \end{split} \end{equation} $

Neste momento, é importante observarmos algumas coisas sobre o cálculo de $fatorial(5)$ realizado acima:

  • O fatorial de um número é definido (e calculado) a partir de versões mais simples de si mesmo (fatorial de números menores).

  • O formato do cálculo realizado mostra uma expansão seguida de uma contração. A expansão ocorre à medida que o processo do cálculo do fatorial constrói uma cadeia de operações adiadas (multiplicações, neste caso). A contração ocorre à medida em que as multiplicações são de fato realizadas.

  • Existe um ponto em que a recursão pára de ser aplicada. No caso acima, quando chegamos ao $fatorial(0)$, interrompemos a recursão, pois sabemos, de acordo com a definição, que $fatorial(0) = 1$. Esta etapa é extremamente importante, pois caso ela não existisse, teríamos que calcular $fatorial(-1)$, $fatorial(-2)$, $fatorial(-3)$, e seguiríamos calculando indefinidamente. Nosso cálculo nunca terminaria. Quando dizemos que $fatorial(0) = 1$, estamos impondo uma condição para que o procedimento recursivo termine. Este tipo de condição é chamada de condição de parada ou de caso base do procedimento recursivo.

  • Enquanto o procedimento não atinge a condição de parada, as chamadas recursivas vão sendo empilhadas (como em uma pilha de pratos mesmo) para serem resolvidas em um momento futuro. Ou, como dissemos anteriormente, é formada uma cadeia de operações adiadas. Quando o procedimento atinge a condição de parada, ele a resolve, e usa o resultado dela para resolver as demais chamadas ainda não resolvidas, na ordem em que elas estão na pilha, ou seja, da última para a primeira a serem empilhadas. Este é o processo de contração a que nos referimos acima. A figura abaixo ilustra melhor esta situação.

Fatorial

Um benefício de conceitos recursivos é que, em geral, a implementação da definição do conceito em uma linguagem de programação é uma tarefa relativamente simples. Por exemplo, veja abaixo a implementação do cálculo do fatorial de um número. Mesmo que você não entenda alguns detalhes do código, tente ver a correspondência entre a definição recursiva do fatorial de um número e a implementação recursiva desta definição.

def fatorial(n):
    # Caso base: O fatorial de 0 é 1.
    if n == 0:
      return 1

    # O fatorial de um número n é: n * fatorial(n - 1).
    return n * fatorial(n - 1)

No código acima, foi simples traduzir para uma linguagem de programação (Python, neste caso) as duas partes da definição recursiva do fatorial de um número. É possível ler o código e identificar facilmente o que ele está fazendo. Na maioria dos casos, se um algoritmo pode ser definido de forma recursiva, a implementação do algoritmo será direta (mas nem sempre simples).

Outra vantagem de algoritmos recursivos é que eles são, em geral, fáceis de serem lidos. A correspondência entre a definição e a implementação é óbvia. Por outro lado, como veremos em breve, algoritmos recursivos não são muito eficientes em alguns casos, pois pode ser que eles realizem grandes quantidades de trabalho repetido. Nosso próximo exemplo irá ilustrar melhor essa ineficiência.

Mas antes disso, a título de curiosidade, aqui está uma possível implementação iterativa da função fatorial(n):

def fatorial(n):
  fatorial = 1
  while (n):
    fatorial *= n
    n -= 1
  return fatorial

Sequência de Fibonacci

Definiremos agora uma sequência matemática muito famosa chamada Sequência de Fibonacci: $1, 1, 2, 3, 5, 8, 13, \dots$. Nessa sequência, os dois termos iniciais são $0$ e $1$, e cada termo subsequente é igual à soma dos dois termos anteriores. O cálculo do $n$-ésimo termo da sequência de Fibonacci pode ser definido recursivamente da seguinte forma:

Definição: O $n$-ésimo termo da sequência de Fibonacci, denotado por $F_{n}$, pode ser calculado assim:

  • $F_{0} = 0$

  • $F_{1} = 1$

  • $F_n = F_{n - 1} + F_{n - 2}$

A partir da definição acima, podemos implementar o cálculo do $n$-ésimo termo da sequência assim:

def fib(n):
    # Caso base: cálculo dos dois primeiros termos.
    if n <= 1:
        return n
    # Cálculo do n-ésimo termo.
    return fib(n - 1) + fib(n - 2)

Novamente, note a correspondência direta entre a definição e sua implementação. Entretanto, a implementação acima é ineficiente porque ela faz uma grande quantidade de trabalho repetido. Para ilustrar isso, veja a figura abaixo, ilustrando o cálculo de fib(5), que mostra as muitas chamadas repetidas da função fib, que, por questões de espaço, escrevemos simplesmente como f.

Fatorial

Apesar de a função fib possuir alguma semelhança em sua estrutura com a função fatorial, a complexidade da função fib é muito maior. Sua complexidade é $O(2^n)$. Isso ocorre por causa da quantidade de chamadas repetidas (desnecessárias) à função fib.

A título de curiosidade, existem formas mais eficientes de implementarmos a função fib, sem usar recursão, e sem fazer chamadas desnecessárias, como a seguinte:

def fib(n):
    if n <= 1:
        return n
    fib_n_menos_2 = 0
    fib_n_menos_1 = 1
    for i in range(2, n):
        fib_n = fib_n_menos_1 + fib_n_menos_2
        fib_n_menos_2 = fib_n_menos_1
        fib_n_menos_1 = fib_n
    return fib_n_menos_1 + fib_n_menos_2

A função acima calcula o $n$-ésimo termo da série de Fibonacci com complexidade $O(n)$.

Pelos exemplos de funções recursivas (e suas respectivas versões iterativas) que mostramos até agora, você pode estar pensando que recursão não é um recurso tão vantajoso assim. Afinal, a versão iterativa das funções que mostramos não é muito mais complicada que a versão recursiva correspondente, e a versão iterativa é mais eficiente em ambos os casos.

Na prática, a versão iterativa de um algoritmo tende a ser mais eficiente que a versão recursiva. Entretanto, em alguns casos a versão iterativa é tão mais complicada que acaba sendo mais vantajoso usar a versão recursiva (por questões de legibilidade e facilidade de manutenção) do que a versão iterativa. Em outros casos (como o nosso próximo exemplo), a própria natureza recursiva do problema e a elevada complexidade computacional inerente a ele (uma solução iterativa não traria ganho significativo de desempenho) fazem com que recursão seja a melhor escolha.

Torres de Hanoi

Um dos exemplos mais famosos de recursão é o problema conhecido como Torres de Hanoi, supostamente formulado no século XIX pelo matemático Francês Edouard Lucas. Esse problema também é conhecido como problemas das Torres de Lucas (em homenagem ao autor do problema), ou problema das Torres de Brahma (certamente não em homenagem à marca de cerveja). A lenda associada a esse problema é a seguinte.

No templo de Brahma em Benares, debaixo do domo que marca o centro do mundo, existem 64 discos de ouro puro que os monges carregam, um de cada vez, para três agulhas de diamante. Eles fazem isso em obediência à ordenança imutável do deus Brahma, segundo a qual nenhum disco pode ser colocado em cima de um disco menor que ele.

Hanoi

Segundo essa lenda, no começo do mundo, os 64 discos formavam uma torre (empilhados do maior para o menor) em uma das agulhas de diamante. O deus Brahma então ordenou aos monges que transferissem a torre de discos de uma agulha para outra, movendo um disco de cada vez e usando como "agulha auxiliar" a terceira agulha, e lhes disse que quando eles terminarem essa tarefa, chegará o fim do mundo.

Nesse momento, além dos questionamentos sobre os aspectos místicos da história, você pode estar se perguntando: O quê esse problema tem a ver com recursão? Por quê 64 discos? Por quê eles ainda não terminaram essa tarefa aparentemente simples?

Vamos examinar cada uma das perguntas acima.

A recursão aparece no problema se considermos que para mover 64 discos de uma agulha para a outra (usando uma outra agulha como auxiliar), basta saber como transferir 63 discos de uma agulha para a outra. Nesse momento, você, ainda descrente a respeito do funcionamento de recursão, pode estar rindo e pensando "é fácil assumir magicamente que se sabe como transferir os 63 discos de uma torre para a outra!" Mas, assim como no caso do fatorial, para mover 63 discos, precisamos somente saber mover 62 discos, e para mover 62 discos, precisamos somente saber como mover 61. Bom, nesse ponto você já deve antecipar aonde isso vai parar…​

Para resolver o problema das torres de Hanoi com $n$ discos, são necessários $2^n - 1$ movimentos, o que é deveras custoso computacionalmente. Isso nos leva à resposta da segunda pergunta que fizemos acima: por quê 64 discos? O deus Brahma quis ser generoso com o mundo e dar à humanidade tempo o bastante para se desenvolver.

Como os monges têm que mover 64 discos de uma torre para outra, usando uma terceira como auxiliar, e assumindo que cada movimento seja completado em 1 segundo, eles demorarão aproximadamente 584.942.417.355 anos, ou seja, 584 trilhões de anos para completar a tarefa. Estima-se que a Terra tenha menos de 5 bilhões de anos de idade, o que nos leva a crer que ainda tempos bastante tempo pela frente antes que os monges completem a tarefa e venha o fim do mundo.

A essa altura do campeonato, os monges já devem estar bastante cansados de executar essa entediante tarefa. Nós, como pessoas de bom coração, vamos construir um programa que imprime a sequência de movimentos necessários para resolver o problema. Depois disso, podemos enviar essa sequência a eles, que comprarão um robô avançadíssimo capaz de ler as sequências e mover os discos corretamente, poupando-os dessa tarefa chata. Mas espera, se fizermos isso, o mundo pode acabar antes do que acabaria? É isso mesmo que queremos? Bom, vamos construir o programa de decidimos isso depois.

def hanoi(n, origem, destino, aux):
  # Caso base: movendo um disco.
  if n == 1:
    print("Mova de {} para {}".format(origem, destino))
  else:
    # Movendo n - 1 discos de 'origem' para 'aux'.
    hanoi(n - 1, origem, aux, destino)
    # Movendo um único disco de 'origem' para 'destino'.
    hanoi(1, origem, destino, aux)
    # Movendo os n - 1 discos de 'aux' para 'destino'.
    hanoi(n - 1, aux, destino, origem)

A função acima pode ser invocada assim:

hanoi(4, 'A', 'C', 'B')
Mova de A para B
Mova de A para C
Mova de B para C
Mova de A para B
Mova de C para A
Mova de C para B
Mova de A para B
Mova de A para C
Mova de B para C
Mova de B para A
Mova de C para A
Mova de B para C
Mova de A para B
Mova de A para C
Mova de B para C

Algoritmos (Procedimentos) Iterativos

Explicamos anteriormente o algoritmo recursivo para o cálculo do fatorial de um número inteiro positivo. Entretanto, ao estudarmos a definição, vemos que o fatorial de um número inteiro positivo $n$ nada mais é do que o produto de todos os inteiros positivos menores ou iguais a $n$. Por exemplo:

$5! = 5 \times 4 \times 3 \times 2 \times 1 = 120$

Comparando este exemplo com o exemplo do fatorial recursivo, vemos que a principal diferença entre o procedimento recursivo e o iterativo é que o procedimento iterativo contém instruções completas de como realizar uma tarefa, ao passo que o procedimento recursivo contém instruções de como realizar uma tarefa tendo como base instâncias menores da mesma tarefa. Um exemplo tornará esta explicaçao mais clara.

Relembrando, a definição recursiva do fatorial de um número é a seguinte:

Definição: O fatorial de um número $n$, denotado por $n!$, é calculado como a seguir:

  • $0! = 1$

  • $n! = n \times (n - 1)!$

A definição iterativa do fatorial é um número é a seguinte:

Definição: O fatorial de um número $n$, denotado por $n!$, é calculado como a seguir:

$n! = n \times (n - 1) \times (n - 2) \times \dots \times 2 \times 1$

Como dissemos acima, o procedimento iterativo nos diz cada passo do cálculo do fatorial de um número, enquanto o procedimento recursivo nos diz como calcular o fatorial de um número $n$ baseado no fatorial de um número menor, ($n - 1$, neste caso).

Na prática, é muito fácil distinguir um algoritmo recursivo de um algoritmo iterativo. O algoritmo recursivo sempre terá uma chamada a si mesmo. O algoritmo iterativo nunca terá isso. Veja abaixo o algoritmo recursivo para o cálculo do fatorial de um número e o algoritmo iterativo logo em seguida e compare os dois.

def fatorial_recursivo(n):
    if n == 0:
      return 1
    else:
      return n * fatorial_recursivo(n - 1)

def fatorial_iterativo(n):
  fatorial = 1
  for i in range(1, n + 1):
    fatorial = fatorial * i
  return fatorial

No código acima, a chamada à função range(1, n + 1) gera uma lista de números no intervalo $[1,n]$, ou seja, indo de $1$ até $n$.

As noções de algoritmo recursivo e algoritmo iterativo, bem como as nuances de Python, ficarão mais claras ao longo do curso. Nosso objetivo aqui é simplesmente dar uma breve introdução a estes tópicos.

Até aqui, classificamos algoritmos em iterativos e recursivos. Na próxima seção mostraremos outra classificação de algoritmos, de acordo com a estratégia que eles usam para resolver um dado problema. Essas duas classificações são independentes uma da outra (no sentido de que elas podem co-existir).

Playground

# Use este espaço para praticar o que você aprendeu nesta seção. # Basta digitar o código no espaço abaixo e clicar 'run'.