learn.lianglianglee.com/专栏/重学数据结构与算法-完/13 排序:经典排序算法原理解析与优劣对比.md.html
2022-05-11 19:04:14 +08:00

759 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<!-- saved from url=(0046)https://kaiiiz.github.io/hexo-theme-book-demo/ -->
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/static/favicon.png">
<title>13 排序:经典排序算法原理解析与优劣对比.md.html</title>
<!-- Spectre.css framework -->
<link rel="stylesheet" href="/static/index.css">
<!-- theme css & js -->
<meta name="generator" content="Hexo 4.2.0">
</head>
<body>
<div class="book-container">
<div class="book-sidebar">
<div class="book-brand">
<a href="/">
<img src="/static/favicon.png">
<span>技术文章摘抄</span>
</a>
</div>
<div class="book-menu uncollapsible">
<ul class="uncollapsible">
<li><a href="/" class="current-tab">首页</a></li>
</ul>
<ul class="uncollapsible">
<li><a href="../">上一级</a></li>
</ul>
<ul class="uncollapsible">
<li>
<a href="/专栏/重学数据结构与算法-完/00 数据结构与算法,应该这样学!.md.html">00 数据结构与算法,应该这样学!.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/01 复杂度:如何衡量程序运行的效率?.md.html">01 复杂度:如何衡量程序运行的效率?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/02 数据结构:将“昂贵”的时间复杂度转换成“廉价”的空间复杂度.md.html">02 数据结构:将“昂贵”的时间复杂度转换成“廉价”的空间复杂度.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/03 增删查:掌握数据处理的基本操作,以不变应万变.md.html">03 增删查:掌握数据处理的基本操作,以不变应万变.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/04 如何完成线性表结构下的增删查?.md.html">04 如何完成线性表结构下的增删查?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/05 栈:后进先出的线性表,如何实现增删查?.md.html">05 栈:后进先出的线性表,如何实现增删查?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/06 队列:先进先出的线性表,如何实现增删查?.md.html">06 队列:先进先出的线性表,如何实现增删查?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/07 数组:如何实现基于索引的查找?.md.html">07 数组:如何实现基于索引的查找?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/08 字符串:如何正确回答面试中高频考察的字符串匹配算法?.md.html">08 字符串:如何正确回答面试中高频考察的字符串匹配算法?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/09 树和二叉树:分支关系与层次结构下,如何有效实现增删查?.md.html">09 树和二叉树:分支关系与层次结构下,如何有效实现增删查?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/10 哈希表:如何利用好高效率查找的“利器”?.md.html">10 哈希表:如何利用好高效率查找的“利器”?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/11 递归:如何利用递归求解汉诺塔问题?.md.html">11 递归:如何利用递归求解汉诺塔问题?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/12 分治:如何利用分治法完成数据查找?.md.html">12 分治:如何利用分治法完成数据查找?.md.html</a>
</li>
<li>
<a class="current-tab" href="/专栏/重学数据结构与算法-完/13 排序:经典排序算法原理解析与优劣对比.md.html">13 排序:经典排序算法原理解析与优劣对比.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/14 动态规划:如何通过最优子结构,完成复杂问题求解?.md.html">14 动态规划:如何通过最优子结构,完成复杂问题求解?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/15 定位问题才能更好地解决问题:开发前的复杂度分析与技术选型.md.html">15 定位问题才能更好地解决问题:开发前的复杂度分析与技术选型.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/16 真题案例(一):算法思维训练.md.html">16 真题案例(一):算法思维训练.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/17 真题案例(二):数据结构训练.md.html">17 真题案例(二):数据结构训练.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/18 真题案例(三):力扣真题训练.md.html">18 真题案例(三):力扣真题训练.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/19 真题案例(四):大厂真题实战演练.md.html">19 真题案例(四):大厂真题实战演练.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/20 代码之外,技术面试中你应该具备哪些软素质?.md.html">20 代码之外,技术面试中你应该具备哪些软素质?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/21 面试中如何建立全局观,快速完成优质的手写代码?.md.html">21 面试中如何建立全局观,快速完成优质的手写代码?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/加餐 课后练习题详解.md.html">加餐 课后练习题详解.md.html</a>
</li>
</ul>
</div>
</div>
<div class="sidebar-toggle" onclick="sidebar_toggle()" onmouseover="add_inner()" onmouseleave="remove_inner()">
<div class="sidebar-toggle-inner"></div>
</div>
<script>
function add_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.add('show')
}
function remove_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.remove('show')
}
function sidebar_toggle() {
let sidebar_toggle = document.querySelector('.sidebar-toggle')
let sidebar = document.querySelector('.book-sidebar')
let content = document.querySelector('.off-canvas-content')
if (sidebar_toggle.classList.contains('extend')) { // show
sidebar_toggle.classList.remove('extend')
sidebar.classList.remove('hide')
content.classList.remove('extend')
} else { // hide
sidebar_toggle.classList.add('extend')
sidebar.classList.add('hide')
content.classList.add('extend')
}
}
function open_sidebar() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.add('show')
overlay.classList.add('show')
}
function hide_canvas() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.remove('show')
overlay.classList.remove('show')
}
</script>
<div class="off-canvas-content">
<div class="columns">
<div class="column col-12 col-lg-12">
<div class="book-navbar">
<!-- For Responsive Layout -->
<header class="navbar">
<section class="navbar-section">
<a onclick="open_sidebar()">
<i class="icon icon-menu"></i>
</a>
</section>
</header>
</div>
<div class="book-content" style="max-width: 960px; margin: 0 auto;
overflow-x: auto;
overflow-y: hidden;">
<div class="book-post">
<p id="tip" align="center"></p>
<div><h1>13 排序:经典排序算法原理解析与优劣对比</h1>
<p>前面课时中,我们学习了分治法的思想,以及二分查找的实现方法。我们讲到,二分查找要求原数组必须有序。其实,由无序到有序,这是算法领域最常见的一类问题,即排序问题。本课时,我们就来学习 4 种常见的排序算法,包括冒泡排序、插入排序、归并排序以及快速排序。此外,我们还会对这 4 种排序算法的优劣势进行详细地对比分析。</p>
<h3>什么是排序问题</h3>
<p><strong>排序,就是让一组无序数据变成有序的过程。</strong> 一般默认这里的有序都是从小到大的排列顺序。下面我们先来讲讲,如何判断不同的排序算法的优劣。</p>
<p>衡量一个排序算法的优劣,我们主要会从以下 3 个角度进行分析:</p>
<p>1<strong>时间复杂度</strong>,具体包括,最好时间复杂度、最坏时间复杂度以及平均时间复杂度。</p>
<p>2<strong>空间复杂度</strong>,如果空间复杂度为 1也叫作原地排序。</p>
<p>3<strong>稳定性</strong>,排序的稳定性是指相等的数据对象,在排序之后,顺序是否能保证不变。</p>
<h3>常见的排序算法及其思想</h3>
<p>接下来,我们就开始详细地介绍一些经典的排序算法。</p>
<h4>冒泡排序</h4>
<p>1、<strong>冒泡排序的原理</strong></p>
<p><strong>从第一个数据开始,依次比较相邻元素的大小。如果前者大于后者,则进行交换操作,把大的元素往后交换。通过多轮迭代,直到没有交换操作为止。</strong> 冒泡排序就像是在一个水池中处理数据一样,每次会把最大的那个数据传递到最后。</p>
<p><img src="assets/CgqCHl75xgeAF_xkABrEk0C0heo355.gif" alt="动画1.gif" /></p>
<p>2、<strong>冒泡排序的性能</strong></p>
<p><strong>冒泡排序最好时间复杂度是 O(n)</strong>,也就是当输入数组刚好是顺序的时候,只需要挨个比较一遍就行了,不需要做交换操作,所以时间复杂度为 O(n)。</p>
<p><strong>冒泡排序最坏时间复杂度会比较惨,是 O(n*n)</strong>。也就是说当数组刚好是完全逆序的时候,每轮排序都需要挨个比较 n 次,并且重复 n 次,所以时间复杂度为 O(n*n)。</p>
<p>很显然,<strong>当输入数组杂乱无章时,它的平均时间复杂度也是 O(n*n)</strong></p>
<p><strong>冒泡排序不需要额外的空间,所以空间复杂度是 O(1)。冒泡排序过程中,当元素相同时不做交换,所以冒泡排序是稳定的排序算法</strong>。代码如下:</p>
<pre><code>public static void main(String[] args) {
int[] arr = { 1, 0, 3, 4, 5, -6, 7, 8, 9, 10 };
System.out.println(&quot;原始数据: &quot; + Arrays.toString(arr));
for (int i = 1; i &lt; arr.length; i++) {
for (int j = 0; j &lt; arr.length - i; j++) {
if (arr[j] &gt; arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
System.out.println(&quot;冒泡排序: &quot; + Arrays.toString(arr));
}
</code></pre>
<h4>插入排序</h4>
<p>1、<strong>插入排序的原理</strong></p>
<p><strong>选取未排序的元素,插入到已排序区间的合适位置,直到未排序区间为空</strong>。插入排序顾名思义,就是从左到右维护一个已经排好序的序列。直到所有的待排数据全都完成插入的动作。</p>
<p><img src="assets/CgqCHl75xmqAXrQnAB7zyryidSU192.gif" alt="动画2.gif" /></p>
<p>2、<strong>插入排序的性能</strong></p>
<p><strong>插入排序最好时间复杂度是 O(n)</strong>,即当数组刚好是完全顺序时,每次只用比较一次就能找到正确的位置。这个过程重复 n 次,就可以清空未排序区间。</p>
<p><strong>插入排序最坏时间复杂度则需要 O(n*n)</strong>。即当数组刚好是完全逆序时,每次都要比较 n 次才能找到正确位置。这个过程重复 n 次,就可以清空未排序区间,所以最坏时间复杂度为 O(n*n)。</p>
<p><strong>插入排序的平均时间复杂度是 O(n*n)</strong>。这是因为往数组中插入一个元素的平均时间复杂度为 O(n),而插入排序可以理解为重复 n 次的数组插入操作,所以平均时间复杂度为 O(n*n)。</p>
<p><strong>插入排序不需要开辟额外的空间,所以空间复杂度是 O(1)</strong></p>
<p>根据上面的例子可以发现,<strong>插入排序是稳定的排序算法</strong>。代码如下:</p>
<pre><code>public static void main(String[] args) {
int[] arr = { 2, 3, 5, 1, 23, 6, 78, 34 };
System.out.println(&quot;原始数据: &quot; + Arrays.toString(arr));
for (int i = 1; i &lt; arr.length; i++) {
int temp = arr[i];
int j = i - 1;
for (; j &gt;= 0; j--) {
if (arr[j] &gt; temp) {
arr[j + 1] = arr[j];
} else {
break;
}
}
arr[j + 1] = temp;
}
System.out.println(&quot;插入排序: &quot; + Arrays.toString(arr));
}
</code></pre>
<h4>小结:插入排序和冒泡排序算法的异同点</h4>
<p>接下来我们来比较一下上面这两种排序算法的异同点:</p>
<p><strong>相同点</strong></p>
<ul>
<li>插入排序和冒泡排序的平均时间复杂度都是 O(n*n),且都是稳定的排序算法,都属于原地排序。</li>
</ul>
<p><strong>差异点</strong></p>
<ul>
<li>冒泡排序每轮的交换操作是动态的,所以需要三个赋值操作才能完成;</li>
<li>而插入排序每轮的交换动作会固定待插入的数据,因此只需要一步赋值操作。</li>
</ul>
<p>以上两种排序算法都比较简单,通过这两种算法可以帮助我们对排序的思想建立基本的了解,接下来再介绍一些时间复杂度更低的排序算法,它们的时间复杂度都可以达到 O(nlogn)。</p>
<h4>归并排序</h4>
<p>1、<strong>归并排序的原理</strong></p>
<p><strong>归并排序的原理其实就是我们上一课时讲的分治法</strong>。它首先将数组不断地二分,直到最后每个部分只包含 1 个数据。然后再对每个部分分别进行排序,最后将排序好的相邻的两部分合并在一起,这样整个数组就有序了。</p>
<p><img src="assets/Ciqc1F75xq2APVN0ACXGvhT4W44926.gif" alt="动画3.gif" /></p>
<p>代码如下:</p>
<pre><code>public static void main(String[] args) {
int[] arr = { 49, 38, 65, 97, 76, 13, 27, 50 };
int[] tmp = new int[arr.length];
System.out.println(&quot;原始数据: &quot; + Arrays.toString(arr));
customMergeSort(arr, tmp, 0, arr.length - 1);
System.out.println(&quot;归并排序: &quot; + Arrays.toString(arr));
}
public static void customMergeSort(int[] a, int[] tmp, int start, int end) {
if (start &lt; end) {
int mid = (start + end) / 2;
// 对左侧子序列进行递归排序
customMergeSort(a, tmp, start, mid);
// 对右侧子序列进行递归排序
customMergeSort(a, tmp,mid + 1, end);
// 合并
customDoubleMerge(a, tmp, start, mid, end);
}
}
public static void customDoubleMerge(int[] a, int[] tmp, int left, int mid, int right) {
int p1 = left, p2 = mid + 1, k = left;
while (p1 &lt;= mid &amp;&amp; p2 &lt;= right) {
if (a[p1] &lt;= a[p2])
tmp[k++] = a[p1++];
else
tmp[k++] = a[p2++];
}
while (p1 &lt;= mid)
tmp[k++] = a[p1++];
while (p2 &lt;= right)
tmp[k++] = a[p2++];
// 复制回原素组
for (int i = left; i &lt;= right; i++)
a[i] = tmp[i];
</code></pre>
<p>2、<strong>归并排序的性能</strong></p>
<p><strong>对于归并排序,它采用了二分的迭代方式,复杂度是 logn</strong></p>
<p>每次的迭代,需要对两个有序数组进行合并,这样的动作在 O(n) 的时间复杂度下就可以完成。因此,**归并排序的复杂度就是二者的乘积 O(nlogn)。**同时,<strong>它的执行频次与输入序列无关,因此,归并排序最好、最坏、平均时间复杂度都是 O(nlogn)</strong></p>
<p><strong>空间复杂度方面,由于每次合并的操作都需要开辟基于数组的临时内存空间,所以空间复杂度为 O(n)</strong>。归并排序合并的时候,相同元素的前后顺序不变,所以<strong>归并是稳定的排序算法</strong></p>
<h4>快速排序</h4>
<p>1、<strong>快速排序法的原理</strong></p>
<p><strong>快速排序法的原理也是分治法</strong>。它的每轮迭代,会选取数组中任意一个数据作为分区点,将小于它的元素放在它的左侧,大于它的放在它的右侧。再利用分治思想,继续分别对左右两侧进行同样的操作,直至每个区间缩小为 1则完成排序。</p>
<p><img src="assets/Ciqc1F75x8KAROF9AFLsWEVvUPU075.gif" alt="动画4.gif" /></p>
<p>代码参考:</p>
<pre><code>public static void main(String[] args) {
int[] arr = { 6, 1, 2, 7, 9, 11, 4, 5, 10, 8 };
System.out.println(&quot;原始数据: &quot; + Arrays.toString(arr));
customQuickSort(arr, 0, arr.length - 1);
System.out.println(&quot;快速排序: &quot; + Arrays.toString(arr));
}
public void customQuickSort(int[] arr, int low, int high) {
int i, j, temp, t;
if (low &gt;= high) {
return;
}
i = low;
j = high;
temp = arr[low];
while (i &lt; j) {
// 先看右边,依次往左递减
while (temp &lt;= arr[j] &amp;&amp; i &lt; j) {
j--;
}
// 再看左边,依次往右递增
while (temp &gt;= arr[i] &amp;&amp; i &lt; j) {
i++;
}
t = arr[j];
arr[j] = arr[i];
arr[i] = t;
}
arr[low] = arr[i];
arr[i] = temp;
// 递归调用左半数组
customQuickSort(arr, low, j - 1);
// 递归调用右半数组
customQuickSort(arr, j + 1, high);
}
</code></pre>
<p>2、<strong>快速排序法的性能</strong></p>
<p><strong>在快排的最好时间的复杂度下</strong>,如果每次选取分区点时,都能选中中位数,把数组等分成两个,那么<strong>此时的时间复杂度和归并一样,都是 O(n*logn)</strong></p>
<p><strong>而在最坏的时间复杂度下</strong>,也就是如果每次分区都选中了最小值或最大值,得到不均等的两组。那么就需要 n 次的分区操作,每次分区平均扫描 n / 2 个元素,<strong>此时时间复杂度就退化为 O(n*n) 了</strong></p>
<p><strong>快速排序法在大部分情况下,统计上是很难选到极端情况的。因此它平均的时间复杂度是 O(n*logn)</strong></p>
<p><strong>快速排序法的空间方面,使用了交换法,因此空间复杂度为 O(1)</strong></p>
<p>很显然,快速排序的分区过程涉及交换操作,所以<strong>快排是不稳定的排序算法</strong></p>
<h3>排序算法的性能分析</h3>
<p>我们先思考一下排序算法性能的下限,也就是最差的情况。在前面的课程中,我们写过求数组最大值的代码,它的时间复杂度是 O(n)。对于 n 个元素的数组,只要重复执行 n 次最大值的查找就能完成排序。因此<strong>排序最暴力的方法,时间复杂度是 O(n*n)。这恰如冒泡排序和插入排序</strong></p>
<p><strong>当我们利用算法思维去解决问题时,就会想到尝试分治法。此时,利用归并排序就能让时间复杂度降低到 O(nlogn)</strong>。然而,<strong>归并排序需要额外开辟临时空间。一方面是为了保证稳定性,另一方面则是在归并时,由于在数组中插入元素导致了数据挪移的问题。</strong></p>
<p><strong>为了规避因此而带来的时间损耗,此时我们采用快速排序</strong>。通过交换操作,可以解决插入元素导致的数据挪移问题,而且降低了不必要的空间开销。但是由于其动态二分的交换数据,导致了由此得出的排序结果并不稳定。</p>
<h3>总结</h3>
<p>本课时我们讲了4 种常见的排序算法,包括冒泡排序、插入排序、归并排序以及快速排序。这些经典算法没有绝对的好和坏,它们各有利弊。在工作过程中,需要你根据实际问题的情况来选择最优的排序算法。</p>
<p><strong>如果对数据规模比较小的数据进行排序,可以选择时间复杂度为 O(n*n) 的排序算法</strong>。因为当数据规模小的时候,时间复杂度 O(nlogn) 和 O(n*n) 的区别很小,它们之间仅仅相差几十毫秒,因此对实际的性能影响并不大。</p>
<p><strong>但对数据规模比较大的数据进行排序,就需要选择时间复杂度为 O(nlogn) 的排序算法了</strong></p>
<ul>
<li>归并排序的空间复杂度为 O(n),也就意味着当排序 100M 的数据,就需要 200M 的空间,所以对空间资源消耗会很多。</li>
<li>快速排序在平均时间复杂度为 O(nlogn),但是如果分区点选择不好的话,最坏的时间复杂度也有可能逼近 O(n*n)。而且快速排序不具备稳定性,这也需要看你所面对的问题是否有稳定性的需求。</li>
</ul>
</div>
</div>
<div>
<div style="float: left">
<a href="/专栏/重学数据结构与算法-完/12 分治:如何利用分治法完成数据查找?.md.html">上一页</a>
</div>
<div style="float: right">
<a href="/专栏/重学数据结构与算法-完/14 动态规划:如何通过最优子结构,完成复杂问题求解?.md.html">下一页</a>
</div>
</div>
</div>
</div>
</div>
</div>
<a class="off-canvas-overlay" onclick="hide_canvas()"></a>
</div>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v652eace1692a40cfa3763df669d7439c1639079717194" integrity="sha512-Gi7xpJR8tSkrpF7aordPZQlW2DLtzUlZcumS8dMQjwDHEnw9I7ZLyiOj/6tZStRBGtGgN6ceN6cMH8z7etPGlw==" data-cf-beacon='{"rayId":"70997dd9783c3cfa","version":"2021.12.0","r":1,"token":"1f5d475227ce4f0089a7cff1ab17c0f5","si":100}' crossorigin="anonymous"></script>
</body>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NPSEEVD756"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-NPSEEVD756');
var path = window.location.pathname
var cookie = getCookie("lastPath");
console.log(path)
if (path.replace("/", "") === "") {
if (cookie.replace("/", "") !== "") {
console.log(cookie)
document.getElementById("tip").innerHTML = "<a href='" + cookie + "'>跳转到上次进度</a>"
}
} else {
setCookie("lastPath", path)
}
function setCookie(cname, cvalue) {
var d = new Date();
d.setTime(d.getTime() + (180 * 24 * 60 * 60 * 1000));
var expires = "expires=" + d.toGMTString();
document.cookie = cname + "=" + cvalue + "; " + expires + ";path = /";
}
function getCookie(cname) {
var name = cname + "=";
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
}
return "";
}
</script>
</html>