剑指offer之链表
一、杂话:数据结构的储存方法无非两种,一种用数组,顺序存储,一种用链表,称为链式存储。数组和链表都属于线性结构,是其余数据结构的基础。因此,在讲完数组之后,自然就是链表了。
二、链表的相关知识点
①我所认识的链表:我的理解中,链表就是用一条绳子将一个个数据串在一起,但是只能顺着串,不能反过来。
②注意细节:
1.在链表中要区分链表指针和链表结点,链表指针是指向链表的地址,而结点具有数据和链表指针。
创建头结点时,用的new其实是分配了一个链表的大小,可以认为这个空间其实就是头结点,但是我们用的是这个指向头结点的头指针。形如:ListNode<int>* p=new ListNode<int>();实际上是创建了一个指向头结点空间的头指针p。
2.另外,在c中承认NULL等效于nullptr,但是在c++中有所区分,所以用到指针的话,得用nullptr。
3.创建链表时,注意必要的判空语句,不要对空指针应用。
如:if(tmp!=nullptr&&tmp->next!=nullptr) ListNode<int> *p=tmp->next;
4.结构体内可以有指向该结构体类型的指针,但不可以含该类型的成员。
5.xx类型的指针就可以使用xx类的成员。如:int *p=new int[10],则p[0]是允许的。
又如:ListNode<int> *p=new ListNode<int>(),则p->next也是允许的。所以就可以理解为啥head->next了
③链表的结构定义:</int></int></int></int></int>
template <typename T> struct ListNode{//也可用class定义 T val;//刷题时不难发现多数T=int struct ListNode *next;//struct写不写都可 ListNode():val(0),next(nullptr){}; ListNode(T n):val(n),next(nuulptr){}; };
创建链表:
ListNode<int>* head = new ListNode<int>(); //由向量建立一个链表 vector<int>v{ 1,2,3,4,5,6,7 }; ListNode<int>* tmp = head;//对临时链表操作,注意不能删除,否则会删除相应的空间,导致掉链 for (int i = 0; i < v.size(); ++i) { tmp->next = new ListNode<int>(v[i]); tmp=tmp->next; } //打印链表 tmp = head->next; while (tmp != nullptr) { cout << tmp->val << "->"; tmp = tmp->next; } cout << endl;
④链表的常见操作
因为来链表分为单向链表,静态链表,循环链表和双向链表,静态链表使用二维数组来实现储存的,过程复杂而不常用,不做讨论。仅讨论其余三种链表的常见操作。
1.单向链表的常见操作:
为了让链表的插入和删除操作都一致,会在单项链表中引入一个头结点head。让其指向存有数据的第一个结点。
(1)插入操作
//插入操作 //比如插入num=8到位置pos=3 int num = 8; int pos = 3; tmp = head;//对临时链表操作 while ((--pos)&&tmp!=nullptr){ tmp = tmp->next; } if (tmp != nullptr) { ListNode<int>* tmplist = tmp->next;//使用临时链表保持信息,注意不能删除,否则会删除相应的空间,导致掉链 tmp->next = new ListNode<int>(num); tmp->next->next = tmplist; } //打印链表 tmp = head->next; while (tmp != nullptr) { cout << tmp->val << "->"; tmp = tmp->next; } cout << endl;
注意一点就是:中间新建的临时指针,不要删除掉,否则对应的链可能会因此断掉,这个删除结点时是不同。
(2)删除操作
//比如删除pos=2的元素 int pos = 2; tmp = head; while ((--pos) && tmp !=nullptr) { tmp = tmp->next; } if (tmp!=NULL&&tmp->next != nullptr) { ListNode<int>* p = tmp->next; tmp->next = p->next; free(p);//删除不要的结点,释放空间 } //打印链表 tmp = head->next; while (tmp != nullptr) { cout << tmp->val << "->"; tmp = tmp->next; } cout << endl;
2.循环链表
循环链表需要让最后结点指向头结点,并创建一个尾指针,指向最后结点,而头结点原先是由头指针指向的,因此只需把头指针赋值给尾指针即可。
(1)插入操作:
//循环链表的额外动作 tmp->next = head;//最后结点指向头结点 //制造尾指针 ListNode<int>* rear = tmp; //打印链表 tmp = head; int recycle = 16;//控制打印次数 while ((recycle--)&&tmp != NULL) { cout << tmp->val << "->"; tmp = tmp->next; } cout << endl; //插入操作 //比如在位置pos=1处插入num=99; //循环链表一般情况只给了尾指针,因此不知道头结点在哪,所以只能从尾指针查找 int pos = 1; int num = 99; while ((pos--) && rear != nullptr) { rear = rear->next; } if (rear != nullptr&&rear->next!=nullptr) { ListNode<int>* p = rear->next; rear->next = new ListNode<int>(num); rear->next->next = p; } //再次打印 tmp = head; recycle = 16;//控制打印次数 while ((recycle--) && tmp != nullptr) { cout << tmp->val << "->"; tmp = tmp->next; } cout << endl;
(2)删除操作
//删除操作 //比如删除在位置pos=3的数 int pos = 3; while ((pos--) && rear != nullptr) { rear = rear->next; } if (rear != nullptr && rear->next != nullptr) { ListNode<int> *tmplist = rear->next; rear->next = tmplist->next; free(tmplist); }
3.双向链表
由于双向链表比单链表多了一个指向前一个链表的指针,所以得重新定义它的结构:
template <typename T> struct DListNode { T val; struct DListNode* front; struct DListNode* next; DListNode() :val(0),front(nullptr), next(nullptr) {}; DListNode(T n) :val(n), front(nullptr), next(nullptr) {}; };
//由向量建立链表 DListNode<int>* dhead = new DListNode<int>();//头结点头指针 DListNode<int>* dtmp = dhead;//临时操作指针 vector<int>v{ 1,2,3,4,5,6,7 }; for (int i = 0; i < v.size(); ++i) { DListNode<int> * newlist= new DListNode<int>(v[i]); dtmp->next = newlist; newlist->front = dtmp; dtmp = dtmp->next; } DListNode<int>* dtail = new DListNode<int>();//尾结点尾指针 dtmp->next = dtail;//最后结点指向尾结点 dtail->front = dtmp;//注意尾结点的前指针要指向最后一个结点 //顺着打印 cout << "顺着打印:" << endl; dtmp = dhead; while (dtmp != nullptr) { cout << dtmp->val << "->"; dtmp = dtmp->next; } cout << endl; //逆着打印 cout << "逆着打印\n"; dtmp = dtail; while (dtmp != nullptr) { cout << dtmp->val << "->"; dtmp = dtmp->front; }
(1)插入操作
由于双向链表多了一个指向前面的指针,因此插入和删除时会与单链表有点不同。
//插入结点 //比如在位置pos=4,插入num=100 dtmp = dhead; int pos = 4; int num = 100; while ((--pos) && dtmp != nullptr) { dtmp = dtmp->next; } if (dtmp != nullptr && dtmp->next != nullptr) { DListNode<int>* newdlist = new DListNode<int>(num); DListNode<int>* p = dtmp->next; newdlist->next = p; p->front = newdlist; dtmp->next = newdlist; newdlist->front = dtmp;//需要四步建立四个指针 }
(2)删除操作
//删除结点 //比如删除位置pos=5的结点 dtmp = dhead; int pos = 5; while ((--pos)&&dtmp!=nullptr) { dtmp = dtmp->next; } if (dtmp != nullptr && dtmp->next != nullptr) { DListNode<int>* q = dtmp->next; dtmp->next = q->next; q->next->front = dtmp; free(q); }
⑤链表的用途:
链表的大小是可变的,不需要像数组一样提前给定长度,而且插入和删除操作比数组简单,所以具有一定的灵活性,但是寻找对应下标时,就没数组这么方便了。所以如果只需要存储,不太需要查找的话,用链表时比较合适的。
三、刷题总结
①JZ3 从尾到头打印链表---较难
思路:直接看代码吧。也就三种。方式一和方式二其实是一致的。方式三涉及到链表反转,之后的题会用到。
vector<int> printListFromTailToHead(ListNode* head) { /*法一:用reverse*/ /*vector<int>ArrayList; while(head!=nullptr){ ArrayList.push_back(head->val); head=head->next; } reverse(ArrayList.begin(), ArrayList.end()); return ArrayList;*/ /*法二:用栈*/ /*stack<ListNode*>s;vector<int>ArrayList; while(head!=nullptr){ s.push(head); head=head->next; } while(!s.empty()){ ArrayList.push_back(s.top()->val); s.pop(); } return ArrayList;*/ /*方法三:反转链表*/ vector<int>res; ListNode*pre=nullptr; ListNode* cur=head; while(cur!=nullptr){ ListNode *tmp=cur->next; cur->next=pre; pre=cur; cur=tmp; } while(pre!=nullptr){ res.push_back(pre->val); pre=pre->next; } return res; }
②JZ 链表中倒数第k个结点---中等
思路:这个题其实很简单,只要统计出结点的个数n,然后倒数第k个实际就是正数n-k个。
ListNode* FindKthToTail(ListNode* pHead, int k) { // write code here //简单解法:既然要求倒数第k个结点,问题等效于求第n-k+1个结点 int sum=0;ListNode *p=pHead; //统计结点总数 while(p!=nullptr){ sum++; p=p->next; } if(k>sum) return nullptr; for(int i=0;i<sum-k;i++) pHead=pHead->next; return pHead; }
分析下下:当然这样的时间复杂度是O(n),空间复杂度是O(1)。当然也可以把链表元素放到数组中,这样其实也一样,不过对于取出多个元素时,这种方法就会方便很多。
③JZ15 反转链表---中等
思路:
方法一:用栈 ,但是要注意就是必须重新创造新的结点,然后把原结点的值赋给新的结点,不断创造新结点,连接结点,同时还要删除旧结点。显然空间复杂度和时间复杂度都为O(n)。
代码如下:
ListNode* ReverseList(ListNode* pHead) { //利用栈来实现反转 if(pHead==NULL||pHead->next==NULL) return pHead; stack<ListNode *> s;ListNode *reverseList; while(pHead!=NULL){ s.push(pHead); pHead=pHead->next; } reverseList = new ListNode(s.top()->val); free(s.top()); s.pop(); pHead = reverseList; while (!s.empty()) { pHead->next = new ListNode(s.top()->val); free(s.top()); s.pop(); pHead = pHead->next; } return reverseList; }
方法二:前面的题目已经有涉及到了。其实就是交换指针就行了。
具体方法见图:
初始化pre=nullptr,cur=head,然后,每次先存下tmp=cur->next,修改cur->next=pre,再移动pre=cur,cur=tmp。重复上述动作,直至当前的cur==nullptr,此时的pre就是新的链表的第一个结点。
代码如下:
ListNode* ReverseList(ListNode* pHead) { if(pHead==NULL||pHead->next==NULL) return pHead; ListNode *pre=NULL,* cur=pHead; while(cur!=NULL){ ListNode *temp=cur->next; cur->next=pre;//反指向 pre=cur; cur=temp; } return pre; }
显然,指针改变指针,时间复杂度为O(n),但空间复杂度为O(1)。比方法一要好。
//////////////////////////////////////////////////////////////////////////////////
特别的,k个一组反转链表
/////////////////////////////////////////////////////////////////////////////////
//1.反转链表中的一段,通用的函数 //返回新首节点 ListNode * reverse(ListNode *head,ListNode *tail){ ListNode *pre=nullptr,* next=nullptr; //为了维护原首节点,即现段末节点,用tmp做临时操作 ListNode * tmp=head; while(head!=tail){ next=tmp->next; tmp->next=pre; pre=tmp;tmp=next; } return pre; } //2. 实现k个一组递归反转 ListNode *reverseKGroup(ListNode *head,int k){ //recursion //base case if(!head||!head->next) return head; //operations //先找到tail,tail是下一段反转的起始节点 ListNode *tail=head; for(int i=0;i<k;++i){ if(!tail) return head;//不够k个,无需反转 tail=tail->next; } //获取新链表的节点 ListNode *newhead=reverse(head,tail,k); //原首节点是现在反转段的末节点,往下连接tail开始新的反转段 head->next=reverseKGroup(tail,k); return newhead; }
④JZ16 合并两个排序的链表---简单
思路:大致归为两种解法,一种是创造新的链表,比较两链表的结点,然后往新链表中按顺序放入结点。另一种是直接递归,可以理解为,要么下一个结点是在链1,要么在链2,不断递推下去,最终会有一个链空了,这时就是回归的时候。
//创造新的链表 ListNode* Merge(ListNode* pHead1, ListNode* pHead2) { ListNode *res=new ListNode(-1);//头结点头指针 while(pHead1&&pHead2){ if(pHead1->val>pHead2->val){ res->next=pHead1; pHead1=pHead1->next; } else{ res->next=pHead2; pHead2=pHead2->next; } res=res->next; } res->next= pHead1? pHead1:pHead2; return res->next; }
//递归解法 ListNode* Merge(ListNode* pHead1, ListNode* pHead2) { if(pHead1==nullptr) return pHead2; else if(pHead2==nullptr) return pHead1; else if(pHead1->val<pHead2->val){ pHead1->next=Merge(pHead1->next,pHead2); return pHead1; } else{ pHead2->next=Merge(pHead1,pHead2->next); return pHead2; } }
思考:递归看着好像很玄学,原因在于并不清楚,怎么递推,怎么回归。是否可以理解为,问题由很多子问题组成,而子问题与问题一致,所以有点像俄罗斯套娃,因此递归?以后明白了,再回来解答这个困惑吧。
⑤JZ25 复杂链表的复制---较难
1.解题前,先理解下何为浅拷贝,何为深拷贝:
所谓浅拷贝,其实就是拷贝的时候,指向的内存是同一个地方,改变这个新的对象,也会改变原对象的内容,可以理解为就是拷了一个指针,指向了原对象的地址。浅拷贝主要出现在类里面,当定义了一个对象之后,再定义一个新对象,并直接将第一个对象赋值给新对象时,如果没有自定义拷贝函数,就会调用默认的拷贝函数,这时,新对象的内容,即成员其实跟原对象的成员是同一块内存。而深拷贝,其实就是分配一个新的空间,然后复制原对象的内容,放到里面,改变新的对象,并不会影响原对象。像在类里面就得自己定义一个拷贝函数,然后将新对象的成员用原对象一一赋值,这样就相当于复制了一份,而不只是指向了同一块内存。
2.除此之外,还得理解下题目最后一句话的意思:
题目的意思其实就是说,不要返回形参中的结点。后面引用两个字,其实就是说我们创造一个新变量时,用已有变量去赋值,其实就是使用的这个已有变量的引用,也叫这个新变量为已有变量的引用。“赋值可以理解为引用”。下面是与函数形参相关的额外知识点:
函数形参有三种传递方式,按值传递,按指针传递,按引用传递。形参相当于局部变量,会放到栈里面,函数执行完就会被销毁。
(1)按值传递好理解,就是形参就相当于定义一个新变量,让它的值等于传入量,主要是针对基本数据类型。注意,数组不属于按值传递,数组传入会退化为指针,属于指针传递。
(2)按指针传递,其实就是将内存地址传入,可以通过指针来改变内存里的内容,进而改变变量。传入指针时,同样会copy一个新指针,执行完函数后,会被销毁,但是地址所存内容已经改变,因此可以起到改变变量的作用。这里要注意一个点,就是,形参是局部变量,但是如果说指针是形参的话,那么返回形参指针是可以的,因为销毁时只会销毁指针,而不会销毁指针指向的内存空间。而如果指针不是形参,而是在函数体内,不通过malloc/new分配,而自定义的指针,那么其会存在栈中,一旦函数体执行完,不仅会销毁指针,还会销毁指针所指的内存空间,这时再return 这个指针,也只是复制了地址,但是地址已经没有存东西了,所以return的会是不合法指针,这是不允许的。
(3)按引用传递,可以理解为直接对变量进行操作,就是用个别名而已。改变形参,就相当于改变实参。有一种特殊的引用就是const引用,目的在于,不拷贝变量,但是又不想改变变量,所以声明为const。
思路:这个题想了比较久,主要是发现拷贝的时候,总是会直接用到原链表的结点,可能是习惯了只改变指针了。random的指向,不可以用原链表的来赋值,所以需要自己连接。怎么自己连接呢?
第一步,采用的是用一个hashTable1记录原链表的结点的对应下标,这样才能在知道原结点的random指针时,找到新链表结点的random指向对应的下标。因此hashTable1就是key为结点指针,value为下标。可以理解为,对random指向的结点进行编码。
第二步,为了译码,获得新链表结点的random指向的结点,需要一个新的hashTable2来记录下标对应的新节点。因此hashTable2就是key为下标,value为对应结点指针。这样,hashTable2[
hashTable1[pHead->random]],就是新链表random指向的结点。当然,还有特殊情况,可能原链表某结点的random指向为空,那么这时,新链表对应的结点的random也应该指向为空,因此需要判断一下原链表该结点random是否指向空。
总之,就是hashTable1结点指针对应下标,hashTable2下标对应结点指针。一个编码,一个译码。
补充说明:做了leetcode上克隆图之后有了新的点。可以把编码和译码两个哈希表合为一个,直接建立旧新结点的映射。
代码如下:
RandomListNode* Clone(RandomListNode* pHead) { if(pHead==nullptr) return nullptr; //深拷贝 RandomListNode*tmp=pHead;//临时链表指针 RandomListNode* clone=new RandomListNode(-1);//头结点 RandomListNode* tclone=clone; //用哈希表记录编码和译码方式 //map<RandomListNode*,int>hashTable1; //map<int,RandomListNode*>hashTable2; //int flag=0; //改为用一个哈希表就行 map<RandomListNode* ,RandomListNode* >hashTable; while(tmp!=nullptr){//先建立顺序链表并创建编码和译码 tclone->next=new RandomListNode(tmp->label); tclone=tclone->next; hashTable[tmp]=tclone; //hashTable1[tmp]=flag;//结点指针对应下标 //hashTable2[flag++]=tclone;//下标对应结点指针 tmp=tmp->next; } //译码补充random tmp=pHead;tclone=clone->next; while(tclone!=nullptr){ if(tmp->random!=nullptr) tclone->random=hashTable[tmp->random]; //tclone->random=hashTable2[hashTable1[tmp->random]]; else tclone->random=nullptr; tmp=tmp->next; tclone=tclone->next; } return clone->next; }
⑥JZ36 两个链表的第一个公共结点---中等
思路:
1.方法一:用哈希表记录就可以了。
ListNode* FindFirstCommonNode( ListNode* pHead1, ListNode* pHead2) { if(pHead1==nullptr||pHead2==nullptr) return nullptr;//特例特判 unordered_set<ListNode*>hashTable;ListNode* tmp=pHead1; while(tmp){ hashTable.insert(tmp); tmp=tmp->next; } while(pHead2){ if(hashTable.count(pHead2)) return pHead2; pHead2=pHead2->next; } return nullptr; }
2.方法二:双指针的方法,通过变换指针的位置来使其相遇。有点像链表中找环那道题用的思想,都是利用的指针所走长度的和相等,从而相遇来找到重合的点。不过那道题用的快慢指针,这个题不需要。
本题的原理可以解释为,假设链表1在公共结点前一段长a,链表2在公共结点前一段长b,两链表公共长度为c,则指针走一遍链表1或链表2之后,再走另一链表,最终就会相遇。因为a+c+b=b+c+a。
图示如下:
本题在实现交换指针位置时要注意两个点:第一,要注意先判断指针能不能走,不能走了就换位置,并记录次数,能走就继续走。第二,需要格外加一个计数器,计算交换位置的次数,只能交换两次,如果两次还每遇到话,所以没有公共结点。
代码如下:
ListNode* FindFirstCommonNode( ListNode* pHead1, ListNode* pHead2) { if(pHead1==nullptr||pHead2==nullptr) return nullptr;//特例特判 ListNode* tmp1=pHead1;ListNode* tmp2=pHead2; int flag=0;//标记,记录交换指针次数,超过2次还没遇到证明没有公共结点 while(tmp1!=tmp2){ //先走再判断是否到头,然后换指针位置 if(tmp1==nullptr){ tmp1=pHead2;++flag; } else tmp1=tmp1->next; if(tmp2==nullptr){ tmp2=pHead1;++flag; } else tmp2=tmp2->next; if(flag>2) return nullptr; } return tmp1; }
⑦JZ55 链表中环的入口结点---中等
思路图解:
代码如下:
ListNode* EntryNodeOfLoop(ListNode* pHead) { //当然可以使用哈希表记录,这里就不再写了 //用快慢指针解决 ListNode *fast=pHead,* slow=pHead; do{ if(!fast||!fast->next) return nullptr; fast=fast->next->next; slow=slow->next; }while(fast!=slow); fast=pHead; while(fast!=slow){ fast=fast->next; slow=slow->next; } return fast; }
1.注意一点,就是在第一个while处,应该xian执行内部,再判断,因为一开始的fast和slow都在起点处,判断条件肯定不通过,会跳过这步循环。所以应该先让指针动起来,离开起点,再判断。这时用do...while比较合适。
2.总结下第六第七题:似乎跟公共结点或环点有关的问题都是让两指针通过换位置,让他们走过相等的路程,从而相遇,来找到公共结点的。
A.找环时用快慢指针,快指针一次走两步,慢指针一次走一步,数学等式是(n-1)(b+c)+c=a,其中b+c是环长度,a是入环前长度。假设两指针在b处相遇,那么只要让快指针回到起点,快指针速度变为和慢指针一致,那么最后两指针就会走到环点处。因此,找环只交换快指针的位置就行。
B.而找公共结点时,是相同速度的指针,需要分别交换两个指针的位置,数学等式为a+c+b=b+c+a。
⑧JZ56 删除链表中的重复结点---较难
思路:一开始没有完全理解题意,以为是删除重复的结点,但是要保留一个,没想到题目要求的是全部删掉。所以一开始用哈希表的做法并不能实现。后来用的双指针,用probe去试探,让它遍历重复的结点并删除掉,再将其与cur拼接即可。
图解如下:
代码如下:
ListNode* deleteDuplication(ListNode* pHead) { //双指针法 //需要一个头结点 ListNode *myhead=new ListNode(-1); myhead->next=pHead; ListNode *cur=myhead,* probe=pHead; while(probe){ if(probe->next&&probe->val==probe->next->val){ while(probe&&probe->next&&probe->val==probe->next->val){ ListNode *tmp=probe; probe=tmp->next; free(tmp); } ListNode *tmp=probe; probe=tmp->next; free(tmp); cur->next=probe; } else{ probe=probe->next; cur=cur->next; } } return myhead->next; }
试题链接:
从尾到头打印链表
链表中倒数第K个结点
反转链表
合并两个排序的链表
复杂链表的复制
两个链表的第一个公共结点
链表中环的入口结点
删除链表中重复的结点