2025年10月11日 星期六

利用 Stack 上的陣列空間來加速執行速度

今天練習的題目是 3186. Maximum Total Damage With Spell Casting,我使用的方法是先對輸入陣列排序,然後再使用 stack 上固定大小的陣列來處理輸入陣列的資料,因為題目有對輸入陣列數量限制在 $10^5$ 以內,這樣的做法對靜態編譯的程式語言,通常都可以跑出不錯的執行時間。

C++ 就用 array< int, 100000> arr 來加速。

class Solution {
public:
    long long maximumTotalDamage(vector< int>& power) {
        int n = 0;
        array< int, 100000> arr;
        array< int, 100000> count;
        ranges::sort(power);
        for (auto p : power) {
            if (n == 0 || arr[n - 1] != p) {
                ++n;
                arr[n - 1] = p;
                count[n - 1] = 1;
            } else {
                count[n - 1]++;
            }
        }
        vector< long long> f(n, 0);
        long long mx = 0;
        for (int i = 0, j = 0; i < n; ++i) {
            while (i > j && arr[i] > arr[j] + 2) {
                mx = max(mx, f[j]);
                ++j;
            }
            f[i] = mx + 1LL * arr[i] * count[i];
        }
        return *ranges::max_element(f);
    }
};

Python3 比較特別,還是直接用 Counter 比較快,因為它有特別的最佳化處理,對於動態編譯執行的程式語言,還是多利用最佳化的內建函式,才會比較有效率。

class Solution:
    def maximumTotalDamage(self, power: List[int]) -> int:
        freq = Counter(sorted(power))
        keys = list(freq.keys())
        values = list(freq.values())
        n = len(keys)
        f = [0] * n
        j = 0
        mx = 0
        for i in range(n):
            while i > j and keys[i] > keys[j] + 2:
                mx = max(mx, f[j])
                j += 1
            f[i] = mx + keys[i] * values[i]
        return max(f)

Go 就用 arr := [100000]int{} 來加速。

func maximumTotalDamage(power []int) int64 {
	n := 0
	slices.Sort(power)
	arr := [100000]int{}
	count := [100000]int{}
	for _, p := range power {
		if n == 0 || arr[n-1] != p {
			n += 1
			arr[n-1] = p
			count[n-1] = 1
		} else {
			count[n-1]++
		}
	}
	j := 0
	var mx int64
	f := make([]int64, n)
	for i := range n {
		for i > j && arr[i] > arr[j]+2 {
			mx = max(mx, f[j])
			j++
		}
		f[i] = mx + int64(arr[i])*int64(count[i])
	}
	return slices.Max(f)
}

Rust 就用 let mut arr = [0; 100000] 來加速。

impl Solution {
    pub fn maximum_total_damage(mut power: Vec<i32>) -> i64 {
        power.sort_unstable();
        let mut arr = [0; 100000];
        let mut count = [0; 100000];
        let mut n = 0;
        for p in power {
            if n == 0 || arr[n - 1] != p as usize {
                n += 1;
                arr[n - 1] = p as usize;
                count[n - 1] = 1;
            } else {
                count[n - 1] += 1;
            }
        }
        let mut f = vec![0i64; n];
        let mut j = 0;
        let mut mx = 0;
        for i in 0..n {
            while i > j && arr[i] > arr[j] + 2 {
                mx = mx.max(f[j]);
                j += 1;
            }
            f[i] = mx + arr[i] as i64 * count[i] as i64;
        }
        f.into_iter().max().unwrap()
    }
}

在 LeetCode 上面看到 C++/Python3 排名前面都有用 display_runtime.txt 作弊,如果是用這個方法加上 display_runtime.txt 作弊可以輕鬆跑出 Runtime 0ms Beats 100% 的結果。

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月8日 星期三

找出陣列的最大值

今日練習的題目是 2300. Successful Pairs of Spells and Potions,一般來說可以利用 Binary Search 來解答,效率是 $O(m*log(m)*n)$,不過這題目因為數量不大,所以可以建立一個跟最大值一樣大的陣列,用來查表快速解答 $O(m + n)$ 相當高效,隨便寫一下都可以 Beats 99%,不過缺點是會使用多一些記憶體,剛好題目的數量不大,所以程式還不至於爆掉,而我只是要提一下幾種程式語言可以怎麼取陣列最大值而已。

C++ 借助 C++20 的 ranges 的 max_element 或是 C++17 的 max_element

int max_potion = *ranges::max_element(potions);

Python3 內建用法簡單直白

maxPotion = max(potions)

Go 借助 slices 套件

maxPotion := slices.Max(potions)

Rust 搭配 match arm 使用 Vec< i32 > 的 Trait Iterator 中的 max() 函式,這樣寫最對味。

let max_potion = match potions.iter().max() {
    Some(&val) => val as usize,
    None => return vec![0; spells.len()],
};

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年10月6日 星期一

Heapify push

今日練習的題目是 778. Swim in Rising Water,解法似乎有許多種,我自己用的是 Priority Queue,雖然不是相對有效的方法,不過在 C++/Go/Rust 都還可以跑到 Runtime 0ms Beats 100%,這篇算是補充之前的 Heapify 沒有提到的 push 的使用方式。

如果是使用 Python3 的話,因為預設是 Min Heap,剛好是解題需要的,所以不用花點小心思來轉換。

class Solution:
    def swimInWater(self, grid: List[List[int]]) -> int:
        m = len(grid)
        n = len(grid[0])
        visited = [[False for _ in range(n)] for _ in range(m)]
        heap = []
        heapq.heappush(heap, (grid[0][0], 0, 0))
        visited[0][0] = True
        d = (0, 1, 0, -1, 0)
        while heap:
            h, x, y = heapq.heappop(heap)
            if x == m - 1 and y == n - 1:
                return h
            for i in range(4):
                nx = x + d[i]
                ny = y + d[i + 1]
                if nx < 0 or nx >= m or ny < 0 or ny >= n or visited[nx][ny]:
                    continue
                heapq.heappush(heap, (max(h, grid[nx][ny]), nx, ny))
                visited[nx][ny] = True
        return 0

如果是使用 C++ 的話,因為預設是 Max Heap,所以需要花點小心思來轉換。

class Solution {
public:
    int swimInWater(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();
        priority_queue<tuple<int, int, int>> pq;
        pq.push({-grid[0][0], 0, 0});
        array<int, 5> d{0, 1, 0, -1, 0};
        vector< vector < bool > > visited(m, vector<bool>(n));
        visited[0][0] = true;
        while (!pq.empty()) {
            auto [cur, x, y] = pq.top();
            if (x == m - 1 && y == n - 1)
                return -cur;
            pq.pop();
            for (int i = 0; i < 4; ++i) {
                int nx = x + d[i];
                int ny = y + d[i + 1];
                if (nx < 0 || nx >= m || ny < 0 || ny >= n || visited[nx][ny])
                    continue;
                visited[nx][ny] = true;
                pq.push({min(cur, -grid[nx][ny]), nx, ny});
            }
        }
        return 0;
    }
};

如果是使用 Go 的話,又要很麻煩地自己多刻一些程式碼來搭配 container/heap 使用。

import "container/heap"

type PriorityQueue [][]int

func (pq PriorityQueue) Len() int           { return len(pq) }
func (pq PriorityQueue) Less(i, j int) bool { return pq[i][0] < pq[j][0] }
func (pq PriorityQueue) Swap(i, j int)      { pq[i], pq[j] = pq[j], pq[i] }
func (pq *PriorityQueue) Push(x any) {
	*pq = append(*pq, x.([]int))
}
func (pq *PriorityQueue) Pop() any {
	old := *pq
	n := len(old)
	x := old[n-1]
	*pq = old[0 : n-1]
	return x
}

func swimInWater(grid [][]int) int {
	d := []int{0, 1, 0, -1, 0}
	m, n := len(grid), len(grid[0])
	visited := make([][]bool, m)
	for i := range m {
		visited[i] = make([]bool, n)
	}
	pq := PriorityQueue([][]int{})
	heap.Push(&pq, []int{grid[0][0], 0, 0})
	visited[0][0] = true
	for pq.Len() != 0 {
		arr := heap.Pop(&pq).([]int)
		h, x, y := arr[0], arr[1], arr[2]
		if x == m-1 && y == n-1 {
			return h
		}
		for i := range 4 {
			nx := x + d[i]
			ny := y + d[i+1]
			if nx < 0 || nx >= m || ny < 0 || ny >= n || visited[nx][ny] {
				continue
			}
			heap.Push(&pq, []int{max(h, grid[nx][ny]), nx, ny})
			visited[nx][ny] = true
		}
	}
	return 0
}

如果是使用 Rust 的話,就需要使用到 std::collections::BinaryHeap,預設是 Max Heap 所以要花點小心思來轉換,不過搭配 `while let Some(...) = pq.pop()` 整個行雲流水般好用。

use std::collections::BinaryHeap;

impl Solution {
    pub fn swim_in_water(grid: Vec < Vec < i32 > >) -> i32 {
        let d = [0, 1, 0, -1, 0];
        let m = grid.len();
        let n = grid[0].len();
        let mut pq = BinaryHeap::new();
        let mut visited = vec![vec![false; n]; m];
        pq.push((-grid[0][0], 0, 0));
        visited[0][0] = true;
        while let Some((h, x, y)) = pq.pop() {
            if x == m - 1 && y == n - 1 {
                return -h;
            }
            for i in 0..4 {
                let nx = x as i32 + d[i];
                let ny = y as i32 + d[i+1];
                if nx < 0 || ny < 0 || nx >= m as i32 || ny >= n as i32 {
                    continue;
                }
                let nx = nx as usize;
                let ny = ny as usize;
                if visited[nx][ny] {
                    continue;
                }
                pq.push((-grid[nx][ny].max(-h), nx, ny));
                visited[nx][ny] = true;
            }
        }
        0
    }
}

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; } }; ``` ### 總結 從一個簡單的模擬迴圈,到一個帶有瑕疵的數學公式,再到一個能處理所有邊界情況的完美表達式,這個解題過程本身就是一趟精彩的旅程。它告訴我們,一個「能動」的程式和一個「完美」的程式之間,往往是對細節和邊界情況的極致追求。 希望這次的分享能對你有所啟發!祝大家寫程式愉快!

GitHub 上面清不掉的未讀訊息

看到 GitHub notification 消不掉的問題 才知道發生什麼事情。

本來是 GitHub 官方應該要處理的問題,我又不想安裝 GitHub CLI 來用,不如來順便練習一下 Vibe coding 好了,於是弄了一個 https://ghno.sylee.org/,程式碼放在 https://github.com/fourdollars/go-playground/tree/main/github-notifications-oauth 上面。

伺服器只是處理 OAuth 重導,跟透過使用者傳來的 Token 呼叫 GitHub API 而已,利用 https://github.com/settings/applications/new 註冊一個 OAuth app 來用,清空的感覺真好。

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月29日 星期一

凸多邊形的三角形分割以及二維陣列與整數最大值的使用

今天練習的題目是 1039. Minimum Score Triangulation of Polygon,第一次遇到這種 DP 類型,後來想通後,發現解題辦法不外乎兩種。

一種是由上而下,透過記憶跟遞迴來處理,效率較差,寫成 C++ 會像是這樣:

class Solution {
public:
    int minScoreTriangulation(vector&lt;int&gt;&amp; values) {
        int n = values.size();
        array&lt;array&lt;int, 50&gt;, 50&gt; memo{};
        function&lt;int(int, int)&gt; dp = [&amp;](int i, int j) -> int {
        	if (i + 2 &gt; j)
                return 0;
            if (!memo[i][j]) {
                if (i + 2 == j)
                    return memo[i][j] =
                               values[i] * values[i + 1] * values[i + 2];
                int score = numeric_limits&lt;int&gt;::max();
                int product = values[i] * values[j];
                for (int k = i + 1; k &lt; j; ++k)
                    score =
                        min(score, product * values[k] + dp(i, k) + dp(k, j));
                memo[i][j] = score;
            }
            return memo[i][j];
        };
        return dp(0, n - 1);
    }
};

另一種是由下而上,透過疊代來處理,效率較好,寫成 C++ 會像是這樣:

class Solution {
public:
    int minScoreTriangulation(vector&lt;int>&amp; values) {
    	int n = values.size();
        array&lt;array&lt;int, 50&gt;, 50&gt; dp{};
        for (int d = 2; d &lt; n; ++d) {
            for (int i = 0; i + d &lt; n; ++i) {
                int j = i + d;
                int score = numeric_limits&lt;int&gt;::max();
                int product = values[i] * values[j];
                for (int k = i + 1; k &lt; j; ++k)
                    score =
                        min(score, product * values[k] + dp[i][k] + dp[k][j]);
                dp[i][j] = score;
            }
        }
        return dp[0][n - 1];
    }
};

用疊代的方法寫成 Python3 會像這樣:

class Solution:
    def minScoreTriangulation(self, values: List[int]) -> int:
        n = len(values)
        dp = [[0 for _ in range(n)] for _ in range(n)]
        for d in range(2, n):
            for i in range(n - d):
                j = i + d
                score = float("inf")
                product = values[i] * values[j]
                for k in range(i + 1, j):
                    score = min(score, product * values[k] + dp[i][k] + dp[k][j])
                dp[i][j] = score
        return dp[0][n - 1]

用疊代的方法寫成 Go 會像這樣:

func minScoreTriangulation(values []int) int {
	n := len(values)
	dp := make([][]int, n)
	for i := range n {
		dp[i] = make([]int, n)
	}
	for d := 2; d &lt; n; d++ {
		for i := range n - d {
			j := i + d
			score := math.MaxInt
			product := values[i] * values[j]
			for k := i + 1; k &lt; j; k++ {
				score = min(score, product*values[k]+dp[i][k]+dp[k][j])
			}
			dp[i][j] = score
		}
	}
	return dp[0][n-1]
}

用疊代的方法寫成 Rust 會像這樣:

impl Solution {
    pub fn min_score_triangulation(values: Vec&lt;i32&gt;) -> i32 {
    	let n = values.len();
        let mut dp = vec![vec![0; n]; n];
        for d in 2..n {
            for i in 0..(n - d) {
                let j = i + d;
                let mut score = i32::MAX;
                let mut product = values[i] * values[j];
                for k in (i + 1)..j {
                    score = score.min(product*values[k]+dp[i][k]+dp[k][j]);
                }
                dp[i][j] = score;
            }
        }
        dp[0][n-1]
    }
}

另外值得一提的是 Rust 目前 1.90.0 還沒有支援 nested recursive function call,雖然上面沒有全部寫出來,但是 C++, Python3, Go 都有支援,如果這題要用 Rust 寫記憶跟遞迴會蠻麻煩的,反正效率也比較差,就寫個 C++ 意思意思一下就好了。

2025年9月28日 星期日

Heapify

今日練習的題目是 976. Largest Perimeter Triangle,官方的解答是直接排序後,從後面開始找出第一個合法的三角形周長就可以了,不過還有另一種解法就是利用 Heapify,雖然最糟的情況下演算法效能會跟直接排序法差不多,但是最佳狀況會比排序法好,但是現實上也可能會因為排序法使用連續的記憶體也可能會效率更好一點,所以還是要看資料本身適合哪一種。

如果是使用 Python3 的話,因為預設是 Min Heap 所以會需要使用點小技巧來使用成 Max Heap 的樣子。

class Solution:
    def largestPerimeter(self, nums: List[int]) -> int:
        nums = [-num for num in nums]
        heapq.heapify(nums)
        a = -heapq.heappop(nums)
        b = -heapq.heappop(nums)
        while len(nums) &gt; 0:
            c = -heapq.heappop(nums)
            if b + c &gt; a:
            	return a + b + c
            a, b = b, c
        return 0

如果是使用 C++ 的話,由於預設是 Max Heap,所以直接倒進去 priority_queue 使用就可以了。

class Solution {
public:
    int largestPerimeter(vector&lt;int&gt;&amp; nums) {
    	priority_queue<int> pq(nums.begin(), nums.end());
        int a = pq.top();
        pq.pop();
        int b = pq.top();
        pq.pop();
        while (!pq.empty()) {
            int c = pq.top();
            if (b + c &gt; a)
            	return a + b + c;
            pq.pop();
            swap(a, b);
            swap(b, c);
        }
        return 0;
    }
};

如果是使用 Go 的話,要自己刻一些東西搭配 container/heap 使用,使用起來相當麻煩,不然就是要使用第三方套件才會比較輕鬆些。

import "container/heap"

type IntHeap []int

func (h IntHeap) Len() int { return len(h) }

func (h IntHeap) Less(i, j int) bool { return h[i] &gt; h[j] }
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }

func (h *IntHeap) Push(x any) {
	*h = append(*h, x.(int))
}

func (h *IntHeap) Pop() any {
	old := *h
	n := len(old)
	x := old[n-1]
	*h = old[0 : n-1]
	return x
}

func largestPerimeter(nums []int) int {
	if len(nums) &lt; 3 {
    	return 0
	}

	h := IntHeap(nums)
	heap.Init(&amp;h)
	for h.Len() &gt;= 3 {
    	a := heap.Pop(&amp;h).(int)
		b := h[0] 
		c := h[1]

        if h.Len() &gt; 2 &amp;&amp; h[2] &gt; c {
        	c = h[2]
        }

		if b+c &gt; a {
        	return a + b + c
		}
	}

	return 0
}

如果是使用 Rust 的話,就需要使用到 std::collections::BinaryHeap,預設就是 Max Heap,所以使用上也算是簡單。


use std::collections::BinaryHeap;

impl Solution {
    pub fn largest_perimeter(nums: Vec&lt;i32&gt;) -&gt; i32 {
        let mut heap = BinaryHeap::from(nums);

        while heap.len() &gt;= 3 {
            let a = heap.pop().unwrap();
            let b = heap.pop().unwrap();
            let c = heap.pop().unwrap();

            if b + c &gt; a {
                return a + b + c;
            } else {
                heap.push(b);
                heap.push(c);
            }
        }

        0
    }
}

不過 Rust 寫成排序的方法,寫起來會比較漂亮跟簡潔,充滿著滿滿的 Rust 程式碼正統風格。

impl Solution {
    pub fn largest_perimeter(mut nums: Vec&lt;i32&gt;) -&gt; i32 {
        nums.sort_unstable();
        nums.windows(3)
            .rev()
            .find_map(|window| {
                if window[0] + window[1] &gt; window[2] {
                    Some(window[0] + window[1] + window[2])
                } else {
                    None
                }
            })
            .unwrap_or(0)
    }
}

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

***此篇文章由 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月25日 星期四

字串串接

今天練習的 LeetCode 題目是 166. Fraction to Recurring Decimal,解題的思路是使用 Hash Map 去記錄小數點後面每一位遇到的餘數的位置,如果遇到記錄過的餘數就能使用記錄的位置將括號插入,但是這個題目也是在考驗如何用程式語言來串接字串。

如果是使用 Python3 來寫的話最簡單,不用考慮整數溢位的問題, str 的串接也相當直覺容易。

class Solution:
    def fractionToDecimal(self, numerator: int, denominator: int) -&gt; str:
        if numerator == 0:
            return "0"

        num = ""

        if (numerator &lt; 0) != (denominator &lt; 0):
        	num += "-"

        n = abs(numerator)
        d = abs(denominator)

        num += str(n // d)
        r = n % d

        if r == 0:
            return num

        num += "."

        pos = {}
        while r != 0:
            if r in pos:
                num = num[0 : pos[r]] + "(" + num[pos[r] :]
                num += ")"
                return num
            pos[r] = len(num)
            r *= 10
            num += str(r // d)
            r %= d

        return num

如果是用 C++ 的話,就要小心整數溢位的問題,不過 string 的串接也還算簡單容易上手。

class Solution {
public:
    string fractionToDecimal(int numerator, int denominator) {
        if (numerator == 0)
            return "0";

        string num;

        if ((numerator &lt; 0) != (denominator &lt; 0))
            num += "-";

        long long n = abs((long long)numerator);
        long long d = abs((long long)denominator);

        num += to_string(n / d);
        long long r = n % d;

        if (r == 0)
            return num;

        num += ".";

        unordered_map&lt;long long, int&gt; pos;
        while (r != 0) {
            if (pos.contains(r)) {
                num.insert(pos[r], "(");
                num += ")";
                break;
            }
            pos[r] = num.length();
            r *= 10;
            num += to_string(r / d);
            r %= d;
        }

        return num;
    }
};

如果是用 Go 的話,也要小心整數溢位的問題,不過 string 的串接最好使用 strings.Builder 比較有效率,要用 strconv.FormatInt 來轉換小數點後面的數字,比較有點小麻煩的是要準備 abs 函式,因為 Go 沒有提供!什麼這麼基本的東西居然沒提供!

func abs(x int64) int64 {
	if x &lt; 0 {
    	return -x
	}
	return x
}

func fractionToDecimal(numerator int, denominator int) string {
	if numerator == 0 {
		return "0"
	}

	var builder strings.Builder

	if (numerator &lt; 0) != (denominator &lt; 0) {
    	builder.WriteString("-")
	}

	n := abs(int64(numerator))
	d := abs(int64(denominator))

	builder.WriteString(strconv.FormatInt(n/d, 10))
	r := n % d

	if r == 0 {
		return builder.String()
	}

	builder.WriteString(".")

	rMap := make(map[int64]int)
	for r != 0 {
		if pos, ok := rMap[r]; ok {
			result := builder.String()
			return result[:pos] + "(" + result[pos:] + ")"
		}

		rMap[r] = builder.Len()

		r *= 10

		builder.WriteString(strconv.FormatInt(r/d, 10))

		r %= d
	}

	return builder.String()
}

如果是用 Rust 的話,就要懂得使用 String::new() 跟 push_str() 還有 push() 的方法,還有如何使用 char::from_digit() 來轉換字元,當然也需要注意整數溢位的問題。

use std::collections::HashMap;

impl Solution {
    pub fn fraction_to_decimal(numerator: i32, denominator: i32) -> String {
        if numerator == 0 {
            return "0".to_string();
        }

        let mut result = String::new();

        if (numerator &lt; 0) != (denominator &lt; 0) {
        	result.push('-');
        }

        let n = (numerator as i64).abs();
        let d = (denominator as i64).abs();

        result.push_str(&amp;(n / d).to_string());
        let mut remainder = n % d;

        if remainder == 0 {
            return result;
        }

        result.push('.');

        let mut remainder_map = HashMap::&lt;i64, usize&gt;::new();
        while remainder != 0 {
            if let Some(&amp;pos) = remainder_map.get(&amp;remainder) {
            	result.insert(pos, '(');
                result.push(')');
                break;
            }

            remainder_map.insert(remainder, result.len());

            remainder *= 10;

            if let Some(digit_char) = char::from_digit((remainder / d) as u32, 10) {
                result.push(digit_char);
            }
            
            remainder %= d;
        }

        result
    }
}

最後是 Go 跟 Rust 檢查餘數是否存在於 Hash Map 中的方式,比起 C++ 跟 Python3 來說比較特別,展現了各自程式語言本身的設計哲學。

2025年9月23日 星期二

字串分割

今日的 165. Compare Version Numbers Solved 使用 Python3 五分鐘就可以解掉了,主要的思考套路是設法弄出兩個等長的整數陣列來比較就可以了。

class Solution:
    def compareVersion(self, version1: str, version2: str) -> int:
        ver1 = [int(x) for x in version1.split(".")]
        ver2 = [int(x) for x in version2.split(".")]
        for x, y in zip_longest(ver1, ver2, fillvalue=0):
            if x < y:
                return -1
            elif x > y:
                return 1
        return 0

如果要用 C++ 就會有點囉唆,要使用 stringstream 搭配 getline 才行,不是那麼直覺,之後再用 resize 調整成一樣的長度來比較。

class Solution {
    vector&lt;int&gt; parse(string version) {
    	string num;
        stringstream ss(version);
        vector&lt;int&gt; ver;
        while (getline(ss, num, '.'))
            ver.emplace_back(stoi(num));
        return ver;
    }

public:
    int compareVersion(string version1, string version2) {
        auto ver1 = parse(version1);
        auto ver2 = parse(version2);
        int len = max(ver1.size(), ver2.size());
        ver1.resize(len, 0);
        ver2.resize(len, 0);

        for (int i = 0; i &lt; len; ++i) {
        	if (ver1[i] &lt; ver2[i])
            	return -1;
            else if (ver1[i] &lt; ver2[i])
            	return 1;
        }

        return 0;
    }
};

如果是 Go 要熟悉一下內建的 strings.Split 以及 strconv.Atoi

func compareVersion(version1 string, version2 string) int {
	str1 := strings.Split(version1, ".")
	str2 := strings.Split(version2, ".")
	length := max(len(str1), len(str2))
	ver1 := make([]int, length)
	ver2 := make([]int, length)
	for i, s := range str1 {
		num, _ := strconv.Atoi(s)
		ver1[i] = num
	}
	for i, s := range str2 {
		num, _ := strconv.Atoi(s)
		ver2[i] = num
	}
	for i := range length {
		if ver1[i] &lt; ver2[i] {
        	return -1
		} else if ver1[i] &gt; ver2[i] {
        	return 1
		}
	}
	return 0
}

如果是 Rust 要懂得使用 split, filter_map, collect 的串接組合技,跟 trim, parse, ok 來將字串轉換成整數,以及用 resize 將陣列調整成一樣長度,再用跟 Python 一樣的 zip 結合起來一個一個比較。

impl Solution {
    pub fn compare_version(version1: String, version2: String) -> i32 {
        let mut ver1: Vec&lt;i32&gt; = version1.split('.').filter_map(|s| s.trim().parse::&lt;i32&gt;().ok()).collect();
        let mut ver2: Vec&lt;i32&gt; = version2.split('.').filter_map(|s| s.trim().parse::&lt;i32&gt;().ok()).collect();
        let len = ver1.len().max(ver2.len());
        ver1.resize(len, 0);
        ver2.resize(len, 0);
        for (x, y) in ver1.into_iter().zip(ver2) {
            if x &lt; y {
            	return -1;
            } else if x &gt; y {
            	return 1;
            }
        }
        0
    }
}

2025年9月17日 星期三

C++ 自製的 gcd 比標準函式庫提供的跑得還要快?

今天在用 C++ 重複練習 2197. Replace Non-Coprime Numbers in Array 一遍,試著寫出跑得更快的版本,發現自己寫出來的 gcd 在 LeetCode 平台上反而跑得比較快,目前還不曉得背後到底是怎麼一回事,先記錄一下,目前想到的做法是盡量避免呼叫 gcd 以及盡量使用已使用的記憶體空間來加速,最好的時候可以跑到 Runtime 2ms Beats 98.62% 的成績。

class Solution {
    int gcd(int a, int b) {
        if (b > a)
            swap(a, b);
        while (b != 0) {
            a %= b;
            swap(a, b);
        }
        return a;
    }

public:
    vector&lt;int&gt; replaceNonCoprimes(vector&lt;int&gt;& nums) {
        int idx = 0;
        for (int i = 1; i < nums.size(); ++i) {
            int num = gcd(nums[idx], nums[i]);
            if (num == 1)
                nums[++idx] = nums[i];
            else
                nums[idx] = nums[idx] / num * nums[i];
            for (int j = idx; j > 0; --j) {
                int num = gcd(nums[j], nums[j - 1]);
                if (num == 1)
                    break;
                nums[j - 1] *= nums[j] / num;
                --idx;
            }
        }
        nums.resize(idx + 1);
        return nums;
    }
};

2025年9月16日 星期二

Rust 沒官方標準支援 gcd 跟 lcm 的 traits 蠻令人意外的

今天在練習 2197. Replace Non-Coprime Numbers in Array 時,需要使用到 GCD 跟 LCM,在 C++/Python 都有官方支援,Rust 跟 Go 都沒有支援,有點意外。

impl Solution {
    pub fn replace_non_coprimes(nums: Vec&lt;i32>) -> Vec&lt;i32> {
        let mut arr = Vec::with_capacity(nums.len());
        let mut curr = nums[0];
        let gcd = |mut a: i32, mut b: i32| -> i32 {
            if a < b {
                (a, b) = (b, a);
            }
            while b != 0 {
                (a, b) = (b, a % b);
            }
            a
        };
        let lcm = |a: i32, b: i32| -> i32 {
            a / gcd(a, b) * b
        };
        for &num in &nums[1..] {
            if gcd(num, curr) > 1 {
                curr = lcm(num, curr);
                while let Some(&last) = arr.last() && gcd(curr, last) > 1 {
                    curr = lcm(last, curr);
                    arr.pop();
                }
            } else {
                arr.push(curr);
                curr = num;
            }
        }
        arr.push(curr);
        arr
    }
}

這裡還剛好可以使用到 Rust 1.88 版本才開始有支援的 Let chainsLet chains 讓程式碼的表示更直白,真是太棒了。

if let Channel::Stable(v) = release_info()
    && let Semver { major, minor, .. } = v
    && major == 1
    && minor == 88
{
    println!("`let_chains` was stabilized in this version");
}

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!

2025年9月2日 星期二

C++/Python3/Go/Rust 關於 list/array/vector 的 sort 在設計上的不同差異

***此篇文章由 Gemini AI 產生*** 在軟體開發中,排序幾乎可以說是最常見的需求之一。然而,不同的程式語言在排序功能的設計上卻有著一些有趣的差異。這篇文章將帶您一探究竟,比較 C++、Python、Go 和 Rust 中 `list`、`array` 或 `vector` 等資料結構的排序(sort)功能,並透過程式碼範例,讓您更深入了解它們在設計上的不同之處。 ### C++:回傳布林值的比較函式 C++ 的 `std::ranges::sort`(以及舊版的 `std::sort`)在自訂排序行為時,需要傳入一個比較函式(Comparison Function),而這個函式必須回傳布林值(`bool`)。 這個比較函式通常接收兩個參數,並在這兩個參數之間進行比較。如果第一個參數應該排在第二個參數之前,則回傳 `true`,否則回傳 `false`。 ```cpp #include #include #include #include struct Person { std::string name; int age; }; int main() { std::vector<Person> people = { {"Alice", 30}, {"Bob", 25}, {"Charlie", 35} }; // 使用 lambda 運算式定義比較函式,按年齡升序排序 std::ranges::sort(people, [](const Person& a, const Person& b) { return a.age < b.age; }); for (const auto& person : people) { std::cout << person.name << " (" << person.age << ")" << std::endl; } return 0; } ``` **輸出結果:** ``` Bob (25) Alice (30) Charlie (35) ``` ### Python:提供 Key Function 提取比較值 Python 的 `list.sort()` 方法則提供了另一種更為簡潔的自訂排序方式。您可以傳入一個名為 `key` 的參數,這個 `key` 參數會接收一個函式,用來從每個元素中提取一個用於比較的「鍵」(key)。 這種設計的好處是,您不需要撰寫一個完整的比較函式,只需要提供一個簡單的函式來告訴 `sort()` 方法要用哪個值來進行排序即可。 ```python class Person: def __init__(self, name, age): self.name = name self.age = age people = [ Person("Alice", 30), Person("Bob", 25), Person("Charlie", 35) ] # 使用 lambda 函式作為 key,按年齡升序排序 people.sort(key=lambda person: person.age) for person in people: print(f"{person.name} ({person.age})") ``` **輸出結果:** ``` Bob (25) Alice (30) Charlie (35) ``` ### Go:回傳 -1, 0, 1 的比較函式 Go 語言的 `slices.SortFunc` 則採用了另一種常見的比較函式設計。您需要傳入一個比較函式,這個函式會回傳一個整數,用來表示兩個元素的相對順序: * **-1**:如果第一個元素應該排在第二個元素之前。 * **0**:如果兩個元素相等。 * **1**:如果第一個元素應該排在第二個元素之後。 ```go package main import ( "fmt" "slices" ) type Person struct { Name string Age int } func main() { people := []Person{ {"Alice", 30}, {"Bob", 25}, {"Charlie", 35}, } // 按年齡升序排序 slices.SortFunc(people, func(a, b Person) int { if a.Age < b.Age { return -1 } if a.Age > b.Age { return 1 } return 0 }) fmt.Println(people) } ``` **輸出結果:** ``` [{Bob 25} {Alice 30} {Charlie 35}] ``` ### Rust:兩種方式,任君挑選 Rust 在排序功能的設計上,可以說是集各家之大成,提供了兩種不同的自訂排序方式: 1. **`sort_unstable_by_key`**:類似於 Python 的 `key` 參數,您可以傳入一個函式來提取用於比較的鍵。 2. **`sort_unstable_by`**:類似於 Go 的比較函式,您可以傳入一個回傳 `Ordering`(一個包含 `Less`、`Equal` 和 `Greater` 三個值的枚舉)的函式。 ```rust #[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] struct Person { name: String, age: u32, } fn main() { let mut people = vec![ Person { name: "Alice".to_string(), age: 30 }, Person { name: "Bob".to_string(), age: 25 }, Person { name: "Charlie".to_string(), age: 35 }, ]; // 1. 使用 sort_unstable_by_key 按年齡升序排序 people.sort_unstable_by_key(|p| p.age); println!("{:?}", people); // 2. 使用 sort_unstable_by 按年齡降序排序 people.sort_unstable_by(|a, b| b.age.cmp(&a.age)); println!("{:?}", people); } ``` **輸出結果:** ``` [Person { name: "Bob", age: 25 }, Person { name: "Alice", age: 30 }, Person { name: "Charlie", age: 35 }] [Person { name: "Charlie", age: 35 }, Person { name: "Alice", age: 30 }, Person { name: "Bob", age: 25 }] ``` ### 總結 | 語言 | 方法 | 回傳值 | | --- | --- | --- | | C++ | 比較函式 | `bool` | | Python | Key Function | 任何可比較的值 | | Go | 比較函式 | `-1`, `0`, `1` | | Rust | Key Function 或比較函式 | 任何可比較的值或 `Ordering` | 從以上比較可以看出,雖然排序是一個基本的功能,但不同的程式語言在 API 設計上卻有著不同的哲學和取捨。C++ 提供了最底層的控制,但也相對繁瑣;Python 則追求簡潔與易用性;Go 則採用了傳統的「三向比較」;而 Rust 則提供了最靈活的選擇,讓開發者可以根據自己的需求選擇最適合的方式。 希望這篇文章能幫助您更深入地了解不同程式語言在排序功能設計上的差異,並在未來的開發工作中,選擇最適合您需求的排序方式。

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,不妨也思考一下:**我的程式碼,將在怎樣的硬體和語言環境下運行?** 這個問題,或許能引導你找到通往極致效能的真正路徑。

2025年6月29日 星期日

在 Rust 上又遇到了一次跟 C++ 一樣的問題

今天在練習 LeetCode [1498. Number of Subsequences That Satisfy the Given Sum Condition](https://leetcode.com/problems/number-of-subsequences-that-satisfy-the-given-sum-condition/) 這題時,用 Rust 寫出了以下的程式碼。 ```rust impl Solution { pub fn num_subseq(mut nums: Vec<i32>, target: i32) -> i32 { let MOD = 1e9 as i32 + 7; let n = nums.len(); let mut pow2 = vec![1; n]; for i in 1..n { pow2[i] = (pow2[i-1] << 1) % MOD; } nums.sort(); let (mut l, mut r, mut count) = (0, n - 1, 0); while l <= r { if nums[l] + nums[r] <= target { count = (count + pow2[r-l]) % MOD; l += 1; } else { r -= 1; } } count } } ``` 心想說應該沒什麼問題吧,就用跟其他程式語言同樣的邏輯,再寫一遍就好了。 結果就撞到了跟上次 [C++ 的 std::string::size() 使用上要小心的地方](https://fourdollars.blogspot.com/2024/11/c-stdstringsize.html) 同樣的問題。 Orz r 在 0 之後變成了 usize 的最大值,然後就爆掉了。 應該要特地將 r 弄成 i32 才對,可是不覺得上面的程式碼看起來很順暢不是嗎?盡量少用 as,讓型別自動推導出來,然後就爆掉了,果然魔鬼都是藏在細節裡面。囧rz ```rust impl Solution { pub fn num_subseq(mut nums: Vec<i32>, target: i32) -> i32 { let MOD = 1e9 as i32 + 7; let n = nums.len(); let mut pow2 = vec![1; n]; for i in 1..n { pow2[i] = (pow2[i-1] << 1) % MOD; } nums.sort(); let (mut l, mut r, mut count) = (0, n as i32 - 1, 0); while l <= r { if nums[l as usize] + nums[r as usize] <= target { count = (count + pow2[(r-l) as usize]) % MOD; l += 1; } else { r -= 1; } } count } } ```

透過 BROWSER 環境變數在 headless server 上搞定 Gemini CLI 的 Google 登入

Updated on 2025/09/27: 新版本的 @google/gemini-cli 已經能夠簡單處理這個問題了,它會產生一段 URL 讓你連過去,然後再將產生的 string 複製貼回來就能夠使用了。

此篇文章由 Gemini AI 產生

嘿,各位開發者!如果你也嘗試在沒有圖形介面的伺服器上使用 npm install -g @google/gemini-cli,並希望透過 Google 帳號登入,你可能遇到了一個小麻煩:「沒有網址讓我連過去認證!」

別擔心,這篇文章將帶你一步步解決這個問題,讓你能在伺服器上順利使用 Gemini CLI。

問題根源:無頭環境的挑戰

當你在本地電腦使用 Google 登入時,CLI 會自動開啟瀏覽器讓你完成認證。但在伺服器這種「無頭 (headless)」環境,它根本沒辦法開啟瀏覽器。這導致了認證流程的中斷。

我們要做的,就是**「欺騙」CLI**,讓它以為有個瀏覽器會幫它處理網址,但實際上我們只是把認證網址攔截下來,然後手動到自己的電腦上完成登入。

步驟一:讓 CLI「吐出」認證網址

首先,我們需要一個簡單的 Shell 指令碼,來捕捉 Gemini CLI 嘗試開啟的認證網址。

  1. 建立 url-logger.sh 指令碼:


    在你的伺服器上,建立一個新檔案,例如放在 /usr/local/bin/url-logger.sh。

    nano /usr/local/bin/url-logger.sh
    

    然後貼上以下內容:

    #!/bin/bash
    # 這個指令碼會將 Gemini CLI 嘗試開啟的網址記錄下來
    
    # 認證網址將被寫入這個檔案
    URL_FILE="$HOME/gemini_auth_url.txt"
    
    # 確保檔案存在
    touch "$URL_FILE"
    
    # 將網址和時間戳記附加到檔案中
    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$URL_FILE"
    
    # 同時印出到終端機,方便查看
    echo "URL would be opened: $1"
    
  2. 賦予執行權限:

    讓這個指令碼可執行。

    chmod +x /usr/local/bin/url-logger.sh
    

步驟二:設定 BROWSER 環境變數

接下來,我們要告訴系統,當任何程式(包括 Gemini CLI)嘗試開啟瀏覽器時,都執行我們剛才建立的 url-logger.sh 指令碼。

  1. 編輯你的 Shell 設定檔:

    這通常是 ~/.bashrc (如果你用 Bash) 或 ~/.zshrc (如果你用 Zsh)。

    # 例如:
    nano ~/.bashrc
    
  2. 添加 BROWSER 環境變數:

    在檔案的任何位置,加入這行。請確保路徑是 url-logger.sh 的實際存放位置。

    export BROWSER=/usr/local/bin/url-logger.sh
    
  3. 儲存並重新載入設定:

    儲存檔案後,執行 source ~/.bashrc (或 source ~/.zshrc),或者直接關閉並重新開啟你的 SSH 連線,讓設定生效。

步驟三:啟動 Gemini CLI 並獲取認證網址

現在,萬事俱備!我們可以啟動 Gemini CLI 並開始認證流程了。

  1. 啟動 Gemini CLI:

    gemini
    

    進入互動式介面後,輸入 /auth,然後選擇 "Login with Google"。

  2. 檢查認證網址檔案:

    此時,gemini 不會開啟任何視窗,而是把認證網址寫入到我們指定的檔案中。你可以用 cat 命令查看:

    cat "$HOME/gemini_auth_url.txt"
    

    你會看到一串類似 https://accounts.google.com/o/oauth2/v2/auth?client_id=... 的超長網址。請完整複製這串網址!

步驟四:在本地電腦完成 Google 認證

現在,輪到你的本地電腦出場了!

  1. 在本地瀏覽器開啟網址:

    把你剛才從伺服器上複製的完整認證網址,貼到你本地電腦的網頁瀏覽器中,然後打開它。

  2. 完成 Google 登入和授權:

    按照 Google 頁面的指示,用你的 Google 帳號登入並授權。完成後,瀏覽器會嘗試跳轉到一個類似 http://localhost:PORT/oauth2callback?code=YOUR_CODE... 的網址。這個頁面會顯示「此網站無法連線」之類的錯誤,這是完全正常的!

步驟五:將 localhost 回調傳回伺服器

這是最關鍵的一步!我們要將本地瀏覽器嘗試跳轉的 localhost 網址(即使它報錯了),再傳回給伺服器上的 gemini CLI。

  1. 複製 localhost 回調網址:

    從你本地瀏覽器的網址列上,完整複製那個 http://localhost:PORT/oauth2callback?... 的網址。務必包含所有的參數!

  2. 在伺服器上執行 curl 命令:

    回到你的伺服器終端機,執行以下 curl 命令,將雙引號內的內容替換成你剛才複製的完整 localhost 網址:

        curl "http://localhost:YOUR_PORT/oauth2callback?code=YOUR_CODE&scope=..."
    

    例如:

    curl "http://localhost:21165/oauth2callback?code=4/0xxxxxxxxxxxxxxxxx&scope=https://www.googleapis.com/auth/userinfo.email..."
    

    執行完 curl 後,你會看到伺服器上的 gemini CLI 互動介面會顯示認證成功的訊息。恭喜你!

搞定!還有更好的選擇嗎?

你已經成功在無頭伺服器上搞定了 Gemini CLI 的 Google 帳號登入!這證明了只要掌握方法,多麼棘手的環境問題都能克服。

不過,對於長期或自動化用途,我仍然會強烈推薦使用服務帳戶 (Service Account) 進行認證。服務帳戶提供更高的安全性,而且完全不需要人工介入,對於伺服器應用來說是更優雅的解決方案。你可以參考官方文件或相關教學,了解如何建立和使用 Google Cloud 服務帳戶金鑰。

希望這篇文章對你有幫助!Happy coding!

以上內容由 Gemini AI 整理 Gemini CLI 伺服器設定指南 對話內容產出。