关于haskell:Euler#4拥有更大的域名

Euler #4 with bigger domain

考虑修改后的欧拉问题4--"找到最大回文数,它是100到9999之间两个数的乘积。"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rev :: Int -> Int
rev x = rev' x 0

rev' :: Int -> Int -> Int
rev' n r
    | n == 0 = r
    | otherwise = rev' (n `div` 10) (r * 10 + n `mod` 10)

pali :: Int -> Bool
pali x = x == rev x

main :: IO ()
main = print . maximum $ [ x*y | x <- nums, y <- nums, pali (x*y)]
    where
        nums = [9999,9998..100]
  • 使用-O2ghc 7.4.1的haskell解决方案大约需要18个秒。
  • 类似的C解决方案需要0.1秒。

所以haskell是180倍更慢的。我的解决方案有什么问题?我假设这种类型的哈斯克尔的问题解决得很好。

附录-模拟C溶液:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define A   100
#define B   9999
int ispali(int n)
{
    int n0=n, k=0;
    while (n>0) {
        k = 10*k + n%10;
        n /= 10;
    }
    return n0 == k;
}
int main(void)
{
    int max = 0;
    for (int i=B; i>=A; i--)
        for (int j=B; j>=A; j--) {
            if (i*j > max && ispali(i*j))
                max = i*j;      }
    printf("%d
"
, max);
}


The similar C solution

这是一个常见的误解。

列表不是循环!

并且使用列表来模拟循环会影响性能,除非编译器能够从代码中消除列表。

如果你想比较苹果和苹果,写haskell结构或多或少相当于一个循环,一个尾部递归工作程序(使用严格的累加器,尽管编译器通常足够聪明,可以自己计算出严格性)。

现在让我们更详细地看看。比较而言,用gcc-o3编译的c在这里大约需要0.08秒,用ghc-o2编译的原始haskell大约需要20.3秒,用ghc-o2-fllvm大约19.9秒。相当可怕。

原始代码中的一个错误是使用divmod。C代码使用等效于quotrem的代码,它们映射到机器划分指令,比divmod更快。对于积极的论点,语义是相同的,所以只要知道这些论点总是非消极的,就不要使用divmod

改变这一点,使用本机代码生成器编译时,运行时间变为~15.4秒,使用llvm后端编译时,运行时间变为~2.9秒。

这种差异是由于即使是机器除法运算也相当慢,LLVM用乘法和移位运算代替除法/余数。手工对本机后端进行相同的操作(实际上,更好的替代方法是利用我知道参数总是非负的这一事实),从而将时间缩短到约2.2秒。

我们的关系越来越近了,但与C还是有很大的差距。

那是由于名单的缘故。代码仍然构建回文列表(并遍历两个因素的Int)列表)。

由于列表不能包含未绑定的元素,这意味着代码中存在大量装箱和拆箱操作,这需要时间。

因此,让我们消除这些列表,并看看将c转换为haskell的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module Main (main) where

a :: Int
a = 100

b :: Int
b = 9999

ispali :: Int -> Bool
ispali n = go n 0
  where
    go 0 acc = acc == n
    go m acc = go (m `quot` 10) (acc * 10 + (m `rem` 10))

maxpal :: Int
maxpal = go 0 b
  where
    go mx i
        | i < a = mx
        | otherwise = go (inner mx b) (i-1)
          where
            inner m j
                | j < a = m
                | p > m && ispali p = inner p (j-1)
                | otherwise = inner m (j-1)
                  where
                    p = i*j

main :: IO ()
main = print maxpal

嵌套循环被转换成两个嵌套的工作函数,我们使用一个累加器来存储迄今为止发现的最大回文。用ghc-o2编译,运行时间约为0.18秒,用ghc-o2-fllvm编译,运行时间约为0.14秒(是的,llvm比本机代码生成器更擅长优化循环)。

仍然不是很好,但是大约2的因素也不错。

也许有些人会发现以下内容,其中循环被抽象出来更具可读性,生成的核心对于所有意图和目的都是相同的(模转换参数顺序),当然性能也是相同的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
module Main (main) where

a :: Int
a = 100

b :: Int
b = 9999

ispali :: Int -> Bool
ispali n = go n 0
  where
    go 0 acc = acc == n
    go m acc = go (m `quot` 10) (acc * 10 + (m `rem` 10))

downto :: Int -> Int -> a -> (a -> Int -> a) -> a
downto high low acc fun = go high acc
  where
    go i acc
        | i < low   = acc
        | otherwise = go (i-1) (fun acc i)

maxpal :: Int
maxpal = downto b a 0 $ \m i ->
            downto b a m $ \mx j ->
                let p = i*j
                in if mx < p && ispali p then p else mx

main :: IO ()
main = print maxpal


@AxBlont至少部分正确;下面的修改使程序运行速度几乎是原来的三倍:

1
2
3
4
5
6
7
maxPalindrome = foldl f 0
  where f a x | x > a && pali x = x
              | otherwise       = a

main :: IO ()
main = print . maxPalindrome $ [x * y | x <- nums, y <- nums]
  where nums = [9999,9998..100]

不过,这仍然会导致因子60的减速。


这更符合C代码所做的工作:

1
2
3
4
5
6
7
8
9
maxpali :: [Int] -> Int
maxpali xs = go xs 0
  where
    go [] m     = m
    go (x:xs) m = if x > m && pali(x) then go xs x else go xs m

main :: IO()
main = print . maxpali $ [ x*y | x <- nums, y <- nums ]
  where nums = [9999,9998..100]

在我的盒子里,这需要2秒,而C版则需要5秒。


haskell可能正在存储整个列表[x*y x<-nums,y<-nums,pali(x*y)],其中c解决方案会实时计算最大值。我对此不确定。

此外,C解决方案仅在产品超过之前的最大值时计算ispali。我敢打赌,不管x*y是否可能是最大值,haskell计算的都是回文积。


另一种方法是使用两个折叠,而不是扁平列表上的一个折叠:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- foldl g0 0 [x*y | x<-[b-1,b-2..a], y<-[b-1,b-2..a], pali(x*y)]      (A)
-- foldl g1 0 [x*y | x<-[b-1,b-2..a], y<-[b-1,b-2..a]]                 (B)
-- foldl g2 0 [ [x*y | y<-[b-1,b-2..a]] | x<-[b-1,b-2..a]]             (C)

maxpal b a = foldl f1 0 [b-1,b-2..a]                              --   (D)
  where
    f1 m x = foldl f2 m [b-1,b-2..a]
      where
        f2 m y | p>m && pali p = p
               | otherwise     = m  
           where p = x*y

main = print $ maxpal 10000 100

似乎运行得比(B)快得多(如Larsmans的答案),同样(仅比以下基于循环的代码慢3-4倍)。融合foldlenumFromThenTo的定义使我们得到了"功能循环"代码(如Danielfischer的答案所示)。

1
2
3
4
5
6
7
8
9
maxpal_loops b a = f (b-1) 0                                      --   (E)
  where              
    f x m | x < a     = m
          | otherwise = g (b-1) m
      where
        g y m | y < a         = f (x-1) m
              | p>m && pali p = g (y-1) p
              | otherwise     = g (y-1) m
           where p = x*y

(C)变体非常暗示了进一步的算法改进(当然,这超出了原始q的范围),利用列表中隐藏的顺序,被扁平化破坏:

1
2
3
4
5
6
7
8
9
10
11
{- foldl g2 0 [ [x*y | y<-[b-1,b-2..a]] | x<-[b-1,b-2..a]]             (C)
   foldl g2 0 [ [x*y | y<-[x,  x-1..a]] | x<-[b-1,b-2..a]]             (C1)
   foldl g0 0 [ safehead 0 . filter pali $
                [x*y | y<-[x,  x-1..a]] | x<-[b-1,b-2..a]]             (C2)
   fst $ until ... (\(m,s)-> (max m .
                safehead 0 . filter pali . takeWhile (> m) $
                                                   head s, tail s))          
           (0,[ [x*y | y<-[x,  x-1..a]] | x<-[b-1,b-2..a]])            (C3)
   safehead 0 $ filter pali $ mergeAllDescending
              [ [x*y | y<-[x,  x-1..a]] | x<-[b-1,b-2..a]]             (C4)
-}

当子列表中的标题x*y小于当前找到的最大值时,(C3)可以立即停止。它是短切函数循环码所能实现的,而不是(C4),保证先找到最大回文数。另外,对于基于列表的代码,它的算法性质在视觉上更明显。


在我看来,你有一个分支预测问题。在C代码中,有两个嵌套循环,一旦在内部循环中看到回文,内部循环的其余部分将很快被跳过。

您提供产品列表而不是嵌套循环的方式,我不确定GHC是否在做任何预测。