【数据结构详解】——八大排序(收藏版)

【数据结构排序算法系列】数据结构八大排序算法 排序算法在计算机应用中随处可见,如Windows操作系统的文件管理中会自动对用户创建的文件按照一定的规则排序(这个规则用户可以自定义,默认按照文件名排序)因此熟练掌握各种排序算法是非常重要的,本博客将对数据结构中常见的八大排序算法进行详细的讲解。 写在前面的话:首先要明白在学习同类型的排序算法时,一定要明白任何相同领域存在的东西一定是竞争之后的结果,所以对于相同类型的排序之所以能够存在多种被人熟 阅读详情

📖 前言:排序与我们的日常生活息息相关。例如,教师按身高来安排学生的座位,试卷和答题卡按从小号到大号的顺序来整理,各类比赛按成绩的高低来排名,查询火车票时会按照出发的先后来显示,到网上购物会参考销量高低来排序购买等。排序是数据处理和分析中最常用的运算之一,它往往可以提高数据处理的效率;排序也是最基本的算法之一,其他很多算法都是以排序算法为基础,所以研究和掌握排序算法是非常重要的。在信息时代,面对庞大的信息量,想要靠人工进行排序,会耗费大量时间和精力,甚至无法完成。所以,依靠计算机快速、准确地对数据进行排序,是很有必要的。


🎓 作者:HinsCoder
📦 作者的GitHub:代码仓库
📌 往期文章&专栏推荐:

  1. 【C语言详解】专栏
  2. 【数据结构详解】专栏
  3. 【C语言详解】——函数栈帧的创建与销毁(动图详解)
  4. 【数据结构详解】——线性表之顺序表(多图详解)
  5. 【数据结构详解】——线性表之单链表(动图详解)
  6. 【数据结构详解】——线性表之双向链表(动图详解)

🕒 1. 排序的概念及其种类

🕘 1.1 排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:待排序的记录序列中相同元素在排序后相对位置不发生变化,则称该排序方法具有稳定性。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求在内外存之间多次交换数据的排序。

🕘 1.2 常见的排序算法

在这里插入图片描述

排序OJ(可使用各种排序跑这个OJ) 🔎 排序OJ

🕒 2. 直接插入排序

💡 算法思想:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。就像玩扑克牌时,对其进行从小到大排序。

请添加图片描述
代码实现如下:

void InsertSort(int* a, int n)
{
    for (int i = 0; i < n - 1; i++)	  // 因为x元素位置是i的下一个位置,为防止x越界,需要使 i < n - 1
    {
        int end = i;          // 当前要插入位置的索引
        int tmp = a[end + 1]; // 需要插入的元素值

        // 将 tmp 插入到合适的位置
        while (end >= 0 && a[end] > tmp)
        {
            a[end + 1] = a[end]; // 如果当前元素大于 tmp,则后移
            --end;
        }
        
        a[end + 1] = tmp; // 插入 tmp 到正确位置
    }
}

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

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高。
  2. 最坏时间复杂度(逆序):O(N2)
    最好时间复杂度(顺序):O(N)
  3. 空间复杂度:O(1)
  4. 稳定性:稳定

🕒 3. 希尔排序(缩小增量排序)

💡 算法思想:先选定一个整数gap,把待排序文件中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组内的元素进行排序。然后将gap逐渐减小重复上述分组和排序的工作。当到达gap=1时,所有元素在统一组内排好序。

在这里插入图片描述

算法拆解:预排序+直接插入排序

预排序的本质也是插入排序,是对每一小组中的数据进行插入排序。目的是让整个数组接近有序,使得一开始无序的数组趋向于让大的数往后面走,小的数往前面走;在经过预排序使得整个数组接近有序后,就可以更加快速(中间也减少了数据的挪动次数)的将数组进行排序。

请添加图片描述
代码实现如下:

void ShellSort(int* a, int n)
{
    int gap = n;  // 初始增量设置为数组长度n

    // 当增量大于1时继续排序
    while (gap > 1)
    {
        // 使用动态增量序列 gap = gap / 3 + 1,这种增量选择时间复杂度约为O(N^1.3)
        gap = gap / 3 + 1;

        // 对每个子序列进行插入排序,步长为 gap
        for (int i = 0; i < n - gap; ++i)
        {
            int end = i;          // 当前要插入位置的索引
            int tmp = a[end + gap];  // 要插入的元素

            // 使用插入排序的方式将 tmp 插入到合适位置
            while (end >= 0 && a[end] > tmp)
            {
                a[end + gap] = a[end];  // 如果前一个元素比 tmp 大,则向后移动 gap 个位置
                end = end - gap;        // 继续向前比较
            }
            a[end + gap] = tmp;  // 将 tmp 插入到正确的位置
        }
    }
}

希尔排序的特性总结:

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

🕒 4. 直接选择排序

💡 算法思想:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始(末尾)位置,然后选出次小(或次大)的一个元素,存放在最大(最小)元素的下一个位置,重复这样的步骤直到全部待排序的数据元素排完。
请添加图片描述

代码实现如下:这里可以进行一个优化,最小值和最大值同时选,然后将最小值与起始位置交换,将最大值与末尾位置交换。

void Swap(int* p1, int* p2)
{
    int tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}

void SelectSort(int* a, int n)
{
    int begin = 0;  // 起始位置
    int end = n - 1;  // 结束位置

    // 循环直到整个数组都被排序
    while (begin < end)
    {
        int mini = begin;  // 保存最小元素下标
        int maxi = begin;  // 保存最大元素下标

        // 在当前未排序部分查找最小和最大元素的下标
        for (int i = begin + 1; i <= end; ++i)
        {
            if (a[i] < a[mini])
            {
                mini = i;  // 更新最小元素下标
            }
            if (a[i] > a[maxi])
            {
                maxi = i;  // 更新最大元素下标
            }
        }

        // 将找到的最小元素交换到起始位置
        Swap(&a[begin], &a[mini]);

        // 如果最大元素的位置在起始位置,更新最大元素下标为 mini
        if (maxi == begin)
        {
            maxi = mini;
        }

        // 将找到的最大元素交换到末尾位置
        Swap(&a[end], &a[maxi]);

        // 缩小排序范围
        ++begin;
        --end;
    }
}

选择排序的特性总结:

  1. 选择排序步骤非常好理解,但是效率不是很好(不论数组是否有序都会执行原步骤),实际中很少使用。
  2. 时间复杂度:O(N2)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

🕒 5. 堆排序

💡 算法思想:堆排序即利用堆的思想来进行排序,总共分为两个步骤:1. 建堆(升序:建大堆;降序:建小堆) 2. 利用堆删除思想来进行排序:建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。

这里以升序为例:

  • 首先应该建一个大堆,不能直接使用堆来实现。可以将需要排序的数组看作是一个堆,但需要将数组结构变成堆结构。
  • 我们可以从堆从下往上的第二行最右边开始依次向下调整直到调整到堆顶,这样就可以将数组调整成一个堆,且如果建立的是大堆,堆顶元素为最大值。
  • 然后按照堆删的思想将堆顶和堆底的数据交换,但不同的是这里不删除最后一个元素。
  • 这样最大元素就在最后一个位置,然后从堆顶向下调整到倒数第二个元素,这样次大的元素就在堆顶,重复上述步骤直到只剩堆顶时停止。

请添加图片描述

// AdjustDown函数:在数组a中,从节点root开始向下调整,使得以root为根的子树满足大顶堆的性质。
void AdjustDown(int* a, int n, int root)
{
	assert(a);
	int parent = root; // 当前子树的根节点
	int child = parent * 2 + 1; // 左孩子节点

	// 循环直到没有孩子节点
	while (child < n)
	{
		// 如果右孩子存在且比左孩子大,则选择右孩子作为比较对象
		if (child + 1 < n && a[child + 1] > a[child])
		{
			child++;
		}

		// 如果孩子节点比父节点大,则交换父节点和孩子节点的值,并更新父节点和孩子节点继续向下比较
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break; // 如果孩子节点不再比父节点大,则退出循环
		}
	}
}

void HeapSort(int* a, int n)
{
	assert(a);
 
	// 建立大顶堆
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i); // 对每个非叶子节点进行向下调整,建立大顶堆
	}
 
	// 交换堆顶元素和末尾元素,并重新调整堆
	for (int i = n - 1; i > 0; i--)
	{
		Swap(&a[i], &a[0]); // 将当前堆顶(最大值)与数组末尾元素交换
		AdjustDown(a, i, 0); // 调整剩余堆为大顶堆,范围缩小为0到i-1
	}
}

堆排序的特性总结:

  1. 堆排序使用堆来选数,效率较高,适用于需要频繁插入和删除的场景。
  2. 时间复杂度:O(N*logN)
  3. 空间复杂度:O(1)
  4. 稳定性:不稳定

🕒 6. 冒泡排序

💡 算法思想:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。一共进行n-1趟这样的交换将可以把所有的元素排好。
请添加图片描述
代码实现如下:

void BubbleSort(int* a, int n)
{
    // 外层循环控制排序轮数,每轮将一个最大的元素放到最后
    for (int j = 0; j < n; ++j)
    {
        int exchange = 0;  // 交换标志,用于判断本轮是否有元素交换

        // 内层循环遍历当前未排序部分,将相邻的元素逐个比较并交换
        for (int i = 1; i < n - j; ++i)
        {
            if (a[i - 1] > a[i])
            {
                Swap(&a[i - 1], &a[i]);  // 如果前一个元素大于后一个元素,则交换它们
                exchange = 1;  // 设置交换标志为1,表示本轮有元素交换
            }
        }

        // 如果本轮没有进行任何交换,说明数组已经有序,可以提前结束排序
        if (exchange == 0)
        {
            break;
        }
    }
}

冒泡排序的特性总结:

  1. 冒泡排序是一种非常容易理解的排序,在数据有序时可以提前结束排序。
  2. 时间复杂度:O(N2)
  3. 空间复杂度:O(1)
  4. 稳定性:稳定

🕒 7. 快速排序

💡 算法思想:快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

🕘 7.1 Hoare版本(左右指针法)

请添加图片描述

具体思路是:

  1. 选定一个基准值(pivot / key),最好选定最左边或者最右边;
  2. 确定两个指针left和right分别从左边和右边向中间遍历数组;
  3. 如果选最右边为基准值,那么left指针先走,如果遇到大于基准值的数就停下来。这样能保证相遇位置比基准值大;
  4. 然后右边的指针再走,遇到小于基准值的数就停下来;
  5. 交换left和right指针对应位置的值;
  6. 重复以上步骤,直到left = right ,最后将基准值与left(right)位置的值交换,这便完成一趟排序。

请添加图片描述

这样基准值左边的所有数都比它小,而它右边的数都比它大,从而它所在的位置就是排序后的正确位置。之后再递归排以基准值为界限的左右两个区间中的数,当区间中没有元素时,排序完成。
请添加图片描述

// 快速排序hoare版本
int PartSort(int* a, int left, int right)
{
	int key = right;// 选定基准值
	while (left < right)
	{
		// 选右边为基准值,则左指针先走,从左往右找到第一个比基准值大的元素
        while (left < right && a[left] <= a[key])
		{
			left++;
		}
 
        // 右指针再走,从右往左找到第一个比基准值小的元素
        while (left < right && a[right] >= a[key])
		{
			right--;
		}
		Swap(&a[left], &a[right]);
	}
	Swap(&a[left], &a[key]);
	return left;
}
 
void QuickSort(int* a, int left, int right)
{
    assert(a);
	if (left >= right)
	{
		return;	// 如果左边界大于等于右边界,表示已经排序完成。
	}
 
	
	int keyi = PartSort(a, left, right);	// 对数组进行一次分割操作,获取基准值的最终位置
	QuickSort(a, left, keyi - 1);			// 对基准值左边的子数组进行递归排序
	QuickSort(a, keyi + 1, right);			// 对基准值右边的子数组进行递归排序
}

🕘 7.2 挖坑法

💡 算法思想:挖坑法与上面Hoare版本思想基本一致,都是利用递归(即分治的算法思想)去实现排序。但挖坑法的不同之处在于,当left和right这两个下标在遍历过程中找到满足条件的元素时(即元素值大于或小于基准值key),它们会立即将这些元素放置到合适的位置,这个过程可以描述为边查找边替换。相比之下,Hoare版本则是先记录下符合条件的元素下标,在遍历完成后再进行位置的交换。

请添加图片描述

具体思路是:

  1. 先将选定的基准值(最左边)直接取出,然后留下一个坑;
  2. 当右指针遇到小于基准值的数时,直接将该值放入坑中,而右指针指向的位置形成新的坑位;
  3. 然后左指针遇到大于基准值的数时,将该值放入坑中,左指针指向的位置形成坑位;
  4. 重复该步骤,直到左右指针相等。最后将基准值放入坑位之中。

注意,在操作过程中,我们并不是真正地创建一个新的坑位,而是在逻辑上将其视为坑位。实际上,坑位仍然占据着原来的元素,我们只是在操作时暂时将原元素存储起来,然后用新元素直接覆盖它。在left和right最终相遇的位置,就会形成一个坑位,恰好可以放置key。

// 快速排序挖坑法
int PartSort(int* a, int left, int right)
{
    int key = a[left]; // 取出基准值
    int hole = left; // 保存坑的位置

    while (left < right)
    {
        // 从右向左找第一个小于基准值的元素
        while (left < right && a[right] >= key)
        {
            right--;
        }
        // 将找到的小于基准值的元素填入左边的坑中
        a[hole] = a[right];
        hole = right; 

        // 从左向右找第一个大于基准值的元素
        while (left < right && a[left] <= key)
        {
            left++;
        }
        // 将找到的大于基准值的元素填入右边的坑中
        a[hole] = a[left];
        hole = left; 
    }

    // 将基准值填入最后一个坑中,此时左右两部分已经分好
    a[hole] = key;

    return hole; 
}

void QuickSort(int* a, int left, int right)
{
    assert(a);
	if (left >= right)
	{
		return;	// 如果左边界大于等于右边界,表示已经排序完成。
	}
 
	int keyi = PartSort(a, left, right);	// 对数组进行一次分割操作,获取基准值的最终位置
	QuickSort(a, left, keyi - 1);			// 对基准值左边的子数组进行递归排序
	QuickSort(a, keyi + 1, right);			// 对基准值右边的子数组进行递归排序
}

🕘 7.3 前后指针法

💡 算法思想:有两个指针:cur、prev(cur的位置在prev的下一个位置),期间通过cur去遍历数组,将每个元素与key值比较大小,若发现有元素小于key,就让prev++(即走到下一个位置),再与cur此时对应的元素进行交换,通过递归的方式重复上述的步骤,即可实现完全排序。

简而言之:

  • prev和key初始位置之间的区间,是在维护一个所有元素都小于key的区域,目的是使key左侧的序列中的所有值都小于key。
  • prev++到cur之间的区间,则是在维护一个所有元素都大于key值的区域,以确保key右侧的序列中的所有值都大于key。
  • cur不断寻找小于key的元素并与prev++后的位置交换,是为了将序列后方小于key的元素移动到key前方,保证key左侧子序列的值都小于key,右侧子序列的值都大于key。

具体思路是:

  1. 选定基准值,定义prev和cur指针(cur = prev + 1);
  2. cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置;
  3. 将prev对应值与cur对应值交换;
  4. 重复上面的步骤,直到cur走出数组范围;
  5. 最后将基准值与prev对应位置交换;
  6. 递归排序以基准值为界限的左右区间。
    请添加图片描述
// 快速排序前后指针法
int PartSort(int* a, int left, int right)
{
    // 选择基准值为数组的第一个元素(left)
    int keyi = left; // 基准值索引
    int prev = left; // 前指针初始化为基准值的位置
    int cur = left + 1; // 后指针初始化为基准值后一个位置

    // 遍历数组,将小于基准值的元素交换到前面
    while (cur <= right)
    {
        if (a[cur] < a[keyi] && ++prev != cur)
        {
            Swap(&a[prev], &a[cur]); // 如果当前元素小于基准值,交换到前面
        }
        cur++; 
    }

    Swap(&a[prev], &a[keyi]); // 将基准值交换到最终位置

    // 选择基准值为数组的最后一个元素(right)
    /*
    int keyi = right; // 基准值索引
    int prev = left - 1; // 前指针初始化为左边界的前一个位置
    int cur = prev + 1; // 后指针初始化为左边界

    // 遍历数组,将小于基准值的元素交换到前面
    while (cur <= right)
    {
        if (a[cur] < a[keyi] && ++prev != cur)
        {
            Swap(&a[cur], &a[prev]); // 如果当前元素小于基准值,交换到前面
        }
        cur++; 
    }

    Swap(&a[keyi], &a[++prev]); // 将基准值交换到最终位置
    */
    
    return prev; 
    
}

void QuickSort(int* a, int left, int right)
{
    assert(a);
	if (left >= right)
	{
		return;	// 如果左边界大于等于右边界,表示已经排序完成。
	}
 
	int keyi = PartSort(a, left, right);	// 对数组进行一次分割操作,获取基准值的最终位置
	QuickSort(a, left, keyi - 1);			// 对基准值左边的子数组进行递归排序
	QuickSort(a, keyi + 1, right);			// 对基准值右边的子数组进行递归排序
}

🕘 7.4 快速排序的优化

快速排序是一种高效的排序算法,但在某些情况下存在缺陷。例如,若基准值选为最小值,则会引发不必要的递归。此外,对于大量有序或近似有序的数据排序时,快速排序效率较为低下,甚至可能导致程序崩溃,因为过多的递归调用可能会造成栈溢出。为了提高效率并避免这些问题,可以采用以下两种优化策略:

  1. 三数取中法选基准值
  2. 递归到小的子区间时,可以考虑使用插入排序

🕤 7.4.1 三数取中

💡 算法思想:即在选择基准值时,不是简单地选择数组的第一个元素或者最后一个元素,而是从当前子数组的第一个元素、中间元素和最后一个元素中选择中间大小的元素作为基准值。这种方法的优势在于:

  • 降低最坏情况的发生概率: 如果每次选取的基准值都是当前子数组中的中间值,那么快速排序在大多数情况下会有较好的性能表现,因为这样可以避免极端情况下分割不均匀的问题。
  • 减少递归深度: 选取较为中间的元素作为基准值,可以更均匀地划分数组,避免出现极端的递归深度,从而减少排序的时间复杂度。
// 三数取中法
int MidIndex(int* a, int left, int right)
{
    int mid = (left + right) / 2;	// 计算中间元素的索引
    // 若要防止整数溢出可以使用以下方式计算mid
    // int mid = left + (right - left) / 2;

    // 比较三个关键位置的元素,选出中间值的索引作为基准值
    if (a[left] < a[right])
    {
        if (a[mid] < a[left])
        {
            return left;
        }
        else if (a[mid] > a[right])
        {
            return right;
        }
        else
        {
            return mid;
        }
    }
    else
    {
        if (a[mid] > a[left])
        {
            return left;
        }
        else if (a[mid] < a[right])
        {
            return right;
        }
        else
        {
            return mid;
        }
    }
}
// 快速排序前后指针法优化
int PartSort(int* a, int left, int right)
{
	int mid = MidIndex(a, left, right);
	// 将基准位置调整至最左边
	Swap(&a[mid], &a[left]);

    // 选择基准值为left
    int keyi = left; 
    int prev = left; 
    int cur = left + 1; 
    ......
}

🕤 7.4.2 小区间优化排序

💡 算法思想:优化排序的时间复杂度,减少不必要的调用递归次数

具体来说,就是在完成一次快速排序递归后,将数据分为左右两个子序列。如果这两个子序列的数据量都较小,就可以直接采用更适合小数据集的排序方法,如插入排序,而不必再进行快速排序的递归操作。这样不仅提高了算法的效率,也优化了递归层次,特别是在数据量较小的情况下更为明显。

// 快速排序小区间优化
void QuickSort(int* a, int left, int right)
{
    assert(a);
	if (left >= right)
	{
		return;	
	}

	// 小区间优化,减少递归次数
	if (right - left + 1 < 10)
	{
		InsertSort(a + left, right -left + 1);
	}
	else
	{
		int keyi = PartSort(a, left, right);
		QuickSort(a, left, keyi - 1);
		QuickSort(a, keyi + 1, right);
	}
}

🕘 7.5 快速排序的非递归实现

💡 算法思想:快速排序的非递归实现依赖于栈来存储待排序的子区间。这种方法不仅避免了递归可能导致的栈溢出问题,而且其核心思想与递归实现是相似的。

具体思路是:

  1. 将数组左右下标入栈;
  2. 若栈不为空,两次取出栈顶元素,分别为闭区间的左右界限;
  3. 将区间中的元素按照上述任意一种方法得到基准值的位置;
  4. 再以基准值为界限,若基准值左右区间中有元素,则将区间入栈;
  5. 重复上述步骤直到栈为空。
void QuickSortNonR(int* a, int left, int right)
{
    // 创建栈
    Stack st;
    StackInit(&st);
 
    // 原始数组区间入栈
    StackPush(&st, right);  // 先将右边界入栈
    StackPush(&st, left);   // 再将左边界入栈
 
    // 将栈中区间排序
    while (!StackEmpty(&st))
    {
        // 弹出栈顶的左右边界
        left = StackTop(&st);
        StackPop(&st);
        right = StackTop(&st);
        StackPop(&st);
        
        // 对当前区间进行划分,得到基准值的位置
        int mid = PartSort(a, left, right);
 
        // 将基准值两侧的子区间入栈,准备下一轮排序
        if (right > mid + 1)
        {
            StackPush(&st, right);  // 右边界入栈
            StackPush(&st, mid + 1);  // 右侧子区间的左边界入栈
        }
        if (left < mid - 1)
        {
            StackPush(&st, mid - 1);  // 左侧子区间的右边界入栈
            StackPush(&st, left);  // 左边界入栈
        }
    }
    
    // 销毁栈
    StackDestroy(&st);
}

快速排序的特性总结:

  1. 快速排序整体的综合性能和使用场景都是比较好的,所以才叫快速排序。
  2. 时间复杂度:最好:O(N*logN) ; 最坏:O(N2)
  3. 空间复杂度:O(logN) ~ O(N)
  4. 稳定性:不稳定

🕒 8. 归并排序

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

请添加图片描述

简单来说,快速排序类似于二叉树的前序遍历:首先选择一个基准值(key),然后不断划分区间,对区间内的元素进行排序,直到整个序列有序。这个过程中,一棵二叉树也随之构建完成。

相对地,归并排序则类似于二叉树的后序遍历:它会持续划分区间,直到区间内元素有序,然后利用额外空间对元素进行排序,并将它们合并回原区间,直至整个序列有序。这个过程中,划分区间相当于达到树的最底层,而归并排序则相当于从树的底层开始向上遍历。

🕘 8.1 递归实现

💡 算法思想:首先,编译器并不知道数组中是否存在有序的序列。因此,可以将数组进行划分,一分为二,二分为四…直至每个序列只剩下一个数字。毕竟,单个数字可以被视为已经排序好的。最终,将这些被划分的序列合并起来。

具体思路是:

  1. 确定递归的结束条件,求出中间数mid;
  2. 进行分解,根据mid来确定递归的区间大小;
  3. 递归分解完左边,然后递归分解右边;
  4. 左右分解完成后,进行合并;
  5. 申请新数组进行合并,比较两个数组段,记得查漏补缺;
  6. 合并的时候要对齐下标,每个tmp的下标要找到array中对应的下标。
void _MergeSort(int* a, int left, int right, int* tmp)
{
    // 区间中没有元素时不再合并
    if (left >= right)
    {
        return;
    }
 
    // 划分数组,每次一分为二
    int mid = (left + right) / 2;
    _MergeSort(a, left, mid, tmp);   // 划分左区间
    _MergeSort(a, mid + 1, right, tmp); // 划分右区间
 
    // 合并有序序列
    int begin1 = left, end1 = mid;      // 有序序列1
    int begin2 = mid + 1, end2 = right; // 有序序列2
    int i = left;				// 辅助数组的起始位置

    // 注意结束条件为一个序列为空时就停止
    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] < a[begin2])
        {
            tmp[i++] = a[begin1++];	// 将较小的元素放入辅助数组中
        }
        else
        {
            tmp[i++] = a[begin2++];	// 将较小的元素放入辅助数组中
        }
    }

    // 两序列不可能同时为空,将剩余元素合并到辅助数组中
    while (begin1 <= end1)
    {
        tmp[i++] = a[begin1++];
    }

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

    // 将辅助数组中排好序的部分拷贝回原数组中
    memcpy(a + left, tmp + left, (right - left + 1) * sizeof(int));
}

// 归并排序递归实现
void MergeSort(int* a, int n)
{
    assert(a);

    // 为归并过程分配一个临时数组,用于存储中间结果
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail!");
        exit(-1);
    }

    // 递归调用归并排序
    _MergeSort(a, 0, n - 1, tmp);

    // 释放临时数组
    free(tmp);
    tmp = NULL;
}

🕘 8.2 非递归实现

💡 算法思想:非递归实现与递归实现的思想相似。区别在于,非递归是从单个元素的组开始,逐步扩大为2个元素、4个元素的组(二倍数扩大组数),即序列划分过程和递归是相反的。如此继续,直至完成所有元素的归并。

在这里插入图片描述

void MergeSortNonR(int* a, int n)
{
    assert(a);

    // 为归并过程分配一个临时数组,用于存储中间结果
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail!"); 
        exit(-1);
    }

    // 初始化每组的元素个数为1
    int gap = 1;

    // 当gap小于数组长度时继续归并
    while (gap < n)
    {
        // 记录tmp数组中的元素下标
        int index = 0;

        // 遍历数组,将每两个gap长度的子数组进行归并
        for (int i = 0; i < n; i += 2 * gap)
        {
            // 归并 取小的尾插
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
            
            // 当原数组中元素个数不是2^n时,最后两组会出现元素不匹配的情况
            // 情况1:第一组越界或第二组全部越界
            if (end1 >= n || begin2 >= n)
            {
                break;
            }
            
            // 情况2:第二组部分越界
            if (end2 >= n)
            {
                end2 = n - 1;	// 修正一下end2,继续归并
            }

            // 归并两个子数组到tmp数组中
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (a[begin1] < a[begin2])
                {
                    tmp[index++] = a[begin1++];
                }
                else
                {
                    tmp[index++] = a[begin2++];
                }
            }

            // 如果第一个子数组还有剩余元素,直接复制到tmp中
            while (begin1 <= end1)
            {
                tmp[index++] = a[begin1++];
            }

            // 如果第二个子数组还有剩余元素,直接复制到tmp中
            while (begin2 <= end2)
            {
                tmp[index++] = a[begin2++];
            }
            
            // 将辅助数组中排好序的部分拷贝回原数组中
    		memcpy(a + i, tmp + i, (end2 - i + 1) * sizeof(int));
        }

        // 每次循环后将每组元素的个数翻倍
        gap *= 2;
    }

    // 释放临时数组的内存
    free(tmp);
    tmp = NULL;
}

归并排序的特性总结:

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

🕒 9. 计数排序

💡 算法思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用,操作步骤:

  1. 统计相同元素出现次数
  2. 根据统计的结果将序列回收到原来的序列中

请添加图片描述

void CountSort(int* a, int n)
{
    assert(a);  

    // 创建计数数组,数组大小为原数组中最大值-最小值+1
    int max = a[0], min = a[0];
    int i = 0;
    for (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* count = (int*)malloc(sizeof(int) * range);
    
    assert(count);

    // 初始化计数数组为0
    memset(count, 0, sizeof(int) * range);

    // 统计每个值出现的次数
    for (i = 0; i < n; i++)
    {
        count[a[i] - min]++;
    }

    // 根据计数数组进行排序
    int j = 0;
    for (i = 0; i < range; i++)
    {
        while (count[i]--)
        {
            a[j++] = i + min;  // 将排序后的值放回原数组
        }
    }

    free(count);
    count = NULL;  
}

注意:计数排序在排负数时,可将负数的类型转化成 unsigned int。

只适合整数,不适合浮点数、字符串等。 数组中元素有正有负的情况时不适用计数排序。

计数排序的特性总结:

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

🕒 10. 排序算法复杂度及稳定性分析

注:在特殊情况下,排序的时间复杂度会发生变化,不能作为判断该排序是否稳定的依据。

排序方法时间复杂度(平均情况)最好情况最坏情况空间复杂度稳定性
直接插入排序O(n2)O(n)O(n2)O(1)稳定
希尔排序O(n(1~2))O(nlog2n)O(n2)O(1)不稳定
直接选择排序O(n2)O(n2)O(n2)O(1)不稳定
堆排序O(nlog2n)O(nlog2n)O(nlog2n)O(1)不稳定
冒泡排序O(n2)O(n)O(n2)O(1)稳定
快速排序O(nlog2n)O(nlog2n)O(n2)O(log2n)不稳定
归并排序O(nlog2n)O(nlog2n)O(nlog2n)O(n)稳定
计数排序O(n+r)O(n+r)O(n+r)O®稳定

注:

  1. 希尔排序的时间复杂度和增量的选择有关。
  2. n 是待排序的元素数量。r 是待排序元素中的最大值加1。

最后我们对各种排序算法进行一个简单的测试:随机测试10000个数据:

void TestOP()
{
	// 用当前时间作为随机数种子,确保每次运行时生成不同的随机数
	srand(time(0));
	const int N = 1000000;
	int* a1 = (int*)malloc(sizeof(int)*N);
	int* a2 = (int*)malloc(sizeof(int)*N);
	int* a3 = (int*)malloc(sizeof(int)*N);
	int* a4 = (int*)malloc(sizeof(int)*N);
	int* a5 = (int*)malloc(sizeof(int)*N);
	int* a6 = (int*)malloc(sizeof(int)*N);
	int* a7 = (int*)malloc(sizeof(int)*N);
	int* a8 = (int*)malloc(sizeof(int)*N);

	for (int i = 0; i < N; ++i)
	{
		//a1[i] = rand();
		a1[i] = rand()+i;
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
		a5[i] = a1[i];
		a6[i] = a1[i];
		a7[i] = a1[i];
		a8[i] = a1[i];
	}

	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();

	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	int begin3 = clock();
	SelectSort(a3, N);
	int end3 = clock();

	int begin4 = clock();
	HeapSort(a4, N);
	int end4 = clock();

	int begin5 = clock();
	BubbleSort(a5, 0, N - 1);
	int end5 = clock();

	int begin6 = clock();
	QuickSort(a6, 0, N - 1);
	int end6 = clock();

	int begin7 = clock();
	MergeSort(a7, N);
	int end7 = clock();

	int begin8 = clock();
	CountSort(a8, N);
	int end8 = clock();
	
	// 输出每个排序算法的执行时间(以毫秒为单位)
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);
	printf("BubbleSort:%d\n", end5 - begin5);
	printf("QuickSort:%d\n", end6 - begin6);
	printf("MergeSort:%d\n", end7 - begin7);
	printf("CountSort:%d\n", end8 - begin8);

	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
	free(a8);
}

在这里插入图片描述


❗ 转载请注明出处
作者:HinsCoder
博客链接:🔎 作者博客主页

【数据结构】七大排序算法(超详细) 本文详细介绍了数据结构中的七大排序算法 阅读详情

相关推荐

【数据结构】八大排序详解

排序就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前(相同的数据,保证排序前后它们的相对位置不变),则称这种排序算法是稳定的;否则称为不稳定的。内部排序:数据元素全部放在内存中的排序。外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据……

不 良的博客 2457

数据结构——【排序】详解

一. 排序💨 1.1 基本概念 排序(sorting)又称分类,就是将一组任意序列得数据元素按一定得规律进行排列(按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作),使之成为有序序列。 平时的上下文中,如果提到排序,通常指的是排升序(非降序)。 通常意义上的排序,都是指的原地排序(in place sort)。 1.2 稳定性 定义:数组 arr 中有若干元素,其中 A元素和 B元素相等,并且 A元素在 B元素前面,如果使用某种排序算法排序后,能够保证 A元素依然在 B元素的前面,可

Lucky_mzc的博客 1万+

【数据结构和算法】 八大排序算法详解

???? 作者:Linux猿 ???? 简介:优快云博客专家????,C/C++、面试、刷题、算法尽管咨询我,关注我,有问题私聊! ???? 关注专栏:C/C++面试通关集锦(优质好文持续更新中……)???? 目录 一、冒泡排序 1. 算法思想 2. 实例演示 3. 代码实现 4. 算法复杂度 二、选择排序 1. 算法思想 2. 实例演示 3. 代码实现 4. 算法复杂度 三、快速排序 1. 算法思想 2. 实例演示 3. 代码实现 4. 算法复杂度 四、归并排序

Muti-Agent 7682

数据结构——排序

数据结构Java 排序简单排序(冒泡、插入、选择),高级排序(希尔、递归、快速),排序的稳定性

m0_51530927的博客 4245

NVIDIA B300 vs B200:详细解析与性能对决

详细解析 NVIDIA B300 整机)的性能,并与前代的 B200 进行对比。它不仅在核心计算能力(FP8)上实现了飞跃,更重要的是通过革命性的第二代 Transformer Engine 和第五代 NVLink 技术,彻底重塑了大规模 AI 模型训练和推理的效率与规模上限。其显存容量和带宽的巨大提升也为处理超大型模型和数据提供了坚实基础。与 B200 相比,B300 是一次全面的、代际性的性能跃升,特别适合那些需要处理最前沿、最复杂 AI 和科学计算任务的用户和组织。

2401_89353317的博客 3044

单细胞测序流程(八)单细胞的marker基因转化和​GO富集分析

系列文章目录 单细胞测序流程(一)简介与数据下载 单细胞测序流程(二)数据整理 单细胞测序流程(三)质控和数据过滤——Seurat包分析,小提琴图和基因离差散点图 单细胞测序流程(四)主成分分析——PCA 单细胞测序流程(五)t-sne聚类分析和寻找marker基因 单细胞测序流程(六)单细胞的细胞类型的注释 单细胞测序流程(七)单细胞的细胞类型轨迹分析———————————————— 本期主讲内容——单细胞的maeker基因转化和​富集分析 marker基因转化是为了将基因的id和基.

qq_45478665的博客 1万+

【数据结构】常见八大排序算法(附动图)

本文围绕八种常见排序算法展开讲解,包含算法思想、动图、源代码和对算法的优化等

Eristic0618的博客 9993

从零开始构建量化价值投资多因子模型

量化价值投资多因子模型的构建目的在于通过系统性的方法,综合考虑多个影响股票价值的因素,以更准确地评估股票的投资价值,为投资者提供科学的投资决策依据。本模型的构建范围涵盖了从因子的选取、数据的处理、模型的建立到模型的评估和优化等一系列过程。通过对历史数据的分析和回测,验证模型的有效性和稳定性,进而应用于实际的投资组合管理中。本文将按照以下结构进行组织:首先介绍量化价值投资和多因子模型的相关背景知识,包括核心概念和联系;接着详细阐述核心算法原理和具体操作步骤,并给出Python代码示例;

AI天才研究院 1240

数据结构——排序算法总结(八个)

内排序:指在排序期间数据对象所有存放在内存的排序。外排序:指在排序期间所有对象太多,不能同一时候存放在内存中,必须依据排序过程的要求,不断在内,外存间移动的排序。

qq_34252068的博客 7956

TTL转RS422(B)工业级转换模块设计:从原理到实战部署

在工业自动化与嵌入式系统中,串行通信是实现设备间数据交换的基础。其核心原理在于通过特定的电气标准与协议,将数据位序列化后进行传输。RS422作为一种差分平衡传输协议,相比常见的TTL电平,在抗共模干扰与远距离传输能力上具有显著的技术优势,这使其成为工业现场总线、楼宇自动化等对可靠性要求严苛场景的优选方案。要实现TTL设备与RS422网络的稳定互联,关键在于设计一个能够完成单端信号到差分信号高质量转换的硬件模块。这涉及到差分总线收发器芯片的选型、包含终端电阻与TVS二极管的保护电路设计,以及遵循差分走线规则的

weixin_29195615的博客 341

IDEA集成selenium自动化测试(Java+Selenuim+testNG+Maven)

文章目录写在前面01 | 新建Maven项目02 | idea集成Selenium03 | 写在前面 因为软件测试的需求和博主比较经常使用Java,于是,想在Idea中集成selenuim,用Java编写测试代码,因此有了这篇文章来记录一下过程。 资源指路: java使用selenium实现模拟浏览器操作API大全 模拟登录 TestNG入门教程 感谢上面博客的大佬。 01 | 新建Maven项目 不使用骨架创建,直接点击左侧Maven,然后一路next即可。 02 | idea集成Sele

aLinxi的博客 8279

《数据结构初阶》【八大排序——群英荟萃】

博客内容主要划分为:八大排序算法的介绍+八大排序算法的实现。全篇共计1.5w字,涉及的排序算法有:直接插入排序、希尔排序、简单选择排序、堆排序、冒泡排序、快速排序、归并排序和计数排序。(博主力推,内容绝对详实!!!)

序属秋秋秋的博客 2396

论文代码复现 | 无人机与卡车联合配送(Python+Gurobi)(The flying sidekick traveling salesman problem)

无人机配送-代码复现The flying sidekick traveling salesman problem-Part 1文献笔记:(无人机配送-代码复现)The flying sidekick traveling salesman problem-Part 1摘要论文主要图标摘录数值实验算例结果参考文献 文献笔记:(无人机配送-代码复现)The flying sidekick traveling salesman problem-Part 1 摘要 Once limited to the milit

HsinglukLiu的博客 3万+

【数据结构】八大排序

八大排序

2203_76003626的博客 3483

【Qt开发教程】网络代理QNetworkProxy类详解及实战应用

QNetworkProxy 提供了一种灵活而强大的方式来配置和管理网络代理,通过设置代理类型、主机、端口和认证信息,可以使应用程序的所有网络请求通过代理服务器进行。结合 QNetworkProxyFactory 可以实现动态代理选择和自动配置,提高应用的网络通信能力和安全性。在实际应用中,通过合理设置代理、处理代理认证和错误情况,可以确保应用程序在复杂的网络环境中稳定运行,为用户提供可靠的网络访问体验。

科技改变人类,技术成就未来 1542

【产品经理修炼之道】- 基金产品-“TA系统 ”

TA:Transfer Agent 缩写,通常称成“登记过户”。开放式基金登记过户(TA)系统是基金的登记过户系统。其功能涵盖了基金交易业务的各个环节,包括账户业务处理,如投资者开户、修改账户资料、挂失/解挂等;日常交易业务处理,如认购、申购(一般申购和定期定额)、赎回(一般赎回、指定赎回、预约赎回等)、转托管、基金转换等;特殊业务处理,如非交易过户、账户冻结/解冻等;分红业务处理,如现金分红和红利再投资。

xiaoli8748的专栏 1677

《数据结构初阶》【八大排序——巅峰决战】

博客内容主要划分为:八大排序算法的排序竞赛+八大排序算法的性能分析。全篇共计1.4w多字,涉及的排序算法有:直接插入排 序、希尔排序、简单选择排序、堆排序、冒泡排序、快速排序、归并排序和计数排序。(博主力推,内容绝对详...

序属秋秋秋的博客 2383

温度PID控制PLC程序

温度PID稳定控制程序,适用于欧姆龙PLC,对其他PLC也具有很大参考价值

[数据结构] 八大排序

其基本思想是选择一个基准值,通过一趟排序将待排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据要小,然后再递归地对这两部分进行排序,以达到整个数据变成有序序列。是一种简单的排序算法。堆排序的平均时间复杂度为。第二步:cur开始往后走,如果遇到比key小的值,则++prev,然后交换prev和cur指向的元素,再++cur,如果遇到比key大的值,则只++cur。,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。

DerrictC的博客 2022

win10 跳过U8环境检测的IE WebControls.rar

win10 安装u8的时候 提示IE WebControls 不通过,这个工具可以欺骗u8的检查。 跳过U8环境检测的IE WebControls。提示导入失败后,重新检测通过。

锂电池充电器电路设计 里面有电路

锂电池充电器电路设计 里面有电路 3.7V升压到5V电路设计。

数据结构-八大排序详解(动图+实现详解+总结)

数据结构-八大排序详解(动图+实现详解+总结)

吴名氏的博客 4万+

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

基础数据结构——八大排序详解

1、排序的分类: 内排序:排序的整个过程中,待排序的所有记录全部放在内存中 外排序:由于排序的记录个数太多,不能同时放在内存,整个排序需要在内外存之间交换数据才能进行

仟各的博客 9544

【数据结构与算法】八大排序

对于排序的了解一定要理解思想,能够很清楚它的时间复杂度和空间复杂度,稳定性等特性。稳定的排序有:直接插入排序、冒泡排序、归并排序不稳定的排序有:希尔排序、选择排序、堆排序、快速排序、计数排序。

比昨天强一点就好的博客 3908

【数据结构基础】之八大排序(C语言实现)

算法和数据结构是有一定的关联的,八大排序算法在数据结构里算是比较重要的一个部分,今天本篇博客将介绍八大排序算法的原理和实现。

wszhj_的博客 1722

数据结构之八大排序总结

1、排序的概念:排序就是将一组数据按照一定的顺序,递增或递减排列起来。 2、排序的稳定性:对于两个关键字相等的记录,它们在序列中的相对位置,在排序之前和经过排序之后,没有改变。 3、内部排序与外部排序:内部排序指的是排序时需要将全部数据加载到内存中;外部排序是指所要排序的数据太大了不能同时在内存中进行。 4、八大排序:插入排序、希尔排序、选择排序、快速排序、冒泡排序、堆排序、归并排序、计数排...

一个金牛座 的程序员 8825

Java数据结构——八大排序

Java数据结构中的八大排序,直接插入排序,希尔排序,直接选择排序,堆排序,冒泡排序,快速排序,归并排序,计数排序

2401_89019969的博客 3033

数据结构——八大排序

该方法就是key指向左端点值,prev也指向左端点,cur指向prev的下一个位置,然后cur开始向后遍历,遇到比key小的数后,prev往前走一步,然后交换cur和prev,然后cur继续走,当cur指向序列之外时,此时prev指向的数据就已经有序了,返回该值作为区分区间的依据。直接插入排序的逻辑就是插入排序的逻辑,我们根据动图分析,我们先将第一个数3看作为有序,然后将后面的数与其进行比较,如果小于则3往后挪,将后面的数插到前面,否则就不动,此时这两个数就有序了。当只剩一个区间时,就说明已经排序完成了。

xsc2004zyj的博客 2713
上一篇: 【数据结构详解】——计数排序(动图详解)
下一篇: 【手撕OJ题】——876. 链表的中间结点
HinsCoder
博客等级 码龄5年 1万+粉丝 220原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值