C语言实现-排序2

在这里插入图片描述

🎯引言

在算法设计与实现中,排序算法是最为基础且重要的内容之一。在各种排序算法中,快速排序、归并排序和计数排序各有其独特的优势和应用场景。快速排序以其平均情况下的高效性著称,是很多实际应用中的首选;归并排序则以其稳定性和适用于大规模数据的能力受到广泛关注;而计数排序在特定条件下能够以线性时间完成排序,适用于数据范围较小的整数序列。本文将深入探讨这三种经典排序算法的原理、实现以及它们各自的应用场景,以帮助读者更好地理解并应用这些算法。

👓快速排序、归并排序、计数排序

1.快速排序

1.1快速排序递归实现

快速排序(QuickSort)是一种基于分治法的高效排序算法。其核心思想是通过选择一个基准值(pivot),将待排序的数组分成两个部分,使得基准值左边的元素都小于或等于它,右边的元素都大于或等于它,然后递归地对左右两个部分进行排序,最终达到整个数组有序的效果。

图示:

在这里插入图片描述

1.2三种找基准值的函数实现:

1.2.1Hoare版本

Hoare版本的核心思想是通过一对指针从数组的两端向中间移动,逐步将小于基准值的元素移到左侧,大于基准值的元素移到右侧,最后将基准值放置在正确的位置。以下是对这段代码的详细解释:

void Swap(int* a1, int* a2)
{
	int temp = *a1;
	*a1 = *a2;
	*a2 = temp;
}

int _QuickSort1(int* arr, int left, int right)
{
	int key = arr[left];  // 基准值设为数组的第一个元素
	int start = left + 1; // 左指针从基准值的下一个元素开始
	int end = right;      // 右指针从数组的最右端开始

	while (start <= end)
	{
		// 从右侧向左扫描,找到第一个小于或等于基准值的元素
		while (start <= end && arr[end] > key)
		{
			end--;
		}

		// 从左侧向右扫描,找到第一个大于或等于基准值的元素
		while (start <= end && arr[start] < key)
		{
			start++;
		}

		// 如果左指针未超出右指针,则交换这两个不符合基准值位置的元素
		if (start <= end)
		{
			Swap(&arr[end], &arr[start]);
			start++; // 左指针右移,继续扫描
			end--;   // 右指针左移,继续扫描
		}
	}

	// 最后,将基准值与右指针位置的元素交换
	Swap(&arr[left], &arr[end]);

	return end; // 返回右指针的位置,这个位置是基准值的最终位置
}

步骤详解

  1. 选择基准值:
    • 基准值 (key) 选取为数组的第一个元素 arr[left]
  2. 初始化指针:
    • 左指针 start 初始化为基准值的下一个位置,即 left + 1
    • 右指针 end 初始化为数组的最右端,即 right
  3. 扫描并交换元素:
    • 通过 while循环,左右指针同时向中间移动,直到 start > end。在每次循环中:
      • 右指针向左移动,直到找到一个小于或等于基准值的元素。
      • 左指针向右移动,直到找到一个大于或等于基准值的元素。
      • 如果左指针未超出右指针,则交换左右指针所在位置的元素,并继续扫描。
  4. 放置基准值:
    • 最终,基准值与右指针所在位置的元素交换,这样基准值就被放置在正确的位置。
  5. 返回分区点:
    • 函数返回右指针的位置 (end),该位置就是基准值在整个数组中的最终位置。这一位置将用于递归调用快速排序函数,以对左右两部分继续进行排序。
1.2.2挖坑法

挖坑法通过在数组中“挖坑”,依次将基准值两侧的元素填入坑中,直到最终将基准值放入正确的位置。以下是对这段代码的详细解释:

int _QuickSort2(int* arr, int left, int right)
{
	int hore = left;      // 初始坑的位置,设为数组的第一个元素位置
	int key = arr[left];  // 基准值设为数组的第一个元素

	while (left < right)
	{
		// 从右侧向左扫描,寻找第一个小于基准值的元素
		while (left < right && arr[right] >= key)
		{
			right--;
		}
		// 将找到的元素填入左边的坑中
		arr[hore] = arr[right];
		hore = right; // 更新坑的位置

		// 从左侧向右扫描,寻找第一个大于基准值的元素
		while (left < right && arr[left] <= key)
		{
			left++;
		}
		// 将找到的元素填入右边的坑中
		arr[hore] = arr[left];
		hore = left; // 更新坑的位置
	}

	// 最后将基准值填入最后一个坑中
	arr[hore] = key;

	return hore; // 返回坑的位置,也就是基准值的最终位置
}

步骤详解

  1. 初始化:
    • hore 表示当前“坑”的位置,初始设为 left,即数组的第一个位置。
    • key 为基准值,选取数组的第一个元素 arr[left]
  2. 右侧扫描并填坑:
    • 通过 while (left < right) 循环不断收缩 leftright 之间的范围。
    • 首先,从右向左扫描 (right--),寻找第一个小于基准值 key 的元素,并将其填入当前“坑” (arr[hore]) 中,然后将 hore 更新为 right
  3. 左侧扫描并填坑:
    • 接下来,从左向右扫描 (left++),寻找第一个大于基准值 key 的元素,并将其填入当前“坑” (arr[hore]) 中,然后将 hore 更新为 left
  4. 最终基准值归位:
    • leftright 相遇时,最终会形成一个“坑”,这个位置即是基准值的最终位置。将基准值 key 放入这个位置。
  5. 返回分区点:
    • 函数返回 hore 的位置,这个位置就是基准值 key 在数组中的正确位置。这个位置将用于递归调用快速排序函数,对左、右两部分继续进行排序。

关键点分析

  • 坑的概念: 挖坑法的核心在于“坑”的概念。通过在数组中找到不满足条件的元素,将它们依次填入当前的坑,最后形成一个新坑,将基准值放入这个坑中。
  • 双向扫描: 代码使用双向扫描法,一次从右向左,再一次从左向右,这样能够在每次循环中有效地将不符合条件的元素移到正确的一侧。
  • 稳定性: 挖坑法并不保证排序的稳定性,因为相同的元素在分区过程中可能会被移动。
1.2.3Lomuto分区法

Lomuto分区法相较于Hoare分区法更加简洁,通过两个指针的移动和交换,将基准值左边的所有元素移到数组的左侧。以下是对这段代码的详细解释:

int _QuickSort3(int* arr, int left, int right)
{
	int prev = left;       // 初始化前指针,指向数组的第一个元素
	int cur = left + 1;    // 初始化当前指针,指向数组的第二个元素
	int key = arr[left];   // 基准值设为数组的第一个元素

	while (cur <= right)
	{
		// 如果当前元素小于基准值,将当前元素与前指针指向的元素交换位置
		if (arr[cur] < key && ++prev != cur)
		{
			Swap(&arr[cur], &arr[prev]);
		}
		cur++; // 移动当前指针到下一个位置
	}

	// 最后将基准值放置在前指针指向的位置上
	Swap(&arr[prev], &arr[left]);

	return prev; // 返回基准值的最终位置
}

步骤详解

  1. 初始化:

    • prev 指向数组的第一个元素 (left),这是前指针,负责标记已处理区的最后一个位置。
    • cur 指向数组的第二个元素 (left + 1),这是当前指针,负责扫描数组中剩余的元素。
    • key 为基准值,选取数组的第一个元素 arr[left]
  2. 扫描数组:

    • 使用 while (cur <= right) 循环遍历数组中从 curright 的所有元素。
    • 如果 arr[cur] < key(当前元素小于基准值),则:
      • prev 向右移动一位 (++prev)。
      • 如果 prev 不等于 cur,说明当前元素需要交换到前面的部分,则交换 arr[cur]arr[prev] 的值。
    • 如果 arr[cur] >= key
      • cur++
  3. 交换基准值:

    • 遍历结束后,所有小于基准值的元素都被移到数组的左侧。此时,前指针 prev 指向的元素即为最后一个小于基准值的元素。
    • 将基准值 arr[left]arr[prev] 交换,这样基准值就被放置在正确的位置上。
  4. 返回分区点:

    • 函数返回 prev,即基准值的最终位置。这个位置将用于递归调用快速排序函数,对左、右两部分继续进行排序。

1.3快速排序递归代码

void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	// 找到基准值的位置
	int mid = _QuickSort3(arr, left, right);

	// 递归排序基准值左侧的子数组
	QuickSort(arr, left, mid - 1);
	
	// 递归排序基准值右侧的子数组
	QuickSort(arr, mid + 1, right);
}

步骤详解

  1. 递归结束条件:
    • 首先检查 left 是否大于或等于 right。如果是,则表示子数组长度为 1 或无效(left >= right),此时无需继续排序,直接返回。
    • 这个条件也是递归函数的基线条件,确保递归不会无限进行。
  2. 分区操作:
    • 调用 _QuickSort3(arr, left, right),将数组 arr 的部分从 leftright 进行分区。这个函数返回基准值在排序后的数组中的最终位置 mid
    • _QuickSort3 中,基准值被放置在了正确的位置上,确保了基准值左边的所有元素都小于等于它,右边的所有元素都大于等于它。
  3. 递归调用:
    • 对基准值左侧的子数组(arr[left...mid-1])调用 QuickSort 进行递归排序。
    • 对基准值右侧的子数组(arr[mid+1...right])调用 QuickSort 进行递归排序。
  4. 递归过程:
    • 递归调用继续分解子数组,直到每个子数组的长度为1或0,即 left >= right。此时递归结束,并逐层返回,最终完成整个数组的排序。

1.4快速排序非递归实现

实现需要借助栈,可以通过之前我写的实现栈的博客,讲栈的相关代码拷贝过来,其实非递归的本质,是通过栈来模拟递归的实现。

void QuickSortNonR(int* a, int left, int right)
{
	// 初始化栈
	Stack s1;
	StackInit(&s1);

	// 首次调用分区函数
	int mid = _QuickSort2(a, left, right);

	// 将右侧子数组的边界压入栈中
	StackPush(&s1, right);
	StackPush(&s1, mid + 1);

	// 将左侧子数组的边界压入栈中
	StackPush(&s1, mid - 1);
	StackPush(&s1, left);

	// 循环处理栈中的子数组
	while (!IsEmpty(&s1))
	{
		// 从栈中取出子数组的边界
		left = StackTop(&s1);
		StackPop(&s1);

		right = StackTop(&s1);
		StackPop(&s1);
		
		// 对当前子数组进行分区
		mid = _QuickSort2(a, left, right);

		// 如果右侧子数组还有元素,继续处理
		if (mid + 1 < right)
		{
			StackPush(&s1, right);
			StackPush(&s1, mid + 1);
		}

		// 如果左侧子数组还有元素,继续处理
		if (mid - 1 > left)
		{
			StackPush(&s1, mid - 1);
			StackPush(&s1, left);
		}
	}

	// 销毁栈,释放资源
	StackDestory(&s1);
}

步骤详解

  1. 初始化栈:
    • 首先,使用 StackInit(&s1) 初始化一个栈 s1。这个栈将用来保存子数组的边界,以模拟递归过程。
  2. 初始分区:
    • 调用 _QuickSort2(a, left, right) 对整个数组进行初次分区,并得到基准值的位置 mid
  3. 压栈操作:
    • 将初次分区得到的左、右子数组的边界分别压入栈中:
      • 首先压入右子数组的边界 [mid + 1, right]
      • 然后压入左子数组的边界 [left, mid - 1]
    • 栈中保存的顺序是右子数组在栈顶,左子数组在栈底,这样确保在非递归的过程中先处理左子数组。
  4. 循环处理栈中的子数组:
    • while (!IsEmpty(&s1)) 循环中,不断从栈中取出子数组的边界进行处理。
    • 每次从栈中取出边界 [left, right],然后对该范围内的数组进行分区。
  5. 判断并压栈:
    • 分区后,如果右子数组 [mid + 1, right] 还有元素,则将它的边界压入栈中,等待后续处理。
    • 同样地,如果左子数组 [left, mid - 1] 还有元素,也将它的边界压入栈中。
    • 通过这种方式,逐步处理整个数组,最终实现排序。
  6. 栈的销毁:
    • 当所有子数组都处理完毕后,栈为空,退出循环,最后销毁栈 StackDestory(&s1),释放资源。

关键点分析

  • 非递归实现: 通过使用栈来保存待处理的子数组边界,避免了递归调用,从而实现了快速排序的非递归版本。这对于避免递归过深带来的栈溢出问题非常有用。
  • 栈的使用: 栈的使用模拟了递归的过程,使得算法仍然遵循“分治法”的思想,逐步分解问题并解决。

2.归并排序

2.1归并排序递归实现

图解:

在这里插入图片描述

归并排序(Merge Sort)是一种基于分治法的排序算法,通过将数组分解成更小的部分,对每部分进行排序,然后合并排序结果,最终实现整个数组的排序。下面是对提供的归并排序代码的详细解析:

void _MergeSort(int* a, int* temp, int left, int right)
{
	// 递归终止条件
	if (left >= right)
	{
		return;
	}

	// 计算中间位置
	int mid = (left + right) / 2;

	// 递归排序左右子数组
	_MergeSort(a, temp, left, mid);
	_MergeSort(a, temp, mid + 1, right);

	// 合并两个排序好的子数组
	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;

	int i = begin1;
	for (i = begin1; i <= right; i++)
	{
		if (begin1 > end1 || begin2 > end2)
		{
			break;
		}

		if (a[begin1] <= a[begin2])
		{
			temp[i] = a[begin1];
			begin1++;
		}
		else
		{
			temp[i] = a[begin2];
			begin2++;
		}
	}

	// 将左半部分剩余的元素拷贝到temp数组中
	while (begin1 <= end1)
	{
		temp[i] = a[begin1];
		begin1++;
		i++;
	}

	// 将右半部分剩余的元素拷贝到temp数组中
	while (begin2 <= end2)
	{
		temp[i] = a[begin2];
		begin2++;
		i++;
	}

	// 将临时数组中的数据拷贝回原数组
	for (int j = left; j <= right; j++)
	{
		a[j] = temp[j];
	}
}

void MergeSort(int* a, int n)
{
	// 创建临时数组用于合并操作
	int* temp = (int*)malloc(sizeof(int) * n);

	int left = 0;
	int right = n - 1;
	_MergeSort(a, temp, left, right);

	// 释放临时数组的内存
	free(temp);
}

步骤详解

  1. 初始化和内存分配:
    • MergeSort 函数初始化临时数组 temp,该数组的大小与原数组 a 相同,用于在合并过程中存储中间结果。
  2. 递归分解:
    • _MergeSort 函数通过递归将待排序数组分解成越来越小的子数组,直到每个子数组的长度为1(或为空)。这种分解通过计算 mid = (left + right) / 2 来确定中间位置,并分别对左半部分 [left, mid] 和右半部分 [mid + 1, right] 进行递归排序。
  3. 合并操作:
    • 合并操作的目标是将两个已排序的子数组 [left, mid][mid + 1, right]合并成一个大的已排序子数组。合并的具体步骤如下:
      • 设置指针 begin1begin2 分别指向两个子数组的开始位置,end1end2 分别指向两个子数组的结束位置。
      • 使用一个循环将两个子数组中的元素比较,并将较小的元素拷贝到 temp 数组中。指针 begin1begin2 会在循环过程中向后移动。
      • 将左半部分和右半部分剩余的元素分别拷贝到 temp 数组中。如果一个子数组已经处理完毕,另一个子数组可能还有剩余元素。
      • 最后,将临时数组 temp 中的排序结果拷贝回原数组 a 的对应位置。
  4. 清理内存:
    • MergeSort 函数结束时,释放临时数组 temp 的内存以避免内存泄漏。

2.2归并排序非递归实现

归并排序的非递归实现(也称为迭代实现)通过使用逐步增加的间隔(gap)来合并已排序的子数组,而不是通过递归来实现。下面是对提供的非递归归并排序代码的详细解析:

void MergeSortNonR(int* a, int n)
{
    // 初始化间隔为1
    int gap = 1;
    // 动态分配内存,用于存储合并后的结果
    int* temp = (int*)malloc(sizeof(int) * n);

    // 当间隔小于数组长度时进行合并
    while (gap < n)
    {
        // 遍历数组,合并两个相邻的子数组
        for (int m = 0; m < n; m += 2 * gap)
        {
            // 确定区间[m,m+gap-1] [m+gap,m+2*gap-1]
            // 确定第一个子数组的起始和结束位置
            int begin1 = m, end1 = m + gap - 1;
            // 确定第二个子数组的起始和结束位置
            int begin2 = m + gap, end2 = m + 2 * gap - 1;

            // 如果第一个子数组的结束位置或第二个子数组的开始位置超出数组边界,则停止合并
            if (end1 >= n || begin2 >= n)
            {
                break;
            }

            // 如果第二个子数组的结束位置超出数组边界,调整结束位置
            if (end2 >= n)
            {
                end2 = n - 1;
            }

            // 合并两个已排序的子数组
            int i = begin1;
            for (i = begin1; i <= end2; i++)
            {
                // 如果第一个子数组或第二个子数组的指针超出范围,停止合并
                if (begin1 > end1 || begin2 > end2)
                {
                    break;
                }

                // 将较小的元素拷贝到临时数组中
                if (a[begin1] < a[begin2])
                {
                    temp[i] = a[begin1];
                    begin1++;
                }
                else
                {
                    temp[i] = a[begin2];
                    begin2++;
                }
            }

            // 将第一个子数组剩余的元素拷贝到临时数组中
            while (begin1 <= end1)
            {
                temp[i] = a[begin1];
                begin1++;
                i++;
            }

            // 将第二个子数组剩余的元素拷贝到临时数组中
            while (begin2 <= end2)
            {
                temp[i] = a[begin2];
                begin2++;
                i++;
            }

            // 将临时数组中的排序结果拷贝回原数组
            for (int j = m; j <= end2; j++)
            {
                a[j] = temp[j];
            }
        }

        // 将间隔加倍,以便在下一轮合并中处理更大的子数组
        gap *= 2;
    }

步骤详解

  1. 初始化和内存分配:
    • gap 变量用于控制每次合并的子数组间隔,初始值为1。
    • 动态分配内存创建临时数组 temp,该数组用于存储合并后的结果。
  2. 逐步合并子数组:
    • 使用 while (gap < n) 循环来逐步增加合并的子数组的间隔,直到 gap 达到数组长度。
    • for (int m = 0; m < n; m += 2 * gap) 循环中,m 指定每次合并的起始位置,并将数组分成两个部分进行合并。
  3. 确定子数组的边界:
    • 对于每次合并操作,确定左子数组 [begin1, end1] 和右子数组 [begin2, end2] 的边界。
    • 如果右子数组的开始位置 begin2 超出了数组边界,则停止合并。
  4. 合并两个子数组:
    • 使用两个指针 begin1begin2 分别指向两个子数组的开始位置,将较小的元素拷贝到 temp 数组中。
    • 将较小元素的指针向后移动,直到其中一个子数组的元素处理完毕。
  5. 拷贝剩余的元素:
    • 如果左子数组 [begin1, end1] 中还有剩余元素,则将它们拷贝到 temp 数组中。
    • 如果右子数组 [begin2, end2] 中还有剩余元素,也将它们拷贝到 temp 数组中。
  6. 将合并结果拷贝回原数组:
    • temp 数组中的排序结果拷贝回原数组 a 的相应位置 [m, end2]
  7. 更新间隔:
    • 每次合并完成后,将 gap 乘以2,以便进行下一轮合并操作。这样,gap 会逐步增大,直到处理整个数组。
  8. 释放内存:
    • 最后,释放临时数组 temp 的内存,以避免内存泄漏。

3.计数排序

计数排序是一种线性时间复杂度的非比较排序算法,适用于排序值域较小的整数集合。它通过统计每个元素出现的次数,进而直接定位元素的位置,完成排序。这种排序算法特别适合对大量重复的元素进行排序。以下是代码的详细解析:

void CountSort(int* a, int n)
{
	int min = a[0];
	int max = a[0];

	// 找到数组中的最小值和最大值
	for (int i = 1; i < n; i++)
	{
		if (a[i] < min)
		{
			min = a[i];
		}

		if (a[i] > max)
		{
			max = a[i];
		}
	}

	// 计算范围 (max - min + 1)
	int range = max - min + 1;
	// 创建计数数组,并初始化为0
	int* arr = (int*)malloc(sizeof(int) * range);
	memset(arr, 0, sizeof(int) * range);

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

	// 根据计数数组重新排列原数组
	int index = 0;
	for (int j = 0; j < range; j++)
	{
		while (arr[j] != 0)
		{
			a[index++] = j + min;
			arr[j]--;
		}
	}

	// 释放动态分配的内存
	free(arr);
}

步骤详解

  1. 找出最小值和最大值:
    • 首先遍历整个数组,找到数组中的最小值 min 和最大值 max
    • 这是为了确定待排序数组中元素的值域(range),即从 minmax 之间的范围。
  2. 初始化计数数组:
    • 计算出 range = max - min + 1,这个范围决定了计数数组 arr 的大小。
    • 动态分配内存创建计数数组 arr,并将其初始化为0。计数数组的每个索引位置用于存储对应值出现的次数。
  3. 统计每个元素的出现次数:
    • 再次遍历原数组,对于每个元素 a[i],在计数数组 arr[a[i] - min] 对应的位置加1。这一步记录了数组中每个值的出现次数。
  4. 重新排列原数组:
    • 遍历计数数组 arr,根据记录的次数将元素按顺序写回到原数组 a 中。
    • 例如,如果 arr[j] 的值为3,表示值 j + min 在原数组中出现了3次,因此将 j + min 写入原数组3次。
  5. 释放内存:
    • 最后,释放动态分配的计数数组 arr,避免内存泄漏。

🥇结语

通过对快速排序、归并排序和计数排序的分析与比较,我们可以看到,每种算法都有其独特的优势和适用领域。在实际开发中,选择合适的排序算法至关重要,需要综合考虑数据规模、数据类型以及排序的稳定性要求。希望通过本文的介绍,读者能够更好地理解这些算法的特性,并在不同的应用场景中作出最佳选择。无论是在学术研究还是实际工程中,掌握这些经典排序算法,都是提升编程能力的关键一步。

  • 38
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 10
    评论
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值