## 定義 從一堆資料中找到想要的資料,這就是搜尋,比如在一堆考卷中找到自己的考卷 有一個最簡單的方法,就是從頭開始一張張翻看名字,直到找到自己的考卷 ## 線性搜尋 所謂的線性搜尋,就是剛剛說的方法,也是最直覺的方式 ```cpp= int l_search(int n, int cur) { for (int i=0;i<n;i++) if (a[i] == cur) // 找到了 return i ; // 回傳位置 return n ; // 沒找到 回傳範圍外的值 } ``` 這樣做的時間複雜度是 $O(n)$,如果要找的次數過多,數據的量過大,找起來就會非常耗時 所以這時候就會需要一個速度快一點的搜尋方式 ## 二分搜尋 二分搜尋最簡單的例子就是在猜數字,猜 $1\sim 100$,每次都猜範圍的中間 如果用概念去想一下就知道,因為每次範圍都可以減半,所以找起來特別快 不過這麼快的搜尋速度是有條件的,就是陣列必須滿足單調性 所謂的單調性可以簡單理解成遞增或遞減,需要這個條件的原因比較重要 查找的時候需要判斷數值的大小關係,右(左)邊一定要皆 $\ge(\le)$ 中點 這樣才能透過判斷來縮減查找的範圍,而實作上需要用 sort() 或題目自帶滿足單調性 由於這種搜尋一次減少一半範圍的做法,二分搜的時間複雜度為 $O(log(n))$ ```cpp= int b_search(int n, int cur) { int L = 0, R = n ; // 範圍的左右端點 while (L < R) { int M = (L+R) / 2 ; // 中間點 if (a[M] < cur) // 點在右半邊 L = M+1 ; // 拋棄左半邊 else if (a[M] > cur) // 點在左半邊 R = M ; // 拋棄右半邊 else // 找到了 return M ; } return n ; // 沒找到 回傳範圍外的值 } ``` 不過這樣其實很容易寫錯,因為實作方式其實有很多種,比如上述就是用左閉右開的概念 換句話說就是判斷的範圍包含左邊,但不包含右邊,"純搜尋"建議用左閉右閉的方法 並且找 $\ge cur$ 的第一個位置,比較不會出錯,使用範圍也比較廣泛 不過在更進階的題目上,二分搜的使用需要更靈活,建議還是用推的會比較好 通常只要知道左右的開(閉合)情況,就可以快速地寫出對應的二分搜 ```cpp= int b_search_p(int n, int cur) { int L = 0, R = n-1 ; while (L < R) { int M = (L+R) / 2 ; // 中間點 if (a[M] < cur) // 點在右半邊 L = M+1 ; // 拋棄左半邊 else if (a[M] > cur) // 點在左半邊 R = M-1 ; // 拋棄右半邊 else // 找到了 return M ; } return L ; // return R 也可以 } ``` 但在"只需要"搜尋的場合,還是會用 STL 內建的函式來解決 ```cpp= auto l_pos = lower_bound(v.begin(), v.end(), 6) ; // iterator auto u_pos = lower_bound(v.begin(), v.end(), 6) ; // iterator cout << "index: " << l_pos-v.begin() ; // position cout << ' ' << u_pos-v.begin() << '\n' ; // position cout << "value: " << *l_pos << ' ' << *u_pos << '\n' ; ``` 實際上用到搜尋的例子會不僅限於"在資料內找到某數"的概念 在比較困難的題目就有可能會遇到其他方式的搜尋,比如"在範圍內找到最小可通過解" 這時候通常就會用搜尋+函式判斷是否能通過,一般都用二分搜 這種比較困難的題目,最後面會放一題相對簡單的,有興趣的可以試試看 ## 例題-ZJ f071. 2. 刮刮樂 (Lottery) [題目連結](https://zerojudge.tw/ShowProblem?problemid=f071) 解題思路 : 這題不難,就是判斷資料當中是否有對應的數字,並根據規則做加減 不過有幾個需要注意的地方,也算是題目敘述不足的部分 第一就是幸運數字可以重複使用,舉個例子,假設你的幸運數字是 $1$,號碼有五個 $1$ 那這五個 $1$ 的獎金都可以被你拿走,第二個是幸運數字有可能相同 這個情況比較複雜,假設第一個跟第二個幸運數字相同,那一個號碼的獎金還是一份 假設第一(二)個跟第三個幸運數字相同,那因為獎金增加又減少,所以等於不變 至於搜尋的部分,因為這裡數字沒排序,而且只需要找 $3\times 5$ 次,所以用線性搜尋就好 ```cpp= #include<bits/stdc++.h> using namespace std ; int main() { ios::sync_with_stdio(0), cin.tie(0) ; int a, b, c ; cin >> a >> b >> c ; vector<int> v(5), m(5) ; // 輸入 for (int i=0;i<5;i++) cin >> v[i] ; for (int i=0;i<5;i++) cin >> m[i] ; bool havec = false ; // 是否出現過 c int ans = 0 ; // 總和 for (int i=0;i<5;i++) { if (v[i] == a || v[i] == b) // 遇到 a 或 b (a == b 算一份獎金) ans += m[i] ; if (v[i] == c) { // 遇到 c (注意 a== c or a == b) ans -= m[i] ; havec = true ; } } if (havec) // 有出現過 c cout << max(ans, 0) << '\n' ; else // 沒出現過 c cout << ans*2 << '\n' ; return 0 ; } ``` ## 例題-ZJ d732. 二分搜尋法 [題目連結](https://zerojudge.tw/ShowProblem?problemid=d732) 解題思路 : 題目問是否存在一個整數存在數列當中,通常用 lower_bound() 就可以了 因為 lower_bound() 是找大於等於的第一個數,所以找到的數可能不是題目要求的 如果找到的數跟題目要求的不同,那就代表沒找到,不過還有一種情況要考慮 如果題目要求的數字 $>$ 數列的最大數,那找到的位置就會是陣列的範圍外 所以這時候不能直接轉換成數字,而是要用 if 去把它過濾掉 ```cpp= #include<bits/stdc++.h> using namespace std ; int main() { ios::sync_with_stdio(0), cin.tie(0) ; int n, k ; cin >> n >> k ; vector<int> v(n) ; // 輸入 for (int i=0;i<n;i++) cin >> v[i] ; int cur ; for (int i=0;i<k;i++) { // 找 k 個數 cin >> cur ; // 二分搜找位置 auto pos = lower_bound(v.begin(), v.end(), cur) ; if (pos == v.end() || *pos != cur) // 未找到 cout << "0\n" ; else // 找到了 cout << (pos-v.begin())+1 << '\n' ; } return 0 ; } ``` 注意我在第 $19$ 行有用到 if 的特性,所以 `pos == v.end()` 成立就不會判斷 `*pos != cur` 也就是前面說的小心使用到範圍外的位置,會導致錯誤 ## 例題-ZJ f581. 3. 圓環出口 [題目連結](https://zerojudge.tw/ShowProblem?problemid=f581) 解題思路 : (建議先學過基礎演算法-前綴和與差分再解這題) 這題的題目敘述就有點不太好理解了,直接用題目的例子去理解會比較快 假設房間給與的點數分別為 $2,1,5,4,3,5,3$,要求的點數分別為 $8,9,12$ 那第一個要 $8$ 點,所以 $2+1+5 = 8$,停下來的地方是 "點數為 $4$ 的房間" 所以是滿足條件後的一個房間,第二個要 $9$ 點,所以 $4+3+5=12$ 從 $4$ 開始,所以每次都是從停下的地方開始加,加到滿足後再往後一個房間 接下來就是如何快速找到滿足條件的房間,這裡就需要用到前綴和了 因為前綴和可以快速計算兩個房間路徑上的點數總和,不過有些問題 假設要從第 $5$ 間房間到第 $3$ 間房間,一般的前綴和就無法輕鬆解決 所以我們可以把前綴和擴充,把最後一間到第一間到...到最後二間的前綴和也加進去 ```cpp= vector<int> pre(2*n-1) ; // 前綴和 for (int i=0;i<n;i++) { cin >> pre[i] ; pre[i] += pre[i-1] ; // 直接計算前綴和 } for (int i=n;i<2*n-1;i++) // 把前綴和拆成 pre[n-1] 與其他 pre[i] = pre[n-1] + pre[i%n] ; ``` 這樣就如果需要 need 個點數,用二分搜找 `pre[(pos+n-1)%n]+need` 就可以找到滿足條件的位置 最後在記得把位置往後一位,然後把位置取 $n$ 的餘數,因為前綴和的大小超過 $n$ ```cpp= #include<bits/stdc++.h> using namespace std ; int main() { ios::sync_with_stdio(0), cin.tie(0) ; int n, m ; cin >> n >> m ; vector<int> pre(2*n-1) ; // 前綴和 cin >> pre[0] ; // 前綴和 for (int i=1;i<n;i++) { cin >> pre[i] ; pre[i] += pre[i-1] ; } for (int i=n;i<2*n-1;i++) pre[i] = pre[n-1] + pre[i%n] ; int need, pos = 0 ; for (int i=0;i<m;i++) { cin >> need ; // 找到滿足條件的位置 auto np = lower_bound(pre.begin(), pre.end(), pre[(pos+n-1)%n]+need) ; pos = (np-pre.begin()+1) % n ; // 再往後一間 } cout << pos << '\n' ; return 0 ; } ``` ## 例題-ZJ f815. TOI_y21m4_a01遊戲升等 [題目連結](https://zerojudge.tw/ShowProblem?problemid=f815) (這題是比較困難的題目,如果能看得懂就看,看不懂可以先跳過) 解題思路 : 首先要說明,如果升兩等不可以升兩次一等,題目好像沒說,在這裡補充一下 再來就是計算每個角色升到 $x$ 等的花費有沒有超過持有的金幣,這個應該不難,用 for-loop ```cpp= bool cost(vector<int> &v, int lvl) { // 如果都升到 lvl,需要花費的金幣有沒有在 c 之內 LL now = 0 ; for (int i=0;i<n;i++) { // 計算需要花費多少金幣 if (lvl > v[i]) { // 需要升級 now += (LL)(lvl-v[i])*(lvl-v[i]) ; if (now > c) // 花費超過 c return false ; } } return true ; // 算完沒超過 c } ``` 接下來才是比較困難的地方,就是要找到滿足條件的最大可能,最簡單的方式就是線性搜尋 也就是從 $1$ 開始,慢慢數字往上加,就可以找到滿足條件的最大可能了 ```cpp= for (int i=1;i<m;i++) { if (!cost(i)) { cout << i-1 << '\n' ; return 0 ; } } ``` 不過這樣的做法時間複雜度有點高,假設等級範圍是 $0 \sim m$,時間複雜度就是 $O(nm)$ 可能有人不知道等級範圍怎麼算,其實只要考慮最極端的情況就可以了 首先金幣只花在一個人,升的等級肯定最多,再來那個人本身的等級越高越好 所以金幣最多 $10^{14}$,可以升 $10^7$ 等,角色原先最多 $10^7$ 等,加起來最多 $2\times 10^7$ 那已經知道線性搜尋效率太差,那要不要試試看二分搜,畢竟概念上可以把可能性看陣列 陣列的內的元素就會像這樣: $\{cost(1), cost(2), ..., cost(m)\}$ 那就可以用二分搜去找滿足條件的最大值,不過跟一般的二分搜不太一樣 ```cpp= #include<bits/stdc++.h> using namespace std ; typedef long long LL ; int n ; LL c ; bool cost(vector<int> &v, int lvl) { // 如果都升到 lvl,需要花費的金幣有沒有在 c 之內 LL now = 0 ; for (int i=0;i<n;i++) { // 計算需要花費多少金幣 if (lvl > v[i]) { // 需要升級 now += (LL)(lvl-v[i])*(lvl-v[i]) ; if (now > c) // 花費超過 c return false ; } } return true ; // 算完沒超過 c } int main() { ios::sync_with_stdio(0), cin.tie(0) ; cin >> n >> c ; vector<int> v(n) ; for (int i=0;i<n;i++) cin >> v[i] ; // 用二分搜去找滿足條件的最大可能 int L = 0, R = INT_MAX ; while (L <= R) { int M = (L+R) / 2 ; if (cost(v, M)) // 滿足條件 L = M+1 ; else // 不滿足條件 R = M-1 ; } cout << R << '\n' ; return 0 ; } ``` 這個二分搜跟我們上面介紹的有點不一樣,只要用推的去理解就可以發現差異帶來的影響 二分搜只要考慮三種問題,第一是能不能找到對的值,第二是能不能停下來,第三是答案是 L 還是 R 通常會卡在第二與第三,考試或檢定通常會有紙,記得用比較小的情況推一下才不會出錯