排序:为什么插入排序比冒泡排序更受欢迎?

文章探讨了冒泡排序和插入排序在时间复杂度相同的情况下,插入排序更受欢迎的原因。分析了排序算法的执行效率、内存消耗和稳定性,并提到了希尔排序作为优化选项。文章指出,算法选择取决于数据结构,如在链表上的适用性和复杂度变化。
摘要由CSDN通过智能技术生成

文章来源于极客时间前google工程师−王争专栏。

需掌握的的排序:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。按照时间复杂度可以分为三类:

image

问题:插入排序和冒泡排序的时间复杂度相同,都是O(n^2),在实际的软件开发中,为什么我们更倾向于使用插入排序算法而不是冒泡排序算法呢

如何分析一个“排序算法”?

算法的执行效率

1.最好情况、最坏情况、平均情况时间复杂度

对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。

2.时间复杂度的系数、常数、低阶

时间复杂度反映的是数据规模n很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。

但是在实际的软件开发中,我们排序的可能是10,100,1000这样数据规模很小的数据,所以在时间复杂度相同的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。

3.比较次数和交换(或移动)次数

基于比较的排序算法会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以分析排序算法的执行效率,应该把比较次数和交换(或移动)次数也考虑进去。

排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。

针对排序算法的空间复杂度,还引入了一个新的概念,原地排序。原地排序算法,就是特指空间复杂度是O(1)的排序算法。

排序算法的稳定性

用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,还有一个重要的指标,稳定性

这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

比如有一组数据2,9,3,4,8,3排序之后就是2,3,34,8,9。

这组数据里有两个3,如果经过排序算法之后,两个3的前后顺序不变,那我们就把这种排序算法叫作稳定排序算法;如果前后顺序发生变化,对应的算法就叫作不稳定的排序算法

真正的软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个key来排序。

需求:现在要给电商交易系统中的“订单”排序。订单中有两个属性,一个是下单时间,另一个是订单金额。如果现在有10万条订单数据,我们希望按照金额从小到大排序,对于金额相同的订单,我们希望按照下单时间从早到晚有序。

最先想到的方法是:先按照金额排序,然后再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路,理解简单,实现复杂。

借助稳定排序算法:先按照下单时间对订单数据进行排序,然后
用稳定排序算法,按照订单金额重新排序。两遍排序,实现需求。

稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序保持不变

image

冒泡排序(Bubble Sort)

冒泡排序只会操作相邻的两个数据。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序。

对一组数据4,5,6,3,2,1,从小到大排序,第一次冒泡的详细过程如下图所示:
image

经过一次冒泡操作之后,6这个元素已经存储在正确的位置上。要想完成所有数据的排序,我们只需要进行6次这样的冒泡操作就行了。

image

优化:当某次冒泡操作已经没有数据交换时,说明已经达到了完全有序,不用再继续执行后续的冒泡操作。如下图所示,这里给6个元素排序,只需要4次冒泡操作就可以了。

image

代码如下:

public void bubbleSort(int[] array){
    int len = array.length;
    if(len < = 1) return;
    for(int i=0;i<len;++i){
        //提前退出冒泡循环的标志位
        boolean flag = false;
        for(int j=0;j<len-1-i;++j){
            if(array[j] > array[j+1]){
                //表示有数据交换
                flag = true;
                int temp = array[j];
                array[j] = array[j+1];
                array[j+1] = temp;
            }
        }
        if(flag){//没有数据交换提前退出
            break;
        }
    }
}

这里有三个问题:

第一,冒泡排序是原地排序算法吗?

冒泡过程中只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1),是一个原地排序算法。

第二,冒泡排序是稳定的排序算法吗

只有交换才可以改变两个元素的前后顺序。当有相邻的两个元素大小相等的时候,我们不做交换,所以冒泡排序是稳定的排序算法。

第三,冒泡排序的时间复杂度是多少

最好情况下,要排序的数据已经是有序的,只需要进行一次冒泡操作,就可以,所以最好情况复杂度是O(n)。最坏情况,需要进行n次冒泡操作,所以最坏情况时间复杂度是O(n^2)。

image

用概率论的方法定量分析平均时间复杂度,涉及的数学推理和计算比较复杂。可以通过“有序度”和“逆序度”这两个概念来进行分析。

有序度是数组中具有有序关系的元素对的个数。数学表达式如下:

有序元素对:a[i] <= a[j], 如果 i < j。

image

对于一个倒叙排列的数组,比如6,5,4,3,2,1,有序度是0;1,2,3,4,5,6,有序度就是n*(n-1)/2,也就是15.我们将这种完全有序的数组的有序度叫作满有序度

逆序度的定义正好跟有序度相反。

逆序元素对:a[i] > a[j], 如果 i < j。

根据这三个概念,我们还可以得出一个公式:逆序度 = 满有序度 - 有序度

排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。

对于包含n个数据的数组进行冒泡排序,平均交换次数是多少?最坏情况下,初始状态的有序度为0,所以要进行n*(n-1)/2次交换。最好情况下,初始状态的有序度是n*(n-1)/2,就不需要进行交换。我们可以取个中间值n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。所以平均情况下的时间复杂度就是O(n^2)。

插入排序(Insertion Sort)

先看一个问题。一个有序数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,只要遍历数组,找到数据应该插入的位置讲其插入即可。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
这是一个动态排序的过程,即动态地往有序集合中添加数据,我们可以通过这种方法保持集中的数据一直有序。而对于一组静态数据,我们也可以借鉴上面讲的插入方法,来进行排序,于是就有了插入排序算法

插入排序具体是如何借助上面的思想来实现排序的呢

首先,我们将数组中的数据分为两个区间,已排序区间未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置讲其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中的元素为空,算法结束。

如图所示,要排序的数据是4,5,6,1,3,2,其中左侧为已排序区间,右侧是未排序区间。
image
插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当我们需要将一个数据a插入到已排序区间时,需要拿a与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素a插入。

对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。

如下图所示。满有序度是n*(n-1)/2=15,初始有序度是5,所以逆序度是10。插入排序中,数据移动的个数总和也等于3+3+4=10。
image
代码实现如下:

public void InsertSort(int[] array){
    int len = array.length;
    if(len <= 1){return;}
    //i默认为1,分为两个区间,开始遍历无序区间
    for(int i=1;i<n;++i){
       int j = i-1;
       //声明无序区间第一个元素
       int value = array[i];
       //循环有序区间
       for(;j>=0;--j){
           if(array[j]>value){
               //数据移动
               array[j+1] = array[j];
           }else{
               //小于无序区间元素跳出循环,记录j
               break;
           }
       }
       //将数据插入有序区间的正确位置
       array[j+1] = value;
    }
}

自行实现插入降序算法

第一,插入排序是原地算法吗?

从实现过程可以看出,插入排序的算法运行不需要额外的存储空间,所以空间复杂度为O(1),也就是说,这是一个原地排序算法。

第二,插入排序是稳定的排序算法吗?

排序之后,相同元素前后顺序不变,所以插入排序是稳定的排序算法。

插入排序的时间复杂度是多少?

最好时间复杂度是O(n),只需要从尾到头遍历已经有序的数据

如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为O(n^2)。

在数组中插入一个数据的平均时间复杂度是O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行n次插入操作,所以平均时间复杂度是O(n^2)。

选择排序(Selection Sort)

选择排序算法的实现思路类似插入排序,也分排序区间和未排序区间。但是选择排序每次都会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
image

选择排序空间复杂度为O(1),是一种原地排序算法。选择排序最好、最坏和平均情况时间复杂度都是O(n^2)。

选择排序是不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,破坏了稳定性。

比如5,8,5,2,9这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素2,与第一个5交换位置,那第一个5和中间的5顺序就变了,所以就不稳定了。相对于冒泡排序和插入排序,选择排序就稍微逊色了。

解答开篇

冒泡排序和插入排序的时间复杂度都是O(n^2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?

不管如何优化,元素交换的次数是一个固定值,是原始数据的逆序度

从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要3个赋值操作,而插入排序只需要1个。

冒泡排序中数据的交换操作:
if (a[j] > a[j+1]) { // 交换
   int tmp = a[j];
   a[j] = a[j+1];
   a[j+1] = tmp;
   flag = true;
}

插入排序中数据的移动操作:
if (a[j] > value) {
  a[j+1] = a[j];  // 数据移动
} else {
  break;
}

将执行一个赋值语句的时间粗略地记为单位时间(unit_time),对一个逆序度为K的数组进行排序。冒泡需要K次交换操作,交换操作耗时3*K单位时间。插入排序中数据移动操作只需要K个单位时间。

随机生成10000个数组,每个数组中包含200个数据,冒泡排序需要584ms完成,插入排序只需要105ms就能搞定!

//数组工厂
public class ArrayFactory {
	private static Random rand = new Random();
	//获得10000个包含200个随机数的数组
	public static int[][] get2Array(){
		int[][] array = new int[10000][200];
		for(int i=0;i<array.length;++i){
			array[i] = getArray();
		}
		return array;
	}
	public static int[] getArray(){
		int[] array = new int[200];
		for(int i=0;i<array.length;++i){
			array[i] = rand.nextInt(500);
		}
		return array;
	}
}
//排序效率测试
public class TestSort {
	//冒泡排序
	public static void bubbleSort(int[] a){
		if(a == null) return;
		int len = a.length;
		if(len <= 1)return;
		for(int i=0;i<len;++i){
			//退出标志
			boolean bol = true;
			for(int j=0;j<len-i-1;++j){
				if(a[j]>a[j+1]){
					//交换
					int temp = a[j+1];
					a[j+1] = a[j];
					a[j] = temp;
					bol = false;
				}
			}
			if(bol){
				break;
			}
		}
	}
	//插入排序
	public static void InsertSort(int[] a){
		if(a == null) return;
		int len = a.length;
		if(len <= 1) return;
		//用下标1分为两个区间左边为有序区间,右边为无序区间
		for(int i=1;i<len;++i){
			//记录无序区间第一个数
			int value = a[i];
			int j = i-1;
			//遍历无序区间数据个数
			for(;j>=0;--j){
				//无序区间每一个数与value比较
				if(a[j]>value){
					//移动
					a[j+1] = a[j];
				}else{
					break;
				}
			}
			//插入数据
			a[j+1] = value;
		}
	}
	//打印数组
	public static void printArray(int[] array){
		int len = array.length;
		for(int i=0;i<len;++i){
			if(i == len-1){
				System.out.print(array[i]);
			}else{
				System.out.print(array[i]+", ");
			}
		}
		System.out.println();
	}
	//打印10000个数组冒泡排序的时间
	public static void bubbleSortTime(){
		long start = System.currentTimeMillis();
		int[][] array = ArrayFactory.get2Array();
		for(int i=0;i<array.length;++i){
			bubbleSort(array[i]);
		}
		System.out.println(System.currentTimeMillis()-start);
	}
	//打印10000个数组插入排序的时间
	public static void insertSortTime(){
		long start = System.currentTimeMillis();
		int[][] array = ArrayFactory.get2Array();
		for(int i=0;i<array.length;++i){
			InsertSort(array[i]);
		}
		System.out.println(System.currentTimeMillis()-start);
	}
	public static void main(String[] args) {
		bubbleSortTime();//output:584
		insertSortTime();//output:105
	}
}

插入排序性能优化-希尔排序

评价一个排序算法,需要从执行效率、内存消耗和稳定性三个方面看。重点掌握分析方法。
image

这三种排序算法,实现代码简单,对于小规模数据的排序,用起来非常高效。但是在大规模数据排序中,这个时间复杂度还是有点高,所以我们更倾向于时间复杂度为O(nlogn)的排序算法。

思考

特定的算法是依赖特定的数据结构的。都是基于数组实现,如果数据存储在链表中,这三种排序算法还能工作吗?相应的时间、空间复杂度是多少?

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值