Sort
排序。把一群數字由小到大排好。
實際要做排序,有兩個方向,一是將數字放入循序性資料結構(例如 array 與 list ),然後執行下述其中一種排序演算法。二是使用有排序功效的資料結構,例如 binary heap 、 binary search tree ,將數字整個倒進去、整個倒出來即排序完畢。
| best average worst | extra | stable | case case case | space | ---------------+----------------------------+-------+-------- brute force | O(NR) O(NR) O(NR) | O(N) | O selection sort | O(NN) O(NN) O(NN) | O(1) | X bubble sort | O(N) O(NN) O(NN) | O(1) | O gnome sort | O(N) O(NN) O(NN) | O(1) | O insertion sort | O(N) O(NN) O(NN) | O(1) | O Shell sort | O(NN) O(NN) O(NN) | O(1) | X merge sort | O(NlogN) O(NlogN) O(NlogN) | O(N) | O quicksort | O(NlogN) O(NlogN) O(NN) | O(N) | X heapsort | O(NlogN) O(NlogN) O(NlogN) | O(1) | X counting sort | O(N+R) O(N+R) O(N+R) | O(R) | O bucket sort | O(N+B+?) O(N+B+?) O(N+B+?) | O(B) | X radix sort | O(ND) O(ND) O(ND) | O(D) | O
除了數字可以排序之外,事實上字元也可以排序,因為電腦中的字元就是數字(請參照 ASCII 表)。指標也可以排序,因為指標就是記憶體位址也就是數字。一般資料也可以排序,只要資料裡的某個特定欄位是數字,這個欄位被稱作鍵值( key )。
排序原理
排序的基本原理,當今只有兩種,一是對調(數字是實數),二是放置(數字必須是整數)。
純粹透過對調來排序,已證明出數字兩兩比較的次數是 Ω(NlogN) ,不可能更少了,當今也已經有了到達下限的排序演算法,例如 merge sort 。同時透過對調與放置來排序,則可以打破方才的下限,例如 flashsort 。
純粹透過放置來排序,需要額外的記憶體空間來放置數字。時間複雜度通常是數字數量加上記憶體用量,效率相當好,只可惜只能處理整數,例如 counting sort 。
暴力搜尋
依序枚舉每一個整數,看看陣列裡頭有沒有。
- void brute_force(int array[], int N)
- {
- int max_value = -1e9;
- int min_value = 1e9;
- for (int i=0; i<N; ++i)
- {
- max_value = max(max_value, array[i]);
- min_value = min(min_value, array[i]);
- }
- for (int i=min_value; i<=max_value; ++i)
- {
- // 看看陣列裡面有沒有
- for (int j=0; j<N; ++j)
- if (array[j] == i)
- cout << i;
- }
- }
令陣列最小值為 A ,最大值為 B 。令 A 和 B 之間的整數有 R 個, R = B-A+1 。時間複雜度為 O(RN) 。
Selection Sort
掃描一遍所有數字,找到最小值,挪至陣列左端。遞迴處理尚未排序的 N-1 個元素。
- void selection_sort(int array[], int N)
- {
- for (int i=0; i<N; ++i) // N改為N-1更精準
- {
- // 從尚未排序的數字當中,找出最小的數字。
- // (它也是全部數字當中第i小的數字。)
- int min_index = i;
- for (int j=i+1; j<N; ++j)
- if (array[j] < array[min_index])
- min_index = j;
- // 把第i小的數字,放在第i個位置。
- swap(array[i], array[min_index]);
- }
- }
Bubble Sort
由左到右,相鄰兩兩比較,較大者往右挪,最後最大值會出現在陣列右端。遞迴處理尚未排序的 N-1 個元素。
- void bubble_sort(int array[], int N)
- {
- for (int i=0; i<N; ++i) // N改為N-1更精準
- for (int j=0; j<N-i-1; ++j)
- if (array[j] > array[j+1])
- swap(array[j], array[j+1]);
- }
稍做改良:一旦排序好,便趕快結束。當資料很亂時,這麼做效益不彰。
- void bubble_sort(int array[], int N)
- {
- for (int i=0; i<N; ++i) // N改為N-1更精準
- {
- bool sorted = true;
- for (int j=0; i<N-j-1; ++j)
- if (array[j] > array[j+1])
- {
- swap(array[j], array[j+1]);
- sorted = false;
- }
- if (sorted) return;
- }
- }
Gnome Sort
原理和 Bubble Sort 相同,但是兩兩比較的先後次序有所改變。特色是程式碼只有一個迴圈,結構簡單。
- void gnome_sort(int array[], int N)
- {
- for (int i=0; i<N; )
- if (i == 0 || array[i-1] < array[i])
- i++;
- else
- {
- swap(array[i-1], array[i]);
- i--;
- }
- }
Insertion Sort
由左到右,逐一把數字插入到目前已排序的陣列當中。需將大量數字往右挪,以騰出空間插入數字。
- void insertion_sort(int array[], int N)
- {
- for (int i=2; i<N; ++i)
- {
- int pivot = array[i];
- int j;
- for (j=i-1; j>=0; --j)
- if (pivot < array[j])
- array[j+1] = array[j]; // 先行往右挪
- else
- break;
- array[j+1] = pivot; // 插入
- }
- }
資料結構如果是 array ,可以使用 Binary Search 快速找到插入點;但是很不幸的,插入時還是要挪動整塊記憶體。
資料結構如果是 list ,就無法使用 Binary Search 得到插入點;但是很幸運地,插入時不必挪動整塊記憶體。
UVa 10107
Shell Sort
Shell 是一個人名,是發明這個演算法的人,不是殼的意思。
運用 Scaling Method :以固定間隔取得數字做為一組,各組各自做 Insertion Sort ;然後減少間隔大小,重複上述動作。
- void shell_sort(int array[], int N)
- {
- for (int gap = N/2; gap > 0; gap /= 2)
- for (int i = gap; i < N; ++i)
- for (int j = i-gap; j >= 0 && v[j] > v[j+gap]; j -= gap)
- swap(v[j], v[j+gap]);
- }
Merge Sort
運用 Divide and Conquer : Divide 是陣列分兩半; Conquer 是兩半分別排序; Combine 是兩半各自排序好的陣列,弄成一條排序好的陣列。
可以直接使用 STL 的 stable_sort() 。
UVa 10810
Quicksort
運用 Divide and Conquer : Divide 是選定 pivot ,把 pivot 挪到陣列邊緣,然後把陣列分成大的一邊和小的一邊; Conquer 是兩邊分別排序; Combine 不做任何事。
可以任選一個數字當作 pivot ,排序結果皆正確。要讓 Quicksort 達到最佳效率,就是每次選中的 pivot ,都剛好可以把陣列分成兩等份,如此一來時間複雜度是 O(NlogN) ,這是帶點運氣成份的。幸運的是,即便把陣列分成數量懸殊的兩半,即便是 1000000:1 ,只要有個比例,時間複雜度還是 O(NlogN) 。
固定選擇最後一個數字當作 pivot ,也有機會把陣列分成兩等份。但是這卻產生一個古怪的現象:遇到已經排序的陣列,卻會變成每次都沒有分到,時間複雜度變成 O(N²) ,超級慢。
結果導致 Quicksort 有時快、有時卻很慢,遇到幾乎排序好的陣列,更是慢到吐血。時快時慢,該快不快,那不是很莫名其妙嗎?
為了避免這種情況,可以每次都用亂數指定 pivot ,這樣不管給定陣列是有排序的或是沒排序的,兩等份的機會都一樣多,如此就比較不會出現前述的古怪情形。不過這又衍生出一個問題,只是想做個排序,卻還得載入亂數模組,耗損系統資源、拖慢系統速度,帶來了新的壞處。
最後大家捨棄亂數,轉而構思一些簡單小技巧,讓選出來的 pivot 儘可能把陣列分成兩等分。例如 Java 的 Quicksort 是把陣列切成前中後三段,拿這三段中央的數字,三個數字的中位數當作 pivot 。這便是一個簡單實用的小技巧。
- // sort interval [left, right]
- void quick_sort(int array[], int left, int right)
- {
- if (left < right)
- {
- // divide (partition)
- int pivot = array[(left+right)/2]; // 可以隨便選
- int i = left - 1, j = right + 1;
- while (i < j)
- {
- do ++i; while (array[i] < pivot);
- do --j; while (array[j] > pivot);
- if (i < j) swap(array[i], array[j]);
- }
- // then conquer
- quick_sort(array, left, i-1);
- quick_sort(array, j+1, right);
- // no need to combine sub-solutions
- }
- }
- void quick_sort(int array[], int left, int right)
- {
- if (left < right)
- {
- // divide (partition)
- int pivot = array[(left+right)/2]; // 可以隨便選
- swap(array[right], array[(left+right)/2]); // 換到最右邊
- int i = left, j = left;
- for (; j < right; ++j)
- if (array[j] <= pivot)
- {
- if (i < j) swap(array[i], array[j]); // 不加if也可以
- i = i + 1;
- }
- if (i < right) swap(array[i], array[right]); // 不加if也可以
- // then conquer
- quick_sort(array, left, i-1);
- quick_sort(array, i+1, right);
- // no need to combine sub-solutions
- }
- }
- void quick_sort(int array[], int left, int right)
- {
- int array[N] = -1;
- for (int i=0; i<N; ++i)
- {
- while (array[i] > 0)
- {
- int pivot = array[i], k = i;
- for (int j=i+1; array[j] < 0; ++j)
- if (array[j] < pivot)
- swap(array[++k], array[j])
- swap(array[i], array[k])
- array[k] = -array[k];
- }
- array[i] = -array[i];
- }
- }
- https://github.com/yuhanlyu/Snippets/blob/master/sort/quickstackless.c
Quicksort 演算法的陷阱相當多,須考慮數字全都相等、判斷式是小於還是小於等於、分割點恰好選到最大值或者最小值、遞迴的區段範圍、遞迴的區段很短、 …… 等等問題。編寫 Quicksort 的程式碼時,最好是寫一支窮舉所有排列的程式,一一排序、一一驗證。如果擔心自己寫的程式碼用在正事上不妥當,也可以採用程式語言函式庫內建的 Quicksort 。
UVa 755
© 2010 tkcn. All rights reserved.
Heapsort
陣列可以做為二元樹資料結構。把陣列本身當作是 heap ,逐一把數字加入 heap ,達到排序功效。
Introsort
Quicksort 的加強版。遞迴分割陣列,區間越來越短,數字也幾乎排序好了。對於幾乎已經排序好的區間, Quicksort 效率非常差,所以改用 Heapsort 。
分水嶺通常設定成 logN² = 2logN , N 是陣列長度。
可以直接使用 STL 的 sort() 。
Counting Sort
全部數字依其數值大小,放到相符位置。接著由小到大讀取各個位置的數字。
- void counting_sort(int array[], int N)
- {
- // 歸類並標記
- int count[10000] = {0};
- for (int i=0; i<N; ++i)
- count[ array[i] ]++;
- // 計數陣列的索引值大小順序,正是元素大小順序。
- for (int i=0, k=0; k<N; ++i)
- while (count[i]--)
- array[k++] = i;
- }
- struct Data {int key, data;};
- void counting_sort(Data* array[], int N)
- {
- // 歸類並標記
- vector<Data*> count[10000];
- for (int i=0; i<N; ++i)
- count[ array[i]->key ].push_back( array[i] );
- // 索引值的大小順序,剛好也是元素的大小順序。
- for (int i=0, k=0; k<N; ++i)
- for (int j=0; j<count[i].size(); ++j)
- array[k++] = count[i][j];
- }
Bucket Sort
全部數字依其數值區間,放到相符桶子。接著各個桶子各自排序之後,再由小到大讀取各個桶子的數字。
Radix Sort
由低位數到高位數,每個位數依序做為鍵值,做 D 次 counting sort , D 為位數大小。
資料是數字,數字表示成二進位, D = logR 。
Flash Sort
延伸閱讀:以指標排序
排序會搬動資料,但是大多數時候我們不希望搬動資料。此時可以取出資料的記憶體位址,存入指標,對指標進行排序。
也有人以索引值排序,道理跟指標相同。
UVa 482
延伸閱讀: stable
兩筆相同資料,原本排在前頭的,排序後仍在前頭;原本排在後頭的,排序後仍在後頭。這稱作 stable 的排序演算法,相同資料、順序不變。
只要是放在陣列的資料,任何一種排序演算法,都可以擴充成 stable 的排序演算法。你想到解決方法了嗎?
延伸閱讀: lexicographical order
當資料是高維度數據,如何比較大小呢?其中一種方式叫做字典順序,先比較第一維度;如果平手,再比較第二維度;如果又平手,再比較第三維度;以此類推。
英文字典,即採用了字典順序,排序所有英文單字。英文字典當中,兩個英文單字比較大小的方式,就是字典順序。
延伸閱讀: Sorting Network
Search
眾裏尋他千百度,驀然回首,那人卻在,燈火闌珊處。《辛棄疾.青玉案》
Select
羽望見良麾蓋,策馬刺良於萬眾之中,斬其首還。《三國志》
Select
選擇。找到特定名次的數字,例如第 k 小、第 k 大的數字。或者,找到特定數字的名次。
最簡單的方式就是先排序、再搜尋。以下我們探討更快的方法。
Select in Array: Quickselect
Quicksort 只遞迴其中一邊。最佳、平均時間複雜度為 O(N) ,最差是 O(N²) 。
Select in Array: Median-of-Medians Algorithm
找到中位數們的中位數,將數字分成大的一邊和小的一邊,遞迴找其中一邊。時間複雜度 O(N) ,但是不實用。
1. 五個五個分堆,最後一堆可以沒有滿。 第一堆 ● ● ● ● ● 第二堆 ● ● ● ● ● 第三堆 ● ● ● ● ● 第四堆 ● ● ● ● ● 第五堆 ● ● ● ● ● 第六堆 ● ● ● 2. 每堆各自排序。然後求出每堆的中位數 ○。 小 → 大 ↑ ● ● ○ ● ● 沒 ● ● ○ ● ● 有 ● ● ○ ● ● 順 ● ● ○ ● ● 序 ● ● ○ ● ● ↓ ● ○ ● ← 最後一堆對齊一下比較好看 3. 求出中位數們的中位數 x。遞迴套用此演算法求得 x。 小 → 大 ↑ ● ● ○ ● ● 沒 ● ● ○ ● ● 有 ● ● ○ ● ● 順 ● ● ○ ● ● 序 ● ● x ● ● ← 中位數可能在任何一個地方 ↓ ● ○ ● 4. 將全部的數字分成兩邊,一邊小於 x ,一邊大於等於 x。 小於 x ←| |→ 大於等於 x ... ● ● ● ● ● ● x ● ● ● ● ● ● ● ● ... 5. 看看 k 是在哪一邊。遞迴下去找出答案。
時間複雜度証明 小 → 大 第一堆 ● ● ○ ● ● 第二堆 ● ● ○ ● ● 第三堆 ● ● ○ ● ● 第四堆 ● ● ○ ● ● 第五堆 ● ● x ● ● 第六堆 ● ○ ● 依照中位數們的大小,重新排列每一堆。 小 → 大 第四堆 中 ● ● ○ ● ● 第二堆 位 ● ● ○ ● ● 第五堆 數 ● ● x ● ● ← 中位數 x 變成排在中間 第一堆 小 ● ● ○ ● ● 第三堆 ↓ ● ● ○ ● ● 第六堆 大 ● ○ ● 觀察一定小於等於x的數、一定大於等於x的數。 一定小於 一定大於 等於x的數 等於x的數 ◊ ◊ ◊ ● ● ● ● ○ ● ● ◊ ◊ ◊ ● ● ● ● ○ ● ● ◊ ◊ x ● ● ● ● x ◊ ◊ ● ● ○ ● ● ● ● ◊ ◊ ◊ ● ● ○ ● ● ● ● ◊ ◊ ◊ ● ○ ● ● ◊ ◊ 推得「小於等於 x 的數至少有 3n/10 - 6 個。大於 x 的數至多有 7n/10 + 6 個。」 推得「大於等於 x 的數至少有 3n/10 - 6 個。小於 x 的數至多有 7n/10 + 6 個。」 至多7n/10 + 6 差不多至多7n/10 + 6 小於 x ←| |→ 大於等於 x ... ● ● ● ● ● ● x ● ● ● ● ● ● ● ● ... 分兩邊後, 小於的那一邊至多有 7n/10 + 6 個, 大於等於的那一邊差不多至多有 7n/10 + 6 個。 遞迴下去,總時間複雜度為 O(n)。
可以直接使用 STL 的 nth_element() 。
Select in Sorted Array
O(1) 。
Select in Sorted Arrays
找到中位數們的中位數,每列皆實施 Binary Search ,最後所有數字分為大的一邊和小的一邊,遞迴找其中一邊。兩邊的數量至少都是一半的列的一半,每回合至少削減一半的列的一半。每列各自建立首尾索引值,記錄剩下的元素。
每回合需時 O(XlogY) ,總共 O(logY) 回合,時間複雜度為 O(XlogYlogY) 。其中 X 是陣列數量, Y 是陣列長度。
簡單來說,此演算法是搜尋中中數、分兩邊、遞迴一邊。
Select in Sorted Arrays
找到中位數們的中位數,找到最大中位數、最小中位數。最小中位數至少小於一半的數字,最大中位數亦如是。判斷第 K 大是小於一半或大於一半,每回合削減最大中位數的右半或最小中位數的左半。每次刪除一列的一半,總共 O(XlogY) 回合。
以大小為 X 的二元搜尋樹儲存中位數,每回合可以快速找到最大與最小中位數。一共操作 O(XlogY) 次,每次操作 O(logX) ,時間複雜度為 O(XlogXlogY) 。其中 X 是陣列數量, Y 是陣列長度。
Select in Sorted Matrix
切成一列一列,找到中位數們的中位數,再利用 Saddleback Search 將矩陣分為大的一邊和小的一邊,遞迴找其中一邊。
每回合需時 O(N) ,總共 O(logN) 回合,時間複雜度為 O(NlogN) 。
Select in Sorted Matrix
Divide and Conquer 。 O(N) 。
Count
選出牡丹第一枝,勸君折取莫遲疑,世間若問相知處,萬事逢春正及時。《六十甲子籤》
Longest Plateau
一條數列,找到連續出現最多次的數字暨次數。
- int longest_plateau(int a[], int n)
- {
- if (n == 0) return 0;
- int counter = 1;
- int length = 1;
- for (int i=1; i<n; i++)
- {
- if (a[i] == a[i-1])
- {
- counter++;
- length = max(length, counter);
- }
- else
- counter = 1;
- }
- return length;
- }
已排序數列,有更簡單的做法。
- int longest_plateau(int a[], int n)
- {
- if (n == 0) return 0;
- int length = 1;
- for (int i = 1; i<n; i++)
- if (a[i] == a[i-length])
- length++;
- return length;
- }
Mode
一條數列,找到出現最多次的數字暨次數。
先排序再搜尋。採用交換式排序,例如 quicksort 或者 merge sort ,時間複雜度 O(NlogN) ,額外的空間複雜度 O(1) 。採用索引式排序,例如 counting sort 或者 hash table ,時間複雜度 O(N+R) ,額外的空間複雜度 O(R) 。
Majority Vote
一條數列,找到出現過半的數字暨次數。時間複雜度 O(N) ,額外的空間複雜度 O(1) 。
- int find_mode(int a[], int n)
- {
- int mode = a[0], counter = 0;
- for (int i=0; i<n; i++)
- if (counter == 0)
- mode = i, counter = 1;
- else
- if (a[i] == mode)
- counter++;
- else
- counter--;
- counter = 0;
- for (int i=0; i<n; i++)
- if (a[i] == mode)
- counter++;
- if (counter < (n+1)/2) return -1;
- return counter;
- }
Maximum Gap
給定一串未排序數列,排序之後,考慮相鄰兩數的差值,其中最大的差值稱作「最大間隔」。可能同時有許多個「最大間隔」。
時間複雜度 O(N) ,不必排序,就能找出所有最大間隔。但是前提是 floor 運算是 O(1) 。
一、找到最大值max、最小值min。 二、建立N-1個bucket, 一個bucket的範圍大小是 D = (max-min)/(N-1)。 三、除了最大值、最小值以外, 剩下的N-2個數字通通塞入bucket, 數字 x 塞到 floor[(x-min)/D]。 回、N-1個bucket,N-2個數字,必有空的bucket。 所以最大間隔的兩數必定跨過空的bucket,最大間隔大於D。 換句話說,最大間隔的兩數不在同一個bucket。 四、刪除空的bucket,重新編號。 算出各個bucket內含數字的最大值bucket[i].max、最小值bucket[i].min。 五、max { bucket[i+1].min - bucket[i].max } 即是最大間隔。
Minimum Gap
一串數列當中,找到相差最小的兩個數字。
給定一串未排序數列,排序之後,考慮相鄰兩數的差值,其中最小的差值稱作「最小間隔」。一條數列可能同時有許多個「最小間隔」。
時間複雜度 O(NlogN) 。
【待補文字】
Count Distinct
一串數列當中,找到相異數字個數。
http://ravi-bhide.blogspot.tw/2011/04/flajolet-martin-algorithm.html
https://chenjiehua.me/database/hyperloglog-bigdata.html
末一位可能是0/1,如果都出現,答案至少2 末二位可能是00/01/10/11,如果都出現,答案至少4
【待補文字】
Pairwise Sum
Pairwise Distance
沒有留言:
張貼留言