排序总结
下面的排序算法中会多次用到交换函数,这里先给出其定义:
// 交换数组中 i 和 j 位置上的值
var swap = function(arr, i, j){
// 注意:调用此函数时,需要保证 i≠j,否则会出错
arr[i] = arr[i] ^ arr[j]
arr[j] = arr[i] ^ arr[j]
arr[i] = arr[i] ^ arr[j]
}
选择排序
基本思想:每一趟,从待排序序列中选择最小的元素,放到已排序序列的末尾。一共需要 n-1 趟。
var selectSort = function (arr) {
const n = arr.length
for (let i = 0; i < n - 1; i++){
let minIndex = i // 记录最小元素的索引
// 从 arr[i+1...n-1] 中找到最小元素的索引并更新
for (let j = i + 1; j < n; j++){
if (arr[j] < arr[minIndex]) {
minIndex = j
}
}
// 交换数组中 minIndex 和 i 位置上的值
if (minIndex !== i) {
swap(arr, minIndex, i)
}
}
}
冒泡排序
基本思想:每一趟冒泡,从后往前(或从前往后)两两比较相邻元素的值,若为逆序,则交换它们。一趟冒泡结束后,会将最小的元素交换到待排序序列的第一个位置(或将最大的元素交换到待排序序列的最后一个位置)。一共需要 n-1 趟。
var bubbleSort = function (arr) {
const n = arr.length
for (let i = 0; i < n - 1; i++){
let flag = false // 记录本趟冒泡是否发生交换
for (let j = n - 1; j >= i + 1; j--){
if (arr[j] < arr[j-1]) {
flag = true
swap(arr, j, j - 1)
}
}
// 本趟冒泡没有发生交换,说明数组已经有序,排序结束
if(!flag) return
}
}
插入排序
基本思想:每次将一个待排序的元素插入前面已排好序的子序列,直到全部元素插入完成。
var insertSort = function (arr) {
const n = arr.length
// 外层循环的每一次执行,需要将 arr[0...i] 中的元素变成有序的
for (let i = 1; i < n; i++){
for (let j = i; j > 0 && arr[j] < arr[j-1]; j--){ // 如果当前元素不小于前一个元素,说明 arr[0...i] 已经有序,直接进入下一次外层循环
swap(arr, j, j - 1)
}
}
}
归并排序(2路)
基本思想:整体就是一个简单递归。将原数组拆分成左右两个子数组,将这两个子数组排好序,然后合并成一个更大的有序数组。
// 对 arr 中的两个有序子数组 arr[l...mid] 和 arr[mid+1...r] 进行排序
var merge = function(arr, l, mid, r){
const ans = []
let i = l, j = mid + 1
while(i <= mid && j <= r){
ans.push(arr[i] <= arr[j] ? arr[i++] : arr[j++])
}
while(i <= mid){
ans.push(arr[i++])
}
while(j <= r){
ans.push(arr[j++])
}
for(let k = l; k <= r; k++){
arr[k] = ans.shift()
}
}
// 对 arr[l...r] 进行归并排序
var process = function(arr, l, r){
if(l === r) return
const mid = l + ((r - l) >> 1) // 找到中间位置
process(arr, l, mid) // 对左边进行排序
process(arr, mid + 1, r) // 对右边进行排序
merge(arr, l, mid, r) // 将左右两个有序的数组合并成一个更大的有序数组
}
var mergeSort = function(arr){
if(arr.length === 0){
return
}
process(arr, 0, arr.length - 1)
}
归并排序的应用
数组中的逆序对
题:在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
解:设数组当前元素为 x, 其左边有 y 个数比它大,那么对于 x,就产生 y 个逆序对。统计数组中所有元素的逆序对个数,加起来就是整个数组的逆序对总数。可以利用归并排序的合并阶段计算出数组中每个元素的逆序对个数(即左边有多少个元素比当前元素大)。设在合并阶段,左子数组当前元素为 a,右子数组当前元素为 b。那么:
- 当
a <= b
时,将 a 添加到结果数组,左子数组当前元素后移,不产生逆序对 - 当
a > b
时,将 b 添加到结果数组,右子数组当前元素后移,对于 b 来说,a 至左子数组末尾元素都比它大,产生若干逆序对
注意:当 a === b 时,应该将 a 添加到结果数组,这样,才能完整的统计右子数组中每个元素的左边有多少个元素比它大
举个例子:统计数组[7, 3, 2, 6, 0, 1, 5, 4]
的逆序对总数,其归并排序的过程如图:
对于元素 4 来说,统计其逆序对个数的过程为:①合并[5]
和[4]
时产生 1 个逆序对②合并[0, 1]
和[4, 5]
时不产生逆序对③合并[2, 3, 6, 7]
和[0, 1, 4, 5]
时产生 2 个逆序对。共计 3 个逆序对,即元素 4 的左边有 3 个元素比它大。统计其它元素的逆序对过程以此类推......
备注:
- 当前算法会将原数组按升序排列
- 对于数组中每个元素,计算其逆序对的过程都是分批的、不重复的、也不遗漏的
let ans // 逆序对的个数
// 合并两个有序子数组 arr[l...mid]、arr[mid+1...r],并统计逆序对的个数
var mergeAndCount = function(arr, l, mid, r){
const tempArr = []
let i = l, j = mid + 1
while(i <= mid && j <= r){
if(arr[i] <= arr[j]){
// 不产生逆序对
tempArr.push(arr[i++])
}else{
// arr[i...mid] 与 arr[j] 构成共 mid-i+1 逆序对
ans += (mid - i + 1)
tempArr.push(arr[j++])
}
}
while(i <= mid){
// 左子数组中有剩余元素,但是由于对逆序对的统计是针对于右子数组中的元素的,所以这里不产生逆序对
tempArr.push(arr[i++])
}
while(j <= r){
// 右子数组中有剩余元素,但这些剩余元素都大于左子数组中的所有元素,所以也不产生逆序对
tempArr.push(arr[j++])
}
// 将临时数组中的元素拷贝到原数组中
for(let k = l; k <= r; k++){
arr[k] = tempArr.shift()
}
}
// 对 arr[l...r] 进行归并排序
var mergeSort = function(arr, l, r){
if(l === r) return
const mid = l + ((r - l) >> 1) // 找到中间位置
mergeSort(arr, l, mid) // 对左边进行排序
mergeSort(arr, mid + 1, r) // 对右边进行排序
mergeAndCount(arr, l, mid, r) // 合并左右两个有序子数组,并统计逆序对的个数
}
var reversePairs = function(nums){
ans = 0
if(nums.length === 0){
return 0
}
mergeSort(nums, 0, nums.length - 1)
return ans
}
快速排序
基本思想:在待排序数组arr[l...r]
中任取一个元素作为枢轴,记为 pivot 。通过一趟排序(或一次划分)将数组分割成独立的三部分:arr[l...m]
、arr[m+1...n-1]
和arr[n...r]
。使得arr[l...m]
中的所有元素小于 pivot,arr[m+1...n-1]
中的所有元素等于 pivot,arr[n...r]
中的所有元素大于 pivot。然后分别递归的对arr[l...m]
和arr[n...r]
重复上述过程。
划分的步骤为:定义一个小于枢轴元素的区域右边界 lArea 和 一个大于枢轴元素的区域左边界 rArea,遍历arr[l...r]
:
- 如果当前元素小于枢轴元素,则交换当前元素和 lArea 的下一个元素,并将 lArea 和当前元素右移
- 如果当前元素大于枢轴元素,则交换当前元素和 rArea 的上一个元素,并将 rArea 左移
- 如果当前元素等于枢轴元素,则当前元素右移
// 对 arr[l...r] 进行一次划分,并返回小于枢轴的区域右边界和大于枢轴的区域左边界
var partition = function(arr, l, r, pivot){
let lArea = l - 1 // 小于 pivot 的区域右边界
let rArea = r + 1 // 大于 pivot 的区域左边界
while(l < rArea){
if(arr[l] > pivot){
l !== rArea - 1 && swap(arr, l, rArea - 1)
rArea--
}else if(arr[l] < pivot){
lArea + 1 !== l && swap(arr, lArea + 1, l)
lArea++
l++
}else{
l++
}
}
return [lArea, rArea]
}
// 对 arr[l...r] 进行快速排序
var process = function(arr, l, r){
if(l >= r) return
const pivot = arr[Math.round(Math.random() * (r - l) + l)] // 任取一个元素作为枢轴
const areas = partition(arr, l, r, pivot)
process(arr, l, areas[0])
process(arr, areas[1], r)
}
var quickSort = function(arr){
if(arr.length === 0){
return
}
process(arr, 0, arr.length - 1)
}
快速排序的应用
颜色分类
题:给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。必须在不使用库内置的 sort 函数的情况下解决这个问题。进阶:你能想出一个仅使用常数空间的一趟扫描算法吗?
解:很明显,我们只需要将枢轴元素设为 1,然后进行一次快速排序中的『划分』,即可满足题目要求。下面是代码实现:
var sortColors = function(nums) {
const pivot = 1
let lArea = -1, rArea = nums.length
let i = 0
while(i < rArea){
if(nums[i] > pivot){
i !== rArea - 1 && swap(nums, i, rArea - 1)
rArea--
}else if(nums[i] < pivot){
i !== lArea + 1 && swap(nums, i, lArea + 1)
lArea++
i++
}else{
i++
}
}
};
堆排序
基本思想:将arr[0...n-1]
建成初始大根堆,由于大根堆本身的特点,堆顶元素就是最大值,所以将堆顶元素与堆底元素交换。此时已不满足大根堆的性质,需要将arr[0...n-2]
重新从堆顶元素向下调整成一个新的大根堆,然后将堆顶元素与堆底元素交换。继续将arr[0...n-3]
调整成一个新的大根堆,交换堆顶元素与堆底元素。...... 重复上述过程,即可完成排序。
// 建立大根堆
var buildMaxHeap = function(arr){
// 找到最后一个分支节点(就是末尾节点的父节点)
const branch = Math.floor((arr.length - 2) / 2)
// 对 branch 及其之前的节点进行调整(branch 之后的节点都是叶子节点,已经是一个大根堆,无需调整)
for(let i = branch; i >= 0; i--){
heapAdjust(arr, i, arr.length)
}
}
// 将以任意元素为根的子树调整成一个大根堆
var heapAdjust = function(arr, index, heapSize){
// 找到左孩子节点
let left = index * 2 + 1
// 只要还存在孩子节点,就需要判断是否进行调整
while(left < heapSize){
// 找到左右孩子节点中较大的那个,下标赋给 largest
let largest = ((left + 1) < heapSize && arr[left+1] > arr[left]) ? left + 1 : left
// 父节点和较大的孩子节点之间,谁的值大,就把谁的下标赋给 largest
largest = arr[index] < arr[largest] ? largest : index
if(largest === index){
// 孩子节点的值均不大于父节点的值,无需调整
break
}else{
// 存在孩子节点的值大于父节点的值,需要调整
swap(arr, index, largest)
// 更新父节点的下标和左孩子节点的下标
index = largest
left = index * 2 + 1
}
}
}
var heapSort = function(arr){
let n = arr.length
buildMaxHeap(arr) // 建立大根堆
for(let i = n; i > 1; i--){
heapAdjust(arr, 0, n) // 将剩余元素调整成一个新的大根堆
swap(arr, 0, --n) // 交换堆顶元素和堆底元素
}
}
堆排序的应用
给几乎有序的数组排序
题:已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离可以不超出k,并且k相对于数组来说比较小。请选择一个合适的排序算法针对这个数组进行排序。
解:由于数组当前元素的位置相对于排好序之后的位置距离不超过 k,所以可以肯定的是arr[0...k]
的最小值一定是arr[0]
,因为arr[k+1]
及之后的元素到arr[0]
的距离都大于 k。以此类推:可以知道,arr[1...k+1]
的最小值一定是arr[1]
,arr[2...k+2]
的最小值一定是arr[2]
......
算法的具体步骤如下:
- 将
arr[0...k]
添加到小根堆,然后弹出堆顶元素,作为arr[0]
的值 - 下标从 k+1 开始,遍历原数组,每次都将当前元素添加到小根堆,然后弹出堆顶元素添加到原数组中
- 遍历完成后,将小根堆中的剩余元素全部依次弹出,添加到原数组中
// 创建一个小根堆类
class MinHeap{
constructor(){
this.heap = []
}
// 向堆中添加一个元素
add(x){
this.heap.push(x)
let index = this.heap.length - 1 // 当前节点的下标
let parentIndex = Math.trunc((index - 1) / 2) // 父节点的下标
while(parentIndex >= 0 && this.heap[parentIndex] > this.heap[index]){ // 存在父节点且父节点的值大于当前节点的值,就需要进行调整
// 交换当前节点和父节点的值
const temp = this.heap[index]
this.heap[index] = this.heap[parentIndex]
this.heap[parentIndex] = temp
// 更新当前节点和父节点的下标
index = parentIndex
parentIndex = Math.trunc((index - 1) / 2)
}
}
// 弹出堆顶元素
poll(){
const res = this.heap.shift()
// 交换堆顶元素与堆底元素
this.heap.length !== 0 && this.heap.unshift(this.heap.pop())
let index = 0 // 从堆顶元素向下进行堆调整
let left = index * 2 + 1 // 左孩子的下标
// 只要还存在孩子节点,就需要判断是否进行调整
while(left < this.heap.length){
// 找到左右孩子节点中较小的那个,下标赋给 smallest
let smallest = (left + 1 < this.heap.length && this.heap[left+1] < this.heap[left]) ? left + 1 : left
// 父节点和较小的孩子节点之间,谁的值小,就把谁的下标赋给 smallest
smallest = this.heap[smallest] < this.heap[index] ? smallest : index
if(smallest === index){
// 孩子节点的值均不小于父节点的值,无需调整
break
}else{
// 存在孩子节点的值小于父节点的值,需要调整
const temp = this.heap[index]
this.heap[index] = this.heap[smallest]
this.heap[smallest] = temp
index = smallest
left = index * 2 + 1
}
}
return res
}
// 判断堆是否为空
isEmpty(){
return this.heap.length === 0
}
}
var kSort = function(arr, k){
const heap = new MinHeap()
// 将 arr[o...k] 添加到小根堆中
let index = 0
while(index <= k){
heap.add(arr[index++])
}
// 弹出堆顶元素 x(x是 arr[0...k] 的最小值),由于每个元素当前位置相对于排好序之后的位置,距离不超过 k,所以 x 一定是 arr[0]
arr[0] = heap.poll()
// arr[1...k+1] 的最小值一定是 arr[1],arr[2,k+2] 的最小值一定是 arr[2],以此类推...
while(index < arr.length){
heap.add(arr[index])
arr[index-k] = heap.poll()
index++
}
// 将小根堆中的剩余元素全部依次弹出,然后添加到原数组中
while(!heap.isEmpty()){
arr[index-k] = heap.poll()
index++
}
}
基数排序(桶排序)
基本思想:基数排序是一种很特别的排序方法,它不基于比较和移动进行排序,而基于元素各位的大小进行排序。其思想是将待排序元素按照位数切割成个、十、百、千等位,然后从最低位开始依次排序,直到最高位排序完成。每一次排序就是一次『分配和收集』。分配的过程是:取出当前位数的值并将元素放入该值所对应的桶中。收集的过程是:取出每个桶中的元素并按照先后顺序放回原数组中。
举个例子:对数组[3, 1, 18, 11, 28, 45, 23, 50, 30]
进行基数排序的过程如图:
// 获取数组中最大元素的十进制位数
var maxBits = function(arr){
let ans = 0
let max = arr[0]
for(let i = 1; i < arr.length; i++){
if(arr[i] > max) max = arr[i]
}
while(max !== 0){
ans++
max = Math.floor(max / 10)
}
return ans
}
// 获取 x 第 d 位的数字(从右往左依次是第一位、第二位...)
var getDigit = function(x, d){
return Math.floor(x / Math.pow(10, d - 1)) % 10
}
// 对 arr[l...r] 进行基数排序
var process = function(arr, l, r, digit){
// 基数排序的过程需要进行 digit 次的『分配和收集』
for(let d = 1; d <= digit; d++){
// 准备一个长度为 10 的数组,其中 counts[i] 表示数组中某位小于等于 i 的元素有多少个(d=1 表示个位,d=2 表示十位,以此类推)
const counts = new Array(10).fill(0)
// 初始化 counts 数组
for(let i = l; i <= r; i++){
const j = getDigit(arr[i], d)
counts[j]++
}
for(let i = 1; i < 10; i++){
counts[i] += counts[i-1]
}
// 准备一个长度等于原数组的临时数组,用于放置一次『分配和收集』之后的结果
const temp = new Array(r - l + 1)
// 对原数组从后往前遍历,根据 counts 数组中的值,将元素放到合适的位置(此步骤结束后,就相当于对原数组进行了一次『分配和收集』)
for(let i = r; i >= l; i--){
const j = getDigit(arr[i], d)
temp[counts[j] - 1] = arr[i]
counts[j]--
}
// 将临时数组中的值拷贝到原数组中,一次『分配和收集』结束
for(let i = l, j = 0; i <= r; i++, j++){
arr[i] = temp[j]
}
}
}
var radixSort = function(arr){
if(arr.length === 0) return
process(arr, 0, arr.length - 1, maxBits(arr))
}