『数据结构』红黑树(red-black tree)

1. 定义与性质

红黑树是一种平衡的二叉查找树

1.1. 数据域

每个结点有 5 个数据域

  • color: red or black
  • key: keyword
  • left: pointer to left child
  • right:pointer to right child
  • p: pointer to nil leaf

1.2. 红黑性质

满足下面的 红黑性质 的二叉查找树就是红黑树:

  • 每个结点或是红色或是黑色
  • 根是黑
  • nil leaf 是 黑
  • 红结点的孩子是黑
  • 从每个结点出发,通过子孙到达叶子结点的各条路径上 黑结点数相等

如,叶子结点 是 nil, 即不存储任何东西, 为了编程方便,相对的,存有数据的结点称为内结点

为了节省空间, 可以如下实现, 只需要一个 nil 结点
nil leaf

1.3. 黑高度

从某个结点 x 到叶结点的黑色结点数,称为此结点的黑高度, 记为 $h_b(x)$
树的黑高度是根的黑高度

  1. 以 x 为 根的子树至少包含 $2^{h_b(x)}-1$个结点
  2. 一颗有 n 个内结点的红黑树高度至多为$2lg(n+1)$

可用归纳法证明1
证明 2:
设树高 h
由红黑性质4, 根结点到叶子路径上的黑结点数至少 $\frac{h}{2}$,即 $h_b(root)\geqslant \frac{h}{2}$
再由1,

即 $ h\leqslant 2lg(n+1)$

2. 旋转

由于上面证明的红黑树高为 $O(logn)$,红黑树的 insert, delete, search 等操作都是, $O(logn)$.
进行了 insert, delete 后可能破坏红黑性质, 可以通过旋转来保持.

下面是对结点 x 进行 左旋与右旋.
注意进行左旋时, 右孩子不是 nil(要用来作为旋转后 x 的双亲), 同理 右旋的结点的左孩子不是nil
左旋与右旋
总结起来就是: 父亲旋转,顺时针就是右旋,逆时针就是左旋, 旋转的结果是儿子成为原来父亲的新父亲, 即旋转的结点下降一层, 它的一个儿子上升一层.

3. 插入

插入的过程:

  • 先同二叉查找树那样插入, 做为叶子(不为空)
  • 然后将新结点的 左右孩子设为 nil , 颜色设为红色
  • 最后再进行颜色调整以及旋转(维持红黑性质)

这是算法导论1上的算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
RB-INSERT(T, z)  
y ← nil[T] // 新建节点“y”,将y设为空节点。
x ← root[T] // 设“红黑树T”的根节点为“x”
while x ≠ nil[T] // 找出要插入的节点“z”在二叉树T中的位置“y”
do y ← x
if key[z] < key[x]
then x ← left[x]
else x ← right[x]
p[z] ← y // 设置 “z的父亲” 为 “y”
if y = nil[T]
then root[T] ← z // 情况1:若y是空节点,则将z设为根
else if key[z] < key[y]
then left[y] ← z // 情况2:若“z所包含的值” < “y所包含的值”,则将z设为“y的左孩子”
else right[y] ← z // 情况3:(“z所包含的值” >= “y所包含的值”)将z设为“y的右孩子”
left[z] ← nil[T] // z的左孩子设为空
right[z] ← nil[T] // z的右孩子设为空。至此,已经完成将“节点z插入到二叉树”中了。
color[z] ← RED // 将z着色为“红色”
RB-INSERT-FIXUP(T, z) // 通过RB-INSERT-FIXUP对红黑树的节点进行颜色修改以及旋转,让树T仍然是一颗红黑树

3.1. 二叉查找树的插入

可以用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
def insert(self,nd):
if not isinstance(nd,node):
nd = node(nd)
elif nd.isBlack: nd.isBlack = False

if self.root is None:
self.root = nd
self.root.isBlack = True
else:
parent = self.root
while parent:
if parent == nd : return None
if parent>nd:
if parent.left :
parent = parent.left
else:
parent.left = nd
break
else:
if parent.right:
parent = parent.right
else:
parent.right = nd
break
self.fixUpInsert(parent,nd)

3.2. 颜色调整与旋转

3.2.1. 问题

在插入后,可以发现后破坏的红黑性质只有以下两条(且互斥)

  1. root 是红 (这可以直接将root 颜色设为黑调整)
  2. 红结点的孩子是黑

所以下面介绍如何保持 红结点的孩子是黑 , 即插入结点的双亲结点是红的情况.

下面记 结点 x 的 双亲为 p(x), 新插入的结点为 x, 记 uncle 结点 为 u(x)

由于 p(x) 是红色, 而根结点是黑色, 所以 p(x)不是根, p(p(x))存在

3.2.2. 情况

有如下三种情况

每种情况的解决方案如下

3.2.2.1. case1: x 的叔叔是红色的

这里只需改变颜色, 将 p(x)变为 黑, p(p(x))变为红, u(x) 变为黑色 (x为右孩子同样)

3.2.2.2. case2: x 的叔叔是黑色, x,p(x), p(p(x)),方向为 left-right 或者 right-left

即 x,p(x), p(p(x)) 成折线状

3.2.2.3. case3: x 的叔叔是黑色, x,p(x), p(p(x)),方向为 left-left 或者 right-right

即 x,p(x), p(p(x)) 成直线状

当 x 为右孩子时, 通过旋转变成p(x) 的双亲, 然后相当于 新插入 p(x)作为左孩子, 再进行转换.

即将新结点的双亲向上一层旋转,颜色变为黑色, 而新节点的祖父向下一层, 颜色变为红色

3.2.3. 总体解决方案

我最开始也没有弄清楚, 有点绕晕的感觉, 后来仔细读了书上伪代码, 然后才发现就是一个状态机, 画出来就一目了然了.

现在算是知其然了, 那么怎样知其所以然呢? 即 为什么要分类这三个 case, 不重不漏了吗?

其实也简单, 只是太繁琐.
就是将各种情况枚举出来, 一一分析即可. 我最开始试过, 但是太多,写在代码里很容易写着写着就混了.
而算法导论上分成这三个case , 很简洁, 只是归纳了一下而已. 如果想看看枚举情况的图与说明,可以参考2 .

算法导论上的伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RB-INSERT-FIXUP(T, z)
while color[p[z]] = RED // 若“当前节点(z)的父节点是红色”,则进行以下处理。
do if p[z] = left[p[p[z]]] // 若“z的父节点”是“z的祖父节点的左孩子”,则进行以下处理。
then y ← right[p[p[z]]] // 将y设置为“z的叔叔节点(z的祖父节点的右孩子)”
if color[y] = RED // Case 1条件:叔叔是红色
then color[p[z]] ← BLACK ▹ Case 1 // (01) 将“父节点”设为黑色。
color[y] ← BLACK ▹ Case 1 // (02) 将“叔叔节点”设为黑色。
color[p[p[z]]] ← RED ▹ Case 1 // (03) 将“祖父节点”设为“红色”。
z ← p[p[z]] ▹ Case 1 // (04) 将“祖父节点”设为“当前节点”(红色节点)
else if z = right[p[z]] // Case 2条件:叔叔是黑色,且当前节点是右孩子
then z ← p[z] ▹ Case 2 // (01) 将“父节点”作为“新的当前节点”。
LEFT-ROTATE(T, z) ▹ Case 2 // (02) 以“新的当前节点”为支点进行左旋。
color[p[z]] ← BLACK ▹ Case 3 // Case 3条件:叔叔是黑色,且当前节点是左孩子。(01) 将“父节点”设为“黑色”。
color[p[p[z]]] ← RED ▹ Case 3 // (02) 将“祖父节点”设为“红色”。
RIGHT-ROTATE(T, p[p[z]]) ▹ Case 3 // (03) 以“祖父节点”为支点进行右旋。
else (same as then clause with "right" and "left" exchanged) // 若“z的父节点”是“z的祖父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
color[root[T]] ← BLACK

我用python 实现如下. 由于左右方向不同, 如果向上面伪代码那样实现, fixup 代码就会有两份类似的(即 right left 互换), 为了减少代码冗余, 我就定义了 setChild, getChild 函数, 传递左或是右孩子这个方向的数据(代码中是isLeft), 所以下面的就是完整功能的 fixup, 可以减少一般的代码量, haha😄,
(下文 删除结点同理)

其实阅读代码也简单, 可以直接当成 isLeft 取真值.

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
def fixUpInsert(self,parent,nd):
''' adjust color and level, there are two red nodes: the new one and its parent'''
while not self.checkBlack(parent):
grand = self.getParent(parent)
isLeftPrt = grand.left is parent
uncle = grand.getChild(not isLeftPrt)
if not self.checkBlack(uncle):
# case 1: new node's uncle is red
self.setBlack(grand, False)
self.setBlack(grand.left, True)
self.setBlack(grand.right, True)
nd = grand
parent = self.getParent(nd)
else:
# case 2: new node's uncle is black(including nil leaf)
isLeftNode = parent.left is nd
if isLeftNode ^ isLeftPrt:
# case 2.1 the new node is inserted in left-right or right-left form
# grand grand
# parent or parent
# nd nd
parent.setChild(nd.getChild(isLeftPrt),not isLeftPrt)
nd.setChild(parent,isLeftPrt)
grand.setChild(nd,isLeftPrt)
nd,parent = parent,nd
# case 2.2 the new node is inserted in left-left or right-right form
# grand grand
# parent or parent
# nd nd
grand.setChild(parent.getChild(not isLeftPrt),isLeftPrt)
parent.setChild(grand,not isLeftPrt)
self.setBlack(grand, False)
self.setBlack(parent, True)
self.transferParent(grand,parent)
self.setBlack(self.root,True)

4. 删除

算法导论上的算法

写的很简练👍
rb-delete

4.1. 二叉查找树删除结点

下面 z 是要删除的结点, y 是 其后继或者是它自己, x 是 y 的一个孩子(如果 y 的孩子为 nil,则为 nli, 否则 y 只有一个非 nil 孩子, 为 x)

  • 当 z 孩子全是 nil (y==z): 直接让其双亲对应的孩子为 nil
  • 当 z 只有一个非 nil 孩子 x (y==z):

    1. 如果 z 为根, 则让 x 为根.
    2. 让 y 的双亲连接到 x
  • 当 z 有两个非nil孩子(y!=z): 复制其后继 y 的内容到 z (除了指针,颜色) , 将其后继 y 的孩子(最多只有一个 非 nil ,不然就不是后继了)连接到其后继的双亲, 删除 其后继y,

3 如果要删除有两个孩子的结点 z , 则找到它的后继y(前趋同理), 可以推断 y 一定没有左孩子, 右孩子可能有,可能没有. 也就是最多一个孩子.
所以将 y 的值复制到 x 位置, 现在相当于删除 y 处的结点.
这样就化为 删除的结点最多一个孩子的情况.

4.2. 调整颜色与旋转

可以发现只有当 y 是黑色,才进行颜色调整以及旋转(维持红黑性质), 因为如果删除的是红色, 不会影响黑高度, 所有红黑性质都不会破坏
伪代码如下, (我的python代码见文末)

如果被删除的结点 y 是黑色的, 有三种破坏红黑性质的情况

  1. y是根, 则 y 的一个红色孩子成为新根
  2. 进行删除结点过程中, p(y) 的孩子有 x, 两者都是红色
  3. 删除 y 导致包含y 的路径上的黑结点 少 1个

修复3的思路:
如果可能,在兄弟一支,通过旋转,改变颜色修复
否则, 将红结点一直向上推(因为当前路径上少了一个黑结点,向上推的过程中使红结点所在的子树都少一个黑结点), 直到到达树根, 那么全部路径都少一个黑结点, 3就修复了, 这时只需将根设为黑就修复了 1

代码中的 while 循环的目的是将额外的黑色沿树上移,直到

  • x 指向一个红黑结点
  • x 指向根,这时可以简单地消除额外的黑色
  • 颜色修改与旋转

在 while 中, x 总是指向具有双重黑色的那个非根结点, 在第 2 行中要判断 x 是其双亲的左右孩子
w 表示 x 的相抵. w 不能为 nil(因为 x 是双重黑色)

算法中的四种情况如图所示

  • x 的兄弟 w 是红色的

  • x 的兄弟 w 是黑色的, w的两个孩子都是黑色的
  • x 的兄弟 w 是黑色的, w 的左孩子是红,右孩子是黑
  • x 的兄弟 w 是黑色的, w 的孩子是红色的

注意上面都是先考虑的左边, 右边可以对称地处理.

同插入一样, 为了便于理解, 可以作出状态机.
而且这些情形都是归纳化简了的, 你也可以枚举列出基本的全部情形.

5. 数据结构的扩张

5.1. 平衡树的扩张

通过在平衡树(如红黑树上的每个结点 加上 一个数据域 size (表示以此结点为根的子树的结点数.) 可以使获得第 i 大的数 的时间复杂度为 $O(logn)$

在 $O(n)$ 时间内建立, python代码如下

1
2
3
def setSize(root):
if root is None:return 0
root.size = setSize(root.left) + setSize(root.right)+1

在$O(logn)$时间查找,

1
2
3
4
5
6
7
8
def find(root,i):
r = root.left.size +1
if r==i:
return root
if r > i:
return find(root.left,i)
else:
return find(root.right,i-r)

6. python 代码

github地址

我的代码有两点不同

  • 用了 setChild, getChild 来简化代码量
  • 每个结点没有上面的 p 域, 即指向 nil leaf的,我直接让 left, right 为 None, 然后定义了两个函数 setBlack, checkBlack 来操作 颜色数据 isBlack(当为 True 时代表黑色,否则为红). 如果为 None, 这两个函数也能正确的处理.可以直接见代码

其他的基本上是按照算法导论上的伪代码提到的case 来实现的. 然后display 只是测试的时候,为了方便调试而层序遍历打印出来

效果如下

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
'''
#########################################################################
# File : redBlackTree.py
# Author: mbinary
# Mail: zhuheqin1@gmail.com
# Blog: https://mbinary.coding.me
# Github: https://github.com/mbinary
# Created Time: 2018-07-12 20:34
# Description:
#########################################################################
'''
from functools import total_ordering
from random import randint, shuffle

@total_ordering
class node:
def __init__(self,val,left=None,right=None,isBlack=False):
self.val =val
self.left = left
self.right = right
self.isBlack = isBlack
def __lt__(self,nd):
return self.val < nd.val
def __eq__(self,nd):
return nd is not None and self.val == nd.val
def setChild(self,nd,isLeft = True):
if isLeft: self.left = nd
else: self.right = nd
def getChild(self,isLeft):
if isLeft: return self.left
else: return self.right
def __bool__(self):
return self.val is not None
def __str__(self):
color = 'B' if self.isBlack else 'R'
return f'{color}-{self.val:}'
def __repr__(self):
return f'node({self.val},isBlack={self.isBlack})'
class redBlackTree:
def __init__(self,unique=False):
'''if unique is True, all node'vals are unique, else there may be equal vals'''
self.root = None
self.unique = unique

@staticmethod
def checkBlack(nd):
return nd is None or nd.isBlack
@staticmethod
def setBlack(nd,isBlack):
if nd is not None:
if isBlack is None or isBlack:
nd.isBlack = True
else:nd.isBlack = False
def sort(self,reverse = False):
''' return a generator of sorted data'''
def inOrder(root):
if root is None:return
if reverse:
yield from inOrder(root.right)
else:
yield from inOrder(root.left)
yield root
if reverse:
yield from inOrder(root.left)
else:
yield from inOrder(root.right)
yield from inOrder(self.root)
def getParent(self,chd):
'''note that use is to find real node when different nodes have euqiv val'''
if self.root is chd:return None
nd = self.root
while nd:
if nd>chd and nd.left is not None:
if nd.left is chd: return nd
else: nd = nd.left
elif nd<chd and nd.right is not None:
if nd.right is chd: return nd
else: nd = nd.right
def find(self,val):
nd = self.root
while nd:
if nd.val ==val:
return nd
elif nd.val>val:
nd = nd.left
else:
nd = nd.right
def getSuccessor(self,nd):
if nd:
if nd.right:
nd = nd.right
while nd.left:
nd = nd.left
return nd
else:return self.getParent(nd)
def transferParent(self,origin,new):
if origin is self.root:
self.root = new
else:
prt = self.getParent(origin)
prt.setChild(new, prt.left is origin)

def insert(self,nd):
if not isinstance(nd,node):
nd = node(nd)
elif nd.isBlack: nd.isBlack = False

if self.root is None:
self.root = nd
self.root.isBlack = True
else:
parent = self.root
while parent:
if parent == nd : return None
if parent>nd:
if parent.left :
parent = parent.left
else:
parent.left = nd
break
else:
if parent.right:
parent = parent.right
else:
parent.right = nd
break
self.fixUpInsert(parent,nd)
def fixUpInsert(self,parent,nd):
''' adjust color and level, there are two red nodes: the new one and its parent'''
while not self.checkBlack(parent):
grand = self.getParent(parent)
isLeftPrt = grand.left is parent
uncle = grand.getChild(not isLeftPrt)
if not self.checkBlack(uncle):
# case 1: new node's uncle is red
self.setBlack(grand, False)
self.setBlack(grand.left, True)
self.setBlack(grand.right, True)
nd = grand
parent = self.getParent(nd)
else:
# case 2: new node's uncle is black(including nil leaf)
isLeftNode = parent.left is nd
if isLeftNode ^ isLeftPrt:
# case 2.1 the new node is inserted in left-right or right-left form
# grand grand
# parent or parent
# nd nd
parent.setChild(nd.getChild(isLeftPrt),not isLeftPrt)
nd.setChild(parent,isLeftPrt)
grand.setChild(nd,isLeftPrt)
nd,parent = parent,nd
# case 2.2 the new node is inserted in left-left or right-right form
# grand grand
# parent or parent
# nd nd
grand.setChild(parent.getChild(not isLeftPrt),isLeftPrt)
parent.setChild(grand,not isLeftPrt)
self.setBlack(grand, False)
self.setBlack(parent, True)
self.transferParent(grand,parent)
self.setBlack(self.root,True)

def copyNode(self,src,des):
'''when deleting a node which has two kids,
copy its succesor's data to his position
data exclude left, right , isBlack
'''
des.val = src.val
def delete(self,nd):
'''delete node in a binary search tree'''
if not isinstance(nd,node):
nd = self.find(nd)
if nd is None: return
y = None
if nd.left and nd.right:
y= self.getSuccessor(nd)
else:
y = nd
py = self.getParent(y)
x = y.left if y.left else y.right
if py is None:
self.root = x
elif y is py.left:
py.left = x
else:
py.right = x
if y != nd:
self.copyNode(y,nd)

if self.checkBlack(y): self.fixUpDel(py,x)


def fixUpDel(self,prt,chd):
''' adjust colors and rotate '''
while self.root != chd and self.checkBlack(chd):
isLeft = prt.left is chd
brother = prt.getChild(not isLeft)
# brother is black
lb = self.checkBlack(brother.getChild(isLeft))
rb = self.checkBlack(brother.getChild(not isLeft))
if not self.checkBlack(brother):
# case 1: brother is red. converted to case 2,3,4
# prt (isLeft) rotate
prt.setChild(brother.getChild(isLeft), not isLeft)
brother.setChild(prt, isLeft)

self.setBlack(prt,False)
self.setBlack(brother,True)

self.transferParent(prt,brother)
elif lb and rb:
# case 2: brother is black and two kids are black.
# conveted to the begin case
self.setBlack(brother,False)
chd = prt
prt = self.getParent(chd)
else:
if rb:
# case 3: brother is black and left kid is red and right child is black
# uncle's son is nephew, and niece for uncle's daughter
nephew = brother.getChild(isLeft)
self.setBlack(nephew,True)
self.setBlack(brother,False)

# brother (not isLeft) rotate
prt.setChild(nephew,not isLeft)
brother.setChild(nephew.getChild(not isLeft),isLeft)
nephew.setChild(brother, not isLeft)
brother = nephew

# case 4: brother is black and right child is red
brother.isBlack = prt.isBlack
self.setBlack(prt,True)
self.setBlack(brother.getChild(not isLeft),True)

# prt left rotate
prt.setChild(brother.getChild(isLeft),not isLeft)
brother.setChild(prt,isLeft)

self.transferParent(prt,brother)

chd = self.root
self.setBlack(chd,True)


def display(self):
def getHeight(nd):
if nd is None:return 0
return max(getHeight(nd.left),getHeight(nd.right)) +1
def levelVisit(root):
from collections import deque
lst = deque([root])
level = []
h = getHeight(root)
ct = lv = 0
while 1:
ct+=1
nd = lst.popleft()
if ct >= 2**lv:
lv+=1
if lv>h:break
level.append([])
level[-1].append(str(nd))
if nd is not None:
lst += [nd.left,nd.right]
else:
lst +=[None,None]
return level
def addBlank(lines):
width = 5
sep = ' '*width
n = len(lines)
for i,oneline in enumerate(lines):
k = 2**(n-i) -1
new = [sep*((k-1)//2)]
for s in oneline:
new.append(s.ljust(width))
new.append(sep*k)
lines[i] = new
return lines

lines = levelVisit(self.root)
lines = addBlank(lines)
li = [''.join(line) for line in lines]
li.insert(0,'red-black-tree'.rjust(48,'-') + '-'*33)
li.append('end'.rjust(42,'-')+'-'*39+'\n')
return '\n'.join(li)

def __str__(self):
return self.display()

测试代码

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
def genNum(n =10):
nums =[]
for i in range(n):
while 1:
d = randint(0,100)
if d not in nums:
nums.append(d)
break
return nums

def buildTree(n=10,nums=None,visitor=None):
if nums is None or nums ==[]: nums = genNum(n)
rbtree = redBlackTree()
print(f'build a red-black tree using {nums}')
for i in nums:
rbtree.insert(i)
if visitor:
visitor(rbtree)
return rbtree,nums
def testInsert():
def visitor(t):
print(t)
rbtree,nums = buildTree(visitor = visitor)
print('-'*5+ 'in-order visit' + '-'*5)
for i,j in enumerate(rbtree.sort()):
print(f'{i+1}: {j}')

def testSuc():
rbtree,nums = buildTree()
for i in rbtree.sort():
print(f'{i}\'s suc is {rbtree.getSuccessor(i)}')

def testDelete():
#nums = [2,3,3,2,6,7,2,1]
nums = None
rbtree,nums = buildTree(nums = nums)
print(rbtree)
for i in nums:
print(f'deleting {i}')
rbtree.delete(i)
print(rbtree)

if __name__=='__main__':
#testSuc()
#testInsert()
testDelete()

7. 参考

1. 算法导论
2. https://www.jianshu.com/p/a5514510f5b9?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation
3. https://www.jianshu.com/p/0b68b992f688?utm_campaign=maleskine&utm_content=note&utm_medium=seo_notes&utm_source=recommendation
-------本文结束感谢阅读-------
Title - Artist
0:00