前端面试算法复习之排序算法

235 阅读9分钟

排序算法归纳

关键字说明

评价算法的术语

时间复杂度: 算法中基本操作重复执行的次数是问题规模n的函数f(n),T(n)=O(f(n))
平均时间复杂度: 所有可能的输入数据对应f(n)的平均值,一般我们所说的时间复杂度都是指平均时间复杂度
空间复杂度: 算法所需要的存储空间同问题规模n的函数

稳定: 对于大小相等的两个数x1和x2,排序前x1在x2前面,排序后x1还是会在x2前面
不稳定: 排序后x1可能会在x2后面

内部排序: 所有排序操作都在内存中完成
外部排序: 待排序的序列存储在外存上,排序过程中需要进行多次的内、外存之间的交换。例如大文件的排序。

排序算法比较

二分查找

在一个有序序列中查找出关键字所在的位置。可以利用二分的思想,假设序列为非递减序列,将关键字和中间的元素比较,如果等于中间元素,则返回中间元素的下标,如果比中间元素小,那么查找的关键字必定在左边区域,如果比中间元素大,那么查找的关键字比定在右边区域,此为一趟查找,然后再进行递归,直到找到关键字或者查找的区域为空,则此序列中没有该关键字。

function binarySearch(arr, key, left, right) {
  if (left > right) { return -1}
  let mid = Math.floor((left+right)/2)
  if (key === arr[mid]) { return mid }
  if (key < arr[mid]) { return binarySearch(arr, key, left, mid-1) }
  if (key > arr[mid]) { return binarySearch(arr, key, mid+1, right) }
}

// 二分查找  非递归
function binarySearch(arr, key) {
  if (!Array.isArray(arr) || arr.length <= 0) {
    throw Error('非法输入')
  }
  let index = -1, left = 0, right = arr.length-1
  while (left <= right) {
    let mid = Math.floor((left+right)/2)
    if (key === arr[mid]) { return mid }
    (key < arr[mid]) ? (right = mid-1) : (left = mid+1);
  }
  return index
}

七大排序算法

插入排序

基本思想:在一个长度为i-1的已经排好序的序列中插入一个元素,从而得到一个长度为i的有序序列。
思路:从已排好序的序列的最后一个元素往前搜索,找到第一个比插入元素小的元素,插入到它后面
当待排序的序列已经有序时,时间复杂度最小,为O(n-1);当待排序的序列为逆序时,时间复杂度最大。平均时间复杂度为为O(n^2)
当待排序序列的长度比较小时,插入排序是一种很好的选择。js数组原生的sort方法,在数组长度比较小时用的就是插入排序。

function insertSort(arr) {
    if (!Array.isArray(arr) || arr.length <= 1) {
        return arr
    }
    let i, j
    for (i = 1; i < arr.length; i++) {
        let key = arr[i]
        for (j = i-1; j >= 0; j--) {
            if (key > arr[j]) {
                arr[j+1] = key
                break;
            }else {
                arr[j+1] = arr[j]
            }
        }
        j < 0 && (arr[0] = key)
    }
    return arr
}

希尔排序

希尔排序属于插入排序的一种,利用插入排序的特性:当待排序的序列基本有序时,插入排序的效率会大大提升。先将待排序的序列按照固定增量分割为若干个子序列,分别进行插入排序,使得各个子序列有序,再缩小增量,使得整个序列基本有序,最后一趟增量缩小为1,对整个序列进行插入排序。
希尔排序需要一个合适的增量序列,应使增量序列中的任意两个值没有有大于1的公因子,否则就重复了。目前还没有公认的最佳的增量的序列。

function shellSort(arr, stepArr = [5, 3, 1]) {
  if (!Array.isArray(arr) || !Array.isArray(stepArr) || arr.length <= 1) {
    return arr
  }
  stepArr.forEach(step => shellInsert(arr, step))
  return arr
}

function shellInsert(arr, step) {
  if (!Array.isArray(arr) || arr.length <= 1) {
    return arr
  }
  let i, j, k
  for (k = 0; k < step; k++) {
    for (i = step+k; i < arr.length; i += step) {
      let key = arr[i]
      for (j = i-step; j >= 0; j -= step) {
        if (key > arr[j]) {
          arr[j+step] = key
          break
        }else {
          arr[j+step] = arr[j]
        }
      }
      j < 0 && (arr[k] = key)
    }
  }
}

冒泡排序

冒泡排序是最简单的排序算法之一。每一趟循环将待排序中最大的元素放到序列末尾,每一趟能将一个元素排好序,经过n-1趟后整个数组便排好序了。时间复杂度为O(n^2)

function bubbleSort(arr) {
  if ( (!Array.isArray(arr) || arr.length <= 1)) {
    return arr
  }
  let i, j, len = arr.length
  for (i = 0; i < len-1; i++) {
    for (j = 0; j < len-1; j++) {
      if (arr[j] > arr[[j+1]]) {
        [arr[j], arr[j+1]] = [arr[j+1], arr[j]]
      }
    }
  }
  return arr
}

快速排序

快速排序是实际工作中最常用的排序算法,是最好的排序算法之一,在时间复杂度为O(nlogn)的排序算法中,平均性能最好。
基本思想:从待排序序列中选取一个关键字,将其他元素和关键字进行比较,比关键字小的元素放在它前面,比关键字大的元素放在它后面,这样通过这个关键字将数组划分成了两部分,关键字前面的元素都比后面的元素小,这是一趟快排。再利用递归的思想,对关键字前面的元素和关键字后面的元素分别进行快排,递归的终止条件是关键字分割的前面和后面两部分只剩下一个元素,或者没有元素,此时局部便排好序了。
快排的时间复杂度和关键字的选取有关,当每次选取的关键字为数组中最大或最小的元素时,快排退化为冒牌排序,时间复杂度为O(n^2);当每次选取的关键字能将前后划分成长度相近的两部分,快排的性能达到最优,时间复杂度为O(nlogn)。平均时间复杂度为O(nlogn)

function quickSort(arr) {
  if (!Array.isArray(arr) || arr.length <= 1) {
    return arr
  }
  
  let index, key, left = [], right = []
  // 选取关键字,并在数组中剔除关键字
  index = Math.floor((arr.length)/2)
  key = arr[index]
  arr.splice(index, 1)

  arr.forEach(el => { (el < key) ? left.push(el) : right.push(el) });

  return quickSort(left).concat(key, quickSort(right))
}

选择排序

选择排序也是最简单的排序算法之一。其思路是每一趟遍历找出未排序序列中最小或最大的元素,将其和第一个元素和最后一个元素交换位置。时间复杂度为O(n^2)

function selectSort(arr) {
  if (!Array.isArray(arr) || arr.length <= 1) {
    return arr
  }
  let i, j, min, minIndex, len = arr.length
  for (i = 0; i < len; i++) {
    min = arr[i]
    minIndex = i
    for (j = i+1; j < len; j++) {
      if (arr[j] < min) {
        min = arr[j]
        minIndex = j
      }
    }
    (minIndex !== i) && ([arr[i], arr[minIndex]] = [min, arr[i]])
  }
  return arr
}

堆排序

基本思想:堆排序是选择排序的一种。堆的定义:在一颗完全二叉树中,非终端节点的值全都大于(或全都小于)其左右子节点的值,对应的堆称为大顶堆或小顶堆,堆顶的元素一定是最大的或最小的。对一组无序的序列进行堆排序,就是把这组序列对应的完全二叉树建成大顶堆或小顶堆,再不断取出堆顶元素。
建堆:从最后一个非终端节点开始,往前一直到第一个节点,调整节点和子节点的位置。如果要建大顶堆,则将当前根节点和子节点中最大的元素调整为根节点,使得左右子节点的值都小于根节点。 思路:先建成大顶堆,再每次将堆顶元素和无序序列中最后一个元素交换位置,并重新调整堆,每次放到最后的都是无序序列中最大的元素。建成小顶堆直接遍历的话,小顶堆只能保证非终端节点的值小于左右节点的值,但不能保证整个堆是有序的,所以建成小顶堆只能依次取出堆顶元素,需要额外O(n)的存储空间。

function heapSort(arr) {
  if (!Array.isArray(arr) || arr.length <= 1) {
    return arr
  }
  let i, j, heapSize = arr.length
  // 建大顶堆
  for (i = Math.floor(heapSize/2)-1; i >= 0; i--) {
    heapAdjust(arr, i, heapSize)
  }
  for (i = heapSize-1; i >= 1; i--) {
    [arr[0], arr[i]] = [arr[i], arr[0]]
    heapAdjust(arr, 0, --heapSize)
  }
  return arr
}

function heapAdjust(arr, i, size) {
  if (!Array.isArray(arr) || typeof i !== 'number' || typeof size !== 'number') {
    throw TypeError('Illegal Input')
  }
  let left = i*2 + 1, right = i*2 + 2, max = i
  if (left < size && arr[left] > arr[max]) {
    max = left
  }
  if (right < size && arr[right] > arr[max]) {
    max = right
  }
  if (max !== i) {
    [arr[i], arr[max]] = [arr[max], arr[i]]
    max <= Math.floor(size/2)-1 && heapAdjust(arr, max, size)
  }
}

归并排序

归并排序是一类不同的排序方法
基本思想:将两个已经有序的子序列合并为一个有序的序列的时间复杂度为O(m+n),此为二路归并,相应的还可以有三路归并等。
思路:自顶向下的设计,将待排序序列分割成两个子序列,再对这两个子序列分别进行归并排序,最后,将这两个子序列合并为一个,需要用到递归,递归的终止条件是分割的子序列的长度为1,那么长度为1的序列必定是有序的。
归并排序是稳定的排序算法,可以保证大小相等的元素排序前后的相对位置不变。归并排序需要额外O(n)的存储空间,用于将两个有序子序列合并为一个序列,其时间复杂度为O(nlogn)

function mergeSort(arr) {
  if (!Array.isArray(arr) || arr.length <= 1) {
    return arr
  }
  MSort(arr, 0, arr.length-1)
  return arr
}

function MSort(arr, left, right) {
  if (left === right) {
    return
  }
  let mid = Math.floor((left+right)/2)
  MSort(arr, left, mid)
  MSort(arr, mid+1, right)
  merge(arr, left, mid, right)
}

function merge(arr, left, mid, right) {
  let i, j, _arr = []
  for (i = left, j = mid+1; i <= mid && j <= right; ) {
    if (arr[i] <= arr[j]) {
      _arr.push(arr[i++]);
    }else {
      _arr.push(arr[j++]);
    }
  }
  while (i <= mid) { _arr.push(arr[i++]) }
  while (j <= right) { _arr.push(arr[j++]) }
  _arr.forEach((el, index) => arr[left+index] = el)
}

基数排序

最后说一下基数排序,在实际问题当中,对于两个数据的比较并非只是简单地比较大小,可能需要根据多个关键字进行比较,比如对于扑克牌地比较,就可以有两个关键字:面值和花色,可能需要先比较面值,面值相同的情况下再比较花色,可以把面值看做高位关键字,花色看作低位关键字。对于这种多关键字排序有两种做法:最高位优先和最低位优化。最高位优先需要将序列逐层分割为若干子序列,再对若干子序列分别进行排序;最低位优先不必分割子序列,对每个关键字都是整个序列参与排序,因为高位关键字在低位关键字之后排序。多关键字排序对于具体的排序算法并不要求。
基数排序是按照低位先排序,然后收集;再按照高位排序,然后收集;以此类推,直到最高位。
此外还有计数排序和桶排序,计数排序需要与关键字大小相关的额外存储空间,这几种排序算法很少被使用。