前端学算法 —— 排序算法

230 阅读14分钟
       说到算法,我们大多数人刚开始接触的大多数都是排序算法了,因为排序算法是我们最常用到算法之一了,许许多多的场景都会要求排序。今天就来说说我所认识的几种算法。
  • 交换排序

    1. 冒泡排序
    2. 快速排序
  • 插入排序

    1. 直接插入排序
    2. 希尔排序(二分插入法)
  • 选择排序

    1. 直接选择排序
    2. 二元选择排序
  • 其他排序

    1. 堆排序
    2. 归并排序


    声明:下面用到的n都为数组长度

冒泡排序

原理:对于数组array,设 i 为数组array的第一个元素的下标;对数组array内的元素从前到后两两相邻的元素进行对比(array[i]和array[i+1]对比,array[i+1]和array[i+2]对比),在一轮比较中选出最大的或者最小的值往后沉,最后数组长度减一。此后重复这个比较过程,直到最后要比较的元素只剩下一个。得到有序数组

JS代码

function bubbleSort (array) {
    for (var i = 0;i < array.length - 1;i++) {
        for (var j = 0;j < array.length - i - 1; j++) {
            if (array[j] > array[j + 1]) {
                var temp = array[j + 1]
                array[j + 1] = array[j]
                array[j] = temp
            }
        }
    }
}

实现过程比较简单,这一过程总共需要进行n - 1次排序,但是实际上很多时候并不需要把n - 1次排序全部执行完就能得到我们想要的结果;所以在得到有序数组之后所进行的排序都是无用功,白白浪费了资源。

对于上面说的情况,我们可以设置一个标志表示该次排序是否有数据进行交换,当在某一次排序时没有进行数据交换,则说明排序完成,终止算法。这样的话我们的算法也只是在得到我们想要的结果后多做了一次无用功,而不是把n-1次排序都进行完毕

冒泡排序 - 算法改进1

JS代码

function bubbleSortChangeA (array) {
    var sign = true // 标志是否执行排序
    var length = array.length
    while (sign) {
        var isChange = false // 标志是否有数据交换
        for (var i = 0;i < length - 1;i++) {
            if (array[i] > array[i + 1]) {
                var temp = array[i + 1]
                array[i + 1] = array[i]
                array[i] = temp
                isChange = true  // 产生数据交换,设置为true
            }
        }
        length--
        sign = isChange ? true : false
    }
}

该实现方式不能确定算法总共执行了多少次,最多执行n - 1次,最少执行 1 次(当数组本来就是有序数组时 ::滑稽脸)

传统的冒泡排序算法和上面做了小改进之后,可以发现每次排序只能确定一个最大值或者最小值,这样平均排序次数还不是最理想。对此,我们可以借鉴二元选择排序,每次循环确定一个最大值和最小值,而后每次循环确定次小值和次大值,如此重复操作。并且设置标志表示是否进行了数据交换,没有数据交换时直接结束算法

冒泡排序 - 算法改进2

JS代码

function bubbleSortChangeB (array) {
    var sign = true // 标志是否执行排序
    var length = array.length
    var count = 0 // 初始化排序次数
    while (sign && count < Math.floor(length / 2)) {
        var isChange = false // 标志是否有数据交换
        for (var i = count;i < length - count - 1;i++) {
            // 正向确定最小值
            if (array[i] > array[i + 1]) {
                var temp = array[i + 1]
                array[i + 1] = array[i]
                array[i] = temp
                isChange = true
            }
        }
        if (isChange) {
            isChange = false // 重置数据交换标志
            for (var j = length - count - 2;j > count;j--) {
                // 反向确定最小值
                if (array[j] < array[j - 1]) {
                    var temp = array[j - 1]
                    array[j - 1] = array[j]
                    array[j] = temp
                    isChange = true
                }
            }
        }    
        count = isChange ? ++count : count // 猜猜这里的++count和count++有什么区别
        sign = isChange ? true : false
    }
}

该实现方式最多执行 n / 2次排序,最少同上吧(哈哈!!),大大减少了排序次数,上面说的正向反向确定值的问题

快速排序

快速排序和冒泡排序同为交换排序,它是由C. A. R. Hoare在1962年提出的对冒泡排序的一种改进

原理:从数组中选择一个基准元素,通常选取第一个或者是最后一个,先从末尾向前查找一个比基准元素小的元素,找到后跟基准元素交换;然后从前面向后查找一个比基准元素大的元素,找到后也跟基准元素交换;一次排序完成后,把数组分割成两部分,其中一部分都比基准元素小,在基准元素的前面;另一部分都比基准元素大,在基准元素后面;这一过程就是快速排序所做的。之后对两部分元素分别递归进行快速排序,直至数组元素是有序的

JS代码

function quickSort (array, left, right) { // left:一个元素下标;right:最后一个元素下标
    var pivotKey = array[left] // 选取第一个元素为基准元素
    var pivotKeyIndex = left // 基准元素下标
    var startIndex = left
    var endIndex = right
    while (startIndex < endIndex) {
        // 从右到左查找比基准元素小的元素并记下该元素的下标
        while (startIndex < endIndex && array[endIndex] >= pivotKey) {
            endIndex--
        }
        if (array[endIndex] < pivotKey) {        
            // 和基准元素互换
            array[pivotKeyIndex] = array[endIndex]
            array[endIndex] = pivotKey
            pivotKeyIndex = endIndex
            // 从左到右查找比基准元素大的元素并记下该元素的下标
            while (startIndex < endIndex && array[startIndex] <= pivotKey) {
                startIndex++
            }
            // 和基准元素交换
            if (array[startIndex] > pivotKey) {
                array[pivotKeyIndex] = array[startIndex]
                array[startIndex] = pivotKey
                pivotKeyIndex = startIndex
            }
        }
    }
    // 当基准元素两边元素个数大于1时递归执行函数
    if (pivotKeyIndex - left >= 2) {
        quickSort(array, left, pivotKeyIndex - 1)
    }
    if (right - pivotKeyIndex >= 2) {
        quickSort(array, pivotKeyIndex + 1, right)
    }
}

网上很多对快速排序的做法各有异同,以上代码严格按照教科书所写的实现,每找到一个符合条件的就跟基准元素交换;但是这样有点太繁琐了,本文贴出一个修改版本。

拓展:该做法大同小异,有一个地方不一样,那就是在找到比基准元素小或者大的元素时不立即交换,而是找到了比它小的元素后去找比它大的元素,然后大小的两个元素交换;当查找完成时即上面的startIndex = endIndex,把基准元素跟下标为startIndex的元素交换。然后对基准元素两端的元素递归执行排序,直到整个数组有序

JS代码

function quickSortChange(array, left, right) { // left:一个元素下标;right:最后一个元素下标
    var pivotKey = array[left] // 选取第一个元素为基准元素
    var pivotKeyIndex = left // 基准元素下标
    var startIndex = left
    var endIndex = right
    while (startIndex < endIndex) {
        // 从右到左查找比基准元素小的元素并记下该元素的下标
        while (startIndex < endIndex && array[endIndex] >= pivotKey) {
            endIndex--
        }
        if(array[endIndex] < pivotKey){
            // 从做到右查找比基准元素大的元素并记下该元素的下标
            while (startIndex < endIndex && array[startIndex] <= pivotKey) {
                startIndex++
            }
            if (array[startIndex] > pivotKey) {
                // 交换大小元素和基准元素
                var temp = array[endIndex]
                array[endIndex] = array[startIndex]
                array[startIndex] = pivotKey
                array[pivotKeyIndex] = temp
                pivotKeyIndex = startIndex
            } else {
                // 直接和基准元素交换
                array[pivotKeyIndex] = array[endIndex]
                array[endIndex] = pivotKey
                pivotKeyIndex = endIndex
            }
        }
    }
    if (pivotKeyIndex - left >= 2) {
        quickSortChange(array, left, pivotKeyIndex - 1)
    }
    if (right - pivotKeyIndex >= 2) {
        quickSortChange(array, pivotKeyIndex + 1, right)
    }
}

两个版本代码量都差不多,性能上来说没做过测试不敢下定论,但是快排本身速度就不错,这两个版本之间的差距相信也都查差不了多少

直接选择排序

在遇到要我们排序的问题时,我们最容易想到的应该就是直接选择排序了

原理:用数组的第一个元素跟后面的每一个元素对比,选出最大值或最小值,替换位置;然后再用下一个元素跟它后面的进行比较,选出次小值或次大值...以此类推,直到整个数组比较完毕成为一组有序序列

JS代码

function simpleSelectSort (array) {
    for (var i = 0; i < array.length - 1; i++) {
        for (var j = i + 1; j < array.length; j++) {
            if (array[i] > array[j]) {
                var temp = array[i]
                array[i] = array[j]
                array[j] = temp
            }
        }
    }
}

直接选择排序需执行n - 1次循环排序

二元选择排序

二元选择排序是直接选择排序的改进版,直接选择排序每次循环只选出一个最大值或最小值,而二元选择排序改进的地方在冒泡算法改进2中提到的思想是一样的

原理:在一次排序循环中同时确定最大值和最小值的下标,一趟循环结束后分别和首位和末尾元素交换,往中间靠拢,重复执行,最后得到有序数组

JS代码

function binarySelectSort (array) {
    var max = 0,min = 0,count = 0;
    while (count < Math.floor(array.length / 2)) {
        max = count, min = count;
        for (var i = count + 1; i < array.length - count; i++) {
            max = array[i] > array[max] ? i : max
            min = array[i] < array[min] ? i : min
        }
        var minTemp = array[count]
        array[count] = array[min]
        array[min] = minTemp
        max = max === count ? min : max
        var maxTemp = array[array.length - ++count]
        array[array.length - count] = array[max]
        array[max] = maxTemp
    }
}

经改进后,排序次数直接减半即 Math.floor(n / 2)次;那么我们能不能像冒泡算法中的那样设置标志表示有没有元素交换确定数组是否已经有序呢?我们看一组数据就知道了 => [1, 4, 2, 3, 5]

直接插入排序

原理:把数组的第一个元素看成是一个有序的子序列,然后从第二个元素开始,与这个有序的子序列的每一个元素进行比较,将它插入到比它大和比它小的元素之间;然后往后的每一个元素进行此操作,最后得到有序数组

JS代码

function simpleInsertSort (array) {
    for (var i = 1; i < array.length; i++) {
        var temp = array[i]
        var j = i
        while (j - 1 >= 0 && temp < array[j - 1]) {
            array[j] = array[j - 1]
            j--
        }
        array[j] = temp   
    }    
}

希尔排序

希尔排序(缩小增量排序)是直接插入排序的一种更高效改进版本

原理:先取一个小于n的整数d作为第一个增量,把相距为d的元素看做一组子数组,对这些子数组分别进行直接插入排序,然后改变增量d;直至d为1时,即对整个数组进行直接插入排序得到有序数组

例子:[4, 8, 9, 1, 3, 8, 1, 6]

d = n / 2时,分成这几个子数组[4, 3],[8, 8],[9, 1],[1, 6]

d = n / 4时,分成这几个子数组[4, 9, 3, 1],[8, 1, 8, 6]

逐渐减少d的值,对这些子数组进行直接插入排序

注意:分成这些子数组是我们为了容易理解自己构造出来的,实际上原来的数组的元素位置没有发生任何改变

JS代码

function shellInsertSort (array) {
    var d = Math.floor(array.length / 2) // 设置缩小增量
    while (d >= 1) {
        for (var i = 0; i < d; i++) { // 每次改变缩小增量,循环排序d次
            // 对按照缩小增量d划分的子数组进行直接插入排序
            for (var j = i + d; j < array.length; j += d) {
                var temp = array[j]
                var k = j
                while (k >= i && temp < array[k - d]) {
                    array[k] = array[k - d]
                    k -= d
                }
                array[k] = temp
            }
        }
        d = Math.floor(d / 2)
    }
}

堆排序

介绍堆排序之前,我们先要了解堆是个什么东西概念:堆是一颗顺序存储的完全二叉树。其中,每个结点的值都不大于其孩子结点的值,这样称为小跟堆;每个结点的值都不小于其孩子结点的值,这样的堆称为大根堆。

对于有n个元素的序列{r1, r2, ..., rn}堆满足以下条件:

(1)ri <= r(2i + 1) 且 ri <= r(2i + 2) (小根堆)

(2)ri >= r(2i + 1) 且 ri >= r(2i + 2) (大根堆)

示例分析:[3, 5, 8, 15, 31, 25]

上面的数组转化为堆结构:3为父结点,它的左孩子结点是8,右孩子结点15;8也为一个父结点,它的左孩子结点是31,右孩子结点是3

由此可以看出:设r[i]为数组中的一个元素,那么它的左孩子结点是r[2i + 1];右孩子结点是r[2i + 2];当i > 0时,它的父结点是r[Math.floor((i - 1) / 2)]

堆排序原理:把一堆数组按照堆的定义调整为新的数组(堆)(这个过程称为创建初始堆),交换r[0]和r[n](第一个元素和最后一个元素交换);然后将r[0, ..., n - 1]个元素重新调整为堆,交换r[0]和r[n - 1];重复进行该操作,直至r[0]和r[1]交换完成,数组便有序

操作步骤:

(1) 根据数组构造初始堆(构建一个完全二叉树,保证所有的父结点都比它的孩子结点数值大。方法:从最后一个元素开始逐个跟他们的父结点)

(2) 每次交换第一个和最后一个元素,此时最后一个元素为最大值,将要处理的元素个数减一

(3) 重复进行步骤(1)和(2),直至r[0]和r[1]交换位置

JS代码

function heapSort(array) {
    var length = array.length // 当前要处理的元素个数
    while (length > 1) {
        for (var i = length - 1; i >= 0; i--) {
            // 如存在右孩子结点并且比父结点大则交换
            if ((2 * i + 2) < length && array[i] < array[2 * i + 2]) {
                var rightChild = array[2 * i + 2]
                array[2 * i + 2] = array[i]
                array[i] = rightChild
            }
            // 如存在左孩子结点并且比父结点大则交换
            if ((2 * i + 1) < length && array[i] < array[2 * i + 1]) {
                var leftChild = array[2 * i + 1]
                array[2 * i + 1] = array[i]
                array[i] = leftChild
            }
        }
        // 第一个和最后一个元素交换
        var maxTemp = array[0]
        array[0] = array[length - 1]
        array[length - 1] = maxTemp
        length--
    }
}

堆排序算法看起来跟冒泡排序算法有点像,都是把最大值或最小值沉到后面或前面,重复这个操作;但是在性能上面来说堆排序要比冒泡排序好太多,利用堆这种数据结构的算法都不会差

归并排序

原理:归并排序是利用归并(将两个有序序列合并成为一个有序序列称为归并)思想实现的排序方法,采用经典的分治算法。将大问题拆分成n个小问题然后递归求解,然后再把小问题的答案合并在一起,从而得到大问题的答案

操作步骤:

  1. 将数组从中间分割开两个子数组,子数组也均分,直到每个子数组的长度为1,此时每个子数组都是有序的
  2. 将两两相邻的两个子数组合并称为一个有序的子数组,这些合并后的子数组也和相邻的子数组合并,直至最后合并后的数组长度和初始数组长度一样并且是有序的

JS代码

function mergeSort (array, left, right) { // left:一个元素下标;right:最后一个元素下标
    var mid = Math.floor((left + right) / 2)
    if (left < right) {
        mergeSort(array, left, mid)
        mergeSort(array, mid + 1, right)
        mergeArray(array, left, mid, right)
    }
}
// 合并两个有序数组为一个
function mergeArray (array, left, mid, right){
    var i = left
    var j = mid + 1
    var temp = []
    while (i <= mid && j <= right) {
        if (array[i] > array[j]) {
            temp.push(array[j])
            j++
        } else {
            temp.push(array[i])
            i++
        }
    }
    while (i <= mid) {
        temp.push(array[i])
        i++
    }
    while (j <= right) {
        temp.push(array[j])
        J++
    }
    for(var l = 0; l < temp.length; l++) {
        array[left + l] = temp[l]
    }
}

以上代码均已做简单测试能正确运行,如发现任何问题欢迎指出,或者对以上算法有更好的实现方法也欢迎跟大家一起分享。

       另外,算法的好坏由算法复杂度决定,算法复杂度体现在运行算法时计算机所需资源的多少上,而计算机资源最重要的是时间和空间资源,所以描述算法的复杂度分为时间复杂度和空间复杂度;顾名思义,时间复杂度描述的是运行算法需要多长时间,空间复杂度描述的是运行算法需要多少内存空间。