排序算法总结(js)

166 阅读7分钟

排序算法总结

一、前言

参考文献1

参考文献2

1. 什么是复杂度分析?

(1)数据结构和算法解决是 “如何让计算机更快时间、更省空间的解决问题”。

(2)因此需从执行时间和占用空间两个维度来评估数据结构和算法的性能。

(3)分别用时间复杂度和空间复杂度两个概念来描述性能问题,二者统称为复杂度。

(4)复杂度描述的是算法执行时间(或占用空间)数据规模的增长关系。

2. 如何进行复杂度分析

(1)大O表示法

算法的执行时间与每行代码的执行次数成正比,用 T(n) = O(f(n)) 表示,其中 T(n) 表示算法执行总时间,f(n) 表示每行代码执行总次数,而 n 往往表示数据的规模。这就是大 O 时间复杂度表示法。

(2)时间复杂度

  • 定义:算法的时间复杂度,也就是算法的时间量度。

大 O 时间复杂度表示法 实际上并不具体表示代码真正的执行时间,而是表示 代码执行时间随数据规模增长的变化趋势,所以也叫 渐进时间复杂度,简称 时间复杂度

  • 特点:由于 时间复杂度 描述的是算法执行时间与数据规模的 增长变化趋势,所以 常量、低阶、系数 实际上对这种增长趋势不产生决定性影响,所以在做时间复杂度分析时 忽略 这些项。

(3)时间复杂度分析

遵循以下规则:

  • 只关注循环执行次数最多的一段代码
  • 加法法则:多段代码取最大
  • 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
  • 多个规模求加法:比如方法有两个参数控制两个循环的次数,那么这时就取二者复杂度相加
  • 多个规模求乘法:同理嵌套乘法

(4)空间复杂度分析

空间复杂度表示 算法的存储空间与数据规模之间的增长关系

二、排序算法图片总结

排序算法

三、排序算法代码实现

1. 冒泡排序

两层for循环,相邻元素两两对比,借用temp进行元素交换,返回数组

function bubbleSort (arr) {
    var len = arr.length
    for (var i = 0; i < len; i++) {
        for (var j = 0; j < len; j++) {
            if (arr[j] > arr[j+1]) { // 相邻比较
                var temp = arr[j+1] 
                arr[j+1] = arr[j] // 两两交换
                arr[j] = temp;
            }
        }
    }
    return arr;
}

改进冒泡排序:

设置标志pos,记录每趟排序最后一次进行交换的位置,pos后都已排序就位,下一趟只需要扫描到pos位置即可。

function bubbleSort2 (arr) {
    var i = arr.length - 1
    while (i > 0) {
        var pos = 0
        for (var j = 0; j < i; j++) { // i记录的是遍历的终点
            if (arr[j] > arr[j+1]) {
                pos = j // 记录交换的位置
                var temp = arr[j+1]
                arr[j+1] = arr[j]
                arr[j] = temp
            }
        }
        i = pos // 改变i的值,改变遍历终点
    }
    return arr
}

算法分析:

(1)时间复杂度

  • 最佳:T(n) = O(n)
  • 最差:T(n) = O(n^2)
  • 平均:T(n) = O(n^2)

(2)冒泡排序只涉及相邻数据间的交换,只需要常量级的临时空间,属于原地排序,空间复杂度O(1)。

(3)冒泡排序是稳定的排序。当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序。

2. 选择排序

最稳定的排序

原理:先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。重复直到排序结束。

拿每一个数去比较,找最小的元素先进行排序

function selectionSort (arr) {
    var len = arr.length
    var minIndex, temp
    for (var i = 0; i < len - 1; i++) {
        minIndex = i
        for (var j = i + 1; j < len; j++) {
            if (arr[j] < arr[minIndex]) { // 寻找最小数
                minIndex = j // 将最小数的索引记录
            }
        }
        temp = arr[i]
        arr[i] = arr[minIndex] // 将最小数放到最前面
        arr[minIndex] = temp // 原位置与最小数下标位置的数交换
    }
    return arr
}

算法分析

(1)选择排序时间复杂度:最佳、最差、平均:T(n) = O(n)

(2)选择排序是原地排序,空间复杂度O(1)

(3)选择排序是一种不稳定排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。

3. 插入排序

原理:通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入

步骤

  • 从第一个元素开始,该元素可以认为已经被排序;
  • 取出下一个元素,在已经排序的元素序列中从后向前扫描;
  • 如果该元素(已排序)大于新元素,将该元素移到下一位置;
  • 重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置;
  • 将新元素插入到该位置后;
  • 重复步骤 2 ~ 5。
const insertSort = arr => {
    const len = arr.length
    if (len <= 1) return
    
    let preIndex, current
    for (let i = 1; i < len; i++) {
        preIndex = i - 1 // 待比较元素下标
        current = arr[i] // 当前元素
        // 当待比较元素比当前元素大
        while (preIndex >= 0 && arr[preIndex] > arr[current]) {
            arr[preIndex + 1] = arr[preIndex] // 将待比较元素往后移一位
            preIndex-- // 游标前移一位(在有序数组中移动),一前一后中间有一个空位
        }
        if (preIndex + 1 != i) { // 说明上面发生了移动
            arr[preIndex + 1] = current // 将当前元素current插入到中间的空位
        }
        
    }
    return arr
}

算法分析

(1)插入排序不需要额外的存储空间,是一个原地排序,空间复杂度为O(1)。

(2)插入排序是稳定的排序算法。对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变。

(3)插入排序时间复杂度:最佳O(n),最差O(n^2),平均O(n^2)

4. 归并排序

分治思想:把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

注:x >> 1 是位运算中的右移运算,表示右移一位,等同于 x 除以 2 再取整,即 x >> 1 === Math.floor(x / 2)

采用自上而下的递归方法:

const mergeSort = arr => {
    const len = arr.length
    if (len < 2) {
        return arr
    }
    let middle = Math.floor(len / 2),
        left = arr.slice(0, middle),
        right = arr.slice(middle) //拆分成两个子数组
    return merge(mergeSort(left), mergeSort(right))
}

const merge = (left, right) => {
    const result = []
    while (left.length && right.length) {
        // 注意判断条件是<=,如果只是<,那么排序不稳定
        if (left.length && right.length) {
            if (left[0] <= right[0]) {
                result.push(left.shift())
            } else {
                result.push(right.shift())
            }
        }
    }
    while (left.length) result.push(left.shift()) // 说明right数组都已经入栈,剩下left直接入栈
    while (right.length) result.push(right.shift())
    return result
}

算法分析

(1)归并排序不是原地排序,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。 临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。

(2)归并排序是稳定排序。merge 方法里面的 left[0] <= right[0] ,保证了值相同的元素,在合并前后的先后顺序不变。

(3)归并排序的时间复杂度:

假设数组长度为 n,那么拆分数组共需 logn 步,又每步都是一个普通的合并子数组的过程,时间复杂度为 O(n),故其综合时间复杂度为 O(n log n)

最佳 = 最差 = 平均:T(n) = O(n log n)

5. 快速排序

特点快,效率高。但是要另外声明两个数组,浪费内存

思想:

  • 先找到一个基准点(一般指数组的中部),然后数组被该基准点分为两部分,依次与该基准点数据比较,如果比它小,放左边;反之,放右边
  • 左右分别用一个空数组去存储比较后的数据。
  • 最后递归执行上述操作,直到数组长度 <= 1;
// 方法一
const quickSort1 = arr => {
    if (arr.length <= 1) {
        return arr
    }
    // 取基准点
    const midIndex = Math.floor(arr.length / 2)
    // 取基准点的值, splice(index, 1)返回的是含有被删除元素的数组
    const valArr = arr.splice(midIndex, 1)
    const midIndexVal = valArr[0]
    const left = [] // 存比基点小的数组
    const right = []
    // 遍历数组,进行判断分配
    for (let i = 0; i < arr.length; i++) {
        if (arr[i] < midIndexVal) {
            left.push(arr[i])
        } else {
            right.push(arr[i])
        }
    }
    // 递归执行,直到数组长度<=1
    return quickSort1(left).concat(midInterVal, quickSort1(right))
}
// 方法二,分区操作
const quickSort2 = (arr, left, right) => {
    let len = arr.length,
        partitionIndex
    left = typeof left != 'number' ? 0 : left
    right = typeof right != 'number' ? len -1 : right
    if (left < right) {
        partitionIndex = partition(arr, left, right)
        quickSort2(arr, left, partitionIndex - 1)
        quickSort2(arr, partitionIndex + 1, right)
    }
    return arr
}

const partition = (arr, left, right) => {
    let pivot = left, // 设定基准值
        index = pivot + 1
    for (let i = index; i <= right; i++) {
        if (arr[i] < arr[pivot]) {
            swap(arr, i, index)
            index ++;
        }
    }
    swap(arr, pivot, index - 1)
    return index - 1
}
const swap = (arr, i, j) => {
	let temp = arr[i];
	arr[i] = arr[j];
	arr[j] = temp;
};

算法分析

(1)快速排序是原地算法。

(2)快速排序算法不稳定。和选择排序相似,快速排序每次交换的元素都有可能不是相邻的,因此它有可能打破原来值为相同的元素之间的顺序。

(3)时间复杂度:

最佳情况:T(n) = O(n log n)。 最差情况:T(n) = O(n2)。 平均情况:T(n) = O(n log n)。

归并排序与快速排序比较

快速排序与归并排序

结论:

  • 归并排序的处理过程是由下而上的,先处理子问题,然后再合并。

  • 而快排正好相反,它的处理过程是由上而下的,先分区,然后再处理子问题。

  • 归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。

  • 归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。

  • 快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。

6. 希尔排序

思想:

  • 先将整个待排序的记录序列分割成为若干子序列。
  • 分别进行直接插入排序。
  • 待整个序列中的记录基本有序时,再对全体记录进行依次直接插入排序。

例子:[35, 33, 42, 10, 14, 19, 27, 44],我们采取间隔 4。创建一个位于 4 个位置间隔的所有值的虚拟子列表。下面这些值是 { 35, 14 },{ 33, 19 },{ 42, 27 } 和 { 10, 44 }。

我们比较每个子列表中的值,并在原始数组中交换它们。

然后,我们采用 2 的间隔,这个间隙产生两个子列表:{ 14, 27, 35, 42 }, { 19, 10, 33, 44 }。

最后,我们使用值间隔 1 对数组的其余部分进行排序。

const shellSort = arr => {
    let len = arr.length,
        temp,
        gap = 1
    while (gap < len / 3) {
        gap = gap * 3 + 1 // 动态定义间隔序列
    }
    for (gap; gap > 0; gap = Math.floor(gap / 3)) {
        for (let i = gap; i < len; i ++) {
            temp = arr[i]
            let j = i - gap
            for (; j >= 0 && arr[j] > temp; j -= gap) {
                arr[j + gap] = arr[j]
            }
            arr[j + gap] = temp
        }
    }
    return arr
}

算法分析

(1)希尔排序是原地排序,空间复杂度O(1)

(2)希尔排序不稳定。单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序,但在多次不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,可能导致相同元素相对顺序发生变化。

(3)时间复杂度:

最佳:T(n) = O(n log n)

最差:T(n) = O(n log2 n)

平均:T(n) = O(n log2 n)