0%

这里简单记录一下 npm 包的创建和发布流程。

创建

在项目中通过 npm init 可创建 package.json 文件,之后填写内容大致如下:

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
{
"name": "@qiweipeng/use-axios",
"version": "1.0.0",
"description": "An axios hook.",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"files": [
"/lib"
],
"scripts": {
"build": "rm -rf ./lib && tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/qiweipeng/useAxios.git"
},
"keywords": [
"axios",
"zod"
],
"author": "Weipeng Qi <qiweipeng@hotmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/qiweipeng/useAxios/issues"
},
"homepage": "https://github.com/qiweipeng/useAxios#readme",
"dependencies": {
"@anatine/zod-mock": "^3.12.0",
"@faker-js/faker": "^8.0.0",
"axios": "^1.3.0",
"zod": "^3.21.0"
},
"devDependencies": {
"@types/react": "^18.0.0",
"typescript": "^4.4.2"
},
"peerDependencies": {
"react": "^18.0.0"
}
}

同时,在项目根目录创建 index.ts 文件,该文件作为入口文件,暴露项目中需要公开的文件。如:

1
2
3
4
5
// index.ts
export {default as axios} from './src/axios'
export * from './src/useAxios'
export * from './src/useValidatedAxios'
export {default as useUpdateRef} from './src/utils/useUpdateRef'

下面解释一下 package.json 中的部分字段的设置:

  • name:包名,其中 qiweipengnpm 网站中的用户名,如此设置后,安装的命令就是如 yarn add @qiweipeng/use-axios
  • version:版本号,这个每次发版更新即可;
  • maintypes:分别代表了编译之后 JavaScriptTypeScript 的入口文件,由于编译之后的目录是 ./lib 文件夹,而我们预先设置的入口文件是在根目录,所以设置的路径分别是 ./lib/index.js./lib/index.d.ts
  • files:表示发布的代码的目录,这里需要设置为编译后的目录,即 /lib
  • scripts-build:编译命令,代码完成后需要执行该命令编译代码;
  • dependencies:发布的包的依赖;

另外,需要注意在 tsconfig.json 文件中,加入如下两个字段:

1
2
"outDir": "./lib",
"rootDir": "./",

因为 ./lib 文件夹没必要提交到 Git,所以最好在 .gitignore 中加入 lib 进行忽略。

发布

代码完成后执行 npm build 编译代码,之后使用 npm login 可以登录账号,登录完成后使用 npm publish --access public 即可发布。

另外,如果代码仓库是私有库,又或者我们发布的仓库不是 npm 官方的仓库而是自建库,那么可以在根目录创建 .npmrc 文件来进行设置:

1
2
registry="http://xxx.xxx/"
//xxx.xxx/:_authToken=abbe5034-c886-26b6-a324-2f3c701de522

其中第一行代表了包发布的地址,第二行的含义是给某个地址设置 _authToken,即一次性密码,设置后即使本地没有登录,也可以读取该密码进行登录来发包。

这里记录一下在 Kindle 上最喜欢的几个字体。

中文字体

仓耳今楷01-W04

介于宋体与楷体之间的字体。仓耳今楷分01-05共五种字体,其中01最为方正,观感特别好。手机上使用仓耳今楷01-W03

仓耳云黑-W04

更具现代和时尚感的黑体字,比一般的黑体偏圆润一点点,但不过度。手机上使用仓耳云黑-W03

方正悠黑508R

可以用来替代默认黑体字。手机上使用方正悠黑506L

方正悠宋508R

可以用来替代默认宋体字。手机上使用方正悠宋507R

汉仪润圆-55W

虽然是「圆」体,但实际观感有宋体的感觉。手机上使用汉仪润圆-45W

汉仪正圆-55S

比一般的圆体字型略小,更清秀一些,可以替代默认圆体字。手机上使用汉仪正圆-45S

霞鹜文楷-Bold

Github 上一万多星的开源字体,非常适合阅读使用。手机上使用霞鹜文楷-Regular

英文字体

Bookerly

Kindle 中自带的字体,目前英文阅读首选字体。

介绍

已知两个元素,如何快速判断两个元素是否属于同一个集合?已知两个集合,如何将两个集合合并为同一个集合?并查集就是解决这两个问题的数据结构。

并查集可以非常快判断网络中节点间的连接状态。

并查集是特殊的树结构,它是又孩子指向父亲。

注意并查集只关注两个点的连接问题,而不关注两个点的路径问题(路径问题显然比只关注连接要更复杂)。

并查集也不考虑添加和删除元素,只考虑当下的元素进行并或者查的操作。对于一组数据,并查集主要支持两个动作:

  • unionElements(p, q),即将 p 和 q 两个元素所属的集合合并;
  • isConnected(p, q),判断 p 和 q 两个元素是否属于同一个集合。

接口

并查集接口

1
2
3
4
5
6
public interface UF {

int getSize();
boolean isConnected(int p, int q);
void unionElements(int p, int q);
}

Quick Find

这种实现方式只是在查询上比较快速,在合并操作上并不高效,并不是并查集的最终实现。

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
/**
* 我们可以将数据按照如下方式存储,每个元素对应一个 id,id 相同即代表元素属于同一个集合。
*
* 0 1 2 3 4 5 6 7 8 9
* id 0 1 0 1 0 1 0 1 0 1
*
* 表示 0 2 4 6 8 在一个集合中,1 3 5 7 9 在另一个集合中。
*
* 此时,查找一个元素属于哪个集合就会非常快,只需要 O(1) 的复杂度。
*
* 操作 复杂度
* isConnected(p, q) O(1)
* unionElements(p, q) O(n)
*/

public class UnionFind implements UF {

private int[] id; // 这个并查集本质就是一个数组。

public UnionFind(int size) {
id = new int[size];

// 初始化时,并没有合并任何元素。
for (int i = 0; i < id.length; i++) {
id[i] = i;
}
}

/**
* 并查集中元素的个数。
*/
@Override
public int getSize() {
return id.length;
}

/**
* 查找元素 p 所对应的集合编号。
* 复杂度:O(1)
*/
private int find(int p) {
if (p < 0 || p >= id.length) {
throw new IllegalArgumentException("p is out of bound.");
}

return id[p];
}

/**
* 查找元素 p 和元素 q 是否属于同一个集合。
* 复杂度:O(1)
*/
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}

/**
* 合并元素 p 和元素 q 所属的集合。
* 复杂度:O(n)
*/
@Override
public void unionElements(int p, int q) {
int pID = find(p);
int qID = find(q);

if (pID == qID) {
return;
}

for (int i = 0; i < id.length; i++) {
if (id[i] == pID) {
id[i] = qID;
}
}
}
}

Quick Union

带路径压缩优化的 Quick Union 并查集实现。

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
/**
* Quick Union 是并查集的一种更高效的实现。
* 它将每一个元素都看作是一个节点。
*
* 0 1 2 3 4 5 6 7 8 9
* parent 1 1 1 8 3 0 5 1 8 8
*
* 上面的内容可以用下面两个树来表示,分别表示两个集合;下面两棵树都是由孩子指向父亲的,根节点指向自己。
*
* 1 8
* / | \ | \
* 0 2 7 3 9
* | |
* 5 4
* |
* 6
*
* 操作 复杂度
* isConnected(p, q) O(h)
* unionElements(p, q) O(h)
*
* 如果加上路径压缩优化后的并查集,上述复杂度可以表示为 O(log*n),注意 * 可以称为「星」,它不是乘号,这是一个比 O(logn) 更快的更接近的 O(1) 级别的复杂度。
*/

public class UnionFind implements UF {

// parent[i] 表示第 i 个元素指向的父节点
private int[] parent;
// rank[i] 表示以 i 为根的集合所表示的层数
private int[] rank;

public UnionFind(int size) {
parent = new int[size];
rank = new int[size];

for (int i = 0; i < size; i++) {
parent[i] = i;
rank[i] = 1;
}
}

@Override
public int getSize() {
return parent.length;
}

// 复杂度为 O(h),其中 h 为树的高度。
private int find(int p) {
if (p < 0 || p >= parent.length) {
throw new IllegalArgumentException("p is out of bound.");
}

while (p != parent[p]) {
parent[p] = parent[parent[p]]; // 路径压缩
p = parent[p];
}
return p;
}

// 复杂度为 O(h),其中 h 为树的高度。
@Override
public boolean isConnected(int p, int q) {
return find(p) == find(q);
}

// 复杂度为 O(h),其中 h 为树的高度。
@Override
public void unionElements(int p, int q) {

int pRoot = find(p);
int qRoot = find(q);

if (pRoot == qRoot) {
return;
}

if (rank[pRoot] < rank[qRoot]) {
parent[pRoot] = qRoot;
} else if (rank[pRoot] > rank[qRoot]) {
parent[qRoot] = pRoot;
} else {
parent[qRoot] = pRoot;
rank[pRoot] += 1;
}
}
}

介绍

Trie 也叫字典树、前缀树。Trie 只处理字符串。

当我们通过搜索的方式查找字符串时,假设有 n 个条目,当使用线性结构时,查找复杂度为 O(n);当使用树结构时,查找复杂度为 O(logn),这个复杂度虽然已经很好了,但是它依然和数据量呈正相关,当数据量非常大时,查找速度依然会变慢。但是当使用 Trie 完成时,它可以做到查找速度和数据量无关,只和要查找的字符串长度有关。

Trie 是一棵多叉树,比如要存储的字符串只包含 26 个小写字母,那么每个节点就最多包含 26 个叉。

1
2
3
4
5
6
7
     root
/ / \
a(*) c h
| | |
t(*) a i(*)
/ \
r(*) t(*)

上面这个 Trie 一共存了 a、at、car、cat、hi 这几个单词,因为有些单词是其他单词的一部分,所以需要一个额外的布尔 isWord 来标记这个节点是否是一个单词。可以看到,查询的时候,不管这棵树中存多少个单词,查询的深度都是单词的长度,这就是 Trie 的优势。同时,Trie 查询是否有单词以某个字符串为前缀会特别方便,因此 Trie 也叫前缀树。

Trie 的查询操作和其中包含多少个字符串是无关的,只和要查询的字符串长度有关。因此,如果有非常多的字符串,我们使用 Trie 进行查询效率是非常高的。

这里实现的 Trie 不具有删除操作,应用中也是可以添加删除操作的。

Trie 的最大局限性就是空间问题,相应的会有压缩字典树,可以把一个没有分叉的单链存储成一个节点,而不是每个字符占用一个节点。

实现

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
public class Trie {

private class Node {

public boolean isWord;
public TreeMap<Character, Node> next;

public Node(boolean isWord) {
this.isWord = isWord;
next = new TreeMap<>();
}

public Node() {
this(false);
}
}

private Node root;
private int size;

public Trie() {
root = new Node();
size = 0;
}

// 获得 Trie 中存储的单词数量
public int getSize() {
return size;
}

// 向 Trie 中添加一个新的单词 word
public void add(String word) {

Node cur = root;
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
if (cur.next.get(c) == null)
cur.next.put(c, new Node());
cur = cur.next.get(c);
}

if (!cur.isWord) {
cur.isWord = true;
size++;
}
}

// 查询单词 word 是否在 Trie 中
public boolean contains(String word) {

Node cur = root;
for (int i = 0; i < word.length(); i++) {
char c = word.charAt(i);
if (cur.next.get(c) == null)
return false;
cur = cur.next.get(c);
}
return cur.isWord;
}

// 查询是否在Trie中有单词以 prefix 为前缀
public boolean isPrefix(String prefix) {

Node cur = root;
for (int i = 0; i < prefix.length(); i++) {
char c = prefix.charAt(i);
if (cur.next.get(c) == null)
return false;
cur = cur.next.get(c);
}

return true;
}
}

介绍

经典面试问题,一面墙,每次可以在其中一段区间进行染色,染色可以覆盖之前的染色,若干次染色后,问某个区间内有几种颜色?

这里涉及了两个操作,一个是更新,一个是查询。可以使用数组解决这个问题,染色就修改数组的指定区间,查询就是遍历整个数组,两种操作的复杂度都为 O(n)。

而如果使用线段树解决,复杂度为 O(logn)。

操作 使用数组复杂度 使用线段树复杂度
区间更新 O(n) O(logn)
区间查询 O(n) O(logn)

对于线段树,不考虑添加和删除操作,线段树解决的问题,区间本身是固定的,因此存储线段树使用静态数组即可。假定研究的问题是数组区间求和,数组共 8 个元素,则构造的线段树如下:

1
2
3
4
5
6
7
8
9
                   A[0...7]
/ \
A[0...3] A[4...7]
/ \ / \
A[0...1] A[2...3] A[4...5] A[6...7]
/ \ / \ / \ / \
A[0] A[1] A[2] A[3] A[4] A[5] A[6] A[7]

// 根节点存储全部区间的和,A[0...3]存储前半段的和,以此类推。

如果数组是 10 个元素,线段树则表示为:

1
2
3
4
5
6
7
8
9
                   A[0...9]
/ \
A[0...4] A[5...9]
/ \ / \
A[0...1] A[2...4] A[5...6] A[7...9]
/ \ / \ / \ / \
A[0] A[1] A[2] A[3,4] A[5] A[6] A[7] A[8,9]
/ \ / \
A[3] A[4] A[8] A[9]
  • 线段树不是满二叉树,更不是完全二叉树。
  • 平衡二叉树,指二叉树的每个节点的左右子树的高度差的绝对值不超过 1。平衡二叉树一定不会退化成链表。
  • 堆就是平衡二叉树,完全二叉树就一定是平衡二叉树。但是二分搜索树就不一定是平衡二叉树。
  • 线段树是一棵平衡二叉树。
  • 线段树虽然不是完全二叉树,但类似也是只有最下层是不「满」的,我们可以近似将其看作一个满二叉树,因此也可以使用数组表示。
  • 对于线段树来说,如果使用数组来表示,如果区间有 n 个元素,那么数组需要 4n 的空间来存储。对于线段树我们不考虑添加元素,即区间固定。所以创建线段树复杂度为 O(n),准确的讲是 O(4n)。当然,如果使用链式的方式存储线段树,则不需要 4n 的空间也可以。

应用

线段树常用于基于区间进行的统计查询。如果我们经常需要查询数组中某区间的最大值,最小值或者这个区间的数字和等,就可以使用线段树使操作复杂度由 O(n) 下降为 O(logn)。

具体的说,比如一个网站需要统计其2017年注册用户中至今为止消费最高的用户?消费最少的用户?学习时长最长的用户?

实现

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
public interface Merger<E> {

E merge(E a, E b);
}

public class SegmentTree<E> {

private E[] tree;
private E[] data;
// 融合器,区间中的元素进行何种融合存到线段树中。
private Merger<E> merger;

/**
* 构造函数。
*
* @param arr 传入的数组。
* @param merger 融合器。
*/
@SuppressWarnings("unchecked")
public SegmentTree(E[] arr, Merger<E> merger) {

this.merger = merger;

data = (E[]) new Object[arr.length];
for (int i = 0; i < arr.length; i++) {
data[i] = arr[i];
}

// 线段树所使用数组空间长度应该是传入数组长度的 4 倍
tree = (E[]) new Object[4 * arr.length];
buildSegmentTree(0, 0, data.length - 1);
}

/**
* 在 treeIndex 的位置创建表示区间[l...r]的线段树
*
* @param treeIndex 创建的线段树根节点所在的索引
* @param l 区间的左边
* @param r 区间的右边
*/
private void buildSegmentTree(int treeIndex, int l, int r) {
if (l == r) {
tree[treeIndex] = data[l];
return;
}

int leftTreeIndex = leftChild(treeIndex);
int rightTreeIndex = rightChild(treeIndex);

int mid = l + (r - l) / 2;
buildSegmentTree(leftTreeIndex, l, mid);
buildSegmentTree(rightTreeIndex, mid + 1, r);

tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]);
}

// 区间长度。
public int getSize() {
return data.length;
}

// 获得指定索引的元素。
public E get(int index) {
if (index < 0 || index >= data.length) {
throw new IllegalArgumentException("Index is illegal.");
}
return data[index];
}

// 左孩子索引。
private int leftChild(int index) {
return 2 * index + 1;
}

// 右孩子索引。
private int rightChild(int index) {
return 2 * index + 2;
}

// 返回区间 [queryL, queryR]的值
public E query(int queryL, int queryR) {
if (queryL < 0 || queryL >= data.length || queryR < 0 || queryR >= data.length || queryL > queryR) {
throw new IllegalArgumentException("Index is illegal.");
}

return query(0, 0, data.length - 1, queryL, queryR);
}

// 在以treeIndex 为根的线段树中[l...r]的范围内,搜索区间 [queryL...queryR]的值
private E query(int treeIndex, int l, int r, int queryL, int queryR) {
if (l == queryL && r == queryR) {
return tree[treeIndex];
}

int mid = l + (r - l) / 2;
int leftTreeIndex = leftChild(treeIndex);
int rightTreeIndex = rightChild(treeIndex);

if (queryL >= mid + 1) {
return query(rightTreeIndex, mid + 1, r, queryL, queryR);
} else if (queryR <= mid) {
return query(leftTreeIndex, l, mid, queryL, queryR);
}

E leftResult = query(leftTreeIndex, l, mid, queryL, mid);
E rightResult = query(rightTreeIndex, mid + 1, r, mid + 1, queryR);
return merger.merge(leftResult, rightResult);
}

// 将index位置的值,更新为e
public void set(int index, E e) {
if (index < 0 || index >= data.length) {
throw new IllegalArgumentException("Index is illegal");
}

data[index] = e;
set(0, 0, data.length - 1, index, e);
}

// 在以treeIndex为根的线段树中更新index的值为e
private void set(int treeIndex, int l, int r, int index, E e) {

if (l == r) {
tree[treeIndex] = e;
return;
}

int mid = l + (r - l) / 2;
// treeIndex的节点分为[l...mid]和[mid+1...r]两部分

int leftTreeIndex = leftChild(treeIndex);
int rightTreeIndex = rightChild(treeIndex);
if (index >= mid + 1) {
set(rightTreeIndex, mid + 1, r, index, e);
} else { // index <= mid
set(leftTreeIndex, l, mid, index, e);
}

tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]);
}

@Override
public String toString() {
StringBuilder res = new StringBuilder();
res.append('[');
for (int i = 0; i < tree.length; i++) {
if (tree[i] != null) {
res.append(tree[i]);
} else {
res.append("null");
}

if (i != tree.length - 1) {
res.append(", ");
}
}
res.append(']');
return res.toString();
}
}

使用线段树,可以以 O(logn) 的复杂度方便的进行数组区间求和、求区间最大值等操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {

public static void main(String[] args) {

Integer[] nums = { -2, 0, 3, -5, 2, -1 };
SegmentTree<Integer> segTree = new SegmentTree<>(nums, new Merger<Integer>() {
@Override
// 用于求和的线段树。
public Integer merge(Integer a, Integer b) {
return a + b;
// return Math.max(a, b); // 用于求最大值的线段树。
}
});
// SegmentTree<Integer> segTree = new SegmentTree<>(nums, (a, b) -> a + b); // Lambda 表达式 的写法

System.out.println(segTree.query(0, 2)); // 1
System.out.println(segTree.query(2, 5)); // -1
System.out.println(segTree.query(0, 5)); // -3

System.out.println(segTree);
}
}

更多

  • 我们这里实现的线段树只能实现单个元素更新,没有实现区间更新。比如说,我们希望区间 [2,5]中所有元素 +3,在线段树中,需要将这个叶子节点以及他们的父节点都进行更新,复杂度会变为 O(n) 级别,比较慢。一个方式是进行懒惰更新,lazy 更新,我们只把线段树中具体到相应区间的节点进行更新,其下面的子节点先不进行更新,而使用一个 lazy 数组记录未更新的内容,之后如果进行查询时先查找 lazy 数组看是否有未更新的内容,如果有再更新一下。
  • 线段树不仅仅适用于一维,二维甚至三维的区间问题都可以使用线段树解决。
  • 关于区间操作,还有另外一个重要的数据结构:树状数组。

介绍

前端代码可以通过引入 ESLintTypeScript 来提升一定的代码质量,但是实际执行中是否能严格执行,并没有强制保障,仅仅引入它们并不能保证程序员就不会把错误的代码提交给 Git

husky 以及 lint-staged 的引入就是为了在 Git 提交环节去验证代码有没有严格遵守某些规范,强制所有要提交的代码必须满足要求。理想状态下,我们很难会再把一份处处报错的代码提交给 Git 了。

安装

这里我们使用 huskylint-staged 以及 TypeScript 的命令为项目在 Git 提交阶段添加 Hook,保证之后的每一次代码提交都必须满足项目设置的 ESLint 规则,必须通过 TypeScript 编译器检查。

安装 huskylint-staged

1
2
$ yarn add --dev husky
$ yarn add --dev lint-staged

之后使用

1
npx husky add .husky/pre-commit "yarn lint-staged && tsc --skipLibCheck --noEmit"

该命令会在 .husky 文件夹下创建 pre-commit 文件,并写入 yarn lint-staged && tsc --skipLibCheck --noEmit 命令,这是一个自定义的 Git 钩子,即在每次 Git 提交的时候都会会去执行设置的脚本,如果脚步没有执行通过,则提交会失败。

之后,在 package.json 中添加需要执行的这个脚本:

1
2
3
4
5
6
"lint-staged": {
"*.js|*.jsx|*.ts|*.vue|*.tsx": [
"eslint",
"eslint --fix"
]
}

理解

husky 的作用是可以便捷的为 Git 操作添加 HookGit 中的各个操作都是提供了钩子的,其文档中说明了这些 Hooks 可以放置的位置:

By default the hooks directory is $GIT_DIR/hooks, but that can be changed via the core.hooksPath configuration variable (see git-config[1]).

对于一个 Git 项目,其 .git/hooks/ 路径中可以看到一些 Hooks 的样本,默认是可以把一个自定义的 Hook 放置在此。

husky 安装后先去修改了 .git/config 文件,增加了:

1
2
[core]
hooksPath = .husky

这将 Hooks 的路径定向了项目目录下的 .husky 文件夹。之后,husky 将会在这个文件夹中创建 Hook

前文中定义执行的脚本为 yarn lint-staged && tsc --skipLibCheck --noEmit

前者是通过 lint-staged 去执行 ESLint 相关,因为对于这方面的检查,没必要每次扫描全项目,只需要改哪里检查哪里即可,这就是 lint-staged 这个库的作用。

tsc --skipLibCheck --noEmit 是去检查整个项目是否存在 TypeScript 相关的编译错误。这个每次提交都会扫描整个项目,原因是 TypeScript 的错误不是仅限于当前修改的文件,可能修改当前文件,导致的是其它文件使用时报错。

介绍

React Native 全局状态(如登录后的用户信息)的管理方案通常可以是 ReduxMobx 或者原生的 Context

全局状态的管理要解决两个问题:一个是数据的存储,全局状态需要有一个可以存储的地方;另一个是全局的访问,需要方便地在任何位置获取和修改。

基于 React Native 自带的能力,本文使用 useReducer 处理数据的存储,使用 Context 使状态可以全局读取和修改。(本文的功能服务于函数组件,类组件使用时由于无法使用 Hook 就没那么方便了。)

实现

我们实现一个用户登录信息的管理工具,用于用户登录信息的读取和管理。其代码实现如下:

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
// AuthContex.tsx

import React, {
createContext,
ReactNode,
useReducer,
useEffect,
useContext,
useMemo,
} from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';

type UserInfo = {
username: string;
};

type AuthState = {
isLoading: boolean;
token?: string;
userInfo?: UserInfo;
};

type AuthAction = {
type: 'retrieveToken' | 'login' | 'logout';
token?: string;
userInfo?: UserInfo;
};

function reducer(state: AuthState, action: AuthAction) {
switch (action.type) {
case 'retrieveToken':
return {
...state,
isLoading: false,
token: action.token,
userInfo: action.userInfo,
};
case 'login':
return {
...state,
isLoading: false,
token: action.token,
userInfo: action.userInfo,
};
case 'logout':
return {
...state,
isLoading: false,
token: undefined,
userInfo: undefined,
};
}
}

const initialState = {
isLoading: true,
token: undefined,
userInfo: undefined,
};

const AuthContext = createContext<
| {
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
}
| undefined
>(undefined);

const AuthProvider = ({children}: {children: ReactNode}): JSX.Element => {
const [state, dispatch] = useReducer(reducer, initialState);

useEffect(() => {
async function retrieveToken() {
try {
const values = await AsyncStorage.multiGet(['token', 'userInfo']);
if (values[0][1] && values[1][1]) {
const tokenValue = values[0][1];
const userInfoValue = JSON.parse(values[1][1]);
dispatch({
type: 'retrieveToken',
token: tokenValue,
userInfo: userInfoValue,
});
} else {
dispatch({
type: 'retrieveToken',
token: undefined,
userInfo: undefined,
});
}
} catch {
dispatch({
type: 'retrieveToken',
token: undefined,
userInfo: undefined,
});
}
}
retrieveToken();
}, []);

return (
<AuthContext.Provider value={{state, dispatch}}>
{children}
</AuthContext.Provider>
);
};

const useAuth = (): {
state: AuthState;
actions: {
login: (token: string, userInfo: UserInfo) => Promise<void>;
logout: () => Promise<void>;
};
} => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used in AuthProvider!');
}

const authActions = useMemo(
() => ({
login: async (token: string, userInfo: UserInfo) => {
await AsyncStorage.multiSet([
['token', token],
['userInfo', JSON.stringify(userInfo)],
]);
context.dispatch({type: 'login', token: token, userInfo: userInfo});
},
logout: async () => {
await AsyncStorage.multiRemove(['token', 'userInfo']);
context.dispatch({
type: 'logout',
token: undefined,
userInfo: undefined,
});
},
}),
[context],
);

return {state: context.state, actions: authActions};
};

export {AuthProvider, useAuth};

使用

我们最终暴露了 AuthProvider 这样一个组件和 useAuth 这样一个 Hook

AuthProvider

AuthProvider 用于提供一个在其内部可以任意访问和修改全局状态的 Context,它的唯一使用是将该组件包裹在业务组件树的最外层,如可以在 App.js 中作为组件的最外层。

1
2
3
4
5
6
7
function App(): JSX.Element {
return (
<AuthProvider>
<AppContent /> // 这里是具体的业务组件
</AuthProvider>
);
}

这样,我们在内部组件中,就可以在任何位置访问到该 Context

useAuth

真正业务中,我们需要读取用户 token、用户信息,同时还需要有用户登录和退出登录等操作,而这些只需要 useAuth 这一个 Hook 即可。

这个 Hook 返回的对象中包含了一个 state 对象和一个 actions 对象。前者规定了用户信息的存储内容,其中 UserInfo 需要根据项目中需要存储的字段自主配置。使用时,我们直接读取即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1、根据 token 是否存在判断当前界面是主页面还是登录页面
const {state} = useAuth();
return (
<NavigationContainer>
{state.token ? <MainStack /> : <LoginStack />}
</NavigationContainer>
);

// 2、读取用户的 username 并展示
const {state} = useAuth();
return (
<Text style={styles.username}>
{state.userInfo?.username ?? '未设置用户名'}
</Text>
);

actions 对象中,预置了 loginlogout 两个操作(需要实现注册操作只需以类似方式添加即可),使用时只需要在合理的位置调用,即可实现登录信息的设置和全局状态的更改。

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
// 3、登录接口调用成功后执行登录逻辑(本地数据存储,全局状态设置)
const {actions: {login}} = useAuth();
// ...
<TouchableOpacity
style={styles.login}
onPress={() => {
// 先调用登录接口,成功后回调
login('token123', {username: 'qiweipeng'})
.then(() => {
console.log('登录成功!');
})
.catch(() => {
console.log('因为本地没有存储成功导致登录失败!');
});
}}>
<Text style={styles.loginText}>Sign In</Text>
</TouchableOpacity>
// ...

// 4、执行退出登录逻辑(清除本地数据,全局状态更改)
const {actions: {logout}} = useAuth();
// ...
<TouchableOpacity
style={styles.logout}
onPress={() => {
logout()
.then(() => {
console.log('退出登录成功!');
})
.catch(() => {
console.log('因为本地数据清除失败导致退出登录失败!');
});
}}>
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
// ...

思考

1、本文使用 useReducer 进行数据的存储,实际上使用 useState 也不是不行,如果数据比较简单,也是可以使用 useState 的,需要寄托在 Context 中都是可以实现数据的全局读写的。
2、useReducer 使用了类似 Redux 的思想,通过 dispatch 函数操作 action 继而影响 state。如果没有封装直接使用的话,我们可能会考虑在需要更改状态的位置直接调用 dispatch 函数。但是本文为了较好的封装,同时也不希望 reducer 这个函数直接对外暴露,因此在设计 useAuth 的时候,定义了 loginlogout 这样的函数,从而将reducer 的细节封装在了内部。比如 retrieveToken(应用启动后从本地读取数据到内存的步骤)这样一个 action 就是没必要暴露出去的,我们在内部就完成了其全部逻辑。

优先队列介绍

优先队列也是一个队列,因此也适用于之前实现的队列接口。不过优先队列出队永远需要优先级对高的。

优先队列针对的是动态的情况,如果是静态的,那么只需要排序一次即可。

依然可以使用之前实现普通队列的方式实现优先队列,即使用数组或链表这样的线性结构实现优先队列。只不过无法再像实现普通队列那样,使入队和出队的复杂度都为 O(1)。

使用堆来实现优先队列,可以使得入队和出队操作都为 O(logn) 级别。

入队 出队
专门维护一个顺序的线性结构,即入队时进行排序 O(n) O(1)
普通队列出队操作时遍历找到优先级最高的元素 O(1) O(n)
使用堆实现 O(logn) O(logn)

堆的介绍

  • 满二叉树,除最后一层无任何叶子节点外,其所有非叶子节点均既有左孩子又有右孩子。
  • 完全二叉树,其不一定是一颗满二叉树,但是其不满的部分一定是在树的右下侧。也就是除了最底层之外,上面的是一个满二叉树,最底层从左开始放置元素。
  • 二叉堆就是一个完全二叉树。
  • 最大堆:堆中某个节点的值总是不大于其父节点的值(相应也有最小堆)。需要注意的是,这个定义没有要求较下层的某个节点一定不会大于较上层的所有节点。

对于完全二叉树来说,通过层序遍历,可以将节点放入一个数组中。实际上堆的存储结构也是使用一个数组最为合适。

当使用一个数组表示一个堆的时候,根节点放在第一个位置,索引为 0。假如某节点索引为 i,其左孩子节点索引就是 2i + 1,右孩子节点索引就是 2i + 2,其父节点索引就是 (i - 1) / 2。如果为了表示方便,也可以空一个位置,将根节点放在索引为 1 的位置,那么假如某节点索引为 i,其左孩子节点索引就是 2i,右孩子节点索引就是 2i + 1,其父节点索引就是 i / 2。

根节点索引 当前节点索引 左孩子索引 右孩子索引 父节点索引
0 i 2i + 1 2i + 2 (i - 1) / 2
1 i 2i 2i + 1 i / 2

对于完全二叉树,寻找最后一个非叶子节点的索引就是找最后一个节点的父亲节点。

复杂度

操作 复杂度
add O(logn)
extractMax O(logn)
findMax O(1)
replace O(logn)
heapify O(n)

其中,add 操作的步骤是先将元素添加到队尾,之后使用 sift up 操作,即不断和其父节点做比较,如果其大于父节点元素就两者互换,这个操作的复杂度是 O(logn)extractMax 操作的步骤是先获取队首最大元素,之后将队尾元素放到队首,移除队尾多余的空位后,将换到队首的那个元素使用 sift down 操作,这个复杂度也是 O(logn)

replace 操作是使用一个新元素替换掉队首的最大元素,可以使用 add 操作之后接一个 extractMax 操作来实现,但是这样的话复杂度就是两个 O(logn) 操作。所以可以专门给它一个优化实现,就是使用新元素直接替换队首元素位置,之后将新元素做 sift down 操作,这样复杂度就是一个 O(logn)

heapify 操作是将一个任意数组转为一个堆的过程。具体实现是先将这个数组看作一个完全二叉树,之后从最后一个非叶子节点开始逐渐向前进行 sift down 操作。如果将 n 个元素逐个插入到一个空堆中,复杂度是 O(nlogn) 级别的。但是 heapify 的过程,算法复杂度其实是 O(n) 级别的(这个复杂度先记住就好)。

堆的实现

使用动态数组实现一个最大堆:

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
public class MaxHeap<E extends Comparable<E>> {
private Array<E> data;

public MaxHeap(int capacity) {
data = new Array<>(capacity);
}

public MaxHeap() {
data = new Array<>();
}

/**
* 将任意一个数组生成为一个最大堆。
* 如果将 n 个元素逐个插入到一个空堆中,复杂度是 O(nlogn) 级别的。
* heapify 的过程,算法复杂度是 O(n) 级别的
*/
public MaxHeap(E[] arr) {
data = new Array<>(arr);
for (int i = parent(arr.length - 1); i >= 0; i--) {
siftDown(i);
}
}

/**
* 返回堆中元素个数。
*/
public int size() {
return data.getSize();
}

/**
* 返回一个布尔值,表示堆是否为空。
*/
public boolean isEmpty() {
return data.isEmpty();
}

/**
* 返回完全二叉树的数组表示中,一个索引所表示的元素的父亲节点的索引。
*/
private int parent(int index) {
if (index == 0) {
throw new IllegalArgumentException("index-0 doesn't have parent.");
}
return (index - 1) / 2;
}

/**
* 返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引。
*/
private int leftChild(int index) {
return index * 2 + 1;
}

/**
* 返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引。
*/
private int rightChild(int index) {
return index * 2 + 2;
}

/**
* 向堆中添加元素
*/
public void add(E e) {
data.addLast(e);
siftUp(data.getSize() - 1);
}

/**
* 元素堆上浮,新添加一个元素后,需要不断和它堆父节点做比较,如果大了就上浮
*
* @param k 需要上浮元素的索引
*/
private void siftUp(int k) {

while (k > 0 && data.get(parent(k)).compareTo(data.get(k)) < 0) {
data.swap(k, parent(k));
k = parent(k);
}
}

/**
* 看堆中的最大元素,不取出。
*/
public E findMax() {
if (data.getSize() == 0) {
throw new IllegalArgumentException("Can not findMax when heap is empty.");
}

return data.get(0);
}

/**
* 取出堆中最大元素。复杂度 O(logn)
* 对于堆来讲,我们只能拿最大堆也就是根节点,或者说数组索引为 0 的元素。
*/
public E extractMax() {

E ret = findMax();

// 先把最大元素和最后一个元素交换。
data.swap(0, data.getSize() - 1);
data.removeLast();

// 之后将根节点元素做下沉操作,也就是把它交换到合适的位置。
siftDown(0);

return ret;
}

private void siftDown(int k) {

while (leftChild(k) < data.getSize()) {
int j = leftChild(k);
if (j + 1 < data.getSize() && data.get(j + 1).compareTo(data.get(j)) > 0) {
j = rightChild(k);
}

if (data.get(k).compareTo(data.get(j)) >= 0) {
break;
}

data.swap(k, j);
k = j;
}
}

/**
* 取出堆中最大的元素,并且替换成元素 e
* 正常情况下,这是两个操作,即先 extractMax,再 add,复杂度就是连续两个 O(logn)
* 但是组合起来就可以直接用新元素替换旧元素,然后再 siftDown,复杂度就仅仅是 O(logn)
*/
public E replace(E e) {
E ret = findMax();
data.set(0, e);
siftDown(0);

return ret;
}
}

优先队列的实现

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
public class PriorityQueue<E extends Comparable<E>> implements Queue<E> {

private MaxHeap<E> maxHeap;

public PriorityQueue() {
maxHeap = new MaxHeap<>();
}

@Override
public int getSize() {
return maxHeap.size();
}

@Override
public boolean isEmpty() {
return maxHeap.isEmpty();
}

@Override
public E getFront() {
return maxHeap.findMax();
}

@Override
public void enqueue(E e) {
maxHeap.add(e);
}

@Override
public E dequeue() {
return maxHeap.extractMax();
}
}

更多

关于堆:

我们实现的是二叉堆,相应的也就有三叉堆、四叉堆等等。对于 d叉堆来说,层数有可能更少,这在有些操作上会让速度更快。但是比如下沉操作,就要考虑 d 个子节点而不是 2 个。

另外,更高级的堆还有如索引堆(它可以操作堆中的某个元素)、二项堆、斐波那契堆等。

关于队列:

如果从广义上讲,能满足的队列接口的数据结构就可以成为队列。那么除了普通的队列,优先队列之外,其实栈也可以被理解是一个队列。当我们这么理解的时候,就会发现,之前使用栈辅助实现二分搜索树的前序遍历,使用队列辅助实现二分搜索树的层序遍历,他们在逻辑上是一致的。

这里记录一下学习 React Native 中需要用的知识和教程的网站,用作需要时查阅。

教程

JavaScript教程 - 廖雪峰的官方网站

JavaScript 教程 - 网道

ES6 入门教程

TypeScript 入门教程

JavaScript Express

React Express

React Native Express

React Native+Redux打造高质量上线App-慕课网实战

官网

TypeScript: Typed JavaScript at Any Scale.

React – A JavaScript library for building user interfaces

React Native · A framework for building native apps using React

React Navigation | React Navigation

ESLint - Pluggable JavaScript linter

Redux - A predictable state container for JavaScript apps. | Redux

README · MobX

npm | build amazing things

Babel · The compiler for next generation JavaScript

Jest · 🃏 Delightful JavaScript Testing

Husky - Git hooks

相关中文站点

TypeScript中文网 · TypeScript——JavaScript的超集

React 官方中文文档 – 用于构建用户界面的 JavaScript 库

React Native 中文网 · 使用React来编写原生应用的框架

MobX 介绍 · MobX 中文文档

介绍

映射在有的语言也叫 字典,代表一种一一对应的关系。

主要的应用如:

  • 字典:单词–>释意

  • 名册:身份证号–>人

  • 车辆管理:车牌号–>车

  • 数据库:id–>信息

  • 单词统计:单词–>频率

    映射也分有序映射和无序映射。有序映射基于搜索树实现,无序映射通常会使用哈希表实现。

    集合和映射是很相似的,使用映射完全可以包装出一个集合的结构,只需不管 value 即可。

实现

定义映射的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface Map<K, V> {

void add(K key, V value);

V remove(K key);

boolean contains(K key);

V get(K key);

void set(K key, V newValue);

int getSize();

boolean isEmpty();
}

使用二分搜索树实现的映射:

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
public class BSTMap<K extends Comparable<K>, V> implements Map<K, V> {

// 在类的内部定义一个二分搜索树的节点类
private class Node {
public K key;
public V value;
public Node left, right;

public Node(K key, V value) {
this.key = key;
this.value = value;
left = null;
right = null;
}
}

private Node root;
private int size;

public BSTMap() {
root = null;
size = 0;
}

@Override
public int getSize() {
return size;
}

@Override
public boolean isEmpty() {
return size == 0;
}

@Override
public void add(K key, V value) {
root = add(root, key, value);
}

private Node add(Node node, K key, V value) {
if (node == null) {
size++;
return new Node(key, value);
}

if (key.compareTo(node.key) < 0) {
node.left = add(node, key, value);
} else if (key.compareTo(node.key) > 0) {
node.right = add(node, key, value);
} else {
node.value = value;
}

return node;
}

private Node getNode(Node node, K key) {
if (node == null) {
return null;
}

if (key.compareTo(node.key) == 0) {
return node;
} else if (key.compareTo(node.key) < 0) {
return getNode(node.left, key);
} else {
return getNode(node.right, key);
}
}

@Override
public boolean contains(K key) {
return getNode(root, key) != null;
}

@Override
public V get(K key) {
Node node = getNode(root, key);
return node == null ? null : node.value;
}

@Override
public void set(K key, V newValue) {
Node node = getNode(root, key);

if (node == null) {
throw new IllegalArgumentException(key + "dosen't exist!");
}

node.value = newValue;
}

private Node minimum(Node node) {
if (node.left == null) {
return node;
}

return minimum(node.left);
}

private Node removeMin(Node node) {
if (node.left == null) {
Node rightNode = node.right;
node.right = null;
size--;
return rightNode;
}

node.left = removeMin(node.left);
return node;
}

@Override
public V remove(K key) {
Node node = getNode(root, key);
if (node != null) {
root = remove(root, key);
return node.value;
}

return null;
}

private Node remove(Node node, K key) {
if (node == null) {
return null;
}

if (key.compareTo(node.key) < 0) {
node.left = remove(node.left, key);
return node;
} else if (key.compareTo(node.key) > 0) {
node.left = remove(node.right, key);
return node;
} else {
// 待删除节点右子树为空
if (node.left == null) {
Node rightNode = node.right;
node.right = null;
size--;
return rightNode;
}

// 待删除节点右子树为空
if (node.right == null) {
Node leftNode = node.left;
node.left = null;
size--;
return leftNode;
}

// 待删除节点左右子树均不为空
// 找到比待删除节点大的最小节点,即待删除节点右子树的最小节点
// 用这个节点顶替待删除节点的位置。
Node successor = minimum(node.right);
successor.right = removeMin(node.right);
successor.left = node.left;

node.left = node.right = null;

return successor;
}
}

}

使用链表实现的映射:

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
public class LinkedListMap<K, V> implements Map<K, V> {

private class Node {
public K key;
public V value;
public Node next;

public Node(K key, V value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}

public Node(K key) {
this(key, null, null);
}

public Node() {
this(null);
}

@Override
public String toString() {
return key.toString() + ": " + value.toString();
}
}

private Node dummyHead;
private int size;

public LinkedListMap() {
dummyHead = new Node();
size = 0;
}

@Override
public int getSize() {
return size;
}

@Override
public boolean isEmpty() {
return size == 0;
}

/**
* 私有的辅助函数,传入一个 key,返回对应的节点的引用。
*/
private Node getNode(K key) {
Node cur = dummyHead.next;

while (cur != null) {
if (cur.key.equals(key)) {
return cur;
}
cur = cur.next;
}

return null;
}

@Override
public boolean contains(K key) {
return getNode(key) != null;
}

@Override
public V get(K key) {
Node node = getNode(key);
return node == null ? null : node.value;
}

@Override
public void add(K key, V value) {
Node node = getNode(key);

if (node == null) {
dummyHead.next = new Node(key, value, dummyHead.next);
size++;
} else {
node.value = value;
}
}

@Override
public void set(K key, V newValue) {
Node node = getNode(key);

if (node == null) {
throw new IllegalArgumentException(key + "dosen't exist!");
}

node.value = newValue;
}

@Override
public V remove(K key) {
Node prev = dummyHead;

while (prev.next != null) {
if (prev.next.key.equals(key)) {
break;
}
prev = prev.next;
}

if (prev.next != null) {
Node delNode = prev.next;
prev.next = delNode.next;
delNode.next = null;
size--;
return delNode.value;
}

return null;
}
}

分析

链表映射 二分搜索树映射(平均) 二分搜索树映射(最差)
增 add O(n) O(logn) O(n)
删 remove O(n) O(logn) O(n)
改 set O(n) O(logn) O(n)
查 get O(n) O(logn) O(n)
查 contains O(n) O(logn) O(n)

对映射的分析基本和对集合的分析是一致的。