经典排序算法——快速排序、归并排序、堆排序

之前两篇关于排序算法的综述以及平方阶复杂度的3种具体类型的排序算法,这一篇将具体介绍其中平均时间复杂度在平方阶 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)的三个排序算法,以及各种算法的代码实现(亲测正确)。

快速排序

快速排序是由东尼·霍尔所发展的一种排序算法。在平均状况下,排序 n 个项目要 O ( n l o g n ) O(nlogn) O(nlogn)次比较。在最坏状况下则需要 O ( n 2 ) O(n^2) O(n2)次比较,但这种状况并不常见。事实上,快速排序通常明显比其他 O ( n l o g n ) O(nlogn) O(nlogn)算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。

算法思想

过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在简单排序基础上的递归分治法。

算法步骤

  • 从数列中挑出一个元素,称为 “基准”(pivotpos)。
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。

图示

算法复杂度

  • 最优情况
    • Partition每次都划分得很均匀,如果排序n个关键字,其递归树的深度就为 [ l o g 2 n ] + 1 [log_2n]+1 [log2n]+1( [x] 表示不大于 x 的最大整数),即仅需递归 l o g 2 n log_2n log2n次。第一次Partiation应该是需要对整个数组扫描一遍,做n次比较。然后,获得的枢轴将数组一分为二,那么各自还需要T(n/2)的时间(注意是最好情况,所以平分两半)。故为: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
  • 最坏情况
    • 当待排序的序列为正序或逆序排列时为最糟糕情况下的快排。此时需要执行n‐1次递归调用,且第i次划分需要经过n‐i次关键字的比较才能找到第i个记录,也就是枢轴的位置,因此比较次数为: ∑ i = 1 n − 1 ( n − i ) = n ( n − 1 ) 2 \sum_{i=1}^{n-1}(n-i) = \frac{n(n-1)}{2} i=1n1(ni)=2n(n1),故时间复杂度为: O ( n 2 ) O(n^2) O(n2)

稳定性

因为快排是根据基准pivotpos来进行的分区操作,当存在元素与基准相同时,由于分区的操作,最后会将基准值放在与之相同元素的后面,因此快速排序时一种不稳定的排序算法。

代码实现(递归与非递归)

// 分区操作
int partition(int arr[], int low, int high)
{
	int pivot = arr[low];  //基准
	while (low < high)
	{
		while (arr[high] >= pivot && low < high)
			--high;  // 找到排在后面但是小于基准的最先元素
		arr[low] = arr[high];
		while (arr[low] <= pivot && low < high)
			++low;
		arr[high] = arr[low];
	}
	arr[low] = pivot;
	return low;
}

//快速排序
void Quick_Sort(int arr[], int low, int high)
{
	int pivotpos;
	if (low < high)
	{
		pivotpos = partition(arr, low, high);
		Quick_Sort(arr, low, pivotpos - 1);
		Quick_Sort(arr, pivotpos + 1, high);
	}
}

//非递归方法
void Quick_Sort_NonRecursive(int arr[], int low, int high)
{
	int pivotpos;
	std::stack pos_stack;
	pos_stack.push(low);
	pos_stack.push(high);
	while (!pos_stack.empty())
	{
		high = pos_stack.top();  // 注意出栈顺序
		pos_stack.pop();
		low = pos_stack.top();
		pos_stack.pop();
		if (low < high)
		{
			pivotpos = partition(arr, low, high);
			//左边序列起始、终止位置入栈
			pos_stack.push(low);
			pos_stack.push(pivotpos - 1);
			//右边
			pos_stack.push(pivotpos + 1);
			pos_stack.push(high);
		}
	}
}

快排优化

根据上面时间复杂度的分析,可以看出快速排序的时间复杂度最优、最坏的关键在于基准的选择上。因此对于基准的选择的优化便是对于快速排序的算法优化。

  • 随机算法选取基准
    使用随机化算法(舍伍德算法)产生一个随机数rand,随机数的范围为[left, right],并用此随机数为下标对应的元素a[rand]作为中轴,并与最后一个元素a[right]交换,然后进行与选取最后一个元素作为中轴的快排一样的算法即可。
  • 三数取中(median-of-three)
    假设数组被排序的范围为left和right,center=(left+right)/2,对a[left]、a[right]和a[center]进行适当排序,取中值为中轴,将最小者放a[left],最大者放在a[right],把中轴元与a[left + 1]交换,并在分割阶段将i和j初始化为left+2和right-1。然后使用双向描述法,进行快排。

归并排序

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

算法思想

将序列每相邻两个数字进行归并操作(merge),形成floor(n/2+n%2)个序列,排序后每个序列包含两个元素将上述序列再次归并,形成floor(n/4)个序列,每个序列包含四个元素。重复步骤2,直到所有元素排序完毕。

算法步骤

  • 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
  • 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
  • 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
  • 重复步骤 3 直到某一指针达到序列尾;
  • 将另一序列剩下的所有元素直接复制到合并序列尾。

图示

算法复杂度

归并排序需要不仅时时间还有空间上的辅助,因此从时间复杂度和空间复杂度进行分析。

  • 时间复杂度
    总时间=分解时间+解决问题时间+合并时间。分解时间就是把一个待排序序列分解成两序列,时间为一常数,时间复杂度o(1).解决问题时间是两个递归式,把一个规模为n的问题分成两个规模分别为n/2的子问题,时间为2T(n/2).合并时间复杂度为 O ( n ) O(n) O(n)。总时间 T ( n ) = 2 T ( n / 2 ) + o ( n ) T(n)=2T(n/2)+o(n) T(n)=2T(n/2)+o(n).这个递归式可以用递归树来解,其解是 o ( n l o g n ) o(nlogn) o(nlogn).此外在最坏、最佳、平均情况下归并排序时间复杂度均为 o ( n l o g n ) o(nlogn) o(nlogn)
  • 空间复杂度
    如之前的算法步骤第一步,需要申请空间,该空间的作用时用于存放合并后的序列。因此需要初始序列规模n的空间,故空间复杂度为 O ( n ) O(n) O(n)

稳定性

元素的移动完全在合并操作上,对于合并的过程,我们完全可以添加条件限制相同的元素是否移动,所以合并排序是具有稳定性的排序。

代码实现

int * temp = new int[len];
// 合并操作
void merge(int arr[], int low, int mid, int high)
{
	int i, j, index;
	for (int i = low; i <= high; ++i) //复制数组,空间复杂度为O(n)
		temp[i] = arr[i];
	for (i = low, j = mid + 1, index = low; i <= mid && j <= high; ++index)
	{
		if (temp[i] > temp[j])
		{
			arr[index] = temp[j];
			++j;
		}
		else
		{
			arr[index] = temp[i];
			++i;
		}
	}
	while (i <= mid) arr[index++] = temp[i++];
	while (j <= high) arr[index++] = temp[j++];
	memset(temp, 0, sizeof(temp));
}



void Merge_Sort(int arr[], int low, int high)
{
	int mid;
	if (low < high)
	{
		mid = (high + low) / 2;
		Merge_Sort(arr, low, mid);
		Merge_Sort(arr, mid + 1, high);
		merge(arr, low, mid, high); // 归并
	}
}

//非递归
void Merge_Sort_NonRecursive(int arr[], int n)
{
	int step = 2, low, high, mid;  //二路归并步长
	while (step <= n)
	{
		int curpos = 0;
		while (curpos + step <= n)
		{
			high = curpos + step - 1;
			low = curpos;
			mid = curpos + step / 2 - 1;
			merge(arr, low, mid, high);
			curpos += step;
		}
		if (curpos < n - step / 2)  // 如过剩余个数比一个step长度还多,那么就在进行一次合并
		{
			mid = curpos + step / 2 - 1;
			merge(arr, curpos, mid, n - 1);
		}
		step *= 2;
	}
	mid = step / 2 - 1; 
	merge(arr, 0, mid, n - 1);
}

堆排序

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。
补充:
堆:是一种特殊的数据结构。满足:

  • 必须时完全二叉树
  • 数组实现
  • 任一结点的值是其子树所有结点的最大值或最小值(根节点为:最大值时,称为“最大堆”,也称大顶堆;最小值时,称为“最小堆”,也称小顶堆。)

算法步骤

  • 创建一个堆 H[0……n-1];
  • 把堆首(最大值)和堆尾互换;
  • 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
  • 重复步骤 2,直到堆的尺寸为 1。

图示

时间复杂度

堆排序的主要阶段为:初始化建立堆和重建堆。因此堆排序的时间复杂度由这两部分组成。

  • 初始化建立堆
    假如有N个节点,那么高度为 h = l o g N h=logN h=logN,最后一层每个父节点最多只需要下调1次,倒数第二层最多只需要下调2次,顶点最多需要下调h次,而最后一层父节点共有 2 h − 1 2^{h-1} 2h1个,倒数第二层公有 2 h − 2 2^{h-2} 2h2,顶点只有1个,所以总共的时间复杂度为 s = 1 ∗ 2 h − 1 + 2 ∗ 2 h − 2 + . . . + ( h − 1 ) ∗ 2 1 + h ∗ 2 0 s = 1 * 2^{h-1}+ 2 * 2^{h-2} + ... + (h-1)* 2^1 + h * 2^0 s=12h1+22h2+...+(h1)21+h20将h代入后 s = 2 N − 2 − l o g 2 N s= 2N - 2 - log_2N s=2N2log2N,近似的时间复杂度就是O(N)。
  • 重建堆
    重建的过程,需要循环 n -1 次,每次都是从根节点往下循环查找,所以每一次时间是 logn,总时间:logn(n-1) = nlogn - logn。

故综合以上可以得出堆排序时间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)。因为堆排序是就地排序,空间复杂度为常数 O ( 1 ) O(1) O(1)

稳定性

堆排序是不稳定的算法,它不满足稳定算法的定义。它在交换数据的时候,是比较父结点和子节点之间的数据,所以,即便是存在两个数值相等的兄弟节点,它们的相对顺序在排序也可能发生变化。

代码实现

/* 最大堆向下调整算法
* param: index 调整开始位置
*		 length 数组长度范围
*/
void MaxHeap(int arr[], int index, int length)
{
	int node = index;
	int child_index = node * 2 + 1;
	int current = arr[node];
	for (; child_index <= length; node = child_index, child_index = node * 2 + 1)
	{
		if (child_index < length && arr[child_index] < arr[child_index + 1])
			++child_index;  // 子节点中的最大值
		if (current > arr[child_index]) break;
		else
		{
			arr[node] = arr[child_index];
			arr[child_index] = current;
		}
	}
}


void Heap_Sort(int arr[], int n)
{
	for (int i = n / 2 - 1; i >= 0; --i)
	{
		MaxHeap(arr, i, n - 1);  // 建立最大堆
	}
	for (int i = n - 1; i > 0; --i)  // 从最后开始调整
	{
		int temp = arr[0];
		arr[0] = arr[i];
		arr[i] = temp;
		MaxHeap(arr, 0, i - 1);  // 数组长度范围减一
	}
}

总结

当数据量,数据规模较大时,应该采用此3类排序算法,这样效率相比于之前的时间复杂度为 O ( n 2 ) O(n^2) O(n2)的三种排序算法来说更高、更好些。
这三类排序算法的结论:

  • 快速排序是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;
  • 堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,适合超大数据量。这两种排序都是不稳定的。
  • 若要求排序稳定,则可选用归并排序。

参考资料

https://github.com/hustcc/JS-Sorting-Algorithm

你可能感兴趣的:(Algorithms)