Sonatype OSSRH (OSS Repository Hosting) 提供了 JAR 包发布服务,并支持自动将 JAR 包同步到 Maven 中央仓库,所以我们将 JAR 包发布到 Sonatype OSSRH 即可。

创建 Sonatype 工单

第一步在 Sonatype 上注册一个账号,创建成功后在上面创建一个 IssueProject 选择 Community Support - Open Source Project Repository Hosting (OSSRH)Issue Type 选择 New Project

alt

这里要注意的是 Group Id 的填写,根据 Coordinates 的描述,这里分两种情况:

  1. 你拥有某个域名,如 example.com
  2. 你没有域名,但是你的代码托管在了某个代码托管服务上,如 GitHub

对于第一种情况,你的 Group Id 可以是任何以 com.example 为前缀的字符串,如 com.example.myawesomeproject。不过,Sonatype 会要求你证明确实拥有 example.com 域名,你需要在你的域名注册商那创建一条 TXT 记录,其内容就是你创建的 Issue 的工单号,如 OSSRH-12345,具体步骤可参考 How do I set the TXT record needed to prove ownership of my Web Domain?

对于第二种情况,以 GitHub 为例,你的 Group Id 必须是 io.github.myusernamemyusername 是你的 GitHub 账户名或者是组织名,类似的,为了证明你对 myusername 的所有权,你需要在 myusername 下创建一个公开的仓库,仓库名称为你所创建 Issue 的工单号,如 OSSRH-12345,认证完成之后你就可以删掉这个仓库。Sonatype 所支持的代码托管服务如下:

Service Example groupId
GitHub io.github.myusername
GitLab io.gitlab.myusername
Gitee io.gitee.myusername
Bitbucket io.bitbucket.myusername
SourceForge io.sourceforge.myusername

工单示例可参考 Publish my open source java package

安装 GPG

GPG 用于对所发布的包进行签名,在 GnuPG 根据自己的操作系统下载 GPG 安装包,安装完成后执行 gpg --full-gen-key 生成秘钥对,选择默认选项即可,生成秘钥对时会要求输入姓名、邮箱、注释和密码,其中密码在发布阶段会用到,秘钥生成信息类似如下:

1
2
3
4
pub   rsa3072 2022-02-26 [SC]
E892F685E5EA9005E0A2DE31F0F732425A15D81D
uid examplename <examplename@example.com>
sub rsa3072 2022-02-26 [E]

其中 E892F685E5EA9005E0A2DE31F0F732425A15D81D 是秘钥的 ID,然后我们需要将公钥分发到公共的秘钥服务器上,这样 Sonatype 就可以通过这个公钥来验证我们所发布包的签名是否正确:

1
gpg --keyserver keyserver.ubuntu.com --send-keys E892F685E5EA9005E0A2DE31F0F732425A15D81D

这里选择的公共秘钥服务器是 keyserver.ubuntu.com,也可以选择其他服务器,如 keys.openpgp.org 或者 pgp.mit.edu

配置 settings.xml

为了将包发到 Sonatype OSSRH,需要在 Mavensettings.xml 中配置用户信息,即在 servers 下添加如下信息,这里的 your-jira-idyour-jira-pwd 对应第一步创建的账号和密码:

1
2
3
4
5
<server>
<id>ossrh</id>
<username>your-jira-id</username>
<password>your-jira-pwd</password>
</server>

另外,为了在打包时对文件进行签名还需要在 profiles 下添加如下信息,这里的 the_pass_phrase 为生成 GPG 秘钥时设置的密码:

1
2
3
4
5
6
7
8
9
10
<profile>
<id>ossrh</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<gpg.executable>gpg</gpg.executable>
<gpg.passphrase>the_pass_phrase</gpg.passphrase>
</properties>
</profile>

配置 pom.xml

最后是配置 pom.xml,首先我们需要告诉 Maven 将包部署到 Sonatype OSSRH,需要增加一个 nexus-staging-maven-plugin 插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<distributionManagement>
<snapshotRepository>
<id>ossrh</id>
<url>https://s01.oss.sonatype.org/content/repositories/snapshots</url>
</snapshotRepository>
</distributionManagement>
<build>
<plugins>
<plugin>
<groupId>org.sonatype.plugins</groupId>
<artifactId>nexus-staging-maven-plugin</artifactId>
<version>1.6.7</version>
<extensions>true</extensions>
<configuration>
<serverId>ossrh</serverId>
<nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
<autoReleaseAfterClose>true</autoReleaseAfterClose>
</configuration>
</plugin>
</plugins>
</build>

然后是配置 Javadoc 和源码插件,如果最后的 JAR 包没有包含 Javadoc 和源码,Sonatype 会不允许通过:

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
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>2.2.1</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>2.9.1</version>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

不过上述配置不适合 Kotlin 项目,会提示 Missing: no javadoc jar found in folder '/com/example/username/awesomeproject',需要将 maven-javadoc-plugin 替换为 dokka-maven-plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.dokka</groupId>
<artifactId>dokka-maven-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<id>attach-javadocs-dokka</id>
<goals>
<goal>javadocJar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

最后,剩下补充一些元数据,这个也是必填项,包括:

  • 项目名称,描述和地址
  • 许可证信息
  • 开发者信息
  • 源码地址

完整的示例可参考:

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
36
37
38
39
40
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.simpligility.training</groupId>
<artifactId>ossrh-demo</artifactId>
<version>1.0</version>
<packaging>jar</packaging>

<name>ossrh-demo</name>
<description>A demo for deployment to the Central Repository via OSSRH</description>
<url>http://github.com/simpligility/ossrh-demo</url>

<licenses>
<license>
<name>The Apache Software License, Version 2.0</name>
<url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
</license>
</licenses>

<developers>
<developer>
<name>Manfred Moser</name>
<email>manfred@sonatype.com</email>
<organization>Sonatype</organization>
<organizationUrl>http://www.sonatype.com</organizationUrl>
</developer>
</developers>

<scm>
<connection>scm:git:git://github.com/simpligility/ossrh-demo.git</connection>
<developerConnection>scm:git:ssh://github.com:simpligility/ossrh-demo.git</developerConnection>
<url>http://github.com/simpligility/ossrh-demo/tree/master</url>
</scm>

...

</project>

发包

执行 mvn clean deploy 即可发包,如果执行成功,在提交的工单中会自动增加一条回复:

Central sync is activated for com.example.awesomeproject. After you successfully release, your component will be available to the public on Central https://repo1.maven.org/maven2/, typically within 30 minutes, though updates to https://search.maven.org can take up to four hours.

也就是30分钟内即可从 Maven 中央仓库下载 JAR 包,不过要想能在 search.maven.org 搜索到你的 JAR 包,需要等待至多4个小时。

另外,因为配置 nexus-staging-maven-plugin 时指定了 autoReleaseAfterClosetrue,所以发包后不需要去 https://oss.sonatype.org/#stagingRepositories 手动执行 closerelease 操作。

参考

二叉搜索树的删除可以分为三种情况。第一,被删除的节点是叶子节点:

alt

第二,被删除的节点只有一个孩子节点:

alt

第三,被删除的节点有两个孩子节点:

alt

对于第一种情况,我们只需断开被删除的节点和其父节点的关联即可,即将节点3的左孩子节点指针置为空;对于第二种情况,我们可以用被删除的节点的孩子节点来替代被删除的节点,即将节点5的右孩子指针改为指向节点7;第三种情况是最为复杂的情况,相当于删除一个子树的根节点,为了保持二叉搜索树的性质,我们可以使用左子树中的最大值或右子树的最小值来替代被删除的根节点。

不过在实现时,考虑到实现的简便,对于第三种情况会通过直接修改当前节点的值来替代修改节点的指针指向,以上述例子来说,如果使用指针修改的方式,则需要修改节点5的左孩子指针,修改节点2的左孩子指针和右孩子指针(这里假设使用节点2来替代被删除的节点3),总共三处修改较为繁琐;而如果使用修改节点值的方式,只需要先将节点3的值改为2(这里假设使用节点2来替代被删除的节点3),然后就可以将问题转化为在余下的左子树中删除节点2。具体代码如下:

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
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right


class Solution:
def deleteNode(self, root: TreeNode, key: int) -> TreeNode:
if not root:
return root

if root.val < key:
root.right = self.deleteNode(root.right, key)
elif root.val > key:
root.left = self.deleteNode(root.left, key)
else:
if root.left and root.right:
root.val = self._find_min(root.right)
root.right = self.deleteNode(root.right, root.val)
else:
return root.left or root.right

return root

def _find_min(self, root):
while root.left:
root = root.left

return root.val

介绍

MapReduce: Simplified Data Processing on Large Clusters6.824: Distributed Systems 中所介绍的第一篇论文。它提出了一种针对大数据处理的编程模型和实现,使得编程人员无需并行和分布式系统经验就可以轻松构建大数据处理应用。该模型将大数据处理问题拆解为两步,即 mapreducemap 阶段将一组输入的键值对转化为中间结果键值对,reduce 阶段对中间结果键值对按照相同的键进行值的合并,从而得到最终的结果。

背景

对于 Google 来说,每天运行的系统会产生大量的原始数据,同时又要对这些原始数据进行加工产生各种衍生数据,虽然大部分数据加工的逻辑都较为简单,然而由于数据量过于庞大,为了在合理的时间内完成数据处理,通常需要将待处理的数据分发到几百或几千台机器上并行计算,这就存在几个问题:

  1. 如何使计算可并行
  2. 如何分发数据
  3. 如何处理异常

如果每一个数据加工任务都需要独立去解决上述的问题,一方面会使得原本简单的代码逻辑变得庞大、复杂和难以维护,另一方面也是在重复工作。受 Lisp 等其他函数式编程语言中的 mapreduce 函数的启发,Google 的工程师们发现大部分的数据处理遵循如下的模式:

  1. 对输入的每一条数据应用一个 map 函数产生一组中间结果键值对
  2. 对中间结果键值对按照相同的键聚合后,应用 reduce 函数生成最终的衍生数据

因此,Google 的工程师们抽象出了 MapReduce 框架,使得应用开发人员可以专注于计算逻辑实现而无需关心底层运行细节,统一由框架层处理并行、容错、数据分发和负载均衡等系统问题。现在再来看前面提到的问题是如何解决的:

  1. 如何使计算可并行:在 map 阶段,对数据分发后,各任务间无依赖,可并行执行;在 reduce 阶段,不同 key 的数据处理间无依赖,可并行执行
  2. 如何分发数据:在 map 阶段,可按执行 map 任务的节点数量平均分发(这只是一种可能的策略,具体分发策略见后文描述);在 reduce 阶段,可按 key 相同的数据聚合后分发
  3. 如何处理异常:重新执行某个节点上失败的 mapreduce 任务作为首要的容错手段

编程模型

假设需要统计一组文档中每个单词出现的次数,在 MapReduce 框架下用户需要编写 mapreduce 函数,近似的伪代码表示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
map(String key, String value):
// key: document name
// value: document contents
for each word w in value:
EmitIntermediate(w, "1");

reduce(String key, Iterator values):
// key: a word
// values: a list of counts
int result = 0;
for each v in values:
result += ParseInt(v);
Emit(AsString(result));

假设有两个文档 hello.txtworld.txt,其内容分别为:

1
2
3
4
5
hello.txt:
It was the best of times

world.txt:
it was the worst of times

对上述 mapreduce 函数来说,map 函数每次处理一个文档,key 为文档的名称,value 为文档的内容,即:

1
2
map("hello.txt", "It was the best of times")
map("world.txt", "it was the worst of times")

map 函数执行时会遍历文档的内容,对每个单词输出中间结果键值对(作为示例,这里省去了将文档内容拆分为单词的过程,同时也忽略了标点符号、大小写等与示例无关的内容),键为单词,值为 "1",所有 map 函数执行完成后生成的中间结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
hello.txt:
it 1
was 1
the 1
best 1
of 1
times 1

world.txt:
it 1
was 1
the 1
worst 1
of 1
times 1

然后,MapReduce 框架对所有中间结果按照相同的键进行聚合,即:

1
2
3
4
5
6
7
it ["1", "1"]
was ["1", "1"]
the ["1", "1"]
best ["1"]
worst ["1"]
of ["1", "1"]
times ["1", "1"]

最后,MapReduce 框架将上述聚合后的数据分发给 reduce 函数执行,即:

1
2
3
4
5
6
7
reduce("it", ["1", "1"])
reduce("was", ["1", "1"])
reduce("the", ["1", "1"])
reduce("best", ["1"])
reduce("worst", ["1"])
reduce("of", ["1", "1"])
reduce("times", ["1", "1"])

reduce 函数执行时会遍历 values,将每个字符串转换为整型后累加,然后作为 reduce 的结果返回,最终得到所有单词出现的次数:

1
2
3
4
5
6
7
it 2
was 2
the 2
best 1
worst 1
of 2
times 2

实际执行 reduce 函数时,并不会将 values 一次性传给某个 reduce 函数,因为有可能数据量太大无法完全载入内存,所以 values 在实现时是个迭代器,reduce 函数能以流式的形式获取值。

另外,虽然在上述的例子中 mapreduce 处理的都是字符串类型的数据,但是也可以支持其他类型的数据,mapreduce 处理的数据类型遵循如下的模式:

1
2
map (k1, v1) -> list(k2, v2)
reduce (k2, list(v2)) -> list(v2)

可以看到,map 产生的中间结果的数据类型和最终结果的数据类型是一致的。对整个框架来说,最初的输入和最终的输出都是某种形式的字节流或字符串,因此在 GoogleC++ 实现中,提供了专门的数据转换接口,用户可实现该接口用于字符串和 mapreduce 需要的数据类型之间转换。

实现

MapReduce 的具体实现视硬件环境的不同而不同,论文中描述的实现是针对 Google 内部广泛使用的硬件环境,即通过交换以太网相连的大量廉价 PC 组成的集群:

  1. 每台机器的配置一般为双核 x86 处理器,2-4 GB 内存,运行 Linux 系统
  2. 使用廉价网络硬件,带宽一般为 100 Mbit/s1 Gbit/s,不过平均来说会小于 bisection bandwidthbisection bandwidth 指当某个网络被分成两部分时,这两部分间的带宽)
  3. 一个集群一般由几百上千台机器组成,所以机器异常是家常便饭
  4. 存储使用的是廉价的 IDE 硬盘,并直接装载到了机器上。不过 Google 内部实现了一套分布式文件存储系统来管理这些硬盘上的数据,并通过数据冗余作为在不可靠的硬件上实现可用性和可靠性的手段。
  5. 用户向调度系统提交一组任务,每个任务包含多个子任务,调度系统会为每个任务分配一批集群内的机器执行。

执行概览

map 执行阶段,框架会自动将输入数据分为 M 片,从而将 map 任务分发到多台机器上并行执行,每台机器只处理某一片的数据。同样的,在 reduce 阶段,框架首先将中间结果数据根据分片函数(例如 hash(key) mod R)拆分为 R 片,然后分发给 reduce 任务执行,用户可自行指定 R 的值和实现具体的分片函数。

下图展示了 Google 所实现的 MapReduce 框架的整体执行流程:

alt

当用户提交 MapReduce 任务后,框架会执行以下一系列流程(下文中的序号和上图中的序号对应):

  1. 首先 MapReduce 框架将输入数据分为 M 片,每片数据大小一般为 16 MB64 MB(具体大小可由用户入参控制),然后将 MapReduce 程序复制到集群中的一批机器上运行。
  2. 在所有的程序拷贝中,某台机器上的程序会成为主节点(master),其余称为工作节点(worker),由主节点向工作节点分派任务,一共有 Mmap 任务和 Rreduce 任务需要分派。主节点会选择空闲的工作节点分派 mapreduce 任务。
  3. 如果某个工作节点被分派了 map 任务则会读取当前的数据分片,然后将输入数据解析为一组键值对后传递给用户自定义的 map 函数执行。map 函数产生的中间结果键值对会暂存在内存中。
  4. 暂存在内存中的中间结果键值对会周期性的写入到本地磁盘中,并根据某个分片函数将这些数据写入到本地磁盘下的 R 个区,这样相同键的中间结果数据在不同的 map 节点下属于同一个区号,就可以在后续将同一个键的中间结果数据全部发给同一个 reduce 节点。同时,这些数据写入后的地址会回传给 master 节点,master 节点会将这些数据的地址发送给相应的 reduce 节点。
  5. reduce 节点接收到 master 节点发送的中间结果数据地址通知后,将通过 RPC 请求根据数据地址读取 map 节点生成的数据。在所有中间结果数据都读取完成后,reduce 节点会先将所有中间结果数据按照键进行排序,这样所有键相同的数据就聚合在了一起。之所以要排序是因为一个 reduce 节点会分发处理多个键下的中间结果数据。如果中间结果数据量太大不足以完全载入内存,则需要使用外部排序。
  6. reduce 节点执行时会先遍历排序后的中间结果数据,每遇到一个新的键就会将该键及其对应的所有中间结果数据传递给用户自定义的 reduce 函数执行。reduce 函数执行的结果数据会追加到当前 reduce 节点的最终输出文件里。
  7. 当所有 map 任务和 reduce 任务都执行完成后,master 节点会唤醒用户程序,并将控制权交还给用户代码。

当成功结束 MapReduce 任务后,其执行结果就保存在了 R 个文件中(每个文件对应一个 reduce 节点的产出,文件的名字由用户所指定)。一般来说,用户不必将这 R 个输出文件合并成一个,它们通常会作为另一个 MapReduce 任务的输入,或交由其他分布式应用处理。

基于上述流程,再来看在 编程模型 这节中的例子。假设有6个文档,分别是 1.txt6.txt,每个文档中的内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1.txt:
It was the best of times

2.txt:
it was the worst of times

3.txt:
it was the age of wisdom

4.txt:
it was the age of foolishness

5.txt:
it was the epoch of belief

6.txt:
it was the epoch of incredulity

对应 MapReduce 执行流程为:

  1. 我们假设每两个文档的数据大小为 16 MB,则6个文档对应3片数据
  2. 由1所知一共有3个 map 任务,不妨将 reduce 任务也设为3个,并将6个文档按顺序每两个一组依次分发给每个 map 节点
  3. 每个 map 节点处理的数据分片为两个文档,所产生的中间结果数据分别为:
    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
    36
    37
    38
    39
    40
    41
    map worker 1:
    it 1
    was 1
    the 1
    best 1
    of 1
    times 1
    it 1
    was 1
    the 1
    worst 1
    of 1
    times 1

    map worker 2:
    it 1
    was 1
    the 1
    age 1
    of 1
    wisdom 1
    it 1
    was 1
    the 1
    age 1
    of 1
    foolishness 1

    map worker 3:
    it 1
    was 1
    the 1
    epoch 1
    of 1
    belief 1
    it 1
    was 1
    the 1
    epoch 1
    of 1
    incredulity 1
  4. 在每个 map 节点上将中间结果数据按照某个哈希函数分发到3个区,不妨为以下结果:
    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
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    map worker 1:
    region 1:
    it 1
    best 1
    it 1

    region 2:
    was 1
    of 1
    was 1
    worst 1
    of 1

    region 3:
    the 1
    times 1
    the 1
    times 1

    map worker 2:
    region 1:
    it 1
    age 1
    it 1
    age 1
    foolishness 1

    region 2:
    was 1
    of 1
    of 1
    wisdom 1
    was 1

    region 3:
    the 1
    the 1

    map worker 3:
    region 1:
    it 1
    epoch 1
    belief 1
    it 1
    epoch 1

    region 2:
    was 1
    of 1
    was 1
    of 1

    region 3:
    the 1
    the 1
    incredulity 1
  5. reduce 节点按照数据分区接收到所有中间结果数据后将其按照键排序:
    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
    36
    37
    38
    39
    40
    41
    reduce worker 1:
    age 1
    age 1
    belief 1
    best 1
    epoch 1
    epoch 1
    foolishness 1
    it 1
    it 1
    it 1
    it 1
    it 1
    it 1

    reduce worker 2:
    of 1
    of 1
    of 1
    of 1
    of 1
    of 1
    was 1
    was 1
    was 1
    was 1
    was 1
    was 1
    wisdom 1
    worst 1

    reduce worker 3:
    incredulity 1
    the 1
    the 1
    the 1
    the 1
    the 1
    the 1
    times 1
    times 1
  6. reduce 节点调用用户自定义 reduce 函数计算单词出现次数,最终每个 reduce 节点的输出文件为:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    reduce worker 1 output:
    age 2
    belief 1
    best 1
    epoch 2
    foolishness 1
    it 6

    reduce worker 2 output:
    of 6
    was 6
    wisdom 1
    worst 1

    reduce worker 3 output:
    incredulity 1
    the 6
    times 2
  7. 将代码控制权交还给用户代码

Master 节点数据结构

master 节点需要维护当前所有的 mapreduce 任务,每个任务需区分不同的状态(空闲、进行中、完成),同时还需要知道每个任务对应的工作节点。作为 map 节点和 reduce 节点间中间结果数据的传输媒介,master 节点需保存 R 个中间结果分区,每当一个 map 节点执行成功时,会将生成的 R 个中间结果文件地址发送给 master 节点,当 master 节点收到通知后,会将其转发给对应进行中的 reduce 节点。

对应数据结构简单示例如下:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 任务状态
enum TaskState {
// 空闲
Idle,

// 进行中
InProgress,

// 完成
Completed
}

// 一个 map 或 reduce 任务
class Task {
// 任务状态
TaskState state;

// 对应的工作节点 id
int workerId;
}

// 工作节点
class Worker {
// 节点 id
int id;
}

// Map 任务产生的中间结果文件,一个 map 任务一般会产生多个中间结果文件
class IntermediateFile {
// 文件地址
string location;

// 文件大小
long size;
}

// 中间结果文件集,所有 map 任务产生的中间结果文件会根据分片函数划分到本地磁盘下的 R 个区
class IntermediateFileRegion {
// 中间结果文件
IntermediateFile[] intermediateFiles;
}

// Map 节点
class MapWorker : Worker {
// 中间结果文件集,一共有 R 个
IntermediateFileRegion[] intermediateFileRegions;
}

// Reduce 节点
class ReduceWorker : Worker {
// 中间结果文件,master 节点会不断发送中间结果文件给 reduce 节点,当所有中间结果文件都收到后,reduce 节点开始工作
IntermediateFile[] intermediateFiles;
}

// 主节点
class Master {
// Map 任务,一共有 M 个
Task[] mapTasks;

// Reduce 任务,一共有 R 个
Task[] reduceTasks;

// 工作节点,最多有 M + R 个,一个工作节点并不是只负责 map 或者 reduce 任务,master 节点会选择空闲节点分派 map 或者 reduce 任务
Worker[] workers;

// 中间结果文件集,一共有 R 个,由 map 节点下的中间结果文件集聚合而来,某个 map 节点执行成功后会将生成的 R 个中间结果文件地址发送给 master 节点,由 master 节点将某个区下的中间结果文件地址转发给对应 reduce 节点
IntermediateFileRegion[] intermediateFileRegions;
}

容错

因为 MapReduce 框架借助几百或几千台机器来处理海量数据,所以必须优雅的应对机器异常。

工作节点异常

master 节点会周期性的对工作节点进行探活。如果某个工作节点在一段时间内无响应,则 master 节点会将该工作节点标记为异常。该工作节点完成的所有 map 任务的状态都会被重置为空闲,可重新被 master 节点调度到其他工作节点上执行。类似的,该工作节点所有进行中的 mapreduce 任务也都会被重置为空闲,并重新接受调度。

之所以这里已完成的 map 任务也需要重新执行是因为所产生的中间结果文件是保存在 map 节点的本地磁盘上,当该节点无响应时便认为无法与之连通从而认为无法通过 RPC 请求获取这些数据。而如果 reduce 节点异常,它所完成的 reduce 任务不需要重新执行是因为 reduce 节点执行成功后产生的输出文件是保存在全局的文件系统上。

如果某个 map 任务一开始由工作节点 A 执行,之后由工作节点 B 执行(因为节点 A 发生了异常),则所有执行 reduce 任务的节点都会被通知,其中所有要从节点 A 读取数据但还未读取的 reduce 节点会转而从节点 B 读取数据。

MapReduce 框架能从容应对大量的节点异常。例如,在某次 MapReduce 任务中,由于对运行中的集群进行网络维护一次性造成了80台机器在几分钟内无法连通。MapReduce 框架可直接重新分发和执行这些不连通的节点正在处理的任务,然后继续后续流程,并最终完成当次任务。

主节点异常

类似于游戏的自动存档,我们可以定期为主节点内部的数据结构保存检查点。如果主节点发生异常,则可以重新启动一个主节点程序并加载最新的检查点数据。然而对于单个主节点来说,主节点异常发生的概率较小,所以在 Google 的实现中,如果主节点发生异常,则会直接中断当次 MapReduce 任务。客户端可捕获到这种情况,并根据自身需要决定是否进行重试。

执行语义

如果用户编写的 mapreduce 函数是确定性的函数(即对于相同的输入始终返回相同的输出),则对于同一份输入,分布式的 MapReduce 框架的执行结果和一个串行执行且没有任何异常的 MapReduce 框架的执行结果相同。

不论是 map 还是 task 任务,都需要将执行结果写入到文件系统上,通过原子性的写入提交,可实现上述的语义保证。每个进行中的任务会先将输出结果写入到私有临时文件中,对 reduce 任务来说,最终只产生一个文件,而对于 map 任务则会产生 R 个文件(每个文件对应一个 reduce 任务)。当一个 map 任务执行完成时,map 节点会发送一条消息给 master 节点,这条消息中包含了 map 任务所生成的 R 个临时文件的名字。如果 master 节点收到了一条已经完成的 map 任务的消息,则会忽略该消息,否则将 R 个临时文件的名字保存在内部的数据结构中。

reduce 任务执行完成时,reduce 节点能原子性的将其生成的临时文件重命名为最终的输出文件。如果同一个 reduce 任务有多个工作节点执行(因为网络连通问题导致 master 重新分发 reduce 任务),则对同一个最终输出文件会有多个文件重命名的请求。通过底层文件系统的原子性重命名保证,最终的输出文件只会对应一个 reduce 任务的结果。

Google 内部大部分的 mapreduce 函数都是确定性的,在这种情况下分布式程序执行的结果和串行程序执行的结果相同的语义性保证使得开发人员能很容易的审视所编写的程序的行为(即如果程序的执行结果不符合预期,那么可以基本肯定的是开发人员编写的 map 或者 reduce 函数存在问题,而不是 MapReduce 框架存在问题)。当 map 或者 reduce 函数不具有确定性时,框架能提供稍弱一级但仍是合理的语义性保证。在非确定性的函数下,某个 reduce 任务 R1 由分布式执行的结果等价于一个串行执行的程序 A 执行 R1 后的结果。但是,另一个 reduce 任务 R2 的执行结果也可能等同于由另一个不同的串行执行的程序 B 执行后的结果。

假设有一个 map 任务 M,以及总共有两个 reduce 任务 R1R2,记 e(Ri) 表示 Ri 执行并提交成功的结果。以前面的单词统计为例,假设发送给 map 任务的只有两个文档:

1
2
3
4
5
1.txt:
It was the best of times

2.txt:
it was the worst of times

map 函数是非确定性的情况下,不妨这样实现 map 函数:

1
2
3
4
5
6
7
8
9
10
map(String key, String value):
// key: document name
// value: document contents
for each word w in value:
r = Random(0, 1)

if r > 0.5:
EmitIntermediate(w, "1");
else:
EmitIntermediate(w, "0");

即对于某个单词,map 函数有一半的概率计数为1,一半的概率计数为0。

类似的,以同样的手段来实现 reduce 函数,对于某个单词的所有出现次数,reduce 函数有一半的概率会计数,一半的概率会忽略:

1
2
3
4
5
6
7
8
9
10
11
12
reduce(String key, Iterator values):
// key: a word
// values: a list of counts
int result = 0;
for each v in values:
r = Random(0, 1)

if r > 0.5:
result += ParseInt(v);
else:
result += 0;
Emit(AsString(result));

R1 为统计单词 it 的个数,经过 map 任务后,生成的中间结果键值对可能为以下四种情况:

  1. [0, 1]
  2. [1, 0]
  3. [1, 1]
  4. [0, 0]

最后由 reduce 任务执行后的结果可能为0、1、2三种情况,而相同的输入由一个串行执行的程序来执行也是同样的结果,即不管是分布式的程序还是串行的程序最终结果都是相同的集合,所以认为两者是等价的,也是合理的。

在确定性的函数下,相同的输入必然返回相同的输出,而在不确定性的函数下,不同的输入可能返回相同的输出或者相同的输入可能返回不同的输出。这就类似于知道 x 的定义域是 {1, 2, 3}y 值域是 {4, 5, 6},求 f(x),显然 f(x) 存在不止唯一的解。

记上述的 mapreduce 函数组成的串行程序为 A,假设有另一个串行程序 B,其中 map 函数不变,reduce 函数变为:

1
2
3
4
5
6
7
8
9
10
11
12
reduce(String key, Iterator values):
// key: a word
// values: a list of counts
int result = 0;
for each v in values:
r = Random(0, 1)

if r > 0.5:
result += 0;
else:
result += ParseInt(v);
Emit(AsString(result));

R2 为统计单词 was 的个数,由 AB 执行的最终结果都等于集合 {0, 1, 2},相比于确定性的函数,开发人员因此无法有效的审视所编写函数的行为。

局部性

Google 内部的运行环境中网络带宽属于稀缺资源,不管是 map 还是 reduce 任务都依托于文件的读取,不可避免的会产生大量网络 IO。而在前面提到,Google 内部实现了一套分布式文件存储系统(GFS)来管理存储在集群内机器本地磁盘上的文件,对于每一个文件 GFS 会将其切分为若干个 64MB 的数据块,每个数据块存有多份冗余(一般是3份)保存在不同的机器上。对于 MapReduce 框架来说,原始的数据输入是保存在本地磁盘上的,依据这个特性,框架在分发 map 任务时,根据输入数据在 GFS 内的位置会优先选择本地磁盘上存有对应输入数据的工作节点。如果找不到这样的工作节点,则会选择一个距离输入数据最近的工作节点(例如工作节点和存有输入数据的节点由同一个交换机相连)。当运行大量的 MapReduce 任务时,大部分的输入数据都是从本地读取从而不消耗任何网络带宽。

任务粒度

如前文所述 MapReduce 框架会根据输入数据拆分为 Mmap 任务和 Rreduce 任务。理想情况下,MR 的值应该远大于工作节点的数量。为什么需要远大于?首先,MR 的数量比工作节点的数量少是不适合的,会造成资源空闲;其次,如果 MR 的数量和工作节点相等,由于每台机器的处理能力有差异或者输入数据不同,每个工作节点完成某个任务需要的时间也不同,会存在部分机器先完成任务的情况,同样会造成资源空闲,另一方面 MapReduce 面向的是大数据处理,输入数据的数据量远大于工作节点的数量,MR 数量较少的情况下单个工作节点需要处理的数据量则较大,单次处理时间可能较慢,而如果发生了异常,重新执行的成本也较高。所以 MR 的数量设置较大能更好的应对负载均衡,性能好的机器在完成任务后可以继续处理其他任务,同时当某个任务执行异常时也能更快的恢复:该异常的工作节点已完成的 map 任务可分发给余下的工作节点执行。

当然 MR 的数量也是存在上限的,对于 master 节点来说,它需要维护 Mmap 任务和 Rreduce 任务,时间复杂度是 O(M + R),另一方面每个 map 任务会产出 R 份中间结果数据,对应 Rreduce 任务,所以 master 节点也需要 O(M * R) 的空间复杂度来维护这样的对应关系(Google 实际实现时,每个 map/reduce 关系对约占据 1 byte 内存)。

另外,由于每个 reduce 任务的最终产出结果是一个单独的文件所以 R 的数量受用户设置限制。在实践中,会趋向于让每个 map 任务处理 16 MB64 MB 的输入数据来确定 M 的大小,因为 64 MB 正好是 GFS 单个数据块的大小,这样每个 map 任务必然能从本地磁盘读取输入数据而不会涉及网络 IO(如果能将任务分发给存有对应输入数据的节点的话),而 R 的数量会在工作节点的数量上乘上一个较小的常数得到。Google 内部运行 MapReduce 任务时通常设置 M 为200000,使用2000台机器的情况下设置 R 为5000。

后备任务

类似于木桶原理,一次 MapReduce 任务完成的时间取决于最慢的机器完成 mapreduce 任务的时间,这也是造成 MapReduce 任务耗时长的常见原因之一。某台机器执行慢可能有好几个原因造成,例如某台机器的磁盘存在异常,可能频繁遭遇可校正的异常,从而使得磁盘的读速度从 30 MB/s 降低到 1 MB/s。而调度系统同时有可能分配了其他的任务给这台机器,会进一步引发 CPU、内存、本地磁盘、网络带宽的竞争,从而造成执行 MapReduce 任务的耗时更长。Google 内部曾经遇到一个问题,由于机器初始化代码中的一个 bug 造成处理器的缓存被禁用,在这些受影响的机器上运行的任务耗时增长了超过100倍。

针对这个问题,Google 提出了一个通用的缓解机制。当一次 MapReduce 任务快执行结束时,框架会将剩余还在进行中的任务分配给其他机器执行。不管是原先分配的机器执行完成,还是新分配的机器执行完成,对应的任务都将标记为完成。让一个任务由两台机器同时执行势必存在资源浪费,Google 通过调优使得耗费的计算资源控制在了增加几个百分比以内。这个机制在处理一个数据量巨大的 MapReduce 任务时能大幅降低整体耗时。在某个约需处理 1T 数据的排序任务中,不启用这个机制的情况下整体耗时会增加44%。

改进

大多数情况下用户仅需编写 mapreduce 函数就能满足需求,本节主要描述一些 MapReduce 的扩展,可能在某些场合下会比较有用。

分片函数

用户可指定 MapReduce 任务最终输出文件的数量 R,也即 reduce 任务的数量。那么由 map 任务产生的中间结果数据应该发给哪个 reduce 节点执行呢?这个就交由分片函数决定,默认的分片函数是哈希函数(例如 hash(key) mod R),这种分片结果一般比较均匀。不过,有时候自定义分片函数会更有用,例如,当最终结果文件的键是 URL 时,我们希望同属于一个 host 下的 URL 对应的数据最终都在同一个文件里,用户可自定义分片函数来实现,例如 hash(Hostname(urlkey)) mod R,即先通过 urlkey 提取 host,然后对 host 计算哈希最后取模 R

顺序保证

MapReduce 框架保证在同一个中间结果分区内,即同一个 reduce 任务内,中间结果数据是按照键的升序处理的,因为 reduce 任务处理前会先将中间结果数据按照键进行排序。这样在 reduce 任务处理完成后,最终结果文件内的数据也是按照键的顺序排序的,这就有利于对最终结果文件按键进行高效的随机查找,或方便其他需要排好序的数据的场景。

合并函数

在某些场景下,map 任务产生的中间结果数据的键存在大量的重复,同时用户编写的 reduce 函数又符合交换律和结合律(即 a + b = b + a(a + b) + c = a + (b + c))。一个典型案例就是前文描述的单词计数程序,每个 map 任务都会产生成百上千的形如 <the, 1> 的中间结果数据,其中 the 指某个单词,1表示该单词出现的次数。这些同键的中间结果数据接着会经过网络传输发送给 reduce 任务,然后由 reduce 函数合并相加。为了减少这种雷同数据的网络传输,用户可编写自定义的合并函数,map 任务在生成中间结果数据前先进行同键的合并,从而将原来成百上千的同键网络传输降低为1次。

一般来说,合并函数和 reduce 函数的用户代码实现是相同的。不同在于 MapReduce 框架如何处理这两个函数产出的结果,reduce 函数的产出结果会写到最终的结果文件里,而合并函数的产出结果会写到中间结果文件里,然后发送给 reduce 任务。

在特定情况下,由于省去了大量的网络 IO,合并函数能显著的降低一次 MapReduce 任务执行的耗时。

输入和输出类型

MapReduce 框架支持从多个数据格式读取输入数据。例如,text 模式下将输入数据的每一行作为键值对,其中键通过在文本中的偏移量来确定,而值就是当前行的内容。另一种通用支持的格式是本身保存了已排好序的键值对。不管是哪种输入格式,MapReduce 都能从原始输入中准确切分出键值对供 map 任务使用(例如 text 模式保证以每一行的结束进行切分)。用户也可实现自定义的 reader 接口来支持读取新的输入格式,不过大部分情况下内置的输入格式已经能满足需求。

虽然前文描述过 MapReduce 的原始输入数据来源于文本文件,不过用户自定义的 reader 接口并不一定要从文本文件读取,例如还可以从数据库或内存中读取。

类似的,MapReduce 框架也支持不同的最终输出数据的格式,用户也同样可实现支持自定义的输出格式。

副作用

在某些情况下,用户可能希望在 mapreduce 阶段生成额外的辅助文件,这就要求开发人员自己保证输出文件的原子性和幂等性,特别是用户程序先将数据写入到临时文件内,最后在所有数据写入完成后能原子性的将临时文件重命名。

不过,MapReduce 框架本身并不支持两阶段协议来保证 mapreduce 任务输出多个文件时的一致性,同样的,这也需要开发人员自己来保证。因此多文件一致性对应的任务应当是确定性的,否则如何确定产出的文件是符合一致性的?而在实践中要求任务是确定性的并不是个问题。

忽略异常数据

有时候由于用户编写的 mapreduce 函数存在 bug,导致处理某些数据时 mapreduce 函数必然发生异常,这就造成 MapReduce 任务无法正常完成。正常来说应当修复 bug,但有时候不可行,例如造成 bug 的代码可能是第三方库引入的。另一方面,有时候忽略这些造成异常的数据也是可以接受的,例如在对一个数据量非常庞大的数据集做统计分析时。因此,MapReduce 框架提供了一种可选的执行模式,当其检测到某些输入数据必然造成异常时,则会跳过这些数据从而使得执行流程能继续走下去。

为了实现这个功能,首先每个工作节点上都安装了一个 signal handler 程序用于捕获段异常和总线异常。在执行 mapreduce 任务之前,MapReduce 框架首先将当前任务需要的输入数据所对应的序号保存在工作节点内的一个全局变量中,在执行 mapreduce 任务时,如果用户代码发生异常,此时 signal handler 能捕获到相应的异常信号,然后 signal handler 会发送一个 UDP 数据包给主节点,该数据包中包含了执行当次任务的输入数据序号。如果主节点发现某个数据对应的任务执行失败了多次,则会忽略该数据而不是重新执行 mapreduce 任务。按照这样的描述,被忽略的数据是数据片维度,而不是键值对维度,因为每片的数据块大小相比于总数据量的大小来说微乎其微,所以整体影响不大。

本地执行

调试分布式程序并不是件简单的事,对于 MapReduce 任务来说,一次任务会被分发到几千台机器上执行,每台机器实际执行的任务也无法预测。为了方便调试、性能分析和小规模测试,Google 实现的 MapReduce 框架也提供了一个串行执行的版本,能在单台机器上串行执行所有任务。同时,用户也可通过参数控制一次 MapReduce 任务只执行些特定的 map 任务。通过在启动程序时指定调试参数,用户就可轻松的使用调试或测试工具(如 gdb)对编写的程序进行调试和测试。

状态信息

主节点内部同时运行了一个 HTTP 服务,用于提供给用户查看一系列状态信息。状态信息页面展示了当前任务的进度,例如有多少个任务已经完成,有多少个任务正在进行中,输入数据的大小,中间结果数据的大小,最终结果数据的大小,任务处理百分比等。同时,状态页面也提供了每个任务执行产生的标准错误输出和标准输出文件。用户可根据这些信息来预测任务需要多久才能完成,以及是否需要添加更多的计算资源。状态页面也可用于判断当前任务执行耗时是否比预期的长。

此外,状态页面也显示了失败的工作节点,以及这些失败的工作节点对应的 mapreduce 任务。这有助于用户排查编写的代码中是否有 bug

计数器

MapReduce 框架内部提供了一个计数器用于统计各个事件发生的次数。例如,用户可能希望统计一次任务中一共处理了多少个单词,或者有多少个德语文档建立了索引。

如果要开启这个功能,用户需要编写一个命名计数器,然后在 mapreduce 函数中在需要的时候对计数器自增,例如:

1
2
3
4
5
6
7
8
Counter* uppercase;
uppercase = GetCounter("uppercase);

map(String name, String contents):
for each word w in contents:
if (IsCapitalized(w)):
uppsercase->Increment();
EmitIntermediate(w, "1");

每个工作节点上的计数器的值会周期性的发送给主节点(如前文所述,主节点会周期性的对工作节点进行心跳探测,工作节点会在响应结果中带上计数器的值)。主节点会对执行成功的 mapreduce 任务返回的计数器聚合,当整个 MapReduce 任务完成将控制权交还给用户代码时,用户代码可获取到创建的计数器的值。当前的计数器的值也同样会展示在状态页面,用户也可根据此信息来观测整个任务的进展。在对计数器聚合时,和主节点会忽略已完成的某个任务的重复通知一样,主节点同样会忽略某个来自已完成任务的计数器更新,从而避免重复计数(任务的重复执行主要有两种情况,一种是由于网络不连通,导致主节点重新分配某个 mapreduce 任务到新的工作节点上;另一种是触发了后备任务,主节点主动分发同一个 mapreduce 任务给多个工作节点执行)。

MapRecue 框架本身也维护了一些计数器,例如已处理的输入数据键值对的数量,以及已生成的最终数据键值对的数量。

用户能很方便的通过计数器来检查 MapReduce 任务的行为。例如,在任务执行时用户可通过计数器来确保输出的键值对数量是否等于输入的键值对数量,或者已处理的德语文档的数量在全部已处理的文档数量中的占比是否符合预期。

性能

这一节主要描述 MapReduceGoogle 内部环境下运行的性能情况,这里不再赘述。简单举例来说,在1800台机器上执行一个 10T 数据量的分布式 grep 搜索耗时约150秒。

总结

最后,来自 Google 的总结:

  1. 限制性的编程模型使得计算并行化变得容易,以及有着较好的容错性,这也体现了计算机领域的一个重要思想:抽象
  2. 对于大型系统来说,网络 IO 容易成为瓶颈
  3. 冗余执行可以作为有效降低成为性能短板的机器带来的影响的手段,另外冗余也是应对机器异常、数据丢失的方式

参考

往一个二叉搜索树中插入一个节点后的结果并不唯一,例如对于下面的二叉搜索树:

alt

如果要插入节点2,可以将2作为3的左子节点:

alt

或者将2作为1的右子节点:

alt

对于第一种方法,类似于往单链表的中间插入节点,既要更新前继节点的 next 指针,又要将新的节点的 next 指针指向下一个节点;而对于第二种方法,只需要将新节点挂载到目标节点的左子节点或右子节点即可,实现上较为简洁,可分为非递归和递归两种解法。

非递归

整体算法分为两步:

  1. 找到要挂载的叶子节点
  2. 将新节点挂载到该叶子节点的左子节点或右子节点上

第一步等同于二叉搜索树的查找,从根节点开始,将目标值和当前节点的值进行比较,如果当前节点的值比目标值小,说明要找的节点在右子树中,移动到右子节点中查找;如果当前节点的值比目标值大,说明要找的节点在左子树中,移动到左子节点中查找。

找到目标叶子节点后,比较该叶子节点的值和目标值的大小,来决定新节点是作为左子节点还是右子节点插入:

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
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right


class Solution:
def insertIntoBST(self, root: TreeNode, val: int) -> TreeNode:
if not root:
return TreeNode(val)

prev, current = None, root

while current:
prev = current

if current.val < val:
current = current.right
else:
current = current.left

if prev.val > val:
prev.left = TreeNode(val)
else:
prev.right = TreeNode(val)

return root

递归

一般的二叉树问题的递归解法遵循如下的模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def dfs(root):
if not root:
# 处理终止条件

# 一种情况是左右子树只处理一边
if some condition:
dfs(root.left)
else:
dfs(root.right)

# 另一种情况是左右子树都处理
dfs(root.left)
dfs(root.right)

return something

在当前的问题下,终止条件发生的条件为找到了目标叶子节点,此时需要新建一个节点;而对于递归的处理,这里适用于第一种情况,即左右子树只处理一边,判断条件为比较当前节点的值和目标值的大小,所以可以粗略的构造出程序的框架:

1
2
3
4
5
6
7
8
9
10
def dfs(root, val):
if not root:
create new node with val

if root.val > val:
dfs(root.left)
else:
dfs(root.right)

return something

下一个问题是,这个递归函数的返回值是什么?从终止条件的处理可以看到递归函数返回的是某个节点,联想到往一个二叉搜索树中插入一个节点后需要返回一个新的树,所以这里递归函数的返回值应该是根节点。

然而还缺少一步,就是新节点的挂载,目前新节点返回后并没有任何节点引用它,需要在终止条件的上层调用中处理,即每次递归调用时都重新赋值左子树或右子树的根节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right


class Solution:
def insertIntoBST(self, root: TreeNode, val: int) -> TreeNode:
if not root:
return TreeNode(val)

if root.val < val:
root.right = self.insertIntoBST(root.right, val)
else:
root.left = self.insertIntoBST(root.left, val)

return root

二叉树的遍历直观的解法是使用递归求解,不过同样也可使用非递归方式求解。

前序遍历

先来看前序遍历的递归求解:

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
from typing import List


# Definition for a binary tree node.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right


class Solution:
def preorderTraversal(self, root: TreeNode) -> List[int]:
values = []

self._preorder_traversal(root, values)

return values

def _preorder_traversal(self, root: TreeNode, values: List[int]) -> None:
if not root:
return

values.append(root.val)

self._preorder_traversal(root.left, values)
self._preorder_traversal(root.right, values)

对于如下的二叉树:

alt

其调用链为:

1
2
3
4
5
_preorder_traversal(1)
_preorder_traversal(2)
_preorder_traversal(4)
_preorder_traversal(5)
_preorder_traversal(3)

可以看到越深的节点对应的函数调用越先返回,对应先进后出的模型,即栈,所以递归转非递归可借助栈实现。

由于前序遍历是先访问根节点,所以对于每个子树,可以先将根节点入栈,然后依次弹出栈顶的节点,从而实现先访问根节点,然后将左右子树的根节点入栈,由于左子树需要先于右子树被访问,所以右子树的根节点要先入栈,然后再入栈左子树的根节点:

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
from typing import List


# Definition for a binary tree node.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right


class Solution:
def preorderTraversal(self, root: TreeNode) -> List[int]:
if not root:
return []

values = []
stack = [root]

while stack:
current = stack.pop()
values.append(current.val)

if current.right:
stack.append(current.right)

if current.left:
stack.append(current.left)

return values

中序遍历

在递归的方案下,前序遍历改为中序遍历只需改变 values.append(root.val) 的执行位置即可,而在非递归方案下,并不能通过直接改变 values.append(current.val) 的执行位置来实现,因为不管放到哪个位置,都会提前访问到根节点。

中序遍历下,最左下方的节点是最先被访问的,沿着左子树的根节点这条线,等同于一个单链表的倒序访问,单链表的倒序如果用栈来实现则是将单链表的所有节点从链表头开始遍历依次放入栈,然后再依次出栈,类似的,只要当前节点存在左子树,则持续将左子树的根节点压入栈,这样下次出栈时,就会先访问最左下方的节点。当某个节点出栈时,由于上述的操作,它必然是某个子树的最左下方的节点,此时需要转到该节点的右子树重复上述流程从而访问右子树的全部节点:

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
from typing import List


class TreeNode:
def __init__(self, x):
self.val = x
self.left = None
self.right = None


class Solution:
def inorderTraversal(self, root: TreeNode) -> List[int]:
values = []
stack = []
current = root

while current or stack:
while current:
stack.append(current)
current = current.left

current = stack.pop()
values.append(current.val)
current = current.right

return values

虽然前序遍历的非递归方案不适用于中序遍历,不过中序遍历的递归方案可略微修改适用于前序遍历,只需将 values.append(current.val) 放在不断入栈左子树的循环中即可:

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
from typing import List


# Definition for a binary tree node.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right


class Solution:
def preorderTraversal(self, root: TreeNode) -> List[int]:
values = []
stack = []
current = root

while current or stack:
while current:
values.append(current.val)
stack.append(current)
current = current.left

current = stack.pop()
current = current.right

return values

后序遍历

后序遍历和中序遍历相同,最先访问的都是最左下方的节点,所以对左子树不断入栈这段逻辑不变,不同的是当出栈时,当前出栈的节点有可能存在右子树,而右子树还还没有被访问,所以当前节点还不能出栈。因此,需要先判断栈顶的节点是否存在右子树,以及右子树是否被访问过,如果存在右子树且未被访问则转向右子树重复上述流程,否则可弹出栈顶节点。而判断栈顶的右子树是否被访问可通过比较栈顶的右子树和上一个被访问的节点来实现,如果两者相等,说明栈顶的右子树刚被访问过,否则未被访问过:

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
from typing import List


# Definition for a binary tree node.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right


class Solution:
def postorderTraversal(self, root: TreeNode) -> List[int]:
prev = None
current = root
stack = []
values = []

while current or stack:
while current:
stack.append(current)
current = current.left

top = stack[-1]

if top.right and prev != top.right:
current = top.right
else:
current = stack.pop()
values.append(current.val)
prev = current
current = None

return values

通用模板

上述各非递归方案各不相同,是否存在和递归方案类似的通用模板方案?这里 给出了一种通用方案,首先需要额外引入一个数据结构来标记节点是否被访问过:

1
2
3
4
5
6
7
8
9
private class Pair {
boolean visited;
TreeNode node;

Pair(TreeNode node, boolean visited) {
this.node = node;
this.visited = visited;
}
}

Python 中,可简单通过元组来实现,对应模板代码为:

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
from typing import List


# Definition for a binary tree node.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right


class Solution:
def xxxTraversal(self, root: TreeNode) -> List[int]:
if not root:
return []

stack = [(root, False)]
values = []

while stack:
current, visited = stack.pop()

if visited:
values.append(current.val)
else:
# 在这里处理左子树,右子树,根节点的入栈顺序
pass

return values

对于三种遍历方式,上述模板方法仅在处理左子树,右子树,根节点的入栈顺序上不同,实际入栈顺序和遍历顺序相反:

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
# 前序遍历
if current.right:
stack.append((current.right, False))

if current.left:
stack.append((current.left, False))

stack.append((current, True))

# 中序遍历
if current.right:
stack.append((current.right, False))

stack.append((current, True))

if current.left:
stack.append((current.left, False))

# 后序遍历
stack.append((current, True))

if current.right:
stack.append((current.right, False))

if current.left:
stack.append((current.left, False))

从出栈的角度来说,上述方法和理论遍历顺序并不一致,每个节点会入栈两次,第二次入栈时才会设置 visitedTrue,但从 visited 的角度来说顺序是和理论遍历顺序一致的。

参考

Docker Hub 的免费账户已不再支持关联 GitHub 仓库并自动构建镜像的功能,不过可以通过 GitHub Actions 来自动构建和推送镜像。实现方式非常简单,Docker 官方已给出了示例(Build and push Docker images):

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
name: ci

on:
push:
branches:
- 'master'

jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
push: true
tags: user/app:latest

一共有三处要注意,第一开头的 branches 下对于新建的仓库需要填写 main 而不是 master

第二需要为 Login to DockerHub 阶段设置 DockerAccess TokenAccess Token 可以通过 Docker HubAccount Settings -> Security -> New Access Token 创建,然后通过 GitHub 仓库的 Settings -> Secrets -> New repository secret 分别创建 DOCKERHUB_USERNAMEDOCKERHUB_TOKEN

第三最后的 tags: user/app:latest 中的 userapp 需要修改为实际的用户名和镜像名。

参考:

Commodity Hardware 指较为廉价的硬件设备,它具有如下特点:

  1. 价格相对低廉
  2. 易采购
  3. 和同类型的硬件可相互替换

由此相关的一个概念叫 commodity computing,即使用大量的廉价硬件来实现低成本、高性能的并行计算,与之相对的则是使用较少数高成本的超级计算机。

参考:

创建了 Azure MySQL 实例后(这里使用的是 Flexible Server),首先导出原始数据库的数据,因为用的是 Docker 所以通过以下方式导出:

1
docker exec ${container_id} /usr/bin/mysqldump -u ${user_name} --password=${password} ${database_name} > backup.sql

然后通过 Azure CLI 创建一个新的数据库:

1
az mysql flexible-server db create --resource-group ${resource_group} --server-name ${server_name} --database-name ${database_name}

最后通过 Azure CLI 导入数据:

1
az mysql flexible-server execute -n ${server_name} -u ${user_name} -p ${password} -d ${database_name} -f ${path_to_backup_sql_file}

参考:

Java 中,如果两个同名方法仅返回值类型不同,这是不允许的,即编译器不会认为这是方法重载,如下述类中的方法 f

1
2
3
4
5
6
7
8
9
public class Demo {
public int f() {
return 0;
}

public String f() {
return "";
}
}

编译器会提示 'f()' is already defined in 'Demo'。假设编译器支持这种方式的方法重载,会有什么问题?在某些情况下,编译器无法区分调用的是哪个方法,例如当调用 f() 却忽略返回值时:

1
f();

所以仅返回值类型不同不能作为方法重载的形式。

参考:

  • Thinking in Java

和常见的语言不同,Pythonfor/while 可以配合 else 使用。简单来说,当 for/while 循环体中没有执行 break 时,就会执行 else 中的代码。假设需要判断数组中是否存在某个数,如果不存在的话则抛出异常,一种可能的写法是:

1
2
3
4
5
6
7
8
9
10
11
target = 10
found = False

for i in range(5):
if i == target:
found = True

break

if not found:
raise Exception('not found')

借助 for/while else 可改写成:

1
2
3
4
5
6
7
target = 10

for i in range(5):
if i == target:
break
else:
raise Exception('not found')

虽然代码少了几行,但是对于不熟悉该语法特性的人来说可能无法一眼看穿代码的意图。

参考:

0%