经典算法(冒泡排序丶选择排序丶插入排序)

276 阅读7分钟

可能大多数人都会认为,冒泡丶选择丶插入排序谁不会啊,但是我只想说这是基本功,虽然不难,但是我们也要去了解他们。接下来我就会用JavaScript来写出这三种排序算法,并且分析他们的特点。

1.冒泡排序

基础版本

冒泡排序的排序思想是:通过循环数组并对比相邻之间元素的大小,如果相邻元素的大小顺序不符合我们的预期的话,就交换顺序。通过这种方式,我们每遍历一遍数组,都可以将一个最大或者最小的元素放到未排序部分数组的末尾,这样的遍历我们要执行n-1次,直到数组有序为止。

function bubbling(targetArray){
    for(l = targetArray.length-1;l;l--){
        let n = l
        for(let i = 0;i<n;i++){
            if(targetArray[i]<targetArray[i+1]){
                let item = targetArray[i]
                targetArray[i] = targetArray[i+1]
                targetArray[i+1] = item
            }
        }
    }
    return targetArray
}

可以看到,我们每循环一次都会找出一个最大或者最小的元素,所以下次再循环的时候,尾部已经有序的部分就会被略过。

优化版本

可能有的人看了上面的代码就会发现,如果前面的数据可能已经是有序的了,但是我们仍然还在一个个地去遍历它,那我们可不可以提前终止这种无所谓的遍历呢,答案当然是可以的。

function bubbling(targetArray){ 
    for(l = targetArray.length-1;l;l--){
        let n = l
        let change = true
        for(let i = 0;i<n;i++){
            if(targetArray[i]<targetArray[i+1]){
                change = false
                let item = targetArray[i]
                targetArray[i] = targetArray[i+1]
                targetArray[i+1] = item
            }
        }
        if(change){
            break
        }
    }
    return targetArray
}

这次我们添加了一个变量change来判断这一次遍历是否有元素的交换,如果没有交换就说明前面的数据已经是有序的了,我们就提前结束循环。但是我想说的是,这样的看起来好像让算法更有效率的优化并没有对算法带来实质性的改变。因为我们虽然做了优化,但是那种特殊的情况出现的概率只是一小部分,我们却付出了其他大部分不出现那种情况下也要额外申请一个变量并且为这个变量赋值和判断。然而我们判断一个算法的性能并不是只看它在一种特定的情况下的性能,而是所有的情况。

符合使用习惯的版本

在这里给大家介绍一下平时在实际的应用中对各类算法的封装方式,可能对算法的效率会有所影响,但是这样可以使算法的实用性更广。比如上面这个算法,我们虽然可以排序一个简单的数组,但是对于对象数组就不行了(对象数组的排序在实际应用中还是很常见的),而且我们也无法通过不修改代码的方式去改变我们排序的方向,即由大到小或者由小到大。所以我们为了让算法的适用性更广,就有了以下的代码

function bubbling3(targetArray,sort,key){
    if(!key){
        for(l = targetArray.length-1;l;l--){
            let n = l
            for(let i = 0;i<n;i++){
                if(sort && targetArray[i]<targetArray[i+1]){
                    let item = targetArray[i]
                    targetArray[i] = targetArray[i+1]
                    targetArray[i+1] = item
                } else if (!sort && targetArray[i]>targetArray[i+1]){
                    let item = targetArray[i]
                    targetArray[i] = targetArray[i+1]
                    targetArray[i+1] = item
                }
            }
        }
    } else {
        for(l = targetArray.length-1;l;l--){
            let n = l
            for(let i = 0;i<n;i++){
                if(sort && targetArray[i][key]<targetArray[i+1][key]){
                    let item = targetArray[i]
                    targetArray[i] = targetArray[i+1]
                    targetArray[i+1] = item
                } else if (!sort && targetArray[i][key]>targetArray[i+1][key]){
                    let item = targetArray[i]
                    targetArray[i] = targetArray[i+1]
                    targetArray[i+1] = item
                }
            }
        }
    }
    return targetArray
}

我们通过传入一个sort变量来调整排序的顺序,通过传入key变量来适应普通数组和对象数组的变化,这样是不是就更加符合我们平时使用的习惯了呢?当然会损失一些性能,但是这样程度的性能损失是我们可以接受的。笔者个人建议大家在封装这些工具函数的时候尽量遵循函数式编程的习惯,这样会增加适用性。

算法分析

1.空间复杂度分析

冒泡排序只涉及相邻数据之间的交换操作,申请了常量级的额外空间,因此空间复杂度为O(1)是一个原地排序算法。

2.时间复杂度分析

最好情况我们需要交换0次数据,最差情况我们需要交换n*(n-1)/2次交换,取平均值的话我们需要交换n*(n-1)/4因此我们的时间复杂度为O(n)

3.是不是稳定排序算法

如果我们在判断相邻的元素相等的时候不交换位置,那么原有的顺序就不会被改变,所以该算法是一个稳定排序算法

2.选择排序

选择排序的排序思想给冒泡有相似之处,冒泡是通过比较并交换位置来找出一个最大或者最小值,而选择排序则虽然也比较但只记录最大值或最小值的位置,在遍历结束之后再进行位置的交换。

function selectionSort(targetArray){
    for(l = targetArray.length-1;l;l--){
        let n = l
        let max = targetArray[0]
        let index = 0
        let i = 1
        for(;i<n;i++){
            if(max<targetArray[i]){
                index = i
                max = targetArray[i]
            }
        }
        targetArray[index] = targetArray[n]
        targetArray[n] = max;
    }
    return targetArray
}

算法分析

1.空间复杂度分析

选择排序和冒泡排序一样,需要的额外内存空间也是常量级的,因此空间复杂度也是O(1)

2.时间复杂度分析

选择排序和冒泡排序需要比较的次数是一样的,因此时间复杂度也是一样的,为(n)

3.是不是稳定排序算法

不是稳定排序算法,每次我们在找到最大或者最小值之后和最后面的值进行交换时,会破坏原有的顺序。

插入排序

插入排序就和前面那两种排序有所不同了,插入排序的思想是:找到当前元素在已排序区间元素中的位置,并通过把后面的元素整体向前或者向后挪来为要插入的元素腾出位置,当所有的元素在已排序区间找到它的位置后整个数组就是有序的了。

前面说了,我们每次插入一个数据之前都要把整体往前或者往后挪,为了让算法的逻辑更清晰,我想我们需要一个专门用来挪移元素的方法

function move(array,start,end,sort){
    if(sort){           //true  从左往右
        for(let i = end;i>start-1;i--){
            array[i+1] = array[i]
        }
    } else {            //false 从右往左
        for(let i = start;i<end+1;i++){
            array[i-1] = array[i]
        }
    }
}

这个方法可以通过传入需要挪移部分的开头和结尾的数组下标和方向sort变量来控制挪移的长度和方向。接下来我们进行插入排序的主要逻辑

function insertSort (targetArray){
    let index = 0
    let l = targetArray.length - 1
    while(index!==l){
        let item = targetArray[l]
        for(let i = 0;i<index+2;i++){
            let left = targetArray[i-1]
            let right = targetArray[i]
            if(!left || !right){
                if(left){
                    index = l
                    break
                }
                if(right&&item<right){
                    index++
                    move(targetArray,i,l-1,true)
                    targetArray[i] = item
                    break
                }
            } else if (item>=left && item<=right){
                index++
                move(targetArray,i,l-1,true)
                targetArray[i] = item
                break
            }
        }
    }
    return targetArray
}

在每次寻找插入位置的时候,我们都要找到左右两边的节点,并和他们进行对比。在这里我们要特别注意的是,在开头的时候是没有左边的节点的,最开始的两个已排序区间的大小顺序,决定了最后数组的大小顺序,以及在最后的位置是没有右边的节点的,注意好了这两个边界,就没问题了。我们对比找到该插入的节点并挪移其他元素来为要插入的节点腾出位置,直到所有元素都插入到了已排序的区间。

算法分析

1.空间复杂度分析

很明显不需要额外的空间,因此空间复杂度为O(1)

2.时间复杂度分析

每次循环都相当于在插入一个数据,因此我们需要n-1次循环才可以把整个数组排序完毕,因此时间复杂度为O(n²)

3.是不是稳定排序算法

我们在插入的时候可以选择将后面出现的相同的元素插入到后面,这样的话就不会破坏原有的顺序了