(译)纯粹函数式数据结构

本文译自 objc.io出品的书籍《Functional Programming in swift》第九章,objc.io 由 Chris Eidhof, Daniel EggertFlorian Kugler 成立于柏林。成 objc.io 的目的是针对深入的、跟所有 iOS 和 OS X 开发者相关的技术话题创造一个正式的平台。objc .io 出过24期的期刊,每一期都针对特定的主题提出解决方案, 可以到objc中国查看这些文章的中文版本。本书延续了 objc.io 一贯的风格,讲解得很深入,可惜译者水平有限,无法将书中的精彩之处忠实的表达出来。 想购买正版书籍请到 https://www.objc.io/books/functional-swift/

在前一章中,我们见识了怎样使用枚举为你开发的应用程序量身定做新的类型。在本章中,将会定义递归的枚举类型,并讲解怎样将之用于定于高效而健壮的数据结构

二分查找树(Binary Search Trees)

和Object -C语言中的NSSet一样,swift也没有操作集合(set)的库(Swift does not have a library for manipulating sets, like Objective-C’s NSSet library 这句翻译错了)。尽管可以像封装Core Image库和封装String初始化方法一样,将NSSet封装成为swift语言的接口,但我们要尝试一种稍微有点不同的方案。我们的目标并不是定义swift语言操作集合完整的库,而是为了掩饰怎样使用递归枚举定义高效的数据结构。

在本章实现的小型库中,将会实现如下四个操作:

  • emptySet — 返回空的集合
  • isEmptySet — 检查集合是否为空
  • setInsert —将一个元素插入到集合中
  • setContains — 检查一个集合中是否包含某元素

作为第一次尝试,我们使用数组表示集合。这四个操作的实现是很简单的:

func emptySet<T>() -> Array<T> {
    return []
}

func isEmptySet<T>(set: [T]) -> Bool {
    return set.isEmpty
}

func setInsert<T>(x: T, set:[T]) -> [T] {
    return [x] + set
}

func setContains<T: Equatable>(x: T, set: [T]) -> Bool {
    return contains(set, x)
}

这种实现虽然简单,但是其弱点在于对很多操作其复杂度是现行的。对于很大的集合,会引起性能问题。

有很多方法可以提高性能。例如,我们可以确保数组排好序这样就可以使用二分查找定位元素。但这里我们将定义一个二分查找树来表示集合。我们可以使用传统C语言的形式定义树数据结构,每个结点都保存子树的指针。然而,我们还可以将树直接定义为一个枚举类型,并使用和上一章相同的Box技巧。

enum Tree<T> {
    case Leaf
    case Node(Box<Tree<T>>, T, Box<Tree<T>>)
}

其中Box定义为:

class Box<T> {
    let unbox: T
    init(_ value: T) { self.unbox = value }
}

这个定义表明树如下的任何一种结构:

  • 没有关联值得树叶,或者
  • 具有三个关联值得结点:左子树,结点存储的值,右子树

在定义树的函数之前,我们可以手写几个树的实例,以便对其有个直观的认识:

let leaf: Tree<Int> = Tree.Leaf
let five: Tree<Int> = Tree.Node(Box(leaf), 5, Box(leaf))

注:现在已经可以编译但会在运行时挂起(puzzling)

leaf树是空的,five树在一个结点中保存5,但它的左子树和右子树都是空的。我们可以生成这种结构并编写一个函数创建只有单个值的树:

func single<T>(x: T) -> Tree<T> {
    return Tree.Node(Box(Tree.Leaf), x, Box(Tree.Leaf))
}   

正如前一章一样,我们可以编写一个函数使用switch语句操作TreeT枚举类型。因为Tree枚举类型是递归的,所以毫无意外,我们为之编写的一些函数也是递归的。例如下面计算树中存储元素数目的函数:

func count<T>(tree: Tree<T>) -> Int {
    switch tree {
        case let Tree.Leaf:
            return 0
        case let Tree.Node(left, x, right):
            return 1 + count(left.unbox) + count(right.unbox)
    }
}

对于是树叶的情形,立即返回0。是结点的情形则要有趣得多:我们递归的计算两个子树所包含的元素的数目。但返回两个子节点的数目后,将两者相加,最后加1(1是指本结点包含的元素)

同样,可以编写一个函数获取树中所有元素组成的数组:

func elements<T>(tree: Tree<T>) -> [T] {
    switch tree {
        case let Tree.Leaf:
            return []
        case let Tree.Node(left, x, right):
            return elements(left.unbox) + [x] + elements(right.unbox)
    }
}

下面回到原来的思路,继续编写使用树表示的高效的集合库。其中isEmptySet操作和isEmptySet操作很简单:

func emptySet<T>() -> Tree<T> {
    return Tree.Leaf
}

func isEmptySet<T>(tree: Tree<T>) -> Bool {
    switch tree {
        case let Tree.Leaf:
        return true
    case let Tree.Node(_, _, _):
        return false
    }
}

注意isEmptySet函数分支为结点的情形,我们不需要检查结点的子树或者结点中存储的值,就可以直接返回false。相应的,我们可以使用通配符代表结点关联的三个值,因为并不使用它们。

然而,编写setInsert和setContains函数使可以发现,效率并没有改进多少。但是如果我们限制树为二分查找树,效率会提高很多。如果一个非空树满足下列条件,那么它就是一个二分查找树:

  • 左支树的所有值都比根节点存储的值小
  • 右支树的所有值都比根节点存储的值大
  • 左支树和右支树都是二分查找树
    可以编写一个函数检查一个树是否是二分查找树:
func isBST<T: Comparable>(tree: Tree<T>) -> Bool {
    switch tree {
        case Tree.Leaf:
            return true
        case let Tree.Node(left, x, right):
            let leftElements = elements(left.unbox)
            let rightElements = elements(right.unbox)
                return all(leftElements) { y in y < x }
                && all(rightElements) { y in y > x }
                && isBST(left.unbox)
                && isBST(right.unbox)
    }
}

all函数检查数组中的所有元素是否都满足某个条件,该函数的定义可以父子本书的附录中找到。

二分查找树的最显著的特性是查找效率高,可以和数组的二分查找的效率相媲美。当我们遍历查找树中的某一个元素时,每一次迭代都可以排除一半余下的元素。例如,下面的函数检查树中是否包含某个元素:

func setContains<T: Comparable>(x: T, tree: Tree<T>) -> Bool {
    switch tree {
        case Tree.Leaf:
            return false
        case let Tree.Node(left, y, right) where x == y:
            return true
        case let Tree.Node(left, y, right) where x < y:
            return setContains(x, left.unbox)
        case let Tree.Node(left, y, right) where x > y:
            return setContains(x, right.unbox)
        default:
            assert(false, "The impossible occurred")
    }
}

setContains函数检查四种可能的情况:

  • 如果树为空,则x不再树中,返回false
  • 如果树非空,并且树根结点存储的值和x相同,则返回true
  • 如果树非空,而且根节点中存储的值大于x,我们知道如果树包含x,它一定在左子树中,因此,递归查找左子树
  • 类似的,如果x大于根结点中存储的值,我们递归查找右子树
    不幸的是,swift编译器并不能知晓这四种情况已经覆盖了所有的可能性,所以需要添加一个多余的default语句。

插入函数以相同的方式遍历树:

func setInsert<T: Comparable>(x: T, tree: Tree<T>) -> Tree<T> {
    switch tree {
        case Tree.Leaf:
            return single(x)
        case let Tree.Node(left, y, right) where x == y:
            return tree
        case let Tree.Node(left, y, right) where x < y:
            return Tree.Node(Box(setInsert(x, left.unbox)), y, right)
        case let Tree.Node(left, y, right) where x > y:
            return Tree.Node(left, y, Box(setInsert(x, right.unbox)))
        default:
            assert(false, "The impossible occurred")
    }
}

setInsert函数寻找合适的位置插入新的元素。如果树为空,它会创建一个只有一个值的树并返回。如果树中已经包含要插入的元素,它就返回初始树。否则,setInsert会继续递归,导航到合适的位置插入该值

setInsert和setContains函数性能在最坏情况下仍然是线性的—毕竟,可能会有非常不均衡的树,所有的左子树都为空。更加理想的实现,如2-3树, AVL树或者红黑树,可以避免这种情况。此外,我们并没有实现删除方法,而删除方法需要重新调整二分查找树的元素顺序。很多文献资料实现了这个棘手的操作—–再次强调,这个例子只做演示用,我们并不打算实现一个完整的库。

使用Tries实现自动补全

我们已经见识了二叉树,这最后一节讲解一个更加高级更加纯粹的函数式数据结构。假设我们需要编写自动补全算法—-给定历史查询的列表以及当前查询的前缀,我们可以计算出可能补全的列表

使用数据,解决方案很直接:

func autocomplete(history: [String], textEntered: String) -> [String] 
    return history.filter { string in
        string.hasPrefix(textEntered)
    }
}

该函数的性能很差。对于很大的历史列表数据或者很长的前缀,该函数可能会很慢。我们可以让数组保持有序并使用二分查找来改善性能。但是我们要尝试不同的解决方案,使用专门为这种查询量身定做的数据结构。

Tries,也称作数字查找树(digital search trees),是一种特殊的有序树。一般情况下,Tries用于字符串(字符串由单个的字符构成)查找。我们不将字符串保存在二叉查找树中,而是将字符串保存在一种结构中,该结构重复的为字符串的字符建立分支。

二分查找树的每个结点有两个子树,但是Tries子树的数目并不固定。每个字符都有一个子树。例如,下图表示存储字符串 “cat,” “car,” “cart,” 和“dog”的Tries。

《(译)纯粹函数式数据结构》

要判断字符串 “care”是否在trie中,我们从根节点出发,闲着标有’c’,’a’,’r’的分支查找。因为标有’r’的结点并没有标有’e’的子结点,所以字符串“care”不在trie中。

字符串“cat”在trie中,因为我们沿着分支可以依次找到其所有的字符。

怎样使用为swift表示这种结构呢?我们第一次尝试定义一个枚举,其成员和一个字典关联(dictionary),该字典将字符和其子分支相关联

enum Trie {
  case Node([Character: Trie])
}

这个枚举有两个需要改进的地方。首先,结点需要增加一些附加信息。从上面举出的trie的例子可以知道,要添加字符串”cart”到trie,”cart”的所有前缀—也就是“c,” “ca,” 和 “car” —也需要显示在trie中。为了区分前缀是否在trie中,我们向结点添加一个boolean值。这个布尔值表明当前的字符串是否在trie中。最后,我们定义一个通用的ties,这样它就不限于只存储字符了。因此,重新定义这个枚举类型,如下所示:

enum Trie<T: Hashable> {
  case Make(Bool, [T: Trie<T>]) 
}

接下来,我们会将[T]当做字符串,将T当做字符。这并不准确,因为T也可能是其他类型,而字符串和[T]也不等同—但是我想让你理解得更直观一些。

在定义自动补全的函数之前,我们先写几个简单的函数热热身。例如,一个空的trie包含一个和空dictionary关联的结点。

func empty<T: Hashable>() -> Trie<T> {
    return Trie.Make(false, [T: Trie<T>]())
}

如果我们设置存储在空trie中的布尔值为true而不是false,那么空字符串将是空trie的一个成员,这并不是我们希望的。

下面,我们定义一个函数,将trie数据结构转化为包含其所有元素的数组:

func elements<T: Hashable>(trie: Trie<T>) -> [[T]] {
    switch trie {
        case let Trie.Make(isElem, rest):
            var result: [[T]] = isElem ? [[]] : []
            for (key, value) in rest {
                result += elements(value).map {xs in
                    [key] + xs
                }
            }
            return result
    }
}

这个函数有点绕。它首先检查当前trie的根结点是不是trie的一个成员,如果是,这个trie包含一个空的元素,否则的话,result被初始化为一个空的数组。下一步,遍历dictionary,通过调用elements(value)来计算子结点的元素。最后,每个子结点关联的‘字符’(其实是T类型的数据)被添加到那个子结点元素的前面—这一步是使用map完成的

下一步,定义查找和插入方法。在这之前,先定义一些辅助函数。我们有一个数组表示自动补全的关键字,然而tries被定义为一个枚举类型,而不是一个数组类型,但是遍历数组仍然很有用。为了方便使用,我们为数组添加一个扩展:

extension Array {
    var decompose : (head: T, tail: [T])? {
        return (count > 0) ? (self[0], Array(self[1..<count])) : nil
    }
}

decompose计算属性首先检查数组是否为空,如果为空,则返回nil,否则,它返回一个元组。该元组由该数组的第一个元素以及去掉第一个元素之后的数组组成。我们可以通过循环调用decompose直到返回nil来遍历数组。

使用decompose计算属性,可以定义一个查找函数,给定一个T类型的数组,遍历一个trie来确定该值是否保存在trie中(对于本例,T为字符,[T]可视为一个字符串,可理解为检查该字符串是否保存在trie中)

func lookup<T: Hashable>(key: [T], trie: Trie<T>) -> Bool {
    switch trie {
        case let Trie.Make(isElem, rest):
            if let (head, tail) = key.decompose {
                if let subtrie = rest[head] {
                    return lookup(tail, subtrie)
                }
            } else { 
                return isElem 
            }
    }
    return false
}

这可以区分为三种情况:

  • key为空—-这种情况下,返回isElem,改值表示当前结点描述的字符串是否在trie中(见定义)
  • key不为空—这种情况下,我们检查和key第一个元素关联的trie是否存在(是否为空),如果存在,则进行递归调用,查询这个子结点是否包含key去掉第一个元素之后的值
  • key不为空,而且其对应的子结点也不存在—-在这种情况下,直截了当的返回false,因为这个key不在trie中

我们可以改进lookup函数,让它返回所有包含某些前缀的子结点:

func withPrefix<T: Hashable>(prefix: [T], trie: Trie<T>) -> Trie<T>? {
    switch trie {
        case let Trie.Make(isElem, rest):
            if let (head, tail) = prefix.decompose {
                if let remainder = rest[head] {
                    return withPrefix(tail, remainder)
                }
            } else {
                return trie
            }
    }
    return nil
}

这个函数和lookup函数唯一的区别就是他不在返回isElem布尔值,而是返回根结点的值的isElem为真的整个子结点(The only difference with the lookup function is that we no longer return the isElem boolean, but instead return the whole subtrie, containing all the elements with the argument prefix.)

最后,我们使用这个数据结构定义autocomplete函数:

func autocomplete<T: Hashable>(key: [T], trie: Trie<T>) -> [[T]] {
    if let prefixTrie = withPrefix(key, trie) {
        return elements(prefixTrie)
    } else {
        return []
    }
}

要计算trie中包含指定前缀的所有的字符串,我们仅仅是调用withPrefix函数,如果返回值非空,调用elements返回其包含的所有元素,否则,返回空数组

要完善该库,你需要自己编写插入和删除方法

讨论

本章仅仅介绍了两个使用枚举类型编写高效的,不变的数据结构的例子。ChrisOkasaki写的Functional Data Structures (1999)书中,还有许多其他的例子。感兴趣的读者还可以阅读Ralph Hinze和Ross Paterson关于finger trees (2006)的研究成果—用于众多应用程序的通用的纯粹函数式数据结构

点赞