Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

陣列、字串與 vector

C++ 陣列、字串與 vector:從手動記憶體到動態容器

用班級成績統計的小程式,搞懂 C 風格陣列的限制、std::string、std::vector 的動態成長與迭代器,並理解為什麼 C++ 把記憶體控制權交回你手上。

你想存 50 個學生的成績,但 C++ 不讓你「先不決定大小」

假設你要寫一支小程式,讀進一個班的成績、算平均、找最高分。在 Python 裡你會直接 scores = [],邊讀邊 append,完全不用想「這個 list 能裝幾個」。但當你第一次用 C++ 寫同樣的東西,編譯器會立刻給你難堪:

int scores[];        // 錯誤:陣列大小未知,無法宣告
scores.append(95);   // 錯誤:陣列沒有 append 這個方法

這不是 C++ 在找麻煩,而是它在逼你面對一個 Python 幫你藏起來的問題:資料到底放在記憶體的哪裡、佔多大、由誰負責管理。 這篇文章帶你走過 C++ 處理「一串資料」的三個工具——C 風格陣列、std::stringstd::vector——並理解為什麼現代 C++ 程式幾乎只用最後一個。相較於 Python 把一切都當物件動態管理,C++ 給你的是更貼近硬體的控制權,而控制權的代價就是你得多懂一點底層。

C++ 陣列、字串與 vector概念示意圖

C 風格陣列:最接近硬體,也最容易受傷

C++ 從 C 繼承了原生陣列(raw array)。它的宣告需要在編譯期就確定大小:

#include <iostream>

int main() {
    int scores[5] = {90, 85, 72, 95, 88};  // 5 個整數,連續排在記憶體裡
    std::cout << scores[0] << "\n";          // 輸出:90
    std::cout << scores[4] << "\n";          // 輸出:88
    return 0;
}

int scores[5] 做的事很單純:在記憶體上劃出一塊「5 個 int 連續排列」的空間。如果一個 int 是 4 個位元組,這塊就是 20 個位元組,緊緊相連。scores[2] 的意思其實是「從起點往後跳 2 個 int 的位置」——這是 C++ 速度快的根本原因,存取任何一個元素都是 $O(1)$,因為位置是用乘法直接算出來的,不需要逐個尋找。

但原生陣列有三個讓初學者吃足苦頭的限制:

第一,大小是固定的,而且通常要編譯期就知道。 你不能跑到一半才決定「其實我需要 100 個」。

第二,它不會記得自己有多大。 這點和 Python 差很多。在 C++,陣列傳進函式時會「退化」成一個指標(pointer,只記得起點位址,忘了長度),所以你得自己另外傳長度:

#include <iostream>

double average(const int arr[], int n) {  // arr 其實只是起點指標
    int sum = 0;
    for (int i = 0; i < n; ++i) {
        sum += arr[i];
    }
    return static_cast<double>(sum) / n;   // 強制轉型避免整數除法
}

int main() {
    int scores[5] = {90, 85, 72, 95, 88};
    std::cout << average(scores, 5) << "\n";  // 輸出:86
    return 0;
}

第三,也是最危險的——C++ 不會幫你檢查邊界。scores[10] 時程式照樣執行,去讀一塊不屬於你的記憶體:

int scores[5] = {90, 85, 72, 95, 88};
std::cout << scores[10];  // 未定義行為!可能印出垃圾值、也可能當機

這在 Python 會直接拋出 IndexError,明確告訴你錯在哪。C++ 為了速度,預設不做這個檢查——這就是所謂「未定義行為」(undefined behavior),是 C/C++ 程式無數安全漏洞的源頭。你拿到了直接操作記憶體的權力,但也擔起了不能越界的責任。

結論先講:除非你有非常明確的理由,現代 C++ 不要直接用原生陣列。 它存在的價值主要是讓你理解底層,以及和舊有的 C 程式碼相容。

std::string:別再用 char 陣列拼字串了

C 處理文字的方式是「一串 char 加上一個結尾的 '\0'」,也就是 C 風格字串(C-string)。它一樣有「不知道自己多長、容易越界、不能輕鬆變長」的所有毛病。C++ 標準函式庫提供了 std::string 來解決這些問題:

#include <iostream>
#include <string>

int main() {
    std::string name = "優學院";
    std::string greeting = "你好,";

    greeting += name;            // 字串可以直接相加(串接)
    greeting += "!";

    std::cout << greeting << "\n";        // 輸出:你好,優學院!
    std::cout << greeting.size() << "\n"; // 輸出:bytes 數(中文一字多 byte)
    return 0;
}

std::string 用起來和 Python 的 str 很像:可以用 + 串接、用 .size() 問長度、用 [i] 取單一字元。但有兩個關鍵差異要記住:

第一,C++ 的 std::string 是可變的(mutable)。 Python 的字串一旦建立就不能改,每次「修改」其實是產生新字串。C++ 則允許你就地改:

std::string s = "cat";
s[0] = 'b';
std::cout << s << "\n";  // 輸出:bat

第二,雙引號的兩種意義。 "hello" 預設是 C 風格字串字面值(型別接近 const char*),只有在指定型別或上下文需要時才會轉成 std::string。所以判斷型別要小心:

std::string a = "hello";   // a 是 std::string,沒問題
auto b = "hello";          // b 是 const char*,不是 std::string!
auto c = std::string("hello");  // 想用 auto 又要 string,這樣寫

auto 是 C++ 讓編譯器自動推斷型別的關鍵字,但它推出來的是「字面值本身的型別」,初學時這裡很容易踩雷。

std::vector:你 90% 的時間都該用它

終於來到主角。std::vector 是 C++ 的「動態陣列」,它把原生陣列的速度和「可以自動長大、記得自己多大、用起來像 Python list」結合在一起。它幾乎就是你寫一般 C++ 程式時,存放一串同型別資料的預設選擇。

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores;       // 一開始是空的,不用先決定大小
    scores.push_back(90);          // 在尾端加一個元素(像 Python 的 append)
    scores.push_back(85);
    scores.push_back(72);

    std::cout << scores.size() << "\n";  // 輸出:3
    std::cout << scores[0] << "\n";      // 輸出:90
    return 0;
}

注意 std::vector<int> 裡的 <int>。這是 C++ 的模板(template)語法,意思是「一個裝 int 的 vector」。和 Python 的 list 可以隨意混裝不同型別不同,C++ 的 vector 在編譯期就鎖定一種型別——這是靜態型別語言的特性,雖然少了點彈性,但換來的是編譯器幫你抓型別錯誤,以及更高的執行效率。

vector 也可以一開始就給定初始內容或大小:

std::vector<int> a = {90, 85, 72};      // 用初始化列表
std::vector<int> b(5, 0);                // 5 個元素,全部初始化為 0
std::vector<std::string> names = {"Amy", "Bob"};  // 也能裝字串

三種走訪方式,從新手到地道

走訪(traverse)一個 vector 有好幾種寫法,由淺入深正好帶出 C++ 的幾個重要概念。

寫法一:傳統索引迴圈。 最直觀,和原生陣列一樣,但用 .size() 就不必另外記長度:

for (std::size_t i = 0; i < scores.size(); ++i) {
    std::cout << scores[i] << " ";
}

這裡用 std::size_t 而不是 int,因為 .size() 回傳的是無號整數型別,型別配對才不會被編譯器警告。這是個常被忽略但很地道的細節。

寫法二:範圍 for 迴圈(range-based for)。 C++11 之後的寫法,最接近 Python 的 for x in list

for (int s : scores) {        // 把每個元素複製一份到 s
    std::cout << s << " ";
}

for (const int& s : scores) { // 用 const 參考,避免複製、且不可改
    std::cout << s << " ";
}

const int& 這個寫法值得記起來:& 代表「參考」(reference),意思是「不要複製整個元素,直接借用它」;const 代表「我只讀不改」。當 vector 裝的是大型物件(例如 std::string)時,加上 const& 能避免無謂的複製,這是 C++ 注重效能的典型體現。相較之下 Python 的 for 迴圈背後永遠是傳參考,你沒有這個選擇權,C++ 則把選擇權交給你。

寫法三:迭代器(iterator)。 這是底層機制,前兩種寫法其實都是它的包裝:

for (std::vector<int>::iterator it = scores.begin(); it != scores.end(); ++it) {
    std::cout << *it << " ";   // *it 取出迭代器指向的值
}

// 用 auto 簡化冗長的型別名稱
for (auto it = scores.begin(); it != scores.end(); ++it) {
    std::cout << *it << " ";
}

迭代器你可以想成「一個會走動的指針」:begin() 指向第一個元素,end() 指向「最後一個元素的後面一格」(注意是後面,不是最後一個),++it 讓它前進一格,*it 取出它目前指向的值。為什麼要懂這個?因為 C++ 標準函式庫的所有演算法(排序、搜尋、去重……)都是建立在迭代器之上的通用介面。學會它,等於拿到整個 <algorithm> 工具箱的鑰匙:

#include <algorithm>

std::sort(scores.begin(), scores.end());           // 排序
auto it = std::max_element(scores.begin(), scores.end());  // 找最大值
std::cout << *it << "\n";

動手寫一段:班級成績統計

把前面的概念合起來,寫一支完整可執行的小程式。它讀入一串成績、算平均、找最高分、並印出及格名單:

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>

int main() {
    std::vector<int> scores = {90, 55, 72, 95, 48, 88, 63};

    // 1. 算總和與平均
    int sum = 0;
    for (int s : scores) {
        sum += s;
    }
    double avg = static_cast<double>(sum) / scores.size();

    // 2. 找最高分(用標準函式庫的迭代器版本)
    int highest = *std::max_element(scores.begin(), scores.end());

    // 3. 把及格的挑進另一個 vector
    std::vector<int> passed;
    for (int s : scores) {
        if (s >= 60) {
            passed.push_back(s);
        }
    }

    // 4. 輸出結果
    std::cout << "人數:" << scores.size() << "\n";
    std::cout << "平均:" << avg << "\n";
    std::cout << "最高:" << highest << "\n";
    std::cout << "及格人數:" << passed.size() << "\n";

    std::cout << "及格分數:";
    for (int s : passed) {
        std::cout << s << " ";
    }
    std::cout << "\n";

    return 0;
}

// 輸出:
// 人數:7
// 平均:73
// 最高:95
// 及格人數:5
// 及格分數:90 72 95 88 63

把這段存成 grades.cpp,用 g++ grades.cpp -o grades 編譯,再執行 ./grades 就能看到結果。試著改改看:把 passed 也排序、或統計不及格人數,感受一下 vector 與標準函式庫怎麼配合。

常見錯誤

寫 C++ 容器時,初學者最常掉進這幾個坑:

  1. 越界存取不會報錯,但會悄悄毀掉程式。 vector[i] 和原生陣列一樣不檢查邊界。需要安全檢查時改用 vector.at(i),越界會丟出 std::out_of_range 例外。別期待 C++ 像 Python 那樣自動給你 IndexError

  2. 在範圍 for 迴圈裡 push_back 會炸掉。 邊走訪邊新增元素,可能觸發 vector 重新配置記憶體,使迴圈正在用的迭代器或參考全部失效(iterator invalidation),導致未定義行為。要新增就走訪舊資料、寫進「另一個」vector。

  3. 忘了 &,不小心複製了一大份資料。 for (std::string line : lines) 會把每一行字串完整複製一次;資料量一大就是效能殺手。唯讀就用 for (const std::string& line : lines)。這是 C++ 才需要操心、Python 幫你免費搞定的事。

  4. int.size() .size() 回傳無號的 std::size_t,拿 int 去比較或相減(尤其空 vector 時 size()-1 會變成超大正數)容易出意外。索引迴圈用 std::size_t

  5. auto x = "字串" 當成 std::string 它其實是 const char*。要 std::string 就明確寫型別,或用 C++14 的 auto x = "字串"s;(需 using namespace std::string_literals;)。

深入探討(研究所視角)

前面說 push_back 像 Python 的 append,但 C++ 把背後的機制攤在你面前讓你能看清楚、甚至能調控。理解它,才算真正懂 vector。

記憶體佈局:vector 和原生陣列其實長得一樣。 一個 std::vector<int> 在記憶體裡通常就是三個指標:指向資料起點、指向已用資料的尾端、指向所配置空間的尾端。而它指向的那塊實際資料是連續的,和原生陣列毫無二致。這代表 &vec[0] 拿到的是一塊正規的連續記憶體,可以直接傳給只吃 C 陣列的舊函式。這種「連續性保證」是 vector 被選為預設容器的關鍵——它對 CPU 的快取(cache)非常友善,因為現代 CPU 一次會把相鄰的資料一起載入快取,連續存取時幾乎每次都命中。相比之下,鏈結串列(linked list)的節點散落各處,走訪時不斷快取失誤(cache miss),實務上往往比 vector 慢得多,即使理論上某些操作的複雜度更漂亮。這是「大 O 不是全部」的經典案例。

容量與大小是兩回事。 vector 有兩個數字:size()(目前裝了幾個元素)和 capacity()(這塊記憶體目前能裝幾個元素而不必重新配置)。capacity >= size 永遠成立。觀察它的成長很有啟發:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    for (int i = 0; i < 9; ++i) {
        v.push_back(i);
        std::cout << "size=" << v.size()
                  << " capacity=" << v.capacity() << "\n";
    }
    return 0;
}

// 典型輸出(依編譯器實作而異,GCC 為 2 倍成長):
// size=1 capacity=1
// size=2 capacity=2
// size=3 capacity=4
// size=4 capacity=4
// size=5 capacity=8
// size=6 capacity=8
// size=7 capacity=8
// size=8 capacity=8
// size=9 capacity=16

攤銷 $O(1)$ 的 push_back。 你可能會困惑:如果 vector 的資料必須連續,那當空間滿了、要新增一個元素時,不就得「配置一塊更大的記憶體、把舊資料全部複製過去」嗎?那單次複製是 $O(n)$ 啊,怎麼說 push_back 是 $O(1)$?

關鍵在於成長策略。vector 不是每滿一格就加一格,而是每次滿了就把容量翻倍(GCC 用 2 倍,部分實作用 1.5 倍)。這樣,從空一路 push_back 到 $n$ 個元素,重新配置只發生在容量為 $1, 2, 4, 8, \dots$ 的時刻,總複製次數約為 $1 + 2 + 4 + \dots + n \approx 2n$。把這 $2n$ 次複製平均分攤到 $n$ 次 push_back,每次平均只多做 2 次複製——也就是常數。這就是攤銷分析(amortized analysis):個別操作偶爾很貴($O(n)$),但長期平均下來是 $O(1)$,記作攤銷 $O(1)$。如果改用「每次只加 1 格」的線性成長,總複製會變成 $1 + 2 + \dots + n \approx n^2/2$,攤銷成本退化成 $O(n)$,效能天差地別。倍增成長正是攤銷 $O(1)$ 成立的數學前提。

你可以介入這個過程。 如果事先知道大概要裝多少,用 reserve() 一次配置好,就能完全避免中途的重新配置與複製:

std::vector<int> v;
v.reserve(1000);   // 先把容量開到 1000,後續 push_back 不再搬家
for (int i = 0; i < 1000; ++i) {
    v.push_back(i);
}

這種「我知道規模,先把記憶體談好」的最佳化,是 C++ 給你而 Python 通常拿不到的控制權。Python 的 list 背後也是動態陣列、也用類似的倍增策略,但它把 capacity 藏起來,你沒有 reserve 可呼叫。

這正是 C++ 設計哲學「零成本抽象」(zero-overhead abstraction)的縮影:std::vector 用起來像高階語言的 list 一樣方便,但它沒有為這份方便偷偷付出額外的執行成本——你不用的特性不會拖慢你,你想要的控制權(容量、記憶體佈局、是否複製)它都原原本本交給你。當你帶著 Python 的直覺進入 C++,真正要學的不是新語法,而是這份「方便背後的代價,現在由你掌握」的思維轉換。

AI 共讀助教正在陪你讀:C++ 陣列、字串與 vector:從手動記憶體到動態容器
嗨!我是這篇文章的共讀助教,只根據〈C++ 陣列、字串與 vector:從手動記憶體到動態容器〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。