算法學習筆記之遞歸排序與查找
算法學習筆記系列,這篇主要總結匯總一下基礎算法中的遞歸,排序,查找。是接上一篇關於基礎數據結構的《算法學習筆記之複雜度分析與線性表》。
文章目錄
1. 遞歸
遞歸需要滿足的三個條件,1. 一個問題的解可以分解爲幾個子問題的解。2. 這個問題與分解之後的子問題,除了數據規模不同,求解思路完全一樣。3. 存在遞歸終止條件。寫遞歸代碼的關鍵就是找到如何將大問題分解爲小問題的規律,並且基於此寫出遞推公式,然後再推敲終止條件,最後將遞推公式和終止條件翻譯成代碼。編寫遞歸代碼的關鍵是,只要遇到遞歸,我們就把它抽象成一個遞推公式,不用想一層層的調用關係,不要試圖用人腦去分解遞歸的每個步驟。
- 注意事項:
- 遞歸代碼要警惕堆棧溢出
- 遞歸代碼要警惕重複計算
爲了避免重複計算,我們可以通過一個數據結構(比如散列表)來保存已經求解過的 f(k)。當遞歸調用到 f(k) 時,先看下是否已經求解過了。如果是,則直接從散列表中取值返回,不需要重複計算
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
// hasSolvedList可以理解成一個Map,key是n,value是f(n)
if (hasSolvedList.containsKey(n)) {
return hasSolvedList.get(n);
}
int ret = f(n-1) + f(n-2);
hasSolvedList.put(n, ret);
return ret;
}
因爲遞歸本身就是藉助棧來實現的,只不過我們使用的棧是系統或者虛擬機本身提供的,我們沒有感知罷了。如果我們自己在內存堆上實現棧,手動模擬入棧、出棧過程,這樣任何遞歸代碼都可以改寫成看上去不是遞歸代碼的樣子。
2. 排序
排序算法最常用的:冒泡排序、插入排序、選擇排序、歸併排序、快速排序、計數排序、基數排序、桶排序。
2.1 冒泡排序(Bubble Sort)
冒泡排序只會操作相鄰的兩個數據。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求。如果不滿足就讓它倆互換。一次冒泡會讓至少一個元素移動到它應該在的位置,重複 n 次,就完成了 n 個數據的排序工作。當某次冒泡操作已經沒有數據交換時,說明已經達到完全有序,不用再繼續執行後續的冒泡操作。
- 冒泡的過程只涉及相鄰數據的交換操作,只需要常量級的臨時空間,所以它的空間複雜度爲 O(1),是一個原地排序算法。
- 在冒泡排序中,只有交換纔可以改變兩個元素的前後順序。爲了保證冒泡排序算法的穩定性,當有相鄰的兩個元素大小相等的時候,我們不做交換,相同大小的數據在排序前後不會改變順序,所以冒泡排序是穩定的排序算法。
// 冒泡排序,a表示數組,n表示數組大小
public void bubbleSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n; ++i) {
// 提前退出冒泡循環的標誌位
boolean flag = false;
for (int j = 0; j < n - i - 1; ++j) {
if (a[j] > a[j+1]) { // 交換
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true; // 表示有數據交換
}
}
if (!flag) break; // 沒有數據交換,提前退出
}
}
2.2 插入排序(Insertion Sort)
將數組中的數據分爲兩個區間,已排序區間和未排序區間。初始已排序區間只有一個元素,就是數組的第一個元素。插入算法的核心思想是取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入,並保證已排序區間數據一直有序。重複這個過程,直到未排序區間中元素爲空,算法結束。
插入排序也包含兩種操作,一種是元素的比較,一種是元素的移動。當我們需要將一個數據 a 插入到已排序區間時,需要拿 a 與已排序區間的元素依次比較大小,找到合適的插入位置。找到插入點之後,我們還需要將插入點之後的元素順序往後移動一位,這樣才能騰出位置給元素 a 插入。
// 插入排序,a表示數組,n表示數組大小
public void insertionSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 1; i < n; ++i) {
int value = a[i];
int j = i - 1;
// 查找插入的位置
for (; j >= 0; --j) {
if (a[j] > value) {
a[j+1] = a[j]; // 數據移動
} else {
break;
}
}
a[j+1] = value; // 插入數據
}
}
2.3 選擇排序(Selection Sort)
選擇排序算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。選擇排序是一種不穩定的排序算法。選擇排序每次都要找剩餘未排序元素中的最小值,並和前面的元素交換位置,這樣破壞了穩定性。
2.4 歸併排序(Merge Sort)
如果要排序一個數組,我們先把數組從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合併在一起,這樣整個數組就都有序了。歸併排序是一個穩定的排序算法。歸併排序的時間複雜度是 O(nlogn)。歸併排序不是原地排序算法。
2.5 快速排序算法(Quicksort)
如果要排序數組中下標從 p 到 r 之間的一組數據,我們選擇 p 到 r 之間的任意一個數據作爲 pivot(分區點)。我們遍歷 p 到 r 之間的數據,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。經過這一步驟之後,數組 p 到 r 之間的數據就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的。
根據分治、遞歸的處理思想,我們可以用遞歸排序下標從 p 到 q-1 之間的數據和下標從 q+1 到 r 之間的數據,直到區間縮小爲 1,就說明所有的數據都有序了。
快速排序並不是一個穩定的排序算法。
歸併排序的處理過程是由下到上的,先處理子問題,然後再合併。而快排正好相反,它的處理過程是由上到下的,先分區,然後再處理子問題。歸併排序雖然是穩定的、時間複雜度爲 O(nlogn) 的排序算法,但是它是非原地排序算法。我們前面講過,歸併之所以是非原地排序算法,主要原因是合併函數無法在原地執行。快速排序通過設計巧妙的原地分區函數,可以實現原地排序,解決了歸併排序佔用太多內存的問題。
快排是一種原地、不穩定的排序算法
2.6 桶排序(Bucket sort)
三種時間複雜度是 O(n) 的排序算法:桶排序、計數排序、基數排序。因爲這些排序算法的時間複雜度是線性的,所以我們把這類排序算法叫作線性排序(Linear sort)。之所以能做到線性的時間複雜度,主要原因是,這三個算法是非基於比較的排序算法,都不涉及元素之間的比較操作。
桶排序,顧名思義,會用到“桶”,核心思想是將要排序的數據分到幾個有序的桶裏,每個桶裏的數據再單獨進行排序。桶內排完序之後,再把每個桶裏的數據按照順序依次取出,組成的序列就是有序的了。
如果要排序的數據有 n 個,我們把它們均勻地劃分到 m 個桶內,每個桶裏就有 k=n/m 個元素。每個桶內部使用快速排序,時間複雜度爲 O(k * logk)。m 個桶排序的時間複雜度就是 O(m * k * logk),因爲 k=n/m,所以整個桶排序的時間複雜度就是 O(n*log(n/m))。當桶的個數 m 接近數據個數 n 時,log(n/m) 就是一個非常小的常量,這個時候桶排序的時間複雜度接近 O(n)。
2.7 計數排序(Counting sort)
當要排序的 n 個數據,所處的範圍並不大的時候,比如最大值是 k,我們就可以把數據劃分成 k 個桶。每個桶內的數據值都是相同的,省掉了桶內排序的時間。
// 計數排序,a是數組,n是數組大小。假設數組中存儲的都是非負整數。
public void countingSort(int[] a, int n) {
if (n <= 1) return;
// 查找數組中數據的範圍
int max = a[0];
for (int i = 1; i < n; ++i) {
if (max < a[i]) {
max = a[i];
}
}
int[] c = new int[max + 1]; // 申請一個計數數組c,下標大小[0,max]
for (int i = 0; i <= max; ++i) {
c[i] = 0;
}
// 計算每個元素的個數,放入c中
for (int i = 0; i < n; ++i) {
c[a[i]]++;
}
// 依次累加
for (int i = 1; i <= max; ++i) {
c[i] = c[i-1] + c[i];
}
// 臨時數組r,存儲排序之後的結果
int[] r = new int[n];
// 計算排序的關鍵步驟,有點難理解
for (int i = n - 1; i >= 0; --i) {
int index = c[a[i]]-1;
r[index] = a[i];
c[a[i]]--;
}
// 將結果拷貝給a數組
for (int i = 0; i < n; ++i) {
a[i] = r[i];
}
}
計數排序只能用在數據範圍不大的場景中,如果數據範圍 k 比要排序的數據 n 大很多,就不適合用計數排序了。而且,計數排序只能給非負整數排序,如果要排序的數據是其他類型的,要將其在不改變相對大小的情況下,轉化爲非負整數。
2.8 基數排序(Radix sort)
基數排序對要排序的數據是有要求的,需要可以分割出獨立的“位”來比較,而且位之間有遞進的關係,如果 a 數據的高位比 b 數據大,那剩下的低位就不用比較了。除此之外,每一位的數據範圍不能太大,要可以用線性排序算法來排序,否則,基數排序的時間複雜度就無法做到 O(n) 了。
2.9 排序算法總結
總結之前的基礎排序算法如下:
排序算法 | 時間複雜度 | 是否穩定排序 | 是否原地排序 |
---|---|---|---|
冒泡排序 | 是 | 是 | |
插入排序 | 是 | 是 | |
選擇排序 | 否 | 是 | |
快速排序 | 否 | 是 | |
歸併排序 | 是 | 否 | |
計數排序 | 是 | 否 | |
桶排序 | 是 | 否 | |
基數排序 | ,d是維度 | 是 | 否 |
- Glibc 中的 qsort() 函數
qsort() 會優先使用歸併排序來排序輸入數據,要排序的數據量比較大的時候,qsort() 會改爲用快速排序算法來排序。
qsort() 選擇分區點的方法是“三數取中法”,即,從區間的首、尾、中間,分別取出一個數,然後對比大小,取這 3 個數的中間值作爲分區點。這樣每間隔某個固定的長度,取數據出來比較,將中間值作爲分區點的分區算法。
qsort() 並不僅僅用到了歸併排序和快速排序,它還用到了插入排序。在快速排序的過程中,當要排序的區間中,元素的個數小於等於 4 時,qsort() 就退化爲插入排序,不再繼續用遞歸來做快速排序
3. 二分查找
二分思想,每次都與區間的中間數據比對大小,縮小查找區間的範圍。
二分查找針對的是一個有序的數據集合,查找思想有點類似分治思想。每次都通過跟區間的中間元素對比,將待查找的區間縮小爲之前的一半,直到找到要查找的元素,或者區間被縮小爲 0。
時間複雜度爲 O(logn)。O(logn) :對數時間複雜度。這是一種極其高效的時間複雜度,有的時候甚至比時間複雜度是常量級 O(1) 的算法還要高效。
3.1 簡單的二分查找
- 最簡單的情況就是有序數組中不存在重複元素,我們在其中用二分查找值等於給定值的數據。
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return -1;
}
- 用遞歸來實現
// 二分查找的遞歸實現
public int bsearch(int[] a, int n, int val) {
return bsearchInternally(a, 0, n - 1, val);
}
private int bsearchInternally(int[] a, int low, int high, int value) {
if (low > high) return -1;
int mid = low + ((high - low) >> 1);
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
return bsearchInternally(a, mid+1, high, value);
} else {
return bsearchInternally(a, low, mid-1, value);
}
}
- 應用場景的侷限性
- 二分查找依賴的是順序表結構,簡單點說就是數組
- 二分查找針對的是有序數據
- 數據量太小不適合二分查找
- 數據量太大也不適合二分查找
3.2 查找第一個值等於給定值的元素
有序數據集合中存在重複的數據,以數據是從小到大排列爲前提。
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
if ((mid == 0) || (a[mid - 1] != value)) return mid;
else high = mid - 1;
}
}
return -1;
}
3.3 查找最後一個值等於給定值的元素
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
else low = mid + 1;
}
}
return -1;
}
3.4 查找第一個大於等於給定值的元素
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] >= value) {
if ((mid == 0) || (a[mid - 1] < value)) return mid;
else high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
3.5 查找最後一個小於等於給定值的元素
public int bsearch7(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else {
if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
else low = mid + 1;
}
}
return -1;
}
4. 跳錶
鏈表加多級索引的結構,就是跳錶。跳錶使用空間換時間的設計思路,通過構建多級索引來提高查詢的效率,實現了基於鏈表的“二分查找”。跳錶是一種動態數據結構,支持快速的插入、刪除、查找操作,時間複雜度都是 O(logn)。跳錶的空間複雜度是 O(n)。不過,跳錶的實現非常靈活,可以通過改變索引構建策略,有效平衡執行效率和內存消耗。