单链表:多米诺骨牌的奇妙旅程
1.多米诺骨牌的结构定义
角色和工具:
你----首席设计师(通常在main函数中):手握关键的设计蓝图
SLTNode* phead : 蓝图上的红色“起点标记”,指向第一块骨牌。若蓝图为空(phead = NULL),则表示桌上无骨牌。
SLTNode : 一块精心设计的骨牌
data : 骨牌上的数字
next : 骨牌的“瞄准器”,指向倒下后将撞倒的下一块骨牌的位置。若是最后一块,瞄准器指向“虚无”(NULL)。
SLTNode** pphead(万能修改笔) :一种特权工具,当函数(助理)需要直接修改你蓝图上的“起点标记”本身(即改变phead指向何处)时,你必须通过传递&phead(“起点标记”在蓝图上的存储位置)将万能修改笔交给助理,助理拿到的就是pphead。
//定义骨牌上面的数字类型
typedef int SLTDataType;
//结构定义
typedef struct SListNode
{SLDataType data;//骨牌上面的数字struct SListNode* next;//内置“瞄准器”(指向下一块骨牌)
}SLTNode;
data:存储骨牌上的独特数字
next:一个指针,存储下一块SLTNode骨牌在内存中的地址,若为最后一块,next为NULL。
红色起点标记 骨牌1 骨牌2 骨牌3🚩 +------+------+ +------+------+ +------+------+phead ---> | 数据 | 瞄准 |--->| 数据 | 瞄准 |--->| 数据 | NULL |+------+------+ +------+------+ +------+------+
phead就像一个红色箭头,永远指向第一块骨牌。每块骨牌上都有一个数字,还有一个小箭头指向下一块骨牌。最后一块骨牌的箭头指向虚无(NULL),表示链表结束。
2.多米诺骨牌的核心操作
设定:
assert( ):安全检查员,条件为假时停止程序并报告错误,调试时非常有用
malloc( ):向“骨牌工厂”(内存堆)申请空间制造新骨牌
free( ):将不再需要的骨牌送回工厂回收
标记起点(SLTNode* phead)
设计师在摆放阵列时,首先会在蓝图上指定"起点标记"phead。
//在main函数中,你的蓝图
SLTNode* phead = NULL;//最初,蓝图为空,桌上无骨牌,“起点标记”指向虚无
phead是一个SLTNode*类型的一级指针。
它不属于任何骨牌,是你用来标记第一块骨牌位置的独立工具。
2.1 创建一块新骨牌
SLTNode* SLTbuyNode(SLTDataType x) {//从工厂定制新骨牌SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));//⚠️工厂资源不足if (newnode == NULL) {perror("骨牌制造失败(malloc failed)!");exit(1);}//为骨牌添加数字x并初始化瞄准器new->data = x;new->next = NULL;//瞄准器暂指向虚无return newnode;//全新骨牌出厂
}
2.2 查看整个多米诺阵列(打印链表)
void SLTPrint(SLTNode* phead) {
//从第一块骨牌开始检查SLTNode* pcur = phead;while(pcur) {printf("%d -> ", pcur->data); //打印当前骨牌数字pcur = pcur->next; //顺着瞄准器看下一块}printf("NULL\n");
}
2.3 在最前面添加骨牌 (头插)
【开始状态】+------+------+ +------+------+
🚩---> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+【创建新骨牌】
+------+------+
| 1 | next |---> NULL <- 新骨牌
+------+------+【调整新骨牌的next指针】
+------+------+ +------+------+ +------+------+
| 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL
+------+------+ +------+------+ +------+------+【使用万能修改笔修改起点标记】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+
1)创建新骨牌;
2)让新骨牌的next指针指向原来的第一块;
3)移动起点标记,让它指向新骨牌(使用万能修改笔)。
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{assert(pphead); // 确保万能修改笔有效// 1⃣ 创建新骨牌SLTNode* newnode = SLTbuyNode(x);// 2⃣ 调整新骨牌的next指针,指向原来的第一块骨牌// *pphead表示原始的phead值,即原来第一块骨牌newnode->next = *pphead;// 3⃣ 使用万能修改笔,修改起点标记指向新骨牌*pphead = newnode;// 这一步是关键!通过*pphead修改了主函数中的phead
}
2.4 在末尾添加新骨牌(尾插)
【情况1:桌面上没有骨牌】
🚩---> NULL (空桌面)【创建并放置第一块骨牌】+------+------+
🚩---> | 1 | next |---> NULL <- 需要万能修改笔!✏️+------+------+【情况2:已有骨牌】+------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |---> NULL+------+------+ +------+------+【创建新骨牌】
+------+------+
| 3 | next |---> NULL <- 新骨牌
+------+------+【寻找最后一块骨牌】+------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |---> NULL <- 找到!+------+------+ +------+------+^|ptail【调整最后一块骨牌的next指针】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^ ^| |ptail ptail->next = newnode
1)桌面上没有骨牌(需要万能修改笔);
2)已有骨牌(需要找到最后一块并调整其next指针)。
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{assert(pphead); // 确保万能修改笔有效// 1⃣ 创建新骨牌SLTNode* newnode = SLTbuyNode(x);// 2⃣ 处理特殊情况:桌面上没有骨牌if (*pphead == NULL){// 使用万能修改笔,将起点标记指向新骨牌*pphead = newnode;}else {// 3⃣ 寻找最后一块骨牌SLTNode* ptail = *pphead; // 从第一块开始找while (ptail->next) // 当next指针不是指向虚无时{ptail = ptail->next; // 移动到下一块骨牌}// 4⃣ 调整最后一块骨牌的next指针,指向新骨牌ptail->next = newnode;}
}
2.5 头部移除骨牌(头删)
【开始状态】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+【记住第二块骨牌】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^| next【移除第一块骨牌】💥 砰!💥 +------+------+ +------+------+| 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+^| next【使用万能修改笔修改起点标记】+------+------+ +------+------+🚩---------------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+
1)记住第二块骨牌的位置(next);
2)移除第一块骨牌;
3)移动起点标记,让他指向原来的第二块骨牌(需要使用万能修改笔)。
void SLTPopFront(SLTNode** pphead)
{// 确保万能修改笔有效且桌上有骨牌assert(pphead && *pphead);// 1⃣ 记住第二块骨牌的位置(next)SLTNode* next = (*pphead)->next;// 2⃣ 移除第一块骨牌free(*pphead); // 骨牌爆炸!💥// 3⃣ 使用万能修改笔修改起点标记// 让起点标记指向原来的第二块骨牌*pphead = next;
}
2.6 移除阵列末尾的骨牌(尾删)
【情况1:只有一块骨牌】+------+------+
🚩---> | 1 | next |---> NULL+------+------+【移除唯一骨牌】💥 砰!💥【使用万能修改笔修改起点标记】
🚩---> NULL (空桌面) <- 需要万能修改笔!✏️【情况2:多块骨牌】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+【找到倒数第二块骨牌】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^ ^| |prev ptail【调整倒数第二块骨牌的next指针,移除最后一块】+------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |---> NULL 💥 砰!💥+------+------+ +------+------+ ^|prev->next = NULL
1)只有一块骨牌(相当于头删);
2)有多块骨牌(需要找到倒数第二块,调整其next指针,然后移除最后一块)
void SLTPopBack(SLTNode** pphead)
{// 确保万能修改笔有效且桌上有骨牌assert(pphead && *pphead);// 1⃣ 处理特殊情况:只有一块骨牌if ((*pphead)->next == NULL){free(*pphead); // 骨牌爆炸!💥*pphead = NULL; // 使用万能修改笔将起点标记指向虚无}else {// 2⃣ 找到倒数第二块骨牌SLTNode* prev = NULL; // 前一块骨牌SLTNode* ptail = *pphead; // 当前骨牌while (ptail->next) // 当next指针不是指向虚无时{prev = ptail; // 记住前一块骨牌ptail = ptail->next; // 移动到下一块骨牌}// 3⃣ 调整倒数第二块骨牌的next指针,指向虚无prev->next = NULL;// 4⃣ 移除最后一块骨牌free(ptail); // 骨牌爆炸!💥ptail = NULL; // 安全起见,将指针置空}
}
2.7 骨牌寻宝之旅(查找)
【寻找数字为2的骨牌】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+🔍 🔍 🔍"不是2" "找到2!" "不需要看了"pcur = pcur->next return pcur
查找操作过程: 查找就像一次寻宝之旅,从第一块骨牌开始,沿着next指针挨个检查骨牌上的数字,直到找到目标或者到达队伍末尾。
SLTNode* SLTFind(SLTNode* phead,SLTDataType x)
{//准备好探险家(不需要万能修改笔,因为不修改链表)SLTNode* pcur = phead;//从第一块骨牌开始,逐块检查while(pcur){//检查当前骨牌上的数字是不是我们要找的if(pcur->next == x){return pcur;//找到了!返回这块骨牌} 移动到下一块骨牌pcur = pcur->next;}//遍历完所有骨牌都没有找到return NULL;}
2.8 在指定位置前插入骨牌
【开始状态】(要在数字2前插入)+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^|pos【创建新骨牌】
+------+------+
| 1.5 | next |---> NULL <- 新骨牌
+------+------+【找到pos前一块骨牌】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^ ^| |prev pos【调整骨牌的next指针】+------+------+ +------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 1.5 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+ +------+------+^ ^ ^| | |prev newnode = prev->next pos = newnode->next
前插操作过程: 前插就是在指定骨牌前面插入新骨牌。这需要先找到指定骨牌的前一块,然后调整next指针。如果指定骨牌是第一块,就变成了头插操作。
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{// 确保万能修改笔和目标位置都有效assert(pphead && pos);// 1⃣ 处理特殊情况:插入位置是第一块骨牌if (pos == *pphead){// 直接调用头插函数SLTPushFront(pphead, x);}else {// 2⃣ 创建新骨牌SLTNode* newnode = SLTbuyNode(x);// 3⃣ 找到pos前一块骨牌SLTNode* prev = *pphead;while (prev->next != pos){prev = prev->next;}// 4⃣ 调整骨牌的next指针// prev->newnode->posprev->next = newnode; // 前一块指向新骨牌newnode->next = pos; // 新骨牌指向目标位置}
}
首先检查万能修改笔和目标位置是否有效。如果插入位置是第一块骨牌(pos == *pphead),就直接调用头插函数。否则,创建新骨牌,然后找到pos前一块骨牌(prev)。接着调整next指针,将前一块指向新骨牌(prev->next = newnode),新骨牌指向pos(newnode->next = pos)。
2.9 在指定位置之后插入骨牌
【开始状态】(要在数字1后插入)+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^|pos【创建新骨牌】
+------+------+
| 1.5 | next |---> NULL <- 新骨牌
+------+------+【调整骨牌的next指针】+------+------+ +------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 1.5 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+ +------+------+^ ^ ^| | |pos newnode = pos->next pos原来的next = newnode->next
后插就是在指定骨牌后面插入新骨牌。这比前插简单,因为我们已经有了前一块骨牌(pos),只需调整next指针即可。
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{// 确保目标位置有效assert(pos);// 1⃣ 创建新骨牌SLTNode* newnode = SLTbuyNode(x);// 2⃣ 调整骨牌的next指针(这里顺序很重要!)// pos->newnode->pos原来指向的骨牌newnode->next = pos->next; // 先让新骨牌指向pos原来指向的骨牌pos->next = newnode; // 再让pos指向新骨牌
}
首先确保目标位置有效,然后创建新骨牌。接着调整next指针,将新骨牌的next指针指向pos原来指向的骨牌(newnode->next = pos->next),再将pos的next指针指向新骨牌(pos->next = newnode)。
为什么顺序很重要? 如果先修改pos->next再修改newnode->next,新骨牌就会指向自己,形成环!正确的顺序是:先连接新骨牌的下一步,再调整前面的连接。
2.10 删除指定位置的骨牌
【开始状态】(要删除数字2)+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^|pos【找到pos前一块骨牌】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^ ^ ^| | |prev pos pos->next【调整前一块骨牌的next指针,移除pos骨牌】+------+------+ +------+------+
🚩---> | 1 | next |----------------------------------------> | 3 | next |---> NULL+------+------+ +------+------+^ ^| |prev prev->next = pos->next💥 砰!💥 (pos骨牌被移除)
删除指定骨牌需要先找到它的前一块,然后调整前一块的next指针,跳过要删除的骨牌,直接指向后面的骨牌。如果要删除的是第一块骨牌,就变成了头删操作。
void SLTErase(SLTNode** pphead, SLTNode* pos)
{// 确保万能修改笔和目标位置都有效assert(pphead && pos);// 1⃣ 处理特殊情况:删除第一块骨牌if (pos == *pphead){// 直接调用头删函数SLTPopFront(pphead);}else {// 2⃣ 找到pos前一块骨牌SLTNode* prev = *pphead;while (prev->next != pos){prev = prev->next;}// 3⃣ 调整前一块骨牌的next指针// prev->pos->pos的下一块 变成 prev->pos的下一块prev->next = pos->next;// 4⃣ 移除pos骨牌free(pos); // 骨牌爆炸!💥pos = NULL; // 安全起见,将指针置空}
}
首先检查万能修改笔和目标位置是否有效。如果要删除的是第一块骨牌(pos == *pphead),就直接调用头删函数。否则,找到pos前一块骨牌(prev),调整其next指针指向pos后面的骨牌(prev->next = pos->next),然后释放pos骨牌。
2.11 删除指定位置后的骨牌
【开始状态】(要删除数字1后面的骨牌,即数字2)+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^ ^ ^| | |pos del del->next【找到要删除的骨牌】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^ ^| |pos del = pos->next【调整pos的next指针,移除del骨牌】+------+------+ +------+------+
🚩---> | 1 | next |----------------------------------------> | 3 | next |---> NULL+------+------+ +------+------+^ ^| |pos pos->next = del->next💥 砰!💥 (del骨牌被移除)
void SLTEraseAfter(SLTNode* pos)
{// 确保目标位置有效且其后有骨牌assert(pos && pos->next);// 1⃣ 记住要删除的骨牌SLTNode* del = pos->next;// 2⃣ 调整pos的next指针// pos->del->del的下一块 变成 pos->del的下一块pos->next = del->next;// 3⃣ 移除del骨牌free(del); // 骨牌爆炸!💥del = NULL; // 安全起见,将指针置空
}
首先确保目标位置有效且其后有骨牌。然后记住要删除的骨牌(del = pos->next)。接着调整pos的next指针,指向del后面的骨牌(pos->next = del->next)。最后释放del骨牌。
2.12 销毁整个骨牌系统
【开始状态】+------+------+ +------+------+ +------+------+
🚩---> | 1 | next |------------> | 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+ +------+------+^ ^ | | pcur next 【第一轮:释放第一块骨牌】💥 砰!💥 +------+------+ +------+------+| 2 | next |------------> | 3 | next |---> NULL+------+------+ +------+------+^ ^| |pcur next【第二轮:释放第二块骨牌】💥 砰!💥 +------+------+| 3 | next |---> NULL+------+------+^|pcur = next【第三轮:释放最后一块骨牌】💥 砰!💥pcur = NULL【使用万能修改笔修改起点标记】
🚩---> NULL (完全清空)
销毁就是从头到尾逐一释放所有骨牌。关键是在释放当前骨牌之前,先记住下一块骨牌的位置。最后,将起点标记指向NULL表示链表为空。
void SLTDestroy(SLTNode** pphead)
{assert(pphead);// 从第一块骨牌开始SLTNode* pcur = *pphead;// 遍历并释放所有骨牌while (pcur){// 记住下一块骨牌的位置SLTNode* next = pcur->next;// 释放当前骨牌free(pcur); // 骨牌爆炸!💥// 移动到下一块骨牌pcur = next;}// 使用万能修改笔将起点标记指向NULL*pphead = NULL;
}
首先确保万能修改笔有效。然后从第一块骨牌开始遍历链表。在每一轮循环中,先记住下一块骨牌的位置(next = pcur->next),然后释放当前骨牌(free(pcur)),最后移动到下一块骨牌(pcur = next)。循环直到处理完所有骨牌(pcur = NULL)。最后,使用万能修改笔将起点标记设为NULL,表示链表为空。
3.总结
时间复杂度特点
-
头部操作高效:所有头部操作(添加/删除)均为O(1)时间复杂度,无需遍历链表
-
尾部操作耗时:尾部操作(添加/删除)均为O(n)时间复杂度,需要遍历至链表末尾
-
已知位置优势:在已知节点位置时,操作效率高
-
已知节点后插入/删除:O(1)
-
已知节点前操作:需要找到前驱节点,仍为O(n)
-
-
查找操作线性:查找节点为O(n),必须从头开始顺序查找,无法随机访问
操作 | 时间复杂度 | 说明 |
---|---|---|
创建新节点 (SLTbuyNode) | O(1) | 只需分配内存并初始化一个节点 |
从头部添加节点 (SLTPushFront) | O(1) | 直接修改头指针和新节点指针 |
尾部添加节点 (SLTPushBack) | O(n) | 需要遍历找到最后一个节点 特殊情况:空链表时为O(1) |
头部移除节点 (SLTPopFront) | O(1) | 只需修改头指针并释放第一个节点 |
尾部移除节点 (SLTPopBack) | O(n) | 需要遍历找到倒数第二个节点 |
查找节点 (SLTFind) | O(n) | 最坏情况需要遍历整个链表 |
在指定位置前插入节点 (SLTInsert) | O(n) | 需要遍历找到前一个节点 特殊情况:在头部插入为O(1) |
在指定位置后插入节点 (SLTInsertAfter) | O(1) | 已有节点位置,直接修改指针 |
删除指定位置的节点 (SLTErase) | O(n) | 需要遍历找到前一个节点 特殊情况:删除头节点为O(1) |
删除指定位置后的节点 (SLTEraseAfter) | O(1) | 已有前一个节点位置,直接修改指针 |
销毁链表 (SLTDestroy) | O(n) | 需要遍历并释放整个链表的所有节点 |
结构特点
-
动态内存:链表节点动态分配内存,可根据需求增长或缩减
-
指针连接:节点通过next指针链接,形成线性结构
-
单向访问:单链表只能从头到尾单向访问
-
头指针关键:头指针是访问整个链表的唯一入口点
-
二级指针必要性:修改头指针需要使用二级指针,否则修改不会反映到主函数
优点
-
插入删除优势:已知位置时的插入删除非常高效,无需移动其他元素
-
内存利用灵活:不需要连续内存空间,适合动态变化的数据
-
查找劣势:不支持随机访问,查找效率低于数组
-
空间开销:每个节点需要额外的指针空间开销
单链表适合频繁在已知位置插入删除的场景,不适合需要随机访问或频繁查找的场景。