Eu vou andar por aí por um tempo, mas há um ponto.
Semigrupos
A resposta é a propriedade associativa da operação de redução binária .
Isso é bastante abstrato, mas a multiplicação é um bom exemplo. Se x , y e z são alguns números naturais (ou inteiros, ou números racionais, ou números reais ou números complexos ou N × N matrizes, ou qualquer de um monte mais coisas), então x × y é o mesmo tipo de número como x e y . Começamos com dois números, por isso é uma operação binária e obtivemos um, então reduzimos a contagem de números que tínhamos em um, tornando isso uma operação de redução. E ( x × y ) × z é sempre o mesmo que x × ( y ×z ), que é a propriedade associativa.
(Se você já sabe tudo isso, pode pular para a próxima seção.)
Mais algumas coisas que você costuma ver na ciência da computação que funcionam da mesma maneira:
- adicionando qualquer um desses tipos de números em vez de multiplicar
- concatenação de strings (
"a"+"b"+"c"
é "abc"
se você começa com "ab"+"c"
ou "a"+"bc"
)
- Emendando duas listas juntas.
[a]++[b]++[c]
é similarmente [a,b,c]
de trás para frente ou de frente para trás.
cons
na cabeça e no rabo, se você pensa na cabeça como uma lista única. Isso é apenas concatenar duas listas.
- tomando a união ou a interseção de conjuntos
- Booleano e Booleano ou
- bit a bit
&
, |
e^
- composição das funções: ( f ∘ g ) ∘ h x = f ∘ ( g ∘ h ) x = f ( g ( h ( x )))
- máximo e mínimo
- módulo de adição p
Algumas coisas que não fazem:
- subtração, porque 1- (1-2) ≠ (1-1) -2
- x ⊕ y = tan ( x + y ), porque tan (π / 4 + π / 4) é indefinido
- multiplicação sobre os números negativos, porque -1 × -1 não é um número negativo
- divisão de números inteiros, que tem todos os três problemas!
- lógico não, porque ele tem apenas um operando, não dois
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, como print2( print2(x,y), z );
e print2( x, print2(y,z) );
tem saída diferente.
É um conceito útil o suficiente que o denominamos. Um conjunto com uma operação que possui essas propriedades é um semigrupo . Portanto, os números reais sob multiplicação são um semigrupo. E sua pergunta acaba sendo uma das maneiras pelas quais esse tipo de abstração se torna útil no mundo real. As operações de semigrupos podem ser otimizadas da maneira que você está perguntando.
Tente isso em casa
Até onde eu sei, essa técnica foi descrita pela primeira vez em 1974, no artigo de Daniel Friedman e David Wise, “Dobrando recursões estilizadas em iterações” , embora eles assumissem mais algumas propriedades do que precisavam.
Haskell é uma ótima linguagem para ilustrar isso, porque possui a Semigroup
classe de tipo em sua biblioteca padrão. Ele chama a operação de um Semigroup
operador genérico <>
. Como listas e cadeias são instâncias de Semigroup
, suas instâncias definem <>
como o operador de concatenação ++
, por exemplo. E com a importação correta, [a] <> [b]
é um alias para [a] ++ [b]
, o que é [a,b]
.
Mas e os números? Nós acabamos de ver que tipos numéricos são semigroups sob qualquer adição ou multiplicação! Então qual chega a ser <>
um Double
? Bem, qualquer um! Haskell define os tipos Product Double
, where (<>) = (*)
(que é a própria definição em Haskell), e também Sum Double
, where (<>) = (+)
.
Uma das rugas é que você usou o fato de que 1 é a identidade multiplicativa. Um semigrupo com uma identidade é chamado monóide e é definido no pacote Haskell Data.Monoid
, que chama o elemento de identidade genérico de uma classe de tipo mempty
. Sum
, Product
e listar cada um tem um elemento de identidade (0, 1 e []
, respectivamente), portanto, são instâncias Monoid
e também Semigroup
. (Não deve ser confundida com uma mônada , então esqueça que eu as criei.)
São informações suficientes para converter seu algoritmo em uma função Haskell usando monoides:
module StylizedRec (pow) where
import Data.Monoid as DM
pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
- itself n times. This is already in Haskell as Data.Monoid.mtimes, but
- let’s write it out as an example.
-}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.
É importante ressaltar que esse é um semigrupo de módulo de recursão de cauda: todo caso é um valor, uma chamada recursiva de cauda ou o produto de semigrupo de ambos. Além disso, esse exemplo foi usado mempty
em um dos casos, mas se não precisássemos disso, poderíamos ter feito isso com a classe de tipo mais geral Semigroup
.
Vamos carregar este programa no GHCI e ver como ele funciona:
*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49
Lembre-se de como declaramos pow
para um genérico Monoid
, de quem chamamos a
? Demos GHCI informação suficiente para deduzir que o tipo a
aqui é Product Integer
, que é um instance
de Monoid
cuja <>
operação é inteiro multiplicação. Então se pow 2 4
expande recursivamente para 2<>2<>2<>2
, que é 2*2*2*2
ou 16
. Por enquanto, tudo bem.
Mas nossa função usa apenas operações monóides genéricas. Anteriormente, eu disse que existe outra instância de Monoid
chamado Sum
, cuja <>
operação é +
. Podemos tentar isso?
*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14
A mesma expansão agora nos dá, em 2+2+2+2
vez de 2*2*2*2
. Multiplicação é adição como exponenciação é multiplicação!
Mas dei outro exemplo de um monóide Haskell: listas, cuja operação é concatenação.
*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]
Escrever [2]
diz ao compilador que esta é uma lista, <>
nas listas ++
, [2]++[2]++[2]++[2]
é assim [2,2,2,2]
.
Finalmente, um algoritmo (dois, de fato)
Simplesmente substituindo x
por [x]
, você converte o algoritmo genérico que usa o módulo de recursão de um semigrupo em um que cria uma lista. Qual lista? A lista de elementos aos quais o algoritmo se aplica <>
. Como também usamos apenas operações de semigrupos que as listas possuem, a lista resultante será isomórfica ao cálculo original. E como a operação original era associativa, podemos igualmente avaliar os elementos de trás para frente ou de frente para trás.
Se o seu algoritmo chegar a um caso base e terminar, a lista ficará vazia. Como o caso terminal retornou algo, esse será o elemento final da lista e, portanto, terá pelo menos um elemento.
Como você aplica uma operação de redução binária a todos os elementos de uma lista em ordem? Isso mesmo, uma dobra. Então você pode substituir [x]
para x
, obter uma lista de elementos para reduzir em <>
, e em seguida, dobra-direita ou esquerda vezes na lista:
*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16
A versão com foldr1
realmente existe na biblioteca padrão, como sconcat
para Semigroup
e mconcat
para Monoid
. Ele faz uma dobra preguiçosa à direita na lista. Ou seja, ele se expande [Product 2,Product 2,Product 2,Product 2]
para 2<>(2<>(2<>(2)))
.
Isso não é eficiente nesse caso, porque você não pode fazer nada com os termos individuais até gerar todos eles. (Em um momento, discuti aqui sobre quando usar dobras à direita e quando usar dobras estritas à esquerda, mas isso foi longe demais.)
A versão com foldl1'
é uma dobra esquerda estritamente avaliada. Ou seja, uma função recursiva da cauda com um acumulador estrito. Isso avalia (((2)<>2)<>2)<>2
, calculado imediatamente e não depois, quando necessário. (Pelo menos, não há atraso dentro da dobra em si:. Da lista a ser dobrada é gerado aqui por outra função que pode conter avaliação lenta) Assim, os calcula de dobragem (4<>2)<>2
, em seguida, imediatamente calcula 8<>2
, em seguida 16
. É por isso que precisamos que a operação fosse associativa: acabamos de alterar o agrupamento dos parênteses!
A estrita dobra à esquerda é o equivalente ao que o GCC está fazendo. O número mais à esquerda no exemplo anterior é o acumulador, neste caso, um produto em execução. A cada etapa, é multiplicado pelo próximo número da lista. Outra maneira de expressar isso é: você itera sobre os valores a serem multiplicados, mantendo o produto em execução em um acumulador e, a cada iteração, multiplica o acumulador pelo próximo valor. Ou seja, é um while
laço disfarçado.
Às vezes, pode ser feito com a mesma eficiência. O compilador pode otimizar a estrutura de dados da lista na memória. Em teoria, ele possui informações suficientes no momento da compilação para descobrir que deve fazê-lo aqui: [x]
é um singleton, [x]<>xs
o mesmo é cons x xs
. Cada iteração da função pode reutilizar o mesmo quadro de pilha e atualizar os parâmetros no local.
Uma dobra direita ou uma dobra estrita à esquerda podem ser mais apropriadas, em um caso específico, então saiba qual você deseja. Também existem algumas coisas que apenas uma dobra à direita pode fazer (como gerar saída interativa sem aguardar toda a entrada e operar em uma lista infinita). Aqui, porém, estamos reduzindo uma sequência de operações a um valor simples, de modo que uma dobra esquerda estrita é o que queremos.
Portanto, como você pode ver, é possível otimizar automaticamente o módulo de recursão da cauda em qualquer semigrupo (um exemplo é um dos tipos numéricos usuais em multiplicação) para uma dobra preguiçosa à direita ou uma dobra estrita à esquerda, em uma linha de Haskell.
Generalizando mais
Os dois argumentos da operação binária não precisam ser do mesmo tipo, desde que o valor inicial seja do mesmo tipo que o resultado. (É claro que você sempre pode inverter os argumentos para corresponder à ordem do tipo de dobra que você está fazendo, esquerda ou direita.) Portanto, você pode adicionar patches repetidamente a um arquivo para obter um arquivo atualizado ou começar com um valor inicial de 1.0, divida por números inteiros para acumular um resultado de ponto flutuante. Ou acrescente elementos à lista vazia para obter uma lista.
Outro tipo de generalização é aplicar as dobras não em listas, mas em outras Foldable
estruturas de dados. Frequentemente, uma lista vinculada linear imutável não é a estrutura de dados que você deseja para um determinado algoritmo. Uma questão que não abordamos acima é que é muito mais eficiente adicionar elementos à frente de uma lista do que à parte de trás e, quando a operação não é comutativa, a aplicação x
à esquerda e à direita da operação não é o mesmo. Portanto, você precisaria usar outra estrutura, como um par de listas ou árvore binária, para representar um algoritmo que poderia ser aplicado x
à direita <>
e à esquerda.
Observe também que a propriedade associativa permite reagrupar as operações de outras maneiras úteis, como dividir e conquistar:
times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n = y <> y
| otherwise = x <> y <> y
where y = times x (n `quot` 2)
Ou paralelismo automático, em que cada encadeamento reduz um subintervalo a um valor que é então combinado com os outros.
if(n==0) return 0;
(não retorna 1 como em sua pergunta).x^0 = 1
, então isso é um bug. Não que isso importe para o resto da questão; o asm iterativo verifica primeiro esse caso especial. Mas, estranhamente, a implementação iterativa introduz uma multiplicidade1 * x
disso que não estava presente na fonte, mesmo se fizermos umafloat
versão. gcc.godbolt.org/z/eqwine (e gcc só consegue com-ffast-math
.)