勇者抽刃向强者的博客

梦是现实的延续,现实是梦的终结

翻转二叉树

1.题目内容

给你一棵二叉树的根节点 root ,翻转这棵二叉树,并返回其根节点。

示例 1:

img

1
2
输入:root = [4,2,7,1,3,6,9]
输出:[4,7,2,9,6,3,1]

示例 2:

img

1
2
输入:root = [2,1,3]
输出:[2,3,1]

示例 3:

1
2
输入:root = []
输出:[]

提示:

  • 树中节点数目范围在 [0, 100]
  • -100 <= Node.val <= 100

2.解法

递归

思路及算法

这是一道很经典的二叉树问题。显然,我们从根节点开始,递归地对树进行遍历,并从叶子节点先开始翻转。如果当前遍历到的节点 root 的左右两棵子树都已经翻转,那么我们只需要交换两棵子树的位置,即可完成以 root 为根节点的整棵子树的翻转。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//C++
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if (root == nullptr) {
return nullptr;
}
TreeNode* left = invertTree(root->left);
TreeNode* right = invertTree(root->right);
root->left = right;
root->right = left;
return root;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
//Java
class Solution {
public TreeNode invertTree(TreeNode root) {
if (root == null) {
return null;
}
TreeNode left = invertTree(root.left);
TreeNode right = invertTree(root.right);
root.left = right;
root.right = left;
return root;
}
}

复杂度分析

  • 时间复杂度:O(N),其中 N 为二叉树节点的数目。我们会遍历二叉树中的每一个节点,对每个节点而言,我们在常数时间内交换其两棵子树。
  • 空间复杂度:O(N)。使用的空间由递归栈的深度决定,它等于当前节点在二叉树中的高度。在平均情况下,二叉树的高度与节点个数为对数关系,即 O(log⁡N)。而在最坏情况下,树形成链状,空间复杂度为 O(N)。

对称二叉树

1.题目内容

给你一个二叉树的根节点 root , 检查它是否轴对称。

示例 1:

img

1
2
输入:root = [1,2,2,3,4,4,3]
输出:true

示例 2:

img

1
2
输入:root = [1,2,2,null,3,null,3]
输出:false

提示:

  • 树中节点数目在范围 [1, 1000]
  • -100 <= Node.val <= 100

2.解法

(1)递归

思路及算法

如果一个树的左子树与右子树镜像对称,那么这个树是对称的。

fig1

因此,该问题可以转化为:两个树在什么情况下互为镜像?

如果同时满足下面的条件,两个树互为镜像:

  • 它们的两个根结点具有相同的值
  • 每个树的右子树都与另一个树的左子树镜像对称

fig2

我们可以实现这样一个递归函数,通过「同步移动」两个指针的方法来遍历这棵树,p 指针和 q 指针一开始都指向这棵树的根,随后 p 右移时,q 左移,p 左移时,q 右移。每次检查当前 p 和 q 节点的值是否相等,如果相等再判断左右子树是否对称。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Java
class Solution {
public boolean isSymmetric(TreeNode root) {
return check(root, root);
}

public boolean check(TreeNode p, TreeNode q) {
if (p == null && q == null) {
return true;
}
if (p == null || q == null) {
return false;
}
return p.val == q.val && check(p.left, q.right) && check(p.right, q.left);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
//C++
class Solution {
public:
bool check(TreeNode *p, TreeNode *q) {
if (!p && !q) return true;
if (!p || !q) return false;
return p->val == q->val && check(p->left, q->right) && check(p->right, q->left);
}

bool isSymmetric(TreeNode* root) {
return check(root, root);
}
};

复杂度分析

假设树上一共 n 个节点。

  • 时间复杂度:这里遍历了这棵树,渐进时间复杂度为 O(n)。
  • 空间复杂度:这里的空间复杂度和递归使用的栈空间有关,这里递归层数不超过 n,故渐进空间复杂度为 O(n)。

(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
//Java
class Solution {
public boolean isSymmetric(TreeNode root) {
return check(root, root);
}

public boolean check(TreeNode u, TreeNode v) {
Queue<TreeNode> q = new LinkedList<TreeNode>();
q.offer(u);
q.offer(v);
while (!q.isEmpty()) {
u = q.poll();
v = q.poll();
if (u == null && v == null) {
continue;
}
if ((u == null || v == null) || (u.val != v.val)) {
return false;
}

q.offer(u.left);
q.offer(v.right);

q.offer(u.right);
q.offer(v.left);
}
return true;
}
}
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
//C++
class Solution {
public:
bool check(TreeNode *u, TreeNode *v) {
queue <TreeNode*> q;
q.push(u); q.push(v);
while (!q.empty()) {
u = q.front(); q.pop();
v = q.front(); q.pop();
if (!u && !v) continue;
if ((!u || !v) || (u->val != v->val)) return false;

q.push(u->left);
q.push(v->right);

q.push(u->right);
q.push(v->left);
}
return true;
}

bool isSymmetric(TreeNode* root) {
return check(root, root);
}
};

复杂度分析

  • 时间复杂度:O(n)。
  • 空间复杂度:这里需要用一个队列来维护节点,每个节点最多进队一次,出队一次,队列中最多不会超过 n 个点,故渐进空间复杂度为 O(n)。

相同的树

1.题目内容

给你两棵二叉树的根节点 pq ,编写一个函数来检验这两棵树是否相同。

如果两个树在结构上相同,并且节点具有相同的值,则认为它们是相同的。

示例 1:

img

1
2
输入:p = [1,2,3], q = [1,2,3]
输出:true

示例 2:

img

1
2
输入:p = [1,2], q = [1,null,2]
输出:false

示例 3:

img

1
2
输入:p = [1,2,1], q = [1,1,2]
输出:false

提示:

  • 两棵树上的节点数目都在范围 [0, 100]
  • -104 <= Node.val <= 104

2.解法

(1)深度优先搜索

思路及算法

如果两个二叉树都为空,则两个二叉树相同。如果两个二叉树中有且只有一个为空,则两个二叉树一定不相同。

如果两个二叉树都不为空,那么首先判断它们的根节点的值是否相同,若不相同则两个二叉树一定不同,若相同,再分别判断两个二叉树的左子树是否相同以及右子树是否相同。这是一个递归的过程,因此可以使用深度优先搜索,递归地判断两个二叉树是否相同。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Java
class Solution {
public boolean isSameTree(TreeNode p, TreeNode q) {
if (p == null && q == null) {
return true;
} else if (p == null || q == null) {
return false;
} else if (p.val != q.val) {
return false;
} else {
return isSameTree(p.left, q.left) && isSameTree(p.right, q.right);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//C++
class Solution {
public:
bool isSameTree(TreeNode* p, TreeNode* q) {
if (p == nullptr && q == nullptr) {
return true;
} else if (p == nullptr || q == nullptr) {
return false;
} else if (p->val != q->val) {
return false;
} else {
return isSameTree(p->left, q->left) && isSameTree(p->right, q->right);
}
}
};

复杂度分析

  • 时间复杂度:O(min⁡(m,n)),其中 m 和 n 分别是两个二叉树的节点数。对两个二叉树同时进行深度优先搜索,只有当两个二叉树中的对应节点都不为空时才会访问到该节点,因此被访问到的节点数不会超过较小的二叉树的节点数。

  • 空间复杂度:O(min⁡(m,n)),其中 m 和 n 分别是两个二叉树的节点数。空间复杂度取决于递归调用的层数,递归调用的层数不会超过较小的二叉树的最大高度,最坏情况下,二叉树的高度等于节点数。

(2)广度优先搜索

思路及算法

也可以通过广度优先搜索判断两个二叉树是否相同。同样首先判断两个二叉树是否为空,如果两个二叉树都不为空,则从两个二叉树的根节点开始广度优先搜索。

使用两个队列分别存储两个二叉树的节点。初始时将两个二叉树的根节点分别加入两个队列。每次从两个队列各取出一个节点,进行如下比较操作。

  1. 比较两个节点的值,如果两个节点的值不相同则两个二叉树一定不同;
  2. 如果两个节点的值相同,则判断两个节点的子节点是否为空,如果只有一个节点的左子节点为空,或者只有一个节点的右子节点为空,则两个二叉树的结构不同,因此两个二叉树一定不同;
  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
//Java
class Solution {
public boolean isSameTree(TreeNode p, TreeNode q) {
if (p == null && q == null) {
return true;
} else if (p == null || q == null) {
return false;
}
Queue<TreeNode> queue1 = new LinkedList<TreeNode>();
Queue<TreeNode> queue2 = new LinkedList<TreeNode>();
queue1.offer(p);
queue2.offer(q);
while (!queue1.isEmpty() && !queue2.isEmpty()) {
TreeNode node1 = queue1.poll();
TreeNode node2 = queue2.poll();
if (node1.val != node2.val) {
return false;
}
TreeNode left1 = node1.left, right1 = node1.right, left2 = node2.left, right2 = node2.right;
if (left1 == null ^ left2 == null) {
return false;
}
if (right1 == null ^ right2 == null) {
return false;
}
if (left1 != null) {
queue1.offer(left1);
}
if (right1 != null) {
queue1.offer(right1);
}
if (left2 != null) {
queue2.offer(left2);
}
if (right2 != null) {
queue2.offer(right2);
}
}
return queue1.isEmpty() && queue2.isEmpty();
}
}

复杂度分析

  • 时间复杂度:O(min⁡(m,n)),其中 m 和 n 分别是两个二叉树的节点数。对两个二叉树同时进行广度优先搜索,只有当两个二叉树中的对应节点都不为空时才会访问到该节点,因此被访问到的节点数不会超过较小的二叉树的节点数。

  • 空间复杂度:O(min⁡(m,n)),其中 m 和 n 分别是两个二叉树的节点数。空间复杂度取决于队列中的元素个数,队列中的元素个数不会超过较小的二叉树的节点数。

二叉树的最大深度

1.题目内容

给定一个二叉树 root ,返回其最大深度。

二叉树的 最大深度 是指从根节点到最远叶子节点的最长路径上的节点数。

示例 1:

img

1
2
输入:root = [3,9,20,null,null,15,7]
输出:3

示例 2:

1
2
输入:root = [1,null,2]
输出:2

提示:

  • 树中节点的数量在 [0, 104] 区间内。
  • -100 <= Node.val <= 100

2.解法

树的遍历方式总体分为两类:深度优先搜索(DFS)、广度优先搜索(BFS)。

  • 常见 DFS : 先序遍历、中序遍历、后序遍历。
  • 常见 BFS : 层序遍历(即按层遍历)。

求树的深度需要遍历树的所有节点,本文将介绍基于 后序遍历(DFS) 和 层序遍历(BFS) 的两种解法。

(1)后序遍历(DFS)

思路及算法

树的后序遍历 / 深度优先搜索往往利用 递归 实现,这里使用递归实现。

关键点: 此树的深度和其左(右)子树的深度之间的关系。显然,此树的深度等于左子树的深度与右子树的深度中的最大值 +1。

算法解析:

  1. 终止条件: 当 root 为空,说明已越过叶节点,因此返回 深度 0 。
  2. 递推工作: 本质上是对树做后序遍历。
    • 计算节点 root 的 左子树的深度 ,即调用 maxDepth(root.left)
    • 计算节点 root 的 右子树的深度 ,即调用 maxDepth(root.right)
  3. 返回值: 返回 此树的深度 ,即 max(maxDepth(root.left), maxDepth(root.right)) + 1

img

img

img

img

img

img

img

img

img

img

代码

1
2
3
4
5
6
7
//Java
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) return 0;
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;
}
}
1
2
3
4
5
6
7
8
//C++
class Solution {
public:
int maxDepth(TreeNode* root) {
if (root == nullptr) return 0;
return max(maxDepth(root->left), maxDepth(root->right)) + 1;
}
};

复杂度分析

  • 时间复杂度 O(N) : N 为树的节点数量,计算树的深度需要遍历所有节点。
  • 空间复杂度 O(N): 最差情况下(当树退化为链表时),递归深度可达到 N 。

(2)层序遍历(BFS)

思路及算法

树的层序遍历 / 广度优先搜索往往利用 队列 实现。

关键点: 每遍历一层,则计数器 +1 ,直到遍历完成,则可得到树的深度。

算法解析:

  1. 特例处理: 当 root 为空,直接返回 深度 0 。
  2. 初始化: 队列 queue (加入根节点 root ),计数器 res = 0
  3. 循环遍历: 当 queue 为空时跳出。
    • 初始化一个空列表 tmp ,用于临时存储下一层节点。
    • 遍历队列: 遍历 queue 中的各节点 node ,并将其左子节点和右子节点加入 tmp
    • 更新队列: 执行 queue = tmp ,将下一层节点赋值给 queue
    • 统计层数: 执行 res += 1 ,代表层数加 1。
  4. 返回值: 返回 res 即可。

img

img

img

img

img

img

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Java
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) return 0;
List<TreeNode> queue = new LinkedList<>() {{ add(root); }}, tmp;
int res = 0;
while (!queue.isEmpty()) {
tmp = new LinkedList<>();
for(TreeNode node : queue) {
if (node.left != null) tmp.add(node.left);
if (node.right != null) tmp.add(node.right);
}
queue = tmp;
res++;
}
return res;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//C++
class Solution {
public:
int maxDepth(TreeNode* root) {
if (root == nullptr) return 0;
vector<TreeNode*> que;
que.push_back(root);
int res = 0;
while (!que.empty()) {
vector<TreeNode*> tmp;
for(TreeNode* node : que) {
if (node->left != nullptr) tmp.push_back(node->left);
if (node->right != nullptr) tmp.push_back(node->right);
}
que = tmp;
res++;
}
return res;
}
};

复杂度分析

  • 时间复杂度 O(N): N 为树的节点数量,计算树的深度需要遍历所有节点。
  • 空间复杂度 O(N): 最差情况下(当树平衡时),队列 queue 同时存储 N/2 个节点。

二叉树的中序遍历

1.题目内容

给定一个二叉树的根节点 root ,返回 它的 中序 遍历

示例 1:

img

1
2
输入:root = [1,null,2,3]
输出:[1,3,2]

示例 2:

1
2
输入:root = []
输出:[]

示例 3:

1
2
输入:root = [1]
输出:[1]

提示:

  • 树中节点数目在范围 [0, 100]
  • -100 <= Node.val <= 100

2.解法

(1)递归

思路及算法

递归遍历过程如下:

  1. 前序遍历:打印 - 左 - 右
  2. 中序遍历:左 - 打印 - 右
  3. 后序遍历:左 - 右 - 打印

题目要求的是中序遍历,那就按照 左-打印-右这种顺序遍历树就可以了,递归函数实现

  • 终止条件:当前节点为空时
  • 函数内:递归的调用左节点,打印当前节点,再递归调用右节点

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Java
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<Integer>();
dfs(res,root);
return res;
}

void dfs(List<Integer> res, TreeNode root) {
if(root==null) {
return;
}
//按照 左-打印-右的方式遍历
dfs(res,root.left);
res.add(root.val);
dfs(res,root.right);
}
}

复杂度分析

  • 时间复杂度:O(n)。
  • 空间复杂度:O(h),h 是树的高度。

(2)迭代

思路及算法

递归实现时,是函数自己调用自己,一层层的嵌套下去,操作系统/虚拟机自动帮我们用 来保存了每个调用的函数,现在我们需要自己模拟这样的调用过程。

递归的调用过程是不断往左边走,当左边走不下去了,就打印节点,并转向右边,然后右边继续这个过程。
我们在迭代实现时,就可以用栈来模拟上面的调用过程。

1.gif

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//Java
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<Integer>();
Stack<TreeNode> stack = new Stack<TreeNode>();
while(stack.size()>0 || root!=null) {
//不断往左子树方向走,每走一次就将当前节点保存到栈中
//这是模拟递归的调用
if(root!=null) {
stack.add(root);
root = root.left;
//当前节点为空,说明左边走到头了,从栈中弹出节点并保存
//然后转向右边节点,继续上面整个过程
} else {
TreeNode tmp = stack.pop();
res.add(tmp.val);
root = tmp.right;
}
}
return res;
}
}

复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(h),h 是树的高度

基本计算器

1.题目内容

给你一个字符串表达式 s ,请你实现一个基本计算器来计算并返回它的值。

注意:不允许使用任何将字符串作为数学表达式计算的内置函数,比如 eval()

示例 1:

1
2
输入:s = "1 + 1"
输出:2

示例 2:

1
2
输入:s = " 2-1 + 2 "
输出:3

示例 3:

1
2
输入:s = "(1+(4+5+2)-3)+(6+8)"
输出:23

提示:

  • 1 <= s.length <= 3 * 105
  • s 由数字、'+''-''('')'、和 ' ' 组成
  • s 表示一个有效的表达式
  • ‘+’ 不能用作一元运算(例如, “+1” 和 "+(2 + 3)" 无效)
  • ‘-‘ 可以用作一元运算(即 “-1” 和 "-(2 + 3)" 是有效的)
  • 输入中不存在两个连续的操作符
  • 每个数字和运行的计算将适合于一个有符号的 32位 整数

2.解法

双栈

思路及算法

我们可以使用两个栈 numsops

  • nums : 存放所有的数字
  • ops :存放所有的数字以外的操作,+/- 也看做是一种操作

然后从前往后做,对遍历到的字符做分情况讨论:

  • 空格 : 跳过
  • ( : 直接加入 ops 中,等待与之匹配的 )
  • ) : 使用现有的 numsops 进行计算,直到遇到左边最近的一个左括号为止,计算结果放到 nums
  • 数字 : 从当前位置开始继续往后取,将整一个连续数字整体取出,加入 nums
  • +/- : 需要将操作放入 ops 中。在放入之前先把栈内可以算的都算掉,使用现有的 numsops 进行计算,直到没有操作或者遇到左括号,计算结果放到 nums

一些细节:

  • 由于第一个数可能是负数,为了减少边界判断。一个小技巧是先往 nums 添加一个 0
  • 为防止()内出现的首个字符为运算符,将所有的空格去掉,并将(-替换为 (0-(+ 替换为 (0+(当然也可以不进行这样的预处理,将这个处理逻辑放到循环里去做)

代码

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
//C++
class Solution {
public:
void replace(string& s){
int pos = s.find(" ");
while (pos != -1) {
s.replace(pos, 1, "");
pos = s.find(" ");
}
}
int calculate(string s) {
// 存放所有的数字
stack<int> nums;
// 为了防止第一个数为负数,先往 nums 加个 0
nums.push(0);
// 将所有的空格去掉
replace(s);
// 存放所有的操作,包括 +/-
stack<char> ops;
int n = s.size();
for(int i = 0; i < n; i++) {
char c = s[i];
if(c == '(')
ops.push(c);
else if(c == ')') {
// 计算到最近一个左括号为止
while(!ops.empty()) {
char op = ops.top();
if(op != '(')
calc(nums, ops);
else {
ops.pop();
break;
}
}
}
else {
if(isdigit(c)) {
int cur_num = 0;
int j = i;
// 将从 i 位置开始后面的连续数字整体取出,加入 nums
while(j <n && isdigit(s[j]))
cur_num = cur_num*10 + (s[j++] - '0');
// 注意上面的计算一定要有括号,否则有可能会溢出
nums.push(cur_num);
i = j-1;
}
else {
if (i > 0 && (s[i - 1] == '(' || s[i - 1] == '+' || s[i - 1] == '-')) {
nums.push(0);
}
// 有一个新操作要入栈时,先把栈内可以算的都算了
while(!ops.empty() && ops.top() != '(')
calc(nums, ops);
ops.push(c);
}
}
}
while(!ops.empty())
calc(nums, ops);
return nums.top();
}
void calc(stack<int> &nums, stack<char> &ops) {
if(nums.size() < 2 || ops.empty())
return;
int b = nums.top(); nums.pop();
int a = nums.top(); nums.pop();
char op = ops.top(); ops.pop();
nums.push(op == '+' ? a+b : a-b);
}
};
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
//Java
class Solution {
public int calculate(String s) {
// 存放所有的数字
Deque<Integer> nums = new ArrayDeque<>();
// 为了防止第一个数为负数,先往 nums 加个 0
nums.addLast(0);
// 将所有的空格去掉
s = s.replaceAll(" ", "");
// 存放所有的操作,包括 +/-
Deque<Character> ops = new ArrayDeque<>();
int n = s.length();
char[] cs = s.toCharArray();
for (int i = 0; i < n; i++) {
char c = cs[i];
if (c == '(') {
ops.addLast(c);
} else if (c == ')') {
// 计算到最近一个左括号为止
while (!ops.isEmpty()) {
char op = ops.peekLast();
if (op != '(') {
calc(nums, ops);
} else {
ops.pollLast();
break;
}
}
} else {
if (isNum(c)) {
int u = 0;
int j = i;
// 将从 i 位置开始后面的连续数字整体取出,加入 nums
while (j < n && isNum(cs[j])) u = u * 10 + (int)(cs[j++] - '0');
nums.addLast(u);
i = j - 1;
} else {
if (i > 0 && (cs[i - 1] == '(' || cs[i - 1] == '+' || cs[i - 1] == '-')) {
nums.addLast(0);
}
// 有一个新操作要入栈时,先把栈内可以算的都算了
while (!ops.isEmpty() && ops.peekLast() != '(') calc(nums, ops);
ops.addLast(c);
}
}
}
while (!ops.isEmpty()) calc(nums, ops);
return nums.peekLast();
}
void calc(Deque<Integer> nums, Deque<Character> ops) {
if (nums.isEmpty() || nums.size() < 2) return;
if (ops.isEmpty()) return;
int b = nums.pollLast(), a = nums.pollLast();
char op = ops.pollLast();
nums.addLast(op == '+' ? a + b : a - b);
}
boolean isNum(char c) {
return Character.isDigit(c);
}
}

复杂度分析

  • 时间复杂度:O(n)。
  • 空间复杂度:O(n)。

柱状图中最大的矩形

1.题目内容

给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。

求在该柱状图中,能够勾勒出来的矩形的最大面积。

示例 1:

img

1
2
3
输入:heights = [2,1,5,6,2,3]
输出:10
解释:最大的矩形为图中红色区域,面积为 10

示例 2:

img

1
2
输入: heights = [2,4]
输出: 4

提示:

  • 1 <= heights.length <=105
  • 0 <= heights[i] <= 104

2.解法

(3)单调栈

思路及算法

我们归纳一下枚举「高」的方法:

  • 首先我们枚举某一根柱子 i 作为高 h=heights[i];
  • 随后我们需要进行向左右两边扩展,使得扩展到的柱子的高度均不小于 h。换句话说,我们需要找到左右两侧最近的高度小于 h 的柱子,这样这两根柱子之间(不包括其本身)的所有柱子高度均不小于 h,并且就是 iii 能够扩展到的最远范围。

那么我们先来看看如何求出一根柱子的左侧且最近的小于其高度的柱子。除了根据「前言」部分暴力地进行枚举之外,我们可以通过如下的一个结论来深入地进行思考:

对于两根柱子 j0 以及 j1,如果 j0<j1并且 heights[j0]≥heights[j1],那么对于任意的在它们之后出现的柱子 i(j1<i) 一定不会是 i 左侧且最近的小于其高度的柱子。

换句话说,如果有两根柱子 j0 和 j1,其中 j0 在 j1 的左侧,并且 j0 的高度大于等于 j1,那么在后面的柱子 i 向左找小于其高度的柱子时,j1 会「挡住」j0,j0 就不会作为答案了。

这样以来,我们可以对数组从左向右进行遍历,同时维护一个「可能作为答案」的数据结构,其中按照从小到大的顺序存放了一些 j 值。根据上面的结论,如果我们存放了 j0,j1,⋯ ,js,那么一定有 height[j0]<height[j1]<⋯<height[js],因为如果有两个相邻的 j 值对应的高度不满足 < 关系,那么后者会「挡住」前者,前者就不可能作为答案了。

当我们枚举到第 i 根柱子时,我们的数据结构中存放了 j0,j1,⋯ ,js,如果第 iii 根柱子左侧且最近的小于其高度的柱子为 ji,那么必然有height[j0]<height[j1]<⋯<height[ji]<height[i]≤height[ji+1]<⋯<height[js] 这样我们就可以使用二分查找的方法找到 i 对应的 ji,但真的需要吗?当我们枚举到 i+1 时,原来的 i 也变成了 j值,因此 i 会被放入数据结构。由于所有在数据结构中的 j 值均小于 i,那么所有高度大于等于 height[i] 的 j 都不会作为答案,需要从数据结构中移除。而我们发现,这些被移除的 j 值恰好就是ji+1,⋯ ,js。

这样我们在枚举到第 i 根柱子的时候,就可以先把所有高度大于等于 height[i] 的 j 值全部移除,剩下的 j值中高度最高的即为答案。在这之后,我们将 i 放入数据结构中,开始接下来的枚举。此时,我们需要使用的数据结构也就呼之欲出了,它就是栈。

  • 栈中存放了 j 值。从栈底到栈顶,j 的值严格单调递增,同时对应的高度值也严格单调递增;

  • 当我们枚举到第 i 根柱子时,我们从栈顶不断地移除 height[j]≥height[i] 的 j 值。在移除完毕后,栈顶的 j 值就一定满足 height[j]<height[i],此时 j 就是 i 左侧且最近的小于其高度的柱子。

    • 这里会有一种特殊情况。如果我们移除了栈中所有的 j 值,那就说明 i 左侧所有柱子的高度都大于 height[i],那么我们可以认为 i 左侧且最近的小于其高度的柱子在位置 j=−1,它是一根「虚拟」的、高度无限低的柱子。这样的定义不会对我们的答案产生任何的影响,我们也称这根「虚拟」的柱子为「哨兵」。
  • 我们再将 i 放入栈顶。

栈中存放的元素具有单调性,这就是经典的数据结构「单调栈」了。

代码

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
//C++
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int n = heights.size();
vector<int> left(n), right(n);

stack<int> mono_stack;
for (int i = 0; i < n; ++i) {
while (!mono_stack.empty() && heights[mono_stack.top()] >= heights[i]) {
mono_stack.pop();
}
left[i] = (mono_stack.empty() ? -1 : mono_stack.top());
mono_stack.push(i);
}

mono_stack = stack<int>();
for (int i = n - 1; i >= 0; --i) {
while (!mono_stack.empty() && heights[mono_stack.top()] >= heights[i]) {
mono_stack.pop();
}
right[i] = (mono_stack.empty() ? n : mono_stack.top());
mono_stack.push(i);
}

int ans = 0;
for (int i = 0; i < n; ++i) {
ans = max(ans, (right[i] - left[i] - 1) * heights[i]);
}
return ans;
}
};
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
//Java
class Solution {
public int largestRectangleArea(int[] heights) {
int n = heights.length;
int[] left = new int[n];
int[] right = new int[n];

Deque<Integer> mono_stack = new ArrayDeque<Integer>();
for (int i = 0; i < n; ++i) {
while (!mono_stack.isEmpty() && heights[mono_stack.peek()] >= heights[i]) {
mono_stack.pop();
}
left[i] = (mono_stack.isEmpty() ? -1 : mono_stack.peek());
mono_stack.push(i);
}

mono_stack.clear();
for (int i = n - 1; i >= 0; --i) {
while (!mono_stack.isEmpty() && heights[mono_stack.peek()] >= heights[i]) {
mono_stack.pop();
}
right[i] = (mono_stack.isEmpty() ? n : mono_stack.peek());
mono_stack.push(i);
}

int ans = 0;
for (int i = 0; i < n; ++i) {
ans = Math.max(ans, (right[i] - left[i] - 1) * heights[i]);
}
return ans;
}
}

复杂度分析

  • 时间复杂度:O(N)。
  • 空间复杂度:O(N)。

(2)单调栈+常数优化

思路及算法

在方法一中,我们首先从左往右对数组进行遍历,借助单调栈求出了每根柱子的左边界,随后从右往左对数组进行遍历,借助单调栈求出了每根柱子的右边界。那么我们是否可以只遍历一次就求出答案呢?

答案是可以的。在方法一中,我们在对位置 i 进行入栈操作时,确定了它的左边界。从直觉上来说,与之对应的我们在对位置 i 进行出栈操作时可以确定它的右边界!仔细想一想,这确实是对的。当位置 i 被弹出栈时,说明此时遍历到的位置 i0 的高度小于等于 height[i],并且在 i0 与 i 之间没有其他高度小于等于 height[i] 的柱子。这是因为,如果在 i 和 i0之间还有其它位置的高度小于等于 height[i] 的,那么在遍历到那个位置的时候,i 应该已经被弹出栈了。所以位置 i0 就是位置 i 的右边界。

等等,我们需要的是「一根柱子的左侧且最近的小于其高度的柱子」,但这里我们求的是小于等于,那么会造成什么影响呢?答案是:我们确实无法求出正确的右边界,但对最终的答案没有任何影响。这是因为在答案对应的矩形中,如果有若干个柱子的高度都等于矩形的高度,那么最右侧的那根柱子是可以求出正确的右边界的,而我们没有对求出左边界的算法进行任何改动,因此最终的答案还是可以从最右侧的与矩形高度相同的柱子求得的。读者可以仔细思考一下这一步。

在遍历结束后,栈中仍然有一些位置,这些位置对应的右边界就是位置为 n 的「哨兵」。我们可以将它们依次出栈并更新右边界,也可以在初始化右边界数组时就将所有的元素的值置为 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
//C++
class Solution {
public:
int largestRectangleArea(vector<int>& heights) {
int n = heights.size();
vector<int> left(n), right(n, n);

stack<int> mono_stack;
for (int i = 0; i < n; ++i) {
while (!mono_stack.empty() && heights[mono_stack.top()] >= heights[i]) {
right[mono_stack.top()] = i;
mono_stack.pop();
}
left[i] = (mono_stack.empty() ? -1 : mono_stack.top());
mono_stack.push(i);
}

int ans = 0;
for (int i = 0; i < n; ++i) {
ans = max(ans, (right[i] - left[i] - 1) * heights[i]);
}
return ans;
}
};
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
//Java
class Solution {
public int largestRectangleArea(int[] heights) {
int n = heights.length;
int[] left = new int[n];
int[] right = new int[n];
Arrays.fill(right, n);

Deque<Integer> mono_stack = new ArrayDeque<Integer>();
for (int i = 0; i < n; ++i) {
while (!mono_stack.isEmpty() && heights[mono_stack.peek()] >= heights[i]) {
right[mono_stack.peek()] = i;
mono_stack.pop();
}
left[i] = (mono_stack.isEmpty() ? -1 : mono_stack.peek());
mono_stack.push(i);
}

int ans = 0;
for (int i = 0; i < n; ++i) {
ans = Math.max(ans, (right[i] - left[i] - 1) * heights[i]);
}
return ans;
}
}

复杂度分析

  • 时间复杂度:O(N)。
  • 空间复杂度:O(N)。

逆波兰表达式求值

1.题目内容

给你一个字符串数组 tokens ,表示一个根据逆波兰表示法表示的算术表达式。

请你计算该表达式。返回一个表示表达式值的整数。

注意:

  • 有效的算符为 '+''-''*''/'
  • 每个操作数(运算对象)都可以是一个整数或者另一个表达式。
  • 两个整数之间的除法总是 向零截断
  • 表达式中不含除零运算。
  • 输入是一个根据逆波兰表示法表示的算术表达式。
  • 答案及所有中间计算结果可以用 32 位 整数表示。

示例 1:

1
2
3
输入:tokens = ["2","1","+","3","*"]
输出:9
解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9

示例 2:

1
2
3
输入:tokens = ["4","13","5","/","+"]
输出:6
解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6

示例 3:

1
2
3
4
5
6
7
8
9
10
输入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]
输出:22
解释:该算式转化为常见的中缀算术表达式为:
((10 * (6 / ((9 + 3) * -11))) + 17) + 5
= ((10 * (6 / (12 * -11))) + 17) + 5
= ((10 * (6 / -132)) + 17) + 5
= ((10 * 0) + 17) + 5
= (0 + 17) + 5
= 17 + 5
= 22

提示:

  • 1 <= tokens.length <= 104
  • tokens[i] 是一个算符("+""-""*""/"),或是在范围 [-200, 200] 内的一个整数

逆波兰表达式:

逆波兰表达式是一种后缀表达式,所谓后缀就是指算符写在后面。

  • 平常使用的算式则是一种中缀表达式,如 ( 1 + 2 ) * ( 3 + 4 )
  • 该算式的逆波兰表达式写法为 ( ( 1 2 + ) ( 3 4 + ) * )

逆波兰表达式主要有以下两个优点:

  • 去掉括号后表达式无歧义,上式即便写成 1 2 + 3 4 + * 也可以依据次序计算出正确结果。
  • 适合用栈操作运算:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中

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
31
32
33
34
35
36
//Java
class Solution {
public int evalRPN(String[] tokens) {
Deque<Integer> stack = new LinkedList<Integer>();
int n = tokens.length;
for (int i = 0; i < n; i++) {
String token = tokens[i];
if (isNumber(token)) {
stack.push(Integer.parseInt(token));
} else {
int num2 = stack.pop();
int num1 = stack.pop();
switch (token) {
case "+":
stack.push(num1 + num2);
break;
case "-":
stack.push(num1 - num2);
break;
case "*":
stack.push(num1 * num2);
break;
case "/":
stack.push(num1 / num2);
break;
default:
}
}
}
return stack.pop();
}

public boolean isNumber(String token) {
return !("+".equals(token) || "-".equals(token) || "*".equals(token) || "/".equals(token));
}
}
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
//C++
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> stk;
int n = tokens.size();
for (int i = 0; i < n; i++) {
string& token = tokens[i];
if (isNumber(token)) {
stk.push(atoi(token.c_str()));
} else {
int num2 = stk.top();
stk.pop();
int num1 = stk.top();
stk.pop();
switch (token[0]) {
case '+':
stk.push(num1 + num2);
break;
case '-':
stk.push(num1 - num2);
break;
case '*':
stk.push(num1 * num2);
break;
case '/':
stk.push(num1 / num2);
break;
}
}
}
return stk.top();
}

bool isNumber(string& token) {
return !(token == "+" || token == "-" || token == "*" || token == "/");
}
};

复杂度分析

  • 时间复杂度:O(n),其中 n 是数组 tokens 的长度。需要遍历数组 tokens 一次,计算逆波兰表达式的值。
  • 空间复杂度:O(n),其中 n 是数组 tokens 的长度。使用栈存储计算过程中的数,栈内元素个数不会超过逆波兰表达式的长度。

每日温度

1.题目内容

给定一个整数数组 temperatures ,表示每天的温度,返回一个数组 answer ,其中 answer[i] 是指对于第 i 天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0 来代替。

示例 1:

1
2
输入: temperatures = [73,74,75,71,69,72,76,73]
输出: [1,1,4,2,1,1,0,0]

示例 2:

1
2
输入: temperatures = [30,40,50,60]
输出: [1,1,1,0]

示例 3:

1
2
输入: temperatures = [30,60,90]
输出: [1,1,0]

提示:

  • 1 <= temperatures.length <= 105
  • 30 <= temperatures[i] <= 100

2.解法

单调栈

思路及算法

可以维护一个存储下标的单调栈,从栈底到栈顶的下标对应的温度列表中的温度依次递减。如果一个下标在单调栈里,则表示尚未找到下一次温度更高的下标。

正向遍历温度列表。对于温度列表中的每个元素 temperatures[i],如果栈为空,则直接将 i 进栈,如果栈不为空,则比较栈顶元素 prevIndex 对应的温度 temperatures[prevIndex] 和当前温度 temperatures[i],如果 temperatures[i] > temperatures[prevIndex],则将 prevIndex 移除,并将 prevIndex 对应的等待天数赋为 i - prevIndex,重复上述操作直到栈为空或者栈顶元素对应的温度小于等于当前温度,然后将 i 进栈。

为什么可以在弹栈的时候更新 ans[prevIndex] 呢?因为在这种情况下,即将进栈的 i 对应的 temperatures[i] 一定是 temperatures[prevIndex] 右边第一个比它大的元素,试想如果 prevIndexi 有比它大的元素,假设下标为 j,那么 prevIndex 一定会在下标j的那一轮被弹掉。

由于单调栈满足从栈底到栈顶元素对应的温度递减,因此每次有元素进栈时,会将温度更低的元素全部移除,并更新出栈元素对应的等待天数,这样可以确保等待天数一定是最小的。

以下用一个具体的例子帮助读者理解单调栈。对于温度列表 [73,74,75,71,69,72,76,73],单调栈 stack 的初始状态为空,答案 ans 的初始状态[0,0,0,0,0,0,0,0],按照以下步骤更新单调栈和答案,其中单调栈内的元素都是下标,括号内的数字表示下标在温度列表中对应的温度。

  • 当 i=0 时,单调栈为空,因此将 0 进栈。

    • stack=[0(73)]
    • ans=[0,0,0,0,0,0,0,0]
  • 当 i=1 时,由于 74 大于 73,因此移除栈顶元素 0,赋值 ans[0]:=1−0,将 1 进栈。

    • stack=[1(74)]
    • ans=[1,0,0,0,0,0,0,0]
  • 当 i=2 时,由于 75 大于 74,因此移除栈顶元素 1,赋值 ans[1]:=2−1,将 2 进栈。

    • stack=[2(75)]
    • ans=[1,1,0,0,0,0,0,0]
  • 当 i=3 时,由于 71 小于 75,因此将 3 进栈。

    • stack=[2(75),3(71)]
    • ans=[1,1,0,0,0,0,0,0]
  • 当 i=4 时,由于 69 小于 71,因此将 4 进栈。

    • stack=[2(75),3(71),4(69)]
    • ans=[1,1,0,0,0,0,0,0]
  • 当 i=5 时,由于 72 大于 69 和 71,因此依次移除栈顶元素 4 和 3,赋值 ans[4]:=5−4 和 ans[3]:=5−3,将 5 进栈。

    • stack=[2(75),5(72)]
    • ans=[1,1,0,2,1,0,0,0]
  • 当 i=6 时,由于 76 大于 72 和 75,因此依次移除栈顶元素 5 和 2,赋值 ans[5]:=6−5 和 ans[2]:=6−2,将 6 进栈。

    • stack=[6(76)]
    • ans=[1,1,4,2,1,1,0,0]
  • 当 i=7 时,由于 73 小于 76,因此将 7 进栈。

    • stack=[6(76),7(73)]
    • ans=[1,1,4,2,1,1,0,0]

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//Java
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int length = temperatures.length;
int[] ans = new int[length];
Deque<Integer> stack = new LinkedList<Integer>();
for (int i = 0; i < length; i++) {
int temperature = temperatures[i];
while (!stack.isEmpty() && temperature > temperatures[stack.peek()]) {
int prevIndex = stack.pop();
ans[prevIndex] = i - prevIndex;
}
stack.push(i);
}
return ans;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//C++
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
int n = temperatures.size();
vector<int> ans(n);
stack<int> s;
for (int i = 0; i < n; ++i) {
while (!s.empty() && temperatures[i] > temperatures[s.top()]) {
int previousIndex = s.top();
ans[previousIndex] = i - previousIndex;
s.pop();
}
s.push(i);
}
return ans;
}
};

复杂度分析

  • 时间复杂度:O(n),其中 n 是温度列表的长度。正向遍历温度列表一遍,对于温度列表中的每个下标,最多有一次进栈和出栈的操作。

  • 空间复杂度:O(n),其中 n 是温度列表的长度。需要维护一个单调栈存储温度列表中的下标。

简化路径

1.题目内容

给你一个字符串 path ,表示指向某一文件或目录的 Unix 风格 绝对路径 (以 '/' 开头),请你将其转化为更加简洁的规范路径。

在 Unix 风格的文件系统中,一个点(.)表示当前目录本身;此外,两个点 (..) 表示将目录切换到上一级(指向父目录);两者都可以是复杂相对路径的组成部分。任意多个连续的斜杠(即,'//')都被视为单个斜杠 '/' 。 对于此问题,任何其他格式的点(例如,'...')均被视为文件/目录名称。

请注意,返回的 规范路径 必须遵循下述格式:

  • 始终以斜杠 '/' 开头。
  • 两个目录名之间必须只有一个斜杠 '/'
  • 最后一个目录名(如果存在)不能'/' 结尾。
  • 此外,路径仅包含从根目录到目标文件或目录的路径上的目录(即,不含 '.''..')。

返回简化后得到的 规范路径

示例 1:

1
2
3
输入:path = "/home/"
输出:"/home"
解释:注意,最后一个目录名后面没有斜杠。

示例 2:

1
2
3
输入:path = "/../"
输出:"/"
解释:从根目录向上一级是不可行的,因为根目录是你可以到达的最高级。

示例 3:

1
2
3
输入:path = "/home//foo/"
输出:"/home/foo"
解释:在规范路径中,多个连续斜杠需要用一个斜杠替换。

示例 4:

1
2
输入:path = "/a/./b/../../c/"
输出:"/c"

提示:

  • 1 <= path.length <= 3000
  • path 由英文字母,数字,'.''/''_' 组成。
  • path 是一个有效的 Unix 风格绝对路径。

2.解法

思路及算法

我们首先将给定的字符串 path 根据 / 分割成一个由若干字符串组成的列表,记为 names。根据题目中规定的「规范路径的下述格式」,names 中包含的字符串只能为以下几种:

  • 空字符串。例如当出现多个连续的 /,就会分割出空字符串;
  • 一个点 .
  • 两个点 ..
  • 只包含英文字母、数字或_的目录名。

对于「空字符串」以及「一个点」,我们实际上无需对它们进行处理,因为「空字符串」没有任何含义,而「一个点」表示当前目录本身,我们无需切换目录。

对于「两个点」或者「目录名」,我们则可以用一个栈来维护路径中的每一个目录名。当我们遇到「两个点」时,需要将目录切换到上一级,因此只要栈不为空,我们就弹出栈顶的目录。当我们遇到「目录名」时,就把它放入栈。

这样一来,我们只需要遍历 names 中的每个字符串并进行上述操作即可。在所有的操作完成后,我们将从栈底到栈顶的字符串用 / 进行连接,再在最前面加上 / 表示根目录,就可以得到简化后的规范路径。

代码

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
//C++
class Solution {
public:
string simplifyPath(string path) {
auto split = [](const string& s, char delim) -> vector<string> {
vector<string> ans;
string cur;
for (char ch: s) {
if (ch == delim) {
ans.push_back(move(cur));
cur.clear();
}
else {
cur += ch;
}
}
ans.push_back(move(cur));
return ans;
};

vector<string> names = split(path, '/');
vector<string> stack;
for (string& name: names) {
if (name == "..") {
if (!stack.empty()) {
stack.pop_back();
}
}
else if (!name.empty() && name != ".") {
stack.push_back(move(name));
}
}
string ans;
if (stack.empty()) {
ans = "/";
}
else {
for (string& name: stack) {
ans += "/" + move(name);
}
}
return ans;
}
};
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
//Java
class Solution {
public String simplifyPath(String path) {
String[] names = path.split("/");
Deque<String> stack = new ArrayDeque<String>();
for (String name : names) {
if ("..".equals(name)) {
if (!stack.isEmpty()) {
stack.pollLast();
}
} else if (name.length() > 0 && !".".equals(name)) {
stack.offerLast(name);
}
}
StringBuffer ans = new StringBuffer();
if (stack.isEmpty()) {
ans.append('/');
} else {
while (!stack.isEmpty()) {
ans.append('/');
ans.append(stack.pollFirst());
}
}
return ans.toString();
}
}

复杂度分析

  • 时间复杂度:O(n),其中 n 是字符串 path 的长度。
  • 空间复杂度:O(n)。我们需要 O(n) 的空间存储 names 中的所有字符串。
0%