Capítulo 1 :


A Linguagem Scheme

Uma primeira visão que podemos ter de Scheme é como uma calculadora: escrevemos uma expressão e o interpretador Scheme nos dá o resultado. Portanto, nosso primeiro passo é ver como expressões são escritas em Scheme.

1.1 : Notação Pré-fixada

A maneira de escrevermos expressões em Scheme é a primeira vista um pouco estranha, diferente da maneira como escrevemos expressões matemáticas. Entretanto, vamos perceber que a notação de Scheme tem uma grande vantagem, que é a homogeneidade. Existe basicamente uma única regra que rege a escrita de qualquer expressão.

Em Scheme, quando queremos calcular o valor de uma função, escrevemos (f x), ao invés de f(x), como é usual em matemática. Se a função tiver vários parâmetros, como g(x,y), escrevemos

(g x y)

Os parênteses permitem o que chamamos de aninhamento de expressões. Assim, a expressão f(g(x)) é escrita como

(f (g x))

Os operadores aritméticos básicos (+, -, × , /), apesar de usualmente escritos com notação infixada, isto é, com o operador escrito entre os operandos, como em x + y, na verdade são funções como outras quaisquer, que têm como domínio os pares de reais e como imagem os reais. Em Scheme, esses operadores são escritos como as outras funções, no que é usualmente chamado de notação pré-fixada. Então, 5 + 3 se escreve em Scheme como

(+ 5 3)

e (5+3) × 2 se escreve como

(* (+ 5 3) 2)

Observe que, no computador, o operador de multiplicação × é usualmente escrito como *.

Exercício 1.1: Escreva as expressões abaixo na notação pré-fixada de Scheme, e execute-as:

  1. 2+3
  2. 8/2
  3. 4
  4. 4.2 + 3.8
  5. 2 × 4+3
  6. 2+4 × 3
  7. (2+4) × 3
  8. 9 × (5-2)
  9. sin 3.1415
  10. 1+2+3
  11. 1+2+3+4+5+6+7+8+9
  12. 1 × 2 × 3 × 4 × 5 × 6 × 7 × 8 × 9
  13. (1+2+3+4+5) - 6
  14. (2+4) × (3+5)
  15. (9-5) × (8-3)
  16. (9/3) + (3*2)
  17. sin 4.5 + cos 3.7
  18. log 4.5 + log 3.2

Exercício 1.2: Escreva as expressões abaixo em notação matemática usual:

  1. (+ 3 2)
  2. (- 9 5)
  3. (+ 5 (/ 8 2))
  4. (* 3.1 (+ 2 5.4))
  5. (sin 3.14)
  6. (* 2 -0.7 5)
  7. (* (+ 3 4) 2)
  8. (+ 4 (* 5 (/ 15 3)))
  9. (+ (* (/ 15 3) 5) 4.5)
  10. (* (+ 2 3) (+ 4 5))
  11. (+ (/ 27 3) (* 3 2))
  12. (* (+ 5 (+ 2 3)) 4)
  13. (* (+ (+ 2 3) 4) (sin 5))
  14. (/ (* (+ 8 a) (- b 4)) 2)
  15. (/ (* (+ (- (+ 7 6) 5) 4) 3) 2)
  16. (sin (* 2 x))
  17. (+ (sin x) (cos x))
  18. (sin (+ x (cos x)))

Por diversas razões, é bastante comum em computação trabalharmos com números inteiros, ao invés de números reais. O conjunto de inteiros é fechado em relação às operações de soma, subtração e multiplicação (isto é, a soma, subtração e multiplicação de inteiros resultam sempre em inteiros), mas não em relação à divisão. Por isso as vezes iremos usar dois outros operadores aritméticos: um será escrito como a ÷ b, que denota a parte inteira da divisão a/b; o outro é escrito como a mod b, denotando o resto da divisão de a por b. Em geral, temos que

a = (a÷ b) × b + (a mod b)

Em Scheme, a ÷ b pode ser calculado com (quotient a b), e a mod b com (remainder a b).

Exercício 1.3: Escreva as expressões abaixo na notação pré-fixada de Scheme, e execute-as:

  1. 4 ÷ 2
  2. 5 ÷ 2
  3. 1 ÷ 2
  4. 1 mod 2
  5. 20 mod 3
  6. (5/2)-(5 ÷ 2)
  7. (1345 ÷ 17) × 17 + (1345 mod 17)

1.2 : Um pouco de Sintaxe

Tudo que escrevemos em Scheme é chamado genericamente de expressão S, ou mais simplesmente expressão. As expressões que escrevemos em Scheme podem ser classificadas em dois tipos: átomos e listas. Os átomos podem ser de três tipos (por enquanto): numerais, por exemplo 4, -3.56; textos (strings, em inglês), que são sempre escritos entre aspas, como "Um exemplo de texto"; e símbolos, por exemplo a, define, nil?, +. Uma lista, por sua vez, é uma sequência de expressões entre parênteses, separadas por espaços; por exemplo (+ 3 4), (define a (+ 3 x)), (3 4 5), (display "alo mundo") e ((3 4) (2 3) () ((9) +)).

Exercício 1.4: Quais dos exemplos a seguir não são expressões sintaticamente corretas? Por quê?

  1. (a b
  2. ()
  3. (((a)))
  4. a b
  5. (a b) c
  6. (a b (c))
  7. "(a b"
  8. a b)
  9. (((a b) c) (d))

Na sintaxe de numerais, vale a pena observar o uso do ponto decimal (como em inglês), ao invés da vírgula, e a possibilidade de usar notação científica para números muito grandes ou muito pequenos. Com essa notação, um valor como 3.5 × 10- 7 pode ser escrito como 3.5e-7.

Em geral, espaços entre os diversos símbolos de uma expressão só são relevantes para separar um símbolo de outro. Assim, as expressões abaixo são equivalentes:

(a b (c)) ( a b ( c) )

enquanto essas não são:

(a b (c)) (ab (c))

pois a b é interpretado como dois símbolos seguidos, enquanto ab é considerado como um único símbolo. Neste texto, apenas por convenção, vamos usar apenas um único espaço entre duas expressões seguidas; assim, escreveremos (a b (c d)), e não ( a b( c d ) ). Qualquer expressão pode ser quebrada em várias linhas; para o interpretador, uma mudança de linha equivale a um espaço em branco. Em geral, o uso de quebras de linha pode ajudar na leitura de expresões mais complexas.

Finalmente, podemos comentar expressões. Por enquanto, como estamos usando a linguagem apenas como uma calculadora, comentários não têm muita utilidade. Mas mais adiante, quando passarmos a definir funções mais complexas, comentários passam a ser uma importante forma de colocarmos explicações adicionais. Um comentário é qualquer texto colocado em uma linha após um ponto-e-vírgula (`;'), e é totalmente ignorado pelo interpretador.

1.3 : Um pouco de Semântica

Cada uma das formas possíveis de expressões de Scheme tem um valor associado; geralmente, escrevemos uma expressão para que o intepretador avalie esse valor.

Podemos representar o processo de cálculo que Scheme usa como um processo de reescrita: a cada passo da avaliação, uma parte da expressão é substituída por seu valor, seguindo determinadas regras. A medida que formos introduzindo novas construções em Scheme, iremos definindo novas regras de como essas construções são reescritas. Por enquanto, temos quatro regras, uma para cada tipo de expressão: A sequência de substituições que uma lista sofre até chegar ao seu valor final é chamada de traço.


Exemplo 1.1: Vamos acompanhar os passos que Scheme dá para calcular o valor da expressão abaixo:

(+ (* 8 -5) 7.3 (* 2 7.3))

(+ (* 8 -5) 7.3 (* 2 7.3))
executa multiplicação
(+ -40 7.3 (* 2 7.3))
executa multiplicação
(+ -40 7.3 14.6)
executa soma
-18.1 resultado final

A cada passo, podemos colocar ao lado um pequeno texto justificando aquele passo. Em geral, essas explicações não são necessárias, sendo usadas somente em situações mais complexas. Da mesma forma, na medida em que ganhamos familiaridade com a linguagem, passos intermediários podem ser pulados, principalmente quando envolvem apenas operações aritméticas.


É importante ressaltar que, dada uma expressão, não existe uma ordem fixa para seu traço. Por exemplo, os dois passos abaixo são igualmente válidos:

(+ (* 8 -5) 7.3 (* 2 7.3)) (+ -40 7.3 (* 2 7.3))

(+ (* 8 -5) 7.3 (* 2 7.3)) (+ (* 8 -5) 7.3 14.6)

Independentemente da ordem usada, o resultado final será sempre o mesmo (por que?).

A regra de avaliação de listas acima pode ser quebrada pelas chamadas formas especiais. O exemplo mais característico de uma forma especial é o operador quote. Esse operador tem a propriedade de não calcular seu único parâmetro.

Exercício 1.5: Experimente os exemplos abaixo:

  1. (quote a)
  2. (quote (+ 3 5))
  3. (+ 3 5)
  4. (quote (sin (+ 3.45 (* 5.65 23.4))))
  5. (quote (mesa cadeira piso))
  6. (quote 4.5)

Exercício 1.6: O operador quote é tão utilizado que existe uma sintaxe especial para ele; experimente:

  1. 'a
  2. '(a b c)
  3. '(a 'b c)
  4. '(a (b) (c d))
  5. '(* (+ 3 2) (- 9 8))

1.4 : Definições

Uma primeira forma de abstração é darmos nomes às coisas. Em Scheme, usamos o operador define para isso. Por exemplo, (define x 3) associa x ao valor 3. Também podemos dizer que a variável x tem valor 3. A partir daí, quando calculamos o valor da expressão x, o valor resultante será 3. Como o valor definido pode ser usado em qualquer expressão após a execução do define, essas definições são chamadas de definições globais.


Exemplo 1.2: Vamos acompanhar os passos que Scheme dá para calcular o valor da expressão

(+ (* 8 b) a (* 2 a))

assumindo que a está definido como 7.3, e b como -5.

(+ (* 8 b) a (* 2 a))
valor de b
(+ (* 8 -5) a (* 2 a))
valor de a
(+ (* 8 -5) 7.3 (* 2 a))
valor de a
(+ (* 8 -5) 7.3 (* 2 7.3))
executa multiplicação
(+ -40 7.3 (* 2 7.3))
executa multiplicação
(+ -40 7.3 14.6)
executa soma
-18.1 resultado final


O conjunto de associações de nomes com valores em vigor para uma determinada expressão é chamado de ambiente. Neste texto, vamos usar a notação

{n1 v1,n2 v2,..., nn vn}

para representar um ambiente onde o nome ni está associado ao valor vi. Por exemplo, após as definições
      (define aro 28)
      (define arco (+ (* aro 3) 5))
teremos o ambiente global {aro 28, arco 89}.

Exercício 1.7: Qual o efeito das declarações abaixo, executadas em ordem?

  1. (define pi 3.1415927)
  2. (define raio 24.56)
  3. (define area (* pi (* raio raio)))
  4. (define nome "Maria Clara")
  5. (define a +)

Uma outra forma de darmos nomes a valores é com a construção let. Essa construção permite darmos nomes locais a valores. Ao contrário de um define, que define nomes globais, esses nomes só são válidos dentro da expressão onde eles são definidos, não interferindo com outras partes de um programa. A sintaxe geral de um let é:
      (let ([nome1 exp1]
              ...
            [nomen expn])
              corpo)
observação: na construção acima, e em algumas outras construções ao longo do texto, iremos usar colchetes [...] no lugar de parênteses (...), de modo a deixar mais claro sua estrutura. Entretanto, em qualquer uso real esses colchetes devem ser substituídos por parênteses.

Quando executamos um let, primeiro todas as expressões exp_i são calculadas, e associadas aos respectivos nomes, criando um ambiente local. Em seguida, o corpo é calculado, trocando-se cada ocorrência de nome_i pelo valor correspondente no ambiente.


Exemplo 1.3: Para calcularmos o valor da expressão
      (let ([x (+ 9 8)]     ;lembre-se: trocar [...] por (...)
            [y (sin 10)])
         (+ (* x y) y))
executamos

(let ((x (+ 9 8)) (y (sin 10))) (+ (* x y) y))
soma e seno
(let ((x 17) (y -0.544021)) (+ (* x y) y))
corpo do let
(+ (* x y) y) {x 17,y -0.544021}
ambiente local
(+ (* 17 -0.544021) -0.544021)
aritmética
-9.79238 resultado final

A notação

(+ (* x y) y) {x 17,y -0.544021}

usada no traço acima, significa que a expressão (+ (* x y) y) deve ser executada no ambiente local {x 17,y -0.544021}.


É importante lembrar que as variáveis definidas em um let só podem ser usadas no corpo do let; em particular, uma variável não pode ser usada na definição de outra variável, como na expressão abaixo:
      ; código incorreto!
      (let ((x (sin 10))
            (y (* x (+ x 10))))
           (- x y))
Se necessário, tais situações podem ser tratadas com lets aninhados, como no exemplo abaixo.


Exemplo 1.4: O corpo de um let pode conter outra expressão let, como no caso abaixo:
      (let ((x (sin 10)))
           (let ((y (* x (+ x 10))))
             (- x y)))
O traço dessa expressão segue a mesma regra do let:

(let ((x (sin 10))) (let ((y (* x (+ x 10)))) (- x y)))
seno
(let ((x -0.544)) (let ((y (* x (+ x 10)))) (- x y)))
corpo do 1o let
(let ((y (* x (+ x 10)))) (- x y)) {x -0.544}
valor de x
(let ((y (* -0.544 (+ -0.544 10)))) (- -0.544 y))
aritmética
(let ((y -5.14425)) (- -0.544 y))
corpo do let
(- -0.544 y) {y -5.14425}
valor de y
(- -0.544 -5.14425)
aritmética
4.60023


Os principais usos da construção let são na simplificação de expressões muito complexas e para colocar em evidência sub-expressões comuns. Por exemplo, considere a expressão

(- (sin (+ a (* b c))) (cos (+ a (* b c))))

Com esse código, não só escrevemos duas vezes a sub-expressão (+ a (* b c)), como fazemos o computador calculá-la duas vezes. Se reescrevermos a expressão como

(let ((x (+ a (* b c)))) (- (sin x) (cos x)))

eliminamos os dois problemas.

Exercício 1.8: Calcule o valor das expressões abaixo:
1

  1. (let ((x 10) (y 5)) (* (+ x y) (- x y)))
  2. (let ((x (sin 5))) (* x x))
  3. (+ 10 (let ((x (sin 5))) (* x x)))
  4. (let ((x 10)) (let ((y (+ x 5))) (* x y)))
  5.       (let ((delta (- (* b b) (* 4 a c)))
                (dois-a (* 2 a)))
                  (/ (+ (- b) (sqrt delta)) dois-a))
    
    assumindo que a, b e c estejam definidos como -1, 1 e 2 respectivamente.

Observe como, em todas essas expressões, os nomes x, y, etc não precisavam estar definidos previamente; por outro lado, no caso de haver um define prévio de qualquer desses nomes, após a expressão o nome continua representando o valor que representava antes, pois um nome definido por um let só é válido dentro do corpo do let.

Exercício 1.9: Primeiro, defina globalmente (com defines) o ambiente {x 5, y 10}. Em seguida, calcule o valor das expressões abaixo:

  1. (+ x (let ((x (sin 5))) (* x x)))
  2. (- (let ((x (sin 5))) (* x y)) x)

1.5 : Listas

Muitas vezes precisamos trabalhar com um conjunto de dados com alguma estrutura. Exemplos típicos são matrizes de números ou conjuntos. Uma linguagem de programação deve oferecer formas de estruturarmos os dados básicos (como números), que nos permitam criar dados mais complexos.

Em Scheme, o mecanismo básico de estruturação de dados é a lista. Já vimos listas anteriormente, quando vimos a sintaxe de Scheme; agora vamos ver como podemos manipular listas em um programa.

Listas representam sequências de valores. Como listas também são valores, podemos ter listas como elementos de outras listas; essas listas são chamadas aninhadas. É importante perceber o significado de aninhamentos; por exemplo, a lista (10 20 40) tem 3 elementos, 10, 20 e 40, enquanto a lista ((10 20 40)) tem apenas 1 elemento, que é a lista (10 20 40).

Uma primeira forma de criarmos listas é com o operador quote, que também já vimos anteriormente. Outra forma é com a função (cons a b), que acrescenta o elemento a no início da lista b. Por exemplo,

(cons 3 '(4 5)) (3 4 5)

Vale a pena ressaltar que cons sempre precisa de uma lista como seu segundo argumento.2 Portanto, para criarmos uma lista com um único elemento x, devemos escrever (cons x '()), colocando esse elemento no início de uma lista vazia; e para criarmos uma lista com dois elementos x e y, devemos escrever (cons x (cons y '())), e não (cons x y). Em geral, qualquer lista pode ser construída por sucessivas aplicações de cons sobre a lista vazia. Por exemplo, a lista (4 10 23 1) pode ser criada pela expressão

(cons 4 (cons 10 (cons 23 (cons 1 '()))))

Igualmente, listas aninhadas também podem ser construídas somente com cons. Por exemplo,

(cons (cons 4 (cons 5 '())) (cons 2 '())) ((4 5) 2)

Quando queremos escrever uma lista diretamente, com valores fixos, é mais conveniente usarmos quote. Assim, uma lista como (4 10 23 1) pode ser escrita diretamente em Scheme precedida pelo quote: '(4 10 23 1). Note, entretanto, que o quote só serve para construirmos listas com valores constantes.

Exercício 1.10: Qual o valor das expressões abaixo?

  1. (cons 2 '())
  2. (cons 1 (cons 2 '()))
  3. (cons 1 (cons 2 (cons 3 '())))
  4. (cons 'China '(Uruguai Grecia))
  5. (cons '() '())
  6. (cons '() '(a b c))
  7. (cons '(a b c) '(d))
  8. (let ((x 'Paris) (y '(Roma Berlin))) (cons x y))
  9. (let ((x 'Paris) (y '(Roma Berlin))) (cons 'x y))
  10. (let ((n '())) (cons (cons 3 n) n))
  11. (cons 2 (let ((x '())) (cons x x)))
  12. (let ((x '(y z))) (cons 'x x))

Exercício 1.11: Usando apenas números, cons e a lista vazia, escreva expressões que resultem nas listas abaixo:

  1. (2 4)
  2. ((2 4))
  3. (((2 4)))
  4. ((2 4) (3 5))

Para `desmontarmos' uma lista, temos duas funções, que fazem o inverso de cons: car e cdr. car retorna o primeiro elemento de uma lista, enquanto cdr retorna o resto da lista, isto é, a lista sem o seu primeiro elemento. Por exemplo, se definirmos l como sendo a lista (a b c), então (car l) será a e (cdr l) será (b c). Em geral, se b é uma lista qualquer, temos que

(car (cons a b)) a

(cdr (cons a b)) b

Note que o cdr de uma lista é sempre uma lista. Se x é uma lista não vazia, temos também que

(cons (car x) (cdr x)) x

Exercício 1.12: Qual o valor das expressões abaixo?

  1. (car '(a b))
  2. (cdr '(a b))
  3. (car '(a))
  4. (car '((a)))
  5. (cdr '(a))
  6. (cdr '((a)))
  7. (let ((l '(lua sol marte))) (cdr l))
  8. (let ((l '(lua sol marte))) (car (cdr l)))
  9. (let ((l '(lua sol marte))) (car (cdr (cdr l))))
  10. (let ((lista '((a1 b1) (a2 b2) (a3 b3)))) (car (cdr (car lista))))

Apesar de car só acessar o primeiro elemento de uma lista, combinando aplicações de car e cdr podemos acessar qualquer parte de uma dada lista. Por exemplo, o segundo elemento de uma lista l pode ser acessado com a expressão (car (cdr l)). Para simplificar essas composições, Scheme oferece algumas formas compactas, como exemplificado abaixo:

(car (cdr l)) (cadr l)

(car (car l)) (caar l)

(cdr (car l)) (cdar l)

((car (car (car l)))) (caaar l)

Essas formas compactas existem para qualquer combinação de até quatro níveis de car com cdr.

Exercício 1.13: Defina l como sendo a lista ((a b c) (e f) g). Diga qual forma compacta deve ser usada para:

  1. extrair a de l;
  2. extrair b de l;
  3. extrair c de l;
  4. extrair e de l;
  5. extrair f de l;
  6. extrair g de l.

1.6 : Definindo Funções

Uma segunda forma de abstração, bem mais poderosa que o uso de nomes, é a definição de funções. Em matemática, escrevemos f(x) = 2x para definir uma função f que leva um número ao seu dobro. Em Scheme, essa função seria definida como:

(define f (lambda (x) (* 2 x)))

A construção lambda usada acima serve exatamente para criarmos funções. O construtor lambda atua sobre uma lista com os parâmetros da função e um corpo que calcula o valor da função. No nosso exemplo, o único parâmetro é x, e o corpo é a expressão (* 2 x). O define é usado como sempre, para dar um nome à função criada com o lambda.3

Uma vez definida, uma função pode ser usada como qualquer outra em Scheme, bastando escrever uma lista com seu nome seguido dos eventuais argumentos; após definir f como acima, experimente executar (f 3). De maneira geral, sempre que escrevemos uma nova função devemos testá-la, isto é, aplicá-la sobre alguns valores para verificar se ela está correta.

Funções podem ter zero, um, ou mais parâmetros. Por exemplo, uma função para tirar a média entre três números pode ser escrita como

(define media (lambda (x y z) (/ (+ x y z) 3)))

e usada, por exemplo, na expressão (media 5.7 6.3 8.1). Como terminologia, dizemos que chamamos a função media passando como parâmetros os valores 5.7, 6.3 e 8.1; a função media, quando chamada, recebe esses valores e retorna um resultado.

Exercício 1.14: Defina (e teste) as seguintes funções em Scheme:

  1. dobro(x) = 2x
  2. quadrado(x) = x2
  3. incrementa(x) = x+1
  4. decrementa(x) = x-1
  5. f(x,y) = x+y (adição)
  6. f(x) = 3 (função constante)
  7. f(x,y) = 10 (função constante com dois parâmetros)
  8. uma função que retorne a área da circunferência para um dado raio;
  9. uma função que retorne o perímetro da circunferência para um dado raio;
  10. f(x,y) = 2x + 5y
  11. f(x,y,z) = 10x + 5y - z
  12. f(x) = (sin x)2 + (cos x)2
  13. uma função que retorne o segundo elemento de uma dada lista;
  14. uma função que retorne o terceiro elemento de uma dada lista;
  15. uma função que receba uma lista de números e retorne a soma do primeiro com o segundo elemento;
  16. uma função que receba dois valores e retorne uma lista com esses valores;
  17. uma função que calcule a raiz de uma equação de 2o grau:

    (dica: use let para calcular o valor intermediário delta = b2 - 4ac.)

Assim como em matemática, cada função em Scheme é limitada a um determinado domínio; não podemos aplicar qualquer função a qualquer valor. Por exemplo, chamadas como (sin '(a b)) ou (car 4), se executadas, irão resultar em situações de erro. Da mesma forma, as funções em Scheme tem um contra-domínio, que é o conjunto de valores que a função pode retornar. Por exemplo, o contra-domínio da função cons é o conjunto de todas as listas. As condições que os argumentos de uma função têm que satisfazer para que a função opere corretamente são chamadas de pré-condições da função. Por exemplo, a pré-condição de (sin x) é que x seja um número real, ou seja, x . Já a pré-condição da função dada no exercício
1.14.n é que seu argumento seja uma lista com comprimento maior ou igual a 2; de outra forma, não podemos retornar seu segundo elemento. Muitas vezes, não explicitamos as pré-condições de uma função. No entanto, é muito importante que sempre tenhamos conciência do domínio de validade de qualquer função que usamos. Muitos erros em programas são causados por funções sendo chamadas com argumentos inválidos ou não previstos.

Exercício 1.15: Descreva as pré-condições de cada uma das funções definidas no exercício anterior. Descreva também os domínios e contra-domínios dessas funções.

Conforme explicado acima, o define e o lambda tem significados independentes, e são muitas vezes usados separadamente. O lambda cria funções, enquanto o define nomeia valores que, em particular, podem ter sido criados por uma expressão lambda. Por outro lado, é extremamente comum usarmos a combinação
      (define f
        (lambda (x y)
            ... ))
Nesses casos, podemos reescrever a definição da função como
      (define (f x y)
            ... )
É importante frizarmos que essa segunda forma tem exatamente o mesmo significado da primeira, sendo apenas uma maneira mais compacta de escrevermos uma mesma coisa. De forma a deixar mais claro o papel de cada construção, neste texto vamos procurar usar a notação básica, escrevendo o lambda explicitamente.

1.7 : Semântica de Funções

Quando reescrevemos uma expressão, o que ocorre com uma função? Por exemplo, se tivermos uma definição como

(define media (lambda (x y) (/ (+ x y) 2)))

como fazer o traço da expressão (media (* 8 9) 3)? Primeiro, podemos seguir as regras normais:

(media (* 8 9) 3)
multiplicação
(media 72 3)
valor de media
((lambda (x y) (/ (+ x y) 2)) 72 3)

Note como substituimos media por seu valor, como fazemos com qualquer símbolo. Em seguida, aplicamos uma nova regra: uma lista cujo primeiro valor seja uma expressão lambda pode ser substituída pelo corpo da expressão lambda; no exemplo acima, o corpo da função é (/ (+ x y) 2). Esse corpo terá como ambiente local a associação dos argumentos com os parâmetros correspondentes; no nosso exemplo, os parâmetros são x e y, e os argumentos são 72 e 3, resultando no ambiente {x 72, y 3}. Aplicando a regra toda, temos:

((lambda (x y) (/ (+ x y) 2)) 72 3)
corpo do lambda
(/ (+ x y) 2) {x 72, y 3}
valores de x e y
(/ (+ 72 3) 2)
aritmética
37.5 resultado final

Vale a pena observar que existe uma similaridade entre as construções lambda e let. Ambas avaliam seu corpo substituindo nele nomes locais por seus valores. Entretanto, no let esses valores são definidos na própria expressão, enquanto no lambda os valores dos nomes locais só são definidos quando a função é chamada. Em geral, qualquer let poderia ser reescrito segundo a regra abaixo:

(let ([n1 e1] ... [nn en]) corpo) ((lambda (n1 ... nn) corpo) e1 ... en)

Exercício 1.16: Escreva os traços das expressões abaixo:

  1. ((lambda (x y) 10) 3 (* 8 3))
  2. ((lambda (x y) (let ((z x)) (+ z y))) 15 23)
  3. ((lambda (x) (* x x)) (let ((x 2)) (+ x 4)))
  4. (((lambda (x) (lambda (y) (- x y))) 4) 5)

As regras colocadas acima para traços de chamadas de funções devem ser usadas apenas quando estamos interessados no funcionamento da função, por exemplo quando estamos testando se ela funciona corretamente. Uma vez que uma função esteja definida corretamente, e tenhamos entendido bem o que ela faz, podemos passar da sua chamada ao resultado final em apenas um passo. Quando usamos a função media em outras expressões mais complexas, podemos em um único passo substituir a sub-expressão (media x y) pela média aritmética de x e y. Na realidade, já fazíamos isso com outras funções: quando escrevemos algo como (sin x), existe em algum lugar da máquina uma definição de como se calcula um seno. Mas para nós a chamada é abstrata, e substituimos direto (sin x) pelo seno de x. Essa é a grande capacidade de abstração que funções proporcionam: uma vez (corretamente) definida, podemos usar qualquer função da mesma forma que qualquer outra operação da nossa linguagem, sem nos preocuparmos em como ela foi escrita e/ou como funciona.

Vale a pena lembrarmos que tal raciocínio abstrato só é válido quando a função está definida corretamente. Caso contrário, esse raciocínio pode nos criar enormes dificuldades de entendimento; imagine você tentando saber por que uma fórmula está dando um valor incorreto, quando a função sin que você está usando é que está retornando um valor errado. Por isso, é extremamente importante que toda nova função, uma vez definida, seja testada adequadamente, para podermos ter alguma confiança na sua corretude.

Nunca é demais enfatizar a importância de mecanismos de abstração: eles formam a essência do poder de um computador. Saber programar significa saber explorar esses mecanismos, por exemplo definindo novas funções de forma a estender a capacidade de uma linguagem ou de um programa. Não só linguagens de programação oferecem esses mecanismos: até mesmo editores de texto possibilitam a criação de abstrações úteis. Saber explorar esses mecanismos é o que distingue um expert de um usuário medíocre.

1.8 : Predicados

Existe uma classe de funções em matemática que retornam como valor simplesmente verdadeiro ou falso, isto é, tais funções apenas testam uma determinada propriedade. Exemplos comuns são os operadores de comparação, como x y e x y, e pertinência em conjuntos (x Y). Chamamos esse tipo de função de predicado. Predicados em Scheme são escritos como qualquer outra função, mas retornam valores especiais que representam verdadeiro ou falso. Em Scheme, o valor verdadeiro é denotado pelo símbolo #t, e o valor falso pelo símbolo #f.4 Em outras palavras, predicados são funções cujo contra-domínio é o conjunto {#f, #t}.

A comparação x<y pode ser escrita em Scheme como (< x y). Nessa expressão, < é uma função que retorna #t ou #f, dependendo se x é ou não menor que y. Como o teclado normal de um computador não possui o símbolo , este é escrito como <=. Assim, x y vira (<= x y). Da mesma forma, o predicado é escrito como >=. Muitas linguagens de programação usam essa mesma sintaxe para esses operadores.

Existem diversos predicados que operam sobre átomos e listas em geral. De especial importância para nós é o predicado null?, que testa se uma lista é vazia. Note que, por convenção, nomes de predicados costumam terminar com um ponto de interrogação.

Outros predicados bastante úteis são os de igualdade. Para compararmos se dois números são iguais, usamos o predicado =; para outros tipos de átomos, usamos o predicado equal?.

Exercício 1.17: Qual o valor das expressões abaixo?

  1. (null? '(a))
  2. (null? '())
  3. (null? '(()))
  4. (let ((l '(()))) (null? (car l)))
  5. (let ((x (* 8 5))) (= x 40))
  6. (let ((x 'abobora)) (equal? x 'abobora))
  7. ((lambda (x) (equal? x "Jose")) "Jose")
  8. ((lambda (x y) (= x y)) 1 1.0)

Scheme define vários outros predicados úteis. Ao longo do texto, vamos usar alguns deles:

Como predicados são funções, podemos definir novos predicados da mesma forma que definimos funções, com o construtor lambda.


Exemplo 1.5: O predicado zero?, se não fosse pré-definido, poderia ser escrito como

(define zero? (lambda (x) (= x 0)))



Exemplo 1.6: O predicado abaixo testa se um número é divisível por outro, verificando se o resto da divisão de um pelo outro é zero:
      (define divide?
        (lambda (x y)
          (zero? (remainder x y))))


Exercício 1.18: Defina os seguintes predicados em Scheme:

  1. um predicado para verificar se um número é múltiplo de 3;
  2. um predicado para verificar se um número é maior que 5;
  3. um predicado para verificar se um número é maior ou igual a 5;
  4. um predicado para verificar se um número não é maior ou igual a 5;
  5. um predicado para verificar se um número é menor que .

1.9 : Condicionais

Muitas vezes, precisamos descrever uma função por casos. Um exemplo tradicional é a função de valor absoluto, em que f(x) é x se x 0, e -x caso contrário. Traduzindo a expressão acima para Scheme, teremos:
      (define f
        (lambda (x)
          (if (>= x 0) x (- x))))
O if é um operador que recebe três argumentos; dependendo do primeiro argumento ser verdadeiro ou falso, ele escolhe seu valor entre o segundo e o terceiro. No exemplo acima, se (>= x 0) for verdadeiro, o valor do if será o valor de x; caso contrário, o valor do if será o valor de (- x). A semântica do if pode ser dada pelas suas regras de reescrita:

(if '#t b c) b

(if '#f b c) c

Note que só podemos reescrever um if após termos calculado sua condição.

Quando precisamos testar entre mais de dois casos, usamos o que se chama aninhamento de ifs: um dos casos do if (ou os dois) é um outro if.


Exemplo 1.7: A função abaixo retorna o sinal de um número: -1 se ele é negativo, 0 se ele é 0, e +1 se ele é positivo:
      (define sig
        (lambda (x)
          (if (< x 0)
              -1
              (if (> x 0) 1 0))))
O traço da chamada (sig 3) pode ser escrito como abaixo:

(sig 3)
definição de sig
((lambda (x) (if (< x 0) -1 (if (> x 0) 1 0))) 3)
lambda, com {x 3}
(if (< 3 0) -1 (if (> 3 0) 1 0))
comparação
(if '#f -1 (if (> 3 0) 1 0))
regra do if
(if (> 3 0) 1 0)
comparação
(if '#t 1 0)
regra do if
1


Exercício 1.19:
Defina e teste as seguintes funções em Scheme. Teste cada função com diversos valores, correspondentes aos diversos casos:

  1. (dica: use let para calcular x2 uma única vez.)

Outra construção para expressões condicionais é o cond, que é uma espécie de generalização do if para vários casos. A forma geral do cond é
      (cond [c1 e1]
            [c2 e2]
              ...
            [cn en]
            [else e])
observação: novamente usamos colchetes [...] no lugar de parênteses (...), de modo a deixar mais claro o formato do cond. Novamente, em qualquer uso real esses colchetes devem ser substituídos por parênteses.


Exemplo 1.8: A função sig, definida no exemplo
1.7, poderia ser redefinida com cond, conforme mostrado abaixo:
      (define sig
        (lambda (x)
          (cond
            [(< x 0) -1]    ;lembre-se: trocar [...] por (...)
            [(> x 0)  1]
            [else     0])))


A semântica do cond pode ser dada pelas regras:

(cond ('#t e) ...) e

(cond ('#f e) (c e') ...) (cond (c e') ...)

(cond (else e)) e

Isso é, calculamos a primeira condição; se seu valor for verdadeiro, o valor do cond será o valor da expressão correspondente. Caso seu valor seja falso, a opção é descartada e passamos para a opção seguinte. Caso nenhuma das condições seja verdadeira, todas serão descartadas e a última expressão, associada ao símbolo else, será usada.

As construções if e cond são de certo modo equivalentes, e tudo que pode ser escrito com uma pode ser escrito com a outra:

(if a b c) (cond (a b) (else c))

(cond (c1 v1) (c2 v2) (else v)) (if c1 v1 (if c2 v2 v))

Para verificarmos a primeira equivalência, basta vermos que ambas as expressões são sempre reduzidas para o mesmo resultado. Quando a for falso, temos pela regra do if que:

(if '#f b c) c

Por outro lado, usando as regras do cond, temos:

(cond ('#f b) (else c)) (cond (else c)) c

Quando a é verdadeiro, temos:

(if '#t b c) b

(cond ('#t b) (else c)) b

A segunda equivalência pode ser mostrada de maneira semelhante.

Neste texto, vamos procurar usar if sempre que tivermos apenas dois casos, e cond nas outras situações. Lembre-se que qualquer função pode ser escrita de diversas maneiras. As vezes, uma maneira pode ser `melhor', ou por ser mais simples, ou mais fácil de entender, ou mais eficiente. Outras vezes, a escolha é apenas uma questão de gosto e hábito.

Exercício 1.20: Reescreva as funções do exercício 1.19 usando cond no lugar de if.

Para juntarmos dois ou mais testes, usamos os chamados operadores lógicos. Por exemplo, o teste i < j < l tem na verdade duas comparações, de i com j e de j com l. Estamos testando se ambas são verdadeiras, então unimos os testes com um and (`e' em inglês). Ou seja, o teste seria (i < j) e (j < l). Na notação pré-fixada, (and (< i j) (< j l)). As regras de reescrita para o and são:

(and #f a) #f

(and #t a) a

Da mesma forma que o and testa se duas (ou mais) condições são simultaneamente verdadeiras, o or (`ou' em inglês) testa se alguma condição é verdadeira. Assim, o teste

(or (< i j) (> i k))

verifica se i é menor que j ou maior que k. As regras de reescrita para o or são:

(or #f a) a

(or #t a) #t

Finalmente, existe o operador not (`não'), que inverte o resultado de um teste. Por exemplo, (not (< i j)) é equivalente a (>= i j), enquanto (not (= i j)) testa se i e j são números diferentes.

Note que, em geral, se a e b são duas expressões quaisquer, temos que:

(and a b) (if a b #f)

(or a b) (if a #t b)

(not a) (if a #f #t)

ou seja, todos esses operadores lógicos podem ser substituídos pelo if. Usualmente, essas equivalências são usadas na outra direção, substituindo-se ifs por operadores lógicos, por resultar em expressões mais simples. Temos também que:

(if (not a) b c) (if a c b)

isto é, sempre podemos trocar a ordem dos casos em um if negando sua condição. De modo geral, costumamos tratar primeiro o caso mais simples.

Exercício 1.21: Escreva em Scheme os predicados (funções) para as propriedades abaixo, e teste-os. Não use if nem cond.

  1. número está no intervalo [3, 50];
  2. número está no intervalo [3,30);
  3. número está fora do intervalo [3,30);
  4. número é par maior que 10;
  5. número é impar entre 5 e 15;
  6. lista tem pelo menos dois elementos;
  7. os dois primeiros elementos de uma dada lista são iguais.

Exercício 1.22: Reescreva os predicados do exercício anterior sem usar and, or, ou not. Troque-os por ifs ou conds.

Exercício 1.23: Reescreva os predicados abaixo, sem usar o operador not:

  1. (not (< x 0))
  2. (not (>= x y))
  3. (not (and (> x 5) (<= x 10)))
  4. (not (or (<= x 5) (> x 10)))

   Figura 1.1: Tabela (fictícia) de imposto de renda devido.

Exercício 1.24: Escreva uma função que, dado o total dos rendimentos tributáveis de uma pessoa, retorne o valor do imposto devido. Baseie sua função na tabela fictícia mostrada na figura
1.1.

Por exemplo, o imposto devido por alguém que teve um total anual de rendimentos tributáveis igual a R$53.200 será igual a .



Capítulo 2 :


Recursão

Até agora, vimos como definir funções compondo outras funções já existentes, e/ou definindo casos, com ifs e conds. Com esse arsenal de mecanismos, ficamos muito dependentes de que funções o sistema nos oferece, pois nosso poder para criar novas funções ainda é muito pequeno. Como exemplo padrão, considere a função fatorial:

n! = n × (n-1) × ... × 1

Apesar da nossa linguagem ter um operador de multiplicação, não temos como expressar uma multiplicação envolvendo um número variável de fatores. Como podemos definir uma função como essa?

Uma primeira forma seria termos a função fatorial pré-definida na nossa linguagem (como temos seno, cosseno, etc). Isso aumentaria o tamanho e a complexidade da linguagem, sem efetivamente resolver nosso problema, pois nenhuma linguagem pode implementar todas as funções que todos poderão precisar. Uma outra solução é encontrar algum mecanismo que nos permita expressar a função fatorial, e muitas outras funções úteis.

Um mecanismo bastante poderoso (em um certo sentido, `o mais poderoso') para criarmos novas funções é a recursão. Dizemos que uma definição é recursiva quando usamos o próprio termo sendo definido dentro da definição. Esse ciclo na definição pode nos levar a definições sem sentido, como por exemplo f(x)=f(x); tal `definição' não nos diz nada sobre a função f. Mas em outros casos, é exatamente da natureza cíclica de uma definição recursiva que vem seu poder de expressão.

Voltemos ao exemplo da função fatorial. Uma outra forma de definirmos essa função, sem usarmos ` ... ', é mostrada abaixo:

Note que usamos a própria função sendo definida na sua definição, caracterizando uma definição recursiva. A definição acima pode ser diretamente traduzida para a notação pré-fixada:
      (define fat
        (lambda (n)
          (if (zero? n)
              1
              (* n (fat (- n 1))))))

Em geral, uma definição recursiva é definida por casos: um caso base, e um caso recursivo. O caso base é geralmente uma situação trivial (número 0, lista vazia, etc), onde calcular o valor da função também é trivial, e não envolve recursão. No exemplo acima, o caso base é 0!=1.

No caso recursivo, por sua vez, é fundamental que façamos a aplicação recursiva sobre uma situação `mais simples' que a original, isto é, mais próxima do caso base. No nosso exemplo, se queremos n!, calculamos (n-1)! (observe que n-1 está mais próximo de 0 do que n). Sabendo o resultado dessa aplicação recursiva, precisamos então descobrir como modificar esse resultado para termos o resultado que procuramos. No exemplo acima, basta multiplicarmos (n-1)! por n.

Uma boa maneira de melhorarmos nossa compreensão sobre o funcionamento de uma função recursiva (e acreditar que não temos um círculo vicioso) é fazendo o traço de sua execução. O traço de uma função recursiva é feito da mesma forma que o de uma função não recursiva. Em particular, tratamos a chamada recursiva exatamente como trataríamos uma chamada a uma outra função. Por exemplo, o traço da chamada (fat 4) será:

(fat 4)
corpo de fat
(if (zero? 4) 1 (* 4 (fat (- 4 1))))
condicional
(* 4 (fat 3))
corpo de fat
(* 4 (if (zero? 3) 1 (* 3 (fat (- 3 1)))))
condicional
(* 4 (* 3 (fat 2)))
corpo de fat
(* 4 (* 3 (if (zero? 2) 1 (* 2 (fat (- 2 1))))))
condicional
(* 4 (* 3 (* 2 (fat 1))))
corpo de fat
(* 4 (* 3 (* 2 (if (zero? 1) 1 (* 1 (fat (- 1 1)))))))
condicional
(* 4 (* 3 (* 2 (* 1 (fat 0)))))
corpo de fat
(* 4 (* 3 (* 2 (* 1 (if (zero? 0) 1 (* 0 (fat (- 0 1))))))))
caso base
(* 4 (* 3 (* 2 (* 1 1))))
multiplicação
(* 4 (* 3 (* 2 1)))
multiplicação
(* 4 (* 3 2))
multiplicação
(* 4 6)
multiplicação
24 resultado final

Observe como a chamada recursiva vai se `desenrolando' repetidamente, até chegar no caso base. A partir daí, vai `retornando' e executando as operações que ficaram pendentes (no caso acima, as multiplicações).

Um outro exemplo clássico é uma função que calcula o comprimento de uma dada lista.5 Essa função tem como domínio o conjunto de listas e como contra-domínio os números naturais:
      (define comp
        (lambda (l)
          (if (null? l)
              0
              (+ 1 (comp (cdr l))))))
Nesse exemplo, o caso base é sobre a lista vazia, cujo comprimento é 0. A chamada recursiva, por sua vez, é feita sobre o cdr da lista original, que é uma lista `mais próxima' da lista vazia. Neste caso, o comprimento da lista é simplesmente 1 a mais que o comprimento do seu cdr. O traço da chamada (comp '(a b c)) será:

(comp '(a b c))

(if (null? '(a b c)) 0 (+ 1 (comp (cdr '(a b c)))))

(+ 1 (comp '(b c)))

(+ 1 (if (null? '(b c)) 0 (+ 1 (comp (cdr '(b c))))))

(+ 1 (+ 1 (comp '(c))))

(+ 1 (+ 1 (if (null? '(c)) 0 (+ 1 (comp (cdr '(c)))))))

(+ 1 (+ 1 (+ 1 (comp '()))))

(+ 1 (+ 1 (+ 1 (if (null? '()) 0 (+ 1 (comp (cdr '())))))))

(+ 1 (+ 1 (+ 1 0)))

(+ 1 (+ 1 1))

(+ 1 2)

3

Para definirmos uma função recursivamente, devemos tentar identificar quatro ítens: 1) qual o caso base, 2) qual o valor da função no caso base, 3) como é a chamada recursiva, e 4) como transformar o valor da chamada recursiva no valor final da função. Em geral, o último item é o único que pode apresentar alguma dificuldade. No exemplo do fatorial, o caso base é sobre 0, e o valor da função nesse caso é 0! = 1; o caso recursivo é sobre n-1, e para transformarmos (n-1)! em n! basta multiplicarmos o primeiro valor por n. No exemplo do comprimento da lista, o caso base é sobre a lista vazia, cujo comprimento é 0; o caso recursivo é sobre o cdr da lista, e para transformarmos o comprimento do cdr no comprimento da lista, temos apenas que somar 1.


Exemplo 2.1: Considere uma função que retorne uma lista de 1s com um dado comprimento; por exemplo:

(lista-1 4) (1 1 1 1)

Note que, ao contrário de comp, que tem como domínio listas e como contra-domínio os naturais, essa função tem como domínio os naturais e como contra-domínio listas. Vamos tentar identificar os quatro ítens descritos acima. Como a função recebe um número natural, o candidato óbvio para o caso base é 0; uma lista de 1s com comprimento 0 é exatamente a lista vazia, isto é,

(lista-1 0) ()

O caso recursivo também não é difícil, sendo a aplicação da função sobre n-1. Finalmente, se temos uma lista com n-1 1s, como transformá-la em uma lista com n 1s? Basta colocar mais um 1 na frente, com cons. Juntando tudo temos:
      (define lista-1
        (lambda (n)
          (if (zero? n)
              '()
              (cons 1 (lista-1 (- n 1))))))


Um erro bastante comum em definições recursivas é tratarmos como caso base um caso que, na realidade, ainda poderia ser tratado normalmente. Por exemplo, a função lista-1 definida acima poderia ser definida como:
      (define lista-1
        (lambda (n)
          (if (= n 1)
              '(1)
              (cons 1 (lista-1 (- n 1))))))
O que há de errado com essa definição? Em primeiro lugar, a função não funciona mais para n=0, que é um valor perfeitamente válido para o comprimento de uma lista. Em segundo lugar, esse caso base é algo artificial; por que não definirmos então o caso base para n=2, ou mesmo 3? Várias funções têm seu comportamento naturalmente estendido para `valores limite' como 0 ou a lista vazia, e não há porque a implementação não tratar tais casos. Exemplos comuns são soma de 0 termos (que é 0, elemento neutro da soma), produto de 0 fatores (que é 1, elemento neutro do produto), x0 = 1 (podemos pensar em x0 como um produto de 0 fatores), 0! = 1 (idem), etc. Por outro lado, algumas poucas funções realmente não são bem definidas para tais valores, e neste caso devemos ajustar nosso caso base. Um exemplo típico é o maior elemento de uma lista; não existe um `maior elemento' em uma lista vazia (mais matematicamente, podemos dizer que a operação máximo não tem elemento neutro).

Exercício 2.1: Defina as funções abaixo; lembre-se de primeiro tentar identificar os quatro ítens descritos acima.

  1. e2(n) = 2n (sendo n um número natural)
  2. exp(x,n) = xn
  3. f(n) = sin 1 + sin 2 + ... + sin n
  4. sf(n) = 1!+2!+ ... +n! (dica: use a função fat definida anteriormente.)
  5. f(n) = sin 1 + cos 2 + sin 3 + cos 4 + ... + (sin | cos ) n (dica: use o predicado even? para decidir entre seno e cosseno.)

Exercício 2.2: Defina as funções abaixo; como os parâmetros são listas, em geral o caso base será sobre a lista vazia. Quando o resultado da função for uma lista, tente usar cons para construí-lo.

  1. uma função que retorne a soma dos elementos de uma lista de números;
  2. uma função que conte quantas vezes um dado elemento aparece em uma lista;
  3. um predicado para verificar se um dado elemento está presente em uma lista;6
  4. uma função maximo que retorne o maior elemento de uma lista de números; (dica: use a função pré-definida max, que retorna o maior entre dois números dados.)
  5. um predicado para verificar se todos os números de uma dada lista são pares;
  6. uma função que receba uma lista de números, e retorne uma lista com os números incrementados de 1; por exemplo:

    (f '(1 4 6 2)) (2 5 7 3)

  7. uma função que receba uma lista de números inteiros, e retorne uma lista contendo somente os números da lista dada que sejam pares; por exemplo:

    (pares '(4 3 2 10 4 5)) (4 2 10 4)

    (dica: use o predicado pré-definido even? para testar se um número é par.)
  8. uma função que concatene duas listas;7 por exemplo:

    (concatena '(a b c) '(d e)) (a b c d e)

Exercício 2.3: Escreva uma função que substitua todas as ocorrências de um dado símbolo em uma lista por outro símbolo; por exemplo,

(substitui 'rato 'gato '(um rato maior que outro rato)) (um gato maior que outro gato)


Exemplo 2.2: Considere uma função ordena, para ordenar os elementos de uma lista. Essa função deve receber uma lista qualquer de números, e retornar uma lista com os mesmos números em ordem crescente. Por exemplo,

(ordena '(5 4 10 9 3 2 8)) (2 3 4 5 8 9 10)

Vamos tentar definir essa função recursivamente, aplicando nossa regra. O caso base natural seria a lista vazia. Como ordenar essa lista? Isso é fácil, é a própria lista vazia. Vamos agora imaginar a chamada recursiva, isto é, o valor de (ordena (cdr l)). Juntando o que temos até agora, ficamos com algo como:
      (define ordena
        (lambda (l)
          (if (null? l)
              '()
              ... (ordena (cdr l)) ...)))
O que falta? Mais exatamente, como transformar o resultado de (ordena (cdr l)) no resultado de (ordena l)? Não é difícil perceber que precisamos inserir o primeiro elemento da lista, (car l), na posição correta dentro do resultado que já temos. Vamos imaginar, temporariamente, que temos uma função para fazer isso. Com ela, nossa função final seria:
      (define ordena
        (lambda (l)
          (if (null? l)
              '()
              (insere (car l) (ordena (cdr l))))))
Temos agora que definir a função insere. Essa função recebe um número e uma lista ordenada, e deve inserir esse número na lista, na posição correta. Novamente vamos usar recursão, tentando identificar os vários casos possíveis. Se a lista for vazia, basta colocar o número no início da lista:
      (define insere
        (lambda (n l)
          (cond
            ((null? l) (cons n '()))
              ...)))
E se a lista não for vazia? Nesse caso, como queremos colocar n em ordem dentro de l, podemos comparar n com o primeiro valor de l. Se n for menor, ele entra no início da lista e pronto. Isso nos dá mais um caso para o cond:
            ((<= n (car l)) (cons n l))
Finalmente, se n for maior que o primeiro elemento de l, basta inserirmos n em (cdr l), usando recursão, e recolocar (car l) no início da lista:
      (define insere
        (lambda (n l)
          (cond
            ((null? l) (cons n '()))
            ((<= n (car l)) (cons n l))
            (else (cons (car l) (insere n (cdr l)))))))


Vale a pena comentarmos o exemplo acima. Observe como resolvemos o problema da ordenação criando um novo problema, de inserção. Como esse novo problema era mais simples que o anterior, tivemos um progresso, e ao resolver esse último problema, resolvemos o primeiro. Essa técnica é muito poderosa, pois permite resolvermos um problema por partes, em vez de atacá-lo todo de uma vez.

Exercício 2.4: Defina uma função para inverter uma lista;8 por exemplo:

(inverte '(a b c d e)) (e d c b a)

(dica: siga os passos do exemplo anterior. Quando necessário, defina uma função auxiliar para resolver parte do problema.)

Definições recursivas não são usadas apenas para funções. Na seção 1.2 definimos uma expressão como sendo ou átomos ou uma lista de expressões entre parênteses. Note como usamos o termo expressão, que está sendo definido, dentro da sua definição. Nessa definição, os átomos tem o papel de caso base.

Listas também podem ser definidas recursivamente. Podemos definir uma lista como sendo ou 1) a lista vazia, ou 2) um elemento (o car da lista) mais uma lista (o cdr da lista).

Exercício 2.5: Algumas funções exigem que se modifique mais de um parâmetro na chamada recursiva. Defina as funções abaixo:

  1. uma função que retorne o n-ésimo elemento de uma dada lista, onde n é um parâmetro da função; por exemplo:

    (n-esimo 3 '(a b c d e f)) c

    Assuma que n é menor ou igual que o comprimento da lista.
  2. uma função que substitua o valor do n-ésimo elemento de uma lista; por exemplo:

    (muda-n-esimo '(a b c d e f) 3 'novo) (a b novo d e f)

  3. uma função que retorne os n primeiros elementos de uma lista (um prefixo da lista); por exemplo:

    (primeiros 5 '(a b c d e f g h i j)) (a b c d e)

    Assuma que n é menor ou igual ao comprimento da lista.
  4. uma função que retorne os n últimos elementos de uma lista (um sufixo da lista). (dica: ao invés de recursão, procure apenas combinar as funções que você já tem.)
  5. dadas duas listas de números com comprimentos iguais, retornar a lista das somas; por exemplo:

    (soma-listas '(1 3 8) '(2 1 2)) (3 4 10)

  6. generalize a função anterior para o caso de uma das listas ser menor que a outra;
  7. um predicado que verifique se duas listas de números são iguais; por exemplo:

    (igual '(3 4 5) '(3 4 5)) #t

    (igual '(3 4 5) '(4 5)) #f

2.1 : Recursão Final

Considere uma função para retornar o n-ésimo elemento de uma lista:
      (define n-esimo
        (lambda (n l)
          (if (= n 1)   ; primeiro elemento?
              (car l)
              (n-esimo (- n 1) (cdr l)))))
Considere o traço dessa função em uma situação típica:

(n-esimo 4 '(a b c d e f))
definição + if
(n-esimo 3 '(b c d e f))
definição + if
(n-esimo 2 '(c d e f))
definição + if
(n-esimo 1 '(d e f))
caso base
d

Se observarmos o traço acima, podemos identificar uma importante diferença em relação ao traço de uma função com recursão usual (ver por exemplo o traço da função comp, na página traco-comp): a cada passo, a função é chamada com novos argumentos, sem acumular nada extra a ser feito depois; é como se a função estivesse sendo chamada pela primeira vez, só que com outros argumentos. Quando a função chega ao caso base, não há mais nada a ser feito, e temos direto o resultado final da função. Isso ocorre porque, a medida que a função vai se desenrolando, ela não cria operações pendentes, a serem executadas após o retorno do caso base. A chamada da função principal vai sendo simplesmente repetida, cada vez com novos parâmetros, até chegar no caso final. Esse tipo de recursão é chamado de recursão final (tail recursion, em inglês).

Por causa dessas características, funções com recursão final, em geral, são ligeiramente mais eficientes que funções com recursão normal. Além disso, computadores têm um limite para o número de operações pendentes que eles podem manter. Apesar desse limite ser bastante alto (da ordem de 103~ 104 operações), podemos ter problemas quando manipulamos listas ou números muito grandes usando recursão normal. Funções que usam recursão final, como não acumulam nada, não sofrem esse limite.

Como funções com recursão final são mais eficientes, surge a questão de que funções podemos definir desta forma. Algumas funções, como a ilustrada acima, são naturalmente definidas sob a forma de recursão final. Outras funções podem ser colocadas nessa forma, através de algumas modificações. Uma técnica bastante importante que podemos usar para colocar uma função no formato de recursão final é o uso de um acumulador. Ela consiste em colocarmos um parâmetro extra na função que estamos definindo, e a cada chamada recursiva `acumular' parte do resultado nesse parâmetro, de modo que ao chegarmos no caso base o resultado final é exatamente o valor do acumulador, não restando mais nada a ser feito.


Exemplo 2.3: Uma função para somar todos os elementos de uma lista, na sua definição usual, não usa recursão final, pois após a chamada recursiva ainda temos que somar o primeiro elemento da lista para obtermos o resultado final. Na versão mostrada abaixo, usamos um acumulador para guardar o valor dessa soma:
      (define soma
        (lambda (l a)
          (if (null? l)
              a
              (soma (cdr l) (+ a (car l))))))
Se fizermos uma chamada como (soma '(3 5 9) 0), teremos o traço abaixo:

(soma '(3 5 9) 0)
definição de soma + if
(soma '(5 9) 3)
definição de soma + if
(soma '(9) 8)
definição de soma + if
(soma '() 17)
caso base
17 valor do acumulador

Observe como o segundo parâmetro vai acumulando o resultado, de modo que ao chegar no caso base seu valor é o resutado final da função.


Vale a pena ressaltar que o que caracteriza a recursão final é o fato da chamada recursiva ser a última função executada. É essa propriedade que faz com que o traço da função tenha a estrutura de repetição mostrada acima. O uso de um ou mais parâmetros extras ou acumuladores é apenas uma maneira de colocarmos uma função na forma de recursão final. Como já observado, muitas funções usam recursão final sem necessidade de parâmetros extras. Por outro lado, muitas funções podem ficar muito mais complicadas quando escritas com recursão final. Ao longo do texto, vamos tratar recursão final como uma ferramenta a mais, tentando não enfatizar nem evitar seu uso.


Exemplo 2.4: Uma maneira de escrevermos uma função para inverter uma lista é mostrada abaixo:
      (define inverte (lambda (l a)
        (if (null? l)
            a
            (inverte (cdr l) (cons (car l) a)))))
Que pode ser usada da seguinte forma:

(inverte '(3 4 5) '()) (5 4 3)

Note que criamos um parâmetro extra (a, no caso acima) que começa com a lista vazia e vai acumulando o resultado ao longo da recursão. Quando chegamos no caso base, a resposta final está nesse acumulador. Observando o traço dessa função, é fácil ver o padrão característico da recursão final: Por exemplo, para inverter a lista '(a b c) teremos:

(inverte '(a b c) '())

(inverte (cdr '(a b c)) (cons (car '(a b c) '())))

(inverte '(b c) '(a))

(inverte '(c) '(b a))

(inverte '() '(c b a))
caso base
(c b a) resultado final

Novamente o segundo parâmetro acumula o resultado, de modo que ao chegar no caso base seu valor é o resutado final da função.


Quando a recursão final necessita de parâmetros extras, é comum definirmos uma função auxiliar para fazer a recursão final, com os parâmetros extras, e definir a função `principal' apenas como uma interface que fornece os valores iniciais adequados para a função auxiliar. Desta forma, a função principal não precisa ser chamada com os parâmetros extras. Seguindo essa idéia, a função inverte seria definida como:
      (define inverte-aux (lambda (l a)
        (if (null? l)
            a
            (inverte-aux (cdr l) (cons (car l) a)))))

(define inverte (lambda (l) (inverte-aux l '())))
Temos aqui mais um exemplo de abstração: com esse método, quem usa a função pode abstrair (e portanto, não se preocupar com) a possível existência de parâmetros extras na função.

Exercício 2.6: (Re)escreva as funções abaixo usando recursão final:

  1. fat(n) = n!
  2. exp(x,n) = xn (sendo n um número natural)
  3. f(n) = sin 1 + sin 2 + ... + sin n
  4. sf(n) = 1!+2!+ ... +n! (dica: use a função fat definida anteriormente.)
  5. uma função comp que retorne o comprimento de uma lista
  6. uma função conta que retorne o número de vezes que um dado símbolo aparece em uma lista.
  7. uma função maximo que retorne o maior elemento de uma lista de números.
  8. uma função pos que retorne em que posição um elemento ocorre em uma lista (ou '() se ele não estiver presente na lista). Por exemplo,

    (pos 'melao '(abacaxi melao banana pera)) 2

    (pos 'uva '(abacaxi melao banana pera)) ()

  9. uma função que retorne uma lista crescente de números com um dado comprimento; por exemplo:

    (ld 5) (1 2 3 4 5)

Exercício 2.7: Escreva uma função para calcular

usando recursão final. (dica: use três acumuladores: um para o somatório, outro para o fatorial, e o terceiro para a exponenciação.)

Exercício 2.8: A série de Fibonacci é definida como

0, 1, 1, 2, 3, 5, 8, 13, 21, ...

onde cada termo é a soma dos dois termos anteriores, isto é,

  1. Escreva uma função recursiva fib que calcule o n-ésimo termo da série de Fibonacci. Não use recursão final; baseie sua função na equação acima.
  2. Você tem alguma esperança de calcular (fib 40) com sua função? Por que? (Veja quanto tempo sua função demora para calcular (fib n), para n pouco maior que 20).
  3. Reescreva a função fib usando recursão final, e compare a eficiência das duas implementações. (dica: use dois parâmetros adicionais, para passar adiante os dois últimos termos da série.)

2.2 : Indução

Quando discutimos funções, vimos que, quando raciocinamos sobre uma função, podemos pensar na sua execução de duas formas: concreta e abstrata. A forma concreta corresponde a seguirmos o traço de execução dentro da função, executando cada passo dela, como o computador sempre faz. A forma abstrata pode ser usada quando assumimos que a função está correta e sabemos o que ela calcula; nesse caso, podemos passar direto da chamada para o resultado. É a forma abstrata que permite executarmos mentalmente um passo como

(+ 8 (sin 5)) (+ 8 -0.958924)

sem nem mesmo sabermos como a função sin é implementada.

A mesma visão abstrata que usamos em chamadas comuns pode ser aplicada sobre funções recursivas. A idéia por trás disso é que uma chamada recursiva é, em muitos aspectos, similar a uma chamada não recursiva. Como exemplo, vamos voltar a função fatorial. Suponha que queremos saber o valor de (fat 6); o traço dessa chamada poderia ser escrito como:

(fat 6)
definição concreta de fat
(* 6 (fat 5))
definição abstrata de fat (5!=120)
(* 6 120)
aritmética
720 resultado final (igual a 6!)

Essa visão abstrata é fundamental quando queremos verificar se uma função está escrita corretamente. Imagine que queremos nos certificar que nossa implementação da função fatorial está correta, isto é, para qualquer n 0 temos que

(fat n) n!

Fazendo o traço para diversos valores de n, ou executando a função no computador, podemos ganhar mais confiança em nossa definição, mas não podemos provar que ela está correta, pois não podemos fazer o traço concreto da função para todos os possíveis valores de n.

Para verificarmos e/ou nos certificarmos que uma função está escrita corretamente, o primeiro passo é colocar claramente qual a pré-condição da função. No nosso exemplo, não esperamos que chamadas como (fat -5) ou (fat '(a b c)) funcionem. Ou seja, a pré-condição para uma chamada (fat n) é que n seja um número natural (um inteiro maior ou igual a 0).

Em seguida, fazemos o traço da função para um valor abstrato de n. Como n estará representando qualquer valor que satisfaça a pré-condição, toda vez que tivermos uma seleção na nossa função (ifs ou conds) iremos fazer um traço separado para cada caso. Voltando ao nosso exemplo, como a função fat tem dois casos, iremos fazer dois traços: no primeiro caso, temos n=0, e o traço fica trivial:

(fat 0)
definição concreta de fat
(if (zero? 0) 1 (* 0 (fat (- 0 1))))
seleção
1 resultado final

Esse caso está correto, pois sabemos que 0!=1; logo temos que

(fat 0) 0!

No segundo caso, imaginamos um n qualquer maior que 0, e fazemos o seu traço. Note o uso de itálico para indicar os valores abstratos dentro das expressões:

(fat n)
definição concreta de fat (com n 0)
(* n (fat (n-1)))
definição abstrata de fat: (fat (n-1)) (n-1)!
(* n (n-1)!)
aritmética
n × (n-1)! resultado final

Ou seja, temos que

(fat n) n × (n-1)! = n × ((n-1) × ... × 1) = n!

Como isso vale para qualquer n>0, e já vimos também o caso n=0, podemos afirmar que

(fat n) n!

para qualquer n 0.

Esse raciocínio está correto, ou estamos blefando? Afinal, estamos usando o fato que (fat (n-1)) (n-1)! para chegarmos a (fat n) n!. Não estamos andando em círculos?

A justificativa para o raciocínio que utilizamos é chamada de princípio de indução. Segundo o princípio de indução, quando estamos verificando se uma função está corretamente definida, podemos assumir que qualquer chamada recursiva da função retornará a resposta correta, desde que a função seja chamada com valores adequados à sua pré-condição e que a função termine.9

Antes de continuarmos, é importante frizar que esse problema não é apenas teórico: a função fatorial é muito simples, quase trivial, mas para funções mais complexas pode ser bastante complicado nos certificarmos que escrevemos a função corretamente. Além disso, um programador experiente não pensa `concretamente' sobre programas, mas abstratamente, ou indutivamente. Ilustrativamente, podemos dizer que o programador iniciante, é o que vê 120! como 120 × 119 × 118 × 117 × ... × 5 × 4 × 3 × 2 × 1, enquanto um programador experiente vê 120! como 120 × 119!.


Exemplo 2.5: Considere a função abaixo, para calcular xn usando um acumulador e recursão final:
      (define exp
        (lambda (x n a)
          (if (zero? n)
              a
              (exp x (- n 1) (* x a)))))
A terminação de exp é trivialmente garantida, pois a cada chamada decrementamos n, e paramos quando n=0. A pré-condição dessa função é que x e a sejam números reais quaisquer, e n um inteiro maior ou igual a zero. Mas para verificarmos que exp realmente calcula xn, precisamos colocar o papel de a mais claro. Em particular, o que ocorre se chamarmos (exp x n a) com um a qualquer? Não é difícil perceber que a função deveria retornar a × xn; portanto, o que devemos verificar é que

(exp x n a) a × xn

No caso base, com n=0, temos que

(exp x 0 a) a = a × x0

que está correto. No caso geral, com n>0, temos que

(exp x n a) (exp x (n-1) (x × a))

Aqui usamos a hipótese de indução no passo, e um pouco de aritmética na igualdade, para chegarmos em:

(exp x (n-1) (x × a)) (x × a) × xn-1=a × xn

Finalmente, se juntarmos tudo e fixarmos a=1, teremos:

(exp x n 1) 1 × xn = xn



Exemplo 2.6: Considere a função abaixo (incorreta), para calcular 2n:
      (define f
        (lambda (n)
           (if (zero? n)
               1
               (/ (f (+ n 1))))))
Para n=0, temos

(f 0) 1=20

como queríamos. Para n>0, temos

(f n) (/ (f (n+1)) 2)

Usando o princípio de indução aqui, conseguiríamos provar trivialmente que

(f n) 2n+1/2 = 2n

Mas não é muito difícil perceber que essa função não funciona, donde há algo errado em nosso raciocínio. Se chamarmos essa função com o argumento 5, por exemplo, teremos o traço abaixo:

(f 5)

(/ (f 6) 2)

(/ (/ (f 7) 2) 2)

(/ (/ (/ (f 8) 2) 2) 2)

...

A função ficará se repetindo infinitamente, em uma situação que chamamos de loop. Como a função não termina, não podemos usar o princípio de indução.


2.3 : Terminação

Muitas vezes, é bastante fácil verificarmos que uma função não vai entrar em loop. Funções como fatorial, com valor 0 de caso base e que decrementam em 1 seu argumento a cada chamada recursiva, sempre terminam quando chamadas com um argumento positivo, pois qualquer número natural chega a 0 após um certo número de decrementos. Entretanto, nem todas funções recursivas são como a função fatorial. Para funções mais complexas, o problema de garantirmos que a função não entra em loop pode ser difícil, ou mesmo impossível, de resolver.

Como regra geral, para nos certificarmos que uma função f termina, precisamos conseguir uma função h sobre os parâmetros da função f tal que 1) h é sempre um número inteiro maior ou igual que 0, e 2) h decresce a cada nova chamada recursiva. Podemos pensar em h como uma medida do tamanho do problema. O argumento é que, se f entrasse em loop, teríamos uma sequência infinita de valores de h, onde todos os valores são inteiros positivos e cada novo valor é estritamente menor que o anterior. Como tal sequência é impossível, a função tem que terminar.10

No caso da função fatorial, podemos fazer a opção trivial de h(n)=n. Como a pré-condição de fat é n 0, a condição 1 é satisfeita; além disso, como a chamada recursiva é sobre n-1, a condição 2 também é satisfeita. Para muitas funções sobre listas, podemos definir h como o comprimento da lista. O comprimento é sempre um inteiro positivo (condição 1), e (cdr l) tem sempre um comprimento menor que o comprimento de l (condição 2).


Exemplo 2.7: O algoritmo de Euclides, para calcular o MDC (Máximo Divisor Comum) entre dois números, se baseia nas igualdades mdc(a,0) = a, mdc(0,b) = b, mdc(a,b)=mdc(a-b,b), se a b, e mdc(a,b)=mdc(a,b-a), se b a. Sua implementação em Scheme pode ser escrita como:
      (define mdc
        (lambda (a b)
          (cond
            ((zero? a) b)
            ((zero? b) a)
            ((>= a b) (mdc (- a b) b))
            (else (mdc a (- b a))))))
A pré-condição para mdc é que a e b sejam números naturais (isto é, inteiros maiores ou iguais a 0). Para provarmos que essa função está correta, temos que verificar 4 casos. Note que (mdc a b) denota nossa função, definida em Scheme, enquanto mdc(a,b) denota a função matemática de Máximo Divisor Comum. Portanto, usando o princípio da indução não é difícil nos certificarmos que nossa função está correta, desde que ela termine.

Para nos convencermos da terminação da função mdc, podemos definir h como h(a,b)=a+b. Como a e b são sempre positivos (pré-condição da função), temos que 1) h(a,b)=a+b 0. Como a chamada recursiva só é feita se a e b forem maiores que 0, temos que a subtração necessariamente diminui seus valores. Portanto, 2) a cada nova chamada a soma a+b será menor que na chamada anterior. Logo, como vimos acima, a função termina.


Exercício 2.9: Faça o traço da chamada (mdc 12 30).

Exercício 2.10: Uma definição mais tradicional para o algoritmo de Euclides se baseia nas igualdades mdc(a,0) = a, mdc(a,b)=mdc(b,a), e mdc(a,b)=mdc(a-b,b), se a b.

  1. Defina a função mdc em Scheme usando essas igualdades.
  2. Mostre que sua função sempre termina quando chamada com argumentos positivos.


Exemplo 2.8: Existem alguns casos em que a terminação pode ser uma questão sem resposta conhecida. A função abaixo, por exemplo, é famosa por esse motivo:
      (define collatz
        (lambda (n)
           (cond
             ((= n 1) '())
             ((even? n) (cons n (collatz (quotient n 2))))
             (else (cons n (collatz (+ (* n 3) 1)))))))


Exercício 2.11: Implemente a função collatz, e calcule seu valor para os argumentos 15, 23, 27 e 28. Você consegue estabelecer alguma relação entre o argumento e o comprimento da resposta? Existe algum n natural para o qual essa função fica em loop?

Exercício 2.12: Considere a função abaixo, para calcular o produto de dois números naturais:

      (define mult
        (lambda (a b)
          (cond
            ((zero? b) 0)
            ((even? b) (* 2 (mult a (quotient b 2))))
            (else (+ a (* 2 (mult a (quotient b 2))))))))
Mostre que essa função sempre termina quando chamada com a,b 0, e que

(mult a b) a × b

Exercício 2.13: Escreva uma função que retorne a lista dos fatores primos de um dado número. Por exemplo,

(fatores 84) (2 2 3 7)

Use a expressão (zero? (remainder x y)) para testar se x é divisível por y. Mostre que sua função sempre termina quando chamada com n 0, e que o produto dos elementos da lista retornada é igual a n.

2.4 : Padrões de Recursão

Muitas das funções recursivas definidas ao longo deste capítulo seguem determinados padrões. Isto é, muitas das funções que vimos são bastante parecidas entre si, compartilhando uma estrutura comum, e diferindo apenas em alguns poucos pontos. Nesta seção, vamos tentar identificar alguns dos padrões mais comuns, e mostrar como podemos definir funções em Scheme que refletem cada padrão.

A identificação de padrões em programas é altamente benéfica. Em primeiro lugar, é uma fonte de soluções de problemas. Geralmente, quando temos que escrever uma função ou programa para uma determinada tarefa, a primeira pergunta que nos fazemos é: `será que eu já não fiz ou ví algo parecido antes?'. A nossa bagagem de padrões conhecidos, que adquirimos ao longo do tempo, nos permite resolver um grande número de problemas de forma quase imediata. Em segundo lugar, muitos padrões podem ser diretamente programados na linguagem. Com isso, ao identificarmos que um determinado problema pode ser resolvido com um determinado padrão, não precisamos nem mesmo escrever toda a solução, mas apenas usar a função já existente.

O primeiro padrão que vamos estudar é chamado filtro. Considere a função abaixo, para filtrar os elementos pares de uma lista:
      (define filtra-pares
        (lambda (l)
          (cond
            ((null? l) '())
            ((even? (car l)) (cons (car l) (filtra-pares (cdr l))))
            (else (filtra-pares (cdr l))))))
Dada uma lista de números, filtra-pares retorna uma nova lista com apenas os números pares da lista original.

Exercício 2.14: Escreva uma função filtra-impar, que retorne uma lista com apenas os números ímpares da lista original (isto é, filtre os elementos ímpares).

Exercício 2.15: Escreva uma função filtra-10, que filtre os elementos maiores que 10.

Exercício 2.16: As funções filtra-par, filtra-impar e filtra-10 são semelhantes? Por que? Em que elas diferem?

Podemos literalmente abstrair as diferenças entre essas funções. Em vez de uma função com um teste concreto, fixo, como por exemplo even?, criamos uma função genérica filtra, onde o predicado de teste é mais um parâmetro da função:
      (define filtra
        (lambda (f l)   ; f e' o predicado de filtro
          (cond
            ((null? l) '())
            ((f (car l)) (cons (car l) (filtra f (cdr l))))
            (else (filtra f (cdr l))))))
Assim, para filtrarmos os números pares de uma lista, podemos executar

(filtra even? '(1 10 9 45 22 11 0)) (10 22 0)

Exercício 2.17: Use a função filtra para filtrar os seguintes elementos de uma lista:

  1. elementos ímpares;
  2. elementos maiores que 10; (dica: você não precisa definir um novo predicado. Ao invés disso, você pode usar lambda diretamente na chamada: pense na função (lambda (x) (> x 10)).)
  3. elementos no intervalo [5,15].


Exemplo 2.9: O predicado filtra permite uma definição bastante interessante para a função ordena, que ordena os elementos de uma dada lista em ordem crescente. Dada uma lista não vazia l, o algoritmo usa filtra para dividir (cdr l) em duas partes, uma com os elementos menores que (car l) e a outra com os elementos maiores que (car l). Essas duas listas são então ordenadas de forma independente com chamadas recursivas de ordena. Finalmente, as duas sub-listas ordenadas e (car l) são reunidos em uma única lista com a função append e cons.
      (define (ordena l)
        (if (null? l)
            '()
            (append
              (ordena (filtra (lambda (x) (<= x (car l))) (cdr l)))
              (cons (car l)
                (ordena (filtra (lambda (x) (> x (car l))) (cdr l)))))))


Um outro padrão bastante comum é o mapeamento, que são funções que recebem uma lista e retornam outra lista, formada pelos elementos da lista original transformados de alguma forma. Esse padrão pode ser capturado pela função genérica mapeia. Essa função recebe uma função f e uma lista, e retorna a lista dos resultados da aplicação de f sobre cada elemento da lista.
11 Por exemplo, para arredondarmos todos os números de uma lista, podemos chamar

(mapeia round '(1.3 5.01 3.32)) (1 5 3)

onde round é uma função pré-definida que arredonda um número real.

Exercício 2.18: Escreva a função mapeia.

Exercício 2.19: Usando mapeia, escreva as funções abaixo. Não use recursão.

  1. uma função ls que receba uma lista de números e retorne a lista de seus senos.
  2. uma função q que receba uma lista de números e retorne a lista de seus quadrados. Por exemplo,

    (q '(3 9 2)) (9 81 4)

Exercício 2.20: Escreva uma função que calcule o somatório dos elementos de uma lista; use recursão final. Em seguida, escreva uma função que calcule o produto dos elementos de uma lista, também usando recursão final. Qual a semelhança entre essas duas funções? Quais as diferenças? Generalize as duas em uma função acumula, que receba como parâmetro, além da lista e do acumulador, a operação a acumular (+, *, etc).

Exercício 2.21: Usando a função acumula, defina funções para as tarefas abaixo. Não use recursão.

  1. calcular o maior elemento de uma lista;
  2. calcular o menor elemento de uma lista;
  3. calcular o comprimento de uma lista;
  4. contar quantas vezes um dado número aparece em uma lista;
  5. inverter uma lista.

Exercício 2.22: Usando a função acumula, escreva um predicado genérico, que receba um predicado e uma lista, e verifique se todos os elementos da lista satisfazem o predicado dado. Por exemplo,

(acpred even? '(2 3 4 6)) #f

(acpred odd? '(3 19 7 1)) #t

Exercício 2.23: Identifique alguma função recursiva já implementada por você que não possa ser reescrita sem o uso de recursão, usando-se filtra, mapeia, e acumula.

2.5 : Repetições

Scheme oferece uma outra forma de definirmos funções com recursão final, onde é realçado o aspecto repetitivo da recursão final e um padrão estrutural bastante comum nesse tipo de recursão.

Vamos começar revendo uma típica função com recursão final. Considere as definições abaixo, para somar os elementos de uma lista:
      (define soma-aux
        (lambda (l s)
          (if (null? l)
              s
              (soma-aux (cdr l) (+ s (car l))))))

(define soma (lambda (ll) (soma-aux ll 0)))
Se analisarmos a estrutura da função soma, podemos identificar vários elementos, comuns à maioria das funções com recursão final:

Exercício 2.24: Identifique cada um dos elementos acima na função definida no exemplo 2.4

Se fizermos o traço da chamada (soma '(4 8 1 -3)), teremos:

(soma '(4 8 1 -3))

(soma-aux '(4 8 1 -3) 0)

(soma-aux '(8 1 -3) 4)

(soma-aux '(1 -3) 12)

(soma-aux '(-3) 13)

(soma-aux '() 10)
l é nula, retorna valor de s
10 valor de s

Observe como, nesse traço, temos uma repetição de passos, onde apenas os valores de l e de s vão mudando a cada passo. Funções com recursão final podem ser escritas de uma forma em que esse caráter repetitivo fique mais explícito, através da construção do. Seguindo nosso exemplo, podemos reescrever a função soma usando a construção do, como mostrado abaixo:
      (define soma
        (lambda (ll)
          (do ([l ll (cdr l)]
               [s 0 (+ s (car l))])
              ((null? l) s))))
observação: novamente usamos colchetes [...] no lugar de parênteses (...), de modo a deixar mais claro o formato do do. Novamente, em qualquer uso real esses colchetes devem ser substituídos por parênteses. Note como os mesmos elementos identificados na implementação recursiva estão presentes aqui, agrupados de uma forma diferente. O primeiro argumento do do é uma lista, onde colocamos o que seriam os parâmetros variáveis na definição recursiva; junto colocamos seu valor inicial e a expressão de atualização, usada para se calcular o próximo valor. Assim, o primeiro item dessa lista,

(l ll (cdr l))

define uma variável l, que começa a repetição com o valor de ll, e a cada passo recebe um novo valor (cdr l). Da mesma forma, o segundo item,

(s 0 (+ s (car l)))

cria uma variável s, com valor inicial igual a 0, e que a cada novo passo recebe o valor da expressão (+ s (car l)). O segundo argumento do do é outra lista, cujo primeiro item é o teste de terminação, e o segundo é o valor final do do. No exemplo acima, esse argumento é a lista ((null? l) s).

A sintaxe geral da construção do é mostrada abaixo:
      (do ([n1 v1 e1]
            ...
           [nn vn en])
          (teste valor-final))
Nessa construção, cada grupo [ni vi ei] cria uma nova variável chamada ni, que começa a repetição com valor vi, e a cada passo recebe um novo valor dado por ei. O teste é um predicado qualquer; enquanto ele for falso, o do continuará executando passos. Finalmente, quando teste for verdadeiro, a execução do do termina, resultando no valor da expressão valor-final. Mais formalmente, a sintaxe geral de do mostrada acima é equivalente a:
      (define aux
        (lambda (n1 ... nn)
          (if teste
              valor-final
              (aux e1 ... en))))

(aux v1 ... vn) ; <= expressao equivalente ao do


Exemplo 2.10: Vamos definir, usando do, uma função para calcular a média dos elementos de uma lista de números:
      (define (media l)
        (do ((l1 l (cdr l1))
             (s  0 (+ s (car l1)))
             (n  0 (+ n 1)))
             ((null? l1) (/ s n))))
Nessa função, temos uma variável l1, que começa com o valor de l, e a cada repetição se torna (cdr l1); temos também uma variável s que é inicada com 0 e que a cada passo recebe (+ s (car l1)); e temos uma terceira variável n, iniciada também com 0 e evoluindo com (+ n 1). O teste de parada é dado pela expressão (null? l1), e o valor final do do (e da função) será o valor da expressão (/ s n).


Exercício 2.25:
Reescreva a função media apresentada no exemplo anterior usando recursão final. Preserve todos os elementos usados na versão com do.

O traço de um do pode ser feito meramente acompanhando-se a evolução de suas variáveis. Assim, o traço de uma função definida com do e sua equivalente definida com recursão final são bastante parecidos. Mas é importante frizarmos que a construção do, apesar de ter o mesmo comportamento de funções com recursão final, não implica em chamadas de função na sua execução.


Exemplo 2.11: Considere a definição de media dada no exemplo
2.10. Vamos fazer o traço da chamada (media '(3 4 2)):

(media '(3 4 2))
do, valores iniciais
(do '(3 4 2) 0 0)
l1 (cdr l1), s (+ s (car l1)), n (+ n 1)
(do '(4 2) 3 1)
l1 (cdr l1), s (+ s (car l1)), n (+ n 1)
(do '(2) 7 2)
l1 (cdr l1), s (+ s (car l1)), n (+ n 1)
(do '() 9 3)
predicado (null? l1) verdadeiro, termina com valor (/ s n)
3


Exercício 2.26: Faça novamente o traço da chamada (media '(3 4 2)), mas usando a definição recursiva de media, feita no exercício 2.25. Compare os dois traços.

Exercício 2.27: (Re)escreva as rotinas abaixo usando do; se quiser, escreva antes a função com recursão final, e depois transforme.

  1. fat(n) = n!
  2. exp(x,n) = xn (sendo n um número natural)
  3. f(n) = sin 1 + sin 2 + ... + sin n
  4. uma função comp que retorne o comprimento de uma lista
  5. uma função som que retorne o somatório de uma lista de números.
  6. uma função inverte que retorne uma dada lista invertida.
  7. uma função conta que retorne o número de vezes que um dado símbolo aparece em uma lista;

Exercício 2.28: Reescreva a função acumula, do exercício
2.20, usando do.



Capítulo 3 :


Representação de Dados

Até agora, todas as funções que definimos trabalhavam sobre números e listas. Entretanto, um computador não manipula apenas esses tipos de dados; um computador é capaz de tratar conjuntos, imagens, matrizes, sons, enfim, uma variedade ilimitada de tipos de informação. Como um computador pode manipular todos esses tipos de informação, e como uma linguagem pode nos oferecer todos esses tipos?

Temos aqui uma questão similar a que já encontramos com funções. Lá, a questão era: como uma linguagem pode oferecer todas as funções que precisamos? E a solução era que, ao invés de fornecer todas as funções que precisamos, uma linguagem deve oferecer mecanismos para criarmos novas funções.

Da mesma forma, uma linguagem não pode nos oferecer todos os tipos de dados que precisamos. Ao contrário, mais importante que os tipos básicos que ela oferece são os mecanismos oferecidos para criarmos novos tipos. Uma das tarefas mais importantes de programação é decidirmos como cada informação que precisamos tratar deverá ser representada no computador, através da linguagem que utilizamos. Em Scheme, o principal mecanismo para a construção de novos tipos é a lista.

Neste capítulo, vamos ver como listas podem ser usadas para representar diversos tipos de informação diferentes, como conjuntos, catálogos, polinômios, expressões aritméticas, e mesmo números naturais. Outro mecanismo importante para representação de dados, o vetor, será visto no capítulo proc.

3.1 : Representando Conjuntos

Uma grande variedade de dados podem ser representados através do conceito matemático de conjuntos. Por exemplo, um programa de controle acadêmico pode utilizar conjuntos para armazenar as disciplinas cursadas por um aluno, os pré-requisitos de uma disciplina, os alunos de cada curso, etc.

Uma maneira bastante direta para se representar conjuntos em Scheme é através de uma lista de seus elementos. Note que um mesmo conjunto pode ser representado por várias listas; por exemplo, o conjunto {a,b} pode ser representado pelas listas (a b), (b a), (a b a), etc. De modo a diminuir um pouco essa multiplicidade, vamos exigir que uma lista representando um conjunto nunca tenha elementos repetidos (esse tipo de restrição sobre possíveis representações de um valor é denominado invariante). Assim, as listas (a b) e (b a) ambas representam o conjunto {a,b}, enquanto (a b a) não é uma representação válida de nenhum conjunto (pois não satisfaz o invariante).

Mais do que armazenar dados, um computador deve ser capaz de manipular esses dados. Assim, mais importante do que definirmos como representar um determinado tipo de dado, é mostrarmos como podemos manipular dados usando essa representação. Vamos então mostrar como as diversas operações básicas de conjuntos podem ser definidas sobre a representação com listas.

A operação mais básica sobre conjuntos é o predicado pertence?, para verificar se um dado elemento pertence a um conjunto. Sua definição não apresenta dificuldades:
      (define pertence?
        (lambda (e c)
          (if (null? c)
              #f
              (or (equal? e (car c))
                  (pertence? e (cdr c))))))
No caso base, com o conjunto vazio, a função resulta em falso (nenhum elemento pode pertencer ao conjunto vazio). Se a lista não é vazia, um elemento na lista ou é igual ao primeiro elemento ou então está no resto da lista.

A união de dois conjuntos já não é tão direta. Podemos pensar em simplesmente concatenar as duas listas, mas se existirem elementos comuns aos dois conjuntos, eles apareceriam repetidos na lista final, desrespeitando nosso invariante. Para evitarmos esse problema, temos que tomar o cuidado de somente inserir um novo elemento no resultado se ele ainda não estiver lá:
      (define uniao
        (lambda (a b)
          (cond
            ((null? a) b)
            ((pertence? (car a) b) (uniao (cdr a) b))
            (else (cons (car a) (uniao (cdr a) b))))))

Exercício 3.1: Faça o traço da chamada (uniao '(a b c) '(b e)).

Exercício 3.2: Escreva uma função que receba dois conjuntos e retorne sua interseção.

Exercício 3.3: Escreva uma função que receba dois conjuntos e retorne sua diferença. (A diferença entre dois conjuntos A e B, A-B, é o conjunto formado pelos elementos de A que não pertencem a B.)

Exercício 3.4: Escreva um predicado que receba dois conjuntos c1 e c2 e verifique se c1 c2.

Exercício 3.5: Escreva um predicado que receba dois conjuntos c1 e c2 e verifique se c1 = c2. Observe que não basta comparar as listas. Por exemplo, os conjuntos representados pelas listas (1 2 3) e (2 3 1) são iguais. (dica: lembre-se que c1 = c2 se e somente se c1 c2 e c2 c1.)

Exercício 3.6: Redefina as funções de união e interseção de conjuntos usando do.

Exercício 3.7: Reescreva as funções de união e interseção de conjuntos utilizando a função acumula, do exercício
2.20.

3.2 : Representação de Números Naturais

Vamos imaginar que nossa linguagem ou nosso computador não tivesse uma maneira interna de representar números. Uma maneira bastante simples de representar os números naturais seria através de listas de átomos, onde o comprimento da lista corresponderia ao natural representado. Assim, o número 1 seria representado por (1), o número 2 por (1 1), e assim sucessivamente. Essa maneira de se representar números naturais é comumente chamada de notação unária.

Com essa representação, poderíamos definir algumas operações primitivas sobre naturais de maneira bastante direta. O número 0 seria representado pela lista vazia,

(define zero '())

Um predicado para verificar se um número é zero seria:
12

(define zer? (lambda (n) (null? n)))

ou mais diretamente:

(define zer? null?)

A operação de incrementar 1 seria:

(define inc (lambda (n) (cons 1 n)))

e a operação de decrementar 1:

(define dec (lambda (n) (cdr n)))

A princípio, as definições acima podem parecer algo inúteis, dado que as funções são tão simples, no caso de zer? sendo apenas outro nome para null?. Entretanto, isso é uma aplicação direta de nossa forma básica de abstração, que é dar nomes às coisas. O benefício principal ao definirmos essas funções é o que chamamos de abstração de dados, pois abstraimos como os dados estão sendo representados. Com o conjunto básico de operações zero, zer?, inc e dec, podemos definir todas as operações aritméticas usuais, sem nos preocuparmos com que representação estamos usando.

A operação para somar dois números pode ser definida como:
      (define soma
        (lambda (a b)
           (if (zer? b)
               a
               (inc (soma a (dec b))))))
ou alternativamente, com recursão final:
      (define soma
        (lambda (a b)
          (do ((a1 a (inc a1))
               (b1 b (dec b1)))
             ((zer? b1) a1))))
Observe como toda a manipulação dos números é feita através das funções básicas; não existe nenhuma referência ao fato dos números estarem sendo representados por listas. Podemos escrever e entender a função soma sem saber como os números estão sendo representados. De fato, podemos redefinir nossa representação de números, para usarmos a representação normal de Scheme, como mostrado abaixo:
      (define zero 0)
      (define zer? zero?)
      (define inc (lambda (n) (+ n 1)))
      (define dec (lambda (n) (- n 1)))
e a função soma definida acima continuará funcionando corretamente.

Exercício 3.8: Defina as funções abaixo, para operarem sobre dois naturais dados na representação unária. Como na definição de soma, não faça referência à representação por listas; use apenas as operações sobre números definidas anteriormente.

  1. Uma função sub, para subtrair dois números. Por exemplo,

    (sub '(1 1 1 1) '(1 1)) (1 1)

    O que acontece com sua função na chamada abaixo?

    (sub '(1) '(1 1 1))

  2. Uma função mult, para multiplicar dois números. Por exemplo,

    (mult '(1 1 1) '(1 1)) (1 1 1 1 1 1)

Da mesma forma que podemos definir as operações aritméticas, podemos também definir os predicados usuais de comparação numérica. A função abaixo compara dois números a e b, e retorna -1 se a < b, 0 se a = b, e 1 se a > b.
      (define comp
        (lambda (a b)
           (cond
             ((and (zer? a) (zer? b)) 0)
             ((zer? a) -1)
             ((zer? b)  1)
             (else (comp (dec a) (dec b))))))

Exercício 3.9: Defina as funções abaixo, para operarem sobre dois naturais dados na representação unária. Como na definição de soma, não faça referência a representação por listas; use apenas as operações sobre números definidas anteriormente.

  1. Uma função div, para dividir dois números, retornando o quociente da divisão. Por exemplo,

    (div '(1 1 1 1 1) '(1 1)) (1 1)

    O que ocorre com sua função se for feita uma divisão por 0?
  2. Uma função resto, retornando o resto da divisão de dois números. Por exemplo,

    (resto '(1 1 1 1 1) '(1 1)) (1)

Conforme discutimos anteriormente, um conceito vital em qualquer programa é o da representação de dados, isto é, como representar os tipos de dados que o programa manipula usando-se os mecanismos de representação oferecidos pela linguagem.

Nesta seção, vimos como mesmo tipos básicos, como números naturais, podem ser representados através de outros tipos. Outro ponto importante que devemos ressaltar é o uso de abstrações nas representações de dados. O melhor exemplo é dado pela própria linguagem Scheme. Scheme nos oferece uma representação de números, junto com operadores de soma, subtração, etc. Com esses operadores, podemos construir programas usando números sem nem mesmo saber como esses números são representados internamente. Da mesma forma, a linguagem nos oferece listas, junto com operadores como car, cdr e cons, e usamos essas listas sem nos preocuparmos em como elas são implementadas. Quando definimos uma representação adequada para nossos dados, devemos sempre escrever as funções necessárias para manipular essa representação. Assim, podemos posteriormente usar esse novo tipo sem termos que nos preocupar em como ele está representado.

3.3 : Tabelas Associativas

Uma outra estrutura de dados muito comum é a tabela, também chamada de catálogo, diretório, lista associativa, tabela de símbolos, etc, conforme seu uso específico. Esse tipo de estrutura associa um determinado valor, chamado de chave, a outros valores que tenham alguma relação específica com a chave. Um exemplo típico de tabela é um catálogo telefônico, que associa a cada nome de pessoa (a chave) um ou mais telefones.

Uma maneira de representarmos uma tabela contendo um `caderno de telefones' é com uma estrutura como mostrada abaixo:
      (
        (Denise  2221111)
        (Paulo   2741555)
        (Jose    2221133)
        (Maria   2129876)
      )
Isso é, uma lista, onde cada elemento da lista é outra lista, com um nome (a chave) e o telefone associado ao nome.

Exercício 3.10: Usando a representação acima, faça as tarefas a seguir:

  1. Defina uma variável catalogo, com sua lista pessoal de telefones.
  2. Defina uma função de consulta que receba um nome e retorne o telefone associado a esse nome em catalogo. Por exemplo,

    (telefone 'Denise) 2221111

    (dica: use recursão final.)

Da mesma forma que podemos usar funções definidas previamente na definição de novas funções, podemos compor tipos para construir tipos mais complexos. Tendo números, listas, tabelas e conjuntos, podemos construir conjuntos de tabelas, listas de conjuntos, e assim por diante.


Exemplo 3.1: Podemos representar o resultado de matrícula de uma faculdade como uma lista associativa, que associa o número de matrícula de cada aluno ao conjunto de disciplinas em que ele se matriculou. Um exemplo de tal estrutura é mostrado abaixo:
      (define M97-1 '(
        (M-9611110 (INF1001 FIS1034 MAT1090))
        (M-9610222 (FIS1034 FIL1890))
        (M-9520000 ())
        (M-9523456 (INF1001 FIS1034 MAT1090 JUR1110))))


Exercício 3.11: Escreva uma função que receba o código de uma disciplina e uma lista representando um resultado de matrícula, e retorne uma lista dos alunos matriculados nessa disciplina. Por exemplo:

(cursando 'MAT1090 M97-1) (M-9611110 M-9523456)

(dica: use suas funções para manipular conjuntos.)

Exercício 3.12: Escreva uma função que receba os números de matrícula de dois alunos e retorne a lista de disciplinas que ambos fazem juntos. (dica: use suas funções para manipular conjuntos e para manipular tabelas.)

Considere agora mais duas tabelas, uma associando o número de matrícula ao nome do aluno, e outra associando o código de disciplina ao professor. Por exemplo:
      (define NOMES '(
        (M-9611110 "Paulo Moura")
        (M-9610222 "Maria Nunes")
        (M-9520000 "Marcos Cintra")
        (M-9523456 "Marta Silva Teles")))

(define PROFS '( (INF1001 "Joel Santos Borges") (FIS1034 "Fabio Tavares") (MAT1090 "Leila Pinheiro") (JUR1110 "Sonia Maria do Carmo") (FIL1890 "Carlos Augusto")))

Exercício 3.13: Defina uma função com a mesma especificação do exercício
3.11, mas que retorne os nomes dos alunos, ao invés de seus números de matrícula. Por exemplo:

(cursando 'MAT1090 M97-1) ("Paulo Moura" "Marta Silva Teles")

Exercício 3.14: Escreva uma função que receba o nome de um aluno, e retorne uma lista com os nomes de seus professores.

3.4 : Árvores Binárias

A estrutura apresentada na seção 3.1 para conjuntos pode ser bastante ineficiente para grandes conjuntos. Por exemplo, um conjunto com dez mil elementos exige dez mil comparações para concluirmos que um elemento não pertence ao conjunto.

Quando consultamos um catálogo telefônico, não começamos na primeira página e vamos conferindo cada nome no catálogo até acharmos o que estamos procurando. A estrutura existente no catálogo, onde os nomes aparecem em ordem alfabética, nos permite uma estratégia de busca bem mais eficiente. Abrimos o catálogo aproximadamente ao meio, e comparamos os nomes achados com o nome que estamos procurando. Se o nome que procuramos for `menor' (isto é, vier antes na ordem alfabética) que o nome encontrado, continuamos a busca na primeira metade do catálogo; se não, procuramos na segunda metade. A cada passo que damos, dividimos o espaço de busca ao meio, até localizarmos a página exata onde o nome que procuramos se encontra. Esse algoritmo de busca é conhecido como busca binária.

Como a cada passo dividimos o número de elementos onde procurar por dois, se o número de elementos no início da busca for n, precisaremos de aproximadamente log 2 n passos até chegar a 1 elemento, terminando a busca. Para dar uma idéia do ganho em eficiência, em um hipotético catálogo com um milhão de nomes, precisaríamos de menos de 20 comparações para achar um dado nome (log2 106 20).

Exercício 3.15: Considere um catálogo de telefones na sua casa:

  1. estime, muito aproximadamente, quantos assinantes estão listados.
  2. Procure um nome qualquer no catálogo, e conte quantos outros nomes você tem que ler até achar (ou não) o que você procura.
  3. Seria viável achar algum nome no catálogo se este não estivesse organizado em ordem alfabética?

Poderíamos tentar traduzir diretamente esse algoritmo para nossa implementação em listas, mantendo a lista de elementos de um conjunto sempre em ordem crescente. Mas existe um problema: para fazermos a primeira comparação, precisamos acessar um elemento no meio da lista, e portanto teríamos que percorrer meia lista; não há como acessarmos esse elemento diretamente. Para resolver esse problema, podemos representar nosso conjunto não como uma lista ordenada, mas como uma árvore binária de busca.

Para estudarmos árvores de busca, precisamos saber o que é uma árvore binária. Uma árvore binária é uma estrutura recursiva, com uma definição algo similar a que apresentamos para listas na página lista-rec. Mais precisamente, uma árvore binária é ou 1) uma árvore vazia, ou 2) um elemento qualquer, denominado raiz, mais duas árvores, denominadas sub-árvore esquerda e sub-árvore direita. Os elementos de uma árvore são usualmente chamados de nós.

Árvores binárias têm uma representação gráfica que (em parte) justifica o nome árvore, mas onde a raiz é representada em cima, enquanto a árvore `cresce' para baixo.

   Figura 3.1: Representação gráfica de uma árvore binária
(incluindo as sub-árvores vazias).

A figura
3.1 exemplifica a representação gráfica de uma árvore binária, onde usamos o símbolo para representar as árvores vazias.

   Figura 3.2: Árvore binária da figura 3.1,
sem mostrar as sub-árvores vazias.

Note que, em geral, não desenhamos as sub-árvores vazias, donde a mesma árvore poderia ser desenhada como na figura
3.2. A raiz dessa árvore é o nó A, sua sub-árvore esquerda tem B como raiz, e C é a raiz da sub-árvore direita. Os nós F, H, I e J são as folhas, isto é, árvores cujas duas sub-árvores são vazias, enquanto os nós B e E tem uma das suas sub-árvores vazias (a da esquerda em B, e da direita em E). Os filhos do nó C são os nós F e G; o nó B tem um único filho, E; folhas não têm filhos. O pai do nó E é o nó B; a raiz é o único nó `órfão' em uma árvore. A altura de uma árvore é o maior número de nós que temos que passar para ir da raiz até um nó qualquer da árvore. Uma árvore vazia tem altura 0, e uma folha tem altura 1. A árvore da figura 3.2 tem altura 4.

Para manipular árvores binárias em Scheme, podemos representar essas árvores através de listas. Uma forma possível para essa representação é descrita a seguir: árvores vazias são representadas pela lista vazia. Árvores não vazias são representadas por uma lista com 3 elementos: o primeiro é a raiz da árvore, enquanto o segundo e o terceiro elementos são listas representando as sub-árvores esquerda e direita. Essa representação é chamada pré-fixada.


Exemplo 3.2: Usando a representação pré-fixada na árvore da figura 3.2, temos a lista

(A (B () (E (H () ()) ())) (C (F () ()) (G (I () ()) (J () ()))))

Ou seja, a raiz da árvore é A, sua sub-árvore esquerda é a árvore (B () (E (H () ()) ())), e sua sub-árvore direita é (C (F () ()) (G (I () ()) (J () ()))). A sub-árvore (B () (E (H () ()) ())) tem como raiz B, uma árvore vazia como sub-árvore esquerda, e a árvore (E (H () ()) ()) como sub-árvore direita. Finalmente, a sub-árvore (C (F () ()) (G (I () ()) (J () ()))) tem raiz C, sua sub-árvore esquerda é a folha (F () ()), e sua sub-árvore direita é a árvore (G (I () ()) (J () ())).


Uma forma de melhorarmos essa representação vem do fato que, usualmente, muitos nós de uma árvore binária são folhas. Como folhas têm sub-árvores vazias, não há necessidade de representarmos essas sub-árvores, desde que haja alguma forma de se saber se uma dada árvore é uma folha. Por isso, podemos representar folhas diretamente pelo conteúdo de sua raiz, e utilizar o predicado pré-definido de Scheme list?: toda árvore cuja representação não for uma lista é uma folha, e portanto subentende-se que suas sub-árvores são vazias.


Exemplo 3.3: Com a modificação descrita acima, a mesma árvore da figura 3.2 pode ser representada pela lista

(A (B () (E H ())) (C F (G I J)))

É importante frizar que folhas não são um caso extra de árvores. Folhas são árvores `normais', e podem ser representadas dessa forma. Só na representação de árvores estamos tratando folhas como um caso a parte, para termos representações mais eficientes.


Exercício 3.16: Desenhe as árvores representadas pelas seguintes expressões pré-fixadas:

  1. (A B C)
  2. (5 (1 3 4) 7)
  3. (2 (10 () 3) (2 1 9))
  4. A
  5. (A () (B () (C () (D () E))))

Da mesma forma que fizemos com nossa representação de números naturais, vamos definir algumas operações básicas para manipulação de árvores. Primeiro, definimos uma constante com o valor da árvore vazia; para testarmos se uma árvore é vazia, definimos o predicado vazia?:
      (define arvore-vazia '())

(define vazia? null?)
O predicado folha? verifica se uma árvore é uma folha. Observe que as folhas são as únicas árvores que não representamos como listas, portanto basta testar se a representação não é uma lista para sabermos se é uma folha:
      (define folha?
        (lambda (a)
          (not (list? a))))
Para criarmos uma árvore, dadas suas três partes, definimos a função cria-arvore: se as duas sub-árvores e e d forem vazias, a nova árvore é uma folha, e pode ser representada diretamente pelo valor da raiz. Caso contrário, contruimos a lista com os três elementos dados:
      (define cria-arvore
        (lambda (r e d)
          (if (and (vazia? e) (vazia? d))
              r
              (cons r (cons e (cons d '()))))))

A função raiz retorna a raiz de uma dada árvore:
      (define raiz
        (lambda (a)
          (if (folha? a)
              a
              (car a))))
Se a árvore é uma folha, seu valor é o valor da raiz. Caso contrário, a raiz é o primeiro elemento da árvore. Essa função não pode ser aplicada sobre a árvore vazia, que não tem raiz.

Exercício 3.17:
Defina as funções esquerda e direita para acessar as duas sub-árvores de uma árvore. Lembre-se de tratar o caso da árvore ser uma folha.

Geralmente, funções recursivas definidas sobre árvores binárias envolvem duas chamadas recursivas, uma para cada sub-árvore.


Exemplo 3.4: Considere uma função para contar o número de nós em uma dada árvore. Uma árvore vazia tem 0 nós, uma folha tem 1. Outras árvores tem um nó na raiz, mais os das suas sub-árvores:
      (define num-nos
        (lambda (a)
          (cond
            ((vazia? a) 0)
            ((folha? a) 1)   ; caso opcional
            (else (+ 1 (num-nos (esquerda a)) (num-nos (direita a)))))))
Na realidade, como os operadores esquerda e direita já tratam folhas corretamente, não precisamos tratar folhas de modo separado (abstração de dados):
      (define num-nos
        (lambda (a)
          (cond
            ((vazia? a) 0)
            (else (+ 1 (num-nos (esquerda a)) (num-nos (direita a)))))))
Com a definição acima, quando a for uma folha, (esquerda a) retornará {()}, fazendo (num-nos (esquerda a)) valer 0; o mesmo vale para o lado direito. Logo, num-nos retornará 1, como esperado.



Exemplo 3.5:
Uma função para somar 1 em todos os nós de uma árvore de números pode ser definida como abaixo. Observe como fazemos a recursão sobre as duas sub-árvores, e usamos cria-arvore para compor o resultado:
      (define soma-arvore
        (lambda (a)
          (if (vazia? a)
              a
              (cria-arvore (+ 1 (raiz a))
                           (soma-arvore (esquerda a))
                           (soma-arvore (direita a))))))


Exercício 3.18: Usando as operações definidas acima para manipulação de árvores, escreva as funções abaixo:

  1. uma função que retorne a soma de todos os nós de uma dada árvore de números;
  2. uma função que retorne a altura de uma dada árvore; (dica: use a função pré-definida max.)
  3. uma função que retorne uma lista com todos os elementos de uma dada árvore; (dica: use a função pré-definida append, ou a função definida no exercício 2.2.h, para juntar as várias partes do resultado.)
  4. na função do item anterior, em que ordem os elementos da árvore aparecem na lista? Qual é o primeiro? Qual é o último?
  5. uma função que receba n e retorne uma árvore contendo n nós, todos com o valor 1. Procure fazer as duas sub-árvores terem sempre aproximadamente o mesmo número de elementos.
  6. uma função que receba n e retorne uma árvore contendo n nós, cada um com um valor diferente de 1 até n. Procure fazer as duas sub-árvores terem sempre aproximadamente o mesmo número de elementos. (dica: defina sua função com dois parâmetros, i e n, para retornar uma árvore numerada de i até n.)

Exercício 3.19: Considere a função definida no exercício
3.18.e. Qual é a relação entre o n dado e a altura da árvore resultante dessa função?

Representação de Expressões Aritméticas

Um uso bastante comum de árvores binárias é na representação de expressões aritméticas. Essas árvores têm como elementos números e operadores aritméticos. Toda árvore cuja raiz é um número é uma folha. Quando a raiz é um operador, as sub-árvores esquerda e direita representam o primeiro e o segundo operandos, respectivamente.

   Figura 3.3: Árvore binária representando a expressão 5 ×  (3 + 1).


Exemplo 3.6: A figura
3.3 mostra a árvore binária que representa a expressão 5 × (3 + 1). Essa árvore tem como raiz o nó *, como sub-árvore esquerda uma folha com raiz 5, e como sub-árvore direita outra expressão. Essa sub-árvore direita, por sua vez, tem como raiz o +, como sub-árvore esquerda o 3, e como sub-árvore direita o 1.


Exercício 3.20: Desenhe árvores representando as seguintes expressões:

  1. 3+4
  2. 3 × 4 + 5
  3. 3 + 4 × 5
  4. 3+4+5
  5. 3/4 + 5/2
  6. 3+2+10+9+2
  7. (3+2)+10+(9+2)

Se aplicarmos a representação pré-fixada descrita acima sobre árvores de expressões, vamos chegar exatamente na forma como escrevemos expressões aritméticas em Scheme. Por exemplo, a árvore na figura
3.3 é representada pela lista (* 5 (+ 3 1)), que é como representamos aquela expressão em Scheme.

Como já vimos, uma árvore binária não vazia é composta por três partes: a raiz, a sub-árvore esquerda e a sub-árvore direita. Na representação pré-fixada, colocamos esses três elementos em uma lista, na ordem raiz -- sub-árvore esquerda -- sub-árvore direita, mas poderíamos colocá-los em qualquer ordem. Em particular, na chamada notação in-fixada, colocamos primeiro a sub-árvore esquerda (representada na ordem in-fixada também), em seguida a raiz, e finalmente a sub-árvore direita (também na ordem in-fixada). Na notação pós-fixada, por sua vez, colocamos primeiro a sub-árvore esquerda (representada agora na ordem pós-fixada), em seguida a sub-árvore direita (também na ordem pós-fixada), e no final a raiz.


Exemplo 3.7: Voltando à figura 3.3, temos que a representação in-fixada daquela expressão é bastante familiar: (5 * (3 + 1)). A representação pós-fixada, por sua vez, é (5 (3 1 +) *).


Exercício 3.21: Represente as árvores desenhadas no exercício 3.20 nas formas in-fixada e pós-fixada.

É interessante observar como a representação que usamos para árvores, in-fixada, pré-fixada ou pós-fixada, fica `abstraida' quando usamos nossas funções de manipulação raiz, folha?, cria-arvore, etc. Por exemplo, a função soma-arvore descrita no exemplo 3.5 não faz nenhuma referência ao fato da árvore estar representada na forma pós-fixada. Se decidirmos usar a representação pós-fixada, por exemplo, precisamos apenas redefinir nossas operações básicas.

Exercício 3.22: Redefina as funções básicas sobre árvores (arvore-vazia, vazia?, folha, raiz, direita, esquerda e cria-arvore) para operarem sobre árvores representadas na forma in-fixada. Note que nem todas precisam ser modificadas.

Exercício 3.23: Escreva uma função que receba uma expressão na forma pré-fixada e retorne a representação da mesma expressão na forma in-fixada. Por exemplo

(converte '(* (+ 5 4) (- 2 3))) ((5 + 4) * (2 - 3))

(dica: se sua árvore é uma folha (isto é, um número), é fácil, pois folhas são iguais nas formas pré-fixada e in-fixada. Caso contrário, troque os elementos de ordem, chamando a função recursivamente para converter as sub-árvores.)

Árvores Binárias de Busca

Vamos voltar ao nosso problema de procurar um valor em um determinado conjunto de maneira eficiente, e ver como podemos usar árvores binárias para resolvê-lo usando listas.

Imagine uma árvore binária de números, com a seguinte propriedade: todos os elementos da sub-árvore esquerda são menores que a raiz, e todos os elementos da sub-árvore direita são maiores que a raiz. Se formos procurar um determinado valor nessa árvore, apenas comparando nosso valor com a raiz, sabemos em que sub-árvore nosso valor pode ser encontrado: se o valor for igual à raiz, encontramos o que estamos procurando; se for menor, só pode estar na sub-árvore esquerda, e se for maior, na sub-árvore direita. Assim, com apenas uma comparação, dividimos em duas a árvore onde precisamos continuar a busca. Se as sub-árvores também tiverem a propriedade acima, podemos continuar nossa busca `descendo' pela árvore, em cada passo decidindo se continuamos pela esquerda ou pela direita, até encontrarmos o elemento procurado ou chegarmos em uma árvore vazia (significando que o elemento não se encontra em nosso conjunto).

Mais precisamente, definimos uma árvore binária de busca, ou simplesmente árvore de busca, como uma árvore binária onde todas as sub-árvores satisfazem o invariante: se a árvore não é vazia, todos os elementos da sub-árvore esquerda são menores que a raiz, e todos os elementos da sub-árvore direita são maiores que a raiz.


Exemplo 3.8:

   Figura 3.4: Uma árvore de busca.

A árvore na figura
3.4 ilustra uma possível árvore de busca para o conjunto {1,2,4,5,6,7,10,11,13,20,24,56}.


Exercício 3.24: Um mesmo conjunto pode ser representado por diversas árvores de busca. Desenhe duas outras árvores de busca que representem o mesmo conjunto da árvore da figura 3.4.

Exercício 3.25: Defina uma função que retorne o menor elemento de uma árvore de busca. (dica: o menor elemento de uma árvore de busca está sempre no seu `canto' mais esquerdo.)

Exercício 3.26: Escreva o predicado pertence? sobre árvores de busca. Lembre-se: se a árvore for vazia, nenhum elemento pertence a ela. Caso contrário, se o elemento procurado for igual à raiz, então ele pertence ao conjunto. Se ele for menor que a raiz, deve-se procurar na sub-árvore esquerda; se for maior, na sub-árvore direita.

Exercício 3.27:
Escreva um predicado para inserir um novo elemento em uma dada árvore de busca. Se a árvore for vazia, retorne uma folha com o novo elemento. Se o elemento a ser inserido for igual à raiz, então ele já pertence a árvore, e podemos retornar a árvore sem modificações. Se ele for menor que a raiz, devemos inserí-lo na sub-árvore esquerda; se for maior, na sub-árvore direita.

Antes de continuarmos, é importante frizar o que vimos nesta seção: como diferentes representações para um mesmo tipo, como listas × árvores de busca para conjuntos, podem implicar em operações com desempenhos bastante desiguais. Não por acaso, em geral representações com operações mais eficientes envolvem implementações mais complexas. O estudo de representações eficientes para diferentes tipos de informação é uma das áreas centrais em computação. Na próxima seção, vamos ver uma outra representação para números naturais, bem mais eficiente que a que vimos na seção
3.2.

3.5 : Notação Posicional e Bases de Numeração

A notação unária, vista na seção 3.2, apesar de muito simples, não é muito eficiente. Por exemplo, para representarmos o número 1.598.980, precisaremos de uma lista com 1.598.980 elementos. Para calcularmos uma soma como 1354+4590 iremos executar milhares de vezes as funções dec e inc. Uma representação bem mais eficiente para números naturais, que podemos usar no computador, é a que a humanidade já vem usando há alguns séculos: a notação posicional decimal, ou base 10.

Usando a notação decimal, podemos representar um número como uma lista de dígitos, cada um entre 0 e 9, onde cada casa vale 10 vezes mais que a anterior. Por convenção, vamos armazenar a lista começando com as unidades, depois as dezenas, e assim por diante. Isso é, um número como 1.542 será representado pela lista (2 4 5 1). Assim, ao invés de uma lista com 1.542 elementos da notação unária, temos agora uma lista com apenas 4 elementos. É comum acharmos anti-natural essa representação aparentemente invertida, com as unidades à esquerda. Entretanto, se pensarmos em como fazemos operações aritméticas, vamos perceber que em geral tratamos os vários dígitos nessa ordem. Por exemplo, quando somamos dois números, primeiro somamos as unidades, depois as dezenas, e assim por diante. Por isso, essa ordem torna mais simples a implementação de várias operações sobre números.

Para facilitar nossas operações, vamos convencionar que nossos números nunca terão zeros não significativos, isto é, o último elemento da lista nunca será 0. (Temos aqui mais um exemplo do uso de um invariante). Em particular, o número 0 será representado pela lista vazia, e não pela lista (0).

Para entender melhor a representação base 10, considere a função base->num definida abaixo. Essa função recebe um número na notação base 10 e o converte para um número na representação interna de Scheme.
      (define base->num
        (lambda (l)
          (if (null? l)
              0
              (+ (car l) (* base (base->num (cdr l)))))))
onde base foi definida como

(define base 10)

Ao calcularmos o valor de (base->num '(1 8 5)) teremos o traço abaixo:

(base->num '(1 8 5))

(+ 1 (* 10 (base->num '(8 5))))

(+ 1 (* 10 (+ 8 (* 10 (base->num '(5))))))

(+ 1 (* 10 (+ 8 (* 10 (+ 5 (* 10 (base->num '())))))))

(+ 1 (* 10 (+ 8 (* 10 (+ 5 (* 10 0))))))

(+ 1 (* 10 (+ 8 (* 10 5))))

(+ 1 (* 10 58))

581

A função inversa, que converte da representação interna de Scheme para nossa base 10, pode ser definida como:
      (define num->base
        (lambda (n)
          (if (zero? n)
              '()
              (cons (remainder n base) (num->base (quotient n base))))))
Como exemplo, o traço de (num->base 581) será:

(num->base 581)

(cons (remainder 581 10) (num->base (quotient 581 10)))

(cons 1 (num->base 58))

(cons 1 (cons (remainder 58 10) (num->base (quotient 58 10))))

(cons 1 (cons 8 (num->base 5)))

(cons 1 (cons 8 (cons (remainder 5 10) (num->base (quotient 5 10)))))

(cons 1 (cons 8 (cons 5 (num->base 0))))

(cons 1 (cons 8 (cons 5 '())))

'(1 8 5)

Operações Aritméticas com Notação Posicional

Vamos ver agora como definir as operações aritméticas usando essa representação. A constante zero é trivialmente definida como

(define zero '())

e o predicado zer? como

(define zer? null?)

Note como o invariante simplifica esse predicado. Se não tivessemos o invariante, teríamos que verificar casos como (0 0 0). A operação de incremento já não é tão simples. Temos três casos: se a lista for vazia (isto é, zero), o incremento é '(1). Se a lista não for vazia, verificamos se podemos incrementar seu primeiro dígito. Se sim, isso é tudo que precisamos fazer. Mas se ao incrementarmos o primeiro dígito chegamos na base, temos que usar o `vai um': o primeiro dígito vai para 0 e incrementamos o resto da lista. Traduzindo para Scheme temos13 :
      (define inc
        (lambda (n)
          (cond
            ((null? n) '(1))
            ((= (+ (car n) 1) base) (cons 0 (inc (cdr n)))))))
            (else (cons (+ (car n) 1) (cdr n)))

Exercício 3.28: Faça o traço das chamadas (inc '(9 3 1)) e (inc '(9 9)).

Exercício 3.29: Defina a função dec para decrementar um número representado na base 10.

Tendo definido as operações primitivas zero, zer?, inc e dec, podemos definir a operação de soma exatamente como havíamos definido na representação unária. Apesar de correta, essa operação seria tão ineficiente quanto era no caso anterior. Nos valendo da representação base 10, podemos definir a função de soma de uma forma um pouco mais complexa, mas muito mais eficiente. Novamente, vamos imitar o que a humanidade faz há alguns séculos.

Vamos imaginar que queremos somar 98+1707:

Para executarmos essa soma, primeiro somamos os dois primeiros algarismos; caso essa soma seja maior que 10 (a base), diminuimos 10 do total e marcamos um `vai um'.

A seguir, repetimos exatamente os mesmos passos para o resto dos dois números (lidos da direita para a esquerda). Isso é, somamos os dois primeiros algarismos e o `vai um', verificamos se o resultado é maior que a base, etc.

Esse processo deve ser repetido até os dois números terminarem. Porém, ocorre frequentemente de um dos números terminar antes do outro. No nosso exemplo, vamos chegar na seguinte situação:

Nesse caso, simplesmente estendemos o número menor com um 0 e continuamos o processo:

Quando finalmente os dois números terminarem, chegamos ao fim da nossa operação:

Note que, se os dois números a serem somados respeitarem o invariante (isto é, se eles não tiverem zeros à esquerda), o resultado também respeitará.

      (define soma
        (lambda (a b v)     ; v indica "vai um"
          (cond
            ((and (null? a) (null? b)) (if (zero? v) '() '(1)))
            ((null? a) (soma '(0) b v))
            ((null? b) (soma a '(0) v))
            (else (let ((d (+ (car a) (car b) v)))
                    (if (< d base)
                      (cons d (soma (cdr a) (cdr b) 0))
                      (cons (- d base) (soma (cdr a) (cdr b) 1))))))))
   Figura 3.5: Função para somar dois números na base decimal.

A tradução para Scheme do algoritmo descrito acima está na figura
3.5.

Vários pontos merecem comentário. Primeiro, nossa função deve ter 3 argumentos: os dois números sendo somados, e o `vai um'. Quando os dois números terminarem, basta verificarmos o `vai um' para termos o resultado. Se um dos números terminar antes do outro, chamamos recursivamente a função com os mesmos argumentos, mas trocando o número que terminou pela lista (0). Com isso, temos o mesmo efeito de estendermos o número com zeros. Finalmente, no caso normal, definimos uma variável local d com a soma dos dois primeiros algarismos e o vai um. Dependendo desse valor ser menor que a base (10) ou não, a chamada recursiva deve indicar (ou não) o vai um.

Exercício 3.30: Qual o traço da chamada (soma '(8 9) '(7 0 7 1))?

Exercício 3.31: Implemente as seguintes funções, para operarem sobre dois números representados na base 10:

  1. uma função para subtrair dois números. Siga o mesmo raciocínio que usamos para soma, tentando traduzir para Scheme o algoritmo de subtração que usamos manualmente.
  2. Um predicado para testar se um número é menor que outro.

Base Binária

Se redefinirmos a variável base como 1, teremos problemas já na função num->base; como (quotient n 1) é igual a n, essa função ficaria em loop se a base fosse 1. Mas se redefinirmos base como qualquer outro número natural maior ou igual a 2, todas as funções continuarão funcionando corretamente. A maneira mais fácil de percebermos isso é verificando que em nenhuma das funções usamos o fato de base ser 10.

Também é importante observarmos que, para qualquer número natural n,

(base->num (num->base n)) n

Isso significa que o resultado de (num->base n) continua sendo uma boa representação para um número natural n qualquer, mesmo com outras bases, pois sempre podemos voltar ao número original usando base->num. Para verificarmos que isso é realmente verdade, podemos usar indução. Se n=0, a igualdade é trivial, pois:

(base->num (num->base 0))
definição de num->base
(base->num '())
definição de base->num
0

Para outros valores de n, com base sendo um natural b>=2, temos:

(base->num (num->base n))
def. num->base
(base->num (cons n mod b (num->base (n÷ b))))
def. base->num
(+ n mod b (* b (base->num (num->base (n÷ b)))))
indução
(+ n mod b (* b (n÷ b)))

n mod b + b × (n÷ b)
aritmética
n

Note que, no caso específico de base=1, a função num->base não termina, e portanto a prova acima não é válida.

De particular interesse para nós é a base 2, chamada de base binária. Nessa base, qualquer número pode ser escrito usando-se apenas os algarismos 0 e 1; por exemplo, o número 154 pode ser representado em binário pela lista '(0 1 0 1 1 0 0 1), pois:

0 + (2 × (1 + 2 × (0 + 2 × (1 + 2 × (1 + 2 × (0 + 2 × (0 + 2 × 1))))))) = 154

Apesar da representação de um número na base binária usar mais algarismos que na base decimal, a representação e manipulação de cada algarismo é bem mais simples na base binária, pois só existem dois algarismos possíveis.

A base binária é a forma usual como um computador representa números internamente. Entretanto, através do uso de mecanismos de abstração, raramente essa representação é visível quando utilizamos um computador: operações como + e * têm comportamento igual em qualquer representação, e o uso de funções como num->base e base->num permite que números sejam lidos e escritos externamente na base decimal, com a qual estamos acostumados.

Exercício 3.32: Converta os números abaixo para as bases indicadas (se quiser, use sua função num->base, definindo antes base com o valor adequado):

  1. 2, 4, 6, 16, e 33 para a base binária (base 2);
  2. 3, 6, e 12 para a base 3;
  3. 13, 64, e 60 para a base 16.

Exercício 3.33: Converta os números em base binária '(0 1 1 0 1), '(1 1 1), e '(1 1 0 1) para a base decimal (se quiser, use a função base->num, definindo antes base com o valor 2).

Exercício 3.34: Faça o traço da chamada

(soma '(1 1 1 0 1) '(0 1 1))

assumindo que base vale 2. Converta os argumentos para a base decimal e verifique o resultado.

Exercício 3.35: Se um número precisa de x dígitos para ser representado na base decimal, quantos dígitos serão necessários para representá-lo na base binária?

Exercício 3.36: Da mesma forma que é muito simples multiplicarmos um número por 10, quando usamos nossa notação decimal, algumas operações sobre números na base binária tem implementações triviais. Mostre como executar as seguintes operações sobre um dado número representado na base binária:

  1. dup2(x) = 2x
  2. div2(x) = x ÷ 2
  3. verificar se o número é par.

Exercício 3.37: Considere as seguintes igualdades: a × b = 0, para b=0; a × b = 2(a × (b÷ 2)), para b par; e a × b = a+2(a × (b÷ 2)), para b ímpar (note que, para b ímpar, b÷ 2 = (b-1)÷ 2). Usando essas igualdades, e as funções já definidas nos exercícios anteriores, implemente uma função para multiplicar dois números na base binária. (É interessante observar que esse algoritmo de multiplicação é exatamente o mesmo que usamos para multiplicar dois números manualmente, `traduzido' para a base binária. Nesta base, o algoritmo fica bastante simplificado, pois a multiplicação por cada algarismo é trivial, sendo sempre uma multiplicação por 0 ou por 1.) (dica: veja o exercício 2.12.)

3.6 : Outros Exercícios

Exercício 3.38: Podemos representar um polinômio

an xn + an-1 xn-1 + ... + a1 x + a0

no computador através da lista de seus coeficientes, (a0 a1 ... an). Por exemplo, o polinômio 3x2 - 5x + 23 é representado pela lista (23 -5 3).

  1. Usando esta representação, escreva uma função que receba um polinômio e um valor para x, e retorne o valor do polinômio para o x dado. (dica: use a igualdade

    an xn + an-1 xn-1 + ... + a1 x + a0 = a0 + x(a1 + x(a2 + ... + x (an) ... ))

    para simplificar os cálculos.)
  2. Escreva uma função auxiliar que receba n e retorne a lista (1/1! - 1/3! 1/5! - 1/7! ... ) com n elementos.
  3. A lista gerada pelo item anterior, com n=4, representa o polinômio Usando a função do primeiro item, calcule o valor desse polinômio para alguns valores de x entre 0 e 1. Compare os valores obtidos com os de sin x. Aumente o número de termos do seu polinômio (aumentando n na função do item anterior), e repita a comparação.
  4. Escreva uma função que some dois polinômios. A soma de dois polinômios é um polinômio de grau igual ao maior grau entre os polinômios somados, e onde cada coeficiente é igual a soma dos coeficientes de igual grau nos polinômios somados. (dica: veja exercício 2.5.f.)
  5. Escreva uma função que receba um polinômio e retorne sua derivada. Relembrando, a derivada de um polinômio de grau n

    an xn + an-1 xn-1 + ... + a1 x + a0

    é o polinômio de grau n-1

    (n × an) xn-1 + ((n-1) × an-1) xn-2 + ... + 2a2 x + a1

      (define calc
         (lambda (exp)
            (cond
               ((number? exp) exp)
               ((equal? (car exp) '+) (+ (calc (cadr exp)) (calc (caddr exp))))
               ((equal? (car exp) '*) (* (calc (cadr exp)) (calc (caddr exp)))))))
   Figura 3.6: Definição inicial da função calc.

Exercício 3.39: Considere a função mostrada na figura
3.6, onde number? é um predicado pré-definido que testa se um determinado valor é um número.

  1. faça o traço da chamada (calc '(+ (* 4 5) 2)).

  2. Modifique a função calc de forma que ela também interprete subtração e divisão.

  3. Modifique sua função para tratar expressões com nomes, como por exemplo (+ x y). Para isso, vamos precisar de um ambiente, que associe cada nome ao seu valor. Esse ambiente pode ser representado como uma lista associativa; por exemplo, o ambiente {x 12, y 23} será representado pela lista ((x 12) (y 23)). O ambiente deve então ser passado como um parâmetro adicional para a função calc, e consultado (use uma função auxiliar consulta) toda vez que a expressão for um nome (use o predicado symbol? para testar se a expressão é um nome). Um exemplo de uso segue abaixo; lembre-se que calc agora recebe dois argumentos, a expressão a ser calculada e o ambiente com o valor das variáveis:

    (calc 'b '((a 1) (b 8))) 8

    (calc '(* (+ y 3) x) '((x 2) (y 5))) 16

  4. Usando a função acima, escreva uma função declare que acrescente um par (nome valor) em um dado ambiente, mas onde o valor pode ser dado através de uma expressão. Essa função recebe um par (nome expressão) e um ambiente, e retorna o novo ambiente. Por exemplo:

    (declare '(x 1) '((y 2) (z 5))) ((x 1) (y 2) (z 5))

    (declare '(val (* 2 2)) '((y 2) (z 5))) ((val 4) (y 2) (z 5))

    (declare '(x (+ 4 y)) '((y 2) (z 5))) ((x 6) (y 2) (z 5))

  5. Usando a função acima, acrescente na sua função calc uma operação para definir valores (parecida com o let), chamada def. O uso de def é exemplificado abaixo:

    (calc '(def (x 10) (+ x 3)) '()) 13

    (calc '(def (x (+ 2 8)) (* (+ x 3) y)) '((y 5))) 65

    (dica: use calc para calcular o valor da expressão, em um ambiente onde a nova variável foi associada ao valor adequado. Use a função do item anterior para construir esse novo ambiente.)



Capítulo 4 :


Programação Procedural

Até agora, temos trabalhado primordialmente com avaliação de expressões: entramos com uma expressão para o interpretador, e este mostra o valor final da expressão. Após o término da execução de uma expressão, tudo que sobra é seu valor na tela; de resto, o sistema não sofre qualquer modificação.

Existem outros tipos de operações, como o define, que, ao contrário de apenas calcular um valor, alteram o estado do sistema, afetando a maneira como esse funciona. Quando executamos um define, não estamos interessados no valor retornado, e sim na alteração causada no ambiente pela associação de um símbolo com um valor. Esse tipo de operação é chamado de procedimento.
14

Em geral, usamos o termo procedimento para nos referir a uma função quando queremos frizar o fato dela ser usada para alterar o ambiente, e não para retornar um valor. Ao contrário, quando quisermos frizar que uma função não é um procedimento, podemos chamá-la de função pura. Assim, procedimentos são funções que, quando chamadas, modificam o ambiente de alguma forma, enquanto funções puras simplesmente retornam um resultado. A programação com procedimentos nos leva a um estilo de programação um pouco diferente, usualmente chamado programação procedural, ou programação imperativa, em oposição ao estilo que vinhamos adotando até então, chamado programação funcional. Em especial, o uso de traços não é suficiente para descrevermos o funcionamento de procedimentos, pois não temos como expressar as modificações no ambiente. Recursão continua sendo um recurso fundamental em programação procedural, mas como nunca utilizamos valores retornados por procedimentos, procedimentos quase sempre usarão apenas recursão final.

4.1 : Interação

Todas as funções que definimos até agora recebem argumentos e retornam um valor. Através do interpretador Scheme, podemos chamar qualquer função fornecendo seus argumentos, e ver o seu resultado. Entretanto, muitos programas tem outras formas de interação com o usuário. Muitas vezes é necessário mostrar resultados intermediários, ler outros dados fornecidos pelo usuário, ou apenas mostrar os resultados em algum outro formato. Para isso, as linguagens oferecem funções especiais tanto para entrada quanto para a saída de dados.

Para ilustrar nossa discussão, vamos imaginar que queremos uma função que, quando chamada com um argumento x, mostre algo como

O valor do seno de x é y

onde y é o valor de sin x. Essa função (ou, mais especificamente, procedimento) poderia ser escrita como:
      (define mostra-seno
        (lambda (x)
          (begin (display "O valor do seno de ")
                 (display x)
                 (display " é ")
                 (display (sin x)))))
Para mostrarmos um valor na tela, usamos o procedimento display, que simplesmente mostra na tela o valor de seu único argumento. Note que cada chamada à função display exibe uma parte do nosso resultado na tela; o valor retornado pela função é totalmente irrelevante para nós. Para agrupar essas chamadas, usamos o construtor begin: uma chamada (begin c1 c2 ... cn) executa c1, depois c2, etc, e finalmente retorna o resultado de cn. Os valores retornados por c1,c2,...,cn-1 são simplesmente descartados.15

É interessante observar que o construtor begin só faz sentido junto com procedimentos. Uma expressão como

(begin (* 10 5) (- 5 4)) 1

apesar de correta para o interpretador, é um pouco absurda, pois vai calcular (* 10 5) para jogar fora o resultado.

Vamos ver agora um exemplo um pouco mais complexo.


Exemplo 4.1: Imagine que queremos mostrar todos os elementos de uma dada lista, um por linha. No caso base, que é a lista vazia, simplesmente não fazemos nada, pois não há nada a ser mostrado. No caso recursivo, mostramos o primeiro elemento e chamamos o procedimento recursivamente para mostrar o resto da lista:
      (define mostra-lista
        (lambda (l)
          (if (null? l)
              '()
              (begin
                (display (car l))
                (newline)
                (mostra-lista (cdr l))))))
O procedimento newline é um procedimento sem parâmetros, que quando chamado pula uma linha no terminal.


A lista vazia no primeiro caso do if é algo artificial, pois esse é o caso em que não há nada a ser feito. Da mesma forma que colocamos '(), poderíamos ter colocado 0, ou mesmo 1. Como essa situação ocorre frequentemente em procedimentos, existe um formato especial para isso: o segundo caso do if é opcional, e quando não fornecido significa exatamente que não há nada a ser feito nem valor a ser dado neste caso. Portanto, nosso exemplo pode ser reescrito assim, usando-se not na condição para invertermos os casos:
      (define mostra-lista
        (lambda (l)
          (if (not (null? l))
              (begin
                (display (car l))
                (newline)
                (mostra-lista (cdr l))))))

Esse uso do if com apenas um caso é outra característica típica de programação procedural. Em funções puras sempre precisamos especificar o valor a ser retornando, nos dois casos de um condicional. Em procedimentos, é comum não haver nada a ser retornado nem executado em um dos casos, então podemos simplesmente omitir esse caso.


Exemplo 4.2:
Um procedimento bastante útil é um que mostra repetidas vezes um mesmo texto na tela. Sua definição é bastante simples:
      (define (mostra-n n t)
        (if (not (zero? n))
           (begin
             (display t)
             (mostra-n (- n 1) t))))
Com essa função, uma chamada como (mostra-n 40 "*") produz 40 asteriscos na saída, e uma chamada como (mostra-n x " ") serve para pular x espaços em branco.


Exercício 4.1:
Escreva procedimentos para realizar as tarefas abaixo:

  1. Dado um número n, escrever os valores de n2,(n-1)2,...,9,4,1, um por linha.
  2. Dado um número n, escrever os valores de 1, 4, 9, ...,(n-1)2,n2, um por linha.
  3. Escrever os elementos de uma dada lista, um por linha, precedidos pelo seu número de ordem. Por exemplo, uma chamada como

    (mostra-num-linha '(martelo prego alicate))

    deve produzir uma saída como
    1 - martelo
    2 - prego
    3 - alicate
    
  4. Escrever os elementos de uma dada lista, dois por linha, separados por um espaço. Por exemplo, uma chamada como

    (mostra-lista '(martelo prego alicate tesoura serrote))

    deve produzir uma saída como
    martelo prego
    alicate tesoura
    serrote
    
    (dica: use o predicado even? para decidir se é hora de mudar de linha.)
  5. Generalize a função do exercício anterior para, ao invés de escrever dois elementos por linha, escrever n por linha, onde n é um parâmetro do procedimento.

Repetições em Programação Procedural

Conforme já mencionamos, o estilo de programação procedural nos leva a usar com bastante frequência procedimentos com recursão final. Portanto, é bastante natural usarmos o do na programação de procedimentos. Analisando a estrutura das chamadas recursivas em procedimentos, podemos perceber que é bastante comum a chamada recursiva ser precedida por outras ações, dentro de uma construção begin. O do tem uma facilidade específica para estruturarmos essas ações, no chamado corpo do do. O corpo de um do são expressões opcionais que podemos escrever depois das variáveis, condição de parada e valor final. As ações especificadas nesse corpo são executadas a cada repetição.

A sintaxe geral da construção do, incluindo um corpo opcional, é mostrada abaixo:
      (do ([n1 v1 e1]
            ...
           [nn vn en])
          (teste valor-final)
          corpo)
A sintaxe geral de do mostrada acima é equivalente a:
      (define aux
        (lambda (n1 ... nn)
          (if teste
              valor-final
              (begin
                corpo
                (aux e1 ... en)))))

(aux v1 ... vn) ; <= expressao equivalente ao do
A única diferença para o formato do do com que vinhamos trabalhando é a presença do corpo. Este pode ser formado por um número qualquer de expressões, sem necessidade de um begin, que são executadas a cada repetição do do.


Exemplo 4.3: Um procedimento para mostrar todos os valores de uma dada lista, um por linha, é mostrado a seguir:
      (define mostra
        (lambda (l)
          (do ((l1 l (cdr l1)))
              ((null? l1))
              (display (car l1))
              (newline))))
Observe que o segundo argumento do do, a lista ((null? l1)), tem um único elemento, o teste (null? l1). Como mostra é um procedimento, não precisamos especificar qual será o valor final do do. Neste exemplo, o corpo do do é formado por duas expressões, (display (car l1)) e (newline). Para cada valor de l1 dentro da repetição, essas duas expressões são executadas. Observe que não precisamos usar begin para juntar vários comandos no corpo do do.


Exercício 4.2: Reescreva os procedimentos do exercício
4.1 usando do.

Exercício 4.3: Através de procedimentos adequados, diversos tipos de dados representados por listas podem ser mostrados de forma mais natural. Defina procedimentos para exibir os tipos abaixo, no formato mostrado:

  1. Conjuntos representados por listas. Mostre os elementos entre chaves {...}, separados por vírgulas; por exemplo:

    (mostra-conjunto '(3 10 9 5)) {3,10,9,5}

  2. Números na representação binária. Mostre os números com os dígitos mais significativos à esquerda, e seguidos por um b. Trate o valor zero de maneira adequada; por exemplo:

    (mostra-binario '(0 0 1 0 1)) 10100b

    (mostra-binario '()) 0b

  3. Catálogos representados por listas associativas. Mostre cada par chave valor em uma linha, com um -> entre eles; por exemplo:

    (mostra-catalogo '((Denise 2221111) (Paulo 2741555)))

  4. Conjuntos representados por árvores de busca. Mostre os elementos entre chaves {...}, separados por vírgulas, em ordem crescente; por exemplo:

    (mostra-conjunto '(5 (3 1 4) (10 () 12))) {1,3,4,5,10,12}

    (dica: use suas funções para manipulação de árvores binárias (vazia?, raiz, etc). Se você percorrer a árvore da maneira adequada, os elementos aparecerão naturalmente em ordem crescente. A maior dificuldade deste exercício é colocar as vírgulas de maneira correta.)

Exercício 4.4: Uma maneira de mostrarmos uma árvore binária é colocando um valor em cada linha; primeiro a raiz, em seguida a sub-árvore esquerda e depois a sub-árvore direita. De modo a facilitar a identificação de cada sub-árvore, identamos cada nova sub-árvore com 4 espaços em branco a mais que sua raiz. Uma árvore vazia pode ser mostrada como um simples traço (-), e folhas não precisam mostrar suas sub-árvores. Assim, por exemplo, a árvore da figura
3.2 seria exibida como:

A
    B
        -
        E
            H
    C
        F
        G
            I
            J
Escreva um procedimento que, dada uma lista representando uma árvore binária, exiba a árvore da forma descrita acima. (dica: use a função mostra-n, do exemplo 4.2, para fazer a identação.)


Exemplo 4.4: Um uso bastante frequente de display é para acompanharmos a execução de um determinado programa. De maneira geral, qualquer função na forma
      (define f
         (lambda (a b)
           ...))
pode ser reescrita como
      (define f
         (lambda (a b)
           (begin (display a) (newline) (display b) (newline)
             ...)))
Com isso, cada vez que a função é chamada, ela mostra no terminal seus parâmetros. Assim, temos uma maneira de saber quando a função está sendo chamada, com que valores, etc. Isso é útil tanto para entendermos melhor o funcionamento de uma dada função quanto para detectarmos erros em alguma função que não esteja funcionando corretamente.


A função read permite lermos dados do terminal durante a execução de um programa. Quando chamada, essa função espera até que o usuário entre com um valor no teclado; esse valor é então retornado pela função read.


Exemplo 4.5: Uma chamada como

(+ (read) (read))

irá esperar o usuário (você!) entrar com dois números, e em seguidá imprimirá sua soma.16


Usualmente, um read é precedido por um display com uma mensagem avisando ao usuário que ele deve entrar com algum dado (um prompt, em inglês). Assim, o exemplo acima poderia ser reescrito como:
(begin (display "Entre com dois números")
       (newline)
       (+ (read) (read)))

Exercício 4.5: Defina funções para as tarefas abaixo:

  1. Pedir um número ao usuário, e em seguida mostrar a mensagem "O seno de x é sin x", onde x é o valor fornecido e sin x o seu seno. (dica: use let para guardar o valor lido.)
  2. Repetidamente pedir um número ao usuário, até este entrar com o número 0, e retornar a soma dos números lidos.
  3. Repetidamente pedir um número ao usuário, até este entrar com o número 0, e retornar uma lista com todos os números lidos, na mesma ordem em que eles foram dados.
  4. Pedir um número n ao usuário, em seguida ler mais n números, e retornar uma lista com esses n números.
  5. Repetidamente pedir um número ao usuário, até este entrar com o número 0, e mostrar a soma e o produto dos números lidos (não incluir o 0).

4.2 : Vetores

Vetores (ou arrays, como são chamados em outras linguagens) são estruturas de dados de certo modo similares a listas, no sentido que também armazenam uma sequência de valores. Entretanto, enquanto em listas só podemos manipular diretamente o primeiro elemento, através de car, vetores permitem o que chamamos de acesso direto. Isso significa que podemos consultar e/ou alterar diretamente qualquer elemento de um vetor.

Vetores em Scheme são representados textualmente entre um #( e um ). Por exemplo, a expressão

#(3 19 (8 9) 4)

representa um vetor com 4 elementos. Assim como listas, para descrevermos diretamente um vetor em Scheme devemos usar o quote. Por exemplo, para definir v como sendo o vetor acima, escrevemos

(define v '#(3 19 (8 9) 4))

Para acessarmos um elemento qualquer de um vetor, usamos a função vector-ref. Essa função recebe o vetor a ser consultado e um índice, um número natural que indica a posição do elemento que queremos consultar. Em Scheme, a indexação de vetores começa sempre com 0; assim, para acessarmos o primeiro elemento usamos o índice 0, para o segundo elemento o índice 1, etc. Em particular, em um vetor com n elementos, o índice do último elemento será n-1. Por exemplo, se v está definido como acima, teremos:

(vector-ref v 0) 3

(vector-ref v 1) 19

(vector-ref v 2) (8 9)

(vector-ref v 3) 4

Se tentarmos executar (vector-ref v 4) teremos um erro, pois não existe posição 4 no vetor v.

A função vector-length retorna o número de elementos de um vetor. Em particular, a expressão (- (vector-length v) 1) retorna o índice do último elemento de um vetor.

Exercício 4.6:

  1. Defina uma variável contendo um vetor com os elementos 1, 2, 4, 5, e 10.
  2. Defina uma função que receba um vetor e retorne seu último elemento.
  3. Defina uma função que receba um vetor de números e retorne a média aritmética entre o primeiro e o último elementos.
  4. Defina uma função que receba um vetor de números e retorne a soma de seus três primeiros elementos.
  5. Defina uma função que receba um vetor e um índice i, e retorne a média dos valores nas posições i e i+1.
  6. Defina um predicado que receba um vetor e dois índices i e j, e verifique se os valores nas posições i e j do vetor são iguais.

Como a única maneira de se acessar um elemento de um vetor é através de um índice, a recursão sobre vetores é bastante diferente da recursão sobre listas. Ao invés de usarmos car, cdr e null?, devemos usar um índice variando de um em um, passando assim por todos os elementos do vetor. A função vector-length pode ser usada para saber em que índice parar (ou começar) a recursão.


Exemplo 4.6: Para somarmos os elementos de um vetor, podemos definir uma função como abaixo, usando recursão final:
      (define soma-aux
        (lambda (v i s)
          (if (= i (vector-length v))
              s
              (soma-aux v (+ i 1) (+ s (vector-ref v i))))))

(define soma (lambda (v) (soma-aux v 0 0)))
Na função soma-aux, v é o vetor cujos elementos estamos somando, i percorre os índices, e s acumula o resultado. O traço dessa função, quando chamada sobre o vetor #(2 5 3), será:

(soma '#(2 5 3))

(soma-aux '#(2 5 3) 0 0)

(soma-aux '#(2 5 3) 1 2)

(soma-aux '#(2 5 3) 2 7)

(soma-aux '#(2 5 3) 3 10)
fim, pois i = (vector-length v)
10 resultado final

Essa mesma função pode ser escrita, de forma mais compacta, usando-se o do:
      (define soma
        (lambda (v)
          (do ((i 0 (+ i 1))
               (s 0 (+ s (vector-ref v i))))
              ((= i (vector-length v)) s))))



Exemplo 4.7: Como podemos acessar os elementos do vetor diretamente, isto é, em qualquer ordem, podemos reescrever a função soma somando os elementos do último para o primeiro. Como a terminação é quando i passar de 0, só precisamos chamar vector-length uma única vez, no início da repetição. Nesse caso, a função pode ser escrita como:
      (define soma
        (lambda (v)
            (do ((i (- (vector-length v) 1) (- i 1))
                 (s 0 (+ s (vector-ref v i))))
                ((< i 0) s))))
Note que i começa com o tamanho do vetor menos 1, que é o último índice do vetor.


Exercício 4.7: Defina as seguintes funções:

  1. Uma função que retorne o produto dos elementos de um vetor.
  2. Uma função que retorne uma lista com os elementos de um vetor.17
  3. Uma função busca que verifique se um dado elemento existe em alguma posição do vetor. Caso positivo, a função deve retornar o índice onde esse elemento foi encontrado; caso contrário, retorna (). Por exemplo,

    (busca '#(banana pera uva goiaba) 'uva) 2

    (busca '#(banana pera uva goiaba) 'pitanga) ()

  4. Uma função que imprima os elementos de um vetor, um por linha.
  5. Uma função maior que retorne o maior elemento em um vetor de números. Por exemplo,

    (maior '#(10 8 5 19 3 -8)) 19

Exercício 4.8: Defina uma função indice-maior que retorne o índice do maior elemento em um vetor de números. Essa função deve receber, além do vetor, um índice indicando até que elemento procurar. Por exemplo,

(indice-maior '#(10 8 5 19 3 -8) 5) 3

pois o maior valor do vetor, 19, está na posição 3; enquanto

(indice-maior '#(10 8 5 19 3 -8) 2) 0

pois o maior elemento até o índice 2 (isto é, entre os 3 primeiros elementos) é 10, na posição 0.

Exercício 4.9: Considere um vetor onde cada elemento é uma lista com um símbolo e um número, como por exemplo:

(define v #((u 1) (r 2) (s 5) (c 0) (c 7) (o -1) (i 4) (c -1)))

O número em cada posição do vetor é usado para indicar uma próxima posição no vetor, formando um encadeamento. Por exemplo, na posição 3 de v temos a lista (c 0), indicando que a próxima posição no encadeamento é a posição 0. Na posição 0 temos (u 1), indicando o 1 como a próxima posição. O valor -1 em uma posição indica o fim de uma cadeia.

Escreva uma função cadeia que, dados um vetor como descrito acima, e uma posição inicial, retorne a lista com os símbolos contidos nas posições encadeadas. Por exemplo, com v declarado como acima, teríamos:

(cadeia v 3) (c u r s o)

(cadeia v 6) (i c c)

O procedimento vector-set! permite alterarmos o valor de qualquer posição de um vetor. Esse procedimento recebe o vetor a ser alterado, o índice, e o novo valor a ser colocado naquele índice. O valor antigo é simplesmente apagado. Por exemplo, se definirmos

(define vet '#(2 90 -9 0.1))

e executarmos

(vector-set! vet 2 13)

após essa operação vet será '#(2 90 13 0.1). Vale a pena frizar que, ao contrário de cons para listas, vector-set! não cria um novo vetor, mas modifica o valor armazenado em uma dada posição de um vetor pré-existente. Também é importante lembrar que vector-set! é um procedimento. O valor retornado por essa função é irrelevante. Usamos vector-set! somente para modificar um valor em uma posição de um dado vetor.


Exemplo 4.8:
Queremos um procedimento zera-vetor que receba um vetor, e coloque 0 em todas as suas posições. A melhor maneira de definirmos essa função é com um do:
      (define zera-vetor
        (lambda (v)
          (do ((i 0 (+ i 1)))
              ((= i (vector-length v)))
              (vector-set! v i 0))))
Note que o vector-set! é usado no corpo do do, pois ele só executado por seu efeito sobre o vetor.


Exercício 4.10: Escreva um procedimento que receba um vetor de números e seu tamanho, e coloque em cada posição do vetor seu antigo valor mais 1.

Exercício 4.11:
Defina um procedimento troca que receba um vetor e dois índices, e troque os valores nas posições dos índices um pelo outro.

Exercício 4.12:
O que faz o procedimento abaixo?

      (define s-aux
        (lambda (v i)
          (if (> i 0)
              (begin
                (troca v i (indice-maior v i))
                (s-aux v (- i 1))))))

(define s (lambda (v) (s-aux v (- (vector-length v) 1))))
Lembre-se que as funções troca e indice-maior foram definidas nos exercícios
4.11 e 4.8, respectivamente. (dica: faça o traço da chamada (s '#(7 8 12 10 6)))

O uso de quote para criarmos vetores é bastante adequado para nossos pequenos exemplos, mas quando queremos construir vetores maiores (por exemplo, com mil elementos), ou dinamicamente dentro de uma função, precisamos de uma função que crie novos vetores. Ao contrário de listas, que podem ser criadas elemento por elemento com a função cons, vetores são criados em uma única operação, com um tamanho pré-determinado. A função make-vector faz isso: recebe como parâmetro um número n e retorna um novo vetor com n posições. Esse novo vetor tem todas as suas posicões inicializadas com (), a lista vazia. Por exemplo,

(make-vector 8) #(() () () () () () () ())

Essa diferença na forma de criarmos vetores e listas implica diferenças na forma de programarmos usando um ou outro. Com listas, é comum termos funções que usam cons recursivamente, contruindo uma lista elemento por elemento. Com vetores, raramente usamos make-vector dentro de uma função recursiva, a não ser que nosso objetivo seja criar vários vetores. Em geral, criamos o vetor e então chamamos uma função recursiva para operar sobre seus elementos, um a um.


Exemplo 4.9: Considere uma função que receba um número n e retorne um vetor de n elementos contendo 0 em todas as suas posições. Para isso, basta criarmos o vetor com make-vector, chamarmos o procedimento zera-vetor, definido no exemplo 4.8, e em seguida retornar esse vetor:
      (define cria-zeros
        (lambda (n)
          (let ((v (make-vector n)))
             (begin
               (zera-vetor v)
               v))))



Exemplo 4.10: Considere uma função que receba uma lista e crie um vetor contendo os mesmos elementos da lista. Podemos definir essa função como se segue:18
      (define lista->vetor-aux
        (lambda (v i l)
           (if (null? l)
               v
               (begin
                  (vector-set! v i (car l))
                  (lista->vetor-aux v (+ i 1) (cdr l))))))

(define lista->vetor (lambda (l) (lista->vetor-aux (make-vector (length l)) 0 l)))
A função lista->vetor calcula o tamanho da lista l, usando a função length, e cria um vetor do mesmo tamanho. Em seguida, chama a função auxiliar lista->vetor-aux para preencher o vetor com os valores da lista. Nesse exemplo, como o vetor foi explicitamente criado com o mesmo tamanho da lista, a recursão e o vetor terminam quando a lista terminar.


Exercício 4.13: Escreva o traço da chamada (lista->vetor '(a b c d)).

Exercício 4.14: Reescreva a função lista->vetor usando do.

Exercício 4.15: Escreva uma função soma-vetor, que receba dois vetores de números com o mesmo tamanho, e retorne um novo vetor com a soma dos elementos dos vetores dois a dois. Por exemplo:

(soma-vetor '#(1 3 5) '#(8 7 8)) #(9 10 13)

Exercício 4.16:
Escreva uma função cria-vetor que receba um tamanho n e uma função f, e retorne o vetor composto pelos valores f(0), f(1), ..., f(n-1). Por exemplo,

(cria-vetor 5 (lambda (x) (* x 2))) #(0 2 4 6 8)

(cria-vetor 4 (lambda (x) 1)) #(1 1 1 1)

Exercício 4.17: Refaça o exercício
4.15 usando a função cria-vetor.

Exercício 4.18: Um uso bastante comum de vetores é para a representação de matrizes. Uma matriz pode ser representada por um vetor de linhas, onde cada linha é um vetor de colunas. Por exemplo, podemos representar a matriz

através do vetor #(#( 1 -9 16) #(-8 23 0))

  1. Escreva uma função que receba dois parâmetros m e n e retorne uma matriz de zeros de tamanho m × n. Por exemplo

    (make-matrix 3 2) #(#(0 0) #(0 0) #(0 0))

    (dica: use a função cria-vetor.)

  2. Escreva uma função matrix-ref que receba uma matriz a e dois índices i e j, e retorne o valor do elemento ai j.

  3. Escreva uma função matrix-set! que receba uma matriz a, dois índices i e j, e um valor v, e modifique o valor da posição ai j para v.

  4. Escreva uma função cria-matriz, que receba m, n, e uma função f, e retorne uma nova matriz de tamanho m × n, onde cada elemento ai j tem o valor de f(i,j). Por exemplo,

    (cria-matriz 3 3 +) #(#(0 1 2) #(1 2 3) #(2 3 4))

    (dica: use a função cria-vetor.)

  5. Escreva uma função que receba duas matrizes e seus tamanhos, e retorne a matriz com o produto das duas matrizes dadas. Para lembrar, o produto de uma matriz a de tamanho n × m por uma matriz b de m × k, é uma matriz c de tamanho n × k, com seus elementos definidos pela fórmula

    (dica: procure escrever uma função para calcular o somatório acima, e use a função cria-matriz para construir o resultado.)

Listas × Vetores

Listas e vetores são, até certo ponto, intercambiáveis. Qualquer estrutura que pode ser representada com listas pode ser representada com vetores, e vice-versa. Entretanto, as dificuldades e o desempenho associados a cada representação podem ser bem diferentes. De maneira geral, listas são mais flexíveis e mais simples de usar, pois não precisam ser previamente criadas com um tamanho fixo; ao contrário, listas vão sendo construídas a medida que seus valores vão aparecendo. Por outro lado, a memória de computadores tem um funcionamento bastante similar a um enorme vetor. Por isso, vetores têm, em qualquer linguagem, implementações bem mais eficientes do que listas. Por exemplo, o acesso ao milésimo elemento de um vetor é uma operação bastante simples para o computador. Já acessar o milésimo termo de uma lista exige percorrer todos os 999 elementos anteriores. Além disso, representação de matrizes usando vetores é bem mais direta que com listas; portanto, muitos programas de manipulação numérica, que fazem intenso uso de matrizes, usam exclusivamente vetores.

A maioria dos programas reais usam representações de dados (ou estruturas de dados) mais complexas que listas ou vetores simples, muitas vezes envolvendo uma combinação de ambos.



Capítulo 5 :


Solução de Alguns Exercícios Selecionados




Notas de Pé de Pagina

(1) Nesse tipo de exercício, primeiro tente calcular o valor da expressão sem o uso do computador. Sempre que possível, faça o traço da avaliação. Procure usar o computador apenas para verificar sua resposta.

(2) Na realidade, podemos aplicar cons sobre quaisquer valores. O resultado, em geral, não será uma lista, mas um par, que é escrito como (a . b). Esse tipo de estrutura, entretanto, foge do escopo deste texto.

(3) Lambda é o nome da letra grega . Essa notação para funções é baseada no -cálculo, uma teoria criada na década de 40 pelo matemático A. Church. Em uma notação mais `matemática', a definição de f poderia ser escrita como f = x . 2x.

(4) Em várias implementações de Scheme, a lista vazia () pode ser usada no lugar de #f, para representar o valor falso.

(5) Essa função é pré-definida em Scheme sob o nome length.

(6) Essa função é pré-definida em Scheme sob o nome member.

(7) Essa função é pré-definida em Scheme sob o nome append.

(8) Essa função é pré-definida em Scheme sob o nome reverse.

(9) Essa formulação do princípio de indução é um pouco diferente da formulação tradicional. A nosso ver, ela é melhor adaptada para problemas ligados a funções recursivas. Para quem já conhece o princípio de indução, a formulação colocada aqui pode ser justificada associando-se, a cada chamada da função, um natural n igual ao número de chamadas recursivas que a função faz até terminar. Cada chamada recursiva tem um n menor que a chamada original, pois exigimos que a função termine; o caso base, n=0, corresponde às situações em que a função não faz chamadas recursivas. Logo, pelo princípio de indução forte, podemos usar a correção dessas chamadas nas nossas hipóteses.

(10) Para alguns tipos de função, como a função de Ackerman, esse método para provar terminação não é suficiente. Mas tais casos são algo exóticos, e fogem do escopo deste texto.

(11) Essa função é pré-definida em Scheme sob o nome map.

(12) Como Scheme já tem uma função pré-definida chamada zero?, para evitar confusão vamos usar o nome zer? para nosso predicado.

(13) O leitor mais atento perceberá que existe um pequeno `roubo' nessa definição: se não temos números, não podemos executar a operação (+ (car n) 1) indicada. Como (car n) é necessariamente um único dígito, esse incremento poderia ser definido por casos. Mas, para evitar mais complexidade, vamos assumir que essa soma restrita a dígitos simples é válida

(14) Várias linguagens, notadamente Pascal, tem explícito o conceito de procedimentos. Nessas linguagens, um procedimento não retorna nenhum valor. Scheme não oferece tal conceito, portanto procedimentos têm que ser implementados através de funções. Por isso, nossos `procedimentos' sempre retornam algum valor, mas vamos sempre ignorar esse valor.

(15) Em Scheme, muitas construções tem o chamado begin implícito, entre elas lambda, let e if. Isso significa que podemos usar várias expressões em sequência no corpo dessas construções sem necessidade de um begin. Entretanto, por razões didáticas, vamos sempre usar o begin explicitamente.

(16) No princípio dos tempos, computadores não tinham terminais com telas, e toda saída de dados era impressa em papel, cartão, ou algum outro meio. Como programadores são bastante conservadores, o termo imprimir ainda é usado no jargão da área como sinônimo de exibir algo no terminal.

(17) Essa função é pré-definida em Scheme sob o nome vector->list.

(18) Essa função é pré-definida em Scheme sob o nome list->vector.