golang的重要数据结构-slice和map

首先,golang默认都是采用值传递,即拷贝传递。

最终我们可以确认的是Go语言中所有的传参都是值传递(传值),都是一个副本,一个拷贝。因为拷贝的内容有时候是非引用类型(int、string、struct等这些),这样就在函数中就无法修改原内容数据;有的是引用类型(指针、map、slice、chan等这些),这样就可以修改原内容数据。

是否可以修改原内容数据,和传值、传引用没有必然的关系。在C++中,传引用肯定是可以修改原内容数据的,在Go语言里,虽然只有传值,但是我们也可以修改原内容数据,因为参数是引用类型。

这里也要记住,引用类型和传引用是两个概念。

再记住,Go里只有传值(值传递)。

这时就有人觉得疑惑了,为什么slice和map在局部变量也能改变外部变量???

其实,他们在函数传参的时候,还是值传递,只不过slice和map,channel这些属于指针类型,

每次copy一个指针消耗非常低(因为只开辟了一点的堆内存,并不像数组那种需要开辟一个大的栈内存),指针指向的地址还是原来那些内容。

其实就是传递指向该地址的指针副本,指针占4个字节。

slice的数据结构

type slice struct{
    array unsafe.Point   //底层数组的指针
    len int
    cap int
}

从这里可以看出slice占24个字节(8+8+8)

再看看appendint的操作:

func appendInt(x []int, y int) []int {
    var z []int
    zlen := len(x) + 1
    if zlen <= cap(x) {
        // There is room to grow.  Extend the slice.
        z = x[:zlen]
    } else {
        // There is insufficient space.  Allocate a new array.
        // Grow by doubling, for amortized linear complexity.
        zcap := zlen
        if zcap < 2*len(x) {
            zcap = 2 * len(x)
        }
        z = make([]int, zlen, zcap)
        copy(z, x) // a built-in function; see text
    }
    z[len(x)] = y
    return z
}

其中len是slice中存有数据的大小,cap是底层数组的大小。

当做切片操作时,引用的是源slice的底层数组,只有在做append操作时如果超过了cap,就会自动分配一个新的底层数组,cap大小是原cap的两倍(反正就是2的n次方)。

    sli := make([]int, 0, 1)
    fmt.Printf("%p, len = %v, cap = %v, byte = %v\n", sli, len(sli), cap(sli), unsafe.Sizeof(sli))
    sli = append(sli, 1)
    fmt.Printf("%p, len = %v, cap = %v, byte = %v\n", sli, len(sli), cap(sli), unsafe.Sizeof(sli))
    sli = append(sli, 2)
    fmt.Printf("%p, len = %v, cap = %v, byte = %v\n", sli, len(sli), cap(sli), unsafe.Sizeof(sli))
    sli = append(sli, 3)
    fmt.Printf("%p, len = %v, cap = %v, byte = %v\n", sli, len(sli), cap(sli), unsafe.Sizeof(sli))
    sli = append(sli, 4)
    fmt.Printf("%p, len = %v, cap = %v, byte = %v\n", sli, len(sli), cap(sli), unsafe.Sizeof(sli))
    sli = append(sli, 5)
    fmt.Printf("%p, len = %v, cap = %v, byte = %v\n", sli, len(sli), cap(sli), unsafe.Sizeof(sli))

仔细观察一下,如何扩容的,地址变化了,但是大小永远不会变一直为24个字节:

 

只有sclie, map, chan可以用%p来打印地址

    sli1 := []int{4, 5, 6, 7, 7, 7, 7, 77, 7, 77, 7, 7, 7, 7, 7, 7, 7, 7, 7}
    fmt.Printf("slice : %p, len = %v, cap = %v, byte = %v\n\n", sli1, len(sli1), cap(sli1), unsafe.Sizeof(sli1))    
    arr := [...]int{4, 5, 6, 7, 7, 7, 7, 77, 7, 77, 7, 7, 7, 7, 7, 7, 7, 7, 7}
    fmt.Printf("arr : %p, len = %v, cap = %v, byte = %v\n\n", &arr, len(arr), cap(arr), unsafe.Sizeof(arr))

切片大小一直为24,数组这时候为152,所以说平时如果数据量大,传递切片的指针copy值是要比数组的copy值效率高很多!!!

 

----------------------------------------------------------------- 以下讲一下map -----------------------------------------------------------------

map是必须要通过make初始化的,否则会报错!

golang的map就是用哈希表来实现的,hashmap底层的数据结构:数组 + 链表

hashmap 通过一个 bucket 数组实现,HashMap会首先通过一个哈希函数将key转换为数组下标,所有元素将被 hash 到数组中的 bucket 中,bucket 填满后,

将通过一个 overflow 指针来扩展一个 bucket 出来形成链表,也就是解决冲突问题。

两个键值对哈希运算后的值相同时就会发生哈希碰撞,当发生哈希碰撞时,键值对就会存储在该数组对应链表的下一个节点上。

尽管这样,HashMap的操作效率也是很高的。当不存在哈希碰撞时查找复杂度为O(1),存在哈希碰撞时复杂度为O(N)。所以,但从性能上讲HashMap中的链表出现越少,性能越好;

当然,当存储的键值对非常多时,从存储的角度链表又能分担一定的压力。

 

// HashMap木桶(数组)的个数
const BucketCount = 16

// 链表结构里的数据:键值对
type KV struct {
    Key   string
    Value string
}

// 链表结构
type LinkNode struct {
    Data     KV
    NextNode *LinkNode
}

// 哈希表的结构
type HashMap struct {
    Buckets [BucketCount]*LinkNode // 数组:散列表,里面数据结构是哈希桶,存有键值对的链表
}

//创建只有头结点的链表
func CreateLink() *LinkNode {
    //头结点数据为空 是为了标识这个链表还没有存储键值对
    linkNode := &LinkNode{KV{"", ""}, nil}
    return linkNode
}

// 创建HashMap
func CreateHashMap() *HashMap {
    myMap := &HashMap{}
    //为每个元素添加一个链表对象
    for i := 0; i < BucketCount; i++ {
        myMap.Buckets[i] = CreateLink()
    }
    return myMap
}

 

// 哈希函数,简单的散列算法:它可以将不同长度的key散列成0-BucketCount的整数
func HashCode(key string) int {
    sum := 0
    for i := 0; i < len(key); i++ {
        sum += int(key[i])
    }
    return sum % BucketCount
}

 

//添加键值对
func (myMap *HashMap)AddKeyValue(key string, value string)  {
    //1.将key散列成0-BucketCount的整数作为Map的数组下标
    mapIndex := HashCode(key)

    //2.获取对应数组头结点
    link := myMap.Buckets[mapIndex]

    //3.在此链表添加结点
    if link.Data.Key == "" && link.NextNode == nil {
        //如果当前链表只有一个节点,说明之前未有值插入  修改第一个节点的值 即未发生哈希碰撞
        link.Data.Key = key
        link.Data.Value = value
    }else {
        //发生哈希碰撞
        link.AddNode(KV{key, value})
    }
}

 

//按键取值
func (myMap *HashMap)GetValueForKey(key string) string {
    //1.将key散列成0-BucketCount的整数作为Map的数组下标
    mapIndex := HashCode(key)
    //2.获取对应数组头结点
    link := myMap.Buckets[mapIndex]
    var value string
    //遍历找到key对应的节点(因为有可能是哈希冲突了)
    head := link
    for {
        if head.Data.Key == key {
            value = head.Data.Value
            break
        }else {
            head = head.NextNode
        }
    }
    return value
}

 

 总的来说:就是数组(散列表) + 链表(哈希冲突),  散列表通过散列函数(哈希函数)来确认下标,因为是连续内存,可以根据数组下标偏移量来确定位置,查询复杂度为O(1),

又因为事先已经初始化了数组,只不过存的值为空而已,所以插入的时候只管往数组里面篡改数值就行了,操作复杂度为O(1)。

哈希表的特点是会有一个哈希函数,对你传来的key进行哈希运算,得到唯一的值,一般情况下都是一个数值。Golang的map中也有这么一个哈希函数,也会算出唯一的值,对于这个值的使用,Golang也是很有意思。

Golang把求得的值按照用途一分为二:高位和低位。

  它的tophash 存储的是哈希函数算出的哈希值的高八位。是用来加快索引的。因为把高八位存储起来,这样不用完整比较key就能过滤掉不符合的key,加快查询速度当一个哈希值的高8位和存储的高8位相符合,

再去比较完整的key值,进而取出value。(如果是java的hashmap,比较key是否相等直接用equal,效率会比较低,因为key有可能是一大串字符)

 参考文献:

https://segmentfault.com/a/1190000018380327?utm_source=tag-newest

https://studygolang.com/articles/14583

 

hashmap 作者原文注释:

This file contains the implementation of Go's map type.

A map is just a hash table. The data is arranged
into an array of buckets. Each bucket contains up to
8 key/value pairs. The low-order bits of the hash are
used to select a bucket. Each bucket contains a few
high-order bits of each hash to distinguish the entries
within a single bucket.

If more than 8 keys hash to a bucket, we chain on
extra buckets.

When the hashtable grows, we allocate a new array
of buckets twice as big. Buckets are incrementally
copied from the old bucket array to the new bucket array.

意思为:当bucket数组需要扩容时,它会开辟一倍的内存空间,并且会渐进式的把原数组拷贝,即用到旧数组的时候就拷贝到新数组。

Map iterators walk through the array of buckets and
return the keys in walk order (bucket #, then overflow
chain order, then bucket index). To maintain iteration
semantics, we never move keys within their bucket (if
we did, keys might be returned 0 or 2 times). When
growing the table, iterators remain iterating through the
old table and must check the new table if the bucket
they are iterating through has been moved ("evacuated")
to the new table.

Picking loadFactor: too large and we have lots of overflow
buckets, too small and we waste a lot of space. I wrote
a simple program to check some stats for different loads:

 

以下要注意append的误区:

 

posted @ 2019-07-27 15:12  天之草  阅读(1012)  评论(0编辑  收藏  举报