69 votes

Évaluation paresseuse et complexité temporelle

Je regardais sur stackoverflow Évaluation paresseuse non triviale ce qui m'a conduit à la présentation de Keegan McAllister : Pourquoi apprendre Haskell . Dans la diapositive 8, il montre la fonction minimale, définie comme suit :

minimum = head . sort

et affirme que sa complexité est O(n). Je ne comprends pas pourquoi la complexité est dite linéaire si le tri par remplacement est O(nlog n). Le tri auquel il est fait référence dans le post ne peut pas être linéaire, car il ne suppose rien sur les données, comme cela serait requis par les méthodes de tri linéaire, telles que le tri par comptage.

L'évaluation paresseuse joue-t-elle un rôle mystérieux ici ? Si oui, quelle est l'explication derrière cela ?

59voto

Will Ness Points 18581

En minimum = head . sort El sort ne sera pas fait complètement, parce qu'il ne sera pas fait à l'avance . Le site sort ne sera fait qu'autant que nécessaire pour produire le tout premier élément, demandé par head .

Dans le cas de mergesort, par exemple, au départ n Les numéros de la liste seront comparés par paires, puis les gagnants seront mis par paires et comparés ( n/2 ), puis les nouveaux gagnants ( n/4 ), etc. En tout, O(n) comparaisons pour produire l'élément minimal.

mergesortBy less [] = []
mergesortBy less xs = head $ until (null.tail) pairs [[x] | x <- xs]
  where
    pairs (x:y:t) = merge x y : pairs t
    pairs xs      = xs
    merge (x:xs) (y:ys) | less y x  = y : merge (x:xs) ys
                        | otherwise = x : merge  xs (y:ys)
    merge  xs     []                = xs
    merge  []     ys                = ys

Le code ci-dessus peut être complété pour étiqueter chaque nombre qu'il produit avec le nombre de comparaisons qui ont été effectuées pour le produire :

mgsort xs = go $ map ((,) 0) xs  where
  go [] = []
  go xs = head $ until (null.tail) pairs [[x] | x <- xs]   where
    ....
    merge ((a,b):xs) ((c,d):ys) 
            | (d < b)   = (a+c+1,d) : merge ((a+1,b):xs) ys    -- cumulative
            | otherwise = (a+c+1,b) : merge  xs ((c+1,d):ys)   --   cost
    ....

g n = concat [[a,b] | (a,b) <- zip [1,3..n] [n,n-2..1]]   -- a little scrambler

En l'exécutant pour plusieurs longueurs de liste, nous constatons que c'est en effet ~ n :

*Main> map (fst . head . mgsort . g) [10, 20, 40, 80, 160, 1600]
[9,19,39,79,159,1599]

Pour voir si le code de tri lui-même est ~ n log n nous le modifions de façon à ce que chaque numéro produit n'entraîne que son propre coût, et le coût total est alors trouvé par sommation sur l'ensemble de la liste triée :

    merge ((a,b):xs) ((c,d):ys) 
            | (d < b)   = (c+1,d) : merge ((a+1,b):xs) ys      -- individual
            | otherwise = (a+1,b) : merge  xs ((c+1,d):ys)     --   cost

Voici les résultats pour des listes de différentes longueurs,

*Main> let xs = map (sum . map fst . mgsort . g) [20, 40, 80, 160, 320, 640]
[138,342,810,1866,4218,9402]

*Main> map (logBase 2) $ zipWith (/) (tail xs) xs
[1.309328,1.2439256,1.2039552,1.1766101,1.1564085]

Ce qui précède montre ordres de croissance empiriques pour les longueurs croissantes de la liste, n qui diminuent rapidement, comme c'est le cas dans les cas suivants ~ n log n les calculs. Voir aussi cet article de blog . Voici une vérification rapide de la corrélation :

*Main> let xs = [n*log n | n<- [20, 40, 80, 160, 320, 640]] in 
                                    map (logBase 2) $ zipWith (/) (tail xs) xs
[1.3002739,1.2484156,1.211859,1.1846942,1.1637106]

éditer : L'évaluation paresseuse peut être considérée métaphoriquement comme une sorte d'idiome producteur/consommateur. 1 avec un stockage indépendant de mémorisation comme intermédiaire. Toute définition de la production que nous écrivons, définit un producteur qui produira sa production, petit à petit, au fur et à mesure de la demande de son ou ses consommateurs - mais pas avant. Ce qui est produit est mémorisé, de sorte que si un autre consommateur consomme la même production à un rythme différent, il accède au même stockage, rempli précédemment.

Lorsqu'il ne reste plus de consommateurs faisant référence à un élément de stockage, celui-ci est mis au rebut. Parfois, grâce à des optimisations, le compilateur est capable de supprimer complètement le stockage intermédiaire, éliminant ainsi l'homme du milieu.

1 voir aussi : Générateurs simples et évaluation paresseuse par Oleg Kiselyov, Simon Peyton-Jones et Amr Sabry.

21voto

gspr Points 5595

Supposons que minimum' :: (Ord a) => [a] -> (a, [a]) est une fonction qui renvoie le plus petit élément d'une liste ainsi que la liste avec cet élément enlevé. Il est clair que cela peut être fait en temps O(n). Si vous définissez ensuite sort comme

sort :: (Ord a) => [a] -> [a]
sort xs = xmin:(sort xs')
    where
      (xmin, xs') = minimum' xs

alors l'évaluation paresseuse signifie que dans (head . sort) xs seul le premier élément est calculé. Cet élément est, comme vous le voyez, simplement (le premier élément de) la paire minimum' xs qui est calculée en temps O(n).

Bien sûr, comme le souligne delnan, la complexité dépend de l'implémentation de sort .

13voto

Luis Casillas Points 11718

Vous avez obtenu un bon nombre de réponses qui traitent des spécificités de la situation. head . sort . J'ajouterai juste quelques déclarations plus générales.

Avec une évaluation avide, les complexités de calcul de divers algorithmes se composent de manière simple. Par exemple, la borne supérieure la plus basse (LUB) pour f . g doit être la somme des LUB pour f y g . Ainsi, vous pouvez traiter f y g comme des boîtes noires et raisonnent exclusivement en termes de leurs LUB.

Avec une évaluation paresseuse, cependant, f . g peut avoir un LUB meilleur que la somme de f y g Les LUB. Vous ne pouvez pas utiliser le raisonnement en boîte noire pour prouver la LUB ; vous devez analyser les implémentations et leur interaction.

D'où le fait souvent cité que la complexité de l'évaluation paresseuse est beaucoup plus difficile à évaluer que celle de l'évaluation rapide. Pensez simplement à ce qui suit. Supposons que vous essayez d'améliorer la performance asymptotique d'un morceau de code dont la forme est f . g . Dans un langage avide, il y a une stratégie évidente que vous pouvez suivre pour faire cela : choisir le plus complexe des éléments suivants f y g et améliorer celui-là en premier. Si vous réussissez cela, vous réussirez à la f . g tâche.

Dans une langue paresseuse, par contre, vous pouvez avoir ces situations :

  • Vous améliorez le plus complexe des f y g mais f . g ne s'améliore pas (ou même devient pire ).
  • Vous pouvez améliorer f . g d'une manière qui n'aide pas (ou même aggraver ) f o g .

12voto

HaskellElephant Points 4985

L'explication dépend de l'implémentation de sort et pour certaines implémentations, ce ne sera pas vrai. Par exemple, avec un tri par insertion qui insère à la fin de la liste, l'évaluation paresseuse n'est pas utile. Choisissons donc une implémentation à examiner, et pour des raisons de simplicité, utilisons le tri par sélection :

sort [] = []
sort (x:xs) = m : sort (delete m (x:xs)) 
  where m = foldl (\x y -> if x < y then x else y) x xs

La fonction utilise clairement un temps O(n^2) pour trier la liste, mais puisque head n'a besoin que du premier élément de la liste, sort (delete x xs) n'est jamais évalué !

8voto

augustss Points 15750

Ce n'est pas si mystérieux. Quelle quantité de liste devez-vous trier pour livrer le premier élément ? Vous devez trouver l'élément minimal, ce qui peut facilement être fait en temps linéaire. Il se trouve que pour certaines implémentations de sort L'évaluation paresseuse le fera pour vous.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X