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): 先將陣列排序,然後用兩個指針 lr 遍歷一次,找出符合條件的最長區間。時間複雜度是 O(N log N),主要瓶頸在排序。
  2. 雜湊表計數 (Hash Map): 用一個雜湊表(在 Python 中是 dictCounter)來計算每個數字出現的頻率。然後遍歷雜湊表,找出 xx + 1 的頻率總和的最大值。時間複雜度是 O(N),因為只需要遍歷陣列一次。

理論上,雜湊表法 (O(N)) 應該完勝排序法 (O(N log N))。讓我們來實際測試一下。


第一戰:C++ 的意外結局

在 C++ 的世界裡,我們用 std::sortstd::unordered_map 來實現這兩種方法。

方法一:排序 + 滑動窗口 (O(N log N))

#include <vector>
#include <algorithm>
#include <ranges>

class Solution {
public:
    int findLHS(std::vector<int>& 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))

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

class Solution {
public:
    int findLHS(std::vector<int>& nums) {
        std::unordered_map<int, int> 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))

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))

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 這題時,用 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() 使用上要小心的地方 同樣的問題。 Orz

r 在 0 之後變成了 usize 的最大值,然後就爆掉了。

應該要特地將 r 弄成 i32 才對,可是不覺得上面的程式碼看起來很順暢不是嗎?盡量少用 as,讓型別自動推導出來,然後就爆掉了,果然魔鬼都是藏在細節裡面。囧rz

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] + nums[r] <= target {
                count = (count + pow2[(r-l) as usize]) % MOD;
                l += 1;
            } else {
                r -= 1;
            }
        }
        count
    }
}

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

嘿,各位開發者!如果你也嘗試在沒有圖形介面的伺服器上使用 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 伺服器設定指南 對話內容產出。

2024年12月15日 星期日

C++17 好用的 tuple 的 auto 解構

Gemini 說這功能是在 C++17 之後才有的。
#include <iostream>
#include <vector>
#include <tuple>

using namespace std;

int main()
{
	vector<tuple<int,int,double>> box;
	for (int i{1}; i < 10; ++i)
		box.push_back(make_tuple(i, i*i, (double)i/(i*i)));
	for (auto [idx, square, value]: box)
		cout << idx << " " << square << " " << value << endl;
	while (!box.empty()) {
		auto [idx, square, value] = box.back();
		box.pop_back();
		cout << idx << " " << square << " " << value << endl;
	}
	return 0;
}

2024年11月29日 星期五

C++ 的 std::string::size() 使用上要小心的地方

std::string::size() 的回傳會是 size_t 這樣的無號正整數或零的型別。

我在刷 LeetCode 的題目時寫出了這樣的程式碼。

class Solution {
public:
    int strStr(string haystack, string needle) {
        for (int i = 0; i <= haystack.size() - needle.size(); i++) {
        	// ...
        }
        return -1;
    }
};

當 needle 比 haystack 還要長時,其運算結果原以為會是負數,但是因為無號正整數或零的型別,就會被轉成正整數,導致 for 迴圈中會透過 i 去使用到 haystack 或是 needle 以外的記憶體位址,導致程式崩潰。

${shlibs:Depends} 使用上的一些陷阱

其實 0~git202411270842.3c1cdd3 這樣的 Debian Version 會等於 0~git202411270842.3c1cdd3-0

$ dpkg --compare-versions 0~git202411270842.3c1cdd3-0 eq 0~git202411270842.3c1cdd3; echo $?
0

於是 0~git202411270842.3c1cdd3-0~ 這樣的 Debian Version 就會小於 0~git202411270842.3c1cdd3

$ dpkg --compare-versions 0~git202411270842.3c1cdd3-0~ lt 0~git202411270842.3c1cdd3; echo $?
0

例如套件 A 使用了 0~git202411270842.3c1cdd3-0~ 這樣的版號。

陷阱來自於在套件 B 的 debian/control 裡面相依性使用 ${shlibs:Depends} 時,只會找到套件 A 的 upstream version 而已,然後會自動代入 (>= 0~git202411270842.3c1cdd3) 這樣的版號相依。

於是在套件 B 的 Debian packaging 打包後,就會遇到明明套件 B 的 Debian source packages 可以成功編譯成 Debian binary packages,但是卻無法安裝使用的情況。

$ sudo apt install libcamhal-ipu6ep
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Some packages could not be installed. This may mean that you have
requested an impossible situation or if you are using the unstable
distribution that some required packages have not yet been created
or been moved out of Incoming.
The following information may help to resolve the situation:

The following packages have unmet dependencies:
 libcamhal-ipu6ep : Depends: libbroxton-ia-pal-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libgcss-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-aiq-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-aiqb-parser-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-bcomp-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-cca-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-cmc-parser-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-coordinate-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-dvs-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-emd-decoder-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-exc-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-isp-bxt-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-lard-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-log-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-ltm-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-mkn-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
                    Depends: libia-nvm-ipu6ep0 (>= 0~git202411270842.3c1cdd3) but it is not going to be installed
E: Unable to correct problems, you have held broken packages.

至於解決方法就是避免使用比 0~git202411270842.3c1cdd3-0 還要小的版號,像是可以改用 0~git202411270842.3c1cdd3-1~ 這樣的版號就不會產生問題了。

$ dpkg --compare-versions 0~git202411270842.3c1cdd3-1~ gt 0~git202411270842.3c1cdd3; echo $?
0

以上是測試打包 ppa:oem-solutions-group/intel-ipu6 時的一些心得感想。

2020年4月23日 星期四

以後的筆記要搬到 https://hackmd.io/@fourdollars 上面囉

本來打算將 Autopkgtest 初體驗 這篇筆記搬回來 Blogger,突然發現這樣好像有點在浪費時間。

因為 HackMD - Markdown 協作知識庫 實在是太好用了,功能都很完整(至少我需要的都有),所以以後不打算再維護這邊了。

https://hackmd.io/@fourdollars