算法学习——用JavaScript实现排序算法

前言

想学习一下算法的知识,以经典的排序算法来入门。感觉算法重在思想,用程序实现反而没有那么复杂,按照算法的描述一步步编写、改进即可。

用什么语言实现都不重要,本质上都是对算法的具象化。在这里我选择用JavaScript来实现。

在这里不对这些算法的过程、复杂度等作介绍,单纯是对算法进行实现,有一篇博客讲的特别好:十大经典排序算法,里面介绍了十大排序算法的思想,还附带了形象的动态图演示,每种算法也有代码实现(博客没说是什么语言,看不出是C、C++还是JAVA。。),最后还对这些算法的特性、复杂度作了比较。

先写了JavaScript自带的sort方法(据说底层就是冒泡排序。。。(●ˇ∀ˇ●)),然后是冒泡排序、选择排序、插入排序、快速排序、基数排序、归并排序。其他的排序后面慢慢学习、补充。

代码有些地方值得改进,应该还能够写得更简单、更优雅。知道没什么人看,纯粹当是一个心得笔记和代码记录啦,哈哈哈。。。

sort()方法

ES6里提供的sort()方法就已经满足排序的需求,底层据说是冒泡排序,所以第一个就直接试着调用sort()方法看看它的效果。

sort()的参数可以是一个排序规则函数,本质上就是告诉sort方法你想以什么样的标准进行排序,规则函数就是对这个标准的描述。具体的排序过程,是由底层完成的。

这个排序操作是直接在原数组上进行的,而不是返回一个数组副本。

对于前端来说,感觉不会处理到特别庞大的数据(一般应该都是后端将数据处理好,再返回给前端吧),所以平时开发直接使用自带的sort方法就好了。在少量数据的情况下,不同的排序算法优势是体现不出来的,换句话说用谁都一样。

var arr = new Array(100);
for(let i of arr.keys()) { //生成乱序数组
    arr[i] = parseInt(Math.random()*1000);
}
//sort方法/冒泡排序
const sort0 = (arr) => {
    let sortarr = Array.from(arr);
    sortarr.sort((x, y) => x - y);
    console.log(sortarr);
}

console.log(arr);
sort0(arr);

冒泡排序

精髓是:每一轮都会把1个相对最大的元素排到后面去。一共要找到n-1个这样的元素,所以需要n-1轮。

var arr = new Array(100);
for(let i of arr.keys()) {
    arr[i] = parseInt(Math.random()*1000);
}

// 精髓是:每一轮都会把1个相对最大的元素排到后面去 一共要找到n-1个这样的元素,所以需要n-1轮
const sort00 = (arr) => {
    let sortarr = Array.from(arr);
    let loop = 0; //已进行轮数
    for (let i = 1; i < (sortarr.length - 1); i++) { //进行n-1轮
        for (let i of sortarr.keys()) { //一轮
            if (i < (sortarr.length - loop)) {
                if (sortarr[i] > sortarr[i + 1]) {
                    let tmp = sortarr[i];
                    sortarr[i] = sortarr[i + 1];
                    sortarr[i + 1] = tmp;
                }
            } else {
                break;  //不需要对已排好的元素再判断一次,在这里将一轮循环停止
            }
        }
        loop++;
    }
    console.log(sortarr);
}
console.log(arr);
sort00(arr);

选择排序

实现后的感受:听起来简单明了的算法思想(比如算法会描述为“先xxx、再xxx,就完成了这个算法”),但实现起来还是挺费功夫的。因为每一个操作都需要考虑到或者要单独实现(比如交换、寻找最小元素),要把这些操作结合起来,才能形成一个完美、优雅地算法。

var arr = new Array(100);
for(let i of arr.keys()) { //生成乱序数组
    arr[i] = parseInt(Math.random()*1000);
}

//选择排序
const sort1 = (arr) => {
    let sortarr = Array.from(arr);
    for (let i of sortarr.keys()){
        exchangeEle(i);
    }
    function exchangeEle(step){ //交换元素
        let tmp = sortarr[step];
        let mixEleIndex = findMix(sortarr.slice(step, sortarr.length), step);
        sortarr[step] = sortarr[mixEleIndex];
        sortarr[mixEleIndex] = tmp;
    } 
    function findMix(subArr, step){ //寻找子数组里最小的元素
        let mix = subArr[0];
        let mixEleIndex = 0;
        let count = 0;
        for (let [index, ele] of subArr.entries()) {
            if (ele < mix) {
                mix = ele;
                mixEleIndex = index;
            };
        }
        //console.log(mix, mixEleIndex);
        return mixEleIndex + step;
    }
    console.log(sortarr);
}
console.log(arr);
sort1(arr);

插入算法

精髓:相当于整理扑克牌的过程(这个比喻十分恰当)。即:抽出一张牌来,然后放入自己已经排好序的那部分牌组的适当位置里。在大小比较以及插入那一块,应该是可以优化的,感觉写得有点冗余了。

var arr = new Array(100);
for(let i of arr.keys()) { //生成乱序数组
    arr[i] = parseInt(Math.random()*1000);
}

//插入排序
const sort2 = (arr) => { 
    let sortarr = Array.from(arr);
    let extractEleIndex = 1; //将要抽取的元素的索引值
    for (let i = 0;i < sortarr.length;i++) {
        extractAndInsert();
    } 
    function extractAndInsert(){
        let subArr = sortarr.slice(0, extractEleIndex);
        // console.log(subArr);
        for (let i = subArr.length;i > 0;i--) { 
            if(sortarr[extractEleIndex] != undefined) { //相当于抽到空牌就停止,不要拿空牌去跟真实存在的牌比较,结果还把空牌加到牌组头去了。。
                if (subArr[i - 1] < sortarr[extractEleIndex]) {
                    let val = sortarr[extractEleIndex];
                    sortarr.splice(extractEleIndex, 1);
                    sortarr.splice(i, 0, val);
                    break;
                } else if (i == 1) {
                    let val = sortarr[extractEleIndex];
                    sortarr.splice(extractEleIndex, 1);
                    sortarr.unshift(val);
                    break;
                }
            }
        }
        extractEleIndex ++; 
    }
    console.log(sortarr);
}
console.log(arr);
sort2(arr);

快速排序

分割、交换的过程不难实现,关键在于怎么把已经分别排好序的子数组,重新组合回自己原初长度的数组。虽然想得出要拼接数组,也感觉到可能需要递归,但是写起来就犯傻了。

递归在定义上是就是“自己调用自己”,但是光靠这个定义始终想不出具体要怎么自己调用自己。

后来想到了一个形象的理解,就是无限取值(无限return。。。)。就像树状结构一样,让“调用”不断向下延申、分叉、操作,然后再将子节点“调用”的值返回到父节点“调用”,父节点“调用”又把自己的值返回给他自己的父节点…最后全部返回到根节点“调用”(最初的函数调用),得到最终结果。

想通了这点,就想到了可以在拼接数组的时候,同时调用自己(递归)。让孙子数组拼接成子数组,子数组拼接成父数组,父数组拼接成爷数组…子子孙孙这样不断调用,不断拼接,最后拼回我原来的数组,并且已经是都排好序的了。

所以我的这一种实现代码的精髓在于用“递归”进行“拼接”。但是看了一些文章和介绍,实现的方法其实不止一种,还有不少其他的方式。

var arr = new Array(100);
for(let i of arr.keys()) { //生成乱序数组
    arr[i] = parseInt(Math.random()*1000);
}

//快速排序
const sort3 = (arr) => {
    let sortarr = Array.from(arr);
    function doSort(arr) {
        let midIndex = Math.floor(arr.length / 2); //基准元素或叫中轴元素:取第一个(midIndex = 0)、中间一个(midIndex = Math.floor(arr.length / 2))、最后一个(midIndex = arr.length-1)都可
        let midArr = arr[midIndex] != undefined ? [arr[midIndex]] : [];
        let leftArr = [], rightArr = [];
        for (let i of arr.keys()) {
            if (i != midIndex) {
                if (arr[i] <= arr[midIndex]) {
                    leftArr.push(arr[i]);
                } else {
                    rightArr.push(arr[i]);
                }
            }
        }
        if (leftArr.length == 0 && rightArr.length ==0) {
            return midArr; //[] [ 649 ] []
        } else {
            //递归:定义上是指的就是“自己调用自己”。形象的理解就是无限取值,就像树状结构一样不断向下延申、分叉、操作,然后再将值返回到父节点,最终全部返回到根节点,得到最终结果
            //return doSort(leftArr).concat(midArr).concat(doSort(rightArr)); //旧语法的写法
            return [...doSort(leftArr), ...midArr, ...doSort(rightArr)]; //利用ES6特性 更优雅的写法 无限地拼接。。。
        }
    }
    sortarr =  doSort(sortarr); //最初的调用 (根节点“调用”)
    console.log(sortarr);
}
console.log(arr);
sort3(arr);

基数排序

百度百科有这么一段介绍:

基数排序的发明可以追溯到1887年赫尔曼·何乐礼在打孔卡片制表机(Tabulation Machine)上的贡献。它是这样实现的:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。

一直搞不懂一个点:比如当排序排到十位的时候,像19的十位是“1”,可以放进1号桶;但是像8这样十位为空的数值,要放到哪里。看到了上面的介绍后才明白,8的十位没有数字,那就相当于0,所以要放进0号桶。这一过程也相当于对8的十位进行补0操作了。

需要注意的地方是,从数组取数字、放入桶内、从桶内取出、重新放回到数组这些“取”、“放”操作的顺序(从前取还是从后取,放在前面还是放在后面)。

var arr = new Array(100);
for(let i of arr.keys()) { //生成乱序数组
    arr[i] = parseInt(Math.random()*1000);
}
//基数排序
const sort4  = (arr) => {
    let sortarr = Array.from(arr);
    let buckets = new Array(10), N = 1;
    for (let b of buckets.keys()) { //初始化0-9的桶
        buckets[b] = [];
    }
    function doSort() {
        for (let ele of sortarr) { //放入桶中
            let n = parseInt(ele % Math.pow(10, N) / Math.pow(10, N-1)); //取数位上的数字。若此位已无数字,此位会为0(相当于此位补0)
            buckets[n].push(ele);
        }
        if (buckets[0].length == sortarr.length) {
            return false; //所有元素都被移入“0”这个桶,证明所有元素在此数位都无数字,已经排完了。返回false,取消迭代操作
        } else {
            sortarr = []; //先把排序数组清空,用于重新放置从桶中取出的元素
            for (let b of buckets.keys()) { //依次从0-9的桶中取出 取的时候要注意“先入先出”这个顺序。。
                for (let i of buckets[b].keys()) {
                    sortarr.push(buckets[b][i]);
                }
                buckets[b] = []; //在排序数组上加上元素的同时,注意要把元素从桶中移除。 这里直接在最后一步全部清空即可
            }
            N++; //下一次取更高的数位
            return true;
        }
    }
    while (1) {
        if(!doSort()) break;
    }
    console.log(sortarr);
}
console.log(arr);
sort4(arr);

归并排序

从这里了解到了“分治”的理念。分治与递归一般是一起出现的,是设计算法的一种思想。

归并,我拆解开觉得大概就是“递归”、“合并”之意吧。咋看之下有点像快速排序(因为也用到了递归、合并),但是在写的过程中发现不太一样。

1.失败的例子

首先写出了一个错误、失败的代码实现。对于理解归并排序的递归、合并,印象更深刻了。

var arr = new Array(100);
for(let i of arr.keys()) { //生成乱序数组
    arr[i] = parseInt(Math.random()*1000);
}

//失败的归并排序
const sort5x = (arr) => {
    let sortarr = Array.from(arr);
    function doSort(arr) {
        let arr1 = arr.slice(0, Math.floor(arr.length/2) + 1);
        let arr2 = arr.slice(Math.floor(arr.length/2) + 1, arr.length);
        if (arr1.length == 2) { //只是对最底层节点(数组长度为2)作了交换排序,但是往上层节点都没有做排序操作,就直接返回了。所以这个排序只是两两排序,最后没有达到全局排序
            if (arr1[0] > arr1[1]) {
                let tmp = arr1[0];
                arr1[0] = arr1[1];
                arr1[1] = tmp;
            }
        }
        if (arr2.length == 2) {
            if (arr2[0] > arr2[1]) {
                let tmp = arr2[0];
                arr2[0] = arr2[1];
                arr2[1] = tmp;
            }
        }
        return ( arr1.length <= 1 || arr2.length <= 1) ? [...arr1, ...arr2] : [...doSort(arr1), ...doSort(arr2)];
    }
    sortarr =  doSort(sortarr);
    console.log(sortarr);
}
console.log(arr);
sort5(arr);

失败在于,只是在将数组切分为长度为“2”的最小数组时,对这些小数组的两个元素进行了比较、交换。最后从整个大数组的层面看,就只是相邻元素之间进行了两两排序、交换,而没有对整体进行排序。

写出这个逻辑的原因,还是因为自己对归并排序的思想理解不到位。除了“递归”之外,“合并”也是归并排序的一个重要操作。

2.成功的例子

除了“递归”之外,“合并”也是归并排序的一个重要操作。上面例子失败的原因,就在于没有理解归并排序里“合并”这一步操作。

我想当然地把数组合并理解成了数组拼接(即concat方法),所以在返回、合并数组时,使用了ES6的扩展运算符来拼接数组,以期将分割的小数组通过递归“拼接”回大数组。这也是之前写了快速排序,受到了影响吧。可见递归排序跟快速排序还是有区别的。

合并数组的方法,在于从两个被切割的小数组中取值,用取到的值组合成一个新数组(而不是单纯的拼接)。坑的是,一些文章只是告诉了说归并排序有“合并”这一步骤,而没有具体去说这个步骤怎么操作。。。

合并的操作过程是,对两个小数组的第一个元素进行比较,取较小的元素放入新数组中(若是一样大,其实可以一起放?感觉不一起放也可以,因为下一次的时候,被留下的元素还是会被进行比较、被放进去。就看想不想更优化一些吧,可以省去几步比较)。并将这个元素从小数组中移除,此时小数组的第二个元素就变成了第一个,所以只要继续重复上一步即可。

排序的整个流程可以总结为:大数组分割成2个小数组->让小数组“合并”(此操作已经包含了排序的过程)成新的大数组。因为一开始(只对原长度的那个数组进行分割时),合并成的大数组不一定是有序的,要想整体有序,就必须先让局部有序。所有就利用了分治和递归的策略,将数组无限细分、合并(其中已经包含了排序的过程),最后将它们重新组合回原长度的数组(此时就完成了整体有序)。

var arr = new Array(100);
for(let i of arr.keys()) { //生成乱序数组
    arr[i] = parseInt(Math.random()*1000);
}

//归并排序
const sort5 = (arr) => {
    let sortarr = Array.from(arr);
    function doSort(arr) { //排序: 分割->更局部的排序->合并
        let subarr1 = arr.slice(0, Math.floor(arr.length/2)); //分割数组
        let subarr2 = arr.slice(Math.floor(arr.length/2), arr.length);
        function merge(arr1, arr2) { //合并操作
            let mergeArr = []; //合并起来的数组
            while (1) {
                if (arr1.length == 0 && arr2.length == 0) break;
                if (arr1[0] != undefined && arr2[0] != undefined) {
                    if (arr1[0] <= arr2[0]) {
                        mergeArr.push(arr1[0]); //注意放置位置,加在后面
                        arr1.splice(0, 1);
                    } else {
                        mergeArr.push(arr2[0]);
                        arr2.splice(0, 1);
                    }
                } else { 
                    if (arr1[0] == undefined) {
                        mergeArr = mergeArr.concat(arr2); //全取
                        arr2 = []; //清空
                    } else if (arr2[0] == undefined) {
                        mergeArr = mergeArr.concat(arr1);
                        arr1 = [];
                    }
                }
            }
            return  mergeArr;
        }
        return merge(subarr1.length <= 1 ? subarr1 : doSort(subarr1), subarr2.length <= 1 ? subarr2 : doSort(subarr2)); //递归、合并(所以叫归并?..)
    }
    sortarr =  doSort(sortarr);
    console.log(sortarr);
}
console.log(arr);
sort5(arr);

小插曲:在完成了大部分工作后,运行了一下结果,发现还是没有达到整体排序的效果。思来想去,逐步排查了自己的操作逻辑,也想不到到底哪一步做得不对。凭借一点平时的经验,“凡是遇到那种想破脑袋也想不出错在哪儿的玄学问题,往往出错的地方很蠢、很低级、很简单”,于是开始检查变量、方法调用等地方,果然发现了问题所在:1.取出小数组元素放入大数组的位置错了,本应是push,结果成了unshift、2.数组的concat()方法,没注意该方法是返回数组副本,而不是直接操作原数组。结果没把小数组的元素放到大数组里

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值