JavaScript 数据结构与算法之美
算法为王。
想学好前端,先练好内功,只有内功深厚者,前端之路才会走得更远。
笔者写的 JavaScript 数据结构与算法之美 系列用的语言是 JavaScript ,旨在入门数据结构与算法和方便以后复习。
之所以把 计数排序、桶排序、基数排序 放在一起比较,是因为它们的平均时间复杂度都为 O(n)。
因为这三个排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作 线性排序(Linear sort)。
之所以能做到线性的时间复杂度,主要原因是,这三个算法不是基于比较的排序算法,都不涉及元素之间的比较操作。
另外,请大家带着问题来阅读下文,问题:如何根据年龄给 100 万用户排序 ?
桶排序是计数排序的升级版,也采用了分治思想
。
思想
比如:
桶排序利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。
为了使桶排序更加高效,我们需要做到这两点:
桶排序的核心:就在于怎么把元素平均分配到每个桶里,合理的分配将大大提高排序的效率。
实现
// 桶排序
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)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。
适用场景
动画
bocket-sort.gif
思想
关键在于理解最后反向填充时的操作。
使用条件
实现
方法一:
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 万考生,如何通过成绩快速排序得出名次呢?
分析
动画
counting-sort.gif
思想
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。
由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
例子
假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢 ?
这个问题里有这样的规律:假设要比较两个手机号码 a,b 的大小,如果在前面几位中,a 手机号码已经比 b 手机号码大了,那后面的几位就不用看了。所以是基于位
来比较的。
桶排序、计数排序能派上用场吗 ?手机号码有 11 位,范围太大,显然不适合用这两种排序算法。针对这个排序问题,有没有时间复杂度是 O(n) 的算法呢 ?有,就是基数排序。
使用条件
位
来比较;方案
按照优先从高位或低位来排序有两种实现方案:
实现
/**
* name: 基数排序
* @param array 待排序数组
* @param max 最大位数
*/
const radixSort = (array, max) => {
console.time('计数排序耗时');
const buckets = [];
let unit = 10,
base = 1;
for (let i = 0; i < max; i++, base *= 10, unit *= 10) {
for (let j = 0; j < array.length; j++) {
let index = ~~((array[j] % unit) / base); //依次过滤出个位,十位等等数字
if (buckets[index] == null) {
buckets[index] = []; //初始化桶
}
buckets[index].push(array[j]); //往不同桶里添加数据
}
let pos = 0,
value;
for (let j = 0, length = buckets.length; j < length; j++) {
if (buckets[j] != null) {
while ((value = buckets[j].shift()) != null) {
array[pos++] = value; //将不同桶里数据挨个捞出来,为下一轮高位排序做准备,由于靠近桶底的元素排名靠前,因此从桶底先捞
}
}
}
}
console.timeEnd('计数排序耗时');
return array;
};
测试
const array = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.log('原始array:', array);
const newArr = radixSort(array, 2);
console.log('newArr:', newArr);
// 原始 array: [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]
// 堆排序耗时: 0.064208984375ms
// newArr: [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
分析
动画
LSD 基数排序动图演示:
radixSort.gif
回过头来看看开篇的思考题:如何根据年龄给 100 万用户排序 ?
你可能会说,我用上一节讲的归并、快排就可以搞定啊!是的,它们也可以完成功能,但是时间复杂度最低也是 O(nlogn)。
有没有更快的排序方法呢 ?以下是参考答案。
基数排序 vs 计数排序 vs 桶排序
基数排序有两种方法:
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
复杂性对比
名称 | 平均 | 最好 | 最坏 | 空间 | 稳定性 | 排序方式 |
---|---|---|---|---|---|---|
桶排序 | O(n + k) | O(n + k) | O(n2) | O(n + k) | Yes | Out-place |
计数排序 | O(n + k) | O(n + k) | O(n + k) | O(k) | Yes | Out-place |
基数排序 | O(n * k) | O(n * k) | O(n * k) | O(n + k) | Yes | Out-place |
n: 数据规模
桶排序的时间复杂度可以是多种情况的,取决于桶内的排序。
算法可视化工具
旨在通过交互式可视化的执行来揭示算法背后的机制。
微信 gif 图的限制,该动画请点击原文
微信 gif 图的限制,该动画请点击原文
JavaScript 数据结构与算法之美 的系列文章。
标题 | 链接 |
---|---|
时间和空间复杂度 | https://github.com/biaochenxuying/blog/issues/29 |
线性表(数组、链表、栈、队列) | https://github.com/biaochenxuying/blog/issues/34 |
实现一个前端路由,如何实现浏览器的前进与后退 ? | https://github.com/biaochenxuying/blog/issues/30 |
栈内存与堆内存 、浅拷贝与深拷贝 | https://github.com/biaochenxuying/blog/issues/35 |
递归 | https://github.com/biaochenxuying/blog/issues/36 |
非线性表(树、堆) | https://github.com/biaochenxuying/blog/issues/37 |
冒泡排序、选择排序、插入排序 | https://github.com/biaochenxuying/blog/issues/39 |
归并排序、快速排序、希尔排序、堆排序 | https://github.com/biaochenxuying/blog/issues/40 |
计数排序、桶排序、基数排序 | https://github.com/biaochenxuying/blog/issues/41 |
十大经典排序汇总 | https://github.com/biaochenxuying/blog/issues/42 |
强烈推荐 GitHub 上值得前端学习的数据结构与算法项目 | https://github.com/biaochenxuying/blog/issues/43 |
如果有错误或者不严谨的地方,请务必给予指正,十分感谢。
文中所有的代码及测试事例都已经放到我的 GitHub 上了。
觉得有用 ?喜欢就收藏,顺便点个赞吧,你的支持是我最大的鼓励!
参考文章: