数组
在每一门编程语言中,基本都会有数组这种数据结构。不过,它并不仅仅是编程语言中的一种数据类型,还是一种最基础的数据结构。尽管数组看起来非常基础,简单。但是我想很多人都不明白它的精髓。
在大部分编程语言中,数组都是从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不友好,没有办法有效预读。
存储:
数组的缺点是大小固定,一经声明就要占用连续的内存空间。如果数组声明的过大,系统可能没有足够的连续内存分配给它,如果过小,则会出现不够用情况,这时只能再声明一个更大的内存,并将之前的数组拷贝进去,而拷贝是非常耗时的。链表的缺点则是需要消耗额外的空间去存储上下节点信息,而且,对链表进行频繁的插入,删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片。