数据结构——七大排序算法详解

目录

一、引言   

二、直接插入排序

三、希尔排序

四、选择排序

五、堆排序

六、冒泡排序

七、快速排序

1.hoare版本

2.挖坑版本

3.前后指针版本

4.非递归版本

八、归并排序

1.递归版本

2.非递归版本

1.越界访问出现的原因及解决方法

九、排序算法复杂度及稳定性分析

十、总结


一、引言   

    排序在算法和数据结构占据着重要的地位。所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。在本文中将会详细介绍七大排序算法,它们分别是直接插入排序,希尔排序,选择排序,堆排序,冒泡排序,快速排序,归并排序。它们有一定的关系如图1.1。

 图1.1 不同排序的关系

二、直接插入排序

     思路:将待插入的数据从末尾后面进入,这里保证末尾及末尾前面已经是有序,才能保证待插入数据插入后整个是有序的,从末尾到前面依次比较大小,排升序就是待插入的数据和前面数据比较大小的时候遇到小于或者等于待插入数据就插入到与之比较的数据的后面,前面数据大于待插入数据的时候就将前面数据向后面移动一位,将待插入数据向前移动一位与前面的数据再做比较。可以通过动图2.1来更好的理解。

     注意:这里待插入数据遇到和前面数据相等的时候,插入到前面数据的后面而不是前面可以保证插入排序的稳定性。

     特点:1. 元素集合越接近有序,直接插入排序算法的时间效率越高。

                2.时间复杂度最坏为:O(N^2),最好为:O(N)。

                3. 空间复杂度:O(1),它是一种稳定的排序算法。


图2.1 直接插入排序动态图

     下面是代码及其解释:

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

       从第end+1即第二个数据开始后面每个数据依次进行插入排序操作,这里将插入操作放在循环的外面可以保证当待插入的数据需要插入到数组第一个位置的时候end=-1时依旧可以正常插入,如果放在里面end=-1进入不了循环就插入不了,导致排序出问题。

三、希尔排序

       思路:在直接插入排序的基础上进行优化,因为直接插入排序当元素集合越接近有序的时候效率越高,所以我们尽可能让元素集合越接近有序之后再进行直接插入排序。我们就需要在直接插入排序之前进行预排序去让让元素集合越接近有序。

      方法:通过先选定一个整数,把待排序元素集合中所有元素分成整数个组,所有距离为该整数的元素分在同一组内,并对每一组内的元素进行排序。然后,取,重复上述分组和排序的工作。当分组为1时,这里就等同于直接插入排序了,不过元素集合大部分都已经接近有序。分组大于1的时候都属于是预排序。

     注意:这里预排序的分组并不好确定,我这里选用的是gap(分组)=gap/3+1,最开始的gap为n,当然也有其他分组的方法。对于其时间复杂度的分析也比较困难,目前并没有得到一个确定的大小。

      特点:1.时间复杂度最好在O(N^1.3)左右(参考),最坏在O(N^2)。

                 2.空间复杂度O(1),它是一种不稳定的排序算法。

      下面是代码及其解释(两种):

//每组分开走
void ShellSort1(int* a, int n)
{
	int gap = n;
	while (gap > 1)
	{
		gap = gap / 3 + 1;
		for (int j = 0; j < gap; j++)
		{
			for (int i = j; i < n - gap; i += gap)
			{
				int end = i;
				int tmp = a[end + gap];
				while (end >= 0)
				{
					if (tmp < a[end])
					{
						a[end + gap] = a[end];
						end -= gap;
					}
					else
					{
						break;
					}
				}
				a[end + gap] = tmp;
			}
		}
	}
}
//每组一起走
void ShellSort2(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 (tmp < a[end])
				{
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					break;
				}
			}
			a[end + gap] = tmp;
		}
	}
}

     这里的代码根据组与组之间排序的先后顺序分成两种写法,一个写法是每组分开走,一个组排完之后再去排另一个组。另一个写法是依次排一个组排一个数据。不过整体写法都一致。

四、选择排序

     思路:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。可以参考动图4.1理解。

图4.1 直接选择排序动态图

     我这里做了一点点优化,每次选出最小和最大的元素,分别放在序列的起始位置。

     注意:这里虽然可以尽可能保证排序的稳定性,通过找最小元素的时候遇到相等的元素不改变下标,找最大元素的时候遇到相等的元素改变下标。但是举个例子当整个元素集合中第二小的元素有两个的时候且其中一个在首位,还有一个最小的元素在另一个不在首位的元素后面,那么将这个最小元素放在首位去之后,稳定性就不存在了。如图4.2。

图4.2 稳定性验证

     特点:1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用

                2. 时间复杂度最坏:O(N^2),最好:O(N^2)。

                3. 空间复杂度:O(1),它是一种不稳定的排序算法。

      下面是代码及其解释:

void swap(int* a, int* b) 
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}
void SelectSort(int* a, int n)
{
	int max = 0;
	int mini = 0;
	int end = n-1;
	int begin = 0;
	while (end>begin)
	{
		for (int i = begin; i < end; i++)
		{
			if (a[i] >= a[max])
			{
				max = i;
			}
			if (a[i] < a[mini])
			{
				mini = i;
			}
		}
		swap(&a[max], &a[end]);
		swap(&a[mini], &a[begin]);
		begin++;
		end--;
	}
}

     整体难度不大,需要注意的就是取最大和最小的时候尽量保证稳定性,虽然没办法保证其整体绝对的稳定性。

五、堆排序

     思路:通过二叉树的向下调整算法,保证最大的或者最小的永远在堆顶,然后将堆顶与堆底的数据交换再让堆底减小一位即访问不到已经交换的堆底数据,然后再进行向下调整算法。以此类推让每个数据调整一下。

     注意:排升序要建大堆,排降序建小堆。这里对建堆和向下调整算法不做过多介绍,如果不了解的朋友可以参考看一下我的另一篇博文(数据结构------二叉树)里面有详细介绍。

     特点:1. 堆排序使用堆来选数,效率就高了很多。

                2. 时间复杂度最好:O(N*logN),最坏:O(N*logN)。

                3. 空间复杂度:O(1),它是一种不稳定的排序算法。

     下面是代码及其解释:

void AdjustDown(int* a, int n, int root)//大堆
{
	int child = root * 2 + 1;//排大堆先假定左孩子比右孩子大,排小堆假定左孩子比右孩子小
	while (child <n)
	{
		if (child + 1 < n && a[child] < a[child + 1])
		{
			++child;
		}
		if (a[root] < a[child])
		{
			swap(&a[root], &a[child]);
			root = child;
			child = root * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void HeapSort(int* a, int n)
{
	// 向下调整建堆 O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}

	// O(N*logN)
	int end = n - 1;
	while (end > 0)
	{
		swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}
}

     首先使用向下调整算法建堆,排升序要建大堆,排降序建小堆。然后按照前面思路交换之后调整,直到能够访问到的数据只有一个,其他数据排序也排好了。此时最后一个数据也是有序的就不用交换调整了。

六、冒泡排序

     思路:第一个数据和第二个数据比较,如果第一个数据大那么就让第一个数据和第二个数据交换,如果第二个数据大则不交换。然后再让第二个数据和第三个数据比较,和上面类似,将更大的交换在后面再与后面的数据比较。这样循环一次就可以让最大的数放在最后面,循环n次就可以把整个数据集合排好序。可以参考动图6.1更好的理解。

图6.1 冒泡排序动态图

     注意:当两个相比的数据相等的时候不交换去保证冒泡排序的稳定性。

     特点:1. 冒泡排序是一种非常容易理解的排序

                2. 时间复杂度最坏:O(N^2),最好:O(N)。

                3. 空间复杂度:O(1),它是一种稳定的排序算法。

     下面是代码及其解释:

void BubbleSort(int* a, int n)
{
	while (n--)
	{
		int flag = 0;//优化
		for (int i = 0; i < n-1; i++)
		{
			if (a[i] > a[i + 1])
			{
				swap(&a[i], &a[i + 1]);
				flag = 1;
			}
		}
		if (flag == 0)
		{
			break;
		}
	}
}

     这里我用一个flag做了一点优化,如果每组比较的数据都没有进行交换的操作,那么就说明整个数据集合已经是有序的了,可以直接跳出循环,不用比较了。

七、快速排序

      思路:选择一个值,将比这个值小的放它左边(并没有严格顺序),比这个值大的放右边(并没有严格顺序),然后依次在小的那边选个值相同排,大的那边选个值相同排,一直进行类似二分的方式排,直到划分到只有一个数据的区域就返回。

      特点:1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序。

                 2. 时间复杂度最好:O(N*logN),最坏:O(N^2)。

                 3. 空间复杂度:O(logN) ,它是一种不稳定的排序算法。

1.hoare版本

      排一次的思路:选择一个基准值保存其下标,先从后面找到比这个基准值小的值的下标然后从前面找的比这个基准值大的值的下标,将找到的两个进行交换,最后当从后面找的指针和从前面找的指针相遇的时候把基准值下标和相遇时的下标交换位置就可以实现比这个值小的放它左边,比这个值大的放右边。可以参考动图7.1.1更好理解。

     递归思路:我们需要通过递归去一直类似二分的方式去划分区域,划分一次按照上面思路排序一次,最后区域只有一个数据的时候就不用划分了因为已经是有序的了。

                                                    图7.1.1 hoare版本一次快排思路
      注意:1.一定先从后面开始找,这样可以保证当begin和end相等的时候这个值一定是比基准值小的,否则会出问题。
                 2.在每次找基准小值和基准大值的时候都要保证begin小于end。

     下面是代码及其解释:

int PartSort1(int* a, int left, int right)
{
	int begin = left;
	int end = right;
	int key = begin;
	while (begin < end)
	{
		while (begin < end && a[end] >= a[key])
		{
			end--;
		}
		while (begin < end&&a[begin] <= a[key])
		{
			begin++;
		}
		swap(&a[begin], &a[end]);
	}
	swap(&a[key], &a[begin]);
	return begin;
}
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	int key = PartSort1(a, left, right);
	QuickSort(a, left, key - 1);
	QuickSort(a, key + 1, right);
}

     这里分开两个函数写是因为有三种版本的递归快排,这样写更方便,PartSort1就是我们上面所叙述的排一次的思路,QuickSort就是我们上面所说的递归思路,这里需要注意区间的划分。

2.挖坑版本

    排一次的思路: 把基准值的值保存下来,然后将其当成一个坑位,先走后面找到一个比基准值小的值填到这个坑位。然后后面又形成一个坑位,就这样轮流来回填坑位。最后当从后面找的指针和从前面找的指针相遇的时候也是一个坑位,因为后面指针和前面指针都是轮流指向的坑位,然后将保存的基准值放入相遇时的坑位。可以参考图7.2.1更好理解。

    递归思路:和hoare版本类似,我们需要通过递归去一直类似二分的方式去划分区域,划分一次按照上面思路排序一次,最后区域只有一个数据的时候就不用划分了因为已经是有序的了。

图7.2.1 挖坑版本一次快排思路

    下面是代码及其解释:

int PartSort2(int* a, int left, int right)
{
	int tmp = a[left];
	int koil = left;
	int begin = left, end = right;
	while (begin < end)
	{
		while (begin < end&&a[end] >= tmp)
		{
			--end;
		}
		a[koil] = a[end];
		koil = end;
		while (begin < end && a[begin] <= tmp)
		{
			++begin;
		}
		a[koil] = a[begin];
		koil = begin;
	}
	a[koil] = tmp;
	return begin;
}
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	int key = PartSort3(a, left, right);
	QuickSort(a, left, key - 1);
	QuickSort(a, key + 1, right);
}

     整体思路和hoare基本上一致,还是注意在每次找基准小值和基准大值的时候都要保证begin小于end。

3.前后指针版本

     排一次的思路:整体相对于前面两个版本更简单,一个前指针一个后指针,让前指针去找比基准值小的值,找到之后与后指针的下一个值交换,直到前指针大于数组大小,再让后指针与基准值交换。可以参考7.3.1更好的理解。

    递归思路:和前面两个版本类似,还是最重要的区域划分。

图7.3.1 前后指针版本一次快排思路

    下面是代码及其解释:

int PartSort3(int* a, int left, int right)
{
	int fast = left+1;
	int slow = left;
	int key = left;
	while (fast <= right)
	{
		if (a[fast] < a[key]&&++slow!=fast)
		{
			swap(&a[slow], &a[fast]);
		}
		fast++;
	}
	swap(&a[slow], &a[key]);
	return slow;
}
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	int key = PartSort3(a, left, right);
	QuickSort(a, left, key - 1);
	QuickSort(a, key + 1, right);
}

    前后指针版本相对于前两个版本更好理解,同时代码也更少一点。

4.非递归版本

   思路:这里的整体排序思路和hoare版本类似或者其他版本也可以用,最关键的区域划分,递归版本用递归去一次次的划分区域。这里我们非递归划分区域需要用到栈,将区域的范围存到栈里面,然后每次排序的时候从栈里面取出范围去排序。当区域的个数只有一个的时候,这个区域范围就不再入栈,当栈为空的时候即数据集合都排好序了。

   注意:这里需要用到栈的知识以及相关接口,如果不了解的朋友可以看我前面的一篇名为(数据结构——栈与队列及其相互实现)的博文,这里不做过多介绍。

    下面是代码及其解释:

void QuickSortNonR(int* a, int left, int right)
{
	Stack a1;
	StackInit(&a1);
	StackPush(&a1, right);
	StackPush(&a1, left);
	while (!StackEmpty(&a1))
	{
		int begin = StackTop(&a1);
		StackPop(&a1);
		int end = StackTop(&a1);
		StackPop(&a1);
		int key = PartSort1(a, begin, end);
		if (key + 1 < end)
		{
			StackPush(&a1, end);
			StackPush(&a1, key + 1);
		}
		if (key - 1 > begin)
		{
			StackPush(&a1, key - 1);
			StackPush(&a1, begin);
		}
	}
	StackDestroy(&a1);
}

   这里需要注意栈的特性:先进后出,所有入栈的时候一定要考虑好出栈的顺序。栈相关接口可以去我关于栈的博文找到。

八、归并排序

   思路:这里非递归版本和递归版本有很大不同,递归采用二分划分区域,非递归采用直接从小区域到大区域开始合并。

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

               2. 时间复杂度最好:O(N*logN),最坏:O(N*logN)。

               3. 空间复杂度:O(N) ,它是一种稳定的排序算法。

1.递归版本

   思路:这里的递归方式和二叉树的后序遍历类似,先将区域按照二分方式一直划分直到划分到区域只有一个数据的时候进行依次合并如一个数据区域与一个数据区域合并,二个数据区域和二个数据区域合并,这里的合并方式是通过创建一个新的数组,将有序的数据区域和数据区域依次比较,较小的数据入到新数组,变成一个更大的有序区域。再将这一段新数组的值拷贝到原来的数组。可以参考图8.1.1更好理解。

图8.1.1 归并排序

    下面是代码及其解释:

void _MergeSort(int* a, int* tmp, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	int midi = (begin + end) / 2;
	// 如果[begin, midi][midi+1, end]有序就可以进行归并了
	_MergeSort(a, tmp, begin, midi);
	_MergeSort(a, tmp, midi + 1, end);
	int begin1 = begin, end1 = midi;
	int begin2 = midi + 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, (end - begin + 1) * sizeof(int));
}
void MergeSort(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}
	_MergeSort(a, tmp, 0, n-1);
	free(tmp);
}

    这里的递归不可能每次都开辟一个数组,所以在一个函数中开好,另一个函数作为主体的排序函数,先分再合最后拷贝。

2.非递归版本

   思路:非递归不先用二分方式去划分区域,而生直接采用先一一合并(一个数据和一个数据)走完整个数组,然后二二合并,再四四合并(2的倍数)······直到合并的总数据等于整个数组的总数据。

   注意:这里的关键点是合并的区域容易出现越界访问的问题,下面会具体阐述原因及解决方法。

void MergeSortNonR(int* a, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	if (tmp == NULL)
	{
		perror("malloc fail");
		return;
	}
	int gap = 1;
	while (gap < n)
	{
		for (int i = 0; i < n; i += gap*2)
		{
			int begin1 = i, end1 = i + gap - 1;
			int begin2 = i + gap, end2 = i + 2 * gap - 1;
			int j = i;
			if (end1>=n-1)
			{
				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, (end2-i+1) * sizeof(int));
		}
		gap *= 2;
	}
}

1.越界访问出现的原因及解决方法

   这里循环进行的条件i<n,当总数据个数不是2的倍数的时候会出现,end1,begin2和end2都可能出现大于n的情况,所以我们就要分三种情况去详细讨论,

1.当end1大于n的时候

   这个时候只有一个区域,另一个区域全都越界了就不需要进行合并操作了,直接跳出循环即可。

2.当begin2大于n的时候

   和第一种情况类似,所以可以将这两种情况合并为一种情况。当end1大于等于n-1的时候,begin2大于等于n一定越界了。

2.当end2大于n的时候

    这个时候第二个合并区域是有在访问范围里面的数据个数也就需要合并,不过需要调整一下end2的范围让越界的数据无法访问,即end2=n-1。

九、排序算法复杂度及稳定性分析

十、总结

    排序是数据结构非常重要的一环,本文详细介绍了一下七大主要排序算法,可能有错误或者解释不是很清楚的情况请见谅。希望本文能够帮助你更好的理解这七大排序算法,我们共同进步!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值