【问题标题】:Lazy Evaluation and Time Complexity惰性评估和时间复杂度
【发布时间】:2012-08-16 23:07:44
【问题描述】:

我正在查看 stackoverflow Non-Trivial Lazy Evaluation,这让我看到了 Keegan McAllister 的演示文稿:Why learn Haskell。在幻灯片 8 中,他展示了最小函数,定义为:

minimum = head . sort

并声明其复杂度为 O(n)。如果按替换排序为 O(nlog n),我不明白为什么说复杂性是线性的。帖子中提到的排序不能是线性的,因为它不对数据做任何假设,因为它是线性排序方法所需要的,例如计数排序。

惰性求值在这里起到了神秘的作用吗?如果有,背后的解释是什么?

【问题讨论】:

  • 请注意,该幻灯片正确地指出“小心sort 实现”。写一个 sort 很容易,但这个作文需要 O(n log n) 时间或更糟,但不是因为你想的那样。

标签: algorithm sorting haskell lazy-evaluation time-complexity


【解决方案1】:

您已经获得了大量解决head . sort 细节的答案。我将添加一些更一般的陈述。

通过急切评估,各种算法的计算复杂性以一种简单的方式构成。例如,f . g 的最小上限 (LUB) 必须是 fg 的 LUB 之和。因此,您可以将 fg 视为黑匣子,并仅根据它们的 LUB 进行推理。

但是,通过惰性求值,f . g 的 LUB 可以比 fg 的 LUB 之和更好。您不能使用黑盒推理来证明 LUB;您必须分析实现及其交互。

因此,经常被引用的事实是惰性求值的复杂性比急切求值更难推理。想想以下。假设您正在尝试改进形式为f . g 的一段代码的渐近性能。在急切的语言中,您可以遵循明显的策略来执行此操作:选择更复杂的 fg,并首先改进它。如果你成功了,你就成功了f . g 任务。

另一方面,在惰性语言中,您可能会遇到以下情况:

  • 您改进了更复杂的 fg,但 f . g 没有改进(甚至变得更糟)。
  • 您可以通过fg 无济于事(甚至更糟)的方式改进f . g

【讨论】:

    【解决方案2】:

    minimum = head . sort 中,sort 不会完全完成,因为它不会提前完成sort 只会根据head 的要求来生成第一个元素。

    在例如合并排序,首先将列表中的n 号码进行成对比较,然后将获胜者配对并比较(n/2 号码),然后是新的获胜者(n/4)等。总而言之,O(n)比较以产生最小元素。

    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
    

    上面的代码可以被扩充,以标记它产生的每个数字,并在它的产生中进行一些比较:

    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
    

    运行它几个列表长度,我们看到确实是~ n

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

    要查看排序代码本身是否为~ n log n,我们对其进行更改,使每个产生的数字只携带自己的成本,然后通过对整个排序列表求和来找到总成本:

        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
    

    这里是不同长度列表的结果,

    *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]
    

    上面显示了empirical orders of growth 列表长度的增加,n,正如 ~ n log n 计算所显示的那样,它正在迅速减少。另见this blog post。这是一个快速的相关性检查:

    *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]
    

    编辑: 惰性评估可以比喻为一种生产者/消费者习语1,以独立的记忆存储作为中介。我们编写的任何生产性定义都定义了一个生产者,该生产者将在其消费者要求时,一点一点地生产其输出 - 但不会更快。无论生产什么都被记忆,因此如果另一个消费者以不同的速度消费相同的输出,它会访问相同的存储空间,之前已填充。

    当没有更多的消费者引用一个存储时,它会被垃圾收集。有时,通过优化编译器能够完全取消中间存储,将中间人排除在外。

    1 另请参阅:Simple Generators v. Lazy Evaluation,作者 Oleg Kiselyov、Simon Peyton-Jones 和 Amr Sabry。

    【讨论】:

      【解决方案3】:

      受 Paul Johnson 回答的启发,我绘制了这两个函数的增长率。首先我修改了他的代码,每次比较打印一个字符:

      import System.Random
      import Debug.Trace
      import Data.List
      import System.Environment
      
      rs n = do
          gen <- newStdGen
          let ns = randoms gen :: [Int]
          return $ take n ns
      
      cmp1 x y = trace "*" $ compare x y
      cmp2 x y = trace "#" $ compare x y
      
      main = do
          n <- fmap (read . (!!0)) getArgs
          xs <- rs n
          print "Sorting entire list"
          print $ sortBy cmp1 xs
      
          print "Head of sorted list"
          print $ head $ sortBy cmp2 xs
      

      计算*# 字符,我们可以在均匀间隔的点对比较计数进行采样(请原谅我的python):

      import matplotlib.pyplot as plt
      import numpy as np
      import envoy
      
      res = []
      x = range(10,500000,10000)
      for i in x:
          r = envoy.run('./sortCount %i' % i)
          res.append((r.std_err.count('*'), r.std_err.count('#')))
      
      plt.plot(x, map(lambda x:x[0], res), label="sort")
      plt.plot(x, map(lambda x:x[1], res), label="minimum")
      plt.plot(x, x*np.log2(x), label="n*log(n)")
      plt.plot(x, x, label="n")
      plt.legend()
      plt.show()
      

      运行脚本将为我们提供以下图表:

      下边线的斜率是..

      >>> import numpy as np
      >>> np.polyfit(x, map(lambda x:x[1], res), deg=1)
      array([  1.41324057, -17.7512292 ])
      

      ..1.41324057(假设它是一个线性函数)

      【讨论】:

      • 您的“n*log(n)”行使用以“e”为基数的日志(2.714..),但排序功能通常随着日志基数 2 增加(因为比较强加的二进制拆分操作员)。根据 python 文档,您可以将基数作为第二个参数传递给“log”,在这种情况下,我怀疑您的红线和蓝线会同时出现。比较您的“最小”行(我假设实际上是“head.sortBy cmp2”与线性“(n-1)”行)也会很有趣。(这是“最小”的理论最小比较次数.
      【解决方案4】:

      在实践中看到这一点的一种有趣方式是跟踪比较函数。

      import Debug.Trace
      import Data.List
      
      myCmp x y = trace (" myCmp " ++ show x ++ " " ++ show y) $ compare x y
      
      xs = [5,8,1,3,0,54,2,5,2,98,7]
      
      main = do
          print "Sorting entire list"
          print $ sortBy myCmp xs
      
          print "Head of sorted list"
          print $ head $ sortBy myCmp xs
      

      首先,请注意整个列表的输出与跟踪消息交错的方式。其次,请注意仅计算头部时跟踪消息的相似之处。

      我刚刚通过 Ghci 运行了这个,它并不完全是 O(n):需要 15 次比较才能找到第一个元素,而不是应该需要的 10 次。但它仍然小于 O(n log n)。

      编辑: 正如 Vitus 在下面指出的那样,进行 15 次比较而不是 10 次与说它不是 O(n) 是不同的。我的意思是它需要超过理论上的最小值。

      【讨论】:

      • O(n) 并不意味着使用了 10 次比较。即使你用了 50 次比较,也不能说它不是 O(n),因为 O(5n) = O(n) .您必须查看比较次数如何随输入长度的变化而变化。
      • +1 这是一个很好的答案(除了稍微误导 O(n)),我不明白为什么它被否决了。
      • 这是比较计数的图表:imgur.com/vfEPp。蓝线是对整个列表进行排序时的比较,绿线 - 获取头部
      • @DanielVelkov 您应该在此处添加此图片作为答案。
      • 我同意这一点。很棒的图表。它还很好地展示了 O(n . log n) 与 O(n) 的接近程度。顺便说一句,下线的斜率是多少?它看起来像 1,但很难看到。
      【解决方案5】:

      解释取决于sort 的实现,对于某些实现,它不是真的。例如,对于在列表末尾插入的插入排序,惰性求值没有帮助。所以让我们选择一个实现来查看,为了简单起见,让我们使用选择排序:

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

      该函数显然使用 O(n^2) 时间对列表进行排序,但由于 head 只需要列表的第一个元素,因此永远不会评估 sort (delete x xs)

      【讨论】:

        【解决方案6】:

        假设minimum' :: (Ord a) =&gt; [a] -&gt; (a, [a]) 是一个函数,它返回列表中的最小元素以及删除该元素的列表。显然,这可以在 O(n) 时间内完成。如果您随后将sort 定义为

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

        那么惰性求值意味着在(head . sort) xs 中只计算第一个元素。如您所见,此元素只是 minimum' xs 对的(第一个元素),它的计算时间为 O(n)。

        当然,正如 delnan 所指出的,复杂性取决于sort 的实现。

        【讨论】:

        • 你说的很清楚,但请注意你的排序不是 O(n log n),但我明白了 ;)
        • @LeonardoPassos 这就是这个答案的美妙之处。 :) 这是一个选择排序,O(n^2),但它在 O(n) 时间内产生最小值。
        • 这个例子有点误导,因为我们想使用(head . sort) 作为 O(n) 中的最小函数,但是您的排序需要这样一个最小函数。从一个已经使用这样一个函数的排序中获得一个 O(n) 最小值会更有趣。
        • @JoachimBreitner:我同意。但我可能会称之为做作而不是误导:)
        • this other answer 在这里通过分离找到最小值和移除最小值来避免这个问题,当最小元素已知时,这在 O(n) 中变得微不足道。
        【解决方案7】:

        这并不神秘。您需要对列表的多少进行排序才能提供第一个元素?您需要找到最小元素,这可以在线性时间内轻松完成。碰巧的是,对于sort 的某些实现,惰性求值会为您执行此操作。

        【讨论】:

          猜你喜欢
          • 2011-05-25
          • 1970-01-01
          • 2014-02-08
          • 2017-01-18
          • 2018-11-11
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多