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

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 實際挑戰看看!

🚀 深入淺出模逆元:費馬小定理與快速冪的完美結合

在解決涉及「巨大數字」與「取餘數 (Modulo)」的演算法題目時,我們經常會遇到一個棘手的問題:加、減、乘法都可以直接在取餘數的狀態下進行,唯獨「除法」不行。

為了解決除法失效的問題,數學家們引入了「模逆元 (Modular Multiplicative Inverse)」的概念,而最優雅的實作方式,莫過於結合「費馬小定理」與「快速冪」。這篇文章將帶您一步步拆解其中的奧秘。

🛑 第一步:為什麼取餘數的世界裡,除法會壞掉?

讓我們先來看一個簡單的例子:我們想要計算 (12 / 4) % 7

  • 在正常的數學裡:12 / 4 = 3,然後 3 % 7 = 3。答案是 3。

現在,如果我們在計算過程中,數字已經因為太大而被取過餘數了呢?

  • 12 % 7 = 5
  • 如果我們拿取完餘數的數字直接去除:5 / 4 = 1.25
  • 1.25 是小數!在取餘數的整數運算世界裡,這完全失去了意義。
正常除法:12 / 4 = 3 3 % 7 = 3 (正確) 先取餘數再除 (12 % 7) / 4 5 / 4 = 1.25 (崩潰!)

🔄 第二步:模逆元的救贖 (Modular Multiplicative Inverse)

既然不能「除以 4」,那我們能不能改成「乘以某個數字」?

在普通的數學裡,除以 4 等同於乘以 0.25(因為 4 × 0.25 = 1,它們互為倒數)。
在模 7 的世界裡,我們也在尋找一個數字 X,使得 (4 × X) % 7 = 1。這個 X 就是 4 在模 7 底下的模逆元

讓我們暴力找找看 X 是多少:

  • 4 × 1 = 4 ≡ 4 (mod 7)
  • 4 × 2 = 8 ≡ 1 (mod 7) 🎯 找到了!

所以,在模 7 的世界裡,「除以 4」就等同於「乘以 2」

我們回頭驗證一開始的算式:
先取餘數:12 % 7 = 5
把除以 4 改成乘以 2:5 × 2 = 10
再取餘數:10 % 7 = 3答案完美吻合!

📜 第三步:費馬小定理 (Fermat's Little Theorem)

雖然我們可以用迴圈暴力找出模逆元,但當模數 P = 10^9 + 7(十億等級的質數)時,暴力尋找會非常慢。這時候,偉大的費馬小定理就派上用場了。

💡 費馬小定理:
如果 P 是一個質數,且 A 不是 P 的倍數,那麼:
AP-1 ≡ 1 (mod P)

我們把這個等式稍微拆解一下,把其中一個 A 獨立出來:

A × AP-2 ≡ 1 (mod P)

看出來了嗎?根據模逆元的定義(A × X ≡ 1),這個 AP-2 完美符合 X 的位置!

結論:在模 P 的世界裡,數字 A 的模逆元就是 AP-2 (mod P)。

⚡ 第四步:快速冪 (Binary Exponentiation) —— O(log N) 的超光速引擎

雖然我們得出模逆元是 AP-2,但當 P = 10^9 + 7 時,我們要計算 A1000000005。如果傻傻地用 for 迴圈乘十億次,肯定會超時 (TLE)。

為了解決這個問題,我們使用快速冪 (Binary Exponentiation) 技巧。它的核心概念是「倍增」。

以計算 A¹¹ 為例 (11 的二進位是 1011) A¹¹ = A⁸ × A² × A¹ A⁴ (跳過不乘) A⁸ 平方 平方 平方 A¹¹ (Result) 只需要執行 log₂(N) 次「平方」操作,十億次運算瞬間縮減為 30 次!

💻 第五步:程式碼實作

有了費馬小定理與快速冪的觀念,求模逆元的實作就非常簡單了。在 Python 中甚至有內建的高效寫法;而在 C/C++ 中,我們通常會自己實作一個 power 函數。

Python 實作

Python 從 3.8 版本開始,內建的 pow 函數直接支援了求模逆元,只要將指數設為 -1 即可(底層自動採用快速冪與擴展歐幾里得演算法):

MOD = 10**9 + 7
v = 4

# Python 內建魔法,直接求 4 在模 10^9+7 下的模逆元
inv_v = pow(v, -1, MOD)

# 或是手動利用費馬小定理:v^(MOD-2) % MOD
inv_v_fermat = pow(v, MOD - 2, MOD)

print(inv_v) # 輸出:250000002

C++ 實作

在 C++ 中,我們需要手動實作快速冪函數(Binary Exponentiation):

#include <iostream>

long long MOD = 1e9 + 7;

// 快速冪實作:計算 (base^exp) % MOD
long long power(long long base, long long exp) {
    long long res = 1;
    base %= MOD;
    while (exp > 0) {
        // 如果當前二進位最低位是 1,則乘上當前的 base
        if (exp % 2 == 1) {
            res = (res * base) % MOD;
        }
        // base 自我平方 (A^1 -> A^2 -> A^4 -> A^8 ...)
        base = (base * base) % MOD;
        // 將 exp 右移一位 (等同於 exp /= 2)
        exp /= 2;
    }
    return res;
}

// 求模逆元
long long modInverse(long long n) {
    // 根據費馬小定理,逆元為 n^(MOD-2)
    return power(n, MOD - 2);
}

int main() {
    long long v = 4;
    std::cout << "4 的模逆元是: " << modInverse(v) << std::endl;
    // 驗證: (4 * 250000002) % 1000000007 == 1
    return 0;
}

🎉 總結

在涉及大數取餘數的運算中,模逆元是我們繞過「除法禁區」的唯一橋樑。而費馬小定理快速冪的結合,不僅在數學上極具美感,更是將工業界效能極限(將十億次迴圈化為 30 次運算)展現得淋漓盡致!

2026年3月14日 星期六

從 LeetCode 到 Linux Kernel 的工程智慧:解構「快樂字串」

在程式設計的領域中,有些題目表面上是排列組合的益智遊戲,但深挖其背後的邏輯,你會發現它觸及了計算機科學最核心的課題:如何在有限限制下,進行極致的資源調度與導航。

今天我們要聊的是 LeetCode 的「快樂字串」(Happy String)問題。

什麼是快樂字串?

一個長度為 n 的字串被稱為「快樂字串」,需滿足:

  1. 僅由 {'a', 'b', 'c'} 組成。

  2. 無連續重複:相鄰的字元不能相同(例如 "aba" 是快樂的,但 "aa" 不是)。

任務是:在所有按字典序排列的快樂字串中,找出第 k 個。

階段一:直覺的探索 —— 回溯法 (Backtracking)

最直覺的方法是透過深度優先搜尋(DFS)來模擬人類構思字串的過程。

程式碼實作

class Solution {
    bool solve(string& ans, int n, int& k) {
        if (ans.size() == n) {
            return --k == 0; // 找到第 k 個組合時停止
        }
        for (char c : {'a', 'b', 'c'}) {
            if (!ans.empty() && c == ans.back()) continue; // 核心約束
            
            ans.push_back(c);
            if (solve(ans, n, k)) return true; // 提早回傳
            ans.pop_back(); // 回溯
        }
        return false;
    }
public:
    string getHappyString(int n, int k) {
        string ans = "";
        return solve(ans, n, k) ? ans : "";
    }
};

工程思考

這種方法就像是在迷宮中探路。雖然直觀,但當 n 變大時,搜尋空間會呈指數級成長。在系統底層開發中,過深的遞迴會導致 Stack Overflow,這在核心(Kernel)環境中是絕對要避免的。

階段二:極致的跳轉 —— 數學定位法 (Mathematical Positioning)

如果我們能不透過「走迷宮」,而是直接「空降」到第 k 個字串呢?

觀察規律發現:第一個字元決定後,後續每個位置都只有 2 種選擇。這意味著這是一個變相的 二進位導航系統

最佳化程式碼

class Solution {
public:
    string getHappyString(int n, int k) {
        int m = 1 << (n - 1); // 每個分支的組合數 (2^(n-1))
        if (3 * m < k) return ""; // 總數檢查

        string ans = "";
        k--; // 轉為 0-indexed 處理區間
        
        // 1. 確定首位字元
        ans.push_back('a' + k / m);
        k %= m;

        // 2. 數學跳轉確定後續字元
        for (int i = 1; i < n; ++i) {
            m >>= 1; // 剩餘組合數減半
            char next = 'a' + (k / m);
            if (next >= ans.back()) next++; // 巧妙排除重複,維持字典序
            
            ans.push_back(next);
            k %= m;
        }
        return ans;
    }
};

深入核心:Linux Kernel 中的精準對應

我們在上述數學解法中使用了兩個核心技巧:「基於 2 的冪次方的空間劃分」「前置狀態的數學補償」。在 Linux 核心中,這兩個技巧有著極其精準的對應實例。

實例 A:二進位空間劃分 —— 夥伴系統 (Buddy System)

在快樂字串中,我們利用 m = 1 << (n - 1) 來確定區間,並用除法與餘數直接定位分支。 在 Linux 的記憶體管理核心 mm/page_alloc.c 中,著名的**夥伴系統(Buddy Allocator)**使用了完全一樣的數學邏輯來分配實體記憶體。

當系統釋放一個大小為 2^order 的記憶體區塊時,它必須找到相鄰的「夥伴(Buddy)」來合併。核心絕對不會去遍歷記憶體,而是直接透過位元運算算出夥伴的精確位置:

/* Linux Kernel: mm/page_alloc.c 核心邏輯簡化 */
static inline unsigned long
__find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
    // 利用 XOR 和 1 << order 直接算出相鄰區塊的位置
    // 就像我們用 k / (1 << (n-1)) 定位分支一樣,純數學、零迴圈!
    return page_pfn ^ (1 << order);
}

精準對應:這與我們處理快樂字串時,依賴 $2$ 的冪次方(二進位樹狀結構)來進行 $O(1)$ 的空間跳轉與定位,在數學本質上完全一致。

實例 B:相鄰約束的 $O(1)$ 跳轉 —— 格雷碼 (Gray Code)

快樂字串的最核心限制是:相鄰元素不能重複。我們用了一行神來之筆 if (next >= ans.back()) next++,在 $O(1)$ 時間內算出第 $k$ 個符合約束的狀態。

在 Linux 驅動程式(特別是旋轉編碼器 drivers/input/misc/rotary_encoder.c 等硬體)中,也有一個嚴格的相鄰約束:相鄰的狀態只能有 1 個位元不同(避免硬體雜訊)。這稱為格雷碼(Gray Code)

工程師如何找出第 $k$ 個符合約束的格雷碼?他們不會從 0 開始生成並檢查相鄰狀態,而是直接套用數學偏移公式:

/* Linux Kernel 中常見的格雷碼轉換邏輯 */
unsigned int binary_to_gray(unsigned int k)
{
    // 透過自己與自己右移一位的值做 XOR,直接計算出第 k 個合法狀態
    // 完美滿足「相鄰狀態約束」,且是 O(1) 的純數學計算
    return k ^ (k >> 1);
}

精準對應:快樂字串的約束是「相鄰字元不同」,格雷碼的約束是「相鄰位元僅一處不同」。兩者都放棄了狀態機遍歷(Backtracking),轉而發掘出**「將序號 $k$ 透過偏移/補償,直接對射到合法狀態空間」**的終極數學公式。

跨領域的共鳴:AI、遊戲生成與空間定位

這種「拒絕盲目搜尋,改用數學約束或狀態機直接算出解」的思想,不僅限於底層作業系統。在當今最前沿的領域中,它更是解決效能瓶頸的核心技術。

1. 大型語言模型 (LLM) 的受限解碼 (Constrained Decoding)

在 LLM 的推論過程中,生成文字的本質就是在「巨大的字彙庫中尋找下一個合法的 Token」。這與我們找快樂字串的字元非常相似。 當我們要求 LLM 嚴格輸出特定格式(如 JSON)時,現代推論引擎(如 vLLM, Guidance)會使用有限狀態機 (FSM)。在生成每個 Token 前,系統會先「計算」出目前狀態下哪些字元是合法的(就像我們排除 ans.back()),並將不合法 Token 的機率強制歸零。這保證了模型一步到位生成完美格式,徹底避免了昂貴的回溯與重寫。

2. 電腦圖學與遊戲開發 (Procedural Generation)

在《Minecraft》這類擁有無限大世界的遊戲中,系統不可能把整個地圖存下來,也不可能在玩家移動時慢慢「搜尋」該生成什麼地形。開發者使用了 Perlin Noise 等純數學函數:只要給定一個座標 $(x, y)$(就像是我們題目中的 $k$),系統就能透過位元運算與雜湊,在 $O(1)$ 時間內直接「算出」這格的地形,且完美滿足相鄰區塊平滑過渡的約束條件。

3. 空間資料庫與希爾伯特曲線 (Hilbert Curve)

Google Maps 或外送平台需要在資料庫中快速找到目標。但地圖是 2D 的,而資料庫索引是 1D 的。科學家發明了空間填充曲線:給定一個 1D 序號 $k$,可以透過一系列精妙的位元運算,直接算出它在地圖上的 $(x, y)$ 座標,完全不需遍歷地圖。這與我們算出「第 $k$ 個快樂字串」的數學原理(空間劃分與位移)屬於同一個家族的工程魔法。

結語:從搜尋者進化為計算者

從回溯法進化到數學定位法,代表了程式設計師思維的轉變:從「一個個去試」的狀態搜尋,進化到「洞察規則」後的直接數學對射。

只要問題具備「狀態空間極大」與「明確的相鄰約束」兩大特徵,頂尖的工程師永遠會找出隱藏的數學規律,透過空間映射、位元運算與狀態掩碼,將複雜的搜尋問題降維打擊成常數時間的計算。這種對系統與數學的雙重敏銳度,正是頂尖架構師的標誌。

本文為「程式夥伴」專題系列,探討演算法與底層工程的連結。

2026年3月12日 星期四

從 LeetCode 3600 看工程思維:極致效能 vs 萬用架構

在面對演算法挑戰時,我們常常會陷入「尋求最快解」的迷思。然而,在真實世界的軟體工程中,最快的演算法真的永遠是最好的選擇嗎?

今天我們透過 LeetCode 3600: Maximize Spanning Tree Stability with Upgrades 這道經典的圖論題,來看看兩種截然不同的解題思路,以及它們在現實工業界中各自稱霸的真實場景。

題目背景:尋找最穩定的生成樹

這道題目的核心目標是:在給定的一張圖中,挑選出 $N-1$ 條邊形成「生成樹 (Spanning Tree)」。其中有些邊是「必選的」,有些是「可選的」。我們手上有 $K$ 次升級機會,可以將可選邊的強度乘以 2。 目標:最大化這棵樹的「最弱連結 (最小邊緣強度)」。

面對這個「最大化最小值」的難題,我們有兩種截然不同的解題策略。

策略一:二分搜尋 + 貪心驗證 (Binary Search on Answer)

這是一個非常扎實且通用的工程思維:我們不知道答案是多少,但我們可以「猜」。

運作邏輯:

  1. 確定答案的上下界(例如強度從 $0$$100,000$)。

  2. 猜測一個目標值 mid,並寫一個 check(mid) 函數來驗證:「在 $K$ 次升級內,我們能不能讓所有挑選的邊,強度都 $\ge mid$?」

  3. 根據驗證結果,不斷縮小範圍,直到找到極限值。

💡 真實世界的應用場景:高擴充性的「萬用框架」

這個演算法雖然時間複雜度多了一個 $\log M$,但在真實的工程應用中,它卻具有壓倒性的擴充性 (Extensibility)

  • 電信網路的 SLA 保證與預算規劃 中華電信或 AWS 要牽線連接全球的資料中心,並保證最低可用頻寬。公司今年只給了預算購買 $K$ 台「訊號放大器」。網路架構師會透過這個演算法「二分猜測」保證頻寬,驗證預算是否足夠,藉此找出基礎建設的投資報酬率極限。

  • EDA (電子設計自動化) 與晶片佈線 晶片設計中,工程師可以在線路上插入 Buffer(緩衝器)來增強訊號,但 Buffer 非常耗電且最多只能放 $K$ 個。軟體會猜測最差的訊號延遲時間,去驗證這 $K$ 個 Buffer 該怎麼放。

  • 為什麼它無可取代? 真實世界的規則往往不講武德。如果今天老闆說:「A 線路升級加 500、B 線路升級乘以 1.5、C 線路升級需要消耗 2 次升級機會...」純數學推導會瞬間崩潰,但二分搜尋版完全不受影響!你只需要在 check() 函數裡加上幾行 if-else,這個演算法就能完美存活。

策略二:Kruskal 演算法變形 (Pure Greedy)

這是一個將圖論數學性質發揮到極致的 $O(E \log E)$ 神級解法。它捨棄了「猜答案」,直接在一次遍歷中找出答案。

運作邏輯:

  1. 將所有「可選邊」依照強度由大到小排序。

  2. 依序將邊加入圖中(使用並查集 DSU 避免迴圈)。

  3. 既然有 $K$ 次升級機會,為了讓「最小值」最大化,自然要把機會留給生成樹中最弱的那 $K$ 條邊

  4. 透過追蹤 edgesUsed,精準攔截出「沒有被升級的最弱邊」和「有被升級的最弱邊」,最後比較瓶頸即可。

💡 真實世界的應用場景:極致效能的「特製手術刀」

當規則明確且不變時,這種純貪心演算法能提供無與倫比的效能,特別適合底層系統與即時運算。

  • Linux Kernel 的生成樹協定 (STP) 在網路橋接系統 (net/bridge/br_stp.c) 中,為了解決交換機之間的「廣播風暴」,Kernel 必須在極短時間內找出無迴圈的生成樹。Kruskal 演算法的底層邏輯正是這類網路防護機制的核心。

  • 機器學習:層次分群 (Hierarchical Clustering) 在資料科學中 (例如 Single-linkage Clustering),我們需要將特徵相近的資料點分群。背後運作完全依賴排序與並查集 (DSU),能快速將距離最短的節點合併,達成自動分類。

  • 電腦視覺:影像分割 (Image Segmentation) 讓電腦看懂照片中哪裡是人、哪裡是背景。演算法將像素當作節點,顏色差異當作邊,透過並查集在極短的 $O(1)$ 時間內將相似區塊合併,達成即時去背效果。

總結

LeetCode 3600 教會我們最重要的一課是:演算法沒有絕對的好壞,只有最適合的情境。

  • 如果你面對的是一個規則單純、對效能要求極高的底層系統,請拿出 Kruskal 貪心法 這把特製手術刀。

  • 如果你面對的是一個業務邏輯複雜、需求隨時會變更的商業系統,請架構好 二分搜尋驗證 這個萬用防護罩。

下次在解題時,不妨多想一步:這段程式碼如果放到真實世界,它會扮演什麼樣的角色呢?

2026年1月20日 星期二

修復 v4l2loopback 0.15.0 的 V4L2 緩衝區佇列問題

## 問題現象 使用 GStreamer v4l2sink 將影像送到 v4l2loopback 虛擬攝影機時出現錯誤: ``` WARN v4l2bufferpool: buffer X was not queued, this indicates a driver bug ``` 經過分析發現是 v4l2loopback 0.15.0 違反 V4L2 規格所致。 ## V4L2 緩衝區佇列規格 V4L2 規格定義的緩衝區使用流程: 1. **VIDIOC_REQBUFS** - 配置緩衝區 2. **VIDIOC_QBUF** - 將緩衝區排入驅動程式佇列 3. **VIDIOC_DQBUF** - 從驅動程式佇列取出緩衝區 核心規則:DQBUF 只能回傳先前透過 QBUF 排入的緩衝區。 正確流程:`QBUF → (處理) → DQBUF → QBUF → (處理) → DQBUF ...` v4l2loopback 0.15.0 的錯誤流程:`(預填充) → DQBUF → (自動循環) → DQBUF → (自動循環) → DQBUF ...` 錯誤點:完全不需要 QBUF,DQBUF 永遠有可用緩衝區。 ## v4l2loopback 0.15.0 的問題 ### prepare_buffer_queue() 預先填充佇列 ```c /* 原始碼 */ for (pos = 0; pos < count; ++pos) { bufd = &dev->buffers[pos]; if (list_empty(&bufd->list_head)) list_add_tail(&bufd->list_head, &dev->outbufs_list); } ``` 問題:所有緩衝區在配置後立即被加入輸出佇列,沒有經過 QBUF。 ### vidioc_dqbuf() 循環使用緩衝區 ```c /* 原始碼 */ bufd = list_first_entry_or_null(&dev->outbufs_list, ...); if (bufd) list_move_tail(&bufd->list_head, &dev->outbufs_list); ``` 問題:使用 `list_move_tail()` 將緩衝區移到佇列尾端,形成循環。 ### 違反規格的行為 1. 緩衝區未經 QBUF 就出現在輸出佇列 2. DQBUF 回傳從未排入的緩衝區 3. 緩衝區自動循環使用 實際流程:`DQBUF → DQBUF → DQBUF ...` (完全不需要 QBUF) ### GStreamer 的檢查 GStreamer v4l2bufferpool 會追蹤已排入的緩衝區。收到未排入的緩衝區時報錯: ``` WARN v4l2bufferpool: buffer X was not queued, this indicates a driver bug ``` 這是正確的行為,因為驅動程式確實違反了規格。 ## 修復方法 ### 修改 prepare_buffer_queue() 清空輸出佇列,不要預先填充: ```c /* 清空輸出佇列 */ list_for_each_entry_safe(bufd, n, &dev->outbufs_list, list_head) { list_del_init(&bufd->list_head); } /* 重設緩衝區狀態 */ for (pos = 0; pos < count; ++pos) { bufd = &dev->buffers[pos]; unset_flags(bufd->buffer.flags); dev->bufpos2index[pos % count] = bufd->buffer.index; } ``` ### 修改 vidioc_dqbuf() 移除緩衝區,不要循環使用: ```c bufd = list_first_entry_or_null(&dev->outbufs_list, ...); if (bufd) list_del_init(&bufd->list_head); /* 移除,不循環 */ ``` ### 修復後的行為 - 輸出佇列初始為空 - 緩衝區只在透過 QBUF 排入時才進入輸出佇列 - DQBUF 移除緩衝區,不循環使用 - 符合 V4L2 規格要求 ## 測試驗證 修復前: ```bash gst-launch-1.0 icamerasrc ! v4l2sink device=/dev/video0 # ERROR: buffer X was not queued, this indicates a driver bug ``` 修復後: ```bash gst-launch-1.0 icamerasrc ! v4l2sink device=/dev/video0 # 正常運作,無錯誤訊息 # Firefox/Chrome 可正常使用虛擬攝影機 ``` ## 技術細節 ### 為什麼原設計會預先填充? v4l2loopback 作為虛擬裝置,可能想簡化緩衝區管理,確保永遠有可用緩衝區。但這違反了 V4L2 規格,與嚴格遵循規格的程式(如 GStreamer)不相容。 ### list_del_init vs list_move_tail - `list_del_init()`: 明確表示「緩衝區不在佇列中」 - `list_move_tail()`: 暗示「移到另一個位置」,語義模糊 使用 `list_del_init()` 讓緩衝區狀態更清楚。 ### 多執行緒考量 正確的緩衝區狀態追蹤在多執行緒環境下很重要,可避免競爭條件。 ## 參考資料 - V4L2 Buffer: https://www.kernel.org/doc/html/latest/userspace-api/media/v4l/buffer.html - VIDIOC_QBUF/DQBUF: https://www.kernel.org/doc/html/latest/userspace-api/media/v4l/vidioc-qbuf.html - V4L2 Streaming I/O: https://www.kernel.org/doc/html/latest/userspace-api/media/v4l/io.html - GStreamer v4l2bufferpool: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/blob/main/subprojects/gst-plugins-good/sys/v4l2/gstv4l2bufferpool.c ## 上游貢獻 修補程式已提交至上游:https://github.com/v4l2loopback/v4l2loopback/pull/656 ## 結論 V4L2 緩衝區管理規格的核心原則: 1. 明確的狀態轉換:QBUF → DQBUF 2. 應用程式控制:由應用程式決定何時提供緩衝區 3. 可預測行為:嚴格遵循規格 當應用程式報告 "driver bug" 時,通常確實是驅動程式的問題。

解析位元運算公式 n & ~(((n + 1) & ~n) >> 1)

# 前言 在解決「尋找最小的 $x$ 使得 $x \lor (x + 1) = n$ 」這個問題時,我們得出了一個結論:對於奇數 $n$ ,答案是將 $n$ 的二進位表示中,「末尾連續的 1」序列裡最高位的那一個 `1` 修改為 `0`。 例如:如果 $n$ 的二進位是 `...01111`,我們目標是把它變成 `...00111`。 這個操作可以透過以下這行精簡的位元運算公式完成: ```cpp n & ~(((n + 1) & ~n) >> 1) ``` 這篇筆記旨在拆解這個複合表達式,逐步說明其工作原理。 --- # 公式拆解 這個公式可以分為三個主要步驟來理解,我們由內而外進行分析。 我們的目標 $n$ 範例設為 **23**,二進位表示為 `00010111`。 我們的目標是將末尾三個 `1` 中最左邊的那個(值為 4 的位元)關閉。 ## 步驟一:找出右邊數來第一個「0」的位置 **表達式核心:** `(n + 1) & ~n` 這是非常經典的位元操作技巧,用於定位最低位的 `0`。 1. **`n + 1` 的進位特性**: 當一個整數加 1 時,其二進位末尾所有的連續 `1` 都會因為進位而變成 `0`,直到遇到第一個 `0`,該位置會變成 `1`,進位停止。 * (23) = `00010111` * (24) = `00011000` (注意末尾三個 1 變成了 0,它們左邊的 0 變成了 1) 2. **`~n` 取反**: * `~n` = `11101000` 3. **`&` (AND) 運算**: 將上述兩個結果進行 AND 運算,只會保留同時為 `1` 的位元。 ``` 00011000 (n + 1) & 11101000 (~n) ---------------- 00001000 (結果為 8) ``` **小結**:這一步成功分離出了 從右側開始數第一個非 `1` 的位置。 ## 步驟二:鎖定目標位元(向右移位) **表達式:** `(步驟一的結果) >> 1` 我們在步驟一找到了第一個 `0` 的位置(在範例中是第 3 位,數值為 8)。 但我們的目標是修改這個 `0` 位置**右邊**的那一個 `1`(也就是末尾連續 `1` 序列的最高位)。 因此,我們將步驟一的結果向右移動一位: * `00001000 >> 1` = `00000100` (數值為 4) **小結**:這一步得到了一個「遮罩 (Mask)」,這個遮罩只有我們想要修改的那一個目標位元是 `1`,其他都是 `0`。 ## 步驟三:清除目標位元 **表達式:** `n & ~(步驟二的遮罩)` 現在我們有了目標遮罩 `Mask = 00000100`。我們要利用這個遮罩把 對應位置的 `1` 變成 `0`,並保持其他位置不變。 這是標準的「清除位元」操作: 1. **`~Mask` (遮罩取反)**: 製造一個工具,目標位置為 `0`,其他位置全為 `1`。 * `~Mask` = `11111011` 2. **`n & (~Mask)`**: 將原始的 與這個反向遮罩做 AND。 * 目標位置:`1 & 0` 結果為 `0`(成功清除)。 * 其他位置:`x & 1` 結果仍為 `x`(保持不變)。 ``` 00010111 (n, 即 23) & 11111011 (~Mask) ---------------- 00010011 (結果為 19) ``` # 總結 回顧整個流程: 1. `00010111` (原始 n) 2. `00001000` (找到第一個 0) 3. `00000100` (右移,鎖定目標 1) 4. `11111011` (取反,準備清除工具) 5. `00010011` (與原數 AND,完成清除) 這個公式 `n & ~(((n + 1) & ~n) >> 1)` 利用了加法進位的特性和基本的邏輯閘操作,在不使用任何迴圈的情況下,精確地完成了「關閉末尾連續 1 中最高位」的任務。這是一種高效且常見於底層優化的寫法。

2026年1月6日 星期二

深入解析 grep 的「Broken pipe」錯誤:隨機出現的原因與優雅解法

最近我在 [ChromeOS Flex](https://chromeos.google/products/chromeos-flex/) 的 Linux 環境中使用 [Homebrew](https://brew.sh/) 時,總是會隨機看到 `grep: write error: Broken pipe`。後來去檢查相關程式碼,發現是這行 `grep -E "^(flags|Features)" /proc/cpuinfo | grep -q "ssse3"` 指令造成的。 這個錯誤雖然不一定影響最終結果,但在特定環境下出現總是讓人感到困惑。為什麼這行看似普通的管線指令會隨機報錯?今天就來深入探討其成因,並提供修正方案。 ## 「破裂的管線」錯誤成因 這個錯誤的核心,在於 Linux 作業系統處理**行程(Process)**與**管線(Pipe)**的機制。 當我們執行上述指令時,發生了以下連鎖反應: 1. **前端寫入(Producer)**: 第一個指令 `grep -E ...` 負責讀取 CPU 資訊,並持續將符合條件的資料寫入管線。 2. **後端讀取與提早退出(Consumer)**: 管線另一端的 `grep -q "ssse3"` 負責接收資料。關鍵在於 `-q` (quiet) 參數,它告訴 `grep` 一旦找到 "ssse3",就**立即停止讀取並退出程式**。 3. **管線斷裂(Broken Pipe)**: 當後端 `grep` 退出後,管線的讀取端隨之關閉。然而,前端的 `grep` 此時可能還在嘗試寫入剩餘的資料。當它試圖寫入這條「已關閉」的管線時,系統便會發送 **SIGPIPE** 訊號,導致行程終止並印出 `write error: Broken pipe`。 ## 為何錯誤是隨機出現的? 既然機制如此,為何不是「每次」都報錯?這歸因於**競爭條件(Race Condition)**。 電腦的排程器決定了兩個程式執行的快慢: * **沒事發生的情況**:前端 `grep` 產出資料的速度夠快,或者資料量剛好夠小,在後端退出前就已經寫完並結束了。 * **報錯的情況**:後端 `grep -q` 很快就找到了 "ssse3" 並關閉管線,而前端還來不及寫完剩下的資料,導致寫入失敗。 這種執行時間上的微小差異,決定了你是否會看到這行錯誤訊息。 ## 更優雅的解決方案 既然問題出在「多個指令透過管線傳輸」的時間差,最穩健的解法就是**避免使用管線**,將邏輯整合到單一 `grep` 指令中。 我們可以將原本的指令: ```bash grep -E "^(flags|Features)" /proc/cpuinfo | grep -q "ssse3" ``` 改寫為: ```bash grep -qE '^(flags|Features).*\bssse3\b' /proc/cpuinfo ``` ### 改寫後的優點: * **消除競爭條件**:單一行程運作,完全根除「管線破裂」的可能性。 * **效能提升**:省去了啟動兩個行程與建立管線的開銷。 * **精準度提高**: * `^(flags|Features)`:鎖定行首。 * `.*\bssse3\b`:利用 `\b`(單詞邊界)精確匹配 "ssse3",避免誤判類似字串。 下次若在 Script 中發現類似的隨機管線錯誤,不妨檢查是否使用了會提早退出的指令(如 `grep -q`、`head` 等),並嘗試將其整合以提升穩定性! 順便生了一個 [pull request](https://github.com/Homebrew/brew/pull/21366) 給 Homebrew

2026年1月5日 星期一

自幹了一個 Agent Skills - Launchpad Skill

放在 https://github.com/fourdollars/lp-api/ 專案底下的 launchpad 目錄底下,當然就是用 lp-api 這個小工具來串接。

我自己有在 OpenCode, GitHub Copilot CLI, Gemini CLI 上面成功掛載起來使用。

基本上就是把 AI Agent 接上 Launchpad,用自然語言去叫 AI 做自己想要做的事情。

以下是幾張 OpenCode 上使用的示範。

有興趣的人可以去看看 Agent Skills 了解一下。

2025年10月10日 星期五

逐步優化 LeetCode 3494「巫師釀藥」問題的完整推導

***此篇文章由 Gemini AI 產生*** 演算法的魅力不僅在於解決問題,更在於追求極致的效率與優雅。本文將以 LeetCode 3494. Find the Minimum Amount of Time to Brew Potions 問題為例,記錄一次從初步正確解法到極致高效解法的完整優化歷程。我們將歷經三個關鍵的里程碑,程式碼的運行時間也從 **231ms**,途經 **183ms**,最終達到 **91ms**。 #### 奠定基石 — 問題的數學模型推導 在分析任何程式碼之前,我們必須先從問題的第一性原理出發,建立一個準確的數學模型。這個模型將是我們後續判斷所有演算法正確性的「黃金標準」。 **1. 變數定義** * $n$: 巫師數量, $m$: 藥水數量 * $s_i$: `skill[i]` * $m_j$: `mana[j]` * `skill_prefix_sum[i]`: `skill` 的**標準前綴和**,即 $P[i] = \sum_{k=0}^{i-1} s_k$。特別地,$P[0]=0$。 * $S_j$: 第 $j$ 瓶藥水的**發車時間**(巫師0的開始時間)。 * $T_j(i)$: 第 $i$ 位巫師完成第 $j$ 瓶藥水的**完成時間點**。 **2. 核心約束與「剛性排程」** 問題的關鍵是「立即傳遞」。對於同一瓶藥水 `j`,巫師 `i` 的開始時間 $S_j(i)$ 和完成時間 $T_j(i)$ 與巫師0的開始時間 $S_j$ 之間,存在一個固定的相對關係: $$S_j(i) = S_j + \text{skill_prefix_sum}[i] \cdot m_j$$ $$T_j(i) = S_j(i) + s_i \cdot m_j = S_j + \text{skill_prefix_sum}[i+1] \cdot m_j$$ **3. 推導發車時間 $S_j$** 要計算下一瓶藥水 `j` 的發車時間 $S_j$,必須保證它不會與上一瓶藥水 `j-1` 的任何工作衝突。這意味著,對於**每一位**巫師 `i`,他開始處理藥水 `j` 的時間 $S_j(i)$,必須晚於或等於他完成藥水 `j-1` 的時間 $T_{j-1}(i)$。 $$S_j(i) \ge T_{j-1}(i) \quad (\text{對所有 } i \in [0, n-1] \text{ 恆成立})$$ 將 $S_j(i)$ 的表達式代入並移項: $$S_j \ge T_{j-1}(i) - \text{skill_prefix_sum}[i] \cdot m_j$$ $S_j$ 必須滿足所有巫師的約束,因此取所有約束中的最嚴格者(即下界的最大值): $$S_j = \max_{0 \le i \lt; n} \left( T_{j-1}(i) - \text{skill_prefix_sum}[i] \cdot m_j \right)$$ 這就是我們計算每一輪發車時間的**核心公式**。 ----- ### 第一站:最初的起點 (231ms) — 隱晦但正確的實作 (與官方解答同構) 我們的探索始於一個能夠正確解決問題、但邏輯較為隱晦的實作版本。 #### 邏輯分析 此版本的程式碼透過一個巧妙的順向和逆向迴圈,計算出了與我們上述基礎模型完全等價的結果。 **程式碼 (版本一:231ms)** ```cpp class Solution { public: long long minTime(vector<int>& skill, vector<int>& mana) { int n = skill.size(); // finish_times[i] 儲存第 i 巫師完成上一瓶藥水的時間點 T_{j-1}(i) array<long long, 5000> finish_times{}; for (long long mana_val : mana) { // rolling_max_term 是一個滾動計算的中間值,最終用於計算 S_j long long rolling_max_term = finish_times[0]; // 順向迴圈: 計算一個與 S_j 相關的中間值 for (int i = 1; i < n; ++i) { rolling_max_term = max(rolling_max_term + (long long)skill[i - 1] * mana_val, finish_times[i]); } // 更新最後一位巫師的完成時間 T_j(n-1) finish_times[n - 1] = rolling_max_term + (long long)skill[n - 1] * mana_val; // 逆向迴圈: 根據新的 T_j(n-1) 反向推導並更新整個狀態陣列 for (int i = n - 2; i >= 0; --i) { finish_times[i] = finish_times[i + 1] - (long long)skill[i + 1] * mana_val; } } return finish_times.empty() ? 0 : finish_times[n - 1]; } }; ``` * **效能瓶頸**:雖然邏輯正確,但對每一瓶藥水都需要進行兩次獨立的、遍歷 `N` 位巫師的迴圈,導致效能不佳。 ----- ### 第二站:清晰的力量 (151ms) — 導入前綴和的重構 優化的第一步,是讓程式碼的邏輯變得更清晰。我們直接將基礎數學模型,用最直白的方式翻譯成程式碼。 #### 邏輯分析 這個版本的程式碼是我們在「奠定基石」章節所推導出的數學模型的**直接實現**。它的正確性由基礎模型的正確性所保證。 **程式碼 (版本二:151ms)** ```cpp class Solution { public: long long minTime(vector<int>& skill, vector<int>& mana) { int n = skill.size(); if (n == 0) return 0; // 預計算 skill 的標準前綴和 array<long long, 5001> skill_prefix_sum{}; for (int i = 0; i < n; ++i) { skill_prefix_sum[i + 1] = skill_prefix_sum[i] + skill[i]; } // finish_times[i] 儲存第 i 巫師完成上一瓶藥水的時間點 T_{j-1}(i) array<long long, 5000> finish_times{}; for (long long mana_val : mana) { // 1. 直接根據公式計算發車時間 S_j long long earliest_start_time = 0; for (int i = 0; i < n; ++i) { earliest_start_time = max(earliest_start_time, finish_times[i] - skill_prefix_sum[i] * mana_val); } // 2. 直接根據公式更新所有巫師的完成時間 T_j(i) for (int i = 0; i < n; ++i) { finish_times[i] = earliest_start_time + skill_prefix_sum[i + 1] * mana_val; } } return finish_times.empty() ? 0 : finish_times[n - 1]; } }; ``` * **效能提升之謎 (231ms -\> 151ms)**:程式碼的結構現在完全對應數學公式,這種直接的轉換有時能幫助編譯器產生更優化的機器碼,同時更友好的記憶體存取模式也帶來了速度提升。 ----- ### 第三站:演算法的躍遷 (91ms) — O(1) 狀態轉移的數學魔法 #### 演算法推導 — 革命性的狀態壓縮 最驚人的優化來自於對遞迴關係的根本性重構,將 `O(N)` 的狀態傳遞壓縮為 `O(1)`。 1. **建立關於最終答案的遞迴式** 令 $T_j$ 為處理完第 `j` 瓶藥水的最終答案,即 $T_j=T_j(n-1)$。我們可以推導出 $T_j$ 和 $T_{j-1}$ 的關係: $$T_j = T_{j-1} + \Delta_j + P[n] \cdot (m_j - m_{j-1})$$ 其中 $\Delta_j = \max_{i} ( P[i+1]m_{j-1} - P[i]m_j )$。 2. **從高效程式碼反向推導** 91ms 的程式碼使用了一個累加器 `timeSum`,其定義為 ${timeSum}_j = T_j - {sPrefix}[n-1] \cdot m_j$ 而它的遞迴式是 $\text{timeSum}_j = \text{timeSum}_{j-1} + \text{maxTime}_j$ 3. **證明等價性** 將 `timeSum` 的定義代入其遞迴式,我們可以得到一個關於 $T_{j}$ 的新遞迴式。將這個新遞迴式與我們在第 1 步中推導的遞迴式進行比較,經過代數化簡,最終可以證明兩者等價的充要條件是: $$\text{maxTime}_j = \max_{i} ( P[i+1]m_{j-1} - P[i]m_j ) + s_0m_j - s_0m_{j-1}$$ 而 91ms 程式碼中 `maxTime` 的計算方式,正是這個看似複雜的表達式經過巧妙化簡後的結果!這證明了其演算法的正確性。 #### 邏輯分析 此演算法透過精妙的代數變換,證明了我們可以只用一個累加器 `time_accumulator` 來傳遞狀態,而無需儲存整個 `finish_times` 陣列。其詳細的數學推導過程,顯示了其與基礎模型在結果上的完全等價性。 **程式碼 (版本三:91ms)** ```cpp class Solution { public: long long minTime(vector<int>& skill, vector<int>& mana) { int n = skill.size(); int m = mana.size(); // 定義一個特殊的 "前綴和",不包含 skill[0],為數學簡化服務 array<long long, 5000> skill_prefix_sum_from_one{}; for (int i = 1; i < n; i++) { skill_prefix_sum_from_one[i] = skill_prefix_sum_from_one[i - 1] + skill[i]; } // 初始化 O(1) 狀態累加器 long long time_accumulator = (long long)skill[0] * mana[0]; // 每一輪,狀態轉移只依賴 time_accumulator 這個 O(1) 的值 for (int j = 1; j < m; j++) { long long prev_mana = mana[j - 1]; long long current_mana = mana[j]; // 計算用於更新累加器的核心項 long long max_term_for_sum = (long long)skill[0] * current_mana; for (int i = 1; i < n; i++) { long long time_diff_term = skill_prefix_sum_from_one[i] * prev_mana - skill_prefix_sum_from_one[i - 1] * current_mana; max_term_for_sum = max(max_term_for_sum, time_diff_term); } // 更新 O(1) 狀態 time_accumulator += max_term_for_sum; } // 根據最終的狀態值,還原出真正的答案 return time_accumulator + skill_prefix_sum_from_one[n - 1] * mana[m - 1]; } }; ``` * **效能提升的根源**: 1. **狀態壓縮**:將 `O(N)` 的狀態依賴(整個 `finish_times` 陣列)壓縮到 `O(1)` 的狀態依賴(僅 `time_accumulator` 一個值)。 2. **單一迴圈與資料局部性**:所有計算都在一個 `O(N)` 的內層迴圈中完成,且資料依賴性極小,極大地提升了 CPU 快取效率。 ----- ### 總結:我們的優化之旅 從 231ms 到 91ms,我們經歷了從一個能工作的解法,到一個更清晰的重構,再到一個基於深刻數學洞察的根本性優化。這趟旅程清晰地展示了在追求極致效能的道路上,清晰的邏輯、直接的數學轉譯,以及對問題結構本身的顛覆性思考,都是不可或缺的關鍵步驟。

2025年10月7日 星期二

解題筆記:運用 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年10月5日 星期日

417. Pacific Atlantic Water Flow - C++ | Runtime 0ms | Beats 100% | 逆向雙 BFS 解法

***此篇文章由 Gemini AI 產生*** # Intuition (思路) 我第一個想法是去檢查每一個格子。從每個格子出發,我都可以做一次搜尋(例如 DFS 或 BFS),來看看是否存在一條路徑可以同時流到太平洋(頂部和左側邊界)和大西洋(底部和右側邊界)。然而,這種做法的效率會非常差,因為我會多次重複計算重疊子問題的路徑,最後很可能會導致「超時 (Time Limit Exceeded)」。 一個好得多的方法是「逆向思考」。與其問「這個格子的水可以流到哪裡?」,不如反過來問「有哪些格子可以流到海洋裡?」。我可以從直接接觸海洋的格子開始,向內陸或「向上游」進行遍歷。任何從海洋邊界出發,只要往等高或更高的地方走就能到達的格子,都代表該格子的水可以「向下流」到那片海洋。 所以,我可以先從所有太平洋邊界的格子開始進行一次搜尋,找出所有能流到太平洋的格子。接著,再從所有大西洋邊界的格子開始做第二次搜尋,找出所有能流到大西洋的格子。最後,兩次搜尋結果中都有出現的格子,就是我們的答案。 # Approach (解題方法) 核心思想是使用兩次獨立的廣度優先搜尋(BFS),來分別找出所有可以流入兩大洋的格子。 1. **從太平洋開始:** * 初始化一個佇列 (queue),並將所有在第一列和第一行的格子(也就是太平洋邊界)加入佇列中。 * 使用一個 `weight` (權重) 矩陣來追蹤可達性。當我們從太平洋這邊訪問到一個格子時,我們就將它的權重加上 `1`。 * 執行 BFS。從佇列中取出格子並探索其相鄰的格子。我們只能從一個格子 `A` 移動到相鄰的格子 `B`,當且僅當 `height(B) >= height(A)`。這就模擬了從海洋的角度來看,水流「往上游」的情況。 * 第一次 BFS 將會把所有能流到太平洋的格子,其權重都標記為 `1`。 2. **移至大西洋:** * 重新初始化 `visited` 追蹤器(但不清空 `weight` 矩陣)。 * 將所有在最後一列和最後一行的格子(大西洋邊界)加入佇列中。 * 使用同樣的「往上游」邏輯,執行第二次 BFS。 * 這一次,當我們訪問到一個格子時,我們將它的權重加上 `2`。 3. **找出交集:** * 在兩次 BFS 遍歷都完成後,我們遍歷整個 `weight` 矩陣。 * 任何權重等於 `3` 的格子(代表來自太平洋 BFS 的 `1` + 來自大西洋 BFS 的 `2`),就是水可以同時流到兩大洋的格子。 * 我們收集所有這些格子的座標,組成最終的答案。 這種方法透過高效地在單次遍歷中找到每個海洋的可達格子集合,避免了多餘的計算。 # Complexity (複雜度) - Time complexity (時間複雜度): 我們執行了兩次 BFS 遍歷。每一次 BFS 最多只會訪問每個格子和邊一次。最後收集結果的迴圈也會遍歷所有格子。因此,時間複雜度與網格中的格子總數成正比。 $$O(m \times n)$$ 其中 $m$ 是列數,$n$ 是行數。 - Space complexity (空間複雜度): 我們使用了一個 `weight` 矩陣、一個 `visited` 矩陣以及一個 BFS 用的佇列。這些資料結構所需的空間與網格中的格子總數成正比。 $$O(m \times n)$$ # Code (程式碼) ```cpp class Solution { int m, n; // weight[i][j] stores reachability: // 1: can reach Pacific // 2: can reach Atlantic // 3: can reach both array<array<int, 200>, 200> weight{}; array<array<bool, 200>, 200> visited{}; // Helper to add a cell to the queue and mark it inline void enqueue(queue<pair<int, int>>& q, int x, int y, int inc) { weight[x][y] += inc; visited[x][y] = true; q.push({x, y}); } // BFS to find all cells that can flow "down" to the initial set of cells in the queue void bfs(queue<pair<int, int>>& q, vector<vector<int>>& heights, int inc) { array<array<int, 2>, 4> dirs{{{0, 1}, {1, 0}, {0, -1}, {-1, 0}}}; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (auto& d : dirs) { int nx = x + d[0]; int ny = y + d[1]; // Check bounds if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue; // If already visited in this specific BFS or water can't flow "uphill" if (visited[nx][ny] || heights[x][y] > heights[nx][ny]) continue; // Enqueue the valid neighbor enqueue(q, nx, ny, inc); } } } public: vector<vector<int>> pacificAtlantic(vector<vector<int>>& heights) { m = heights.size(); n = heights[0].size(); queue<pair<int, int>> q; // 1. BFS from Pacific Ocean (top and left borders) for (int i = 0; i < m; ++i) enqueue(q, i, 0, 1); for (int i = 1; i < n; ++i) enqueue(q, 0, i, 1); bfs(q, heights, 1); // Reset visited grid for the second BFS visited = {}; // 2. BFS from Atlantic Ocean (bottom and right borders) for (int i = 0; i < m; ++i) enqueue(q, i, n - 1, 2); for (int i = 0; i < n - 1; ++i) enqueue(q, m - 1, i, 2); bfs(q, heights, 2); // 3. Collect all cells that can reach both oceans (weight == 3) vector<vector<int>> ans; ans.reserve(m + n); // Reserve memory as an optimization for (int i = 0; i < m; ++i) for (int j = 0; j < n; ++j) if (weight[i][j] == 3) ans.push_back({i, j}); return ans; } }; ```

2025年10月2日 星期四

解碼換水瓶問題:從直覺模擬到終極數學公式

***此篇文章由 Gemini AI 產生*** 大家好!我是 Coding Partner。今天想跟大家分享一個很有趣的程式問題,它看似簡單,但深入挖掘後會發現許多值得玩味的細節。 **問題情境:** 你手上有 `numBottles` 瓶水。喝完後,空瓶可以拿去回收兌換新的水。第一次兌換需要 `numExchange` 個空瓶,但之後每一次兌換所需的空瓶數都會加一。請問,你最多總共能喝到幾瓶水? 這是一個典型的演算法問題,考驗我們如何將一個生活化的情境轉化為高效的程式碼。接下來,我會帶大家走過三種層次的思考:從最直觀的模擬,到有瑕疵的數學解,最終抵達一個堪稱完美的終極公式。 ### 第一層:直觀模擬法 — 穩紮穩打的起點 面對這個問題,第一個閃過腦中的念頭,就是「照著規則跑一次看看」。這就是模擬法。它的邏輯非常直接: 1. 先把手上的水全部喝掉,得到第一批空瓶。 2. 用一個迴圈不斷檢查:手上的空瓶夠不夠換下一次? 3. 如果夠,就進行兌換,更新手上空瓶數、總共喝掉的瓶數,以及下一次兌換的成本。 4. 如果不夠,就結束。 這個思路清晰、不易出錯,寫成程式碼也相當直觀。 #### 模擬法程式碼 ```cpp #include class Solution { public: /** * @brief Calculates the maximum bottles drunk via simulation. * @param numBottles The initial number of full water bottles. * @param numExchange The number of empty bottles required for the first exchange. * @return The total number of bottles drunk. */ int maxBottlesDrunk_Simulation(int numBottles, int numExchange) { // Start by drinking all the initial bottles. int drunkBottles = numBottles; // These now become your initial empty bottles. int emptyBottles = numBottles; // Loop as long as we can afford the next exchange. while (emptyBottles >= numExchange) { // Perform one exchange. emptyBottles -= numExchange; // The cost for the next exchange increases. numExchange++; // We got a new bottle, so we drink it immediately. drunkBottles++; // That newly drunk bottle becomes an empty one. emptyBottles++; } return drunkBottles; } }; ``` 這個解法很棒,是個可靠的起點。但身為工程師,我們總會忍不住問:「有沒有可能更快?有沒有辦法不用迴圈,一次就算出來?」 ### 第二層:數學公式解 — 抓住核心關係 要擺脫迴圈,我們得換個思路。與其模擬「過程」,不如直接計算「結果」。我們可以問:**最多能進行幾次兌換?** 假設總共能兌換 `k` 次,那麼答案就是 `初始瓶數 + k`。為了算出 `k`,我們必須滿足一個核心條件: $$\text{Total Bottle Supply} \ge \text{Total Exchange Cost}$$ * **空瓶總供給量 (Total Bottle Supply)**:來自於 `numBottles` 個初始空瓶,以及 `k` 次兌換後新產生的 `k` 個空瓶,總共是 `numBottles + k`。 * **總兌換成本 (Total Exchange Cost)**:這是一個等差級數 `numExchange + (numExchange + 1) + ...` 的總和,其結果為 `(k/2) * (2 * numExchange + k - 1)`。 將這兩者放入不等式中: $$\text{numBottles} + k \ge \frac{k \times (2 \cdot \text{numExchange} + k - 1)}{2}$$ 經過整理與化簡,我們可以將這個不等式改寫成一個標準的二次不等式形式: $$k^2 + (2 \cdot \text{numExchange} - 3)k - 2 \cdot \text{numBottles} \le 0$$ 瞧!我們成功地把問題轉化成了一個國中數學的二次不等式問題。只要解出滿足這個不等式的最大整數 `k`,問題就解決了。 #### 有瑕疵的數學解程式碼 根據這個推導,我們可以寫出第一版的數學解程式碼。它使用 `floor()` 函數來取得 `k` 的最大整數解,並加上 `if` 判斷來處理無法首次兌換的邊界情況。 ```cpp #include #include class Solution { public: /** * @brief A flawed mathematical attempt to solve the problem. * It uses floor() and requires an explicit edge case check. */ int maxBottlesDrunk_FlawedMath(int numBottles, int numExchange) { // This guard clause is necessary because the formula below doesn't handle this case. if (numBottles < numExchange) { return numBottles; } double n = static_cast(numBottles); double x = static_cast(numExchange); double b = 2 * x - 3; double c = -2 * n; double t_root = (-b + std::sqrt(b * b - 4 * 1 * c)) / 2.0; // The intuitive approach: the number of exchanges is the floor of the root. int exchanges = static_cast(std::floor(t_root)); return numBottles + exchanges; } }; ``` 這個版本看起來不錯,但魔鬼,就藏在我們沒看到的細節裡。 ### 第三層:完美的細節 — `floor` vs `ceil - 1` 上面這個 `floor(t)` 的版本,在兩個關鍵的邊界情況會出錯: 1. **需要 `if` 補丁**:它無法自行處理 `numBottles < numExchange` 的情況,必須依賴一個額外的 `if` 判斷,顯得不夠優雅。 2. **資源正好用盡**:當 `t` 算出來剛好是整數時(例如 `t=2.0`),代表所有資源正好「完美用盡」。但在現實的逐步兌換中,這意味著你在支付最後一次費用時,會正好差一個空瓶(因為那個空瓶要等換完喝掉才有),導致兌換失敗。`floor(t)` 會多算出一次兌換。 這時,高手們發現了一個極其精妙的數學技巧來取代 `floor(t)`,那就是 `ceil(t) - 1`(對 `t` 無條件進位後再減一)。 我們來看看這個技巧有多神奇: * **當 `t` 不是整數 (如 2.7)**:`ceil(2.7) - 1 = 3 - 1 = 2`。這和 `floor(2.7) = 2` 結果一樣。 * **當 `t` 是整數 (如 2.0)**:`ceil(2.0) - 1 = 2 - 1 = 1`。它自動修正了「差一」的錯誤! * **當初始無法兌換 (t \< 1,如 0.8)**:`ceil(0.8) - 1 = 1 - 1 = 0`。它竟然連 `if` 判斷都省了,直接得到正確的兌換次數 0! 這個 `ceil(t) - 1` 的表達式,用純數學的優雅,完美地處理了所有邊界情況。 ### 終極程式碼 — 結合效率與優雅 現在,我們可以寫出最終版的程式碼了。它不僅是 O(1) 的時間複雜度,而且極其簡潔、強固。 ```cpp #include #include // Required for std::sqrt and std::ceil class Solution { public: /** * @brief Calculates the maximum number of bottles that can be drunk using a direct mathematical formula. * @param numBottles The initial number of full water bottles. * @param numExchange The number of empty bottles required for the first exchange. * @return int The total number of bottles drunk. */ int maxBottlesDrunk(int numBottles, int numExchange) { // Use long long for coefficients to prevent overflow if inputs are large. long long b = 2LL * numExchange - 3; long long c = -2LL * numBottles; // Discriminant from the quadratic formula: b^2 - 4ac double delta = (double)b * b - 4.0 * c; // The positive root of the quadratic equation double t_root = (-b + sqrt(delta)) / 2.0; // The number of exchanges 'k' is calculated using ceil(t) - 1. // This trick correctly handles both non-integer cases, perfect integer cases, // and the edge case where no exchanges are possible, all in one formula. int exchanges = (int)ceil(t_root) - 1; return numBottles + exchanges; } }; ``` ### 總結 從一個簡單的模擬迴圈,到一個帶有瑕疵的數學公式,再到一個能處理所有邊界情況的完美表達式,這個解題過程本身就是一趟精彩的旅程。它告訴我們,一個「能動」的程式和一個「完美」的程式之間,往往是對細節和邊界情況的極致追求。 希望這次的分享能對你有所啟發!祝大家寫程式愉快!

2025年10月1日 星期三

從模擬到數學魔法:解開「換酒問題」的奧秘

***此篇文章由 Gemini AI 產生*** 在程式設計的世界裡,我們時常遇到一些看似簡單,卻蘊含巧妙解法的問題。「換酒喝」或稱「空瓶換水」(Water Bottles) 就是一個典型的例子。這個問題不僅考驗我們的邏輯,更展示了如何從直觀的模擬思維,昇華到高效的數學公式解法。 今天,就讓我們一起踏上這段有趣的解題之旅,從頭到尾徹底理解這兩種方法的思路與奧妙。 ### 問題描述 > 給你 `numBottles` 瓶酒。你可以用 `numExchange` 個空酒瓶來換一瓶新酒。 > > 請問你總共可以喝到多少瓶酒? **舉個例子:** 假設你有 9 瓶酒 (`numBottles = 9`),並且 3 個空瓶可以換一瓶新酒 (`numExchange = 3`)。 - 你先喝掉 9 瓶,得到 9 個空瓶。 - 用 9 個空瓶換 3 瓶新酒。 - 喝掉 3 瓶新酒,得到 3 個空瓶。 - 再用 3 個空瓶換 1 瓶新酒。 - 喝掉 1 瓶新酒,剩下 1 個空瓶。 - 剩下 1 個空瓶,不夠再換了。 - 總共喝了 9 + 3 + 1 = 13 瓶。 ### 解法一:直觀模擬法 (The Simulation Approach) 身為工程師,我們的第一直覺通常是「模擬」。電腦最擅長的就是重複執行指令,所以我們可以寫一個迴圈,完整模擬整個換酒的過程。 #### 思考邏輯 1. **起始**:我們先把手上所有的酒 (`numBottles`) 喝完,這就是我們能喝到的基礎數量。 2. **迴圈**:只要手上的空瓶數量還足夠兌換 (`>= numExchange`),我們就持續進行交換。 3. **交換**:在每一輪迴圈中,我們計算能換到多少瓶新酒 (`gain`),並將其加入總數。 4. **更新**:手上的空瓶數會更新為「上一輪換完剩下的」加上「剛換來喝完的」。 5. **結束**:當空瓶數不再足以兌換時,迴圈結束,我們得到最終答案。 #### 程式碼實現 (C++) 這個邏輯直接、清晰,就像我們腦中沙盤推演一樣。 ```cpp class Solution { public: int numWaterBottles(int numBottles, int numExchange) { // Start with the initial number of bottles. int sum = numBottles; int emptyBottles = numBottles; // Keep exchanging as long as we have enough empty bottles. while (emptyBottles >= numExchange) { // Calculate how many new bottles we can get. int gain = emptyBottles / numExchange; // Add the newly obtained bottles to the total sum. sum += gain; // Update the count of empty bottles: // The remainder from the exchange + the new bottles we just drank. emptyBottles = (emptyBottles % numExchange) + gain; } return sum; } }; ``` 這個解法完全正確,而且很容易理解。但身為追求極致的開發者,我們不禁會想:有沒有更快、更優雅的方法? ### 解法二:數學公式法 (The Mathematical Approach) 讓我們換個角度,尋找問題背後的數學規律。這個問題的核心可以轉化為:**每多喝一瓶「換來的」酒,我們淨消耗了多少空瓶?** #### 思考邏輯 - 你用 `numExchange` 個空瓶,換來 **1** 瓶新酒。 - 喝完這瓶新酒後,它又變成了 **1** 個空瓶回到你手上。 所以,你實際付出的代價是 `numExchange - 1` 個空瓶,就成功多喝了一瓶酒。 現在,問題變成了: **你最初喝完 `numBottles` 瓶後得到的 `numBottles` 個空瓶,總共能讓你完成多少次「淨消耗 `numExchange - 1` 個空瓶」的交換?** 這裡有個小細節:你手上的空瓶不可能全部都「淨消耗」掉,因為最後總會剩下不夠交換的瓶子。一個巧妙的理解方式是: > 想像你手上有 `numBottles` 個空瓶。你先拿出 1 個放在旁邊備用,剩下 `numBottles - 1` 個空瓶作為你的「週轉資本」。 > > 每當你想換酒時,就從「週轉資本」裡拿出 `numExchange - 1` 個,再把你旁邊備用的那 1 個湊上去,正好是 `numExchange` 個。 > > 換來新酒喝完,又得到 1 個空瓶,你再把它放到旁邊當作下一次的備用瓶。 這個過程可以一直持續下去,直到你的「週轉資本」耗盡為止。所以,你**額外**能喝到的酒瓶數,就等於: `額外瓶數 = (週轉資本) / (每次交換的淨成本)` `額外瓶數 = (numBottles - 1) / (numExchange - 1)` *(這裡使用整數除法,會自動捨去餘數,正好對應了資本不足、無法再換的情況)* 最後,把初始喝的瓶數和額外喝的瓶數相加,就是總數。 #### 程式碼實現 (C++) 這個驚人的發現,讓我們可以將原本的迴圈濃縮成一行程式碼。 ```cpp class Solution { public: int numWaterBottles(int numBottles, int numExchange) { // Edge case: If we can't even make one exchange initially. // This check is optional as the formula works even without it. if (numBottles < numExchange) { return numBottles; } // Total = Initial bottles + additional bottles gained from exchanges. return numBottles + (numBottles - 1) / (numExchange - 1); } }; ``` ### 結論 我們從一個直觀的模擬解法,透過轉換思考角度,最終得到了一個極其簡潔的數學公式。 | **特性** | **模擬解法** | **數學解法** | | :--- | :--- | :--- | | **直觀性** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | | **效率** | O(log N) | O(1) | | **程式碼簡潔度** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | | **核心思想** | 逐步模擬實際過程 | 分析資源的淨消耗 | 這趟解題之旅告訴我們,許多程式問題的背後都隱藏著數學規律。當我們能夠洞察其本質,就能寫出更高效、更優雅的程式碼。下次遇到類似問題時,不妨也試著跳出模擬的框架,尋找那把化繁為簡的數學鑰匙。 Happy Coding\!

2025年9月30日 星期二

當數學遇上程式:用質因數分解優雅解決「三角和」問題

***此篇文章由 Gemini AI 產生*** 在程式解題的世界裡,我們時常在尋找暴力解法之外的、那條更優雅、更高效的康莊大道。今天,讓我們來深入探討一個經典問題 — LeetCode 上的「找出陣列的三角和」,並一步步揭示如何將一個直觀的 $O(n^2)$ 解法,昇華為一個令人驚嘆的 $O(n)$ 數論解法。 ### 問題的起點:模擬與組合數 「三角和」問題要求我們對一個數字陣列進行反覆操作:不斷將相鄰的兩個數字相加(對 10 取餘)形成一個新陣列,直到陣列只剩下最後一個數字。 一個最直觀的想法是**模擬**這個過程。這個方法可行,但時間複雜度是 $O(n^2)$。以下是不使用額外空間、直接在原陣列上操作的優化版本: ```cpp // O(n^2) Simulation Method (In-place, O(1) extra space) // This approach directly simulates the process without creating new vectors. int n = nums.size(); if (n == 1) return nums[0]; // We perform n-1 passes of transformation. // After each pass, the effective size of the array shrinks by one. for (int pass = 0; pass < n - 1; ++pass) { // This inner loop iterates up to the new boundary. // We update each element based on its next neighbor. for (int i = 0; i < n - 1 - pass; ++i) { nums[i] = (nums[i] + nums[i + 1]) % 10; } } // After all passes, the result is the first element of the array. return nums[0]; ``` 稍微深入思考,你會發現這個過程其實與數學上的**帕斯卡三角形**(或稱楊輝三角)如出一轍。最終的結果可以表示為一個組合數公式: $$S = \left( \sum_{k=0}^{n-1} C(n-1, k) \cdot \text{nums}[k] \right) \pmod{10}$$ 這是一個巨大的突破!但當我們嘗試用程式實現這個公式時,很快就會撞上兩堵高牆: 1. **整數溢位**:組合數 $C(n, k)$ 的值增長極快,即便是 `unsigned long long` 也無法承受。 2. **模運算下的除法**:為了避免溢位,我們想在計算過程中就取餘。但遞迴公式 $C(n,k) = C(n,k-1) \cdot \frac{n-k+1}{k}$ 涉及除法,而模 10 的運算下,許多數字(如 2, 4, 5)並不存在模反元素,這條路也被堵死了。 難道我們只能止步於 $O(n^2)$ 嗎? ### 思維躍遷:用質因數取代數值 真正的優化來自於一個核心思想的轉變:**如果我們不能直接處理數值,那就去處理它們的組成部分 — 質因數。** 既然模數是 10,其質因數就是 2 和 5。我們可以將任何數字 `N` 表示為一種特殊形式: $$N = 2^{p_2} \cdot 5^{p_5} \cdot R$$ 其中 `R` 是除去所有 2 和 5 之後的剩餘部分。我們只需要追蹤這三個值 (`p2`, `p5`, `R % 10`),就可以在不產生大數的情況下,完成所有計算! ### 質因數的四則運算 我們定義一個新的數字結構 `FactorRep` (Factor Representation),並為它實現乘法和除法: * **乘法**:兩個 `FactorRep` 相乘時,`p2` 和 `p5` 指數各自相加,`R` 則直接相乘後取餘。 * **除法**:指數各自相減,`R` 的除法則透過一個預先計算好的「模反元素」表來轉換為乘法。 這個技巧完美地繞過了模運算除法的限制,為我們的 $O(n)$ 解法鋪平了最後一哩路。 ### 最終解法:$O(n)$ 的優雅實現 現在,讓我們將所有想法整合起來,呈現這份兼具性能與巧思的最終程式碼。在這個版本中,我們將計算係數的複雜邏輯抽離到一個獨立的 `get_coeffs` 函式中,讓主函式 `triangularSum` 的意圖更加清晰:**先取得係數,再計算總和**。 ```cpp // Represents a number N as N = 2^p2 * 5^p5 * rem struct FactorRep { int p2 = 0; // Exponent of prime factor 2 int p5 = 0; // Exponent of prime factor 5 int rem = 1; // Remainder part, coprime to 10 }; class Solution { private: // Decomposes an integer n into its FactorRep form. FactorRep get_factors(int n) { if (n == 0) return {0, 0, 0}; FactorRep rep; rep.p2 = 0; while (n > 0 && n % 2 == 0) { rep.p2++; n /= 2; } rep.p5 = 0; while (n > 0 && n % 5 == 0) { rep.p5++; n /= 5; } rep.rem = n % 10; return rep; } // A simple integer power helper. int power(int base, int exp) { int res = 1; for (int i = 0; i < exp; ++i) res *= base; return res; } // Converts a FactorRep back to its integer value mod 10. int to_int(const FactorRep& rep) { if (rep.p2 > 0 && rep.p5 > 0) return 0; int term2 = power(2, rep.p2); int term5 = power(5, rep.p5); return (term2 * term5 * rep.rem) % 10; } // Calculates the binomial coefficients C(m, k) % 10 for k = 0 to m. vector<int> get_coeffs(int m) { array<int, 10> inv_mod10{}; inv_mod10[1] = 1; inv_mod10[3] = 7; inv_mod10[7] = 3; inv_mod10[9] = 9; vector<int> coeffs(m + 1); FactorRep current_coeff = {0, 0, 1}; coeffs[0] = 1; coeffs[m] = 1; for (int k = 1; k <= m / 2; ++k) { FactorRep num_factors = get_factors(m - k + 1); FactorRep den_factors = get_factors(k); current_coeff.p2 += num_factors.p2 - den_factors.p2; current_coeff.p5 += num_factors.p5 - den_factors.p5; int inv = inv_mod10[den_factors.rem]; current_coeff.rem = (current_coeff.rem * num_factors.rem * inv) % 10; coeffs[k] = to_int(current_coeff); coeffs[m - k] = coeffs[k]; } return coeffs; } public: int triangularSum(vector<int>& nums) { const int m = nums.size() - 1; if (m < 0) return 0; if (m == 0) return nums[0]; // Step 1: Get the binomial coefficients. // All complex logic is now encapsulated in the get_coeffs helper function. vector<int> coeffs = get_coeffs(m); // Step 2: Calculate the final weighted sum. int ans = 0; for (int i = 0; i <= m; ++i) { ans = (ans + coeffs[i] * nums[i]) % 10; } return ans; } }; ``` ### 結語 從一個看似簡單的陣列問題出發,我們經歷了從模擬到組合數學,再到最終的數論解法。這個過程完美地展示了演算法優化的魅力所在:它不僅僅是寫出能運作的程式碼,更是尋找一種更深刻、更本質的數學結構,並將其轉化為高效的計算策略。 下一次當你遇到一個看似只能暴力解決的問題時,不妨停下來想一想,背後是否也隱藏著這樣一條通往 $O(n)$ 的優雅捷徑呢?

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年9月6日 星期六

基於四底對數的整數級數求和

***此篇文章由 Gemini AI 產生*** ### **問題定義** 我們的目標是計算一個特定總和 `S`,這個總和是由一個函數 `f(i) = ⌊log₄(i)⌋ + 1` 對所有從 1 到 `4^p - 1` 的整數 `i` 的值累加而成。其數學表達式為: $$S_p = \sum_{i=1}^{4^p-1} \left( \lfloor \log_4(i) \rfloor + 1 \right)$$ 這裡的 `p` 是一個正整數。 --- ### **公式推導** 直接計算這個總和很困難,關鍵在於觀察函數 `f(i)` 的特性。`f(i)` 的值在一系列連續的整數區間內是恆定的,這些區間由 4 的次方所界定。 #### **步驟一:級數的轉換** 1. **分析函數值**: * 對於任何落在區間 $[4^k, 4^{k+1} - 1]$ 內的整數 `i`,`log₄(i)` 的值會介於 `k` 與 `k+1` 之間 (即 `k ≤ log₄(i) < k+1`)。 * 因此,`⌊log₄(i)⌋` 的值恆為 `k`。 * 所以,對於這個區間內的所有 `i`,函數 `f(i)` 的值恆為 `k+1`。 2. **計算區間內的整數數量**: * 在區間 $[4^k, 4^{k+1} - 1]$ 中,整數的數量為: $$(4^{k+1} - 1) - 4^k + 1 = 4^{k+1} - 4^k = 4^k(4-1) = 3 \cdot 4^k$$ 3. **重寫總和表達式**: * 我們的總和 $S_p$ 是加總到 $4^p - 1$,這恰好涵蓋了從 `k=0` 到 `k=p-1` 的所有完整區間。 * 因此,我們可以將原本對 `i` 的求和,轉化為對 `k` 的分組求和: $$S_p = \sum_{k=0}^{p-1} (\text{區間內函數的值}) \times (\text{區間內的整數數量})$$ $$S_p = \sum_{k=0}^{p-1} (k+1) \cdot (3 \cdot 4^k)$$ #### **步驟二:求解等比等差數列** 我們可以將常數 3 提出,得到一個**等比等差數列**的和: $$S_p = 3 \cdot \sum_{k=0}^{p-1} (k+1) \cdot 4^k$$ 令核心部分為 `A`: $$A = \sum_{k=0}^{p-1} (k+1) \cdot 4^k = 1 \cdot 4^0 + 2 \cdot 4^1 + 3 \cdot 4^2 + \dots + p \cdot 4^{p-1}$$ 我們使用**錯位相減法**來求解 `A`。首先將 `A` 乘以級數的公比 4: $$4A = 1 \cdot 4^1 + 2 \cdot 4^2 + \dots + (p-1) \cdot 4^{p-1} + p \cdot 4^p$$ 接著,將 `A` 減去 `4A`: $$-3A = (1 \cdot 4^0 + 2 \cdot 4^1 + \dots + p \cdot 4^{p-1}) - (1 \cdot 4^1 + 2 \cdot 4^2 + \dots + p \cdot 4^p)$$ 合併同類項後,算式變為: $$-3A = 1 \cdot 4^0 + (2-1) \cdot 4^1 + (3-2) \cdot 4^2 + \dots + (p - (p-1)) \cdot 4^{p-1} - p \cdot 4^p$$ 這可以簡化為一個等比級數與一個單項: $$-3A = (4^0 + 4^1 + 4^2 + \dots + 4^{p-1}) - p \cdot 4^p$$ 括號中的部分是一個首項為 1,公比為 4,共有 `p` 項的等比數列。其和為 $\frac{4^p - 1}{4 - 1} = \frac{4^p - 1}{3}$。代入上式: $$-3A = \frac{4^p - 1}{3} - p \cdot 4^p$$ 為了求解 `A`,我們繼續整理。兩邊同乘以 -1: $$3A = p \cdot 4^p - \frac{4^p - 1}{3}$$ 通分整理後: $$3A = \frac{3p \cdot 4^p - (4^p - 1)}{3}$$ 最終得到: $$3A = \frac{(3p - 1) \cdot 4^p + 1}{3}$$ --- ### **最終公式** 我們最初的目標是 $S_p = 3 \cdot A$。將 `A` 的結果代入,即可得到該總和的封閉形式解(closed-form solution): $$S_p = \frac{(3p - 1) \cdot 4^p + 1}{3}$$ 這個公式允許我們直接代入 `p` 來獲得最終結果,而無需進行迭代求和。 --- ### **範例驗證 (p=2)** 現在我們設定 `p = 2` 來驗證公式的準確性。我們要計算從 `i = 1` 到 `4² - 1 = 15` 的總和: $$S_2 = \sum_{i=1}^{15} \left( \lfloor \log_4(i) \rfloor + 1 \right)$$ #### **方法一:直接套用公式** 將 `p = 2` 代入我們推導出的公式,逐步計算: $$S_2 = \frac{(3 \cdot 2 - 1) \cdot 4^2 + 1}{3}$$ $$S_2 = \frac{(6 - 1) \cdot 16 + 1}{3}$$ $$S_2 = \frac{5 \cdot 16 + 1}{3}$$ $$S_2 = \frac{80 + 1}{3}$$ $$S_2 = \frac{81}{3} = 27$$ 公式計算出的結果是 **27**。 #### **方法二:分組逐項計算** 我們也可以將 1 到 15 的數字分組來計算實際總和: 1. **第一組 (k=0):** 當 `i` 在區間 `[1, 3]` * 包含的數字:1, 2, 3 (共 3 個) * 對於這些數字,`⌊log₄(i)⌋ + 1` 的值皆為 `0 + 1 = 1`。 * 此組的總和為: 3 個數字 × 1 = 3 2. **第二組 (k=1):** 當 `i` 在區間 `[4, 15]` * 包含的數字:4, 5, ..., 15 (共 `15 - 4 + 1 = 12` 個) * 對於這些數字,`⌊log₄(i)⌋ + 1` 的值皆為 `1 + 1 = 2`。 * 此組的總和為: 12 個數字 × 2 = 24 **總和** = 第一組總和 + 第二組總和 = `3 + 24 = 27`。 兩種方法得到的結果 **27** 完全相同。

2025年9月5日 星期五

想算數字裡有幾個 1?C++, Python3, Go, Rust 大亂鬥!

***此篇文章由 Gemini AI 產生*** 哈囉,各位寫 code 的夥伴們,大家好! 今天我們來聊一個面試或刷題時偶爾會碰到,但其實也蠻有趣的小問題:「怎麼計算一個數字在二進位下有幾個 1?」 聽起來好像很學術,但別擔心,其實觀念很簡單。比方說,數字 5,寫成二進位是 `101`,那它就有兩個 1。數字 7,二進位是 `111`,就有三個 1。這在某些演算法或需要操作底層位元(bit)的場景,其實還蠻實用的。 今天我們就來看看用四種現在很夯的語言:C++, Python3, Go, 和 Rust,要怎麼解決這個問題。順便比較一下它們的寫法和「個性」有什麼不一樣。 #### C++:硬底子真功夫,效能我最強 C++ 就是那個班上最認真、對所有細節都一清二楚的學霸。要解決這個問題,C++ 提供了一個超級方便的內建函式(built-in function),不過在比較舊的 C++ 版本可能需要自己手刻。 在比較新的 C++ 標準 (C++20) 中,你可以直接用 `` 這個函式庫: ```cpp #include #include int main() { int num = 77; // 77 的二進位是 1001101 // std::popcount 會直接幫你數好有幾個 1 int count = std::popcount(static_cast<unsigned int>(num)); std::cout << "數字 " << num << " 裡有 " << count << " 個 1!" << std::endl; // 輸出: 數字 77 裡有 4 個 1! return 0; } ``` **重點分析:** * **`std::popcount`**: 這名字超直白,「pop」在這裡指的是 "population"(總數),所以 `popcount` 就是「計算總數」。這是編譯器等級的優化,速度快到飛起來。 * **`static_cast<unsigned int>`**: 這是在做「型別轉換」。因為 `popcount` 通常是針對無號整數 (unsigned) 操作的,所以我們先把 `num` 轉成無號整數,避免一些奇怪的問題。 C++ 的寫法就是這麼樸實無華且枯燥,但效能絕對是頂尖的! #### Python3:人生苦短,我用 Python Python 就是那個朋友群裡最會聊天的萬人迷,語法親切又好懂。解決這個問題,方法多到你可以自己選。 最直覺的方法,就是直接把它變成二進位字串,然後數裡面有幾個 '1'。 ```python num = 77 # 77 的二進位是 1001101 # bin(num) 會回傳 '0b1001101' # 我們用 .count('1') 來數 '1' 的數量 count = bin(num).count('1') print(f"數字 {num} 裡有 {count} 個 1!") # 輸出: 數字 77 裡有 4 個 1! ``` 如果你是 Python 3.10 以上的版本,還可以用一個更潮的寫法: ```python num = 77 count = num.bit_count() # 直接呼叫! print(f"數字 {num} 裡有 {count} 個 1!") # 輸出: 數字 77 裡有 4 個 1! ``` **重點分析:** * **`bin()`**: Python 的內建函式,可以馬上把數字轉成 `0b` 開頭的二進位字串,超方便。 * **`.count('1')`**: 字串的 method,用來計算某個字元出現的次數。 * **`.bit_count()`**: 在新版 Python 中,整數自己就有這個 method,寫起來更乾淨,而且底層的效能也很好。 Python 的寫法就是這麼優雅,幾行就搞定,可讀性超高。 #### Go:簡單、可靠,Google 親兒子 Go 語言給人的感覺,就像是個務實的工程師,不喜歡花俏的東西,但該有的都有,而且跑起來穩定又快速。 Go 在標準函式庫 `math/bits` 裡面,也直接提供了需要的功能。 ```go package main import ( "fmt" "math/bits" ) func main() { var num uint = 77 // 77 的二進位是 1001101 // Go 推薦使用無號整數 (uint) 來做位元運算 count := bits.OnesCount(num) fmt.Printf("數字 %d 裡有 %d 個 1!\n", num, count) // 輸出: 數字 77 裡有 4 個 1! } ``` **重點分析:** * **`import "math/bits"`**: Go 把跟位元操作相關的功能都整理在這個 package 裡,分工很清楚。 * **`bits.OnesCount()`**: 函式名稱也是簡單明瞭,就是「計算 1 的數量」。 * **`var num uint = 77`**: Go 是一個強型別語言,它會建議你,在做位元運算時,最好一開始就宣告成無號整數 (`uint`),這樣語意更明確。 Go 的風格就是這樣,清楚、直接,而且效能也很棒。 #### Rust:安全、並行,程式界的超級英雄 Rust 是這幾年超級紅的語言,主打的就是「安全」跟「極致效能」。它給人的感覺有點像 C++ 的進化版,嚴格但能讓你寫出非常可靠的程式碼。 Rust 在整數型別上,也直接內建了計算 1 數量的方法。 ```rust fn main() { let num = 77_i32; // 77 的二進位是 1001101 // _i32 是告訴編譯器,這是一個 32 位元的整數 // 直接在數字後面呼叫 .count_ones() let count = num.count_ones(); println!("數字 {} 裡有 {} 個 1!", num, count); // 輸出: 數字 77 裡有 4 個 1! } ``` **重點分析:** * **`_i32`**: 這是 Rust 的語法糖,用來標明數字的型別,`i32` 代表 32 位元有號整數。Rust 對型別非常嚴格,所以寫清楚是好習慣。 * **`.count_ones()`**: Rust 把這個功能直接做成整數型別的一個 method,只要是數字,後面加上 `.count_ones()` 就能用,非常直覺。 Rust 的寫法兼具了 C++ 的效能和 Python 的簡潔,而且還有編譯器這個超級保母在後面幫你檢查,讓你寫 code 超有安全感。 ----- ### 總結一下 | 語言 | 主要寫法 | 風格特色 | | --- | --- | --- | | **C++** | `std::popcount(num)` | 效能至上,語法較嚴謹 | | **Python3** | `bin(num).count('1')` 或 `num.bit_count()` | 語法甜美,可讀性高,快速開發 | | **Go** | `bits.OnesCount(num)` | 務實可靠,標準庫功能齊全 | | **Rust** | `num.count_ones()` | 安全第一,語法現代且效能強悍 | 今天這個小問題,其實四種語言都有非常簡單、高效的解法。從這些小地方,我們也可以稍微窺探出不同程式語言在設計哲學上的差異。 沒有最好的語言,只有最適合的工具。希望這篇簡單的比較,能幫助大家對這幾種語言有更具體的認識。下次不管你用哪種語言,碰到要數 1 的時候,就知道該怎麼做了吧! Happy coding!