演算法概述
建國中學 游承曦
給你一個問題
數學上
程式上
計算特定數值下的正確答案
或證明
設計出一套解決問題的流程
能算出該問題在各種情況下的解
為什麼用C/C++
-
執行速度快(很多
-
語法非常的基本
-
只有少數情況才會用到其他語言(e.g. 大數 \(\to\) Python
錯誤訊息
Accept (AC) | 答案正確 | 爽。 |
---|---|---|
Wrong Answer (WA) | 答案錯誤 | 找蟲。 (debug) |
Time Limit Error (TLE) | 執行超出時間限制 | 找蟲 或 考慮更好的做法 |
Memory Limit Error (MLE) | 執行超出記憶體限制 | 找蟲 或 考慮更好的做法 |
Runtime Error (RE) | 執行時發生錯誤 | 很多原因 |
Compilation Error (CE) | 編譯錯誤 | 痾... |
前言到這邊。
語法還不太熟的要趕快練習RRR ><
什麼是資料結構?
- 儲存資料的形式
- 選擇適合的資料結構來搭配演算法
- (之後會有更詳細的介紹)
什麼是演算法?
- 解決問題的一套流程
- 正確性
- 效率
舉個例子
手上有十張牌上面寫著1~10
將這十張牌由小到大,從左到右排好
神奇的作法
- 將牌洗一洗再隨便擺出來
- 若尚未排序好,回到1.
- 完成
正確性(X)
效率(X)
嚴謹的作法
- 每次挑選手上最小的牌
- 若桌上沒有牌就直接放,有牌就放到最左側
- 若手還有牌,回到1.
- 完成
時間複雜度
判斷一個演算法的效率
- 對於兩個函數 \(f(n), g(n)\),考慮 \(n\) 非常大的情況
- 當 \(\displaystyle\lim_{n\to\infty} \frac{f(n)}{g(n)}\) 趨近於 \(0\) 時,\(f(n) < g(n)\)
- 當 \(\displaystyle\lim_{n\to\infty} \frac{f(n)}{g(n)}\) 趨近於 \(\infty\) 時,\(f(n) > g(n)\)
- 其餘 \(f(n) = g(n)\)
量級
- 若 \(f(n) = n^2, g(n) = n\),則 \(\frac{f(n)}{g(n)} = n\)
- 當 \(n\) 趨近無限大時,\(\frac{f(n)}{g(n)} = \infty\),故 \(f(n) > g(n)\)
白話一點的例子
- \(f(n) = n^2, g(n) = 2n\)
- 當 \(n=10\), \(f(n)=100, g(n)=20\)
- 當 \(n=1000000\), \(f(n)=1000000000000, g(n)=2000000\)
再更白話一點
可以發現數字\(n\)在很大的時候,
實際上影響力最大是最高次項,
係數和其他較低次項的並不會有什麼影響,
我們會用「 \(\mathcal{O}\) 」來表示演算法中用到的最高次項
時間複雜度
- \(f(n) = 100n\)
- \(g(n) = \frac{2n^3}{5}+7n+100\)
- \(h(n) = 1000\)
例子
- \(O(f(n)) \in O(n)\)
- \(O(g(n)) \in O(n^3)\)
- \(O(h(n)) \in O(1)\)
學了時間複雜度有什麼用?
- 估算程式執行的時間
- 在解題時需在時限內回答(通常\(1\)秒)
- \(1\)秒大概跑\(1\)億次\((10^8)\)運算 (輸入輸出不算)
可是空間複雜度ㄋ?
- 通常題目不會特別卡記憶體
- 看題目的 \(n\) 大小
- 如果真的有卡空間就要考慮優化演算法
回到剛剛的排序問題
- 每次挑選手上最小的牌
- 若桌上沒有牌就直接放,有牌就放到最左側
- 若手還有牌,回到1.
- 完成
計算執行次數
- 第一次檢查 \(n\) 張,第二次 \(n-1\) 張 ... 第 \(n\) 次 \(1\) 張
- 總共執行 \(1+2+3+...+n = \frac{n(n+1)}{2} = \frac{n^2+n}{2}\) 次
- 所以這個做法的時間複雜度為 \(O(n^2)\)
僅供參考
時間複雜度 | 適用範圍 (1秒) |
---|---|
\(O(n!)\)
\(O(2^n)\)
\(O(n^4)\)
\(O(n^3)\)
\(O(n^2)\)
\(O(n \log n)\)
\(n \leq 10\)
\(n \leq 20\)
\(n \leq 50\)
\(n \leq 200\)
\(n \leq 3000\)
\(n \leq 10^6\)
排序演算法
- 「排序」指的是將一堆資料依照某種準則排列
- 排序後的資料可能有特殊的性質
- 不同排序法有不同的複雜度與不同的應用
以下五種範例都是由小排序到大
氣泡排序 Bubble Sort
// sort arr[0, n)
for(int i=n-1 ; i>0 ; i--){
for(int j=0 ; j<i ; j++){
if(arr[j] > arr[j+1]){
// swap
int tmp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tmp;
}
}
}
時間複雜度 \(\mathcal{O}(n^2)\)
額外空間複雜度 \(\mathcal{O}(1)\)
透過不斷地交換
選擇排序 Selection Sort
// sort arr[0, n)
for(int i=n-1 ; i>0 ; i--){
int mxp = 0;
for(int j=1 ; j<=i ; j++){
if(arr[j] > arr[mxp]) mxp = j;
}
// swap
int tmp = arr[mxp];
arr[mxp] = arr[i];
arr[i] = tmp;
}
時間複雜度 \(\mathcal{O}(n^2)\)
額外空間複雜度 \(\mathcal{O}(1)\)
每次選擇最小或最大,移動到頭或尾
插入排序 Insertion Sort
// sort arr[0, n)
for(int i=1 ; i<n ; i++){
int j = 0;
while(arr[j] < arr[i] && j < i) j++;
int tmp = arr[i];
for(int k=i ; k>j ; k--) arr[k] = arr[k-1];
arr[j] = tmp;
}
時間複雜度 \(\mathcal{O}(n^2)\)
額外空間複雜度 \(\mathcal{O}(1)\)
每次將後方的元素插入到正確位置
合併排序 Merge Sort
時間複雜度 \(\mathcal{O}(n \log n)\)
額外空間複雜度 \(\mathcal{O}(n)\)
採用分治法 (Divide & Conquer)
int arr[N], buf[N];
void MergeSort(int l, int r){ // [l, r]
if(l >= r) return;
int m = (l + r) / 2;
MergeSort(l, m), MergeSort(m+1, r);
int i = l, j = m+1, k = l;
while(i <= m && j <= r){
if(arr[i] < arr[j]) buf[k++] = arr[i++];
else buf[k++] = arr[j++];
}
while(i <= m) buf[k++] = arr[i++];
while(j <= r) buf[k++] = arr[j++];
for(int p=l ; p<=r ; p++) arr[p] = buf[p];
}
int main(){
MergeSort(0, n-1);
}
快速排序 Quick Sort
時間複雜度 最佳 \(\mathcal{O}(n \log n)\) 最差 \(\mathcal{O}(n^2)\)
額外空間複雜度 \(\mathcal{O}(1)\)
一樣是分治法,這裡的例子取pivot為arr[r]
int arr[N];
void QuickSort(int l, int r){ // [l, r]
if(l >= r) return;
int pivot = arr[r], pos = l;
for(int i=l ; i<r ; i++)
if(arr[i] < pivot){
int tmp = arr[i];
arr[i] = arr[pos];
arr[pos++] = tmp;
}
int tmp = arr[pos];
arr[pos] = pivot;
arr[r] = tmp;
QuickSort(l, pos-1);
QuickSort(pos+1, r);
}
int main(){
QuickSort(0, n-1);
}
每次都要刻一個排序出來很煩
怎麼辦?
C++ 都幫你想好惹
std::sort()
#include <algorithm>
using namespace std;
sort( begin, end ); // [begin, end)
#include <iostream>
#include <algorithm>
using namespace std;
int arr[1010];
int main(){
sort(arr, arr + 100); // [0, 99]
sort(arr, arr + 101); // [0, 100]
sort(arr + 3, arr + 87); // [3, 86]
}
期望複雜度 \(\mathcal{O}(n \log n)\)
自訂義排序!
剛剛的排序都是由小到大,怎麼可能那麼單純
假設我要從大排到小
bool cmp();
sort(begin, end, cmp);
#include <iostream>
#include <algorithm>
using namespace std;
bool cmp(int a, int b){
// 前面 後面
return a > b;
}
int arr[1010];
int main(){
sort(arr, arr+1010, cmp);
}
更簡潔的寫法
簡稱毒瘤
#include <iostream>
#include <algorithm>
using namespace std;
int arr[1010];
int main(){
sort(arr, arr+1010, [](int a, int b){return a > b;});
}
在cmp的位置改成[](){}
練習題
-
ZJ a104 裸排序
-
APCS / ZJ b966 一維線段覆蓋長度
-
APCS / ZJ c471 物品堆疊
二分搜
猜數字遊戲>///<
假設我從\(1\)~\(1000\)選一個數字,
你每次猜一個數字,而我回答比答案大or小
直到猜中答案
怎麼樣才是最好的猜法? 猜最少次?
猜法比較
假如亂猜,從1猜到1000,要猜1000次
但如果每次從中間猜
1~1000: 500, 大, 500~1000: 750, ...
最多只需要猜 \(\log_2 1000 \approx 10\) 次
在由小到大的數列上做搜尋,
具有單調性 (monotone),便是二分搜的核心
直接來看題目吧
(  ̄▽ ̄)σ
ZJ d732 - 二分搜尋法
給一個序列 \(a\),
\(K\) 次詢問數字 \(x\) 存不存在於這個序列?
\(\mathcal{O}(K \log N)\)
TIOJ 1044 - 猜數字
[本題為互動題]
電腦隨機生成一個數 \(K\) 介於 \(1\)~\(N\),
每次可以詢問一個數 \(Q\),電腦會告訴你
「\(Q\) 小於 \(K\)」或「\(Q\) 不小於 \(K\)」,
一開始先給定 \(N\),最後回答 \(K\) 的數值
\(\mathcal{O}(\log N)\)
ZJ b844 - 一堆按鈕
(挑戰) 給你一堆按鈕編號 \(1\) ~ \(2^{31}-1\),每個按鈕旁一開始都顯示數字 \(0\),當按下第 \(K\) 個按鈕可以讓編號 \(K\) 後(包含)的數字 \(1\) 變 \(0\),\(0\) 變 \(1\),經過 \(N\) 次操作後有 \(Q\) 筆詢問,問編號 \(P\) 的按鈕的數字是多少?
\(N \leq 5\times 10^5,\) \(K < 2^{31},\) \(Q \leq 2\times 10^5\)
std::lower_bound()、std::upper_bound()
\(\mathcal{O}(N\log N+Q\log C)\)
對答案二分搜
對於一些可以知道答案範圍的題目,
又有絕對的單調性,
那不就可以在答案範圍內做二分搜?
很抽象吧直接看題目辣
APCS / ZJ c575 - 基地台
給 \(N\) 個電信公司需要服務的據點的座標 \(x_i\),並且最多可以架設 \(K\) 個基地台在任一座標位置,每個基地台服務的半徑範圍皆一樣,求半徑至少為多少可以覆蓋所有據點?
\( 1 \leq K < N \leq 50000, \ 0 \leq x_i \leq 10^9\)
我們已經知道至少和至多需要多長的半徑,
那就二分搜可能的答案並驗證它
ZJ c250 - (題目名稱沒意義)
肝它題序至少有3000字吧...
(超級挑戰)
給你 \(N\) 個數字
求出任兩數字間的第 \(K\) 大差
\(N \leq 32768, \ K \leq \frac{N(N-1)}{2}\)
TIOJ 1208 - 第K大連續和
484跟上面那題很像呢w
(超級超級挑戰)
給你 \(N\) 個數字,求第 \(K\) 大的連續和
連續和定義為任選 \([L, R]\) 的區間總和
\(N \leq 20000, \ K \leq \frac{N(N-1)}{2}\)
這題要用到分治(Divide & Conquer)的做法,
有機會之後再說(x)
延伸:三分搜
演算法概述
By youou
演算法概述
- 421