[知识点] 7.1 栈,队列与链表

总目录 > 7 数据结构 > 7.1 栈,队列与链表

前言

马上要考数据结构了,停更一个星期后决定先把数据结构这一块复习一遍。

子目录列表

1、数据结构简介

2、栈

3、队列

4、链表

 

7.1 栈,队列与链表

1、数据结构简介

数据结构,顾名思义,计算机存储数据的结构。最简单的,变量,数组,都是数据结构。一般情况下对于数据结构的学习中,这些就直接略过而归入语言基础了,而数据结构专题往往从栈,队列与链表这三大基本结构开始介绍。

程序的运行离不开数据结构,而不同的数据结构各有优劣,对于不同的问题,选取合适的数据结构能够大幅提升算法的效率

 

2、栈

① 概念

栈(stack)是最常见的线性数据结构之一,并且栈这个概念在计算机中经常被提及——汇编语言里有堆栈操作程序,平常编程时会提到栈溢出……

那么栈到底是什么?从中文里似乎不能直接想到,那么从英文入手 —— stack,其本义为堆叠(n. & v.),想象一下,新学期开学了,学校下发了 3 本新教材,你想把它们整理好并堆成一叠放在寝室——先把数据结构放在桌上,再把汇编语言放在数据结构上面,再把计算机组成放在汇编语言上面,叠好了。这个过程是从下到上堆叠的。而现在开始上数据结构课了,如果你需要从里面取出教材,在不考虑直接从下面抽取的情况,则必须要从最上方的计算机组成开始取,然后再拿出汇编语言,最后才能拿到数据结构。

这种结构的最大特点是——后加入的数据必须最先取出,简称为后进先出(LIFO, last in first out)

我们将栈的最上方称为栈顶,最下方称为栈底,每次加入或弹出数据都只能从栈顶进行。

汉诺塔问题也是个比较典型的栈——每次在堆叠的时候,我们必须将最上方那块最小的先移动到其他位置,才能移动下方的,以此类推——而上次介绍汉诺塔问题是在递归部分(请参见:2.2 递归与分治)。

说到递归,我们发现它似乎和栈有异曲同工之妙——递归过程不也是对于最后进入的层必须最先处理吗?没错,递归其实本质也是栈结构,这就是为什么在介绍 DFS 的时候提到,DFS 是通过栈这个数据结构实现的(请参见:3.1 DFS / BFS 搜索

汇编语言里提到的堆栈操作程序同理,将寄存器中的数据 PUSH 进去,最先 POP 出来的则是最后 PUSH 进去的。

② 常用操作

那么对于这样一个数据结构,可以进行哪些操作?我们还是模拟一下取书的过程:

> 首先肯定可以把书放入;

> 然后可以看到最上方的书是什么(下方的书不能看到);

> 然后可以取出最上方的书;

> 可以对书本数量进行计数;

> 可以判断是否还有书。

那么这五个过程,可以分别对应栈的五种操作:

> push:将元素加入栈顶;

> top:获取栈顶元素;

> pop:将栈顶元素取出;

> size:获取栈内元素个数;

> empty:判断栈是否为空。

③ 代码实现

先给出一段手搓的栈的实现代码。

 1 #include <bits/stdc++.h>
 2 using namespace std;
 3 
 4 #define MAXN 1005
 5 
 6 class Stack {
 7     int stk[MAXN], tot;
 8 public:
 9     Stack() : tot(0) {}
10     void push(int o) {
11         tot++, stk[tot] = o;
12     }
13     int top() {
14         return stk[tot];
15     }
16     void pop() {
17         tot--;
18     }
19     int size() {
20         return tot;
21     }
22     bool empty() {
23         return tot;
24     }
25 };
26 
27 int main() {
28     Stack s;
29     s.push(1), s.push(2), s.push(3);
30     cout << s.size() << ' ' << s.top() << endl;
31     s.pop(), s.pop();
32     cout << s.size() << ' ' << s.top() << endl;
33     s.pop();
34     cout << s.empty();
35     return 0;    
36 }

五个操作应该都不用过多解释。

如果使用的是 C++,那么学过 STL(请参见:1.3.1 STL 简介)应该就知道,可以使用 STL 中的 stack 来实现栈,其构造方式为:

stack <Typename T, Container> s;

其中 T 表示数据类型,除了 int, double 等基本类型之外,同样支持诸如类等更高级的类型。Container 为容器,可省略,省略则默认为 deque,同时标准容器 vector, list 也可以使用。关于容器的使用,暂时不介绍。

STL 内的栈支持的五种方法同上,也就是说将上述代码主程序创建的栈从手工栈改成 STL 栈,对结果没有影响。感兴趣的可以去 <stack> 库看看实现方式。

④ 应用举例

栈的应用和递归思想的应用基本是一致的,因为递归思想本身就是通过栈实现。常提的几种题型包括:

(1) 进制转换

(2) 括号匹配

(3) 行编辑程序问题

(4) 表达式求值

(5) 汉诺塔问题

等等。

 

2、队列

① 概念

和栈通常一起提及的另一种数据结构——队列(queue),这个概念就很好理解了,因为生活中随处可见排队的现象,对于一列排队买包子的人,必然是先开始排队的先买到包子,则和栈结构恰好相反,队列结构的最大特点是——先加入的数据最先取出,简称为先进先出(FIFO, first in first out)

我们将队列的最前方称为队首,最尾端称为队尾,每次加入数据都只能从队尾加入,而每次弹出数据只能从队首弹出。

② 常用操作

对于队列,可以进行的操作和栈类似:

> push:将元素加入队尾;

> front:获取队首元素;

> back:获取队尾元素;

> pop:将队首元素取出;

> size:获取队列中元素个数;

> empty:判断队列是否为空。

③ 代码实现

由于栈只需要对栈顶元素进行操作,所以只需要一个变量 size,就可以同时完成加入,访问,取出,获取元素,判断是否为空 5 种操作。队列则因为加入和弹出分别在队首和队尾,则需要两个变量 head 和 tail

 1 #include <bits/stdc++.h>
 2 using namespace std;
 3 
 4 #define MAXN 1005
 5 
 6 class Queue {
 7     int q[MAXN], head, tail;
 8 public:
 9     Queue() : head(1), tail(0) {}
10     void push(int o) {
11         tail++, q[tail] = o;
12     }
13     int back() {
14         return q[tail];
15     } 
16     int front() {
17         return q[head];
18     }
19     void pop() {
20         head++;
21     }
22     int size() {
23         return tail - head + 1;
24     }
25     bool empty() {
26         return tail - head > 0;
27     }
28 };
29 
30 int main() {
31     Queue q;
32     q.push(1), q.push(2), q.push(3);
33     cout << q.size() << endl;
34     cout << q.front() << ' ' << q.back() << endl;
35     q.pop(), q.pop();
36     cout << q.size() << endl;
37     q.pop();
38     cout << q.empty();
39     return 0;    
40 }

上述代码将队列封装在一个类中了,目的是规范化,其实通常可以不用这样规矩,简单一点的框架伪代码可以为:

1 int head = 1, tail = 2;
2 while (head != tail) {
3     int o = q[head];
4     for (...) {
5         if (...)
6             tail++, q[tail] = v;
7     }
8     head++;
9 }

同样地,如果使用的是 C++,可以使用 STL 队列。其构造方式为:

queue <Typename T, Container> q;

和 stack 一样,它们都是容器适配器,允许加入容器 Container。

STL 队列支持的六种方法同上。

④ 应用举例

DFS 是用栈实现的,而 BFS(请参见:3.1 DFS / BFS 搜索)是用队列实现的。在清晰认识到队列的原理后,对 BFS 的理解应该就轻松多了。

⑤ 其他队列

> 循环队列

在数据量较大的情况下,为了防止溢出,可以使队列首尾相连进行循环,即 x 的后继为 (x + 1) % size,但这样可能会覆盖有效数据。

> 双端队列

队首队尾均支持插入和弹出元素操作。

> 优先队列

请参见:7.4 堆与优先队列

 

3、链表

① 链表与数组

常见的数组,和上述的栈和队列,它们的共同特点是都属于线性结构,在数据结构中称之为线性表。除此之外,和数组类似的还有一种线性表——链表(linked-list)

数组的数据是相互独立的,对于任意一个元素,它只和它在数组中的位置有对应关系,所以在访问数据时时间复杂度为 O(1),而插入、删除数据为 O(n),因为一个元素的增加或减少要影响接下来所有的元素。

链表则相反——它的结构类似于链子,元素本身没有固定的位置,而是靠元素和元素之间的位置关系来维持结构,下图体现了数组和链表的区别:

对于链表,访问数据时间复杂度为 O(n)插入、删除数据为 O(1)。上图展示的是单向链表,每个元素有一个后继指针,同时还有双向链表,即每个元素有前驱指针和后继指针两个。下面先介绍单向链表。

② 构建

class Node {
    int v;
    Node *nxt;
};

这是采用指针形式单向链表构建方式。Node 为结点,其包含两个数据成员:v 为该结点的值,*nxt 为其后继结点的指针,用以链接元素。当然也可以不使用指针而直接定义一个变量,但就需要更多的空间了。

③ 常用操作

相比栈和队列,链表可操作性更强,操作的具体流程也更为复杂。这里先列举最常规的操作:

> insert:在任意位置插入一个元素

给出一个具体位置(指针 p),直接实现 O(1) 插入,举例:

假设指针 p 指向 5,则表示在 5 后面插入元素。先建立一个新结点,将 9 存入该结点,再将 5 的后继指针赋值给 9 的后继指针,而后将 5 的后继指针指向 9,插入就完成了。这是从中间插入的过程,由于是插入到给定指针的元素后面,如果要在链表头插入数据,则需要特判,同时,要维护链表的头指针,如果从头插入,则头指针需要同时修改。

代码实现见下方。

> erase:删除任意位置的一个元素

单向链表的删除操作相对难理解一些。因为没有前驱指针,我们只能通过将被删除元素的后继元素覆盖到被删除的元素上,如图:

图中可以看出,虽然我们确实将 5 删除了,但其实是把 7 赋值到原来 5 所在的结点,再通过修改指针将 7 所在的那个结点删除了,通过这种方式间接删去了 5。

> size:获取链表内元素个数

> empty:判断链表是否为空

④ 代码实现

 1 class List {
 2     int tot;
 3     Node *head;
 4 public:
 5     void insert(int o) {
 6         Node *node = new Node;
 7         node -> v = o;
 8         node -> nxt = head;
 9         head -> pre = node;
10         head = node;
11         tot++;
12     }
13     void insert(int o, Node *p) {
14         Node *node = new Node;
15         node -> v = o;
16         node -> nxt = p -> nxt;
17         node -> pre = p;
18         p -> nxt -> pre = node; 
19         p -> nxt = node;
20         tot++;
21     }
22     void erase(Node *p) {
23         p -> pre -> nxt = p -> nxt;
24         p -> nxt -> pre = p -> pre;
25         delete p;
26         tot--;
27     }
28     int size() {
29         return tot;
30     }
31     bool empty() {
32         return tot;
33     }
34 };

⑤ 双向链表与 list

双向链表很好理解,新增加一个前驱指针后,可以进行反向遍历,对应的操作的具体实现会有所不同,比如删除操作就不再需要围魏救赵了。下面直接给出代码。

 1 class List {
 2     int tot;
 3     Node *head;
 4 public:
 5     void insert(int o) {
 6         Node *node = new Node;
 7         node -> v = o;
 8         node -> nxt = head;
 9         head = node;
10         tot++;
11     }
12     void insert(int o, Node *p) {
13         Node *node = new Node;
14         node -> v = o;
15         node -> nxt = p -> nxt;
16         p -> nxt = node;
17         tot++;
18     }
19     void erase(Node *p) {
20         p -> v = p -> nxt -> v;
21         Node *node = p -> nxt;
22         p -> nxt = p -> nxt -> nxt;
23         tot--;
24     }
25     int size() {
26         return tot;
27     }
28     bool empty() {
29         return tot;
30     }
31 };

C++ 的 STL 中提供的是双向链表 list,功能相当丰富,提供的成员方法多达三十多种,除了上述几种操作之外,还有诸如:clear 清空链表,remove 删除特定值元素,sort 链表排序,unique 元素去重等等。

不同于 stack 和 queue 是容器适配器,list 就是容器,需要通过迭代器 iterator 访问元素,上面给出的单向和双向链表中其实都略过了查询操作,关于 STL 容器的介绍请参见:<施工中>,此处不深入解释。

⑤ 循环链表

链表同样可以循环,即链表头和链表尾相连。

⑥ 应用举例

> 图的存储结构之一——邻接链表,请参见:8.2 图的存储与遍历

> 解决哈希冲突的链地址法,请参见:7.2 哈希表

posted @ 2020-05-28 12:28  jinkun113  阅读(345)  评论(0编辑  收藏  举报