本文是 “408数据结构” 的复习笔记中“线性表、栈、队列”的部分,主要依据王道的课本,考408的小伙伴可以拿走原笔记。
有错误的地方还请各位留言指出,谢谢啦(ง •_•)ง
按照王道数据结构的章节目录,本篇文章有以下【三】章
- 基础知识
- 线性表
- 栈与队列
目录
基础
学习工具
可以使用这个可交互的数据结构学习网页
时间复杂度
算法中基本运算的频度 f(n) 来分析时间复杂度
Q:什么是 “基本运算”?
A:最坏和最好描述的运算对象
不仅与问题规模有关,还与代输入数据的性质(初始状态)有关,根据不同的初始状态,可将算法时间复杂度分为以下三类:
- 最好时间复杂度
- 平均时间复杂度
- 最坏时间复杂度:一般考虑这种情况
常见复杂度比较:
O ( 1 ) < O ( l o g 2 n ) < O ( n ) < O ( n l o g 2 n ) < O ( n 2 ) < O ( n 3 ) < O ( 2 n ) < O ( n ! ) < O ( n n ) O(1)<O(log_2n)<O(n)<O(nlog_2n)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n) O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)
【2013】将两个长度分别为 m m m 和 n n n 的升序链表合并成一个长度为 m + n m+n m+n 的降序列表,最坏时间复杂度为
A:O(max(m, n))
B:O(min(m, n))
B:合并算法:两条链表的元素两两比较,最坏的是两条链中的元素依次比较{2,3,4,5}和{1,6}
【p11】思考斐波那契递归算法(递推公式 F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n) = F(n-1) + F(n-2) F(n)=F(n−1)+F(n−2))的时间复杂度
O ( 2 n ) O(2^n) O(2n):可以想象成一个二叉树,每个父节点 F ( n ) F(n) F(n)都有两个子节点 F ( n − 1 ) F(n-1) F(n−1)和 F ( n − 2 ) F(n-2) F(n−2)。
空间复杂度
算法原地工作:算法需要的辅助空间为常量O( 1 1 1)
考试时花费大量时间思考一个问题的最优解是得不偿失的, O ( n ) O(n) O(n)的算法能拿11分的话, O ( n 2 ) O(n^2) O(n2)的算法能拿10分
线性表
线性表是一种逻辑结构,其对应的存储结构有
- 顺序表(数组)
- 链表
顺序表(数组)
地址连续的存储单元
// 静态分配
#define MaxSize 50 //最大容量
typedef struct{
Type data[MaxSize];
int Length; //当前长度
} SqList
// 动态分配
#define InitSize 100
typedef struct{
Type *data;
int MaxSize, Length; //最大容量 和 当前长度
} SeqList
插入
输入:
- i(表示第 i 个位置插入)
- e(要插入的新元素)
过程:
- 先判断是否合法( i 有无越界,数组长度有无限制等)
- 将第 i 个以及之后的所有元素后移一位
- 第 i 个位置放入 e
- Length += 1
删除
输入:
- i(表示删除第 i 个元素)
过程:
- 先判断是否合法( i 有无越界)
- 将第 i 个以及之后的所有元素前移一位
- Length -= 1
查找
输入:e(查找元素值等于 e 的元素
过程:
- 遍历查找
【2010-循环左移】,一个序列 a 0 , a 1 , a 2 , … , a n a_0,a_1,a_2, … ,a_n a0,a1,a2,…,an,要将此序列循环左移 p p p 位 ( 0 < p < n ) (0<p<n) (0<p<n),得到 a p , a p + 1 , a p + 2 , … , a n , a 0 , a 1 , a 2 , … , a p − 1 a_{p}, a_ {p+1},a_{p+2}, … ,a_n, a_0, a_1, a_2, … ,a_{p-1} ap,ap+1,ap+2,…,an,a0,a1,a2,…,ap−1 ,设计一个循环左移算法分析时间和空间复杂度
参考思路:将原串分成两个字串【0,p-1】和【p,n】,记将【1,2,3】反转为【3,2,1】的操作为Reverse,则 Reverse【Reverse【0,p-1】,Reverse【p,n】】即为循环左移的结果
void Reverse(int R[], int from, int to){
int temp;
while(from < to){
temp = R[from];
R[from] = R[to];
R[to] = temp;
from ++;
to --;
}
}
void Converse(int R[], int n, int p){
Reverse(R, 0, p-1);
Reverse(R, p, n-1);
Reverse(R, 0, n-1);
}
时间复杂度为 O ( p / 2 ) + O ( ( n − p ) / 2 ) + O ( n / 2 ) = O ( n ) O(p/2)+O((n-p)/2)+O(n/2)=O(n) O(p/2)+O((n−p)/2)+O(n/2)=O(n)
空间复杂度为 O ( 1 ) O(1) O(1)
【2011-两个等长有序数组的中位数】即长度为 L 的数组中处在 L/2 位置的数称为“中位数”,如:
(11,13,15,17,19)中位数为 15
(2,4,6,8,11,13,15,17,19,20)中位数为 11
现有两个等长序列 A 和 B,设计一个算法找出 AB 合并后有序数列的“中位数”
参考思路:二分思维,记 A、B 的 “中位数“ 分别为 a、b,LA、RA分别为 A 被 a 分成的左右两部分,同理也有 LB、RB。
① 若 a=b ,则 a,b 都是所求的”中位数“
② 若 a<b ,则在 RA 和 LB 中找”中位数“
③ 若 a>b ,则在 LA 和 RB 中找”中位数“
参考代码:p25
时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)
空间复杂度为 O ( 1 ) O(1) O(1)
【扩展练习】两个不一定等长有序数组的中位数
链表
单链表
头结点:单链表的第一个节点前附加的节点,头结点数据域可以不设信息,也可以记录表长等相关信息
表头:如果带头结点的链表,头结点就是表头;不带头结点,表头就是第一个数据结点
头指针:指向表头
建立
- 头插法:新节点插入到头结点之后,作为第一个数据结点
- 尾插法:需要增加一个指向表尾的指针,每次新节点加到末尾,更新尾指针
双链表
比单链表多了一个前驱指针
循环链表
循环单链表:将单链表中最后一个结点的指针指向头节点,在操作尾结点时要注意维持这一 ”环“ 的特性
循环双链表:… …
静态链表
用数组实现的链表
数组下标 | 0(头结点) | 1 | 2 | 3 | 4 | |
---|---|---|---|---|---|---|
数据 | b | a | d | c | ||
下一结点的下标 | 2 | 5 | 1 | -1(尾结点) | 3 |
栈和队列
栈
栈是一种加了约束的线性表
栈顶(Top):允许出栈(删除)入栈(插入)的一端
栈底(Bottom):最先进来的,最后出去的那个崽
基本操作:
void InitStack(&S); // 初始化一个空栈
bool IsEmpty(S); // 判断是否为空
void Push(&S, x); // 若S未满,将元素x从栈顶放入
void Pop(&S, &x); // 若S非空,将栈顶元素从栈顶拿出,并赋值给x
void GetTop(S, &x); // 若S非空,读取栈顶元素,赋值给x
void Clear(&S); // 销毁栈
顺序栈
用数组实现的栈,连续内存
#define MaxSize 50
typedef struct{
Type data[MaxSize];
int top; // 栈顶指针,初始化或空栈时为-1,有内容时从0开始
} SqStack;
顺序栈内元素的个数 = S.top + 1
共享栈
两个栈在一块连续的内存,栈底分别在两端,栈顶向中间延申
#define MaxSize 50
typedef struct{
Type data[MaxSize];
int top0; // 0号栈的栈顶指针,初始化或空栈时为 -1
int top1; // 1号栈的栈顶指针,初始化或空栈时为 MaxSize
} GayStacks;
当 top1 - top0 = 1 时,栈满了
链栈
采用无头结点的单链表实现,规定表头(第一个节点)为栈顶
【2013-进出栈分析】一个栈入栈先后顺序为 1 , 2 , 3 , 4 , … , n 1,2,3,4,… ,n 1,2,3,4,…,n,出栈先后顺序为 P 1 , P 2 , P 3 , P 4 , … , P n P_1,P_2,P_3,P_4,…,P_n P1,P2,P3,P4,…,Pn,若 P 2 = 3 P_2=3 P2=3 ,则 P 3 P_3 P3 可能的取值有____种
显然,从4开始往后的 n − 3 n-3 n−3 个数都行, P 1 = 1 P_1=1 P1=1 的话, P 3 P_3 P3 就可以为 2;同理 P 1 = 2 P_1=2 P1=2 的话, P 3 P_3 P3 就可以为 1。
所以答案是 n − 1 n-1 n−1种
队列
操作受限的线性表
队头:崽子们从这里出队
队尾:崽子们从这里入队
基本操作
void InitQueue(&Q); // 初始化一个空队
bool IsEmpty(Q); // 判断是否为空
void EnQueue(&Q, x); // 若Q未满,将元素x入队,成为队尾
void DeQueue(&Q, &x); // 若Q非空,将队首的崽拎出来,并赋值给x
void GetHead(Q, &x); // 若Q非空,读取队首元素,赋值给x
顺序队列
用数组实现的队列,连续存储
#define MaxSize 50
typedef struct{
Type data[MaxSize];
int front; // 队头,初始化 = 0,有崽子出队时,先取值,再+1,
int rear; // 队尾,初始化 = 0,有崽子入队时,先赋值,再+1。
// 所以队尾处始终为 null
} SqQueue;
缺点是:数组空间用完一遍后,front 和 rear 都跑到 MaxSize 去了,出队时释放的空间不能再用了
改进👇
循环队列
将数组逻辑上视为一个”环“,当 rear 因为元素入队跑到 MaxSize - 1 时,再入队一个崽,rear就取余运算变成 0,重新回到存储空间的开始位置
入队赋值后:Q.rear = (Q.rear + 1) % MaxSize
出队取值后:Q.front = (Q.front + 1) % MaxSize
队列长度:Length = (Q.rear - Q.front + MaxSize) % MaxSize
真·队满和队空时都有 Q.front == Q.rear
,所以仅凭判断首尾相等是区分不了队满和队空的,需要一些措施
- 将 ”队满“ 定义为 ”假·队满“ 即
Q.front == (Q.rear + 1) % MaxSize 假·队满 : 未满
,当队尾快要超圈,但还差一个单元时,就认为队列已满 - 在循环队列的类中添加一个成员变量 size 记录崽子个数
- 在循环队列的类中添加一个成员变量 tag 来记录
Q.front == Q.rear
的原因是删除还是增加
链式队列
一个单链表,头指针指向的是队头,尾指针指向的是队尾
typedef struct{
Type data;
struct Node *next;
} Node;
typedef struct{
Node *front, *rear; // 队列的队头和队尾,初始化的时候都指向头结点,如果两者相等,则队列为空
} LinkQueue
通常这个单链表会设置一个头结点,Q.front 指向这个头结点,出队的是第一个数据结点,即头结点的下一个Q.front->next
被拎出去
双端队列
两端都可以入队和出队
给一个入队序列,要会判断哪些序列是可能的出对序列。
- 输出受限的双端队列:两端都能入队,只有一端能出队
- 输入受限的双端队列:两端都能出队,只有一端能入队
栈和队列的应用
括号匹配&表达式求值
表达式有以下三种不同记法
- 中缀表达式:1 + (2 + 3) × 4 - 5
- 前缀表达式:- + 1 × + 2 3 4 5
- 后缀表达式:1 2 3 + 4 × + 5 -
人的大脑很容易理解与分析中缀表达式,但计算机更容易计算前缀或后缀表达式。
并且注意到只有中缀表达式是有括号来标志嵌套运算优先级的,前缀和后缀表达式并不需要括号就能处理。
所以计算表达式的值时,通常需要先将中缀表达式转换为前缀或后缀表达式,然后再进行求值。
【Q1】看看你的电脑如何处理后缀表达式:1 2 3 + 4 * +5 –
- 首先一个一个读入,是数字就压入栈(假设栈顶在右边,从右边入栈)中
1 2 3 // 读入了三个,全是数字,依次压栈
- 读到了一个运算符号 + ,关键点来了!
这个 + 号告诉你现在要做一个加法,那么哪两个数字相加呢?从栈里Pop出两个崽子啊!
于是你从栈里 Pop 出了 3 和 2,并做了一个加法 2 + 3 = 5 2+3=5 2+3=5 ,得到的结果再压回栈中。
1 5 // 得到的 5 压回栈中,所以之前的 ‘2’,‘3’,’+’ 三个符号已经完成了它们的使命
- 再往下读到 4,压进去
1 5 4
- 再读到运算符号 * ,同样的操作方式,抓两个壮丁过来相乘,得到的结果压回去
1 20 // 5 × 4 = 20,20压回栈中
- 再一个运算符 +,咳咳
21 // 1 + 20 = 21,21压回栈中
- 一个数字 5,一个运算符 -
16 // 21 - 5 = 16
最终结果 16,
ps:这里最后一步的减法为什么是 21 - 5 而不是 5 - 21。再模拟一下读取到运算符(记为 o)的时候进行的操作:抓(从栈顶抓)两个壮丁,先抓到的叫A,后弹出的叫B,那么进行的运算是 B o A = Result,这个Result 压回栈
【Q2】如何进行各种缀的表达式之间的转换呢?
中缀 → 后缀
运算符优先级:
运算符 | ( | × ÷ | + - | ) |
---|---|---|---|---|
进栈后优先级 | 1 | 5 | 3 | 6 |
进栈前(刚扫描)的优先级 | 6 | 4 | 2 | 1 |
算法:
- 扫描到数字,直接添加到后缀表达式
- 扫描到的运算符 a 的优先级 > 栈顶运算符 top 的优先级:a 进栈,扫描下一个
- 否则弹出 top,并将 top 添加到后缀表达式
- 然后再比较新的栈顶 newtop 和 a 的优先级,回到上两步
以下是样例代码
#include <iostream>
#include <string>
#include <stack>
#include <algorithm>
using namespace std;
bool isOperator(char ch)
{
string operators = "+-*/";
return operators.find(ch) != operators.npos;
}
bool isOperand(char ch)
{
if (ch >= '0' && ch <= '9')
{
return true;
}
else if (ch >= 'a' && ch <= 'z')
{
return true;
}
else if (ch >= 'A' && ch <= 'Z')
{
return true;
}
return false;
}
int calculate(char op, int x2, int x1)
{
switch (op)
{
case '-':
return (x2 - x1);
case '+':
return (x2 + x1);
case '*':
return (x2 * x1);
case '/':
return (x2 / x1);
}
}
// 遍历栈
string stackToStr(stack<char> S)
{
stack<char> temp = S;
int i = 0;
string stack = "";
while (i < S.size())
{
stack += temp.top();
temp.pop();
i++;
}
reverse(stack.begin(), stack.end());
return stack;
}
// 计算后缀表达式的值
int countPostfix(string postfix)
{
stack<int> s;
for (int i = 0; i < postfix.length(); i++)
{
if (postfix[i] >= '0' && postfix[i] <= '9')
s.push(postfix[i] - '0');
else
{
int x1 = s.top();
s.pop();
int x2 = s.top();
s.pop();
s.push(calculate(postfix[i], x2, x1));
}
}
return s.top();
}
// 转换成后缀表达式
string toPostfix(string infix)
{
// 存放运算符的栈
stack<char> ops;
// 后缀表达式的字符串
string postfix = "";
for (int i = 0; i < infix.length(); i++)
{
// cout << endl << "current char: " << infix[i] << endl;
char c = infix[i];
// 是数字,直接加在后缀表达式后面
if (isOperand(c))
postfix = postfix + c;
// 是 右括号 ,把栈顶一直弹出,直到左括号
else if (c == ')')
{
while (ops.top() != '(')
{
postfix = postfix + ops.top();
ops.pop();
}
ops.pop();
}
// 遇到 +,- 因为是优先级最低的,所以把栈顶的一直弹出
else if (c == '+' || c == '-')
{
while (ops.size() && isOperator(ops.top()))
{
postfix = postfix + ops.top();
ops.pop();
}
// 弹出后,再把当前的 +,- 加到栈里
ops.push(c);
}
// 遇到优先级高的运算符,直接加到栈里
else
{
ops.push(c);
}
// cout << "postfix: " << postfix << endl;
// cout << "stack: " << stackToStr(ops) << endl;
}
while (ops.size())
{
postfix = postfix + ops.top();
ops.pop();
}
return postfix;
}
// 转换成前缀表达式
string toPrefix(string infix)
{
reverse(infix.begin(), infix.end());
stack<char> ops;
string prefix = "";
for (size_t i = 0; i < infix.length(); i++)
{
char c = infix[i];
if (isOperand(c))
prefix += c;
else if (c == '(')
{
while (ops.top() != ')')
{
prefix += ops.top();
ops.pop();
}
ops.pop();
}
else if (c == '+' || c == '-')
{
if (ops.size() && (ops.top() == '*' || ops.top() == '/'))
{
prefix += ops.top();
ops.pop();
}
ops.push(c);
}
else
ops.push(c);
}
while (ops.size())
{
prefix += ops.top();
ops.pop();
}
reverse(prefix.begin(), prefix.end());
return prefix;
}
// 计算前缀表达式
int countPrefix(string prefix)
{
reverse(prefix.begin(), prefix.end());
stack<int> nums;
for (size_t i = 0; i < prefix.size(); i++)
{
char c = prefix[i];
if (isOperand(c))
{
nums.push(c - '0');
}
else
{
int x1 = nums.top();
nums.pop();
int x2 = nums.top();
nums.pop();
nums.push(calculate(prefix[i], x1, x2));
}
}
return nums.top();
}
int main()
{
vector<string> expressions = {
"6*(3*(4-3)+4)",
"6-2-(5-6)+3-4",
"1+(2+3)*(4-5)",
"1+(2+3)*4-5",
"1+2+3*4-5"
};
for (size_t i = 0; i < expressions.size(); i++)
{
string infix = expressions[i];
cout << "expression: " << infix << endl;
string postfix = toPostfix(infix);
cout << "postfix: " << postfix << " ; result: " << countPostfix(postfix) << endl;
string prefix = toPrefix(infix);
cout << "prefix: " << prefix << " ; result: " << countPrefix(prefix) << endl;
cout << endl;
}
return 0;
}
栈与递归
递归的精髓:将原始问题转化为属性相同但规模更小的问题【递归体】,然后给出一个边界条件【递归出口】解出问题
在递归调用的过程中,系统为每一层的返回点、局部变量、传入实参等开辟了递归工作栈来进行数据存储。系统栈中保存的函数信息满足 先调用 先进 后出 的特点。
代码量少但效率不高,原因在于递归调用的过程中会包含对某一问题的重复求解【重复计算】
队列与层遍历
二叉树的层遍历
矩阵的压缩存储
多维数组: ( 数 组 的 ) n 数 组 (数组的)^n数组 (数组的)n数组 ,维度是一个逻辑上的,物理上的存储依然是一段连续的空间,拿二维数组来说,在物理存储上有两种映射方式
- 按行优先:一行一行的 ∣ a 00 ∣ a 01 ∣ a 02 ∣ a 10 ∣ a 11 ∣ a 12 ∣ |a_{00}|a_{01}|a_{02}|a_{10}|a_{11}|a_{12}| ∣a00∣a01∣a02∣a10∣a11∣a12∣
- 按列优先:一列一列的 ∣ a 00 ∣ a 10 ∣ a 01 ∣ a 11 ∣ a 02 ∣ a 12 ∣ |a_{00}|a_{10}|a_{01}|a_{11}|a_{02}|a_{12}| ∣a00∣a10∣a01∣a11∣a02∣a12∣
对称矩阵
只存一半(主对角线+上/下三角区域)
对于 n 阶方阵,只用一个一维数组 B[ n ( n + 1 ) / 2 n(n+1)/2 n(n+1)/2] 即可,映射方式为行优先
三角矩阵
不同于线性代数里的 “真·上下三角”(三角区域外全为0),这里的三角是指 “假·上下三角”(三角区域外全为一个常数C),因此要比上一个对称矩阵多出一项来放这个 C
三对角矩阵
长这样:【三条斜线】
行优先映射就完事儿了,只存这块斜带区域的值
稀疏矩阵
外形方面么得规律,但 0 元素超级多的矩阵
- 用三元组(行,列,值)的数组保存
- 十字链表
其它相关文章