算法工程师面试必考项:排序算法

共 1739字,需浏览 4分钟

 ·

2020-08-24 21:24


AI编辑:我是小将


排序是最基本的算法之一,常见的排序算法有插入排序、希尔排序、选择排序、冒泡排序、堆排序、归并排序及快速排序。每个排序算法的时间复杂度是不同的,但是最优的时间复杂度是O(NlogN)。有些排序算法是原址排序(即不需要额外空间),也有一些是非原址排序,这也是需要注意的特点。同样地,还要注意排序算法是否是稳定排序,这有时候很重要。这篇文章简单地介绍各个排序算法的思想,然后使用C++实现各个排序算法。

插入排序

插入排序算法思想很简单,就是将元素插入已经有序的数组来完成排序。假定数组前i-1位置是有序的,现在你要将第i个元素插入这个有序数组,那么可以依次与第i-1,i-2...等位置的元素进行比较,直到找出一个小于该元素的位置j。将j+1i-1位置元素依次后移一个位置,然后将该元素放入第j+1位置。此时前i个位置是有序的。重复这个过程至第N个元素,就可以完成数组的排序了。

// 插入排序
template <typename Comparable>
void insertionSort(vector& v)
{
 for (int i = 1; i < v.size(); ++i)
 {
  // 先保存当前要插入的元素
  Comparable tmp = move(v[i]);
  int j = i;
  for (; j > 0 && tmp < v[j - 1]; --j)
  {
   v[j] = move(v[j - 1]);  // 大于tmp的元素后移
  }
  v[j] = move(tmp);  // 插入正确的位置
 }
}

这里的实现我们采用泛型模板,泛型参数Comparable要求支持比较操作符<。同时我们使用了移动语义,这有利于效率的提升。后面其它算法的实现我们也都采用这种方式。从算法的实现可以看到有两层循环,那么插入排序的复杂度是O(N^2)。此外,插入排序对于两个相等的元素,并不会改变其先后顺序,所以是稳定排序。同时其不需要额外空间,也是原址排序。

希尔排序

希尔排序是以提出者(Donald Shell)命名的。希尔排序与插入排序的思想很相似,但是其使用了一个递增序列H1, H2, ..., Ht。希尔排序每个阶段处理这个递增序列中的一个元素Hk,其进行的是Hk间隔排序,就是保证对于任意的i,要有A[i] < A[i+Hk]。每个阶段的排序利用与插入排序相似的思想:处理的位置ihk, hk + 1, ..., N,而且每个位置的插入比较位置是i, i - Hk, , i - 2Hk...。希尔排序的一个特点是,如果先进行Hk间隔排序,那么Hk-1间隔排序后,Hk间隔仍然保持有序。这是希尔排序有效的重要保证。对于递增序列,常使用的是Ht = floor(N/2),而且Hk = floor((Hk+1)/2)

// 希尔排序
template <typename Comparable>
void shellSort(vector& v)
{
 // 依次处理递增序列各个间隔值(从后往前)
 for (int gap = v.size() / 2; gap > 0; gap /= 2)
 {
  // 对于每个间隔,进行插入排序
  for (int i = gap; i < v.size(); ++i)
  {
   Comparable tmp = move(v[i]);
   int j = i;
   for (; j >= gap && tmp < v[j - gap]; j -= gap)
   {
    v[j] = move(v[j - gap]);
   }
   v[j] = move(tmp);
  }
 }
}

希尔排序的复杂度并不是那么直接,其与选用的递增序列有关,最差状态下为O(N^2),但是如果选用合适的递增序列,其复杂度可以达到次二次时间,如O(N^(3/2))。此外,也可以看到希尔排序是原址排序与稳定排序的。

选择排序

选择排序应该是最直观的排序算法,其将数组分为排序部分与未排序部分,每次在未排序部分选出一个最小值,然后放到排序部分的后面。假定数组前i-1个位置已经有序,那么从未排序序列i, i+1,..., N中找到最小值位置j,然后交换位置j与位置i上元素。选择排序也需要重复这样的过程N-1次。

// 选择排序
template <typename Comparable>
void selectSort(vector& v)
{
 for (int i = 0; i < v.size() - 1; ++i)
 {
  int minIdx = i;
  // 寻找未排序部分的最小值位置
  for (int j = i + 1; j < v.size(); ++j)
  {
   if (v[minIdx] > v[j])
   {
    minIdx = j;
   }
  }
  swap(v[minIdx], v[i]);  // 通过交换将最小值在正确位置
 }
}

选择排序的时间复杂度为O(N^2),且是原址排序。但是选择排序由于交换,导致其是不稳定排序方式。

冒泡排序

冒泡排序如其名,就是让数组元素像水中气泡一样逐渐上浮,从而达到排序的目的。其也是将数组分为排序部分与未排序部分。对于未排序部分,依次比较相邻两个元素,如果前者大于后者则交换其位置。和选择排序与插入排序一样,冒泡排序也应该需要N-1次重复操作,但是有一个更好的选择。那就是在每个阶段,记录一个flag标志,如果没有进行任何一次元素交换,说明未排序部分已经有序,后面就不需要再继续冒泡了。

// 冒泡排序
template <typename Comparable>
void bubbleSort(vector& v)
{
 bool flag = true;  // 是否交换过元素
 for (int i = 0; flag; ++i)
 {
  flag = false;   // 初始为false
  // 向下冒泡
  for (int j = v.size() - 1; j > i; --j)
  {
   if (v[j] < v[j - 1])  // 不是<=,那样是不稳定排序
   {
    swap(v[j], v[j - 1]);
    flag = true;
   }
  }

 }
}

冒泡排序时间复杂度也是O(N^2),而且是原址排序。冒泡排序也是稳定排序,但是要注意交换条件。

归并排序

前面所讨论的排序算法都是复杂度为O(N^2)的低效率排序算法。下面的算法都是时间复杂度为O(NlogN)的高级算法。我们从归并算法说起,归并算法是基于分治策略。归并算法的基础是合并两个已经有序的子数组,将两个已经有序的子数组进行合并是容易的。比如两个有序子数组AB,然后有一个输出数组C。此时你需要三个位置索引ijk,每次比较A[i]B[j],然后将最小者复制到C[k],同时递增相应的位置索引。重复上述过程知道某一个子数组遍历完,未遍历完的子数组剩余部分直接复制到输出数组就完成整个合并过程。利用合并,归并排序算法的步骤为:(1)将数组分为两个大小相等的子数组;(2)对每个子数组进行排序,除非子数组比较小,否则利用递归方式完成排序;(3)合并两个有序的子数组,完成排序。

// 归并排序的辅助方法:合并两个有序数组
// v为要排序数组,tmp为辅助数组
// left为左子数组的开始位置,right为右子数组的开始位置,end为结束位置
template <typename Comparable>
void merge(vector& v, vector& tmp, int left,
 int right, int end)

{
 int tmpPos = left;
 int leftEnd = right - 1;
 int num = end - left + 1;  // 总数

 // 合并两个子数组直到某一个子数组遍历完
 while (left <= leftEnd && right <= end)
 {
  if (v[left] <= v[right])
  {
   tmp[tmpPos++] = move(v[left++]);
  }
  else
  {
   tmp[tmpPos++] = move(v[right++]);
  }
 }

 // 处理左子数组剩余部分
 while (left <= leftEnd) { tmp[tmpPos++] = move(v[left++]); }
 // 处理右子数组剩余部分
 while (right <= end) { tmp[tmpPos++] = move(v[right++]); }

 // 合并结果复制到原数组
 while (num > 0)
 {
  v[end] = move(tmp[end]);
  --end;
  --num;
 }
}

// 归并合并的内部调用函数
// v为要排序数组,tmp为辅助数组
// left要排序数组部分的开始位置,right是结束位置
template <typename Comparable>
void mergeSort(vector& v, vector& tmp, 
 int left, int right)

{
 if (left < right)
 {
  int mid = (left + right) / 2;
  // 递归处理每个子数组
  mergeSort(v, tmp, left, mid);
  mergeSort(v, tmp, mid + 1, right);
  // 合并
  merge(v, tmp, left, mid + 1, right);
 }
}

// 归并排序
template<typename Comparable>
void mergeSort(vector& v)
{
 vector tmp(v.size());
 mergeSort(v, tmp, 0, v.size() - 1);
}

归并排序的时间复杂度是O(NlogN),但是其唯一的问题是需要一个额外的数组空间,所以是非原址排序。但是其也是稳定排序。

堆排序

堆排序是利用堆来进行排序。堆一种特殊的二叉树,对于最大堆来说,其每个节点的值都大于或者等于其子节点的值。可以使用数组来存储堆:将根节点放在第一个数组位置,根节点的左右子节点分别存储在数组的第二与第三位置,然后其左子节点的左右子节点放置在第四与第五位置,依次类推。如果数组索引是从0开始的,对于位置i处的节点,其左子节点位置为2*i+1,其右子节点位置为2*(i+1)。要进行堆排序,首先要建立堆。要构建堆,需要使用一个”下沉过程“,这里我们称为siftdown:首先将位于根节点的键值与其子节点的较大键值进行比较,如果根节点的键值较小,那么就交换根节点与子节点,然后对该子节点重复这个过程,直到到达叶节点或者根节点的键值不小于其子节点的键值。假定一个堆的深度为d,那么首先可以将深度为d-1的节点使用siftdown过程,这样深度为d-1的子树满足堆性质,然后对深度为d-2的节点使用siftdown过程,······,最后处理堆的根节点,此时这个树满足堆性质。一旦我们构建好了堆,我们可以在保持堆性质的同时重复删除根节点,得到的这些根节点是有序的,从而达到排序的目的。怎么在删除根节点之后还保持堆性质呢?这里有一个技巧,我们可以交换根节点与最右子节点的键值,此时将堆将缩减一个元素(最右子节点),然后对当前根节点调用siftdown。我们知道最右子节点恰好是数组最后的位置,所以重复这一过程,可以达到原址排序的目的。

// 返回节点i的左子节点位置
inline int leftChild(int i) return 2 * i + 1; }

// 堆排序辅助函数
// v是存储堆的数组,i是要下沉的节点,n代表当前堆的大小
template <typename Comparable>
void siftDown(vector& v, int i, int n)
{
 int child;
 Comparable tmp = move(v[i]);  // 记录要下沉的值
 while (leftChild(i) < n)
 {
  child = leftChild(i); // 左子节点
  // 寻找最大子节点
  if (child != n - 1 && v[child] < v[child + 1])
  {
   ++child;
  }
  if (tmp < v[child])  // 子节点上移
  {
   v[i] = move(v[child]);
   i = child;
  }
  else  // 终止
  {
   break;
  }
 }
 v[i] = move(tmp);  // 下沉到正确位置
}

template <typename Comparable>
void heapSort(vector& v)
{
 // 先建立堆
 for (int i = v.size() / 2 - 1; i >= 0; --i)
 {
  siftDown(v, i, v.size());
 }
 // 重复删除根节点
 for (int i = v.size() - 1; i > 0; --i)
 {
  swap(v[i], v[0]);  // 交换根节点与最右子节点
  siftDown(v, 0, i);  // 下沉根节点
 }
}

堆排序的时间复杂度也是O(NlogN),其不需要额外空间,是原址排序。但是由于进行了根节点与最右子节点的交换,堆排序是不稳定的。

快速排序

快速排序与归并排序有相似之处,其采用的也是分治的策略。快速排序也将数组划分为两部分,但是其划分是根据一个选定的中心点(pivot),前半部分是小于pivot值,后半部分大于pivot。不断重复这种策略在每个子数组上,即可完成排序。快速排序的一个关键点是选择中心点,选择中心点后要将原数组分割成两部分。如果中心点选择不恰当,那么会导致分割的两个子数组大小严重不平衡,这样快速排序的性能就会恶化。一个比较好的策略是选择数组最左边、最右边与中心位置的中间值,即Median-of-Three策略:

// 快速排序:选定中心点策略 
// v是排序数组,left与right分别是要分割数组的左右边界
template <typename Comparable>
const Comparable& median3(vector& v, int left, int right)
{
 int mid = (left + right) / 2;
 if (v[mid] < v[left]) { swap(v[mid], v[left]); }
 if (v[left] > v[right]) { swap(v[left], v[right]); }
 if (v[mid] > v[right]) { swap(v[mid], v[right]); }

 // left位置的值小于等于pivot,right位置的值一定大于等于pivot,
 // 要分割的数组变成left+1到right-1
 swap(v[mid], v[right - 1]);  // 将pivot放到right-1位置处
 return v[right - 1];
}

这个选择中心点的策略很简单,但是最后把选择的中心点存储在数组倒数第二个位置,这个是为分割做准备的。一旦选定中心点,那么就要根据中心点将数组分为左右两部分。一个比较好的策略是采用左右夹逼。假定要分割的数组是A[left], A[left+1], ..., A[right]。此时记住A[right]此时存储的是中心点的值。我们设置两个位置索引变量iji从最左侧left开始,j从最右侧right-1开始。我们将i向右移动,直到此位置处的值大于或者等于pivot,同时我们将j向左移动,直到此位置处的值小于或者等于pivot。如果此时i还在j的左侧,我们交换位置i和位置j处的值。然后重复上面的过程直到i出现在j的右侧,此时我们只需要交换位置i与位置right处的值就完成了分割。完成分割后,我们只需要递归处理每个子数组即可。

// 快速排序辅助函数
template <typename Comparable>
void quickSort(vector& v, int left, int right)
{
 if (left + 1 < right) // 3个及以上元素
 {
  Comparable pivot = median3(v, left, right);  // 中心点
  int i = left, j = right - 1;
  while (true)
  {
   while (v[++i] < pivot) {}  // i右移
   while (v[--j] > pivot) {}  // j左移
   if (i < j)
   {
    swap(v[i], v[j]);
   }
   else
   {
    break;
   }
  }
  swap(v[i], v[right - 1]);
  // 对子数组递归
  quickSort(v, left, i - 1);
  quickSort(v, i + 1, right);
 }
 else if (left < right)  // 两个元素
 {
  if (v[left] > v[right]) { swap(v[left], v[right]); }
 }
}

// 快速排序
template <typename Comparable>
void quickSort(vector& v)
{
 quickSort(v, 0, v.size() - 1);
}

递归时,要分两种情况,三个及以上元素时继续调用快速排序,但是两个元素时,必须要单独处理。因为选定中心点需要三个及以上元素。其实,快速排序对于小数组优势并不是很明显,当数组较小时,可以使用其它排序算法处理,比如插入排序:

// 插入排序
template <typename Comparable>
void insertionSort(vector& v, int left, int right)
{
 for (int i = 1; i < v.size(); ++i)
 {
  Comparable tmp = move(v[i]);
  int j = i;
  for (; j > 0 && tmp < v[j - 1]; --j)
  {
   v[j] = move(v[j - 1]);
  }
  v[j] = move(tmp);
 }
}
// 快速排序辅助函数
const int SIZE = 5;
template <typename Comparable>
void quickSort(vector& v, int left, int right)
{
 if (left + SIZE < right) 
 {
  Comparable pivot = median3(v, left, right);  // 中心点
  int i = left, j = right - 1;
  while (true)
  {
   while (v[++i] < pivot) {}  // i右移
   while (v[--j] > pivot) {}  // j左移
   if (i < j)
   {
    swap(v[i], v[j]);
   }
   else
   {
    break;
   }
  }
  swap(v[i], v[right - 1]);
  // 对子数组递归
  quickSort(v, left, i - 1);
  quickSort(v, i + 1, right);
 }
 else 
 {
  insertionSort(v, left, right);
 }
}

// 快速排序
template <typename Comparable>
void quickSort(vector& v)
{
 quickSort(v, 0, v.size() - 1);
}

快速排序的时间复杂度平均为O(NlogN),但是最差时也表现为O(N^2)。其次,快速排序也是原址排序,但是其是不稳定的。

大部分排序算法这里算是介绍完了,从比较上来看,这些排序算法最优性能为O(NlogN)。但是大家可以发现一个事实,这些算法都是通过比较来完成的,而且已经证明O(NlogN)是所有利用比较来进行排序的算法的一个下限。还有一点要说明,这些排序算法都有一个前提,那就是要排序的数组可以全部读进内存。但是当要排序的元素量非常大时,可能无法一下子将所有元素放进内存,此时需要外部排序算法。感兴趣可以去了解。

链表排序

前面的排序方法我们都是处理是vector,其实我们都默认要排序的是数组结构,那么元素可以随机存取(Random Access)。但是,我们知道有些结构是不支持随机存取的,比如链表结构。这里我们简单地讨论单链表结构:

// 单链表节点
class ListNode
{

public:
 int val;
 ListNode* next;
 ListNode(int value, ListNode* nt = nullptr)
  :val{value}, next{nt}
 {}
};

链表结构只能前向遍历,这是很大的限制。但是,这并不代表上面的排序算法不能起作用,只不过要进行修改。我们先看一下如何使用插入排序来对一个链表排序。对于插入排序,关键的是要找到插入点位置,链表只能前向遍历,所以必须从头节点找到插入点位置,而不能采用之前的策略。实现就很简单了:

// 从链表头节点开始寻找插入点位置
ListNode* findInsertPos(ListNode* head, int x)
{
 ListNode* prev = nullptr;
 for (ListNode* cur = head; cur != nullptr && cur->val <= x;
  prev = cur, cur = cur->next)
  ;
 return prev;
}

// 使用插入排序对链表进行排序
void insertionSortList(ListNode* & head)
{
 //// 哑巴节点
 ListNode dummy{ INT_MIN }; // 不要执行dummy.next = head
    // 每次处理一个节点
 for (ListNode* cur = head; cur != nullptr;)
 {
  ListNode* pos = findInsertPos(&dummy, cur->val);  // 确定插入位置
  // 插入此位置
  ListNode* tmp = cur->next;
  cur->next = pos->next;
  pos->next = cur;
  cur = tmp;
 }
 head = dummy.next;
}

上面的插入排序算法的时间复杂度还是O(N^2),并且不要额外空间。我们也可以使用归并排序对链表排序,此时的复杂度为O(NlogN)。具体实现如下:

// 归并两个有序链表
ListNode* mergeList(ListNode* l1, ListNode* l2)
{
 // 哑巴节点
 ListNode dummy{ 0 };
 ListNode* cur = &dummy;
 // 处理公共部分
 for (; l1 != nullptr && l2 != nullptr;
  cur = cur->next)
 {
  if (l1->val <= l2->val)
  {
   cur->next = l1;
   l1 = l1->next;
  }
  else
  {
   cur->next = l2;
   l2 = l2->next;
  }
 }
 // 处理l1剩余部分
 while (l1 != nullptr) { cur->next = l1; l1 = l1->next; cur = cur->next; }
 // 处理l2剩余部分
 while (l2 != nullptr) { cur->next = l2; l2 = l2->next; cur = cur->next; }
 return dummy.next;
}

// 归并排序链表
void mergeSortList(ListNode* & head)
{
 // 无元素或者只有一个元素
 if (head == nullptr || head->next == nullptrreturn;
 // 利用快慢指针找到中间节点
 ListNode* slow = head, *fast = head;
 while (fast->next != nullptr && fast->next->next != nullptr)
 {
  fast = fast->next->next;
  slow = slow->next;
 }

 // 根据中间节点分割链表
 fast = slow;
 slow = slow->next;
 fast->next = nullptr;

 mergeSortList(head);   // 处理前半部分
 mergeSortList(slow);   // 处理后半部分
 head = mergeList(head, slow);  // 归并
}

可以看到,链表的归并排序不需要额外存储空间,这与数组的归并排序不同。

前面说过,仅通过比较的方式来进行排序的算法,其效率不可能优于O(NlogN)。但是也是存在可以在线性时间内完成排序的算法,如桶排序与基数排序。路漫漫其修远兮,感兴趣的继续探索吧!

Reference

[1] Mark Allen Weiss, Data Structures and Algorithm Analysis in C++, fourth edition, 2013.
[2] Richard E. Neapolitan, Foundations of Algorithms, fifth edition, 2016.
[3] LeetCode 题解.


如果觉得不错,请关注一下公众号,也请为小编点个在看!


推荐阅读

VoVNet:实时目标检测的新backbone网络

算法工程师面试必选项:动态规划

物体检测和分割轻松上手:从detectron2开始(上篇)

物体检测和分割轻松上手:从detectron2开始(下篇)

想读懂YOLOV4,你需要先了解下列技术(一)

想读懂YOLOV4,你需要先了解下列技术(二)

PyTorch分布式训练简明教程


机器学习算法工程师


                                            一个用心的公众号


 


浏览 18
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报