剑指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个结点
反转链表
合并两个排序的链表
复杂链表的复制
两个链表的第一个公共结点
链表中环的入口结点
删除链表中重复的结点

全部评论

相关推荐

过往烟沉:我说什么来着,java就业面就是广!
点赞 评论 收藏
分享
不愿透露姓名的神秘牛友
11-24 20:55
阿里国际 Java工程师 2.7k*16.0
程序员猪皮:没有超过3k的,不太好选。春招再看看
点赞 评论 收藏
分享
1 收藏 评论
分享
牛客网
牛客企业服务