QQ水群时看到这么一个段子,虽然段子很老套,但这么工整的一段话看起来就让人手痒,就做一个代码练习吧。

image1.png

写一个能随机生成这段话的代码。但是如果只是一模一样地生成就有点太无聊了,最好是能根据输入的词能够按这样的模板输出。

这段文本每行的结构非常规律:{动词}{数字}{量词名词}。比如 “喝100杯奶茶” → 动词=”喝”、数字=100、后缀=”杯奶茶”。把 100 句拆完,动词和名词短语各自去重,就有了一个可复用的词库,换个输入文本也能按同样模板生成。

解析文本结构

先定义数据结构。每行拆成三部分:数字前的动词、数字本身、数字后的名词短语。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type Entry struct {
Prefix string // 动词部分
Suffix string // 量词+名词
Num int
}

func parse(text string) []Entry {
var entries []Entry
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 找到数字的起止位置
numStart, numEnd := -1, -1
for i, r := range line {
if unicode.IsDigit(r) {
if numStart == -1 {
numStart = i
}
numEnd = i + 1
}
}
if numStart == -1 {
continue
}
num, _ := strconv.Atoi(line[numStart:numEnd])
entries = append(entries, Entry{
Prefix: line[:numStart],
Suffix: line[numEnd:],
Num: num,
})
}
return entries
}

拿到 entries 后,先原样倒序打印,验证解析正确:

1
2
3
4
5
6
7
8
9
func printReverse(entries []Entry) {
fmt.Println(strings.Repeat("=", 54))
var sb strings.Builder
for i := len(entries) - 1; i >= 0; i-- {
fmt.Fprintf(&sb, "%s%d%s\n", entries[i].Prefix, entries[i].Num, entries[i].Suffix)
}
fmt.Print(sb.String())
fmt.Println(strings.Repeat("=", 54))
}
1
2
3
4
5
//======================================================
//喝100杯奶茶
//来99个拥抱
//看98场日落
//……

构建词库

把 100 个 Entry 的 Prefix 和 Suffix 分别去重:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func buildPools(entries []Entry) (prefixes, suffixes []string) {
seenP := make(map[string]bool)
seenS := make(map[string]bool)
for _, e := range entries {
if !seenP[e.Prefix] {
prefixes = append(prefixes, e.Prefix)
seenP[e.Prefix] = true
}
if !seenS[e.Suffix] {
suffixes = append(suffixes, e.Suffix)
seenS[e.Suffix] = true
}
}
return
}

这段 100 句的文本里不同的动词和不同的名词短语。理论上可以组合不同的句子。

模板随机生成

有了词库,模板就是 {前缀}{数字}{后缀}——从两个库里各随机抽一个,拼上递减的数字:

1
2
3
4
5
6
7
8
9
10
func generate(prefixes, suffixes []string, count int) string {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
var sb strings.Builder
for i := count; i >= 1; i-- {
p := prefixes[rng.Intn(len(prefixes))]
s := suffixes[rng.Intn(len(suffixes))]
fmt.Fprintf(&sb, "%s%d%s\n", p, i, s)
}
return sb.String()
}

image2.png

这样只要换一段结构相同的输入文本,就能按同样的模板生成完全不同的内容——真正做到了开头说的 “根据输入的词能够按这样的模板输出”。

1
2
3
4
5
6
7
8
9
func main() {
text := `喝100杯奶茶
来99个拥抱
看98场日落
……`
entries := parse(text)
prefixes, suffixes := buildPools(entries)
fmt.Println(generate(prefixes, suffixes, 100))
}

保持语义合理:分段 shuffle

纯随机从词库抽会产生 “爱99杯奶茶” 这种语义不通的组合。如果想让输出更自然,可以保留原始的 [前缀, 后缀] 配对,只改变它们对应的数字。做法和初版一样——分段 shuffle。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func shuffleEntries(entries []Entry, groupSize int) string {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// 按 groupSize 分段
groups := make([][]Entry, 0)
for i := 0; i < len(entries); i += groupSize {
end := i + groupSize
if end > len(entries) {
end = len(entries)
}
groups = append(groups, entries[i:end])
}
for g := 0; g < len(groups)-1; g++ {
rng.Shuffle(len(groups[g]), func(i, j int) {
groups[g][i], groups[g][j] = groups[g][j], groups[g][i]
})
}
// 拼接输出
var sb strings.Builder
for i := len(groups) - 1; i >= 0; i-- {
for _, e := range groups[i] {
fmt.Fprintf(&sb, "%s%d%s\n", e.Prefix, e.Num, e.Suffix)
}
}
return sb.String()
}

代码练习不一定非得是 LeetCode 算法题。像这样从一段文字里提取模式、抽象成数据结构、再按模板生成,也是一个很不错的练习方向。