栈、队列和堆详解(c++)

我们先模拟实现, 然后根据代码讲解如何使用.

目录

一.栈

1.stack类中的相关成员

2.stack成员函数的具体实现

3.stack的使用

 二.队列

1.queue类中的相关成员

2.queue成员函数的具体实现

3.queue的使用

三.堆

1.priority_queue的特性介绍

2.priority_queue类中的相关成员

3.priority_queue类中成员函数的具体实现

四.deque的介绍

1.deque的组织形式

2.deque插入元素

3.deque删除元素

4.deque缺陷

五.容器和容器适配器

1.容器

2.适配器


一.栈

因为c++编译器中已经给出了stack, 所以我们在实现时把其放到自己的命名空间中.

1.stack类中的相关成员

采用模板类的方式实现, 可以针对于各种不同的数据, 因为c++中已经给出了很多可以用来实现stack的容器, 我们这里选用deque(双端队列).(deque在后面会详细讲解)

功能介绍:

①.push入栈.

②.pop出栈.

③.top访问栈顶元素.

④.size计算栈中元素个数.

⑤.empty判断栈中是否为空.

为什么使用deque而不是vector实现栈?

1.首先我们要知道, 我们实现的栈只有两种操作, 一种是push, 一种是pop, 也就是出栈和入栈两种操作, 因此使用vector, list, deque 都可以, 但他们的性能上有区别, 所以我们在使用的时候, 需要根据实际情况来选择使用那种容器, 但这里为什么将deque作为stack的默认容器呢? 我们平时在c中实现的satck都是使用动态数组来完成的, 也就是vector那么为什么在这里就不使用了, 其实它是考虑了扩容的问题, 因为我们使用vector, 一但容器容量满了, 就需要扩容, 但vector扩容时十分耗费资源, 需要重新申请空间和拷贝元素, 而如果我们使用deque的话, 实现push和pop两个操作和vector相似, 并且扩容时相对比较方便, 因此在这里编译器选择使用deque来作为stack的默认容器.  

2.栈不需要遍历, 因此刚好把deque的劣势规避了, 所以使用deque更加合适.

    template>

    class stack{

    public:

        stack();

        void push(const T& x) ;

        void pop() ;

        T& top() ;

        const T& top()const ;

        size_t size()const ;

        bool empty()const ;

    private:

        Con _c;

    };

2.stack成员函数的具体实现

a.构造函数不需要显示定义, 默认的构造函数会调用deque的构造函数对类的数据成员进行初始化.

b.push入栈

        void push(const T& x) {
            _c.push_back(x);
        }

直接调用deque的push_back函数, 向双端队列尾部插入元素即可.

c.pop出栈

        void pop() {
            _c.pop_back();
        }

同理, 调用deque的pop_back函数, 从双端队列尾部删除元素即可.

d.top访问栈顶元素

        T& top() {
            return _c.back();
        }

返回双端队列的尾部元素.(const类型的top与普通top完全相同)

e.size有效元素个数

        size_t size()const {
            return _c.size();
        }

依然是返回双端队列中的有效元素个数, 调用deque的size函数.

f.empty判空

        bool empty()const {
            return _c.empty();
        }

调用deque的empty函数, 判断双端队列中元素是否为空.

3.stack的使用

测试:

①.创建栈.

②.向栈中插入元素.

③.打印栈顶元素.

④.打印栈中元素个数.

⑤.出栈.

⑥.打印栈顶元素.

⑦.打印栈中元素个数.

    lz::stack s1;
    s1.push(1);
    s1.push(2);
    s1.push(3);
    s1.push(4);
    s1.push(5);
    s1.push(6);
    cout << s1.top() << endl;
    if (!s1.empty()) {
        cout << s1.size() << endl;
    }
    s1.pop();
    s1.pop();
    s1.pop();
    cout << s1.top() << endl;
    if (!s1.empty()) {
        cout << s1.size() << endl;
    }

运行结果:

栈、队列和堆详解(c++)_第1张图片

 二.队列

同样我们的队列需要在自己的命名空间中实现.

1.queue类中的相关成员

采用模板类的方式实现, 应对于不同类型的数据, 底层使用的默认容器为deque.

功能介绍:

①.push入队列.

②.pop出队列.

③.back访问队列尾部元素.

④.front访问队列首部元素.

⑤.size返回队列中有效元素个数.

⑥.empty判断队列是否为空.

为什么默认容器使用deque而不使用list?

1.queue不需要存储一些额外的内容, 而list中的每个结点还存储了next | prev两个结构体指针, 所以使用list会浪费内存空间.

2.deque是分段连续的, 因此其缓存效率高.

3.queue不需要遍历, 因此规避了deque的劣势.

    template>

    class queue{

    public:

        queue() ;

        void push(const T& x) ;

        void pop() ;

        T& back() ;

        const T& back()const ;

        T& front() ;

        const T& front()const ;

        size_t size()const ;

        bool empty()const ;

    private:

        Con _c;
    };

2.queue成员函数的具体实现

a.构造函数不需要显示定义, 默认的构造函数会调用deque的构造函数对类的数据成员进行初始化.

b.push

        void push(const T& x) {
            _c.push_back(x);
        }

调用deque的push_back函数, 向队列尾部插入元素.

c.pop

        void pop() {
            _c.pop_front();
        }

调用deque的pop_front函数, 从队列首部删除元素.

d.back

        T& back() {
            return _c.back();
        }

        const T& back()const {
            return _c.back();
        }

调用deque的back函数, 访问队列尾部的元素.(const类型的back和普通类型back实现方式完全相同)

e.front

        T& front() {
            return _c.front();
        }

        const T& front()const {
            return _c.front();
        }

调用deque的front函数, 访问队列首部元素.(const类型的实现和普通类型的实现完全相同)

f.size

        size_t size()const {
            return _c.size();
        }

调用deque的size函数, 返回队列中有效元素的个数.

g.empty

        bool empty()const {
            return _c.empty();
        }

调用deque的empty函数, 判断队列是否为空.

3.queue的使用

测试:

①.创建队列.

②.向对列中插入元素.

③.打印队列首部和尾部的元素.

④.将元素出队列.

⑤.打印队列首部元素和尾部元素.

⑥.打印有效元素个数.

    lz::queue a;
    a.push(1);
    a.push(2);
    a.push(3);
    a.push(4);
    a.push(5);
    cout << a.front() << endl;
    cout << a.back() << endl;
    a.pop();
    a.pop();
    a.pop();
    cout << a.front() << endl;
    cout << a.back() << endl;
    if (!a.empty()) {
        cout << a.size() << endl;
    }

运行结果:

栈、队列和堆详解(c++)_第2张图片

三.堆

priority_queue(优先级队列), 是一个完全二叉树, 每个父亲结点都比子节点大或者小, 因为其性能和使用方式跟堆完全相同, 因此在c++中就将其看作堆.

1.priority_queue的特性介绍

使用时注意包含头文件: #include

注意:

1. 默认情况下, priority_queue是大堆.

2. 如果在priority_queue中放自定义类型的数据, 用户需要在自定义类型中提供> 或者< 的重载.

3. 当我们在堆中放置的元素是自定义类型的指针时, 堆在进行元素比较的时候是使用地址来进行比较的, 并不会使用指针指向的内容来进行比较, 因此为了解决这种问题, 我们要自定义比较方式, 而自定义比较方式的方法有三种:  ①.函数指针   ②.仿函数   ③.Iambda表达式(c++11).

默认的存储结构:

优先级队列默认情况下将元素存储到vector中, 堆的概念:  元素存储到数组中(完全二叉树)

堆中默认的比较方式:

默认情况下优先级队列中元素是按照小于的方式进行比较的, 创建出来的是大堆, 如果想要创建小堆, 只需要在实例化时, 让优先级队列中的元素按照大于及greater的方式进行比较即可.  例如:  priority_queue, greater> p;(greater为仿函数)

堆中存储的数据类型:

1.内置类型(c++编译器给出的): 

如果优先级队列中存储的是内置类型的元素, 直接采用less的默认方式进行比较或者传递greater.

2.自定义类型:

如果优先级队列中存储的是自定义类型的元素, 使用less的默认方式或greater进行比较时, 需要用户在自定义的类中将<或者>重载.

3.有些情况下less或者greater已经不能进行比较了(指针类型):

我们需要自定义比较方式, 比如: priority_queue, Compare>  p;

如何自定义比较方式:

①.函数指针(不推荐).

②.仿函数

说明(什么是仿函数): 仿函数也可以将其称为函数对象, 可以像函数调用方式使用的对象.

实现方式: 只需在类中将函数调用运算符即()重载即可.

实例:

栈、队列和堆详解(c++)_第3张图片作用: 让算法或者类更加灵活----->通过仿函数来配置算法或者类具体的功能, 例如: sort中的比较方式, priority_queue中的比较方式.

③.Iambda表达式(后期会介绍)

2.priority_queue类中的相关成员

依旧是按模板类的方式实现, 应对于不同类型的数据, 防止重复代码, 模板参数列表分别为: 数据类型T, 容器类型默认vector, 比较方式默认less.

成员函数介绍:

①.默认构造函数.

②.区间构造函数.

③.empty判空

④.size有效元素个数.

⑤.top返回堆顶元素.

⑥.push向堆中插入元素.

⑦.pop删除堆顶元素.

⑧.AdjustDown向下调整算法(实现堆的核心算法)

    template , class Compare = less >

    class priority_queue{

    public:

        priority_queue();

        template 
        priority_queue(InputIterator first, InputIterator last) ;

        bool empty() const ;

        size_t size() const ;

        const T& top() const ;

        void push(const T& x) ;

        void pop() ;


    private:

        void AdjustDown(int n) ;

    private:

        Container c;

        Compare comp;

    };

3.priority_queue类中成员函数的具体实现

(1).向下调整算法

        void AdjustDown(int n) {
            int father = n;
            int child = father * 2 + 1;
            int size = c.size();
            while (child < size) {
                if (child + 1 < size && comp(c[child], c[child + 1])) {
                    child++;
                }
                if (comp(c[father], c[child])) {
                    swap(c[father], c[child]);
                    father = child;
                    child = father * 2 + 1;
                }
                else {
                    return;
                }
            }
        }

根据大堆的建立我们来分析一下代码, 首先我们取到一个父亲节点和一个左孩子节点, 然后进入循环, 在其右孩子存在的情况下, 取到两个孩子中的最大值, 然后再拿孩子中的最大值和父亲节点比, 如果比父亲节点大, 则交换两个的位置, 交换之后重新设置父亲节点和孩子结点继续循环向下调整, 没有父亲节点大, 则退出函数, 经过这样一套流程, 我们就能保证在创建大根堆时, 父节点的值是最大的, 但这个算法显然只能保证一条二叉树满足堆的特性, 但整个堆则是一个完全二叉树, 有很多二叉树的分支, 我们要保证堆中每个父亲结点和孩子结点都要满足堆的特性, 因此使用循环从最后一个父亲结点开始调用该算法, 直到堆中的根节点.

运行图示:

栈、队列和堆详解(c++)_第4张图片

 (2).构造函数

        priority_queue(){}

        template 
        priority_queue(InputIterator first, InputIterator last) {
            while (first != last) {
                c.push_back(*first);
                first++;
            }
            int i;
            for (i = (size() - 2) / 2; i >= 0; i--) {
                AdjustDown(i);
            }
        }

①.默认构造函数的实现是为了, 在创建堆对象时, 由默认构造函数调用vector和less的构造函数对这两个数据成员进行初始化.(调用这个的时候创建出来的是一个空的堆, 后续我们在使用时需要自己向堆中插入元素)

②.区间构造函数, 我们提前创建好一个数组, 并在其中存入元素, 调用该函数创建堆, 则是利用我们提前存入数组中的那些元素, 对其进行组织, 将其调整成堆, 存在我们的数据成员vector中, 具体实现, 将区间中的数据先拷贝到自己的数据成员vector中, 然后从完全二叉树的最后一个父亲结点开始调用向下调整算法, 并向前循环调用, 直到根节点.(和前面向下调整算法中讲解的完全相同)

测试:

    int a[] = { 3,4,5,6,7,8,2,3,1,0 };
    lz::priority_queue a1;
    lz::priority_queue a2(a, a + sizeof(a) / sizeof(a[0]));
    a1.put();
    a2.put();

运行结果:

栈、队列和堆详解(c++)_第5张图片

 (3).容量相关操作

        bool empty() const {
            return c.empty();
        }

        size_t size() const {
            return c.size();
        }

        const T& top() const {
            if (empty()) {
                assert(false);
            }
            return c[0];
        }

①.判断堆是否为空, 直接调用vector的empty即可.

②.判断堆中有效元素个数, 依旧是调用vector的size函数.

③.访问堆顶元素, 首先判断堆是否为空, 在堆不为空的前提下返回vector中0号下标所对应的元素, 为空则报错.

测试:

    int a[] = { 3,4,5,6,7,8,2,3,1,0 };
    lz::priority_queue a2(a, a + sizeof(a) / sizeof(a[0]));
    if (!a2.empty()) {
        cout << a2.size() << " " << a2.top() << endl;
    }

运行结果:

栈、队列和堆详解(c++)_第6张图片

 (4).堆中插入元素和删除元素(push&pop)

        void push(const T& x) {
            c.push_back(x);
            int i;
            if (size() > 1) {
                for (i = (size() - 2) / 2; i >= 0; i--) {
                    AdjustDown(i);
                }
            }
        }

        void pop() {
            if (empty()) {
                assert(false);
            }
            int size = c.size();
            swap(c[0], c[size - 1]);
            c.resize(size - 1);
            AdjustDown(0);
        }

①.向堆中插入元素, 我们先将元素放到vector的尾部, 然后和创建堆时一样, 从最后一个父亲结点开始, 调用向下调整算法, 直到根节点.(每插入一个元素都要重新保证二叉树符合堆的特性)

②.从堆中删除元素, 堆中元素的删除, 只能删除堆顶元素, 因此我们先将堆顶元素和堆中最后一个元素交换位置, 将vector的有效元素个数减1, 然后再看堆顶元素, 除了堆顶的这个元素外,其左右两边的子树都符合堆的特性, 因此只用从根节点开始, 再调用一次向下调整算法, 即可让整个二叉树依旧保证堆的特性.

堆的删除图示:

栈、队列和堆详解(c++)_第7张图片

测试:

    int a[] = { 3,4,5,6,7,8,2,3,1,0 };
    lz::priority_queue a2(a, a + sizeof(a) / sizeof(a[0]));
    a2.push(10);
    a2.push(1);
    a2.push(11);
    a2.push(5);
    a2.put();
    a2.pop();
    a2.pop();
    a2.pop();
    a2.pop();
    a2.put();

运行结果:

栈、队列和堆详解(c++)_第8张图片

四.deque的介绍

double-ended queue(双端队列)(容器)

1.deque的组织形式

图示:

栈、队列和堆详解(c++)_第9张图片

两个迭代器, start和finish, (start)一个负责队列的前端, (finish)一个负责队列的后端, 其次还需要一个map来记录数据空间, 接着来看, 我们start中的node指针则指向map中当前所在空间的位置, 从而可以让start通过map找到数据空间, 然后就是first和last指针, 分别指向数据空间首部和尾部, 以此来作为我们使用cur指针插入元素时的标志, finish中也同样如此, 因为其使用了map来记录数据空间, 因此deque的扩容也变的更为简单(相对于vector), 只用创建一个更大的map, 将旧的map中的数据空间的地址复制过去即可, 但也正因为这样组织空间所以使其元素的遍历变的更加困难, 所以我们在使用deque的时候, 要考虑到其是否需要遍历操作, 如果要用到就考虑换个容器, 不适用deque.

2.deque插入元素

插入:  当我们进行头插尾插时, 直接寻找start和finish中的cur即可, 头插时start中的cur向前走插入元素, 如果start对应的空间满了, 则在map中重新申请一块空间, 使用start指向就行, 尾插时finish中的cur向后走插入元素, 如果finish对应的空间满了, 同样在map中新申请一块空间, 使用finish指向就行, 如果map满了这时就涉及到我们deque的扩容问题了, 我们只需重新申请一块更大的map类型的空间, 再将原来map中存储的地址拷贝过去并更改start和finish中的node指针的指向就行, 无需对每个存储数据的空间进行拷贝, 这也正好显示了对比于vector时deque扩容的优越性. 头插尾插的时间复杂度为0(1).

3.deque删除元素

删除:  当我们进行头删尾删时, 也是针对start和finish中的cur指针进行操作, 头删时start中的cur向后移动即可, 如果cur走到last的位置, 则node向后走一位, 并改变first, cur, last的指向即可, 尾删时finish中的cur向前移动即可, 如果cur走到first的位置, 则node向前走一位, 并改变first, cur, last的指向即可, 当start和finish走到一起时, 并且他们的cur也都指向同一个空间时, 意味着deque为空. 头删尾删的时间复杂度为0(1).

4.deque缺陷

缺陷: 

1.代码实现复杂.   

2.遍历操作复杂并且效率低, 因为迭代器每移动一次都要检测是否在空间的末尾.

五.容器和容器适配器

1.容器

容器是什么?

容器就是能够存储数据的一种结构体, 因此容器适配器在某种情况下也可以看做容器.

容器的分类:

(1).序列式容器(线性数据结构)

      a. c++98:  vector, list, deque

      b. c++11:  array(静态类型顺序表)  forward_list(带头结点循环单链表)

(2).关联式容器(存储的是键值对)

      C++ STL 标准库提供了 4 种关联式容器,分别为 map、set、multimap、multiset

      具体的在c++后期会介绍.

2.适配器

适配器是什么?

适配器是一种设计模式(设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结), 该种模式是将一个类的接口转换成客户希望的另外一个接口.

例子:

(1).像我们前面说到的栈(stack)、队列(queue)和堆(priority_queue)都属于容器适配器.

(2).在list中实现了一个正向迭代器和一个反向迭代器, 反向迭代器就是对正向迭代器进行了封装, 因此反向迭代器也可以称为迭代器适配器.

你可能感兴趣的:(c++,拓扑学,开发语言,后端,数据结构)