隊列(Queue)數據結構是先進先出(FIFO,first-in, first-out)的線性表,先進入隊列的元素,最先被移除。隊列適用于移除順序需與添加順序保持一致的情況。
這篇文章將介紹隊列的常用操作,使用多種方式實現隊列,并分析其時間復雜度。
1. 常用操作
下面是為創(chuàng)建隊列所提供的協(xié)議:
public protocol Queue {
associatedtype Element
mutating func enqueue(_ element: Element) -> Bool
mutating func dequeue() -> Element?
var isEmpty: Bool { get }
var peek: Element? { get }
}
上述協(xié)議定義了隊列的核心操作:
- 入隊 enqueue:向隊列尾部添加元素。操作成功返回 true;反之,返回 false。
- 出隊 dequeue:移除隊列頭部元素,并返回移除的元素。
- isEmpty:檢查隊列是否為空。
- peek:返回隊列頭部元素,但并不移除。
隊列只能從頭部移除、尾部添加,不關心中間元素。如果有其他類型操作,數組可能更能滿足你的需要。
下面將介紹四種不同創(chuàng)建隊列的方式:
- 數組(Array)
- 雙向鏈表(Doubly linked list)
- 圓形緩沖區(qū)(Ring Buffer)
- 使用兩個棧(Stack)。
2. 使用數組實現隊列
Swift 標準庫提供了一些高度優(yōu)化的基本數據結構(如有序的數組),可以使用這些數據結構構建更為高級的數據結構。
這一部分將使用數組創(chuàng)建隊列:
創(chuàng)建 QueueArray.swift 文件,添加以下代碼:
/// 使用數組實現隊列
public struct QueueArray<T>: Queue {
// 存儲使用數組
private var array: [T] = []
public init() { }
}
這里定義了通用類型結構體,遵守了Queue
協(xié)議。
2.1 隊列狀態(tài)
繼續(xù)向 QueueArray 添加以下代碼:
public var isEmpty: Bool {
array.isEmpty
}
public var peek: T? {
array.first
}
使用數組已有功能可以很方便的查看隊列是否為空,查看隊列第一個元素。這些操作的復雜度為O(1)
。
2.2 入隊 Enqueue
向隊列尾部添加元素只需調用數組的append()
方法。添加以下代碼:
/// 入隊元素
/// - Parameter element: 要入隊的元素
/// - Returns: 入隊成功,返回true;入隊失敗,返回false。
public mutating func enqueue(_ element: T) -> Bool {
array.append(element)
return true
}
入隊操作的平均復雜度是O(1)
。
上圖中,添加了Mic后還有兩個空間。添加兩個元素后,數據將被填滿。再嘗試添加元素時,數據將會擴容。
擴容時需要重新分配內存,將數組原來元素復制過來。每次擴容時容量都會翻倍,因此,擴容發(fā)生的頻率并不高。入隊的平均復雜度是O(1)
,最壞情況復雜度是O(n)
。
2.3 出隊 Dequeue
使用以下代碼出隊元素:
/// 出隊元素
/// - Returns: 出隊的元素。當隊列為空時,返回nil。
public mutating func dequeue() -> T? {
isEmpty ? nil : array.removeFirst()
}
從數據最前面移除元素是線性時間操作,它需要后面元素前移。出隊復雜度是O(n)
。
2.4 實戰(zhàn)
為了方便測試,讓 QueueArray 遵守CustomStringConvertible
協(xié)議,代碼如下:
extension QueueArray: CustomStringConvertible {
public var description: String {
String(describing: array)
}
}
使用以下代碼對隊列進行測試:
public func testQueueArray() {
var queue = QueueArray<String>()
queue.enqueue("Ray")
queue.enqueue("Brian")
queue.enqueue("Eric")
queue
queue.dequeue()
queue
queue.peek
}
根據控制臺輸出,查看隊列表現是否符合預期。
2.5 復雜度
基于數組的隊列大部分操作都是常數時間的,只有dequeue()
的時間復雜度是線性時間。
操作 | 平均復雜度 | 最壞情況復雜度 |
---|---|---|
enqueue | O(1) | O(n) |
dequeue | O(n) | O(n) |
使用基于數組的隊列非常簡單,但也有一些缺陷。從最前面移除元素需移動后面所有元素,性能太低,數據量變大時尤為明顯。數組填滿時需擴容,且擴容后占用很多空閑空間。隨著時間推移,它占用空間可能越來越大。
這些不足可以避免嗎?下面看下基于鏈表的隊列。
3. 基于雙向鏈表的隊列
雙向鏈表與單向鏈表有些類似,只是結點同時包含了指向上一個結點的指針。
由于這篇文章的重點不在雙向鏈表,你可以直接在這里獲取雙向鏈表的實現。如果你對鏈表不了解,可以查看我的另一篇文章鏈表 LinkedList。
創(chuàng)建 QueueLinkedList.swift 文件,并添加以下類:
public class QueueLinkedList<T>: Queue {
// 內部存儲使用雙向鏈表
private var list = DoublyLinkedList<T>()
public init() { }
}
3.1 入隊 enqueue
使用下面代碼向隊列添加元素:
/// 入隊。
/// - Parameter element: 要入隊的元素
/// - Returns: 入隊成功,返回 true;反之,返回 false。使用基于鏈表的隊列,不會入隊失敗。
public func enqueue(_ element: T) -> Bool {
list.append(element)
return true
}
雙向鏈表會更新 tail 節(jié)點的上一個、下一個指針,使其指向新添加的節(jié)點。由于只需更改指針,復雜度為O(1)
。
3.2 出隊 Dequeue
使用下面代碼移除頭部元素:
/// 出隊,并返回出隊的元素。
/// - Returns: 隊列為空時,返回空;反之,返回移除的元素。
public func dequeue() -> T? {
guard !list.isEmpty, let element = list.first else {
return nil
}
return list.remove(element)
}
從頭部移除元素時只需操作指針,復雜度為O(1)
。與基于數組的隊列相比,無需移動元素,只需更新前面兩個節(jié)點的next
和previous
指針。
3.3 隊列狀態(tài)
與數組類似,借助DoublyLinkedList
的peek
、isEmpty
屬性提供隊列首部元素、是否為空功能。
public var peek: T? {
list.first?.value
}
public var isEmpty: Bool {
list.isEmpty
}
3.4 實戰(zhàn)
使用下面代碼檢驗隊列功能:
public func testQueueDoublyLinkedList() {
let queue = QueueLinkedList<String>()
queue.enqueue("Ray")
queue.enqueue("Brian")
queue.enqueue("Eric")
queue
queue.dequeue()
queue
queue.peek
}
這里的輸出應與 QueueArray 隊列輸出相同。
3.5 復雜度
下面是基于雙向鏈表的隊列復雜度:
操作 | 平均復雜度 | 最壞情況復雜度 |
---|---|---|
enqueue | O(1) | O(1) |
dequeue | O(1) | O(1) |
QueueArray 主要問題是 dequeue 元素為線性時間,而基于雙向鏈表的隊列 dequeue 操作為恒定時間,即復雜度為O(1)
。
上面表格并未體現出基于雙向鏈表隊列的缺點。盡管復雜度是O(1)
,但它帶有額外開銷。每個元素都需要開辟額外空間存儲指向上一個、下一個結點的指針,且每次創(chuàng)建元素時,都需要分配內存。與之相反,基于數組的隊列一次開辟大量內存空間,其比多次開辟小空間性能更好。
是否可以避免多次分配內存空間,又保持O(1)
復雜度?如果隊列大小固定,可以使用環(huán)形緩沖區(qū)(Ring Buffer,也稱為圓形緩沖區(qū) circular buffer、圓形隊列 circular queue、循環(huán)緩沖區(qū) cyclic buffer)實現。例如,玩游戲時,使用基于環(huán)形緩沖區(qū)的隊列記錄游戲參與者的次序。
4. 基于環(huán)形緩沖區(qū)的隊列
環(huán)形緩沖區(qū) ring buffer是固定大小的數組。
4.1 Ring Buffer 工作原理
下面介紹如何使用 ring buffer 實現隊列。
創(chuàng)建大小固定為四的環(huán)形緩沖區(qū),它的兩個指針用途如下:
- read 指針跟蹤 queue 的頭部。
- write 指針跟蹤下一個可用空間,覆蓋掉已經被讀取的元素。
enqueue 元素如下:
每次入隊元素 write 指針加一。繼續(xù)入隊元素:
Write 指針在 read 指針前面兩步,意味著隊列不為空。
下面出隊兩個元素:
出隊就是 read 指針讀取 ring buffer 元素。
再次入隊元素以填滿隊列:
Write 指針到達尾部時,再次從頭部開始,這也是這種數據結構為何被稱為環(huán)形緩沖區(qū) circular buffer。
再次 dequeue 兩個元素:
Read 指針也指向了頭部,與 write 指針相遇,這時隊列為空。
下面是 RingBuffer 的實現:
public struct RingBuffer<T> {
// 使用數組存儲
private var array: [T?]
private var readIndex = 0
private var writeIndex = 0
public init(count: Int) {
array = Array<T?>(repeating: nil, count: count)
}
public var first: T? {
array[readIndex]
}
/// 入隊。
/// - Parameter element: 要入隊的原
/// - Returns: 入隊成功時,返回 true;反之,返回 false。當隊列滿時,入隊會失敗。
public mutating func write(_ element: T) -> Bool {
if !isFull {
array[writeIndex % array.count] = element
writeIndex += 1
return true
} else {
return false
}
}
/// 出隊
/// - Returns: 隊列為空時,返回 nil;反之,返回出隊的元素。
public mutating func read() -> T? {
if !isEmpty {
let element = array[readIndex % array.count]
readIndex += 1
return element
} else {
return nil
}
}
private var availableSpaceForReading: Int {
writeIndex - readIndex
}
public var isEmpty: Bool {
availableSpaceForReading == 0
}
private var availableSpaceForWriting: Int {
array.count - availableSpaceForReading
}
public var isFull: Bool {
availableSpaceForWriting == 0
}
}
extension RingBuffer: CustomStringConvertible {
public var description: String {
let values = (0..<availableSpaceForReading).map {
String(describing: array[($0 + readIndex) % array.count]!)
}
return "[" + values.joined(separator: ", ") + "]"
}
}
在QueueRingBuffer.swift
文件中添加以下代碼:
public struct QueueRingBuffer<T>: Queue {
/// 使用環(huán)形緩沖區(qū)存儲
private var ringBuffer: RingBuffer<T>
/// 初始化隊列
/// - Parameter count: 指定隊列的固定大小
public init(count: Int) {
ringBuffer = RingBuffer<T>(count: count)
}
public var isEmpty: Bool {
ringBuffer.isEmpty
}
public var peek: T? {
ringBuffer.first
}
}
為了遵守Queue
協(xié)議,這里也創(chuàng)建了isEmpty
和peek
屬性。這里并未暴露RingBuffer
類,而是提供了接口。isEmpty
和peek
操作的復雜度都是O(1)
。
4.2 入隊 enqueue
使用以下方法入隊元素:
/// 入隊
/// - Parameter element: 要入隊的元素
/// - Returns: 入隊成功時,返回 true;反之,返回 false。當隊列滿時,入隊會失敗。
public mutating func enqueue(_ element: T) -> Bool {
ringBuffer.write(element)
}
直接調用RingBuffer
的write(_:)
方法即可。由于 Ring Buffer 有固定大小,需根據入隊是否成功返回true
、false
。
4.3 出隊 dequeue
使用以下方法出隊元素:
/// 出隊
/// - Returns: 隊列為空時,返回 nil;反之,返回出隊的元素。
public mutating func dequeue() -> T? {
ringBuffer.read()
}
4.4 實戰(zhàn)
使用下面代碼檢驗基于環(huán)形緩沖區(qū)的隊列:
public func testQueueRingBuffer() {
var queue = QueueRingBuffer<String>(count: 3)
queue.enqueue("Ray")
queue.enqueue("Brian")
queue.enqueue("Eric")
queue
queue.dequeue()
queue
queue.peek
}
上面方法輸出與使用基于數組、雙向鏈表的隊列一致。
4.5 復雜度
基于環(huán)形緩沖區(qū)的隊列復雜度如下:
操作 | 平均復雜度 | 最壞情況復雜度 |
---|---|---|
enqueue | O(1) | O(1) |
dequeue | O(1) | O(1) |
基于環(huán)形緩沖區(qū)隊列復雜度與基于雙向鏈表隊列復雜度相同,但因為環(huán)形緩沖區(qū)有固定大小,enqueue 可能失敗。
截止目前,分別介紹了基于數組、雙向鏈表、環(huán)形緩沖區(qū)的隊列。下面將介紹一種基于兩個堆棧的隊列,它在內存讀取、存儲方面優(yōu)于雙向鏈表,也無需像環(huán)形緩沖區(qū)那樣有固定大小。
5. 基于兩個棧的隊列
在QueueStack.swift
文件中添加以下結構體:
public struct QueueStack<T> : Queue {
// 使用兩個數組存儲
private var leftStack: [T] = []
private var rightStack: [T] = []
public init() { }
}
基于兩個棧的隊列整體邏輯很簡單,當 enqueue 元素時,進入 Right Stack;當 dequeue 元素時,將 Right Stack 放入 Left Stack,再從 Left Stack pop 元素,這樣就滿足了先進先出原則。
為了遵守Queue
協(xié)議,添加以下屬性:
public var isEmpty: Bool {
// 隊列是否為空,只有兩個棧都為空時才為空。
leftStack.isEmpty && rightStack.isEmpty
}
public var peek: T? {
// 如果leftStack不為空,返回它的最后一個;如果為空,返回rightStack第一個。
!leftStack.isEmpty ? leftStack.last : rightStack.first
}
isEmpty
和peek
操作復雜度都是O(1)
。
5.1 入隊 enqueue
使用以下方法入隊:
public mutating func enqueue(_ element: T) -> Bool {
// 入隊時每次向 rightStack 添加
rightStack.append(element)
return true
}
添加元素的復雜度是O(1)
。
5.2 出隊 dequeue
使用下面代碼出隊元素:
public mutating func dequeue() -> T? {
if leftStack.isEmpty { // 如果 leftStack 為空,將rightStack反轉后存入leftStack,并清空rightStack。
leftStack = rightStack.reversed()
rightStack.removeAll()
} else { // leftStack不為空,則移除最后一個元素。
return leftStack.popLast()
}
}
只有當 leftStack 為空時,才會將 rightStack 棧內元素反轉后移動到 leftStack。
雖然反轉數組復雜度是
O(n)
,但出隊平均復雜度依然是O(1)
。例如,左右兩個棧均有很多元素,只有左側棧為空時,才會將右側棧反轉后移動到左側棧,之后繼續(xù)移除左側棧元素。反轉操作發(fā)生頻率很低。
5.3 實戰(zhàn)
使用下面代碼檢驗基于兩個棧實現的隊列:
public func testQueueStacks() {
var queue = QueueStack<String>()
queue.enqueue("Ray")
queue.enqueue("Brian")
queue.enqueue("Eric")
queue
queue.dequeue()
queue
queue.peek
}
上面方法輸出與使用基于數組、雙向鏈表、圓形緩沖區(qū)的隊列一致。
5.4 復雜度
基于兩個棧隊列復雜度如下:
操作 | 平均復雜度 | 最壞情況復雜度 |
---|---|---|
enqueue | O(1) | O(n) |
dequeue | O(1) | O(n) |
與基于數組的隊列相比,基于兩個棧的隊列將出隊時間復雜度從O(n)
降低到了O(1)
。
與基于環(huán)形緩沖區(qū)的隊列相比,基于兩個棧的隊列沒有固定大小。當右側棧需要反轉或容量變滿時,它的復雜度是O(n)
,但這些情況發(fā)生的頻率并不高。
由于數組內存空間是連續(xù)的,首次訪問時會添加到緩存,后續(xù)讀取也會命中緩存。因此,在存取方面基于兩個棧的隊列,性能優(yōu)于基于雙向鏈表的隊列。如下圖所示:
鏈表元素內存空間并未連續(xù),會增加查找時間:
6. 隊列算法題
這一部分介紹三道隊列算法題。
6.1 記錄游戲次序
假設你在和朋友玩游戲,但大家都記不住該誰玩了??梢詣?chuàng)建一個管理者,記錄游戲次序。
隊列數據結構適合解決上述問題。
下面是記錄次序的協(xié)議:
protocol BoardGameManager {
associatedtype Player
mutating func nextPlayer() -> Player?
}
為QueueLinkedList
添加以下 extension:
extension QueueLinkedList: BoardGameManager {
public typealias Player = T
public func nextPlayer() -> T? {
// 通過dequeue獲取下一個選手,如果獲取不到,直接返回空。
guard let person = dequeue() else { return nil }
// enqueue 同一位選手,會將其添加到尾部。
enqueue(person)
return person
}
}
使用以下代碼進行測試:
var queue = QueueLinkedList<String>()
queue.enqueue("Vincent")
queue.enqueue("Remel")
queue.enqueue("Lukiih")
queue.enqueue("Allison")
print(queue)
print("==== boardgame ====")
queue.nextPlayer()
print(queue)
queue.nextPlayer()
print(queue)
queue.nextPlayer()
print(queue)
queue.nextPlayer()
print(queue)
使用不同的實現方式,它的時間復雜度可能不同。上面的算法使用了雙向鏈表,它的時間復雜度是O(1)
,空間復雜度也是O(1)
;如果使用基于數據的隊列,它的時間復雜度是O(n)
,空間復雜度是O(1)
。
6.2 反轉隊列
實現一個算法,對隊列進行反轉。
Queue 是先進先出,Stack 是后進先出。可以使用 stack 輔助反轉隊列,即將 queue 內容插入 stack,再將 stack 元素 pop 后添加到 queue。
如下所示:
extension QueueArray {
func reversed() -> QueueArray {
// 創(chuàng)建 queue 副本
var queue = self
// 創(chuàng)建 stack
var stack = Stack<T>()
// dequeue queue的所有元素,并添加到 stack。
while let element = queue.dequeue() {
stack.push(element)
}
// 將 stack pop 后添加到 queue。
while let element = stack.pop() {
queue.enqueue(element)
}
return queue
}
}
由于需遍歷兩次,它的時間復雜度是O(n)
。
使用以下代碼進行測試:
var queue = QueueArray<String>()
queue.enqueue("1")
queue.enqueue("8")
queue.enqueue("11")
queue.enqueue("648")
print("before: \(queue)")
print("after: \(queue.reversed())")
6.3 雙端隊列
雙端隊列(doubled-ended queue,簡寫為 deque)是一種具有隊列和棧性質的抽象數據類型,可以從兩端插入、移除雙端隊列的元素??梢园?deque 認為既是 queue,又是 stack。
下面的Deque
協(xié)議用于輔助創(chuàng)建自定義數據結構,Direction
枚舉用于描述操作針對頭部還是尾部。
enum Direction {
case front
case back
}
protocol Deque {
associatedtype Element
var isEmpty:Bool { get }
func peek(from direction: Direction) -> Element?
mutating func enqueue(_ element: Element, to direction: Direction) -> Bool
mutating func dequeue(from direction: Direction) -> Element?
}
環(huán)形緩沖區(qū)、棧、數組、雙向鏈表都可用于構建雙端隊列,這里使用雙向鏈表。
首先創(chuàng)建DequeDoubleLinkedList
類,如下所示:
class DequeDoubleLinkedList<Element>: Deque {
// 使用雙向鏈表存儲
private var list = DoublyLinkedList<Element>()
public init() { }
}
實現isEmpty
,其復雜度是O(1)
。
var isEmpty: Bool {
list.isEmpty
}
下面實現peek(from:)
方法,它的復雜度是O(1)
。
func peek(from direction: Direction) -> Element? {
// 根據direction決定查看first還是last。
switch direction {
case .front:
return list.first?.value
case .back:
return list.last?.value
}
}
下面實現enqueue(_:)
方法,根據方向添加元素:
func enqueue(_ element: Element, to direction: Direction) -> Bool {
// 根據direction決定添加方式。
switch direction {
case .front:
// 會將 element 設置為新的頭節(jié)點
list.prepend(element)
case .back:
// 會將 element 設置為新的尾節(jié)點
list.append(element)
}
return true
}
上面的方法只需更新指針指向,因此它的復雜度是O(1)
。
下面實現dequeue(_:)
方法:
func dequeue(from direction: Direction) -> Element? {
let element: Element?
// 雙向鏈表有指向前、后結點的指針,只需移除指針即可。
switch direction {
case .front:
guard let first = list.first else { return nil }
element = list.remove(first)
case .back:
guard let last = list.last else { return nil }
element = list.remove(last)
}
return element
}
與enqueue(_:)
類似,dequeue(_:)
的復雜度也是O(1)
。
使用以下代碼驗證雙端隊列:
let deque = DequeDoubleLinkedList<Int>()
deque.enqueue(1, to: .back)
deque.enqueue(2, to: .back)
deque.enqueue(3, to: .back)
deque.enqueue(4, to: .back)
print(deque)
deque.enqueue(5, to: .front)
print(deque)
deque.dequeue(from: .back)
deque.dequeue(from: .back)
deque.dequeue(from: .back)
deque.dequeue(from: .front)
deque.dequeue(from: .front)
deque.dequeue(from: .front)
print(deque)
總結
- 隊列是先進先出。
- enqueue 將元素添加到尾部。
- dequeue 移除隊列頭部元素。
- 數組的元素分布在連續(xù)內存塊中;鏈表的元素隨機分布,緩存命中率低。
- 環(huán)形緩沖區(qū)適合固定大小的隊列。
- 使用兩個棧可以將
dequeue(_:)
平均復雜度降低到O(1)
,并且在查找方面優(yōu)于雙向鏈表。
Demo名稱:Queue
源碼地址:https://github.com/pro648/BasicDemos-iOS/tree/master/Queue
歡迎更多指正:https://github.com/pro648/tips
本文地址:https://github.com/pro648/tips/blob/master/sources/隊列的四種實現方式:數組、雙向鏈表、環(huán)形緩沖區(qū)、棧.md