排序算法(JS)

302 阅读9分钟

排序算法

常见的内部排序算法有:选择排序、冒泡排序、插入排序、堆排序、希尔排序、归并排序、快速排序、桶排序、计数排序、基数排序

1、选择排序

简单但是最没用的排序算法。因为他的时间复杂度为n²。但是可以优化

算法思想

假设一个数组,先默认第一个数据为最小值,minPos为0。然后遍历数组,每个数据和minPos对应的值进行比较,如果小于minPos对应值,就将该数据对应的下标赋值给minPos,一轮循环结束后,就可以找到数组的最小值下标。接着,将最小值下标对应的值与默认的最小值,即第一个数据进行交换。如此,就找到了最小的值,并将其放在第一个位置。

接着,需要按照上述方法依次找到第2、3...n个最小值。需要遍历的数组变为已排序的其他数据组成的数组。

时间复杂度:O(n²)

空间复杂度:1

选择排序动态图

/**
 * 选择排序
 * @param {*} a 需要排序的数组
 */
function selectionSort(a) {
    let len = a.length;
    for(let i=0;i<len - 1;i++) { //这里之所以是len-1,是因为到最后两个元素,交换位置,整个数组就已经排好序了。
        let minPos = i;
        for(let j=i+1;j<len;j++) {
            a[j] < a[minPos] && (minPos = j)
        }
        [a[minPos], a[i]] = [a[i], a[minPos]]
    }
}
let a = [5,4,1,3,7,90,45,34,23,58]
console.log(a)// [5, 4, 1, 3, 7, 90, 45, 34, 23, 58]
selectionSort(a)
console.log(a) // [1, 3, 4, 5, 7, 23, 34, 45, 58, 90]

优化版本,新增一个type参数,实现降序、升序功能

/**
 * 选择排序
 * @param {*} a 需要排序的数组
 * @param {*} type 排序类型, 'desc':降序;'asc':升序,默认升序。拼写错误,升序
 */
function selectionSort2(a, type = 'asc') {
	let len = a.length;
	for (let i = 0; i < len - 1; i++) {
		let index = i;
		for (let j = i + 1; j < len; j++) {
			let isSwitch = type === 'desc' ? a[j] > a[index] : a[j] < a[index]
			isSwitch && (index = j);
		}
		[a[index], a[i]] = [a[i], a[index]];
	}
}
let a = [5, 4, 1, 3, 7, 90, 45, 34, 23, 58, 92, 9];
selectionSort2(a);
console.log(a); // [1, 3, 4, 5, 7, 9, 23, 34, 45, 58, 90, 92]

再次优化版本,同时找出最小值与最大值放在数列两侧,两边逐渐逼近,循环次数会减少一些

/**
 * 优化版选择排序
 * 同时找出最小值与最大值放在数列两侧,两边逐渐逼近,循环次数会减少一些
 * @param {*} a 需要排序的数组
 */
function selectionSort3(a) {
	let len = a.length;
	for (let i = 0; i < len / 2; i++) {
        let minPos = i,
		maxPos = len - 1 - i;
		for (let j = i + 1; j < len - i - 1; j++) {
			a[j] < a[minPos] && (minPos = j);
			a[j] > a[maxPos] && (maxPos = j);
		}
		[a[minPos], a[i]] = [a[i], a[minPos]];
		[a[maxPos], a[len - 1 - i]] = [a[len - 1 - i], a[maxPos]];
	}
}

let a = [5, 4, 1, 3, 7, 90, 45, 34, 23, 58];
selectionSort3(a);
console.log(a);
算法验证,对数器(随机生成足够多的随机数组,将自己的算法与原生的结果进行比对)

2、冒泡排序

冒泡排序(Bubble Sort)也是一种简单直观的排序算法。

算法思想

遍历数组,相邻两个数据进行不较,如果前一个数据大于后一个数据,交换两个数据,一直到遍历结束,得到最大值,位于数据的最末端。

然后,按照之前的做法依次得到除排好位置的数组的最大值,如此,数组顺序就排好了。

时间复杂度:O(n²)

空间复杂度:1

冒泡排序动图

/**
 * 冒泡排序
 * @param {*} a 需要排序的数组
 */
function bubbleSort(a) {
	let len = a.length;
	for (let i = 0; i < len - 1; i++) {
		for (let j = 0; j < len - 1 - i; j++) {
			a[j] > a[j + 1] && ([a[j], a[j + 1]] = [a[j + 1], a[j]]);
		}
	}
}

let a = [5, 4, 1, 3, 7, 90, 45, 34, 23, 58, 92, 9];
bubbleSort(a);
console.log(a);// [1, 3, 4, 5, 7, 9, 23, 34, 45, 58, 90, 92]

优化版本,新增一个type参数,实现降序、升序功能

/**
 * 冒泡排序
 * @param {*} a 需要排序的数组
 * @param {*} type 排序类型, 'desc':降序;'asc':升序,默认升序.拼写错误,升序
 */
function bubbleSort2(a, type = 'asc') {
	let len = a.length;
	for (let i = 0; i < len - 1; i++) {
		for (let j = 0; j < len - 1 - i; j++) {
			let isSwitch = type === 'desc' ? a[j] < a[j + 1] : a[j] > a[j + 1]
			isSwitch && ([a[j], a[j + 1]] = [a[j + 1], a[j]]);
		}
	}
}
let a = [5, 4, 1, 3, 7, 90, 45, 34, 23, 58, 92, 9];
bubbleSort2(a);
console.log(a); // [1, 3, 4, 5, 7, 9, 23, 34, 45, 58, 90, 92]

优化版本,设置isSorted来确认数组是否已经是有序的,时间复杂度减少至O(n)

/**
 * 优化版冒泡排序
 * @param {*} a 需要排序的数组
 * @param {*} type 排序类型, 'desc':降序;'asc':升序,默认升序.拼写错误,升序
 */
 function bubbleSort3(a, type = 'asc') {
	let len = a.length;
	let n = 0;
	for (let i = 0; i < len - 1; i++) {
		let isSorted = true; // <== 设置标志变量 isSorted 初始值为 true
		for (let j = 0; j < len - 1 - i; j++) {
			n++
			let isSwitch = type === 'desc' ? a[j] < a[j + 1] : a[j] > a[j + 1]
			 // <== 发生了交换操作, 说明再这一轮中数组仍然无序, 将变量 isSorted 设置为 false
			isSwitch && ([a[j], a[j + 1]] = [a[j + 1], a[j]]) && (isSorted = false);
		}
		
		if(isSorted) {
			return;
		}
	}
}

let a = [1,2,3,4];
bubbleSort3(a);
console.log(a); // [1, 3, 4, 5, 7, 9, 23, 34, 45, 58, 90, 92]

3、插入排序

插入排序的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

算法思想

将第一个数据看作有序序列,第二个数据之后的看作未排序数列。从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

时间复杂度:O(n²)

空间复杂度:1

image

方式一:将需插入的数据和已排序列,从大到小,两两比较,如果前一个数据大于后一个数据,则交换,直至排好顺序

/**
 * 插入排序
 * @param {*} a 需要排序的数组
 */
 function insertionSort(a) {
	let len = a.length;
	for(let i=1;i<len;i++) {
		for(j=i;j>0;j--) {
			a[j] < a[j-1] && ([a[j],a[j-1]] = [a[j-1], a[j]])
		}
	}
}
let a = [5, 4, 1, 3, 7, 90, 45, 34, 23, 58, 92, 9];
insertionSort(a);
console.log(a); // [1, 3, 4, 5, 7, 9, 23, 34, 45, 58, 90, 92]

方式二:优化点,新增tmp用于存放当前数据。然后,从后向前循环已排序列,如果数据大于tmp,数据往后挪,反之,退出本次循环,并将tmp赋值给a[j+1]

/**
 * 插入排序 新增temp变量
 * @param {*} a 需要排序的数组
 */
function insertionSort2(a) {
	let len = a.length;
	for(let i=1;i<len;i++) {
		let j, tmp = a[i]
		for(j=i-1;j>=0;j--) {
			if(a[j] > tmp) {
				a[j+1] = a[j]
			}else {
				break;
			}
		}
		a[j+1] = tmp
	}
}
let a = [5, 4, 1, 3, 7, 90, 45, 34, 23, 58, 92, 9];
insertionSort2(a);
console.log(a); // [1, 3, 4, 5, 7, 9, 23, 34, 45, 58, 90, 92]

:可以结合二分查找优化插入排序


4、希尔排序

希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。

希尔排序是基于插入排序的以下两点性质而提出改进方法的:

  • 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率
  • 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;

算法思想

先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。

时间复杂度:O(n的1.3次方)

空间复杂度:1

image

/**
 * 希尔排序
 * @param {*} a 
 */
 function shellSort(arr) {
    var len = arr.length,
        temp,
        gap = 1;
    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;
            for (j = i-gap; j >= 0 && arr[j] > temp; j-=gap) {
                arr[j+gap] = arr[j];
            }
            arr[j+gap] = temp;
        }
    }
    return arr;
}

let a = [5, 4, 1, 3, 7, 90, 45, 34, 23, 58, 92, 9];
shellSort(a);
console.log(a); // [1, 3, 4, 5, 7, 9, 23, 34, 45, 58, 90, 92]

5、归并排序

归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

作为一种典型的分而治之思想的算法应用,归并排序的实现有两种方法:

  • 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
  • 自下而上的迭代;

算法思想

1、申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;

2、设定两个指针,最初位置分别为两个已经排序序列的起始位置;

3、比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;

4、重复步骤 3 直到某一指针达到序列尾;

5、将另一序列剩下的所有元素直接复制到合并序列尾。

时间复杂度:O(nlogn)

空间复杂度:O(n)

image

分而治之

image

合并相邻有序子序列

image

/**
 * 归并排序
 * @param {*} a 
 * @returns 返回已排序数组,原数组不变
 */
function mergeSort(a) {
	// 采用自上而下的递归方法
	let len = a.length;
	if (len < 2) return a;

	let mid = ~~(len / 2),
		left = a.slice(0, mid),
		right = a.slice(mid);
	return merge(mergeSort(left), mergeSort(right));

	function merge(left, right) {
		let res = [];
		while (left.length && right.length) {
			let target = left[0] <= right[0] ? left.shift() : right.shift();
			res.push(target);
		}
		while (left.length) {
			res.push(left.shift());
		}

		while (right.length) {
			res.push(right.shift());
		}
		return res;
	}
}

let a = [5, 4, 1, 3, 7, 90, 45, 34, 23, 58, 92, 9];
mergeSort(a);
console.log(mergeSort(a)); // [1, 3, 4, 5, 7, 9, 23, 34, 45, 58, 90, 92]

6、快速排序

快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。

快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。

快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。

算法思想

  • 从数列中挑出一个元素,称为 "基准"(pivot)
  • 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;

快速排序也使用分治的方法。

将原始数组分为较小的数组(但它没有像归并排序那样将它们分割开)。

(1) 首先,从数组中选择中间一项作为主元

(2) 创建两个指针,左边一个指向数组第一个项,右边一个指向数组最后一个项。移动左指针直到我们找到一个比主元大的元素,接着,移动右指针直到找到一个比主元小的元素,然后交换它们,重复这个过程,直到左指针超过了右指针。

(3) 接着,算法对划分后的小数组(较主元小的值组成的子数组,以及较主元大的值组成的子数组)重复之前的两个步骤,直至数组已完全排序。

时间复杂度:O(nlogn)

空间复杂度:O(logn)

image

image

/**
 * 快速排序
 * @param {*} a
 */
function quickSort(a) {
	quick(a, 0, a.length - 1);
	return a;

	function quick(list, left, right) {
		let index = 0;
		if (list.length > 1) {
			index = partition(list, left, right); // 帮助我们将子数组分离为较小值数组和较大值数组
			left < index - 1 && quick(list, left, index - 1);
			index < right && quick(list, index, right);
		}
	}

	function partition(list, left, right) {
		let mid = list[(right + left) >> 1];
		while (left <= right) {
			while (list[left] < mid) {
				left++;
			}
			while (list[right] > mid) {
				right--;
			}
			if (left <= right) {
				[list[left], list[right]] = [list[right], list[left]];
				left++;
				right--;
			}
		}
		return left;
	}
}

let a = [7, 3, 2, 8, 1, 9, 5, 4, 6, 10];
quickSort(a);
console.log(a);

7、堆排序

满足下面两个条件的就是堆:

  • 堆是一个完全二叉树
  • 堆上的任意节点值都必须大于等于(大顶堆)或小于等于(小顶堆)其左右子节点值

如果堆上的任意节点都大于等于子节点值,则称为 大顶堆

如果堆上的任意节点都小于等于子节点值,则称为 小顶堆

也就是说,在大顶堆中,根节点是堆中最大的元素;

在小顶堆中,根节点是堆中最小的元素;

image

怎样创建一个大(小)顶堆

完全二叉树适用于数组存储法,而堆又是一个完全二叉树,所以它可以直接使用数组存储法存储:

简单来说: 堆其实可以用一个数组表示,给定一个节点的下标 i (i从1开始) ,那么它的父节点一定为 A[i/2] ,左子节点为 A[2i] ,右子节点为 A[2i+1]

算法思想

堆是一棵完全二叉树,它可以使用数组存储,并且大顶堆的最大值存储在根节点(i=1),所以我们可以每次取大顶堆的根结点与堆的最后一个节点交换,此时最大值放入了有效序列的最后一位,并且有效序列减1,有效堆依然保持完全二叉树的结构,然后堆化,成为新的大顶堆,重复此操作,直到有效堆的长度为 0,排序完成。

完整步骤为:

  • 将原序列(n个)转化成一个大顶堆
  • 设置堆的有效序列长度为 n
  • 将堆顶元素(第一个有效序列)与最后一个子元素(最后一个有效序列)交换,并有效序列长度减1
  • 堆化有效序列,使有效序列重新称为一个大顶堆
  • 重复以上2步,直到有效序列的长度为 1,排序完成

image

时间复杂度:nlogn

空间复杂度:1


/**
 * 堆排序
 * @param {*} a
 */
function heapSort(a) {
	let len; // 因为声明的多个函数都需要数据长度,所以把len设置成为全局变量
	buildMaxHeap(a);

	for (let i = a.length - 1; i > 0; i--) {
		[a[0], a[i]] = [a[i], a[0]];
		len--;
		heapify(a, 0);
	}
	return a;

	function buildMaxHeap(a) {
		// 建立大顶堆
		len = a.length;
		for (let i = Math.floor(len / 2); i >= 0; i--) {
			heapify(a, i);
		}
	}

	function heapify(a, i) {
		// 堆调整
		let left = 2 * i + 1,
			right = 2 * i + 2,
			largest = i;

		if (left < len && a[left] > a[largest]) {
			largest = left;
		}

		if (right < len && a[right] > a[largest]) {
			largest = right;
		}

		if (largest != i) {
			[a[i], a[largest]] = [a[largest], a[i]];
			heapify(a, largest);
		}
	}
}
let a = [107, 107, 101, 103, 102, 108, 101, 109, 105, 104, 106, 110];
console.log(heapSort(a));
console.log(a);

8、计数排序

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

计数排序的特征

当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 Θ(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。

由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。例如:计数排序是用来排序0到100之间的数字的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在基数排序中的算法来排序数据范围很大的数组。

通俗地理解,例如有 10 个年龄不同的人,统计出有 8 个人的年龄比 A 小,那 A 的年龄就排在第 9 位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去 1 的原因。

算法思想:

(1)找出待排序的数组中最大和最小的元素

(2)统计数组中每个值为i的元素出现的次数,存入数组C的第i项

(3)对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)

(4)反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1

时间复杂度:O(n + k)

空间复杂度:O(n + k)

image

/**
 * 计数排序
 * @param {*} a
 * @param {*} maxV
 */
function countSort(a, maxV) {
	let count = new Array(maxV + 1),
		len = a.length,
		countL = count.length,
		sortedIndex = 0;
	for (let i = 0; i < len; i++) {
		count[a[i]] = count[a[i]] + 1 || 1;
	}

	for (let j = 0; j < countL; j++) {
		while (count[j]) {
			a[sortedIndex++] = j;
			count[j]--;
		}
	}
	return a;
}

/**
 * 优化版:计数排序(某个范围排序)
 * @param {*} a
 */
function countSort2(a) {
	let min = Math.min(...a),
		max = Math.max(...a),
		len = a.length,
		count = new Array(max - min + 1).fill(0),
		countL = count.length,
		sortedIndex = 0;
	// 遍历初始数组,给对应下标(arr[j] - min)计数加1
	for (let i = 0; i < len; i++) {
		count[a[i] - min]++;
	}

	for (let j = 0; j < countL; j++) {
		while (count[j]) {
			a[sortedIndex++] = j + min;
			count[j]--;
		}
	}
	return a;
}

/**
 * 优化版:计数排序(某个范围排序且稳定的)
 * @param {*} a
 */
function countSort3(a) {
	let min = Math.min(...a),
		max = Math.max(...a),
		len = a.length,
		count = new Array(max - min + 1).fill(0),
		countL = count.length,
		sortedIndex = 0,
		res = [];
	// 遍历初始数组,给对应下标(arr[j] - min)计数加1
	for (let i = 0; i < len; i++) {
		count[a[i] - min]++;
	}

	// 从第2个数开始,将后面元素的计数变成与前面所有元素的计数之和
	for (let j = 1; j < countL; j++) {
		// 加上前一位的计数次数(也就是之前所有元素的计数之和)
		count[j] += count[j - 1];
	}

	// 对初始序列,进行从后往前遍历
	for (let k = len - 1; k >= 0; k--) {
		//  获取元素对应的计数
		sortedIndex = count[a[k] - min];
		// 因为下标0占了一位,所以下标要减1
		res[sortedIndex - 1] = a[k];
		count[a[k] - min]--;
	}
	return res;
}

let a = [107, 107, 101, 103, 102, 108, 101, 109, 105, 104, 106, 110];
console.log(countSort3(a));
console.log(a);

9、基数排序

基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

基数排序:

  • 是一种非比较的排序
  • 计数排序的进阶版
  • 按照相同位有效数字的值分组排序
  • 有桶的概念 通过一个桶 从右至左 按照每一位的大小依次进行排序
  • 每一位的排序都遵循队列 进行先入先出重新写入

时间复杂度:O(n * k)

空间复杂度:O(n + k)

image

/**
 * 基数排序
 * @param {*} a
 */
function radixSort(a) {
	let maxDigit = (Math.max(...a) + '').length
	let counter = [],mod = 10,dev = 1;
    for (let i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
        for(let j = 0; j < a.length; j++) {
            let bucket = parseInt((a[j] % mod) / dev);
            !counter[bucket] && (counter[bucket] = []);
            counter[bucket].push(a[j]);
        }
        let pos = 0;
        for(let j = 0; j < counter.length; j++) {
            let value = null;
            if(counter[j] ) {
                while ((value = counter[j].shift())) {
                      a[pos++] = value;
                }
          }
        }
    }
    return a;
}
let a = [107, 107, 101, 103, 102, 108, 101, 109, 105, 104, 106, 110];
console.log(radixSort(a, 3));
console.log(a);

10、桶排序

桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:

  • 在额外空间充足的情况下,尽量增大桶的数量
  • 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中

时间复杂度:O(n + k)

空间复杂度:O(n + k)

image

然后,元素在每个桶中排序:

image

function bucketSort(arr, bucketSize) {
    if (arr.length === 0) {
      return arr;
    }

    var i;
    var minValue = arr[0];
    var maxValue = arr[0];
    for (i = 1; i < arr.length; i++) {
      if (arr[i] < minValue) {
          minValue = arr[i];                // 输入数据的最小值
      } else if (arr[i] > maxValue) {
          maxValue = arr[i];                // 输入数据的最大值
      }
    }

    //桶的初始化
    var DEFAULT_BUCKET_SIZE = 5;            // 设置桶的默认数量为5
    bucketSize = bucketSize || DEFAULT_BUCKET_SIZE;
    var bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;  
    var buckets = new Array(bucketCount);
    for (i = 0; i < buckets.length; i++) {
        buckets[i] = [];
    }

    //利用映射函数将数据分配到各个桶中
    for (i = 0; i < arr.length; i++) {
        buckets[Math.floor((arr[i] - minValue) / bucketSize)].push(arr[i]);
    }

    arr.length = 0;
    for (i = 0; i < buckets.length; i++) {
        insertionSort(buckets[i]);                      // 对每个桶进行排序,这里使用了插入排序
        for (var j = 0; j < buckets[i].length; j++) {
            arr.push(buckets[i][j]);                      
        }
    }

    return arr;
}