查找算法
順序查找
查找成功最多要n 次,平均(n+1)/2次,時間復雜度為O(n)。
優點:既適用順序表也適用單鏈表,同時對表中元素順序無要求,給插入帶來方便,只需插入表尾即可。
缺點:速度較慢。
public int searchByOrder(int flagNum, int[] array, int n){
if(array!=null && n>0){
for(int i=0; i<n; i++){
if(array[i] == flagNum){
return I;
}
}
}
return -1;
}
改進:在表尾設置一個崗哨,這樣不用去循環判斷數組下標是否越界,因為最后必然成立。
public int searchByOrder(int flagNum, int[] array, int n){
int i = 0;
array[n] = flagNum; //哨崗,末位一定相等
if(array!=null && n>0){
for(i=0; ; i++){
if(array[i] == flagNum){
break;
}
}
}
if(i<n)return I;
return -1;
}
二分查找
適用條件:
- 必須為順序存儲結構。
- 必須按有序排列;
二分查找的判定樹不僅是二叉排序樹,而且是一棵理想平衡樹。時間復雜度為O(lbn)。
- 優點:比較次數少,查找速度快,適用于數據被存儲和排序后相對穩定的。
- 缺點:查找之前要為建立有序表付出代價,很少進行插入和刪除操作;只適應于順序存儲,不適應于鏈接存儲的有序表。
循環實現
public int binSearchByCycle(int flagNum, int[] array) {
if(array!=null && array.length>0){
int startInd = 0;
int endInd = array.length -1;
int midInd = 0;
while(startInd <=endInd) {
midInd = (startInd + endInd)/2;
if(array[midInd] == flagNum) {
return midInd; //返回下標
}else if(array[midInd] < flagNum) {
startInd = midInd+1;
}else {
endInd = midInd -1;
}
}
}
return -1; //找不到返回 -1
}
遞歸實現
public int binSearchByRecursion(int flagNum, int[] array, int startInd, int endInd) {
if(array!=null && array.length>0){
int midInd = (startInd + endInd)/2;
if(array[startInd] > flagNum || array[endInd] < flagNum || startInd < endInd) { //遞歸截止條件
return -1;
}
if(array[midInd] == flagNum) {
return midInd; //返回下標
}else if(array[midInd] < flagNum) {
return binSearchByRecursion(flagNum, array, midInd+1, endInd);
}else {
return binSearchByRecursion(flagNum, array, startInd, midInd -1);
}
}else {
return -1;
}
}
排序算法
約定
待排序的元素需要實現 Java 的 Comparable 接口,該接口有 compareTo() 方法,可以用它來判斷兩個元素的大小關系。
public abstract class Sort<T extends Comparable<T>> {
public abstract void sort(T[] nums);
protected boolean less(T v, T w) {
return v.compareTo(w) < 0;
}
protected void swap(T[] a, int i, int j) {
T t = a[I];
a[i] = a[j];
a[j] = t;
}
}
選擇排序
從數組中選擇最小元素,將它與數組的第一個元素交換位置。再從數組剩下的元素中選擇出最小的元素,將它與數組的第二個元素交換位置。不斷進行這樣的操作,直到將整個數組排序。
選擇排序需要 ~N2/2 次比較和 ~N 次交換,==它的運行時間與輸入無關==,這個特點使得它對一個已經排序的數組也需要這么多的比較和交換操作。
public class Selection<T extends Comparable<T>> extends Sort<T> {
@Override
public void sort(T[] nums) {
int N = nums.length;
for (int i = 0; i < N - 1; i++) { //確定第I個元素
int min = i; //最小元素下標
for (int j = i + 1; j < N; j++) {//依次和后面的比較
if (less(nums[j], nums[min])) {
min = j;
}
}
swap(nums, i, min);
}
}
}
冒泡排序
從左到右不斷交換相鄰逆序的元素,在一輪的循環之后,可以讓未排序的最大元素上浮到右側。
在一輪循環中,如果沒有發生交換,那么說明數組已經是有序的,此時可以直接退出。
public class Bubble<T extends Comparable<T>> extends Sort<T> {
@Override
public void sort(T[] nums) {
int N = nums.length;
boolean isSorted = false;
for (int i = N - 1; i > 0 && !isSorted; i--) { //從右到左確定第i個位置第數
isSorted = true;
for (int j = 0; j < i; j++) { //從左到右依次交換
if (less(nums[j + 1], nums[j])) {
isSorted = false;
swap(nums, j, j + 1);
}
}
}
}
}
插入排序
每次都將當前元素插入到左側已經排序的數組中,使得插入之后左側數組依然有序。
對于數組 {3, 5, 2, 4, 1},它具有以下逆序:(3, 2), (3, 1), (5, 2), (5, 4), (5, 1), (2, 1), (4, 1),插入排序每次只能交換相鄰元素,令逆序數量減少 1,因此插入排序需要交換的次數為逆序數量。
==插入排序的時間復雜度取決于數組的初始順序,如果數組已經部分有序了,那么逆序較少,需要的交換次數也就較少,時間復雜度較低==。
- 平均情況下插入排序需要 ~N2/4 比較以及 ~N2/4 次交換;
- 最壞的情況下需要 ~N2/2 比較以及 ~N2/2 次交換,最壞的情況是數組是倒序的;
- 最好的情況下需要 N-1 次比較和 0 次交換,最好的情況就是數組已經有序了。
public class Insertion<T extends Comparable<T>> extends Sort<T> {
@Override
public void sort(T[] nums) {
int N = nums.length;
for (int i = 1; i < N; i++) {
for (int j = i; j > 0 && less(nums[j], nums[j - 1]); j--) {
swap(nums, j, j - 1);
}
}
}
}
希爾排序
對于大規模的數組,插入排序很慢,因為它只能交換相鄰的元素,每次只能將逆序數量減少 1。希爾排序的出現就是為了解決插入排序的這種局限性,它通過交換不相鄰的元素,每次可以將逆序數量減少大于 1。
希爾排序使用插入排序對間隔 h 的序列進行排序。通過不斷減小 h,最后令 h=1,就可以使得整個數組是有序的。
public class Shell<T extends Comparable<T>> extends Sort<T> {
@Override
public void sort(T[] nums) {
int N = nums.length;
int h = 1;
while (h < N / 3) {
h = 3 * h + 1; // 1, 4, 13, 40, ...
}
while (h >= 1) {
for (int i = h; i < N; i++) {
for (int j = i; j >= h && less(nums[j], nums[j - h]); j -= h) {
swap(nums, j, j - h);
}
}
h = h / 3;
}
}
}
希爾排序的運行時間達不到平方級別,使用遞增序列 1, 4, 13, 40, ... 的希爾排序所需要的比較次數不會超過 N 的若干倍乘于遞增序列的長度。后面介紹的高級排序算法只會比希爾排序快兩倍左右。
歸并排序
歸并排序的思想是將數組分成兩部分,分別進行排序,然后歸并起來。
- 歸并方法
歸并方法將數組中兩個已經排序的部分歸并成一個。
public abstract class MergeSort<T extends Comparable<T>> extends Sort<T> {
protected T[] aux;
protected void merge(T[] nums, int l, int m, int h) {
int i = l, j = m + 1;
for (int k = l; k <= h; k++) {
aux[k] = nums[k]; // 將數據復制到輔助數組
}
for (int k = l; k <= h; k++) {
if (i > m) {
nums[k] = aux[j++];
} else if (j > h) {
nums[k] = aux[i++];
} else if (aux[i].compareTo(aux[j]) <= 0) {
nums[k] = aux[i++]; // 先進行這一步,保證穩定性
} else {
nums[k] = aux[j++];
}
}
}
}
- 自頂向下歸并排序
將一個大數組分成兩個小數組去求解。
因為每次都將問題對半分成兩個子問題,這種對半分的算法復雜度一般為 O(NlogN)。
public class Up2DownMergeSort<T extends Comparable<T>> extends MergeSort<T> {
@Override
public void sort(T[] nums) {
aux = (T[]) new Comparable[nums.length];
sort(nums, 0, nums.length - 1);
}
private void sort(T[] nums, int l, int h) {
if (h <= l) {
return;
}
int mid = l + (h - l) / 2;
sort(nums, l, mid);
sort(nums, mid + 1, h);
merge(nums, l, mid, h);
}
}
- 自底向上歸并排序
先歸并那些微型數組,然后成對歸并得到的微型數組。
public class Down2UpMergeSort<T extends Comparable<T>> extends MergeSort<T> {
@Override
public void sort(T[] nums) {
int N = nums.length;
aux = (T[]) new Comparable[N];
for (int sz = 1; sz < N; sz += sz) {
for (int lo = 0; lo < N - sz; lo += sz + sz) {
merge(nums, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1));
}
}
}
}
快速排序
- 基本算法
- 歸并排序將數組分為兩個子數組分別排序,并將有序的子數組歸并使得整個數組排序;
- 快速排序通過一個切分元素將數組分為兩個子數組,左子數組小于等于切分元素,右子數組大于等于切分元素,將這兩個子數組排序也就將整個數組排序了。
public class QuickSort<T extends Comparable<T>> extends Sort<T> {
@Override
public void sort(T[] nums) {
shuffle(nums);
sort(nums, 0, nums.length - 1);
}
private void sort(T[] nums, int l, int h) {
if (h <= l)
return;
int j = partition(nums, l, h);
sort(nums, l, j - 1);
sort(nums, j + 1, h);
}
private void shuffle(T[] nums) {
List<Comparable> list = Arrays.asList(nums);
Collections.shuffle(list);
list.toArray(nums);
}
}
- 切分
取 a[l] 作為切分元素,然后從數組的左端向右掃描直到找到第一個大于等于它的元素,再從數組的右端向左掃描找到第一個小于它的元素,交換這兩個元素。不斷進行這個過程,就可以保證左指針 i 的左側元素都不大于切分元素,右指針 j 的右側元素都不小于切分元素。當兩個指針相遇時,將切分元素 a[l] 和 a[j] 交換位置。
private int partition(T[] nums, int l, int h) {
int i = l, j = h + 1;
T v = nums[l];
while (true) {
while (less(nums[++i], v) && i != h) ;
while (less(v, nums[--j]) && j != l) ;
if (i >= j)
break;
swap(nums, i, j);
}
swap(nums, l, j);
return j;
}
- 性能分析
快速排序是原地排序,不需要輔助數組,但是遞歸調用需要輔助棧。
快速排序最好的情況下是每次都正好將數組對半分,這樣遞歸調用次數才是最少的。這種情況下比較次數為 CN=2CN/2+N,復雜度為 O(NlogN)。
最壞的情況下,第一次從最小的元素切分,第二次從第二小的元素切分,如此這般。因此最壞的情況下需要比較 N2/2。為了防止數組最開始就是有序的,在進行快速排序時需要隨機打亂數組。
- 算法改進
- 切換到插入排序
因為快速排序在小數組中也會遞歸調用自己,對于小數組,插入排序比快速排序的性能更好,因此在小數組中可以切換到插入排序。
- 三數取中
最好的情況下是每次都能取數組的中位數作為切分元素,但是計算中位數的代價很高。一種折中方法是取 3 個元素,并將大小居中的元素作為切分元素。
- 三向切分
對于有大量重復元素的數組,可以將數組切分為三部分,分別對應小于、等于和大于切分元素。
三向切分快速排序對于有大量重復元素的隨機數組可以在線性時間內完成排序。
public class ThreeWayQuickSort<T extends Comparable<T>> extends QuickSort<T> {
@Override
protected void sort(T[] nums, int l, int h) {
if (h <= l) {
return;
}
int lt = l, i = l + 1, gt = h;
T v = nums[l];
while (i <= gt) {
int cmp = nums[i].compareTo(v);
if (cmp < 0) {
swap(nums, lt++, i++);
} else if (cmp > 0) {
swap(nums, i, gt--);
} else {
I++;
}
}
sort(nums, l, lt - 1);
sort(nums, gt + 1, h);
}
}
- 基于切分的快速選擇算法
快速排序的 partition() 方法,會返回一個整數 j 使得 a[l..j-1] 小于等于 a[j],且 a[j+1..h] 大于等于 a[j],此時 a[j] 就是數組的第 j 大元素。
可以利用這個特性找出數組的第 k 大的元素。
該算法是線性級別的,假設每次能將數組二分,那么比較的總次數為 (N+N/2+N/4+..),直到找到第 k 個元素,這個和顯然小于 2N。
public T select(T[] nums, int k) {
int l = 0, h = nums.length - 1;
while (h > l) {
int j = partition(nums, l, h);
if (j == k) {
return nums[k];
} else if (j > k) {
h = j - 1;
} else {
l = j + 1;
}
}
return nums[k];
}
堆排序
- 堆
堆中某個節點的值總是大于等于其子節點的值,并且堆是一顆完全二叉樹。
堆可以用數組來表示,這是因為堆是完全二叉樹,而完全二叉樹很容易就存儲在數組中。位置 k 的節點的父節點位置為 k/2,而它的兩個子節點的位置分別為 2k 和 2k+1。這里不使用數組索引為 0 的位置,是為了更清晰地描述節點的位置關系。
public class Heap<T extends Comparable<T>> {
private T[] heap;
private int N = 0;
public Heap(int maxN) {
this.heap = (T[]) new Comparable[maxN + 1];
}
public boolean isEmpty() {
return N == 0;
}
public int size() {
return N;
}
private boolean less(int i, int j) {
return heap[i].compareTo(heap[j]) < 0;
}
private void swap(int i, int j) {
T t = heap[I];
heap[i] = heap[j];
heap[j] = t;
}
}
- 上浮和下層
在堆中,當一個節點比父節點大,那么需要交換這個兩個節點。交換后還可能比它新的父節點大,因此需要不斷地進行比較和交換操作,把這種操作稱為上浮。
private void swim(int k) {
while (k > 1 && less(k / 2, k)) {
swap(k / 2, k);
k = k / 2;
}
}
類似地,當一個節點比子節點來得小,也需要不斷地向下進行比較和交換操作,把這種操作稱為下沉。一個節點如果有兩個子節點,應當與兩個子節點中最大那個節點進行交換。
private void sink(int k) {
while (2 * k <= N) {
int j = 2 * k;
if (j < N && less(j, j + 1))
j++;
if (!less(k, j))
break;
swap(k, j);
k = j;
}
}
- 插入元素
將新元素放到數組末尾,然后上浮到合適的位置。
public void insert(Comparable v) {
heap[++N] = v;
swim(N);
}
- 刪除最大元素
從數組頂端刪除最大的元素,并將數組的最后一個元素放到頂端,并讓這個元素下沉到合適的位置。
public T delMax() {
T max = heap[1];
swap(1, N--);
heap[N + 1] = null;
sink(1);
return max;
}
- 堆排序
把最大元素和當前堆中數組的最后一個元素交換位置,并且不刪除它,那么就可以得到一個從尾到頭的遞減序列,從正向來看就是一個遞增序列,這就是堆排序。
-
構建堆:無序數組建立堆最直接的方法是從左到右遍歷數組進行上浮操作。一個更高效的方法是從右至左進行下沉操作,如果一個節點的兩個節點都已經是堆有序,那么進行下沉操作可以使得這個節點為根節點的堆有序。葉子節點不需要進行下沉操作,可以忽略葉子節點的元素,因此只需要遍歷一半的元素即可。
image.png
-
交換堆頂元素與最后一個元素: 交換之后需要進行下沉操作維持堆的有序狀態。
image.png
public class HeapSort<T extends Comparable<T>> extends Sort<T> {
/**
* 數組第 0 個位置不能有元素
*/
@Override
public void sort(T[] nums) {
int N = nums.length - 1;
for (int k = N / 2; k >= 1; k--)
sink(nums, k, N);
while (N > 1) {
swap(nums, 1, N--);
sink(nums, 1, N);
}
}
private void sink(T[] nums, int k, int N) {
while (2 * k <= N) {
int j = 2 * k;
if (j < N && less(nums, j, j + 1))
j++;
if (!less(nums, k, j))
break;
swap(nums, k, j);
k = j;
}
}
private boolean less(T[] nums, int i, int j) {
return nums[i].compareTo(nums[j]) < 0;
}
}
- 分析
一個堆的高度為logN,因此在堆中插入元素和刪除最大元素的復雜度都為 logN。
對于堆排序,由于要對 N 個節點進行下沉操作,因此復雜度為 NlogN。
堆排序是一種原地排序,沒有利用額外的空間。
現代操作系統很少使用堆排序,因為它無法利用局部性原理進行緩存,也就是數組元素很少和相鄰的元素進行比較和交換。
計數排序
計數排序的核心在于將輸入的數據值轉化為鍵存儲在額外開辟的數組空間中。作為一種線性時間復雜度的排序,==計數排序要求輸入的數據必須是有確定范圍的整數==。
當輸入的元素是 n 個 0 到 k 之間的整數時,它的==運行時間是 O(n + k)==。計數排序不是比較排序,排序的速度快于任何比較排序算法。由于用來計數的數組C的長度取決于待排序數組中數據的范圍(等于待排序數組的最大值與最小值的差加上1),這使得計數排序對于數據范圍很大的數組,需要大量時間和內存。比較適合用來排序==小范圍非負整數數組的數組==。
public int[] sort(int[] arr) throws Exception {
int maxValue = getMaxValue(arr);
return countingSort(arr, maxValue);
}
//1.找出最大值
private int getMaxValue(int[] arr) {
int max = arr[0];
for(int valus : arr){
if(valus > max){
max = valus;
}
}
return max;
}
private int[] countingSort(int[] arr, int maxValue){
int len = maxValue + 1;
int[] bucket = new int[len];
//2. 值轉換為數組位置,數組存數值出現的次數
for(int value : arr){
bucket[value]++;
}
//3. 順序填充數組
int sortIndex = 0;
for(int i = 0; i < len; i++){
while(bucket[i] > 0){
arr[sortIndex++] = i;
bucket[i]--; //次數減1
}
}
return arr;
}
桶排序
桶排序是計數排序的升級版。它利用了函數的映射關系,高效與否的關鍵就在于這個映射函數的確定。為了使桶排序更加高效,我們需要做到這兩點:
- 在額外空間充足的情況下,盡量增大桶的數量;
- 使用的映射函數能夠將輸入的N個數據均勻的分配到 K個桶中。
同時,對于桶中元素的排序,選擇何種比較排序算法對于性能的影響至關重要。
當輸入數據均勻分配到每一個桶時最快,當都分配到同一個桶時最慢。
實間復雜度N*K
public int[] bucketSort(int[] arr, int bucketSize){
if(arr == null || arr.length == 0){
return arr;
}
int minValue = arr[0];
int maxValue = arr[0];
for(int value : arr){
if(value < minValue){
min = value;
}else if(value maxValue){
maxValue = value;
}
}
int bucketCount = (int)Math.floor((maxValue - minValue)/bucketSize) + 1;
int[][] buckets = new int[bucketCount][0];
//利用映射函數將數據分配到各個桶中,小的數組在前面的桶里
for(int i = 0; i < arr.length; i++){
int index = (int) Math.floor((arr[i] - minValue)/bucketSize); //第幾個桶下標
buckets[index] = arrAppend(buckets[index], arr[I]);
}
int index = 0;
for(int[] bucket : buckets){
if(bucket.length <= 0){
continue; //桶里沒分配到
}
//對每個桶進行排序,這里使用插入排序,也可根據實際使用其他排序算法
bucket = InsertSort.sort(bucket);
for(int value : bucket){
arr[index++] = value;
}
}
return arr;
}
//自動擴容
private int[] arrAppend(int[] arr, int value){
arr = Arrays.copyOf(arr, arr.length + 1);
arr[arr.length - 1] = value;
return arr;
}
小結
-
排序算法的比較
image.png
快速排序是最快的通用排序算法,它的內循環的指令很少,而且它還能利用緩存,因為它總是順序地訪問數據。它的運行時間近似為 ~cNlogN,這里的 c 比其它線性對數級別的排序算法都要小。
使用三向切分快速排序,實際應用中可能出現的某些分布的輸入能夠達到線性級別,而其它排序算法仍然需要線性對數時間。
- Java 主要排序方法為 java.util.Arrays.sort(),對于原始數據類型使用三向切分的快速排序,對于引用類型使用歸并排序。