4-2 排序算法 O(nlgn)

1 nlogn

1.1 快速排序(Quick Sort)

快速排序通常采用分治法,选择一个基准元素(pivot),将数组分为两部分,一部分小于基准,另一部分大于基准,然后递归地对这两部分排序。关键点包括:分区操作、递归调用、原地排序(通常)等。

快速排序的核心在于分治策略原地分区,但具体实现方式可能有多种变体。

原理:分治法策略,选择基准元素分割数组
平均时间复杂度:O(n log n)
特点:不稳定排序,原地排序

首先看一个“伪快速排序”

func quickSort(nums []int) []int {
	if len(nums) < 2 {
		return nums
	}

	mid := nums[0]
	var left, right []int

	for _, n := range nums[1:] { // 跳过第一个元素
		if n < mid {
			left = append(left, n)
		} else {
			right = append(right, n)
		}
	}

	// 正确合并:左分区 + mid + 右分区
	return append(append(quickSort(left), mid), quickSort(right)...)
}
  • 优点:逻辑简单,符合分治思想。
  • 缺点
    1. 非原地排序:每次递归创建新列表,空间复杂度 O(n log n)。
    2. 基准值固定为第一个元素:对已排序数组效率极低。

真正的快速排序

func QuickSort(arr []int, low, high int) {
	if low < high {
		// 分区操作,返回基准值的正确位置
		pivotIndex := partition(arr, low, high)
		// 递归排序左半部分
		QuickSort(arr, low, pivotIndex)
		// 递归排序右半部分
		QuickSort(arr, pivotIndex+1, high)
	}
}

// Hoare 分区方案(双指针法)
func partition(arr []int, low, high int) int {
	pivot := arr[low] // 选择第一个元素为基准
	left, right := low, high

	// 指针相遇或交叉时跳出
	for left < right {
		// 右指针向左找小于pivot的元素
		for left < right && arr[right] >= pivot {
			right--
		}
		// 左指针向右找大于pivot的元素
		for left < right && arr[left] <= pivot {
			left++
		}
		// 交换左右指针的元素
		if left < right {
			arr[left], arr[right] = arr[right], arr[left]
		}
	}

	// 将基准值放到正确位置 (right == left !!!!!)
	arr[low], arr[right] = arr[right], arr[low]
	return right // 返回基准值的最终位置
}

// Lomuto 分区方案(单指针法)
func partition(arr []int, low, high int) int {
    pivot := arr[high] // 选择最后一个元素为基准
    i := low - 1       // 标记较小元素的插入位置
    for j := low; j < high; j++ { // 内部循环条件:j < high
        if arr[j] < pivot {
            i++
            arr[i], arr[j] = arr[j], arr[i]
        }
    }
    // 将基准值放到正确位置
    arr[i+1], arr[high] = arr[high], arr[i+1]
    return i + 1
}

1.2 归并排序(Merge Sort)

原理:分治法策略,递归分割后合并有序序列
时间复杂度:O(n log n)
特点:稳定排序,需要O(n)额外空间

func mergeSort(arr []int) []int {
    if len(arr) <= 1 {
        return arr
    }
    
    mid := len(arr)/2
    left := mergeSort(arr[:mid])
    right := mergeSort(arr[mid:])
    
    return merge(left, right)
}

func merge(left, right []int) []int {
    result := make([]int, 0, len(left)+len(right))
    i, j := 0, 0
    
    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++
        }
    }
    
    result = append(result, left[i:]...)
    result = append(result, right[j:]...)
    return result
}

1.3 堆排序(Heap Sort)

堆排序是一种基于完全二叉树的比较排序算法,其核心思想是将待排序序列构建成一个大顶堆(或小顶堆),通过不断提取堆顶元素并调整堆结构来实现排序。以下是详细说明及Go语言实现:

1.3.1 堆排序原理

  1. 树结构基础
    • 完全二叉树:堆排序基于完全二叉树实现,可用数组高效存储。
    • 父节点与子节点关系
      • 左子节点索引:2*i + 1
      • 右子节点索引:2*i + 2
      • 父节点索引:(i-1)/2(当i>0时)
  1. 堆的性质
    • 大顶堆:父节点值 ≥ 子节点值(用于升序排序)
    • 小顶堆:父节点值 ≤ 子节点值(用于降序排序)
  2. 算法步骤
    • 构建初始堆从最后一个非叶子节(n-1)/2点开始,自底向上调整堆。
    • 交换与调整:将堆顶元素(最大值)与末尾元素交换,缩小堆范围后重新调整堆。
    • 重复操作:直到所有元素有序。

image

原理:利用堆数据结构进行排序
时间复杂度:O(n log n)
特点:不稳定排序,原地排序

func heapSort(arr []int) {
    n := len(arr)
    
    // 构建最大堆
    for i := n/2 - 1; i >= 0; i-- {
        heapify(arr, n, i)
    }
    
    // 逐个提取元素
    for i := n-1; i > 0; i-- {
        arr[0], arr[i] = arr[i], arr[0]
        heapify(arr, i, 0)
    }
}

// 入参 n 为数组的长度
// i 为需要调整的节点
func heapify(arr []int, n, i int) {
    largest := i
    left := 2*i + 1
    right := 2*i + 2
    
    if left < n && arr[left] > arr[largest] {
        largest = left
    }
    
    if right < n && arr[right] > arr[largest] {
        largest = right
    }

    if largest != i {
        arr[i], arr[largest] = arr[largest], arr[i]
        // 当父节点和子节点交换后,子节点所在的子树可能不再满足堆的条件,需要继续调整 !!!!
        heapify(arr, n, largest)
    }
}

1.4 希尔排序(Shell Sort)

原理:

  1. 间隔序列:采用希尔原始序列(n/2递减),也可替换为更优的Hibbard序列(2^k-1)
  2. 子序列排序:每个间隔分组独立进行插入排序
  3. 渐进缩小:通过逐步缩小间隔,最终完成完全排序

时间复杂度:O(n log n)
特点:不稳定排序,原地排序

// 希尔排序实现
func shellSort(arr []int) {
    n := len(arr)
    
    // 初始间隔设为数组长度的一半,逐步缩小间隔
    for gap := n/2; gap > 0; gap /= 2 {
        // 对每个子序列进行插入排序
        for i := gap; i < n; i++ {
            temp := arr[i]
            j := i
            
            // 对间隔gap的元素进行插入排序
            for j >= gap && arr[j-gap] > temp {
                arr[j] = arr[j-gap]
                j -= gap
            }
            arr[j] = temp
        }
    }
}

2 题目

215. 数组中的第K个最大元素

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

方法一:基于快速排序的选择方法

思路和算法

我们可以用快速排序来解决这个问题,先对原数组排序,再返回倒数第 k 个位置,这样平均时间复杂度是 O(nlogn),但其实我们可以做的更快。

首先我们来回顾一下快速排序,这是一个典型的分治算法。我们对数组 a[l⋯r] 做快速排序的过程是(参考《算法导论》):

分解: 将数组 a[l⋯r] 「划分」成两个子数组 a[l⋯q−1]、a[q+1⋯r],使得 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。其中,计算下标 q 也是「划分」过程的一部分。
解决: 通过递归调用快速排序,对子数组 a[l⋯q−1] 和 a[q+1⋯r] 进行排序。
合并: 因为子数组都是原址排序的,所以不需要进行合并操作,a[l⋯r] 已经有序。
上文中提到的 「划分」 过程是:从子数组 a[l⋯r] 中选择任意一个元素 x 作为主元,调整子数组的元素使得左边的元素都小于等于它,右边的元素都大于等于它, x 的最终位置就是 q。
由此可以发现每次经过「划分」操作后,我们一定可以确定一个元素的最终位置,即 x 的最终位置为 q,并且保证 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。所以只要某次划分的 q 为倒数第 k 个下标的时候,我们就已经找到了答案。 我们只关心这一点,至于 a[l⋯q−1] 和 a[q+1⋯r] 是否是有序的,我们不关心。

因此我们可以改进快速排序算法来解决这个问题:在分解的过程当中,我们会对子数组进行划分,如果划分得到的 q 正好就是我们需要的下标,就直接返回 a[q];否则,如果 q 比目标下标小,就递归右子区间,否则递归左子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。这就是「快速选择」算法。

我们知道快速排序的性能和「划分」出的子数组的长度密切相关。直观地理解如果每次规模为 n 的问题我们都划分成 1 和 n−1,每次递归的时候又向 n−1 的集合中递归,这种情况是最坏的,时间代价是 O(n 
2
 )。我们可以引入随机化来加速这个过程,它的时间代价的期望是 O(n),证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。需要注意的是,这个时间复杂度只有在 随机数据 下才成立,而对于精心构造的数据则可能表现不佳。因此我们这里并没有真正地使用随机数,而是使用双指针的方法,这种方法能够较好地应对各种数据。

func findKthLargest(nums []int, k int) int {
    n := len(nums)
    return quickselect(nums, 0, n - 1, n - k)
}

func quickselect(nums []int, l, r, k int) int{
    if (l == r){
        return nums[k]
    }
    partition := nums[l]
    i := l - 1
    j := r + 1
    for (i < j) {
        for i++;nums[i]<partition;i++{}
        for j--;nums[j]>partition;j--{}
        if (i < j) {
            nums[i],nums[j]=nums[j],nums[i]
        }
    }
    if (k <= j){
        return quickselect(nums, l, j, k)
    }else{
        return quickselect(nums, j + 1, r, k)
    }
}

方法二:基于堆排序的选择方法

思路和算法

我们也可以使用堆排序来解决这个问题——建立一个大根堆,做 k−1 次删除操作后堆顶元素就是我们要找的答案。在很多语言中,都有优先队列或者堆的的容器可以直接使用,但是在面试中,面试官更倾向于让更面试者自己实现一个堆。所以建议读者掌握这里大根堆的实现方法,在这道题中尤其要搞懂「建堆」、「调整」和「删除」的过程。

func findKthLargest(nums []int, k int) int {
    heapSize := len(nums)
    buildMaxHeap(nums, heapSize)
    for i := len(nums) - 1; i >= len(nums) - k + 1; i-- {
        nums[0], nums[i] = nums[i], nums[0]
        heapSize--
        maxHeapify(nums, 0, heapSize)
    }
    return nums[0]
}

func buildMaxHeap(a []int, heapSize int) {
    for i := heapSize / 2 - 1; i >= 0; i-- {
        maxHeapify(a, i, heapSize)
    }
}

func maxHeapify(a []int, i, heapSize int) {
    l, r, largest := i * 2 + 1, i * 2 + 2, i
    if l < heapSize && a[l] > a[largest] {
        largest = l
    }
    if r < heapSize && a[r] > a[largest] {
        largest = r
    }
    if largest != i {
        a[i], a[largest] = a[largest], a[i]
        maxHeapify(a, largest, heapSize)
    }
}

347. 前 K 个高频元素

给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。

func topKFrequent(nums []int, k int) []int {
    occurrences := map[int]int{}
    for _, num := range nums {
        occurrences[num]++
    }
    h := &IHeap{}
    heap.Init(h)
    for key, value := range occurrences {
        heap.Push(h, [2]int{key, value})
        if h.Len() > k {
            heap.Pop(h)
        }
    }
    ret := make([]int, k)
    for i := 0; i < k; i++ {
        ret[k - i - 1] = heap.Pop(h).([2]int)[0]
    }
    return ret
}

type IHeap [][2]int

func (h IHeap) Len() int           { return len(h) }
func (h IHeap) Less(i, j int) bool { return h[i][1] < h[j][1] }
func (h IHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *IHeap) Push(x interface{}) {
    *h = append(*h, x.([2]int))
}

func (h *IHeap) Pop() interface{} {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[0 : n-1]
    return x
}

 

posted @ 2025-08-10 20:08  PlusBei  阅读(19)  评论(0)    收藏  举报