## 定義 在生活中就有許多排序的例子,比如老師要求同學按照身高排隊,按照業績多寡評定優良員工等等 在程式中排序最重要的概念就是按照"特定條件"排好資料,在之後的演算法也可以看到排序的重要性 接下來就介紹幾種排序的做法,會從比較直覺(簡單)的做法到複雜的做法,資料都是 `a[0] ~ a[n-1]` 不過要強調,通常實作不需要自己寫排序演算法,而是寫前面說的"特定條件",然後用 sort() 排序還有一個比較需要考慮的點,就是時間跟空間複雜度 因為在大量資料的時候,效率跟花費還是比較需要在意的 ## 選擇排序法 (Selection Sort) 選擇排序就是找出還沒排序的目前最小值,然後將它移到前面 比如 $2,3,4,1$ 四個數字要用選擇排序,實際的作法就會像是下面這樣 最先找到最小值就是 $1$,把 $1$ 放到前面,會變成 $1,3,4,2$ 再來還沒排序過的數字剩下 $3,4,2$,最小值是 $2$,把 $2$ 放到前面,變成 $2,4,3$ 接著沒排序的數字剩下兩個 $4,3$,最小值是 $3$,把 $3$ 放到前面,變成 $3,4$ 最後數字就會變成 $1,2,3,4$,就這樣排完了,概念講述結束,接下來換實作 ```cpp= void selection_sort(int n) { for (int i=0;i<n-1;i++) { // 到 n-1 就可以了(因為最後一次排兩個數字) int min_idx = i ; // 最小值的 index for (int j=i+1;j<n;j++) if (a[j] < a[min_idx]) // 更小的值 min_idx = j ; if (min_idx != i) { // 交換數字位置 int tmp = a[min_idx] ; a[min_idx] = a[i] ; a[i] = tmp ; } } } ``` 跟前面的概念差不多,實作起來跟講解過程差不多 每次都花 $O(n)$ 的時間找最小值,找了 $n$ 次,時間複雜度是 $O(n^2)$ 空間複雜度除了原有的陣列就是一些變數而已,所以是 $O(1)$ ## 插入排序法 (Insertion Sort) 插入排序的概念比較像一排不同高度的積木分成兩組,一組是排好的一組是還沒排的 因為一開始都還沒排,所以排好的那組一開始沒有積木,當加入一個新的積木到排好的組別 就要將它插入到正確的位置,不過程式不是用插入,而是慢慢移過去,舉個例子 $4,2,3,1$ 用插入排序去做,那一開始就是先把 $4$ 移過去,沒排的剩下 $2,3,1$ $2,3,1$ 把 $2$ 移過去,排好的組別變成 $4,2$,沒排的剩下 $3,1$ 這時候因為排好的組別順序不對,所以要將 $2$ 插到最前面變成 $2,4$ $3,1$ 把 $3$ 移過去,排好的組別變成 $2,4,3$,沒排的剩下 $1$ 因為排好的組別順序不對,所以要將 $2$ 插到中間變成 $2,3,4$ 把最後的 $1$ 移過去,排好的組別變成 $2,3,4,1$ 因為順序不對,將 $1$ 插到最前面變成 $1,2,3,4$ 實際的作法只是把插入的概念變成慢慢從右邊往左移到正確位置 ```cpp= void insertion_sort(int n) { for (int i=0;i<n;i++) { // 需到 n (因為一次插一個數字) // 注意分組,排好的組別是 a[0] ~ a[i-1] // 需要插入的值是 a[i],未排序的組別為 a[i+1] ~ a[n-1] for (int j=i;j>=1;j--) { // 插入(慢慢移動) if (a[j-1] > a[j]) { // 需左移 int tmp = a[j-1] ; a[j-1] = a[j] ; a[j] = tmp ; } else break ; // 不須移動了,插入完畢 } } } ``` 插入過程最多跑 $O(n)$,共插入 $n$,時間複雜度最壞為 $O(n^2)$ 只有多了幾個變數,所以空間複雜度是 $O(1)$ ## 泡沫排序法 (Bubble Sort) 泡沫排序法跟上面兩個不太一樣,但是也是滿簡單就能理解的排序法 最主要的方式就是重複做"相鄰兩元素,大的放右邊,小的放左邊,放相反就交換" 但是這樣其實還不夠明確,因為沒有一個條件能讓我們知道甚麼時候該停止重複 所以泡沫演算法需要從稍微推一下,假設一組測資 $3,5,1,2,4,7,9$ 這樣 如果從頭跑一次,$3,5$ 比較,不用交換,$5,1$ 比較,要交換,$5,2$ 比較.... 透過這樣的方式,最後最大的數字會跑到最後方,那下次跑的時候就可以忽略最後方的元素 所以第一趟就排好最後一個元素,第二趟排好最後第二個元素,...,第 $n$ 趟排好第一個元素 實際上的程式碼會像是這樣 ```cpp= void bubble_sort(int n) { for (int i=0;i<n;i++) { for (int j=0;j<n-i-1;j++) { // 從最後一個、最後二個... if (a[j+1] < a[j]) { // 比較並交換 int tmp = a[j] ; a[j] = a[j+1] ; a[j+1] = tmp ; } } } } ``` 一次比較+交換跑 $O(n)$,共排了 $n$ 個元素,時間複雜度為 $O(n^2)$ 只有多了幾個變數,所以空間複雜度是 $O(1)$ ## 計數排序法(Counting Sort) 計數排序相較於前面的排序法差很多,因為前面都是用交換位置(對調)的概念 現在是用額外的空間去放數字,這個概念就是用空間換時間,也滿符合直覺的 計數排序法的實作很簡單,假設數字的範圍是 $0~K$,那我開一個大小 $K$ 的陣列 用來表示數字出現過幾次,最後在一次輸出所有的數字(出現過一次以上就照出現次數輸出) ```cpp= void counting_sort(int n, int K) { int cnt[K+1] ; // 數字範圍 0~K for (int i=0;i<=K;i++) // 初始化 cnt[i] = 0 ; for (int i=0;i<n;i++) cnt[a[i]]++ ; // 元素出現的數量 + 1 int cur = 0 ; // 現在是 a 中的第幾個位置 (a 的 index) for (int i=0;i<=K;i++) while (cnt[i]--) // 剩餘出現數量 > 0 a[cur++] = i ; // 放置 } ``` 計算元素出現次數 $O(n)$,依照出現次數放入陣列 $O(K)$,時間複雜度為 $O(n+K)$ 需要多一個陣列儲存出現次數,所以空間複雜度是 $O(K)$ 不過要注意的是,如果 $K$ 過大,空間跟時間的花費都會更多 ## 穩定排序 or 不穩定排序 所謂穩定的排序,就是在排序使用的"特定條件"相同時,排序前後位置關係相同 如果以分數作為排序條件,原本 $90$ 分的小美排在 $90$ 分的小王上面 穩定的排序後小美依然在小王上面,反之不穩定的排序則兩者順序不一定 但其實只要在陣列內的元素,所有不穩定的排序都可以轉化成穩定的排序 最關鍵的地方就是在排序條件上,只要排序條件加上 index 就可以了 ## 觀念總結 以上都是比較簡單的排序法,因為這篇主要是介紹排序的基礎概念,不會講太難的 另一方面排序本身都是搭配其他的演算法,純排序的概念用舉例的功效大於題目解說 如果再大部分的情況下,排序只需要用內建的 sort() 即可,通常需要寫的是排序條件 最後還是提供一些題目,來讓各位能夠用題目練習排序條件的做法 ## 例題-ZJ m931. 1. 遊戲選角 [題目連結](https://zerojudge.tw/ShowProblem?problemid=m931) 解題思路 : 有 n 個角色,每個角色有攻擊力和防禦力。 角色的能力值是攻擊力和防禦力的平方和,輸出能力值第二大的攻擊力和防禦力數值。 保證每個角色的能力值相異。 這題其實只要用 "能力值" 作為排序條件,然後排序後第二大的就是答案了 ```cpp= #include<bits/stdc++.h> using namespace std ; typedef pair<int, int> Pii ; #define FF first #define SS second bool cmp(Pii &x, Pii &y) { // 排序條件(能力值) return x.FF*x.FF+x.SS*x.SS < y.FF*y.FF+y.SS*y.SS ; } int main() { ios::sync_with_stdio(0), cin.tie(0) ; int n ; cin >> n ; vector<Pii> v(n) ; for (int i=0;i<n;i++) // 輸入 cin >> v[i].FF >> v[i].SS ; sort(v.begin(), v.end(), cmp) ; // 內建排序 cout << v[n-2].FF << ' ' << v[n-2].SS << '\n' ; // 第二大 return 0 ; } ``` ## 例題-ZJ d545. 2. 抽紙牌(poker) [題目連結](https://zerojudge.tw/ShowProblem?problemid=d545) 解題思路: 這題比較不同的地方就是要一次儲存兩個參數,排序時也需要同時比較這兩個參數 這裡用 STL 的 pair,或是用 Tuple,不過我這裡用的是 struct 原本花色的比較需要將字元轉成能比較的數字,但因為剛好這四個英文字母順序跟花色大小相同 所以比較字元直接相當於比較花色,不需要再特別去轉換 然後還有一個需要做的就是先比較數字再比較花色,其實就是數字相同時才比較花色 ```cpp= #include<bits/stdc++.h> using namespace std ; struct card { // 一張卡兩個資料 char color ; int num ; } ; card A[54] ; bool cmp(card x, card y) { // 排序條件 if (x.num == y.num) // 數字一樣才比較 return x.color > y.color ; // 比較花色 return x.num > y.num ; // 比較數字 } int main() { ios::sync_with_stdio(0), cin.tie(0) ; int n, m ; cin >> n ; for (int i=0;i<n;i++) // 輸入 cin >> A[i].color >> A[i].num ; cin >> m ; sort(A, A+n, cmp) ; // 排序 cout << A[m-1].color << ' ' << A[m-1].num << '\n' ; // card ans = *nth_element(A, A+m-1, A+n, cmp) ; // cout << ans.color << ' ' << ans.num << '\n' ; return 0 ; } ``` 不過之前有說過 nth_element() 的用法,在這篇也可以用 就不需要用到 sort 了 ## 例題-ZJ d150. 11369 - Shopaholic [題目連結](https://zerojudge.tw/ShowProblem?problemid=d150) 解題思路 : 這題是買二送一,分次結帳看最多可以省多少,直覺看來,分最多次就是三個三個一組 至於三個三個應該怎麼分,可以從較小的情況開始討論,先從六個開始討論 直接拿隨便三個去結帳,其中最小的價格就是省下的錢,那想要省下的錢越多 就是要讓三個中最便宜的價格盡量更大,想當然另外兩個會更貴 所以在還沒結帳的商品內,選出價格最高的三個,價格第三高的就是買二送一省下來的錢 總結就是挑價格從大到小第 $3n$ 個的商品($n \ge 1$) ```cpp= #include<bits/stdc++.h> using namespace std ; bool cmp(int &x, int &y) { // 由大到小排序 return x > y ; } int main() { ios::sync_with_stdio(0), cin.tie(0) ; int t ; cin >> t ; while (t--) { int n ; cin >> n ; vector<int> v(n) ; for (int i=0;i<n;i++) // 輸入 cin >> v[i] ; sort(v.begin(), v.end(), cmp) ; // 排序 int ans = 0 ; // 從第 3 個(index 2)開始,三個三個數 for (int i=2;i<n;i+=3) ans += v[i] ; cout << ans << '\n' ; } return 0 ; } ``` ## 例題-ZJ b162. NOIP2007 1.統計數字 [題目連結](https://zerojudge.tw/ShowProblem?problemid=b162) 解題思路 : 計算不同數字的出現次數,這裡需要用到一個排序後的特性 簡單來說就是排序後相同的數字會放在一起,比如 $1,2,2,2,3,3,4,5,5,5,5$ 那這時候從左到右跑,如果相鄰卻不同值表示遇到新的數字了 所以只要一邊紀錄目前數字出現幾次,一邊判斷是否需要更換數字 ```cpp= #include<bits/stdc++.h> using namespace std ; int main() { ios::sync_with_stdio(0), cin.tie(0) ; int n ; cin >> n ; vector<int> v(n) ; for (int i=0;i<n;i++) // 輸入 cin >> v[i] ; sort(v.begin(), v.end()) ; // 排序 int cnt = 1 ; // 數字出現次數 for (int i=1;i<n;i++, cnt++) { // 每次迴圈代表出現次數+1 if (v[i] != v[i-1]) { // 數字與之前不同 cout << v[i-1] << ' ' << cnt << '\n' ; cnt = 0 ; // 更新數字出現次數 } } cout << v[n-1] << ' ' << cnt << '\n' ; // 輸出最後一個數字 return 0 ; } ```