本次来介绍算法界常见的八大排序算法,全文以C++代码实现,并以int为待排序数据,进行升序排序(全文n指代待排序数组长度):
冒泡排序
冒泡排序相信是大多数人接触到的第一个排序算法,正如其名,如同水中一个个冒出发气泡: 
每次遍历交换,若前后两个数据不符合当前排序规则,交换两数据的位置,像这样每次会将最大(或最小)的数据交换到后面的相符的位置,如同气泡一般浮出水面。因此每次遍历便可将遍历的范围缩小,最多遍历数组长度次。
以下是冒泡排序的动图表示:

以下是相关实现代码:
cpp
void Sort::BubbleSort(std::vector<int>& nums)
{
int n = nums.size();
bool flag;
for (int i = 0; i < n; ++i)
{
flag = true;//检测本次循环是否交换了数据,未交换表示排序已完成
for (int j = 1; j < n - i; ++j)//每次可将最大的数放到最后,可减少循环范围
{
if (nums[j - 1] > nums[j])
{
std::swap(nums[j - 1], nums[j]);
flag = false;
}
}
if (flag)
break;
}
}
该排序算法的时间复杂度是O(N²) ,空间复杂度是O(1) ,属于**稳定***排序。
tips:即经过排序后,相同数据的相对位置不发生变化
选择排序
每次遍历从数组中找到最小(或最大)的数据 ,根据排序规则放到数组中首(末)位置,缩小循环范围(去除确定位置的数据),进行下一次遍历,直到确定所有数据的确切位置。
以下是选择排序的代码:
cpp
void Sort::SelectSort(std::vector<int>& nums)
{
//一趟排序仅搜索一个数
for (int i = 0; i < n; ++i)
{
int mini = i;
for (int j = i + 1; j < n; ++j)
{
if (nums[j] < nums[mini])
mini = j;
}
std::swap(nums[i], nums[mini]);
}
}
可以象上面一样仅搜索一个最小的数据,当然也可以在一次遍历中同时搜索最大的数据:
cpp
void Sort::SelectSort(std::vector<int>& nums)
{
//一趟排序仅搜索两个数
int begin = 0, end = nums.size() - 1;
while (begin < end)
{
int mini = begin, maxi = end;
for (int i = begin; i <= end; ++i)
{
if (nums[i] < nums[mini])
mini = i;
if (nums[i] > nums[maxi])
maxi = i;
}
std::swap(nums[mini], nums[begin++]);
std::swap(nums[maxi], nums[end--]);
}
}
选择排序的时间复杂度是是O(N²) ,空间复杂度是O(1) ,属于不稳定排序。
插入排序
插入排序的思想就与我们玩扑克牌相类似,每次抽牌时,原本再手上的牌已经是有序的,而抽到的这张牌则会选择找到确切的位置,而之后的牌则会后移。

插入排序的思想则是:
首先假设[0, end]下标下的序列有序,然后将end + 1下标下的数据(tmp),从后往前依次比较,直到找到比x大的数的下标i,将[i, end]的数据往后移,并将x放置于i位置,然后循环往复依次增大end,直到越界。
以下是插入排序的代码实现:
cpp
void Sort::InsertSort(std::vector<int>& nums)
{
int n = nums.size();
for (int i = 0; i < n - 1; ++i)
{
int end = i, tmp = nums[end + 1];
while (end >= 0)
{
if (nums[end] > tmp)
nums[end + 1] = nums[end];
else
break;
--end;
}
nums[end + 1] = tmp;
}
}
插入排序的时间复杂度是是O(N²) ,空间复杂度是O(1) ,属于稳定排序。
希尔排序
对于上述的插入排序存在两种极端情况:
1、当数组接近逆序时,时间复杂度会达到O(N²);
2、当数组接近顺序时,时间复杂度则为O(N)。
而一个叫希尔的人发现这种情况,于是便想到了一种非常巧妙的方法来缩小该算法的时间复杂度:即先对原数组进行 预排序 ,使数组 接近有序,最后再使用插入排序使整个数组保持有序。如此使用插入排序的阶段的时间复杂度便可接近O(N),只要是预排序的时间复杂度小于O(N²),最终整个排序的时间复杂度也会降下来。
这种精妙的排序方法以其设计者命名为希尔排序,又称 缩小增量法。该排序的思想是:
1、先选择一个小于数组长度n的值(一般选择n/2或n/3)作为gap,然后 以gap作为第一增量,距离gap的数作为一组,对每组都使用插入排序,然后不断缩小gap的值,并进行分组插入排序。以上便是预排序的阶段;
2、当gap减小到1时,此时就是正统的插入排序阶段,最终使数组有序。
以下是该算法的代码实现:
cpp
void Sort::ShellSort(std::vector<int>& nums)
{
int n = nums.size(), gap = n;
while (gap > 1)
{
gap /= 2;
//gap = gap / 3 + 1;//加1使最后增量为1
for (int i = 0; i < n - gap; ++i)
{
int end = i, tmp = nums[end + gap];
while (end >= 0)
{
if (nums[end] > tmp)
nums[end + gap] = nums[end];
else
break;
end -= gap;
}
nums[end + gap] = tmp;
}
}
}
希尔排序的时间复杂度是O(NlogN) ,空间复杂度是O(1) ,属于不稳定排序。
平均时间复杂度。
堆排序
学习堆排序,就得先学会向下调整建堆算法。因为进行堆排序要先对原数组进行建堆,此时便需要用到向下调整建堆算法。

向下调整建堆算法的思想(建大堆* ):
1、如图左右子树均为大堆,根节点不符合大堆的规则。首先从根节点开始,先选择左右子节点中较大的与父节点进行比较,若没有右子节点,直接让左子节点与节点进行比较;
2、若父节点的值大于选中的子节点说明建堆完成,直接跳出循环;
3、若父节点的值小于选中的子节点,则交换父子节点,然后让父节点指针指向子节点,子节点指针指向当前父节点的左左子节点的位置;
4、当子节点指针越界,即建堆完成。
tips:大堆:父节点的值大于左右子树;小堆:父节点的值小于左右子树。

向下调整建堆算法的代码实现:
cpp
//向下调整建堆(n表示需要建堆的数据长度)
void AdjustDown(std::vector<int>& nums, int n, int parent)
{
int child = 2 * parent + 1;
while (child < n)
{
if (child + 1 < n && nums[child + 1] > nums[child])
++child;
if (nums[child] > nums[parent])
{
std::swap(nums[child], nums[parent]);
parent = child;
child = 2 * parent + 1;
}
else
break;
}
}
而要运用到排序中,就需要对数组进行建堆:
(1)、上述的例子中左右子树已经是大堆,而对一个乱序的数组,先从最后一个非叶子节点,即末尾子节点(n - 1)的父节点[(n - 1 - 1) / 2]开始向下调整建堆,此时该节点的左右子树一定是大堆(左右子树各个左节点,或只有左子树存在一个节点);
(2)、逐渐减小向下调整建堆的根节点,直到指向数组首位置,即堆的根节点时,数组成堆。
此时便可进行排序的最后一步,由堆的性质可知堆顶必定是堆中最大值。我们便可将堆顶数据与堆的末尾数据与堆顶进行交换,并对堆顶使用向下调整建堆,但是对于交换到堆顶的元素不在参与之后的建堆过程,直到将所有数据交换完毕,排序完成。
以下是代码实现:
cpp
void Sort::HeapSort(std::vector<int>& nums)
{
int n = nums.size();
//从最后一个节点(n - 1)的父节点[(n - 1 - 1) / 2]开始向下调整建堆
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
AdjustDown(nums, n, i);
//由于堆顶元素必定为堆中最大元素,每次将堆顶与堆的末尾元素交换,
//并对堆顶进行向下调整建堆,但每次交换到末尾的元素不再参加之后的建堆过程
//即缩小需建堆的数据长度
int end = n - 1;
while (end > 0)
{
std::swap(nums[0], nums[end]);
//交换到末尾的元素不再参加建堆过程
AdjustDown(nums, end, 0);
--end;
}
}
堆排序的时间复杂度是O(NlogN) ,空间复杂度是O(1) 。属于不稳定排序。
快速排序
快速排序可谓是实际运用最广泛的排序算法,就连C++的算法(algorithm )库的sort库函数都使用的是该排序算法。其本质思想就是:先找一个基准值,然后让数组分为左右两个子序列,左子序列都小于等于基准值,右子序列都大于等于基准值,再分别对左右子序列进行相同的过程,最终使数组有序。
接下来就看看该排序的几种方法:
Hoare法
以下是Hoare法的动图:

这种方法的思想是:
1、选择一个值作为一次排序过程的基准值key,一般选择左端点或右端点;
2、移动左右指针,若选择左端点为key,先移动右端点找到小于key值的下标然后移动左指针,找到大于key值的下标,再交换左右指针的数据,然后循环前述过程,直到左右指针相遇,即为基准点keyi;
3、跳出循环后,将key与基准点数据交换;
4、最后分[begin, keyi - 1] 和 [keyi + 1, end]分别进行递归重复上述类似步骤。
tips:由于第3点要将key值与相遇位置数据交换为确保最后相遇时指向的下标的数据是小于key。反之选择右端点则要先移动左指针。
以下是该方法单趟的代码实现:
cpp
int PartSort1(std::vector<int>& nums, int begin, int end)
{
int key = nums[begin];
int left = begin, right = end;
while (left < right)
{
//右指针向左找小于key的下标
while (left < right && nums[right] >= key)
--right;
//左指针向右找大于key的下标
while (left < right && nums[left] <= key)
++left;
std::swap(nums[left], nums[right]);
}
int keyi = left;
std::swap(nums[begin], nums[keyi]);
return keyi;
}
挖坑法
以下是挖坑法的动图:

该方法的思想是:
1、选择一个值作为一次排序过程的基准值key,一般选择左端点或右端点;
2、(选择左端点值为key)此时左指针的位置为坑位,所以应先移动右指针寻找小于key的值,当右指针的指向数据小于key,将该数据放于左指针处,这时,右指针指向位置变为坑位,再移动左指针找到大于key的下标位置,将该数据放于右指针处,如此循环直到左右指针相遇,即基准点keyi;
3、再把key放于keyi位置;
4、最后分[begin, keyi - 1] 和 [keyi + 1, end]分别进行递归重复上述类似步骤。
以下是该方法单趟的代码实现:
cpp
int PartSort2(std::vector<int>& nums, int begin, int end)
{
int key = nums[begin];
int left = begin, right = end;
while (left < right)
{
//此时坑位再左指针的位置
while (left < right && nums[right] >= key)
--right;
nums[left] = nums[right];
//此时坑位再右指针的位置
while (left < right && nums[left] <= key)
++left;
nums[right] = nums[left];
}
int keyi = left;
nums[keyi] = key;
return keyi;
}
前后指针法
以下是前后指针法的动图:

该方法的思想是:
1、选择一个值作为一次排序过程的基准值key,一般选择左端点或右端点;
2、开始时,prev指向序列首位置,cur指向下个位置。cur指向的数据小于key时,先让prev加1([begin, prev]位置的数据必定小于等于key,而下一个数属于不确定态),然后与cur指向的数据交换,再让cur指针后移,循环上述过程直到cur越界。此时prev指向位置即为基准点keyi;
3、再将key与prev指向数据交换;
4、最后分[begin, keyi - 1] 和 [keyi + 1, end]分别进行递归重复上述类似步骤。
以下是该方法单趟的代码实现:
cpp
int PartSort3(std::vector<int>& nums, int begin, int end)
{
int key = nums[begin];
int prev = begin, cur = begin + 1;
while (cur <= end)
{
if (nums[cur] < key && ++prev != cur)
std::swap(nums[prev], nums[cur]);
++cur;
}
int keyi = prev;
std::swap(nums[keyi], nums[begin]);
return keyi;
}
最后是快速排序的调用部分代码,分[begin, keyi - 1]和[keyi + 1, end]左右两子序列进行递归:
cpp
void _QuickSort(std::vector<int>& nums, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSort1(nums, begin, end);
//int keyi = PartSort2(nums, begin, end);
//int keyi = PartSort3(nums, begin, end);
_QuickSort(nums, begin, keyi - 1);
_QuickSort(nums, keyi + 1, end);
}
void Sort::QuickSort(std::vector<int>& nums)
{
_QuickSort(nums, 0, nums.size() - 1);
}
快排优化
对于快排我们还可以进行一定的优化:
小区间优化
即到[begin, end]的区间小于一定值时,切换排序方法,这样可以大量减少递归的量。数据的量越大,效果越明显。此处使用了插入排序:
cpp
#define MIN_RANGE 10
void _InsertSort(std::vector<int>& nums, int l, int r)
{
for (int i = l; i < r; ++i)
{
int end = i, tmp = nums[end + 1];
while (end >= 0)
{
if (nums[end] > tmp)
nums[end + 1] = nums[end];
else
break;
--end;
}
nums[end + 1] = tmp;
}
}
void _QuickSort(std::vector<int>& nums, int begin, int end)
{
if (begin >= end)
return;
if (end - begin + 1 <= MIN_RANGE)
{
//小区间优化
_InsertSort(nums, begin, end);
}
else
{
int keyi = PartSort1(nums, begin, end);
int keyi = PartSort2(nums, begin, end);
int keyi = PartSort3(nums, begin, end);
_QuickSort(nums, begin, keyi - 1);
_QuickSort(nums, keyi + 1, end);
}
}
三数取中
当待排数组原本是有序时,每次选择端点位置的数作基准值,会使排序的效率大幅降低,时间复杂度会退化到O(N²) (因为每次分左右子序列时其中一个数列长度为1,而另一个子序列则是这次区间长度减1)。此时我们便可使用三数取中的方法来规避该极端情况。
三数取中的思想就是,取区间的左右端点以及中间点,进行大小比较得到中间值并返回。
以下是代码实现(以Hoare法举例):
cpp
//三数取中
int GetMidi(std::vector<int>& nums, int left, int right)
{
int mid = left + (right - left) / 2;
if (nums[mid] > nums[left])
{
if (nums[mid] < nums[right])
return mid;
else if (nums[left] > nums[right])
return left;
else
return right;
}
else
{
if (nums[mid] > nums[right])
return mid;
else if (nums[left] < nums[right])
return left;
else
return right;
}
}
int PartSort1(std::vector<int>& nums, int begin, int end)
{
int midi = GetMidi(nums, begin, end);
std::swap(nums[midi ], nums[begin]);
int key = nums[begin];
int left = begin, right = end;
while (left < right)
{
//右指针向左找小于key的下标
while (left < right && nums[right] >= key)
--right;
//左指针向右找大于key的下标
while (left < right && nums[left] <= key)
++left;
std::swap(nums[left], nums[right]);
}
int keyi = left;
std::swap(nums[begin], nums[keyi]);
return keyi;
}
三路划分法
虽然快排很强大,能应对大多数场景,但对于一种极端情况 却会显得非常鸡肋。让我们先看一道算法题:912. 排序数组 - 力扣(LeetCode)。
将以上快排的代码放入我们发现却意外的超时了,就算能通过执行用时也会达到1800ms以上。而对于之前的希尔排序,堆排序都仅需50ms左右。这是为什么呢?
这便是之前提到的极端情况 的错,当数组全为相同数据时,即使使用优化也会导致排序的时间复杂度会退化到O(N²)。此时我们便可使用三路划分的方法应对该情况:
1、key默认为left指向位置的值;
2、开始时,left指向左端点,right指向右端点,cur指向left+1;
3、(1)、cur指向小于key的数据时,交换left与cur指向的数据,left++, cur++;
(2)、cur指向大于key的数据时,交换right与cur指向的数据,right++;
tips:(该情况之所以不需要移动cur,是因为对于right是不加判断直接丢到了cur位置的,
而交换过来的数据可能大于key;而left则始终指向key值,交换时可以直接移动cur)(3)、cur指向等于key的数据时,cur++;
4、直到cur指向大于right的位置,此时[left, right]的整体都等于key,可以将其区间算作一个基准点;
5、最后再以[begin, left - 1] 与 [right + 1, end]进行递归。
以下是该算法的实现此时使用该方法虽然可以过题,但执行时间还是会达到1600ms。所以我们还可以加上随机取数来进行优化,实际上就是使用rand函数选择[begin, end]区间随机选择一个下标与左端点进行交换。
以下是三路划分法的快速排序的整体实现代码:
cpp
std::pair<int, int> PartSort4(std::vector<int>& nums, int begin, int end)
{
int randomi = rand() % (end - begin) + begin;
std::swap(nums[randomi], nums[begin]);
int key = nums[begin];
int left = begin, cur = left + 1, right = end;
while (cur <= right)
{
if (nums[cur] < key)
std::swap(nums[cur++], nums[left++]);
else if (nums[cur] > key)
std::swap(nums[cur], nums[right--]);
else
++cur;
}
return { left, right };
}
#define MIN_RANGE 10
void _InsertSort(std::vector<int>& nums, int l, int r)
{
for (int i = l; i < r; ++i)
{
int end = i, tmp = nums[end + 1];
while (end >= 0)
{
if (nums[end] > tmp)
nums[end + 1] = nums[end];
else
break;
--end;
}
nums[end + 1] = tmp;
}
}
void _QuickSort(std::vector<int>& nums, int begin, int end)
{
if (begin >= end)
return;
if (end - begin + 1 <= MIN_RANGE)
{
//小区间优化
_InsertSort(nums, begin, end);
}
else
{
std::pair<int, int> keyi = PartSort4(nums, begin, end);
_QuickSort(nums, begin, keyi.first - 1);
_QuickSort(nums, keyi.second, end);
}
}
void Sort::QuickSort(std::vector<int>& nums)
{
srand((unsigned int)time(nullptr));
_QuickSort(nums, 0, nums.size() - 1);
}

可见此时快速排序才配得上"快速"二字。
快速排序的时间复杂度是O(NlogN) ,空间复杂度是O(logN) 。属于不稳定排序。
归并排序
归并排序是分治法 的一个典型应用,其本质是:将两个有序的数组进行合并为一个有序的数组。
对两个有序数组合并是非常简单的: 先让两个指针分别指向两数组的首位置,将指向的数据较小的放到一个tmp的拷贝数组,并后移指向该数据的指针,直到任一指针越界;但此时会有一个数组的数据还存在未放入的数据,**直接将剩下数据放入tmp***即可,最后tmp数组就是两数组合并后的有序数组。
tips:由于可以确定该数组中的数据剩下的第一个元素是大于已排序数组的末位数据,那么剩下的数据必定都大于(原数组有序)
而归并的思想就是:
1、先通过将原数组进行对半切分,分成左右两子数组(其实就是使用变量划分界限,并非实际分为两个数组)再不断的递归直到左右两数组都仅剩下一个数据,此时左右两数组必定有序;
2、然后通过之前合并两有序数组的思想,将两数组合并到一个tmp数组,最后再拷贝到原数组中,将可以形成一个包含两个数据的有序数组。
3、再进行回溯,此时便是两个较长的有序数组,进行类似的操作直至最后整个数组有序。
以下是归并排序的动图表示:
以下是归并排序的代码实现:
cpp
void _MergeSort(std::vector<int>& nums, int begin, int end, std::vector<int>& tmp)
{
if (begin >= end)
return;
int mid = begin + (end - begin) / 2;
int left1 = begin, right1 = mid;
int left2 = mid + 1, right2 = end;
_MergeSort(nums, left1, right1, tmp);
_MergeSort(nums, left2, right2, tmp);
int i = begin;
while (left1 <= right1 && left2 <= right2)
{
//left1指向相等的数先入,保证排序的稳定性
if (nums[left1] <= nums[left2])
tmp[i] = nums[left1++];
else
tmp[i] = nums[left2++];
++i;
}
while (left1 <= right1)
tmp[i++] = nums[left1++];
while (left2 <= right2)
tmp[i++] = nums[left2++];
/*for (int j = begin; j <= end; ++j)
nums[j] = tmp[j];*/
memcpy(nums.data() + begin, tmp.data() + begin, sizeof(int) * (end - begin + 1));
}
void Sort::MergeSort(std::vector<int>& nums)
{
int n = nums.size();
std::vector<int> tmp(n);
_MergeSort(nums, 0, n - 1, tmp);
}
归并排序的时间复杂度是O(NlogN) ,空间复杂度是O(N) 。属于稳定排序。
计数排序
通过前面几个排序算法的学习,想必已是筋疲力尽。那么最后放松放松来个简单的排序------计数排序,相信通过该排序的名字便已经知道该排序的大致思路了。
即使用一个哈希表(map)对数据进行存储计数,再把数据按大小顺序依次放回原数组。
以下是计数排序的代码实现:
cpp
void Sort::CountSort(std::vector<int>& nums)
{
std::map<int, int> count;
for (int i = 0; i < nums.size(); ++i)
++count[nums[i]];
int j = 0;
for (auto p : count)
{
int x = p.first, cnt = p.second;
while (cnt--)
nums[j++] = x;
}
}
计数排序的时间复杂度是O(logN) ,空间复杂度是O(N) 。属于不稳定排序。
排序比较
既然介绍了这么多排序算法,此时肯定会存在一个疑问,究竟哪一个排序算法的效率更高。
那接下来就测试一下,以下是相关测试代码(快排使用三路划分法):
cpp
void testSort()
{
srand(time(0));
const int N = 10000;
std::vector<int> a1(N);
std::vector<int> a2(N);
std::vector<int> a3(N);
std::vector<int> a4(N);
std::vector<int> a5(N);
std::vector<int> a6(N);
std::vector<int> a7(N);
std::vector<int> a8(N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
a8[i] = a1[i];
}
int begin1 = clock();
Sort::BubbleSort(a1);
int end1 = clock();
int begin2 = clock();
Sort::InsertSort(a2);
int end2 = clock();
int begin3 = clock();
Sort::ShellSort(a3);
int end3 = clock();
int begin4 = clock();
Sort::SelectSort(a4);
int end4 = clock();
int begin5 = clock();
Sort::HeapSort(a5);
int end5 = clock();
int begin6 = clock();
Sort::QuickSort(a6);
int end6 = clock();
int begin7 = clock();
Sort::MergeSort(a7);
int end7 = clock();
int begin8 = clock();
Sort::CountSort(a8);
int end8 = clock();
printf("BubbleSort:%d\n", end1 - begin1);
printf("InsertSort:%d\n", end2 - begin2);
printf("ShellSort:%d\n", end3 - begin4);
printf("SelectSort:%d\n", end4 - begin4);
printf("HeapSort:%d\n", end5 - begin5);
printf("QuickSort:%d\n", end6 - begin6);
printf("MergeSort:%d\n", end7 - begin7);
printf("CountSort:%d\n", end8 - begin8);
}
这是N为10000时的运行结果:

可见冒泡、插入与选择排序是比较慢的,我们去掉这三种,测试N为100,000和1,000,000时的运行结果:

