ARTS

ARTS 是陈浩(网名左耳朵耗子)在极客时间专栏里发起的一个活动,目的是通过分享的方式来坚持学习。

每人每周写一个 ARTS:Algorithm 是一道算法题,Review 是读一篇英文文章,Technique/Tips 是分享一个小技术,Share 是分享一个观点。

本周内容

这一周的 ARTS 你将看到:

  1. Work Break II 这道题的评论区竟然有一半人都在吐槽测试例太恶心?
  2. 一篇文章了解 Golang GC 的“昨天、今天和明天”。
  3. 一个老 Gopher 常谈的问题,到底 return 和 defer 哪个先执行?
  4. 不要一开始就经陷入细节的地狱。

Algorithm

本周的算法题是 LeetCode 的 139.Work Break140.Work Break II.

我本来只想做一下 140 题,这道题目的要求简单来说就是,输入一个字符串 s 和一个由不同单词组成的字典 wordDict,由你来判断上面输入的 s 是否可以通过只添加空格的方式(只能把字符串拆分成多个“单词”,但是不能调整字符顺序)拆分成由 wordDict 中的单词组成的一个“句子”。

不(jian)难写出了下面通过回溯来“从前到后”拼接所有合法结果的代码。

“从前向后”回溯但会超时的解法

func wordBreak(s string, wordDict []string) []string {
    var ans []string
    wd := make(map[string]struct{}, len(wordDict))
    for _, word := range wordDict {
        wd[word] = struct{}{}
    }
    bt140(0, s, "", wd, &ans)
    return ans
}

func bt140(start int, s, currStr string, wd map[string]struct{}, ans *[]string) {
    if start == len(s) {
        *ans = append(*ans, currStr)
        return
    }
    newStr := currStr
    for i := start; i < len(s); i++ {
        cs := s[start : i+1]
        if _, ok := wd[cs]; !ok {
            continue
        }
        if start != 0 {
            cs = " " + cs
        }
        newStr += cs
        bt140(i+1, s, newStr, wd, ans)
        newStr = currStr
    }
}

但是无奈地发现单纯通过回溯的方式“从前向后”拼接符合要求的单词是没办法通过下面这个测试例的。无论怎么优化都超时,这是最致郁的。

s :="a...73a...aba...73a...a"
wordDict := []string{"a","aa","aaa","aaaa","aaaaa","aaaaaa","aaaaaaa","aaaaaaaa","aaaaaaaaa","aaaaaaaaaa"}

上面测试例中长达 140 多的输入字符串长度必然会让代码长时间卡在对第 75 个字符 b 判断失败之后的递归上。打开力扣评论区看到大半都在吐槽这个测试例我就放心了。除此之外还有很多评论在说可以根据第 139 题的结论,先对 140 的输入 s 判断一下是否能分割成句子,然后在真正的进行分割就可以通过。抱着试一试的心态,先把 139 题用最通俗的方式 AC 了,索性先看下 139 题。

使用第139题“从后向前”回溯的解法优化超时问题

大体思路就是使用回溯“从后往前”找满足要求的分割方式:每层判断当前起始位置 start 开始是否可以组成的单词,如果可以就把当前这个单词和 s “后面”部分每个满足要求的分割拼接在一起,直到从 start 为 0 的时候找到的单词也被拼接到结果中。具体代码如下。

func wordBreak(s string, wordDict []string) bool {
    wd := make(map[string]struct{}, len(wordDict))
    for _, s := range wordDict {
        wd[s] = struct{}{}
    }
    return breakable(s, wd)
}

func breakable(s string, wd map[string]struct{}) bool {
    mem := make(map[int]bool, len(wd))
    var dfs func(start int) bool
    dfs = func(start int) bool {
        if _, ok := mem[start]; ok {
            return mem[start]
        }
        if start == len(s) {
            return true
        }
        for i := start; i < len(s); i++ {
            _, ok := wd[s[start:i+1]]
            if ok && dfs(i+1) {
                mem[start] = true
                return true
            }
        }
        mem[start] = false
        return false
    }
    return dfs(0)
}

在最开始的 140 题目的解法中 wordBreak 函数里加入上面 breakable 函数先判断输入字符串能不能被正确分割,代码如下。

func wordBreak(s string, wordDict []string) []string {
    var ans []string
    wd := make(map[string]struct{}, len(wordDict))
    for _, word := range wordDict {
        wd[word] = struct{}{}
    }
    if !breakable(s, wd) {
        return ans
    }
    bt140(0, s, "", wd, &ans)
    return ans
}

直接使用“从后向前”的回溯方式

上面使用 139 题答案帮助判断的方式不仅可以 AC,而且最吊诡的是这个“组合”解法竟然能拿双百。想象一下如果面试的时候真的被问到这道题的话,如果给出这样的结果还是显得有些太奇怪了。毕竟针对测试例单独做优化这种路子有点野,除非遇到一个野生面试官,否则这种接法不会让他满意。

既然已经选择了回溯,就用回溯最常用的记忆化优化一下吧。因为“从前向后”的方式我暂时想不到方便的记忆化实现方式(如果你知道怎么做的话请在评论里告诉我),参考了别人的答案还是用了“从后向前”的方式来做。简单来说,就是记忆从某个 start 起到 s 结束所有的正确分割,实现可以参考下面的代码。

func wordBreak(s string, wordDict []string) []string {
    sz := len(wordDict)
    wd := make(map[string]struct{}, sz)
    mem := make(map[int][]string, sz)
    for _, word := range wordDict {
        wd[word] = struct{}{}
    }

    var dfs func(start int) []string
    dfs = func(start int) []string {
        if _, ok := mem[start]; ok {
            return mem[start]
        }
        var ret []string
        if start == len(s) {
            return ret
        }
        for i := start; i < len(s); i++ {
            w := s[start : i+1]
            if _, ok := wd[w]; !ok {
                continue
            }
            sfxs := dfs(i + 1)
            // s 中的最后一个可匹配的 word 就不需要再加空格了
            if i+1 == len(s) {
                ret = append(ret, w)
            }
            for _, sfx := range sfxs {
                ret = append(ret, w+" "+sfx)
            }
        }
        mem[start] = ret
        return ret
    }
    return dfs(0)
}

Review

这周一起回顾一下 Go 官方介绍 GOGC 的文章:Getting to Go: The Journey of Go's Garbage Collector

文章是 2018 年 7 月 在 Symposium on Memory Management (ISMM) 中的一次演讲的 PPT,可以算是目前为止官方对 Golang GC 历史最全面的一次介绍。下面是我对文中关于 GC 发展史主要内容的总结。

先说结论,目前 Golang 的 GC 算法是参考了 Dijkstra 的这篇论文 On-the-Fly Garbage Collection: An Exercise in Cooperation 来实现的。目前的 GC 是基于三色标记算法的,无分代,无碎片搬移,并使用了混合写屏障,存在微秒级别的 STW(Stop The World) 的,与业务逻辑并行执行的垃圾回收算法。

具体的故事还是从 2014 年开始说起,那时候的 GC 几乎是 Go 被吐槽最多的点,这已经严重拖累了 Go 的发展。这也是作者 Rick Hudson 刚来到 Go 团队时的情况,作者和其他开发人员一起制定了可实现的短期目标:没有读屏障的并行 GC.

Go 开发团队最终选择了“三色标记+并行执行+GC特殊阶段才开启写屏障”的实现,具体实现中还会使用“GC Pacer”来控制内存申请速度和内存标记速度,比如防止内存申请速度长期比标记速度快导致的“标记不完”的问题。

经过一番努力在 Go1.5 版本中(首次使用上述 GC 算法,在此之前写屏障会在 GC 时全程开启),经过线上服务的验证,垃圾回收延迟(原文中 GC latency)从 300-400 毫秒(ms)降低到了 30-40 毫秒。

1.6 版本中,延迟又被降低到了 5 毫秒以内。

1.8 版本中,延迟又又被降低到了 1 毫秒以内。

1.10 ……

至此作者认为 Go 终于摘掉了“GC 太垃圾”的帽子。在整个优化和提升的过程中,作者也客观的提到了 Go 团队所做出的取舍:为了编译速度放弃 ROC(Request Oriented Collector),为了吞吐量放弃分代(generational GC),等等。

最后作者表达了对未来 Go 发展的期待:保持性能的前提下提高可靠性,维持现有的 GC 模型,继续优化逃逸分析,优化写屏障,同时希望能够吃上未来五年内存性能发展所带来的红利(因为 GC 是 non-copying?)。

Tip

这周的 Tip 想聊一个经常被问到的问题:“return 和 defer 哪个先执行?”

先说结论吧:首先,编译器读到 defer 时对 defer function 的参数求值,这时会拷贝 defer function 的参数; 然后,程序执行到 return 时先将返回值保存到栈里;随后,按照后进先出的顺序执行之前保存好的 defer function;最后,函数退出并处理返回值。

如果这道题被问到的时候是结合代码的话,那么一定会涉及到 defer function 参数求值以及值传递引用传递的问题(虽然项目中并不推荐这样使用)。以上的这种问题,直接看官方的这个 https://blog.golang.org/defer-panic-and-recover 就可以完全解决了。文中最为关键的三句话,摘抄在这里。

  1. A deferred function's arguments are evaluated when the defer statement is evaluated.
    一个 defer function 的参数在 defer 语句被求值(我理解是编译器读到这句时)的时候就已经被求值了。
  2. Deferred function calls are executed in Last In First Out order after the surrounding function returns.
    defer function 在其外层函数 return 后才执行,且执行顺序是后进先出。
  3. Deferred functions may read and assign to the returning function's named return values.
    defer function 可以对函数的有名返回值进行读取和赋值操作。

最后的最后,特别提醒一个值得注意的问题,就是 defer function 参数中如果出现了指针类型的话,一定要特别关注 defer function 是否会改变指针指向的值。

这个问题就聊到这里吧,如果你觉得我的观点存在任何问题,请随时在评论区指出来。

Share 灵光一闪

最近一直在看 GOGC 相关的东西,包括一些官网文档和大神们提炼的文章,越看越想深入去研究细节。结果就是陷入茫茫细节的海洋,逐渐忘了最开始想看什么内容。这个过程很像 DFS,但问题是可能深入的分支非常深,同时越深入的内容难度也会越大,导致花了非常多精力和时间之后还是没有一个整体的认知。或许学习的过程在最开始可以像 BFS 一样,先了解整体,再逐层深入,最后需要深度的时候在选择一个或者几个关键点深入学下去。

俗话说,细节是魔鬼(The devil is in the detail)。细节可能最后是无法避免的,接触魔鬼之前多学几样驱魔之术,应该比单纯靠勇气的结果好上不少。

以上。


澎湃哥
45 声望6 粉丝