quinta-feira, 25 de fevereiro de 2010

SICP - Exercícios 1.16 a 1.19 - Exponenciação, Multiplicação, e Fibonacci em tempo logarítmico

Exercício 1.16
Projete um procedure que desenvolva um processo iterativo de exponenciação que use uma sucessão de quadrados e também use um número logarítmico de passos, assim como fast-expt. (Dica: usando a observação de que (bn/2)2 = (b2)n/2, mantenha, junto com o expoente n e a base b, uma variável de estado adicional a, e defina a transformação de estado de tal maneira que o produto abn se mantenha constante de estado para estado. No início, tem-se a igual a 1, e a resposta é dada pelo valor de a ao final do processo. Em geral, a técnica de definir uma quantidade invariante, que não se altera de estado para estado, é uma maneira poderosa de se pensar sobre o projeto de algoritmos iterativos.)
Solução: A dica já diz tudo. Vamos ao código:

Veja que a invariante se mantém nas duas chamadas recursivas a fast-expt-iter. Para a primeira, temos a(b2)n/2 = abn. Já na segunda, temos (ab)bn-1 = abn.

Exercício 1.17: O algoritmo de exponenciação desta seção é baseado em executar a exponenciação por meio de repetidas multiplicações. De maneira similar, é possível pensar na multiplicação de inteiros por meio de adições repetidas. O seguinte procedure de multiplicação (que assume que nossa linguagem é capaz apenas de realizar adições, e não multiplicações), é análogo ao procedure expt:

Tal algoritmo tem um número de passos linear em b. Agora, suponha que a linguagem inclua, além da adição, as operações double, que dobra um inteiro, e halve, que divide um inteiro (par) por 2. Usando essas operações, projete um procedure análogo a fast-expt, que use um número de passos logaritmico.
Solução: Vamos chamar nosso procedure fast-mult. Vou copiar o código de fast-expt, para que possamos realizar as substituições necessárias.

Para chegar à definição de fast-mult a b, vamos observar as analogias:
 ExponenciaçãoMultiplicação
Linear emnb
Operação repetidamultiplicaçãoadição
Condição de paradan = 0b = 0
Resposta de parada ("elemento neutro")10
Recursão embn = (bn/2)2ab = 2(a(b/2))
Agora, fica fácil ver as analogias. Para chegar de fast-expt a fast-mult, subsituímos b por a, n por b, 1 por 0 (na primeira cláusula do cond), sqr por double e, finalmente, * por +. O resultado é mostrado a seguir:

Para os efeitos desse exercício, halve e double são primitivas. Como elas não o são de fato, tivemos que defini-las.

Exercício 1.18
Usando os resultados dos exercícios 1.16 e 1.17, desenvolva um procedure que gere um processo iterativo para a multiplicação de dois inteiros em termos de adições, multiplicações por 2 (double), e divisões por 2 (halve), e que use um número logarítmico de passos.
Solução: Agora, temos um análogo ao procedure fast-expt-iter, do exercício 1.16. Basta notar que ab = (2a)(b/2). Vamos usar, além de a e b, a variável de estado adicional x. No início, temos x = 0. A invariante que iremos preservar de é x+ab.

Os códigos de fast-mult-iter e fast-expt-iter apresentam, portanto, exatamente a mesma estrutura. Assim, podem ser abstraídos por procedures de ordem superior (tradução livre para higher order procedures). Isso será visto na próxima seção.

Exercício 1.19 Existe um algoritmo inteligente para computar os números de Fibonacci em um número logarítmico de passos. Lembre-se da transformação das variáveis de estado a e b no processo de fib-iter da seção 1.2.2: a ← a + b e b ← a. Denomine T essa transformação, e observe que aplicando-se T repetidamente por n vezes, começando com 1 e 0, produz o par Fib(n+1) e Fib(n). Em outras palavras, os números de Fibonacci são produzidos aplicando-se Tn, a enésima potência da transformação T, começando com o par (1, 0). Agora, considere que T seja caso especial com p=0 e q=1 numa família de transformações Tpq, onde Tpq transforma o par (a, b) de acordo com a ← bq + aq + +ap e b ← bq + aq. Mostre que, se aplicarmos a transformação Tpq duas vezes, o efeito é o mesmo de se usar uma única transformação Tp'q', da mesma família, e compute p' e q' em termos de p e q. Isso nos dá uma maneira explícita de achar o quadrado de transformações dessa família, e assim podemos computar Tn usando uma sucessão de quadraturas, como no procedure fast-expt. Junte isso tudo e complete o seguinte procedure, que roda em um número logarítmico de passos.

Solução: À primeira vista, esse exercício parece difícil, mas na verdade é um dos mais fáceis até agora... Basta entender o enunciado e saber um pouquinho de aritmética. Vamos lá:
Tpq(a, b) = (bq + aq + ap, bp + aq)
Tpq(Tpq(a, b)) = T2pq(a, b) = Tpq(bq + aq + ap, bp + aq) =
  ( (bp + aq)q + (bq + aq + ap)q + (bq + aq + ap)p,
    (bp + aq)p + (bq + aq + ap)q )

T2pq(a, b) = ( b(2pq + q2) + a(2q2 + 2pq + p2),
               b(p2 + q2) + a(2pq + q2) )


T2pq(a, b) = ( b(2pq + q2) + a(2pq + q2) + a(p2 + q2),
               b(p2 + q2) + a(2pq + q2) )


Se considerarmos p' = p2 + q2 e q' = 2pq + q2, então temos:
T2pq(a, b) = ( bq' + aq' + ap', bp' + aq' ) = Tp'q'(a, b)
Assim, mostramos que "elevar Tpq ao quadrado" significa aplicar Tp'q', com p' = p2 + q2 e q' = 2pq + q2. O código final para fib-iter fica, portanto:

terça-feira, 23 de fevereiro de 2010

SICP Exercício 1.15 - Calculando o seno

O seno de um ângulo (especificado em radianos) pode ser computado fazendo uso da aproximação sin x ≈ x, se x é suficientemente pequeno, e da identidade trigonométrica
sin x = 3 sin x/3 - 4 sin3 x/3
para redzir o tamanho do argumento da função seno. (Para os propósitos deste exercício, um ângulo é considerado "suficientemente pequeno" se seu módulo não é maior que 0.1 radianos). Essas ideias são incorporadas nos seguintes procedures:

a. Quantas vezes o procedure p é aplicado quando (sine 12.15) é avaliado?
b. Qual é a ordem de crescimento em espaço e número de passos (como uma função de a) usada pelo processo gerado pelo procedure sine quando a expressão (sine a) é avaliada?
Solução:
a. O procedure p é aplicado 5 vezes. Vamos avaliar a expressão 12.15 manualmente:

De agora em diante, vamos pular as avaliações dos ifs.

Agora, a expansão acabou. Vai começar a redução. Desde já, podemos ver que o procedure p é chamado exatamente 5 vezes. Os passos a seguir são mostrados apenas para que possamos ver todo o cálculo.

b. A ordem de crescimento, em fumção de a, do processo gerado pelo procedure sine quando a expressão (sine a) é avaliada, é log a, tanto em espaço quanto em número de passos.
Primeiramente, vamos chamar de k(a) o número de vezes que o procedure p é chamado, em função de a.
Observando a estrutura do código, podemos chegar à seguinte fórmula.
k(a) = 0, se |a| ≤ 0.1
k(a) = 1 + k(a/3), caso contrário.
Vamos expandir k(a/3):
k(a/3) = 1, se |a|/3 ≤ 0.1, ou seja, se |a| ≤ 0.3
k(a/3) = 1+ k(a/32), caso contrário
Voltando para k(a), podemos dizer:
k(a) = 0, se |a| ≤ 0.1
k(a) = 1, se 1 < |a| ≤ 0.3
k(a/3) = 1+ k(a/32), caso contrário
Ora, tal expansão nos leva a pensar em um padrão. A fórmula fechada para k(a) é
k(a) = 0, se |a| ≤ 0.1
k(a) = teto(log(a/0.1, 3)), caso contrário.
Na fórmula anterior, log(x, y) significa o logaritmo de x na base y, e a função teto(x) é definida como o menor inteiro maior ou igual a x.
Assim, teto(0) = 0, mas teto(0.000001) = 1. De fato, para a=12.15 temos log(121.5, 3) = 4.369070246428542 e, portanto, k(12.15) = 5.
Para responder (finalmente) ao exercício, vamos considerar que as funções cube e p executam um número constante de passos. Isso só é verdade se os argumentos dessas funções não forem muito grandes, mas para o propósito desse exercício podemos assumir tal fato. Daí, fica fácil observar que:
  1. k(a) é da ordem de log a
  2. O espaço utilizado é proporcional a k(a), portanto sine também é da ordem de log a
  3. O mesmo vale para o número de passos, uma vez que consideramos (cube a) e (p a) constantes em a

quinta-feira, 18 de fevereiro de 2010

SICP - Exercício 1.14 - Contando o troco... Um pouco de análise de algoritmos

Desenhe a árvore ilustrando o processo gerado pelo count-change da seção 1.2.2 quando está contando o troco para 11 centavos. Quais são as ordens de crescimento de espalo e número de passos usados por esse processo à medida que a quantia a ser trocada aumenta?

Solução: Primeiramente, vamos ao desenho da árvore:

As reticências são para representar um processo que se repete.

Complexidade Espacial
A complexidade espacial do processo gerado por count-change é linear, mais precisamente, Θ(n), com a quantidade a ser trocada. Vejamos: segundo o livro, o espaço é proporcional à altura da árvore que descreve o processo. Sabemos que (count-change n) chama (cc n 5). A altura da árvore se dará pelo caminho (cc n 5) -> (cc n 4) -> (cc n 3) -> (cc n 2) -> (cc n 1) -> (cc (- n 1) 1) -> (cc (- n 2) 1) -> ... (cc 1 1) -> (cc 0 1). A altura é, portanto, da forma n + constante. Assim, temos um processo Θ(n) em espaço.

Complexidade do número de operações
A complexidade do número de operações da ordem de n5. Para que possamos chegar a esse resultado, notemos que, como (count-change n) executa somente a expressão (cc n 5), devemos estudar a complexidade desta última expressão. Seja S(n, p) o número de passos realizados por (cc n p), para n≥0 e 0≤p≤5. Definindo como C(n) o número de passos em que estamos interessados, temos:

C(n) = S(n, 5) + constante

Porém, é possível achar uma expressão para S(n, 5).
S(n, 5) = S(n, 4) + S(n-50, 5)

Analogamente,
S(n, 4) = S(n, 3) + S(n-25, 4)
S(n, 3) = S(n, 2) + S(n-10, 3)
S(n, 2) = S(n, 1) + S(n-5, 2)
S(n, 1) = S(n, 0) + S(n-1, 1)

Agora, basta resolver as "recursões", de baixo pra cima...

Primeiro, notamos que o número de passos em p=0 é uma constante.
S(n, 1) = k + S(n-1, 1)
Usando a mesma fórmula para expandir S(n-1, 1), temos
S(n-1, 1) = k + S(n-2, 1) e, portanto,
S(n, 1) = k + (k + S(n-2, 1)) = 2k + S(n-2, 1)
A fórmula geral pode ser vista facilmente:
S(n, 1) = q*k + S(n-q, 1)
Sabemos que a o número de passos quando n é 0 também é constante (vamos chamar de p). Podemos substituir q por n e obter
S(n, 1) = n*k + S(n-n, 1)
S(n, 1) = k*n + p
Portanto, S(n, 1) é Θ(n).

Agora, vamos trabalhar com
S(n, 2) = S(n, 1) + S(n-5, 2)
S(n, 2) = S(n, 1) + S(n-5, 1) + S(n-2*5, 2)
S(n, 2) = S(n, 1) + S(n-5, 1) + S(n-2*5, 1) + S(n-3*5, 2)
A fórmula geral é, portanto:
S(n, 2) = S(n, 1) + S(n-5, 1) + S(n-2*5, 1) + ... + S(n-k*5, 1) + S(n-(k+1)*5, 2)

De maneira geral, sabemos calcular cada um dos termos, com exceção do último. Mas há uma condição especial em que sabemos calcular até mesmo o último termo. Podemos expandir a expressão até o primeiro valor de k tal que n-(k+1)*5 seja menor que zero. Daí, saberemos que, para tal k, S(n - (k+1)*5, 2) será uma constante.

Vamos determinar tal k:

n-5(k+1) < 0
n-5 < 5k
k > n/5-1

Assim, sabemos que o nosso valor de interesse para k é o teto (arredondamento para cima) de n/5. Nessas condições, o somatório para S(n, 2
S(n, 2) = O(n) + O(n-5) + O(n-10) + ... + O(n-5*n/5) + constante
Estamos somando um número dependente de n de termos que também são dependentes de n. Raciocinando informalmente, temos

S(n, 2) = n/5*O(n)
S(n, 2) = O(n2)

Analogamente, podemos calcular as complexidades de S(n, 3), S(n, 4) e, finalmente, C(n) = S(n, 5)

S(n, 3) = n/10*O(n2)
S(n, 3) = O(n3)

S(n, 4) = n/25*O(n3)
S(n, 4) = O(n4)

C(n) = S(n, 5) = n/50*O(n4)
C(n) = O(n5)

Concluindo
O método de calcular troco apresentado é uma simples tradução de um algoritmo recursivo para a linguagem Scheme. Assim sendo, por não tomar os devidos cuidados, repete bastante certos cálculos. Mas nem tudo está perdido, há técnicas para eliminar essas repetições

O raciocínio informal que eu usei pra obter a complexidade n5 a primeira vista parece razoavelmente complicado. Lembre-se, porém, que ele é usado pra analisar vários algoritmos diferentes (principalmente do estilo divisão e conquista). Assim sendo, não se preocupe, porque você vai acabar absorvendo a técnica.

SICP - Exercício 1.13 - Termo geral da sequência de Fibonacci

Prove que Fib(n) é o inteiro mais próximo de φn/√5, onde φ = (1+√5)/2. Dica: defina ψ = (1-√5)/2. Use indução e a definição dos números de Fibonacci para provar que Fib(n) = (φnn)/√5.

Solução: Vamos provar por indução que Fib(n) = (φnn)/√5. Temos Fib(0)= 0 =(φ00)/√5, e Fib(1)= 1 = (φ11)/√5. Juntas, essas afirmações formam a base da indução. Suponhamos que Fib(n) = (φnn)/√5 e, mais ainda, Fib(n+1) = (φn+1n+1)/√5, para todo n não negativo.

Temos, pela definição dos números de Fibonacci:

Fib(n+2) = Fib(n+1) + Fib(n)
Fib(n+2) = (φn+1n+1)/√5 + (φnn)/√5
Fib(n+2) = ((φn+1n) - (ψn+1n))/√5
Fib(n+2) = (φn(φ+1) - ψn(ψ+1))/√5

Porém, como φ+1=φ² e ψ+1=ψ² (é verdade, faça as contas...), temos:
Fib(n+2) = (φn+2n+2)/√5.

Ou seja, provamos por indução que Fib(n)= (φnn)/√5. Mas não é esse o nosso objetivo. O nosso objetivo é mostrar que Fib(n) é o inteiro mais próximo de φn/√5. Para concluir a prova, basta notar que:
  1. Fib(n)= φn/√5 - ψn/√5
  2. n|/√5 < 0.5, para todo n não-negativo.



SICP - Exercício 1.12 - Triângulo de Pascal

O padrão de números abaixo é denominado triângulo de Pascal.
    1
   1 1
  1 2 1
 1 3 3 1
1 4 6 4 1
   ...

Os números nas "arestas" do triângulo são todos 1, e cada número dentro do triângulo é a soma dois dois números sobre ele. Escreva um procedure que compute elementos do triângulo de pascal por meio de um processo recursivo.

Solução: Veja que o enunciado dá todas as dicas para a elaboração de um processo recursivo. O que faremos é uma simples tradução das propriedades enunciadas. Primeiramente, vamos definir que (pascal n p) representa o número na linha n e na coluna p do triângulo. Ambas as contagens começam do zero. Com essa convenção, (pascal n p) é igual à combinação de n elementos, tomados p a p.

Definida a nossa convenção, é só traduzir as regras. Os números das "arestas" são aqueles na primeira coluna ou na última coluna. Portanto, são os que obedecem à condição (or (= p 0) (= p n)). Para os demais, temos (em notação matemática, pascal(n,p) = pascal(n - 1, p - 1) + pascal(n - 1, p). Temos, portanto, o código:

Lembrando que, no "mundo real", deveríamos checar se os argumentos para a função pascal fazem sentido.

SICP - Exercício 1.11

Antes de começar
Como já perceberam, não tenho muita experiência com posts... Acabam saindo sempre muito grandes, mesmo eu tentando dividir em partes. Assim sendo, resolvi desistir da ideia de publicar uma seção por post, e vou começar a publicar um Exercício por post. Afinal de contas, menor que isso não pode ficar, né? Bem, aí vão o enunciado e a solução

Exercício 1.11
Uma função é definida com a regra f(n) = n, se n < 3, e f(n) = f(n-1) + 2f(n-2) + 3f(n-3), se n ≥ 3. Escreva um procedure que compute f por meio de um processo recursivo. Escreva um procedure que computa f por meio de um processo iterativo.

Solução: A solução que gera um processo recursivo é mera tradução da definição para a linguagem Scheme. O código é dado abaixo:

Agora, para a versão de f que gera um processo iterativo. Usando a regra, vemos que f(0) = 0, f(1) = 1, f(2) = 2, f(3) = 4, f(4) = 11, e assim sucessivamente. Vamos fazer uma definição análoga à definição iterativa para cálculo do número de Fibonacci. Se eu começar com a = 0, b = 1 e c = 2, e executar as transformações a -> b, b -> c, e c-> c + 2b + 3a, n vezes, eu termino com a = f(n), b = f(n+1), e c = f(n+2). O código que faz isso é mostrado abaixo (com o cuidado de fazer f funcionar também para números negativos).

SICP Seção 1.2 - Procedures e os Processos que eles geram - Parte 1

Como eu disse num post anterior, quando a seção for muito grande, eu vou dividir os posts em várias seções. Pois bem, ocorre que a seção 1.2 pede diversas avaliações manuais, de modo que as soluções dos exercícios ficam muito extensas. Assim, pra não matar ninguém de tédio, vou dividir essa seção em partes menores. Aqui estão as soluções dos exercícios 1.9 e 1.10.

Exercício 1.9
Cada um dos dois procedures a seguir define um método para adicionar dois inteiros positivos, em termos dos procedures inc, que incrementa seu argumento em 1 unidade, e dec, que decrementa seu argumento de 1.

Usando o modelo da substituição, ilustre o processo gerado por cada procedure na avaliação de (+ 4 5). Esses processos são iterativos ou recursivos?
Solução:Primeiramente, vamos ver o processo de avaliação para (+ 4 5), utilizando a primeira definição apresentada:

Ora, vimos que, para a primeira definição, processo primeiro expande as expressões, depois calcula os resultados. O processo é, portanto, recursivo. Mais especificamente, é linearmente recursivo em a.
Agora, vejamos a avaliação de (+ 4 5), usando o segundo procedure mostrado:

Vimos, portanto, que a segunda definição de + apresentada desenvolve um processo iterativo.
Dica: quem quiser testar no DrScheme, deve definir inc e dec em termos de -. Como você estará redefinindo +, definir inc em termos do + redefinido vai causar uma recursão infinita (pense e descobrirá). As definições de inc e dec devem ser:

No exercício, é mais fácil considerar inc e dec como primitivas...

Exercício 1.10
O procedure abaixo computa uma função matemática denominada função de Ackerman.

Quais os valores das seguintes expressões?
(A 1 10)
(A 2 4)
(A 3 3)
Considere os seguintes procedures, em que A é o procedure definido acima:
(define (f n) (A 0 n))
(define (g n) (A 1 n))
(define (h n) (A 2 n))
(define (k n) (* 5 n n))
Dê expressões matemáticas concisas para as funções computadas pelos procedures f, g e h para valores positivos de n. Por exemplo, (k n) computa 5n^2.
Solução: A maneira mais fácil de saber o valor das expressões (A 1 10), (A 2 4) e (A 3 3) é definir a função A no DrScheme e calcular seus valores pela janela de interações... Mas creio que não seja esse o objetivo do exercício. Vou resolver as perguntas finais, sobre f, g e h, e depois faço a resolução das expressões manualmente.
Expressão para (f n) = (A 0 n)
Hipótese: (f n) = 2*n.
Prova: Muito simples. O enunciado fala em valores positivos de n (portanto 0 estaria fora), mas vamos fazer com valores não-negativos. Vamos fazer uma avaliação parcial de A, substituindo x por 0 e y por n. Temos, então, que (f n) é equivalente a:

Repare que nem inseri a expressão correspondente ao else, pois como a segunda cláusula do cond sempre avaliará para #t, a cláusula do else é inacessível. De qualquer forma, para n = 0, temos (f n) = 0 (ou seja, 2*0), e para n diferente de zero, a segunda cláusula do cond avalia para #t e o resultado da expressão é (* 2 n). Ou seja, de qualquer forma, temos (f n) = (* 2 n).

Expressão para (g n) = (A 1 n)
Analogamente ao anterior:

Considerando valores positivos de n, vemos que (g n) as duas primeiras cláusulas do cond sempre avaliarão para #f, de modo que o resultado sempre será equivalente à expressão abaixo.

Mas a expressão do corpo de g ainda pode ser simplificada, notando que (A 0 n) = (f n) = (* 2 n), e (A 1 (- n 1)) = (g (- n 1)). Temos, portanto:

Daí, fica fácil ver por indução que, de fato, (g n) 2^n.

Expressão para (h n) = (A 2 n)
Podemos fazer da mesma maneira que fizemos para g. Vamos pular os passos semelhantes e chegar direto em:

Novamente, podemos notar que (A 1 n) = 2^n e (A 2 (- n 1)) = (h (- n 1)), ou seja:

Também por indução, dá pra ver que a fórmula é
(2^ (2^ (2^...))), em que o número de 2 que aparece é exatamente n. Assim, para (h 1) = 2,(h 2) = (2^ 2) e (h 3) = (2^ (2^ 2)) = 16.

Agora, vamos à avaliação das expressões que o enunciado pede.

Avaliação de (A 1 10)

Avaliação de (A 2 4)

Avaliação de (A 3 3)

quarta-feira, 17 de fevereiro de 2010

Seção 1.1 - Os Elementos da Programação

Antes de Começar

Scheme é uma linguagem relativamente fácil de se implementar (se você não se preocupar com completeza e performance). De fato, durante a resolução dos exercícios vou implementar algumas variantes de interpretadores, no capítulo 4, e mesmo um pequeno compilador, no capítulo 5. A linguagem tem uma especificação IEEE, datada de 1990, mas os padrões de facto são os RnRS, ou seja, os Relatórios Revisados sobre a linguagem Scheme, cuja direção é dada pela própria comunidade de programadores.  Até o R5RS, a especificação da linguagem tinha menos de 50 páginas. Com o R6RS, veio o suporte a Unicode, módulos, entre outros, e o padrão cresceu um pouco.

A relativa facilidade de se implementar Scheme fez com que implementações proliferassem. Algumas implementações são boas e completas. Outras, nem tanto. Assim, um dos primeiros desafios pelo qual o aspirante a Schemer deve passar é a escolha de boas implementações. Eu particularmente vou adotar PLT Scheme, uma excelente implementação que vem já com um bom ambiente de desenvolvimento e excelente documentação offline. Caso tenha dúvidas sobre algum conceito, é só apertar F1 no ambiente do DrScheme e buscar na documentação.

Na primeira vez que o DrScheme for iniciado, é necessário que se escolha uma linguagem de trabalho. Basta clicar na região adequada no canto inferior esquerdo e escolher a linguagem Module. Você vai reparar que a primeira linha do arquivo na região de definições vai se tornar #lang scheme. Para resolver os exercícios do SICP, você não precisa saber o que isso significa exatamente. Quem quiser se aprofundar mais no uso de Scheme (além do requerido para o SICP), pode ler o Guia na documentação do PLT Scheme ou, alternativamente, o livro The Scheme Programming Language, de Kent Dybvig, também disponível gratuitamente na internet. Vale lembrar que Dybvig, além de um notável Schemer e pesquisador, é o desenvolvedor da Chez Scheme, sem dúvida a implementação mais rápida que há por aí... Chez Scheme é paga, mas no site você também encontra o Petite Chez Scheme, que é a basicamente a mesma coisa, só que sem o compilador. Petite é gratis...

Uma vez escolhido o ambiente de desenvolvimento, vamos começar com os exercícios. A seção 1.1 introduz os três elementos de qualquer linguagem de programação: primitivas, meios de combinação e meios de abstração. Apenas um detalhe. Apesar de os comentários serem em português, faço meus códigos todos em inglês (nomes de variáveis, funções, módulos, etc.), pois acredito que isso mantém a uniformidade. Se houver dúvida quanto ao significado de alguma coisa, basta comentar que eu esclareço.

Exercício 1.1
Abaixo está uma sequência de expressões. Qual o resultado impresso pelo interpretador em resposta a cada expressão? Assuma que a sequência deve ser avaliada na ordem em que é apresentada.

Solução: O objetivo deste exercício é ver se o leitor entendeu o básico da avaliação de expressões. Ou seja, tal exercício deve ser resolvido sem ajuda do interpretador. Uma maneira boa de se apresentar a solução é por meio de um arquivo interpretável é usar o mecanismo de testes do DrScheme. O mecanismo é muito simples. Basicamente, você define testes com check-expect e os executa por meio da execução da expressão (test). A forma check-expect (não digo função pois não é uma função, já que os testes só são definidos com check-expect, ou seja, os argumentos não são avaliados) aceita dois argumentos. O primeiro é a expressão que se quer testar e o segundo é o resultado esperado.


Exercício 1.2
Traduza a seguinte expressão em forma prefixa:






Solução: O objetivo desse exercício é saber se o leitor consegue converter entre notação infixa e prefixa. Tenho algumas dicas:
  1. operadores aritméticos permitem múltiplos argumentos, então a + b + c vira (+ a b c).
  2. olhe para a expressão meio que "de trás pra frente". Assim, a primeira coisa que se nota na expressão em questão é a divisão.
  3. Scheme suporta números racionais, então é possível escrever 4/5 direto em vez de (/ 4 5).


Exercício 1.3
Defina um procedure que recebe três números como argumentos e retorna a soma dos quadrados dos dois maiores.
Solução: O segredo para fazer menos comparações é, em vez de procurar os dois maiores, procurar o menor e retornar o quadrado dos dois outros. Vale lembrar que a função para calcular o quadrado, no PLT Scheme, denomina-se sqr.
Observação: Os dez testes que eu codifiquei antes da própria função podem ser considerados overkill e, de fato, o são. Mas o programa é tão pequeno que compensa testar todas as combinações possíveis de estados (três argumentos iguais, dois argumentos iguais, e três argumentos distintos).


Exercício 1.4
Observe que nosso modelo de avaliação permite combinações cujos operadores são expressões compostas. Use essa observação para descrever o comportamento do seguinte procedure:


Solução: Basicamente, a função calcula a + |b|. O jeito "padrão" de fazer esse cálculo (em C, por exemplo), é usando a + abs(b). Em Scheme, é possível observar a expressão na íntegra. Quando b é maior que zero, a + |b| é a + b, ou, em Scheme (+ a b). Caso contrário, a expressão torna-se a – b, isto é, (- a b). Observe que a única diferença entre as expressões é o operador. Assim sendo, é possível usar um condicional para definir o operador adequado para cada situação. A pergunta é: por que isso é possível em Scheme? Lembre-se que o algoritmo para avaliar uma combinação é recursivo: Para avaliar uma combinação, avaliam-se as suas sub-expressões e aplica-se o resultado da avaliação da sub-expressão da esquerda (o operador) aos resultados das avaliações das demais sub-expressões (os argumentos). Vamos mostrar (parcialmente) os passos da avaliação de (a-plus-abs-b a b) para um valor positivo e um valor negativo de b.
Valor positivo:

Analogamente, para valor negativo


Exercício 1.5
Ben Bitdiddle inventou um teste para determinar se o interpretador com o qual ele interage está usando ordem aplicativa de avaliação ou ordem normal de avaliação. Ele define os dois procedures abaixo:

Depois ele avalia a expressão:

Qual comportamento Ben observará com um interpretador que usa ordem aplicativa de avaliação? Qual comportamento ele observará com um interpretador que usa ordem normal de avaliação? Explique sua resposta. (Assuma que a regra de avaliação para a forma especial if é a mesma caso o interpretador use ordem normal ou aplicativa: A expressão-predicado é avaliada primeiro, e o resultado determina se o conseqüente ou o alternativo deve ser avaliado).
Solução: Para resolver esse problema, primeiro observa-se que p é uma recursão infinita. Cada vez que p é chamada, ela se chama novamente. Assim sendo, a avaliação nunca termina. Vale observar que, como Scheme apresenta otimização de tail-calls, a pilha não cresce com as chamadas recursivas de p... Assim, não haverá sequer o erro de estouro de memória. A avaliação simplesmente nunca acaba, mesmo que a memória esteja limitada a apenas 1MB...
Agora, vamos à expressão de teste: (test 0 (p)).
Se o interpretador usa ordem normal de avaliação, cada argumento de test só são avaliados quando (e se) seu valor for necessário. Usando o modelo de substituição, temos:

Agora, para se avaliar o resultado do if, é necessário avaliar a expressão (= 0 0). O resultado de tal avaliação é #t. Temos, portanto:

Veja que o valor de (p) não é necessário para que a avaliação possa se completar, então (p) nunca é, de fato, avaliada. Resumindo: num interpretador com ordem normal de avaliação, a expressão (test 0 (p)) produz o resultado 0.
Por outro lado, se o interpretador for de ordem aplicativa, temos a regra: para avaliar uma combinação, avaliamos as sub-expressões e aplicamos o resultado da sub-expressão da esquerda às demais sub-expressões. Assim sendo, as sub-expressões são avaliadas antes da aplicação, independentemente de seus resultados serem ou não necessários.
Para avaliar (test 0 (p)) num interpretador com ordem aplicativa (supondo ordem de avaliação da esquerda para a direita), temos os seguintes passos:
  1. Avalie test
  2. Avalie 0
  3. Avalie (p)
  4. Aplique o resultado da avaliação de test aos argumentos 0 e (p)
Até aí, tudo bem. O problema é que o passo 3 nunca retorna. Como a avaliação da expressão de teste depende da avaliação de (p), ela também não retorna. Ou seja, se o interpretador usar ordem aplicativa de avaliação, o comportamento que Bitdiddle observará é que a expressão de teste nunca produzirá um valor, ficando travada em loop infinito.

Exercício 1.6
Alyssa P. Hacker não vê por que if deve ser disponibilizado como uma forma especial. Por que não posso definir um procedure regular em termos de cond?, ela pergunta. A amiga de Alyssa, Eva Lu Ator, diz que isso pode de fato ser feito, e define uma nova versão de if.

Eva demonstra o programa para Alyssa:

Satisfeita, Alyssa usa new-if para reescrever o programa da raiz quadrada:

O que acontece quando Alyssa tenta usar esse procedure para computar raízes quadradas? Explique.
Solução: Quando Alyssa tenta sqrt-iter definido em termos de new-if, o programa produz infinitas chamadas (cada sqrt-iter invoca um novo new-if, que por sua vez invoca outro sqrt-iter, e assim por diante...) e jamais produz uma resposta. Explicação: A razão pela qual sqrt-iter não responde tem a ver com o fato de o interpretador usar a ordem aplicativa de avaliação. Ora, new-if é uma função e, assim sendo, deve avaliar seus argumentos antes que possa a eles ser aplicada. No caso de sqrt-iter, temos uma espécie de "dependência circular". Antes que sqrt-iter possa produzir um resultado, a expressão new-if que ocorre em seu corpo deve ser avaliada. Ou seja, sqrt-iter depende de new-if. Por outro lado, antes que new-if possa produzir um resultado, ela precisa avaliar uma nova expressão sqrt-iter. Embora a nova expressão sqrt-iter tenha argumentos distintos, uma chamada a sqrt-iter sempre ocasionará uma nova chamada a sqrt-iter, numa espécie de recursão infinita que nunca retorna.

Exercício 1.7
O teste good-enough? usado na computação de raízes quadradas não será muito efetivo para encontrar as raízes quadradas de números muito pequenos. Além disso, em computadores reais, as operações aritméticas são quase sempre executadas com precisão limitada. Isso faz com que nosso teste seja inadequado para números muito grandes. Uma estratégia alternativa de implementação de good-enough? é ver como guess muda de uma iteração para a próxima e parar quando a mudança for uma fração muito pequena de guess. Projete um procedure square-root que use esse tipo de teste de parada. Tal teste funciona melhor para números pequenos e grandes?
Solução: Abaixo mostro as definições de sqrt dada pelo livro, junto com outras funções associadas.

Usando essas definições, podemos avaliar sqrt para números pequenos, digamos, 10^-4 e 10^-6. Os resultados são:

Resultados aceitáveis deveriam ser aproximadamente 1.0e-2 e 1.0e-3, respectivamente. Ou seja, o procedure >sqrt de fato não funciona para números muito pequenos. Isso acontece porque, quando os números cuja raiz desejamos calcular aproximam a constante usada no teste, good-enough? vai , às vezes, retornar #t mesmo quando o guess não é bom o suficiente. Isso tem a ver com a relação entre a constante utilizada no teste good-enough? e o número cuja raiz se deseja calcular. De fato, mesmo quando os números são marginalmente menores que a constante (no caso 0.001), o resultado não é bom.

O procedure expt vem na distribuição padrão com o PLT Scheme, enquanto sqrt é o mostrado pelo livro. Pode-se ver que a diferença entre a raiz calculada por sqrt e a calculada por expt é bastante significativa.
Uma "mostração" (não confundir com demonstração) simples de que sqrt não funciona para números pequenos é dada a seguir: Se considerarmos que sqrt é não-decrescente, ou seja, que (x . >= . y) implica ((sqrt x) . >= . (sqrt y)), tem-se que o menor valor que a função sqrt pode retornar é aquele que ela retorna quando o argumento é 0, ou seja (sqrt 0). Assim, podemos estar certos de que sqrt não vai funcionar para qualquer número m cuja raiz for menor que (sqrt 0). No caso das constantes 0.001 para good-enough? e 1.0 como o chute inicial, todos os números cuja raiz for menor que 0.03125 vão apresentar resultados ruins. Mas o fato que eu "mostrei" é que, independentemente da constante, sempre haverá um intervalo para o qual ela será inadequada.
Agora vamos testar para números grandes (10^200 e 10^300):

Para 1.0e200, o resultado está correto, mas para 1.0e300, sqrt nunca produz um resultado. Isso acontece porque o procedure good-enough? jamais retorna #t se o guess for um número grande. Vejamos: se o argumento for suficientemente grande, sua raiz também o será. Assim, para que a raiz de um número grande seja retornada, o sistema deve ser capaz de trabalhar com um valor alto para guess. Porém, como o computador tem precisão (de ponto flutuante) limitada, nesse caso a diferença entre (square guess) e x não pode ser calculada com a precisão desejada (0.001, no caso). Assim, se guess for suficientemente grande, good-enough? sempre retorna #f e a avaliação nunca termina.
Vamos reescrever as funções de acordo com a dica no exercício. Eu reescrevi todas as funções relacionadas colocando um "i"(de improved) no começo do nome. O novo isqrt agora também funciona para números negativos (veja que Scheme suporta números complexos). Além disso, zero agora é um caso especial.

Agora, isqrt funciona bem em números muito pequenos, e muito grandes.


Exercício 1.8
O método de Newton para as raízes cúbicas é baseado no fato de que, se y é uma apresentação para a raiz cúbica de x, então uma melhor aproximação é dada pelo valor







Use esta fórmula para implementar um procedure cube-root análogo ao da raiz quadrada.Solução: O algoritmo é absolutamente análogo ao da raiz quadrada. A principal mudança é no método improve. Ao invés de usar a fórmula (y + x/y)/2, em que y é a aproximação atual, eu uso a fórmula dada pelo exercício.

Alguns testes:

segunda-feira, 15 de fevereiro de 2010

SICP: Resumão e o que esperar

Este post é motivacional: justifica por que escolhi o SICP e não outro livro qualquer. Além disso, apresenta um resumo (uma visão de 10 mil pés de altitude) do conteúdo do livro, para que todo mundo saiba o que esperar dele. Como é um resumo de um livro com mais de 600 páginas, esse post é extenso. Porém, não é imprescindível. Quem quiser chegar logo aos exercícios resolvidos, pode ignorar todo o texto abaixo e ir direto ao próximo post.

O Structure and Interpretation of Computer Programs, carinhosamente chamado de SICP (pronúncia: seek p), é um dos melhores livros sobre computação jamais escritos. Síntese de alguns dos aspectos mais importantes sobre computação, o objetivo do livro é ensinar ao leitor a programar. Eu programo desde os 13 anos, e esse livro me ensinou a programar (de novo). O livro encara a programação não como mera maneira de fazer com que o computador faça algo, mas  como um meio de expressar e comunicar ideias. Mais precisamente, ideias sobre processos.

Programas, sendo elementos de comunicação, devem ser feitos para que humanos possam ler, e apenas eventualmente para que computadores possam executar. Sabe as constatações sobre a importância da legibilidade e manutenibilidade de código, que estão bem na moda nos últimos 3 a 5 anos? Esses caras já tinham notado a importância disso na década de 70!!!

O livro é uma viagem, no melhor sentido da palavra. Mas não é para os "fracos de coração". Pense bem, o que você espera de um livro cuja dedicatória é "Este livro é dedicado, em respeito e admiração, ao espírito que vive no computador". Só pode ser muito bom, ou muito ruim. Aliás, as opiniões das pessoas sobre o SICP (vide reviews do Amazon) são altamente polarizadas. Ninguém fica em cima do muro... Eu, obviamente, estou no grupo dos que adoram o SICP. Pra mim, ele tem pelo menos uma boa ideia (um momento a-ha!!!) em cada página. Às vezes, mais que isso...

Outro aspecto interessante de SICP é que ele ensina a programar, mas não ensina uma linguagem de programação. Sim, você entendeu bem, e não, isso não é um absurdo. A linguagem usada durante o livro é um subconjunto da linguagem Scheme. Scheme é um dialeto de Lisp e, assim sendo, tem muito pouca sintaxe. Além disso, a maioria das implementações utiliza um ambiente interativo no qual você pode digitar as expressões e visualizar seus resultados, sem se preocupar com entrada e saída. Ou seja, a parte de Scheme utilizada pelo livro é muito fácil de aprender, então não precisa ser ensinada explicitamente. Você vai "pegando o jeito" enquanto faz os exercícios.

Como síntese de diversos aspectos sobre computação, o livro faz um excelente trabalho. Tem apenas cinco capítulos, divididos em 22 seções. Meu plano é fazer um post por seção, mas caso alguma seção seja muito grande, farei mais de um post, em casos especiais. A seguir, apresento um resumo de cada capítulo.

Capítulo 1 - Construindo Abstrações com Prodedures

No primeiro capítulo, você aprende as características básicas da linguagem. Suas primitivas, meios de combinação, e meios de abstração. O principal meio é a definição de procedures. Os procedures definidos nos capítulos 1 e 2 são, de fato, funções, no sentido matemático da palavra. Isso quer dizer que a parte da linguagem usada nos dois primeiros capítulos é funcional, não tendo nenhum efeito colateral.

Além disso, inicialmente só se trabalha com números. Os conceitos mais importantes apresentados são o conceito de expressão, a avaliação de expressões pelo método da substituição, a relação entres procedures e os processos que eles geram, recursão linear e iteração, recursão em árvore e, principalmente, procedures de primeira classe.


Acredite: funções de primeira classe são a primeira revolução, apresentada no SICP, na maneira de pensar sobre programação. Scheme tem como um de seus pilares o fato de possuir procedures de primeira classe. Isso quer dizer que os procedures podem ser passados como argumentos para outros procedures, e podem ser retornados a partir de outros. Tais propriedades possibilitam novas maneiras de organizar os programas, evitando a repetição de código e a repetição de ideias.

Capítulo 2 - Construindo Abstrações com Dados

Todas as funções do capítulo 1 trabalham apenas com números. No capítulo 2, são apresentadas as ideias de dados compostos, dados hierárquicos, sequências, dados simbólicos (não encontrados em muitas linguagens), entre outras coisas. Basicamente, o capítulo serve para dar uma "embaçada" na ideia de que "funções e dados são coisas diferentes". Você deve estar pensando: "é lógico que funções e dados são coisas diferentes, afinal de contas as funções (ativas) processam os dados (passivos)". Bem, isso não é bem verdade (não falo mais pra não estragar a surpresa). Quem ler o livro vai entender...

Além disso, o capítulo mostra a importância de programar para uma representação abstrata de uma determinada estrutura, e não para sua representação concreta. Mais ainda, mostra uma maneira de organizar programas a partir da introdução de camadas de abstração, onde cada camada superior tem acesso as camadas inferiores, por meio de interfaces bem definidas.


Capítulo 3 - Modularidade, Objetos e Estado

Nos dois primeiros Capítulos, em nenhum momento se escreve o equivalente ao que, em C (ou Java), seria "int i; i = 3;". Ou seja, não há nenhum assignment. Isso mesmo, são mais de 200 páginas, 120 exercícios (alguns complexos como codificação de Huffman ou mesmo o problema das 8 rainhas), e nenhum assignment. Isso tem o efeito de uma bela sacudida. Antes de ler o SICP pela primeira vez, em 2007, eu simplesmente não pensava que isso fosse possível.

Ao perceber que você programou bastante sem usar assignment algum, você vê que, ao contrário do que pensava, assignments não devem ser necessariamente utilizados em todos os lugares. Aliás, atualmente (ou melhor, finalmente), é considerado boa prática de programação evitar a mutação de dados (assignments ou setters), utilizando-a somente quando estritamente necessário. Há uma grande classe de bugs que simplesmente deixa de aparecer nos seus programas quando não há efeitos colaterais (mutações). Além disso, fica mais fácil raciocinar sobre operaçoes concorrentes em objetos imutáveis, bem como fica mais fácil a paralelização automática de programas sem efeitos colaterais. Portanto, vá em frente e passe a usar o modificador  final em todos os atributos de suas classes que não necessitem ser alterados.

Apesar de não ser verdadeira a ideia de que a única maneira de programar é com assignments, há situações em que eles são imprescindíveis. Para representar estados de objetos que mudam com o tempo, como o saldo na sua conta bancária, por exemplo assignments são necessário. Uma vez constatado esse fato, o livro mostra os benefícios e os custos da introdução de assignment, depois mostra as novas regras para avaliação de expressões (uma vez que os assignments são introduzidos, a regra da substituição fica inválida). Em seguida, são mostrados exemplos de estruturas de dados mutáveis, um simulador para circuitos digitais, e um propagados de restrições (muito legal).

As duas seções mais importantes do capítulo 3 são, em minha opinião, a seção sobre concorrência e a seção sobre streams. A seção sobre concorrência mostra a relação entre tempo e sistemas concorrentes, e apresenta mecanismos para controle da concorrência. A seção sobre streams é, na minha opinião, a segunda revolução no livro. Mostra como podem ser implementadas estruturas de dados que, aparentemente, são infinitas. Basicamente, você representa uma estrutura infinita como uma lista preguiçosa (lazy), em que os elementos são carregados somente quando seus valores são necessários. Usando streams, é possível expressar vários algoritmos de maneira mais elegante e expressiva.

Capítulo 4 - Abstração Metalinguística

O capítulo 4 explora um fato a que se dá pouca importância: O interpretador da sua linguagem é apenas mais um programa. Tende-se a ver interpretadores (e compiladores) como entidades mágicas, e ter com respeito a eles um comportamento passivo. Uma vez que o interpretador é apresentado como apenas mais um programa (e um que nem é tão difícil assim de programar), isso dá bastante poder ao programador, que agora está em par com o designer da linguagem. O programador não precisa se limitar a contentar com o que lhe foi dado, e pode experimentar com linguagens. O programador é o novo designer... Lisp sempre teve esse espírito, por exemplo através do sistema de macros. Embora o SICP não aborde macros, ele dá um vislumbre do que é possível uma vez que se tenha acesso de escrita (e não apenas leitura) à sua linguagem.

São desenvolvidos vários interpretadores. O primeiro é um simples interpretador metacircular. Depois, é feito um interpretador com lazy evaluation, em seguida implementa-se o operador amb de backtracking e, finalmente, desenvolve-se um mecanismo para programação lógica que, embora tenha sintaxe Lisp, tem semântica parecida com o Prolog.

Capítulo 5 - Computando com Máquinas de Registrador

O interpretador desenvolvido no capítulo 4 é de Scheme, para Scheme. O observador atento dispara: "assim não vale: como você vai implementar o primeiro interpretador de Scheme?". Além disso, alguém mais atento ainda pode verificar que, por exemplo, o interpretador metacircular só será tail-recursive se o interpretador original também o for. Ou seja, o nível é muito alto...

O capítulo 5 corrige de certa forma esse problema. É mostrado o design de máquinas de registradores, que podem ser encaradas como máquinas abstratas, cujo comportamento é mais próximo do de um computador real. Em seguida, mostra-se uma linguagem para descrever máquinas de registradores, desenvolve-se um simulador para uma máquina, apresentam-se conteúdos sobre alocação dinâmica e coletor de lixo, e, finalmente, mostra-se um compilador de Scheme.


Finalmente...

Como se pode ter uma ideia, o livro é bem completo. Começa em operações com números e chega até a compilação da linguagem. Em cada seção, novos conhecimentos são apresentados e testados com exercícios bastante "desafiadores". O livro sem dúvidas é pra quem quer ter um maior entendimento da área de computação como um todo, e não pra quem quer apenas aprender um novo truque. Por outro lado, os conhecimentos mostrados no SICP são perenes, e vão me acompanhar durante toda a minha carreira na área. Espero que meus posts sejam úteis para mais alguém. No próximo post, falo sobre os exercícios da seção 1.1 - Os elementos da programação.

Livro: SICP - Estrutura e Interpretação de Programas de Computador

Este é o primeiro de uma série de posts cujo objetivo é apresentar, em português, a solução da maioria dos exercícios do livro Structure and Interpretation of Computer Programs (carinhosamente chamado de SICP), de autoria de Harold Abelson e Gerald Jay Sussman. Além de resolver os exercícios, quando pertinente farei comentários sobre algumas seções do livro. Antes que eu me esqueça, o texto do livro está disponível, em sua integridade, gratuitamente. Veja a página na editora MIT Press ou mesmo a página do curso 6.001 do MIT. Mesmo que o livro esteja disponível gratuitamente, recomendo que comprem em papel, nem que seja apenas para prestigiar o trabalho magistral de Abelson e Sussman.


No passado, eu já li o livro e resolvi os exercícios dos dois primeiros capítulos. Além disso, assisti às aulas disponíveis em http://groups.csail.mit.edu/mac/classes/6.001/abelson-sussman-lectures/.


Dessa vez, resolvi resolver tudo (revisar minhas soluções antigas e postar as novas), e publicar minhas soluções para o livro inteiro. Estou fazendo isso por um conjunto de motivos:

1) Auto-edificação: resolver os dois primeiros capítulos me tornou um programador melhor (e mais produtivo, também). Ler o livro, então, nem se fala... Tenho certeza que os exercícios dos três últimos capítulos são igualmente ilustrativos. Os exercícios completam o livro. Eles são muito bem escolhidos, de modo que vale a pena resolvê-los. Não são fáceis, é verdade, mas cada um te faz aprender uma coisa nova... Não desperdiçam o seu tempo. Resumindo: estou resolvendo os exercícios em interesse próprio, para melhorar como profissional e porque sei que o investimento de tempo compensa.

2) Disciplina: a publicação das soluções exige mais disciplina e cuidado na hora de elaborá-las... além disso, me motiva a continuar: como estou comprometido a publicar, e antes de publicar tenho que fazer, vou acabar fazendo. Na improvável situação de esse blog ter leitores, espero que eles me cobrem e me mantenham na linha.

3) Propagar a palavra: O SICP é praticamente desconhecido no Brasil. Isso é uma pena, pois o livro é uma jóia. Arrisco dizer que, se todo mundo lesse o SICP primeiro, ficaria muito mais fácil ler todos os outros livros, já que o SICP é uma síntese de toda a Computação. Espero que, através desses posts, mais gente venha a conhecê-lo, e quem sabe resolver os exercícios também. Se convencer pelo menos uma pessoa a ler o livro e fazer os exercícios, considero minha missão cumprida.

No próximo post, vou fazer um resumão do livro e, no terceiro post, começo a mostrar as soluções. Fique ligado.