美文网首页
从归并排序说起

从归并排序说起

作者: _刘小c | 来源:发表于2020-11-16 11:44 被阅读0次

    引子

    我常常会想解决算法问题的开始在哪里,难道是记下茫茫多的解决技巧,或者是熟悉于特定的编程语言,还是说题海战术?

    可我们的精力能力有限,特别是平时工作并不是面向算法开发的,而在这样的背景下,如果想领悟算法本质。我觉得最讲究的,最根源的,还是如何从零开始,挖掘一个统一通用的解决技巧。

    为了解决一个问题,最简单的办法,就是把其他的可变条件简单化。而我打算从最简单的归并排序开始...

    经典问题

    //正整数排序
    arr=[2322,981,333,121,9901]
    

    思考过程:

    1. 5个数排序太难了
    2. 4个数会不会,不会
    3. 3个会不会,不会
    4. 2个呢,会
    5. 1个,不用排
    sort=([a,b]) => a>b ? [b,a] : [a,b]
    

    所以其实我们如果搞定了n=1/n=2,那么我们只需要找n=3/4/5转化成n=2的方法即可

    分治

    最经典的分治在这里被提出了,其实也是符合最早数学家的想法的

    1. n=3可以化为两个数组n=2和n=1
    2. 将两个数组排好序的数组,然后结合起来
     arr1=[2,5] arr2=[3,4]
     arr3=[2,3,4,5]
    
    1. 想要结合,其实我们就很自然想到最笨的办法,一个个遍历/对比,因为数字量小,所以人类很快的能得出结果
    2. n=4可以化为n=2和n=2;n=5可以化为n=2和n=3
    3. ok,我们找到了规律
                         a1..n, n=1
    sort(a1,...,an){     a1>a2?[a2,a1]:[a1:a2], n=2
                         merge(sort(a1..n/2),sort(an/2+1...n), n>=3
    

    小结:分治法其实是早期解决问题思路的开端,人类是习惯于解决简单的问题的,找到了简单问题的解法,再把问题转化为,如何把复杂问题简单化。这也是数学思路解算法问题的入口。

    算法

    其实当我们推理出了公式,将它转发成伪代码甚至代码都是很简单的一件事

    sort = (arr) => {
        if (arr.length <= 1) return arr
        // 当n=2时,也可以转化成n=3的问题解决
        return merge(sort(arr.slice(0, parseInt(arr.length / 2))), sort(arr.slice(parseInt(arr.length/2))))
    }
    

    我们还有一个merge函数没有实现,这个merge就是之前说的n=2时可以手动循环遍历的过程,之前说过,循环遍历是人类的思想,所以我们尝试用人类思想描述一下merge

    // 人类白话实现merge
    merge = (a, b) => {
        let c=[], i=0, k=0
        while (i<a.length || k<b.length) {
            if (k<=b.length)      {c.push(a[i]); i+=1}
            else if (i >=a.length){c.push(b[k]); k+=1}
            else if (a[i] < b[k]) {c.push(a[i]); i+=1}
            else                  {c.push(b[k]); k+=1}
            
        }
        return c
    }
    

    又回到人类思想的通病,白话实现起来很简单,但是似乎就是很难证明是对的,害怕自己的思想有遗漏,但谁让它更接近人类思维,更好想象。

    高中时候,做数学题,证明自己的猜想时,我们一般用代入法,n=3,n=4,n=5好像都是对的,那就应该是对的。

    如何实在想完全证明是对的,数学思路的数学归纳法是你的出路,我们也可以用递归写一个merge

    merge = (a, b) => {
       if (!a.length) return b
       if (!b.length) return a
       // 每次拿出a数组最左边的一个数字first
       let [first, ...rest] = a
       // 在b数组中找到比最左边的数字小的数集合bMove
       const bMove = b.filter(v => v < first)
       // 将bMove放在最左边,中间放first,右边把剩下的递归merge
       return [...bMove, first, ...merge(rest, b.filter(v => v >= first))]
    }
    

    乍一看数学公式是真好理解,但其实我们用了concat(解构)/ filter等函数,这些函数其实实现是未知的

    小结:这个时候,这个算法雏形已经出来了,从零开始,我们利用分治法,将问题简单到可以人类解决的时候,再用归纳法证明,最后转化成代码。而且run起来,完全是已经可以准确排序了。

    优化

    为什么说只是雏形呢,因为算法不仅仅是解题,考虑的还有复杂度问题

    性能分析:

    • 空间上:每次slice会开辟新的内存,每次filter和解构也会开辟新的内存
    • 时间上:数学思路大量运用递归,每次压栈都要出栈。

    性能优化:

    • 如何节约内存空间?就地操作

    • 如何节约时间操作?改造递归为尾递归或者循环,并尽量减少循环次数

    // 改造merge,传进来一个数组,以及它排序好的左右截断的middle,而不是传两个数组
    merge = (arr, start, end, middle) => {
        if (end - start > 1) {
            // 直接取数组第一位,没有解构开辟新数组
            let pivot = arr[start]
            let offset = 0
            // 遍历右边一截,找到比pivot小的偏移量,因为右边已经排好序,一旦找到比pivot大的则说明已经找完,退出不必要的循环
            for (let i = middle; i < end; i++) {
                if (arr[i] <= pivot) {
                    offset += 1
                } else {
                    break
                }
            }
            if (offset > 0) {
            //将偏移的一整坨挪动到数组的头部,完成就地变换
                let move = arr.splice(middle, offset)
                arr.splice(start, 0, ...move)
            }
            // 递归merge
            start = start + offset + 1
            middle = middle + offset
            merge(arr, start, end, middle)
        }
        return arr
     }
    

    sort也要配合改一下,入出参

    sort = (arr, start, end) => {
        start = start || 0
        end = end || arr.length
        let middle = parseInt((start + end) / 2)
        if (end - start > 1) {
            sort(arr, start, middle)
            sort(arr, middle, end)
        }
        return merge(arr, start, end, middle)
    }
    

    小结:到这儿这个归并排序已经十分ok了。虽然优化空间肯定是还有的。但是思考方向是一致的。

    自顶向下vs自底向上

    问题的解法一定不止一种,取决于你思考的方式。之前的思路是对整个数组进行逐步切分的自顶向下。但从结果看,最后我们是分治到了n=2和n=1的情况。

    既然分治的结果是最简单化(这里是分割到n=2),那么,其实也可以自底向上,优先每隔两个数字化分数组,然后两两merge,再四四merge,再八八……

    // 自底向上实现sort
    sort = (arr) => {
        for(let size = 2; size / 2 < arr.length; size *= 2) {
            console.log('size=', size)
            for(let i = 0; i < arr.length; i += size ) {
                let middle = i + size / 2
                merge(arr, i, i + size, middle)
                console.log(arr)
            }
        }
        return arr
    }
    

    区别于之前的sort,自底向上的思路更工整更好理解,原因在于我们融入了人类的思考。但是人类的思考弊端就是它是取决于个人的经验积累,好懂的好懂,难懂的也难懂。它不像数学思维,很容易证明也很难推翻它,因为它是层层推理得来,没有什么理解瓶颈

    总结

    并不在于怎么解决归并排序,却从头到尾分析了归并排序。
    原因很简单:为了解决一个问题,最简单的办法,就是把其他的可变条件简单化。为了研究如何从零到一地思考算法,以最简单的排序问题入口,从最开始的归并问题说起。

    相关文章

      网友评论

          本文标题:从归并排序说起

          本文链接:https://www.haomeiwen.com/subject/wobabktx.html