最近在学习过程中,我遇到了一些排序相关的问题。这让我想起了之前面试的过程中曾经问过的一个问题:”你知道几种排序“。为了加深自己对排序算法的理解,我决定重新整理这个问题并分享我的答案。
生成数据
在排序之前我们需要先写一个生成乱序数据的方法
可以分成两步:
1、生成一个有序数组
2、将起随机打乱
如何打乱呢?
我们可以新建一个数组,每次从数组中随机取出一个并按抽取照顺序依次放入数组中。
为了避免内存的浪费,可以在原数组上做上述类似的操作,将数组分为两部分,末端为已打乱,其他部分为未打乱,
例如:
step1:从所有数组中随机选取一个,与最后一个元素交换位置,这样最后一个元素为已打乱,假设数组长度为length,前面length-1 个元素为待打乱,
step2:从lenght-1 中随机抽取一个元素,与倒数第二个元素交换,这样就有两个元素是已打乱的,以此类推。
我们将上述逻辑转化为代码:
// 交换元素的方法,后续就不重复写了
function swap(arr, i, j) {
if (i === j) {
return;
}
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
function randomArr(len) {
const arr = [];
for (let i = 0; i < len; i++) {
let randomIndex = Math.floor(Math.random() * (len - i)) + i;
let changeIndex = len - i - 1;
if (typeof arr[changeIndex] === 'undefined') {
arr[changeIndex] = changeIndex;
}
if (typeof arr[randomIndex] === 'undefined') {
arr[randomIndex] = randomIndex;
}
swap(arr, randomIndex, changeIndex);
}
return arr;
}
上述代码中我们将生成有序数组和打乱过程合并在一个for循环中了,如果在原始数组上读取不到值,那么我们就先赋值再做交换处理。
排序算法
有了上述生成数据的方法,我们可以编写我们的排序算法啦,首先想到的是三种非常经典的排序。
插入排序
插入排序的思想很简单,从第二个元素开始,向前寻找合适的位置,然后插入其中,在寻找合适位置的过程中同样需要遍历,用一张图表示
function insertSort(arr) {
let len = arr.length;
for (let i = 1; i < len; i++) {
// 寻找合适的位置
let temp = arr[i];
let insertIndex = i - 1;
while (insertIndex >= 0 && arr[insertIndex] > temp) {
arr[insertIndex + 1] = arr[insertIndex];
insertIndex--;
}
arr[insertIndex + 1] = temp;
}
return arr;
}
冒泡排序
从第一个元素开始,依次和后面一个元素作比较,如果后面的元素较大,则互换位置,当最后一个元素比较完成时最大的元素一定会置换到最后的位置去,我们对剩下的元素做同样的操作就可以依次固定最大的,次大的元素。整个过程看上就就是把最大的元素不断地向后冒泡。
function bubbleSort(arr){
for (let i = 0; i < arr.length; i++) {
for (let j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
let temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
return arr;
}
选择排序
找到最小的元素记住它,与第一个元素交换位置,然后找剩下的元素中最小的,与第二个元素交换位置,以此类推
function selectionSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
let min = i;
for (let j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[min]) {
min = j;
}
}
swap(arr, i, min);
}
return arr;
}
以上排序几乎没有开辟新的空间来存储子数组,所以空间复杂度是O(1), 但是都是两次嵌套循环,所以时间复杂度是O(n^2)
下面我们来看一些牺牲空间换时间的排序算法。
计数排序
上述我们生成的数据是从0开始的,并且是不重复的,最后排序的结果一定是[0,1,2,3…],key和value 是相等的
如果对于这种数组来说,如果我们新开辟一个数组,直接讲值最为key放入新的数组中,则新数组就是排序的结果
举个例子:
原始数据 [6,5,4,3,2,1,0]
生成一个新数组[]
- 第一个元素是6, 放入新数组的索引为6的位置[,6]
- 第二个元素是5 放入新数组的索引为5的位置[,5,6]
- … 最后变成了 [0,1,2,3,4,5,6]
只需要遍历一遍,数据的排序就好了,其核心思想是,value 作为新数组的key,新数组中自然就会将其排列好。
如果将上述思想再扩展一下,就是所谓的计数排序,
我们需要考虑到数据可能是重复的,数据起始位置不一定从0开始,我们可以将数据作为新的数组的key,但是考虑到重复的数据,我们需要记录一下这个值出现的次数,我们可以将新数组的形式设计为下面这种格式
[key<原始数据的value> : value<原始数据中出现了几次>]
举个例子
原始数据为 [3,3,1,2,2,0]
3出现了2次
1出现了1次
2出现了2次
0出现了1次
那么新数组为 [0:1,1:1,2:2,3:3]
这个新数组记录的就是原始数据中值出现的次数,我们称之为计数数组,
最后按照这个计数结果依次展开不就是最后排序的结果了
[0,1,2,2,3,3]
这种排序对于上述我们演示的数据来说效率极高,但是对于数据跨度很大的数据来说会浪费很多不必要的空间。而且不太适合value 为非数字类型的数组
比如 原始数据为[0,100]
计数数组的长度会变成100,本来只有两个数据,但是我们却要建立长度为100的计数数组,很显然这会浪费额外的空间
// 获取最大值和最小值
function getCountInfo(arr){
let min = arr[0];
let max = arr[0];
let canCount = true;
for(let i=0;i<arr.length;i++){
if(typeof arr[i]!=='number'){
canCount = false;
break;
}
if(arr[i]>max){
max = arr[i]
}
else if(arr[i]<min){
min = arr[i]
}
}
return {min,max,canCount};
}
function countingSort(arr){
const {min,max,canCount} = getCountInfo(arr);
if(!canCount){
return arr;
}
const countingArr = [];
for(let i=0;i<arr.length;i++){
let countIndex = arr[i]-min;
if(typeof countingArr[countIndex] === 'undefined'){
countingArr[countIndex] = 0;
}
countingArr[countIndex]++;
}
let originIndex = 0;
for(let i=0;i<countingArr.length;i++){
let amount = countingArr[i];
for(let j=0;j<amount;j++){
let originValue = i+min;
arr[originIndex++] = originValue;
}
}
return arr;
}
接下来几种排序都有点”分治“的思想,
分治法(英语:Divide and conquer)字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
举个例子对于一个大的数组,第一步将数据分成两部分,小的数据和大的数据,然后再讲小的数据继续划分,直到划分的结果只剩下两个数字,最后问题就解决了。
接下来我们来看看具体是怎么分治的。
快速排序
快速排序的思想在于
1、先找一个基准数字,比它小的放在左边,比它大的放在右
2、这样就把数据分成了两部分,
3、用同样的方法把上述小的数组继续细分,直到小数组中只剩下两个数字
这就是上述提到的分治的思想,把大问题拆成小问题,只要这个拆分的尽头问题解决了那么整个问题都解决了。
那编码的关键在于如何将一个数组按照某个基准数分成两部分,基准数我们可以采用数组的第一个元素,
方法一:从基准元素后面一个元素开始遍历,比这个基准元素小的都依次跟在基准元素后面
举个例子:原始数据为 3,6,4,5,2,1
我们以3 为基准,从6开始遍历,如果遇到小的就跟在3后面
- 6>3 所以先不动
- 4>3 所以先不动
- 5>3 先不动
- 2<3 ,让2排在3后面,这时候6就需要腾位置了,所以我们将2和6交换
- 数组变成 3,2,4,6,1
- 1<3 ,我们需要跟在3后面,2已经占据了第一个位置,那么1只能去3后面的第二个位置(4的位置)的位置
- 所以1与4进行交换,
- 最终变成 3,2,1, 4,6
- 数据分成了两部分,321 和 46 ,基准数据后面跟着的都是比它小的,所以我们将基准数据放到前半部分的最后,3和1交换位置
- 数据变成1,2 3 ,4,6
- 这样用基准数据分成大数据和小数据就完成啦
我们将上述过程变成代码
function quickSort(arr,start=0,end=arr.length){
if(start>=end){
return;
}
let splitIndex = partion(arr,start,end);
quickSort(arr,start,splitIndex-1);
quickSort(arr,splitIndex,end);
}
function partion(arr,start=0;end=arr.length){
let baseValue = arr[start];
let minOffset = start;
for(let i = start+1;i<end;i++){
if(arr[i]<baseValue){
swap(arr,i,minOffset);
minOffset++;
}
}
swap(arr,minOffset,start);
return minOffset;
}
方法二:我们也可以使用双指针来操作
这篇文章对于双指针讲解的比较详细 快速排序算法详解(原理、实现和时间复杂度) (biancheng.net)
// 双指针分发
function partion(arr, left = 0, right = arr.length) {
let startIndex = left;
let endIndex = right - 1;
while (startIndex < endIndex) {
while (arr[startIndex] <= arr[endIndex] && startIndex < endIndex) {
endIndex--;
}
swap(arr, startIndex, endIndex);
while (arr[startIndex] <= arr[endIndex] && startIndex < endIndex) {
startIndex++;
}
swap(arr, startIndex, endIndex);
}
return startIndex;
}
归并排序
归并排序是一种分治算法,它将一个大数组分成两个或更多个小数组,对每个子数组进行排序,然后将排序后的子数组合并成一个已排序的大数组。以下是归并排序的基本步骤:
- 将数组分成两个(或更多)子数组。如果数组只有一个元素,则已经排序,不需要进一步操作。
- 对每个子数组进行归并排序(递归地应用归并排序算法)。
- 将排序后的子数组合并成一个已排序的大数组。
合并过程如下:
- 创建两个指针,一个指向第一个子数组的第一个元素,另一个指向第二个子数组的第一个元素。
- 比较两个指针所指向的元素。将较小(或较大,取决于排序顺序)的元素添加到结果数组中,并将相应的指针向前移动一位。
- 重复步骤2,直到一个子数组中的所有元素都被添加到结果数组中。
- 将另一个子数组中剩余的元素添加到结果数组中。
function mergeSort(arr) {
if (arr.length < 2) {
return arr;
}
let middle = Math.floor(arr.length / 2);
let leftArr = arr.slice(0, middle);
let rightArr = arr.slice(middle, arr.length);
arr = null;
return merge(mergeSort(leftArr), mergeSort(rightArr));
}
function merge(left = [], right = []) {
const res = [];
let leftIndex = 0,
rightIndex = 0;
while (leftIndex < left.length || rightIndex < right.length) {
let leftValue = left[leftIndex] ?? Infinity;
let rightValue = right[rightIndex] ?? Infinity;
if (leftValue < rightValue) {
res.push(leftValue);
leftIndex++;
} else {
res.push(rightValue);
rightIndex++;
}
}
left = null;
right = null;
return res;
}
堆排序
堆排序是一种基于比较的排序算法,它利用数据结构二叉堆(通常为最大堆或最小堆)对数组进行排序。以下是堆排序的基本步骤:
- 将数组构建成一个最大堆(或最小堆,取决于排序顺序)。
- 将堆顶元素(最大值或最小值)与堆的最后一个元素交换。
- 移除堆中的最后一个元素,将其添加到已排序部分。
- 对剩余的堆进行调整,使其重新满足最大堆(或最小堆)的性质。
- 重复步骤2-4,直到堆为空。
构建最大堆的过程如下:
- 从数组的一半处开始向前遍历,对每个元素执行下沉操作。
- 下沉操作:将当前元素与其子节点中的较大者(或较小者,取决于排序顺序)交换,直到它满足堆的性质(即当前元素大于(或小于)其子节点)。
function heapSort(arr) {
buildMaxHeap(arr);
let len = arr.length;
for (let i = arr.length - 1; i >= 0; i--) {
swap(arr, 0, i);
len--;
heapify(arr, 0, len);
}
return arr;
}
function buildMaxHeap(arr) {
let len = arr.length;
for (let i = Math.floor(len / 2); i >= 0; i--) {
heapify(arr, i, len);
}
}
function heapify(arr, i, len = arr.length) {
let left = 2 * i + 1;
let right = 2 * i + 2;
let largest = i;
if (left < len && arr[left] > arr[largest]) {
largest = left;
}
if (right < len && arr[right] > arr[largest]) {
largest = right;
}
if (largest !== i) {
swap(arr, i, largest);
heapify(arr, largest, len);
}
}
桶排序
桶排序(Bucket sort)是一种线性排序算法,它的基本思想是将待排序数据分到有限数量的桶子里,然后对每个桶子里的数据进行排序,最后将所有桶子里的数据依次取出,即可得到排好序的序列。桶排序的时间复杂度为O(n+k),其中n是待排序元素的个数,k是桶的数量。
以下是桶排序的过程:
- 确定桶的数量和每个桶能够容纳的数据范围。
- 遍历待排序序列,将每个元素放到对应的桶中。
- 对每个桶中的元素进行排序。
- 按照桶的顺序,依次将所有元素取出,即可得到排好序的序列。
需要注意的是,如果待排序元素的分布比较均匀,则每个桶中的元素数量不会太多,排序效率较高。但如果待排序元素分布不均匀,则某些桶中的元素数量可能会很多,需要考虑如何优化桶内排序算法。
function bucketSort(arr, bucketSize = 5, min = 0, max = arr.length - 1) {
let buckets = [];
function getBucketIndex(value) {
let index = Math.floor((value - min) / bucketSize);
return index;
}
function insertToArr(bucketArr, value) {
if (!bucketArr) {
return [value];
}
let insertIndex = 0;
while (
bucketArr[insertIndex] < value &&
insertIndex < bucketArr.length
) {
insertIndex++;
}
for (let i = bucketArr.length; i > insertIndex; i--) {
bucketArr[i] = bucketArr[i - 1];
}
bucketArr[insertIndex] = value;
return bucketArr;
}
for (let i = 0; i < arr.length; i++) {
let bucketIndex = getBucketIndex(arr[i]);
if (!buckets[bucketIndex]) {
buckets[bucketIndex] = [];
}
insertToArr(buckets[bucketIndex], arr[i]);
}
let index = 0;
for (let i = 0; i < buckets.length; i++) {
for (let j = 0; j < buckets[i].length; j++) {
arr[index++] = buckets[i][j];
}
}
return arr;
}
基数排序
基数排序(Radix sort)是一种非比较型整数排序算法,它的基本思想是将待排序元素按照位数依次排序,从低位到高位,直到所有位数排序完成。基数排序可以采用LSD(Least significant digit)和MSD(Most significant digit)两种方式实现。其中,LSD方式从低位到高位依次排序,MSD方式从高位到低位依次排序。
以下是LSD方式实现基数排序的过程:
- 将待排序元素按照个位数的大小放入桶中。
- 按照桶的顺序,依次将所有元素取出,组成新的序列。
- 将新的序列按照十位数的大小放入桶中。
- 按照桶的顺序,依次将所有元素取出,组成新的序列。
- 重复步骤3和4,直到所有位数都排好序。
基数排序的时间复杂度为O(d(n+k)),其中d是元素的最大位数,k是每个桶中元素的数量。基数排序适用于元素数量大、位数少的序列排序。
function radixSort(arr) {
let countingArr = new Array(10);
let len = arr.length;
let position = 1;
function getNumByPosition(num, position) {
const res = parseInt((num % (position * 10)) / position);
return isNaN(res) ? 0 : res;
}
while (len / position > 1) {
countingArr.fill(null);
for (let i = 0; i < len; i++) {
let positionNum = getNumByPosition(arr[i], position);
if (!countingArr[positionNum]) {
countingArr[positionNum] = [];
}
countingArr[positionNum].push(arr[i]);
}
// 调整顺序
let temp = [];
let index = 0;
for (let j = 0; j < countingArr.length; j++) {
for (let k = 0; countingArr && k < countingArr[j].length; k++) {
temp[index] = countingArr[j][k];
index++;
}
}
arr = temp;
position = position *= 10;
}
return arr;
}
希尔排序
function shellSort(arr) {
for (
let gap = Math.floor(arr.length / 2);
gap >= 1;
gap = Math.floor(gap / 2)
) {
for (let i = gap; i < arr.length; i++) {
for (let j = i; j >= gap && arr[j] < arr[j - gap]; j -= gap) {
swap(arr, j, j - gap);
}
}
}
return arr;
}