八大排序详解:动图、代码、注释

目录

何为八大排序?

直接插入排序

排序过程解读

直接插入排序的特性总结:

希尔排序

希尔排序的特性总结:

直接选择排序

直接选择排序的特性总结:

堆排序

直接选择排序的特性总结:

冒泡排序

快速排序

1.Hoare版本

2.挖坑法

前后指针法

递归版本

非递归版本

快速排序的特性总结:

归并排序

递归版

非递归版

归并排序的特性总结:

计数排序

计数排序的特性总结:


何为八大排序?


八大排序算法通常指的是在计算机科学中广泛使用的八种内部排序算法,这些算法包括:

插入排序。这是一种简单的排序算法,通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

希尔排序。也称为缩小增量排序,这是一种基于插入排序的算法,通过比较相隔一定间隔的元素来工作,然后逐渐减少间隔,直至1。
简单选择排序。这种算法通过在未排序序列中找到最小(或最大)元素,将其放到已排序序列的末尾。
冒泡排序。这种算法重复地遍历要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。
快速排序。这是一种分而治之的策略,通过一个分割点将数组分成两部分,然后递归地对这两部分进行快速排序。
归并排序。这种算法采用分治法,将数组分成两部分,分别对这两部分进行归并排序,然后将结果合并。
计数排序。这是一种线性时间复杂度的排序算法,适用于一定范围内的整数排序,它通过统计不同值的数量来进行排序。

堆排序。利用建堆的思想去排序。

选择排序。先选择出合理的数据,再进行排序。

本文将通过排序算法的复杂度讲解、算法解析及稳定性讲解,带你深入了解八大排序。

解释何为稳定性

假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次
序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排 序算法是稳定的;否则称为不稳定的。
简而言之就是,排序之前与之后,相同值的元素相对位置不变。

直接插入排序

直接插入排序是一种简单的插入排序法,其基本思想是:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想

在摸牌排序时,我们总是把一个随机的牌,插入到已经有序的手牌中,但是已经有序的手牌是如何有序的呢?可以理解为,第一次摸牌时,只有一张手牌,可以认为一张就是有序

排序过程解读

当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与 array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移
在动图中可以看到步骤为
1.摸牌(tmp)
2.从已经有序数组的end位置开始往前遍历,后移
3.找到合适的位置之后,插入

//插入排序 :默认有序,插入无序
void InsertSort(int* a, int n)
{	for (int i = 0; i < n - 1; i++)		//默认第一个有序,一共 n - 1 趟{int end = i;		//end 是下标int tmp = a[i + 1];while (end >= 0){if (a[end] > tmp){a[end + 1] = a[end];end--;}elsebreak;}a[end + 1] = tmp;}}

    if (a[end] > tmp)
    {
        a[end + 1] = a[end];
        end--;
    }
    else
        break;

满足条件后移,因为已经是一个有序数组,到位置直接break

        a[end + 1] = tmp;然后再插入
 

直接插入排序的特性总结:

1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
需要注意的是,最然是N^2的时间复杂度,但是相对于冒泡排序,确是性能非常好的排序算法。

希尔排序

希尔排序(Shell's Sort)是插入排序的一种,又称“缩小增量排序”(Diminishing Increment Sort),是直接插入排序算法的一种更高效的改进版本。该方法因D.L.Shell于1959年提出而得名。

其核心要点是 预排序与直接插入排序的结合。假定一个增量gap,让数组分为gap组,先对每组进行排序(预排序),使原数组接近于有序,再对整体排序。
基本思想是:
先选定一个整数gap,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。

void ShellSort(int* a, int n)		//希尔排序(缩小增量排序)
{int gap = n;while (gap > 1) //最后一次循环结束,gap等于1{gap = gap / 3 + 1;for (int i = 0; i < n - gap; i++)			//类比直接插入排序{int end = i;int tmp = a[i + gap];while (end >= 0)		//挪动{if (a[end] > tmp){a[end + gap] = a[end];end -= gap;}elsebreak;}a[end + gap] = tmp;			//挪完,插入}}}

gap先被初始化为n,在第一次进行预排序时gap = gap / 3 + 1,此后每一次排序,gap都在不断缩小。

对于gap的取值,有对应的结论:当gap越大时,排序的时间短,但是越接近无需;gap越小时,排序时间长,但是越接近有序。

设置循环的控制条件时,i < n - gap是因为防止tmp = a[i + gap]发生越界。

a[end + gap] = a[end];        //此句体现后移

类比插入排序,便可以发现,只是把插入排序中的“1”换成了gap

需要注意的是,当gap = gap/ 3 + 1时,可以使最后的gap一定为1

希尔排序的特性总结:


1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的
希尔排序的时间复杂度都不固定

笔者也去查阅了许多资料,目前市面上也是众说纷纭,一般认为是O(N^1.3)

直接选择排序

基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。

排序过程:

在元素集合array[i]--array[n-1]中选择关键码最大(小)的数据元素若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换在剩余的array[i]--array[n-2](array[i+1]--array[n-1])集合中,重复上述步骤,直到集合剩余1个元素。

可以从图中看到,需要进行多趟遍历,不断选出最大最小值,进行排序。


void SelectSort(int* a, int n)
{int begin = 0;		//通常用下标记录数据,以便随机访问int end = n - 1;while (begin < end){int maxi = begin;		//每次都需要重建maxi 与 mini,需要封装在while内部	//不能初始化为 0 ,每次都要从begin处开始int mini = begin;for (int i = begin; i <= end; i++)		//再begin 和 end(包含end,是 <= )这个区间查找{if (a[i] > a[maxi])maxi = i;if (a[i] < a[mini])mini = i;}Swap(&a[begin], &a[mini]);if (begin == maxi)		// == 不是 =maxi = mini;Swap(&a[end], &a[maxi]);begin++;end--;}
}

        需要定义一段区间,区间从两侧开始慢慢有序,不断向中间逼近。

    for (int i = begin; i <= end; i++)        //再begin 和 end(包含end,是 <= )这个区间查找
    {
        if (a[i] > a[maxi])
            maxi = i;

        if (a[i] < a[mini])
            mini = i;
    }

此段代码完成maxi与mini的查找,找到之后,再完成头尾与最大、最小的位置交换。

为了防止出现头就是最大值的情况,需要加一句判断。

        if (begin == maxi)       
            maxi = mini;

循环结束之后,两端向中间逼近。
        begin++;
        end--;

由于这种算法既不稳定效率低下,因此极少使用。

直接选择排序的特性总结:


1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:不稳定

堆排序

堆排序是利用堆的特性进行排序,是堆的重要应用。其中,向下调整算法是建堆及排序的关键。

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是 通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
首先介绍什么是向下调整算法:
向下调整算法主要用于在删除堆的根节点或在根节点插入新值后,重新恢复堆的顺序性质。

向下调整算法的基本步骤如下:

  1. 确定调整的位置:当删除或替换堆顶元素后,需要调整的节点通常是新的根节点。

  2. 比较与子节点的值:在最小堆中,比较该节点与其子节点(如果存在)的值,确定哪个子节点的值最小;在最大堆中,则确定哪个子节点的值最大。

  3. 交换:如果父节点的值大于(在最小堆中)或小于(在最大堆中)子节点的值,则与该子节点交换位置。

  4. 重复:将交换后的子节点作为当前节点,重复步骤2和3,直到当前节点没有子节点,或者它的值已经符合堆的性质。


void AdjustDown(int* a, int n, int parent)			//升序建大堆,找大孩子
{int child = parent * 2 + 1;			//假设左孩子大while (child < n)		//child是下标,最后一次是 n - 1,可以参与调整{if (child + 1 < n&& a[child + 1] > a[child])child = child + 1;if (a[child] > a[parent]){Swap(&a[child], &a[parent]);parent = child;		//交换之后,不断迭代child = parent * 2 + 1;}else	//前提是,其余元素已经是个堆结构break;}}

在代码中,假设左孩子大,当右孩子存在时,若大于左孩子,则更新孩子的下标。


        if (a[child] > a[parent])
        {
            Swap(&a[child], &a[parent]);

            parent = child;        //交换之后,不断迭代
            child = parent * 2 + 1;
        }

如果父子逆序,只需要交换父子。需要注意的是:完成一次交换之后,需要更新父子的下标,完成迭代。

else    //前提是,其余元素已经是个堆结构
    break;

若不需要交换,可以直接跳出堆结构。

简而言之,向下调整算法就是:先找孩子,逆序交换,顺序跳出。

介绍完调整算法,便可以介绍排序算法。

进行堆排序时,需要两个步骤:1.向下调整算法建堆 2.向下调整算法排序。

1.建堆过程

  观察向下调整算法,其参数是void AdjustDown(int* a, int n, int parent)。我们需要传入①数组②数组大小③父节点的下标

在使用向下调整算法时,需要保证其余的结构已经满足大堆或者小堆的结构,因此,建堆可以进行倒着建堆。

观察这个小堆结构,在建堆时,可以认为第三层的数据本身就是堆结构,因此只需要传第三层的父节点,不断向下调整建堆,父节点坐标--,遍历完所有父节点,便可完成建堆。

    for (int i = (n - 1 - 1) / 2; i >= 0; i--)        //i从倒数第一个父亲开始,不断--,不断建好堆的每一部分{AdjustDown(a, n, i);}

这段代码便是建堆的过程。

(n - 1 - 1) / 2 这种书写方式是因为n  - 1是最后一个孩子节点的下标,( (n - 1) - 1) / 2是

(孩子 - 1)/ 2才是父节点的下标。i--可以让父节点完成遍历。

循环的内容是向下调整算法,其中参数n控制向下调整算法的结束。(此处需要了解传参时个参数的作用)

2.排序过程

向下调整算法排序: 不断筛选最大值,放到数组末尾。

利用堆的特性,堆的根一定是最大值或最小值,取根,与数组的末尾的数据交换顺序,交换完之后,再去对新数组(忽略交换的最后的数据)调整建堆。

int end = n - 1;while (end > 0)
{Swap(&a[end], &a[0]);AdjustDown(a, end, 0);		//从根开始向下调整, 新数组大小是endend--;	
}

让end是最后数据的下标,end不断--,完成数组的排序。

因此,完整的堆排序为


void HeapSort(int* a, int n)
{//1.向下调整算法建堆 :传父亲下标for (int i = (n - 1 - 1) / 2; i >= 0; i--)		//i从倒数第一个父亲开始,不断--,不断建好堆的每一部分{AdjustDown(a, n, i);}//2.向下调整算法排序: 不断筛选最大值,放到数组末尾int end = n - 1;while (end > 0){Swap(&a[end], &a[0]);AdjustDown(a, end, 0);		//从根开始向下调整, 新数组大小是endend--;	}}

直接选择排序的特性总结:


1. 堆排序使用堆来选数,效率就高了很多。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(1)        //利用自身建堆
4. 稳定性:不稳定

冒泡排序

冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地遍历要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。遍历数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。

这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

注意:冒泡排序效率十分低下,但是其形象的特点,教学意义重大。

冒泡排序算法的步骤如下:

  1. 比较相邻的元素:如果第一个比第二个大(为实现升序排序),就交换它们两个。

  2. 对每一对相邻元素做同样的工作:从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。

  3. 针对所有的元素重复以上的步骤,除了最后已经排序好的元素。

  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

void BubbleSort(int* a, int n)
{for (int i = 0; i < n - 1; i++)		//趟数(类比插入排序的趟数){bool exchange = false;for (int j = 0; j < n - 1 - i; j++){if (a[j] > a[j + 1])		//每两个元素,一一比较{Swap(&a[j], &a[j + 1]);exchange = true;}}if (exchange == false)		//当某一趟没有发生交换,说明已经有序,则直接跳出循环break;}}

可以观察到上述的冒泡排序进行了一定的优化,当某一趟不需要进行交换时,表明数组已经有序,便可以跳出循环,排序结束。

示例:

假设我们有以下已排序的数组进行排序:[1, 2, 3, 4, 5]。使用优化后的冒泡排序,第一趟排序后因为没有发生任何交换,算法会立即结束,不需要进行剩余的排序操作。

这个优化不会降低冒泡排序的时间复杂度,它仍然是O(n^2),但是它可以显著减少最好情况下的时间复杂度到O(n),即当输入数组已经是有序的情况下。

快速排序

快速排序,堪称是所有排序的老大哥,性能极其优秀。

快速排序(Quick Sort)是由英国计算机科学家东尼·霍尔(Tony Hoare)发现的,他在1960年发明了这种算法。东尼·霍尔是在研究一个文件排序的问题时,发明了快速排序算法。

这位大佬也是既低调又高调,低调是不用自己的名字命名,高调则是直接起名“快速”。

目前关于快速排序也是有多个子版本,这里着重介绍如下几个。

基本思想:

任取待排序元素序列中 的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
可以观察到,其思想是先利用单趟排序,使得单趟有序,再分别对子区间排序,因此便衍生出了递归版本和非递归实现的版本。

/*
每个子排序,都需要一个keyi来分割区间
*/

1.Hoare版本

顾名思义,这也是霍尔大佬发现的最初版本。

//Hoare法
int PartSort1(int* a, int left, int right)
{int keyi = left;while (left < right)	//右找小;左找大{while (left < right && a[right] >= a[keyi])			//左边是key,右边先走right--;		while (left < right && a[left] <= a[keyi])		//防止越界 与 死循环left++;//到此处时,一定是找到了大和小Swap(&a[left], &a[right]);		//体现交换排序}//相遇Swap(&a[left], &a[keyi]);	//最后相遇,让数据key和相遇位置的数据(比key小)交换keyi = left;return keyi;}

可以观察到右边先找小,左边再找大(原因下方讲述)

上述循环既左边找小,右边找大,符合条件交换,相遇之后,再交换keyi处和相遇位置处的值。

从而保证单趟结束之后,keyi左边都比a[keyi]小,右边都比它大。

left < right && a[right] >= a[keyi] 此句代码则能够防止 1.出现死循环 >= 2.出现越界 left < right

易错讲解

当升序排序时

无论升降序,都是让对方先走。

最后都要和keyi处的数据换。

如此保证,右边比key大;左边比key小

左边做K分析

情况一;R停下来时,一定是找到了  --- 小
情况二:L停下时,一定是刚交换完的之后 ----  小
算法优化
此算法的时间复杂度目前代码是n^2,主要原因是key的取值,如果keyi每次取最左边,如果原数组有序,则会形成等差数列的复杂度,造成n^2。

分析:

//如果已经有序,每次取得是最左边的数,则左边是一个不存在的区间,右边则会形成等差数列一共进行n - 1次调用(每次深度 - 1),每一次调用的循环规模是n - 1, n - 2, n - 3.

//则时间复杂度是所有规模之和,n + n - 1 + n - 2 + .....

优化后:

了解到主要原因是处在key的取值后,那便尽量让key的取值接近中位数。此时便出现三数取中。

三数取中

比较头、中、尾三个元素的大小,取中间值作为key

以下是三位取中的实现

//优化版:三数取中法(两两比较)int GetMidIndex(int* a, int left, int right)			//取下标
{int mid = (left + right) >> 1;		//÷2if (a[left] < a[mid]){if (a[mid] <= a[right])return mid;else if (a[left] <= a[right])		//走到此处,前提条件一定是 a[left] >= a[right]return right;elsereturn left;}else	//一定是a[left] >= a[mid]{if (a[mid] >= a[right])return mid;else if (a[left] <= a[right])	//此时一定是a[mid] < a[right]return left;elsereturn right;}}

得到中间值的下标之后,只需要让中间值与左边的值交换,便可以仍然让左边的值作为key

int midi = GetMidIndex(a, left, right);    //三数取中,记录Index
Swap(&a[midi], &a[left]);        //始终让最左边的数是“中”

2.挖坑法

其思想仍然是让左边小于key,右边大于key,分割出两段区间。


//挖坑
int PartSort2(int* a, int left, int right)
{int midi = GetMidIndex(a, left, right);    //三数取中,记录IndexSwap(&a[midi], &a[left]);int key = a[left];    //keyint hole = left;    //假设最开始left是坑的位置while (left < right)    //左找大,右找小{while (left < right && a[right] >= key)    //找数据right--;//交换数据,填洞Swap(&a[right], &a[hole]);        //交换数据,拿当前数据填坑hole = right;                //自己形成新的坑while (right > left && a[left] <= key)left++;Swap(&a[left], &a[hole]);hole = left;}//最后相遇,相遇在坑处(其中一个一定是坑,那么相遇一定是相遇在坑处)a[hole] = key;        //形成三个区间,左边比key小,右边比key大,最后相遇被key填上return hole;    //返回最后一个坑的位置} 

仍然是右边先找小,再左边找大,只不过区别是,每次找到之后,都要去填坑,形成新的坑。

最后key填到相遇的位置(hole中)。

前后指针法

可以说是最简单的一种版本了,至于要定义两个下标 prev 和 cur(是下标,不是数据),遍历数组就好。

过程:

通过此操作,使得大于key的值,不断翻滚向前,prev与cur夹杂的数据就是大于key的数据。

        


//前后指针法
int PartSort3(int* a, int left, int right)
{int cur = left + 1;int prev = left;		//不能是 0, 应该是left端点 int midi = GetMidIndex(a, left, right);	//三数取中,记录IndexSwap(&a[midi], &a[left]);int keyi = left;while (cur <= right)		//闭区间{if (a[cur] < a[keyi] && ++prev != cur)			//找到小,prev++;再交换Swap(&a[cur], &a[prev]);cur++;}Swap(&a[prev], &a[keyi]);		//交换数据,挪动keyi的位置,形成三个子区间keyi = prev;return keyi;}

找完之后,让再交换keyi与prev处的值,让keyi到prev处,从而分割出两段区间。


/*


三种子排序,都是形成了 左 keyi 右 三个区间


快排一般用前后指针法

cur找小,prev++,交换  (cur 与 prev最开始相邻)

最后交换a[prev] 与 a[keyi]

keyi = prev;
return keyi;   

*/
 

讲解完三种子排序,便可以讲解快排。

递归版本

//类似二叉树的展开,左边完成之后,再走右边
void QuickSort(int* a, int begin, int end)		//三种子排序,最终思想都是左边比key小,右边比key大,中间是keyi
{if (begin >= end)return;int keyi = PartSort3(a, begin, end);		//单趟排序//[left, keyi - 1] keyi [keyi + 1, end]QuickSort(a, begin, keyi - 1);	//利用单趟排序的返回值,左区间重复QuickSort(a, keyi + 1, end);	//右边重复
}

每次子排序,都会形成[left, keyi - 1] keyi [keyi + 1, end]两段区间,再利用这两段区间,分别进行子排序。(类似于二叉树的先序遍历)

非递归版本

由于其递归过程形似二叉树的展开过程,因此非递归借助栈来实现。

基本思想:1.利用栈的后进先出 2.当大区间完成排序之后,带入子区间进入栈

注意点:
对于借助栈的循环而言,一般都是利用栈的后进先出。其循环格式一般为
1.判空
2.出数据,使用数据(注意后入先出)
3.入数据
//循环:入  +  出 + 功能操作
void QuickSortNonR(int* a, int begin, int end)	//快排非递归利用栈实现(深度优先)
{ST st;STInit(&st);	STPush(&st, end);STPush(&st, begin);while (!STEmpty(&st)){int left = STTop(&st);STPop(&st);int right = STTop(&st);STPop(&st);int keyi = PartSort3(a, left, right);//[left, keyi - 1] keyi [keyi + 1, right]if (keyi + 1 < right)	//只有一个元素不需要排序{STPush(&st, right);STPush(&st, keyi + 1);}if (left < keyi - 1){STPush(&st, keyi - 1);STPush(&st, left);}}STDestroy(&st);}


在此段代码中,先押入右再压入左,则先取左,再取右

        int keyi = PartSort3(a, left, right);
        //[left, keyi - 1] keyi [keyi + 1, right]

每次调用单趟排序之后,都会形成两段区间。

    STDestroy(&st);用完之后,将栈空间销毁。

快速排序的特性总结:


1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(logN)        //递归调用深度
4. 稳定性:不稳定

5.三种方法都无法解决有大量重复元素的问题。

解决:三路划分法 + 随机数取中

三路划分更全面,但是右路的原因,造成效率下降(日常写快排还是hoare和前后指针)

1.随机数取中

让mid取[left, end]区间的任意一个数

(left  + rand() / (right - left))此时取出的mid值,保证是区间内的任意值

rand % x ,保证结果小于x 因此 left + x < right;

2.三路划分法

当有大量重复元素时,每次让左边的值为key,则剩下的递归区间:左区间没有,有区间为n - i (i为递归次数),此时变成最坏情况

解决本质:

小的放在左边,大的放到右边,等于key的集中中间

将等于key的数据,放到中间,每次只对小于的部分和大于的部分进行划分

在过程中,left与right不断靠近中间

既有hoare的思想,也有前后指针的思想。

1.小的往左甩,大的往右甩right--

2.把等于key的值,往中间推

往右甩不会影响,所以不需要++c。往右甩有三种情况,大于等于 相等

在++Cur的过程中,始终保持left位置处的值 == key

最终left 与  right区间中的数据就是与key相等数据

[begin,left - 1]      [left, right]         [right + 1 ,end] 最终可形成这三段区间。

核心代码:


void QuickSort(int* nums, int begin, int end)
{if (begin >= end)return;int left = begin;int right = end;int cur = left + 1;int midi = MidIndex(nums, begin, end);Swap(&nums[midi], &nums[left]);int key = nums[left];while (cur <= right){if (nums[cur] < key)    //cur是下标,不是具体元素{Swap(&nums[cur], &nums[left]);      //分别和;;left和right处的值换,让left与right再不断移动,靠近中间 left++; cur++;}else if (nums[cur] > key){Swap(&nums[cur], &nums[right]);right--;}elsecur++;}QuickSort(nums, begin, left - 1);QuickSort(nums, right + 1, end);}

最终left,right区间夹杂的数据就是等于key的值。

例子:

给你一个整数数组 nums,请你将该数组升序排列。(需要解决三种方法都无法解决有大量重复元素的问题。)

示例 1:

输入:nums = [5,2,3,1]
输出:[1,2,3,5]

示例 2:

输入:nums = [5,1,1,2,0,0]
输出:[0,0,1,1,2,5]

提示:

  • 1 <= nums.length <= 5 * 104
  • -5 * 104 <= nums[i] <= 5 * 104

解决代码:


int MidIndex(int* nums, int left, int right)
{int mid = left + (rand() % (right - left));     //中间值变化//rand % x ,保证结果小于x 因此 left + x < right;if (nums[left] < nums[mid]){if (nums[mid] <= nums[right])return mid;if (nums[left] <= nums[right])return right;elsereturn left;}else{if (nums[left] <= nums[right])return left;if (nums[mid] <= nums[right])return right;elsereturn mid;}}void Swap(int* a, int* b)
{int tmp = *a;*a = *b;*b = tmp;
}//优化:三路划分法 + 随机数取中void QuickSort(int* nums, int begin, int end)
{if (begin >= end)return;int left = begin;int right = end;int cur = left + 1;int midi = MidIndex(nums, begin, end);Swap(&nums[midi], &nums[left]);int key = nums[left];while (cur <= right){if (nums[cur] < key)    //cur是下标,不是具体元素{Swap(&nums[cur], &nums[left]);      //分别和;;left和right处的值换,让left与right再不断移动,靠近中间 left++; cur++;}else if (nums[cur] > key){Swap(&nums[cur], &nums[right]);right--;}elsecur++;}QuickSort(nums, begin, left - 1);QuickSort(nums, right + 1, end);}int* sortArray(int* nums, int numsSize, int* returnSize) {srand(time(0));int begin = 0;int end = numsSize - 1;QuickSort(nums, begin, end);*returnSize = numsSize;     //传入的returnSize是一个指针,需要人为赋值return nums;
}

归并排序

顾名思义,是通过两个数组的归并完成的排序。

基本思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。

其具体思想是:将大区间拆分成一个个小区间,再两两归并,再拷贝回原来的区间。

递归版

//1.拆分  2.归并
void _MergeSort(int* a, int begin, int end, int* tmp)
{if (begin == end)return;int mid = (begin + end) / 2;//拆分 [begin, mid] [mid + 1, end]_MergeSort(a, begin, mid, tmp);_MergeSort(a, mid + 1, end, tmp);//归并int begin1 = begin;   int end1 = mid;int begin2 = mid + 1; int end2 = end;int i = begin;	// i 丛begin 开始,而不是从 0 开始,  tmp数组随a从begin位置开始拷贝while (begin1 <= end1 && begin2 <= end2)	//尾插{if (a[begin1] <= a[begin2])tmp[i++] = a[begin1++];		//begin1 和 begin2 不能同时++,只有用完之后才能++elsetmp[i++] = a[begin2++];}while (begin1 <= end1)	//到此处一定有一个结束{tmp[i++] = a[begin1++];}while (begin2 <= end2)	{tmp[i++] = a[begin2++];}memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));	//不是只拷贝前几个字节,而是拷贝完成内容修改的部分
}//需要注意的是,尾插从begin处开始,拷贝回去也是从begin处开始。
void MergeSort(int* a, int n)
{int* tmp = (int*)malloc(sizeof(int) * n);_MergeSort(a, 0, n - 1, tmp);		//n是数组大小,n - 1才是下标free(tmp);tmp = NULL;
}

以下体现的是拆分

    //拆分 [begin, mid] [mid + 1, end]
    _MergeSort(a, begin, mid, tmp);
    _MergeSort(a, mid + 1, end, tmp);

以下体现的是归并

int begin1 = begin;   int end1 = mid;
int begin2 = mid + 1; int end2 = end;

int i = begin;    // i 丛begin 开始,而不是从 0 开始,  tmp数组随a从begin位置开始拷贝
while (begin1 <= end1 && begin2 <= end2)    //尾插    //只有一个元素时,begin == end
{
    if (a[begin1] <= a[begin2])
        tmp[i++] = a[begin1++];        //begin1 和 begin2 不能同时++,只有用完之后才能++

    else
        tmp[i++] = a[begin2++];
}

while (begin1 <= end1)    //到此处一定有一个结束
{
    tmp[i++] = a[begin1++];
}

while (begin2 <= end2)    
{
    tmp[i++] = a[begin2++];
}
 

最后需要拷贝回去。

需要注意的是,并归到tmp数组时,需要从begin处开始并归,因此拷贝回去,也是从此处拷贝回去。

非递归版

对于递归,可以完成层层拆分;对于非递归,则需要借助gap来完成小组之间的递归

大体可以看作两步

1.三层循环嵌套 2.进行参数调整,防止出现越界。


void MergeSortNonR(int* a, int n)
{int* tmp = (int*)malloc(sizeof(int) * n);int gap = 1;	//表示两个begin1 之间的元素个数while (gap < n){	int j = 0;for (int i = 0; i < n; i += 2 * gap){int begin1 = i; int end1 = i + gap - 1;	//end下标是  begin下标 + 间隔元素个数 - 1 int begin2 = end1 + 1; int end2 = begin2 + gap - 1;//三种越界if (end1 >= n || begin2 >= n)break;if (end2 >= n)end2 = n - 1;while (begin1 <= end1 && begin2 <= end2){if (a[begin1] <= a[begin2])tmp[j++] = a[begin1++];elsetmp[j++] = a[begin2++];}while (begin1 <= end1){tmp[j++] = a[begin1++];}while (begin2 <= end2){tmp[j++] = a[begin2++];}//完成一组,拷贝一组memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));	//闭区间元素个数需要 + 1}gap *= 2;}free(tmp);tmp = NULL;}

1.三层嵌套讲解

不同于快排的非递归实现,快排的非递归实现借助的是栈,这是因为其区间变化是从大到小,类比二叉树。

并归排序的非递归采用的是从前到后分割区间,因此可以用gap来分割。

首先是gap  < n,保证了循环过程中,不能出现数组越界。

再者是        for (int i = 0; i < n; i += 2 * gap) 通过 i 来生成两两归并的起点

最后是        while (begin1 <= end1 && begin2 <= end2) 来完成归并。

简而言之就是:1.控gap 2.控起点 3.完归并

2.越界讲解。

此处越界,可分为三种情况,

三种越界图示:

对于第一第二种情况,只需要让循环终止即可,通过归并一组,拷贝一组,只需要让归并的部分拷贝到原数组即可

对于第三种情况,只需要让end2修改成n - 1即可(没必要让进行两个归并的数组大小非得相同)。

需要注意的是:

 ①   int begin1 = i; int end1 = i + gap - 1;    //end下标是  begin下标 + 间隔元素个数 - 1 
    int begin2 = end1 + 1; int end2 = begin2 + gap - 1;

对于bg1 和 end1,最开始只指向一个元素,两者重合。同时end是下标,其值为 begin的下标 + 间隔元素个数 - 1。

② if (a[begin1] <= a[begin2])
                tmp[j++] = a[begin1++];

else
                tmp[j++] = a[begin2++];

在归并时,不能同时让begin1 和 begin2同时++,只有用到时,才可以++

③若把j定义在次循环外部,则 j 可以定义为0,没完成一趟遍历(归并完一整组,j会重新变成0,重新用尾插的方式,归并到tmp数组

④同理,从begin位置开始归并,归并一组,拷贝一组,因此拷贝也要从begin处进行

            memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));    //闭区间元素个数需要 + 1
每次拷贝的元素个数是end2 - i + 1(下标 + 1 == 元素个数; 元素个数 - 1 == 下标)

⑤i += 2*gap (两个begin1之间间隔2*gap个元素)

⑥完成一大趟并归之后,gap *= 2 (先是 一一归并,再是两两归并……)

    free(tmp);
    tmp = NULL;
归并完成之后,释放空间。

归并排序的特性总结:


1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2. 时间复杂度:O(N*logN)
3. 空间复杂度:O(N)
4. 稳定性:稳定

(另外多提一嘴,归并排序和快排是在工程项目中最常用的两种排序,因为两者快、高效且一个稳定,一个不稳定,可以达到效果互补)

计数排序

计数排序是一个比较有趣的排序方法,体现在两点:1.不需要进行数据的比较 2.效率忽高忽低

就拿吃席举例子,那么小孩桌坐的是:冒泡排序、插入排序、直接基数排序

大人桌坐的是:快排、并归、希尔、堆排序

计数排序比较特殊,如果去大人桌,那么他喝酒比大人还牛;但如果去小孩桌,那他喝饮料都和不过小孩。

非比较排序
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤:
1. 统计相同元素出现次数
2. 根据统计的结果将序列回收到原来的序列中

简单来说就是 :

1.建立一个count(tmp)数组,来统计原数组种数据出现的个数

2.tmp数组中的数据,按照顺序映射回原数组。

表达式: tmp数组下标 == 数据值 - min

void CountSort(int* a, int n)
{int max = a[0];int min = a[0];//求max 与 minfor (int i = 0; i < n; i++){if (a[i] > max)max = a[i];if (a[i] < min)min = a[i];}int range = max - min + 1;int* tmp = (int*)malloc(sizeof(int) * range);memset(tmp, 0, sizeof(int) * range);	//每个字节都被初始化为数字0。 不能是字符 '0' (字符‘0’存储的ASC II值不是数字0)if (tmp == NULL){perror("malloc fail");return;}//相对映射for (int i = 0; i < n; i++){tmp[a[i] - min]++;}//映射到原数组int j = 0;for (int i = 0; i < range; i++)		//遍历tmp计数数组时,开辟的空间大小是range,而不是n{while (tmp[i]--)	//1.不为0  2.看次数(count){a[j++] = i + min;}}free(tmp);tmp = NULL;
}

观察代码不难发现,此处需要三个循环。

循环一:遍历原数组,找max与min

循环二:遍历原数组,将原数组的数据,映射到tmp数组,当出现相同的元素时,对应tmp数组的元素++

循环三:遍历tmp数组(大小是range),将元素拷贝回原数组。

需要注意的是

① 

tmp[a[i] - min]++;

a[j++] = i + min;

这两段代码,便体现了tmp数组的下标 == 有效数据大小 - min的特点。

完成之后,释放空间。

memset(tmp, 0, sizeof(int) * range);

需要对tmp数组所有数据初始化为数字0。

for (int i = 0; i < range; i++)        //遍历tmp计数数组时,开辟的空间大小是range,而不是n
{
    while (tmp[i]--)    //1.不为0  2.看次数(count)
    {
        a[j++] = i + min;
    }

}

第三次遍历tmp数组时,由于tmp数组元素被初始化为0,因此可以直接while(tmp[i]--)

既可以保证不为0,同时不为0时,还可以根据被统计的次数不断--,通过a[j++] = i  +  min;给数组a排序。

计数排序的特性总结:


1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2. 时间复杂度:O(MAX(N,范围))
3. 空间复杂度:O(范围)
比特科技
4. 稳定性:稳定

由此便可以知道为什么说计数排序是一个有趣的排序,观察空间复杂度,当数据比较集中时,range很小,空间复杂度极低;同时时间按复杂度变为O(N) 比快排还要优秀许多倍!!!同时稳定性也良好!

但是数据过分分散时,空间复杂度及时间复杂度就变得很差。

以上便是八大内排序的常用算法。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://xiahunao.cn/news/3004051.html

如若内容造成侵权/违法违规/事实不符,请联系瞎胡闹网进行投诉反馈,一经查实,立即删除!

相关文章

云服务器+ASF实现全天挂卡挂时长

目录 前言正文1.安装下载2.编辑配置文件3.设置Steam社区证书4.启动ASF5.给游戏挂时长6.进阶-ASF自动启动且后台保活 前言 我遇到的最大的问题是&#xff0c;网络问题 其实不然&#xff0c;各大厂商的云服务器后台都有流量监控&#xff0c;意味着依靠一般方法是不能正常访问St…

JUC线程

进程和线程&#xff1a; 进程&#xff08;Process&#xff09;是计算机中的程序关于某数据集合上的一次运行活动&#xff0c;是系统进行资源分配的基本单位&#xff0c;是操作系统结构的基础。 线程&#xff08;英语&#xff1a;thread&#xff09;是操作系统能够进行运算调度…

五大开放式耳机推荐,选对耳机让运动更带感!

看似精彩的户外运动经历背后&#xff0c;其实是枯燥的体能运动和训练&#xff0c;以及独自长途和长时间旅行伴随的孤独感&#xff0c;而排解这些不良情绪的最佳方式就是音乐。如果你希望在运动、舒适、安全和音质之间获得一个最佳平衡&#xff0c;那相比入耳式耳机&#xff0c;…

基于SSM的个人博客系统(四)

目录 5.3 博客类别管理模块 5.3.1 添加博客类别 5.3.2 修改博客类别 5.3.3 删除博客类别 5.3.4 显示博客类别 5.4 评论管理模块 5.4.1 审核评论 5.4.2 删除评论 前面内容请移步 基于SSM的个人博客系统&#xff08;三&#xff09; 个人博客系统的设计与实现免费源码…

Java对接高德api搜索POI 2.0 关键字搜索

目录 一、注册账号 二、搜索小demo 1.首先要引入依赖 2. 然后查看打印结果即可 三、搜索接口代码 1.引入依赖 2.yml配置 2.Controller 3.静态工具类 四、运行测试 一、注册账号 高德开放平台 | 高德地图API 注册高德开发者&#xff1b;去控制台创建应用&#xff…

带状疱疹科普

什么是带状疱疹 早期疱疹 4天后的疱疹(第三天开始用药) 每种药物的作用 原理 我使用的药物 - 注意事项: 吃药前需要做抽血检查肾功能,肾功能不好不建议吃该药物. 有条件的建议去医院皮肤科,此文章仅仅科普

【题解 | 思维】三元组中心问题

三元组中心问题 注意点&#xff1a; 同一个位置的元素&#xff0c;不管以它为中心能组成多少个三元组&#xff0c;只记一个不同索引位置的相同元素&#xff0c;算多个中心元素。 常规暴力 import java.util.Scanner;public class Main {public static void main(String[] args…

实时监控视频拼接系统:功能和拼接参数介绍

目录 一、实时视频拼接系统介绍 &#xff08;一&#xff09;实时视频拼接的定义 &#xff08;二&#xff09;主要功能 1、视频拼接 2、拼接形式选择 3、前端选择 4、拼接展示 5、数据处理效率提升 6、任务管理 &#xff08;三&#xff09;实时拼接效果 二、拼接需要…

YOLOv9/YOLOv8算法改进【NO.126】YOLOv9的RepNCSPELAN进行二次创新

前 言 YOLO算法改进系列出到这&#xff0c;很多朋友问改进如何选择是最佳的&#xff0c;下面我就根据个人多年的写作发文章以及指导发文章的经验来看&#xff0c;按照优先顺序进行排序讲解YOLO算法改进方法的顺序选择。具体有需求的同学可以私信我沟通&#xff1a; 首推…

springBootAdmin监控

简介 用于对 Spring Boot 应用的管理和监控。可以用来监控服务是否健康、是否在线、以及一些jvm数据等等 Spring Boot Admin 分为服务端(spring-boot-admin-server)和客户端(spring-boot-admin-client)&#xff0c;服务端和客户端之间采用 http 通讯方式实现数据交互&#xf…

【IDEA】2023版IDEA安装破解教程

2023版IDEA安装破解教程 第一步&#xff1a;IDEA的卸载 这里以Windows11系统为例&#xff0c;首先我们打开控制面板&#xff0c;点击程序&#xff0c;找到自己的IDEA&#xff0c;双击卸载。&#xff08;或者可以直接找到idea所在文件位置&#xff0c;直接delete文件夹&#x…

Java | Leetcode Java题解之第59题螺旋矩阵II

题目&#xff1a; 题解&#xff1a; class Solution {public int[][] generateMatrix(int n) {int num 1;int[][] matrix new int[n][n];int left 0, right n - 1, top 0, bottom n - 1;while (left < right && top < bottom) {for (int column left; co…

图神经网络入门与实战:从图嵌入(GE)到图神经网络(GNN)

目录 一. 图的基本概念(Graph) 1.1 图的定义 1.2 图表示的基本概念 1.3 图的应用场景 1.4 图的分类 二. 图嵌入(Graph Embedding) 2.1 图嵌入的基本概念 2.2 图嵌入方法分类 2.3 图嵌入和图神经网络的区别 三. 图神经网络(Graph Neural Network) 3.1 图神经网络的基…

docker 集群管理实战mesos+zookeeper+marathon(一)

一 实验环境 1.1 系统版本&#xff0c;本实验使用cnetos7.9版本镜像 1.2 准备5台虚拟机&#xff0c;其中3台master&#xff0c;两台slave&#xff0c;使用克隆的方式 1.3 使用远程连接工具登录 1.4 修改主机名 1.5 设置域名映射 每个虚拟机都配置一下&#xff0c;这里就演示一…

Golang | Leetcode Golang题解之第64题最小路径和

题目&#xff1a; 题解&#xff1a; func minPathSum(grid [][]int) int {if len(grid) 0 || len(grid[0]) 0 {return 0}rows, columns : len(grid), len(grid[0])dp : make([][]int, rows)for i : 0; i < len(dp); i {dp[i] make([]int, columns)}dp[0][0] grid[0][0]…

双塔模型模型结构、样本选择、训练方式、线上服务、模型更新

召回模型目的是快速选取用户可能感兴趣的物品&#xff0c;凡事用户可能感兴趣的都取回来 然后交给后续排序模型逐一甄别。 双塔模型结构 不止能使用id特征&#xff08;能使用id之外的其他特征&#xff09;&#xff0c;用户侧能用画像等其他特征&#xff0c;包括离散特征和连续…

自定义 Dockerfile 构建 PostgreSQL 15 编译版 Docker 镜像

BG 前几日 Sean 老师发布了一篇文章 – PostgreSQL安装(一): 再简单点儿&#xff0c;用Docker?, 介绍如何快速安装启动 PostgreSQL 数据库。 本文再稍微延伸一点&#xff0c;介绍一下如何自定义 Dockerfile&#xff0c;加入自己想要预制的参数&#xff0c;构建一个自定义的 …

【经典算法】LeetCode112. 路径总和(Java/C/Python3/Go实现含注释说明,Easy)

作者主页&#xff1a; &#x1f517;进朱者赤的博客 精选专栏&#xff1a;&#x1f517;经典算法 作者简介&#xff1a;阿里非典型程序员一枚 &#xff0c;记录在大厂的打怪升级之路。 一起学习Java、大数据、数据结构算法&#xff08;公众号同名&#xff09; ❤️觉得文章还…

004 秒杀下单

文章目录 超卖问题方案一方案二方案三aop锁(单机锁)aop锁(单机锁)pom.xmlLockAspect.javaServiceLock.java 分布式锁Mysql分布式锁Redis分布式锁ServiceRedisLock.javaLockRedisAspect.java 下单性能优化数据一致性解决一致性问题异步同步库存 秒杀下单业务步骤: 1.数据校验(身…

LeetCode 543.二叉树的直径

题目描述 给你一棵二叉树的根节点&#xff0c;返回该树的 直径 。 二叉树的 直径 是指树中任意两个节点之间最长路径的 长度 。这条路径可能经过也可能不经过根节点 root 。 两节点之间路径的 长度 由它们之间边数表示。 示例 1&#xff1a; 输入&#xff1a;root [1,2,3,4,5]…