第三梯队
冒泡排序
冒泡排序的思想:把相邻的元素两两比较,当一个元素大于右侧相邻元素时,交换它们的位置;当一个元素小于或者等于右侧相邻元素时,位置不变
实现代码
function bubbleSort(list:Array<number>) {
// 注意 这里是 < length - 1 也就是说遍历次数为 n-1
for (let i = 0; i < list.length - 1; i++) {
// 这里 < length -1的原因同上 - i的原因是 有序区
for (let j = 0; j < list.length - i -1; j++) {
if( list[j] > list[j+1]) {
let temp = list[j];
list[j] = list[j+1];
list[j+1] = temp
}
}
}
return list
}
优化1:标记是否有序
// 是否有序标识优化
function bubbleSortTwo(list:Array<number>) {
// 注意 这里是 < length - 1 也就是说遍历次数为 n-1
for (let i = 0; i < list.length - 1; i++) {
let isSorted = true // 有序标识 每一轮都为true
for (let j = 0; j < list.length - i -1; j++) {
if( list[j] > list[j+1]) {
let temp = list[j];
list[j] = list[j+1];
list[j+1] = temp
// 如果有交换发生,则不是有序的
isSorted = false
}
}
if(isSorted) {
break
}
}
return list
}
优化3:有序区优化
// 有序区优化
function bubbleSortThree(list:Array<number>) {
// 记录边界位置
let sortBorder = list.length - 1;
//记录最后一次交换的位置
let lastExcChangeIndex = 0;
// 注意 这里是 < length - 1 也就是说遍历次数为 n-1
for (let i = 0; i < list.length - 1; i++) {
let isSorted = true; // 有序标识 每一轮都为true
for (let j = 0; j < sortBorder; j++) {
if( list[j] > list[j+1]) {
let temp = list[j];
list[j] = list[j+1];
list[j+1] = temp;
// 如果有交换发生,则不是有序的
isSorted = false;
// 把边界更新为最后一次交换元素的位置
lastExcChangeIndex = j
}
}
sortBorder = lastExcChangeIndex
if(isSorted) {
break
}
}
return list
}
鸡尾酒优化
冒泡排序的每一轮都是从左到右来比较元素,进行单向比较,而鸡尾酒排序的元素比较和交换是双向的
// 鸡尾酒排序
function bubbleSortFour(list:Array<number>) {
// 外层总轮数 这里取一半 floor
let outLength = Math.floor(list.length/2)
for (let i = 0; i < outLength; i++) {
let sorted = true; // 有序标识
for (let j = i; j < list.length - 1 - i; j++) {
if(list[j] > list[j + 1]){
let temp = list[j];
list[j] = list[j + 1];
list[j + 1] = temp
sorted = false
}
}
if(sorted){
break
}
sorted = true;
// 从右往左 比较
for (let j = list.length - 1 - i; j > i ; j--) {
if(list[j] < list[j - 1]){
let temp = list[j];
list[j] = list[j - 1];
list[j - 1] = temp;
sorted = false;
}
}
if(sorted) {
break
}
}
return list
}
鸡尾酒+有序区优化
// 鸡尾酒排序+有序边界
function bubbleSortFive(list:Array<number>) {
let leftSortBorder = 0;
let leftLastSortIndex= 0;
let rightSortBorder = list.length - 1;
let rightLastSortIndex = 0;
// 外层总轮数 这里取一半 floor
let outLength = Math.floor(list.length/2)
for (let i = 0; i < outLength; i++) {
let sorted = true; // 有序标识
for (let j = leftSortBorder; j < rightSortBorder; j++) {
if(list[j] > list[j + 1]){
let temp = list[j];
list[j] = list[j + 1];
list[j + 1] = temp
sorted = false;
// 记录右边界
rightLastSortIndex = j;
}
}
// 赋值右边界
rightSortBorder = rightLastSortIndex
if(sorted){
break
}
sorted = true;
// 从右往左 比较
for (let j = rightSortBorder; j > leftSortBorder ; j--) {
if(list[j] < list[j - 1]){
let temp = list[j];
list[j] = list[j - 1];
list[j - 1] = temp;
sorted = false;
// 记录左边界
leftLastSortIndex = j - 1;
}
}
// 赋值左边界
leftSortBorder = leftLastSortIndex
if(sorted) {
break
}
}
return list
}
选择排序
选择排序的思想:每一轮选择最小者,直接交换到数组最左边
实现代码
function sort(arr:Array<number>) {
for (let index = 0; index < arr.length - 1; index++) {
let minIndx = index;
for (let j = index + 1; j < arr.length; j++) {
if(arr[j] < arr[minIndx]) {
minIndx = j
}
};
let temp = arr[minIndx];
arr[minIndx] = arr[index];
arr[index] = temp
}
return arr
}
选择排序,解决了冒泡排序频繁的元素交换操作导致的代码效率问题,但是也变为了不稳定排序
插入排序
插入排序的思想:维护一个有序区,把元素一个一个插入到有序区的适当位置,直到所有元素有序
实现代码
function insertSort(arr:Array<number>) {
for (let index = 1; index < arr.length; index++) {
let temp = arr[index];
let j = index - 1;
// 从右比较元素 并且右移
for (; j >= 0 && arr[j] > temp; j--) {
arr[j + 1] = arr[j]
}
// 插入insertValue
arr[j + 1] = temp
}
return arr
};
小结
第三梯队的排序算法平均时间复杂度都为 O(n2)
区别和差异
1. 性能方面
冒泡排序和插入排序的元素比较和交换次数取决于原始数组的有序程度
但是总体来讲,插入排序的性能略高于冒泡排序
因为冒泡排序每两个元素之间的交换是彼此独立的
而插入排序的交换是连续的
显然插入排序省去了许多无谓的交换操作
此外,选择排序和前两者不一样,它的元素比较交换次数是固定的,和原始数组有序程度无关
所以:当原始数组接近有序时,插入排序性能最优,当原始数组大部分元素无序时,选择排序性能最优
2. 是否稳定
冒泡排序和插入排序是稳定排序,选择排序是不稳定排序
第二梯队
堆排序
堆排序思想:堆排序是基于二叉堆这种具有自我调整能力的排序算法,总结为:把无序数组构建成为二叉堆,需要升序,则构建最大二叉堆,需要降序则构建最小二叉堆
实现代码
/**
* @param {Array<number>} arr 待调整的堆
* @param {number} parentIndex 要下沉的父节点
* @param {number} length 堆的有效大小
* @description 下沉调整 最大堆
*/
function downAdjust(arr: Array<number>, parentIndex: number, length:number) {
// temp 保存父节点的值,用作最后赋值
let temp = arr[parentIndex];
// 左子节点下标
let childrenIndex = parentIndex * 2 + 1;
while(childrenIndex < length) {
// 如果存在右节点,且右节点比左节点大 则定位到右节点
if(childrenIndex + 1 < length && arr[childrenIndex + 1] > arr[childrenIndex]) {
childrenIndex = childrenIndex + 1;
}
// 如果父节点大于等于最大子节点,则直接break
if(temp >= arr[childrenIndex]) {
break
}
// 单向赋值
arr[parentIndex] = arr[childrenIndex];
parentIndex = childrenIndex
childrenIndex = childrenIndex * 2 + 1
}
arr[parentIndex] = temp;
}
/**
* @param {Array<number>} arr 待调整的堆
* @description 堆排序 升序
*/
function heapSort(arr:Array<number>) {
// 1. 把无序数组构建为最大堆
for (let index = arr.length - 2; index >= 0; index--) {
downAdjust(arr, index, arr.length - 1)
}
console.log('最大堆: %j', arr)
// 2. 循环删除堆顶元素,移动到数组尾部,调整堆,产生新的堆顶
for (let index = arr.length - 1; index >= 0; index--) {
// 最后一个元素和 堆顶交换
let temp = arr[0];
arr[0] = arr[index];
arr[index] = temp;
// 下沉调整最大堆
downAdjust(arr, 0, index)
// index 的值 为堆的有效长度 这里很巧妙
}
}
function main() {
let arr = [1, 3, 2, 6, 5, 7, 8, 9, 10, 0];
heapSort(arr);
console.log(arr)
}
归并排序
归并排序思想:把数组拆成两两一组,有序合并,并递归,经典的分治思想
实现代码
function merge(arr:Array<number>, start:number, end:number, middle:number) {
// 新建合并数组,设置指针
let tempArray = new Array(end - start + 1);
let p1 = start;
let p2 = middle + 1;
let p = 0;
while(p1 <= middle && p2 <= end) {
if(arr[p1] <= arr[p2] ) {
tempArray[p++] = arr[p1++]
} else {
tempArray[p++] = arr[p2++]
}
}
// 如果数组中还有剩余 则依次放入大数组中
while(p1 <= middle) {
tempArray[p++] = arr[p1++]
}
while(p2 <= end) {
tempArray[p++] = arr[p2++]
}
// 合并数组放入原数组
for (let index = 0; index < tempArray.length; index++) {
arr[start + index] = tempArray[index]
}
}
function sort(arr:Array<number>, start:number, end:number) {
if(start < end) {
/*
这里找到的middle为 start 和 end 的 "中位数"
即 start = 5; end = 10; 则 middle = 7;
需注意的是 这里采取的是向下舍入即:
5,6,7,8,9,10 共 六个元素,则 middle=7; 5,6,7为一组;8,9,10为一组
6,7,8,9,10 共五个元素,则 middle=8; 6,7,8为一组;9,10为一组
公式为: Math.floor((end-start)/2) + start
转换位运算符公式为:Math.floor(A / Math.pow(2, B)) => Math.floor(A / (2 ** B)) => (A >> B)
所以:Math.floor((end-start)/2) + start === ((end - start)>>1) + start
*/
// 折半分为两个小数组 并递归
let middle = ((end - start)>>1) + start;
sort(arr, start, middle);
sort(arr, middle + 1, end);
// 合并两个小数组
merge(arr, start, end, middle)
}
return arr
}
function main() {
const arr = [5, 8, 6, 3, 9, 2, 1, 7];
console.log(sort(arr, 0, arr.length - 1))
}
快速排序
快速排序思想:快速排序也是分治思想的经典体现,即:选择一个基准元素,大于它的在一边,小于它的在另一边,并递归
实现代码:
双边法:
/**
* @param {Array<number>} arr 待交换的数组
* @param {number} startIndex 起始下标
* @param {number} endIndex 结束下标
* @returns {number} 返回基准元素位置
* @description 分治 双边循环法
*/
function partition(arr: Array<number>, startIndex: number, endIndex: number):number {
// 取第一个位置的元素作为基准元素
const pivot = arr[startIndex];
// left 指针
let left = startIndex;
// right 指针
let right = endIndex;
while(left != right) {
// 控制right指针左移
while(left < right && arr[right] > pivot) {
right--
}
// 控制left指针右移
while(left < right && arr[left] <= pivot) {
left++
}
// 如果指针不重合 交换left right 指针所指元素
if(left < right) {
let p = arr[left];
arr[left] = arr[right];
arr[right] = p;
}
}
// pivot 和重合点交换
arr[startIndex] = arr[left];
arr[left] = pivot;
/*
这里有个问题思索了很久:怎么保证交换的left元素 一定是小于 或者 大于 基准元素
细想下来:如上代码 left 或者 right 其中一个停止,另一个 移动过来重合
如果 left => right 则 right一定为 小于= pivot 的元素
如果 left <= right 则 一定为 小于= pivot的元素
*/
return left
}
function quickSort(arr: Array<number>, startIndex: number, endIndex: number) {
if(startIndex >= endIndex) {
return
}
// 得到基准元素的位置
let pivotIndex = partition(arr, startIndex, endIndex);
// 根据基准元素 分为两部分递归排序
quickSort(arr, startIndex, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, endIndex);
}
function main() {
let arr = [4, 2, 6, 2, 2, 2, 2, 8];
quickSort(arr, 0, arr.length - 1 );
console.log(arr)
}
单边法:
/**
* @param {Array<number>} arr 待交换的数组
* @param {number} startIndex 起始位置
* @param {number} endIndex 结束位置
* @description 分治 单边循环法
*/
function partition(arr: Array<number>, startIndex: number, endIndex: number):number {
// 拿到基准元素
let pivot = arr[startIndex];
// 边界
let mark = startIndex;
for (let index = startIndex; index <= endIndex; index++) {
if(arr[index] < pivot) {
mark++;
let p =arr[mark];
arr[mark] = arr[index];
arr[index] = p
}
}
arr[startIndex] = arr[mark];
arr[mark] = pivot;
return mark
}
function quickSort(arr: Array<number>, startIndex: number, endIndex: number){
if(startIndex >= endIndex) {
return
}
const pivotIndex = partition(arr, startIndex, endIndex);
quickSort(arr, startIndex, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, endIndex)
}
function main() {
let arr = [4, 4, 6, 5, 3, 2, 8, 1];
quickSort(arr, 0, arr.length - 1);
console.log(arr)
}
非递归实现:
/**
* @param {Array<number>} arr 待排序的数组
* @param {number} startIndex 起始位置
* @param {number} endIndex 结束位置
* @returns 基准元素下标
* @description 分治 单边循环法
*/
function partition(arr: Array<number>, startIndex: number, endIndex: number) {
// 基准元素
let pivot = arr[startIndex]
// 边界
let mark = startIndex
for (let index = startIndex; index <= endIndex; index++) {
if(arr[index] < pivot) {
mark++;
let p = arr[mark];
arr[mark] = arr[index];
arr[index] = p
}
};
arr[startIndex] = arr[mark];
arr[mark] = pivot;
return mark
}
interface StackItem {
startIndex: number;
endIndex: number;
}
function quickSort(arr: Array<number>, startIndex: number, endIndex: number) {
// 用一个栈来代替递归的调用栈
let quickSortStack:Array<StackItem> = [];
// 起止下标
let obj:StackItem = {
startIndex,
endIndex
};
quickSortStack.push(obj);
// 当栈为空时结束
while(quickSortStack.length) {
let item = quickSortStack.pop() as StackItem;
let { startIndex, endIndex } = item
let pivotIndex = partition(arr, startIndex, endIndex);
// 如果一边有两个 或者两个以上的元素 则继续入栈
if(startIndex < pivotIndex - 1) {
quickSortStack.push({
startIndex,
endIndex: pivotIndex - 1
})
}
if(endIndex > pivotIndex + 1) {
quickSortStack.push({
startIndex : pivotIndex + 1,
endIndex: endIndex
})
}
}
}
function main() {
let arr = [4, 4, 6, 5, 3, 2, 8, 1];
quickSort(arr, 0, arr.length - 1);
console.log(arr)
}
希尔排序
希尔排序是插入排序的升级版,对原数组进行一些"预处理",使原数组大部分元素变得有序
实现代码
function sort(arr: Array<number>) {
let d = arr.length;
while(d > 1) {
d = Math.floor(d / 2);
for (let x = 0; x < d; x++) {
for (let index = x + d; index < arr.length; index+=d) {
let temp = arr[index];
let j = index - d;
for (; j >= 0 && arr[j] > temp; j-= d) {
arr[index] = arr[j];
}
arr[j+ d] = temp;
}
}
}
return arr
}
小结
第二梯队的性能要比第三梯队高出一个量级
其中希尔排序平均时间复杂度最快可达到O(n1.3),快速排序、归并排序、堆排序平均时间复杂度在O(nlogn)
后三者的差异:
1. 快速排序虽然平均时间复杂度在O(nlogn),但是在极端坏的情况下为O(n2),但是堆排序和归并排序稳定在O(nlogn)
2. 平均复杂度而言,堆排序性能要略低于快速排序和归并排序,主要原因为:二叉堆的父子节点在内存中并不连续
在访问内存数据时,顺序储存的数据,读写效率往往是最高的,根据 CPU 的空间局限性原理,CPU在每次访问内存时,会把内存中相邻的数据也一并存入缓存,下次再读取相邻数据时,就不用从内存中读取了,而是直接从 CPU 缓存中读取
3. 归并排序是稳定排序,堆排序和快速排序都是不稳定排序
4. 快速排序和堆排序都不需要开辟额外的储存空间,而归并排序则需要建立 merge 数组
第一梯队
计数排序
计数排序的思想:基于元素下标来确定元素位置,不依靠元素的比较和交换
实现代码
朴素版
function countSort(arr: Array<number>):Array<number> {
// 1. 拿到数列的最大值
let max = arr[0];
for (let index = 1; index < arr.length; index++) {
if(arr[index] > max) {
max = arr[index]
}
}
// 2. 根据最大值确定统计数组长度
let countArray = (new Array(max + 1)).fill(0);
// 3. 遍历无序数组 填充统计数组
for (let index = 0; index < arr.length; index++) {
countArray[arr[index]]++
}
// 遍历统计数组输出结果
let sortedArray = new Array();
for (let index = 0; index <= countArray.length; index++) {
for (let j = 0; j < countArray[index]; j++) {
sortedArray.push(index)
}
}
return sortedArray
}
优化版
function countSort(arr: Array<number>) {
// 1. 拿到最大值和最小值
let max = arr[0];
let min = arr[0];
for (let index = 1; index < arr.length; index++) {
if(arr[index] > max) {
max = arr[index]
}
if(arr[index] < min) {
min = arr[index]
}
}
// 2. 根据最大值和最小值确定数组长度
let length = max - min + 1;
let countArray = (new Array(length)).fill(0)
// 3. 统计对应元素个数
for (let index = 0; index < arr.length; index++) {
countArray[arr[index] - min]++
}
// 4. 遍历统计数组 输出结果
let sortedArray = []
for (let index = 0; index < countArray.length; index++) {
for (let j = 0; j < countArray[index]; j++) {
sortedArray.push(index + min)
}
}
return sortedArray
}
稳定版
function countSort(arr: Array<number>) {
// 1. 拿到最大值和最小值
let max = arr[0];
let min = arr[0];
for (let index = 1; index < arr.length; index++) {
if(arr[index] > max) {
max = arr[index]
}
if(arr[index] < min) {
min = arr[index]
}
}
// 2. 根据最大值和最小值确定数组长度
let length = max - min + 1;
let countArray = (new Array(length)).fill(0)
// 3. 统计对应元素个数
for (let index = 0; index < arr.length; index++) {
countArray[arr[index] - min]++
}
// 4. 统计数组做变形 后边元素值为前边元素值之和
for (let index = 1; index < countArray.length; index++) {
countArray[index] += countArray[index - 1]
}
// 5. 倒序遍历原数组,从统计数组中找到位置,填入结果数组
let sortedArray = new Array(arr.length);
for (let index = arr.length - 1; index >= 0; index--) {
// -1 是因为 元素填入数组是 数组下标从 0 开始
sortedArray[countArray[arr[index] - min] - 1] = arr[index]
countArray[arr[index] - min]--
}
return sortedArray
}
桶排序
桶排序是计数排序的升级版,弥补了计数排序的局限性(计数排序不适用于非整数排序)
实现代码
function bucketSort(arr:Array<number>) {
// 1. 找出最大值和最小值 并得出差值 d
let max = arr[0];
let min = arr[0];
for (let index = 0; index < arr.length; index++) {
if(arr[index] > max) {
max = arr[index]
}
if(arr[index] < min) {
min = arr[index]
}
}
const d = max - min;
// 2. 初始化桶
const bucketNum = arr.length;
/*
小坑:这样的写法会导致每个元素数组为同一个地址
let bucketList = new Array(bucketNum).fill([])
*/
let bucketList = new Array(bucketNum)
for (let index = 0; index < bucketList.length; index++) {
bucketList[index] = []
}
// 3. 遍历原始数组,将每个元素放入桶中
let area = d/(bucketNum - 1);
for (let index = 0; index < arr.length; index++) {
let num = Math.floor((arr[index] - min)/area);
bucketList[num].push(arr[index])
}
/*
具体建立多少桶,和如何确定桶的区间范围有很多中,
这里实现的很巧妙:
1. math.floor:
(元素-min)/area = 排在第几个桶,理论上来讲应该为 向上取整,但是数组下标从 0 所以为 math.floor
2. bucketNum - 1:
由于 (最大值 - min)/area 一定为最后一个桶 且为整数,即:
d = max - min
area = d/num
result = (max - min)/area = (max - min)/d * num = (max - min)/(max - min) * num
result === num
但是由于数组下标为 0 开始 最后一个桶为 num - 1
所以在计算 area时 为 d/(num - 1)
*/
// 4. 每个桶内进行排序
for (let index = 0; index < bucketList.length; index++) {
bucketList[index] = bucketList[index].sort((a:number, b:number) => (a - b))
}
// 拉平数组 输出
return bucketList.flat()
}
基数排序
基数排序也是解决计数排序的局限性问题,例如给英文单词排序,思想为:把工作拆分成为多轮进行,每一轮对单个字符使用计数排序
实现代码
// ascii 码取值范围
let ASCII = 128;
function getCharIndex(str:string,index:number):number {
if(str.length <= index) {
return 0
}
return str.charCodeAt(index)
}
function radixSort(arr:Array<string>, maxLength:number) {
let sortedArray = new Array(arr.length);
for (let k = maxLength - 1; k >= 0; k--) {
// 1. 创建统计数组,这里为了简洁,直接以ascii 码范围作为数组长度
let countArray = new Array(ASCII).fill(0);
for (let index = 0; index < arr.length; index++) {
let i = getCharIndex(arr[index], k)
countArray[i]++
}
// 2. 统计数组变形,后边元素等于前边元素之和
for (let index = 1; index < countArray.length; index++) {
countArray[index] += countArray[index-1]
}
// 3. 倒序遍历原始数列,从统计数组找到正确位置,输出到结果数组
for (let index = arr.length - 1; index >= 0; index--) {
let i = getCharIndex(arr[index], k);
let sortedIndex = countArray[i] - 1;
sortedArray[sortedIndex] = arr[index];
countArray[i]--
}
arr=[...sortedArray]
}
return arr
}
function main() {
const arr = ['qd', 'abc', 'qwe', 'hhh', 'a', 'cws', 'ope'];
console.log(radixSort(arr, 3))
}
小结
第一梯队的排序算法都为线性复杂度的排序算法
计数排序算法时间复杂度为 O(n + m) 其中 m 为原始数组的范围
桶排序算法时间复杂度为 O(n) n为桶的数量
基数排序算法的时间复杂度为 O(k(n + m)) k 为元素最大位数,m为每一位的取值范围
此外,这三种排序算法都为稳定排序
大总结
摘要总结自: 漫画算法 小灰的算法之旅 + 程序员小灰公主号文章