确定用于表示给定值集合的数据结构通常比看起来更棘手。由于每种数据结构都针对一定数量的用例进行了优化,因此找到每组数据的正确匹配通常会对我们的代码最终变得有效率产生重大影响。
雨燕标准库附带了三个主要数据结构- Array,Dictionary以及Set-每个配备了一组不同的优化,优点和缺点。本周,我们来看看其中的一些特性,以及我们有时可能需要在标准库的范围之外进行冒险以找到满足我们需求的正确数据结构。
同时小编这里有些书籍和面试资料哦(点击下载)
数组的线性
Array
可以说是Swift中最常用的数据结构之一,并且有充分的理由。它保持元素顺序,易于以可预测的方式迭代,并且可以存储任何类型的值 - 从结构,类实例到其他集合。
例如,这里我们使用一个数组来存储放置在Canvas
绘图应用程序中的形状集合。然后,当被要求将我们的画布渲染成图像时,我们只是遍历我们的数组,以便使用a绘制每个元素DrawingContext
- 如下所示:
struct Canvas {
var shapes: [Shape]
func render() -> Image {
let context = DrawingContext()
shapes.forEach(context.draw)
return context.makeImage()
}
}
在线性绘制我们所有的形状时,就像我们上面所做的那样,使用数组非常合适。数组不仅以非常有效的方式存储它们的元素,它们还具有保证的迭代顺序,这为我们提供了可预测的绘制顺序,而无需进行任何额外的工作。
但是,就像所有其他数据结构一样,数组也有缺点。在我们的例子中,当我们想要从画布中删除形状时,我们将开始遇到一个这样的缺点。由于数组元素是通过索引存储的,因此在删除之前,我们总是需要查找与给定形状相关联的索引:
extension Canvas {
mutating func remove(_ shape: Shape) {
guard let index = shapes.firstIndex(of: shape) else {
return
}
shapes.remove(at: index)
}
}
起初上面的代码可能看起来不成问题,但它很可能成为包含大量形状的任何画布的性能瓶颈 - 因为就*时间复杂性而言firstIndex
是线性的(O(N)
)。*
虽然我们可以在我们使用Canvas
类型的任何地方解决这个限制- 例如总是通过索引而不是通过值或ID来引用形状 - 这样做会使我们的代码更复杂和更脆弱,因为我们总是需要确保每当我们正在使用的画布发生变化时,我们的索引都不会变得陈旧。
集的速度
相反,让我们看看我们是否可以Canvas
通过改变其底层数据结构来优化自身。看看上面的问题,我们最初的想法之一可能是使用a Set
而不是a Array
。就像我们在*“Swift中的集合的力量”*中看到的那样,设置对数组的一大优势是插入和删除都可以始终以constant(O(1)
)时间执行,因为成员是通过哈希值存储的,而不是索引。
更新Canvas
以使用集合将使其看起来像这样:
struct Canvas {
var shapes: Set<Shape>
func render() -> Image {
let context = DrawingContext()
shapes.forEach(context.draw)
return context.makeImage()
}
mutating func remove(_ shape: Shape) {
shapes.remove(shape)
}
}
同样,上面的代码看起来可能正确,它甚至可以毫不费力地编译。然而,虽然我们已经解决了我们的删除问题,但是我们也失去了稳定的绘制顺序 - 因为,与数组不同,集合不会给我们一个保证的迭代顺序 - 在这种情况下这是一个破坏者,因为我们开始以看似随机的顺序绘制用户的形状。
索引索引
我们继续尝试。接下来,让我们看看我们是否可以Canvas
通过引入一个优化来Dictionary
让我们根据其ID查找任何形状的索引。我们首先使我们的shapes
private 数组能够控制元素的插入方式 - 使用新add
方法 - 每次添加新形状时,我们还将其索引添加到字典中:
struct Canvas {
private var shapes = [Shape]()
private var indexes = [Shape.ID : Int]()
func render() -> Image {
let context = DrawingContext()
shapes.forEach(context.draw)
return context.makeImage()
}
mutating func add(_ shape: Shape) {
let index = shapes.count
indexes[shape.id] = index
shapes.append(shape)
}
}
由于我们现在总是知道给定形状存储在哪个索引,我们可以在恒定时间内快速执行删除,就像我们使用集合时一样:
extension Canvas {
mutating func remove(_ shape: Shape) {
guard let index = indexes[shape.id] else {
return
}
shapes.remove(at: index)
indexes[shape.id] = nil
}
}
但是,我们的新Canvas
实现有一个非常严重的错误。每次我们删除一个形状时,我们实际上使所有高于我们刚删除的索引的索引无效 - 因为每个索引都将朝向数组的开头移动一步。虽然我们可以通过在每次删除后调整这些索引来解决这个问题,但这又会让我们回到O(N)
领域,这是我们从一开始就一直在努力避免的。
我们的最后一个实现确实有优点。通常,在这种情况下使用两个数据结构的组合可能是个好主意 - 因为我们经常能够利用一个数据结构的优势来弥补其他的弱点,反之亦然。
那么让我们再试一次,但这一次,让我们首先回顾一下我们的实际需求:
- 我们需要插入和移除以具有恒定的时间复杂度,并且应该可以在不知道其基础索引的情况下移除形状。
- 我们需要有保证的迭代次序,以便能够保持稳定的绘图顺序。
看看上面的要求,我们发现虽然我们需要一个稳定的迭代顺序,但我们实际上并不需要索引 - 这会使链表完全适合我们的用例。
链接列表由节点组成,其中每个节点包含到列表中下一个节点的引用(或链接),这意味着它可以以可预测的方式迭代 - 在删除元素时不需要任何索引更新。但是,Swift标准库(尚未)包含链接列表类型,因此如果我们想要使用它 - 我们首先必须构建它。
建立链表
让我们首先声明一个List
结构,它将跟踪列表中的第一个和最后一个节点。我们将在我们的类型之外将这两个属性设置为只读,以确保数据的一致性:
struct List<Value> {
private(set) var firstNode: Node?
private(set) var lastNode: Node?
}
接下来,让我们创建我们的Node
类型 - 我们将创建一个类,因为我们希望能够通过引用而不是值来引用节点。我们的列表将是双重链接的,这意味着每个节点将包含对其下一个邻居以及其前一个邻居的引用。每个节点也会存储一个Value
- 像这样:
extension List {
class Node {
var value: Value
fileprivate(set) weak var previous: Node?
fileprivate(set) var next: Node?
init(value: Value) {
self.value = value
}
}
}
我们制作上述previous
属性的原因weak
是为了避免保留周期,如果我们在两个方向都保持强引用,就会发生这种情况。要了解有关避免保留周期的更多信息,请查看“内存管理”基础知识文章。
这实际上是我们在启用链表存储值方面需要的所有代码。但这只是拼图的第一部分,就像任何其他集合一样,我们也希望能够迭代它并改变其内容。让我们从迭代开始,由于Swift非常面向协议的设计,可以通过遵循Sequence
和实现该makeIterator
方法轻松实现:
extension List: Sequence {
func makeIterator() -> AnyIterator<Value> {
var node = firstNode
return AnyIterator {
// Iterate through all of our nodes by continuously
// moving to the next one and extract its value:
let value = node?.value
node = node?.next
return value
}
}
}
由于我们上面的迭代非常简单,我们使用标准库AnyIterator
来避免必须实现自定义迭代器类型 - 对于更高级的用例,可以通过符合来实现IteratorProtocol
。
接下来,让我们添加用于改变链接列表的API - 从插入开始。我们将List
使用一个append
方法进行扩展,该方法为插入的值添加一个新节点,然后返回该节点 - 如下所示:
extension List {
@discardableResult
mutating func append(_ value: Value) -> Node {
let node = Node(value: value)
node.previous = lastNode
lastNode?.next = node
lastNode = node
if firstNode == nil {
firstNode = node
}
return node
}
}
上面我们使用@discardableResult
属性,该属性告诉编译器在未使用调用方法的结果时不生成任何警告 - 因为我们可能并不总是对创建的实际节点感兴趣。
由于链接列表不是基于索引,而是基于通过引用维护一系列值,因此实现删除只需更新已删除的节点next
和previous
邻居,现在指向彼此:
extension List {
mutating func remove(_ node: Node) {
node.previous?.next = node.next
node.next?.previous = node.previous
// Using "triple-equals" we can compare two class
// instances by identity, rather than by value:
if firstNode === node {
firstNode = node.next
}
if lastNode === node {
lastNode = node.previous
}
}
}
有了上述内容,我们的初始版本List
已经完成,我们已经准备好了。让我们更新Canvas
以使用我们的新列表 - 以及允许我们快速查找与给定形状ID对应的节点的字典 - 作为其新的数据结构组合:
struct Canvas {
private var shapes = List<Shape>()
private var nodes = [Shape.ID : List<Shape>.Node]()
func render() -> Image {
let context = DrawingContext()
shapes.forEach(context.draw)
return context.makeImage()
}
mutating func add(_ shape: Shape) {
nodes[shape.id] = shapes.append(shape)
}
mutating func remove(_ shape: Shape) {
guard let node = nodes.removeValue(forKey: shape.id) else {
return
}
shapes.remove(node)
}
}
我们现在既有快速插入和删除,也有可预测的迭代顺序,而无需在呼叫站点添加任何额外的复杂性 - 非常酷!而且,由于我们将新List
的类型设置为完全通用类型,因此我们现在可以在需要以线性方式存储无索引值时重复使用它。
结论
尽管数据结构非常基础,可以在各种编程语言中找到它们,但决定在任何给定情况下使用哪一种仍然需要大量的思考,测试和实验 - 特别是如果我们希望我们的代码保持高效因为它与越来越多的数据一起使用。
随着我们的需求的发展,任何给定情况的正确数据结构很可能会随着时间的推移而发生变化,有时使用多个数据结构的组合 - 而不仅仅是一个 - 可能是实现我们所需的性能特征的方法。
我们将在接下来的文章中继续探索数据结构的世界 - 特别是通过查看那些尚未在标准库中实现的数据结构。与许多其他事情一样,将我们的思维扩展到Swift之外有时需要在每种情况下选择正确的数据结构。
如果您有任何问题,意见或反馈,请通过加我们的交流群 点击此处进交流群 ,来一起交流或者发布您的问题,意见或反馈。
谢谢阅读~点个赞再走呗!?
原文地址 https://www.swiftbysundell.com/posts/picking-the-right-data-structure-in-swift