【C++】競程筆記(搜尋演算法、習題練習)

程式碼範例參考:NTUCPC Guide,此筆記僅為個人學習用途。


線性搜尋法(linear search)

時間複雜度為 $O(N)$ ,就是用 for 迴圈遍歷得出的結果,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#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)$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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_boundupper_bound
回傳位置第一個 ≥ val 的元素位置第一個 > val 的元素位置
當 val 存在時指向 val 首次出現的位置指向 val 最後一次出現後的下一個位置
當 val 不存在時指向第一個大於 val 的元素指向第一個大於 val 的元素
當 val 大於所有元素時回傳 last回傳 last
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#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:

1
2
lower_bound of 3 at index: 1
lower_bound of 4 at index: 3
1
2
auto it = std::upper_bound(vec.begin(), vec.end(), 3);
std::cout << "upper_bound of 3 at index: " << (it - vec.begin()) << std::endl;

output:

1
upper_bound of 3 at index: 3

可用來計算某個值在序列容器裡面出現的次數:

1
2
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#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

排序完後可以加一個判斷提速: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,因為要透過原理去求平方根近似值。

那這原理是啥呢?十分逼近法(二分搜)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#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

二分搜的效率已經算很快了,但還有更快的解法,就是牛頓法求近似值。牛頓法是利用 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)$ 代入公式內,得到:

經過通分跟一些處理過後,得到:

然後就可以開始寫程式了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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

  1. 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 會很大,所以線性搜尋絕對穩死。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#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