《数据结构》--链表【包含跳表概念】

不知道大家对链表熟悉还是陌生,我们秉着基础不牢,地动山摇的原则,会一点点的介绍链表的,毕竟链表涉及的链式存储也很重要的。在这之前,我们认识过顺序存储的顺序表,它其实就是一个特殊的数组。那链表到底是什么?又有什么用呢?与顺序结构有什么不一样呢?

一、认识链表

第一点:链表是什么?

链表是一种数据结构,用于以线性的方式存储一组数据。它与数组不同,链表的大小可以动态调整,且它的元素(称为节点),在内存中不必是连续存储的。每个节点包括两部分:数据域与指针域。

链表的基本结构:

头节点->节点1->节点2->节点3->尾节点

链表的分类:

根据不同的分类标准分为:单向和双向循环与不循环带不带头节点(又称哨兵节点)

常见的链表是:不带头节点的单向非循环链表带头结点的双向循环链表

链表的优点:

动态大小:方便插入和删除节点,适合频繁的插入和删除操作。

链表的缺点:

随机访问效率低,必须从头节点开始逐一遍历

每个节点需要额外的存储指针,空间开销较大

第二点:链表有什么用?

一方面,由于是动态大小,内存不必连续,这就可以充分利用碎片内存,对内存的利用率高。

另一方面,链式存储的概念在实现其它数据结构(栈和队列)以及相关算法方面有着很大的用途。

第三点:与顺序结构有什么不同?

1.存储方式:

顺序存储(数组):内存连续,通过下标直接访问元素,效率高。

链式存储(链表):内存不连续,每个节点包含数据域和指针域,节点间由指针相互链接。

2.动态大小:

顺序:大小在创建时确定,扩展和缩减不方便,需要重新分配内存进行扩容。

链式:大小可以动态变化,插入和删除节点时不需要移动其它元素,只需要改变指针域的指针指向

3.访问效率:

顺序:支持随机访问,时间复杂度O(1)

链式:只能从头遍历,时间复杂度O(n)

4.修改操作:

顺序:插入和删除需要移动大量数据,时间复杂度O(n)

链式:插入和操作只要调整指针指向,时间复杂度O(1)

5.内存占用:

顺序:由于是连续存储,内存发呢哦欸相对简单,但可能造成内存浪费(预留空间)。

链式:每个节点需要额外的存储空间来保存指针,整体的开销相对较大。

6.适用场景:

顺序存储:适合频繁访问的场景:查找、索引

链式存储:适合频繁插入和删除的场景:例如:栈、队列


 二、链表的API

基础操作:(数据结构)

增、删、查、(改)

增--插入:在链表的指定位置添加一个节点

删--删除:删除指定位置的一个节点

查--搜索:查找链表中特定位置的值、查找节点中是否中存在某值

遍--遍历:按顺序访问链表的所有节点、获取链表的长度

进阶操作:(算法提高)

合并两个有序链表:给出两个有序链表,现在要求能够合并两个链表,新的链表仍然有序

原地反转单向链表:给出一个单向链表,现在要求能够将链表反过来,要求不增加新链表

判断链表是否有环:给出一个单向链表,判断这个链表中是否存在环,没有什么特殊要求


 三、C语言实现链表

 这里不使用C++是后面我们还会用C++模拟实现STL中的list容器(有点恼火,估计要磨几天)。

单链表实现

定义链表节点

typedef int T;//T是int的别名,方便之后使用其它类型数据,增加可维护性
typedef struct ListNode {
	T data;
	struct ListNode* next;
}Node;

 创建新节点

Node* CreateNode(T _val)
{
    //使用malloc在堆区申请一个节点空间
    Node* new_node = (Node*)malloc(sizeof(Node));
    assert(new_node);
    
    new_node->data=_val;
    new_node->next=NULL;
    return new_node;
}

注意1:如果不在堆区申请空间,而是直接定义一个指针,那么当函数返回时,指针就会被自动释放,因为栈区的变量的生命周期就是定义开始到作用域结束。这样就导致后面访问时虽然还是那个地址,不过属于非法访问了已经。

注意2:assert()是断言函数,用来判断表达式是否为真,如果不为真,就强制结束程序。在这进行判断是为了保证申请空间成功。如果失败就不要后续的非法访问了。

尾插

void insert_tail(Node** pphead, T _val)
{
    assert(pphead);
    if(*pphead==NULL){//如果是空链表
        *pphead=CreateNode(_val);
        return;
    }
    //如果还没返回,那就是非空链表,在后面插入
    Node* tail=*pphead;
    while(tail->next){
        tail=tail->next;
    }
    //找到了最后一个节点,在最后一个节点后面插入一个节点
    tail->next=CreateNode(_val);
}

注意1:尾插是在链表的最后一个节点后面进行插入。需要考虑这个链表是空链表还是非空链表。如果是空链表,那么我将新节点当成头节点就成了。如果是非空链表,我需要遍历到尾节点处,尾节点的特点是next指针是空,那么我不知道具体循环多少次就可以使用while循环。当找到了尾节点,我让尾节点的next指针指向新的节点那么我的插入操作就完成了。

注意2:传入的参数是二级指针。why?首先链表的头本身是一个指针,然后我想要在这个链表上进行修改的操作,那就需要传入这个指针变量的指针。

例如:phead是一个指向链表头节点的指针,只不过此时存储的值是NULL。右面是新建的节点。

假设红色的是函数的一级指针参数, 我们想要插入一个节点,我们其实是想让phead指向这个新创建的节点(0x0001),也就是让phead的值改为0x0001。但我们的函数与phead没有任何的关系,只有_phead的值与phead的值一样,假如我让_phead的值为0x0001,那么_phead指向了新节点,一旦函数结束,形参的生命周期结束,这个指向关系将会终止,连新建的节点都不知道怎么去访问了。造成了内存泄露。

总结:头节点传二级指针,判断链表是否为空,寻找尾节点是看尾节点的next是否指向NULL

头插

void insert_head(Node** pphead,T _val)
{
    assert(pphead);

    Node* newnode = CreateNode(_val);
    newnode->next=*pphead;
    *pphead=newnode;
}

头插比较简单,不需要考虑是否为空,不管是不是空都是在头部插入。

注意1:创建完节点后,不要让头指针直接指向新节点,要先让新节点的next指针指向头指针,然后再让头指针指向新节点。不然原来的链表的地址将会丢失。 

 尾删

void del_tail(Node** pphead)
{
    assert(pphead && (*pphead));

    Node* tail=*pphead;
    if(tail->next==NULL){//如果只有一个节点
        free(*pphead);
        *pphead=NULL;
        return;
    }

    while(tail->next->next){
        tail=tail->next;
    }
    free(tail->next);
    tail->=NULL;
}

注意1:头指针不能为空,而且头指针指向的节点也不能为空。

注意2:我们要删除尾节点,那么就是将我们尾节点释放并将前一个节点的next指针置空。找到尾节点时,我们尾节点的前一个节点的状态是什么样子的呢?cur->next就指向tail,而tail->next就指向NULL,所以不妨直接将循环截至条件设置为cur->next->next==NULL时截至,然后释放cur->next也就是tail,再将cur->next置空NULL。

注意3:要完成注意2的前提下,我们需要判断尾节点是否有前一个节点,也就是是否只有一个节点。如果只有一个节点,我们将头节点释放置空就完事了。 

 头删

void del_head(Node** pphead)
{
    assert(pphead && (*pphead));

    Node* tmp_node=(*pphead)->next;
    free(*pphead);
    *pphead=tmp_node;
}

注意1:解引用符*与访问符->的优先级的问题。需要我们将*pphead整体括起来。

注意2:先存储后释放,再重设头节点。 

遍历与查找

Node* find(Node* phead,T _val)
{
    Node* cur=phead;
    while(cur){
        if(cur->data == _val){
            return cur;
        }
        cur=cur->next;
    }
    return NULL;
}

 直接循环完事了,对于不用修改操作的函数,用二级指针的必要都没有,循环遍历,如果头指针为空,那么循环都进不去,返回NULL。

在第i个位置插入节点

void insert(Node** pphead,int i,T _val){
    assert(pphead);
    assert(i>=0&&i<length(*pphead));//int length(Node* phead),遍历计数实现
    if(i==0){
        insert_head(pphead,_val);    
        return;
    }
    Node* new_node =CreateNode(_val);//新建一个待插入节点
    Node* cur=*pphead;i--;
    while(i--){//cur遍历到第i-1个位置
        cur = cur->next;
    }
    new_node->next=cur->next;//将第i个节点放在后面,
    cur->next=new_node;//连接上前面的节点,加入节点  
}

删除第i个位置节点

void del(Node* pphead,int i)
{
    assert(pphead&&(*pphead));
    assert(i>=0&&i<length(*phead));
    if(i==0){
        del_head(pphead);
        return;
    }
    if(i==length(*phead)){
        del_tail(pphead);
        return;
    }
    
    Node* cur=*pphead;i--;
    while(i--){//找到第i-1个节点
        cur=cur->next;
    }
    Node* tmp_node=cur->next;
    cur->next=cur->next->next;//如果上面不判断i==length,那么就不会有cur->next的存在
    free(tmp_node);
}

双链表实现

双链表节点定义:

typedef int T;

typedef struct TwoListNode{
    T data;
    struct TwoListNode* prev;//前驱节点指针
    struct TwoListNode* next;//后继节点指针
}TNode;

其他的操作类似,此处不再赘述。 

算法题练习

//Definition for singly-linked list.
struct ListNode {
    int val;
     ListNode *next;
     ListNode(int x) : val(x), next(NULL) {}
};

合并链表

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

(双指针算法:基础算法--双指针【概念+图解+题解+解释】-CSDN博客

class Solution {
public:
	ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
		ListNode* tmp = new ListNode;
		ListNode* ans = tmp;
		while (list1 != nullptr && list2 != nullptr) {
			tmp->next = new ListNode;
			tmp = tmp->next;
			if (list1->val < list2->val) {
				tmp->val = list1->val;
				list1 = list1->next;
			}
			else{
				tmp->val = list2->val;
				list2 = list2->next;
			}
		}
		while (list1 != nullptr) {
			tmp->next = new ListNode;
			tmp = tmp->next;
			tmp->val = list1->val;
			list1 = list1->next;
		}
		while (list2 != nullptr) {
			tmp->next = new ListNode;
			tmp = tmp->next;
			tmp->val = list2->val;
			list2 = list2->next;
		}
		return ans->next;
	}
};

反转链表

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

 

(递归算法:基础算法--递归算法【难点、重点】-CSDN博客) 

class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        if (!head || !head->next) {
            return head;
        }
        ListNode* second = head->next;
        ListNode* newHead = reverseList(second);
        second->next = head;
        head->next = nullptr;
        return newHead;
    }
};

环形链表

给定一个链表的头节点  head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

不允许修改 链表。

(双指针算法:基础算法--双指针【概念+图解+题解+解释】-CSDN博客

class Solution {
public:
    ListNode *detectCycle(ListNode *head) {
        if(head==nullptr)return nullptr;
        ListNode* fast = head->next;
        ListNode* slow = head;
        while (fast != nullptr && fast->next != nullptr) {//确保快指针能走两步
            if (fast == slow) {
                //如果快慢指针相等了,说明是环,进行环的首节点锁定操作
                while (head != slow->next) {
                    slow = slow->next;
                    head = head->next;
                }
                return head;
            }
            
            fast = fast->next->next;//快指针走两步
            slow = slow->next;//慢指针走一步
        }
        return nullptr;
    }
};

*跳表

从第一节的对比中可以看出,链表虽然通过增加指针域提升了自由度,但是却导致数据的查询效率恶化。特别是当链表长度很长时,对数据的查询还得从头依次查询,这样效率会很低。跳表的产生就是为了解决链表过长的问题,通过增加链表的多级索引来加快原始链表的查询效率。这样的方式可以让查询的时间复杂度从O(n)提升到O(logn)

跳表通过增加的多级索引能够实现高效的动态插入和删除,其效率和红黑树平衡二叉树不相上下。目前redislevelDB都有用到跳表。

从上图可以看出,索引级的指针域除了指向下一个索引的next指针,还有一个down指针指向低一级的链表位置,这样才能实现跳跃查询的目的。

评论 18
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值