常见排序算法极速通关

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情

引言

文中列举了常见的排序算法的实现思路以及具体代码,可以在leetcode题目 912. 排序数组 中运行,方便查看效果。

选择排序

首先需要明确一个问题: 选择排序选择的是什么?

对于升序排序来说,选择排序是在无序的那一部分中选出最大的那个数的索引,将这个数与无序部分的第一个数进行交换,成为有序的一部分,然后不断地重复这一过程,直到数组成员全部有序。

选择排序.gif

var sortArray = function(nums) {
    for(let i=0; i<nums.length; i++) {
        for(let j=i; j<nums.length; j++) {
            if(nums[i]>nums[j]) {
                [nums[i], nums[j]] = [nums[j], nums[i]]
            }
        }
    }
    return nums
};
复制代码

选择排序.gif

插入排序

玩斗地主之类的牌类游戏时就会用到插入排序,每个人首先要一张接一张地拿到自己的牌,并插入到手中已有的牌里,这不就是从无序中选择一个数,插入到有序的数组中吗?

插入排序的插入是指从未排序的序列中选择一个(一般是第一个)插入到前方已经排好的序列中

function insertionSort(arr) {
  for(let i=0; i<arr.length; i++) {
    // j-1>=0, 保证前面至少有一个元素,如果前面没有元素,没有必要进行比较了
    for(let j=i; j-1>=0; j--) {
      if(arr[j] < arr[j-1]) {
        [arr[j], arr[j-1]] = [arr[j-1], arr[j]]
      } else break
    }
  }
  return arr
}
​
// 优化
function insertionSort(arr) {
  for(let i=0; i<arr.length; i++) {
    // j-1>=0, 保证前面至少有一个元素,如果前面没有元素,没有必要进行比较了
    let j
    // 暂存准备插入的元素,能够减少赋值的操作,但总体复杂度不变
    let temp = arr[i]
    for(j=i; j-1>=0 && temp<arr[j-1]; j--) {
      arr[j] = arr[j-1]
    }
    arr[j] = temp
  }
  return arr
}

const arr = [6, 4, 2, 3, 1, 5]
console.log(insertionSort(arr))
复制代码

插入排序.gif

插入排序的重要特性

对于选择排序来说, 它的时间时间复杂度稳定在O(n^2),其内部 循环每次都是从头循环到尾,即使内部循环的的第一个数就是最小的那个数,而对于插入排序来说,其内部排序是可以中途退出的,即内部循环找到了待插入值的位置后就结束了,那么对于一个有序数组来说,插入排序的时间复杂度可以到O(n),但其总体复杂度还是不变的,如果一个数组总体有序的话可以考虑插入排序。

归并排序

自顶向下的归并排序

运用递归的思想,先分成左、右部分,对左右部分递归地进行merge排序,这样左右部分是有序的,然后再将有序的两部分进行合并。

归并排序.gif

var sortArray = function (arr) {
   //[l, r]前闭后闭
  const merge = (arr, l, mid, r) => {
    const temp = arr.slice(l, r + 1)
    let i = l,
      j = mid + 1
    // 思考为什么要减去l?temp与原数组是存在偏差的
    // 截取的数组第一个位置为0,但在原数组中位置是l
    // 因此偏差为l,每次要减去l 
    for (let k = l; k <= r; k++) {
      // 左半部分越界
      if (i > mid) {
        arr[k] = temp[j - l]
        j++
        // 右半部分越界
      } else if (j > r) {
        arr[k] = temp[i - l]
        i++
      } else if (temp[i - l] < temp[j - l]) {
        arr[k] = temp[i - l]
        i++
      } else {
        arr[k] = temp[j - l]
        j++
      }
    }
  }
  const sort = (arr, l, r) => {
    if(l >= r) return
    let mid = Math.floor((l+r)/2)
    sort(arr,l,mid)
    sort(arr,mid+1, r)
    merge(arr, l, mid, r)
  }
  sort(arr, 0, arr.length-1)
}

const arr = [1,3,53,9,8,5,4]
mergeSort(arr)
console.log(arr)

复制代码

优化一: 如果左右两部分已经有序,即arr[mid]<=arr[mid+1],那么这两段就没有必要进行归并

  const sort = (arr, l, r) => {
    if(l >= r) return
    let mid = Math.floor((l+r)/2)
    sort(arr,l,mid)
    sort(arr,mid+1, r)
    // 两段已经有序,如果arr[mid]<=arr[mid+1],那么这两段就没有必要进行归并
    if(arr[mid]>arr[mid+1]) {
      merge(arr, l, mid, r)
    }
  }
复制代码

优化二:还是对sort进行优化,在数据量小的时候插入排序的性能优于归并排序

function insertionSort(arr, l, r) {
  for(let i=l; i<=r; i++) {
    let j
    let temp = arr[i]
    for(j=i; j-1>=l && temp<arr[j-1]; j--) {
      arr[j] = arr[j-1]
    }
    arr[j] = temp
  }
  return arr
}
​
const mergeSort = (arr) => {
  const merge = (arr, l, mid, r) => {
    const temp = arr.slice(l, r + 1)
    let i = l,
      j = mid + 1
    //思考为什么要减去l?temp与原数组是存在偏差的
    // 截取的数组第一个位置为0,但在原数组中位置是l
    // 因此偏差为l,每次要减去l
    for (let k = l; k <= r; k++) {
      // 左半部分越界
      if (i > mid) {
        arr[k] = temp[j - l]
        j++
        // 右半部分越界
      } else if (j > r) {
        arr[k] = temp[i - l]
        i++
      } else if (temp[i - l] < temp[j - l]) {
        arr[k] = temp[i - l]
        i++
      } else {
        arr[k] = temp[j - l]
        j++
      }
    }
  }
  
  const sort = (arr, l, r) => {
    // 优化前返回条件
    // if(l >= r) return
    // 优化后返回条件, 当数据量较小时,插入排序的性能优于归并排序
    if(r-l<=15) {
      insertionSort(arr, l, r)
      // 注意此处,已经排序完毕,立即返回
      return
    }
    let mid = Math.floor((l+r)/2)
    sort(arr,l,mid)
    sort(arr,mid+1, r)
    // 两端已经有序,如果arr[mid]<=arr[mid+1],那么这两段就没有必要进行归并
    if(arr[mid]>arr[mid+1]) {
      merge(arr, l, mid, r)
    }
  }
  sort(arr, 0, arr.length-1)
}
​
const arr = [1,3,53,9,8,5,4]
mergeSort(arr)
console.log(arr)
​
复制代码

自底向上的归并排序

// 自底向上的归并排序
const mergeSort = (arr) => {
  const merge = (arr, l, mid, r) => {
    const temp = arr.slice(l, r + 1)
    let i = l,
      j = mid + 1
    //思考为什么要减去l?temp与原数组是存在偏差的
    // 截取的数组第一个位置为0,但在原数组中位置是l
    // 因此偏差为l,每次要减去l
    for (let k = l; k <= r; k++) {
      // 左半部分越界
      if (i > mid) {
        arr[k] = temp[j - l]
        j++
        // 右半部分越界
      } else if (j > r) {
        arr[k] = temp[i - l]
        i++
      } else if (temp[i - l] < temp[j - l]) {
        arr[k] = temp[i - l]
        i++
      } else {
        arr[k] = temp[j - l]
        j++
      }
    }
  }
  const sort = (arr) => {
    for (let sz = 1; sz < arr.length; sz += sz) {
      for (let i = 0; i + sz < arr.length; i += sz + sz) {
        // 合并两个区间,这个时候mid=i+sz-1, i+sz<n,说明第二个区间存在
        // 但是极端情况下第二个区间可能只有arr[i+sz]这一个数字
        // 为防止越界,r=Math.min(i+sz+sz-1, arr.lenth-1)
        merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, arr.length - 1))
      }
    }
  }
  sort(arr)
}
const arr = [1, 3, 53, 9, 8, 5, 4]
mergeSort(arr)
console.log(arr)
​
复制代码

快速排序

挑选一个元素作基准(暂时默认选取第一个元素作为基准),将小于基准的元素放在基准之前,大于基准的元素放在基准之后,再分别对小数区与大数区进行排序。

上述操作基于partion函数实现,该函数是快速排序的关键。

第一版

黄色矩形代表的是基准元素 快速排序.gif

const quickSort = (arr, l, r) => {
  // partition将数组分成两部分
  // l, r分别代表数组的左右边界
  // 默认将arr[l]作为标志位
  // [l+1, j] 部分小于arr[l]
  // [j+1,i-1]部分大于arr[l] (闭区间)
  const partition = (arr, l, r) => {
    let j=l
    for(i=l+1; i<=r; i++) {
      if(arr[i]<arr[l]) {
        j++
        [arr[i], arr[j]] = [arr[j], arr[i]]
      }
    }
    [arr[l], arr[j]] = [arr[j], arr[l]]
    return j
  }
  if(l>=r) return
  let mid = partition(arr, l, r)
  quickSort(arr,l,mid-1)
  quickSort(arr,mid+1,r)
}
​
const arr = [1, 3, 53, 9, 8, 5, 4]
quickSort(arr, 0, arr.length-1)
console.log(arr)
复制代码

第一版快速排序的主要问题在于partition的实现,因为我们选取的基准是第一个元素,如果该数组本身就是有序数组的话可能会导致栈溢出。

第二版 增加随机性

随机快速排序.gif 增加基准元素选取的随机性后,从动态图中可以看到,黄色矩形并是第一个,而是随机选取的一个元素,然后和第一个元素进行交换。

let p = l + Math.floor(Math.random()*(r-l));
[arr[p], arr[l]] = [arr[l], arr[p]]
复制代码

交换后的实现代码和第一版基本一样。

const quickSort = (arr, l, r) => {
  const partition = (arr, l, r) => {
    let p = l + Math.floor(Math.random()*(r-l));  //灵魂分号!!! FBI warning!!!
    [arr[p], arr[l]] = [arr[l], arr[p]]
    let j = l
    for(i=l+1; i<=r; i++) {
      if(arr[i]<arr[l]) {
        j++
        [arr[i], arr[j]] = [arr[j], arr[i]]
      }
    }
    [arr[l], arr[j]] = [arr[j], arr[l]]
    return j
  }
  if(l>=r) return // 递归推出条件
  let mid = partition(arr, l, r)
  quickSort(arr,l,mid-1)
  quickSort(arr,mid+1,r)
}
复制代码

冒泡排序

对于升序而言,从结果上来看,无序部分中较大元素依次冒泡出来

  • 第i轮开始,arr[n-i, n]已排好序
  • 第i轮:通过冒泡在arr[n-i, n]位置放上合适的元素
  • 第i轮结束: arr[n-i- 1, n]已排好序

但冒泡排序并不是仅仅简单的将最大(小)的数字移到最后,而是在这过程中不断将相邻的逆序对减少,每冒泡一次,逆序对的数量就会减少,直到减为0,排序完成,也就是说每冒泡一次,数组整体的有序程度是逐渐上升的。

// 冒泡排序
var sortArray = function(nums) {
    for(let i=0; i<nums.length; i++) {
        for(let j=0; j<nums.length-i; j++) {
            if(nums[j]>nums[j+1]) {
                [nums[j], nums[j+1]] = [nums[j+1], nums[j]]
            }
        }
    }
    return nums
}
复制代码

希尔排序

简单理解:希尔排序就是插入排序pro

其基本思想在于:让数组越来越有序,与冒泡排序不同一次只处理一个逆序对,希尔排序一次同时处理多个逆序对,而不仅仅是相邻的逆序对。

实现思路:对元素间距为n/2的所有数组做插入排序,对元素间距为n/4的所有数组做插入排序 对元素间距为n/8的所有数组做插入排序,..., 直到对元素间距为1的所有数组做插入排序,排序完成。

一句话总结就是:

每一轮按照事先决定的间隔进行插入排序,间隔会依次缩小,最后一次一定要是1。

// 希尔排序
var sortArray = function(nums) {
  let space = Math.floor(nums.length/2)
  while(space >= 1) {
    // 对间隔为space的数组进行插入排序
      for(let start = 0; start < space; start++) {
        // 进行插入排序的逻辑
        for(let i=start+space; i<nums.length; i+=space) {
            let temp = nums[i]
            let j
            for(j=i; j-space>=0 && temp < nums[j-space]; j-=space) {
                nums[j] = nums[j-space]
            }
            nums[j] = temp
        }
      }
      space = Math.floor(space/2)
  }
  return nums
}
​
// 改进  四重循环变为三重循环
var sortArray = function(nums) {
    let space = Math.floor(nums.length/2)
    while(space >= 1) {
        //   现在第space个元素就是第一个子序列对应的第二个元素
        for(let i=space; i<nums.length; i++) {
            let temp = nums[i]
            let j
            for(j=i; j-space>=0 && temp < nums[j-space]; j-=space) {
                nums[j] = nums[j-space]
            }
            nums[j] = temp
        }
        space = Math.floor(space/2)
    }
    return nums
}
复制代码

数组排序时间空间复杂度速查

算法 时间复杂度 空间复杂度
最佳 平均 最差 最差
快速排序 O(nlog⁡(n)) O(nlog⁡(n)) O(n2) O(log⁡(n))
归并排序 O(nlog⁡(n)) O(nlog⁡(n)) O(nlog⁡(n)) O(n)
Timsort O(n) O(nlog⁡(n)) O(nlog⁡(n)) O(n)
堆排序 O(nlog⁡(n)) O(nlog⁡(n)) O(nlog⁡(n)) O(1)
冒泡排序 O(n) O(n2) O(n2) O(1)
插入排序 O(n) O(n2) O(n2) O(1)
选择排序 O(n2) O(n2) O(n2) O(1)
希尔排序 O(n) O((nlog⁡(n))2) O((nlog⁡(n))2) O(1)

猜你喜欢

转载自juejin.im/post/7109461706794860575