【C++】競程筆記(搜尋演算法、習題練習) === [TOC] 程式碼範例參考:NTUCPC Guide,此筆記僅為個人學習用途。 --- 線性搜尋法(linear search) --- 時間複雜度為 $O(N)$ ,就是用 for 迴圈遍歷得出的結果,如下: ```cpp= #include <iostream> #include <vector> using namespace std; int main(){ vector <int> a = {2,1,7,2,4,3,0,11,3,4,5,5,6,7,100,92,41,27,14}; for (int i=0;i<a.size();i++){ if (a[i] == 100){ cout << i; break; } } return 0; } ``` 以上程式碼透過遍歷尋找 100 這個元素的位置,最終輸出結果為 14。 二分搜尋法(binary search) --- :::success 使用此搜尋法前需要將資料進行「排序」。 ::: 二分搜將已排序好的序列二分為一,分為左右兩邊,取中間值後,若發現此中間值比欲搜尋值大,那麼就往左查找,反之往右。 那往左、往右之後呢,就是繼續二分,不斷「切割」序列,直到找到值為止。 時間複雜度: $O(log N)$ ```cpp= // arr 是我們要搜尋的序列 (由小到大排列好的)、len 是序列的長度、tar 是 // 我們要搜尋的目標。 // 回傳值為目標的索引值。 int bin_search (int arr[], int len, int tar) { int l = 0, r = len; // 答案候選區間 (左閉右開)。 while (l < r) { int m = l + (r - l) / 2; // 猜中間的。 if (arr[m] < tar) l = m + 1; // 猜的太小,更新候選區間為右半邊。 else if (arr[m] > tar) r = m; // 猜的太大,更新候選區間為左半邊。 else return m; // 猜中了。 } return len; // 沒找到,回傳 len。 } ``` ### binary search in STL --- * std::binary_search(start, end, val); * std::upper_bound (first, last, val, comp); * std::lower_bound (first, last, val, comp); 前三個參數 first, last, val 分別為:從序列容器哪裡開始、從序列容器的哪裡結束、要求什麼值。 comp 為比較函數,要跟 val 比較值,為 optional 的選項,這個函數帶有兩個參數,然後最後要回傳布林值。如果範圍內的元素小於 val,則回傳 true,否則回傳 false。 upper_bound -> 找上限;lower_bound -> 找下限。 而兩者差異如下表(作者繪製): | | lower_bound | upper_bound | | -------- | -------- | -------- | | 回傳位置 | 第一個 ≥ val 的元素位置 | 第一個 > val 的元素位置 | | 當 val 存在時 | 指向 val 首次出現的位置 | 指向 val 最後一次出現後的下一個位置 | | 當 val 不存在時 | 指向第一個大於 val 的元素 | 指向第一個大於 val 的元素 | | 當 val 大於所有元素時 | 回傳 last | 回傳 last | ```cpp= #include <iostream> #include <vector> #include <algorithm> using namespace std; int main() { vector<int> vec = {1, 3, 3, 5, 7, 9}; auto it = lower_bound(vec.begin(), vec.end(), 3); cout << "lower_bound of 3 at index: " << (it - vec.begin()) << endl; it = lower_bound(vec.begin(), vec.end(), 4); cout << "lower_bound of 4 at index: " << (it - vec.begin()) << endl; return 0; } ``` output: ``` lower_bound of 3 at index: 1 lower_bound of 4 at index: 3 ``` ```cpp= auto it = std::upper_bound(vec.begin(), vec.end(), 3); std::cout << "upper_bound of 3 at index: " << (it - vec.begin()) << std::endl; ``` output: ``` upper_bound of 3 at index: 3 ``` 可用來計算某個值在序列容器裡面出現的次數: ```cpp= int count = std::upper_bound(vec.begin(), vec.end(), val) - std::lower_bound(vec.begin(), vec.end(), val); ``` ### 二分搜範例 --- LeetCode 3Sum:https://leetcode.com/problems/3sum/description/ 給定一個序列 `[nums[i], nums[j], nums[k]]` 被叫做三胞胎(triplets),然後 $i \neq j \neq k$ ,找出 `num[i] + nums[j] + nums[k] = 0`。 ```cpp= #include <iostream> #include <vector> #include <algorithm> using namespace std; class Solution { public: vector<vector<int>> threeSum(vector<int>& nums) { vector<vector<int>> result; int n = nums.size(); if (n < 3) return result; sort(nums.begin(), nums.end()); if (nums[0] > 0 || nums[n - 1] < 0) return result; for (int i = 0; i < n - 2; i++){ if (nums[i] > 0) break; if (i > 0 && nums[i] == nums[i - 1]) continue; int left = i + 1, right = n - 1; while (left < right){ int sum = nums[i] + nums[left] + nums[right]; if (sum < 0){ // 若和 < 0 left ++; } else if (sum > 0){ // 若和 > 0 right --; } else{ // 這邊找到解了! result.push_back({nums[i], nums[left], nums[right]}); while (left < right && nums[left] == nums[left + 1]) left ++; while (left < right && nums[right] == nums[right - 1]) right --; left ++; right --; } } } return result; } }; ``` ![image](https://hackmd.io/_uploads/BknPN5I6yl.png) 排序完後可以加一個判斷提速:`if (nums[0] > 0 || nums[n - 1] < 0) return result;` 因為預設的排序肯定都會是升序,所以如果第一個元素 > 0,則後面都是 > 0 了,不可能有 < 0 的情況出現,可直接回傳空的 vector,避免後續運算。 在 for 裡面,有條判斷:`if (nums[i] > 0) break;`,因為題目要求的是三項加起來 = 0,所以萬一有一項是 > 0,那就不妙了,所以直接 break(再強調:因為資料排序過了,所以可以這樣做)。 而 `if (i > 0 && nums[i] == nums[i - 1]) continue;` 是為了避免出現重複的數字(目前數字跟前面一項相同)。 之後 27 行以下都是二分搜的部分了。 搜尋法習題練習 --- 1. leetcode sqrt(x) : https://leetcode.com/problems/sqrtx/description/ 這題千萬不要直接引入 sqrt(x) 函數喔XD,因為要透過原理去求平方根近似值。 那這原理是啥呢?十分逼近法(二分搜)。 ```cpp= #include <iostream> using namespace std; class Solution { public: int mySqrt(int x) { if (x == 0 || x == 1) return x; int left = 1, right = x, ans = 0; while (left <= right){ long long mid = left + (right - left) / 2; long long square = mid * mid; if (square == x) return mid; else if (square < x){ ans = mid; left = mid + 1; } else { right = mid - 1; } } return ans; } }; ``` ![image](https://hackmd.io/_uploads/B10bC3I6kx.png) 二分搜的效率已經算很快了,但還有更快的解法,就是牛頓法求近似值。牛頓法是利用 limit 跟微分出來的結果。 具體公式如下: $x_{n+1} = x_{n} - \frac{f(x_{n})}{f'(x_{n})}$ 那這題要求的是平方根,所以我們推導一下公式: 令 $f(x) = x^{2} - m$ 當 $x^{2} - m = 0$ ,則 $x = \sqrt m$ 。這是我們要求的東西。 $f'(x) = 2x$ ,我們的一階導函數求出來了,而 $m$ 等於 $x^{0} \cdot m$ ,屬於常數項,所以直接消失 bang 不見。 之後再將 $f(x)$ 跟 $f'(x)$ 代入公式內,得到: $$x_{n+1} = x_{n} - \frac{x^{2}_{n} - m}{2x_{n}}$$ 經過通分跟一些處理過後,得到: $$x_{n+1} = \frac{x_{n} + \frac{m}{x_{n}}}{2}$$ 然後就可以開始寫程式了: ```cpp= #include <iostream> using namespace std; class Solution { public: int mySqrt(int x) { if (x == 0) return 0; long long r = x; while (r * r > x){ r = (r + x / r) / 2; } return r; } }; ``` ![image](https://hackmd.io/_uploads/ryikA3I61g.png) 2. 2020 年 7 月 APCS 第三題,圓環出口:https://zerojudge.tw/ShowProblem?problemid=f581 **題目解說:** 有 $n$ 個房間(編號由 $0$ 到 $n-1$ ),形成環狀結構。 * 每個房間 $i$ 都可走到房間 $(i+1)$ $mod$ $n$。 * 如:當 $n = 5$ ,房間為 $0→1→2→3→4→0→1 \cdots$ 循環移動。 每個房間 $i$ 進入時會獲得對應的點數 $p_{i}$ ,最初從房間 $0$ 出發,並依照 $m$ 個任務來行動。 **解題目標:** 依序完成 $m$ 個任務,對於每個任務 $p_{i}$ ,至少要累積到 $q_{i}$ 個點數,然後停到某個房間 $t$ 。之後,下一個任務的起點就變成房間 $(t+1)$ $mod$ $n$ 。 最後要計算完成第 $m$ 個任務後會停在哪個房間。 **統整一下要做的事情:** 1. 從當前房間開始累積點數,直到點數累積到目標 $q_{i}$ 。 2. 當點數達標時,停在最後進入的房間,然後下一次的起點變為 $(t+1)$ $mod$ $n$ 。 3. 若點數累積到房間 $n - 1$ 還沒達標,則需要從房間 $0$ 繼續累積(因為房間為環狀的)。 這題使用到前綴和求和會更快,而至於要快速找到最後停留的房間,則使用二分搜會更好,因為 n 會很大,所以線性搜尋絕對穩死。 ```cpp= #include <iostream> #include <vector> #include <algorithm> using namespace std; int main(){ ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; vector<long long> p(n), prefix(n+1, 0); for(int i=0; i<n; i++){ cin >> p[i]; prefix[i+1] = prefix[i] + p[i]; } long long total = prefix[n]; // 一圈總和 int start = 0; for(int i=0; i<m; i++){ // 執行 m 個任務 long long q; cin >> q; // 若從start到房間n-1的累積點數>=q,則在prefix中找prefix[start] + q的下限 if(prefix[n] - prefix[start] >= q){ long long target = prefix[start] + q; // 在區間 [start+1, n] 中搜尋 int j = lower_bound(prefix.begin()+start+1, prefix.end(), target) - prefix.begin(); // 求索引 j // 完成任務的房間為 j-1 start = (j) % n; // 下一個起始房間是 (t+1) mod n,其中 t = j-1 } else{ long long rem = q - (prefix[n] - prefix[start]); // 從前綴和中搜尋,區間 [0, n] 中找 prefix[j] >= rem int j = lower_bound(prefix.begin(), prefix.end(), rem) - prefix.begin(); start = (j) % n; } } cout << start << "\n"; return 0; } ``` ![image](https://hackmd.io/_uploads/rkKkGaUpyl.png)