面試中可能被問到的常用排序演算法
排序演算法
排序演算法是一種比較簡單的演算法,從我們一開始接觸計算機程式設計開始接觸的可能就是排序或者搜尋一類的演算法,但是因為排序在其他的一些演算法中應用較多,所以為了提高效能已經研究了多種排序演算法。目前區別排序演算法主要還是以時間複雜度,空間複雜度,穩定性等來排序,接下來我們分別分析。
穩定性演算法
區別一個排序演算法是否是穩定演算法只需看相同的關鍵字在排序完成後是否保持原來兩者的前後關係即可,比如對於[1,2,3,4,1],a[0]=a[4],a[0]在a[4]之前,穩定的排序在排序完成後a[0]依舊在a[4]的前面,反之則是不穩定排序演算法。
氣泡排序
基本原理
氣泡排序(Bubble Sort)是一種比較簡單的排序演算法。基本原理為選定一個數作為比較標準,遍歷整個陣列比較兩個數的大小,如果順序不對則進行交換,知道沒有再需要交換的數為止。氣泡排序是穩定的排序演算法
氣泡排序演算法的運作如下:
- 比較相鄰的兩個元素。並根據需要進行交換,如果需要正序,那麼就將較大的放在後面,倒敘則將較小的放在後面。
- 對每一組相鄰元素同樣的操作。這步做完後,最後的元素會是最大的數。
- 外迴圈對除已經選擇出的元素重複上面的步驟,直到沒有任何一對數字需要比較,表示排序已經完成。
程式碼
public static void bubbleSort(int[] arr){ for(int i=0;i<arr.length;i++){ for(int j=0;j<arr.length-i-1;j++){ if(arr[j] > arr[j+1]){ swap(arr, j, j+1); } } } }
複雜度
如果序列的初始狀態是正序的,一趟掃描即可完成排序,不需要交換操作。經過n次的迴圈後排序完成,所以時間複雜度為O(n),整個過程沒有使用輔助空間,空間複雜度為O(1)。
選擇排序
選擇排序(Selection sort)是一種很簡單排序演算法。它要求是每一次從待排序的元素中選出最小(最大)的一個元素,存放在起始位置,然後再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到上一位已經排好序的後面。以此類推,直到全部待排序的資料元素排完。 選擇排序是不穩定的排序方法。
選擇排序演算法的運作如下:
- 第一次遍歷整個資料序列,查找出最大(小)的數。並將該數放在第一位置。
- 將已經排序好的位置除外,剩下的資料序列重複進行上面的操作。
程式碼
public static void insertSort(int[] arr){ for(int i=0;i<arr.length;i++){ //一趟之後最小的數到了下標為i的位置 for(int j=i+1;j<arr.length;j++){ if(arr[i] > arr[j]){ swap(arr, i, j); } } } }
複雜度
如果資料本身就是有序的,0次交換;最壞的情況下需要進行n-1次交換;比較操作次數固定為N^2/2,時間複雜度為O(n^2),空間複雜度為O(1)。
直接插入排序
插入排序是比較簡單的排序方法,插入排序將待排序陣列分為兩部分,一部分是已排序部分,另一部分則是待排序部分。最開始僅第一個數字為已排序部分。然後每次從待排序部分取出一個數,同已排序部分的資料進行比較,選出剛好前一個數比該數小,後一個數比該數大(第一位除外),將該數放在這個位置。進過遍歷後整個陣列有序。
選擇排序演算法的運作如下:
- 將第一個數選擇為已排序部分,取第二個數同第一個數比較,如果大於第一個數則不變,小於則交換位置。上述過程完成後將前兩個數字作為已排序部分。
- 再次從待排序部分取出一個數字,重複上訴步驟找出該數的位置。從後向前掃描過程中,需要反覆把已排序元素逐步向後挪位,為最新元素提供插入空間。
- 重複上面的步驟直到將資料全部遍歷完成則表示陣列有序。
程式碼
public static void insertSort(int[] nums){ int i,j; for(i=1;i<nums.length;i++){ int temp = nums[i]; //將元素後移 for(j=i-1;j>=0&&temp<nums[j];j--){ nums[j+1] = nums[j]; } nums[j+1] = temp; } }
複雜度
在將n個元素的序列進行升序或者降序排列,採用插入排序最好情況就是序列已經是有序的了,在這種情況下,需要進行的比較操作需n-1次即可。最壞情況就是序列是反序的,那麼此時需要進行的比較共有n(n-1)/2次。平均來說插入排序演算法複雜度為 O(n^2)。所以插入排序不適合對於資料量比較大的排序應用。但是在需要排序的資料量很小或者若已知輸入元素大致上按照順序排列,插入排序的效率還是不錯。
帶哨兵的插入排序
在插入排序的時候,我們看到每一次進行比較都有兩次比較操作j>=0&&temp<nums[j]
,即既要保證不越界又要判斷資料是否符合條件,假設在反序的情況下就幾乎多出一倍的比較次數。這裡我們使用一個哨兵來消除掉多的比較操作。
程式碼
public static void insertWithSentinelSort(int[] nums){ int i,j; for(i=1;i<nums.length;i++){ //將第一個元素指定為哨兵 //要求傳入的陣列比原陣列長度大1 nums[0] = nums[i]; //將元素後移 //這裡只需比較資料是否符合條件 for(j=i-1;nums[j]>nums[0];j--){ nums[j+1] = nums[j]; } nums[j+1] = nums[0]; } }
新增哨兵的好處就是將原本的比較次數減少,提高了演算法效率。
希爾排序
希爾排序是插入排序的一種更高效的改進版本。希爾排序是非穩定排序演算法。
希爾排序是把記錄按下標的一定的步長進行分組,對每組資料使用直接插入排序演算法排序;隨著步長逐漸減少,每組包含的關鍵詞越來越多,當步長為1時,剛好就是一個插入排序。而在此時整個資料序列已經基本有序,插入排序在對幾乎已經排好序的資料操作時,效率高,可以達到線性排序的效率。所以希爾排序的整體效率較高。
希爾排序的步驟:
- 選擇步長大小,根據步長將資料分組
- 迴圈對每一組進行排序
- 修改步長的大小(一般為一半,也可以通過步長陣列指定),重複1-2操作
- 直到步長為1進行排序後停止
程式碼
public static void shellSort(int[] nums){ int size = nums.length/2; int i,j; while(size>=1){ for(i=0;i<nums.length;i++){ for(j=i;j+size<nums.length;j+=size){ if(nums[j]>nums[j+size]){ swap(nums, j, j+size); } } } size/=2; } }
複雜度
希爾排序的時間複雜度分析比較複雜,因為它和所選取的步長有著直接的關係。步長的選取沒有一個統一的定論,只需要使得步長最後為1即可。希爾排序的時間複雜度根據所選取的步長不同時間複雜度範圍在o(n^1.3)~o(n^2)。
快速排序
快速排序是對氣泡排序的改進。
快排的基本步驟:
- 從待排序列中選取一個數(一般為第一個),進行一次排序,將大於該數的放在該數前面,小於該數的放在其後面。
- 上述操作將待排序列分為兩個獨立的部分,遞迴的進行上面的操作,直到序列無法再被分割。
- 最後一次排序後序列中是有序的。
程式碼
public static void quickSort(int[] nums, int low, int high){ if(low<high){ int partation = partition(nums, low, high); //這裡返回的low值的位置已經確定了 所以不用參與排序 quickSort(nums, 0, low-1); quickSort(nums, low+1, high); } } //進行一次排序 將待排序列分為兩個部分 public static int partition(int[] nums, int low, int high){ //選取第一個值為樞紐值 int pivo = nums[low]; while(low<high){ while(low<high&&nums[high]>=pivo){ high--; } nums[low] = nums[high]; while(low<high&&nums[low]<=pivo){ low++; } nums[high]=nums[low]; } nums[low] = pivo; return low; }
複雜度
時間複雜度
在最優情況下,Partition每次都劃分得很均勻,如果排序n個關鍵字,其遞迴的深度就為log2n+1,即僅需遞迴log2n 次。時間複雜度為O(nlogn)。
最糟糕的情況就是待排序列為需要排序方向的逆序。每次劃分只得到一個比上一次劃分少一個記錄的子序列。這時快排退化為氣泡排序。時間複雜度為O(n^2)。
快排的平均複雜度為O(nlogn),證明過程較長,直接貼個連結 吧。
空間複雜度
被快速排序所使用的空間,根據上面我們實現的程式碼來看,在任何遞迴呼叫前,僅會使用固定的額外空間。然而,如果需要產生 o(logn)巢狀遞迴呼叫,它需要在他們每一個儲存一個固定數量的資訊。因為最好的情況最多需要O(logn)次的巢狀遞迴呼叫,所以它需要O(logn)的空間。最壞情況下需要 O(n)次巢狀遞迴呼叫,因此需要O(n)的空間。
歸併排序
歸併是指將兩個及以上的有序序列合併成一個有序序列。
歸併排序步驟:
- 申請一個和待排序列長度相同的空間空間該空間用來存放合併後的序列
- 設定兩個數為對陣列中位置的指向,最初位置分別為兩個已經排序序列的起始位置
- 比較兩個指標所指向的元素,選擇小的元素放入到合併空間,並移動被選擇的數的指標到下一位置
- 重複步驟3直到某一指標到達指定的序列尾部
- 將另一序列剩下的所有元素直接複製到合併序列尾
程式碼
public static void mergeSort(int[] nums, int[] temp, int left, int right){ if(left<right){ int mid = (left+right)/2; mergeSort(nums, temp,left,mid); mergeSort(nums, temp,mid+1,right); merge(nums,temp, mid, left, right); } } public static void merge(int[] nums, int[] temp, int mid, int left, int right){ int i=left,j=mid+1,k=0; while(i<=mid&&j<=right){ if(nums[i]<nums[j]){ temp[k++] = nums[i++]; }else { temp[k++] = nums[j++]; } } while(i<=mid){ temp[k++] = nums[i++]; } while(j<=right){ temp[k++] = nums[j++]; } //將temp中的元素全部拷貝到原陣列中 //這裡必須將原來排好序的陣列值複製回去 //否則後續的對比前面排序長的陣列排序時會出錯 //比如4 1 2 3講過排序後分為1 4 和2 3兩組 //如果沒有將值複製回去那麼合併後將是2 3 4 1 k=0; while(left<=right){ nums[left++] = temp[k++]; } }
複雜度
歸併排序是一種效率高且穩定的演算法。但是卻需要額外的空間。
歸併排序的比較是分層次來歸併的。第一次將序列分為兩部分,第二次將第一次得到的兩部分各自分為兩部分。最後分割合併就類似一課二叉樹。其平均時間複雜度為O(nlogn)。空間複雜度因為其需要額外長度為n的輔助空間,其空間複雜度為O(n)。
大資料量排序
上面演示的程式碼也被成為2-路歸併排序,其核心思想是將以為陣列中前後響鈴的兩個有序序列合併為一個有序序列。但是實際上平時我們不會使用這種排序方式。
但是歸併排序使用場景還是很多的,特別是在對數量較大的序列進行排序是,比如目前我們有大量的資料儲存在文字中,現在需要對其進行排序。由於記憶體的限制沒有辦法一次性載入所有的資料,這時候我們就可以使用歸併排序,將大的檔案分割為若干份小檔案,分別對這些小檔案的資料進行排序後使用歸併排序再將其進行排序。並且排序過程中可以使用多執行緒等手段提高演算法效率。
TIMSort
在JDK中,Arrays工具類為我們提高了各種型別的排序方法,Arrays.sort在JDK1.6及之前使用的是歸併排序,在1.7開始使用的是TimSort排序。
TimSort演算法是一種起源於歸併排序和插入排序的混合排序演算法,設計初衷是為了在真實世界中的各種資料中可以有較好的效能。基本工作過程是:
- 掃描陣列,確定其中的單調上升段和嚴格單調下降段,將嚴格下降段反轉;
- 定義最小基本片段長度,短於此的單調片段通過插入排序集中為長於此的段;
- 反覆歸併一些相鄰片段,過程中避免歸併長度相差很大的片段,直至整個排序完成,所用分段選擇策略可以保證O(n log n)時間複雜性。