面试中最常见的排序算法--归并排序篇

873 阅读5分钟

这是我参与更文挑战的第 18 天,活动详情查看: 更文挑战

排序在工程应用中有非常重要的作用,比如你随意点开一个搜索引擎,通过搜索得到的结果就是经过排序处理的;你参加互联网电商的秒杀活动,用户请求到达服务器之后,服务端程序会根据请求到达的时间进行排序处理。在数据库的设计中,字段的有序性也会影响我们的查询性能。

因此,面试中出现关于排序的算法题也就不足为奇了,接下来我将探讨面试中最经常出现的排序算法之一:归并排序

归并排序

归并排序是将一个数组里面的元素进行排序的有效手段。它应该是在读书时学习的一个非常经典的排序算法了。不过这里我们不再采用教科书上的讲解方式,而是采用与“二叉树”进行结合的方式来学习。合并排序本质上与二叉树的后序遍历非常类似的。

后序遍历有个重要的特点:拿到子树的信息,利用子树的信息,整合出整棵树的信息。

如果我们把有序性也当成信息,那么归并排序本质上就是一个后序遍历。

可以用伪代码表示如下:

function 后序遍历/归并排序():
    sub_info = 子结构(子树/子数组)
    full_info = 整合(sub_info)

那么合并排序/后序遍历的考点就可以总结为以下 3 点:

  • 如何划分子结构
  • 获取子结构的信息
  • 利用子结构的信息,整合出整棵树的信息

那么接下来我们就从这三方面入手。并且与二叉树的后序遍历的代码对照着一起看。

在正式开始讲题目之前,我们先学习一下开闭原则。实际上,绝大部分语言在设计的时候,都是按照这个原则。比如数组的第一个元素取下标 0,那么长度为 n 的数组,就需要用开闭原则区间 [0, n) 来表示。

这样表示好处理,区间长度直接就是右边界减去左边界。比如 [0, 10) 的区间长度就是 10。但是如果使用双闭区间,比如 [0, 9],那么求区间长度时,运行式为:9 - 0 + 1。还需要在减法的基础上加 1。

1. 划分

首先我们看一下如何划分子数组。对于二叉树而言,子树的划分是天然的,已经在数据结构里面约定好了,比如 TreeNode.left、TreeNode.right。

但是对于数组而言,在切分的时候,如果想到达最优的效率,那么将数组切为平均的两半效率应该是最高的(可以联想到二叉平衡树的效率)。

二叉树的写法如下:

// 二叉树的后序遍历左右子树
root.left, root.right; 可以直接通过root的结构体信息得到。

归并排序的写法如下:

// 归并排序切分左右子数组
final int m = b + ((e-b)>>1);
数组a, [b, m) => 表示左子数组。
数组a, [m, e) => 表示右子数组。
需要通过计算得到。

2. 子数组的信息

由于这里是排序,那么就分别需要对左子数组和右子数组进行排序。如果你还能想起来我们在“第 06 讲”介绍过的“二叉树的后序遍历”,那么对子数组的排序,只需要递归就可以了。

// 二叉树的后序遍历拿左右子树的信息
postOrder(root.left);
postOrder(root.right);

归并排序则需要这样写:

// 归并排序去拿左右子数组的信息
merge_sort(a, b, m);
merge_sort(a, m, e);

3. 信息的整合

接下来,我们需要将从子树/子数组里面拿到的信息进行加工。不同的需求会导致加工的方式也不太一样。对于合并排序而言,我们需要将两个有序的子数组,合并成一个大的有序的数组。

最后,还需要考虑一下边界:

  • 当 b >= e,说明这个区间是一个空区间,没有必要再排序;
  • 当 b + 1 == e,说明只有一个元素,也没有必要排序。

以上两种边界情况分别可以对应到当二叉树为空,以及二叉树只有一个结点的情况。

完整代码

到这里,我们已经可以写出归并排序的代码了(解析在注释里):

class Solution {
  private void msort(int[] a, int b, int e, int t[]) {
    // 空区间 或 只有一个元素
    // 为了防止b + 1溢出,这里用b >= e先判断一下
    if (b >= e || b + 1 >= e) {
      return;
    }
    // 分成两半, 二叉树可以自动取得root.left, root.right
    // 这里我们需要通过计算来得到左右子数组。
    final int m = b + ((e - b) >> 1);

    // 类比二叉树分别遍历左子树和右子树。
    msort(a, b, m, t);
    msort(a, m, e, t);
    // i指向左子数组的开头,j指向右子数组的开头
    // to指向要临时数组t与区间[b, e)对应的位置
    int i = b;
    int j = m;
    int to = b;

    // 将两个子数组进行合并, 注意下面是一个很重要的模板
    // 这里的判断条是,只要两个子数组中还有元素
    while (i < m || j < e) {
      // 如果右子数组没有元素 或 左子数组开头的元素小于右子数组开头的元素
      // 那么取走左子数组开头的元素
      // 考点:a[i] <= a[j]这样可以保证合并排序是稳定的,不要写错!
      if (j >= e || i < m && a[i] <= a[j]) {
        t[to++] = a[i++];
      } else {
        // 否则就是取右子数组开头的元素
        t[to++] = a[j++];
      }
    }
    // 把合并的结果拷回原来的数组a[]
    for (i = b; i < e; i++) {
      a[i] = t[i];
    }
  }
  public void mergeSort(int[] nums) {
    // 如果传进来的数组为空
    if (nums == null || nums.length == 0) {
      return;
    }
    // t是一个临时中转的数组
    int[] t = new int[nums.length];
    msort(nums, 0, nums.length, t);
  }
}

复杂度分析:时间复杂度 O(NlgN),空间复杂度 O(N)。

小结

这里我们归纳一下归并排序的考点:

  • 如何切分左右子数组;
  • 如何进行合并,合并时注意循环的条件,以及稳定排序的写法;
  • 开闭原则。