3.6 希尔排序(Shell Sort)
思想
- 先将整个待排序的记录序列分割成为若干子序列。
- 分别进行直接插入排序。
- 待整个序列中的记录基本有序时,再对全体记录进行依次直接插入排序。
过程
1.举个易于理解的例子:[35, 33, 42, 10, 14, 19, 27, 44],我们采取间隔 4。创建一个位于 4 个位置间隔的所有值的虚拟子列表。下面这些值是 { 35, 14 },{ 33, 19 },{ 42, 27 } 和 { 10, 44 }。
2.我们比较每个子列表中的值,并在原始数组中交换它们(如果需要)。完成此步骤后,新数组应如下所示。
3.然后,我们采用 2 的间隔,这个间隙产生两个子列表:{ 14, 27, 35, 42 }, { 19, 10, 33, 44 }。
4.我们比较并交换原始数组中的值(如果需要)。完成此步骤后,数组变成:[14, 10, 27, 19, 35, 33, 42, 44],图如下所示,10 与 19 的位置互换一下。
5.最后,我们使用值间隔 1 对数组的其余部分进行排序,Shell sort 使用插入排序对数组进行排序。
实现
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)。
动画
3.7 堆排序(Heap Sort)
堆的定义
堆其实是一种特殊的树。只要满足这两点,它就是一个堆。
- 堆是一个完全二叉树。
完全二叉树:除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
也可以说:堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。
对于每个节点的值都大于等于
子树中每个节点值的堆,我们叫作大顶堆
。
对于每个节点的值都小于等于
子树中每个节点值的堆,我们叫作小顶堆
。
其中图 1 和 图 2 是大顶堆,图 3 是小顶堆,图 4 不是堆。除此之外,从图中还可以看出来,对于同一组数据,我们可以构建多种不同形态的堆。
思想
- 将初始待排序关键字序列 (R1, R2 .... Rn) 构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素 R[1] 与最后一个元素 R[n] 交换,此时得到新的无序区 (R1, R2, ..... Rn-1) 和新的有序区 (Rn) ,且满足 R[1, 2 ... n-1] <= R[n]。
- 由于交换后新的堆顶 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)。
动画
3.8 桶排序(Bucket Sort)
桶排序是计数排序的升级版,也采用了分治思想
。
思想
- 将要排序的数据分到有限数量的几个有序的桶里。
- 每个桶里的数据再单独进行排序(一般用插入排序或者快速排序)。
- 桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
比如:
桶排序利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。
为了使桶排序更加高效,我们需要做到这两点:
- 在额外空间充足的情况下,尽量增大桶的数量。
- 使用的映射函数能够将输入的 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)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。
适用场景
- 桶排序比较适合用在外部排序中。
- 外部排序就是数据存储在外部磁盘且数据量大,但内存有限,无法将整个数据全部加载到内存中。
动画
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]
方法二:
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 是待排序列最大值。
动画