Agora consideramos os elementos da programação: usamos operações aritméticas primitivas, combinamos essas operações e abstraímos essas operações compostas definindo-as como procedimentos compostos. Mas isso não é suficiente para nos permitir dizer que sabemos programar. Nossa situação é análoga à de alguém que aprendeu as regras de como as peças se movem no xadrez, mas não sabe nada sobre aberturas típicas, táticas ou estratégias. Como o jogador iniciante de xadrez, ainda não conhecemos os padrões comuns de uso no domínio. Falta-nos o conhecimento de quais movimentos valem a pena fazer (quais procedimentos valem a pena definir). Falta-nos a experiência para prever as consequências de fazer um movimento (executar um procedimento).
A capacidade de visualizar as consequências das ações em consideração é crucial para se tornar um programador especialista, assim como é em qualquer atividade criativa e sintética. Para se tornar um fotógrafo especialista, por exemplo, é preciso aprender a olhar para uma cena e saber quão escura cada região aparecerá em uma impressão para cada possível escolha de exposição e condições de desenvolvimento. Somente então podemos raciocinar de trás para frente, planejando o enquadramento, a iluminação, a exposição e o desenvolvimento para obter os efeitos desejados. O mesmo acontece com a programação, onde estamos planejando o curso de ação a ser tomado por um processo e onde controlamos o processo por meio de um programa. Para nos tornarmos especialistas, devemos aprender a visualizar os processos gerados por vários tipos de procedimentos. Somente depois de desenvolver essa habilidade podemos aprender a construir programas que exibam o comportamento desejado de forma confiável.
Um procedimento é um padrão para a evolução local de um processo computacional. Ele especifica como cada estágio do processo é construído sobre o estágio anterior. Gostaríamos de ser capazes de fazer afirmações sobre o comportamento global de um processo cuja evolução local foi especificada por um procedimento. Isso é muito difícil de fazer em geral, mas podemos pelo menos tentar descrever alguns padrões típicos de evolução de processos.
Nesta seção, examinaremos algumas "formas" comuns para processos gerados por procedimentos simples. Também investigaremos as taxas nas quais esses processos consomem os importantes recursos computacionais de tempo e espaço. Os procedimentos que consideraremos são muito simples. Seu papel é semelhante ao desempenhado por padrões de teste em fotografia: como padrões prototípicos super simplificados, em vez de exemplos práticos por si só.
Começamos considerando a função fatorial, definida por Existem muitas maneiras de calcular fatoriais. Uma delas é usar a observação de que é igual a vezes para qualquer inteiro positivo : Assim, podemos calcular calculando e multiplicando o resultado por . Se adicionarmos a estipulação de que 1! é igual a 1, essa observação se traduz diretamente em um procedimento:
(define (factorial n)
(if (= n 1)
1
(* n (factorial (- n 1)))))
Podemos usar o modelo de substituição de 1.1.5 para observar esse procedimento em ação calculando 6!, como mostrado em Figura 1.3.
Figura 1.3: Um processo recursivo linear para calcular 6!.
Agora, vamos adotar uma perspectiva diferente sobre o cálculo de fatoriais. Poderíamos descrever uma regra para calcular especificando que primeiro multiplicamos 1 por 2, depois multiplicamos o resultado por 3, depois por 4, e assim por diante até atingirmos . Mais formalmente, mantemos um produto acumulado, juntamente com um contador que conta de 1 até . Podemos descrever a computação dizendo que o contador e o produto mudam simultaneamente de um passo para o próximo de acordo com a regra
produto contador * produto contador contador + 1
e estipulando que é o valor do produto quando o contador excede .
Mais uma vez, podemos reformular nossa descrição como um procedimento para calcular fatoriais:29
(define (factorial n)
(fact-iter 1 1 n))
(define (fact-iter produto contador max-contador)
(if (> contador max-contador)
produto
(fact-iter (* contador produto)
(+ contador 1)
max-contador)))
Como antes, podemos usar o modelo de substituição para visualizar o processo de cálculo de 6!, como mostrado em Figura 1.4.
Figura 1.4: Um processo iterativo linear para calcular 6!.
Compare os dois processos. De um ponto de vista, eles parecem quase iguais. Ambos calculam a mesma função matemática no mesmo domínio, e cada um requer um número de passos proporcional a para calcular . De fato, ambos os processos até realizam a mesma sequência de multiplicações, obtendo a mesma sequência de produtos parciais. Por outro lado, quando consideramos as "formas" dos dois processos, descobrimos que eles evoluem de maneira bastante diferente.
Considere o primeiro processo. O modelo de substituição revela uma forma de expansão seguida de contração, indicada pela seta em Figura 1.3. A expansão ocorre à medida que o processo constrói uma cadeia de operações adiadas (neste caso, uma cadeia de multiplicações). A contração ocorre quando as operações são realmente realizadas. Esse tipo de processo, caracterizado por uma cadeia de operações adiadas, é chamado de processo recursivo. A execução desse processo exige que o interpretador mantenha o controle das operações a serem realizadas posteriormente. No cálculo de , o comprimento da cadeia de multiplicações adiadas, e, portanto, a quantidade de informações necessárias para mantê-la, cresce linearmente com (é proporcional a ), assim como o número de passos. Esse processo é chamado de processo recursivo linear.
Em contraste, o segundo processo não cresce e diminui. A cada passo,
tudo o que precisamos manter, para qualquer
,
são os valores atuais das variáveis produto
,
contador
e max-contador
. Chamamos isso de
processo iterativo. Em geral, um processo iterativo é aquele
cujo estado pode ser resumido por um número fixo de
variáveis de estado,
juntamente com uma regra fixa que descreve como as variáveis de estado
devem ser atualizadas à medida que o processo passa de um estado para
outro e um teste de fim (opcional) que especifica as condições sob as
quais o processo deve terminar. No cálculo de
, o número de passos necessários cresce linearmente com
.
Esse processo é chamado de
processo iterativo linear.
O contraste entre os dois processos pode ser visto de outra maneira. No caso iterativo, as variáveis do programa fornecem uma descrição completa do estado do processo em qualquer ponto. Se pararmos a computação entre os passos, tudo o que precisamos fazer para retomar a computação é fornecer ao interpretador os valores das três variáveis do programa. Isso não acontece com o processo recursivo. Nesse caso, há algumas informações "ocultas" adicionais, mantidas pelo interpretador e não contidas nas variáveis do programa, que indicam "onde o processo está" na negociação da cadeia de operações adiadas. Quanto mais longa a cadeia, mais informações devem ser mantidas.30
Ao contrastar iteração e recursão, devemos ter cuidado para não
confundir a noção de um processo
recursivo com a noção de um procedimento
recursivo. Quando descrevemos um procedimento como recursivo,
estamos nos referindo ao fato sintático de que a definição do
procedimento se refere (direta ou indiretamente) ao próprio
procedimento. Mas quando descrevemos um processo como seguindo um padrão
que é, digamos, linearmente recursivo, estamos falando sobre como o
processo evolui, não sobre a sintaxe de como um procedimento é escrito.
Pode parecer perturbador que nos refiramos a um procedimento recursivo
como fact-iter
como gerando um processo iterativo. No
entanto, o processo realmente é iterativo: seu estado é completamente
capturado por suas três variáveis de estado, e um interpretador precisa
manter o controle de apenas três variáveis para executar o processo.
Uma razão pela qual a distinção entre processo e procedimento pode ser
confusa é que a maioria das implementações de linguagens comuns
(incluindo Ada, Pascal e C) é projetada de tal forma que a interpretação
de qualquer procedimento recursivo consome uma quantidade de memória que
cresce com o número de chamadas de procedimento, mesmo quando o processo
descrito é, em princípio, iterativo. Como consequência, essas linguagens
podem descrever processos iterativos apenas recorrendo a construções
especiais de "looping", como do
, repeat
,
until
, for
e while
. A
implementação de Scheme que consideraremos em
Capítulo 5 não compartilha essa
deficiência. Ela executará um processo iterativo em espaço constante,
mesmo se o processo iterativo for descrito por um procedimento
recursivo. Uma implementação com essa propriedade é chamada
recursão de cauda. Com
uma implementação de recursão de cauda, a iteração pode ser expressa
usando o mecanismo comum de chamada de procedimento, de modo que
construções especiais de iteração são úteis apenas como açúcar
sintático.31
Exercício 1.9: Cada um dos seguintes dois procedimentos define um método para adicionar dois inteiros positivos em termos dos procedimentos
inc
, que incrementa seu argumento em 1, edec
, que decrementa seu argumento em 1.(define (+ a b) (if (= a 0) b (inc (+ (dec a) b)))) (define (+ a b) (if (= a 0) b (+ (dec a) (inc b))))
Usando o modelo de substituição, ilustre o processo gerado por cada procedimento ao avaliar
(+ 4 5)
. Esses processos são iterativos ou recursivos?
Exercício 1.10: O seguinte procedimento calcula uma função matemática chamada função de Ackermann.
(define (A x y) (cond ((= y 0) 0) ((= x 0) (* 2 y)) ((= y 1) 2) (else (A (- x 1) (A x (- y 1))))))
Quais são os valores das seguintes expressões?
(A 1 10) (A 2 4) (A 3 3)
Considere os seguintes procedimentos, onde
A
é o procedimento definido acima:(define (f n) (A 0 n)) (define (g n) (A 1 n)) (define (h n) (A 2 n)) (define (k n) (* 5 n n))
Dê definições matemáticas concisas para as funções computadas pelos procedimentos
f
,g
eh
para valores inteiros positivos de . Por exemplo,(k n)
computa .
Outro padrão comum de computação é chamado recursão em árvore. Como exemplo, considere o cálculo da sequência de números de Fibonacci, em que cada número é a soma dos dois anteriores:
Em geral, os números de Fibonacci podem ser definidos pela regra Podemos traduzir imediatamente essa definição em um procedimento recursivo para calcular números de Fibonacci:
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1))
(fib (- n 2))))))
Considere o padrão dessa computação. Para calcular
(fib 5)
, calculamos (fib 4)
e
(fib 3)
. Para calcular (fib 4)
, calculamos
(fib 3)
e (fib 2)
. Em geral, o processo
evoluído se parece com uma árvore, como mostrado em
Figura 1.5. Observe que os ramos se
dividem em dois a cada nível (exceto na base); isso reflete o fato de
que o procedimento fib
chama a si mesmo duas vezes cada vez
que é invocado.
Figura 1.5: O processo recursivo em árvore gerado
ao calcular (fib 5)
.
Esse procedimento é instrutivo como um protótipo de recursão em árvore,
mas é uma maneira terrível de calcular números de Fibonacci porque faz
muita computação redundante. Observe em
Figura 1.5 que toda a computação de
(fib 3)
—quase metade do trabalho—é duplicada. De fato, não
é difícil mostrar que o número de vezes que o procedimento calculará
(fib 1)
ou (fib 0)
(o número de folhas na
árvore acima, em geral) é precisamente
. Para ter uma ideia de quão ruim isso é, pode-se mostrar que o valor
de
cresce exponencialmente com
.
Mais precisamente (veja
Exercício 1.13),
é o inteiro mais próximo de
, onde
é a razão áurea, que satisfaz a
equação
Assim, o processo usa um número de passos que cresce exponencialmente
com a entrada. Por outro lado, o espaço necessário cresce apenas
linearmente com a entrada, porque precisamos manter o controle apenas de
quais nós estão acima de nós na árvore em qualquer ponto da computação.
Em geral, o número de passos necessários para um processo recursivo em
árvore será proporcional ao número de nós na árvore, enquanto o espaço
necessário será proporcional à profundidade máxima da árvore.
Também podemos formular um processo iterativo para calcular os números de Fibonacci. A ideia é usar um par de inteiros e , inicializados para e , e aplicar repetidamente as transformações simultâneas
Não é difícil mostrar que, após aplicar essa transformação vezes, e serão iguais, respectivamente, a e . Assim, podemos calcular os números de Fibonacci iterativamente usando o procedimento
(define (fib n)
(fib-iter 1 0 n))
(define (fib-iter a b count)
(if (= count 0)
b
(fib-iter (+ a b) a (- count 1))))
Este segundo método para calcular é uma iteração linear. A diferença no número de passos necessários pelos dois métodos—um linear em , outro crescendo tão rápido quanto —é enorme, mesmo para entradas pequenas.
Não se deve concluir disso que os processos recursivos em árvore são
inúteis. Quando consideramos processos que operam em dados
hierarquicamente estruturados em vez de números, descobrimos que a
recursão em árvore é uma ferramenta natural e poderosa.32
Mas mesmo em operações numéricas, os processos recursivos em árvore
podem ser úteis para nos ajudar a entender e projetar programas. Por
exemplo, embora o primeiro procedimento fib
seja muito
menos eficiente que o segundo, ele é mais direto, sendo pouco mais do
que uma tradução para Lisp da definição da sequência de Fibonacci. Para
formular o algoritmo iterativo, foi necessário perceber que a computação
poderia ser reformulada como uma iteração com três variáveis de estado.
É preciso apenas um pouco de engenhosidade para chegar ao algoritmo iterativo de Fibonacci. Em contraste, considere o seguinte problema: De quantas maneiras diferentes podemos fazer o troco de $1,00, usando meio-dólar, quartos, moedas de dez centavos, moedas de cinco centavos e moedas de um centavo? Mais geralmente, podemos escrever um procedimento para calcular o número de maneiras de trocar qualquer quantia de dinheiro?
Esse problema tem uma solução simples como um procedimento recursivo. Suponha que pensemos nos tipos de moedas disponíveis como dispostos em alguma ordem. Então, a seguinte relação é válida:
O número de maneiras de trocar a quantia usando tipos de moedas é igual a
Para ver por que isso é verdade, observe que as maneiras de fazer o troco podem ser divididas em dois grupos: aquelas que não usam nenhuma moeda do primeiro tipo e aquelas que usam. Portanto, o número total de maneiras de fazer o troco para uma quantia é igual ao número de maneiras de fazer o troco sem usar nenhuma moeda do primeiro tipo, mais o número de maneiras de fazer o troco assumindo que usamos uma moeda do primeiro tipo. Mas este último número é igual ao número de maneiras de fazer o troco para a quantia que resta após usar uma moeda do primeiro tipo.
Assim, podemos reduzir recursivamente o problema de trocar uma quantia dada ao problema de trocar quantias menores usando menos tipos de moedas. Considere essa regra de redução cuidadosamente e convença-se de que podemos usá-la para descrever um algoritmo se especificarmos os seguintes casos degenerados:33
Podemos facilmente traduzir essa descrição em um procedimento recursivo:
(define (count-change amount)
(cc amount 5))
(define (cc amount kinds-of-coins)
(cond ((= amount 0) 1)
((or (< amount 0)
(= kinds-of-coins 0))
0)
(else
(+ (cc amount (- kinds-of-coins 1))
(cc (- amount (first-denomination
kinds-of-coins))
kinds-of-coins)))))
(define (first-denomination kinds-of-coins)
(cond ((= kinds-of-coins 1) 1)
((= kinds-of-coins 2) 5)
((= kinds-of-coins 3) 10)
((= kinds-of-coins 4) 25)
((= kinds-of-coins 5) 50)))
(O procedimento first-denomination
recebe como entrada o
número de tipos de moedas disponíveis e retorna a denominação do
primeiro tipo. Aqui estamos pensando nas moedas como dispostas em ordem
do maior para o menor, mas qualquer ordem serviria.) Podemos agora
responder à nossa pergunta original sobre trocar um dólar:
(count-change 100)
292
Count-change
gera um processo recursivo em árvore com
redundâncias semelhantes às da nossa primeira implementação de
fib
. (Vai demorar bastante para que 292 seja computado.)
Por outro lado, não é óbvio como projetar um algoritmo melhor para
computar o resultado, e deixamos esse problema como um desafio. A
observação de que um processo recursivo em árvore pode ser altamente
ineficiente, mas muitas vezes fácil de especificar e entender, levou as
pessoas a propor que se poderia obter o melhor dos dois mundos
projetando um "compilador inteligente" que pudesse transformar
procedimentos recursivos em árvore em procedimentos mais eficientes que
computam o mesmo resultado.34
Exercício 1.11: Uma função é definida pela regra que se e se . Escreva um procedimento que computa por meio de um processo recursivo. Escreva um procedimento que computa por meio de um processo iterativo.
Exercício 1.12: O seguinte padrão de números é chamado Triângulo de Pascal.
1 1 1 1 2 1 1 3 3 1 1 4 6 4 1 . . .Os números nas bordas do triângulo são todos 1, e cada número dentro do triângulo é a soma dos dois números acima dele.35 Escreva um procedimento que computa elementos do Triângulo de Pascal por meio de um processo recursivo.
Exercício 1.13: Prove que é o inteiro mais próximo de , onde . Dica: Seja . Use indução e a definição dos números de Fibonacci (veja 1.2.2) para provar que .
Os exemplos anteriores ilustram que os processos podem diferir consideravelmente nas taxas em que consomem recursos computacionais. Uma maneira conveniente de descrever essa diferença é usar a noção de ordem de crescimento para obter uma medida grosseira dos recursos necessários para um processo à medida que as entradas aumentam.
Seja um parâmetro que mede o tamanho do problema, e seja a quantidade de recursos que o processo requer para um problema de tamanho . Em nossos exemplos anteriores, tomamos como o número para o qual uma determinada função deve ser calculada, mas há outras possibilidades. Por exemplo, se nosso objetivo é calcular uma aproximação para a raiz quadrada de um número, podemos tomar como o número de dígitos de precisão necessários. Para a multiplicação de matrizes, podemos tomar como o número de linhas nas matrizes. Em geral, há várias propriedades do problema em relação às quais será desejável analisar um determinado processo. Da mesma forma, pode medir o número de registros de armazenamento interno usados, o número de operações elementares da máquina realizadas, e assim por diante. Em computadores que realizam apenas um número fixo de operações por vez, o tempo necessário será proporcional ao número de operações elementares da máquina realizadas.
Dizemos que tem ordem de crescimento , escrito (pronunciado “teta de ”), se existirem constantes positivas e independentes de tais que para qualquer valor suficientemente grande de . (Em outras palavras, para valores grandes de , o valor está "sanduichado" entre e .)
Por exemplo, com o processo recursivo linear para calcular o fatorial descrito em 1.2.1, o número de passos cresce proporcionalmente à entrada . Assim, os passos necessários para esse processo crescem como . Também vimos que o espaço necessário cresce como . Para o fatorial iterativo, o número de passos ainda é mas o espaço é —ou seja, constante.36 A computação recursiva em árvore de Fibonacci requer passos e espaço , onde é a razão áurea descrita em 1.2.2.
As ordens de crescimento fornecem apenas uma descrição grosseira do comportamento de um processo. Por exemplo, um processo que requer passos e um processo que requer passos e um processo que requer passos todos têm como ordem de crescimento. Por outro lado, a ordem de crescimento fornece uma indicação útil de como podemos esperar que o comportamento do processo mude à medida que alteramos o tamanho do problema. Para um processo (linear), dobrar o tamanho do problema aproximadamente dobrará a quantidade de recursos usados. Para um processo exponencial, cada incremento no tamanho do problema multiplicará a utilização de recursos por um fator constante. No restante de 1.2, examinaremos dois algoritmos cuja ordem de crescimento é logarítmica, de modo que dobrar o tamanho do problema aumenta a necessidade de recursos por uma quantidade constante.
Exercício 1.14: Desenhe a árvore que ilustra o processo gerado pelo procedimento
count-change
de 1.2.2 ao fazer o troco para 11 centavos. Quais são as ordens de crescimento do espaço e do número de passos usados por esse processo à medida que a quantidade a ser trocada aumenta?
Exercício 1.15: O seno de um ângulo (especificado em radianos) pode ser calculado usando a aproximação se for suficientemente pequeno, e a identidade trigonométrica para reduzir o tamanho do argumento de sin. (Para os propósitos deste exercício, um ângulo é considerado “suficientemente pequeno” se sua magnitude não for maior que 0,1 radianos.) Essas ideias são incorporadas nos seguintes procedimentos:
(define (cube x) (* x x x)) (define (p x) (- (* 3 x) (* 4 (cube x)))) (define (sine angle) (if (not (> (abs angle) 0.1)) angle (p (sine (/ angle 3.0)))))
- Quantas vezes o procedimento
p
é aplicado quando(sine 12.15)
é avaliado?- Qual é a ordem de crescimento em espaço e número de passos (como uma função de ) usados pelo processo gerado pelo procedimento
sine
quando(sine a)
é avaliado?
Considere o problema de calcular a exponencial de um número dado. Gostaríamos de um procedimento que receba como argumentos uma base e um expoente inteiro positivo e calcule . Uma maneira de fazer isso é via a definição recursiva que se traduz facilmente no procedimento
(define (expt b n)
(if (= n 0)
1
(* b (expt b (- n 1)))))
Este é um processo recursivo linear, que requer passos e espaço. Assim como com o fatorial, podemos facilmente formular uma iteração linear equivalente:
(define (expt b n)
(expt-iter b n 1))
(define (expt-iter b counter product)
(if (= counter 0)
product
(expt-iter b
(- counter 1)
(* b product))))
Esta versão requer passos e espaço.
Podemos calcular exponenciais em menos passos usando quadrados sucessivos. Por exemplo, em vez de calcular como podemos calculá-lo usando três multiplicações: Este método funciona bem para expoentes que são potências de 2. Também podemos aproveitar os quadrados sucessivos para calcular exponenciais em geral, se usarmos a regra Podemos expressar esse método como um procedimento:
(define (fast-expt b n)
(cond ((= n 0)
1)
((even? n)
(square (fast-expt b (/ n 2))))
(else
(* b (fast-expt b (- n 1))))))
onde o predicado para testar se um inteiro é par é definido em termos do
procedimento primitivo remainder
por
(define (even? n)
(= (remainder n 2) 0))
O processo evoluído por fast-expt
cresce logaritmicamente
com
tanto em espaço quanto em número de passos. Para ver isso, observe que
calcular
usando fast-expt
requer apenas uma multiplicação a mais do
que calcular
. O tamanho do expoente que podemos calcular, portanto, dobra
(aproximadamente) a cada nova multiplicação permitida. Assim, o número
de multiplicações necessárias para um expoente de
cresce aproximadamente tão rápido quanto o logaritmo de
na base 2. O processo tem
de crescimento.37
A diferença entre
de crescimento e
de crescimento torna-se impressionante à medida que
aumenta. Por exemplo, fast-expt
para
= 1000 requer apenas 14 multiplicações.38
Também é possível usar a ideia de quadrados sucessivos para criar um
algoritmo iterativo que calcula exponenciais com um número logarítmico
de passos (veja
Exercício 1.16), embora, como é comum
com algoritmos iterativos, isso não seja tão direto quanto o algoritmo
recursivo.39
Exercício 1.16: Projete um procedimento que evolua um processo de exponenciação iterativa que use quadrados sucessivos e um número logarítmico de passos, como o
fast-expt
. (Dica: Usando a observação de que , mantenha, junto com o expoente e a base , uma variável de estado adicional , e defina a transformação de estado de tal forma que o produto permaneça inalterado de estado para estado. No início do processo, é tomado como 1, e a resposta é dada pelo valor de no final do processo. Em geral, a técnica de definir uma quantidade invariante que permanece inalterada de estado para estado é uma maneira poderosa de pensar sobre o projeto de algoritmos iterativos.)
Exercício 1.17: Os algoritmos de exponenciação nesta seção são baseados na realização de exponenciação por meio de multiplicação repetida. De maneira semelhante, pode-se realizar a multiplicação de inteiros por meio de adição repetida. O seguinte procedimento de multiplicação (no qual se assume que nossa linguagem só pode somar, não multiplicar) é análogo ao procedimento
expt
:(define (* a b) (if (= b 0) 0 (+ a (* a (- b 1)))))
Este algoritmo leva um número de passos que é linear em
b
. Agora suponha que incluamos, junto com a adição, operaçõesdouble
, que dobra um inteiro, ehalve
, que divide um inteiro (par) por 2. Usando essas operações, projete um procedimento de multiplicação análogo aofast-expt
que use um número logarítmico de passos.
Exercício 1.18: Usando os resultados de Exercício 1.16 e Exercício 1.17, crie um procedimento que gere um processo iterativo para multiplicar dois inteiros em termos de adição, dobra e divisão por dois, e use um número logarítmico de passos.40
Exercício 1.19: Existe um algoritmo inteligente para calcular os números de Fibonacci em um número logarítmico de passos. Lembre-se da transformação das variáveis de estado e no processo
fib-iter
de 1.2.2: e . Chame essa transformação de , e observe que aplicar repetidamente vezes, começando com 1 e 0, produz o par e . Em outras palavras, os números de Fibonacci são produzidos aplicando , a potência da transformação , começando com o par (1, 0). Agora considere como o caso especial de e em uma família de transformações , onde transforma o par de acordo com e . Mostre que, se aplicarmos tal transformação duas vezes, o efeito é o mesmo que usar uma única transformação da mesma forma, e calcule e em termos de e . Isso nos dá uma maneira explícita de elevar essas transformações ao quadrado, e assim podemos calcular usando quadrados sucessivos, como no procedimentofast-expt
. Junte tudo isso para completar o seguinte procedimento, que é executado em um número logarítmico de passos:41(define (fib n) (fib-iter 1 0 0 1 n)) (define (fib-iter a b p q count) (cond ((= count 0) b) ((even? count) (fib-iter a b ⟨??⟩ ;compute p' ⟨??⟩ ;compute q' (/ count 2))) (else (fib-iter (+ (* b q) (* a q) (* a p)) (+ (* b p) (* a q)) p q (- count 1)))))
O máximo divisor comum (MDC) de dois inteiros e é definido como o maior inteiro que divide ambos e sem deixar resto. Por exemplo, o MDC de 16 e 28 é 4. Em Capítulo 2, quando investigarmos como implementar a aritmética de números racionais, precisaremos ser capazes de calcular MDCs para reduzir números racionais à sua forma mais simples. (Para reduzir um número racional à sua forma mais simples, devemos dividir tanto o numerador quanto o denominador pelo seu MDC. Por exemplo, 16/28 reduz-se a 4/7.) Uma maneira de encontrar o MDC de dois inteiros é fatorá-los e procurar por fatores comuns, mas existe um algoritmo famoso que é muito mais eficiente.
A ideia do algoritmo é baseada na observação de que, se é o resto quando é dividido por , então os divisores comuns de e são exatamente os mesmos que os divisores comuns de e . Assim, podemos usar a equação
MDC(a,b) = MDC(b,r)
para reduzir sucessivamente o problema de calcular um MDC ao problema de calcular o MDC de pares de inteiros cada vez menores. Por exemplo,
MDC(206,40) = MDC(40,6) = MDC(6,4) = MDC(4,2) = MDC(2,0) = 2
reduz MDC(206, 40) a MDC(2, 0), que é 2. É possível mostrar que, começando com quaisquer dois inteiros positivos e realizando reduções repetidas, sempre se chegará eventualmente a um par onde o segundo número é 0. Então, o MDC é o outro número no par. Esse método para calcular o MDC é conhecido como Algoritmo de Euclides.42
É fácil expressar o Algoritmo de Euclides como um procedimento:
(define (gcd a b)
(if (= b 0)
a
(gcd b (remainder a b))))
Isso gera um processo iterativo, cujo número de passos cresce como o logaritmo dos números envolvidos.
O fato de que o número de passos necessários para o Algoritmo de Euclides ter crescimento logarítmico tem uma relação interessante com os números de Fibonacci:
Teorema de Lamé: Se o Algoritmo de Euclides requer passos para calcular o MDC de algum par, então o menor número no par deve ser maior ou igual ao número de Fibonacci.43
Podemos usar esse teorema para obter uma estimativa da ordem de crescimento do Algoritmo de Euclides. Seja o menor dos dois números de entrada para o procedimento. Se o processo leva passos, então devemos ter . Portanto, o número de passos cresce como o logaritmo (na base ) de . Assim, a ordem de crescimento é .
Exercício 1.20: O processo que um procedimento gera é, é claro, dependente das regras usadas pelo interpretador. Como exemplo, considere o procedimento iterativo
gcd
dado acima. Suponha que interpretássemos esse procedimento usando avaliação de ordem normal, como discutido em 1.1.5. (A regra de avaliação de ordem normal paraif
é descrita em Exercício 1.5.) Usando o método de substituição (para ordem normal), ilustre o processo gerado na avaliação de(gcd 206 40)
e indique as operaçõesremainder
que são realmente realizadas. Quantas operaçõesremainder
são realmente realizadas na avaliação de ordem normal de(gcd 206 40)
? E na avaliação de ordem aplicativa?
Esta seção descreve dois métodos para verificar a primalidade de um inteiro , um com ordem de crescimento , e um algoritmo “probabilístico” com ordem de crescimento . Os exercícios no final desta seção sugerem projetos de programação baseados nesses algoritmos.
Desde os tempos antigos, os matemáticos têm sido fascinados por problemas relacionados a números primos, e muitas pessoas trabalharam no problema de determinar maneiras de testar se números são primos. Uma maneira de testar se um número é primo é encontrar os divisores do número. O seguinte programa encontra o menor divisor inteiro (maior que 1) de um número dado . Ele faz isso de maneira direta, testando para divisibilidade por inteiros sucessivos começando com 2.
(define (smallest-divisor n)
(find-divisor n 2))
(define (find-divisor n test-divisor)
(cond ((> (square test-divisor) n)
n)
((divides? test-divisor n)
test-divisor)
(else (find-divisor
n
(+ test-divisor 1)))))
(define (divides? a b)
(= (remainder b a) 0))
Podemos testar se um número é primo da seguinte forma: é primo se e somente se for seu próprio menor divisor.
(define (prime? n)
(= n (smallest-divisor n)))
O teste final para find-divisor
é baseado no fato de que,
se
não for primo, ele deve ter um divisor menor ou igual a
.44
Isso significa que o algoritmo precisa testar apenas divisores entre 1 e
. Consequentemente, o número de passos necessários para identificar
como primo terá ordem de crescimento
.
O teste de primalidade com ordem de crescimento é baseado em um resultado da teoria dos números conhecido como o Pequeno Teorema de Fermat.45
Pequeno Teorema de Fermat: Se é um número primo e é qualquer inteiro positivo menor que , então elevado à potência é congruente a módulo .
(Dois números são ditos congruentes módulo se ambos têm o mesmo resto quando divididos por . O resto de um número quando dividido por também é referido como o resto de módulo , ou simplesmente como módulo .)
Se não for primo, então, em geral, a maioria dos números não satisfará a relação acima. Isso leva ao seguinte algoritmo para testar primalidade: Dado um número , escolha um número aleatório e calcule o resto de módulo . Se o resultado não for igual a , então certamente não é primo. Se for , então as chances são boas de que seja primo. Agora escolha outro número aleatório e teste-o com o mesmo método. Se ele também satisfizer a equação, então podemos ter ainda mais confiança de que é primo. Ao tentar mais e mais valores de , podemos aumentar nossa confiança no resultado. Esse algoritmo é conhecido como o teste de Fermat.
Para implementar o teste de Fermat, precisamos de um procedimento que calcule a exponencial de um número módulo outro número:
(define (expmod base exp m)
(cond ((= exp 0) 1)
((even? exp)
(remainder
(square (expmod base (/ exp 2) m))
m))
(else
(remainder
(* base (expmod base (- exp 1) m))
m))))
Isso é muito semelhante ao procedimento fast-expt
de
1.2.4. Ele usa quadrados sucessivos, de
modo que o número de passos cresce logaritmicamente com o expoente.46
O teste de Fermat é realizado escolhendo aleatoriamente um número
entre 1 e
inclusive e verificando se o resto módulo
da
potência de
é igual a
. O
número aleatório
é escolhido usando o procedimento random
, que assumimos
estar incluído como primitivo no Scheme. Random
retorna um
inteiro não negativo menor que sua entrada inteira. Portanto, para obter
um número aleatório entre 1 e
, chamamos random
com uma entrada de
e adicionamos 1 ao resultado:
(define (fermat-test n)
(define (try-it a)
(= (expmod a n n) a))
(try-it (+ 1 (random (- n 1)))))
O seguinte procedimento executa o teste um número determinado de vezes, como especificado por um parâmetro. Seu valor é verdadeiro se o teste for bem-sucedido todas as vezes, e falso caso contrário.
(define (fast-prime? n times)
(cond ((= times 0) true)
((fermat-test n)
(fast-prime? n (- times 1)))
(else false)))
O teste de Fermat difere em caráter da maioria dos algoritmos familiares, nos quais se calcula uma resposta que é garantidamente correta. Aqui, a resposta obtida é apenas provavelmente correta. Mais precisamente, se falhar no teste de Fermat, podemos ter certeza de que não é primo. Mas o fato de passar no teste, embora seja uma indicação extremamente forte, ainda não é uma garantia de que seja primo. O que gostaríamos de dizer é que, para qualquer número , se realizarmos o teste suficientes vezes e descobrirmos que sempre passa no teste, então a probabilidade de erro em nosso teste de primalidade pode ser tão pequena quanto desejarmos.
Infelizmente, essa afirmação não é totalmente correta. Existem números que enganam o teste de Fermat: números que não são primos e ainda têm a propriedade de que é congruente a módulo para todos os inteiros . Tais números são extremamente raros, então o teste de Fermat é bastante confiável na prática.47
Existem variações do teste de Fermat que não podem ser enganadas. Nesses testes, assim como no método de Fermat, testa-se a primalidade de um inteiro escolhendo um inteiro aleatório e verificando alguma condição que depende de e . (Veja Exercício 1.28 para um exemplo de tal teste.) Por outro lado, em contraste com o teste de Fermat, pode-se provar que, para qualquer , a condição não se mantém para a maioria dos inteiros a menos que seja primo. Assim, se passar no teste para alguma escolha aleatória de , as chances são melhores do que 50% de que seja primo. Se passar no teste para duas escolhas aleatórias de , as chances são melhores do que 75% de que seja primo. Ao executar o teste com mais e mais valores escolhidos aleatoriamente de , podemos tornar a probabilidade de erro tão pequena quanto desejarmos.
A existência de testes para os quais se pode provar que a chance de erro se torna arbitrariamente pequena despertou interesse em algoritmos desse tipo, que passaram a ser conhecidos como algoritmos probabilísticos. Há muita atividade de pesquisa nessa área, e algoritmos probabilísticos têm sido aplicados com sucesso em muitos campos.48
Exercício 1.21: Use o procedimento
smallest-divisor
para encontrar o menor divisor de cada um dos seguintes números: 199, 1999, 19999.
Exercício 1.22: A maioria das implementações de Lisp inclui uma primitiva chamada
runtime
que retorna um inteiro que especifica a quantidade de tempo que o sistema está em execução (medido, por exemplo, em microssegundos). O seguinte procedimentotimed-prime-test
, quando chamado com um inteiro , imprime e verifica se é primo. Se for primo, o procedimento imprime três asteriscos seguidos pela quantidade de tempo usada para realizar o teste.(define (timed-prime-test n) (newline) (display n) (start-prime-test n (runtime))) (define (start-prime-test n start-time) (if (prime? n) (report-prime (- (runtime) start-time)))) (define (report-prime elapsed-time) (display " *** ") (display elapsed-time))
Usando esse procedimento, escreva um procedimento
search-for-primes
que verifique a primalidade de números ímpares consecutivos em um intervalo especificado. Use seu procedimento para encontrar os três menores primos maiores que 1000; maiores que 10.000; maiores que 100.000; maiores que 1.000.000. Observe o tempo necessário para testar cada primo. Como o algoritmo de teste tem ordem de crescimento de , você deve esperar que o teste para primos próximos de 10.000 leve cerca de vezes mais tempo do que o teste para primos próximos de 1000. Seus dados de tempo confirmam isso? Quão bem os dados para 100.000 e 1.000.000 suportam a previsão de ? Seu resultado é compatível com a noção de que os programas em sua máquina são executados em tempo proporcional ao número de passos necessários para a computação?
Exercício 1.23: O procedimento
smallest-divisor
mostrado no início desta seção faz muitos testes desnecessários: depois de verificar se o número é divisível por 2, não há sentido em verificar se ele é divisível por qualquer número par maior. Isso sugere que os valores usados paratest-divisor
não devem ser 2, 3, 4, 5, 6, …, mas sim 2, 3, 5, 7, 9, …. Para implementar essa mudança, defina um procedimentonext
que retorne 3 se sua entrada for igual a 2 e, caso contrário, retorne sua entrada mais 2. Modifique o procedimentosmallest-divisor
para usar(next test-divisor)
em vez de(+ test-divisor 1)
. Comtimed-prime-test
incorporando essa versão modificada desmallest-divisor
, execute o teste para cada um dos 12 primos encontrados em Exercício 1.22. Como essa modificação reduz o número de passos de teste pela metade, você deve esperar que ele execute cerca de duas vezes mais rápido. Essa expectativa é confirmada? Se não, qual é a razão observada entre as velocidades dos dois algoritmos, e como você explica o fato de que ela é diferente de 2?
Exercício 1.24: Modifique o procedimento
timed-prime-test
de Exercício 1.22 para usarfast-prime?
(o método de Fermat) e teste cada um dos 12 primos que você encontrou naquele exercício. Como o teste de Fermat tem ordem de crescimento , como você esperaria que o tempo para testar primos próximos de 1.000.000 se comparasse ao tempo necessário para testar primos próximos de 1000? Seus dados confirmam isso? Você consegue explicar qualquer discrepância que encontrar?
Exercício 1.25: Alyssa P. Hacker reclama que fizemos muito trabalho extra ao escrever
expmod
. Afinal, ela diz, já sabemos como calcular exponenciais, então poderíamos simplesmente ter escrito(define (expmod base exp m) (remainder (fast-expt base exp) m))
Ela está correta? Esse procedimento serviria tão bem para nosso teste de primalidade rápido? Explique.
Exercício 1.26: Louis Reasoner está tendo grande dificuldade em fazer Exercício 1.24. Seu teste
fast-prime?
parece ser mais lento que o testeprime?
. Louis chama sua amiga Eva Lu Ator para ajudar. Quando eles examinam o código de Louis, descobrem que ele reescreveu o procedimentoexpmod
para usar uma multiplicação explícita, em vez de chamarsquare
:(define (expmod base exp m) (cond ((= exp 0) 1) ((even? exp) (remainder (* (expmod base (/ exp 2) m) (expmod base (/ exp 2) m)) m)) (else (remainder (* base (expmod base (- exp 1) m)) m))))
“Eu não vejo que diferença isso faz,” diz Louis. “Eu vejo,” diz Eva. “Ao escrever o procedimento dessa forma, você transformou o processo de em um processo de .” Explique.
Exercício 1.27: Demonstre que os números de Carmichael listados em Nota de rodapé 47 realmente enganam o teste de Fermat. Ou seja, escreva um procedimento que receba um inteiro e teste se é congruente a módulo para todo , e tente seu procedimento nos números de Carmichael fornecidos.
Exercício 1.28: Uma variante do teste de Fermat que não pode ser enganada é chamada de teste de Miller-Rabin (Miller 1976; Rabin 1980). Ele começa a partir de uma forma alternativa do Pequeno Teorema de Fermat, que afirma que, se é um número primo e é qualquer inteiro positivo menor que , então elevado à -ésima potência é congruente a 1 módulo . Para testar a primalidade de um número pelo teste de Miller-Rabin, escolhemos um número aleatório e elevamos à -ésima potência módulo usando o procedimento
expmod
. No entanto, sempre que realizamos o passo de elevar ao quadrado emexpmod
, verificamos se descobrimos uma “raiz quadrada não trivial de 1 módulo ,” ou seja, um número diferente de 1 ou cujo quadrado é igual a 1 módulo . É possível provar que, se tal raiz quadrada não trivial de 1 existir, então não é primo. Também é possível provar que, se for um número ímpar que não é primo, então, para pelo menos metade dos números , calcular dessa maneira revelará uma raiz quadrada não trivial de 1 módulo . (É por isso que o teste de Miller-Rabin não pode ser enganado.) Modifique o procedimentoexpmod
para sinalizar se ele descobre uma raiz quadrada não trivial de 1, e use isso para implementar o teste de Miller-Rabin com um procedimento análogo afermat-test
. Verifique seu procedimento testando vários primos e não primos conhecidos. Dica: Uma maneira conveniente de fazerexpmod
sinalizar é fazê-lo retornar 0.
29
Em um programa real, provavelmente usaríamos a estrutura de blocos
introduzida na última seção para esconder a definição de
fact-iter
:
(define (factorial n)
(define (iter product counter)
(if (> counter n)
product
(iter (* counter product)
(+ counter 1))))
(iter 1 1))
Evitamos fazer isso aqui para minimizar o número de coisas a serem consideradas de uma só vez.
30 Quando discutimos a implementação de procedimentos em máquinas de registradores em Capítulo 5, veremos que qualquer processo iterativo pode ser realizado “em hardware” como uma máquina que tem um conjunto fixo de registradores e nenhuma memória auxiliar. Em contraste, a realização de um processo recursivo requer uma máquina que usa uma estrutura de dados auxiliar conhecida como pilha.
31 A recursão em cauda é conhecida há muito tempo como uma otimização de compilador. Uma base semântica coerente para a recursão em cauda foi fornecida por Carl Hewitt (1977), que a explicou em termos do modelo de computação de “passagem de mensagens” que discutiremos em Capítulo 3. Inspirados por isso, Gerald Jay Sussman e Guy Lewis Steele Jr. (veja Steele e Sussman 1975) construíram um interpretador de recursão em cauda para Scheme. Steele mais tarde mostrou como a recursão em cauda é uma consequência natural da maneira de compilar chamadas de procedimento (Steele 1977). O padrão IEEE para Scheme exige que implementações de Scheme sejam recursivas em cauda.
32 Um exemplo disso foi sugerido em 1.1.3. O próprio interpretador avalia expressões usando um processo recursivo em árvore.
33 Por exemplo, trabalhe detalhadamente como a regra de redução se aplica ao problema de dar troco para 10 centavos usando moedas de um e cinco centavos.
34 Uma
abordagem para lidar com computações redundantes é organizar as
coisas de modo que automaticamente construamos uma tabela de valores
à medida que eles são computados. Cada vez que somos solicitados a
aplicar o procedimento a algum argumento, primeiro verificamos se o
valor já está armazenado na tabela, caso em que evitamos realizar a
computação redundante. Essa estratégia, conhecida como
tabela ou
memorização, pode ser
implementada de maneira direta. A tabela pode às vezes ser usada
para transformar processos que requerem um número exponencial de
passos (como count-change
) em processos cujos
requisitos de espaço e tempo crescem linearmente com a entrada. Veja
Exercício 3.27.
35 Os elementos do triângulo de Pascal são chamados de coeficientes binomiais, porque a linha consiste nos coeficientes dos termos na expansão de . Esse padrão para calcular os coeficientes apareceu no trabalho seminal de Blaise Pascal sobre teoria da probabilidade, Traité du triangle arithmétique, de 1653. De acordo com Knuth (1973), o mesmo padrão aparece no Szu-yuen Yü-chien (“O Espelho Precioso dos Quatro Elementos”), publicado pelo matemático chinês Chu Shih-chieh em 1303, nas obras do poeta e matemático persa Omar Khayyam do século XII, e nas obras do matemático hindu Bháscara Áchárya do século XII.
36 Essas afirmações mascaram uma grande quantidade de simplificação. Por exemplo, se contarmos os passos do processo como “operações de máquina”, estamos assumindo que o número de operações de máquina necessárias para realizar, digamos, uma multiplicação é independente do tamanho dos números a serem multiplicados, o que é falso se os números forem suficientemente grandes. Observações semelhantes valem para as estimativas de espaço. Assim como o projeto e a descrição de um processo, a análise de um processo pode ser realizada em vários níveis de abstração.
37 Mais precisamente, o número de multiplicações necessárias é igual a 1 menos o logaritmo na base 2 de mais o número de uns na representação binária de . Esse total é sempre menor que duas vezes o logaritmo na base 2 de . As constantes arbitrárias e na definição da notação de ordem implicam que, para um processo logarítmico, a base à qual os logaritmos são tomados não importa, então todos esses processos são descritos como .
38 Você pode se perguntar por que alguém se importaria em elevar números à 1000ª potência. Veja 1.2.6.
39 Esse algoritmo iterativo é antigo. Ele aparece no Chandah-sutra de Áchárya Pingala, escrito antes de 200 a.C. Veja Knuth 1981, seção 4.6.3, para uma discussão completa e análise desse e de outros métodos de exponenciação.
40 Esse algoritmo, às vezes conhecido como o “método do camponês russo” de multiplicação, é antigo. Exemplos de seu uso são encontrados no Papiro Rhind, um dos dois documentos matemáticos mais antigos existentes, escrito por volta de 1700 a.C. (e copiado de um documento ainda mais antigo) por um escriba egípcio chamado A’h-mose.
41 Esse exercício foi sugerido a nós por Joe Stoy, com base em um exemplo em Kaldewaij 1990.
42 O Algoritmo de Euclides é assim chamado porque aparece nos Elementos de Euclides (Livro 7, ca. 300 a.C.). De acordo com Knuth (1973), ele pode ser considerado o algoritmo não trivial mais antigo conhecido. O método egípcio antigo de multiplicação (Exercício 1.18) é certamente mais antigo, mas, como Knuth explica, o algoritmo de Euclides é o mais antigo conhecido por ter sido apresentado como um algoritmo geral, em vez de um conjunto de exemplos ilustrativos.
43 Esse teorema foi provado em 1845 por Gabriel Lamé, um matemático e engenheiro francês conhecido principalmente por suas contribuições à física matemática. Para provar o teorema, consideramos pares , onde , para os quais o Algoritmo de Euclides termina em passos. A prova é baseada na afirmação de que, se são três pares sucessivos no processo de redução, então devemos ter . Para verificar a afirmação, considere que um passo de redução é definido pela aplicação da transformação , resto de dividido por . A segunda equação significa que para algum inteiro positivo . E como deve ser pelo menos 1, temos . Mas no passo de redução anterior temos . Portanto, . Isso verifica a afirmação. Agora podemos provar o teorema por indução em , o número de passos que o algoritmo requer para terminar. O resultado é verdadeiro para , já que isso apenas requer que seja pelo menos tão grande quanto . Agora, assuma que o resultado é verdadeiro para todos os inteiros menores ou iguais a e estabeleça o resultado para . Seja pares sucessivos no processo de redução. Por nossas hipóteses de indução, temos e . Assim, aplicando a afirmação que acabamos de provar junto com a definição dos números de Fibonacci, temos , o que completa a prova do Teorema de Lamé.
44 Se é um divisor de , então também é. Mas e não podem ser ambos maiores que .
45 Pierre de Fermat (1601-1665) é considerado o fundador da teoria dos números moderna. Ele obteve muitos resultados importantes na teoria dos números, mas geralmente anunciava apenas os resultados, sem fornecer suas provas. O Pequeno Teorema de Fermat foi declarado em uma carta que ele escreveu em 1640. A primeira prova publicada foi dada por Euler em 1736 (e uma prova idêntica foi descoberta nos manuscritos não publicados de Leibniz). O mais famoso dos resultados de Fermat—conhecido como o Último Teorema de Fermat—foi anotado em 1637 em sua cópia do livro Aritmética (do matemático grego do século III Diofanto) com a observação “Eu descobri uma prova verdadeiramente maravilhosa, mas esta margem é muito pequena para contê-la.” Encontrar uma prova do Último Teorema de Fermat tornou-se um dos desafios mais famosos da teoria dos números. Uma solução completa foi finalmente dada em 1995 por Andrew Wiles da Universidade de Princeton.
46 Os passos de redução nos casos em que o expoente é maior que 1 são baseados no fato de que, para quaisquer inteiros , , e , podemos encontrar o resto de vezes módulo calculando separadamente os restos de módulo e módulo , multiplicando esses valores e, em seguida, tomando o resto do resultado módulo . Por exemplo, no caso em que é par, calculamos o resto de módulo , elevamos ao quadrado e tomamos o resto módulo . Essa técnica é útil porque significa que podemos realizar nossos cálculos sem precisar lidar com números muito maiores que . (Compare com Exercício 1.25.)
47 Números que enganam o teste de Fermat são chamados números de Carmichael, e pouco se sabe sobre eles, exceto que são extremamente raros. Existem 255 números de Carmichael abaixo de 100.000.000. Os menores são 561, 1105, 1729, 2465, 2821 e 6601. Ao testar a primalidade de números muito grandes escolhidos aleatoriamente, a chance de encontrar um valor que engane o teste de Fermat é menor que a chance de a radiação cósmica causar um erro no computador ao executar um algoritmo "correto". Considerar um algoritmo inadequado pelo primeiro motivo, mas não pelo segundo, ilustra a diferença entre matemática e engenharia.
48 Uma das aplicações mais impressionantes do teste de primalidade probabilístico tem sido no campo da criptografia. Embora seja agora computacionalmente inviável fatorar um número arbitrário de 200 dígitos, a primalidade de tal número pode ser verificada em alguns segundos com o teste de Fermat. Esse fato forma a base de uma técnica para construir "códigos inquebráveis" sugerida por Rivest et al. (1977). O algoritmo RSA resultante tornou-se uma técnica amplamente utilizada para aumentar a segurança das comunicações eletrônicas. Por causa disso e de desenvolvimentos relacionados, o estudo dos números primos, uma vez considerado o epítome de um tópico da matemática "pura" a ser estudado apenas por si mesmo, agora se revela com importantes aplicações práticas em criptografia, transferência eletrônica de fundos e recuperação de informação.