初阶数据结构——排序

排序的概念

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

常见排序算法

在这里插入图片描述

插入排序

直接插入排序是一种简单的插入排序法,其基本思想是:

把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
在这里插入图片描述

在这里插入图片描述

void InsertSort(int* a, int n)
{
	for (int i = 0; i < n-1; ++i)
	{
		// [0, end] 有序,插入tmp依旧有序
		int end = i;
		int tmp = a[i+1];

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

		a[end + 1] = tmp;
	}
}

插入排序是O(N^2)中适应性较好的排序,在局部有序的情况下排序效率高

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

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1),它是一种稳定的排序算法
  4. 稳定性:稳定

希尔排序

希尔排序法又称缩小增量法。

在这里插入图片描述
一组中的两个数交换:
在这里插入图片描述
排好一组:
在这里插入图片描述
排好gap组:
第一种方法:一组一组排
在这里插入图片描述
第二种方法:多组并排
在这里插入图片描述

一组一组排和多组并排两种效率都一样,写哪种都可以,写多组并排的多一些,因为可以写两层就写两层了。

gap大小意味着:
在这里插入图片描述

gap从大到小,从预排序到直接插入排序:

void ShellSort(int* a, int n)
{
	int gap = n;
	while (gap > 1)
	{
		gap = gap / 3 + 1;
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int tmp = a[end + gap];
			while (end >= 0)
			{
				if (a[end] > tmp)
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

gap = gap / 3 + 1保证了gap最后一次一定是1,比gap = gap / 3 然后再进行一次直接插入排序好
写gap = gap / 4 + 1,gap = gap / 2 什么的也可以,gap的取值有很多

测试希尔排序和插入排序谁更快

void TestOP()
{
	srand(time(0));
	int n = 500000;
	int* a1 = (int*)malloc(sizeof(int)*n);
	int* a2 = (int*)malloc(sizeof(int)*n);
	for (int i = 0; i < n; i++)
	{
		a1[i] = rand();
		a2[i] = a1[i];
	}

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

	int begin2 = clock();
	ShellSort(a1, n);
	int end2 = clock();

	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
}

int main()
{
	//TestInsert();
	//TestShell();
	TestOP();
	return 0;
}

clock获取的是程序开始执行到当前位置的时间点,两个clock之间的差距就是这个函数消耗的时间,单位是毫秒ms
在这里插入图片描述

学习各种排序是因为不同排序适应的场景不同

时间复杂度分析:

先单看红框
在这里插入图片描述
在这里插入图片描述
gap很大时:

gap=n/3,每组有3个数要比较,每组最坏挪动6次,有n/3组,合计6*n/3=2n,也就是n

gap=n/2,每组有2个数要比较,每组最坏挪动3次,有n/2组,合计3*n/2,也就是n

所以可以说gap很大时–>O(N)

把gap=1代入会发现是N^2,但因为经过了预排序,不可能是 N ^ 2

这个公式算是以最坏情况来算,可是越往后走不是最坏,因为前面的预排序会对后面的产生增益

所以难以计算gap不大不小时候的复杂度。

希尔排序的特性总结:

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

《数据结构(C语言版)》— 严蔚敏
在这里插入图片描述

《数据结构-用面相对象方法与C++描述》— 殷人昆
在这里插入图片描述
我们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:O(n^1.25) 到 O(1.6 * n^1.25) 来算。

我们可以认为整体时间复杂度取 O(N^1.3)较合适

选择排序

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

优化:
选最值的时候可以同时选最大值,最小值

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

void SelectSort(int* a, int n)
{
	int begin = 0, end = n - 1;
	while (begin < end)
	{
		int mini = begin, maxi = begin;
		for (int i = begin; i < end; i++)
		{
			if (a[i] < a[mini])
				mini = i;
			if (a[i] > a[maxi])
				maxi = i;
		}
		Swap(&a[begin], &a[mini]);
		//如果begin和maxi重叠
		if (begin == maxi)
		{
			maxi = mini;
		}
		Swap(&a[end], &a[maxi]);

		begin++;
		end--;
	}
}

注意begin可能会和maxi重叠的问题
在这里插入图片描述

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

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

堆排序

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法

详细可看我的另一篇文章 二叉树

数组可能不是堆,但它是一个完全二叉树。
那么要排序的第一步是建堆

可以向上调整建堆,也可以向下调整建堆
向上调整的前提是:前面是堆
向下调整的前提是:左子树和右子树是大堆/小堆

在这里插入图片描述

注意排升序建大堆,排降序建小堆。

void AdjustDown(int* a, int n, int parent)
{
	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)
{
	// 建大堆
	for (int i = (n-1-1)/2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}

	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}
}

堆排序的特性总结:

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

冒泡排序

冒泡排序就是把小的元素往前调或者把大的元素往后调。 比较是相邻的两个元素比较,交换也发生在这两个元素之间。

void BubbleSort(int* a, int n)
{
	for (int j = 0; j < n; ++j)
	{
		bool exchange = false;
		for (int i = 1; i < n-j; i++)
		{
			if (a[i - 1] > a[i])
			{
				int tmp = a[i];
				a[i] = a[i - 1];
				a[i - 1] = tmp;

				exchange = true;
			}
		}

		if (exchange == false)
		{
			break;
		}
	}
}

冒泡排序的特性总结:

  1. 冒泡排序是一种非常容易理解的排序
  2. 时间复杂度:O(N^2)
  3. 空间复杂度:O(1)
  4. 稳定性:稳定

快速排序

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

在这里插入图片描述

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
		return;

	int keyi = PartSort(a, begin, end);
	// [begin, keyi-1] keyi [keyi+1, end]

	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi+1, end);
}

在这里插入图片描述

将区间按照基准值划分为左右两半部分的常见方式有:

hoare

left作为keyi,先右边找小,然后左边找大,左右找到的位置交换,最后让相遇位置和keyi位置交换,让keyi到正确的位置。
注意先后顺序,左边作keyi就要右边先找

int PartSort1(int* a, int left, int right)
{
	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]);

	return left;
}

易错点:
在这里插入图片描述
左边作key,右边先走的原因:
在这里插入图片描述

挖坑法

选值作key(一般选left位置值),把该位置作为坑,先右边找小,找到了就和坑位置交换,然后变成新的坑,然后左边找小,也是交换然后变成新坑。最后坑位置就是key值应该在的位置。
在这里插入图片描述

// 挖坑法
// [left, right]
int PartSort2(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;
}
前后指针法

在这里插入图片描述

// 前后指针法
// [left, right]
int PartSort3(int* a, int left, int right)
{
	int prev = left;
	int cur = left+1;
	int keyi = left;
	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;
}
快排的时间复杂度

在这里插入图片描述
当每次选key都是中位数,效率就很好,但是数组有序的时候,就是快排最坏的情况,时间复杂度是O(N^2)

三路划分

测试排序的OJ

当我们用快排跑OJ的时候会发现超时

在这里插入图片描述
针对这种测试用例,我们可以采用三路划分的方法
在这里插入图片描述
三路划分的方法有点像hoare和前后指针的结合,前后指针是把大的值往后推,三路划分是把和key相等的值往中间推。

在这里插入图片描述

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
		return;

    int key = a[left];
    int l=left,r=right,c=left+1;
    //三路划分
    //a[c]<key,交换c和l的位置,++l,++c
    //a[c]>key,交换c和r的位置,--r
    //a[c]==key,++c
	while (c<=r)
	{
        if(a[c]<key)
        {
            Swap(&a[l], &a[c]);
            ++l;
            ++c;
        }
        else if(a[c]>key)
        {
            Swap(&a[r], &a[c]);
            --r;
        }
        else
        {
            ++c;
        }
	}
    //[begin,l-1] [l,r] [r+1,end]

	QuickSort(a, begin, l - 1);
	QuickSort(a, r+1, end);
}

三路划分和普通划分相比效率会略低一些,c和r的交换会费事,但是三里路划分全面,应对的场景更多

三数取中和随机数选中

传统的三数取中也会被测试用例卡住,比如left,right,和中间位置都是接近最小,最大的值,那么我们可以不用固定的三数取中,而用随机数选中

int GetMidIndex(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 right;
		}
		else
		{
			return left;
		}
	}
	else // a[left] > a[mid]
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[left] > a[right])
		{
			return right;
		}
		else
		{
			return left;
		}
	}
}

改变后:

int GetMidIndex(int* a, int left, int right)
{
	int mid = left+(rand()%(right-left));
	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
		{
			return mid;
		}
		else if (a[left] < a[right])
		{
			return right;
		}
		else
		{
			return left;
		}
	}
	else // a[left] > a[mid]
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[left] > a[right])
		{
			return right;
		}
		else
		{
			return left;
		}
	}
}

int* sortArray(int* nums, int numsSize, int* returnSize){
    srand(time(0));
    QuickSort(nums,0,numsSize-1);
    *returnSize=numsSize;
    return nums;
}
快排的非递归版本

在这里插入图片描述

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 = PartSort1(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);
}
快速排序的总结
  1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
  2. 时间复杂度:O(N*logN)
  3. 空间复杂度:O(logN)
  4. 稳定性:不稳定

归并排序

分治的思想,将已有序的子序列合并,得到完全有序的序列
在这里插入图片描述

归并的递归版本
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, end1 = mid;
	int begin2 = mid+1, end2 = end;
	int i = begin;
	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+begin, tmp+begin, sizeof(int) * (end - begin + 1));
}

void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);

	_MergeSort(a, 0, n - 1, tmp);

	free(tmp);
}

改进:小区间优化

只针对递归版本
在这里插入图片描述
只是为了让8个数有序,付出的代价有点高,递归次数很多
大区间不断切割,当切到小区间的时候,为了让小区间有序就没必要再用递归这种方式,可以考虑用其他排序。用简单的排序就可以,比如直接插入排序。

void _MergeSort(int* a, int begin, int end, int* tmp)
{
	if (begin == end)
		return;

	// 小区间优化
	if (end - begin + 1 < 10)
	{
		InsertSort(a+begin, end - begin + 1);

		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, end1 = mid;
	int begin2 = mid+1, end2 = end;
	int i = begin;
	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+begin, tmp+begin, sizeof(int) * (end - begin + 1));
}
归并的非递归版本

在这里插入图片描述
坑点:

数据是2的次方的时候理想情况,但是不理想情况时会存在很多问题
在这里插入图片描述
针对越界问题有两种方法,第一种方法好理解

1、遇到begin2或者end1越界就break,遇到end2越界就修正end2=n-1。但是要注意拷贝的时候不能一次性全部拷贝,不然就会从tmp中拷贝随机值覆盖掉a数组中的数据。得归并一组就拷贝一组。

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);

	// 1  2  4 ....
	int gap = 1;
	while (gap < n)
	{
		int j = 0;
		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;

			printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);

			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++];
				}
				else
				{
					tmp[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));
		}
		printf("\n");
		
		//memcpy(a, tmp, sizeof(int) * n);
		gap *= 2;
	}

	free(tmp);
}

2、不管是begin2,end1,还是end2越界我们都进行修正,修正成不存在的区间 while (begin1 <= end1 && begin2 <= end2)就不会出现越界,然后就可以采用全部拷贝。

注意遇到end1,begin2越界时不能把begin2和end2修成n-1,这样会导致n-1位置的值会多次合并

错误示范:
在这里插入图片描述

正确示范:
在这里插入图片描述

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);

	// 1  2  4 ....
	int gap = 1;
	while (gap < n)
	{
		int j = 0;
		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;

			printf("修正前:[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);

			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;
			}

			printf("修正后:[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);

			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++];
			}
		}
		printf("\n");
		
		memcpy(a, tmp, sizeof(int) * n);
		gap *= 2;
	}

	free(tmp);
}

内排序和外排序

在这里插入图片描述
对40G的文件如何排序,可以考虑递归分割的方式去排,其他排序一般只适用于内排序,而归并排序既适用于内排序也适用于外排序。
文件不能用其他排序的思想,因为文件是不能用下标随机访问的。

在这里插入图片描述

把100亿整数的文件切成40个小文件,
先把1G读到内存中,然后用快排等排序好,(归并有空间复杂度的消耗,所以不用)。然后两两归并。

让小文件有序就在内存中排序,再写回小文件。
大的文件归并的话,是在磁盘上归并,比如用文件指针打开不同的文件,然后从文件中读取整数,小的尾插(写到新文件),用文件接口的方式将两个小文件归并出一个大文件。

非比较排序

基数排序
按个位十位百位去排序,在实际中意义不大

在这里插入图片描述

void CountSort(int* a, int n)
{
	int min = a[0], max = a[0];
	for (int i = 0; i < n; i++)
	{
		if (a[i] < min)
		{
			min = a[i];
		}

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

	int range = max - min + 1;
	int* countA = (int*)malloc(sizeof(int) * range);
	memset(countA, 0, sizeof(int) * range);

	// 统计次数
	for (int i = 0; i < n; i++)
	{
		countA[a[i] - min]++;
	}

	// 排序
	int k = 0;
	for (int j = 0; j < range; j++)
	{
		while (countA[j]--)
		{
			a[k++] = j + min;
		}
	}
}

缺陷1:依赖数据范围,适用于范围集中的数组
缺陷2:只能用于整形

时间复杂度:O(N+Range)
空间复杂度:O(Range)
稳定性:稳定

稳定性

在这里插入图片描述
在这里插入图片描述

排序算法复杂度和稳定性

排序方法平均情况最好情况最坏情况辅助空间稳定性
冒泡排序O(N^2)O(N)O(N^2)O(1)稳定
选择排序O(N^2)O(N^2)O(N^2)O(1)不稳定
直接插入排序O(N^2)O(N)O(N^2)O(1)稳定
希尔排序O(n logn) ~ O(N^2)O(N^1.3)O(N^2)O(1)不稳定
堆排序O(n logn)O(n logn)O(n logn)O(1)不稳定
归并排序O(n logn)O(n logn)O(n logn)O(n)稳定
快速排序O(n logn)O(n logn)O(N^2)O(logn) ~ O(n)不稳定
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值