2 链表
2.1 链表的基础理论
什么是链表:链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。
链表的入口节点称为链表的头结点也就是head
链表的类型:
-
单链表
-
双链表:
-
每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点
-
既可以向前查询也可以向后查询
-
-
循环链表:
-
链表首尾相连
-
可以用来解决约瑟夫环问题
-
链表的存储方式:
-
数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。
-
链表是通过指针域的指针链接在内存中各个节点,所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理
链表节点的定义
链表的定义
链表的操作:
-
删除节点(在C++里最好是再手动释放这个D节点,释放这块内存。其他语言例如Java、Python,就有自己的内存回收机制,就不用自己手动释放了。)O(1)(但是要注意,要是删除第五个节点,需要从头节点查找到第四个节点通过next指针进行删除操作,查找的时间复杂度是O(n))
-
添加节点:O(1)
-
查找:O(n)
链表 VS 数组
分析:链表是一个一个的结点 ,每个结点指向下一个结点这一长串的链我们怎样把它拎起来,肯定是拎着个头
所以有两种实现办法:
-
带有头指针的链表
-
带有头结点的链表:最前面的结点中不放数据,数据域是不用的,不存数据,这个结点的全部功能和作用就是来指向下一个结点,这种就叫带有头结点的链表(这种方法一般更好用,因为这样可以不用根据是否是头指针来分别处理操作,可以把是头节点和不是头节点的这两种情况统一起来;如果题目让返回操作后链表的头指针,返回这个构造节点的next就行)
这俩的区别:
-
头结点的链表始终比带有头指针的链表要多耗费一点空间
-
头指针链表:假设要在头部结点插入一个新结点,这时头指针的值变化了,所以头指针链表插入或者删除操作可能会修改头指针;而带有头结点的链表的插入和删除都不会修改链表的指针,而只会修改链表指针指向的第一个头结点的next指针域
-
主要区别就是在我们写程序时,如果采用带有头指针的链表,要注意一些操作要修改头指针;如果采用头结点的链表,那么各类操作都不可能改变指针,只改变头结点的值 选哪种都行,根据需求进行,演示带有头指针的,因为复杂一些
链表的基本实现
如果不定义构造函数使用默认构造函数的话,在初始化的时候就不能直接给变量赋值!
1. C语言
定义结构体:
-
每一个结点(要包含数据域+指针域)
struct Node{ int data; // int * point;指向的是下一个结点,所以是错的 struct Node* next; };
-
整个链表
操作:
-
初始化
//因为要修改head,所以把head的指针传进去;初始化肯定会成功所以是void //结构体指针的指针,因为head是指针,我们传的是head的地址,所以就要拿指针的指针来承接 void init(struct Node** phead){ *phead=NULL; }
意思就是头指针的变量类型本来就是结点指针类型的,要修改它的话传给函数的值应该是头指针的指针(因为头指针指向的是下一个结点的地址,而不是它自己)
-
求表长
参数传什么呢:获取表长的函数是不会改变链表的,是只读的,所以只需要传head是不需要传它的指针的,因为head就是一个简单的指针,不是一个对象,所以传递head不是很麻烦,之前说过,当需要传递一个比较大的对象时,应该传指针
int getLength(struct Node* head){ int len=0; while(head){ len++; //不是把链表的头指针改了,因为C语言这个地方是值传递,我们/是把头指针的值传给了函数里面的局部变量,所以在while循环中改变的是局部变量,并没有改真正head的值 head=head->next; } return len; }
-
遍历
传什么参数:注意顺序表这里传了一个指针进去,因为顺序表的结构体很大,我们需要传结构体的指针,而在这里链表,往里传的是很小的头指针 ,所以不大,在这里也不需要改变,所以依然传的是一个值
void printList(struct Node* head){ while(head){ printf("%d\n",head->data); head=head->next; //同理,在这里并没有改变头指针,改变的只是一个局部变量 } }
-
构造一个结点的函数
返回值是指向这个结构体的指针
struct Node* createNode(int x){ struct Node* t;//临时变量 t=(struct Node*)malloc(sizeof(struct Node)); //创建结点可能会失败,当内存空间都被用光的时候,malloc会失败,这里没有涉及 //一个好的习惯是所有的指针域都不能保持为未赋值的状态,所以初始化一下 t->next=NULL; t->data=x; return t; }
-
查找
查找第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; }
-
插入
要指定在哪个表里面插入,所以要先有个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; } }
-
删除链表中的结点
传什么参数:删除链表中的头结点时,会改变头指针,所以要传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. 设计链表
题意理解:让我们实现五个函数:
-
获取第n个节点的值
-
头部插入节点
-
尾部插入节点
-
第n个节点前插入节点
-
删第n个节点
需要注意的是,这个题目里的n是从0开始的,也就是获取第0个节点也就是链表的头节点
这里统一使用了虚拟头节点的方式,这样方便对链表进行增删改的操作
获取第n个节点的值:(注意:第0个节点就是头节点)
-
首先要对n进行合法判断
-
定义一个临时指针cur(专门用来遍历的),因为操作的是要获取当前这个节点的值,所以直接cur=dummy_head.next(为什么要纠结如何赋值?因为这里如何赋值和下面的while进行循环遍历是“一脉相承”的)
在对cur进行赋值的时候,以及while里面究竟怎么操作的时候想清楚极端的情况(头节点、尾节点)就可以了
头部插入节点:
-
首先要定义一个新的链表节点
-
为什么这里赋值的顺序这么重要,其实这个地方还好,因为是在head前插入的,head还是有记录的,但是如果是在第n个节点前插入新节点,head就没有办法保存
尾部插入节点:
-
先定义一个新的节点
-
找尾部的位置
当前节点一定要指向尾部节点,这样才能插入新节点,那如何让当前节点cur停在尾部,就是让cur.next=null,这就明确了终止条件
第n个节点前插入节点:
-
构造新结点
-
找到n前一个的位置
需要注意的是,要在第n个节点前插入一个新节点的话,那么就要知道n前一个节点,所以cur最开始要指向dummy_head
对于这么写能不能找到第n个节点(前一个结点),对于这种边界上的考虑,举一个极端的例子就可以了
注意,必须保证cur.next是第n个节点
-
进行插入
删除第n个节点:
要删除第n个节点,必须要知道它前一个节点的指针,所以第n个指针一定是current.next
-
遍历去找第n个节点
举一个极端的例子进行测试
-
删除的操纵
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; } }
易错点:
-
链表类中的节点属性是虚拟头节点
-
要利用好size这个信息
-
添加节点要size++
-
删除节点要size--
-
添加节点的操作需要有pre和cur两个节点分别存储index-1和index这两个节点(因为如果只用一个,.next.next不能通过,无法保证.next是非null的,Java会报错)
2.4 206.反转链表
1. 双指针写法
-
首先要有一个指针指向头节点cur
-
还需要一个指针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. 递归写法
递归写法的逻辑和双指针写法是一样的,但是递归的写法更简短,更晦涩难懂
递归写法和双指针写法的代码都是一一对应的