二元樹 — 四種遍歷方式與遞迴/迭代實作完整教學 | 資料結構與演算法

2026/06/22
二元樹 — 四種遍歷方式與遞迴/迭代實作完整教學 | 資料結構與演算法

二元樹(Binary Tree) 是每個節點最多有兩個子節點的樹狀結構,也是學習遞迴思維的最佳切入點。掌握四種遍歷方式與遞迴/迭代的雙重實作,你將能解決絕大多數樹相關的面試題,同時深刻理解 DFSBFS 兩大搜尋框架的本質。

前言

想像你是一個家族族譜的管理員。族譜是一棵樹,每個人最多有兩個子女(左子節點和右子節點)。現在要「走訪」所有成員,你有幾種選法?

  • 前序:先記錄自己,再去左邊,最後去右邊(從頂層長輩開始,按輩份往下記)
  • 中序:先走完左邊所有人,再記錄自己,最後走右邊(若依名字排序,中序走法恰好得到有序結果)
  • 後序:先走完兩邊所有子孫,最後才記錄自己(適合計算每個人的「後代人數」)
  • 層序:一代一代地走,先記錄所有第一代,再記錄所有第二代(像公司的組織圖展示)

這就是二元樹遍歷的本質——何時處理當前節點的選擇。

本文學習要點:

  • 四種遍歷的概念、圖解與使用場景
  • 遞迴與迭代兩種實作方式(含 Morris Traversal 的 O(1) 空間版本)
  • 樹的高度、深度、節點計數
  • 完全二元樹與滿二元樹的定義與性質
  • C++ 對照實作與複雜度分析
  • 五道 LeetCode 練習題解析

核心概念

樹的基本術語

在正式寫程式碼之前,先把術語對齊:

術語說明
根節點(Root)樹的頂端,沒有父節點
葉節點(Leaf)沒有子節點的節點
高度(Height)從節點到最遠葉節點的邊數(根的高度 = 整棵樹的高度)
深度(Depth)從根節點到該節點的邊數(根的深度 = 0)
層(Level)深度相同的節點集合(根在第 0 層或第 1 層,依定義而定)

二元樹的類型

完全二元樹(Complete Binary Tree):除了最後一層外,每一層都填滿節點,且最後一層的節點靠左對齊。堆積(Heap) 就是完全二元樹,可以用陣列高效儲存,index i 的左右子節點分別在 2i+12i+2

滿二元樹(Full Binary Tree):每個節點要麼是葉節點(0 個子節點),要麼有兩個子節點。節點數與葉節點數滿足:葉節點數 = 內部節點數 + 1

平衡二元樹(Balanced Binary Tree):任意節點的左右子樹高度差不超過 1。AVL Tree 和 Red-Black Tree 都是平衡二元搜尋樹(下一篇主題)。

四種遍歷的本質與圖解

遍歷的本質只有一件事:決定何時處理當前節點

        1
       / \
      2   3
     / \ / \
    4  5 6  7
遍歷方式英文處理時機結果
前序Preorder進入節點時(根→左→右)1, 2, 4, 5, 3, 6, 7
中序Inorder左子樹處理完後(左→根→右)4, 2, 5, 1, 6, 3, 7
後序Postorder離開節點時(左→右→根)4, 5, 2, 6, 7, 3, 1
層序Level Order逐層從上到下(BFS)[1], [2, 3], [4, 5, 6, 7]

迭代前序使用 Stack(後進先出)的執行過程:

push 1 → pop 1 (處理), push 3, push 2
pop 2 (處理), push 5, push 4
pop 4 (處理)
pop 5 (處理)
pop 3 (處理), push 7, push 6
pop 6 (處理)
pop 7 (處理)
→ 結果:1, 2, 4, 5, 3, 6, 7

注意:右子節點先入 Stack,因為 Stack 是後進先出,左子節點要先處理。

四種遍歷的實際應用場景

  • 前序:複製樹(先建根再建子節點)、序列化、DOM 渲染
  • 中序:在 BST 中取得有序序列、資料庫索引
  • 後序:刪除樹(先刪子節點才能刪父節點)、計算子樹大小、編譯器的 AST(抽象語法樹)求值
  • 層序:組織圖渲染、最短路徑(無權重樹)、鋸齒形遍歷

JavaScript / TypeScript 實作

TreeNode 節點定義

// ─── 節點定義 ────────────────────────────────────────────────────────────────
class TreeNode {
  val: number;
  left: TreeNode | null;
  right: TreeNode | null;

  constructor(
    val: number = 0,
    left: TreeNode | null = null,
    right: TreeNode | null = null
  ) {
    this.val = val;
    this.left = left;
    this.right = right;
  }
}

前序遍歷(Preorder Traversal)

// 遞迴版本:O(n) time, O(h) space(h = 樹高)
function preorderRecursive(root: TreeNode | null): number[] {
  const result: number[] = [];

  function dfs(node: TreeNode | null): void {
    if (!node) return;
    result.push(node.val);  // 根:先處理自己
    dfs(node.left);          // 左:再走左子樹
    dfs(node.right);         // 右:最後走右子樹
  }

  dfs(root);
  return result;
}

// 迭代版本:使用顯式 Stack,右先入(後進先出,所以左先處理)
function preorderIterative(root: TreeNode | null): number[] {
  if (!root) return [];
  const result: number[] = [];
  const stack: TreeNode[] = [root];

  while (stack.length > 0) {
    const node = stack.pop()!;
    result.push(node.val);
    if (node.right) stack.push(node.right); // 右先入
    if (node.left) stack.push(node.left);   // 左後入(先出)
  }

  return result;
}

中序遍歷(Inorder Traversal)

// 遞迴版本:左→根→右
function inorderRecursive(root: TreeNode | null): number[] {
  const result: number[] = [];

  function dfs(node: TreeNode | null): void {
    if (!node) return;
    dfs(node.left);          // 左
    result.push(node.val);  // 根:中間才處理
    dfs(node.right);         // 右
  }

  dfs(root);
  return result;
}

// 迭代版本:一路向左壓 Stack,彈出後轉向右子樹
function inorderIterative(root: TreeNode | null): number[] {
  const result: number[] = [];
  const stack: TreeNode[] = [];
  let curr: TreeNode | null = root;

  while (curr !== null || stack.length > 0) {
    // 一路向左,把沿途節點全壓入 Stack
    while (curr !== null) {
      stack.push(curr);
      curr = curr.left;
    }
    // 左子樹走完,彈出並處理
    curr = stack.pop()!;
    result.push(curr.val);
    // 轉向右子樹
    curr = curr.right;
  }

  return result;
}

後序遍歷(Postorder Traversal)

// 遞迴版本:左→右→根
function postorderRecursive(root: TreeNode | null): number[] {
  const result: number[] = [];

  function dfs(node: TreeNode | null): void {
    if (!node) return;
    dfs(node.left);
    dfs(node.right);
    result.push(node.val);  // 根:最後才處理
  }

  dfs(root);
  return result;
}

// 迭代版本技巧:前序是「根→左→右」,後序是「左→右→根」
// 只要把前序改成「根→右→左」(右先入 Stack),再 reverse 即得後序
function postorderIterative(root: TreeNode | null): number[] {
  if (!root) return [];
  const result: number[] = [];
  const stack: TreeNode[] = [root];

  while (stack.length > 0) {
    const node = stack.pop()!;
    result.push(node.val);
    if (node.left) stack.push(node.left);   // 左先入(後處理)
    if (node.right) stack.push(node.right); // 右後入(先處理)
    // 此時順序是 根→右→左
  }

  return result.reverse(); // 反轉後得到 左→右→根
}

層序遍歷(Level Order Traversal)

// 使用 Queue(先進先出)逐層展開
// 關鍵技巧:用 levelSize 固定「每一層的節點數」,避免 queue 動態增長影響判斷
function levelOrder(root: TreeNode | null): number[][] {
  if (!root) return [];
  const result: number[][] = [];
  const queue: TreeNode[] = [root];

  while (queue.length > 0) {
    const levelSize = queue.length; // 固定本層節點數
    const level: number[] = [];

    for (let i = 0; i < levelSize; i++) {
      const node = queue.shift()!;
      level.push(node.val);
      if (node.left) queue.push(node.left);
      if (node.right) queue.push(node.right);
    }

    result.push(level);
  }

  return result;
}

// 測試
const root = new TreeNode(1,
  new TreeNode(2, new TreeNode(4), new TreeNode(5)),
  new TreeNode(3, new TreeNode(6), new TreeNode(7))
);
console.log(levelOrder(root)); // [[1], [2, 3], [4, 5, 6, 7]]

樹的高度、深度與節點計數

// 最大深度(即樹的高度):後序 DFS,當前高度 = 1 + max(左高度, 右高度)
// LeetCode 104
function maxDepth(root: TreeNode | null): number {
  if (!root) return 0;
  return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}

// 最小深度:注意葉節點的定義(左右子節點都為 null 才算葉節點)
function minDepth(root: TreeNode | null): number {
  if (!root) return 0;
  // 只有右子樹時,不能直接取 min(左邊的 0 不代表葉節點)
  if (!root.left) return 1 + minDepth(root.right);
  if (!root.right) return 1 + minDepth(root.left);
  return 1 + Math.min(minDepth(root.left), minDepth(root.right));
}

// 節點計數:遍歷所有節點
function countNodes(root: TreeNode | null): number {
  if (!root) return 0;
  return 1 + countNodes(root.left) + countNodes(root.right);
}

Morris 中序遍歷(O(1) 空間)

Morris Traversal 利用葉節點的空指標,暫時串連回祖先,省去 Stack 空間,讓空間複雜度降至 O(1):

// 核心思想:找當前節點的中序前驅(左子樹最右節點),
// 將其 right 指標暫時指向當前節點,形成「線索(Thread)」
function morrisInorder(root: TreeNode | null): number[] {
  const result: number[] = [];
  let curr: TreeNode | null = root;

  while (curr !== null) {
    if (curr.left === null) {
      // 左子樹為空,直接處理當前節點,向右移動
      result.push(curr.val);
      curr = curr.right;
    } else {
      // 找左子樹的最右節點(中序前驅)
      let predecessor: TreeNode = curr.left;
      while (predecessor.right !== null && predecessor.right !== curr) {
        predecessor = predecessor.right;
      }

      if (predecessor.right === null) {
        // 第一次訪問:建立線索,深入左子樹
        predecessor.right = curr;
        curr = curr.left;
      } else {
        // 第二次訪問:移除線索,處理當前節點
        predecessor.right = null; // 務必恢復結構!
        result.push(curr.val);
        curr = curr.right;
      }
    }
  }

  return result;
}

Morris Traversal 的注意事項:第二次訪問前驅節點時,必須將線索(predecessor.right = null)恢復,否則會破壞原樹結構,導致後續遍歷出錯——這是最容易犯的錯誤。

翻轉二元樹

// LeetCode 226:後序 DFS,先翻轉子節點再交換
function invertTree(root: TreeNode | null): TreeNode | null {
  if (!root) return null;

  const left = invertTree(root.left);
  const right = invertTree(root.right);

  // 子節點翻轉完成後,交換自己的左右子節點
  root.left = right;
  root.right = left;

  return root;
}

從前序 + 中序建構二元樹(LeetCode 105)

// 關鍵洞察:
// 1. preorder[0] 必然是整棵樹的根
// 2. 在 inorder 找到根的位置 mid
// 3. inorder[0..mid-1] 是左子樹,inorder[mid+1..n-1] 是右子樹
// 4. 用 Map 預存中序索引,將查詢從 O(n) 降至 O(1)
function buildTree(preorder: number[], inorder: number[]): TreeNode | null {
  const map = new Map<number, number>();
  inorder.forEach((val, i) => map.set(val, i));

  let preIdx = 0;

  function helper(left: number, right: number): TreeNode | null {
    if (left > right) return null;

    const rootVal = preorder[preIdx++];
    const node = new TreeNode(rootVal);
    const mid = map.get(rootVal)!;

    // 注意:必須先建左子樹(對應 preIdx 的下一個元素)
    node.left = helper(left, mid - 1);
    node.right = helper(mid + 1, right);

    return node;
  }

  return helper(0, inorder.length - 1);
}

二元樹最大路徑和(LeetCode 124)

// 難點:區分兩個量
// gain(node) = node.val + max(gain(left), gain(right))    ← 只能選一邊,用於回傳父節點
// path(node) = node.val + gain(left) + gain(right)        ← 左右都選,用於更新全局答案
function maxPathSum(root: TreeNode | null): number {
  let ans = -Infinity;

  function gain(node: TreeNode | null): number {
    if (!node) return 0;

    // 若子樹貢獻為負,不如不選(取 0)
    const L = Math.max(0, gain(node.left));
    const R = Math.max(0, gain(node.right));

    // 以當前節點為「拐點」的最大路徑和(可左右都選)
    ans = Math.max(ans, node.val + L + R);

    // 向父節點只能提供單邊最大值(路徑不能分叉)
    return node.val + Math.max(L, R);
  }

  gain(root);
  return ans;
}

C++ 對照

#include <algorithm>
#include <climits>
#include <queue>
#include <stack>
#include <unordered_map>
#include <vector>

// ─── 節點定義(LeetCode 常用裸指標版)────────────────────────────────────────
struct TreeNode {
  int val;
  TreeNode* left;
  TreeNode* right;
  TreeNode(int v = 0, TreeNode* l = nullptr, TreeNode* r = nullptr)
      : val(v), left(l), right(r) {}
};

// ─── 前序迭代遍歷 ─────────────────────────────────────────────────────────────
std::vector<int> preorderIterative(TreeNode* root) {
  std::vector<int> result;
  if (!root) return result;

  std::stack<TreeNode*> stk;
  stk.push(root);

  while (!stk.empty()) {
    auto* node = stk.top(); stk.pop();
    result.push_back(node->val);
    if (node->right) stk.push(node->right); // 右先入
    if (node->left)  stk.push(node->left);  // 左後入
  }
  return result;
}

// ─── 中序迭代遍歷 ─────────────────────────────────────────────────────────────
std::vector<int> inorderIterative(TreeNode* root) {
  std::vector<int> result;
  std::stack<TreeNode*> stk;
  auto* curr = root;

  while (curr || !stk.empty()) {
    while (curr) {
      stk.push(curr);
      curr = curr->left;
    }
    curr = stk.top(); stk.pop();
    result.push_back(curr->val);
    curr = curr->right;
  }
  return result;
}

// ─── 後序迭代遍歷(前序修改版 + reverse)─────────────────────────────────────
std::vector<int> postorderIterative(TreeNode* root) {
  std::vector<int> result;
  if (!root) return result;

  std::stack<TreeNode*> stk;
  stk.push(root);

  while (!stk.empty()) {
    auto* node = stk.top(); stk.pop();
    result.push_back(node->val);
    if (node->left)  stk.push(node->left);  // 左先入
    if (node->right) stk.push(node->right); // 右後入
  }
  std::reverse(result.begin(), result.end()); // 根→右→左 反轉 = 左→右→根
  return result;
}

// ─── 層序遍歷 ─────────────────────────────────────────────────────────────────
std::vector<std::vector<int>> levelOrder(TreeNode* root) {
  std::vector<std::vector<int>> result;
  if (!root) return result;

  std::queue<TreeNode*> q;
  q.push(root);

  while (!q.empty()) {
    int levelSize = static_cast<int>(q.size());
    std::vector<int> level;
    level.reserve(levelSize);

    for (int i = 0; i < levelSize; ++i) {
      auto* node = q.front(); q.pop();
      level.push_back(node->val);
      if (node->left)  q.push(node->left);
      if (node->right) q.push(node->right);
    }
    result.push_back(std::move(level));
  }
  return result;
}

// ─── Morris 中序遍歷(O(1) 空間)─────────────────────────────────────────────
std::vector<int> morrisInorder(TreeNode* root) {
  std::vector<int> result;
  auto* curr = root;

  while (curr) {
    if (!curr->left) {
      result.push_back(curr->val);
      curr = curr->right;
    } else {
      auto* pred = curr->left;
      while (pred->right && pred->right != curr)
        pred = pred->right;

      if (!pred->right) {
        pred->right = curr;   // 建立線索
        curr = curr->left;
      } else {
        pred->right = nullptr; // 移除線索
        result.push_back(curr->val);
        curr = curr->right;
      }
    }
  }
  return result;
}

// ─── 最大深度 ─────────────────────────────────────────────────────────────────
int maxDepth(TreeNode* root) {
  if (!root) return 0;
  return 1 + std::max(maxDepth(root->left), maxDepth(root->right));
}

// ─── 翻轉二元樹(LeetCode 226)───────────────────────────────────────────────
TreeNode* invertTree(TreeNode* root) {
  if (!root) return nullptr;
  TreeNode* left  = invertTree(root->left);
  TreeNode* right = invertTree(root->right);
  root->left  = right;
  root->right = left;
  return root;
}

// ─── 從前序 + 中序建構(LeetCode 105)───────────────────────────────────────
class BuildTreeSolution {
  std::unordered_map<int, int> inorderMap;
  int preIdx = 0;

  TreeNode* build(const std::vector<int>& preorder, int inLeft, int inRight) {
    if (inLeft > inRight) return nullptr;
    int rootVal = preorder[preIdx++];
    auto* root  = new TreeNode(rootVal);
    int inMid   = inorderMap[rootVal];
    root->left  = build(preorder, inLeft, inMid - 1);
    root->right = build(preorder, inMid + 1, inRight);
    return root;
  }

 public:
  TreeNode* buildTree(std::vector<int>& preorder, std::vector<int>& inorder) {
    for (int i = 0; i < (int)inorder.size(); ++i)
      inorderMap[inorder[i]] = i;
    return build(preorder, 0, (int)inorder.size() - 1);
  }
};

複雜度分析

操作時間複雜度空間複雜度(遞迴/迭代)空間複雜度(Morris)
四種遍歷O(n)O(h)O(1)(Morris 限中序)
最大/最小深度O(n)O(h)
翻轉二元樹O(n)O(h)
建構(前序+中序)O(n)O(n)
最大路徑和O(n)O(h)

h = 樹高。平衡樹為 O(log n),退化成鏈結串列(所有節點都偏一側)時 h = O(n)。


變體與延伸

線索二元樹(Threaded Binary Tree)

Morris Traversal 是「臨時線索化」,而線索二元樹是把空指標永久利用起來,指向中序前驅/後繼。這讓遍歷可以 O(1) 空間進行,且不需要在遍歷過程中動態建立/移除線索。

N-ary Tree(多叉樹)

二元樹的每個節點最多兩個子節點,而 N-ary Tree 可有任意數量的子節點。DOM Tree 和檔案系統目錄樹都是多叉樹。

前序遍歷的邏輯完全相同,只是從 dfs(left), dfs(right) 改成 for (const child of node.children) dfs(child)

真實應用場景

前端 DOM 樹遍歷(前序):瀏覽器的 DOM 就是一棵樹,React 的 Virtual DOM diff 算法使用前序 DFS 遍歷節點進行渲染更新。

組織架構圖渲染(層序 BFS):企業組織圖、文件目錄等按層展示的視覺化,天然對應層序遍歷。

編譯器表達式解析樹(後序)(a + b) * c 被解析成抽象語法樹(AST),後序遍歷即可生成逆波蘭表示法,對應機器指令執行順序。

    *
   / \
  +   c
 / \
a   b

後序遍歷:a b + c *   ← 逆波蘭表示法(RPN)

面試考點

常見陷阱一:忘記處理 null 節點

幾乎所有樹的遞迴函數第一行都需要 if (!root) return ...,忘記會導致 null 解參考(null dereference)錯誤。

常見陷阱二:層序遍歷不固定 levelSize

// 錯誤寫法:queue 在迴圈中動態增長,導致本層和下層混在一起
for (let i = 0; i < queue.length; i++) { /* 每次 push 都會增大 queue.length */ }

// 正確寫法:先固定本層的節點數
const levelSize = queue.length;
for (let i = 0; i < levelSize; i++) { /* levelSize 是固定的 */ }

常見陷阱三:建構樹時左右子樹順序(LeetCode 105)

必須先建左子樹再建右子樹,因為 preIdx 是共用的閉包變數,順序錯誤會導致錯誤的前序索引對應。

常見陷阱四:最大路徑和混淆兩個量(LeetCode 124)

向父節點回傳的值只能帶一邊(路徑不能分叉),但更新全局答案時可以左右兩邊相加。這是最常混淆的地方:

// 向父節點回傳:只能選左或右其中一邊
return node.val + Math.max(L, R);

// 更新全局答案:可以左右兩邊都選(以當前節點為「拐點」)
ans = Math.max(ans, node.val + L + R);

常見陷阱五:Morris Traversal 忘記恢復結構

第二次訪問前驅節點時,必須執行 predecessor.right = null,否則原本的樹結構被破壞,後續遍歷結果錯誤。


LeetCode 練習

LeetCode 144 — Binary Tree Preorder Traversal(Easy)

直接套用迭代前序模板:Stack + 右先入左後入。

function preorderTraversal(root: TreeNode | null): number[] {
  if (!root) return [];
  const result: number[] = [];
  const stack: TreeNode[] = [root];

  while (stack.length > 0) {
    const node = stack.pop()!;
    result.push(node.val);
    if (node.right) stack.push(node.right);
    if (node.left) stack.push(node.left);
  }
  return result;
}
// 時間 O(n),空間 O(h)

LeetCode 94 — Binary Tree Inorder Traversal(Easy)

套用中序迭代模板:一路向左壓 Stack,彈出後轉向右子樹。

function inorderTraversal(root: TreeNode | null): number[] {
  const result: number[] = [];
  const stack: TreeNode[] = [];
  let curr: TreeNode | null = root;

  while (curr !== null || stack.length > 0) {
    while (curr !== null) {
      stack.push(curr);
      curr = curr.left;
    }
    curr = stack.pop()!;
    result.push(curr.val);
    curr = curr.right;
  }
  return result;
}
// 時間 O(n),空間 O(h)

LeetCode 102 — Binary Tree Level Order Traversal(Medium)

層序遍歷的標準實作,用 levelSize 技巧按層分組。

function levelOrderTraversal(root: TreeNode | null): number[][] {
  if (!root) return [];
  const result: number[][] = [];
  const queue: TreeNode[] = [root];

  while (queue.length > 0) {
    const levelSize = queue.length;
    const level: number[] = [];

    for (let i = 0; i < levelSize; i++) {
      const node = queue.shift()!;
      level.push(node.val);
      if (node.left) queue.push(node.left);
      if (node.right) queue.push(node.right);
    }
    result.push(level);
  }
  return result;
}
// 時間 O(n),空間 O(n)(最底層可能有 n/2 個節點)

LeetCode 104 — Maximum Depth of Binary Tree(Easy)

後序 DFS,當前深度 = 1 + max(左深度, 右深度)。

function maximumDepth(root: TreeNode | null): number {
  if (!root) return 0;
  return 1 + Math.max(maximumDepth(root.left), maximumDepth(root.right));
}
// 時間 O(n),空間 O(h)

LeetCode 226 — Invert Binary Tree(Easy)

後序 DFS:先翻轉左右子樹,再交換自己的左右子節點。

function invertBinaryTree(root: TreeNode | null): TreeNode | null {
  if (!root) return null;
  const left  = invertBinaryTree(root.left);
  const right = invertBinaryTree(root.right);
  root.left  = right;
  root.right = left;
  return root;
}
// 時間 O(n),空間 O(h)

總結

二元樹是樹狀資料結構的入口,也是遞迴思維訓練的最佳場域。回顧本文的核心知識點:

  1. 四種遍歷的本質是「何時處理當前節點」的選擇:前序(進入時)、中序(左子樹後)、後序(離開時)、層序(BFS 逐層)。

  2. 遞迴 vs. 迭代:遞迴寫法簡潔,但有堆疊溢出風險;迭代用顯式 Stack/Queue 模擬,不會溢出但程式碼較長。面試中兩種都要能寫出來。

  3. Morris Traversal 用臨時線索省去 Stack 空間,達到 O(1) 空間的中序遍歷,是進階技巧。

  4. 後序 DFS 是萬能框架:最大深度、翻轉二元樹、路徑和、直徑,幾乎都是後序 DFS 的變形——先遞迴處理子樹,再根據子樹結果計算當前節點的答案。

  5. 五大陷阱:null 檢查、層序的 levelSize、建構樹的 preIdx 順序、最大路徑和的雙量區分、Morris 的線索恢復。

熟練樹的遍歷框架,是攻克圖論、動態規劃、分治法等進階主題的基石。希望這篇文章能幫助你建立清晰的樹思維,在面試中自信地處理各種樹題。如果有任何問題或疑惑,歡迎至 聯絡頁面 留言交流。

下一篇預告:我們將深入 二元搜尋樹(Binary Search Tree) 的世界,探討 BST 的插入、刪除、查詢操作,以及如何利用 BST 的有序性解決更多進階問題。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。