JavaScript 数据结构与算法(五) 排序算法

本文参考文献:http://ahuntsun.top/navitem/algorithm/theory/notes/11.html
配套视频教程:https://www.bilibili.com/video/BV1r7411n7Pw?p=1&spm_id_from=pageDriver

排序算法

大O表示法

  • 在计算机中采用粗略的度量来描述计算机算法的效率,这种方法被称为大O表示法
  • 在数据项个数发生改变时,算法的效率也会跟着改变。所以说算法A比算法B快两倍,这样的比较是没有意义的。
  • 因此我们通常使用算法的速度随着数据量的变化会如何变化的方式来表示算法的效率,大O表示法就是方式之一。
常见的表示形式
形式名称
O(1)常数
O(log(n))对数
O(n)线性
O(nlog(n))线性和对数乘积
O(n2)平方
O(2n)指数

不同的表示形式的时间复杂度
在这里插入图片描述
图中O(1)的曲线与X轴重合

可以看到效率从大到小分别是:O(1)> O(logn)> O(n)> O(nlog(n))> O(n2)> O(2n)。

推导规则

如何根据一个算法来推导其效率,并用大O表示法展示?

  • 规则一:用常量1取代运行时间中所有的加法常量。如7 + 8 = 15,用1表示运算结果15,大O表示法表示为O(1)
  • 规则二:运算中只保留最高阶项。如N3+3N+1,用大O表示法表示为:O(N3);
  • 规则三:若最高阶项的常数不为1,可将其省略。如4N2+2N+5,大O表示法表示为:O(N2);

排序算法

常见的排序算法有很多种,本文主要实现以下几种:

简单排序:冒泡排序、选择排序、插入排序;
高级排序:希尔排序、快速排序;

基本类的封装

我们创建一个列表类 ArrayList,在这个类中我们实现五种排序方法以及插入方法和toString方法。

class ArrayList {
    constructor() {
        this.array = []	// 用于存放数组
    }
    insert(item) {
        this.array.push(item)
    }
    toString() {
        return this.array.join('-')
    }
}

冒泡排序

核心思想:
从第一个元素开始,两两判断大小,将较大的元素放到后面,每一次循环可以排出一个最大的值,进行多次循环后即完成了排序。因为每次循环都会排出一个元素,纵向上看就像是每次冒出一个泡泡,就叫冒泡法

思路:

  • 对未排序的各元素从头到尾依次比较相邻的两个元素大小关系;
  • 如果左边元素比右边大,则将两元素交换位置。如果左边元素比右边小,则不交换位置;
  • 比较完成后,向右移动一位,继续比较两个元素,最后比较 length - 2 和 length - 1这最后两个数据;
  • 当到达最右端时(即比较完最后两个元素时),最大的元素一定被放在了最右边;
  • 按照这个思路,再次从最左端重新开始时,只需要走到倒数第二个位置即可,最后一个元素不需要再比较;

在这里插入图片描述

实现思路

共需两层循环

  • 外层循环控制需冒泡的次数:(j从0开始)
    • 第一次:j = length - 1,比较到倒数第一个位置 ;
    • 第二次:j = length - 2,比较到倒数第二个位置 ;
  • 内层循环控制每次冒泡需要比较的次数:
    • 第一次比较: i = 0,比较 0 和 1 位置的两个数据;
    • 最后一次比较:i = length - 2,比较length - 2和 length - 1两个数据;

详细过程如下图所示:
在这里插入图片描述

代码实现:
bubblesort() {
    for (let i = 0; i < this.array.length - 1; i++) {
        for (let j = 0; j < this.array.length - 1 - i; j++) {
            if (this.array[j] > this.array[j + 1]) {
                let c = this.array[j]
                this.array[j] = this.array[j + 1]
                this.array[j + 1] = c
            }
        }
    }
    return this.array
}
效率
  • 上面所讲的对于7个数据项,比较次数为:6 + 5 + 4 + 3 + 2 + 1;
  • 对于N个数据项,比较次数为:(N - 1) + (N - 2) + (N - 3) + … + 1 = N * (N - 1) / 2;如果两次比较交换一次,那么交换次数为:N * (N - 1) / 4;
  • 使用大O表示法表示比较次数和交换次数分别为:O(N * (N - 1) / 2)O( N * (N - 1) / 4),根据大O表示法的三条规则进行化简,最终结果为O(N^2);

选择排序

该方法的核心思想就是:

  • 以第一个元素开始,向后比较,首先找到数组中最小的元素,并将第一个元素与最小元素的位置互换
  • 再以第二个元素为开始,向后比较,找到除第一个元素外数组中最小的元素,并将其与第二个元素的位置互换
  • 以此类推,直到执行到了:以数组最后第二个元素为开始的循环,此时只需判断最后第二个元素与最后一个元素的大小及位置互换即可

可以判断出,我们共需要执行大的循环 list.length - 1次(交换),执行小的循环的次数(比较)则是越来越少。

选择排序改进了冒泡排序:

  • 将交换次数由O(N2)减小到O(N);
  • 但是比较次数依然是O(N2);

选择排序的思路

  • 选定第一个索引的位置比如0,然后依次和后面位置的每个元素进行比较;
  • 如果后面的元素(假设是索引位置3的元素),小于索引0位置的元素,则交换位置,索引3和索引0的元素互相交换;
  • 经过一轮的比较之后,可以确定一开始指定的索引0位置的元素是最小的;
  • 随后使用同样的方法,以索引1为开始,逐个比较剩下的元素即可,这样的循环共需执行 list.length - 1次;
  • 可以看出选择排序,第一轮会选出最小值,第二轮会选出第二小的值,直到完成排序。
实现思路

两层循环:

  • 外层循环控制指定的索引:
    • 第一次:j = 0,指定第一个元素 ;
    • 最后一次:j = length - 1,指定最后一个元素 ;
  • 内层循环负责将指定索引(i)的元素与剩下(i ~ length - 1)的元素进行比较;
实现代码
selectionSort() {
    // 外层循环,分别以第i个元素为开始位置
    for (let i = 0; i < this.array.length - 1; i++) {
        // c用于存放最小元素的索引位置
        let c = i;
        // 内层循环,第c个和第j + 1个元素比较大小
        for (let j = i; j < this.array.length - 1; j++) {
            // 比较两个元素大小,并将c赋值为较小元素的索引
            if (this.array[c] > this.array[j + 1]) {
                c = j + 1
            }
        }
        // 交换元素位置,把找到的当前循环最小值元素移到 c 位置
        var temp = this.array[c]
        this.array[c] = this.array[i]
        this.array[i] = temp
    }
}
效率
  • 选择排序的比较次数为:N * (N - 1) / 2,用大O表示法表示为:O(N2);
  • 选择排序的交换次数为:(N - 1) / 2,用大O表示法表示为:O(N);
  • 可以看出,选择排序的比较次数和冒泡排序一样,但交换次数更少,所以选择排序的效率高于冒泡排序

插入排序

插入排序是简单排序中效率最高的一种排序方式。

实现思路
  • 插入排序思想的核心是局部有序。某几个元素是桉顺序排列的,就被称为局部有序,一个元素也是局部有序;
  • 首先指定一数据X(从第一个数据开始),并将数据X的左边变成局部有序状态;
  • 随后将X右移一位,再次达到局部有序之后,继续右移一位,重复前面的操作直至X移至最后一个元素。

在这里插入图片描述
动态表示:
在这里插入图片描述

实现代码
insertionSort() {
    // 外层循环:将第一个数据当成局部有序,从第二个数据开始比较,向左侧局部有序块进行插入
    for (let i = 1; i < this.array.length; i++) {
        // 获取 i 位置的元素,将其与左侧元素依次比较
        var temp = this.array[i]
        var j = i
        // 内层循环:如果左侧元素大于当前元素,则将左侧的元素向右移一位
        // 即将temp与左边的局部有序数据依次比较,直到temp找到合适位置
        while (j > 0 && this.array[j - 1] > temp) {
            this.array[j] = this.array[j - 1]
            j--;
        }
        // 当 j - 1位置的元素值比temp值小时,内循环结束
        // 内循环结束后,此时 temp元素应处在j位置
        this.array[j] = temp
    }
}
效率
  • 比较次数:第一趟时,需要的最大次数为1;第二次最大为2;以此类推,最后一趟最大为N-1;所以,插入排序的总比较次数为N * (N - 1) / 2;但是,实际上每趟发现插入点之前,平均只有全体数据项的一半需要进行比较,所以比较次数为:N * (N - 1) / 4;
  • 交换次数:指定第一个数据为X时交换0次,指定第二个数据为X最多需要交换1次,以此类推,指定第N个数据为X时最多需要交换N - 1次,所以一共需要交换N * (N - 1) / 2次,平均交换次数为N * (N - 1) / 4;
  • 虽然用大O表示法表示插入排序的效率也是O(N^2),但是插入排序整体操作次数更少,因此,在简单排序中,插入排序效率最高;

希尔排序

希尔排序是插入排序的一种高效的改进版,效率比插入排序要高。

历史背景

  • 希尔排序按其设计者希尔(Donald Shell)的名字命名,该算法由1959年公布;
  • 希尔算法首次突破了计算机界一直认为的算法的时间复杂度都是O(N2)的大关,为了纪念该算法里程碑式的意义,用Shell来命名该算法;
实现思路

插入排序的问题

  • 假设一个很小的数据项在很靠近右端的位置上,这里本应该是较大的数据项的位置;
  • 将这个小数据项移动到左边的正确位置,所有的中间数据项都必须向右移动一位,操作的次数很多,导致这样效率非常低;
  • 如果通过某种方式,不需要一个个移动所有中间的数据项,就能把较小的数据项移到左边,那么这个算法的执行速度就会有很大的改进。

希尔排序的实现思路

  • 希尔排序主要通过对数据进行分组实现快速排序;
  • 根据设定的增量(gap)将数据分为gap个组(组数等于gap),再在每个分组中进行局部排序;

    假如有数组有10个数据,第1个数据(index=0)为黑色,增量为5。那么第二个为黑色的数据是index=5位置的,第3个数据为黑色的数据是index=10位置的(不存在)。所以黑色的数据每组只有2个,10 / 2 = 5一共可分5组,即组数等于增量gap。

  • 排序之后,减小增量,继续分组,再次进行局部排序,直到增量gap=1为止。随后只需进行微调就可完成数组的排序;

具体过程如下:

  • 排序之前的,储存10个数据的原始数组为:
    在这里插入图片描述
  • 设初始增量gap = length / 2 = 5,即数组被分为了5组,如图所示分别为:[8, 3]、[9, 5]、[1, 4]、[7, 6]、[2, 0]:
    在这里插入图片描述
  • 随后分别在每组中对数据进行局部排序,5组的顺序如图所示,变为:[3, 8]、[5, 9]、[1, 4]、[6, 7]、[0, 2]:
    在这里插入图片描述
  • 然后缩小增量gap = 5 / 2 = 2,即数组被分为了2组,如图所示分别为:[3,1,0,9,7]、[5,6,8,4,2]:
    在这里插入图片描述
  • 随后分别在每组中对数据进行局部排序,两组的顺序如图所示,变为:[0,1,3,7,9]、[2,4,5,6,8]:
    在这里插入图片描述
  • 然后缩小增量gap = 2 / 1 = 1,即数组被分为了1组,如图所示为:[0,2,1,4,3,5,7,6,9,8]:
    在这里插入图片描述
  • 最后只需要对该组数据进行插入排序即可完成整个数组的排序:
    在这里插入图片描述

动态过程为(图中d为增量gap):
在这里插入图片描述

增量gap的选择
  • 原稿中希尔建议的初始间距为N / 2,比如对于N = 100的数组,增量序列为:50,25,12,6,3,1,当不能整除时向下取整。
  • Hibbard增量序列:增量序列算法为:2^k - 1^,即1,3,5,7… …等;这种情况的最坏复杂度为O(N3/2),平均复杂度为O(N5/4)但未被证明;
  • Sedgewcik增量序列:
    在这里插入图片描述
    在本文中使用原稿建议的增量取法,即 N / 2。
实现代码
shellSort() {
    // 获取初始长度
    var length = this.array.length;
    // 获取初始增量
    var gap = Math.floor(length / 2)
    // 第一层循环:使gap不断变小,直至增量为0
    while (gap >= 1) {
        // 第二层循环:以gap为增量,从第gap个元素开始(即从每个分组的第二个开始,第一个元素作为插入排序中的局部有序),
        // 从前往后对每一个分组的第二个变量进行插入排序,再依次往后,对每个分组的第三个、第四个变量进行插入排序,直到全部完成
        for (var i = gap; i < length; i++) {
            // 当前元素作为被比较的元素
            var temp = this.array[i]
            var j = i
            // 第三层循环,找到temp的正确插入位置
            // 每到某一个分组中,就对这个分组进行插入排序(以第j个为被比较元素,即temp)
            // 步长为gap,当前一个元素小于temp 或 j超出数组的头部时终止循环(j - gap < 0)
            while (this.array[j - gap] > temp && j - gap > -1) {
                // 后移
                this.array[j] = this.array[j - gap]
                // 往前搜索
                j = j - gap
            }
            // 将j位置赋值为temp,插入完成
            this.array[j] = temp
        }
        // 增量向下取整
        gap = Math.floor(gap / 2)
    }
}
效率
  • 希尔排序的效率和增量gap十分相关
  • 经过统计,在使用原稿增量时,最坏情况下时间复杂度为O(N2),通常情况下都要好于O(N2)

快速排序

参考:https://segmentfault.com/a/1190000037611587?utm_source=sf-similar-article

迄今为止,最快的算法之一是快速排序(Quicksort)

快速排序用分治策略对给定的列表元素进行排序。这意味着算法将问题分解为子问题,直到子问题变得足够简单可以直接解决为止。

实现思路

工作原理:

  1. 在数组中选择一个元素,这个元素被称为基准(Pivot)。通常把数组中的第一个或最后一个元素作为基准。
  2. 然后,重新排列数组的元素,以使基准左侧的所有元素都小于基准,而右侧的所有元素都大于基准。这一步称为分区。如果一个元素等于基准,那么在哪一侧都无关紧要。
  3. 随后,再针对基准的左侧和右侧分别重复这一过程,直到对数组完成排序。

接下来通过一个例子理解这些步骤。假设有一个含有未排序元素 [7, -2, 4, 1, 6, 5, 0, -4, 2] 的数组。选择最后一个元素作为基准。数组的分解步骤如下图所示:

在这里插入图片描述
分区后,基准元素始终处于数组中的正确位置(即当完全排序后,该基准元素应该在的位置)。

黑色粗体边框的数组表示该特定递归分支结束时的样子(该部分排序完成),最后得到的数组只包含一个元素。

最后可以看到该算法的结果排序,也就是当我们对这个数组的所有分区都完成基准值的寻找和插入后,这个数组也就被排序完成了

分区函数实现

这一算法的主干是**“分区”步骤**。无论用递归还是循环的方法,这个步骤都是一样的。

正是因为这个特点,首先编写数组分区的代码:

partition(arr, start, end) {
    // 取数组的最后一个元素为基准值
    const pivotValue = arr[end]
    // pivotIndex是基准值最后要插入的正确位置,从start开始搜索
    let pivotIndex = start
    // 循环进行元素的交换,以及pivotIndex的搜索
    // 这个循环的本质就是将数组中小于基准值的元素不断往前交换,放到pivotIndex的位置,直到遍历到最后一个元素之前,
    // 此时pivotIndex位置的值应该是数值中第一个大于基准值的元素的位置
    for (let i = start; i < end; i++) {
        // 如果当前值小于基准值
        if (arr[i] < pivotValue) {
            // 将当前值与pivotIndex位置的值进行交换,因为此时pivotIndex的值肯定大于等于基准值(除了i = pivotIndex的情况)
            [arr[i], arr[pivotIndex]] = [arr[pivotIndex], arr[i]]
            // 当前值小于基准值,继续往下搜索
            pivotIndex++
        }
    }
    // 此时arr[pivotIndex]的值肯定大于等于基准值,基准值的正确位置为pivotIndex,交换他们两个的位置
    [arr[pivotIndex], arr[end]] = [arr[end], arr[pivotIndex]]
    // 返回基准值的正确位置
    return pivotIndex
}
  • 代码以最后一个元素为基准,用变量 pivotIndex 来跟踪“中间”位置,这个位置左侧的所有元素都比 pivotValue小,而右侧的元素都比pivotValue大。
  • 最后一步把基准(最后一个元素)与pivotIndex交换。
递归实现

使用递归函数的方式来实现快速排序:

// 利用 递归 实现快速排序
quickSortRecursive(arr, start, end) {
    // 如果起止位置符合条件,证明这个数组已经排序完成
    if (start >= end) return

    // 获取基准值索引位置
    let index = this.partition(arr, start, end)
    // 对左边的分组进行排序
    this.quickSortRecursive(arr, start, index - 1)
    // 对右边的分组进行排序
    this.quickSortRecursive(arr, index + 1, end)
}
  • 在这个函数中首先对数组进行分区,之后对左右两个子数组进行分区。只要这个函数收到一个不为空或有多个元素的数组,则将重复该过程。
  • 空数组和仅包含一个元素的数组被视为已排序。

最后用下面的例子进行测试:

var list = new ArrayList()
list.array = [7, -2, 4, 1, 6, 5, 0, -4, 2]
list.quickSortRecursive(list.array, 0, list.array.length - 1)

console.log(list)

// 输出:
// -4,-2,0,1,2,4,5,6,7
循环实现

快速排序的递归方法更加直观。但是用循环实现快速排序是一个相对常见的面试题。

与大多数的递归到循环的转换方案一样,最先想到的是用来模拟递归调用。这样做可以重用一些我们熟悉的递归逻辑,并在循环中使用。

我们需要一种跟踪剩下的未排序子数组的方法。一种方法是简单地把“成对”的元素保留在堆栈中,用来表示给定未排序子数组的 startend

JavaScript 没有显式的栈数据结构,但是数组支持 push()pop()函数。但是不支持 peek()函数,所以必须用 stack [stack.length-1] 手动检查栈顶。

我们将使用与递归方法相同的“分区”功能。

quickSortIterative(arr) {
    // 用push()和pop()函数创建一个将作为栈使用的数组
    let stack = [];

    // 将整个初始数组做为“未排序的子数组”
    stack.push(0);
    stack.push(arr.length - 1);

    // 没有显式的peek()函数
    // 只要存在未排序的子数组,就重复循环
    while (stack[stack.length - 1] >= 0) {

        // 提取顶部未排序的子数组
        let end = stack.pop();
        let start = stack.pop();

        let pivotIndex = this.partition(arr, start, end);

        // 如果基准的左侧有未排序的元素,
        // 则将该子数组添加到栈中,以便稍后对其进行排序
        if (pivotIndex - 1 > start) {
            stack.push(start);
            stack.push(pivotIndex - 1);
        }

        // 如果基准的右侧有未排序的元素,
        // 则将该子数组添加到栈中,以便稍后对其进行排序
        if (pivotIndex + 1 < end) {
            stack.push(pivotIndex + 1);
            stack.push(end);
        }
    }
}

最后用下面的例子进行测试:

var list = new ArrayList()
list.array = [7, -2, 4, 1, 6, 5, 0, -4, 2]
list.quickSortIterative(list.array)

console.log(list)

// 输出:
// -4,-2,0,1,2,4,5,6,7
效率

快速排序在最坏情况下的时间复杂度是 O(n2)

平均时间复杂度为 O(nlogn)。通常,使用随机版本的快速排序可以避免最坏的情况。

快速排序算法的弱点是基准的选择。每选择一次错误的基准(大于或小于大多数元素的基准)都会带来最坏的时间复杂度。在重复选择基准时,如果元素值小于或大于该元素的基准时,时间复杂度为 O(nlogn)

根据经验可以观察到,无论采用哪种数据基准选择策略,快速排序的时间复杂度都倾向于具有 O(nlog n)

快速排序不会占用任何额外的空间(不包括为递归调用保留的空间)。这种算法被称为in-place算法,不需要额外的空间。

总结

各类排序算法的效率

在这里插入图片描述

常见问题

Q1:在各自最优条件下,哪些排序算法的时间复杂度最低?
A1:冒泡排序插入排序的时间复杂度最低,最好情况下为O(n)

Q2:为什么冒泡排序最好情况下时间复杂度为O(n)?
A2: 从代码上看,即使数组本身就已是正序,但是循环的次数并没有减少,只是交换次数没有了,比较次数仍一样,时间复杂度应该还是O(n2)。实际上,是需要对代码做出一点优化,增加一个标志位,如果进行第一遍遍历后数组本身没有进行交换,则认为这个数组是已被排好序的,就结束后面的循环,这样时间复杂度就是O(n)了。

public void bubbleSort(int arr[]) {
    boolean didSwap;
    for(int i = 0, len = arr.length; i < len - 1; i++) {
        didSwap = false;
        for(int j = 0; j < len - i - 1; j++) {
            if(arr[j + 1] < arr[j]) {
                swap(arr, j, j + 1);
                didSwap = true;
            }
        }
        if(didSwap == false)
            return;
    }    
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值