❖前言
算法题,是很多程序员的梦魇之一。算法本身当然是有趣的,但不会做题的抓狂、面试回答不出问题的窘迫,没有人会喜欢。
应对算法题挑战的传统方式是 刷题 。 刷题,也就是做大量的题目来学习算法。很多人笃信一种理论,无论是数学学习还是算法学习,都必须进行大量的练习。
我也认同这种理论。冯诺依曼的箴言还挂在本博客的首页上:
Young man, in mathematics you don’t 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
这种不可变数据结构库,那么我们会视情况退回到非纯函数式编程。不过,这种情况总体应该是较少的。在每道题目的最后,我可能也会把对应的命令式版本给出,也许我们还会探讨一下函数式版本和命令式版本的区别。
这些文章不是 函数式编程入门
。虽然我也许会提示某个库函数的用法,但是总体来说,这些文章假设你已经有了纯函数式编程的基本知识 – 递归,列表,fold,map 等等。如果你对函数式编程和 Haskell 语言一无所知,那么在阅读这些文章之前最好简单学习一些 Haskell 知识。以下是我比较推荐的学习资源:
- cis194 课程
- Thinking Functionally With Haskell,以及其中文翻译版本 Haskell函数式程序设计
- 练习是重要的,可以通过 Haskell 99 题 进行练习。这些练习和 Leetcode 不同,大部分通过直接的递归就可以完成
路漫漫其修远兮,吾将上下而求索
❖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),我们的思考也从这里出发。然而,这个问题却似乎不是这种情况。考虑这个问题的数学表达:
这没有给我们多少灵感。事实上,在很多编程语言里,把 ,也就是某个列表的所有排列求出来,比这个问题还要麻烦。1
这种情况下,一个直觉的尝试是,简单的递归 能不能解决问题呢?
❖递归
下一个排列函数(记为 next
)虽然定义在排列和字典序上,但却显然可以被推广到更泛化的结构上。
我们首先考查十进制整数的情况。显然地,十进制整数也可以定义 next
函数。这就是大名鼎鼎的 后继
函数,即 .
十进制整数可以被表示为一个列表(或者,命令式语言里的数组),列表中的每个元素表示数字的每一位。例如, 被表示为 [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
那么,问题就变成了
- 如何定义
isMax
- 如果
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
函数虽然优雅直观,但是不够高效。主要的问题在于,
tick
中,不需要用到sort
.xs
是有序的,可以利用这点在 内把新的有序序列组合起来down
被next
调用了好多次,每次调用都是 的,导致总体复杂度出现了 .
问题 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
如果我们可以在 时间内算出 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> 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
这是 时间复杂度的。不过,我们的常数确实会比命令式程序大些。理论上来说,上一节定义的 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 库,也用了命令式的方法。
在命令式程序中,上面的算法可以被描述为两个过程:
- 从右向左遍历,找到最长的下降序列
- 进行计算,
如果 ,那么反转数组
其他情况,找到 ,使得
交换 ,反转
如果读者完全理解了上面的函数式算法,这个命令式算法想必是非常直白的。但如果把这个算法直接端到你的面前喂给你,你真的能明白它为什么是正确的吗?
让我们用一个 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))