演算法介紹
複雜度分析
一連串指令用以解決特定問題
aka. 解決問題的思維
ex:最大公因數的短除法和輾轉相除法流程不一樣
哪個跑起來比較快結束?
(N是一個任意自然數)
int d = 0;
for(int i = 0; i < n; ++i){
d = d + 1
}#1
int d = 0;
for(int i = 0;i < n; ++i){
for(int j = 0;j < n;++j){
d = d + 1;
}
}#2
| n=10 | N=100 | N=1000 | N=10000 | |
|---|---|---|---|---|
| #1 | 10 | 100 | 10^3 | 10^4 |
| #2 | 100 | 10000 | 10^6 | 10^8 |
...
只要N大了,實際運算量就差很多
第一個程式,迴圈跑N次
第二個程式,迴圈跑N次,而每一次裡面都要再跑N次
而電腦每1秒平均可以跑10^8次個運算
而要表達程式的效率,我們就需要定義程式的複雜度
作為時間複雜度的標記
若運算增長的函式為f(x),則記為O( f(x) )
性質:
1. Big-O代表一個上界,忽略比較慢的增長函式 。ex: 如果一個程式一部分的增長是n,另一部分的是n^2,則仍記為O(n^2)。
2. 如果某個數字對其增長幅度沒有引響,則不計在Big-O裡面(俗稱的常數)。ex: n, 2*n, 100*n, n+1000 增長幅度都是一樣的,為線性,所以都記為O(n)。
"如果某個數字對其增長幅度沒有引響,則不計在Big-O裡面"。
忽略掉的就是所謂常數。雖然在帳面上常數不存在,但實際上是有差的,比如O(1000*n)跟O(n)就差了1000倍。
ex: 取餘數%和加法雖然都是O(1),不過取餘數的常數比加法大了許多
估算記憶體運用情形
實際可以用的記憶體也是有限的,所以空間複雜度也不能太差
性質和運算方法跟時間複雜度大同小異,把宣告過的變數加一加就可以算了
credit: ioncamp
讓凌亂的數列經過排序後呈現遞增或遞減
選擇排序
不停找最大值
時間複雜度O(n^2)
14
20
3
32
27
14
20
3
27
32
14
20
3
27
32
14
3
20
27
32
3
14
20
27
32
泡沫排序
讓最大值慢慢浮出來
時間複雜度O(n^2)
32
20
3
14
27
20
32
3
14
27
20
3
32
14
27
20
3
14
32
27
20
3
14
27
32
快速排序
從數列中選出一個標準
將數列依標準分成兩半
時間複雜度最好O(nlogn)
最壞O(n^2)
32
3
14
20
27
3
32
20
27
原數列
第一次分割
32
27
14
20
14
3
第二次分割
3
14
20
27
32
第三次分割
3
14
20
27
32
排序完成
合併排序
將數列不停一分為二
求出兩半以排序好的序列,再把兩半合併成另一個排序好的序列
時間複雜度O(nlogn)
32
20
3
14
27
32
20
3
14
27
32
20
3
14
27
3
14
20
27
32
14
27
3
14
20
32
20
32
3
14
27
3
14
20
7
8
50
sorted:
個設定一個指針,代表目前填到哪
3
7
8
14
20
50
補充
終極密碼
出題者心中想一個數字,而玩家每次猜一個數字,出題者回答答案是大於還是小於玩家的猜測,直到玩家猜到答案為止。
玩家每次猜測其實就是在縮小範圍,而這就是二分搜
假設目前所知答案的區間在[l, r],每次詢問中間的數,也就是mid = (l+r)/2。
如果大於,把目前所知區間修改為[mid, r],因為l~mid都小於等於mid,所以可以忽略
而如果小於就變成[l, (l+r)/2],同樣原因
複雜度:O(log n)
原因:假設原本區間有n個數,每次把區間砍半是n/2,再砍半是(n/2)/2,以此類推,直到長度剩1。
給一個已經排序好的數列,問數字k是否存在於數列裡
一個一個找:O(n),還可以更好
二分搜:每次往中間的數找,如果等於k就回傳true,而如果中間大於k,就代表在數列左半邊,小於則在右半邊。
https://algorithm-visualizer.org/branch-and-bound/binary-search
如果剛剛的數列不是排序好的,可以二分搜嗎?
因為每次取中間的值的時候,不能預測左邊一定小於中間,也不知道右邊一定大於中間,所以不能直接縮小範圍
簡言之,對一個東西二分搜(不一定是數列),要符合單調性,也就是在連續區間上只會往同一個趨勢變化
不行
二分搜不一定要在數列上,任何有單調性的詢問也可以套用
ex;假設某問題中,如果答案是k時符合條件,而且答案是>k時也符合條件,就有單調性。(跟數列上一樣的事情)
ex:基地台
簡單作法:從小到大枚舉基地台直徑,並檢查這個直徑能不能完整
直線上有N個服務點,每架設一座基地台可以涵蓋直徑R範圍以內的服務點。
輸入服務點的座標位置以及一個正整數K,請問:在k座基地台的直徑皆相同的條件下,基地台最小直徑R為多少才能使的N個服務點皆被覆蓋到?
觀察:直徑越長,其涵蓋越多位置,也就越可能涵蓋所有服務點
換句話說:如果長度為r的直徑可以讓k個基地台覆蓋所有服務點,那直徑>r同樣可以
單調性!
二分搜:如果直徑mid可以,那就代表mid跟>mid的直徑都符合,也就是說>mid都一定不會是最小符合的直徑,所以可以把右界縮小到mid。
至於要怎麼檢查mid是否能覆蓋所有基地台,會用到後面講到的貪婪演算法
實作方法很多,以下提供我常用的
check()函數是檢查mid這個值符合還不符合條件(ex: mid小於答案就回傳false)
int binary_search(){
int l = 0;
int r = n;
while( l < r-1 ){
int mid = ( l + r ) / 2;
if(check(mid)){
r = mid;
}
else{
l = mid;
}
}
return l;
}
//laialanorz二分搜運用在一個呈現單調成長的序列上
但是如果要在呈現類似二次函數的序列上找最大(小)值呢?
目標(最小的f(x))
一樣設定一個左右界線L, R
示意:
取兩個中間值(一個中間靠右一個中間靠左):mL, mR
考慮f(x)中,f(mL), f(mR)的大小關係
if f(mL)>f(mR)
兩種情況:
f(mL)
f(mR)
f(mL)
f(mR)
可以發現,最低點不可能出現在<ml的地方,因次可以把L=mL
if f(mL)<f(mR)
兩種情況:
f(mL)
f(mR)
f(mL)
f(mR)
最低點不可能出現在>mR的地方,因次可以把R=mR
怎麼取mL, mR?
不拘(不一定要三等分)
ex:
mR=(L+R)/2
mL=(L+mR)/2
複雜度:O(log n)
每次縮小範圍接近二分之一,所以乃為log n,但常數比二分搜大
long double tenary_search(){
long double l = -1e9, r = 1e9;
while(r-l>1e-9){
ll mr = (l + r) / 2.0;
ll ml = (l + mr) / 2.0;
if(check(ml) > check(mr)){
l = ml;
}
else{
r = mr;
}
}
return l;
}函式裡面自己呼叫自己
ex: 費式數列 f(x)=f(x-1)+f(x-2)
int fib(int x){
if(x == 1) return 1;
else if(x == 2) return 1;
return fib(x-1) + fib(x-2);
}遞迴通常包刮:
1. 把問題拆解
2. 每個拆解後的問題處理方法都類似
公式:
每一個小問題公式都一樣,所以用遞迴很容易
int gcd( int a, int b ){
if(b == 0)
return a;
return gcd( b, a%b );
}c++(gcc)內建公式:__gcd(a, b)
觀察:如果要把最下面的盤子移動到C,那必須打C上面的盤子都先移動到B
步驟:
1.把前n-1個盤子都移動到B
2. 編號n從A移動到C
3. 前n-1個從B移動到C
把A, B, C想像成:起點,備胎,目標
函式:solve(移動數量,起點,備胎,目標)
操作:
solve(移動數量-1,起點,目標,備胎)
移動n到目標
solve(移動數量-1,備胎,起點,目標)
solve(3, a, b, c)
call stack
solve(2, a, c, b)
solve(1, a, b, c)
solve(1, c, a, b)
solve(2, b, a, c)
solve(1, b, c, a)
solve(1, a, b, c)
void solve(int n, char a, char b, char c){
if(n==0) return;
solve(n-1, a, c, b);
printf("move ring %d form %c to %c", n, a, c);
solve(n-1, b, a, c);
}
int main(){
int n;
n=4;
solve(n, 'a', 'b', 'c');
}當遞迴途中狀況已經不可能達成題目要求
就直接return
分而治之
1. 分成小問題
2. 解決小問題
3. 合併小問題
通常用遞迴實作
合併排序即為分治演算法應用
不斷把數列一分為二,對排序好的兩半,可以O(n)合併為一個完整排序好的數列
對於一 般問題,複雜度通常為:O(log n*合併複雜度)
給你一個序列,請求出最大的連續元素的和
ex: 1 2 -3 4 5,答:4+5=9
把序列切兩半,最大連續和可能出現在左邊,右邊,或橫跨左右兩邊
左右兩邊的答案都可以遞迴解決,而算橫跨的部分只需要從中間分邊往左右兩邊跑,紀錄左右兩邊過程中最大直,並加起來,
複雜度:往左右兩邊跑為O(n),所以總共O(n logn)
dynamic programming
複習:費式數列
f(n) = f(n - 1) + f(n - 2)
ex:直接遞迴求解f(4)
f(4)
f(3)
f(2)
f(2)
f(1)
f(1)
f(0)
f(1)
f(0)
總共遞迴了9次
f(2)被重複算到2次
粗估時間複雜度O(2^n)
把算過的答案存起來
f[n] = f[n - 1] + f[n - 2]
f(0)
f(1)
f(3)
f(2)
f(4)
總共計算了5次
時間複雜度為O(n)
dp的特性
好像跟分治好像?
主要差異:子問題會被重複利用,ex: f(x)會被f(x+1)和f(x+2)利用
dp的思路
走樓梯
一段樓梯總共有n階
每次可以踏1階或2階
請問有幾種方法可以走到第n階?
ex:
n=1,只能走1階,方法數為1
n=2,走兩次1階,直接走2階,方法數為2
n=3,走三次1階,先1階再2階,先2階再1階,方法數為3
走樓梯
一段樓梯總共有n階
每次可以踏1階或2階
請問有幾種方法可以走到第n階?
如何定義問題?
d[i]為走到i階的方法數
d[i]如何從d[0], d[1]...d[i - 1]得到答案?
每次只能走1階或2階
假如在第i階
只有可能從第i - 1階或第i - 2階走到
走到第i階的方法數
就是走到第i - 1階+走到第i - 2階的方法數
也就是說,d[i] = d[i - 1] + d[i - 2]
結論
邊界為d[1] = 1、d[2] = 2
轉移式為d[i] = d[i - 1] + d[i - 2]
時間複雜度為O(n)
等等,是不是在哪裡看過
沒錯他就是費式數列
f(n) = f(n - 1) + f(n - 2)
表演酬勞
一名表演者總共有n個表演邀約
每天剛好有1個邀約,每個邀約有不同酬勞
但因為表演很累,這名表演者無法連續表演兩天
請問這名表演者最多能拿到多少酬勞?
ex:
n=5
酬勞為1, 2, 3, 1, 5
選擇第1, 3, 5天表演能拿到最多酬勞為9
表演酬勞
一名表演者總共有n個表演邀約
每天剛好有1個邀約,每個邀約有不同酬勞
但因為表演很累,這名表演者無法連續表演兩天
請問這名表演者最多能拿到多少酬勞?
在計算d[i]時,有兩種可能的情況
於是d[i]只能從d[i - 2]或d[i - 1]求解
但別忘了第一種情況有選a[i]
所以d[i] = d[i - 2] + a[i]或d[i - 1]
而題目要求的是最大的酬勞
d[i] = max(d[i - 2] + a[i], d[i - 1])
結論
邊界為d[0] = a[0]、d[1] = max(a[0], a[1])
轉移式為d[i] = max(d[i - 1], d[i - 2] + a[i])
時間複雜度為O(n)
女裝
在總共n天的暑訓中
每天會從林O閎、何O甫、林O銓3人中選1人女裝
這3人在第i天女裝的美麗值分別為a[i], b[i], c[i]
但他們都不願意連續兩天女裝
請問最多的美麗值總合為多少?
ex:
n = 4
a = 1, 3, 4, 2,、b = 2, 4, 1, 5、c = 3, 1, 2, 6
第1天林O銓,第2天何O甫,第三天林O閎,第四天林O銓
能獲得最多美麗值為17
題目純屬虛構,如有雷同純屬巧合
女裝
在總共n天的暑訓中
每天會從林O閎、何O甫、林O銓3人中選1人女裝
這3人在第i天女裝的美麗值分別為a[i], b[i], c[i]
但他們都不願意連續兩天女裝
請問最多的美麗值總合為多少?
如何定義問題?
定義d[i]為0~i天最多的美麗值
那麼轉移式是d[i] = d[i - 1] + max(a[i], b[i], c[i])?
題目要求不能連續2天同1人女裝
這樣的轉移式是錯的
改變一下定義
定義d[i][j]為0~i天能獲得的最大美麗值
且第i天選擇了第j個人女裝
總共3個人,j有0, 1, 2三種狀況
假設第i天選了第0個人
第i - 1天只能選第1或2人
轉移式為
結論
邊界為d[0][0] = a[0]、d[0][1] = b[0]、d[0][2] = c[0]
轉移式有三條
時間複雜度為O(n)
拾金不昧
你居住的城市是由n * m個街區構成的矩形
為了充實口袋,咳咳,是拾金不昧
你每天都要從城市的左上角走到右下角
而你只能選擇往右或往下走
a[i][j]代表你在座標(i, j)能獲得的錢
在街區中也有一些不法之徒打算搶劫你的錢
因此a[i][j]有可能是負數
ex:
n = 3, m = 3
2 -2 3
-6 5 2
-3 7 4
最大能獲得的錢為2 + (-2) + 5 + 7 + 4 = 16
如何定義問題?
d[i][j]代表走到在座標(i, j)的最多錢
d[i][j]如何從d[0][0], dp[0][1] ...得到答案
還記得爬樓梯嗎
爬到第n步必定從第n - 1或n - 2步
跟這題一樣
每次只能往右或往下
座標(i, j)必定從(i - 1, j)或(i, j - 1)走到
d[i][j]能拿到的最多錢
就是max(d[i - 1][j], d[i][j - 1]) + a[i][j]
那邊界呢?
d[0][0] = a[0][0]
d[0][1] = max(d[-1][1], d[0][0]) + a[0][1]?
陣列的index不能放負數
那就直接無視他
d[0][1] = d[0][0] + a[0][1]
也就是說d[0][0~j] = a[0][0~j]
結論
邊界d[0][0~j] = a[0][0] + a[0][1]... + a[0][j]
d[0~i][0] = a[0][0] + a[1][0]... + a[i][0]
轉移式為d[i][j] = max(d[i - 1][j], d[i][j - 1]) + a[i][j]
時間複雜度為O(n*m)
例題
每次都做最好決定的演算法
aka.只有一個轉移點的動態規劃
要如何判斷直徑mid是否覆蓋所有服務點?(基地台可以任意擺)
1. 排序服務點座標
2. 如果目前基地台直徑超過mid,就換下一個基地台(並且基地台數量+1)
3. 看用到的基地台數量是不是<=k
每次把基地台撐到最極限一定是最好的
給n線段[Li, Ri],問最多可以剩下幾個使得所有線段不相交
性質:當從左到右擺放線段的時候,如果兩個線段重疊,總是取右端點最小的線段一定最好
證明:右端點越大的話則會佔據越多之後能擺放的位置
根據這性質,作法就是:對線段右界排序,然後從左到右,如果線段的左界覆蓋左邊的線就拋棄
1. 找出一個固定走法
2. 證明其永遠不會比較差
c++內建函式庫,集結非常多好用資料結構和演算法實作
只要#include <bits/stdc++.h> 就會引入所有STL工具
函式設計為可以互相混用,讓運用非常有彈性,宣告方法也大致類似
常用STL函式
動態陣列(= c的malloc,可以隨時擴展陣列容量) 的實作,並可配合許多其他STL函式操作
宣告:vector<TYPE> NAME(SIZE, INIT);
尾端新增:NAME.push_back(VALUE);
隨機存取:NAME[INDEX]
大小:NAME.size()
O(nlogn)排序
小到大:
sort(起點,終點);
大到小:
sort(起點,終點,greater<TYPE>());
如果用vector:起點=NAME.begin(),終點=NAME.end()
一般陣列:起點=NAME,終點=NAME+長度
stack資料結構實作
stack性質:先進後出(如圖)
宣告:stack<TYPE> NAME;
新增:NAME.push(VALUE);
刪除:NAME.pop();
頂端直:NAME.top()
大小:NAME.size()
基本上用vector是一樣的事
像排隊一樣
先進先出
宣告:queue<TYPE> NAME;
新增:NAME.push(VALUE);
刪除:NAME.pop();
前端值:NAME.front()
尾端值:NAME.back()
大小:NAME.size()
優先度最高的會在頂端,heap實作
刪除,新增:O(log n)
取值:O(1)
宣告:priority_queue<TYPE> NAME;(值最大在頂端)
priority_queue<TYPE,vector<int>,greater<int>> NAME(最小)
新增:NAME.push(VALUE);
刪除:NAME.pop();
頂端值:NAME.top()
大小:NAME.size()
自動排序且不重複的集合,紅黑樹(平衡二元樹)實作
複雜度皆為log n
宣告:set<TYPE>NAME;
新增:NAME.insert(VALUE);
刪除:NAME.erase(VALUE);
找第一個大於等於:NAME.lower_bound(VALUE)
大小:NAME.size()
由一個關鍵字和對應的值組成
複雜度皆為log n
宣告:map<TYPE,TYPE>NAME;
新增:NAME.insert({VALUE,VALUE});
刪除:NAME.erase(VALUE);
找第一個大於等於:NAME.lower_bound(VALUE)
大小:NAME.size()
對陣列二分搜的實作
lower_bound:找出陣列中"大於或等於"val的最小值的位置
auto it = lower_bound(v.begin(), v.end(), val);
upper_bound:找出vector中"大於"val的最小值的位置:
auto it = upper_bound(v.begin(), v.end(), val);