[算法拆解] 一文说透排序算法的特点(上)

569 阅读8分钟

说透排序算法(上)

系列开篇

为进入前端的你建立清晰、准确、必要概念和这些概念的之间清晰、准确、必要关联, 让你不管在什么面试中都能淡定从容。没有目录,而是通过概念关联形成了一张知识网络,往下看你就明白了。当你遇到【关联概念】时,可先从括号中的(强/弱)判断简单这个关联是对你正在理解的概念是强相关(得先理解你才能继续往下)还是弱相关(知识拓展)从而提高你的阅读效率。我也会定期更新相关关联概念。

算法拆解是带你分析算法,掌握算法规律,体会概念与关联的力量。更重要的是让你不害怕算法题,利用分治思维拆解它,你会发现,又是回到了 概念关联 上。下面来看看我们遇到问题,如何去拆解, 并举一反三的吧。

算法是逃不掉的,它是你最直接的实现程序能力的体现。你写的每一句代码,每一种架构思考,每一种优化方式,都是你编程路上的硬核能力,所幸这个能力下'功夫'就能得到。

这几篇说什么,你能获得什么

这几篇重点是:

  1. 详细描述各种排序算法的使用方式实现方式特点复杂度分析
  2. 让你能清晰的了解这些排序的区别与联系掌握排序思想的精髓

先说下为什么分上中下3篇,因为一篇的话篇幅太长,人的接受能力、程度还有耐心分成2篇比一篇更容易吸收,而且读下篇的时候能很好的用上篇的一些基础知识来做铺垫,流水账列出全部的排序方式只会让你读一遍就忘,收藏然后永远就不看了。

我们不要流水账列举这些算法,一个个拆解他们,从简单的开始,一步步走向更复杂的逻辑。

下面我们就一个个看这些个排序算法,不要急,一个个吃透了就不担心忘记,因为理解了,忘了就直接跳到中间看就好,右边有目录,随时查阅。

大纲-由浅入深

上:

  • 冒泡排序
  • 选择排序
  • 插入排序
  • 希尔排序

中:

  • 计数排序
  • 桶排序
  • 基数排序

下:

  • 快速排序
  • 归并排序
  • 堆排序

总结

  • 先了解各种排序后再看看动画图加深印象
  • 各种排序的各维度对比
  • 时间空间复杂度如何分析
  • 排序的深水区拓展

那么进入排序的世界吧

1.冒泡排序

简单的排序我们就不废话,简单说

冒泡排序的工作原理:

  1. 进入第【1】轮比较,我们从头开始比较相邻元素,两两比较,如果前一个大于后一个,就交换。
  2. 继续比较,从开始第一对到结尾的最后一对,结束后,最后的元素会是本轮(第【1】轮)最大的数。也就是说我们进行了【1】轮比较,获得了 【1】个最大的数。
  3. 进行第【2】轮比较 ,注意这轮只需要比较前面 len - [1] 个数了,因为最后一个已经最大。
  4. 重复步骤直到最后一轮,没有数字比较。

实现

let testArr = [1, 9, 34, 5, 1, 2]

const bubbleSort = (array) => {
  if (array.length <= 1) {
    return array;
  }

  let lenIndex = array.length - 1;

  for (let i = 0; i < lenIndex; i++) {
    for (let j = 0; j < lenIndex - i; j++) {
      // 比较相邻的元素。前面的大就换到后面,这样一轮下来最大的就'冒'到最后了
      if (array[j] > array[j+1]) {
        // 利用解构赋值的 模式匹配来交换
        [array[j], array[j+1]] = [array[j+1], array[j]]
      }
    }
  }
  return array
}

console.log(bubbleSort(testArr))

2.选择排序

选择排序的工作原理:

  1. 遍历数组,找到最小元素,存放到最前面位置,当前第一轮,就放第一个[index === 0]。
  2. 继续第二轮,找到最小元素,放到第二个位置。
  3. 继续下去,直到所有元素均排序完毕。

简单来说就是:

  • 第一轮,遍历一遍[选择]最小的,放第一个位置,
  • 第二轮,在剩下的数里面[选择]个最小的,放第二个位置
  • ......
  • 每轮选择一个,直到最后一轮。

实现

let testArr = [1, 9, 34, 5, 1, 2]

const selectionSort = (array) => {
  if (array.length <= 1) {
    return array;
  }

  let len = array.length
  let minIndex
  for (let i = 0; i < len - 1; i++) {
    // 本轮 index 
    minIndex = i;
    // 遍历剩下的数组,从中找到最小的
    for (let j = i + 1; j < len; j++) {
      if (array[j] < array[minIndex]) {
        // 当遍历发现有数据更小,记下它的index
        minIndex = j
      }
    }
    // 交换本轮放置位置 array[i]的数,和我们刚找到最小的数,也就是把最小的数放到本轮位置上。
    [array[i], array[minIndex]] = [array[minIndex], array[i]]
  }
  return array;
}

console.log(selectionSort(testArr))

3. 插入排序

(直接)插入排序的工作原理:

  1. 选取当前位置(当前位置前面已经有序) 目标就是将当前位置数据插入到前面合适位置。
  2. 向前遍历,一个个比较,比它大的往后移动(一次次交换),最终找到合适位置,插入。

形象记忆: 就像操场排队,一个个比过去,然后在合适位置插入站好。

实现

let testArr = [1, 9, 34, 5, 1, 2]

const insertionSort = (array) => {
  if (array.length <= 1) {
    return array;
  }
  let current = 0;
  for (let i = 0; i < array.length - 1; i++) {
    // current 就是当前的要插入的数(手里现在拿的牌)
    current = array[i + 1];
    let preIndex = i;
    // 把这个数依次跟它前一个、前前面等等比较,
    while (preIndex >= 0 && current < array[preIndex]) {
        array[preIndex + 1] = array[preIndex];
        preIndex--;
    }
    // 因为他前面的都是拍好序的,记下index直接插入就行了
    array[preIndex + 1] = current;
  }
  return array;
}

console.log(insertionSort(testArr))

文章写到这,我突然觉得,我这样列举下所有的排序,诚然你们会留下印象,但只是简单的粗浅的印象,比如这样排序为什么快,有的排序在数列基本有序后效率会提高,甚至这个O(n),O(...)这些时间空间复杂度到底啥意思都有疑问,就好像回到很久之前的小学课堂听着老师的照本宣科而忘了实践这玩意到底知道它干嘛,没有追根溯源,没有再进一步思考。所以我决定大多数用自己的白话,和简单的例子还有边角知识等把些必要的零件给你们,让你们有完整的体系思考,这样记得更牢,或者说已经成为你们内化的知识,这就是我的目的,所以决定分个3篇来把排序这问题好好缕缕。

从旁观者的角度看自己的行为,(尽可能)从未来思考自己的当下的做法是否合理。当你看到一半,有很多疑问,太正常了。合理的做法不是怀疑自己,而是去寻找能解释你疑问的答案,可搜索,可评论,可询问等一切手段让你头脑保持清醒。你的疑问可能来自于,有些知识我知道而在文章中并未点出,缺陷来自于我的表达,也可能来自于你过去的错误认知。当然,有的你可以先记下来,继续,多读多体会,多看其他的例子声音,再回头理解,这样就可以在你脑海中形成这些足够清晰的概念

4. 希尔排序

希尔排序,是插入排序的一种更高效的改进版本。我们再回头关注下直接插入排序,我们发现一个特点,当插入排序用于基本有序的数列,效率会高,因为插入排序比较时如果数列基本有序其实真正需要做的操作就少,打个比方,如果排队基本已经从低到高了,只要换几个人就行,操作当然会减少,(扑克牌理牌也是同理,做个极端假设,已经直接有序了那么比较结果就是一次操作都不做)。

希尔排序的改进思想就是:先将整个序列分割成为若干子序列分别进行直接插入排序,每一轮这个序列都会变得更有序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。

那么接下来的问题就是分割方式,我们采用 取余 这个类似报数(1、2、3、4、1、2、3、4然后1的一组,2的一组。。。分4组)这样的形式分组各组分别进行直接插入排序,这样很小的数在后面可以通过较少的次数移动到相对靠前的位置。(为什么较少次数?假设共8人 1,2,3,4,1,2,3,4 这个第4组 4-4 如果最后一个位子的数是最小的,一下就能换到第4位,而不是一个个比较往前面换,这就是为什么能用较小代价换到前面)

在希尔排序出现之前,计算机界普遍存在“排序算法不可能突破O(n²)”的观点。希尔排序是第一个突破O(n²)的排序算法。

我认为这个画个图吧,更清晰理解如何分组:gap是间隙的意思,你可以称为增量

图例

5    6    1    8    2    4    6    1      gap = length / 2 = 4 增量是4,表示间隔是4
└─────── 1组 ───────┘
     └─────── 2组 ───────┘
          └─────── 3组 ───────┘
               └─────── 4组 ───────┘
               
那么,这第一轮分组下来,组别是 [5, 2], [6, 4], [1, 6], [8, 1] 这样对这些小组进行插入排序,
就变成 [2, 5], [4, 6], [1, 6], [1, 8] => [2, 4, 1, 1, 5, 6, 6, 8] 
2                   5
     4                   6
          1                   6
                1                  8
这样可以看到像12这种小元素, 8这种大的很快交换很少次数就到头尾了,数列也`基本有序`了

然后我们缩小增量 

2    4    1    1    5    6    6    8     gap = 4(刚刚的gap) / 2 = 2
└─────────┘─────────┘─────────┘
     └─────────┘─────────┘─────────┘
     
就变成 [2, 1, 5, 6], [4, 1, 6, 8] => 再排序 [1, 2, 5, 6] [1, 4, 6, 8]
=> 第二轮排好结果 [1, 1, 2, 4, 5, 6, 6, 8]
1         2         5         6
     1         4         6         8
     
继续下去,第三轮,直到 gap = 1 整个变成一组, 其实我们发现已经拍好了,所以最后一轮啥都不干。

我们观察到,增量(也就是交换数之间的间隔)越来越少,直到gap<1也就是没间隔相邻了,就剩一组了,所以希尔排序也称递减增量排序算法。

实现

let testArr = [5, 6, 1, 8, 2, 4, 6, 1]

const shellSort = (array) => {
  let len = array.length
  // 首先我们把间隔设为 gap = len / 2 取整数; 每次取一个间隔进行分组来排序。
  for (let gap = len / 2; gap >= 1; gap = Math.floor(gap / 2)) {
    // 其实下面就是一个以gap间隔分组的数列进行的直接插入排序
    for (let i = gap; i < len; i++) {
      for (let j = i; j >= gap && array[j] < array[j - gap]; j = j - gap) {
        [array[j - gap], array[j]] = [array[j], array[j - gap]]
      }
    }
    console.log(`本轮gap: ${gap}`, `本轮排序结果: ${array}`)
  }
  return array
}

console.log(shellSort(testArr))

我们再思考下这个排序的优化,理论上希尔排序的增量数列可以任取,需要的唯一条件是最后一个一定为1(因为要保证按1有序)。

我们选取了增量是 gap = len / 2 这样,折半是最初 Donald Shell 提出的增量。但细想下,按 4 有序的数列再去按 2 排序会浪费很多次比较,在我们的图示上也能看出来,2轮分组时其实很多对都比较过了。所以我们的结论是:增量序列中每两个元素最好不要出现1以外的公因子! 我们取 Knuth 提出的 (1, 4, 13, 40, 121, ... 3k+1)当增量序列来优化,一般来说复杂度小于O(n^(3/2))。

Knuth优化实现希尔排序


let testArr = [5, 6, 1, 8, 2, 4, 6, 1, 10, 3, 18, 0]

const shellSort = (array) => {
  let len = array.length
  let gap = 1
  // 使用 Knuth 增量 
  while (gap < len / 3) {
    gap = gap * 3 + 1
  }
  for (gap; gap >= 1; gap = Math.floor(gap / 3)) {
    for (let i = gap; i < len; i++) {
      for (let j = i; j >= gap && array[j] < array[j - gap]; j = j - gap) {
        [array[j - gap], array[j]] = [array[j], array[j - gap]]
      }
    }
    console.log(`本轮gap: ${gap}`, `本轮排序结果: ${array}`)
  }
  return array
}

console.log(shellSort(testArr))

请等待中、下、总结篇


继续下去,你总会有收获。 上面这句话给你们,同样也给我自己前进的动力。

我是摩尔,数学专业,做过互联网研发,测试,产品
致力用技术改变别人的生活,用梦想改变自己的生活
关注我,找到自己的互联网思路,踏实地打牢固自己的技术体系
点赞、关注、评论、谢谢
有问题求助可私信 1602111431@qq.com 我会尽可能帮助你

参考