前端算法系列(2):排序

945 阅读5分钟

概述

js提供的排序api为

arr.sort([compareFunction])

如果不提供排序函数,则会将元素转化为字符串并按从前到后依次对比各字符的charcode升序排列,比如

[12,2].sort()//[12,2]

比较函数的两个参数a,b是待比较的两个元素,如果函数返回值小于0,则a会排在b之前,如果返回值大于0,则a排在b之后,否则不变。

其他排序

其他是和语言无关的排序算法,这里会介绍三种简单的和三种时间复杂度低的,即

  • 冒泡排序
  • 选择排序
  • 插入排序
  • 归并排序
  • 快速排序
  • 堆排序

其中前三种时间复杂度o(n^2),后三种o(nlogn),相关源码这里

另外的这里不展开,比如

  • 桶排序 先确定有限个桶,然后将待排序元素按照特定属性放入各自桶中,再根据需要确定是否对每个桶进行排序。比如在前 K 个高频元素中的特定属性指的是元素出现的频率,这道题的具体思路为
    • 统计每个元素出现的频率,并记录最大频率数n
    • 准备n个桶,并根据频率数将各自元素放入各个桶内
    • 然后从高频到低频的方向统计k个元素即可
  • 拓扑排序 是对有向无环图顶点的广度优先遍历

冒泡排序

双层遍历,外层确定遍历范围,内层比较。

以递增排序为例,外层i从最后一个元素开始递减直至第一个,内层j从第0个开始和下一个比较,如果前面的大于后面的就交换,直到i-1个。

module.exports = (arr) => {
  for (let i = arr.length - 1; i > 0; i--) {
    for (let j = 0; j < i; j++) {
      if (arr[j] > arr[j + 1]) {
        ;[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
      }
    }
  }
  return arr
}

选择排序

选择排序和冒泡很类似,但内层比较遇到需要调整时不会立即交换,而是在内层比较之前假定第0个为最大值(记序号为max),然后遍历一遍待排序元素,直到第i个,然后将第i个和第max个交换。

虽然不需要每次都交换,但时间复杂度没差别。

module.exports = (arr) => {
  for (let i = arr.length - 1; i > 0; i--) {
    let max = 0
    for (let j = 1; j <= i; j++) {
      if (arr[max] < arr[j]) {
        max = j
      }
    }
    ;[arr[max], arr[i]] = [arr[i], arr[max]]
  }
  return arr
}

插入排序

插入排序是假定第0个有序,i从第1个开始依次插入前面(i-1)有序数组中,使前i个成有序数组。

插入时,虽然插入位置可以通过二分法寻找,但实际的插入时间复杂度仍然没变化,而且书写更容易出错,因此这里选择交换插入。

module.exports = (arr) => {
  for (let i = 1; i < arr.length; i++) {
    // binaryInsert(arr, i)
    compareInsert(arr, i)
  }
  return arr
}
//比较插入
function compareInsert(arr, i) {
  while (arr[i - 1] > arr[i] && i > 0) {
    ;[arr[i - 1], arr[i]] = [arr[i], arr[i - 1]]
    i--
  }
}

归并排序

归并排序是分治法的一类典型问题,即将原问题分为子问题(分),然后解决子问题进而解决大问题,并最终解决原问题(治)。

归并排序即将大数组分为两个小数组,然后递归分解每个小数组,最终得到长度为1的子数组,然后将已经排好序的小数组(长度为1的数组已经有序)分别合并成大数组,并最终合并成排好序的原数组。

module.exports = (arr) => {
  function sort(arr1) {
    if(arr.length<2) return arr
    let mid=Math.floor((arr.length-1)/2)
    return merge(sort(arr.slice(0,mid+1)),sort(arr.slice(mid+1,arr.length)))
  }
  function merge(arr1, arr2) {
    let arr=[]
    while(arr1.length&&arr2.length){
      arr.push(arr1[0]>arr2[0]?arr2.shift():arr1.shift())
    }
    arr.push(...(arr1.length?arr1:arr2))
    return arr
  }

  return sort(arr)
}

快速排序

快速排序也是一种分治,和归并排序不同的是,快速排序是原地排序,而且只需要将大问题分解为小问题并解决即可,不需要后面的治。

具体的说,就是每次取一个参考值,比如第0个,将大于参考值的放到参考值后面,小于参考值的放到前面,这样就保证了参考值的有序,且原数组被分成两部分,在每个区间递归快速排序直到全部有序。

module.exports = (arr) => {
  function sort(l, r) {
    //由于l和r的来源,这里l可能大于r
    if (l >= r) {
      return
    }
    let pivot = arr[l], //挖坑
      left = l,
      right = r
    while (left < right) {
      //此时第0个位置为空,则遇到右边小于pivot的就移动过来
      //后面需要等于号,不然如果有重复数据永远不会走出循环
      while (left < right && arr[right] >= pivot) {
        right--
      }
      arr[left] = arr[right]
      //此时right的位子为空
      while (left < right && arr[left] <= pivot) {
        left++
      }
      arr[right] = arr[left]
    }
    //此时left===right
    arr[left] = pivot
    sort(l, left - 1)
    sort(left + 1, r)
  }
  sort(0, arr.length - 1)
  return arr
}

堆排序

堆数据结构

堆是一种数据结构,在逻辑上是一种完全二叉树,在物理上是数组。

这一节前置了一些二叉树的知识,后面会在对应章节具体讨论。

image.png

因为数组下标是从0开始的,二叉树从1开始的,因此处理前在数组前添加一个元素,比如0。二叉树在数组中按照广度优先遍历的顺序保存。

如果所有父节点都大于等于其子结点,这样的堆被称为大根堆,按照数组的形式即arr[i]>=arr[2*i]且arr[i]>=arr[2*i+1],最后一个非叶子节点序号是Math.floor((arr.length - 1) / 2)

堆排序

堆排序就是利用堆这种数据结构进行排序,即利用堆顶(比如大根堆)是最值的特点可以依次选出前n(大)元素,乃至将整体排序,堆排序适合不需要完整排序的情况,比如数组中的第K个最大元素

在排序之前我们得到的是一个普通数组,因此首先要建堆和排序两部分

module.exports = (arr) => {
  //当把数组当作树看待时,应在前面补充一个元素,使得下标一致,这样数组的最下下标为1
  arr.unshift(0)
  //首先将原序列调整为大根堆
  buildMaxHeap(arr)
  //从大根堆最后一个开始和第一个元素交换,即将最大元素调整至序列最后
  for (let i = arr.length - 1; i > 1; i--) {
    ;[arr[i], arr[1]] = [arr[1], arr[i]]
    //  然后把除了最后一个元素的序列调整成大根堆,重复以上过程直到子序列还剩最后一个元素,排序完成
    heapAjust(arr, 1, i - 1)
  }
  arr.shift()
  return arr
}
//调整以第k个元素为根的子树为大根堆
const heapAjust = (arr, k, end) => {
  //arr为原数组,k为指定待调整子树的根节点,len为调整范围,创建大根堆时不是必须的,主要是排序时使用
  //将根节点调整为该子树的最大值
  arr[0] = arr[k] //先把当前子树根节点暂存,随后就可以将该位置用更大的值覆盖
  for (let i = 2 * k; i <= end; i *= 2) {
    if (i < end && arr[i] < arr[i + 1]) {
      //如果有右子树且右子树大于左子树,则用右子树和当前父节点比较
      i += 1
    }
    if (arr[0] >= arr[i]) {
      break
    } else {
      arr[k] = arr[i] //较大的子结点成为根节点
      k = i //继续以新的k为根节点继续比较
    }
  }
  //最后k的位置是最终应该放的值
  arr[k] = arr[0]
}
//建立大根堆,大根堆中的父节点大于等于子结点
const buildMaxHeap = (arr) => {
  //将所有的非终端节点,从后往前依次遍历,使以各个节点为根的子树变为大根堆,
  //其中最后一个非终端节点为:不大于序列长度n/2的整数,此时n=arr.length-1

  //注意条件没用等号,因为0位置是后来补的
  for (let i = Math.floor((arr.length - 1) / 2); i > 0; i--) {
    heapAjust(arr, i, arr.length - 1)
  }
}