前言

我们都知道要进大厂第一关往往就是要手撕一个普普通通的排序,然而这些排序早就在学完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);
}

这很容易对吧,我们先把partitionquick_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;
   }
}

首先我们定义parentchild,它本身是啥其实无所谓,只是怕为空,可能会出错,所以不妨先定义成$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,这样做可行是基于每个子问题它都是有序的,我们才可以这样做,如果是乱序的,那么……你去试试吧,肯定不成。
好了,言归正传,我们把可以比较的部分都放进去之后,假设还有残余呢,那么就把残余的直接放进去就好了。
说着很麻烦,但是写起来很容易!!!
模版:
我们定义左边界、中间、右边界分别为beginmidend

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;
}

总结

这一个博文不是给初学者的,自然有很多地方不是按照初学者的视角去总结的,我更多地是站在自己从迷迷糊糊,到完全捡起的这个过程中的体悟写出来的(包括一些自言自语吧),希望能对大家有帮助,也希望大家能够多多讨论,最后,别忘了复杂度的分析哦,关于复杂度请参考:
数据结构难点坑点要点总结


喵喵狂吠
6 声望5 粉丝

假发消费者,计科小学生。