{%hackmd @hipp0\Hippotumuxthem %} <style> .text-center{ text-align: center; //文字置中 } .text-left{ text-align: left; //文字靠左 } .text-right{ text-align: right; //文字靠右 } </style> <style> .reveal .slides { text-align: left; font-size:30px; } </style> ### 分治法內容 - 分治法介紹 - 複雜度 - 費式數列 - 結論 --- ### 分治法 (Divide and Conquer) 分治是一種非常重要的演算法思維模式與策略,有很多重要的演算法都是根據分治的思維模式,例如快速排序法、合併排序法、快速傅立葉轉換(FFT)、矩陣乘法、整數乘法以及在一些在計算幾何的知名演算法都是分治的策略。 當碰到一個不曾見過的問題,分治往往是第一個思考的重點,因為分治的架構讓我們很容易找到比暴力要好的方法。 ---- ## 核心想法 分治法主要有三個步驟,切割,解決,合併。 - 切割:把問題以「相同問題」切割成更多小的子問題。 - 解決:把切割完的子問題解決。 - 合併:把解決完的子問題合併起來成為問題的答案。 ---- ### 代碼 ```cpp= // 看問題需要回傳什麼型態 int Divide_and_Conquer(int left, int right) { // 要多小才不繼續分 if (right - left < 1) { // 回傳你小問題的答案 return 1; } int mid = (left + right)/2; // 切割問題 int left_return = Divide_and_Conquer(left, mid); int right_return = Divide_and_Conquer(mid+1, right); // 合併問題 (假設回傳相加) int total_return = left_return + right_return; return total_return; } ``` ---- ### 簡單例子1 (找序列最大/小值) 題目 : 給定長度為 N 的序列,找到最大值/最小值 ---- ### 想法 按照分治法三個步驟,切割,解決,合併,把問題大化小。 - 切割: 分成左右兩半 (切到剩下兩個以下就停止) - 解決: 計算其最大值/最小值 - 合併: 根據左右兩半回傳的值,計算其最大值最小值並回傳 ---- ### 代碼 (計算最大值演示) ```cpp= int Divide_find_max(int left, int right, vector<int> &vec) { // 最小問題 if (right - left < 2) { return max(vec[left], vec[right]); } // 切割 int mid = (left + right)/2; int left_ret = Divide_find_max(left, mid, vec); int right_ret = Divide_find_max(mid+1, right, vec); int total_ret = max(left_ret, right_ret); return total_ret; } ``` ---- ### 簡單例子2 (合併排序法) 在排序那篇中,有提到合併排序法,合併排序法的根本原理就是分治法 ---- ### 想法 按照分治法三個步驟,切割,解決,合併,把問題大化小。 - 切割: 分成左右兩半 (切到剩下兩個以下就停止) - 解決: 回傳排序好的序列 ---- ### 合併: 合併的時候,會拿到左右兩區間 "排序好的序列" ,這邊可以利用排序好的特性來進行合併。 假設目前左區間回傳 \[ 1, 3, 8, 9] 右區間回傳 \[2, 4, 6,] ---- ### 合併: \[ 1, 3, 8, 9] \[2, 4, 6] 利用已經排序的特性,左邊一定比右邊小,所以可以把兩個序列從左邊比到右邊,放進新的序列中。 ---- ### 第一輪 \[<font color=#FF0000> 1 </font>, 3, 8, 9] \[<font color=#0000FF> 2 </font>, 4, 6] #### 新序列 \[<font color=#FF0000> 1 </font>] ---- ### 第二輪 \[1, <font color=#FF0000> 3 </font>, 8, 9] \[<font color=#0000FF> 2 </font>, 4, 6] #### 新序列 \[1, <font color=#0000FF> 2 </font>] ---- ### 第三輪 \[1, <font color=#FF0000> 3 </font>, 8, 9] \[2, <font color=#0000FF> 4 </font>, 6] #### 新序列 \[1, 2, <font color=#FF0000> 3 </font>] ---- ### 第四輪 \[1, 3, <font color=#FF0000> 8 </font>, 9] \[2, <font color=#0000FF> 4 </font>, 6] #### 新序列 \[1, 2, 3, <font color=#0000FF> 4 </font>] ---- ### 第五輪 \[1, 3, <font color=#FF0000> 8 </font>, 9] \[2, 4, <font color=#0000FF> 6 </font>] #### 新序列 \[1, 2, 3, 4, <font color=#0000FF> 6 </font>] ---- ### 第六輪 \[1, 3, <font color=#FF0000> 8 </font>, 9] \[2, 4, 6] #### 新序列 \[1, 2, 3, 4, 6, <font color=#FF0000> 8 </font>] ---- ### 第七輪 \[1, 3, 8, <font color=#FF0000> 9 </font>] \[2, 4, 6] #### 新序列 \[1, 2, 3, 4, 6, 8, <font color=#FF0000> 9 </font>] --- ### 複雜度 這兩個簡單的例子可以推到 - 找最大值/最小值: $O(N)$ - 合併排序法: $O(NlogN)$ ---- ### 複雜度 如果分割的很少,那每個問題的"量"就會很多, 如果分割的很多,那每個問題的"量"雖然少,但合併次數也會很多, 到底要怎麼來計算複雜度呢? ---- ### 複雜度 分治是一個遞迴的演算法,不像迴圈的複雜度可以用加總的方法或是乘法計算,遞迴的複雜度是由遞迴關係式(recurrence relation)所表達。計算複雜度必須解遞迴關係式。遞迴關係又稱為差分方程式(difference equation),解遞迴關係是個複雜的事情。 分治演算法的常見形式是將一個大小為 n 的問題切成 a 個大小為 b 的子問題此外必須做一些分割與合併的工作。假設大小為 n 的問題的複雜度是 T(n),而分割合併需要的時間是 f(n),可以得到以下遞迴關係: $T(n) = aT(\frac{n}{b}) + f(n)$ ---- ### 主定理 (Master Method) 原理就是根據剛剛說到的,要來評估分割,合併的關係。 一個分治的問題可以寫成 $T(n) = aT(\frac{n}{b}) + f(n)$ 主定理分成三種情況 ---- ### 情況一 (簡單說,合併解決問題 < 分割) 如果存在 ε > 0 使得 $f(n) = O(n^{log_b (a) - ε})$ 則 $T(n) = Θ(n^{log_b a})$ ---- ### 情況二 (合併解決問題 = 分割) 如果存在 ε $\geq$ 0 使得 $f(n) = Θ(n^{log_b a} log^εn)$ 則 $T(n) = Θ(n^{log_b a} log^{ε+1}n)$ ---- ### 情況三 (合併解決問題 > 分割) 如果存在 ε > 0 使得 $f(n) = Ω(n^{log_b (a) + ε})$ 且存在 c < 1 滿足 $af(\frac{n}{b}) \leq cf(n)$ $T(n) = Θ(f(n))$ ---- ### 複雜度分析-找最大值/最小值 $T(n) = aT(\frac{n}{b}) + f(n)$ 根據切割和合併寫下 $T(n) = 2T(\frac{n}{2}) + Θ(1)$ 根據情況一 存在 ε > 0 使得 $f(n) = O(n^{log_2 (2) - ε})$ 所以 $T(n) = Θ(n^{log_2 2}) = Θ(n)$ ---- ### 複雜度分析-合併排序法 $T(n) = aT(\frac{n}{b}) + f(n)$ 根據切割和合併寫下 $T(n) = 2T(\frac{n}{2}) + Θ(n)$ 根據情況二 存在 ε = 0 使得 $f(n) = Θ(n^{log_2 2} log^εn)$ 則 $T(n) = Θ(n^{log_2 2} log^{ε+1}n) = Θ(nlogn)$ ---- ### 題外話 主定理並不是萬能的,還是會有很多例外,複雜一點就變得很難評估 也有另外一種算法 Recursion-Tree Method,如果有興趣的話可以自行查詢。 --- ### 費式數列 前面提過很多很多次,已經學會了最原始遞迴,後來的動態規劃,再到用線性代數的方式和矩陣快速冪求解。 費式數列是一個很酷的數列,無處不在,甚至和黃金比例也產生了關係,接下來就來利用費式數列的一些性質,導出一些式子,就可以利用分治法求解。 為了方便,定義費式數列從第一項開始,也就是 $F_1 = F_2 = 1$ ---- ### 性質一 (不會用到,但有趣提一下) $F_{n+2} = \sum_{k=1}^n F_k + 1$ ---- ### 證明 首先有 $F_{n+2} = F_{n+1} + F_{n}$ 如果展開 n + 1 項 $F_{n+2} = F_{n} + F_{n-1} + F_{n}$ 繼續展開直到 n = 2 $F_{n+2} = F_{2} + F_{1} + ... + F_{n-1} + F_{n}$ 定義得 $F_{2} = 1$ 得到 $F_{n+2} = 1 + F_{1} + ... + F_{n-1} + F_{n}$ ---- ### 性質二 第(m+n)項等於第 m 項乘上第(n-1)項後再加上第(m+1) 項乘上第 n 項之和 $F_{m+n} = F_m F_{n-1} + F_{m+1}F_{n}$ ---- ### 證明 利用 $F_1 = F_2 = 1$,以及費式數列定義得 $F_{m+n} = F_{1}F_{m+n-2}+F_{2}F_{m+n-1}$ = $F_{1}F_{m+n-2} + F_{2}(F_{m+n-2} + F_{m+n-3})$ = $F_{m+n-2}(F_1 + F_2) + F_{2}F_{m+n-3}$ = $F_{2}F_{m+n-3} + F_3F_{m+n-2}$ 會發現是不是又跟一開始長的形式一樣,所以繼續推下去可以得到 = $F_{m-1}F_{n} + F_mF_{n-1}$ ---- ### 奇偶項恆等式 - 奇數項 $F_{2n-1} = F_n^2 + F_{n-1}^2$ - 偶數項 $F_{2n} = (F_{n-1} + F_{n+1})F_n = (2F_{n-1} + F_{n})F_n$ #### 證明 由性質二 $F_{m+n} = F_m F_{n-1} + F_{m+1}F_{n}$ 奇數項令 m = n-1 直接得證 偶數項令 m = n ,然後展開 n + 1 項得證 ---- ### 分治法求解 剛剛得到 - 奇數項 $F_{2n-1} = F_n^2 + F_{n-1}^2$ - 偶數項 $F_{2n} = (F_{n-1} + F_{n+1})F_n = (2F_{n-1} + F_{n})F_n$ 所以發現可以直接根據奇數偶數分治下去 ---- ### 實作 ```cpp= const int mod = 1e9 + 7; long long fib(long long n) { // 從第一項算 if (n <= 2) return 1; if (n % 2 != 0) { long long left_ret = fib((n+1)/2 - 1); long long right_ret = fib((n+1)/2); long long total_ret = (left_ret*left_ret + right_ret*right_ret) % mod; return total_ret; } else { long long left_ret = fib(n/2 - 1); long long right_ret = fib(n/2); long long total_ret = (2*left_ret + right_ret)*right_ret % mod; return total_ret; } } ``` ---- ### 複雜度分析 很不幸的,目前複雜度還是 $O(N)$,雖然分成了一半一半,但並沒有做到任何可以加快的部分。 #### 主定理 $T(n) = 2T(\frac{n}{2}) + O(1)$ 這個跟找最小/大值一樣。 所以時間複雜度真的為 $O(N)$ ---- ### 加快 會發現雖然分成了一半,可以讓 n 快速下降,但是總數量卻是不變的,不過,雖然總數量一樣,但是卻是會重複非常多次,所以如果能夠紀錄走過的路,那就可以優化了! 這邊使用 map 來儲存,因為除二的結果不重複的數量大概為 $logn$ 但是如果想用陣列儲存會因為 n 太大沒辦法存,所以只能使用速度較慢的 map 來存,但好在數量小,所以不太影響。 ---- ### 實作 ```cpp= const int mod = 1e9 + 7; map<long long,long long> mp; long long fib(long long n) { if (n == 0) return 0; if (n <= 2) return 1; if (mp.find(n) != mp.end()) return mp[n]; if (n % 2 != 0) { long long left_ret = fib((n+1)/2 - 1); long long right_ret = fib((n+1)/2); long long total_ret = (left_ret*left_ret + right_ret*right_ret) % mod; mp[n] = total_ret; return total_ret; } else { long long left_ret = fib(n/2 - 1); long long right_ret = fib(n/2); long long total_ret = (2*left_ret + right_ret)*right_ret % mod; mp[n] = total_ret; return total_ret; } } ``` ---- ### 時間複雜度 $O(logn)$ 因為每次都除二,所以總數量大概為 logn 雖然 map 的查詢,儲存時間為 $O(NlogN)$ 但是因為 $N = logn$,和題目總數 n ~ 1E12,實在是小到可以忽略,可以當成 O(1) (嚴謹一點當然可以寫成 $O(logn \times loglogn)$,放去主定理推也是可以得到一樣的結果) #### 主定理 分成了左右兩半,但另外一邊反而會因為記錄過所以不會重複算到 $T(n) = T(n/2) + O(1)$ 根據情況二, ε = 0,可得時間複雜度為 $O(logn)$ ---- ### 實測 當然也可以用程式碼來粗估複雜度 ```cpp= #include <bits/stdc++.h> using namespace std; const int mod = 1e9 + 7; map<long long,long long> mp; int times = 0; long long fib(long long n) { if (n == 0) return 0; if (n <= 2) return 1; if (mp.find(n) != mp.end()) return mp[n]; times ++; if (n % 2 != 0) { long long left_ret = fib((n+1)/2 - 1); long long right_ret = fib((n+1)/2); long long total_ret = (left_ret*left_ret + right_ret*right_ret) % mod; mp[n] = total_ret; return total_ret; } else { long long left_ret = fib(n/2 - 1); long long right_ret = fib(n/2); long long total_ret = (2*left_ret + right_ret)*right_ret % mod; mp[n] = total_ret; return total_ret; } } void solve(){ long long n; mp.clear(); times = 0; cin >> n; if (n == 0) { cout << 0 << endl; }else{ cout << fib(n) << endl; } cout << "times = " << times << endl; cout << "----" << endl; return; } signed main(){ ios::sync_with_stdio(0), cin.tie(0); long long t = 1; cin >> t; while(t--){ solve(); } } ``` --- ### 優缺點 ---- ### 優點 較為直觀,就是核心三步驟(分割,求解,合併),通用問題全部都可以用。 ---- ### 缺點 分治法的題目較為難寫,同時並不是說分治下去的時間複雜度就會好,還是需要去評估的。 另外,雖然複雜度一樣,但只要有分出去的動作,就是一種 cost ,所以正常情況下,如果有不需要分治的狀況,分治的常數往往都是較差的一方。 ---- ### 常見優化方式 - 在分到量很小的時候,可以考慮暴力計算 - 注意是否會遇到相同子問題,可以儲存起來。 ---- ### 比較 (貪心,DP,分治) | | 貪心 | 動態規劃 | 分治法 | | -------- | -------- | -------- | -------- | | 類型 | 優化問題 | 優化問題 | 通用問題 | | 子問題結構 | 一個子問題 | 很多子問題 (不獨立) | 很多子問題 (獨立) | | 最優子結構 | 必須滿足 | 必須滿足 | 不須要 | | 子問題數 | 解決一個子問題 | 全部都要解決 | 全部都要解決 | | 子問題在最優解中 | 部分 | 部分 | 全部 | | 選擇/求解次數 | 先選擇再解決問題 | 先解決子問題再選擇 | 先選擇再解決問題 | ---- ### 總結 分治對於初學者來說較有難度,但是對於基本功,看待遞迴複雜度很有幫助。 分治法有很多延伸,像是線段樹、CDQ分治法、DP優化,FFT,NTT,Karatsuba,甚至數值方法中也很常用像是勘根法,也都是建立在分治上,可以說是非常實用(且困難)的算法。 ---- ### 練習 - J001 - J002 - I009 (費式數列用分治法求解) 挑戰題 (經典題) - J003
{"contributors":"[{\"id\":\"b4bc52a4-04a8-4c6d-920a-32b9ab31a7f9\",\"add\":9600,\"del\":392}]","title":"分治法","description":"分治法"}
   changed a year ago 339 views
   owned this note