【数据结构与算法】OJ题目详解(一)-单链表:从易到难的面试OJ题目

这里写目录标题

单链表基础结构示意图

单链表结构
头指针 Head
节点1

val: 1

next: →
节点2

val: 2

next: →
节点3

val: 3

next: NULL

示意图说明:

  1. 头指针 (Head):指向链表的第一个节点,是访问链表的入口
  2. 节点 (Node) :链表的基本单元,每个节点包含两部分:
    • 数据域 (val):存储节点的值(图中显示为 1、2、3)
    • 指针域 (next):存储下一个节点的地址(箭头表示指向关系)
  3. 尾节点 :最后一个节点的 next 指针为 NULL(空指针),表示链表结束

关键特性:

  • 链表通过指针连接各个节点,物理存储上不要求连续
  • 只能从头节点开始顺序访问(单向遍历)
  • 插入/删除节点时,只需修改相邻节点的指针,时间复杂度 O(1)(若已知位置)

理解了这一基础结构后,我们再来看具体的链表操作题目

一. 移除链表元素

【题目链接】

题目描述

给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回新的头节点


初始状态 初始状态 初始状态

解法一:双指针法

定义两个指针prev和pcur,初始时prev指向头节点,在遍历链表的过程中prev指向pcur的前驱节点,即 p c u r = p r e v − > n e x t pcur = prev->next pcur=prev−>next,最后还需要判断head的val是否等于val,如果等于则返回head的下一个节点

  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:这种解法思路简单,不需要其他的想法遍历一遍链表就可以得到答案,但是真正实现的时候会发现这种思路实现起来很繁琐,有很多条件判断,整体的代码很冗杂


解法二:哨兵位+双指针

初始时动态申请一个节点newhead指向头节点(head),定义prev指向newhead,定义pcur指向prev的下一个节点,使用双指针遍历链表,遇到值和val相同的就删掉,最后返回newhead的next就是答案

  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:新增加一个哨兵位节点可以省去很多的判断(例题头节点是否为空),使得代码更加简洁,且不需要专门看头节点的val是否等于val,因为它会在遍历的时候就被删掉

代码实现

小提示:动态申请的内存一定要自己手动释放掉,同时对应的指针也要及时改成空指针防止野指针的出现

二. 反转链表

【题目链接】

题目描述

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表


初始状态 初始状态 初始状态

解法一:哨兵位+头插法

初始时动态申请一个哨兵节点,定义一个pcur节点指向头节点(head),用pcur遍历原链表,每次将pcur遍历到的节点头插到新链表上,也就是哨兵位的后面

  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:头插法的代码不难,简洁的同时还用到了上一题的知识点,但是看起来不够高级

代码实现

解法二:三指针法

三指针法对于初学者来说会比较难想但是实现起来会很优雅 ,初始时定义三个指针 n 1 , n 2 , n 3 n_1,n_2,n_3 n1,n2,n3分别指向NULL,head, n 3 n_3 n3可以不给予初始化,因为 n 3 n_3 n3是用来保存 n 2 n_2 n2的下一个节点的,接下来进入循环


  1. 保存 n 2 n_2 n2的后继节点
  2. n 2 n_2 n2的 n e x t next next指向 n 1 n_1 n1
  3. 将 n 2 n_2 n2赋值给 n 1 n_1 n1
  4. 将 n 3 n_3 n3赋值给 n 2 n_2 n2

< 循环内部 > <循环内部> <循环内部>


  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:三指针法的代码相比较而言就更加简单了,但是初学者可能会有些不懂,这个时候在纸上去模拟一下这个过程就会恍然大悟了

代码实现

AC的如此简洁,如此优雅......

三. 合并两个有序链表

题目描述

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

【题目链接】

解法:哨兵位+双指针

先动态申请一个哨兵位当作新链表的头节点和尾节点,之后定义两个指针分别遍历两个链表,每次比较两个指针对应的val,谁的更小,就将哪个节点尾插到新链表的上

当循环结束之后,肯定还有一个链表的节点没有全部接上新链表,也就代表还有一个指针不是空的,把那个指针尾插到新链表即可

合并过程模拟

为了更直观地理解合并过程,我们用一个表格来模拟。假设有两个升序链表:

  • 链表1: 1 -> 3 -> 5
  • 链表2: 2 -> 4 -> 6

合并后的新链表初始只有一个哨兵位节点 phead,尾指针 ptail 也指向它。

步骤 l1 指向 l2 指向 比较结果 操作 (尾插) 新链表状态 (哨兵位后) ptail 移动后指向
初始 1 (链表1头) 2 (链表2头) --- --- (空) 哨兵位
1 1 2 l1->val (1) < l2->val (2) 将 l1 节点尾插 1 节点1
2 3 2 l2->val (2) < l1->val (3) 将 l2 节点尾插 1 -> 2 节点2
3 3 4 l1->val (3) < l2->val (4) 将 l1 节点尾插 1 -> 2 -> 3 节点3
4 5 4 l2->val (4) < l1->val (5) 将 l2 节点尾插 1 -> 2 -> 3 -> 4 节点4
5 5 6 l1->val (5) < l2->val (6) 将 l1 节点尾插 1 -> 2 -> 3 -> 4 -> 5 节点5
6 (NULL) 6 l1 已空,l2 非空 将剩余 l2 整体尾插 1 -> 2 -> 3 -> 4 -> 5 -> 6 节点6
结束 NULL NULL --- 释放哨兵位,返回 phead->next 1 -> 2 -> 3 -> 4 -> 5 -> 6 ---

说明

  1. 每次循环比较 l1->vall2->val,将值较小的节点尾插到新链表(ptail->next = 较小节点)。
  2. 尾插后,ptail 移动到新插入的节点,被插入的链表指针(l1l2)后移一位。
  3. 当某一链表遍历完(指针为 NULL),循环结束,将另一链表的剩余部分直接接在 ptail 之后。
  4. 最终返回哨兵位的下一个节点(phead->next)作为新链表的头。

通过表格可以清晰看到,每次操作都保证了新链表始终保持升序。

  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:这题也可以不使用哨兵位来解决。和上面的一样,如果使用哨兵位代码将会简洁很多,关于使用双指针合并升序链表的步骤主要还是因为题目给的链表本身是升序的

代码实现

cpp 复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */

typedef struct ListNode ListNode;

struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) {
    ListNode* l1 = list1, *l2 = list2;
    if(l1 == NULL)
    {
        return l2;
    }
    if(l2 == NULL)
    {
        return l1;
    }

    ListNode* phead, *ptail;
    phead = ptail = (ListNode*)malloc(sizeof (ListNode));
    while(l1 && l2)
    {
        if(l1->val < l2->val)
        {
            ptail->next = l1;
            ptail = ptail->next;
            l1 = l1->next;
        }else{
            ptail->next = l2;
            ptail = ptail->next;
            l2 = l2->next;
        }
    }

    if(l1)
    {
        ptail->next = l1;
    }
    if(l2)
    {
        ptail->next = l2;
    }

    ListNode* retHead = phead->next;
    free(phead);
    phead = NULL;
    
    return retHead;
}

四. 链表分割

【题目链接】

题目描述

现有一链表的头指针 ListNode* pHead,给一定值x,编写一段代码将所有小于x的结点排在其余结点之前,且不能改变原来的数据顺序,返回重新排列后的链表的头指针

解法:双哨兵位

根据题意,可能很多人一下就想到动态申请一个哨兵位,然后遍历链表将所有小于x的节点尾插到新链表中,但是这样做会发现,那些大于等于x的节点直接就丢失了...

换个思路,要让全部小于x的节点在前,而且顺序还不能改变所以肯定是要新的链表的,但是一个不够啊,那我们干脆直接同时维护两个链表啊

动态申请两个哨兵位,定义一个指针遍历原链表,当节点的val小于x时将这个节点尾插到新链表1,大于等于x时插入新链表2,最后再合并两个链表即可

  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:思路不要受限,在经过上面几道题目之后,这题突然来个定义两个哨兵位还是很有意思的,解题的思路不难

代码实现

cpp 复制代码
/*
struct ListNode {
    int val;
    struct ListNode *next;
    ListNode(int x) : val(x), next(NULL) {}
};*/
class Partition {
public:
    ListNode* partition(ListNode* pHead, int x) {
        ListNode* pcur = pHead;

        ListNode* lowHead, *lowtail;
        lowHead = lowtail = (ListNode*)malloc(sizeof (ListNode));
        ListNode* upHead, *uptail;
        upHead = uptail = (ListNode*)malloc(sizeof (ListNode));

        while(pcur)
        {
            if(pcur->val < x)
            {
                lowtail->next = pcur;
                lowtail = lowtail->next;
            }else{
                uptail->next = pcur;
                uptail = uptail->next;
            }
            pcur = pcur->next;
        }
        uptail->next = NULL;

        lowtail->next = upHead->next;

        free(upHead);
        upHead = NULL;

        ListNode* retHead = lowHead->next;
        free(lowHead);
        lowHead = NULL;
        return retHead;

    }
};

五. 链表的回文结构

题目描述

对于一个链表,请设计一个时间复杂度为O(n),额外空间复杂度为O(1)的算法,判断其是否为回文结构。给定一个链表的头指针A,请返回一个bool值,代表其是否为回文结构。保证链表长度小于等于900

解法一:数组+双指针

因为这题链表的长度最大是900,所以我们可以将链表元素全部放进一个900大小的数组中,然后用双指针判断链表是否为回文结构即可


双指针判断回文结构的方法

使用前后指针,定义一个left指针指向数组第一个位置的下标,right指针指向数组最后一个位置的下标,之后进入循环while(left < right)只要left和right指向的值不同,那就不是回文结构


  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:标准的解法其实不能使用这个,这题使用这种解法完全是题目防水...但是根据题目随机应变也是一个很好的能力

解法二:寻找链表的中间节点+反转链表

假设此时我们有如上这个链表,因为回文结构一定是中间对称的(例如:12321),所以其实我们就可以直接在原链表上面判断,我们找到中间节点,将中间节点到尾节点部分的链表反转,此时我们就会发现是否有回文结构的链表的区别

如果链表具有回文结构就会满足一个性质,分别从链表的头节点和中间节点开始,它们对应的数值都是相同的,就像上面的例子成这样12123

到这里相信聪明的你已经不用我继续说就会写了...

  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:同时使用到了快慢指针和三指针反转链表的知识,最主要的还是需要做题者理解到回文结构的本质是什么

代码实现

cpp 复制代码
/*
struct ListNode {
    int val;
    struct ListNode *next;
    ListNode(int x) : val(x), next(NULL) {}
};*/
class PalindromeList {
public:
    ListNode* MiddleNode(ListNode* A)
    {
        ListNode* fast = A;
        ListNode* slow = A;
        while(fast && fast->next)
        {
            slow = slow->next;
            fast = fast->next->next;
        }

        return slow;
    }

    ListNode* reverselist(ListNode* A)
    {
        ListNode* n1 = NULL, *n2 = A, *n3 = NULL;

        while(n2)
        {
            n3 = n2->next;   //这个一定要先赋值
            n2->next = n1;
            n1 = n2;
            n2 = n3;
        }

        return n1;
    }

    bool chkPalindrome(ListNode* A) {
        //拿到链表中间节点
        ListNode* mid = MiddleNode(A);
        //将中间节点开始的链表反转
        ListNode* l2 = reverselist(mid);
        //判断回文
        ListNode* l1 = A;
        while(l2)
        {
            if(l1->val != l2->val)
            {
                return false;
            }

            l1 = l1->next;
            l2 = l2->next;
        }

        return true;
    }
};

六. 相交链表

【题目链接】

题目描述

给你两个单链表的头节点 headAheadB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null

解法:双指针

其实这题可以换一个难一些的方式考察判断两个字符串是否有相同的后缀这样可以绕一些弯子。我们需要判断两个链表是否相交首先最容易想到的就是只要两个链表有相同的节点就是相交了

【注意】是地址相同才可以哦~

所以很简单,定义两个指针分别指向两个链表,遍历判断是否有相同节点。好像有点不对劲......

我们发现题目给出的链表长度不一定是相同的啊,我们是知道了要判断地址,但是如何能让两个指针同时指到一个节点呢?其实要是给出的链表一定是相同长度的就好了啊

但是题目给出的不相同,我们可以先算出两个链表各自的长度sizeAsizeB,然后在更长的链表的地方指针先走abs(sizeA - sizeB),这样就保证了两个链表从同一个起跑线开始的了,最后同时遍历判断是否相同即可

  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:主要先算出来两个链表长度的差值会有些不好想,这题的难点也就在这里,不过如果想到了就很简单

代码实现

cpp 复制代码
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
typedef struct ListNode ListNode;

struct ListNode *getIntersectionNode(struct ListNode *headA, struct ListNode *headB) 
{
    ListNode* pa = headA;
    ListNode* pb = headB;
    //计算两个链表各自的长度
    int sizeA = 0, sizeB = 0;
    while(pa)
    {
        pa = pa->next;
        sizeA++;
    }
    while(pb)
    {
        pb = pb->next;
        sizeB++;
    }

    ListNode* shortlist = headA;
    ListNode* longlist = headB;
    int gap = abs(sizeA - sizeB);
    if(sizeA > sizeB)
    {
        shortlist = headB;
        longlist = headA;
    }
    //让长链表先走gap步
    while(gap--)
    {
        longlist = longlist->next;
    }

    while(shortlist)
    {
        if(shortlist == longlist)
        {
            return longlist;
        }

        shortlist = shortlist->next;
        longlist = longlist->next;
    }

    return shortlist;
}

七. 随机链表的复制

【题目链接】

题目描述

解法: 跟随插入法

题目分析

这一题看起来很简单,根据值复制节点再连接起来就好了,但是我们发现里面还有一个random指针需要让新节点指向的新链表的随机节点。如果单纯想遍历链表来连接的话,每次连一个random指针都要重新的遍历一遍链表,就不说麻烦,这样的时间复杂度都很高了,所以不使用这种方法

我们发现,难点就在random指针的连接上,每次很难找到新链表上那个对应的节点。那我们直接让新链表的节点好找不就行了吗,所以有

  • 每次复制一个新的节点,把它插入到原链表的节点上,如图

    后面的复制了的节点都进行这样的操作,所以此时我们就有了思路

  • 链接上新链表的random指针copy->random = pcur->random->next;

  • 断开新旧链表,返回新链表的头节点

代码实现

c 复制代码
/**
 * Definition for a Node.
 * struct Node {
 *     int val;
 *     struct Node *next;
 *     struct Node *random;
 * };
 */
typedef struct Node Node;

Node* buyNode(int x)
{
    Node* tmp = (Node*)malloc(sizeof(Node));
    tmp->val = x;
    tmp->next = tmp->random = NULL;
    return tmp;
}

void setrandom(Node* head)
{
    Node* pcur = head;
    while(pcur)
    {
        Node* pcopy = pcur->next;
        if(pcur->random)
        {
            pcopy->random = pcur->random->next;
        }
        pcur = pcur->next->next;
    }
}

struct Node* copyRandomList(struct Node* head) 
{
    if(head == NULL) return NULL;

    //先将新链表插入到旧链表中
	Node* pcur = head;
    while(pcur)
    {
        Node* newNode = buyNode(pcur->val);
        newNode->next = pcur->next;
        pcur->next = newNode;

        pcur = pcur->next->next;
    }
    //链接random指针
    setrandom(head);
    //将新链表与原链表断开
    Node* newHead = head->next, *newtail = head->next;
    pcur = newtail->next;
    while(pcur)
    {
        newtail->next = pcur->next;
        newtail = newtail->next;
        pcur = newtail->next;
    }

    return newHead;
}
  • 时间复杂度: O(n)
  • 空间复杂度: O(1)

分析:这题难点在random指针,巧妙的地方就是跟随复制,既然要一样的链表那干脆直接就每个新节点跟在原来的节点后面,这样random指针就很好赋值了,毕竟对应的新成员就跟在老成员的后面嘛

相关推荐
人道领域1 小时前
【LeetCode刷题日记】递归与回溯实战 257.二叉树的所有路径——一篇文章彻底搞懂回溯
开发语言·python·算法·leetcode
ulias2121 小时前
leetcode热题 - 7
数据结构·算法·leetcode
代码中介商1 小时前
树与二叉树:数据结构核心解析
数据结构
吃好睡好便好1 小时前
在Matlab中用sphere( )函数绘制球面图
开发语言·前端·javascript·学习·算法·matlab·信息可视化
图码1 小时前
矩阵中的“对角线强迫症”:如何优雅地判断Toeplitz矩阵?
数据结构·c++·线性代数·算法·青少年编程·矩阵
lynnlovemin1 小时前
二分查找与二分答案算法详解(基于C++实现)
c语言·开发语言·算法·二分查找·二分答案
瑞行AI1 小时前
一套数据格式框架搞定大模型微调和对齐训练
算法·语言模型
玛卡巴卡ldf1 小时前
【LeetCode 手撕算法】(动态规划)爬楼梯、杨辉三角、打家劫舍、完全平方数、零钱兑换、单词拆分、最长递增子序列、乘积最大子数组、分割等和子集
java·数据结构·算法·leetcode·动态规划·力扣
jake·tang1 小时前
深度解析 VESC 参数辨识源码:电阻、电感与磁链
arm开发·c++·嵌入式硬件·算法·数学建模·傅立叶分析