演算法概述

建國中學 游承曦

給你一個問題

數學上

程式上

計算特定數值下的正確答案

或證明

設計出一套解決問題的流程

能算出該問題在各種情況下的解

為什麼用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. 將牌洗一洗再隨便擺出來
  2. 若尚未排序好,回到1.
  3. 完成

正確性(X)

效率(X)

嚴謹的作法

  1. 每次挑選手上最小的牌
  2. 若桌上沒有牌就直接放,有牌就放到最左側
  3. 若手還有牌,回到1.
  4. 完成

時間複雜度

判斷一個演算法的效率

  • 對於兩個函數 \(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. 每次挑選手上最小的牌
  2. 若桌上沒有牌就直接放,有牌就放到最左側
  3. 若手還有牌,回到1.
  4. 完成

計算執行次數

  • 第一次檢查 \(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