gopl-zh.github.com/ch4/ch4-03.md

12 KiB
Raw Blame History

4.3. Map

哈希表是一種巧妙併且實用的數據結構。它是一個無序的key/value對的集合其中所有的key都是不同的然後通過給定的key可以在常數時間複雜度內檢索、更新或刪除對應的value。

在Go語言中一個map就是一個哈希表的引用map類型可以寫爲map[K]V其中K和V分别對應key和value。map中所有的key都有相同的類型所以的value也有着相同的類型但是key和value之間可以是不同的數據類型。其中K對應的key必須是支持==比較運算符的數據類型所以map可以通過測試key是否相等來判斷是否已經存在。雖然浮點數類型也是支持相等運算符比較的但是將浮點數用做key類型則是一個壞的想法正如第三章提到的最壞的情況是可能出現的NaN和任何浮點數都不相等。對於V對應的value數據類型則沒有任何的限製。

內置的make函數可以創建一個map

ages := make(map[string]int) // mapping from strings to ints

我們也可以用map字面值的語法創建map同時還可以指定一些最初的key/value

ages := map[string]int{
	"alice":   31,
	"charlie": 34,
}

這相當於

ages := make(map[string]int)
ages["alice"] = 31
ages["charlie"] = 34

因此另一種創建空的map的表達式是map[string]int{}

Map中的元素通過key對應的下標語法訪問

ages["alice"] = 32
fmt.Println(ages["alice"]) // "32"

使用內置的delete函數可以刪除元素

delete(ages, "alice") // remove element ages["alice"]

所有這些操作是安全的卽使這些元素不在map中也沒有關繫如果一個査找失敗將返迴value類型對應的零值例如卽使map中不存在“bob”下面的代碼也可以正常工作因爲ages["bob"]失敗時將返迴0。

ages["bob"] = ages["bob"] + 1 // happy birthday!

而且x += yx++等簡短賦值語法也可以用在map上所以上面的代碼可以改寫成

ages["bob"] += 1

更簡單的寫法

ages["bob"]++

但是map中的元素併不是一個變量因此我們不能對map的元素進行取址操作

_ = &ages["bob"] // compile error: cannot take address of map element

禁止對map元素取址的原因是map可能隨着元素數量的增長而重新分配更大的內存空間從而可能導致之前的地址無效。

要想遍歷map中全部的key/value對的話可以使用range風格的for循環實現和之前的slice遍歷語法類似。下面的迭代語句將在每次迭代時設置name和age變量它們對應下一個鍵/值對:

for name, age := range ages {
	fmt.Printf("%s\t%d\n", name, age)
}

Map的迭代順序是不確定的併且不同的哈希函數實現可能導致不同的遍歷順序。在實踐中遍歷的順序是隨機的每一次遍歷的順序都不相同。這是故意的每次都使用隨機的遍歷順序可以強製要求程序不會依賴具體的哈希函數實現。如果要按順序遍歷key/value對我們必須顯式地對key進行排序可以使用sort包的Strings函數對字符串slice進行排序。下面是常見的處理方式

import "sort"

var names []string
for name := range ages {
	names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
	fmt.Printf("%s\t%d\n", name, ages[name])
}

因爲我們一開始就知道names的最終大小因此給slice分配一個合適的大小將會更有效。下面的代碼創建了一個空的slice但是slice的容量剛好可以放下map中全部的key

names := make([]string, 0, len(ages))

在上面的第一個range循環中我們隻關心map中的key所以我們忽略了第二個循環變量。在第二個循環中我們隻關心names中的名字所以我們使用“_”空白標識符來忽略第一個循環變量也就是迭代slice時的索引。

map類型的零值是nil也就是沒有引用任何哈希表。

var ages map[string]int
fmt.Println(ages == nil)    // "true"
fmt.Println(len(ages) == 0) // "true"

map上的大部分操作包括査找、刪除、len和range循環都可以安全工作在nil值的map上它們的行爲和一個空的map類似。但是向一個nil值的map存入元素將導致一個panic異常

ages["carol"] = 21 // panic: assignment to entry in nil map

在向map存數據前必須先創建map。

通過key作爲索引下標來訪問map將産生一個value。如果key在map中是存在的那麽將得到與key對應的value如果key不存在那麽將得到value對應類型的零值正如我們前面看到的ages["bob"]那樣。這個規則很實用但是有時候可能需要知道對應的元素是否眞的是在map之中。例如如果元素類型是一個數字你可以需要區分一個已經存在的0和不存在而返迴零值的0可以像下面這樣測試

age, ok := ages["bob"]
if !ok { /* "bob" is not a key in this map; age == 0. */ }

你會經常看到將這兩個結合起來使用,像這樣:

if age, ok := ages["bob"]; !ok { /* ... */ }

在這種場景下map的下標語法將産生兩個值第二個是一個布爾值用於報告元素是否眞的存在。布爾變量一般命名爲ok特别適合馬上用於if條件判斷部分。

和slice一樣map之間也不能進行相等比較唯一的例外是和nil進行比較。要判斷兩個map是否包含相同的key和value我們必須通過一個循環實現

func equal(x, y map[string]int) bool {
	if len(x) != len(y) {
		return false
	}
	for k, xv := range x {
		if yv, ok := y[k]; !ok || yv != xv {
			return false
		}
	}
	return true
}

要註意我們是如何用!ok來區分元素缺失和元素不同的。我們不能簡單地用xv != y[k]判斷那樣會導致在判斷下面兩個map時産生錯誤的結果

// True if equal is written incorrectly.
equal(map[string]int{"A": 0}, map[string]int{"B": 42})

Go語言中併沒有提供一個set類型但是map中的key也是不相同的可以用map實現類似set的功能。爲了説明這一點下面的dedup程序讀取多行輸入但是隻打印第一次出現的行。它是1.3節中出現的dup程序的變體。dedup程序通過map來表示所有的輸入行所對應的set集合以確保已經在集合存在的行不會被重複打印。

gopl.io/ch4/dedup

func main() {
	seen := make(map[string]bool) // a set of strings
	input := bufio.NewScanner(os.Stdin)
	for input.Scan() {
		line := input.Text()
		if !seen[line] {
			seen[line] = true
			fmt.Println(line)
		}
	}

	if err := input.Err(); err != nil {
		fmt.Fprintf(os.Stderr, "dedup: %v\n", err)
		os.Exit(1)
	}
}

Go程序員將這種忽略value的map當作一個字符串集合併非所有map[string]bool類型value都是無關緊要的有一些則可能會同時包含tue和false的值。

有時候我們需要一個map或set的key是slice類型但是map的key必須是可比較的類型但是slice併不滿足這個條件。不過我們可以通過兩個步驟繞過這個限製。第一步定義一個輔助函數k將slice轉爲map對應的string類型的key確保隻有x和y相等時k(x) == k(y)才成立。然後創建一個key爲string類型的map在每次對map操作時先用k輔助函數將slice轉化爲string類型。

下面的例子演示了如何使用map來記録提交相同的字符串列表的次數。它使用了fmt.Sprintf函數將字符串列表轉換爲一個字符串以用於map的key通過%q參數忠實地記録每個字符串元素的信息

var m = make(map[string]int)

func k(list []string) string { return fmt.Sprintf("%q", list) }

func Add(list []string)       { m[k(list)]++ }
func Count(list []string) int { return m[k(list)] }

使用同樣的技術可以處理任何不可比較的key類型而不僅僅是slice類型。這種技術對於想使用自定義key比較函數的時候也很有用例如在比較字符串的時候忽略大小寫。同時輔助函數k(x)也不一定是字符串類型,它可以返迴任何可比較的類型,例如整數、數組或結構體等。

這是map的另一個例子下面的程序用於統計輸入中每個Unicode碼點出現的次數。雖然Unicode全部碼點的數量鉅大但是出現在特定文檔中的字符種類併沒有多少使用map可以用比較自然的方式來跟蹤那些出現過字符的次數。

gopl.io/ch4/charcount

// Charcount computes counts of Unicode characters.
package main

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"unicode"
	"unicode/utf8"
)

func main() {
	counts := make(map[rune]int)    // counts of Unicode characters
	var utflen [utf8.UTFMax + 1]int // count of lengths of UTF-8 encodings
	invalid := 0                    // count of invalid UTF-8 characters

	in := bufio.NewReader(os.Stdin)
	for {
		r, n, err := in.ReadRune() // returns rune, nbytes, error
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Fprintf(os.Stderr, "charcount: %v\n", err)
			os.Exit(1)
		}
		if r == unicode.ReplacementChar && n == 1 {
			invalid++
			continue
		}
		counts[r]++
		utflen[n]++
	}
	fmt.Printf("rune\tcount\n")
	for c, n := range counts {
		fmt.Printf("%q\t%d\n", c, n)
	}
	fmt.Print("\nlen\tcount\n")
	for i, n := range utflen {
		if i > 0 {
			fmt.Printf("%d\t%d\n", i, n)
		}
	}
	if invalid > 0 {
		fmt.Printf("\n%d invalid UTF-8 characters\n", invalid)
	}
}

ReadRune方法執行UTF-8解碼併返迴三個值解碼的rune字符的值字符UTF-8編碼後的長度和一個錯誤值。我們可預期的錯誤值隻有對應文件結尾的io.EOF。如果輸入的是無效的UTF-8編碼的字符返迴的將是unicode.ReplacementChar表示無效字符併且編碼長度是1。

charcount程序同時打印不同UTF-8編碼長度的字符數目。對此map併不是一個合適的數據結構因爲UTF-8編碼的長度總是從1到utf8.UTFMax最大是4個字節使用數組將更有效。

作爲一個實驗我們用charcount程序對英文版原稿的字符進行了統計。雖然大部分是英語但是也有一些非ASCII字符。下面是排名前10的非ASCII字符

下面是不同UTF-8編碼長度的字符的數目

len count
1   765391
2   60
3   70
4   0

Map的value類型也可以是一個聚合類型比如是一個map或slice。在下面的代碼中圖graph的key類型是一個字符串value類型map[string]bool代表一個字符串集合。從概念上將graph將一個字符串類型的key映射到一組相關的字符串集合它們指向新的graph的key。

gopl.io/ch4/graph

var graph = make(map[string]map[string]bool)

func addEdge(from, to string) {
	edges := graph[from]
	if edges == nil {
		edges = make(map[string]bool)
		graph[from] = edges
	}
	edges[to] = true
}

func hasEdge(from, to string) bool {
	return graph[from][to]
}

其中addEdge函數惰性初始化map是一個慣用方式也就是説在每個值首次作爲key時才初始化。addEdge函數顯示了如何讓map的零值也能正常工作卽使from到to的邊不存在graph[from][to]依然可以返迴一個有意義的結果。

練習 4.8 脩改charcount程序使用unicode.IsLetter等相關的函數統計字母、數字等Unicode中不同的字符類别。

練習 4.9 編寫一個程序wordfreq程序報告輸入文本中每個單詞出現的頻率。在第一次調用Scan前先調用input.Split(bufio.ScanWords)函數,這樣可以按單詞而不是按行輸入。