learn-tech/专栏/JavaScript进阶实战课/13JS引擎如何实现数组的稳定排序?.md
2024-10-16 06:37:41 +08:00

105 lines
11 KiB
Markdown
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.

因收到Google相关通知网站将会择期关闭。相关通知内容
13 JS引擎如何实现数组的稳定排序
你好,我是石川。
我们经常将数据结构和算法这两个词连起来说,可是你有没有想过,这两者是什么关系?
可以说数据结构是服务于算法的。这么说比较概括。下面我们就找一个切入点来讲解两者的关系我们先从排序算法说起。提到排序我们就要说到常用的数据结构数组比如在JS中我们一般都用过它的排序方法可是你知不知道排序方法背后用的是哪种算法呢这一讲就通过数组了解排序算法再来看JS 引擎是如何实现数组的排序的。
在数据结构中,我们大体可以分为两类。第一类是线性表,第二类是非线性表。这里的线性和我们生活中理解的线性没有本质上的区别,意思就是“按顺序的”。数组可以说是一种连续存储的线性表,并且可以算是一种比较基础的线性数据结构了。通过数组,我们可以实现比如之前说过的栈、队列等线性结构。你也可以用它来创建非常经典的非线性结构,比如堆和图。
除了数组以外另外一种既基础又经典的数据结构就是对象了。对象的一个特点是含有属性很多时候可以用来作为节点node通过节点相连的方式就可以创建链式结构比如链表对象的另外一个特点是它支持键值对key-value pair通过这个特点可以用来实现散列表和字典。
回到今天的话题,我们来重点说说数组。
数组的优劣势是什么
数组作为一种线性数据结构最大的特点是连续性带来的优势是可随机访问性但是它的劣势是高效的插入和删除。几乎所有的语言都把数组当做一种内置的数据类型提供在这一点上JavaScript也不例外。作为JavaScript数据类型之一我们在前面介绍过数组。但是作为数据结构我们可以从另一个角度来理解它。
我们前面说,数组的劣势是插入和删除,这是什么意思呢?我们假设在排队的时候,有人插队,那么整个组的人都会往后移动。这个时候,时间复杂度为\(O(n)\)。
那么使用数组的时候如何尽量克服这个劣势问题呢?和排队一样,方法就是按规矩让这个人排到队尾。在数组中,如果把数据元素插到队尾的话,那么整个时间复杂度仅仅为\(O(1)\)。所以通常如果我们不在乎增加一个新的数据到哪儿那么最好就是把它加到队尾。在JavaScript中如何将一条信息加到数组的队尾呢我们知道数组也是一种对象那么它也有着一些自带的属性和方法用来对数组进行操作。在JavaScript当中实现插入最简单的方式就是在数组的长度基础上算出下一个空位赋值为我们想加入的数据元素。
var week = [1,2,3,4,5,6];
week[week.length] = 7;
console.log(week) // 返回 [1,2,3,4,5,6,7]
在这里你可以看到它和C或者Java相同和不同的影子。和C或者Java相同的是我们的直觉会告诉我们开始数组长度是6那在第6个位置上赋值怎么会是空位这样不会覆盖到第6个数字吗不会。这是因为数组通常是从0而不是1开始计算的。和C或者Java不同的是在JavaScript里数组的长度是可以动态扩容的或者用我们之前讲的函数式思想来看内容也是可变的这点从上面的例子就可以看出。而在C和Java里数组的长度是一开始就决定的如果这时候增加一个新的数据元素就需要创建一个新的数组。
除了上面的方法还有另外一种方法可以快速地增加一个值到数组的尾部这种方式就是使用JavaScript中数组自带的push方法。从这里我们大概可以看出队列和栈的影子。当我们用上面排队举例子时其实就有队列的概念。下面的push在英文里其实就是“压栈”的意思。
var week = [1,2,3,4,5,6];
week.push(7);
console.log(week) // 返回 [1,2,3,4,5,6,7]
JS如何实现数组的排序
说完了数组的优劣势我们来看看它的排序实现。数组是如何实现排序的呢通过下面的例子我们可以来看看几种排序方式。其中前2种冒泡和选择排序是偏理论型的排序方式后面3种插入、快排和归并属于应用型的算法。Chrome用的V8引擎就是快排和插入排序的结合火狐用的SpiderMonkey则是基于归并来实现排序的。为什么浏览器会使用这几种排序方式呢我们可以通过它们的原理和特点来了解。
理论类排序法
首先我们先来看看理论类的冒泡、选择这两种排序算法。这两种方式用的都是一种比较和交换的方法compare and swap。我们上面说过对于数组来说如果我们在非队尾的位置插入的话就容易造成队列中插入的元素以后的一系列元素的移动。但是如果使用交换的方式就巧妙地避免了这个问题。
冒泡排序bubble sort的核心思想就是通过比较两个相邻的数据元素如果前面的大于后面的就交换它们的位置。
选择排序selection sort用的是一种原地比较的算法in-place compare它的核心思想就是找到最小的数据元素把它放到第一位然后再从剩下的数组中找到最小的数据元素然后放到第二位以此类推。
之所以说这两类排序算法更多是理论层面,是因为它们的时间复杂度都是\(O(n^{2})\)。下面我们来看看有没有时间复杂度更低的方式来处理数组的排序。
常用类排序法
下面我们再来看看一些非理论类,实际使用中常用的排序算法。
插入排序insertion sort和之前的冒泡和选择排序法不同它不是用比较和交换而是通过位移来做插入。它假设数组中第1个元素已经排序了。从第2个和第1个比较如果小于第1个值就插入到前面如果大于就原地不动第3个值同理和前面的2个比较小于就左移插入大于就不动后面的操作都以此类推。虽然插入排序虽然从复杂度上来看和冒泡及选择算法类似都是\(O(n^{2})\),但是实际上,对于一些较短的数组来说,它的性能是明显更好的。
归并排序merge sort可以算是比较常见的应用型排序算法了它的复杂度是\(O(n log n)\)。下面,我们就来看看它的实现。归并算法用到的是我们前面介绍过的递归和分治的算法思想。这种方法的核心就是先把一个大的数组拆分成只包含一个元素的不可再分的数组,然后两两比较,最后再合并成一个数组。
下面我们再来看看快排算法quick sort和归并类似快排也用到了递归和分治同样它的时间复杂度是\(O(n log n)\)。说它也用了分治,是因为快排也会对数组“分开来”分析;但是和归并不同的是,它不会把元素真的“拆出来”再组合。那么它是怎么实现排序的呢?
在快排中用到的是区分点pivot和指针pointer。在数组中我们会在最前和最后一个元素的位置各加一个指针然后在中间的位置加一个区分点。我们会不断移动左指针直到我们找到一个元素大于区分点同时我们也会不断移动右指针直到我们找到一个小于区分点的数然后将它和左指针指向的数据交换。这里类似我们在冒泡排序中提到的比较和交换的算法。这个过程可以使得所有区分点左边的值都小于右边的数这个过程叫做分区partition。这个方式会按照递归的方式不断重复直到最后整个数组排序完成。
关于快排有一点需要注意,就是分区的实现。关于分区实现中的区分点,我们需要注意的是为了避免分区后两个区域的数据量相差很大,我们可以默认在两个指针之间选一个平均值,但更好的办法是通过随机的方式来选择,虽然这样不能保证最好的结果,但从概率角度,也不会太差。
排序的稳定性问题
前面我们说了那么多那么回到我们之前的问题为什么V8用的是插入加快排的方式SpiderMonkey用的是归并的方式来做排序呢这就要从排序的稳定性说起了。在ECAMScript的官方文档中虽然并不指定JS引擎对sort的具体实现方式但是有一条准则就是要确保数组的排序必须是稳定排序stable sort
从理论上讲,虽然归并和快排的复杂度都是相同的,但是前面我们忽略了两个重要因素,第一个是时间复杂度的平均和极端情况,第二个是空间复杂度的问题。快排在平均情况下的空间复杂度是\(O(n log n)\),但是在极端情况下是\(O(n^{2})\)。
那你会说,这样为什么不直接用归并排序,因为归并排序也不是没有问题,在使用归并排序的时候,虽然它是稳定的,但它不是原地排序法,这就造成了它的空间复杂度是\(O(n)\)必然要高于其它的算法类型。所以这就是为什么V8和SpiderMonkey各自选择不同算法的原因。对于V8而言为了既解决稳定性问题又解决复杂度问题就使用了快排加插入的方式在数据量比较小的情况下使用插入在数据量比较大的时候则切换到快排。而对于SpiderMonkey来说就通过归并来满足稳定性的需求。
延伸:线性类排序法
还有一类的排序法我们称之为线性排序法为什么这么叫呢。因为他们使用的都是非比较类的排序方法这里包括了计数、桶和基数排序因为这几种排序算法都是用于特殊场景我们就不在这里讲它们的具体实现了但是我也会把源码放到GitHub上。如果你想做延伸了解可以通过代码注释来了解。
总结
今天我们通过数组这种最简单的数据结构了解了并不简单的排序算法。你应该学到了数组这种数据结构的优劣性同时我们也看到了V8和SpiderMoney使用的排序方式。这一讲最核心的并不是我们学到了这些逻辑和应用更重要的是我们看到了全面分析的思维方式。比如我们看到的时间复杂度只是一个方面它不能直接代替性能。当我们把它和稳定性及空间复杂度的思考维度相加的时候才能对解决方案作出更全面和客观的判断。同时这也告诉我们我们所写的程序基于不同的引擎和数据量可能会有不同的结果。
思考题
今天留给你的思考题是通过今天学到的知识你能不能讲讲你知道的其它的JS引擎用的是什么算法
期待在留言区看到你的分享,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!