插入排序与希尔排序
前言
本篇文章是排序算法系列的第二篇,学习插入排序和希尔排序
后面这段话将作为排序算法系列博客每一篇的开头:
为避免文中过多赘述,写在最前面:
- 接下来所有的排序算法讲解中,无论是思路梳理,还是代码实现,都是最终实现从小到大排序,从大到小可以学会后自行类推。
- 都是使用int数组进行排序,数据总量为n
插入排序
核心理念
插入排序,是将排序问题转化成了一个插入数据的问题,假设有一个乱序的数组,我们创建一个新的数组,让它始终都是一个有序数组,也就是每添加一个数据进入数组,都将它按照有序的排列插入进数组。
根据这个想法,可以将无序的待排序数组,挨个取出来,按照那个始终有序的数组规则插入进有序数组,当待排序数组的所有数字都插入完成,就完成了排序。
这样解释起来,并不难理解,也确实能够完成排序
我在第一次学习到这里的时候,就自己思考去写代码了,创建了一个新的数组,将老数组的元素挨个取出来插入新数组,最终完成排序,下面这个就是我当初写的代码,最终的结果确实正确的给数组排序了,但我看了书上标准的实现后,震惊了,书中的实现并没有创建新的数组。
所以不要着急去看下面这个代码(你肯定不会看,因为我也没怎么写注释😄),容我在后面先分析一下为什么不用创建新的数组,真正最完美的插入排序思路
/**
* 自己思考时写的,创建了一个新的数组去插入,但其实没必要
*/
public static void insertSort1(int[] arr){
//新数组,有序插入无序数组的数据
int[] sortArr = new int[arr.length];
sortArr[0] = arr[0];
for (int i = 1; i < arr.length; i++) {
for (int j = i-1; j >= 0; j--) {
if (sortArr[j]>arr[i]){
sortArr[j+1] = sortArr[j];
if (j==0){
sortArr[j] = arr[i];
}
}else {
sortArr[j+1] = arr[i];
break;
}
}
}
arr = sortArr;
}
我们来分析一下,一个有序数组,如何保持插入数据后依然有序:
- 首先我们已知当前数组是有序的,从小到大排列
- 那么我们新的要插入的数据,有两种办法找到自己的位置
- 第一种是从前往后遍历有序数组,挨个比较与待插入数字的大小,当找到第一个大于等于待插入数据的,这里就是待插入数据的位置,之前从这个位置开始的所有数据都要后移一位
- 第二种是从后向前遍历有序数组,挨个比较与待插入数字的大小,当找到第一个小于等于待插入数据的,这个数字的后一个就是待插入数据的位置,之前从这个位置开始的所有数据都要后移一位
- 可以发现关键点三个步骤,第一步找到待插入数据的位置,第二步将从该位置开始的数据全部后移一位,第三步将待插入数据插入找到的位置
- 那么应该从前向后遍历还是从后向前遍历呢,如果从前向后,执行第二步回避从后向前麻烦,因为如果把一个数字后移,需要提前将他的下一个数字用临时变量存起来,否则就被直接覆盖了,而从后向前只需要最后一个数字的后面有一个空位即可顺利的挨个后移,而且可以一边寻找位置一边就后移,当发现遍历到的数字大于待插入数字,就直接后移,再遍历下一个,直到找到第一个小于待插入数据的,将数字插入到这个位置的后一个就完成了
- 这样一想,有序数组新插入一个数据,只需要在最后边多一个空位,那我们的插入排序其实可以不用创建新的数组
- 我们从前向后遍历整个无序数组,让数组从第一个到遍历到的数据保持有序,每遍历到下一个数字就将它插入到前面的有序部分去,被遍历到的数字抽出来正好可以提供那个需要的空位
- 我们其实可以从无序数组的第二个开始遍历,因为他的前边只有一个数字,必然是有序的,从第二个数字开始向前插入即可
接下来用代码实现一下,外层循环从第二个开始向后遍历,内层循环从第i个开始向前遍历
/**
* 将之前那个自己的实现代码优化后,排序过程只用原来的数组
*/
public static void insertSort2(int[] arr){
//从第二个数开始遍历
for (int i = 1; i < arr.length; i++) {
//待插入的数存进临时变量(相当于空出一个位置方便后移)
int temp = arr[i];
//是否在循环内找到了位置
boolean index = false;
//内层遍历待插入数前面的所有数去找到他的位置
for (int j = i-1; j >= 0; j--) {
/*
* 依次向下标i之前的数据,挨个比较
* 如果当前位置数据比自己大就将其后移
* 直到找到比自己小的,说明这个数的下一个位置就是属于待插入数据的,将数据插入后跳出内层循环
* 如果一直到索引0的位置还没有找到比自己小的,说明自己就是最小的了,将数据插入即可
*/
if (arr[j]>temp){
arr[j+1]=arr[j];
}else {
arr[j+1] = temp;
index = true;
break;
}
}
//如果没有在循环结束前找到,说明这个数字在前边是最小的,直接插入在索引0的位置
if (!index){
arr[0] = temp;
}
}
}
上面这个代码,还不是最优的写法,但其实效率上和数中的标准实现,已经没有什么区别了,我还是先把这段代码贴出来,因为这个读起来应该能更好理解一些
下面再看看书中的标准实现,内层循环用了while的,更简短更优雅的代码
/**
* 表中的写法——内层用了while
*/
public static void insertSort3(int[] arr){
for (int i = 1; i < arr.length; i++) {
//临时遍历保存待插入的数
int insertVal = arr[i];
//要遍历的数索引
int insertIndex = i-1;
/*
* 1.insertIndex>=0保证插入位置索引不越界
* 2.insertVal < arr[insertIndex]表示待插入数据还没有找到应该插入的位置
* 满足这两个条件,需要将arr[insertIndex]后移,insertIndex--
*/
while (insertIndex>=0 && insertVal < arr[insertIndex]){
arr[insertIndex+1] = arr[insertIndex];
insertIndex--;
}
//退出while循环后,说明当前insertIndex+1就是待插入数据的位置
arr[insertIndex+1] = insertVal;
}
}
其实这个while也可以用for来写
/**
* 看了标准写法的while后
* 进一步优化我自己的代码,将循环中的if,提到for循环的条件表达式中
*/
public static void insertSort4(int[] arr){
long start = System.currentTimeMillis();
//从第二个数开始遍历
for (int i = 1; i < arr.length; i++) {
//待插入的数存进临时变量
int temp = arr[i];
//记录待插入数应该插入的位置(初始化为要被插入的数据本身的位置)
int index = i;
//内层遍历待插入数前面的所有数去找到他的位置
for (int j = i-1; j >= 0 && arr[j]>temp; j--) {
/*
* 依次向下标i之前的数据,挨个比较
* 如果当前位置数据比自己大就将其后移,记录下当前这个位置的索引
* 后续有两种情况会退出这个循环:
* 1.当前要遍历的数字比自己小时就不会再进入循环了,而那个上一次循环中记录下来的索引位置就是要插入的位置
* 2.如果一直到索引0的位置还没有找到比自己小的,下一次也会退出循环,上一次循环中记录的索引位置必然为0,也是要插入的位置
*/
arr[j+1]=arr[j];
index = j;
}
//综上只要是退出了循环,index就是指向数据要插入的位置,直接插入就好
arr[index] = temp;
}
System.out.println("总执行时长(毫秒):"+(System.currentTimeMillis()-start));
// System.out.println(Arrays.toString(arr));
}
我一共贴出了四段代码,我们来比较一下他们的排序效率
首先已经测试过了,排序结果都是没有问题的
还是8w随机数据,分别排序五次,记录排序时间
int[] arr = new int[80000];
for (int i = 0; i < 80000; i++) {
arr[i] = (int) (Math.random() * 8000000);
}
-
insertSort1(),这个是最初自行实现的,用到两个数组的代码
2175、1334、1877、2039、1911 (可以发现并不稳定,在1.3s到2.1s之间)
-
insertSort2(),这个是自己优化成只使用一个数组后的代码
483、492、470、471、471 (比起两个数组效率提升四倍左右)
-
insertSort3(),这是书中的标准实现
434、463、454、443、470 (比自己的代码效率上稍有提升)
-
insertSort4(),说这个是看了标准实现后,内层继续使用for但优化的更简洁后的代码
470、457、466、473、468**(效率介于2、3之间)**
2、3、4效率对比实在不太明显,改为20w数据测试
2: 2943、2934、2930、2981、2925
3: 2820、2837、2843、2828、2837
4: 2953、2958、2957、2950、2935
确实还是标准写法效率上有一定的优势
拆解来看,标准写法使用了while的优势就在下面
while执行一句:index–;
用for再怎么优化这里也是两句:j–;index = j;
希尔排序
核心理念
希尔排序是在插入排序的基础上进行的优化
首先来说一下插入排序有一个什么样的问题
对插入排序来说,我们分析一下它最好情况和最坏情况排序速度差别有多大
最好情况:待排序数组已经是有序的
最坏情况:就是待排序数组正好是逆序的
8w数据测试排序来看,随机数组插入排序大概450毫秒左右完成排序,最坏情况则需要平均950毫秒左右,而最好情况只需要1-2毫秒
可以见得,2毫秒到950毫秒,差距非常之大
这种情况产生的原因在于,如果很小的数字在很靠后的位置,插入排序每次只能将数据移动一位来从最后面移动到最前面
有没有什么办法可以解决这个问题呢?
有一个叫做希尔的人,想到了办法,用分组的方式,增加移动的步长,分组越多移动的步长越大
最多分成两两一组也就是n/2组
比如用一个数组举例[5,8,12,4,15,1,7,6]
将它分成四组,第一个和第五个,第二个和第六个,第三个和第七个,第四个和第八个
分别把他们想象成一个数组进行插入排序,排序后会变成下面的样子
[5,1,7,22,4,8,12,6]
虽然没有完成排序,但是偏小一些的数字被很快的移动到了更靠前的位置
如果再将分组数降低为上一次的1/2,也就是分为2组,第一个第三个第五个第七个一组,第二个第四个第六个第八个一组,再次对两组分别进行插入排序后如下
[5,1,7,4,12,6,15,8]
这两次过后虽然还是没有排序完成,但是整个数组更加趋向于有序了
分为两组后,再缩小分组数为之前的1/2,就是分为1组了,相当于对全部进行插入排序
开始有点抽象,有人就有疑惑了,那不还是插入排序吗,接着向后分析
比较最开始的数组和现在的数组
[5,8,12,4,15,1,7,6]
[5,1,7,4,12,6,15,8]
发现更多小一些的数字到了更前面,大一点的数字到了更后面
那么之前的这些操作,相当于将本来要每次移动一位移动到这里来的数字,更快的放在了前面一些的位置
此时再执行插入排序就会少了非常多移动操作,因此排序的效率会得到提升
上述这个思想就是希尔排序,也称之为缩小增量排序
我们来用代码实现一下上述这个思路
大家可以结合注释读懂这段代码
/**
* 自己根据希尔排序概念分析思路实现:
* 1.插入排序的问题在于,如果很小的数字在很后边,那么它从最后边移动到前面来,需要非常多次的比较和移动操作
* 2.如果递减步长的分组进行插入排序可以有效减少比较和移动的次数
* 3.具体方式为,分组数g = 数据量/2,分组方式为第i个和第i+g个、...第i+(数据量/2)g个,一组,i <= g;
* 4.每一组进行插入排序后,再次分组,这次的 分组数g = 上次分组数/2,分组方式为第i个和第i+g、第i+2g...第i+(数据量/2)g为一组
* 5.直到进行到g=1,相当于最后是执行一次完整的插入排序,1/2=0不大于0,下一次就不会再进入循环了,至此排序完成
*/
public static void shellSort(int[] arr){
//只要分组数大于1,进进行分组插入排序
for (int groups = arr.length/2; groups > 0; groups/=2){
//外层循环分组
for (int i = 0; i < groups; i++) {
//内层循环把每一组执行插入排序(从第二个数开始遍历),出循环说明第i组插入排序完成了
for (int x = i+groups; x < arr.length; x+=groups) {
//待插入的数存入临时变量
int temp = arr[x];
//记录待插入数应该插入的位置的前一个(从当前待插入数据的上一个开始)
int index = x-groups;
//插入排序的内层循环,将要插入的数据向前比较找到位置,出循环说明位置找到了
while (index >= 0 && temp<arr[index]){
//遍历到的数后移
arr[index+groups] = arr[index];
//将要遍历的数移动到再上一个位置,方便下一次遍历
index-=groups;
}
//退出循环说明上一次循环找到了位置,应该是index的下一个,也就是加上一个步长groups
arr[index+groups] = temp;
}
}
}
}
代码读懂了吗?如果我告诉你这个不是最完美的希尔排序,你应该不会想揍我吧😂
这段代码其实是笔者当初自己学的时候,根据希尔排序的思路自行实现出来的,测试了排序结果是没有问题的,8w数据排序也一下子提速到了10毫秒,比起插入排序是一个质的飞跃,我当初非常惊喜。之后去看了网上的标准实现,但我已经钻进我自己的实现思路这个圆圈当中出不来,甚至有点没看懂标准实现代码为什么可以完成排序
于是我先粘下来标准实现的代码,运行测试了一下,毫无疑问排序结果是没有任何问题的
然后我又试了一下标准代码8w数据的效率,发现也是10ms左右,我又释怀了,觉得我这个也没问题,可能就是思路不太一样
都是10ms有点看不出差距,我尝试将数据量提升到了800w数字的排序
这时出现差距了,800w数据排序,我的野鸡实现方式,平均下来比标准实现方式慢了近200ms
我必须弄清楚,这个差距出在哪里
首先第一件事就是看懂标准的希尔排序是怎么完成排序的
我没有加注释,大家先自行随便看一下代码
public static void shellSort2(int[] arr) {
int temp, j;
for (int path = arr.length / 2; path > 0; path /= 2) {
for (int i = path; i < arr.length; i++) {
j = i;
temp = arr[j];
while (j - path >= 0 && temp < arr[j - path]) {
arr[j] = arr[j - path];
j -= path;
}
arr[j] = temp;
}
}
}
只看代码,标准实现只用了三层循环,而我的代码出现了四层循环。
我发现最中间第二个for循环,和我的代码中第三层的for循环,都是在进行插入排序
但我百思不得其解,为什么分组的插入排序,他可以从i=groups开始每次i++,遍历到n去做
于是new了一个简单一些的数组,debug看了一下排序过程
终于被我看到问题所在了
原来是我没有抓住本质
分组进行插入排序,我就将一组和一组隔离开,进行排序,但其实不用这样
本质上无论你属于哪一个分组,都是在做步长为组数的插入排序
也就是说,按每一组插入排序,本质上就是从第 组数+1 个元素开始一直到最后一个数字,按 组数 为 步长 进行插入排序
还用上面那个数组举例[5,8,12,4,15,1,7,6]
第一次将它分成四组
第一个和第五个执行插入排序,本质上是第五个元素执行步长为4的插入排序;
第二个和第六个执行插入排序,本质上是第六个元素执行步长为4的插入排序
第三个和第七个执行插入排序,本质上是第七个元素执行步长为4的插入排序
第四个和第八个执行插入排序,本质上是第八个元素执行步长为4的插入排序
完成后:[5,1,7,6,15,8,12,8]
第二次分为两组
第一个第三个第五个第七个一组,依次对第三、五、七个数做步长为2的插入排序
第二个第四个第六个第八个一组,依次对第四、六、八个数做步长为2的插入排序
其实还是3、4、5、6、7、8都做了步长为2的插入排序
也就是从 组数+1 数字开始到最后一个数组,做 组数 为 步长 的插入排序
完成后:[5,1,7,4,12,6,15,8]
这样才算是真的弄明白了这个标准希尔排序,不得不说想出算法和优化代码的人太厉害了
没有抓住本质的我,所以设计实现代码时,多了一层按组隔离的循环,多执行了一些循环体判断和i++的操作,导致了和标准实现方式出现的效率差距
再看一下给标准实现加上注释的代码吧
public static void shellSort3(int[] arr) {
//存放待插入数据的临时变量
int temp;
//记录待插入数据将要插入的位置
int index;
//外层控制,分组方式的执行次数,只要分组数大于1,就进行分组插入排序,每排完一次,分组/=2
for (int groups = arr.length / 2; groups > 0; groups /= 2) {
//内层进行插入排序,每一个分组分开进行插入排序,其本质上,无论你是属于第几个分组的数字,从第组数个数字开始,你都需要以组数为步长进行插入排序
for (int i = groups; i < arr.length; i++) {
index = i;
temp = arr[index];
//寻找要插入的位置,每次向前去遍历隔一个组数的数字,寻找插入位置
while (index - groups >= 0 && temp < arr[index - groups]) {
arr[index] = arr[index - groups];
index -= groups;
}
//找到了位置就插入
arr[index] = temp;
}
}
}
比起上一章,冒泡和选择,插入和希尔难理解了一些,但按思路跟下来应该还是可以理解到位的
下面说一下测试结果:
我的电脑来测试,希尔排序,8w数据只需要10毫秒左右,比起插入排序快了40多倍,质的飞跃
可见学好算法对程序性能提升可以有多恐怖
从这里开始呢,我的机器性能可以采用800w数据排序来记录执行时间了
希尔排序(根据思路自己实现的)——1822、1876、1850、1896、1836
希尔排序(标准实现的)——1678、1614、1734、1639、1666
下一篇我们将学习一种还要更快的排序,快速排序