手写排序算法

225 阅读4分钟

注:摘自修言的掘金小册,完整版请购买小册查看

冒泡排序

冒泡排序的过程,就是从第一个元素开始,重复比较相邻的两个项,若第一项比第二项更大,则交换两者的位置;反之不动。

每一轮操作,都会将这一轮中最大的元素放置到数组的末尾。假如数组的长度是 n,那么当我们重复完 n 轮的时候,整个数组就有序了。

function betterBubbleSort(arr) {
    const len = arr.length  
    
    for(let i=0;i<len;i++) {
        // 区别在这里,我们加了一个标志位
        let flag = false
        for(let j=0;j<len-1-i;j++) {
            if(arr[j] > arr[j+1]) {
                [arr[j], arr[j+1]] = [arr[j+1], arr[j]]
                // 只要发生了一次交换,就修改标志位
                flag = true
            }
        }
        
        // 若一次交换也没发生,则说明数组有序,直接放过
        if(flag == false)  return arr;
    }
    return arr
}

冒泡排序的时间复杂度

我们分最好、最坏和平均来看:

  • 最好时间复杂度:它对应的是数组本身有序这种情况。在这种情况下,我们只需要作比较(n-1 次),而不需要做交换。时间复杂度为 O(n)
  • 最坏时间复杂度: 它对应的是数组完全逆序这种情况。在这种情况下,每一轮内层循环都要执行,重复的总次数是 n(n-1)/2 次,因此时间复杂度是 O(n^2)
  • 平均时间复杂度:这个东西比较难搞,它涉及到一些概率论的知识。实际面试的时候也不会有面试官摁着你让你算这个,这里记住平均时间复杂度是 O(n^2) 即可。

选择排序

选择排序的关键字是“最小值”:循环遍历数组,每次都找出当前范围内的最小值,把它放在当前范围的头部;然后缩小排序范围,继续重复以上操作,直至数组完全有序为止。

function selectSort(arr)  {
  // 缓存数组长度
  const len = arr.length 
  // 定义 minIndex,缓存当前区间最小值的索引,注意是索引
  let minIndex  
  // i 是当前排序区间的起点
  for(let i = 0; i < len - 1; i++) { 
    // 初始化 minIndex 为当前区间第一个元素
    minIndex = i  
    // i、j分别定义当前区间的上下界,i是左边界,j是右边界
    for(let j = i; j < len; j++) {  
      // 若 j 处的数据项比当前最小值还要小,则更新最小值索引为 j
      if(arr[j] < arr[minIndex]) {  
        minIndex = j
      }
    }
    // 如果 minIndex 对应元素不是目前的头部元素,则交换两者
    if(minIndex !== i) {
      [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]
    }
  }
  return arr
}

选择排序的时间复杂度

在时间复杂度这方面,选择排序没有那么多弯弯绕绕:最好情况也好,最坏情况也罢,两者之间的区别仅仅在于元素交换的次数不同,但都是要走内层循环作比较的。因此选择排序的三个时间复杂度都对应两层循环消耗的时间量级: O(n^2)。

插入排序

插入排序的核心思想是“找到元素在它前面那个序列中的正确位置”。

具体来说,插入排序所有的操作都基于一个这样的前提:当前元素前面的序列是有序的。基于这个前提,从后往前去寻找当前元素在前面那个序列里的正确位置。

function insertSort(arr) {
  // 缓存数组长度
  const len = arr.length
  // temp 用来保存当前需要插入的元素
  let temp  
  // i用于标识每次被插入的元素的索引
  for(let i = 1;i < len; i++) {
    // j用于帮助 temp 寻找自己应该有的定位
    let j = i
    temp = arr[i]  
    // 判断 j 前面一个元素是否比 temp 大
    while(j > 0 && arr[j-1] > temp) {
      // 如果是,则将 j 前面的一个元素后移一位,为 temp 让出位置
      arr[j] = arr[j-1]   
      j--
    }
    // 循环让位,最后得到的 j 就是 temp 的正确索引
    arr[j] = temp
  }
  return arr
}

插入排序的时间复杂度

  • 最好时间复杂度:它对应的数组本身就有序这种情况。此时内层循环只走一次,整体复杂度取决于外层循环,时间复杂度就是一层循环对应的 O(n)
  • 最坏时间复杂度:它对应的是数组完全逆序这种情况。此时内层循环每次都要移动有序序列里的所有元素,因此时间复杂度对应的就是两层循环的 O(n^2)
  • 平均时间复杂度:O(n^2)

归并排序

归并排序是对分治思想的典型应用,它按照如下的思路对分治思想“三步走”的框架进行了填充:

分解子问题:将需要被排序的数组从中间分割为两半,然后再将分割出来的每个子数组各分割为两半,重复以上操作,直到单个子数组只有一个元素为止。

求解每个子问题:从粒度最小的子数组开始,两两合并、确保每次合并出来的数组都是有序的。(这里的“子问题”指的就是对每个子数组进行排序)。

合并子问题的解,得出大问题的解:当数组被合并至原有的规模时,就得到了一个完全排序的数组

function mergeSort(arr) {
    const len = arr.length
    // 处理边界情况
    if(len <= 1) {
        return arr
    }   
    // 计算分割点
    const mid = Math.floor(len / 2)    
    // 递归分割左子数组,然后合并为有序数组
    const leftArr = mergeSort(arr.slice(0, mid)) 
    // 递归分割右子数组,然后合并为有序数组
    const rightArr = mergeSort(arr.slice(mid,len))  
    // 合并左右两个有序数组
    arr = mergeArr(leftArr, rightArr)  
    // 返回合并后的结果
    return arr
}
  
function mergeArr(arr1, arr2) {  
    // 初始化两个指针,分别指向 arr1 和 arr2
    let i = 0, j = 0   
    // 初始化结果数组
    const res = []    
    // 缓存arr1的长度
    const len1 = arr1.length  
    // 缓存arr2的长度
    const len2 = arr2.length  
    // 合并两个子数组
    while(i < len1 && j < len2) {
        if(arr1[i] < arr2[j]) {
            res.push(arr1[i])
            i++
        } else {
            res.push(arr2[j])
            j++
        }
    }
    // 若其中一个子数组首先被合并完全,则直接拼接另一个子数组的剩余部分
    if(i<len1) {
        return res.concat(arr1.slice(i))
    } else {
        return res.concat(arr2.slice(j))
    }
}

归并排序的时间复杂度分析

O(nlog(n))

快速排序

快速排序在基本思想上和归并排序是一致的,仍然坚持“分而治之”的原则不动摇。区别在于,快速排序并不会把真的数组分割开来再合并到一个新数组中去,而是直接在原有的数组内部进行排序。

快速排序会将原始的数组筛选成较小和较大的两个子数组,然后递归地排序两个子数组。

// 快速排序入口
function quickSort(arr, left = 0, right = arr.length - 1) {
  // 定义递归边界,若数组只有一个元素,则没有排序必要
  if(arr.length > 1) {
      // lineIndex表示下一次划分左右子数组的索引位
      const lineIndex = partition(arr, left, right)
      // 如果左边子数组的长度不小于1,则递归快排这个子数组
      if(left < lineIndex-1) {
        // 左子数组以 lineIndex-1 为右边界
        quickSort(arr, left, lineIndex-1)
      }
      // 如果右边子数组的长度不小于1,则递归快排这个子数组
      if(lineIndex<right) {
        // 右子数组以 lineIndex 为左边界
        quickSort(arr, lineIndex, right)
      }
  }
  return arr
}
// 以基准值为轴心,划分左右子数组的过程
function partition(arr, left, right) {
  // 基准值默认取中间位置的元素
  let pivotValue = arr[Math.floor(left + (right-left)/2)]
  // 初始化左右指针
  let i = left
  let j = right
  // 当左右指针不越界时,循环执行以下逻辑
  while(i<=j) {
      // 左指针所指元素若小于基准值,则右移左指针
      while(arr[i] < pivotValue) {
          i++
      }
      // 右指针所指元素大于基准值,则左移右指针
      while(arr[j] > pivotValue) {
          j--
      }

      // 若i<=j,则意味着基准值左边存在较大元素或右边存在较小元素,交换两个元素确保左右两侧有序
      if(i<=j) {
          swap(arr, i, j)
          i++
          j--
      }

  }
  // 返回左指针索引作为下一次划分左右子数组的依据
  return i
}

// 快速排序中使用 swap 的地方比较多,我们提取成一个独立的函数
function swap(arr, i, j) {
  [arr[i], arr[j]] = [arr[j], arr[i]]
}

快速排序的时间复杂度分析

快速排序的时间复杂度的好坏,是由基准值来决定的。

  • 最好时间复杂度:它对应的是这种情况——我们每次选择基准值,都刚好是当前子数组的中间数。这时,可以确保每一次分割都能将数组分为两半,进而只需要递归 log(n) 次。这时,快速排序的时间复杂度分析思路和归并排序相似,最后结果也是 O(nlog(n))
  • 最坏时间复杂度:每次划分取到的都是当前数组中的最大值/最小值。大家可以尝试把这种情况代入快排的思路中,你会发现此时快排已经退化为了冒泡排序,对应的时间复杂度是 O(n^2)
  • 平均时间复杂度: O(nlog(n))