Arts 第六十周(5/25 ~5/31)

222 阅读14分钟

ARTS是什么?
Algorithm:每周至少做一个leetcode的算法题;
Review:阅读并点评至少一篇英文技术文章;
Tip:学习至少一个技术技巧;
Share:分享一篇有观点和思考的技术文章。

Algorithm

LC 886. Possible Bipartition

题目解析

有一堆人,他们中间会相互讨厌彼此,把这堆人分成两组,相互讨厌的人不能被分在一组,问你是否可分。这道题目的输入条件是人数和一个数组表示的相互讨厌的人。

一开始的时候没想太多,就直接弄了两个集合表示两个组,然后遍历讨厌关系数组依次往两个集合里面放人,如果有冲突就 return false,如果能放完,就 return true。这么简单粗暴的想法肯定是有问题的,问题在于这么做缺乏全局的考虑,比如下面这个例子:

[[1,2],[3,4],[5,6],[1,6],[3,6]]

上面的例子是可以将人分成 1,3,52,4,6 这两组,但是当你遍历完 [1,2] 后,你遍历到 [3,4] 的时候,你并不知道到底 3 是要放在 1 那一组还是 2 那一组。

我们需要进行更为全面的遍历,你可以把这个想象成图的问题,图上的每个节点代表的是每个人,相邻的两个人相互讨厌。然后,我们这时有两种颜色,我们需要把所有的节点都标上颜色,相邻的两个节点不能标成同样的颜色,否则,算作失败,当把所有节点都标好颜色后,表示可以分成两组。我们可以用深度优先搜索来实现,当然广度优先搜索也是可以的。

这道题目也可以用并查集来做,思考也非常直接,当我们发现两个人相互讨厌对方,那就去和除自己外,对方讨厌的人做集合合并。比如例子:

[[1,2],[1,4]]

我们还是遍历上面这个数组,遍历到 [1,2] 时,1,2 自成一个集合,并且标记 1 讨厌的人是 2,2 讨厌的人是 1。然后,遍历到 [1,4] 时,4 会去和 1 讨厌的人,也就是 2 所在的集合合并。因为只有两个集合,不在这个集合就会在那个集合,不能和讨厌的人在同一个集合。

时间复杂度方面,如果使用搜索,那么要遍历到图上的节点和边,时间复杂度就是 O(N + M) 这里的 N 表示的是人数,M 表示的是 dislikes 数组的长度。使用并查集的话,因为我们用到了路径压缩,每次的 find 还有 union 操作可以看作是 O(1) 的时间复杂度,整体时间复杂度就会是 O(N)

空间复杂度和时间一样。说到这里,你可能会说并查集更优,但其实这么简单的认为是不准确的,这里你并不知道图上边的个数,也就是输入参数是不确定的,并查集中的 O(1) 只能算是平均时间复杂度,而且我们忽略了整体时间中的常数项。因此,哪种方法更优还要结合实际情况来考虑


参考代码(DFS)

func possibleBipartition(N int, dislikes [][]int) bool {
    group := map[int][]int{}

    for i := 1; i <= N; i++ {
        group[i] = []int{}
    }

    // group 中存放每个人所讨厌的人
    for i := 0; i < len(dislikes); i++ {
        group[dislikes[i][0]] = append(group[dislikes[i][0]], dislikes[i][1])
        group[dislikes[i][1]] = append(group[dislikes[i][1]], dislikes[i][0])
    }

    // colors 数组存放每个人(节点)被标记的颜色
    // 0 表示还未被标记
    // -1,1 表示两种颜色
    colors := make([]int, N + 1)

    // 遍历所有人(节点)
    for i := 1; i <= N; i++ {
        // 如果该节点还未被标记,那么就去标记
        // 如果标记不成功,返回 false
        if colors[i] == 0 && !dfs(colors, group, i, 1) {
            return false
        }
    }

    return true
}

func dfs(colors []int, group map[int][]int, index int, color int) bool {
    // 给当前节点做标记
    colors[index] = color
    
    // 遍历相邻节点(讨厌的人)
    for _, v := range group[index] {
        // 如果发现讨厌的人被标记同样的颜色
        // 表示无法标记,return false
        if colors[v] == color {
            return false
        }

        // 相邻节点未被标记
        // 递归进行标记,颜色和当前节点必须不一样
        if colors[v] == 0 && !dfs(colors, group, v, -color) {
            return false
        }
    }

    return true
}

参考代码(Union-Find)

func possibleBipartition(N int, dislikes [][]int) bool {
    // 集合数组
    root := make([]int, N + 1)

    // 用于记录讨厌的人
    hater := make([]int, N + 1)

    // 初始化,所有人一开始自成集合
    for i := 0; i <= N; i++ {
        root[i] = i
    }
    
    
    for i := 0; i < len(dislikes); i++ {
        e0, e1 := dislikes[i][0], dislikes[i][1]

        // 分别找到这两个相互讨厌的人所在的集合
        r0, r1 := find(root, e0), find(root, e1)

        // 如果在同一个集合,直接返回 false
        if r0 == r1 {
            return false
        }

        // 记录讨厌的人
        if hater[e0] == 0 {
            hater[e0] = e1
        }

        if hater[e1] == 0 {
            hater[e1] = e0
        }

        // 和彼此相互讨厌的人所在的集合合并
        union(root, hater[e0], e1)
        union(root, hater[e1], e0)
    }

    return true
}

func find(root []int, ele int) int {
    if root[ele] == ele {
        return ele
    }

    root[ele] = find(root, root[ele])

    return root[ele]
}

func union(root []int, ele1 int, ele2 int) {
    root1 := find(root, ele1)
    root2 := find(root, ele2)

    if root1 != root2 {
        root[root1] = root2
    }
}

Review

Code Review Best Practices

这篇文章摘录自美国一家叫做 Palantir 的公司的技术博客。主要是讲述了代码评审的一些注意事项,以及我们为什么要做代码评审,其背后有那些意义。

  • 我们为什么要做代码评审,其背后的价值是什么?

    • 首先提交代码的人可以从代码评审中获知一些有价值的信息,比如代码的风格要求,自己的代码是否存在 bugs,通过交流让自己对自己写的东西更自信,同时也可以快速知道自己的问题所在
    • 代码评审提供了一个很好的交流技术认知与观点的渠道,通过这样的交流,代码提交者和评审员都可以相互了解彼此的不足,相互鼓励。如果代码评审做得好可以有效凝结团队的凝聚力
    • 代码评审可以让代码更加的一致,清晰易懂,让代码更易维护
  • 代码评审具体评审什么?

    这个主要看团队,没有具体的规定。有的团队只需要保证基本的功能 work,有些团队则需要审查代码的方方面面。可以看到的是,这里有一个效率和代码质量的 trade-off,也就是说你代码评审做的越仔细,肯定能发现更多的问题,但是需要花更多的时间,开发效率会受影响。

    另外就是,代码评审没有所谓的 “阶级性”,不管是普通的软件工程师写出来的代码,还是经验非常丰富的资深软件工程师写出来的代码,都需要进行代码评审。就算是你的代码中不存在任何的错误,代码评审也可以给别人提供很好的参考和学习的机会。代码评审的目的并不一定是要找出程序中的 bugs,其背后还有很多潜在的价值,知道这一点非常重要。

  • 何时做代码评审?

    代码评审一般是在各种测试以及代码风格检测之后,在代码提交到 main branch 之前。这样做可以确保评审只需要针对特定的业务逻辑,可以节省评审员的时间。另外就是,如果你的一次提交的修改量过大,比如说修改超过五个文件,那么就需要将一次提交拆解成多个小的提交,这样可以保证每个提交的主题比较清晰,评审会更有针对性,效果也更好。

    每一个提交最好只有一个目的,比如说你这个提交是为了解决之前的一个 bug,那么这个提交之中就不要出现比如说代码重构等内容,否则的话,很可能会造成混乱,因为这两者从结果上看就存在本质的区别,如果是解决了一个 bug,那么需要通过具体的测试,或者有具体的结果证伪;而如果是重构代码,那么就不应该期待有功能上或者是运行结果上的改变。

  • 谁来做评审员?

    一般来说,其中一个评审员会是较为资深的软件工程师或是团队的领导。评审员数量尽量不要超过三个,因为每个人都或多或少有自己的观点和偏好,有些时候这么做可以,那么做也行,过多的观点有时并不是一个好事,很可能会影响代码评审的效率。

  • 代码评审中的一些注意事项

    应该从下面的几个方面来思考:

    • 目的:这次代码提交的目的是什么,这次提交达到目的了吗?对于代码的变动,不懂的地方可以提出对应的问题,比如说为什么要改这个类,要改那个函数,当解释不是特别清楚的时候,可能意味着要思考是否需要重写,或是加上对应的测试和注释

    • 具体实现:可以思考自己之前是否也做过类似的改变?当时是怎么做的,遇到了哪些问题,这些问题对这次提交有没有借鉴意义?另外,思考代码的实现中是否存在冗余,还有就是有没有新引入外部依赖。最后再大致看看代码有没有准守基本的代码标准

    • 代码可读性:作为评审员,可以问问自己读过代码后的感受,找出代码中让自己模糊不清的地方,比如变量命名不清,函数没有拆分等等。另外就是,如果代码中有 TODOs 时,需要提醒提交员记录,比如将这个 TODO 放到任务清单中,或是提交 GitHub 的 issue,不然的话,很容易遗忘。

    • 可维护性:代码必须要有测试,并查看对应的测试覆盖率等基本信息。如果项目中有 README 文件,必须考虑到该文件是否要做更新。评审员需要留下整体的回复,让代码提交者清楚地知道自己的建议或者认可。

  • 评论和回复

    代码评审中的评论要尽量的简洁、友好并且态度积极,避免带有强制性的口吻,我们需要知道建议和命令的区别,比如下面的语句就是建议

    Is this really the correct behavior? If so, please add a comment explaining the logic.

    而下面这句话就是命令,要尽量避免:

    Add @Override

    在一次的评审过后,记得加上自己的期待,比如

    Feel free to merge after responding to the few minor suggestions

    Please consider my suggestions and let me know when I can take another look.

    对于代码提交者来说,一定要重视别人的评论,每个评论都要写上自己的最后的决定和做这个决定的理由,即使是简单的 “ACK” 或者是 “done” 也不例外,这是对别人的一个基本的尊重,不管怎样,别人花了时间来帮你,要让别人看出你积极诚恳的态度。另外,如果不能够和评审员保持一样的观点,记得一定要通过线下或者其他的渠道进行交流。

总之,就像文中说的那样,代码评审这个东西,更像是一门艺术而不是科学,去学习它的唯一途径就是去做,去实践,去踩坑,才会发现自己变得越来越有经验,但是前提是我们需要有看重代码评审的意识,以及认真学习的态度。


Tip

上周在学习 Andrew Ng 公开课的时候,了解了 Octave 这个图形数据化的工具。之前用过 Matlab,这个是 Matlab 的开源版本,安装好后直接在命令行就可以运行,感觉非常的方便,这里做下简单的记录


如果是在 Mac 上安装 Octave,非常的简单,只需要借助 Homebrew,确认 Homebrew 更新后,一行命令就可以搞定:

>$ brew install octave

相关命令

  • 调节命令行前面的引导字符

    >$ PS1('>>')
    
    >>
    
  • 定义向量和矩阵

    % 用分号表示行结束
    % 向量一般默认是 m x 1 的大小,区别于数组的 1 x m
    v = [1;2;3] % vector
    arr = [1 2 3] % array
    matrix = [1 2; 3 4; 5 6]  % 3 x 2 matrix
    
    a = 1:0.1:2 % 生成一个等间隔数组
    
  • 一些内置的矩阵相关的函数

    >> one(2, 3)
    ans =
    
     1   1   1
     1   1   1
    
    >> zeros(2, 3)
    ans =
    
     0   0   0
     0   0   0
    
    >> eye(3)
    ans =
    
    Diagonal Matrix
    
     1   0   0
     0   1   0
     0   0   1
    
  • 加载并保存数据数据文件

    >> load {filename}  % 加载 filename 文件中的数据
    >> save {filename} {variableName} % 把一个变量表示数据导入到对应的文件中
    
  • 矩阵的常见操作

    >> A(2,:) % : 表示取整行或者是整列,这个例子中取的是第二行
    >> A([1 3], :) % 取第一行和第三行
    >> A(A[1;2;3;4]) % 按列拼接
    >> A = A(:) % 把矩阵转换成向量
    
  • 矩阵的常见计算

    >> A' % 矩阵的转置
    >> pinv(A) % 求逆矩阵,inv(A) 也是求逆矩阵,不同之处在于 pinv 会一直有解,即使矩阵不可逆
    >> A .* B  % . 后加运算符表示的是矩阵的算术计算,这里仅仅矩阵对应位置的元素相乘,区别于矩阵计算
    
  • 一些常用的内置函数

    • sum
    • prod
    • find
    • floor & ceil
    • max
  • 一些常见的图形命令,这里就不一一解释,对应查文档即可

    • plot
    • hold on
    • xlabel & ylabel
    • legend
    • title
    • print
    • close
    • figure
    • subplot
    • clf
    • imagesc
    • colorbar
    • colormap

Share

分享皓叔的一篇关于 makefile 的 文章

makefile 可以说是相当常见且基础的东西了,但由于自己的专业不是计算机相关,对这个名词还是相当地陌生。可能 makefile 在当下并没有这么经常被提及,我想原因可能是现代的 IDE 帮你做了很多事情,比如 VS Code,要做什么直接安装一个插件就好了,没必要费事费时地去写 makefile,写不好还容易出错。

那之所以想学学如何写 makefile 的原因在于,makefile 实在是太常见了,在项目中经常看到,似乎成为了行业中的一种常见的规范。另外就是 makefile 可以让你离计算机底层更近一点,让你更加了解一些语言的编译流程和环境配置,总之,能够让你思考得更深。

这篇文章是这个系列的第一篇,主要就是两点,一是程序的编译和链接,二是 makefile 的基本规则,做个学习记录:

  • 程序的编译和链接:

    比如 Java,C,C++ 这样的语言,在运行之前都需要有一个编译的步骤,这个步骤主要是编译器帮助检测程序的语法是否正确,编译过后会生成一个中间文件,比如 C 文件经过编译后在 UNIX 系统下生成 .o 文件,表示的是对象文件(object file)。

    但是编译过后,还需要链接函数和全局变量,我们需要使用 .o 文件来链接程序。如果源文件过多,会生成很多的 .o 文件,在链接时需要明显指出中间目标文件名,为了方便起见,我们需要 makefile 来定义这一层的依赖关系。链接这个过程不会考虑源文件,只会在 .o 文件中寻找。

  • makefile 的基本规则:

    大致规则如下:

    target ... : prerequisites ...
              command
                ...
    

    target 表示的是目标文件,prerequisites 表示的是要生成 target 所需要的文件,command 是 make 需要执行的命令。

    makefile 说白了就是表示的是文件的依赖关系,换句话说就是 target 依赖于 prerequisites,其生成的规则在 command 中,如果 prerequisites 中有一个以上的文件有更新的话,command 就会被执行