寻找匹配的数字
我们首先要面对的问题,是如何在某个 可能 编码了数字的位置把这个数字找出来。在此,我们要做一些简单的假设。第一个假设是我们处理的对象是图像中的单一行,第二个假设是我们明确知道条形码左边缘位置,这个位置即条形码的起始位置。
::: {#run-length-encoding} :::
游程编码
我们如何解决线条宽度的问题呢。答案就是对图像数据进行游程编码(run length encode)。
-- file: ch12/Barcode.hs
type Run = Int
type RunLength a = [(Run, a)]
runLength :: Eq a => [a] -> RunLength a
runLength = map rle . group
where rle xs = (length xs, head xs)
group 函数会把一个列表中所有连续的相同元素分别放入一个子列表中。
group [1,1,2,3,3,3,3]
[[1,1],[2],[3,3,3,3]]
我们的 runLength 函数将( group 返回的列表中的)每个子列表表示为子列表长度和首个元素组成的对。
ghci> let bits = [1,1,0,0,1,1,0,0,1,1,1,1,1,1,0,0,1,1,1,1]
ghci> runLength bits
Loading package array-0.1.0.0 ... linking ... done.
Loading package containers-0.1.0.1 ... linking ... done.
Loading package bytestring-0.9.0.1 ... linking ... done.
[(2,1),(2,0),(2,1),(2,0),(6,1),(2,0),(4,1)]
[译注:上述ghci输出的最后一行的列表中,每一个"长度-值"对就是一个"游程"]
由于我们进行游程编码的数据只包含0和1,因此编码的数字只会在0和1两个值之间变化。既然这样,我们就可以只保留长度而丢弃被编码数字,而不会丢失任何有用的信息。
-- file: ch12/Barcode.hs
runLengths :: Eq a => [a] -> [Run]
runLengths = map fst . runLength
ghci> runLengths bits
[2,2,2,2,6,4,4]
上面给出的位模式并不是我们随便编出来的;而是上面我们捕获的图像中的某一行里面的编码的左侧保护序列和第一个编码数字。如果我们丢弃表示保护序列的条纹,游程编码后的值就是[2, 6, 4, 4]。我们怎样在"引入数组"一节中的编码表中找到匹配的位模式呢?
[译注1:此处稍微做一下展开。首先,在 greyscale-to-binary-and-type-safety 一节中我们已经知道, Zero 才是代表黑色条纹的值。此处的0是与 Zero 对应的,它同样表示黑色条纹,相应的,1表示白色条纹,与 ean-13-encoding 一节中介绍EAN-13条码编码格式时约定的0/1所代表的颜色相反,再次请读者留心这点,因为接下来的内容都会遵守这个约定。]
[译注2:如果你实在看不出这个游程编码的值是如何表示之前捕获的图像中数字的,这里我们来详细解释一下。前文说过,由于条纹在照片中的实际宽度受到拍摄距离等因素的影响,因此我们不能对其有任何假设。而且一般来说,条形码中表示每1位的条纹所占的宽度几乎不可能只有1个像素,而是会由纵向上的复数个像素表示一位。比方说上面给出的序列,很明显就是用了两个像素来表示每个1个二进制位,其实际表示的二进制序列为"01010001100"(0为黑色,1为白色),当"以1为黑色,0为白色"时,该序列即"10101110011"。这样就可以看出,该序列就是由"101"(左侧保护序列)和"0111001"(倒数第2位识别错误的"7"的奇校验编码)以及下一位数字的第一个二进制位"0"组成的了。]
::: {#scaling-run-lengths-and-finding-approximate-matches} :::
缩放游程,查找相近的匹配
一个合理的方法是缩放这些游程编码值,让它们的和为1。我们将使用 Ratio Int 类型替代一般的 Double 类型来保存这些缩放后的值,因为 Ratio 值在 ghci 的输出中可读性更好。这点可以为交互式调试与开发提供方便。
-- file: ch12/Barcode.hs
type Score = Ratio Int
scaleToOne :: [Run] -> [Score]
scaleToOne xs = map divide xs
where divide d = fromIntegral d / divisor
divisor = fromIntegral (sum xs)
-- A more compact alternative that "knows" we're using Ratio Int:
-- scaleToOne xs = map (% sum xs) xs
type ScoreTable = [[Score]]
-- "SRL" means "scaled run length".
asSRL :: [String] -> ScoreTable
asSRL = map (scaleToOne . runLengths)
leftOddSRL = asSRL leftOddList
leftEvenSRL = asSRL leftEvenList
rightSRL = asSRL rightList
paritySRL = asSRL parityList
我们定义了类型别名 Score,这样其余的大部分代码就不需要关心 Score 底层的类型是什么。当我们的代码开发完毕,一头埋进 ghci 做后续调试的时候,只要我们愿意,我们还是能把" Score "对应的底层类型改为 Double ,而不需要修改其它代码。
我们可以用 scalarToOne 函数来缩放我们所要寻找的数字序列。我们解决了拍摄距离所导致的条纹宽度不能确定的问题。现在,在缩放后的游程编码表和从图像中的提取出游程编码序列间应该有十分接近的匹配。
接下来的问题是如何将直观感觉上的"十分接近"转化为对"足够接近"的度量。给出两个缩放过的长度序列,我们可以像下面这样计算出一个大概的"差异度"(distance)。
精确匹配的两个值之间的差异度是0,匹配程度越低,差异度的值就越大。
ghci> let group = scaleToOne [2,6,4,4]
ghci> distance group (head leftEvenSRL)
13%28
ghci> distance group (head leftOddSRL)
17%28
对给定的一个经过缩放的游程编码表,我们从中选择与输入序列最接近的几个匹配结果。
-- file: ch12/Barcode.hs
bestScores :: ScoreTable -> [Run] -> [(Score, Digit)]
bestScores srl ps = take 3 . sort $ scores
where scores = zip [distance d (scaleToOne ps) | d <- srl] digits
digits = [0..9]
::: {#list-comprehensions} :::
列表推导式
我们在上面的例子中引入的新表示法叫做 列表推导式(list comprehension) ,列表推导式可以以一个或多个列表为基础创建新列表。
ghci> [ (a,b) | a <- [1,2], b <- "abc" ]
[(1,'a'),(1,'b'),(1,'c'),(2,'a'),(2,'b'),(2,'c')]
竖线右侧的每一个 生成器表达式(generator expression) 组合,都会代入到竖线左侧的表达式中求值。生成表达式绑定了左侧的变量a,a又用"<-"绑定到右侧的元素列表。正如上面的例子展示的,生成表达式的组合将按照深度优先的顺序遍历:先是第一个列表的第一个元素分别与第二个列表中的每个元素分别组合,以此类推。
我们还可以在列表推导式的右侧为生成器指定guard。guard是一个 Bool 表达式。如果guard的值为 False , 则该元素被跳过。
ghci> [ (a,b) | a <- [1..6], b <- [5..7], even (a + b ^ 2) ]
[(1,5),(1,7),(2,6),(3,5),(3,7),(4,6),(5,5),(5,7),(6,6)]
其中还可以用 let 表达式绑定本地变量(local variable)。
ghci> let vowel = (`elem` "aeiou")
ghci> [ x | a <- "etaoin", b <- "shrdlu", let x = [a,b], all vowel x ]
["eu","au","ou","iu"]
如果生成器表达式中的某个模式匹配失败了,那么也不会有错误发生,只会跳过未匹配的列表元素。
ghci> [ a | (3,a) <- [(1,'y'),(3,'e'),(5,'p')] ]
"e"
列表推导式功能强大用法简洁,但可能不太容易看懂。如果能小心使用,它也可以让我们的代码更容易理解。
-- file: ch12/Barcode.hs
-- our original
zip [distance d (scaleToOne ps) | d <- srl] digits
-- the same expression, expressed without a list comprehension
zip (map (flip distance (scaleToOne ps)) srl) digits
-- the same expression, written entirely as a list comprehension
[(distance d (scaleToOne ps), n) | d <- srl, n <- digits]
::: {#remembering-a-matchs-parity} :::
记录匹配数字的奇偶性
对左侧分组数字的每一个匹配,我们必须记录它是在奇校验编码表还是偶校验编码表中匹配到的。
-- file: ch12/Barcode.hs
data Parity a = Even a | Odd a | None a
deriving (Show)
fromParity :: Parity a -> a
fromParity (Even a) = a
fromParity (Odd a) = a
fromParity (None a) = a
parityMap :: (a -> b) -> Parity a -> Parity b
parityMap f (Even a) = Even (f a)
parityMap f (Odd a) = Odd (f a)
parityMap f (None a) = None (f a)
instance Functor Parity where
fmap = parityMap
我们将匹配到的数字包装在该数字的实际编码所采用的奇偶性内,并且使它成为一个 Functor 实体,这样我们就可以方便的操作奇偶编码值(parity-encoded values)了。
[译注:此处所说的"奇偶编码值"可以理解为"对同一个数字同时具有奇校验编码和偶校验编码两种形式的编码值"(即左分组中所有的编码值都是"奇偶编码值"),为了简化描述,后文也会采用这种简称,请读者留意。]
我们可能需要对奇偶编码值按它们包含的数字进行排序。 Data.Function 模块提供的一个好用的组合子 on 可以帮助我们实现这个功能。
-- file: ch12/Barcode.hs
on :: (a -> a -> b) -> (c -> a) -> c -> c -> b
on f g x y = g x `f` g y
compareWithoutParity = compare `on` fromParity
它的作用可能不是很明确,你可以试着去想象这样一个函数:它接受两个参数f和g,返回值是一个函数,这个返回的函数也有两个参数,分别为 x 和 y 。 on 将 g 分别对 x 和 y 应用,然后将 f 应用于这两个结果(所以它的名字叫 on )。
把匹配数字装入奇偶性的方法一目了然。
-- file: ch12/Barcode.hs
type Digit = Word8
bestLeft :: [Run] -> [Parity (Score, Digit)]
bestLeft ps = sortBy compareWithoutParity
((map Odd (bestScores leftOddSRL ps)) ++
(map Even (bestScores leftEvenSRL ps)))
bestRight :: [Run] -> [Parity (Score, Digit)]
bestRight = map None . bestScores rightSRL
一旦在奇校验表或偶校验表里找到了左侧分组某个编码的几个最佳匹配,我们就可以将他们按照匹配的质量排序。
::: {#another-kind-of-laziness-of-keyboarding-variety} :::
键盘惰性
定义 Parity 类型时,我们可以使用haskell的记录( record )语法来避免手写formParity函数。也就是说,可以这么写:
-- file: ch12/Barcode.hs
data AltParity a = AltEven {fromAltParity :: a}
| AltOdd {fromAltParity :: a}
| AltNone {fromAltParity :: a}
deriving (Show)
那我们为什么没这么做呢?答案说起来有些丢人,而且与 ghci 的交互调试有关。当我们告诉GHC让它自动把一个类型派生为 Show 的实体时,GHC会根据我们是否使用记录语法来定义这个类型而生成不同的代码。
ghci> show $ Even 1
"Even 1"
ghci> show $ AltEven 1
"AltEven {fromAltParity = 1}"
ghci> length . show $ Even 1
6
ghci> length . show $ AltEven 1
27
使用记录语法定义生成的Show实体明显很"啰嗦",同时这也会给调试带来很大的干扰。比方说在我们检查 ghci 输出的奇偶编码值列表的时候,这样的输出结果会特别长以至于我们不得不一行行地扫读输出结果。
当然我们可以手动实现干扰更少的Show实体。避开记录语法写起来也更简洁,而且通过编写我们自己的 formParity 函数可以让GHC帮我们派生输出更简洁的 Show 实例。其实也并不是非这么做不可,但是程序员的惰性有时也的确会为代码引入一些特别的做法。
列表分块
使用列表时常常需要对它进行分块(chunk)。例如,条形码中的每个数字都由四个连续的数字编码而成。我们可以将表示一个行的列表转换为如下这种包含四个元素的列表组成的列表。
-- file: ch12/Barcode.hs
chunkWith :: ([a] -> ([a], [a])) -> [a] -> [[a]]
chunkWith _ [] = []
chunkWith f xs = let (h, t) = f xs
in h : chunkWith f t
chunksOf :: Int -> [a] -> [[a]]
chunksOf n = chunkWith (splitAt n)
像这种需要手写泛型的列表操作函数的情况比较罕见。因为一般在 Data.List 模块里翻一翻就能找到完全符合要求或者基本满足需要的函数。
生成候选数字列表
这几个辅助函数一旦就绪,为每个数字分组生成候选匹配的函数也就很容易搞定了。首先,我们先得做一些前期的检查,来确定这些匹配是否都是有意义的。只有以黑色( Zero )条纹开始,并且条纹数量足够多的游程列表才是有意义的。下面是这个函数中的前几个等式。
-- file: ch12/Barcode.hs
candidateDigits :: RunLength Bit -> [[Parity Digit]]
candidateDigits ((_, One):_) = []
candidateDigits rle | length rle < 59 = []
[译注:代码中的59表示条形码中的条纹数,它是这样求出的:3(左侧保护序列101)+4x6(每个数字的条纹数目4x左侧分组的数字数)+5(两个分组中间的保护序列10101)+4x6(同左分组)+3(右侧保护序列) = 59。]
只要任意一次 bestLeft 或 bestRight 的应用得到一个空列表,我们都不能返回有效结果。否则,我们将丢弃 Score 值,返回一个由标记了编码奇偶性的候选数字列表组成的列表。外部的列表有12个元素,每个元素都代表条形码中的一个数字。子列表中的每个数字都根据匹配质量排序。
下面给出这个函数的其余部分
-- file: ch12/Barcode.hs
candidateDigits rle
| any null match = []
| otherwise = map (map (fmap snd)) match
where match = map bestLeft left ++ map bestRight right
left = chunksOf 4 . take 24 . drop 3 $ runLengths
right = chunksOf 4 . take 24 . drop 32 $ runLengths
runLengths = map fst rle
我们看一看从上面图像中提取出的每个线条分组(表示一个数字的四个线条算作一组)对应的候选数字。
ghci\> :type input input :: \[(Run, Bit)\] ghci\> take 7 input
\[(2,Zero),(2,One),(2,Zero),(2,One),(6,Zero),(4,One),(4,Zero)\] ghci\>
mapM\_ print \$ candidateDigits input \[Even 1,Even 5,Odd 7,Odd 1,Even
2,Odd 5\] \[Even 8,Even 7,Odd 1,Odd 2,Odd 0,Even 6\] \[Even 0,Even 1,Odd
8,Odd 2,Odd 4,Even 9\] \[Odd 1,Odd 0,Even 8,Odd 2,Even 2,Even 4\] \[Even
3,Odd 4,Odd 5,Even 7,Even 0,Odd 2\] \[Odd 2,Odd 4,Even 7,Even 0,Odd
1,Even 1\] \[None 1,None 5,None 0\] \[None 1,None 5,None 2\] \[None
4,None 5,None 2\] \[None 6,None 8,None 2\] \[None 7,None 8,None 3\]
\[None 7,None 3,None 8\]
没有数组和散列表的日子
在命令式语言中,数组的地位就像是Haskell中的列表或元组,不可或缺。命令式语言中的数组通常是可变的,即我们随时可以修改数组中的元素值,我们对这点也习以为常。
正如我们在"修改数组元素"一节中提到的一样,Haskell数组并不是可变的。这意味着如果要"修改"数组中的单个元素,整个数组都要被复制一次,被修改的元素将在复制的过程中被设置为新的值。显然,以这种方法"修改"数组不可能在性能比拼中获胜。
可变数组还被用来构建另一种命令式语言常见数据结构------散列表(hash table)。在散列表的典型实现中,数组扮演了"脊柱"的角色:数组中的每个元素都是一个列表。在散列表中添加一个元素时,我们通过对元素进行散列(hash),确定这个元素在数组中的偏移,然后修改位于这个偏移的列表,把这个元素添加进去。
如果构造散列表所使用的数组不是可变的,那么如果要更新一个散列表的话,我们就不得不创建一个新的数组------先复制原数组,然后把一个新的列表放到由散列值确定的偏移位置上。我们不需要复制其他偏移位置上的列表,但是由于必须复制这个"脊柱",性能方面已经遭到了致命打击。
不可变的数组一下就让我们的工具箱中两种命令式语言中的典型数据结构直接下岗。可见数组在纯Haskell代码中的确不像在许多别的语言中那么有用。不过,有很多涉及数组的代码都只是在构建阶段更新数组,构建完成后都将其当作只读的数组来使用。
[译注:此处的"构建阶段(build phase)"并不仅限于用 listArray 函数或者直接调用构造器函数,还包括"原始的"数组生成完毕,进行后续的值设置的过程,这些过程中可能包含对数组的修改(以及底层的复制)操作。]
答案的森林
但事实上,用不了可变的数组和散列表并没有想象中那么悲剧。数组和散列表经常被用作由键索引的值的集合,而在Haskell中,我们使用 树 来实现这个功能。
在Haskell中实现一个简单的树类型非常简单。不仅如此,更实用的树类型实现起来也是出奇的简单。比方说红黑树。红黑树这种自平衡结构,就是因为其平衡算法出了名的难写,才让几代CS在校生闻风丧胆。
综合运用Haskell的代数数据类型组合、模式匹配、guard等特性可以把最可怕的平衡操作的代码缩减至只有短短几行。但是我们先不急着构造树类型,先来关注为什么它们在纯函数式语言中特别有用。
对函数式程序员来说,树的吸引力在于修改代价低。我们不用打破不可变原则:树就和其他东西一样不可变。然而,我们修改一棵树的时候,可以在新旧两棵树之间共享大部分的结构。举例来说,有一颗有10000个节点的树,我们可能想要在里面添加或者移除一个节点,这种情况下,新旧两棵树能够共享大约9985个节点。换句话说,每次更新树的时候所需要修改的元素数目取决于树的高度,或者说是节点数的对数。
Haskell标准库提供了两种采用平衡树实现的集合类型:Data.Map用于键/值对,Data.Set 用于集合。鉴于在下一节会用到 Data.Map,我们就先简要地介绍一下这个模块。 Data.Set 与 Data.Map 很相似,相信你应该也能很快掌握。
Note
关于性能
一个具有良好实现的纯函数式树结构与散列表在性能上应该是可以一较高下的。你不应该在你的代码会付出性能代价的假设下实现树类型。
::: {#a-brief-introduction-to-maps} :::
map简介
Data.Map 模块提供了参数化类型 Map k a ,将键类型k映射到关联值类型a。尽管其内部为一个size-balanced tree,但是它的实现对我们是不可见的。
[译注1:Size-Balanced Tree(SBT)是一种通过大小(Size)域来保持平衡的二叉搜索树,因此得名。]
[译注2:原文对于value的使用有些混乱。为了明确表达,从此处开始,key都译为"键",而value在表达"map中由key所映射到的值"时都译为"映射值"]
Map 的键是严格求值的,但是映射值却是非严格求值。换句话说,map的 脊柱 ,或者说结构,是一直在更新的,但是map中映射的值还是要等到我们强迫对它们求值的时候才被计算出来。
记住这点很重要,因为对于不期望内存泄漏的程序员来说, Map 类型对映射值采用的惰性求值策略往往是内存泄漏的源头。
由于 Data.Map 模块包含几个与 Prelude 模块中冲突的名字,所以它通常用限定形式导入。本章靠前的部分中,我们再导入它时添加了一个前缀 M 。
类型约束
Map类型并不对键值的类型做任何显式的约束,但是该模块中多数实用函数都要求键类型为 Ord 类型类的实体。需要强调的是,这里体现了Haskell中一个常见设计模式:类型约束的设置应该推迟到最终应用的地方,而不需要库作者为这种事情做额外劳动。
Map 类型和该模块中的函数都没有对映射值的类型设置约束。
::: {#partial-application-awkwardness} :::
部分应用时的尴尬
由于某些原因,Data.Map 模块中的某些函数的类型签名并不便于部分应用。函数操作的map总是作为最后一个参数,但是它们是第一个参数才更便于局部应用。结果造成使用部分应用Map函数的代码几乎总得通过适配函数(adapter function)来调整参数顺序。
::: {#getting-started-with-the-api} :::
map API入门
Data.Map 模块有一个巨大的"暴露区"(surface area):它导出了很多函数。而其中只有为数不多的几个函数算得上是该模块中最常用的核心部分。
如果需要创建一个空的 map ,可以使用 empty 函数。如果要创建包含一个键/值对的 map ,则应该使用 singleton 函数。
ghci> M.empty
Loading package array-0.1.0.0 ... linking ... done.
Loading package containers-0.1.0.1 ... linking ... done.
fromList []
ghci> M.singleton "foo" True
fromList [("foo",True)]
由于 Map 的实现对我们是透明的,我们就无法对 Map 类型的值进行模式匹配。不过,该模块提供了一些查找函数可供我们使用,其中有两个函数应用特别广泛。查找函数有一个稍微复杂的类型签名,但是不要着急,这些很快在第14章中都会弄明白的。
ghci> :type M.lookup
M.lookup :: (Ord k, Monad m) => k -> M.Map k a -> m a
返回值中的类型参数m通常是Maybe类型。话句话说,如果map中包含具有给定键的映射值,lookup函数会把映射值装入 Just 返回。否则返回 Nothing 。
ghci> let m = M.singleton "foo" 1 :: M.Map String Int
ghci> case M.lookup "bar" m of { Just v -> "yay"; Nothing -> "boo" }
"boo"
findWithDefault 函数额外指定一个参数值,如果map中不包含查找的键,则返回该指定值。
Note
小心部分应用函数!
有一个(!)运算符会查找键并且返回与该键关联的原始值(即,不是返回装在 Maybe 或者其他什么东西里的值)。不幸的是,这并不是一个全函数:如果该键在map中不存在的话,它会调用 error 。
要在map中添加一个键值对,最有用的函数是 insert 和 insertWith’ 。insert函数就是简单的在map中插入键/值对,如果该键已经存在,则覆盖其关联的任何值。
ghci> :type M.insert
M.insert :: (Ord k) => k -> a -> M.Map k a -> M.Map k a
ghci> M.insert "quux" 10 m
fromList [("foo",1),("quux",10)]
ghci> M.insert "foo" 9999 m
fromList [("foo",9999)]
insertWith' 函数会额外接受一个 组合函数(combining function) 。如果map中没有指定的键,就把该键/值对原封不动插入。否则,就先对新旧两个映射值应用组合函数,把应用的结果作为新的映射值更新到map中。
ghci> :type M.insertWith'
M.insertWith' :: (Ord k) => (a -> a -> a) -> k -> a -> M.Map k a -> M.Map k a
ghci> M.insertWith' (+) "zippity" 10 m
fromList [("foo",1),("zippity",10)]
ghci> M.insertWith' (+) "foo" 9999 m
fromList [("foo",10000)]
函数名最后的钩号暗示我们 insertWith' 将对组合函数严格求值。这个设计帮你避免了内存泄漏。该函数同时存在一个惰性的变种(即没有最后钩号的 insertWith ),但你大概永远用不到它。
delete 函数从map中删除指定键。如果键不存在的话, delete 会将map原封不动返回。
ghci> :type M.delete
M.delete :: (Ord k) => k -> M.Map k a -> M.Map k a
ghci> M.delete "foo" m
fromList []
最后,还有几个常用的函数用于在maps上进行类似集合的操作。例如,我们接下来会用到的 union。这个函数是"左偏"(left biased)的:如果两个map包含相同的键,返回map中将包含左侧map中对应的关联值。
ghci> m `M.union` M.singleton "quux" 1
fromList [("foo",1),("quux",1)]
ghci> m `M.union` M.singleton "foo" 0
fromList [("foo",1)]
到此我们仅仅讲到了Data.Map中百分之十的API。在第13章中我们会更加广泛深入的讲解其中的API。我们鼓励你自行浏览模块的文档,相信你会从中获得更多启发。这个模块滴水不漏的设计一定会让你印象深刻。
延伸阅读
[Okasaki99]一书将教我们如何优雅且严密地实现纯函数式数据结构,其中包括多种平衡树。该书还中还包含了作者对于纯函数式数据结构和惰性求值的宝贵思考。
我们把Okasaki这本书列为为函数式程序员的必读书目。如果你不方便翻阅Okasaki这本书,可以去看Okasaki的博士论文,[Okasaki96]是该书的一个不很完整的精简版本,在网上可以免费获得。
::: {#turning-digit-soup-into-an-answer} :::
从成堆的数字中找出答案
我们现在又有了新的问题要解决。后十二个数字还只是一堆候选数字;此外,我们需要根据这12个数字中的前6个数字的奇偶性信息来计算第一个数字。最后,我们还需要确认求出的校验码的有效性。
这看起来很有挑战!这一大堆不确定的数据;该拿它们怎么办?采用暴力搜索是一个很合理的提议。那么,如果候选数字就是上面的 ghci 会话中给出的那些,我们需要测试多少种组合?
ghci> product . map length . candidateDigits $ input
34012224
可见暴力搜索要检查的组合太多了。我们还是先着眼于一个知道如何解决的子问题,晚些时候在考虑剩下的的。
::: {#solving-for-check-digits-in-parallel} :::
批量求解校验码
我们暂时不考虑搜索的方案,先来关注如何计算校验码。条形码的校验码可以是十个数字中的任意一个。对于一个给定的校验码,怎样反推出它是从怎样的输入序列中计算出来的呢?
-- file: ch12/Barcode.hs
type Map a = M.Map Digit [a]
在这个map中,键值是一个校验码,映射值是一个可以计算出这个校验码的序列。以它为基础,我们进一步定义两种map类型。
我们将把这两种类型的map统称为"答案map"(solution map),因为它们包含了"求解"每个校验码对应的各个数字序列。
给定一个数字,我们可以按如下方法更新一个给定的答案map
-- file: ch12/Barcode.hs
updateMap :: Parity Digit -- ^ new digit
-> Digit -- ^ existing key
-> [Parity Digit] -- ^ existing digit sequence
-> ParityMap -- ^ map to update
-> ParityMap
updateMap digit key seq = insertMap key (fromParity digit) (digit:seq)
insertMap :: Digit -> Digit -> [a] -> Map a -> Map a
insertMap key digit val m = val `seq` M.insert key' val m
where key' = (key + digit) `mod` 10
从map中取出一个既存的校验码,一个可以求出该校验码的序列,一个新的输入数字,这个函数将可以求出新的校验码的新序列更新至map。
这部分内容可能有点不太好消化,看一个例子应该会更明白。我们假设现在要查找数字是 4 ,它是序列 [1, 3] 对应的校验码,我们想要添加到map的数字是 8 。4+8 ,模10得 2 ,那么 2 就是要插入到map中的键。能计算出新校验码 2 的序列就是 [8, 1, 3] ,这个序列就是要插入的映射值。
[译注: 在实际调用 updateMap 函数的时候, digit 是一个候选数字,key是指"在插入候选数字 new 之前,由这个不完整的'猜测'序列算出的临时校验码", seq 就是 key 所对应的"不完整的'猜测'序列(指条形码的编码数字序列)"。 updateMap 的实际功能就是将 digit 插入到列表 seq 的最前面,然后由插入的seq再求出一个校验码的临时值。并以这个临时值和插入后的序列分别为键值和映射值,插入到指定的map中。这之中需要注意的地方是在 insertMap 函数中,key' 表示新求出的临时校验码,这个校验码的算法与前文checkDigit的算法并不相同:没有对输入序列进行 mapEveryOther (*3) (reverse ds) 和类似 (10 -) 这样的计算。实际上,这两个操作只是被推迟了,并且由于校验码只有一位数,因此校验码的值与"(10 - 校验码)"的值也是一一对应的(即"单射"),所以map中保存这个没有经过 (10 - ) 操作的键值也是没有问题的,只要在需要提取真正的校验码时用10减去这个键值就可以了,除了这些之外,其计算方法与 checkDigit 函数中的方法是等价的。]
对候选数字序列中的每一个数字,我们都会通过当前数字和之前的map生成一个新的答案map。
-- file: ch12/Barcode.hs
useDigit :: ParityMap -> ParityMap -> Parity Digit -> ParityMap
useDigit old new digit =
new `M.union` M.foldWithKey (updateMap digit) M.empty old
我们再通过一个例子演示这段代码的实际功能。这次,我们用ghci交互演示。
ghci> let single n = M.singleton n [Even n] :: ParityMap
ghci> useDigit (single 1) M.empty (Even 1)
fromList [(2,[Even 1,Even 1])]
ghci> useDigit (single 1) (single 2) (Even 2)
fromList [(2,[Even 2]),(3,[Even 2,Even 1])]
[译注:这个函数的参数中, old 代表上一候选数字应用此函数时产生的map,而 old 代表"条形码中的上一个数字位置"通过不断折叠应用此函数所产生的map,digit 表示当前考察的候选数字。这个函数的实际作用是在某个候选数字列表中遍历的过程中,当前考察的这个候选数字插入到给定map的每个映射值的最前方,并求得新的临时校验码,然后将这个临时校验码和插入后的序列作为键值对插入到map中,并与前一候选数字应用此函数的结果map做"并集"操作( M.union ),由于候选数字序列是按照匹配程度降序排列的,因此如果当前序列中的键值与前一候选数字产生的某个键值发生冲突,那么它就会被 M.Union 的"左偏"性质覆盖掉,而保留前一候选数字所产生的新序列。]
传给 useDigits 函数的新答案map(即参数 new 对应的map)最开始是空的。其值将通过在输入数字的序列上折叠 useDigits 函数来填充。
-- file: ch12/Barcode.hs
incorporateDigits :: ParityMap -> [Parity Digit] -> ParityMap
incorporateDigits old digits = foldl' (useDigit old) M.empty digits
incooperateDigit 函数可以用旧的答案map生成完整的新的答案map。
ghci> incorporateDigits (M.singleton 0 []) [Even 1, Even 5]
fromList [(1,[Even 1]),(5,[Even 5])]
[译注: incorporate 函数中,参数 old 代表条码中上一个位置的数字组成的可能的数字逆序序列以及他们对应的临时校验码组成的map,参数digits表示该位置上的候选数字列表。]
最终,我们必须构造完整的答案map。我们先创建一个空的map,然后在条形码的数字序列上依次折叠。我们为每个位置生成一个包含截止到该位置的猜测序列的 new map。这个map将作为下一位置上的折叠过程的 old map出现。
-- file: ch12/Barcode.hs
finalDigits :: [[Parity Digit]] -> ParityMap
finalDigits = foldl' incorporateDigits (M.singleton 0 [])
. mapEveryOther (map (fmap (*3)))
(回想一下,我们在"EAN-13编码"一节中定义checkDigit函数的时候,要求计算校验码的之前,数字要每隔一位乘以3后再进行下一步处理。)
finalDigits 函数接受的列表有多少个元素呢?我们还不知道数字序列的第一个数字是什么,所以很明显第一位数字不能计入,并且在调用finalDigits 时校验码还只是猜测值,我们也不该把它计入。所以这个输入列表应该有11个元素。
从 finalDigits 返回后,答案map必然还不完整,因为我们还没有确定首位数字是什么。
用首位数字补全答案map
我们还没说过如何从左侧分组的奇偶编码类型中提取出首位数字。其实只要直接重用我们前面编写的代码就可以了。
-- file: ch12/Barcode.hs
firstDigit :: [Parity a] -> Digit
firstDigit = snd
. head
. bestScores paritySRL
. runLengths
. map parityBit
. take 6
where parityBit (Even _) = Zero
parityBit (Odd _) = One
现在这个不完整的答案map中的每个元素都包含一个由数字和编码奇偶性信息组成的逆序的列表。接下来的任务就是通过计算每个序列的首位数字来创建一个完整的答案map,并通过它创建最终的答案map(即键值都是正确的校验码,映射值都是完整的12位正序列表的map)。
-- file: ch12/Barcode.hs
addFirstDigit :: ParityMap -> DigitMap
addFirstDigit = M.foldWithKey updateFirst M.empty
updateFirst :: Digit -> [Parity Digit] -> DigitMap -> DigitMap
updateFirst key seq = insertMap key digit (digit:renormalize qes)
where renormalize = mapEveryOther (`div` 3) . map fromParity
digit = firstDigit qes
qes = reverse seq
[译注: mapKeys 将第一个参数指定的函数逐一应用于map中的每个key,并用结果替换掉原key值。]
如此往复,我们最终消去了Parity类型,并撤销了之前乘以3的操作。最后一步,就是完成校验码的计算。
-- file: ch12/Barcode.hs
buildMap :: [[Parity Digit]] -> DigitMap
buildMap = M.mapKeys (realCheckDigit)
. addFirstDigit
. finalDigits
where realCheckDigit c = (10 - c) `mod` 10
找出正确的序列
我们现在有一个包含了所有可能的校验码与对应序列映射的map。剩下的就是逐一验证我们对校验码的猜测值,检查在答案map中是否存在对应的键值。
-- file: ch12/Barcode.hs
solve :: [[Parity Digit]] -> [[Digit]]
solve [] = []
solve xs = catMaybes $ map (addCheckDigit m) checkDigits
where checkDigits = map fromParity (last xs)
m = buildMap (init xs)
addCheckDigit m k = (++[k]) <$> M.lookup k m
[译注:catMaybes 接受一个 Maybe 类型元素组成的列表,返回一个只由 Just 构造器的参数值构成的列表(即参数列表中的 Nothing 值会被直接忽略)。
我们用从照片上取下来的那一行来试验,看看能否得到正确的结果。
ghci> listToMaybe . solve . candidateDigits $ input
Just [9,7,8,0,1,3,2,1,1,4,6,7,7]
太棒了!这正是照片中编码的数字序列。
处理行数据
我们反复强调"处理的是图像中的一行"。下面是"处理一行"的具体的做法
-- file: ch12/Barcode.hs
withRow :: Int -> Pixmap -> (RunLength Bit -> a) -> a
withRow n greymap f = f . runLength . elems $ posterized
where posterized = threshold 0.4 . fmap luminance . row n $ greymap
withRow 函数接受图像中的一行,将该行转换为黑白图像,然后对游程编码后的行数据应用指定函数。该函数通过 row 函数来获取行数据。
row :: (Ix a, Ix b) => b -> Array (a,b) c -> Array a c
row j a = ixmap (l,u) project a
where project i = (i,j)
((l,_), (u,_)) = bounds a
这个函数需要稍作解释。我们知道 fmap 用来变换数组中的元素值,而此处的 ixmap 则用来变换数组中的索引值。这个强大的函数使我们可以任意地从数组取出"切片"。
ixmap 的第一个参数是新数组的边界。边界可以与原数组有不同的维。比方说,我们可以从一个二维数组中取出一个一维数组表示的行。
[译注:此处所说的"有不同的维"包括维数不同、"维的类型"不同、以及两种都有的情况。]
第二个参数是一个映射函数。其参数为新数组的索引值,返回值为原数组的索引值。映射索引的值接下来会变为新数组原索引值处的值。例如,如果我们把2传给映射函数,它返回(2, 2)。这表示新数组中索引值为2的元素值将取自源数组中索引值为(2, 2)的元素。
最终装配
candidateDigits 只要不是从条形码序列的起始处调用,就会返回一个空结果。使用下面的函数,我们可以轻松的扫描一整行,并得到匹配结果。
-- file: ch12/Barcode.hs
findMatch :: [(Run, Bit)] -> Maybe [[Digit]]
findMatch = listToMaybe
. filter (not . null)
. map (solve . candidateDigits)
. tails
..FIXME: 应该是指的candidateDigits的惰性求值
这里,我们利用了惰性求值的优点。 tails 前面的map函数只会在产生非空列表的时候参会真正求值。
-- file: ch12/Barcode.hs
findEAN13 :: Pixmap -> Maybe [Digit]
findEAN13 pixmap = withRow center pixmap (fmap head . findMatch)
where (_, (maxX, _)) = bounds pixmap
center = (maxX + 1) `div` 2
最后,我们做了一个简单的封装,用来打印从命令行传入的netpbm图像文件中提取的条形码。
注意到在我们本章定义的超过30个函数中,只有 main 是涉及 IO 的。
关于开发方式的一些意见
你可能发现了,本章中给出的许多函数都是些放在源码顶部的小函数。这并非偶然。正如我们早些时候提到过的,当我们开始本章的撰写时,我们并不知道要怎样构造这个解决方案。
我们还经常需要找到问题域来明确解决问题的大方向。为了这个目的,我们耗费了大量的时间摆弄 ghci ,对各种函数做小测试。这需要函数定义在源文件的最上方,否则 ghci 就找不到它们了。
一旦我们对这些函数的功能和行为满意了,我们就开始将它们黏合在一起,继续通过 ghci 观察执行的结果。这就是添加类型签名的好处------如果某些函数没法组合到一起,我们可以通过类型签名尽早发现。
最后,我们有了一大堆短小的顶层函数,每个函数都有类型签名。这可能并不是最紧凑的形式;在搞定这些函数的逻辑之后,我们其实可以将其中很多函数放到 let 或者 where 块中。然而,我们发现这种更大的纵向空间,短小的函数体,以及类型签名,都使代码变得更易读了,所以我们也不再考虑拿这些函数玩儿什么"golfing"[^2] 了。
使用强静态类型的语言工作不会影响增量的流式的问题解决模式。我们发现这种"先编写函数",再"用*ghci* 测试,获取有用信息"的模式非常快速;这为我们快速编写优质代码提供了巨大帮助。
[^1]: 该公式在ITU-R Recommendation 601中首次提及。 [^2]: 这个golfing的说法来源于Perl程序员们玩儿的一个游戏,即程序员尝试为某种目的编写最短的代码,敲键盘次数最少的获胜。