有序数组的一种实现

作者:Ole Begemann,原文链接,原文日期:2017-02-08
译者:四娘;校对:Cwift;定稿:CMB

上周的 Swift Talk 里,Florian 和 Chris 编写了一个有序数组类型 SortedArray:一个总是按照指定规则排序的数组。这很赞,因为它将多个[不变性](https://en.wikipedia.org/wiki/Invariant_(computer_science)编码到了类型系统里。用户可以使用这个类型去取代普通的 数组 ,而且不用担心忘记手动排序数组。

为了保持视频简短,Florian 和 Chris 省略掉了一些很实用的功能。我想给你展示一下这部分实用功能的实现。这些实现都不难编写,我的主要目的是让你明白借助标准库去实现一个紧贴需求的自定义集合类型是多么简单。

你可以去 GitHub 上查看全部代码 ,但下面会有更多讲解。

指定集合的协议

如视频所示,SortedArray 遵守了 RandomAccessCollection ,它可以让随机访问数组元素的操作变得更快,稍后实现一个高效的 Binary Search 的时候会用到它。

实现部分很直观,因为把所有东西都桥接到用来实际存储的数组那里。由于 Index 是 Int 类型,你甚至不用自己实现 index(_:offsetBy:)distance(from:to:) 函数,标准库已经提供了默认的实现。

SortedArray 不能遵守 MutableCollection 或者 RangeReplaceableCollection ,因为他们的 语义 -- 插入/替换特定位置的元素,跟我们保持元素有序的原则冲突。

字面量表达

SortedArray 也不遵守 ExpressibleByArrayLiteral ,即你不能像下面这么做:

let sorted: SortedArray = [3,1,2]

这个功能很好,但是你没办法给一个字面数组传递排序算法,并且 SortedArray 的元素必须遵守 Comparable 。因为 Swift 3 还不支持 conditional protocol conformance ,所以需要写成下面这样:

extension SortedArray: ExpressibleByArrayLiteral where Element: Comparable {
    ...
}

也许 Swift 4 中可以实现 conditional protocol conformance。

Binary search

使用有序数组的好处之一就是可以通过 Binary Search 快速找到某一个数组元素。在这里 Binary Search 的时间复杂度 是 log n 而不是线性的

为了实现该算法,我首先写了一个辅助函数 search(for:)。你可以去 GitHub 上查看完整代码 ;这里我想讨论一下返回的类型:

fileprivate enum Match<Index: Comparable> {
    case found(at: Index)
    case notFound(insertAt: Index)
}

extension SortedArray {
     
    /// 使用 Binary Search 找到 `newElement`
    ///
    /// - Returns: 如果 `newElement` 在数组里,就返回 `.found(at: index)`,
    ///   这里的 `index` 是数组里元素的位置
    ///   如果 `newElement` 不在这个数组里,就会返回 `.notfound(insertAt: index)`
    ///   这里的 `index` 是根据排序算法得出元素应该插入的位置
    ///   如果数组包含了多个元素,且都等于 `newElement`,那就无法保证哪一个会被找到
    ///   
    /// - Complexity: O(_log(n)_),这里的 _n_ 是数组的大小.
    fileprivate func search(for newElement: Element) -> Match<Index> {
        ...
    }
}

标准库里的 index(of:) 返回的是一个 Optional<Index>,没有找到的情况就会返回 nil。而 search(for:) 方法也类似,但它的返回值是一个自定义的枚举,无论是 .found 或者 .notFound 都会带上一个序号作为附加信息。这可以让我们在搜索和插入时使用一致的算法:返回的序号就是我们需要维持有序数组时插入元素的位置。

算法准备就绪之后,就可以开始实现 index(of:)contains(_:) 了:

extension SortedArray {

    /// 返回特定值在集合里第一次出现的位置
    ///
    /// - Complexity: O(_log(n)_),这里的 _n_ 是数组的大小
    public func index(of element: Element) -> Index? {
        switch search(for: element) {
        case let .found(at: index): return index
        case .notFound(insertAt: _): return nil
        }
    }

    /// 返回一个布尔值,表示这个序列是否包含给定的元素
    ///
    /// - Complexity: O(_log(n)_),_n_ 是数组的大小
    public func contains(_ element: Element) -> Bool {
        return index(of: element) != nil
    }
}

需要注意的是,这里的实现不止比标准库里的实现更高效,而且通用性更强. 标准库里这个方法还要求 where Iterator.Element: Comparable 的约束,而 SortedArray 总是拥有一个排序算法,所以不需要这样的约束。

插入元素

下一个任务是利用 binary search 的优势去提高插入元素的效率。我决定提供两个插入函数: 第一个会在正确的位置去插入单个元素,保持数组有序。它利用 binary search 去找到正确的插入位置,复杂度为 O(log n)。插入新元素到非空数组里,最糟糕的时间复杂度是 O(n),因为全部已有元素不得不移动位置去提供空间。

第二个函数可以插入一组序列。这里我选择先把所有元素都插入到数组最后,然后进行一次重新排序. 这比重复寻找正确的插入位置更快(如果插入的数组元素个数大于 log n)。

extension SortedArray {
    
    /// 插入一个新元素到数组里,并保持数组有序
    ///
    /// - Returns: 新元素插入的位置
    /// - Complexity: O(_n_)     这里的 _n_ 是数组大小
    ///    如果新元素插入到数组最后,时间复杂度下降到 O(_log n_)
    @discardableResult
    public mutating func insert(_ newElement: Element) -> Index {
        let index = insertionIndex(for: newElement)
        // 如果元素可以被插入到数组最后,则复杂度为 O(1)
        // 最糟糕的情况是 O(_n) (插入到最前面时)
        _elements.insert(newElement, at: index)
        return index
    }

    /// 插入 `elements` 里的所有元素到 `self` 里,保持数组有序
    /// 这会比每个元素都单独插入一遍更快
    /// 因为我们只需要重新排序一次
    ///
    /// - Complexity: O(_n * log(n)_),_n_ 是插入后数组的大小
    public mutating func insert<S: Sequence>(contentsOf newElements: S) where S.Iterator.Element == Element {
        _elements.append(contentsOf: newElements)
        _elements.sort(by: areInIncreasingOrder)
    }
}

其它优势

Chris 和 Florian 已经在这一集里做过展示,我们可以得到一个更高效的 min() max() 。因为最小值最大值分别是有序集合的第一个和最后一个元素:

extension SortedArray {
     /// 返回集合里的最小值
    ///
    /// - Complexity: O(1).
    @warn_unqualified_access
    public func min() -> Element? {
        return first
    }

    /// 返回集合里的最大值
    ///
    /// - Complexity: O(1).

    @warn_unqualified_access
    public func max() -> Element? {
        return last
    }
}

当你在类型的内部实现中调用这些函数,却没有显式地写明 self. 的前缀时,@warn_unqualified_access 会告诉编译器抛出一个警告 。这样可以帮助你避免混淆了这些函数与全局函数 min(::) max(::)

如同 index(of:)contains(_:) 一样,我们的 min()max() 更加通用,因为它们不需要元素是 Comparable 的. 我们获得了更高的效率,更少的约束。

只有协议要求的才可以自定义

这四个方法都不是 Sequence 和 Collection 协议里要求必须实现的,他们不在协议的定义里。他们只是拓展里的默认实现。结果就是,调用这些方法的时候都会是静态派发,因为他们不是 可自定义的

SortedArray 里的实现并不会重写默认实现(因为只要协议里定义的方法才可以被重写),他们只是附属品。当你直接使用 SortedArray 的时候,更加高效的实现会让你收益。但当它们作为泛型时将永远不会被调用。例如:

let numbers = SortedArray(unsorted: [3,2,1])
    // 这会直接调用 SortedArray.max()
    let a = numbers.max()

func myMax<S: Sequence>(_ sequence: S ) -> S.Iterator.Element?
    where S.Iterator.Element: Comparable {
    return sequence.max()
}

// 这种写法调用的是 Sequence.max() 了(更低效的版本)
let b = myMax(numbers)

我们没办法改变这个 "bug",swift-evolution 有讨论过让这些方法变成协议的一部分(我不确定这是不是一个好的做法)。

2017.02.09 更新: 我忘了 index(of:)contains(_:) 这些方法,现在还不是 Sequence 和 Collection 的一部分,因为他们需要 Iterator.Element 是 Equatable 的。而现在还没有方法去定义一个泛型协议。Brent Royal-Gordon 在 swift-evolution 里进行了相关的讨论并且提问泛型协议是否应该加入 Swift 里。

切片

我尝试着把 SortedArray 保存在一个 ArraySlice 而不是 Array 里,这么做的优势就是可以非常简单地把 SortedArray.SubSequence 定义为 ArraySlice。这会让切片操作变得非常简单,因为 sortedArray.prefix(5) 会直接返回另一个 SortedArray,而不是默认的 RandomAccessSlice

最后我还是决定放弃这种做法,因为长时间持有一个 ArraySlice 的实例不是一件好事. 就算只持有一个非常大的数组的切片,也会一直间接持有那个大的数组,这会导致非常高的内存占用,这是使用者不想看到的,就算基底 Array 的内存不会泄露,但切片还是会让它无法及时释放。

外部 Modules 引入的泛型类型的性能表现

如果你想在你的代码里使用 SortedArray (或者别的性能要求比较高的泛型),我建议你不要直接把它作为第三方 module 引入,而是 直接把源代码文件加入到你的 module 里

就 Swift 3 而言,Swift 无法在跨 Module 的情况下表现出泛型类型的优势。换而言之,如果你在代码里使用了 SortedArray<Int>,并且 SortedArray 是定义在另一个 Module 的时候,编译器无法为元素为 Int 的 Array 优化生成代码,只能按照常规的方式,将每一个泛型值打包到一个容器里,然后通过 witness table 进行方法派发。这很容易造成你的代码在执行时被拖慢 一到两个数量级

当前版本的 Swift 编译器无法约束从外部 Module 引入的泛型(标准库除外)。…这个限制会让外部 Module 引入的集合类型性能大幅下降。特别当集合中的元素是简单的,被极度优化的值类型,例如 Int,甚至是 String。 依赖引入的 Module,你的集合装填基础数据类型时性能会有 10-200 倍的下降。

标准库是唯一一个例外,标准库里的类型对于任何 Module 都是可见的。

我希望 Swift 编译器团队可以找到一个方法解决这个问题。虽然我不知道该怎么做。编译器现在加入了一个
非正式的修饰符 @_specialize (可能在 之后会加入一个新的语法 )。给一个方法添加这个修饰符时,相关的类型就告诉编译器为自己生成特殊的代码。目前正在开发的版本里,这个修饰符好像支持使用 _Trivial64 去把所有不那么重要的值类型都封装成相同的大小。

总结

完整的实现 总共两百多行,包括注释。

就像你看到的,自定义集合类型有很多需要考量的东西。而且我们都只考虑了接口的设计,我们甚至还没接触底层的实现。但我觉得这些付出都是有回报的。我们获得了一个行为和内建集合类型完全一致的类型,兼容序列和集合操作的同时还会根据算法自我变化。

虽然跨 Module 使用泛型类型确实对于性能有很大的影响。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,409评论 25 707
  • 随着理财观念逐渐深入人心,人们都开始学着进行理财投资,挑选合适的理财产品促进资产增值。 在互联网技术普及化的今天,...
    0df811f3735b阅读 258评论 1 0
  • 无谓的损耗,无趣的浏览碎片,消耗了大把好时光,每天没点长进,早上热醒,睡下看手机,洗头发,早早去上班,一天又开始了...
    linglingchai阅读 152评论 0 0
  • Android组件Activity 什么是Activity 首先我们要知道Activity是一个什么东西)。Act...
    Soda_Liu阅读 392评论 0 0