0


前端必会的十个排序算法

一. 冒泡排序

1.1 思路

冒泡排序是一种简单的排序方法。

  • 基本思路是通过两两比较相邻的元素并交换它们的位置,从而使整个序列按照顺序排列。
  • 该算法一趟排序后,最大值总是会移到数组的最后面,那么接下来就不用再考虑这个最大值。
  • 一直重复这样的操作,最终就可以得到排序完成的数组。

这种算法是稳定的,即相等元素的相对位置不会发生变化。

  • 而且在最坏情况下,时间复杂度为O(n^2),在最好情况下,时间复杂度为O(n)。

因此,冒泡排序适用于数据规模小的场景。

1.2 动图展示

1.3 冒泡排序流程

冒泡排序的流程如下:

  1. 从第一个元素开始,逐一比较相邻元素的大小。
  2. 如果前一个元素比后一个元素大,则交换位置。
  3. 在第一轮比较结束后,最大的元素被移动到了最后一个位置。
  4. 在下一轮比较中,不再考虑最后一个位置的元素,重复上述操作。
  5. 每轮比较结束后,需要排序的元素数量减一,直到没有需要排序的元素。
  6. 排序结束。
  7. 这个流程会一直循环,直到所有元素都有序排列为止。

1.4 冒泡排序代码

// 定义函数,用于实现冒泡排序算法
function bubbleSort(arr: number[]): number[] {
  // 外层循环,控制需要比较的轮数
  for (let i = 0; i < arr.length - 1; i++) {
    // 内层循环,控制每轮需要比较的次数
    for (let j = 0; j < arr.length - 1 - i; j++) {
      // 如果前一个元素比后一个元素大,则交换它们的位置
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  // 返回排序后的数组
  return arr;
}

// 测试代码
const arr = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.log(bubbleSort(arr));
// 输出:[2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]

说明:

  1. 冒泡排序是一种暴力枚举算法,通过多次循环比较相邻的元素,把最大的元素逐渐冒泡到数组末端。
  2. 外层循环:控制排序的趟数,每一轮排序会把最大的元素放到最后,因此每次循环需要比较的元素个数也会逐渐减少。
  3. 内层循环:比较相邻元素,如果左边元素比右边元素大,则交换位置。
  4. 冒泡排序是一种时间复杂度较高的算法,一般不用于大数据量的排序,但它很容易理解,是一种初学者学习排序算法的好

1.5 冒泡排序的时间复杂度

在冒泡排序中,每次比较两个相邻的元素,并交换它们的位置,如果左边的元素比右边的元素大,则交换它们的位置。这样的比较和交换的过程可以用一个循环实现。

  • 在最好的情况下,数组已经是有序的,那么比较和交换的次数是最少的。

    • 在这种情况下,比较次数是n-1次,交换次数是0次,其中n是数组的长度。
  • 在最坏的情况下,数组是逆序的,那么比较和交换的次数是最多的。

    • 在这种情况下,比较次数是n-1次,交换次数是n(n-1)/2次,其中n是数组的长度。
  • 在平均情况下,比较和交换的次数取决于数组的排列方式。

    • 一般来说,平均情况下比较次数是n-1次,交换次数是n(n-1)/4次,其中n是数组的长度。

冒泡排序的时间复杂度分析:

  • 最好情况:当序列已经有序,每次比较和交换操作都不会进行,只需要进行n-1次比较,时间复杂度为O(n)。
  • 最坏情况:当序列完全逆序,需要进行n-1轮比较和n-1次交换操作,时间复杂度为O(n^2)。
  • 平均情况:需要进行的比较和交换操作的次数在所有情况中的平均值,时间复杂度也是O(n^2)。

由此可见,冒泡排序的时间复杂度主要取决于数据的初始顺序,最坏情况下时间复杂度是O(n^2),不适用于大规模数据的排序。

1.6 冒泡排序小结

  • 冒泡排序适用于数据规模较小的情况,因为它的时间复杂度为O(n^2),对于大数据量的排序会变得很慢。
  • 同时,它的实现简单,代码实现也容易理解,适用于学习排序算法的初学者。
  • 但是,在实际的应用中,冒泡排序并不常用,因为它的效率较低。
  • 此外,冒泡排序比较和交换的次数较多,占用更多的存储空间和时间,不适用于处理大数据量的情况。
  • 因此,在实际应用中,冒泡排序通常被更高效的排序算法代替,如快速排序、归并排序等。

二. 选择排序

2.1 思路

选择排序(Selection Sort)是一种简单的排序算法。

它的基本思想是:

  • 首先在未排序的数列中找到最小(大)元素,然后将其存放到数列的起始位置;
  • 接着,再从剩余未排序的元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
  • 以此类推,直到所有元素均排序完毕。

选择排序的主要优点与数据移动有关。

  • 如果某个元素位于正确的最终位置,则它不会被移动。
  • 选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对n个元素的表进行排序总共进行至多n-1次交换。
  • 在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。

选择排序的实现方式很简单,并且容易理解,因此它是学习排序算法的很好的入门途径。

2.2 动图展示

2.3 选择排序流程

选择排序流程详细步骤:

  1. 首先将要排序的数组复制到一个新数组中,这样原数组不会被改变。
  2. 初始化最小数字的索引值为0,然后在数组中循环,在当前索引后面的元素中找到最小的数字的索引。
  3. 如果当前索引位置的数字不是最小数字,那么将这两个数字互换。
  4. 继续寻找下一个数字,直到索引到最后一个元素,此时整个数组已经是从小到大排序的了。
  5. 重复上面的步骤,每次排序的范围都会减少一个,直到整个数组排序完毕。

2.4 选择排序代码

function selectionSort(arr: number[]): number[] {
  // 循环遍历整个数组
  for (let i = 0; i < arr.length; i++) {
    // 预设最小数的索引为当前循环的索引
    let minIndex = i;
    // 在后面的数中寻找更小的数
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[j] < arr[minIndex]) {
        // 如果找到更小的数,记录它的索引
        minIndex = j;
      }
    }
    // 如果当前循环的索引不是最小数的索引,交换它们
    if (i !== minIndex) {
      [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
    }
  }
  // 返回排序后的数组
  return arr;
}

// 测试数据
const testArr = [5, 2, 9, 1, 5, 6];
// 调用插入排序函数
const sortedArr = selectionSort(testArr);
// 打印结果
console.log(sortedArr);

说明:

  1. 首先循环遍历整个数组。
  2. 在每一次循环中,预设最小数的索引为当前循环的索引。
  3. 在后面的数中寻找更小的数,如果找到更小的数,记录它的索引。
  4. 如果当前循环的索引不是最小数的索引,交换它们。
  5. 重复步骤2-4,直到遍历完整个数组。
  6. 返回排序后的数组。

2.5 选择排序的时间复杂度

计算选择排序算法的时间复杂度,通常是通过分析算法中每一步的执行次数来确定的。

我们分析选择排序中的每一步,再将每一步的时间复杂度加起来,最后得到的就是选择排序的时间复杂度。

  • 在选择排序中,最多的操作是内层循环,其执行了N-1次,并且每次执行内层循环都要花费O(N)的时间。

    • 因此,内层循环的时间复杂度是O(N^2)。
  • 外层循环也要执行N-1次,因此,它的时间复杂度也是O(N^2)。

    • 所以,整个选择排序算法的时间复杂度是O(N^2)。

2.6 选择排序小结

  • 选择排序是一种简单易懂的排序算法。
  • 它的基本思想是遍历整个列表,每次找出最小的元素,并且将它移到列表的最左边,重复这个过程直到整个列表都有序排列。
  • 在平均情况下,选择排序的时间复杂度为 O(n^2),在最坏情况下与最好情况下都为 O(n^2)。
  • 选择排序在数据规模较小时非常适用,在数据规模较大时不够高效。

三. 插入排序

3.1 思路

插入排序就像是打扑克牌,从牌堆顶取一张牌,找到合适的位置插入到已有牌的顺序中,并不断重复这一步骤直到所有的牌都被插入到合适的位置,最终使得整副牌有序。

与打牌类似,插入排序(Insertion sort)的实现方法是:

  • 首先假设第一个数据是已经排好序的,接着取出下一个数据,在已经排好序的数据中从后往前扫描,找到比它小的数的位置,将该位置之后的数整体后移一个单位,然后再将该数插入到该位置。
  • 不断重复上述操作,直到所有的数据都插入到已经排好序的数据中,排序完成。

插入排序的优势在于它的性能表现在已经有序的序列上比冒泡排序、选择排序两种算法要好。

  • 它的时间复杂度为O(n),因此,如果序列已经被排好,插入排序将会比冒泡排序和选择排序快得多。
  • 另外,插入排序空间复杂度为O(1),因此,对于内存限制较小的情况,插入排序也是一个更优的选择。

3.2 动图展示

3.3 插入排序流程

插入排序的流程如下:

  1. 首先,假设数组的第一个元素已经排好序了,因为它只有一个元素,所以可以认为是有序的。
  2. 然后,从第二个元素开始,不断与前面的有序数组元素进行比较。
  3. 如果当前元素小于前面的有序数组元素,则把当前元素插入到前面的合适位置。
  4. 否则,继续与前面的有序数组元素进行比较。
  5. 以此类推,直到整个数组都有序。
  6. 循环步骤2~5,直到最后一个元素。
  7. 完成排序。

3.4 插入排序代码

function insertionSort(arr: number[]): number[] {
  // 对于数组的每一个元素,从它开始到0位置,比较该元素和前一个元素的大小
  for (let i = 1; i < arr.length; i++) {
    let current = arr[i];
    let j = i - 1;
    // 如果该元素小于前一个元素,那么前一个元素向后移动,并继续向前比较
    while (j >= 0 && arr[j] > current) {
      arr[j + 1] = arr[j];
      j--;
    }
    // 如果该元素大于前一个元素,那么它将放到合适的位置
    arr[j + 1] = current;
  }
  // 返回排序后的数组
  return arr;
}

// 测试数据
const testArr = [5, 2, 9, 1, 5, 6];
// 调用插入排序函数
const sortedArr = insertionSort(testArr);
// 打印结果
console.log(sortedArr);

说明:

  1. 首先定义了一个 insertSort 函数,并传入一个数字数组作为参数。
  2. 接着定义一个变量 current,它将存储当前需要比较的数字。
  3. 然后使用一个循环,将数组的第二项到最后一项依次与前面的数字进行比较。
  4. 在内层循环中,首先将 j 定义为 i-1,然后每次执行循环时,如果 j 大于等于 0 并且 arr[j] 大于 current,我们就交换 arr[j] 和 arr[j + 1] 的值。
  5. 在循环结束后,将 current 插入到正确的位置,并继续比较下一个数字。
  6. 当所有数字都被比较过后,我们就可以返回最终排序好的数组。

3.5 插入排序的时间复杂度

插入排序的时间复杂度在最好的情况下为O(n),在最坏的情况下为O(n^2),平均时间复杂度为O(n^2)。

  • 当数据已经有序时,插入排序只需要做n-1次比较和0次移动,运行时间为O(n);
  • 当数据完全逆序时,插入排序需要做n-1趟比较和3/2*(n-1)^2/2次移动,运行时间为O(n^2)。

由于插入排序的最好时间复杂度与最坏时间复杂度都接近O(n^2),所以插入排序适用于数据规模不大的场合,如果数据规模很大,通常使用其他算法。

3.6 插入排序小结

  • 插入排序是一种简单而直观的排序算法,它可以快速地对部分有序的数组进行排序。
  • 插入排序通过比较相邻的元素并在需要时将其交换,来实现从小到大的排列。
  • 插入排序的时间复杂度在最好情况下是线性O(n),最坏情况下是O(n^2)。

总而言之,如果数组部分有序,插入排序可以比冒泡排序和选择排序更快。

  • 但是如果数组完全逆序,则插入排序的时间复杂度比较高,不如快速排序或归并排序。
  • 因此,在选择排序算法时,应该根据需要选择合适的算法。

四. 归并排序

4.1 思路

归并排序(merge sort)是一种常见的排序算法:

  • 它的基本思想是将待排序数组分成若干个子数组。
  • 然后将相邻的子数组归并成一个有序数组。
  • 最后再将这些有序数组归并(merge)成一个整体有序的数组。

这个算法最早出现在1945年,由约翰·冯·诺伊曼(John von Neumann)(又一个天才,现代计算机之父,冯·诺依曼结构、普林斯顿结构)首次提出。

  • 当时他在为美国政府工作,研究原子弹的问题。
  • 由于当时计算机,他在研究中提出了一种高效计算的方法,这个方法就是归并排序。

归并排序的基本思路是先将待排序数组递归地拆分成两个子数组,然后对每个子数组进行排序,最后将两个有序子数组合并成一个有序数组。

  • 在实现中,我们可以使用“分治法”来完成这个过程,即将大问题分解成小问题来解决。 归并排序的算法复杂度为 O(nlogn),是一种比较高效的排序算法,因此在实际应用中被广泛使用。

虽然归并排序看起来比较复杂,但是只要理解了基本思路,实现起来并不困难,而且它还是一个非常有趣的算法。

4.2 动图展示

4.3 归并排序流程

归并排序是一种基于分治思想的排序算法,其基本思路可以分为三个步骤:

步骤一:分解(Divide)归并排序使用递归算法来实现分解过程,具体实现中可以分为以下几个步骤:

  • 如果待排序数组长度为1,认为这个数组已经有序,直接返回;
  • 将待排序数组分成两个长度相等的子数组,分别对这两个子数组进行递归排序;
  • 将两个排好序的子数组合并成一个有序数组,返回这个有序数组。

步骤二:合并(Merge)合并过程中,需要比较每个子数组的元素并将它们有序地合并成一个新的数组:

  • 可以使用两个指针 i 和 j 分别指向两个子数组的开头,比较它们的元素大小,并将小的元素插入到新的有序数组中。
  • 如果其中一个子数组已经遍历完,就将另一个子数组的剩余部分直接插入到新的有序数组中。
  • 最后返回这个有序数组。

步骤三:归并排序的递归终止条件

  • 归并排序使用递归算法来实现分解过程,当子数组的长度为1时,认为这个子数组已经有序,递归结束。总体来看,归并排序的基本思路是分治法,分成子问题分别解决,然后将子问题的解合并成整体的解。

4.4 归并排序代码

// 定义函数mergeSort,参数是待排序数组arr
function mergeSort(arr: number[]): number[] {
    // 计算数组长度
    const n = arr.length;
    // 如果数组长度小于等于1,则直接返回该数组
    if (n <= 1) {
        return arr;
    }
    // 计算中间位置
    const middle = Math.floor(n / 2);
    // 对左边的数组进行归并排序
    const left = mergeSort(arr.slice(0, middle));
    // 对右边的数组进行归并排序
    const right = mergeSort(arr.slice(middle));
    // 合并两个排好序的数组
    return merge(left, right);
}

// 定义函数merge,参数是两个排好序的数组left和right
function merge(left: number[], right: number[]): number[] {
    // 定义指针变量,分别指向两个数组的开头
    let i = 0, j = 0;
    // 定义一个空数组,用来存放合并后的数组
    const result = [];
    // 比较两个数组的第一个元素,将较小的放入result数组
    while (i < left.length && j < right.length) {
        if (left[i] < right[j]) {
            result.push(left[i++]);
        } else {
            result.push(right[j++]);
        }
    }
    // 将没有比较完的剩余元素放入result数组
    while (i < left.length) {
        result.push(left[i++]);
    }
    while (j < right.length) {
        result.push(right[j++]);
    }
    // 返回合并后的数组
    return result;
}

// 测试数据
const testArr = [5, 2, 9, 1, 5, 6];
// 调用插入排序函数
const sortedArr = mergeSort(testArr);
// 打印结果
console.log(sortedArr);

说明:

  1. mergeSort 函数实现归并排序的递归调用,在该函数内,如果数组的长度小于等于1,直接返回该数组。
  2. 如果数组的长度大于1,那么执行以下代码:
    • 先计算数组的中点,并将数组分为左右两半。- 递归调用左边和右边的数组,最终得到两个有序的数组。
  1. merge 函数实现将两个有序的数组合并为一个有序的数组。

4.5 归并排序的时间复杂度

复杂度的分析过程:

  • 假设数组长度为 n,需要进行 logn 次归并操作;
  • 每次归并操作需要 O(n) 的时间复杂度;
  • 因此,归并排序的时间复杂度为 O(nlogn)。

最好情况:****O(log n)

  • 最好情况下,待排序数组已经是有序的了,那么每个子数组都只需要合并一次,即只需要进行一次归并操作。
  • 因此,此时的时间复杂度是 O(log n)。

最坏情况:****O(nlogn)

  • 最坏情况下,待排序数组是逆序的,那么每个子数组都需要进行多次合并。
  • 因此,此时的时间复杂度为 O(nlogn)。

平均情况:****O(nlogn)

  • 在平均情况下,我们假设待排序数组中任意两个元素都是等概率出现的。
  • 此时,可以证明归并排序的时间复杂度为 O(nlogn)。

4.6 归并排序小结

  • 归并排序是一种分治策略的排序算法,是利用分治的思想将一个大问题分成小问题,并在适当的地方合并它们以解决该问题的方法。

它是一种稳定的排序算法,时间复杂度为O(nlogn)。

归并排序使用了额外的空间,因此更适合处理大数据。

  • 归并排序的基本流程是通过递归将数组分成两半,分别进行递归排序,最终再进行合并。
  • 具体来说,将数组的中间元素作为分界点,分别对左右两边的数组进行排序,并在排序完成后进行合并。

归并排序的代码实现较为简单,但要注意关于递归函数和合并函数的实现。

归并排序是一种不需要过多研究的算法,适合于所有的排序场景。

五. 快速排序

5.1 思路

快速排序(Quicksort),又称分区交换排序(partition-exchange sort),简称快排

  • 由Tony Hoare在1959年发明。
  • 快速排序使用了分治的思想,将数组划分为两个子数组,每个子数组再分别进行排序,最终实现整个数组的排序。
  • 快速排序的特点是时间复杂度较好,平均情况下为O(nlogn)。
  • 另外,快速排序是一种原地排序算法,不需要额外的存储空间。

快速排序是一种非常流行的排序算法,因为它的时间复杂度和实现方式非常优秀。

快速排序广泛应用于各种场景,如数据库、搜索引擎等,其高效的排序速度和低空间复杂度使得它成为了一种非常重要的排序算法。

5.2 动图展示

5.3 快速排序流程

快速排序的思路可以分解成以下几个步骤:

  • ①首先,我们需要选择一个基准元素,通常选择第一个或最后一个元素作为基准元素。
  • ②然后,我们定义两个指针 i 和 j,分别指向数组的左右两端。
  • ③接下来,我们从右侧开始,向左移动 j 指针,直到找到一个小于或等于基准元素的值。
  • ④然后,我们从左侧开始,向右移动 i 指针,直到找到一个大于或等于基准元素的值。
  • ⑤如果 i 指针小于或等于 j 指针,交换 i 和 j 指针所指向的元素。
  • ⑥重复步骤 3-5,直到 i 指针大于 j 指针,这时,我们将基准元素与 j 指针所指向的元素交换位置,将基准元素放到中间位置。
  • ⑦接着,我们将数组分为两部分,左侧部分包含小于或等于基准元素的元素,右侧部分包含大于基准元素的元素。
  • ⑧然后,对左右两部分分别进行递归调用快速排序,直到左右两部分只剩下一个元素。
  • ⑨最终,整个数组就变得有序了。

5.4 快速排序代码

// 定义快速排序函数,参数为待排序的数组
function quickSort(array: number[]): number[] {
    // 定义辅助函数,用于排序
    function sort(left: number, right: number): void {
        // 如果左边的索引比右边的索引大,说明区间内已经没有数据,退出函数
        if (left >= right) {
            return;
        }
        // 取出基准数
        let pivot = array[left];
        // 定义两个指针
        let i = left;
        let j = right;
        // 开始排序
        while (i < j) {
            // 从右边开始搜索,直到找到比基准数小的数
            while (i < j && array[j] >= pivot) {
                j--;
            }
            // 如果找到了,则将该数存放在左边
            if (i < j) {
                array[i] = array[j];
                i++;
            }
            // 从左边开始搜索,直到找到比基准数大的数
            while (i < j && array[i] <= pivot) {
                i++;
            }
            // 如果找到了,则将该数存放在右边
            if (i < j) {
                array[j] = array[i];
                j--;
            }
        }
        // 将基准数存放在最终的位置上
        array[i] = pivot;
        // 递归处理基准数左边的数据
        sort(left, i - 1);
        // 递归处理基准数右边的数据
        sort(i + 1, right);
    }
    // 调用辅助函数,开始排序
    sort(0, array.length - 1);
    // 返回排序后的数组
    return array;
}

// 测试数据
const testArr = [5, 2, 9, 1, 5, 6];
// 调用插入排序函数
const sortedArr = quickSort(testArr);
// 打印结果
console.log(sortedArr);

说明:

  1. 在数组中选择一个元素作为基准元素(通常是数组的第一个元素)
  2. 将小于等于基准元素的元素移到数组的左边,大于基准元素的元素移到数组的右边
  3. 递归地对左半部分数组和右半部分数组进行快速排序

5.5 快速排序的时间复杂度

快速排序的复杂度分析需要从时间复杂度和空间复杂度两个方面来考虑。

时间复杂度: 快速排序是一种分治思想的排序算法,它的时间复杂度取决于基准数的选取。

  • 最坏情况下,每次选取的基准数为序列中的最大或最小数,导致划分的两部分长度不平均,递归的次数将会达到O(n),因此时间复杂度为O(n^2)。
  • 最优情况下,每次选取的基准数将整个序列划分成两个长度大致相等的部分,递归的次数将会达到log2n,因此时间复杂度为O(nlogn)。
  • 平均情况下,时间复杂度为O(nlogn)。

空间复杂度: 快速排序是一种原地排序算法,它不需要额外的存储空间。

  • 在递归过程中,空间复杂度仅为递归调用的栈空间,因此空间复杂度为O(logn)。

总结: 快速排序的时间复杂度为O(nlogn),空间复杂度为O(logn),是一种高效的排序算法。

5.6 快速排序小结

快速排序是一种分治算法,它的思想是通过选定一个基准值,将数组分成两个部分,左边部分的元素都小于等于基准值,右边部分的元素都大于基准值,然后再递归地对左右两个部分进行快速排序。

快速排序的时间复杂度为 O(nlogn)

  • 在最坏情况下,时间复杂度可能退化为O(n^2)。
  • 但是它的平均时间复杂度是O(nlogn)。

快速排序的空间复杂度为 O(logn)

  • 因为它需要递归调用,但是它的空间复杂度与数据的大小并不相关,所以它不会导致内存使用方面的困难。

总的来说,快速排序是一种高效的排序算法,适用于大多数的排序场景。

六. 基数排序

6.1 思路

基数排序是一种非比较型整数排序算法,其原理是将整数按位数分组,对每一位进行桶排序。

  • 基数排序从低位向高位排序,将整数分为个位、十位、百位等不同的数位。
  • 再将每一位数位上的数字分别放入桶中进行排序,最后将排好序的数字从桶中取出。

基数排序是桶排序的扩展,用于整数排序,与桶排序相比,其多了一重循环,用于不同数位的比较。

基数排序适用于整数排序,特别是位数大小不一的整数排序,比如电话号码、银行卡号等。

基数排序的优点:

  1. 稳定性:基数排序是稳定的排序算法,排序后,相同的数字的相对位置不变。
  2. 时间复杂度低:基数排序的时间复杂度为O(n * k),k为数字的位数。
  3. 适用范围广:基数排序适用于数据范围很大的场景,如数字的位数不多。

基数排序的缺点:

  1. 需要额外的存储空间:基数排序需要使用一些额外的存储空间存储桶,占用额外的空间。
  2. 数据结构不同导致不能比较:基数排序只适用于整数数字,不能对其他数据结构进行排序。

6.2 动图展示

6.3 基数排序流程

  1. 根据待排序的数的位数,确定需要进行的趟数,每一趟按照对应的位数进行排序。
  2. 创建10个桶,分别代表0~9的数字,存储对应位数上数字为该数字的数。
  3. 遍历待排序数组,将每一个数字按照其对应位数上的数字放入对应的桶中。
  4. 按照桶的顺序依次遍历每一个桶,将桶中的数字存入辅助数组中。
  5. 将辅助数组的数字复制回原数组中。
  6. 如果还有多余的趟数,则从第2步开始重复上述操作,直到所有的趟数都已经结束。
  7. 排序完成。

6.4 基数排序代码

function radixSort(arr: number[]) {
  const maxDigit = getMaxDigit(arr); // 获取最大位数

  for (let i = 0; i < maxDigit; i++) {
    let buckets: number[][] = []; // 创建桶

    for (let j = 0; j < arr.length; j++) {
      let digit = getDigit(arr[j], i); // 获取数字的第i位数字

      if (!buckets[digit]) {
        buckets[digit] = [];
      }

      buckets[digit].push(arr[j]); // 将数字放入相应的桶中
    }

    arr = [].concat(...buckets); // 将桶中的数字取出来,重新放入arr数组中
  }

  return arr;
}

// 获取最大位数
function getMaxDigit(arr: number[]) {
  let max = 0;

  for (let i = 0; i < arr.length; i++) {
    let digit = getDigitCount(arr[i]);
    max = Math.max(max, digit);
  }

  return max;
}

// 获取数字的位数
function getDigitCount(num: number) {
  if (num === 0) return 1;

  return Math.floor(Math.log10(Math.abs(num))) + 1;
}

// 获取数字的第i位数字
function getDigit(num: number, i: number) {
  return Math.floor(Math.abs(num) / Math.pow(10, i)) % 10;
}

整体流程分为两步:

  1. 从低位到高位依次排序,每一位排序时都是使用计数排序的思想。
  2. 从最低位的排序结果开始,依次累加上一位的排序结果,得到最终的排序结果。

具体实现过程中,使用了一个二维数组作为桶,桶的第一维是数字的每一位,第二维是存储这一位数字相同的数字的数组。

使用了两个循环,外层循环进行排序的次数,内层循环进行元素的遍历。

最终,排序完成后,把结果存入原数组,完成排序。

6.5 基数排序的时间复杂度

基数排序的时间复杂度分析:

  1. 对于单次排序,基数排序的时间复杂度为 O(n),因为每个数都只需要进行一次排序。
  2. 对于整个排序过程,基数排序的时间复杂度为 O(d(n + k)),其中 d 为位数,n 为数组长度,k 为桶的数量。
    • 在最坏情况下,当所有数的位数都相同时,d 与 n 的值相同,所以时间复杂度可以看做 O(n^2)。

基数排序的空间复杂度分析:

  1. 基数排序的空间复杂度为 O(n + k),其中 n 为数组长度,k 为桶的数量。
  2. 需要额外的数组存储排序的中间结果,空间复杂度为 O(n)。

6.6 基数排序小结

基数排序是一种非比较型整数排序算法,适用于大量数,很长的数列。它是桶排序的一种改进版本。它的基本思想是:将整数按位数切割成不同的数字,然后按每个位数分别比较。具体来说,就是将数列分别按个位,十位,百位... 的大小顺序排序,最后组合在一起。

优点:

  1. 时间复杂度稳定,为O(n * k),其中n为数列的长度,k为数列中数的最大位数。
  2. 空间复杂度小,只需要额外的常数空间。
  3. 是稳定的排序算法,也就是说,相同的数字排序后仍然相同。

缺点:

  1. 不适用于浮点数和负数。
  2. 对于数据范围较大的数列,k的大小也很大,因此,时间复杂度可能较高。

总体来说,基数排序是一种非常有效的整数排序算法,特别是对于大量数,很长的数列。

七. 桶排序

7.1 思路

桶排序是一种非比较的排序算法,通过分配数据到不同的桶中,最后对每个桶内的数据进行单独的排序或计数,最后将所有桶内的数据按顺序连接,即实现了排序的过程。

桶排序的基本思想是将数组分到有限数量的桶子里。

  • 每个桶子再个别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)。
  • 它是鸽巢排序的一种归纳结果。

桶排序是一种特殊的计数排序,它是针对于元素的数量而不是元素的值。

桶排序的优点:

  • 速度快,时间复杂度为 O(n)。

桶排序的缺点:

  • 需要额外的内存空间。
  • 当待排序的数据的取值范围比较大的时候,桶的数量也会比较大,内存开销会比较大。
  • 对于浮点数排序比较麻烦。

7.2 动图展示

7.3 桶排序流程

  1. 首先,需要确定桶的个数,并且确定每一个桶的数值范围,这两个信息是排序过程的关键。
  2. 接下来,遍历数组中的每一个元素,并将其分配到相应的桶中。
  3. 对每一个桶内的数据进行排序,可以使用其他的排序算法或者再次使用桶排序(下面代码中我使用的是插入排序)。
  4. 最后,将每个桶中的数据依次取出,组成有序的数列。

7.4 桶排序代码

function bucketSort(arr: number[], bucketSize = 5) {
  if (arr.length === 0) {
    return arr;
  }

  // 找到数组中的最大值和最小值
  let i;
  let minValue = arr[0];
  let maxValue = arr[0];
  for (i = 1; i < arr.length; i++) {
    if (arr[i] < minValue) {
      minValue = arr[i];
    } else if (arr[i] > maxValue) {
      maxValue = arr[i];
    }
  }

  // 计算桶的数量
  let bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;
  let buckets = new Array(bucketCount);
  for (i = 0; i < buckets.length; i++) {
    buckets[i] = [];
  }

  // 将数组中的元素分配到各个桶中
  for (i = 0; i < arr.length; i++) {
    buckets[Math.floor((arr[i] - minValue) / bucketSize)].push(arr[i]);
  }

  // 对每个桶中的元素进行排序
  arr.length = 0;
  for (i = 0; i < buckets.length; i++) {
    if (buckets[i].length > 0) {
      insertionSort(buckets[i]);
      for (let j = 0; j < buckets[i].length; j++) {
        arr.push(buckets[i][j]);
      }
    }
  }

  return arr;
}

// 插入排序
function insertionSort(arr: number[]) {
  let i;
  let j;
  let temp;
  for (i = 1; i < arr.length; i++) {
    temp = arr[i];
    j = i - 1;
    while (j >= 0 && arr[j] > temp) {
      arr[j + 1] = arr[j];
      j--;
    }
    arr[j + 1] = temp;
  }
  return arr;
}

桶排序的代码整体实现如下:

  1. 定义一个函数,接收一个数组作为参数,进行桶排序。
  2. 定义一个辅助数组,并且初始化所有的元素为0。
  3. 遍历原数组,对于每个元素,将辅助数组对应下标的元素加一。
  4. 遍历辅助数组,并且将原数组的元素依次存储在辅助数组对应的位置上。
  5. 将原数组的元素从辅助数组的末尾复制到原数组的开头

7.5 桶排序的时间复杂度

桶排序的复杂度分析主要是分析其两个主要部分的复杂度,即:

  • 桶的初始化: 在桶的初始化过程中,每个桶的创建都是常数时间的

    • 因此,初始化的时间复杂度是 O(n),其中 n 为要排序的数的个数。
  • 将数据放入桶: 对于每个数,它只需要被放入一次

    • 因此该部分的时间复杂度是 O(n)。

因此,桶排序的总时间复杂度是 O(n),这是一种非常高效的排序算法。

7.6 桶排序小结

桶排序是一种非比较型排序算法,它的工作原理是将数组分到有限数量的桶子里。

  • 每个桶子再个按照顺序排序,最后依次把不同桶子中的数据拼接起来。
  • 桶排序的优势在于它对于一定范围内的数据,具有非常高的排序效率,时间复杂度为O(n)。
  • 但是对于数据范围很大的情况,会造成空间浪费。

总的来说,桶排序是一种非常有效的排序方法,但它并不适用于所有场景,需要根据数据特点进行适当的选择。

八. 计数排序

8.1 思路

计数排序是一种非比较型整数排序算法。它的核心思想是对于待排序的数组,统计每个数出现的次数,然后根据次数从前往后依次输出。

计数排序的时间复杂度为O(n),这使得它比其他排序算法快得多,特别是当待排序的数据量很大,但是其空间复杂度却较高。

优点:

  1. 时间复杂度低,只需要O(n)的时间复杂度。
  2. 易于实现。
  3. 对于数据范围很小的情况下,它可以达到稳定性。

缺点:

  1. 空间复杂度高,需要额外的存储空间。
  2. 仅适用于整数数据的排序。
  3. 不适用于浮点数和负数的排序。

8.2 动图展示

8.3 计数排序流程

计数排序的流程分析:

  1. 首先确定数列的范围,并创建一个对应的桶列表,每个桶代表一个数列中的值;
  2. 然后遍历原数列,并统计每个数列中值出现的次数,并将次数加入对应的桶中;
  3. 接下来将桶列表转换为前缀和数组,即统计每个桶前面所有桶的数值总和;
  4. 最后遍历原数列,对于每个数,将它插入对应的桶中的对应位置,位置确定的方法是:将该数的数值减去数列的最小值,然后加一,再在前缀和数组中找到该数的前一个数的位置,这个位置即为该数的插入位置。

通过这样的步骤,我们就可以得到一个从小到大排序的数列。

8.4 计数排序代码

function countingSort(array: number[]): number[] {
  const n = array.length;

  // 计算数列最大值和最小值
  let max = array[0];
  let min = array[0];
  for (let i = 1; i < n; i++) {
    if (array[i] > max) {
      max = array[i];
    }
    if (array[i] < min) {
      min = array[i];
    }
  }

  // 统计数列中每个值出现的次数
  const count = new Array(max - min + 1).fill(0);
  for (let i = 0; i < n; i++) {
    count[array[i] - min]++;
  }

  // 累加数组
  for (let i = 1; i < count.length; i++) {
    count[i] += count[i - 1];
  }

  // 从后向前遍历原始数组,按照统计数组中的位置放置元素
  const res = new Array(n);
  for (let i = n - 1; i >= 0; i--) {
    res[--count[array[i] - min]] = array[i];
  }

  return res;
}

在上面的代码中,我们首先定义了一个名为countingSort的函数

  • 该函数接受两个参数:待排序数组arr和数组中元素的最大值maxValue。
  • 接下来,我们创建了一个大小为maxValue + 1的数组count,该数组用于统计数字出现的次数。
  • 接着,我们使用for循环对数组arr中的每个元素进行处理,并统计每个数字出现的次数。
  • 最后,我们遍历数组count,并使用for循环把它的元素按照统计的次数逐个输出到结果数组中,最终得到有序数组。

8.5 计数排序的时间复杂度

计数排序的时间复杂度分析如下:

  • 预处理:建立一个计数数组,把输入数组中每个数字出现的次数存入计数数组。

    • 时间复杂度:O(k),其中k是数字的取值范围。
  • 累加:遍历计数数组,把每个数字出现的次数加上它前面数字出现的次数。

    • 时间复杂度:O(k)
  • 输出:遍历输入数组,把每个数字存入输出数组对应的位置。

    • 时间复杂度:O(n),其中n是输入数组的长度。

因此,计数排序的总时间复杂度为:O(k + n)。

计数排序对于长度较短,数字取值范围较小的数组是非常高效的。

8.6 计数排序小结

计数排序是一种特殊的排序算法:

  • 其不同于其他算法的地方在于它不依赖于比较,而是通过计算每个元素的出现次数,来确定每个元素的排名。

计数排序的复杂度为 O(n+k),其中 k 是数组中出现的数的范围。

  • 因此,计数排序非常适合数据范围较小的数组,并且可以很快地对数组进行排序。
  • 但是,由于需要额外的存储空间来存储每个数的出现次数,因此不适合大数据量的数组。

总的来说,计数排序是一种非常有效的排序算法,在适当的情况下可以使用。

九. 堆排序

9.1 思路

堆排序(Heap Sort)是一种选择排序,它的特点是:对需要排序的数据建立一个堆,然后每次取出堆顶元素,直到堆为空。

  • 每次取出堆顶元素后,剩下的元素就是一个新的待排序的序列,因此,每次取出的元素都是序列中最大(最小)的元素。

在堆排序中,可以使用大根堆或小根堆。

  • 大根堆排序时,每次取出的都是最大的元素。
  • 小根堆排序时,每次取出的都是最小的元素。

堆排序的时间复杂度为 O(nlogn)。

学习堆排序之前最好先理解堆结构,这样更有利于对堆排序的理解。

9.2 视频展示

此处为语雀视频卡片,点击链接查看:dui.mp4

9.3 堆排序流程

堆排序可以分成两大步骤:构建最大堆和排序

构建最大堆:

①遍历待排序序列,从最后一个非叶子节点开始,依次对每个节点进行调整。

②假设当前节点的下标为 i,左子节点的下标为 2i+1,右子节点的下标为 2i+2,父节点的下标为 (i-1)/2。

③对于每个节点 i,比较它和左右子节点的值,找出其中最大的值,并将其与节点 i 进行交换。

④重复进行这个过程,直到节点 i 满足最大堆的性质。

⑤依次对每个非叶子节点进行上述操作,直到根节点,这样我们就得到了一个最大堆。

排序:

①将堆的根节点(也就是最大值)与堆的最后一个元素交换,这样最大值就被放在了正确的位置上。

②将堆的大小减小一,并将剩余的元素重新构建成一个最大堆。

③重复进行步骤 ① 和步骤 ②,直到堆的大小为 1,这样我们就得到了一个有序的序列。

9.4 堆排序代码

// 堆排序的函数
function heapSort(arr: number[]) {
  // 先建立大根堆(从最后一个非叶子节点向上调整)
  for (let i = Math.floor(arr.length / 2) - 1; i >= 0; i--) {
    adjustHeap(arr, i, arr.length);
  }
  // 每次把堆顶的数与最后一个数交换,并重新调整堆
  for (let i = arr.length - 1; i > 0; i--) {
    [arr[0], arr[i]] = [arr[i], arr[0]];
    adjustHeap(arr, 0, i);
  }
  return arr;
}

// 堆调整函数
function adjustHeap(arr: number[], i: number, len: number) {
  let temp = arr[i];
  // 将当前节点与其左右子节点比较,找出最大的那个
  for (let j = 2 * i + 1; j < len; j = 2 * j + 1) {
    if (j + 1 < len && arr[j + 1] > arr[j]) {
      j++;
    }
    // 如果子节点比父节点大,就交换
    if (arr[j] > temp) {
      arr[i] = arr[j];
      i = j;
    } else {
      break;
    }
  }
  arr[i] = temp;
}

// 测试数据
const testArr = [5, 2, 9, 1, 5, 6];
// 调用插入排序函数
const sortedArr = heapSort(testArr);
// 打印结果
console.log(sortedArr);

整个代码实现了快速排序的算法流程:

  • heapSort 函数是堆排序的主体函数,使用大根堆实现从小到大的排序
  • adjustHeap 函数是堆调整函数,用来调整大根堆,以保证堆顶的数是整个堆中最大的数

9.5 堆排序的时间复杂度

堆排序的时间复杂度为O(nlogn),详细的计算过程如下:

堆排序的主要过程是建堆和排序。

  • 建堆的时间复杂度为O(n),因为需要对每一个节点都进行比较,以确保堆性质得到满足。

  • 排序的时间复杂度为O(nlogn),因为每一次排序都需要交换根节点和最后一个节点,并且重新堆化剩下的元素。

    • 这个过程的时间复杂度与树的高度相关,由于这是一个完全二叉树,所以高度为logn。- 因此,排序的总时间复杂度为O(nlogn)。

总的来说,堆排序的时间复杂度为O(nlogn),是一种高效的排序算法。

9.6 堆排序小结

堆排序是一种选择排序的改进版,利用了堆这种数据结构的性质。

  • 它的时间复杂度为O(nlogn),空间复杂度为O(1)。
  • 它的优点在于其不需要额外的数组,只需要在原数组上操作,因此空间复杂度比较低。
  • 同时,它还比较快,比较适合大规模数据的排序。不过,它的缺点是实现较为复杂,需要对堆数据结构有较深的了解。
  • 同时,在实际应用中,由于需要频繁的交换元素,因此在排序速度上可能比较慢。
  • 因此,需要根据实际情况选择排序方式。

十. 希尔排序

10.1 思路

希尔排序是一种创新的排序算法,它的名字来源于它的发明者Donald Shell,1959年,希尔排序算法诞生了。

  • 在简单排序算法诞生后的很长一段时间内,人们不断尝试发明各种各样的排序算法,但是当时的排序算法的时间复杂度都是O(N²),看起来很难超越。
  • 当时计算机学术界充满了"排序算法不可能突破O(N²)"的声音,这与人类100米短跑不可能突破10秒大关的想法一样。
  • 但是,终于有一天,一位科学家发布了一种超越O(N²)的新排序算法,并以Shell的名字命名,以纪念这个历史里程碑。
  • 随后,还出现了许多可以超越O(N²)的排序算法,希尔排序作为其中之一,也是一种重要的排序算法。

希尔排序(Shell sort)是插入排序的一种改进版本,通过使用更大的步长来减少待排序列表中的元素,并最终通过使用步长为 1 的插入排序将数据有序化。

希尔排序的时间复杂度较为复杂,但是一般被认为是O(n^(3/2))或者O(n^(4/3))。

与其他排序算法相比,希尔排序具有较快的初始速度,但其最坏时间复杂度与插入排序相同。

10.2 希尔排序流程

回顾插入排序:

  • 由于希尔排序基于插入排序, 所以有必须回顾一下前面的插入排序.
  • 我们设想一下, 在插入排序执行到一半的时候, 标记符左边这部分数据项都是排好序的, 而标识符右边的数据项是没有排序的.
  • 这个时候, 取出指向的那个数据项, 把它存储在一个临时变量中, 接着, 从刚刚移除的位置左边第一个单元开始, 每次把有序的数据项向右移动一个单元, 直到存储在临时变量中的数据项可以成功插入.

插入排序的问题:

  • 假设一个很小的数据项在很靠近右端的位置上, 这里本来应该是较大的数据项的位置.
  • 把这个小数据项移动到左边的正确位置, 所有的中间数据项都必须向右移动一位.
  • 如果每个步骤对数据项都进行N次复制, 平均下来是移动N/2, N个元素就是 N*N/2 = N²/2.
  • 所以我们通常认为插入排序的效率是O(N²)
  • 如果有某种方式, 不需要一个个移动所有中间的数据项, 就能把较小的数据项移动到左边, 那么这个算法的执行效率就会有很大的改进.

希尔排序的做法:

  • 比如下面的数字, 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, 简单的把每趟排序分成两半.
  • 也就是说, 对于N = 100的数组, 增量间隔序列为: 50, 25, 12, 6, 3, 1.
  • 这个方法的好处是不需要在开始排序前为找合适的增量而进行任何的计算.
  • 我们先按照这个增量来实现我们的代码.

10.3 希尔排序代码

// TypeScript代码实现希尔排序
function shellSort(arr: number[]) {
  // 定义增量, 每次分组, 增量为数组长度的一半
  let gap = Math.floor(arr.length / 2);
  while (gap > 0) {
    // 按组进行排序
    for (let i = gap; i < arr.length; i++) {
      // 获取当前元素
      let current = arr[i];
      let j = i;
      // 将相邻元素比较, 满足条件就后移
      while (j >= gap && arr[j - gap] > current) {
        arr[j] = arr[j - gap];
        j -= gap;
      }
      // 将当前元素插入合适的位置
      arr[j] = current;
    }
    // 每次递减增量, 直到为1
    gap = Math.floor(gap / 2);
  }
  return arr;
}

整个代码实现了快速排序的算法流程:

  • heapSort 函数是堆排序的主体函数,使用大根堆实现从小到大的排序
  • adjustHeap 函数是堆调整函数,用来调整大根堆,以保证堆顶的数是整个堆中最大的数整个代码实现了希尔排序的算法流程:
  • 整段代码为一个函数,函数名为"shellSort",参数为待排序的数组"arr"。
  • 从最外层循环开始,设置gap的值为数组长度的一半,每次循环缩小gap的值,直到gap的值为1。
  • 在gap的控制下,进行插入排序,在gap的间隔内进行循环,在循环内部,比较相邻两个数的大小,若左边数大于右边数,则交换位置。
  • 整段代码最后一行,返回排好序的数组。

10.4 希尔排序的时间复杂度

希尔排序的效率

  • 希尔排序的效率很增量是有关系的.
  • 但是, 它的效率证明非常困难, 甚至某些增量的效率到目前依然没有被证明出来.
  • 但是经过统计, 希尔排序使用原始增量, 最坏的情况下时间复杂度为O(N²), 通常情况下都要好于O(N²)

Hibbard 增量序列

  • 增量的算法为2^k - 1. 也就是为1 3 5 7...等等.
  • 这种增量的最坏复杂度为O(N^3/2), 猜想的平均复杂度为O(N^5/4), 目前尚未被证明.

Sedgewick增量序列

  • {1, 5, 19, 41, 109, … }, 该序列中的项或者是94^i - 92^i + 1或者是4^i - 3*2^i + 1
  • 这种增量的最坏复杂度为O(N^4/3), 平均复杂度为O(N^7/6), 但是均未被证明.

总之, 我们使用希尔排序大多数情况下效率都高于简单排序, 甚至在合适的增量和N的情况下, 还好好于快速排序.

10.5 希尔排序小结

希尔排序是一种改进版的插入排序,从历史的角度来看,它是一种非常非常重要的排序算法,因为它解除了人们对原有排序的固有认知。

但是现在已经有很多更加优秀的排序算法:归并排序、快速排序等,所以从实际的应用角度来说,希尔排序已经使用的非常非常少了。

因为,我们只需要了解其核心思想即可。

标签: 排序算法 算法

本文转载自: https://blog.csdn.net/weixin_46489412/article/details/142108428
版权归原作者 飝~仔 所有, 如有侵权,请联系我们删除。

“前端必会的十个排序算法”的评论:

还没有评论