:+1: [2020 競技程設教材 HackMD Book](https://hackmd.io/@nckuacm/ryLIV6BYI) :+1: 2019 Week 2: Data Structures and Thought method = 接下來的章節,我們採用 **C++** 作為主要使用的程式語言,原因是相對於其他主流的高階程式語言(如: Python, JavaScript...),C++ 作為一個編譯語言有著相對快的執行速度,並且在一些細節操作上可以更加自由。不過在部分複雜度較低,或是時間限制較寬鬆的題目中採用 Python 解題也可以提升解題速度,這部分就交給讀者自行探索。 # Basic programming 在撰寫程式時常常會需要估算資料大小,以選擇最適合使用的資料型別,就算是身經百戰的選手也可能因沒有小心估算數值的範圍而導致 WA,以下給出一些 C++ 中的常用型別以及適合存放的資料大小(好用的估計值): `int x`: $|x| < 2 \times 10^9$ `long long x`: $|x| < 9 \times 10^{18}$ `float x`: 整數+小數部分共 6 位精確度 - 整數: 最大超過 $8 \times 10^{6}$ 後不精確 - 小數: 小數點下精度最大約 $6$ 位 `double x`: 整數+小數部分共 15 位精確度 - 整數: 最大超過 $4 \times 10^{15}$ 後不精確 - 小數: 小數點下精度最大約 $15$ 位 `double` 與 `float` 能提供的數值精確度是取決於儲存的數值的**整數+小數部分的位數**,也就是說在使用浮點數時必須在數字大小和小數精度中做抉擇,若是同時存放過大且對小數精確度要求極高的數值時,很有可能會出現誤差。 > 讀者如果對浮點數的實作以及精確值有興趣, > 可以自行翻閱 [IEEE 754](https://zh.wikipedia.org/wiki/IEEE_754) 浮點數規範 在第 15 週會討論到如何解決因精度不夠而產生的問題 #### 範例 [CODEFORCES 1106C Lunar New Year and Number Division](https://codeforces.com/contest/1106/problem/C) ```cpp #include <bits/stdc++.h> using namespace std; int const maxn = 3e5 + 10; int n, a[maxn]; int main() { cin >> n; for(int i = 0; i < n; i++) cin >> a[i]; sort(a, a+n); long long ans = 0, tmp; for(int i = 0; i < n/2; i++) tmp = (a[i] + a[n-i-1]), ans += tmp*tmp; cout << ans << endl; return 0; } ``` 學習重點: * 最多會有 $3\times 10^5$ 個數字,每個數字最大為 $10^4$,估計 `ans` 的最大值 $(2\times 10^4)^2\times (3\times 10^5)/2 = 6\times 10^{13}$,會超過 `int` 的範圍,所以改用 `long long` #### 範例 [CODEFORCES 1111A Superhero Transformation](https://codeforces.com/contest/1111/problem/A): ```cpp #include <bits/stdc++.h> using namespace std; bool vowel['z'+1] = {}; // vowel := 是否為母音 string S, T; int main() { vowel['a'] = vowel['e'] = vowel['i'] = vowel['o'] = vowel['u'] = true; cin >> S >> T; bool ok = true; if(S.size() != T.size()) ok = false; for(int i = 0; i < S.size(); i++) if(vowel[S[i]] != vowel[T[i]]) ok = false; cout << (ok? "Yes" : "No") << endl; return 0; } ``` 學習重點: * a 的 ascii 是 97,所以在 C++ `'a'` 與 `97` 是相同的 * `bool vowel['z'+1] = {}`:後面使用一個空大括號,可以初始化陣列值為`0`。 * `vowel[S[i]] != vowel[T[i]]`:使用 vowel 陣列判斷字母是否為母音。 * 三元運算子(`?:`)使用恰當能使程式碼變得乾淨許多 #### 範例 [UVa OJ 232 Crossword Answers](https://uva.onlinejudge.org/external/2/232.pdf): 先將在 $(r, c)$ 上的編號 `flag[r][c]` 求出來 接著直接分別由上到下由左至右將 word 輸出 ```cpp #include<cstdio> int const maxn = 20; int r, c, flag[maxn][maxn]; char puz[maxn][maxn]; bool across_head(int i, int j) { return !j || puz[i][j-1] == '*'; } bool down_head(int i, int j) { return !i || puz[i-1][j] == '*'; } void across_print() { for(int i = 0; i < r; i++) for(int j = 0; j < c; j++) { if(across_head(i, j) && puz[i][j] != '*') printf("\n%3d.", flag[i][j]); if(puz[i][j] != '*') putchar(puz[i][j]); } } void down_print() { for(int i = 0; i < r; i++) for(int j = 0; j < c; j++) if(down_head(i, j) && puz[i][j] != '*') { printf("\n%3d.", flag[i][j]); for(int k = i; puz[k][j] != '*' && k < r; k++) putchar(puz[k][j]); } } bool flag_position(int i, int j) { return (across_head(i, j) || down_head(i, j)) && puz[i][j] != '*'; } int main() { int kase = 0; while(scanf("%d%d", &r, &c) && r) { for(int i = 0; i < r; i++) scanf("%s", puz[i]); //preprocess int n = 0; for(int i = 0; i < r; i++) for(int j = 0; j < c; j++) if(flag_position(i, j)) flag[i][j] = ++n; else flag[i][j] = 0; //print answer if(kase) putchar('\n'); printf("puzzle #%d:\n", ++kase); printf("Across"); across_print(); putchar('\n'); printf("Down"); down_print(); putchar('\n'); } return 0; } ``` 學習重點: - 複習一點 index 的用法 - 條件判斷頗長,給它取個名字然後呼叫它,可增加可讀性 - 別總是一氣呵成,分幾次做通常(?)會比較好做 # Basic STL 介紹 STL 全名 Standard Template Library 由容器 (containers)、迭代器 (Iterators)、演算法 (Algorithms)、函式 (Functions) 4 種元件組成。 接下來的幾週課程會陸續介紹幾個常用的 STL 裡的容器及函式 絕大部分 STL 的東西只要涉及區間的操作,==區間表示一律為左閉右開==[^start_at_zero] 推薦的參考網站: [cplusplus.com](http://www.cplusplus.com)、[C++ reference](https://en.cppreference.com) ## vector vector 跟一般在使用的陣列很像,最大的特色就是他的儲存空間是動態增減的 ### `std::vector` ```cpp #include <vector> using std::vector; ``` #### 宣告: `vector<T> v`: `v` 為空的 vector,且各元素型別為 `T` `vector<T> v(size_type a)`: `v` 長度為 `a` `vector<T> v(size_type a, const T& b)`: `v` 會被 `a` 個 `b` 填滿 #### 函式: `v.size()`: `v` 目前的長度 `v.push_back(T a)`: 在 `v` 的結尾加一個 `a` `v.pop_back()`: 刪除 `v` 的最末項[^pop_back] `v.empty()`: 是否 `v` 為空 ```cpp int a; do { cin >> a; v.push_back(a); } while (a); cout << v.size() << '\n'; v.pop_back(); v.pop_back(); cout << v.size() << '\n'; cout << (v.empty()? "YES" : "NOT EMPTY"); ``` ``` > 5 2 8 9 1 2 0 < 7 < 5 < NOT EMPTY ``` `v[i]`: `v` 的第 `i` 項 `v.clear()`: 清空 `v` `v.resize(size_type a)`: 將 `v` 的長度條至 `a` ```cpp v.resize(4); v.resize(8, 100); v.resize(12); for (int i = 0; i < v.size(); i++) cout << v[i] << ' '; cout << '\n'; v.clear(); cout << "size = " << v.size(); ``` ``` < 5 2 8 9 100 100 100 100 0 0 0 0 < size = 0 ``` `v.begin()`: v 的起始地址 `v.end()`: v 的末端地址 + 1 (由於左閉右開) `v.assign(InputIterator l, InputIterator r)`: 將指標 `l` 到 `r` 的內容覆蓋至 `v` `v.assign(size_type n, const value_type& val)`: 覆蓋 `n` 個 `val ` 至 `v` ```cpp v.assign(7, 100); // 7 ints with a value of 100 cout << "#1: " << v.size() << '\n'; vector<int>::iterator it = v.begin() + 1; v.assign(it, v.end()-1); cout << "#2: " << v.size(); ``` ``` < #1: 7 < #2: 5 ``` ## string 字串 (string),顧名思義是很多個字它們串在一起 例如: "stringisastring",就是一堆字 's', 't', 'r', 'i', 'n', 'g', 'i', ..., 'g' 串在一起。 字串可像序列一樣表示, 例如長度為 $n$ 的字串 $A$ 為: $A_1, A_2, ... , A_{n-1}, A_n$ 字串的問題與序列問題的區別在於字串通常探討的是字元前後**連續**的特性。 ### `std::string` string 的用法很像 `vector<char>` 但多了一些優化,以及功能、且 **`vector<char>` 有的 `string` 都有** ```cpp #include <string> using std::string; ``` 若 `t` 是 `string` 或 [C style string](http://archive.oreilly.com/oreillyschool/courses/cplusplus1/cplusplus106.html) 則: #### 運算: `s = t`: 會將 `t` 複製給 `s` ` s += t`: 在 `s` 的尾端接上 `t` #### 函式: `cin >> s`: 輸入字串至 `s` `cout << s`: 輸出 `s` `getline(cin, s, char c)`: 輸入字串至 `s`,直到讀到 `c`。`c` 預設為 `'\n'` ```cpp getline(cin, s, '\n'); t = " and"; // t is a string now s += t; s += " carry on"; cout << s; ``` ``` > keep calm < keep calm and carry on ``` `s == t`: 是否 `s` 跟 `t` 相同 `s.c_str()`: 回傳 `s` 的 C style string ```cpp char cstr[100]; strcpy(cstr, s.c_str()); cstr[8] = 'l'; cstr[9] = '\0'; cout << cstr << '\n'; cout << (cstr == s? "YES" : "NO"); ``` ``` < keep call < NO ``` #### 範例 [UVa 101 The Blocks Problem](https://uva.onlinejudge.org/external/1/101.pdf): >挺複雜的題目 將 4 道指令拆解成兩種指令的合成,也就是 `return_above` 以及 `move_to` - `return_above(a)`: 將特定 block `a` 上方的 blocks 全數返回到原來的位置。 - `move_to(a, b)`: 將 block `a` 及其上所有 blocks 移動到 block `b` 所在的位置最上方。 宣告 `vector<int> pile[maxn];` 以 `pile[p]` 紀錄位置 `p` 上的所有 blocks (從 0 開始由下而上) ```cpp #include<iostream> #include<string> #include<vector> using namespace std; const int maxn = 26; int n, pos[maxn]; // pos := position vector<int> pile[maxn]; int height(int obj) { int i = 0, p = pos[obj]; for(; pile[p][i] != obj; i++); return i + 1; } void return_above(int obj) { int p = pos[obj], h = height(obj); for(int i = h; i < pile[p].size(); i++) { int w = pile[p][i]; // w := wood pile[w].push_back(w); pos[w] = w; } pile[p].resize(h); } void move_to(int a, int b) { int ap = pos[a], bp = pos[b]; int ah = height(a); for(int i = ah-1; i < pile[ap].size(); i++) { int w = pile[ap][i]; // w := wood pile[bp].push_back(w); pos[w] = bp; } pile[ap].resize(ah-1); } int main() { cin >> n; for(int i = 0; i < n; i++) { pos[i] = i; pile[i].push_back(i); } int a, b; string c1, c2; while(cin >> c1 && c1 != "quit") { cin >> a >> c2 >> b; if(pos[a] == pos[b]) continue; if(c1 == "move") return_above(a); if(c2 == "onto") return_above(b); move_to(a, b); } // output for(int i = 0; i < n; i++) { cout << i << ":"; for(int j = 0; j < pile[i].size(); j++) cout << " " << pile[i][j]; cout << endl; } return 0; } ``` 學習重點: - 長度(或高度) 與最後一位 index 之間的關係 (尤其從 0 開始數) - 化繁為簡,將重複性高的功能抽象(函式化)出來 ## sort 將一堆元素由"*給定規則*"排成一[順序](https://en.wikipedia.org/wiki/Order_theory),稱為排序 (sort)。 例如: `5, 6, 9, 8, 2` 這五個元素由小到大排為 `2, 5, 6, 8, 9` `a, bc, aa, ay` 這四個元素由字典順序排為 `a, aa, ay, bc` ### `std::sort` STL 的 `sort` 裡有複雜的優化,**預設**將容器元素由==小排至大== ```cpp #include <algorithm> using std::sort; ``` 假設有如下資料結構: ```cpp struct T { int val, num; }; vector<T> v; ``` 若想依 `num` 對 `v` 做排序,需寫**比較**函數 比較函數是在定義"**小於**"**運算**: ```cpp bool cmp(const T &a, const T &b) { return a.num < b.num; } ``` > 或將小於運算子 (`operator<`) 重載也能達到一樣的效果 將 `cmp` 做為參數: ```cpp sort(v.begin(), v.end(), cmp); ``` 當然也可以直接把匿名函數寫進去: ```cpp sort(v.begin(), v.end(), [](T a, T b) { return a.num < b.num }); ``` 順帶一提, 若把小於行為內部定義為"大於": ```cpp [](T a, T b) { return a.num > b.num } ``` 則排序為從大至小。 #### 範例 [CODEFORCES 1114B Yet Another Array Partitioning Task](https://codeforces.com/contest/1114/problem/B): > 乍看之下有夠難欸 仔細觀察發現, $k$ 個切出來的 subarray 中最大的 $m$ 個元素,收集起來恰好就是原 array 中前 $k\times m$ 大的元素 所以將它們加起來就能得到所有 beauty 的總和 ```cpp #include<bits/stdc++.h> using namespace std; int const maxn = 2e5 + 100; int n, m, k, a[maxn]; vector<int> idx; int main() { scanf("%d%d%d", &n, &m, &k); for(int i = 0; i < n; i++) { scanf("%d", &a[i]); idx.push_back(i); } long long sum = 0; sort(idx.begin(), idx.end(), [&](int x, int y) { return a[x] > a[y]; }); for(int i = 0; i < m*k; i++) sum += a[idx[i]]; printf("%lld\n", sum); : . ``` 因為要求輸出間隔的 index 位置,所以我們需要原本的 index 位置 將 `idx` 再排序一遍,就能穩穩輸出來了 :+1: ```cpp : . sort(idx.begin(), idx.begin() + m*k); for(int i = m-1; i < m*(k-1); i+=m) printf("%d ", idx[i]+1); putchar('\n'); return 0; } ``` 學習重點: - 凡事先試試 sort,想想有什麼好處,通常狀況只會變好 - `sort` 複雜度 $O(N\log_2 N)$,大部份競賽中並不會比 $O(N)$ 壞很多 - `for` 迴圈中大於 $1$ 以上的累加 # 演算法的效率 >演算法的設計,得建立在資料結構之上,並評估**時間**與**空間**上的效率 題目給定輸入規模通常很大,2 倍、3 倍、甚至 10 倍的常數倍優化其實不是競賽時考慮的要點。 我們所設計的演算法必須根據輸入規模 $N$ 而定。 * Big $O$ 表示法 $f(N) = O(g(N)) \iff \exists N_0,c>0. \forall N>N_0.|f(N)|\leq c\cdot |g(N)|$ 意思是說在 $N$ 足夠大的時候,已經存在正數 $c$ 使得 $c\cdot |g(N)|$ 大於 $|f(N)|$ 或是說 $g(N)$ 趨近無窮的成長速度不比 $f(N)$ 來得慢 例如: $f(x)=x^2 + x + 1$ 在 $x$ 很大的時候,主要影響整個函數值的大小是平方項,這時可以說 $f(N) = O(N^2)$[^1] 假設輸入規模為 $N$,常見的複雜度有: $O(1)\leq O(\log_2N)\leq O(N)\leq O(N\log_2N)\leq O(N^k)\leq O(k^N)\leq O(N!)\leq O(N^N)$ > $k$ 為不受輸入規模影響的常數 ## 合理的複雜度 記憶體空間的用量在各個比賽中規範都不相同 但有些基本的考慮因素,例如 遞迴深度,使用的變數多寡,程式碼長度[^sloc] 而普遍上題目都會以秒為單位去做時間限制 (e.g. 3 秒、10 秒) 但通常我們會直接考慮輸入資料的規模與計算出來的複雜度 有個傳統(?)的範圍:$10^7$ 左右 > 這只是大約的估計範圍 假設輸入規模為 $N$,演算法的複雜度為 $O(N^2\log_2 N)$ 那麼需要滿足 $N^2\log N \leq 10^7$ 具體一點,上面如果 $N=10^5$,那你就得重新設計演算法了。 # 設計演算法的思維 這邊探討幾個常見去設計一個演算法的切入點 ## 考慮最大連續和問題 給定一個長度為 $N$ 的整數數列 $A_1, A_2, ... , A_N$,要求找到 $1 \leq i \leq j \leq N$,使得 $A_i+A_{i+1}+...+A_j$ 儘量大。 > 注意 數列中可能會有負數 ## 枚舉 所謂枚舉,通俗點說就是**數出**部份給定的集合中元素。 下面直接給出程式來解決最大連續和問題: ```cpp int best = A[1]; //與其用無限小,不如這樣初始化更不易出錯 for (int i = 1; i <= N; i++) { for (int j = i; j <= N; j++) { int sum = 0; for (int k = i; k <= j; k++) sum += A[k]; best = max(best, sum); } } ``` 通常我們假設簡單的 $1$ 道指令花費 $1$ 單位的時間 > 例如 算數 比較 宣告 判斷 等 > 而使用的函式需要看內部做了什麼才能估算時間 粗估一下可知道上面演算法的複雜度為 $O(N^3)$ #### 練習: [GCJ Kickstart Round G 2018 A Product Triplets](https://code.google.com/codejam/contest/5374486/dashboard#s=p0): Small dataset ## 動態規劃 部份朋友可能知道可以令 $S_i = A_1 + A_2 + ... A_i$ 而 $A_i+A_{i+1}+...+A_j = S_j - S_{i-1}$ 這樣子有了 $S_i$ 就可將連續和的計算從 $O(N)$ 降為 $O(1)$ 構造 $S_i$ 非常的直覺: ```cpp S[0] = 0; for (int i = 1; i < N; i++) S[i] = S[i-1] + A[i]; ``` 從邊界遞推地紀錄所有問題的解,且一個項用到前一項的**最佳**結果,就是動態規劃的精神。 而程式可改進為: ```cpp for (int i = 1; i <= N; i++) for (int j = i; j <= N; j++) best = max(best, S[j] - S[i-1]); ``` 複雜度降為 $O(N^2)$。 #### 練習: [CODEFORCES 1033C Permutation Game](https://codeforces.com/problemset/problem/1033/C) 第 9 週將繼續深入討論動態規劃 ## 貪心法 籠統的講,貪心法就是:每次做一個在**當下看起來**最佳的決策,進而漸漸求出全局最佳解 > 這種短視近利的心態,居然也是個不錯的思維( 貪心法是動態規劃的特例,上面動態規劃恰好可以用貪心的角度去看, 即 每次 $S_i$ 所遞推拿的項 $A_i$,正好不用任何考慮,疊上 $S_{i-1}$ 它就是解。 #### 練習: [ZEROJUDGE d652 貪婪之糊](https://zerojudge.tw/ShowProblem?problemid=d652) ## 分治法 分治 (divide & conquer) 簡稱 D&C,就是將一個大的問題,**分成**幾個互相*獨立*的子問題,然後再將子問題分成子子問題[^2],一直重複分割的動作,直到最小的問題足夠和別的最小問題**合併求解**出父問題。 將數列切一半,問左半的以及右半的最大連續和多少,以及問包含切開的那道分水嶺的最大連續和為多少,選出三者中最大值,它就是整個數列(原問題)的最大連續和: ```cpp int maxsum(int l, int r) { // 此為左閉右開區間 [l, r) if (r-l == 1) return A[l]; int m = (r+l)/2, sum, centre = A[m]; sum = 0; for (int i = m ; i < r; i++) centre = max(centre, sum += A[i]); sum = centre; for (int i = m-1; i >= l; i--) centre = max(centre, sum += A[i]); return max(centre, max(maxsum(l, m), maxsum(m, r))); } ``` 要驗證分治法的正確性,只需考慮子問題[^3]們解完後(假設已拿到解),再合併為父問題看是否解完即可,並考慮最小的孫子問題到的邊界是否正確。 [第六週教材](https://hackmd.io/s/rkIHhGZ_4#Merge-sort)的 Merge sort 也是一個不錯的分治法例子 [^sloc]: 有時會想試試不靠儲存空間,使用大量的判定輸出,此時程式碼長度的估算也得考量 [^1]: 注意這邊的符號($=$)與數學上的用法"[等價](https://en.wikipedia.org/wiki/Equivalence_relation)"意義不同。 [^2]: 子子問題就是指從子問題直接分割出來的更小子問題 [^3]: 子問題而非子子問題也非子子..子問題 [^pop_back]: 若 v 為空,會發生無法預期的結果 [^start_at_zero]: [Why numbering should start at zero](https://www.cs.utexas.edu/users/EWD/transcriptions/EWD08xx/EWD831.html)