数据结构与算法的重温之旅(九)——三个简单的排序算法

前面的几篇文章讲了一些基础的数据结构类型,此次咱们就深刻算法,先从简单的排序算法提及。在排序算法中,入门必学的三个算法分别是冒泡排序、插入排序和选择排序。下面就具体讲一下这三个算法的原理和代码实现算法

1、冒泡排序(Bubble Sort)

冒泡排序只会操做相邻的两个数据。每次冒泡操做都会对相邻的两个元素进行比较,看是否知足大小关系要求。若是不知足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工做。数组

打个比方,好比[6, 5, 4, 3, 2, 1],先从下标为0的元素开始,第一个位元素比第二位大,因此第一位与第二位的位置互相交互,而后第二位与第三位的互相比较,第二位的比第三位的大,这两位互相交互,以此类推。当完成一轮比较后就会从下标位1的元素开始重复上述过程,直到下标为n-1时则中止。因此能够得出时间复杂度是O(n)=n^{2}。下面是具体的代码实现:bash

var arr = [5,3,6,2,8,1,9,4,7,10,11,34,12]
var index = 0
while (index < arr.length - 1) {
	for (var i = 0; i < arr.length - index; i++) {
		var temp = ''
		if (arr[i] > arr[i+1]) {
			temp = arr[i+1]
			arr[i+1] = arr[i]
			arr[i] = temp
        }
    }
	++index
}复制代码

在这里提一个知识点,若是涉及到两数交换的话除了用一个临时变量来暂存这种方法外,还有用异或运算来实现两数交换。上述的代码能够改写以下:数据结构

var arr = [5,3,6,2,8,1,9,4,7,10,11,34,12]
var index = 0
while (index < arr.length - 1) {
	for (var i = 0; i < arr.length - index; i++) {
		var temp = ''
		if (arr[i] > arr[i+1]) {
			arr[i] = arr[i] ^ arr[i+1]
			arr[i+1] = arr[i] ^ arr[i+1]
			arr[i] = arr[i] ^ arr[i+1]
        }
    }
	++index
}复制代码

异或运算是位运算,也就是将数字转换成二进制来运算。异或运算是两个数相同位上的数互相比较,只要是相同则返回0,不一样则为1。拿个简单的例子:post

var a = 1, b = 2
a = a ^ b
b = a ^ b
a = a ^ b复制代码

第一步异或运算里,a为1,对应的二进制是01,b为2对应的二进制是10,因此运算后a为11,也就是3。第二步的异或运算里,a为11,b为10,运算后可得b为01,也就是1。到第三步里,a为11,b为01,可得a为10,也就是2。这就不利用临时变量来实现两数交换。位运算在处理数据量比较大的状况下十分的高效,可是因为冒泡排序算法的时间复杂度过高,因此在大数据的状况下还不如换另外一种时间复杂度低的算法。性能

冒泡排序除了上述利用位运算来优化外还能够经过判断后面的元素是否有交换来提早结束冒泡,优化改进以下:大数据

var arr = [5,3,6,2,8,1,9,4,7,10,11,34,12]
var index = 0
while (index < arr.length - 1) {
    var state = false
	for (var i = 0; i < arr.length - index; i++) {
		var temp = ''
		if (arr[i] > arr[i+1]) {
			temp = arr[i+1]
			arr[i+1] = arr[i]
			arr[i] = temp
            state = true
        }
    }
    if (!state) break
	++index
}复制代码

在这里,若是判断到后面没有发生交换,则能够判断后面的元素已经有序,则不须要再次遍历数组,提升了算法的性能。优化

2、插入排序(Insertion Sort)

插入排序的思想比上面的冒泡排序的思想要复杂一点。插入排序是将数组分红两个区间,一个是有序区间,一个是无序区间。通常的会将数组第一个元素默认为有序区间,而后将无序区间中的元素插入到有序区间相应的位置中,直到无序区间为0为止,时间复杂度是O(n)=n^{2}。代码以下:ui

for (var i = 1; i < n; ++i) {
  var value = a[i];
  // 查找插入的位置
  for (var j = i - 1; j >= 0; --j) {
    if (a[j] > value) {
      a[j+1] = a[j];  // 数据移动
    } else {
      break;
    }
  }
  a[j+1] = value; // 插入数据
}

复制代码

3、选择排序(Selection Sort)

选择排序和上面的插入排序相似,也是分有一个有序区间和无序区间,与插入排序不一样的是,选择排序里插入到有序区间的元素是无序区间里的最小值,时间复杂度是O(n)=n^{2}。代码以下:spa

var arr = [5,3,6,2,8,1,9,4,7,10,11,34,12]
for (var i = 0; i < arr.length - 1; i++) {
	var min = arr[i]
	var index = i
	for (var j = i; j < arr.length; j++) {
		if (min > arr[j]) {
			min = arr[j]
			index = j
		}
    }
	var temp = arr[i]
	arr[i] = min
	arr[index] = temp
}复制代码

4、排序算法的分析

在用算法的时候,不止要懂原理,也要懂如何根据它的性能来使用到不一样的场景中,下面就以三个点来讲一下算法性能的分析。

1.执行效率

咱们在分析算法的时间复杂度的时候,要分别的列出最好状况、最坏状况合评价状况的时间复杂度。为何要作区分呢,首先为了算法之间更好的对比性能,其次是有些极端状况,好比说在高度有序或者杂乱无章的状况下执行的时间会各有不一样,因此咱们要知道排序算法在不一样数据下的性能表现。

除此之外,以前所要忽略的系数、常数、低阶也要考虑进来。咱们知道,时间复杂度反应的是数据规模 n 很大的时候的一个增加趋势,因此它表示的时候会忽略系数、常数、低阶。可是实际的软件开发中,咱们排序的多是 10 个、100 个、1000 个这样规模很小的数据,因此,在对同一阶时间复杂度的排序算法性能对比的时候,咱们就要把系数、常数、低阶也考虑进来。

还有一点,在上面说提到的三个基于比较的排序算法,都涉及到比较和替换。因此,若是咱们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。

2.内存消耗

算法的内存消耗其实就是算法所额外占用空间的多少,能够经过空间复杂度来衡量。针对排序算法的空间复杂度,这里引入了一个新概念原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。咱们上面讲的三种排序算法,都是原地排序算法。

3.算法的稳定型

仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,咱们还有一个重要的度量指标,稳定性。这个概念是说,若是待排序的序列中存在值相等的元素,通过排序以后,相等元素之间原有的前后顺序不变。

好比一组数据5,2,3,2,6,1。经排序后可得1,2,2,3,5,6。这组数据里有两个 2。通过某种排序算法排序以后,若是两个 2 的先后顺序没有改变,那咱们就把这种排序算法叫做稳定的排序算法;若是先后顺序发生变化,那对应的排序算法就叫做不稳定的排序算法。

可能看着这个例子看不出什么起来,可是若是要排序的是对象元素,则很容易看出算法的稳定性。好比说,咱们如今要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另外一个是订单金额。若是咱们如今有 10 万条订单数据,咱们但愿按照金额从小到大对订单数据排序。对于金额相同的订单,咱们但愿按照下单时间从早到晚有序。对于这样一个排序需求,咱们怎么来作呢?借助稳定排序算法,这个问题能够很是简洁地解决。解决思路是这样的:咱们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成以后,咱们用稳定排序算法,按照订单金额从新排序。两遍排序以后,咱们获得的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为何呢?

稳定排序算法能够保持金额相同的两个对象,在排序以后的先后顺序不变。第一次排序以后,全部的订单按照下单时间从早到晚有序了。在第二次排序中,咱们用的是稳定的排序算法,因此通过第二次排序以后,相同金额的订单仍然保持下单时间从早到晚有序。若是是先排金额再排时间的话,排时间的时候可能某个很前的时间端有个很大的金额,这个时候大金额可能排到前面致使不能知足先金钱有序的前提。下面就以上面的三个算法来更加详细的说明。

5、三个排序算法之间的比较

首先以是不是原地排序算法为例。冒泡排序算法涉及到相邻的交换和比较操做,只须要常量级的临时空间,因此空间复杂度是O(1), 是原地排序算法。插入排序和冒泡排序同样也执行了交换和比较操做,因此也是原地排序,空间复杂度为1。同理,因为选择排序和插入排序相似,因此也是原地排序算法。

再来讲一下是不是稳定排序算法。在冒泡排序中,只有交换才能够改变两个元素的先后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,咱们不作交换,相同大小的数据在排序先后不会改变顺序,因此冒泡排序是稳定的排序算法。

在插入排序中,对于值相同的元素,咱们能够选择将后面出现的元素,插入到前面出现元素的后面,这样就能够保持原有的先后顺序不变,因此插入排序是稳定的排序算法。

那选择排序是稳定排序算法吗?答案是否认的,选择排序是一种不稳定的排序算法。选择排序的定义里,每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。

6、有序度和无序度

在进行时间复杂度的比较以前,咱们先来过一下有序度和无序度。咱们先以冒泡排序为例。若是一组数据已经排好序了,那么在用冒泡排序进行排序的时候,只需遍历一层循环则能够了得出结果,因此最好时间复杂度是O(n)。可是若是数据是彻底倒序,则要进行n次冒泡操做,则最坏条件下时间复杂度是O(n^{2})。这个时候求平均时间复杂度的时候就用到标题上写的有序度和无序度了。

有序度是数组中具备有序关系的元素对的个数。有序元素对用数学表达式表示就是这样:

有序元素对:a[i] <= a[j], 若是 i < j。
复制代码

好比3,4,6,5,2,1这个数据里,有序度为5,有序元素的对数分别是(3,4), (3,6), (3,5), (4,6), (4,5)。对于有序度是n*(n-1)/2,咱们能够把它称做为满有序度,而逆序度的计算公式则是满有序度减有序度。

咱们从上面知道,冒泡排序包含两个操做原子,比较和交换。每交换一次,有序度就加 1。无论算法怎么改进,交换次数老是肯定的,即为逆序度,也就是n*(n-1)/2–初始有序度。如上面的3,4,6,5,2,1这个例子,则要进行10次交换操做。

对于包含 n 个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏状况下,初始状态的有序度是 0,因此要进行 n*(n-1)/2 次交换。最好状况下,初始状态的有序度是 n*(n-1)/2,就不须要进行交换。咱们能够取个中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均状况。换句话说,平均状况下,须要 n*(n-1)/4 次交换操做,比较操做确定要比交换操做多,而复杂度的上限是 O(n^{2}),因此平均状况下的时间复杂度就是O(n^{2})

在插入排序中,若是要排序的数据已是有序的,咱们并不须要搬移任何数据。若是咱们从尾到头在有序数据组里面查找插入位置,每次只须要比较一个数据就能肯定插入的位置。因此这种状况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。若是数组是倒序的,每次插入都至关于在数组的第一个位置插入新的数据,因此须要移动大量的数据,因此最坏状况时间复杂度为 O(n^{2})。在前面的文章中咱们得知,在数组中插入一个数据的平均时间复杂度是多少吗?没错,是 O(n)。因此,对于插入排序来讲,每次插入操做都至关于在数组中插入一个数据,循环执行 n 次插入操做,因此平均时间复杂度为 O(n^{2})

同理,咱们能够经过上面的两个算法能够得出选择排序的最快时间复杂度是O(n),最慢时间复杂度是O(n^{2}),平均时间复杂度是O(n^{2})

在这里咱们能够得出一个结论,因为选择排序不是一个稳定性排序算法,即便和冒泡和插入排序同样是原地排序算法和时间复杂度同样,但因为这个缺点,因此就选择插入排序或冒泡排序。而在冒泡排序和插入排序中对比,咱们能够发现冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序须要 3 个赋值操做,而插入排序只须要 1 个。插入排序比冒泡排序少了两个步骤使得它的性能比冒泡排序要好一点。可能在小数据下看不出来,可是涉及到大数据的状况下这点细微的差异就会被放大出来了。


上一篇文章: 数据结构与算法的重温之旅(八)——递归

下一篇文章:数据结构与算法的重温之旅(十)——归并排序和快速排序​​​​​​​

相关文章
相关标签/搜索