排序算法总结(二)——O(nlgn)时间复杂度排序

希尔排序

我们前面讲的直接插入排序, 应该说,它的效率在某些时候是很高的,比如,我们的记录本身就是基本有序的,我们只需要少量的插入操作,就可以完成整个记录集的排序工作,此时直接插入很高效。还有就是记录数比较少时,直接插入的优势也比较明显。可问题在于,两个条件本身就过于苛刻,现实中记录少或者基本有序都属于特殊情况。有条件当然是好,条件不存在,我们创造条件也是可以去做的。于是科学家希尔研究出了一种排序方法,对直接插入排序改进后可以增加效率。

如何让待排序的记录个数较少呢?很容易想到的就是将原本有大量记录数的记录进行分组。分割成若千个子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序,当整个序列都基本有序时,注意只是基本有序时,再对全体记录进行一次直接插入排序。这就是希尔排序的基本思想。

希尔排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序,同时该算法是冲破O(n2)的第一批算法之一。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。

首先确定一个增量步长step,将整个数据每相隔step的数分为一组,则一共有step个组,对每个组分别插入排序,之后再减小step的值重复之前的操作,直至step为1。

void shell_sort(int a[], int n) {
	int k,j;
	int step = n;
	do {
		step = step / 3;    //设置增量,标准不唯一,这里设置为除以3,很多地方都是除以2
		for (int i = step; i < n; i++)
                 {
			if (a[i] < a[i - step]) 
                        {    //将相隔step的数看作一组进行插入排序(即一共step个组,每个组分别插入排序)
				k = a[i];
				for (j = i - step; j >= 0 && k < a[j]; j -= step) 
                                {
					a[j + step] = a[j];
				}
				a[j + step] = k;
			}
		}
	}
	while (step > 1);
}

复杂度分析

希尔排序是一种插入排序,只需要O(1)的复杂空间,最好时间复杂度为O(n^1.3),最坏时间复杂度为O(n^2),平均时间复杂度为O(nlogn)。(不太懂,不同地方说的都不一样)

堆排序

什么是堆

通俗来讲堆其实就是利用完全二叉树的结构来维护的一维数组。
大顶堆:每个结点的值都大于或等于其左右孩子结点的值(arr[i]>=arr[2i+1]&&arr[i]>=arr[2i+2])
小顶堆:每个结点的值都小于或等于其左右孩子结点的值(arr[i]<=arr[2i+1]&&arr[i]<=arr[2i+2])

堆排序 

堆排序( Heap Sort )就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次小值。如此反复执行,便能得到一个有序序列了。

void heap_sort(int a[], int n){
	for (int i = (n-1) / 2; i >= 0; i--) {
		heap_adjust(a, i, n);    //把a中第i元素和其两个子节点构建成大顶堆
	}

	for (int i = n - 1; i > 0; i--) {
		swap(a[0], a[i]);    //将堆顶记录和当前未经排序子序列的最后一共记录交换
		heap_adjust(a, 0, i - 1);    //再将a[0]到a[i-1]重新调整为大顶堆
	}
}

从代码中也可以看出,整个排序过程分为两个for 循环。第一个循环要完成的就是将现在的待排序序列构建成一个大顶堆。第二个循环要完成的就是逐步将每个最大直的根结点与末尾元素交换,并且再调整其成为大顶堆。接下来看看如何构造堆:

void heap_adjust(int a[], int i, int n) {
	int k = a[i];
	for (int j = 2 * i; j < n; j *= 2) {
		if (j < n && a[j] < a[j + 1]) {
			++j;    //j为孩子节点中较大的下标
		}
		if (k >= a[j]) {
			break;
		}
		swap(a[i], a[j]);    //如果父节点比较大子节点小,则交换两者
		i = j;
	}
}

复杂度分析

它的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作(书上是这么说的,不过个人感觉如果是根节点最多可能进行logn+1次交换操作,有点疑问),因此整个构建堆的时间复杂度为0(n)。

在正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间(完全二叉树的某个结点到根结点的距离为logi+1,并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为0(nlogn)。所以总体来说,堆排序的时间复杂度为0(nlogn)。 由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。 

归并排序

归并排序(Merging Sort) 就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2] ([x]表示不小于x的最小整数)个长度为2或1的有序子序列;再两两归并,...如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。代码如下:

void Msort(int a[], int b[], int s, int t) {
	int* c = new int[t+1];
	int m;
	if (s == t) {
		b[s] = a[s];
	}
	else {
		m = (s + t) / 2;    //将a[s...t]平分为a[s...m]和a[m+1...t]
		Msort(a, c, s, m);    //递归将a[s...m]归并为有序的c[s...m]
		Msort(a, c, m + 1, t);    //递归将a[m+1...t]归并为有序的c[m+1...t]
		Merge(c, b, s, m, t);    //将c[s...m]和c[m+1...t]归并到b[s...m]
	}
	delete[] c;
}

void merge_sort(int a[], int n) {
	Msort(a,a,0,n-1);
}

 过程如下图所示:

下面为我们来看一看归并函数的实现:

//将有序的a[s...m]和a[m+1...t]归并为有序的a[s...t]
void Merge(int a[], int b[], int s, int m, int t) {
	int i = s;
	int j = m + 1;
	int k;
	for ( j= m + 1,k = i; i <= m && j <= t; ++k) {
                //将a中记录的由小到大归并如b
		if (a[i] < a[j]) {
			b[k] = a[i++];
		}
		else {
			b[k] = a[j++];
		}
	}
	if (i <= m) {
		for (int ii = 0; ii <= m - i; ++ii) {
			b[k + ii] = a[i + ii];    //将剩余a[i...m]复制带b[i...m]
		}
	}
	if (j <= t) {
		for (int jj = 0; jj <= t - j; ++jj) {
			b[k + jj] = a[j + jj];    //将剩余a[m+1...t]复制带b[m+1...t]
		}
	}
}

我们来分析一下归并排序的时间复杂度,一趟归并需要将a[0]~ a[n-1]中相邻的长度为h的有序序列进行两两归并。并将结果放到TR1[1]~ b1[m]中,这需要将待排序序列中的所有记录扫描一遍, 因此耗费0(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行[log2n]次,因此,总的时间复杂度为0(nlgn), 而且这是归并排序算法中最好、最坏、平均的时间性能。

由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为logn的栈空间,因此空间复杂度为O(n+logn)。

非递归实现归并排序

void MergePass(int a[], int b[], int k, int n) {
	int i = 1;
	while (i < n - 2 * k + 1) {
		Merge(a, b, i, i + k - 1, i + 2 * k - 1);//两两归并
		i = i + 2 * k;
	}
	if (i < n - k + 1) {    //归并最后两个序列
		Merge(a, b, i, i + k - 1, n-1);
	}
	else {    若最后只剩下单个子序列
		for (int j = i; j < n; j++) {    
			b[j] = a[j];
		}
	}
}

void merge_sort2(int a[], int n) {
	int* b = new int[n];
	int k = 1;    //子序列长度
	while (k < n) {
		MergePass(a, b, k, n);
		k = 2 * k;
		MergePass(b, a, k, n);
		k *= 2;
	}
}

过程如下图所示:

非递归的迭代方法,避免了递归时深度为logn的栈空间,空间只是用到申请归并临时用的b[]数组,因此空间复杂度为O(n), 并且避免递归也在时间性能上有一定的提升。应该说,使用归并排序时,尽量考虑用非递归方法。

快速排序

快速排序(Quick Sort)的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中部分记录的关键字均比另一部分 记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。代码如下:

void Qsort(int a[], int low, int high) {
	int pivot;
	if (low < high) {
		pivot = Partition(a, low, high);

		Qsort(a, low, pivot - 1);
		Qsort(a, pivot + 1, high);
	}
}

void Quick_sort(int a[], int n) {
	Qsort(a, 0, n - 1);
}

快速排序步骤:
(1)首先设定一个分界值,通过该分界值将数组分成左右两部分。
(2)将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。
(3)重复(1)(2)步骤直至数据不能再划分为止。

第一次排序:
以第一个数为分界值,从左往右找一个比分界值大的数,从右往左找一个比分界值小的数,如果比分界值大的在小的左边,则交换两个数位置。再记录下比分界值小的数的个数,最后将分界值放入对应位置,完成第一次排序。接着对分界值左边和右边的数分别进行递归操作直到完成排序。

在这里插入图片描述

其实现主要在Partition中完成:

int Partition(int a[], int low, int high) {
	int i = low;
	int j = high + 1;
	int key = a[low];
	while (true)
	{
		/*从左向右找比key大的值*/
		while (a[++i] < key)
		{
			if (i == high) {
				break;
			}
		}
		/*从右向左找比key小的值*/
		while (a[--j] > key)
		{
			if (j == low) {
				break;
			}
		}
		if (i >= j) break;
		/*交换i,j对应的值*/
		swap(a[i], a[j]);
	}
	/*中枢值与j对应值交换*/
	swap(a[low], a[j]);
	return j;
}

 复杂度分析

在最坏的情况下,时间复杂度为O(n2),最优情况为O(nlogn),平均情况为O(nlogn)。

就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为log2n,其空间复杂度也就为O(logn),最坏情况,需要进行n-1递归调用,其空间复杂度为0(n),平均情况,空间复杂度也为O(logn)。

总结

参考:大话数据结构 ——程杰

           超详细十大经典排序算法总结

发布了33 篇原创文章 · 获赞 148 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_40692109/article/details/103283216