在Go语言中构建N-gram频率表时,很多开发者会直接按字节切割字符串,这种方式在处理纯英文文本时可能暂时正常工作,但一旦遇到中文、 emoji等包含多字节的Unicode字符,就会出现统计错误。正确做法是基于Unicode符文(rune)进行处理,才能构建出健壮的N-gram频率表。

N-gram与Unicode符文基础
N-gram指文本中连续N个单元组成的序列,根据单元类型可以分为字符级N-gram、词级N-gram等。在字符级N-gram场景下,这里的单元就应该是完整的Unicode字符,也就是Go语言中的rune类型。Go语言中字符串底层是字节序列,直接遍历字符串得到的是字节,而使用for range遍历字符串时,每次迭代得到的是rune,这才是我们需要的处理单元。
比如字符串"你好a",包含3个符文:'你'、'好'、'a'。如果按字节处理,底层有6个字节(每个中文占3字节,英文占1字节),会得到完全不符合预期的分割结果。
基础N-gram频率表实现
我们先实现基于符文处理的N-gram频率统计核心逻辑,使用map来存储N-gram到出现次数的映射。
package main
import (
"fmt"
)
// 构建N-gram频率表,输入文本和n值,返回map[string]int
func buildNGramFreq(text string, n int) map[string]int {
freq := make(map[string]int)
// 将字符串转换为符文切片,方便按索引取连续n个符文
runes := []rune(text)
// 如果文本长度小于n,无法生成N-gram,直接返回空map
if len(runes) < n {
return freq
}
// 遍历符文切片,取连续n个符文组成N-gram
for i := 0; i <= len(runes)-n; i++ {
// 截取连续n个符文,转换为字符串作为N-gram的键
ngram := string(runes[i : i+n])
freq[ngram]++
}
return freq
}
func main() {
text := "你好世界,Hello"
n := 2
freq := buildNGramFreq(text, n)
for k, v := range freq {
fmt.Printf("N-gram: %s, 频率: %dn", k, v)
}
}
上述代码中,我们先将字符串转换为[]rune类型,这样就保证了每个元素都是一个完整的Unicode字符。之后遍历符文切片,每次取连续的n个符文组成N-gram,统计出现次数。
处理边界情况与异常输入
实际使用场景中,输入文本可能包含各种特殊情况,需要进一步完善实现:
- 输入n值小于等于0时,需要返回错误或者空结果,避免无效计算
- 文本中包含连续的空白符、不可见控制符时,是否需要过滤可以根据业务需求调整
- 如果文本是空字符串,直接返回空的频率表
下面是优化后的实现,增加了参数校验和可选的空白符过滤逻辑:
package main
import (
"fmt"
"strings"
"unicode"
)
// 构建N-gram频率表,支持过滤空白符
// text: 输入文本,n: N-gram的n值,filterSpace: 是否过滤空白符
func buildNGramFreqV2(text string, n int, filterSpace bool) (map[string]int, error) {
// 参数校验
if n <= 0 {
return nil, fmt.Errorf("n必须大于0")
}
freq := make(map[string]int)
// 处理空文本
if text == "" {
return freq, nil
}
// 转换为符文切片
runes := []rune(text)
// 如果需要过滤空白符,先过滤掉空白符符文
if filterSpace {
var filtered []rune
for _, r := range runes {
if !unicode.IsSpace(r) {
filtered = append(filtered, r)
}
}
runes = filtered
}
// 文本长度小于n,无法生成N-gram
if len(runes) < n {
return freq, nil
}
// 统计N-gram频率
for i := 0; i <= len(runes)-n; i++ {
ngram := string(runes[i : i+n])
freq[ngram]++
}
return freq, nil
}
func main() {
text := " 你好 世界 Hello "
n := 2
// 不过滤空白符的情况
freq1, _ := buildNGramFreqV2(text, n, false)
fmt.Println("不过滤空白符的结果:")
for k, v := range freq1 {
fmt.Printf("N-gram: %s, 频率: %dn", k, v)
}
// 过滤空白符的情况
freq2, _ := buildNGramFreqV2(text, n, true)
fmt.Println("n过滤空白符的结果:")
for k, v := range freq2 {
fmt.Printf("N-gram: %s, 频率: %dn", k, v)
}
}
使用utf8包处理字节流场景
如果我们的输入不是完整的字符串,而是字节流(比如从网络、文件读取的分块字节数据),这时候可以结合unicode/utf8包来逐个解码符文,避免一次性将全部字节转换为符文切片占用过多内存。
utf8包提供了DecodeRune方法,可以从字节切片中解码出一个符文,并返回符文和占用的字节数。我们可以用这个方法逐个处理字节流中的符文,维护一个滑动窗口来生成N-gram。
package main
import (
"fmt"
"unicode/utf8"
)
// 从字节流中构建N-gram频率表,适合处理大文件或网络流
func buildNGramFromBytes(data []byte, n int) map[string]int {
freq := make(map[string]int)
if n <= 0 {
return freq
}
// 滑动窗口存储最近的n个符文
window := make([]rune, 0, n)
// 当前处理到的字节索引
idx := 0
for idx < len(data) {
// 解码一个符文
r, size := utf8.DecodeRune(data[idx:])
if r == utf8.RuneError {
// 遇到无效的UTF8编码,跳过该字节
idx++
continue
}
idx += size
// 将符文加入窗口
window = append(window, r)
// 如果窗口长度超过n,移除最前面的符文
if len(window) > n {
window = window[1:]
}
// 窗口长度等于n时,生成N-gram并统计
if len(window) == n {
ngram := string(window)
freq[ngram]++
}
}
return freq
}
func main() {
// 模拟包含中文和英文的字节数据
data := []byte("你好世界Hello")
freq := buildNGramFromBytes(data, 2)
for k, v := range freq {
fmt.Printf("N-gram: %s, 频率: %dn", k, v)
}
}
总结
在Go语言中构建N-gram频率表的核心在于正确处理Unicode符文,避免直接按字节切割字符串。对于小文本场景,可以先将字符串转换为[]rune再处理;对于大字节流场景,可以结合unicode/utf8包逐个解码符文,通过滑动窗口生成N-gram。同时需要根据业务需求做好参数校验和边界情况处理,才能构建出健壮可靠的N-gram频率统计功能。
Go语言N-gram频率表Unicode符文处理utf8包修改时间:2026-06-09 20:09:25