查看原文
其他

面试官爱问的 10 大经典排序算法,20+ 张图来搞定

The following article is from 技术让梦想更伟大 Author 李肖遥

(给算法爱好者加星标,修炼编程内功

作者:技术让梦想更伟大 / 李肖遥 (本文来自作者投稿)

冒泡排序

简介

冒泡排序是因为越小的元素会经由交换以升序或降序的方式慢慢到数列的顶端,就如同碳酸饮料中二氧化碳的气泡最终会上浮到顶端一样,故名冒泡排序

复杂度与稳定性

思路原理

以顺序为例

  1. 从第一个元素开始一个一个的比较相邻的元素,如果第一个比第二个大即a[1]>a[2],就彼此交换。

  2. 从第一对到最后一对,对每一对相邻元素做一样的操作。此时在最后的元素应该会是最大的数,我们也称呼一遍这样的操作一趟冒泡排序

  3. 针对所有的元素重复以上的步骤,每一趟得到的最大值已放在最后,下一次操作则不需要将此最大值纳入计算。

  4. 持续对每次对越来越少的元素,重复上面的步骤。

  5. 直到所有的数字都比较完成符合a[i]<a[i+1],即完成冒泡排序。

图示过程

以数组数据{ 70,50,30,20,10,70,40,60}为例:

如图,每一次排序把一个最大的数被放在了最后,然后按照这个趋势逐渐往前,直到按从小到大的顺序依次排序。

到了第4轮的时候,整个数据已经排序结束了,但此时程序仍然在进行。

直到第5,6,7轮程序才算真正的结束,这其实是一种浪费算力的表现

主要代码实现

void bubble_sort(int a[],int n) {
    for(int i=0; i<n; i++) {
        for(int j=0; j<n-i; j++) {
            if(a[j]>a[j+1]) {
                swap(a[j],a[j+1]);  //交换数据
            }
        }
    }
}

注意,由于C++的namespace std命名空间的使用,std自带了交换函数swap(a,b),可以直接使用,其功能是交换a与b的两个值,当然你可以自定义swap函数,其模板代码为:

template<class T>        //模板类,可以让参数为任意类型
void swap(T &a,T &b) {
    T c(a);
    a=b;
    b=c;
}

或者指定类型修改为整形,如:

void swap(int &a, int &b) { //指定类型
    int temp = a;
    a = b;
    b = temp;
}

选择排序

简介

选择排序是一种简单直观的排序算法,它从待排序的数据元素中选出最小或最大的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小或最大元素,然后放到已排序的序列的末尾。以此类推,直到全部待排序的数据元素的个数为零。

复杂度与稳定性

过程介绍(以顺序为例)

  1. 首先设置两个记录i和j,i从数组第一个元素开始,j从(i+1)个元素开始。

  2. 接着j遍历整个数组,选出整个数组最小的值,并让这个最小的值和i的位置交换。

  3. i选中下一个元素(i++),重复进行每一趟选择排序。

  4. 持续上述步骤,使得i到达(n-1)处,即完成排序 。

图示过程:

以数据{2,10,9,4,8,1,6,5}为例

如图所示,每次交换的数据使用红颜色标记出,已经排好序的数据使用蓝底标注,

每一趟从待排序的数据元素中选出最小的一个元素,顺序放在已排好序的数列的最后,直到全部待排序的数据元素排完。

我们只需要进行n-1趟排序即可,因为最后剩下的一个数据一定是整体数据中最大的数据。

代码实现

void select_sort(int a[],int n){
    int temp;
    for(int i=0;i<n-1;i++){
        temp=i;      //利用一个中间变量temp来记录需要交换元素的位置
        for(int j=i+1;j<n;j++){
            if(a[temp]>a[j]){   //选出待排数据中的最小值
                temp=j;  
            }
        }
        swap(a[i],a[temp]); //交换函数
    }

相比冒泡排序的不断交换,简单选择排序是等到合适的关键字出现后再进行交换,并且交换一次就可以达到一次冒泡的效果。

插入排序

简介

插入排序是一种最简单的排序方法,对于少量元素的排序,它是一个有效的算法。

复杂度与稳定性

过程介绍

首先将一个记录插入到已经排好序的有序表中,从而一个新的、记录数增1的有序表。

每一步将一个待排序的元素,按其排序码的大小,插入到前面已经排好序的一组元素的适当位置上去,直到元素全部插入为止。

可以选择不同的方法在已经排好序数据表中寻找插入位置。根据查找方法不同,有多种插入排序方法,下面要介绍的是直接插入排序。

  1. 每次从无序表中取出第一个元素,把它插入到有序表的合适位置,使有序表仍然有序。

  2. 第一趟比较前两个数,然后把第二个数按大小插入到有序表中;

  3. 第二趟把第三个数据与前两个数从后向前扫描,把第三个数按大小插入到有序表中;

  4. 依次进行下去,进行了(n-1)趟扫描以后就完成了整个排序过程。

图示过程

以数组数据{ 70,50,30,20,10,70,40,60}为例:

将红色的数据依次插入组成一个逐渐有序的数组

代码实现

void insert_sort(int a[],int n) {
    int i,j;
    //外层循环标识并决定待比较的数值
    for(i=1; i<n; i++) { //循环从第2个元素开始
        if(a[i]<a[i-1]) {
            int temp=a[i];
            //待比较数值确定其最终位置
            for(j=i-1; j>=0 && a[j]>temp; j--) {
                a[j+1]=a[j];
            }
            a[j+1]=temp;//此处就是a[j+1]=temp;
        }
    }
}

希尔排序

简介

希尔排序又称缩小增量排序,是直接插入排序算法的一种更高效的改进版本。

希尔排序是非稳定排序算法,在对几乎已经排好序的数据操作时,效率极高,即可以达到线性排序的效率。

复杂度与稳定性

过程介绍

先将整个待排序的记录序列分组,对若干子序列分别进行直接插入排序,随着增量逐渐减少即整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。

过程如下:

  1. 选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;

  2. 按增量序列个数 k,对序列进行 k 趟排序;

  3. 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。

  4. 仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

图示过程

可以看见,相比直接插入排序由于可以每趟进行分段操作,故整体效率体现较高。

主要代码实现

void shellSort(int arr[], int n) {
    int i, j, gap;
    for (gap = n / 2; gap > 0; gap /= 2) {
        for (i = 0; i < gap; i++) {
            for (j = i + gap; j < n; j += gap) {
                for (int k = j; k > i && arr[k] < arr[k-gap]; k -= gap) {
                    swap(arr[k-gap], arr[k]);
                }
            }
        }
    }
}

堆排序

简介

堆排序是指利用堆这种数据结构所设计的一种排序算法,它是一个近似完全二叉树的结构。

同时堆满足堆积的性质:即子结点的键值或索引总是小于或大于它的父节点。

复杂度与稳定性

什么是堆?

由于堆排序比较特殊,我们先了解一下堆是什么。

堆是一种非线性的数据结构,其实就是利用完全二叉树的结构来维护的一维数组,利用这种结构可以快速访问到需要的值,堆可以分为大顶堆和小顶堆。

  • 大顶堆:每个结点的值都大于或等于其左右孩子结点的值

  • 小顶堆:每个结点的值都小于或等于其左右孩子结点的值

过程介绍

首先把待排序的元素按照大小在二叉树位置上排列,且要满足堆的特性,如果根节点存放的是最大的数,则叫做大根堆,反之就叫做小根堆了。

根据这个特性就可以把根节点拿出来,然后再堆化下,即用父节点和他的孩子节点进行比较,取最大的孩子节点和其进行交换,再把根节点拿出来,一直循环到最后一个节点,就排序好了。

由于堆的实现图解需要很长篇幅,故这里不画图,肖遥会单独出一篇堆的图解,感谢关注。其代码实现如下。

主要代码实现

/* Function: 交换交换根节点和数组末尾元素的值*/
void Swap(int *heap, int len) {
    int temp;
  
    temp = heap[0];
    heap[0] = heap[len-1];
    heap[len-1] = temp;
}
  
/* Function: 构建大顶堆 */
void BuildMaxHeap(int *heap, int len) {
    int i,temp;
    for (i = len/2-1; i >= 0; i--) {
        if ((2*i+1) < len && heap[i] < heap[2*i+1]) {  /* 根节点大于左子树 */
            temp = heap[i];
            heap[i] = heap[2*i+1];
            heap[2*i+1] = temp;
            /* 检查交换后的左子树是否满足大顶堆性质 如果不满足 则重新调整子树结构 */
            if ((2*(2*i+1)+1 < len && heap[2*i+1] < heap[2*(2*i+1)+1]) || (2*(2*i+1)+2 < len && heap[2*i+1] < heap[2*(2*i+1)+2])) {
                BuildMaxHeap(heap, len);
            }
        }
        if ((2*i+2) < len && heap[i] < heap[2*i+2]) {  /* 根节点大于右子树 */
            temp = heap[i];
            heap[i] = heap[2*i+2];
            heap[2*i+2] = temp;
            /* 检查交换后的右子树是否满足大顶堆性质 如果不满足 则重新调整子树结构 */
            if ((2*(2*i+2)+1 < len && heap[2*i+2] < heap[2*(2*i+2)+1]) || (2*(2*i+2)+2 < len && heap[2*i+2] < heap[2*(2*i+2)+2])) {
                BuildMaxHeap(heap, len);
            }
        }
    }
}

归并排序

简介

归并排序是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法的一个非常典型的应用,其核心思想是将两个有序的数列合并成一个大的有序的序列。

复杂度与稳定性

注:归并排序需要创建一个与原数组相同长度的数组来辅助排序

过程介绍

首先将已有序的子序列合并,得到完全有序的序列,即先使每个子序列有序,再使子序列段间有序。

过程如下:

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列

  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置

  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置

  4. 重复步骤c直到某一指针超出序列尾

  5. 将另一序列剩下的所有元素直接复制到合并序列尾

图示过程

第一次排序将数据分为“两个”一组,组内顺序,其次再逐个的将各组进行整合,最终完成归并排序

主要代码实现

void merge(int arr[],int l,int mid,int r) {
    int aux[r-l+1];//开辟一个新的数组,将原数组映射进去
    for(int m=l; m<=r; m++) {
        aux[m-l]=arr[m];
    }
  
    int i=l,j=mid+1;//i和j分别指向两个子数组开头部分
  
    for(int k=l; k<=r; k++) {
        if(i>mid) {
            arr[k]=aux[j-l];
            j++;
        } else if(j>r) {
            arr[k]=aux[i-l];
            i++;
        } else if(aux[i-l]<aux[j-l]) {
            arr[k]=aux[i-l];
            i++;
        } else {
            arr[k]=aux[j-l];
            j++;
        }
    }
}
  
void merge_sort(int arr[],int n) {
    for(int sz=1; sz<=n; sz+=sz) {
        for(int i=0; i+sz<n; i+=sz+sz) { //i+sz防止越界
            //对局部:arr[i...sz-1]和arr[i+sz.....i+2*sz-1]进行排序
            merge(arr,i,i+sz-1,min(i+sz+sz-1,n-1));    //min函数防止越界
        }
    }
  
}
  

快速排序

简介

快速排序在1960年提出,是考察次数最多的排序,无论是在大学专业课的期末考试,还是在公司的面试测试题目中,快速排序都极大的被使用,在实际中快速排序也极大的被使用。

复杂度与稳定性

过程介绍

通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

  1. 在数组中选择一个基准点

  2. 分别从数组的两端扫描数组,设两个指示标志

  3. 从后半部分开始,如果发现有元素比该基准点的值小,就交换位置

  4. 然后从前半部分开始扫描,发现有元素大于基准点的值,继续交换位置

  5. 如此往复循环,然后把基准点的值放到high这个位置,排序完成

以后采用递归的方式分别对前半部分和后半部分排序,当前半部分和后半部分均有序时该数组就自然有序了。

图示过程

可以看出,在第四趟时已经达到顺序,但是仍然还是会继续计算几趟直到完成全部运算

主要代码实现

void qucik_sort(int a[],int low,int high) {
  int i,j,temp;
  i=low;
  j=high;
  if(low<high) {
    temp=a[low];    //设置枢轴
    while(i!=j) {
      while(j>i&&a[j]>=temp) {
        --j;
      }
      if(i<j) {
        a[i]=a[j];
        ++i;
      }

      while(i<j&&a[i]<temp) {
        ++i;
      }
      if(i<j) {
        a[j]=a[i];
        --j;
      }
    }
    a[i]=temp;
    qucik_sort(a,low,i-1);
    qucik_sort(a,i+1,high);
  }
}

计数排序

简介

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

计数排序算法不是基于元素比较,而是利用数组下标来确定元素的正确位置。

它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k),快于任何比较排序算法。

当然这是一种牺牲空间换取时间的做法,而且当O(k)>O(n*log(n))的时候其效率反而不如基于比较的排序。

复杂度与稳定性

过程介绍

  1. 找出待排序的数组中最大和最小的元素

  2. 统计数组中每个值为i的元素出现的次数,存入数组C的第i项

  3. 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)

  4. 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1

图示过程

如下图,A为待排序的数组,C记录A中各个值的数目。

将C[i]转换为值小于等于i的元素个数。

为数组A从后向前的每个元素找到对应的B中的位置,每次从A中复制一个元素到B中,C中相应的计数减一。

当A中的元素都复制到B中后,B就是排序好的结果,如图所示。

代码实现

#include<stdio.h>
#include<stdlib.h>
#include<string.h>

void CountSort(int *arr, int len){
    if(arr == NULL) return;
    int max = arr[0], min = arr[0];
    for(int i = 1; i < len; i++){
        if(arr[i] > max)    max = arr[i];
        if(arr[i] < min)    min = arr[i];
    }
    int size = max - min + 1;
    int *count =(int*)malloc(sizeof(int)*size);
    memset(count, 0, sizeof(int)*size);

    for(int i = 0; i < len; i++)
        count[arr[i] - min]++;//包含了自己!
    for(int i = 1; i < size; i++)
        count[i] += count[i - 1];

    int* psort =(int*)malloc(sizeof(int)*len);
    memset(psort, 0, sizeof(int)*len);

    for(int i = len - 1; i >= 0; i--){
        count[arr[i] - min]--;//要先把自己减去
        psort[count[arr[i] - min]] = arr[i];
    }

    for(int i = 0; i < len; i++){
        arr[i] = psort[i];
    }

    free(count);
    free(psort);
    count = NULL;
    psort = NULL;
}


void print_array(int *arr, int len)
{
    for(int i=0; i<len; i++)
        printf("%d ", arr[i]);
    printf("\n");
}

int main()
{
    int arr[8] = {2, 5, 3, 0, 2, 3, 0, 3};
    CountSort(arr, 8);
    print_array(arr, 8);

    return 0;
}

桶式排序

简介

桶排序也称箱排序,原理是将数组分到有限数量的桶子里。每个桶子再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)。

桶排序是鸽巢排序的一种归纳结果。当要被排序的数组内的数值是均匀分配的时候,桶排序使用线性时间(Θ(n))。但桶排序并不是 比较排序,他不受到 O(n log n) 下限的影响。

复杂度与稳定性

过程介绍

  1. 根据待排序集合中最大元素和最小元素的差值范围和映射规则,确定申请的桶个数;

  2. 遍历待排序集合,将每一个元素移动到对应的桶中;

  3. 对每一个桶中元素进行排序,并移动到已排序集合中。

图示过程

元素分布在桶中:

然后,元素在每个桶中排序:

代码实现

#include<iterator>
#include<iostream>
#include<vector>
using namespace std;
const int BUCKET_NUM = 10;

struct ListNode{
    explicit ListNode(int i=0):mData(i),mNext(NULL){}
    ListNode* mNext;
    int mData;
};

ListNode* insert(ListNode* head,int val){
    ListNode dummyNode;
    ListNode *newNode = new ListNode(val);
    ListNode *pre,*curr;
    dummyNode.mNext = head;
    pre = &dummyNode;
    curr = head;
    while(NULL!=curr && curr->mData<=val){
            pre = curr;
            curr = curr->mNext;
    }
    newNode->mNext = curr;
    pre->mNext = newNode;
    return dummyNode.mNext;
}


ListNode* Merge(ListNode *head1,ListNode *head2){
    ListNode dummyNode;
    ListNode *dummy = &dummyNode;
    while(NULL!=head1 && NULL!=head2){
            if(head1->mData <= head2->mData){
                    dummy->mNext = head1;
                    head1 = head1->mNext;
            }else{
                    dummy->mNext = head2;
                    head2 = head2->mNext;
            }
            dummy = dummy->mNext;
    }
    if(NULL!=head1) dummy->mNext = head1;
    if(NULL!=head2) dummy->mNext = head2;

    return dummyNode.mNext;
}

void BucketSort(int n,int arr[]){
    vector<ListNode*> buckets(BUCKET_NUM,(ListNode*)(0));
    for(int i=0;i<n;++i){
            int index = arr[i]/BUCKET_NUM;
            ListNode *head = buckets.at(index);
            buckets.at(index) = insert(head,arr[i]);
    }
    ListNode *head = buckets.at(0);
    for(int i=1;i<BUCKET_NUM;++i){
            head = Merge(head,buckets.at(i));
    }
    for(int i=0;i<n;++i){
            arr[i] = head->mData;
            head = head->mNext;
    }
}

基数排序

简介

基数排序是透过键值的部份资讯,将要排序的元素分配至某些“桶”中,藉以达到排序的作用,在某些时候,基数排序法的效率高于其它的稳定性排序法。

复杂度与稳定性

图示过程

设有一个初始序列为: R {50, 123, 543, 187, 49, 30, 0, 2, 11, 100}

过程介绍

任何一个阿拉伯数,它的各个位数上的基数都是以0~9来表示的。所以我们不妨把0~9视为10个桶。

  1. 我们先根据序列的个位数的数字来进行分类,将其分到指定的桶中。

  2. 分类后,我们在从各个桶中,将这些数按照从编号0到编号9的顺序依次将所有数取出来。

  3. 得到的序列就是个位数上呈递增趋势的序列。

  4. 按照上图个位数排序:{50, 30, 0, 100, 11, 2, 123, 543, 187, 49}

  5. 接下来对十位数、百位数也按照这种方法进行排序,最后就能得到排序完成的序列。

主要代码实现

public class RadixSort {
    // 获取x这个数的d位数上的数字
    // 比如获取123的1位数,结果返回3
    public int getDigit(int x, int d) {
        int a[] = {1, 1, 10, 100}; // 本实例中的最大数是百位数,所以只要到100就可以了
        return ((x / a[d]) % 10);
    }

  public void radixSort(int[] list, int begin, int end, int digit) {
      final int radix = 10; // 基数
      int i = 0, j = 0;
      int[] count = new int[radix]; // 存放各个桶的数据统计个数
      int[] bucket = new int[end - begin + 1];
      // 按照从低位到高位的顺序执行排序过程
      for (int d = 1; d <= digit; d++) {
          // 置空各个桶的数据统计
          for (i = 0; i < radix; i++) {
              count[i] = 0;
          }
          // 统计各个桶将要装入的数据个数
          for (i = begin; i <= end; i++) {
              j = getDigit(list[i], d);
              count[j]++;
          }
          // count[i]表示第i个桶的右边界索引
          for (i = 1; i < radix; i++) {
              count[i] = count[i] + count[i - 1];
          }
          // 将数据依次装入桶中
          // 这里要从右向左扫描,保证排序稳定性
          for (i = end; i >= begin; i--) {
              j = getDigit(list[i], d);
              // 求出关键码的第k位的数字, 例如:576的第3位是5
              bucket[count[j] - 1] = list[i];
              // 放入对应的桶中,count[j]-1是第j个桶的右边界索引
              count[j]--; // 对应桶的装入数据索引减一
          }
          // 将已分配好的桶中数据再倒出来,此时已是对应当前位数有序的表
          for (i = begin, j = 0; i <= end; i++, j++) {
              list[i] = bucket[j];
          }
      }
  }

  public int[] sort(int[] list) {
      radixSort(list, 0, list.length - 1, 3);
      return list;
  }

  // 打印完整序列
  public void printAll(int[] list) {
      for (int value : list) {
          System.out.print(value + "\t");
      }
      System.out.println();
  }

  public static void main(String[] args) {
      int[] array = {50, 123, 543, 187, 49, 30, 0, 2, 11, 100};
      RadixSort radix = new RadixSort();
      System.out.print("排序前:\t\t");
      radix.printAll(array);
      radix.sort(array);
      System.out.print("排序后:\t\t");
      radix.printAll(array);
  }
}

总结

10种排序算法对比,我们了解到了各种排序的原理及优缺点,记住任何一种排序都不是十全十美的,因此在我们实际应用中,最好的方式就是扬长避短。



- EOF -

推荐阅读  点击标题可跳转

1、图解排序算法:归并排序

2、图解排序算法:快速排序

3、深入理解快速排序和 STL 的 sort 算法


觉得本文有帮助?请分享给更多人

关注「算法爱好者」加星标,修炼编程内功

点赞和在看就是最大的支持❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存