栈和递归

4.1. 栈
在前面的课程里,我们已经学习过 vector 、 set 、 map 、图和树这几种数据结构。
在这一节课程,我们将学习另外一种特殊数据结构——栈(stack)。
栈,是一种满足一定约束的线性数据结构。其约束是:只允许在栈的一端插入或删除元素,这一端被
称为 栈顶;相对地,我们把另一端称为 栈底。
可以想像一下往子弹夹中装入子弹的情形,正常情况下,只能往子弹夹入口端压入子弹,这一步就好
比向栈中压入元素,我们称之为 push ;射击的时候,弹夹会从顶端弹出子弹,这一步就好比从栈顶弹
出元素,我们称之为 pop 。
可以发现,从栈的顶端弹出的子弹是目前弹夹中最后一个被压入的子弹,这也体现了栈的另一个重要
性质——先进后出:越早进入栈的元素,出来的时间越晚。
为了方便,我们通常用一个 top 来指示栈顶的位置。
下图演示了栈的 push 和 pop 的过程。
在这里插入图片描述
这一节,我们动手来实现栈这个数据结构。为了便于代码复用和调试,我们通常会把数据结构封装起
来——将栈写成一个 class 或 struct ,将当前栈的数据和对当前栈的操作都放在里面。
我们接下来定义一个结构体 Stack ,并规定这个栈最大能储存的元素个数为 ,然后定义用来储
存数据的数组 int data[10000] 。因为我们把数组定义成了 int ,那么这个栈自然只能储存整数了。然
后,用一个 top 来指示现在栈顶的下标。而栈底元素,我们直接默认其下标为 。初始的时候,栈中
没有元素, top 的值为 。
初始的栈就像下面这样:
在这里插入图片描述
在 main 函数的上面写下:

struct Stack {
int data[10000];
int top = ‐1;
};

这个栈,我们需要实现它的两种基本操作 push 和 pop 。
首先,我们来实现 push 。 push 操作是把一个 int 类型的数据压入栈中,那么这个 push 的参数是一
个 int 。将一个元素压入栈很简单,我们先让栈顶的位置 top 加 ,然后把插入的值赋值给栈顶位置
即可。
在这里插入图片描述
10000
0
−1
1

在结构体定义的数据下面写下:

void push(int x) {
top++;
data[top] = x;
}

但是上面的代码有个问题,有可能当前栈中的元素太多,再插入元素就会导致数组访问越界了,这就
是所谓 栈溢出(stack overflow)。因此在这里,我们需要特殊处理一下,如果栈已经满了导致无法
入栈,不对栈进行修改,并恢复栈顶指针 top 的值。
刚才的代码修改如下:

void push(int x) {
top++;
if (top < 10000) {
data[top] = x;
} else {
top‐‐;
cout << "stack overflow" << endl;
}
}

这一步我们来实现出栈 pop 操作。 pop 操作也很简单,只需要让栈顶指针减少 即可。注意,如果栈
本身就是空的,就不要对栈任何操作了。
在 push 函数后面继续写下:

void pop() {
if (top >= 0) {
top‐‐;
}
}

1
栈除了 push 和 pop 这两个比较重要的操作,还有另外一个操作——获取当前栈顶元素的值。对于这个
操作,我们只需访问 data[top] 就可以了。
一定要注意哦,这里可能会出现数组下标越界的情况。如果下标越界,我们什么都不返回,让系统随
机返回一个数吧(当然,之后大家在使用栈的时候一定要确保,获取栈顶元素的时候栈不能为空)。
在 pop 函数下面写下:

int topval() {
if (top >= 0) {
return data[top];
}
}

我们的栈已经写好了,现在我们尝试用我们定义好的 Stack 来完成一些有趣的工作。我们先定义一
个 Stack 类型的变量,然后依次把 到 这十个整数压入栈中。
在 main 函数里面写下:

Stack s;
for (int i = 1; i <= 10; i++) {
s.push(i);
}

然后,我们依次把栈中的元素弹出来,弹出钱先然后输出它们。
在 main 函数里面继续写下:

for (int i = 1; i <= 10; i++) {
cout << s.topval() << " ";
s.pop();
}

这一节已经完成,点击运行,你会发现输出和输入是反着的,这也是栈的先进后出的性质体现。

4.2. 标准库的栈
就像, vector 、 map 、 set 一样,在 C++ 的标准库中也已经有了 stack 的实现,功能齐全且易于使
用。
标准库里面的 stack 在头文件 里面,它的定义和 map 、 set 、 vector 都大同小异,如果你对
前面的标准库已经使用得很熟练了,那么对于 stack 的使用你也会一目了然。 stack s 定义了一个
储存 T 类型数据的栈 s 。
标准库的栈除了支持 push() , pop() 等基本操作以外,还支持 top() 来获取栈顶元素、 empty() 判断
栈是否为空、 size() 计算栈中元素的个数。

#include <stack>
#include <iostream>
using namespace std;
int main() {
stack<string> s;
s.push("123456");
s.push("aaaaa");
s.push("bbbbbb");
while (!s.empty()) {
cout << s.top() << endl;
s.pop();
}
return 0;
}

这份代码对栈的使用做了示例,有了前面课程的基础,相信你很快就能看懂了。

栈 stack 的方法总结如下:

方法 功能 参数类型 返回值类型
方法 功能 参数类型 返回值类型
push 压入元素到栈顶 T 类型 无
pop 弹出栈顶元素 无 无
top 返回栈顶元素 无 T 类型
empty 栈是否为空 无 bool 类型: false 表示不为空, true 表示栈为空
size 栈的元素个数 无 非负整数( size_t 类型)

【小练习】栈的性质
选出下列关于栈的正确的选项。
栈是一种只能在一端进行操作的线性结构。
栈是一种先进先出的数据结构。

4.3. 栈的应用
4.3.1. 应用 1:火车出入站
在火车调度站里,我们可以借助类似下图中的轨道,将火车车厢的顺序进行调整。我们都知道,火车
的车厢是一节一节的,每一节都可以与前后分离,成为单独的一节。
在这里插入图片描述
这样的设计加上上面的轨道,通过合理的控制,我们就可以调节车厢之间的顺序了。
比如,进站前车厢的顺序是 。我们让车厢依次进入下方纵向的铁轨(实际上这里,纵向的
铁轨就是一个“栈”),然后再依次出站,这样车厢的顺序就变成了 。
在这里插入图片描述
或者我们让 先入站,然后 出站,然后 入站,然后让 出站,然后让 进站,然后让
出站,最后车厢的顺序变成了 。
在这里插入图片描述
但是并不是所有的出站顺序都是合法的。现在对于一种我们期望的出站顺序,作为火车站长,你需要
知道这个顺序是否是合法的。在后面的课程里我们将着手解决这个问题。

【实践操作】火车出入站
在这一节,我们要动手解决火车出入站的问题。
首先,我们来对问题进行抽象。抽象是我们在今后解题过程中常用的技能,指的是把一个实际的问题
转换成我们学过的的问题。
给定 个数的排列 和一个栈, 个值的入栈顺序为 ,判断出栈顺序是否可以是排列 。
我们首先来把数据读入,首先输入一个整数 ,然后输入 个整数,用一个 vector 来存储排列 。
在 main 函数里面写下:

int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

这里用到了 vector ,不要忘记了在 #include 下面加上 vector 对应的头文件:
#include
我们用一个栈 s 来记录当前栈的信息,初始的时候栈为空。
第一个出栈的元素是 ,由于我们规定了进栈顺序,所以我们必须依次把 到 这些元素压入到
栈中,然后让 出栈。
接下来,对于第二个出栈的元素 ,由于我们只能对栈顶进行操作,如果当前栈顶元素不等于
,我们就必须接着往栈中压入元素,直到找到了 ,然后把 这个元素 pop 掉。如果所有元
素都已经被压入栈中,都不能让 正确地出栈,说明这组出栈序列是不合法的。
之后对于 ,采用类似 的方法进行处理,以此类推。
你应该已经发现,对每个元素的处理逻辑都是一样。
根据上面的思路,我们开始实现算法部分的代码。首先,定义一个栈 以及一个用来记录位置的变量
,这个 变量用来记录当前还没有压入栈中的元素的起始位置,也就是说 到 都已经
进过栈了。然后定义一个用来记录当前出栈序列是否合法的变量 。
在 main 函数里面继续写下:

stack<int> s;
int cur = 1;
bool f = 1;

不要忘记在 #include 之后加上头文件:
#include
按照我们算法的思路,现在我们需要从第一个需要出栈的元素开始,依次进行模拟。对于枚举的 个
,如果栈顶不等于 ,就一直向栈顶 push 元素。
先在上一步写下的代码后面继续写下

for (int i = 0; i < n; i++) {
while (s.top() != a[i]) {
s.push(cur);
cur++;
}
}

仔细分析发现,上面的代码虽然逻辑上是正确的,但是还存很多缺陷。

  1. 如果 为空, s.top() 会出现错误,程序会因此而异常中止。一定要记住,访问栈之前一定要确
    保栈不为空。对于目前写出的代码,我们可以改成 while (s.empty() || s.top() != a[i]) ,注
    意 s.top() != a[i] 一定要在 s.empty() 后面,因为如果 s.empty() 为真,那么后
    面 s.top() != a[i] 就不会再计算了,所以就不会出现错误。

  2. 当 很小的时候, 一直增加也不可能满足 s.top() == a[i] ,这个循环会一直跑下去,实
    际上这时候就是不合法的情况了。所以我们必须限制 cur <= n ,那么现在, while 后面的判断需
    要改成
    while ((s.empty() || s.top() != a[i]) && cur <= n)
    注意运算符优先级的问题,一定要在前面的 || 运算式两边加上括号哦。
    改完以后,刚才的代码应该变成下面这样了:

     for (int i = 0; i < n; i++) {
     while ((s.empty() || s.top() != a[i]) && cur <= n) {
     s.push(cur);
     cur++;
     }
     }
    

这时候,如果仍然 s.top() != a[i] ,那么说明这个顺序是不可能的。在 while 循环外面继续写

if (s.empty() || s.top() != a[i]) {
f = 0;
break;
} else {
s.pop();
}

注意,上面的代码在访问栈之前同样还是要判断一下是否空。 break 是因为一旦出现了不合法的情
况,后面的就都不用判断了,我们也就不用进行后面的计算了。
最后,我们输出一下结果,在 main 函数里面的 for 循环下面写下:

if (f) {
cout << "legal" << endl;
} else {
cout << "illegal" << endl;
}

这一节已经完成,运行以后分别输入下面两组数据试试吧。
5
1 3 2 5 4
5
1 5 3 2 4
4.3.2. 应用 2:括号匹配
括号匹配是这样一个问题:给定一个包含若干小括号的表达式,判断表达式中的括号是否是正确匹配
的。一个 ( 只能唯一匹配一个 ) 。
括号匹配问题的判断方法有很多,其中最方便的还是用栈来判断。扫描一遍字符串,当遇到 ( 的时
候,压入栈;当遇到 ) 的时候,从栈中弹出一个 ( 。如果栈为空无法弹出元素,说明不合法。最后,
如果栈中还有多余的括号也不合法。
在稍后的练习题中,你将要动手解决这样的问题。
4.4. 递归
递归是计算机编程中应用最广泛的一个技巧,也是比较难理解的一个技巧,所以我们打算花大量的时
间来理解递归。所谓递归,就是函数调用函数 自身 ,一个函数在其定义中有直接或者间接调用自身都
叫递归。而递归一般都用来解决有重复子问题的问题。
我们先来理解直接递归,间接递归非常复杂,用的比较少。下面通过求解 ( 代表阶乘)的问题来
理解直接递归。我们知道 ,所以我们很容易写下下面的代码。

int factorial(int n) {
return n * factorial(n ‐ 1);
}

我们定义 int factorial(int n) 函数的功能是返回 。如果你在心里模拟一遍 factorial(1) 的运行
过程,你会发现函数好像会无限的调用下去。这样引申出递归一个难点——边界条件。所谓边界条
件,就是在什么情况下,函数不应该再继续调用自身了。
那么很显然,按照阶乘的定义 factorial 函数对应的边界条件是 n = 1 ,如果 n = 1 ,函数应该立即返
回 。

int factorial(int n) {
if (n == 1) {
return 1;
}
return n * factorial(n ‐ 1);
}

为了更通俗易懂地理解,看下面这段代码。

int factorial_1() {
return 1;
}
int factorial_2() {
return 2 * factorial_1();
}
int factorial_3() {
return 3 * factorial_2();
}

调用 factorial(3) 和 factorial_3() 会得到同样的结果。你会发现, factorial(3) 递归的过程
和 factorial_3() 调用的过程是一模一样,只是 factorial(3) 调用的是自己,这也就是所谓的递
归。 factorial_2() 相当于调用 factorial(2) 。如果你调用的 n 更大, 你会发现,写出
的 factorial_n() 都具有一样的形式,所以我们可以只写一个函数,然后自己调用自己,实现递归。
在递归中我们经常用深度来表示当前递归所在的层数。调用 factorial(5) 的时候层次关系如下图。
在这里插入图片描述

4.4.1. 斐波那契数列
斐波那契数列的定义是:
这个数列中的元素分别为 。
如果用递归实现 Fibonacci 数列的计算,按照定义可以很简单地写出代码实现:

int fib(int n) {
if (n == 1 || n == 2) {
return 1;
}
return fib(n ‐ 1) + fib(n ‐ 2);
}

用递归的方式计算斐波那契数列:计算第 n 项时,需要先计算出第 n ‐ 1 项和第 n‐2 项;接下来我们
去计算第 n‐1 项,很快我们意识到,第 n‐2 项又会被重新算一次;可以预感到,前面的若干项将会重
复计算多次,当n很大时,我们的程序可能就超时了,请思考:
能不能每一项最多只算一次?
可以有什么办法来实现?
在这里插入图片描述
4.5. 栈和递归
大家想一想,递归和栈有什么关系?
如果禁止你写递归函数,请问有没有什么办法实现递归的操作?(你可以尝试换一种方式写一写汉
诺塔)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值