算法学习:数组 vs 链表

在这里插入图片描述

🔥 个人主页:空白诗

在这里插入图片描述

文章目录

    • 🎯 引言
    • 🛠️ 内存基础
      • 什么是内存❓
      • 内存的工作原理 🎯
    • 📦 数组(Array)
      • 📖 什么是数组
      • 🌀 数组的存储
      • 📝 示例代码(go语言)
        • 🎯 执行增加操作
        • 🎯 执行删除操作
      • 📊 优缺点分析
    • 🔗 链表(Linked List)
      • 📖 什么是链表
      • 🌀 链表的存储
      • 📝 示例代码(go语言)
      • 📊 优缺点分析
    • 📊 数组与链表的对比
      • 🎯 访问速度
      • 🎯 插入与删除效率
      • 🎯 空间利用效率
      • 🎯 应用场景
    • 📚 小结


🎯 引言

在编程的奇妙世界里,数组和链表作为两种基础且重要的数据结构,各自扮演着不可替代的角色。它们在存储和管理数据方面展现出了不同的优势和局限。本文将带领你深入了解数组(Array)链表(Linked List)的奥秘🚀


🛠️ 内存基础

什么是内存❓

内存,尤其是随机存取存储器(RAM),是计算机中用于临时存储数据和程序指令的部分。与硬盘相比,内存访问速度快,但信息非持久保存。

想象一下,当你在解决一个复杂的算法问题时,那些数字、字符,乃至复杂的数据结构,都需要一个地方暂时停留和操作——这个地方就是内存

内存的工作原理 🎯

内存由一系列连续或非连续的存储单元组成,每个单元都有一个独一无二的地址。通过地址,CPU(中央处理器)可以迅速找到所需的数据。

就好比内存是一个储物柜,你将东西放进去后会给你一个号码,通过号码你可以快速找到你存储物品的柜子。

在这里插入图片描述

需要将数据存储到内存时,你请求计算机提供存储空间,计算机给你一个存储地址。需要存储多项数据时,有两种基本方式——数组和链表


📦 数组(Array)

📖 什么是数组

数组是一种线性数据结构它将元素按照一定的顺序存储在一块连续的内存区域中。每个元素都有一个索引(从0开始),通过索引可以快速访问数组中的任意元素。但是对于插入和删除,特别是当位置不在末尾时,可能需要移动后续的所有元素,以保持连续性,导致最坏情况下的时间复杂度为O(n)。

+---+---+---+---+
| 1 | 2 | 3 | 4 |
+---+---+---+---+
  ^           ^
  |           |
 索引0       索引3

🌀 数组的存储

数组在创建时会一次性申请足够的内存空间进行存储。这意味着数组的大小是固定的,一旦声明,不能轻易改变。

如果需要在数组中添加新元素很麻烦,因为数组必须是连续的。比如你和3个朋友一起去看电影,已经选好了连坐的4个位置并且付款。此时又有一个朋友要与你们一起看,你们想要连排座,但是你们已经选好的这4个位置旁边没有了空位,所以你们只能放弃这4个位置进行退款,然后重新选择有5个连坐的位置。如果又来了一位朋友,而当前坐的地方也没有空位,你们就得再次转移!真是太麻烦了。如果没有了空间,就得移到内存的其他地方,因此添加新元素的速度会很慢。

而此时如果中间某个人不看电影了,那么后面的人就需要向前靠拢和大家坐在一起,即在数组中删除元素,就需要移动后面的所有元素。

在这里插入图片描述
如上图,此时如果再来一个人,只能舍弃原本的四个位置,去重新找有五个连续的位置。如果此时’王五‘不看电影了,那右边的赵六就需要向左边坐一个位置与大家靠拢。

📝 示例代码(go语言)

package main

import "fmt"

func main() {
	// 定义一个整型切片
	arr := []int{1, 2, 3, 4, 5}
	temp := []int{10, 11}
	// 遍历切片并打印每个元素及其地址
	for i := range arr {
		fmt.Printf("Element: %d, Address: %p\n", arr[i], &arr[i])
	}
	for i := range temp {
		fmt.Printf("temp: %d, Address: %p\n", temp[i], &temp[i])
	}
	// 添加一个元素
	arr = append(arr, 6)

	// 删除索引为3的元素(值为4)
	//arr = append(arr[:3], arr[4:]...)

	// 	遍历切片并打印每个元素及其地址
	fmt.Printf("-----\n")
	for i := range arr {
		fmt.Printf("Element: %d, Address: %p\n", arr[i], &arr[i])
	}
	for i := range temp {
		fmt.Printf("temp: %d, Address: %p\n", temp[i], &temp[i])
	}
}

在Go语言中,数组和切片的处理方式有所不同。首先,理解一下基本概念:

  • 数组:固定大小的元素序列,分配一块连续的内存。
  • 切片:是对数组的一个引用,包含指向底层数组的指针、长度和容量信息。切片本身是轻量级的,修改切片(如追加、删除)操作可能引起底层数组的重新分配。

这段代码中,arr 是一个切片,但它的初始化方式 [1, 2, 3, 4, 5] 实际上创建了一个底层数组,并用这个数组来初始化切片。而 temp 同样是一个基于数组初始化的切片。

假设初始时,arr 的底层数组在内存中的布局如下(简化表示):

| arr[0]: 1 | arr[1]: 2 | arr[2]: 3 | arr[3]: 4 | arr[4]: 5 |

每个元素旁边标注的是其值和大致的内存地址(实际地址会更复杂,但这里为了简化说明)。注意,&arr[i] 获取的是元素的地址,对于切片中的元素,这实际上是底层数组中相应元素的地址。

🎯 执行增加操作
Element: 1, Address: 0x14000016180
Element: 2, Address: 0x14000016188
Element: 3, Address: 0x14000016190
Element: 4, Address: 0x14000016198
Element: 5, Address: 0x140000161a0
temp: 10, Address: 0x1400000e0a0
temp: 11, Address: 0x1400000e0a8
-----
Element: 1, Address: 0x1400001c140
Element: 2, Address: 0x1400001c148
Element: 3, Address: 0x1400001c150
Element: 4, Address: 0x1400001c158
Element: 5, Address: 0x1400001c160
Element: 6, Address: 0x1400001c168
temp: 10, Address: 0x1400000e0a0
temp: 11, Address: 0x1400000e0a8

如上输出结果所示,增加元素时数组的地址全部发生了变化。

在Go语言中,当你对切片(slice)执行append操作时,如果切片的容量(cap)不足以容纳新的元素,Go会执行以下步骤:

  1. 检查容量: 首先,Go检查切片的当前容量是否足够容纳新元素。如果足够,切片会在原地扩展,也就是直接在现有底层数组的末尾添加新元素,此时原有元素的地址不会改变。

  2. 容量不足时的处理: 如果当前切片的容量不足以容纳新元素,Go会创建一个新的、容量更大的底层数组。然后,它会将原切片中的所有元素复制到新数组中,再在新数组的末尾追加新元素。这意味着所有元素都会被移动到新的内存位置,因此它们的地址会改变。

在代码示例中,由于初始时没有明确指定切片的容量,切片会有一个默认的容量。当你调用append添加第六个元素时,如果这个操作导致需要更多空间超出了切片的当前容量,Go就会执行上述的第二步,即创建新的底层数组并复制元素。因此,追加元素后你会观察到每个元素的地址都发生了变化因为它们都被移到了新的内存位置上

总结来说,切片追加元素后地址变化的原因在于添加操作导致了底层数组的重新分配,从而引发了元素地址的更新。

🎯 执行删除操作
Element: 1, Address: 0x140000b6030
Element: 2, Address: 0x140000b6038
Element: 3, Address: 0x140000b6040
Element: 4, Address: 0x140000b6048
Element: 5, Address: 0x140000b6050
temp: 10, Address: 0x140000a4020
temp: 11, Address: 0x140000a4028
-----
Element: 1, Address: 0x140000b6030
Element: 2, Address: 0x140000b6038
Element: 3, Address: 0x140000b6040
Element: 5, Address: 0x140000b6048
temp: 10, Address: 0x140000a4020
temp: 11, Address: 0x140000a4028

如上输出结果所示,当删除数组一个元素时(此时删除了索引为3的元素),后续数组的所有元素都向前移动。

当执行 arr = append(arr[:3], arr[4:]...) 这行代码时,Go的切片操作实际上做了以下几步:

  1. 切片操作:首先,它创建了两个新的切片,一个包含从开始到索引3(不包括3)的元素,另一个包含从索引4开始到最后的元素。
  2. 合并与重新分配:然后,使用 append 函数将这两个切片的内容合并。由于原切片的连续性被打破(需要“跳过”索引3的元素),append 可能会检查当前切片的容量是否足够存放新数据。如果不够,它可能会分配一个新的足够大的底层数组来存储合并后的结果;如果当前切片的剩余容量足够,则直接在原有底层数组的基础上进行操作。

删除元素并重新分配内存后,arr 中剩余元素的地址发生了改变,因为它们现在位于一个全新的、连续的内存区域。当打印出每个元素的地址时,你会发现从原来索引3之后的所有元素的地址相比之前都“向前移动”了,这是因为它们现在位于一个起始位置更早的连续块中。

而对于 temp 切片,因为它没有进行任何删除或添加操作,所以其元素的地址保持不变。每次打印 temp 的元素地址时,你会看到相同的地址输出,因为这部分内存没有被重新分配。

总之,删除切片中的元素并导致元素地址“向前移动”的根本原因,在于append操作可能触发的底层数组的重新分配和数据复制到新位置的过程,以维持切片元素的连续性。

📊 优缺点分析

  • 优点:

    • 随机访问: 直接通过索引访问,时间复杂度为O(1)。
    • 简单易用: 大多数编程语言内置支持,易于理解和实现。
  • 缺点:

    • 插入与删除: 在数组中插入或删除元素需要移动元素,最坏情况下时间复杂度为O(n)。
    • 固定大小限制: 传统数组大小固定,动态数组虽然可以自动扩容,但在扩容时可能会导致性能开销。

🔗 链表(Linked List)

📖 什么是链表

链表也是一种线性数据结构,但与数组不同,链表中的元素在内存中并不是顺序存放的,而是通过存在元素中的指针链接起来。每个链表节点包含两个部分:数据域指针域

链表访问某个元素需要从头节点开始,沿着指针一步步遍历,最坏情况下时间复杂度为O(n),意味着数据越大,查找越慢。但是在插入和删除操作上链表表现出色,特别是在链表的头部或尾部进行时,只需调整相邻节点的指针即可,时间复杂度为O(1),即使在中间操作,也仅需改动少量指针,避免了大量数据移动。

🌀 链表的存储

链表中的元素可存储在内存的任何地方,因为链表的每个元素都存储了下一个元素的地址,从而使一系列随机的内存地址串在一起。

在这里插入图片描述

这犹如寻宝游戏。你前往第一个地址,那里有一张纸条写着“下一个元素的地址为 123”。因此,你前往地址 123,那里又有一张纸条,写着“下一个元素的地址为 847”,以此类推。在链表中添加元素很容易:只需将其放入内存,并将其地址存储到前一个元素中。

因此使用链表时,根本就不需要移动元素,只要有足够的内存空间,就能为链表分配内存。

📝 示例代码(go语言)

package main

import (
	"fmt"
)

// ListNode 定义链表节点
type ListNode struct {
	Value int
	Next  *ListNode
}

// LinkedList 定义链表结构
type LinkedList struct {
	Head *ListNode
}

// NewListNode 创建新节点
func NewListNode(value int) *ListNode {
	return &ListNode{Value: value}
}

// Append 向链表末尾追加节点
func (list *LinkedList) Append(value int) {
	newNode := NewListNode(value)
	if list.Head == nil {
		list.Head = newNode
	} else {
		current := list.Head
		for current.Next != nil {
			current = current.Next
		}
		current.Next = newNode
	}
}

// Delete 删除链表中第一个匹配值的节点
func (list *LinkedList) Delete(value int) {
	if list.Head == nil {
		return
	}
	if list.Head.Value == value {
		list.Head = list.Head.Next
		return
	}
	current := list.Head
	for current.Next != nil && current.Next.Value != value {
		current = current.Next
	}
	if current.Next != nil {
		current.Next = current.Next.Next
	}
}

// PrintListWithAddresses 打印链表节点值和地址
func (list *LinkedList) PrintListWithAddresses() {
	current := list.Head
	for current != nil {
		fmt.Printf("Value: %d, Address: %p -> ", current.Value, current)
		current = current.Next
	}
	fmt.Println("nil")
}

func main() {
	// 创建链表实例
	linkedList := &LinkedList{}

	// 增加节点
	linkedList.Append(1)
	linkedList.Append(2)
	linkedList.Append(3)

	fmt.Println("增加前:")
	linkedList.PrintListWithAddresses()

	// 删除索引为1的元素(值为2)
	linkedList.Delete(2)

	fmt.Println("删除后:")
	linkedList.PrintListWithAddresses()

	// 再次增加节点
	linkedList.Append(4)

	fmt.Println("增加后:")
	linkedList.PrintListWithAddresses()
}
增加前:
Value: 1, Address: 0x1400010a240 -> Value: 2, Address: 0x1400010a250 -> Value: 3, Address: 0x1400010a260 -> nil
删除后:
Value: 1, Address: 0x1400010a240 -> Value: 3, Address: 0x1400010a260 -> nil
增加后:
Value: 1, Address: 0x1400010a240 -> Value: 3, Address: 0x1400010a260 -> Value: 4, Address: 0x1400010a270 -> nil

这是一个简单的Go语言示例,模拟演示了链表的创建、增加节点、删除节点以及输出节点值和地址的操作。从上述输出结果可以看见,不管是增加还是删除,改变的只有元素的指向,并没有修改其内存地址,删除也没有移动其他元素的内存地址。

📊 优缺点分析

  • 优点:

    • 动态大小: 链表的长度可以在运行时动态改变,无需担心预先分配内存的问题。
    • 高效插入删除: 在链表中插入或删除元素只需要修改相邻节点的指针,时间复杂度为O(1)(在有指针的情况下)。
  • 缺点:

    • 访问速度: 不能直接通过索引访问,需要从头节点开始遍历,时间复杂度为O(n)。
    • 额外空间开销: 每个节点除了存储数据,还需要存储指向下一个节点的指针。

📊 数组与链表的对比

在这里插入图片描述

🎯 访问速度

  • 数组: 🏆 胜出在于其提供了常数时间O(1)的访问速度。由于元素在内存中连续存储,给定索引后,计算元素地址简单直接,瞬间定位。
  • 链表: 访问某个元素需要从头节点开始,沿着指针一步步遍历,最坏情况下时间复杂度为O(n),意味着数据越大,查找越慢。

🎯 插入与删除效率

  • 链表: 🏆 在插入和删除操作上表现出色,特别是在链表的头部或尾部进行时,只需调整相邻节点的指针即可,时间复杂度为O(1)。即使在中间操作,也仅需改动少量指针,避免了大量数据移动。
  • 数组: 对于插入和删除,特别是当位置不在末尾时,可能需要移动后续的所有元素,以保持连续性,导致最坏情况下的时间复杂度为O(n)。

🎯 空间利用效率

  • 数组: 可能导致内存浪费。预先分配固定大小的内存空间,如果未填满,则有未使用的空间。不过,对于确切知道大小的数据集,这不构成太大问题。
  • 链表: 每个节点除了存储数据外,还需要额外的内存来存储指向下一个节点的指针,这构成了空间上的开销。然而,链表能够根据需要动态调整大小,避免了预分配过大内存的问题。

🎯 应用场景

  • 数组: 非常适合于需要快速随机访问数据的场景,例如图像处理、音频数据、大规模科学计算等,其中数据一旦加载,就频繁查询而很少修改。
  • 链表: 在频繁进行插入和删除操作的场景中大放异彩,如实现动态数据结构(如队列、栈)、构建更复杂的数据结构(如哈希表的链地址法解决冲突、图的邻接表表示)或者处理不确定长度的数据流。

📚 小结

数组与链表各有千秋,选择合适的工具对于提高程序性能至关重要。理解它们的底层原理,能帮助我们在面对具体问题时做出明智的选择。希望这篇学习笔记能加深你对这两种基础数据结构的理解,为你的编程之旅增添一份助力!✨

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/601785.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【Spark】 Spark核心概念、名词解释(五)

Spark核心概念 名词解释 1)ClusterManager:在Standalone(上述安装的模式,也就是依托于spark集群本身)模式中即为Master(主节点),控制整个集群,监控Worker。在YARN模式中为资源管理器ResourceManager(国内s…

编程入门(六)【Linux系统基础操作三】

读者大大们好呀!!!☀️☀️☀️ 🔥 欢迎来到我的博客 👀期待大大的关注哦❗️❗️❗️ 🚀欢迎收看我的主页文章➡️寻至善的主页 文章目录 🔥前言🚀LInux的进程管理和磁盘管理top命令显示查看进…

SpringBoot整合Redis(文末送书)

文章目录 Redis介绍使用IDEA构建项目,同时引入对应依赖配置Redis添加Redis序列化方法心跳检测连接情况存取K-V字符串数据(ValueOperations)存取K-V对象数据(ValueOperations)存取hash数据(HashOperations&a…

2024年武汉市工业投资和技术改造及工业智能化改造专项资金申报补贴标准、条件程序和时间

一、申报政策类别 (一)投资和技改补贴。对符合申报条件的工业投资和技术改造项目,依据专项审计报告明确的项目建设有效期(最长不超过两年)内实际完成的生产性设备购置与改造投资的8%,给予最高不超过800万元专项资金支持。 (二)智能化改造补贴。对符合申报条件的智能化改造项目…

互联网产品为什么要搭建会员体系?

李诞曾经说过一句话:每个人都可以讲5分钟脱口秀。这句话换到会员体系里面同样适用,每个人都能聊点会员体系相关的东西。 比如会员体系属于用户运营的范畴,比如怎样用户分层,比如用户标签及CDP、会员积分、会员等级、会员权益和付…

鸿蒙通用组件弹窗简介

鸿蒙通用组件弹窗简介 弹窗----Toast引入ohos.promptAction模块通过点击按钮,模拟弹窗 警告对话框----AlertDialog列表弹窗----ActionSheet选择器弹窗自定义弹窗使用CustomDialog声明一个自定义弹窗在需要使用的地方声明自定义弹窗,完整代码 弹窗----Toa…

Seata之TCC 模式的使用

系列文章目录 文章目录 系列文章目录前言前言 前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站,这篇文章男女通用,看懂了就去分享给你的码吧。 Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能…

【python数据分析基础】—pandas透视表和交叉表

目录 前言一、pivot_table 透视表二、crosstab 交叉表三、实际应用 前言 透视表是excel和其他数据分析软件中一种常见的数据汇总工具。它是根据一个或多个键对数据进行聚合,并根据行和列上的分组键将数据分配到各个矩形区域中。 一、pivot_table 透视表 pivot_tabl…

风与水如何联合优化?基于混合遗传算法的风-水联合优化运行程序代码!

前言 为提高风电场的供电质量同时增加其发电效益,利用储能技术为风电场配置一个蓄能系统是比较重要的解决措施之一。风电的蓄能技术有水力蓄能、压缩空气蓄能、超导磁力蓄能、流体电池组、电解水制氢等,其中水力蓄能是技术较成熟的一种蓄能方式,且小型的…

给网站网页PHP页面设置密码访问代码

将MkEncrypt.php文件上传至你网站根目录下或者同级目录下。 MkEncrypt.php里面添加代码,再将调用代码添加到你需要加密的页进行调用 MkEncrypt(‘123456’);括号里面123456修改成你需要设置的密码。 密码正确才能进去页面,进入后会存下cookies值&…

项目经理【过程】概念

系列文章目录 【引论一】项目管理的意义 【引论二】项目管理的逻辑 【环境】概述 【环境】原则 【环境】任务 【环境】绩效 【人】概述 【人】原则 【人】任务 【人】绩效 【过程】概念 一、过程是什么 1.1 项目管理五大过程组 1.2 五大过程组之间的相互作用 1.3 项目阶段VS过…

《Linux运维总结:ARM架构CPU基于docker-compose一离线部署consul v1.18.1集群工具》

总结:整理不易,如果对你有帮助,可否点赞关注一下? 更多详细内容请参考:《Linux运维篇:Linux系统运维指南》 一、部署背景 由于业务系统的特殊性,我们需要面向不通的客户安装我们的业务系统&…

【SpringBoot】-- 监听容器事件、Bean的前后置事件

目录 一、ApplicationContextInitializer 使用 1、自定义类,实现ApplicationContextInitializer接口 2、在META-INF/spring.factories配置文件中配置自定义类 二、ApplicationListener 使用 1、自定义类,实现ApplicationListener接口 2、在META-…

tensorboard子目录运行

tensorboard默认在根目录运行,浏览器访问127.0.0.1:6006打开界面。 如果想在子目录运行,那么可以这么执行 tensorboard --logdir ./logs --path_prefix/app/asd 然后浏览器既可以通过 http://localhost:6006/app/asd/来访问。​​​​​​ 但这么做遇…

HADOOP之YARN详解

目录 一、YARN的简介 1.1 MapReduce 1.x 1.1.1 MapReduce 1.x的角色 1.2 YARN的介绍 1.3 YARN的设计思想 二 YARN的配置 1. mapred-site.xml 2. yarn-site.xml ​编辑 3. hadoop-env.sh 4. 分发到其他节点 5.YARN的服务启停 6. 任务测试 三 YARN的历史日志 1. 历…

JetBrains的多数据库管理和SQL工具DataGrip 2024.1版本在Windows/Linux系统的下载与安装配置

目录 前言一、DataGrip在Windows安装二、DataGrip在Linux安装三、Windows下使用配置四、Linux下使用配置总结 前言 ​ “ DataGrip是一款多数据库管理和SQL工具,适用于不同类型的数据库。它提供了丰富的功能和工具,可以帮助开发人员更高效地管理数据库、…

【Linux网络编程】4.TCP协议、select多路IO转换

目录 TCP协议 TCP通讯时序 三次握手 四次挥手 滑动窗口 测试代码1 测试结果 Address already in use解决方法 批量杀进程 测试代码2 测试结果 测试代码4 测试结果 TCP状态转换 主动发起连接请求端 主动关闭连接请求端 被动接收连接请求端 被动关闭连接请求端…

浅谈自己用过最好用的AI工具概括

个人最经常用的AI工具的其实是Copilot,但是也有别的一些最好用的AI工具,包括: OpenAI GPT-3:这是一个自然语言生成模型,具有强大的语言理解和生成能力。它可以用于各种任务,如文字生成、自动回复和文本摘要…

1984. 学生分数的最小差值C++

给你一个 下标从 0 开始 的整数数组 nums ,其中 nums[i] 表示第 i 名学生的分数。另给你一个整数 k 。 从数组中选出任意 k 名学生的分数,使这 k 个分数间 最高分 和 最低分 的 差值 达到 最小化 。 返回可能的 最小差值 。 示例 1: 输入&…

台灯的十大品牌有哪些?十大护眼灯品牌推荐

相信细心的家长已经发现,自家孩子随着步入更高的年级,每天晚上学习的时间也越来越晚了,而这个过程中必然少不了一盏好的台灯! 市场上有不少网红代言的护眼灯,虽然它们销售量高,但其实缺乏专业技术和安全保障…
最新文章