【第三章】栈、队列和数组

3.1 栈

3.1.1 栈的基本概念

  1. 栈的定义
  • (Stack)是只允许在一端进行插入或删除操作的线性表。
  • 栈顶(Top):线性表允许进行插入和删除操作的那一端。
  • 栈底(Bottom):固定的,不允许进行插入和删除操作的另一端。
  • 空栈:不含任何元素的空表。

栈的操作特性可以明显地概括为:后进先出Last in first Out,LIFO)。

  1. 栈的基本操作

严蔚敏教材中给出的基本操作:

  • InitStack(&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\) 个不同元素入栈时,出栈元素不同排列的个数为 \(\frac{1}{n+1}C_{2n}^{n}\)

该公式称为卡特兰数(Catalan)公式,可用数学归纳法证明。

3.1.2 栈的顺序存储结构

1. 顺序栈的实现

  • 采用顺序存储结构的栈称为顺序栈,它利用一组地址连续的存储单元存放自栈底到栈顶到数据元素,同时附设一个指针(top) 指示当前栈顶元素的位置。

看到顺序栈要想到其顺序存储,也就是地址是连续存放的。和顺序表有一定的相似之处。

栈的顺序存储类型的定义为:

#define MaxSize 50
typedef struct {
    ElemType data[MaxSize];    // 存放栈中元素
    int top;                   // 栈顶指针
}
  • 栈顶:
    • 栈顶指针S.top
    • 初始设置 S.top=-1
    • 栈顶元素S.data[S.top]
  • 出栈入栈:
    • 入栈操作:栈不满时,栈顶指针先加1,再送值到栈顶。
    • 出栈操作:栈非空时,先取栈顶元素,再将栈顶指针减1.
  • 判空和判满:
    • 栈空条件S.top==-1
    • 栈满条件S.top==MaxSize-1
  • 长度:
    • 栈长S.top+1

另一种常见方式:

  1. 初始设置栈顶指针 S.top=0;(⚠️注意与前一种方法区分)
  2. 入栈时先将值送到栈顶,栈顶指针加1。
  3. 出栈时,栈顶指针先减1,再取栈顶元素。
  4. 栈空条件S.top==0
  5. 栈满条件S.top==MaxSize

第一种方法中,S.top 时刻指向栈顶元素,而第二种方法更加有先见之明,时刻指向那个可以插入新元素的末尾空位置。

[!WARNING] 注意
栈的判空、判满条件会根据实际给出的条件不同发生变化,下文中代码实现是在栈顶指针初始化为-1的条件下的相应方法,其他情况需具体问题具体分析。

2. 顺序栈道基本操作

这里的 S.top 时刻指向栈顶元素。

(1)初始化

void InitStack(SqStack &S) {
    S.top=-1;                // 初始化栈顶指针
} 

(2)栈判空

bool StackEmpty(SqStack S) {
    if (S.top==-1) {            // 如果是-1就是空
        return true;
    } else {
        return false;           // 不空
    }
}

(3)入栈

bool Push(SqStack &S,ElemType x) {
    if (S.top==MaxSize-1)            // 如果栈满,报错
        return false;
    S.data[++S.top]=x;               // 先移动指针,再存放值
    return true;
}

(4)出栈

bool Pop(SqStack &S,ElemType &x) {
    if (S.top==-1)        // 栈空还想出栈?报错! 
        return false;   
    x=S.data[S.top--];    // 先出栈,指针再减 1
    return true;
}

(5)读栈顶元素

bool GetTop(SqStack S,ElemType &x) {
    if (S.top==-1)                 // 栈空读空气?
        return false;
    x=S.data[S.top];               // 读取栈顶元素->x
    return true;
}

3. 共享栈

利用栈底位置不变的特性,可让两个顺序栈共享一个一维数组,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间中的中间延伸。

  • 两个栈的栈顶指针都指向栈顶元素;
    • top0=-1 时0号栈为空;
    • top1=MaxSize 时1号栈为空;
  • 仅当两个栈顶指针相邻(top1-top0=1)时,判断为栈满。
  • 当0号栈入栈时 top0 先加1再赋值,1号栈入栈时 top1 先减1再赋值;出栈时则刚好相反。

意义:共享栈是为了更有效利用存储空间,两个栈的空间相互调节,只有在整个存储空间被占满时才发生上溢。其存取数据的时间复杂度仅为O(1),所以对存取效率没有什么影响。

3.1.3 栈的链式存储结构

  • 链栈:采用链式存储的栈称为链栈
  • 优点
    • 便于多个栈共享存储空间和提高效率。
    • 不存在栈满上溢的情况。
  • 实现
    • 采用单链表实现链栈。
    • 规定所有操作都是在单链表的表头进行的。
    • 规定链栈没有头结点Lhead 指向栈顶元素

栈的链式存储结构描述为:

typedef struct Linknode {
    ElemType data;                // 数据域
    struct Linknode *next;        // 指针域
}LiStack;                         // 栈类型定义

采用链式存储结构的特点

  • 便于结点的插入和删除。
  • 入栈和出栈操作都在链表的表头进行。

对于带头结点不带头结点的链栈,具体实现方式不同。这点与链表中是一样的,带头结点的链表实现方式也与不带的不同。

3.2 队列

3.2.1 队列的基本概念

1. 队列的定义

队列(Queue):

  • 简称
  • 同为一种操作受限的线性表。
  • 只允许在表一端进行插入,在表的另一端进行删除。
  • 插入:向队列中插入元素称为
    • 入队(或)
    • 进队
  • 删除:删除元素称为
    • 出队(或)
    • 离队
  • 队头Front):允许删除的一端,也称队首
  • 队尾Rear):允许插入的一端。
  • 空队列:不含任何元素的空表。

直接理解为日常生活中排队就行。最早排队的也是最早离队的。最前面排队的人就是队头的人,只允许你走,你若是插到队头去就算插队了,不是很文明,故也是不被允许的操作。

操作特性:先进先出First In First Out,FIFO)。

2. 队列常见的基本操作

  • InitQueue(&Q):初始化队列,构造一个空队列Q。
  • QueueEmpty(Q):判队列空,若队列Q为空返回true,否则返回false。
  • EnQueue(&Q, x):入队,若队列Q未满,将x加入,使之成为新的队尾。
  • DeQueue(&Q, &x):出队,若队列Q非空,删除队首元素,并用x返回。
  • GetHead(Q, &x):读队首元素,若队列Q非空,则将队首元素赋值给x。

由于队列是受限制的,因此不是任何队线性表的操作都可以作为栈和队列的操作。比如,不可以随便读取栈或队列中间的某个元素。

3.2.2 队列的顺序存储结构

1. 队列的顺序存储

队列的顺序实现是指分配一块连续的存储单元存放队列的元素,并附设两个指针:

  • 队首指针front 指向队首元素
  • 队尾指针rear 指向队尾元素的下一个位置

不同教材对指针的规定不同,其实现的具体操作也就不同。但归根结底,其思想都是一致的,请结合具体情况具体分析。

队列的顺序存储类型是可以这样描述:

#define MaxSize 50                // 定义队列中元素的最大个数
typedef struct {
    ElemType data[MaxSize];       // 用数组存放队列元素
    int front,rear;               // 队首指针和队尾指针
} SqQueue;
  • 初始时Q.front=Q.rear=0
  • 入队操作:队不满时,先送值到队尾元素,再将队尾指针加1。
  • 出队操作:队不空时,先取队首元素值,再将队首指针加1.

这里初始状态的 Q.front==Q.rear 可以作为队列的判空条件。

[!Warning] 判断队满需注意
能否用 Q.rear==MaxSize 作为队满的判断条件呢?答案是显然不能,当队列中入队并使得对队列满时,rear 指向了队列能承受最大元素的位置(rear=MaxSize)。此时前面的“人”出队,只留下最后一个人,则 rear 仍然指向该处,此时 rear=MaxSize 但显然由于前面的人已经出队,导致此时误判为“溢出”,也就是书上说的假溢出。

2. 循环队列

循环队列就是为了解决“假溢出”的问题。

  • 循环队列:将顺序队列臆造为一个环状的空间(把存储队列元素的表从逻辑上看作一个环)。
  • 实现:当队首指针 Q.front=MaxSize-1 后,再前进一个元素就自动到0(利用除法取模运算实现)。

[!Question] 那么队列的判空条件是什么呢?
显然是 Q.front==Q.rear。但如果入队的速度太快,队尾指针很快就会赶上队首指针,此时尽管队列是满的,但也会出现 Q.front==Q.rear 的情况,所以必须采取一些方法来解决:

  1. 牺牲一个单元来区分队空和队满(比较普遍的做法)。约定“队首指针在队尾下一个位置作为队满的标志”。
  • 队满条件(Q.rear+1)%MaxSize==Q.front
  • 队空条件Q.front==Q.rear
  • 队列中元素的个数(Q.rear-Q.front+MaxSize)%MaxSize
  1. 类型中增设 size 数据成员,表示元素个数。
  • 若删除成功,则 size 减1,若插入成功,则 size 加1。
  • 队空时,Q.size==0
  • 队满时,Q.size==MaxSize
  • 队空和队满都有: Q.front==Q.rear
  1. 类型中增设 tag 数据成员,以区分是队满还是队空。
  • 删除成功tag=0,若导致 Q.front==Q.rear,则为队空
  • 插入成功tag=1,若导致 Q.front==Q.rear,则为队满

这里队列中的元素个数不是:(Q.rear-Q.front)%MaxSize 的原因是 Q.rear-Q.front 可能为负数。

3. 循环队列的操作

(1)初始化

void InitQueue(SqQueue &Q) {
    Q.rear=Q.front=0;    // 初始化队首、队尾指针
}

(2)判队空

bool isEmpty(SqQueue Q) {
    if (Q.rear==Q.front)         // 队空条件
        return true;
    else
        return false;
}

(3)入队

bool EnQueue(SqQueue &Q,ElemType x) {
    if ((Q.rear+
    
    1)%MaxSize==Q.front)    // 队满则报错
        return false;
    Q.data[Q.rear]=x;
    Q.rear=(Q.rear+1)%MaxSize;          // 队尾指针加1取模
    return true;    
}

(4)出队

bool DeQueue(SqQueue &Q,ElemType &x) {
    if(Q.rear==Q.front)                     // 队空还想出队呢?
        return false;
    x=Q.data[Q.front];
    Q.front=(Q.front+1)%MaxSize;            // 队首指针加1取模
    return true;
}

3.2.3 队列的链式存储结构

1. 队列的链式存储

链式队列:队列的链式表示。它实际上是一个同时有队首指针和队尾指针的单链表。

  • 队首指针:指向队头结点。
  • 队尾指针:指向队尾结点(单链表的最后一个结点)。

队列的链式存储类型可描述为:

typedef struct LinkNode{        // 链式队列结点
    ElemType data;
    struct LinkNode *next;
}LinkNode;

typedef struct {                // 链式队列
    LinkNode *front,*rear;      // 链式队列的队头和队尾指针
}LinkQueue;

不带头结点时,当 Q.front==NULLQ.rear==NULL 时,链式队列为空。

[!NOTE] 链式队列为空的判断

  • 入队时:
    • 建立一个新结点,将新结点插入到链表的尾部,并让 Q.rear 指向这个新插入的结点(若原队列为空队,则令 Q.front 也指向该结点)。
  • 出队时:
    • 首先判断队是否为空,若不空,则取出队首元素,将其从链表中删除,并让 Q.front 指向下一个结点(该结点为最后一个结点,则置 Q.frontQ.rear 都为NULL)。

不难看出,不带头结点的链表队列在操作上往往比较麻烦,因此通常将链式队列设计成一个带头结点的单链表,这样插入和删除草走就统一了。

  • 用单链表表示的链式队列特别适合于数据元素变动比较大的情形,而且不存在队列满且产生溢出的问题。
  • 另外,假如程序中要使用多个队列,与多个栈的情形一样,最好使用链式队列,这样就不会出现存储分配不合理和“溢出”的问题。

2. 链式队列的基本操作

(1)初始化

void InitQueue(LinkQueue &Q) { // 初始化带头结点的链式队列
    Q.front=Q.rear=(LinkNode*)malloc(sizeof(LinkNode));// 建立头结点
    Q.front->next=NULL;        // 初始为空
}

(2)判队空

bool IsEmpty(LinkQueue Q) {
    if (Q.front==Q.rear)    // 判空条件
        return true;
    else
        return false;
}

(3)入队

void EnQueue(LinkQueue &Q,ElemType x) {
    LinkNode *s=(LinkNode *) malloc(sizeof(LinkNode));//创建新结点
    s->data=x;
    s->next=NULL;
    Q.rear->next=s;    // 插入链尾
    Q.rear=s;          // 修改尾指针
}

(4)出队

bool DeQueue(LinkQueue &Q,ElemType &x) {
    if (Q.front==Q.rear)
        return false;           // 空队
    LinkNode *p=Q.front->next;  // 用p指向队头结点的后一个结点
    x=p->data;                  // 取出数据->x
    Q.front->next=p->next;      // 出队
    if(Q.rear==p)            // 只有一个结点时队头结点的下一个结点p就是队尾结点
        Q.rear=Q.front;         // 若原队只有一个结点,删除后变空
    free(p);
    return true;
}

这里单独对只有一个结点时做判断是因为只有一个结点时,p 指向空。但向前移动front后 Q.rear 仍然指向已经被free掉的结点A!这是一个悬空指针,会导致严重错误。

3.2.4 双端队列

  • 双端队列:两端都允许进行插入删除操作的线性表。
  • 左端:也视为前端
  • 右端:也视为后端

在双端队列入队时,

  • 前端进入的元素排列在队列中后端进的元素的前面,
  • 后端进的元素排列在队列中前端进的元素的后面。
  • 出队时:
    • 无论是前端还是后端出队,先出的元素排列在后出的元素的前面。

所以无论你干的是前端还是后端,被裁的时候都是一视同仁。

[!NOTE] 输出受限的双端队列
允许在一端进行插入和删除,但另一端只允许插入的双端队列。

[!NOTE] 输入受限的双端队列
允许在一端进行插入和删除,但另一端只允许删除的双端队列。

删除操作被砍了,就是输出操作受限。因为删除就是出队,出队的时候人从队伍中出去那不就是输出了吗。
同理,插入操作被砍了,就是输入操作受限。

[!NOTE] 注意区分
请注意区分双端队列与普通队列的区别,

  • 普通队列两端各负责一个功能
  • 双端队列的一端完全开放,既能插入也能删除,但另一端是受限制的。
    • 输入受限的一端完全开放,一端输入受限🚫。
    • 输出受限的一端完全开放,一端输出受限🚫。

[!TIP] 解题技巧

  • 栈是退化后的队列,所以栈能完成的操作队列一定能完成。
  • 如果要判断一个双端队列是否合法,只需要找到退化成栈后不能完成的那些操作进行检验即可。然后根据序列的相对位置进行拼凑。

3.3 栈和队列的应用

想要学会栈和队列,必须学习其应用,把握规律,举一反三。

3.3.1 栈在括号匹配中的应用

假设表达式中允许包含两种括号:圆括号和方括号,其嵌套的顺序任意即 ({}())[([][])] 等均为正确的格式,[(])([())(()] 均为不正确的格式。
考虑下列括号序列:

image.png

分析如下:
1)计算机接收第一个括号 [ 后,期待与之匹配的第8个括号 ] 出现。
2)获得了第2个括号 (,此时第1个括号 [ 暂时放在一边,而急迫期待与之匹配的第7个括号 ) 出现。
3)后的了第3个括号 [,此时第2个括号 ( 暂时被放在一边,而迫切期待与之匹配的第4个括号 ] 出现。第3个括号的期待得到满足,消解之后,第2个括号的期待匹配又成为当前最急迫的任务。
4)以此类推,可见该处理过程与栈道思想吻合。

算法的思路如下:

  1. 初始设置一个空栈,顺序读入括号
    • 若是左括号,则作为一个新的更急迫的期待压入栈中,自然使原有的栈中所有未消解的期待的急迫性降了一级。
    • 若是右括号,则或使置于栈顶的最急迫期待得以消解,或是不合法的情况(括号序列不匹配,退出程序)。
  2. 算法结束时,栈为空,否则括号序列不匹配。

3.3.2 栈在表达式求值中的应用

表达式求值是程序设计语言中一个最基本的问题,它是栈应用的一个典型范例。

1 . 算数表达式

中缀表达式(如3+4)是人们常用的算数表达式,操作符中以中缀形式处于操作数的中间。与前缀表达式(如+34)或后缀表达式(如34+)相比,中缀表达式不容易被计算机解析,但仍被许多程序语言使用,因为它更符合人们的思维习惯。

与前缀表达式或后缀表达式不同的是,中缀表达式中的括号是必需的。计算过程中必需用括号将操作符和对应的操作数括起来,用于指示运算的次序。后缀表达式的运算符在操作数后面,后缀表达式中考虑了运算符的优先级,没有括号,只有操作数和运算符。

中缀表达式 A+B*(C-D)-E/F 对应的后缀表达式为 ABCD-*+EF/-,将后缀表达式与原表达式对应的表达式树的后序遍历序列进行比较,可发现它们有异曲同工之妙。

2 . 中缀表达式转后缀表达式

[!NOTE] 中缀表达式转后缀表达式的方法

下面先给出一种由中缀表达式转后缀表达式的手算方法
1)按照运算符的运算顺序对所有运算单位加括号
2)将运算符移至对应括号的后面,相当于按“左操作数-右操作符-运算符”重新组合。
3)去除所有括号

例如,中缀表达式 A+B*(C-D)-E/F 转后缀表达式的过程如下(下标表示运算符的运算顺序):

1)加括号:((A+(B*(C-D)))-(E/F))
2)运算符后移:((A(B(CD)-)*)+(EF)/)-
3)去除括号后,得到后缀表达式:ABCD-*+EF/-

注意这里是手算的方法,若用计算机实现则只用第一步的表达式。

在计算机中,中缀表达式转后缀表达式时需要借助一个栈,用于保存暂时还不能确定运算顺序的运算符。从左到右依次扫描中缀表达式中的每一项,具体转化过程如下:

  1. 遇到操作数
    • 直接加入后缀表达式。
  2. 遇到界限符
    • 若为“(”,则直接入栈;
    • 若为“)”,
      • 不入栈
      • 依次弹出栈中的运算符后加入后缀表达式
      • 直到遇到“(”为止,
      • 并直接删除“(”。
  3. 遇到运算符
      • 优先级高于栈顶运算符
      • 遇到栈顶为“(”
    • 则直接入栈;
      • 优先级低于或等于栈顶运算符
      • 则依次弹出栈中的运算符并加入后缀表达式,
        • 直到遇到一个优先级低于它的运算符
        • 遇到“(”
        • 栈空为止,
    • 之后将当前运算符入栈。

[!TIP] GPT给出的理解方法
🔥 最关键的理解:
栈里存的永远是“还不能决定顺序的运算符”。
后缀表达式中出现的永远是“已经确定执行顺序的符号”。

只要你抓住这两句话,你完全不需要死记步骤,会“自然地知道操作”。

[!TIP] 记忆心法🌈

操作数 → 出
(     → 入
)     → 弹到 (
op    → 弹掉所有 >= 它的,再入栈> 

[!NOTE] 栈的深度分析
栈的深度:指栈中元素的个数。

  • 通常是给出入栈和出栈道序列,求最大深度(栈的容量当然应大于或等于最大深度)。
  • 有时会间接给出入栈和出栈序列,例如以中缀表达式和后缀表达式的形式给出入栈和出栈序列。
  • 掌握栈道先进后出的特点进行手工模拟是解决这类问题的有效方法。

跳出题目的框架,抓住本质规律才能快速解题。

3 . 后缀表达式求值

用栈实现表达式求值的分析

通过后缀表示计算表达式值的过程:从左往右依次扫描表达式中的每一项。

  • 若该项是操作数
    • 则将其压入栈中;
  • 若该项是操作符 <op>
    • 则从栈中退出两个操作数 XY,形成运算指令 X<op>Y
    • 并将结果压入栈中。
  • 当所有项都扫描并处理完后
    • 栈顶存放的就是最后的计算结果。

3.3.3 栈在递归中的应用

递归是一种重要的程序设计方法。简单地说,若在一个函数、过程或数据结构的定义中又应用了它自身,则这个函数、过程或数据结构称为是递归定义的,简称递归

递归通常把一个大型的复杂问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的代码就可以描述出解题过程所需要的多次重复计算,大大减少了程序的代码量。但在通常情况下,它的效率并不是太高。

菲波纳契数列就是一个经典的例子,其定义如下:

\[F(n)=\begin{cases} F(n-1)+F(n-2),&n>1 \\ 1,&n=1 \\ 0,&n=0 \end{cases} \]

用程序实现:

int F(int n) {
    if (n==0) {
        return 0;                    // 边界条件
    } else if (n==1)
        return 1;                    // 边界条件
    else
        return F(n-1)+F(n-2);        // 递归表达式
}

必需注意递归模型不能是循环定义的,必须满足以下两个条件:

  • 递归表达式(递归体)
  • 边界条件(递归出口)

递归的精髓在于降低问题规模。

[!NOTE] 栈在函数调用中的作用和工作原理

  • 在递归调用的过程中,系统为每一层的返回点、局部变量、传入实惨等开辟了递归工作栈来进行数据存储,递归次数过多容易造成栈溢出等。
  • 而其效率不高的原因是递归调用过程中包含很多重复的计算。

图见书上P93的图3.16

显然,在递归调用的过程中,F(3) 被计算2次,F(2) 被计算3次。F(1) 被调用5次,F(0) 被调用3次。所以,递归的效率低下,但优点是代码简单,容易理解。在第5章第数中利用了递归的思想额,代码变得非常简单。通常情况下,初学者很难理解递归的调用过程,如果想理解递归具体是如何实现的,可以参阅编译原理教材中的相关内容。

通过可以将递归算法转换为非递归算法。

3.3.4 队列在层次遍历中的应用

在信息处理中有一大类问题需要逐层或逐行处理。这类问题的解决方法往往是在处理当前层或当前行时就对下一层或下一行做预处理,把处理顺序安排好,等到当前层或当前行处理完毕,就可以处理下一层或下一行。使用队列是为了保存下一步的处理顺序。下面用二叉树层次遍历的例子,说明队列的应用。

该过程的描述:

  1. 若队空(所有结点都已处理完毕),则结束遍历;否则重复3操作。
  2. 队列中第一个结点出队,并访问之。
    1. 若其有左孩子,则将左孩子入队;
    2. 若其有右孩子,则将右孩子入队,返回第2步。

3.3.5 队列在计算机系统中的应用

队列在计算机系统中的应用非常广泛,以下仅从两个方面来阐述:

  • 第一个方面
    • 是解决主机与外部设备之间速度不匹配的问题,
  • 第二个方面
    • 是解决由多用户引起的资源竞争问题。

[!NOTE] 缓冲区的逻辑结构
对于第一个方面,仅以主机和打印机🖨️之间速度不匹配的问题为例做简要说明。

  • 问题:
    • 主机输出数据给打印机打印,输出数据的速度比打印机打印数据的速度快得多,因为速度不匹配,若直接把输出的数据送给打印机打印,显然不可行。
  • 解决方法:
    • 设置一个打印数据缓冲区,主机把要打印输出的数据依次写入这个缓冲区,写满后就暂停输出,转去做其他事情。打印机就从缓冲区中按照先进先出的原则依次取出数据并打印,打印完后再向主机发出请求。主机接到请求后再向缓冲区写入打印数据。这样做既保证了打印数据的正确,又使主机提高了效率。

由此可见,打印数据缓冲区中所存储的数据就是一个队列。

[!NOTE] 多队列出队/入队操作的应用
对于第二个方面,CPU资源的竞争是一个典型的例子。

  • 在一个带有多终端的计算机系统上,有多个用户需要CPU各自运行自己的程序,它们分别通过各自的终端向操作系统提出占用CPU的请求。
  • 操作系统通常按照每个请求在时间上的先后顺序,把它们排成一个队列,每次把CPU分配给队首请求的用户使用
  • 当相应的程序运行结束或用完规定的时间间隔后,令其出队,再把CPU分配给新的队首请求的用户使用
  • 这样既能满足每个用户的请求,又使CPU能够正常运行。

3.4 数组和特殊矩阵

矩阵在计算机图形学、工程计算中占有举足轻重的地位。在数据结构中考虑的是如何用最小的内存空间来存储同样的一组数据。所以,我们不研究矩阵及其运算等,而把精力放在如何将矩阵更有效地存储在内存中,并能方便地提取矩阵中的元素。

3.4.1 数组的定义

数组是由 \(n(n\geq 1)\)相同类型的数据元素构成的有限序列,每个数据元素称为一个数组元素,每个元素在 \(n\) 个线性关系中的序号称为该元素的下标,下标的取值范围称为数组的维界

数组线性表的关系:

  • 数组是线性表的推广。
  • 一维数组可视为一个线性表
  • 二维数组可视为其元素是定长数组线性表,以此类推。
  • 数组一旦被定义,其维数和维界就不再改变
  • 因此,除结构的初始化和销毁外,数组只会有存取元素修改元素的操作。

3.4.2 数组的存储结构

大多数计算机语言都提供了数组数据类型,逻辑意义上的数组可采用计算机语言中的数组数据类型进行存储,一个数组的所有元素在内存中占用一段连续的存储空间。

以一维数组 A[0...n-1] 为例,其存储结构关系式为:

\[LOC(a_{i})=LOC(a_{0})+i\times L,(0\leq i\leq n) \]

其中,\(L\) 是每个数组元素所占的存储单元

对于多维数组,有两种映射方法:

  • 按行优先
  • 按列优先

以二维数组为例,按行优先存储的基本思想是:先行后列,先存储行号较小的元素,行号相等先存储列号较小的元素。

设二维数组的行下标与列下标的范围分别为 \([0,h_{1}]\)\([0,h_{2}]\),则存储结构关系式为:

\[LOC(a_{i,j})=LOC(a_{0,0})+[i\times(h_{2}+1)+j]\times L \]

另外,当以列优先方式存储时,得出存储结构关系式为:

\[LOC(a_{i,j})=LOC(a_{0,0})+[j\times(h_{1}+1)+i]\times L \]

3.4.3 特殊矩阵的压缩矩阵

  • 压缩矩阵:指
    • 为多个值相同的矩阵只分配一个存储空间
    • 零元素不分配空间
  • 特殊矩阵:指
    • 具有许多相同矩阵元素或零元素,并且这些相同矩阵元素或零元素的分布有一定规律性的矩阵。
    • 常见的特殊矩阵有:
      • 对称矩阵
      • 上(下)三角矩阵
      • 对角矩阵
  • 特殊矩阵压缩存储方法:找出特殊矩阵中值相同的矩阵元素的分布规律,把那些呈现规律性分布的、值相同的多个矩阵元素压缩存储到一个存储空间中。

1. 对称矩阵

对称矩阵

  • 若对一个 \(n\) 阶矩阵 \(A\) 中的任意一个元素 \(a_{i,j}\) 都有 \(a_{i,j}=a_{j,i}(1\leq i,j\leq n)\),则称其为对称矩阵。
  • 其中的元素可以划分为3个部分,即
    • 上三角区
    • 主对角线
    • 下三角区。

对于 \(n\) 阶对称矩阵,上三角区的所有元素和下三角区的对应元素相同,若仍采用二维数组存放,则会浪费几乎一半的空间,为此将 \(n\) 阶对称矩阵 \(A\) 存放在一维数组 B[n(n+1)/2] 中,即元素 \(a_{i,j}\) 存放在 \(b_{k}\) 中。

比如只存放下三角部分(含主对角部线)的元素:
在数组 \(B\) 中,位于元素 \(a_{i,j}(i\geq j)\) 前面的元素个数为:

  • \(1\) 行:1个元素(\(a_{1,1}\)
  • \(2\) 行:2个元素(\(a_{2,1},a_{2,2}\)
  • ...
  • \(i-1\) 行:\(i-1\) 个元素(\(a_{i-1,1},a_{i-2,2},\dots,a_{i-1,i-1}\)
  • \(i\) 行:\(j-1\) 个元素(\(a_{i,1},a_{i,2},\dots,a_{i,j-1}\)

因此,元素 \(a_{i,j}\) 在数组 \(B\) 中的下标 \(k=1+2+3+\dots+(i-1)+j-1=i(i-1)/2+j-1\)(数组下标从0开始)。因此,元素下标之间的对应关系如下:

\[k=\begin{cases} \frac{i(i-1)}{2}+j-1,&i\geq j(下三角和主对角线元素) \\ \frac{j(j-1)}{2}+i-1,&i<j(上三角区元素a_{i,j}=a_{j,i}) \end{cases} \]

当数组下标从1开始时,可以采用同样的推导方法,王道让读者(我们)自行想象。

[!NOTE] 解读

  • 实际上,数组的下标从零开始就可以这样去理解。
    • a[0] 表示前面有零个元素,那就是第一个元素。
    • a[1] 表示前面有1个元素,所以是数组中的第二个。
  • 这里计算 a[i][j](1<=i,j<=n) 就是用这样的原理,把前面的行的所有元素都加上,最后一行比较特殊,除了自身都加上,就得到了前面所有的元素的数量。
  • 前面所有元素的数量,就是我们要求的数组下标了。

[!TIP] 注意

  • 二位数组 A[n][n]A[0...n-1][0...n-1] 的写法是等价的。若数组写成 A[1...n][1...n],则表示指定了下标是从1开始的。
  • 二维数组元素写为 a[i][j],注意数组下标 ij 通常是从0开始的。
  • 矩阵元素通常写为 \(a_{i,j}\) ,行号 i 和列号 j 通常是从1开始的。

2. 三角矩阵

[!NOTE] 下三角矩阵

下三角矩阵中,上三角区的所有元素均为同一常量。其存储思想与对称矩阵类似,不同之处在于存储完下三角区和主对角线上的元素之后,紧接着存储对角线上方的常量一次,所以可以将 \(n\) 阶下三角矩阵 \(A\) 压缩存储在 \(B[n(n+1)/2+1]\) 中。

元素下标之间的对应关系为:

\[k=\begin{cases} \frac{i(i-1)}{2}+j-1,&i\geq j(下三角区和主对角线元素) \\ \frac{n(n+1)}{2},&i<j(上三角区元素) \end{cases} \]

由于上三角区域全是相同的常数,所以涉及到上三角直接访问一维数组的最后一个元素即可。这里和对称矩阵唯一的区别就是常数部分被存储在了数组的最后一个位置。

[!NOTE] 上三角矩阵

上三角矩阵中,下三角区的所有元素均为同一常量。只需存储主对角线、上三角区上的元素和下三角区的常量一次,可讲其压缩存储在 B[n(n+1)/2+1] 中。

在数组 \(B\) 中,位于元素 \(a_{i,j}(i\leq j)\) 前面的元素个数为:

  • \(1\) 行:\(n\) 个元素
  • \(2\) 行:\(n-1\) 个元素
  • ...
  • \(i-1\) 行:\(n-i+2\) 个元素
  • \(i\) 行:\(j-i\) 个元素

因此

元素下标之间的对应关系如下:

\[k=\begin{cases} \frac{(i-1)(2n-i+2)}{2}+(j-i),&i\leq j(上三角区和主对角线元素) \\ \frac{n(n+1)}{2},&i>j(下三角区元素) \end{cases} \]

主对角线上方的元素的关系和前面对称矩阵有所不同。这是因为对称矩阵是填满了的,所以可以直接按照列来计算,而上三角矩阵则为了节省空间按照行优先的原则存储。这就导致需要一行一行地数,所以是一个递减的数列。

[!NOTE] 如何理解
这部分内容很简单,建议画一个大一点的矩阵自己推导一番,这样就能真正理解这个公式了。

3. 三对角矩阵

对角矩阵也叫带状矩阵

  • 定义:对 \(n\) 阶矩阵 \(A\) 中任意一个元素 \(a_{i,j}\),当 \(|i-j|>1\) 时,若有 \(a_{i,j}=0(1\leq i,j\leq n)\),则称为三对角矩阵。
  • 特征:在三对角矩阵中,所有非零元素都集中在以主对角线为中心的3条对角线的区域,其他区域的元素都为零。

三对角矩阵 \(A\) 也可以采用压缩存储,将3条对角线上的元素按行优先方式存放在一维数组B中,且 \(a_{1,1}\) 存放于 \(B[0]\) 中。

也就是一行一行地读取并放入一维数组中。

由此可以计算矩阵 \(A\) 中3条对角线上的元素 \(a_{i,j}(1\leq i,j \leq n,|i-j|\leq 1)\) 在一维数组B中存放的下标为 \(k=2i+j-3\)

反之,若已知三对角矩阵中的某个元素 \(a_{i,j}\) 存放在一维数组B的第 \(k\) 个位置,则有 \(i=\lfloor (k+1)/3+1 \rfloor\)\(j=k-2i+3\)。例如,当 \(k=0\) 时,\(i=\lfloor (0+1)/3+1 \rfloor=1\)\(j=0-2\times1+3=1\),存放的是 \(a_{1,1}\);当 \(k=2\) 时,\(i=\lfloor (2+1) / 3+1 \rfloor=2\)\(j=2-2\times2+3=1\),存放的是 \(a_{2,1}\);当 \(k=4\) 时,\(i=\lfloor (4+1) /3+1 \rfloor=2\)\(j=4-2\times2+3=3\),存放的是 \(a_{2,3}\)

这一块虽然王道讲的很简略,但实际做题目真的很简单,所以如果觉得难理解(下标的计算细节)可以直接去做一下题试试。

3.4.4 稀疏矩阵

矩阵中非零元素的个数t,相对矩阵元素的个数s来说非常少,即 \(s\gg t\) 的矩阵称为系数矩阵

例如,一个矩阵的阶为 \(100\times100\),该矩阵中的元素少于100个非零元素。

若采用常规的方法存储稀疏矩阵,则相当浪费存储空间,因此仅存储非零元素。但通常非零元素的分布没有规律,所以仅存储非零元素的值是不够的,还要存储它所在的行和列。

因此,将非零元素及其相应的行和列构成一个三元组(行标i,列标j,值 \(a_{i,j}\)),然后按照某种规律来存储这些三元组线性表。

  • 缺点
    • 系数矩阵压缩存储后便失去了随机存取特性
  • 存储结构
    • 十字链表存储
    • 数组存储
  • 保存的信息
    • 行数
    • 列数
      • 用于判断稀疏矩阵的大小,方便还原。
    • 非零元素的个数。
      • 用于判断稀疏矩阵的稀疏程度。
posted @ 2026-05-16 00:51  syn_tax  阅读(8)  评论(0)    收藏  举报