1. 归并排序

1. 算法介绍

归并排序采用的分治思想,其核心工作流程如下:

  • 将数据分为左右两部分
  • 对左右两部分进行排序
  • 对已经排序好的左右两部分进行合并

具体操作示意图如下:
db7f892d3355ef74da9cd64aa926dc2b.jpg
通过分解图可知,归并排序采用的是一种将大问题转化为小问题的思路,且每个问题解决方法是一样,这就很容易让我们想到递归。
若是用递归解决该问题,我们就需要知道递归公式、终止条件。

  • 终止条件:显而易见,当数据分无可分之时,递归停止。即data_start >= data_end
  • 递归公式:mergesort(start, end)=merge(``merge_sort(start, mid),merge_sort(mid, end)``)

我们将上述公式翻译为代码:

  1. void sort(int *arr, int *tmp, const int &start, const int &end)
  2. {
  3. if (start < end)
  4. {
  5. int middle = (start + end) / 2;
  6. sort(arr, tmp, start, middle);
  7. sort(arr, tmp, middle + 1, end);
  8. merge(arr, tmp, start, middle, end);
  9. }
  10. }

至此,我们已经搞定了数据分割,那么如何对数据进行排序及合并呢?
核心思想为:准备一个临时数组,同时对数据部分的左右两部分进行遍历,若是左侧数据较小,则将其放入临时数组中,左侧指针后移,反之将右侧数据放入数组并将右侧指针后移,待其中一部分遍历完成后,将另一部分的内容按顺序拷贝到临时数组中即可。
下面时操作示意图:
95897ade4f7ad5d10af057b1d144a22f.jpg
具体代码如下:

  1. void merge(int *arr, int *tmp, const int &start, const int &mid, const int &end)
  2. {
  3. int left_start = start;
  4. int left_end = mid;
  5. int right_start = mid + 1;
  6. int right_end = end;
  7. int i = start;
  8. int j = right_start;
  9. int t = 0;
  10. while (i <= left_end && j <= right_end)
  11. {
  12. if (arr[i] <= arr[j])
  13. {
  14. tmp[t++] = arr[i++];
  15. }
  16. else
  17. {
  18. tmp[t++] = arr[j++];
  19. }
  20. }
  21. while (i <= left_end)
  22. {
  23. tmp[t++] = arr[i++];
  24. }
  25. while (j <= right_end)
  26. {
  27. tmp[t++] = arr[j++];
  28. }
  29. t = 0;
  30. while(left_start <= right_end)
  31. {
  32. arr[left_start++] = tmp[t++];
  33. }
  34. }

1.2 算法分析

  • 是否为原地排序算法:否。
  • 是否为稳定排序算法:是。
  • 时间复杂度:通过上述代码我们可以看到,归并排序的时间复杂度与数据有序度毫无关系,因此其时间复杂度非常稳定,最好、最坏、平均时间复杂度均为3-3O(nlogn)的排序算法 - 图4

    1. 上述结果是如何推断出来的呢?<br /> 假设n个元素进行归并排序的时间为![](https://cdn.nlark.com/yuque/__latex/514884be093e9ab7909b0d394e7b74d2.svg#card=math&code=T%28n%29&height=20&width=35),而在一次归并排序中会对两个子串进行归并排序,因此其中一个子串进行归并排序的时间为![](https://cdn.nlark.com/yuque/__latex/7896bf02e3f797e64993d2c3dd5f503b.svg#card=math&code=T%28n%2F2%29&height=20&width=51)。通过分析merge函数,合并两段数据的时间复杂度为![](https://cdn.nlark.com/yuque/__latex/7ba55e7c64a9405a0b39a1107e90ca94.svg#card=math&code=O%28n%29&height=20&width=36),因此我们就得到了以下公式:<br />![](https://cdn.nlark.com/yuque/__latex/c4238f79a2796ee986e50735ab874cb2.svg#card=math&code=T%EF%BC%88n%EF%BC%89%3D%202T%28n%2F2%29%2Bn%20%20%20%20%20%20&height=24&width=166) ![](https://cdn.nlark.com/yuque/__latex/a43481fd7dd2fc4a486459e2b3a7b3e0.svg#card=math&code=T%EF%BC%881%EF%BC%89%3DC&height=24&width=86)<br /> 那么如何求解呢?我们将![](https://cdn.nlark.com/yuque/__latex/7b8b965ad4bca0e41ab51de7b31363a1.svg#card=math&code=n&height=12&width=10)进行均分,可以得到如下公式:<br />
    1. T(n) = 2T(n / 2) + n
    2. = 2(2T(n / 4) + n / 2)) +n = 4(T(n / 4)) + 2n
    3. = 4(2T(n / 8) + n / 4) + 2n = 8(T(n / 8)) + 3n
    4. = (2 ^ K)(T(n / (2 ^ k))) + kn
    1. 通过![](https://cdn.nlark.com/yuque/__latex/a43481fd7dd2fc4a486459e2b3a7b3e0.svg#card=math&code=T%EF%BC%881%EF%BC%89%3DC&height=24&width=86)可知,![](https://cdn.nlark.com/yuque/__latex/6ce53dd199bd00c35131b2f53a1c45c7.svg#card=math&code=n%3D2%20%5E%20k%0A&height=19&width=49),则![](https://cdn.nlark.com/yuque/__latex/16be857e060173b735793ddddadbf7e2.svg#card=math&code=k%3Dlog_2n&height=18&width=70),则![](https://cdn.nlark.com/yuque/__latex/e1a19af7eac66993ae31b17a1615210f.svg#card=math&code=T%28n%29%3DnC%2Bnlog_2n&height=20&width=149),因此![](https://cdn.nlark.com/yuque/__latex/63899ab6d2ea38c1d3977b780eed0dc1.svg#card=math&code=O%28n%29%3Dnlogn&height=20&width=99)。
  • 空间复杂度:归并排序执行过程中的空间消耗由两部分组成,一部分为临时数组,另一部分为递归入栈。前一部分的空间复杂度为3-3O(nlogn)的排序算法 - 图5,后一部分为3-3O(nlogn)的排序算法 - 图6,因此最终结果为3-3O(nlogn)的排序算法 - 图7

    2. 快速排序

    2.1 算法介绍

    快速排序与归并排序一样,采用的也是分治思想,但实现原理却有很大的不同。
    快速排序核心思想:

  • 在一组数据中任意选择一个值作为分区点(pivot)

  • 将比pivot值小的数字放在pivot左侧
  • 将比pivot值大的数组放在pivot右侧
  • 将左右子序列作为数据(不包含分区点)重复执行上述三个步骤,直到左右子序列只剩一个值为止

那么现在有几个问题:

  1. 虽说任意选取值作为分区点,那么在实际使用过程中,具体应该如何选择呢?
  2. 如何完成分区点对原始数据的分割呢?

首先回答第一个问题:在实际过程中,我们一般选择原始数据的最后一个元素作为分区点。
接着回答第二个问题:学过归并排序之后,我们很容易就能想到,我们可以申请两个临时数组,将比pivot小的数字放到第一个数组,将比pivot大的数字放到第二个数组,之后再按照顺序合并数组即可,具体操作示意图如下:
6643bc3cef766f5b3e4526c332c60adc.webp
这是一种很简单的分区做法,但是我们发现这样做会耗费大量的内存空间,那么有没有改进的方法呢?当然有!下面我将思路写成了伪代码。

  1. int partition(arr, begin, end)
  2. {
  3. povit := arrr[end]
  4. i := begin
  5. j := end
  6. for j := begin to end do
  7. {
  8. if (arr[j] < povit)
  9. {
  10. swap arr[i] with arr[j]
  11. i := i+1
  12. }
  13. }
  14. swap arr[i] with arr[end]
  15. return i
  16. }

改进后的分区方法有一点像选择排序。我们使用两个指针i,j,使用i将数据分为已处理部分和未处理部分,使用j遍历原始数据。初始时,i,j都指向第一个数据,然后比较j元素与povit元素的大小,若j小于povit元素,则交换i,j元素,同时i,j后移,反之则只有j后移,直到j遍历到n-1个数据时跳出循环,此时i即是分区点所在的位置,将i,povit数据交换即可。
下面我们看一张图,加深一下理解:
086002d67995e4769473b3f50dd96de7.webp
注意:第五步中j会指向最后一个元素,此处仅仅是因为for循环结束后,会将j偏移至此。
下面我们看一下具体代码:

  1. void quick_sort(int *arr, const int begin, const int end)
  2. {
  3. if (begin >= end)
  4. {
  5. return;
  6. }
  7. int q = parition(arr, begin, end);
  8. quick_sort(arr, begin, q - 1);
  9. quick_sort(arr, q + 1, end);
  10. }
  11. int parition(int *arr, const int begin, const int end)
  12. {
  13. int deal_pos = begin; // 已处理区域的待插入位置
  14. int undeal_pos = deal_pos; // 未处理区域的位置
  15. int pivot = arr[end]; // 选择的分割点数值
  16. int tmp = 0;
  17. for (; undeal_pos < end; ++undeal_pos)
  18. {
  19. if (arr[undeal_pos] < pivot)
  20. {
  21. tmp = arr[undeal_pos];
  22. arr[undeal_pos] = arr[deal_pos];
  23. arr[deal_pos] = tmp;
  24. deal_pos += 1;
  25. }
  26. }
  27. tmp = arr[end];
  28. arr[end] = arr[deal_pos];
  29. arr[deal_pos] = tmp;
  30. return deal_pos;
  31. }

2.2 算法分析

  • 是否是稳定排序算法:否
  • 是否是原地排序算法:是
  • 时间复杂度分析:

    • 最好时间复杂度:3-3O(nlogn)的排序算法 - 图10

      1. 分区点选择非常好,每次都能平分数据。此时快速排序算法为归并算法,因此时间复杂度为![](https://cdn.nlark.com/yuque/__latex/d344075a2c690847a757434e9e7fa128.svg#card=math&code=O%28nlogn%29&height=20&width=67)。
    • 最坏时间复杂度:3-3O(nlogn)的排序算法 - 图11

      1. 数据原本就是有序的,仅有某一部分分区有数据,因此需要进行![](https://cdn.nlark.com/yuque/__latex/7b8b965ad4bca0e41ab51de7b31363a1.svg#card=math&code=n&height=12&width=10)次分区操作,每次分区需要扫描![](https://cdn.nlark.com/yuque/__latex/a2f070a31330443ceb0dcf352fe50035.svg#card=math&code=n%2F2&height=20&width=26)次数据,因此时间复杂度为![](https://cdn.nlark.com/yuque/__latex/9f84a66d88d24c3b1bc91df5b5346a13.svg#card=math&code=O%28n%5E2%29&height=23&width=43)。
    • 平均时间复杂度:3-3O(nlogn)的排序算法 - 图12

  • 空间复杂度分析:3-3O(nlogn)的排序算法 - 图13
    • 最好空间复杂度:3-3O(nlogn)的排序算法 - 图14
    • 最坏空间复杂度:3-3O(nlogn)的排序算法 - 图15
    • 平均空间复杂度:3-3O(nlogn)的排序算法 - 图16

      3. 快速排序与归并排序区别

      分析两种排序算法执行过程,我们发现两种算法是反的。
      递归排序是由下到上的,在将数据分割完成之后,才会进行数据排序、合并。而快速排序是由上到下的,在将数据排序之后,才会进行分割。
      aa03ae570dace416127c9ccf9db8ac05.webp

      4. 如何在时间复杂度为3-3O(nlogn)的排序算法 - 图18时,找到一组数据中的第K大元素?

      假设我们有一组数据5,1,2,3,6,7,排好序之后数据为7,6,5,3,2,1,我们认为第2大元素为6
      我们可以想到使用快速排序中的分区方法来进行这一过程:
      将数据的最后一个数据作为povit,通过分区方法,将数据分为arr[begin, i]、povit、arr[i +1, end]三部分,此时我们就可以说下标为i的元素是原始数据中第i+1大的元素。因此:
  1. 若是K=i+1,则说明找到了直接返回即可;
  2. 若是K>i+1,则说明要找的元素在右半分区;
  3. 若是K>i+1,则说明要找的元素在左半分区;
  4. 若是遇到2、3情况,则递归分区所在的半区,直到情况1满足位置。

下面我们分析一下时间复杂度:
第一次分区要遍历的元素为3-3O(nlogn)的排序算法 - 图19个,第二次分区要遍历的元素为3-3O(nlogn)的排序算法 - 图20个,第三次为3-3O(nlogn)的排序算法 - 图21,直到最后遍历的元素个数为1为止,加起来之后遍历的总次数为3-3O(nlogn)的排序算法 - 图22,因此时间复杂度为3-3O(nlogn)的排序算法 - 图23
当然上述只是平均情况下的,若数据是有序的,则每次要遍历的元素个数为3-3O(nlogn)的排序算法 - 图24,此时最终的时间复杂度为3-3O(nlogn)的排序算法 - 图25,当然这也是最差时间复杂度。

5. 合并日志问题

若当前有十个日志文件,每个日志文件大小为300M。已知每个文件的日志都是按照时间戳从小到大排列的,若现在日志所在的电脑只有1G内存,如何完成日志文件的合并?
思路解析:可以参考归并算法的合并算法,归并排序中的合并算法为两路合并,我们这里采用多路合并的方式。
具体做法:申请一个大小为10的数组,从10个文件中分别取出第一条日志,拿出时间戳最小的一条日志存入最终日志文件中,然后从时间戳最小的日志所在的文件取出第二条日志放入数组,再次比较,以此类推,知道所有文件都合并存入最终日志文件为止。