前言
这篇文章,我们继续来看几道题,今天是与链表相关的面试题。
每一道题,我们都要争取找出最优的算法来实现。
题目1:移除链表元素
题目链接先给大家:
链接: link
🆗,接下来我们一起来看一下:
题目分析
大家如果看过我上一篇文章(链接: link )的话,会发现这道题跟上一篇文章中的第一道题 移除元素 是很像的。
只不过那道题是是在数组里,而今天这道题是移除链表中的某个元素。
题目给我们一个链表的头节点 head 和一个整数 val ,让我们删除链表中所有满足 Node.val == val 的节点,并返回 新的头节点。
那这道题要怎么解决呢,下面我们一起来分析一下:
思路1:暴力求解
首先第一个思路,我们最容易想到的,还是这个比较暴力的方法:
就是遍历链表,找到值等于val 的结点,然后一一删除。
在链表中删除元素的好处在于,我们不需要像数组那样挪动数据。
但是呢?
我们看到题目中给的是单链表:
不过单链表的删除我们在之前单链表的文章里也讲了:
那它的缺点在于:
首先如果单链表不带头(哨兵位),且要删除的元素是首元素,即头删,我们是需要单独处理的,当然你可以选择带头的链表。
其次,我们删除链表中的元素还需要找到他的前一个,所以每删除一个元素,我们都需要遍历找它的前一个。
所以说呢?
这种方法虽然可行,但是实现起来其实是会比较麻烦的,我们就不实现这种方法了,大家有兴趣的话也可以试一试。
思路2:取非val值尾插至新链表
好,那么我们接下来提供另外一种思路:
那这种思路是什么呢?
其实跟我们上一篇文章移除元素那道题当时提供的第二种思路比较类似。
思路讲解
怎么做呢?
我们从头去遍历链表,如果链表结点的值等于val,我们就把当前结点删除并释放掉,如果结点的值不等于val,我就把它尾插到一个新的空链表上。
老规矩,接下来我们画图来带大家再梳理一遍思路:
大家要注意,数据结构的学习,画图是很重要的,图画清楚了,看着图去写代码就很简单了。
我们就来看题目给的示例:
开始了:
初始状态:
开始遍历,不等于就尾插,等于就删除:
相信图大家都很容易理解。
但是要注意⚠:
新链表尾插结束一定要将尾结点的指针域置空,因为新链表的尾结点不一定是原链表的尾结点,如果不是的话,它作为新的尾,但是还指向原来在它后面的那个结点,而它后面的那个结点等于val的话,会被我们删除释放掉,这时就存在野指针问题了。
思考
刚才的思路我们创建了一个新链表,将需要保留的结点尾插到了新链表里。
那大家想一下,这是不是和上一篇文章那道题的第2种思路开辟一个新数组一样,是一种空间换时间的做法呢?
显然不是的!
为什么?
因为我们只是把原链表的结点拿了下来进行尾插,并没有创建新的结点。
另外,还有一个问题:
我们选择把要保留的结点尾插到一个新的链表,那这个新的链表,通过前面的学习,我们知道,有两种结构可以选择:
带哨兵位的和不带哨兵位的。
其实刚才我们的图里画的是不带哨兵位的。
那有什么区别呢?
其实这道题你选择这两种结构哪一个都可以,带哨兵位的好处就是插入第一个元素,即头插的时候,可以方便一点,如果不带哨兵,头插需要单独处理。
经过前面的学习,相信大家都明白这两者的区别,就不具体解释了。
代码实现
至于代码的实现,这里两种结构,我都给大家写了:
不带哨兵位
struct ListNode* removeElements(struct ListNode* head, int val){ //不带哨兵位 if(head==NULL) return NULL; struct ListNode* cur=head; struct ListNode* newhead=NULL; struct ListNode* tail=NULL; while(cur) { if(cur->val!=val) { if(newhead==NULL) { newhead=tail=cur; } else { tail->next=cur; tail=cur; } cur=cur->next; } else { struct ListNode* tmpnext=cur->next; free(cur); cur=tmpnext; } } if(tail) tail->next=NULL; return newhead; }
不带哨兵位的实现,有一些地方需要单独判断,大家要注意:
比如:
其它的大家仔细看看应该都能明白。
带哨兵位
struct ListNode* removeElements(struct ListNode* head, int val){ //带哨兵位 struct ListNode* headnode=(struct ListNode*)malloc(sizeof(struct ListNode)); headnode->next=NULL; struct ListNode* cur=head; struct ListNode* tail=headnode; while(cur) { if(cur->val!=val) { tail->next=cur; tail=cur; cur=cur->next; } else { struct ListNode* tmpnext=cur->next; free(cur); cur=tmpnext; } } tail->next=NULL; struct ListNode* newhead=headnode->next; free(headnode); return newhead; }
带哨兵位的实现就不需要那么多单独判断的情况了,但是要注意,哨兵位的头结点是我们自己申请出来的,最后最好把头结点释放掉。
题目2:合并两个有序链表
题目链接: link
我们一起看一下题:
我们一起来分析一下这道题:
题目分析
这个题是不是我们上一篇文章也做过类似的,那个是合并有序数组,而这道题是合并有序链表。
那既然是类似的题目,我们就可以用类似的方法来解决。
思路讲解
怎么搞呢?
还是利用双指针,分别从两个链表的第一个元素开始,两两比较大小,取小的那一个尾插到新链表(相等取任何一个都可以),因为最终要返回的还是升序链表。
这其实跟第一题的操作也有点像,第一题我们是去不等于val的结点尾插,这个题是取两个链表中较小的那个尾插。
我们来画一下图:
那大家再来思考一下,这些操作我们肯定要放在循环中进行,那循环结束的条件应该是什么?
🆗,是不是只要有其中一个链表遍历结束,整个循环就应该结束了。
循环结束就完了吗?
并不是,因为还有另一个链表没有处理完,循环结束之后我们只需要把另一个链表的剩余元素链接到尾插的新链表后面就行了。
另外,还需要⚠注意什么呢?
这道题给出的测试用例有这样的情况,就是给的两个链表中可能会有空链表
有空链表怎么处理?
是不是好办啊?
直接返回另一个链表就行了。
当然,如果我们选择的是带哨兵位的链表,这一步其实就不需要了。
好的,这就是整体的一个思路和一些需要注意的地方。
代码实现
那具体实现呢,就还是两种方式:
即尾插的链表我们可以选择带头或是不带头(哨兵位)。
不带哨兵位
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2){ if(list1==NULL) return list2; if(list2==NULL) return list1; struct ListNode* newhead=NULL; struct ListNode* tail=NULL; while(list1&&list2) { if(list1->val<list2->val) { if(newhead==NULL) newhead=tail=list1; else { tail->next=list1; tail=list1; } list1=list1->next; } else { if(newhead==NULL) newhead=tail=list2; else { tail->next=list2; tail=list2; } list2=list2->next; } } if(list1) tail->next=list1; if(list2) tail->next=list2; return newhead; }
带哨兵位
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2){ struct ListNode* guardnode=(struct ListNode* )malloc(sizeof(struct ListNode)); guardnode->next=NULL; struct ListNode* tail=guardnode; while(list1&&list2) { if(list1->val<list2->val) { tail->next=list1; tail=list1; list1=list1->next; } else { tail->next=list2; tail=list2; list2=list2->next; } } if(list1) tail->next=list1; if(list2) tail->next=list2; struct ListNode* newhead=guardnode->next; free(guardnode); return newhead; }
采用带哨兵位的结构,就省去了很多的判断,但要注意最后要释放一下哨兵位的头结点。
题目3:反转链表
题目链接: link
这道题呢也是一道非常经典的题目,一起来看一下:
题目分析
🆗,题目的意思呢是让我们去反转链表,题意很好理解,比如一个链表原来的结点是1,2,3,4,5 ;那翻转之后就应该是5,4,3,2,1。
那这道题呢,我们提供两种比较好的思路,都会给大家一一实现。
思路1:取结点头插
思路1是取原链表的结点进行头插,什么意思呢?
还是搞一个新链表,但是这个新链表的结点不是要我们自己再去创建,而是,我们去遍历原链表,然后,依次把每个结点取下来头插到新链表中。
好的,光说大家可能不太好理解,还是老规矩哈,我把图给大家好好画一下:
就用题目给的例子:
我们现在要完成这样一个翻转。
怎么做呢?大家来看图:
代码实现
思路理清了,写代码其实就很easy了。
struct ListNode* reverseList(struct ListNode* head){ struct ListNode* newhead=NULL; struct ListNode* cur=head; struct ListNode* tmp=NULL; while(cur) { //保存下一个的地址 tmp=cur->next; //头插 cur->next=newhead; newhead=cur; cur=tmp; } return newhead; }
对于代码就不过多解释了,相信大家都能看懂。
那接下来我们看另一种思路:
思路2:改变指针指向
那解这道题呢?还有另外一种想法,就是什么呢:
我们去改变每个结点指针的指向,原来它是指向后一个的,我们现在让它指向前一个,这样是不是也能完成链表的反转。
思考
那想要这样做,就会有一些问题值得我们思考一下:
首先第一个问题,我们说要改变指针的指向,那处在后面的结点,我们都让它指向前面的结点就行了。
那第一个结点呢?它前面啥也没有,我们应该让它指向哪里呢?
🆗,是不是应该让它指向空(NULL)啊,因为反转之后,第一个结点就变成尾结点了,那尾结点的指针域理所当然要指向空了。
我们继续思考一下:
我们要改变指针指向,那这个指针肯定是联系着两个结点的。
那我们在操作时是不是也得需要两个指针,来保存相邻两个结点的地址啊。
两个指针够不够?我们来尝试画一下图:
看这样一个例子:
如果两个指针的话:
从图上看感觉好像可以啊,两个指针向后迭代,好像可以完成。
但是我们仔细分析一下,其实是有的问题的。
大家对比着图看一下,这是单链表,n2结点的指向一旦改变,还能找到下一个结点吗?
就不能了!!!
那就没法继续向后走了呀,所以呢?
我们还要增加一个指针,来保存n2的下一个。
画图分析
那我们来重新画一下图:
那最好反转之后的链表的头就是n1了,所以我们最好返回n1就行了。
代码实现
struct ListNode* reverseList(struct ListNode* head){ if(head==NULL) return NULL; struct ListNode* n1=NULL; struct ListNode* n2=head; struct ListNode* n3=n2->next; while(n2) { n2->next=n1; n1=n2; n2=n3; if(n3) n3=n3->next; } return n1; }
代码呢也好写,就是写代码过程中会出现一些空指针的问题,需要我们特别处理。
🆗,那以上就是这篇文章给大家介绍的几个链表相关的题目题目,希望能帮助到大家,同时也欢迎大家指正!!!