<函数式swift>读书笔记之Trie结构
最近在阅读objc中国翻译的的<函数式swift>,读到”纯函数式数据结构”一章中关于字典树Trie的内容时,惊叹于书中对于这一结构设计的巧妙.读完之后,把自己的学习心得记录下来,一方面帮助自己巩固学习以及日后查找,另一方面,也希望如果有之前对于函数式数据结构没有太多了解的读者如果看到这篇博客,能提供一些帮助.
- 关于书中的Trie结构
该章节(当然书中其他章节也一样)只是试图通过这一数据结构来向读者展示函数式编程这一思想在swift语言中的运用,并非试图构建一个完整的字典树结构.所以,书中的范例,只是提供了一个学习的思路
这里贴上书中所列的例子结构:
Trie的swift定义
第一次在CSDN发博客,似乎代码引用会导致目录结构有问题,而纯贴代码到正文,尖括号里面的内容不会显示出来,无奈采用双尖括号代替尖括号表示泛型
书中对于Trie结构定义如下:struct Trie《Element: Hashable》 { var isElement: Bool var children: [Element: Trie《Element》] } 很简洁,也很精妙.首先这是一个结构体,并且是支持泛型的结构体.也就是不仅能用来表示字符串,任意Hashable的类型都可以成为其元素(方便起见,书中有时会以"字符串"指代取元素组(实际应该是[Element]),本文有时也会如此称呼). isElement:这是用于标识截止与当前的字符串,这句话在目前为止不太容易理解,但是等我们分析elements时,大家将会惊叹于其精妙的设计. children:很明显,Trie结构是一个递归的结构,其子树是一个字典,字典的key是Element类型,而value仍然是Trie《Element》类型
elements
(由于elements函数内容太精髓,并且elements对于这一结构而言意味着结果,而只要首先从宏观上先把握住Trie的核心结构,就可以对elements进行分析,所以本人将elements的分析放到了前面。对于elements的理解透彻了,后面的内容就容易多了)
这个计算属性大有看头,虽然它总共只有5行: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,不过现在的代码让整个过程能稍微清晰一些。”--摘录来自: Chris Eidhof. “函数式 Swift”。 iBooks. 作者如是说,开始我并不理解它的精妙之处,直到我模拟了一遍它的运行之后.通过书中提供的创建方法(这个会在后文分析),用["car","cart","cat","dog"]创建了一个Trie,其结构如下(为了方便分析,我把c这一支提到了前面):
▿ Trie
isElement : false
▿ children : 2 elements▿ 1 : 2 elements
- key : “c”
▿ value : Trie - isElement : false
▿ children : 1 element
▿ 0 : 2 elements
- key : “a”
▿ value : Trie
- isElement : false
▿ children : 2 elements
▿ 0 : 2 elements
- key : “r”
▿ value : Trie - isElement : true
▿ children : 1 element
▿ 0 : 2 elements
- key : “t”
▿ value : Trie
- isElement : true
- children : 0 elements
▿ 1 : 2 elements
- key : “t”
- key : “t”
▿ value : Trie - isElement : true
- children : 0 elements
- key : “r”
- isElement : false
- key : “a”
- key : “c”
▿ 0 : 2 elements
key : “d”
▿ value : Trie- isElement : false
▿ children : 1 element
▿ 0 : 2 elements
- key : “o”
▿ value : Trie
- isElement : false
▿ children : 1 element
▿ 0 : 2 elements
- key : “g”
▿ value : Trie - isElement : true
- children : 0 elements
- key : “g”
- isElement : false
- key : “o”
我简单描述一下这个结构的c这一支:首先根节点的isElement为false,其children包含c和d两个key,分别对应两个Trie《Element》,分别称为TrieC和TrieD,二者的isElement都为false.TrieC的children包含一个key–a,其对应的value称为TrieA;TrieD的children也只包含一个key–o,对应value称为TrieO.TrieA和TrieO的isElement仍然为false,TrieA包含两个key–r,t,对应value分别称为TrieR和TrieT1.不同于前面的分支,TrieR和TrieT1的isElement都为true,这是因为car 和cat都是这个结构的一个元素;TrieR包含一个key–t,其value称为TrieT2.而TrieT1的children不包含任何元素.TrieT2和TrieT1一样,isElement为true,并且children不包含任何元素.
便于理解,这里解释一下:每个子Trie的命名规则如下:添加其对应key值得大写字母作为后缀,如果重复了,则添加1,2…作为区分,这样能够更直观的看出每个Trie所处的层级.
接下来分析一下elements这个函数.首先这是一个递归函数,其终止条件是当Trie.children不包含任何键值对,也就是说该函数会递归到每个分支的末梢.该函数的逻辑:根据Trie的isElement的值,将result初始化为不同的值,如果isElement为true,则初始化为[[]],否则[].这一点很巧妙,一会儿分晓.接下来遍历其children第一层会遍历到两个键值对:(c:TrieC),(d:TrieD),我们着重分析第一对.对于(c:TrieC)这一键值对,会继续对TrieC递归调用elements.对返回结果调用map,将c拼接到返回数组中每个元素的开头.TrieC的elements调用过程仍然会继续下去直到TrieT1和TrieT2.
为了能够彻底弄懂这段代码,我采用了倒推法.从TrieT1和TrieT2倒推回去,由于TrieT1和TrieT2的isElement为true,所以在二者的elements代码中,result被初始化为[[]]并返回.也就是说TrieT1.elements = [[]],TrieT2.elements = [[]].TrieT2对应的key是t,其上一层TrieR的elements中,由于isElement为true,result被初始化为[[]],然后对(t,TrieT2)进行result+= TrieT2.elements.map{ [key] +$0 }, 即TrieR.elements = ([[]] += [[]].map{[key] + S0}) = [[],[t]],因此TrieR.elements = [[],[t]],到这里,isElemnet的作用开始体现了.继续倒推,上一层,TrieA的elements方法中,result初始化为[],对(r,TrieR)进行map操作,给result添加了两个元素:[r],[r] + [t],也就是[[r],[r,t]],到这里,isElement的作用彻底展示出来,当TrieR的isElement赋值为true时,其elements中会添加一个[]元素,从而在上一层的遍历中,会有一个r + []的步骤,从而最终car会成为Trie的一个元素.如果TrieR的isElement赋值为false,则TrieR.elements = [[t]]而不是[[],[t]],这样car便不能成为Trie的一个元素.到这里result = [[r],[r,t]],但是这一层还没有结束,应为TrieA对应着两个键值对,另一个是(t,TrieT1),TrieT1.elements = [[]],所以对(t,TrieT1)进行map操作,会给result再添加一个元素:[t],接着返回.因此TrieA.elements = [[r], [r,t],[t]].以此向上类推,TrieC.element = [[a,r,t],[a,r],[a,t]],另一支不做演示,TrieD.elements = [[o,g]],而根Trie.elements对(c:TrieC,d:TrieD)的遍历中,将c和d分别插入到两个elements的开头,最终Trie.elements = [[c,a,r,t],[c,a,r],[c,a,t],[d,o,g]].
至此elements函数分析完毕,短短几行代码,分析了这么长的篇幅,主要是想尽量将过程讲的更细节一点,方便读者和自己以后理解.
分析完这个函数,其实对于书中的Trie结构的分析基本已经差不多了,后面的内容都比较容易理解- isElement : false
Trie的创建与insert
这里首先需要添加两个extension:extension Array { var slice: ArraySlice<Element> { return ArraySlice(self) } } extension ArraySlice { var decomposed: (Element, ArraySlice<Element>)? { return isEmpty ? nil : (self[startIndex], self.dropFirst()) } }
上面这两个函数分别用于创建一个数组切片,和将一个数组切片分为一个包含首元素和剩余部分的元祖.
如果不太理解数组切片,可以简单的将它理解成数组,但是对于切片的操作,其复杂度是O(1),而对于数组的操作,复杂度则是O(n)
接着我们看书中一个核心的构造方法,该构造方法可以创建一个只有一个元素的Trie(如[[c,a,r])extension Trie { init(_ key: ArraySlice《Element》) { if let (head, tail) = key.decomposed { self.init(isElement: false, children: [head: Trie(tail)]) } else { self.init(isElement: true, children: [:]) } } }
这个方法需要传入一个数组切片,并运用上面为数组切片添加的decomposed方法递归的对数组切片的每个“尾巴”调用自身,直到传入的key为空的数组切片,则创建一个isElement为true,children为空字典的Trie(可以称之为空Trie,参照上面的TrieT1,TrieT2).
上面这个方法只能创建一个只有一条分支,且只有一个元素的Trie。创建一个完整Trie,还需要下面这个方法:extension Trie { func insert(_ key: ArraySlice<Element>) -> Trie<Element> { guard let (head, tail) = key.decomposed else { return Trie(isElement: true, children: children) } var newChildren = children if let nextTrie = children[head] { newChildren[head] = nextTrie.insert(tail) } else { newChildren[head] = Trie(tail) } return Trie(isElement: isElement, children: newChildren) } }
这个方法也很关键,同样是通过递归的方法,将参数数组切片所代表的Trie插入到某个已存在的Trie中。简单分析下这个方法:第一行很难理解:如果传入的是个空切片,直接将isElement赋值为true并直接返回,我们先放在一边,看下面的。如果切片不为空,那么它一定可以拆成(head,tail),然后检查children[head]是否存在,如果不存在,则说明head不在Trie的children的key中,则在children中添加一个键值对,key就是head,而value则是Trie(tail);如果存在,则head存在于其children的key中,那么对head对应的value继续递归,调用insert(tail),继续走下去有两种可能:
1,参数切片中的所有Element都在Trie中,那么递归到最后会对某个子Trie调用insert(tail),而此时tail已经是个空字典了,因此直接将这一个子Trie的isElement赋值为true就可以了;
2,参数切片的后续某一部分不在Trie中,则会走到方法中的if let 的else中,插入一个新的分支并结束递归。
现在我们可以回头来看一下第一行了,为什么当插入一个空切片时直接将isElement赋值为true就完事儿了呢?因为一般人谁会对一个Trie插入一个空的切片呢?走到这里一切都是因为递归到这里了,这意味着最初传入的切片一直都走到 newChildren[head] = nextTrie.insert(tail)这一行里,也就是其每个元素都在Trie上,直接将其遍历到最后的子Trie的isElement赋为true,表示这一支是Trie的一个元素。
至此我们已经可以通过先创建一个空Trie然后对某个字符串数组进行遍历insert而完成整颗Trie的创建了。哦,我们还少了个空Trie的构造方法:extension Trie { /* *“如果将一棵空字典树的 isElement 赋值为 true 而不是 false,那么空字符串就会变成空字典树的一个元素 —— 空字典树里却有一个字符串么?别闹!” 摘录来自: Chris Eidhof. “函数式 Swift”。 iBooks. *首先明确一点,这个初始化方法时创建一个空字典树,也就是说这个字典树的elements()应该返回空数组 *如果isElement为true,则elements()会返回[[]]也就是说有一个空字符串(参见elements的分析) *这就是书中说的"空字典树里却有一个字符串" *这就是空字典树其isElement应该赋值为false的原因 */ init() { isElement = false children = [:] } }
至此,Trie真的可以顺利构造了:
extension Trie { static func build(words:[String]) -> Trie<Character> { let emptyTrie = Trie<Character>() return words.reduce(emptyTrie, {trie, word in trie.insert(Array(word.characters).slice)}) } }
这个方法传入一个字符串数组,对其调用reduce方法,将数组中每个字符串转化为字符数组切片,然后insert到创建的空Trie中;
lookup与complete
接下来,就是书中这一章节开头提到的目的:用这一结构完成字符串的自动补全;
首先添加一个查找方法:extension Trie { func lookup(key: ArraySlice<Element>) -> Trie<Element>? { guard let (head, tail) = key.decomposed else { return self } guard let subtrie = children[head] else { return nil } return subtrie.lookup(key: tail) } }
这个方法第一行还是很莫名其妙,当传入的是个空切片时,直接返回self,wtf为什么我在一个Trie里面查找一个空的切片,你要给我返回整个Trie?好在有前面的经验,现在可以直接预测,这里也是通过递归过来的!看第二行,查找某个子Trie是否存在,不存在直接返回nil,如果存在,那么对这个子Trie继续递归,果然。
以上这个方法返回的还是个Trie,而我们的目标是要返回他的element,需要简单封装一下:extension Trie { func complete(key: ArraySlice<Element>) -> [[Element]] { return lookup(key: key)?.elements ?? [] } }
然后给String添加一个extension:
extension String { func complete(_ knowWords: Trie<Character>) -> [String] { let chars = Array(characters).slice let completed = knowWords.complete(key: chars) return completed.map{ self + String($0) } } }
就是把上面的方法封装了一下。需要提一下的是,由于查找到的子树不包含前面的部分(比如对例子结构输入“ca”查找,查出来的结果是[“r”,”rt”,”t”])这样的,因此需要把self拼接到前面
验证
现在,这个Trie结构已经可以使用了。
首先,用[“car”,”cart”,”cat”,”dog”]创建一个Trie,然后调用elements方法:let contents = ["cat", "car", "cart", "dog"] let trieOfWords = Trie<Character>.build(words: contents) trieOfWords.elements
控制台显示的是:
[“d”,”o”,”g”]
[“c”,”a”,”r”]
[“c”,”a”,”r”,”t”]
[“c”,”a”,”t”]
完美- 总结
理解这一结构的关键,我觉着还不是函数式编程思想,而是递归,以及抽象能力。如果抽象能力一般,那么建议如我第三点中分析的那样,模拟一下递归过程,应该会有助于理解这一结构。如果本文有任何错漏之处,有幸被哪位客官看到了,劳烦一定要指点小弟!