数据结构与算法专题之线性表——链表(一)单链表

来源:互联网 发布:php post数组长度限制 编辑:程序博客网 时间:2024/06/05 15:01

  本文是数据结构与算法专题的第一篇文章,后面会持续更新关于数据结构的文章。此系列主要针对数据结构的入门级小伙伴们,文中我会尽量使用白话的语言以及适当的代码、图片和例子来帮助大家理解,并提供一些自己的经验、认识和代码模板,希望能对大家的数据结构学习有所帮助。欢迎大家私信留言交流学习经验~如果发现错误,也欢迎大家指正~

本章内容是线性表

  线性表是数据结构的入门级知识,它是数据的一种线性逻辑结构,关于逻辑结构,分为以下几点:

1. 集合,数据简单的集合划分结构,没什么特点。

2. 线性结构,元素之间存在一对一的对应关系,常见的有顺序表、链表、栈和队列等。

3. 树形结构,元素之间存在一对多的关系,常见的有二叉树。

4. 图,元素之间有多对多的对应关系。

  本文着重介绍线性表中链表里的单链表,其他数据结构会在后续的文章里持续更新

  在说链表之前,我们先回到之前说的线性结构,线性表是典型的线性结构,它是最基本最常用也是最简单的一种数据结构,它的特点也很容易理解,就是除了第一个和最后一个元素,中间的每个相邻元素都是首尾相接的,看起来就像是一条“线”。而线性表的存储方式又分为链式存储和顺序存储,这就上升到了数据在内存中的组织形式了,下面来分别介绍这两种存储形式在内存中表现的区别以及优缺点。

1. 顺序存储

  关于顺序存储,我们在学习数据结构之前都已经接触过了,就是最基本的数组,为什么数组是顺序存储呢?我们回想一下C语言里数组的特点,我们知道访问数组元素可以使用数组名[下标](如arr[6])的形式,也可以使用数组名+偏移量(如arr+6)的指针形式,我们不考虑下标形式,想一下为什么可以使用数组名+偏移量?我们已知在C语言中,数组名即数组首元素的地址,那么可以通过首元素地址+偏移量访问,那么可得知数组里各元素在内存中是顺序排列的,如下图所示:


  这种存储方式优缺点都很突出,比如顺序结构的查询效率很高,查询某位置元素的复杂度为O(1),查找指定内容的元素,也有很多算法,比如快排+二分查找,这些成熟的算法结合顺序表都能得到很好的应用。但是顺序表的插入和删除却最坏可达O(n)复杂度,原因在于添加或删除元素,需要将该元素之后的元素整体后移或前移,最坏的情况就是你删除第一个元素或在第一位添加元素,那么整个顺序表的元素都要移动,效率很低。而且顺序表另一缺点在于内存浪费,因为数组都是预先定义大小的,如果实际存储元素远小于顺序表总大小,那么剩下一大片内存只能白白浪费了。

2. 链式存储

  链式存储与顺序存储不同,它在内存中的组织形式是离散的,逻辑上相邻的元素在内存中可能不会相邻,比如说一个整型链式表的第一个元素可能在地址0x124而第二个元素却在0x666上,这就是链式存储的特点。由于元素是离散存储的,所以我们不能像顺序表那样清楚地知道元素间的位置关系,所以需要引入一个指针域,使用指针域来指向下一个元素所在的位置,这样我们就可以已知链式表的第一个元素,通过指针域的指向,来“顺藤摸瓜”依次找到链式表的所有元素。以单链表为例,如下图:


  可以看出,它的相邻元素的地址并不是连续的,它的优缺点也很明显,可以说是与顺序表是相反的模式,它的查询效率很低,最坏达到O(n),也就是访问最后一个元素或者要查找的元素在最后,那么就要从头到尾遍历这个链表。但是增加和删除就很高效了,我们把指针域比作链子,要删除某个元素,只要把它前面的链子和后面的链子断开,然后让他俩连起来就可以了。可以实现O(1)复杂度。

  接下来进入主题,链表。首先基本的链表有三种,分别是单链表双向链表循环链表,本章先介绍单链表,后续关于链表的章节会陆续介绍双向链表和循环链表。

  单链表是最常见也是最简单的一种链表,它的每个元素含有两部分:值域和指针域,指针域是一个指向下一元素地址的指针,最后一个元素的指针域为空,代表结尾,后面不会再有元素了。

  为了使容器通用,我们这里使用C++模板类(泛型)

  我们定义单链表一个结点的结构为:

template<class T>struct Node{    T data;    Node<T> *next;};
  建议将单链表的操作和属性封装起来,使用面向对象的方法来操作,同样是模板类。

  我们在这里引入单链表的头结点,该结点是特殊结点,值域为空。一个空的单链表只有一个头结点,且头结点的指针域为NULL。引入头结点的目的是为了减少链表特殊情况而导致空指针引用错误,也为了方便实现任意位置插入。

  我们将单链表的类结构定义如下:

template<class T>class LinkList{private:    Node<T> *head; // 头部结点指针    Node<T> *tail; // 尾部指针    int cnt; // 链表长度public:    LinkList()  // 构造函数,初始化链表    {        head = new Node<T>; // 获取一个头结点        head->next = NULL; // 将头结点指针域置空        tail = head; // 尾指针指向头部,此时链表为空        cnt = 0;    }    void push_back(T elem);  // 向尾部插入(正向建表)    void push_front(T elem); // 向头部插入(逆序建表)    void insert(T elem, int index);  // 向index位置插入    void del(int index); // 删除index位置的元素    Node<T>* get(int index); // 获取index位置的元素指针    int size(); // 获取链表的大小    void each(char split); // 遍历输出链表,以split参数为间隔};


下面我们根据定义的结构来逐步介绍并实现单链表的操作

(1) 单链表的尾部插入操作(push_back)

  尾部插入,即正向建立链表,我们在定义链表的时候引入了尾部指针,这个指针的作用就是用来快速插入尾部元素。此指针需要保证始终指向链表最末端的元素。

  插入时,我们先需要申请一个Node结点,假设指向该新节点的指针为p,要插入的元素为elem,则现需要初始化该结点p->data=elem。由于此结点是插入到尾部,所以它插入以后就变成了最后一个元素,故需要将指针域置空,即p->next=NULL。如图所示:


  接下来,需要将p指针指向的元素添加到tail指向的元素之后,这一步操作很简单,看图就能看出来,直接tail->next=p即可。如下图所示:

  然后不要忘记,前面说过tail指针要始终保证指向最后一个元素,所以还需要将tail指向p所指的结点,即执行tail=p,如下图:


  至此,完成了单链表的尾部插入操作,代码实现如下:

template<class T>void LinkList<T>::push_back(T elem)  // 向尾部插入(正向建表){    Node<T> *p = new Node<T>;    p->data = elem;    p->next = NULL;    tail->next = p;    tail = p;    cnt++;}

(2) 单链表的头部插入操作(push_front)

  头部插入,即逆序建链表,逆序建链表不需要tail指针的辅助,只需要利用好head指针和头结点即可,第一步与尾部插入的步骤一样,先生成一个新结点,这里就不图示了,与尾插一致,区别在第二步,由于新结点需要插入到链表头部,也就是头结点与元素0之间,所以我们需要先将p的指针域指向元素0,保证p所指结点与除头结点外所有结点相连,如图:


  然后,再把头结点的指针域指向p,使得包括头结点在内所有结点组成线性链,如下图:


  注意,这里顺序不能乱。如果我们先把头结点指针域指向了p,那么头结点与元素0之间的“链子”就会断开,我们就无法再找到元素0了。如下图:

  另外,头部插入有种特殊情况,假设插入前链表为空且引入了尾指针,那么在插入完成后需要将尾指针指向p的指向,其他情况不需要移动尾指针,如下图(画图实在是费劲,有时候用^代表NULL或者空):


  至此头部插入操作完成,代码如下:

template<class T>void LinkList<T>::push_front(T elem) // 向头部插入(逆序建表){    Node<T> *p = new Node<T>;    p->data = elem;    p->next = head->next;    head->next = p;    if(cnt == 0) // 无元素,移动尾指针    {        tail = p;    }    cnt++;}

(3) 单链表的任意位置插入(insert)

  首先先找出任意位置插入的两种特殊情况,假设链表的长度为n,则在位置0插入和在位置n插入属于两种特殊情况。在位置0插入相当于在链表头插入,即push_front,在位置n插入相当于链表尾插入(注意,并不是在n-1插入,想想为什么),相当于push_back。其他情况均属于通常情况。

  假设我们已有一个含有n元素的单链表,现在要在位置i(非0非n)插入一个新元素p,如图所示:


  在位置i插入,则新元素取代原i元素的位置,原i元素及后面所有元素均链接在新元素之后,有没有感觉很熟悉?没错,跟头部插入大同小异,我们只要把i元素前面的那个元素i-1看做是“head元素”即可。那么如何找到元素i和i-1呢?

  之前说过,链表的查找需要从第一个元素开始“顺藤摸瓜”式的查找,所以我们需要先创建一个指向head的指针ptr,循环i次,每次都把ptr后移,即执行ptr=ptr->next,我们就可以找到元素i-1的位置(仔细想想,纸上画画就明白了),然后ptr->next就是元素i的位置。


  然后我们把ptr看做是head,将p插入ptr和元素i之间,与头部插入类似,先p->next=ptr->next;将p的指针域指向元素i,如下图所示:


  然后再将ptr的指针域指向p,即ptr->next=p,完成新元素的插入。如下图:


  至此任意位置插入操作完成,实现代码如下:

template<class T>void LinkList<T>::insert(T elem, int index)  // 向index位置插入{    if(index > cnt || index < 0) // 非法位置,忽略    {        return ;    }    if(index == 0) // 头部插入,直接调用push_front    {        push_front(elem);    }    else if(index == cnt) // 尾部插入,直接调用push_back    {        push_back(elem);    }    else    {        Node<T> *p = new Node<T>;        p->data = elem;        int i = index;        Node<T> *ptr = head;        while(i--)        {            ptr = ptr->next;        }        p->next = ptr->next;        ptr->next = p;        cnt++;    }}

(4) 删除任意位置的元素(del)

  由于单链表是单向链接的线性表 ,也就是说,只能通过前置元素找到后继元素,反之则不行。所以我们要删除某个元素,就要事先知晓待删除元素的前置元素。假设待删除元素为元素i,则需要将元素i-1的指针域指向元素i+1,也就是“跳过”元素i,这样元素i就“脱离”了链表的链子,然后再释放它的内存即可。

  首先第一步,与任意位置插入一样,我们需要一个初始在head的ptr指针,移动i次使它指向元素i的前置结点i-1,如图所示:


  由于i元素是我们要删除的元素,如果此时修改ptr的指针域使链表“跨过”元素i,那么元素i没有指针指向将无法找到,也就不能对其释放内存,所以我们需要用一个指针指向它,假设为p,p即待删元素的指针,即p=ptr->next,然后再修改ptr的指针域为元素i的下一元素,也就是ptr->next=p->next,修改过后如图所示:


  做完这一步,观察上图,实际上链表中已经没有了i元素,但是因为是删除元素,我们需要将i元素从内存中释放,即释放p指针所指内容,使用delete p即可。

同样,删除也有一种特殊情况,存在尾指针时,如果删除的元素为最后一个元素(n-1元素),则需要修改尾指针指向。

  代码如下:

template<class T>void LinkList<T>::del(int index) // 删除index位置的元素{    if(index >= cnt || index < 0) // 非法位置,忽略    {        return ;    }    Node<T> *ptr = head;    int i = index;    while(i--)    {        ptr = ptr->next;    }    Node<T> *p = ptr->next; // 获取待删元素指针    ptr->next = p->next;    delete p;    if(index == cnt - 1) // 如果删除的元素是最后一个    {        tail = ptr; // 修改尾指针指向为ptr(删除元素的前置)    }    cnt--;}

(5) 获取任意位置的元素指针(get)

  我们这里写的方法是获取元素指针而不是获取元素值,目的是方便修改和获取元素,当然,熟练了以后可以分成两个方法来写,毕竟指针操作还是蛮危险的,这里为了简便,就写成指针了。
  同样,由于链表是单向的,所以我们需要从头遍历找到需要的元素。由于起始元素是元素0,我们需要获取的是元素本身,所以与前面插入删除不同,我们要初始化一个ptr为head->next而不是head,循环i次以后,ptr指向的位置就是元素i,然后返回ptr指针即可,比较简单就不做图啦~直接上代码:

template<class T>Node<T>* LinkList<T>::get(int index) // 获取index位置的元素{    if(index >= cnt) // 如果给定位置过大,则默认返回最后元素    {        index = cnt - 1;    }    if(index < 0) // 如果给定位置过小,则默认返回首元素    {        index = 0;    }    int i = index;    Node<T> *ptr = head->next; // 指针指向首元素    while(i--)    {        ptr = ptr->next;    }    return ptr;}

(6)  获取链表长度(size)

  这个简单,直接返回私有字段cnt即可,代码:

template<class T>int LinkList<T>::size() // 获取链表的大小{    return cnt;}

(7)  遍历输出链表,并以split参数为间隔

  这个方法就是做来输出查看的,事实上对于容器的遍历我们应该使用迭代器,由于比较复杂且超出数据结构范畴,所以这里直接输出。由于泛型的存在,所以这里的输出只能输出基本数据类型,其他类型会出错。

  原理还是一样,初始化一个指针p指向链表首元素,循环判断是不是为空,非空就输出元素的值,然后p指针后移,即p=p->next,直到p指针为空,遍历结束。代码如下:

template<class T>void LinkList<T>::each(char split) // 遍历输出链表,以split参数为间隔{    Node<T> *p = head->next;    while(p)    {        cout<<(p->data);        p=p->next;        putchar(p == NULL ? '\n' : split);    }}

  好啦,至此,关于链表的基本操作就讲解并实现完了,链表作为数据结构课程里第一个要学习的东西,其实实现起来还是蛮复杂的,有好多特殊的地方需要考虑,而且各种指针操作一不小心就炸了 。

  所以学习数据结构不仅要求我们有扎实的编程基础(特别是指针),还要求我们有足够的耐心和信心,这只是区区单链表,本章接下来几节还会讲解双向链表和循环链表,后面的章节还会有更复杂的数据结构与算法。

  下面是整个单链表的定义和实现,没有分写头文件和实现文件,main函数里写了几个例子试了一下,如下:

#include<bits/stdc++.h>using namespace std;template<class T>struct Node{    T data;    Node<T> *next;};template<class T>class LinkList{private:    Node<T> *head; // 头部结点指针    Node<T> *tail; // 尾部指针    int cnt; // 链表长度public:    LinkList()  // 构造函数,初始化链表    {        head = new Node<T>; // 获取一个头结点        head->next = NULL; // 将头结点指针域置空        tail = head; // 尾指针指向头部,此时链表为空        cnt = 0;    }    void push_back(T elem);  // 向尾部插入(正向建表)    void push_front(T elem); // 向头部插入(逆序建表)    void insert(T elem, int index);  // 向index位置插入    void del(int index); // 删除index位置的元素    Node<T>* get(int index); // 获取index位置的元素指针    int size(); // 获取链表的大小    void each(char split); // 遍历输出链表,以split参数为间隔};template<class T>void LinkList<T>::push_back(T elem)  // 向尾部插入(正向建表){    Node<T> *p = new Node<T>;    p->data = elem;    p->next = NULL;    tail->next = p;    tail = p;    cnt++;}template<class T>void LinkList<T>::push_front(T elem) // 向头部插入(逆序建表){    Node<T> *p = new Node<T>;    p->data = elem;    p->next = head->next;    head->next = p;    if(cnt == 0) // 无元素,移动尾指针    {        tail = p;    }    cnt++;}template<class T>void LinkList<T>::insert(T elem, int index)  // 向index位置插入{    if(index > cnt || index < 0) // 非法位置,忽略    {        return ;    }    if(index == 0) // 头部插入,直接调用push_front    {        push_front(elem);    }    else if(index == cnt) // 尾部插入,直接调用push_back    {        push_back(elem);    }    else    {        Node<T> *p = new Node<T>;        p->data = elem;        int i = index;        Node<T> *ptr = head;        while(i--)        {            ptr = ptr->next;        }        p->next = ptr->next;        ptr->next = p;        cnt++;    }}template<class T>void LinkList<T>::del(int index) // 删除index位置的元素{    if(index >= cnt || index < 0) // 非法位置,忽略    {        return ;    }    Node<T> *ptr = head;    int i = index;    while(i--)    {        ptr = ptr->next;    }    Node<T> *p = ptr->next; // 获取待删元素指针    ptr->next = p->next;    delete p;    if(index == cnt - 1) // 如果删除的元素是最后一个    {        tail = ptr; // 修改尾指针指向为ptr(删除元素的前置)    }    cnt--;}template<class T>Node<T>* LinkList<T>::get(int index) // 获取index位置的元素{    if(index >= cnt) // 如果给定位置过大,则默认返回最后元素    {        index = cnt - 1;    }    if(index < 0) // 如果给定位置过小,则默认返回首元素    {        index = 0;    }    int i = index;    Node<T> *ptr = head->next; // 指针指向首元素    while(i--)    {        ptr = ptr->next;    }    return ptr;}template<class T>int LinkList<T>::size() // 获取链表的大小{    return cnt;}template<class T>void LinkList<T>::each(char split) // 遍历输出链表,以split参数为间隔{    Node<T> *p = head->next;    while(p)    {        cout<<(p->data);        p=p->next;        putchar(p == NULL ? '\n' : split);    }}int main(){    LinkList<int> lst;    lst.push_back(1);    lst.each(' ');    lst.push_back(2);    lst.push_back(3);    lst.each(' ');    lst.del(1);    lst.each(' ');    lst.get(0)->data = 666;    lst.each(' ');    return 0;}


  如有不足的地方,欢迎大家指正交流~(手动滑稽

  下集预告&传送门: 数据结构与算法专题之线性表——链表(二)双向链表

阅读全文
0 0
原创粉丝点击