陣列與字串 — Array 操作技巧與字串處理完整指南 | 資料結構與演算法

2026/06/15
陣列與字串 — Array 操作技巧與字串處理完整指南 | 資料結構與演算法

陣列(Array) 是演算法學習的第一塊基石,它的核心優勢來自 連續記憶體 佈局——任何位置的存取都是 O(1)。字串(String) 本質上是字元陣列,兩者共享大量解題模式。掌握 前綴和差分陣列Kadane’s Algorithm 等經典技巧,你將能以 O(n) 的優雅效率解決大多數陣列題型。

前言

想像一排儲物格,每個格子都有固定的門牌號碼(索引),你想找第 5 號格子,只需走直線過去——不需要逐格詢問。這就是 陣列(Array) 的精髓:利用 連續記憶體 讓任意位置的存取在 O(1) 時間內完成。

字串(String) 本質上就是「字元的陣列」,兩者在演算法題目中共享大量模式,幾乎所有陣列技巧都可以直接套用在字串上。

本篇是「資料結構與演算法」系列的第 002 篇,讀完你將能夠:

  • 理解陣列連續記憶體佈局與 O(1) 隨機存取的根本原因
  • 掌握前綴和(Prefix Sum)與差分陣列(Difference Array)兩大預處理模式
  • 熟悉 Kadane’s Algorithm 解決最大子陣列問題
  • 比較 JavaScript 與 C++ 中陣列、字串的底層差異
  • 辨識並避開常見的效能陷阱(字串串接、整數溢位、索引偏移)
  • 解決 LeetCode 高頻陣列題型

核心概念

連續記憶體佈局

陣列的核心優勢來自 連續記憶體(Contiguous Memory):系統在分配時就知道每個元素的大小(stride),只需一次乘法與加法即可定位任意位置:

address(arr[i]) = base_address + i × sizeof(element)

因此 隨機存取(Random Access)永遠是 O(1),與陣列大小完全無關。

下面是連續陣列與鏈結串列的記憶體佈局對比:

連續陣列(Array)— 記憶體連續:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │  ← 值
└───┴───┴───┴───┴───┴───┴───┴───┘
  ^                               ^
0x1000                          0x101F
(每個 int32 佔 4 bytes,共 32 bytes)

arr[5] 的位址 = 0x1000 + 5×4 = 0x1014  ← 一次計算,O(1)

───────────────────────────────────────────────

鏈結串列(Linked List)— 記憶體分散:
┌───┬──┐    ┌───┬──┐    ┌───┬──┐    ┌───┬──┐
│ 0 │──┼───>│ 1 │──┼───>│ 2 │──┼───>│ 3 │∅ │
└───┴──┘    └───┴──┘    └───┴──┘    └───┴──┘
0x1000      0x2048      0x0A10      0x3FFC

list[3] 需要從頭走訪 4 次  ← O(n)

插入與刪除為何是 O(n)

連續記憶體帶來了 O(1) 存取的好處,卻也造成插入與刪除的代價:

在索引 2 插入新元素(value = 99):

插入前:[10, 20, 30, 40, 50]
                ^
                插入點

步驟 1:從末尾開始,將每個元素後移一格
[10, 20, 30, 40, 40, 50]
[10, 20, 30, 30, 40, 50]
...

步驟 2:寫入新值
[10, 20, 99, 30, 40, 50]  ← 完成

最差情況(插入頭部):需要搬移所有 n 個元素 → O(n)

動態陣列 vs 靜態陣列

靜態陣列(Static Array) 大小在編譯期固定,直接分配在堆疊上,零額外開銷(C++ 的 std::array 或 C-style int arr[5])。

動態陣列(Dynamic Array) 在執行期可以擴張容量。當容量滿時,分配更大的空間(通常 2 倍),再將舊資料搬移過去。雖然單次 push 可能是 O(n),但均攤(Amortized)下來每次 push 仍是 O(1)。JavaScript 的 Array、Python 的 list、C++ 的 std::vector 都是動態陣列。

字串的不可變性(Immutability)

JavaScript 的字串是 不可變(Immutable) 的——每次「修改」字串,實際上都是建立一個全新的字串物件:

let s = "hello";
s[0] = "H";      // 靜默失敗!字串不可變
console.log(s);  // 仍然輸出 "hello"

// 若要修改,需建立新字串
s = "H" + s.slice(1);  // 產生新字串 "Hello"

這個特性意味著在迴圈中用 += 串接字串會是 O(n²) 的效能陷阱——每次串接都建立新字串並複製全部舊內容。

C++ 的 std::string可變的,但 std::string_view 提供零複製的唯讀視圖,更高效。


JavaScript / TypeScript 實作

基礎陣列操作

// 陣列基本操作與對應複雜度
const arr: number[] = [10, 20, 30, 40, 50];

// O(1) 隨機存取
console.log(arr[2]);        // 30 — 直接定址

// O(1) 尾端 push / pop(動態陣列攤銷)
arr.push(60);               // [10, 20, 30, 40, 50, 60]
arr.pop();                  // [10, 20, 30, 40, 50]

// O(n) 頭端 unshift / shift(需搬移所有元素)
arr.unshift(0);             // [0, 10, 20, 30, 40, 50]
arr.shift();                // [10, 20, 30, 40, 50]

// O(n) 中間插入(splice)
arr.splice(2, 0, 99);       // [10, 20, 99, 30, 40, 50]

// O(n) 搜尋(未排序)
const idx = arr.indexOf(99); // 2

// O(k) 切片(建立副本)
const sub = arr.slice(1, 4); // [20, 99, 30]

// O(n log n) 排序(TimSort)
arr.sort((a, b) => a - b);

前綴和(Prefix Sum)

前綴和是最基礎也最重要的預處理技巧:預先計算累積和,讓 任意區間的總和查詢從 O(n) 降至 O(1)

核心公式:rangeSum(left, right) = prefix[right + 1] - prefix[left]

// prefix-sum.ts
// 核心思想:預計算累積和,使區間查詢從 O(n) 降至 O(1)

class PrefixSum {
  private prefix: number[];

  constructor(nums: number[]) {
    // prefix[i] = nums[0] + nums[1] + ... + nums[i-1]
    // 索引偏移一位,讓 prefix[0] = 0,簡化邊界處理(無需特判 left = 0)
    this.prefix = new Array(nums.length + 1).fill(0);
    for (let i = 0; i < nums.length; i++) {
      this.prefix[i + 1] = this.prefix[i] + nums[i];
    }
  }

  // 查詢 [left, right] 閉區間的元素總和,O(1)
  rangeSum(left: number, right: number): number {
    return this.prefix[right + 1] - this.prefix[left];
    // 公式推導:
    // prefix[right+1] = sum(0..right)
    // prefix[left]    = sum(0..left-1)
    // 差值            = sum(left..right) ✓
  }
}

// 使用範例
const nums = [3, -2, 4, 1, -5, 8, 2];
const ps = new PrefixSum(nums);
console.log(ps.rangeSum(1, 4));  // -2+4+1+(-5) = -2
console.log(ps.rangeSum(0, 6));  // 全部 = 11
console.log(ps.rangeSum(5, 6));  // 8+2 = 10

進階版本:二維前綴和,用於矩形區域求和:

// 二維前綴和(矩形區域求和)
class PrefixSum2D {
  private prefix: number[][];

  constructor(matrix: number[][]) {
    const m = matrix.length, n = matrix[0].length;
    // prefix[i][j] = 左上角 (0,0) 到 (i-1,j-1) 的矩形總和
    this.prefix = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
    for (let i = 1; i <= m; i++) {
      for (let j = 1; j <= n; j++) {
        this.prefix[i][j] = matrix[i-1][j-1]
          + this.prefix[i-1][j]   // 上方矩形
          + this.prefix[i][j-1]   // 左方矩形
          - this.prefix[i-1][j-1]; // 容斥原理消除重複計算
      }
    }
  }

  // 查詢 (r1,c1) 到 (r2,c2) 的矩形區域總和,O(1)
  regionSum(r1: number, c1: number, r2: number, c2: number): number {
    return this.prefix[r2+1][c2+1]
      - this.prefix[r1][c2+1]   // 扣除上方
      - this.prefix[r2+1][c1]   // 扣除左方
      + this.prefix[r1][c1];    // 容斥原理補回多扣的部分
  }
}

差分陣列(Difference Array)

差分陣列是前綴和的「逆運算」思想:把 區間加值操作 從 O(n) 降至 O(1),最後一次 O(n) 還原。

適合場景:需要對陣列執行大量「區間 +val」操作,最後才需要讀取結果。

// difference-array.ts
// 核心思想:將「區間加值」操作從 O(n) 降至 O(1),還原時 O(n)

class DifferenceArray {
  private diff: number[];

  constructor(nums: number[]) {
    const n = nums.length;
    // diff[i] = nums[i] - nums[i-1](記錄相鄰差值)
    this.diff = new Array(n).fill(0);
    this.diff[0] = nums[0];
    for (let i = 1; i < n; i++) {
      this.diff[i] = nums[i] - nums[i - 1];
    }
  }

  // 對 [left, right] 區間的每個元素加上 val,O(1)
  rangeAdd(left: number, right: number, val: number): void {
    this.diff[left] += val;                  // 從 left 開始「啟動」加值效果
    if (right + 1 < this.diff.length) {
      this.diff[right + 1] -= val;           // 從 right+1 開始「取消」加值效果
    }
    // 原理:還原時做前綴和,left 之前不受影響,right+1 之後被抵消
  }

  // 還原為實際陣列,O(n)
  toArray(): number[] {
    const result = new Array(this.diff.length);
    result[0] = this.diff[0];
    for (let i = 1; i < this.diff.length; i++) {
      result[i] = result[i - 1] + this.diff[i]; // 前綴和還原
    }
    return result;
  }
}

// 使用範例:多次區間加值後一次還原
const da = new DifferenceArray([0, 0, 0, 0, 0]);
da.rangeAdd(1, 3, 5);  // 對索引 1~3 各加 5 → [0, 5, 5, 5, 0]
da.rangeAdd(0, 2, 2);  // 對索引 0~2 各加 2 → [2, 7, 7, 5, 0]
da.rangeAdd(2, 4, -1); // 對索引 2~4 各減 1 → [2, 7, 6, 4, -1]
console.log(da.toArray()); // [2, 7, 6, 4, -1]
// k 次區間加值:O(k),若用暴力法為 O(k·n)

Kadane’s Algorithm(最大子陣列)

Kadane’s Algorithm 是動態規劃與貪心的交匯點,以 O(n) 時間解決 最大子陣列和 問題。

核心決策:以 nums[i] 結尾的最大子陣列,要麼繼承前段(currentSum + nums[i]),要麼從 nums[i] 重新開始。若前段和為負,捨棄它不如從當前元素重新出發。

// kadane.ts
// 核心思想:局部最優 → 全域最優(Greedy / DP 交匯)

function maxSubArray(nums: number[]): number {
  // currentSum:以 nums[i] 結尾的最大子陣列和
  // maxSum:全域最大值
  let currentSum = nums[0];
  let maxSum = nums[0];

  for (let i = 1; i < nums.length; i++) {
    // 決策:繼承前段(currentSum + nums[i])or 重新開始(nums[i])
    // 若前段和為負,拋棄它不如從 nums[i] 重新開始
    currentSum = Math.max(nums[i], currentSum + nums[i]);
    maxSum = Math.max(maxSum, currentSum);
  }
  return maxSum;
}

console.log(maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4]));
// 輸出:6(子陣列 [4, -1, 2, 1])

字串處理技巧

// string-tricks.ts

// ✓ 正確:用陣列收集後一次 join,O(n)
function buildString(words: string[]): string {
  const parts: string[] = [];
  for (const word of words) {
    parts.push(word.toUpperCase());
  }
  return parts.join(', '); // 內部使用 StringBuilder 模式,只複製一次
}

// 字串轉陣列 → 修改 → 轉回字串(處理需要「修改」字串的場景)
function reverseString(s: string): string {
  return s.split('').reverse().join('');
  // split('')  → string[] ← 可以修改
  // reverse()  → 原地反轉
  // join('')   → 轉回 string
}

// 字元頻率計數(用於同字母異序詞問題)
function charFrequency(s: string): Map<string, number> {
  const freq = new Map<string, number>();
  for (const ch of s) {
    freq.set(ch, (freq.get(ch) ?? 0) + 1); // ?? 0 處理第一次出現
  }
  return freq;
}

// 判斷兩字串是否為同字母異序詞(Anagram)
function isAnagram(s: string, t: string): boolean {
  if (s.length !== t.length) return false;
  const freq = charFrequency(s);
  for (const ch of t) {
    const count = freq.get(ch) ?? 0;
    if (count === 0) return false; // t 中有 s 沒有的字元
    freq.set(ch, count - 1);
  }
  return true;
}

console.log(isAnagram("anagram", "nagaram")); // true
console.log(isAnagram("rat", "car"));         // false

C++ 對照實作

std::vector vs std::array vs C-style array

C++ 提供三種陣列選擇,各有使用場景:

// array_comparison.cpp
// 編譯:g++ -O2 -std=c++20 array_comparison.cpp -o array_comparison
#include <array>
#include <iostream>
#include <numeric>
#include <vector>

// ── C-style array:固定大小,堆疊分配 ──────────────────────
// 優點:零開銷,直接對應硬體記憶體模型
// 缺點:無大小資訊,容易越界,不可複製賦值
void cStyleDemo() {
  int arr[5] = {1, 2, 3, 4, 5};
  // sizeof(arr) / sizeof(arr[0]) 才能得到長度,容易忘記
  std::cout << "C-style size: " << sizeof(arr)/sizeof(arr[0]) << "\n"; // 5
}

// ── std::array:固定大小,堆疊分配,型別安全 ────────────────
// 優點:size() 方法、範圍 for、at() 越界檢查、可複製
// 缺點:大小必須是編譯期常數
void stdArrayDemo() {
  std::array<int, 5> arr = {1, 2, 3, 4, 5};
  std::cout << "std::array size: " << arr.size() << "\n"; // 5

  // STL 算法可直接使用
  int sum = std::accumulate(arr.begin(), arr.end(), 0);
  std::cout << "sum: " << sum << "\n"; // 15

  // at() 提供越界檢查(丟出 std::out_of_range)
  try {
    arr.at(10); // 越界存取
  } catch (const std::out_of_range& e) {
    std::cout << "越界:" << e.what() << "\n";
  }
}

// ── std::vector:動態大小,堆積分配 ─────────────────────────
// 優點:動態調整大小,攤銷 O(1) push_back,豐富 STL 方法
// 缺點:堆積分配有額外開銷,cache 效能略低於固定陣列
void stdVectorDemo() {
  std::vector<int> v;
  v.reserve(10); // 預先分配,避免多次 realloc

  for (int i = 0; i < 5; i++) v.push_back(i * 2); // 0 2 4 6 8
  std::cout << "size=" << v.size() << " capacity=" << v.capacity() << "\n";

  v.emplace_back(10); // 直接在容器內構造,比 push_back 少一次複製

  // 刪除中間元素(O(n),後續元素左移)
  v.erase(v.begin() + 2);

  for (int x : v) std::cout << x << " ";
  std::cout << "\n"; // 0 2 6 8 10
}

C++ 前綴和與差分陣列

// prefix_diff.cpp
#include <iostream>
#include <vector>

// 前綴和
struct PrefixSum {
  std::vector<long long> prefix;

  explicit PrefixSum(const std::vector<int>& nums)
      : prefix(nums.size() + 1, 0) {
    for (std::size_t i = 0; i < nums.size(); ++i) {
      prefix[i + 1] = prefix[i] + nums[i];
    }
  }

  // O(1) 區間查詢
  long long rangeSum(int left, int right) const {
    return prefix[right + 1] - prefix[left];
  }
};

// 差分陣列
struct DiffArray {
  std::vector<long long> diff;

  explicit DiffArray(const std::vector<int>& nums)
      : diff(nums.size(), 0) {
    diff[0] = nums[0];
    for (std::size_t i = 1; i < nums.size(); ++i) {
      diff[i] = nums[i] - nums[i - 1];
    }
  }

  // O(1) 區間加值
  void rangeAdd(int left, int right, long long val) {
    diff[left] += val;
    if (right + 1 < static_cast<int>(diff.size())) {
      diff[right + 1] -= val;
    }
  }

  // O(n) 還原
  std::vector<long long> toVector() const {
    std::vector<long long> result(diff.size());
    result[0] = diff[0];
    for (std::size_t i = 1; i < diff.size(); ++i) {
      result[i] = result[i - 1] + diff[i];
    }
    return result;
  }
};

int main() {
  std::vector<int> nums = {3, -2, 4, 1, -5, 8, 2};
  PrefixSum ps(nums);
  std::cout << ps.rangeSum(1, 4) << "\n"; // -2

  std::vector<int> base = {0, 0, 0, 0, 0};
  DiffArray da(base);
  da.rangeAdd(1, 3, 5);
  da.rangeAdd(0, 2, 2);
  auto result = da.toVector();
  for (auto x : result) std::cout << x << " ";
  std::cout << "\n"; // 2 7 7 5 0
  return 0;
}

C++ 字串:std::string vs std::string_view

#include <string>
#include <string_view>
#include <iostream>

void stringVsStringView() {
  std::string s = "Hello, World! This is a long string.";

  // std::string:擁有資料,substr 複製 → O(k) 時間 + O(k) 空間
  std::string sub = s.substr(7, 5); // 複製 "World"
  std::cout << "substr (copy): " << sub << "\n";

  // std::string_view:非擁有的視圖,O(1) 切片,零複製
  // 注意:string_view 的生命週期不能超過底層 string!
  std::string_view sv(s);
  std::string_view sv_sub = sv.substr(7, 5); // 零複製,只移動指標
  std::cout << "string_view (no copy): " << sv_sub << "\n";
}

// 語言差異對照表
// ┌──────────────────┬──────────────────┬──────────────────────────┐
// │ 特性             │ JavaScript       │ C++                      │
// ├──────────────────┼──────────────────┼──────────────────────────┤
// │ 字串可變性       │ 不可變           │ std::string 可變          │
// │ 零複製切片       │ 無原生支援       │ std::string_view          │
// │ 動態陣列         │ Array(內建)    │ std::vector<T>            │
// │ 固定大小陣列     │ TypedArray       │ std::array<T,N>           │
// │ 越界存取         │ 回傳 undefined   │ 未定義行為(UB)          │
// └──────────────────┴──────────────────┴──────────────────────────┘

複雜度分析

陣列操作時間複雜度

操作時間複雜度說明
arr[i] 隨機存取O(1)連續記憶體直接定址
搜尋(未排序)O(n)最差需走訪全部
搜尋(已排序)O(log n)可使用二分搜尋
尾端插入 pushO(1)*動態陣列攤銷值
中間/頭部插入O(n)需搬移後續元素
尾端刪除 popO(1)僅更新長度
中間/頭部刪除O(n)需搬移後續元素

*攤銷分析:容量滿時 2x 擴張,n 次 push 共搬移 n + n/2 + n/4 + … ≈ 2n 次,每次攤銷 O(1)。

字串操作時間複雜度

操作TypeScript / JSC++ std::string說明
長度查詢 .lengthO(1)O(1)儲存於屬性
字元存取 s[i]O(1)O(1)連續記憶體
串接 + 運算子O(n+m)O(n+m)建立新字串
子字串 slice/substrO(k)O(k)複製 k 個字元
string_view 切片O(1)零複製視圖
字串比較O(min(n,m))O(min(n,m))逐字元比較

前綴和 vs 差分陣列對比

前綴和(Prefix Sum)差分陣列(Difference Array)
預處理O(n)O(n)
核心操作區間查詢 rangeSum區間加值 rangeAdd
操作複雜度O(1)O(1)
還原不需要(直接用)O(n),最後才還原
適用場景大量區間查詢大量區間更新

變體與延伸

環形陣列(Circular Array / Ring Buffer)

用固定大小陣列實作 FIFO 佇列,頭尾操作均為 O(1),透過取模運算實現「環繞」效果:

// circular-array.ts
class CircularQueue<T> {
  private data: (T | undefined)[];
  private head: number = 0; // 讀取指標
  private tail: number = 0; // 寫入指標
  private _size: number = 0;

  constructor(private capacity: number) {
    this.data = new Array(capacity);
  }

  enqueue(item: T): boolean {
    if (this._size === this.capacity) return false; // 已滿
    this.data[this.tail] = item;
    this.tail = (this.tail + 1) % this.capacity; // 環形前進
    this._size++;
    return true;
  }

  dequeue(): T | undefined {
    if (this._size === 0) return undefined; // 已空
    const item = this.data[this.head];
    this.data[this.head] = undefined;
    this.head = (this.head + 1) % this.capacity; // 環形前進
    this._size--;
    return item;
  }

  get size(): number { return this._size; }
}

const q = new CircularQueue<number>(3);
q.enqueue(1); q.enqueue(2); q.enqueue(3);
console.log(q.dequeue()); // 1
q.enqueue(4);             // 環繞回到索引 0
console.log(q.size);      // 3

稀疏陣列(Sparse Array)

當陣列中大多數元素為預設值(0 或 null),用 Map 替代實際陣列以節省空間:

// sparse-array.ts
class SparseArray<T> {
  private store = new Map<number, T>();
  constructor(public readonly length: number, private defaultValue: T) {}

  get(index: number): T {
    return this.store.get(index) ?? this.defaultValue;
  }

  set(index: number, value: T): void {
    if (value === this.defaultValue) {
      this.store.delete(index); // 等於預設值就不儲存
    } else {
      this.store.set(index, value);
    }
  }

  get memoryUsage(): number { return this.store.size; }
}

// 應用:1,000,000 格的矩陣,只有 2 格非零
const sparse = new SparseArray<number>(1_000_000, 0);
sparse.set(42, 99);
sparse.set(999_999, 7);
console.log(sparse.memoryUsage); // 2(只儲存 2 個非零值)
console.log(sparse.get(42));     // 99
console.log(sparse.get(100));    // 0(預設值)

多維陣列

// 二維陣列初始化(常見陷阱)
const m = 3, n = 4;

// ❌ 錯誤:所有列共享同一個陣列參考
const wrongGrid = new Array(m).fill(new Array(n).fill(0));
wrongGrid[0][0] = 99;
console.log(wrongGrid[1][0]); // 99!— 意外修改了所有列

// ✓ 正確:使用 Array.from 為每列建立獨立陣列
const grid = Array.from({ length: m }, () => new Array(n).fill(0));
grid[0][0] = 99;
console.log(grid[1][0]); // 0 — 正確獨立

常見面試考點

面試官最愛考的陣列與字串題型,集中在以下幾個模式:

  1. 前綴和應用:區間查詢(LeetCode 303、304)、等量子陣列數量(560)
  2. 差分陣列應用:區間更新後查詢(LeetCode 1109、798、1094)
  3. Kadane’s Algorithm:最大子陣列和(LeetCode 53)及其變形(最大環形子陣列 918)
  4. 雙指標:有序陣列的兩數之和(167)、三數之和(15)、容器盛水(11)
  5. 滑動視窗:最長無重複子字串(3)、最小覆蓋子字串(76)
  6. 字元頻率:同字母異序詞分組(49)、有效的字母異位詞(242)
  7. 原地操作:移除元素(27)、刪除有序陣列重複項(26)
  8. 前綴積/後綴積:除自身以外陣列的乘積(238)

高頻陷阱提醒:

  • 前綴和的「哨兵位」索引偏移(prefix[0] = 0 統一邊界)
  • 字串迴圈串接的 O(n²) 效能問題,應使用 Array.join()
  • 差分陣列的 right + 1 越界問題(宣告時多一格)
  • 二維陣列的淺複製陷阱

LeetCode 練習

題號題目難度核心技巧提示
1Two SumEasyHash Map先查表再存入,一次掃描 O(n)
53Maximum SubarrayMediumKadane’s Algorithmdp[i] = max(nums[i], dp[i-1]+nums[i])
238Product of Array Except SelfMedium前綴積 × 後綴積兩次掃描,不使用除法,空間 O(1)
560Subarray Sum Equals KMedium前綴和 + Hash MapcurrentPrefix - k 是否出現過
49Group AnagramsMedium字元排序 / 頻率計數排序後的字串作為 Map 的 Key

解題詳解:LeetCode 238 — Product of Array Except Self

思路:題目禁止使用除法,也不能 O(n²) 暴力。

關鍵洞察:result[i] = (i 左邊所有元素的積) × (i 右邊所有元素的積)

第一次掃描建立「前綴積」,第二次從右向左邊掃描邊乘上「後綴積」,空間壓縮至 O(1):

// 時間:O(n),空間:O(1)(輸出陣列不計入額外空間)
function productExceptSelf(nums: number[]): number[] {
  const n = nums.length;
  const result = new Array(n).fill(1);

  // 第一次掃描:result[i] = i 左邊所有元素的積
  let prefixProduct = 1;
  for (let i = 0; i < n; i++) {
    result[i] = prefixProduct;
    prefixProduct *= nums[i]; // 為下一位置更新前綴積
  }

  // 第二次掃描(從右到左):乘上 i 右邊所有元素的積
  let suffixProduct = 1;
  for (let i = n - 1; i >= 0; i--) {
    result[i] *= suffixProduct;
    suffixProduct *= nums[i]; // 為下一位置更新後綴積
  }

  return result;
}

// 追蹤過程(nums = [1, 2, 3, 4]):
// 前綴積後 result: [1, 1, 2, 6]
//                   ^           (result[0] 左邊無元素 → 1)
// 後綴積後 result: [24, 12, 8, 6]  ← 正確答案
console.log(productExceptSelf([1, 2, 3, 4])); // [24, 12, 8, 6]

解題詳解:LeetCode 560 — Subarray Sum Equals K

思路:暴力法 O(n²)。用前綴和 + Hash Map 降至 O(n)。

sum(i..j) == k,則 prefixSum[j+1] - prefixSum[i] == k,即 prefixSum[i] == currentPrefix - k

// 時間:O(n),空間:O(n)
function subarraySum(nums: number[], k: number): number {
  // prefixCountMap[s] = 前綴和等於 s 的出現次數
  const prefixCountMap = new Map<number, number>();
  prefixCountMap.set(0, 1); // 空子陣列的基底情況(前綴和為 0 出現一次)

  let currentPrefix = 0;
  let count = 0;

  for (const num of nums) {
    currentPrefix += num;

    // 若 (currentPrefix - k) 曾出現過,代表存在對應的有效子陣列
    const complement = currentPrefix - k;
    count += prefixCountMap.get(complement) ?? 0;

    // 記錄當前前綴和
    prefixCountMap.set(currentPrefix, (prefixCountMap.get(currentPrefix) ?? 0) + 1);
  }

  return count;
}

// 追蹤過程(nums = [1, 1, 1], k = 2):
// i=0: prefix=1, complement=-1(未找到), map={0:1, 1:1}
// i=1: prefix=2, complement=0(找到!+1), map={0:1, 1:1, 2:1}
// i=2: prefix=3, complement=1(找到!+1), map={0:1, 1:1, 2:1, 3:1}
// count = 2 ✓
console.log(subarraySum([1, 1, 1], 2)); // 2
console.log(subarraySum([1, 2, 3], 3)); // 2([1,2] 和 [3])

常見陷阱

前綴和的索引偏移混淆

// ❌ 錯誤:l = 0 時需要特判,容易出錯
function rangeSumBad(prefix: number[], l: number, r: number): number {
  return l > 0 ? prefix[r] - prefix[l - 1] : prefix[r]; // 特判!
}

// ✓ 正確:宣告時多一個哨兵位 prefix[0] = 0,統一公式
function rangeSumGood(prefix: number[], l: number, r: number): number {
  // prefix 長度為 n+1,prefix[i+1] = sum(0..i)
  return prefix[r + 1] - prefix[l]; // 永遠不需要特判
}

字串迴圈串接的 O(n²) 陷阱

// ❌ 錯誤:每次 += 建立新字串,累積 O(n²)
function joinBad(words: string[]): string {
  let result = '';
  for (const word of words) {
    result += word + ','; // 每次產生全新完整字串並複製!
  }
  return result;
}

// ✓ 正確:用陣列收集後一次 join,O(n)
function joinGood(words: string[]): string {
  return words.join(',');
}

二維陣列的淺複製陷阱

// ❌ 錯誤:fill 共享同一個陣列參考
const wrong = new Array(3).fill(new Array(4).fill(0));
wrong[0][0] = 99;
console.log(wrong[1][0]); // 99!— 不是預期的 0

// ✓ 正確:Array.from 為每列建立獨立陣列
const correct = Array.from({ length: 3 }, () => new Array(4).fill(0));
correct[0][0] = 99;
console.log(correct[1][0]); // 0 — 正確

總結

本篇涵蓋了陣列與字串的核心知識:

  • 連續記憶體 讓隨機存取達到 O(1),但插入/刪除代價是 O(n) 元素搬移
  • 前綴和 以 O(n) 預處理換取 O(1) 區間查詢,適合大量查詢場景
  • 差分陣列 以 O(1) 記錄區間更新,最後 O(n) 一次還原,適合大量更新場景
  • Kadane’s Algorithm 以 O(n) 貪心決策解決最大子陣列問題
  • 字串不可變性 是 JavaScript 效能陷阱的重要根源,善用 Array.join()
  • C++ 的 std::string_view 提供零複製字串視圖,是效能敏感場景的利器

這兩個資料結構是後續所有進階題型的基礎——雙指標、滑動視窗、二分搜尋、哈希表都會以陣列為底層操作對象。

下一篇我們將進入鏈結串列(Linked List)——從連續記憶體轉向分散的節點與指標,探討為什麼有了陣列,工程師還需要鏈結串列。

希望這篇文章能幫助你打好陣列與字串的底層基礎。如有任何問題或疑惑,歡迎至 Contact 頁面 留言討論!

BenZ Software Developer

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