之前两篇关于排序算法的综述以及平方阶复杂度的3种具体类型的排序算法,这一篇将具体介绍其中平均时间复杂度在平方阶O(nlog2n) 的三个排序算法,以及各种算法的代码实现(亲测正确)。
快速排序
快速排序是由东尼·霍尔所发展的一种排序算法。在平均状况下,排序 n 个项目要 O(nlogn)次比较。在最坏状况下则需要O(n^2)
次比较,但这种状况并不常见。事实上,快速排序通常明显比其他 O(nlogn) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。
算法思想
过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在简单排序基础上的递归分治法。
算法步骤
1.从数列中挑出一个元素,称为 “基准”(pivotpos)。
2.重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
3.递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
图示
算法复杂度
最优情况:
Partition每次都划分得很均匀,如果排序n个关键字,其递归树的深度就为 [log2n]+1([x] 表示不大于 x 的最大整数),即仅需递归log2n次。第一次Partiation应该是需要对整个数组扫描一遍,做n次比较。然后,获得的枢轴将数组一分为二,那么各自还需要T(n/2)的时间(注意是最好情况,所以平分两半)。故为:O(nlog2n)
最坏情况:
当待排序的序列为正序或逆序排列时为最糟糕情况下的快排。此时需要执行n‐1次递归调用,且第i次划分需要经过n‐i次关键字的比较才能找到第i个记录,也就是枢轴的位置,因此比较次数为:n(n-1)/2 ,故时间复杂度为:O(n^2)
稳定性
因为快排是根据基准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<int> 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,直到所有元素排序完毕。
算法步骤
1.申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
2.设定两个指针,最初位置分别为两个已经排序序列的起始位置;
3.比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
4.重复步骤 3 直到某一指针达到序列尾;
5.将另一序列剩下的所有元素直接复制到合并序列尾。
图示
算法复杂度
归并排序需要不仅时时间还有空间上的辅助,因此从时间复杂度和空间复杂度进行分析。
时间复杂度
总时间=分解时间+解决问题时间+合并时间。分解时间就是把一个待排序序列分解成两序列,时间为一常数,时间复杂度o(1).解决问题时间是两个递归式,把一个规模为n的问题分成两个规模分别为n/2的子问题,时间为2T(n/2).合并时间复杂度为O(n) O(n)O(n)。总时间T(n)=2T(n/2)+o(n) T(n)=2T(n/2)+o(n)T(n)=2T(n/2)+o(n).这个递归式可以用递归树来解,其解是o(nlogn) o(nlogn)o(nlogn).此外在最坏、最佳、平均情况下归并排序时间复杂度均为o(nlogn) 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)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。
补充:
堆:是一种特殊的数据结构。满足:
必须时完全二叉树
数组实现
任一结点的值是其子树所有结点的最大值或最小值(根节点为:最大值时,称为“最大堆”,也称大顶堆;最小值时,称为“最小堆”,也称小顶堆。)
算法步骤
1.创建一个堆 H[0……n-1];
2.把堆首(最大值)和堆尾互换;
3.把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
4.重复步骤 2,直到堆的尺寸为 1。
图示

时间复杂度
堆排序的主要阶段为:初始化建立堆和重建堆。因此堆排序的时间复杂度由这两部分组成。
初始化建立堆
假如有N个节点,那么高度为h=logN h=logNh=logN,近似的时间复杂度就是O(N)。
重建堆
重建的过程,需要循环 n -1 次,每次都是从根节点往下循环查找,所以每一次时间是 logn,总时间:logn(n-1) = nlogn - logn。
故综合以上可以得出堆排序时间复杂度:O(nlog2n) 因为堆排序是就地排序,空间复杂度为常数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)的三种排序算法来说更高、更好些。
这三类排序算法的结论:
1.快速排序是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短
2.堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,适合超大数据量。这两种排序都是不稳定的。
3.若要求排序稳定,则可选用归并排序。