JavaScript 数据结构与算法之美 - 十大经典排序算法汇总(中)

简介: JavaScript 数据结构与算法之美 - 十大经典排序算法汇总(中)

3.6 希尔排序(Shell Sort)


思想


  • 先将整个待排序的记录序列分割成为若干子序列。
  • 分别进行直接插入排序。
  • 待整个序列中的记录基本有序时,再对全体记录进行依次直接插入排序。


过程


1.举个易于理解的例子:[35, 33, 42, 10, 14, 19, 27, 44],我们采取间隔 4。创建一个位于 4 个位置间隔的所有值的虚拟子列表。下面这些值是 { 35, 14 },{ 33, 19 },{ 42, 27 } 和 { 10, 44 }。


微信图片_20220513220350.png


2.我们比较每个子列表中的值,并在原始数组中交换它们(如果需要)。完成此步骤后,新数组应如下所示。


微信图片_20220513220401.png


3.然后,我们采用 2 的间隔,这个间隙产生两个子列表:{ 14, 27, 35, 42 }, { 19, 10, 33, 44 }。


微信图片_20220513220411.png


4.我们比较并交换原始数组中的值(如果需要)。完成此步骤后,数组变成:[14, 10, 27, 19, 35, 33, 42, 44],图如下所示,10 与 19 的位置互换一下。


微信图片_20220513220422.png


5.最后,我们使用值间隔 1 对数组的其余部分进行排序,Shell sort 使用插入排序对数组进行排序。


微信图片_20220513220432.png


实现


const shellSort = arr => {
    let len = arr.length,
        temp,
        gap = 1;
    console.time('希尔排序耗时');
    while (gap < len / 3) {
        //动态定义间隔序列
        gap = gap * 3 + 1;
    }
    for (gap; gap > 0; gap = Math.floor(gap / 3)) {
        for (let i = gap; i < len; i++) {
            temp = arr[i];
            let j = i - gap;
            for (; j >= 0 && arr[j] > temp; j -= gap) {
                arr[j + gap] = arr[j];
            }
            arr[j + gap] = temp;
            console.log('arr  :', arr);
        }
    }
    console.timeEnd('希尔排序耗时');
    return arr;
};


测试


// 测试
const array = [35, 33, 42, 10, 14, 19, 27, 44];
console.log('原始array:', array);
const newArr = shellSort(array);
console.log('newArr:', newArr);
// 原始 array:   [35, 33, 42, 10, 14, 19, 27, 44]
// arr      :   [14, 33, 42, 10, 35, 19, 27, 44]
// arr      :   [14, 19, 42, 10, 35, 33, 27, 44]
// arr      :   [14, 19, 27, 10, 35, 33, 42, 44]
// arr      :   [14, 19, 27, 10, 35, 33, 42, 44]
// arr      :   [14, 19, 27, 10, 35, 33, 42, 44]
// arr      :   [14, 19, 27, 10, 35, 33, 42, 44]
// arr      :   [10, 14, 19, 27, 35, 33, 42, 44]
// arr      :   [10, 14, 19, 27, 35, 33, 42, 44]
// arr      :   [10, 14, 19, 27, 33, 35, 42, 44]
// arr      :   [10, 14, 19, 27, 33, 35, 42, 44]
// arr      :   [10, 14, 19, 27, 33, 35, 42, 44]
// 希尔排序耗时: 3.592041015625ms
// newArr:     [10, 14, 19, 27, 33, 35, 42, 44]


分析


  • 第一,希尔排序是原地排序算法吗 ?


希尔排序过程中,只涉及相邻数据的交换操作,只需要常量级的临时空间,空间复杂度为 O(1) 。所以,希尔排序是原地排序算法。


  • 第二,希尔排序是稳定的排序算法吗 ?


我们知道,单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序,但在多次不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,可能导致相同元素相对顺序发生变化。


因此,希尔排序不稳定


  • 第三,希尔排序的时间复杂度是多少 ?


最佳情况:T(n) = O(n log n)。

最差情况:T(n) = O(n log2 n)。

平均情况:T(n) = O(n log2 n)。


动画


微信图片_20220513220516.gif


3.7 堆排序(Heap Sort)


堆的定义


堆其实是一种特殊的树。只要满足这两点,它就是一个堆。


  • 堆是一个完全二叉树。


完全二叉树:除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。


  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。

也可以说:堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。


对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作大顶堆


对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作小顶堆


微信图片_20220513220538.png


其中图 1 和 图 2 是大顶堆,图 3 是小顶堆,图 4 不是堆。除此之外,从图中还可以看出来,对于同一组数据,我们可以构建多种不同形态的堆。


思想


  1. 将初始待排序关键字序列 (R1, R2 .... Rn) 构建成大顶堆,此堆为初始的无序区;
  2. 将堆顶元素 R[1] 与最后一个元素 R[n] 交换,此时得到新的无序区 (R1, R2, ..... Rn-1) 和新的有序区 (Rn) ,且满足 R[1, 2 ... n-1] <= R[n]。
  3. 由于交换后新的堆顶 R[1] 可能违反堆的性质,因此需要对当前无序区 (R1, R2 ...... Rn-1) 调整为新堆,然后再次将 R[1] 与无序区最后一个元素交换,得到新的无序区 (R1, R2 .... Rn-2) 和新的有序区 (Rn-1, Rn)。不断重复此过程,直到有序区的元素个数为 n - 1,则整个排序过程完成。


实现


// 堆排序
const heapSort = array => {
    console.time('堆排序耗时');
    // 初始化大顶堆,从第一个非叶子结点开始
    for (let i = Math.floor(array.length / 2 - 1); i >= 0; i--) {
        heapify(array, i, array.length);
    }
    // 排序,每一次 for 循环找出一个当前最大值,数组长度减一
    for (let i = Math.floor(array.length - 1); i > 0; i--) {
        // 根节点与最后一个节点交换
        swap(array, 0, i);
        // 从根节点开始调整,并且最后一个结点已经为当前最大值,不需要再参与比较,所以第三个参数为 i,即比较到最后一个结点前一个即可
        heapify(array, 0, i);
    }
    console.timeEnd('堆排序耗时');
    return array;
};
// 交换两个节点
const swap = (array, i, j) => {
    let temp = array[i];
    array[i] = array[j];
    array[j] = temp;
};
// 将 i 结点以下的堆整理为大顶堆,注意这一步实现的基础实际上是:
// 假设结点 i 以下的子堆已经是一个大顶堆,heapify 函数实现的
// 功能是实际上是:找到 结点 i 在包括结点 i 的堆中的正确位置。
// 后面将写一个 for 循环,从第一个非叶子结点开始,对每一个非叶子结点
// 都执行 heapify 操作,所以就满足了结点 i 以下的子堆已经是一大顶堆
const heapify = (array, i, length) => {
    let temp = array[i]; // 当前父节点
    // j < length 的目的是对结点 i 以下的结点全部做顺序调整
    for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
        temp = array[i]; // 将 array[i] 取出,整个过程相当于找到 array[i] 应处于的位置
        if (j + 1 < length && array[j] < array[j + 1]) {
            j++; // 找到两个孩子中较大的一个,再与父节点比较
        }
        if (temp < array[j]) {
            swap(array, i, j); // 如果父节点小于子节点:交换;否则跳出
            i = j; // 交换后,temp 的下标变为 j
        } else {
            break;
        }
    }
};


测试


const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
console.log('原始array:', array);
const newArr = heapSort(array);
console.log('newArr:', newArr);
// 原始 array:  [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]
// 堆排序耗时: 0.15087890625ms
// newArr:     [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]


分析


  • 第一,堆排序是原地排序算法吗 ?


整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序原地排序算法。


  • 第二,堆排序是稳定的排序算法吗 ?


因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。


所以,堆排序是不稳定的排序算法。


  • 第三,堆排序的时间复杂度是多少 ?


堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。


最佳情况:T(n) = O(n log n)。

最差情况:T(n) = O(n log n)。

平均情况:T(n) = O(n log n)。


动画


微信图片_20220513220630.gif


微信图片_20220513220632.gif


3.8 桶排序(Bucket Sort)


桶排序是计数排序的升级版,也采用了分治思想


思想


  • 将要排序的数据分到有限数量的几个有序的桶里。
  • 每个桶里的数据再单独进行排序(一般用插入排序或者快速排序)。
  • 桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。


比如:


微信图片_20220513220702.png


桶排序利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。


为了使桶排序更加高效,我们需要做到这两点:


  • 在额外空间充足的情况下,尽量增大桶的数量。
  • 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中。


桶排序的核心:就在于怎么把元素平均分配到每个桶里,合理的分配将大大提高排序的效率。


实现


// 桶排序
const bucketSort = (array, bucketSize) => {
  if (array.length === 0) {
    return array;
  }
  console.time('桶排序耗时');
  let i = 0;
  let minValue = array[0];
  let maxValue = array[0];
  for (i = 1; i < array.length; i++) {
    if (array[i] < minValue) {
      minValue = array[i]; //输入数据的最小值
    } else if (array[i] > maxValue) {
      maxValue = array[i]; //输入数据的最大值
    }
  }
  //桶的初始化
  const DEFAULT_BUCKET_SIZE = 5; //设置桶的默认数量为 5
  bucketSize = bucketSize || DEFAULT_BUCKET_SIZE;
  const bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;
  const buckets = new Array(bucketCount);
  for (i = 0; i < buckets.length; i++) {
    buckets[i] = [];
  }
  //利用映射函数将数据分配到各个桶中
  for (i = 0; i < array.length; i++) {
    buckets[Math.floor((array[i] - minValue) / bucketSize)].push(array[i]);
  }
  array.length = 0;
  for (i = 0; i < buckets.length; i++) {
    quickSort(buckets[i]); //对每个桶进行排序,这里使用了快速排序
    for (var j = 0; j < buckets[i].length; j++) {
      array.push(buckets[i][j]);
    }
  }
  console.timeEnd('桶排序耗时');
  return array;
};
// 快速排序
const quickSort = (arr, left, right) => {
    let len = arr.length,
        partitionIndex;
    left = typeof left != 'number' ? 0 : left;
    right = typeof right != 'number' ? len - 1 : right;
    if (left < right) {
        partitionIndex = partition(arr, left, right);
        quickSort(arr, left, partitionIndex - 1);
        quickSort(arr, partitionIndex + 1, right);
    }
    return arr;
};
const partition = (arr, left, right) => {
    //分区操作
    let pivot = left, //设定基准值(pivot)
        index = pivot + 1;
    for (let i = index; i <= right; i++) {
        if (arr[i] < arr[pivot]) {
            swap(arr, i, index);
            index++;
        }
    }
    swap(arr, pivot, index - 1);
    return index - 1;
};
const swap = (arr, i, j) => {
    let temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
};


测试


const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
console.log('原始array:', array);
const newArr = bucketSort(array);
console.log('newArr:', newArr);
// 原始 array:  [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]
// 堆排序耗时:   0.133056640625ms
// newArr:       [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]


分析


  • 第一,桶排序是原地排序算法吗 ?


因为桶排序的空间复杂度,也即内存消耗为 O(n),所以不是原地排序算法。


  • 第二,桶排序是稳定的排序算法吗 ?


取决于每个桶的排序方式,比如:快排就不稳定,归并就稳定。


  • 第三,桶排序的时间复杂度是多少 ?


因为桶内部的排序可以有多种方法,是会对桶排序的时间复杂度产生很重大的影响。所以,桶排序的时间复杂度可以是多种情况的。


总的来说


最佳情况:当输入的数据可以均匀的分配到每一个桶中。


最差情况:当输入的数据被分配到了同一个桶中。


以下是桶的内部排序快速排序的情况:


如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k =n / m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。


m 个桶排序的时间复杂度就是 O(m k logk),因为 k = n / m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。


当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。


最佳情况:T(n) = O(n)。当输入的数据可以均匀的分配到每一个桶中。

最差情况:T(n) = O(nlogn)。当输入的数据被分配到了同一个桶中。

平均情况:T(n) = O(n)。


桶排序最好情况下使用线性时间 O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为 O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。

适用场景


  • 桶排序比较适合用在外部排序中。
  • 外部排序就是数据存储在外部磁盘且数据量大,但内存有限,无法将整个数据全部加载到内存中。


动画


微信图片_20220513220801.gif


3.9 计数排序(Counting Sort)


思想


  • 找出待排序的数组中最大和最小的元素。
  • 统计数组中每个值为 i 的元素出现的次数,存入新数组 countArr 的第 i 项。
  • 对所有的计数累加(从 countArr 中的第一个元素开始,每一项和前一项相加)。
  • 反向填充目标数组:将每个元素 i 放在新数组的第 countArr[i] 项,每放一个元素就将 countArr[i] 减去 1 。


关键在于理解最后反向填充时的操作。


使用条件


  • 只能用在数据范围不大的场景中,若数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序。
  • 计数排序只能给非负整数排序,其他类型需要在不改变相对大小情况下,转换为非负整数。
  • 比如如果考试成绩精确到小数后一位,就需要将所有分数乘以 10,转换为整数。


实现


方法一:


const countingSort = array => {
    let len = array.length,
        result = [],
        countArr = [],
        min = (max = array[0]);
    console.time('计数排序耗时');
    for (let i = 0; i < len; i++) {
        // 获取最小,最大 值
        min = min <= array[i] ? min : array[i];
        max = max >= array[i] ? max : array[i];
        countArr[array[i]] = countArr[array[i]] ? countArr[array[i]] + 1 : 1;
    }
    console.log('countArr :', countArr);
    // 从最小值 -> 最大值,将计数逐项相加
    for (let j = min; j < max; j++) {
        countArr[j + 1] = (countArr[j + 1] || 0) + (countArr[j] || 0);
    }
    console.log('countArr 2:', countArr);
    // countArr 中,下标为 array 数值,数据为 array 数值出现次数;反向填充数据进入 result 数据
    for (let k = len - 1; k >= 0; k--) {
        // result[位置] = array 数据
        result[countArr[array[k]] - 1] = array[k];
        // 减少 countArr 数组中保存的计数
        countArr[array[k]]--;
        // console.log("array[k]:", array[k], 'countArr[array[k]] :', countArr[array[k]],)
        console.log('result:', result);
    }
    console.timeEnd('计数排序耗时');
    return result;
};


测试


const array = [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2];
console.log('原始 array: ', array);
const newArr = countingSort(array);
console.log('newArr: ', newArr);
// 原始 array:  [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2]
// 计数排序耗时:   5.6708984375ms
// newArr:       [1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 4, 4, 6, 7, 7, 8, 8, 9, 9]


微信图片_20220513220844.png


方法二:


const countingSort2 = (arr, maxValue) => {
    console.time('计数排序耗时');
    maxValue = maxValue || arr.length;
    let bucket = new Array(maxValue + 1),
        sortedIndex = 0;
    (arrLen = arr.length), (bucketLen = maxValue + 1);
    for (let i = 0; i < arrLen; i++) {
        if (!bucket[arr[i]]) {
            bucket[arr[i]] = 0;
        }
        bucket[arr[i]]++;
    }
    for (let j = 0; j < bucketLen; j++) {
        while (bucket[j] > 0) {
            arr[sortedIndex++] = j;
            bucket[j]--;
        }
    }
    console.timeEnd('计数排序耗时');
    return arr;
};


测试


const array2 = [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2];
console.log('原始 array2: ', array2);
const newArr2 = countingSort2(array2, 21);
console.log('newArr2: ', newArr2);
// 原始 array:  [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2]
// 计数排序耗时:   0.043212890625ms
// newArr:       [1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 4, 4, 6, 7, 7, 8, 8, 9, 9]


例子


可以认为,计数排序其实是桶排序的一种特殊情况


当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

我们都经历过高考,高考查分数系统你还记得吗?我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 50 万考生,如何通过成绩快速排序得出名次呢?


  • 考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。
  • 根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。
  • 我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。
  • 因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。


分析


  • 第一,计数排序是原地排序算法吗 ?


因为计数排序的空间复杂度为 O(k),k 桶的个数,所以不是原地排序算法。


  • 第二,计数排序是稳定的排序算法吗 ?


计数排序不改变相同元素之间原本相对的顺序,因此它是稳定的排序算法。


  • 第三,计数排序的时间复杂度是多少 ?


最佳情况:T(n) = O(n + k)

最差情况:T(n) = O(n + k)

平均情况:T(n) = O(n + k)

k 是待排序列最大值。


动画


微信图片_20220513220930.gif

相关文章
|
26天前
|
存储 人工智能 算法
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
这篇文章详细介绍了Dijkstra和Floyd算法,这两种算法分别用于解决单源和多源最短路径问题,并且提供了Java语言的实现代码。
62 3
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
|
29天前
|
机器学习/深度学习 存储 缓存
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
文章主要介绍了排序算法的分类、时间复杂度的概念和计算方法,以及常见的时间复杂度级别,并简单提及了空间复杂度。
20 1
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
|
22天前
|
存储 算法 Java
Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性
Java Set因其“无重复”特性在集合框架中独树一帜。本文解析了Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性,并提供了最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的hashCode()与equals()方法。
31 4
|
29天前
|
搜索推荐 算法
数据结构与算法学习十四:常用排序算法总结和对比
关于常用排序算法的总结和对比,包括稳定性、内排序、外排序、时间复杂度和空间复杂度等术语的解释。
17 0
数据结构与算法学习十四:常用排序算法总结和对比
|
29天前
|
存储 缓存 分布式计算
数据结构与算法学习一:学习前的准备,数据结构的分类,数据结构与算法的关系,实际编程中遇到的问题,几个经典算法问题
这篇文章是关于数据结构与算法的学习指南,涵盖了数据结构的分类、数据结构与算法的关系、实际编程中遇到的问题以及几个经典的算法面试题。
27 0
数据结构与算法学习一:学习前的准备,数据结构的分类,数据结构与算法的关系,实际编程中遇到的问题,几个经典算法问题
|
1月前
|
机器学习/深度学习 存储 算法
【数据结构与算法基础】——算法复杂度
【数据结构与算法基础】——算法复杂度
|
1月前
|
机器学习/深度学习 存储 算法
【初阶数据结构】算法效率大揭秘 | 时间与空间复杂度的深度剖析
【初阶数据结构】算法效率大揭秘 | 时间与空间复杂度的深度剖析
|
29天前
|
机器学习/深度学习 搜索推荐 算法
探索数据结构:初入算法之经典排序算法
探索数据结构:初入算法之经典排序算法
|
29天前
|
算法 Java 索引
数据结构与算法学习十五:常用查找算法介绍,线性排序、二分查找(折半查找)算法、差值查找算法、斐波那契(黄金分割法)查找算法
四种常用的查找算法:顺序查找、二分查找(折半查找)、插值查找和斐波那契查找,并提供了Java语言的实现代码和测试结果。
17 0
|
3月前
|
JavaScript 算法 前端开发
JS算法必备之String常用操作方法
这篇文章详细介绍了JavaScript中字符串的基本操作,包括创建字符串、访问特定字符、字符串的拼接、位置查找、大小写转换、模式匹配、以及字符串的迭代和格式化等方法。
JS算法必备之String常用操作方法

热门文章

最新文章