目录
是时候梳理一下传说中的十大排序算法了。
冒泡排序
冒泡排序(Bubble Sort)的核心思路是通过不断交换元素的位置从而确定范围内的最值,是最基础的排序方式之一。
如图,对于一个无序数列,通过5个数内逐对进行两两比对交换,小的放后面,就可以将5个数中的最小值放在最后一位,那同理确定好最后一位后,对前四位也进行同样的流程就能让倒数第二位成为第二小的数以此类推。
以下为C++实现。
vector<int> sort(vector<int> a) {
//总共迭代n-1轮,第一轮对比交换n-1次,每轮少一次
for (int i = 0; i < a.size() - 1; i++) {
//每一轮交换的次数为 n-1-轮数
for (int j = 0; j < a.size() - 1 - i; j++) {
if (a[j] > a[j + 1]) {
//包含在algorithm头文件下的交换方法,支持所有标准容器
swap(a[j], a[j + 1]);
}
}
}
return a;
}
可以看出冒泡排序使用了迭代(Iteration)的策略,即显式地使用循环结构更新变量的状态。其时间复杂度为O(n^2),空间复杂度为O(1)。当然也可以通过递归实现,但会增加空间复杂度,不推荐使用。
插入排序
插入排序(Insertion Sort)的核心思路是把未被排序的元素根据大小关系逐一插入到已排序好的数列中,就是我们整理扑克牌用到的算法。
假如我们手上有一沓牌,那么第一张就是算作已经排好的数列,然后摸到第二张牌看看放在前面合适还是后面合适,放好后继续摸牌。我们遍历数组的过程实际上就对应摸牌的过程。
以下为C++实现。
vector<int> sort(vector<int> a) {
//按顺序从数组中读取元素,就是逐个摸牌的过程
//从1开始,因为第一个元素我们认为是一开始就排好的
for (int i = 1; i < a.size(); i++) {
//现在开始把获取到的新元素进行插入
//读取过了几个元素,那么已经排好的元素就是前几个,新元素在这个范围内找位置
//同时新插入的元素会影响老元素的位置
//要从后往前遍历寻找位置,这样还能把老元素的位置往后推
for (int j = i; j > 0; j--) {
if (a[j] < a[j - 1]) {
swap(a[j], a[j-1]);
}
else {
break;
}
}
}
return a;
}
上述代码的插入是通过交换实现的,我们也可以通过直接移动位置实现,这样更方便理解与实现。
vector<int> sort(vector<int> a) {
for (int i = 1; i < a.size(); i++) {
//获取要插入的值
int value = a[i];
//逐个对比看合不合适
//这里不用for循环是因为,操作执行的次数是由到哪里决定的而不是固定次数
//"j"在方法外声明可以在循环跳出后确定位置,不用在方法中进行多余操作
int j = i - 1;
while (j >= 0 && a[j] > value) {
a[j + 1] = a[j];
j--;
}
//比较出a[j+1]比value小,插在它后面
a[j + 1] = value;
}
return a;
}
两个方法的时间复杂度为O(n^2),空间复杂度为O(1)。但实际上下面的移动方法代替了较为复杂的交换,减少了不必要的赋值次数,所以更快一些。
选择排序
选择排序(Selection Sort)就是每次循环一趟就找到数组中最小值的索引,这个即为选择的过程。然后再把最小值中的元素与第一个元素进行互换。当然也可以在循环时把最小值和最大值都找出来并且交换,这样的话虽然时间复杂度一样,但实际会快一些。
以下为C++实现。
vector<int> sort(vector<int> a) {
//外面这层循环用来进行交换,所以次数为交换的次数
//最后一个数不需要进行交换
for (int i = 0; i < a.size() - 1; i++) {
int min = a[i];
int minIndex = i;
//更新最值及其索引
for (int j = i; j < a.size(); j++) {
if (a[j] < min) {
min = a[j];
minIndex = j;
}
}
swap(a[i], a[minIndex]);
}
return a;
}
以下为一次循环选出两个值的方法。
vector<int> sort(vector<int> a) {
//外面这层循环用来进行交换,所以次数为交换的次数
for (int left = 0, right = a.size() - 1; right > left; left++, right--) {
//首先再通过一次循环找到范围内的最小值和最大值
int min = a[left], max = a[right];
int minIndex = left, maxIndex = right;
//更新最值及其索引
for (int i = left; i <= right; i++) {
if (a[i] < min) {
min = a[i];
minIndex = i;
}
if (a[i] > max) {
max = a[i];
maxIndex = i;
}
}
swap(a[left], a[minIndex]);
//如果最大值原本在left位置,它现在被移动到了minIndex位置
if (maxIndex == left) {
maxIndex = minIndex;
}
swap(a[right], a[maxIndex]);
}
return a;
}
这两种方法空间复杂度为O(1),时间复杂度为O(n^2)。
归并排序
归并排序(Merge Sort)基于分治(Divide and Conquer)的策略,即分而治之,逐个击破。
就像这样把,每组数据两两分开,一直分到最后再开始合并。我们知道有序数组的合并可以通过简单的双指针方法直接合并,而当两个数组的元素都只有一个时,就可以看作是在合并两个有序数组了。
以下为C++实现。
vector<int> sort(vector<int> a) {
//使用递归时,一般在开始设置终止条件
if (a.size() <= 1) {
return a;
}
vector<int>::iterator mid = a.begin() + a.size() / 2;
//记住了嗷,这种初始化遵循"左闭右开"
vector<int> left(a.begin(), mid);
vector<int> right(mid, a.end());
//只要对应的这一段元素个数比1大,就要继续分
left = sort(left);
right = sort(right);
return merge(left, right);
}
//merge意为合并,这个方法下写双指针法合并有序序列
vector<int> merge(vector<int> left, vector<int> right) {
//这两个整数代表要合并的两个数组的索引,相当于双指针法中的指针
int index_left = 0;
int index_right = 0;
//存放合并后的数组
vector<int> temp;
//不断比较两个数组索引下的大小,按从小到大排序,直到有一个数组遍历完毕
while (index_left < left.size() && index_right < right.size()) {
if (left[index_left] < right[index_right]) {
temp.push_back(left[index_left]);
index_left++;
}else{
temp.push_back(right[index_right]);
index_right++;
}
}
//right数组可能没遍历完,继续遍历
while (index_right < right.size()) {
temp.push_back(right[index_right]);
index_right++;
}
//left数组可能没遍历完,继续遍历
while (index_left < left.size()) {
temp.push_back(left[index_left]);
index_left++;
}
return temp;
}
可以看出,当问题可以分解为多个相同结构的子问题时,递归(Recursion)的策略是最直观的解决方案。归并排序的空间复杂度为O(n),其拥有着与二分查找法类似的结构,但其递归深度为logn层,每层处理问题的总次数都为n,故其时间复杂度为O(n*logn)。
希尔排序
希尔排序(Shell Sort)其实就是插入排序的优化版。因为对于插入排序,由于其在插入时找到位置就跳出循环的特性,其最优时间复杂度为O(n),但最坏情况是O(n^2)。这说明如果数组越接近有序,那么插入排序的性能就越高。比如[2,3,4,1]这个数组,只有一个数据是无序的,但却要分别对比前面3个位置。但如果是[2,1,4,3],较小的元素2、1都在前面,就只需要“1”比较一次前面,“3”比较一次前面了。
希尔排序就采用了这种分组排序的方法,使小元素快速前移、大元素快速后移,这样一来后续元素的插入就会更快。其核心优化目标正是解决插入排序在数据较为无序时性能急剧下降的问题。
这种分组排序是依据“间隔”实现的,记作“gap”,意思就是所有相隔gap个位置的元素视为一组,这一组中进行排序。让元素用大间隔的跳跃代替插入排序的逐位移动,让无序的数组一步一步接近有序。
比如下面的数组中,通过4的gap分为了四组:[8,4],[7,3],[6,2],[5,1]。
这里用到的间隔序列可以称作“Shell原始序列”,不断的对半分,n/2、n/4...一直到1,这种序列下的平均复杂度为O(n^(3/2)),通过前期较大的gap解决宏观的无序,方便后期的小gap排序效率接近O(n)。
除了Shell原始序列外,也有Hibbard序列、Sedgewick序列等,可以作为了解。
以下为C++实现。
vector<int> sort(vector<int> a) {
//让gap从数组长度的一半开始算,每次分组后在各组内部进行插入排序
//gap为1时是最后一次排序
for (int gap = a.size() / 2; gap >= 1 ; gap /= 2) {
//然后我们对各自的组进行插入排序
//这时间,数组中的前gap个元素都被认为是各自组中已经排好的那个元素
//所以我们从索引为gap的元素开始插入
for (int i = gap; i < a.size(); i++) {
//对于在i位置上的元素来说,它的组里只有i - n * gap位置上的元素被排好了
//和插入排序一般操作
int value = a[i];
int j = i - gap;
while (j >= 0 && a[j] > value) {
a[j + gap] = a[j];
j -= gap;
}
a[j + gap] = value;
}
}
return a;
}
希尔排序的空间复杂度为O(1),但其时间复杂度是取决于gap的大小以及序列的类型。我的实现方法使用的是原始序列,平均时间复杂度大致为O(n*(3/2)),最坏情况O(n^2)。总的来说,如果数据量大且无序,可以考虑使用希尔排序。
堆排序
堆排序(Heap Sort)是一种基于“堆(Heap)”这种数据结构的排序方法。我们知道在内存布局场景下堆是一种内存管理方式,而在数据结构场景下,堆是一种使用顺序结构的(即数组)、有序的完全二叉树。
堆有“大堆”与“小堆”之分,大堆指所有的父节点都大于子节点,小堆指所有的父节点都小于子节点。
整体思路是这样的,先将数组中的元素存放在一个完全二叉树中,再通过调整算法将这个二叉树调整为大堆或者小堆,此时在堆中的最值是明确的,像这样不断找到未排序列的最值,从而达到排序的目的。
要知道堆的底层结构是数组,数组可以通过建堆(将一个无序数组调整为大堆或小堆)得到一个堆。那在这个过程中,数组会从其一开始对于的一个完全二叉树调整为堆结构,其中数组的索引和堆中的节点是一一对应的,如图。
而且我们还需要清楚在堆中,所有的父节点索引与子节点索引遵循着这样的关系:child = parent * 2 + 1 or 2。
关于堆的调整算法有两个,即为“向上调整算法”与“向上调整算法”。
向上调整算法:是指在堆中加入新元素时,其放在数组的最后一个位置,对应完全二叉树的末尾位置,让其不断向上对比交换,用来满足大堆或小堆的性质。
向下调整算法:是指从一个完全二叉树的根节点出发,不断向下与子节点对比交换,用来满足大堆或小堆的性质,常在出堆(将根节点与末尾节点调换后移除出去,防止树状结构被破坏)后,根节点的值发生变化时使用。
需要注意的是,这两种调整算法都是基于原来的二叉树已经是堆的情况下使用。所以如果我们使用向下调整法建堆时,需要从最后一个非叶子节点(非叶指没有子节点)自底而上地使用这种算法将完全二叉树变为堆。
至于为什么不用向上调整法建堆,是因为其时间复杂度较大,性能较低。
void build(vector<int>& arr) {
//使用向上调整法,每个节点逐个插入
for (int i = 1; i < n; i++) {
//基本操作的时间复杂度为O(logn)
up(arr, i);
}
}
//整体的时间复杂度为O(n*logn)
void build(vector<int>& arr) {
//使用向下调整法,从最后一个非叶子节点开始,自底向上调整
//最后一个节点的父节点就是最后一个非叶子节点,n = parent*2+1or2
//由于偶数个是减1,奇数个减2,计算向零取整
//所以 i = n/2 - 1
for (int i = n/2 - 1; i >= 0; i--) {
//基本操作的时间复杂度为O(logn)
//每层的代价还很小
down(arr, i);
}
}
//整体的时间复杂度为O(n)
那么我们在回顾一下,我们的堆排序就是要先建堆,再出堆,出去的元素在数组中从后往前放入,就成功实现了。
以下为C++实现。
//为了能够使用向下调整算法建堆,这里编写向下调整算法
//需要传入父节点的索引,也是为了方便递归
//这里为了堆排序时由小到大,我们调整为大堆
void Adjust(vector<int>& a, int root_index, int length) {
//根据我们的关系式得到两个子节点
int left_index = root_index * 2 + 1;
int right_index = root_index * 2 + 2;
//找到最小节点并交换
int big_index = root_index;
if (left_index < length && a[left_index] > a[big_index]) {
big_index = left_index;
}
if (right_index < length && a[right_index] > a[big_index]) {
big_index = right_index;
}
if (big_index != root_index) {
swap(a[big_index], a[root_index]);
//这时原来最小节点的位置上元素被换掉了,那么它的子节点受到影响,开始递归调整
Adjust(a, big_index, length);
}
}
vector<int> sort(vector<int> a) {
//先进行建堆
//从最后一个非叶子节点开始逐个进行向下调整
for (int i = a.size() / 2 - 1; i >= 0; i--) {
Adjust(a, i, a.size());
}
//现在数组a对应的完全二叉树就是一个大堆
//创建一个数组容纳出堆的元素
//出堆
for (int i = a.size() - 1; i > 0; i--) {
// 将当前堆顶,即最大值,与堆末尾的节点交换
swap(a[0], a[i]);
// 对剩余堆进行调整,堆大小减1
Adjust(a, 0, i);
}
return a;
}
总的来说,其整体的时间复杂度为O(n*logn),空间复杂度为O(1)(我们的迭代没有创建副本,在原数组基础上交换而来)。适合处理大规模数据。
快速排序
快速排序(Quick Sort)更像是指一种排序思路,通过分治的思想使其十分高效。它的基本思路是这样的,先从数组中选择一个元素作为基准,然后将待排序列按照这个基准划分为两个部分,其中一部分的所有元素均比另一部分的元素小,然后递归地再对这两部分进行排序,最终使整个序列有序。
下面介绍几种典型的快排方法。
1、挖坑法
首先先选定key值(也被叫做pivot,意为轴)作为基准,通常是最左边的或者最右边的(这里假设为最左边的位置)。将这个位置的元素临时取出,形成一个坑。然后一个指针从右边开始从右向左遍历数组,当找到一个比key小的元素,就把这个元素填到坑里。然后右边指针停下,左边指针以相同的方式寻找比key值大的元素,这样反复填坑,当二者相遇时,左边指针所过之处都是小于key的,右边指针所过之处都是大于key的,他们脚下最后的坑把key填入,顺利分成两部分,然后每部分递归。
以下为C++实现。
vector<int> sort(vector<int>& a, int left, int right) {
//递归的终止条件
if (left >= right) {
return a;
}
//初始化指针(就是索引)
int begin_index = left, end_index = right;
//开始挖坑
int key = a[begin_index];
//二者不相遇就一直走
while (begin_index < end_index) {
while (a[end_index] > key && begin_index < end_index) {
end_index--;
}
a[begin_index] = a[end_index];
while (a[begin_index] <= key && begin_index < end_index) {
begin_index++;
}
a[end_index] = a[begin_index];
}
a[begin_index] = key;
//递归排序
sort(a, left, begin_index - 1);
sort(a, begin_index + 1, right);
return a;
}
最优与平均时间复杂度为O(n*logn),最坏情况为O(n^2),每次分区极不平衡。最优与平均空间复杂度为O(logn),最差为O(n)。
2、左右指针法(hoare版本)
同样先选一个key值,这里选左边第一个。右边指针开始走,遇到比key小的停下,等左边指针走,左边指针走到比key大的停下,两个指针指的值交换,然后右边指针继续走,以此类推,到两指针相遇时,将他们指向的值与key交换。分区递归。
以下为C++实现。
vector<int> sort(vector<int> &a, int left, int right) {
//递归的终止条件
if (left >= right) {
return a;
}
//初始化指针(就是索引)
int begin_index = left, end_index = right;
//设置key值为第一个
int key = a[begin_index];
//二者不相遇就一直走
while (begin_index < end_index) {
while (a[end_index] > key && begin_index < end_index) {
end_index--;
}
while (a[begin_index] <= key && begin_index < end_index) {
begin_index++;
}
swap(a[end_index], a[begin_index]);
}
swap(a[left], a[begin_index]);
//递归排序
sort(a, left, begin_index - 1);
sort(a, begin_index + 1, right);
return a;
}
最优与平均时间复杂度为O(n*logn),最坏情况为O(n^2),最优与平均空间复杂度为O(logn),最差为O(n),不经过优化的快速排序时空间复杂度都一样。
3、前后指针法(快慢指针)
首先确定key值,一般选最左边。然后确定前指针pre为最左边的一位,后指针cur在pre后一位。先由cur开始往后遍历,一旦遇到小于key的值就停下,让pre+1后二者交换。pre只有在cur遇到小值时才移动并交换,说明pre的所过之处都是比key小的。所以当最后cur出界时,pre的位置的索引就是key值该在的位置,与key值交换。分区递归。
以下为C++实现。
vector<int> sort(vector<int> &a, int left, int right) {
//递归的终止条件
if (left >= right) {
return a;
}
//初始化指针(就是索引)
int pre_index = left, cur_index = left + 1;
//设置key值为第一个
int key = a[left];
//cur指针不越界就一直走
while (cur_index <= right) {
while (cur_index <= right && a[cur_index] > key) {
cur_index++;
}
if (cur_index <= right) {
pre_index++;
swap(a[pre_index], a[cur_index]);
}
cur_index++;
}
swap(a[pre_index], a[left]);
//递归排序
sort(a, left, pre_index - 1);
sort(a, pre_index + 1, right);
return a;
}
最优与平均时间复杂度为O(n*logn),最坏情况为O(n^2),最优与平均空间复杂度为O(logn),最差为O(n)。不经过优化的快速排序时空间复杂度都一样。
4、快排优化方法
通过选取较为中间的值作为基准,可以防止快速排序的时间复杂度落入最坏情况。
首先是随机化基准法。
int pivot_index = left + rand() % (right - left + 1);
swap(a[left], a[pivot_index]);
int key = a[left];
其次是三数取中法。
int mid = left + (right - left) / 2;
if (a[mid] < a[left]) {
swap(a[left], a[mid]);
}
if (a[right] < a[left]) {
swap(a[left], a[right]);
}
if (a[mid] < a[right]) {
swap(a[mid], a[right]);
}
int key = a[right];
桶排序
桶排序(Bucket Sort)顾名思义就是把所有元素放在若干个桶中,然后再在每个桶中进行递归或是使用其他排序方法。最后将桶中的元素依次取出,得到有序序列。
一个"桶"更像是一个区间,我们通过找出数组中的最大值和最小值后就可以确定数组里每一个元素都在这一个区间内了。将这个区间均匀分成若干个桶,每个桶装符合区间的元素。如果元素为整数,就是每个桶装这个数字的元素,然后遍历数组对号入座。
以上为一个桶装一个整数的简单情况,多数情况下一个桶需要装一个区间的整数。
以下为C++实现。
//bucket为每个桶装的整数区间的大小
vector<int> sort(vector<int>& a, int bucketSize = 5) {
//通过最小值最大值确定范围
int max = *max_element(a.begin(), a.end());
int min = *min_element(a.begin(), a.end());
//+1包含两头
int range = max - min;
//创建桶,一共count个桶,现在每个桶中装着的对应整数的个数为0
int count = range / bucketSize + 1;
vector<vector<int>> buckets(count);
//对号入座
for (int num : a) {
//num-min就得到对应的桶
int bucket_index = (num - min) / bucketSize;
buckets[bucket_index].push_back(num);
}
//每个桶中重新排序,什么方法都行
//对引用排序,不能副本
for (vector<int> &bucket : buckets) {
sort(bucket.begin(), bucket.end());
}
//根据桶的顺序重构数组
//用来索引存放新数据
int index = 0;
for (vector<int> bucket : buckets) {
for (int num : bucket) {
a[index++] = num;
}
}
return a;
}
假设数组有n个元素,共分了k个桶,其空间复杂度为O(n+k),最优时间复杂度为O(n+k),最坏O(n^k)。适合数据多且较为集中时的排序。
计数排序
计数排序(Counting Sort)就是统计每个元素都出现了几次,最后再按顺序把他们列出来。首先根据数组中最大元素的大小,将所有元素都映射到这个区间上,就类似于上面桶排序的那种特殊情况,每个桶只装同一个整数。
有两种映射方式,首先是绝对映射,其区间就是[0,max],然后就开始统计,会造成严重的空间浪费。第二种是相对映射,其区间为[0,max-min],当元素对号入座时,通过减去min就可以对应上索引了。在重构数组时加上min就回去了。
以下为C++实现。
vector<int> sort(vector<int>& a) {
//通过最小值最大值确定范围
int max = *max_element(a.begin(), a.end());
int min = *min_element(a.begin(), a.end());
//+1包含两头
int range = max - min + 1;
//创建映射区间,一共range个位置,现在每个位置的整数对应元素出现的个数,现在初始为0
vector<int> bucket(range, 0);
//对号入座
for (int num : a) {
//num-min就得到对应的存放索引
bucket[num - min]++;
}
//用来索引存放新数据
int index = 0;
for (int i = 0; i < range; i++) {
while (bucket[i] > 0) {
//i+min就是元素的值
a[index++] = i + min;
bucket[i]--;
}
}
return a;
}
假设整数区间的范围为k,时间复杂度为O(n+k),空间复杂度为O(n+k),同样适合较为集中的数据。
基数排序
基数排序(Radix Sort)是一种非比较型的线性时间排序算法,它通过逐位处理数据元素的各个位数来实现排序。分为从最低位开始(LSD,Least Significant Digit)与从最高位开始(MSD)。
以下为从最低位(LSD)开始,C++实现。
//获取数字的第d位数字,从个位开始,即d=0
int getDigit(int num, int d) {
int n = 1;
while (d != 0) {
n *= 10;
d--;
}
return (num / n) % 10;
}
vector<int> sort(vector<int>& a) {
//找到最大值且确定最大位数
int max = *max_element(a.begin(), a.end());
int max_digit = 0;
while (max > 0) {
//只要max不为0,就加一位
max_digit++;
max /= 10;
}
//对每一位进行计数排序
for (int d = 0; d < max_digit; d++) {
//进行映射区间为0~9的计数排序
vector<int> count(10, 0);
//统计当前位的数字出现次数
for (int num : a) {
int digit = getDigit(num, d);
count[digit]++;
}
//计算累计次数
//通过这样的计算可以通过存入的个位数字是几判断出其前面有多少个元素,应该放在数组的什么位置
//现在的count存放的是比该元素此位上要小的元素数量的总和
//比如cout[3]就表示个位数是0、1、2、3的元素的数量
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
//创建存放排序结果的数组
vector<int> temp(a.size());
//反向存放
//因为要递减
for (int i = a.size() - 1; i >= 0; i--) {
int digit = getDigit(a[i], d);
temp[count[digit] - 1] = a[i];
count[digit]--;
}
//将排序结果反回原数组
a = temp;
}
return a;
}
时间复杂度为O(dn),空间复杂度为O(n),d为最大元素的位数。
小结
关于排序算法,我们也常常讨论到稳定性与自适应性。稳定性是指这种方法能否保持相等元素的原始顺序,比如冒泡、插入、归并就稳定,堆、选择、快速排序就不稳定。自适应是指要是数组中已经存在有序部分能不能提高效率,像堆排序和选择排序无论输入如何,时间都是固定的。
说了这么多,终于说完了,在未来要是遇到奇怪的排序要求时,一定不要忘了这十大排序算法啊。别让选择,成为遗憾。
如有补充纠正欢迎留言。