408答疑
文章目录
- 一、栈
- 四、参考资料
一、栈
1、栈(Stack)的概念和特点
定义
栈是一种特殊的线性表,其特点是只允许在一端进行插入或删除操作。
术语
- 栈顶(Top):允许进行插入和删除操作的一端。
- 栈底(Bottom):固定的,不允许进行插入和删除操作的另一端。
- 空栈:不含任何元素的空表。
操作特性
栈的操作特性可以明显地概括为后进先出(Last In First Out,LIFO)。这意味着最后进入栈的元素会最先被移除。
示例
假设某个栈 S = ( a 1 , a 2 , a 3 , a 4 , a 5 ) S = (a_1, a_2, a_3, a_4, a_5) S=(a1,a2,a3,a4,a5),则:
- a 1 a_1 a1 为栈底元素。
- a 5 a_5 a5 为栈顶元素。
- 入栈次序依次为 a 1 , a 2 , a 3 , a 4 , a 5 a_1, a_2, a_3, a_4, a_5 a1,a2,a3,a4,a5。
- 出栈次序为 a 5 , a 4 , a 3 , a 2 , a 1 a_5, a_4, a_3, a_2, a_1 a5,a4,a3,a2,a1。
入栈次序 : a 1 → a 2 → a 3 → a 4 → a 5 出栈次序 : a 5 → a 4 → a 3 → a 2 → a 1 \begin{align*} \text{入栈次序} & : a_1 \rightarrow a_2 \rightarrow a_3 \rightarrow a_4 \rightarrow a_5 \\ \text{出栈次序} & : a_5 \rightarrow a_4 \rightarrow a_3 \rightarrow a_2 \rightarrow a_1 \end{align*} 入栈次序出栈次序:a1→a2→a3→a4→a5:a5→a4→a3→a2→a1

直观理解
栈的先进后出结构可以用"喝多了吐"来形象地理解:最后喝的酒最先吐出来,即最后进入的元素最先被移除。
栈的基本操作
各种辅导书中给出的基本操作的名称不尽相同,但所表达的意思大致是一样的。这里我们以严蔚敏编写的教材为准给出栈的基本操作,希望读者能熟记下面的基本操作。
初始化栈
InitStack(&S)
:初始化一个空栈S
。
判断栈是否为空
StackEmpty(S)
:判断一个栈是否为空,若栈S
为空则返回true
,否则返回false
。
入栈操作
Push(&S, x)
:入栈,若栈S
未满,则将x
加入使之成为新栈顶。
出栈操作
Pop(&S, &x)
:出栈,若栈S
非空,则弹出栈顶元素,并用x
返回。
读取栈顶元素
GetTop(S, &x)
:读栈顶元素,但不出栈,若栈S
非空,则用x
返回栈顶元素。
销毁栈
DestroyStack(&S)
:销毁栈,并释放栈S
占用的存储空间("&"表示引用调用)。
在解答算法题时,若题干未做出限制,则也可直接使用这些基本的操作函数。
栈的数学性质
当 n n n 个不同元素入栈时,出栈元素不同排列的个数为 1 n + 1 C 2 n n \frac{1}{n+1} C_{2n}^n n+11C2nn。这个公式称为卡特兰数(Catalan)公式,可采用数学归纳法证明,有兴趣的读者可以参考组合数学教材。
2、栈的顺序存储结构
顺序栈的定义
顺序栈是栈的顺序实现,利用顺序存储结构(数组)实现的栈。栈顶指针 top
的初始化有两种形式:-1 和 0,这会影响入栈 push
和取栈顶元素 top
的操作实现。
栈顶指针初始化
- 初始设置
S.top = -1
:栈顶元素:S.data[S.top]
。- 入栈操作:栈不满时,栈顶指针先加 1,再送值到栈顶。
- 出栈操作:栈非空时,先取栈顶元素,再将栈顶指针减 1。
- 栈空条件:
S.top == -1
;栈满条件:S.top == MaxSize - 1
;栈长:S.top + 1
。
- 初始设置
S.top = 0
:入栈时先将值送到栈顶,栈顶指针再加 1;出栈时,栈顶指针先减 1,再取栈顶元素;栈空条件是S.top == 0
;栈满条件是S.top == MaxSize
。
注意事项
顺序栈的入栈操作受数组上界的约束,当对栈的最大使用空间估计不足时,有可能发生栈上溢,此时应及时向用户报告消息,以便及时处理,避免出错。
共享栈
利用栈底位置相对不变的特性,可让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸。

共享栈的操作
- 两个栈的栈顶指针都指向栈顶元素,
top0 = -1
时 0 号栈为空,top1 = MaxSize - 1
时 1 号栈为空;仅当两个栈顶指针相邻(top1 - top0 = 1
)时,判断为栈满。 - 当 0 号栈入栈时
top0
先加 1 再赋值,1 号栈入栈时top1
先减 1 再赋值;出栈时则刚好相反。
共享栈的优势
共享栈是为了更有效地利用存储空间,两个栈的空间相互调节,只有在整个存储空间被占满时才发生上溢。其存取数据的时间复杂度均为 O ( 1 ) O(1) O(1),所以对存取效率没有什么影响。
代码实操
静态顺序栈
结构定义
- 静态顺序栈使用固定大小的数组存储栈元素,栈顶指针 top 用于记录栈的当前状态
c
typedef struct SeqStack
{
ElemType data[MAX_SIZE]; // 栈空间,固定大小
int top; // 栈顶指针
} SeqStack;
初始化
- 将栈顶指针初始化为0,表示栈为空。
c
void initStack(SeqStack &pst)
{
pst.top = 0;
}
判空
- 通过判断栈顶指针是否为0来判断栈是否为空。
c
bool empty(SeqStack &pst)
{
return pst.top == 0;
}
入栈
- 将元素压入栈中,同时检查是否栈满。
c
void pushStack(SeqStack &pst, ElemType x)
{
if (pst.top >= MAX_SIZE) // 栈满时无法入栈
{
printf("空间已满, %d 不能入栈.\n", x);
return;
}
pst.data[pst.top] = x;
pst.top++;
}
出栈
- 移除栈顶元素,同时检查栈是否为空。
c
void popStack(SeqStack &pst)
{
if (empty(pst))
{
printf("栈已空,不能出栈.\n");
return;
}
pst.top--;
}
取栈顶元素
- 返回栈顶元素,但不移除。
c
int topStack(SeqStack &pst)
{
return pst.data[pst.top - 1]; // 返回栈顶元素
}
打印栈
- 从栈顶到栈底依次打印栈内元素。
c
void printStack(SeqStack &pst)
{
for (int i = pst.top - 1; i >= 0; --i)
printf("%d\n", pst.data[i]);
}
静态顺序栈使用固定大小的数组存储数据,操作简单,但无法动态扩展。适合栈大小已知且固定的应用场景。
动态顺序栈(小概率出现)
结构定义
- 动态顺序栈使用动态分配的数组存储栈元素,支持动态扩展。
c
typedef struct SeqStack
{
int *data; // 动态分配的栈空间
int top; // 栈顶指针
int maxsize; // 当前栈的最大容量
} SeqStack;
初始化
- 动态分配栈空间,并初始化栈顶指针和容量。
c
void initStack(SeqStack &pst, int size)
{
pst.data = (int *)malloc(sizeof(int) * size);
pst.top = 0;
pst.maxsize = size;
}
入栈
- 将元素压入栈中,同时检查是否栈满。
c
void push(struct seqstack *s, int value)
{
if (s->top < s->maxsize)
{
s->data[s->top] = value;
s->top++;
printf("Pushed %d onto the stack.\n", value);
}
else
{
printf("Stack is full. Cannot push %d.\n", value);
}
}
动态顺序栈通过动态分配内存,可以在运行时调整栈的大小,但需要手动管理内存分配和释放。
3、栈的链式存储结构
链栈概念
链栈是栈的链式实现,利用链式存储结构(链表)进行实现。使用链表实现栈结构,只允许在链表的一头(一般为表头)插入和删除。
链栈的优点
- 便于多个栈共享存储空间和提高其效率。
- 不存在栈满上溢的情况。
链栈的实现
通常采用单链表实现链栈,并规定所有操作都是在单链表的表头进行的。这里规定链栈没有头结点,Lhead
指向栈顶元素。

链栈的操作
- 入栈和出栈的操作都在链表的表头进行。
- 对于带头结点和不带头结点的链栈,具体的实现会有所不同。
注意事项
采用链式存储,便于结点的插入与删除。链栈的操作与链表类似,但需要注意的是,对于带头结点和不带头结点的链栈,具体的实现会有所不同。
代码实操
结点定义
- 链栈使用链表存储栈元素,每个结点存储一个元素和指向下一个结点的指针。
c
typedef struct LinkStackNode
{
int data; // 栈元素
struct LinkStackNode *next; // 指向下一个结点
} LinkStackNode, *LinkStack;
初始化
- 创建一个头结点,初始化链栈。
c
LinkStack initStack()
{
LinkStackNode *s = (LinkStackNode *)malloc(sizeof(LinkStackNode));
s->next = NULL;
return s;
}
判空
- 通过判断头结点的 next 指针是否为空来判断栈是否为空。
c
bool empty(LinkStack pst)
{
return pst->next == NULL;
}
入栈操作
- 将新元素插入到头结点的下一个位置,实现头插法。
c
void pushStack(LinkStack pst, int x)
{
LinkStackNode *s = (LinkStackNode *)malloc(sizeof(LinkStackNode));
s->data = x;
s->next = pst->next;
pst->next = s;
}
出栈操作
- 移除头结点的下一个结点,并释放内存。
c
void popStack(LinkStack pst)
{
LinkStackNode *p = pst->next;
pst->next = p->next;
free(p);
}
取栈顶元素
- 返回头结点的下一个结点的数据。
c
int topStack(LinkStack pst)
{
return pst->next->data;
}
打印链栈
- 从头结点的下一个结点开始,依次打印链栈中的元素。
c
void printStack(LinkStack pst)
{
LinkStackNode *p = pst->next;
while (p != NULL)
{
printf("%d\n", p->data);
p = p->next;
}
}
链栈使用链表实现,支持动态扩展,无需提前分配固定大小的内存。适合栈大小不确定或频繁变化的场景。
4、栈的应用
栈在表达式转换中的应用
表达式分类
- 前缀表达式
- 中缀表达式
- 后缀表达式
表达式划分规则
表达式的分类是按照运算符跟两个运算数的位置进行划分的:
- 前缀表达式: + a b +ab +ab(运算符在两个运算数的前面)
- 中缀表达式: a + b a+b a+b(运算符在两个运算数的中间)
- 后缀表达式: a b + ab+ ab+(运算符在两个运算数的后面)
表达式手动转换(最基础最重要)
中缀表达式转化为后缀表达式
步骤
- 按照运算符的运算顺序对所有运算单位加括号。
- 将运算符移至对应括号的后面,相当于按"左操作数右操作数运算符"重新组合。
- 去除所有括号。
示例
例如,中缀表达式 A + B ∗ ( C − D ) − E / F A+B*(C-D)-E/F A+B∗(C−D)−E/F 转后缀表达的过程如下(下标表示运算符的运算顺序):
- 加括号: ( ( A + ③ ( B ∗ ② ( C − ① D ) ) ) − ⑤ ( E / ④ F ) ) ((A+③(B*②(C-①D)))-⑤(E/④F)) ((A+③(B∗②(C−①D)))−⑤(E/④F))。
- 运算符后移: ( ( A ( B ( C D ) − ① ) ∗ ② ) + ③ ( E F ) / ④ ) − ⑤ ((A(B(CD)-①)*②)+③(EF)/④)-⑤ ((A(B(CD)−①)∗②)+③(EF)/④)−⑤。
- 去除括号后,得到后缀表达式: A B C D − ① ∗ ② + ③ E F / ④ − ⑤ ABCD-①*②+③EF/④-⑤ ABCD−①∗②+③EF/④−⑤。
表达式借助栈转换
运算符的优先级关系
θ 1 \theta _{1} θ1 \ θ 2 \theta _{2} θ2 | + | - | * | / | ( | ) | # |
---|---|---|---|---|---|---|---|
+ | > | > | < | < | < | > | > |
- | > | > | < | < | < | > | > |
* | > | > | > | > | < | > | > |
/ | > | > | > | > | < | > | > |
( | < | < | < | < | < | = | |
) | > | > | > | > | > | > | |
# | < | < | < | < | < | = |
表格隐含了左结合思想,所以是同级别符号中栈顶运算符大于当前运算符
- θ 1 \theta _{1} θ1为栈顶运算符, θ 2 \theta _{2} θ2为当前运算符
计算机利用栈将中缀表达式转化为后缀表达式的过程
步骤
- 手算(检验借助栈的答案正确否)。
- 借助一个栈和一个队列:操作符栈、结果栈。
- 表达式扫描顺序:
从左往右
扫描。 - 遇到操作数,直接输出到结果栈。
- 遇到运算符,则比较优先级:
- 若其优先级高于栈顶运算符或遇到栈顶为"(",则直接入栈;
- 若其优先级低于或等于栈顶运算符,则依次弹出栈中的运算符并输出到结果栈,直到遇到一个优先级低于它的运算符或遇到"("或栈空为止,之后将当前运算符入栈。
- 如果遇到括号,则根据括号的方向进行处理:
- 若为"(",则直接入栈;
- 若为")",则不入栈,且依次弹出栈中的运算符并输出到结果栈,直到遇到"("为止,并直接删除"("。
- 重复上述的3、4、5步骤,直到表达式扫描完毕。
- 扫描完成中缀表达式后,结果栈中所保留的数据则为后缀表达式。
示例
表达式: ( a + b ) ∗ c + d − ( e + g ) ∗ h (a+b)*c+d-(e+g)*h (a+b)∗c+d−(e+g)∗h
手算结果: a b + c ∗ d + e g + h ∗ − ab+c*d+ eg+h*- ab+c∗d+eg+h∗−





计算机利用栈将中缀表达式转化为前缀表达式的过程
步骤
- 手算(检验借助栈的答案正确否)。
- 借助一个栈和一个队列:操作符栈、结果栈。
- 表达式扫描顺序:
从右往左
扫描。 - 遇到操作数,直接输出到结果栈。
- 遇到运算符,则比较优先级:
- 若其优先级高于或等于栈顶运算符或遇到栈顶为")",则直接入栈;
- 若其优先级低于栈顶运算符,则依次弹出栈中的运算符并输出到结果栈,直到遇到一个优先级低于或等于它的运算符或遇到")"或栈空为止,之后将当前运算符入栈。
- 如果遇到括号,则根据括号的方向进行处理:
- 若为")",则直接入栈;
- 若为"(",则不入栈,且依次弹出栈中的运算符并输出到结果栈,直到遇到")"为止,并直接删除")"。
- 重复上述的3、4、5步骤,直到表达式扫描完毕。
- 扫描完成中缀表达式后,结果栈中所保留的数据则为前缀表达式。
示例
表达式: ( a + b ) ∗ c + d − ( e + g ) ∗ h (a+b)*c+d-(e+g)*h (a+b)∗c+d−(e+g)∗h
手算结果: − + ∗ + a b c d ∗ + e g h -+*+abcd *+egh −+∗+abcd∗+egh
栈在表达式求值中的应用
算术表达式
中缀表达式
中缀表达式(如 3 + 4 3+4 3+4)是人们常用的算术表达式,操作符以中缀形式处于操作数的中间。与前缀表达式(如 + 34 +34 +34)或后缀表达式(如 34 + 34+ 34+)相比,中缀表达式不容易被计算机解析,但仍被许多程序语言使用,因为它更符合人们的思维习惯。
括号的必要性
与前缀表达式或后缀表达式不同的是,中缀表达式中的括号是必需的。计算过程中必须用括号将操作符和对应的操作数括起来,用于指示运算的次序。
后缀表达式的特点
后缀表达式的运算符在操作数后面,后缀表达式中考虑了运算符的优先级,没有括号,只有操作数和运算符。
示例
中缀表达式 A + B ∗ ( C − D ) − E / F A+B*(C-D)-E/F A+B∗(C−D)−E/F 对应的后缀表达式为 A B C D − ∗ + E F / − ABCD-*+EF/- ABCD−∗+EF/−,将后缀表达式与原表达式对应的表达式树的后序遍历序列进行比较,可发现它们有异曲同工之妙。

表达式求值
中缀表达式求值
步骤
- 手算求值用于检验借助栈的答案是否正确。
- 需要借助两个栈结构
- 操作符栈
- 数据栈
- 表达式扫描顺序:从左往右扫描。
- 遇到操作数,将操作数压入数据栈。
- 遇到运算符,比较优先级:
- 如果当前运算符的优先级 > > > 栈顶运算符的优先级(当栈顶是括号时,直接入栈),则将运算符直接入栈。
- 如果当前运算符的优先级 < < < 栈顶运算符的优先级,则将栈顶运算符出栈,并将数据栈出栈,先出的为右值,后出的为左值,将运算之后的结果重新入到数据栈。
- 遇到括号,根据括号的方向进行处理:
- 如果是左括号,则直接入栈。
- 如果是右括号,则遇到左括号前将所有的运算符全部出栈,并将数据栈两个数出栈,将运算之后的结果重新入到数据栈,直到遇到左括号为止。
- 重复上述的3、4、5步骤,直至整个表达式扫描完成。
示例
中缀表达式求值,例如 ( 3 + 4 ) − 7 × 5 − 6 (3+4)-7\times 5 - 6 (3+4)−7×5−6。



后缀表达式求值
定义
后缀表达式又称逆波兰表达式,运算符位于操作数之后。
步骤
- 手算求值用于检验借助栈的答案是否正确。
- 只需借助一个栈:数据栈。
- 表达式扫描顺序:从左往右扫描。
- 如果遇到操作数,将操作数压入数据栈。
- 如果遇到运算符:
- 弹出栈顶的两个数,先出栈的为右数,后出栈的为左数。
- 做运算后将结果重新入栈。
- 重复步骤 3 和 4,直到表达式扫描完毕,则数据栈中保存的数据则为表达式的结果。
例题
例如, ( 3 + 4 ) − 7 × 5 − 6 (3+4) -7\times 5 - 6 (3+4)−7×5−6 对应的后缀表达式就是 3 4 + 75 × − 6 − 3\ 4\ +\ 75\ \times-\ 6\ - 3 4 + 75 ×− 6 −。




前缀表达式求值
与后缀表达式求值差不多,扫描方向相反即可
栈的深度分析
所谓栈的深度,是指栈中的元素个数,通常是给出入栈和出栈序列,求最大深度(栈的容量应大于或等于最大深度)。有时会间接给出入栈和出栈序列,例如以中缀表达式和后缀表达式的形式给出入栈和出栈序列。掌握栈的先进后出的特点进行手工模拟是解决这类问题的有效方法。
栈在括号匹配中的应用
概述
假设表达式中允许包含两种括号:圆括号和方括号,其嵌套的顺序任意,即 []()
或 [([][])]
等均为正确的格式,而 [(])
或 ([()])
或 (())
均为不正确的格式。
括号序列示例
考虑下列括号序列:

分析过程
- 计算机接收第 1 个括号
[
后,期待与之匹配的第 8 个括号]
出现。 - 获得了第 2 个括号
(
,此时第 1 个括号[
暂时放在一边,而急迫期待与之匹配的第 7 个括号)
出现。 - 获得了第 3 个括号
[
,此时第 2 个括号(
暂时放在一边,而急迫期待与之匹配的第 4 个括号]
出现。第 3 个括号的期待得到满足,消解之后,第 2 个括号的期待匹配又成为当前最急迫的任务。 - 以此类推,可见该处理过程与栈的思想吻合。
算法思想
- 初始设置一个空栈,顺序读入括号。
- 若是左括号,则作为一个新的更急迫的期待压入栈中,自然使原有的栈中所有未消解的期待的急迫性降了一级。
- 若是右括号,则或使置于栈顶的最急迫期待得以消解,或是不合法的情况(括号序列不匹配,退出程序)。算法结束时,栈为空,否则括号序列不匹配。
代码实操
- 给定一个只包括'(',')','{','}','[',']'的字符串 s,判断括号字符串是否有效。
有效字符串需满足:- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 每个右括号都有一个对应的相同类型的左括号。
- 示例:
- 输入:
"()"
输出:true
- 输入:
"()[]{}"
输出:true
- 输入:
"(]"
输出:false
- 输入:
"([)]"
输出:false
- 输入:
"{[]}"
输出:true
- 输入:
c
//判断字符串中的括号是否匹配,利用链栈存储左括号。
bool isValid(char *s)
{
LinkStack st = initStack();
while (*s != '\0')
{
if (*s == '{' || *s == '[' || *s == '(') // 左括号入栈
pushStack(st, *s);
else
{
if (empty(st)) // 右括号但栈为空,不匹配
return false;
char topval = topStack(st); // 取栈顶元素
if ((*s == '}' && topval != '{') || (*s == ']' && topval != '[') || (*s == ')' && topval != '('))
return false;
popStack(st); // 匹配成功,出栈
}
s++;
}
return empty(st); // 栈为空则匹配
}
利用栈存储左括号,遇到右括号时检查栈顶元素是否匹配。最终栈为空则表示括号匹配。
栈在递归中的应用
递归的定义
递归是一种重要的程序设计方法。简单来说,若在一个函数、过程或数据结构的定义中又应用了它自身,则这个函数、过程或数据结构称为是递归定义的,简称递归。
递归的特点
递归通常把一个大型的复杂问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的代码就可以描述出解题过程所需要的多次重复计算,大大减少了程序的代码量。但在通常情况下,它的效率并不是太高。
斐波那契数列的递归定义
以斐波那契数列为例,其定义为:
F ( n ) = { F ( n − 1 ) + F ( n − 2 ) , n > 1 1 , n = 1 0 , n = 0 F(n) = \begin{cases} F(n-1) + F(n-2), & n > 1 \\ 1, & n = 1 \\ 0, & n = 0 \end{cases} F(n)=⎩ ⎨ ⎧F(n−1)+F(n−2),1,0,n>1n=1n=0
递归模型的条件
必须注意递归模型不能是循环定义的,其必须满足下面的两个条件:
- 递归表达式(递归体)。
- 边界条件(递归出口)。
递归的精髓
递归的精髓在于能否将原始问题转换为属性相同但规模较小的问题。
栈在函数调用中的作用和工作原理
在递归调用的过程中,系统为每一层的返回点、局部变量、传入实参等开辟了递归工作栈来进行数据存储,递归次数过多容易造成栈溢出等。而其效率不高的原因是递归调用过程中包含很多重复的计算。
递归调用执行过程
下面以 n = 5 n=5 n=5 为例,列出递归调用执行过程:

显然,在递归调用的过程中, F ( 3 ) F(3) F(3) 被计算 2 次, F ( 2 ) F(2) F(2) 被计算 3 次。 F ( 1 ) F(1) F(1) 被调用 5 次, F ( 0 ) F(0) F(0) 被调用 3 次。所以,递归的效率低下,但优点是代码简单,容易理解。
递归算法转换为非递归算法
可以将递归算法转换为非递归算法,通常需要借助栈来实现这种转换。
进制转换
代码实操
- 编写函数实现,将一个十进制整数value,转换为对应的二进制。
c
//将十进制数转换为二进制数,利用栈存储中间结果。
void Dec2Bin(int value)
{
int stack[200] = {0};
int top = 0;
while (value)
{
stack[top++] = value % 2; // 计算余数并入栈
value /= 2; // 更新值
}
while (top)
printf("%d", stack[--top]); // 逆序输出栈内容
}
利用栈的后进先出特性,存储每次除法的余数,最后逆序输出即为二进制结果。
出栈的顺序判断
代码实操
- 给出入栈序列 In = [6,7,8,9,10,11],出栈序列 Out = [9,11,10,8,7,6],判断出栈序列是否是入栈序列的一种出栈可能性。
c
//判断给定的出栈序列是否合法,利用链栈模拟入栈和出栈过程。
bool isValidStackSeq(ElemType pushed[], ElemType popped[], int n)
{
LinkStack st = initStack();
int i = 0, j = 0;
while (i < n)
{
if (pushed[i] != popped[j])
{
pushStack(st, pushed[i]); // 不匹配时入栈
i++;
}
else
{
i++;
j++;
while (!empty(st) && topStack(st) == popped[j]) // 匹配时出栈
{
popStack(st);
j++;
}
}
}
return empty(st); // 栈为空则表示出栈序列合法
}
通过模拟入栈和出栈操作,判断给定的出栈序列是否与入栈序列匹配。栈为空时说明出栈序列合法。
四、参考资料
鲍鱼科技课件
b站免费王道课后题讲解:
网课全程班: