从此不再无序:八大排序算法总结(附JavaC源码)

Posted 飞人01_01

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从此不再无序:八大排序算法总结(附JavaC源码)相关的知识,希望对你有一定的参考价值。

前言

大家好!今天小编整理一下面试官常考的一大热点题型:“排序”。下面的文章将重点的几大排序做了解析,我们从冒泡、选择、插入、归并、快速、堆、计数和基数这八大经典的排序算法讲起,比如:希尔排序,在插入排序的基础上做了优化,本文就不在讲解,博客网站上有很多文章!!!

大部分公司都会注重查找和排序算法。应聘者可以在了解各种查找和排序算法的基础上,重点掌握二分查找、归并排序和快速排序。 还要对各种排序算法的时间、空间复杂度烂熟于心,了解它的优缺点。

我参考的文章有:十大经典排序算法总结(Java实现+动画)十大经典排序算法动画 和《大话数据结构》

Java语言源码:GitHub

C语言源码:Github

开始之前,先简单认识一下排序和相关术语的概览吧。

排序:假设含有n个记录(数据元素)的序列为{R1,R2,……,Rn},其相应的关键字分别为{k1,k2,……,kn},需确定1,2,……,n的一种排列p1,p2,……,pn,使其相应的关键字满足Kp1 <= Kp2 <= …… <=

Kpn 非递减(非递增)关系,即使序列成为一个按关键字有序的序列,这样的操作称为排序。

稳定性: 假设Ki = Kj(i和j都在区间[1,n],且i 不等于j),并且在排序前的序列中Ri 领先于Rj(即i<j)。 如果排序后Ri仍然领先于Rj,则称所用的排序方法是稳定的;反之,则称所用的排序方法是不稳定的。

内排序与外排序内排序是在排序整个过程中,待排序的所有数据全部被放置在内存中。外排序是由于排序的数据个数太多不能同时放置在内存中,整个排序过程需要在内外存之间多次交换数据才能进行。

这里我们先把swap交换函数给实现了,等会一下的代码就直接调用这个函数。

void swap(int arr[], int L, int R) 
{
	int tmp = arr[L];
    arr[L] = arr[R];
    arr[R] = tmp;
}

一、冒泡排序 (Bubble Sort)

冒泡排序:两两比较相邻的数据,如果反序则交换,直到没有反序的数据为止。

在实现冒泡排序的细节上,我们分为两种冒泡排序:初级版与改进版。

动图演示:

//冒泡排序初级版
void BubbleSort(int arr[], int arrLength) 
{
    int i = 0;
    for (i = 0; i < arrLength - 1; i++) //循环的躺数
    {
        int j = 0;
        for (j = 0; j < arrLength - 1 -i; j++) //一趟需要交换数据的对数
        {
            if(arr[j] > arr[j+1]) //升序
                swap(arr, j, j+1);
        }
    }
}

解析:第5行的for循环,控制了这组数据需要进行多少趟,上面动图中,第一次循环“48”在前面的位置,最后来到倒数第二的位置上,这样一次,我们称为一趟排序。假设这个arr数组共有10个元素,则整个循环完成,只需要循环9趟即可完成排序。(arrLength - 1)

​ 第8行的for循环,控制了这一趟中,有多少对数据进行比较大小,例如:arr数组共有10个元素,第一趟我们只需要比较9对元素,就能让最大的元素来到数组的末尾;第二趟,此时最大的元素已经来到了数组的末尾,我们就不需要再对它进行判断大小了。所以,第二层循环是跟着第一层循环的改变而改变的。

第一趟: 比较9对元素

第二趟:比较8对元素

……

综上:第二层循环的停止条件为 (arrLength - 1 - i)。

初级版的冒泡我们就叙述忘了,大家是否能够发现一些问题???

例如: 待排序的元素为 {2,1,3,4,5,6,7,8,9,10},此时我需要对这个10个元素排为升序,我们观察发现,只需要2和1交换位置,整个数组就是升序了。但我们的初级版冒泡排序会怎么样?

当第一趟排序执行完后,数组的情况是{1,2,3,4,5,6,7,8,9,10}。此时程序会停下来吗???

当然不会,它还是会“傻傻”的一直在循环判断,此时我们的算法就不是那么的高效,所以我们在初级版冒泡排序的基础上,加上了一个bool值,flag。

//冒泡排序改进版
void BubbleSort(int arr[], int arrLength) 
{
    if (arr == NULL)
        return; //空指针,提前退出
    
    int i = 0;
    int flag = 0; //因为C语言没有bool类型,就以整形代替。 
    for (i = 0; i < arrLength - 1 && flag != 1; i++) //一次循环完后,flag还是1,则有序
    {
        int j = 0;
        flag = 1;
        for (j = 0; j < arrLength - 1 -i; j++)
        {
            if(arr[j] > arr[j+1]) //升序
            {
                swap(arr, j, j+1);
                flag = 0; //如果if语句进来后,则说明当前数组还是无序的
            }
        }
    }
}

二、选择排序 (Selection Sort)

选择排序:通过n-i次元素之间的比较,从n-i+1个元素中选出最小(最大)的元素,跟第i个元素交换。

简单来说:看图。

void selectSort(int arr[], int arrLength)
{
    if (arr == NULL)
        return; //空指针,提前退出
    
    int i,j,minIndex;
    for (i = 0; i < arrLength; i++)
    {
        minIndex = i; 
        for (j = i + 1; j < arrLength; j++)
        {
            if (arr[minIndex] < arr[j]) 
                minIndex = j; //保存最小值的下标
        }
        if (minIndex != i)
            swap(arr, minIndex, i); //如果minIndex不是i,说明有最小值
    }
}

三、插入排序 (Insert Sort)

插入排序:将一个数据插入到已经排好序的有序表中,从而得到一个新的、数据个数增1的有序表。

动图演示:

void insertSort(int arr[], int arrLength)
{
    if (arr == NULL)
        return; //空指针,提前退出
    
    int i = 0;
    int j = 0;
    for (i = 1; i < arrLength; i++)
    {
        int insertValue = arr[i];
        for (j = i - 1; j >= 0 && insertValue < arr[j]; j--) //insertValue < arr[j]
        {
            arr[j + 1] = arr[j]; //前一个数据往后移动
        }
       if (insertValue != arr[i]) //如果经过循环后,这两个不相等,说明循环里面移动过数据
          arr[++j] = insertValue; //这里值得注意的是,for循环停止时,j-- 已经自减了
    }
}

插入排序,就像我们过年时,几个小伙伴一起斗地主一样,每从桌上拿起一张牌,我们就会按照3 4 5 6 7……J Q K A,的顺序进行排列。插入的过程中,其余的牌就要整体移动,给插入的这张牌让一个位置。

四、归并排序 (Merger Sort)

归并排序:就是将一组数据进行二分拆开成左、右两个数组,分别使左右两个数组有序后,再合并到一起。

将大问题拆分为小问题,然而小问题也可以拆分为更小的问题,可以考虑递归函数。

先看动图:

//递归解法
#define MAXNUM 20  //数组最大元素个数
void mergerSort1(int arr[], int left, int right)
{
    if (arr == NULL || left == right)
        return;
    
    int mid = left + ((right - left) >> 1); //取中间数,也就是 (left + right)/2
    mergerSort(arr, left, mid); //递归调用左数组
    mergerSort(arr, mid+1, right); //递归调用右数组
    merger(arr, left, mid, right); //左右数组分别有序后,合并到一起
}

void merger(int arr[], int left, int mid, int right)
{
    int help[MAXNUM]= {0}; //用于临时存储两个数组合并时的有序数组
    int i = 0; //指向help数组
    int p1 = left; //指向左数组
    int p2 = mid + 1; //指向右数组
    while (p1 <= mid && p2 <= right)
        help[i++] = arr[p1] < arr[p2]? arr[p1++] : arr[p2++]; //谁更小,就放入help数组
    
    while (p1 <= mid)  //左边数组还有数据,就放入help
        help[i++] = arr[p1++];
    
    while (p2 <= right) //右边数组还有数据,就放入help
        help[i++] = arr[p2++];
    
    //将help数组的所有数据按照顺序放入原数组arr
    int j = 0;
    for (j = 0; j < i; j++)
        arr[left+j] = help[j]; //注意是从left位置处开始拷贝
}

将整个数组分成小块,将每个小块的数据变为有序后,再合并起来,这就是归并。

想要写降序,第20行的三目操作符修改一下就可以!

还有就是第7行的取中间数,为什么要这样写??两点原因

  1. 位运算的速度远快于普通的加减乘除!
  2. 考虑溢出的情况,例如int最大的32亿左右,如果此时我的left是19亿,right是19亿,此时二者相加就溢出了整形的范围。
//非递归解法
void mergerSort2(int arr[],int arrLength)
{
    int mergerSize = 1; //表示左数组的元素个数,最开始时,左边元素个数1个,右边也为1个
    while (mergerSize < arrLength)
    {
        int L = 0;
        while (L < arrLength) //一趟
        {
            int M = L + mergerSize - 1; //取中间值
            if(M >= arrLength)
                break; //如果中间值大于等于数组的长度,则提前退出
            
            //取右边数组的范围
            int R = (M + mergerSize) < (arrLength - 1)? M+mergerSize:arrLength-1;
       		merger(arr,L,M,R); //还是调用归并函数
            L = R + 1;
        }
        
        if(mergerSize > arrLength / 2)
            return; //防止整形溢出,提前判断一下
        
        mergerSize <<= 1; //乘2     mergerSize = mergerSize * 2;
    }
}

void merger(int arr[], int left, int mid, int right)
{
    int help[MAXNUM]= {0}; //用于临时存储两个数组合并时的有序数组
    int i = 0; //指向help数组
    int p1 = left; //指向左数组
    int p2 = mid + 1; //指向右数组
    while (p1 <= mid && p2 <= right)
        help[i++] = arr[p1] < arr[p2]? arr[p1++] : arr[p2++]; //谁更小,就放入help数组
    
    while (p1 <= mid)  //左边数组还有数据,就放入help
        help[i++] = arr[p1++];
    
    while (p2 <= right) //右边数组还有数据,就放入help
        help[i++] = arr[p2++];
    
    //将help数组的所有数据按照顺序放入原数组arr
    int j = 0;
    for (j = 0; j < i; j++)
        arr[left+j] = help[j]; //注意是从left位置处开始拷贝
}

非递归与递归二者的区别?

当我们递归调用到数据的最底层,最先移动的数据还是下标为0和下标为1的数据,这二者进行比较,此时,其他的参数在栈区里面保存着,当这两个数据操作完成之后,才返回函数,去进入下一个下标2、3的数据进行比较和排序。

反观非递归的解法,则直接定义最开始时,左右数组的元素个数(mergerSize),当下标为0、1的数据操作之后,L直接跳到2、3的位置,一趟排序之后,此时数组中每两个数据是有序的; 此时扩大mergerSize为2,即就是左右数组大小各为2,再次重复上面的操作。

五、快速排序 (Quick Sort)

在讲解快速排序问题之前,我们先了解一下“荷兰国旗问题”。

荷兰国旗问题

从上面的这个题目,我们可以提取出一些算法思想。

假设待排序的数组是{10, 30, 50, 40, 20, 60, 40},我们将数组的最后一个元素40作为“中心点”,将数组中的所有数据都跟“中心点”(40)做比较,比中心点小的,放到数组的前面,比中心点大的放到数组的后面,等于中心点的放到中间。 这样,我们将整个数组分为了三个区域:< 区、= 区、> 区

经过上面的步骤得到以下数组:

  • {10,30,20,40,60,50,40}; 此时蓝色区域就是 < 区,绿色区域就是 >区。
  • 此时将数组最后一个元素40绿色区域的第一个元素交换位置
  • 得到{10,30,20 ,40,40, 50,60};

现在看上去,整体从左到右就是一个升序,至于 <区 和 >区 再重复以上步骤就能使其有序。又是递归函数

画图理解一下其中的算法思想,这样才更容易下面的代码!!!

void quickSort(int arr[], int arrLength)
{
    if (arr == NULL || arrLength < 2)
        return;
    process(arr, 0, arrLength - 1);
}

void process(int arr[], int left, int right)
{
    if (left >= right)
        return;

    //随机数srand放入主函数
    swap(arr, left + rand() % (right - left + 1), right); //从数组中随机抽取一个元素与数组最后的元素交换位置
                                                        //这里也是快排时间复杂度变为O(N logN)的原因

    int LMid, RMid; //三个区中,等于区的第一个元素下标,和最后一个元素下标----也就是上面文字解释中的{40,40}的下标
    netherlandsFlag(arr, left, right, &LMid, &RMid); //就是上面解释的,将数组分为三个区
    process(arr, left, LMid - 1); //递归调用 <区的数据,上面文字解释中的 {10,30,20}
    process(arr, RMid + 1, right); //递归调用 >区的数据,上面文字解释中的 {50,60}
}

void netherlandsFlag(int arr[], int left, int right, int* LMid, int* RMid)
{
    if (left > right)
    {
        *LMid = *RMid = -1;
        return;
    }
    if (left == right)
    {
        *LMid = *RMid = left;
        return;
    }

    int minRange = left - 1; //<区范围
    int maxRange = right; //>区范围,将最后一个元素先放到>区范围,总共整体排序后,与>区的第一个元素交换
    int index = left; //循环判断的索引值

    while (index < maxRange) //索引值不跟>区范围遇到,循环继续
    {
        if (arr[index] < arr[right])
            swap(arr, index++, ++minRange); //放入<区,过后,索引值index++
        else if (arr[index] > arr[right])
            swap(arr, index, --maxRange); //放入>区,过后,索引值不变,因为从>区过来的值还不知道是大是小,所以还需要判断
        else
            index++; //相等的话,不交换,索引值index++即可
    }

    swap(arr, maxRange, right); //>区的第一个元素与数组的最后一个元素交换
    *LMid = minRange + 1; //等于区的第一个元素
    *RMid = maxRange; //等于区的最后一个元素
}

快速排序的时间复杂度能优化到O(N logN),最为关键的就是第14行的交换函数!!!

只有当“中心点”取到数组正中间时,此时的时间复杂度才是最好的,当取到数组的两边时,时间复杂度就很高了。所以加了随机数,使之“中心点”在数组中的出现是等概率的,具体的证明方式,就得看数学功底了!!!

六、堆排序 (Heap Sort)

将堆排序之前,我们先来了解了解“完全二叉树”是个什么!!!

如上图所示

左边是完全二叉树,判断的理由是: 整棵树从上到下,从左到右,没有缺失结点。

右边则不是完全二叉树:理由是: 整颗树从上到下,从左到右,在第三层第二个结点处断了,而第三个结点又还在,形成了一个“空位”,则不是完全二叉树。若将第三层的第三个结点移动到这一层第第二个结点处,则就会变为完全二叉树,如下图:

了解了完全二叉树。 我们就直接进入正题。以大根堆为例,小根堆就是改一下条件即可!

大根堆:顾名思义,就是将数值大的作为根,有以下性质:

  1. 左孩子结点 小于或等于 根结点。
  2. 右孩子结点 大于或等于 根结点。

将整个数组变为大根堆之后,此时根结点当前数组中最大的数值,我们就把他取出来,与数组的最后一个元素进行交换即可。

这里需要处理的问题就是:

  • 取出根结点的值后,如何让整颗树还是保持大根堆的形式??

带着这个问题,我们来看代码。

void heapSort(int arr[], int arrLength)
{
    if(arr == NULL || arrLength < 2)
        return;
    
    int i = 0;
    for (i = 0; i < arrLength; i++)
        heapInsert(arr, i); //大根堆的形式插入
    
    //经过上面的循环之后,形成了大根堆。此时就将根结点的数据与数组中最后一个元素进行交换
    //交换之后,根结点的数据并不是此时这颗树的最大值,所有将这个结点的数据往下层移动
    int heapSize = arrLength;
     swap(arr,0,--heapSize);
    while (heapSize > 0)
    {
        heapify(arr,0,heapSize); //判断左右孩子结点和根结点的关系,条件成立,就往下层移动
        swap(arr,0,--heapSize);
    }
}

void heapInsert(int arr[], int index)
{
    //数组是以下标为0处开始放入数据的
    //则根结点和左右孩子有以下关系
    //index 的根结点index/2, index的 左孩子为index*2+1,右孩子就是index*2+2
    //新插入的结点,插入到叶子结点处,然后去寻找父节点,判断二者之间的大小,比父节点大的话,就要往上移动
    while (arr[index] > arr[(index-1) / 2])
    {
        swap(arr,index,(index-1) / 2);
        index = (index-1) / 2;
    }
}

void heapify(int arr[], int index, int heapSize)
{
    int leftChild = index * 以上是关于从此不再无序:八大排序算法总结(附JavaC源码)的主要内容,如果未能解决你的问题,请参考以下文章

数据结构与算法--八大排序算法(内部排序)

数据结构与算法--八大排序算法(内部排序)

八大排序算法总结

糊涂算法之「八大排序」总结——用两万字,8张动图,450行代码跨过排序这道坎(建议收藏)

数据结构-八大排序算法的时间复杂度 稳定性

八大排序算法总结