【数据结构】线性表—(静态)链表

本来要先讲数组的,介于之前已经总结过可变数组vector了,故不再开一个专题去介绍用法和原理。但是要提一嘴:

数组作为数据结构可以高效地存储和查询给定索引(下标)的数据,其时间复杂度均为O(1),因为这个性质,数组可以用来模拟其他很多数据结构,但是如果要将整个数组进行移位操作,例如在中间插入和删除数据,或者在没排序的情况下搜索指定元素,那么时间复杂度可达到O(n),效率很低的。

那么,我们就从链表开始学习数据结构吧!

链表是一种物理存储单元上非连续、非顺序的存储结构数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。但是我们这里先介绍静态链表(由数组模拟链表)以先了解链表的各项操作。简单来说:链表能知道每个元素之前/之后是谁,这样能恢复整个表的排列顺序。利用这种方式,来存储元素排列顺序的表,称为链表。

单链表由三个重要部分组成,next指针,value,idx指针,head指针分别用来记录后续节点的下标,当前节点的值,标明使用节点的指针,头指针。

struct ListNode {
       int next;
       int val;
       ListNode(int _val=0,int _next=0) //初始化
       {next=_next;val=_val;}
};

ListNode Node[MAXSIZE];	
int idx;      //当前用的元素的指针
//idx在我看来扮演两个角色,第一个是在一开始的时候,作为链表的下标,让我们好找
    //第二在链表进行各种插入,删除等操作的时候,作为一个临时的辅助性的所要操作的元素的下
    //标来帮助操作。并且是在每一次插入操作的时候,给插入元素一个下标,给他一个窝,感动!
    /*
    再次插句话,虽然我们在进行各种操作的时候,元素所在的下标看上去很乱,但是当我们访问的
    时候,是靠着指针,也就是靠Node[].next来访问的,这样下标乱,也就我们要做的事不相关了。
    另外,我们遍历链表的时候也是这样,靠的是Node[].next
    */
int head=-1; //初始化头指针,头指针初始时指向-1,之后指向插入的第一个元素

现在,我们来学习链表的基本操作:

首先,如何把一个元素插入头节点后面呢?

1.头结点后面添加元素:

  • 在val的idx处存储元素val[idx] = x;
  • 该元素插入到头结点后面 next[idx] = head;
  • 头结点指向该元素 head = idx;
  • idx 指向下一个可存储元素的位置 idx++。

翻译成代码

void add_to_head (int x) {
    Node[idx].val=x,Node[idx].next=head,head=idx++;
}//插入头节点的操作
//如何理解Node[idx].next=head呢?首先初始时head指向-1,我们插入一个新的元素,下一个元素也要指向-1,然后head指向idx的元素

图解:

2.在索引k的数字后插入元素

  • 在e的idx处存储元素val[idx] = x
  • 该元素插入到第k个插入的数后面 next[idx] = next[k];
  • 第k个插入的数指向该元素 next[k] = idx;
  • idx 指向下一个可存储元素的位置 idx++。

代码实现如下

void add(int k,int x) {
    Node[idx].val=x,Node[idx].next=Node[k].next,Node[k].next=idx++;
}//插入第k个节点的操作

图解:

3.删除索引为k的元素后的元素

  • next[k] 的值更新为 next[next[k]]

代码实现

void del(int k) {
    Node[k].next = Node[Node[k].next].next;
}//删除操作

图解:

现在就来附上这道题的AC代码了:

https://www.acwing.com/file_system/file/content/whole/index/content/4008335/

#include<iostream>
using namespace std;

struct ListNode {
       int next;
       int val;
       ListNode(int _val=0,int _next=0) //初始化
       {next=_next;val=_val;}
};

ListNode Node[100010];
int idx;      //当前用的元素的指针
int head=-1; //初始化头指针

void add_to_head (int x) {
    Node[idx].val=x,Node[idx].next=head,head=idx++;
}//插入头节点的操作

void add(int k,int x) {
    Node[idx].val=x,Node[idx].next=Node[k].next,Node[k].next=idx++;
}//插入第k个节点的操作

void del(int k) {
    Node[k].next = Node[Node[k].next].next;
}//删除操作

int main() {
    int m;
cin >> m;

for (int i = 0; i < m; i++) {
    char g;
    cin >> g;
    if (g == 'H') {
        int x;
        cin >> x;
        add_to_head(x);
    }
    else if (g == 'D') {
            int k;
            cin >> k;
            if (!k) head = Node[head].next;
            else del(k - 1); //为什么k-1呢,因为idx初始化为0
    }
    else if (g == 'I') {
            int k, x;
            cin >> k >> x;
            add(k - 1, x);
    }

}
    for(int i=head;i!=-1;i=Node[i].next) cout << Node[i].val << " "; //遍历操作

    return 0;
}

某些令人疑惑的问题

1、对于删除头节点:head=Node[head].next的操作?
删除头结点是head指向的结点,也就是链表中的第一个结点,【head指向可能是一个空结点(val数组不存值),或者是非空结点。指向的空结点叫头结点,指向的非空结点叫首元结点,即后者并没有头结点这种概念】(这句只针对学过单链表的同学,没学过的同学不用理会这句话)。但是y总并没有区分这个概念,所以删除头结点就是删除链表的第一个有值的结点,即首元结点,head指向Node[head].next是因为head本来就指向它的下一个结点,所以next[head]就是头结点的下一个结点。

2、为什么最后一个节点的Node[idx].next一定等于-1?
首先这个head指的是链表中头节点的下标,当链表中没有节点时head = -1,但当链表中有值插到头节点的时候,head储存的就是这个值的idx1,通过val[idx1]可以求出这个节点的值。而next[idx1]就等于head之前的值,即-1。如果再在头节点插入一个元素,则head指向这个元素的idx2,而next[idx2]就等于上一次插入的head值,即idx1。此时,head = idx2,next[idx2] = idx1,next[idx1] = -1.

比如:因为初始化head=-1,head指向链表的头节点地址;例如输入H 9后,则有val[0] = 9; next[0] = -1;head=0;之后无论进行什么操作,链表最后一个节点,其对应的next数组值一定为-1。

 

双链表:

每个结点记录自己的前驱和后驱,与单链表只能往后走相比,它的好处是可以往前走和向后走,增加了前缀结点,一定程度上提升了查找元素的速度   在查找元素时,可以反向查找前缀结点。

const int MAXSIZE = 1e5;
struct ListNode {
    int pre,next;
    int val;
    ListNode (int _val=0,int _pre=0,int _next=0)
    {pre=_pre;next=_next;val=_val;}
};
ListNode Node[MAXSIZE];

int head=0;
int tail=MAXSIZE-1;
int idx;

首先先初始化双链表:

void init(){
    Node[head].next = tail;
    Node[tail].pre = head;
     //这两个点是假想出来的,是为了方便边界处理,要不然又要写一个函数专门处理头和尾
     //因为头和尾只有一边链子 
     idx=1;// 0地址已经被用过,从1开
}

现在来介绍双链表的一些基本操作

 

1.往索引为k的右边插入新结点

直接上图解吧:

这四个步骤的顺序可调整,但是必须要保证需要的链不断!!比如,如果先做④再做①,画红叉的链断了,p->next为新的结点node了,那么node又怎么和后面的接结点连上呢?后面的结点已经不能通过p来找到了。

代码实现如下:

void rightInsert(int k, int x){
    Node[idx].val = x;
    Node[idx].next = Node[k].next;
    Node[idx].pre = k;
    Node[Node[k].next].pre = idx;
    Node[k].next = idx;
    idx++;
}

 

2.在索引为k的左边插入新结点

可以不用重新写一个函数,直接在k左边的结点进行往右边的插入就可以了

rightInsert(Node[k].pre,x)

当然,为了锻炼代码能力,我也写一个leftInsert函数吧

图解如下:

代码实现如下:

void leftInsert(int k,int x) {
    Node[idx].val=x;
    Node[idx].next = k;
    Node[Node[k].pre].next=k;
    Node[idx].pre=Node[k].pre;
    Node[k].pre=idx;
    idx++;
}

 

3.删除操作

图解如下:

代码实现如下:

void del(int k) {
    Node[Node[k].pre].next=Node[k].next;
    Node[Node[k].next].pre=Node[k].pre;
}

 

现在我们给出这一题的AC代码:

https://www.acwing.com/problem/content/description/829/

#include<iostream>
using namespace std;

const int MAXSIZE = 1e5+10;
struct ListNode {
    int pre,next;
    int val;
    ListNode (int _val=0,int _pre=0,int _next=0)
    {pre=_pre;next=_next;val=_val;}
};
ListNode Node[MAXSIZE];

int head=0;
int tail=MAXSIZE-1;
int idx;

void init(){
    Node[head].next = tail;
    Node[tail].pre = head;
     //这两个点是假想出来的,是为了方便边界处理,要不然又要写一个函数专门处理头和尾
     //因为头和尾只有一边链子 
     idx=1;// 0地址已经被用过,从1开
}

void rightInsert(int k, int x){
    Node[idx].val = x;
    Node[idx].next = Node[k].next;
    Node[idx].pre = k;
    Node[Node[k].next].pre = idx;
    Node[k].next = idx;
    idx++;
}

void remove(int k) {
    Node[Node[k].pre].next=Node[k].next;
    Node[Node[k].next].pre=Node[k].pre;
}

int main(){
    int m;
    char op[5];
    int k, num;

    init();
    cin>>m;
    while(m--){
        scanf("%s", op);
        if(string(op) == "R"){
            cin>>num;
            rightInsert(Node[tail].pre, num);
        }else if(string(op) == "D"){
            cin>>k;
            remove(k);
        }else if(string(op) == "L"){
            cin>>num;
            rightInsert(head, num);
        }else if(string(op) == "IL"){
            cin>>k>>num;
            rightInsert(Node[k].pre, num);
        }else if(string(op) == "IR"){
            cin>>k>>num;
            rightInsert(k, num);
        }
    }

    int p = Node[head].next;
    while(p != tail){
        cout<<Node[p].val<<" ";
        p = Node[p].next;
    }

    return 0;
}

 

一些链表的常见操作补充:

(找个时间来写) 鸽鸽鸽~ Dec 27,2023

 

STL中的链表

链表一样可以使用STL来简化操作,只需要#include<list>

以下是一些常用的方法

1)list<int>a;:定义一个int类型的链表a
2int arr[5]={1,2,3};list<int> a(arr,arr+3);;从数组arr中的前三个元素作为链表a的初始值
3)a.size();:返回链表的结点数量
4)list<int>::iterator it;:定义名字为it的迭代器
5)a.begin();a.end();:链表开始和末尾的迭代器指针
6)it++;it--;:迭代器分别指向后一个元素和前一个元素
7)a.pop_front();a.pop_back();:在链表开头或者末尾删除元素
8)a.insert(it,x);:在迭代器it前插入元素x
9)a.push_front(x);a.push_back(x);:在链表开头或者末尾插入元素x
10)a.erase(it);:删除迭代器it所指向的元素
11for (it=a.begin();i!=a.end();i++):遍历链表

 

为什么不用课本上学的结构体来构造链表??

学过数据结构课的人,对链表的第一反应就是:

链表由节点构成,每个节点保存了 值 和 下一个元素的位置 这两个信息。节点的表示形式如下:

class Node{
public:
    int val;
    Node* next;
};

这样构造出链表节点的是一个好方法,也是许多人一开始就学到的。

使用这种方法,在创建一个值为 x 新节点的时候,语法是:

Node* node = new Node();
node->val = x


看一下创建新节点的代码的第一行:

Node* node = new Node();,中间有一个 new 关键字来为新对象分配空间。

new的底层涉及内存分配,调用构造函数,指针转换等多种复杂且费时的操作。一秒大概能new1w次左右。

在平时的工程代码中,不会涉及上万次的new操作,所以这种结构是一种 见代码知意 的好结构。

但是在算法比赛中,经常碰到操作在10w级别的链表操作,如果使用结构体这种操作,是无法在算法规定时间完成的。

所以,在算法比赛这种有严格的时间要求的环境中,不能频繁使用new操作。也就不能使用结构体来实现数组。

当然,有时间我也会把动态链表学一遍的

posted @   綾川雪絵  阅读(51)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示