常见的十大排序算法详解
性能对比
名词解释
- n: 数据规模
- k:“桶”的个数
- In-place: 占用常数内存,不占用额外内存
- Out-place: 占用额外内存
- 稳定性:排序后2个相等键值的顺序和排序之前它们的顺序相同
测试数组:arr: [2, 48, 4, 19, 27, 5, 14, 36, 38, 44, 3, 46, 47, 50, 26]
冒泡排序
算法思想:
- 从数组头部开始,不断比较相邻2个元素的大小,将较大的往后移,直到数组末尾。经过一轮比较即可找到最大元素,且将之放在了数组末尾。
- 第二轮比较仍从数组头部开始,直到数组倒数第二个元素,找打第二大的元素,且将其放在数组倒数第二位置。
- 依此类推,进行n-1次递归,即可确认所有元素的大小位置。
//冒泡排序
export const bubbleSort = (arr) => {
let length = arr.length;
for (let i = 0; i < length; i++) {//第一轮轮询
let flag = false; //设置个标识,用于如果数组已经排序成功则,不需要在轮询优化
for (let j = 0; j < length - i - 1; j++) {//之后每次轮询长度-1
if (arr[j] > arr[j + 1]) {//比较相邻2个元素,将较大的元素向后移(相邻元素位置交换)
let temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
flag = true; //如果还存在大小比较则继续轮询
}
}
if (!flag) { //如果本轮不存在大小互换则表示已经排序成功,不需要再比较了
break;
}
}
return arr;
}
冒泡排序在数组已经排序完成后依旧会继续轮询,比较相邻元素大小,直到n*(n-1)次轮询全部执行,为了避免不必要的浪费,上面代码添加了flag,区分数组是否排序完成,排序完成则不继续遍历。
选择排序
算法思想:
- 从数组头部开始,假设第一个元素即为最小元素,不断与最小元素比较,如果当前元素比之前的元素更小则记录最小元素下标,直到数组末尾。此时已经找到了最小元素的下标,将其与第一个元素调换位置即可。
- 第二轮比较,从数组的第二个元素开始,直到数组末尾,找到第二小的元素下标,和数组第二位置元素互换位置。
- 依此类推,进行n-1次递归,即可确认所有元素的大小位置。
//选择排序排序
export const selectionSort = (arr) => {
let length = arr.length;
for (let i = 0; i < length; i++) {//第一轮轮询
let minIndex = i; //设置最小元素下标为当前元素下标
for (let j = i + 1; j < length; j++) {//将当前元素和之后所有元素进行比较
if (arr[j] < arr[minIndex]) {//比较相邻2个元素,将较大的元素向后移(相邻元素位置交换)
minIndex = j;
}
}
//找到最小元素下标后,将其和当前遍历的第一个元素位置互换
let temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
return arr;
}
选择排序不稳定,如果存在相同的值的情况下就存在该问题,例如:[6,6,2],第一次排序就将第一个6和2互换了位置,即第一个6在第二个6的后面。
选择排序复杂度较高,无法像冒泡排序样优化,因为每次都遍历了剩余数组,所有可以同时寻找最大和最小值,这样即可节约遍历次数。
优化后算法
//选择排序排序
export const selectionSort = (arr) => {
let length = arr.length;
let maxPos = length - 1; //最大元素数组位置
for (let i = 0; i < length; i++) {//第一轮轮询
let minIndex = i; //设置最小元素下标为当前元素下标
let maxIndex = maxPos; //设置最大元素下标为当前元素下标
for (let j = i + 1; j < maxPos; j++) {//将当前元素和之后所有元素进行比较
if (arr[j] < arr[minIndex]) {//比较相邻2个元素,将较大的元素向后移(相邻元素位置交换)
minIndex = j;
}
if (arr[j] > arr[maxIndex]) {
maxIndex = j;
}
}
//找到最小元素下标后,将其和当前遍历的第一个元素位置互换
let minTemp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = minTemp;
//找到最小元素下标后,将其和当前遍历的最后一个元素位置互换
let maxTemp = arr[maxPos];
arr[maxPos] = arr[maxIndex];
arr[maxIndex] = maxTemp;
maxPos--;
if (minIndex >= maxPos) {//如果最小元素下标和最大元素下标重合,则表示排序完成
break;
}
}
return arr;
}
类似对半查找算法,将数组分为了2部分,最小元素和最大元素依次向中靠拢,当下标交叉时即表示排序完成。
插入排序
算法思想:
- 假设数组第一个元素已经排好序了。
- 从数组第二个元素开始,与他前一个元素进行比较,假设当前元素要插入的位置为前一个元素的下标,比较插入位置的元素和当前元素的值,如果小于插入位置的元素,则将插入位置的元素向后移动一位。
- 在第2步比较的插入位置,继续向前一个元素比较,如果当前元素的值依旧小,则继续移动插入位置的元素后移一位。
- 依次内推,直到找到排序元素的插入位置的前一个元素下标位置为止。
- 将排序元素赋值给插入位置即可。
//插入排序
export const insertSort = (arr) => {
let length = arr.length;
for (let i = 1; i < length; i++) {
let prevIndex = i - 1; //当前元素的前一个元素下标
let current = arr[i]; //当前元素值
while (prevIndex >= 0 && arr[prevIndex] > current) {//如果下标存在,且前一个元素大于当前元素
arr[prevIndex + 1] = arr[prevIndex]; //将大于当前元素的值,依次向后移动一位
prevIndex--; //继续跟前一个元素比较
}
//此时已经获取了当前元素应该插入位置的前一个元素下标,所以prevIndex + 1就是当前元素的插入下标
arr[prevIndex + 1] = current;
}
return arr;
}
优化方式和选择排序类似,对半插入,将数组分为最小值和最大值同时插入,可节约一半时间消耗。
希尔排序
希尔排序又叫缩小增量排序,是简单插入排序的改进版。简单插入排序是依次和前一个元素比较,至少移动和比较均需n-1次,希尔排序采用分组跳跃式比较,逐步缩小增量大小,直至增量为1。
算法思想:
1.将数据按一定的增量分组,在组内进行插入排序。
2.缩小增量大小,继续进行组内插入排序。
3.直到增量为1,则进入最后一次的比较。
//希尔排序
export const shellSort = (arr) => {
let length = arr.length;
let gap = 0; //增量大小
for (gap = length / 3; gap > 0; gap = Math.floor(gap / 2)) {//每次遍历增量减半
//内部进行插入排序
for (let i = gap; i < length; i++) {//跨度增量进行比较
let temp = arr[i];
let j = i - gap; //跨度坐标
for (j; j > 0 && arr[j] > temp; j -= gap) {//相同跨度的元素,进行值交换
arr[j + gap] = arr[j]
}
arr[j + gap] = temp; //非相同跨度的元素值还原
}
}
return arr;
}
需注意:增量只能为整数,最小增量为1
因为是跳跃式比较,所以不稳定。
归并排序
算法思想:
-
先将排序数组从中间一分为2,拆成2个数组(L1、R1)。
-
继续对拆分的数据进行拆分,L1拆分为L11、L12两数组,R1拆分为R11、R12两数组。
-
直到将排序数组拆分为每个数组只有一个元素为止。(这个过程称之为递归有序)
-
将拆分后的有序数组依次排序,然后再合并回来,例如:将L11排序、L12排序,然后合并为L1。
-
新定义一个数组作为排序数组用,从递归后的数组依次排序合并,直到合并L1、R1为止。
合并的原理是左边数组的第一个元素和右边数组的第一个比较,比较成功后则去掉成功元素,继续比较第一个元素,直到某个数组元素为空,再将另一个数组的剩余元素添加到最后即可。
//归并排序
export const mergeSort = (arr) => {
let length = arr.length;
if (length < 2) {
return arr;
}
let midIndex = Math.floor(length / 2);
let leftArr = arr.slice(0, midIndex);
let rightArr = arr.slice(midIndex);
return merge(mergeSort(leftArr), mergeSort(rightArr));
}
const merge = (leftArr, rightArr) => {
let sortArr = [];
while (leftArr.length && rightArr.length) {
//每次只需比较两个数组的第一个元素即可
if (leftArr[0] <= rightArr[0]) {
sortArr.push(leftArr.shift());
} else {
sortArr.push(rightArr.shift());
}
}
//如果比较完成后左侧仍剩余数据则直接添加到数组末尾
while (leftArr.length) {
sortArr.push(leftArr.shift());
}
//如果比较完成后右侧仍剩余数据则直接添加到数组末尾
while (rightArr.length) {
sortArr.push(rightArr.shift());
}
return sortArr;
}
快速排序
算法思想:
- 从数列中随机选择一个数值作为基数。(一般以数组第一个元素为基数)
- 先从右向左找,将小于基数的值放在基数左边,找到后再从左往右找将大于基数的值放在基数右边。
- 当第一遍循环完成后数据以基数为分界线,左边都是小于基数的,右边都是大于基数的。
- 重置基数,对基数左侧和右侧分别递归上诉步骤,直到排序完成。
/**
* 快速排序
* arr:排序数组
* startIndex:数组起始位置
* endIndex:数组结束位置
*/
export const quickSort = (arr, startIndex, endIndex) => {
if (startIndex < endIndex) {
let i = startIndex;
let j = endIndex;
let base = arr[i]; //基数
while (i < j) {
//从右向左找第一个小于基数的数
while (i < j && arr[j] > base) {
j--;
}
if (i < j) {//找到第一个小于基数的数,则调换他们的位置
arr[i] = arr[j];
i++;
}
//从左往右找,第一个大于基数的值
while (i < j && arr[i] < base) {
i++;
}
if (i < j) {//找到第一个大于基数的数,则调换他们的位置
arr[j] = arr[i];
j--;
}
}
//当第一轮遍历完成后,将基数值放在当前查找的重叠坐标
arr[i] = base;
//对以基数坐标切分的左右半边分别递归排序,直到完成排序
quickSort(arr, startIndex, i - 1);
quickSort(arr, i + 1, endIndex);
}
}
堆排序
- 堆:一种类似完全二叉树数据结构。
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
- 完全二叉树: 除了最后一层之外的其他每一层都被完全填充,并且所有结点都保持向左对齐。
算法思想:
- 将排序数组组成一个堆结构。
- 将堆结构转化为一个大顶堆,根据大顶堆特性,当前堆顶元素即为最大元素。
- 将堆顶元素和最后一个元素互换,然后重新对剩余节点构建大顶堆。
- 重复第3步骤,每次大顶堆的构建都会获得当前节点的最大值,这样既可会的一个正向排序的数组。
//数组中2元素交换位置
const swap = (arr, i, j) => {
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
//堆排序
export const heapSort = (arr) => {
//第一次大堆顶处理后可以拿到数组最大值
let size = arr.length;
buildMapHeap(arr, size);
for (let i = size - 1; i >= 0; i--) {
//将最大元素放到数组末尾
swap(arr, 0, i);
//数组元素减少最后一个
size--;
//继续对剩余元素进行大堆顶排序
heapify(arr, 0, size);
}
return arr;
}
//构建大顶堆
const buildMapHeap = (arr, size) => {
let lastHeapIndex = Math.floor(size / 2); //最后一个节点的父元素下标
for (let i = lastHeapIndex; i >= 0; i--) {
heapify(arr, i, size);
}
}
//跳转堆为大顶堆
const heapify = (arr, index, size) => {
let left = 2 * index + 1; //最后叶子节点的左节点坐标
let right = 2 * index + 2; //最后叶子节点的右节点坐标
let lastParentIndex = index; //最后叶子节点的父节点坐标
//如果左叶子节点大于父节点,变更父节点坐标
if (left < size && arr[left] > arr[lastParentIndex]) {
lastParentIndex = left;
}
//如果右叶子节点大于父节点,变更父节点坐标
if (right < size && arr[right] > arr[lastParentIndex]) {
lastParentIndex = right;
}
//如果当前节点坐标变动过,则交换元素位置,将最大值放在堆顶
if (lastParentIndex != index) {
swap(arr, index, lastParentIndex);
//对剩余元素进行大堆顶处理
heapify(arr, lastParentIndex, size);
}
}
计数排序
算法思想:
-
查找数组的最大值和最小值,并申请max-min+1的额外数组空间(Array[max-min+1])。
-
将待排序集合记录到申请的额外数组中,记录坐标:index=value-min,并统计每个值的出现次数。
例如本例中max=50,min=2,所以会申请一个长度为49的数组
出现次数 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 新下标 0 46 2 17 25 3 12 34 36 42 1 44 45 48 24 数据值 2 48 4 19 27 5 14 36 38 44 3 46 47 50 26 -
将额外数组依次展开,即可得到排序后的数组。
//计数排序
export const countingSort = (arr) => {
let max = Math.max.apply(null, arr);
let min = Math.min.apply(null, arr);
let countArr = new Array(max - min + 1);
let arrLen = arr.length;
let countLen = max - min + 1;
let resultArr = [];
//统计每个元素出现个数
for (let i = 0; i < arrLen; i++) {
let countNum = countArr[arr[i] - min] || 0;
countArr[arr[i] - min] = countNum + 1;
}
//将计数项逐项相加,为了让重复元素可以遍历读取
let sum = 0
for (let j = 0; j < countLen; j++) {
let countValue = countArr[j] || 0;
sum = sum + countValue;
countArr[j] = sum;
}
//展开计数数组
for (let n = arrLen - 1; n >= 0; n--) {
//计数下标
let countIndex = arr[n] - min;
//根据存在值的计数数组移至新的结果数组
resultArr[countArr[countIndex] - 1] = arr[n];
//因为方便重复数据展开做了相加操作,所以每拿到一个结果,统计个数应减少一个
countArr[countIndex]--;
}
return resultArr;
}
桶排序
算法思想:
-
查找数组的最大值和最小值。
-
设定间隔大小,将桶分区管理,并确定应该申请的桶数。
桶数:max/bucketSize - min/bucketSize+1。
-
遍历数组将,所有元素放入分区桶中。
-
对每个桶进行排序。
-
将分区桶合并还原为排序数组。
本例将数据分为5个桶排序合并。
//桶排序
export const bucketSort = (arr) => {
let max = Math.max.apply(null, arr);
let min = Math.min.apply(null, arr);
let bucketSize = 5; //默认将数组划分为5个区域
//重新计算划分区域,即桶数
let bucketCount = Math.floor((max - min) / bucketSize) + 1;
let bucketList = new Array(bucketCount);
//初始化每个桶区域数组为空,避免其他数据污染
for (let i = 0; i < bucketList.length; i++) {
bucketList[i] = []
}
//将元素放入桶中
for (let j = 0; j < arr.length; j++) {
//计算元素值对应的桶坐标
let bucketIndex = Math.floor((arr[j] - min) / bucketSize);
//将元素放入对应的桶中
bucketList[bucketIndex].push(arr[j]);
}
let bucketArr = [];
//对每个桶内数据进行排序,然后合并数组
for (let i = 0; i < bucketList.length; i++) {
//桶内使用插入排序
insertSort(bucketList[i]);
//依次遍历桶,将桶数据平铺为有序数组
for (let j = 0; j < bucketList[i].length; j++) {
bucketArr.push(bucketList[i][j]);
}
}
return bucketArr;
}
基数排序
算法思想:
- 查找数组的最大值,计算出最大位数。
- 将所有元素按位数分配到对应的桶中(每个位数分为[0,10)个桶)。
- 将桶元素合并,生成该位数上的新数组。
- 对新数组进行下一位数的排序,依次重复2、3、4步骤。
简单来说就是先对元素的个位数分组排序,然后对十位数分组排序,依次类推…
//基数排序
export const radixSort = (arr) => {
let max = Math.max.apply(null, arr);
let dight = 1; //从个位开始排序,十位值为10,百位100,依次类推
let mod = 10; //用于求余,获取某位数上的值
let maxDight = max.toString().length; //数据最大位数
let bucketList = [];
//遍历统计每个位数的元素
for (let i = 0; i < maxDight; i++ , dight *= 10, mod *= 10) {
for (let j = 0; j < arr.length; j++) {
//某一位数上的桶坐标,例如112,在十位的数字为1,(112%100)/10
let bucketIndex = Math.floor((arr[j] % mod) / dight);
//初始化桶为空数组
if (bucketList[bucketIndex] == null || bucketList[bucketIndex] == undefined) {
bucketList[bucketIndex] = [];
}
//将数据放入桶中
bucketList[bucketIndex].push(arr[j]);
}
//重组数据
let pos = 0;
for (let n = 0; n < bucketList.length; n++) {
if (bucketList[n] != null && bucketList[n] != undefined) {
//遍历每个桶重组数据
for (let m = 0; m < bucketList[n].length; m++) {
arr[pos++] = bucketList[n][m];;
}
}
}
//重组桶数组为空数组
bucketList = [];
}
return arr;
}