Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

陣列、字串與 vector

深入 std::vector:攤銷複雜度、迭代器失效與移動語意

為什麼 push_back 攤銷 O(1)、擴容何時用移動而非複製,以及 std::string 的小字串優化

push_back 一次只加一個元素,為什麼整體還是 $O(n)$?

你已經會用 std::vector 了:宣告、push_back[] 存取、size()。但這裡有一個看似矛盾的事實值得停下來想想。

vector 的底層是一塊連續記憶體。當這塊記憶體塞滿、你又呼叫 push_back 時,它必須「搬家」:配置一塊更大的新記憶體、把舊元素全部複製過去、再釋放舊空間。搬一次家的代價是 $O(n)$。那麼,連續呼叫 $n$ 次 push_back,會不會就是 $n$ 次搬家、總共 $O(n^2)$?

如果真是 $O(n^2)$,那 vector 就不堪用了——把一百萬個元素塞進去要做一兆次操作。但實際上,從空 vector 連續 push_back $n$ 次,總成本是 $O(n)$,平均每次只有 $O(1)$。這個「平均」不是運氣好,而是一種可以嚴格證明的保證,叫做攤銷複雜度(amortized complexity)

這篇文章不再重複「vector 是動態陣列」這種入門結論。我們要鑽進它的內部:成長策略為什麼選 2 倍而不是固定增量、攤銷分析怎麼算、迭代器失效(iterator invalidation)的精確規則、reserveshrink_to_fit 的真實行為,以及移動語意(move semantics)如何讓容器操作從「複製」變成「轉移」。理解這些,你才算真的掌握了現代 C++ 容器。

陣列、字串與 vector進階概念示意圖

成長策略:為什麼是「乘以一個倍數」而不是「加一個常數」

vector 滿載時要擴容,關鍵問題是:新容量要設多大?有兩種直覺:

  1. 固定增量:每次容量 $+k$(例如每次多 10 格)。
  2. 幾何成長:每次容量 $\times g$(例如每次變 2 倍)。

我們來算固定增量的代價。假設每次擴容 $+k$,從 0 成長到 $n$ 個元素,總共要擴容約 $n/k$ 次。第 $i$ 次擴容時要複製約 $i \cdot k$ 個既有元素。總複製成本是:

$$ \sum_{i=1}^{n/k} i \cdot k = k \cdot \frac{(n/k)(n/k+1)}{2} \approx \frac{n^2}{2k} = O(n^2) $$

無論 $k$ 多大,固定增量都逃不掉 $O(n^2)$。這就是為什麼幾乎所有實作都選擇幾何成長

幾何成長為什麼快?假設倍率是 2。容量序列是 $1, 2, 4, 8, \dots, n$。每次擴容複製的元素數等於當下容量,總複製成本是:

$$ 1 + 2 + 4 + \dots + n = 2n - 1 = O(n) $$

關鍵在於這是一個等比級數,被最後一項主導。把 $O(n)$ 的總成本攤到 $n$ 次 push_back 上,每次平均 $O(1)$——這就是攤銷 $O(1)$。

#include <vector>
#include <iostream>

int main() {
    std::vector<int> v;
    size_t last_cap = 0;
    for (int i = 0; i < 32; ++i) {
        if (v.capacity() != last_cap) {
            std::cout << "size=" << v.size()
                      << " 觸發擴容, capacity -> " << v.capacity() << "\n";
            last_cap = v.capacity();
        }
        v.push_back(i);
    }
}

在 libstdc++(GCC)上你會看到 capacity 走 1, 2, 4, 8, 16, 32 的 2 倍序列;在 libc++(Clang/macOS)也是 2 倍。有趣的是 MSVC 用 1.5 倍。這個差異不是隨意的,背後有記憶體重用的考量,我們留到最後一節談。

動手算一下:攤銷分析的會計法

攤銷 $O(1)$ 可以用更嚴謹的方式證明,這裡介紹直觀的「會計法(accounting method)」。

我們給每次 push_back 收取固定 3 元的「攤銷費用」,看看這筆錢夠不夠用。一次 push_back 的真實工作有兩種:

  • 不擴容:只是把元素寫進空格,真實成本 1 元。
  • 擴容:要複製當前所有元素,真實成本是當下的 size。

收費 3 元的分配方式是這樣的:

  • 1 元付給「把自己寫進新格子」的當下動作。
  • 1 元存起來,準備將來「複製自己」時用。
  • 1 元存起來,準備將來「複製某個沒存夠錢的舊鄰居」時用。

用 2 倍成長時,每次擴容後,新容量的後半段(也就是新加入的那一半元素)每個都帶著 2 元存款進來。當下一次擴容發生,需要複製的元素數量恰好等於上次擴容後新增的元素數量——而這些元素正好每個都存了夠用的錢。所以帳永遠是平的:每次 push_back 只要付 3 元(常數),$n$ 次總共 $3n = O(n)$。

擴容到容量 4,已有 2 個元素 [a, b](複製成本由它們先前的存款支付)
push c -> [a,b,c]     c 存 2 元(共 2 元)
push d -> [a,b,c,d]   d 存 2 元(共 4 元)  <- 滿了
push e -> 擴容!複製 a,b,c,d 共 4 次,剛好花掉 c,d 存的 4 元
         新容量 8,e 進來 [a,b,c,d,e],e 存 2 元

這個會計法的美妙之處:它把「偶爾很貴的擴容」的成本,預先分攤給「便宜的普通插入」,證明了最壞情況的單次操作雖然是 $O(n)$,但任何一連串操作的平均都是 $O(1)$。注意攤銷 $O(1)$ 不等於「每次都 $O(1)$」——某一次 push_back 仍可能因擴容而卡頓 $O(n)$,這在即時系統(real-time system)裡是要小心的。

迭代器失效:那個指向元素的指標,什麼時候會變成廢墟

入門時你大概被告知「擴容後舊迭代器會失效」。但精確的規則比這句話豐富,搞錯會寫出存取已釋放記憶體(use-after-free)的 bug。

#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {10, 20, 30};
    int* p = &v[0];          // 指向第一個元素的裸指標
    auto it = v.begin();     // 迭代器

    v.push_back(40);         // 若觸發擴容,整塊記憶體搬家

    // 危險!p 與 it 可能指向已被釋放的舊記憶體
    // std::cout << *p;      // 未定義行為(undefined behavior)
    std::cout << v[0];       // 安全:用索引重新存取永遠正確
}

vector 的迭代器失效規則:

  • push_back / insert / emplace_back:若造成擴容(新 size > 舊 capacity),所有迭代器、指標、參考全部失效。若沒擴容,insert 點之後的全部失效,之前的仍有效。
  • erase:被刪除點及其之後的全部失效。
  • reserve / resize 變大造成重配置:全部失效。
  • clear:所有迭代器失效,但 capacity 不變(記憶體不歸還)。

對比之下,std::list(雙向鏈結串列)的節點各自獨立配置,刪除某節點只讓指向那一個節點的迭代器失效,其餘安然無恙。這是 vector「連續記憶體」帶來的代價——快取友善(cache-friendly)但結構脆弱。

一個經典陷阱是在迴圈中邊走邊刪:

// ❌ 錯誤:erase 後 it 失效,++it 是未定義行為
for (auto it = v.begin(); it != v.end(); ++it) {
    if (*it % 2 == 0) v.erase(it);
}

// ✅ 正確:erase 回傳「被刪元素的下一個」的有效迭代器
for (auto it = v.begin(); it != v.end(); ) {
    if (*it % 2 == 0) it = v.erase(it);
    else ++it;
}

// ✅ 更好(C++20):用 std::erase 一行解決
std::erase_if(v, [](int x){ return x % 2 == 0; });

reserveshrink_to_fitsize vs capacity 的精確語意

size() 是「目前有幾個元素」,capacity() 是「不重新配置就能容納幾個元素」。兩者分離,正是控制效能的著力點。

如果你事先知道要放多少元素,用 reserve 一次配足,能消除所有中間擴容:

std::vector<int> v;
v.reserve(1'000'000);   // 一次配置好,capacity >= 1000000,size 仍為 0
for (int i = 0; i < 1'000'000; ++i)
    v.push_back(i);     // 全程零擴容,零複製

這把 $O(n)$ 次潛在搬家壓成 1 次配置。在效能敏感的程式碼裡,reserve 是最便宜的優化之一。

反方向的問題是:vector 縮小(eraseclearpop_back)時,capacity 不會自動下降——記憶體被保留以備重用。要主動歸還記憶體得用 shrink_to_fit,但它只是「請求」,標準不保證實作一定照辦:

std::vector<int> v(1000);
v.clear();                    // size=0,但 capacity 仍 1000
v.shrink_to_fit();            // 請求把 capacity 降到貼近 size

// 保證歸還記憶體的慣用法:swap 技巧(C++11 前的經典手法)
std::vector<int>().swap(v);   // 與一個空的臨時 vector 交換,舊記憶體隨臨時物件析構

特別提醒一個 reserve 的反模式:不要在迴圈裡反覆呼叫 reserve 來「逐步」擴容reserve(n) 只在 $n >$ 當前 capacity 時才重配置,所以 reserve(size()+1) 每次都可能觸發重配置,反而退化成 $O(n^2)$。要嘛一次 reserve 到位,要嘛就放手讓 push_back 用它的幾何成長策略。

移動語意:當「搬家」從複製變成轉移

回到開頭的擴容。擴容要把舊元素搬到新記憶體——但「搬」到底是複製還是移動,對效能影響巨大。考慮 std::vector<std::string>,每個 string 內部又是一塊堆積(heap)記憶體:

#include <vector>
#include <string>

std::vector<std::string> names;
names.push_back("一段很長很長的字串,內部有自己的堆積記憶體配置...");
// ... 觸發擴容時,這個 string 要被搬到新位置

若用複製搬家,每個 string 都要重新配置堆積、逐字元複製,代價昂貴。若用移動搬家,只需把舊 string 的內部指標「偷」給新位置、把舊的設為空殼——$O(1)$ 的指標轉移,不碰字元資料。

C++11 的移動語意讓這成為可能,但有一個反直覺的條件:擴容時,只有當元素型別的移動建構子(move constructor)標記為 noexceptvector 才會用移動;否則為了異常安全(exception safety)退回用複製。

原因是「強異常保證(strong exception guarantee)」:擴容過程中若搬到一半某個元素的移動丟出例外,已被搬走(掏空)的舊元素無法復原,vector 會處於壞掉的中間狀態。複製則不同——複製失敗時舊元素原封不動,可以安全回滾。所以標準函式庫保守地規定:移動可能丟例外時,寧可慢一點用複製,也要保住資料完整。

#include <vector>
#include <iostream>

struct Widget {
    Widget() = default;
    Widget(const Widget&) { std::cout << "複製\n"; }
    // 關鍵:標記 noexcept,vector 擴容才會選用移動
    Widget(Widget&&) noexcept { std::cout << "移動\n"; }
};

int main() {
    std::vector<Widget> v;
    v.reserve(1);
    v.emplace_back();          // 第一個元素
    std::cout << "--- 觸發擴容 ---\n";
    v.emplace_back();          // 擴容,搬移已有元素:印出「移動」
}

把 move constructor 的 noexcept 拿掉,重新編譯,輸出會變成「複製」。這是一個常被忽略卻影響深遠的細節:為你的類別正確標記 noexcept 移動,是讓它在 vector 中高效運作的前提。 好消息是,編譯器自動生成的移動建構子,在所有成員都 noexcept-movable 時會自動帶上 noexcept,所以多數情況你不必手動操心。

std::string 的小字串優化(SSO):不是每個字串都住在堆積

std::string 常被當成「字元的 vector」,但它有一個 vector 沒有的招數:小字串優化(Small String Optimization, SSO)

短字串(通常 15 個字元以內,依實作而定)會直接存在 string 物件本身的堆疊(stack)空間裡,完全不配置堆積記憶體。只有超過門檻才退回到動態配置。

#include <string>
#include <iostream>

int main() {
    std::string s = "hi";          // 短,存在物件內部,無堆積配置
    std::cout << s.capacity() << "\n";   // libstdc++ 通常印 15

    std::string big(100, 'x');     // 長,配置堆積
    std::cout << big.capacity() << "\n"; // >= 100
}

SSO 的意義是:建立大量短字串(網路封包欄位、解析出的 token、map 的 key)幾乎零堆積配置成本,避免了 malloc/free 的開銷與記憶體碎片。代價是 sizeof(std::string) 比想像中大(典型 32 bytes),因為要預留內嵌緩衝區。這是「用空間換配置次數」的經典取捨,也解釋了為什麼 string 不能簡單地用 vector<char> 取代。

重點回顧

  • 幾何成長(2 倍)讓 push_back 攤銷 $O(1)$:總複製成本是等比級數 $O(n)$;固定增量則退化為 $O(n^2)$。攤銷 $O(1)$ 不代表每次都快,擴容那一次仍是 $O(n)$。
  • 迭代器失效有精確規則push_back 擴容時全部失效;不擴容時 insert 點之後失效;erase 回傳下一個有效迭代器。用索引重新存取永遠安全。
  • sizecapacity 分離是優化的著力點:已知數量就 reserve 一次到位;縮小不自動還記憶體,要 shrink_to_fit 或 swap 技巧。別在迴圈裡逐步 reserve
  • 擴容用移動還是複製,取決於 noexcept:移動建構子標 noexcept 才會被 vector 採用,否則為異常安全退回複製。為自訂類別正確標記是高效的前提。
  • std::string 有 SSO:短字串內嵌於物件、零堆積配置;這是 string 與 vector<char> 的本質差異之一。

深入探討(研究所視角)

成長倍率與記憶體重用:為什麼有些實作選 1.5 而非 2。 倍率 2 有一個理論上的缺陷:歷次配置的區塊大小是 $1, 2, 4, 8, \dots$,任何一次新配置所需的空間,都嚴格大於先前所有已釋放區塊的總和($2^k > 2^{k-1} + \dots + 1$)。這意味著在簡單的順序配置器下,vector 永遠無法重用之前釋放的記憶體,只能不斷往位址高處延伸。若倍率取黃金比例 $\varphi \approx 1.618$ 以下(如 MSVC 的 1.5),則經過若干次成長後,累積釋放的空間總和會超過下一次所需,配置器有機會重用舊空間,降低記憶體峰值與碎片。這是個經典的工程取捨:成長越快,重配置次數越少(複製成本低)但記憶體峰值高且難重用;成長越慢則相反。實務上 1.5 與 2 的差異在多數工作負載中不顯著,但它揭示了「攤銷分析只看時間、不看空間局部性」的盲點。

勢能法(potential method)與更嚴格的攤銷證明。 會計法直觀但不夠形式化。研究所層級會用勢能函數(potential function) $\Phi$:定義 $\Phi = 2 \cdot \text{size} - \text{capacity}$,攤銷成本定義為 $\hat{c}_i = c_i + \Phi_i - \Phi_{i-1}$。可證明無論是否擴容,每次 push_back 的攤銷成本都恆為常數。勢能法的威力在於它能處理 push_backpop_back 交錯、甚至帶縮容(shrink)的混合序列——只要勢能函數設計得當,就能證明整串操作的攤銷界限,這在分析 deque、splay tree、Fibonacci heap 等資料結構時是核心工具。

vector<bool> 的特化爭議。 標準函式庫對 std::vector<bool> 做了特化:它不是真正存 bool,而是用位元壓縮(bit-packing),每個元素只佔 1 bit。代價是 operator[] 不回傳 bool&,而是回傳一個代理物件(proxy),導致 auto x = v[i] 的型別不是 bool 而是代理,&v[i] 也無法取得真正的指標。這個「為了省空間而破壞容器一致語意」的設計被廣泛視為標準的歷史性失誤,C++ 委員會至今未能移除它(向後相容包袱)。需要位元集合時,固定大小用 std::bitset、動態用 std::vector<char>boost::dynamic_bitset 通常是更乾淨的選擇。理解這個案例,能幫你體會「過早優化進標準介面」的長期代價。

vectorstd::span(C++20)與資料導向設計。 現代高效能 C++(遊戲引擎、HPC)強調資料導向設計(data-oriented design):把同型別資料連續排列以最大化快取命中。vector 的連續記憶體正是基礎,而 C++20 的 std::span 提供一個不擁有記憶體的「視窗」,讓函式能無痕接收 vector、C 陣列、std::array 的連續區段而不複製。延伸閱讀方向包括:SoA(Structure of Arrays)vs AoS(Array of Structures)的快取行為、std::pmr(polymorphic memory resource)讓你替容器換上 arena/stack 配置器以控制配置策略、以及 false sharing 在多執行緒共寫連續陣列時的效能陷阱。這些主題把「容器」從語法層級提升到系統效能工程的層級,是計算密集領域研究的起點。

AI 共讀助教正在陪你讀:深入 std::vector:攤銷複雜度、迭代器失效與移動語意
嗨!我是這篇文章的共讀助教,只根據〈深入 std::vector:攤銷複雜度、迭代器失效與移動語意〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。