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