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

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:從手動記憶體到動態容器〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。