文章目录
- 912.排序数组
- 题目:
- [1. 冒泡排序](#1. 冒泡排序)
- [2. 简单选择排序](#2. 简单选择排序)
- [3. 插入排序](#3. 插入排序)
- [4. 快速排序](#4. 快速排序)
-
- [时间复杂度 O(n log n)](#时间复杂度 O(n log n))
- [空间复杂度 O(log n)](#空间复杂度 O(log n))
- 稳定性:不稳定
- [5. 归并排序](#5. 归并排序)
-
- [时间复杂度 O(n log n)](#时间复杂度 O(n log n))
- [空间复杂度 O(n)](#空间复杂度 O(n))
- 稳定性:稳定
- [6. 堆排序](#6. 堆排序)
912.排序数组
题目:
给你一个整数数组 nums,请你将该数组升序排列。
你必须在 不使用任何内置函数 的情况下解决问题,时间复杂度为 O(nlog(n)),并且空间复杂度尽可能小。
示例 1 :
输入 :nums = [5,2,3,1]
输出 :[1,2,3,5]
解释:数组排序后,某些数字的位置没有改变(例如,2 和 3),而其他数字的位置发生了改变(例如,1 和 5)。
示例 2 :
输入 :nums = [5,1,1,2,0,0]
输出 :[0,0,1,1,2,5]
解释:请注意,nums 的值不一定唯一。
提示 :
1 <= nums.length <= 5 * 10^4
-5 * 10^4 <= nums[i] <= 5 * 10^4
优先使用快速排序(随机 pivot)
- 平均时间复杂度 O(n log n)
- 空间复杂度 O(log n)
- 原地排序,实际性能最好
需要稳定排序 → 归并排序
需要时间复杂度严格稳定(无最坏情况)→ 堆排序
插冒归 ------ 稳定
快堆选 ------ 不稳定
1. 冒泡排序
- 从头开始,两两相邻比较
- 前面比后面大,就交换(大的往后挪)
- 每一轮走完,最大的数会像气泡一样"冒"到最后面
- 重复这个过程,直到整个数组有序
荐(2)
go
package main
import "fmt"
// 冒泡排序:对数组进行升序排序
func bubbleSort(nums []int) []int {
n := len(nums)
// 外层循环:控制排序的总轮数;n 个数字,只需要排 n-1 轮就够了
for i := 0; i < n-1; i++ {
// 内层循环:每一轮两两比较,把大的元素往后移;每一轮结束,最后面 i 个数字已经排好序了,不用再比
for j := 0; j < n-i-1; j++ {
// 升序:前一个元素 > 后一个元素 则交换
// 降序:前一个元素 < 后一个元素 则交换
if nums[j] > nums[j+1] {
// 交换两个元素的位置
nums[j], nums[j+1] = nums[j+1], nums[j]
}
}
}
// 返回排序完成的数组
return nums
}
func main() {
arr := []int{4, 2, 5, 1, 3}
fmt.Println(bubbleSort(arr)) // 输出:[1 2 3 4 5]
}
时间复杂度:O(n²)
- 两层循环
- 最好、最坏、平均都是 O(n²)
空间复杂度:O(1)
- 没有开辟新数组/切片
- 只使用了临时变量交换
- 原地排序
稳定性:稳定
稳定性 = 相等的元素,在排序后,前后顺序保持不变
冒泡排序可以通过增加一个标志位 判断某一轮是否发生交换 ,如果没有交换说明数组已经有序,可以提前结束 。这个优化不会改变最坏时间复杂度,仍然是 O(n²),但可以将最好情况优化到 O(n),空间复杂度仍然是 O(1)。
go
package main
import "fmt"
// bubbleSort 冒泡排序 优化版本
// 增加了交换标记,数组提前有序时可以直接退出,提升效率
func bubbleSort(nums []int) []int {
n := len(nums)
// 外层循环:控制排序轮数,最多执行 n-1 轮
for i := 0; i < n-1; i++ {
swapped := false // 标记本轮是否发生过交换
// 内层循环:两两比较,将大元素向后交换
// 每轮结束后,最后 i 个元素已确定位置,无需再比较
for j := 0; j < n-i-1; j++ {
// 升序排列:前一个 > 后一个 则交换
if nums[j] > nums[j+1] {
nums[j], nums[j+1] = nums[j+1], nums[j]
swapped = true // 发生了交换,标记为 true
}
}
// 如果本轮一次交换都没发生 → 数组已经完全有序
// 直接跳出循环,不用继续排序
if !swapped {
break
}
}
return nums
}
func main() {
arr := []int{4, 2, 5, 1, 3}
fmt.Println(bubbleSort(arr)) // 输出:[1 2 3 4 5]
}
2. 简单选择排序
- 一开始,整个数组都是未排序的
- 从第一个位置开始,认为当前位置是要放"最小值"的位置
- 在当前位置后面的所有元素中,找到最小值
- 把这个最小值交换到当前位置
- 然后移动到下一个位置 ,重复这个过程
- 直到所有位置都处理完,数组就有序了
go
package main
import "fmt"
// 选择排序:对数组进行升序排序
func selectionSort(nums []int) []int {
n := len(nums)
// 外层循环:控制每一轮要放"最小值"的位置
// 外层循环:i 走到 n-1 就够了,最后一个元素自动有序
for i := 0; i < n - 1; i++ {
// 先假设当前位置是最小值下标
minIndex := i
// 内层循环:在未排序区找到真正的最小值
for j := i + 1; j < n; j++ {
// 升序:找到更小的值就更新最小值下标
// 降序:找到更大的值就更新最大值下标
if nums[j] < nums[minIndex] {
minIndex = j
}
}
// 找到最小值后,与当前位置交换
if minIndex != i { // 小优化:避免自己和自己交换
nums[i], nums[minIndex] = nums[minIndex], nums[i]
}
}
// 返回排序完成的数组
return nums
}
func main() {
arr := []int{4, 2, 5, 1, 3}
fmt.Println(selectionSort(arr)) // 输出:[1 2 3 4 5]
}
时间复杂度:O(n²)
- 两层循环
- 最好、最坏、平均都是 O(n²)
空间复杂度:O(1)
- 没有开辟新数组/切片
- 只使用了临时变量交换
- 原地排序
稳定性:不稳定
3. 插入排序
- 把数组分成左边有序区 、右边无序区
- 最开始第一个元素自己就是有序区,剩下的都是无序区
- 每次从无序区拿第一个元素,当成要插入的数
- 把这个数从后往前和有序区的元素比较
- 如果有序区的元素更大(升序),就把它往后挪一位
- 直到找到比它小的元素 ,或走到有序区开头
- 通过元素后移腾出位置,把要插入的数放到空出来的位置
- 重复直到无序区为空,数组就有序了
go
package main
import "fmt"
// 插入排序:对数组进行升序排序
func insertionSort(nums []int) []int {
n := len(nums)
// 外层循环:从第二个元素开始(第一个默认有序)
for i := 1; i < n; i++ {
// 保存当前要插入的元素
cur := nums[i]
// 从 i 的前一个位置开始往前比较
j := i - 1
// 内层循环:往前遍历有序区,比 cur 大的元素往后挪
// 升序:nums[j] > cur
// 降序:nums[j] < cur
for j >= 0 && nums[j] > cur {
nums[j+1] = nums[j] // 元素后移
j-- // 继续往前比较
}
// 循环结束,j+1 就是插入的正确位置
nums[j+1] = cur
}
return nums
}
func main() {
arr := []int{4, 2, 5, 1, 3}
fmt.Println(insertionSort(arr)) // 输出:[1 2 3 4 5]
}
时间复杂度:O(n²)
- 两层循环
- 最坏/平均:O(n²)
- 最好(数组已经有序):O(n)
空间复杂度:O(1)
- 原地排序
- 只使用了临时变量
- 没有开辟新数组
稳定性:稳定
4. 快速排序
- 选定一个基准值 pivot(固定选区间第一个元素)
- 使用左右双指针在当前区间内遍历
- 右指针向左找:找到小于基准的元素停下
- 左指针向右找:找到大于基准的元素停下
- 交换左右指针指向的元素,让小的靠左、大的靠右
- 双指针相遇时,将基准值交换到指针位置(基准永久就位)
- 递归处理基准左侧区间 和右侧区间
- 递归终止:区间长度 ≤1 时天然有序
- 随机基准:随机选一个元素当 pivot,避免有序数组退化
go
package main
import (
"fmt
// "math/rand" // 随机基准需要打开:生成随机数
// "time" // 随机基准需要打开:设置随机种子
)
// 格式:func quickSort(nums []int) []int
// 原地排序 + 双指针交换法 + 固定基准(默认)
func quickSort(nums []int) []int {
// ==================== 关键 Go 语法讲解 ====================
// 第一步:先声明函数变量 sort
// 作用:告诉编译器"有一个叫 sort 的函数",为递归做准备
// 因为 Go 中函数不能直接在定义时调用自己,必须先声明再赋值
// ==========================================================
var sort func(arr []int, left int, right int)
// ==================== 关键 Go 语法讲解 ====================
// 第二步:给 sort 变量赋值,真正实现函数逻辑
// 此时函数已经声明过了,内部可以安全递归调用 sort
// ==========================================================
sort = func(arr []int, left int, right int) {
// 递归终止条件:区间长度 <= 1,天然有序,直接返回
if left >= right {
return
}
// ===================== 基准选择 =====================
// 版本1:固定基准
pivot := arr[left]
// 版本2:随机基准(加分项,避免有序数组退化)
// 打开注释即可使用,记得同时打开上方 import 包
/*
rand.Seed(time.Now().UnixNano()) // 初始化随机种子
randomIdx := left + rand.Intn(right-left+1) // 在 [left, right] 随机选下标
arr[left], arr[randomIdx] = arr[randomIdx], arr[left] // 交换到最左边
pivot = arr[left] // 更新基准
*/
// ====================================================
// 初始化左右双指针
i, j := left, right
// 双指针分区核心逻辑
for i < j {
// 右指针向左找:第一个 小于 pivot 的元素
for i < j && arr[j] >= pivot {
j--
}
// 左指针向右找:第一个 大于 pivot 的元素
for i < j && arr[i] <= pivot {
i++
}
// 指针未相遇 → 交换
if i < j {
arr[i], arr[j] = arr[j], arr[i]
}
}
// 指针相遇,将基准放到最终正确位置
// 循环结束:i 和 j 已经相遇(i == j)
// 相遇位置 i 就是基准 pivot 的最终正确位置
// 因为基准取自 arr[left],所以将基准与相遇位置交换
arr[left], arr[i] = arr[i], arr[left]
// 递归排序左区间
sort(arr, left, i-1)
// 递归排序右区间
sort(arr, i+1, right)
}
// 启动递归,对整个数组排序
sort(nums, 0, len(nums)-1)
// 返回排序后的数组
return nums
}
func main() {
// 测试用例 1
nums1 := []int{5, 2, 3, 1}
fmt.Println(quickSort(nums1)) // 输出: [1 2 3 5]
// 测试用例 2
nums2 := []int{5, 1, 1, 2, 0, 0}
fmt.Println(quickSort(nums2)) // 输出: [0 0 1 1 2 5]
}
时间复杂度 O(n log n)
固定基准
- 平均:O(n log n)
每次划分均匀,效率最高 - 最坏:O(n²)
数组完全有序/逆序,划分极度不平衡 - 最好:O(n log n)
每次划分完美均匀
随机基准
- 平均:O(n log n)
- 最坏:O(n log n)
最坏时间复杂度仍然是 O(n²),但通过随机选择 pivot,使这种情况出现的概率极低,因此期望时间复杂度为 O(n log n) 【降低"出现最坏情况的概率"】 - 最好:O(n log n)
空间复杂度 O(log n)
- 平均:O(log n)
递归调用栈深度,平衡划分 - 最坏:O(n)
划分极度不平衡,递归退化为链表 - 原地排序,无额外数组空间开销
稳定性:不稳定
5. 归并排序
- 采用分治法,把数组从中间分成左右两半
- 递归地把左右两半分别排好序
- 把两个已经有序的子数组合并成一个大的有序数组
- 重复直到整个数组有序
- 归并排序永远是 O(n log n),不会退化
- 缺点是需要额外辅助空间
go
package main
import "fmt"
// mergeSort 归并排序
func mergeSort(nums []int) []int {
// 递归终止条件
// 数组长度 <= 1,天然有序,直接返回
if len(nums) <= 1 {
return nums
}
// 1. 拆分:从中间分成左右两半
mid := len(nums) / 2 // 找到中间位置
left := mergeSort(nums[:mid]) // 递归处理左半边
right := mergeSort(nums[mid:]) // 递归处理右半边
// 2. 合并:将两个有序的数组合并成一个有序数组
return merge(left, right)
}
// merge 合并两个【已经有序】的数组 → 返回新的有序数组
func merge(left, right []int) []int {
// 创建一个空的结果切片
// 长度 0,容量预先分配为 左+右 总长度(避免自动扩容,更高效)
result := make([]int, 0, len(left)+len(right))
// 双指针:i 遍历 left,j 遍历 right
i, j := 0, 0
// 3. 双指针同时遍历,谁小就先把谁放进结果
for i < len(left) && j < len(right) {
if left[i] <= right[j] {
result = append(result, left[i])
i++ // 左指针后移
} else {
result = append(result, right[j])
j++ // 右指针后移
}
}
// 4. 处理 left 剩下的元素(如果有)
// 此时 left 剩下的一定都比结果里的大
for i < len(left) {
result = append(result, left[i])
i++
}
// 5. 处理 right 剩下的元素(如果有)
// 此时 right 剩下的一定都比结果里的大
for j < len(right) {
result = append(result, right[j])
j++
}
// 返回最终合并好的有序数组
return result
}
func main() {
nums := []int{5, 2, 3, 1}
fmt.Println(mergeSort(nums)) // 输出: [1 2 3 5]
nums2 := []int{5, 1, 1, 2, 0, 0}
fmt.Println(mergeSort(nums2)) // 输出: [0 0 1 1 2 5]
}
时间复杂度 O(n log n)
- 最好:O(n log n)
- 最坏:O(n log n)
- 平均:O(n log n)
- 不会像快排那样退化,永远稳定高效
空间复杂度 O(n)
- O(n)
- 需要额外数组存储合并结果
- 递归调用栈:O(log n)
- 总空间:O(n)
稳定性:稳定
6. 堆排序
- 把数组变成一个大顶堆(根节点最大)
- 把堆顶最大值 和数组最后一位交换 → 最大值就位
- 把剩下的元素重新调整成堆
- 重复以上步骤,直到整个数组有序
- 时间复杂度永远 O(n log n) ,原地排序,不需要额外空间
go
package main
import "fmt"
// sortArray 堆排序对外接口
func sortArray(nums []int) []int {
n := len(nums)
// 1. 构建大顶堆:从最后一个非叶子节点向上调整
for i := n/2 - 1; i >= 0; i-- {
heapify(nums, n, i)
}
// 2. 一个个交换堆顶到末尾,并调整剩余元素
for i := n - 1; i > 0; i-- {
// 将堆顶最大值 与 当前数组末尾交换
nums[0], nums[i] = nums[i], nums[0]
// 交换后,剩余元素重新调整为大顶堆
heapify(nums, i, 0)
}
return nums
}
// heapify 调整堆:让以 i 为根的子树变成大顶堆
// n:堆的长度,i:当前要调整的根节点
func heapify(nums []int, n int, i int) {
largest := i // 最大值初始化为根节点
left := 2*i + 1 // 左孩子节点下标
right := 2*i + 2 // 右孩子节点下标
// 如果左孩子更大,更新最大值下标
if left < n && nums[left] > nums[largest] {
largest = left
}
// 如果右孩子更大,更新最大值下标
if right < n && nums[right] > nums[largest] {
largest = right
}
// 如果最大值不是根节点,说明需要交换
if largest != i {
nums[i], nums[largest] = nums[largest], nums[i]
// 交换后,递归调整受影响的子树
heapify(nums, n, largest)
}
}
func main() {
// 测试用例 1
nums1 := []int{5, 2, 3, 1}
fmt.Println(sortArray(nums1)) // 输出: [1 2 3 5]
// 测试用例 2
nums2 := []int{5, 1, 1, 2, 0, 0}
fmt.Println(sortArray(nums2)) // 输出: [0 0 1 1 2 5]
}
时间复杂度 O(n log n)
建堆 O(n) + 每次调整堆 O(log n)
初始化建堆的时间复杂度为 O(n),建完堆以后需要进行 n−1 次调整,一次调整的时间复杂度为 O(logn),那么 n−1 次调整即需要 O(nlogn) 的时间复杂度。因此,总时间复杂度为 O(n+nlogn)=O(nlogn)。
空间复杂度 O(1)
原地排序,没有额外开辟数组(只需要常数的空间存放若干变量)
稳定性:不稳定
小顶堆
特点 :堆顶是整个堆里最小 的值
核心逻辑:快速获取当前堆的最小值
1. 海量数据找 最大的 K 个数
用途 :数据太大无法全加载内存,找最大 K 个
为什么用小顶堆:
- 我们要保留大的,淘汰小的
- 小顶堆能O(1) 拿到堆里最小的那个
- 只要新数 > 堆顶,就替换堆顶
我们要留大的,扔小的
那我就把当前最小的放堆顶
新来一个数,只要比最小的大,就把最小的踢走
最后剩下的,自然就是最大的 K 个
原理:
- 维护一个大小固定为 K 的小顶堆
- 遍历所有数据
- 新数 > 堆顶 → 删堆顶,插入新数
- 最终堆里就是全局最大 K 个数
2. 最小优先队列
用途 :每次要取最小元素
原理:
- 小顶堆保证堆顶最小
- 每次出队都是最小值,时间 O(logn)
想拿最小值 → 直接拿堆顶
拿走后,堆会自动调整,新堆顶又是新的最小值
永远能最快拿到最小
这就是 "最小优先队列"。
大顶堆
特点 :堆顶是整个堆里最大 的值
核心逻辑:快速获取当前堆的最大值
海量数据找 最小的 K 个数
用途 :有限内存,找最小 K 个
原理:
- 维护大小为 K 的大顶堆
- 新数 < 堆顶 → 替换
- 最终堆里就是全局最小 K 个数
为什么用大顶堆:快速拿到堆里最大的,方便淘汰大的,保留小的