【数据结构】堆、堆排序、堆的应用

  • 堆只关心 父子结点间 的大小关系,对兄弟结点不做要求,即只需要满足根结点总大于或小于所有孩子结点。

  • 堆排序是选择排序的一种改进,选择排序进行 n - 1 遍历,每次选出最小的元素放在有序序列的最后一个位置,再在剩下的序列中选择最小的元素。所以选择排序产生的序列 前 m (<= n) 项 总是有序的。

  • 堆排序通过优化查找 最大或最小值 的时间改进快速排序。

  • 升序排序,将原序列建成大顶堆(或是最大堆),大顶堆保证了树的根结点始终是该树中数值最大的元素,对外层遍历的时候,将堆的根节点与最后一个元素交换,这样就保证了序列的最后一个元素为最大值,再将剩下的 n - ii 是已经有序的元素个数)个元素重新调整成大顶推。

堆的基本操作

默认将堆中的结点元素视为整型,定义数组表示堆结构:

vector<int> heap(size); // size 为结点数量

建立大顶堆

建堆过程,从最后一个非叶子结点开始,因为堆是个完全二叉树,使用数组存储,根结点存在下标为 1 的位置,2 * i2 * i + 1 分别为第 i 个结点的孩子,因此最后一个结点的位置为 n,那么最后一个父亲结点位置为 n / 2

// 因为是全局变量故省掉了参数 (vector<int> &heap, int n), 下同
void createHeap() {
    for (int i = n / 2; i >= 1; i--) {	// 从最后一个父亲结点向前移都是父亲结点
        downAdjust(i, n);
    }
}

向下调整

downAdjust 向下调整是将当前子树(其实就可以看作为独立的一棵树)调整为以 根节点为最大值 的完全二叉树。调整过程,即如果当前结点均大于两个孩子结点,则将当前结点与两个孩子中 较大的结点 交换,再将所交换的孩子作为根结点继续向下调整。

最重要的,因为从最后一个父亲结点开始调整,因此,再往前调整其他的子树时就能保证其左右子树已经形成以根结点为最大值的树了,因此每次交换时总能把 左右子树中最大的值 换到根的位置。

void downAdjust(int low, int high) {
    int i = low, j = i * i;
    while (j <= high) {
        if (j + 1 <= high && heap[j + 1] > heap[j]) j = j + 1; // 比 j++ 更直观
        if (heap[i] >= heap[j]) break;  // 父结点大于或等于较大的孩子结点则直接退出
        swap(heap[i], heap[j]);
        i = j;
        j = i * 2;
    }
}

这里是对大顶堆做调整,如果是小顶堆则直接修改注释行,为

if (heap[i] <= heap[j]) break;

向上调整

建堆过程实际上是对父子结点间无序的完全二叉树进行调整,如果在建堆完成后要插入新的结点,就必须重新对堆做调整,因为插入结点后,仍然要保持 CBT 的结构,因此必须也只能在堆的最后的一个位置后插入新结点,也就是数组的第 n + 1 个位置上。之后从新加入的结点开始 向上调整 (upAdjust) ,即如果该结点的值大于其父结点就一直向上交换。

void upAdjust(int low, int high) {
    int i = high, j = i / 2;
    while (j >= low) {
        if (heap[j] >= heap[i]) break;	// 父结点比新插入的结点大直接退出
        swap(heap[j], heap[i]);
        i = j;
        j = i / 2;
    }
}

因为向上调整只与父亲结点比较大小,因此不存在与较大值比较的情况,所以相对 downAdjust 少了一行代码。

for 循环有更简便的写法,但是逻辑上似乎没上面那么直观,考试编码时适用:

void downAdjust(int low, int high) {
    for (int i = low, j = i * 2; j <= high; i = j, j *= 2) {
        if (j + 1 <= high && heap[j + 1] > heap[j]) j++;
        if (heap[i] >= heap[j]) break;
        swap(heap[i], heap[j]);
    }
}
void upAdjust(int low, int high) {
    for (int i = high, j = i / 2; j >= low; i = j, j /= 2) {
        if (heap[j] <= heap[i]) break;
        swap(heap[j], heap[i]);
    }
}

堆的插入与删除

现在有了 downAdjustupAdjust,在堆中 插入删除 结点就变得很容易了。

  • 插入结点,即在 堆末尾 插入新结点,堆的大小 加一 ,再从末尾元素向上调整。
  • 删除结点,即取出根节点,将 最后一个结点作为根节点 ,堆的大小 减一 ,再从根节点向下调整。
// 在末尾插入元素
void insertHeap(int x) {
    heap[++n] = x;
    upAdjust(1, n);
}
// 删除堆顶元素
void deleteMax() {
    heap[1] = heap[n--];
    downAdjust(1, n);
}

堆的基本操作完结。

堆排序

堆排序。堆虽然用数组存储,但是逻辑上是完全二叉树,对于选择排序中 找最大元素 的操作,对堆而言其实是 O(1) 的时间复杂度,但是因为每次取出最大元素后都要从根结点重新向下调整,这个调整的过程其实跟堆的层数有关,所以实际上在堆中取最大元素可以视作是 O(logN) 的时间复杂度,那么一共是 n - 1 次查找,所以堆排序的时间复杂度是 O(N·logN)

void heapSort() {
    createHeap();   // 先建堆
    for (int i = n; i > 1; i--) {
        swap(heap[i], heap[1]);  // 将堆顶元素(最大值)与最后一个元素交换
        downAdjust(1, i - 1);  // 将堆重新调整
    }
}

堆的其他问题

PTA 上刷题,碰到考察判断某个序列是大顶堆还是小顶推或不是堆的题目。因此先留下写过的解决方案以便回顾。

首先想到的是,堆是二叉树的结构,所以直接写成递归了。先考虑边界情况再做递归:

  1. 如果是 空树或叶子结点 则视作是堆(A或B,如果A为真则不会执行B了,因此在一个 if 中判断)

  2. 如果是非叶子结点,则判断根结点与左右孩子是否符合根结点最大的条件(堆是完全二叉树,如果是非叶子结点至少能保证左孩子一定存在,因此同样在一个 if 中判断)

  3. 如果符合以上条件再递归地判断左右孩子是不是堆

bool isMaxHeap(int i) {
    if (i > n || i * 2 > n) return true;
    if (heap[i] < heap[i * 2] || (i * 2 + 1 <= n && heap[i] < heap[i * 2 + 1])) 
        return false;
    return isMaxHeap(i * 2) && isMaxHeap(i * 2 + 1);
}
bool isMinHeap(int i) {
    if (i > n || i * 2 > n) return true;
    if (heap[i] > heap[i * 2] || (i * 2 + 1 <= n && heap[i] > heap[i * 2 + 1]))
        return false;
    return isMinHeap(i * 2) && isMinHeap(i * 2 + 1);
}

上面的方法是正常思路,也算简洁,但却是蠢办法,哈哈。堆是完全二叉树,用数组存储,直接遍历一遍数组,判断一下父结点和孩子结点是不是满足大小关系不就好了吗,不满足直接返回 false

bool isMaxHeap() {
    for (int i = 2; i <= n; i++)
        if (heap[i / 2] < heap[i]) return false;
    return true;
}
bool isMinHeap() {
    for (int i = 2; i <= n; i++)
        if (heap[i / 2] > heap[i]) return false;
    return true;
}

堆的应用

学习堆的时候,听到堆最常见的应用是实现优先队列。比如操作系统中的,优先级调度算法,当有优先级更高的任务来的时候,下一次要执行的应该是优先级更高的任务,而不是 FIFO 了。

恰好,在PTA中碰到这样一题:

消息队列是Windows系统的基础。对于每个进程,系统维护一个消息队列。如果在进程中有特定事件发生,如点击鼠标、文字改变等,系统将把这个消息加到队列当中。同时,如果队列不是空的,这一进程循环地从队列中按照优先级获取消息。请注意优先级值低意味着优先级高。请编辑程序模拟消息队列,将消息加到队列中以及从队列中获取消息。

输入格式:
输入首先给出正整数N(≤105),随后N行,每行给出一个指令—— GETPUT ,分别表示从队列中取出消息或将消息添加到队列中。如果指令是 PUT ,后面就有一个消息名称、以及一个正整数表示消息的优先级,此数越小表示优先级越高。消息名称是长度不超过10个字符且不含空格的字符串;题目保证队列中消息的优先级无重复,且输入至少有一个 GET

输出格式:
对于每个 GET 指令,在一行中输出消息队列中优先级最高的消息的名称和参数。如果消息队列中没有消息,输出 EMPTY QUEUE! 。对于 PUT 指令则没有输出。

输入样例:
9
PUT msg1 5
PUT msg2 4
GET
PUT msg3 2
PUT msg4 4
GET
GET
GET
GET

输出样例:
msg2
msg3
msg4
msg1
EMPTY QUEUE!

因为题目说,优先级数值越小的优先级越高,所以这里应该用到小顶堆。每个消息包含 消息的内容优先级数值 ,因此需要创建结构体表示消息实体。优先队列中的 PUTGET 对应的就是堆中的插入和删除操作,那么剩下的就很容易了。代码如下:

#include <iostream>
#include <vector>
using namespace std;
struct node {
    char msg[15];
    int prior;  // priority
};
vector<node*> heap;  //地址传递减少开销
int len = 0;
void downAdjust(int low, int high) {
    int i = low, j = i * 2;
    while (j <= high) {
        if (j + 1 <= high && heap[j + 1]->prior < heap[j]->prior) j = j + 1;
        if (heap[i]->prior <= heap[j]->prior) break;
        swap(heap[i], heap[j]);
        i = j;
        j = i * 2;
    }
}
void upAdjust(int low, int high) {
    int i = high, j = i / 2;
    while (j >= low) {
        if (heap[j]->prior <= heap[i]->prior) break;
        swap(heap[j], heap[i]);
        i = j;
        j = i / 2;
    }
}
node* deleteMin() {
    node* root = heap[1];
    heap[1] = heap[len--];
    downAdjust(1, len);
    return root;
}
void insertHeap(node* Node) {
    heap[++len] = Node;
    upAdjust(1, len);
}
int main() {
    int n;
    char cmd[5];
    scanf("%d", &n);
    heap.resize(n + 1);
    for (int i = 0; i < n; i++) {
        scanf("%s", cmd);
        if (cmd[0] == 'G') {
            if (len == 0)
                printf("EMPTY QUEUE!\n");
            else {
                node* top = deleteMin();
                printf("%s\n", top->msg);
            }
        } else {
            node* Node = new node;
            scanf("%s%d", Node->msg, &Node->prior);
            insertHeap(Node);
        }
    }
    return 0;
}

代码很简单,为了减少值传递的时间消耗,在 vector 中以指针的形式存放数据,其他的逻辑都很清晰。用堆实现代码过程主要是为了复习堆的基本操作,但是在 C++ 的标准模板库中已经包含对优先队列的实现,所以直接用 priority_queue 再写了一个代码版本:

#include <bits/stdc++.h>
using namespace std;
struct node {
    char msg[15];
    int prior;
    bool operator<(const node& a) const { return this->prior > a.prior; }
};
int main() {
    priority_queue<node> q;
    int n, prior;
    char cmd[5];
    scanf("%d", &n);
    while (n--) {
        scanf("%s", cmd);
        if (cmd[0] == 'G') {
            if (q.empty()) printf("EMPTY QUEUE!\n");
            else {
                printf("%s\n", q.top().msg);
                q.pop();
            }
        } else {
            node Node;
            scanf("%s%d", Node.msg, &Node.prior);
            q.push(Node);
        }
    }
    return 0;
}

C++ 中的基础数据类型都有默认的大小比较方式,在 priority_queue<数据类型> 中可以直接使用,优先队列知道如何给元素进行调整,但是自己定义的结构体,对 priority_queue 而言,并不知道如何比较大小关系,因为 priority_queue 内部实现是用小于运算符对元素作比较,故需要在结构体中重载 < 运算符,也可单独定义 cmp 函数作为优先队列的参数,但是谁会愿意使多写几行代码呢,哈哈。

注意:起初为了代码更简洁,使用 string 类型,以及cincout 做输入输出,但是在题目输入最大规模数据量的样例中超时了,无奈改成了字符数组和 scanf

总结

关于堆的内容,这里已经记录的差不多了,作为笔记篇,主要为了方便今后自己复习查看,所以一定不能有错。

发布了27 篇原创文章 · 获赞 0 · 访问量 90

猜你喜欢

转载自blog.csdn.net/charjindev/article/details/104312211