数据结构与算法之美笔记——排序(下)

摘要:

本章节主要讲解「归并排序」( Merge Sort )和「快速排序」( Quick Sort ),这两种排序主要应用了分治的思想,时间复杂度都为 O(nlogn),但是在实际生产中快速排序使用更加广泛。

归并排序

原理

归并排序将一组数据进行一分为二的分解操作,直到子数组中只有一个元素为止,此时将分解的子数组进行合并,在合并的过程中进行排序,合并后的数组就是一个有序的数组,按照这种操作方式循环合并拆分的数组,最后合并完成的数组就是已经完成排序的完整数组。

实现逻辑解析

通过上一节的归并排序的逻辑描述可以看出,拆分数组与合并数组就是一个递归的过程,在递归章节讲过如果要实现递归代码最重要是的找到递推公式和终止条件。

根据归并排序的逻辑可以得到如下的递推公式和终止条件。

递推公式 merge\_sort(p,r)=merge(merge\_sort(p,q),merge\_sort(q+1,r))
终止条件 p\geq r

主要说一下上面公式中参数,p 和 r 分别表示数组头、尾的元素下标,q 表示数组中间元素的下标,满足公式 q=\frac{p+r}2,当 p\geq r 时就是数组被拆分到只有一个元素的时候,这个时候拆分终止。

代码实现

递归代码的实现
public void mergeSort(int[] array, int head, int tail) {
  if(head >= tail) {return;}

  mergeSort(array, head, (head + tail) / 2);
  mergeSort(array, (head + tail) / 2 + 1, tail);
  merge(array, head, tail);
}
merge 函数的实现

从代码中可以看到,归并排序的重点在于 merge 方法的实现,接下来我们分析一下 merge 函数如何实现。

我们申请一个临时数组用来保存合并后的数组,当合并完成后把临时数组中的元素迁移至原数组中相应位置。合并过程中需要对两个子数组的元素有序合并,根据归并排序的逻辑两个子数组已经是有序的,我们可以通过两个指针 i 和 j 分别指向两个子数据的头元素,遍历数组并且对比 i 和 j 指向的元素大小,较小的元素就放入临时数组中,并且相应的指针自增 1,指向相应数组的下一个元素,直到其中的一个元素遍历完。

其中的一个子数组的元素遍历结束,也就意味着这个子数组的元素已经有序合并完成,但另外一个子数组的元素依然没有完全合并完成,因为子数组本就有序,我们只需要将剩下的群组元素按顺序添加到临时数组的尾部,这样临时数组就是合并两个子数组完成的有序数组了,最后只需要临时数组中的元素迁移至原数组相应的位置即可。

private void merge(int[] array, int head, int tail) {
  int i = head, midPoint = (head + tail) / 2, j = midPoint + 1, k = 0;
  int[] temp = new int[tail - head + 1];

  // merge sub arrays to temp
  while(i <= midPoint && j <= tail) {
    if(array[i] <= array[j]) {
      temp[k++] = array[i++];
    } else {
      temp[k++] = array[j++];
    }
  }

  // merge extra elements of sub arrays to temp
  int start = i, end = midPoint;
  if(j <= tail) {
    start = j;
    end = tail;
  }

  while(start <= end) {
    temp[k++] = array[start++];
  }

  // move temp to array
  for(int s = 0; s < k; s++) {
    array[head + s] = temp[s];
  }
}
简化 merge 函数

上一节分析并且实现了 merge 函数, 其实可以通过哨兵对 merge 函数代码进行简化。

private void mergeWithGuard(int[] array, int head, int tail) {
  int i = head, midPoint = (head + tail) / 2, j = midPoint + 1, k = 0;
  int[] temp = new int[tail - head + 1];

  // make max as guard
  int max = array[midPoint];
  if(array[tail] > max) {
    max = array[tail];
  }
  boolean isGuard = false;

  // merge sub arrays to temp
  while(i <= midPoint && j <= tail) {
    if(array[i] <= array[j]) {
      temp[k++] = array[i++];
    } else {
      temp[k++] = array[j++];
    }

    // add guard
    if((i > midPoint || j > tail) && !isGuard) {
      if(i > midPoint) {
        i--;
        array[i] = max;
      } else {
        j--;
        array[j] = max;
      }
      isGuard = true;
    }
  }

  // move temp to array
  for(int s = 0; s < k; s++) {
    array[head + s] = temp[s];
  }
}

这里哨兵是两个子数组中的最大值,基于两个子数组有序的条件,比较两个子数组的最后一个元素就可以得到结果,当一个子数组被遍历结束后将哨兵插入这个子数组的末尾,并将子数组的相应指针指向尾部,此时循环就不会结束,直到将另一个子数组的所有元素也合并进临时数组才会结束。这里的哨兵就可以简化掉将未遍历完的子数组元素合并至临时数组的代码。

执行效率分析

时间复杂度

分析归并排序的时间复杂度其实就是在分析递归的时间复杂度。归并排序的递归方法时间复杂度主要由三部分构成,两个拆分后的子数组归并排序的时间复杂度和合并数组的时间复杂度,可以转换为如下公式。

T(c)=T(a)+T(b)+K
T 表示消耗的时间
c 表示归并排序未分解的数组
a 和 b 分别表示归并排序两个拆分后的子数组
K 表示合并数组消耗的时间
当归并排序元素个数为 1 的数组时消耗时间是个常数,T(1)=C

其实分析递归算法的时间复杂度也是按照递推公式。归并排序的合并操作时间复杂度是 O(n),所以时间复杂度的计算公式可以转换为 T(n)=T(\frac n2)+T(\frac n2)+n=2T(\frac n2)+n,假设将大小为 n 的数组进行拆分至大小为 1 的数组需要 k 次,根据公式进行递推。

T(n)=2\times T(\frac n2)+n=2\times(2\times T(\frac n4)+\frac n2)+n=4\times T(\frac n4)+2n= 4\times(2\times T(\frac n8)+\frac n4)+2n=8\times T(\frac n8)+3n=...=2^k\times T(\frac n{2^k})+k\times n

因为第 k 次时拆分后子数组大小为 1,所以有等式关系 \frac{n}{2^k}=1,即可以得到 k=\log_2n,将 k 代入第 k 次递推公式,T(n)=2^{\log_2n}\times T(1)+n\times\log_2n=n\times C+n\times \log_2n,使用大 O 标记法表示时间复杂度为 O(nlogn)

根据上面的分析归并排序不会受到数据有序度等的影响,所以归并排序的最好、最坏和平均时间复杂度都是 O(nlogn)

空间复杂度

归并排序的递归方法中,只有 merge 函数申请了一个额外的存储空间作为临时数组,但不需要将每个递归的子问题的临时数组的额外空间都进行累积计算,因为临时数组的存储空间在使用完成后就会被释放,所以归并排序的空间复杂度是 O(n),也就是说归并排序并不是原地算法。

稳定性

归并排序拆分数组操作并不会导致相同数据的前后顺序发生改变,在合并子数组的操作中控制小于等于的数合并在前,也不会改变相同数据的前后顺序,所以归并排序是稳定的排序算法。

快速排序

原理

快速排序(以下简称「快排」)也是根据分治的思想而来,与归并排序相似,快排也会进行数组的拆分,但不同的是快排不会进行合并操作。快排的排序操作发生在分区,也就是子数组拆分的时候,快排进行分区时会选择数组中的一个元素作为「分区点」( pivot ),接着遍历数组中元素并把元素与分区点进行比较,比分区点小的数据放置在分区点的左端,比分区点大的数据放置在分区点的右端,子数组的拆分就以分区点的位置为中间点进行拆分,子数组再进行同样的操作直到子数组元素只有 1 个时终止。

实现逻辑解析

根据快排的原理,快排与归并排序类似,也是属于递归的方式实现,同样可以先分析快排的递推公式和终止条件。

递推公式 quick\_sort(p,r)=partition(p,r)+quick\_sort(p,q-1)+quick\_sort(q+1,r)
终止条件 p\geq r

递推公式中的 q 表示分区点的下标位置。

代码实现

private void quickSort(int[] array, int head, int tail) {
  if(head >= tail) {return;}

  int pivot = partition(array, head, tail);
  quickSort(array, head, pivot - 1);
  quickSort(array, pivot + 1, tail);
}
partition 函数的实现

上一节的代码实现中可以看出,partition 函数的实现是快排的实现重点。根据快排的原理介绍,最先想到的实现方法是先确定分区点(一般会选择当前排序数组的最后一个元素),再申请两个临时数组将比分区点大的数据放入一个数组,比分区点小的数据放入另一个数组,最后再将两个数组和分区点合并成一个数组。

虽然这样可以实现 partition 函数的功能,但因为会额外申请两个临时数组,会造成空间复杂度的增加,其实分区函数可以使用巧妙的方法实现,将空间复杂度降低到 O(1)

partition 函数的实现方法与选择排序的方式类似,会使用两个指针 i 和 j 同时指向数组的头元素,以 i 指向数组位置之前为比分区点小的区域,j 作为遍历数组的指针,当遍历到的数据比分区点小时与 i 指向的元素交换,并且 i 的位置向后移动一位,当循环遍历结束后将分区点元素与 i 指向的元素交换。

private int partition(int[] array, int head, int tail) {
  int i = 0;
  for(int j = 0; j <= tail; j++) {
    if(array[j] <= array[tail]) {
      int temp = array[i];
      array[i] = array[j];
      array[j] = temp;
      i++;
    }
  }

  return i - 1;
}

执行效率分析

时间复杂度

快排的时间复杂度会受到数据有序程度和分区点选择的影响,我们先假设分区点每次都能将数组均分,这种情况下快排的时间复杂度分析与归并排序的分析相似,快排的分区函数时间复杂度也为 O(n),所以这种情况下快排的时间复杂度为 O(nlogn)

分区点每次都能将数组分成两个平均的子数组这种情况比较理想,属于快排分区的最好情况,所以 O(nlogn) 是快排的最好时间复杂度。

如果在数据完全有序的情况下,每次分区点都选择最后一个,需要进行 n 次排序,每次排序的时间复杂度为 O(n),所以这种情况下快排的时间复杂度会退化为 O(n^2),这种分区极不平衡的情况下时间复杂度是快排的最坏时间复杂度。

那快排的平均时间复杂度是多少?这里通过递推公式进行计算会比较复杂,在大多数情况下快排的时间复杂度都可以做到 O(nlogn),只有极少数情况会退化为 O(n^2),而且也有方法将这种情况发生的概率降到很低,在以后的章节中会讲解。

空间复杂度

快排没有申请额外的存储空间,所以空间复杂度是 O(1),也就是说快排是原地算法。

稳定性

快排的分区函数会交换元素的位置,例如 6,4,6,1,3 这组数据在第一次分区交换时就会导致两个 6 的前后顺序发生变化,所以快排是一种不稳定的排序算法。

两种排序的比对

两种算法虽然都是根据分治思想,利用递归方法实现的,但是归并排序是先将问题分解为子问题,最后再将子问题归并及排序,这是种由下至上的解决思路,快排先进行了分区排序再将问题分解为子问题进行类似的解决,这是由上至下的解决思路。

归并算法虽然与快排的时间复杂度一样,都是 O(nlogn),并且也稳定,但就是由于不是原地算法,导致在使用上没有快排广泛。


文章中如有问题欢迎留言指正
本章节代码已经上传GitHub,可点击跳转查看代码详情。
数据结构与算法之美笔记系列将会做为我对王争老师此专栏的学习笔记,如想了解更多王争老师专栏的详情请到极客时间自行搜索。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容