假期连载之二

       线性表的基本运算算法分析线性表的链式存储—单链表

前面我们介绍了线性表的顺序存储中有关数据结构以及其常用运算,接下来我们将对其中涉及搜索、插入和删除部分的代码进行性能分析。

      

       首先对于上节使用的伪码描述的Locate(L,e)搜索算法进行分析,该算法的核心思想是,从表L的开始位置起,根据给定的值e,逐个与各表项的值进行比较,若给定值与表中某个值相等,则算法报告搜索成功,并输入该表项的具体位置,如果查遍所有表项仍不符合要求,则返回null通常衡量搜索代码的时间代价时使用数据比较次数的数值,在搜索成功的情况下,若要找的正好是表中的第一项,则搜索比较次数为1,若为最后的第n项,则比较次数为n,这是最坏的情况,因此若要计算平均数据比较次数,需要考虑到各个表项的搜索概率pi以及找到该表项时的数据比较次数ci。因此搜索的平均数据比较次数ACN(Average comparing number)为:

                                          ACN=pi*ci  (i=1,2,3,…,n)

    通常我们仅考虑搜索项中相等概率的情形,即每个表项被选择的概率均为1/n,且搜索第一个表项的搜索比较次数为1,之后依次类推。则:

            ACN=pi*ci=(1/n)(1+2+,…,+n)=(1/n)*(1+n)*n/2=(1+n)/2

即需要比较(1+n)/2个表项。

    相反,如果搜索不成功即整个表都检测一遍,因此ACN将为n

 

    在执行顺序表的插入或删除运算时,需要注意,此时表的长度是会发生改变的,因此为了在执行这一类操作过程中不改变原表项的位置关系,通常需要成块移动表项。例如在插入运算中,必须先将第i个到第n个所有表项成块后移一位,以空出可供插入新表项的位置。而当删除操作时,在删除第i个表项后,需要将从i+1n-1的所有表项向前移动一位,覆盖原位置。在这两种操作中,表项移动的方向是恰好相反的

   

    因此在分析顺序表的插入和删除操作时,其时间代价主要是以循环内表项的移动次数来衡量的

 

    例如InsList(L,i,e)插入运算,在插入数据之前,必须先从后往前循环,将第n项到n-i项均向后移动一位。因此最好的情形是在第n+1位插入新表项,此时表项移动个数为0。最差情况是在第1个位置插入新表项,即在0之后。移动表项个数为n。因此平均数据移动次数AMN(Average moving number)在各表项的插入概率相等时为:

                     AMN=(1/(n+1))* (n-i)=(1/(n+1))*(n+…+1+0)=n/2(i=1,2,…,n+1)

因此就整体性能来说,插入时有i+1个插入位置,平均移动n/2个表项。

   

    同时对于删除操作而言,当删除第i个表项时,需要移动第i+1到第n个总共n-1个表项。另外对于插入操作所不同的是,删除操作并不涉及到第n+1项的情况,因此上式AMNi的情况应为1,2,…,n即可,所以:

            AMN=(n-1)/2

       当然在一些算法中并不要求插入时并不必须在表末进行,或者删除时可以将最后一个表项填到第i个空位中去,则此时的插入AMN固定为0,而删除AMN固定为1

 

2、  线性表的链式存储(Linked List)

在线性表的顺序存储中,我们使用了基于数组的存储结构,即其物理位置上为邻接关系,这使得顺序表具有如下优点:

1)  无需为表示结点间的逻辑关系而增加额外的存储空间,存储利用率提高。

2)  可以方便地随机存取表中的任一结点,存取速度快。

但是,这也不可避免地带来的一些缺点:

1)  在表中插入新元素或删除无用元素时,为了保持其他元素的相对次序不变,平均需要移动一半元素,运行效率较低。

2)  由于顺序表要求占用连续空间,我们在Operating System中了解到,当内存空间使用Static allocation时,当表的长度变化较大,则难以确定合适的存储空间大小,容易造成内存空间的浪费或溢出。如果采用Dynamic allocation策略使用动态存储模式,则可以使用新数组替换原数组,尽管在空间上能够得到有效利用,但会造成较大的时间开销。

 

为了克服顺序表的缺点,在一些插入/删除操作频繁从而导致空间需求不定的情况中,我们采用链接方式来存储线性表。

 

从链表的实现角度来看,链表分为静态链表动态链表两类。而从链接方式来看,链表分为单链表循环链表双向链表三类。

 

       在链表结构中,其存储表项可以是物理连续的,也可以是非连续的,甚至呈零散分布,故其结点的逻辑次序和物理顺序不一定相同。为了正确表示出单链表的逻辑关系,我们必须在存储每个表项数据元素值的同时,存储指示其后继结点的地址信息,我们把这两部分信息组成的存储映像称为结点(Node),通常也称为域(Field)

       结点包含了两个域:数据域和指针域,他们分别用来结点的值和直接后继结点的地址。在单链表Single linked list中,每一个结点中只有一个next指向其直接后继。

 

       值得注意的是,我们在构建一个链表时,为了存放第一个结点的地址,因此通常设置一个头指针H指向第一个结点并作为其直接前驱,而最后一个结点的next指针直接设置为空null

 

下面我们给出C语言方法描述的单链表存储结构:

 

       typedef struct Node

{

       ElemType data;

       struct Node *next;

}Node,*LinkList;                  //这里的LinkListNode *等价,通常使用LinkList强调头指针,而Node*来定义指向任意结点的指针。

 

C++的面向对象方法中,我们通常使用两个类即结点类和链表类来定义单链表。而其具体实现的方法又有4种。

1)      复合类,在LinkNode类中声明友元类List,从而使得LinkNode类和List类都能访问LinkNode类的私有数据成员。

2)      嵌套类,在List类定义的内部对LinkNode类进行定义,则必须将LinkNode类定义在其private部分,从而确保List类的外部对象和函数不能直接接触到LinkNode类的对象,但LinkNode类的数据成员则必须放在public部分,使得LinkNode类和List类的成员都能访问它们。

3)      基类和派生类,把LinkNode声明为基类,同时List类声明为派生类,建立继承关系,让List类继承其数据成员和某些成员函数,表示一种使用关系。此时LinkNode类的数据成员应为protected,以保证被子类使用。

4)      struct定义LinkNode类,和C语言方法相似,但其失去了封装性,仅在需要简化描述时采用此类方法。

下面仅给出复合类的具体声明方法:

class List;

class LinkNode

{

       friend class List;

       private:

              int data;

              LinkNode *link;

};

class Link

{

       public:

              //链表公共操作

              ……

       private:

              LinkNode *first      //链表头指针

};

 

单链表的基本运算

 

       1)初始化单链表:

              InitList(LinkList *L)

{

       *L=(LinkList)malloc(sizeof(Node));

       (*L)->next=NULL;        //带头结点的链表,故L->next=NULL

}

 

这里明确malloc/freenew/delete的区别,我们知道,malloc/freeC++/C里的标准库函数,而new/deleteC++的运算符,两者都含有申请内存空间/释放的功能,但malloc/free仅仅能做到这一点。C++引入运算符new/delete的目的是,当我们需要动态地建立对象时,由于面向对象方法的限制,必须要利用到运算符的性质,而避免使用函数来操作。在分配内存空间后,还必须执行对象的构造方法,在释放空间时需要执行析构函数,这是malloc/free所不具备的,因此new/delete有其合理性,这一点在java中得到了继承和发展。

 

       2)建立单链表

       这里先介绍头结点的概念,我们知道,通常链表初始化过程中会产生一个头指针*L,此时L直接指向null,有时我们需要建立一个头结点以满足一些特殊操作,因此L->next被设置为null

             

       基于头结点的建表法,即头插法建表。头插法建表的思想是,在一个带头结点的链表L中,首先申请结点空间*s,并给其数据域赋值。由于L->next指向了表中下一个结点,故先s->next=L->next链接后继结点,同时L->=s完成链表的结点插入操作。其伪码表示为:

       CreateFromHead(L)

              flag=1;

              while flag do

                     c=getchar();

                     if c!=’$’ then                                                   //用来确保’$’为输入结束符

                            s=(Node*)malloc(sizeof(Node));

                            s->data=c;

                            s->next=L->next;

                            L->next=s;

                     else then

                            flag=0;

 

       头插法中输入元素与得到的单链表逻辑顺序相反,因此也被称作逆序建表法。如果需要链表顺序与输入顺序一致,可以直接采用尾插法建表解决。

       CreateFromTail(L)

              flag=1;

              r=L;                                                         //r为尾指针,初始化时应指向头结点。

              while flag do

                     c=getchar();

                     if c!=’$’ then

                            s=(Node*)malloc(sizeof(Node));

                            s->data=c;

                            r->next=s;

                            r=s;

                     else then

                            flag=0;

                            r->next=NULL;     

3)删除单链表

单链表删除操作的思想是,首先声明一个空指针r并使其指向要删除的结点,同时将要删除结点直接前驱的next指针指向其直接后继,然后释放r的空间即可。下面给出一个带头结点链表中删除第i个元素的伪码表示:

DelList(L,i,e)

       pre=L,k=0;

       while pre->next!=NULL&&k<i-1 do

              pre=pre->next;

              k=k+1;

       if !(pre->next) then

               error “删除位置不合理!”;

       r=pre->next;

       pre->next=pre->next->next;

       e=r->data;

       free(r);

       return OK;

 

4)链表的前插操作,和头插法建表类似,但需要注意的是,由于插入操作可在链表尾部进行,因此其循环条件为pre!=NULL&&k<i-1,并非是pre->next!=NULL,如果在删除操作中忽略了这一点,则可能造成空指针操作,发生错误。

 

 

(未完待续)