【面试分享】嵌入式面试题常考难点之关于单链表的增删改查

【面试分享】嵌入式面试题常考难点之关于单链表的增删改查

在众多经典数据结构中,单链表以其简单灵活的特性,成为嵌入式面试题库中的常客,尤其是在考察增删改查(CRUD)操作时。本文旨在深入剖析单链表在面试场景中常考的难点,通过解析这些基本操作的实现细节与优化策略,帮助读者掌握应对相关面试题的技巧,同时提升对链表这一基础数据结构的深刻理解。

单链表,作为一种线性数据结构,其特点在于每个结点包含两部分:存储数据的元素和指向下一个结点的指针。这一结构特性使得单链表在插入、删除等操作上相比数组展现出更高的效率,但也给查找等操作带来了一定挑战。正因如此,面试官倾向于通过单链表的增删改查来评估候选人对指针操作的熟练度、逻辑思维能力以及对时间与空间复杂度的敏感度。

在这里插入图片描述

接下来,我们将依次探讨单链表增(Create)删(Delete)改(Update)查(Read) 操作的核心逻辑、常见陷阱及优化思路,力求为即将步入面试场的开发者们提供一份详实的备考指南。无论是追求极致性能的算法爱好者,还是希望在面试中脱颖而出的求职者,都能从本文中获得宝贵的知识与启发。


一、单链表结点定义

为了方便介绍,本文将使用以下结构体创建链表的结点

typedef struct node {
    int nodeId;
    char nodeData[20];

    struct node *next;
} NODE;

extern NODE *head;

并用如下链表初始化函数创建一条初始链表:

NODE *initList(NODE *pHead)
{
    NODE *temp = NULL;

    for (int i = MAX_NODE_NUM; i > 0; i--) {
        temp = (NODE *)malloc(sizeof(NODE));
        if (temp == NULL) {
            printf("Memory allocation failed!\n");
            exit(0);
        } else {
            temp->nodeId = i;
            temp->next = pHead;
            pHead = temp;
            sprintf(temp->nodeData, "<Node_%d>", temp->nodeId);
        }
    }

    return pHead;
}

[!NOTE]

上述代码中的 MAX_NODE_NUM 宏定义为 4,也就是初始链表的长度为 4 个结点。

打印链表 ID 的函数:

void printList(NODE *pHead)
{
    while (pHead != NULL) {
        printf("%d -> ", pHead->nodeId);
        pHead = pHead->next;
    }
    printf("NULL\n");
}

打印链表 ID 及信息的函数:

void printListData(NODE *pHead)
{
    while (pHead != NULL) {
        printf(" %d: %s\n |\n", pHead->nodeId, pHead->nodeData);
        pHead = pHead->next;
    }   
    printf(" NULL\n");
}

main 函数简单测试一下:

int main()
{
    head = initList(head);
    printList(head);
    putchar('\n');
    printListData(head);
    return 0;
}

执行结果如下:

1 -> 2 -> 3 -> 4 -> NULL

 1: <Node_1>
 |
 2: <Node_2>
 |
 3: <Node_3>
 |
 4: <Node_4>
 |
 NULL

后续创建链表的新结点,使用以下函数:

NODE *createNewNode()
{
    NODE *temp = (NODE *)malloc(sizeof(NODE));

    if (temp == NULL) {
        printf("Memory allocation failed!\n");
        exit(0);
    } else {
        printf("Enter the Node Id: ");
        scanf("%d", &temp->nodeId);
        temp->next = NULL;
        sprintf(temp->nodeData, "<New_Node_%d>", temp->nodeId);
    }

    return temp;
}

[!NOTE]

结点 ID 需要用户手动输入。

二、增(Create)——插入结点

1. 于链表头部插入结点(头插法)

链表的头插法写法也是多种多样,以下是两种常见写法:

  1. 在执行头插法时,创建新结点后插入新结点,并返回新的链表头指针:

    NODE *insertAtHead(NODE *head)
    {
        NODE *newNode = createNewNode();
        newNode->next = head;
        head = newNode;
        return head;
    }
    
  2. 已经创建了新结点,执行头插法时添加进链表,并返回新的链表头指针:

    NODE *insertAtHead(NODE *pHead, NODE *newNode)
    {
        newNode->next = head;
        pHead = newNode;
        return pHead;
    }
    

其实不管怎么变化,核心只有最后几句代码:

  1. 首先是 newNode->next = head,让新结点的 next 指向链表的头部,接入链表;

    在这里插入图片描述

  2. 然后是 pHead = newNode,让链表头指针指向新结点;
    在这里插入图片描述

  3. 最后返回头指针。

2. 于链表尾部插入结点(尾插法)

链表的为插法跟头插法一样,也是有两种常见写法:

  1. 在执行尾插法时,创建新结点后,先判断链表是否存在,如果存在就添加进链表,否则以该结点为链表头部创建链表,并返回链表头指针:

    NODE *insertAtTail(NODE *pHead)
    {
        NODE *newNode = createNewNode();
        NODE *temp = pHead;
    
        if (temp == NULL) {
            pHead = newNode;
            newNode->next = NULL;
        } else {
            while (temp->next != NULL)
                temp = temp->next;
    
            temp->next = newNode;
            newNode->next = NULL;
        }
    
        return pHead;
    }
    
  2. 已经创建了新结点,执行尾插法时,先判断链表是否存在,如果存在就添加进链表,否则以该结点为链表头部创建链表,并返回链表头指针:

    NODE *insertAtTail(NODE *pHead, NODE *newNode)
    {
        NODE *temp = pHead;
    
        if (temp == NULL) {
            pHead = newNode;
            newNode->next = NULL;
        } else {
            while (temp->next != NULL)
                temp = temp->next;
    
            temp->next = newNode;
            newNode->next = NULL;
        }
        
        return pHead;
    }
    

两中尾插法的方式是一样的,当链表存在时,尾插法的插入过程就是通过 while (temp->next != NULL) temp = temp->next; 遍历链表,判断当前结点是否为链表最后一个结点。

在这里插入图片描述

一旦找到链表的最后一个结点,就让该结点的 next 指针指向插入链表的新结点。

在这里插入图片描述

[!NOTE]

为什么头插法不需要判断链表是否存在,而尾插法需要?

头插法和尾插法在插入结点时的处理方式不同,在头插法中,始终是在链表的头部插入一个新结点。这种方法不需要考虑链表是否为空,因为新的头结点将始终指向当前的头结点,即使链表为空(headNULL),这也是有效的。

在尾插法中,需要遍历链表找到最后一个结点,然后在其后插入一个新结点。如果链表为空,就需要特别处理,因为此时链表没有结点(或者说链表不存在),不存在所谓的尾结点。遍历一个不存在的链表,是一种指针的非法访问,会导致段错误

3. 于链表中间插入结点

3-1. 在指定结点前插入结点(前插法)

所谓前插法,就是在链表中找到指定结点,并在该结点前插入新结点。例如,当前链表为 0 -> 1 -> 2 -> 3 -> 4 -> NULL,现在有个新结点 100 要求插入,并指定在结点 3 前插入,插入后链表为 0 -> 1 -> 2 -> 100 -> 3 -> 4 -> NULL

前插法在编码时需要考虑到一些特殊情况:

  1. 链表没有结点(链表不存在)

    这时有两种处理方式,由开发者决定使用哪一种。一是直接返回错误代码,告知功能使用者,链表为空,无法插入新结点。二是以当前新结点为链表头,创建链表,写法参考头插法。

  2. 指定结点为链表头结点

    跟第一种情况第二点一样的处理处理方式差不多,也是头插法的处理方式。

  3. 链表存在,但结点不存在

    如果遍历完这个链表都没找到指定的结点,就可能是参数传递错误,也可能是其他原因,总之这种情况无法插入结点。可以通过输出 Log 的方式提示功能使用者,并作出相应的处理动作。

  4. 单链表不可反向回退

    单链表结点的特性就决定了链表只能单个方向遍历,所以在遍历结点的时候,应该通过临时指针 temp 指向的结点的 next 去找目标结点。如若不然,只是用临时指针 temp 搜索目标结点,就会出现找到目标结点也无法插入的情况,如下图所示:

    在这里插入图片描述

结合以上四种情况,前插法的代码如下所示:

  1. 参数列表中不含新结点,由前插法函数申请新结点:

    NODE *insertBefore(NODE *pHead, int nodeId)
    {
        NODE *newNode = createNewNode();
        NODE *temp = pHead;
    
        if (temp == NULL) {
            pHead = newNode;
            newNode->next = NULL;
        } else if (temp->nodeId == nodeId) {
            newNode->next = pHead;
            pHead = newNode;
        } else {
            while (temp->next != NULL && temp->next->nodeId != nodeId)
                temp = temp->next;
    
            if (temp->next == NULL) {
                printf("Node not found!\n");
                free(newNode);
            } else {
                newNode->next = temp->next;
                temp->next = newNode;
            }
        }
        
        return pHead;
    }
    
  2. 参数列表中含新结点指针(常用):

    NODE *insertBefore(NODE *pHead, NODE *newNode, int nodeId)
    {
        NODE *temp = pHead;
    
        if (temp == NULL) {
            pHead = newNode;
            newNode->next = NULL;
        } else if (temp->nodeId == nodeId) {
            newNode->next = pHead;
            pHead = newNode;
        } else {
            while (temp->next != NULL && temp->next->nodeId != nodeId)
                temp = temp->next;
    
            if (temp->next == NULL) {
                printf("Node not found!\n");
            } else {
                newNode->next = temp->next;
                temp->next = newNode;
            }
        }
        
        return pHead;
    }
    

两个代码核心部分是一样的,代码解析如下:

  1. 通过 if (temp == NULL) 判断链表是否存在,如果不存在,新结点 newNode 则作为链表头;
  2. 如果链表已经存在,则通过 else if (temp->nodeId == nodeId) ,判断第一个节点是不是目标节点,如果是,则以头插法的方式,把新结点插在目标结点前,新结点成为新的链表头;
  3. 如果以上两个判断都不是,则通过 while (temp->next != NULL && temp->next->nodeId != nodeId) 遍历链表,直到找到目标结点或者链表遍历结束;
  4. 如果目标结点未找到,则输出 Log。在此处两个代码有一点区别,如果新结点是由前插法内部生成的,要注意把无法插入的新结点释放掉(执行 free(newNode);),如果是传参传进来的新结点,则不需要释放;
  5. 如果找到目标结点,则通过 newNode->next = temp->next;,把新结点挂在链表上。再通过 temp->next = newNode;,把当前结点的 next 指针指向新结点,完成新结点的插入。

以下是前插法执行的动画过程:

在这里插入图片描述

3-2.在指定结点后插入结点(后插法)

后插法相对于前插法要简单一些,只需要找到目标结点并在其后面插入新结点即可,其处理过程有点类似于尾插法。例如,当前链表为 0 -> 1 -> 2 -> 3 -> 4 -> NULL,现在有个新结点 100 要求插入,并指定在结点 2 前插入,插入后链表为 0 -> 1 -> 2 -> 100 -> 3 -> 4 -> NULL

后插法在编码时也由一些需要注意的情况,不过与前面提到的前插法差不多,这里就不赘述了。

后插法的代码如下所示:

  1. 参数列表中不含新结点,由后插法函数申请新结点:

    NODE *insertAfter(NODE *pHead, int nodeId)
    {
        NODE *newNode = createNewNode();
        NODE *temp = pHead;
    
        if (temp == NULL) {
            pHead = newNode;
            newNode->next = NULL;
        } else {
            while (temp->next != NULL && temp->nodeId != nodeId)
                temp = temp->next;
    
            if (temp->nodeId != nodeId) {
                printf("Node not found!\n");
                free(newNode);
            } else {
                newNode->next = temp->next;
                temp->next = newNode;
            }
        }
        
        return pHead;
    }
    
  2. 参数列表中含新结点指针(常用):

    NODE *insertAfter(NODE *pHead, NODE *newNode, int nodeId)
    {
        NODE *temp = pHead;
    
        if (temp == NULL) {
            pHead = newNode;
            newNode->next = NULL;
        } else {
            while (temp->next != NULL && temp->nodeId != nodeId)
                temp = temp->next;
    
            if (temp->nodeId != nodeId) {
                printf("Node not found!\n");
            } else {
                newNode->next = temp->next;
                temp->next = newNode;
            }
        }
        
        return pHead;
    }
    

两个代码核心部分是一样的,代码解析如下:

  1. 与前插法相同,通过 if (temp == NULL) 判断链表是否存在,如果不存在,新结点 newNode 则作为链表头;
  2. 如果链表存在,则通过 while (temp->next != NULL && temp->nodeId != nodeId) 遍历链表,直到找到目标结点或者链表遍历结束;
  3. 通过 if (temp->nodeId != nodeId) 判断 while 循环退出的具体原因,如果遍历完链表,目标结点未找到,则输出 Log。在此处两个代码也是有区别,原理跟前插法一样,不赘述。
  4. 如果是找到目标结点,提前结束了 while 循环,则通过 newNode->next = temp->next;,把新结点挂在链表上。再通过 temp->next = newNode;,把当前结点的 next 指针指向新结点,完成新结点的插入。

以下是后插法执行的动画过程:

在这里插入图片描述

三、删(Delete)——删除结点

1. 根据结点内容删除结点

一般链表的结点都有一个所谓的唯一标识符,例如本文使用的结点中的 nodeId,这样可以通过这个唯一标识符找到对应的结点(类似 Python 中的键值对,nodeId 的效果相当于键值对中的 key)。前面使用前插法和后插法都是使用了这个 nodeId 索引到对应的目标结点的。

那么根据结点内容来删除结点,也成了最常见的删除结点的办法,通常这里的结点内容指的就是唯一标识符。在完成这个编码的时候也是需要注意以下两点:

  1. 判断链表是否存在:如果不存在,有可能是链表已经被删完了,或者传参时没传入正确的参数,此时应该立即返回,并提示用户;
  2. 临时指针指向被删结点的上一个结点:即将被删除的结点在被剔除链表之前,它的上一个结点的 next 要先指向被删除的结点的下一个结点,因为单链表不可逆,因此在临时指针应当指在被删结点的上一个结点上,这样才有利于删除的操作。

结合以上两点,根据 nodeId 删除结点的方法如下:

NODE *deleteNodeByNodeId(NODE *pHead, int nodeId)
{
    NODE *temp = pHead;

    if (temp == NULL) {
        printf("List is empty!\n");
    } else if (temp->nodeId == nodeId) {
        pHead = temp->next;
        free(temp);
    } else {
        while (temp->next != NULL && temp->next->nodeId != nodeId)
            temp = temp->next;

        if (temp->next == NULL) {
            printf("Node not found!\n");
        } else {
            NODE *delNode = temp->next;
            temp->next = delNode->next;
            free(delNode);
        }
    }
    return pHead;
}

代码解析如下:

  1. 首先用临时指针 temp 代替链表头指针,通过 if (temp == NULL) 判断链表是否存在;
  2. 如果链表存在,结合结点 ID,通过 else if (temp->nodeId == nodeId) 判断需要删除的结点是否是链表的头结点。如果是,则将头指针后移到下个结点,再将头节点释放;
  3. 如果以上两个情况都不符合,则开始遍历链表,直到找到目标结点或者链表遍历结束;
  4. 退出遍历之后,如果没有找到目标结点则返回;
  5. 如果找到了目标结点,则新建一个指针指向被删除结点,先改变临时结点的 next 指向,再释放目标结点,完成删除。

以下是该函数执行的动画过程:

在这里插入图片描述

2. 根据位置删除结点

这种删除结点的方式不常见,假设链表有 m 个结点,要求删除第 n 个结点(m ≥ n),则在链表中找到第 n 个结点并删除。具体代码如下:

NODE *deleteNodeByPosition(NODE *pHead, unsigned int position)
{
    NODE *temp = pHead;
    if (temp == NULL) {
        printf("List is empty!\n");
    } else if (position == 0) {
        pHead = temp->next;
        free(temp);
    } else {
        for (unsigned int i = 0; i < position - 1; i++) {
            if (temp->next == NULL) {
                printf("Invalid position!\n");
                return pHead;
            }
            temp = temp->next;
        }

        NODE *delNode = temp->next;
        temp->next = delNode->next;
        free(delNode);
    }
    return pHead;
}

代码前半部分与前面大部分代码相似,就不过多解释,只从 for 循环开始解析,如下:

  1. 因为 0 号结点算链表的第一个结点,所以在 for 循环中的第二个表达式,位置数要减一;
  2. 如果位置大于链表长度,也就是已经遍历完链表了,但还到达指定位置,直接返回;
  3. 如果找到了目标结点,删除结点的方法与上一个代码的方式一样,此处省略。

3. 删除指针指向的结点(经典面试题)

这是一道 C 语言的经典面试题,原题目不太记得,大概就是在单链表中,未给出头指针,只有一个指针指向链表的某个结点,现在要求删除这个结点。

从前面提到两种删除结点的方式来看,我们要删除单链表上的某一个结点 N 的话,都是在 N 结点的上一个结点进行操作的,我们用 M 结点来代替 N 结点的上一个结点。删除的过程,就是让 M 结点的 next 指向 N 结点的下一个结点,然后再释放 N 结点。

但现在指针指在要求被删的结点上,倒退回上一个结点是不可能的事,所以这里就需要换一种思路来完成,那就是“移花接木”。我们都知道,链表主要的作用就是方便管理数据,而数据是可以被复制、转移和修改的,所谓删除结点,可以理解为把这个结点的数据从链表上去除,那么只要这个链表上没有这个结点的数据,不就等同于把这个结点在链表上删除了吗?因此,本题的解法就是:既然我们无法直接删除这个结点,那就把下一个结点的数据复制到当下指针所指的结点上,此时链表上就会有两个数据一样的结点(包括 next 也复制),然后再把当下指针所指的结点的下一个结点释放掉,完成结点的删除。

以下是删除给定指针指向的节点的函数实现:

int deleteNode(NODE *node) {
    if (node == NULL || node->next == NULL) {
        printf("Cannot delete the given node.\n");
        return -1;
    }

 	NODE *temp = node->next;
    memcpy(node, temp, sizeof(NODE));
    free(temp);
    
    return 0;
}

代码解析如下:

  1. 先判断 node 指针是否为空,和 node 下一个结点是否存在,满足任意条件直接返回 -1
  2. 用临时指针指向下一个结点,把下一个结点的数据全部复制到本结点上,然后是否下一个结点。

[!NOTE]

为什么 node->next 也不能为 NULL

如果 node->nextNULL,就说明 node 指针指向的是链表的最后一个结点,没办法改变上一个结点的 next 指针指向 NULL。如果把 node 指针所指的结点直接释放掉,并不能使上一个结点的 next 指针指向 NULL,它依然是指向原本 node 所指的地址,而此时该地址已经被释放,后续所有的访问操作都是非法访问。

四、改(Update)和查(Read)————修改结点与查找结点

改和查,我们放在一起来讲解,因为修改结点数据其中就包含了查找结点。其实不单单是修改节点数据时有查找结点的操作,前面提到的前插法和后插法,还有删除结点的两个方法,都包含了查找结点的操作。

在单链表中查找某个结点,通常有两个目的,一个是读取里面的数据,二是修改。

我们先从比较简单的查找结点说起,在前面删除结点的章节就提过,链表的结点都有一个所谓的唯一标识符,一般查找结点也是通过这个唯一标识符查找的,所以查找结点的方法能很快的就写出来,如下:

NODE *searchNode(NODE *pHead, int nodeId)
{
    NODE *temp = pHead;
    while (temp != NULL) {
        if (temp->nodeId == nodeId)
            return temp;

        temp = temp->next;
    }
    return NULL;
}

其实就是通过遍历的方式,找到对应的结点。

接下来是改数据,一般来说改数据都是根据具体需求来决定,例如我要别某个结点的 nodeData 内容改成其它内容,我的代码可以这样写:

int updateNode(NODE *pHead, int nodeId, char *newData)
{
    NODE *temp = searchNode(pHead, nodeId);
    if (temp != NULL) {
        strcpy(temp->nodeData, newData);
        return 0;
    } else {
        return -1;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Grayson Zheng

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值