# 【資工考研】DSA: Search and Sort
## Search
1. Linear (sequential) search
2. Binary search
### Linear Search
適合在支援 random access (e.g. array) 或 sequential access (e.g. linked list) 的資料結構上實作
Time complexity: $O(n)$
```cpp!
template <typename T>
int linearSearch(const std::vector<T> &arr, T x)
{
if (arr.empty())
return -1;
const int n = arr.size();
for (int i = 0; i < n; ++i)
{
if (arr[i] == x)
return i;
}
return -1;
}
```
```cpp!
template <typename T>
int sentinelSearch(std::vector<T> &arr, T x)
{
if (arr.empty())
return -1;
const int n = arr.size();
T last = arr.back();
if (last == x)
return n - 1;
arr.back() = x;
int i = 0;
while (arr[i] != x)
++i;
arr.back() = last;
return i < n - 1 ? i : -1;
}
```
### Binary Search
1. **資料需要事先排序過**
2. 資料必須保存在支援 random access 的資料結構上
Time complexity: $O(\lg n)$
想法:Divide and Conquer
每次都找出當前區段 (\[`l`, `r`]) 最中間位置 (`mid`) 的資料做比對
如果不是所要資料,因為資料已經保證為有序的,故如果位於 `mid` 的資料比目標小,表示目標在 `mid` 右邊 (\[`mid + 1`, `r` ]);如果位於 `mid` 的資料比目標大,表示目標在 `mid` 左邊 (\[`l`, `mid - 1` ])
不斷對半切,縮小搜尋範圍,最終找到目標
```cpp!
template <typename T>
int binarySearch(const std::vector<T> &arr, T x)
{
int l = 0, r = arr.size() - 1;
while (l <= r)
{
const int mid = l + (r - l) / 2; // mid = (l + r) / 2
if (arr[mid] == x)
return mid;
if (arr[mid] < x)
l = mid + 1;
else
r = mid - 1;
}
return -1;
}
```
遞迴寫法:
```cpp!
template <typename T>
int binarySearch(const std::vector<T> &arr, T x, int l = 0, int r = -1)
{
if (r == -1)
r = arr.size() - 1;
if (l > r)
return -1;
const int mid = l + (r - l) / 2;
if (arr[mid] == x)
return mid;
if (arr[mid] < x)
return binarySearch(arr, x, mid + 1, r);
return binarySearch(arr, x, l, mid - 1);
}
```
從遞迴寫法我們可以很容易看出時間函數為 $T(n) = T(\frac{n}{2}) + 1 = O(\lg n)$
||$n$ 筆資料以 BST 找 `x` |$n$ 筆資料以 Binary search 找 `x`|
|:---:|:---:|:---:|
| Best Case | $O(\lg n)$ | $O(1)$ |
| Worst Case | $O(n)$ | $O(\lg n)$ |
## Sort
|| Time Complexity | Time Complexity | Time Complexity | Space Complexity | Stable |
|:---:|:---:|:---:|:---:|:---:|:---:|
| Method | Best Case | Worst Case | Avg. Case | | |
| Insertion | $O(n)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | O |
| Selection | $O(n^2)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | X |
| Bubble | $O(n)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | O |
| Shell | $O(n)$ | $O(n^\frac{23}{15})$ | $O(n^2)$ | $O(1)$ | X |
| Quick | $O(n\lg n)$ | $O(n^2)$ | $O(n\lg n)$ | $O(\lg n)$ to $O(n)$ | X |
| Merge | $O(n\lg n)$ | $O(n\lg n)$ | $O(n\lg n)$ | $O(n)$ | O |
| Heap | $O(n\lg n)$ | $O(n\lg n)$ | $O(n\lg n)$ | $O(1)$ | X |
### Insertion Sort
想法:
我們從第二個元素 ($\mathrm{arr}\lbrack 1 \rbrack$) 開始看到最後一個 ($\mathrm{arr}\lbrack n-1 \rbrack$)
對於 $\mathrm{arr}\lbrack i \rbrack$ ,其前 $i-1$ 筆資料都**已經是**排序好的
我就找到 $\mathrm{arr}\lbrack 0\cdots i-1 \rbrack$ 中哪裡插入 $\mathrm{arr}\lbrack i \rbrack$ 會使 $\mathrm{arr}\lbrack 0\cdots i \rbrack$ 為有序即可
例如
> input: `[5, 3, 6, 2, 4]`
$i = 1$: `[3, 5, 6, 2, 4]`
$i = 2$: `[3, 5, 6, 2, 4]`
$i = 3$: `[2, 3, 5, 6, 4]`
$i = 4$: `[2, 3, 4, 5, 6]`
```cpp!
template <typename T, typename Compare = std::less<T>>
void insertionSort(std::vector<T> &arr)
{
const int n = arr.size();
Compare comp;
for (int i = 1; i < n; ++i)
{
T key = arr[i]; // element to be inserted
int j = i - 1;
while (j >= 0 && comp(key, arr[j])) // arr[j] > key
{
arr[j + 1] = arr[j];
--j;
}
arr[j + 1] = key;
}
}
```
我們預設是由小排到大的升序排列
Time Complexity:
- Best case: $O(n)$ ,當 input 是由小到大 ($T(n) = T(n-1) + 1 = O(n)$)
- Worst case: $O(n^2)$ ,當 input 是由大到小 ($T(n) = T(n-1) + n-1 = O(n^2)$)
- Avg. case: $O(n^2)$ ($T(n) = T(n-1) + \frac{1+\cdots +n-1}{n-1} = O(n^2)$)
Space Complexity:
無須額外空間,故 $O(1)$
Stable:
最簡單的檢驗方式就是有兩個一樣的值出現在 `arr` 時,排序過後會不會改變這兩個值原本輸入時的先後順序
例如 `[2, 5, 5*, 3]`
你會發現 insertion sort 要抓取 `5*` 時(`key = 5*`) 他是找在 `5*` 之前有沒有比它還大的,而 `5` 並沒有比它大
所以不會改變先後順序
我們可以這樣設計資料
```cpp!
struct Data
{
int key;
char label;
friend bool operator<(const Data &lhs, const Data &rhs) noexcept
{
return lhs.key < rhs.key;
}
};
```
```cpp!
int main()
{
std::vector<Data> arr = {
{2, '\0'},
{5, '\0'},
{5, '*'},
{3, '\0'},
};
insertionSort(arr);
printData(arr);
}
```
最後結果 `[2, 3, 5, 5*]`
insertion sort 很適合資料少時使用
### Selection Sort
想法:
$i$ 從頭看到倒數第二個
對於區間 $\mathrm{arr}\lbrack i\cdots n \rbrack$ 找出其最小值 $\mathrm{arr}\lbrack min \rbrack$ 並 $\mathrm{swap}\left( \mathrm{arr}\lbrack i \rbrack, \mathrm{arr}\lbrack min \rbrack \right)$
例如
> input: `[5, 3, 6, 2, 4]`
$i=0$: `[2, 3, 6, 5, 4]`
$i=1$: `[2, 3, 6, 5, 4]`
$i=2$: `[2, 3, 4, 5, 6]`
$i=3$: `[2, 3, 4, 5, 6]`
$i=4$: `[2, 3, 4, 5, 6]`
```cpp!
template <typename T, typename Compare = std::less<T>>
void selectionSort(std::vector<T> &arr)
{
const int n = arr.size();
Compare comp;
for (int i = 0; i < n - 1; ++i)
{
int min = i;
for (int j = i; j < n; ++j)
{
// find the pos of min
if (comp(arr[j], arr[min])) // arr[j] < arr[min]
min = j;
}
if (i != min)
std::swap(arr[i], arr[min]);
}
}
```
Time Complexity:
都是 $O(n^2)$
Space Complexity:
$O(1)$
可以注意到,selection sort 的特點就是 $i$ 的迭代過程中每回合頂多只會 swap 一次
對於大型紀錄之排序,該一特點能展現出來
不過 selection sort 是 unstable 的
```cpp!
int main()
{
std::vector<Data> arr = {
{2, '\0'},
{5, '\0'},
{5, '*'},
{3, '\0'},
};
selectionSort(arr);
printData(arr);
}
```
結果: `[2, 3, 5*, 5]`
這是因為我們每次只在意小的值有沒有放到正確的位置上,並沒有在意 swap 過程中造成的先後順序打亂
### Bubble Sort
想法:
由左而右兩相鄰元素互比
如果前者大於後者則 swap
> [!Tip]
> 最多只會有 $n-1$ 回合,每個回合我們都會把極大值升到最後面去
> [!Note]
> 如果改成從右到左操作,那就是把極小值降到最前面去
```cpp!
template <typename T, typename Compare = std::less<T>>
void bubbleSort(std::vector<T> &arr)
{
const int n = arr.size();
Compare comp;
for (int i = 0; i < n - 1; ++i)
{
bool swapped = false;
for (int j = 0; j < n - i - 1; ++j)
{
// if (arr[j] > arr[j + 1])
if (comp(arr[j + 1], arr[j]))
{
std::swap(arr[j], arr[j + 1]);
swapped = true;
}
}
if (!swapped) // no swap, stop iteration
break;
}
}
```
Time Complexity:
- Best Case: $O(n)$ ,當資料是由小到大排好 ($T(n) = n-1 = O(n)$)
- Worst Case: $O(n^2)$ ,當資料由大到小排好 ($T(n) = T(n-1) + n -1 = O(n^2)$)
- Avg. Case: $O(n^2)$ ($T(n) = T(n-1) + O(n) = O(n^2)$)
Space Complexity:
$O(1)$
Bubble sort 是 stable 的
因為我們是從前看到後,每次比大小,一樣的兩個值,在前面的不會被 swap 到後面去
```cpp!
int main()
{
std::vector<Data> arr = {
{2, '\0'},
{5, '\0'},
{5, '*'},
{3, '\0'},
};
bubbleSort(arr);
printData(arr);
}
```
最後結果 `[2, 3, 5, 5*]`
### Shell Sort
Shell sort,也是一種 in-place 的比較排序演算法
它可以被理解為 bubble sort 或 insertion sort 的泛化
這個方法的開始是將相距較遠的元素成對排序,然後逐步減少要比較的元素之間的間隔
通過從遠距離的元素開始,它可以比單純的鄰近交換更快地將某些錯位的元素移到正確的位置
Shellsort 的運行時間非常依賴於它使用的間隔序列 (gap sequence)
對於許多實用的變體,其時間複雜度的確定仍然是未解的問題
考試不怎麼考它
就算考也不太可能考你它的時間複雜度,正如上面講的,這不是什麼很基礎的問題
以下為 [wiki](https://en.wikipedia.org/wiki/Shellsort#Pseudocode) 上的 pseudocode
```pseudocode!
# Sort an array a[0...n-1].
gaps = [701, 301, 132, 57, 23, 10, 4, 1] # Ciura gap sequence
# Start with the largest gap and work down to a gap of 1
# similar to insertion sort but instead of 1, gap is being used in each step
foreach (gap in gaps)
{
# Do a gapped insertion sort for every element in gaps
# Each loop leaves a[0..gap-1] in gapped order
for (i = gap; i < n; i += 1)
{
# save a[i] in temp and make a hole at position i
temp = a[i]
# shift earlier gap-sorted elements up until the correct location for a[i] is found
for (j = i; (j >= gap) && (a[j - gap] > temp); j -= gap)
{
a[j] = a[j - gap]
}
# put temp (the original a[i]) in its correct location
a[j] = temp
}
}
```
### Quick Sort
想法:也是 Divide and Conquer
選擇一個元素作為 pivot ,決定其正確位置使得位於 pivot 左邊的資料都 $\le$它、位於 pivot 右邊的資料都 $\ge$ 它
這樣的方式就把原始陣列拆兩邊了,再對兩邊也進行 quick sort
#### Hoare's partition
我們每次都挑最左邊的當作 pivot 並且用以下方式進行 partition:
`[pv, _, _, _, _, _, _, _, _, _]`
兩個指標 `i` 跟 `j` 分別從左右兩側開始選,他們分別代表到時候 pivot 的左右兩邊
他們的目標是要挑出不符合我們目標 「位於 pivot 左邊的資料都 $\le$它、位於 pivot 右邊的資料都 $\ge$ 它」 的元素
`[pv, _, i, _, _, _, _, _, _, j]`
每次找到後就 $\mathrm{swap}\left( \mathrm{arr}\lbrack i \rbrack , \mathrm{arr}\lbrack j \rbrack \right)$
(`i` 跟 `j` 找到的分別是對方那邊需要的元素, swap 後相對排序就正確了)
之後當 `i` 超過 `j` 時表示該結束了
`[pv, _, _, _, _, j, i, _, _, _]`
結束的最後一步是 $\mathrm{swap}\left( \mathrm{arr}\lbrack l \rbrack , \mathrm{arr}\lbrack j \rbrack \right)$
其中 $\mathrm{arr}\lbrack l \rbrack$ 即 pivot
這樣就的確達成 「位於 pivot 左邊的資料都 $\le$它、位於 pivot 右邊的資料都 $\ge$ 它」
```cpp!
template <typename T, typename Compare>
int partition(std::vector<T> &arr, int l, int r, Compare comp)
{
T pivot = arr[l];
int i = l, j = r + 1;
while (true)
{
do
{
++i;
} while (i <= r && comp(arr[i], pivot)); // arr[i] < pivot
// stop when arr[i] >= pivot
do
{
--j;
} while (j >= l && comp(pivot, arr[j])); // arr[j] > pivot
// stop when arr[j] <= pivot
if (i >= j)
break;
std::swap(arr[i], arr[j]);
}
std::swap(arr[l], arr[j]);
return j;
}
template <typename T, typename Compare = std::less<T>>
void quickSort(std::vector<T> &arr, int l = 0, int r = -1)
{
if (r == -1)
r = arr.size() - 1;
if (l < r)
{
Compare comp;
int p = partition(arr, l, r, comp);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
}
```
最後我們再對 pivot 兩邊也 quick sort 即可
Time Complexity:
- Best Case: $(n\lg n)$ ,如果資料皆相同 or pivot 恰好將資料分成兩等分 ($T(n) = 2T(\frac{n}{2}) + O(n)$)
- Worst Case: $O(n^2)$ ,如果資料是由小到大或由大到小 (pivot 會選成最小或最大值) ($T(n) = T(n-1) + O(n^2)$)
- Avg. Case: $O(n\lg n)$ ($T(n) = \frac{1}{n}\left\lbrack\sum\limits_{i=0}^{n-1} T(i) + T(n-i-1) \right\rbrack + n -1$)
可以看到, pivot 選的如何影響了 quick sort 的表現
Space Complexity:
因為我們需要遞迴呼叫,所以是 $O(\lg n)$ 到 $O(n)$
Quick sort 很明顯,是 unstable 的
```cpp!
int main()
{
std::vector<Data> arr = {
{5, '*'},
{5, '\0'},
{5, '\0'},
{5, '\0'},
{5, '\0'},
};
quickSort(arr);
printData(arr);
}
```
結果: `[5, 5, 5*, 5, 5]`
#### Comen's Partition
只差在怎麼寫 partition
```cpp!
template <typename T, typename Compare>
int partition(std::vector<T> &arr, int l, int r, Compare comp)
{
T pivot = arr[r];
int i = l - 1;
for (int j = l; j <= r - 1; ++j)
{
if (!comp(pivot, arr[j])) // arr[j] <= pivot
std::swap(arr[++i], arr[j]);
}
std::swap(arr[++i], arr[r]);
return i;
}
```
之後就都一樣
```cpp!
template <typename T, typename Compare = std::less<T>>
void quickSort(std::vector<T> &arr, int l = 0, int r = -1)
{
if (r == -1)
r = arr.size() - 1;
if (l < r)
{
Compare comp;
int p = partition(arr, l, r, comp);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
}
```
這個版本反而會有個問題,那就是當資料都相同時,反而變成 worst case $O(n^2)$
解決辦法有兩種:
1. 先檢查是不是資料都相同,雖然要花 $O(n)$ 來檢查,但時間複雜度上還是不影響
2. 改用 Hoare 提出的 partition
#### Median of Medians
我們說過,怎麼選 pivot 會影響 quick sort 的表現
我們就算亂數隨機挑一個當 pivot ,worst case 也還是一樣 $O(n^2)$
好的挑法例如 middle of three 或是 median of medians
以下介紹 median of medians 的想法:
1. 我們先將資料分成 $\left\lceil \frac{n}{5} \right\rceil$ 組,每組盡量塞滿 $5$ 個元素
2. 每組各自做排序,因為資料量很小,可以用 insertion sort 使這步驟維持在 $O(n)$
3. 將這 $\left\lceil \frac{n}{5} \right\rceil$ 組中的中位數組成一個集合,再從該集合中挑選中位數作為 pivot ,此即 median of medians (標題回收)
4.
```cpp!
template <typename Iterator, typename Compare = std::less<typename std::iterator_traits<Iterator>::value_type>>
void insertionSort(Iterator first, Iterator last, Compare comp = Compare())
.
.
.
template <typename T, typename Compare>
int partition(std::vector<T> &arr, int l, int r, Compare comp)
{
const int n = r - l + 1;
if (n <= 5)
{
insertionSort(arr.begin() + l, arr.begin() + r + 1, comp);
return l + n / 2;
}
const int numGroups = (n + 4) / 5; // ⌈ n/5 ⌉
for (int i = 0; i < numGroups; ++i)
{
const int start = l + i * 5;
const int end = std::min(start + 4, r);
insertionSort(arr.begin() + start, arr.begin() + end + 1, comp);
std::swap(arr[l + i], arr[start + (end - start) / 2]);
}
const int idx = l + numGroups / 2;
insertionSort(arr.begin() + l, arr.begin() + l + numGroups, comp);
T pivot = arr[idx];
// Just like we did before.
int i = l, j = r + 1;
while (true)
{
do
{
++i;
} while (i <= r && comp(arr[i], pivot));
do
{
--j;
} while (j >= l && comp(pivot, arr[j]));
if (i >= j)
break;
std::swap(arr[i], arr[j]);
}
std::swap(arr[l], arr[j]);
return j;
}
template <typename T, typename Compare = std::less<T>>
void quickSort(std::vector<T> &arr, int l = 0, int r = -1)
{
if (r == -1)
r = arr.size() - 1;
if (l < r)
{
Compare comp;
int p = partition(arr, l, r, comp);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
}
```
這樣就保證 quick sort worst case 也是 $O(n\lg n)$
> [!Important]
> 像這些 comparison-based 的想法,一般時間最快達 $\Omega (n\lg n)$
### Merge Sort
想法:Divide and Conquer
比較兩個數,小的擺前面,這樣的問題應該簡單到不行吧?
資料量不大的時,比較大小排順序對我們不是什麼很難的問題
Merge sort 的想法便是把原始資料拆到足夠小,小到我們很容易解決
再把結果合併回來,得出所求
```cpp!
template <typename T, typename Compare>
void merge(std::vector<T> &arr, int l, int mid, int r, Compare comp)
{
const int n1 = mid - l + 1;
const int n2 = r - mid;
std::vector<T> arrL(arr.begin() + l, arr.begin() + mid + 1); // arr[l ... mid]
std::vector<T> arrR(arr.begin() + mid + 1, arr.begin() + r + 1); // arr[mid + 1 ... r]
int i = 0, j = 0, k = l;
while (i < n1 && j < n2)
{
if (comp(arrR[j], arrL[i])) // arrL[i] > arrR[j]
arr[k++] = arrR[j++];
else
arr[k++] = arrL[i++];
}
while (i < n1)
arr[k++] = arrL[i++];
while (j < n2)
arr[k++] = arrR[j++];
}
template <typename T, typename Compare = std::less<T>>
void mergeSort(std::vector<T> &arr, int l = 0, int r = -1)
{
Compare comp;
if (r == -1)
r = arr.size() - 1;
if (l < r)
{
const int mid = l + (r - l) / 2;
mergeSort(arr, l, mid);
mergeSort(arr, mid + 1, r);
merge(arr, l, mid, r, comp);
}
}
```
Time Complexity:
都是 $O(n\lg n)$
Space Complexity:
merge sort 並非 in-place 的
我們需要額外的空間先處理完子問題,最後才合併
需要 $O(n)$
Merge sort 是 stable 的
```cpp!
int main()
{
std::vector<Data> arr = {
{2, '\0'},
{5, '\0'},
{5, '*'},
{3, '\0'},
};
mergeSort(arr);
printData(arr);
}
```
結果: `[2, 3, 5, 5*]`
> [!Important]
> 任何 stable 的 sorting algorithm 寫的時候都要注意等號的處理!
> 寫錯就會失去 stable
### Heap Sort
建立 [heap](https://hackmd.io/@RyoukiWei/binary_tree_1#Heap) 來排序
```cpp!
template <typename T, typename Compare>
void heapify(std::vector<T> &arr, int n, int i, Compare comp)
{
int largest = i;
const int left = 2 * i + 1;
const int right = 2 * i + 2;
if (left < n && comp(arr[largest], arr[left]))
largest = left;
if (right < n && comp(arr[largest], arr[right]))
largest = right;
if (largest != i)
{
std::swap(arr[i], arr[largest]);
heapify(arr, n, largest, comp);
}
}
template <typename T, typename Compare = std::less<T>>
void heapSort(std::vector<T> &arr)
{
const int n = arr.size();
Compare comp;
// Build a max heap
for (int i = n / 2 - 1; i >= 0; --i)
{
heapify(arr, n, i, comp);
}
// Extract elements one by one from the heap
for (int i = n - 1; i > 0; --i)
{
std::swap(arr[0], arr[i]); // Move the largest element to the end
heapify(arr, i, 0, comp); // Restore the heap property
}
}
```
Time Complexity:
都是 $O(n\lg n)$
> [!Tip]
> 做 $n-1$ 回合類似 $\mathrm{delete-max}$ 的操作,每回合 $O(\lg n)$
> 故總共 $O(n\lg n)$
Space Complexity:
直接拿原陣列操作,所以是 in-place 的,$O(1)$
那很顯然 heap sort is unstable
### Counting Sort
Counting sort 並不是用 comparison 的方式
它適用於我們對當前資料的值域是已知的情況
這種時候我們可以用找數字的方式來排順序
```cpp!
template <typename T, typename Key, typename Functor>
void countingSort(const std::vector<T> &arr, std::vector<T> &output, Key k, Functor getKey)
{
std::vector<Key> freq(k + 1);
for (const auto &entry : arr)
{
++freq[getKey(entry)];
}
for (int i = 1; i <= k; ++i)
{
freq[i] += freq[i - 1];
}
const int n = arr.size();
output.resize(n);
for (int i = n - 1; i >= 0; --i)
{
output[freq[getKey(arr[i])]--] = arr[i];
}
}
template <typename T, typename Key>
void countingSort(const std::vector<T> &arr, std::vector<T> &output, Key k)
{
std::vector<Key> freq(k + 1);
for (const auto &entry : arr)
{
++freq[entry];
}
for (int i = 1; i <= k; ++i)
{
freq[i] += freq[i - 1];
}
const int n = arr.size();
output.resize(n);
for (int i = n - 1; i >= 0; --i)
{
output[freq[arr[i]]--] = arr[i];
}
}
```
Time Complexity:
都是 $O(n+k)$
因為 $k$ 其實是已知的常數,所以 counting sort 是 linear time algorithm
Space Complexity:
$O(n+k)$
而可想而知,我們抓數字時,相同的值是按照原本陣列的順序在抓的
所以是 stable 的