函数式 Leetcode 百题 – 排列

前言

算法题是很多程序员的梦魇之一。算法本身当然是有趣的但不会做题的抓狂、面试回答不出问题的窘迫没有人会喜欢。

应对算法题挑战的传统方式是 刷题 刷题也就是做大量的题目来学习算法。很多人笃信一种理论无论是数学学习还是算法学习都必须进行大量的练习。

我也认同这种理论。冯诺依曼的箴言还挂在本博客的首页上

Young man, in mathematics you dont understand things. You just get used to them.

直觉的认识直觉的经验是无比宝贵的财富。练习是获取这种宝贵财富最可靠的手段。简而言之想要学习算法你必须刷题必须练习。

然而我确实极度厌烦一种算法学习的常规路径。那就是看到某道题不会做思考了 10 分钟还是不知道从何入手那么就去寻找答案。也许你 理解 了答案或者记住了答案下次看到相同的题目的时候就套进去。只要你见过的答案够多那么你能 的题目也够多。

为什么我不喜欢这种学习方法呢也许是我不够聪明吧我很难 理解 别人给的答案。太多的人只是把最后的高效算法告诉你而忽略了直觉建立的过程更别提算法的证明了。从这些人的答案里我既没有感受到他们对于问题的深刻理解又没有感受到算法本身的美感。

难道我们只能通过给 天才 设计的教育来学习算法吗

不。世界上还有一群人在尝试着用纯函数式的语言用等式推导的方法来设计程序。这些人同样也珍视直觉的价值。他们会从一个清晰而不高效的程序出发一步步地给出最终的高效算法。在这个过程中算法的美、算法的 insight 纤毫毕现。已故的 Richard Bird台湾的穆信成老师都是这种方式的先驱。在他们的论文和书籍中我感受到了一种真正的温柔。

我想虽然等式推导很多时候非常困难但是把算法的 non-trivial 之处是什么、算法的直觉是什么讲清楚也许没有那么困难。

本系列文章就是我的尝试。我将在这些文章中讲解我是怎么用纯函数式编程解决 Leetcode 的。至于为什么选择 Leetcode 而不是难度更高的 Codeforces我认为难度太高也许不是好事。高难度的问题很可能需要多个算法共同来处理而我们更想在每篇文章中关注某类具体的算法。另一个原因是我本人对于算法仍然是某种意义的 初学者难度太高我也处理不来

选择 Leetcode 的一个后果是我们必须用 Racket 或者 Scala 而非 Haskell 来提交代码。这有时候会造成一些困扰不过我的经验是大部分我给出的 Haskell 代码可以直接翻译到 Scala 上。每道题目我也会给出对应的 Scala 解。如果确实有问题比如 Scala 没有 Data.Graph 这种不可变数据结构库那么我们会视情况退回到非纯函数式编程。不过这种情况总体应该是较少的。在每道题目的最后我可能也会把对应的命令式版本给出也许我们还会探讨一下函数式版本和命令式版本的区别。

这些文章不是 函数式编程入门。虽然我也许会提示某个库函数的用法但是总体来说这些文章假设你已经有了纯函数式编程的基本知识 递归列表foldmap 等等。如果你对函数式编程和 Haskell 语言一无所知那么在阅读这些文章之前最好简单学习一些 Haskell 知识。以下是我比较推荐的学习资源

路漫漫其修远兮吾将上下而求索

31. 下一个排列

题目描述

以下是 Leetcode 的原始描述

整数数组的一个 排列 就是将其所有成员以序列或线性顺序排列。

例如arr = [1,2,3] 以下这些都可以视作 arr 的排列[1,2,3][1,3,2]`[3,1,2][2,3,1] 整数数组的 下一个排列 是指其整数的下一个字典序更大的排列。更正式地如果数组的所有排列根据其字典顺序从小到大排列在一个容器中那么数组的 下一个排列 就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列那么这个数组必须重排为字典序最小的排列其元素按升序排列

例如arr = [1,2,3] 的下一个排列是 [1,3,2] 类似地arr = [2,3,1] 的下一个排列是 [3,1,2] arr = [3,2,1] 的下一个排列是 [1,2,3] 因为 [3,2,1] 不存在一个字典序更大的排列。 给你一个整数数组 nums 找出 nums 的下一个排列。

必须 原地 修改只允许使用额外常数空间。

我们是在纯函数式编程。纯函数式编程没有所谓的 修改所以最后一句话我们无视即可。

第一想法

一般来说很多算法问题能够给一个极为简洁但低效的纯函数式解。我们把这个解称之为程序的标准参照Spec),我们的思考也从这里出发。然而这个问题却似乎不是这种情况。考虑这个问题的数学表达

next(a)={min(P)a=max(P)min({mmP,m>a})otherwise \text{next}(a) = \begin{cases} \min(\mathbb{P}) & a = \max(\mathbb{P}) \\ \min(\{ m | m \in \mathbb{P}, m > a \}) & \text{otherwise} \end{cases}

这没有给我们多少灵感。事实上在很多编程语言里 P\mathbb{P}也就是某个列表的所有排列求出来比这个问题还要麻烦。1

这种情况下一个直觉的尝试是简单的递归 能不能解决问题呢

递归

下一个排列函数记为 next虽然定义在排列和字典序上但却显然可以被推广到更泛化的结构上。

我们首先考查十进制整数的情况。显然地十进制整数也可以定义 next 函数。这就是大名鼎鼎的 后继 函数 f(x)=x+1f(x) = x + 1 .

十进制整数可以被表示为一个列表或者命令式语言里的数组),列表中的每个元素表示数字的每一位。例如100100 被表示为 [1, 0, 0] .

我们特别关心列表表示下的后继函数因为在这种表示下>= 恰恰就是字典序比较。我们把列表表示下十进制整数的后继函数记作 next10.

1-- next10 [1, 1, 0] = [1, 1, 1]
2next10 ...

例如next10 [1, 1, 0] = [1, 1, 1]

这个例子给了我们非常强的直觉。那就是 next10 [1, 0] = [1, 1]此时 next10 [1, 1, 0] = 1 : next10 [1, 0]. 把这种关系一般化我们得到对于 x:xs 表示的整数

1next10 (x:xs) = x:(next10 xs)

上式在大部分时候都成立。除非next10 xs 已经 求不出来了比如 [1, 9, 9] 的下一个数字后继 [2, 0, 0]这时就必须要 进位。更严格地说99 最大 的两位十进制整数所以上式不成立。可以证明只要 xs 还不是最大值那么上面的等式就成立。

类似地同样可以证明题目要求的 next 函数具有同样的性质 xs 不是长度为 length xs 的排列的最大值时

1next (x:xs) = x:next xs

这个性质实在太过重要因为我们可以直接构造递归函数

1next (x:xs)
2    | isMax xs  = tick x xs
3    | otherwise = x:next xs

那么问题就变成了

  1. 如何定义 isMax
  2. 如果 xs 已经是最大值了那么 tick 函数该怎么定义呢

isMax 的定义

对于一个排列 xs 来说最大 就是说无论怎么重新排列新的 xs' 一定有 xs >= xs'.

什么样的排列具有这种性质呢一个很自然的想法是因为这是字典序所以如果想要排列尽可能大那就一定要把大的数放在前面。换句话说降序 的排列一定是最大的。

1isMax :: Ord a => [a] -> Bool
2isMax = down

Haskell 判断一个列表的升降我们可以使用 zip with tail 的方法。具体来说就是首先把列表和它的 tail 进行 zip然后再使用 map, all 之类的函数来判断。

1dup :: [a] -> [(a, a)]
2dup xs = zip xs (tail xs)
3
4down :: Ord a => [a] -> Bool
5down = all (uncurry (>=)) . dup

tick 的定义

如果 xs 已经满足了 down换言之它已经是最大的了那么 x:xs 必须要进行比较复杂的操作才能得到下一个枚举。这个复杂的操作被我称为 tick.

例如[2, 4, 3, 1] 的下一个枚举是 [3, 1, 2, 4]. 这时 x 2, xs [4, 3, 1]xs 满足 down所以这时不能再进行简单的递归而要用 tick 进行计算。

继续以 [2, 4, 3, 1] 为例。直觉上来讲要求 [2, 4, 3, 1] 的下一个枚举 ys@(y:ys'), 可以分为两步

  • 确定 y 是什么
  • 确定 ys' 是什么

首先y 要严格大于 x. 因为我们已经表明所有以 x 开头的枚举都要比 x:xs 小。其次y 要尽可能小不然会有更小的枚举 p 满足 p > x:xs.

上例中 y 3. 很容易想到3 xs = [4, 3, 1] 最小的 大于 x = 2 的数。我们把这种数叫做 pivot. 可以写出定义代码

1pivot x xs = minumum [ x' | x' <- xs, x' > x  ]

注意到 xs 是有序的以上代码可以改写为

1pivot x = last . takeWhile (> x)

需要指出的是pivot 函数是一个偏函数我们用了不安全的 last minimum 操作这意味着在调用时必须保证至少存在一个 x' > x.

如果能够找到这样的 y > x那么我们就可以保证y:ys' 绝对比 x:xs 大。所以ys' 的构造要使得 y:ys' 尽可能小。也就是说ys' 应该是最小的一个排列。和我们上面对于最大值的讨论一样最小的排列就是单调递增的排列。这只需要排序即可。

ys' 中的元素自然是 xs 除掉 y 之后再加上 x. 例如上面的例子 [2, 4, 3, 1]可以给出

1x:xs = [2, 4, 3, 1]
2
3y = last . takeWhile (> x) $ xs
4  = last [4, 3]
5  = 3
6
7ys'' = delete 3 xs
8     = [4, 1]
9
10ys' = sort (x:ys'')
11    = sort [2, 4, 1]
12    = [1, 2, 4]
13
14ys = y:ys'
15   = [3, 1, 2, 4]

最后还需要讨论一种情况那就是 pivot x xs = ⊥. 这种情况必须先行判断以免出现错误。什么时候 pivot 不存在呢那就是 x:xs 已经满足 down 的时候例如 [4, 3, 2, 1]这种时候只需要把输入反转即可。

根据以上讨论我们给出 tick 函数的定义

1import Data.List (delete)
2
3tick :: Ord a -> a -> [a] -> [a]
4tick x xs
5    | null l    = reverse (x:xs)
6    | otherwise = y:(sort (x:ys'))
7    where l = takeWhile (> x) xs
8          y = last l
9          ys' = delete y xs
1> tick 4 [3, 2, 1]
2[1, 2, 3, 4]
3
4> tick 2 [4, 3, 1]
5[3, 1, 2, 4]

毫无疑问目前的 next 函数已经能够正确地解决这个问题了。读者不妨把以上的 Haskell 代码翻译为 Scala Leetcode 里试一试。从递归出发我们发现这个问题的解非常直觉可以简单而轻松地给出。

更高效的代码

上面定义的 next 函数虽然优雅直观但是不够高效。主要的问题在于

  1. tick 不需要用到 sort. xs 是有序的可以利用这点在 O(n)O(n) 内把新的有序序列组合起来
  2. down next 调用了好多次每次调用都是 O(n)O(n) 导致总体复杂度出现了 O(n2)O(n^2).

问题 1. 很简单只需要重新写一下 tick 即可。

问题 2. 比较麻烦。我们需要仔细观查一下计算的过程例如 next [3, 5, 1, 4, 2]

1next [3, 5, 1, 4, 2]
2= 3:(next [5, 1, 4, 2])
3= 3:(5:(next [1, 4, 2]))
4= 3:(5:(tick 1 [4, 2]))

可以看到这个计算总是会具有一种 形状那就是输入的前一部分保持不变后一部分被 tick.

1[3, 5,           1, 4, 2]
2^^^^^^           #######
3保持不变的部分     tick 的部分

而它们的分界线直觉上来讲就是 down False 变为 True 的时刻。我们再回到 next 函数

1next (x:xs)
2    | down xs  = tick x xs
3    | otherwise = x:next xs

在每次递归的时候next (x:xs) 都会计算 down xs再用刚才的例子

1-- 第一次递归
2down [5, 1, 4, 2] = False
3-- 第二次递归
4down [1, 4, 2]    = False
5-- 第三次递归
6down [4, 2]       = True

事实上down 每次都顺序地计算 tails 里的下一个元素

1> tails [3, 5, 1, 4, 2]
2[[3,5,1,4,2],[5,1,4,2],[1,4,2],[4,2],[2],[]]

不妨把 next 每次用到的 down xs 提前2算出来并放到一个列表里

1import Data.List (tails)
2
3nextDown (x:xs) (d:ds)
4    | d = tick x xs
5    | otherwise = x:nextDown xs ds
6
7next xs = nextDown xs (downs xs)
8downs = drop 1 . map down . tails

如果我们可以在 O(n)O(n) 时间内算出 downs问题 2. 就迎刃而解了。直觉上来说这是很容易的因为 down [4, 3, 2, 1] 本来就是要保证

  • 4 >= 3
  • down [3, 2, 1]

换句话说当我们要算 down [4, 3, 2, 1] 的时候down [3, 2, 1] 已经被计算过了只是需要一种方式把这种 上一次的计算 存起来以便后续使用。

函数式编程社区早就有了对于这种问题的解决方法 scan theorem.

Scan theorem 指的是

1map (foldl op a) . inits = scanl op a

类似地

1map (foldr op a) . tails = scanr op a

利用这个定理我们给出以下程序计算过程. 注意为了避免问题我们定义了

1tails' = filter (not . null) . tails
1downs = { definition }
2        drop 1 . map down . tails'
3      = { definition }
4        drop 1 . map (all (uncurry (>=)) . dup) . tails'
5      = { map distributivity }
6        drop 1 . map (all (uncurry (>=))) . map dup . tails'
7      = { need another proof (1) }
8        drop 1 . map (all (uncurry (>=))) . tails . dup
9      = { definition }
10        drop 1 . map (foldr (&&) True . map (uncurry (>=))) . tails . dup
11      = { fold-map fusion }
12        drop 1 . map (foldr join True) . tails . dup
13      = { scan theorem }
14        drop 1 . scanr join True . dup
15          where join (x, x1) r = x >= x1 && r
  1. 需要一个新的证明它指的是下面的代码是等价的
1> map dup $ tails' [4, 2, 3, 1]
2[[(4,2),(2,3),(3,1)],[(2,3),(3,1)],[(3,1)],[]]
3
4> tails $ dup [4, 2, 3, 1]
5[[(4,2),(2,3),(3,1)],[(2,3),(3,1)],[(3,1)],[]]

证明略去。

总结

结合上面的讨论我们可以给出高效的函数式实现

1tick x xs = build l r x
2    where (l, r) = span (> x) xs
3          build [] r x = reverse (x:r)
4          build l  r x = last l : reverse (init l ++ [x] ++ r)
5
6dup xs = zip xs (tail xs)
7downs = scanr (\(x, x1) r -> (x >= x1) && r) True . dup
8
9next xs = l ++ tick (head r) (tail r)
10    where (l, r) = splitAt (n - 1) xs
11          n = length $ takeWhile not $ downs xs

这是 O(n)O(n) 时间复杂度的。不过我们的常数确实会比命令式程序大些。理论上来说上一节定义的 nextDown 函数的实现要更高效一些避免了 length splitAt),但是我仍然觉得本节给出的实现更清楚。

我把等价的 Scala 代码提交到了 leetcode:

1object Solution {
2    def nextPermutation(nums: Array[Int]): Unit = {
3        val nextNums = next(nums.toList).toArray
4        for (i <- nums.indices) {
5            nums(i) = nextNums(i)
6        }
7    }
8
9    def dup(xs: List[Int]): List[(Int, Int)] = xs zip xs.drop(1)
10
11    def tick(x: Int, xs: List[Int]): List[Int] = {
12        val (l, r) = xs.span(_ > x)
13        if (l.isEmpty) r.reverse ++ List(x)
14        else l.last :: (l.init ++ List(x) ++ r).reverse
15    }
16
17    def tailsDown(xs: List[(Int, Int)]): List[Boolean] = 
18        xs.scanRight(true) { case ((x, x1), r) => (x >= x1) && r }
19
20    def next(xs: List[Int]): List[Int] = {
21        val n = tailsDown(dup(xs)).takeWhile(!_).length
22        val (l, r) = xs.splitAt(n - 1)
23        l ++ tick(r.head, r.tail)
24    }
25}

下一个排列 问题是少有的命令式程序可以比函数式程序简洁的问题。甚至Haskell Data.Permute 也用了命令式的方法。

在命令式程序中上面的算法可以被描述为两个过程

  1. 从右向左遍历找到最长的下降序列 a[i(len - 1)]a[i \dots (\text{len - 1})]
  2. 进行计算
    • 如果 i=0i = 0那么反转数组

    • 其他情况找到 j[i,(len - 1)]j \in [i, \text{(len - 1)}]使得

      • a[j]>a[i1]a[j] > a[i - 1]
      • ki,a[k]>a[i1]a[k]a[j]\forall k \ge i, a[k] > a[i - 1] \to a[k] \ge a[j]

      交换 i1,ji - 1, j反转 a[i(len - 1)]a[i \dots (\text{len - 1})]

如果读者完全理解了上面的函数式算法这个命令式算法想必是非常直白的。但如果把这个算法直接端到你的面前喂给你你真的能明白它为什么是正确的吗

让我们用一个 Python 程序结束今天的故事吧

1def swap(a, i, j):
2    tmp = a[i]
3    a[i] = a[j]
4    a[j] = tmp
5
6def rev(a, start, end):
7    l = end - start
8    for i in range(start, start + l // 2):
9        swap(a, i, end - (i - start) - 1)
10
11def solve(a):
12    for i in range(len(a) - 1, -1, -1):
13        if i >= 1 and a[i - 1] < a[i]:
14            break
15    if i == 0:
16        rev(a, 0, len(a))
17    else:
18        j = len(a) - 1
19        while j >= i and a[j] <= a[i - 1]:
20            j -= 1
21        swap(a, i - 1, j)
22        rev(a, i, len(a))