结合代码 图解 堆排序
参考链接
堆排序
概述
堆排序是利用 堆 这种数据结构进行排序的一种排序算法,堆排序是一种选择排序(每次选出序列中 最大 / 最小 元素)
它的最好和最坏时间复杂度都是O(n logN)
升序->
大顶堆
降序->
小顶堆
堆
堆是具有以下性质的完全二叉树:
因为是完全二叉树,所以可以映射为一个一维数组
- 每个节点值都
>=
其左右孩子节点的值,称为大顶堆 <=
称为小顶堆
基本步骤
- 先将待排序列构造成一个大顶堆
- 将根节点(序列中最大的节点)与末尾元素进行交换
- 对剩余前 n+1 个元素重复上述步骤
图解
以int nums[8] = { 5,23,16,88,47,65,32,9 }
为例

首先会将给到的数组看作是一颗完全二叉树
然后将它做一轮调整,将其调整为一个 大根堆 或者 小根堆
// 视作一棵完全二叉树 // 从最后一个非叶子节点开始,对每一个非叶子节点进行调整 for (int i = size / 2 - 1; i >= 0; --i) { adjust(arr, size, i); }
具体的调整函数的动作是:
从最后一个 非叶子节点(因为是完全二叉树,所以这个索引号是可以确定计算出来的size/2-1
)开始(也就是88)
比较当前节点和左右孩子节点的值,如果孩子节点中存在比当前节点更大的值,就交换
同时,需要对交换前的孩子节点位置做递归的调整(因为会影响到下面子树)
比如调整 16,发现左右孩子中有比它更大的,就会去交换 16 和 65 的位置,然后继续去调整交换后 16 节点的二叉树,因为下面在没有孩子了,所以结束
void adjust(int arr[],int len,int index) { // 所要调整节点的左右孩子节点的索引 int left = 2 * index + 1; int right = 2 * index + 2; int maxIdx = index;// 三个数中,最大的那个的下标 // 如果孩子节点中有更大的,就把它和两个孩子中相对较大的交换 // 如果子节点索引大于等于了数组长度,则这个孩子是不存在的 // 值相等的情况可以有多种不同的实现方法,所以说是不稳定的排序 if (left<len && arr[left]>arr[maxIdx]) maxIdx = left; if (right<len && arr[right]>arr[maxIdx]) maxIdx = right; // 不等就说明当前节点不是最大数 if (maxIdx != index) { // 交换 swap(arr[maxIdx], arr[index]); // 如果被交换前的孩子节点,还有子节点,那么交换后的 maxInx 位置(值为当前节点而不再是较大子节点),需要递归地去调整 // 究竟有没有是在 adjust 函数内部判断的 adjust(arr, len, maxIdx); } }
这样第一轮循环结束后我们就得到了一个 大根堆 / 小根堆

我们再回过头来看这个数组[88,47,65,23,5,16,32,9]
,很明显对于我们需要实现的目标(有序序列)而言,我们只得到了序列中最大的数和它的位置是确定的,而剩余序列并不有序
也就是说对于构造一次大根堆而言,我们只能得到最大的元素
所以我们需要第二轮循环,它进行如下动作:
将已经确定位置的最大元素与队列末尾元素交换,然后对剩下的序列从根节点开始向下调整,重复这一过程直到所有的元素均被排序
// 上面结束,就完成了一趟建大堆的过程,但是叶子节点从左到右并不是有顺序的 // 接下来是把最大的丢到最后去,然后再重复建堆(调整) // 调到 1 就行,最后一个最小的就保留在最上面不用换 for (int i = size - 1; i >= 1; --i) { // 将当前最大元素与数组末尾元素交换 // 下标为0的根最大,i是当前末尾 swap(arr[0], arr[i]); // 将未完成排序的部分继续进行堆排序 adjust(arr, i, 0); }
这样,我们最终可以得到:排序完成的序列[5,9,16,23,32,47,65,88]

完整代码
void adjust(int arr[], int len, int index) { int left = 2 * index + 1; int right = 2 * index + 2; int maxIdx = index; if (left<len && arr[left]>arr[maxIdx]) maxIdx = left; if (right<len && arr[right]>arr[maxIdx]) maxIdx = right; if (maxIdx != index) { swap(arr[maxIdx], arr[index]); adjust(arr, len, maxIdx); } } void heapSort(int arr[], int size) { for (int i = size / 2 - 1; i >= 0; --i) { adjust(arr, size, i); } for (int i = size - 1; i >= 1; --i) { swap(arr[0], arr[i]); adjust(arr, i, 0); } } int main() { int nums[8] = { 5,23,16,88,47,65,32,9 }; heapSort(nums, 8); for (int num : nums) printf("%d ", num); return 0; }
但是这里好像没有涉及到“优先队列”,我在力扣看到的一些涉及堆的题目都用到了“优先队列”
上面是基于递归的,这里还有另外一种 基于迭代 的 adjust 函数的实现,更巧妙但也更不好理解
void adjustHeap(vector<int>& nums, int i, int len) { int temp = nums[i];// temp始终保存的是最初是要调整位置的值 for (int k = i * 2 + 1; k < len; k = 2 * k + 1) { // 这里 k 的初始化就是左孩子节点,下面的 k+1 就表示了右孩子节点 // k 的迭代规则也对应了下一个左孩子 // 第一句先比出了左右孩子中较大的那个 if (k + 1 < len && nums[k + 1] > nums[k]) k++; // 如果较大的孩子大于了当前节点,就更新值并更新索引i(对应了当前要调整的父节点值) // 注意这里只是更新但是没有交换,用子节点值去更新了父节点值,并重复这一过程 // 但是更新子节点的过程被保留了 if (nums[k] > temp) { // nums[k]代表了更大的孩子 // 很明显这是一个从上至下的过程,nums[i]代表了这个过程中最大的值 nums[i] = nums[k]; i = k; } else break;// 不涉及更新就不再向下处理 } // 这也是上面为什么要更新 i 的原因,i对应了每一轮被交换的子节点的索引 // 但是调整 i 不会影响到整个循环吗?毕竟是循环条件 // 不会,因为只有第一次初始化k用到了i,后面k的更新都与 i 无关 nums[i] = temp;// 这一句是对上面只更新不交换的画龙点睛,它省去了定节点不断交换到最下层位置的路径消耗,一步到位更新最终位置的值 }
复杂度分析
堆排的空间复杂度是O(1)
,因为可以直接在原数组上操作,不需要额外的空间
然后时间复杂度:最好 = 最坏 = 平均 = O(N logN)
,这是怎么计算出来的呢?
首先是代码中的两个循环:
-
建堆阶段:
首先执行一个外层循环,这个循环会遍历非叶子节点。在每次循环内,
adjust()
函数会递归调用,对堆进行调整adjust()
本身的时间复杂度是O(log n)
,因为它在树的高度上进行操作这个外层循环的时间复杂度是
O(n/2)
因此,建堆阶段的总时间复杂度是
O(n/2 * log n) = O(n * log n)
-
排序阶段:
有一个外层循环,这个循环从数组的最后一个元素向前遍历。在每次循环内,执行一次
swap()
,交换堆顶元素和当前循环的元素,然后调用adjust()
函数对剩余的元素进行堆调整这个外层循环执行了 n - 1 次,每次执行
adjust
的时间复杂度是O(log n)
,因此排序阶段的总时间复杂度是O((n - 1) * log n) = O(n * log n)
所以代码的总复杂度为2O(log n)
,也就是O(log n)
级别
那为什么说它的 最好时间复杂度 = 最坏 = 平均呢?
无论输入数据是否有序、随机、或其他分布情况,都需要进行堆的构建和维护,所以它的性能在不同情况下表现都一致
这使得堆排序相对稳定,但它也有一些局限性,比如不适用于小规模数据或部分有序的数据,因为它在这些情况下的性能可能不如其他排序算法
本文作者:YaosGHC
本文链接:https://www.cnblogs.com/yaocy/p/16576230.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步