Quaisquer dicas sobre como resolver com eficiência a seguinte função no Haskell, para grandes números (n > 108)
f(n) = max(n, f(n/2) + f(n/3) + f(n/4))
Eu vi exemplos de memorização em Haskell para resolver números de fibonacci, que envolviam computar (preguiçosamente) todos os números de fibonacci até o n necessário. Mas, neste caso, para um dado n, precisamos apenas calcular muito poucos resultados intermediários.
obrigado
haskell
memoization
Angel de Vicente
fonte
fonte
Respostas:
Podemos fazer isso de maneira muito eficiente, criando uma estrutura que podemos indexar em tempo sublinear.
Mas primeiro,
Vamos definir
f
, mas faça com que use 'recursão aberta' em vez de se chamar diretamente.Você pode obter um não-personalizado
f
usandofix f
Isso permitirá que você teste
f
o que você quer dizer com pequenos valoresf
chamando, por exemplo:fix f 123 = 144
Podemos memorizar isso definindo:
Isso tem um desempenho razoavelmente bom e substitui o que levaria tempo O (n ^ 3) por algo que memorizasse os resultados intermediários.
Mas ainda leva tempo linear apenas para indexar e encontrar a resposta memorizada
mf
. Isso significa que resultados como:são toleráveis, mas o resultado não é muito melhor que isso. Nós podemos fazer melhor!
Primeiro, vamos definir uma árvore infinita:
E então definiremos uma maneira de indexá-lo, para que possamos encontrar um nó com índice
n
no tempo O (log n) :... e podemos achar uma árvore cheia de números naturais conveniente, para que não tenhamos que mexer com esses índices:
Como podemos indexar, você pode converter uma árvore em uma lista:
Você pode verificar o trabalho até agora, verificando se isso
toList nats
oferece[0..]
Agora,
funciona exatamente como na lista acima, mas, em vez de levar um tempo linear para encontrar cada nó, pode persegui-lo em tempo logarítmico.
O resultado é consideravelmente mais rápido:
Na verdade, é muito mais rápido que você possa passar e substituir
Int
comInteger
acima e obter ridiculamente grandes respostas quase que instantaneamentefonte
f_tree
ser definida em umawhere
cláusula para evitar salvar caminhos desnecessários na árvore nas chamadas?A resposta de Edward é uma jóia tão maravilhosa que eu a dupliquei e forneci implementações
memoList
ememoTree
combinadores que memorizam uma função de forma aberta-recursiva.fonte
Não é a maneira mais eficiente, mas memoriza:
ao solicitar
f !! 144
, é verificado sef !! 143
existe, mas seu valor exato não é calculado. Ainda está definido como resultado desconhecido de um cálculo. Os únicos valores exatos calculados são os necessários.Então, inicialmente, na medida em que foi calculado, o programa não sabe nada.
Quando fazemos a solicitação
f !! 12
, ela começa a fazer alguma correspondência de padrões:Agora começa a calcular
Isso recursivamente faz outra demanda em f, então calculamos
Agora podemos voltar alguns
O que significa que o programa agora sabe:
Continuando a chegar:
O que significa que o programa agora sabe:
Agora continuamos com nosso cálculo de
f!!6
:O que significa que o programa agora sabe:
Agora continuamos com nosso cálculo de
f!!12
:O que significa que o programa agora sabe:
Portanto, o cálculo é feito com preguiça. O programa sabe que
f !! 8
existe algum valor para , é igual ag 8
, mas não tem idéia do queg 8
é.fonte
g n m = (something with) f!!a!!b
Este é um adendo à excelente resposta de Edward Kmett.
Quando tentei o código dele, as definições
nats
eindex
pareciam bastante misteriosas, então escrevi uma versão alternativa que achei mais fácil de entender.Eu defino
index
enats
em termos deindex'
enats'
.index' t n
é definido sobre o intervalo[1..]
. (Lembre-se de queindex t
é definido acima do intervalo[0..]
.) Ele funciona pesquisando a árvore tratandon
como uma sequência de bits e lendo os bits ao contrário. Se o bit for1
, é necessário o ramo direito. Se o bit estiver0
, ele pega o ramo esquerdo. Para quando atinge o último bit (que deve ser a1
).Assim como
nats
é definido para,index
para queindex nats n == n
sempre seja verdadeiro,nats'
é definido paraindex'
.Agora,
nats
eindex
são simplesnats'
eindex'
com os valores alterados por 1:fonte
Conforme declarado na resposta de Edward Kmett, para acelerar as coisas, você precisa armazenar em cache cálculos caros e poder acessá-los rapidamente.
Para manter a função não monádica, a solução de construir uma árvore lenta e infinita, com uma maneira apropriada de indexá-la (como mostrado nas postagens anteriores), cumpre esse objetivo. Se você abandonar a natureza não monádica da função, poderá usar os contêineres associativos padrão disponíveis no Haskell em combinação com as mônadas "semelhantes a estados" (como Estado ou ST).
Embora a principal desvantagem seja a obtenção de uma função não monádica, você não precisa mais indexar a estrutura e pode usar implementações padrão de contêineres associativos.
Para fazer isso, primeiro você precisa reescrever sua função para aceitar qualquer tipo de mônada:
Para seus testes, você ainda pode definir uma função que não memoriza usando Data.Function.fix, embora seja um pouco mais detalhada:
Em seguida, você pode usar a mônada do estado em combinação com o Data.Map para acelerar as coisas:
Com pequenas alterações, você pode adaptar o código para trabalhar com Data.HashMap:
Em vez de estruturas de dados persistentes, você também pode tentar estruturas de dados mutáveis (como o Data.HashTable) em combinação com a mônada ST:
Comparado à implementação sem nenhuma memorização, qualquer uma dessas implementações permite que, para grandes entradas, obtenha resultados em microssegundos, em vez de esperar vários segundos.
Usando o Critério como referência, pude observar que a implementação com o Data.HashMap realmente teve um desempenho um pouco melhor (cerca de 20%) do que os Data.Map e Data.HashTable para os quais os tempos eram muito semelhantes.
Achei os resultados do benchmark um pouco surpreendentes. Meu sentimento inicial era que o HashTable superaria a implementação do HashMap porque é mutável. Pode haver algum defeito de desempenho oculto nesta última implementação.
fonte
Alguns anos depois, observei isso e percebi que havia uma maneira simples de memorizar isso em tempo linear usando
zipWith
uma função auxiliar:dilate
tem a propriedade útil quedilate n xs !! i == xs !! div i n
.Então, supondo que recebamos f (0), isso simplifica a computação para
Parecendo muito com a descrição original do problema e fornecendo uma solução linear (
sum $ take n fs
será usado O (n)).fonte
Mais um adendo à resposta de Edward Kmett: um exemplo independente:
Use-o da seguinte maneira para memorizar uma função com um único número inteiro arg (por exemplo, fibonacci):
Somente valores para argumentos não negativos serão armazenados em cache.
Para também armazenar em cache valores para argumentos negativos, use
memoInt
, definido da seguinte maneira:Para armazenar em cache valores para funções com dois argumentos inteiros
memoIntInt
, use o seguinte:fonte
Uma solução sem indexação e não baseada em Edward KMETT.
Eu fatoro subárvores comuns a um pai comum (
f(n/4)
é compartilhado entref(n/2)
ef(n/4)
, ef(n/6)
é compartilhado entref(2)
ef(3)
). Salvando-os como uma única variável no pai, o cálculo da subárvore é feito uma vez.O código não se estende facilmente a uma função de memorização geral (pelo menos, eu não saberia como fazê-lo), e você realmente precisa pensar em como os subproblemas se sobrepõem, mas a estratégia deve funcionar para vários parâmetros não inteiros gerais . (Pensei em dois parâmetros de string.)
A nota é descartada após cada cálculo. (Mais uma vez, eu estava pensando em dois parâmetros de string.)
Não sei se isso é mais eficiente que as outras respostas. Cada pesquisa é tecnicamente apenas uma ou duas etapas ("Olhe para o seu filho ou filho do seu filho"), mas pode haver muito uso de memória extra.
Edit: Esta solução ainda não está correta. O compartilhamento está incompleto.Edit: Ele deve compartilhar os sub-filhos corretamente agora, mas percebi que esse problema tem muitos compartilhamentos não triviais:
n/2/2/2
en/3/3
pode ser o mesmo. O problema não é um bom ajuste para minha estratégia.fonte