【数据结构】3. 栈和队列

3.1 栈

3.1.1 栈的基本概念

(1)栈的定义

(Stack):只允许在一端进行插入或删除操作的线性表。首先栈是一种线性表,但是限定这种线性表只能在某一端进行插入和删除操作,如图 3-1 所示。
栈顶(Top):线性表允许进行插入和删除的那一端。
栈底(Bottom):固定的,不允许进行插入和删除的另一端。
空栈:不含任何元素的空表。
假设某个栈 \(S=(a_1, a_2, a_3, a_4, a_5)\),如图 3-1 所示,则 \(a_1\) 为栈底元素,\(a_5\) 为栈顶元素。
由于栈只能在栈顶进行插入和删除操作,故进栈次序依次为 \(a_1\)\(a_2\)\(a_3\)\(a_4\)\(a_5\),而出栈次序为 \(a_5\)\(a_4\)\(a_3\)\(a_2\)\(a_1\)
由此可见,栈的一个明显的操作特性可以概括为后进先出(Last In First Out, LIFO),故又称为后进先出的线性表。

注意:
我们每接触到一种新的数据结构类型,都应该分别从其逻辑结构、存储结构和对数据的运算三个方面着手,以加深对定义的理解。

(2)栈的基本操作

各种辅导书中给出的基本操作的名称不尽相同,但所表达的意思大致是一样的。
这里我们以严蔚敏编写的教材为准给出栈的基本操作,希望读者能熟记下面的基本操作:

  • InitStack(&S):初始化一个空栈 S。
  • StackEmpty(S):判断一个栈是否为空,若栈 S 为空返回 true,否则返回 false。
  • Push(&S, x):进栈,若栈 S 未满,将 x 加入使之成为新栈顶。
  • Pop(&S, &x):出栈,若栈 S 非空,弹出栈顶元素,并用 x 返回。
  • GetTop(S, &x):读栈顶元素,若找 S 非空,用 x 返回栈顶元素。
  • ClearStack(&S):销毁栈,并释放栈 S 占用的存储空间。

(注:符号“&”是 C++ 特有的,用来表示引用调用,有的书上采用 C 语言中的指针类型“*”,也可以达到传址的目的。)
在解答算法题时,若题干没有做出限制,可以直接使用这些基本的操作函数。

3.1.2 栈的顺序存储结构

(1)顺序栈的实现

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

#define MaxSize 50 //定义找中元素的最大个数
typedef struct {
    Elemtype data[MaxSize]; //存放栈中元素
    int top; //栈顶指针
} SqStack;

栈顶指针:S.top,初始时设置 S.top = -1
栈顶元素:S.data[S.top]
进栈操作:栈不满时,栈顶指针先加 1,再送值到栈顶元素。
出栈操作:栈非空时,先取栈顶元素值,再将栈顶指针减 1。
栈空条件:S.top == -1
栈满条件:S.top == MaxSize-1;
栈长:S.top+1

由于顺序栈的入栈操作受数组上界的约束,当对栈的最大使用空间估计不足时,有可能发生栈上溢,此时应及时向用户报告消息,以便及时处理,避免出错。
对于栈和后面提到的队列的判空和判满条件会因为实际给的条件不同而变化,以上提到的方法以及下面给出的代码实现只是在栈顶指针设定的条件下相应的方法,而其他情况需要
具体问题具体分析。

(2)栈的基本运算

栈操作的示意图如图 3-2 所示,图 3-2(a) 是空栈,图 3-2(c) 是 A、B 、C、D、E 共 5 个元素依次入栈后的结果,图 3-2(d) 是在图 3-2(c) 之后 E、D、C 相继出栈,此时栈中还有 2 个元素,或许最近出找的元素 C、D、E 仍在原先的单元存储着,但 top 指针己经指向了新的栈顶,则元素 C、D、E 己不在栈中了,读者应通过该示意图深刻理解栈顶指针的作用。

下面是顺序栈上常用的基本运算的实现。

  1. 初始化
    void InitStack(&S) {
        s.top = -1; //初始化找顶指针
    }
    
  2. 判栈空
    bool StackEmpty(S) {
        if(s.top == -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; //指针先加 1,再入栈
        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;
    }
    

注意:
这里栈顶指针指向的就是栈顶元素,所以进栈时的操作是 S.data[++S.top] = x;出栈时的操作是 x = S.data[S.top--]
如果栈顶指针初始化为 S.top = 0,即栈顶指针指向栈顶元素的下一个位置,则入栈操作变为 S.data[S.top++] = x;出栈操作变为 x = S.data[--S.top]
相应的栈空、栈满条件也会发生变化。请读者仔细体会其中的不同之处,做題时也应灵活应变。

(3)共享栈

利用栈底位置相对不变的特性,可以让两个顺序栈共享一个一维数据空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸,如图 3-3 所示。

两个栈的栈顶指针都指向栈顶元素,top0 = -1 时 0 号栈为空,top1 = MaxSize 时 1 号桟为空;
仅当两个栈顶指针相邻(top1-topO = 1)时,判断为栈满。
当 0 号栈进栈时 topO 先加 1 再赋值,1 号栈进栈时 top1 先减 1 再赋值;出栈时则刚好相反。
共享栈是为了更有效地利用存储空间,两个栈的空间相互调节,只有在整个存储空间被占满时才发生上溢。
其存取数据的时间复杂度均为 \(\mathcal{O}(1)\),所以对存取效率没有什么影响。

3.1.3 栈的链式存储结构

采用链式存储的栈称为链栈,链栈的优点是便于多个栈共享存储空间和提高其效率,且不存在栈满上溢的情况。
通常采用单链表实现,并规定所有操作都是在单链表的表头进行的。
这里规定链栈没有头结点,Lhead 指向栈顶元素,如图 3-4 所示。

栈的链式存储类型可描述为

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

采用链式存储,便于结点的插入与删除。 链栈的操作与链表类似,在此不做详细讨论。
读者需要注意的是,对于带头结点和不带头结点的链栈,在具体的实现方面有所不同。

3.2 队列

3.2.1 队列的基本概念

(1)队列的定义

队列(Queue):队列简称队,也是一种操作受限的线性表,只允许在表的一端进行插入,而在表出队列一的另一端进行删除。
向队列中插入元素称为入队或进队:删除元素称为出队或离队。
这和我们日常生活中的排队是一致的,最早排队的也是最早离队的。

其操作的特性是先进先出(First In First Out, FIFO),故又称为先进先出的线性表,如图 3-5 所示。
队头(Front):允许删除的一端,又称为队首。
队尾(Rear):允许插入的一端。
空队列:不含任何元素的空表。

(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 分别指示队头元素和队尾元素的位置。
设队头指针指向队头元素,队尾指针指向队尾元素的下一个位置(也可以让 rear 指向队尾元素,front 指向队头元素的前一个位罝,对于这种设置方法,请读者以图 3-6 为例思考出队和入队后这两个指针的变化)。

队列的顺序存储类型可描述为:

#define MaxSize 50
typedef struct {
    ElemType data[MaxSize];
    int front,rear;
} SqQueue;

初始状态(队空条件):Q.ftont == Q.rear==0
进队操作:队不满时,先送值到队尾元素,再将队尾指针加 1。
出队操作:队不空时,先取队头元素值,再将队头指针加 1。
如图 3-6(a) 所示为队列的初始状态,有 Q.front == Q.rear == 0 成立,该条件可以作为队列判空的条件。
但能否用 Q.rear == MaxSize 作为队列满的条件呢?
显然不能,图 34(d) 中,队列中仅有 1 个元素,但仍满足该条件。
这时入队出现 “上溢出”,但这种溢出并不是真正的溢出,在 data 数组中依然存在可以存放元素的空位置,所以是一种“假溢出”。

(2)循环队列

前面已指出了顺序队列的缺点,这里我们引出循环队列的概念。
将顺序队列臆造为一个环状的空间,即把存储队列元素的表从逻辑上看成一个环,称为循环队列。
当队首指针 Q.front=MaxSize-1 后,再前进一个位置就自动到 0, 这可以利用除法取余运算(%)来实现。

初始时:Q.front = Q.rear = 0
队首指针进 1:Q.front = (Q.front+1) % MaxSize
队尾指针进 1:Q.rear = (Q.rear+1) % MaxSize
队列长度:(Q.rear+MaxSize-Q.front) % MaxSize

出队入队时:指针都按顺时针方向进 1(如图 3-7 所示)。
那么,循环队列队空和队满的判断条件是什么呢?
显然,队空的条件是 Q.front == Q.rear
如果入队元素的速度快于出队元素的速度,队尾指针很快就赶上了队首指针,如图 3-7(d1) 所示,此时可以看出队满时也有 Q.front == Q.rear
循环队列出入队示意图如图 3-7 所示。

为了区分队空还是队满的情况,有三种处理方式:

  1. 牺牲一个单元来区分队空和队满,入队时少用一个队列单元,这是一种较为普遍的做法,约定以“队头指针在队尾指针的下一位置作为队满的标志”,如图 3-7(d2) 所示。
    队满条件为:(Q.rear+1) % MaxSize == Q.front
    队空条件为:Q.front == Q.rear
    队列中元素的个数:(Q.rear-Q.front+MaxSize) % MaxSize
  2. 类型中增设表示元素个数的数据成员。
    这样,则队空的条件为 Q.size == 0;队满的条件为 Q.size == MaxSize
    这两种情况都有 Q.front == Q.rear
  3. 类型中增设 tag 数据成员,以区分是队满还是队空。
    tag 等于 0 的情况下,若因删除导致 Q.front == Q.rear 则为队空;
    tag 等于 1 的情况下,若因插入导致 Q.front == Q.rear 则为队满。

(3)循环队列的操作

  1. 初始化
    void InitQueue(&Q) {
        Q.rear = Q.front = 0; //初始化队首、 队M指针
    }
    
  2. 判队空
    bool isEmpty(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)队列的链式存储

队列的链式表示称为链队列,它实际上是一个同时带有队头指针和队尾指针的单链表。
头指针指向队头结点,尾指针指向队尾结点,即单链表的最后一个结点(注意与顺序存储的不同)。
队列的链式存储如图 3-8 所示:

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

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

Q.front==NULLQ.rear==NULL 时, 链式队列为空。
出队时,首先判断队是否为空,若不空,则取出队头元素,将其从链表中摘除,并让 Q.front 指向下一个结点(若该结点为最后一个结点,则置 Q.frontQ.rear 都为 NULL)。
入队时,建立一个新结点,将新结点插入到链表的尾部,并改让 Q.rear 指向这个新插入的结点(若原队列为空队,则令 Q.front 也指向该结点)。
不难看出,不设头结点的链式队列在操作上往往比较麻烦,因此,通常将链式队列设计成一个带头结点的单链表,这样插入和删除操作就统一了,如图 3-9 所示。

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

(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) {
        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; //空队
        p = Q.front->next;
        x = p->data;
        Q.front->next = p->next;
        if(Q.rear==p)
            Q.rear = Q.front;//若原队列中只有一个结点,删除后变空
        free(p);
        return true;
    }
    

3.2.4 双端队列

双端队列是指允许两端都可以进行入队和出队操作的队列,其元素的逻辑结构仍是线性结构,如图 3-10 所示。
将队列的两端分别称为前端和后端,两端都可以入队和出队 。

在双端队列进队时:前端进的元素排列在队列中后端进的元素的前面,后端进的元素排列在队列中前端进的元素的后面。
在双端队列出队时:无论前端还是后端出队,先出的元素排列在后出的元素的前面。
思考:如何由入队序列 a,b,c,d 得到出队序列 d,c,a,b?
输出受限的双端队列:允许在一端进行插入和删除,但在另一端只允许插入的双端队列称为输出受限的双端队列,如图 3-11 所示。

输入受限的双端队列:允许在一端进行插入和删除,但在另一端只允许删除的双端队列称为输入受限的双端队列,如图 3-12 所示。
而如果限定双端队列从某个端点插入的元素只能从该端点删除,则该双端队列就蜕变为两个栈底相邻接的栈了。

3.3 栈和队列的应用

要熟练掌握栈和队列,必须学习栈和队列的应用,把握其中的规律,然后才能举一反三。接
下来将简单介绍栈和队列的一些常见应用。

3.3.1 栈在括号匹配中的应用

假设表达式中允许包含两种括号:圆括号和方括号,其嵌套的顺序任意,即 ([]())[([][])]等均为正确的格式,
[(])([())(()]均为不正确的格式。
考虑下列括号序列:
[ ( [ ] [ ] ) ]
1 2 3 4 5 6 7 8
分析如下:

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

算法的思想如下:

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

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

表达式求值是程序设计语言编译中一个最基本的问题,它的实现是栈应用的一个典型范例。
中缀表达式不仅依赖运算符的优先级,而且还要处理括号。
后缀表达式的运算符在操作数后面,在后缀表达式中己考虑了运算符的优先级,没有括号,只有操作数和运算符。
中缀表达式 A+B*(C-D)-E/F 所对应的后缀表达式为 ABCD-*+EF/

中缀表达式转化为后缀表达式的过程,见本章习题 3.3.6 第 11 题的解析,这里不再赘述。
读者也可以将后缀表达式与原运算式对应的表达式树(用来表示算术表达式的二元树,如图 3-15)的后序遍历进行比较,可以发现它们有异曲冋工之妙。

通过后缀表示计算表达式值的过程为:
顺序扫描表达式的每一项,然后根据它的类型做如下相应操作:
如果该项是操作数,则将其压入栈中;
如果该项是操作符, 则连续从栈中退出两个操作数 Y 和 X, 形成运算指令 XY, 并将计算结果重新压入栈中。
当表达式的所有项都扫描并处理完后,栈顶存放的就是最后的计算结果。
例如,后缀表达式 ABCD-*+EF/- 求值的过程需要 12 步,见表 3-1。

3.3.3 栈在递归中的应用

递归是一种重要的程序设计方法。
简单地说,如果在一个函数、过程或数据结构的定义中又应用了它自身,那么这个函数、过程或数据结构称为是递归定义的,简称递归。
它通常把一个大型的复杂问题,层层转化为一个与原问题相似的规模较小的问题来求解。
递归策略只需少量的代码就可以描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
但在通常情况下,它的效率并不是太髙。
以斐波那契数列为例,其定义为

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

这就是递归的一个典型例子,用程序来实现如下:

int Fib(n){ //斐波那契数列的实现
    if(n==0)
        return 0; //边界条件
    else if(n==1)
        return 1; //边界条件
    else
        return Fib(n-1)+Fib(n-2); //递归表达式
}

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

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

递归的精髓在于能否将原始问题转换为属性相同但规模较小的问题。

在递归调用的过程中,系统为每一层的返回点、局部变量、传入实参等开辟了递归工作栈来进行数据存储,递归次数过多容易造成桟溢出等。
而其效率不高的原因是递归调用过程中包含很多重复的计算
下面以 n=5 为例,列出递归调用执行过程,如图 3-16 所示。

显然,在递归调用的过程中,Fib(3) 被计算了 2 次,Fib(2)被计算了 3 次。
Fib(1) 被调用了 5 次,Fib(0) 被调用了 3 次。
所以,递归的效率低下,但优点是代码简单,容易理解。
在第 4 章的树中利用了递归的思想,代码将会变得十分简单。
通常情况下,初学者对于递归的调用过程很难理解,若读者想具体了解递归是如何实现的,可以参阅《编译原理》 的相关内容。
可以将递归算法转换为非递归算法,通常潘要借助栈来实现这种转换。

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

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

表 3-2 显示了层次遍历二叉树的过程。

该过程的简单描述如下:

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

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

队列在计算机系统中的应用非常广泛,以下仅从两个方面来简述队列在计算机系统中的作用:
第一个方面是解决主机与外部设备之间速度不匹配的问题,
第二个方面是解决由多用户引起的资源竞争问题。

对于第一个方面,仅以主机和打印机之间速度不匹配的问题为例作简要说明。
主机输出数据给打印机打印,输出数据的速度比打印数据的速度要快得多,由于速度不匹配,若直接把输出的数据送给打印机打印显然是不行的。
解决的方法是设置一个打印数据缓冲区,主机把要打印输出的数据依次写入到这个缓冲区中,写满后就暂停输出,转去做其他的事情。
打印机就从缓冲区中按照先进先出的原则依次取出数据并打印,打印完后再向主机发出请求。
主机接到请求后再向缓冲区写入打印数据。
这样做既保证了打印数据的正确,又使主机提高了效率。
由此可见,打印数据缓冲区中所存储的数据就是一个队列。

对于第二个方面,CPU(即中央处理器,它包括运算器和控制器)资源的竞争就是一个典型的例子。
在一个带有多终端的计算机系统上,有多个用户需要 CPU 各自运行自己的程序,它们分别通过各自的终端向操作系统提出占用 CPU 的请求。
操作系统通常按照每个请求在时间上的先后顺序,把它们排成一个队列,每次把 CPU 分配给队首请求的用户使用。
当相应的程序运行结束或用完规定的时间间隔后,则令其出队,再把 CPU 分配给新的队首请求的用户使用。
这样既满足了每个用户的请求,又使 CPU 能够正常运行。

3.4 特殊矩阵的压缩存储

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

3.4.1 数组的定义

数组是由 n(\(n\gt 1\))个相同类型的数据元素构成的有限序列,
每个数据元素称为一个数组元素,每个元素受 n 个线性关系的约束,
每个元素在 n 个线性关系中的序号称为该元素的下标,并称该数组为 n 维数组。

数组与线性表的关系:数组是线性表的推广。
一维数组可以看做是一个线性表;二维数组可以看做元素是线性表的线性表,依此类推。
数组一旦被定义,它的维数和维界就不再改变。
因此,除了结构的初始化和销毁之外,数组只会有存取元素和修改元素的操作。

3.4.2 数组的存储结构

大多数计算机语言都提供了数组数据类型,逻辑意义上的数组可以采用计算机语言中的数组数据类型进行存储,一个数组的所有元素在内存中占用一段连续的存储空间。
以一维数组 A[n] 为例,其存储结构关系式为

\[LOC(a_i) = LOC(a_0) + (i)\times L \, (0\le i\lt n) \]

其中,L 是每个数组元素所占存储单元。

对于多维数组,有两种映射方法:按行优先和按列优先。
以二维数组为例,按行优先存储的基本思想是:先行后列,先存储行号较小的元素,行号相等先存储列号较小的元素。
设二维数组的行下标与列下标的范围分别为 \([l_1, h_1]\)\([l_2, h_2]\),则存储结构关系式为

\[LOC(a_{i,j}) = LOC(a_{l_1,l_2}) + [(i-l_1) * (h_2-l_2+1)+(j-l_2)] * L \]

假设 \(l_1\)\(l_2\) 的值均为 0,则上式变为

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

例如,对于数组 \(A_{2\times 3}\), 它按行优先方式在内存中的存储形式如图 3-19 所示。

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

\[LOC(a_{i,j}) = LOC(a_{l_1,l_2}) + [(j-l_2)*(h_1-l_1+1)+(i-l_1)]* L \]

例如,对于数组 \(A_{2\times 3}\),它按列优先方式在内存中的存储形式如图 3-20 所示。

3.4.3 矩阵的压缩存储

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

(1)对称矩阵

若一个 n 阶方阵 A[n][n] 中的任一个元素 \(a_{i.j}\),都有 \(a_{i,j}=a_{j,i}\)\(1\le i,j\le n\)),则称其为对称矩阵。
对于一个 n 阶方阵,其中元素可以划分为 3 个部分,即上三角区、 主对角线和下三角区,如图 3-21 所示。

对于 n 阶对称矩阵,上三角区所有元素和下三 角区对应元素相同,如果还采用二维数组存放,就会浪费几乎一半的空间,
为此将对称矩阵 A[n][n]存放在一维数组 B[n(n+1)/2]中,即元素 \(a_{i,j}\) 存放在 \(b_k\) 中。
只存放主对角线和下三角区的元素。
在数组 B 中,位于元素 \(a_{i,j}\)\(i\ge j\)) 前面的元素个数为
第 1 行:1 个元素(\(a_{1,1}\));
第 2 行:2 个元素 (\(a_{2,1}, a_{2,2}\));
……
第 i-1 行:i-1 个元素(\(a_{i-1,1}, a_{i-1,2},\cdots, a_{i-1,i-1}\)
第 i 行:j-1 个元素(\(a_{i,1}, a_{i,2}, a_{i,j-1}\)
故,元素 \(a_{i,j}\) 在数组 B 中的下标 \(k= 1+2+\cdots +(i-1) + j-1 = (i-1)i/2 + j-1\)(数组下标从 0 开始)。
因此,元素下标之间的对应关系如下:

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

当数组下标从 1 开始时,可以采用同样的推导方法,请读者自行思考。

(2)三角矩阵

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

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

下三角矩阵的在内存中的压缩存储形式如图 3-22 所示。

上三角矩阵(见图 3-23(b))中,下三角区的所有元素均为同一常量。

只需存储主对角线、上三角区上的元素和下三角区的常量一次,可以将其压缩存储在 B[n(n+1)/2+1] 中。

在数组 B 中,位于元素 \(a_{i,j}\)\(i\le j\))前面的元素个数
第 1 行:n 个元素
第 2 行:n-1 个元素
……
第 i-1 行:n-i+2个元素
第 i 行:j-i 个元素

故,元素 \(a_{i,j}\) 在数组 B 中的下标 \(k = n+(n-1)+\cdots+(n-i+2)+(j-i+1)-1 = (i-1)(2n-i+2)/2+(j-i)\)
因此,元素下标之间的对应关系如下:

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

上三角矩阵的在内存中的压缩存储形式如图 3-24 所示。

以上推导均假设数组的下标是从 0 开始,若題设有具体要求,则应该灵活应对。

(3)三对角矩阵

对角矩阵也称为带状矩阵。
对于 n 阶方阵 A 中的任一元
\(a_{i,j}\),当 \(|i-j|\gt 1\) 时,有 \(a_{i,j}=0\)\(1\le i,j\le n\)),则称为三对角矩阵,如图 3-25 所示。

在三对角矩阵中,所有非零元素都集中在以主对角线为中心的 3 条对角线的区域中,其他区域的元素都为零。
三对角矩阵 A 也可以采用压缩存储,将 3 条对角线上的元素按行优先方式存放在一维数组 B 中,且 \(a_{1,1}\) 存放于 B[0] 中,其存储形式如图 3-26 所示。

由此可以计算矩阵 A 中 3 条对角线上的元素 \(a_{i,j}\)\(1\le i,j \le n,\, |i-j|\le 1\)) 在一维数组 B 中存放的下标为 k=2i+j-3。
反之,若已知三对角线矩阵中某元素 \(a_{i,j}\) 在一维数组 B 中存放于第 k 个位罝,则可求得
\(i=\lceil(k+1)/3+1\rceil, j=k-2i+3\)
例如,当 k=0 时,\(i= \lceil(0+1)/3+1\rceil =1, j=0-2\times 1+3=1\), 存放的是 \(a_{i,1}\)
当 k=2 时,\(i=\lceil(2+1)/3+1\rceil=2, j=2-2\times 2+3=1\),存放的是 \(a_{2,1}\)
当 k=4 时,\(i=\lceil(4+1)/3+1\rceil=2, j=4-2\times 2+3=3\),存放的是 \(a_{2,3}\)

3.4.4 稀疏矩阵

矩阵元素个数 s 相对于矩阵中非零元素个数 t 来说非常多,即 \(s\gg t\) 的矩阵称为稀疏矩阵。
例如,若一个矩阵的阶为 \(100\times 100\),而该矩阵中只有少于 100 个非零元素。

如果采用常规的方法存储稀疏矩阵,那将相当浪费存储空间,因此仅存储非零元素。
但通常零元素的分布没有规律,所以,仅存储非零元素的值是不够的,还要存储它所在的行和列。
因此,将非零元素及其相应的行和列构成一个三元组(行标,列标,值),如图 3-27 所示,然后再按照某种规律存储这些二元组。
稀疏矩阵压缩存储后便失去了随机存取特性。

posted @ 2018-07-30 04:56  4thirteen2one  阅读(839)  评论(0编辑  收藏  举报