【譯】Swift演算法俱樂部-佇列
本文是對ofollow,noindex">Swift Algorithm Club 翻譯的一篇文章。
Swift Algorithm Club 是raywenderlich.com 網站出品的用Swift實現演算法和資料結構的開源專案,目前在GitHub上有18000+:star:️,我初略統計了一下,大概有一百左右個的演算法和資料結構,基本上常見的都包含了,是iOSer學習演算法和資料結構不錯的資源。
:octopus:andyRon/swift-algorithm-club-cn 是我對Swift Algorithm Club,邊學習邊翻譯的專案。歡迎有興趣學習演算法和資料結構,有時間的小夥伴一起參與翻譯,歡迎issue,或者直接提交pull request。
本文的翻譯原文和程式碼可以檢視:octopus:swift-algorithm-club-cn/Queue
這個話題已經有個輔導文章
佇列的本質是一個數組,但只能從隊尾新增元素,從隊首移除元素。這保證了第一個入隊的元素總是第一個出隊。先到先得!
為什麼要這樣做呢?在很多演算法的實現中,你可能需要將某些物件放到一個臨時的列表中,之後再將其取出。通常加入和取出元素的順序非常重要。
佇列可以保證元素存入和取出的順序是先進先出(first-in first-out, FIFO)的,第一個入隊的元素總是第一個出隊,公平合理!
另外一個非常類似的資料結構是棧 ,它是一個後進先出(last-in, first-out, LIFO)的結構。
舉例來說,我們將一個數字入隊:
queue.enqueue(10)
佇列現在為[ 10 ]
。再將下一個數字入隊:
queue.enqueue(3)
佇列現在為[ 10, 3 ]
。再加入一個數字:
queue.enqueue(57)
佇列現在為[ 10, 3, 57 ]
。現在我們將第一個元素出隊:
queue.dequeue()
這條語句返回數字10
,因為這是我們入隊的第一個元素。佇列現在是[ 3, 57 ]
。剩下的元素都往前移動一位。
queue.dequeue()
這條語句返回3
,下次呼叫dequeue
將返回57
,以此類推。如果佇列為空,出隊操作將返回nil
,在有些實現中,會觸發一個錯誤資訊。
注意:佇列並不總是最好的選擇,如果加入和刪除元素的順序無所謂的話,你可以選擇使用棧 來達到目的。棧更加簡單快速。
程式碼
下面給出了一個簡單粗暴的佇列實現。它只是簡單地包裝了一下自帶的陣列,並提供了入隊(enqueue)、出隊(dequeue)和取得隊首元素(peek)三個操作:
public struct Queue<T>{ fileprivate var array = [T]() public var isEmpty: Bool { return array.isEmpty } public var count: Int { return array.count } public mutating func enqueue(_element: T) { array.append(element) } public mutating func dequeue() -> T? { if isEmpty { return nil } else { return array.removeFirst() } } public var front: T? { return array.first } }
上面實現的佇列只是可以正常工作,但並沒有任何的優化。
入隊操作的時間複雜度為O(1) ,因為在陣列的尾部新增元素只需要固定的時間,跟陣列的大小無關。
你可能會好奇為什麼在陣列尾部新增元素的時間複雜度為O(1) ,或者說只需要固定的時間。這是因為在 Swift 的內部實現中,陣列的尾部總是有一些預設的空間可供使用。如果我們進行如下操作:
var queue = Queue<String>() queue.enqueue("Ada") queue.enqueue("Steve") queue.enqueue("Tim")
則陣列可能看起來想下面這樣
[ “Ada”, “Steve”, “Tim”, xxx, xxx, xxx ]
xxx
代表已經申請,但還沒有使用的記憶體。在尾部新增一個新的元素就會用到下一塊未被使用的記憶體:
[ “Ada”, “Steve”, “Tim”, “Grace”, xxx, xxx ]
這只是簡單的拷貝記憶體的工作,只需要固定的常量時間。
當然,陣列尾部的未使用記憶體的大小是有限的,如果最後一塊未使用記憶體也被佔用的時候,再新增元素會使得陣列重新調整大小來獲取更多的空間。
重新調整的過程包括申請新的記憶體,將已有資料遷移到新記憶體中。這個操作的時間複雜度是O(n) ,所以是一個較慢的操作。但考慮到這種情況並不常見,所以,這個操作的時間複雜度依然是O(1) 的,或者說是近似O(1) 的。
但出隊操作就有點不一樣了。出隊操作是將陣列頭部的元素移除,而不是尾部。這個操作的時間複雜度永遠都是O(n) ,因為這會導致記憶體的移位操作。
在我們的例子中,將"Ada"
出隊會使得"Steve"
接替"Ada"
的位置;"Tim"
接替"Steve"
的位置;"Grace"
接替"Tim"
的位置:
出隊前[ “Ada”, “Steve”, “Tim”, “Grace”, xxx, xxx ]
///
///
///
///
出隊後[ “Steve”, “Tim”, “Grace”, xxx, xxx, xxx ]
在記憶體中移動這些元素的時間複雜度永遠都是O(n) ,所以我們實現的簡單佇列對於入隊操作的效率是很高的,但對於出隊操作的效率卻較為低下。
更加高效的佇列
為了讓佇列的出隊操作更加高效,我們可以使用和入隊所用的相同小技巧,保留一些額外的空間,只不過這次是在隊首而不是隊尾。這次我們需要手動編碼實現這個想法,因為 Swift 內建陣列並沒有提供這種機制。
我們的想法如下:每當我們將一個元素出隊,我們不再將剩下的元素向前移位(慢),而是將其標記為空(快)。在將"Ada"
出隊後,陣列如下:
[ xxx, “Steve”, “Tim”, “Grace”, xxx, xxx ]
"Steve"
出隊後,陣列如下:
[ xxx, xxx, “Tim”, “Grace”, xxx, xxx ]
這些在前端空出來的位子永遠都不會再次使用,所以這是些被浪費的空間。解決方法是將剩下的元素往前移動來填補這些空位:
[ “Tim”, “Grace”, xxx, xxx, xxx, xxx ]
這就需要移動記憶體,所以這是一個O(n) 操作,但因為這個操作只是偶爾發生,所以出隊操作平均時間複雜度為O(1)
下面給出了改進版的佇列的時間方式:
public struct Queue<T>{ fileprivate var array = [T?]() fileprivate var head = 0 public var isEmpty: Bool { return count == 0 } public var count: Int { return array.count - head } public mutating func enqueue(_element: T) { array.append(element) } public mutating func dequeue() -> T? { guard head < array.count, let element = array[head] else { return nil } array[head] = nil head += 1 let percentage = Double(head)/Double(array.count) if array.count > 50 && percentage > 0.25 { array.removeFirst(head) head = 0 } return element } public var front: T? { if isEmpty { return nil } else { return array[head] } } }
現在陣列儲存的元素型別是T?
,而不是先前的T
,因為我們需要某種方式來將陣列的元素標記為空。head
變數用於儲存佇列首元素的下標值。
絕大多數的改進都是針對dequeue()
函式,在將隊首元素出隊時,我們首先將array[head]
設定為nil
來將這個元素從陣列中移除。然後將head
的值加一,使得下一個元素變成新的隊首。
陣列從這樣:
[ “Ada”, “Steve”, “Tim”, “Grace”, xxx, xxx ]
head
變成這樣:
[ xxx, “Steve”, “Tim”, “Grace”, xxx, xxx ]
head
這就像在某個超市,在那裡排隊結賬的人保持不動,而收銀員從頭往隊尾移動來挨個結賬。
當然,如果我們從不移除隊首的空位,隨著不斷地入隊和出隊,佇列所佔空間將不斷增長。為了週期性地清理無用空間,我們編寫了如下程式碼:
let percentage = Double(head)/Double(array.count) if array.count > 50 && percentage > 0.25 { array.removeFirst(head) head = 0 }
這段程式碼計算了隊首空餘的元素佔陣列總元素的百分比,如果空餘元素超過 25%,我們就進行一波清理。但是,如果佇列的長度過小,我們也不想頻繁地清理空間,所以在清理空間之前,佇列中至少要有 50 個元素。
注意:50這個數字只是我憑空捏造的一個數字,在實際的專案中,你應該根據專案本身來選定一個合情合理的值。
To test this in a playground, do:
如果想在 Playground 中測試,可以參考下面的程式碼:
var q = Queue<String>() q.array// [] empty array q.enqueue("Ada") q.enqueue("Steve") q.enqueue("Tim") q.array// [{Some "Ada"}, {Some "Steve"}, {Some "Tim"}] q.count// 3 q.dequeue()// "Ada" q.array// [nil, {Some "Steve"}, {Some "Tim"}] q.count// 2 q.dequeue()// "Steve" q.array// [nil, nil, {Some "Tim"}] q.count// 1 q.enqueue("Grace") q.array// [nil, nil, {Some "Tim"}, {Some "Grace"}] q.count// 2
為了測試佇列的自動調整特性,將下面這段程式碼:
if array.count > 50 && percentage > 0.25 {
替換為:
if head > 2 {
現在,如果你再次執行出隊操作,陣列將看起來像下面這樣:
q.dequeue()// "Tim" q.array// [{Some "Grace"}] q.count// 1
在陣列前面的nil
已經被移除了,陣列本身也沒有空間浪費了。新版本的佇列實現並沒有比初版複雜很多,但現在出隊操作的複雜度已經從當初的O(n)
變為了現在的O(1)
,只是因為我們在陣列的使用策略上耍了一點小心機。
擴充套件閱讀
事實上,佇列還有很多種其他的實現方式,例如可以使用連結串列 、環形緩衝區 或是堆 來實現。
佇列有很多變體,包括雙端佇列 ,一個兩端都可以出隊和入隊的佇列;優先佇列 ,一個有序的佇列,最重要的元素排在隊首。
作者:Matthijs Hollemans;譯者:KSCO
校隊:Andy Ron