白话数据结构之基本概念篇(3)_数组&链表

本文详细介绍了数据结构的基础——数组和链表。数组提供随机访问,但插入删除效率低,链表反之,适用于动态扩容。文中讲解了数组的寻址公式、插入删除操作,链表的结构和操作,包括单链表、双向链表、循环链表的特性,以及它们在性能上的比较。文章还探讨了链表编码技巧和内存管理注意事项,旨在帮助读者深入理解这两种重要数据结构。
摘要由CSDN通过智能技术生成

数据结构与算法学习笔记系列文章目录

基本概念篇
1. 入门概述
2. 复杂度
3. 数组&链表
4. 栈&堆
5. 排序算法
6. 查找算法

编程思想篇

实际问题篇
1. 约瑟夫环


一、前言

本节介绍一下最基础的两个数据结构:数组、链表。
后续的其他数据结构都是可以使用它们来进行表示的。


二、前置条件

C语言基础


三、本文参考资料

《大话数据结构》
《数据结构与算法之美》
百度
《果果带你写链表,小学生看了都说好!》


四、正文部分

4.1 数组

4.1.1 数组的基本概念

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

  • 优点:“随机访问”。
  • 缺点:数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作,非常低效
  • 注意事项:
    数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)
    数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。
    即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。
     

4.1.2 数组的寻址公式

我们拿一个长度为 10 的 int 类型的数组 int[] a = new int[10]来举例。
图中,计算机给数组 a[10],分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。
在这里插入图片描述
计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。
当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:

	a[i]_address = base_address + i * data_type_size

其中 data_type_size 表示数组中每个元素的大小。
我们举的这个例子里,数组中存储的是 int 类型数据,所以 data_type_size 就为 4 个字节。

这里也同样解释了为什么数组下标需要从0开始而不是从1开始:

  1. 历史原因
    C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。
    实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。

  2. 从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。

     a[k]_address = base_address + (k-1)*type_size
    

 

4.1.3 数组的相关操作

  • 插入
    如果在数组的末尾插入元素,那就不需要移动数据了,这时的时间复杂度为 O(1)。
    但如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是 O(n)。
    因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1+2+…n)/n=O(n)。

    在特定场景下(如无序时插入),在第 k 个位置插入一个元素的时间复杂度(直接将原位置的值与插入的值交换)就会降为 O(1)。

  • 删除
    如果删除数组末尾的数据,则最好情况时间复杂度为 O(1);
    如果删除开头的数据,则最坏情况时间复杂度为 O(n);
    平均情况时间复杂度也为 O(n)。

    我们可以先记录下已经删除的数据。
    每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。
    当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
     

4.1.4 数组的访问越界问题

在这里插入图片描述
实际操作时发现不同编译器的允许得到的结果不一样
 

4.1.5 二维数组内存寻址公式

a[i][j]
	address = base_address + ( i * n + j) * type_size
		a[0][0] a[0][1] ... a[0][j-1]
		a[1][0] a[1][1] ...
		...
		a[j][0] a[j][1] ... a[i-1/**8][j-1]

在这里插入图片描述
 

4.1.6 ArrayList

ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。
比如前面提到的数组插入、删除数据时需要搬移其他数据等。
另外,它还有一个优势,就是支持动态扩容。

如果使用 ArrayList,我们就完全不需要关心底层的扩容逻辑,ArrayList 已经帮我们实现好了。
每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍大小。
因为扩容操作涉及内存申请和数据搬移,是比较耗时的。
所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。

  1. Java ArrayList 无法存储基本类型,
    比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,
    所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。

  2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。

  3. 当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList<arraylist>
     

4.2 链表

4.2.1 链表的基本概念

链表并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是 100MB 大小的链表,根本不会有问题。
在这里插入图片描述

  • 单链表
    我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。
    其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。
    而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。
    在这里插入图片描述
    在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。
    所以,在链表中插入和删除一个数据是非常快速的。
    从图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)。
    在这里插入图片描述
    链表要想随机访问第 k 个元素,就没有数组那么高效了。
    因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。
    所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。

  • 循环链表
    在这里插入图片描述
    循环链表的优点是从链尾到链头比较方便。
    当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题。
    尽管用单链表也可以实现,但是用循环链表实现的话,代码就会简洁很多。

  • 双向链表
    在这里插入图片描述
    双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。
    所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。
    虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。

    从结构上来看,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。

    • 删除给定指针指向的结点
      已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。
      但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。
      所以,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!

    • 在链表的某个指定结点前面插入一个结点
      双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。

    • 按值查询
      可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。

  • 双向循环链表
    在这里插入图片描述

对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;
而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。

 

4.2.2 链表操作

  • 核心知识:
    这个链表结构体里面有一个指针,这个指针,等于其他结构体的地址。即结构体A里面的某一个指针,指向结构体B。
4.2.2.1 初始化

在这里插入图片描述

在这里插入图片描述

4.2.2.2 插入

可以在链表的头部插入新的节点,也可以在列表的尾部插入新的节点。
在这里插入图片描述

  1. 在头部插入

    void InsertNodeToHead(spy newNode)
    {
     newNode->next_addr = pHead;//新插入节点的下一个节点为空
     pHead = newNode; //头结点指向新插入的节点
    }
    

    在这里插入图片描述
    在这里插入图片描述

  2. 在尾部插入
    在这里插入图片描述
    tmp假设是最后一个元素,B是新元素

    tmp->next.addr=&B;
    B.next addr = NULL;
    

    找出最后一个元素

    void InsertNodeToTail(p_spy newNode)
    {
    	p_spy  temp;//定义一个临时节点
    	if(pHead == NULL) {  //如果链表为空
    		pHead = newNode;
    		newNode->next = NULL;
    	} else {
    		temp= pHead ;//让头结点指向临时节点
    		while (temp) {
    			if (temp->next == NULL) //找到了最后一个节点 temp就是最后一个节点
    				break;
    			else
    				temp= temp->next;
    		}
    	temp->next = newNode;//最后一个节点插入新节点
    	newNode->next = NULL;//新节点的下一个节点为空,这样就将新节点插入最后了
    	}
    }
    

    在这里插入图片描述

4.2.2.3 删除

关键在于我们怎么找到前面的人:temp
如果我的下一项就等于你的话,我就是你的前一个。找到之后,就执行这条指令:temp->next=后面的人=needDeleteNode->next
在这里插入图片描述

void RemoveNode(p_spy needDeleteNode)
{
	p_spy temp;//定义一个临时的节点,用来遍历
	if (pHead == needDeleteNode)//如果被删除的节点正好是头结点指向的那一个节点
	{
		pHead = needDeleteNode->next;//直接让头节点指向被删除节点的下一个节点
		return;//返回
	}
	else
	{
		/* 找出NeedDeleteNode的上一个节点 */
		temp = pHead;//让头结点指向临时节点temp
		while(temp)
		{
			if (temp->next == needDeleteNode)//找到了,要删除的节点就是temp的下一个节点
				break;
			else
				temp= temp->next;//继续找
		}  
		if (temp)
		{
			//让被删除的上一个节点temp的下一个节点指向被删除节点的下一个节点,那么就把原来的要删除的节点给从链表中删除了。
			temp->next = needDeleteNode-> next;
		}
	}
}
4.2.2.4 链表的使用
/*------------------------1.链表和结点的定义----------------------------*/

/*结点结构体*/
typedef struct LIST_NODE {
    int data;                        /*用于存放结点数据*/
    struct LIST_NODE *pxNext;        /*用于指向下一个结点*/
    struct LIST_NODE *pxPrevious;    /*用于指向上一个结点*/
}ListNode;


/*链表结构体*/
typedef struct LIST {
    unsigned int NumberOfNodes;      /*用于记录链表结点数量*/
    ListNode RootNode;               /*用于作为循环链表的参考点*/
}List;

/*------------------------2.链表和结点的初始化---------------------------*/
/*结点初始化*/
void ListInitialiseItem(ListNode *pxListNode, int value)
{  
 pxListNode->data = value;       /*结点数据赋值*/
}

/*链表初始化*/
void ListInitialise(List *pxList)
{
    pxList->RootNode.pxNext = &(pxList->RootNode);     /*由于此时链表中没有结点,第一个结点指向自己*/
    pxList->RootNode.pxPrevious = &(pxList->RootNode); /*由于此时链表中没有结点,第一个结点指向自己*/

    pxList->NumberOfNodes = 1;                         /*链表结点计数初始化为1,也就是只有一个根结点*/     
}

/*------------------------3.1结点插入链表---------------------------*/
void ListInsertEnd(List *pxList, ListNode *pxInsertListNode)
{
    ListNode *pxNextNode = &(pxList->RootNode);                /*插入结点的后结点*/
    ListNode *pxPreviosNode = pxList->RootNode.pxPrevious;     /*插入结点的前结点*/


    pxInsertListNode->pxNext = pxNextNode;                     /*插入结点指向后结点*/
    pxInsertListNode->pxPrevious = pxPreviosNode;              /*插入结点指向前结点*/ 

    pxPreviosNode->pxNext = pxInsertListNode;                  /*前结点指向插入结点*/   
    pxNextNode->pxPrevious = pxInsertListNode;                 /*后结点指向插入结点*/   

    (pxList->NumberOfNodes)++;                                  /*链表结点计数加1*/
}

/*------------------------3.2链表删除结点---------------------------*/
void ListRemove(List *pxList, ListNode *pxIListToRemove)
{
  ListNode *pxPreviosNode = pxIListToRemove->pxPrevious;     /*删除结点的前结点*/
  ListNode *pxNextNode = pxIListToRemove->pxNext;            /*删除结点的后结点*/

  pxNextNode->pxPrevious = pxPreviosNode;                    /*后结点指向前结点*/
  pxPreviosNode->pxNext = pxNextNode;                        /*前结点指向后结点*/

  (pxList->NumberOfNodes)--;                                 /*链表结点计数减1*/
}


int main(void)
{
 /*1.定义链表、结点*/
 List      list;         //定义链表
 ListNode  list_node1;   //定义结点1
 ListNode  list_node2;   //定义结点2

 /*2.初始化链表、结点*/
 ListInitialise(&list);
 
 ListInitialiseItem(&list_node1, 100);
 ListInitialiseItem(&list_node2, 200);
 
 /*3.插入链表*/
 ListInsertEnd(&list, &list_node1);
 ListInsertEnd(&list, &list_node2);
 
 /*4.删除结点*/
 ListRemove(&list, &list_node1);
 
 return 0;
}

在这里插入图片描述

4.2.3 链表编码技巧

  1. 理解指针或引用的含义
    将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,
    或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

    p->next=q。这行代码是说,p 结点中的 next 指针存储了 q 结点的内存地址。

  2. 警惕指针丢失和内存泄漏
    插入结点时,一定要注意操作的顺序,要先将结点 x 的 next 指针指向结点 b,再把结点 a 的 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。
    删除链表结点时,也一定要记得手动释放内存空间

  3. 利用哨兵简化实现难度
    head=null 表示链表中没有结点了。其中 head 表示头结点指针,指向链表中的第一个结点。

    如果我们引入哨兵结点,在任何时候,不管链表是不是空,head 指针都会一直指向这个哨兵结点。
    我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表。
    在这里插入图片描述

  4. 重点留意边界条件处理

  5. 举例画图,辅助思考
     

4.3 性能比较

在这里插入图片描述

数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。
而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。

数组的缺点是大小固定,一经声明就要占用整块连续内存空间。

  • 如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。
  • 如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。

链表本身没有大小的限制,天然地支持动态扩容。

如果代码对内存的使用非常苛刻,那数组就更适合你。
因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。

而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。
 


五、总结

数组主要用于固定大小的数据结构,其特点是支持随机访问与基于下标的查找,但插入与删除较为耗时
链表主要用于不定长的数据结构,其特点是支持没有大小限制,只要内存够,可以无限增长,插入删除较为方便,但要注意前驱节点的操作,与数组相反,查找时则需要遍历链表才能进行访问。

双向链表提供了一种利用空间去交换时间的技巧。

数组在使用时还需要额外注意内存的使用,不得越界访问别的空间,否则会出现意想不到的错误,且这些错误都是无法被编译器发现的。
 


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值