为啥这个的第二个版本在指数时间内运行?

Posted

技术标签:

【中文标题】为啥这个的第二个版本在指数时间内运行?【英文标题】:Why does the second version of this run in exponential time?为什么这个的第二个版本在指数时间内运行? 【发布时间】:2021-10-31 22:28:00 【问题描述】:

我正在编写一个程序来确定两个字符串之间的 Levenshtein 距离是否在线性时间内正好是 2。

我有一个算法可以做到这一点。我使用简单的递归方法来扫描字符串,当它发现差异时,它会分支为 3 个选项,尝试删除、插入或替换。但是我做了一个修改,如果我们的总数超过 2,我们就放弃那个分支。这将分支的总数限制为 9,并使算法线性化。

这是我的初始实现:

diffIs2 x y =
  2 == go 0 x y
  where
    go total xs@(x1:xs') ys@(y1:ys')
      | x1 == y1
      =
        go total xs' ys'
      | total < 2
      =
        minimum $ zipWith (go $ total + 1)
          [ xs', xs , xs' ]
          [ ys , ys', ys' ]
    go total xs ys =
      total + length xs + length ys

测试证实这在线性时间内运行。

我还有第二个类似的程序,据我所知,它也应该是线性的。

这里的想法是使用短路和惰性评估来限制评估的分支数量。我们总是允许分支,但是,当我们分支时,我们取每个和 3 的最小值。这样,如果分支总数超过 3,短路应该会阻止进一步的评估。

我们必须为部分求值实现一个自然数类型。

data Nat
  = Zero
  | Succ Nat
  deriving
    ( Eq
    , Ord
    )

natLength :: [a] -> Nat
natLength [] =
  Zero
natLength (a : b) =
  Succ (natLength b)

nat3 =
  Succ $ Succ $ Succ Zero

diffIs2 x y =
  Succ (Succ Zero) == go x y
  where
    go xs@(x1:xs') ys@(y1:ys')
      | x1 == y1
      =
        go xs' ys'
      | otherwise
      =
        Succ $
          minimum $
            map (min nat3) $
              zipWith go
                [ xs', xs , xs' ]
                [ ys , ys', ys' ]
    go xs ys =
      natLength $ xs ++ ys

对此进行测试表明它不会在线性时间内运行。有些东西使它成指数,但我不知道是什么。短路确实按预期工作。我们可以通过以下程序来展示这一点,该程序由于min 的短路而在有限时间内停止:

f = Succ f

main =
  print $ (Succ Zero) == min (Succ Zero) f

但是当把它放在算法中时,它似乎并没有像我预期的那样工作。

为什么第二个代码是指数的?我的误解是什么?

【问题讨论】:

f = Succ fSucc f 的无限递归,所以Succ (Succ (Succ (Succ (...)))))) @WillemVanOnsem 是的,这就是意图。由于惰性求值,程序会在有限时间内停止,而如果它是严格的,它将永远循环。 我不确定,但我猜minimum 不够懒惰以启用短路。 minimum ys 在所有情况下都会完全扫描ys。最重要的是minimum [Succ f, Succ f] 不是Succ f(即f),其中f 是您的无穷大值。您可能需要一个自定义的minimum,它会在可能的情况下(即没有找到Zero 时)在深入挖掘值之前尽早返回Succ Succ Zero `min` (Succ undefined) 不同,尽管我们知道它应该是Succ Zero。会不会是您的nat3 实际上将分支因子设置为 4?会不会是这样,给您带来了如此大的恒定因素放缓,或者您实际上绝对肯定地检测到了指数增长? @WillNess 我不能确定很多渐近复杂度,但是增长更符合指数复杂度而不是线性复杂度,我可以说运行了带有分支的算法 1 的版本因子 4 比下面的算法快得多。 【参考方案1】:

虽然默认的 min 对于问题末尾的简单示例来说已经足够懒惰了,但它并不像我们希望的那样懒惰:

ghci> let inf = Succ inf
ghci> inf `min` inf `min` Zero == Zero
^CInterrupted.

要修复它,我们需要一个懒惰的min

min' :: Nat -> Nat -> Nat
min' Zero _ = Zero
min' _ Zero = Zero
min' (Succ x) (Succ y) = Succ (min' x y)

最大的不同是现在我们可以在完全评估参数之前得到部分结果:

min (Succ undefined) (Succ undefined) === undefined
min' (Succ undefined) (Succ undefined) === Succ (min' undefined undefined)

现在我们可以如下使用它:

diffIs2 :: Eq a => [a] -> [a] -> Bool
diffIs2 x y = Succ (Succ Zero) == go x y
  where
    go xs@(x1:xs') ys@(y1:ys')
      | x1 == y1 = go xs' ys'
      | otherwise = Succ $ go xs' ys `min'` go xs ys' `min'` go xs' ys'
    go xs ys = natLength $ xs ++ ys

请注意,您甚至不需要额外的min' nat3,因为无论如何***比较只会强制前三个Succs。

【讨论】:

我看到min 没有像它可能的那样懒惰,我看到修复是如何工作的,但我仍然在努力理解为什么我的代码不起作用。在提供的代码中,由于min nat3 映射到整个列表中,因此在两个输入可能都长于 3 的任何情况下,代码都不会采用 min。如示例所示,min nat3 不会评估整个表达式. @ÉamnOlive 这是一个好问题。我相信这是因为每个分支都会自己分支,并且在该分支中,3 的限制被重置。并且Succs 不会立即传播,仅传播单个Succ,并且仅传播单个级别。因此,一旦到达输入的末尾,它只会达到 3 的限制。很高兴展示它在实践中的评估方式,但它很快就会变得非常混乱。

以上是关于为啥这个的第二个版本在指数时间内运行?的主要内容,如果未能解决你的问题,请参考以下文章

为啥使用第一个阅读器 read() 运行第二个阅读器比在自己的阅读器上运行它运行得更快?

改造不会序列化我的响应类中的第二个对象

在 UINavigationController 中运行 UIViewController 作为 UISplitViewController 的第二个视图

为啥我的第二个视图在转换后没有加载?

尝试运行我的应用程序的第二个目标时的 SIGABRT

为啥我的第二个下拉菜单没有被填充?