一、栈
1.1栈的定义和基本概念
要讨论一种数据结构,就要从三方面讨论:逻辑结构、数据的运算、存储结构(物理结构)。
1.1.1 定义
线性表:线性表是具有相同数据类型的n个数据元素的有限序列,其中n为表长,当n=0时为空表。
栈(stack):栈是 只允许在一端进行插入或删除操作 的线性表,即只能从栈尾插入删除。
重要术语:栈顶、栈底、空栈。LIFO:last in first out,即后进先出。
所以栈的逻辑结构和普通线性表没有差别,数据操作上插入和删除操作有区别。
1.1.2 基本操作
先回顾一下线性表的基本操作:
栈的基本操作:
1.1.3 常考题型
若进栈顺序为“a-b-c-d-e”,则出栈顺序有哪些?
由于进栈顺序有可能和出栈操作交叉进行,所以出栈顺序有多种。若有n个不同元素进栈,出栈顺序的不同排列个数为:
即“卡特兰数”。
所以考试时几乎不太可能让写出所有的出栈顺序,一般会出选择题,还是比较简单的。
1.2 栈的顺序存储实现
顺序栈就相当于顺序链表,用静态数组方式实现,是顺序存储。所有顺序栈的基本操作(创增删查)的时间复杂度都是O(1)。
缺点:大小不可变。
1.2.1 初始化顺序栈
#include<iostream>
using namespace std;
#define MaxSize 10
//定义顺序栈
typedef struct {
int data[MaxSize]; //静态数组存放栈中元素
int top; //栈顶指针
}SqStack;
//初始化栈
void InitStack(SqStack& S) {
S.top = -1; //初始化栈顶指针
}
//判断栈空
bool StackEmpty(SqStack S) {
if (S.top == -1)
return true; //栈空
else
return false; //栈不空
}
void test(){
SqStack S;
//操作
}
代码解析:
-
top是栈顶指针,指向栈顶元素,若栈中有四个元素,则top=4,即栈顶元素的下标。
-
刚开始将S.top置为-1,因为初始化的顺序栈里面没有数据,data[0]没有数据,没有“栈顶元素”。
-
在声明一个栈的时候分配内存使用的是变量声明的方式,没有使用malloc函数,在代码执行结束后系统会自动回收这块内存空间。
顺序存储:给各个数据元素分配连续的存储空间,大小为:MaxSize*sizeof(int)。
创建好之后在内存中是这个样子:
1.2.2 进栈操作
//新元素入栈
bool Push(SqStack& S, int x) {
if (S.top == MaxSize - 1)
return false;
S.top = S.top + 1;
S.data[S.top] = x;
return true;
}
注意:S.top 充当数组下标,是一个 int 型。
1.2.3 出栈操作
//出栈操作
bool Pop(SqStack& S, int& x) {
if (S.top == -1)
return false;
x = S.data[S.top]; //x存储栈顶元素,即要出栈的元素
S.top = S.top - 1; //指针-1
return true;
}
代码解析:
-
先判断栈是否为空,若为空直接返回false;
-
让 x 保存需要出栈的元素,即栈顶元素,并带回到函数调用者那里;
-
令栈的指针 -1,只是在逻辑上删除了该元素,但是它的数据还残存在内存中。
1.2.4 读取栈顶元素
//读取栈顶元素
bool GetTop(SqStack S, int& x) {
if (S.top == -1)
return false;
x = S.data[S.top]; //x存储栈顶元素,即要出栈的元素
return true;
}
代码解析:读取栈顶元素的代码和出栈操作的代码几乎一模一样,只是不需要将栈顶指针 -1,只要把被删元素 x 带回即可。
1.2.5 共享栈
顺序栈的缺点:大小不可变。
为了解决这个缺点,可以采用共享栈的方式。即两个栈共享同一片存储空间。
共享栈的优点:节省存储空间,降低发生上溢的可能。
上溢和下溢,对栈而言:栈满还存为上溢,栈空再取即下溢。上溢和下溢都修改了栈之外的内存,因此有可能导致程序崩溃。
//共享栈
typedef struct {
int data[MaxSize]; //静态数组存放栈中元素
int top0; //0号栈栈顶指针
int top1; //1号栈栈顶指针
}ShStack;
//初始化栈
void InitStack(ShStack& S) {
S.top0 = -1; //初始化栈顶指针
S.top1 = MaxSize;
}
刚开始的指针情况如图:
代码解析:
-
top0 从下往上存,top1 从上往下存,在逻辑上实现了两个栈的存取,可以提高内存空间的资源利用率。
-
判断栈满的条件为:top0+1==top1
1.3 链栈
链栈和单链表几乎一模一样,对应的基本操作(创增删查)也几乎相同,只是进栈/出栈只能在栈顶一端进行(链头作为栈顶)。链栈也分为带头结点和不带头结点,下面我 主要用不带头结点的方式 实现一遍链栈的操作。
1.3.1 链栈的定义
//链栈定义
typedef struct LinkNode {
int data;
struct LinkNode* next;
}*LiStack;
1.3.2 链栈的初始化(创)
//栈的初始化
void InitStack(LiStack& S) {
S = NULL; //设置栈顶指针为空
}
若带头结点则改为“S.next = NULL”。
1.3.3 链栈的入栈(增)
//链栈的插入
bool Push(LiStack& list, int x) {
SNode* s = (SNode*)malloc(sizeof(LiStack));
// 内存分配失败的情况
if(s == NULL) {
return false;
}
s->data = x;
s->next = list;
list = s;
}
先申请一个指针 s ,让 s 的 data 域保存要入栈的元素 x ,然后将 s 的 next 链到 list ,即将 s 插入到 list 前面,最后移动 list 的位置让其始终指向栈顶元素。
1.3.4 链栈的出栈(删)
//链栈的出栈
bool Pop(LiStack& list, int& x) {
if (list == NULL)
return false;
//出栈结点是栈顶指针指向的结点
x = list->data; //将出栈节点的值赋给x
SNode* p = (SNode*)malloc(sizeof(LiStack)); //临时结点,用于之后释放空间
list = list->next; //使头指针指向下一结点
free(p);
return true;
}
要注意在使用 free 函数释放内存空间的时候,前面创建内存空间的时候一定要用 malloc 函数创建(这是c语言的方法);如果使用c++的方法,就是前面用 new 创建,后面用 delete 删除。
1.3.5 链栈的查找(获取栈顶元素)
//获取栈顶元素
bool GetPop(LiStack& list, int& e) {
if (list == NULL) //判空
return false;
e = list->data; //用e保存当前要出栈的元素值
return true;
}
1.3.6 其他基本操作
//1.输出栈的元素
void PrintS(LiStack list) {
SNode* s = (SNode*)malloc(sizeof(LiStack));
s = list;
while (s != NULL) {
cout << s->data << " ";
s = s->next;
}
cout << "成功输出!" << endl;
}
//2.判空操作
bool Empty(LinkStack S) {
// 栈为空的条件即栈顶指针指向NULL
if(S == NULL)
return true;
return false;
}
二、队列
2.1 队列的定义和基本概念
2.1.1 定义
队列是一种操作受限的线性表,即只允许在一端进行插入(入队),在另一端进行删除(出队)的线性表。
队列是先进先出(FIFO:first in first out)
2.1.2 基本操作
2.2 队列的顺序存储实现
2.2.1 初始化顺序队列
//定义一个顺序队列
typedef struct {
int data[MaxSize]; //静态数组存放队列元素
int front, rear; //队头指针和队尾指针
}SqQueue;
//初始化队列
void InitQueue(SqQueue& Q) {
//初始时,队头队尾指针指向0
Q.rear = Q.front = 0;
}
//判空
bool Empty(SqQueue Q) {
if (Q.front == Q.rear)
return true;
return false;
}
队头指针指向队头,队尾指针指向队尾的后一个位置,即下一个要插入元素的位置。初始化时让队头队尾指针同时指向0,就代表一个空队列。
2.2.2 入队操作(循环队列)
//入队(错误示范)
bool EnQueue(SqQueue& Q, int x) {
if (队列已满)
return false;
Q.data[Q.rear] = x;
Q.rear = Q.rear + 1;
return true;
}
由于队列是顺序存储结构,内存固定不变,所以要先判断队列是否已满,如果没满就进行入队操作,最后将队尾指针向后移一位。这是“错误示范”的思路。
至于错到了哪儿,我们要先想一下如何判断队列已满?是判断 Q.rear = MaxSize 吗?好像不太对,如果正常情况下队列已满之后,有一些元素从队头出队了,那这时候 Q.rear = MaxSize 仍然成立,但是队列并不是满的,再次有元素入队的话可以放到前面去。这是不是有点循环的味道了,这就对了。基于此,我们改进代码为 Q.rear = (Q.rear + 1) % MaxSize; 这样的话,每次队列满的时候,Q.rear 都会重新指向 data[0] 的位置:比如一个顺序队列最大容量为 10 ,某时刻 Q.rear指向队尾,即Q.rear=9,此时如果有新的元素入队,根据改进后的代码,得 Q.rear=(9+1)%10 ,即 Q.rear=0。
那么我们还剩一个问题:判断队列已满的条件是什么?有同学可能要说“那当然是 Q.rear == Q.front” 啊,队头指针和队尾指针指向同一个位置的话就说明队列已满。但是你想一想我们的“判空”操作的判断条件是什么,在“判空”操作中,我们说当 Q.front==Q.rear 时就认为队列为空,那如果按照前面说的这种判定方法,是不是就有歧义了?所以我们的判定条件要改为 (Q.rear + 1) % MaxSize == Q.front ,即如果队尾指针的下一个位置指向队头,就说明队列已满(如下图),所以我们 必须牺牲一个存储单元来实现判断队列已满 的操作。
于是,我们得到了正确的入队操作的代码:
//入队
bool EnQueue(SqQueue& Q, int x) {
if ((Q.rear + 1) % MaxSize == Q.front) //判断队列已满
return false;
Q.data[Q.rear] = x; //新元素插入队尾
Q.rear = (Q.rear + 1) % MaxSize; //队尾指针+1取余
return true;
}
2.2.3 出队操作(删除)
//出队
bool DeQueue(SqQueue& Q, int& x) {
if (Q.rear == Q.front) //判断队空
return false;
x = Q.data[Q.front]; //x保存出队元素
Q.front = (Q.front + 1) % MaxSize; //队头指针后移
return true;
}
每出队一个元素,队头指针就向后移一位,同样用取模运算,让队头指针可以循环移动。
2.2.4 获取队头元素的值
//获得队头元素的值
bool GetHead(SqQueue& Q, int& x) {
if (Q.rear == Q.front) //判断队空
return false;
x = Q.data[Q.front]; //x保存出队元素
return true;
}
2.2.5 计算队列元素个数
(rear+MaxSize-front)%MaxSize
2.3 判断队空/队满
2.3.1 指针判别法
在 2.2.2入队操作 一节已经详细讲过了,会浪费一个存储空间。如果自己写代码的话这种方法就够用了,但是考试过程中出题老师可能会要求你不让浪费掉这部分空间,于是有了后两种方法。
2.3.2 添加size计数器
//定义一个顺序队列
typedef struct {
int data[MaxSize]; //静态数组存放队列元素
int front, rear; //队头指针和队尾指针
int size;
}SqQueue;
在初始化队列的时候,添加一个 size 用来记录队列中元素个数,插入成功 size++ ,出队成功 size-- ,当 size==MaxSize 时,队列已满,当 size==0 时,队列已空。初始化 size 为 0 。
2.3.3 添加入队/出队标志 tag
//定义一个顺序队列
typedef struct {
int data[MaxSize]; //静态数组存放队列元素
int front, rear; //队头指针和队尾指针
int tag; //标志最近进行的是入队还是出队操作
}SqQueue;
在定义一个队列的时候,添加一个tag标志,当执行入队操作时,令tag=1;执行出队操作时,令tag=0。我们知道,只有入队才会让队列已满,只有出队才会让队列已空,所以:
队满条件:rear == front && tag == 1;
队空条件:rear == front && tag == 0;
2.4 其他出题方法
考试时可能会让队尾指针 rear 指向队尾元素,而不是队尾元素的下一位置,这样的话对应的判空、判满、入队、出队等操作的实现方式都要做一定的小小的改变。
2.5 链队列
2.5.1 链队列的声明
//声明一个结点
typedef struct LinkNode { //链式队列结点
int data;
struct LinkNode* next;
}LinkNode;
//声明一个队列
typedef struct {
LinkNode* front, * rear; //队列的队头和队尾
}LinkQueue;
带头结点和不带头结点两种实现方式的头指针 front 的指向是不一样的。
2.5.2 链队列的初始化
//初始化(带头结点)
void InitQueue(LinkQueue& Q) {
//初始时,front、rear都指向头结点
Q.front = Q.rear = (LinkNode*)malloc(sizeof(LinkNode)); //申请内存空间
Q.front->next = NULL; //头结点的指针域置为空
}
//初始化(不带头结点)
void InitQueue(LinkQueue& Q) {
Q.front = NULL;
Q.rear = NULL;
}
2.5.3 链队列的入队
//入队(带头结点)
void EnQueue(LinkQueue& Q, int x) {
LinkNode* s = (LinkNode*)malloc(sizeof(LinkNode)); //申请一个节点s
s->data = x;
s->next = NULL;
Q.rear->next = s; //新结点插入到rear之后
Q.rear = s; //尾指针指向s
}
//入队(不带头结点)
void EnQueue(LinkQueue& Q, int x) {
LinkNode* s = (LinkNode*)malloc(sizeof(LinkNode)); //申请一个节点s
s->data = x;
s->next = NULL;
if (Q.front == NULL) { //当队列为空时,插入第一个元素
Q.front = s; //修改队头指针
Q.rear = s;
}
else {
Q.rear->next = s; //新结点插入到rear之后
Q.rear = s; //尾指针指向s
}
}
代码不难,结合 2.5.1 链队列的声明 理解。注意插入的元素为第一个元素的情况。
2.5.4 链栈的出队
//出队(带头结点)
bool DeQueue(LinkQueue& Q, int& x) {
if (Q.front == Q.rear) //判空操作
return false;
LinkNode* p = (LinkNode*)malloc(sizeof(LinkNode));
p = Q.front->next; //p指向要删除的元素
x = p->data; //x保存要删除的元素的值
Q.front->next = p->next;
if (Q.rear == p) //如果要删除的是最后一个元素,要做特殊处理,即将队列置为空
Q.rear = Q.front;
free(p);
return true;
}
//出队(不带头结点)
bool DeQueue(LinkQueue& Q, int& x) {
if (Q.front == NULL) //判空操作
return false;
LinkNode* p = (LinkNode*)malloc(sizeof(LinkNode));
p = Q.front; //p指向要删除的元素
x = p->data; //x保存要删除的元素的值
Q.front = p->next; //移动front指针的位置到下一结点
if (Q.rear == p) { //如果要删除的是最后一个元素,要做特殊处理,即将队列置为空
Q.rear = NULL;
Q.front = NULL;
}
free(p);
return true;
}
整体思路是用一个指针p指向被删节点,用x保存p的值,然后删除p结点即可。注意当删除元素为最后一个元素时,要做特殊处理,即把队列置为空。
2.6 双端队列
双端队列是只允许从两端插入、两端删除的线性表。在此基础上也会延伸出“输入受限的双端队列、输出受限的双端队列”等概念,即只允许从一端插入、两端删除 或 两端插入、一端删除的操作。
考点:
-
和栈相似,会让我们判断一个输入序列的输出序列是否合法,以及有多少种输出序列等。题目比较简单。
注意:在栈中合法的输出序列,在双端队列中必定合法。
三、栈的应用
3.1 括号匹配(重要)
3.1.1 为什么用栈?
比如“(((())))”一共8个括号,包含4个左括号和4个右括号。在匹配时最先出现的右括号匹配最后出现的左括号,即当出现一个右括号时,消除最后出现的左括号。这就和进栈出栈类似,也是后进先出。
3.1.2 算法思路
最下面的蓝色框是结束框。
3.1.3 代码
//括号判断
bool bracketCheck(char str[], int length) {
SqStack S; //初始化一个栈
InitStack(S);
for (int i = 0; i < length; i++) {
if (str[i] == '(' || str[i] == '[' || str[i] = '{') {
Push(S, str[i]); //扫描到左括号,入栈
}
else {
if (StackEmpty(S)) //扫描到右括号且当前栈空
return false; //匹配失败
char topElem; //用来保存出栈的元素,即右括号
Pop(S, topElem); //栈顶元素出栈
//判断括号是否匹配,若不匹配,返回false
if (str[i] == '(' && topElem != ')')
return false;
if (str[i] == '[' && topElem != ']')
return false;
if (str[i] == '{' && topElem != '}')
return false;
}
}
return StackEmpty(S); //检索完毕后若栈空,则匹配成功
}
考试中可以直接使用基本操作,不过要用注释说明。本题采用顺序栈存储的实现方式,在实际应用过程中可能会出现栈满的情况,故可用链栈实现。但考试时用顺序栈就行,相对简单。
代码思路:依次遍历所有元素,遇到左括号就入栈,遇到右括号就出栈;匹配失败的情况是左括号、右括号单独出现或左右括号不匹配。
3.2 表达式求值
表达式共分为三种:中缀表达式,前缀表达式,后缀表达式。表达式分为三部分:操作数、运算符、界限符,比较通俗易懂,不做解释。
其中,中缀表达式就是我们最熟悉的那种表达式,比如“((15÷(7-(1+1)))×3)-(2+(1+1))”,就是一个中缀表达式。考试时最长考的是后缀表达式,因为其应用面较广。
3.2.1 背景
就是有个人,叫波兰,有一天突发奇想发明出一种不用界限符(即括号)也能无歧义地表达运算顺序的方法,就是现在的前缀表达式(又叫波兰表达式)和后缀表达式(又叫逆波兰表达式)。
3.2.2 三种表达式的对比和转化
3.2.2.1 对比
概念 | 例子1 | 例子2 | 例子3 | |
---|---|---|---|---|
中缀表达式(最熟悉) | 运算符在两个操作数中间 | a+b | a+b-c | a+b-c*d |
后缀表达式(最常考) | 运算符在两个操作数后面 | ab+ | ab+c- | ab+cd* |
前缀表达式 | 运算符在两个操作数前面 | +ab | -+abc | -+ab*cd |
3.2.2.2 中缀转后缀(手算)
中缀转后缀的方式:先确定运算顺序,然后根据运算顺序依次按照“左操作数 右操作数 运算符”的顺序组合成一个新的操作数。
注意:在实际操作过程中,我们会发现,一个中缀表达式的运算顺序可能并不是唯一的,比如 a+b-c 可以先算加法也可以先算减法,这就势必会导致转换出来的后缀表达式不唯一,理论上来说多种结果都正确,但由于算法三大特性之唯一性,我们规定“左优先”原则,即 只要左边的运算符能先计算,就优先算左边的 。这样可以保证结果唯一。
比如下图两种计算方法,同样的式子,计算顺序不一样,转换出来的后缀表达式也不一样,即使两种结果都应该正确,但考试过程中以及计算机计算过程中都采用左边的那个,即采用左优先原则。
同时,也要掌握后缀转中缀的计算方法。
3.2.2.3 后缀表达式计算(机算)
后缀表达式的计算顺序和运算符的顺序相同。也就是让运算符前面最近的两个操作数进行运算。特点:最后出现的操作数先运算,也就是后进先出。这就和栈不谋而合。
用栈实现后缀表达式的计算:
-
从左往右扫描下一个元素,直到处理完所有元素;
-
若扫描到操作数则压入栈,并回到①;否则执行③;
-
若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压入栈顶,回到①。(注意,先弹出的是右操作数,后弹出的是左操作数)
3.2.2.4 中缀转前缀(手算)
计算方法:
-
确定中缀表达式中各个运算符的运算顺序;
-
选择下一个运算符,按照【运算符 左操作数 右操作数】的方式组合成新的操作数;
-
如果还有运算符未处理,就继续第二步。
右优先原则:只要右边的运算符能先计算,就优先计算右边的。
3.2.2.5 前缀表达式计算(机算)
其实和后缀表达式的计算方法相似,只是要从右往左扫描了,并且弹出的顺序也有所不同,先弹出左操作数,后弹出右操作数。
-
从右往左扫描下一个元素,直到处理完所有元素;
-
若扫描到操作数则压入栈,并回到①;否则执行③;
-
若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压入栈顶,回到①。(注意,先弹出的是左操作数,后弹出的是右操作数)
3.2.2.6 中缀转后缀(机算)
算法目的:给计算机一个中缀表达式,输出一个后缀表达式。
考点:考察进行到某一步时,栈内的情况是怎么样的,选择题。
学习目标:能用笔算的方式模拟整个过程,不需要会写代码。
过程:
-
初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。从左到右处理各个元素,直到末尾。可能遇到三种情况:
-
-
遇到操作数。直接加入后缀表达式;
-
遇到界限符。遇到 “(” 直接入栈,遇到 “)” 则依次弹出栈内运算符并加入后缀表达式,直到弹出“)”为止。注意:“(”不加入后缀表达式;
-
遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到“(”或栈空则停止,之后再把当前运算符入栈。
-
-
最后将栈中剩余运算符依次弹出,并加入后缀表达式。
下图为手推过程,黑笔是栈,红笔是当前扫描的位置,蓝笔是当前后缀表达式。需要注意的是第⑧步中,“-”没有直接出栈是因为暂时不能确定它的运算顺序,比如D后面如果跟个“*”就要先计算后面的,所以要把“-”压入栈。每一步都根据上面说的步骤一一对应找一下理解理解就好了。
3.2.2.7 中缀表达式计算(机算)
就是 后缀表达式计算 和 中缀转后缀 的两种算法的结合。
过程:
-
初始化两个栈,一个操作数栈,用于保存不能确定运算次序的操作数;一个运算符栈,用于保存不能确定运算次序的运算符。
-
若扫描到操作数,压入操作数栈;
-
若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)
中缀转后缀的逻辑:
-
初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。从左到右处理各个元素,直到末尾。可能遇到三种情况:
-
-
遇到操作数。直接加入后缀表达式;
-
遇到界限符。遇到 “(” 直接入栈,遇到 “)” 则依次弹出栈内运算符并加入后缀表达式,直到弹出“)”为止。注意:“(”不加入后缀表达式;
-
遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到“(”或栈空则停止,之后再把当前运算符入栈。
-
-
最后将栈中剩余运算符依次弹出,并加入后缀表达式。
3.3 递归
函数调用时需要用一个“函数调用栈”存储:调用返回地址、实参、局部变量。
递归调用时,函数调用栈可称为“递归工作栈”。每进入一层递归,就将递归调用所需信息压入栈顶;每退出一层递归,就从栈顶弹出相应信息。
缺点:
-
太多层递归可能会导致栈溢出。
-
通常情况下,针对同一问题的递归算法求解比非递归算法求解的效率更低,因为递归算法包含很多重复运算,效率会降低。
四、数组和特殊矩阵
数组的存储结构分为:一维数组和二维数组。之前只是泛泛了解一下,现在需要了解他们背后的实现细节。
特殊矩阵分为:对称矩阵、三角矩阵、三对角矩阵、系数矩阵。主要是用一些简单的策略节省存储空间。
4.1 数组
4.1.1 一维数组
int a[10];
在 c 语言中我们使用如上代码定义一个 int 类型的数组,各元素大小相同,且物理上连续存放。每一个元素所占的内存空间大小是一样的,都是4个字节(int型)。所以只要知道了一个数组的起始地址 LOC ,就可以计算出每一个元素 a[i] 的存放地址:LOC+i*sizeof(int) 。除非题目特别说明,否则默认数组下标从 0 开始。
4.1.2 二维数组
int a[2][4];
使用如上代码定义一个二维数组。二维数组在逻辑上是两行,但是在内存中是线性存储的,如图:
计算机会采用行优先存储或列优先存储来存储二维数组,本质上是为了把二维数组拉成线性结构,因为计算机内存是线性的。
而线性存储二维数组所带来的好处就是可以实现随机存取。即如果知道二维数组的起始地址,那么只要给出某元素的行号和列号,就可以计算出该元素在内存中的存放位置。
在M行N列的二维数组b[M] [N] 中,若按行优先,则bpi[j]的存储地址 = LOC+(i* N+j)*sizeof(int)。列优先类似。
4.2 矩阵
普通矩阵可以采用二维数组的方式存储。注意:描述矩阵元素时,行列号通常从 1 开始;而描述数组时通常下标从0开始。这里不探讨普通矩阵,主要看看一些特殊矩阵是如何压缩存储的。
4.2.1 对称矩阵
定义:若在 n 阶方阵中任意一个元素 ai,j 都有 ai,j=aj,i,则该矩阵为对称矩阵。
普通存储:n*n二维数组;
压缩存储:只存储主对角线+下(上)三角区。
4.2.1.1 按行优先存储
要思考两个问题:
1.数组大小应该是多少?
我们要存储下三角区,第一行 1 个元素,第二行 2 个元素,第三行 3 个元素......那么一共有 n 行,即一共有 (1+2+3+...+n)=(1+n)*n/2 个元素,即一维数组的长度应为(1+n)*n/2 。
2.如何将矩阵的行号和列号映射为对应数组的下标?
按行优先的原则,ai,j 是第 [1+2+3+...+(i-1)]+j = i*(i-1)/2+j-1 个元素,注意数组下标若从 0 开始,下标要减一。那如果要访问上三角区(即j>i)的元素怎么办呢?由于是对称矩阵,所以 ai,j=aj,i,当访问上三角区的元素时,可以访问第 “ j*(j-1)/2+i-1 ”个元素(即i和j互换)。
上述结论不用记,因为考试肯定不会考原题,需要会现推。考试可能会给你一维数组存储上三角区,或者按列优先原则存储,让求 ai,j 的下标,关键在于求出 ai,j 是第几个元素。
4.2.2 三角矩阵
定义:除了主对角线和下三角区,其他元素都相同。
压缩存储:按行优先原则将橙色区域元素存入一维数组中。并在最有一个位置存储常量c。
4.2.2.1 下三角矩阵
两个问题:
1.数组大小?
n*(n+1)/2+1 。多出来的1是用来存放常量c的。
2.如何访问 ai,j ?
k表示是第几个元素。
4.2.2.2 上三角矩阵
数组大小和下三角矩阵一样。
访问上三角区时,ai,j 元素的位置:n+(n-1)+...+(n-i+2)+j-i 。即ai,j 是第 (i-1)*(2 *n-i+2)/2+j-i 个元素。
4.2.3 三对角矩阵
定义:三对角矩阵又称为带状矩阵,当 |i-j|>1 时,有ai,j=0 (1≤i,j≤n)。
压缩存储:按行优先(或列优先)原则,只存储带状部分。
两个问题:
1.数组大小?
除第一行和最后一行是两个元素外,其余均为3个元素,故数组大小为 3n-2。
2.如何访问ai,j?
按行优先原则,计算 ai,j 是第几个元素。
前 i-1 行共有 3*(i-1)-1 个元素,ai,j 是第 i 行第 j-i+2 个元素,所以 ai,j 是第 2i+j-2 个元素。
Q:若已知数组下标k,如何得到i,j?
A:数组下标为 k ,即第 k+1 个元素。
前 i-1 行共 3(i-1)-1 个元素,显然,3(i-1)-1 < k+1 ≤ 3i-1 【即k+1处在两行之间】
解得 i ≥ (k+2)/3 ,向上取整即可求得 i 值
由 k=2i+j-3 得 j=k-2i+3
4.2.4 稀疏矩阵
定义:非零元素远远少于矩阵元素的个数。
特点:稀疏矩阵的特点是 矩阵中非零元的元素较少 ,切记不是“矩阵中的元素较少”!极容易看错!
压缩存储:
1.顺序存储——三元组<行,列,值>。
上图稀疏矩阵的三元组如下:
可以定义一个struct,有i,j,v三个属性,一个struct对应三元组中的一行,然后再定义一个struct的一维数组就可以顺序存储这些三元组。
这种方式存储的话,如果要访问一个三元组,就需要顺序依次访问,就失去了随机存取的特性。
考点:若采用三元组表存储结构存储稀疏矩阵M,则除了三元组表外,还需要保存M的行数和列数。
原因:每个非零元素都是由三元组组成,但是仅通过三元组表中的元素无法判断稀疏矩阵 M 的大小,因此还要保存 M 的行数和列数。此外,还可以保存 M 的非零元素个数。
2.链式存储——十字链表法
每个结点由三个数据域和两个指针组成,三个数据分别是存储元素的行号、列号、值,两个指针分别指向同一列的下一个元素、同一行的下一个元素。
4.3 考点
1.矩阵的压缩存储需要多长的数组?
2.由矩阵行列号 <i,j> 推出对应的数组下标号 k。
3.由 k 推出 <i,j> 。
4.易错点:
-
存储上三角还是下三角;
-
行优先还是列优先;
-
矩阵元素从0开始还是从1开始;
-
数组下标从0开始还是从1开始。
五、习题精选
本章节代码题不多,主要是栈的基本操作那一部分,并且一般不用写出基本操作的函数实现,只要会用就行(包括入栈、出栈、判空、栈满、入队、出队等操作)。以下这些都是王道25考研数据结构书的课后习题里面我的错题,以及一些比较有代表意义的题目,不多,但应该会很有帮助
1.设链表不带头结点且所有操作均在表头进行,则下列最不适合作为链栈的是: (C)
A.只有表头结点指针,没有表尾指针的双向循环链表
B.只有表尾结点指针,没有表头指针的双向循环链表
C.只有表头结点指针,没有表尾指针的单向循环链表
D.只有表尾结点指针,没有表头指针的单向循环链表
解析:对于双向循环链表,不管是表头指针还是表尾指针,都可以很方便地找到表头结点,方便在表头做插入或删除操作。而单循环链表通过尾指针可以很方便的找到表头结点,但通过头指针找尾节点需要遍历一次链表,故选C。
2.一个栈的入栈序列为1,2,3,...,n,出站序列是P1,P2,...Pn。若P2=3,则P3可能取值的个数为: (n-1)
解析:P3可以取除了3之外的所有数。自己推演一遍就会了。
3.与顺序队列相比,链式队列 (D)
A.优点是队列的长度不受限制
B.优点是进队和出队时间效率更高
C.缺点是不能进行顺序访问
D.缺点是不能根据队首指针和队尾指针计算队列长度
解析:A选项,链式队列的长度虽然不固定,但仍不能无限扩容,因此仍然受限制。B选项,顺序队列和链式队列进出队的时间复杂度都是O(1)。C选项,顺序队列和链式队列都可以顺序访问。D选项,对于顺序队列,可以通过队头指针和队尾指针计算队列中的元素个数,而链式队列则不能。
4.用链式存储方式的队列进行删除操作时需要 (D)
A.仅修改头指针 B.仅修改尾指针
C.头尾指针都要修改 D.头尾指针可能都要修改
解析:仅针对AD选项,队列中只有一个元素时需要修改头尾指针,要使队列为空,其余情况仅修改头指针即可。
5.若将n阶下三角矩阵A按列优先顺序压缩存放在一维数组B[1...n(n+1)/2+1]中,则存放到B[k]中的非零元素ai,j(1≤i,j≤n)的下标i、j与k的对应关系是:
( (j-1)(2n-j+2)/2+i-j+1 )
解析:题干关键信息:下三角,n阶,列优先。
那么元素 ai,j 之前共有 j-1 列,即有 n+(n-1)+(n-2)+...+(n-j+2)=(j-1)(2n-j+2)/2 个元素,元素 ai,j 是第 j 列上第 i-j+1 个元素,数组 B 的下标从 1 开始,k=(j-1)(2n-j+2)/2+i-j+1。
6.有一个n×n的对称矩阵A,将其下三角部分按行存放在一维数组B中,而 A[0] [0]存放于B[0]中,则元素 A[i] [i]存放于B中的哪个下标处? ( (i+3)*i/2 )
解析:先计算 A[i] [i]是第几个元素,再计算它的下标。
由题意可知, 由于对称矩阵A的元素是从第 0 行 0 列开始存储,所以A[i] [i] 是第 i+1 行第 i+1 列的那个元素。并且是下三角部分按行存储,第一行1个元素,第二行2个元素,...,第 i 行 i 个元素,因此前 i 行有 (1+2+...+i)=(i+1)*i/2 个元素,然后再加上第 i+1 行的 i+1 个元素,则 A[i] [i] 是第 (i+1) *i/2+i+1 个元素。
由于数组B的下标从0开始,故 A[i] [i] 的存放位置的下标要减一。即元素A[i] [i] 存放的下标为 (i+1) *i/2+i =(i+3) *i/2
其他的还有很多给出元素下标让计算数组下标的题目,平时刷题过程中多注意练习就好,不算难。