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,我們經歷了從一個能工作的解法,到一個更清晰的重構,再到一個基於深刻數學洞察的根本性優化。這趟旅程清晰地展示了在追求極致效能的道路上,清晰的邏輯、直接的數學轉譯,以及對問題結構本身的顛覆性思考,都是不可或缺的關鍵步驟。

沒有留言: