Swift函数式编程八(纯函数式数据结构)

wuchangjian2021-10-31 11:04:11编程学习

代码地址

纯函数式数据结构 (Purely Functional Data Structures) 指的是那些具有不变性的高效的数据结构。

二叉搜索树

使用indirect关键字将二叉树定义为一个递归枚举:

indirect enum BinarySearchTree<Element: Equatable>  {
    case leaf
    case node(BinarySearchTree<Element>, Element, BinarySearchTree<Element>)
}

这个定义规定了每一棵树,要么是:

  • 一个没有关联值的叶子leaf,要么是
  • 一个带有三个关联值的节点node,关联值分别是左子树,储存在该节点的值和右子树。

手动构造树:

let leaf = BinarySearchTree<Int>.leaf
let five: BinarySearchTree<Int> = .node(.leaf, 5, .leaf)

编写两个构造方法:

extension BinarySearchTree {
    init() {
        self = .leaf
    }
    init(value: Element) {
        self = .node(.leaf, value, .leaf)
    }
}

编写一个计算属性获取这棵树的存值的个数:

extension BinarySearchTree {
    var count: Int {
        switch self {
        case .leaf:
            return 0
        case let .node(left, _, right):
            return left.count + 1 + right.count
        }
    }
}

编写一个 elements 属性,用于计算树中所有元素组成的数组:

extension BinarySearchTree {
    var elements: Array<Element> {
        switch self {
        case .leaf:
            return []
        case let .node(left, value, right):
            return left.elements + [value] + right.elements
        }
    }
}

count 属性与 elements 非常相似。对于 leaf 的情况,会有一个基础值。而在 node 的情况下, 它将递归地调用子节点,然后将结果与当前节点中的元素合并起来。这个被抽象出来的过程, 有时候被称作 fold 或 reduce:

extension BinarySearchTree {
    func recuce<A>(initialResult: A, nextNodeResult: (A, Element, A) -> A) -> A {
        switch self {
        case .leaf:
            return initialResult
        case let .node(left, value, right):
            return nextNodeResult(left.recuce(initialResult: initialResult, nextNodeResult: nextNodeResult), value, right.recuce(initialResult: initialResult, nextNodeResult: nextNodeResult))
        }
    }
}

这样可以以很少的代码来编写 elements 与 count:

    func count1() -> Int {
        self.recuce(initialResult: 0) { $0 + 1 + $2 }
    }
    func elements1() -> Array<Element> {
        self.recuce(initialResult: []) { $0 + [$1] + $2 }
    }

检查一棵树是否为空:

    var isEmpty: Bool {
        if case .leaf = self {
            return true
        }
        return false
    }

遗憾地是,当insert 和 contains 函数时,没有什么可以 利用的特性。不过为这个结构加上一个二叉搜索树的限制,问题就会迎刃而解。如果一 棵 (非空) 树符合以下几点,就可以被视为一棵二叉搜索树:

  • 所有储存在左子树的值都小于其根节点的值
  • 所有储存在右子树的值都大于其根节点的值
  • 其左右子树都是二叉搜索树

写一个 (低效率的) 属性来检查 BinarySearchTree 实际上是不是一棵二叉搜索树:

    var isBST: Bool {
        switch self {
        case .leaf:
            return true
        case let .node(left, value, right):
            return left.isBST && right.isBST && left.elements.all{ $0 < value } && right.elements.all{ $0 > value }
        }
    }

方法 all 检查了一个数组中的元素是否都符合某个条件。它被定义为一个 Sequence 协议的拓展:

extension Sequence {
    func all(predicate: (Element) -> Bool) -> Bool {
        for item in self where !predicate(item) {
            return false
        }
        return true
    }
}

二叉搜索树的关键特性在于它们支持高效的查找操作,类似于在一个数组中做二分查找。遍历一棵树来查找某个元素是否在树中时,可以在每一步都排除一半元素。编写一个contains 函数,来查找一个元素是否在树中:

    func contains(value: Element) -> Bool {
        switch self {
        case .leaf:
            return false
        case let .node(_, myValue, _) where value == myValue:
            return true
        case let .node(left, myValue, _) where value > myValue:
            return left.contains(value: value)
        case let .node(_, myValue, right) where value < myValue:
            return right.contains(value: value)
        default:
            return false
        }
    }
    func contains1(value: Element) -> Bool {
        switch self {
        case .leaf:
            return false
        case let .node(left, myValue, right):
            if value == myValue {
                return true
            } else if value > myValue {
                return left.contains1(value: value)
            } else {
                return right.contains1(value: value)
            }
        }
    }

contains 函数被分为四种可能的情况:

  • 树是空的,则不在树中,返回false。
  • 树不为空,且储与存在根节点的值相等,返回true。
  • 树不为空,且储比存在根节点的值小,如果在树中的话,一定是在左子 树中,所以在左子树中递归搜索 x。
  • 大于根节点的值,就在右子树中继续搜索。

Swift 的编译器还没有聪明到能够发现这四种情况已经包括了所有的可能性,所以还得再添加一个用来安抚编译器的 default。

插入操作也是用同样的方式对二叉搜索树进行搜索:

    mutating func insert(value: Element) {
        switch self {
        case .leaf:
            self = BinarySearchTree(value: value)
        case .node(var left, let myValue, var right):
            if value > myValue {
                left.insert(value: value)
            } else if value < myValue {
                right.insert(value: value)
            }
            self = .node(left, myValue, right)
        }
    }

insert 会找到一个合适的位置来添加新元素。如果树是空的,就构建一棵只有一个元素的树。如果元素已经存在,就返回树本身。否则,insert函数持续地递归,直到找到一个合适的位置来插入新元素。

insert 函数被写作一个 mutating (可变的)函数,然而,这与那种基于类的数据结构中的可变性有很大区别。实际的值并没有被修改,被修改的只是变量。举个例子,在执行插入操作的情况 下,新的树是在旧树的分支之外构建的,分支本身并不会被修改。数据结构的这种特性通常被称作可持久化的数据结构 (persistent data structures) 可以观察一个例子来验证这个特性:

var tree = BinarySearchTree(value: 10)
var tree1 = tree
tree1.insert(value: 8)
print(tree.elements) // [10]
print(tree1.elements) // [10, 8]

字典树的自动补全

现在实现一个自动补全算法 —— 在给定一组搜索的历史记录和一个现在待搜索字符串的前缀时,计算出与之相匹配的补全列表。

如果使用数组,能够很快解决问题:

extension String {
    func complete(history: [String]) -> [String] {
        history.filter { $0.hasPrefix(self) }
    }
}

但是这个函数不是很高效。在历史记录很多,或是前缀很⻓的情况下,运算会很慢。因此可以将历史记录排序为一个数组,并对其使用某种二叉搜索来提高性能。

字典树:是一种特定类型的有序树,通常被用于搜索由一连串字符组成的字符串。不同于将一组字符串储存在一棵二叉搜索树中,字典树把构成这些字符串的字符逐个分解开来,储存在了一个更高效的数据结构中。

在 Swift 中表示一棵字典树,是写一个结构体,并以一个字典作 为属性,用来储存所有节点处字符与子字典树的映射关系:

struct Trie {
    var children: [Character: Trie]
}

在这个基础上做两点优化:

  • 在每个节点添加一个额外的布尔值 isElement区分出这些前缀是不是也作为一个元素储存在字典树中
  • 定义为泛型字典树,去掉只能储存字符的限制
struct Trie<Element: Hashable> {
    var isElement: Bool
    var children: [Element: Trie<Element>]
}

写一个空字典树的构造方法:

extension Trie {
    init() {
        isElement = false
        children = [:]
    }
}

定义一个elements属性将字典树展平 (flatten) 为一个包含全部元素的数组:

extension Trie {
    var elements: [[Element]] {
        var result: [[Element]] = isElement ? [[]] : []
        for (key, value) in children {
            result += value.elements.map { [key] + $0 }
        }
        
        return result
    }
}

这个函数的内部实现十分精妙:

  • 首先,会检查当前的根节点是否被标记为一棵字典树的成员。如果是,这个字典树就包含了一个空的键,反之,result 变量则会被实例化为一个空的数 组。
  • 接着,函数会遍历字典,计算出子树的所有元素 —— 这是通过调用 value.elements 实现 的。
  • 最后,每一棵子字典树对应的 “character” (也就是代码中的 key) 会被添加到子树 elements 的首位 —— 这正是 map 函数中所做的事情。

也可以使用 flatmap 函数来取代 for 循环来实现属性 elements:

    var elements1: [[Element]] {
        var result: [[Element]] = isElement ? [[]] : []
        result += children.flatMap { (item) in
            return item.value.elements.map { [item.key] + $0 }
        }
        
        return result
    }

构造一棵树,测试一下 elements 属性:

let rTrie = Trie(isElement: false, children: ["e": Trie(isElement: true, children: [:])])
var trie = Trie(isElement: false, children: ["a": Trie(isElement: true, children: ["m": Trie(isElement: true, children: [:]), "r": rTrie]), "b": Trie(), "c": Trie()])
print("----\(trie.elements)----")
print("----\(trie.elements1)----")
/*输出:
 ----[["a"], ["a", "m"], ["a", "r", "e"]]----
 ----[["a"], ["a", "m"], ["a", "r", "e"]]----
 */

一个能够被遍历的数组还是很有用的,实现这个功能:

extension Array {
    var slice: ArraySlice<Element> {
        ArraySlice(self)
    }
}

extension ArraySlice {
    var decomposed: (Element, ArraySlice<Element>)? {
        isEmpty ? nil : (first!, dropFirst())
    }
}

可 以通过重复调用 decomposed 递归地遍历一个数组,直到返回 nil,而此时数组将为空。

之所以为 ArraySlice 而不是 Array 定义 decomposed,是因为性能上的原因。 Array 中的 dropFirst 方法的复杂度是 O(n),而 ArraySlice 中 dropFirst 的复杂度则 为 O(1)。因此,此处的 decomposed 也只具有 O(1) 的复杂度。

使用 decompose 函数递归地对一个数组的 元素求和:

func sum(integers: ArraySlice<Int>) -> Int {
    guard let (head, tail) = integers.decomposed else {
        return 0
    }
    return head + sum(integers: tail)
}

print(sum(integers: Array(1...100).slice))
/*输出:5050*/

遍历一棵字典树,来逐一确定对应的键是否储存在树中:

extension Trie {
    func lookup(key: ArraySlice<Element>) -> Bool {
        guard let (head, tail) = key.decomposed else { return isElement }
        guard let sub = children[head] else { return false }
        
        return sub.lookup(key: tail)
    }
}

print("----\(trie.lookup(key: ["a", "r", "e"].slice))----")
/*输出:----true----*/

对 lookup 函数小作修改,给定一个前缀键组,返回一个含有所有匹配元素的子树:

extension Trie {
    func lookup1(key: ArraySlice<Element>) -> Trie<Element>? {
        guard let (head, tail) = key.decomposed else { return self }
        guard let sub = children[head] else { return nil }
        
        return sub.lookup1(key: tail)
    }
}

计算字典树中与给定前缀相匹配的所有字符串,只需要调用 lookup 函数,如果结果是字典树,就将其中的元素提取出来。如果不存在与给定前缀匹配的子树,就返回一个空数组:

extension Trie {
    func complete(key: ArraySlice<Element>) -> [[Element]] { lookup1(key: key)?.elements ?? [] }
}

使用 decompose 方式来含有一 个元素的字典树:

extension Trie {
    init(_ key: ArraySlice<Element>) {
        if let (head, tail) = key.decomposed {
            self = Trie(isElement: false, children: [head: Trie(tail)])
        } else {
            self = Trie(isElement: true, children: [:])
        }
    }
}

定义两个版本的插入函数来填充字典树:

extension Trie {
    func inserting(_ key: ArraySlice<Element>) -> Trie<Element> {
        guard let (head, tail) = key.decomposed else {
            return Trie(isElement: true, children: children)
        }
        
        var newChildren = children
        if let trie = children[head] {
            newChildren[head] = trie.inserting(tail)
        } else {
            newChildren[head] = Trie(tail)
        }
        
        return Trie(isElement: isElement, children: newChildren)
    }
    mutating func inserting1(_ key: ArraySlice<Element>) {
        guard let (head, tail) = key.decomposed else {
            isElement = true
            return
        }
        
        if var trie = children[head] {
            trie.inserting1(tail)
        } else {
            children[head] = Trie(tail)
        }
    }
}

var iTrie = trie.inserting(["a", "r", "g", "u", "m", "e", "n", "t"].slice)
iTrie.inserting1(["a", "p", "p", "l", "e"].slice)
print("----\(iTrie.elements)----")
/*输出:----[["a"], ["a", "r", "g", "u", "m", "e", "n", "t"], ["a", "r", "e"], ["a", "m"]]----*/
  • 如果键组为空,将isElement设置为true。
  • 如果键组不为空,且键组的head已经存在于当前节点的children字典中,递归地调用该函数,将键组的 tail 插入到对应的子字典树中。
  • 如果键组不为空,且第一个键head并不在该字典树children字典中,创 建一棵新的字典树来储存键组中剩下的键。然后,以 head 键对应新的字典树,储存在 当前节点中,完成插入操作。

字符串字典树

可以为字符串字典树写一些简化操作的封装,首先从单词列表来进行字典树的构建:

extension Trie where Element == Character {
    static func build(words: [String]) -> Trie {
        return words.reduce(Trie()) { (result, word) in
            return result.inserting(Array(word).slice)
        }
    }
}

通过调用之前定义的 complete 方法,并将结果转换回字符串,就能得到一组经过 我们自动补全的单词了。注意在每个结果前拼接输入字符串的方式,这么做是因为 complete 方法的返回没有包含相同的前缀,只返回了单词剩下的部分:

extension String {
    func compelte(knownword: Trie<Character>) -> [String] {
        knownword.complete(key: Array(self).slice).map { self + String($0) }
    }
}

测试一下函数,使用一个简单的列表,创建一颗字典树,然后列出自动补
全的选项:

let content =  ["cat", "car", "cart", "dog", "你", "你好", "你好吗"]
let wordsT = Trie.build(words: content)
print("----\("c".compelte(knownword: wordsT))----")
/*输出:----["cat", "car", "cart"]----*/
print("----\("你好".compelte(knownword: wordsT))----")
/*输出:----["你好", "你好吗"]----*/

相关文章

测试面试题

(朋友整理的) 面经:面试会问大致方向一&#...

基于MATLAB的汽车出入库计时计费系统

汽车出出入库计时计费系统的核心技术就是车牌识别系统。 图一:车牌汉字...

国产骨传导耳机推荐,目前最好用的几款骨传导耳机

国产骨传导耳机推荐,目前最好用的几款骨传导耳机

随着生活节奏的加快,人们坐着工作的时间越来越长,身体甚至一整...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。