对于排序算法,我们需要熟练掌握性能较好的归并排序、快速排序以及计数排序。归并排序经常用于链表的排序。快排中的划分过程可以用于寻找第K大/小的元素,虽然也可以用堆实现,但是js中还要自己实现堆,比较费时间。计数排序比较适合待排元素数值在一个可控的范围内,比如对年龄、成绩等进行排序。
1.冒泡排序
思路
冒泡排序比较所有相邻的两个项,如果第一个比第二个大,则交换它们。元素项向上移动至正确的顺序,就好像气泡升至表面一样,冒泡排序因此得名。
js实现
function bubbleSort(nums) {
const {length} = nums;
// 第一层循环控制数组经过多少轮排序,轮数和数组长度一致
for (let i = 0; i < length; i++) {
// 第二层循环从第一位迭代至倒数第二位,相邻两位进行比较
for (let j = 0; j < length - 1; j++) {
if (nums[j] > nums[j + 1]) {
// 交换
[nums[j], nums[j + 1]] = [nums[j + 1], nums[j]];
}
}
}
return nums;
}
冒泡排序工作过程示意图:
该示意图中每一小段(红色框内)表示外循环的一轮,而相邻两项的比较则是在内循环中进行的。
注意当算法执行外循环的第二轮的时候,数字4和5已经是正确排序的了。即冒泡排序的每一轮结束后,就有一个元素被排到正确位置。因此,以上实现可以进行优化一下。
改进后的冒泡排序
function modifiedBubbleSort(nums) {
const {length} = nums;
for (let i = 0; i < length; i++) {
// 每一轮比较就有一个数被排在正确位置,故数组尾部已排序的数字不要进行比较
for (let j = 0; j < length - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
[nums[j], nums[j + 1]] = [nums[j + 1], nums[j]];
}
}
}
return nums;
}
改进后的冒泡排序工作过程示意图
冒泡排序总结:时间复杂度:O(),稳定排序,原地排序(额外空间O(1))
2.选择排序
思路
选择排序大致的思路是找到数据结构中的最小值并将其放置在第一位,接着找到第二小的值并将其放在第二位,以此类推。
js实现
function selectionSort(nums) {
const {length} = nums;
let minIndex; // 记录每一轮找到的最小值位置
for (let i = 0; i < length - 1; i++) {
minIndex = i;
// 获取最小值位置
// 每次从位置i开始寻找,因为位置i之前的元素已排序
for (let j = i; j < length; j++) {
if (nums[j] < nums[minIndex]) {
minIndex = j;
}
}
// 若当前元素和获取到的最小值位置值不同,则交换,保持稳定性
if (minIndex !== i) {
[nums[i], nums[minIndex]] = [nums[minIndex], nums[i]];
}
}
return nums;
}
选择排序工作过程示意图:
选择排序总结:时间复杂度:O(),稳定排序,原地排序(额外空间O(1))
3.插入排序
思路
插入排序每次排一个数组项,以此方式构建最后的排序数组。假定第一项已经排序了。接着,它和第二项进行比较——第二项是应该待在原位还是插到第一项之前呢?这样,头两项就已正确排序,接着和第三项比较(它是该插入到第一、第二还是第三的位置呢),以此类推。
插入排序过程示意图
js代码实现
function insertionSort(nums) {
const {length} = nums;
let currNum; // 当前待排数字
// 假定第一位已经排序
for (let i = 1; i < length; i++) {
let j = i; // 当前位置i,从i到0向前比较
currNum = nums[i]; // 待排序数字
// 从倒数第一个已排序元素位置向前遍历,将前面比待排数字大的元素后移
while(j > 0 && nums[j - 1] > currNum) {
nums[j] = nums[j - 1];
j--; // 向前移动指针
}
// 插入待排元素
nums[j] = currNum;
}
return nums;
}
插入排序总结:时间复杂度:O(),稳定排序,原地排序(额外空间O(1))
4.归并排序
思路
归并排序是一种分而治之算法。其思想是将原始数组切分成较小的数组,直到每个小数组只有一个位置,接着将小数组归并成较大的数组,直到最后只有一个排序完毕的大数组。
MozillaFirefox使用归并排序作为Array.prototype.sort的实现,而Chrome(V8引擎)使用了一个快速排序的变体。
归并排序工作过程示意图
js实现
function mergeSort(nums) {
if (nums.length > 1) {
const {length} = nums;
const mid = Math.floor(length / 2);
// 递归划分,直到每个小数组只包含一个元素
const left = mergeSort(nums.slice(0, mid));
const right = mergeSort(nums.slice(mid, length));
// 合并和排序小数组
nums = merge(left, right);
}
return nums;
}
function merge(left, right) {
let i = 0;
let j = 0;
const result = []; // 保存排序结果
// 排序和合并过程
// 比较来自left数组的项是否比来自right数组的项小
// 如果是,将该项从left数组添加至归并结果数组,并将指针i向后移动一位
// 否则,从right数组添加项并将指针j向后移动一位
while(i < left.length && j < right.length) {
result.push(left[i] < right[j] ? left[i++] : right[j++]);
}
// 上面循环结束,i和j最多只有一边还有剩余元素
return result.concat(i < left.length ? left.slice(i) : right.slice(j));
}
总结:
由于长度为n的数组每次都被分为两个长度为n/2的数组,因此不管输入什么样的数组,归并排序的时间复杂度都是O(nlogn)。归并排序需要创建一个长度为n的辅助数组。如果用递归实现归并排序,那么递归的调用栈需要O(logn)的空间。因此,归并排序的空间复杂度是O(n)。
5.快速排序
思路
快速排序的基本思想是分治法,排序过程如下:
- 选择主元:从数组中选择一个值作为主元(pivot),有多种方式:1.数组第一或最后一个元素; 2. 随机选取; 3. 数组中间值。
- 划分(partition)操作:使数组中所有比主元值小的数据移到数组的左边,所有比主元大的数据移到数组的右边。
- 接着,算法对划分后的小数组(较主元小的值组成的子数组,以及较主元大的值组成的子数组)重复之前的两个步骤(选择各子数组的主元然后划分),直至数组已完全排序。
快速排序中的partition函数还经常被用来选择数组中第k大的数字,而这也是一道非常经典的算法面试题。
快速排序工作过程示意图
理解快速排序的关键在于理解它划分的过程。下面以数组[4,1,5,3,6,2,7,8]为例分析划分的过程。这个示例运用同向双指针实现数组划分,比起相向双指针的实现,边界处理要简单很多。
- 假设数字3被随机选中为主元,将该数字被交换到数组的尾部。
- 初始化两个指针,指针初始化至下标为-1的位置,指针初始化至下标为0的位置,如图(a)所示。
- 始终将指针指向已经发现的最后一个小于3的数字。初始化时尚未发现任何一个小于3的数字,因此将指针指向一个无效的位置-1。
- 将指针从下标为0的位置开始向右扫描数组中的每个数字。当指针指向第1个小于3的数字1时,指针向右移动一格,然后交换两个指针指向的数字,此时数组(即两个指针)的状态如图(b)所示。
- 继续右移指针直到遇到下一个小于3的数字2,指针再次向右移动一格,然后交换两个指针指向的数字,此时数组(即两个指针)的状态如图(c)所示。
- 继续右移指针直到指向数字3也没有遇到新的小于3的数字,此时整个数组都已经扫描完毕。再次将指针向右移动一格,然后交换指针和指向的数字,于是所有小于3的数字都位于3的左边,所有大于3的数字都位于数组的右边,如图(d)所示。
js实现
- 使用同向移动的双指针实现
function quickSort(nums) {
quick(nums, 0, nums.length - 1);
return nums;
}
function quick(nums, start, end) {
if (end > start) {
const pivotIndex = partition(nums, start, end);
quick(nums, start, pivotIndex - 1);
quick(nums, pivotIndex + 1, end);
}
}
function partition(nums, start, end) {
// 随机选择主元
const random = Math.floor(Math.random() * (end - start + 1)) + start;
// 将主元与最后一个元素交换位置
swap(nums, random, end);
// 变量small相当于指针P1,它始终指向已经发现的最后一个小于中间值的数字。
let small = start - 1;
// for循环中的变量i相当于指针P2,它从左到右扫描整个数组。
for(let i = start; i < end; i++) {
if (nums[i] < nums[end]) {
small++;
swap(nums, i, small);
}
}
small++;
// 将主元交换到合适的位置,使比它小的数字都在它的左边,比它大的数字都在它的右边。
swap(nums, small, end);
// 主元最终的位置
return small;
}
function swap(arr, i, j) {
if (i !== j) {
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
- 使用相向双指针实现
function quickSort(nums) {
return quick(nums, 0, nums.length - 1);
}
function quick(nums, left, right) {
let index;
if (nums.length > 1) {
index = partition(nums, left, right);
if (left < index - 1) {
quick(nums, left, index - 1)
}
if (index < right) {
quick(nums, index, right)
}
}
return nums;
}
function partition(nums, left, right) {
const mid = Math.floor((right - left) / 2) + left;
const pivot = nums[mid];
let i = left;
let j = right;
while(i <= j) {
// 移动left指针直到找到一个比主元大的元素
while(nums[i] < pivot) i++;
// 移动right指针直到找到一个比主元小的元素
while(nums[j] > pivot) j--;
if (i <= j) {
[nums[i], nums[j]] = [nums[j], nums[i]];
i++;
j--;
}
}
// 划分位置
return i;
}
总结:
快速排序的时间复杂度取决于所选取的中间值在数组中的位置。如果每次选取的中间值在排序数组中都接近数组中间的位置,那么快速排序的时间复杂度是O(nlogn)。如果每次选取的中间值都位于排序数组的头部或尾部,那么快速排序的时间复杂度是O()。这也是随机选取中间值的原因,避免在某些情况下快速排序退化成时间复杂度为O()的算法。由此可知,在随机选取中间值的前提下,快速排序的平均时间复杂度是O(nlogn),是非常高效的排序算法。
6.计数排序
思路
计数排序的基本思想是先统计数组中每个整数在数组中出现的次数,然后按照从小到大的顺序将每个整数按照它出现的次数填到数组中。
例如,如果输入整数数组[2,3,4,2,3,2,1],扫描一次整个数组就能知道数组中1出现了1次,2出现了3次,3出现了2次,4出现了1次,于是先后在数组中填入1个1、3个2、2个3及1个4,就可以得到排序后的数组[1,2,2,2,3,3,4]。
计数排序是一种线性时间的整数排序算法。如果数组的长度为n,整数范围(数组中最大整数与最小整数的差值)为k,对于k远小于n的场景(如对某公司所有员工的年龄排序),那么计数排序的时间复杂度优于其他基于比较的排序算法(如归并排序、快速排序等)。
计数排序算法工作过程图示
js实现
function countingSort(nums) {
let min = Number.MAX_VALUE;
let max = Number.MIN_VALUE;
for (let num of nums) {
min = Math.min(num, min);
max = Math.max(num, max);
}
// 利用数组下标是递增的性值,将会把nums中的数字映射counts数组的下标
// 初始化计数数组,大小为原数组中最大值与最小值之差加1
// 避免min与0相差较大,造成数组空间浪费
const counts = new Array(max - min + 1).fill(0);
// 把nums中的数字映射counts数组的下标,counts数组的值是nums中数字出现的次数
for (let num of nums) {
counts[num - min]++;
}
let sortedIndex = 0;
// count是num出现的次数
counts.forEach((count, num) => {
while(count > 0) {
nums[sortedIndex++] = num;
count--;
}
});
return nums;
}
总结:
如果数组的长度为n,整数的范围为k,那么计数排序的时间复杂度就是O(n+k)。由于需要创建一个长度为O(k)的辅助数组counts,因此空间复杂度为O(k)。当k较小时,无论从时间复杂度还是空间复杂度来看计数排序都是非常高效的算法。当k很大时,计数排序可能就不如其他排序算法(如快速排序、归并排序)高效。
7.桶排序
思路
桶排序将元素分为不同的桶,即较小的数组,再使用一个简单的算法,如插入排序(用来排序小数组的不错算法),来对每个桶进行排序。
对于桶排序,首先要判断需要用多少桶来排序各个元素,桶排序再所有元素平分到各个桶中的时候表现最好。
桶排序主要分为两个部分:
- 创建桶并将元素分布到不同的桶中
- 对每个桶执行插入排序算法和将所有桶合并为排序后的结果数组
桶排序的工作过程示意图
以数组[5, 4, 3, 2, 6, 1, 7, 10, 9, 8]为例,假定每个桶的大小(bucketSize)为3, 计算出桶的个数(bucketCount)为 Math.floor(10 / 3) + 1 = 4。
然后遍历元素,将每个元素放入合适的桶中。例如元素5将放入Math.floor((5 - 1) / 3),即下标为1的桶中,其他元素以此类推。
最后,使用插入排序对每个桶进行排序,再将每个排序后的桶按顺序合并。
js实现
function bucketSort(nums, bucketSize = 5) {
if (nums.length < 2) {
return nums;
}
// buckedtSize为每个桶中最多可以装的元素个数
// 根据原数组大小和指定的桶的大小可以计算出桶的个数
const buckets = createBuckets(nums, bucketSize);
return sortBuckets(buckets);
}
function createBuckets(nums, bucketSize) {
let min = Number.MAX_VALUE;
let max = Number.MIN_VALUE;
for (let num of nums) {
min = Math.min(min, num);
max = Math.max(max, num);
}
// 计算桶的个数
const bucketCount = Math.floor((max - min) / bucketSize) + 1;
// 每一个桶是一个数组,故使用一个二维数组存储桶
const buckets = new Array(bucketCount).fill(0).map(() => new Array());
// 计算每个元素将被添加进哪个桶
for (let num of nums) {
const bucketIndex = Math.floor((num - min) / bucketSize);
buckets[bucketIndex].push(num);
}
return buckets;
}
function sortBuckets(buckets) {
const sortedNums = []; // 保存排序后的结果
for (let i = 0; i < buckets.length; i++) {
if (buckets[i]) {
// 使用插入排序排序每个桶内元素
insertionSort(buckets[i])
sortedNums.push(...buckets[i])
}
}
return sortedNums;
}
function insertionSort(nums) {
const {length} = nums;
let currNum; // 当前待排数字
// 假定第一位已经排序
for (let i = 1; i < length; i++) {
let j = i; // 当前位置i,从i到0向前比较
currNum = nums[i]; // 待排序数字
// 从倒数第一个已排序元素位置向前遍历,将前面比待排数字大的元素后移
while(j > 0 && nums[j - 1] > currNum) {
nums[j] = nums[j - 1];
j--; // 向前移动指针
}
// 插入待排元素
nums[j] = currNum;
}
return nums;
}
8.基数排序
思路
基数排序根据数字的有效位或基数将整数分布到桶中。主要用于排序整数,也可以被修改成支持排序字母。
比如,对于十进制数,使用的基数是10。因此,算法将会使用10个桶用来分布元素并且首先基于个位数字进行排序,然后基于十位数字,然后基于百位数字,以此类推。
基数排序工作过程示意图
js实现
function radixSort(nums, radixBase = 10) {
if (nums.length < 2) {
return nums;
}
let min = Number.MAX_VALUE;
let max = Number.MIN_VALUE;
for (let num of nums) {
min = Math.min(min, num);
max = Math.max(max, num);
}
// 任何进制的0次方都是1
let digit = 1;
// 循环次数将由数组中最高位个数决定,0-9执行一次,0-99执行两次,依次类推
// 即先排个位,再排百位(若有),千位
while((max - min) / digit >= 1) {
nums = countingSortForRadix(nums, radixBase, digit, min);
digit *= radixBase;
}
return nums;
}
function countingSortForRadix(nums, radixBase, digit, min) {
let bucketIndex;
// 初始化桶的个数,十进制有10个桶
const buckets = new Array(radixBase).fill(0);
const temp = [];
// 统计当前数位上各个数字出现的次数
for (let num of nums) {
bucketIndex = Math.floor(((num - min) / digit) % radixBase);
buckets[bucketIndex]++;
}
// 计算累积结果来得到正确的计数值
for (let i = 1; i < radixBase; i++) {
buckets[i] += buckets[i - 1];
}
// 在计数完成后,要开始将值移回原始数组中
for (let i = nums.length - 1; i >= 0; i--) {
bucketIndex = Math.floor(((nums[i] - min) / digit) % radixBase);
temp[--buckets[bucketIndex]] = nums[i];
}
for (let i = 0; i < nums.length; i++) {
nums[i] = temp[i]
}
return nums;
}
9.堆排序
思路
利用二叉堆结构可以实现堆排序算法,主要包括三个步骤:
- 用数组创建一个最大堆用作源数据;
- 在创建最大堆后,最大的值会被存储在堆的第一个位置。我们要将它替换为堆的最后一个值,将堆的大小减1;
- 最后,我们将堆的根节点下移并重复步骤2直到堆的大小为1。
堆排序算法不是一个稳定的排序算法,也就是说如果数组没有排好序,可能会得到不一样的结果。
堆排序工作过程示意图
js实现
function heapSort(nums) {
let heapSize = nums.length;
buildMaxHeap(nums);
while (heapSize > 1) {
swap(nums, 0, --heapSize);
heapify(nums, 0, heapSize);
}
return nums;
}
function buildMaxHeap(nums) {
for (let i = Math.floor(nums.length / 2); i >= 0; i -= 1) {
heapify(nums, i, nums.length);
}
return nums;
}
function heapify(nums, index, heapSize) {
let largest = index;
const left = (2 * index) + 1;
const right = (2 * index) + 2;
if (left < heapSize && compareFn(nums[left], nums[index]) > 0) {
largest = left;
}
if (right < heapSize && compareFn(nums[right], nums[largest]) > 0) {
largest = right;
}
if (largest !== index) {
swap(nums, index, largest);
heapify(nums, largest, heapSize);
}
}
function compareFn(a, b) {
return a - b;
}
function swap(arr, i, j) {
if (i !== j) {
[arr[i], arr[j]] = [arr[j], arr[i]]
}
}
10.希尔排序
js实现
function shellSort(nums) {
let increment = nums.length / 2;
while (increment > 0) {
for (let i = increment; i < nums.length; i++) {
let j = i;
const temp = nums[i];
while (j >= increment && compareFn(nums[j - increment], temp) > 0) {
nums[j] = nums[j - increment];
j -= increment;
}
nums[j] = temp;
}
if (increment === 2) {
increment = 1;
} else {
increment = Math.floor((increment * 5) / 11);
}
}
return nums;
}
function compareFn(a, b) {
return a - b;
}