C语言实现链表

概念:链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 。

链表分为:

  • 带哨兵位/不带哨兵位

  • 双向/单向

  • 循环/不循环

 

单链表(不带哨兵位单向不循环链表)

typedef int SLTDataType;
typedef struct SListNode
{
    SLTDataType data;
    struct SListNode* next;	// 指向下一个节点,通过这个指针将这个节点与下个节点连接起来
}SLTnode;

单链表的常见函数接口实现.

// 创建节点,传入
SLTNode* CreateLTNode(SLTDataType x)
{
    SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));	// 通过malloc函数创建一个新的节点
    if(newnode == NULL)
    {
        perrot("malloc fail");
        return;
    }
    newnode->data = x;		// 对新创建的节点内的值进行初始化
    newnode->next = NULL;	// 对新创建的节点内的指针做初始化
    return newnode;
}

// 在链表头部插入数据。注意,因为我们需要在头部插入数据,即改变第一个节点的指向
int main()
{
    int x = 10;
    SLTNode* head = CreateLTNode(x);	// 此时head指向的就是存储数据10的那个节点
    SLPushFront(&head, 20);				// 头插之后,head就需要指向存储数据为20的那个节点
    // 想要改变一级指针的值,我们就需要传入二级指针,所以当函数有可能会改变head的指向时,就需要传入二级指针
}

void SLPushFront(SLTNode** phead, SLTDAtaType x)
{
    assert(phead);		// 检查传入的指针是否是有效指针
    SLTNode* newnode = CreateLTNode(x);
    newnode->data = x;
    newnode->next = NULL;
    
    newnode->next = *phead;		// 将老节点连接在新节点后面
    *phead = newnode;			// 更新头节点
}

// 在链表的尾部插入数据,如果链表为空,那么此时尾插就等同于头插。所以需要传入二级指针
void SLPushBack(SLTNode** phead, SLDataType x)
{
    assert(phead);
    SLTNode* newnode = CreateLTNode(x);
    if(*phead == NULL)			// 通过检查头节点是否位空来判断链表是否为空,为空则需要改变链表的头节点
    {
        *phead = newnode;
    }
    else
    {
        SLTNode* tail = *phead;		// 链表不为空,那么就遍历链表,找到链表的末尾去插入数据
        while(tail->next != NULL)	
        // 想要将节点链入,我们就需要在前一个节点的next之中,存入节点的地址,否则就无法将节点链入链表
        {							
            tail = tail->next;					
        }														
        tail->next = newnode;				
    }															
									
}
// while(tail != NULL)
// {
// 		tail = tail->next;
// }
// tail = newnode;
// SLTNode* tail = *phead;
// 此写法是错的,虽然看上去我们最后找到了那个尾节点,并将新节点链入链表了,但实际上,这种写法根本没有将新节点链入链表。

// 下图中tail指向的值是链表的最后一个节点,此时才能通过解引用去改变next指向.
// 否则,当tail为最后一个节点的next时,将tail的值改变并不会影响next.
// 最后,tail确实是最后一个节点的next,为NULL,但是tail并不是最后一个节点的next,tail是next的拷贝,他们是两个变量,只是值相同罢了。
// 我们需要改变的是最后一个节点的next,而不是next的拷贝。所以,tail需要保留的是最后一个节点的地址,通过地址解引用去改变next的指向。	。	

插入的示意图

 

// 删除链表尾部的数据,有可能链表只有一个数据了,那么删除尾部就是在删除头部数据。面说过,凡是需要修改头指针的行为,都需要传入二级指针
void SLPopBack(SLTNode** phead)
{
    assert(phead);
    assert(*phead);		// 检查链表是否为空,为空就不能删除了,不使用assert的话,也可以使用if语句来判断
    if((*phead->next) == NULL)
    {
        free(*phead);	// 释放空间,防止内存泄漏
        *phead = NULL;	// 如果只有一个数据,那么就需要将头指针指控,防止野指针
    }
    else
    {
        SLTNode* prev = NULL;		// 有多个数据,那么就需要遍历,找到最后一个节点,释放空间,但是如果只是释放了空间,
        SLTNode* tail = *phead;		// 那么前一个指针还会保留一个无效的地址,下一次遍历就会造成野指针问题,
        while(tail->next)			// 所以我们需要记录倒数第二个节点,并将倒数第二个节点的next置空: prev->next = NULL;
        {
            prev = tail;
            tail = tail->next;
        }
        free(tail);
        prev->next = NULL;
    }
}

// 删除链表头部数据,在头部删除,肯定需要改变头指针,所以也需要传入二级指针
void SLPopFront(SLTNode** phead)
{
    assert(phead);
    assert(*phead);		// 判断链表是否为空,为空则不需要进行删除
    SLTNode* next = (*phead)->next;	// 提前记录next,这样在释放空间以后,就不会出现找不到下一个节点的问题
    free(*phead);
    *phead = next;
}

// 在链表中查找是否存在某个值,存在则返回这个值所在节点的地址,否则返回NULL。因为只是查找,不需要改变链表,所以传一级指针即可
SLTNode* SLFind(SLTNode* phead, SLTDataType x)
{
    SLTNode* cur = phead;
    while(cur)					// 从头节点开始查找,遍历链表
    {
        if(cur->data == x)
        {
            return cur;
        }
        cur = cur->next;
    }
    return NULL;
}

// 再pos位置之前插入数据,既是插入,就有可能发生头插,所以须传入二级指针
void SLInsert(SLTNOde** phead, SLTNode* pos, SLTDataType x)
{
    assert(phead);
    assert(pos);	// 检查插入位置是否合法,排除NULL
    if(*phead == pos)	// 检查是否头插
    {
        SLPushFront(phead, x);	// 直接复用头插
    }
    else
    {
        SLTNode* prev = *phead;		// 如果不是头插,那么和查找的逻辑一样,只不过这次比较的不是里面的数据,而是地址,
        while(prev->next != pos)	// 同样,插入节点需要更新前一个节点的next,所以还需要记录前一个结点的地址
        {
            prev = prev->next;
        }
        SLTNode* newnode = CreateLTNode(x);
        prev->next = newnode;
        newnode->next = pos;
    }
    
}

// 删除pos位置的值,有可能发生头删,所以依旧传入二级指针
void SLErase(SLTNode** phead, SLTNode* pos)
{
    assert(pphead);
    assert(pos);	// 检查删除位置的合法性,排除NULL
    if(pos == *phead)	// 删除位置为头节点,直接复用头删
    {
        SLPopFront(phead);
    }
    else
    {
        SLTNode* prev = *phead;		// 与插入一样,需要先找到这个节点,通过地址比较寻找,
        while(prev->next != pos)	// 同样,在删除之后,还需要将被删除节点的前后指针连接起来,否则会造成数据丢失
        {							// 寻找删除节点的前一个结点
            prev = prev->next;
        }
        prev->next = pos->next;		// 将前一个指针指向被删除节点的后一个指针
        free(pos);
    }
}

 

 

带头双向循环链表

typedef int LTDataType;
typedef struct ListNode
{
    struct ListNode* next;	// 指向下一个节点
    struct ListNode* prev;	// 指向前一个结点
    LTDataType data;
}LTNode;

int main()
{
    LTNode* head = Init();	// 这个就是哨兵位,这个节点并不存储数据,
    						// 只是为了方便查找,插入等操作
}
// 有了哨兵位之后,想要进行头插,头删等操作时,就没有那么多种情况了,无论怎么插入删除,都不会改变哨	 	兵位,插入删除都是在head之后举行的
// 从下面这个图中可以看出,这个链表的不再是只能从一个方向遍历了,再任意一个节点都可以向前或向后遍历
// head与链表的最后一个节点相连接,当我们需要进行尾插时,就不需要在去遍历链表了,而是直接通过head来  	访问

 一些函数接口实现:

// 初始化链表,主要是创建哨兵位
LTNode* Init()
{
    LTNode* newnode = (LTNode*)malloc(sizeof(LTNOde));
    newnode->prev = newnode;
    newnode->next = newnode;
    return newnode;
}

// 创建链表节点
LTNode* CreatNode(LTDataType x)
{
    LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
    if(newnode == NULL)
    {
        perror("malloc fail");
        return NULL;
    }
    newnode->data = x;
    newnode->next = NULL;	// 置空,防止野指针问题
    newnode->prev = NULL;
    return LTNode;
}

// 在链表末尾插入数据,尾部与head相连,所以不需要再去遍历找尾
void LTPushBack(LTNode* phead, LTDataType x)
{
    assert(phead);
    LTNode* newnode = CreatNode(x);	// 插入数据时,一定要注意,节点之间的连接关系
    newnode->next = phead;		// 在进行断开连接(改变指针指向)时,要注意先进行连接的建立
    phead->prev->next = newnode;// 否则,断开连接之后,我们会与节点失联,这样也就无法继续接
    newnode->prev = phead->prev;// 下来的操作。
    phead->prev = newnode;		// 如: 先执行 phead->prev = newnode;
    							// 那么此时,head与链表的最后一个节点连接断开,但是,新插入
    							// 的节点还没有与前最后节点建立联系,就会导致链表结构错误
}
// 如下图,phead->prev改变之后,phead->prev也就指向了newnode,
// phead->prev->next = newnode;
// 即->newnode->next = newnode;
// 此时连接就发生了错误,简单一点的方法就是建立一个对象,用来记录tail的地址
// LTNode* tail = phead->prev; 这样就不用关注先断哪个连接,先建立哪个连接了

 插入示意图: 

// 头插,过程与尾插差不多,具体就不赘述了
void LTPushFront(LTNode* phead, LTDataType x)
{
    assert(phead);
    LTNode* newnode = CreatNode(x);
    newnode->next = phead->next;
    newnode->prev = phead;
    phead->next->prev = newnode;
    phead->next = newnode;
}

// 判断链表是否为空,只要判断head的next指向的是不是自己就行了
bool LTEmpty(LTNode* phead)
{
    if(phead->next == phead)
    {
        return true;
	}
    else
    {
        return false;
    }
}

// 尾删,删除也变得简单,现在可以直接通过prev指针找到前一个节点,我们不用在自己记录了
void LTPopBack(LTNode* phead)
{
    assert(phead);
    assert(!LTEmpty(phead));
    LTNode* node = phead->prev;	// 临时对象,记录要删除数据地址
    phead->prev = node->prev;	// 然后重新建立连接
    phead->prev->next = phead;	
    free(node);					// 最后销毁节点,释放空间
}

// 头删,和尾删差不多。
void LTPopFront(LTNode* phead)
{
    assert(phead);
    assert(!LTEmpty(phead));
    LTNode* first = phead->next;	// 要删除的节点
    LTNode* second = first->next;	// 被删除结点的下一个节点
	phead->next = second;			// 重新映射
    second->prev = phead;
    free(first);					// 释放空间
}

// 插入,在pos位置前面插入。
// 由此可以看出,双向链表的插入不用区分是否是空链表,即无所谓是否是头插等
// 头插完全可以调用这个函数
void LTInsert(LTNode* pos, LTDataType x)
{
    assert(pos);
    LTNode* prev = pos->prev;	// 记录pos的前一个节点
    LTNode* newnode = CreatNode(x);
    
    prev->next = newnode;	// 重新映射关系
    newnode->prev = prev;
    node->next = pos;
    pos->prev = newnode;
}

// 删除,删除pos位置
// 删除也是一样,无论删除哪个位置,逻辑与过程都一样
// 头删也可以直接调用这个函数
void LTErase(LTNode* pos)
{
    assert(pos);
    LTNode* posprev = pos->prev;
    LTNode* posnext = pos->next;
    posprev->next = posnext;
    posnext->prev = posprev;
    free(pos);
}

// 最后,当链表使用完成之后,要将链表继续销毁,释放空间,防止内存泄漏
void Destroy(LTNode* phead)
{
    assert(phead);
    LTNode* cur = phead->next;
    while(cur != phead)
    {
        LTNode* next = cur->next;
        free(cur);
        cur = next;
    }
    free(phead);
}

 

顺序表链表异同

不同点顺序表链表
存储空间上物理上一定连续逻辑上连续,但物理上不一定连续
随机访问支持O(1)不支持:O(N)
任意位置插入或删除元素可能需要移动元素,效率低O(N)只需要修改指针指向
插入动态顺序表,空间不够时需要扩容没有容量的概念
引用场景元素高效存储+频繁访问任意位置插入和删除频繁
缓存利用率

链表:

优点:任意位置插入删除O(1)。按需申请空间

缺点:

  • 不支持下标的随机访问。
  • 缓存的命中率低。

顺序表:

缺点:

  • 前面部分的插入删除数据,效率是O(N),需要挪动数据。
  • 空间不够,需要扩容:扩容需要付出代价的,一般还会伴随空间浪费。

优点:

  • 尾插尾删效率不错。
  • 下标的随机访问。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值