演算法之排序
排序的一些概念
穩定性和不穩定性:關鍵字相等的記錄在排序情況不唯一。
內排序和外排序:簡單來說前者的操作都是在記憶體中,後者資料太多,存在於外部存放裝置的互動操作。
一般我們說的的演算法都是值指的內排序。
對於排序的分類可能有好幾種:
一般按操作方式和思想可分為:交換,插入,選擇,合并等。
也有按計算複雜度的來分的,O(n^2)簡單排序(冒泡,簡單選折,直接插入)和改進排序演算法 (希爾排序,堆排序,歸併排序,快速排序)
這邊主要學習幾個常用的演算法,也是最近一舍友去面試時說,現在問的演算法好多。
然後這禮拜斷斷續續的從周一到周四,抽了點時間溫故而知新,順便也保持做筆記的好習慣。(我這是有多閑啊!)
在最基本的排序演算法中,快排是出境率最高的。也是這些演算法中相對來說效率最優異的。
我順便就再去看了遍這幾個演算法的實現。因為可能說你瞭解是瞭解,轉化成代碼時,還真有點難度。我就當複習複習
實現語言我用的是java,因為比較熟,但是其真正要寫得優雅和簡潔,那就得c來寫。並且在目前大部分資料結構書上也都是C來實現教學的。
我這用java,權當走一遍思路流程。
一:簡單排序
這裡主要包括3種:
1.冒泡排序。操作思想是:不斷兩兩比較和交換。
也是最簡單的排序。
簡單排序的思路很簡單,依次比較相鄰的兩個數,將小數放在前面,大數放在後面。2層迴圈
內層迴圈一次後比較相鄰的哪個大小,外層迴圈一次後,確定了末尾的元素為最大值。
因此下次內層訓話後可以不用在比較最末尾的兩元素的比較。
整體的比較次數即為:(1+n)*n/2 複雜度為O(n^2)。
基本實現Java代碼:
private static int[] BubbleSort(int[] nums){int count = 0;for(int i = 0 ; i < nums.length-1; i++){for(int j = 0; j < nums.length-i-1;j++){count++;if(nums[j] > nums [j+1]){int temp = nums[j];nums[j] = nums[j+1];nums[j+1] = temp;}}}System.out.println("執行比較次數:"+count);return nums;}
冒泡的一點最佳化:
比如我有一個數組是部分有序的 {3,2,5,7,8,9}
那麼比較外層i = 0的時候 內迴圈結束後其實整個序列就是排序完了。但是按上面的邏輯,外層迴圈又執行了i++,即i =1,在跳進內層迴圈比較。
一圈比較下來後其實根本沒有發生元素交換,因為他本身已經是有序的了。然後緊接著i =2,3,4.。。。其實此時就完全可以退出整個迴圈了。
因此,我們增加一個標誌位,對以上代碼做一點點改進
private static int[] BubbleSort(int[] nums) {int count = 0;boolean flag = true;for (int i = 0; i < nums.length - 1 &&flag ; i++) {//外層訓話flag = false;for (int j = 0; j < nums.length - i - 1; j++) {//內層迴圈count++;if (nums[j] > nums[j + 1]) {//比較大小,大的放後面,小的放前面int temp = nums[j];nums[j] = nums[j + 1];nums[j + 1] = temp;flag = true;}}}System.out.println("執行比較次數:" + count);return nums;}
冒泡法的變種:雞尾酒排序。基本的思路是,先從左端升序,再從右端降序。
private static int[] cockTail(int[] nums) {int count = 0;boolean flag = true;int left = 0;int right = nums.length-1;while(flag){flag = false;for(int i = left; i<right;i++){if (nums[i] > nums[i + 1]) {int temp = nums[i];nums[i] = nums[i + 1];nums[i + 1] = temp;flag = true;}count++;}right --;for(int i = right; i>left;i--){if (nums[i-1] > nums[i]) {int temp = nums[i];nums[i] = nums[i - 1];nums[i - 1] = temp;flag = true;}count++;}left ++;}System.out.println("執行比較次數:" + count);return nums;}
但是我測試了幾次,雞尾酒排序在尋找的次數根本對傳統的冒泡沒有什麼提升,但只是說因為隨著left和right的縮小,
元素在交換時移動的位置移動次數上可能會有一點減少。
而查了下資料還有一種是雙向冒泡,基本實現和雞尾酒這個很像。
總體來說冒泡排序就是最基本的一種排序方式。百度百科還表示有一種局部冒泡。但對於這樣的一種排序,
傳統意義上的深入不如來的對新的排序研究來的有價值,當然,喜歡研究的朋友可以更深入的瞭解。我這邊只是做下記錄,以及自己學習的筆記。
2.簡單選擇排序
操作思想是在每一次掃描時選取關鍵字最小的記錄作為最左端的元素。
private static void SimpleSelectSort(int[]nums){for(int i = 0; i < nums.length; i++){//外層掃面控制int min = i;//用來記錄值最小的下標,預設假設第數組第一個元素為最小。for(int j = i; j < nums.length ; j++){if(nums[j] < nums[min]){//找出逼min下標中元素還小的值的下標min = j;//保持min始終為最小元素的小標}}if(i!=min){//一趟掃描結束後判斷是否找出了最小的值,如果有的話就交換,將這個最小值移動到此次掃描資料的最前端int temp = nums[i];nums[i] = nums[min];nums[min] = temp;}}}
選擇排序相對於了冒泡來說,在資料比較次數上其實沒有不同,但是相對於減少了每次比較後若資料需要交換而帶來的資料的移動操作,
選擇排序記錄了要移動的下標,說白了,就是先記錄下來最後做選擇性的移動。總體來說可能比冒泡有一點的提升。
3.直接插入排序
private static void InsertSort(int[] nums) {for (int i = 1; i < nums.length; i++) {// 外層對無序列表的掃描if (nums[i] < nums[i - 1]) {int temp = nums[i];// 儲存該點的值,等會要將該值插入適當位置int j = i - 1;//while(j >= 0 && temp < nums[j]){//nums[j + 1] = nums[j];//j--;//}for (; j >= 0 && temp < nums[j]; j--) {// 往後移 tips此處千萬不要 是nums[i] < nums[j]來比較,//因為nums[i]這個值在一次移位後就被覆蓋了,因此也為什麼要用temp來儲存這個值的原因nums[j + 1] = nums[j];// 數組往後移}nums[j + 1] = temp;}}}
整體來說,對於插入排序,資料的比較和交換的平均次數都是 n^2/2.
二:改進的演算法
4.希爾排序.
排序思想:先取一個小於n的整數d1作為第一個增量,把檔案的全部記錄分成d1個組。所有距離為d1的倍數的記錄放在同一個組中。
先在各組內進行直接插入排序;然後,取第二個增量d2<d1重複上述的分組和排序,直至所取的增量dt=1(dt<dt-l<…<d2<d1),
即所有記錄放在同一組中進行直接插入排序為止。(百度百科)
希爾排序是插入排序的一種改進。
private static void shellSort(int[] nums){int increment = nums.length;//增量while(increment >1){increment = increment/2 ;//增量計算//一下基本同插入排序,只是直接插入排序我們比較時是增量是1,shell排序設定了一個自己的增量for(int i = increment; i<nums.length;i++){if(nums[i] < nums[i-increment]){int temp = nums[i];int j = i - increment;for(;j >=0 && temp < nums[j];j-=increment ){nums[j+increment] = nums[j];}nums[j+increment] = temp;}}}}
5.堆排序。
定義:“堆”定義
n個關鍵字序列Kl,K2,…,Kn稱為(Heap),若且唯若該序列滿足如下性質(簡稱為堆性質):
(1)ki<=k(2i)且ki<=k(2i+1)(1≤i≤ n),當然,這是小根堆,大根堆則換成>=號。//k(i)相當於二叉樹的非葉結點,K(2i)則是左孩子,k(2i+1)是右孩子
若將此序列所儲存的向量R[1..n]看做是一棵完全二叉樹的儲存結構,則堆實質上是滿足如下性質的完全二叉樹:
樹中任一非葉結點的關鍵字均不大於(或不小於)其左右孩子(若存在)結點的關鍵字。
思想:堆排序是選擇排序的一種改進。選擇排序的時,我們說是分為有序區和無序區(這邊我們升序排,有序區在左邊)。對數組排序掃描時第一遍
nums【0,n】所有的比較一邊後選取最小的值放在座標0這個位置,然後第二趟掃描 nums【1,n】,同樣的選取最小值放在1位置。
然後和我們可以知道,在第,二次對數組nums【1,n】掃描比較時,有很多比較其實在前面第一趟中已經做過,但是沒有儲存比較記錄。因此選用樹形的結構來儲存。
堆排序的具體步驟:
第一步:將數組構造成堆(這邊我們以大根堆來舉例)
第二步:其實是第一步的深入,在構造大根堆時,我們根據二叉樹特點可知我們可以從最後一個非葉子節點,即節點i = [n/2],開始掃描比較i節點和其子節點得到大小,
如果子節點中大那個值比i節點大,則交換他們的位置。(tips注意點,此刻我們可能覺得i點和他的子節點對於大根堆的定義滿足了,
這邊為了方便我們把這個算作一個事件。i節時間點事件後滿足了大根堆定義。那麼繼續的i--。)然後操作到樹的比較上面幾層時(準確的說就是某個節點 j,
處理完事件後,產生了節點交換,這時的他的一個子節點改變了,這個子節點非葉子節點,即,把他也擁有子節點,因為j節點的資料於其子節點child交換後,
child節點和他的子節點的大根堆結構被破壞了。)看完括弧裡的注意事項,你應該知道某個節點 j在處理完節時間點事件後(即發生了資料交換)
於j節點發生交換的子節點child擁有子節點(即 child * 2 +1>= n),則需要對child節時間點事件進行堆的重構。(感覺有點遞迴的意思了,
而且本來樹這結構就很容易讓人想到遞迴這概念)。
第三步:第二步後,一棵大根堆就構造完了,此時根項目為最大值,將根項目和大根堆的最後一個元素交換。即,將最大值移動到有序區的最左端,
帶排序區的最右端。(數組的右端為有序)。
第四步:第三步結束後,則是類似於第二步的節點資料交換事件後的意思是差不多的,即,此刻原本根根節點的這個大根堆結構可能被破換了,
那就是重構大根堆了(當然,此時構造大根堆的數組是nums【0,n-1】,而非nums【0,n-1】.。意思大家應該知道。
第五步:第四步結束後對於nums【0,n-1】構建完大堆樹,重複第三部,將根項目和當前大根堆最後一個元素,即nums【n-1】互換,重複第四步。
n-1個迴圈後,排序結束。
時間複雜度分析:將待排序數組構造成大根堆的演算法複雜級應該是在 nlogn(n/2個迴圈,迴圈裡怎麼裡面是一個二叉樹(這個怎麼說呢?反正覺得有點二分的感覺。)
抱歉我貧乏的專業詞彙。假設我們把一個節時間點事件演算法複雜度看成是一個O(1)級。那麼最極端或是籠統的算,節點j在每一個節時間點事件操作後,
都需要重構,再極端點的假設說操作的這個節點為根節點,那麼要操作的時間級其實就是這顆樹的深度 logn,當然這事最極端的想法,
其實當節點為 j =【n/2】以及和這個節點在同一層的其他節時間點事件其實都是 O(1)),因此推測演算法複雜級為 nlogn。(當然我這個不是專業的,
只是個人的一點理解和認知)。
同理的可以理解後邊的交換和重構演算法層級還是在 nlogn,最後可得出為 n * logn。
實現代碼;
private static void HeapSort(int[] nums){int n = nums.length;//step1:序列構建成大根堆for(int i = (n-1)/2 ;i >= 0; i--){//即對每個節點和其子節點的大根堆構造HeapAdjust(nums, i, n);}//step2:遍曆最大元素移動到末端for(int i = n-1 ;i >0; i--){ int temp = nums[i]; nums[i] =nums[0]; nums[0] = temp;//step3:重構 HeapAdjust(nums, 0, i);}}/** * 構建大根堆 * @param nums * @param node * @param length */private static void HeapAdjust(int[] nums, int node, int length) {if ((node*2+1) <=length-1) {// 保證該節點為非葉子節點,因為葉子節點就沒意義了int child = node * 2 + 1;//位元組點座標,主要是交換完後需要以該child為節點判斷以及調整大根堆if (child + 1 <= length-1) {//如果有有右子樹if (nums[child + 1] > nums[child]) {child++;//判斷後如果右子樹大於左子樹,child++,即等會要操作的是左子樹節點}}if(nums[node]< nums[child]){//大的子樹與node比較//交換 int temp = nums[node]; nums[node] =nums[child]; nums[child] = temp; //再次重構 HeapAdjust(nums, child, length);}}}
6.歸併排序
概念:歸併(Merge)排序法是將兩個(或兩個以上)有序表合并成一個新的有序表,即把待排序序列分為若干個子序列,每個子序列是有序的。
然後再把有序子序列合并為整體有序序列。
思想:分治
歸併演算法是效率僅次於快排,並且穩定的排序。
在演算法過程中需要申請附加的儲存空間。
實現步驟:
首先我們要明白本身我們排序的是對於的是一個序列。對於要歸併來說,首先要將該序列拆分成兩個子序列,兩個子序列各自排序成有序的,
這就有又涉及到每個子序列的排序了,此刻我們若把一個子序列排序,不就又回到最前面的了嗎?這就是分治和遞迴的一種實現。
一:序列排序,先將序列分割成兩個有序的子序列。然後執行歸併演算法
二:在一種將一個無需序列分割中兩個子序列後,要執行歸併,則要先對每個子序列進行排序,即,對方法一進行遞迴調用。
三:對於一個遞迴函式,則必須涉及到函數的結束,即當一個序列元素個數為1時,跳出遞迴
以下是歸併演算法的遞迴實現:
private static void MergingSort(int [] nums,int head,int tail){int mid;if(head >= tail)return;//while(head < tail){//切分子序列,使之為有序子序列 mid = (tail + head)/2;MergingSort(nums,head,mid);MergingSort(nums, mid+1, tail);//歸併Merging(nums, head, mid, tail);//}}private static void Merging(int[] nums, int head, int mid, int tail) {int[] temp = new int[tail-head+1];// 申請額外空間int low = head;int high = tail;int j = mid + 1;int p = 0;//兩個子序列都非空while (head <= mid && j <= tail) {if (nums[head] > nums[j]) {temp[p++] = nums[j++];} else {temp[p++] = nums[head++];}}//第一個子序列非空,將其中剩餘的元素複製到temp中while (head <= mid) {//temp[p++] = nums[head++];}//第二個子序列非空,將其中剩餘的元素複製到temp中while (j <= tail) {//temp[p++] = nums[j++];}//將緩衝中的刷到歸併序列中 for(int q=0, t =low ;t<=high;q++,t++){ nums[t]=temp[q];//歸併完成後將結果複製回R[low..high] } //Merge }
注意點:上面我注釋掉的while迴圈,這個真的是比較鬱悶的,理論上來說這麼while可行的,但是我測試後,發現跳不出while迴圈了,因為每次執行完
//歸併
Merging(nums, head, mid, tail);
後while判斷還是成立的,網上查了下資料,基本都也是這麼寫。可能因為代碼我是按自己的思路書寫的,諸侯將迴圈控制改成if來控制問題倒是解決了。
更多規範代碼(比如C和c++)請參考規範文檔。
以上是用遞迴實現的,下面以一個非遞迴實現的歸併。其實思路是差不多的。
在上面我們是先思考將要排序序列分割成一個個子序列,然後再執行歸併操作。然後整個形勢剛好可以用遞迴實現,但是,遞迴來說,
在代碼的可讀性和思路上能說比較好理解,但是遞迴的每次調用本身的棄置站台,如果遞迴層數太深,記憶體資源是比較高的。
因此我們也可以轉個思路,比如在一開始我們將該待排序序列分成每個子序列程度為1的多個子序列,然後將相鄰的兩個子序列執行歸併排序。
private static void MergingSort2(int[] nums) {int length = nums.length;for (int n = 1; n < nums.length; n *= 2) {// 做logn 趟歸併int i;for (i = 0; i + 2 * n - 1 <= length-1; i = i + 2 * n) {Merging(nums, i, i + n - 1, i + 2 * n - 1);// 歸併長度為length的兩個相鄰子檔案}if (i + n - 1 < nums.length-1) // 尚有兩個子檔案,其中後一個長度小於lengthMerging(nums, i, i + n - 1, nums.length-1); // 歸併最後兩個子檔案}}private static void Merging(int[] nums, int head, int mid, int tail) {int[] temp = new int[tail-head+1];// 申請額外空間int low = head;int high = tail;int j = mid + 1;int p = 0;//兩個子序列都非空while (head <= mid && j <= tail) {if (nums[head] > nums[j]) {temp[p++] = nums[j++];} else {temp[p++] = nums[head++];}}//第一個子序列非空,將其中剩餘的元素複製到temp中while (head <= mid) {//temp[p++] = nums[head++];}//第二個子序列非空,將其中剩餘的元素複製到temp中while (j <= tail) {//temp[p++] = nums[j++];}//將緩衝中的刷到歸併序列中 for(int q=0, t =low ;t<=high;q++,t++){ nums[t]=temp[q];//歸併完成後將結果複製回R[low..high] } //Merge }
上面在對logn趟迴圈中每趟對子序列執行兩兩歸併其實有點混亂,可能說你在紙上畫圖時很簡單,的確也很明了。
帶式轉成代碼語言就比較噁心了。
可能有更好的代碼格式的最佳化,大家可以自行設計
7.快速排序
快排是也算是冒泡的一種改進,同樣的屬於交換排序
基本思路是通過一趟排序將要排序的資料分割成獨立的兩部分,其中一部分的所有資料都比另外一部分的所有資料都要小,然後再按此方法對這兩部分資料分別
進行快速排序,整個排序過程可以遞迴進行,以此達到整個資料變成有序序列。
整個來說是用到分治和遞迴的兩種基本思想。
private static void quickSort(int[] nums, int low, int high) {int mid;if (low < high) {mid = partition(nums, low, high);quickSort(nums, low, mid - 1);quickSort(nums, mid + 1, high);}}private static int partition(int[] nums, int low, int high) {int key = nums[low];// 設定int temp;while (low < high) {while (low < high && nums[high] >= key)high--;if (low < high) {temp = nums[low];nums[low] = nums[high];nums[high] = temp;}while (low < high && nums[low] <= key) {low++;}if (low < high) {temp = nums[low];nums[low] = nums[high];nums[high] = temp;}}return low;}
以上上快排最基本的實現。
對於快速排序的最佳化:第一點是中間值的選取:也就是這個key。
因為理論上來說,這個key選取的越接近中間數,那麼對於分治後的左右兩邊越平衡,理解成樹形式可以知道此刻樹的深度越小,那遞迴層數也就越小了。
於是最佳化時就在對這個key取值上進行最佳化。現在一般就是取左端,中間,右端三個數後比較取一個處於三個數的中間的值作為最右端
(因為我們函數取key值是nums[low],即最左端的值)。此時只需將微微改動
private static int partition(int[] nums, int low, int high) {int middle = low+(high-low)/2;//中間元素的下標int temp;if(nums[low] > nums[high]){temp = nums[low];nums[low] = nums[high];nums[high] = temp;}if(nums[middle] > nums[high]){temp = nums[middle];nums[middle] = nums[high];nums[high] = temp;}//以上兩個if後保證最小值和中間值處於middle和low位置,下面再用一個if來保證將中間值放在low位置if(nums[middle] > nums[low]){temp = nums[middle];nums[middle] = nums[low];nums[low] = temp;}int key = nums[low];// 設定while (low < high) {while (low < high && nums[high] >= key)high--;if (low < high) {temp = nums[low];nums[low] = nums[high];nums[high] = temp;}while (low < high && nums[low] <= key) {low++;}if (low < high) {temp = nums[low];nums[low] = nums[high];nums[high] = temp;}}return low;}
其實可以把交換那個三句的代碼寫個單獨方法。。。我這邊就隨意點了。
第二點:減少交換次數。
相對來說,快排是也是屬於交換排序,在一趟掃描中(即low往high靠近)時,將交換改成替換
private static int partition(int[] nums, int low, int high) {int middle = low+(high-low)/2;//中間元素的下標int temp;if(nums[low] > nums[high]){temp = nums[low];nums[low] = nums[high];nums[high] = temp;}if(nums[middle] > nums[high]){temp = nums[middle];nums[middle] = nums[high];nums[high] = temp;}//以上兩個if後保證最小值和中間值處於middle和low位置,下面再用一個if來保證將中間值放在low位置if(nums[middle] > nums[low]){temp = nums[middle];nums[middle] = nums[low];nums[low] = temp;}int key = nums[low];// 設定while (low < high) {while (low < high && nums[high] >= key)high--;if (low < high) {nums[low] = nums[high];//temp = nums[low];//nums[low] = nums[high];//nums[high] = temp;}while (low < high && nums[low] <= key) {low++;}if (low < high) {nums[high] = nums[low];//temp = nums[low];//nums[low] = nums[high];//nums[high] = temp;}}nums[low] = key;return low;}
這個具體怎麼理解,可以畫就覺得,的確是這麼著的。
最後再提到一點改進是用尾遞迴,實現代碼:
private static void quickSort2(int[] nums, int low, int high) {int mid;while(low < high){mid = partition(nums, low, high);quickSort2(nums, low, mid - 1);low = mid +1;}//if (low < high) {//mid = partition(nums, low, high);//quickSort2(nums, low, mid - 1);//quickSort2(nums, mid + 1, high);//}}
想瞭解更多可以google或是baidu。