Pular para o conteúdo principal

3.5.3 Explorando o Paradigma de Streams

Streams com avaliação atrasada podem ser uma ferramenta de programação poderosa, ajudando a evitar a necessidade de escrever programas que mantêm explicitamente o estado histórico de um sistema. Como ilustração, vamos revisitar os cálculos iterativos de raiz quadrada introduzidos na seção 1.1.7.

Lembre-se de que uma maneira de computar raízes quadradas é através da busca de um ponto fixo da função yxyy \mapsto \frac{x}{y}. Podemos expressar esta computação como um processo de geração de uma sequência infinita de aproximações, começando com uma estimativa inicial de 1:

function sqrt_improve(guess, x) {
return average(guess, x / guess);
}

function sqrt_stream(x) {
return pair(1,
() => stream_map(guess => sqrt_improve(guess, x),
sqrt_stream(x)));
}

Exibindo este stream podemos ver as sucessivas aproximações da raiz quadrada de 2:

display_stream(sqrt_stream(2));
1
1.5
1.4166666666666665
1.4142156862745097
1.4142135623746899
...

O stream sqrt_stream exibe somas parciais da raiz quadrada que se aproximam cada vez mais do valor correto. Podemos gerar ainda melhores aproximações acelerando a convergência da sequência usando uma técnica de aceleração de sequência.

Uma técnica de aceleração, devida a Leonhard Euler, se baseia na observação de que se uma sequência de aproximações está convergindo para um valor de acordo com uma forma linear (o que é bem lento), então podemos encontrar uma "transformação" da sequência que converge para o mesmo valor mais rapidamente. A transformação de Euler mapeia uma sequência SnS_n para uma sequência cujos termos são

Sn=Sn+1(Sn+1Sn)2Sn12Sn+Sn+1S'_n = S_{n+1} - \frac{(S_{n+1} - S_n)^2}{S_{n-1} - 2S_n + S_{n+1}}

Podemos expressar isso como uma função que transforma um stream:

function euler_transform(s) {
const s0 = stream_ref(s, 0);
const s1 = stream_ref(s, 1);
const s2 = stream_ref(s, 2);
return pair(s2 - square(s2 - s1) / (s0 - 2 * s1 + s2),
() => euler_transform(stream_tail(s)));
}

Para ver quão dramática é a melhoria, podemos aplicar a transformação de Euler ao nosso stream de aproximações de raiz quadrada:

display_stream(euler_transform(sqrt_stream(2)));
1.4142135623730951
1.4142135623730949
1.414213562373095
1.4142135623730951
...

Mesmo melhor, podemos acelerar os resultados aplicando a transformação de forma repetitiva—isto é, criando um stream de streams, onde cada stream é o resultado de aplicar a transformação de Euler ao stream anterior. Isso é feito pela seguinte função accelerated_sequence:

function accelerated_sequence(transform, s) {
return pair(s,
() => accelerated_sequence(
transform,
transform(s)));
}

Podemos aplicar isso ao nosso stream de raiz quadrada:

display_stream(stream_map(
s => stream_ref(s, 0),
accelerated_sequence(euler_transform, sqrt_stream(2))));

O resultado é verdadeiramente impressionante:

1
1.5
1.4142135623730951
1.4142135623730949
...

Dentro de quatro aproximações, chegamos a 15 casas decimais corretas!

Streams de pares

No capítulo 2, consideramos o problema de gerar todas as sequências de pares distintos de inteiros não negativos menores ou iguais a algum inteiro nn. Na seção 2.2.3, vimos como usar map em combinação com accumulate para gerar a sequência de pares para um dado nn. Também vimos como usar filter para gerar apenas pares (i,j)(i,j) que satisfazem i+ji + j é primo. Nossa abordagem era gerar a sequência de todos os pares e então filtrar para selecionar os pares primos.

Com streams, podemos fazer coisas ainda mais sofisticadas. Suponha que queremos generalizar isso para considerar todos os pares de inteiros positivos (i,j)(i,j) com iji \leq j ao invés de restringir a um nn fixo. Não podemos simplesmente gerar o fluxo infinito de pares usando algo como:

stream_map(
i => stream_map(j => list(i, j), integers),
integers);

Isso geraria um stream de streams, o que não é exatamente o que queremos. Para resolver isso, precisamos de uma maneira de entrelaçar os streams. Aqui está uma função que faz isso:

function interleave(s1, s2) {
return is_null(s1)
? s2
: pair(head(s1),
() => interleave(s2, stream_tail(s1)));
}

Agora podemos definir uma função pairs que gera o stream de pares de inteiros (i,j)(i,j) com iji \leq j:

function pairs(s, t) {
return pair(list(head(s), head(t)),
() => interleave(
stream_map(x => list(head(s), x),
stream_tail(t)),
pairs(stream_tail(s),
stream_tail(t))));
}

Streams como sinais

Streams podem ser usados para modelar processamento de sinais. Por exemplo, podemos implementar um "integrador" ou "somador" que, dado um stream a1,a2,a3,a_1, a_2, a_3, \ldots e um valor inicial CC, produz o stream

C,C+a1,C+a1+a2,C+a1+a2+a3,C, C + a_1, C + a_1 + a_2, C + a_1 + a_2 + a_3, \ldots

function integral(integrand, initial_value, dt) {
const integ = pair(initial_value,
() => add_streams(scale_stream(integrand, dt),
integ));
return integ;
}
vi×1/Rdt×1/Cvi

Figura 3.53: Diagrama de fluxo de sinal para um integrador RC. O sinal de entrada v é multiplicado por 1/R, integrado ao longo do tempo (∫dt), e então o resultado é multiplicado por 1/C para produzir a corrente i. O loop de realimentação mostra como a saída do integrador influencia o estado do sistema.

A figura mostra um diagrama de processamento de sinal para o integrador. O diagrama mostra claramente como as operações de stream são combinadas para processar sinais. Este ponto de vista nos permite trabalhar com sistemas altamente complexos de uma maneira modular.

Exercício 3.63

Louis Reasoner pergunta por que a função sqrt_stream é definida com uma função auxiliar explícita. Não seria igualmente válido escrever:

function sqrt_stream(x) {
return pair(1,
() => stream_map(
guess => sqrt_improve(guess, x),
sqrt_stream(x)));
}

Alyssa P. Hacker responde que esta versão da função é consideravelmente menos eficiente porque ela realiza computações redundantes. Explique a resposta de Alyssa. A resposta dela seria diferente se nossa implementação de avaliação atrasada não usasse a otimização de memoização fornecida pela função memo?

💡 Mostrar solução — tente primeiro!💡 Esconder solução

A diferença está na memoização compartilhada. Na versão do texto, o nome local guesses faz o stream referir-se a si mesmo: cada elemento é computado uma vez e lembrado, e o elemento nn custa um passo sobre o n1n-1 já pronto — custo total linear.

Na versão de Louis, a cauda chama sqrt_stream(x) de novo — que constrói um stream novo, sem nada em comum com o anterior. As memoizações nunca são reaproveitadas: para chegar ao elemento nn, recomputa-se o prefixo inteiro a cada passo, e o custo vira quadrático.

Se stream_map/as caudas não usassem memo, as duas versões seriam igualmente ineficientes — sem memoização, referir-se a si mesmo não economiza nada, pois cada percurso recomputa de qualquer forma.

Exercício 3.64

Escreva uma função stream_limit que recebe como argumentos um stream e um número (a tolerância). Ela deve examinar o stream até encontrar dois elementos sucessivos que diferem em valor absoluto por menos que a tolerância, e retornar o segundo dos dois elementos. Usando isso, podemos computar raízes quadradas até uma dada tolerância por

function sqrt(x, tolerance) {
return stream_limit(sqrt_stream(x), tolerance);
}
💡 Mostrar solução — tente primeiro!💡 Esconder solução
Exemplo de Código
Código editável · Ctrl+Enter executa · Esc solta o foco

Cada passo compara vizinhos e avança; quando a diferença cai abaixo da tolerância, devolvemos o segundo — o mais refinado dos dois.

Exercício 3.65

Use a série

ln2=112+1314+\ln 2 = 1 - \frac{1}{2} + \frac{1}{3} - \frac{1}{4} + \cdots

para computar três streams de aproximações para o logaritmo natural de 2, de maneiras análogas às que usamos acima para computar π\pi. Quão rapidamente esses streams convergem?

💡 Mostrar solução — tente primeiro!💡 Esconder solução

As três acelerações do texto, aplicadas à série de ln2\ln 2:

Exemplo de Código
Código editável · Ctrl+Enter executa · Esc solta o foco

Convergência (valor verdadeiro ln20,693147\ln 2 \approx 0{,}693147): a série pura, alternada com termos 1/n1/n, erra na segunda casa mesmo após 10 termos (erro 1/2n\sim 1/2n); a transformação de Euler acerta ~4 casas com os mesmos 10 termos; o tableau acerta o limite da precisão de ponto flutuante com 8 ou 9 — cada linha ganha casas decimais em progressão dramática. (Um pouco além disso o tableau degenera em NaN por cancelamento numérico — aceleração tem preço.)

Exercício 3.66

Examine o stream pairs(integers, integers). Você pode fazer uma estimativa geral de quantos pares precedem o par (1,100)? o par (99,100)? o par (100,100)? (Se você puder fazer uma estimativa precisa da matemática, ainda melhor. Mas a parte importante é você ter uma boa intuição.)

💡 Mostrar solução — tente primeiro!💡 Esconder solução

Empiricamente (conte você mesmo com pairs(integers, integers)!): os pares (1,n)(1, n) ocupam metade do stream — (1,n)(1, n) está no índice 2n32n - 3 (para n2n \ge 2); os pares (n,n)(n, n) estão no índice 2n22^n - 2; e (n,n+1)(n, n+1) no índice 32n123 \cdot 2^{n-1} - 2.

Logo:

  • (1,100)(1, 100): precedido por 197 pares — perfeitamente alcançável.
  • (99,100)(99, 100): precedido por 329829,5×10293 \cdot 2^{98} - 2 \approx 9{,}5 \times 10^{29} pares.
  • (100,100)(100, 100): precedido por 210021,3×10302^{100} - 2 \approx 1{,}3 \times 10^{30} pares.

A intuição: a cada "camada" que o interleave desce, os pares daquela camada passam a aparecer com metade da frequência da camada anterior. A linha 1 recebe metade das posições, a linha 2 um quarto, e assim por diante — pares "profundos" na diagonal são exponencialmente raros no stream, ainda que todos eventualmente apareçam.

Exercício 3.67

Modifique a função pairs para que pairs(integers, integers) produza o stream de todos os pares de inteiros positivos (i,j)(i,j) (sem a condição iji \leq j). Dica: Você precisará misturar um stream adicional.

💡 Mostrar solução — tente primeiro!💡 Esconder solução

Ideia. Além da primeira linha (pares (S0,t)(S_0, t)) e do resto recursivo, misture também a primeira coluna (pares (s,T0)(s, T_0)):

Exemplo de Código
Código editável · Ctrl+Enter executa · Esc solta o foco

Cada par (i,j)(i, j) agora aparece exatamente uma vez: os com i=1i = 1 vêm da linha, os com j=1j = 1 (e i>1i > 1) da coluna, e o resto da recursão.

Exercício 3.68

Louis Reasoner pensa que construir um pairs do jeito que fizemos é excessivamente complicado. Em vez de separar o par (S0,T0)(S_0,T_0) do resto dos pares no primeiro linha, ele propõe trabalhar com todo o primeiro linha como segue:

function pairs(s, t) {
return interleave(
stream_map(x => list(head(s), x), t),
pairs(stream_tail(s), stream_tail(t)));
}

Isso funciona? Considere o que acontece se avaliarmos pairs(integers, integers) usando a definição de Louis.

💡 Mostrar solução — tente primeiro!💡 Esconder solução

A versão de Louis não funciona: entra em recursão infinita antes de produzir o primeiro par.

Na versão do texto, a chamada recursiva pairs(stream_tail(s), stream_tail(t)) está dentro de uma função adiada (() => …) — só roda quando alguém pede a cauda. Na versão de Louis, ela é argumento direto de interleave, e a ordem aplicativa avalia argumentos antes de aplicar a função: para construir pairs(s, t) é preciso antes construir pairs(tail s, tail t), que precisa de pairs(tail tail s, …)… sem nenhum par jamais emergir.

Experimente — nosso executor mostra o estouro de pilha:

Exemplo de Código
Código editável · Ctrl+Enter executa · Esc solta o foco

O pair(cabeça, cauda-adiada) explícito não é burocracia: é o que dá ao stream um pedaço pronto atrás do qual o infinito pode se esconder.

Exercício 3.69

Escreva uma função triples que recebe três streams infinitos, SS, TT e UU, e produz o stream de triplas (Si,Tj,Uk)(S_i,T_j,U_k) tais que ijki \leq j \leq k. Use triples para gerar o stream de todos os triplos pitagóricos de inteiros positivos, isto é, as triplas (i,j,k)(i,j,k) tais que iji \leq j e i2+j2=k2i^2 + j^2 = k^2.

💡 Mostrar solução — tente primeiro!💡 Esconder solução
Exemplo de Código
Código editável · Ctrl+Enter executa · Esc solta o foco

Por que funciona. Uma tripla (Si,Tj,Uk)(S_i, T_j, U_k) com ijki \le j \le k é a cabeça S0S_0 pregada em um par (Tj,Uk)(T_j, U_k) com jkj \le k — que é exatamente o que pairs(t, u) fornece — misturada com as triplas que não usam S0S_0. Os primeiros pitagóricos: (3,4,5)(3, 4, 5) e (6,8,10)(6, 8, 10); os seguintes, como (5,12,13)(5, 12, 13), estão muito fundo no entrelaçamento — o exercício 3.70 resolve essa dor com ordenação por peso.

Exercício 3.70

Seria bom ser capaz de gerar streams nos quais os pares aparecem em alguma ordem útil, ao invés de em uma ordem que depende de entrelaçamento ad hoc. Podemos usar uma técnica similar à função merge do exercício 3.56, se definirmos uma maneira de dizer que um par de pares é "menor que" outro. Uma maneira de fazer isso é definir uma "função de peso" W(i,j)W(i,j) e decretar que (i1,j1)(i_1,j_1) é menor que (i2,j2)(i_2,j_2) se W(i1,j1)<W(i2,j2)W(i_1,j_1) < W(i_2,j_2). Escreva uma função merge_weighted que é como merge, exceto que merge_weighted recebe uma função de peso adicional (isto é, uma função de dois argumentos) como argumento. Usando isso, generalize pairs para uma função weighted_pairs que recebe dois streams, juntamente com uma função que computa um peso, e gera o stream de pares em ordem crescente de peso. Use sua função para gerar

a. o stream de todos os pares de inteiros positivos (i,j)(i,j) com iji \leq j ordenados de acordo com a soma i+ji + j

b. o stream de todos os pares de inteiros positivos (i,j)(i,j) com iji \leq j, onde nem ii nem jj são divisíveis por 2, 3 ou 5, e os pares são ordenados de acordo com a soma 2i+3j+5ij2i + 3j + 5ij.

💡 Mostrar solução — tente primeiro!💡 Esconder solução
Exemplo de Código
Código editável · Ctrl+Enter executa · Esc solta o foco

Diferente do merge do exercício 3.56, aqui não eliminamos "empates": pesos iguais não significam pares iguais (e o exercício 3.71 depende exatamente de vê-los lado a lado).

Exercício 3.71

Números que podem ser expressos como a soma de dois cubos de mais de uma maneira são às vezes chamados de números de Ramanujan, em homenagem ao matemático Srinivasa Ramanujan.1 Os streams ordenados de pares fornecem uma maneira elegante de computar esses números. Para encontrar um número que pode ser escrito como a soma de dois cubos de duas maneiras diferentes, precisamos apenas gerar o stream de pares de inteiros (i,j)(i,j) ponderados de acordo com a soma i3+j3i^3 + j^3, então procurar por dois pares consecutivos com o mesmo peso. Escreva uma função para gerar os números de Ramanujan. O primeiro desses números é 1.729. Quais são os próximos cinco?

💡 Mostrar solução — tente primeiro!💡 Esconder solução
Exemplo de Código
Código editável · Ctrl+Enter executa · Esc solta o foco

No stream ordenado por i3+j3i^3 + j^3, uma soma que aparece duas vezes seguidas é um número de Ramanujan. Depois do 1729 (o número do táxi de Hardy): 4104, 13832, 20683, 32832 e 39312.

Exercício 3.72

De maneira similar ao exercício 3.71, gere um stream de todos os números que podem ser escritos como a soma de dois quadrados de três maneiras diferentes (mostrando como eles podem ser escritos assim).

💡 Mostrar solução — tente primeiro!💡 Esconder solução

Mesma técnica, com peso i2+j2i^2 + j^2 e três pesos consecutivos iguais:

Exemplo de Código
Código editável · Ctrl+Enter executa · Esc solta o foco

Cada elemento traz o número e suas três decomposições: 325=12+182=62+172=102+152325 = 1^2{+}18^2 = 6^2{+}17^2 = 10^2{+}15^2; depois 425425 e 650650.

Footnotes

  1. Ramanujan era um matemático autodidata indiano que trabalhou no início do século 20. Ele é famoso por suas contribuições profundas à teoria dos números, apesar de não ter educação formal. A história de 1729 é famosa: Quando Hardy veio visitá-lo no hospital, ele comentou que havia chegado em um táxi numerado 1729 e que isso parecia um número bastante sem interesse. Ao que Ramanujan replicou: "Não, é um número muito interessante. É o menor número expresso como a soma de dois cubos de duas maneiras diferentes."