3.6 希尔排序(Shell Sort)
待整个序列中的记录基本有序时,再对全体记录进行依次直接插入排序。
举个易于理解的例子:[35, 33, 42, 10, 14, 19, 27, 44],我们采取间隔 4。创建一个位于 4 个位置间隔的所有值的虚拟子列表。下面这些值是 { 35, 14 },{ 33, 19 },{ 42, 27 } 和 { 10, 44 }。
我们比较每个子列表中的值,并在原始数组中交换它们
(如果需要)
。完成此步骤后,新数组应如下所示。
然后,我们采用 2 的间隔,这个间隙产生两个子列表:{ 14, 27, 35, 42 }, { 19, 10, 33, 44 }。
我们比较并交换原始数组中的值
(如果需要)
。完成此步骤后,数组变成:[14, 10, 27, 19, 35, 33, 42, 44],图如下所示,10 与 19 的位置互换一下。
最后,我们使用值间隔 1 对数组的其余部分进行排序,Shell sort 使用插入排序对数组进行排序。
const shellSort = arr => { let len = arr.length, temp, gap = 1 ; console .time('希尔排序耗时' ); while (gap 3) { gap = gap * 3 + 1 ; } for (gap; gap > 0 ; gap = Math .floor(gap / 3 )) { for (let i = gap; 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);
希尔排序过程中,只涉及相邻数据的交换操作,只需要常量级的临时空间,空间复杂度为 O(1) 。所以,希尔排序是原地排序算法。
我们知道,单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序,但在多次不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,可能导致相同元素相对顺序发生变化。因此,希尔排序不稳定。
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
(let i = Math.floor(array .length - 1 ); i > 0 ; i--) { swap(array , 0 , 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; };const heapify = (array , i, length) => { let temp = array [i]; * j + 1 ) { temp = array [i]; if (j + 1 array[j] array[j + 1 ]) { j++; } if (temp array[j]) { swap(array , i, j); i = 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);
整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。
因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。所以,堆排序是不稳定的排序算法。
堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。
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 if (array[i] minValue = array[i]; } else if (array[i] > maxValue) { maxValue = array[i]; } } const DEFAULT_BUCKET_SIZE = 5 ; bucketSize = bucketSize || DEFAULT_BUCKET_SIZE; const bucketCount = Math .floor((maxValue - minValue) / bucketSize) + 1 ; const buckets = new Array (bucketCount); for (i = 0 ; i buckets[i] = []; } for (i = 0 ; i buckets[Math .floor((array[i] - minValue) / bucketSize)].push(array[i]); } array.length = 0 ; for (i = 0 ; i quickSort(buckets[i]); for (var j = 0 ; 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 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, index = pivot + 1 ; for (let i = index; i <= right; i++) { if (arr[i] 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);
因为桶排序的空间复杂度,也即内存消耗为 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)。当输入的数据被分配到了同一个桶中。
桶排序最好情况下使用线性时间 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);
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 if (!bucket[arr[i]]) { bucket[arr[i]] = 0 ; } bucket[arr[i]]++; } for (let j = 0 ; 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);
当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
我们都经历过高考,高考查分数系统你还记得吗?我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 50 万考生,如何通过成绩快速排序得出名次呢?
考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。
根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。
我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。
因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。
因为计数排序的空间复杂度为 O(k),k 桶的个数,所以不是原地排序算法。