Go面试题,统计文件中的前N高频单词

72 阅读4分钟

统计文件中的前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