数据结构之---排序算法

204 阅读8分钟

如果数据有序,那么将对数据的操作带来极大的便利,因此,排序也是日常工作中最长使用的算法,每一种编程语言都提供了原生的排序算法,高效且稳定,不需要我们再去实现它,但学习排序算法,仍然有很好的启示作用。

1. 分而治之

分而治之,是非常重要的编程思想,它的关键之处在于不断缩小问题的规模,直到找到基线条件,而基线条件则是非常简单且易于处理的,这种思想天然的就和递归契合在一起,递归也是一个不断分解问题直到遇到最简单最易处理的情况然后加以解决的过程。

1.1 归并排序

1.1.1 合并两个有序数组

如果两个数组都是有序的,那么将他们合并成一个有序数组是一个非常简单的操作,下面的代码演示了一个简单的合并操作。

// 合并两个有序数组
function merge(arr1, arr2){
    var merge_arr = [];
    var index_1 = 0;
    var index_2 = 0;

    while(index_1<arr1.length && index_2<arr2.length){
        // 哪个数组的头部元素小,就合并谁,然后更新头的位置
        if(arr1[index_1]<=arr2[index_2]){
            merge_arr.push(arr1[index_1]);
            index_1++;
        }else{
            merge_arr.push(arr2[index_2]);
            index_2++;
        }
    }

    // arr1有剩余
    if(index_1<arr1.length){
        while(index_1<arr1.length){
            merge_arr.push(arr1[index_1]);
            index_1++;
        }
    }

    // arr2有剩余
    if(index_2<arr2.length){
        while(index_2<arr2.length){
            merge_arr.push(arr2[index_2]);
            index_2++;
        }
    }

    return merge_arr;
};

var arr1 = [1, 3, 5];
var arr2 = [2, 4, 6];
console.log(merge(arr1, arr2));

程序输出结果为

[ 1, 2, 3, 4, 5, 6 ]

合并两个有序数组的思路如下:

  • 分别使用一个游标指向数组的最小值
  • 比较这两个最小值,将其中最小的放入合并数组
  • 最小值的游标向后移动,指向新的最小值,重复刚才的过程

不能忽略的一点,可能其中某一个游标很快就走到头了,那么另一个没有走到头的游标要单独处理。

1.1.2 分治

对于一个无序的数组,如何借助合并两个有序数组的方法来实现排序呢?一边是无序的数组,一边是要求两个数组有序才能使用的合并算法,完全是风马牛不相及的东西。

如果对数组进行切分,不停的切分,每次分出来的两个数组大小都是原来数组的一半,那么最终数组将被切分成许多个只有一个元素的小数组。而只有一个元素的数组本身就是有序的,这样不就可以借助合并有序数组这个算法了么。这就是分而治之中的分的过程,不断的缩小问题的规模。

分完以后,还要治,分的目的是为了找到基线条件,这个条件下,问题简单且容易被处理,当分出来的数组里只有一个元素或者没有元素时就是基线条件。对这些小的数组进行合并,会形成更大一点的有序数组,在此基础上,继续合并,直到合并成一个数组。

分治法将一个大的问题,转化分解成若干个子问题,当每个子问题都解决了以后,大的问题也就随之解决。

下图演示了分治的过程

归并排序的最终代码如下

function merge_sort_ex(arr, start, end){
    if(start < end){
        // 分
        var middle = Math.floor((start+end)/2);
        var arr1 = merge_sort_ex(arr, start, middle);
        var arr2 = merge_sort_ex(arr, middle+1, end);
        // 治
        return merge(arr1, arr2);
    }
    return [arr[end]];
};

function merge_sort(arr){
    return merge_sort_ex(arr, 0, arr.length-1);
};


var arr = [7, 2, 8, 1, 4, 6, 9, 3];
console.log(merge_sort(arr));

1.2 快速排序

快速排序也是一种分而治之的算法,对于一个无序的数组,从其中随机找出一个数,以这个数为基准对数组进行处理,处理后,这个基准数的左边的数值都比它小,右边都比它大,这样便完成了一次分,分的结果是数组里的数据分成了两派,一派在基准值的左边,一派在基准值的右边。

接下来要治,分别治理这两派,如何治,还是老办法,对他们进行分。如此反复,当一派里只一个元素的时候,它天然就是有序的,不需要继续分治,此时,整个数组都已经变得有序。

1.2.1 分区

分区操作的目的是将数组分成两个区,默认选取数组中的第一个元素作为基准,分区后,数组依然是无序的,但无序中也有序,即基准左边的数都小于等于它,基准右边的数都大于等于它,如果继续对这两个区进行分区操作,那么就越来越变得有序。
分区函数示例代码如下:

// 取arr[start]为基准值,将start到end这个区域进行分区
function partition(arr, start, end){
    var pivotpos = start;
    var pivot = arr[start];

    for(var i =start + 1;i<=end; i++){
        if(arr[i] < pivot){
            pivotpos++;
            if(pivotpos!=i){
                // 将小于基准的值交换到左侧
                var temp = arr[pivotpos];
                arr[pivotpos] = arr[i];
                arr[i] = temp;
            }
        }
    }
    arr[start] = arr[pivotpos];
    arr[pivotpos] = pivot;

    return pivotpos;
};

1.2.2 完整快速排序算法

function quick_sort_ex(arr, start, end){
    if(start < end){
        var pivotpos = partition(arr, start, end);
        quick_sort_ex(arr, start, pivotpos-1);
        quick_sort_ex(arr, pivotpos+1, end);
    }
}

function quick_sort(arr){
    quick_sort_ex(arr, 0, arr.length-1);
}


var arr = [7, 2, 8, 1, 4, 6, 9, 3];
quick_sort(arr);
console.log(arr);

1.2.3 性能分析

理想情况下,每一次分区后,所分得的两个区长度是相等的,假设有n元素,那么只需要分

log2n

l

o

g

2

n

次就可以完成排序,对一个元素定位所需要的时间为O(n)设T(n) 是对n个元素进行排序所需要的时间总的计算时间为:
T(n) = cn + 2T(n/2) (c是一个常量)
= cn + 2(cn/2+2T(n/4)) = 2cn+4T(n/4)
= 2cn + 4(cn/4+2T(n/8)) = 3cn+8T(n/8)
......

最坏的情况下,待排序序列已经是有序的,这样,进行一次分区后,只得到一个分区,这个分区里的元素个数比上一次少一个元素,如此,需要n-1次分区,第一次分区需要遍历n-1个元素才能完成分区,第二次需要n-2次比较,第三次需要n-3次比较,最后一次需要1次比较,总的比较次数就是n*(n-1)/2,约等于n2 /2,时间复杂度为O( n2 )。

1.2.4 快速排序改进

快速排序,简单高效,但是当序列长度在5到25之间时,直接插入排序的速度比快速排序快至少10%, 改进后的快速排序,当数据规模小于25时,采用直接插入排序。

直接插入排序的思路如下:时,假设前面从arr[0]到arr[i-1]已经有序,那么只需将arr[i]和前面那些有序的数值进行比较,找到自己应该插入的位置即可,原来位置上的元素一次向后顺移。

上述逻辑中有一个重要前提,假设从arr[0]到arr[i-1]已经有序,可是我们要做的是对一个无序数组进行排序,你却假设前面的一段已经有序,这不矛盾么?其实,并不矛盾,因此i是从1开始的,从1开始的时候,前面只有arr[0],一个元素的序列当然是有序的,于是顺利进行插入操作,当i=2时,arr[0]和arr[1]已经有序,依次类推。
插入排序示例代码:

function insert_sort(arr, start, end){
    for(var i= start+1; i<=end;i++){
        // 假设从arr[0]到arr[i-1]已经有序,那么只需要比较arr[i]和arr[i-1]的大小即可
        if(arr[i]<arr[i-1]){
            var tmp = arr[i];
            var j = i-1;
            // 找到tmp应该在的位置
            while(j>=start && tmp<arr[j]){
                arr[j+1] = arr[j];
                j--;
            }
            arr[j+1] = tmp;
        }
    }
};

var arr = [7, 2, 8, 1, 4, 6, 9, 3];
insert_sort(arr, 0, arr.length-1);
console.log(arr);

改进后的快速排序算法如下

function quick_sort_ex(arr, start, end){
    if(start < end){
        if(end-start <=25){
            insert_sort(arr, start, end);
        }else {
            var pivotpos = partition(arr, start, end);
            quick_sort_ex(arr, start, pivotpos - 1);
            quick_sort_ex(arr, pivotpos + 1, end);
        }
    }
}

function quick_sort(arr){
    quick_sort_ex(arr, 0, arr.length-1);
}


var arr = [7, 2, 8, 1, 4, 6, 9, 3];
quick_sort(arr, 0, arr.length-1);
console.log(arr);

1.3 二分查找

二分查找法是分而治之的典型应用,在一个有序的序列中,查找某个具体数值的位置,并不需要从头到尾的进行遍历,而是先找到中间的数值,如果中间的数值比要查找的数值小,下一步就去中间数值的右侧继续查找,如果中间的数值比要查找的数值大,下一步就去中间数值的左侧继续查找,如果中间数值和要查找的数值一样大,则返回中间数值的索引。

二分查找算法虽然也是分治法,但是又与快速排序的分治有所区别,二分查找将一个大的问题转化分解成若干个小问题,这些小问题中只解决其中一个,整个大问题就随之解决了。二分查找算法取中间值,将序列一分为二,但是每次都只进入其中某一个子序列,而不像快速排序算法那样,每次分区后,两个子序列都要处理,二分查找算法是分治法的一种特例,叫减治法。

示例代码

function binary_search(arr, target, start, end){
    if(start > end){
        return -1;    //表示找不到
    }

    var middle = Math.floor((start+end)/2);
    if(arr[middle]==target){
        return middle;
    }else if(arr[middle]> target){
        // 去左侧查找
        return binary_search(arr, target, start, middle-1);
    }else{
        // 去右侧查找
        return binary_search(arr, target, middle+1, end);
    }
}


var arr = [1, 3, 4, 6, 7, 9, 10];
console.log(binary_search(arr, 5, 0, arr.length-1));
console.log(binary_search(arr, 9, 0, arr.length-1));

如果问题有一个小小的变化,不再是查找某个具体数值的位置,而是查找第一个比某个数值大的位置,那么该如何处理呢?比如,找到第一个比5大的数的位置。

方法依然是二分查找,需要对逻辑稍作修改,当arr[middle]小于等于target时,我们需要判断arr[middle+1]和 target之间的关系,如果arr[middle+1]>target,返回middle+1,反之,继续去右侧查找,示例代码

// 找到第一个比target大的数的位置
function binary_search_bigger(arr, target, start, end) {
    if(arr[start]>target){
        return start;
    }

    if (start > end) {
        return -1;    //表示找不到
    }

    var middle = Math.floor((start+end)/2);
    if(arr[middle]<=target){
        if(arr[middle+1]> target){
            return middle+1;
        }
        return binary_search_bigger(arr, target, middle+1, end);
    }else{
        return binary_search_bigger(arr, target, start, middle-1);
    }
}

var arr = [1, 3, 4, 6, 7, 9, 10];
console.log(binary_search_bigger(arr, 5, 0, arr.length-1));
console.log(binary_search_bigger(arr, 0, 0, arr.length-1));

1.4 递归

前面所讲的算法,都使用到了递归。递归的思想,精髓之处在于甩锅,你做不到的事情,让别人去做,等别人做完了,你在别人的基础上继续做。

甩锅一共分为四步:

  1. 明确函数的功能,既然是先让别人去做,那你得清楚的告诉他做什么
  2. 正式甩锅,进行递归调用
  3. 根据别人的结果,计算自己的结果
  4. 找到递归的终止条件
    锅是不能一直甩下去的,否则,就变成了死循环,终止条件,就是那个无需甩锅也能解决问题的条件。应用这四步,咱们来解决汉诺塔问题。

下图是一个汉诺塔结构图

要求是将A上的圆盘全部移动到C上,一次只能移动一个圆盘,而且要保证大圆盘不能放在小圆盘的上面。
我们在思考问题时,会不自觉的在脑海里模拟过程,希望能从过程中提取抽象出逻辑,你可以先考虑只有一个盘子的情况,再考虑两个盘子的情况,接着考虑三个盘子的情况,但是随着盘子的增加,你会发现,这个过程并不是一个可以随着盘子数量增加而有规律扩展的,这时,就不要再用这种思维来考虑问题,尝试用递归的思维来考虑。

首先确定目标: 将n个圆盘从A移动到C上

  1. 先将n-1个圆盘从A上借助C移动到B上
  2. 将A上最后一个圆盘移动到C上
  3. 最后借助A将B上的n-1个圆盘移动到C上

经过上面的三步操作,不就完成了整个任务了么,可是有人会提出质疑,如何完成第一步,将n-1个圆盘动到B上?如果你问我这个问题,我会告诉你这样来思考:

目标是将n-1个圆盘从A上移动到B上

  1. 先将n-2个圆盘从A上借助B移动到C上
  2. 将A上最后一个圆盘移动到B上
  3. 最后借助A将C上的n-2个圆盘移动到B上

递归思想,从不正面回答问题,而是将问题的解决推给下一个问题,并且相信,问题总会有解决的时候,当遇到终止条件时,问题得以解决,那么之前的那些层层推理就都有了答案。

结合递归的四个步骤,来实现这个递归解法。
第一步:
那么为了完成递归,需要先定义一个函数,并明确函数的功能,函数定义如下

// pillar_A上有n个圆盘,现在要将圆盘移动到pillar_C上,借助
function hanoi(n, pillar_A, pillar_B, pillar_C){
    
}

函数有四个参数,第1个参数是指圆盘的个数,第2个参数是圆盘目前所在的位置,第4个参数是圆盘的要移动的目的地,第3个参数是移动过程中要借助的柱子。
第二步和第三步:
甩锅,根据别人的结果计算自己的结果

// pillar_A上有n个圆盘,现在要将圆盘移动到pillar_C上,借助pillar_B
function hanoi(n, pillar_A, pillar_B, pillar_C){
    // 借助pillar_C,将A上n-1个圆盘移动到pillar_B
    hanoi(n-1, pillar_A, pillar_C, pillar_B);
    // 将A上最后一个圆盘移动到pillar_C
    console.log("move: "+ pillar_A + "---->" + pillar_C);
    // 借助pillar_A, 将B上n-1个圆盘移动到pillar_C
    hanoi(n-1, pillar_B, pillar_A, pillar_C);
}

第四步:
找到终止条件,n是圆盘的数量,那n必然是大于等于1的,当n等于1的时候,不需要递归调用了,直接从pillar_A移动到pillar_C上就可以了

// pillar_A上有n个圆盘,现在要将圆盘移动到pillar_C上,借助pillar_B
function hanoi(n, pillar_A, pillar_B, pillar_C){
    if(n==1){
        console.log("move: "+ pillar_A + "---->" + pillar_C);
    }
    else{
        // 借助pillar_C,将A上n-1个圆盘移动到pillar_B
        hanoi(n-1, pillar_A, pillar_C, pillar_B);
        // 将A上最后一个圆盘移动到pillar_C
        console.log("move: "+ pillar_A + "---->" + pillar_C);
        // 借助pillar_A, 将B上n-1个圆盘移动到pillar_C
        hanoi(n-1, pillar_B, pillar_A, pillar_C);
    }
}

hanoi(3, "A", "B", "C");

2. 桶排序

桶排序的思想是将待排序序列划分成n个等长的子区间,这些子区间也称为箱,将各个元素根据自己所属的区间放入相应的桶中,如果待排序序列是均匀分布的,那么每个桶中的元素也是均匀的,只需要将每个桶内的元素排好序,再依次收集这些桶中的元素,就得到了最终的有序序列,仍然以arr = [7, 2, 8, 1, 4, 6, 9, 3]为例,数据范围是0到10之间的数据,我们来划分四个桶。

示例代码如下:

const quick_sort = require("./quick_sort.js");

function bucket_sort(arr){
    var sort_arr = []
    var buckets = new Array(4);
    for(var i=0;i<buckets.length;i++){
        buckets[i] = [];
    }
    // 放入对应的桶里
    for(var i=0;i<arr.length;i++){
        var index = Math.floor(arr[i]/3);
        buckets[index].push(arr[i])
    }
    // 对每一个桶进行排序
    for(var i=0;i<buckets.length;i++){
        quick_sort.quick_sort(buckets[i]);
    }

    // 搜集桶里的数据
    for(var i=0;i<buckets.length;i++){
        for(var j=0;j<buckets[i].length;j++){
            sort_arr.push(buckets[i][j]);
        }
    }
    return sort_arr;
};

var arr = [7, 2, 8, 1, 4, 6, 9, 3];
sort_arr = bucket_sort(arr);
console.log(sort_arr);

确定数据范围是0到10,划分为4个桶,10/4=2.5,实际使用时向上取整,除数取3。

在讲hashtable的时候,解决冲突采用开散列法,其实也是分桶的一种方法,当有冲突的时候,冲突的数据放在一个数组里,这其实就是一个桶。

2. 求指定距离内的点

平面上有以下一组点

var points = [
    [1.24, 2.56],  // 1.24是x坐标,2.56是y坐标
    [2.47, 5.84],
    [6.27, 1.46],
    [9.32, 4.98],
    [5.21, 5.23],
    [4.23, 1.23],
    [6.29, 3.67],
    [4.23, 8.34],
    [3.21, 4.68],
    [2.61, 4.23],
    [4.78, 7.35],
    [8.34, 2.57],
    [7.32, 3.58],
    [0.32, 3.94]
];

x,y坐标的范围是[0,10],现在请你设计一种数据结构,能够存储这些坐标点,并提供一个方法search(x, y, dis),传入一个(x,y)坐标,寻找这些点中与它的距离小于等于dis的点,计算连个坐标之间距离的函数已经给出

    var get_dis = function(x1, y1, x2, y2){
        return Math.sqrt(Math.pow(Math.abs(x1-x2),2) + Math.pow(Math.abs(y1-y2), 2));
    };
2.1 思路分析

这样一组数据,存储不是什么问题,哪怕直接使用数组也没问题,关键是要实现search方法,如果是使用数组存储,执行search方法时,就需要遍历数组中的每一个点进行计算,那么最终,可以得到想要的结果。

但如果数据量数以万计,每一次执行search的时候,都需要遍历一遍数组,这样的查询效率是没有办法接受的,因此,必须用一种能提高搜索效率的数据结构来存储数据。

我们采用二维数组map[i][j]来存储这些数据,map[i][j]=[],存储的仍然是一个数组,根据这些点的x,y决定将其放入到哪个桶中,例如[7.32, 3.58]放入到map[7][3]中,这样,这些点就划分到了不同的桶中。

在search的时候,首先根据传入的x,y坐标以及dis范围,确定要遍历的桶的范围,对于x坐标来说,需要遍历的范围是[x-dis, x+dis],对于y坐标来说,需要遍历的范围是[y-dis,y+dis],这样,就缩小了遍历的范围,而不需要像前面那样遍历整个数组,遍历这些桶内的坐标,计算距离,符合要求的点放入到数组中返回。
示例代码:

var points = [
    [1.24, 2.56],
    [2.47, 5.84],
    [6.27, 1.46],
    [9.32, 4.98],
    [5.21, 5.23],
    [4.23, 1.23],
    [6.29, 3.67],
    [4.23, 8.34],
    [3.21, 4.68],
    [2.61, 4.23],
    [4.78, 7.35],
    [8.34, 2.57],
    [7.32, 3.58],
    [0.32, 3.94]
];

var Point = function(x, y){
    this.x = x;
    this.y = y;
};

function MyMap(){
    var map = [];
    for(var i =0;i<10;i++){
        map.push(new Array(10));
    }

    for(var i =0;i<10;i++){
        for(var j =0;j<10;j++){
            map[i][j]= [];
        }
    }

    this.add_point = function(x, y){
        var point = new Point(x, y);
        var index_1 = Math.floor(x);
        var index_2 = Math.floor(y);
        map[index_1][index_2].push(point);
    };

    var get_dis = function(x1, y1, x2, y2){
        return Math.sqrt(Math.pow(Math.abs(x1-x2),2) + Math.pow(Math.abs(y1-y2), 2));
    };

    var get_index = function(index){
        if(index<0){
            return 0;
        }
        if(index>=10){
            return 9;
        }
        return index;
    };

    // 寻找距离(x,y)在dis以内的所有点
    this.search = function(x, y, dis){
        var point_arr = []
        // 缩小计算范围
        var x_start = get_index(Math.floor(x-dis));
        var x_end = get_index(Math.floor(x+dis));
        var y_start = get_index(Math.floor(y-dis));
        var y_end = get_index(Math.floor((y+dis)));
        for(var i=x_start; i<=x_end;i++){
            for(var j=y_start;j<=y_end;j++){
                var points = map[i][j];
                for(var k=0;k<points.length;k++){
                    if(get_dis(x,y, points[k].x, points[k].y)<=dis){
                        point_arr.push(points[k]);
                    }
                }
            }
        }

        return point_arr;
    }
};

var map = new MyMap();
for(var i= 0;i< points.length;i++){
    map.add_point(points[i][0], points[i][1]);
}

var points = map.search(3, 3, 2);
for(var i=0;i<points.length;i++){
    console.log(points[i]);
}

希尔排序:

大量的排序算法的时间复杂度为O(N^ 2),我们设想一下, 子啊插入排序执行到一半的时候, 标识符左边的这部分数据是排好序的, 而标记右边的数据是没有排序的。假设我们一个很小的数据相在靠近右端的位置上,这里本来应该是较大的数据项的位置. 把这个小数据项移动到左边的正确位置,所有的中间数据项。比如我们下面的数字, 81, 94, 11, 96, 12, 35, 17, 95, 28, 58, 41, 75, 15。我们让数组间隔为 5 ,进行分隔,进行排序。(35, 81), (94, 17), (11, 95), (96, 28), (12, 58), (35, 41), (17, 75), (95, 15)。我们在让间隔设置为3, (35, 28, 75, 58, 95), (17, 12, 15, 81),(11, 41, 96, 94)。当间隔为1 , 即为正确的插入排序。初始化,将初始话的间隔为N/2。Hibbard 增量序列, 增量的算法为 2 * k -1, 也就是 1, 3, 5, 7 , 9,奇数的增量 , 最坏的复杂度O(N^ 3/ 2) 平均复杂度为(N^ 5 / 4)。这种增量最坏的复杂度为 O(N ^ 4/3), 平均复杂度为O(N ^ 7 / 6)。代码逻辑, 首先按照特定的间隔,将数据进行分类, 相互比较进行排序, 相同的间隔排序排好后, 再不停的缩小gap, 再排序。