This commit is contained in:
周伟
2022-05-11 18:46:27 +08:00
commit 387f48277a
8634 changed files with 2579564 additions and 0 deletions

View File

@@ -0,0 +1,968 @@
<!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</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">00 数据结构与算法,应该这样学!.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/01 复杂度:如何衡量程序运行的效率?.md">01 复杂度:如何衡量程序运行的效率?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/02 数据结构:将“昂贵”的时间复杂度转换成“廉价”的空间复杂度.md">02 数据结构:将“昂贵”的时间复杂度转换成“廉价”的空间复杂度.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/03 增删查:掌握数据处理的基本操作,以不变应万变.md">03 增删查:掌握数据处理的基本操作,以不变应万变.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/04 如何完成线性表结构下的增删查?.md">04 如何完成线性表结构下的增删查?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/05 栈:后进先出的线性表,如何实现增删查?.md">05 栈:后进先出的线性表,如何实现增删查?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/06 队列:先进先出的线性表,如何实现增删查?.md">06 队列:先进先出的线性表,如何实现增删查?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/07 数组:如何实现基于索引的查找?.md">07 数组:如何实现基于索引的查找?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/08 字符串:如何正确回答面试中高频考察的字符串匹配算法?.md">08 字符串:如何正确回答面试中高频考察的字符串匹配算法?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/09 树和二叉树:分支关系与层次结构下,如何有效实现增删查?.md">09 树和二叉树:分支关系与层次结构下,如何有效实现增删查?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/10 哈希表:如何利用好高效率查找的“利器”?.md">10 哈希表:如何利用好高效率查找的“利器”?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/11 递归:如何利用递归求解汉诺塔问题?.md">11 递归:如何利用递归求解汉诺塔问题?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/12 分治:如何利用分治法完成数据查找?.md">12 分治:如何利用分治法完成数据查找?.md.html</a>
</li>
<li>
<a class="current-tab" href="/专栏/重学数据结构与算法-完/13 排序:经典排序算法原理解析与优劣对比.md">13 排序:经典排序算法原理解析与优劣对比.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/14 动态规划:如何通过最优子结构,完成复杂问题求解?.md">14 动态规划:如何通过最优子结构,完成复杂问题求解?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/15 定位问题才能更好地解决问题:开发前的复杂度分析与技术选型.md">15 定位问题才能更好地解决问题:开发前的复杂度分析与技术选型.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/16 真题案例(一):算法思维训练.md">16 真题案例(一):算法思维训练.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/17 真题案例(二):数据结构训练.md">17 真题案例(二):数据结构训练.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/18 真题案例(三):力扣真题训练.md">18 真题案例(三):力扣真题训练.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/19 真题案例(四):大厂真题实战演练.md">19 真题案例(四):大厂真题实战演练.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/20 代码之外,技术面试中你应该具备哪些软素质?.md">20 代码之外,技术面试中你应该具备哪些软素质?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/21 面试中如何建立全局观,快速完成优质的手写代码?.md">21 面试中如何建立全局观,快速完成优质的手写代码?.md.html</a>
</li>
<li>
<a href="/专栏/重学数据结构与算法-完/加餐 课后练习题详解.md">加餐 课后练习题详解.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">上一页</a>
</div>
<div style="float: right">
<a href="/专栏/重学数据结构与算法-完/14 动态规划:如何通过最优子结构,完成复杂问题求解?.md">下一页</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>