希尔排序🌟🌟
目前为止我们已经讲过了,冒泡排序,选择排序,插入排序这三种排序算法,虽然在某些场景下这几种算法会有比较好的性能表现,但对于更一般的场景来说,这三种排序算法的性能显然是不如我们所愿的。
所以我们今天来学习一种效率更高的排序算法——希尔排序。
我们在之前提到过插入排序在小规模数组大部分有序的情况下,性能是不错的。但是对于大规模数组和乱序的情况下的性能表现就比较差了。
可以想象更极端的场景,我们对一个完全逆序的数组通过插入排序来得到升序的数组,这其中所需要的比较次数是最多的。因为我们每次只能和左边相邻的元素进行交换,一步一个脚印。
希尔排序思想正是通过改进插入排序的这一弱点来进行优化的,通过交换不相邻的元素来让数组局部有序,并最终通过插入排序将局部有序变整体有序。
关于间隔的思考
既然是交换不相邻的元素,很重要的一点就是要确定交换元素的间隔,并逐渐让间隔变小,在最后间隔变为 1 (其实也就是原生的插入排序了)来让数组变有序。
为什么间隔是逐渐变小呢?一开始的排序,我们选择一个更大的间隔,得到的子数组的规模就会更小。
因为我们对于子数组的排序本质上还是使用插入排序,数组规模越小性能越好。
当大间隔的子数组完成排序之后,小间隔的子数组肯定已经是部分有序了,那么小间隔的子数组的排序速度也会得到提升。
不得不说,希尔排序的这个优化权衡了插入排序对于小规模和有序数组良好性能的特点,实在是太妙了!
关于间隔的选择,我们采用这样的一个间隔序列
1 4 13 40 ...
希尔排序算法流程分析
上面的描述如果没太看懂也没关系,我们还是通过一个对有 n 个元素的数组进行希尔排序的具体例子,来理解这个过程(升序),但是看完例子还是希望大家再仔细的体会希尔排序的妙处所在。
排序之前
5 7 8 3 1 2 4 6 11 8 5 1 10 3
第一轮排序
数组长度为 14,所以我们一开始的间隔只需要取 13 就可以了。
加粗的数字即为子数组:
5 7 8 3 1 2 4 6 11 8 5 1 10 3
对子数组进行插入排序,得到有序的子数组:
3 7 8 3 1 2 4 6 11 8 5 1 10 5
第二轮排序
第二轮排序的间隔取 4。
加粗的数字即为子数组:
3 7 8 3 1 2 4 6 11 8 5 1 10 5
对子数组进行插入排序,得到有序的子数组:
1 7 8 3 3 2 4 6 10 8 5 1 11 5
第三轮排序
第三轮排序的间隔取 1,也就是对整个数组做插入排序,具体的过程我们就不演示了,最后得到的是一个有序的数组。
代码实现
public int[] shellSort (int[] nums) {
int n = nums.length;
int h = 1;
while (h < n / 3) {
h = h * 3 + 1;
}
while (h > 0) {
for (int i = h; i < n; i++) {
for (int j = i; j - h >= 0 && nums[j] < nums[j - h]; j -= h) {
swap(nums, j, j - h);
}
}
h /= 3;
}
return nums;
}
public void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
时间复杂度
大家应该也能看出来,希尔排序的性能取决于我们所选择的间隔序列。
很多论文都研究过一些复杂的递增序列,已经超出我们这篇文章的讨论范围了,感兴趣的大家可以自行补充学习。
因为我们的篇幅有限,所以没办法为大家演示各种递增序列的性能表现,大家可以尝试使用各种递增序列来对随机的大规模数组进行性能测试,同时也可以跟插入排序做对比。相信你对希尔排序会有更深的体会。
但我们可以确定的是,希尔排序对于大规模数组以及随机乱序的数组的排序效果,性能都是不错的。
空间复杂度
希尔排序也是一种典型的原地排序算法,空间复杂度为 ${O(1)}$,因为只需要常数级别的额外空间来存储临时变量。
稳定性
希尔排序不是一种稳定的排序算法。虽然希尔排序是基于插入排序的思想,但希尔排序是跳跃性插入的,很大可能破坏稳定性。
写在最后
总的来说,学习希尔排序更重要的是体会算法设计的思想中对插入排序的改进,算法思想的学习和培养才是我们学习基础算法的意义所在。
后面我们就要开始讲解男女老少都口口相传的归并排序和快速排序了,大家期待一下吧!