趣说数据结构(一)链表

220 阅读37分钟

关于本系列

    如果你能够在阅读本文之后对该知识有一个初步的了解,知道基本的概念,那就足够了。

    这就是本系列的创作初衷。

    希望能帮助到正在学习这个知识点的你。

    同时,为了不让编程语言成为阅读的障碍,本系列的文章都会给出C,Java,Python的代码(Python的代码在有空之后会补上)。

    但是本人是菜鸡,基本上是一边查语法一边进行写作,肯定会有诸多不足,请大家多多担待。

欢迎大家的任何意见,由衷感激。

前言

    假设你是一个火车头,你需要搭载乘客了,那么就需要1节车厢,这节车厢拼接在你后面,这样你就拥有了1节车厢用来搭载乘客。


  当你拥有车厢之后,你就可以搭载乘客出发了。你的Boss说,因为你的乘客中有身份尊贵的人,必须安排每1个人都有1个车厢。你不知道为什么,但还是遵守了。
    第一站,外面有4个乘客,你根据Boss的要求,先拼接一个车厢在你后面,然后再拼接第二个车厢在第一个车厢的后面,再拼接第三个车厢在第第二个车厢的后面,依次类推,这样,你就带着4个车厢继续出发了。
PS:他们车厢的编号分别是001,002,003,004哦 image01.png
    第二站,外面有一个乘客,他的要求很奇怪,他说:“我的车厢必须在最前面。”。你遵循以顾客为上帝的原则,还是答应了他的请求,你分配了005号车厢给他。但是你后面拖着4个车厢呢,那该怎么办呢?你灵机一动,先断开和后面车厢相连的部分,那4节车厢就只有他们自己是互相连接在一起的,然后你把这个奇怪要求的乘客的车厢拼接到你后面,然后把那4节车厢的 最前面的一节车厢 拼接 到了奇怪要求的乘客的车厢后面,这样,就完成了这个奇怪乘客的要求,带着5节车厢继续前往下一站了。
image02.png
    第三站,你遇到了警察,警察说怀疑你的车厢里面有他们抓捕的犯人,要求搜查你的车厢。为了不让犯人逃跑,他们会分成两批人,一批从最开始的车厢一节节往后搜查,一批从你连接的这批车厢的最后一个车厢开始一节节的往前搜查。因为除了最开始的车厢因为前面只有火车头的缘故,所以间隔很大,可以从最开始的车厢进入;最后一个车厢也是,因为最后一个车厢的最后没有车厢了,所以可以从最后车厢的后门进入,其余车厢由于互相连接在一起,只有少许的缝隙,肯定不能从那些地方出入。所以是一个不错的瓮中捉鳖的方法。
image03.png
    警察发现002号车厢的人是犯人,把他逮捕走了,但是你就开始发愁,002号车厢都没人了,那是不是应该把他撤走呢?如果空着的话,下一站要上车的人,就得经过005或者004,往002车厢的方向走,这样又会打扰到别的车厢里面的人。你犹豫许久,还是决定把002号车厢给撤走,免得下一站的客人来了需要经过别人的车厢,打扰到别人的休息。
    于是你先把001跟002的连接断开,变成: image04.png
    再把003和002车厢连接的部分断开: image05.png
    再让001车厢和003车厢互相连接,就成功把002车厢移走了。
image06.png
    完成连接之后,你也开始继续出发,前往下一站啦~
    你的旅程还在继续……

接下来我们通过上面这个小故事,来展开看一下链表,到底是什么。

链表

这里需要引入一个概念:线性表

将具有一对一关系的数据线性地存储到物理空间中,这种存储结构就称为线性存储结构(简称线性表)。是一种最基本,最简单,最常用的数据结构。

一对一,可以简单理解为一个身份证对应了一个人。

线性,可以想象把一些数据可以集中起来,成为一条线。

既然是一条线,那么也可以有两种组成线的方法,这就是线性表的两种存储结构:一种是顺序表(顺序存储结构),一种是链表(链式存储结构)。他们之间的区别就是,他们存放数据的地址是否连续,顺序表是连续的,链表不一定是连续的。

节点

    在上面的故事中,火车搭载人的最基本的东西,就是车厢,在链表中,最基本的单位,叫做节点。也就是说上面故事中的车厢,就跟我们链表的节点一样。
    什么是节点呢?从火车的单个车厢来看,上面故事中的车厢,它能搭载人,有前门和后门这三个重要的属性。

  1. 搭载的人,就是节点中的属性,也就是该节点能够保存的数据。
  2. 前门,它能够和上一节车厢的后门互相连接,使这两个车厢连通,也就是该节点和上一个节点连接的方式,可以说是一个指针,指向了上一个节点,我们通过该指针,就可以访问到上一个节点。
  3. 后门,它能够与下一节车厢的前门相互连接,使这两个车厢连通,也就是该节点和下一个节点的连接方式,可以说是一个指针,指向了下一个节点,我们通过该指针,可以访问到下一个节点。

补充个小的知识点,如果了解内存地址的概念也可以直接跳过


假设你去看演出,需要将东西寄存。寄存处有一个柜子,柜子有很多抽屉。 image07.png
每个抽屉可以放一样东西,你有两样东西要寄存,因此要了两个抽屉。
image08.png
你将两样东西存放在这里。 image09.png
现在你可以去看演出了!这大致就是计算机内存的工作原理。计算机就像是很多抽屉的集合体,每个抽屉都有地址。
image10.png
fe0ffeeb是一个内存单元的地址。 需要将数据存储到内存时,你请求计算机提供存储空间,计算机给你一个存储地址。[1]

    车厢中的前门,转换过来说就算是计算机内存中的一个地址,该地址存储了上一个车厢的地址,通过该地址,我们就可以访问到该车厢,如同探宝游戏一样。

    小明在万圣节得到了一张藏宝图,他高兴坏了,迫不及待就根据藏宝图出发寻找宝藏去了。藏宝图上写的是家里鞋柜第二行第三列。
    小明过去打开了鞋柜,发现里面也有一张藏宝图,上面写的是门口的垫子下面。
    小明又在门口的垫子下面发现了一张藏宝图,上面写的是小明卧室的床底下。
    这次,小明终于在床底下找到了他的圣诞礼物,是一本作业~!

    像藏宝图一样,记录着下一个宝藏的地址,这就是指针。指针记录着一个地址,这个地址上有什么,只有访问的时候才知道啦~
    到现在,我们的节点应该拥有的属性都出现了,分别是节点能保存的数据,上一个节点的地址,下一个节点的地址。
    接下来我们来写一下 节点类 ,他是链表中最基本的单位,所以一定要理解。


C

typedef struct Node
{
    struct Node *prev; //存放上一个节点的地址
    int data; //该节点需要保存的元素
    struct Node *next; //存放下一个节点的地址
    
} Node;
//创建节点的函数 
Node* createNode(Node *prev,int data,Node *next)
{
    Node* node = (Node*)malloc(sizeof(Node));//申请内存
    node->data = data;
    node->prev = prev;
    node->next = next;
    return node;
}

Java

class Node{

    Node prev; //存放上一个节点的地址

    int data; //该节点需要保存的元素

    Node next; //存放下一个节点的地址
    
    //必须提供三个属性用作构造该节点
    //即,必须说明 和这个节点相连接的上一个节点是谁,这个节点包含的元素是什么,下一个节点是谁
    public Node(Node prev, int data, Node next) {
        this.prev = prev;
        this.data = data;
        this.next = next;
    }
}

    以Java的代码来介绍,首先我们创建了一个节点类,该类中有三个属性,分别是prev,data,next。

  1. prev,一个指针,它存放的是上一个节点的地址,通过该地址,这样我们就可以通过访问prev来找到上一个节点。
  2. data,是我们这个节点内保存的数据,也叫做元素。
  3. next,一个指针,它存放的是下一个节点的地址,通过该地址,我们就可以访问到该节点的西下一个节点是什么。

实现一个链表

    首先了解一下链表中存在的几个成员属性~


C

typedef struct {

    struct Node *head; //链表中的头节点 
    
    int size; //记录链表的长度
    
    struct Node *last; //链表中的尾节点 
    
} LinkedList;

//初始化链表的函数 
void initLinkedList(LinkedList* list)
{
    list->head = NULL;
    list->last = NULL;
    list->size = 0;
}

Java

public class LinkedList{

    private Node head; //链表中的头节点
    
    private int size; //记录链表的长度
    
    private Node last; //链表中的尾节点
    //初始化链表的方法
    public LinkedList() {
        size = 0;
        head = null;
        last = null;
    }
}
  1. size,代表链表的长度,我们需要知道链表的长度,才能方便我们进行索引操作(如:根据索引插入指定的元素到指定的位置;删除指定位置的节点)。
  2. head,用作记录链表的头节点,也就如同火车中的最前面的车厢。
  3. last,用作记录链表的尾节点,如同火车中的最后面的车厢。

    重点就是理解headlast这两个属性。就像故事中,我们只是一个火车头,我们需要时刻记得第一个车厢和最后一个车厢,这样才能通过他们走进去车厢中 检查乘客的情况,要是我们忘记了第一个车厢,那么我们就只能从最后一个车厢往前走,其余情况也是同理。
    如果我们把第二个车厢记成了第一个车厢,那么我们就会从第二个车厢开始往下搜索,只有从最后一个车厢往前搜索的时候,才能重新找到第一个车厢,所以我们的head和last是非常重要的,每次如果修改了第一节车厢和最后一节车厢,都要更新我们的head和last。
    该链表的实现中还有一个无参的构造方法 LinkedList() (C是initLinkedList()) ,用来初始化size,head,last这三个属性。 (不初始化的后果大家知道的哦)
    现在我们通过实现一下链表中的方法,来继续了解链表。

增加元素

    故事中,我们作为一个火车头,Boss给我们提的要求是每个车厢搭载一个乘客,也就是我们每个节点,只存放一个元素,因此,我将节点中的data属性设置为了整形,该属性也是只用来存放一个元素,对应一个乘客。


    在第一站的时候,我们有四个客人,也就是我们需要四个车厢,让他们连接起来,才好方便带着他们继续出发前往下一站。换到链表这边,是不是我们就有四个元素,然后需要将他们拼接到一起呢?

    我们可以按照客人的编号,将他们连接起来。让客人1号的后门连接到客人2号的前门,然后客人2号的后门,连接到客人3号的前门,客人3号的后门,连接到客人4号的前门~
image12.png
    像图里的车厢互相连接一样,客人1号的后门,和客人2号的前门互相连接了,也就是客人1号可以走到客人2号的车厢;同样,客人2号也可以走到客人1号的车厢,这样我们就实现了车厢的互通。
    在车厢连通之后,我们就要将最前面的车厢和最后面的车厢记录下来,这样我们就可以通过地址,直接去到客人1号的车厢和客人4号的车厢。就可以随时从后面或者从前面来搜查客人了。 image13.png
    我们使用headlast这两个元素,分别记录下了客人1号车厢的地址0x001、客人4号车厢的地址0x004,这样我们随时可以通过0x001,0x004这个两地址找到客人1号,客人4号的车厢。

    在第二站,我们遇到了一个有着奇怪要求的乘客(客人5号),它要求它的车厢必须在最前面。 image14.png
    我们可以直接让客人5号的后门跟客人1号的前门互相连接,然后更新head这个元素,这样最前面的车厢就变成了客人5号的车厢,同时head这个元素记录的地址也变成了0x005。 image15.png
    这样我们就满足了这个奇怪乘客的要求。
    现在我们经历了这两站的客人,学会了把车厢放在最前面和最后面。那如果还有更奇怪的乘客,他的要求是车厢必须在第三个位置,那我们该怎么做呢?
image16.png
    这肯定难不倒你。在故事中,我们002号车厢的客人是警察要逮捕的犯人,警察把他逮捕之后,002号车厢就空出来了,我们为了便携性考虑,就把002车厢给扔掉了呢,现在我们回想一下具体的操作。

    我们首先让001号车厢和002号车厢的连接断开。
image04.png
    再让002和003车厢直接的连接断开,这样002号车厢就和整体的车厢块 分隔开来了。 image05.png
    之后再让001号车厢和003号车厢互相连接,就相当于把002号车厢给丢掉了。
image06.png


    那么,我们现在,要在第三个位置插入一个一个车厢的话,首先我们就需要让客人1号和客人2号的车厢互相断开。
image17.png
    然后让客人1号车厢的后门与客人6号的前门互相连接,然后让客人6号的后门和客人2号的前门相连,这样,客人6号就成功插入到了客人1号和客人2号之间。

image18.png

因为本次插入车厢的操作并没有影响最开始的车厢和最后面的车厢,所以head和last这两个属性就不需要发生变化了。

    到这里位置,关于增加元素的操作,我们都讲的差不多了,现在补一下代码的实现和分析~


C

void add(LinkedList* list,int value){
    addLast(list,value);
}

void addAtIndex(LinkedList* list,int index,int value){
    //判断索引是否合法
    int size = list->size;
    if( index>=0 && index<=size ){
        if(index == size)
            addLast(list,value);
        else if(index == 0)
            addHead(list,value);
        else
            addMiddle(list,index,value);
    }else{
        printf("索引不合法");
    }
}

void addHead(LinkedList* list,int value){
    Node *f = list->head;
    //创建一个新节点
    Node *newNode =  createNode(NULL,value,f);
    //更新链表中的头节点
    list->head = newNode; 
    if(f == NULL) //初始化处理
        list->last = newNode;
    else
        f->prev = newNode;
        
    list->size++;
}

void addMiddle(LinkedList* list,int index,int value){
    //拿到index索引处的节点
    Node *target = getNode(list,index);
    Node *pred = target->prev;
    //创建一个新节点
    Node *newNode = createNode(pred,value,target);
    //原来index索引处的节点往后移动了一位,并且要让他的上一个节点成为新创建的节点
    //这样我们创建的新节点就插入到了原来的index处,原来index处的索引往后移动了一个位置
    target->prev = newNode;

    if(pred == NULL)
        list->head = newNode;
    else
        pred->next = newNode;

    list->size++;
}

void addLast(LinkedList* list,int value){
    Node *l = list->last;
    //创建一个新节点
    Node *newNode =  createNode(l,value,NULL);
    //更新链表中的头节点
    list->last = newNode; 
    if(l == NULL) //初始化处理
        list->head = newNode;
    else
        l->next = newNode;
        
    list->size++;
}

Java


public void add(int value){
    addLast(value);
}

public void add(int index,int value){
    //判断索引是否合法
    if( index>=0 && index<=size ){
        if(index == size)
            addLast(value);
        else if(index == 0)
            addHead(value);
        else
            addMiddle(index,value);
    }else{
        System.out.println("索引不合法");
    }
}

private void addHead(int value){
    Node f = head;
    //创建一个新的节点
    Node newNode = new Node(null,value,f);
    //更新链表中的头节点
    head = newNode;

    if(f == null) //初始化处理
        last = newNode;
    else
        f.prev = newNode;

    //更新链表长度
    size++;
}

private void addMiddle(int index,int value){
    //拿到index索引处的节点
    Node target = getNode(index);
    Node pred = target.prev;
    //创建一个新节点
    Node newNode = new Node(pred,value,target);
    //原来index索引处的节点往后移动了一位,并且要让他的上一个节点成为新创建的节点
    //这样我们创建的新节点就插入到了原来的index处,原来index处的索引往后移动了一个位置
    target.prev = newNode;

    if(pred == null)
        head = newNode;
    else
        pred.next = newNode;

    size++;
}

private void addLast(int value){
    Node l = last;
    Node newNode = new Node(l,value,null);
    last = newNode;

    if(l == null) //初始化处理
        head = newNode;
    else
        l.next = newNode;

    size++;
}

    以Java代码做例子,提供给外部使用的方法只有两个,分别是add(int value)方法和add(int index,int value)方法。

  • add(int value) 方法,该方法默认将元素插入到链表的末尾。在内部,我封装了一个addLast(int value)方法,该方法是将元素插入到链表的末尾。
  • add(int index,int value) 方法,该方法是能够指定元素的插入位置,在判断index这个索引合法的情况下,会根据需要插入的位置,会调用不同的方法来实现。
    • addHead(int value) 方法,该方法用作将元素插入到链表的头部。
    • addMiddle(int index,int value) 方法,该方法会根据index将对应的元素插入到指定的位置。
    • addLast(int value)方法,该方法用作将元素插入到链表的末尾。

现在主要分析add(int index,int value)及其调用的方法方法。

先来看一下addHead(int value)方法

private void addHead(int value){
    Node f = head;
    //创建一个新的节点
    Node newNode = new Node(null,value,f);
    //更新链表中的头节点
    head = newNode;

    if(f == null) //初始化处理
        last = newNode;
    else
        f.prev = newNode;

    //更新链表长度
    size++;
}
  • 首先是Node f = head;,这代表我们创建了一个临时的指针,该指针目前存放的就是我们链表中head属性的地址,我们拿到这个地址,会在后续进行头部插入的操作,所以我们要拿到head属性的地址来使用。
  • 紧接着是Node newNode = new Node(null,value,f);,这句代表我们使用了Node的构造方法,创建了一个 头节点为null,节点的元素为通过形参传递进来的value,尾节点为f的节点。为什么尾节点是f呢?f就是我们上一个语句拿到的,当前链表中的头节点,也就是说,我们创建的这个节点,它的下一个节点就是我们当前链表中的头节点。也就是我们新创建的这个节点,这样新创建的节点就可以根据next指针中保存的地址,访问到我们当前链表中的头节点f。
  • 然后是head = newNode;,代表更新我们链表中的头节点 为 我们新创建的节点,也就是说,现在链表中的head属性,他保存的地址就是我们新创建的这个节点的地址,该节点就成为了链表中的头节点
if(f == null) //初始化处理
    last = newNode;
else
    f.prev = newNode;
  • 这里会先判断,我们之前拿到的头节点f是不是为空,如果拿到的头节点是空的,代表链表中还没有元素,头节点和尾节点都是空的。 image19.png

    • 既然头节点和尾节点是空的,那么链表中就是没有元素,链表中没有元素,当前我们新创建的节点,我们已经让他成为了头节点。 image20.png
    • 但是链表中只有一个元素,所以我们的头节点记录的地址应该是和尾节点一样的,也就是头节点和尾节点同时指向同一个地址。所以我们让last 也保存 newNode这个节点的地址。
    • 同样,既然我们插入的这个节点是链表中的唯一一个节点,那么相对应的,f是空(因为我们插入的时候链表中没有元素),所以他的prev指针,也就是指向上一个节点的指针是为空的,也就是该节点的prev指针没有保存有地址,next指针也是同理。 image21.png
  • 如果f不是空的,代表f保存了一个节点的地址,也就是了链表中存在起码一个元素。 image22.png

    • 我们直接让f这个节点的地址的prev指针,指向我们新创建的节点newNode。 image23.png
    • 让newNode的next指针指向了f,f的prev指针指向了newNode,这两个节点就相互连接起来了。 image24.png 注意哦,创建了新的节点之后,因为该方法是将我们新创建的节点插入到头部的方法,所以我们也要同时更新head这个属性。
  • 最后,还剩一句 size++;,size这个属性我们之前说过,是用来记录链表的长度。这里在特殊声明一下:

size是链表的长度,不是索引,索引是从0开始,索引0代表链表中的第一个元素,但是size=1,那么就代表链表中只有一个元素,该元素的索引是0。

再看一下addMiddle(int index,int value)方法

private void addMiddle(int index,int value){
    //拿到index索引处的节点
    Node target = getNode(index);
    Node pred = target.prev;
    //创建一个新节点
    Node newNode = new Node(pred,value,target);
    //原来index索引处的节点往后移动了一位,并且要让他的上一个节点成为新创建的节点
    //这样我们创建的新节点就插入到了原来的index处,原来index处的索引往后移动了一个位置
    target.prev = newNode;

    if(pred == null)
        head = newNode;
    else
        pred.next = newNode;

    size++;
}
  • getNode(int index);,这个方法是得到指定索引(index)处的节点,该方法在查询元素那块讲解,这里我们只需要记住该方法能够拿到指定索引处的节点即可,比如说链表中有三个节点,那么链表的长度就是3,要拿到中间的那个节点,中间元素的索引就是1,我们使用getNode(1)就能拿到链表中 中间的那个节点了。所以Node target = getNode(index) 就拿到了index索引处的节点的地址,然后把这个地址给了target,这样target就能访问到index索引处的节点了。 image25.png
  • Node pred = target.prev,这里拿到 我们通过getNode(int index)方法拿到的节点的prev地址,也就是我们index索引处节点地址 的 上一个节点的地址信息。 image26.png
  • Node newNode = new Node(pred,value,target);,这里我们继续创建一个新的节点,这个新创建的节点的上一个节点的地址是pred,也就是我们index索引处的上一个节点的地址,然后该节点的元素为value,该节点的下一个节点的地址是target,也就是我们index索引处的节点。 image27.png
  • target.prev = newNode;,让原本index索引处的节点的上一个节点,变成我们新创建的节点。也就是让原本index索引处的节点与我们新创建的节点相连。 image28.png
  • 接下来是经典的判空操作,简单,但重要。
if(pred == null)
    head = newNode;
else
    pred.next = newNode;
    • 这里判断pred是否为空,是为了处理我们根据索引拿到的节点是头节点的情况。如果pred为空了,代表我们拿到的就是头节点了。
    • 为什么是头节点呢?我们知道,链表中的节点总是有pred和next,pred如果为空,代表他前面没有元素,所以他就是链表中的头节点。next为空,代表该节点后面没有节点了,所以他就是链表中的尾节点。
    • 既然pred这个节点是头节点了,代表我们通过index索引拿到的节点就是头节点,那么我们这个方法,是要将新创建的元素添加到index索引处的节点的前面的,也就是说,我们新创建的节点,替代了原先的头节点成为了新的头节点,所以我们就需要更新head这个属性。
    • PS:只要操作涉及到headlast, 就必须进行更新,这是链表有序的保障。
  • 如果pred不为空,则代表链表中存在至少一个节点,也代表我们拿到的这个节点不是头节点。

    • 既然我们通过index索引拿到的节点不是头节点,那么我们直接让index索引处的上一个节点pred 的next指针,指向我们新创建的newNode节点,这样我们新创建的节点,就和原先index索引处节点的上一个节点互相连接起来了。 image29.png
    • 我们上面也操作pred指针处的节点(0x0010)和我们新创建的节点newNode(0x2345)相连了,至此,完成了链表中的节点插入到指定索引操作。
    • 最后size++;,操作完成不要忘记更新链表的长度了,这也是个很重要要的属性,他是我们链表中所有操作前提的保障。

至此,具体的操作方法我们已经说完了,addLast(int value)这个方法就留给大家自己思考~

接下来就是我们的add(int index,int value)这个方法了

public void add(int index,int value){
    //判断索引是否合法
    if( index>=0 && index<=size ){
        if(index == size)
            addLast(value);
        else if(index == 0)
            addHead(value);
        else
            addMiddle(index,value);
    }else{
        System.out.println("索引不合法");
    }
}
  • 该方法首先判断我们的索引是否合法,即索引的范围index>=0 && index<=size,index>=0大家都理解的,索引是从0开始的,所以index必须大于等于0,那为什么index可以小于等于size呢? 我们通常通过索引和长度做判断不都是index<size吗?
  • 这里因为允许尾部插入,所以当index == size的时候,我们就让他执行addLast(int value)方法 ,让该方法将元素插入到链表的末尾。
  • 如果index != size,那么我们就在进行一次判断,判断index是不是0,如果是0,那么代表需要将包含value这个元素的节点插入到0索引的位置,也就是将该节点插入到链表的头部。所以这里就直接调用addHead(int value)方法了。
  • 如果及不满足index == size,也不满足index == 0,就代表着我们需要将包含value的元素插入到指定的索引处,我们上面也写了一个方法来帮我们完成操作,所以我们直接调用了addMiddle(int index,int value)方法即可。
  • 最后,add(int index,int value)是必须判断index索引是否合法,合法的时候才会往下判断需要调用哪个方法来进行操作,如果index索引不合法,就在控制台输出相应信息了~索引不合法的处理大家也可以根据需求来更改。

最后这个add(int value)方法调用的addLast(int value)方法就交给大家进行测验,看看自己掌握的怎么样~

public void add(int value){
    addLast(value);
}
private void addLast(int value){
    Node l = last;
    Node newNode = new Node(l,value,null);
    last = newNode;

    if(l == null) //初始化处理
        head = newNode;
    else
        l.next = newNode;

    size++;
}

删除元素

    故事中,我们的002号车厢的客人被警察逮捕了,于是002号车厢就空出来了,我们就把002号车厢扔掉了。
    我们在回顾一下我们扔掉002号车厢的操作,首先断开002号车厢前门和001号车厢后门连接的部分,在断开002号车厢后门与003号车厢前面连接的部分,然后再让001号车厢后门和003号前面相连,这样001号车厢就和003号车厢互相连接了,002号车厢就被我们抛弃啦。     删除其实跟增加差不多,也可以分成三种情况讨论,分别是删除头节点,删除尾节点,删除其他节点,写起来也蛮简单的。


C

void removeNode(LinkedList* list,int index){
    //检查索引是否合法
    int size = list->size; 
    if( index>=0 && index<size ){
        //拿到需要删除的元素,让他的上一个元素直接连接到他的下一个元素即可
        Node *killNode = getNode(list,index);
        Node *prev = killNode->prev;
        Node *next = killNode->next;
        //需要做判空处理
        if(prev == NULL)
            list->head = next;
        else
            prev->next = next;


        if(next == NULL)
            list->last = prev;
        else
            next->prev = prev;

        list->size--;
    }else{
        printf("索引不合法");
    }
}

Java

public void remove(int index){
    //检查索引是否合法
    if( index>=0 && index<size ){
        //拿到需要删除的元素,让他的上一个元素直接连接到他的下一个元素即可
        Node killNode = getNode(index);
        Node prev = killNode.prev;
        Node next = killNode.next;
        //需要做判空处理
        if(prev == null)
            head = next;
        else
            prev.next = next;

        //需要做判空处理
        if(next == null)
            last = prev;
        else
            next.prev = prev;

        size--;
    }else{
        System.out.println("索引不合法");
    }
}
  • 同样需要检查索引是否合法,index必须小于size,因为index作为索引,是肯定比size少一个,如果index==size,需要删除的元素就不在链表中了(没有该节点)。
  • 首先是通过Node killNode = getNode(int index) 拿到需要删除的那个节点。 image30.png
  • 然后 Node prev = killNode.prev;,
             Node next = killNode.next; 记录需要删除节点的上一个节点和下一个节点,方便他们进行互相连接 image31.png
  • 之后就是经典的判空处理了,简单,但重要。这是需要删除的节点(killNode)的上一个节点的判空操作
//需要做判空处理 
if(prev == null) 
    head = next; 
else 
    prev.next = next;
    • 首先要判断prev,也就是我们需要删除的那个节点的上一个节点。如果prev为空,那么代表我们需要删除的节点killNode是头节点,我们就直接让killNode的下一个节点成为头节点。这样就相当于删除掉了头节点,让原先的头节点的下一个节点成为了头节点。
    • 如果prev不为空,直接让prev的下一个元素指向killNode的下一个节点,也就是next image32.png
  • 接下来就是需要删除的节点(killNode)的下一个节点的判空操作了。
//需要做判空处理
if(next == null)
    last = prev;
else
    next.prev = prev;
    • 同上面一样,同样判断next是否为空,如果为空,就代表killNode节点是尾节点,我们将尾节点删除,让尾节点的前一个元素prev成为新的尾节点,这样就更新了尾节点
    • 如果next不为空,则正常进行连接操作,让killNode的下一个节点的prev属性,指向killNode的上一个节点,这样killNode的上下两个节点就互相连接在一起了。 image33.png

如图所示,这样元素为value的节点就和元素为5的节点互相连接起来了,通过元素为value的next指针,就能访问到元素为5的节点了,虽然元素为7的节点还存在,但是我们已经没有办法访问到元素为7的节点了。所以我们说元素为7的节点已经被删除(扔掉)了。

修改元素

    这个是链表操作中最容易理解的一个操作了。
    假设故事中警察并没有找到犯人,同时那位尊贵的乘客来了,他指名道姓需要002号车厢,那么我们就只能把002号车厢的客人请出去,让我们那位尊贵的客人坐进去。也就是说,我们需要从最开始的车厢往后走,走到002号车厢,或者从最后面往前走,走到002号车厢,把尊贵的客人带到002号车厢,同时把002号车厢的客人请走轰走


C

void setNode(LinkedList* list,int index,int value){
    //检查索引是否合法
    int size = list->size;
    if( index>=0 && index<size){
        Node *target = getNode(list,index);
        target->data = value;
    }else{
        printf("索引不合法");
    }
}

Java

public void set(int index,int value){
    //检查索引是否合法
    if( index>=0 && index<size){
        Node target = getNode(index);
        target.data = value;
    }else{
        System.out.println("索引不合法");
    }
}
  • 这个大家应该也看得懂了,我们通过getNode(int index)拿到需要修改值的节点,直接修改节点的data属性即可

查询元素

    在故事中,第三站,警察要搜查我们车厢,把犯人抓回去。 那么我们为了配合警察的工作,告诉了他们最前面的车厢和最后面的车厢的地址,这样他们就可以从最前面和最后面一起往中间搜查,这样排查能够防止遗漏,同时又全面~
image03.png
    但是我们只是一个人,没有办法同时从最后一节车厢和最前面一节车厢搜索,我们只能从一个方向出发,如果我们能够知道要搜查的是哪个客人的车厢,就能够判断是从最后一节车厢进去快,还是从最前面一节车厢进去快了。
    从链表中看,也就是我们需要一个方法,能够根据索引,拿到对应的节点 image03.png


C

//获取指定索引处的节点 
Node* getNode(LinkedList* list,int index){
    Node *target;
    int size = list->size;
    if(index< (size>>1) ){
    	target = list->head;
    	int i;
    	for(i = 0;i<index;i++){
            target = target->next;
        }
        return target;
    }else{
	target = list->last;
    	int i;
    	for(i = size-1;i>index;i--){
            target = target->prev;
        }
        return target;
    }
}

int get(LinkedList* list,int index){
    int size = list->size;
    if(index>=size || index < 0) return -1;
    Node *res = getNode(list,index);
    return res->data;
}

Java

public int get(int index){
    if(index>=size || index < 0) return -1;
    return getNode(index).data;
}
private Node getNode(int index){
    Node target;

    if(size<(index>>1)){ //如果需要查找的索引在链表的前半段,则从头节点往后查找
        target = head;
        for(int i = 0;i<index;i++){
            target = target.next;
        }
        return target;
    }else{ //否则从尾节点往前找
        target = last;
        for(int i = size-1;i>index;i--){
            target = target.prev;
        }
        return target;
    }
}
  • get(int index) 这个方法判断索引是否合法,只有合法的索引才能通过索引拿到链表中的节点的元素。也就是当索引合法之后,才会调用getNode(int index),这里的getNode(int index)是封装起来的,私有的,外部无法访问,所以不用担心我们会调用到这个未经检查索引的getNode(int index)方法。
  • getNode(int index)该方法将返回我们通过index索引找到的节点的地址,通过该地址,我们就可以访问到这个节点,对该节点进行操作。
    • Node target,算是个指针,记录我们之后找到的节点的地址信息。
    • index>>1相当于index/2,右移比直接/快一点。
    • 也就是我们直接判断,index是处于链表中的哪个位置,如果在前半段,就从头节点开始往后找。否则就从后面往前找。
    • 循环遍历就是需要经过多少个位置能找到对应索引处的节点,这里就不展开说了。
  • target = head 或者 target = last,代表我们的target拿到的是头节点的地址,还是尾节点的地址,如果是头节点的地址,那么我们需要往下找的话,直接让target = target.next 这个语句每指向一次,target就会往下找一个节点,target = target.prev同理,只不过这是从后往前开始找。
  • PS:除了更新操作,其余时候千万不要操作headlast这两个属性哦,这两个属性只用作记录和更新,如果我们对其进行了操作,比如head = head.next,那么链表中记录的头节点就往后移动了一位,相当于我们执行了一次删除头节点的操作。
  • 因此,headlast我们只有在头尾节点发生变化的时候才进行更新。

完整代码

因为C不太熟悉,所以只在main里面创建了链表,相应方法都已经测试 (跑了lc的测试)

C

#include <stdio.h>
#include <malloc.h>
#include <stdlib.h>
#include <stdbool.h>

typedef struct Node
{
    struct Node *prev; //指向上一个节点
    int data; //该节点需要保存的元素
    struct Node *next; //指向下一个节点
    
} Node;

typedef struct {
    struct Node *head; //链表中的头节点 
    int size; //链表中的长度 
    struct Node *last; //链表中的尾节点 
} LinkedList;

void addHead(LinkedList* list,int value);
void addMiddle(LinkedList* list,int index,int value);
void addLast(LinkedList* list,int value);
//初始化链表的函数 
void initLinkedList(LinkedList* list)
{
    list->head = NULL;
    list->last = NULL;
    list->size = 0;
}

//创建节点的函数 
Node* createNode(Node *prev,int data,Node *next)
{
    Node* node = (Node*)malloc(sizeof(Node));//申请内存
    node->data = data;
    node->prev = prev;
    node->next = next;
    return node;
}

//获取指定索引处的节点 
Node* getNode(LinkedList* list,int index){
    Node *target;
    int size = list->size;
    if(index< (size>>1) ){
    	target = list->head;
    	int i;
    	for(i = 0;i<index;i++){
            target = target->next;
        }
        return target;
    }else{
        target = list->last;
        int i;
        for(i = size-1;i>index;i--){
            target = target->prev;
        }
        return target;
    }
}

int get(LinkedList* list,int index){
    int size = list->size;
    if(index>=size || index < 0) return -1;
    Node *res = getNode(list,index);
    return res->data;
}

void add(LinkedList* list,int value){
    addLast(list,value);
}

void addAtIndex(LinkedList* list,int index,int value){
    //判断索引是否合法
    int size = list->size;
    if( index>=0 && index<=size ){
        if(index == size)
            addLast(list,value);
        else if(index == 0)
            addHead(list,value);
        else
            addMiddle(list,index,value);
    }else{
        printf("索引不合法");
    }
}

void addHead(LinkedList* list,int value){
    Node *f = list->head;
    //创建一个新节点
    Node *newNode =  createNode(NULL,value,f);
    //更新链表中的头节点
    list->head = newNode; 
    if(f == NULL) //初始化处理
        list->last = newNode;
    else
        f->prev = newNode;
        
    list->size++;
}

void addMiddle(LinkedList* list,int index,int value){
    //拿到index索引处的节点
    Node *target = getNode(list,index);
    Node *pred = target->prev;
    //创建一个新节点
    Node *newNode = createNode(pred,value,target);
    //原来index索引处的节点往后移动了一位,并且要让他的上一个节点成为新创建的节点
    //这样我们创建的新节点就插入到了原来的index处,原来index处的索引往后移动了一个位置
    target->prev = newNode;

    if(pred == NULL)
        list->head = newNode;
    else
        pred->next = newNode;

    list->size++;
}

void addLast(LinkedList* list,int value){
    Node *l = list->last;
    //创建一个新节点
    Node *newNode =  createNode(l,value,NULL);
    //更新链表中的头节点
    list->last = newNode; 
    if(l == NULL) //初始化处理
        list->head = newNode;
    else
        l->next = newNode;
        
    list->size++;
}

void removeNode(LinkedList* list,int index){
    //检查索引是否合法
    int size = list->size; 
    if( index>=0 && index<size ){
        //拿到需要删除的元素,让他的上一个元素直接连接到他的下一个元素即可
        Node *killNode = getNode(list,index);
        Node *prev = killNode->prev;
        Node *next = killNode->next;
        //需要做判空处理
        if(prev == NULL)
            list->head = next;
        else
            prev->next = next;


        if(next == NULL)
            list->last = prev;
        else
            next->prev = prev;

        list->size--;
    }else{
        printf("索引不合法");
    }
}

void setNode(LinkedList* list,int index,int value){
    //检查索引是否合法
    int size = list->size;
    if( index>=0 && index<size){
        Node *target = getNode(list,index);
        target->data = value;
    }else{
        printf("索引不合法");
    }
}


//创建链表 
LinkedList* list;

LinkedList* createLinkedList() {
    //申请内存
    list = (LinkedList*)malloc(sizeof(LinkedList));
    initLinkedList(list);
    return list;
}

int main(){
	//创建链表 
	LinkedList *list = createLinkedList();
	//之后就自己测试啦~ 
	return 0;
} 

Java

public class LinkedList{

    private int size; //记录链表的长度

    private Node head; //链表中的头节点

    private Node last; //链表中的尾节点

    public LinkedList() {
        size = 0;
        head = null;
        last = null;
    }

    //在链表类中声明一个内部类,该类只在LinkedList中使用
    class Node{
        Node prev; //上一个节点的地址
        int data; //该节点保存的元素
        Node next; //下一个节点的地址

        //必须提供三个属性用作构造该节点
        //即,必须说明 和这个节点相连接的上一个节点是谁,这个节点包含的元素是什么,下一个节点是谁
        public Node(Node prev, int data, Node next) {
            this.prev = prev;
            this.data = data;
            this.next = next;
        }
    }
    
    public int get(int index){
        if(index>=size || index < 0) return -1;
        return getNode(index).data;
    }

    private Node getNode(int index){
        Node target;

        if(size<(index>>1)){ //如果需要查找的索引在链表的前半段,则从头节点往后查找
            target = head;
            for(int i = 0;i<index;i++){
                target = target.next;
            }
            return target;
        }else{ //否则从尾节点往前找
            target = last;
            for(int i = size-1;i>index;i--){
                target = target.prev;
            }
            return target;
        }
    }

    public void add(int value){
        addLast(value);
    }
    
    public void add(int index,int value){
        //判断索引是否合法
        if( index>=0 && index<=size ){
            if(index == size)
                addLast(value);
            else if(index == 0)
                addHead(value);
            else
                addMiddle(index,value);
        }else{
            System.out.println("索引不合法");
        }
    }
    
    private void addHead(int value){
        Node f = head;
        //创建一个新的节点
        Node newNode = new Node(null,value,f);
        //更新链表中的头节点
        head = newNode;

        if(f == null) //初始化处理
            last = newNode;
        else
            f.prev = newNode;

        //更新链表长度
        size++;
    }
    
    private void addMiddle(int index,int value){
        //拿到index索引处的节点
        Node target = getNode(index);
        Node pred = target.prev;
        //创建一个新节点
        Node newNode = new Node(pred,value,target);
        //原来index索引处的节点往后移动了一位,并且要让他的上一个节点成为新创建的节点
        //这样我们创建的新节点就插入到了原来的index处,原来index处的索引往后移动了一个位置
        target.prev = newNode;

        if(pred == null)
            head = newNode;
        else
            pred.next = newNode;

        size++;
    }
    
    private void addLast(int value){
        Node l = last;
        Node newNode = new Node(l,value,null);
        last = newNode;

        if(l == null) //初始化处理
            head = newNode;
        else
            l.next = newNode;

        size++;
    }

    public void remove(int index){
        //检查索引是否合法
        if( index>=0 && index<size ){
            //拿到需要删除的元素,让他的上一个元素直接连接到他的下一个元素即可
            Node killNode = getNode(index);
            Node prev = killNode.prev;
            Node next = killNode.next;
            //需要做判空处理
            if(prev == null)
                head = next;
            else
                prev.next = next;


            if(next == null)
                last = prev;
            else
                next.prev = prev;

            size--;
        }else{
            System.out.println("索引不合法");
        }
    }

    public void set(int index,int value){
        //检查索引是否合法
        if( index>=0 && index<size){
            Node target = getNode(index);
            target.data = value;
        }else{
            System.out.println("索引不合法");
        }
    }
}

参考文献

[1] [美] Aditya Bhargava.算法图解[M].袁国忠译注.北京:人民邮电出版社,2017: 16-17.