通过自己学习有关排序的知识后,为了加深知识理解,也为能更好的和大家分享交流,于是写了这样的一篇文章。很多内容及代码都是根据自己从网上看过的资料然后加以自己的理解写出来,因为第一次创作,不对的地方还希望大家多加指教。
1、排序算法的介绍
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。排序算法,就是如何使得记录按照要求排列的方法。
2、排序算法的分类
- 比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。
- 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。
3、算法讲解
这次将会讲解比较类排序,以后有机会再讲解非比较类排序。同时我将从各个排序算法的实现原理,代码实现,和时间、空间复杂度以及稳定性来分析(由于代码实现的语言版本过多,一一实现起来比较繁琐,这里选择使用JavaScript来完成)
相关概念的介绍:
- 稳定性:如果a原本在b前面,而a=b,经过排序之后a,b仍保持排序之前的相对位置,即a仍然在b的前面。
- 时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
- 空间复杂度: 是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。
冒泡排序
(1)算法介绍
冒泡排序是一种简单的排序算法。在运行过程中较小的元素经过逐渐变换会慢慢“漂浮”到数列的顶端,类似于水中气泡的原理,因此取名冒泡排序。
(2)算法描述
-
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
-
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
-
- 针对所有的元素重复以上的步骤,除了最后一个;
-
- 重复以上操纵,直到排序完成。
(3)动画演示
(4)代码演示
function bubbleSort(arr) {
var len = arr.length;
for(vari = 0; i < len - 1; i++) {
for(varj = 0; j < len - 1 - i; j++) {
// 相邻元素两两对比
if(arr[j] > arr[j+1]) {
// 元素交换
vartemp = arr[j+1];
arr[j+1] = arr[j];
arr[j] = temp;
}
}
}
return arr;
}
(5)复杂度计算
- 时间复杂度:当数组本身是顺序的时候,外层循环遍历一次就可完成,此时为冒泡排序的最好情况,时间复杂度为
O(n),同理,当数组为逆序的时候是最差的情况,内外层同时进行遍历,假设数组长度为n,一共要进行(n-1)次循环,每一次循环都要进行当前n-1次比较,所以一共的比较次数是:(n-1) + (n-2) + (n-3) + … + 1 = n * (n-1) / 2,计算得出时间复杂度是O(n²)。(一般我们取最坏情况下时间复杂度作为算法的时间复杂度) - 空间复杂度:分析过程我们可以得出,冒泡排序只额外开辟了一个交换空间,所以它的空间复杂度为
O(1) - 稳定性:当元素相等时,我们可以不进行交换操纵,从而保留两个元素的相对位置。因此,冒泡排序是可以实现稳定的。
选择排序
(1)算法介绍
选择排序是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
(2)算法描述
- 初始状态:无序区为R[1..n],有序区为空;
- 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1..i-1]和R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1..i]和R[i+1..n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
- n-1趟结束,数组有序化了。
(3)动画演示
(4)代码演示
function selectSort(arr) {
var len = arr.length;
var min, temp;
for(var i = 0; i < len - 1; i++) {
min = i;
for(var j = i + 1; j < len; j++) {
// 寻找最小的数
if(arr[j] < arr[min]) {
// 将最小数的索引保存
minIndex = j;
}
}
temp = arr[i];
arr[i] = arr[min];
arr[min] = temp;
}
return arr;
}
(5)复杂度计算
- 时间复杂度:相比较冒泡排序我们可以发现,选择排序并没有采取值交换措施,而只更新了最小值的下标,每轮循环值最多也只做了一次值交换。时间上得到了很大的提升。但是它也使用双层循环,所以时间复杂度还是
O(n²)。 - 空间复杂度:通过分析很容易得出选择排序额外的辅助空间只有一个辅助变量,所以空间复杂度为
O(1) - 稳定性:假设存在数组[2,3,4,3*],进行选择排序后的结果为[2,3*,3,4]。很明显排序过程中是不能保证相等元素的相对位置的。所以选择排序为不稳定的算法。
插入排序
(1)算法介绍
插入排序是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
(2)算法描述
- 从第一个元素开始,该元素可以认为已经被排序。
- 取出下一个元素,在已经排序的元素序列中从后向前扫描,
- 如果该元素大于新元素,将该元素移到下一位置
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;重复步骤2~5
(3)动画演示
(4)代码演示
function insertionSort(arr) {
varlen = arr.length;
var preIndex, current;
//假设arr[0]是有序的
for(var i = 1; i < len; i++) {
preIndex = i - 1;
current = arr[i];
//当cur值小于有序数组最后一位的时候才进行插入操纵
while(preIndex >= 0 && arr[preIndex] > current) {
//已扫描过的元素向后挪位
arr[preIndex + 1] = arr[preIndex];
preIndex--;
}
arr[preIndex + 1] = current;
}
return arr;
}
(5)复杂度计算
- 时间复杂度:数组有序的情况下为最好的情况,时间复杂度为
O(n),而最坏的情况下,外层循环n-1次,内层循环1+2+3+…+(n-2)=(n-2)(n-1) / 2次,所以最坏情况时间复杂度是O(n²) - 空间复杂度:插入排序通过反复把已排序元素逐步向后挪位,为最新元素提供插入空间。因为辅助空间只有一个辅助变量,所以空间复杂度为
O(1) - 稳定性:通过分析过程,我们得出在进行相等元素插入操纵的时候,可以默认插入到相当元素的后面,以此保证元素的相对位置。所以插入算法是可以达到稳定的算法
希尔排序
(1)算法介绍
希尔排序是插入排序的一种算法,是对直接插入排序的一个优化,也称缩小增量排序。
(2)算法描述
- 将待排序的数组元素按下标的一定增量分组,分成多个子序列。
- 然后对各个子序列进行直接插入排序算法排序。
- 然后依次缩减增量再进行排序,直到增量为1时,进行最后一次直接插入排序,排序结束。
(3)动画演示
(4)代码演示
function shellSort(arr){
// 10个需要数据进行三轮交换排序
// 第一轮排序将10个数据分成了5组
for(let i=5;i<arr.length;i++){
// 遍历各组中所有的元素,共有5组,每组2个元素,步长为5
for(let j = i-5;j >= 0;j -= 5){
//如果当前元素大于加上步长后的那个元素,则交换(从小到大排序)
if(arr[j] > arr[j+5]){
let tmp = arr[j];
arr[j] = arr[j+5];
arr[j+5] = tmp;
}
}
}
// 第二轮排序将10个数据分成了5/2 = 2组
for(let i=2;i<arr.length;i++){
for(let j = i-2;j >= 0;j -= 2){
//如果当前元素大于加上步长后的那个元素,则交换(从小到大排序)
if(arr[j] > arr[j+2]){
let tmp = arr[j];
arr[j] = arr[j+2];
arr[j+2] = tmp;
}
}
}
// 最后一轮排序将10个数据分成了2/2 = 1组
for(let i=1;i<arr.length;i++){
for(let j = i-1;j >= 0;j -= 1){
//如果当前元素大于加上步长后的那个元素,则交换(从小到大排序)
if(arr[j] > arr[j+1]){
let tmp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tmp;
}
}
}
return arr;
}
(5)复杂度计算
- 时间复杂度:希尔排序的时间复杂度其实是和增列序列有关系的,即我们程序实现的步长,{5,2,1}这种序列就是我们程序中实现的这种,并不是很好的增量序列,使用这个增量序列的时间复杂度(最坏的情况)是
O(n^2)。另外Hibbard提出了另一个增量序列{1,3,7,...,2^k-1}(质数增量),这种序列的时间复杂度(最坏情形)为O(n^1.5),这个提高就厉害了,关键只是修改了一个增量;Sedgewick也提出过几种增量序列,其最坏情形运行时间为O(n^1.3),其中最好的一个序列是{1,5,19,41,109,...}。 - 空间复杂度:希尔排序是插入排序的改进版,它的本质还是插入排序,所以它的空间复杂度也是
O(1)。 - 稳定性:虽然插入排序可以实现稳定性,但是作为升级版的希尔算法却不是稳定的。因为在不同增量的比较大小过程中,相同元素是存在被改变相对位置的情况的。所以希尔算法不是稳定的算法。
归并排序
(1)算法介绍
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。
(2)算法描述
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
(3)动画演示
(4)代码演示
function mergeSort(arr) {
var len = arr.length;
if(len < 2) {
return arr;
}
//math.floor向下舍入到最接近的整数
var middle = Math.floor(len / 2),
left = arr.slice(0, middle),
right = arr.slice(middle);
//递归调用mergeSort,直到拆分的数组长度<2
return merge(mergeSort(left), mergeSort(right));
}
function merge(left, right) {
var result = [];
//拆分的左右数组的第一位进行比较,然后依次放入result数组
while(left.length>0 && right.length>0) {
if(left[0] <= right[0]) {
result.push(left.shift());
}else{
result.push(right.shift());
}
}
//当左右一边的数组长度为0时,依次将另外一组的元素添加到result数组。
while(left.length)
result.push(left.shift());
while(right.length)
result.push(right.shift());
return result;
}
(5)复杂度计算
-
- 时间复杂度:因为归并算法属于等子规模问题,所以我们这里使用Master定理来求解时间复杂度,具体求解过程可点击该第三方链接查看,最后计算得出归并算法的时间复杂度是
O(n*logn)
- 时间复杂度:因为归并算法属于等子规模问题,所以我们这里使用Master定理来求解时间复杂度,具体求解过程可点击该第三方链接查看,最后计算得出归并算法的时间复杂度是
-
- 空间复杂度:归并算法所需要的额外空间就是临时数组和递归时压入栈的数据的占用空间n+logn,所以空间复杂度为
O(n)
- 空间复杂度:归并算法所需要的额外空间就是临时数组和递归时压入栈的数据的占用空间n+logn,所以空间复杂度为
-
- 稳定性:在短的有序序列合并的过程中,我们可以保证如果两个当前元素相等时,我们把处在前面的元素保存在结果序列的前面,这样就保证了相等元素的相对位置没有改变。所以归并算法是可以达到稳定的。
快速排序
(1)算法介绍
快速排序通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。
(2)算法描述
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序。
(3)动画演示
(4)代码演示
function quicksort(arr, left, right){
//这里"准基"取数组下标中间值的值
swap(arr,Math.floor(left+(right-left)/2), right)
//partition分区操纵后返回一个长度为2的数组
let p = [0,1] = partition(arr,left,right)
quicksort(arr, left, p[0]-1)
quicksort(arr, p[1]+1, right)
}
function swap(arr, i, j){
const a = arr[i]; arr[i] = arr[j]; arr[j] = a;
}
function partition(arr,left,right){
let Less = left-1
let more = right
while(left<more){
if(arr[left] < arr[more])
swap(arr, ++Less, left++)
else if(arr[left] > arr[more])
swap(arr, left, --more)
else left++
}
swap(arr, more, right)
return [Less++, more]
}
(5)复杂度计算
- 时间复杂度:快速排序的时间复杂度也可以利用Master定理进行计算,计算过程较为繁琐,可点击第三方链接进行查看。我们得出最好的情况下每一次基准值都刚好平分整个数组,则时间复杂度为
O(nlogn),而最坏的情况下每一次基准值都是数组中的最大/最小值,则时间复杂度为O(n²) - 空间复杂度:快速排序是递归的,需要借助栈来保存每一层递归的调用信息,所以空间复杂度和递归树的深度是一致的,最好的情况下每一次基准值都刚好平分整个数组,空间复杂度为
O(logn),而最坏的情况下每一次基准值都是数组中的最大/最小值,空间复杂度为O(n) - 稳定性:快速排序递归过程中是可能出现交换相同元素位置的情况的,所以它不是稳定的算法
堆排序
(1)算法介绍
堆排序是利用堆进行排序的方法。其基本思想为:将待排序列构造成一个大顶堆(或小顶堆),整个序列的最大值(或最小值)就是堆顶的根结点,将根节点的值和堆数组的末尾元素交换,此时末尾元素就是最大值(或最小值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次大值(或次小值),如此反复执行,最终得到一个有序序列。
(2)算法描述
- 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
- 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。
(3)动画演示
(4)代码演示
function heapSort( arr) {
if (arr == null || arr.length == 0) {
return;
}
var len = arr.length;
// 构建大顶堆,这里其实就是把待排序序列,变成一个大顶堆结构的数组
buildMaxHeap(arr, len);
// 交换堆顶和当前末尾的节点,重置大顶堆
for (var i = len - 1; i > 0; i--) {
swap(arr, 0, i);
len--;
heapify(arr, 0, len);
}
}
function buildMaxHeap(arr,len) {
// 从最后一个非叶节点开始向前遍历,调整节点性质,使之成为大顶堆
for (var i = Math.floor(len / 2) - 1; i >= 0; i--) {
heapify(arr, i, len);
}
}
function heapify(arr,i,len) {
// 先根据堆性质,找出它左右节点的索引
var left = 2 * i + 1;
var right = 2 * i + 2;
// 默认当前节点(父节点)是最大值。
var largestIndex = i;
if (left < len && arr[left] > arr[largestIndex]) {
// 如果有左节点,并且左节点的值更大,更新最大值的索引
largestIndex = left;
}
if (right < len && arr[right] > arr[largestIndex]) {
// 如果有右节点,并且右节点的值更大,更新最大值的索引
largestIndex = right;
}
if (largestIndex != i) {
// 如果最大值不是当前非叶子节点的值,那么就把当前节点和最大值的子节点值互换
swap(arr, i, largestIndex);
// 因为互换之后,子节点的值变了,如果该子节点也有自己的子节点,仍需要再次调整。
heapify(arr, largestIndex, len);
}
}
function swap (arr,i,j) {
var temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
(5)复杂度计算
- 时间复杂度:堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。其中构建初始堆时间复杂度为
O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)…1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级。 - 空间复杂度:堆排序具体的细节实现有两种方式:一种方式是将堆顶元素删除后,放到一个辅助数组中,然后进行堆调整使之成为一个新堆。接下来,继续删除堆顶元素,直至将堆中所有的元素都出堆,此时排序完成。这种方式需要一个额外的辅助空间
O(N),另一种方式是:将每次删除的堆顶元素放到数组的末尾。因为,对于堆的基本操作而言是将堆中的最后一个元素替换堆顶元素,然后向下进行堆调整。因此,可以利用这个特点将每次删除的堆顶元素保存在数组末尾,当所有的元素都出堆后,数组就排好序了。这种方式不需要额外的辅助空间,空间复杂度为O(1) - 稳定性:这里我们假设有一个序列[77,35,77*],排序前,77在77*的前面,构造成大顶锥之后,77是该大顶锥的根节点,所以在第一次排序中它被交换到序列的最后一个位置上。相对位置发生了改变,因此堆排序是不稳定的
算法总结
上面的表格清晰的反映了不同算法之间的差异,存在即合理。它们不同的特点也造就了不同的使用场景,只有清晰的了解了每一种算法的特点,才能在合适的时候最合适的使用~
算法延伸
在计算机发展初期,计算机的存储容量很小。所以那时很注意空间的复杂性。 但是,随着计算机行业的迅速发展,计算机的存储容量已经达到了很高的水平。所以现在不需要特别关注算法的空间复杂性。现在的内存也不像以前那么贵,于是就有了不少空间复杂度换时间复杂度的做法。(我们在前几种算法讲解中提到的申请额外辅助空间,其实也是起到了空间换时间的作用,而最经典的几例空间换时间就是非比较类排序中的桶排序和计数排序~~~~~~~)