1. 什么是 Deque?
- 核心概念: Deque 是 “Double-Ended Queue”(雙端隊列)的縮寫。你可以把它想象成一個可以在兩端(頭部和尾部)高效地進行添加或刪除操作的線性數據結構。
- 關鍵特性:
- 雙端操作: 這是 deque 最顯著的特點。你可以在隊列的開頭(front) 或結尾(back) 快速地插入(
push_front
,push_back
)或刪除(pop_front
,pop_back
)元素。 - 順序容器: 元素在 deque 中是按順序存儲的,每個元素都有一個確定的位置(索引)。你可以像數組或 vector 一樣,通過下標
operator[]
或at()
來隨機訪問任意位置的元素(效率接近 O(1),但比 vector 稍慢一點,后面解釋原因)。 - 動態大小: 和 vector、list 一樣,deque 可以根據需要動態地增長或縮小,你不需要預先指定其大小。
- 雙端操作: 這是 deque 最顯著的特點。你可以在隊列的開頭(front) 或結尾(back) 快速地插入(
- 形象比喻: 想象一個特殊的書架(或者更準確地說,一組小書架拼成的大書架):
- 你既可以從書架的最左邊(front) 快速地拿走或放入一本書。
- 也可以從書架的最右邊(back) 快速地拿走或放入一本書。
- 你還可以直接走到書架中間某個編號的位置(索引),快速找到并取閱(訪問)那本書。
- 當一個小書架放滿了,管理員可以輕松地在最左邊或最右邊拼接一個新的空小書架,而不需要把整個大書架的書都重新整理一遍(這是它和 vector 擴容的關鍵區別)。
2. Deque 的內部實現機制(理解其高效性的關鍵)
這是理解 deque 為何能在兩端高效操作,以及其隨機訪問性能、內存特性的核心。典型的 STL 實現(如 GCC 的 libstdc++ 或 Clang 的 libc++)采用一種叫做 “分塊數組” 或 “塊鏈” 的策略:
- 不是一整塊連續內存: 不像
std::vector
總是試圖維護一整塊大的連續內存空間。 - 由多個固定大小的 “塊”(Chunks / Blocks) 組成: Deque 內部管理著一個數組(通常叫
map
或block map
),這個數組的每個元素是一個指針,指向一個固定大小的內存塊。 - 塊內連續,塊間不連續: 每個內存塊內部是連續存儲元素的。但是,不同的內存塊在物理內存上不一定是連續的。這些塊通過
map
數組組織起來。 map
數組本身也是動態數組: 這個指向塊的指針數組 (map
) 本身也是一個動態數組(通常實現為 vector)。當 deque 增長導致需要更多的塊時,這個map
數組也可能需要擴容(重新分配和復制指針),但這比 vector 擴容(復制所有元素)開銷小得多。- 維護關鍵指針/迭代器: Deque 對象內部維護幾個關鍵信息:
- 指向
map
數組的指針。 map
數組的大小(能容納多少塊指針)。- 指向第一個元素所在塊的指針(
start
迭代器或類似結構,包含塊指針、當前塊內起始位置)。 - 指向最后一個元素所在塊的下一個位置的指針(
finish
迭代器或類似結構,包含塊指針、當前塊內結束位置)。 - 通常還會緩存第一個和最后一個元素在各自塊內的具體位置。
- 指向
大家可以通過下面這3幅圖來大概理解一下deque的結構:
為什么這種結構支持高效的雙端操作?
- 在頭部插入(
push_front
):- 檢查第一個塊
start
指向的塊是否還有空間(在頭部方向)。 - 如果有空間: 直接在那個塊的空位(
start
指針前面)放入新元素,更新start
的位置信息。非常快 (O(1))。 - 如果沒空間: 在
map
數組的前端(或找到一個空閑位置)分配一個新塊,將新元素放入新塊的末尾位置(因為新塊要加在最前面),然后更新map
和start
指向這個新塊。如果map
數組前端沒空間了,可能需要重新分配并復制map
(這個開銷相對小,因為只復制指針)。均攤復雜度接近 O(1)。
- 檢查第一個塊
- 在尾部插入(
push_back
):- 檢查最后一個塊
finish
指向的塊是否還有空間(在尾部方向)。 - 如果有空間: 直接在那個塊的空位放入新元素,更新
finish
的位置信息。非常快 (O(1))。 - 如果沒空間: 在
map
數組的后端分配一個新塊,將新元素放入新塊的開頭位置,更新map
和finish
。同樣,map
擴容開銷較小。均攤復雜度接近 O(1)。
- 檢查最后一個塊
- 在頭部刪除(
pop_front
):- 直接移除
start
當前指向的元素。 - 更新
start
指向下一個位置(或下一個塊的開頭,如果當前塊刪空了)。 - 如果當前塊變空,可以釋放它(可選,實現可能緩存空塊)。O(1)。
- 直接移除
- 在尾部刪除(
pop_back
):- 直接移除
finish
前一個位置的元素(finish
通常指向最后一個元素的下一個位置)。 - 更新
finish
指向前一個位置(或前一個塊的末尾,如果當前塊刪空了)。 - 同樣,空塊可能被釋放或緩存。O(1)。
- 直接移除
為什么隨機訪問是 O(1)(接近)?
- 計算目標元素位置: 假設每個塊固定大小為
block_size
(例如 512 字節或存 16/32/64 個元素,取決于元素大小和實現)。 - 定位塊: 要訪問索引
i
處的元素:- 首先計算它在哪個塊:
block_index = (i + start_block_offset) / block_size
(需要知道start
塊在map
中的位置和start
在它自己塊內的偏移)。 - 然后在
map[block_index]
指向的塊內找到偏移:offset_in_block = (i + start_block_offset) % block_size
。
- 首先計算它在哪個塊:
- 直接訪問: 通過
map[block_index] + offset_in_block
直接訪問元素。 - 開銷: 這個過程涉及幾次整數除法和取模運算(現代 CPU 很快),以及兩次指針解引用(訪問
map
數組,再訪問具體塊)。這比 vector 的一次指針偏移訪問(start_pointer + i * element_size
)略慢,但復雜度仍然是常數時間 O(1)。這是“接近 O(1)”的含義,實際常數因子比 vector 大。
3. Deque 的核心操作與復雜度
操作 | 函數 | 時間復雜度 | 說明 |
---|---|---|---|
頭部插入 | push_front(value) | 均攤 O(1) | 在開頭添加一個元素。非常高效。 |
尾部插入 | push_back(value) | 均攤 O(1) | 在末尾添加一個元素。非常高效。 |
頭部刪除 | pop_front() | O(1) | 移除開頭元素。非常高效。 |
尾部刪除 | pop_back() | O(1) | 移除末尾元素。非常高效。 |
隨機訪問 | operator[] , at | O(1) | 通過索引訪問元素。效率接近 vector,常數因子略高。at 會進行邊界檢查。 |
訪問頭部元素 | front() | O(1) | 返回開頭元素的引用。 |
訪問尾部元素 | back() | O(1) | 返回末尾元素的引用。 |
中間插入 | insert(pos, value) | O(n) | 在迭代器 pos 指定的位置插入元素。需要移動后續元素,效率較低。 |
中間刪除 | erase(pos) | O(n) | 刪除迭代器 pos 指定的元素。需要移動后續元素,效率較低。 |
獲取大小 | size() | O(1) | 返回當前元素數量(通常內部維護)。 |
檢查是否空 | empty() | O(1) | 檢查 deque 是否為空。 |
清空 | clear() | O(n) | 刪除所有元素。通常會釋放所有內存塊(實現可能緩存空塊)。 |
4. Deque 的迭代器失效規則(重要!)
理解迭代器何時失效對安全使用容器至關重要:
- 插入(
push_front
,push_back
,insert
):push_front
/push_back
: 通常只使所有迭代器失效(但指向元素的引用和指針通常仍然有效!)。 為什么?因為map
數組可能重新分配,導致指向塊的指針改變。不過,重要提示:大多數現代實現保證push_front
/push_back
不會使指向已有元素的引用和指針失效(失效的是迭代器本身,因為它可能存儲了塊指針和位置信息)。這是標準允許但不強制的,主流實現(libstdc++, libc++)都提供了這個保證。如果插入導致新塊分配,map
重分配,則所有迭代器失效(引用/指針仍然指向有效元素)。如果沒導致新塊/map
重分配,可能只有end()
迭代器失效(實現細節)。最安全保守的做法是:在push_front
/push_back
后,假設所有迭代器可能失效(除非你明確知道當前實現和容量情況)。引用和指針通常安全。insert(pos, value)
(在中間插入): 使所有指向插入位置之后(包括pos
本身)的迭代器、引用和指針失效。 因為需要移動后面的元素。
- 刪除(
pop_front
,pop_back
,erase
):pop_front
: 使指向被刪除元素(第一個元素)的迭代器、引用和指針失效。front()
返回的引用也失效。其他迭代器/引用/指針通常安全。pop_back
: 使指向被刪除元素(最后一個元素)的迭代器、引用和指針失效。back()
返回的引用也失效。其他迭代器/引用/指針通常安全。erase(pos)
(在中間刪除): 使所有指向被刪除元素及其之后位置的迭代器、引用和指針失效。 因為需要移動后面的元素填補空缺。
swap
: 交換兩個 deque 后,兩個 deque 的所有迭代器、引用和指針都會交換它們的有效性。原來指向 deque A 的迭代器現在指向 deque B 中的對應元素(如果還有效的話),反之亦然。clear
: 所有迭代器、引用和指針失效。
總結關鍵點: 在 deque 中間進行插入或刪除操作是昂貴的(O(n))且會使相關迭代器失效。在兩端操作非常高效,并且通常不會使指向已有元素的引用和指針失效(迭代器本身需要謹慎對待,特別是push_front
/push_back
之后)。
5. Deque 的典型應用場景
- 雙端隊列操作: 這是最直接的用途,當你需要一個隊列,但頻繁需要同時在隊頭和隊尾進行操作時。例如:
- 任務調度器: 一些系統可能從隊列頭部取任務執行,但也允許高優先級任務插入到隊列頭部(
push_front
)。 - 撤銷/重做(Undo/Redo)歷史記錄: 新操作
push_back
,撤銷時pop_back
,重做可能需要訪問尾部附近。 - 滑動窗口算法: 需要高效地移除窗口左端元素(
pop_front
)和添加右端元素(push_back
),同時可能需要隨機訪問窗口內的任意元素來計算最大值、最小值、平均值等。
- 任務調度器: 一些系統可能從隊列頭部取任務執行,但也允許高優先級任務插入到隊列頭部(
- 需要高效隨機訪問,且插入主要在兩端: 如果你需要一個支持隨機訪問(像數組)的容器,但插入和刪除操作主要發生在開頭或結尾,而不是中間,那么 deque 是比 vector 更好的選擇。Vector 在頭部插入/刪除是 O(n) 的災難。
- 替代 vector 的謹慎選擇: 當你對 vector 在尾部插入導致擴容并復制所有元素的性能開銷非常敏感,并且你同時需要在頭部進行插入操作時。Deque 擴容(添加新塊)的開銷更小、更平攤。但要注意 deque 的隨機訪問稍慢且內存占用通常更大。
6. Deque vs. Vector vs. List
特性 | std::deque | std::vector | std::list |
---|---|---|---|
內部結構 | 分塊數組 (塊鏈) | 單一動態數組 | 雙向鏈表 |
內存連續性 | 塊內連續,塊間不連續 | 完全連續 | 完全不連續 |
隨機訪問 | O(1) (接近 vector) | O(1) (最快) | O(n) (慢) |
頭部插入/刪除 | O(1) (高效) | O(n) (低效, 需要移動所有元素) | O(1) (高效) |
尾部插入/刪除 | O(1) (高效) | 均攤 O(1) (高效, 除非擴容) | O(1) (高效) |
中間插入/刪除 | O(n) (需要移動元素) | O(n) (需要移動元素) | O(1) (已知位置迭代器) |
迭代器失效 (尾部插入) | 可能失效 (若導致map 重分配) | 可能失效 (若擴容) | 通常不失效 |
迭代器失效 (頭部插入) | 可能失效 (若導致map 重分配) | 總是失效 | 通常不失效 |
內存使用 | 較高 (塊指針數組+可能空余空間) | 較低 (但尾部可能有預留空間) | 較高 (每個元素需額外指針開銷) |
緩存友好性 | 中等 (塊內友好) | 非常好 (整個數組連續) | 差 (元素分散) |
主要優勢 | 高效雙端操作 + 不錯隨機訪問 | 極致隨機訪問 + 尾部高效插入 | 任意位置高效插入/刪除 |
主要劣勢 | 中間操作慢,內存開銷稍大 | 頭部/中間插入刪除慢,擴容代價大 | 隨機訪問慢,內存開銷大 |
7. 簡單代碼示例
#include
#include
#include int main() {// 1. 創建 dequestd::deque myDeque = {1, 2, 3}; // {1, 2, 3}// 2. 雙端插入myDeque.push_front(0); // {0, 1, 2, 3}myDeque.push_back(4); // {0, 1, 2, 3, 4}// 3. 訪問頭部和尾部std::cout << "Front: " << myDeque.front() << std::endl; // 0std::cout << "Back: " << myDeque.back() << std::endl; // 4// 4. 隨機訪問 (索引 2)std::cout << "Element at index 2: " << myDeque[2] << std::endl; // 2// 安全隨機訪問 (索引 2)std::cout << "Element at index 2 (using at): " << myDeque.at(2) << std::endl; // 2// 5. 雙端刪除myDeque.pop_front(); // {1, 2, 3, 4}myDeque.pop_back(); // {1, 2, 3}// 6. 遍歷 (使用迭代器)std::cout << "Deque contents: ";for (auto it = myDeque.begin(); it != myDeque.end(); ++it) {std::cout << *it << " ";}std::cout << std::endl; // Output: 1 2 3// 7. 中間插入 (效率較低!)auto it = myDeque.begin() + 1; // 指向索引1的元素 '2'myDeque.insert(it, 99); // {1, 99, 2, 3}// 8. 中間刪除 (效率較低!)it = myDeque.begin() + 2; // 指向索引2的元素 '2' (在99之后)myDeque.erase(it); // {1, 99, 3}// 9. 輸出最終結果std::cout << "Final deque: ";for (int num : myDeque) { // 范圍for循環std::cout << num << " ";}std::cout << std::endl; // Output: 1 99 3return 0;
}
總結
- Deque 的核心價值在于雙端操作的高效性 (O(1)) 和良好的隨機訪問能力 (O(1))。 這是它在 STL 容器家族中的獨特定位。
- 理解其分塊存儲機制是理解其性能特征(為什么兩端快、隨機訪問接近 vector、中間慢)、內存占用和迭代器失效規則的關鍵。
- 優先在兩端操作。 避免在 deque 中間頻繁插入或刪除,這比在 list 中做同樣操作代價高得多(O(n) vs O(1))。如果需要頻繁在中間操作,
std::list
是更合適的選擇。 - 隨機訪問很快,但比 vector 略慢。 如果你需要極致的隨機訪問速度,并且插入刪除只在尾部進行,
std::vector
仍然是最優選擇。 - 注意內存使用。 Deque 通常比 vector 占用更多內存,因為它有管理塊的開銷(指針數組)和塊內可能的空間浪費。
- 小心迭代器失效。 特別是在進行
push_front
/push_back
(可能使所有迭代器失效)和中間插入刪除(使局部迭代器失效)之后。引用和指針指向的元素內容在兩端操作后通常仍然有效,這是 deque 一個非常有用且重要的特性。 - 明智選擇容器: 沒有“最好”的容器,只有“最合適”的。根據你的具體需求(操作的位置、頻率、是否需要隨機訪問、內存限制、迭代器穩定性要求)來權衡選擇 vector、deque 或 list。