前言
初学数据结构算法,看了很多大佬们写的文章,很多他们觉得很简单,没有提过或者一笔带过的东西,往往我可能需要研究思考个三五天,所以初学时往往会漏掉很多东西,以这篇文章来算,排序前前后后差不多学了三遍。第一遍看代码,觉得排序不过如此很简单嘛。第二遍,哟,还能这样写的啊。第三遍,厉害啊,原来还有这么多其他的东西在里面。所以学完第三遍以后,赶紧来记录一下,后续不排除还有第四遍(手动狗头)。
在开始正式的分享之前,还是想要说下自己学习算法的方法,当然可能不是每个人都和我一样是一个菜鸟。每次学习的时候,我都习惯于将代码复制到vscode中打个断点去调试一下,毕竟人脑肯定不如计算机,有时候人脑会过于理想化,就是把一些计算过程想象成自己觉得的样式,但实际情况是和想象的完全不一样的,不知道大家是否有这样一种感觉,你觉得计算完的结果应该是这样的,肯定没问题,但计算出来却出乎意料,所以绝大多数的时候,还是需要自己运行一下代码,看看执行过程,这样才能真正的了解代码运行的情况。
准备工作
对于排序算法来说,不仅仅时间复杂度很重要,同时还需要关注这个算法是否是原地排序,是否是稳定的。下面来一一简单解释下。
所谓原地排序,刷过题的小伙伴应该都是比较了解的,就是空间复杂度为O(1)的算法,换句话说,就是在原输入数据中进行修改,返回修改后的数据。其次就是稳定性,就是排序中,两个同样的值,在排序前后的值的位置是不变的。这个也很好理解,如果位置不变,那么肯定会减少移动和交互的次数,在大规模的数据排序中,这种差距是非常明显的,后面也会去验证。
另外为了方便测试,准备了一个生成随机数组的方法,根据输入,生成对应长度的数组:
const generateArr = nums => {
const arr = [];
for (let i = 0; i < num; i++) {
arr[i] = Math.floor(Math.random() * (10 * num));
}
return arr;
}
这个后面会用到,先提前拿出来说一下。
选择排序
const selectionSort = arr => {
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] > arr[j]) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
}
return arr;
}
这里思路很简单,就不再赘述了。先看看他是否是一个稳定的排序算法,举个例子:
代入上面的方法,可以看到在第一轮排序以后,第一个7被交换到了第二个7的后面,所以根据前面关于稳定性的定义,显然选择排序并不是稳定的排序算法。
以上这些都是比较容易得到的结论,那么经常谈论到的排序算法优化,又该如何做呢。对于选择排序来说,优化的地方有两点,一个就是减少比较次数,另一个就是优化变量交换。所以可能在你忽略的地方恰恰就是优化的点。
利用上面的生成数组的方法,简单做了一个计算算法执行时间的工作,当然这里时间并不是精确的,但是也能大概看到算法的执行时间趋势。
const selectionSort = arr => {
// 优化:这里到最后一个元素的时候,整个数组已经是有序的了,所以可以去掉
for (let i = 0; i < arr.length - 1; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] > arr[j]) {
// 1.位运算
arr[i] = arr[i] ^ arr[j];
arr[j] = arr[i] ^ arr[j];
arr[i] = arr[i] ^ arr[j];
// 2.数组解构
[arr[i], arr[j]] = [arr[j], arr[i]];
// 3.临时变量
const temp = arr[j];
arr[j] = arr[i];
arr[i] = temp;
}
}
}
return arr;
}
const arr = generateArr(100000);
const start = Date.now();
selectionSort(arr);
const end = Date.now();
console.log(`算法用时为:${end - start}ms`);
分别按照上述三种变量交换的方式,经过多次测试,发现数据量在10w的时候,执行时间消耗截图如下:
每次执行误差在100ms左右,所以其实有些出乎意料,当然仔细一想发现确实也是这么回事。先来看位运算,虽然位运算执行快,但还是需要计算的,交换一次变量需要执行三次位运算,三次赋值运算。再看解构,左右两边都是结构赋值操作,所以是执行了4次的赋值操作。最后临时变量实际只有三次赋值。执行结果符合上述的解析。
当将数据量减少到1w的时候,三种交换方法执行时间基本差不多了,所以到这里,可以得到一个推断:算法应用到那些数据规模较大的计算中才有意义,当数据量比较少的时候,其实执行速度是差不多的。为了代码的简洁,上面讲述变量交换的时候,还是用解构的方法来作为示例了。
其实一说出来大家都能明白,只是容易忽略这些,了解了这些,以后别人再问优化交换变量,想必也都能做到心中有数,其实下面几种排序,也都是类似的。所以也就不再一一说明。
冒泡排序
const bubleSort = arr => {
for (let i = 0; i < arr.length - 1; i++) {
// 设置一个标识符,表示此轮是否有进行交换
let flag = false;
for (let j = 0; j < arr.length - i - 1; j++) {
if (arr[j + 1] < arr[j]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
// 出现交换,标识符设置为true
flag = true;
}
}
// 表示没有交换,说明已经是有序的,所以可以直接退出循环
if (!flag) break;
}
return arr;
}
通过上述代码可以很容易看出冒泡排序是一个原地排序算法,接着再去分析冒泡排序是否是稳定的排序算法,还是先来看冒泡排序的执行流程,以上面的例子为准:
不难看出重复的7一直保持着前后的顺序,所以冒泡排序是一个稳定的排序算法。
上面代码里有一个标识符flag。当排序一个数组时,本质上就是将数组中无序的地方给修改成有序的,对于冒泡排序来说,每一轮的排序都会得到一个最大值。当前轮没有进行交换的时候,说明后面的已经是排序完毕了,所以可以直接退出循环。
再来看个例子:
不知道有没有细心的同学发现其中的问题,反正我当时是没有发现(狗头保命)。其实就是内循环的时候,右边的2个数已经是有序的了,但是比较的时候,还是会去遍历比较,因此这里可以添加一个有序边界的标示,只去遍历到有序边界的前一个数。
// 设置最后一次交换的位置以及有序边界索引
let lastExchange = 0, orderBorder = arr.length - 1;
for (let i = 0; i < arr.length - 1; i++) {
let flag = false;
for (let j = 0; j < orderBorder; j++) {
if (arr[j + 1] < arr[j]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
flag = true;
// 为什么不是j+1呢,原因就是每次是j和j+1相比,j+1已经有序,那么j肯定也是有序的,所以需要取一个最小的值
lastExchange = j;
}
}
// 把最后一个交换位置赋给有序边界
orderBorder = lastExchange;
if (!flag) break;
}
到这里冒泡排序的优化是否就已经结束了呢,不,还有优化的空间。冒泡排序每次都是两两比较,将较大的值往右边冒泡过去,这样的话,一共就需要进行length - 1轮冒泡。如果往右边冒泡完以后,在望左边冒泡呢。来看下执行流程:
可以看到在数据部分有序的情况下,只用了两轮就执行完了排序,最后一轮只是展示结果,实际在第二轮就已经排好序了,是不是又进行了一次优化呢?实际上以上排序算法有一个新的名词叫做鸡尾酒排序,是冒泡排序的一种优化,两者的时间复杂度是一样的,但是鸡尾酒排序更适合那种数组本身存在部分有序的情况下,它的执行效率会比冒泡排序更高。
下面来看看其代码实现:
const cocktailSort = arr => {
const n = Math.floor(arr.length / 2);
for (let i = 0; i < n; i++) {
let flag = false,
lastLeftExchange = 0,
orderLeftBorder = i,
lastRightExchange = 0,
orderRightBorder = arr.length - i - 1;
// 往右冒泡
for (let j = i; j < orderRightBorder; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
flag = true;
lastRightExchange = j;
}
}
orderRightBorder = lastRightExchange;
if (!flag) break;
// 往左冒泡
flag = false;
for (let j = orderRightBorder; j > i; j--) {
if (arr[j - 1] > arr[j]) {
[arr[j - 1], arr[j]] = [arr[j], arr[j - 1]];
flag = true;
lastLeftExchange = j - 1;
}
}
orderLeftBorder = lastLeftExchange;
if (!flag) break;
}
return arr;
}
这里将刚刚的有序边界判断代入其中,每轮循环确定左右有序边界,每次就只需要比较左右边界中未排序的成员就可以了。可能会有人奇怪:这里边界条件声明放在了for循环里面了,但是刚刚明明是放在外面的。仔细一想就明白了,普通冒泡的时候,只有往右比较,下次循环还是基于这个边界的,但是双向冒泡就不一样了,往右冒泡完以后,往左的时候是需要用到之前循环的右边界的值的,因此如果边界放在最外层的话,就会导致每轮循环初始条件都是不变的,从而排序导致了错误。
继续用上面的方法来测试算法的执行时间,结果发现10w数据量情况下,鸡尾酒排序一般是10s左右,普通冒泡和优化冒泡的时间都在16s左右,结果就不截图了,有兴趣可以自己手动执行看看。
看到这里,以后出去面试的话,面试官问到冒泡排序怎么优化,就能好好说说了。
插入排序
const insertionSort = arr => {
for (let i = 1; i < arr.length; i++) {
// 要插入的数据
const value = arr[i];
let j = i - 1;
for (;j >= 0; j--) {
// 如果要插入的数据比他前面的数要小
if (value < arr[j]) {
// 往后移动一位
arr[j + 1] = arr[j];
} else {
break;
}
}
// 找到合适位置插入数据
arr[j + 1] = value;
}
return arr;
}
上面的代码才符合插入的定义,可以看到当找到合适的插入位置的时候,会将后面的数全部往后移动一位,然后将这个数字插入找到的位置中。在此之前,我也看过这样的插入排序:
function insertSort(arr) {
const len = arr.length;
// 比较每项的数值和他左边已排序的树进行比较,外层排序次数len - 1
for (let i = 0; i < len - 1; i++) {
// 内层排序从1开始,和他左边的值进行比较
for (let j = i + 1; j > 0; j--) {
if (arr[j - 1] > arr[j]) {
[arr[j - 1], arr[j]] = [arr[j], arr[j - 1]];
}
}
}
return arr;
}
这种更类似于选择排序,区别就是选择排序是和右边所有的数进行比较,而此种排序是和左边的数比较,按照上面的执行时间方法进行对比以后会发现,差距还是比较明显的,所以此方法更像是一种选择排序的变形,其时间复杂度是和选择排序一样的,有兴趣的同学可以按照上面的步骤试试。
同样插入排序也是一个原地排序算法,并且也是稳定的排序算法,这个一样从执行流程中可以看出,就不再赘述了。
希尔排序
const shellSort = arr => {
// 动态计算间隔序列
const len = arr.length;
let h = 1;
while (h < len / 3) {
h = h * 3 + 1;
}
// 两个间隔h的元素进行比较
while (h > 1) {
for (let i = h; i < len; i++) {
for (let j = i; j >= h; j -= h) {
if (arr[j] < arr[j - h]) {
[arr[j], arr[j - h]] = [arr[j - h], arr[j]];
}
}
}
h = (h - 1) / 3;
}
return arr;
}
希尔排序是插入排序的一种优化,它并不是比较相邻两个元素,而是比较一定间隔的两个元素,通过缩小间隔来再次重复进行比较。下面是它的执行过程:
可以看到在第一轮的时候,第一个7就被交换到了第二个7后面的位置,因此希尔排序并不是一个稳定的排序。关于其时间复杂度,受到其间隔序列影响,因此也是不一样的,一般情况下,就将其平均时间复杂度设置为O(nlogn)。依据这个时间复杂度,进行了上面的一个时间测试,结果发现在10w量级的情况下,希尔排序耗时远远高过插入排序,这是怎么回事呢?
如果对插入排序了解的比较多的话,就可以看出这里面进行了很多次无效的循环,也就是插入排序中说的,当前值不需要移动,那么说明前面的值都是有序的,也就不需要再去循环了,所以循环是可以退出的,因此按照插入排序的思路,再去优化一下算法:
// 省略
for (; gap > 0; gap = Math.floor(gap / 3)) {
for (let i = gap; i < len; i++) {
temp = arr[i];
let j = i - gap;
for (; j >= 0 && arr[j] > temp; j -= gap) {
arr[j + gap] = arr[j];
}
arr[j + gap] = temp;
}
}
这里前面都是一样的,后面将循环改造了一下,但最外层循环还是一样的,里面的双层循环看起来和插入排序基本已经差不多了,不同点在于,将判断条件放入了for循环中,所以当for循环的条件不满足时,就不会进行循环,相当于使用了break退出了循环,效果也是一样的。最后执行测试代码看看执行时间:
简直就是巨量的提升!所以这里可以看出来,有时候算法步骤不恰当,将会导致算法效率大大的降低,所以一定要理解每一步的意义,而不仅仅只是知道怎么写。
结尾
至此,简单排序算法极其优化就已经说完了,没想到说了这么多,所以就暂且到这里,自己也需要消化消化,后面继续说。
本文使用 mdnice 排版