本文整理来源 《轻松学算法——互联网算法面试宝典》/赵烨 编著
插入排序
什么是插入排序
插入排序分为两种,一种是直接插入排序,一种是二分法插入排序。这两种排序实际上都是插入排序,唯一的不同就是插入的方式不一样。
插入排序就是往数列里面插入数据元素。一般我们认为插入排序就是往一个已经排好顺序的待排序的数列中插入一个数之后,数列依然有序。
二分插入排序应该也是用来分治法的思想去排序的。实际上二分就是使用二分查找来找到这个插入的位置,剩下的插入的思想其实和直接插入排序一样。
所以完成插入排序,就是需要找到这个待插入元素的位置。
插入排序的原理
插入排序实际上把待排序的数列分成了两部分,一部分已排好序,另一部分待排序。
直接插入排序的整个执行过程:
- 首先需要明确待排序的数列由两部分组成,一部分是已排好序的部分,另一部分是待排序的部分。
- 接着我们每次选择待排序的部分的第1个元素,分别与前面的元素进行比较。当大于前面的元素时,可以直接进入已排序的部分;当小于前面的元素时,需要把这个元素拿出来,将前面的元素后移一位,继续与前面的元素相比,知道比较完数组的第1个元素或者出现一个元素小于我们拿出来的这个元素,这时停止比较、移动,直到把这个元素怒放到当时的空位上。
- 一直重复步骤2,当排序的部分已经没有元素可进行插入时,停止操作,当前的数列为已经排好序的数列。
插入排序的实现
首先外层是一个大循环,循环这个待排序的部分数列,内层是分别与前1个元素进行比较、移动,直到找到位置进行插入位置。
public class InsertSort {
private int[] array;
public InsertSort(int[] array) {
this.array = array;
}
public void sort() {
if (array == null) {
throw new RuntimeException("array is null");
}
int length = array.length;
if (length > 0) {
for (int i = 1; i < length; i++) {
int temp = array[i];
int j = i;
for (; j > 0 && array[j - 1] > temp; j--) {
array[j] = array[j - 1];
}
array[j] = temp;
}
}
}
public void print(){
for (int anArray : array) {
System.out.println(anArray);
}
}
}
测试代码
public class InsertSortTest {
@Test
public void main(){
int[] arrays = {5,9,1,9,5,3,7,6,1};
InsertSort insertSort = new InsertSort(arrays);
insertSort.sort();
insertSort.print();
}
}
插入排序的特点及性能
插入排序的操作很简单,而且我们通过实例及原理可以知道,插入排序在数列近似有序时,效率会非常高,因为这样会减少比较和移动的次数。
插入排序的时间复杂度是$O({n}^2)$
,我们会发现这个实现是双重嵌套循环,外层执行n遍,内层在最坏的情况下执行n遍,而且除了比较操作还有移动操作。最好的情况是数列近似有序,这时一部分内层循环只需要比较及移动较少的次数就可以完成排序。如果数列本身已经排好序,那么插入排序也可以达到线性实现复杂度及$O(n^2)$
,所以我们应该明确认识到,使用插入排序算法进行排序时,数列越近似有序,性能越高。
插入排序的空间复杂度时$O(1)$
,是常量级,由于在采用插入排序时,我们只需要使用一个额外的空间来存储这个"拿出来"的元素,所以插入排序只需要额外的一个空间去做排序,这是常量级的空间消耗。
插入排序时稳定的,由于数组内部自己排序,把后面的部分按前后顺序一点点地比较、移动,可以保持相对顺序不变,所以插入排序是稳定的排序算法。
希尔排序
插入排序算法主要是比较和移动的两个操作,会导致时间复杂度很大。但是插入排序在序列本身有序时能够达到$O(n)$
的时间复杂度,也就是说实际上如果序列本身有一定的有序性,那么使用插入排序的效率会更高,如果序列本身很短,那么插入排序的效率会很高。
什么是希尔排序
希尔排序也是一种插入排序算法,也叫做缩小增量排序,是直接插入排序的一种更高效的改进算法。
希尔排序在插入排序的基础上,主要通过两点来改进排序算法:一是在插入排序在对近似有序的数列进行排序时,排序的性能会比较好;二是插入排序的性能比较低效,及每次只能将数据移动一位。
希尔排序的原理
希尔排序的基本思想是:把待排序的数列按照一定的增量分割成多个数列。但是这个子数列不是连续的,二是通过前面提到的增量,按照一定相隔的增量进行分割的,然后对各个子数列进行插入排序,接着增量逐渐减小,然后依然对每部分进行插入排序,在减小到1之后直接使用插入排序处理数列。
特别强调,这里选择增量的要求是每次都要减少,知道最后一次变为1为止。首选增量一般为$frac{n}{2}$
,n为待排序的数列长度,并且每次增量都为上次的$frac{1}{2}$
。
希尔排序的实现
希尔排序实际上只是插入排序的改进,在算法实现上,我们需要额外操作的只有对增量的处理及对数列的分块处理。
public class ShellSort {
private int[] array;
public ShellSort(int[] array) {
this.array = array;
}
public void sort() {
int temp;
for (int k = array.length / 2; k > 0; k /= 2) {
for (int i = k; i < array.length; i++) {
for (int j = i; j >= k; j -= k) {
if (array[j - k] > array[j]) {
temp = array[j - k];
array[j - k] = array[j];
array[j] = temp;
}
}
}
}
}
public void print() {
for (int anArray : array) {
System.out.println(anArray);
}
}
}
public class ShellSortTest {
@Test
public void main(){
int[] arrays = {5,9,1,9,5,3,7,6,1};
ShellSort shellSort= new ShellSort(arrays);
shellSort.sort();
shellSort.print();
}
}
希尔排序的特点及性能
其实希尔排序只使用了一种增量的方式去改进插入排序,从上述对该算法的描述及实例中,我们能够清楚的知道实际上希尔排序在内部还是使用插入排序进行处理的。但是这个增量确实有它 意义,不管数列有多长,刚开始时增量会很大,但是数列整体已经开始趋于有序了,所以插入排序的速度还是会越来越快的。
在时间复杂度上,由于增量的序列不一定,所以时间复杂度也不确定。这在数学上还无法给出确切的结果。我们可以采用每次除以2的方式,但是研究,有以下几种推荐序列:
N/3+1
,N/3^2+1
,N/3^3+1
••••••(据说在序列数N<100000时最优)$2^{k}-1$
,$2^{(k-1)}-1$
,$2^{k-2}-1$
••••••(设k为总趟数)- 其他的还有质数
对于每次除以2的增量选择,希尔排序的最好情况当然是本身有序,每次区分都不用排序,时间复杂度是$O(n)$
;但是在最坏的情况下依然每次都需要移动,时间复杂度与直接插入排序在最坏情况下的时间复杂度一样$O(n^{2})$
。
但是一般认为希尔排序的平均时间复杂度时$O({n}^{1.3})$
。当然,希尔排序的时间复杂度与其增量序列有关,一般我们知道希尔排序会比插入排序快一些,这就足够了。
在希尔排序的实现中仍然使用了插入排序,只是进行了分组,并没有使用其他空间,所以希尔排序的空间复杂度同样是$O(1)$
,是常量级的。
在希尔排序中会进行分组、排序,所以同样的元素,其相对位置可能会发生变化,这是因为同样值的元素若不在一个组中,则有可能后面的元素会被移动到前面。所以希尔排序是不稳定的算法。
希尔排序的适用场景
在使用希尔排序时,需要选择合适的增量序列作为排序辅助,而这也是一个比较复杂的抉择。所以希尔排序在实际使用中并不常用。
简单选择排序
什么是选择排序
选择排序是一种非常简单的排序算法,就是在序列中依次选择最大(或者最小)的数,并将其放到待排序的数列的起始位置。
简单选择排序的原理
简单选择排序的原理非常简单,即在待排序的数列中寻找最大(或者最小)的一个数,与第1个元素进行交换,接着在剩余的待排序的数列中继续找最大(最小)的一个数,与第2个元素交换。依次类推,一直到待排序的数列中只有一个元素为止。
也就是说,简单选择排序可分为两部分,一部分是选择待排序的数列中最小的一个数,另一部分是让这个数与待排序的数列部分的第1个数进行交换,直到待排序的数列只有一个元素,至此整个数列有序。
简单排序的实现
public class SelectSort {
private int[] array;
public SelectSort(int[] array) {
this.array = array;
}
public void sort() {
int length = array.length;
for (int i = 0; i < length; i++) {
int minIndex = i;
for (int j = i + 1; j < array.length; j++) {
if (array[j] < array[minIndex]) {
minIndex = j;
}
}
if (minIndex != i) {
int temp = array[minIndex];
array[minIndex] = array[i];
array[i] = temp;
}
}
}
public void print() {
for (int anArray : array) {
System.out.println(anArray);
}
}
}
测试代码
public class SelectSortTest {
@Test
public void main(){
int[] arrays = {5,9,1,9,5,3,7,6,1};
SelectSort selectSort = new SelectSort(arrays);
selectSort.sort();
selectSort.print();
}
}
选择排序的特点及性能
由于在简单选择排序中,我们一般在原本的待排序的数组上排序并交换,基本上使用的都是常量级的额外空间,所以其空间复杂度时$O(1)$
。
在最好的情况下,每次都要找的最大(或者最小)的元素就是待排序的数列的第1个元素,也就是说数列本身有序,这样我们只需要一次遍历且不需要交换,即可实现一趟排序;而在最坏的情况下,每次在数列中要找的元素都不是第1个元素,每次都需要交换。比较的次数只与数列的长度有关,而在外部遍历整个数列,也与长度有关,所以这样的双重循环不管在什么情况下,时间复杂度都是$O(n^{2})$
,但是由于选择有序不需要一个一个地往前移动,而是直接交换,而比较所消耗的CPU要比交换所消耗的CPU小一些,所以选择排序的时间复杂度相对于冒泡排序会好一些。
简单选择排序优化
通过选择排序的思想,我们知道选择排序的一个重要步骤是在待排序的数列中寻找最大(或者最小)的一个元素,那么如何寻找这个元素就成为一个可以优化的点。
另外,我们每次都要寻找两个值中的一个最大值,一个是最小值。这时如果需要将数列的最后一个元素进行交换。这样我们一次就能寻找两个元素进行交换,把最大值与待排序的数列的最后一个元素进行交换。这样我们一次就能够寻找两个元素,使外层循环的时间缩短了一半,性能也提高了很多。通过一次遍历就可以找到两个最值,并且没有其他性能损耗。
简单选择排序
简单选择排序并不很常见,它只是选择排序的一个思想基础,选择排序还有其他方案可以实现。
小结
类别 | 排序方法 | 时间复杂度(平均|最好|最坏) | 空间复杂度(辅助存储) | 稳定性 |
---|---|---|---|---|
插入排序 | 直接插入 | $O(n^2)$ |
$O(n)$ |
$O(n^2)$ |
插入排序 | 希尔排序 | $O(n^{1.3})$ |
$O(n)$ |
$O(n^2)$ |
选择排序 | 简单选择 | $O(n^2)$ |
$O(n^2)$ |
$O(n^2)$ |
选择排序 | 堆排序 | $O(nlogn)$ |
$O(nlogn)$ |
$O(nlogn)$ |
交换排序 | 冒泡排序 | $O(n^2)$ |
$O(n)$ |
$O(n^2)$ |
交换排序 | 快速排序 | $O(nlogn)$ |
$O(nlogn)$ |
$O(nlogn)$ |
一般情况下在选择排序算法时有限选择快速排序,虽然堆排序的空间复杂度更低,但是堆排序没有快速排序简单。