大多數專案變慢,不是因為語言不夠快,而是我們的做法繞太遠。
演算法講的不是炫技,而是取捨:時間 vs 空間、一次就做完 vs 拆小分工。這篇用工程視角帶你梳理:什麼是演算法、為什麼在產品規模放大後變得關鍵,它在前端事件迴圈與後端 I/O 裡各扮演什麼角色。
接著用可複製的 JavaScript 範例走過排序、二分搜尋、BFS/DFS、Dijkstra、動態規劃與回溯,示範如何量測、何時換策略(例如小資料選簡單、資料暴增改分治或快取)。你會看到清楚的決策框架,而不是空談複雜度:當資料帶著雜訊、需求一直改、部屬資源有限時,怎麼用最少的力氣把體感速度拉回來。希望本篇文章能夠幫助到需要的您。
目錄
{tocify} $title={目錄}
什麼是「演算法」?
演算法(Algorithm)是解決特定問題的一組明確、有限且可執行的步驟。它不依賴特定語言;你可用自然語言、流程圖或任何程式語言來表達。一個成熟的演算法通常滿足:
1. 正確性:對所有合法輸入給出正確輸出。
2. 有限性:有限步驟內結束(不會無限迴圈)。
3. 確定性:每一步都有清楚定義,不含歧義(隨機化演算法則在步驟中包含機率選擇,但仍清楚定義)。
4. 輸入/輸出:接受輸入、產出結果。
5. 效率:在可接受的時間與空間內完成。
簡單說:演算法是「可落地操作說明書」,讓電腦知道每一步該做什麼。
為什麼需要演算法?
軟體世界少不了限制:CPU 時間、記憶體、IO/網路、電量。同一個問題,演算法不同,成本差距可能是等級差(O(n) vs O(n²) vs O(n log n))。舉例:
排序 100 萬筆資料,若用 O(n²) 的演算法,實務上幾乎不可行;O(n log n) 才可能在合理時間內完成。
手機端運算受限於電量與熱節流,更仰賴節省時間/空間的作法。
雲端服務在大流量下,演算法效率直接影響雲資源費用。
演算法也影響使用者體驗(頁面載入、搜尋結果等待、推播推薦準確度)與商業指標(轉換率、留存、運維成本)。
演算法在程式設計中扮演什麼角色?
可以把軟體系統想成三層:
1. 資料結構:怎麼存資料(陣列、鏈結串列、堆、樹、圖…)
2. 演算法:怎麼處理資料(排序、搜尋、路徑、最佳化…)
3. 應用情境:實際的產品需求(電商排序、路線規劃、欺詐偵測…)
演算法決定:
1. 可擴充性(Scalability):資料量或使用者飆升時,是否撐得住。
2. 可維護性:思路清楚、邊界可控,Bug 少。
3. 成本結構:效能佳 → 省機器、降延遲、用戶體驗提升。
演算法的複雜度:時間與空間
分析演算法常用 Big-O 記號:
時間複雜度:步驟數量隨輸入 n 成長的上界,例如 O(1)、O(log n)、O(n)、O(n log n)、O(n²)…
空間複雜度:額外記憶體使用量的上界(不含輸入本身)。
常見直覺:
多層巢狀迴圈 → 可能 O(n²) 或更差。
分治(切半)→ 常見 O(n log n) 或 O(log n)。
Hash 查找平均 O(1),但最壞情況仍需考慮。
追求時間快,可能增加空間(如快取、DP 表);追求省空間,可能時間變慢。
演算法的主要種類與典型思路
1. 排序(Sorting)
比較式:快速排序(Quick Sort)、合併排序(Merge Sort)、堆排序(Heap Sort)、氣泡/插入/選擇(教學用)。
非比較式:計數排序、基數排序、桶排序(對數值範圍與分布有假設)。
考量:穩定性(相同鍵保持原順序)、就地(in-place)、最壞/平均/最佳情況。
2. 搜尋(Searching)
線性搜尋 O(n);二分搜尋 O(log n)(需已排序)。
在雜湊結構(Map/Set)上,平均 O(1) 查找。
3. 分治(Divide and Conquer)
把問題拆成小問題遞迴解,典型如合併排序、快速排序、二分搜尋、Karatsuba 乘法。關鍵是子問題獨立且規模縮小。
4. 貪心(Greedy)
每一步做出局部最佳,期望導向整體佳解。如區間選擇、哈夫曼編碼、最小生成樹(Kruskal/Prim)。需要證明貪心選擇性與最優子結構。
5. 動態規劃(Dynamic Programming, DP)
把重複子問題記錄起來(自頂向下記憶化或自底向上表格法)。如最短路徑(DAG)、背包、序列對齊、LIS、編輯距離。重點是狀態定義與轉移方程。
6. 回溯(Backtracking)與搜尋
系統性探索解空間,剪枝降低爆炸。常見於排列組合、N 皇后、數獨、子集合。
7. 圖演算法(Graph)
遍歷:BFS、DFS。
最短路徑:Dijkstra(非負權)、Bellman-Ford(可負權)、Floyd-Warshall(多源)。
最小生成樹:Kruskal、Prim。
拓撲排序:DAG 排序、偵測環。
網路流/匹配:Edmonds-Karp、Dinic(較進階)。
8. 字串演算法
匹配:KMP、Rabin-Karp、Boyer-Moore。
處理:字首/字尾函式、Z-algorithm、Trie/Suffix Array(進階)。
9. 隨機化與近似
隨機化:亂數樞紐的快速排序、隨機抽樣。
近似/啟發式:當精確解太貴時,求「夠好」解(如旅行推銷員近似)。
在 JavaScript 中如何使用演算法?
1. JS 的特性與注意點
單執行緒事件迴圈:長時間計算要避免阻塞 UI;可用 Web Worker、分批運算(分片/微任務)、或在 Node.js 以 worker_threads 或任務佇列分散。
記憶體管理:物件分配與 GC 會影響效能;在熱路徑中減少暫時性物件。
原生方法:Array.prototype.sort、map/filter/reduce 很方便,但鏈式操作可能隱含多次掃描;必要時合併迭代以減 GC/遍歷次數。
型別:JS 是動態型別,數字皆為 IEEE-754 雙精度(BigInt 例外);算術密集場景可考慮 TypedArray 或 WebAssembly。
2. 實作建議流程
2.1. 定義問題與輸入/輸出(含邊界條件)。
2.2. 選擇或設計演算法,先用偽碼與複雜度分析驗證。
2.3. 小資料集單元測試,覆蓋邊界(空陣列、極端值、重複元素)。
2.4. 量測效能(performance.now()、console.time),必要時優化或換策略。
2.5. 若在前端,注意避免主執行緒長阻塞;在後端,留意事件迴圈與非同步 IO 的互動。
JavaScript 演算法範例
下列範例著重清晰度與邏輯。實務上,瀏覽器或引擎可能已有高度最佳化的原生行為(例如 Array.prototype.sort),但學習過程中,手刻能幫你掌握核心概念與複雜度。
1. 合併排序(Merge Sort)— 穩定、O(n log n)
// 合併排序:穩定排序,時間 O(n log n),空間 O(n)
function mergeSort(arr, cmp = (a, b) => a - b) {
if (arr.length <= 1) return arr.slice(); // 回傳拷貝避免就地修改
const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid), cmp);
const right = mergeSort(arr.slice(mid), cmp);
return merge(left, right, cmp);
}
function merge(a, b, cmp) {
const res = [];
let i = 0, j = 0;
while (i < a.length && j < b.length) {
if (cmp(a[i], b[j]) <= 0) {
res.push(a[i++]); // <= 保持穩定性
} else {
res.push(b[j++]);
}
}
while (i < a.length) res.push(a[i++]);
while (j < b.length) res.push(b[j++]);
return res;
}
// 使用
const nums = [5, 1, 4, 2, 8, 5];
console.log(mergeSort(nums)); // [1, 2, 4, 5, 5, 8]
重點:穩定排序可保留相同鍵的原本相對順序,對多鍵排序(次要鍵/主要鍵)很有用。
2. 快速排序(Quick Sort)— 平均 O(n log n),就地、快
// 快速排序(原地):平均 O(n log n),最壞 O(n^2)
function quickSort(arr, cmp = (a, b) => a - b, left = 0, right = arr.length - 1) {
if (left >= right) return arr;
const pivotIndex = partition(arr, cmp, left, right);
quickSort(arr, cmp, left, pivotIndex - 1);
quickSort(arr, cmp, pivotIndex + 1, right);
return arr;
}
function partition(arr, cmp, left, right) {
// 以中點作為樞紐,可降低最壞情況機率;也可改用隨機樞紐
const mid = left + ((right - left) >> 1);
const pivot = arr[mid];
[arr[mid], arr[right]] = [arr[right], arr[mid]];
let i = left;
for (let j = left; j < right; j++) {
if (cmp(arr[j], pivot) <= 0) {
[arr[i], arr[j]] = [arr[j], arr[i]];
i++;
}
}
[arr[i], arr[right]] = [arr[right], arr[i]];
return i;
}
// 使用
const data = [9, 3, 7, 1, 2, 8];
console.log(quickSort(data)); // 原地排序
小訣竅:實務上常用「三數取中」或隨機樞紐降低退化機率;亦可在小陣列改用插入排序混合提升表現。
3. 二分搜尋(Binary Search)— O(log n)
// 在已排序陣列中搜尋 target,回傳索引,否則回傳 -1
function binarySearch(sortedArr, target, cmp = (a, b) => a - b) {
let lo = 0, hi = sortedArr.length - 1;
while (lo <= hi) {
const mid = lo + ((hi - lo) >> 1);
const c = cmp(sortedArr[mid], target);
if (c === 0) return mid;
if (c < 0) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
// 變體:lower_bound(第一個 >= target 的位置)
function lowerBound(sortedArr, target, cmp = (a, b) => a - b) {
let lo = 0, hi = sortedArr.length;
while (lo < hi) {
const mid = lo + ((hi - lo) >> 1);
if (cmp(sortedArr[mid], target) < 0) lo = mid + 1;
else hi = mid;
}
return lo;
}
// 使用
const sorted = [1, 3, 3, 5, 8, 13];
console.log(binarySearch(sorted, 5)); // 3
console.log(lowerBound(sorted, 3)); // 1(第一個 3 的位置)
4. BFS / DFS(圖與樹的遍歷)
// 無向圖:鄰接表表示
class Graph {
constructor(n) {
this.n = n;
this.adj = Array.from({ length: n }, () => []);
}
addEdge(u, v) {
this.adj[u].push(v);
this.adj[v].push(u);
}
}
function bfs(graph, start) {
const dist = Array(graph.n).fill(Infinity);
const q = [];
dist[start] = 0;
q.push(start);
let head = 0; // O(1) 佇列索引,避免 shift O(n)
while (head < q.length) {
const u = q[head++];
for (const v of graph.adj[u]) {
if (dist[v] === Infinity) {
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
return dist;
}
function dfs(graph, start) {
const visited = Array(graph.n).fill(false);
const order = [];
(function go(u) {
visited[u] = true;
order.push(u);
for (const v of graph.adj[u]) {
if (!visited[v]) go(v);
}
})(start);
return order;
}
// 使用
const g = new Graph(6);
g.addEdge(0,1); g.addEdge(0,2); g.addEdge(1,3); g.addEdge(2,4); g.addEdge(4,5);
console.log(bfs(g, 0)); // [0,1,1,2,2,3]
console.log(dfs(g, 0)); // 例如 [0,1,3,2,4,5]
提示:在前端長圖遍歷可能卡住主執行緒,可將遍歷分片執行,或把工作丟到 Web Worker。
5. Dijkstra(非負權最短路徑)
// 使用二元小頂堆作優先佇列
class MinHeap {
constructor() { this.a = []; }
size() { return this.a.length; }
push(x) { this.a.push(x); this._up(this.a.length - 1); }
pop() {
if (!this.a.length) return null;
const top = this.a[0];
const last = this.a.pop();
if (this.a.length) { this.a[0] = last; this._down(0); }
return top;
}
_up(i) {
while (i > 0) {
const p = (i - 1) >> 1;
if (this.a[p][0] <= this.a[i][0]) break;
[this.a[p], this.a[i]] = [this.a[i], this.a[p]];
i = p;
}
}
_down(i) {
const n = this.a.length;
while (true) {
let m = i, l = i*2+1, r = i*2+2;
if (l < n && this.a[l][0] < this.a[m][0]) m = l;
if (r < n && this.a[r][0] < this.a[m][0]) m = r;
if (m === i) break;
[this.a[m], this.a[i]] = [this.a[i], this.a[m]];
i = m;
}
}
}
function dijkstra(adj, src) {
const n = adj.length;
const dist = Array(n).fill(Infinity);
const heap = new MinHeap();
dist[src] = 0;
heap.push([0, src]); // [距離, 節點]
while (heap.size()) {
const [d, u] = heap.pop();
if (d !== dist[u]) continue; // 舊條目
for (const [v, w] of adj[u]) {
if (dist[v] > d + w) {
dist[v] = d + w;
heap.push([dist[v], v]);
}
}
}
return dist;
}
// 使用(有向或無向皆可;此例以有向為例)
const adj = [
[[1,2],[2,5]], // 0->1(2), 0->2(5)
[[2,1],[3,3]], // 1->2(1), 1->3(3)
[[3,1]], // 2->3(1)
[] // 3
];
console.log(dijkstra(adj, 0)); // [0,2,3,4]
6. 動態規劃:最長遞增子序列(LIS)O(n log n)
// 回傳 LIS 長度;若要重建路徑可另外儲存前驅
function lengthOfLIS(nums) {
const tails = []; // tails[k] = 長度為 k+1 的遞增序列之最小可能尾值
for (const x of nums) {
let i = 0, j = tails.length;
while (i < j) { // 二分找第一個 >= x 的位置
const m = (i + j) >> 1;
if (tails[m] < x) i = m + 1; else j = m;
}
tails[i] = x;
}
return tails.length;
}
console.log(lengthOfLIS([10,9,2,5,3,7,101,18])); // 4 (2,3,7,101)
7. 回溯:子集合(含重複元素去重)
function subsetsWithDup(nums) {
nums.sort((a,b)=>a-b);
const res = [];
const path = [];
function backtrack(start) {
res.push(path.slice());
for (let i = start; i < nums.length; i++) {
if (i > start && nums[i] === nums[i-1]) continue; // 去重
path.push(nums[i]);
backtrack(i + 1);
path.pop();
}
}
backtrack(0);
return res;
}
console.log(subsetsWithDup([1,2,2]));
// [[],[1],[1,2],[1,2,2],[2],[2,2]]
8. 字串匹配:KMP(線性時間)
// 回傳所有匹配起點
function kmpSearch(text, pattern) {
if (pattern.length === 0) return [0];
const lps = buildLPS(pattern);
const res = [];
let i = 0, j = 0;
while (i < text.length) {
if (text[i] === pattern[j]) {
i++; j++;
if (j === pattern.length) {
res.push(i - j);
j = lps[j - 1];
}
} else {
if (j > 0) j = lps[j - 1];
else i++;
}
}
return res;
}
function buildLPS(p) {
const lps = Array(p.length).fill(0);
let len = 0, i = 1;
while (i < p.length) {
if (p[i] === p[len]) {
lps[i++] = ++len;
} else if (len > 0) {
len = lps[len - 1];
} else {
lps[i++] = 0;
}
}
return lps;
}
console.log(kmpSearch("ababcabcabababd", "ababd")); // [10]
9. 貪心:活動選擇(最多不重疊區間)
// 輸入:[ [start, end], ... ],選擇最多不重疊活動
function selectMaxActivities(intervals) {
// 依結束時間排序
intervals.sort((a, b) => a[1] - b[1]);
const res = [];
let lastEnd = -Infinity;
for (const [s, e] of intervals) {
if (s >= lastEnd) {
res.push([s, e]);
lastEnd = e;
}
}
return res;
}
console.log(selectMaxActivities([[1,3],[2,4],[3,5],[0,6],[5,7],[8,9]]));
// 可能結果:[[1,3],[3,5],[5,7],[8,9]]
效能量測與實務優化
1. 量測
function bench(fn, input, times = 10) {
const { performance } = globalThis;
const runs = [];
for (let i = 0; i < times; i++) {
const cloned = typeof structuredClone === "function"
? structuredClone(input)
: JSON.parse(JSON.stringify(input)); // 粗略拷貝(注意循環參照)
const t0 = performance.now();
fn(cloned);
const t1 = performance.now();
runs.push(t1 - t0);
}
runs.sort((a,b)=>a-b);
const median = runs[Math.floor(runs.length / 2)] || 0;
return { medianMs: median, runsMs: runs };
}
注意:量測時盡量固定輸入大小與分布,並多跑幾次取中位數,避免 JIT 暖機與雜訊影響。
2. 前端避免阻塞
分片運算:把大計算切小塊,用 requestIdleCallback、setTimeout(0) 或微任務調度。
Web Worker:把 CPU 密集工作移出主執行緒。
資料結構選擇:例如大量 push/pop 從尾端的情況下,Array 很適合;若需頻繁從頭端移除,請用索引佇列技巧或自製佇列。
3. 後端(Node.js)
避免在熱路徑中同步阻塞(如同步檔案 IO)。
CPU 密集任務可用 worker_threads 或外部服務分擔。
善用串流處理(不一次把大檔載入記憶體)。
設計與選擇演算法的實用心法
1. 先清楚定義輸入/輸出與限制(上限 n、是否需要穩定、是否可近似)。
2. 先挑簡單可靠的(可行、好維護)→ 再視需求優化。
3. 證明或直覺 + 實測並行:先估 Big-O,再用 benchmark 驗證。
4. 留意最壞情況(快速排序退化、Hash 碰撞、惡意輸入)。
5. 把常見問題類型化:
順序相關 → 排序/貪心/DP
路徑/連通 → 圖演算法
子結構重複 → DP
解空間爆炸 → 回溯 + 剪枝/啟發式
6. 工程化:測試、型別(TS)、Lint、文件、可觀測性(log/metrics)。
常見陷阱與邊界案例
1. 空輸入 / 單元素:排序、搜尋、平均值都要處理。
2. 重複值:穩定性需求?二分搜尋找「第一個 ≥ x」或「最後一個 ≤ x」。
3. 整數溢位/精度:JS number 精度有限,需用 BigInt 或字串演算法。
4. 遞迴深度:資料太深可能 stack overflow;改寫為迭代或手動堆疊。
5. 不可變資料結構:在 React/狀態管理中常見,避免直接突變造成難以追蹤的狀態錯誤。
6. 資料預處理:排序一次 + 多次查詢 → 整體更省。
把演算法帶進產品場景(思考框架)
電商排序:多鍵排序(價格、評分、上架時間),可先依主要鍵排序,再用「穩定排序」串接次要鍵。
路線規劃:Dijkstra/A*;路網很大就做分區與快取。
推薦/搜尋:倒排索引 + 排序;字串匹配與相似度量測。
風控/偵測:圖關係 + BFS/社群偵測(進階)。
儀表板:大量資料彙整,先分桶/近似聚合,避免每次全掃。
總結
演算法不是競賽專屬,它是日常工程的效率語言。先把問題說清楚、找出限制,再挑一個對的思路與能維護的實作。掌握基本盤(排序、搜尋、圖、DP、回溯),你就能在 JavaScript 或任何語言裡,把「做得出來」提升到「做得好」。
