顯示具有 algorithm 標籤的文章。 顯示所有文章
顯示具有 algorithm 標籤的文章。 顯示所有文章

2026年4月9日 星期四

🚀 LeetCode 3653 解題解析:從暴力解到「根號分治 × 乘法差分陣列」

題目連結:3653. XOR After Range Multiplication Queries I

這道題目要求我們對一個陣列進行多次查詢:每次以固定的步伐 k,將區間 [l, r] 內(每隔 k 步)的元素乘上某個數字 v,最後求整個陣列的 XOR 總和。

如果測資很小,雙層 for 迴圈就能輕鬆解決;但如果陣列長度 N 和查詢次數 Q 都高達十萬呢?這時就需要拿出更高階的武器了!

🛑 第一步:為什麼原本的方法會慢?(痛點分析)

讓我們先回顧一下原本直覺的寫法:

for l, r, k, v in queries:
    for i in range(l, r + 1, k):
        nums[i] = (nums[i] * v) % MOD
想像一下最壞的情況:
假設陣列長度 N = 100,000,然後有一萬筆查詢,每一筆的步伐 k = 1(也就是每次都要修改整個陣列)。
這時,內層迴圈每次都要跑十萬次,總共要跑 10,000 × 100,000 = 1,000,000,000(十億)次!程式絕對會跑到超時 (Time Limit Exceeded)。

💡 發現問題點: 當步伐 k 很小的時候,我們會浪費大量的時間在迴圈上。

具象化分流:20 筆查詢的奇妙旅程 總共 20 筆混合查詢 k ≤ B (小步伐) k > B (大步伐) 10 筆大步伐 直接暴力 for 迴圈 (跳躍次數極少,瞬間完成) 10 筆小步伐 k = 2 k = 3 其他 5 筆查詢 差分陣列 A (共用同一陣列記號) O(N) 展開 1 次 3 筆查詢 差分陣列 B (共用同一陣列記號) O(N) 展開 1 次 2 筆查詢 個別差分陣列 (各自分配) 各自展開 1 次 🔥 10 筆查詢,總共只需展開不到 4 次!

⚔️ 第二步:降維打擊武器一 ——「根號分治」(分塊)

既然 k 很小的時候會出問題,那我們就根據 k 的大小來「分工合作」吧!這就是根號分治 (Square Root Decomposition) 的核心思想。

我們設定一個界線,通常是陣列長度的平方根,也就是 B = √N(當 N=100,000 時,B ≈ 316)。我們把查詢分成兩派:

  • 🏃‍♂️ 大步伐派 (k > B): 因為步伐很大(每次至少跳 316 格),就算跑完整個陣列,最多也只會跳 316 次。次數非常少!對於這類查詢,我們直接使用原本的 for 迴圈暴力更新,速度其實非常快。
  • 🚶‍♂️ 小步伐派 (k ≤ B): 步伐很小,每次查詢都要跳好幾萬次。對於這類查詢,我們絕對不能用迴圈慢慢跳,我們需要引入下一個武器來「批次處理」。
步長 k 閾值 B = √N 小步伐派 (k ≤ B) 使用「乘法差分」批次處理 大步伐派 (k > B) 暴力 for 迴圈 (最多跳 √N 次)

🛡️ 第三步:降維打擊武器二 ——「乘法差分陣列」

對於小步伐的查詢,我們怎麼批次處理呢?答案是 差分陣列 (Difference Array)

想像一個情境:你要在 [l, r] 區間內的所有數字都乘上 v

  • 傳統做法: 一個一個乘。
  • 差分做法: 我只在起點 l 做個記號「從這裡開始乘上 v」,然後在終點的下一格 r + 1 做個記號「從這裡開始取消乘上 v 的影響」。最後,我只要從頭走到尾掃描一次,就能把這些記號擴散到整個陣列!
步長 k = 2 的差分陣列操作 (區間 l 到 r 乘上 v) l diff[l] × v l+1 l+2 l+3 l+4 (r) l+5 l+6 diff[next] × v⁻¹ 前綴積擴散:每隔 k 步傳遞一次乘數 當到達 l+6 時,乘上 v⁻¹ (模逆元) 剛好抵銷 v 的效果
⚠️ 致命的數學陷阱:如何取消乘法?
在普通的數學裡,要取消「乘以 v」,我們只要「除以 v」就好。
但是!我們的題目要求對 10**9 + 7 取餘數 (% MOD)。在有取餘數的世界裡,除法是會壞掉的!

這時,我們就要請出 Python 的內建魔法:模逆元 (Modular Multiplicative Inverse)
簡單來說,模逆元就是一個神奇的數字,當你乘上這個數字時,效果就等同於「除以 v」。

在 Python 3.8 以後,計算模逆元只需要一行程式碼:
inv_v = pow(v, -1, MOD)  # 取得 v 的模逆元,完美代替除法!

💡 為什麼需要「差分陣列」?(用最簡單的話說)

先忘記乘法,想一下加法。如果我要把陣列中 [1] 到 [5] 的位置都 +10
笨方法是:a[1]+=10, a[2]+=10, a[3]+=10, a[4]+=10, a[5]+=10
聰明方法(差分)是:我只在起點做記號 diff[1] += 10,然後在終點的下一格做記號 diff[6] -= 10
最後我只要「從左到右,把前一格的數字加到自己身上(前綴和)」,魔法就會發生:

  • i=1: 拿到 +10
  • i=2: 繼承前一格的 +10
  • ... 一路繼承 ...
  • i=6: 繼承了 +10,但是遇到自己這格的 -10,互相抵銷變成 0!從這裡開始又恢復原狀。

🔄 從「加法」變成「乘法」

這題只是把加法變乘法:

  • 加法的起點是 +v,乘法的起點就是 *v
  • 加法的終點抵銷是 -v,乘法的終點抵銷就是 /v(也就是乘上模逆元 v⁻¹

🦘 加入「步伐 k」的跳躍魔法

這題更進階的地方在於,我們不是連續修改,而是每隔 k 步修改一次

假設操作是:從 l=1 開始,到 r=5 結束,步伐 k=2,乘上 v=10
實際要修改的位置是:1, 3, 5

第一步:做記號

  • 起點:diff[1] *= 10
  • 終點外:我們實際上跳了 3 次(1, 3, 5),下一次會跳到哪?1 + 3 * 2 = 7。所以在越界的第一個位置做抵銷記號:diff[7] *= 10⁻¹

第二步:跳躍式前綴積展開

以前是「加上前一格」,現在因為步伐是 k=2,我們改成「乘上自己往前退 k 格的數字」。我們來跑一次看看:

一開始的 diff: [1, 10, 1, 1,  1,  1,  1, 10⁻¹]
(index)         0   1  2  3   4   5   6   7

從左到右,每個數字乘上「往前 2 格」的數字:
i=0: 1
i=1: 10
i=2: 1 * (往前2格的 diff[0]=1) = 1
i=3: 1 * (往前2格的 diff[1]=10) = 10  ✅ 成功傳遞給 3 了!
i=4: 1 * (往前2格的 diff[2]=1) = 1
i=5: 1 * (往前2格的 diff[3]=10) = 10  ✅ 成功傳遞給 5 了!
i=6: 1 * (往前2格的 diff[4]=1) = 1
i=7: 10⁻¹ * (往前2格的 diff[5]=10) = 1 ✅ 10 和 10⁻¹ 抵銷,變回 1,停止傳遞!
    

最後展開的 diff 就變成了:[1, 10, 1, 10, 1, 10, 1, 1]
只有 1, 3, 5 這三個位置變成了 10!然後我們再把這個展開後的 diff 乘回原本的 nums 陣列,就大功告成了!

⚡ 核心加速關鍵:為什麼要「依照步數 k」分組 (Grouping)?

差分陣列本身只是工具,「把相同步伐 k 的查詢合併處理」才是真正的加速引擎。

讓我們來算一筆帳,假設有 10,000 筆查詢,其中有 5,000 筆的步伐都是 k=2

❌ 做法一:不分組(每來一筆查詢就做一次差分展開)

  • 拿一筆 k=2 的查詢,在 diff 陣列做頭尾記號 (2次操作)。
  • 掃描一次陣列進行「前綴積展開」,把記號擴散出去 (N 次操作,約 100,000 次)。
  • 把這 5,000 筆 k=2 的查詢都這樣做...
  • 總運算量: 5,000 × 100,000 = 500,000,000(五億次,還是會超時!)

✅ 做法二:依照步伐分組(批次處理)

  • 我們先不急著展開。把這 5,000 筆 k=2 的查詢,全部先在同一個 diff 陣列上「做記號」
  • 做記號非常快,每筆查詢只要修改頭尾兩個數字。5,000 筆查詢做完記號,只需要 10,000 次操作。
  • 等這 5,000 筆的記號都做完了,我們只做「一次」前綴積展開 (100,000 次操作)。
  • 在這次展開中,5,000 個區間的病毒與解藥會「同時」互相疊加、傳染、抵銷。
  • 總運算量: 10,000 (做記號) + 100,000 (一次展開) = 110,000(十一萬次,整整快了近 5000 倍!)

這就是為什麼要配合「根號分治」:
因為我們規定小步伐派的 k ≤ B (約 316)。這代表我們最多只需要展開 316 次!
無論有幾萬筆小步伐查詢,它們都會被分類到這 316 個桶子裡。每個桶子(每種 k)只要花 O(N) 的時間展開一次。
因此,處理所有小步伐查詢的總時間被嚴格限制在 316 × 100,000 ≈ 3千萬次 以內,這在 1 秒的時限內綽綽有餘!

🚧 為什麼不同的步數 (k) 不能共用同一個差分陣列?

這是一個非常敏銳的問題!答案藏在「展開(前綴積)的公式」裡。

讓我們回顧一下展開差分陣列的那行程式碼:

diff[i] = diff[i] * diff[i - k]

致命衝突:記號本身是「沒有記憶」的

假設我們硬把 k=2k=3 的查詢,都做記號在同一個 diff 陣列裡:

  • 查詢A (k=2):在 diff[1] 標記了 *10
  • 查詢B (k=3):在 diff[2] 標記了 *99

現在陣列長這樣:diff = [1, 10, 99, 1, 1, 1...]

問題來了!當我們要掃描陣列把記號擴散出去時,我們該用哪種步伐 k 呢?

  • 如果用 k=2 展開diff[1] 的 10 會正確傳給 diff[3]。但是!diff[2] 上的 99(本來是 k=3 專用的),也會被當成 k=2,錯誤地傳給 diff[4]
  • 如果用 k=3 展開diff[2] 的 99 會正確傳給 diff[5]。但是 diff[1] 上的 10 會被錯誤地傳給 diff[4]

白話文比喻:

這就像是車站裡有「每 2 站停一次的區間車」和「每 3 站停一次的快車」。
你在第 1 站放了一個包裹 (記號)。如果沒有分開月台(不同的 diff 陣列),當列車進站時,包裹根本不知道自己該上哪一班車!它只是一個數字,無法告訴展開的迴圈:「嘿!我是屬於 k=2 的,請每隔 2 格複製我一次。」

結論:

因為擴散的步伐(傳染的方向)是由展開時的 k 決定的,所以:
👉 同一個 diff 陣列在展開時,只能服務一種 k
這就是為什麼我們要在外層寫一個 for k in range(1, B + 1):,每次輪到一個新的 k,我們就把 diff 陣列清空重置,讓它專心只處理這批步伐為 k 的包裹!

🧩 第四步:將所有拼圖組合起來 (完整程式碼解析)

現在我們擁有了所有武器,來看看完整的高階寫法是如何運作的:

import math
from typing import List

class Solution:
    def xorAfterQueries(self, nums: List[int], queries: List[List[int]]) -> int:
        MOD = 10**9 + 7
        n = len(nums)
        
        # 【策略一:根號分治】計算閾值 B
        B = max(1, math.isqrt(n))
        
        # 準備空陣列,用來依照步長 k 分組存放小步伐查詢
        grouped_queries = [[] for _ in range(B + 1)]
        
        # 分流查詢
        for l, r, k, v in queries:
            if v == 1: continue  # 小優化:乘數為 1 不改變數字
            
            if k <= B:
                # 步伐小,存起來等一下批次處理
                grouped_queries[k].append((l, r, v))
            else:
                # 步伐大,直接暴力更新 (次數保證小於 B)
                for i in range(l, r + 1, k):
                    nums[i] = (nums[i] * v) % MOD
        
        # 【策略二:乘法差分】處理小步伐查詢
        diff = [1] * n
        for k in range(1, B + 1):
            if not grouped_queries[k]: continue
            
            # 每次換新的步長 k,重置差分陣列
            for i in range(n): diff[i] = 1
            
            for l, r, v in grouped_queries[k]:
                # 1. 區間起點:標記乘上 v
                diff[l] = (diff[l] * v) % MOD
                
                # 2. 區間終點外:計算越界的第一個位置
                num_steps = (r - l) // k
                next_idx = l + (num_steps + 1) * k
                
                if next_idx < n:
                    # 標記乘上 v 的反元素 (相當於除以 v,用來抵銷)
                    inv_v = pow(v, -1, MOD)
                    diff[next_idx] = (diff[next_idx] * inv_v) % MOD
            
            # 3. 前綴積展開:將記號擴散回原陣列
            for i in range(n):
                if i >= k:
                    diff[i] = (diff[i] * diff[i - k]) % MOD
                if diff[i] != 1:
                    nums[i] = (nums[i] * diff[i]) % MOD
                    
        # 【最終收尾】:計算 XOR 總和
        result = 0
        for num in nums:
            result ^= num
            
        return result

🚀 進階優化:極致的效能突破 (殘差類掃描)

如果把上述的「標準版根號分治」提交,已經能順利通過。但你會發現有些解法跑得快上數倍,這是因為標準寫法中仍有許多可以壓榨效能的空間。以下是四個極致優化的關鍵:

1. 只處理「真正出現」的步伐 k

標準版中,for k in range(1, B + 1) 會跑滿所有可能的 k 值(例如 316 次),即使某些 k 根本沒有出現在查詢中。進階版改用字典 (Dictionary),只迭代有實際查詢的 k

2. 捨棄 O(N) 的全域陣列重置

標準版每換一個 k,就要花 O(N) 把整個 diff 陣列清為 1。進階版不再使用全域 diff,而是只把「事件 (起點與終點)」存起來,省去了巨大的重置開銷。

3. 殘差類 (Residue Class) 獨立掃描

這是最核心的架構改變。我們把查詢按照起點 l % k 分組(稱為殘差類)。例如 k=2 時,分成了「奇數索引組」和「偶數索引組」。

  • 每個殘差類內的事件各自排序。
  • 掃描時,用雙指標 (Two Pointers) 順著步長 k 往前跳,中途遇到事件就即時套用乘數。
  • 好處:如果某個殘差類完全沒有查詢,就可以直接跳過,連掃描都不用掃!

4. 延遲寫回 nums (Lazy Update)

標準版每處理完一種 k,就去更新一次 nums 陣列。進階版準備了一個 factors 陣列來累積所有小步伐的乘積,最後才統一更新到 nums 陣列,將記憶體寫入次數降到最低。

以下是融合了上述所有優化技巧的「極速版」程式碼:


from typing import List
import math
from collections import defaultdict

class Solution:
    def xorAfterQueries(self, nums: List[int], queries: List[List[int]]) -> int:
        MOD = 10**9 + 7
        n = len(nums)
        if n == 0: return 0
        
        # 【策略一:根號分治】
        B = int(math.sqrt(n)) + 1
        
        # 只記錄有出現的小步伐查詢
        small = defaultdict(list)
        
        # 處理大步伐 (直接暴力做)
        for l, r, k, v in queries:
            if k >= B:
                idx = l
                while idx <= r:
                    nums[idx] = (nums[idx] * v) % MOD
                    idx += k
            else:
                small[k].append((l, r, v))
                
        # factors 用來累積所有小步伐造成的最終乘數
        factors = [1] * n
        
        # 【策略二:殘差類掃描 (Residue Class Sweep)】
        for k, qlist in small.items():
            # 依照 l % k 分組記錄事件
            events = [[] for _ in range(k)]
            
            for l, r, v in qlist:
                res = l % k
                step = (r - l) // k
                last = l + step * k
                
                # 放入事件:(起點, 乘上 v)
                events[res].append((l, v))
                
                # 放入事件:(終點外, 乘上 v 的模逆元抵銷)
                end_idx = last + k
                if end_idx < n:
                    inv_v = pow(v, MOD - 2, MOD) # 費馬小定理求模逆元
                    events[res].append((end_idx, inv_v))
                    
            # 針對每個殘差類獨立掃描
            for res in range(k):
                ev = events[res]
                if not ev: continue
                
                ev.sort() # 依照 index 排序事件
                cur_multiplier = 1
                ptr = 0
                m = len(ev)
                
                # 順著步伐 k 跳躍掃描
                i = res
                while i < n:
                    # 如果走到事件觸發點,更新目前的乘數
                    while ptr < m and ev[ptr][0] == i:
                        cur_multiplier = (cur_multiplier * ev[ptr][1]) % MOD
                        ptr += 1
                        
                    factors[i] = (factors[i] * cur_multiplier) % MOD
                    i += k
                    
        # 【最終收尾】統一將累積的乘數寫回 nums,並計算 XOR
        ans = 0
        for i in range(n):
            nums[i] = (nums[i] * factors[i]) % MOD
            ans ^= nums[i]
            
        return ans

🌟 同場加映:極速版 C++ 實作

競技程式設計界最受歡迎的 C++ 實作版本。邏輯與上述 Python 極速版完全相同,加上了快速冪 (Binary Exponentiation) 來手動實作模逆元,在 LeetCode 上能達到極低的執行時間與記憶體消耗。

#include <vector>
#include <cmath>
#include <unordered_map>
#include <algorithm>

using namespace std;

class Solution {
public:
    int xorAfterQueries(vector<int>& nums, vector<vector<int>>& queries) {
        long long MOD = 1e9 + 7;
        int n = nums.size();
        if (n == 0) return 0;
        
        // 【策略一:根號分治】
        int B = sqrt(n) + 1;
        
        // 紀錄小步伐查詢
        unordered_map<int, vector<vector<int>>> small;
        
        // 處理大步伐
        for (const auto& q : queries) {
            int l = q[0], r = q[1], k = q[2], v = q[3];
            if (v == 1) continue; // 小優化:乘數為 1 不改變數字
            
            if (k >= B) {
                int idx = l;
                while (idx <= r) {
                    nums[idx] = (1LL * nums[idx] * v) % MOD;
                    idx += k;
                }
            } else {
                small[k].push_back({l, r, v});
            }
        }
        
        // factors 用來累積所有小步伐的乘數
        vector<long long> factors(n, 1);
        
        // 快速冪 (用於費馬小定理求模逆元)
        auto power = [&](long long base, long long exp) {
            long long res = 1;
            base %= MOD;
            while (exp > 0) {
                if (exp % 2 == 1) res = (res * base) % MOD;
                base = (base * base) % MOD;
                exp /= 2;
            }
            return res;
        };
        
        // 【策略二:殘差類掃描】
        for (const auto& [k, qlist] : small) {
            // events[res] 存放 (index, factor) 對
            vector<vector<pair<int, long long>>> events(k);
            
            for (const auto& q : qlist) {
                int l = q[0], r = q[1];
                long long v = q[2];
                
                int res = l % k;
                int step = (r - l) / k;
                int last = l + step * k;
                
                events[res].push_back({l, v});
                
                int end_idx = last + k;
                if (end_idx < n) {
                    long long inv_v = power(v, MOD - 2);
                    events[res].push_back({end_idx, inv_v});
                }
            }
            
            // 獨立掃描每個殘差類
            for (int res = 0; res < k; ++res) {
                auto& ev = events[res];
                if (ev.empty()) continue;
                
                sort(ev.begin(), ev.end());
                
                long long cur_multiplier = 1;
                int ptr = 0;
                int m = ev.size();
                
                // 順著步伐 k 往前跳
                for (int i = res; i < n; i += k) {
                    while (ptr < m && ev[ptr].first == i) {
                        cur_multiplier = (cur_multiplier * ev[ptr].second) % MOD;
                        ptr++;
                    }
                    factors[i] = (factors[i] * cur_multiplier) % MOD;
                }
            }
        }
        
        // 統一寫回 nums 並計算 XOR
        int ans = 0;
        for (int i = 0; i < n; ++i) {
            nums[i] = (1LL * nums[i] * factors[i]) % MOD;
            ans ^= nums[i];
        }
        
        return ans;
    }
};

🌍 走出競技場:這些技巧在工業界的真實身影

這道題目中用到的技巧,剝開數學的外衣後,其實就是工業界底層系統架構的核心哲學。我們來看看它們在真實世界中的對應:

1. 📷 影像處理 (Computer Vision)

  • 積分圖 (Integral Image) = 二維前綴和/差分:
    在早期非常著名的 Viola-Jones 人臉偵測演算法 中,需要瘋狂計算圖片中任意矩形區域的像素總和。如果每次都用雙層迴圈加總,相機根本無法做到即時偵測。解法就是建構一張「積分圖」(二維前綴和)。建好之後,不管矩形多大,只要查表 4 個頂點的座標做加減,O(1) 就能得到總和!這就是差分陣列的二維直系血親。
  • Bayer 濾色陣列與步伐 k:
    相機感光元件 (CMOS) 上的像素排列通常是 RGGB(Bayer pattern)。如果你要對所有的綠色 (G) 像素做白平衡補償,你就必須在記憶體中進行「跨步 (Strided)」的陣列修改。這就是我們題目中 k=2 步伐操作的物理硬體展現。

2. 🧠 AI 與深度學習 (Deep Learning)

  • FlashAttention = 分塊處理 (Tiling / Block Decomposition):
    最近幾年大語言模型 (LLM) 最重要的突破之一就是 FlashAttention。當輸入文本很長時,Attention 矩陣會大到 GPU 記憶體塞不下。FlashAttention 的做法就是「分塊 (Tiling)」:把大矩陣切成一塊塊能塞進 GPU 超高速 SRAM 的小區塊,算完再合併。這與「根號分治 (把 N 切成 √N 的區塊)」在精神上完全一致:根據硬體的極限(閾值 B)來決定任務的切分方式。
  • 空洞卷積 (Dilated Convolution) = 跳躍 k 步的修改:
    在影像語意分割 (如 DeepLab) 中,為了在不增加計算量的情況下擴大 AI 的「視野 (Receptive Field)」,卷積核會「跳著」掃描像素(例如每隔 2 格、4 格取樣)。這與我們題目的跳躍步伐 k 概念如出一轍。
  • 線性 RNN (如 Mamba, RWKV) = 前綴積的極致:
    為了取代 Transformer 龐大的注意力機制,最新的架構利用「前綴和 / 前綴積 (Cumulative Sum/Prod)」的概念,把歷史的對話記憶壓縮成一個狀態向量(只跟前一時刻有關),這正是我們在差分陣列最後一步 diff[i] = diff[i] * diff[i-k] 所做的事情!

3. 🐧 Linux Kernel 與底層系統

  • 差分陣列的靈魂 = 延遲計算 (Lazy Evaluation) & 批次寫入:
    差分陣列的本質是「先記帳,最後再一次結帳」。這在 Linux 裡無處不在:
    • Page Cache (Dirty Pages): 寫入檔案時不會立刻動到硬碟,而是在記憶體標記 Dirty(像差分做記號),等到作業系統覺得夠多了,再一次 Flush 到磁碟(像前綴和展開)。
    • CFS (完全公平排程器): 在計算 CPU 任務的虛擬運行時間 (vruntime) 時,Kernel 也是累積時間的 Delta (差值),而不是每個 CPU 時脈週期都去更新所有 Process 的樹狀結構。
  • 大小任務分流 (Heavy/Light Routing):
    我們在演算法中把查詢分成「大步伐」和「小步伐」。在網路封包處理(如 Linux NAPI)或中斷處理 (Interrupt Handling) 中,如果封包量少(小任務),就觸發中斷;如果封包量如海嘯般湧來(大任務),網卡驅動會關閉中斷,改用「輪詢 (Polling)」把封包整批掃進來。這就是真實世界的根號分治:根據流量的閥值,切換兩種完全不同的處理策略!

總結:
你在這題看到的「差分陣列」,在系統工程裡叫做 Delta Tracking (差分追蹤) 或 Lazy Evaluation (延遲計算)
你看到的「根號分治」,在系統工程裡叫做 Tiling (快取分塊) 或 Heavy/Light Routing (冷熱路徑分流)
演算法題目,其實就是把這些複雜的系統瓶頸,抽象成了純粹的數學遊戲!

🎉 總結

透過「根號分治」,我們巧妙地避開了 k 極大或極小的極端情況;再透過「乘法差分」與 Python 的 pow(v, -1, MOD),我們把複雜的多次區間操作,壓縮成了輕鬆的頭尾標記。這就是演算法之美!

喜歡這篇解析的話,也歡迎到 LeetCode 3653 實際挑戰看看!

2025年11月28日 星期五

Kadane's algorithm

昨天練習的題目是 3381. Maximum Subarray Sum With Length Divisible by K,題目的最佳解是利用 Kadane's algorithm,我覺得比較容易理解的方式是先計算前面 k-1 個 kSum 之後,再往後面用 Kadane's algorithm 來解。

如果是寫 C++ 是這樣:

class Solution {
public:
    long long maxSubarraySum(vector< int>& nums, int k) {
        const int n = nums.size();
        vector< long long> kSum(k, 0);
        long long maxSum = numeric_limits< long long>::min(), prefixSum = 0;
        for (int i = 0; i < k - 1; ++i) {
            prefixSum += nums[i];
            kSum[i] = prefixSum;
        }
        for (int i = k - 1; i < n; ++i) {
            prefixSum += nums[i];
            int r = i % k;
            maxSum = max(maxSum, prefixSum - kSum[r]);
            kSum[r] = min(kSum[r], prefixSum);
        }
        return maxSum;
    }
};

寫 Python3 是這樣:

class Solution:
    def maxSubarraySum(self, nums: List[int], k: int) -> int:
        n = len(nums)
        kSum = [0] * k
        prefixSum = 0
        maxSum = float("-inf")
        for i in range(k - 1):
            prefixSum += nums[i]
            kSum[i] = prefixSum
        for i in range(k - 1, n):
            prefixSum += nums[i]
            r = i % k
            maxSum = max(maxSum, prefixSum - kSum[r])
            kSum[r] = min(kSum[r], prefixSum)
        return maxSum

寫 Go 是這樣,這邊要注意即便是使用 math.MinInt64 還是要用 int64() 明確轉型,不然預設會用 int 然後就會跟後面的 int64 衝突。

func maxSubarraySum(nums []int, k int) int64 {
	n := len(nums)
	kSum := make([]int64, k)
	prefixSum, maxSum := int64(0), int64(math.MinInt64)
	for i := range n {
		prefixSum += int64(nums[i])
		if i < k-1 {
			kSum[i] = prefixSum
			continue
		}
		r := i % k
		maxSum = max(maxSum, prefixSum-kSum[r])
		kSum[r] = min(kSum[r], prefixSum)
	}
	return maxSum
}

寫 Rust 是這樣,沒什麼特別要注意的地方,反正寫錯時編譯器會提醒你該如何修正。 :-P

impl Solution {
    pub fn max_subarray_sum(nums: Vec< i32>, k: i32) -> i64 {
        let k = k as usize;
        let n = nums.len();
        let mut kSum = vec![0i64; k];
        let mut prefixSum = 0i64;
        let mut maxSum = i64::MIN;
        for i in 0..(k-1) {
            prefixSum += nums[i] as i64;
            kSum[i] = prefixSum;
        }
        for i in (k-1)..n {
            prefixSum += nums[i] as i64;
            let r = i % k;
            maxSum = maxSum.max(prefixSum - kSum[r]);
            kSum[r] = kSum[r].min(prefixSum);
        }
        maxSum
    }
}

不曉得為何,寫 Rust 有最愉快的感覺。 :-P

2025年11月17日 星期一

Binary Search

今日練習的題目是 300. Longest Increasing Subsequence 主要是需要使用到 Binary Search

首先是 C++ 的 lower_bound

class Solution {
public:
    int lengthOfLIS(vector< int>& nums) {
        vector< int> arr;
        arr.reserve(nums.size());
        for (auto& num : nums) {
            auto ptr = ranges::lower_bound(arr.begin(), arr.end(), num);
            if (ptr == arr.end()) {
                arr.push_back(num);
            } else {
                *ptr = num;
            }
        }
        return arr.size();
    }
};

然後是 Python 的 bisect.bisect_left()

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        arr = []
        for num in nums:
            i = bisect.bisect_left(arr, num)
            if i == len(arr):
                arr.append(num)
            else:
                arr[i] = num
        return len(arr)

然後是 Go 的 sort.Search()

func lengthOfLIS(nums []int) int {
	arr := make([]int, 0, len(nums))
	for _, num := range nums {
		idx := sort.Search(len(arr), func(i int) bool {
			return arr[i] >= num
		})
		if idx == len(arr) {
			arr = append(arr, num)
		} else {
			arr[idx] = num
		}
	}
	return len(arr)
}

最後是 Rust 的 binary_search

impl Solution {
    pub fn length_of_lis(nums: Vec< i32>) -> i32 {
        let mut arr = Vec::with_capacity(nums.len());
        for num in nums {
            let i = match arr.binary_search(&num) {
                Ok(index) => index,
                Err(index) => index,
            };
            if i == arr.len() {
                arr.push(num);
            } else {
                arr[i] = num;
            }
        }
        arr.len() as i32
    }
}

每種程式語言使用方式都有所不同。

2025年11月8日 星期六

Gray code

今天練習的題目是 1611. Minimum One Bit Operations to Make Integers Zero,後來自己想出解法後去看官方解答才發現是在考 Gray code 的轉換。

我自己列出 16 轉換到 0 的所有 bits 的表現方式,然後仔細觀察後發現有某種規律,於是就猜想了一個方程式出來試試看,結果還真的可以解答,先聲明一下這不是最有效的方法,只是眾多解法中的其中一種而已。

設 $p_1, p_2, \dots, p_j$ 為 $n$ 的二進位中所有 1 的 0-based 索引,且 $p_1 > p_2 > \dots > p_j$ ($p_1$ 是 MSB)。
$$f(n) = \sum_{i=1}^{j} (-1)^{i-1} \cdot (2^{p_i+1} - 1)$$

這是我初始第一個版本

class Solution {
public:
    int minimumOneBitOperations(int n) {
        if (n == 0)
            return 0;
        int k = 32 - __builtin_clz(n);
        int ans = 0;
        bitset<30> arr(n);
        int sign = 1;
        for (int i = k; i > 0; --i) {
            if (arr[i - 1]) {
                ans += sign * ((1 << i) - 1);
                sign *= -1;
            }
        }
        return ans;
    }
};

簡化後的第二個版本

class Solution {
public:
    int minimumOneBitOperations(int n) {
        if (n == 0)
            return 0;
        int k = 32 - __builtin_clz(n);
        int ans = 0;
        int sign = 1;
        for (int i = k; i > 0; --i) {
            if (n & (1 << (i - 1))) {
                ans += sign * ((1 << i) - 1);
                sign *= -1;
            }
        }
        return ans;
    }
};

再進一步簡化後的第三個版本

class Solution {
public:
    int minimumOneBitOperations(int n) {
        if (n == 0)
            return 0;
        int ans = 0;
        int sign = 1;
        while (n) {
            int k = 32 - __builtin_clz(n);
            ans += sign * ((1 << k) - 1);
            sign *= -1;
            n ^= (1 << (k-1));
        }
        return ans;
    }
};

有用 Gemini AI 幫忙寫了一份解答分享在 LeetCode 上面 Using a Math Formula 這樣

2025年10月7日 星期二

Union Find

今日練習的 1488. Avoid Flood in The City 可以用 Union Find 來增加一些執行效率,照例練習一下 C++/Python/Go/Rust 等程式語言。

C++

class UnionFind {
public:
    vector < int > root;
    UnionFind(int n) : root(n + 1) { iota(root.begin(), root.end(), 0); }
    int Find(int x) { return (x == root[x]) ? x : root[x] = Find(root[x]); }
    void UnionNext(int x) { root[x] = Find(x + 1); }
};
class Solution {
public:
    vector < int > avoidFlood(vector < int > & rains) {
        const int n = rains.size();
        UnionFind G(n);
        unordered_map < int, int > rainDay;
        rainDay.reserve(n);
        vector < int > ans(n, 1);
        for (int i = 0; i < n; i++) {
            const int lake = rains[i];
            if (lake > 0) {
                ans[i] = -1;
                G.UnionNext(i);
                auto it = rainDay.find(lake);
                if (it != rainDay.end()) {
                    int prev = it->second;
                    int dry = G.Find(prev + 1);
                    if (dry > i)
                        return {};
                    ans[dry] = lake;
                    G.UnionNext(dry);
                    it->second = i;
                } else {
                    rainDay[lake] = i;
                }
            }
        }
        return ans;
    }
};

Python3

class UnionFind:
    def __init__(self, n: int):
        self.root = [i for i in range(n + 1)]

    def find(self, x: int):
        if x == self.root[x]:
            return x
        else:
            self.root[x] = self.find(self.root[x])
            return self.root[x]

    def unionNext(self, x: int):
        self.root[x] = self.find(x + 1)


class Solution:
    def avoidFlood(self, rains: List[int]) -> List[int]:
        n = len(rains)
        G = UnionFind(n)
        rainDay = {}
        ans = [1] * n
        for i, lake in enumerate(rains):
            if lake > 0:
                ans[i] = -1
                G.unionNext(i)
                if lake in rainDay:
                    prev = rainDay[lake]
                    dry = G.find(prev + 1)
                    if dry > i:
                        return []
                    ans[dry] = lake
                    G.unionNext(dry)
                rainDay[lake] = i
        return ans

Go

type UnionFind []int

func (parent UnionFind) Find(x int) int {
	if x == parent[x] {
		return x
	} else {
		parent[x] = parent.Find(parent[x])
		return parent[x]
	}
}
func (parent UnionFind) UnionNext(x int) {
	parent[x] = parent.Find(x + 1)
}

func avoidFlood(rains []int) []int {
	n := len(rains)
	arr := make([]int, n+1)
	for i := range n + 1 {
		arr[i] = i
	}
	G := UnionFind(arr)
	rainDay := map[int]int{}
	ans := make([]int, n)
    for i := range n {
        ans[i] = 1
    }
	for i, lake := range rains {
		if lake > 0 {
			ans[i] = -1
			G.UnionNext(i)
			if prev, ok := rainDay[lake]; ok {
				dry := G.Find(prev + 1)
				if dry > i {
					return []int{}
				}
				ans[dry] = lake
				G.UnionNext(dry)
			}
			rainDay[lake] = i
		}
	}
	return ans
}

Rust

pub struct UnionFind {
    parent: Vec< i32 >,
}
impl UnionFind {
    pub fn new(n: usize) -> Self {
        let parent = (0..=n).map(|i| i as i32).collect();
        Self{parent}
    }
    pub fn find(&mut self, x: i32) -> i32 {
        let x_usize = x as usize;
        if self.parent[x_usize] == x {
            return x
        }
        self.parent[x_usize] = self.find(self.parent[x_usize]);
        self.parent[x_usize]
    }
    pub fn union_next(&mut self, x: i32) {
        self.parent[x as usize] = self.find(x + 1)
    }
}

use std::collections::HashMap;

impl Solution {
    pub fn avoid_flood(rains: Vec < i32 >) -> Vec < i32 > {
        let n = rains.len();
        let mut uf = UnionFind::new(n);
        let mut pool = HashMap::< i32,i32 >::new();
        let mut ans = vec![1; n];
        for (i, &lake) in rains.iter().enumerate() {
            if lake > 0 {
                ans[i] = -1;
                uf.union_next(i as i32);
                if let Some(prev) = pool.get(&lake) {
                    let dry = uf.find(prev + 1);
                    if dry > i as i32 {
                        return vec![];
                    }
                    ans[dry as usize] = lake;
                    uf.union_next(dry);
                }
                pool.insert(lake, i as i32);
            }
        }
        ans
    }
}

解題筆記:運用 Union-Find 巧妙解決「避免洪水」問題

***此篇文章由 Gemini AI 產生*** 在解決演算法問題時,我們時常會發現一些經典的資料結構能夠以意想不到的方式被應用。今天,我想分享一個 LeetCode 上的問題「[Avoid Flood in The City](https://leetcode.com/problems/avoid-flood-in-the-city/)」,以及一個利用 Union-Find (併查集) 資料結構的巧妙解法。 這個解法的第一眼可能讓人覺得有點困惑,但理解其核心思想後,便會讚嘆於其簡潔與高效。 #### **問題簡述** 我們得到一個整數陣列 `rains`,代表每日的天氣狀況。 * `rains[i] > 0`:表示第 `i` 天,`rains[i]` 號湖泊會下雨並且被裝滿。 * `rains[i] == 0`:表示第 `i` 天是晴天,你可以選擇**一個**滿的湖泊將其抽乾。 如果某個湖泊在已經滿了的情況下再次下雨,就會導致洪水。你的任務是回傳一個陣列 `ans`,記錄你在每個晴天抽乾了哪個湖泊,以成功避免所有洪水。如果無法避免,則回傳空陣列。 #### **核心挑戰** 這個問題的挑戰在於「決策」。當你有好幾個晴天和好幾個滿的湖泊時,你應該在哪个晴天抽乾哪個湖泊? 一個直觀的貪婪策略是:當一個湖泊即將第二次下雨時,我們必須回頭找一個在「上一次下雨」和「這一次下雨」之間的一個晴天,把它抽乾。為了盡可能保留後面的晴天給未來的緊急情況使用,我們應該選擇**最早的那個可用晴天**。 問題來了:如何高效地「找到上一次下雨之後,最早的那個可用晴天」?如果每次都線性掃描,效率會很差。這就是 Union-Find 登場的時機。 #### **Union-Find 的變形與應用** 在這道題目中,Union-Find 的作用不是判斷連通性,而是作為一個「可用晴天」的查找器。它能以近乎 O(1) 的效率告訴我們:「從第 `x` 天開始,下一個可用的晴天是哪一天?」 讓我們看看程式碼的實現: ```cpp class UnionFind { public: vector root; UnionFind(int n) : root(n + 1) { // 初始化,每個節點的根都是它自己 // root = {0, 1, 2, 3, ..., n} iota(root.begin(), root.end(), 0); } // 查找 x 的最終根,帶路徑壓縮優化 int Find(int x) { return (x == root[x]) ? x : root[x] = Find(root[x]); } // 將 x 合併到 x+1 的集合中,表示 x 已被使用 void UnionNext(int x) { root[x] = Find(x + 1); } }; ``` * **初始化**:`root[i] = i`,代表第 `i` 天的「下一個可用日」就是它自己。 * **Find(x)**:查找 `x` 的根。由於路徑壓縮,這會非常快。 * **UnionNext(x)**:這是精髓。當第 `x` 天被「使用」(無論是下雨,還是被安排抽水),我們就執行 `UnionNext(x)`。這會將 `root[x]` 指向 `x+1` 的根。它的效果就像在第 `x` 天立了一個牌子,上面寫著:「此路不通,請找下一天」。因此,下次再執行 `Find(x)` 時,它會自動跳過 `x`,去尋找 `x` 之後第一個可用的日子。 #### **演算法主體邏輯** 有了這個特製的 Union-Find 工具,主體的邏輯就變得清晰了。 ```cpp class Solution { public: vector<int> avoidFlood(vector<int>& rains) { const int n = rains.size(); UnionFind G(n); // 管理 0 到 n-1 天的可用性 unordered_map<int, int> rainDay; // 記錄湖泊最後的下雨日 vector<int> ans(n, 1); // 預設晴天都抽乾 1 號湖 for (int i = 0; i < n; i++) { const int lake = rains[i]; if (lake > 0) { // 如果是雨天 ans[i] = -1; // 雨天答案固定為 -1 // 無論如何,今天下雨了,所以今天也被使用了 G.UnionNext(i); // 檢查這個湖泊之前是否下過雨 auto it = rainDay.find(lake); if (it != rainDay.end()) { // 之前下過雨,必須找個晴天來抽水 int prev = it->second; // 上次下雨日 // 核心:從 prev+1 開始,找最早的可用晴天 int dry = G.Find(prev + 1); if (dry > i) { // 如果找到的晴天在今天之後,來不及了 return {}; // 洪水 } ans[dry] = lake; // 安排在 dry 這天抽乾 lake G.UnionNext(dry); // dry 這天也被使用了 it->second = i; // 更新 lake 的最後下雨日 } else { // 第一次下雨,記錄下來即可 rainDay[lake] = i; } } } return ans; } }; ``` 整個演算法的流程可以總結如下: 1. 遍歷每一天。 2. 如果是**雨天** (`lake > 0`): * 首先檢查 `rainDay` map,看這個湖之前是否滿了。 * 如果是,就從上一次下雨的後一天 `prev + 1` 開始,呼叫 `G.Find(prev + 1)`。這會高效地返回我們需要的、最早的那個可用晴天。 * 如果返回的晴天 `dry` 比今天 `i` 還晚,表示沒有可用的晴天,洪水無法避免。 * 否則,我們就在 `ans[dry]` 記錄下抽乾 `lake` 的計畫,並用 `G.UnionNext(dry)` 將 `dry` 這天標記為已使用。 * 最後,更新 `rainDay` 中 `lake` 的紀錄,並將今天 `i` 也標記為已使用。 3. 如果是**晴天** (`lake == 0`): * 我們在迴圈中不做任何事。晴天是一個被動的資源,只有在雨天需要時,才會被 `G.Find()` 找到並分配任務。如果一個晴天沒被用到,它就保留預設值 `ans[i] = 1`。 #### **結論** 這個解法最漂亮的地方在於它將「查找下一個可用空位」這個問題,抽象化為 Union-Find 資料結構的操作。通過 `UnionNext(x)` 將已使用的日期「跳過」,`Find(x)` 就能始終高效地定位到下一個可用的資源。 它是一個很好的例子,展示了如何將一個我們熟悉的資料結構進行微小的改造,來解決一個看似不相關、但本質相通的問題。

2025年9月28日 星期日

程式碼加速魔法:尋找最大面積三角形的三種策略

***此篇文章由 Gemini AI 產生*** 在電腦程式設計與幾何學的領域裡,尋找平面點集中最大面積三角形是個經典問題。當我們手上的點的數量 $N$ 越來越多時,要怎麼從最初的 $O(N^3)$ 暴力解法加速呢? 這篇文章將帶你深入了解三種不同的解法:從**最直覺的暴力嘗試**,到善用**幾何特性來加速**,最後是挑戰理論極限的**旋轉卡尺法**。我們將提供完整的 Python 程式碼實作,並分析它們在不同情境下的優缺點。 ----- ## 輔助工具:面積計算 不論用哪種方法,我們都需要一個快速計算三角形面積的工具。這裡我們採用行列式形式的**鞋帶公式 (Shoelace Formula)**,它可以算出兩倍的三角形面積(可能帶有正負號)。 $$\text{Area} = \frac{1}{2} \left| x_1(y_2 - y_3) + x_2(y_3 - y_1) + x_3(y_1 - y_2) \right|$$ 在所有實作中,我們都將重複使用這個核心函式: ```python from typing import List def get_signed_double_area(p1: List[int], p2: List[int], p3: List[int]) -> float: """計算由 p1, p2, p3 三點組成的三角形,有正負號的兩倍面積。""" # 這就是行列式 (determinant) 的計算 return ( p1[0] * (p2[1] - p3[1]) + p2[0] * (p3[1] - p1[1]) + p3[0] * (p1[1] - p2[1]) ) ``` ----- ## 方法一:暴力窮舉 (Brute Force) - $O(N^3)$ 這是最簡單、最容易寫的方法。 ### 核心原理 我們只需要找出所有 $\binom{N}{3}$ 種**三個點的組合**,計算每一個三角形的面積,然後挑出最大的那個。由於它的邏輯非常簡單,**常數因子極小**,所以在點的數量 $N$ 不大的時候(例如 $N \le 50$),這個方法**跑起來通常是最快的**。 ### 程式碼實作 ```python class Solution_O_N3: def largestTriangleArea(self, points: List[List[int]]) -> float: n = len(points) ans = 0.0 # 三層迴圈遍歷所有可能的 i < j < k 組合 for i in range(n): for j in range(i + 1, n): for k in range(j + 1, n): p1 = points[i] p2 = points[j] p3 = points[k] # 計算兩倍面積 (行列式) determinant = get_signed_double_area(p1, p2, p3) # 面積 = abs(行列式) / 2 ans = max(ans, abs(determinant) / 2.0) return ans ``` ----- ## 方法二:凸包 (Convex Hull) + $O(M^3)$ 檢查 - $O(N \log N + M^3)$ 這個方法開始運用幾何上的小技巧。它利用了這個很重要的特性: > **最大面積三角形的三個頂點,一定都在點集合的** **凸包 (Convex Hull)** **上。** ### 核心原理 1. **計算凸包 (O(N log N)):** 先用 **Monotone Chain** 等演算法找出凸包 $H$。 2. **縮小檢查範圍:** 假設凸包上有 $M$ 個點,我們只需要檢查這 $M$ 個點之間的 $\binom{M}{3}$ 種組合。 3. **暴力檢查 (O(M^3)):** 在凸包點上執行最可靠的三層迴圈。 如果你的點很多 ($N$ 很大),但它們大多擠在中間,只有少數幾個點在邊緣 ($M$ 很小),這個方法就會有非常顯著的加速效果。 ### 程式碼實作 ```python class Solution_ConvexHull_O_M3: # Monotone Chain 凸包演算法 def convex_hull(self, points: List[List[int]]) -> List[List[int]]: # 1. 排序 O(N log N) points.sort() if len(points) <= 2: return points # 2. 構造上凸包 (upper) upper = [] for p in points: # 移除造成順時針轉向或共線的中間點 (<= 0) while len(upper) >= 2 and get_signed_double_area(upper[-2], upper[-1], p) <= 0: upper.pop() upper.append(p) # 3. 構造下凸包 (lower) lower = [] for p in reversed(points): # 移除造成順時針轉向或共線的中間點 (<= 0) while len(lower) >= 2 and get_signed_double_area(lower[-2], lower[-1], p) <= 0: lower.pop() lower.append(p) # 4. 結合上下凸包,去除頭尾重複點 return upper[:-1] + lower[:-1] def largestTriangleArea(self, points: List[List[int]]) -> float: # 步驟 1: 計算凸包 O(N log N) hull_points = self.convex_hull(points) M = len(hull_points) if M < 3: return 0.0 ans = 0.0 # 步驟 2: 僅在凸包點上進行 O(M^3) 檢查 for i in range(M): for j in range(i + 1, M): for k in range(j + 1, M): p1 = hull_points[i] p2 = hull_points[j] p3 = hull_points[k] determinant = get_signed_double_area(p1, p2, p3) ans = max(ans, abs(determinant) / 2.0) return ans ``` ----- ## 方法三:凸包 + 旋轉卡尺 (Rotating Calipers) - $O(N \log N)$ 這是挑戰演算法理論極限的方法。它將凸包上的檢查從 $O(M^3)$ 降低到驚人的 $O(M)$。 ### 核心原理:單調性 旋轉卡尺利用了幾何上的**單調性**:當我們固定一條基底邊 $\overline{p_i p_j}$,離它最遠的第三個點 $p_k$ 會隨著 $p_j$ 的移動而**順序移動**。 因此,我們不用三層迴圈,而是讓三個指針 $i, j, k$ **同步沿著凸包前進**。在整個演算法過程中,每個指針都只會繞凸包一圈,讓總複雜度降到 $O(M)$。 ### 程式碼實作 ```python class Solution_RotatingCaliper_O_NlogN: # (convex_hull 函式和方法二相同,這裡為保持簡潔省略) def convex_hull(self, points: List[List[int]]) -> List[List[int]]: # ... (請參考方法二的實作) ... points.sort() if len(points) <= 2: return points upper = [] for p in points: while len(upper) >= 2 and get_signed_double_area(upper[-2], upper[-1], p) <= 0: upper.pop() upper.append(p) lower = [] for p in reversed(points): while len(lower) >= 2 and get_signed_double_area(lower[-2], lower[-1], p) <= 0: lower.pop() lower.append(p) return upper[:-1] + lower[:-1] def largestTriangleArea(self, points: List[List[int]]) -> float: hull_points = self.convex_hull(points) M = len(hull_points) if M < 3: return 0.0 ans = 0.0 i = 0 j = 1 k = 2 # O(M) 主迴圈,i 走一圈 while i < M: p_i = hull_points[i % M] # 內層迴圈優化 j 和 k 點的單調移動 (總移動次數 O(M)) while True: p_j = hull_points[j % M] # 優化 k 點:找到離 p_i p_j 基底最遠的點 p_k while True: p_k = hull_points[k % M] p_k_next = hull_points[(k + 1) % M] # 比較 p_k 和 p_{k+1} 誰離 p_i p_j 更遠 area_k = abs(get_signed_double_area(p_i, p_j, p_k)) area_k_next = abs(get_signed_double_area(p_i, p_j, p_k_next)) if area_k_next > area_k: k = (k + 1) % M # k 點前進 else: break # p_k 是最遠點 # 更新最大面積 ans = max(ans, area_k / 2.0) # 優化 j 點:檢查 p_{j+1} 是否能形成更大的三角形 p_j_next = hull_points[(j + 1) % M] area_j_next = abs(get_signed_double_area(p_i, p_j_next, p_k)) if area_j_next > area_k: # area_k 是 $\Delta p_i p_j p_k$ 的面積 j = (j + 1) % M # j 點前進 else: break # j 點停止 # 移動 i 點 i += 1 # 確保指針順序 (避免 j 或 k 追上 i) if j == i: j = (j + 1) % M if k == j: k = (k + 1) % M return ans ``` ----- ## 總結:理論與實戰的取捨 實測結果是很棒的經驗!它告訴我們:**理論上複雜度低,不代表實際上跑得快**。 | 策略 | 複雜度 | 優點 | 實用建議 | | :--- | :--- | :--- | :--- | | **方法一** | $O(N^3)$ | 程式碼最簡單,**常數開銷最小**。 | **N 較小** ($N \le 50$) 時,這是最快的選擇。 | | **方法二** | $O(N \log N + M^3)$ | 利用幾何性質,且 $M^3$ 檢查較為**穩健可靠**。 | **N 很大**,但 $M$ 預期較小時。 | | **方法三** | $O(N \log N)$ | **漸進複雜度最低**。 | **N 極大** ($N \ge 10000$) 時的最終優化方案。 | 這三種方法各有所長,選擇哪個,完全取決於你的專案中對點的數量 ($N$) 和程式**穩健性**的要求!

2025年6月30日 星期一

演算法的逆襲:為何 O(N log N) 的排序,有時竟能擊敗 O(N) 的雜湊?

***此篇文章由 Gemini AI 產生*** ### **演算法的逆襲:為何 O(N log N) 的排序,有時竟能擊敗 O(N) 的雜湊?** 身為一個程式開發者,我們對時間複雜度(Big O)可說是瞭若指掌。O(N) 通常優於 O(N log N),這幾乎是我們優化程式碼時的金科玉律。但如果我告訴你,在某些情況下,一個 O(N log N) 的演算法,在實際執行時竟然能穩定地擊敗 O(N) 的對手,你會怎麼想? 這不是天方夜譚,而是在 LeetCode、專案優化、甚至面試中都可能遇到的真實場景。今天,就讓我們透過一個經典問題「最長和諧子序列 (Longest Harmonious Subsequence)」,來一場跨越 C++ 和 Python 的效能對決,揭開理論與現實之間的迷霧。 #### **戰場設定:最長和諧子序列** 問題很簡單:給定一個整數陣列 `nums`,找到其中最長「和諧子序列」的長度。所謂和諧子序列,是指該子序列中最大值與最小值的差,剛好為 1。 例如,對於 `nums = [1, 3, 2, 2, 5, 2, 3, 7]`,其最長和諧子序列是 `[3, 2, 2, 2, 3]`,由 2 和 3 組成,長度為 5。 解決這個問題,有兩種經典的思路: 1. **排序 + 滑動窗口 (Sorting + Sliding Window):** 先將陣列排序,然後用兩個指針 `l` 和 `r` 遍歷一次,找出符合條件的最長區間。時間複雜度是 O(N log N),主要瓶頸在排序。 2. **雜湊表計數 (Hash Map):** 用一個雜湊表(在 Python 中是 `dict` 或 `Counter`)來計算每個數字出現的頻率。然後遍歷雜湊表,找出 `x` 和 `x + 1` 的頻率總和的最大值。時間複雜度是 O(N),因為只需要遍歷陣列一次。 理論上,雜湊表法 (O(N)) 應該完勝排序法 (O(N log N))。讓我們來實際測試一下。 ----- ### **第一戰:C++ 的意外結局** 在 C++ 的世界裡,我們用 `std::sort` 和 `std::unordered_map` 來實現這兩種方法。 **方法一:排序 + 滑動窗口 (O(N log N))** ```cpp #include #include #include class Solution { public: int findLHS(std::vector& nums) { std::ranges::sort(nums); int ans = 0; int l = 0; for (int r = 1; r < nums.size(); ++r) { while (nums[r] - nums[l] > 1) ++l; if (nums[r] - nums[l] == 1) ans = std::max(ans, r - l + 1); } return ans; } }; ``` **方法二:雜湊表 (O(N))** ```cpp #include #include #include class Solution { public: int findLHS(std::vector& nums) { std::unordered_map freq; for (int num : nums) ++freq[num]; int ans = 0; for (const auto& [num, count] : freq) if (freq.count(num + 1)) ans = std::max(ans, count + freq.at(num + 1)); return ans; } }; ``` **驚人的結果:** 在多數的 LeetCode 測試案例中,**排序法的執行時間竟然比雜湊表法要短!** 這完全違背了我們對 Big O 的直覺。為什麼?答案藏在硬體底層。 #### **解密 C++:當快取為王 (Cache is King)** ##### **1. 記憶體存取模式:循序 v.s. 隨機** * **排序法**在排序完成後,其滑動窗口的操作是**循序存取 (Sequential Access)** 一塊連續的記憶體。這對 CPU 快取(Cache)極度友好。當 CPU 讀取 `nums[i]` 時,它會聰明地將 `nums[i+1]`, `nums[i+2]` 等鄰近資料一併載入到飛快的 L1/L2 快取中。下一次的存取幾乎是零延遲,因為資料早已在手邊。 * **雜湊表法**則完全不同。`freq[num]` 和 `freq[num + 1]` 在記憶體中的位置是透過雜湊函數計算得出的,它們幾乎肯定是**隨機分散 (Random Access)** 的。這會導致大量的**快取未命中 (Cache Miss)**,CPU 不得不頻繁地從慢速的主記憶體 (RAM) 中去抓取資料,效能因此大打折扣。 ##### **2. 隱藏的常數開銷** Big O 符號忽略了常數因子。`unordered_map` 的每次操作都伴隨著不小的開銷: * **雜湊計算:** 需要時間。 * **碰撞處理:** 無法避免,會帶來額外尋找成本。 * **記憶體分配:** 每個鍵值對都可能需要一次動態記憶體分配,這比 `vector` 的整塊分配慢得多。 在 C++ 這種「貼近硬體」的語言中,演算法與硬體(特別是 CPU 快取)的互動方式,往往比單純的理論複雜度更能決定最終效能。在這裡,排序法贏在它優雅而高效的記憶體存取模式。 ----- ### **第二戰:Python 的情勢逆轉** 現在,我們將戰場轉移到 Python。 **方法一:排序 + 滑動窗口 (O(N log N))** ```python class Solution: def findLHS(self, nums: List[int]) -> int: nums.sort() l = 0 ans = 0 for r in range(len(nums)): while nums[r] - nums[l] > 1: l += 1 if nums[r] - nums[l] == 1: ans = max(ans, r - l + 1) return ans ``` **方法二:`Counter` 雜湊表 (O(N))** ```python from collections import Counter class Solution: def findLHS(self, nums: List[int]) -> int: freq = Counter(nums) ans = 0 for num, count in freq.items(): if num + 1 in freq: ans = max(ans, count + freq[num + 1]) return ans ``` **預料之中的結果:** 在 Python 環境下,**`Counter` 雜湊表法輕易地擊敗了排序法**,完美符合 Big O 的理論預期。 為什麼情勢完全逆轉了?答案在於 Python 的語言特性。 #### **解密 Python:直譯器的代價與 C 語言的捷徑** ##### **1. 直譯器的巨大開銷** Python 是直譯語言。在排序法的滑動窗口迴圈中,`nums[r] - nums[l]` 這樣一行簡單的程式碼,背後需要經過 Python 直譯器繁重的工作:物件類型檢查、查找、分派操作等。這個「直譯器開銷」施加在迴圈的每一步上,積少成多,成為了巨大的效能瓶頸。 ##### **2. C 語言實現的「超車道」** Python 的高效,秘訣在於其豐富的、由 C 語言實現的內建函式庫。 * **`Counter(nums)`** 這個操作,其核心的計數邏輯完全是在**高效的 C 語言層級**執行的。Python 直譯器僅僅是發起一個呼叫,然後 C 程式碼就在幕後以極高的速度完成了 O(N) 的計數工作。 * 這個方法巧妙地將最主要的計算壓力,從緩慢的 Python 迴圈轉移到了飛快的 C 語言底層。 在 Python 的世界裡,快取帶來的效益依然存在,但它已經**不足以抵銷直譯器本身的巨大開銷**。效能的瓶頸從「記憶體存取」轉變成了「直譯器速度」。因此,誰能最大限度地減少在 Python 層級的迴圈次數,誰就是贏家。 ----- ### **結論:我們學到了什麼?** 這場跨越 C++ 和 Python 的效能對決,給我們帶來了幾個深刻的啟示: 1. **Big O 是指南,而非聖經:** 它描述了演算法在規模趨於無窮大時的趨勢,但在特定規模和特定環境下,常數因子和硬體互動同樣重要。 2. **了解你的語言:** C++ 讓你更貼近硬體,記憶體模式至關重要。Python 則提供了高效的 C 語言「捷徑」,善用內建函式庫是優化的關鍵。 3. **快取意識:** 無論使用何種語言,寫出對 CPU 快取友好的程式碼,都是一個通用的優化方向。 4. **實測為王 (Profile Your Code):** 當效能成為瓶頸時,不要猜測,要用實際的效能分析工具來找出問題所在。 下一次,當你面對一個演算法選擇時,除了分析 Big O,不妨也思考一下:**我的程式碼,將在怎樣的硬體和語言環境下運行?** 這個問題,或許能引導你找到通往極致效能的真正路徑。

2006年3月26日 星期日

Strassen's algorithm for matrix multiplication

簡單的比較 一般的矩陣運算 以及 Strassen's algorithm

{ r s } { a b } { e f }
{ t u } = { c d } * { g h }

一般的矩陣運算: Θ(n^3)
r = ae + bg
s = af + bh
t = ce + dg
u = cf + dh
總共 4 個加法運算 8 個乘法運算

Strassen's algorithm: Θ(n^lg7)
P1 = a * ( f - h )
P2 = ( a + b ) * h
=> s = P1 + P2
P3 = ( c + d ) * e
P4 = d * ( g - e )
=> t = P3 + P4
P5 = ( a + d ) * ( e + h )
P6 = ( b - d ) * ( g + h )
=> r = P5 + P4 - P2 + P6
P7 = ( a - c ) * ( e + f )
=> u = P5 + P1 - P3 - P7
總共 18 個加法運算 7 個乘法運算

在一般的 CPU 中乘法所需的運算量較大,所以才會造成 Strassen's algorithm 比較快 (應該吧?)