数据结构初阶:八大排序

八大排序

排序是一种非常重要的基础算法,在校招和工作中都非常的实用,它在日常生活中无处不再。本章将介绍八大基本排序。

排序的概念

所谓排序,就是将一串记录按照某种递增递减的关系,使该记录成为一个有序的序列。常见并实用的排序有如下八种。

数据结构初阶:八大排序_第1张图片

//直接插排
void InsertSort(int* a, int n);
//希尔排序
void ShellSort(int* a, int n);
//直接选排
void SelectSort(int* a, int n);
//堆排序
void HeapSort(int* a, int n);
//冒泡排序
void BubbleSort(int* a, int n);

 

1 插入排序

1.1 直接插入排序

基本思想

直接插入排序是最简单的插入排序,例如摸扑克牌时,需要将摸到手的牌按照一定的顺序插入到已有的牌中,这里便是运用了直接插排的思想。

插入排序的定义:把待排的元素按规则逐个插入到一个有序序列中,直到所有元素插完为止,得到一个新的有序序列。

数据结构初阶:八大排序_第2张图片

代码实现

数据结构初阶:八大排序_第3张图片

首先有一个有序数组,此外有一个元素待插入到该数组中,这样只要不满足比较条件就将下标所指元素向后移动一位,直到满足比较条件或下标越界出数组时退出循环,将待插元素插入下标的下一个位置。

//1. 单趟排序 -- 针对一个元素的排序
void InsertSort(int* a, int n) {
	int end;
	int x;
	while (end >= 0) {
		if (a[end] > x) {
			a[end + 1] = a[end];
			end--;
		}
		else {
			break;
		}
	}
	a[end + 1] = x;
}

单趟排序“只能将一个元素有序”,即将 x x x 插入到 [ 0 , e n d ] [0,end] [0,end] 的有序区间。

排序是要将所有元素都按顺序排序,而想实现这样的排序,只能一个一个元素排。所以排序一般分为内外两层循环,内循环调整元素未知,而外循环决定调整哪个元素。

  1. 首先将数组看成仅有一个元素的数组也就是第一个元素,然后将这样一个数组的尾部元素 e n d end end 的下一个元素看成待调整的元素 x x x ,再将待调整的元素运用上述代码“插入”到数组中,这样边排完了头两个元素。
  2. 将数组尾部的下标 e n d end end 向后挪一位,仍将其后的一个元素视为待调整的元素 x x x 再插入一次,这样就完成了第二趟排序。
  3. 以此类推,直到标识数组尾部的下标 e n d end end 指向倒数第1个元素,也就完成了整个数组的排序。

故只要加一个外循环,控制标识数组尾部的下标 e n d end end 遍历区间为 [ 0 , n − 1 ) [0,n-1) [0,n1),这样便能完成对整个数组的排序。

数据结构初阶:八大排序_第4张图片

void InsertSort(int* a, int n) {
	assert(a);
    //外循环 - 多趟排序
	for (int i = 0; i < n - 1; i++) {
		int end = i;
		int x = a[end + 1];
        //内循环 - 单趟排序
		while (end >= 0) {
			if (a[end] > x) {
				a[end + 1] = a[end];
				end--;
			}
			else {
				break;
			}
		}
		a[end + 1] = x;
	}
}

所以插入排序即每次将 x x x 插入数组头到标识数组尾部下标 e n d end end 所在区间 [ 0 , e n d ] [0,end] [0,end] 并完成排序。

将问题分解,可以更简单快速的解决问题。

复杂度分析

最好情况即数组顺序有序或接近有序但仍需遍历一边,时间复杂度为 O ( n ) O(n) O(n),最坏情况为数组逆序所有元素都要调整,时间复杂度为 O ( n 2 ) O(n^2) O(n2)

外循环循环 n n n 次,内循环次数从1开始逐次增加,则循环次数为等差数列之和 T ( n ) = 1 + 2 + 3 + … + n − 1 T(n)=1+2+3+…+n-1 T(n)=1+2+3++n1,故时间复杂度为 O ( n 2 ) O(n^2) O(n2)

开辟空间常数次,故空间复杂度为 O ( 1 ) O(1) O(1)

直接插排对于数组接近有序的情况显得非常的快,而在数组完全逆序的情况下显得非常慢。

 

1.2 希尔排序

直接插排在接近有序是时效率极高,但其他情况下的表现却不尽人意,故Shell想出希尔排序来优化直接插排。在直接插排的前面进行预排序使其性能得到质的飞跃。Shell在计算机中和Linus一样是个流芳百世的名字。

分组预排序

思想上对直接插排进行优化,既然直接插入排序在数组接近有序的情况下效率极高,那就让数组先预排序,使其变得接近有序再去直接插排。这就有点递归分治的思想,这预排序里面就包含了希尔伟大的思想。

  1. gap分组,对分组值进行插入排序;

假设gap为3,则每三个元素分成一组,将一组的元素看出一个数组,忽略原本数组中的其他元素。

数据结构初阶:八大排序_第5张图片

4,5,2,0为一组,3,1,8为一组,6,7,9为一组。gap的值同时也是组的个数。

  1. 对分出的gap组进行插入排序。

将每个所分的组看出一个数组,对每一个这样的数组进行直接插排,过程如图所示:

数据结构初阶:八大排序_第6张图片

预排序所得结果虽不是完全有序,但相对于原数组确实是更加有序的。

数据结构初阶:八大排序_第7张图片

  • 由于直接插入排序对逆序数组的效果差,但若加上预排序的话效果相较于直接插排会非常好。
  • 对于预排序来说,gap越大,效果越差,但时间消耗越小;而gap越小,效果越好,但时间消耗就越大。

代码实现

void ShellSort(int* a, int n) {
	assert(a);
	int gap = n;
    //多次预排一次插排
	while (gap > 1) {
		gap /= 2;
		//排多组
		for (int j = 0; j < gap; j++) {
			//多趟排序
			for (int i = j; i < n - gap; i += gap) {
				int end = i;
				int x = a[end + gap];
				//单趟排序
				while (end >= 0) {
					if (a[end] > x) {
						a[end + gap] = a[end];
						end -= gap;
					}
					else {
						break;
					}
				}
				a[end + gap] = x;
			}
		}
	}
}
  1. 首先完成一组的排序,此时就是将直接插排中所谓的下一个元素改成下“gap”个元素,对于边界的控制仍然满足 e n d end end最大为倒数第二个元素的条件。
  2. 外围再套一层循环,控制排序的起始位置i,也就是控制了多组的排序。
  3. 外围再套一层循环,控制gap不断减小到1,执行多次预排序,最后gap=1时执行直接插排。

由于直接插排的复杂度特点,可能逐步减小的希排复杂度比一次直接插排的复杂度更低。

代码简化
void ShellSort1(int* a, int n) {
	assert(a);
	int gap = n;
	while (gap > 1) {
		gap = gap / 3 + 1;
		for (int i = 0; i < n - gap; i++) {
			int end = i;
			int x = a[end + gap];
			while (end >= 0) {
				if (a[end] > x) {
					a[end + gap] = a[end];
					end -= gap;
				}
				else {
					break;
				}
			}
			a[end + gap] = x;
		}
	}
}

数据结构初阶:八大排序_第8张图片

简化了控制多组的循环,仅用i控制下标end。从原来的一组一组排,变成了多组一起排,先排第一组的第一对元素,再排第二组的第一对,第三组的第一对;第一组的第二对,第二组的第二对,第三组的第二对,……,直到排完。

复杂度分析

希排的时间复杂度分析较为复杂,下面简单推理一下。实现间距为gap的多组预排,最好情况下仅遍历一遍数组,时间复杂度为 O ( n ) O(n) O(n)

组内插排的复杂度

最坏情况下的复杂度分析较为复杂,简单推理一下:假设数组有 n n n 个元素分成 g a p gap gap 组,则每组大概有 n / g a p n/gap n/gap 个元素。每组的插入排序的复杂度大概为 T ( n , g a p ) = 1 + 2 + 3 + . . . + n / g a p T(n,gap)=1+2+3+...+n/gap T(n,gap)=1+2+3+...+n/gap ,总共gap组复杂度大概为 F ( n , g a p ) = ( 1 + 2 + 3 + . . . + n / g a p ) ∗ g a p F(n,gap)=(1+2+3+...+n/gap)*gap F(n,gap)=(1+2+3+...+n/gap)gap
M i n : lim ⁡ g a p → n F ( n , g a p ) = O ( n ) M a x : lim ⁡ g a p → 1 F ( n , g a p ) = O ( n 2 ) Min:\lim_{gap→n}F(n,gap)=O(n)\\Max:\lim_{gap→1}F(n,gap)=O(n^2) Min:gapnlimF(n,gap)=O(n)Max:gap1limF(n,gap)=O(n2)
对于预排序来说,gap越大,效果越差,但时间消耗越小;而gap越小,效果越好,但时间消耗就越大,所以执行多次预排更合理。

多组预排的复杂度

在该循环中gap从n开始不停的除2最终到1,这个过程最外层控制预排次数的循环,大概要走 l o g 2 n log_2n log2n 次,每次gap/=3时,大概预排 l o g 3 n log_3n log3n 次。

而每次预排中的插入排序部分的gap不停的变化,导致复杂度较难计算,只能说约等于 O ( n l o g n ) O(nlogn) O(nlogn),大概为 O ( n 1.3 ) O(n^{1.3}) O(n1.3)

虽然希尔排序用的都是直接插入排序,但希尔排序的精髓在于其思想非常的难得,这使得排序的复杂度得到质的优化。

 

2 选择排序

2.1 直接选择排序

选择排序是所有排序中最简单的排序之一。

基本思想

  1. 遍历一遍数组,选出规定范围内的最大值和最小值,
  2. 将最大值和最后一个位置的元素交换,将最小值和最前一个位置交换。
  3. 将最前最后的位置“排除”出数组,进一步缩小范围,

再重复上述步骤直到选完为止,当然遍历数组选最值时虽可只选一个但为降低时间消耗最好选两个。

代码实现

简单版:一次找一个极值。

void SelectSort(int* a, int n) {
	assert(a);
	while (n) {
		int maxi = 0;
		for (int i = 0; i < n; i++) {
			if (a[i] > a[maxi]) {
				maxi = i;
			}
		}
		Swap(&a[maxi], &a[n - 1]);
		n--;
	}
}

数据结构初阶:八大排序_第9张图片

提升版:一次找两个极值。

//1. 
void SelectSort(int* a, int n) {
	assert(a);
	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[mini], &a[begin]);
		//修正
		if (maxi == begin) {
			maxi = mini;
		}
		Swap(&a[maxi], &a[end]);
		++begin;
		--end;
	}
}
//2. 
void SelectSort2(int* a, int n) {
	assert(a);
	int sz = n;
	while (sz > n / 2) {
		int maxi = mini = n - sz;
		for (int i = n - sz; i < sz; i++) {
			if (a[i] < a[mini]) 
				mini = i;
			if (a[i] > a[maxi]) 
				maxi = i;
		}
		Swap(&a[maxi], &a[sz - 1]);
		if (mini == sz - 1) {
			mini = maxi;
		Swap(&a[mini], &a[n - sz]);
		sz--;
	}
}

第一种方案,定义了头尾两个指针来标识排序的范围,第二种是定义变量sz标识范围大小,但要注意控制排序趟数正好是元素个数的一半,相比来说第一种更加直观。

同时找两个最值的方法在交换的时候会出现一个问题:

既然最小值与首元素交换,最大值与尾元素交换,可能在最大值与尾元素交换的时候,尾元素正好是最小值,或者最小值与首元素交换时,首元素正好时最大值。

本来maxi,mini已经标识好了最值的下标,上述情况下交换会将元素移走,但maxi,mini不会变,这就导致了下一次交换的并不是最值。要想解决这个问题,就必须在交换后检查并修正这两个下标,及时的更改下标的位置使其依旧和元素保持对应。

两次交换中只需要在第一次交换后检查修正就行,第二次交换出现问题但并不影响结果因为下一趟排序会重新遍历找最值。

复杂度分析

每次找一个最值和每次找两个的方式的复杂度具体算式分别为 T 1 ( n ) = n + n − 1 + . . . + 0 T_1(n)=n+n-1+...+0 T1(n)=n+n1+...+0 T 2 ( n ) = n + n − 2 + n − 4 + . . . + 0 T_2(n)=n+n-2+n-4+...+0 T2(n)=n+n2+n4+...+0 ,但时间复杂度都是 O ( n 2 ) O(n^2) O(n2)。所以每次找一个和两个的优化仅仅是量的提升,并没有质的提升。

上述是最坏情况,而最好情况仍然是一样的遍历步骤,所以最好情况的时间复杂度仍是 O ( n 2 ) O(n^2) O(n2)。无论什么情况直接选排的复杂度都是 O ( n 2 ) O(n^2) O(n2),所以直接选择排序的效果比较差。

2.2 堆排序

基本思想

堆排序要先把数组建成堆再进行堆排序,建堆用向下调整算法,时间复杂度为 O ( n ) O(n) O(n)。建堆最好选择排升序建大堆,排降序建小堆。

数组从逻辑结构上看是完全二叉树,但并不一定是堆。所以我们需要先将数组建成堆,然后才能再进行堆排序。若将数组建成小堆,取堆顶就是最小的数,选出次小的数就要删除首元素从第二个元素位置开始破坏堆的结构重新建堆。

  1. 向下调整算法要求结点的左右子树都是堆,故从下往上走,从第一个非叶子结点开始向下调整建堆。
  2. 建堆完成后,数组首尾元素互换选出最大的数,再向下调整选出次大的数,循环直至调整完数组。

将数组首尾元素互换,堆顶最大数就被放在了数组末尾,尾指针减1,再向下调整把次大的数调整到堆顶,再首尾互换,次大的数就放在了后面。依次类推一直循环,直到尾指针指向数组首元素,就排完了整个数组。

代码实现

数据结构初阶:八大排序_第10张图片

void AdjustDown(int* a, int n, int parent) {
	assert(a);
	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);
	}
	//堆排序
	int end = n - 1;
	while (end) {
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		end--;
	}
}

复杂度分析

直接选择排序的复杂度太高,虽然堆排序也是选择排序,但堆排序利用了堆的性质大幅缩减了比较的次数,达到了 O ( n l o g n ) O(nlogn) O(nlogn),建堆用向下调整算法,时间复杂度为 O ( n ) O(n) O(n)。二者综合起来就是 O ( n l o g n ) O(nlogn) O(nlogn),可见堆排和希尔排序属于同一级别的排序。

 

3 交换排序

3.1 冒泡排序

基本思想

相邻两数进行比较,满足交换条件就交换。假设最坏情况下逆序数组有 n n n 个元素,每趟排序将一个最大值放到末尾。

  1. 第一趟排序比较前 n − 1 n-1 n1 数,共比较 n − 1 n-1 n1 次,将最大数放到第 n n n 个位置;

  2. 第二趟排序比较前 n − 2 n-2 n2 数,共比较 n − 2 n-2 n2 次,将次最大数放到第 n − 1 n-1 n1 个位置;

  3. i i i 趟排序比较前 n − i n-i ni 数,共比较 n − i n-i ni 次,将次最大数放到第 n − i + 1 n-i+1 ni+1 个位置;

    … …

  4. 最后一趟第 n − 1 n -1 n1 趟排序比较前 1 1 1 数,共比较 1 1 1 次,将次最大数放到第 n − i + 1 n-i+1 ni+1 个位置。

共排序 n − 1 n-1 n1 趟,每趟排序比上一趟少比较 1 1 1 个数,共将 n − 1 n-1 n1 个数放到末尾,最后一个自然放到了开头。

代码实现

数据结构初阶:八大排序_第11张图片

void BubbleSort(int* a, int n) {
	assert(a);
	//1. 
    //多趟排序 - 控制次数
	for (int i = 0; i < n - 1; i++) {
		bool exchange = true;
        //单趟排序 - 控制边界
		for (int j = 0; j < n - 1 - i; j++) {
			if (a[j] > a[j + 1]) {
				exchange = false;
				Swap(&a[j], &a[j + 1]);
			}
		}
		if (exchange) {
			break;
		}
	} 
	//2. 
	int end = n - 1;
	while (end > 0) {
        bool exchange = true;
		for (int j = 0; j < end; j++) {
			if (a[j] > a[j + 1]) {
				exchange = false;
				Swap(&a[j], &a[j + 1]);
			}
		}
        if (exchange) {
			break;
		}
		--end;
	}
}

通过加上exchange的判断条件优化,单趟排序结束后如果一次交换都没发生,就代表数组已经有序,就可以停止循环。

重点是通过画图分解控制边界,还有是分清楚单趟多趟排序。

复杂度分析

冒泡排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2) ,其最好情况即数组顺序有序的情况下,时间复杂度为 O ( n ) O(n) O(n)

插排选择冒泡比较

首先,选择排序的时间复杂度无论在什么情况下都是 O ( n 2 ) O(n^2) O(n2),所以选择排序的效率是最差的。其次,直接插排和冒泡排序在最坏情况下也都是 O ( n 2 ) O(n^2) O(n2),所以应对比一下二者的一般情况下的表现:

  1. 当数组顺序有序时,二者都是遍历一遍数组, O ( n ) O(n) O(n)
  2. 当数组接近有序时,假设为 1 2 3 4 6 5 1\quad2\quad3\quad4\quad6\quad5 123465 ,此时二者的排序原理不同就体现出优劣了:

冒泡排序需要两趟排序,第一趟从1到5比较 n − 1 n-1 n1 次并交换了5和6,第二趟从1到5比较 n − 2 n-2 n2 次,共比较了 n − 1 + n − 2 n-1+n-2 n1+n2 次。插入排序共排序5趟,前4趟每趟比较1次共4次,第5趟时6和5比较一次并移动然后4和5比较一次,一共比较了 n − 2 + 2 = n n-2+2=n n2+2=n 次。

  • 冒泡排序每趟都要从头开始比较到最终位置,而插入排序只会从无序的部分开始向前比较。冒泡排序相当于从后向前排,插入排序相当于从前向后排。

每个方格表示一个数组元素,当呈现橘红色表示该元素已经排到了正确的位置上,也就是该趟排序已结束。

  • 从箭头的长度可以看出,冒泡排序每趟排序都是从头遍历到后才完成一趟排序,就算前半部分呈有序状之后每趟同样会再遍历一遍。
  • 插入排序时从前向后排序,当数组接近有序时每趟排序只会比较2次,而且之后每趟排序不会重复遍历之前已排完的有序部分。

当存在一个很长的前半部分有序后半部分无序的数组时二者的区别就体现出来了。

你可能感兴趣的:(数据结构学习指导,排序算法,算法,数据结构,八大排序)