Arts 第八十周(1/4 ~1/10)

371 阅读8分钟

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

Algorithm

LC 647. Palindromic Substrings

题目解析

给定一个字符串,需要求出这个字符串的回文子串的个数。这是一道非常经典的题目,通过这道题可以衍生出来很多类似的题目。

一般说来,和回文相关的题目可以试着用动态规划去解,这里也有着相类似的解题思路。那么我们就通过这道题来看看用动态规划具体怎么解这一类的题目。首先,我们需要找到状态和子问题。其实题目暗示已经很明显了,起始下标和终止下标确定一个区间,也就是确定一个子串。那么我们就可以定义 dp[i][j] 表示的是区间 [i, j] 上的子串是否回文。然后,我们需要找到递归方程,也就是子问题之间的递进关系。换句话说,当前状态怎么推导到下一个状态的?我们需要找到相邻状态之间的逻辑关系。我们通过一个例子来看看:

比如,输入的字符串是 abcba,我们可以画出表格

i\j a  b  c  b  a
 a  T  F  F  F  T
 b  F  T  F  T  F
 c  F  F  T  F  F
 b  F  F  F  T  F
 a  F  F  F  F  T

通过上面的表格可以清晰地看到有 7 个 T,也就是 abcba 有 7 个回文子串。同时,通过表格,我们也可以发现一些规律:

  • 矩阵的对角线都是 T。因为对角线表示的区间是单个字符,单个字符肯定回文
  • 对角线将矩阵划分成上下两半,其中有一半肯定全是 F。因为区间的起始下标必须要小于或等于终止下标,否则构不成区间,因此为 F
  • 状态 dp[i][j] 可以由状态 dp[i+1][j-1] 推导得知。比如上面的 dp[0][4] 是由 dp[1][3] 推导得出的。

由此可知,我们需要找到状态 dp[i][j]dp[i+1][j-1] 之间的递进关系。另外,我们还需要想出一个迭代/遍历方式,使得状态 dp[i+1]j-1] 的值在 dp[i][j] 更新之前被计算到。

首先回答第一个问题,从回文的定义以及区间的包含关系可以得出,如果 [i+1,j-1] 不是回文,那么 [i,j] 也肯定不是。但如果 [i+1,j-1] 是回文,我们还需要比较 ij 位置上的字符来确定 [i,j] 的状态,由此可得出递推方程:

dp[i][j] = dp[i+1][j-1] && s.charAt(i) == s.charAt(j)

针对第二个问题,也就是如何遍历。正常情况下,我们都是采用逐行逐列的遍历方式。但对于这种问题,我们可以换一种思维方式。我们可以基于左右指针来遍历,这么说可能有点模糊,我们来看看下面这个模版:

// 这里 len 表示的是左右指针的间隔
// l, r 分别表示的是左指针和右指针

for (int len = 0; len < n; ++len) {
    for (int l = 0, r = l + i; r < n; ++l, ++r) {
    	...
    }
}

这里我就不过多解释为什么这种遍历方式可以解决当前问题。你对照着之前的表格,试着在纸上画画就理解了。它其实是从对角线开始,斜着向矩阵的某一个角去遍历,区别于传统的从上到下,从左到右。

对于这个问题,还有另一个解法。这个解法利用了回文字串从中间向两边延伸的对称性质。因为这种解法并不通用,我就不过多展开了。

时间方面,两种解法的时间都是 O(n^2)。空间上,动态规划使用了二维数组,空间复杂度是 O(n^2)


参考代码(动态规划)

public int countSubstrings(String s) {
    if (s == null || s.length() == 0) {
        return 0;
    }

    int n = s.length();
    boolean[][] dp = new boolean[n][n];

    int count = 0;
    for (int i = 0; i < n; ++i) {
        for (int l = 0, r = l + i; r < n; ++l, ++r) {
            if (s.charAt(l) == s.charAt(r)) {
            	// 这里增加一个 i + 1 >= r - 1 的判断是为了防止越界
                // 针对 l == r,或者 l+1 == r 的情况(这里只存在这两种特殊情况)
                dp[l][r] = l + 1 >= r - 1 ? true : dp[l + 1][r - 1];
            }

            if (dp[l][r]) {
                count++;
            }
        }
    }

    return count;
}

参考代码

public int countSubstrings(String s) {
    if (s == null || s.length() == 0) {
        return 0;
    }

    int count = 1;
    for (int i = 1; i < s.length(); ++i) {
        count += countByGivenMiddle(i - 1, i, s);
        count += countByGivenMiddle(i, i, s);
    }

    return count;
}

public int countByGivenMiddle(int a, int b, String s) {
    int count = 0;
    int i = a, j = b;

    while (i >= 0 && j < s.length() && s.charAt(i--) == s.charAt(j++)) {
        count++;
    }

    return count;
}

Review

Pro Git CH10

Pro Git 的第 10 章,也是全书的最后一章。这一章主要讲述的是 Git 的一些底层实现机制。其实,Git 的所有秘密都存在在 .git 目录下,我们就先从这个目录入手,看看每个子目录具体表示的是什么:

.git/
    config              # 当前项目下的 git 配置
    description         # 主要用于 gitweb 项目,一般情况下用不到
    HEAD                # 存放当前 HEAD 指针的指向位置
    hooks/              # 客户端/服务器 端的脚本文件
    info/               # 里面有一个 exclude 文件,用于存放 ignore 的信息(区别于 .gitignore)
    objects/            # 可以看作是 git 的数据库,用于存储 Git 主要的内容
    refs/               # 存放一些指针(如 commit,tags,branch,remote)
    index               # 存放暂存区的信息

Git 的核心是在 objects 目录下,这里存放的是版本控制所有的实际数据。研究 Git 的机制其实就是研究这个目录下的数据是如何存储的。

Git 是一个内容寻址文件系统。你可以把它想象成一个哈希表,每当你把一个 value 放进来,比如你的一个 commit 信息,git 会产生一个 key。也就是我们熟悉的长长的 SHA-1 字符串:

d5b95be9bc839aba25a6a2322aacc14e1db69fff

往后,我们可以通过这个 SHA-1 字符串来对应到我们需要的内容。SHA-1 字符串,以及其对应的内容都是存在 objects 目录下的。它们是如何存储的呢,举个例子就明白了:

objects/
    00/
    ...
    d5/
        b95be9bc839aba25a6a2322aacc14e1db69fff
        ...
    ...
    ff/

上面我们展示的 d5b95be9bc839aba25a6a2322aacc14e1db69fff 的内容其实存在于 objects/d5 目录下的 b95be9bc839aba25a6a2322aacc14e1db69fff 的文件中。这里你或许发现了,Git 用这个 SHA-1 字符串的头 2 个字符作为文件夹名,后 38 个字符作为文件名。个人觉得这样的分区设计的目的还是为了提升寻找时的效率。

说了这些,你可能好奇这个文件里面到底存了什么。这里我们先提一下 Git 中的几种基本的数据类型:blobtreecommit。下面的这张图可以很好地解释这 3 种数据结构的意义以及它们之间的相互关系:

首先我们来看看 commit 文件,对应于图中的 SHA-1 是 1a410ecac0ca 以及 fd4fc(为了方便表示,这里只是缩写/前缀)。这些文件中存储的是某个 commit 的基本信息,比如作者,commit message 等。除了这些外,最关键的是它还会存放一些 SHA-1,对应的是当前 commit 的父 commit,还有就是指向当前 commit 的内容的 tree。这里就不得不说到第二种数据结构 tree。你可以把它当作是一个目录,其实它实际存储时也是一个以 SHA-1 值命名的文件,区别在于文件中的内容。文件里面存储的是内容的元信息。比如一个 commit 改动了两个文件,那这个 commit 对应的 tree 中就会存有两个 SHA-1,分别表示两个 blob。比如图中的 0155eb。最后说一下 blob 这个东西,这个可以说是 git 中最小的一个结构了。和前面一样,它也是一个以 SHA-1 后 38 位命名的文件,里面存放的就是某个文件的变动情况。

通过上面的解释以及描述,可以很清楚的看到 Git 中的数据存储其实很类似数据结构中的树。每一个节点表示的是一个文件,每个文件中存放了其子节点的 SHA-1(这里看作是子节点的地址)。根据节点的所在位置的不同,我们给了它们不一样的命名。根节点叫做 commit,叶子节点叫做 blob,中间的节点叫做 tree(tree 也可以作为叶子节点,比如一个空文件夹)。


Tip

记录一下近期学到的一些计算机基础知识:

  • 每台计算机都有一个 字长(word size),也就是我们平时经常说的 “32位/64位的系统”。它决定的是虚拟地址空间的最大大小。32 位字长限制的地址范围为 0 ~ 2^32 - 1位,差不多就是 4GB 的空间。当然,我们知道这对于现代的很多计算机是不够的,因此很早就出现了从 32 位字长机器到 64 位字长机器的迁移。扩展到 64 位字长使得虚拟地址空间变为 16EB,这就远远满足了大多数计算机的需求。

  • 不管是程序还是指令,到了机器底层都会成为 0,1 序列。但是不同机器的字节顺序是不一样的,主要有两种规则 - 大端法,小端法。最低有效字节在最前面的方式是小端法,最高有效字节在最前面的方式是大端法。

  • ASCII 码是字符编码的基础。在计算机中,我们可以运行 man ascii 来生成一张 ASCII 字符码表。


Share

新年刚过,到了定计划的时候了

许愿 2021