排序算法讲解JavaScript代码实现

1、冒泡排序

冒泡排序(Bubble Sort)是一种简单的排序算法,它重复地遍历要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。遍历数列的工作是重复进行的,直到没有再需要交换的元素。

又称 沉底法

算法步骤:

  • 比较相邻的元素。如果第一个比第二个大,就交换它们两个。

  • 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。在这一步之后,最后的元素应该是最大的数。

  • 针对所有的元素重复以上的步骤,除了最后一个。

  • 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。

排序实例:

假设有一个数组 [2,5,3,4,6,1]

第一轮冒泡:

  • 比较第一个元素2和第二个元素5,2不大于5,不交换 -> [2,5,3,4,6,1]

  • 比较第二个元素5和第三个元素3,5大于3,交换 -> [2,3,5,4,6,1]

  • ......(重复以上步骤)

  • 经过第一轮冒泡后,最大的数6会被“冒泡”到最后的位置 -> [2,3,4,5,1,6]

第二轮冒泡:

  • 重复上述过程,但是不需要再对最后一个元素进行比较(因为已经是最大的) -> [2,3,4,1,5,6]

......(重复这个过程,每轮冒泡都会把未排序部分的最大值放到正确的位置)

直到整个数组排序完成

性能:

  • 时间复杂度:平均和最坏情况都是O(n^2),其中n是数组的长度。最好的情况是O(n),但这仅在数组已经是排序好的情况下发生。

  • 空间复杂度:O(1),因为它是一种原地排序算法,只需要用到常数级别的额外空间。

优缺点:

  • 优点

    • 简单直观:冒泡排序算法逻辑简单,容易理解和实现。

    • 稳定性:冒泡排序是一种稳定的排序算法,相等的元素在排序后的序列中仍会保持原有的相对顺序。

    • 自适应性:对于已经是部分排序的数列,冒泡排序的效率较高,因为它可以在检测到数列已经排序完成时提前结束。

  • 缺点:

    • 效率低下:在平均和最坏的情况下,冒泡排序的时间复杂度都是O(n^2),这使得它对于大数据集排序来说非常低效。

    • 比较和交换次数多:每次遍历都会进行大量的比较和可能的交换操作,尤其是在数据集接近逆序时。

    • 不适合大数据集:由于其O(n^2)的时间复杂度,冒泡排序在处理大数据集时显得力不从心,会导致性能问题。

JavaScript代码实现:

 arr = [3, 2, 1, 5, 4]
 ​
 function bubbleSort(arr) {
     const length = arr.length
     for (let i = 0; i < length - 1; i++) {
         for (let j = 0; j < length - i - 1; j++) {
             if (arr[j] > arr[j + 1]) {
                 [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
             }
         }
     }
     return arr
 }
  
 ​
 console.log(bubbleSort(arr));  // [1, 2, 3, 4, 5]

2、选择排序

选择排序(Selection Sort)是一种简单直观的排序算法。它的工作原理是:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

算法步骤:

  • 初始状态:设有一个无序的数组R,长度为n,R[0..n-1]表示数组中的所有元素,此时有序区为空,无序区包含所有元素。

  • 循环选择:对于i从0到n-2(因为最后一个元素在之前的步骤中已经被放置到正确的位置,无需再排序),执行以下操作:

    • 在无序区R[i..n-1]中,通过遍历找到最小元素的索引k(如果求最大排序,则找最大元素的索引)。

    • 将R[k]与R[i]交换,使得R[i]成为有序区的最后一个元素,同时k指向的元素被移动到无序区的起始位置。

  • 结束条件:当i等于n-2时,所有元素均已排序,算法结束。

性能:

  • 时间复杂度:选择排序的比较次数是固定的,为n(n-1)/2次,即O(n2)。

  • 空间复杂度:选择排序是原地排序算法,不需要额外的存储空间(除了几个用于交换的临时变量),因此空间复杂度为O(1)。

  • 稳定性:选择排序是不稳定的排序算法。因为在一趟选择中,如果一个元素比当前元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。

优缺点:

  • 优点:

    • 实现简单:选择排序算法的思想直观易懂,实现起来也相对简单,非常适合作为教学或入门级的排序算法。

    • 稳定性独立:尽管选择排序本身是不稳定的(相等的元素可能会改变相对顺序),但在某些情况下,当数据集中不存在大量相等元素时,这一点可能不那么重要。

    • 适应范围广:选择排序可以用于各种类型的数据,包括整数、浮点数、字符串等,因为它仅依赖于元素之间的比较操作。

    • 原地排序:选择排序是一种原地排序算法,它不需要额外的存储空间(除了几个用于交换的临时变量),这对于内存资源受限的环境非常有利。

  • 缺点:

    • 效率低下:选择排序的时间复杂度为O(n^2),在数据量较大时,排序效率非常低。它不适合对性能要求较高的场合。

    • 不稳定:选择排序会破坏原始数据的稳定性,即相等的元素可能会在排序后改变它们的相对顺序。这在某些需要保持元素原始顺序的场合(如排名相同的选手需要保持原有顺序)中是不可接受的。

    • 比较次数多:无论数据集的初始状态如何,选择排序都需要进行大量的比较操作来确定最小(或最大)元素。在最坏情况下(即数据集完全逆序时),比较次数达到n(n-1)/2次,这是非常低效的。

    • 交换次数少但成本高:虽然选择排序的交换次数相对较少(最多n-1次),但每次交换都需要移动大量的数据(尤其是在数组末尾和数组头部之间的交换),这可能导致额外的性能开销。

JavaScript代码实现:

 let arr = [2, 1, 4, 3, 5, 6];
 ​
 function selectionSort(arr) {
     const length = arr.length;
     for (let i = 0; i < length - 1; i++) {
         // 假设当前位置i是最小的
         let minIndex = i;
         // 在i之后的所有元素中寻找比当前元素更小的元素
         for (let j = i + 1; j < length; j++) {
             if (arr[j] < arr[minIndex]) {
                 // 如果找到更小的元素,则更新最小元素的索引
                 minIndex = j;
             }
         }
         // 如果最小元素不是当前元素,则交换它们
         if (minIndex !== i) {
             [arr[i],arr[minIndex]] = [arr[minIndex],arr[i]]
         }
     }
     return arr;
 }
 console.log(selectionSort(arr));  // [1, 2, 3, 4, 5,6]

3、插入排序

插入排序(Insertion Sort)是一种简单直观的排序算法,它通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

算法步骤:

  • 分区操作:

    • 将数组分为已排序区间和未排序区间。初始时,已排序区间只包含数组的第一个元素,而未排序区间包含除第一个元素之外的所有元素。

    • 随着排序的进行,已排序区间的元素逐渐增多,未排序区间的元素逐渐减少。

  • 插入操作:

    • 从未排序区间取出第一个元素(记为当前元素),与已排序区间的元素从后向前逐一比较。

    • 如果当前元素小于或等于已排序区间的某个元素,则将该元素向后移动一位,为当前元素腾出位置。

    • 重复上述比较和移动操作,直到找到当前元素在已排序区间中的正确位置,并将其插入。

  • 重复执行:

    • 重复上述插入操作,直到未排序区间中的所有元素都被插入到已排序区间中,即整个数组排序完成。

排序实例:

假设有一个数组 [4, 2, 5, 1, 3]

  • 初始状态:将数组的第一个元素视为已排序的序列,剩下的元素视为未排序的序列。

    已排序序列:[4] 未排序序列:[2, 5, 1, 3]

  • 第一轮:将未排序序列的第一个元素2与已排序序列的元素进行比较,并将其插入到已排序序列中的正确位置。

    比较过程:24比较,因为2小于4,所以将4后移,将2插入到前面。

    结果:已排序序列变为[2, 4],未排序序列变为[5, 1, 3]

  • 第二轮:将未排序序列的第一个元素5与已排序序列的元素进行比较,并将其插入到已排序序列中的正确位置。

    比较过程:524依次比较,都比它们大,所以直接将其插入到已排序序列的末尾。

    结果:已排序序列变为[2, 4, 5],未排序序列变为[1, 3]

  • 第三轮:将未排序序列的第一个元素1与已排序序列的元素进行比较,并将其插入到已排序序列中的正确位置。

    比较过程:1245依次比较,都比它们小,所以将它们都后移,将1插入到最前面。

    结果:已排序序列变为[1, 2, 4, 5],未排序序列变为[3]

  • 第四轮:将未排序序列的最后一个元素(也是唯一剩下的元素)3与已排序序列的元素进行比较,并将其插入到已排序序列中的正确位置。

    比较过程:31245依次比较,比12大但比4小,所以将45后移,将3插入到24之间。

    结果:已排序序列变为[1, 2, 3, 4, 5],未排序序列为空,排序完成。

性能:

  • 时间复杂度:

    • 最好情况下(数组已经有序),时间复杂度为O(n),因为每个元素只需要比较一次就可以确定其位置。

    • 最坏情况下(数组完全逆序),时间复杂度为O(n^2),因为每个元素都需要与已排序区间的所有元素进行比较。

    • 平均情况下,时间复杂度也为O(n^2)。

  • 空间复杂度:

    • 插入排序是一种原地排序算法,即只需要O(1)的额外空间来存储临时变量(如当前元素)。

  • 稳定性:

    • 插入排序是一种稳定的排序算法,即相同元素的相对位置在排序后不会改变。

优缺点:

  • 优点:

    • 实现简单:插入排序的算法逻辑简单,易于理解和实现。

    • 稳定排序:插入排序是一种稳定的排序算法,即相等元素的相对顺序在排序前后不会改变。

    • 适用于小数据集:对于小规模的数据集,插入排序的效率很高,因为此时数据移动的开销相对较小。

    • 部分已排序的数据效率高:如果数据已经部分排序,插入排序的效率会更高,因为此时数据移动的次数会减少。

  • 缺点:

    • 时间复杂度较高:在最坏的情况下(即输入数组完全逆序),插入排序的时间复杂度为O(n2),其中n是数组的长度。这意味着对于大规模数据集,插入排序可能非常慢。

    • 不适合大规模数据集:由于时间复杂度的限制,插入排序不适合用于大规模数据的排序。

    • 数据移动较多:在插入排序过程中,为了为新元素腾出空间,可能需要移动大量的已排序元素,这增加了排序过程中的开销。

JavaScript代码实现:

 let arr = [2, 1, 4, 3, 5, 6];
 ​
 function insertionSort(arr) {
     let len = arr.length;
     let i, j, key;
     for (i = 1; i < len; i++) {
         key = arr[i]; // 当前需要排序的元素
         j = i - 1;
 ​
         // 将大于key的元素向后移动一位
         while (j >= 0 && arr[j] > key) {
             arr[j + 1] = arr[j];
             j = j - 1;
         }
         arr[j + 1] = key; // 插入key到正确的位置
     }
     return arr;
 }
 console.log(insertionSort(arr)); // [1, 2, 3, 4, 5,6]

4、快速排序

快速排序(Quick Sort)是计算机科学与技术领域中一种非常经典且高效的排序算法,它基于分治法的思想进行排序

原理:

快速排序的基本原理是选择一个基准元素(pivot),然后通过一趟排序将待排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以达到整个数据变成有序序列

算法步骤:

  • 选择基准值(Pivot):从数列中挑出一个元素作为基准值。基准值的选择对快速排序的性能有很大影响,常见的选择方式有取第一个元素、最后一个元素、中间元素或者随机选择一个元素等。为了优化性能,通常会采用随机选择或“三数取中”等方法来选择基准值。

  • 分区(Partitioning):重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。这个分区操作完成后,基准值就处于数列的中间位置。分区操作是快速排序算法中最关键的部分,它使用双指针技术来高效地完成。

  • 递归(Recursion):递归地把小于基准值元素的子数列和大于基准值元素的子数列排序。递归的基是数列的大小是零或一,也就是已经排序好了。

排序实例:

假设我们现在对“6 1 2 7 9 3 4 5 10 8”这10个数进行排序。

  • 第一步:选择基准数

    首先,在这个序列中随便选一个数作为基准数(通常选择第一个数、最后一个数或中间数,或者通过某种策略选取的数)。为了方便,这里选择第一个数6作为基准数。

    第二步:分区操作

    接下来,需要将这个序列中所有比基准数大的数放在6的右边,比基准数小的数放在6的左边。具体过程如下:

    1. 设置哨兵:使用两个变量i和j作为哨兵,分别指向序列的最左边(i=1,指向6)和最右边(j=10,指向8)。

    2. 从右向左找:哨兵j开始向左移动,直到找到一个小于6的数(这里是5)停下来。

    3. 从左向右找:哨兵i开始向右移动,直到找到一个大于6的数(这里是7)停下来。

    4. 交换:交换哨兵i和哨兵j所指向的元素的值,即交换5和7。

    5. 重复上述过程:哨兵j继续向左移动,找到4(小于6),哨兵i继续向右移动,找到9(大于6),再次交换。重复这个过程,直到i和j相遇(在本例中,它们都指向了3)。

    6. 基准数归位:将基准数6与i和j相遇位置的数(这里是3)交换,这样6就归位到了它最终应该在的位置。

    经过上述分区操作后,以6为分界点,6左边的数都小于等于6,6右边的数都大于等于6。此时,原序列被分成了两个子序列:“3 1 2 5 4”和“9 7 10 8”。

    第三步:递归排序

    接下来,对这两个子序列分别进行快速排序。对于子序列“3 1 2 5 4”,以3为基准数进行分区操作;对于子序列“9 7 10 8”,以9为基准数进行分区操作。然后,再对分区后得到的子序列进行排序,直到所有的子序列都只有一个元素或为空,排序就完成了。

性能:

  • 时间复杂度

    • 平均时间复杂度为O(n log n),其中n是数组的长度。

    • 最坏时间复杂度为O(n^2),这通常发生在每次分区操作选择的基准都是最大或最小元素时。为了避免这种情况,通常会采用随机选择基准值或“三数取中”等方法。

  • 空间复杂度:主要是递归栈的空间,平均和最坏情况下都是O(log n),但在最坏情况下会退化到O(n)。

  • 稳定性:快速排序是不稳定的排序算法,因为相同的元素可能在分区过程中交换位置。

优缺点:

  • 优点:

    • 平均性能优秀:在平均情况下,快速排序的时间复杂度为O(n log n),这使得它非常适合用于大规模数据的排序。

    • 原地排序:快速排序是一种原地排序算法,它只需要使用常数的额外空间(递归栈除外),因此在空间使用上相对高效。

    • 分治策略:快速排序采用分治策略,将大问题分解为小问题来解决,这使得它在处理大数据集时非常有效。

    • 适用范围广:快速排序不仅适用于整数排序,也适用于浮点数、字符串等数据的排序。

    • 灵活性强:快速排序算法可以通过调整基准值的选择策略、分区策略等方式进行优化,以适应不同的应用场景。

  • 缺点:

    • 最坏情况性能:在最坏情况下,快速排序的时间复杂度会退化到O(n^2),这通常发生在每次分区操作选择的基准都是最大或最小元素时。尽管这种情况很少见,但在实际应用中仍需注意。

    • 递归深度:快速排序是递归的,递归深度可能会达到O(n),在数据量非常大的情况下可能会导致栈溢出。不过,可以通过尾递归优化、迭代实现等方式来减少递归深度。

    • 不稳定性:快速排序是一种不稳定的排序算法,相同的元素可能在排序过程中交换位置,导致它们的相对顺序发生改变。这对于某些需要保持元素原始顺序的应用场景可能不适用。

    • 对基准值的选择敏感:快速排序的性能受基准值选择的影响较大。如果选择的基准值接近最小值或最大值,可能会导致分区不均匀,从而影响排序效率。因此,在实际应用中需要选择合适的基准值选择策略。

    • 额外的内存开销:虽然快速排序是原地排序算法,但在递归过程中需要额外的栈空间来保存调用信息。虽然这部分空间相对于其他排序算法来说较小,但在数据量非常大时仍需注意栈溢出的风险。

JavaScript代码实现:

 function quickSort(arr, left = 0, right = arr.length - 1) {  
     if (left < right) {  
         // Partition the array by selecting the rightmost element as the pivot  
         const pivotIndex = partition(arr, left, right);  
   
         // 分区前和分区后对元素进行递归排序
         quickSort(arr, left, pivotIndex - 1);  
         quickSort(arr, pivotIndex + 1, right);  
     }  
     return arr;  
 }  
   
 function partition(arr, left, right) {  
     // 选择最右边的元素作为基准值  
     const pivot = arr[right];  
     let i = left - 1; // 较小元素的索引  
   
     for (let j = left; j < right; j++) {  
         // 如果当前元素小于或等于基准值  
         if (arr[j] <= pivot) {  
             i++; // 移动较小元素的索引  
             // 交换arr[i]和arr[j]  
             [arr[i], arr[j]] = [arr[j], arr[i]];  
         }  
     }  
     // 交换arr[i+1]和arr[right](即基准值放到中间)  
     [arr[i + 1], arr[right]] = [arr[right], arr[i + 1]];  
     return i + 1; // 返回基准值的最终位置  
 }  
   
 // 示例用法  
 const arr = [3, 6, 8, 10, 1, 2, 1];  
 console.log(quickSort(arr));  
 // 输出: [1, 1, 2, 3, 6, 8, 10]

5、希尔排序

又称缩小增量排序(Diminishing Increment Sort)

希尔排序(Shell's Sort)是插入排序的一种更高效的改进版本。该算法由D.L.Shell在1959年提出,并因其设计者的名字而得名。希尔排序通过引入一个逐渐递减的步长(增量)来改进插入排序的性能,使得在排序过程中,数据能够更快地接近有序状态,从而减少总的比较和移动次数。

算法步骤:

  • 初始化增量:首先,选择一个初始的增量(步长),这个增量的选择通常与数组的长度有关,如数组长度的一半。

  • 分组排序:根据当前的增量,将数组元素分为若干组,每组内的元素下标之差为当前增量。然后,对每组内的元素进行直接插入排序。

  • 减少增量:完成一轮分组排序后,将增量减小(如每次减半),并重复步骤2,直到增量为1。

  • 最终排序:当增量减至1时,整个数组被分为一组,此时进行一次完整的直接插入排序,完成整个排序过程。

排序实例:

假设一个数组 Arr[] = {76, 13, 49, 38, 27, 65, 76, 49, 30, 1},通过希尔排序将其从小到大排序。

  • 1、确定增量序列

    首先,我们选择一个增量序列,通常初始增量 d 为数组长度的一半,即 d = n / 2,然后每次将增量减半,直到增量为1。对于本例,初始时 n = 10,所以 d = 5

  • 2、分组并插入排序

    根据当前的增量 d,将数组 Arr[] 分割成若干个长度为 d 的子序列(如果最后不足 d 个元素,则剩余的元素自成一组)。然后,对每个子序列进行直接插入排序。

    • d = 5 时,数组被分为两个子序列:{76, 49, 30}{13, 38, 27, 65, 76, 1}(注意这里的分组是跨越的,即按照下标间隔为5来分组)。对每个子序列进行插入排序。

    • 排序后(这里省略具体排序过程),数组可能变为 {30, 49, 76, 13, 27, 38, 65, 76, 1, 49}(注意这只是一个示例结果,实际结果可能不同,因为希尔排序的分组和插入排序过程可能会导致不同的中间结果)。

  • 3、减小增量并重复

    将增量 d 减半,即 d = 2,然后重复第二步的过程。此时,数组被分为 {30, 13, 38, 65, 1}, {49, 27, 76, 76, 49} 两个子序列(注意这里的分组也是跨越的)。对每个子序列进行插入排序。

    • 排序后,数组进一步有序化。

  • 4、直至增量为1

    继续减小增量 d,并重复上述过程,直到 d = 1。当 d = 1 时,整个数组就被视为一个子序列,进行最后一次插入排序。

    • 经过多次分组和插入排序后,最终数组变为有序状态。

性能:

  • 时间复杂度:希尔排序的时间复杂度依赖于所选的增量序列。在理想情况下,当增量序列选择得当,希尔排序的时间复杂度可以达到接近O(n^(3/2)),这远优于普通插入排序的O(n^2)。然而,对于某些增量序列,希尔排序的时间复杂度可能退化为O(n^2)。

    具体来说,不同的增量序列对希尔排序的性能有显著影响。例如,Hibbard序列(如1, 3, 7, ..., 2^k-1)和Sedgewick序列(如一种更为精细的自适应增量序列)在实验中表现出较好的性能,其最坏情形下的时间复杂度分别为O(n^1.5)和O(n^1.3)。而简单的递减增量序列(如每次将增量减半)虽然实现简单,但可能不是最优的选择

  • 空间复杂度:希尔排序是原地排序算法,它不需要额外的存储空间来存储待排序的数据,因此其空间复杂度为O(1)。这意味着希尔排序在内存资源有限或对内存消耗敏感的环境中具有优势。

  • 稳定性:希尔排序是一种不稳定的排序算法。在排序过程中,由于数据被分组并进行了多次插入排序,相同元素的相对位置可能会发生改变。因此,对于需要保持相等元素相对顺序的场景,希尔排序可能不适用。

优缺点:

  • 优点:

    • 效率高:希尔排序通过引入增量,使得在排序初期,元素能够较快地移动到其最终位置附近,从而减少了后续排序的工作量。

    • 适应性强:希尔排序对于中等规模的数据排序表现良好,特别是对于部分有序的数据集,其性能优于传统的插入排序。

    • 实现简单:希尔排序的算法实现相对简单,代码量较少,易于理解和实现。

  • 缺点:

    • 增量选择:希尔排序的性能很大程度上取决于增量的选择。如果增量序列选择不当,可能会导致排序效率降低。

    • 最坏情况性能:虽然希尔排序的平均性能较好,但在最坏情况下,其时间复杂度可能达到O(n^2),尽管这种情况较为罕见。

    • 稳定性:希尔排序是一种不稳定的排序算法,即相等的元素在排序后可能会改变其相对顺序。

JavaScript代码实现:

 function shellSort(arr) {  
     let n = arr.length;  
     let gap = Math.floor(n / 2); // 初始增量  
   
     while (gap > 0) {  
         // 对每个分组进行插入排序  
         for (let i = gap; i < n; i++) {  
             let temp = arr[i];  
             let j;  
             for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {  
                 arr[j] = arr[j - gap]; // 元素后移  
             }  
             arr[j] = temp; // 插入正确位置  
         }  
         gap = Math.floor(gap / 2); // 减小增量  
     }  
     return arr;  
 }  
   
 // 示例  
 let arr = [64, 34, 25, 12, 22, 11, 90];  
 console.log("Original array:", arr);  
 shellSort(arr);  
 console.log("Sorted array:  ", arr);

6、基数排序

基数排序(Radix Sort)也是一个分布式排序算法,它根据数字的有效位或基数(这也是它为什么叫基数排 序)将整数分布到桶中。简单来说是根据最大数的位数来确定排序的次数,排序的时候分别从个位,十位,百位等分别进行排序。

基本思想:

基本思想是将整数按位数切割成不同的数字,然后按每个位数分别进行比较排序。

算法步骤:

  • 1、确定最大数的位数

    首先,需要找出待排序数组中最大数的位数,以确定需要进行多少轮排序。这一步骤是基数排序的基础,因为它决定了后续排序的轮数和每轮排序需要处理的位数。

  • 2、统一数位长度

    将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。这一步骤是为了确保在排序过程中,每个数都能够被正确地分配到对应的桶中,避免因为数位不同而导致的错误。

  • 3、按位排序

    从最低位(或最高位,取决于使用的是LSD还是MSD方法)开始,依次进行排序。在每一轮排序中,根据当前位的数值,将元素分配到对应的桶中。这一步骤是基数排序的核心,它利用了桶排序的思想,通过分配和收集操作来实现对元素的排序。

  • 4、合并桶中元素

    将各个桶中的元素依次取出,合并成一个新的有序数组。这一步骤是在每一轮排序结束后进行的,它将上一轮排序的结果作为下一轮排序的输入,直到所有位数都排序完毕。

  • 5、重复步骤三和四

    对更高位进行排序,直到最高位排序完成。这一步骤是基数排序的迭代过程,通过不断重复步骤三和四,直到所有位数都按照从小到大的顺序排列好。

排序实例:

假设有一个待排序的整数数组:[170, 45, 75, 90, 802, 24, 2, 66]

  • 确定最大位数:

    • 首先,找出数组中的最大数802,它有三位数。

  • 按个位排序:

    • 将数组中的元素根据个位的值分配到不同的桶中(这里假设有10个桶,对应0-9)。

    • 然后按桶的顺序合并回原数组,排序后可能得到(注意:实际排序可能因实现方式而异):

      [2, 24, 45, 66, 75, 90, 170, 802]。但此步仅为示意,具体实现时可能直接操作桶而不显式合并回原数组。

  • 按十位排序:

    • 对上一步排序后的数组再次进行排序,这次是根据十位的值进行分配。

    • 分配和收集过程与按个位排序相同,排序后数组进一步有序化。

  • 按百位排序(如果数组中有三位数的话):

    • 继续上述过程,对十位排序后的数组按百位进行排序。

    • 由于本例中最大数只有三位,所以排序到百位即可完成。

排序结果:经过上述三步排序后,数组最终变为有序数组:[2, 24, 45, 66, 75, 90, 170, 802]

性能:

  • 时间复杂度:时间复杂度通常为O(d(n+k)),其中d是最大数的位数,n是数组的长度,k是桶的数量。

  • 空间复杂度:空间复杂度为O(n+k),n 是待排序元素的数量,k 是桶的数量或与之相关的存储需求,需要额外的存储空间用于计数排序或桶排序过程中的临时存储。

  • 稳定性:基数排序是一种稳定的排序算法。在排序过程中,相同数值的元素会保持原有的顺序,即相等元素在排序后的序列中相对位置不变。这一特性使得基数排序在处理需要保持元素原始顺序的场景时非常有用。

优缺点:

  • 优点:

    • 效率高:对于一定范围内的整数排序,基数排序的时间复杂度可以达到O(nk),其中n是排序元素的个数,k是数字的最大位数。这使得基数排序在处理大量数据且数据范围不大时非常高效。

    • 稳定排序:基数排序是一种稳定的排序算法,即相等元素的相对顺序在排序前后保持不变。

    • 可扩展性:基数排序易于扩展到非数字数据,如字符串的字典序排序。

    • 易于实现:基数排序的算法思想相对直观,实现起来并不复杂。

  • 缺点:

    • 依赖数据特性:基数排序的性能高度依赖于待排序数据的特性,特别是数据的范围。如果数据范围很大,而实际使用的数字范围较小,可能会浪费大量的时间和空间。

    • 内存使用:基数排序可能需要额外的存储空间来存储排序过程中的临时数据,特别是在使用LSD方法时。这可能会成为处理大数据集时的一个瓶颈。

    • 对浮点数和小数排序复杂:虽然基数排序可以处理浮点数和小数,但实现起来相对复杂,需要额外处理小数点位置和小数部分的比较。

    • 不是通用排序算法:虽然基数排序在某些特定情况下非常有效,但它并不适合作为所有排序任务的通用解决方案。对于无序或范围广泛的数据集,其他排序算法(如快速排序、归并排序)可能更加合适。

JavaScript代码实现:

       function radixSort(arr) {
         const base = 10;
         let divider = 1;
         let max = Math.max(...arr);
         while (divider <= max) {
           const buckets = [...Array(10)].map(() => []);
           for (let i of arr) {
             buckets[Math.floor(i / divider) % base].push(i);
           }
           arr = [].concat(...buckets);
           divider *= 10;
         }
         return arr;
       }
 ​
       // 测试基数排序
       let arr = [170, 45, 75, 90, 802, 24, 2, 66];
       console.log(radixSort(arr));
       // 输出: [2, 24, 45, 66, 75, 90, 170, 802]

7、归并排序

归并排序(Merge Sort)是一种建立在归并操作上的有效、稳定的排序算法,它采用了分治策略(Divide and Conquer)。

原理:

归并排序的基本思想是将一个未排序的数组分割成两个或更多的子数组,直到每个子数组只包含一个元素(此时认为它是已排序的),然后将这些子数组归并成较大的有序数组,直到最后只剩下一个排序完毕的数组。

算法步骤:

  • 1、分解

    将数组分解成两个较小的子数组,直到子数组的大小为1。这是递归的终止条件,因为单个元素的数组自然是有序的。

  • 2、递归排序子数组

    递归地对每个子数组进行归并排序。由于每个子数组的大小都在逐渐减小,最终都会达到递归的终止条件,即子数组的大小为1。

  • 3、合并

    将已排序的子数组合并成一个有序的大数组。这是归并排序的核心步骤,通过比较两个子数组中的元素,将它们按顺序合并到一个新的数组中。

排序实例:

假设有一个无序数组 [5, 3, 8, 4, 2]

  • 分解:首先,将数组分解成最小的子数组,每个子数组只包含一个元素。在这个例子中,分解后的子数组为 [5], [3], [8], [4], [2]

  • 递归排序:然后,递归地对这些子数组进行排序。但实际上,由于每个子数组只有一个元素,它们已经“排序”好了(即每个子数组内部都是有序的)。

  • 合并:接下来,算法开始合并相邻的子数组,并按大小顺序排列。合并的规则是:比较两个子数组的最小元素,将较小的元素先放入一个新的临时数组中,然后移动指针到下一个元素,重复这个过程,直到所有的元素都被合并到临时数组中。最后,将临时数组中的元素复制回原数组。

    • 合并 [5][3] 得到 [3, 5]

    • 合并 [8][4] 得到 [4, 8](注意保持稳定性,即相同元素顺序不变,但在这个例子中两个数组只有一个元素)

    • 然后合并 [3, 5][4],因为 [3, 5] 已经是一个有序数组,我们只需将 [4] 插入到 [3, 5] 的正确位置,得到 [3, 4, 5]

    • 最后合并 [3, 4, 5][2],将 [2] 插入到 [3, 4, 5] 的最前面,得到最终的有序数组 [2, 3, 4, 5, 8]

性能:

  • 时间复杂度:归并排序的平均、最好和最坏情况下的时间复杂度均为O(n log n),其中n是数组的长度。这是因为每次归并操作都需要O(n)的时间,而归并操作的次数与树的高度(即log n)成正比。

  • 空间复杂度:归并排序需要额外的存储空间来存放合并后的数组,因此其空间复杂度为O(n)。

  • 稳定性:归并排序是稳定的排序算法,即相等的元素在排序后的顺序与它们在原数组中的顺序相同。

优缺点:

  • 优点:

    • 时间效率高:

      • 归并排序的时间复杂度为O(n log n),这是基于比较的排序算法所能达到的最好时间复杂度。无论是在最好情况、最坏情况还是平均情况下,其时间复杂度都保持不变,因此具有很高的效率。

    • 稳定性:

      • 归并排序是一种稳定的排序算法,即相同元素的相对顺序在排序前后不会发生变化。这种稳定性在某些特定应用场景下非常重要,比如需要保持数据原有顺序的场合。

    • 适用广泛:

      • 归并排序不仅适用于内部排序(即数据全部存放在内存中),还适用于外部排序(即数据存放在外存上,内存无法容纳全部数据)。这使得归并排序在处理大规模数据集时仍然具有实用价值。

    • 易于理解和实现:

      • 归并排序采用了分治法的思想,将大问题分解为小问题来解决,这种思想使得算法的逻辑结构清晰,易于理解和实现。

  • 缺点:

    • 空间复杂度高:

      • 归并排序在排序过程中需要额外的存储空间来存储临时合并的数组,因此其空间复杂度为O(n)。这对于内存受限的系统来说可能是一个问题。

    • 递归开销:

      • 如果使用递归实现归并排序,那么递归调用栈的深度会随着输入数据规模的增大而增大。当递归深度过大时,可能会导致栈溢出的问题。尽管现代编译器和操作系统对递归有很好的优化,但在某些极端情况下仍需注意。

    • 小数据集效率低:

      • 对于小数据集来说,归并排序可能不如其他简单的排序算法(如插入排序、选择排序等)效率高。因为归并排序的常数因子较大,涉及到递归和合并操作,这些操作在数据集较小时会产生较大的开销。

JavaScript代码实现:

 function mergeSort(arr) {  
     if (arr.length < 2) {  
         // 基准情况:数组只有一个元素或为空,直接返回  
         return arr;  
     }  
   
     // 找到数组的中间位置,将数组分为左右两部分  
     const middle = Math.floor(arr.length / 2);  
     const left = arr.slice(0, middle);  
     const right = arr.slice(middle);  
   
     // 递归地对左右两部分进行归并排序  
     return merge(mergeSort(left), mergeSort(right));  
 }  
   
 function merge(left, right) {  
     let result = [], indexLeft = 0, indexRight = 0;  
   
     // 当左右两部分都还有元素时,比较并合并  
     while (indexLeft < left.length && indexRight < right.length) {  
         if (left[indexLeft] < right[indexRight]) {  
             result.push(left[indexLeft]);  
             indexLeft++;  
         } else {  
             result.push(right[indexRight]);  
             indexRight++;  
         }  
     }  
   
     // 如果左边还有剩余元素,直接添加到结果数组中  
     while (indexLeft < left.length) {  
         result.push(left[indexLeft]);  
         indexLeft++;  
     }  
   
     // 如果右边还有剩余元素,直接添加到结果数组中  
     while (indexRight < right.length) {  
         result.push(right[indexRight]);  
         indexRight++;  
     }  
   
     return result;  
 }  
   
 // 示例  
 const array = [38, 27, 43, 3, 9, 82, 10];  
 console.log("Original array:", array);  
 const sortedArray = mergeSort(array.slice()); // 使用slice()来避免直接修改原始数组  
 console.log("Sorted array:  ", sortedArray);

8、堆排序

堆排序是一种基于二叉堆数据结构的排序算法,它利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即子节点的键值或索引总是小于(或者大于)它的父节点。

基本思想:

堆排序的基本思想是:将待排序的序列构造成一个大顶堆(或小顶堆),此时,整个序列的最大值(或最小值)就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值(或最小值)。然后将剩余n-1个序列重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。

算法步骤:

  • 1. 构建堆

    • 从最后一个非叶子节点开始:对于长度为n的数组,最后一个非叶子节点的索引为(n-2)/2(因为数组索引通常从0开始,且最后一个非叶子节点是最后一个叶子节点的父节点)。

    • 向上调整(Heapify):从最后一个非叶子节点开始,向上遍历每个节点,对每个节点执行堆调整操作(上浮或下沉),以确保以该节点为根的子树满足堆的性质。堆调整的过程是,如果父节点的值小于(对于最大堆)或大于(对于最小堆)其子节点的值,则交换父节点与子节点中的较大(或较小)值,并继续对新的父节点进行相同的操作,直到满足堆的性质或到达堆的顶部。

  • 2. 交换堆顶元素与堆尾元素

    • 交换:将堆顶元素(即当前序列中的最大或最小值)与堆的最后一个元素进行交换。这样,堆顶元素就被放到了它在排序后序列中正确的位置上。

  • 3. 重新调整堆

    • 调整堆的大小:将交换后的堆的大小减一(因为最后一个元素已经被放到了正确的位置上,不再参与后续的堆操作)。

    • 堆调整:对新的堆顶元素执行堆调整操作,以恢复堆的性质。这一步是为了确保剩余的元素仍然构成一个最大堆(或最小堆)。

  • 4. 重复步骤2和3

    • 不断重复:不断重复交换堆顶元素与堆尾元素,并重新调整堆的过程,直到堆的大小减至1。此时,整个序列就被排序完成了。

排序实例:

假设有一个数组

  • 1. 构建最大堆

    • 确定最后一个非叶子节点:数组长度为5,最后一个非叶子节点的索引为 (5-2)/2 = 1(因为数组索引从0开始)。

    • 从最后一个非叶子节点开始,向上调整每个节点

      • 对于索引为1的节点(值为6),其左子节点为2(值为8),右子节点为3(值为5)。由于8>6,交换6和8。

      • 交换后,索引为1的节点变为8,其左子节点为2(现为6),右子节点为3(值为5)。由于8>6且8>5,无需交换。

      • 接着调整索引为0的节点(值为4),其左子节点为1(现为8),右子节点为2(值为6)。由于8>4且6>4,将4与8交换。

      • 交换后,索引为0的节点变为8,此时需要继续调整以8为根的子树,确保满足最大堆的性质。经过调整,最终得到最大堆 [9, 6, 8, 5, 4]

  • 2. 交换堆顶元素与堆尾元素,并重新调整堆

    • 交换堆顶元素(最大值)与堆尾元素:将9与4交换,得到 [4, 6, 8, 5, 9],此时9已在正确的位置上。

    • 调整剩余元素构成的堆:将剩余元素[4, 6, 8, 5]重新调整为最大堆。

      • 从新的堆顶元素(值为4)开始调整,最终得到 [6, 4, 8, 5]

    • 重复交换与调整过程:继续将堆顶元素(6)与当前堆的最后一个元素(5)交换,并调整剩余元素构成的堆,直到整个数组排序完成。

  • 3. 重复步骤2,直到排序完成

    经过多次交换与调整,最终得到排序后的数组 [4, 5, 6, 8, 9]

性能:

  • 事件复杂度:

    • 最坏情况、平均情况和最好情况:堆排序的时间复杂度在所有情况下都是O(n log n),其中n是数组(或列表)中元素的数量。这是因为堆排序主要包括两个步骤:构建堆(Build Heap)和排序堆(Heapify & Extract Max/Min)。构建堆的时间复杂度是O(n),而排序堆的过程需要执行n-1次删除堆顶元素并重新调整堆的操作,每次调整的时间复杂度是O(log n),因此总的时间复杂度是O(n log n)。

    • 比较次数:堆排序的比较次数相对固定,大致为O(n log n),因为每个元素在堆的调整过程中都会经历从堆底到堆顶(或相反)的路径,这条路径的长度大约是log n。

  • 空间复杂度:

    • 原地排序:堆排序是一种原地排序算法,它只需要使用常量级别的额外空间(除了几个用于交换的临时变量和可能的递归栈空间,如果使用递归实现的话)。因此,堆排序的空间复杂度是O(1)。

优缺点:

  • 优点:

    • 效率高:堆排序的平均时间复杂度和最坏时间复杂度均为O(n log n),在大数据集上排序时表现出色。

    • 原地排序:堆排序只需要使用常量级别的额外空间,除了几个临时变量和可能需要的栈空间(用于递归),实现了原地排序。

    • 稳定性:虽然堆排序本身不是稳定的排序算法(即相等的元素可能在排序后改变顺序),但在实际应用中,由于其高效率,常常可以接受这种不稳定性。

    • 自适应性:堆排序能较好地利用硬件缓存,因为其每次重建堆时都是从数组的开始到结束连续进行的,这种线性遍历的性质有助于提升缓存的命中率。

  • 缺点:

    • 不稳定性:如前所述,堆排序是一种不稳定的排序算法,因为它不保证具有相同值的元素在排序后的相对顺序与它们在原数组中的相对顺序相同。

    • 初始化堆:堆排序需要在排序前先将数组元素构造成一个堆,这个过程虽然复杂度是O(n),但相对于简单的线性遍历,它需要更多的比较和交换操作。

    • 适用场景限制:堆排序适用于数据规模较大的场景,因为当数据量较小时,快速排序等其他算法可能通过较少的比较和交换达到更优的性能。

    • 递归深度:尽管堆排序可以使用迭代而非递归实现,但如果采用递归方式,递归的深度会达到O(log n),在极端情况下(如非常深的树结构)可能会导致栈溢出。

JavaScript代码实现:

 function heapify(arr, n, i) {  
     let largest = i; // 初始化最大值为根  
     let left = 2 * i + 1; // 左 = 2*i + 1  
     let right = 2 * i + 2; // 右 = 2*i + 2  
   
     // 如果左子节点大于根  
     if (left < n && arr[left] > arr[largest]) {  
         largest = left;  
     }  
   
     // 如果右子节点大于目前的最大值  
     if (right < n && arr[right] > arr[largest]) {  
         largest = right;  
     }  
   
     // 如果最大值不是根  
     if (largest !== i) {  
         // 交换  
         [arr[i], arr[largest]] = [arr[largest], arr[i]];  
   
         // 递归地调整受影响的子树  
         heapify(arr, n, largest);  
     }  
 }  
   
 // 构建最大堆  
 function buildMaxHeap(arr) {  
     let n = arr.length;  
   
     // 从最后一个非叶子节点开始向上构建最大堆  
     for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {  
         heapify(arr, n, i);  
     }  
 }  
   
 // 堆排序函数  
 function heapSort(arr) {  
     let n = arr.length;  
     buildMaxHeap(arr);  
   
     // 一个个从堆顶取出元素  
     for (let i = n - 1; i > 0; i--) {  
         // 移动当前根到末尾  
         [arr[0], arr[i]] = [arr[i], arr[0]];  
   
         // 调用max heapify在减少的堆上  
         heapify(arr, i, 0);  
     }  
 }  
   
 // 示例  
 let arr = [12, 11, 13, 5, 6, 7];  
 heapSort(arr);  
 console.log(arr); // 输出: [5, 6, 7, 11, 12, 13]
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值