一. 最小生成樹算法
- 連通圖:在無向圖G中,若從頂點i到頂點j有路徑,則稱頂點i和頂點j是連通的。若圖G中任意兩個頂點都連通,則稱G爲連通圖。
- 生成樹:一個連通圖的生成樹是該連通圖的一個極小連通子圖,它含有全部頂點,但只有構成一個數的(n-1)條邊。
- 最小生成樹:對於一個帶權連通無向圖G中的不同生成樹,各樹的邊上的 權值之和最小。構造最小生成樹的準則有三條:
- 必須只使用該圖中的邊來構造最小生成樹。
- 必須使用且僅使用(n-1)條邊來連接圖中的n個頂點。
- 不能使用產生迴路的邊。
1. Prim算法
假設G=(V,E)是一個具有n個頂點的帶權連通無向圖,T(U,TE)是G的最小生成樹,其中U是T的頂點集,TE是T的邊集,則由G構造從起始頂點v出發的最小生成樹T的步驟爲:
- 初始化U={v},以v到其他頂點的所有邊爲候選邊(U中所有點到其他頂點的邊)。
- 重複以下步驟(n-1)次,使得其他(n-1)個頂點被加入到U中。
- 從候選邊中挑選權值最小的邊加入TE,設該邊在V-U(這裏是集合減)中的頂點是k,將k加入U中。
- 考察當前V-U中的所有頂點j,修改候選邊,若邊(k,j)的權值小於原來和頂點j關聯的候選邊,則用(k,j)取代後者作爲候選邊。
2. Kruskal算法
假設G=(V,E)是一個具有n個頂點的帶權連通無向圖,T(U,TE)是G的最小生成樹,其中U是T的頂點集,TE是T的邊集,則由G構造從起始頂點v出發的最小生成樹T的步驟爲:
- 置U的初始值等於V(即包含G中的全部頂點),TE的初始值爲空
- 將圖G中的邊按權值從小到大的順序依次選取,若選取的邊未使生成樹T形成迴路,則加入TE,否則放棄,知道TE中包含(n-1)條邊爲止。
二. 最短路徑算法
1. Dijkstra —— 貪心算法
從一個頂點到其餘頂點的最短路徑
設G=(V,E)是一個帶權有向圖,把圖中頂點集合V分成兩組,第1組爲已求出最短路徑的頂點(用S表示,初始時S只有一個源點,以後每求得一條最短路徑v,...k,就將k加到集合S中,直到全部頂點都加入S)。第2組爲其餘未確定最短路徑的頂點集合(用U表示),按最短路徑長度的遞增次序把第2組的頂點加入S中。
步驟:
- 初始時,S只包含源點,即
S={v}
,頂點v到自己的距離爲0。U包含除v外的其他頂點,v到U中頂點i的距離爲邊上的權。 - 從U中選取一個頂點u,頂點v到u的距離最小,然後把頂點u加入S中。
- 以頂點u爲新考慮的中間點,修改v到U中各個點的距離。
- 重複以上步驟知道S包含所有頂點。
2. Floyd —— 動態規劃
Floyd 算法是解決任意兩點間的最短路徑的一種算法,可以正確處理有向圖或負權(但不可存在負權迴路)的最短路徑問題。該算法的時間複雜度爲$$O(N^{3})$$
,空間複雜度爲 $$O(N^{2})$$
設$$D_{i,j,k}$$
爲從$$i$$
到$$j$$
的只以$$(1..k)$$
集合中的節點爲中間節點的最短路徑的長度。
$$ D{i,j,k}=\begin{cases} D{i,j,k-1} &
最短路徑不經過k D{i,k,k-1}+D{k,j,k-1} &
最短路徑經過k \end{cases} $$
因此,$$D{i,j,k}=min(D{i,k,k-1}+D{k,j,k-1},D{i,j,k-1})$$
。僞代碼描述如下:
// let dist be a |V| × |V| array of minimum distances initialized to ∞ (infinity)
for each vertex v
dist[v][v] ← 0
for each edge (u,v)
dist[u][v] ← w(u,v) // the weight of the edge (u,v)
for k from 1 to |V|
for i from 1 to |V|
for j from 1 to |V|
if dist[i][j] > dist[i][k] + dist[k][j]
dist[i][j] ← dist[i][k] + dist[k][j]
end if
三. KMP算法
KMP算法解決的問題是字符匹配,這個算法把字符匹配的時間複雜度縮小到O(m+n),而空間複雜度也只有O(m),n是target的長度,m是pattern的長度。
- 部分匹配表(Next數組):表的作用是 讓算法無需多次匹配S中的任何字符。能夠實現線性時間搜索的關鍵是 在不錯過任何潛在匹配的情況下,我們”預搜索”這個模式串本身並將其譯成一個包含所有可能失配的位置對應可以繞過最多無效字符的列表。
- Next數組(前綴和前綴的比較):t爲模式串,j爲下標
Next[0] = -1
Next[j] = MAX{ k | 0 < k < j | " t0 t1 ... tk " = "t ( j-k ) t ( j-k+1 ) ... t( j-1 )" }
|i| 0| 1| 2| 3| 4| 5 |6| |–| | t[i]| A| B| C| D| A| B| D| |next[i]| -1| 0 |0 |0 |0 |1 |2|
- NextVal數組:是一種優化後的Next數組,是爲了解決類似aaaab這種模式串的匹配,減少重複的比較。 如果
t[next[j]]=t[j]:nextval[j]=nextval[next[j]]
,否則nextval[j]=next[j]
。
|i| 0| 1| 2| 3| 4| 5 |6| |–| | t | a| b| c| a| b| a |a| |next[j] | -1| 0 |0 |0 |1 |2 |1| |nextval[j] | -1| 0 |0 |-1 |0 |2 |1|
在上面的表格中,t[next[4]]=t[4]=b
,所以nextval[4]=nextval[next[4]]=0
四. 查找算法
1. ASL
由於查找算法的主要運算是關鍵字的比較,所以通常把查找過程中對關鍵字的平均比較次數(平均查找長度)作爲衡量一個查找算法效率的標準。ASL= ∑(n,i=1) Pi*Ci,其中n爲元素個數,Pi是查找第i個元素的概率,一般爲Pi=1/n,Ci是找到第i個元素所需比較的次數。
2. 順序查找
原理是讓關鍵字與隊列中的數從最後一個開始逐個比較,直到找出與給定關鍵字相同的數爲止,它的缺點是效率低下。時間複雜度o(n)。
3. 折半查找
折半查找要求線性表是有序表。搜索過程從數組的中間元素開始,如果中間元素正好是要查找的元素,則搜索過程結束;如果某一特定元素大於或者小於中間元素,則在數組大於或小於中間元素的那一半中查找,而且跟開始一樣從中間元素開始比較。如果在某一步驟數組爲空,則代表找不到。這種搜索算法每一次比較都使搜索範圍縮小一半。折半搜索每次把搜索區域減少一半,時間複雜度爲O(log n)。
- 可以藉助二叉判定樹求得折半查找的平均查找長度:log2(n+1)-1。
- 折半查找在失敗時所需比較的關鍵字個數不超過判定樹的深度,n個元素的判定樹的深度和n個元素的完全二叉樹的深度相同log2(n)+1。
public int binarySearchStandard(int[] num, int target){
int start = 0;
int end = num.length - 1;
while(start <= end){
//注意1
int mid = start + ((end - start) >> 1);
if(num[mid] == target)
return mid; else if(num[mid] > target){
end = mid - 1;
//注意2
} else{
start = mid + 1;
//注意3
}
}
return -1;
}
- 如果是start < end,那麼當target等於num[num.length-1]時,會找不到該值。
- 因爲num[mid] > target, 所以如果有num[index] == target, index一定小於mid,能不能寫成end = mid呢?舉例來說:num = {1, 2, 5, 7, 9}; 如果寫成end = mid,當循環到start = 0, end = 0時(即num[start] = 1, num[end] = 1時),mid將永遠等於0,此時end也將永遠等於0,陷入死循環。也就是說尋找target = -2時,程序將死循環。
- 因爲num[mid] < target, 所以如果有num[index] == target, index一定大於mid,能不能寫成start = mid呢?舉例來說:num = {1, 2, 5, 7, 9}; 如果寫成start = mid,當循環到start = 3, end = 4時(即num[start] = 7, num[end] = 9時),mid將永遠等於3,此時start也將永遠等於3,陷入死循環。也就是說尋找target = 9時,程序將死循環。
4. 分塊查找
分塊查找又稱索引順序查找,它是一種性能介於順序查找和折半查找之間的查找方法。分塊查找由於只要求索引表是有序的,對塊內節點沒有排序要求,因此特別適合於節點動態變化的情況。
五. 排序算法
1. 常見排序算法
穩定排序:
- 冒泡排序 — O(n²)
- 插入排序 — O(n²)
- 桶排序 — O(n); 需要 O(k) 額外空間
- 歸併排序 — O(nlogn); 需要 O(n) 額外空間
- 二叉排序樹排序 — O(n log n) 期望時間; O(n²)最壞時間; 需要 O(n) 額外空間
- 基數排序 — O(n·k); 需要 O(n) 額外空間
不穩定排序:
- 選擇排序 — O(n²)
- 希爾排序 — O(nlogn)
- 堆排序 — O(nlogn)
- 快速排序 — O(nlogn) 期望時間, O(n²) 最壞情況; 對於大的、亂數串行一般相信是最快的已知排序
2. 交換排序
冒泡排序
它重複地走訪過要排序的數列,一次比較兩個元素,如果他們的順序錯誤就把他們交換過來。走訪數列的工作是重複地進行直到沒有再需要交換,也就是說該數列已經排序完成。冒泡排序總的平均時間複雜度爲O(n^2)。冒泡排序是一種穩定排序算法。 - 比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。 - 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。在這一點,最後的元素應該會是最大的數。 - 針對所有的元素重複以上的步驟,除了最後一個。 - 持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較。
void bubble_sort(int a[], int n)
{
int i, j, temp;
for (j = 0; j < n - 1; j++)
for (i = 0; i < n - 1 - j; i++)
{
if(a[i] > a[i + 1])
{
temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
}
}
}
快速排序
快速排序是一種 不穩定 的排序算法,平均時間複雜度爲 O(nlogn)。快速排序使用分治法(Divide and conquer)策略來把一個序列(list)分爲兩個子序列(sub-lists)。 步驟爲:
- 從數列中挑出一個元素,稱爲”基準”(pivot),
- 重新排序數列,所有元素比基準值小的擺放在基準前面,所有元素比基準值大的擺在基準的後面(相同的數可以到任一邊)。在這個分區結束之後,該基準就處於數列的中間位置。這個稱爲分區(partition)操作。
- 遞歸地(recursive)把小於基準值元素的子數列和大於基準值元素的子數列排序。
快排的時間花費主要在劃分上,所以 - 最壞情況:時間複雜度爲O(n^2)。因爲最壞情況發生在每次劃分過程產生的兩個區間分別包含n-1個元素和1個元素的時候。 - 最好情況:每次劃分選取的基準都是當前無序區的中值。如果每次劃分過程產生的區間大小都爲n/2,則快速排序法運行就快得多了。
public void sort(int[] arr, int low, int high) {
int l = low;
int h = high;
int povit = arr[low];
while (l < h) {
while (l < h && arr[h] >= povit)
h--;
if (l < h) {
arr[l] = arr[h];
l++;
}
while (l < h && arr[l] <= povit)
l++;
if (l < h) {
arr[h] = arr[l];
h--;
}
}
arr[l] = povit;
System.out.print("l=" + (l + 1) + ";h=" + (h + 1) + ";povit=" + povit + "n");
System.out.println(Arrays.toString(arr));
if (l - 1 > low) sort(arr, low, l - 1);
if (h + 1 < high) sort(arr, h + 1, high);
}
快排的優化
- 當待排序序列的長度分割到一定大小後,使用插入排序。
- 快排函數在函數尾部有兩次遞歸操作,我們可以對其使用尾遞歸優化。優化後,可以縮減堆棧深度,由原來的O(n)縮減爲O(logn),將會提高性能。
- 從左、中、右三個數中取中間值。
3. 插入排序
直接插入排序
插入排序的基本操作就是將一個數據插入到已經排好序的有序數據中,從而得到一個新的、個數加一的有序數據,算法適用於少量數據的排序,時間複雜度爲O(n^2)。是穩定的排序方法。 插入算法把要排序的數組分成兩部分:第一部分包含了這個數組的所有元素,但將最後一個元素除外(讓數組多一個空間纔有插入的位置),而第二部分就只包含這一個元素(即待插入元素)。在第一部分排序完成後,再將這個最後元素插入到已排好序的第一部分中。
void insert_sort(int* a, int len) {
for (int i = 1; i < len; ++i) {
int j = i - 1;
int temp = a[i];
while (j >= 0 && temp < a[j]) {
a[j + 1] = a[j];
j--;
}
a[j + 1] = temp;
}
}
希爾排序
也稱縮小增量排序,是直接插入排序算法的一種更高效的改進版本。希爾排序是非穩定排序算法。
希爾排序是把記錄按下標的一定增量分組,對每組使用直接插入排序算法排序;隨着增量逐漸減少,每組包含的關鍵詞越來越多,當增量減至1時,整個文件恰被分成一組,算法便終止。
void shell_sort(int* a, int len) {
int step = len / 2;
int temp;
while (step > 0) {
for (int i = step; i < len; ++i) {
temp = a[i];
int j = i - step;
while (j >= 0 && temp < a[j]) {
a[j + step] = a[j];
j -= step;
}
a[j + step] = temp;
}
step /= 2;
}
}
4. 選擇排序
直接選擇排序
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然後,再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。實際適用的場合非常罕見。
void selection_sort(int arr[], int len) {
int i, j, min, temp;
for (i = 0; i < len - 1; i++) {
min = i;
for (j = i + 1; j < len; j++)
if (arr[min] > arr[j])
min = j;
temp = arr[min];
arr[min] = arr[i];
arr[i] = temp;
}
}
堆排序
堆排序利用了大根堆(或小根堆)堆頂記錄的關鍵字最大(或最小)這一特徵,使得在當前無序區中選取最大(或最小)關鍵字的記錄變得簡單。
- 將數組分爲有序區和無序區,在無序區中建立最大堆
- 將堆頂的數據與無序區末尾的數據交換
- 從後往前,直到所有數據排序完成
public void heapSort(int[] nums) {
for (int i = nums.length - 1; i >= 0; i--) {
maxHeap(nums, 0, i);
swap(nums, 0, i);
}
}
public void maxHeap(int[] heap, int start, int end) {
if (start == end) {
return;
}
int parent = start;
int childLeft = start * 2 + 1;
int childRight = childLeft + 1;
if (childLeft <= end) {
maxHeap(heap, childLeft, end);
if (heap[childLeft] > heap[parent]) {
swap(heap, parent, childLeft);
}
}
if (childRight <= end) {
maxHeap(heap, childRight, end);
if (heap[childRight] > heap[parent]) {
swap(heap, parent, childRight);
}
}
}
private void swap(int[] nums, int a, int b) {
int t = nums[a];
nums[a] = nums[b];
nums[b] = t;
}
5. 歸併排序
歸併排序採用分治的思想: - Divide:將n個元素平均劃分爲各含n/2個元素的子序列; - Conquer:遞歸的解決倆個規模爲n/2的子問題; - Combine:合併倆個已排序的子序列。
性能:時間複雜度總是爲O(NlogN),空間複雜度也總爲爲O(N),算法與初始序列無關,排序是穩定的。
public void mergeSort(int[] array, int start, int end, int[] temp) {
if (start >= end) {
return;
}
int mid = (start + end) / 2;
mergeSort(array, start, mid, temp);
mergeSort(array, mid + 1, end, temp);
int f = start, s = mid + 1;
int t = 0;
while (f <= mid && s <= end) {
if (array[f] < array[s]) {
temp[t++] = array[f++];
} else {
temp[t++] = array[s++];
}
}
while (f <= mid) {
temp[t++] = array[f++];
}
while (s <= end) {
temp[t++] = array[s++];
}
for (int i = 0, j = start; i < t; i++) {
array[j++] = temp[i];
}
}
6. 基數排序
對於有d個關鍵字時,可以分別按關鍵字進行排序。有倆種方法: - MSD:先從高位開始進行排序,在每個關鍵字上,可採用基數排序 - LSD:先從低位開始進行排序,在每個關鍵字上,可採用桶排序
即通過每個數的每位數字的大小來比較
//找出最大數字的位數
int maxNum(int arr[], int len) {
int _max = 0;
for (int i = 0; i < len; ++i) {
int d = 0;
int a = arr[i];
while (a) {
a /= 10;
d++;
}
if (_max < d) {
_max = d;
}
}
return _max;
}
void radixSort(int *arr, int len) {
int d = maxNum(arr, len);
int *temp = new int[len];
int count[10];
int radix = 1;
for (int i = 0; i < d; ++i) {
for (int j = 0; j < 10; ++j) {
count[j] = 0;
}
for (int k = 0; k < len; ++k) {
count[(arr[k] / radix) % 10]++;
}
for (int l = 1; l < 10; ++l) {
count[l] += count[l - 1];
}
for (int m = 0; m < len; ++m) {
int index = (arr[m] / radix) % 10;
temp[count[index] - 1] = arr[m];
count[index]--;
}
for (int n = 0; n < len; ++n) {
arr[n] = temp[n];
}
radix *= 10;
}
delete (temp);
}
7. 拓撲排序
在有向圖中找拓撲序列的過程,就是拓撲排序。拓撲序列常常用於判定圖是否有環。
- 從有向圖中選擇一個入度爲0的結點,輸出它。
- 將這個結點以及該結點出發的所有邊從圖中刪除。
- 重複前兩步,直到沒有入度爲0的點。
如果所有點都被輸出,即存在一個拓撲序列,則圖沒有環。