算法和数据结构

插入排序算法

1定义和说明

见名知意,插入排序算法,就是对于一个给定的数组,假设前n项是已经排好序的,然后看第n+1项,把它插入到前面已经排好序的n项中去,且要求插入之后的n+1项数据,也是排好序的。好比方我们在斗地主的时候,不断抓牌、理牌的过程:我们抓第2张牌的时候,先把它放到第1张的后面,然后判断它是否小于我们手中的第1张牌?如果小于第1张牌,则交换这2张牌的位置,否则(大于或等于第1张牌),它就放在原地(放在第1张牌的后面)。接下来,开始抓第3张牌并放到第3个位置,再比较它和第2张牌的大小,如果大于或等于第2张牌,则放在原地不动,否则交换它和第2张牌的位置。注意:交换之后,要继续比较它和第1张牌的大小,如果它还小于第1张牌,则交换它和第1张牌的位置,否则,它就放在第2的位置。然后,继续抓牌,并重复执行这个过程。当然,我们在真的斗地主的时候,一般没人会这么繁琐的理牌,肯定是抓到牌之后,马上就知道它应该放在的位置,并直接插入到它应该放的位置。但是,计算机在执行插入排序的时候,的的确确就是完全按照上述一步一步执行的。

2实现思路

对于给定的长度为n的数组arr,我们从第i个元素开始,假定[0,i)之间的元素是已经排好序的,第[i,n)区间的元素是无序的,然后判断第i个元素arr[i]是否小于arr[i-1]?如果不小于,则不作交换处理,则数组的[0,i+1)是有序的,[i+1,n)是无序的,然后把第i+1个元素继续按这个思路插入到前面的i个元素中去。如果小于,则交换arr[i]和arr[i-1]的位置;交换之后,继续判断arr[i-1]和arr[i-2]的大小并选择是否需要交换,直到走到数组的第1个元素arr[0]为止。

3代码实现

public class InsertionSort{
  //本类是插入排序工具类,没有实例化的需求,所以构造方法私有化
  private InsertionSort(){};
  //带泛型的选择插入排序算法,
  public static <T extends Comparable<T>> void sort(T[] arr){
    //从第i个元素开始循环,直到最后1个元素结束,外循环这里的[i,arr.length)区间的元素都是没排序的。
    for(int i=0;i<arr.length;i++){
      //内循环这里的第[0,j==i)区间的元素是拍好序的
      for(int j=i;j>0;j--){
        //拿出这第i个元素和前面的已排好序的元素逐一比较,如果小于则交换位置
        if(arr[j].compareTo(arr[j-1])<0){
          swap(arr,j,j-1);
        }else{
          //如果不小于即arr[j]>=arr[j-1],则它一定也不小于更前面的元素,直接跳出内循环
          break;
        }
      }
    }
  }
  
  private static <T> void swap(T[] arr,int i,int j){
    T temp=arr[i];
    arr[i]=arr[j];
    arr[j]=temp;
  }
  
  public static void main(String[] args){
    Integer[] arr={6,4,2,33,11,5};
    InsertionSort.sort(arr);
    for(Integer k:arr){
      System.out.print(k+"  ");
    }
  }
  
}
//结果:
//2  4  5  6  11  33  

4 对自定义类使用插入排序算法进行排序

我们对插入排序算法已经可以支持泛型的排序算法,即对任意数据类型的数据进行排序。我们自定义一个Student类,让其实现Comparable接口,在compareTo()方法中,先按身高排序,再按年龄进行排序。

public class Student implements Comparable<Student>{
  private String name;
  private int height;
  private int age;
  
  public Student(String name,int height,int age){
    this.name=name;
    this.height=height;
    this.age=age;
  }
  
  @Override
  public int compareTo(Student stu){
    if(this.height!=stu.height){
      return this.height-stu.height;
    }else{
      return this.age-stu.age;
    }
  }
  @Override
  public String toString(){
    return "Student: name="+name+" height="+height+" age="+age;
  }
}

在InsertionSort的main方法中,对Student[]数组进行排序,代码片段如下:

public static void main(String[] args){
  Student[] students={new Student("Jim",120,7),
                      new Student("Kate",120,7),
                      new Student("Tom",110,5)};
  SelectionSort.sort(students);
  for(Student stu:students){
    System.out.println(stu);
  }
}
//结果如下:
//Student: name=Tom height=110 age=5
//Student: name=Jim height=120 age=7
//Student: name=Kate height=120 age=7
​

从上,可以看到,支持泛型的选择排序算法,可以对任意类型的数据进行排序了。同时,看到排序之后,Jim和Kate这2个学生对象的身高,年龄一致,且在原数据中,Jim排在前面,排序之后的结果Jim依然在Kate前面。所以,验证了插入排序是稳定的排序算法。

5 复杂度分析

从插入排序算法的代码看出:

//带泛型的选择插入排序算法,
  public static <T extends Comparable<T>> void sort(T[] arr){
    //从第i个元素开始循环,直到最后1个元素结束,外循环这里的[i,arr.length)区间的元素都是没排序的。
    for(int i=0;i<arr.length;i++){
      //内循环这里的第[0,j==i)区间的元素是拍好序的
      for(int j=i;j>0;j--){
        //拿出这第i个元素和前面的已排好序的元素逐一比较,如果小于则交换位置
        if(arr[j].compareTo(arr[j-1])<0){
          swap(arr,j,j-1);
        }else{
          //如果不小于即arr[j]>=arr[j-1],则它一定也不小于更前面的元素,直接跳出内循环
          break;
        }
      }
    }
  }

当外层循环i=0时,内循环执行0次,i=1时,内循环执行1次,i=2时,内循环2次,i=n时,即数组长度时,内循环n次。所以,总循环次数=0+1+2+…+n=n(1+n)/2=1/2(n2+n),则世界复杂度是O(n2)。验证一下这个时间复杂度:

构建两个辅助工具类:

第1个工具类ArrayGenerator:
import java.util.Random;
​
public class ArrayGenerator {
    private ArrayGenerator(){}
​
  //生成长度为n的整型数组,数组元素上限为bound的随机数
    public static Integer[] generateRandomArray(int n,int bound) {
        Integer[] randomArray = new Integer[n];
        Random random = new Random();
        for (int i = 0; i < n; i++) {
            randomArray[i] = random.nextInt(bound);
        }
        return randomArray;
    }
    
}
构建第2个辅助工具类,SortingHelper
public class SortingHelper {
    private SortingHelper() {
    }
​
  //传入参数为一个T类型的数组,验证其是否已经排好序的,如果每个元素比它前一个元素都大,则该数组是已经排好序的,返回true
    public static <T extends Comparable<T>> boolean isSorted(T[] arr) {
        for (int i = 1; i <arr.length; i++) {
            if (arr[i-1].compareTo(arr[i]) > 0) {
                return false;
            }
        }
        return true;
    }
​
  //传入1个排序方法,以及一个待排序数组,根据排序方法的不同,对数组执行不同的排序,并记录该排序耗时,最后验证排序是否正确
    public static <T extends Comparable<T>> void sortTest(String sortName,T[] arr){
        long beginTime = System.currentTimeMillis();
        if (sortName.equals("SelectionSort")) {
            SelectionSort.sort(arr);
        }
        if (sortName.equals("InsertionSort")) {
            InsertionSort.sort(arr);
        }
        long endTime = System.currentTimeMillis();
        double time = (endTime - beginTime) / 1000.0;
        if (!SortingHelper.isSorted(arr)) {
            throw new RuntimeException(sortName+" failed");
        }
        System.out.println(String.format("%s ,%d ,%s s",sortName,arr.length,time));
    }
}
​

插入排序算法的main()添加下述代码段,进行测试:

public static void main(String[] args){
  Student[] students={new Student("Jim",120,7),
                      new Student("Kate",120,7),
                      new Student("Tom",110,5)};
  SelectionSort.sort(students);
  for(Student stu:students){
    System.out.println(stu);
  }
  int[] size = {10000, 100000};
  for (int i = 0; i < size.length; i++) {
    //调用辅助工具类ArrayGenerator.generateRandomArray()生成2个长度、元素的随机数,分别为1万,10万的数组
     Integer[] integers = ArrayGenerator.generateRandomArray(size[i], size[i]);
    //调用辅助工具类SortingHelper.sortTest(),分别对这2个数组进行插入排序,并验证排序正确性
     SortingHelper.sortTest("InsertionSort", integers);
   }    
}  
//结果:
//Student: name=Tom height=110 age=5
//Student: name=Jim height=120 age=7
//Student: name=Kate height=120 age=7
//InsertionSort ,10000 ,0.226 s
//InsertionSort ,100000 ,24.634 s

从上,可以看到,1万个元素的数组插入排序耗时0.226s,而10万个元素的数组插入排序耗时24.634s,耗时差不多是前者的100倍,而数据量是前者的10倍。因此,说明该排序算法时间复杂度是O(n2)。

6 优化插入排序算法

从现有的排序算法分析,

//带泛型的插入排序算法,
  public static <T extends Comparable<T>> void sort(T[] arr){
    //从第i个元素开始循环,直到最后1个元素结束,外循环这里的[i,arr.length)区间的元素都是没排序的。
    for(int i=0;i<arr.length;i++){
      //内循环这里的第[0,j==i)区间的元素是拍好序的
      for(int j=i;j>0;j--){
        //拿出这第i个元素和前面的已排好序的元素逐一比较,如果小于则交换位置
        if(arr[j].compareTo(arr[j-1])<0){
          swap(arr,j,j-1);
        }else{
          //如果不小于即arr[j]>=arr[j-1],则它一定也不小于更前面的元素,直接跳出内循环
          break;
        }
      }
    }
  }

当我们执行内层循环,当arr[j]<arr[j-1]的时候,我们就需要交换它俩的位置,此时,如果arr[j-2]依然小于交换之后的arr[j-1]的情况下,我们还得交换一次。同时,如果再次交换之后,前面的元素依然小于交换之后的话,仍然要交换一次。那么,有没有聪明一点儿的做法呢?比如,当我们抓牌、理牌的时候,此时手上的牌分别是3,6,8,9,我们抓了一张5,这时候,按照现有的算法逻辑,先比较5和9,发现小于,就交换位置,交换之后,变成3,6,8,5,9,接着,比较8和5,继续交换,变成3,6,5,8,9,继续比较6和5,并交换位置,最后来到,3,5,6,8,9。这里执行了3次比较和交换的操作。试想,手上的牌分别是3,6,8,9,我们抓了一张5的时候,放在9的后面,5<9,把5抽出来,放在另外一个地方,腾出位置,让9移动到5原来的位置,5<8,让8移动到9原来的位置,进而5<6,让6移动到8原有的位置,最后5不小于3,就把5放到6原有的位置。这样,和原有的算法比较,就减少了3次交换位置的操作,取代的是6,8,9三次顺移赋值的操作。于是,我们可以把算法的实现逻辑,改造成下面的实现:

//带泛型的插入排序算法
public static <T extends Comparable<T>> void sortPlus(T[] arr){
  //从抓到的这第i张牌的位置开始,外层循环从[i,arr.length)的元素仍然是未排序的
  for(int i=0;i<arr.length;i++){
    //把这个第i张牌"拿走",放到一个新的地方temp处存放,
    T temp=arr[i];
    //另起一个变量,让它等于第张牌的位置开始,从i到i-1,从右到左的顺序,开始循环。把这个变量放在循环外面的原因是,当我们把6,8,9三张已排序的牌,依次顺移之后,如果前面再没有比它大的元素的话,这个变量j正好要放我们拿走的那个第i张牌,即5应该放的位置。且,我们还要再次用到它,如果放到for loop里面的话,离开loop之后,它的作用域就消失了,我们无法用到它
     //注意,这里的j其实就是将来我们要把arr[i]插入的位置。
    int j;
    for(j=i;j>0;j--){
      //从"拿走的"这个第i张牌位置开始,从右到左,如果相邻的两张牌,左边的牌<右边的牌,则通过右边的牌=左边的牌的赋值方法,来实现依次顺移的操作
      if(temp.compareTo(arr[j-1])<0){
        arr[j]=arr[j-1];
      }
      //********************************************************
      //注意:这里的else从句必须带上,否则,该算法可能会报错,比如:传入3,1,5的数组,排序之后的输出结果,变成了5,3,5。
      //*******************************************************
      else{
        break;
      }
      //对于上述这段for loop代码,也完全可以改造成下述
      //for(j=i;j>0 && arr[j].compareTo(temp)<0;j--){
      //  arr[j]=arr[j-1];
      //}
    }
    //好了,所有小于我们拿走的那个第i张牌,依次顺移走了。此时,j就是我们拿走的那个第i张应该放入的位置,把它放进去就结束这一轮的顺移操作。我们又从第i+1张牌开始,重复此过程。这就省去了逐个交换位置的操作了。
    arr[j]=temp;
  }
}
​

在我们工具类的SortingHelper中,增加这个sortPlus()改进版的插入排序算法的分支,并进行测试对比

public static <T extends Comparable<T>> void sortTest(String sortName,T[] arr){
        long beginTime = System.currentTimeMillis();
        if (sortName.equals("SelectionSort")) {
            SelectionSort.sort(arr);
        }
        if (sortName.equals("InsertionSort")) {
            InsertionSort.sort(arr);
        }
    //加入优化插入排序算法的分支
        if (sortName.equals("InsertionSortPlus")) {
            InsertionSort.sortPlus(arr);
        }
        long endTime = System.currentTimeMillis();
        double time = (endTime - beginTime) / 1000.0;
        if (!SortingHelper.isSorted(arr)) {
            throw new RuntimeException(sortName+" failed");
        }
        System.out.println(String.format("%s ,%d ,%s s",sortName,arr.length,time));
    }

InsertionSort的main()方法中,执行测试对比:

 public static void main(String[] args) {
        int[] dataSize = {10000, 100000};
        System.out.println("Random array:InsertionSort VS InsertionSortPlus:");
        for (int n:dataSize ) {
          //开一个长度为1万,元素大小上限也为1万的随机数组
            Integer[] arr = ArrayGenerator.generateRandomArray(n, n);
          //在排序之前,对这个数组进行复制,便于后面使用不同的方法对相同的数组执行排序测试对比
            Integer[] arr2 = Arrays.copyOf(arr, arr.length);
            SortingHelper.sortTest("InsertionSort", arr);
            SortingHelper.sortTest("InsertionSortPlus",arr2);
        }
 }
//执行结果:
//Random array:InsertionSort VS SelectionSort:
//InsertionSort ,10000 ,0.247 s
//InsertionSortPlus ,10000 ,0.18 s
//InsertionSort ,100000 ,28.561 s
//InsertionSortPlus ,100000 ,15.916 s

从中,我们看到,无论是对于1万还是10万数量级的数组,我们优化后的改进版的插入排序算法的性能都要比之前的好一些。当然,这一点儿改进还不足以吸引人,毕竟还真是相同数量级上的提升。等我们使用一些高级排序算法的时候,比如快速排序算法、合并排序算法,才是更高维度的算法,其性能要远远高于我们现在所使用的的算法。

7 对比选择排序算法和插入排序算法的性能

从上面的分析得出插入排序算法的时间复杂度也是O(n2),即使我们对其进行了改造和优化,其复杂度也依然是保留在O(n2)这个数量级。但是,在实际工程中,和我们之前用到的选择排序算法相比,插入排序算法的性能旺旺要好一些。

例如:给定数组,1,2,4,7,使用选择排序算法的时候,每次都要先假定待排序数据中的第一个是最小元素,然后再从剩下的元素中找一个最小的元素出来,并和认定的那个元素进行比较大小并交换。我们从直观上能看出来,对于这个已经是有序的数组,使用选择排序算法的话,显然做了很多无用功。这样一来,选择排序算法的时间复杂度依然是O(n2)。

而,对于插入排序算法的话,外层循环依然是n次,而对于内层循环,每次都不满足待插入元素的前一个元素比它小,这样就相当于复杂度退化到常数级别的了,进而整体复杂度可以粗略认为是O(n)级别的了。

Wikipedia关于insertion-sort的说明:

  • More efficient in practice than most other simple quadratic (i.e., O(n2)) algorithms such as selection sort or bubble sort
  • Adaptive, i.e., efficient for data sets that are already substantially sorted: the time complexity is O(kn) when each element in the input is no more than k places away from its sorted position

我们还是实际拿数据来测试对比一下,这次我们用ArrayGenerator辅助工具类来构造有序的数组。

 public static Integer[] generateOrderedArray(int n) {
        Integer[] orderedArray = new Integer[n];
        for (int i = 0; i < orderedArray.length; i++) {
            orderedArray[i] = i;
        }
        return orderedArray;
    }

main()代码如下:

 public static void main(String[] args) {
       int[] dataSize = {10000, 100000};
        System.out.println("Random array:InsertionSort VS SelectionSort:");
        for (int n:dataSize ) {
            Integer[] arr = ArrayGenerator.generateRandomArray(n, n);
            Integer[] arr2 = Arrays.copyOf(arr, arr.length);
            SortingHelper.sortTest("SelectionSort", arr);
            SortingHelper.sortTest("InsertionSortPlus",arr2);
        }
////
        System.out.println("Ordered array:InsertionSort VS SelectionSort:");
        for (int n:dataSize ) {
            Integer[] arr = ArrayGenerator.generateOrderedArray(n);
            Integer[] arr2 = Arrays.copyOf(arr, arr.length);
            SortingHelper.sortTest("SelectionSort", arr);
            SortingHelper.sortTest("InsertionSort", arr2);
        }
 }
//输出结果:
Random array:InsertionSort VS SelectionSort:
SelectionSort ,10000 ,0.165 s
InsertionSortPlus ,10000 ,0.24 s
SelectionSort ,100000 ,15.211 s
InsertionSortPlus ,100000 ,17.546 s
Ordered array:InsertionSort VS SelectionSort:
SelectionSort ,10000 ,0.08 s
InsertionSort ,10000 ,0.0 s
SelectionSort ,100000 ,8.293 s
InsertionSort ,100000 ,0.001 s

对于元素是随机数的数组而言,选择排序和插入排序性能几乎相当。

但是,当数组元素是部分有序或者接近有序的情况下,数据量大的情况下,插入排序算法的性能要远远胜于选择排序算法。

当然,这种是比较理想的情形,还有就是这种也只是相同数量级的胜出,跟高级排序算法相比,这根本算不了什么。

留言