算法学习之经典排序算法(一)

245 阅读6分钟

导语

近来一段时间,笔者利用工作之余在LeetCode刷题的过程中发现自己的数据结构与算法基础并不牢固,决定先暂缓刷题,转而将闲暇时间用于数据结构与算法的巩固与学习,在此开贴进行记录。

本章是笔者针对经典排序算法进行的复习整理第一篇,其中所有排序的实现方法都附有文字描述,实现代码,同时,工程文件也上传到笔者的Github,目前算法均通过C语言进行实现,以后若有时间,可能会更新JAVA版本。

以下是笔者Github项目地址,若有需要可以自取。
C语言版本

注1:未经允许,禁止转载!
注2:本文中的所有代码,均以升序排序为例,如需要降序排序代码,相信读者能自行进行修改。
注3:如果文中出现错误/异议,欢迎进行指正/讨论,谢谢。

排序算法概念及名词介绍

在开始之前,首先要明确各类排序的区分以及复杂度的相关概念。

0、什么是排序。

所谓排序,顾名思义就是将一组乱序的数字、物品或是其他东西,按照大小、高度等特征进行有序的排列。同样的,计算机中的排序不过是将要排序的对象换为了一条条数据,将排序的手段换为了特定的算法。

1、复杂度的概念

复杂度是用来粗略描述算法执行所需要的时间、空间资源与数据规模的增长关系。

其数学定义为一个以输入数据量n相关的函数,表现为数学公式形如:O(n),O(n2)O(n),O(n^2)等。

  • 算法的复杂度与其中涉及的常量系数无关,如O(2n),O(3n)O(2n),O(3n)等复杂度都划为O(n)O(n)

  • 若算法的复杂度为多项式相加时,应直接取更大的项作为复杂度,如O(n2)+O(n)O(n^2) + O(n),由于n2n^2的变化率远大于n,所以直接取O(n2)O(n^2)作为复杂度

  • 若某一维度上的复杂度与输入数据量n无关,则记为O(1)O(1)

2、排序的分类

  • 比较排序:通过序列中元素间的相互比较来确定其相对位置,由于其排序所需的时间复杂度无法突破O(nlogn),所以也被称为非线性时间比较类排序

  • 非比较排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序

  • 稳定排序:指经过排序后,相等元素的相对位置不发生变化

  • 不稳定排序:指经过排序后,相等元素的相对位置发生变化

简单排序算法

本节将介绍经典排序算法中的三种简单排序算法,包括:冒泡排序,选择排序,插入排序。

1、冒泡排序(Bubbul Sort)

  • 算法描述:冒泡排序是经典排序算法中较为简单的一种,其排序思路也容易理解。冒泡排序会循环遍历整个序列,在每次遍历中都会对相邻元素进行比较,判断其顺序是否符合目标顺序,如不符合则进行位置交换,在每一次这样的遍历过程中,都会使最大(小)元素慢慢“上浮”到序列最顶端。不断重复这个操作直到遍历完整个序列,则完成对整个序列的排序,使整个序列有序。

  • 代码实现:

    //冒泡排序
    void myBubbulSort(int * arr,int length){
        int tmp;
        for(int i = 0; i < length;i++){
            for(int j = 0; j < length - 1; j++){
                //若不符合目标顺序,则交换位置
                if(arr[j+1] < arr[j]){
                    tmp = arr[j];
                    arr[j] = arr[j+1];
                    arr[j+1] = tmp;
                }
            }
        }
    }
    
  • 算法优化:根据冒泡排序的特点,如果某次遍历没有交换任何元素,那么该序列一定已经有序,且在每完成一次遍历后,序列中最大的元素都会移动到序列尾部。以此分析上述代码,在经过i次遍历后,数列尾部的i~length所在项必定有序,所以在后续遍历中可以不用遍历这一部分,由此可以将代码优化为以下代码:

//优化后的冒泡排序
void myOptimizedBubulSort(int * arr,int length){
    int tmp;
    bool isSwapped;
    for(int i = 0; i < length;i++){
        isSwapped = false;
        for(int j = 0; j < length - i - 1; j++){
            //若不符合目标顺序,则交换位置
            if(arr[j+1] < arr[j]){
                tmp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = tmp;
                isSwapped = true;
            }
        }
        //若一次交换都没有,则序列有序
        if(!isSwapped) break;
    }
}
  • 时间复杂度:任意情况下,未经优化的冒泡排序的时间复杂度都处于O(n)O(n)级别,所以这里重点讨论优化后的算法。在最好情况下,序列有序,此时仅需进行一次遍历,n1n-1次比较,此时其最好时间复杂度为O(n)O(n);在最坏情况下,序列逆序,此时每进行一次循环,都会进行nin-i次比较与等数量级的交换操作,计算器操作次数n(n1)/2=>O(n2)n*(n-1)/2=>O(n^2),最坏时间复杂度为O(n2)O(n^2),冒泡排序的平均时间复杂度为O(n2)O(n^2)

  • 空间复杂度:O(1)O(1)

  • 小结:即使是经过优化后的冒泡排序,其时间复杂度仍然保持在O(n2)O(n^2)的级别,当数据量较小时,该算法的时间消耗可以接受,但在排序数据量很大时,冒泡排序的比较、交换次数会呈指数级的增加,显然对大数据量的序列进行排序时,冒泡排序的时间消耗是不可接受的。

2、选择排序

  • 算法描述:选择排序简单直观,理解起来十分容易,其运行时首先会找出无序区间中的最小项并将其与无序区间的第一个元素进行交换,并重新将剩余的元素划分为新的无序区间,并不断重复此操作。在遍历完成整个序列后,就完成了对序列的排序。

  • 代码实现:

//选择排序
void mySelectionSort(int *arr, int length){
    int min, tmp;
    for(int i = 0; i < length - 1; i++){
        //选择最小项
        min = i;
        for(int j = i + 1; j < length; j++){
            if(min > arr[j]){
                min = arr[j];
            }
        }
        //将最小项与无序区间的第一个元素交换位置
        tmp = arr[i];
        arr[i] = arr[min];
        arr[min] = tmp;
    }
}
  • 时间复杂度:分析上述代码,任意情况下,都会进行n(n1)n*(n-1)次遍历,但其交换次数最多为n1n-1次,其时间复杂度为O(n^2)

  • 空间复杂度:O(1)O(1)

  • 小结:与之前介绍的冒泡排序相比,由于选择排序仅需进行O(n)O(n)次交换,所以其在整体执行效率上会稍优于冒泡排序,但在遍历整个序列寻找最小项时,需要进行O(n2)O(n^2)次比较,所以其整体时间复杂度仍处于O(n2)O(n^2)级别,显然在需要对大数据量的序列进行排序时,选择排序也并不是一个好的选择。

3、插入排序

  • 场景类比:试想一个场景,我们在现实中打扑克,每拿到一张新手牌后,我们都在有序的手牌中找到其合适的位置,并将其插入,重复该操作,最终保证手牌有序。

  • 算法描述:插入排序的思路与上述的场景之十分相似:在算法开始执行前,将序列的第一个元素作为有序区间,剩余部分作为无序区间,之后的每一次遍历,都选取无序区间的第一个元素作为待插入元素,并将其与有序区间中的元素进行比较,以找到待插入元素的合适位置并将其插入。重复上述操作,完整遍历整个序列后,即完成排序。

  • 代码实现:

void myInsertionSort(int* arr, int length){
    for(int i = 1, j, tmp; i < length; i++){
        j = i;
        tmp = arr[j];
        //寻找插入位置,若未找到,则将元素后移
        while(j - 1 >= 0 && tmp < arr [j - 1]){
            arr[j--] = arr[j - 1];
        }
        //找到插入位置,则完成插入
        arr[j] = tmp;
    }
}
  • 算法优化:分析上述代码,可见在每次插入元素时都会在有序区间中进行依序比较的方式来查找插入位置,显然可以将该操作使用二分查找法进行优化,可显著提高这一操作的效率,其优化代码如下:
void myInsertionSortWithBinarySearch(int* arr, int length) {
    int left, right, mid, tmp;
    for (int i = 1, j; i < length; i++) {
        j = i;
	left = 0;
	right = i - 1;//0~(i-1)为有序区间
	tmp = arr[j];
	//使用二分法查找插入位置
	while (left <= right) {
            mid = left + (right - left) / 2;
            //插入位置在mid之前(tmp小于arr[mid])
            if (tmp < arr[mid]) right = mid - 1;
            //插入位置在mid之后(tmp大等于arr[mid])
            else left = mid + 1;
        }
	//将插入位置其后元素后移
	while (j - 1 >= left) arr[j--] = arr[j - 1];
	//进行插入
	arr[left] = tmp;
    }
}
  • 时间复杂度:在最好情况下,每次遍历都只需要将待插入元素与前一个元素相比较一次,总共需要n1n-1次遍历,其最好时间复杂度为O(n)O(n),而最坏情况下,每一次遍历都需要有序区间的所有元素比较,总共需要比较n(n1)/2n*(n-1)/2次,并将对无需区间的元素进行同等数量级的移动,其最坏时间复杂度为O(n2)O(n^2),其平均时间复杂度为O(n2)O(n^2), 使用二分查找对插入排序进行优化,能够将查找插入位置的时间复杂度提高至O(nlogn)O(nlogn),但插入排序的整体平均时间复杂度仍处于O(n2)O(n^2)级别

  • 空间复杂度:O(1)

  • 小结:与前两种排序算法相比,插入排序在待排序序列高度有序的情况下,排序效率很高,时间复杂度达到最为理想的O(n)O(n),但在输入数据随机或为逆序的情况下,其时间复杂度会迅速退化至O(n2)O(n^2)水平。即便如此,在三种简单排序中,插入排序的平均效率也是最高的。在对其使用二分查找法进行优化后,其效率还会有一定的提升。且在后续文章会讲到的某些高级排序算法中,插入排序一般会被用于处理排序后期已经高度有序的序列用于针对高级排序算法进行优化。

结语

本文介绍了经典排序中的三种简单排序算法,冒泡排序,选择排序和插入排序。在这三者中,冒泡排序号是平均耗时最多的,选择排序次之,插入排序最快,但这三种算法的平均时间复杂度都达到了O(n2)O(n^2)级别,这使得一旦需要排序的数据量增多,消耗时间会出现指数爆炸,运行效率大大降低。所以这三种算法仅作为学习使用,在实际开发中,这三者的效率往往使不能接受的。

在下一章节,笔者将介绍高级排序中的希尔排序,归并排序以及快速排序,尽情期待...