统计文件中的前N高频单词
📌 题目描述
给定一个文件夹路径和一个整数N,递归扫描这个文件夹下的所有文件,统计其中txt文件中的英文单词数量,并按照频次降序输出,文件处理要使用并发
英文单词定义为一串连续的字母,不区分大小写,hello,Hello,HELLO都被认为是同一个单词hello
输出示例
hello: 121
world: 120
gogogo: 119
🧠 解题思路
1. 获取文件
首先是获用Go的filepath.WalkDir递归遍历一个文件夹的所有文件,它的用法如下
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
fmt.Printf("Current path is %s\n", path)
fmt.Println(d.IsDir())
fmt.Println(d.Name())
fmt.Println(d.IsDir())
fmt.Println(d.Type())
return nil
})
第一个参数即要遍历的文件夹路径
第二个参数是一个回调函数,简单来说,遍历到每一个目录下的文件或文件夹时,都会调用这个函数,这个函数的第一个参数path为当前文件的路径,第二个参数d可以访问这个文件的信息,第三个参数err表示出现的错误,可以选择不处理,或根据情况处理
回到本题,获取根文件夹下的文件就很容易实现了
首先定义一个切片,用来存储文件路径,然后通过WalkDir获取文件路径,追加到这个切片
paths := make([]string, 0)
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
paths = append(paths, path)
return nil
})
2. 读文件
获取了文件路径后,就要把文件读到内存中,再进行处理了
最朴素的办法就是用os.ReadFile,如下
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
但是使用这个有一个问题,就是ReadFile它会把文件中的所有内容都读进来,假如这个文件非常大,那么很容易OOM(out of memory),所以尽量不要直接使用ReadFile
一个解决方案是逐行读取,使用scanner,如下
file, err := os.Open(path)
if err != nil {
panic(err)
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
data := scanner.Text()
_ = data
}
其中的scanner.Scan()做的就是逐行扫描(默认情况下),通过Text就可以获取到文本内容,这样一来就不容易OOM了
当然有些情况下,一个文件可能所有内容都写到了一行,导致它一行都还是很大,那不还是会OOM吗?
事实上,scanner.Scan有一个隐藏机制,当读取的内容大于一个特定值(默认是64KB,可修改),就会报错
最终的方案就是分块读,使用bufio包,到读到一定size的文本时就停止,将缓冲池清空后再读取,示例如下
reader := bufio.NewReader(file)
buffer := make([]byte, 4096) // 4KB
for {
n, err := reader.Read(buffer)
if n > 0 {
chunk := buffer[:n]
_ = chunk
}
if err == io.EOF {
break
}
if err != nil {
panic(err)
}
}
因为这里要处理连续单词的逻辑,故本文暂且使用第二种逐行读取的方案
3. 获取单词
可以使用正则表达式来匹配单词,如下所示
data := scanner.Text()
re := regexp.MustCompile(`(?i)\b[a-z]+\b`)
words := re.FindAllString(data, -1)
4. 并发处理
对于每一个文件,我们都可以用启动一个goroutine来处理它,这个子goroutine将处理后的单词通过channel传入主gorouitne
主goroutine的逻辑如下
go func() {
for {
select {
case word := <-wordCh:
wordCounter[word]++
case <-allDone:
return
}
}
}()
对于每一个文件,启动一个goroutine来处理,通过WaitGroup来判断是否全部完成
for _, path := range paths {
wg.Add(1)
go func() {
defer wg.Done()
// 文件处理逻辑...
defer file.Close()
}()
}
wg.Wait()
allDone <- struct{}{}
5. 排序输出
将所有的word放到一个切片里,根据出现次数排序,再输出即可,逻辑如下
for word, count := range wordCounter {
words = append(words, struct {
s string
count int
}{s: word, count: count})
}
sort.Slice(words, func(i, j int) bool {
return words[i].count > words[j].count
})
for i := 0; i < N; i++ {
fmt.Printf("%s: %d\n", words[i].s, words[i].count)
}
⏱️ 时间复杂度
假设有这些变量
F:.txt文件的数量L:所有.txt文件的总行数C:所有文件中字符总数W:所有文件中英文单词总数U:不同英文单词的数量(unique words)N:我们需要的前 N 个词
时间复杂度为O(T + L + C + W + U log U)
空间复杂度为O(U)
✅ 代码实现(语言:Go)
package n_frequent_words
import (
"bufio"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"sync"
)
var wordRe = regexp.MustCompile(`(?i)\b[a-z]+\b`)
func Solve(root string, N int) {
paths := make([]string, 0)
wordCounter := make(map[string]int)
wordCh := make(chan string)
allDone := make(chan struct{})
wg := sync.WaitGroup{}
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
paths = append(paths, path)
return nil
})
go func() {
for {
select {
case word := <-wordCh:
wordCounter[word]++
case <-allDone:
close(allDone)
close(wordCh)
return
}
}
}()
for _, path := range paths {
wg.Add(1)
go func() {
defer wg.Done()
file, err := os.Open(path)
if err != nil {
panic(err)
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
data := scanner.Text()
words := wordRe.FindAllString(data, -1)
for _, word := range words {
wordCh <- word
}
}
defer file.Close()
}()
}
wg.Wait()
allDone <- struct{}{}
var words []struct {
s string
count int
}
for word, count := range wordCounter {
words = append(words, struct {
s string
count int
}{s: word, count: count})
}
sort.Slice(words, func(i, j int) bool {
return words[i].count > words[j].count
})
for i := 0; i < N; i++ {
fmt.Printf("%s: %d\n", words[i].s, words[i].count)
}
}
🤖 测试
generate函数是生成测试文件的函数,这里不展开
package n_frequent_words
import "testing"
func TestSolve(t *testing.T) {
generate()
Solve("./", 5)
}
测试结果
~/go/src/my-space/interview/n_frequent_words
go test -v
=== RUN TestSolve
Generating: testdata/file_01.txt
Generating: testdata/file_02.txt
Generating: testdata/file_03.txt
Generating: testdata/file_04.txt
Generating: testdata/file_05.txt
Done.
buffer: 6993
NETWORK: 6982
VARIABLE: 6888
FUNCTION: 6883
apple: 6871
--- PASS: TestSolve (0.33s)
PASS
ok n_frequent_words 0.732s