第5章 数组和广义表
前面讨论的线性结构的数据元素都是非结构的原子类型,即元素的值是不再分解的。
本章讨论的数组和广义表可以看成是线性表的扩展,因为表中的数据元素本身也是一个数据结构。
5.1 数组的定义
和线性表一样,数组的所有数据元素都必须属于同一数据类型。
数组中的每个元素都对应着一组下标(),每个下标的取值范围是,是数组第i维的长度(i=1, 2, ..., n)。
当n=1时,n维数组就退化为定长的线性表。
n维数组含有个数据元素,每个数据元素都受着n个关系的约束。
每个关系中,元素()都有一个直接后继元素,因此,就其单个关系而言,这n个关系仍是线性关系。
二维数组可以看成是(每个数据元素也是一个定长线性表的)线性表,同理一个n维数组类型可以定义为(其数据元素为n-1维数组类型的)一维数组类型。
数组一旦被定义,它的维数和维界就不再改变。因此除了结构的初始化和销毁之外,数组只有存取元素和修改元素值的操作。
5.2 数组的顺序表示和实现
由上一条特性,数组自然地采用顺序存储结构表示。
由于存储单元是一维的,所以二维数组有两种存储方式:
在扩展BASIC、PL/1、COBOL、PASCAL和C语言中,用的都是以行序为主序的存储结构;而在FORTRAN语言中,用的是以列序为主序的存储结构。
- 以列序为主序(column major order):
- 以行序为主序(row major order):
假设每个数据元素占L个存储单元,则二维数组A中任一元素的存储位置可由下式确定
式中LOC(0,0)是二维数组A的起始存储位置( ),也称为基地址或基址; 是第二维(每列)的元素个数。由上,得到n维数组的数据元素存储位置的计算公式:
即为每一维的位置乘以更高维长度之积,再加上第n维的位置,最后将总和乘以每个数据元素所占的存储单元,加上基地址即为所求的数据元素的存储位置。称为n维数组的映像函数。数组元素的存储位置是其下标的线性函数,一旦确定了数组各维的长度,就是常数。
计算各个元素存储位置的时间相等,则存取数组中任一元素的时间也相等。这种存储结构为随机存储结构。
5.3 矩阵的压缩存储
- 通常用高级语言编制程序时,都是用二维数组来存储矩阵元。但在数值分析中经常出现一些阶数很高的矩阵,且有很多值相同的元素或零元素。为了节省存储空间,可以对这些矩阵进行压缩存储。
- 压缩存储是指:为多个值相同的元素只分配一个空间、对零元素不分配空间。
- 若值相同的元素或零元素在矩阵中的分布有一定规律,则称此类矩阵为特殊矩阵;反之称为稀疏矩阵。
5.3.1 特殊矩阵
对于对称矩阵,可以为每一对对称元只分配一个存储空间,不失一般性,我们可以以行序为主序存储在其下三角(包括对角线)中的元。
这样就可以将个元压缩存储到n(n+1)/2个元的空间中。
这样就可以用大小为n(n+1)/2的一维数组存储n阶对称矩阵。
重点是如何将一维数组下标k与对称矩阵元素(i,j)间建立一一对应的关系(数组从0开始,矩阵从1开始):
- 当时,按下三角存储,上方有i-1行,每行i个元素,共计i(i-1)/2个元素;在本行中是第j个,且数组从0计,所以k=i(i-1)/2+j-1;
- 当i
称此数组为n阶对称矩阵A的压缩存储:
这种压缩方法同样也适用于三角矩阵和对角矩阵。
在对称矩阵、三角矩阵和对角矩阵等特殊矩阵中,非零元的分布都有一个明显的规律,从而可以压缩存储到一维数组中,并找到每个非零元在一维数组中的对应关系。
若非零元较零元少,且分布没有一定规律,这种矩阵的压缩存储就比特殊矩阵复杂。这就是下面要讨论的稀疏矩阵。
5.3.2 稀疏矩阵
假设在的矩阵中,有t个元素不为零,则矩阵的稀疏因子为。当稀疏因子不超过0.05时,称其为稀疏矩阵。
压缩存储是指存储矩阵的非零元。因此,除了存储非零元的值之外,还必须同时记下它的行标和列标(均从1开始)。这样,矩阵的一个非零元和一个三元组就互相唯一确定了。
稀疏矩阵可由表示非零元的三元组及其行列数唯一确定。
由三元组表示的不同方法可引出稀疏矩阵不同的压缩方法(两个顺序表、一个链表)。
三元组顺序表——转置
三元组顺序表就是以顺序存储结构来表示三元组表。
在顺序表中,三元组以行序为主序排列。这样做将有利于进行某些矩阵运算。
矩阵的转置运算:
- 将矩阵的行列值相互交换;
- 将每个三元组中的i和j相互交换;
- 重排三元组之间的次序。
转置的前两步很容易,关键是第三步。可以有两种处理方法:
按照转置后排好的顺序,依次在转置前的顺序表中找到相应的三元组进行转置。即按照矩阵的列序来进行转置。
需要遍历顺序表,依次寻找第1列、第2列……第n列的元素,而顺序表开始时是以行序为主序存放的,所以每一列时的行序是按顺序的。
算法如下:
Status TransposeSMatrix(TSMatrix M, TSMatrix &T) {
T.mu = M.nu;
T.nu = M.mu;
T.tu = M.tu;
if (T.tu) {
q = 1;
for (col = 1; col <= M.nu; ++col)
for (p = 1; p <= M.tu; ++p)
if (M.data[p].j == col) {
T.data[q].i = M.data[p].j;
T.data[q].j = M.data[p].i;
T.data[q].e = M.data[p].e;
++q;
}
}
return OK;
}时间复杂度为O(nu·tu),而一般转置算法的时间复杂度为O(mu·nu)。要想用此算法提高效率,则必须满足tu远小于mu*nu。
按照转置前顺序表中三元组的次序进行转置,并将转置结果置入转置后的顺序表的恰当位置。
为确定这些位置,应先求得矩阵每一列中非零元的个数,对应转置后的每一行的非零元的个数,进而通过累加求得每一列的第一个非零元在转置后的顺序表中应有的位置。(思路类似于实验17的基数排序)
附设两个向量:
num[col]表示矩阵第col列中非零元的个数;
cpot[col]表示矩阵第col列的第一个非零元在转置后的顺序表中的恰 当位置。
显然:
cpot[1]=1;
cpot[col]=cpot[col-1]+num[col-1],2≤col≤nu。
这种转置方法称为快速转置,算法如下:
Status FastTransposeSMatrix(TSMatrix M, TSMatrix &T) {
T.mu = M.nu;
T.nu = M.mu;
T.tu = M.tu;
if (T.tu) {
for (col = 1; col <= M.nu; ++col)
num[col] = 0;
for (t = 1; t <= M.tu; ++t)
++num[M.data[t].j];
cpot[1] = 1;
for (col = 2; col <= M.nu; ++col)
cpot[col] = cpot[col-1] + num[col-1];
for (p = 1; p <= M.tu; ++p) {
col = M.data[p].j;
q = cpot[col];
T.data[q].i = M.data[p].j;
T.data[q].j = M.data[p].i;
T.data[q].e = M.data[p].e;
++cpot[col];
}
}
return OK;
}这种算法仅比前一个算法多用了两个辅助向量(可以求完
num
后直接累加,不必新建cpot
,即可只占一个向量的空间)。从时间上看,算法中有4个并列的单循环,循环次数分别为nu和tu,因而总的时间复杂度为O(nu+tu)。就算tu和mu*tu等数量级,其时间复杂度也才为O(mu*nu),和经典算法的时间复杂度相同。
三元组顺序表又称有序的双下标法,非零元在表中按行序有序存储,便于进行依行顺序处理的矩阵运算。
但是,若想存取某一行的非零元,则必须从头开始进行查找。
行逻辑链接的顺序表——乘法
为了便于随机存取任意一行的非零元,则需知道每一行的第一个非零元在三元组表中的位置。按照快速转置算法的思想,可以在矩阵的存储结构中创建一个指示“行”信息的辅助数组
rpos
。这种“带行链接信息”的三元组表就是行逻辑链接的顺序表。这种方法虽然只是多了一个行的信息,但是在矩阵乘法中可以体现其优越性。
M和N分别是m*n和n*s矩阵,经典的矩阵乘法算法的时间复杂度为O(m*n*s)。
若这两个矩阵均为稀疏矩阵并用三元组表存储时,不能使用经典算法。那么如何计算呢?设M*N=Q:
算法如下:
Status MultSMatrix(RLSMatrix M, RLSMatrix N, RLSMatrix &Q) {
if (M.nu != N.mu)
return ERROR;
Q.mu = M.mu;
Q.nu = N.nu;
Q.tu = 0;
if (M.tu * N.tu != 0) {
for (arow = 1; arow <= M.mu; ++arow) {
ctemp[] = 0;
Q.rpos[arow] = Q.tu + 1;
if (arow tp = M.rpos[arow+1];
else
tp = M.tu + 1;
for (p = M.rpos[arow]; p brow = M.data[p].j;
if (brow t = N.rpos[brow+1];
else
t = N.tu + 1;
for (q = N.rpos[brow]; q ccol = N.data[q].j;
ctemp[ccol] += M.data[p].e * N.data[q].e;
}
}
for (ccol = 1; ccol <= Q.nu; ++ccol)
if (ctemp[ccol]) {
if (++Q.tu > MAXSIZE)
return ERROR;
Q.data[Q.tu] = (arow, ccol, ctemp[ccol]);
}
}
}
return OK;
}
若M(i,k)和N(k,j)有一个值为零,则Q(i,j)为零,无需再进行计算。因此,为了得到非零的乘积,只需在M.data和N.data中找到相应的各对元素(M.data中j和N.data中i相等的各对元素)相乘即可。
也就是说,对M.data[1..M.tu]中的每个元素(i,k,M(i,k)),找到N.data中所有相应的元素(k,j,N(k,j))并相乘即可。
为此,需要在N.data中寻找矩阵N的第k行的所有非零元,此时
rpos
便派上用场了。由于矩阵Q的每个元素都是乘积之和,所以应对每个元素设一累计和的变量,通过扫描数组M.data,求得相应元素的乘积并累加。
两个稀疏矩阵相乘的乘积不一定是稀疏矩阵。同理,即使两个分量值相乘不为零,其累加值也可能为零。
所以应对累加后的值进行判断,为零的应舍去。(因为M.data已经是行主序的,所以可以对Q进行逐行处理,先求得累计求和的中间结果(Q的一行),然后再压缩存储到Q.data中去)
此算法的时间复杂度为:
- 累加器
ctemp
初始化的时间复杂度为O(M.mu*N.nu); - 求Q的所有非零元的时间复杂度为O(M.tu*N.tu/N.mu);
- 进行压缩存储的时间复杂度为O(M.mu*N.nu)。因此,总的时间复杂度就是O(M.mu*N.nu+M.tu*N.tu/N.mu)。
若矩阵M和矩阵N的稀疏因子均小于0.05,且M的列数(N的行数)小于1 000,则此算法的时间复杂度相当于O(m*s)。与经典算法的O(m*n*s)相比,这是一个相当理想的结果。
如果事先能估算出所求乘积矩阵Q不再是稀疏矩阵,则以二维数组表示Q,相乘的算法也就更简单了。
十字链表——加法
当进行矩阵加法操作时,由于非零元的插入,会引起顺序表中元素的移动。类似这种运算,矩阵的非零元个数和位置在操作过程中变化较大时,不宜采用顺序存储结构来表示三元组的线性表,应采用链式存储结构表示三元组的线性表。
在链表中,每个非零元可用一个含5个域的结点表示:
这样,每行通过
right
域链接成一个链表,每列通过down
域链接成一个链表。每个非零元既是某个行链表的一个结点,又是某个列链表的一个结点,整个矩阵构成了一个十字交叉的链表,故称为十字链表。
i
:行标j
:列标e
:非零元的值right
:指向同一行的下一个非零元down
:指向同一列的下一个非零元
这样,稀疏矩阵就可以由两个数组表示,它们分别存储行和列链表的头指针:
对于m行n列且有t个非零元的稀疏矩阵,创建十字链表的算法的时间复杂度为O(t*s),s=max{m,n}。因为每建立一个非零元的结点时都要寻查它在行表和列表中的插入位置,对非零元输入的先后次序没任何要求。
若按以行序为主序的次序依次输入三元组,则建立十字链表的算法可以是O(t)数量级的。
下面讨论如何将矩阵B加到矩阵A上:
两矩阵相加类似于两一元多项式相加,只不过有行值和列值两个变元。每个结点既在行表中又在列表中,致使插入和删除时指针的修改稍微复杂,故需更多的辅助指针。
以矩阵A为基准,只可能出现4种情况:(见下)
由此,整个运算过程可从矩阵的第一行起逐行进行,每一行都从表头出发,分别找到A和B在该行中的第一个非零元结点后开始比较:(见下)
为了便于插入和删除结点,还需要设立一些辅助指针:(见...此处由于文档转换后排版比较混乱,请读者自行寻找上下文)
进一步描述将矩阵B加到矩阵A上的操作过程:
当A的本行中的非零元已处理完,需要在本行插入新的结点
p
:if (pa == NULL || pa->j > pb->j) {
if (pre == NULL)
A.rhead[p->i] = p;
else
pre->right = p;
p->right = pa;
pre = p;
}再在对应列插入此结点
p
:if (!A.chead[p->j] || A.chead[p->j]->i > p->i) {
p->down = A.chead[p->j];
A.chead[p->j] = p;
} else {
p->down = hl[p->j]->down;
hl[p->j]->down = p;
}
hl[p->j] = p;第二种情况:
if (pa != NULL && pa->j j) {
pre = pa;
pa = pa->right;
}第三种情况:
if (pa->j == pb->j)
pa->e += pb->e;
if (pa->e == 0) {
if (pre == NULL)
A.rhead[pa->i] = pa->right;
else
pre->right = pa->right;
p = pa;
pa = pa->right;
if (A.chead[p->j] == p)
A.chead[p->j] = hl[p->j] = p->down;
else
hl[p->j]->down = p->down;
free(p);
}初始化:
pa = A.rhead[1];
pb = B.rhead[1];
pre = NULL;
for (j = 1; j hl[j] = A.chead[1];重复本步骤,依次处理本行结点,直至B的本行中再无非零元的结点(
pb==NULL
):若本行不是最后一行,则令
pa
和pb
指向下一行的第一个非零元结点,转上一步;否则结束。- 在A的行链表上设
pre
指针,指示pa
所指结点的前驱结点; - 在A的列链表上设
hl[j]
指针,初始化与每个列链表的头指针相同(hl[j]=chead[j]
)。
若
pa->e
+pb->e
!=0
,则只要将的值送到pa
所指结点的e
域即可;若
pa->e
+pb->e
==0
,则需要在A矩阵的链表中删除pa
所指的结点。注意,此时还需改变同一行中前一结点的
right
域值,以及同一列中前一结点的down
域值。若
pa == NULL
或pa->j
>pb->j
,则需在A矩阵的链表中插入一个值为的结点。注意,此时还需改变同一行中前一结点的
right
域值,以及同一列中前一结点的down
域值。若
pa->j
<pb->j
,则只要将pa
指针往后推进一步。若
pa->j
==pb->j
:
四种情况:
- 当、和二者之和均不为零时,;
- 当、均不为零、但二者之和为零时,删除结点;
- 当不为零而为零时,不变;
- 当为零而不为零时,插入新结点(等于)。
从一个结点来看,进行比较、修改指针所需的时间是一个常数;整个运算过程在于对A和B的十字链表逐行扫描,其循环次数主要取决于A和B矩阵中非零元素的个数ta和tb。
由此,算法的时间复杂度为O(ta+tb)。
5.4 广义表的定义
- 顾名思义,广义表是线性表的推广,也被称为列表(lists,用复数形式以示与统称的表list的区别)。
- 广义表广泛地用于人工智能等领域的表处理语言LISP语言,把广义表作为基本的数据结构,就连程序也表示为一系列的广义表。
- 广义表一般记作其中LS是广义表的名称,n、是它的长度。
- 在线性表中,只限于是单个元素。而在广义表中,可以是单个元素(称为原子),也可以是广义表(称为子表)(递归的定义)。习惯上,用大写字母表示广义表的名称,用小写字母表示原子。
- 当广义表非空时,称第一个元素为它的表头,称其余元素组成的表为它的表尾。
- E=(a,E)是一个递归的表,长度为2,相当于一个无限的列表。
- 列表是一个多层次的结构,可为其他列表所共享,可以是递归的表。
- 表头可以是原子和列表,表尾必定为列表。
5.5 广义表的存储结构
由于广义表中的数据元素可以具有不同的结构,所以难以用顺序存储结构表示,通常采用链式存储结构。
由于列表中的数据元素可能为原子或列表,所以需要两种结构的结点(不同之处用
union
)。非空列表可以分解成表头和表尾,故一对确定的表头和表尾可唯一确定列表。
因此一个表结点可以由3个域组成:
一个原子结点只需两个域:
- 标志域(公共部分,用以区分表结点和原子结点)
- 值域
- 标志域(公共部分,用以区分表结点和原子结点)
- 指示表头的指针域hp
- 指示表尾的指针域tp
设A=()、B=(e)、C=(a,(b,c,d))、D=(A,B,C)、E=(a,E),则它们的存储结构可以表示为:
可以看出:
- 空表的表头指针为空,非空列表的表头指针均指向一个表结点;
- 表结点的hp域指示该表表头(原子/表),tp域指示该表表尾(表/空);
- 容易分清列表中原子和子表所在层次:
- 最高层的表结点个数即为列表的长度。
5. 设A=()、B=(e)、C=(a,(b,c,d))、D=(A,B,C)、E=(a,E),则它们的另一种存储结构可以表示为:
表结点表示不变,原子结点增加tp指针域指向下一个元素结点。
5.6 m元多项式的表示
一般情况下,广义表不使用递归表,也不为其他表所共享。它的特点只是其中的元素可以是另一个广义表,这是它比线性表更灵活的地方,一个实例就是m元多项式的表示。
由于m元多项式中每一项的变化数目的不均匀性和变元信息的重要性,故不适于用线性表表示。
对于m元多项式,可以灵活地分解出一个变元,从而变成一个变元的多项式;随后再分解出第二个变元,等等。
由此,一个m元多项式首先是它的主变元的(一元)多项式,而其系数又是第二变元的多项式。每个多项式都可看作是由一个变量加上若干个系数指数偶对构成的。
只需在表结点和原子结点中添加指数域,并在原子结点中添加系数域,就可以用广义表表示一个m元多项式了。
5.7 广义表的递归算法
- 递归函数虽然结构清晰、程序易读、易证明正确性,但有时递归函数的执行效率很低,因此不能一味追求递归,使用递归应扬长避短。
- 下面以广义表为例,讨论如何利用“分治法”(Divide and Conquer)进行递归算法设计的方法。
- 和数学归纳法类似,递归定义由基本项和归纳项两部分组成:
- 基本项描述一个或几个递归过程的终结状态,一般情况下为n=0或n=1;
- 归纳项描述了如何从当前状态到终结状态的转化。
- 严格定义函数的功能和接口(只要接口一致,便可进行递归调用);
- 对每一次递归都看成只是一个简单的操作,切忌想得太深太远;
- 由归纳假设进行归纳证明时绝不能怀疑归纳假设的正确性。
5.7.1 求广义表的深度
广义表的深度定义为广义表中括弧的重数,是广义表的一种量度。
正如m元多项式中提到的,广义表的深度定义为多项式中变元的个数。
要求广义表的深度,可以求它每个元素的深度:(原子和表,见下)
可见,求广义表的深度的递归算法有两个终结状态:空表和原子。
最后在最外层广义表的每个元素的深度中找到最大值并加1,就得到了广义表的深度。
算法如下:
int GListDepth(GList L) {
if (!L)
return 1;
if (L->tag == ATOM)
return 0;
for (max = 0; pp = L; pp = pp->ptr.tp) {
dep = GListDepth(pp->ptr.hp);
if (dep > max)
max = dep;
}
return max + 1;
}
若为原子,则深度为0;
若为表,则和上述一样处理。
注意,空表的深度为1。
此算法实际上遍历了整个广义表,过程中求了各子表的深度,最后综合得到广义表的深度:
5.7.2 复制广义表
因为一对确定的表头和表尾可唯一确定一个广义表,所以复制广义表时只需分别复制其表头和表尾,然后合成即可。
算法如下(从L复制到T):
Status CopyGList(GList &T, GList L) {
if (!L) // null lists
T = NULL;
else {
if (!(T = (GList)malloc(sizeof(GLNode))))
exit(OVERFLOW);
T->tag = L->tag;
if (L->tag == ATOM) // atom
T->atom = L->atom;
else { // lists
CopyGList(T->ptr.hp, L->ptr.hp); // head
CopyGList(T->ptr.tp, L->ptr.tp); // tail
}
}
}
5.7.3 建立广义表的存储结构
上述两种广义表操作的递归算法分别是:
- 把广义表看成是含有n个并列子表(假设原子也视作子表)的表;
- 把广义表分解成表头和表尾两部分。
若采用第二种方法存储广义表,只需对表头和表尾存储并进行递归即可,算法和上一节的复制算法极为相似。
若采用第一种方法存储广义表,和求深度的算法类似(设S为广义表字符串):
基本项:
- 当S为空表串时,置空广义表;
- 当S为单字符串时,置空广义表;
归纳项:
对每一个非空子串建立一个表结点,令其
hp
域为建立的子表的头指针。其余表结点的尾指针均指向之后的表结点(最后建立的表结点的尾指针为NULL
)。