数据结构和算法:堆

堆(heap)是一种满足特定条件的完全二叉树,主要可分为两种类型:
小顶堆(min heap):任意节点的值 ≤ 其子节点的值。
大顶堆(max heap):任意节点的值 ≥ 其子节点的值。

在这里插入图片描述
堆作为完全二叉树的一个特例,具有以下特性:
‧ 最底层节点靠左填充,其他层的节点都被填满。
‧ 将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。
‧ 对于大顶堆(小顶堆),堆顶元素(根节点)的值是最大(最小)的。

堆的常用操作

需要指出的是,许多编程语言提供的是优先队列(priority queue),这是一种抽象的数据结构,定义为具有优先级排序的队列。
实际上,堆通常用于实现优先队列,大顶堆相当于元素按从大到小的顺序出队的优先队列。
从使用角度来看,可以将“优先队列”和“堆”看作等价的数据结构,统一称作“堆”。

在这里插入图片描述
类似于排序算法中的“从小到大排列”和“从大到小排列”,可以通过设置一个 flag 或修改 Comparator 实现“小顶堆”与“大顶堆”之间的转换。代码如下所示:

/**
 * File: heap.cpp
 * Created Time: 2023-01-19
 * Author: LoneRanger(836253168@qq.com)
 */
#include "../utils/common.hpp"

void testPush(priority_queue<int> &heap, int val) {
    heap.push(val); // 元素入堆
    cout << "\n元素 " << val << " 入堆后" << endl;
    printHeap(heap);
}

void testPop(priority_queue<int> &heap) {
    int val = heap.top();
    heap.pop();
    cout << "\n堆顶元素 " << val << " 出堆后" << endl;
    printHeap(heap);
}

/* Driver Code */
int main() {
    /* 初始化堆 */
    // 初始化小顶堆
    // priority_queue<int, vector<int>, greater<int>> minHeap;
    // 初始化大顶堆
    priority_queue<int, vector<int>, less<int>> maxHeap;

    cout << "\n以下测试样例为大顶堆" << endl;

    /* 元素入堆 */
    testPush(maxHeap, 1);
    testPush(maxHeap, 3);
    testPush(maxHeap, 2);
    testPush(maxHeap, 5);
    testPush(maxHeap, 4);

    /* 获取堆顶元素 */
    int peek = maxHeap.top();
    cout << "\n堆顶元素为 " << peek << endl;

    /* 堆顶元素出堆 */
    testPop(maxHeap);
    testPop(maxHeap);
    testPop(maxHeap);
    testPop(maxHeap);
    testPop(maxHeap);

    /* 获取堆大小 */
    int size = maxHeap.size();
    cout << "\n堆元素数量为 " << size << endl;

    /* 判断堆是否为空 */
    bool isEmpty = maxHeap.empty();
    cout << "\n堆是否为空 " << isEmpty << endl;

    /* 输入列表并建堆 */
    // 时间复杂度为 O(n) ,而非 O(nlogn)
    vector<int> input{1, 3, 2, 5, 4};
    priority_queue<int, vector<int>, greater<int>> minHeap(input.begin(), input.end());
    cout << "输入列表并建立小顶堆后" << endl;
    printHeap(minHeap);

    return 0;
}

在这里插入图片描述

堆的实现

若要将其转换为小顶堆,只需将所有大小逻辑判断取逆(例如,将 ≥ 替换为 ≤ )。

1.堆的存储与表示

完全二叉树非常适合用数组来表示。由于堆正是一种完全二叉树,因此将采用数组来存储堆。
当使用数组表示二叉树时,元素代表节点值,索引代表节点在二叉树中的位置。节点指针通过索引映射公式来实现。

给定索引 𝑖 ,其左子节点的索引为 2𝑖 + 1 ,右子节点的索引为 2𝑖 + 2 ,父节点的索引为 (𝑖 − 1)/2(向下整除)。当索引越界时,表示空节点或节点不存在。
在这里插入图片描述

可以将索引映射公式封装成函数,方便后续使用:

/* 获取左子节点的索引 */
int left(int i) {
    return 2 * i + 1;
}

/* 获取右子节点的索引 */
int right(int i) {
    return 2 * i + 2;
}

/* 获取父节点的索引 */
int parent(int i) {
    return (i - 1) / 2; // 向下整除
}

访问堆顶元素

堆顶元素即为二叉树的根节点,也就是列表的首个元素:

// === File: my_heap.cpp ===
/* 访问堆顶元素 */
int peek() {
	return maxHeap[0];
}

元素入堆

给定元素 val ,首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏,因此需要修复从插入节点到根节点的路径上的各个节点,这个操作被称为堆化(heapify)。
考虑从入堆节点开始,从底至顶执行堆化
如图所示,比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。
在这里插入图片描述
设节点总数为 𝑛 ,则树的高度为 𝑂(log 𝑛) 。由此可知,堆化操作的循环轮数最多为 𝑂(log 𝑛) ,元素入堆操作的时间复杂度为 𝑂(log 𝑛)

/* 元素入堆 */
void push(int val) {
    // 添加节点
    maxHeap.push_back(val);
    // 从底至顶堆化
    siftUp(size() - 1);
}


/* 从节点 i 开始,从底至顶堆化 */
void siftUp(int i) {
    while (true) {
        // 获取节点 i 的父节点
        int p = parent(i);
        // 当“越过根节点”或“节点无须修复”时,结束堆化
        if (p < 0 || maxHeap[i] <= maxHeap[p])
            break;
        // 交换两节点
        swap(maxHeap[i], maxHeap[p]);
        // 循环向上堆化
        i = p;
    }
}

堆顶元素出堆

堆顶元素是二叉树的根节点,即列表首元素。如果我们直接从列表中删除首元素,那么二叉树中所有节点的索引都会发生变化,这将使得后续使用堆化进行修复变得困难。

为了尽量减少元素索引的变动,采用以下操作步骤:
1.交换堆顶元素与堆底元素(交换根节点与最右叶节点)。
2.交换完成后,将堆底从列表中删除(注意,由于已经交换,因此实际上删除的是原来的堆顶元素)。
3.从根节点开始,从顶至底执行堆化。
在这里插入图片描述

在这里插入图片描述
与元素入堆操作相似,堆顶元素出堆操作的时间复杂度也为 𝑂(log 𝑛)。

/* 元素出堆 */
void pop() {
    // 判空处理
    if (isEmpty()) {
        throw out_of_range("堆为空");
    }
    // 交换根节点与最右叶节点(交换首元素与尾元素)
    swap(maxHeap[0], maxHeap[size() - 1]);
    // 删除节点
    maxHeap.pop_back();
    // 从顶至底堆化
    siftDown(0);
}

/* 获取左子节点的索引 */
int left(int i) {
    return 2 * i + 1;
}

/* 获取右子节点的索引 */
int right(int i) {
    return 2 * i + 2;
}

/* 获取父节点的索引 */
int parent(int i) {
    return (i - 1) / 2; // 向下整除
}

/* 从节点 i 开始,从顶至底堆化 */
void siftDown(int i) {
    while (true) {
        // 判断节点 i, l, r 中值最大的节点,记为 ma
        int l = left(i), r = right(i), ma = i;
        if (l < size() && maxHeap[l] > maxHeap[ma])
            ma = l;
        if (r < size() && maxHeap[r] > maxHeap[ma])
            ma = r;
        // 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
        if (ma == i)
            break;
        swap(maxHeap[i], maxHeap[ma]);
        // 循环向下堆化
        i = ma;
    }
}

堆的常见应用

优先队列: 堆通常作为实现优先队列的首选数据结构,其入队和出队操作的时间复杂度均为 𝑂(log 𝑛),而建队操作为 𝑂(𝑛) ,这些操作都非常高效。
堆排序: 给定一组数据,可以用它们建立一个堆,然后不断地执行元素出堆操作,从而得到有序数据。
获取最大的 𝑘 个元素: 这是一个经典的算法问题,同时也是一种典型应用。

建堆操作

在某些情况下,希望使用一个列表的所有元素来构建一个堆,这个过程被称为“建堆操作”。

借助入堆操作实现

首先创建一个空堆,然后遍历列表,依次对每个元素执行“入堆操作”,即先将元素添加至堆的尾部,再对该元素执行“从底至顶”堆化。
每当一个元素入堆,堆的长度就加一。由于节点是从顶到底依次被添加进二叉树的,因此堆是“自上而下”构建的。
设元素数量为 𝑛 ,每个元素的入堆操作使用 𝑂(log 𝑛) 时间,因此该建堆方法的时间复杂度为 𝑂(𝑛 log 𝑛)。

通过遍历堆化实现

实际上,可以实现一种更为高效的建堆方法,共分为两步:
1.将列表所有元素原封不动地添加到堆中,此时堆的性质尚未得到满足。
2.倒序遍历堆(层序遍历的倒序),依次对每个非叶节点执行“从顶至底堆化”。

每当堆化一个节点后,以该节点为根节点的子树就形成一个合法的子堆 。而由于是倒序遍历,因此堆是“自下而上”构建的。

之所以选择倒序遍历,是因为这样能够保证当前节点之下的子树已经是合法的子堆,这样堆化当前节点才是有效的。

值得说明的是,由于叶节点没有子节点,因此它们天然就是合法的子堆,无须堆化。

// === File: my_heap.cpp ===
/* 构造方法,根据输入列表建堆 */
MaxHeap(vector<int> nums) {
	// 将列表元素原封不动添加进堆
	maxHeap = nums;
	// 堆化除叶节点以外的其他所有节点
	for (int i = parent(size() - 1); i >= 0; i--) {
		siftDown(i);
	}
}

复杂度分析

假设完全二叉树的节点数量为 𝑛 ,则叶节点数量为 (𝑛 + 1)/2 ,其中 / 为向下整除。因此需要堆化的节点数量为 (𝑛 − 1)/2 。
在从顶至底堆化的过程中,每个节点最多堆化到叶节点,因此最大迭代次数为二叉树高度 log 𝑛 。

将上述两者相乘,可得到建堆过程的时间复杂度为 𝑂(𝑛 log 𝑛) 。但这个估算结果并不准确,因为没有考虑到二叉树底层节点数量远多于顶层节点的性质。

在这里插入图片描述
节点“从顶至底堆化”的最大迭代次数等于该节点到叶节点的距离,而该距离正是“节点高度”。因此,可以对各层的“节点数量 × 节点高度”求和,得到所有节点的堆化迭代次数的总和。

最终得复杂度为 𝑂(2^ℎ) = 𝑂(𝑛),输入列表并建堆的时间复杂度为 𝑂(𝑛)

Top-k 问题

给定一个长度为 𝑛 的无序数组 nums ,请返回数组中最大的 𝑘 个元素。

方法一:遍历选择

在这里插入图片描述

𝑘 轮遍历:分别在每轮中提取第 1、2、…、𝑘 大的元素,时间复杂度为 𝑂(𝑛𝑘) 。
此方法只适用于 𝑘 ≪ 𝑛 的情况,因为当 𝑘 与 𝑛 比较接近时,其时间复杂度趋向于 𝑂(𝑛^2) 。

当 𝑘 = 𝑛 时,我们可以得到完整的有序序列,此时等价于“选择排序”算法。

方法二:排序

可以先对数组 nums 进行排序,再返回最右边的 𝑘 个元素,时间复杂度为 𝑂(𝑛 log 𝑛) 。
在这里插入图片描述

方法三:堆

以基于堆更加高效地解决 Top‑k 问题:
1.初始化一个小顶堆,其堆顶元素最小;
2.先将数组的前 𝑘 个元素依次入堆;
3. 从第 𝑘 + 1 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆;
4. 遍历完成后,堆中保存的就是最大的 𝑘 个元素。

在这里插入图片描述
在这里插入图片描述

/**
 * File: top_k.cpp
 * Created Time: 2023-06-12
 * Author: Krahets (krahets@163.com)
 */

#include "../utils/common.hpp"

/* 基于堆查找数组中最大的 k 个元素 */
priority_queue<int, vector<int>, greater<int>> topKHeap(vector<int> &nums, int k) {
    // 初始化小顶堆
    priority_queue<int, vector<int>, greater<int>> heap;	//priority_queue <type, container, function> heap;
    // 将数组的前 k 个元素入堆
    for (int i = 0; i < k; i++) {
        heap.push(nums[i]);
    }
    // 从第 k+1 个元素开始,保持堆的长度为 k
    for (int i = k; i < nums.size(); i++) {
        // 若当前元素大于堆顶元素,则将堆顶元素出堆、当前元素入堆
        if (nums[i] > heap.top()) {
            heap.pop();
            heap.push(nums[i]);
        }
    }
    return heap;
}

// Driver Code
int main() {
    vector<int> nums = {1, 7, 6, 3, 2};
    int k = 3;

    priority_queue<int, vector<int>, greater<int>> res = topKHeap(nums, k);
    cout << "最大的 " << k << " 个元素为: ";
    printHeap(res);

    return 0;
}

总共执行了 𝑛 轮入堆和出堆,堆的最大长度为 𝑘 ,因此时间复杂度为 𝑂(𝑛 log 𝑘) 。该方法的效率很高,当 𝑘 较小时,时间复杂度趋向 𝑂(𝑛) ;当 𝑘 较大时,时间复杂度不会超过 𝑂(𝑛 log 𝑛) 。

另外,该方法适用于动态数据流的使用场景。在不断加入数据时,可以持续维护堆内的元素,从而实现最大的 𝑘 个元素的动态更新。

学习地址

学习地址:https://github.com/krahets/hello-algo
重新复习数据结构,所有的内容都来自这里。

  • 25
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Python数据结构算法分析是一门关于使用Python编程语言实现和分析常见数据结构算法的学科。它涉及到各种数据结构,例如数组、链表、栈、队列、散列表、堆、树和图等,以及各种算法,例如排序、搜索、递归、动态规划和贪婪算法等。 在Python中,我们可以使用内置的数据结构算法模块,如列表、元组和字典来处理数据。此外,还有一些第三方库,如NumPy和Pandas,提供了高效的数据结构算法操作。 为了进行数据结构算法分析,我们通常需要考虑以下几个方面: 1. 时间复杂度:衡量算法的执行时间随输入规模增加而增长的速度。常见的时间复杂度有O(1)、O(log n)、O(n)、O(n log n)和O(n^2)等。 2. 空间复杂度:衡量算法在执行过程中所需的额外空间随输入规模增加而增长的速度。常见的空间复杂度有O(1)、O(n)和O(n^2)等。 3. 数据结构选择:根据问题的需求和算法的特点选择合适的数据结构。例如,对于频繁的插入和删除操作,链表可能比数组更合适;对于需要快速查找的问题,散列表或二叉搜索树可能更适合。 4. 算法设计:根据问题的特点设计高效的算法。常见的算法设计技巧包括分治法、动态规划、贪婪算法和回溯法等。 总结来说,Python数据结构算法分析是一门涉及到数据结构算法的学科,通过使用Python编程语言来实现和分析各种常见的数据结构算法。它不仅涉及到具体的数据结构算法的实现,还包括对时间复杂度、空间复杂度、数据结构选择和算法设计等方面的分析和评估。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

飞大圣

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值