几种基础的排序算法

261 阅读7分钟

基础算法

这里的几种基础算法纯属个人研究收获。主要包括以下几种:

  • 选择排序
  • 冒泡排序
  • 插入排序
  • 希尔排序
  • 归并排序
  • 快速排序

这几种算法的思想很简单,只要看代码实现。每个人实现的代码可能形式上千差万别,但是遵循的思想一定会是一样的。

开始

选择排序

选择排序的基本思想是:每一次内循环,选择出一个最小/最大的元素放在数组的一端。其实这个内循环是一个基本实现版本,只要能找出列表中的剩余元素即可。


const selectSort = arr => {
	for(let i = 0; i < arr.length; i++){
            let minIndex = i // 假定这个位置为最小的元素
            for(let j = i+1; j< arr.length; j++){
                if(arr[minIndex] > arr[j]){
                    minIndex = j
                }
            }
            // 将最小元素和 i 位置的元素换位置
            const tmp = arr[i]
            arr[i] =  arr[minIndex]
            arr[minIndex] = tmp
    }
    return arr
}

这个算法只要理解其思想,上面的代码就能写出来。

排序算法的特点如下:

  • 每次排序,只会有最多两个元素换位置(有可能不换,如果数组本身就是有序的)
  • 它的缺点也很明显,无论你输入的原始数组是怎么样的,都会进行排序。即使输入数组是有序的,也会按照规则再进行一次排序。所以,选择排序的运行时间与输入无关
  • 它的交换次数 N 只与数组本身的大小有关,因为每次只会对两个元素的位置进行交换。这是其他算法所不具备的特性。

冒泡排序

这种排序属于基本排序算法。它的思想是:从数组左侧开始,每两个数进行比较,将大数换到右侧,直到将大数换到数组的最右侧为止。当然,这个置换,也是在内循环内完成的。这样,每进行一次内循环,就会有一个数的位置被排定,直到所有所有元素被排定。


const bubble = arr => {
	for(let i = 0; i < arr.length; i++){
            for(let j = 0; j < arr.length - i -1; j++) {
                if( arr[j] > arr[j+1] ){
                    // 换位置,将大数置换到右侧
                    const tmp = arr[j]
                    arr[j] = arr[j+1]
                    arr[j+1] = tmp
                }
            } 
    }
    return arr
}

依然是理解思想就不难写出代码的算法,这里有一个细节需要稍微注意:由于每次最大数都是放在当次循环的最右边的位置,所以,内循环的终止条件是 arr.length - 1 - i

  • 减 1 是为了保证索引不会越界。毕竟有 i + 1 的存在
  • 减 i 是为了保证每次最右边的元素往前推进一位,直到推进到 0

插入排序

插入排序和前面两个基本算法有相似之处,它存在这样的假设:假设当前元素左侧的子数组是有序的,那么只要把当前元素插入到左侧数组的合适位置,就能保证左侧数组依然是有序的。当前,这里在插入到合适位置的过程中,伴随着插入位置右侧的元素往后挪一位,以腾出空位,给新元素插入

理解了思想以后,代码就自然而然的出来了:


const insertSort = arr => {
	for(let i = 1; i <arr.length; i++){
    	   for(let j = i; j >=0; j--) {
           		if(arr[j] < arr[j-1]){
                	// 换位置 
                    const tmp = arr[j]
                   	arr[j] = arr[j-1]
                    arr[j-1] = tmp
                }
           }
    }
    return arr
}

希尔排序

这种排序是插入排序的进阶算法,它的基本思想和插入排序一样。但是实现却有差别。它的思想是,假定数组中相隔 h 的元素是有序的(数组就叫做 h 有序数组),当 h 很大时,就能将一个元素移动很远,在 h 不断变小的过程中,数组也在逐渐变得有序,直到 h 为 1 时,希尔排序就会进行一次直接插入排序,这时,数组就成为了有序数组。

它的文字描述略显晦涩,建议上 B 站 搜索 希尔排序 查看视频讲解,能更深刻的知道其含义和思想。


const xierSort = arr => {
	let h = 1
    const len = arr.length
    // 首先需要对数组进行分割,这个分割可以根据个人需求更改
    // 这里加 1 的目的是为了 Math.floor(h/3) 能取到 1
    while(h < len /3) h = h*3 + 1
    while(h>=1) {
    	// 除了 h 之外,这里的排序算法和插入排序一样
    	for( let i = h; i < len; i++ ) {
            for(let j = i; j>=0; j-=h){
                if( arr[j] < arr[j-h] ){
                    // 换位置
                    const tmp = arr[j]
                    arr[j] = arr[j-h]
                    arr[j-h] = tmp
                }
            }
        }
        h = Math.floor(h/3)
    }
    return arr
}

这个算法的其实就是在插入排序的基础上,加入了步长来减少最终的元素交换次数。

仔细观察这个排序,其实就是先计算出一个最大步长,然后从这个步长进行插入排序。每一次的排序,都会使数组像有序的方向靠拢,直到步长缩小为1。这时,进行一次直接插入排序,即可完整最终的排序。

归并排序

这个排序的基本思想是:将两个有序的数组,按照元素的大小关系,归并为一个更大的数组。下面先实现一个归并函数:

const merge = (arr, start, mid, end) => {
    let s = start
    let e = mid +1
    // 复制源数组
    const newArr = arr.map(Number)
    
    for(let i = start; i < end; i++){
    	if(s > mid) {
          // 左侧取尽
          arr[i] = newArr[e++]
        } else if(e > end) {	
           // 右侧取尽
           arr[i] = newArr[s++]
        } else if(newArr[e] > newArr[s]) {
           // 左侧的比右侧的小,取左侧
           arr[i] = newArr[s++]
        } else {
            // 否则取右侧的值
            arr[i] = newArr[e++]
        }
    }
    return arr
}

这个归并函数实现了从 mid 将数组分为从 [start, mid][mid+1, end] 两部分,然后进行原地归并。这里的原地归并指的是直接在原数组上进行操作,而不重新创建新的数组。

注意:归并时的大小比较,按照 newArr 的元素进行比较,因为 arr 是原地换位置,对应位置的元素会发生改变。

递归实现归并排序

按照归并的思想:将两个有序的数组进行归并,即可得到一个更大的有序数组。对于一个无序的数组,可以从中间进行拆分为两个数组,然后对左右两个数组进行排序,再对这两个数组进行归并即可。这就实现了归并排序

使用递归的目的是为了将左右数组不停地拆分,直到每个数组中的元素小于等于1个为止,这时的所有“左右”数组都是有序的,这样子逐层计算,就能最终将一个数组拆分为最小的单元(一个数组一个元素)。然后对每个拆分单元进行归并处理,最终就能是整个数组有序。

const diguiSort = arr => {
    // 对拆分的左右数组进行排序
    const sort = (arr, start, end) => {
        if(start >= end){
            // 此时数组至多有一个元素,认为数组是有序的
            return
        }
        const mid = Math.floor((end-start)/2) + start
        sort(arr, start, mid) // 对左侧的数组进行排序
        sort(arr, mid+1, end) // 对右侧的数组进行排序
        merge(arr, start, mid, end) // 对左右有序的数组进行归并处理
    }
    sort(arr, 0, arr.length - 1)
    return arr
}

在实现递归时,千万不要去想具体的递归细节。如果你想弄清楚计算细节,可以画出递归树进行分析。只需要分析出每一步需要做什么,按照思路写出公共的计算逻辑和终结条件,将计算交给递归过程即可。

递推实现归并排序

递推过程和递归过程相反,是从小往大推出最终结果的过程,就是常说的 自下而上。而递归是从大往小分解问题得出结果的过程,就是常说的自上而下。基本概念解释完毕,一般能使用递归的逻辑,也可以使用递推计算,不过大部分情况下递推的逻辑会更难想。

递推实现的基本思路:从子数组 length = 1 开始,每次扩充为前面 size 的二倍,也就是 size += size 。直到扩充到数组的长度为止。


const dituiSort = arr =>{
    for(let i = 1; i < arr.length; i+=i){
        for(let j = 0; j < arr.length; j+=i*2) {
            merge(arr, j, i-1+j,Math.min(j+i*2-1, arr.length -1))
        }
    }
}

可以看到,递推的代码很少,很精简,但是其中包含的计算逻辑会更多,更复杂。下面是简答你的分析:

  • 前面提到,length 从 1 开始,每循环一次就翻倍,也就是会经历 1 2 4 8 16 这样的子数组length 变化。这里需要注意的是,每次归并的是两个子数组,所以,每个归并后的数组包含的元素个数是 length * 2
  • start = j 这个没什么争议
  • end = Math.min(j+i*2 -1, arr.length -1) 表示每次归并的最后一个元素的位置,取最小值是为了防止索引越界。而 j + i*2 - 1 是因为子数组开始的索引是 j。数组的长度为 i*2, 最后一个元素的索引为 length - 1。结合前两个计算,即可得出最后一个元素的索引为j+ i *2 - 1

快速排序

这个算法的基本思路是:先选出一个排定的元素,将小于这个数的都放在这个数的左边,大于这个数的都放在这个数的右边,然后再对左右两边排序,再将三部分结合,即可得到一个有序数组。

实现一个 partition 函数

这个函数用于处理将大于给定元素的数放在这个数的右边,小于的放在左边。


const partition = (arr,s, e) => {
    // 选定第 0 个元素为给定元素
    const ele = arr[s]
    let start = s
    let end = e + 1
    
    while(true) {
    	// 选择到一个左侧 比基准元素大的元素
        while(ele > arr[++start]) if(start === e) break;
        // 选择到一个右侧 比基元素小的元素
        while(ele < arr[--end]) if(end === s) break;
        // 两侧的扫描有交叉,结束循环
        if(start >= end) break; // 结束外层循环
        // 将左侧比基准元素大的元素和右侧壁记住元素的小的元素互换位置
        const tmp = arr[start]
        arr[start] = arr[end]
        arr[end] = tmp
    }
    // 将基准元素换到外层循环结束的位置
    arr[s] = arr[end]
    arr[end] = ele
    return end // 这个位置就是数组的分割位置
}

上面的这个函数目的在于计算对于数组 arr 来说,基准元素的位置在哪。同时,将数组调整为了满足 [0, end-1] <= [end] <=[end+1, arr.length -1] 的数组。

这里在 start 和 end 出现交叉后,end 一定会在左侧数组的索引最大的元素位置上。所以,基准元素和这个元素换位置,不影响左侧数据都比基准元素小的规则。

下面的例子展示了上述的过程:

const arr = [0,1]
partition(arr, 0, 1)
// 取基准元素为 arr[0]
const ele = arr[0]

// 按照 partition 运行规则,初始值
end = 1
start = 0

//开始执行循环体

end = 0
start = 1

start > end //直接结束内存循环。

// 此时 end 正好落在了左侧做大索引上,这里就是 0。

快速排序的实现


const kuaipaiSort = arr => {
    const sort = (arr, start, end) =>{
        if(start >=end){
            return
        }
        const splitIndex = partition(arr,start, end)
         // 排除已经排定的位置splitIndex
        sort(arr, start, splitIndex - 1)
        sort(arr, splitIndex + 1, end)
    }
    sort(arr, 0, arr.length - 1)
    return arr
}

总结

这几种排序算法都不难,如果不想原地操作,可以按照思路,构建额外的数组用于“左”“右”两个数组的储存,在理解的深入以后,再尝试使用原地操作的方式书写算法。

这篇文章对于这几个算法的记录仅仅是个人学习过程的记录,内容不够详尽和系统。后续有时间会继续补充。