疯狂的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也会尽量多分享一些自己学习的过程在这里,各位猿友敬请期待吧。

  本周最佳算法:并行归并排序!

时间: 2024-10-06 20:15:32

疯狂的Java算法——插入排序,归并排序以及并行归并排序的相关文章

java算法插入排序优化代码

原文:java算法插入排序优化代码 代码下载地址:http://www.zuidaima.com/share/1550463280630784.htm 一个细节让插入排序更具效率 运行此方法需要为main方法传递参数 package com.zuidaima.sort; /** *@author www.zuidaima.com **/ public class TestSort { public static void main(String args[]){ int l = args.len

Java算法-插入排序

插入排序的基本思想是在遍历数组的过程中,假设在序号 i 之前的元素即 [0..i-1] 都已经排好序,本趟需要找到 i 对应的元素 x 的正确位置 k ,并且在寻找这个位置 k 的过程中逐个将比较过的元素往后移一位,为元素 x “腾位置”,最后将 k 对应的元素值赋为 x ,插入排序也是根据排序的特性来命名的. 插入排序的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入.其具体步骤参见代码及注释. 以下是一个实例,红色 标记的数字为插入的数字,被划掉的数

必须知道的八大种排序算法【java实现】(三) 归并排序算法、堆排序算法详解

一.归并排序算法 基本思想: 归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的.然后再把有序子序列合并为整体有序序列. 归并排序示例: 合并方法: 设r[i-n]由两个有序子表r[i-m]和r[m+1-n]组成,两个子表长度分别为n-i +1.n-m. j=m+1:k=i:i=i; //置两个子表的起始下标及辅助数组的起始下标 若i>m 或j>n,转⑷ //其中一个子表已合并完,比较选取结束 //选取r[i]和r[j]

常见的排序算法(四):归并排序

归并排序(英语:Merge sort,或mergesort),是创建在归并操作上的一种有效的排序算法,效率为O(n·log n).该算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行. 采用分治法: 分割:递归地把当前序列平均分割成两半. 集成:在保持元素顺序的同时将上一步得到的子序列集成到一起(归并). 归并操作(merge),也叫归并算法,指的是将两个已经排序的序列合并成一个序列的操作.归并排序算法依赖归并操作. 归并排序代码如下: 1

Java算法与数据结构

Java算法与数据结构学习 一.数组 //声明数组 dataType[] arrayRefVar; //创建数组 arrayRefVar = new dataType[arraySize]; dataType[] arrayRefVar = new dataType[arraySize]; dataType[] arrayRefVar = {value0, value1, ..., valuek}; 1.使用自定义类封装数组 public class MyArray{ private long

并行归并排序——MPI

并行归并排序在程序开始时,会将n/comm_comm个键值分配给每个进程,程序结束时,所有的键值会按顺序存储在进程0中.为了做到这点,它使用了树形结构通信模式.当进程接收到另一个进程的键值时,它将该键值合并进自己排序的键值列表中.编写一个程序实现归并排序.进程0应该读入n的值,将其广播给其余进程.每个进程需要使用随机数生成器来创建n/comm_sz的局部int型数据列表.每个进程先排序各自的局部列表,然后进程0收集并打印这些局部列表.然后,这些进程使用树形结构通信合并全局列表给进程0,并打印最终

java之插入排序

//插入排序(Insertion Sorting)的基本思想是:把n个待排序的元素看成为一个有序表和一个无序表,开始有序表只包含一个元素,无序表中包含有n-1个元素,排序过程中每次从无序表中取出第一个元素,把它的排序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表. public static void main(String []args){    int arr[]={23,15,-13,62,5,-23,0,17};      for(int i=1;i

算法手记(6)归并排序

今天我主要学习基于分治思想的归并排序算法,这是分治法的典型应用.废话不多说,下面直切正题. 概述: 将两个有序数组归并成一个更大的有序数组,我们称之为归并,人们根据这一操作发明了一种简单的递归排序算法:归并排序. 归并排序最吸引人的是它能够保证任意长度为N的数组排序所需的时间和NlogN成正比:它的主要缺点是需要额外占用的内存空间与N成正比. 分析:实现归并的一种最简单的方法是将两个不同的有序数组归并到第三个数组中,实现的方法很简单,创建一个适当的数组然后将两个数组中的元素一个个从小到大放入这个

Java算法快速排序

快速排序的原理:每次将序列以一个值为界限分成两组,在将两个序列分别以一个界限分成两组这样一直分下去. int[] a = {11,222,44,63,84,11,24,53,123,25,98,76,34}; 第一步:以34将数组a分成两组  11, 25, 24, 11              34,  63, 44, 53, 123, 222, 98, 76, 84 第二步:以11将11, 25, 24, 11分为两组  11, 11,     24, 25.以84将34,  63, 44