post / 2023.12.12

資料結構實驗筆記

資料結構實驗筆記

本文合併了原先的八篇資料結構上機實驗報告,去掉了重複的套話和完整原始碼,只保留每個實驗的核心思路、關鍵資料結構和演算法要點,作為複習提綱使用。程式碼片段為 C++。

一、遞迴與堆疊:費氏數列

問題

用遞迴求第 $n$ 個 Fibonacci 數。

遞迴與堆疊的關係

遞迴能不能正確返回,取決於兩件事:參數如何傳遞、返回到哪裡。以 Fib(5) 為例:

  1. 每遞迴一層,都要給該層的形參重新分配空間;
  2. 還要把「本次呼叫結束後該回到哪一行」這個返回位址保存下來。

這兩樣東西合起來構成一條工作記錄(活動記錄),由系統壓入函式呼叫堆疊。呼叫時推入,返回時從堆疊頂端彈出,於是就能準確回到上一層。

以 Fib(3) = Fib(2) + Fib(1) 為例,呼叫鏈是:

Fib(3)
├── Fib(2)
│   ├── Fib(1) → 返回 1
│   └── Fib(0) → 返回 0
│   得 1
└── Fib(1) → 返回 1
得 2

演算法

int Fib(int n) {
    if (n == 0) return 0;
    if (n == 1) return 1;
    return Fib(n - 1) + Fib(n - 2);
}

這版遞迴每次都會重複計算大量子問題,時間複雜度是指數級的 $O(2^n)$。要高效求解應改用遞推或記憶化,$O(n)$ 即可。遞迴版本的價值在於演示堆疊的工作過程。

二、劃分集合問題

問題

把集合劃分為若干互不相交的子集,使得同一子集內的元素兩兩無衝突,並且子集數量盡量少。(典型應用:排課、日程安排。)

演算法:循環篩選法

  1. 先用一個二維陣列(衝突矩陣)描述衝突關係:$conflict[i][j] = 1$ 表示 $i$ 與 $j$ 衝突,$0$ 表示不衝突。
  2. 從第一個未分組元素開始,把它放入當前組。
  3. 依次檢查其餘未分組元素:如果它與當前組裡的每一個元素都不衝突,就併入當前組;否則跳過,留給下一輪。
  4. 一輪掃描結束後,若還有元素未分組,就開一個新組,重複第 2 步,直到所有元素都分完。

資料結構

用環狀佇列存放待處理元素。每掃描一輪,元素在佇列中輪轉一圈,不需要額外開闢儲存空間。

要點

  • 判斷「能否併入當前組」必須和組裡所有已有元素都比較,只看其中一個會出錯。
  • 這是貪心策略——能塞進當前組就塞,因此得到的是較優解而非嚴格最佳解。

三、稀疏矩陣乘法

問題

矩陣中非零元素很少時,用完整的二維陣列儲存會浪費大量空間,改用三元組只存非零元素,並實作兩個稀疏矩陣的乘法。

資料結構

struct Node {          // 一個非零元素
    int i, j;          // 列號、行號
    int data;          // 元素值
};

struct Matrix {
    int m, n;          // 總列數、總行數
    int L;             // 非零元素個數
    Node* Ma[MAX];     // 非零元素陣列
};

演算法

按定義 $C_{ij} = \sum_k A_{ik} \cdot B_{kj}$,但只累加兩邊都非零的項:

  1. 走訪 $A$ 的每一個非零元素 $(i, k, a)$;
  2. 在 $B$ 中尋找列號等於 $k$ 的非零元素 $(k, j, b)$;
  3. 把 $a \times b$ 累加到結果矩陣的 $(i, j)$ 位置。

結果矩陣的非零元素動態產生,若累加後為 0 則不存入。

要點

  • 比樸素三重迴圈快得多,因為跳過了大量零元素相乘。
  • 想進一步加速,可以給三元組表加列索引(列邏輯鏈結順序表),使第 2 步不必每次從頭掃描 $B$。
  • 必須校驗:$A$ 的行數是否等於 $B$ 的列數,以及輸入的列行索引是否越界。

四、KMP 字串比對

問題

在文本串中尋找模式串首次出現的位置。樸素比對在失配時把模式串整體右移一位、文本指標回退,最壞情況是 $O(nm)$;KMP 把它最佳化到 $O(n+m)$。

核心:LPS 陣列

LPS(Longest Prefix Suffix)記錄模式串每個位置之前的子串中,「最長且相等的前綴和後綴」的長度。它告訴我們在失配時模式串可以安全地向右滑動多少,從而不必回退文本指標。

以模式串 ABABCABAB 為例,其 LPS 陣列為:

索引012345678
字元ABABCABAB
LPS001201234

演算法

// 建構 LPS 陣列
void computeLPS(const string& pat, vector<int>& lps) {
    int len = 0;          // 當前最長前後綴匹配長度
    lps[0] = 0;
    int i = 1;
    while (i < (int)pat.size()) {
        if (pat[i] == pat[len]) {
            lps[i++] = ++len;
        } else if (len != 0) {
            len = lps[len - 1];   // 回退到次長前後綴,繼續比較
        } else {
            lps[i++] = 0;
        }
    }
}

// 在 text 中尋找 pat
void KMPSearch(const string& text, const string& pat) {
    int m = pat.size(), n = text.size();
    vector<int> lps(m);
    computeLPS(pat, lps);

    int i = 0, j = 0;          // i 走文本,j 走模式串
    while (i < n) {
        if (pat[j] == text[i]) { i++; j++; }
        if (j == m) {
            cout << "在索引 " << i - j << " 處找到匹配串" << endl;
            j = lps[j - 1];    // 繼續找下一次匹配
        } else if (i < n && pat[j] != text[i]) {
            if (j != 0) j = lps[j - 1];
            else        i++;   // j 已為 0,無法再回退,只能讓文本指標前進
        }
    }
}

要點

  • 文本指標 i 永不回退,這是 KMP 效率的來源。
  • 失配時利用 lps[j-1] 回退模式指標,而不是把 j 歸零。
  • 算 lps 的那段程式碼自身也是「模式串比對自己」,思路與主迴圈一致。
  • 範例:文本 ABABDABACDABABCABAB,模式 ABABCABAB,匹配位置為索引 10。

五、二元樹的儲存與走訪

問題

給定一串按**層序(BFS 順序)**輸入的字元,’@’ 表示空節點,建構二元樹;再分別實作順序儲存與鏈結儲存下的走訪。

兩種儲存方式

順序儲存:用陣列按索引存放,父子關係由索引算出:

  • 父節點索引為 $i$ → 左孩子 $2i+1$,右孩子 $2i+2$;
  • 反過來:節點 $i$ 的父節點是 $\lfloor (i-1)/2 \rfloor$。

適合完全二元樹;節點稀疏時會浪費大量陣列空間。

鏈結儲存:用結構體加指標,用佇列輔助建構。

struct TreeNode {
    char data;
    TreeNode* left;
    TreeNode* right;
};

建構流程:根節點入佇列 → 每從佇列前端取出一個節點,就依次為它掛上左、右孩子(輸入為 ‘@’ 則該側為空),並把新孩子入佇列。想改成按前序輸入建構,只需把佇列換成堆疊。

三種深度優先走訪

void preOrder(TreeNode* p) {          // 前序:根 → 左 → 右
    if (!p) return;
    visit(p);
    preOrder(p->left);
    preOrder(p->right);
}

void inOrder(TreeNode* p) {           // 中序:左 → 根 → 右
    if (!p) return;
    inOrder(p->left);
    visit(p);
    inOrder(p->right);
}

void postOrder(TreeNode* p) {         // 後序:左 → 右 → 根
    if (!p) return;
    postOrder(p->left);
    postOrder(p->right);
    visit(p);
}

廣度優先走訪

用佇列從根開始逐層處理:走訪一個節點時把它的左右孩子依次入佇列。

void levelOrder(TreeNode* root) {
    if (!root) return;
    queue<TreeNode*> q;
    q.push(root);
    while (!q.empty()) {
        TreeNode* p = q.front(); q.pop();
        visit(p);
        if (p->left)  q.push(p->left);
        if (p->right) q.push(p->right);
    }
}

完整範例

輸入 abc@def@@@@(層序,’@’ 為空),對應的二元樹是:

        a
      /   \
     b     c
    / \   / \
   @   d e   f
      / \ / \ / \
     @  @ @ @ @  @
走訪結果
前序(根左右)a b d c e f
中序(左根右)b d a e c f
後序(左右根)d b e f c a
層序(BFS)a b c d e f

順序儲存下中序走訪的起點要仔細處理:前序可以從索引 0 起步,但中序必須從整棵樹最左下的節點開始,否則會漏掉左子樹的左半邊。

由走訪序列反推二元樹

  • 前序 + 中序、後序 + 中序、層序 + 中序,都可以唯一確定一棵二元樹。
  • 但前序 + 後序不行,無法區分只有一個孩子時它掛在左邊還是右邊。

六、霍夫曼樹與編碼

問題

對字元按出現頻率編碼,使編碼後的總長度最短,並且任意編碼都不是另一個編碼的前綴(前綴碼,保證能唯一解碼)。

演算法

  1. 統計每個字元的出現頻率。
  2. 每個字元建一個葉節點,全部放入最小堆。
  3. 迴圈:取出頻率最小的兩個節點,合併為一個新節點(頻率為兩者之和),把新節點放回堆中。
  4. 堆中只剩一個節點時,它就是霍夫曼樹的根。
while (minHeap.size() > 1) {
    auto left  = minHeap.top(); minHeap.pop();
    auto right = minHeap.top(); minHeap.pop();
    auto parent = new HuffmanNode('$', left->frequency + right->frequency);
    parent->left = left;
    parent->right = right;
    minHeap.push(parent);
}

產生編碼與解碼

  • 產生編碼:從根出發遞迴走訪,往左走記 0,往右走記 1,走到葉節點時把當前路徑串記為該字元的編碼。
  • 編碼:把原文每個字元替換成它的編碼串。
  • 解碼:從根出發,遇到 0 走左、遇到 1 走右,走到葉節點就輸出該字元並回到根重新開始。

要點

  • 頻率越高的字元離根越近,編碼越短——這就是壓縮的原理。
  • 因為編碼都在葉節點上,所以不存在「某個編碼是另一個前綴」的情況,解碼不會歧義。
  • 用最小堆而不是反覆排序,可以把建構複雜度降到 $O(n \log n)$。
  • 出現頻率相同的字元,誰先誰後不影響編碼長度,只影響具體分配到的 0/1 串。

七、排序二元樹與節點刪除

問題

建構一棵排序二元樹(BST),支援插入、中序走訪和刪除節點。

插入

遞迴比較:待插值小於當前節點就往左走,大於就往右走;走到空位就新建節點掛上。

TreeNode* insert(TreeNode* root, int val) {
    if (!root) return new TreeNode(val);
    if (val < root->val)      root->left  = insert(root->left,  val);
    else if (val > root->val) root->right = insert(root->right, val);
    return root;   // 相等時不插入,避免重複
}

中序走訪

中序走訪 BST 會得到遞增序列,這是檢驗 BST 是否建構正確最直接的辦法。

刪除節點

刪除是最容易寫錯的一步,分三種情況:

情況處理方式
葉節點(無孩子)直接刪除
只有一個孩子用這個孩子頂替自己的位置
有兩個孩子用右子樹中的最小節點(中序後繼)替換自己的值,再遞迴刪除那個後繼節點
TreeNode* remove(TreeNode* root, int val) {
    if (!root) return nullptr;
    if (val < root->val)      root->left  = remove(root->left,  val);
    else if (val > root->val) root->right = remove(root->right, val);
    else {
        if (!root->left)  { auto t = root->right; delete root; return t; }
        if (!root->right) { auto t = root->left;  delete root; return t; }
        // 兩個孩子:找右子樹最小節點
        TreeNode* succ = root->right;
        while (succ->left) succ = succ->left;
        root->val  = succ->val;
        root->right = remove(root->right, succ->val);
    }
    return root;
}

也可以用左子樹的最大節點(中序前驅)來替換,效果等價。

刪除整棵樹

後序走訪每個節點依次釋放,不能先釋放父節點再找孩子。

八、二元樹線索化

問題

$n$ 個節點的二元樹共有 $2n$ 個指標欄位,其中實際使用的只有 $n-1$ 條邊,剩下 $n+1$ 個是空指標,浪費明顯。線索化就是用這些空指標存放走訪次序下的前驅/後繼,讓走訪不必再依賴堆疊或遞迴。

資料結構

struct Node {
    int data;
    Node *left, *right;
    int ltag, rtag;   // 0 表示指向孩子,1 表示是線索(指向前驅/後繼)
};

演算法(以中序線索化為例)

用遞迴中序走訪,同時維護一個全域的 pre 指標記錄上一個走訪的節點:

Node* pre = nullptr;

void thread(Node* p) {
    if (!p) return;
    thread(p->left);              // 先處理左子樹

    if (!p->left) {               // 左孩子為空,左指標改指向前驅
        p->left = pre;
        p->ltag = 1;
    }
    if (pre && !pre->right) {     // 前驅的右孩子為空,右指標改指向後繼
        pre->right = p;
        pre->rtag = 1;
    }

    pre = p;                      // 更新前驅
    thread(p->right);             // 再處理右子樹
}

要點

  • left / right 指標的語意是雙重的,必須靠 ltag / rtag 區分,否則會把線索當成孩子指標用,導致無窮迴圈。
  • 中序線索化要先遞迴左子樹,再處理當前節點,最後遞迴右子樹;pre = p 的位置決定了線索指向誰。
  • 前序、後序線索化的思路相同,區別在於處理當前節點的時機與 pre 的更新順序。
  • 線索化後的樹可以用空間 $O(1)$ 完成走訪,不需要堆疊,也不必遞迴。

九、圖的儲存:相鄰矩陣與相鄰串列

相鄰矩陣

用二維陣列 adj[V][V] 存邊:adj[i][j] = 1(或邊的權重)表示 $i \to j$ 有邊。

  • 優點:判斷兩點間是否有邊是 $O(1)$;實作簡單。
  • 缺點:空間固定為 $O(V^2)$,稀疏圖浪費嚴重;走訪某點的所有鄰居要掃描一整列,$O(V)$。
  • 無向圖的矩陣沿主對角線對稱。

相鄰串列

用「陣列 + 鏈結串列」:adj[i] 存放所有與節點 $i$ 相鄰的節點。

vector<int> adj[MAX];          // 每個元素是一個鏈結串列/動態陣列
void addEdge(int u, int v) {
    adj[u].push_back(v);
    adj[v].push_back(u);       // 無向圖要加反向邊
}
  • 優點:空間 $O(V + E)$,稀疏圖友善;走訪某點的鄰居只拜訪真實存在的邊。
  • 缺點:判斷兩點間是否有邊要走走訪鏈結串列,最壞 $O(V)$。
  • 有向圖中,adj[u] 存的是 $u$ 的出邊;求入度需要額外走訪或另建逆相鄰串列。

怎麼選

場景選擇
稠密圖、需要頻繁查詢兩點是否相鄰相鄰矩陣
稀疏圖、需要頻繁走訪某點的所有鄰居相鄰串列
點數極大(如 $10^5$)相鄰串列(矩陣根本開不下)

建圖時的檢查

讀入邊 $(u, v)$ 後要校驗 $0 \le u, v < V$,避免越界;無權圖的兩點間不應重複加邊,否則會影響走訪與度數統計。

十、Kruskal 與最小生成樹

問題

在帶權連通無向圖中找一棵總權重最小的生成樹。

演算法(貪心)

  1. 把所有邊按權重遞增排序。
  2. 依次考察每條邊:如果它的兩個端點還不在同一個集合中,就把它加入生成樹,並合併這兩個集合;否則說明會形成環,直接丟棄。
  3. 選夠 $V-1$ 條邊時結束。
sort(edges.begin(), edges.end(),
     [](const Edge& a, const Edge& b) { return a.weight < b.weight; });

UnionFind uf(numNodes);
vector<Edge> result;
for (const Edge& e : edges) {
    if (uf.find(e.start) != uf.find(e.end)) {   // 不連通 → 加入不會成環
        result.push_back(e);
        uf.unite(e.start, e.end);
    }
}

並查集(Union-Find)

Kruskal 的效率關鍵在「判斷兩個點是否已連通」,這正是並查集的強項。它支援兩種操作:

  • Find(x):找到 $x$ 所在集合的根節點,順帶判斷兩點是否同集合。
  • Union(a, b):把兩個集合合併。

兩種最佳化配合使用可讓均攤複雜度接近常數:

最佳化做法效果
路徑壓縮Find 時把路徑上所有節點直接掛到根上大幅降低樹高
按秩合併把矮樹掛到高樹下面避免樹長歪
int find(int x) {
    if (parent[x] != x) parent[x] = find(parent[x]);   // 路徑壓縮
    return parent[x];
}

void unite(int a, int b) {
    a = find(a); b = find(b);
    if (a == b) return;
    if (rank[a] < rank[b]) swap(a, b);   // 按秩合併
    parent[b] = a;
    if (rank[a] == rank[b]) rank[a]++;
}

要點

  • 時間複雜度 $O(E \log E)$,主要花在排序上。
  • 適合稀疏圖。稠密圖通常用 Prim 演算法($O(V^2)$ 或配堆的 $O(E \log V)$)更快。
  • 邊的排序、並查集的判環,是 Kruskal 的兩個核心動作。

十一、Dijkstra 與單源最短路徑

問題

給定帶權圖和起點 $s$,求 $s$ 到其餘所有頂點的最短距離。

演算法(貪心 + 優先佇列)

  1. 把起點距離設為 0,其餘全部設為無限大。
  2. 把 (距離, 頂點) 放入優先佇列,每次取出距離最小的頂點 $u$——它的最短距離此刻已經確定。
  3. 鬆弛:對 $u$ 的每個鄰居 $v$,若 $dist[u] + w(u,v) < dist[v]$ 則更新 $dist[v]$,並把新的 (dist[v], v) 入佇列。
  4. 重複 2–3,直到佇列為空。
vector<int> dijkstra(const vector<vector<int>>& g, int src) {
    int n = g.size();
    vector<int> dist(n, INF);
    // 小頂堆:佇列前端是距離最小的元素
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;

    dist[src] = 0;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;        // 過期條目,跳過
        for (int v = 0; v < n; v++) {
            if (g[u][v] == 0) continue;   // 0 表示無直接連接
            if (dist[u] + g[u][v] < dist[v]) {
                dist[v] = dist[u] + g[u][v];
                pq.push({dist[v], v});
            }
        }
    }
    return dist;
}

要點

  • 不能處理負權邊。Dijkstra 的正確性依賴「已取出的點距離不會再變小」,負權邊會打破這個前提;含負權時改用 Bellman-Ford 或 SPFA。
  • 用相鄰矩陣表示圖時,內層走訪是 $O(V)$,總複雜度 $O(V^2)$;換成相鄰串列配優先佇列可降到 $O(E \log V)$。
  • 同一個點可能被多次入佇列,所以出佇列時要判斷 d > dist[u] 跳過過期條目。
  • 用 0 表示「無邊」是一種約定;若圖中允許權重為 0 的邊,需要另設標記或改用相鄰串列。

十二、關鍵路徑

問題

在 AON(活動在頂點上)網路中,每個節點表示一個活動、帶有持續時間和先後依賴,找出決定專案總工期的關鍵活動序列。

  • 最早開始時間 ES:該活動最早能什麼時候開工。
  • 最晚開始時間 LS:在不拖延總工期的前提下,最晚必須什麼時候開工。
  • 時間餘量:$LS - ES$。餘量為 0 的活動就是關鍵活動,它們連起來構成關鍵路徑。

正向計算 ES

按拓樸序從頭事件往後推:

$$ES[v] = \max_{u \in pred(v)} \big( ES[u] + duration[u] \big)$$

沒有前置活動的頭事件 $ES = 0$。專案總工期 $T = \max_v (ES[v] + duration[v])$。

void calcEarliest() {
    // 必須先按拓樸序處理,否則前驅的 ES 還沒算好
    for (int u : topoOrder) {
        for (int v : activities[u].next) {
            earliest[v] = max(earliest[v], earliest[u] + activities[u].duration);
        }
    }
}

反向計算 LS

按逆拓樸序從末事件往回推:

$$LS[u] = \min_{v \in succ(u)} \big( LS[v] \big) - duration[u]$$

沒有後繼的末事件,$LS = ES = T - duration$。(等價地,令 $LF[\cdot] = T$,則 $LS = LF - duration$,$LF[u] = \min_{v \in succ(u)} LS[v]$。)

void calcLatest() {
    for (int u : reverseTopoOrder) {
        for (int v : activities[u].next) {
            latest[u] = min(latest[u], latest[v] - activities[u].duration);
        }
    }
}

要點

  • 兩個方向都必須嚴格按拓樸序推進,否則用到的前驅/後繼還沒算好。
  • 判斷頭事件不能看「相鄰串列是否為空」——相鄰串列為空的是末事件(出度為 0)。頭事件的判定依據是入度為 0,這個方向搞反會導致整個計算落空。
  • 關鍵路徑可能不止一條,全部 $\text{餘量} = 0$ 的活動都要標記出來。
  • 想要縮短總工期,只能壓縮關鍵活動;壓縮非關鍵活動沒有意義。

小結

實驗核心資料結構關鍵點
Fibonacci函式呼叫堆疊遞迴靠堆疊保存參數與返回位址
劃分集合衝突矩陣 + 環狀佇列與組內所有元素比較,貪心分組
稀疏矩陣乘法三元組表只累加兩邊都非零的項
KMPLPS 陣列文本指標永不回退
二元樹陣列/鏈結結構順序儲存的父子索引關係;三種 DFS + 一種 BFS
霍夫曼最小堆每次合併頻率最小的兩個節點
排序二元樹二元鏈結串列刪除分三種情況;中序得遞增序列
線索化帶標記位的二元鏈結串列用 ltag/rtag 區分孩子與線索
圖的儲存相鄰矩陣/相鄰串列稠密用矩陣,稀疏用串列
Kruskal邊集 + 並查集排序取邊,判環靠並查集
Dijkstra優先佇列貪心鬆弛,不支援負權邊
關鍵路徑有向無環圖 + 拓樸序正推 ES、反推 LS,餘量為 0 即關鍵

本文作者: 孤筝

本文標題: 資料結構實驗筆記

本文發佈時間: 2023-12-12

本文更新時間: 2026-10-05

本文版權: CC BY-NC-SA 4.0

何時一樽酒,重與細論文。

搜尋