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 伺服器設定指南 對話內容產出。