WWDC 2018: Embracing Algorithm (1)

煞碼特發表於2019-02-28

Session 連結

前言

作為一個 iOS App 開發人員, 經常會聽到這樣的吐槽, 做一個 App 主要是 UI 佈局和動畫, 平時基本上都用不到演算法, 為啥面試的時候總喜歡考演算法?

自己也有過這樣的疑惑, 專案中確實很少使用到演算法, 一般就是常用的幾種設計模式用熟, MVC 和 MVVM 選一個, 然後就開始各種第三方庫, 難一點的可能會遇到一些多執行緒的問題或者元件化開發?

放出 PPT 裡面的一張圖感受下, 確實很好的總結了 iOS App 開發的精髓.

App架構

但是看過這個 Session 之後, 算是得到一些啟示, 一個程式設計師的自我修養最終還是繞不過演算法, 程式碼的優雅和高效始終是我們所追求的, 這無關乎業務和麵試.

介紹演講者

覺得有必要介紹下演講者

WWDC 2015 時候第一次看到 Dave Abrahams 的演講, 當時講的是這個 Session “Protocol-Oriented Programming in Swift”.

在維基百科上搜了下, Dave Abrahams 之前就已經是 C++ STL 的貢獻者之一了, 13年加入 Apple 在 Swift 的核心庫小組裡面擔任 TL. 所以這篇演講也引用了一些 C++ STL 中的哲學思想.

Dave Abrahams 的演講方式也挺有意思的, 採用一種自編自導自演的方式, 創造了一個苛刻的老學究的角色, 模擬對話, 然後引出演講的主題. 將本該嚴謹死板的演算法講出了一些趣味和發人深省的地方.

演講原始碼

除了 Session 視訊, PPT 當然也是要下載的, 得益於 Swift 的開源, PPT 中的程式碼實現都可以在 GitHub 上找到.

GitHub地址: https://github.com/apple/swift, 當然 master 分支上面是沒有的, 切到 swift-4.2-branch-06-11-2018 分支然後在 swift/stdlib/public/core/RangeReplaceableCollection.swift 檔案裡面可以找到 removeAll 方法, 裡面就是 PPT 中講到的實現.

但是比較奇怪的是在 swift-4.2-branch 分支上面這個實現已經變了, 估計 Apple 的開發人員一直在優化這塊的實現, 畢竟4.2目前還不是穩定版本.

一個 Bug 引起的思考

Session 以一個圖形 App 作為例子, 看一下這個 App:

App

然後引出一個 bug, 這個 bug 也是我們新手開發 App 的時候比較容易犯錯的一個問題, 對於陣列邊遍歷邊刪除的問題.

看一下問題:

p-1

如圖, 圖中有10個圖形, 其中我們選中第8個將其刪除, 但是刪除的時候 crash 了, why?

看一下問題程式碼:

extension Canvas {
    mutating func deleteSelection() {
        for i in 0..<shapes.count {
            if shapes[i].isSelected {
                shapes.remove(at: i)
            }
        }
    }
}
複製程式碼
p-2

遍歷的範圍 0..<shapes.count 一開始就已經確定了(10個元素), 當遍歷到第8個圖形的時候, 發現其被選中則進行 remove 操作, 後面兩個元素往前補位, 這個時候陣列裡面只有9個元素了, 所以再按照最開始的範圍遍歷到第十個元素時組數越界產生 crash.

因為平時使用 Objective-C 比較多, 我們結合 Objective-C 來看看, 我們熟悉的陣列遍歷方式有:

  1. 普通 for 迴圈遍歷
    for (NSInteger i = 0; i < shapes.count; i++) {
        // do something
    }
複製程式碼
  1. for-in 遍歷 (這種方式在邊遍歷邊刪除的時候會拋異常).
    for (Shape *shape in shapes) {
        // do something
    }
複製程式碼
  1. block 列舉遍歷
    [shapes enumerateObjectsUsingBlock:^(Shape * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        // do something
    }];
複製程式碼

以上除了第二種方式會拋異常以外, 1和3這兩種都能”混”過去, 為什麼是”混”, 我們來分析下, 假設這個 bug 中的第9個元素也是被選中的, 那麼當遍歷到第8個圖形的時候, 發現其被選中則進行 remove 操作, 後面兩個元素往前補位, 但是此時下標並沒有處理, 下一次遍歷會直接從第9個元素開始(也就是原先的第10個元素), 從而把原生的第9個元素直接跳過去了, 出現了漏刪除的行為.

此類問題我出過一個面試題, 面試題不是很難, 有近一半的面試者出現過邊遍歷邊刪除的問題(為啥出這個題, 因為我也是踩過坑的~).

好了回到正題上, 那麼原因找到了, 具體怎麼個解法呢? Session 中還繞了好幾個彎, 我們先來看第一個彎:

extension Canvas {
    mutating func deleteSelection() {
        var i = 0
        while i < shapes.count {
            if shapes[i].isSelected {
                shapes.remove(at: i)
            }
            i += 1
        }
    }
}
複製程式碼

這個改法和普通的 for 迴圈類似, 只是改成了 while 迴圈, 問題也比較明顯, 假設如果第9個元素也同樣被選中, 就會存在漏刪的問題, 原因上面已經分析過了.

既然是因為下標沒有處理, 那麼處理下下標不就可以了? 第二個彎:

extension Canvas {
    mutating func deleteSelection() {
        var i = 0
        while i < shapes.count {
            if shapes[i].isSelected {
                shapes.remove(at: i)
            }
            else {
                i += 1
            }
        }
    }
}
複製程式碼

這個解法是可行的, 還有別的解法麼? 逆向思維下, 既然刪除一個元素之後, 後面的元素是往前進行補位的, 這樣影響的是正序遍歷時候的下標. 如果我們採用逆序遍歷是不是就不存在這個問題了? 第三個彎:

extension Canvas {
    mutating func deleteSelection() {
        for i in (0..<shapes.count).reversed() {
            if shapes[i].isSelected {
                shapes.remove(at: i)
            }
        }
    }
}
複製程式碼

其實我們一般修改 bug 的話至此就已經完事了, 甚至連逆向思考一下可能都不會去想, 其實這只是剛剛開始.

滑鼠移到 remove 方法, 按住 option 鍵然後點選檢視下文件, remove 方法居然是個 O(n) 複雜度的操作. 再加上外層的 while 迴圈, 整個方法的複雜度有O(n²), 看到這裡我也吃了一驚.

後面, 作者給我們科普了下演算法的複雜度還有 Mac 上字典中對於演算法的定義. 應該也是作為一個引子吧.

這個時候已經不是在解 bug 了, 上升了一個層次 – 程式碼優化, 先放程式碼:

extension Canvas {
    mutating func deleteSelection() {
        shapes.removeAll(where: { $0.isSelected })
    }
}
複製程式碼

程式碼精簡了很多, 語義也十分清晰, 這裡多了個 removeAll 方法, 這個方法應該是 Swift 4.2 新的方法, 之前的版本並沒有找到這個方法. 當然整個過程是值得我們學習的, 對於我們後續封裝自己的擴充套件方法也是很有啟發的.

如果你裝了 Xcode 10 可以點開 removeAll 的文件看一下, 複雜度為 O(n), 這裡是不是勾起了你的好奇心, 從 O(n²) -> O(n) 這個是怎麼辦到的? 如果是你自己優化了這個解法, 是不是這一整天都是神清氣爽的.

extension RangeReplaceableCollection where Self: MutableCollection {
  /// Removes all the elements that satisfy the given predicate.
  ///
  /// Use this method to remove every element in a collection that meets
  /// particular criteria. This example removes all the odd values from an
  /// array of numbers:
  ///
  ///     var numbers = [5, 6, 7, 8, 9, 10, 11]
  ///     numbers.removeAll(where: { $0 % 2 == 1 })
  ///     // numbers == [6, 8, 10]
  ///
  /// - Parameter predicate: A closure that takes an element of the
  ///   sequence as its argument and returns a Boolean value indicating
  ///   whether the element should be removed from the collection.
  ///
  /// - Complexity: O(*n*), where *n* is the length of the collection.
  @inlinable
  public mutating func removeAll(
    where predicate: (Element) throws -> Bool
  ) rethrows {
    if var i = try firstIndex(where: predicate) {
      var j = index(after: i)
      while j != endIndex {
        if try !predicate(self[j]) {
          swapAt(i, j)
          formIndex(after: &i)
        }
        formIndex(after: &j)
      }
      removeSubrange(i...)
    }
  }
}
複製程式碼

上半部分就先講到這, 下半部分還會用到這個演算法, 到時候詳細闡述下.

最後放上一句 PPT 中的至理箴言 “No Raw Loops”. 怎麼做到這一點? 那就是對 Swift 標準庫要做到如數家珍.

相關文章