第三章——集合(集合)

318 阅读9分钟

集合(Collection)是建立在序列(sequence)上层的类型,它添加了可重复遍历元素和根据下标访问元素的功能。

为了具体说明Swift中的集合的实现原理,我们会实现一个自己的集合。可能Swift标准库中没有实现的最有用的容器类型就是队列(queue)了。Swift的数组可以很容易的拿来当栈用——append就是压栈,removeLast就是弹栈。但把数组当队列用就不合适了。你可以把append方法和removeAtIndex(0)方法结合起来用,但移除数列中的元素(不是最后一个)是一个O(n)的操作。因为数组元素在内存中连续存储,移除一个元素意味着后面的元素都要向前移动来弥补被移走的元素留下的空缺。

###设计队列的协议

在我们实现队列之前,我们最好先确定一下我们的队列需要具备哪些功能。一个比较好的方法是先定义一个描述队列的协议:

protocol QueueType {
//自己持有的元素的类型
typealias Element

mutating func enqueue(element: Element)
mutating func dequeue() -> Element?
}

尽管这是一个非常简单的队列协议的定义,它依然提供了很多队列的定义。比如,队列被定义为一个范型容器,通过类型别名Element,它可以容纳任何类型的元素,它对元素类型没有要求,只要保证是某一个具体的类型即可。

需要强调一点,对于enqueuedequeue方法的时间复杂度没有明确要求。我们固然可以规定这两个方法在O(1)的常量时间内完成,而且这样会给用户留下高性能的印象,但同时也会不再适合某些数据结构。比如优先级队列(priority queue)的入队方法需要O(log n)的时间复杂度。

这个协议没有提供peek方法允许用户在获取队首元素的同时不用dequeue。这意味着实现这个协议的队列不具备这一特性(比如操作系统和网络的队列接口就没有peek方法)。它也没有这两个操作是否是线程安全的,以及队列是否是一个集合类型(尽管我们将要实现的版本确实如此)。

这个协议甚至都没有指定队列是否是先入先出的,也就是说可能是后入先出的。如果把append当做入队,isEmpty配合removeLast实现出队功能,我们还可以让数组也实现这个协议。

###数组和可选类型

不过这个协议还是指定了一些信息的。比如与removeLast不同的是,dequeue函数返回的是可选类型的值。如果队列为空,dequeue会返回nil,而如果数组为空,调用removeLast会导致程序退出。

这种风格很大程度上基于个人偏好。Swift的数组更倾向于在调用方法前由用户考虑数组当前的状态,再决定能不能调用方法。最明显的一个例子是数组的下标脚本,你最好确保数组有不少于十个元素,然后才去获取下标为9的数组元素。调用空数组的removeLast方法会导致程序退出。

Swift这么做主要是考虑到数组切片的使用。在Swift中,计算下标其实是很罕见的:

  • 遍历集合元素可以用:for x in collection
  • 遍历集合切片可以用:for idx in collection.indices
  • 获得所有元素和下标:for (num, element) in collection.enumerate()
  • 找到某个指定的元素:if let idx = collection.indexOf({ someMatchingLogin($0) })
  • 变换集合中所有元素:array.map { someTransformation($0) }
  • 获取符合某个要求的元素:collection.filter { someCriteria($0) }

一旦你发现你用到了形如:

for var i = collection.startIndex; i != collection.endIndex; ++i {
//某些操作
}

赶快停下来想想你是否真的需要这样写。有时候你确实得这么做,你在本书中也会看到几个这样的例子,但大多时候你可以用一种更清楚易读的方法来实现同样的功能。手动管理数组下标在我看来极容易导致bug,所以最好不要这么做。如果必须这么做的话,我们在范型章节会看到,可以用一些可重用的范型函数来代替这种写法。这样就可以把经过精心测试的某一小段用于计算下标的代码封装起来。

有时候非用下标不可,那么就需要极为小心的处理下标计算背后的逻辑。所以解封通过下标脚本获得的值[1]往往是多此一举的——这意味着你其实不相信你的代码。但有时候正是因为你过于相信自己的代码,所以有可能直接强制解封(force-unwrapping)获取到的值,这么做既让别人反感,同时也不是什么好习惯。常在河边走,哪能不湿鞋。如果你的代码里到处都是强制解封,你迟早会因此栽跟头。所以不要让这种不好的风格成为习惯,数组的方法没有赋予你这样的权利。

removeLast函数要稍复杂些。如果把数组当做栈来用,只要数组不是空的,你就可能希望让数组最后一个元素出栈:

while !array.isEmpty [
//必须用!.isEmpty来保证数组不为空
let top = array.removeLast()
//处理top元素
}

但如果让dequeue函数的范围值是可选类型,你就可以把代码缩短到一行,同时也不会弄出什么错误:

while let x = q.dequeue() {
//处理队列元素x
}

这么做(为了获取简洁性和安全性而在明知集合不为空时还要进行一层封装)是否利大于弊,就取决于你自己了。

###队列的实现

现在,我们定义好了队列,是时候实现它了。

下面是一个非常简单的队列代码,只包含了enqueuedequeue函数的实现。

因为我们已经把队列的范型占位符定义为Element了,也就是实际所需要的类型别名,所以就没有必要再定义类型别名了。因为占位符只是你随便取的一个名字,如果我们让它叫Foo,我们可以再加一句typealias Element = Foo或者干脆就让Swift的类型推导功能根据enqueuedequeue的返回值类型自己推理[2]

/// 一个高效的FIFO队列,元素类型是Element
struct Queue<Element>: QueueType{
private var left: [Element]
private var right: [Element]

init() {
left = []; right = []
}

//以O(1)的时间复杂度添加元素到队列的末尾
mutating func enqueue(element: Element) {
right.append(element)
}

//以平均O(1)的时间复杂度移除队首的元素
//如果队列为空就返回nil
mutating func dequeue() -> Element? {
guard !(left.isEmpty && right.isEmpty)
else { return nil }

if left.isEmpty {
left = right.reverse()
right.removeAll(keepCapacity: true)
}
return left.removeLast()
}
}

这个实现其实是用两个栈(也就是Swift中的数组)来模拟队列。当元素入队时,他们进入右侧的栈。当元素出队时,从左侧的栈出来。当左侧的栈为空时,就颠倒右侧栈中所有元素并放入左侧栈中[3]

你也许惊讶地发现出队操作的时间复杂度被声明为了O(1),它难道不是包含了一个O(n)的反转操作么。诚然,需要反转右侧栈不假,但对于大量的入队出队操作来说,一个元素的平均出队时间是一个常量[4],但对于某一个元素来说可能并非如此。

其中关键在于理解数组反转是怎么发生的,以及会设计多少元素。一种分析这个问题的策略叫做“银行家方法”。假设每次你放一个元素到队列里,就是把一个令牌放到银行。一次入队对应一个令牌,所以花费的是常数级的时间。当需要把右侧栈反转,元素放入左侧栈中时,你需要取得每一个入队元素对应的令牌,然后用令牌去支付这个元素的反转。因为账户永远不会欠债,所以你的花销永远不会超过你的支出。

这就是为什么单个操作时间不定,但多次操作的平均时间是常量。通过同样的分析,我们也可以知道为什么向数组添加元素,也是花费常数级时间。当数组容量用尽时,系统会分配一块更大的空间然后把现有元素都拷贝过去。因为每次充分配空间都会将之前的容量翻倍,所以你可以套用同样的分析:“每次添加元素拿到一个令牌,扩容复制时花费一个令牌,花费总不会超过收入”。关于详细的数组扩容原理,参考另一篇文章——《Swift数组扩容原理》

###实现CollectionType协议

现在我们有了一个可以调用enqueuedequeue方法的容器,为了让它真正成为一个集合,队列需要实现CollectionType协议:

extension Queue: CollectionType {
var startIndex: Int { return 0 }
var endIndex: Int { return left.count + right.count }

subscript(idx: Int) -> Element {
guard idx < endIndex else { fatalError("Index out of bounds") }

if idx < left.endIndex {
return left[left.count - idx.successor()]
} else {
return right[idx - left.count]
}
}
}

CollctionType协议定义了一个Index别名,不过和Element一样可以用方法和属性定义推导出来。

通过这几行代码,队列实现了CollectionType(自然也就实现了SequenceType协议)。队列现在就有超过40种方法和属性由那俩个协议进行管理了。下面给出一段队列的使用:

var q = Queue<String>()
for x in ["1", "2", "foo", "3"] {
q.enqueue(x)
}

//现在可以用for循环获取队列中元素了
for s in q { print(s) }         //打印出 1 2 foo 3

//把队列传到那些能够接收它作为参数的方法中
q.joinWithSeparator(",")        //返回的是字符串"1,2,foo,3"
let a = Array(q)                //数组a = ["1", "2", "foo", "3"]

//调用SequenceType的拓展方法
q.map{ $0.uppercaseString }     //["1", "2", "FOO", "3"]
q.flatMap{ Int($0) }            // [1,2,3]
q.filter{ $0.characters.count > 1 }

//调用Collection的拓展方法
q.isEmpty                       // false
q.count                         // 4
q.first                         // "1"
q.last                          // "3"

我们模仿了数组的习惯,对于无效的下标不会返回可选类型的值而是直接报错。这里调用Queue.first返回了下一个即将出队的元素,它的作用类似于之前所说的peek

不过先别急着高兴,SequenceType协议要求我们提供一个generate方法,来创建生成器,我们的代码中没有这么做,所以它能够通过编译么?

在Swift2.0以前,我们确实需要实现generate()方法——通过使用IndexingGenerator辅助结构体来创建一个生成器。这个生成器可以通过下标脚本从头到尾遍历元素:

extension Queue: CollectionType {
// startIndex、endIndex和subscript的定义和之前一样
func generate() -> IndexingGenerator<Queue<Element>> {
return IndexingGenerator(self)
}
}

不过我们可以看到这个函数并没有什么实质性的内容,它只是一些固定的模板代码而已。所以在Swift2.0中,由于协议拓展的出现,CollectionType协议通过拓展,定义了一个默认的实现。这样我们就不用自己写这段代码了。关于更多的细节,请参考范型和协议章节。当然,在后面的章节你也会看到如何用自定义的代码重写这个方法。

###实现ArrayLiteralConvertible协议

我们在构造自己的队列时,有必要也让它实现一下ArrayLiteralConvertible协议。这允许用户通过熟悉的[value1, value2, ...]语法来创建队列。这很容易实现:

extension Queue: ArrayLiteralConvertible {
init(arrayLiteral elements: Element...) {
self.left = elements.reverse()
self.right = []
}
}

考虑到队列的逻辑,我们希望反转所有元素并放到左侧栈中,因为它们以后出队时也得放到左侧栈中。所以不如直接放在左侧栈,省去一次元素复制的操作。

现在我们可以很容易的用字面量创建队列了:

let q: Queue = [1,2,3]

需要强调的一点是,Swift中的字面量和类型是不一样的。这里的[1,2,3]不是数组,而是数组字面量。它可以用于创建任何一个实现了ArrayLiteralConvertible协议的类型的实例对象。这个数组字面量内部含有整数字面量,可以用于创建任何实现了IntegerLiteralConvertible协议的类型。

这些字面量是有默认类型的,如果你没有指定它的类型,Swift会把它当做默认的类型。比如数组字面量默认会生成一个数组,整数字面量对应的默认类型是Int,字符串字面量对应的默认类型是String。但默认类型只在你不指明变量类型时才会生效,比如之前我们把队列声明为整数的队列,但我们也可以把它定义为别的类型的整数:

let byteQueue: Queue<Int8> = [1, 2, 3]

字面量的类型一般可以通过上下文推断出来。比如下面就是一个接受字面量作为参数的函数:

func takesSetOfFloats(floats: Set<Float>) {
//...
}
//这样,字面量就会被当做Set<Float>而不是Array<Int>
takesSetOfFloats([1, 2, 3])

实现RangeReplaceableCollectionType协议

下一个需要实现的协议是RangeReplaceableCollectionType,这个协议有三个要求:

  • 实现一个reserveCapacity方法。这个方法在实现map方法时用到过。由于最终元素的数量是可以提前知道的,所以队列可以在充分配存储空间时避免不必要的复制。因此,集合其实不用在这个方法中做任何操作,直接return就可以了。

  • 一个空的初始化方法。它通过一个函数创建新的空集合作为占位符,这些空集合都是相同的类型,这在范型编程中很有用。

  • 一个replaceRange函数。这个函数用一个新的集合替换原来集合中指定的范围。

RangeReplaceableCollectionType协议充分展示了协议拓展的强大。你只要实现一个非常灵活的replaceRange方法,立刻就可以使用由此得到的一系列方法:

  • appendappendContentsOf方法——在末尾添加一个或多个新元素(相当于替换了endIndex..<endIndex部分)
  • removeAtIndexremoveRange方法——用空集合替换某个下标或某个范围内的元素
  • spliceinsertAtIndex方法——用一个或多个新元素替换atIndex..<atIndex范围内的元素
  • removeAll用一个空集合替换startIndex..<endIndex部分

对于特定的集合类型来说,以上方法如果自己有更高效的实现,它可以用自己的实现去替换根据协议拓展得到的默认实现。

这里我们选择最简单、低效的实现方法。但即便是最高效的实现,时间复杂度也是O(n),不过常数因子会略小一些:

extension Queue: RangeReplaceableCollectionType {
mutating func reserveCapacity(n: Int) {
return
}

mutating func replaceRange<C : CollectionType where C.Generator.Element == Element>(subRange: Range<Int>, with newElements: C) {
right = right + left.reverse()
left.removeAll(keepCapacity: true)
right.replaceRange(subRange, with: newElements)
}
}

你可以试着实现一个更高效的版本。可以判断一下subRange是否横跨了左右两个栈,还是只是在某一侧栈的内部。

这里我们没有必要实现init方法,因为它已经被实现了。在这段代码中,appendContentsOfappend方法把新的元素添加到队列末尾,这是符合队列自身逻辑的。

#译者注 [1]:这里没看明白,数组的下标脚本返回的应该不是可选类型吧。如果有正确的理解希望及时告知我。

[2]:这句话其实是针对下文中代码里面,结构体的类型别名Element而言的,因为协议里面说类型别名是Element,所以这里要么沿用协议中定义的类型别名,要么重新定义一个类型别名,同时用typealias Element = 自定义别名表示这个自定义的别名就是对应了协议中定义的类型别名。另外,代码中没有让Queue结构体实现QueueType协议,感觉应该是个失误,我就自己加了,看看正式版会不会改过来。

[3]:考虑一个实际问题:1、2入队,1出队,3、4入队,队列内部的两个栈的情况如下:

  1. 1、2入队:左侧栈为空,右侧栈由下至上是1,2
  2. 1出队(1):因为左侧栈为空,反转右侧栈放入左侧栈中,现在左侧栈由下至上是2,1,右侧栈为空
  3. 1出队(2):左侧栈顶的1出队,左侧栈是2,右侧栈为空
  4. 3、4入队:左侧栈中只有2,右侧栈由下至上是3,4

往后的过程应该都可以自己想象了。反转栈是为了让先进来的元素由右侧栈底变为左侧栈顶

[4]:假设有队列有n个元素,这n个元素都要出队。我们不知道要发生多少次反转,但所有反转所涉及的数的和,一定是n。所以平均每次反转一个数,是一个常量。如果观察Swift源码,还有很多函数的时间复杂度被标记为amortized,这表示虽然不知道某一次操作的时间复杂度,但是大量操作的平均时间复杂度是已知的。