数据结构:为什么单链表总是学不透?核心原理与常见误区解析

在这里插入图片描述

🌈这里是say-fall分享,感兴趣欢迎三连与评论区留言
🔥专栏:《C语言从零开始到精通》
《C语言编程实战》

《数据结构与算法》

《小游戏与项目》

💪格言:做好你自己,你才能吸引更多人,并与他们共赢,这才是你最好的成长方式。


前言:

之前已经了解过经典数据结构中的顺序表了,今天我们来看看这个和顺序表有点类似但又不一样的东西:链表中的单链表,他们都是线性表,但是顺序表是逻辑和物理层面都线性,链表则是通过地址的存储联系起来的逻辑层面的线性表,不多说了,感兴趣的小伙伴快来围观下面的链表详解~



正文:

单链表这东西,说难吧,代码看起来也就那么几行;说简单吧,多少人栽在指针操作上,改了半天还是崩溃。我当年初学的时候也一样,明明觉得看懂了,自己动手写就各种报错——要么是插入后链表断了,要么是删除后程序崩了,甚至有时候打印出来的结果完全是乱的。

今天就结合一套完整代码,聊聊单链表到底难在哪,那些容易踩的坑又该怎么避开。

一、单链表到底是什么

很多人学不透单链表,根源是没理解它的本质。说白了,单链表就是用指针把一个个节点串起来的结构。咱们先看最基础的定义:

// Slist.h
typedef int SLTDataType;  // 数据类型起个别名,以后想存别的类型直接改这就行
// 链表的节点,相当于一节火车车厢
typedef struct SListNode 
{
    SLTDataType data;       // 车厢里装的东西(数据)
    struct SListNode* next; // 下一节车厢的地址(指针)
} SLTNode;

你可以这么理解:

  • 每个SLTNode是一个"节点",里面有两部分:data存实际数据(比如1、2、3),next存下一个节点的地址。
  • 正是next这个指针,把零散的节点"链"成了一个表。最后一个节点的nextNULL,相当于链表的终点。
  • 我们操作链表时,通常用一个"头指针"(比如代码里的plist)来记录第一个节点的位置。如果头指针是NULL,说明这是个空链表。

刚开始学的时候,我总把next当成数据的一部分,其实它的作用是"连接"——这一点想不通,后面的操作肯定会晕。

二、看看容易踩的坑

光说原理太空泛,咱们结合具体代码,看看那些让人头疼的操作里藏着哪些陷阱。

1. 手动创建链表:别小看"连接"的细节

先看一段手动创建节点并连接的测试代码,这是理解链表最直观的方式:

void SLTTest1()
{
    // 先创建4个独立的节点,每个节点都要分配内存
    SLTNode* node1 = (SLTNode*)malloc(sizeof(SLTNode));
    node1->data = 1;  // 给第一个节点存1
    
    SLTNode* node2 = (SLTNode*)malloc(sizeof(SLTNode));
    node2->data = 2;  // 第二个节点存2

    SLTNode* node3 = (SLTNode*)malloc(sizeof(SLTNode));
    node3->data = 3;  // 第三个节点存3

    SLTNode* node4 = (SLTNode*)malloc(sizeof(SLTNode));
    node4->data = 4;  // 第四个节点存4

    // 关键步骤:把节点连起来
    node1->next = node2;  // 第一个节点的next指向第二个
    node2->next = node3;  // 第二个指向第三个
    node3->next = node4;  // 第三个指向第四个
    node4->next = NULL;   // 最后一个节点的next必须是NULL(终点)

    SLTNode* plist = node1;  // 头指针指向第一个节点
    SLTPrint(plist);  // 打印看看结果
}

打印函数是怎么实现的呢?其实就是从头走到尾:

void SLTPrint(SLTNode* phead)
{
    SLTNode* pcur = phead;  // 用一个临时指针遍历,别直接动头指针
    while (pcur)  // 只要当前节点不是NULL,就继续走
    {
        printf("%d->", pcur->data);  // 打印当前节点的数据
        pcur = pcur->next;  // 跳到下一个节点(靠next指针)
    }
    printf("NULL\n");  // 最后打印个NULL,表示结束
    printf("\n");
}

这里有个新手常犯的错:遍历的时候把循环条件写成while(pcur->next)。你想想,这样的话,最后一个节点的data就打印不出来了(因为它的nextNULL)。记住:只要pcur本身不是NULL,就还有数据要打印。

2. 头插和尾插:为啥非要用二级指针

插入操作里,头插(往链表最前面加节点)和尾插(往最后面加节点)是基础,但很多人搞不懂为啥参数要用SLTNode**pphead(二级指针)。

先看头插的代码:

// 头插:在链表最前面加个新节点
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
    assert(pphead);  // 确保传进来的二级指针不是NULL(防错)
    SLTNode* newnode = SLTBuyNode(x);  // 先创建一个新节点(SLTBuyNode是封装的造节点函数)
    newnode->next = *pphead;  // 新节点的next指向原来的头节点
    *pphead = newnode;  // 头指针更新为新节点(现在它是第一个了)
}

再看尾插:

// 尾插:在链表最后面加个新节点
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
    assert(pphead);  // 二级指针必须有效
    SLTNode* newnode = SLTBuyNode(x);
    // 如果链表是空的(头指针是NULL),直接让头指针指向新节点
    if (*pphead == NULL)
    {
        *pphead = newnode;
    }
    else
    {
        // 不是空链表,先找到最后一个节点
        SLTNode* ptail = *pphead;
        while (ptail->next)  // 只要next不是NULL,就还没到尾
        {
            ptail = ptail->next;
        }
        ptail->next = newnode;  // 最后一个节点的next指向新节点
    }
}

当年我就卡在这里:为啥不能用一级指针SLTNode* phead
原因很简单:C语言函数传参是"值传递"。如果头插时用一级指针,函数里改的只是phead的副本,外面的头指针plist根本没变(还是NULL)。而二级指针指向的是头指针本身,改*pphead才能真正更新头指针的值。

举个例子:空链表头插时,必须让plistNULL变成新节点的地址。用一级指针办不到,只能用二级指针。

3. 头删和尾删:边界条件很重要

删除操作比插入更麻烦,尤其是边界情况(比如链表只有一个节点,或者删完后变空)。

先看尾删(删除最后一个节点):

void SLTPopBack(SLTNode** pphead)
{
    // 断言:链表不能是空的(*pphead != NULL),不然删个啥
    assert(pphead && *pphead);
    
    // 情况1:链表只有一个节点
    if ((*pphead)->next == NULL)
    {
        free(*pphead);  // 释放这个节点
        *pphead = NULL;  // 头指针置空(不然就成野指针了)
    }
    // 情况2:链表有多个节点
    else
    {
        SLTNode* ptail = *pphead;  // 用来找尾节点
        SLTNode* prev = *pphead;   // 用来找尾节点的前一个
        while (ptail->next)  // 找到最后一个节点
        {
            prev = ptail;       // 先记下当前节点(下一步就成前一个了)
            ptail = ptail->next;  // 往后挪一步
        }
        free(ptail);  // 释放尾节点
        ptail = NULL;  // 好习惯:释放后指针置空
        prev->next = NULL;  // 前一个节点现在成了尾节点,next置空
    }
}

这些坑你可能也踩过

  • 没判断空链表就删,程序直接崩(所以assert(*pphead)很重要);
  • 只有一个节点时,删完没把头指针置空,结果plist还指向已释放的内存(野指针);
  • 多个节点时,找不到"尾节点的前一个",导致删完后链表没封好口(最后一个节点的next不是NULL)。

4. 指定位置操作:指针指向千万别弄反

比如"在指定节点前面插入"或"删除指定节点",最容易晕的是指针该怎么指。以"指定位置前插入"为例:

// 在pos节点前面插一个新节点
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
    assert(pphead && *pphead);  // 链表非空
    assert(pos);  // 不能往NULL位置插

    // 特殊情况:如果pos是头节点,其实就是头插
    if (pos == *pphead)
    {
        SLTPushFront(pphead, x);  // 直接用头插的逻辑,不用重复写
    }
    else 
    {
        SLTNode* newnode = SLTBuyNode(x);
        newnode->next = pos;  // 新节点的next先指向pos

        // 找pos的前一个节点prev
        SLTNode* prev = *pphead;
        while (prev->next != pos)  // 循环到prev的next是pos为止
        {
            prev = prev->next;
        }
        prev->next = newnode;  // prev的next再指向新节点
    }
}

这里的关键是顺序:必须先让新节点指向pos,再让prev指向新节点。如果先改prev->next,就会找不到pos了(相当于链表在这断了)。我当年就因为顺序搞反,调试了半天都不知道为啥节点丢了。

5. 销毁链表:千万别漏了释放内存

很多人写完增删查改就完事了,忘了销毁链表,结果造成内存泄漏。正确的销毁方式是逐个释放节点:

// 销毁整个链表
void SLTDesTory(SLTNode** pphead)
{
    assert(pphead && *pphead);  // 链表非空
    SLTNode* pcur = *pphead;
    while (pcur)  // 一个个节点释放
    {
        SLTNode* next = pcur->next;  // 先记下下一个节点的地址(不然释放后就找不到了)
        free(pcur);  // 释放当前节点
        pcur = next;  // 移到下一个节点
    }
    *pphead = NULL;  // 最后把头指针置空,避免野指针
}

新手容易忘的是:释放完所有节点后,必须把头指针plist置为NULL。不然plist还指向原来的内存(已经被释放了),下次再用就出问题。

三、学好单链表的3个关键点

  1. 画示意图比死记代码重要:每次写操作前,先在纸上画清楚节点之间的指针关系,插入/删除时哪根指针先动、哪根后动,一目了然。

  2. 边界条件是重中之重:空链表、单节点链表、操作头/尾节点,这些情况一定要单独考虑。写代码时多问自己:如果链表是空的会怎样?如果只有一个节点呢?

  3. 对指针多一点耐心:刚开始晕很正常,毕竟指针是C语言的难点。多动手调试,看看每个指针在步骤中的值变化,慢慢就有感觉了。

单链表虽然基础,但它是理解更复杂数据结构(比如双向链表、树)的敲门砖。避开这些坑,把每一步操作的逻辑吃透,后面学啥都能顺很多。记住:数据结构不是背出来的,是调出来的——多写、多错、多改,自然就会了。


  • 本节完…
评论 22
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值