代码随想录打卡第三天|链表理论基础、203.移除链表元素 、 707.设计链表、 206.反转链表

2 链表

2.1 链表的基础理论

什么是链表:链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。

链表的入口节点称为链表的头结点也就是head

链表的类型:

  1. 单链表

  2. 双链表:

    • 每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点

    • 既可以向前查询也可以向后查询

  3. 循环链表:

    • 链表首尾相连

    • 可以用来解决约瑟夫环问题

链表的存储方式:

  • 数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。

  • 链表是通过指针域的指针链接在内存中各个节点,所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理

链表节点的定义

链表的定义

链表的操作:

  • 删除节点(在C++里最好是再手动释放这个D节点,释放这块内存。其他语言例如Java、Python,就有自己的内存回收机制,就不用自己手动释放了。)O(1)(但是要注意,要是删除第五个节点,需要从头节点查找到第四个节点通过next指针进行删除操作,查找的时间复杂度是O(n))

  • 添加节点:O(1)

  • 查找:O(n)

链表 VS 数组

分析:链表是一个一个的结点 ,每个结点指向下一个结点这一长串的链我们怎样把它拎起来,肯定是拎着个头

所以有两种实现办法:

  1. 带有头指针的链表

  2. 带有头结点的链表:最前面的结点中不放数据,数据域是不用的,不存数据,这个结点的全部功能和作用就是来指向下一个结点,这种就叫带有头结点的链表(这种方法一般更好用,因为这样可以不用根据是否是头指针来分别处理操作,可以把是头节点和不是头节点的这两种情况统一起来;如果题目让返回操作后链表的头指针,返回这个构造节点的next就行

这俩的区别:

  1. 头结点的链表始终比带有头指针的链表要多耗费一点空间

  2. 头指针链表:假设要在头部结点插入一个新结点,这时头指针的值变化了,所以头指针链表插入或者删除操作可能会修改头指针;而带有头结点的链表的插入和删除都不会修改链表的指针,而只会修改链表指针指向的第一个头结点的next指针域

  3. 主要区别就是在我们写程序时,如果采用带有头指针的链表,要注意一些操作要修改头指针;如果采用头结点的链表,那么各类操作都不可能改变指针,只改变头结点的值 选哪种都行,根据需求进行,演示带有头指针的,因为复杂一些

链表的基本实现

如果不定义构造函数使用默认构造函数的话,在初始化的时候就不能直接给变量赋值!

1. C语言

定义结构体:

  1. 每一个结点(要包含数据域+指针域)

    struct Node{
        int data; 
    //  int * point;指向的是下一个结点,所以是错的
        struct Node* next; 
    }; 
  2. 整个链表

操作:

  1. 初始化

    //因为要修改head,所以把head的指针传进去;初始化肯定会成功所以是void 
    //结构体指针的指针,因为head是指针,我们传的是head的地址,所以就要拿指针的指针来承接 
    void init(struct Node** phead){
        *phead=NULL;    
    } 

    意思就是头指针的变量类型本来就是结点指针类型的,要修改它的话传给函数的值应该是头指针的指针(因为头指针指向的是下一个结点的地址,而不是它自己)

  2. 求表长

    参数传什么呢:获取表长的函数是不会改变链表的,是只读的,所以只需要传head是不需要传它的指针的,因为head就是一个简单的指针,不是一个对象,所以传递head不是很麻烦,之前说过,当需要传递一个比较大的对象时,应该传指针

    int getLength(struct Node* head){
        int len=0;
        while(head){
            len++;
            //不是把链表的头指针改了,因为C语言这个地方是值传递,我们/是把头指针的值传给了函数里面的局部变量,所以在while循环中改变的是局部变量,并没有改真正head的值 
            head=head->next;
        }
        return len;
    } 
  3. 遍历

    传什么参数:注意顺序表这里传了一个指针进去,因为顺序表的结构体很大,我们需要传结构体的指针,而在这里链表,往里传的是很小的头指针 ,所以不大,在这里也不需要改变,所以依然传的是一个值

    void  printList(struct Node* head){
        while(head){
            printf("%d\n",head->data);
            head=head->next;
            //同理,在这里并没有改变头指针,改变的只是一个局部变量 
        }
    } 
  4. 构造一个结点的函数

    返回值是指向这个结构体的指针

     struct Node* createNode(int x){
        struct Node* t;//临时变量 
        t=(struct Node*)malloc(sizeof(struct Node));
        //创建结点可能会失败,当内存空间都被用光的时候,malloc会失败,这里没有涉及 
        //一个好的习惯是所有的指针域都不能保持为未赋值的状态,所以初始化一下
         t->next=NULL;
         t->data=x;
         return t; 
     } 
  5. 查找

    查找第k个结点的地址,返回这个结点的地址就行

    struct Node* findKth(struct Node* head,int k){
        //默认非空 
        int count=1;
        struct Node* p;//这里不用申请p也行?
        p=head; 
        //始终要考虑操作失败的情况
        //始终要考虑多种情况
        while(p&&(count<k)){
            count++;
            p=p->next;
         } 
         //这里p可能是空也可能非空,插入和删除后面会自己判断 
         
        return p;
     }
  6. 插入

    要指定在哪个表里面插入,所以要先有个head,表示在head表头所 代表的表里面插入,k表示在哪个位置插入,要插什么元素

    要注意带有头指针的单链表在插入时,是有可能改变头指针的,因为可能会产生一个新的头结点,头指针永远是指向头结点的,所以头指针的值可能会变因为可能会变,所以这里传head的地址

    如果insert插入的位置不合适就会失败,所以函数的返回值应该表示成功还是失败

     int insert(struct Node** phead,int k, int x){
        //这里没有像顺序表一样有一个现成的length去获取 
        //用getLength()去读取也可以,但是不太好,因为getLength()这个函数本身是要把链表
    //   遍历一遍,所以表长如果是N的话,这个地方一定是花N的时间
    //方法:要想在k这个位置插入,首先找到k-1这个位置,从头开始数数一个一个去找
    //成功了就可以插入,如果找k-1都不成功,那就不可以插入 
    //能不能找到k-1就是能不能在k这个位置插入的关键
    //  int location=1;
    //  struct Node* p=*phead;//p从最前面第一个点开始,所以p的初值是第一个点 
        //最开始是空表,p是空值,所以在下面的while就直接退出了
        //如果这个链表一开始就是空的 
        if(k==1){
            struct Node* n;
            n=createNode(x);
            n->next=*phead; 
            *phead=n;
            return 1;
        } 
        //一个一个看,不空就数数 
        //1. 首先要保证p往后找的过程中,不断地指向下一个,p不能变成空 
        //2. 要保证数数不能数过了 
        struct Node* p;
        p=findKth(*phead,k-1);
        if(p){
            struct Node* n;
            n=createNode(x);
            n->next=p->next;
            p->next=n;
            
            return 1;
        } else{
            printf("不对"); 
            return 0;
        }
            
     } 
  7. 删除链表中的结点

    传什么参数:删除链表中的头结点时,会改变头指针,所以要传head的地址;需要删除的位置;还需要获得删除的这个点

    返回值:可能会删除失败,要能知道删除的操作是否成功了,

    在C++里最好是再手动释放这个D节点,释放这块内存。其他语言例如Java、Python,就有自己的内存回收机制,就不用自己手动释放了。

2.2 203. 移除链表元素

注意题意:删除所有值为val的节点

移除某个结点的过程:

注意,如果是C或C++的时候,要把这个被删除的结点的内存释放掉

但是其中涉及到一个问题,就是移除每一个节点都要找到它前面的一个结点,然后指向这个结点的下一个结点,把这个结点从这个列表中移除,但是如果我们要移除头结点的话,这个头结点已经没有前一个结点了,这样的话怎么移除这个头结点,直接用head=head.next,那么说明面对这两种情况(头结点和非头结点的),操作(移除元素的方式)是不一样的

所以就是删除结点的方式没有统一,那有没有方法能让它们统一呢?有,虚拟头结点的方法。就是在链表中再加入一个头结点,叫dummy head,这样的话,我们在删除这个列表中的所有元素就都可以按照之前说的规则了,如果删的是头结点的话,就可以让虚拟结点直接指向这个头结点的下一个,这样就是用统一的规则来删了,这样我们代码看上去会简洁一些

原链表删除元素:首先要判断head头结点是否为空,因为接下来要取头结点的值,如果为空的话会报错;注意因为这是一个单向链表,所以如何找到这个节点的上一个节点很重要,所以当是做原链表删除元素的时候,cur要从head开始,而不是head.next开始,所以还需要让cur.next不为空,因为需要取cur.next的值来和val对比;最后返回的是head,没有改变head的值(删除的不是头结点时),定义了一个临时指针cur,而head还是指向一开始操作的链表(如果用头结点进行遍历,那么头结点的值会一直改变,最后就找不到原来的head值了,也就是这个链表了)

使用虚拟头结点:首先要new一个新的头结点,然后和原链表联系起来;然后要注意cur应该指向的是dummy而不是dummy.next(还是删除元素要找到被删除结点的前一个结点的原因),要删的就是cur.next;最后return的是dummy.next而不是head,因为head很有可能被我们删了

注意:需要保持pre始终是在cur的前面 所以,当找到要删除的结点时,pre.next=cur.next,然后pre不能动了(因为pre始终要在cur的前面),cur还要向后再移动 而当当前结点不是要删除的结点时,pre也要向后移动,cur也要继续向后移动

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode removeElements(ListNode head, int val) {
        //删除的话要先找到被删除节点的前一个节点
        ListNode dummyHead=new ListNode(-1,head);
        ListNode cur=dummyHead;
        while(cur.next!=null){
            //cur.next是要删除的节点
            if(cur.next.val==val){
                //值等于val时移两个
                cur.next=cur.next.next;
            }else{
                //值不等于val时移一个
                cur=cur.next;
            }
        }
        return dummyHead.next;
    }
}

如果使用C,C++编程语言的话,不要忘了还要从内存中删除这两个移除的节点

  • 时间复杂度: O(n)

  • 空间复杂度: O(1)

2.3 707. 设计链表

题意理解:让我们实现五个函数:

  1. 获取第n个节点的值

  2. 头部插入节点

  3. 尾部插入节点

  4. 第n个节点前插入节点

  5. 删第n个节点

需要注意的是,这个题目里的n是从0开始的,也就是获取第0个节点也就是链表的头节点

这里统一使用了虚拟头节点的方式,这样方便对链表进行增删改的操作

获取第n个节点的值:(注意:第0个节点就是头节点)

  1. 首先要对n进行合法判断

  2. 定义一个临时指针cur(专门用来遍历的),因为操作的是要获取当前这个节点的值,所以直接cur=dummy_head.next(为什么要纠结如何赋值?因为这里如何赋值和下面的while进行循环遍历是“一脉相承”的)

    在对cur进行赋值的时候,以及while里面究竟怎么操作的时候想清楚极端的情况(头节点、尾节点)就可以了

头部插入节点:

  1. 首先要定义一个新的链表节点

  2. 为什么这里赋值的顺序这么重要,其实这个地方还好,因为是在head前插入的,head还是有记录的,但是如果是在第n个节点前插入新节点,head就没有办法保存

尾部插入节点:

  1. 先定义一个新的节点

  2. 找尾部的位置

    当前节点一定要指向尾部节点,这样才能插入新节点,那如何让当前节点cur停在尾部,就是让cur.next=null,这就明确了终止条件

第n个节点前插入节点:

  1. 构造新结点

  2. 找到n前一个的位置

    需要注意的是,要在第n个节点前插入一个新节点的话,那么就要知道n前一个节点,所以cur最开始要指向dummy_head

    对于这么写能不能找到第n个节点(前一个结点),对于这种边界上的考虑,举一个极端的例子就可以了

    注意,必须保证cur.next是第n个节点

  3. 进行插入

删除第n个节点:

要删除第n个节点,必须要知道它前一个节点的指针,所以第n个指针一定是current.next

  1. 遍历去找第n个节点

    举一个极端的例子进行测试

  2. 删除的操纵

class ListNode{
    int val;
    ListNode next;
    public ListNode(){}
    public ListNode(int val){
        this.val=val;
    }
    public ListNode(int val,ListNode next){
        this.val=val;
        this.next=next;
    }
}
​
class MyLinkedList {
​
    ListNode dummyHead;//注意这里是易错点,定义的是虚拟头节点
    int size;
​
    public MyLinkedList() {
        size=0;
        dummyHead=new ListNode(0);
    }
    
    public int get(int index) {
        if(index<0||index>=size) return -1;
        ListNode cur=dummyHead;
        for(int i=0;i<=index;i++){
            cur=cur.next;
        }
        //cur到index了
       return cur.val;
    }
    
    public void addAtHead(int val) {
        addAtIndex(0,val);
    }
    
    public void addAtTail(int val) {
        addAtIndex(size,val);
    }
    
    public void addAtIndex(int index, int val) {
        if(index<0||index>size) return;
        size++;
        ListNode cur=dummyHead.next;
        ListNode pre=dummyHead;
        ListNode newNode=new ListNode(val);
        //要找到index-1的节点
        for(int i=0;i<index;i++){
            cur=cur.next;
            pre=pre.next;
        }
        //cur到index了
        //pre到index-1了
        pre.next=newNode;
        newNode.next=cur;
    }
    
    public void deleteAtIndex(int index) {
        if(index<0||index>=size) return;
        size--;
        //要找到被删除节点的前一个节点
        ListNode pre=dummyHead;
        for(int i=0;i<index;i++){
            pre=pre.next;
        }
        //pre到了第index-1个
        pre.next=pre.next.next;
        
​
    }
}
​

易错点:

  1. 链表类中的节点属性是虚拟头节点

  2. 要利用好size这个信息

  3. 添加节点要size++

  4. 删除节点要size--

  5. 添加节点的操作需要有pre和cur两个节点分别存储index-1和index这两个节点(因为如果只用一个,.next.next不能通过,无法保证.next是非null的,Java会报错)

2.4 206.反转链表

1. 双指针写法
  1. 首先要有一个指针指向头节点cur

  2. 还需要一个指针pre,就是定义在cur的前一个(因为翻转,我们要改变的是cur节点的指向),好方便让cur把它的指针改向,指成它的前一位

但是在改方向的过程中,cur原来的指针会断掉,所以需要建立一个临时变量趁没赋值之前提前保存;

赋完值,就要让pre和cur整体向后移一位,pre好移动pre=cur,cur就是直接等于temp;而且是一定要先移动pre,后移动cur(注意,不能是pre=pre.next,因为pre初始值是null

最后要返回新链表的头节点,就是pre

class Solution {
    public ListNode reverseList(ListNode head) {
        ListNode dummyHead=new ListNode(-1,head);
        ListNode pre=null;
        ListNode cur=head;
        ListNode temp=null;
        while(cur!=null){
            temp=cur.next;
            cur.next=pre;
            pre=cur;
            cur=temp;
        }
        return pre;
    }
}

大致步骤:先存再改最后移

易错点:

  • 注意最后的移动也有先后,pre先移动

2. 递归写法

递归写法的逻辑和双指针写法是一样的,但是递归的写法更简短,更晦涩难懂

递归写法和双指针写法的代码都是一一对应的

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值