排序算法

排序的概念及其运用

排序的概念

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

常见的排序算法

cpp 复制代码
// 排序实现的接口
// 插入排序
void InsertSort(int* a, int n);
// 希尔排序
void ShellSort(int* a, int n);
// 选择排序
void SelectSort(int* a, int n);
// 堆排序
void AdjustDwon(int* a, int n, int root);
void HeapSort(int* a, int n);
// 冒泡排序
void BubbleSort(int* a, int n)
// 快速排序递归实现
// 快速排序hoare版本
int PartSort1(int* a, int left, int right);
// 快速排序挖坑法
int PartSort2(int* a, int left, int right);
// 快速排序前后指针法
int PartSort3(int* a, int left, int right);
void QuickSort(int* a, int left, int right);
// 快速排序 非递归实现
void QuickSortNonR(int* a, int left, int right)
// 归并排序递归实现
void MergeSort(int* a, int n)
// 归并排序非递归实现
void MergeSortNonR(int* a, int n)
// 计数排序
void CountSort(int* a, int n)

常见排序算法的实现

1****插入排序

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

直接插入排序:
当插入第i(i>=1)个元素时,前面的array[0],array[1],...,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],...的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
直接插入排序的特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1),它是一种稳定的排序算法
  4. 稳定性:稳定
cpp 复制代码
//升序
void insertsort(int* a, int n)
{
	for (int i = 1; i < n; i++)
	{
		int end=i-1;
		int tmp=a[i];
		//将tmp插入到[0,end]区间中,保持有序
		while (end >= 0)
		{
			if (tmp < a[end])
			{
				a[end + 1] = a[end];
				--end;
			}
			else
			{
				break;
			}
		}
		a[end + 1] = tmp;
	}
	
}

2、希尔排序**(缩小增量排序)**

希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达**=1****时,所有记录在统一组内排好序**。

希尔排序的特性总结:

  1. 希尔排序是对直接插入排序的优化。
  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定。
    希尔排序时间复杂度可大约记为O(N^1.3)。
cpp 复制代码
void ShellSort(int* a, int n)
{
	int gap;
	for (gap = n / 2; gap > 0; gap /= 2)
	{
		for (int i = gap; i < n; i++)
		{
			int tmp = a[i];
			int end = i - gap;
			while (end >= 0)
			{
				if (a[end] > tmp)
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

**3、**选择排序

基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
直接选择排序**:**
在元素集合array[i]--array[n-1]中选择关键码最大(小)的数据元素
若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
在剩余的array[i]--array[n-2](array[i+1]--array[n-1])集合中,重复上述步骤,直到集合剩余1个元素
直接选择排序是最垃圾的排序算法!
我们在此将选择排序优化一下,我们一次选一个最大数,一个最小数。

cpp 复制代码
void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}
void SelectSort(int* a, int n)
{
        int left = 0;
		int right = n - 1;
		while (left < right)
		{
			int mini;
			int maxi;
			mini = maxi = left;
			for (int i = left + 1; i <= right; i++)
			{
				if (a[i] < a[mini])
				{
					mini = i;
				}
				if (a[i] > a[maxi])
				{
					maxi = i;
				}
			}
			Swap(&a[left], &a[mini]);
			if (left == maxi)
			{
				maxi = mini;
			}
			Swap(&a[right], &a[maxi]);
			++left;
			--right;
		}
}

注意,当left和maxi相等时,在交换left和mini后,left下标的数据和mini下标的数据发生交换,此时我们要添加判断,将mini的下标赋值给maxi。但这种优化毕竟是有限的。

4、堆排序

堆排序也是选择排序的一种,但是其时间复杂度相较于直接选择排序优秀很多,为N*logN。注意,在建堆时,我们使用向下调整算法时时间复杂度为N,而使用向上调整算法时间复杂度为N*logN。所以我们使用向下调整算法建堆。

我们来讲一下向上调整算法和向下调整算法的使用场景:

向上调整算法:

在数据结构中,向上调整算法(也称为 "上浮操作")是维护堆(Heap)结构的另一种核心操作,与向下调整算法对应。它的作用是当堆中某个节点的值不符合堆的性质(大根堆或小根堆)时,通过将该节点 "向上调整",使整个堆重新满足堆的特性。

核心场景

当堆中新增元素(通常添加到堆的末尾)后,新元素可能破坏堆的性质,此时需要通过向上调整将其 "上浮" 到合适的位置,恢复堆的结构。

算法思想

大根堆为例(父节点值 ≥ 子节点值),向上调整的步骤如下:

  1. 从当前节点(通常是新增的尾节点)开始,比较其与父节点的值。
  2. 如果当前节点的值 大于 父节点的值,则交换两者的位置。
  3. 交换后,以被交换的父节点为新的当前节点,重复步骤 1-2,直到当前节点的值 ≤ 父节点的值,或当前节点已是根节点(无父节点)。

对于小根堆,逻辑类似,只需比较 "当前节点是否小于父节点",若满足则交换,直到当前节点的值 ≥ 父节点的值或成为根节点。

示例(大根堆)

假设大根堆的数组表示为 [7, 5, 6, 2, 3](满足大根堆性质),新增元素 8 后数组变为 [7, 5, 6, 2, 3, 8](新元素在索引 5,破坏堆性质),调整过程如下:

  1. 当前节点为索引 5(值 8),父节点索引为 (5-1)//2 = 2(值 6)。
  2. 8 > 6,交换索引 5 和 2,数组变为 [7, 5, 8, 2, 3, 6]
  3. 新当前节点为索引 2(值 8),父节点索引为 (2-1)//2 = 0(值 7)。
  4. 8 > 7,交换索引 2 和 0,数组变为 [8, 5, 7, 2, 3, 6]
  5. 新当前节点为索引 0(根节点),调整结束。最终堆满足大根堆性质。
向下调整算法:

在数据结构中,向下调整算法(也称为 "下沉操作")是维护堆(Heap)结构的核心操作之一。它的作用是当堆中某个节点的值不符合堆的性质(大根堆或小根堆)时,通过将该节点 "向下调整",使整个堆重新满足堆的特性。

核心场景

当堆的根节点被替换(例如堆顶元素被移除,用最后一个元素填充根节点)后,新的根节点可能破坏堆的性质,此时需要通过向下调整将其 "下沉" 到合适的位置,恢复堆的结构。

算法思想

大根堆为例(父节点值 ≥ 子节点值),向下调整的步骤如下:

  1. 从当前节点(通常是根节点)开始,比较其与左右子节点的值。
  2. 找出左右子节点中值最大的节点(称为 "较大子节点")。
  3. 如果当前节点的值 小于 较大子节点的值,则交换两者的位置。
  4. 交换后,以被交换的子节点为新的当前节点,重复步骤 1-3,直到当前节点的值 ≥ 所有子节点的值,或当前节点是叶子节点(无子女)。

对于小根堆,逻辑类似,只需将 "找较大子节点" 改为 "找较小子节点",并比较 "当前节点是否大于较小子节点"。

示例(大根堆)

假设大根堆的数组表示为:[2, 7, 6, 5, 3](根节点为 2,不符合大根堆性质),调整过程如下:

  1. 当前节点为索引 0(值 2),左子节点索引 1(值 7),右子节点索引 2(值 6)。较大子节点为 7(索引 1)。
  2. 2 < 7,交换索引 0 和 1,数组变为 [7, 2, 6, 5, 3]
  3. 新当前节点为索引 1(值 2),左子节点索引 3(值 5),右子节点索引 4(值 3)。较大子节点为 5(索引 3)。
  4. 2 < 5,交换索引 1 和 3,数组变为 [7, 5, 6, 2, 3]
  5. 新当前节点为索引 3(值 2),无子女(叶子节点),调整结束。最终堆为 [7, 5, 6, 2, 3],满足大根堆性质。
    同时,我们使用向下调整算法从哪里开始呢?
    答案是:第一个非叶子节点,因为向下调整算法的要求是其子树是堆,对于叶子节点,肯定是堆,所以我们从第一个非叶子节点开始。
cpp 复制代码
for (int i = (n-2)/2; i >0; i--)
{
	AdjustDown(a,n, i);//建大根堆
}

那么,如果我们排升序建大堆还是小堆,答案是大堆。首先我们明确我们要排升序,我们需要把数据从小到大全部找出来,如果我们建的是小根堆,根节点是最小的,但是,我们如果把根节点删除,那么其余节点父子关系全乱了,我们需要重新建堆,比较繁琐,并且,我们需要额外空间存储这个最小值,比较麻烦。建大根堆,根节点为最大值,我们将根节点与最后节点交换一下,再使用向下调整算法,就可以得到次大数,再与倒数第二个节点交换,以此类推...

具体代码:

cpp 复制代码
//升序要建大根堆
void AdjustDown(int* a, int parent, int n)
{
	int child = parent * 2 + 1;
	while (child<n)
	{
		if (child + 1 < n && a[child + 1] > a[child])
		{
			child++;
		}
		if (a[parent] < a[child])
		{
			Swap(&a[parent], &a[child]);
			parent = child;
			child = child * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void HeapSort(int* a, int n)
{
	for (int i = (n - 2) / 2; i >= 0; i--)
	{
		AdjustDown(a, i, n);
	}
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, 0, end);
		end--;
	}
}

5、冒泡排序

冒泡排序是最为简单的排序算法了,但是效率也很低,时间复杂度为O(N*2),思想也比较简单,数组元素两两比较,便能找出最大值放到数组最后,接着比较前n-1个元素,在此不做赘述。

cpp 复制代码
void BubbleSort(int* a, int n)
{
	
	for (int j = n; j > 0; --j)
	{
		int i = 0;
		int flag = 0;
		while (i + 1 < j)
		{
			if (a[i + 1] < a[i])
			{
				Swap(&a[i], &a[i + 1]);
				flag = 1;
		    }
			i++;
		}
		if (flag == 0)
		{
			break;  // 本趟无交换,说明数组已有序,提前退出
		}
	}
}

6、快速排序

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止

版本一:Hoare版本快速排序:

首先,我们可以选定基准值,通常为最左边元素或者最右边的元素。如果选择最左边元素为基准值,那么右边就要先走;如果右边为基准值,那么左边就要先走。

接着,以升序为例,右边定义一个right指针,同时左边定义一个left指针。right指针往左走,遇到比基准值小的数字,则与left指针的数字交换一下。同时,左边指针往右走,找到比基准值大的数字则与right交换一下。最后,左右指针相遇,将相遇的地方的下标的数字与基准值交换一下,那么,基准值左边的数字就一定比基准值小,右边的数字一定比基准值大。那么基准值就到了它应该在的位置。接下来就递归基准值左边区间,基准值右边区间,直到区间只有一个元素(类似二叉树)。

以下是代码:

cpp 复制代码
void QuickSort1(int* a, int left,int right)
{
	if (left >= right)
	{
		return;
	}
	int begin = left;
	int end = right;
	
	//随机选key
	/*int randomi = left + rand() % (right - left);
	Swap(&a[left], &a[randomi]);*/
	int midi = GetMiddleNumi(a, left, right);
	Swap(&a[left], &a[midi]);
	int keyi = left;
	while (left < right)
	{
		while (left<right && a[right]>=a[keyi])
		{
			right--;
		}
		while (left < right && a[left] <= a[keyi])
		{
			left++;
		}
		Swap(&a[left], &a[right]);
	}
	Swap(&a[keyi], &a[left]);
	keyi = left;
	//递归
	QuickSort1(a, begin, keyi - 1);
	QuickSort1(a, keyi + 1, end);
}

在代码书写过程中也有注意事项:首先,while (left<right && a[right]>=a[keyi]),有很多人没有写left<right这一条件,以为上面写了下面就可以不写,这是错误的。如果基准值是数组中最小的数字,那么right指针便会一直向左移动直至跑到left左边,这是不被允许的。还有,就是while循环中a[right],a[left]与a[keyi]的比较问题,如果相等交换其实没任何区别,因为交换后数字不变。但是不能在while循环前先left++,可能会导致问题。

版本二:挖坑法:

挖坑法相较于hoare版本的快速排序相对简单一些。首先将基准值存到一个临时key中,然后那个基准值可以看作一个没有数据的坑位。接下来right指针先走,遇到比基准值key小的数据,则放到原先那个坑里,right指针位置形成新的坑位,接着left指针走,遇到比基准值大的数,则与坑位交换,以此类推,直到left与right相遇,key放在那个位置。

首先将

cpp 复制代码
void QuickSort2(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	int begin = left;
	int end = right;

	//随机选key
	/*int randomi = left + rand() % (right - left);
	Swap(&a[left], &a[randomi]);*/
	int midi = GetMiddleNumi(a, left, right);
	Swap(&a[left], &a[midi]);
	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;
	hole = left;
	//递归
	QuickSort2(a, begin, hole - 1);
	QuickSort2(a, hole + 1, end);
}

版本三:前后指针法:

前后指针法是一个形象的说法,我们定义两个指针一个prev一个cur。

逻辑相对于前两种方法比较简单

1、cur找到比key小的值,++prev,cur和prev位置的值交换,++cur

2、cur找到比key大的值,++cur

说明:

1、prev要么紧紧跟着cur(prev下一个就是cur)

2、prev跟cur中间隔着比key大的一段值区间

代码:

说明:第一个函数是单趟排序,第二个函数是整体。

cpp 复制代码
//前后指针法
int PartSort3(int* a, int left, int right)
{
	int midi=GetMiddleNumi(a, left, right);
	if (midi != left)
	{
		Swap(&a[left], &a[midi]);
	}
	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]);
	keyi = prev;
	return keyi;
}
void QuickSort3(int* a, int left, int right)
{
	if (left > right)
	{
		return;
	}
	//小区间优化--小区间直接使用插入排序
	if ((right - left + 1) > 10)
	{
		int keyi = PartSort3(a, left, right);
		QuickSort3(a, left, keyi - 1);
		QuickSort3(a, keyi + 1, right);
	}
	else
	{
		InsertSort(a, right - left + 1);
	}
}
随机选数以及三数取中

在上述代码中,getmiddlenumi就是三数取中代码,还有一个随机选数。

随机选数:

cpp 复制代码
int randomi = left + rand() % (right - left);
	Swap(&a[left], &a[randomi]);*/

三数取中:

cpp 复制代码
//三数取中,因为顺序或者逆序时间复杂度都很大
int GetMiddleNumi(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
		{
			return mid;

		}
		else if (a[left] > a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else//a[left] > a[mid]
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[left] < a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	
}

这两者的作用都是防止原数组为顺序数组或者逆序数组。当原数组为顺序数组时,那么key选最左边或最右边时,选的基准值为原数组中最大或者最小的,那么必然有一个指针会遍历原数组,为最坏情况,所以为了避免这种情况,我们不得不选择数组中间某个数字,来避免这种情况发生。随机选数也有一定概率选到最左边或者最右边的数字,但这种情况必然比较少见,如果还是不放心,我们可以采取三数取中,比较最左边、最右边以及中间位置的数字,选择第二大的数字与基准值位置的数字做交换,那么基准值必定不是数组中最大值或者最小值,快速排序的效率也就得到了提升。

插入排序做优化

大家一定很好奇,为什么我第三个快速排序最后没有用递归,而是用了插入排序。

其实递归也有坏处:递归层次太深可能会导致栈溢出,所以当区间比较小时,我们可以直接使用插入排序。所以我在前后指针法中最后使用插入排序。

将递归改成循环写法

上面已经分析了,用递归法,递归层次太深,可能会导致递归层次太深,导致栈溢出,所以,在此给出一种循环的方法写快速排序,在此我们要用到栈这种数据结构。

cpp 复制代码
void QuickSortNoR(int* a, int left, int right)
{
	ST st;
	STInit(&st);
	STPush(&st, right);
	STPush(&st, left);
	while (!STEmpty(&st))
	{
		int begin = STTop(&st);
		STPop(&st);
		int end = STTop(&st);
		STPop(&st);
		int keyi=PartSort3(a, begin, end);
		if (keyi + 1 < end)
		{
			STPush(&st, end);
			STPush(&st, keyi + 1);
		}
		if (begin < keyi - 1)
		{
			STPush(&st, keyi - 1);
			STPush(&st, begin);
		}
	}
	STDestory(&st);
}

因为栈是先入后出,所以我们在压栈的时候要先将right压入栈内,再将left压入栈内,这样出栈就是left先出了。

再看while循环内,判断条件自然是栈不为空。我们先对整个区间进行单趟排序,排序完自然而然得到基准值左边区间,基准值以及基准值右边区间,接着对左区间和右区间进行相同操作,那么就写好了。

7、归并排序

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

到最后每个区间只有一个值的时候,我们就可以认为区间是有序的了。接下来我们就可以开始合并了。
分解的部分我们可以通过函数递归来做,首先我们看到每个区间都是二分得来的,我们就可以定义一个mid,再对左区间和右区间递归,直到只有一个元素的情况。,即

cpp 复制代码
if (left == right)
{
	return;
}
int mid =left+(right - left) / 2;
_MergeSort(a, left, mid,tmp);
_MergeSort(a, mid + 1, right, tmp);

这就是分解的过程。

这是简单的图例,与上图一一对应起来了。

接下来就是归并的过程了。

从第一步合并开始,我们不难看出,每一次合并都是两个有序数组比较大小的过程,这时,我们可以创建一个临时数组tmp,将两个数组排完序后放到那个临时数组,再将临时数组的值拷贝回原数组,那么,我们就合并完成一次,接着继续合并,直至原数组有序。

cpp 复制代码
//归并
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
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];
	++begin1;
}

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

memcpy(a + left, tmp + left, sizeof(int) * (i - left));

当左右数组都存在时,我们就比较第一个元素谁小,小的放入临时数组中,再++第一个元素,同时tmp数组++,这样我们就能在左右数组都存在时,将元素排成升序。

但是,因为元数组不一定是偶数,所以分解的时候可能不一定均分,导致左右区间可能元素个数不一样,所以可能会出现有一个数组元素已经全部排完,另一个没有,此时,很好办

cpp 复制代码
while (begin1 <= end1)
{
	tmp[i++] = a[begin1];
	++begin1;
}

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

如果区间1没排完,进入循环1,不会进入循环2;同理,如果区间2没排完,进入循环2,不会进入循环1。最后tmp数组里就存放两个数组归并后的结果,再将其拷贝回原数组。注意,最后拷贝起始位置不一定是数组第一个元素,如上图3 9两个元素就不是原数组第一个元素开始,所以我们需要a+left位置开始拷贝。

以下是归并排序的全部代码:

cpp 复制代码
void _MergeSort(int* a,int left, int right,int* tmp)
{
	if (left == right)
	{
		return;
	}
	int mid =left+(right - left) / 2;
	_MergeSort(a, left, mid,tmp);
	_MergeSort(a, mid + 1, right, tmp);
	//归并
	int begin1 = left, end1 = mid;
	int begin2 = mid + 1, end2 = right;
	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];
		++begin1;
	}

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

	memcpy(a + left, tmp + left, sizeof(int) * (i - left));
}
void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	_MergeSort(a, 0, n - 1, tmp);
	free(tmp);
}

_MergeSort事实上是实现逻辑,MergeSort的作用是创建一个临时数组并调用_MergeSort。

非递归写法

与快速排序一样,归并排序也有非递归写法。但与归并排序递归写法不一样的是,非递归写法事实上是模拟合并的过程。我们定义一个gap,一开始令gap=1。

大致就是这个样子,核心依旧是两个数组归并成一个大数组放入临时数组中,最后拷贝回去。

而gap每次归并结束都要乘以2,直到gap>数组元素n的时候循环结束。

此时定义一个i=left,即数组第一个元素,则begin1=i,end1=i+gap-1;begin2=i+gap,end2=i+2*gap-1。这样我们就可以归并第一第二组数组了,接下来如果要比较第三第四组,那么我们直接i+=2*gap使得i指向第三组第一个元素就行。

cpp 复制代码
int gap = 1;
while (gap < n)
{
    for (int i = 0; i < n; i +=2*gap )
	{
		int j = i;
		int begin1 = i, end1 = i + gap - 1;
		int begin2 = i + gap, end2 = i + 2 * gap - 1;
		nd1 && begin2 <= end2)
		{
			if (a[begin1] < a[begin2])
			{
				tmp[j++] = a[begin1++];
			}
			else
			{
				tmp[j++] = a[begin2++];
			}
		}
		while (begin1 <= end1)
		{
			tmp[j++] = a[begin1++];
		}
		while (begin2 <= end2)
		{
			tmp[j++] = a[begin2++];
		}

	}

我们使用for循环就可以做到。

但是,我们的数组不一定是偶数,所以可能会出现一些情况。

同时,将begin1数组和begin2数组拷贝到临时数组也有两中方式,可以等归并完所有数组再一次性拷贝回a数组,也可以走一次for循环拷贝一次,我们这里先展示一次性拷贝完的写法。

我在下面做个示范

一次性拷贝和多次拷贝的区别示范:

1、首先我们已经在for循环中定义了begin1=i<n,所以begin1不可能越界。

2、end1如果越界:

此时我们将end1定义为n-1即可,同时定义begin2=n,end2=n-1,那么第二组数就不会进入归并了。

3、如果begin2越界

定义begin2=n,end2=n-1,同样不会进入归并。

4、如果end2越界

定义end2=n-1即可。

cpp 复制代码
if (end1 >= n)
{
	end1 = n - 1;
	begin2 = n;
	end2 = n - 1;
}
else if (begin2 >= n)
{
	begin2 = n;
	end2 = n - 1;
}
else if (end2 >= n)
{
	end2 = n - 1;
}

最后走出for循环,说明这一轮归并结束,一次性拷贝回a。最后gap*2即可,直到gap>n外层while循环结束

cpp 复制代码
int* tmp = (int*)malloc(sizeof(int) * n);
int gap = 1;
while (gap < n)
{
    for (int i = 0; i < n; i +=2*gap )
	{
		int j = i;
		int begin1 = i, end1 = i + gap - 1;
		int begin2 = i + gap, end2 = i + 2 * gap - 1;
		//如果最后一把拷贝
		if (end1 >= n)
		{
			end1 = n - 1;
			begin2 = n;
			end2 = n - 1;
		}
		else if (begin2 >= n)
		{
			begin2 = n;
			end2 = n - 1;
		}
		else if (end2 >= n)
		{
			end2 = n - 1;
		}
		while (begin1 <= end1 && begin2 <= end2)
		{
			if (a[begin1] < a[begin2])
			{
				tmp[j++] = a[begin1++];
			}
			else
			{
				tmp[j++] = a[begin2++];
			}
		}
		while (begin1 <= end1)
		{
			tmp[j++] = a[begin1++];
		}
		while (begin2 <= end2)
		{
			tmp[j++] = a[begin2++];
		}

	}
	memcpy(a, tmp,sizeof(int)*n);
	gap *= 2;
}

如果多次拷贝,即走完一次拷贝一次呢,那么很简单,拷贝要写在for循环里,同时拷贝的起始位置要对应

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void MergeSortNonR(int* a, int n)
{
    if (a == NULL || n <= 1)
        return; // 空数组或单个元素无需排序

    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc failed"); // 内存分配失败提示
        exit(EXIT_FAILURE);
    }

    int gap = 1;
    while (gap < n)
    {
        for (int i = 0; i < n; i += 2 * gap)
        {
            // 定义两组的区间:[begin1, end1] 和 [begin2, end2]
            int begin1 = i;
            int end1 = i + gap - 1;
            int begin2 = i + gap;
            int end2 = i + 2 * gap - 1;
            int j = i; // tmp数组的起始写入位置(与a的当前分组起始一致)

            // 边界修正:确保不越界(核心简化逻辑)
            if (end1 >= n) end1 = n - 1;
            if (begin2 >= n) begin2 = n; // 第二组不存在,直接置为无效区间
            if (end2 >= n) end2 = n - 1;

            // 归并两组到tmp
            while (begin1 <= end1 && begin2 <= end2)
            {
                tmp[j++] = (a[begin1] <= a[begin2]) ? a[begin1++] : a[begin2++];
            }
            // 拷贝第一组剩余元素
            while (begin1 <= end1)
            {
                tmp[j++] = a[begin1++];
            }
            // 拷贝第二组剩余元素
            while (begin2 <= end2)
            {
                tmp[j++] = a[begin2++];
            }

            // 关键:将当前分组的有序结果从tmp写回a(避免下一组覆盖tmp数据)
            memcpy(a + i, tmp + i, (j - i) * sizeof(int));
        }

        gap *= 2; // 每组大小翻倍(修复死循环)
    }

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

// 测试代码
int main()
{
    int a[] = {5, 3, 8, 6, 2, 7, 1, 4};
    int n = sizeof(a) / sizeof(a[0]);

    printf("排序前:");
    for (int i = 0; i < n; i++)
        printf("%d ", a[i]);
    printf("\n");

    MergeSortNonR(a, n);

    printf("排序后:");
    for (int i = 0; i < n; i++)
        printf("%d ", a[i]);
    printf("\n");

    return 0;
}

归并排序的特性总结:

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

非比较排序

之前的排序算法说到底还是去比较数字的大小,称为比较排序,那么自然也有非比较排序。

计数排序:

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

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

    计数排序实际上就是统计每一个数字出现的次数,再放到临时数组的对应下标位置,比如下标为0的数字代表数字0出现的次数...
    时间复杂度:我们需要遍历一遍数组即可,再将临时数组中的数字放回原数组,但是,原数组可能只有10个元素,但最小值为0,最大值为10000,如果我们要开一个临时数组储存数字,就得开一个10000个元素的数组。综上时间复杂度:O(MAX(N,范围))。
    计数排序的特性总结:
  3. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
  4. 时间复杂度:O(MAX(N,范围))
  5. 空间复杂度:O(范围)
  6. 稳定性:稳定
    我们要先找出原数组的最大值和最小值,找到原数组的范围。
cpp 复制代码
void CountSort(int* a, int n)
{
	if (a == NULL || n <=1)
	{
		return;
	}
	int max=a[0], min = a[0];
	for (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* countA = (int*)calloc(range,sizeof(int));
	if (countA == NULL)
	{
		perror("calloc fail");
		return ;
	}
    //计数
	for (int i = 0; i < n; i++)
	{
		countA[a[i] - min]++;
	}
	//排序
	int j = 0;
	for (int i = 0; i < range; i++)
	{
		while (countA[i]--)
		{
			a[j++] = i + min;
		}
	}
	free(countA);
	countA = NULL;
	
}

计数那个操作使得计数排序可以对负数进行排序,算是一个优化。

相关推荐
guygg884 小时前
基于自适应傅里叶分解(AFD)及其改进算法的信号分解与重构实现
算法
黑岚樱梦4 小时前
代码随想录打卡day25:56.合并区间
数据结构·算法
自由生长20244 小时前
科普-BOM是什么?和UTF-8什么关系?
算法
小年糕是糕手4 小时前
【数据结构】常见的排序算法 -- 插入排序
c语言·开发语言·数据结构·学习·算法·leetcode·排序算法
墨染点香5 小时前
LeetCode 刷题【142. 环形链表 II】
算法·leetcode·链表
海琴烟Sunshine5 小时前
leetcode 263. 丑数 python
python·算法·leetcode
信仰_2739932435 小时前
Guava Cache淘汰算法
算法·guava
散峰而望5 小时前
C++入门(二) (算法竞赛)
开发语言·c++·算法·github
Cx330❀5 小时前
《C++ 搜索二叉树》深入理解 C++ 搜索二叉树:特性、实现与应用
java·开发语言·数据结构·c++·算法·面试