JavaScript 排序算法

1,092 阅读18分钟

原文链接

前言

对计算机中存储的数据执行的两种最常见操作是 排序检索,自从计算机产业诞生以来便是如此。这也意味着排序和检索在计算机科学中是被研究得最多的操作。前面章节提到的许多数据结构,都对排序和查找算法进行了专门的设计,以使对其中的数据进行操作时更简洁高效。(本文最后提供动画演示链接)

定义

排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。分内部排序外部排序,若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序。反之,若参加排序的记录数量很大,整个序列的排序过程不可能在内存中完成,则称此类排序问题为外部排序。内部排序的过程是一个逐步扩大记录的有序序列长度的过程。本章内容只讨论内部排序。

基本排序算法

基本排序算法其核心思想是指对一组数据按照一定的顺序重新排列。重新排列时用到的技术是一组嵌套的 for循环。其中外循环会遍历数组的每一项,内循环则用于比较元素。这些算法非常逼真地模拟了人类在现实生活中对数据的排序,例如纸牌玩家在处理手中的牌时对纸牌进行排序,或者教师按照字母顺序或者分数对试卷进行排序。

冒泡排序

为什么先讲冒泡排序呢?因为它是出现在很多C语言教材书中,介绍的第一种排序算法,也是最慢的排序算法之一。冒泡排序在实现上是非常容易的,但是比较难理解(至少对于很多初学者而言)。

之所以叫冒泡排序是因为使用这种排序算法排序时,数据值会像气泡一样从数组的一端漂浮到另一端。假设正在将一组数字按照升序排列,较大的值会浮动到数组的右侧,而较小的值则会浮动到数组的左侧。之所以会产生这种现象是因为算法会多次在数组中移动,比 较相邻的数据,当左侧值大于右侧值时将它们进行互换。

[完整代码地址]

/**
 * 冒泡排序
 * @param {array}  arr   待排序数组
 * @param {string} type  排序类型:asc / desc
 */
function bubble (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 标识是否有交换
    var hasExchange = false;
    // 外层循环遍历数组每一个元素
    for (var i = 0; i < len - 1; i++) {
        // 重置
        hasExchange = false;
        // 内层循环用于比较元素
        for (var j = 0; j < len - i - 1; j++) {
            if ((!type || type === 'asc') && arr[j] > arr[j + 1]) { // 顺序排序
                hasExchange = true;
                swap(arr, j, j+1);
            } else if (type === 'desc' && arr[j] < arr[j + 1]) {    // 逆序排序
                hasExchange = true;
                swap(arr, j, j+1);
            }
        }
        // 如果没有任何交换,直接跳出循环
        if (!hasExchange) {
            break;
        }
    }
}

/**
 * 交换数组里面的两个元素
 * @param {array} arr 待交换元素的数组
 * @param {number} i  待交换元素下标1
 * @param {number} j  待交换元素下标2
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

注意: 这里的冒泡排序是较简单版本复杂一点,主要是做了功能拓展和性能优化。

选择排序

选择排序从数组的开头开始,将第一个元素和其他元素进行比较。检查完所有元素后,最小的元素会被放到数组的第一个位置,然后算法会从第二个位置继续。这个过程一直进行,当进行到数组的倒数第二个位置时,所有的数据便 完成了排序。

选择排序会用到嵌套循环。外循环下标从数组的第一个元素移动到倒数第二个元素;内循环下标从第二个数组元素移动到最后一个元素,查找比当前外循环所指向的元素小的元素。每次内循环迭代后,数组中最小的值都会被赋值到合适的位置。

[完整代码地址]

/**
 * 选择排序
 * @param {array}  arr 
 * @param {string} type  asc / desc
 */
function select (arr, type) {
    // 获取数组长度
    var len = arr.length;
    // 外循环遍历数组元素
    for (var i = 0; i < len - 1; i++) {
        // 临时最小值下标
        let idx = i;
        // 内循环比较数组元素大小
        for (var j = i + 1; j < len; j++) {
            if ((!type || type === 'asc') && arr[idx] > arr[j]) { // 顺序
                idx = j;
            } else if (type === 'desc' && arr[idx] < arr[j]) {    // 逆序
                idx = j
            }
        }
        // 如果下标有变
        if (idx !== i) {
            swap(arr, idx, i);      
        }
    }
}

/**
 * 交换数组里面的两个元素
 * @param {array} arr 待交换元素的数组
 * @param {number} i  待交换元素下标1
 * @param {number} j  待交换元素下标2
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

注意: 同样的,这里的选择排序也是较简单版本复杂一点,主要也是做了功能拓展和性能优化。

插入排序

插入排序类似于人类按数字或字母顺序对数据进行排序。例如玩扑克牌的时候,我们会将第二张牌跟第一张牌比较,若比第一张牌小,则移动到第一张牌前面;然后第三张牌跟第二张牌比较,若第三张牌比第二张小,则再与第一张牌比较,若还比第一张牌小,则放在第一张牌的前面,否则,放在第二张牌前面;后面的牌以此类推。

插入排序有两个循环。外循环将数组元素挨个移动,而内循环则对外循环中选中的元素及 它后面的那个元素进行比较。如果外循环中选中的元素比内循环中选中的元素小,那么数组元素会向右移动,为内循环中的这个元素腾出位置。

[完整代码地址]

/**
 * 插入排序
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function insert (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 外循环记录已排序最后下标
    for (var i = 1; i < len; i++) {
        // 临时变量
        var temp = arr[i];
        // 内循环进行数组元素比较
        for (var j = i; j > 0; j--) {
            if ((!type || type === 'asc') && arr[j - 1] > temp) { // 顺序排序
                // 往后挪一位
                arr[j] = arr[j - 1];
                continue;
            } else if (type === 'desc' && arr[j - 1] < temp) {    // 逆序排序
                // 往后挪一位
                arr[j] = arr[j - 1];
                continue;
            }
            break;
        }
        // 放入合适的位置
        arr[j] = temp;
    }
}

基本排序算法性能对比

虽说,这三种排序算法的复杂度非常相似,都是O(n^2),从理论上来说,它们的执行效率也应该差不多。要确定这三种算法的性能差异,我们可以使用一个非正式的计时系统来比较它们对数据集合进行排序所花费的时间。能够对算法进行计时非常重要,因为,对 100 个或 1000 个元素进 行排序时,你看不出这些排序算法的差异。但是如果对上百万个元素进行排序,这些排序算法之间可能存在巨大的不同。

在比较这三个基本排序算法之前,我们需要一个方法,能生成指定长度的、随机初始化元素的数值数组,具体代码如下:

[完整代码地址]

/**
 * 随机生成指定长度的数值数组
 * @param {number} len 数组长度
 */
function RandomArray (len) {
    var res = [];
    for (var i = 0; i < len; i++) {
        res.push(parseFloat((Math.random() * len).toFixed(2)));
    }
    return res;
}

测试实例代码

var arr = [],
    range = 1000;
arr = RandomArray(range);
var start = new Date().getTime();
bubble(arr);
var end = new Date().getTime();
console.log('冒泡排序 ', end - start, ' 毫秒');
arr = RandomArray(range);
var start = new Date().getTime();
select(arr);
var end = new Date().getTime();
console.log('选择排序 ', end - start, ' 毫秒');
arr = RandomArray(range);
var start = new Date().getTime();
insert(arr);
var end = new Date().getTime();
console.log('插入排序 ', end - start, ' 毫秒');

1000数量级(3次)运行结果

// 第一次
冒泡排序  4  毫秒
选择排序  4  毫秒
插入排序  2  毫秒
// 第二次
冒泡排序  3  毫秒
选择排序  3  毫秒
插入排序  1  毫秒
// 第三次
冒泡排序  2  毫秒
选择排序  3  毫秒
插入排序  1  毫秒

从这三次运行结果来看,插入排序性能略胜一筹,而冒泡排序和选择排序性能相当。

10000数量级(3次)运行结果

// 第一次
冒泡排序  225  毫秒
选择排序  154  毫秒
插入排序  49  毫秒
// 第二次
冒泡排序  238  毫秒
选择排序  165  毫秒
插入排序  39  毫秒
// 第三次
冒泡排序  237  毫秒
选择排序  162  毫秒
插入排序  37  毫秒

从上面运行结果可以看出,当排序数量级到达 10000 级别的时候,三种排序性能差距也在增大。可以看出,插入排序性能最佳,其次是选择排序,最慢的是冒泡排序。

100000数量级(3次)运行结果

// 第一次
冒泡排序  25995  毫秒
选择排序  17455  毫秒
插入排序  3626  毫秒
// 第二次
冒泡排序  24643  毫秒
选择排序  16849  毫秒
插入排序  3636  毫秒
// 第三次
冒泡排序  25384  毫秒
选择排序  16940  毫秒
插入排序  3873  毫秒

同理的,当排序数量级到达 100000 数量级的时候,插入排序消耗时间与其他两种排序算法不在一个数量级上。而三次比较下,冒泡排序需要花费的平均时间竟然高达25秒左右,而插入排序花费的平均时间只要3.7秒左右。

高级排序算法

在实际应用中,业务排序数量级可能到达几百上千万的级别,显然,基本排序算法并不能胜任这些场景。那么,下面我们江介绍高级数据排序算法,它们通常被认为是处理大型数据集的最高效排序算法,它们处理的数据集可以达到上百万个元素,而不仅仅是几百个或者几千个。

希尔排序

希尔排序是以它的创造者(Donald Shell) 命名的。这个算法在插入排序的基础上做了很大的改善。希尔排序的核心理念与插入排序不同,它会首先比较距离较远的元素,而非相邻的元素。和简单地比较相邻元素相比,使用这种方案可以使离正确位置很远的元素更快地回到合适的位置。当开始用这个算法遍历 数据集时,所有元素之间的距离会不断减小,直到处理到数据集的末尾,这时算法比较的就是相邻元素了。

希尔排序的工作原理是,通过定义一个间隔序列来表示在排序过程中进行比较的元素之间有多远的间隔。我们可以动态定义间隔序列,不过对于大部分的实际应用场景,算法要用到的间隔序列可以提前定义好。有一些公开定义的间隔序列,使用它们会得到不同的结果。在这里我们用到了 Marcin Ciura 在他 2001 年发表的论文“Best Increments for the Average Case of Shell Sort”中定义的间隔序列。这个间隔序列分别是:701, 301, 132, 57, 23, 10, 4, 1。

[完整代码地址]

/**
 * 希尔排序
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function shell (arr, type) {
    // 声明间隔
    var gaps = [5, 3, 1];
    // 记录数组长度
    var len = arr.length;
    // 最外层循环遍历间隔数组
    for (var i = 0, len0 = gaps.length; i < len0; i++) {
        // 临时变量
        var gap = gaps[i];
        // 第二层循环按间隔遍历数组
        for (var j = gap; j < len; j++) {
            // 临时变量
            var temp = arr[j];
            // 第三层循环比较数组元素大小
            for (var k = j; k >= gap; k -= gap) {
                if ((!type || type === 'asc') && arr[k - gap] > temp) { // 顺序排序
                    // 往后挪gap位
                    arr[k] = arr[k - gap];
                    continue;
                } else if (type === 'desc' && arr[k - gap] < temp) {    // 逆序排序
                    // 往后挪gap位
                    arr[k] = arr[k - gap];
                    continue;
                }
                break;
            }
            arr[k] = temp;
        }
    }
}

归并排序

归并排序的命名来自它的实现原理:把一系列排好序的子序列合并成一个大的完整有序序列。从理论上讲,这个算法很容易实现。我们需要两个排好序的子数组,然后通过比较数据大小,先从最小的数据开始插入,最后合并得到第三个数组。然而,在实际情况中,归并排序还有一些问题,当我们用这个算法对一个很大的数据集进行排序时,我们需要相当大的空间来合并存储两个子数组。就现在来讲,内存不那么昂贵,空间不是问题,因此,我们需要关注的是它和其他排序算法的执行效率。

递归实现

[完整代码地址]

/**
 * 归并排序 - 递归实现
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function merge_recursive (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 归并排序
    merge_sort(arr, 0, len - 1, type);
}

/**
 * 归并排序
 * @param {array}  arr  数组
 * @param {number} low  最小下标
 * @param {number} high 最大下标
 * @param {string} type 排序类型
 */
function merge_sort(arr, low, high, type) {
    if (low < high) {
        var mid = Math.floor((low + high) / 2);
        merge_sort(arr, low, mid, type);
        merge_sort(arr, mid + 1, high, type);
        merge(arr, low, mid, high, type);
    }
}

/**
 * 按大小顺序合并数组
 * @param {array}  arr  数组
 * @param {number} low  最小下标
 * @param {number} mid  中间下标
 * @param {number} high 最大下标
 * @param {string} type 排序类型
 */
function merge (arr, low, mid, high, type) {
    var left = low,
        right = mid + 1,
        t = 0;
    var tempArr = [];
    while (left <= mid && right <= high) {
        if (!type || type === 'asc') { //  顺序排序
            if (arr[left] < arr[right]) {
                tempArr[t++] = arr[left++];
            } else {
                tempArr[t++] = arr[right++];
            }
        } else if (type === 'desc') {  // 逆序排序
            if (arr[left] > arr[right]) {
                tempArr[t++] = arr[left++];
            } else {
                tempArr[t++] = arr[right++];
            }
        }
        
    }
    while (left <= mid) {
        tempArr[t++] = arr[left++];
    }
    while (right <= high) {
        tempArr[t++] = arr[right++];
    }
    while (t > 0) {
        arr[high--] = tempArr[--t];
    }
}

注意:递归方式的归并排序是自顶向下的,而下面要介绍的迭代方式,是自底向上的。

迭代实现

[完整代码地址]

/**
 * 归并排序 - 迭代实现
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function merge_iteration (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 若数组为空或者只有一个元素
    if (len < 2) {
        return;
    }
    // 归并跨度
    var span = 2;
    // 归并
    while (span < len) {
        // 遍历数组
        var i = 0;
        while (i + span <= len) {
            var mid = i + Math.floor(span / 2) - 1;
            merge(arr, i, mid, i + span - 1, type)
            i += span;
        }
        // 遗漏修正
        if (i < len) {
            var temp = i + Math.floor(span / 2) - 1,
                mid = Math.min(temp, len - 1);
            merge(arr, i, mid, len - 1, type);
        }
        // 归并跨度翻倍
        span *= 2;
    }
    // 顶层修正
    merge(arr, 0, span / 2 - 1, len - 1, type);
}


/**
 * 按大小顺序合并数组
 * @param {array}  arr  数组
 * @param {number} low  最小下标
 * @param {number} mid  中间下标
 * @param {number} high 最大下标
 * @param {string} type 排序类型
 */
function merge (arr, low, mid, high, type) {
    var left = low,
        right = mid + 1,
        t = 0;
    var tempArr = [];
    while (left <= mid && right <= high) {
        if (!type || type === 'asc') { //  顺序排序
            if (arr[left] < arr[right]) {
                tempArr[t++] = arr[left++];
            } else {
                tempArr[t++] = arr[right++];
            }
        } else if (type === 'desc') {  // 逆序排序
            if (arr[left] > arr[right]) {
                tempArr[t++] = arr[left++];
            } else {
                tempArr[t++] = arr[right++];
            }
        }
        
    }
    while (left <= mid) {
        tempArr[t++] = arr[left++];
    }
    while (right <= high) {
        tempArr[t++] = arr[right++];
    }
    while (t > 0) {
        arr[high--] = tempArr[--t];
    }
}

快速排序

快速排序是处理大数据集最快的排序算法之一。它是一种分而治之的算法,通过递归的方式将数据依次分解为包含较小元素和较大元素的不同子序列。该算法不断重复这个步骤直 到所有数据都是有序的。

这个算法首先要在列表中选择一个元素作为基准值(pivot)。数据排序围绕基准值进行, 将列表中小于基准值的元素移它的左边,将大于基准值的元素移到它的右边。

递归实现1

[完整代码地址]

/**
 * 快速排序 - 递归实现1
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function quick_recursive1 (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 若果数组长度为空
    if (len === 0) {
        return [];
    }
    var left = [],
        right = [];
    var pivot = arr[0];
    for (var i = 0; i < len; i++) {
        if (arr[i] < pivot) {
            if (!type || type === 'asc') { // 顺序排序
                left.push(arr[i]);
            } else if (type === 'desc') {
                right.push(arr[i]);        // 逆序排序
            }
        } else if (arr[i] > pivot) {
            if (!type || type === 'asc') { // 顺序排序
                right.push(arr[i]);
            } else if (type === 'desc') {
                left.push(arr[i]);        // 逆序排序
            }
        }
    }
    return quick_recursive1(left, type).concat(pivot, quick_recursive1(right, type));
}

这种方法很好理解,但是损耗了一定的性能,因为它的实现需要额外的存储空间。

递归实现2

[完整代码地址]

/**
 * 快速排序 - 递归实现2
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function quick_recursive2 (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 快速排序
    quick_sort (arr, 0, len - 1, type);
}

/**
 * 快速排序
 * @param {array}  arr  待排序数组
 * @param {number} low  排序最小下标
 * @param {number} high 排序最大下标
 * @param {string} type 排序类型
 */
function quick_sort (arr, low, high, type) {
    if (low < high) {
        var idx = partition(arr, low, high, type);
        quick_sort(arr, low, idx - 1, type);
        quick_sort(arr, idx + 1, high, type);
    }
}

/**
 * 将pivot放在适合的位置
 * @param {array}  arr  待排序数组
 * @param {number} low  排序最小下标
 * @param {number} high 排序最大下标
 * @param {string} type 排序类型
 */
function partition (arr, low, high, type) {
    var pivot = arr[low],
        tmpIdx = low;
    for (var i = low + 1; i <= high; i++) {
        if ((!type || type === 'asc') && arr[i] < pivot) { // 顺序排序
            tmpIdx++;
            swap(arr, tmpIdx, i);
        } else if (type === 'desc' && arr[i] > pivot) {    // 逆序排序
            tmpIdx++;
            swap(arr, tmpIdx, i);
        } 
    }
    swap(arr, low, tmpIdx);
    return tmpIdx;
}

/**
 * 交换数组里面的两个元素
 * @param {array} arr 待交换元素的数组
 * @param {number} i  待交换元素下标1
 * @param {number} j  待交换元素下标2
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

这种递归方法,虽然较第一种复杂,但是它不需要额外的空间消耗。

迭代实现

[完整代码地址]

/**
 * 快速排序 - 迭代实现
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function quick_iteration (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 栈、栈顶指针
    var st = [], top = -1;
    // 入栈
    st[++top] = {
        low: 0,
        high: len
    };
    // 遍历
    while (top > -1) {
        // 取栈栈顶元素
        var el = st[top--];
        if (el.low < el.high) {
            var idx = partition(arr, el.low, el.high, type);
            st[++top] = { low: el.low, high: idx - 1 };
            st[++top] = { low: idx + 1, high: el.high };
        }
    }
}

/**
 * 将pivot放在适合的位置
 * @param {array}  arr  待排序数组
 * @param {number} low  排序最小下标
 * @param {number} high 排序最大下标
 * @param {string} type 排序类型
 */
function partition (arr, low, high, type) {
    var pivot = arr[low],
        tmpIdx = low;
    for (var i = low + 1; i <= high; i++) {
        if ((!type || type === 'asc') && arr[i] < pivot) { // 顺序排序
            tmpIdx++;
            swap(arr, tmpIdx, i);
        } else if (type === 'desc' && arr[i] > pivot) {    // 逆序排序
            tmpIdx++;
            swap(arr, tmpIdx, i);
        } 
    }
    swap(arr, low, tmpIdx);
    return tmpIdx;
}

/**
 * 交换数组里面的两个元素
 * @param {array} arr 待交换元素的数组
 * @param {number} i  待交换元素下标1
 * @param {number} j  待交换元素下标2
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

这种实现方式只是将递归实现2递归部分,换成栈的实现,核心思想都一样。

高级排序算法性能对比

本章最后,还是要测试一下上述三种高级排序算法的性能对比,在数量量级较大的时候,是否较其他基本排序算法,会表现得更好。值得注意的是,鉴于JavaScript在大数量级的情况下使用递归方式,很容易导致调用栈溢出,下面测试的高级排序算法,统一都是使用迭代实现的版本。

1000数量级(3次)运行结果

// 第一次
希尔排序  0  毫秒
归并排序  0  毫秒
快速排序  0  毫秒
// 第二次
希尔排序  0  毫秒
归并排序  1  毫秒
快速排序  0  毫秒
// 第三次
希尔排序  2  毫秒
归并排序  0  毫秒
快速排序  0  毫秒

可以看出,三种高级排序算法在1000数量级下,几乎不损耗时间。

10000数量级(3次)运行结果

// 第一次
希尔排序  10  毫秒
归并排序  2  毫秒
快速排序  2  毫秒
// 第二次
希尔排序  11  毫秒
归并排序  2 毫秒
快速排序  1  毫秒
// 第三次
希尔排序  10  毫秒
归并排序  3  毫秒
快速排序  2  毫秒

100000数量级(3次)运行结果

// 第一次
希尔排序  929  毫秒
归并排序  20  毫秒
快速排序  18  毫秒
// 第二次
希尔排序  940  毫秒
归并排序  21 毫秒
快速排序  20  毫秒
// 第三次
希尔排序  915  毫秒
归并排序  21  毫秒
快速排序  23  毫秒

我想,不用我多说,高级排序算法在数量级巨大的情况下,性能表现是非常优异的。(更大的数量级测试,有兴趣的同学可以自行测试)

参考链接