数据结构——线性表(链表)

284 阅读9分钟

顺序表利用数组元素在物理位置(即数组下标)上的邻接关系来表示线性表中数据元素之间的逻辑关系,这使得顺序表具有以下缺点:

① 插入和删除操作需要移动大量元素。在顺序表上做插入和删除操作(时间复杂度为O(n)),等概率下情况下,平均需要移动表中一半的元素。 

② 表的容量难以确定。由于数组的长度必须事先确定(动态分配可解决该问题),因此,当线性表的长度变化较大时,难以确定合适的存储规模。

③ 造成存储空间的“碎片”。数组要求占用连续的存储空间,即使存储单元数超过所需的数目,如果不连续也不能使用,造成存储空间的“碎片”现象。

单链表

template <typename DataType>
struct Node{
    DataType data;                // 数据域
    Node<DataType> *next;         // 指针域 
};

通常在单链表的开始节点之前附设一个类型相同的节点,称为头节点(head node),加上头节点以后,无论单链表是否为空,头指针始终指向头节点,因此空表和非空表的处理也变得统一。

template <typename DataType>
class LinkList{
        public:
            LinkList();                    // 建立只有头节点的空链表
            LinkList(DataType a[],int n);  // 建立n个元素的单链表
            ~LinkList();                   // 析构函数
            int Length();                  // 求单链表的长度
            DataType Get(int i);           // 按位查找,查找第i个节点的元素值
            int Locate(DataType x);        // 按值查找,查找值为x的元素序号
            void Insert(int i,DataType x); // 插入操作,第i个位置插入值为x的节点
            DataType Delete(int i);        // 删除操作,删除第i个节点
            bool Empty();                  // 判断线性表是否为空
            void PrintList();              // 遍历操作,按序号依次输出各元素 
        private:
            Node<DataType> *first;         //单链表的头指针 
};

1.无参构造函数

template <typename DataType>
LinkList<DataType>::LinkList(){
    first = new Node<DataType>;    // 生成头节点
    first->next = nullptr;         // 头节点的指针域置空 
}

2.有参构造函数

   (1) 头插法

如果a[5]={1,2,3,4,5},则经过头插法构造函数之后,遍历操作将打印"5,4,3,2,1"。

template <typename DataType>
LinkList<DataType>::LinkList(DataType a[],int n){
    first = new Node<DataType>;
    first->next = nullptr;
    for (int i=0; i<n; i++){
        Node<DataType> *s = nullptr;
        s= new Node<DataType>;	s->data = a[i];
        s->next = first->next;	first->next = s;	// 将节点s插入头节点后 
    }
}

   (2) 尾插法

如果a[5]={1,2,3,4,5},则经过尾插法构造函数之后,遍历操作将打印"1,2,3,4,5"。

template <typename DataType>
LinkList<DataType>::LinkList(DataType a[],int n){
    first = new Node<DataType>;		
    Node<DataType> *r = first, *s = nullptr;	// 尾指针初始化
    for (int i=0; i<n; i++){
        s = new Node<DataType>;	s->data = a[i];
        r->next = s;	r = s;		// 将节点s插入到终端节点之后 
    }
    r->next = nullptr;	// 单链表建立完毕,将终端节点的指针域置空 
}

3.析构函数

template <typename DataType>
LinkList<DataType>::~LinkList(){
    Node<DataType> *p = first;
    while (first != nullptr){	// 释放每一个节点的存储空间 
        first = first->next;
        delete p;
        p = first;
    }
} 

4.求单链表的长度

template <typename DataType>
int LinkList<DataType>::Length(){
    Node<DataType> *p = first->next;	// 工作指针p的初始化
    int count = 0;
    while (p != nullptr){
        p = p->next;
        count++;
    } 

    return count;
} 

**5.按位查找    **O(n)

template <typename DataType>
DataType LinkList<DataType>::Get(int i){
    Node<DataType> *p = first->next;		// 工作指针的初始化
    int count = 1;
    while (p!=nullptr && count<i){
        p = p->next;
        count++;
    }
    if (p == nullptr)
        throw "illegal search location";
    else
        return p->data;
}

**6.按值查找    **O(n)

template <typename DataType>
int LinkList<DataType>::Locate(DataType x){
    Node<DataType> *p = first->next;	// 工作指针的初始化
    int count = 1;
    while (p != nullptr){
        if (p->data == x)
            return count;
        p = p->next;
            count++;
    }

    return -1;		// 退出循环表面查找失败 
}

**7.插入操作    **O(n)

template <typename DataType>
void LinkList<DataType>::Insert(int i,DataType x){
    Node<DataType> *p = first, *s = nullptr;
    int count = 0;
    while (p != nullptr && count<i-1){
        // 查找第(i-1)个节点
        p = p->next;
        count++; 
    }
    if(p == nullptr)
        throw "illegal insertion location";	// 没有找到第(i-1)个节点
    else {
        s = new Node<DataType>;	s->data = x;	// 申请节点s,数据域为x
        s->next = p->next;	p->next = s;	// 将节点s插入到节点p之后 
    }	
}

**8.删除操作   **O(n)

template <typename DataType>
DataType LinkList<DataType>::Delete(int i){
    DataType x;
    Node<DataType> *p = first, *q = nullptr;
    int count = 0;
    while (p!=nullptr && count<i-1){
        // 查找第(i-1)个节点
        p = p->next;
        count++; 
    }
    if (p==nullptr || p->next==nullptr) 
        throw "illegal deleted location";
    else {
        q = p->next; x = q->data;	// 暂存被删节点	
        p->next = q->next;	// 摘链 
        delete q;

        return x;
    }
}

关于判断条件p->next==nullptr,是因为如果删除的元素是最后一个元素的下一个(不存在),此时p!=nullptr,但仍然是删除位置错误,故增加此判断条件。

9.判断线性表是否为空

template <typename DataType>
bool LinkList<DataType>::Empty(){
    return first->next==nullptr?true:false;
} 

10.遍历操作

template <typename DataType>
void LinkList<DataType>::PrintList(){
    Node<DataType> *p = first->next;	// 工作指针p的初始化
    while (p != nullptr){
        cout << p->data << "\t";
        p = p->next;
    }
cout << endl;
}

双链表

如果希望快速确定单链表中任一结点的前驱结点,可以在单链表的每个结点中再设置一个指向其前驱结点的指针域,这样就形成了双链表(double linked list)。和单链表类似,双链表一般也是由头指针唯一确定,增加头结点也能使双链表的某些操作变得方便。

template <typename DataType>
struct Node{
    DataType data;
    Node<DataType> *prior,*next;    // 前驱,后继指针域
};

在双链表中求表长、按位查找、按值查找、遍历等操作的实现与单链表基本相同,下面讨论插入和删除操作。

1. 插入操作

在结点p的后面插入一个新结点s,需要修改4个指针:

① s->prior = p;

② s->next = p->next;

③ p->next->prior = s;

④ p->next = s;

在修改第②和③布的指针时,要用到p->next以找到结点p的后继结点,所以第④步指针的修改要在第②和③的指针修改完成后才能进行。

2. 删除操作

设指针p指向待删除结点,删除操作可通过下述三条语句完成:

① p->prior->next = p->next;

② p->next->prior = p->prior;

③ delete p;

前两句语句的顺序可以颠倒。

循环链表

在单链表中,如果将终端结点的指针由空指针改为指向头结点,就使得整个单链表形成一个环,这种头尾相接的单链表称为循环单链表(circular singly linked list),在用头指针指示的循环单链表中,找到开始结点的时间为O(1),但找到终端结点的时间为O(n),如果改用指向终端结点的尾指针来指示循环单链表,那寻找开始结点以及终端结点的时间都为O(1),它们的存储地址分别为rear->next->next和rear,显然时间都为O(1)。因此,实际应用中多采用尾指针指示的循环单链表。

循环链表没有增加任何存储量,仅仅对链表的链接方式稍作改变,因此,基本操作的实现和链表类似。从循环链表中任一结点出发,可扫描到其他结点,从而提高链表操作的灵活性。但这种方法的危险在于循环链表中没有明显的尾端,可能会使循环链表的处理操作进入死循环,所以,需要格外注意循环条件,通常判断用作循环变量的工作指针是否等于某一指定指针(如头指针或尾指针),以判定工作指针是否扫描了整个循环链表。例如,在循环链表的遍历算法中,用循环条件p!=first判断工作指针p是否扫描了整个链表。

静态链表

静态链表用数组来表示链表,用数组元素的下标来模拟链表的指针。由于利用数组定义的链表,属于静态存储分配,因此叫作静态链表。最常用的时静态单链表,在不致混淆的情况下,将静态单链表简称为静态链表,存储示意图如下图所示,其中avail是空闲链表(全部由空闲数组单元组成的单链表)头指针,first是静态链表的头指针,为了运算方便,静态链表也带头指针。

静态链表的每个数组元素由两个域构成:data域存放数据元素,next域存放该元素的后继元素所在的数组下标。

template <typename DataType>
struct SNode{
    DataType data;    
    int next;    // 指针域(也称游标),注意不是指针类型
};

静态链表的实现

const int MaxSize = 100;    // 100是示例数据,根据实际问题具体定义
template <typename DataType>
class StaList{
    public:
        StaList();        // 构造函数,初始化空的静态链表和空闲链表
        StaList(DataType a[],int n);    // 构造函数,建立长度为n的静态链表
        ~StaList();        // 析构函数
        // 与单链表成员函数相同
    private:
        SNode SList[MaxSize];    // 静态链表数组
        int first,avail;         // 游标,链表头指针和空闲头指针
};

静态链表采用静态存储分配,因此析构函数为空。求表长、按位查找、按值查找、遍历等操作的实现与单链表基本相同,下面讨论插入和删除操作。

1、插入操作

在静态链表中进行插入操作,首先从空闲链的最前端摘下一个结点,将该结点插入静态链表中,假设新结点插在结点p的后面,则修改指针的操作为:

s = avail;                        // 不用申请结点,利用空闲链的第一个结点
avail = SList[avail].next;        // 空闲链的头指针后移
SList[s].data = x;                // 将x填入下标为s的结点
SList[s].next = SList[p].next;    // 将下标为s的结点插入到下标为p的结点后面
SList[p].next = s;    

2、删除操作

在静态链表中进行删除操作,首先将被删除结点从静态链表中摘下,再插入到空闲链的最前端,假设要删除结点p的后继结点,则修改指针的操作为:

q = SList[p].next;                // 暂存被删结点的下标
SList[p].next = SList[q].next;    // 摘链
SList[q].next = avail;            // 将结点q插在空闲链avail的最前端
avail = q;                        // 空闲链头指针avail指向结点q

静态链表总结:

静态链表虽然用数组来存储线性表,但在执行插入和删除操作时,只需移动游标,无须移动表中的元素,从而改进了在顺序表中插入和删除操作需要移动大量元素的缺点。

顺序表和链表的比较

时间性能比较

1、若线性表需要频繁查找却很少进行插入和删除操作,或者操作和“数据元素在线性表中的位置”密切相关时,宜采用**线性表**作为存储结构;

2、若线性表需要频繁进行插入和删除操作,宜采用**链表**作为存储结构。

空间性能比较

1、如果事先知道线性表的大致长度,使用**顺序表**的空间效率会更高;

2、当线性表中元素个数变化较大或者未知时,最好使用**链表**实现。