與面試避坑)
1. list 到底解決了什么問題又帶來了什么麻煩做 C 開發(fā)的人幾乎繞不開 STL 里的 list。它跟 vector、string 一起構成了日常最常用的線性容器但很多人對它的理解只停留在“鏈表”兩個字上面試一問迭代器失效就含糊其辭遇到中間插入刪除的性能取舍又開始搖擺。list 類的確不復雜但它的底層設計方式——帶頭雙向循環(huán)鏈表、封裝結點指針的迭代器讓它在六七個常用容器里顯得很特別。理解 list 最好的方式不是背接口而是把它拆開了看為什么 STL 要設計這么一種結構list 的每個特性背后到底是什么機制在支撐。這篇文章會從 list 的整體設計思路講起然后把常用接口逐個拆開說透最后到模擬實現(xiàn)的層面把 list 最核心的迭代器、結點、插入刪除邏輯完整過一遍。無論你是剛學 C 的初學者還是在準備面試的求職者都能從中拿到可直接復用的結論和避坑經(jīng)驗。很多人學容器時有一個誤區(qū)把接口死記硬背下來就以為會用了。實際上 list 的難點不在接口本身而在“迭代器為什么會失效”“為什么 sort 要成員函數(shù)”“為什么 list 大小不能像 vector 那樣隨便算”這些設計層面的問題上。這些問題搞不清楚用起來遲早出事故。2. list 底層結構拆解一張帶頭雙向循環(huán)鏈表2.1 為什么 STL 用循環(huán)鏈表而不是普通雙向鏈表先說結論list 的底層就是帶頭結點的雙向循環(huán)鏈表。單鏈表也行但雙向支撐著向前遍歷和 O(1) 的尾部操作循環(huán)省去了大量邊界判斷帶頭結點則讓空鏈表和普通鏈表有了統(tǒng)一的表現(xiàn)。三者缺一接口實現(xiàn)就得處處寫特殊分支既不優(yōu)雅也容易出 bug。用一個直觀的類比如果把鏈表比作一條環(huán)形走廊哨兵位頭結點就是走廊入口處那個永遠存在的信息牌。它不存放有效數(shù)據(jù)只負責告訴你“從哪里開始、到哪里結束”。插入、刪除、查找都從信息牌兩側出發(fā)無論鏈表是空還是滿操作的邏輯完全一致。這比普通單向鏈表那種“頭指針為空就要單獨處理”的寫法舒服得多。2.2 結點內部到底長什么樣list 里每一個元素都被包裝在一個結點結構里結點內部至少包含三個字段存儲數(shù)據(jù)的 value指向前一個結點的 prev指向后一個結點的 next。不同 STL 版本的命名可能不同libstdc 里叫 _M_value、_M_prev、_M_next但結構模型完全一致。結點的存儲有幾個特點值得注意結點是獨立分配的每個新元素對應 new 出來的一個 node不存在數(shù)組式的連續(xù)內存。value 的類型由模板參數(shù) T 決定所以 list 能存放任意類型包括自定義對象、指針、甚至另一個容器。因為每個元素都有獨立的 prev 和 next 指針list 的空間開銷比 vector 大。一個 int 在 vector 里占 4 字節(jié)在 list 里可能要占 24 字節(jié)甚至更多64 位系統(tǒng)下一個指針 8 字節(jié)兩個指針加數(shù)據(jù)就是 16 字節(jié)加上對齊。這也是 list 的第一個“性能陷阱”如果你存的是 int、double 這類小對象vector 的內存效率和 cache 友好性都要好得多。list 的優(yōu)勢只在“頻繁中間插入刪除”這個特定場景下才有意義。補充一個實際經(jīng)驗當元素很小、又需要快速遍歷時vector 始終優(yōu)先。只有當“插入刪除操作多、且發(fā)生在中間位置”時才考慮 list。很多性能問題并不是容器本身不好而是選錯了容器。2.3 list 與 vector、forward_list 的選型對照把幾個按順序存儲的容器放在一起比能更清楚 list 在什么情況下不可替代容器底層結構插入刪除訪問額外開銷vector動態(tài)連續(xù)數(shù)組尾部 O(1)中間 O(n)O(1) 隨機訪問小連續(xù)內存list帶頭雙向循環(huán)鏈表插入刪除 O(1)已知位置無隨機訪問遍歷 O(n)每元素兩個指針forward_list單向鏈表頭插快尾插慢無隨機訪問只能前向遍歷每元素一個指針另外 C11 引入了 forward_list它是單鏈表只有 next 沒有 prev前向遍歷省指針開銷但無法反向遍歷也不能 O(1) 地拿到尾結點。選擇 list 而不是 forward_list大多數(shù)時候就是因為需要雙向操作或反向遍歷。這輪對照下來結論很清晰list 用空間換操作靈活性是一個“插入刪除友好”但“遍歷不友好”的容器。明白了這一點后面再看接口設計就會順暢很多。3. list 核心接口逐個拆解與實操要點3.1 構造與賦值那幾種寫法分別適合什么場景l(fā)ist 的構造、析構到賦值最關鍵的是先建立“對容器操作最終會落在結點指針操作上”的意識。以下是常用用法listint L1; // 空鏈表只有哨兵位結點 listint L2(10); // 10 個默認值 0 listint L3(5, 3); // 5 個 3 listint L4(L3.begin(), L3.end()); // 用迭代器區(qū)間構造 listint L5(L4); // 拷貝構造 L5 L3; // 賦值這些構造函數(shù)是面試里的熱身題真正的考點在“為什么 list 支持這樣構造”。比如區(qū)間構造需要遍歷輸入?yún)^(qū)間每個元素逐個在尾部插入復雜度 O(n)??真湵頉]有分配任何數(shù)據(jù)結點的空間只創(chuàng)建了一個哨兵位這是 list 和 vector 一個很大的差異——vector 空容器可能已經(jīng)預分配了部分內存list 空容器幾乎零空間成本。3.2 迭代器使用正向、逆向、const 三種的切換邏輯list 的迭代器是隨機訪問迭代器嗎不是。list 迭代器是雙向迭代器支持 、--但不支持 n 或 [n] 這類跳躍式操作。日常使用時你必須擺正預期想用下標直接訪問 list 第 N 個元素是不可行的只能從頭或從尾一個個走。listint L{1, 2, 3, 4, 5}; // 正向遍歷 for (auto it L.begin(); it ! L.end(); it) { cout *it ; } // 反向遍歷 for (auto rit L.rbegin(); rit ! L.rend(); rit) { cout *rit ; } // 常量版本只讀不寫 void PrintList(const listint L) { for (auto cit L.cbegin(); cit ! L.cend(); cit) { cout *cit ; } }強調一個實用細節(jié)在 C11 以后除非你明確不需要修改元素否則優(yōu)先用auto it L.begin()而非auto it L.cbegin()。前者可以同時應對讀寫需求后者只讀。如果函數(shù)接收的是 const 引用迭代器就自動變成 const 迭代器無法通過迭代器修改元素。3.3 尾部與頭部操作push/pop 為什么重要list 作為雙向鏈表頭尾都有哨兵指針因此頭插、尾插、頭刪、尾刪都是 O(1)。listint L; L.push_back(10); L.push_front(20); L.pop_back(); L.pop_front();對于 vectorpush_front 根本不存在因為頭插需要全部元素后移。有了 list你可以在頭部操作而不擔心性能崩塌。這在實現(xiàn)隊列、棧的變體結構或者維護最近訪問列表時非常有用。3.4 insert 和 erase知道位置就能原地操作中間插入刪除是 list 的主場。insert 和 erase 都接收迭代器位置操作本身 O(1) 的前提是——你已經(jīng)持有該位置的迭代器。如果只有值需要先 find 遍歷找到位置那總代價還是 O(n)。listint L{1, 2, 3, 4}; auto it L.begin(); it; // 指向 2 it L.insert(it, 99); // 在 2 之前插入 99 L.erase(it); // 刪除 99幾個點值得記住insert 返回值是插入的新元素的迭代器這點和 vector 一致只是 vector 插入可能觸發(fā)擴容、導致所有迭代器失效list 不存在這種問題。erase 返回被刪除元素的下一個迭代器。這個返回值在循環(huán)刪除、條件刪除的場景里極關鍵很多人寫循環(huán)刪除時以為 erase 之后迭代器還能用結果直接越界。erase 之后只有指向被刪結點的迭代器失效其他的迭代器不受影響這是 list 的核心優(yōu)勢。3.5 resize、assign、remove容易被忽略但很實用的接口resize 改變容器大小比當前大就在末尾補默認值或指定值比當前小就刪除多余元素。assign 用新內容替換全部舊內容它內部做了 clear 后再逐個插入的工作。remove 則直接刪除所有等于指定值的元素返回值為元素個數(shù)C20 以前。listint L{1, 2, 2, 3, 2, 4}; L.remove(2); // 刪除所有 2剩下 1,3,4 L.resize(10); // 擴大到 10新元素補 0 L.assign(5, 7); // 整體變成 7,7,7,7,7在面試里我常常會問一個細節(jié)remove 和 erase remove_if 有什么區(qū)別前者只刪特定值后者可以按自定義條件刪。list 還有成員函數(shù) remove_if接收一元謂詞可以按任意條件過濾這是 C20 之前語言沒有標準 erase_if 時的常用手段。3.6 sort、merge、unique、reverse、splicelist 專屬的算法list 有自己專屬的成員函數(shù)這點非常值得展開說。sort— list 不能用 std::sort因為 std::sort 需要隨機訪問迭代器list 迭代器不支持跳躍。list::sort 采用歸并排序思想實現(xiàn)穩(wěn)定排序復雜度 O(n log n)。listint L{5, 3, 1, 4, 2}; L.sort(); // 1 2 3 4 5 L.sort(std::greaterint()); // 降序 5 4 3 2 1merge— 合并兩個已排序的鏈表前提是兩者都已按同樣規(guī)則排序。調用后參數(shù)鏈表會被清空元素被轉移進當前鏈表。listint A{1, 3, 5}; listint B{2, 4, 6}; A.merge(B); // A: 1 2 3 4 5 6B 為空unique— 去重相鄰重復元素。注意它只去“相鄰一樣的元素”如果重復元素不連續(xù)必須先 sort 再用 unique。listint L{1, 1, 2, 2, 2, 3, 1}; L.unique(); // 1 2 3 1最后的 1 沒被去掉reverse— 反轉鏈表O(n)原地完成不需要額外空間。這個操作對雙向鏈表來說就是交換每個結點的 prev 和 next再重設哨兵結點的連接位置復雜度主要花在遍歷上。splice— 把另一個 list 中的結點整體搬家到當前 list 指定位置這是 std 算法庫沒有對應物的操作。splice 被稱為 O(1) 是因為它只改指針不重新構造元素非常適合做“把 B 整段挪到 A 中間”的需求。listint A{1, 2, 5, 6}; listint B{3, 4}; auto it A.begin(); it; it; // it 指向 5 A.splice(it, B); // 把 B 的全部元素插入到 5 之前B 被清空 // A: 1 2 3 4 5 64. list 模擬實現(xiàn)手撕核心代碼徹底搞懂迭代器4.1 為什么 list 迭代器不能像 vector 那樣直接用指針這是 list 學習里最值得鉆進去的地方。vector 的迭代器就是 T*因為 vector 底層是連續(xù)內存元素之間的位置關系就是地址加減關系指針天然支持 N、-N、比較大小。list 底層是不連續(xù)鏈表結點之間通過指針連接T* 對它完全無效。假如你用 T* 做迭代器 操作會把指針移到當前地址的下 N 個字節(jié)而不是移到鏈表下一個結點——這完全錯了。因此 list 迭代器必須是“結點指針的封裝類”它內部保存一個指向 node 的指針重載 operator 時讓指針指向 node-next重載 operator* 時返回結點中存儲的數(shù)據(jù)引用。這也是很多面試者第一次手寫容器時卡住的地方迭代器不是一個簡單 typedef而是需要自己實現(xiàn)的仿指針類。4.2 結點結構 迭代器的完整代碼先看最小可用的模擬實現(xiàn)代碼基于經(jīng)典教學式實現(xiàn)省去大量模板元編程細節(jié)但結構完整template typename T struct list_node { T _data; list_node* _prev; list_node* _next; list_node(const T val T()) : _data(val), _prev(nullptr), _next(nullptr) {} }; template typename T, typename Ref, typename Ptr struct list_iterator { typedef list_nodeT node; node* _node; list_iterator(node* n nullptr) : _node(n) {} Ref operator*() { return _node-_data; } Ptr operator-() { return _node-_data; } list_iterator operator() { _node _node-_next; return *this; } list_iterator operator(int) { list_iterator tmp(*this); _node _node-_next; return tmp; } list_iterator operator--() { _node _node-_prev; return *this; } list_iterator operator--(int) { list_iterator tmp(*this); _node _node-_prev; return tmp; } bool operator!(const list_iterator it) const { return _node ! it._node; } bool operator(const list_iterator it) const { return _node it._node; } };這里的關鍵在于模板參數(shù) Ref 和 Ptr。同一個迭代器類型通過傳入 T 或 const T 就能生成普通迭代器與 const 迭代器避免寫兩遍幾乎相同的類。libstdc 里的 __normal_iterator 也是類似的思路。新手可以先寫死成非 const 版本理解邏輯再看這個泛化寫法。4.3 list 容器本體實現(xiàn)哨兵位 增刪改查接下來是容器本體。核心思路是構造函數(shù)里申請一個哨兵位結點讓它的 prev 和 next 都指向自己template typename T class list { public: typedef list_nodeT node; typedef list_iteratorT, T, T* iterator; typedef list_iteratorT, const T, const T* const_iterator; private: node* _head; void empty_init() { _head new node; _head-_next _head; _head-_prev _head; } public: list() { empty_init(); } iterator begin() { return iterator(_head-_next); } iterator end() { return iterator(_head); } bool empty() const { return _head-_next _head; } size_t size() const { size_t cnt 0; iterator it begin(); while (it ! end()) { cnt; it; } return cnt; } };為什么 end() 返回哨兵位結點而不是最后一個元素之后那個 null 結點因為循環(huán)鏈表里最后一個結點的 next 指向哨兵位哨兵位天然就是“終點”。這樣迭代器在遍歷時只需判斷node ! _head即可不需要單獨處理 nullptr邏輯干凈又無邊界分支。插入和刪除則直接改指針void insert(iterator pos, const T val) { node* cur pos._node; node* prev cur-_prev; node* new_node new node(val); prev-_next new_node; new_node-_prev prev; new_node-_next cur; cur-_prev new_node; } void push_back(const T val) { insert(end(), val); } void push_front(const T val) { insert(begin(), val); } void erase(iterator pos) { node* cur pos._node; node* prev cur-_prev; node* next cur-_next; prev-_next next; next-_prev prev; delete cur; }這里面的操作順序是硬性要求修改 prev 和 next 時不能讓鏈表出現(xiàn)斷裂否則遍歷會崩潰。標準做法是先把要刪除結點的前后結點都取出來重新搭橋再釋放當前結點。寫模擬實現(xiàn)時最常見的錯誤就是先 delete 了 cur又用 cur 的 prev 和 next 去修橋等于訪問了野指針。4.4 clear 與析構逐結點釋放別漏哨兵位list 不像 vector 那樣 bulk 釋放連續(xù)內存它的每個結點各自 new釋放時也必須逐個 deletevoid clear() { node* cur _head-_next; while (cur ! _head) { node* next cur-_next; delete cur; cur next; } _head-_next _head; _head-_prev _head; } ~list() { clear(); delete _head; _head nullptr; }一個高頻筆試題就是讓你實現(xiàn) clear 或者析構。難點在于遍歷過程中必須提前保存下一個結點的地址否則刪除當前結點后就沒法繼續(xù)前進了。這跟解除鏈表結點是一樣的底層邏輯。5. 實戰(zhàn)避坑與面試高頻點5.1 迭代器失效什么時候危險什么時候安全list 的迭代器失效規(guī)則是所有容器里最簡單的但正因為看起來不難很多人才掉坑。insert不會使任何既有迭代器失效。erase只使被刪除位置的迭代器失效不影響其他迭代器。注意刪除后返回的是下一個有效迭代器遍歷刪除時必須接收返回值。所以錯誤寫法是在 for 循環(huán)里依賴原本的迭代器繼續(xù) // 錯誤示范 for (auto it L.begin(); it ! L.end(); it) { if (*it % 2 0) { L.erase(it); // 之后 it 已經(jīng)無效了可能導致崩潰 } } // 正確寫法 for (auto it L.begin(); it ! L.end(); ) { if (*it % 2 0) { it L.erase(it); } else { it; } }這里還要提一個 vector 對比下的易錯點vector 的 erase 會讓“被刪位置到末尾所有迭代器”失效list 只需要處理單個位置。因此在 list 上做條件刪除非常安全只要你自己不畫蛇添足。5.2 size() 是 O(n) 的別在循環(huán)里反復調用vector 的 size() 是 O(1)因為成員變量里存了 count。list 的 size() 在不同標準庫實現(xiàn)中表現(xiàn)不同C11 之前size() 通常是 O(n)要遍歷整個鏈表數(shù)結點C11 之后不少實現(xiàn)改成了 O(1)但標準并不強制。實際工程建議是不要在循環(huán)條件里寫L.size()因為它至少不保證 O(1)??梢韵饶玫?size 存進局部變量再繼續(xù)循環(huán)。我見過一個后臺服務因為這種小差異在超大鏈表上多耗了幾秒的例子處理數(shù)據(jù)量一旦上去O(n) 和 O(1) 的差距會非常明顯。5.3 list 沒有 vector 那樣的 reserve別找它list 不需要 reserve因為插入不需要連續(xù)內存搬遷。如果看到有人試圖為 list 預留內存那他可能是帶著 vector 的思維慣性這本身是思路上的錯位。list 的空間是邊插邊申請cbegin 附近也沒有 capacity 概念。需要提示的是在頻繁插入的 list 場景下如果你對元素類型有默認值可以用帶結點池的內存分配器優(yōu)化但那是進階話題初學階段了解即可。5.4 splice 的坑參數(shù)鏈表被清空cplusplus 文檔里有一句話容易忽視調用 splice 之后被拼接的 list 就空了。如果你后面還在用那個 list必然出邏輯錯誤。listint A{1, 2}; listint B{3, 4}; A.splice(A.end(), B); // A: 1 2 3 4 // 此時 B 為空如果繼續(xù)訪問 B 就會出問題按需備份數(shù)據(jù)或者先確認原 list 已經(jīng)不再需要否則就選擇拷貝式的插入操作而不是 splice。5.5 list 排序為什么是成員函數(shù)而不是 std::sortstd::sort 對迭代器的要求是隨機訪問list 迭代器是雙向迭代器。list::sort 的底層思路是歸并排序而不是快排。前者在鏈表上天然容易實現(xiàn)先分成兩半再遞歸或迭代地合并每次合并只改指針不搬數(shù)據(jù)。歸并排序是穩(wěn)定排序而 std::sort 并不保證穩(wěn)定性std::stable_sort 才保證。二者在面試里經(jīng)常被一起問最好連起來掌握。5.6 remove_if 與仿函數(shù)、lambda 的配合list 的 remove_if 是按條件刪除的成員函數(shù)接受一元謂詞。C11 后我?guī)缀醵加?lambda 寫listint L{1, 2, 3, 4, 5, 6}; L.remove_if([](int x) { return x % 2 0; }); // 剩下 1 3 5這比手動 erase 判斷要簡潔得多。面試時如果被問到怎么按條件刪 list 元素最佳答案就是 remove_if而不是手寫循環(huán)。手寫循環(huán)雖然在很多場合也能完成但 remove_if 是專門為 list 設計的內部處理了迭代器失效細節(jié)正確率更高。6. 一個完整的綜合例子用 list 維護用戶最近操作記錄紙上談兵不如實戰(zhàn)。我拿一個常見業(yè)務場景來串聯(lián)上面所有知識點維護每個用戶最近 100 次操作記錄。操作頻繁、要按時間倒序展示、可能更新頭部也會刪掉尾部這正適合雙向鏈表。#include list #include string #include iostream class RecentActions { public: void Record(const std::string action) { actions.push_front(action); if (actions.size() 100) { actions.pop_back(); } } void PrintAll() { for (const auto act : actions) { std::cout act std::endl; } } // 按條件清理某些歷史記錄 void RemoveIfContains(const std::string keyword) { actions.remove_if([](const std::string s) { return s.find(keyword) ! std::string::npos; }); } // 把另一份臨時列表整體并入頭部 void MergeFront(std::liststd::string other) { actions.splice(actions.begin(), other); Trim(); } private: std::liststd::string actions; void Trim() { while (actions.size() 100) { actions.pop_back(); } } };這里有幾個細節(jié)push_front 模擬最新操作在最前面pop_back 淘汰最舊記錄。size() 調用雖然在 C11 之后很多實現(xiàn)是 O(1)但要在調用處意識到它不一定是 O(1)記錄一個成員變量計數(shù)會更穩(wěn)妥。上面的代碼每輪只調用一次 size性能可以接受。splice 把臨時列表整體搬進來避免了逐個 push_front 的 O(n) 拷貝代價是 other 被清空。7. 我踩過的坑與最后想說的話7.1 寫 list 模擬實現(xiàn)時犯過的錯誤我第一次手寫 list 時犯了一個經(jīng)典錯誤在 erase 里先 delete 結點再用它的 prev 和 next 修橋。表面看邏輯沒錯但 delete 之后那塊內存已經(jīng)被釋放讀一個已釋放對象的成員屬于未定義行為。調試時數(shù)據(jù)僥幸還在跑出正常結果換到 release 模式就隨機崩潰排錯排了很久。后來總結出的經(jīng)驗是鏈表操作里“先取鄰居、再動指針、最后釋放”這三步順序永遠是鐵律。7.2 別把迭代器當指針比較別用 或 list 迭代器只支持 和 !不支持 、 這種關系比較。因為鏈表的地址不具備“前面的結點地址一定更小”的特性。你寫it end()在 vector 上可以編過在 list 上直接編譯報錯。這類問題在 git 提交前的編譯期就能發(fā)現(xiàn)但越早掌握越省心。7.3 什么時候該用 list我的判斷準則在實際項目里我會先問三個問題我需要頻繁在中間插入刪除嗎如果否vector 大概率更好。我的元素是小型 POD 嗎如果是list 的額外指針開銷可能不值得。我需要隨機訪問嗎如果需要list 就出局了。只有當三項都傾向鏈表時list 才是最優(yōu)解。比如做 LRU 緩存、實現(xiàn)任務隊列、合并兩個有序鏈表這類操作list 就非常合適。很多性能問題不是代碼寫得不好而是容器選得不對。理解 list 的底層結構會讓你在選型和寫代碼時都多一份底氣。最后分享一個小技巧調試 list 相關崩潰時先打印出涉及結點的 prev、next 和值確認鏈表有沒有斷裂。這三個字段正常問題多半出在迭代器本身是否已經(jīng)失效字段出現(xiàn)野值那就是內存訪問順序出了錯。掌握這個排查順序能省下大量在 gdb 里瞎轉的時間。