1.2 Procedimentos e os Processos que Eles Geram

O que é um bom programador? Um bom programador é definido por habilidades que vão além do conhecimento técnico básico (como operações e sintaxe). Os elementos essenciais são:

  1. Capacidade de Visualizar Processos
    • Antecipar consequências: Saber prever como um procedimento ou linha de código afetará o fluxo do programa, a eficiência, a manutenção e o resultado.
    • Simulação mental: Entender como o processo gerado por um código se desenrola, passo a passo, incluindo estados intermediários e possíveis erros.
  2. Experiência com Padrões e Estratégias
    • Conhecimento de "movimentos valiosos": Dominar padrões de projeto, algoritmos eficientes e boas práticas.
    • Abstração eficaz: Saber combinar operações primitivas em procedimentos úteis e reutilizáveis.
  3. Planejamento Sintético e Raciocínio Lógico
    • Pensamento reverso: Trabalhar de trás para frente a partir do resultado desejado (como um fotógrafo planeja exposição).
    • Controle do processo: Garantir execução ordenada e confiável do programa.
  4. Aprendizado Contínuo com a Prática
    • Experiência acumulada: Desenvolver intuição através da exposição a cenários complexos.
    • Adaptação: Ajustar estratégias conforme o contexto (como técnicas fotográficas).

1.2.1 Recursão Linear e Iteração

  1. Processo Recursivo Linear:
    • Expansão (acúmulo de operações) seguida de contração (execução das operações).
    • Requer memória proporcional a n (crescimento linear), pois o interpretador rastreia a cadeia de operações pendentes.
  2. Processo Iterativo Linear:
    • Usa variáveis de estado atualizadas iterativamente:
    • Mantém estado fixo, evoluindo em espaço constante (memória fixa), mesmo para grandes n.

Sintaxe recursiva (porém com chamadas de cauda) gera processo iterativo. Mantendo um espaço constante, pois o estado é totalmente descrito pelas variáveis a cada passo. Exemplos em outras linguagens: o for e o while. A recursão sintática (código que chama a si) não implica necessariamente um processo recursivo (com crescimento de memória). Um procedimento recursivo pode gerar um processo iterativo se for tail-recursive4 (recursão de cauda).


Exercício 1.9: Resposta

Processo Recursivo:


(+ 4 5)                         ;; Chamada do procedimento 1
(inc (+ 3 5))                   ;; Passo 1: inc é adiado
(inc (inc (+ 2 5)))             ;; Passo 2: incs acumulam
(inc (inc (inc (+ 1 5))))       ;; Passo 3
(inc (inc (inc (inc (+ 0 5))))) ;; Passo 4: caso-base (a=0)
(inc (inc (inc (inc 5))))       ;; Passo 5: contração começa
(inc (inc (inc 6)))             ;; Passo 6
(inc (inc 7))                   ;; Passo 7
(inc 8)                         ;; Passo 8
9                               ;; Resultado
        

Processo Interativo:


(+ 4 5) ;; Chamada do procedimento 2
(+ 3 6) ;; Passo 1: atualizar a e b
(+ 2 7) ;; Passo 2
(+ 1 8) ;; Passo 3
(+ 0 9) ;; Passo 4: caso-base (a=0)
9       ;; Resultado
        

Exercício 1.10: Resposta

Os valores das expressões são:


(A 1 10)
1024 ;; Resposta

(A 2 4)
65536 ;; Resposta

(A 3 3)
65536 ;; Resposta
        

Definições matemáticas para (f n), (g n), (h n), (k n)


(f n) ;; Sendo n > 0
;; f(n) = 2n

(g n) ;; Sendo n > 0
;; g(n) = 2^n

(h n) ;; Sendo n > 0
;; h(n) = 2↑↑n = 2^(h(n-1)) = 2^(2^(h(n-1))) = 2^(2^(2^(...^(2))))
;; Exemplo:
;;;; h(1) = 2
;;;; h(2) = 2^2 = 4
;;;; h(3) = 2^(2^2) = 16
;;;; h(4) = 2^(2^(2^2)) = 65536

;; (k n) => 5n^2
        

1.2.2 Recursão em Árvore

Exercício 1.11: Resposta

Regra:

f ( n ) = { n para n < 3 f ( n 1 ) + 2 f ( n 2 ) + 3 f ( n 3 ) para n 3 }

Para f(n) como procedimento recursivo:


(define (f n)
  (if (n < 3)
      n
      (+ (f (- n 1))
         (* 2 (f (- n 2)))
         (* 3 (f (- n 3)))
      )
  )
)
        


Para f(n) como procedimento interativo:

  1. Se n < 3 é retornado n
  2. 
    (if (n < 3) n ...)
              
  3. Preciso armazenar os valores:
    • f(n-1) = a
    • f(n-3) = b
    • f(n-2) = c
  4. O valor base é quando n = 3, então:
    • Valor inicial: a = 2
    • Valor inicial: b = 1
    • Valor inicial: c = 0
  5. Quando n > 3 é preciso calcular várias vezes f(n), para isso preciso atualiza os valores durante o processo a fim de não criar um processo recursivo, onde:
    • c = b
    • b = a
    • a = f(n-1) + 2f(n-2) + 3f(n-3)
    Logo preciso de um loop e uma variável de controle que pare o loop quando igual ao valor de n, já que o cálculo será feito a partir do valor de base e o cálculo repetirá n vezes:

(define (loop controle a b c)
  (if (= n controle)
      controle
      (loop (+ 1 controle)
            (+ a (* 2 b) (* 3 c))
            a                  
            b
            
      )
  )
)
        


O Procedimento completo fica:


(define (i n)
  (define (loop controle a b c)
    (if (= n controle)
        a                           ;; Aqui retorna o valor do cálculo
        (loop                       ;; Chamada para atualizar os valores
              (+ 1 controle)        ;; Mecanismo de stop
              (+ a (* 2 b) (* 3 c)) ;; Novo 'a'
              a                     ;; Novo 'b'                  
              b                     ;; Novo 'c'
        )
    )
  )

  (if (< n 3)
      3
                                    ;; Aqui determino o valor inicial de 'a',
                                    ;; 'b', 'c' e da variável de controle:
                                    ;; (loop <a> <b> <c> <controle>)  
      (loop 2 2 1 0)   
  )
)
        

Exercício 1.12: Resposta

Exercício 1.13: Resposta

...

1.2.3 Ordens de Crescimento

Ordens de Crescimento são uma maneira de descrever como o consumo de recursos (como tempo ou espaço) de um processo computacional aumenta à medida que o tamanho do problema cresce. Imagine que você tem um algoritmo que leva R(n) passos para ser executado, onde n é o tamanho da entrada. Se R(n) = Θ(n²), isso significa que, para entradas grandes, o tempo de execução do seu algoritmo crescerá aproximadamente como o quadrado do tamanho da entrada:

  1. Processo Linear:
    • Se um processo requer Θ(n) passos, o número de operações cresce linearmente com n.
    • Exemplo: Se você dobrar o tamanho do problema n, o número de operações também dobrará.
  2. Processo Quadrático:
    • Se um processo requer Θ(n2) passos, o número de operações cresce quadraticamente com n.
    • Exemplo: Dobrar o tamanho do problema quadruplicará o número de operações.
  3. Processo Exponencial:
    • Se um processo requer Θ(ϕn) passos (onde ϕ é a razão áurea), o número de operações cresce exponencialmente com n.
    • Exemplo: Cada incremento no tamanho do problema multiplicará o número de operações por um fator constante.
  4. Processo Logarítmico:
    • Se um processo requer Θ(log n)passos, o número de operações cresce lentamente, mesmo quando n aumenta significativamente.
    • Exemplo: Dobrar o tamanho do problema aumenta o número de operações por uma quantidade constante.
  5. Processo Não Primitivo-Recursivo:
    • Se um processo requer uma função não primitiva-recursiva, como a Função de Ackermann A(n, n), o número de operações cresce de forma extremamente rápida, ultrapassando qualquer função primitiva-recursiva.
    • Exemplo: A Função de Ackermann A(4, n) já produz números tão grandes que são praticamente incomputáveis para valores modestos de n.
    • Característica: O crescimento é tão acelerado que não pode ser descrito por funções como exponenciais, fatoriais ou torres de potências.
Tipo de Processo Ordem de Crescimento Exemplo de Comportamento
Logarítmico Θ(log n) Crescimento muito lento.
Linear Θ(n) Crescimento proporcional ao tamanho do problema.
Quadrático Θ(n2) Crescimento rápido, mas gerenciável.
Exponencial Θ(ϕn) Crescimento explosivo, impraticável para n grande.
Não Primitivo-Recursivo Θ(A(n, n)) Crescimento extremamente rápido, incomputável.

Exercício 1.14: Resposta

Árvore ilustrativa do processo count-change:

Figura retirada de Sicp-Solutions

A Imagem representa:


(count-change 11) ;; Chamada Inicial
;; (cc 11 5) → (+ (cc 11 4) (cc -39 5))
;; (cc 11 4) → (+ (cc 11 3) (cc -14 4))
;; (cc 11 3) → (+ (cc 11 2) (cc   1 3))
;; (cc 11 2) → (+ (cc 11 1) (cc   6 2))
;; (cc 11 1) → (+ (cc 11 0) (cc  10 1))
;; (cc 10 1) → (+ (cc 10 0) (cc   9 1))
;; (cc  9 1) → (+ (cc  9 0) (cc   8 1))
;; (cc  8 1) → (+ (cc  8 0) (cc   7 1))
;; (cc  7 1) → (+ (cc  7 0) (cc   6 1))
;; (cc  6 1) → (+ (cc  6 0) (cc   5 1))
;; (cc  5 1) → (+ (cc  5 0) (cc   4 1))
;; (cc  4 1) → (+ (cc  4 0) (cc   3 1))
;; (cc  3 1) → (+ (cc  3 0) (cc   2 1))
;; (cc  2 1) → (+ (cc  2 0) (cc   1 1))
;; (cc  1 1) → (+ (cc  1 0) (cc   0 1))
;; (cc  0 1) → Retorna 1.
4
        

Análise de crescimento espacial5:

Análise de crescimento temporal6:

Conclusão:

Exercício 1.15: Resposta

O procedimento p é aplicado 5 vezes quando (sine 12.15)


(sine 12.15)
=> (p (sine 4.05))                 ;; 1ª aplicação de p
=> (p (p (sine 1.35)))             ;; 2ª aplicação de p
=> (p (p (p (sine 0.45))))         ;; 3ª aplicação de p
=> (p (p (p (p (sine 0.15)))))     ;; 4ª aplicação de p
=> (p (p (p (p (p (sine 0.05)))))) ;; 5ª aplicação de p
=> (p (p (p (p (p 0.05)))))        ;; Caso base atingido
0.05
        


No procedimento sine, o argumento a é reduzido exponencialmente a cada passo (dividido por 3), o que caracteriza um processo de divisão recursiva. Isso difere de um crescimento linear ou exponencial:

Por que logaritmo? O logaritmo resolve equações onde a incógnita está no expoente. Na condição a 3 k 0.1 , isolamos 3 k e aplicamos log3 para extrair k:

3 k a 0.1  ⇒  k log 3 ( a 0.1 )

Simplificação: Usando a propriedade log 3 ( a b ) = log 3 ( a ) log 3 ( b ) , obtemos:

log 3 ( a 0.1 ) = log 3 ( a ) log 3 ( 0.1 )

O uso do logaritmo é natural já que a redução é exponencial (base 3). Isso resulta em uma complexidade espacial e temporal Θ ( log a ) , muito mais eficiente que soluções lineares ( Θ ( a ) ) ou exponenciais ( Θ ( 3 k ) ).

1.2.4 Exponenciação

Exercício 1.16: Resposta

  1. Exponenciação: bn
  2. Redução por quadrados sucessivos (para n par):
    Se n é par, podemos calcular bn de forma mais rápida:
    bn = b n 2 2
    Exemplo prático:
    28 (2 )4 )2 (4 )2 )2 162 = 256

    Por que isso funciona? Cada vez que elevamos ao quadrado, o expoente cai pela metade. Isso reduz o número de multiplicações!

  3. Passos logarítmicos (por que é rápido?):
    • Para n = 8: 2 8 3  passos ( log 2 8 = 3 )
    • Para n = 16: log 2 16 = 4  passos

    Comparação: Se dobrar (ex: 8 → 16), o número de passos só aumenta em 1! Isso é o que chamamos de crescimento logarítmico.

  4. E se n for ímpar?
    Exemplo para 35:
    3 5 = 3 × 3 4 3 × 3 2 2 = 3 × 81 = 243

    Observação: Mesmo com um passo extra (para ajustar o expoente ímpar), o total de operações ainda é proporcional a log(n).

Formalização:

b n = { 1 se n = 0 , b n / 2 2 se n é par, b × b n - 1 se n é ímpar.

(define (expt-with-successive-squares
          base
          exponent
          current-product
        )
  (cond ((= exponent 0) 
          1
        )
        ((even? exponent)
          (expt-with-successive-squares 
            (* base base)
            (/ exponent 2)
            current-product)
        )
        (else
          (expt-with-successive-squares
            base
            (- exponent 1)
            (* base current-product)
          )
        )
  )
)

        

Exercício 1.17: Resposta


4 Uma função é tail-recursive se a última ação executada antes de retornar for uma chamada direta a si mesma (sem operações pendentes). Em outras palavras, a chamada recursiva é a última operação no fluxo de execução, não havendo necessidade de processamento adicional após o retorno da recursão.

Por que é importante? Em linguagens que suportam otimização de chamada de cauda (TCO), como Scheme, processos tail-recursive são executados em espaço constante (Θ(1))1.2.3, ou seja, não acumulam pilha de chamadas. Isso evita estouro de pilha (stack overflow) para grandes iterações e torna a recursão tão eficiente quanto um loop imperativo.

5 Discas: Ordem de Crescimento Temporal:

  1. Conte o número de operações:
    • bn
    • Pergunte-se: "Quantas operações o algoritmo realiza para uma entrada de tamanho n?".
    • Como usar: Para loops, conte o número de iterações. Para recursão, analise o número de chamadas.
  2. Analise estruturas de repetição (loops):
    • Loops aninhados geralmente indicam crescimento polinomial (ex.: O(n2) para dois loops).
    • Como usar: Identifique o número de loops e sua relação com o tamanho da entrada.
  3. Verifique chamadas recursivas:
    • Recursão pode levar a crescimento exponencial O(2n) ou logarítmico O(log n), dependendo de como o problema é dividido.
    • Como usar: Analise quantas chamadas são geradas em cada passo e como o tamanho do problema é reduzido.
  4. Observe operações internas:
    • Operações dentro de loops ou recursões contribuem para o tempo total.
    • Como usar: Multiplique o número de iterações pelo custo das operações internas.
  5. Use a notação Big-O:
    • Simplifique a análise ignorando constantes e termos de menor ordem.
    • Como usar: Foque no termo dominante da função de custo.

6 Discas: Ordem de Crescimento Espacial:

  1. Analise a estrutura de dados utilizada:
    • Estruturas como arrays, listas ou matrizes ocupam espaço proporcional ao tamanho da entrada.
    • bn Como usar: Verifique o tamanho das estruturas de dados em função de n.
  2. Verifique a profundidade da recursão:
    • Em algoritmos recursivos, o espaço é determinado pela profundidade da pilha de chamadas.
    • font-size: 0.9em !important;
    • Como usar: Analise quantas chamadas recursivas são feitas antes de atingir o caso base.
  3. Observe variáveis temporárias:
    • Variáveis criadas em cada iteração ou chamada recursiva contribuem para o espaço.
    • Como usar: Conte o número de variáveis e seu tamanho em função de n.
  4. Verifique alocações dinâmicas:
    • Alocações de memória durante a execução (ex.: criar novas listas) aumentam o espaço.
    • Como usar: Identifique quanta memória é alocada em função de n.
  5. Analise o uso de memória em função da entrada:
    • Pergunte-se: "Como o uso de memória aumenta à medida que a entrada cresce?".
    • Como usar: Relacione o espaço utilizado com o tamanho da entrada n.