前言
我们都知道要进大厂第一关往往就是要手撕一个普普通通的排序,然而这些排序早就在学完DS
考完期末奔向烧烤的那一刻就被忘的光光的了,为了快速帮助大家快速捡起这八个排序,我想写下这个专题,希望对大家有帮助吧。
注:这个博文适合学过以下八个排序,是拿它快速复习(期末考试啦,笔试啦)的人(把要点快速拎出来,同时帮助手撕算法),如果是没有基础的话,还是好好听课吧,学完再来看哦!
目录
-
半分钟排序(在
IDE
的帮助下半分钟之内要写出来)- 冒泡排序
- 选择排序
- 插入排序
- 希尔排序
-
两分钟排序(需要构造,所以在
IDE
的帮助下最好两分钟写出来)- 快速排序
- 基数排序
- 堆排序
- 归并排序
学习方法
不讲学习方法的知识点就是耍流氓,甩出随便一本算法书都有这样的排序源码,而且网上有太多关于这八种排序的版本了,然而我发现它们有这样的特点:
- 用
template
写的,不适合快速记忆和学习 - 变量定义混乱,抛下代码就走
我力求在这篇博文中写出主要的训练方法和思路,便于记忆。
方法(学三遍):
- 照着源码敲一遍,调通
- 把它删掉,自己写一遍,写出
bug
或者不通的地方,自己调试,如果实在不通,就照着源码看一遍,检查,并且把踩过的坑标记出来 - 在纸上手写一遍,放到
IDE
中,检查步骤同第二步。
经过这几步,这八个基本的排序基本就没问题了。下面我就一个个地讲解这八个排序。
半分钟排序
我想说明一下,下面我会说一个叫边界墙的东西,我先举个例子,免得很难懂,事先声明这不是一个专业术语,只是为了方便我把这些知识讲懂,比如:$12345|98763653274$,竖线部分以左假设是有序的了,我们就认为这个边界墙在这个竖线的位置,比如在下面的冒泡、选择,它们会稳步向右推进(当然如果你把有序元素放在最右边,那么它就会向左推进)。
冒泡排序
第一个排序要注意,首先要检验这个传入数组是否合法,整个源码如下
void bubble_sort(int* data, int n) {
/*
冒泡排序
*/
if (data == nullptr || n <= 1)//检验数组的合法性
return;
bool flag = true;
for (int i = 0;i < n - 1&&flag;i++) {
flag = false;
for (int j = 0;j < n - i - 1;j++) {//注意是n-i+1
if (data[j] > data[j + 1]) {//大则换,说明要小
swap(data[j], data[j + 1]);//这里要用的是j!
flag = true;
}
}
}
return;
}
我在这里用的是优化过的版本,要理解其中的flag
的作用是检测一轮冒泡中是不是有至少一次的交换,如果连一次交换都没有,那就是各个元素严格单调(且具有传递性),可以提前break
。
写对的关键在于:
for(int i=0;i<n;i++)
for(int j=0;j<n-i+1;j++)
if(data[j]>data[j+1])
swap(xxxx)
尤其是内圈的n-i+1
,还有if
内部的前后元素比较,这里很容易把j
手残写成i
,怎么避免这些错误呢?要理解这里的边界是什么意思:
- 外圈的
i
本质上是规定了每一轮之后这个边界墙有多厚(有多少是不用再调整的元素),所以内圈你可以往前写,也可以往后写,当然我这是往后写的 - 内圈就是在这个边界墙围住的内部进行冒泡,每次完成一个元素的调整,
n-i+1
如果想不起是加还是减,但是有个大概印象,不妨用边界去尝试,把这个j
能约束在正常的定义域里即可 - 要保证
j+1
不要越界哦。
要注意的地方就这么多,其实也不是很难,去再写一遍叭!
选择排序
啊我一直会搞混选择排序和插入排序,感觉它们风格有点神似,其实:
- 选择排序是每轮选一个插在边界墙旁边
- 插入排序是每轮选边界墙前头的那个插入已经排好序的序列里。
关于选择排序:
void select_sort(int* data, int n) {
/*
选择排序
*/
if (data == nullptr || n <= 0)
return;
for (int i = 0;i < n;i++) {
int max_idx = i;
for (int j = i;j < n;j++) {
if (data[j] < data[max_idx])
max_idx = j;
}
swap(data[max_idx], data[i]);
}
return;
}
是不是也很简单,为什么这么说呢,要这样去理解:
- 外圈是逐层加厚边界墙,使得可选范围越来越小。
- 内圈就是去选择一个最大值,所以本质上就是在外圈约定的范围里在内圈里选一个最大值,最后和边界墙这个元素交换即可。
插一句,swap
可以用#include<algorithm>
里面的,也可以自己写,如果自己写的话,别忘了加引用符号昂。
插入排序
源码是这样的:
void insert_sort(int* data, int n) {
/*
插入排序
*/
//假设前面有序
if (data == nullptr || n <= 0)
return;
for (int i = 0;i < n-1;i++) {
for (int j = i + 1;j >= 0;j--) {
if (data[j] < data[j - 1])
swap(data[j], data[j - 1]);
}
}
return;
}
记忆它的思路是模拟成一个洗牌的过程(洗好的$|$没洗好的牌):
- 边界墙前面是已经排好序的,就想象成插好的牌,而后面就是准备去插的牌
- 外圈就是逐渐增加已经插好的牌(把
i
就理解成有序的牌),而内圈就是在剩下没有插好的牌(范围已经被边界墙约束好了)里选一个最小的插进去。
然而这样插牌是很形象,却很难实现,我在这个算法里是依次向前冒泡,交换多次达到它自己的位置,也能达到插牌的效果,还有种比较高效的方式就是使用一个临时数记录当前要插进去的数,然后把插好的牌逐个后移,因为是前面是有序的,最后必然可以找到它的归宿,那个时候再把这个临时数插入即可。
希尔排序
一开始俺也觉得这个排序很难理解,尤其是看图解的时候,一层一层的扒出来,然后由不一样的间隔这样去跳跃,让人摸不着头发(只剩光头)。
但是等到我手写出这个算法来的时候发现也没那么难,说白了就是一个变异的选择排序,怎么说是变异了呢?
- 原来选择的时候是一个一个去看,现在是隔着
gap
去看 - 好了,我们知道内层依赖
gap
,外层就要约束gap
,我们让gap
倍数递减就行了,让它逐渐变精细,这个倍数怎么选都可以,当然选适当的质数比较好,我在这里写的是$3$。
void shell_sort(int* data, int n) {
if (data == nullptr || n <= 0)
return;
const int incregap = 3;//这个是间隔变化量
for (int incre = n / incregap;incre > 0;incre /= incregap) {
for (int i = 0;i < n;i += incre) {
int max_idx = i;
for (int j = i;j < n;j += incre) {
if (data[j] < data[max_idx])
max_idx = j;
}
swap(data[max_idx], data[i]);
}
}
}
你看,我们从里向外去看,这一切就没那么难了:
我需要内层以gap
选择排序$\rightarrow$外层去找这个gap
$\rightarrow$这个gap
以一个倍数递减。
与之前的选择排序不同的地方就是外层的这个gap
,这也就是为什么说它半分钟写完比较合适。
两分钟排序
快速排序
其实我是很不喜欢快速排序的,我感觉它特别难写,为了重视这哥们,我就把它放到了两分钟排序,哎,其实理解了它的原理之后也没那么困难嘛,这是有套路的。
快速排序的核心就是Divide and Conquer
,要理解怎么分治,然后求解子问题即可。
我们先要记住,快速排序就是:
- 划墙隔开它们
- 左边快排
- 右边快排
依照这个,主体函数就出来了:
void quick_sort(int* data, int begin, int end) {
if (data == nullptr || end <= begin)
return;
int mid = partition(data, begin, end);
quick_sort(data, begin, mid - 1);
quick_sort(data, mid + 1, end);
}
这很容易对吧,我们先把partition
,quick_sort
都看成黑箱,就这样写:
去,把它们按范围归队,比这个人(随便找一个)高的站左边,矮的站右边。
好了,左边这些人去继续这样排下去
右边这些人你愣着干什么,也去排啊!
对吧,让人脑阔疼的就是怎么切分:
int partition(int* data, int begin, int end) {
int shift = begin - 1;
for (int idx = begin;idx < end;idx++) {
if (data[end] > data[idx]) {
++shift;
swap(data[shift], data[idx]);//一个个从头码放好
}
}
++shift;
swap(data[end], data[shift]);
return shift;
}
其中shift
是一个时刻等待交换的锚点,我们让end
这个对应的人作为任人比较的小姑凉(也就是分界点)。
每交换一次,就让这个锚点自增,这样保证这个锚点以左的时刻都比end
对应的元素小(注意,这个很重要!形象的说就是都比end对应小的元素
$|$其他元素
)等到最后的时候,说明全部都比较完了,那么就成了都比end对应小的元素
$|$都比end对应大的元素
,但是end
还在最后面呢,怎么办,最后:
++shift;
swap(data[end], data[shift]);
大功告成!
基数排序
本来到这,我想偷懒的,于是我求参考了一个博客,发现它写的太复杂了,还是自己手撕比较方便,这就去写(如果下面这个看的头皮发麻,可以先看后续的拆解):
void radix_sort_core(int* data, int n, int base) {
queue<int>box[10];
for (int i = 0;i < n;i++) {
box[(data[i] / base) % 10].push(data[i]);
}
int shift = 0;
for (int i=0;i < 10;i++) {
while (!box[i].empty()) {
data[shift++] = box[i].front();
box[i].pop();
}
}
}
void radix_sort(int* data, int n) {
if (data == nullptr || n <= 0)
return;
int max_val = -1;
for (int i = 0;i < n;i++)
if (data[i] > max_val)
max_val = data[i];//找到最大值
for (int base = 1;max_val / base > 0;base*=10) {//一位一位增加
radix_sort_core(data, n, base);
}
}
这个基数排序说的是什么事呢?
首先我们先看这个radix_sort
,我们第一步是找到一个最大值,我们从这个最大值作为一个把关的,确保找的这个基数不会超过最大值,这样可以及时停止。好了,然后我们就让这个底数自增$\times10$,这个是不是很自然的事情,因为基数排序就是从低位向高位去传递排序(当然,内部排序是稳定的才能支持我们做这个伟大事业)。当然,我们基数递增之后怎么办呢,是不是就要来每一轮排序了,这个排序就在radix_sort_core
里面:
我们是十进制的数字,所以建立$10$个箱子,每个箱子里都装一个队列(就当链表用),这样满足$FILO$,也是我们排序所需要的,我们先依次把元素插入对应余数所在的箱子,然后再按照顺序遍历它们取出来,也就是逐个箱子pop()
到空,这样就完成了一轮排序,为了节省空间,其实结果覆盖并且放在原来data
的空间里就行了。
好啦!再去把它自己写一遍吧。
堆排序
我觉得,这个排序是这八个排序里肉眼可见最难的排序了,对于堆的定义,我简单说一下:
- 最大堆是完全二叉树
- 最大堆的每个元素都比
child
元素要大(也就是每颗子树都是最大堆)
好了,而且我们需要掌握一个思想,偷懒的思想,怎么偷懒呢,其实所有操作都是建堆,你删除了,那就把尾巴那个元素拿到堆顶,然后你去建堆吧,你插入了,那就把新元素插入最后一个位置,然后你去建堆吧,总而言之,要掌握一个思想,堆怎么安排跟你无关,你只要:
操作一下(插入、删除)
建堆();
那么排序就是:
循环 元素次数:
top();
pop();
建堆();
那是不是认为事情就简单了呢,并不是好吗,建堆还是得你来建,这个才是最难的地方呢。好了我们从床上爬起来,开始建堆吧:
我就不写类了,还是写结构体比较舒服:
struct max_heap {
int* h;//堆的数据,用数组存储,从下标为1开始
int size;//一共有多少个元素
max_heap(int*h,int size) {
this->size = size;
this->h = new int[size + 1];
for (int i = 0;i < size;i++)
this->h[i + 1] = h[i];
create_heap();
}
create_heap(){
//建堆的主体内容
}
}
你看我说啥,是不是构造函数也是这样,把传入的数组复制一下,然后二话没说调用建堆()
。
绕不开的,来吧,开始建堆吧:
void create_heap() {
int parent = 1;
int child = 2;
for (int node = size / 2;node >0;node--) {
parent = node;
child = node * 2;
int tmp = h[parent];//核心思想是移动parent
for (;child <= size;child *= 2) {
if (h[child] < h[child+1]&&child+1<=size)
child++;
if (tmp> h[child])//寻找合适的位置,插入parent
break;
h[parent] = h[child];
parent = child;
}
h[parent] = tmp;
}
}
首先我们定义parent
,child
,它本身是啥其实无所谓,只是怕为空,可能会出错,所以不妨先定义成$1$,$2$。开始循环哦:
我们从第一个非叶节点开始,目标是让每个子树都是大根堆,回忆一下,我们的第二个性质。所以叶节点本来就不用排,你要是放进去也可以,就是会消耗循环次数,有点不划算。
然后呢,我们找当前parent
手上两个子节点里最大的一个,也就是我们第一个判断语句的作用。我们得知道,我们的任务是给这个parent
一个合适的归宿,这个是核心任务,这个归宿的意思就是放进去之后,这颗子树就是大根堆,然后接着,如果最原始的那个parent
值已经满足放在当前的位置就ok了,那这个地方就是它的归宿,所以我把他break
掉,如果不然,那就向下继续寻找,怎么向下呢,就是先把child
值上移一层,然后用for
内部的child <= size;child *= 2)
东西让它下移,如此直到末尾。
呼!终于写完了,我们接下来补充边角的东西就行了。
int top() {
return h[1];
}
void pop() {
h[1] = h[size--];
create_heap();
}
void insert(int x) {
h[++size] = x;
create_heap();
}
因为是大根堆,我们想做一个升序序列,所以:
void heap_sort(int* data, int n) {
max_heap mh(data, n);
int* tmp = new int[n];
for (int i = 0;i < n;i++) {
int a = mh.top();
tmp[i] = a;
mh.pop();
}
for (int i = 0;i < n;i++) {
data[n - i - 1] = tmp[i];//tmp已经是正序了
}
delete[]tmp;
}
大功告成!
是不是有点乱,下面我把整体的代码贴出来:
struct max_heap {
int* h;
int size;
max_heap(int*h,int size) {
this->size = size;
this->h = new int[size + 1];
for (int i = 0;i < size;i++)
this->h[i + 1] = h[i];
create_heap();
}
void create_heap() {
int parent = 1;
int child = 2;
for (int node = size / 2;node >0;node--) {
parent = node;
child = node * 2;
int tmp = h[parent];//核心思想是移动parent
for (;child <= size;child *= 2) {
if (h[child] < h[child+1]&&child+1<=size)
child++;
if (tmp> h[child])//寻找合适的位置,插入parent
break;
h[parent] = h[child];
parent = child;
}
h[parent] = tmp;
}
}
int top() {
return h[1];
}
void pop() {
h[1] = h[size--];
create_heap();
}
void insert(int x) {
h[++size] = x;
create_heap();
}
void print() {
for (int i = 1;i <= size;i++)
printf("%d%c", h[i], (i == size) ? '\n' : ' ');
//cout << this->top()<< endl;
}
};
void heap_sort(int* data, int n) {
max_heap mh(data, n);
int* tmp = new int[n];
for (int i = 0;i < n;i++) {
int a = mh.top();
tmp[i] = a;
mh.pop();
}
for (int i = 0;i < n;i++) {
data[n - i - 1] = tmp[i];//tmp已经是正序了
}
delete[]tmp;
}
归并排序
到最后一个排序了,是不是有点小激动,我也是!累死了,其实我觉得归并排序又有点像快速排序的风格,但是它的分治又有点长相不同:
归并排序左边
归并排序右边
合一起!
好了,那么写出来的就是:
void merge_sort(int* data, int begin, int end) {
if (data == nullptr || end <= begin)
return;
int mid = (end + begin) / 2;//注意是加号
merge_sort(data, begin, mid);
merge_sort(data, mid + 1, end);
merge(data, begin, mid, end);
}
注意,里面的int mid=(end+begin)/2
是一个加号,这个我一开始写错了死活调不出来。
好了,那么最关键的还是里面的merge
最困难了,哎,是不是感觉历史惊人地相似(回忆一下partition
)。merge
的本质就是创建一个新的数组(就不说原地算法了),然后我从左边和右边选相对较小的元素挨个放进新数组对吧,这样的每次复杂度就是$O(n_1+n_2)$,其中$n_1$和$n_2$分别是左边和右边那个数组的长度,其实按照关系,它们长度相差$1$或者相等,也可以合并起来。
不过我们的任务不是去分析它们,你只要知道它是线性复杂度就行了,你是不是在想,我们是不是找到了一个线性复杂度的排序方法?震惊!别傻了hhh,这样做可行是基于每个子问题它都是有序的,我们才可以这样做,如果是乱序的,那么……你去试试吧,肯定不成。
好了,言归正传,我们把可以比较的部分都放进去之后,假设还有残余呢,那么就把残余的直接放进去就好了。
说着很麻烦,但是写起来很容易!!!
模版:
我们定义左边界、中间、右边界分别为begin
、mid
、end
。
while(左边指针没到mid,右边指针没到end):
找到较小的值写进去;
指针右移
while(左边指针没到mid):
抄进去
指针右移:
while(右指针没到end):
抄进去
指针右移:
它们是不是很对偶,超有美感,顺手就写出来了!
void merge(int* data, int begin, int mid, int end) {
if (data == nullptr || end <= begin)
return;
int shift_1 = begin;
int shift_2 = mid+1;
int shift = 0;
int* tmp = new int[end - begin + 1];
while (shift_1 <= mid && shift_2 <= end) {
if (data[shift_1 ]<data[shift_2])
tmp[shift++] = data[shift_1++];
else
tmp[shift++] = data[shift_2++];
}
while (shift_1 <= mid)
tmp[shift++] = data[shift_1++];
while (shift_2 <= end)
tmp[shift++] = data[shift_2++];
for (int i = 0;i < end - begin + 1;i++)
data[begin + i] = tmp[i];
delete[]tmp;
}
总结
这一个博文不是给初学者的,自然有很多地方不是按照初学者的视角去总结的,我更多地是站在自己从迷迷糊糊,到完全捡起的这个过程中的体悟写出来的(包括一些自言自语吧),希望能对大家有帮助,也希望大家能够多多讨论,最后,别忘了复杂度的分析哦,关于复杂度请参考:
数据结构难点坑点要点总结
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。