数据结构与算法(三)数组与链表

本文主要介绍了数组和链表这两种基础数据结构。数组是线性表,利用连续内存空间存储相同类型数据,支持随机访问,但插入和删除操作低效。链表通过指针串联零散内存块,有单向、双向和循环链表等,插入和删除时间复杂度低,但随机访问困难。还对比了两者在时间复杂度、性能和存储方面的差异。

数组

在每一门编程语言中,基本都会有数组这种数据结构。不过,它并不仅仅是编程语言中的一种数据类型,还是一种最基础的数据结构。尽管数组看起来非常基础,简单。但是我想很多人都不明白它的精髓。

在大部分编程语言中,数组都是从0开始编号的,为什么是从0开始,而不是从1开始呢?从1开始不是更符合人类的思维习惯吗?

现在,带着这个问题学习下面的内容

如何实现随机访问

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

线性表:数据排列成像一条线一样的结构。每个线性表上的数据最多只有前后两个方向。其实除了数组,链表、队列、栈也是线性表结构。

而与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为在非线性表中,数据之间并不是简单的前后关系。

连续的内存空间和相同的数据类型保证了它的一个堪称“杀手锏”的特性:“随机访问”。但是有利就有弊,这两个限制也让数组的很多操作都变的非常低效,比如说想要在数组中删除,插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。

说到数据访问,那你知道数组是如何实现根据下标进行随机访问吗?

我们拿一个长度为10的数组进行实验,代码:

package main

import "fmt"

func main(){
	var array [10]uint8
	println("array:",&array)
	for i := 0;i<cap(array);i++{
		fmt.Println(&array[i])
	}
}
结果:
0xc00001e0d0
0xc00001e0d1
0xc00001e0d2
0xc00001e0d3
0xc00001e0d4
0xc00001e0d5
0xc00001e0d6
0xc00001e0d7
array: 0xc00001e0d0
0xc00001e0d8
0xc00001e0d9

我们知道,计算机会给每个内存单元分配一个地址,计算机会通过地址访问内存中的数据。当计算机要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址

a[i]_address = base_address + i * data_type_size

其中data_type_size 表示数组中每个元素的大小。

数组适合查找操作,但是时间复杂度不是O(1),即使是排序好的数组,使用二分法查找,时间复杂度也是O(logn)。正确的表述是:数组支持随机访问,根据下标访问的时间复杂度为O(1)。

低效的“插入”和“删除”

前面说到,数组为了保证内存数据的连续性,会导致插入、删除这两个操作比较低效。现在我们就来仔细说一下,究竟什么导致低效?又有哪些方法来改进呢?

首先来看插入操作:

假设数组的长度为n,现在,如果我们需要将一个数据插入到数组中的第k个位置给新来的数据,那么,我们就要将第k~n这部分数据的元素都顺序的往后挪一位。那么插入的时间复杂度是多少呢?

最好情况,在数组最后插入一个数据,时间复杂度是O(1),最坏时间复杂度,在数组第一位插入一个数据,时间复杂度是O(n),由于往数组每个位置插入数据的概率相同,那么平均复杂度就是(1+2+…+n)/n,时间复杂度为O(n)

如果数组中的数据是有序的,我们往数组中插入数据时,就必须将位置之后的数据往后搬移,但是当数组是无序的,数组仅仅用来当做存储的集合时呢? 这种情况下,我们再往数组中的第k个位置插入,为了避免大规模数据搬移,我们可以之间将第k个元素搬移到数组末尾,然后将数据放到数组的第k个位置上。

链表

我们聊聊链表(Linked list)这个数据结构。链表的经典应用场景,那就是LRU缓存淘汰算法。

缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等。

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去、哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略、最少使用策略、最近最少使用策略。

五花八门的链表结构

相比数组,链表是一种稍微复杂一点的数据结构。相比数组,数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们去申请一个100Mb大小的数组,但内存中没有连续,足够大的内存时,即便剩余总可用空间大于100Mb,仍会申请失败。

而链表不同,它并不需要一块连续的内存空间,它通过指针将一组零散的内存块串联起来,所以申请的是100MB大小的链表不会有问题。

链表有多种:单向链表、双向链表和循环链表。我们首先简单实现一个单向链表(golang)

package main

import (
	"fmt"
	"errors"
)

type Node struct {
	Ctx interface{}
	after *Node
}

func newNode(ctx interface{}) *Node{
	return &Node{Ctx:ctx}
}

func (n *Node) Add(node *Node){
	n.after = node
}

func (n *Node) Find(ctx interface{}) *Node{
	for n.after != nil {
		if n.Ctx == ctx {
			return n
		}
		n = n.after
	}
	return nil
}

func (n *Node) Delete(ctx interface{})bool{
	for n.after != nil {
		if n.after.Ctx == ctx{
			n.after = n.after.after
			return true
		}
		n = n.after
	}
	return false
}

func (n *Node) ToArray() *[]*Node{
	array := []*Node{}
	for n != nil {
		array = append(array,n)
		n = n.after
	}
	return &array
}

type link struct {
	firstNode *Node
	endNode *Node
	len int
	cap int
}

func NewLink(cap int) *link{
	return &link{cap:cap}
}


func (l *link) Add(ctx interface{}) error {
	if l.len >= l.cap {
		return errors.New("link is full")
	}
	node :=  newNode(ctx)
	if l.firstNode == nil {
		l.firstNode = node
		l.endNode = node
	}else {
		l.endNode.Add(node)
		l.endNode = node
	}
	l.len += 1
	return nil
}

func (l * link) Find(ctx interface{}) *Node{
	return l.firstNode.Find(ctx)
}

func (l * link) Delete(ctx interface{}) bool{
	// 删除第一位的元素
	if l.firstNode.Ctx == ctx{
		if l.firstNode == l.endNode {
			l.endNode = nil
		}
		l.firstNode = l.firstNode.after
		l.len -= 1
		return true
	}
	// 删除其他元素
	result := l.firstNode.Delete(ctx)
	if result {
		l.len -= 1
	}
	return result
}

func (l *link) ToArray() *[]*Node{
	array := []*Node{}
	if l.len == 0 || l.cap <=0{
		return &array
	}
	return l.firstNode.ToArray()
}

func main()  {
	l1 := NewLink(20)
	for i := 0 ; i <20 ;i++{
		l1.Add(i)
	}
	l1.Delete(19)
	l1.Delete(18)
	l1.Delete(0)
	for _,v := range *l1.ToArray(){
		fmt.Println(v.Ctx)
	}

}

我们知道,在进行数组的插入、删除操作时,为了保证数据的连续性,需要做大量的数据搬移,所以时间复杂度是O(n)。而链表中插入或删除一个数据,我们并不需要为了保持内存的连续性而搬移节点,只需要改变相邻节点的指针指向,所以时间复杂度是O(1)。

但是想要随机访问链表中的第K个数据时就没那么简单了,因为数据并非连续,我们无法通过下标进行访问。只能遍历访问,所以时间复杂度是O(n)。

循环链表相比单向链表,只是将链表最后一个节点的after指针指向首节点。和单链表相比,循环链表的有点是从链尾到链头比较方便,当处理的数据具有环形结构的特点时,就比较适合使用链表,比如著名的约瑟夫问题。

双向链表支持两个方向的遍历,它除了一个after指针指向下一个节点外,还有个before指针指向前一个节点。

数组与链表对比

时间复杂度

时间复杂度数组链表
插入删除O(n)O(1)
随机访问O(1)O(n)

性能:

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

存储:

数组的缺点是大小固定,一经声明就要占用连续的内存空间。如果数组声明的过大,系统可能没有足够的连续内存分配给它,如果过小,则会出现不够用情况,这时只能再声明一个更大的内存,并将之前的数组拷贝进去,而拷贝是非常耗时的。链表的缺点则是需要消耗额外的空间去存储上下节点信息,而且,对链表进行频繁的插入,删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值