瘋狂的Java算法——插入排序,歸並排序以及並行歸並排序


從古至今的難題

  

  在IT屆有一道百算不厭其煩的題,俗稱排序。不管是你參加BAT等高端筆試,亦或是藏匿於街頭小巷的草根筆試,都會經常見到這樣一道百年難得一解的問題。

  今天LZ有幸與各位分享一下算法屆的草根明星,排序屆的領銜大神——插入排序以及歸並排序。最后,在頭腦風暴下,LZ又有幸認識了一位新朋友,名叫並行歸並排序。接下來,咱們就一一認識一下,並且在最后來一次“算林大會”吧。

 

插入排序簡介

  

  插入排序,算林稱最親民的排序算法,插入排序采用最簡單的插入方式對一個整數數組進行排序。它循環數組中從第二個開始的所有元素,並且將每一個循環到的元素插入到相應的位置,從而實現排序的目的。

  

插入排序的代碼展示

  

  使用Java代碼描述插入排序,可以用以下的代碼。

package algorithm; /** * @author zuoxiaolong * */
public abstract class InsertSort { public static void sort(int[] numbers){ for (int i = 1; i < numbers.length; i++) { int currentNumber = numbers[i]; int j = i - 1; while (j >= 0 && numbers[j] > currentNumber) { numbers[j + 1] = numbers[j]; j--; } numbers[j + 1] = currentNumber; } } }

  這個算法從數組的第二個元素開始循環,將選中的元素與之前的元素一一比較,如果選中的元素小於之前的元素,則將之前的元素后移,最后再將選中的元素放在合適的位置。在這個算法執行的過程中,總是保持着索引i之前的數組是升序排列的。

  插入排序理解起來比較簡單,因此LZ就不過多的解釋它的實現原理了,尚未理解的猿友可以自行研究。

  

插入排序的性能分析

  

  接下來,咱們來簡單分析一下插入排序的性能。首先,插入排序當中有兩個循環,假設數組的大小為n,則第一個循環是n-1次,第二個while循環在最壞的情況下是1到n-1次。因此插入排序的時間復雜度大約為如下形式。

  1+2+3+4+...+n-1 = n(n-1)/ 2 = O(n2

  時間復雜度為輸入規模的2次函數,可見插入排序的時間復雜度是比較高的。這是原理上的簡單分析,最后在“算林大會”中,各位可以清楚的看到插入排序隨着輸入規模的增長,時間會指數倍的上升。

  

歸並排序簡介

  

  歸並排序,算林屆的新秀,引領着分治法的潮流。歸並排序將排序問題拆分,比如分成兩個較小的數組,然后對拆分后的數組分別進行排序,最后再將排序后的較小數組進行合並。

  這種思想是一種算法設計的思想,很多問題都可以采用這種方式解決。映射到編程領域,其實就是遞歸的思想。因此在歸並排序的算法中,將會出現遞歸調用。

  

歸並排序的代碼展示

  

  歸並排序主要由兩個方法組成,一個是用於合並兩個已經排序的數組的方法,一個則是遞歸方法,用於將問題無限拆分。接下來咱們一起看看歸並排序的Java代碼展示,如下所示。

package algorithm; /** * @author zuoxiaolong * */
public abstract class MergeSort { public static void sort(int[] numbers){ sort(numbers, 0, numbers.length); } public static void sort(int[] numbers,int pos,int end){ if ((end - pos) > 1) { int offset = (end + pos) / 2; sort(numbers, pos, offset); sort(numbers, offset, end); merge(numbers, pos, offset, end); } } public static void merge(int[] numbers,int pos,int offset,int end){ int[] array1 = new int[offset - pos]; int[] array2 = new int[end - offset]; System.arraycopy(numbers, pos, array1, 0, array1.length); System.arraycopy(numbers, offset, array2, 0, array2.length); for (int i = pos,j=0,k=0; i < end ; i++) { if (j == array1.length) { System.arraycopy(array2, k, numbers, i, array2.length - k); break; } if (k == array2.length) { System.arraycopy(array1, j, numbers, i, array1.length - j); break; } if (array1[j] <= array2[k]) { numbers[i] = array1[j++]; } else { numbers[i] = array2[k++]; } } } }

  可以看到,歸並排序將一個長度為n的數組平均分為兩個n/2的數組分別進行處理,因此,在sort方法中又調用了兩次sort方法自身。當數組大小為1時,則認為該數組為已經為排好序的數組。因此在sort方法中,需要end與pos相差大於2時,才需要進一步拆分,這也是遞歸的終止條件。

  此外,在代碼中,使用了Java提供的arraycory函數進行數組復制,這種直接復制內存區域的方式,將會比循環賦值的方式速度更快。有些算法實現會給merge方法中的兩個臨時數組設置哨兵,目的是為了防止merge中for循環的前兩個if判斷。為了方便理解,LZ這里沒有設置哨兵,當某一個數組的元素消耗完時,將直接使用arraycopy方法把另外一個數組copy到numbers當中。

  

歸並排序的性能分析

  

  與插入排序一樣,咱們來簡單分析一下歸並排序的時間復雜度。咱們假設數組的大小為n,sort方法的時間復雜度為f(end-pos)。簡單的分析merge方法的復雜度,不難發現為(end-pos)*2,這個結果的前提是咱們認為arraycopy方法的復雜度為length參數。

  基於以上的假設,由於end-pos的初始值為n,因此歸並排序的復雜度大約為如下形式。

  2*f(n/2) + 2*n = 2*(2*f(n/4)+2*(n/2)) + 2*n=4*f(n/4) + 2*n + 2*n = n *f(1) + 2*n +...+2*n

  其中f(1)的時間復雜度為常量,假設f(1)=c,而2*n將有log2n個。因此咱們得到歸並排序的最終時間復雜度為如下形式。

  cn + 2n*log2n = O(n*log2n)

  歸並排序的時間復雜度與插入排序相比,已經降低了很多,這一點在數組的輸入規模較大時將會非常明顯,因為log函數的增加速度將遠遠低於n的增加速度。

  

並行歸並排序簡介

  

  並行歸並排序是LZ在學習歸並排序時意淫出來的,最近LZ正在研究Java的並發編程,恰好歸並排序的子問題有一定的並行度與獨立性,因此LZ版的並發歸並排序就這樣誕生了。事后,LZ也人肉過並行歸並排序這個家伙,發現早已眾所周知,不過在不知道的情況下自己能夠想到是不是也應該竊喜一下呢。

  並行歸並排序與普通的歸並排序沒有多大區別,只是利用現在計算機多核的優勢,在有可能的情況下,讓兩個或多個子問題的處理一起進行。這樣一來,在效率上,並行歸並排序將會比歸並排序更勝一籌。

  

並行歸並排序的代碼展示

  

  並行歸並排序主要對sort方法進行了修改,基礎的merge方法與普通的歸並排序是一樣的。因此在進行並行歸並排序時,引用了歸並排序的一些方法,具體的代碼如下所示。

package algorithm; import java.util.concurrent.CountDownLatch; /** * @author zuoxiaolong * */
public abstract class MergeParallelSort { private static final int maxAsynDepth = (int)(Math.log(Runtime.getRuntime().availableProcessors())/Math.log(2)); public static void sort(int[] numbers) { sort(numbers, maxAsynDepth); } public static void sort(int[] numbers,Integer asynDepth) { sortParallel(numbers, 0, numbers.length, asynDepth > maxAsynDepth ? maxAsynDepth : asynDepth, 1); } public static void sortParallel(final int[] numbers,final int pos,final int end,final int asynDepth,final int depth){ if ((end - pos) > 1) { final CountDownLatch mergeSignal = new CountDownLatch(2); final int offset = (end + pos) / 2; Thread thread1 = new SortThread(depth, asynDepth, numbers, mergeSignal, pos, offset); Thread thread2 = new SortThread(depth, asynDepth, numbers, mergeSignal, offset, end); thread1.start(); thread2.start(); try { mergeSignal.await(); } catch (InterruptedException e) {} MergeSort.merge(numbers, pos, offset, end); } } static class SortThread extends Thread { private int depth; private int asynDepth; private int[] numbers; private CountDownLatch mergeSignal; private int pos; private int end; /** * @param depth * @param asynDepth * @param numbers * @param mergeSignal * @param pos * @param end */
        public SortThread(int depth, int asynDepth, int[] numbers, CountDownLatch mergeSignal, int pos, int end) { super(); this.depth = depth; this.asynDepth = asynDepth; this.numbers = numbers; this.mergeSignal = mergeSignal; this.pos = pos; this.end = end; } @Override public void run() { if (depth < asynDepth) { sortParallel(numbers,pos,end,asynDepth,(depth + 1)); } else { MergeSort.sort(numbers, pos, end); } mergeSignal.countDown(); } } }

  在這段代碼中,有幾點是比較特殊的,LZ簡單的說明一下。

  1,分解后的問題采用了並行的方式處理,並且咱們設定了一個參數asynDepth去控制並行的深度,通常情況下,深度為(log2CPU核數)即可。

  2,當子問題不進行並行處理時,並行歸並排序調用了普通歸並排序的方法,比如MergeSort.sort和MergeSort.merge。

  3,因為合並操作依賴於兩個子問題的完成,因此咱們設定了一個合並信號(mergeSignal),當信號發出時,才進行合並操作。

  並行歸並排序在原理上與普通的歸並排序是一樣的,只是對於子問題的處理采用了一定程度上的並行,因此如果猿友們理解歸並排序,那么並行歸並排序並不難理解。

  

並行歸並排序的性能分析

  

  並行歸並排序只是將普通歸並排序中一些可並行的操作進行了並行處理,因此在總體的時間復雜度上並沒有質的變化,都是O(n*log2n)。

  由於並行歸並排序將某些排序操作並行操作,因此在性能上一定是快於普通歸並排序算法的。不過這也不是一定的,當數組規模太小時,並行帶來的性能提高可能會小於線程創建和銷毀的開銷,此時並行歸並排序的性能可能會低於普通歸並排序。

  

算林大會

  

  接下來,就是一周一度的算林大會了,本次算林大會主要由以上三種算法參加,勝者將會成為本周度最受歡迎算法。接下來是算林大會的代碼,請各位猿友過目。

package algorithm; import java.io.File; import java.lang.reflect.Method; import java.util.Random; /** * @author zuoxiaolong * */
public class SortTests { public static void main(String[] args) { testAllSortIsCorrect(); testComputeTime("MergeParallelSort", 40000, 5); testComputeTime("MergeSort", 40000, 5); testComputeTime("InsertSort", 400, 5); } public static void testAllSortIsCorrect() { File classpath = new File(SortTests.class.getResource("").getFile()); File[] classesFiles = classpath.listFiles(); for (int i = 0; i < classesFiles.length; i++) { if (classesFiles[i].getName().endsWith("Sort.class")) { System.out.println("---測試" + classesFiles[i].getName() + "是否有效---"); testSortIsCorrect(classesFiles[i].getName().split("\\.")[0]); } } } public static void testSortIsCorrect(String className){ for (int i = 1; i < 50; i++) { int[] numbers = getRandomIntegerArray(1000 * i); invoke(numbers, className); for (int j = 1; j < numbers.length; j++) { if (numbers[j] < numbers[j-1]) { throw new RuntimeException(className + " sort is error because " + numbers[j] + "<" + numbers[j-1]); } } } System.out.println("---" + className + "經測試有效---"); } public static void testComputeTime(String className,int initNumber,int times,Object... arguments) { long[] timeArray = new long[times]; for (int i = initNumber,j = 0; j < times; i = i * 10,j++) { timeArray[j] = computeTime(i, className, arguments); } System.out.print(className + "時間增加比例:"); for (int i = 1; i < timeArray.length ; i++) { System.out.print((float)timeArray[i]/timeArray[i - 1]); if (i < timeArray.length - 1) { System.out.print(","); } } System.out.println(); } public static long computeTime(int length,String className,Object... arguments){ int[] numbers = getRandomIntegerArray(length); long start = System.currentTimeMillis(); System.out.print("開始計算長度為"+numbers.length+"方法為"+className+"參數為["); for (int i = 0; i < arguments.length; i++) { System.out.print(arguments[i]); if (i < arguments.length - 1) { System.out.print(","); } } System.out.print("],時間為"); invoke(numbers, className, arguments); long time = System.currentTimeMillis()-start; System.out.println(time + "ms"); return time; } public static int[] getRandomIntegerArray(int length){ int[] numbers = new int[length]; for (int i = 0; i < numbers.length; i++) { numbers[i] = new Random().nextInt(length); } return numbers; } public static void invoke(int[] numbers,String className,Object... arguments){ try { Class<?> clazz = Class.forName("algorithm." + className); Class<?>[] parameterTypes = new Class<?>[arguments.length + 1]; parameterTypes[0] = int[].class; for (int i = 0; i < arguments.length; i++) { parameterTypes[i + 1] = arguments[i].getClass(); } Method method = clazz.getDeclaredMethod("sort", parameterTypes); Object[] parameters = new Object[parameterTypes.length]; parameters[0] = numbers; for (int i = 0; i < arguments.length; i++) { parameters[i + 1] = arguments[i]; } method.invoke(null, parameters); } catch (Exception e) { throw new RuntimeException(e); } } }

  以上代碼testAllSortIsCorrect方法首先驗證了三種算法的正確性,也就是說經過sort方法后,數組是否已經升序排列。需要一提的是,由於插入排序的性能太低,因此插入排序測試的最大規模為400萬,而歸並排序測試的最大規模為4億。

  接下來,大家就一起看看運行結果吧。以下是在LZ的mac pro上的運行結果,硬件配置為16G內存,4核i7。這種配置下,異步深度(asynDepth)默認為log24=2。

---測試InsertSort.class是否有效---
---InsertSort經測試有效---
---測試MergeParallelSort.class是否有效---
---MergeParallelSort經測試有效---
---測試MergeSort.class是否有效---
---MergeSort經測試有效--- 開始計算長度為40000方法為MergeParallelSort參數為[],時間為6ms 開始計算長度為400000方法為MergeParallelSort參數為[],時間為44ms 開始計算長度為4000000方法為MergeParallelSort參數為[],時間為390ms 開始計算長度為40000000方法為MergeParallelSort參數為[],時間為3872ms 開始計算長度為400000000方法為MergeParallelSort參數為[],時間為47168ms MergeParallelSort時間增加比例:7.3333335,8.863636,9.9282055,12.181818 開始計算長度為40000方法為MergeSort參數為[],時間為7ms 開始計算長度為400000方法為MergeSort參數為[],時間為81ms 開始計算長度為4000000方法為MergeSort參數為[],時間為839ms 開始計算長度為40000000方法為MergeSort參數為[],時間為9517ms 開始計算長度為400000000方法為MergeSort參數為[],時間為104760ms MergeSort時間增加比例:11.571428,10.358025,11.343266,11.00767 開始計算長度為400方法為InsertSort參數為[],時間為0ms 開始計算長度為4000方法為InsertSort參數為[],時間為3ms 開始計算長度為40000方法為InsertSort參數為[],時間為245ms 開始計算長度為400000方法為InsertSort參數為[],時間為23509ms 開始計算長度為4000000方法為InsertSort參數為[],時間為3309180ms InsertSort時間增加比例:Infinity,81.666664,95.9551,140.76227

  首先可以看到,三種算法都是運行正確的。接下來,咱們可以對比一下三種算法的性能。

  根據輸出結果,規模為400萬時的區別是最明顯與直觀的。並行歸並排序僅需要390ms就完成了400萬規模的排序,而普通的歸並排序則需要839ms才可以,至於插入排序,簡直是不可理喻,竟然需要300多萬ms,大約50分鍾。

  咱們再來看三者的時間增長趨勢。兩種歸並排序基本上與規模的增長趨勢相似,每當規模增加10倍時,時間也基本上增加10倍,而插入排序則幾乎是以100倍的速度在增加,剛好是數組規模增長速度的平方。其中的Infinity是因為當數組規模為400時,毫秒級別的計時為0ms,因此當除數為0時,結果就為Infinity。

  當然了,這一次結果具有一定的隨機性,猿友們可以在自己的電腦上多實驗幾次觀察一下,不過插入排序的時間實在讓人等的蛋疼。

  

小結

  

  好了,本文就到此為止了。對於算法的學習還需要繼續,以后LZ也會盡量多分享一些自己學習的過程在這里,各位猿友敬請期待吧。

  本周最佳算法:並行歸並排序!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM