基础算法学习笔记之排序算法

265 阅读17分钟

一、如何分析算法执行效率

对于排序算法执行效率的分析,一般会从这几个方面来衡量:

1.最好情况、最坏情况、平均情况时间 复杂度
  我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、、最坏时间复杂度对应的要排序的原始数据是什么样的
  为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好比对,所以我们最好都做一下区分,第二,对于要排序的数据,有的接近有序,有的完全无序,有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算的在不同数据下的性能表现
2.时间复杂度的系数、常数、低阶
  时间复杂度反应的是数据规模n很大的一个增长趋势,所有它表示的时候回忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是10个,100个,100个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来
3.比较次数和交换(或移动)次数
  基于比较的排序算法的执行过程,会设计两种操作,一种是元素比较大小,另一种是元素交换或移动,所以,如果在我们排序算法的执行效率的时候,应该把比较次数和交换次数也考虑进去
  排序算法的内存消耗
    算法的内存消耗可以空间复杂度来衡量,排序算法也不例外,针对排序算法的空间复杂度,引入一个新概念,原地排序。原地排序算法,就是指空间复杂度是O(1)的排序算法,我们今天讲的三种排序算法,都是原地排序算法,原地排序算法,就是特指空间复杂度是O(1)的排序算法
  排序算法的稳定性
    仅仅用执行效率和内存消耗来衡量算法的好坏是不够的,针对排序算法,还有一个重要的度量指标,稳定性,就是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间的 原有的先后顺序不变

二、常用排序算法

​ 排序算法有很多,最常用的有:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序。桶排序,

按照时间复杂度可以分为三类“:

​ 1 .冒泡排序、插入排序、选择排序 O(n^2)

​ 2.快速排序、归并排序 O(nlogn)

​ 3.计数排序、基数排序、桶排序 O(n)

算法名称           稳定算法                不稳定算法           原地算法
冒泡排序            是                                         是
插入排序            是                                         是
选择排序                                     是                是
归并排序            是
快速排序                                     是                是
桶排序            (在于桶内的排序方式是否稳定,不是原地算法)
计数排序
基数排序
一、冒泡排序

​ 冒泡排序只会操作相邻的两个数据,每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求,如果不满足就让他俩交换,一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作

对一组数据4,5,6,3,2,1,从小到到大进行排序。第一次冒泡操作的详细过程就是这样:

可以看出,经过一次冒泡操作之后,6这个元素已经存储在正确的位置上。要想完成所有数据的排序,我们只要进行6次这样的冒泡操作就行了。

实际上,刚讲的冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作

代码实现如下:

const bubbleSort = (arr) => {
    if (arr.length <= 1) return
    for (let i = 0; i < arr.length; i++) {
        let hasChange = false
        for (let j = 0; j < arr.length - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                const temp = arr[j]
                arr[j] = arr[j + 1]
                arr[j + 1] = temp
                hasChange = true
            }
        }
        // 如果false 说明所有元素已经到位
        if (!hasChange) break
    }
    console.log(arr)
}

​ 冒泡的过程只设计相邻数据的交换操作,只需要常亮级的临时空间,所以它的空间复杂度为O(1),是一个原地排序算法,在冒泡排序中,只有交换才可以改变两个元素的前后顺序,为了保证毛阿婆排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。最好的情况下,要排序的数据已经是 有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是O(n),而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行n次冒泡操作,所以最坏情况时间复杂度为O(n2)。

二、插入排序

​ 动态的往有序集合中添加数据,可以通过这种方法保持集合中的数据一直有序,而对于一组静态数据,我们也可以借鉴上面讲的插入方法,来进行排序,于是就有了插入排序算法。

​ 首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始化已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间的元素,在已牌组区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序,重复这个过程,直到未排序区间中元素为空,算法结束

插入排序也包含两种操作,一种是元素的比较,一种是元素的移动,,当我们需要将一个数据a 插入到已排序区间是,需要拿a与排序区间的元素依次比较大小,找到合适的插入位置,找打插入点之后,我们还需要将插入点的元素顺序往后 移动一位,这样才嫩腾出位置给元素插入。对于不同的查找插入方法,元素的比较次数是有区别的,但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。

插入排序的运行并不需要额外的存储空间,所以空间复杂度是O(1),也就是说,这是一个原地排序算法。在插入排序中,对于值相同的元素,我们可以选择后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。如果要排序的数据已经是有序的,我们并不需要搬移任何数据,如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置,所以在这种情况下,最好的是时间复杂度为O(n).注意,这里是从尾到头已经有序的数据、注意,这里是从尾到头遍历已经有序的数据。如果数组是倒序的,每次插入都相当与在数组的第一个位置插入新的数据,所以需要大量的数据,所以最坏的时间复杂度为O(n2)。所以对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行n次插入操作,所以平均时间复杂度为o(n2)

代码实现

const insertionSort = (arr) => {
    if (arr.length <= 1) return
    for (let i = 1; i < arr.length; i++) {
        const temp = arr[i]
        let j = i - 1
        // 若arr[i]前有大于arr[i]的值的化,向后移位,腾出空间,直到一个<=arr[i]的值
        for (j; j >= 0; j--) {
            if (arr[j] > temp) {
                arr[j + 1] = arr[j]
            } else {
                break
            }
        }
        arr[j + 1] = temp
    }
    console.log(arr)
}

三.选择排序

​ 选择排序的实现思路有点类似插入排序,也分已排序区间和未排序区间,但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾

const selectionSort = (arr) => {
    if (arr.length <= 1) return
    // 需要注意这里的边界, 因为需要在内层进行 i+1后的循环,所以外层需要 数组长度-1
    for (let i = 0; i < arr.length - 1; i++) {
        let minIndex = i
        for (let j = i + 1; j < arr.length; j++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j // 找到整个数组的最小值
            }
        }
        const temp = arr[i]
        arr[i] = arr[minIndex]
        arr[minIndex] = temp
    }
    console.log(arr)
}

选择排序是一种不稳定的排序算法,基于此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。实际开发中用的最多的是插入排序

四、归并排序

归并排序的核心思想是:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再讲排好序的两部分合并在一起,这样整个数组就都有序了

归并排序使用的就是分治思想,讲一个大问题分解成小的问题来解决,小的问题解决了,大问题也就解决了。分治算法一般都是用帝归来实现的,分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。

实现代码:

const mergeArr = (left, right) => {
    let temp = []
    let leftIndex = 0
    let rightIndex = 0
    // 判断2个数组中元素大小,依次插入数组
    while (left.length > leftIndex && right.length > rightIndex) {
        if (left[leftIndex] <= right[rightIndex]) {
            temp.push(left[leftIndex])
            leftIndex++
        } else {
            temp.push(right[rightIndex])
            rightIndex++
        }
    }
    // 合并 多余数组
    return temp.concat(left.slice(leftIndex)).concat(right.slice(rightIndex))
}

const mergeSort = (arr) => {
    // 当任意数组分解到只有一个时返回。
    if (arr.length <= 1) return arr
    const middle = Math.floor(arr.length / 2) // 找到中间值
    const left = arr.slice(0, middle) // 分割数组
    const right = arr.slice(middle)
    // 递归 分解 合并
    return mergeArr(mergeSort(left), mergeSort(right))
}

const testArr = [12,45,,57,678,34564,234]
const res = mergeSort(testArr)

归并排序是稳定的排序算法,归并排序涉及递归,时间复杂度为O(nlogn),但是归并排序不是原地排序算法

五、快速排序

​ 快排利用的也是分治思想,乍看起来,他有点像归并排序,但是思路其实完全不一样。快排的思想是这样的:如果要排序数组中下表从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。我们遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三个部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1之间是大于pivot的。根据分治、递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,直到区间缩小为1,就说明所有的数据都是有序的

代码实现

const swap = (arr, i, j) => {
    const temp = arr[i]
    arr[i] = arr[j]
    arr[j] = temp
}

// 获取 pivot 交换完后的index
const partition = (arr, pivot, left, right) => {
    const pivotVal = arr[pivot]
    let startIndex = left
    for (let i = left; i < right; i++) {
        if (arr[i] < pivotVal) {
            swap(arr, i, startIndex)
            startIndex++
        }
    }
    swap(arr, startIndex, pivot)
    return startIndex
}

const quickSort = (arr, left, right) => {
    if (left < right) {
        let pivot = right
        let partitionIndex = partition(arr, pivot, left, right)
        quickSort(arr, left, partitionIndex - 1 < left ? left : partitionIndex - 1)
        quickSort(arr, partitionIndex + 1 > right ? right : partitionIndex + 1, right)
    }

}

​ 这里的处理有点类似选择排序。通过游标i把A[p…r-1]分成两部分。A[p…i-1]的元素都是小于pivot的,暂且叫它“已处理区间”,A[i…r-1]是“未处理区间“。我们每次都从未处理的区间A[i…r-1]中取一个元素A[j],与pivot对比,如果小于pivot,则将其加入到已处理区间的尾部,也就是A[i]的位置。组的插入操作还记得吗?在数组某个位置插入元素,需要搬移数据,非常耗时。当时我们也讲了一种处理技巧,就是交换,在O(1)的时间复杂度内完成插入操作。这里我们也借助这个思想,只需要将A[i]与A[j]交换,就可以在O(1)时间复杂度内将A[j]放到下标为i的位置。

​ 快速排序是一种原地、不稳定的排序算法。快速排序的时间复杂度是O(nlognn)

归并排序和快速排序的区别:

​ 可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为O(nlogn)的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。

六.桶排序

​ 桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行 排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了

​ 如果要排序的数据有n个,我们把它们均匀地划分到m个桶内,每个桶里就有k=n/m个元素。每个桶内部使用快速排序,时间复杂度为O(k * logk)。m个桶排序的时间复杂度就是O(m * k * logk),因为k=n/m,所以整个桶排序的时间复杂度就是O(n*log(n/m))。当桶的个数m接近数据个数n时,log(n/m)就是一个非常小的常量,这个时候桶排序的时间复杂度接近O(n)。 桶排序对要排序数据的要求是非常苛刻的。首先,要排序的数据需要很容易就能划分成m个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为O(nlogn)的排序算法了。桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

代码实现

// 思路:
// 将数组中的数据,按桶进行划分,将相邻的数据划分在同一个桶中
// 每个桶用插入排序算法(或者快速排序)进行排序
// 最后整合每个桶中的数据

function bucketSort(array, bucketSize = 5) {
    if (array.length < 2) {
        return array
    }
    const buckets = createBuckets(array, bucketSize)
    return sortBuckets(buckets)
}

function createBuckets(array, bucketSize) {
    let minValue = array[0]
    let maxValue = array[0]
    // 遍历数组,找到数组最小值与数组最大值
    for (let i = 1; i < array.length; i++) {
        if (array[i] < minValue) {
            minValue = array[i]
        } else if (array[i] > maxValue) {
            maxValue = array[i]
        }
    }
    // 根据最小值、最大值、桶的大小,计算得到桶的个数
    const bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1
    // 建立一个二维数组,将桶放入buckets中
    const buckets = []
    for (let i = 0; i < bucketCount; i++) {
        buckets[i] = []
    }
    // 计算每一个值应该放在哪一个桶中
    for (let i = 0; i < array.length; i++) {
        const bucketIndex = Math.floor((array[i] - minValue) / bucketSize)
        buckets[bucketIndex].push(array[i])
    }
    return buckets
}

function sortBuckets(buckets) {
    const sortedArray = []
    for (let i = 0; i < buckets.length; i++) {
        if (buckets[i] != null) {
            insertionSort(buckets[i])
            sortedArray.push(...buckets[i])
        }
    }
    return sortedArray
}

// 插入排序
function insertionSort(array) {
    const { length } = array
    if (length <= 1) return

    for (let i = 1; i < length; i++) {
        let value = array[i]
        let j = i - 1

        while (j >= 0) {
            if (array[j] > value) {
                array[j + 1] = array[j] // 移动
                j--
            } else {
                break
            }
        }
        array[j + 1] = value // 插入数据
    }
}
七.计数排序

​ 计数排序其实是桶排序的一种特殊情况。当要排序的n个数据,所处的范围并不大的时候,比如最大值是k,我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

​ 我们都经历过高考,高考查分数系统你还记得吗?我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有50万考生,如何通过成绩快速排序得出名次呢?考生的满分是900分,最小是0分,这个数据的范围很小,所以我们可以分成901个桶,对应分数从0分到900分。根据考生的成绩,我们将这50万考生划分到这901个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了50万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是O(n)。计数排序的算法思想就是这么简单,跟桶排序非常类似,只是桶的大小粒度不一样。

​ 计数排序只能用在数据范围不大的场景中,如果数据范围k比要排序的数据n大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排 序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

代码实现

const countingSort = array => {
    if (array.length <= 1) return

    const max = findMaxValue(array)
    const counts = new Array(max + 1)

    // 计算每个元素的个数,放入到counts桶中
    // counts下标是元素,值是元素个数
    array.forEach(element => {
        if (!counts[element]) {
            counts[element] = 0
        }
        counts[element]++
    })

    // counts下标是元素,值是元素个数
    // 例如: array: [6, 4, 3, 1], counts: [empty, 1, empty, 1, 1, empty, 1]
    // i是元素, count是元素个数
    let sortedIndex = 0
    counts.forEach((count, i) => {
        while (count > 0) {
            array[sortedIndex] = i
            sortedIndex++
            count--
        }
    })
    // return array
}

function findMaxValue(array) {
    let max = array[0]
    for (let i = 1; i < array.length; i++) {
        if (array[i] > max) {
            max = array[i]
        }
    }
    return max
}

八.基数排序

​ 基数排序对要排序的数据是有要求的,需要可以分割出独立的**“来比较,而且位之间有递进的关系,如果a数据的高位比b数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到O(n)**了。

function radixSort(arr, maxDigit) {
    var mod = 10;
    var dev = 1;
    var counter = [];
    console.time('基数排序耗时');
    for (var i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
        for(var j = 0; j < arr.length; j++) {
            var bucket = parseInt((arr[j] % mod) / dev);
            if(counter[bucket]== null) {
                counter[bucket] = [];
            }
            counter[bucket].push(arr[j]);
        }
        var pos = 0;
        for(var j = 0; j < counter.length; j++) {
            var value = null;
            if(counter[j]!=null) {
                while ((value = counter[j].shift()) != null) {
                    arr[pos++] = value;
                }
            }
        }
    }
    return arr;
}
var arr = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.log(radixSort(arr,2)); //[2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50];