广义表

广义表是线性表的推广,也可称之为列表(lists)。广泛地用于人工智能等领域的表处理语言LISP语言,把广义表为基本的数据结构,就连程序也表示为一系列的广义表。

线性表被定义为一个有限的序列(a1,a2,a3,…,an)其中ai被限定为是单个数据元素。广义表也是n个数据元素d1,d2,d3,…,dn的有限序列,但不同的是,广义表中的di 则既可以是单个元素,还可以是一个广义表,通常记作:GL=(d1,d2,d3,…,dn)。GL是广义表的名字,通常广义表的名字用大写字母表示。n是广义表的长度。若其中di是一个广义表,则称di是广义表GL的子表。在广义表GL中,d1是广义表GL的表头,而广义表GL其余部分组成的表(d2,d3,…,dn)称为广义表的表尾。由此可见广义表的定义是递归定义的。因为在定义广义表时,又使用了广义表的概念。(百度百科,发现百度百科上就是抄的《数据结构》的原话嘛)

在广义表的定义中,元素a1可以是单个元素,也可以是广义表,分别称为广西表的原子子表

当广义表非空时,称第一个元素a1为广义表的表头,称其余元素组成的表是广义表的表尾。

广义表的定义是一个递归的定义,因为在描述广义表时又用到了广义表的概念。

根据定义,广义表的三条重要结论:

1.广义表的元素可以是子表,而子表的元素还可以是子表。由此,广义表是一个多层次结构;

2.广义表可为其他广义表共享(子表);

3.广义表可以是一个递归的表,即任何广义表可以是其本身的一个子表。

根据表头、表尾的定义可知:任何一个非空广义表其表头可能是原子,也可能是广义表,而其表尾必定为广义表。

注意:列表()和(())是不一样的,前者是空表,长度n=0,后者长度n=1,可分解得到其表头、表尾均为空表。即使成员表的长度为零,但因为有成员表,因此长度为1.这也体现了为什么说广义表是一个递归的表。

广义表的存储结构

由于广义表中的数据元素可以具有不同的结构(或原子或广义表),因此难以用顺序存储结构表示,通常采用链式存储结构,每个数据可用一个结点(还有升级空间啊,换上树的话鸟枪换炮啊)。

因为数据元素可能为原子或广义表,因此需要两种结构特点:一种是表结点,用以表示列表;一种是原子结点,用以表示原子。

反之,一对确定的表头表尾可唯一确定广义表。因此,一个表结点可由3个域组成:标志域(用枚举类型),指示表头的指针域和指示表尾的指针域;而原子结点只需两个域:标志域和值域。

模型如下:

typedef enum{ATOM,LIST}ElemTag; //ATOM == 0:原子,LISE == 1:子表
typedef struct GLNode{
    ElemTag tag;    //公共部分,用于区分原子结点和表结点
    union{          //原子结点和表结点的联合部分
        AtomType atom;  //atom是原子结点 的值域,AtomType由用户定义
        //ptr是表结点的指针域,ptr.hp和ptr.tp分别指向表头和表尾
        struct{struct GLNode *hp,*tp;}ptr;
    };
}*GList;  //广义表的类型

这种存储结构吧,是我挺喜欢的一种结构,因为第二种太丑了。

这种存储结构中:

1.除了空表的表头指针为空外,对任何非空列表,其表头指针均指向一个表结点,且该结点中的hp域指示列表表头(或为原子结点,或为表结点),tp域指向列表表尾(除非表尾为空,则指针为空,否则必为表结点);

2. 容易分清列表中原子列表中原子和子表所在层次。

3.最高层的表结点个数即为列表的长度。

还有另外一种结构,刚开始的时候我还没看出这两种结构的区别,仔细琢磨发现这两种结构的区别还是很明显的。

typedef enum{ATOM,LIST}ElemTag; //ATOM == 0:原子,LISE == 1:子表
typedef struct GLNode{
    ElemTag tag;    //公共部分,用于区分原子结点和表结点
    union{          //原子结点和表结点的联合部分
        AtomType atom;  //atom是原子结点 的值域,AtomType由用户定义
        struct GLNode *hp;  //表头指针
    };
    struct GLNode *hp;      //相当于next
}*GList;  //广义表的类型

看起来没有区别,但是画画图会发现这种存储结构相对离散。如果广义表中的子表需要较强独立访问的时候,还是可以考虑的。

m元多项式的表示

在第二章写线性表的时候就提到过如何存储多项式问题,在这里又提到了m元多项式的存储问题。在《数据结构》中,应用模型是实战的第一步,数据结构模型不是单纯的孤立存在,要有不太复杂的操作存在才有意义,才更有实用性。可以说一个模型是有存储,方法两部分组成的(好像C++的封装,不过这也就是ADT所在吧)。

在一般情况下使用的广义表多数既非是递归表,也不为其他表所共享。广义表中的一个数据元素可以是另一个广义表,这就为存储系数提供了便利。

其实m元多项式的表示和多项式的表示一样无语。其实就是先提取公因式把然后把系数保存下来。

步骤:

1.对一个字母提取公因式

2.分解出系数来依次保存(利用递归的特点)

3.向内递归(就是把系数再进行第一步操作)

typedef struct MPNode{
    ElemTag Tag;   //区分原子结点还是表结点
    int exp;    //指数域
    union{
        float coef //系数域
        struct MPNode *hp //表结点的表头指针
    };
    struct MPNode *tp //相当于next
}*MPList;

这个用的是第二种存储结构,虽然丑,但尽量保证的系数的独立性,在以后访问、计算速度可能会更棒一些。

在每一层上增设一个表头结构并利用exp指示该层的变元,可用一维数组存储多项式中所有的变元,故exp域存储的是该变元在一维数组的下标。

广义表的递归算法

鞠老师说这是这章最关键的地方,的确,非常有意思。

因为在做竞赛的时候学个一些树,对写一些简单的递归也算轻车熟路,这一节属于难点,但不是我的难点。大体总结之。

先说说递归,让我形容递归,就三个词:

1.慢:因为递归就是栈的应用,在使用栈空间时间是个问题,递归求阶乘就很好说明问题

2.不负责任:我写递归的时候一直处在不负责任状态,先写出来,管他对错呢,写完在想对错问题,= =。其实递归函数不好理解,等你想明白黄瓜菜都凉了,递归就要随心所欲,然后约束之。

3.强大:很多高级算法就是由递归写的,当递归看多了就发现,数学才是硬伤。

递归设计的实质是:当一个复杂的问题可以分解成若干子问题来处理时,其中某些子问题与原问题有相同的特征属性,则可利用和原问题相同的分析处理方法;反之,这些问题之间的转化关系。

由于递归函数的设计用的是归纳的思想,则在设计递归函数的时候注意:

1.严格定义功能和接口

2.把递归的每个操作看着简单操作

求广义表的深度

求广义表的深度和字典树很像,递归遇到子集深入。

复制广义表

因为广义表可以分为表头和表尾,那么只需要分别复制表头和表尾即可(有点分治的意思)。

if(L->tag==ATOM) T->atom = L->atom;
else{
    //分而治之
    CopyGList(T->ptr.hp,L->ptr.hp);
    CopyGList(T->ptr.tp,L->ptr.tp);
}

建立广义表的存储结构

因为前面已经提到过有两种存储结构,一种是把广义表分解为表头和表尾,另一种是把广义表看成含有n个并列子表(假设原子也视为子表)的表。其实递归存储和复制也差不到哪去。不过书中给出的代码的确不错

Status CreateGList(GList &L, SString S) {
  // 采用头尾链表存储结构,由广义表的书写形式串S创建广义表L。
  // 设emp="()"。
  char s[3]="()";
  SString emp;
  crt_SString(emp,s);
  SString sub,hsub;
  GList p,q;
  if (StrCompare(S, emp)) L = NULL;  // 创建空表
  else {
    if (!(L=(GList)malloc(sizeof(GLNode)))) return ERROR;  // 建表结点
    if (StrLength(S)==1) {  // 创建单原子广义表
       L->tag = ATOM;  L->atom =S[1]; }
    } else {
      L->tag = LIST;  p = L;
      SubString(sub, S, 2, StrLength(S)-2);  // 脱外层括号
      do {  // 重复建n个子表
        sever(sub, hsub);  // 从sub中分离出表头串 hsub
        CreateGList(p->ptr.hp, hsub);  q = p;
        if (!StrEmpty(sub)) {   // 表尾不空
          if (!(p = (GLNode *) malloc (sizeof(GLNode)))) return ERROR;
          p->tag = LIST;  q->ptr.tp = p;
        }//if
      } while (!StrEmpty(sub));
      q->ptr.tp = NULL;
    } // else
  } // else
  return OK;
} // CreateGList
2014/10/15 15:46 pm posted in  算法&数据结构

数组

数组不再是单纯的数据结构的原子类型,而可以形容为线性表的综合体,尤其像多维数组,更像其元素为线性表的线性表(如:二维数组可以看做每个元素是一位数组的一位数组,以此类推)。

数组一旦被定义,它的维数和维界就不再改变。因此,除了结构的初始化和销毁之外,数组只有存取元素和修改元素值的操作。

由于数组一般不做插入或和删除操作,也就是说,一旦建立了数组,则结构中的数据元素个数和元素之间的关系就不再发生变化。因此,采用顺序存数结构表示数组就是很自然的事情了。

由于存储单元是一维的结构,而数组是个多维的结构,则用一组连续存储单元存放数组的数据元素就有个次序约定问题。比如:二维数组要如何存储到线性的内存中?其实不同语言处理起来是不一样的。BASIC,PL/1,COBOC,PASCAL和C语言则采用了以行序为主序的存储结构,说白了,就是在线性内存中,先存储a00,...,a0n直到存储,an0,...,ann,而在FOR,TRAN等语言中,则一列序为主序,先存储a00,...,an0 直到存储 an0,...,ann。存储方式各有优劣,但对于高级语言来说,大部分时候不用考虑这些细节。

对于数组,一旦规定了它的维度和各维的长度,便可以为它分配存储空间,反之,只要给出一组下标,便可以求得相应数组元素的存储位置。

假设每个数据元素占L个存储单元,则二维数组A中任意一个元素aij的存储位置可由下式确定

LOC(i,j) = LOC(0,0)+(b2 x i + j)L

其中LOC(i,j)是aij的存储位置,LOC(0,0)是a00的存储位置,而二维数组A的起始位置,也称为基地址或基址,当然,这是二维数组,特殊到一半,便可得到n维数组的数据元存储位置的计算公式:

LOC(j1,j2,....,jn) = LOC(0,0,...,0)+Σni=1 ciji

其中cn=L,cij=bi x ci (1 i就是常数。由于计算各个元素存储位置的时间相等,所以存取数组中任意元素的时间也相等。称有这一特点的存储结构为随机存储结构。其实这样可以看出来,像使用C语言等编程语言的时候,使用数组其实在隐性的进行着下标运算以获得内存位置

typedef struct{
    ElemYype *base;    //数组元素基址
    int dim;    //数组维数
    int *bounds;    //数组维界基址
    int *constants;    //数组映像函数常量基址
}Array;

多为数组的管理方式有点像上一章提到的页指针和行指针。

也许现在再看数组的ADT感觉十分多余,在C语言甚至C++的基础语法学完后感觉数组就没有什么研究的深度可讲。其实不然,如果脱离高级编程语言,使用低级语言比如汇编,这种存储的数据结构还是很有意义的。语言是在发展的,虽然越发展越是对底层的东西不可见,但也正是越底层的东西,越是不经常改变的。

矩阵的压缩存储

在数值分析中经常出现一些阶数很高的矩阵,同时在矩阵中有许多值相同的元素或者是零元素。有时为了节省内存空间,可以对这类矩阵进行压缩存储。所谓的压缩存储是指:为多个值相同的元素分配一个存储空间,对零元素不分配存储空间。

假若值相同的元素或者零元素在矩阵中的分布有一定的规律,则我们称此类矩阵为特殊矩阵,反之,称之为稀疏矩阵

特殊矩阵

若n阶矩阵A中的元素满足下述性质:aij = aji  (1 2个元素压缩到n(n + 1) / 2个元素的空间中,不失一般性,我们可以行序为主序的存储其下三角(包括对角线)中的元。

假设以一维数组sa[n(n +1)/2]作为n阶对称矩阵A的存储结构,则sa[k]和矩阵元aij之间存在着一一对应的关系。

k = i(i - 1)/2 + j - 1 (当i >=j时)

j(j - 1)/2 + i - 1  (当i ij,反之对所有k=0,1,2,....,n(n + 1)/2 - 1中,都能确定sa[k]中的元在矩阵中的位置(i,j)。由此,称sa[n(n +1)/2]作为n阶对称矩阵A的压缩存储。

不仅是对称矩阵,上下三角矩阵也可以如此压缩成一个一维数组。

稀疏矩阵

在实际应用中,我们还经常遇到另一类矩阵,其非零元较零元少,而且分布没有一定规律,我们称之为稀疏矩阵。

稀疏矩阵只是一个凭人们的直觉来了解的概念。假设m X n的矩阵中,有t个元素不为零,令δ=t/(m + n),称δ为矩阵的稀疏因子,通常认为认为δ<= 0.05时称之为稀疏矩阵。

稀疏矩阵的特点是非零元少,那么,这也是压缩策略。

三元顺序表:直接存储行纵坐标及值,当然,为了各种方便,一般以行序或列序为主序顺序排列。

#define MAXSIZE 12500

typedef struct{
    int i,j;        //存储非零元的下标
    ElemType e;
}Triple;
typedef struct{
    Triple data[MAXSIZE + 1];    //非零元三元组表,data[0]未用
    int mu,nu,tu;                //矩阵的行数,列数,和非零元的个数
}TSMatrix;

三元组实现矩阵转置:

1.将矩阵的行列值(mu,nu)互相交换

2.将每个三元组中的i和j相互调换

3.重排三元组之间的次序便可实现矩阵的转置

开始的时候没感觉到什么,可是书中在强调第三步,明明就是一个二级排序嘛,有什么大不了,慢点的话用冒泡,快点用快排,二级排序嘛。

其实不然,如果是两个for进行冒泡,它的时间复杂度是什么?

for(col = 1;col <=M.nu;++col)
    for(p = 1;p <= M.tu;++p)
        if(...)
        {
            ...
        }

注意第二个for,他才是导致这个算法有待考虑的关键,因为这个算法不是单纯的O(长X宽),而是O(长X非零元个数),也就是说,这个算法,严重受到非零元个数影响。

如果不用三元组的方式,不压缩矩阵,时间复杂度明显是O(长X宽)即O(mu X nu),那么如果tu << mu X nu的话,这个算法还是可以考虑的但因为tu的取值范围是[0,mu X nu],万一tu和mu X nu是一个数量级,那么时间复杂度(O(mu X nu^2))就是成倍的增加.

快速转置法提供了这样一种思路,如果已经确定了转换后的存储位置,那么在转置过程中便可以实现重排操作。但是问题来了,要如何确定转置后的位置?动态线性存储和二维数组不一样,我们无法以交换下标的方式来确定位置。

其实快速转置法是这样解决的,它先定义两个向量(说白了就是一维数组)num和cpot,来分别记录每一列中非零元素的个数和第一个非零元转置后在data中适当的位置。其实这个记录方式和数组相仿。数组也不过就是记录了同地址(cpot的作用)和数组长度(num的作用)。在数组中,对头地址加任何一个小于num的数,便可以访问以头地址为行头的这一维元素的任意元素。如果加的数大于num了呢?那就说明这一行完了,开始下一行(维)了。

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;
//请第col列中第一个非零元在b.data中的序号
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];
}

其中++cpot[col];一定要注意,原位置在被占用之后,可使用位置向后推一。

虽然看起来又多了两个for循环,却是并列关系,所以实际上把时间复杂度降到了O(nu + tu)。

三元组顺序表又称有序的双下标法,他的特点是非零元在表中按行序处理的矩阵运算。然而,若需按行号存取某一行的非零元,则需从头开始进行查找。

行逻辑链接的顺序表(三元组的优化)

为了便于随机存取任意一行的非零元,则需知道每一行的第一个非零元在三元组表中的位置。为此,可使用类似上文提到的cpot向量,只不过cpot记录的是各第一个非零元素。我们现在需要的是一个记录各第一个非零元素在data的位置的向量。

那么便重新构建了这一个数据类型:

typedef struct{
    Triple data[MAXSIZE + 1];    //非零元三元组表,data[0]未用
    int rpos[MAXRC + 1];        //各行第一个非零元的位置表
    int mu,nu,tu;                //矩阵的行数,列数,和非零元的个数
}RLSMatrix;

因为可以方便的访问非零元,则这种数据结构可以更方便的进行矩阵乘法

在计算机中,一个矩阵实际上就是一个二维数组。一个m行n列的矩阵与一个n行p列的矩阵可以相乘,得到的结果是一个m行p列的矩阵,其中的第i行第j列位置上的数为第一个矩阵第i行上的n个数与第二个矩阵第j列上的n个数对应相乘后所得的n个乘积之和。(摘自百度百科)

对于二维数组运算只需要:

for(i = 1;i <= m1;++i)
    for(j = 1;j <= n2;++j){
        Q[i][j] = 0;
        for(k = 1;k <= n1;++k) Q[i][j] += M[i][k] * N[k][j];
    }

但对于三元组表,这种算法显然是不合适的。

我们看点本质的东西,如何确定结果Q数组?

Q(i,j) = Σ(n1,k = 1)M(i,k) X N(k,j) (1 <= i <= m1,1 <= j <= n2)

在经典算法中,无论M(i,k)和N(k,j)是否为零,都需要进行一次运算,因此,在对稀疏矩阵运算的时候应该避免这种运算。换句话说,为求Q的值,只需要在M.data和N.data中找到相应的各对元素(即M.data中的j值和N.data中的i值相等的各对元素)相乘即可。

由此可得,如果想要矩阵相乘,需要从N矩阵中找到i值和M矩阵j相同的元素相乘。那么就需要一个确定某行的快速遍历。这样,rpos数组就有用武之地了。

又因为稀疏矩阵的乘积不一定为稀疏矩阵,而且,因为结果矩阵的每个元都是加和而成的,所以,即使相乘不为0,但加和可能为零,也就是说结果三元组可能存在零元,最后需要剔除。

那么算法可以精简为:

1.清零Q(i,j)

2.选取M中的元M(i,j)

3.找到N中i值与M的j值相等的元N(i,j)

4.相乘累加到Q(i,j)

5.压缩Q矩阵

伪代码如下:

Q初始化;
if(Q是非零矩阵){   //逐行求积
    for(arow = 1;arow <= M.mu;++arow){   //处理M的每一行
        ctemp[] = 0  //累加器清零
        计算Q中第arow行的积并存入ctemp[]中;
        将ctemp[]中非零元压缩存储到Q.data;
    }
}

在书中还提供了比较详细的代码,感觉十分美妙,继续整理:

Status MultSMatrix(RLSMatrix M,RLSMatrix N,RLSMatrix &Q)
{
    //求矩阵乘积Q = M X N,采用行逻辑链接表示
    if(M.nu != N.mu) return ERROR;

    //Q初始化
    Q.mu = M.mu;
    Q.nu = N.nu;
    Q.tu = 0;

    if(M.tu * N.tu != 0){  //Q是非零矩阵
        for(arow = 1;arow  MAXSIZE) return ERROR;
                    Qdata[Q.tu] = (arow,ccol,ctemp[ccol]);
                }
        }
    }
    return OK;
}

这个时间复杂度为O(M.mu X N.nu + M.tu X N.tu / N.mu)

一般在快速矩阵乘法中大都采用快速幂,能让时间复杂度压到O(n^2.81)。

3.十字链表

名字很高大上的样子,在集训队总结图的存储结构的时候我自学过一种叫十字链表的东西,它是正邻接表和逆邻接表的结合体,

这里的十字链表是不一样的,我学过的那个结构才真像十字结构,而这里的十字链表有点像网状结构,真想起来当时学链表的时候和王政他们开玩笑说写个链网来代替二维数组,其实这个就是类似实现,只不过里面保存的只是非零元素。

先说说为什么要用十字链表,对于三元组来说,有个问题很奇特,就是当矩阵加法的时候,可能会导致大量零元变成非零元,然后线性表里会增加很多元素,这样来说,顺序便会打乱。如果再次排序的话,时间复杂度不低。与其在末尾添加最后排序不如之间插入到应有的位置(插排),而对于插入,删除等能引起成员位置移动的操作,当然要选链式结构了,时间复杂度能降到一个数量级。

再说说为什么说这像个链网。每个元素是一个节点,保存有5个域(值),其中i,j,e来分别保存该非零元素的行坐标,列坐标和值,向右域right用以链接同一行中下一个非零元,向下域down用以链接同一列中下一个非零元。同一行的非零元通过right域链接成一条线性表,同一列非零元素通过down域链接成一条线性表。然后在矩阵中有chead和ehead数组来控制坐标头地址,与其说形成一个十字结构不如说是一个网状结构(但是只是考虑单一结点,还是是个十字的)。

typedef struct OLNode{
    int i,j;
    ElemType e;
    struct OLNode *right,*down;
}OLNode;

typedef struct{
    Olink *rhead,*chead;
    int mu,nu,tu;
}CrossList;

对于十字链表的使用,比链表也难不到哪去,一定要有模型的概念,注意细节就好了。

最后说一下对于十字链表处理矩阵加法。

其实不难,就是繁琐,按行或列处理,一个一个相加,注意一共四种情况:

1.A为不为零,B不为零,加和不为零

2.A为零,B不为零

3.A不为零,B为零

4.1.A为不为零,B不为零,加和为零

处理好这四种情况的对链结的增减,矩阵加法也不过如此。


虽然矩阵在这一章属于数组范围,但是矩阵运算着实不简单,之所以不简单因为离不开线性表的灵活运用。

2014/10/11 15:41 pm posted in  算法&数据结构

串(字符串)是有另个或多个组成的有限序列,一般记为S=‘a1...an’(n>=0),其中的S是串名,用单引号括起来的的字符序列是串的值,串中字符的数目n称为串的长度,零个字符的串称为空串,它的长度为零。

串中任意个连续的字符组成的子序列称为该串的子串。包含子串的串相应的称为主串。

称两串相等是指两个串的长度相等且对应位置的字符都相等,也可以说当且仅当两个串的值相等。

由一个或者多个空格组成的串叫做空格串,它的长度为串中空格的个数。为了清楚起见,一般用Ø来表示空串(不是空格串)。

串的逻辑结构和线性表极为相似,区别仅在于传的数据对象约束为字符集。然而串的基本操作和线性表有很大差别。在线性表的基本操作中,大多以“单个元素”作为操作对象,而串通常以“串的整体”作为操作对象。

如果在程序设计语言中,串只是作为输入输出的常量出现,则只需存储此串的串值,即字符串序列即可,但大多数非数值处理的程序中,串也以变量的形式出现。

类似于线性表的顺序存储结构,用一组地址连续的存储串值的字符序列。在串的定长顺序存储结构中,按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区,则可用定长数组描述。串的实际长度可在预定义长度的范围内随意分配,超过预定义长度的串值则被舍去,称之为“截断”。而对串长的有两种表示方法,一是以下标为0的数组分量存放串的实际长度(PASCAL),二是在串值后面加一个不计入串长的标记字符(C)。

实现串操作的原操作(各种操作的基础)为“字符序列的复制”,其操作的时间复杂度基于复制的字符序列的长度,如果在操作中出现串值序列的长度超过上限时,约定用截尾处理,克服这个弊病的唯用不限定串长的最大长度,即动态分配串值的存储空间。

堆分配存储表示

名字什么的在高大上也不过是个名字,堆是机内和栈一样重要的自由存储区,一般栈是由计算机管理的,而堆是由程序员管理的。

这种存储表示的特点是:仍以一组地址连续的存储单元存放串值字符序列,但他们的存储空间是程序执行过程中动态分配而得。

这种存储结构表示时的串操作仍然是基于“字符序列的复制”进行的。

其实也不是多么NB,只不过是在拼接序列等可能超出空间的操作,先开一个两个串长度和的空间(保证操作完能完全存放),然后把两个串复制过去,最后把两个串释放掉。

由于堆分配存储结构的串既有顺序存储结构的特点,处理方便,操作中对串长无限制,更显灵活。(但个人感觉好)。

曾经幻想过能不能把字符串像链表一样连起来,自己最终得到的模型是n段离散的字符串,通过一个记录地址的链表来把n段字符串链接起来。

但看到第四章发现还真有这样的结构,称为块链结构,为了便于进行串的操作,当以链表存储串值时,除头指针外,还附属一个尾指针指示链表中的最后一个元素。

对串进行操作时,只需要从头到尾顺序扫描即可,设尾指针的目的是为了便于进行联接操作,但联结时一定要注意处理串尾的无效字符。

#define CHUNKSIZE 80 //由用户定义的块大小

typedef struct Chunk{
    char ch[CHUNKSIZE];
    struct Chunk * next;
}Chunk;

typedef struct{
    Chunk * head,*tail; //头指针和尾指针
    int len;            //串的当前长度
}Lstring;

在链式存储方式中,结点大小的选择和顺序存储方式的格式选择一样都很重要,它直接影响着串处理的效率,由于所处理的串往往很长或很多,这就要求我们考虑串值的存储密度:

存储密度= 串值所有的存储位/实际分配的存储位

显然存储密度小(如结点为1),运算处理方便,然而存储占用量大,如果在串处理过程中需进行内、外存交换的话,则会因为内外存交换操作过多而影响总效率。

还有,串的字符集的大小也是一个重要因素,一般地,字符集小,其机内编码就短,这也影响串值的存储方式的选取。

串值的链式结构对某些串操作,如联接等,有一定的方便性,但对插入等又有着大量的复杂操作,又不是十分方便。总的来说,顺序结构和链式结构各有优势,分具体情况选用是最棒的选择。

话说数据结构包括存储方式和操作,下面说说操作。

在串中最常用的功能莫过于子串的定位操作,也称模式串的匹配。一般使用一个被王政称为朴素算法的时间复杂度为O(n*m)的东西,其中n和m分别是主串和模式串的长度,其实这也是最容易想到的算法。

节选《数据结构》中关键代码:

while(i  T[0])
    return i - T[0];
return 0;

算法的基本思想是:从主串S的第一个字符起和模式串的第一个字符进行比较,若相等则继续逐个比较后续字符,否者从主串的下一个字符起再重新和模式串的第一个字符起逐个比较,以此类推,直到把主串比较完(匹配不成功)或找到匹配串的位置(匹配成功)。

这种算法容易理解也是最直接简单的查找算法,但往往越好理解的都是越笨拙的,这个算法最大的问题就是慢。有慢的,便必然有快的算法,要看快的,要先直到这个慢的是为什么慢的。

上面那个笨拙的算法,须要两个指针分别指向两个串,当匹配不成功时,两个串都有不同程度的回溯,如果指针不回溯哪怕只有一个指针回溯想必也是极好的。这样,新算法(也可以说是改良算法)就诞生了。

K(Knuth)M(Morris)P(Prate)算法,由前面那3个人同时发现,简称KMP算法,它便实现了只让一个指针回溯,这个指针利用已经得到的“部分匹配”,滑动到模式串的“敲到好处”的位置,继续匹配。

至于怎么确定这个“恰到好处”的位置,就是利用了一个被称之为next函数的东西。

若令next[j] = k,则next[j]表明当模式中第j个字符与主串中相应字符“失配”时,在模式中需重新和主串中该字符进行比较的字符的位置。由此可以引出模式串的next函数的含义:

next[j] = 0当j=1时

        = Max(R|1 1...pk-1'=='pj-k+1...pj-1')

        = 1 其他情况

说白了,就是记录了模式串当前位置和前面几个元素相同。

在寒假ACM集训的时候第一次接触了,KMP,其实KMP中的精华就在next函数中,其中的意义使用灵活,但实在是一种难以总结感觉,更像是经验问题,详情见当时的总结 月球美容计划之那些天我们学过的KMP

在暑假给第3批队员培训的时候,又好好的研究了 KMP,但最后发现王政的KMP理解的比较深入,他的教案文档《Kmp 算法引论》深入浅出,在集训群里又找到这份文档,作为最后对KMP的补充吧。资源链接:KMP字符串匹配算法

总结

  1. 传统算法(朴素算法)最坏情况是O(nm),但一般是在O(n+m),也不是太坏,所以一般没有特殊要求的程序还在用这种算法;

  2. KMP算法在有大量重复的情况下优化明显,对主串较大时比较有效;

  3. next函数还是有缺陷的,比如模式串'aaaab'与主串'aabaaaab'匹配时,用时几乎和朴素算法一样;

  4. 以发展的眼光看问题,其实作为常用算法之一,匹配算法也有发展,KMP已经不是主流。

文本编辑程序问题

文本编辑程序中通过设立页指针、行指针和字符指针,分别指示当前操作的页、行和字符。如果在某行内插入或删除若干字符,则要修改行表中该行的长度。若该行的长度超出了分配给它的存储空间,则要为该行重新分配存储空间,同时还要修改该行的起始位置。

为了查找方便,行表是按行号递增顺序存储的,因此,对行表惊醒的插入或删除运算需移动操作位置以后的全部表项,页表的维护则和行表类似。

由于访问是以页表和行表作为引索的,所以在作行和页的删除操作时,可以只对行表和页表相应的修改,不必删除所涉及的字符,可以节省不少空间。


第四章其实已经看完三天了,这三天又是亚洲区域赛网络赛又是去打针,没有什么比不健康的身体状况更糟糕了,几天终于好些了,一口气把第四章的笔记整理完毕,好收拾旧行装,继续上路。

2014/09/22 15:40 pm posted in  算法&数据结构

栈和队列

从数据结构的角度看,栈和队列也是线性表,其特殊性在于栈和队列的基本操作是线性表操作的子集。他们是操作受限的线性表(其实,更可以认为栈和队列是两种思想,而线性表是它们的具体实现),但在现实上,栈和队列和线性表是大不相同的两类重要的抽象数据类型。

:是限定仅在表尾进行插入或者删除操作的线性表。因此,对栈来说,表尾端有特殊含义,称栈顶,相应的,表头元素称为栈底。

栈又称先进先出(Last In First Out,LIFO)的线性表。

栈的基本操作除了在栈顶进行插入或删除外,还有栈的初始化、判空及取栈顶元素等。

和线性表相似,栈也有两种存储表示方法:

顺序栈:即栈的顺序存储结构是利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素。

在应用过程中,当站的空间不够使用的时候再逐段扩大。

链式栈:用链表来模拟栈,灰常节省内存。

栈的应用:

  1. 数制(进制)转换(逆序)
  2. 括号匹配(延时判断)
  3. 行编辑程序(延时处理)
  4. 表达式求值(延时计算)
  5. 走迷宫(DFS)

求迷宫从入口到出口的所有的路径是一个经典的程序设计问题。由于计算解迷宫问题时通常用到的是“穷举求解”的方法,即从入口出发,顺着某一方向向前探索,若能走通,则继续往前走,否则原路返回,换一个方向再继续探索直至所有可能的通路都探索为止。但所求线路必须为简单路径,即在求得的路径上不能重复出现同一通道块。

设定当前位置的初值的入口位置

do{
    若当前位置可通过且没有走过
    则{
        将当前位置插入栈顶;
        若该位置是出口位置,则结束
        否则切换当前位置的相邻方块为新的当前位置;
    }
    否则
        若栈不空且站定位置尚有其他方向未经探索
        则设定新的当前位置为沿顺时针方向旋转找到栈顶位置的下一相邻块
        若栈不空但栈顶位置的四周均不可通
        则{
            删去栈顶位置
            若栈不同,这重新测试新的栈顶位置
                直至找到一个可通 的相邻快或者栈空
        }
}while(栈不空)

其实就是用循环实现DFS,因为DFS用大量递归,且递归本来就慢些,因此,循环将消耗更少的时间。

递归的本质就是栈,只不过使用栈的是编译器,而不是程序员本身。有的数据结构如二叉树、广义表等,由于结构本事固有递归的特性,则它们 的操作可递归地描述,还有的一类问题,虽然本身没有明显的递归结构,但用递归求解比迭代求解更简单,如八皇后问题、Hanoi塔问题。

汉诺塔问题,可以把整个问题抽象化为

  1. 将第1-n-1个盘子通过使用Y、Z移动到Y柱子上
  2. 将第n个盘子移动到Z柱子上
  3. 将1-n-1个盘子通过使用X、Y移动到Z柱子上。

伪代码:

void move(int x,int n,int z)
{
//c为记录步数的全局变量
printf("%d Move disk %d frome %c to %cn",++c,n,x,z);
}

void hanoi(int n,char x,char y,char z)
{
if(n==1)
move(x,1,z);
else
{
hanoi(n - 1,x,z,y);
move(x,n,z);
hanoi(n - 1,y,x,z);
}
}

这就是把大问题化简为为小的问题然后用递归处理的过程。

在汇编程序设计中,主程序和子程序之间的链接及信息交换相类似,在高级语言编制的程序中,调用函数和被调函数之间的链接及信息交换需通过栈来进行。

通常为在一个函数的运行期间,调用一另个函数时,在运行被调用函数之前,系统需要完成3件事:

  1. 将所有的实在参数返回地址等信息传递给被调用函数
  2. 为被调用函数的局部变量非配存储空间
  3. 将控制转移到被调函数入口。

而从被调用函数返回调用函数之前,系统也应完成3件事:

  1. 保证被调函数的计算结果
  2. 释放被调函数的数据区
  3. 依照被调函数保存的返回地址将控制转移到调用函数

当有多个函数构成的嵌套调用时,按照“后调用先返回”的原则,上述函数之间的信息传递和控制转移必须通过“栈”来实现,即系统将整个程序运行时所需的数据空间安排到一个栈里,每当调用一个函数时,就为它在栈顶分配一个存储区,每当从一个函数退出时,就释放他的存储区,则当前正运行的函数的数据区必在栈顶。

这也就是为什么递归深度太大导致“爆栈”。

系统需设立一个“递归工作域”作为整个递归函数运行期间使用的数据存储区。每一层递归所需信息构成一个“工作记录”,其中包括所有的实在参数,所有局部变量以及上一层的返回地址。

每进入一层递归,就会产生一个新的纪录压入栈顶。每出一层递归,就从栈顶弹出一个工作记录,成这个记录为“活动记录”,并称指示活动记录的栈顶指针为“当前环境指针”。

队列

和栈相反,队列是一种先进先出(First In First Out,FIFO)的线性表,它之允许在表的一端进行插入,而在另一端删除元素。

在队列中,允许拆入的一段叫做队尾,允许删除的一端则称作队头。除了栈和队列之外,还有一种限定性数据结构是双向队列(双端队列)。

两个方向均可以出入,分为左入,左出,右入,右出等操作,但应用远不如栈和队列。

就像链栈一样,队列一样有两种存储结构(相性表的两种实现)

用链表表示的队列简称链队列,一个链队列虽然需要两个分别指向队头和队尾的指针(分别称作头指针尾指针)才能唯一确定。空的链队列的操作即为单向链表的插入和删除操作的特殊情况,只是尚需修改尾指针或头指针。

循环队列

在队列的顺序存储结构中,除了用一组地址连续的存储单元依次存放从队列头到队列尾的元素之外,尚需附属 两个指针front和rear分别只是队列头元素及队列尾元素的位置。

初始化建空队列时,另front = rear = 0,每当插入新的队列尾元素时,头指针增1,因此,在非空队列中,头指针始终指向队列头元素,而尾指针始终指向尾元素的下一个位置。

但这样的操作有个致命的问题,如果不知道队列元素最多是多少,就需要开尽量大的空间,原因头尾指针只增不减,已经被使用过的空间就此浪费。因此为了更经济,为了存放更多的元素,循环队列诞生了。

将顺序队列臆造为一个环状的空间,指针和队列之间的关系不变,判断“空”还是“满”,可有两种处理方法:1.设另一个标志位以区别队列是“空”还是“满”;2.少用一个元素空间,约定以队列头指针在队列尾的下一位置作为队列满的状态。

队列还有很多应用,也有很多拓展,不如优先队列,在先进先出的基础上,为每个元素添加优先级,使队列中优先级高的先先出,在BFS等搜索中,可以进行优化。

2014/09/16 15:39 pm posted in  算法&数据结构

线性表

数据结构第二章便讲了线性表,其实我们常说线性表一般为数组和链表,熟不知线性表其实是一种逻辑结构,而链表和数组分别是两种存储结构(非顺序和顺序映像)。

先说线性结构,线性结构是在数据元素的非空有限集中:

1.存在唯一的一个被称作“第一个”的数据元素;

2.存在惟一的一个被称作“最后一个”的数据元素;

3.除了第一个之外,集合中的每一个数据元素均只有一个前驱;

4.除了最后一个之外,集合中每个数据元素均只有一个后继。

其实以上四条便是线性表的特征,这四句罗利啰嗦的话就是描述了一条线性的,有前驱后继的数据结构。

线性表是n个数据元素的有限序列,在稍复制的线性表中,一个数据元素(一个结点)可以由多个数据项组成。

线性表中元素的个数n(n>=0)定义为线性表的长度,n=0时称为空表。在非空表中的每一个数据元素都有一个确定的位置(只有这样才方便查找)。

线性表是一种非常灵活的数据结构,它的长度可以根据需要而变化,对线性表的数据元素,不仅可以询问、修改,还可以插入和删除(如果这几个功能线性表都不能完成,还学它何用?)。其实对于线性表,或者说包括其他各种数据结构,建立和访问是基础,不会建立和访问可以说对这种数据结构完全不懂,加上会修改可以说能用这种数据结构,再加上会删除才可以说会用这种数据结构,再加上能抽象出自己的应用模型、活用才能说掌握了这一种数据结构。

线性表的顺序表示是指用一组地址连续的存储单元依次存储线性表的数据元素(就像数组)。

第i+1个数据元素的存储位置LOC(ai+1)和第i个数据元素的存储位置LOC(ai)之间满足:

LOC(ai+1)=LOC(ai)+ l;

一般来说,线性表的第i个数据元素ai的存储位置为

LOC(ai)=LOC(a1)+ l*(i-1);

(l为每个元素的需占的存储单元,LOC(a1)为线性表的第一个元素的存储位置,通常被称作相性表的起始位置(废话)和基地址)。

线性表的这种机内表示称作线性表的顺序存储结构或顺序映像。通常称这种存储结构的线性表为顺序表,其特点是为表中相邻的元素ai和ai+1赋以相邻的存储位置LOC(ai)LOC(ai+1)。(其实就是通过元素在计算机内物理位置相邻来表示线性表中的数据元素之间的逻辑关系)。

数据结构一半是怎么存储,一半是怎么运用(算法),线性表的基本操作包括插入,删除。插入就是把元素x插入i位置后,其i到n的元素向后移动1个单位;删除:把第i个元素x删除后,第i+1到n元素向前移动1个单位。

当顺序存储结构的线性表中某个位置上插入或删除一个数据元素时,其时间消耗主要在移动元素上(换句话说,移动元素的操作为估计算法时间复杂度的基本操作),而移动元素的次数取决于插入或者删除元素的位置。

线性表的顺序存储结构可以方便的存取任一元素,但也造成了做插入删除操作时需要移动大量元素的缺点。

而链式存储结构,由于不要求逻辑上相邻的元素也在物理位置也相邻,没有顺序存储结构如上所具有的弱点,但同时也失去了快速的、直接访问的优点。

每个结点包括数据域,存储后继存储元素位置的域称之为指针域。

因为每个结点只包括一个指针与,故又称线性链表或单链表。

在研究链表的时候,我们只关心其逻辑顺序而不管实际机内位置。

关于链表的操作,插入:只需要修改第i-1个元素结点的指向地址和要插入的数据元素x的指向地址;删除,只需要修改第i-1个元素的指向地址,然后释放结点。

操作有点泛泛而谈,其实链表有很多活用的地方,甚至连建立链表也有头插法(逆向建链表)和尾插法(正向建链表)之分。

如果不用指针,可以使用游标来代替指针指示结点在数组中的相对位置,但这种存储结构需要分配出一个足够大的内存空间,为了和指针型链表相区别,我们给这种用数组来描述的链表起名叫做静态链表(前向星便是基于静态链表)。

对于静态链表,如果在删除操作之后,需要辨明数组中那些分量未被使用,解决的办法是将所有未使用的以及删除的用游链表或者一个备用链表每当进行插入时便可以从备用链表上取得第一个结点作为待插入的新结点。(类似专用来记录未被使用的元素下标的栈)。

尾巴指向头的链表为循环链表,当然静态链表也可以用去模来实现循环链表。

因为单链表有着只能从头到尾的单向性,在某些情况,会造成不便,因此,双向链表诞生了。其只不过是多了一个指向前一个结点的指针,操作比单向链表多了一点操作。

一元多项式的表示和相加(线性表的一个应用)

在数学上,一个余元多项式Pn(x)可按升幂写成:

Pn(x) = p0+p1x+p2x^2+.....+pn*n^n;

它可由n+1个系数来唯一确定。因此,在计算机里,它可用一个线性表p表示:p = (p0,p1,...,pn);

不失一般性,多项式的相加减便可以用一对线性表来表示计算

但这样计算也有弊端,比如计算

S(x) = 1 + 3 * x^10000 + 2 * 3 * x^20000时,将浪费大量的内存(p1-p9999都是0)。这时,如果采用每个数据元素有系数和指数两个数据,那么将节省大量内存,当然,也会浪费查找带来的时间。

当然,这只是提供了一个思路,也算相性表的一个应用,线性表是一种十分灵活的数据结构,至于怎么用,全靠编程者个人,这也是为什么可以说数据结构可以成为使用者的艺术。

2014/09/11 15:38 pm posted in  算法&数据结构