纯js常用排序算法+ 详细讲解 + 代码 + 时间、空间复杂度讲解,涵盖冒泡、快排、选择、希尔、插入、归并、堆排序

381 阅读12分钟

本文主要内容为目前市面上常用的7种排序算法,涵盖冒泡、快排、选择、希尔、插入、归并、堆排序,另包含复杂度的讲解,纯js写法,帮助大家理解各类排序算法~

一、时间复杂度和空间复杂度

想要弄懂排序,知道怎么使用排序,必须对时间复杂度以及空间复杂度有所了解。

  • 时间复杂度:时间复杂度是一个函数,它定量描述了算法的运行时间。它衡量的是一个算法在执行时间(速度)上的优劣。简单来说,时间复杂度就是用来估计程序运行时间的。我们通常会估计算法的操作单元数量,来代表程序消耗的时间。

  • 空间复杂度:空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。它反映的是一个趋势,我们用 S (n) 来定义。空间复杂度比较常用的有:O (1)、O (n)、O (n²)。简单来说,空间复杂度就是用来估计程序运行时所占用的内存空间的。

时间复杂度是用来度量算法的运行时间,记作: T(n) = O(f(n))。它表示随着输入大小n的增大,算法执行需要的时间的增长速度可以用f(n)来描述。下面是一些计算时间复杂度的例子:

(一)时间复杂度举例

例1:一个简单的for循环

int sum = 0;
for (int i = 0; i < n; i++) {
    sum += i;
}

这个例子中,for循环内部的代码会执行n次,因此它的时间复杂度为O(n)。

例2:一个嵌套循环

int sum = 0;
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        sum += i * j;
    }
}

这个例子中,内层循环会执行n次,外层循环也会执行n次,因此总共会执行n * n次,所以它的时间复杂度为O(n^2)。

例3:斐波那契数列

int fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

这个例子中,fib函数会递归调用自身两次,每次调用时n的值减少1。因此,它的时间复杂度为O(2^n)。 我们可以将斐波那契数列的递归调用过程看作一棵树,是因为每次调用fib函数时,都会产生两个新的递归调用,分别计算fib(n-1)和fib(n-2)。这两个调用又会产生更多的递归调用,以此类推。

因此,我们可以将初始的fib(n)调用看作根节点,每个节点的两个子节点分别表示fib(n-1)和fib(n-2)的调用。这样,整个递归调用过程就可以表示为一棵树。

其中根节点表示初始的fib(n)调用,每个节点的两个子节点分别表示fib(n-1)和fib(n-2)的调用。这棵树的深度为n,每一层的节点数都是上一层节点数的两倍。因此,整棵树的节点数为2^0 + 2^1 + 2^2 + … + 2^n = 2^(n+1) - 1。画了一张图帮助大家理解~~

image.png

由于每个节点都对应一次fib函数的调用,所以总共会有2^(n+1) - 1次调用。因此,这个算法的时间复杂度为O(2^n)。 在计算时间复杂度时,我们通常只关注函数中主导增长的部分,也就是最高阶项。在这个例子中,最高阶项是2n,所以我们忽略了常数和低阶项,将时间复杂度表示为O(2n)。

这样做的原因是,当n变得足够大时,最高阶项会主导函数的增长,而常数和低阶项的影响会变得微不足道。因此,在比较不同算法的效率时,我们只关注最高阶项,忽略常数和低阶项。

(二)空间复杂度举例

下面是一些空间复杂度的例子:

  1. 空间复杂度为O(1)的例子:
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;

在这段代码中,变量i、j、m所分配的空间都不随着处理数据量变化,因此它的空间复杂度为S(n) = O(1)。

  1. 空间复杂度为O(n)的例子:
int[] m = new int[n];
for (i = 1; i <= n; ++i)
{
    j = i;
    j++;
}

在这段代码中,第一行new了一个数组出来,这个数组占用的大小为n。这段代码的第2-6行,虽然有循环,但没有再分配新的空间。因此,这段代码的空间复杂度主要看第一行即可,即S(n) = O(n)。

3、下面是一个空间复杂度为O(n²)的例子:

int[][] matrix = new int[n][n];
for (int i = 0; i < n; i++)
{
    for (int j = 0; j < n; j++)
    {
        matrix[i][j] = i + j;
    }
}

在这段代码中,第一行new了一个二维数组出来,这个数组占用的大小为n * n。这段代码的第2-8行,虽然有循环,但没有再分配新的空间。因此,这段代码的空间复杂度主要看第一行即可,即S(n) = O(n²)。

4、递归算法的空间复杂度通常与递归深度有关。每次递归调用时,都会在系统栈中压入一个新的栈帧,用于保存当前函数的局部变量、参数和返回地址等信息。因此,递归算法的空间复杂度与递归深度成正比。

例如,下面是一个计算阶乘的递归函数:

int factorial(int n)
{
    if (n == 0)
        return 1;
    else
        return n * factorial(n - 1);
}

这个函数的递归深度为n,因此它的空间复杂度为O(n)。

斐波那契数列也是一样

int fibonacci(int n)
{
    if (n <= 0)
        return 0;
    else if (n == 1)
        return 1;
    else
        return fibonacci(n - 1) + fibonacci(n - 2);
}

这个递归函数的空间复杂度取决于递归树的深度,也就是n。因此,这个递归函数的空间复杂度为O(n)。

二、排序算法讲解 (没有先后之分都需要掌握哦)
一、冒泡排序
(一)冒泡的概念与过程

冒泡排序(Bubble Sort)是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端 。

原始数据: 5 2 4 6 1 3

第一轮排序: 2 5 4 6 1 3
    - 比较第1个和第2个数据,发现2<5,不交换。
    - 比较第2个和第3个数据,发现5>4,交换。
    - 比较第3个和第4个数据,发现4<6,不交换。
    - 比较第4个和第5个数据,发现6>1,交换。
    - 比较第5个和第6个数据,发现1<3,不交换。

第二轮排序: 2 4 5 6 1 3
    - 比较第1个和第2个数据,发现2<4,不交换。
    - 比较第2个和第3个数据,发现4<5,不交换。
    - 比较第3个和第4个数据,发现5<6,不交换。
    - 比较第4个和第5个数据,发现6>1,交换。
    - 比较第5个和第6个数据,发现1<3,不交换。

第三轮排序: 2 4 5 1 6 3
    - 比较第1个和第2个数据,发现2<4,不交换。
    - 比较第2个和第3个数据,发现4<5,不交换。
    - 比较第3个和第4个数据,发现5>1,交换。
    - 比较第4个和第5个数据,发现5<6,不交换。
    - 比较第5个和第6个数据,发现6>3,交换。

以此类推直到最后一轮排序完成。
  
(二)冒泡排序的代码
let arr = [5, 2, 4, 6, 1,3]
  // 每一轮都比上一次少了i次
  function sortBul(arr) {
    for (let i = 0; i < arr.length-1; i++) {
      for (let j = 0; j < arr.length - i-1 ; j++) {
        if (arr[j] > arr[j + 1]) {
          let temp = arr[j + 1]
          arr[j + 1] = arr[j]
          arr[j] = temp
        }
      }
    }
    return arr
  }
  console.log('sortBul', sortBul(arr))
(三)冒泡复杂度分析

冒泡排序的时间复杂度为O(n^2),空间复杂度为O(1)。

时间复杂度的计算方法是,外层循环需要n-1次,内层循环需要n-1-i次,所以总次数为(n-1)+(n-2)+...+2+1=n(n-1)/2,即O(n^2)。

空间复杂度为O(1),因为只需要一个临时变量来交换两个元素的值即可。

二、快速排序
(一)快排的概念和过程

快排的思想:快速排序是一种高效的排序算法。它的基本思想是通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另一部分的所有数据要小,再按这种方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,使整个数据变成有序。

详细说明

假设我们有一个数组 [8, 5, 2, 9, 7, 6, 3],我们选择第一个元素 8 作为基准。接下来我们进行分区操作,将所有小于 8 的元素放到它的左边,所有大于等于 8 的元素放到它的右边。因此需要一个主函数sort,一个实现了快速排序中的分区操作的partition 函数。

function sort(arr, startIndex, endIndex) {
    // 递归结束条件:startIndex大于等于endIndex的时候
    if (startIndex >= endIndex) {
      return
    }
    // 得到基准元素的位置
    let pivotIndex = partition(arr, startIndex, endIndex)
    sort(arr, startIndex, pivotIndex - 1)
    sort(arr, pivotIndex + 1, endIndex)
  }
  function partition(arr, startIndex, endIndex) {
  
      // todo something

  }
  let arr = [8, 5, 2, 9, 7, 6, 3]
  sort(arr, 0, arr.length - 1)
  console.log(arr)
  • 使用两个指针,左指针指向数组的开头,右指针指向数组的结尾。函数首先选择第一个元素作为基准元素,并初始化左右指针和基准元素位置变量 index

  • 然后进入一个外层循环,在循环中先移动右指针,找到第一个小于基准元素的元素,并将其移到左指针所指向的位置,同时更新基准元素位置变量 index 的值并将左指针向右移动一位。然后移动左指针,找到第一个大于基准元素的元素,并将其移到右指针所指向的位置,同时更新基准元素位置变量 index 的值并将右指针向左移动一位。

  • 当左右指针重合时,外层循环结束。

  • 最后将基准元素移到正确的位置上,并返回该位置。初始时数组为 [8, 5, 2, 9, 7, 6, 3]。调用 sort(arr, 0, arr.length - 1) 开始排序。首先调用 partition(arr, 0, 6) 对整个数组进行分区。分区操作结束后,数组变成了 [3, 5, 2, 6, 7, 9, 8],并返回基准元素位置 6。

  • 然后递归地对左右两个子数组进行快速排序。对于左边的子数组 [3, 5, 2, 6, 7],调用 sort(arr, 0, 5) 进行排序。首先调用 partition(arr, 0, 5) 进行分区。分区操作结束后,数组变成了 [2, 3, 5, 6, 7],并返回基准元素位置 1。

  • 接下来递归地对 [2] 和 [5,6,7] 进行快速排序。由于 [2] 只有一个元素,所以不需要进行任何操作;对于 [5,6,7],调用 sort(arr,2,arr.length-1) 进行排序。首先调用 partition(arr,2,arr.length-1) 进行分区。分区操作结束后,数组不变。

(二)快排的代码
// 快排 代码
let arr = [3, 5, 2, 6, 7, 9, 8]
function sort(arr, startIndex, endIndex) {
    // 递归结束条件:startIndex大于等于endIndex的时候
    if (startIndex >= endIndex) {
      return
    }
    // 得到基准元素的位置
    let pivotIndex = partition(arr, startIndex, endIndex)
    sort(arr, startIndex, pivotIndex - 1)
    sort(arr, pivotIndex + 1, endIndex)
  }
  function partition(arr, startIndex, endIndex) {
    // 选择第一个位置的元素作为基准元素
    let pivot = arr[startIndex]
    let left = startIndex
    let right = endIndex
    let index = startIndex
    // 外循环在左右指针重合或者交错的时候结束
    while (right !== left) {
      // right指针从右向左进行比较
      while (right > left) {
        if (arr[right] < pivot) {
          arr[left] = arr[right]
          index = right
          left++
          break
        }
        right--
      }
      console.log('index', index)

      // left指针从左向右进行比较
      while (right > left) {
        if (arr[left] > pivot) {
          arr[right] = arr[left]
          index = left
          right--
          break
        }
        left++
      }
    }
    arr[index] = pivot
  console.log('index',index);

    return index
  }
  sort(arr, 0, arr.length - 1)
  console.log(arr)
(三)快排的复杂度分析

快速排序的时间复杂度为O(nlogn),空间复杂度为O(logn)。

时间复杂度的计算方法是,每次分割需要n次比较,而分割的次数为logn,所以总次数为nlogn,即O(nlogn)。

空间复杂度为O(logn),因为需要递归调用栈来存储每一层的数据,而栈的深度为logn

三、选择排序
(一)选择的概念和过程

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

举例:

假设我们有一个整数数组 [29, 10, 14, 37, 13],我们要使用选择排序对其进行升序排序。

  • 从数组中找到最小的元素,也就是10,将其与第一个元素29交换位置。此时数组变为 [10, 29, 14, 37, 13]。

  • 在剩下的元素中找到最小的元素,也就是13,将其与第二个元素29交换位置。此时数组变为 [10, 13, 14, 37, 29]。

  • 在剩下的元素中找到最小的元素,也就是14,但它已经在正确的位置上了,所以不需要交换。

  • 在剩下的元素中找到最小的元素,也就是29,将其与第四个元素37交换位置。此时数组变为 [10, 13, 14, 29, 37]。

  • 此时所有元素都已经排好序了。

(二)选择排序的代码
// 选择排序
    // 每一轮找到最小的值  交换 随后 依次找最小的值 将它放到最小数列后面
// minIndex 每次循环都加1
let arr = [29, 10, 14, 37, 13]

function select(arr) {
  for (let i = 0; i < arr.length; i++) {
    debugger
    let minIndex = i
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[j] < arr[minIndex]) {
        minIndex = j
      }
    }

    let temp = arr[i];
    arr[i] = arr[minIndex];
    arr[minIndex] = temp
  }
  return arr

}
console.log('select', select(arr));
(三)选择排序的复杂度分析

选择排序的时间复杂度为O(n^2),空间复杂度为O(1)。

时间复杂度的计算方法是,每次需要n次比较,而需要比较n-1次,n-2次,...,1次,所以总次数为n(n-1)/2,即O(n^2)。

空间复杂度为O(1),因为只需要一个额外的变量来存储最小值的下标。

四、归并排序
(一)归并排序的概念和过程

思想:归并排序是一种分治算法。它的基本思想是将一个大的数组分成两个较小的子数组,然后对这两个子数组分别进行排序,最后将两个已排序的子数组合并在一起,形成一个完整的已排序数组。

举例子:

假设我们有一个数组 [9, 3, 22, 8, 2, 98, 35],我们想要对它进行升序排序。 好的,让我们来看一个新的例子。假设我们有一个数组 [9, 3, 22, 8, 2, 98, 35],我们想要对它进行升序排序。

首先,我们调用 mergeSort([9, 3, 22, 8, 2, 98, 35])。在 mergeSort 函数中,我们将数组分成两半:left = [9, 3, 22]right = [8, 2, 98, 35]

然后,我们对这两个子数组分别进行递归排序:

  • 调用 mergeSort([9, 3, 22]) 对左边的子数组进行排序
    • mergeSort([9, 3, 22]) 中,我们将数组分成两半:left = [9]right = [3 ,22]
    • 然后,对这两个子数组分别进行递归排序:
      • 调用 mergeSort([9]) 对左边的子数组进行排序。由于数组长度为1,递归结束,返回 [9]
      • 调用 mergeSort([3 ,22]) 对右边的子数组进行排序
        • mergeSort([3 ,22]) 中,我们将数组分成两半:left = [3]right = [22]
        • 然后,对这两个子数组分别进行递归排序:
          • 调用 mergeSort([3]) 对左边的子数组进行排序。由于数组长度为1,递归结束,返回 [3]
          • 调用 mergeSort([22]) 对右边的子数组进行排序。由于数组长度为1,递归结束,返回 [22]
        • 最后,调用 merge([3], [22]) 将两个已排序的子数组合并在一起,得到 [3 ,22]
    • 最后,调用 merge([9], [3 ,22]) 将两个已排序的子数组合并在一起,得到 [3 ,9 ,22]
  • 调用 mergeSort([8 ,2 ,98 ,35]) 对右边的子数组进行排序
    • mergeSort([8 ,2 ,98 ,35]) 中,我们将数组分成两半:left = [8 ,2]right = [98 ,35]
    • 然后,对这两个子数组分别进行递归排序:
      • 调用 mergeSort([8 ,2]) 对左边的子数组进行排序
        • mergeSort([8 ,2]) 中,我们将数组分成两半:left = [8]right = [2]
        • 然后,对这两个子数组分别进行递归排序:
          • 调用 mergeSort([8]) 对左边的子数组进行排序。由于数组长度为1,递归结束,返回 [8]
          • 调用 mergeSort([2]) 对右边的子数组进行排序。由于数组长度为1,递归结束,返回 [2]
        • 最后,调用 merge([8], [2]) 将两个已排序的子数组合并在一起,得到 [2 ,8]
      • 调用 mergeSort([98 ,35]) 对右边的子数组进行排序
        • mergeSort([98 ,35]) 中,我们将数组分成两半:left = [98]right = [35]
        • 然后,对这两个子数组分别进行递归排序:
          • 调用 mergeSort([98]) 对左边的子数组进行排序。由于数组长度为1,递归结束,返回 [98] -- - - 调用 mergeSort([35]) 对右边的子数组进行排序。由于数组长度为1,递归结束,返回 [35]
    • 最后,调用 merge([98], [35]) 将两个已排序的子数组合并在一起,得到 [35 ,98]
  • 最后,调用 merge([2 ,8], [35 ,98]) 将两个已排序的子数组合并在一起,得到 [2 ,8 ,35 ,98]

最后,调用 merge([3 ,9 ,22], [2 ,8 ,35 ,98]) 将两个已排序的子数组合并在一起,得到最终结果 [2 ,3 ,8 ,9 ,22 ,35 ,98]


merge 函数用于将两个已排序的数组合并成一个新的已排序数组。它接受两个参数:left 和 right,分别表示两个已排序的数组。

在 merge 函数中,我们使用两个指针 leftIndex 和 rightIndex 分别指向两个数组的开头。然后,我们比较两个指针所指向的元素,将较小的元素添加到结果数组 result 中,并将相应的指针向后移动一位。

这个过程会一直重复,直到其中一个数组的所有元素都被添加到结果数组中。此时,另一个数组中可能还剩余一些元素,将它们全部添加到结果数组的末尾。

(二)归并排序的代码
// 它使用递归方法将数组分成两半,然后将它们合并在一起。

    let arr = [2 ,3 ,8 ,9 ,22 ,35 ,98]
    function mergeSort(arr) {
      if (arr.length <= 1) {
        return arr;
      }
      
      const middle = Math.floor(arr.length / 2);
      const left = arr.slice(0, middle);
      const right = arr.slice(middle);
      const sortedLeft = mergeSort(left);
      const sortedRight = mergeSort(right);
      return merge(sortedLeft, sortedRight);
    }

    function merge(left, right) {
      let result = [];
      let leftIndex = 0;
      let rightIndex = 0;
      while (leftIndex < left.length && rightIndex < right.length) {
        if (left[leftIndex] < right[rightIndex]) {
          result.push(left[leftIndex]);
          leftIndex++;
        } else {
          result.push(right[rightIndex]);
          rightIndex++;
        }
      }
      return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex));
    }
    console.log('mergeSort', mergeSort(arr));
(三)归并排序的复杂度分析

归并排序的时间复杂度为O(nlogn),空间复杂度为O(n)。

时间复杂度的计算方法是,每次需要将数组拆分为两个长度相等的子数组,然后对这两个子数组进行排序,最后将这两个有序子数组合并成一个有序数组。拆分操作的时间复杂度为logn,排序的复杂度为n,所以归并排序的时间复杂度为O(nlogn)。

空间复杂度为O(n),因为需要一个长度为n的临时数组来存储排序结果

五、插入排序
(一)插入排序的概念和过程

思想:将数组中的每个元素与它前面的元素进行比较,如果它比前面的元素小,就将它向前移动,直到找到它在已排序序列中的正确位置。

例子

假设我们对数组[4, 2, 3, 1] 进行插入排序,排序的执行过程如下:

  • 首先,将第一个元素 4 视为已排序部分,从第二个元素 2 开始遍历数组。
  • 2 与已排序部分的最后一个元素 4 进行比较,发现 4 > 2,因此将 4 向右移动一位,得到新的数组 [4, 4, 3, 1]
  • 2 插入到已排序部分的最后一个元素的位置,得到新的数组 [2, 4, 3, 1]。继续遍历数组,将 3 与已排序部分的最后一个元素 4 进行比较,发现 4 > 3,因此将 4 向右移动一位,得到新的数组 [2, 4, 4, 1]
  • 3 插入到已排序部分的最后一个元素的位置,得到新的数组 [2, 3, 4, 1]。 继续遍历数组,将 1 与已排序部分的最后一个元素 4 进行比较,发现 4 > 1,因此将 4 向右移动一位,得到新的数组 [2, 3, 4, 4]
  • 继续将 1 与已排序部分的倒数第二个元素 3 进行比较,发现 3 > 1,因此将 3 向右移动一位,得到新的数组 [2, 3, 3, 4]
  • 继续将 1 与已排序部分的倒数第三个元素 2 进行比较,发现 2 > 1,因此将 2 向右移动一位,得到新的数组 [2, 2, 3, 4]
  • 1 插入到已排序部分的最后一个元素的位置,得到新的数组 [1, 2, 3, 4]
(二)插入排序的代码
let arr = [4, 2, 3, 1]
function insertSort(arr) {
  var len = arr.length;
  for (var i = 1; i < len; i++) {
    var temp = arr[i];
    var j = i - 1;
    while (j >= 0 && arr[j] > temp) {
      arr[j + 1] = arr[j];
      j--;
    }
    arr[j + 1] = temp;
  }
  return arr
}
console.log('insertSort', insertSort(arr));
(三)插入排序的复杂度分析

插入排序的时间复杂度为O(n^2),空间复杂度为O(1)。

时间复杂度的计算方法是,将待排序数组分为已排序区间和未排序区间,每次从未排序区间中取出一个元素,将它插入到已排序区间中的合适位置。最好情况下,待排序数组本身就是正序的,每个元素所在位置即为它的插入位置,此时时间复杂度仅为比较时的时间复杂度,为O(log2n)。最坏情况下,待排序数组本身是逆序的,每个元素都需要比较n次才能找到自己的插入位置,此时时间复杂度为O(n^2)。平均情况下,时间复杂度也为O(n^2)。

空间复杂度上,插入排序是就地排序,空间复杂度为O(1)。

六、希尔排序
(一)希尔排序的概念和过程

希尔排序是插入排序的一种更高效的改进版,属于非稳定排序算法。它的基本思想是:先将整个待排序的序列分割成为若干子序列,而后将它们分别进行直接插入排序。下面是一个简单的希尔排序的 JavaScript 代码实现。

我们使用实例来看下希尔排序的过程:

     let arr = [15, 5, 2, 7, 12, 6, 1, 4, 3, 9, 8, 10];
    // 使用希尔排序进行分组,增量gap一般为数组长度的一半。
  • 增量为6时的子序列:[15, 1], [5, 4], [2, 3], [7, 9], [12, 8], [6, 10],俩俩进行比较,第一轮排序后结果为:[1, 4, 2, 7, 8, 6, 15, 5, 3, 9, 12, 10]。之后增量/2,,使用parseInt取整。
  • 增量为3时的子序列: [1, 7, 15, 9], [4, 8, 5, 12],[2, 6, 3, 10], 老规矩用每组相同位置的元素进行比较,如果前面的元素小于后面的,交换位置,第二轮比较完成后,数组为:[1, 4, 2, 7,5, 3, 9, 8, 6, 15, 12, 10],之后增量/2,使用parseInt取整。
  • 增量为1时的子序列:直接进行插入排序即可
(二)希尔排序的代码
function sort(arr){
    //第一趟循环,确定增量
    for(let gap = parseInt(arr.length/2);gap>0;gap = parseInt(gap/2)){
        //第二层循环,找到每个块对应的序列
        for(let i=gap;i<arr.length;i++){
            let j=i;
            let empty = arr[j]
            //使用插入排序对对应的序列进行排序
            while(j - gap>=0 && empty <arr[j - gap]){
                arr[j] =arr[j-gap];
                j -= gap;
            }
            arr[j] = empty;
        }
    }
}
(三)希尔排序的复杂度分析

希尔排序的时间复杂度是O(n^1.3) ~ O(n^2),空间复杂度为常数阶O(1)。

希尔排序的时间复杂度依赖增量序列的函数,这个涉及数学上尚未解决的问题,时间复杂度分析比较困难。当n在某个特定范围时,希尔排序的时间复杂度约为O(n^1.3);最坏的情况下,时间复杂度为O(n^2)。

希尔排序的空间复杂度为常数阶O(1) ,因为不随着待排序数据量的增加而增加,只需要一个额外的存储空间来交换元素即可。

七、堆排序
(一)堆排序的概念和过程

学习堆排序之前需要先学习大顶堆和节点等相关知识,在b站上找了一个老师的课,截了几张图,方便讲解。 首先看下什么是堆和大顶堆

是一种特殊的完全二叉树。它满足两个性质:一是堆的结构性,即任意节点的左右子树都是堆;二是堆的有序性,即父节点的值大于等于(或小于等于)其左右子节点的值。

大顶堆是一种特殊的堆,它满足父节点的值大于等于其左右子节点的值。也就是说,大顶堆中每个节点的值都大于等于其左右孩子节点的值。注意: 没有要求结点的左孩子的值和右孩子的值的大小关系。

image.png

接下来需要知道父节点下标,左右节点下标之间的关系。

image.png

这里顺便介绍一下小顶堆,小顶堆就是是一种特殊的堆,它满足父节点的值小于等于其左右子节点的值。也就是说,小顶堆中每个节点的值都小于等于其左右孩子节点的值。注意: 没有要求结点的左孩子的值和右孩子的值的大小关系。enm,定义太长了是吧,和大顶堆反正记,好多了吧~~~,在看下图,是不是瞬间理解了~

image.png

终于终于等到了~~~堆排序,堆排序是一种基于比较的排序算法。它的基本思想是利用堆这种数据结构所设计的一种排序算法。堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。

堆排序的基本思想是:

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

接下来我们举个例子,说下堆排序的过程

对于数组arr = [22, 8, 98, 23, 4, 76, 1],使用堆排序的过程如下:

  1. 首先,使用adjustHeap函数将数组构造成一个大顶堆。这一步的结果是[98, 23, 76, 22, 4, 8, 1]
  2. 然后,将堆顶元素(98)与末尾元素(1)交换,将最大元素“沉”到数组末端。此时,数组变为[1, 23, 76, 22, 4, 8, 98]
  3. 接着,重新调整结构,使其满足堆条件。这一步的结果是[76, 23, 8, 22, 4, 1]
  4. 继续交换堆顶元素(76)和当前末尾元素(1),此时末尾就为最大值。数组变为[1, 23, 8, 22, 4]
  5. 然后将剩余5个元素重新构造成一个堆,这样会得到5个元素的次小值。这一步的结果是[23, 22, 8, 1, 4]
  6. 如此反复执行调整+交换步骤,直到整个序列有序。最终结果为[1, 4, 8, 22, 23, 76 ,98]
(二)堆排序的代码
// 堆排序代码
let arr = [22, 8, 98, 23, 4, 76, 1]
    function heapSort(arr) {
      // 定义一个临时变量,用于交换元素
      let temp;
      // 第一步:将无序序列构成一个大顶堆
      for (let i = Math.floor(arr.length / 2) - 1; i >= 0; i--) {
        adjustHeap(arr, i, arr.length)
      }
      // 第二步:将堆顶元素与末尾元素交换,将最大元素“沉”到数组末端
      // 然后重新调整结构,使其满足堆条件,然后继续交换堆顶元素和当前末尾元素
      // 反复执行调整+交换步骤,直到整个序列有序
      for (let j = arr.length - 1; j > 0; j--) {
        temp = arr[j];
        arr[j] = arr[0];
        arr[0] = temp;
        adjustHeap(arr, 0, j);
      }
    }

    function adjustHeap(arr, i, length) {
      // 先取出当前元素的值,保存在临时变量中
      let temp = arr[i];
      // 开始调整
      for (let k = i * 2 + 1; k < length; k = k * 2 + 1) {
        if (k + 1 < length && arr[k] < arr[k + 1]) { // 如果左子节点的值小于右子节点的值
          k++; // k指向右子节点
        }
        if (arr[k] > temp) { // 如果子节点大于父节点
          arr[i] = arr[k]; // 把较大的值赋给当前节点
          i = k;
        } else {
          break;
        }
      }
      arr[i] = temp; // 将temp值放到调整后的位置
    }

    console.log('heapSort', heapSort(arr));
    console.log('arr', arr);

adjustHeap函数用于将一个数组(二叉树)调整成一个大顶堆。它接受三个参数:arr表示要调整的数组,i表示非叶子节点在数组中的索引,length表示对多少个元素进行调整。

函数首先将当前元素的值保存在临时变量temp中。然后,它开始调整。它从最下层开始,逐层将大的值往上提。如果子节点大于父节点,则将较大的值赋给当前节点。这样,我们已经将以i为父节点的树的最大值放在了最顶上(局部)。最后,将temp值放到调整后的位置。

adjustHeap函数中的循环条件for (let k = i * 2 + 1; k < length; k = k * 2 + 1)怎么理解?

  • k的初始值为i * 2 + 1,表示i节点的左子节点。
  • 循环条件为k < length,表示只对数组中的元素进行调整。
  • k的更新表达式为k = k * 2 + 1,表示在下一次循环中,k指向当前节点的左子节点。

这个循环用于从最下层开始,逐层将大的值往上提。如果子节点大于父节点,则将较大的值赋给当前节点。这样,我们已经将以i为父节点的树的最大值放在了最顶上(局部)。

(三)堆排序的复杂度分析

堆排序的时间复杂度为O(nlogn),空间复杂度为常数阶O(1)。

堆排序的时间复杂度主要由建堆和调整两部分组成。建堆的时间复杂度为O(n),调整的时间复杂度为O(nlogn),所以堆排序的时间复杂度为O(nlogn)

堆排序的空间复杂度为常数阶O(1),因为不会随着待排序数据量的增加而增加,只需要一个额外的存储空间来交换元素即可。

三、排序复杂度汇总

这个是为了方便大家快速查阅,在网上找了一张图,后面三种排序本文没有讲解~,因为太多了好累xdm,等歇歇再写。 image.png

就写到这里了,期待各位小伙伴的积极发言,及时提供宝贵的意见和建议!