简单排序
主要操作:比较两个数据项、交换两个数据项或复制其中一项,具体操作要看排序的类型。
1.冒泡排序
运行效率较低, 但是在概念上它是排序算法中最简单的,最适合初学的一种排序,具体思路为:
- 对未排序的各元素从头到尾依次比较相邻的两个元素大小关系
- 如果左边的队员高, 则两队员交换位置
- 向右移动一个位置, 比较下面两个队员
- 当走到最右端时, 最高的队员一定被放在了最右边
- 继续从最左边开始排序,把第二高的队员排到倒数第二位置上
- 依此类推,完成排序
ArrayList.prototype.bubbleSort = function () {
// 1.获取数组的长度
var length = this.array.length
// 2.反向循环, 因此次数越来越少
for (var i = length - 1; i >= 0; i--) {
// 3.根据i的次数, 比较循环到i位置
for (var j = 0; j < i; j++) {
// 4.如果j位置比j+1位置的数据大, 那么就交换
if (this.array[j] > this.array[j+1]) {
// 交换
this.swap(j, j+1)
}
}
}
}
ArrayList.prototype.swap = function (m, n) {
var temp = this.array[m]
this.array[m] = this.array[n]
this.array[n] = temp
}
冒泡排序的效率为O(N²),比较次数为N²,交换次数也为N²
2.选择排序
思路:
- 选定第一个索引位置,然后和后面元素依次比较
- 如果后面的队员, 小于第一个索引位置的队员, 则交换位置
- 经过一轮的比较后, 可以确定第一个位置是最小的
- 然后使用同样的方法把剩下的元素逐个比较即可
- 可以看出选择排序,第一轮会选出最小值,第二轮会选出第二小的值,直到最后
ArrayList.prototype.selectionSort = function () {
// 1.获取数组的长度
var length = this.array.length
// 2.外层循环: 从0位置开始取出数据, 直到length-2位置
for (var i = 0; i < length - 1; i++) {
// 3.内层循环: 从i+1位置开始, 和后面的内容比较
var min = i
for (var j = min + 1; j < length; j++) {
// 4.如果i位置的数据大于j位置的数据, 那么记录最小的位置
if (this.array[min] > this.array[j]) {
min = j
}
}
// 5.交换min和i位置的数据
this.swap(min, i)
}
}
选择排序虽然效率也是O(N²),但在一定程度上优化了冒泡排序的效率,比较次数跟选择排序的效率一样都是O(N²),交换次数是O(N)。
3.插入排序
插入排序是简单排序中效率最好的一种,也是学习其他高级排序的基础, 比如希尔排序/快速排序, 所以也非常重要。
思路:
- 从第一个元素开始,该元素可以认为已经被排序
- 取出下一个元素,在已经排序的元素序列中从后向前扫描
- 如果该元素(已排序)大于新元素,将该元素移到下一位置
- 重复上一个步骤,直到找到已排序的元素小于或者等于新元素的位置
- 将新元素插入到该位置后, 重复上面的步骤
ArrayList.prototype.insertionSort = function () {
// 1.获取数组的长度
var length = this.array.length
// 2.外层循环: 外层循环是从1位置开始, 依次遍历到最后
for (var i = 1; i < length; i++) {
// 3.记录选出的元素, 放在变量temp中
var j = i
var temp = this.array[i]
// 4.内层循环: 内层循环不确定循环的次数, 最好使用while循环
while (j > 0 && this.array[j-1] > temp) {
this.array[j] = this.array[j-1]
j--
}
// 5.将选出的j位置, 放入temp元素
this.array[j] = temp
}
}
插入排序对已经有序或基本有序的数据来说,效率高得多,它的效率还是O(N²),它的比较次数是选择排序的一半,所以这个算法效率是高于选择排序的。
高级排序
1. 希尔排序
希尔排序是插入排序的一种高效的改进版, 并且效率比插入排序要更快
插入排序的问题:当某个很小的数据比如1在最右端,要想把它放到正确的索引为0的位置,必须把每个前面的数据都向右移动一位,即比较和交换的次数都是N。
希尔排序就是在此基础上提出了一种不需要一个个移动所有中间数据项的算法,大致思路就是先将数据分成间隔比较大的分组进行排序,接着分成间隔小的分组进行排序,一步步缩小间隔,直到间隔为1。分完间隔排序后每次都让数据离自己正确位置更近了,间隔为1的排序就是插入排序了,此时大家都离自己正确的位置很近了,就不需要再对比交换那么多次数了,比如:
- 有数组 81, 94, 11, 96, 12, 35, 17, 95, 28, 58, 41, 75, 15
- 先让间隔为5, 进行排序. (35, 81), (94, 17), (11, 95), (96, 28), (12, 58), (35, 41), (17, 75), (95, 15)
- 排序后的新序列, 一定可以让数字离自己的正确位置更近一步
- 再让间隔位3, 进行排序. (35, 28, 75, 58, 95), (17, 12, 15, 81), (11, 41, 96, 94)
- 排序后的新序列, 一定可以让数字离自己的正确位置又近了一步
- 最后, 让间隔为1, 也就是正确的插入排序. 这个时候数字都离自己的位置更近, 那么需要复制的次数一定会减少很多
所以,希尔排序间隔的选取非常重要,大致的选择思路为: - 希尔排序的原稿中, 建议的初始间距是N / 2, 简单的把每趟排序分成两半
- 希尔排序的效率很增量是有关系的,但是, 它的效率证明非常困难, 甚至某些增量的效率到目前依然没有被证明出来
- 但是经过统计, 希尔排序使用原始增量, 最坏的情况下时间复杂度为O(N²), 通常情况下都要好于O(N²)
ArrayList.prototype.shellSort = function () {
// 1.获取数组的长度
var length = this.array.length
// 2.根据长度计算增量
var gap = Math.floor(length / 2)
// 3.增量不断变量小, 大于0就继续排序
while (gap > 0) {
// 4.实现插入排序
for (var i = gap; i < length; i++) {
// 4.1.保存临时变量
var j = i
var temp = this.array[i]
// 4.2.插入排序的内层循环
while (j > gap - 1 && this.array[j - gap] > temp) {
this.array[j] = this.array[j - gap]
j -= gap
}
// 4.3.将选出的j位置设置为temp
this.array[j] = temp
}
// 5.重新计算新的间隔
gap = Math.floor(gap / 2)
}
}
总之, 我们使用希尔排序大多数情况下效率都高于简单排序, 甚至在合适的增量和N的情况下, 还好好于快速排序
2.快速排序
快速排序几乎可以说是目前所有排序算法中, 最快的一种排序算法。当然希尔排序确实在某些情况下可能好于快速排序.,但是大多数情况下, 快速排序还是比较好的选择.
快速排序非常重要,是面试中提升水平的排序算法
快速排序是冒泡排序的升级版,冒泡排序要交换很多次才能将最大值放到正确的位置上,而快速排序可以在一次循环中(其实是递归调用)找出某个元素的正确位置, 并且该元素之后不需要任何移动。
快速排序最重要的思想是分而治之,比如:
- 先选择其中一个数字,将大于数字的放到右边,小于数字的放到左边
- 这个数字就被放到了自己肯定正确的地方
- 接下来再对左右两堆数据进行递归
简单的实现方式——像搜索二叉树一样
递归
function quicksort(arr) {
if(arr.length < 2) return arr
let left = []
let right = []
let base = arr[0]
for (let i = 1; i < arr.length; i++) {
if(arr[i] < base) {
left.push(arr[i])
} else {
right.push(arr[i])
}
}
return quicksort(left).concat(base, quicksort(right))
}
性能好一点的做法
function quicksort(arr, begin, end) {
// 跳出递归条件
if (begin >= end) return
// 指针
let left = begin
let right = end
// 基准数
let base = arr[begin]
while (left < right) {
// 找到小于 base 的数
while (left < right && base <= arr[right]) {
right--
}
// 找到大于 base 的数
// = 是为了能跳过第一个数
while (left < right && base >= arr[left]) {
left++
}
// 解构赋值
[arr[left], arr[right]] = [arr[right], arr[left]]
}
[arr[begin], arr[left]] = [arr[left], arr[begin]]
quicksort(arr, begin, left-1)
quicksort(arr, left+1, end)
}
不需要边界条件的方法,结合前两种方法。
function quickSort(arr) {
// 退出递归条件
if(arr.length <= 1) return arr
let left = 0
let right = arr.length-1
// 每次都判断第一个元素
while (left<right) {
// 先判断 左边或者右边是会影响 起始位置和要换的位置的
// 先right--,最后left right会指向小于起始位置的数
// 先left++,最后left right会指向大于起始位置的数,不能直接置换arr[0],arr[left],要置换arr[left-1]
while (left<right && arr[right]>=arr[0]) {
right--
}
while (left<right && arr[left]<=arr[0]) {
left++
}
[arr[left], arr[right]] = [arr[right], arr[left]]
}
// 把第一个元素放到正确的位置上
[arr[0], arr[left]] = [arr[left], arr[0]]
// 递归+拼接数组,注意 右数组的边界是 arr.length,取到最后一个
return quickSort(arr.slice(0, left)).concat(arr[left], quickSort(arr.slice(left+1, arr.length)))
}
快速排序的最坏效率就是每次选择的枢纽都是最左边或最右边,此时效率等同于冒泡排序,但选择中位数的枢纽选择方法是不可能遇到最坏情况的,所以快速排序的平均效率是O(N * logN),其他某些算法的效率也可以达到O(N * logN), 但是快速排序是最好的。
3.归并排序
归并排序,是创建在归并操作上的一种有效的排序算法。算法采用分治法,且各层分治递归可以同时进行。归并排序思路简单,速度仅次于快速排序,为稳定排序算法,一般用于对总体无序,但是各子项相对有序的数列。
思路:
分解(Divide):将n个元素分成个含n/2个元素的子序列。
解决(Conquer):用合并排序法对两个子序列递归的排序。
合并(Combine):合并两个已排序的子序列已得到排序结果。
function mergeSort(arr) {
const length = arr.length;
if (length === 1) { //递归算法的停止条件,即为判断数组长度是否为1
return arr;
}
const mid = Math.floor(length / 2);
const left = arr.slice(0, mid);
const right = arr.slice(mid, length);
return merge(mergeSort(left), mergeSort(right)); //要将原始数组分割直至只有一个元素时,才开始归并
}
function merge(left, right) {
const result = [];
let il = 0;
let ir = 0;
//left, right本身肯定都是从小到大排好序的
while( il < left.length && ir < right.length) {
if (left[il] < right[ir]) {
result.push(left[il]);
il++;
} else {
result.push(right[ir]);
ir++;
}
}
//不可能同时存在left和right都有剩余项的情况, 要么left要么right有剩余项, 把剩余项加进来即可
while (il < left.length) {
result.push(left[il]);
il++;
}
while(ir < right.length) {
result.push(right[ir]);
ir++;
}
return result;
}
4.堆排序
利用最大堆/最小堆的性质排序,基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。
function heapSort(arr) {
let heapSize = arr.length;
buildHeap(arr);//构造一个所有节点都满足arr[parent[i]] > arr[i]的堆结构数组,这样就把值最大的那个节点换到了根节点
while(heapSize > 1) { //*1
//在当前树中,交换位于根节点的最大值和最后一个节点的值,这样就把最大值排在了最后一个节点,这样就排好了最大值
const temp = arr[0];
arr[0]=arr[heapSize-1];
arr[heapSize-1] = temp;
heapSize--;//当前树中最后一个节点已经排好了值,故后面就不用再考虑这个节点,故新的树的大小减一
if (heapSize>1) {
heapify(arr, heapSize, 0);//上面的交换操作产生了新的根节点,新的根节点只是通过跟最后一个节点交换得到的值,故新的根节点不满足条件arr[parent[i]]<arr[i],所以要对根节点再次进行h
}
}
}
/**
* @description 构造一个所有节点都满足arr[parent[i]] > arr[i]的堆结构数组
* @param {Array} arr 待排序数组
*/
function buildHeap(arr) {
const heapSize = arr.length;
const firstHeapifyIndex = Math.floor(heapSize/2-1);//从树的倒数第二层的最后一个有子节点的节点(对于满二叉树就是倒数第二层的最后一个节点)开始进行heapify处理。Math.floor(heapSize/2-1)就是这个最后一个有子节点的节点索引。
for (let i=firstHeapifyIndex; i >= 0; i--) {//从0到firstHeapifyIndex都要进行heapify处理,才能把最大的那个节点换到根节点
heapify(arr, heapSize, i);
}
}
/**
* @description 以数组arr的前heapSize个节点为树,对其中索引为i的节点向子节点进行替换,直到满足从i往下的子节点都有arr[parent[i]]>=arr[i]
* @param {*} arr TYPE Array 待排序的数组
* @param {*} heapSize TYPE Number 待排序的数组中要作为当前树处理的从前往后数的节点个数,即待排序数组中前heapSize个点是要作为树来处理
* @param {*} i TYPE Number arr数组中、heapSize长度的树中的当前要进行往子节点替换的节点的索引
*/
function heapify(arr, heapSize, i) {
const leftIndex = i * 2 + 1;//索引i的节点的左子节点索引
const rightIndex = i * 2 + 2;//索引i的节点的右子节点索引
let biggestValueIndex = i;
if (leftIndex < heapSize && arr[leftIndex] > arr[biggestValueIndex]) {
//节点的最大index为heapSize-1
//注意:这两次比较要跟arr[biggestValueIndex]比较,不能跟arr[i]比较,因为biggestValueIndex是会在左右i之间更新的
biggestValueIndex = leftIndex; //如果左子节点的值大于biggestValueIndex的值(此时就是根节点的值),那么更新biggestValueIndex为左子节点索引
}
if (rightIndex < heapSize && arr[rightIndex] > arr[biggestValueIndex]) {
biggestValueIndex = rightIndex;//如果右子节点的值大于biggestValueIndex的值(此时可能是根节点的值,也可能是左子节点的值),那么更新biggestValueIndex为右子节点索引
}
if (biggestValueIndex !== i) { //如果biggestValueIndex是左子节点索引或右子节点索引,那么交换根节点与biggestValueIndex节点的值
const temp = arr[i];
arr[i] = arr[biggestValueIndex];
arr[biggestValueIndex] = temp;
//交换后,被交换的那个子节点(左子节点或右子节点)往下可能就不再满足[parent[i]]>=arr[i],所以要继续对biggestValueIndex进行heaify处理,即将biggestValueIndex可能需要和子节点进行值交换,直到树的这个分支到叶子节点都满足arr[parent[i]]>=arr[i]
heapify(arr, heapSize, biggestValueIndex);//要
}
}
堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆部分组成。其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)…1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级。
5.计数排序
计数排序是利用数组下标进行排序,适用于一定范围的整数排序,在取值范围不是很大的情况下,性能在某些时候甚至快过O(nlogn)的排序,例如快速排序、归并排序。
(1)简单实现
function countSort(nums) {
let arr = []
for(let i = 0; i < nums.length; i++) {
if(!arr[nums[i]]) arr[nums[i]] = 1
else arr[nums[i]]++
}
let res = []
for(let i = 0; i < arr.length; i++) {
while(arr[i]--) {
res.push(i)
}
}
return res
}
缺点:只适合集中的小整数,否则键值数组的空间浪费过大。
(2)一定范围的计数排序
当数组为:95, 94, 91, 98, 99, 90, 99, 93, 91, 92,若还是直接以它们为下标,会浪费很多空间,所以不直接以数组的值为下标,而是以最大值和最小值的差为下标,再把最小值作为一个偏移量,统计数组。
(3)稳定的计数排序
若数组中有相同的值,还要让排序后它们还保持同样的顺序,对统计数组再次做一些处理,让统计数组从第二个元素开始加上前一个元素的值,从而让统计数组存储的元素值等于整数的最终排序位置。
// 伪代码
// 1. 循环一:根据数组的最大值和最小值计算出差值
for(let i = 0; i < nums.length ; i++ ) {
if(nums[i] > max) max = nums[i]
else if(nums[i] < min) min = nums[i]
}
// 2. 循环二:创建统计数组,并计算统计对应元素个数
let arr = new Array(max-min+1).fill(0)
for(let i = 0; i < nums.length; i++) {
arr[nums[i] - min]++
}
// 3. 循环统计数组:变形,元素 = 元素 + 前一元素
for(let i = 1; i < arr.length; i++) {
arr[i] += arr[i-1]
}
// 4. 循环三:倒序遍历原始数组,从统计数组找到正确位置,输出到结果数组
let res = []
for(let i = nums.length-1; i > 0; i--) {
// 找到对应索引
const idx = arr[nums[i]-min]
res[idx-1] = nums[i]
arr[nums[i]-min]--
}
缺点:数列最大最小值差距过大时,不适用;不是整数时,不适用。
6. 桶排序
在计数排序的基础上,把每个数组下标扩展成一个数组范围,通过映射函数,将待排序数组中的元素映射到各个对应的桶中,在每个桶内再排序,最后将桶中元素逐个放入原序列中。
思路
- 设置固定空桶数
- 将数据放到对应的空桶中
- 将每个不为空的桶进行排序
- 拼接不为空的桶中的数据,得到结果
var bucketSort = function(arr, bucketCount) {
if (arr.length <= 1) {
return arr;
}
bucketCount = bucketCount || 10;
//初始化桶
var len = arr.length,
buckets = [],
result = [],
max = arr[0],
min = arr[0];
for (var i = 1; i < len; i++) {
min = min <= arr[i] ? min: arr[i];
max = max >= arr[i] ? max: arr[i];
}
//求出每一个桶的数值范围
var space = (max - min + 1) / bucketCount;
//将数值装入桶中
for (var i = 0; i < len; i++) {
//找到相应的桶序列
var index = Math.floor((arr[i] - min) / space);
//判断是否桶中已经有数值
if (buckets[index]) {
//数组从小到大排列
var bucket = buckets[index];
var k = bucket.length - 1;
while (k >= 0 && buckets[index][k] > arr[i]) {
buckets[index][k + 1] = buckets[index][k];
k--
}
buckets[index][k + 1] = arr[i];
} else {
//新增数值入桶,暂时用数组模拟链表
buckets[index] = [];
buckets[index].push(arr[i]);
}
}
//开始合并数组
var n = 0;
while (n < bucketCount) {
if (buckets[n]) {
result = result.concat(buckets[n]);
}
n++;
}
return result;
};
//开始排序
arr = bucketSort(arr, self.bucketCount);
注意
桶排序效率最高是元素分散均匀时,若所有数据集中在一个桶中,则排序失效。
7.基数排序
将整数按位数切割成不同的数字,然后按每个位数分别比较。
思路
- 将所有待比较数值统一为同样的数位长度,数位较短的数前面补零
- 然后从最低位开始,依次进行排序
- 所以从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列
function radixSort(ary) {
// 获取最大值
let maxNum = Math.max.apply(Math, ary);
let t = 1,
bucketAry = new Array(10), // 0~9的数组,用来计算数字出现次数
temp = new Array(ary.length); // 交换数组, 用来临时存储排序的数
// 这一步是计算最大数有多少位,这个位数就是要循环的次数
while ((maxNum /= 10) >= 1) {
t++;
}
let rate = 1,
K= null;
for (let i = 1; i <= t; i++) {
// 计数数组归零
bucketAry.fill(0);
// 清点数字次数
ary.forEach((item) => {
// 求数字最后一位的值
k = Math.floor(item / rate) % 10;
bucketAry[k]++;
});
// 通过数字次数得到该数字应该在数组中的位置
bucketAry.reduce((total, item, index) => {
bucketAry[index] = total + item;
return total + item
});
// 通过计算的顺序将ary中数存入temp数组中
for (let j = ary.length - 1; j >= 0; j--) {
k = Math.floor(ary[j] / rate) % 10;
temp[bucketAry[k] - 1] = ary[j];
bucketAry[k]--;
}
// 将temp相同位置的值负值给ary, 不能直接 ary = temp
ary = ary.map((item, index)=>temp[index]);
rate *= 10;
}
temp = null;
bucketAry = null;
return ary;
}