YifanChen's Blog

一个专注技术的新手程序员

0%

链接

110.平衡二叉树
257. 二叉树的所有路径
404.左叶子之和

知识

257. 二叉树的所有路径

404.左叶子之和

初次尝试

110.平衡二叉树

先尝试递归解法。我写下了如下的代码,可以运行成功,但时间复杂度较高:

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 Solution {
public:
// 求最大根节点的最大高度,用后序遍历
int getheight(TreeNode* node)
{
if (node == NULL) return 0;
int left = getheight(node->left);
int right = getheight(node->right);
int res = 1 + max(left, right);
return res;
}

bool isBalanced(TreeNode* root) {
if (root == NULL) return true;

bool left = isBalanced(root->left);
int leftdepth = getheight(root->left);

bool right = isBalanced(root->right);
int rightdepth = getheight(root->right);

bool flag = false;
if (abs(leftdepth - rightdepth) <= 1) flag = true;

// 根节点需要同时满足:左子树为平衡二叉树,右子树也为平衡二叉树,且左右子树高度差<=1
// 整个树才是平衡二叉树
bool res = left && right && flag;
return res;
}
};

接着看卡尔的讲解。

257. 二叉树的所有路径

拿到本题,我毫无办法,直接看卡尔的讲解。

404.左叶子之和

本题我本来想一上来就是层序遍历,但后面发现是左叶子,而非左孩子。叶子节点是没有左右孩子的节点。我产生了一个另外的想法,先前序遍历一遍二叉树,遍历到左节点时判断左节点是否是左叶子节点,是的话则将其加入结果res中,最后返回res即可。我尝试了,但做不出来,看卡尔的讲解。

实现

110.平衡二叉树

平衡二叉树:二叉树中任何一个节点左右子树的高度差不超过1。求高度要用后序遍历。本题可以用递归法,也可以用迭代法,但优先掌握递归法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int getHeight(TreeNode* node) // 返回一个节点的高度
{
if (node == NULL) return 0; // 终止条件

// 后序遍历
// 若某节点的左右子树的高度差超过1,说明该子树不再为平衡二叉树,进而说明整个树并非平衡二叉树
// 当发现这样的节点时,就不返回节点的高度,直接返回-1
// 左
int left = getHeight(node->left);
if (left == -1) return -1; // 左子树并非平衡二叉树,则返回-1
// 右
int right = getHeight(node->right);
if (right == -1) return -1; // 右子树并非平衡二叉树,则返回-1

// 中
int res;
if (abs(left - right) > 1) res = -1; // 左右子树相差大于1,则说明该子树为非平衡二叉树,返回-1
else res = 1 + max(left, right); // 计算左右子树的父节点的高度
return res;
}

必须用后序遍历,因为需要先计算左右子树的高度,然后才能进行比较。完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
// node为根节点的二叉树是平衡二叉树则返回node的高度,是非平衡二叉树则返回-1
int getheight(TreeNode* node)
{
if (node == NULL) return 0;

int left = getheight(node->left);
if (left == -1) return -1;
int right = getheight(node->right);
if (right == -1) return -1;

int res;
if (abs(left - right) > 1) res = -1;
else res = 1 + max(left, right);
return res;
}
bool isBalanced(TreeNode* root) {
if (getheight(root) == -1) return false;
return true;
}
};

257. 二叉树的所有路径

给定二叉树,返回从根节点到叶子节点的所有路径。求路径需要使用前序遍历。原因:只有前序遍历可以让父节点指向孩子节点,从而输出路径。虽然也可以用迭代法,但推荐使用递归法。回溯和递归是相辅相成、相伴而生的。本题第一次提到回溯。本题的解题过程中有回溯的过程。

为什么会有回溯?假设有以下的二叉树:
img

假设有容器收集路径,收集到路径125,如何弹出2和5,然后再让容器重新收集路径13?回溯的过程:弹出5和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
// path数组用来记录单条路径
// res数组用来存放最终的结果(包含多条路径),是一个数组,数组的每个元素都是一个字符串
void traversal(TreeNode* node, vector<int>& path, vector<string>& res)
{
path.push_back(node->val); // 中:处理过程

// 终止条件
// 左右孩子都为空,说明遍历到了叶子节点,收获结果
if (node->left == NULL && node->right == NULL)
{
res.push_back(path); // 将单条路径的结果放入最终结果中,省略了vector->string和加上->的代码
return;
}

// 单层处理逻辑,用前序遍历:中左右
// 中:处理过程,即添加遍历到的节点,本题的处理过程需要写到终止条件之前
// 因为本题的终止条件是到叶子节点,若中写在终止条件之后,则叶子节点没有被放入path中
// 左
if (node->left)
{
traversal(node->left, path, res);
path.pop_back(); // 回溯,弹出5和2
}
// 右
if (node->right)
{
traversal(node->right, path, res);
path.pop_back(); // 回溯
}
}

完整的代码:

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
class Solution {
public:
void traversal(TreeNode* node, vector<int>& path, vector<string>& res)
{
path.push_back(node->val); // 中

// 终止条件
if (node->left == NULL && node->right == NULL)
{
string s;
for (int i = 0; i < path.size() - 1; i ++ )
{
s += to_string(path[i]);
s += "->";
}
s += to_string(path[path.size() - 1]);
res.push_back(s);
return;
}

// 左
if (node->left)
{
traversal(node->left, path, res);
path.pop_back(); // 回溯
}
// 右
if (node->right)
{
traversal(node->right, path, res);
path.pop_back(); // 回溯
}
}

vector<string> binaryTreePaths(TreeNode* root) {
vector<int> path;
vector<string> res;

if (root == NULL) return res;
traversal(root, path, res);
return res;
}
};

404.左叶子之和

左叶子的定义:首先必须是叶子节点(叶子节点的左右孩子为空)。同时还必须是父节点的左孩子。

本题和之前的二叉树类题目有不同之处。之前的题目遍历到哪个节点就处理哪个节点,但这题遍历到某个节点时,不能直接处理该节点,因为无法判断该节点是否其父节点的左孩子。这题的思路为遍历到某个节点,若其左孩子不为空,但左孩子的左右孩子为空,那么该节点的左孩子就是左叶子,处理该节点的左孩子即可。

本题用后序遍历比较容易,因为后序遍历是左右中,是一层层向上返回。本题也可使用迭代法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int traversal(TreeNode* node) 
{
// 终止条件1
if (node == NULL) return 0;

// 终止条件2,可不写,不写就会做无用的递归
// 如果当前遍历的节点是叶子节点,那其左叶子也必定是0
if (node->left == NULL && node->right == NULL) return 0;

// 左
int leftNum = traversal(node->left);
// node为左叶子的父节点,左叶子为node->left
if (node->left != NULL && node->left->left == NULL && node->left->right == NULL)
leftNum = node->left->val;

// 右
int rightNum = traversal(node->right);
// 中
int sum = leftNum + rightNum;

return sum;
}

上述代码可以精简。但不建议初学者看。

本题其实用层序遍历也可以解决,关键依然在于对于左叶子的父节点的判断,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int sumOfLeftLeaves(TreeNode* root) {
int res = 0;
queue<TreeNode*> q;

if (root == NULL) return 0;
q.push(root);

while (q.size())
{
int size = q.size();
while (size -- )
{
TreeNode* node = q.front();
q.pop();
if (node->left && node->left->left == NULL && node->left->right == NULL) res += node->left->val;
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
return res;
}
};

直接把queue改为stack,也可以解决本题。采用queue的写法是层序遍历,而采用stack的写法是普通迭代写法(区别于统一迭代写法)。

心得与备忘录

110.平衡二叉树

  1. 平衡二叉树:二叉树中任何一个节点左右子树的高度差不超过1。求高度要用后序遍历。
  2. 我在初次尝试中的做法较为简单,但没有进行剪枝,意味着即使某些树的子树为非平衡二叉树,依然会继续递归,而不是直接return false。这样导致程序的时间复杂度较高。
  3. 卡尔提供的解法的核心思路在于:node为根节点的二叉树是平衡二叉树则返回node的高度,是非平衡二叉树则返回-1。本解法的时间复杂度较低,原因在于当发现某个子树是非平衡二叉树时,就说明整棵二叉树都是非平衡二叉树,则一路返回-1,相当于做了剪枝的操作。
  4. 本题优先掌握递归法即可,不要求掌握迭代写法。迭代写法代码很长,且因为迭代法有很多重复的计算,导致效率很低。

257. 二叉树的所有路径

  1. 本题第一次让我接触到了回溯。更具体地说,是第一次在代码中显示地写出了回溯。

  2. 本题虽然是一个easy,但对我这个初学者来说难度不小,需要记得复习。

  3. 本题的核心思路依然是递归三部曲:

    • 确定传入的参数:节点、单条路径和最终结果,后两者需要加上引用。
    • 终止条件:到达叶子节点
    • 单层递归逻辑:前序遍历。中节点的处理逻辑需要放在终止条件之前,否则单条路径中不包含叶子节点。左右节点的处理逻辑注意判空和回溯。
  4. 本题推荐采用我在实现中的写法,虽然代码略显复杂,但清楚地体现了回溯的过程,且不容易出错。不建议写精简的写法,容易出错,且对理解本题的原理并无帮助。

404.左叶子之和

  1. 注意本题中对于左叶子的定义。
  2. 本题不能遍历到哪个节点就处理哪个节点,而是遍历到左叶子的父节点,就处理左叶子。
  3. 本题采用后序遍历的写法,代码较为简单,结果从下往上一层层返回。本题也可采用层序遍历的套路写法。
  4. 本题有两个终止条件,第二个终止条件可以不写,但会导致多递归一层。
  5. 注意如何判断一个节点是否为左叶子的父节点if (node->left != NULL && node->left->left == NULL && node->left->right == NULL)
  6. 本题依然可以方便地套用层序遍历的常规代码。
  7. 我非常惊讶地发现,在本题中,若采用类似层序遍历的写法,用栈或者队列作为存放遍历过的节点的数据结构,都可以得到能够正常运行的代码。卡尔的讲义上给出的迭代法的写法并非是严格意义上的层序遍历,而仅仅是前序遍历的普通迭代写法(也并非统一迭代)。层序遍历需要用队列作为数据结构,而卡尔给的迭代写法采用了栈作为数据结构,但采用严格的层序遍历的写法依然可以解决这道题。

链接

104.二叉树的最大深度
111.二叉树的最小深度
222.完全二叉树的节点个数

知识

104.二叉树的最大深度

111.二叉树的最小深度

222.完全二叉树的节点个数

初次尝试

104.二叉树的最大深度

我看到本题后,发现本题在层序遍历里面做过,就用层序遍历先求解。写出了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int maxDepth(TreeNode* root) {
queue<TreeNode*> q;

if (root != NULL) q.push(root);
int res = 0;
while (q.size())
{
int size = q.size();
while (size -- )
{
TreeNode* node = q.front();
q.pop();
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res ++ ;
}
return res;
}
};

本题应该可以尝试用别的遍历方法,比如递归去求解。直接看卡尔的视频讲解吧。

559. n叉树的最大深度

本题我首先尝试用层序遍历的解法求解,写出了如下的可以AC的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int maxDepth(Node* root) {
queue<Node*> q;
int height = 0;

if (root != NULL) q.push(root);

while (q.size())
{
int size = q.size();
while (size -- )
{
Node* node = q.front();
q.pop();

for (int i = 0; i < node->children.size(); i ++ )
if (node->children[i]) q.push(node->children[i]);
}
height ++ ;
}
return height;
}
};

接着思考如何递归求解。递归知道思路,但写不出取孩子树最大高度的那部分关键代码。

111.二叉树的最小深度

本题可以用层序遍历来做,属于层序遍历的10道题之一。我写出了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int minDepth(TreeNode* root) {
queue<TreeNode*> q;
int depth = 0;

if (root != NULL) q.push(root);

while (q.size())
{
int size = q.size();
depth ++ ;
while (size -- )
{
TreeNode* node = q.front();
q.pop();
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
if (!node->left && !node->right) return depth;
}
}
return depth;
}
};

我再尝试用递归求解。发现有坑,做不出来,看卡尔的讲解。

222.完全二叉树的节点个数

拿到本题,我的第一想法依然是层序遍历。不得不说层序遍历法可以解决很多问题。我写下了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int countNodes(TreeNode* root) {
queue<TreeNode*> q;

if (root != NULL) q.push(root);

int cnt = 0;

while (q.size())
{
int size = q.size();
while (size -- )
{
TreeNode* node = q.front();
q.pop();
cnt ++ ;
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
return cnt;
}
};

感叹下,层序遍历确实能解决很多题,应该还不止卡尔给出的那10道题。我再来尝试递归解法。我觉得应该采用后序遍历,先统计根节点左右子树的节点数量,将二者加起来再加1即可。我写出了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
int countNodes(TreeNode* root) {
if (root == NULL) return 0;

// 后序遍历:左右中
int left = countNodes(root->left);
int right = countNodes(root->right);
int cnt = left + right + 1;
return cnt;
}
};

这道题我能想到的解法就是这两种,接着看卡尔的讲解。

实现

104.二叉树的最大深度

什么是深度,什么是高度?

  • 深度:二叉树中任意一个节点到根节点的距离。根节点的深度规定为1(0也可以,规则不同)。
  • 高度:二叉树中任意一个节点到叶子节点的距离。叶子节点的高度规定为1。

求高度用后序遍历,求深度用前序遍历。求高度是从下往上计数,因此要求从下往上遍历,而后序遍历顺序为左右中,恰好就是从下往上。求深度是从上往下计数,因此要求从上往下遍历,而前序遍历顺序为中左右。恰好就是从上往下。本题本来应该用前序遍历,但根节点的高度就是二叉树的最大深度因此本题用后序遍历也可以做

递归三部曲:

  • 传入的参数和返回值

    1
    int getheight(TreeNode* node)
  • 终止条件

    1
    if (node == NULL) return 0; // 叶子节点的高度是1,其下的空节点高度是0
  • 单层递归(后序遍历:左右中)

    1
    2
    3
    4
    int leftheight = getheight(node->left); // 左节点高度
    int rightheight = getheight(node->right); // 右节点高度
    int height = 1 + max(leftheight, rightheight); // 中节点高度,为左右孩子的高度取最大值+1
    return height; // 根节点的高度就是二叉树的最大深度

本题的前序遍历写法相比于后序遍历写法要复杂很多(前序遍历还涉及到回溯的过程)。本题也可以用迭代法实现。

559. n叉树的最大深度

受到代码随想录的启发,写出了递归法(类似后序遍历)的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int maxDepth(Node* root) {
if (root == NULL) return 0; // 终止条件

// 挑选出最高的孩子节点,将其高度记为height
int height = 0;
for (int i = 0; i < root->children.size(); i ++ )
height = max(height, maxDepth(root->children[i]));
height ++ ; // 中节点(父节点)的高度在最高的孩子节点的基础上+1

return height;
}
};

111.二叉树的最小深度

本题和二叉树的最大深度有很多细节不同,容易踩坑。最小深度:根节点到叶子节点的最小距离(根节点到最近的叶子节点的距离)。像104题一样,本题求二叉树的最小深度,也可以通过后序遍历求高度的方式来求解。二叉树的最小深度实际上就是根节点的最小高度。本题求深度,本来应该用前序遍历,但前序遍历的代码不如后序遍历简洁,因此本题依然推荐使用后序遍历

误区,不能写:int height = 1 + min(left, right);,若根节点的左子树为空,右子树不为空,则这样写二叉树的最小深度为1,显然不对。正确的方法是取右子树的最小高度,然后+1。为处理这种情况,需要写如下的单次递归的代码:

1
2
3
4
5
6
7
8
9
10
11
int left = getheight(node->left);
int right = getheight(node->right);
// 若根节点的左子树为空,右子树不为空,二叉树的最小深度为右子树的最小高度+1
if (node->left == NULL && node->right != NULL)
return 1 + right;
// 若根节点的左子树不为空,右子树为空,二叉树的最小深度为左子树的最小高度+1
if (node->left != NULL && node->right == NULL)
return 1 + left;
// 若左右子树都不为空,则取其中最小的最小高度+1返回
int res = 1 + min(left, right);
return res;

本题后序遍历的完整写法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 后序遍历
class Solution {
public:
int minDepth(TreeNode* root) {
if (root == NULL) return 0;

int left = minDepth(root->left);
int right = minDepth(root->right);

// 左子树为空,右子树非空
// 也可写作if (left == 0 && right)
if (root->left == NULL && root->right != NULL)
return 1 + right;
// 左子树非空,右子树为空
// 也可写作if (left && right == 0)
if (root->left != NULL && root->right == NULL)
return 1 + left;
// 左右子树都不为空
int res = 1 + min(left, right);
return res;
}
};

我写出了一个精简后的版本,但并不会影响代码的可读性(依然可以轻松看出后序遍历):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int minDepth(TreeNode* root) {
if (root == NULL) return 0;

int left = minDepth(root->left); // 左
int right = minDepth(root->right); // 右

// 中
if (!left && right) return 1 + right; // 左空右不空
if (left && !right) return 1 + left; // 左不空右空
return 1 + min(left, right); // 左右都不空
}
};

222.完全二叉树的节点个数

普通二叉树,递归法(前中后序)和迭代法(层序遍历)都可以求二叉树的节点个数。我在初次尝试中写的两个版本的代码都是把本题中的二叉树当成了普通二叉树,而没有利用完全二叉树的特性。本题强调了完全二叉树,就是暗示我们尽量利用完全二叉树的特性。

在递归法(前中后序)中,后序遍历的代码是最简洁的。每个节点都遍历了一遍,时间复杂度是O(n)。接下来利用完全二叉树的特性来降低时间复杂度。完全二叉树:除了底层节点,上面的节点都是满的。底层节点从左到右依次排开。对于满二叉树,只要知道深度h,节点数目就是2^h - 1。对于完全二叉树,如果其子树是满二叉树,则可以直接用上述公式来计算,计算完左右子树的节点数再+1(根节点)即可。关键:如何判断子树为满二叉树,并求其深度

对于满二叉树,一直向左遍历和一直向右遍历的深度应该是相等的。一直向左遍历和一直向右遍历的深度相等的完全二叉树的子树一定是满二叉树若遇到子树非满二叉树的情况,则继续向下遍历(即继续遍历子树的左右子树),直到是满二叉树为止,然后不断返回并+1。这种方式利用了完全二叉树的特性,且避免了遍历没有必要的节点,时间复杂度小于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
int getNum(TreeNode* node)
{
// 终止条件1
if (node == NULL) return 0;

// 终止条件2:遇到子树为满二叉树的情况,则返回子树中节点的数量
TreeNode* left = node->left; // 用于遍历子树的左侧
TreeNode* right = node->right; // 用于遍历子树的右侧
int leftdepth = 0, rightdepth = 0; // 左侧和右侧的深度
// 计算左侧的深度
while (left)
{
left = left->left;
leftdepth ++ ;
}
// 计算右侧的深度
while (right)
{
right = right->right;
rightdepth ++ ;
}
// 左右侧深度相等,说明子树是满二叉树,可以利用公式快速计算子树的节点数量
if (leftdepth == rightdepth) return (2 << leftdepth) - 1; // 2 << 0 = 2^1, 2 << 1 = 2^2

// 单层递归的逻辑,后序遍历
leftnum = getNum(node->left); // 左子树数量
rightnum = getNum(node->right); // 右子树数量
int res = leftnum + rightnum + 1; // 中
return res;
}

上述解法不需要去遍历完全二叉树中的所有节点,而是用公式直接计算子树为满二叉树时的节点数量并返回。

心得与备忘录

104.二叉树的最大深度

  1. 本题可以用层序遍历法求解,属于层序遍历法可以求解的10道题之一。但本题用递归(后序遍历)求解代码最为简洁。
  2. 注意二叉树中深度和高度这两个概念。某个节点的深度指其到根节点的距离,某个节点的高度指其到叶子节点的距离。这两个概念可以说是相反的。
  3. 求深度用前序遍历,求高度用后序遍历。
  4. 二叉树的最大深度就是根节点的高度。因此本题可以用后序遍历求解,实际上后序遍历的代码远比前序遍历的代码简单。因此本题的推荐做法就是后序遍历。
  5. 对于559. n叉树的最大深度。同样可以采用类似后序遍历的递归方法和层序遍历的迭代方法。对于递归方法,注意如何挑选出最高的孩子节点。

111.二叉树的最小深度

  1. 本题可以用迭代法(层序遍历)和递归法(前/后序遍历)求解。最推荐的方法是后序遍历,因为其代码最为简洁。
  2. 后序遍历是用来求高度的,但二叉树的最小深度就是根节点的最小高度,因此本题可以用后序遍历。
  3. 本题的易错点为:不可以直接将104题的max改成min(因为二叉树的最小深度为根节点到叶子节点的最小距离),而是要针对左右子树是否为空进行分类讨论。
  4. 本题的层序遍历写法同样需要注意:只有当左右孩子都为空的时候,才说明遍历到最低点了。

222.完全二叉树的节点个数

  1. 本题可以采用递归写法和迭代写法。递归写法建议采用后序遍历,迭代写法建议采用层序遍历。二者的时间复杂度都是O(n)。
  2. 上述方法对普通二叉树都适用,但对本题的完全二叉树,充分利用其特性可将时间复杂度进一步减小。
  3. 一直向左遍历和一直向右遍历的深度相等的完全二叉树的子树一定是满二叉树若遇到子树非满二叉树的情况,则继续向下遍历,最终必然会遇到满二叉树。对于满二叉树,只要知道深度h,节点数目就是2^h - 1。因此不需要遍历完全二叉树的每一个节点,就可以求得其节点的个数。
  4. 上述解法的终止条件有两个,一个是遇到叶子节点,另一个是子树为满二叉树。单层递归逻辑采用后序遍历写法。
  5. 时间复杂度分析:递归调用的次数=树的高度=log n,每层递归需要计算子树的高度,故也是log n。因此总的时间复杂度为O(log n * log n)。
  6. 空间复杂度分析:递归的深度(即递归调用栈的最大深度)大约是树的高度。对于一棵平衡二叉树来说,其高度大约是log n,其中n是树中节点的数量。故空间复杂度为O(log n)。

链接

层序遍历
226.翻转二叉树
101. 对称二叉树

知识

层序遍历

二叉树的层序遍历相当于图论中的广度优先搜索。leetcode 102:层序输出二叉树。

1
2
3
4
5
6
7
graph TD;
A[6] --> B[4];
A --> C[7];
B --> D[1];
B --> E[3];
C --> F[5]
C --> G[8]

上述二叉树,一层放在一个数组里,返回的是二维数组。只依赖二叉树本身的结构,无法层序保存二叉树中的节点。需要借助另一种数据结构:队列,用于保存每一层遍历过的元素。图论中的广度优先搜索也是依赖队列实现的。

模拟过程:根节点6加入队列,记录队列大小(size=1)。size表示这层二叉树中有几个元素。接下来弹出当前层的元素6,将6加入到结果数组中,开始处理下一层。再将6的左右孩子4和7加入队列中,此时size=2,第二层的元素个数为2,接下来弹出size(2)个元素,先弹出4,将4的左右孩子1和3加入队列。再弹出7,size归0,第二层遍历结束。弹出7后,再将7的左右孩子5和8加入队列。此时size=4,说明第三层中元素个数为4。接着队列中再弹出size(4)个元素,加入结果数组。上述过程如下图所示。
Snipaste_2024-02-16_02-48-16.png

我尝试根据上述模拟过程独立写出代码,但不知道怎么写while循环结束的条件。直接看卡尔的讲解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
vector<vector<int>> res;
queue<TreeNode*> q;
if (root != NULL) q.push(root);

// 遍历的终止条件:队列中无元素
while (q.size())
{
int size = q.size(); // 记录当前层节点的数量
vector<int> vec; // 存放一层的节点的值
// 队列中弹出size个节点,加入到vec中
while (size -- )
{
TreeNode* node = q.front();
q.pop();
vec.push_back(node->val);

// 将弹出节点的左右孩子加入到队列中
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res.push_back(vec);
}
return res;

上述代码也是图论中广度优先搜索的模板。leetcode上有10道题都可以用本模板解决,只需要改动不超过三行代码。

初次尝试

226.翻转二叉树

我能想到的办法是层序遍历二叉树,然后将每一层的输出数组翻转。但这样做需要将数组还原回到二叉树,比较麻烦。随后有产生想法,让一个节点的左指针指向其右节点,右指针指向其左节点即可,可能需要一个中间变量来存放左节点或者右节点。直接看卡尔的视频。

101. 对称二叉树

看到本题,我的第一想法是,本题是翻转二叉树的变式。若一个二叉树被翻转后,仍和原来保持一致,那么就可以认为它是对称二叉树。现在的问题在于如何比较两棵二叉树是否完全相同,我认为可以采用层序遍历,一层层比较即可。或者直接层序遍历完后将二叉树存入一个二维数组中,然后用两重循环+双指针算法判断二维数组是否对称。这样做实际上有个问题:

img

上面这棵二叉树,层序遍历得到的二维数组为[1, [2, 2], [3, 3]]。二维数组是对称的,但二叉树却不是对称的。还是看卡尔的讲解吧。

实现

层序遍历

107.二叉树的层次遍历II

只需要在最后翻转res数组即可:reverse(res.begin(), res.end());。翻转一个二维数组,二维数组中所有元素(一维数组)的顺序都会颠倒,但一维数组本身(即一维数组内部的顺序不会改变)。reverse函数可以用双指针算法手动实现:

1
2
3
int len = res.size();
for (int i = 0, j = len - 1; i < len / 2; i ++ , j -- )
swap(res[i], res[j]);

似乎手动实现的速度要快于调用现成的reverse函数。

199.二叉树的右视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
vector<int> rightSideView(TreeNode* root) {
vector<int> res;
queue<TreeNode*> q;

if (root != NULL) q.push(root);
while (q.size())
{
int size = q.size();
for (int i = 0; i < size; i ++ )
{
TreeNode* node = q.front();
q.pop();
if (i == size - 1) res.push_back(node->val); // 将一层最右边的节点的值加入到结果数组中
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
return res;
}
};

637.二叉树的层平均值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
vector<double> averageOfLevels(TreeNode* root) {
vector<double> res;
queue<TreeNode*> q;

if (root != NULL) q.push(root);
while (q.size())
{
int size = q.size();
double sum = 0;
for (int i = 0; i < size; i ++ )
{
TreeNode* node = q.front();
q.pop();
sum += node->val;
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res.push_back(sum / size);
}
return res;
}
};

429. 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
/*
// Definition for a Node.
class Node {
public:
int val;
vector<Node*> children;

Node() {}

Node(int _val) {
val = _val;
}

Node(int _val, vector<Node*> _children) {
val = _val;
children = _children;
}
};
*/

class Solution {
public:
vector<vector<int>> levelOrder(Node* root) {
vector<vector<int>> res;
queue<Node*> q;

if (root != NULL) q.push(root);
while (q.size())
{
vector<int> vec;
int size = q.size();

while (size -- )
{
Node* node = q.front();
q.pop();
vec.push_back(node->val);
for (int i = 0; i < node->children.size(); i ++ )
if (node->children[i]) q.push(node->children[i]);
}
res.push_back(vec);
}
return res;
}
};

515.在每个树行中找最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
vector<int> largestValues(TreeNode* root) {
vector<int> res;
queue<TreeNode*> q;

if (root != NULL) q.push(root);
while(q.size())
{
int size = q.size();
int max = -2147483648; // Node.val的最小值,可简写为int max = INT_MIN;
while (size -- )
{
TreeNode* node = q.front();
q.pop();
max = node->val > max ? node->val: max; // 注意问号表达式的用法
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res.push_back(max);
}
return res;
}
};

116. 填充每个节点的下一个右侧节点指针

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
class Solution {
public:
Node* connect(Node* root) {
queue<Node*> q;

if (root != NULL) q.push(root);
while (q.size())
{
int size = q.size();
Node* node0, *node;
for (int i = 0; i < size; i ++ )
{
// 取出一层的头结点
if (i == 0)
{
node0 = q.front();
q.pop();
// 本句话的目的:当一层只有头节点时,可以让该头节点在弹出的同时继续在队列中行插入其左右子节点
node = node0;
}
// 本层前一个节点next指向本节点
else
{
// node0和node交替前进
node = q.front();
q.pop();
node0->next = node;
node0 = node;
}
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
// 本层最后一个节点指向NULL
node->next = NULL;
}
return root;
}
};

117.填充每个节点的下一个右侧节点指针II

本题代码和116完全相同。116题目中的条件:完整二叉树实际上是多余的。不管是不是完整二叉树,都可以用同样的代码解题。

104.二叉树的最大深度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int maxDepth(TreeNode* root) {
int res = 0;
queue<TreeNode*> q;

if (root != NULL) q.push(root);
while (q.size())
{
int size = q.size();
while (size -- )
{
TreeNode* node = q.front();
q.pop();
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res ++ ;
}
return res;
}
};

111.二叉树的最小深度

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
class Solution {
public:
int minDepth(TreeNode* root) {
int res = 0;
queue<TreeNode*> q;

if (root != NULL) q.push(root);
while (q.size())
{
int size = q.size();
bool flag = true;
while (size -- )
{
TreeNode* node = q.front();
q.pop();
if (node->left == NULL && node->right == NULL)
{
flag = false;
break;
}
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res ++ ;
if (flag == false) break;
}
return res;
}
};

注意:只有当某个节点的左右孩子都为空,这个节点才在二叉树的底部。一旦遇到这样的节点,立即跳出循环,返回res。根据这个思路,我将上述代码做了简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int minDepth(TreeNode* root) {
int res = 0;
queue<TreeNode*> q;

if (root != NULL) q.push(root);
while (q.size())
{
int size = q.size();
while (size -- )
{
TreeNode* node = q.front();
q.pop();
if (node->left == NULL && node->right == NULL)
return ++ res;
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
res ++ ;
}
return res;
}
};

226.翻转二叉树

面试中的常考题。本质是交换每个节点的左右孩子,交换的是指针而非数值。这道题显然用递归解比较简单,但要想清楚用哪种遍历顺序。本题用前序和后序是最直接的,用中序遍历代码比较难写。迭代和层序遍历也可以做此题,但不要求掌握。

递归三部曲:

  • 确定递归函数的返回值和参数: TreeNode* invertTree(root)

  • 确定函数的终止条件:if (root == NULL) return root

  • 具体的处理逻辑:前序遍历——中左右

    对中节点,需要交换中节点的左右孩子: swap(root->left, root->right)

    左节点:invertTree(root->left);

    右节点:invertTree(root->right);

    将swap函数放在处理逻辑的最后,就是左右中,就是后续遍历。因此前序和后续遍历皆可解本题。但中序遍历不可以,举个例子:

    1
    2
    3
    4
    5
    6
    7
    graph TD;
    A[4] --> B[2];
    A --> C[7];
    B --> D[1];
    B --> E[3];
    C --> F[6]
    C --> G[9]
    1
    2
    3
    4
    5
    6
    7
    graph TD;
    A[4] --> B[2];
    A --> C[7];
    B --> D[3];
    B --> E[1];
    C --> F[6]
    C --> G[9]
    1
    2
    3
    4
    5
    6
    7
    graph TD;
    A[4] --> B[7];
    A --> C[2];
    B --> D[6];
    B --> E[9];
    C --> F[3]
    C --> G[1]
    1
    2
    3
    4
    5
    6
    7
    graph TD;
    A[4] --> B[7];
    A --> C[2];
    B --> D[6];
    B --> E[9];
    C --> F[1]
    C --> G[3]

    相当于原先根节点的左子树被处理了两次,原先根节点的右子树没被处理。对中序遍历的写法,具体的逻辑应该为:

    1
    2
    3
    invertTree(root->left); // 处理左子树
    swap(root->left, root->right); // 交换左右子树,原先的右子树变为了现在的左子树,原先的左子树变为了现在的右子树
    invertTree(root->left); // 原先的左子树已经被处理过了,现在需要处理原先的右子树,就是现在的左子树

    不建议绕弯子去写中序,很容易出错。

前序遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 前序遍历写法
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if (root == NULL) return root; // 终止条件

// 中左右
swap(root->left, root->right);
invertTree(root->left);
invertTree(root->right);
return root; // 每次递归后返回结果
}
};

后序遍历写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 后序遍历写法
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if (root == NULL) return root;

// 左右中
invertTree(root->left);
invertTree(root->right);
swap(root->left, root->right);
return root;
}
};

中序遍历写法(绕,理解即可,不要写):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 中序遍历写法
class Solution {
public:
TreeNode* invertTree(TreeNode* root) {
if (root == NULL) return root;

// 左中右
invertTree(root->left);
swap(root->left, root->right);
invertTree(root->left);
return root;
}
};

101. 对称二叉树

本质上是判断根节点的左子树和右子树是否可以互相翻转。需要比较二叉树同是外侧的节点和同是内侧的节点是否相等。接着考虑用哪种方式遍历二叉树。二叉树类的题目确定遍历顺序非常重要本题目只能使用后序遍历(左右中)。因为我们需要先收集完根节点左右孩子的信息再返回给根节点,才能知道根节点的左右孩子是否相同,进而知道二叉树是否是对称的。

  1. 确定函数传入的参数和返回值

    1
    2
    3
    4
    5
    // 判断根节点的左右子树是否可以互相翻转
    // 本质是判断两个二叉树是否可以相互翻转,因此需要同时处理两棵二叉树
    bool compare(TreeNode* left, TreeNode* right) // 传入的参数为左子树的头节点和右子树的头节点
    {
    }
  2. 确定终止条件
    共有以下5种情况
    |左节点|右节点|返回值|
    |:—-:|:—-:|:—-:|
    |空|非空|false|
    |非空|空|false|
    |空|空|true|
    |非空且值不等|非空且值不等|false|
    |非空且值相等|非空且值相等|继续向下遍历,写单层递归的逻辑|

    1
    2
    3
    4
    if (left == NULL && right != NULL) return false;
    else if (left != NULL && right == NULL) return false;
    else if (left == NULL && right == NULL) return true;
    else if (left->val != right->val) return false;
  3. 单层递归的逻辑(如何像下一层遍历)
    同是外侧的节点和同是内侧的节点相同,才可以return true。

    1
    2
    3
    4
    bool outside = compare(left->left, right->right); // 比较一对外侧节点是否相同
    bool inside = compare(left->right, right->left); // 比较一对内侧节点是否相同
    bool res = outside && inside; // 内外侧节点都相同,则才可以左右翻转
    return res;

    上面代码框的前三行代码分别对应后序遍历的左右中。中只能放在最后,不能提前,否则会出现还没计算outside和inside就来计算res的情况,因此必须是后序遍历。

后序遍历解决本题的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
bool compare(TreeNode* left, TreeNode* right)
{
if (left == NULL && right == NULL) return true;
else if (left == NULL && right != NULL) return false;
else if (left != NULL && right == NULL) return false;
else if (left->val != right->val) return false;

bool outside = compare(left->left, right->right);
bool inside = compare(left->right, right->left);
bool res = outside && inside;
return res;
}

bool isSymmetric(TreeNode* root) {
return compare(root->left, root->right);
}
};

本题也可以用迭代法实现。

572.另一个树的子树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
bool compare(TreeNode* left, TreeNode* right)
{
if (left == NULL && right == NULL) return true;
else if (left != NULL && right == NULL) return false;
else if (left == NULL && right != NULL) return false;
else if (left->val != right->val) return false;

bool outside = compare(left->left, right->left);
bool inside = compare(left->right, right->right);
bool res = outside && inside;
return res;
}
bool isSubtree(TreeNode* root, TreeNode* subRoot) {
if (subRoot == NULL) return true;
else if (root == NULL) return false;
else if (compare(root, subRoot)) return true;
// 递归比较root树的子树和subRoot是否相同
return isSubtree(root->left, subRoot) || isSubtree(root->right, subRoot);
}
};

本题注意如何递归地比较root树的子树和subRoot树是否相同:return isSubtree(root->left, subRoot) || isSubtree(root->right, subRoot);

心得与备忘录

层序遍历

  1. 本题(leetcode102)的模板需要熟记,可以用来解决10道leetcode。
  2. 本题的关键在于用队列来保存每一层遍历过的元素。
  3. 本题的思路可以概括为:将二叉树的一层加入到队列中,记录队列的大小为size。然后弹出size个节点,用数组收集弹出的节点,并在弹出节点的同时插入弹出的节点的左右子节点。弹完size个节点后,数组中就是当前层的所有元素,而队列中则是下一层的所有节点。
  4. 本题不需要用指针来遍历整棵树,只需要对维护和操作队列即可。
  5. 本题收获最终结果的退出条件为队列为空;二叉树的一层遍历完毕的退出条件为size = 0。
  6. 二叉树的右视图这题需要特别注意,以下写法是错误的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Solution {
    public:
    vector<int> rightSideView(TreeNode* root) {
    vector<int> res;
    queue<TreeNode*> q;

    if (root != NULL) q.push(root);
    while (q.size())
    {
    int size = q.size();
    while (size -- )
    {
    TreeNode* node = q.front();
    q.pop();
    res.push_back(node->val);
    if (node->right) q.push(node->right);
    }
    }
    return res;
    }
    };

    原因是:对于以下的二叉树:

    1
    2
    3
    graph TD;
    A[6] --> B[4];
    A --> C[NULL];

    尽管6只有左子节点,没有右子节点,但站在二叉树的右边看这颗二叉树,看到的结果是[6, 4],如果按照上面的写法,则返回的结果是[6],4作为左子节点不会被加入到队列中,也不会出现在结果数组中。

  7. N叉树的层序遍历需要注意:新定义的N叉树的名字叫Node,不要下意识地写成TreeNode。在队列中更新N叉树下一层的节点时,注意需要用for循环遍历一遍当前node的孩子数组,因为N叉树中的一个节点不仅有左右孩子,而是有一个孩子数组。

  8. 二叉树的最大深度的解题关键在于:层序遍历二叉树,每遍历完一层记录层数的变量+1。

  9. 二叉树的最大/最小深度这两道题,res ++放在第二重while循环之后和之前都可以。我在实现中的写法都是把res ++放在了第二重while循环之后,但实际上放在第二重while循环之前写出的代码更简洁易懂,可以参考代码随想录上给出的代码。

  10. 注意复习填充每个节点的下一个右侧节点指针,这道题第一遍没有写出来。本题的关键在于特判一层的头节点,以及node0和node交替前进。

  11. 116和117题的代码完全相同。差别只在于116题题目说是完整二叉树,117题目则没有这个说明。

226.翻转二叉树

  1. 注意:本题中的root是指遍历的每个节点,而非特指根节点。

  2. 本题的关键思路:交换中节点的左右子树,递归处理左右节点。

  3. 记住前序和后续的写法即可,swap要么写在左右的上面,要么写在左右的下面。抛弃中序写法,太绕!

  4. 记得最后要return root。因为终止条件:if (root == NULL) return root,只会返回一个为空的节点。大多数情况下不会触发这个终止条件,而是触发最后一个return root

  5. 可以定义一个cur节点遍历二叉树的每个节点,这样就不会与根节点root产生混淆。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 前序遍历写法
    class Solution {
    public:
    TreeNode* invertTree(TreeNode* root) {
    TreeNode* cur = root;
    if (cur == NULL) return cur;

    // 中左右
    swap(cur->left, cur->right);
    invertTree(cur->left);
    invertTree(cur->right);
    return cur;
    }
    };
  6. 本题除递归写法外,用一般迭代、统一迭代、层序遍历的写法都可以,其实在原本的三种迭代方式的代码的基础上稍作修改就可以,但三种迭代方式的代码本身就已经较为复杂,容易写错,因此除非必须建议不要采用迭代写法。

  7. 但还是不得不说,层序遍历解本题也很方便,本题也可以归类到层序遍历能够解决的10道题中,在层序遍历的基础上,交换每个节点的左右子节点即可,代码如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Solution {
    public:
    TreeNode* invertTree(TreeNode* root) {
    queue<TreeNode*> q;

    if (root != NULL) q.push(root);
    while (q.size())
    {
    int size = q.size();
    while (size -- )
    {
    TreeNode* node = q.front();
    q.pop();
    swap(node->left, node->right); // 交换每个节点的左右子节点
    if (node->left) q.push(node->left);
    if (node->right) q.push(node->right);
    }
    }
    return root;
    }
    };

101. 对称二叉树

  1. 本题实际上需要比较根节点的左右子树是否可以相互翻转,因此需要同时遍历两棵树,所以传入的参数为左右子树的头节点。
  2. 本题只能采用后序遍历,遍历左子树的顺序是左右中,遍历右子树的顺序是右左中。
  3. 终止条件需要分五类讨论。见实现中的表格。
  4. 单层递归的核心逻辑为:判断同在外侧的节点是否相同,判断同在内侧的节点是否相同。
  5. 本题的迭代写法其实也不难理解,原理是通过一个容器来成对的存放我们要比较的元素。但优先掌握本题的递归写法即可。
  6. 100.相同的树和572.另一个树的子树基本和本题是一样的,只要稍加修改就可以。572题稍有特殊,需要注意如何递归地比较root树的子树和subRoot树是否相同。同时在主函数中也需要进行分类讨论(subRoot树为空, root树为空,两树相同,root树的子树和subRoot树相同/相异)。

链接

理论基础
递归遍历
迭代遍历
统一迭代

知识

理论基础

二叉树的种类

解题过程中二叉树有两种主要的形式:满二叉树和完全二叉树(完全二叉树包含满二叉树,满二叉树一定是完全二叉树)

“度”是指一个节点拥有的子节点的数量

满二叉树

满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。也可以说深度为k,有2^k-1个节点的二叉树。

完全二叉树

完全二叉树:除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层(h从1开始),则该层包含 1~ 2^(h-1) 个节点。

之前我们刚刚讲过优先级队列其实是一个堆,堆就是一棵完全二叉树,同时保证父子节点的顺序关系。

二叉搜索树

前面介绍的树,都是没有数值的,而二叉搜索树是有数值的了,二叉搜索树是一个有序树。搜索的时间复杂度是O(logn)。

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 它的左、右子树也分别为二叉排序树
    平衡二叉树
    平衡二叉树(Balanced Binary Tree)是一种特殊的二叉树,它满足以下性质:对于树中的每一个节点,其左子树和右子树的高度差的绝对值不超过1。这个条件确保了树的高度大致保持在log(n)级别,其中n是树中节点的数量。由于这种高度平衡,平衡二叉树可以在对数据进行插入、删除和查找操作时提供较好的性能,特别是保持操作的时间复杂度接近于O(logn)
    平衡二叉搜索树
    平衡二叉搜索树:又被称为AVL树。具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。

C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn。map的key和set中的元素是有序的,因为它们的底层实现是平衡二叉搜索树,而平衡二叉搜索树是有序的。

二叉树的存储方式

二叉树可以链式存储,也可以顺序(线性)存储。那么链式存储方式就用指针, 顺序存储的方式就是用数组。顾名思义就是顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在各个地址的节点串联一起。

用数组来存储二叉树如何遍历的呢?如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树

代码构造二叉树:创造一个头节点,其左指针指向左子节点,右指针指向右子节点,然后向函数中传入头节点即可。

二叉树的遍历方式

二叉树主要有两种遍历方式:

  1. 深度优先遍历:先往深走,遇到叶子节点再往回走。
  2. 广度优先遍历:一层一层的去遍历。

从深度优先遍历和广度优先遍历进一步拓展,才有如下遍历方式:

  1. 深度优先遍历(一般用递归法)
  • 前序遍历(递归法,迭代法)
  • 中序遍历(递归法,迭代法)
  • 后序遍历(递归法,迭代法)
  1. 广度优先遍历
    层次遍历(迭代法)

这里前中后,其实指的就是中间节点的遍历顺序(但是所有遍历顺序都是先左后右)。
左指左子树,右指右子树。在左右子树中继续按照规则搜索。
前序遍历:中左右
中序遍历:左中右
后序遍历:左右中

img

每个子树和整棵树都遵循中左右/左中右/左右中。

最后再说一说二叉树中深度优先和广度优先遍历实现方式,我们做二叉树相关题目,经常会使用递归的方式来实现深度优先遍历,也就是实现前中后序遍历,使用递归是比较方便的。之前我们讲栈与队列的时候,就说过栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助使用递归的方式来实现的。

而广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。

二叉树的定义

链式存储的二叉树节点的定义方式:

1
2
3
4
5
6
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(NULL), right(NULL) {} // 构造函数
};

二叉树的定义和链表是差不多的,相对于链表 ,二叉树的节点里多了一个指针,有两个指针,指向左右孩子。

递归遍历

针对leetcode上的三道题目,分别是前序、中序、后序遍历,题号是144,145和94。按照三步来思考,才能保证写出正确的递归代码。所有二叉树的题目都用递归三部曲进行分析。本章节主要讲如何写出递归的代码,不关注底层实现机制。

三部曲:

  1. 确定递归函数的参数和返回值
  2. 确定终止条件
  3. 确定单层递归的逻辑

前序遍历

前序:中左右

  1. 确定递归函数的参数和返回值

    没必要一次性确定,可以在写递归函数时根据需要来填充参数。一般参数为根节点和数组,后者用来存放遍历的结果。返回值一般是void,因为我们把想要的结果放在了参数的数组中。

    1
    2
    3
    4
    void traversal(TreeNode* cur, vector<int>& vec) // 参数为根节点和结果数组
    {

    }
  2. 确定终止条件(搞不好会出现栈溢出等问题)。深度优先搜索是遇到NULL时返回。

    1
    2
    if (cur == NULL)
    return;
  3. 确定单层递归的逻辑

    1
    2
    3
    vec.push_back(cur->val); // 中
    treversal(cur->left, vec); // 左
    treversal(cur->right, vec); // 右

    注意在前序遍历中上面三行代码的顺序不可改变。

中序遍历

左中右。只需要改变第三步:确定单层递归的逻辑的代码。三行代码的顺序不可改变。

1
2
3
treversal(cur->left, vec); // 左
vec.push_back(cur->val); // 中
treversal(cur->right, vec); // 右

后序遍历

左右中。同样只需要改变第三步:确定单层递归的逻辑的代码。三行代码的顺序不可改变。

1
2
3
treversal(cur->left, vec); // 左
treversal(cur->right, vec); // 右
vec.push_back(cur->val); // 中

迭代遍历

前序遍历

非递归的方式:迭代法,如何实现二叉树的前中后序遍历。通常对简单的递归逻辑,要求写出相应的迭代(非递归)写法。最基础的就是用迭代法实现前中后序遍历。使用迭代法模拟递归,也需要使用到栈这种数据结构。理论上,所有递归都可以用栈模拟出来。

以下面的二叉树为例。

1
2
3
4
5
graph TD;
A[5] --> B[4];
A --> C[6];
B --> D[2];
B --> E[1];

前序遍历上述二叉树,顺序为中左右,输出结果为54216。用栈来辅助遍历上述二叉树。首先将5加入栈中,然后弹出5,将其放入结果数组中。接着处理5的左右孩子,先把6加入栈中,再把4加入栈中(栈是先进后出的),然后弹出4,将其放入结果数组中。接着处理4的左右孩子,依旧是先放右孩子1,再放左孩子2,然后弹出2,加入结果数组中,因为2已经是叶子节点了,接着弹出1,加入结果数组中,最后弹出6,加入结果数组中。结果数组中是54216,符合预期。关键点是先将右孩子放入栈中,再将左孩子放入栈中,这样弹出时就会先弹出左孩子。弹出时还要关注弹出的节点是否是叶子节点。是,则不需要继续向栈中加入元素;否,则需要向栈中继续加入弹出节点的左右孩子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vector<int> function(TreeNode* root)
{
stack<TreeNode*> st; // 栈
vector<int> res; // 结果数组

st.push(root); // 中节点入栈
// 栈不为空,则执行以下逻辑
while (!st.empty())
{
// 将中节点从栈中弹出,加入到结果数组中
TreeNode* node = st.top();
st.pop();
if (node != NULL) // 特判:中节点是否为空
res.push_back(node->val);
else continue; // 若中节点为空,进入下一次循环

// 将中节点的左右孩子放入栈中,先将右孩子入栈,再将左孩子入栈,这样出栈时才是先左后右的顺序
st.push(node->right); // 右
st.push(node->left); // 左
}
return res;
}

递归模拟前序遍历,本来前序遍历的顺序应该是中左右,但由于栈先进后出的特性,代码中实际的顺序是中右左。

后序遍历

后序遍历是左右中。实现后序遍历的原理如下图所示:

1
2
3
graph LR
A[前序遍历:中左右] -->|颠倒左右| B[中右左]
B -->|翻转结果数组| C[左右中]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
vector<int> function(TreeNode* root)
{
stack<TreeNode*> st;
vector<int> res;

st.push(root);
while (!st.empty())
{
// 中
TreeNode* node = st.top();
st.pop();
if (node != NULL)
res.push_back(node->val);
else continue;

st.push(root->left); // 左
st.push(root->right); // 右
}
reverse(res.begin(), res.end());
return res;
}

中序遍历

中序遍历无法在前序遍历的基础上通过交换某几行代码的顺序来实现。遍历节点和处理节点是两种逻辑。前序和后序遍历中,遍历的节点和要处理的节点是一个顺序,才能写出上述比较简洁的代码。但在中序遍历中,遍历节点的顺序与和处理节点的顺序不同。后面会继续介绍中序遍历的写法,以及如何像递归写法那样更改几行代码的顺序来实现前中后序遍历的迭代写法。

处理二叉树时有两步操作,一步是访问节点,一步是处理节点。访问节点是从根节点开始,一个节点一个节点地去访问。处理节点是把访问到的节点中的元素放入结果数组中。

以下面的二叉树为例:

1
2
3
4
5
graph TD;
A[5] --> B[4];
A --> C[6];
B --> D[1];
B --> E[2];

对于中序遍历,先访问的节点是5,但先处理的节点应该是1(先把1放入结果数组中)。我们要处理1节点,需要先访问节点5、4、1。这就造成了访问的顺序和处理的顺序不同。因此中序遍历需要另一套写法。

下面模拟中序遍历迭代法的过程。需要一个指针帮助我们遍历二叉树,同时用栈记录遍历过的顺序,然后逆向输出即可。指针一路向左访问,指针先指向5,5入栈;指针再指向4,4入栈;指针再指向1,1入栈。到叶子节点了(叶子节点的左指针为空),便从栈中取出元素,从栈中弹出1并加入到结果数组中。看1的右孩子,为空,故再从栈中弹出4并加入到结果数组中,看4的右孩子,不为空,4的右孩子2入栈。2的左孩子为空,故将2从栈中弹出,加入到结果数组中。再看2的右孩子,为空,故从栈中弹出5并加入到结果数组中。5的右孩子为6,不为空,6入栈。6的左孩子为空,故6出栈,加入结果数组中。6的右孩子为空,本该从栈中弹出元素,但此时栈为空,故结束。结果数组为14256,符合中序遍历的顺序。

总结:用指针遍历节点,用栈来记录遍历过的节点,再从栈中弹出元素放入结果数组中。指针一路向左访问,若某个节点的左指针为空,则从栈中取出该节点并放入结果数组中。若某个节点的右指针为空,则从栈中弹出顶部元素并放入结果数组中,若某个节点的右指针不为空,则将右指针指向的节点入栈。

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
vector<int> traversal(TreeNode* root)
{
vector<int> res;
stack<TreeNode*> st;
TreeNode* cur = root; // 用于遍历二叉树的指针,一开始指向根节点

// cur为空且栈也为空时,遍历终止
while (cur != NULL || !st.empty())
{
// 栈用于记录指针访问过的元素
if (cur != NULL)
{
st.push(cur);
cur = cur->left; // 指针一路向左访问
}
// 指针一路向左,遇到某个节点的左指针为空
// 则从栈中取出该节点并放入结果数组中
else
{
cur = st.top();
st.pop();
res.push_back(cur->val);

// 看当前指针的右孩子是否为空
// 若为空,则从栈中弹出顶部节点,并将其加入到结果数组中
// 若不为空,则将右孩子入栈
cur = cur->right;
}
}
return res;
}

目前迭代法的前中后序遍历没有像递归那样统一起来,其实也是可以统一起来的。统一的写法:用一个栈完成遍历节点和处理节点的过程,但栈中要加入空节点做标记,标记正在遍历的节点和处理的节点。

统一迭代

对前中后序这三种遍历方式,使用迭代法是可以写出统一风格的代码。以中序遍历为例,我们就将访问的节点放入栈中,把要处理的节点也放入栈中但是要做标记。如何标记呢,就是要处理的节点放入栈之后,紧接着放入一个空指针作为标记。 这种方法也可以叫做标记法。

中序遍历

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
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
stack<TreeNode*> st;
vector<int> res;

// 根节点非空才将其放入栈中
if (root != NULL) st.push(root);

// 循环条件:栈不为空
while (!st.empty())
{
// 弹出栈顶元素
TreeNode* node = st.top();
st.pop();

// node不为空,则按照右中左的顺序访问节点
if (node != NULL)
{
if (node->right) st.push(node->right); // 右节点
st.push(node); // 中节点
st.push(NULL); // 中节点访问过,但是还没有处理,加入空节点做为标记
if (node->left) st.push(node->left); // 左节点
}
// 只有遇到空节点的时候,才处理节点(将下一个节点放进结果集)
else
{
// 将空节点弹出,重新取出栈中的元素
node = st.top();
st.pop();
res.push_back(node->val); // 加入到结果集中
}
}
return res;
}
};

对于前序和后序遍历,只需要改变node不为空时访问节点的顺序即可。前序遍历原本的顺序是中左右,考虑到栈先入后出的特性,调整为右左中。后续遍历原本的顺序是左右中,考虑到栈先入后出的特性,调整为中右左。

实现

递归遍历

144. 前序遍历二叉树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
// 前序遍历递归写法的核心函数
void traversal(TreeNode* root, vector<int> &vec) // 递归函数的参数和返回值
{
if (root == NULL) return; // 确定终止条件

vec.push_back(root->val); // 中
traversal(root->left, vec); // 左
traversal(root->right, vec); // 右
}

// 相当于主函数,调用核心函数
vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;

traversal(root, res); // 调用核心函数
return res;
}
};

完整的,带有测试样例的代码为:

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
#include <iostream>
#include <vector>
using namespace std;

struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};

class Solution {
public:
void traversal(TreeNode* root, vector<int>& res) {
if (root == NULL) return;
res.push_back(root->val);
traversal(root->left, res);
traversal(root->right, res);
}

vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
traversal(root, res);
return res;
}
};

int main() {
// 构建树的示例代码,需要根据实际情况调整
TreeNode* node3 = new TreeNode(3);
TreeNode* node2 = new TreeNode(2, node3, nullptr);
TreeNode* root = new TreeNode(1, nullptr, node2);

Solution solution;
vector<int> res = solution.preorderTraversal(root);

// 打印结果
for (int val : res) {
cout << val << " ";
}
cout << endl;

// 释放分配的内存(在实际使用中,考虑使用智能指针自动管理内存)
delete node3;
delete node2;
delete root;

return 0;
}

145. 后序遍历二叉树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
void traversal(TreeNode* root, vector<int>& res)
{
if (root == NULL) return;

traversal(root->left, res); // 左
traversal(root->right, res); // 右
res.push_back(root->val); // 中
}

vector<int> postorderTraversal(TreeNode* root) {
vector<int> res;

traversal(root, res);
return res;
}
};

94. 中序遍历二叉树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
void traversal(TreeNode* root, vector<int>& res)
{
if (root == NULL) return;

traversal(root->left, res); // 左
res.push_back(root->val); // 中
traversal(root->right, res); // 右
}
vector<int> inorderTraversal(TreeNode* root) {
vector<int> res;

traversal(root, res);
return res;
}
};

迭代遍历

144. 前序遍历二叉树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 顺序为中左右,因为栈的先入后出的特性,所以代码顺序调整为中右左
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
stack<TreeNode*> st; // 用栈实现迭代,模拟递归
vector<int> res; // 结果数组

st.push(root);

while (!st.empty())
{
TreeNode* node = st.top();
st.pop();
if (node != NULL)
res.push_back(node->val);
else continue;

st.push(node->right); // 右
st.push(node->left); // 左
}
return res;
}
};

145. 后序遍历二叉树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 后序遍历:左右中。前序遍历:中左右 
// 中左右->中右左->左右中
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
stack<TreeNode*> st;
vector<int> res;

st.push(root);

while (!st.empty())
{
TreeNode* node = st.top();
st.pop();
if (node != NULL) res.push_back(node->val);
else continue;

st.push(node->left); // 左
st.push(node->right); // 右
}
reverse(res.begin(), res.end());
return res;
}
};

94. 中序遍历二叉树

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
// 中序遍历的迭代写法,参见总结部分的精髓即可写出以下的代码
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
stack<TreeNode*> st;
vector<int> res;
TreeNode* cur = root;

// 终止条件:指针和栈都为空
while (cur != NULL || !st.empty())
{
// 指针不为空,则将指针指向的节点放入栈中,指针向左走
if (cur != NULL)
{
st.push(cur);
cur = cur->left;
}
// 指针为空,则将栈顶元素弹出并放入结果数组中,指针向右走
else
{
cur = st.top();
st.pop();
res.push_back(cur->val);
cur = cur->right;
}
}
return res;
}
};

统一迭代

94. 中序遍历二叉树

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
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
stack<TreeNode*> st;
vector<int> res;

// 将非空的头节点插入栈中
if (root != NULL) st.push(root);

// 循环终止条件:栈为空
while (!st.empty())
{
// 弹出栈顶元素
TreeNode* node = st.top();
st.pop();

// 若node不为空,则按照右中左的顺序访问节点
if (node != NULL)
{
if (node->right) st.push(node->right); // 访问右节点
st.push(node); // 访问中节点
st.push(NULL); // 只访问没处理,在中结点上面添加NULL来标记
if (node->left) st.push(node->left); // 访问左节点
}
// 若node为空,则处理该节点下面的节点,将其加入到res中
else
{
node = st.top();
st.pop();
res.push_back(node->val);
}
}
return res;
}
};

144. 前序遍历二叉树

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
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> st;

if (root != NULL) st.push(root);

while (!st.empty())
{
TreeNode* node = st.top();
st.pop();

// node不为空,访问节点
if (node != NULL)
{
if (node->right) st.push(node->right); // 右
if (node->left) st.push(node->left); // 左
st.push(node); // 中
st.push(NULL); // 中节点后面插入NULL
}
// node为空,处理节点
else
{
node = st.top();
st.pop();
res.push_back(node->val);
}
}
return res;
}
};

145. 后序遍历二叉树

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
// 统一迭代写法
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> st;

if (root != NULL) st.push(root);

while (!st.empty())
{
TreeNode* node = st.top();
st.pop();

// 节点不为空,则访问节点,顺序为中右左
if (node != NULL)
{
st.push(node);
st.push(NULL);
if (node->right) st.push(node->right);
if (node->left) st.push(node->left);
}
// 节点为空,则处理节点
else
{
node = st.top();
st.pop();
res.push_back(node->val);
}
}
return res;
}
};

心得与备忘录

递归遍历

  1. 记住递归三部曲:
    • 确定递归函数的参数和返回值
    • 确定终止条件
    • 确定单层递归的逻辑
  2. 前序、中序、后序遍历的代码只有单层递归的逻辑部分有所不同。更确切地说,只是三行代码的顺序发生了改变。
  3. 递归的核心函数返回值为void,结果数组以引用的方式作为参数传入,经过核心函数的改变后,改变后的数组会被传出,在主函数中调用其即可。
  4. 易错点:核心函数中忘记加上引用&,插入数组时没有取root指针的值(root->val),而是直接将root指针传入了。
  5. 注意如何写出完整的带有测试样例的代码,这涉及到如何写struct TreeNodemain函数。
  6. 熟悉几个英文单词:
    • 遍历:traversal
    • 前序:pre-order、中序:in-order、后序:post-order、层序:level-order
    • 二叉树:binary tree
    • 递归:Recursion 迭代:Iteration

迭代遍历

  1. 迭代遍历的本质是用栈来模拟递归,用结果数组来收集结果。由于栈的先入后出的特性,前序遍历的顺序本来应该是中左右,迭代写法的顺序调整为中右左。后序遍历是在前序遍历的基础上颠倒右和左的顺序,再翻转结果数组(前序遍历=中左右->中右左->左右中=后序遍历)。
  2. 中序遍历的迭代写法不能像后序遍历那样从前序遍历迭代写法的基础上直接进行改造。这是因为在中序遍历中,遍历节点的顺序与和处理节点的顺序不同。
  3. 中序遍历的迭代写法需要一个指针来遍历所有节点,一个栈用于记录遍历过的节点,一个数组用于存放结果。
  4. 中序遍历迭代写法的精髓:当指针不为空时,用栈记录指针遍历过的元素,指针持续向左走。当指针为空时,从栈中弹出顶部的节点并将其放入结果数组中,然后指针向右走。当指针为空且栈为空时,终止。
  5. 统一迭代的写法可以将前中后序遍历的迭代写法统一起来。
  6. 迭代写法确实更复杂些,注意事项也更多,也更容易写错。了解思路即可,可以放过。

统一迭代

  1. 统一迭代的思路其实比较清晰:当头节点非空时,头节点入栈。在栈非空时,不断循环。弹出栈顶节点,若该节点不为空,则按照顺序访问节点,并在访问中节点之后插入NULL,作为标记(说明该节点只被访问过,没有被处理过);若该节点为空,则处理当前的栈顶节点(原本的栈顶节点已被弹出),将其放入结果数组中,并将其弹出。
  2. 对于前序、中序和后序遍历,只需要改变node不为空时访问节点的顺序即可。考虑到栈先入后出的特性:前序遍历原本的顺序是中左右,调整为右左中。中序遍历原本的顺序是左中右,调整为右中左。后续遍历原本的顺序是左右中,调整为中右左。
  3. 统一迭代思路清晰且对于前中后序遍历能够保持一致的写法,建议用迭代法遍历二叉树时,优先采用统一迭代的写法。前面的迭代遍历的一般写法虽然较为简单,但只能在前序和后序遍历时保持统一,在中序遍历时需要重新写。

链接

239. 滑动窗口最大值
347.前 K 个高频元素
栈与队列总结

知识

239. 滑动窗口最大值

347.前 K 个高频元素

栈与队列总结

初次尝试

239. 滑动窗口最大值

本题应该有些类似于滑动窗口的经典题目:209.长度最小的子数组。本题的思路:用一个长度始终为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
class Solution {
public:
// 得出队列中的最大值
int queuemax(queue<int> q)
{
int max = q.front();
while (q.size())
{
q.pop();
int tmp = q.front();
if (max < tmp) max = tmp;
}
return max;
}

vector<int> maxSlidingWindow(vector<int>& nums, int k) {
queue<int> q;
vector<int> res;

for (int i = 0; i < nums.size(); i ++ )
{
if (q.size() < k) q.push(nums[i]);
else if (q.size() == k)
{
res.push_back(queuemax(q));
q.push(nums[i]);
q.pop();
}
}
res.push_back(queuemax(q));
return res;
}
};

上述代码在输入数组不大时可以正常运行,但输入数组太大时会超时,测试样例通过了37 / 51。上述暴力做法的时间复杂度是O(n * k)。看代码随想录的视频讲解吧。

347.前 K 个高频元素

拿到这道题,我的第一想法是拿哈希去做。但发现哈希不能解决本题,因为对统计频率的数组排序后,数组的下标(即输入数组的元素)被打乱了。直接看卡尔的讲解。

实现

239. 滑动窗口最大值

本题是第一道hard。难点:如何求窗口中的最大值?暴力做法时间复杂度O(n k)。需要一个特殊的队列,实现pop, push和getMaxValue(返回当前队列中的最大值)这三个操作。还可以用一个优先级队列。cpp中的优先级队列就是大顶堆(单调递减)或者小顶堆(单调递增)。大顶堆本质就是一个排序后的二叉树,最大的元素在上面。始终维护大顶堆,让大顶堆不断插入元素和弹出元素,大顶堆最前面的元素就是滑动窗口的最大值。*但是用大顶堆是不行的,因为无法正确地弹出元素(大顶堆内部自动做了排序,打乱了原来元素的顺序)。

因此用优先队列是不行的,需要我们自行实现一个单调队列来解决问题。需要维持队列中的元素是单调递增或单调递减的,同时需要保证pop操作的正确。单调队列中只需要维护有可能成为最大值的元素,而不需要维护k个元素

模拟单调队列:
假设输入数组为13-1-35321。首先在队列中加入元素1,再加入3,若队列的前面有小于3的元素,则将这些元素全部弹出。这样做可以让队列的出口处就是最大值。由于随着滑动窗口的移动,本身就会舍弃第1个1,因此没必要维护3之前比3小的元素。接着在队列中加入-1。此时队列的前面没有小于-1的元素,故-1可以保留在队列中。此时取队首元素3,就是最大值。接着加入-3,队列前面的元素都大于-3,故保留-3,此时队列的最大值还是3。接着加入5,由于5比队列前面的元素都大,因此需要pop掉除5以外的全部元素,此时取队列的最大值,即队首元素,是5。接着放入3,3<5,放入3,此时队列的最大值还是5。接着加入2,2<5&&2<3,因此放入2,此时队列的最大值还是5。再向后移动,需要把最大值5pop掉,加入1,1<2 && 1<3,因此队列中加入1,此时队列的最大值是队首元素3。

单调队列在数组中移动的规则:除去常规的移动窗口的pop和push操作外,若push进来的元素比队列前面的元素都大,那前面的元素都要pop出去,直到队列前面的元素都大于push进来的元素为止。本方法的好处在于getMaxValue时取队首元素即可。

根据原理,我画出了如下的示意图,可以帮助理解(下图中打x的元素是因为单调队列的特殊规则:若push进来的元素比队列前面的元素都大,那前面的元素都要pop出去而被提前被删去的元素)。
Snipaste_2024-02-07_13-50-29.png

根据上述原理,我尝试写出相应的代码,但是有一个问题我始终无法解决:根据规则:若push进来的元素比队列前面的元素都大,那前面的元素都要pop出去,有时原本的队首元素已经被更新为最大的元素了,意味着滑动窗口本身最前面的元素已经被弹出了,但有时,滑动窗口本身最前面的元素还没有被弹出,它仍作为队首元素,需要手动弹出。如何判断是否需要手动弹出队首元素。我来看看卡尔的代码实现,看他是如何解决这个问题的。

卡尔写了单调队列的三个关键函数。单调队列是在双向队列的基础上实现的,双向队列的首尾都可以出入元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
deque<int> que; // cpp中队列默认用deque双向队列来实现,双向队列的首尾都可以出元素和入元素

void pop(int val)
{
// 只有当需要pop的元素和队首元素(单调队列中目前的最大值)相同时,才弹出队首元素
// 例如上图的倒数第二行到最后一行的操作(532->321)
// 若需要pop的元素小于队首元素,那么在push时该元素已经被删除了
// 例如上图中的-1-35->-353,本来需要手动删除-1,但由于-1<5,因此在push(5)时-1已经被删除了
if (!que.empty() && val == que.front())
que.pop_front();
}

void push(int val)
{
// 当队列不为空且要插入的值val > 队列中的最后一个元素时,持续从队尾弹出元素
// 例如上图中的3-1-3->-1-35,要插入5,持续从队尾弹出比5小的元素,然后再插入5
while (!que.empty() && val > que.back()) que.pop_back();
que.push_back(val);
}

int getMaxValue(deque<int> que)
{
return que.front();
}

在上述关键代码的基础上,我写下了解决本题的完整代码:

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
class Solution {
private:
class MyQueue
{
public:
deque<int> que;
// 单调队列中只维护当前队列中的最大值,作为队首元素
// 故窗口滑动时只需要在滑出最大值时手动删除最大值即可
// 其他值都会在push时被删除
void pop(int val)
{
if (!que.empty() && val == que.front()) que.pop_front();
}

// 保证单调队列从队首到队尾是单调递减的
// 新插入的元素若大于当前队列中的最大值,则删除当前队列,将插入的元素放在队列的首部
void push(int val)
{
while (!que.empty() && val > que.back()) que.pop_back();
que.push_back(val);
}

// 当前队列中的最大值为队首元素,故返回队首元素即可
int getMaxValue()
{
return que.front();
}
};

public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
MyQueue que;
vector<int> res;

for (int i = 0; i < k; i ++ )
que.push(nums[i]);

res.push_back(que.getMaxValue());

for (int i = k; i < nums.size(); i ++ )
{
que.pop(nums[i - k]);
que.push(nums[i]);
res.push_back(que.getMaxValue());
}
return res;
}
};

347.前 K 个高频元素

两个难点:

  • 如何求数组中每个元素的频率

  • 如何对这个频率进行排序,并求前k个高频的元素

用map来进行排序,key用来存放元素,value用来存放元素出现的次数。接着以value为基准做从大到小的排序(不好做,因为map默认是按照key的顺序来进行排序的),最后输出value对应的key即可。时间复杂度O(nlogn)

求前k个高频的元素,只需维护k个有序的集合,没必要对所有元素都进行排序。经典的数据结构:大顶堆、小顶堆。堆擅长在很大的数据集中求前k个高/低频的元素。大顶堆的根节点是最大的元素,小顶堆的根节点是最小的元素。

如何用堆解决这个问题?用堆去遍历map中的所有元素(以value为基准进行统计),堆中维持k个元素,遍历完map中的所有元素后,堆中的k个元素就是前k个高频元素。大/小顶堆?用大顶堆遍历map中的所有元素时,遇到新元素时,将其插入到大顶堆中,会弹出堆顶的元素(最大的元素),用大顶堆遍历完map后,堆中剩下的元素是前k个低频的元素因此要用小顶堆。小顶堆每次在push进来元素时,弹出堆顶元素(最小的元素),堆中剩下的就是最大的元素。最后输出前k个最大的value对应的key。时间复杂度:遍历数组O(n),每次堆中元素调整O(logk)(堆中有k个元素,堆是树状结构),总时间复杂度为O(nlogk)。在数组很大,k较小的情况下,本做法的性能明显优于对整个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
42
class Solution {
public:
class compare
{
public:
// 为定义小顶堆重载运算符,这里的函数名为operator(),而非operator
// 对大顶堆,本来应该是右边的元素>左边的元素,对小顶堆,则与此相反
bool operator() (pair<int, int> lhs, pair<int, int> rhs)
{
return lhs.second > rhs.second; // 比较的是元素出现的次数,而非元素的值
}
};

vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> map;

// nums[i]作为key, 出现此处作为value存入map中
for (int i = 0; i < nums.size(); i ++ )
map[nums[i]] ++ ;

// 定义小顶堆,其中的元素类型是pair<int, int>,底层实现是vector<pair<int, int>>
priority_queue<pair<int,int>, vector<pair<int, int>>, compare> q;

// 遍历map,不断将map中的元素插入小顶堆中,小顶堆不断弹出根节点处最小的元素
// 最后剩下的k个元素是出现频次最高的k个元素
for (auto it = map.begin(); it != map.end(); it ++ )
{
q.push(*it);
if (q.size() > k) q.pop();
}

vector<int> res(k);

// 根节点处是最小的元素,越往下元素越大,因此将小顶堆中的k个元素倒着插入res中
for (int i = k - 1; i >= 0; i -- )
{
res[i] = q.top().first; // 插入res的是key, 即nums[i]
q.pop();
}
return res;
}
};

心得与备忘录

239. 滑动窗口最大值

  1. 单调队列需要手动实现,cpp标准库中没有现成可用的单调队列。
  2. 单调队列的好处在于能够将当前队列中的最大值放在队首,且不改变其他值的排列顺序(即其他值在单调队列中的排列顺序和它们在输入数组中的排列顺序相同)。
  3. 单调队列的特殊规则:若push进来的元素比队列前面的元素都大,那前面的元素都要pop出去。
  4. 定义一个类,在双向队列的基础上实现单调队列,而不要试图在主函数中对queue<int>做加工。
  5. 本题的详细模拟流程见实现中的图片。
  6. 单调队列中定义了三个函数:pop, push和getMaxValue。
    对pop函数(负责模拟窗口的滑动,删除队首的最大值),需要注意:

    • 单调队列中只维护当前队列中的最大值,作为队首元素
    • 故窗口滑动时只需要在滑出最大值时手动删除最大值即可
    • 其他值都会在push时被删除

    对push函数(负责向单调队列中插入元素,同时调整队列中元素的顺序,将最大值置于队首),需要注意:

    • 保证单调队列从队首到队尾是单调递减的
    • 新插入的元素若大于当前队列中的最大值,则删除当前队列,将插入的元素放在队列的首部
    • 当前队列中的非最大值会在不断调用push函数的过程中被删除,最大值则需要pop函数来删除
  7. 时间复杂度: O(n) 空间复杂度: O(k)
    输入数组中的每个元素在单调队列中最多也就被插入和弹出各一次,没有任何多余操作,所以整体的时间复杂度还是O(n)。空间复杂度因为我们定义一个辅助队列,所以是O(k)。
  8. 本题之所以选择在双向队列的基础上加工出单调队列,是因为双向队列可以方便地在队首和队尾插入和删除元素。
  9. 注意类的写法、双向队列的push_back, push_front, pop_back, pop_front以及单调队列的push, pop等方法,不要写错或者混淆。

    347.前 K 个高频元素

  10. 本题的大致思路:用map来存储元素的值和出现次数,用一个小顶堆遍历map,最终获取出现次数最高的k个元素。
    map->priority_queue->vector

  11. 本题的思路:用map的key存储元素的值,value存储元素出现的次数。定义一个小顶堆。遍历map,不断将map中的元素插入到小顶堆中,不断弹出小顶堆根节点处的元素,最后小顶堆中剩下的k个元素就是出现次数最高的k个元素。将这k个元素倒着插入结果数组中即可。
  12. 为什么使用小根堆:小跟堆根节点处的元素最小,每次弹出元素也是从根节点处弹出,因此用大小为k的小根堆遍历完map后,小根堆中的k个元素是出现次数最高的k个元素,较小的元素在遍历的过程中就已经被弹出了。
  13. 注意小顶堆的定义方式:在优先级队列的基础上,传入的参数分别为小顶堆中存储的元素类型,小顶堆的底层实现,自定义的compare类(用于实现小顶堆根节点是最小元素的特性)。
  14. 注意如何写compare类,关键在于重载运算符。
  15. 注意vector数组是可以定义大小的,定义方式为vector<int> res(k),vector元素定义了大小之后,就能像普通数组那样用索引给元素赋值:res[i],插入元素的push_back函数并不是必须的。
  16. 根据小根堆的特点,res数组需要倒着插入,即从下标k - 1处开始插入。
  17. 定义类是,要记得在类中的函数前加上public,否则无法正常调用类中的函数。
  18. 本算法的时间复杂度是O(nlogk),好于对map全部排序的O(nlogn),在n较大,k较小时性能提升尤为明显。

栈与队列总结

  1. 栈和队列的理论基础:栈先入后出,队列先入先出
  2. 用两个栈实现队列,用一个队列实现栈
  3. 栈的应用:栈的应用相对较为简单且单一。栈特别适合处理对相邻字符需要做特殊判断的一些问题。比如相邻的括号匹配(20. 有效的括号)、相邻字符的消除(删除字符串中的所有相邻重复项)和后缀表达式中相邻数字的计算(逆波兰表达式求值)。
  4. 队列的应用:队列的应用更为复杂且多样,包括手动实现单调队列(239. 滑动窗口最大值)和手动实现大/小顶堆(优先级队列)347. 前k个高频元素)。对于队列的应用要多复习,这两题写起来都较为复杂。
  5. 单调队列不是一成不变的,而是不同场景不同写法。不要以为239. 滑动窗口最大值中的单调队列实现就是固定的写法。
  6. 栈里面的元素在内存中是连续分布的么?

    这个问题有两个陷阱:

    • 陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中不一定是连续分布的。
    • 陷阱2:缺省情况下,默认底层容器是deque,那么deque在内存中的数据分布是什么样的呢? 答案是:不连续的,下文也会提到deque。
  7. 拓展题:71. 简化路径
  8. 优先级队列就是大/小顶堆,名字虽为队列(因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列),但本质是完全二叉树。大顶堆(堆头是最大元素),小顶堆(堆头是最小元素)。从小到大排就是小顶堆,从大到小排就是大顶堆。大小顶堆和大小根堆是相同的。

链接

20. 有效的括号
1047. 删除字符串中的所有相邻重复项
150. 逆波兰表达式求值

知识

20. 有效的括号

  1. cpp中,将一个个字母存储在stack中,用stack<int>或者stack<char>都是一样的。若是stack<int>,则字符以ascii码的形式存储。
  2. 在Linux系统中,cd(change directory)命令用于更改当前工作目录。它确实可以借助栈的概念来理解路径的导航,尤其是处理相对路径时。

    考虑命令cd a/b/c/../../,这里我们可以将目录路径视作一个栈的操作序列:

    cd a:进入目录a,相当于将a压入栈。
    cd b:进入子目录b,相当于将b压入栈中a的上面。
    cd c:进入子目录c,相当于将c压入栈中b的上面。
    此时栈的状态为:

    1
    2
    3
    c
    b
    a

    然后遇到..,这代表上一级目录,相当于从栈中弹出最上面的元素。第一个..将c弹出。
    栈的状态更新为:

    1
    2
    b
    a

    第二个..将b弹出。
    最终栈的状态为:

    1
    a

    所以,最后当前工作目录是a。在这个过程中,我们可以将每个目录视为栈中的一个元素,每进入一个新的子目录就相当于压入一个元素,而每次使用..就相当于弹出一个元素,回到上一级目录。这就是栈数据结构在文件系统路径解析中的一个应用。

初次尝试

20. 有效的括号

不知道怎么做,直接看卡尔的讲解视频。

1047. 删除字符串中的所有相邻重复项

本题应该是一道用栈解决的经典问题。以输入s = “abbaca”为例,定义一个栈,遍历字符串,遍历到第一个字符时,判断栈顶元素是否与之相同,是,则弹出栈顶元素,否,则插入该字符。对后面的字符也是这样处理的。根据这个思路,我独立写出了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
string removeDuplicates(string s) {
stack<char> st;

for (int i = 0; i < s.size(); i ++ )
{
if (st.empty()) st.push(s[i]);
else if (s[i] == st.top()) st.pop();
else st.push(s[i]);
}
string out;
while (st.size())
{
out += st.top();
st.pop();
}
reverse(out.begin(), out.end());
return out;
}
};

需要特别注意:

  • 对栈做top操作时需要保证其非空,否则会报访问未知地址,导致程序非法访问内存的错误
  • 将栈中的一个个元素弹出并插入到一个字符串中后,需要将字符串颠倒顺序,输出才是正确的顺序

150. 逆波兰表达式求值

我看题后,暂时还没有解题思路。先看视频,了解本题的解题思路。

实现

20. 有效的括号

本题是用栈来解决的经典题目。栈的应用:编译器做词法分析、linux系统的命令。

不匹配的场景共有三个:

  1. 多出左括号
  2. 括号的类型不匹配
  3. 多出右括号

各种不匹配的场景都可被归为以上三类。

如何用栈结构解决三类不匹配的情形?

对1,从字符串的左边向右边遍历,遇到左括号,就将一个对应的右括号加入到栈中。当遍历到字符串的右括号时,若栈顶元素和右括号相同,则弹出栈顶元素。如果字符串遍历完了,但栈不为空,栈中还剩余右括号,就说明字符串中的左括号多了,不匹配。

对2,从左往右遍历字符串,遇到左括号,就在栈中加入一个对应的右括号。遇到右括号,将其与栈顶的元素比较,若不相同,则说明不匹配。

对3,从左往右遍历字符串,遇到左括号,就在栈中加入一个对应的右括号。遇到右括号,将其与栈顶的元素比较,相同则弹出栈顶的元素。若字符串还没遍历完,栈就空了,说明字符串的前面没有左括号与后面的右括号对应,说明多出了右括号,也不匹配。

字符串遍历完之后,栈是空的,就说明全都匹配了。

剪枝:字符串长度为奇数,一定会有不匹配的括号,直接return false即可。

看了卡尔的视频后,我写出了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
bool isValid(string s) {
stack<int> st; // stack<int>和stack<char>都可以

// 剪枝:字符串长度为奇数,一定不匹配
if (s.size() % 2) return false;

for (int i = 0; i < s.size(); i ++ )
{
if (s[i] == '(') st.push(')');
else if (s[i] == '[') st.push(']');
else if (s[i] == '{') st.push('}');
// 不匹配的两种情况:多出右括号和括号类型不匹配
// 两个判据不可颠倒,否则可能会出现栈为空但依然试图取栈顶元素的情况,编译器会报错
else if (st.empty() || s[i] != st.top()) return false;
// 栈不为空且栈的顶元素和s[i]相同,则弹出st的顶元素,两两抵消
else st.pop();
}
// 不匹配的情况:多出左括号
return st.empty();
}
};

1047. 删除字符串中的所有相邻重复项

本题用栈解决非常简单,用其他数据结构比较复杂。本题和20. 有效的括号是同一类问题。本题的主要思路:相邻的字母相同,就做消除的动作。

栈用来存遍历过的元素,同时帮助我们完成消除的动作。本题可以用一个字符串来模拟栈的行为,这样在输出时就不需要再把栈转换为字符串了。用字符串模拟栈时,可以让字符串的尾部作为栈顶,字符串的头部作为栈底,这样字符串中字符的顺序就是正确的。

根据以上原理,我写出了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
string removeDuplicates(string s) {
string out;

for (int i = 0; i < s.size(); i ++ )
{
if (out.empty()) out.push_back(s[i]);
else if (s[i] == out.back()) out.pop_back();
else out.push_back(s[i]);
}
return out;
}
};

可以将上述代码写的更为精简(将两种需要push_back()的情况合并为一种):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
string removeDuplicates(string s) {
string res;

for (int i = 0; i < s.size(); i ++ )
{
// 字符串为空或者字符串尾部的元素与s[i]不同时,直接在字符串的尾部插入s[i]
if (res.empty() || res.back() != s[i]) res.push_back(s[i]);
// 否则,意味着字符串尾部的元素和s[i]相同,则两两抵消,弹出字符串尾部的元素
else res.pop_back();
}
return res;
}
};

相比于我在初次尝试中空间复杂度为O(n)的做法,上面的做法空间复杂度是O(1),因为返回值不计空间复杂度。

150. 逆波兰表达式求值

什么是逆波兰表达式:是后缀表达式。后缀表达式是方便计算机来做运算的一种表达式。我们正常易于阅读的表达式是中缀表达式。例如(1+2)x(3+4)。画成二叉树的形式:

1
2
3
4
5
6
7
8
graph TD;
A[x] --> B[+];
A --> C[+];
B --> D[1];
B --> E[2];
C --> F[3];
C --> G[4];

后缀表达式就是上述二叉树的后序遍历后续遍历的顺序是左右中。因此后缀表达式是:12+34+x。二叉树的中序表达式是1+2x3+4。中序表达式若要得到正确的结果,需要加上括号。但后缀表达式我们不需要加任何括号,从头到尾遍历我们就可以计算出正确的结果。计算机只需顺序处理后缀表达式,即可得到计算结果,而不必担心括号优先级,这就是为什么说后缀表达式是方便计算机来做运算的一种表达式

计算机如何顺序处理后缀表达式?用栈。遍历后缀表达式时,遇到数字就将数字加入栈中,遇到运算符就从栈中取出元素来做运算,再把运算结果加入栈中。以上面的后缀表达式为例,先将1和2加入栈中,遇到+,则弹出2和1,算2+1=3,将3加入栈中。再将3和4加入栈中,遇到+,则弹出4和3,算4+3=7,将7加入栈中。遇到x,栈中弹出7和3,算7x3=21。最后将21加入栈中。后缀表达式的结果就是栈中最后的元素。

总结:两个数字遇到一个操作符时,也做消除操作,将合成的数字加入到栈中。栈适合做相邻字符的消除操作。

根据以上原理,我参照代码随想录的代码写出了如下的代码:

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 Solution {
public:
int evalRPN(vector<string>& s) {
stack<long long> st;

for (int i = 0; i < s.size(); i ++ )
{
if (s[i] == "+" || s[i] == "-" || s[i] == "*" || s[i] == "/")
{
// 注意采用long long类型
long long num1 = st.top();
st.pop();
long long num2 = st.top();
st.pop();
// 注意是先num2再num1
if (s[i] == "+") st.push(num2 + num1);
else if (s[i] == "-") st.push(num2 - num1);
else if (s[i] == "*") st.push(num2 * num1);
else st.push(num2 / num1);
}
else
st.push(stoll(s[i])); // stoi可以将字符串转换为int, stoll可以将字符串转换为long long
}
int res = st.top();
st.pop(); // 释放内存
return res;
}
};

本题关于数字的变量类型全部用int而不用long long,也可以通过评测。

心得与备忘录

20. 有效的括号

  1. 本题的思路:在字符串中遇到左括号就在栈中插入右括号,在字符串中遇到右括号则判断其能否与栈顶元素相消。
  2. 不匹配的三种情况:多出右括号、多出左括号、括号类型不匹配。
  3. 本题利用了栈的性质:后插入的元素先弹出,这与本题字符串中后出现的左括号必然有先出现的右括号与之匹配的题意相符。
  4. st.empty()s[i] != st.top()这两个判据顺序不可颠倒,否则会出现栈为空但依然试图取栈顶元素的情况,编译器会报错。
  5. 本题可以做剪枝优化:字符串长度为奇数,则必然不匹配。
  6. 栈用stack<int>或者stack<char>都可以。前者就是将字符存储为ascii码。

1047. 删除字符串中的所有相邻重复项

  1. 栈特别适合处理对相邻字符需要做特殊判断的一些问题。比如相邻的括号匹配和消除。
  2. 字符串类型的变量也有empty, back, pop_back, push_back等函数。
  3. 本题可以用字符串来模拟栈,这样返回时不需要将栈转换回字符串,且可以通过让字符串头部对应栈底,字符串尾部对应栈顶的方式,来让输出的字符串不需要调整顺序(即不需要reverse)
  4. 本题需要考虑三种情况:栈为空/栈顶元素和字符串中元素相同/不相同
  5. 函数的递归调用需要用到栈
  6. 一个函数的返回值不会被计入这个函数的空间复杂度,额外的空间占用才会被计入空间复杂度

150. 逆波兰表达式求值

  1. 栈适合用于做相邻两个字符的消除操作。
  2. 逆波兰表达式即为二叉树的后缀表达式。
  3. 后缀表达式由二叉树的后序遍历(按左右中的顺序)得到。
  4. 本题思路:遇到数字则将其插入栈中,遇到运算符就弹出栈中的两个数字,计算并将计算结果插入栈中。
  5. 注意:运算时先num2(后弹出的数字,二叉树的左子节点)后num1(先弹出的数字,二叉树的右子节点)。
  6. stoi可以将字符串转换为int,stoll可以将字符串转换为long long。
  7. 本题无需采用long long类型变量,用int类型变量就可以通过测评。

链接

栈与队列理论基础
232.用栈实现队列
225. 用队列实现栈

知识

栈与队列理论基础

顾名思义,队列是先进先出,栈是先进后出(可以从顶部添加元素,也可以从顶部移除元素,但是不能从中间或底部添加或移除元素)。

栈与队列理论1

栈和队列是STL(C++标准库)里面的两个数据结构。STL有多个版本,其中有三个版本最为普遍。我们介绍的栈和队列是三个版本中的SGI STL里面的数据结构。知道版本才确定其底层实现。

栈:先进后出
栈与队列理论2

栈提供push和pop等等接口,时间复杂度都是O(1),所有元素必须符合先进后出规则(只能在顶部添加和移除元素),所以栈不提供走访功能,也不提供迭代器(iterator)。 不像是set 或者map提供迭代器iterator来遍历所有元素。

我们可以用多种容器来实现栈的功能,栈的底层实现可以是vector,deque(双端队列),list(双向链表)都是可以的, 主要就是数组和链表的底层实现。

我们常用的SGI STL,如果没有指定底层实现的话,默认是以deque为缺省情况下栈的底层结构(deque是容器)。deque是一个双向队列,只要封住一段,只开通另一端就可以实现栈的逻辑了SGI STL中 队列底层实现缺省情况下一样使用deque实现的。也可以指定vector为栈的底层实现,初始化语句如下:

1
2
3
// 第一个参数int:指定了栈中元素的类型
// 第二个参数std::vector<int>:指定了底层容器的类型及其元素类型。即使用一个整型向量来存储栈中的元素。
std::stack<int, std::vector<int> > third; // 使用vector为底层容器的栈

通过允许指定底层容器,std::stack提供了灵活性,可以根据不同的性能需求或使用场景来选择最合适的容器类型。例如,std::vector提供了随机访问的能力,但是在容器前端添加或删除元素可能较慢,而std::deque在容器的前端和后端添加或删除元素都较快,但不支持快速随机访问。选择哪种底层容器取决于你的具体需求。

队列是先进先出的数据结构,同样不允许有遍历行为,不提供迭代器, SGI STL中队列一样是以deque为缺省情况下的底部结构。

也可以指定list 为起底层实现,初始化queue的语句如下:

1
std::queue<int, std::list<int>> third; // 定义以list为底层容器的队列

STL队列和栈都不被归类为容器,而被归类为container adapter(容器适配器)。因为可以用不同的容器来实现栈和队列的功能,因此栈和队列不对应于某个特定的容器

初次尝试

232.用栈实现队列

yxc讲过的应该是用数组来实现栈和队列,并没有见过怎么用栈来实现队列。直接看卡尔的讲解。

225. 用队列实现栈

我想了想,没想出什么好办法,听卡尔讲吧。

实现

232.用栈实现队列

不涉及具体的算法,考察对栈和队列的基本操作。向队列中插入元素123,则队列吐出元素的顺序是123。向栈中插入元素123,则栈吐出元素的顺序是321。若想用栈实现队列,就需要两个栈,一个栈用于存储元素,另一个栈用于改变第一个栈中元素出栈的顺序。第一个栈吐出元素的顺序是321,将它们依次插入第二个栈中,则第二个栈吐出元素的顺序是123。第一个栈被称为入栈,第二个栈被称为出栈。

tstmp_20240205061240

入栈中不要有滞留元素的行为,一旦需要弹出元素,就把入栈中的所有元素全部放入出栈中,让出栈实现元素的弹出。如果没有把入栈中的所有元素全部放入出栈,则出栈中弹出元素的顺序会与队列弹出元素的顺序不同。

本题pop函数的实现需要特别注意。若出栈为空,则将入栈中的所有元素加入到出栈中。peek和pop方法大部分代码都是重复的,可以在peek中直接调用pop方法:result = this->pop();。此时第一个元素被获取的同时也被弹出了,因此需要将其插入回去:stackOut.push(result)(peek方法只需要查询元素的数值,不需要像pop函数那样弹出元素)。参考视频讲解中的伪代码,我写出了以下的代码:

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
class MyQueue {
public:

stack<int> inStack; // 入栈
stack<int> outStack; // 出栈

MyQueue() {
}

void push(int x) {
// 向入栈中插入元素即可
inStack.push(x);
}

int pop() {
// 若出栈为空,则将入栈中的所有元素全部加入到出栈中
// 如果没有把入栈中的所有元素加入到出栈中,则弹出元素的顺序会发生错误
// 若出栈不为空,则跳过if判断部分,直接执行本函数最后三行代码
if (outStack.empty())
{
while (inStack.size())
{
int tmp = inStack.top();
inStack.pop();
outStack.push(tmp);
}
}

// 返回出栈顶部的元素并将该元素弹出
int res = outStack.top();
outStack.pop();
return res;
}

int peek() {
// 复用上面实现的pop函数
int res = this->pop();
// 由于pop函数弹出了出栈顶部的元素,peek函数只需要查询出栈顶部的元素,不需要弹出
// 因此将该元素插入回出栈中
outStack.push(res);
return res;
}

bool empty() {
// 入栈和出栈同时为空时,队列才为空
// 若只有入栈为空,则出栈中依然有元素没有弹出,说明队列还可以弹出元素,不为空
// 若只有出栈为空,则入栈中依然有元素可以加入出栈中,之后出栈还可以继续弹出元素,故队列也不为空
if (inStack.empty() && outStack.empty()) return true;
else return false;
}
};

225. 用队列实现栈

两个栈才能实现一个队列。虽然两个队列可以模拟栈,但重点讲一个队列模拟栈的进元素和出元素

用两个队列模拟栈:假设栈中先后插入元素123,则栈弹出元素的顺序为321。那么我们可以在队列1中先插入123,然后将1和2放入队列2中,然后从队列1中弹出元素3。接着若想让队列1弹出元素2,则将队列2中的元素2放入队列1中即可。详细讲解见代码随想录网站。

用一个队列模拟栈:在队列中先加入123,然后取出元素1,加入队列中;再取出元素2,加入队列中,此时队列弹出的元素就是3。推广:队列中有size个元素,先弹出(size - 1)个元素,再将它们加入队列中,再弹出队列中剩余的最后一个元素即可

根据上述原理,我独立写出了以下的代码:

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
class MyStack {
public:
queue<int> q;

MyStack() {
}

void push(int x) {
q.push(x);
}

int pop() {
int count = q.size(); // 队列中有size个元素
// 循环(size - 1)次
// 先弹出队首的元素,再将其加入到队尾中
while (count > 1)
{
int tmp = q.front();
q.pop();
q.push(tmp);
count -- ;
}
// 弹出队首的元素,即为最后插入的元素
int res = q.front();
q.pop();
return res;
}

// 复用pop函数,但是由于本函数只需要实现查询元素的功能,要记得将弹出的元素插入回去
// 也可直接return q.back()。因为栈顶的元素就是队列尾部的元素(队列中,从front弹出元素,从back插入元素)
int top() {
int res = this->pop();
q.push(res);
return res;
}

// 队列为空,则栈也为空
bool empty() {
return q.empty();
}
};

更简洁的写法:

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
class MyStack {
public:
queue<int> q;

MyStack() {

}

void push(int x) {
q.push(x);
}

int pop() {
int size = q.size();
size -- ;
while (size -- )
{
q.push(q.front());
q.pop();
}
int res = q.front();
q.pop();
return res;
}

int top() {
return q.back();
}

bool empty() {
return q.empty();
}
};

用两个队列que1和que2实现队列的功能,que2其实完全就是一个备份的作用,把que1最后面的元素以外的元素都备份到que2,然后弹出最后面的元素,再把其他元素从que2导回que1。据此原理,我写下了如下的代码:

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
class MyStack {
public:
queue<int> q1;
queue<int> q2;

MyStack() {

}

void push(int x) {
q1.push(x);
}

int pop() {
// 将除去队尾的元素的其他元素全部加入到q2中
int size = q1.size();
size -- ;
while (size -- )
{
q2.push(q1.front());
q1.pop();
}
// 收获答案
int res = q1.front();
// 将q2赋给q1
q1 = q2;
// 清空q2
while (q2.size()) q2.pop();
return res;
}

int top() {
return q1.back();
}

bool empty() {
return q1.empty() && q2.empty();
}
};

心得与备忘录

232.用栈实现队列

  1. 注意stack内置的pop函数不会返回被移除的元素的值。
  2. 实现pop函数时:出栈为空,则插入入栈中的所有元素;出栈不为空,则直接弹出出栈中的首元素。
  3. 一旦需要弹出元素,就把入栈中的所有元素全部放入出栈中,否则出栈中弹出元素的顺序会与队列弹出元素的顺序不同。
  4. 入栈和出栈都为空时,模拟的队列才为空。
  5. 取出栈顶元素再弹出栈顶元素的实现,都是先int tmp = stack.top(),再stack.pop()

225. 用队列实现栈

  1. 本题的关键在于如何弹出元素。
  2. 队列中,从front弹出元素,从back插入元素。取出队列尾部的元素:queue.back(),取出队列头部的元素:queue.front()
  3. 掌握一个队列实现栈的方法即可,两个队列实现栈更加复杂。

链接

28. 实现 strStr()
459.重复的子字符串
字符串:总结篇
双指针总结篇

知识

KMP算法理论

KMP与解决的问题

KMP:发明本算法的三位学者的英文名首字母

应用:字符串匹配问题

经典例子:

  • 给出文本串: aabaabaaf

  • 给出模式串: aabaaf
    求在文本串中是否出现过模式串

暴力方法与KMP

暴力解法:两重for循环,先遍历文本串,再遍历模式串,挨个匹配。从文本串的首位开始,若模式串不匹配文本串,则将模式串后移一位,直到匹配上。时间复杂度O(m * n),m和n分别是文本串和模式串的长度。

KMP算法:跳到之前已匹配的地方,继续匹配。

前缀表的由来

KMP算法如何知道我们之前已匹配过哪些,且跳到已匹配的内容后面继续匹配?

前缀表有什么特性,可以让我们找到之前已匹配过的内容?

在f处不匹配,找到f前面子串的后缀是aa,找到与该后缀相等的前缀的后面开始匹配。故我们要求一个字符串中的最长相等前后缀,重新匹配时跳到最长前缀之后开始匹配。

前缀与后缀

前缀:包含首字母,不包含尾字母的所有子串。

后缀:包含尾字母,不包含首字母的所有子串。

最长相等前后缀

最长相等的前缀和后缀的长度。以模式串aabaaf为例。

子串 前缀 后缀 最长相等的前缀和后缀的长度
a 0
aa a a 1
aab a, aa b, ab 0
aaba a, aa, aab a, ba, aba 1
aabaa a, aa, aab, aaba a, aa, baa, abaa 2
aabaaf a, aa, aab, aaba, aabaa f, af, aaf, baaf, abaaf 0

得到模式串的前缀表:010120。

使用前缀表的匹配过程

模式串 aabaaf
前缀表 010120
发现f不匹配,要找f前的最长相等前后缀,由前缀表得到最长相等前后缀为2。2意味着有一个后缀aa,前面也有一个与之相等的前缀aa。在后缀aa的后面不匹配了,就要从与后缀相等的前缀的后面继续开始匹配。最长相等前后缀为2,故从s[2] = 'b'处开始重新匹配。

next数组

next/prefix都可以用来表示前缀表。在遇到不匹配的地方,next数组告诉我们要回退到哪里。前缀表为010120,对其的处理包括:右移/统一减一。不处理前缀表,就将其作为next数组,依然可以完成KMP算法。

总结

KMP算法->能解决哪些问题->为什么KMP算法匹配失败后可以跳到某个位置->前缀表->前缀表的特性及如何求取前缀表->用前缀表完成一次匹配的操作->实现KMP算法时,有时对前缀表统一减一,有时右移,这不设计KMP算法原理性的东西,只是实现上方法不同而已。

KMP算法的代码实现

next数组不同的实现方式

模式串:aabaaf

文本串:aabaabaaf

前缀表:010120,用next数组表示。

如何求next数组?

  • 有人会把前缀表右移,第一位放上-1,得到-101012,作为next数组。
  • 有人会把前缀表整体-1,得到-10-101-1,作为next数组。
  • 有人会直接拿前缀表做Next数组。
    上述实现方式都可以,但具体处理逻辑会略有差别。

模式串与文本串在模式串的最后一位f处发生了冲突,看f的前一位的前缀表的值是多少,发现是2,于是跳转到下标为2的位置,即b。如果next数组是前缀表右移得到,就直接比较f对应的next数组的值,发现是2,于是也跳转到b的位置。若next数组是前缀表-1得到,那么就把f的前一位的next数组的值+1,依然跳转到b的位置。

next数组的核心:遇到冲突向前回退。本节我们就拿前缀表作为next数组

求Next数组的具体代码

共4步:

  1. 初始化
  2. 处理前后缀不相同的情况
  3. 处理前后缀相同的情况
  4. 更新next数组的值
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
// 传入的参数:next数组,我们需要对其进行赋值,模式串s
void getNext(vector<int> next, string s)
{
// 初始化
// 指针i:指向后缀末尾位置
// 指针j:指向前缀末尾位置,还代表着i之前(包括i)的子串的最长相等前后缀的长度
int j = 0; // 前缀从0开始
int next[N];
next[0] = 0; // next数组初始位置为0

// 比较前后缀所对应的字符是否相等,故i从1开始,这样i和j才能进行比较
for (int i = 1; i < s.size(); i ++ )
{
// 处理前后缀末尾不相同的情况
// 因为在一次循环中,i相当于是固定不动的,所以此时j回退
// j回退到next[j - 1]指向的位置,即遇见冲突,就看next数组(即前缀表)的前一位
// 不止回退一步,而要连续回退,不能写if,而要写while
while ( j > 0 && s[i] != s[j]) j = next[j - 1]; // 因为要求j - 1 >= 0,因此要求j > 0

// 处理前后缀相同的情况
// j代表着i之前(包括i)的子串的最长相等前后缀的长度
if (s[i] == s[j]) j ++ ;

// 更新next数组,在其中存储i之前(包括i)的子串的最长相等前后缀的长度
next[i] = j;
}
}

模拟运行过程

当j指向s[1],i指向s[2]时,前后缀不匹配,此时next[j - 1] = next[0] = 0,j回退到s[0],再次比较前后缀是否匹配,发现仍不相同,此时j无法继续回退,我们就更新next数组的值,next[2] = 0,这就代表i = 2之前包括i的子串的最长相等前后缀为0,这与表格中的结果相同。此时i后移一位,指向s[3],有s[3] == s[0],j ++,j = 1,next[3] = 1,说明aaba的最长相等前后缀长度是1,这与表格中的结果相同。进入下一轮循环,i = 4,同理。最终用getNext函数完成了对next数组的求值。

总结

用上述函数,求得了next数组,即前缀表。

28. 实现 strStr()

求next数组的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 传入的参数:next数组,我们需要对其进行赋值,模式串s
void getNext(vector<int> next, string s)
{
int j = 0; // 前缀从0开始
int next[N];
next[0] = 0; // next数组初始位置为0

// 比较前后缀所对应的字符是否相等,故i从1开始,这样i和j才能进行比较
for (int i = 1; i < s.size(); i ++ )
{
while ( j > 0 && s[i] != s[j]) j = next[j - 1]; // 因为要求j - 1 >= 0,因此要求j > 0

if (s[i] == s[j]) j ++ ;

// 更新next数组,在其中存储i之前(包括i)的子串的最长相等前后缀的长度
next[i] = j;
}
}

用next数组做匹配的代码(文本串s,模式串t):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int j = 0; // 因为next数组里记录的起始位置为0
for (int i = 0; i < s.size(); i++) { // i从0开始,遍历文本串
while(j > 0 && s[i] != t[j]) { // 不匹配, j - 1 >= 0 => j > 0
j = next[j - 1]; // j 寻找之前匹配的位置
}
if (s[i] == t[j]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
// j指向了模式串t的末尾,那么就说明模式串t完全匹配文本串s里的某个子串了
if (j == t.size()) { // 文本串s里出现了模式串t
// 返回当前在文本串匹配模式串的位置i-模式串的长度 + 1,就是文本串字符串中出现模式串的第一个位置(位置从0开始)
return (i - t.size() + 1);
}
}

完整代码:

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
class Solution {
public:
void getNext(int* next, const string& s) {
int j = 0;
next[0] = 0;
for(int i = 1; i < s.size(); i++) {
while (j > 0 && s[i] != s[j]) {
j = next[j - 1];
}
if (s[i] == s[j]) {
j++;
}
next[i] = j;
}
}
int strStr(string haystack, string needle) {
// 特殊情况,模式串长度为0,则返回0,本处是一个易错点
if (needle.size() == 0) {
return 0;
}
// 定义next数组
int next[needle.size()];
// 填充next数组
getNext(next, needle);
// 用next数组做匹配
// j指向模式串,i指向文本串
int j = 0;
for (int i = 0; i < haystack.size(); i++) {
while(j > 0 && haystack[i] != needle[j]) {
j = next[j - 1];
}
if (haystack[i] == needle[j]) {
j++;
}
if (j == needle.size() ) {
return (i - needle.size() + 1);
}
}
// 模式串匹配不上文本串,则返回-1
return -1;
}
};

n为文本串长度,m为模式串长度

  • 时间复杂度: 生成next数组,时间复杂度是O(m);根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n)。所以总共的时间复杂度为O(n + m)
  • 空间复杂度: 开辟空间用于存储next数组,即模式串的前缀表,因此是O(m)

459.重复的子字符串

暴力解法

枚举所有的子串,看能否构成字符串。

1
2
for 子串结束位置
for 子串与主串比较

时间复杂度O(n^2)。目标子串的开始位置必然是主串最前面的元素,因此只需要枚举子串的结束位置即可。

移动匹配

设一个可由重复的子串构成的字符串为s,那么两个s拼接起来,前一个s的后半部分和后一个s的前半部分又可以构成一个新的字符串s。s由重复子串构成的判据:两个s相加起来,若其中出现了s,那么s就是由重复子串构成的。

注意:在(s + s)中去搜索s时,一定要把(s + s)的首元素和尾元素删去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
bool repeatedSubstringPattern(string s) {
string ss = s + s;
ss.erase(ss.begin());
ss.erase(ss.end() - 1);

// string::npos 是 std::string 类型的一个静态成员常量,表示字符串中不存在匹配的位置。在 find() 函数中,如果没有找到匹配的子串,则返回 string::npos。这个值通常是一个很大的无符号整数,表示找不到匹配的位置。
if (ss.find(s) != string::npos)
return true;
else
return false;
}
};

find函数的实现其实就是28. 实现strStr()。若用KMP实现find函数,那么时间复杂度是O(m + n),find函数的其他实现方法时间复杂度大抵也是O(m + n)。

KMP解法

KMP算法的应用场景:模式串是否在文本串中出现过,即上面的find函数的一种实现方式。

前缀:不包含尾字母,一定包含首字母的所有子串。
后缀:包含尾字母,不包含首字母的所有子串。

结论:若s由重复子串组成,那么它的最小重复单位就是它的最长相等前后缀不包含的那个子串。后缀不包含和前缀不包含的部分是相同的,都是最小重复子串。

举例:abababab,最长相等前缀是ababab,最长相等后缀是ababab,剩余的部分ab即为最小重复子串。
图三

推导:设原字符串是s,标出其下标;设最长相等前缀是t,最长相等后缀是f,也分别标出下标。利用最长相等前缀和最长相等后缀的下标之间的对应关系和最长相等前后缀和原字符串下标之间的对应关系推导即可。

实现:设s存在最小重复单位,len为s的长度,则next[len - 1]为s的最长相等前后缀的长度,最小重复单位的长度为:len - next[len - 1],若该长度能被原字符串的长度整除:len % (len - next[len - 1]) == 0,那么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
26
27
28
29
30
31
32
33
34
class Solution {
public:
// 求next数组
void getNext (int* next, const string& s){
// 初始化
next[0] = 0;
int j = 0;
for(int i = 1;i < s.size(); i++){
// 处理前后缀不相同的情况
while(j > 0 && s[i] != s[j]) {
j = next[j - 1];
}
// 处理前后缀相同的情况
if(s[i] == s[j]) {
j++;
}
// 更新next数组
next[i] = j;
}
}
bool repeatedSubstringPattern (string s) {
if (s.size() == 0) {
return false;
}
int next[s.size()];
getNext(next, s);
int len = s.size();
// 核心代码
if (next[len - 1] != 0 && len % (len - (next[len - 1] )) == 0) {
return true;
}
return false;
}
};

初次尝试

28. 实现 strStr()

直接听卡尔讲,尝试去理解,不要求独立写出代码。

459.重复的子字符串

直接听卡尔讲,尝试去理解,不要求独立写出代码。

实现

28. 实现 strStr()

因为KMP算法很难,大家别奢求一次就把kmp全理解了,大家刚学KMP一定会有各种各样的疑问,先留着,别期望立刻啃明白,第一遍了解大概思路,二刷的时候,再看KMP会懂很多。或者说大家可以放弃一刷可以不看KMP,今天来回顾一下之前的算法题目就可以。

因为大家算法能力还没到,细扣很难的算法,会把自己绕进去,就算别人给解释,只会激发出更多的问题和疑惑。所以大家先了解大体过程,知道这么回事, 等自己有算法基础和思维了,在看多看几遍视频,慢慢就理解了。

459.重复的子字符串

本题算是KMP算法的一个应用,不过对KMP了解不够熟练的话,理解本题就难很多。

建议是 KMP和本题,一刷的时候 ,可以适当放过,了解怎么回事就行,二刷的时候再来硬啃

心得与备忘录

28. 实现 strStr()

  1. 我觉得KMP算法很想一种特殊的双指针算法。一个指针i用于遍历文本串,另一个指针j用于根据next数组的指引在模式串中移动。这种双指针算法可以把时间复杂度从暴力算法的O(n * m)优化为O(n + m)。
  2. 代码分为独立的两部分,第一部分是求next数组(即前缀表),第二部分是同时在两个字符串中移动指针并使用next数组。
  3. 当模式串为空时,应当返回0。因为空字符串被认为是任何字符串的子串,所以文本串中最开始与模式串匹配的字符的索引就是0。如果文本串中不存在与之匹配的模式串,则返回 -1。

459.重复的子字符串

  1. 本题有两种解法:移动匹配和KMP解法。如果忘记了KMP算法的next数组怎么写,可以使用移动匹配方法(最难写的KMP部分可以用find函数来代劳)。
  2. 注意string中find函数的用法:在 find() 函数中,如果没有找到匹配的子串,则返回 string::npos。
  3. 本题的KMP解法的关键在于结论:若s由重复子串组成,那么它的最小重复单位就是它的最长相等前后缀不包含的那个子串。

总结

字符串总结

常见题型和常用方法:

  1. 花式反转字符串(整体&局部反转,有时还要搭配双指针算法删除空格)
  2. 后续处理字符串
  3. KMP算法(匹配模式串和文本串)
  4. 移动匹配(核心也是KMP算法,只不过核心由库函数find实现)

小知识:

  1. substr,split,reverse, erase这四个库函数的时间复杂度都是O(n),在循环中使用会使得程序的时间复杂度达到O(n^2)。此时需要双指针算法等进行优化。
  2. 字符串本质为字符数组,数据结构基本等用于普通数组,因此普通数组中常用的双指针算法也常用于字符串中。

双指针总结

双指针应用于:

  1. 数组:移除元素
  2. 字符串:反转字符串、替换数字、翻转字符串里的单词
  3. 链表:反转链表、环形链表II
  4. 哈希表章节:三数之和、四数之和,两数之和若要求返回两数的值而非索引,也可以用双指针做,代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    vector<int> twoSum(vector<int> nums, int target)
    {
    vector<int> res; // 存储结果
    sort(nums.begin(), nums.end()); // 先排序

    int left = 0, right = nums.size() - 1;

    while (left < right)
    {
    if (nums[left] + nums[right] > target) right -- ;
    else if (nums[left] + nums[right] < target) left ++ ;
    else
    {
    res.push_back({nums[left], nums[right]}); // 收获答案
    // 去重
    while (left < right && nums[left] == nums[left + 1]) left ++ ;
    while (left < right && nums[right] == nums[right - 1]) right -- ;
    // 寻找新的答案
    left ++ ;
    right -- ;
    }
    }
    return res;
    }

    上述代码本质上就是三数之和、四数之和的一部分(for循环中的while循环)。相比于两数之和的哈希写法,新增了值去重的功能。

    双指针的题目中,以三数、四数之和以及反转链表最容易写错,一定要多复习。三数、四数之和的易错点在于剪枝、去重和求四数之和时的int类型变量的溢出。反转链表的易错点在于搞错了tmp, cur->next, prev和cur更新的先后顺序。

链接

344.反转字符串
541. 反转字符串II
卡码网:54.替换数字
151.翻转字符串里的单词
卡码网:55.右旋转字符串

知识

541. 反转字符串II

一般来说,编程语言自己实现的库函数都是左闭右开的,因此reverse(s, i, i + k)表示的是反转字符串s的第i位到第i + k位,不包含第i + k位。

卡码网:54.替换数字

  1. 注意,cpp中比较大小不能写作48 <= s[i] <= 57,而是要写作s[i] >= 48 && s[i] <= 57。表达式48 <= s[i] <= 57实际上会先计算48 <= s[i],这个表达式的结果是一个布尔值truefalse,在C++中,这个布尔值会被隐式转换为整数,true转换为1false转换为0。然后,该整数(01)会与57进行比较,所以条件几乎总是为真(除非s[i]是字符'0')。
  2. 扩容字符串的函数为resize函数。
  3. cpp中是可以不遍历字符串中的每个字符,就直接cout输出整个字符串的。
  4. 字符串和数组的区别(摘自代码随想录):
    字符串是若干字符组成的有限序列,也可以理解为是一个字符数组,但是很多语言对字符串做了特殊的规定,接下来我来说一说C/C++中的字符串。
    在C语言中,把一个字符串存入一个数组时,也把结束符 ‘\0’存入数组,并以此作为该字符串是否结束的标志。
    在C++中,提供一个string类,string类会提供 size接口,可以用来判断string类字符串是否结束,就不用’\0’来判断是否结束
    那么vector< char > 和 string 又有什么区别呢?
    其实在基本操作上没有区别,但是string提供更多的字符串处理的相关接口,例如string 重载了+,而vector却没有。所以想处理字符串,我们还是会定义一个string类型。
  5. 若要求某个字符在0-9之间,既可以写s[i] >= 48 && s[i] <= 57(’0’的ascii码是48,’1’的ascii码是57),也可以写s[i] >= '0' && s[i] <= '9'

151.翻转字符串里的单词

  1. erase函数的时间复杂度是O(n)
  2. 本题可以用split函数来从字符串中分割出单词,但那样就失去了意义
  3. 若给一个函数传入的参数加上引用&,那么在函数中对这个参数进行了修改,调用该函数后该参数也会被修改。

卡码网:55.右旋转字符串

  1. 注意:若在ACM模式中调用reverse函数,必须#include <algorithm>,否则会报错。但若调用swap函数,不需要引用任何头文件,直接使用即可。

初次尝试

344.反转字符串

先尝试用reverse函数秒杀,顺便复习reverse函数的用法:

1
2
3
4
5
6
class Solution {
public:
void reverseString(vector<char>& s) {
reverse(s.begin(), s.end());
}
};

reverse函数相当于把字符串反转以后,将新的字符串存入了旧的字符串中。

我曾经做过反转链表的题,猜测用双指针可以解决这道题。写下了以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
void reverseString(vector<char>& s) {
for (int l = 0, r = s.size() - 1; l < r; l ++ , r -- )
{
// swap(s[l], s[r]);
int tmp = s[l];
s[l] = s[r];
s[r] = tmp;
}
}
};

直接用swap函数或者手写swap函数都是可以的。l < r或者l <= r都可以。因为字符串中字符的个数为奇数时,中间那个字符交换不交换都一样;字符个数为偶数时,交换最后两个成对的字符即可。

541. 反转字符串II

拿到这道题,我的第一想法是分类讨论。设字符串的长度是len。若len < k,则全部反转;若k <= len < 2k,则反转前k个字母;若len >= 2k,则按照题意反转。本题在反转的逻辑上没有困难,但问题在于如何分割出需要反转的子字符串。我没想出来什么好办法,写的逻辑太复杂又容易出错。

卡码网:54.替换数字

我先输入字符串s,然后定义每个元素由char类型变量组成的vector。遍历字符串s,若其中的某个字符的ascii码在48-57之间,说明该字符是数字0-9,那么向vector中依次插入number这6个字符。其他情况下,向vector中插入原始字符即可。据此思路写下以下的代码,可以通过评测。

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
# include <iostream>
# include <vector>

using namespace std;

int main()
{
string s;
cin >> s;

vector<char> out;

for (int i = 0; i < s.size(); i ++ )
{
if (s[i] >= 48 && s[i] <= 57)
{
out.push_back('n');
out.push_back('u');
out.push_back('m');
out.push_back('b');
out.push_back('e');
out.push_back('r');
}
else
out.push_back(s[i]);
}

for (int i = 0; i < out.size(); i ++ )
cout << out[i];
cout << endl;
return 0;
}

151.翻转字符串里的单词

这道题yxc应该讲过,要么通过流的方式读入为一个个单词,样例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <string>
#include <sstream> // 引入 stringstream

using namespace std;

int main()
{
string line;
getline(cin, line); // 使用 getline 读取一整行

stringstream ss(line); // 使用 stringstream 来分割字符串
string word;
while (ss >> word) { // 从 stringstream 中读取单词,直到结束
cout << word << endl; // 输出单个单词
}

return 0;
}

要么通过双指针算法找出一个个单词并存储之。然后再将一个个单词逆序拼接为字符串并输出。我先尝试后一种方法。但没有做出来。

卡码网:55.右旋转字符串

本题我下意识地使用substr来写,得到如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <string>

using namespace std;

int main()
{
int k;
string s;
cin >> k >> s;

string s1 = s.substr(s.size() - k, s.size()); // 后面k个字符
string s2 = s.substr(0, s.size() - k); // 字符串在后面k个字符前的字符
cout << s1 + s2 << endl;
}

向substr中传入的区间是左闭右开的。

若不借助库函数,我还有一个想法。先拿一个字符串保存输入字符串的后面k个字符。然后在输入字符串的基础上,从尾部倒着插入前面的那些字符,最后再将另一个字符串保存的原字符串的后面k个字符插到新字符串的前面去。其实倒着插入和顺着插入也没什么区别。

我还想到一种做法。受到151. 翻转字符串里的单词启发,首先反转整个字符串,然后反转字符串的前k位,最后反转字符串的后(n - k)位。由此写出了两个版本的代码,第一版是直接调用reverse函数,第二版是手动实现reverse函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
#include <algorithm>

using namespace std;

int main()
{
int k;
string s;
cin >> k >> s;

reverse(s.begin(), s.end());
reverse(s.begin(), s.begin() + k);
reverse(s.begin() + k, s.end());
cout << s << endl;
}

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
#include <iostream>
#include <string>

using namespace std;

// 手动实现reverse函数
void reverseString(string &s, int i, int j)
{
for (int a = i, b = j; a < b; a ++ , b -- )
{
int tmp = s[a];
s[a] = s[b];
s[b] = tmp;
}
}

int main()
{
int k;
string s;
cin >> k >> s;

reverseString(s, 0, s.size() - 1);
reverseString(s, 0, k - 1);
reverseString(s, k, s.size() - 1);
cout << s << endl;
}

实现

344.反转字符串

在算法的思路上,字符串和数组非常类似。本题应用双指针法即可:首尾交换,再次一级交换,以此类推。因此首尾各有一个指针,两指针交换,然后两指针同时向中间移动。若库函数直接把题目解决了,就不要用库函数。若库函数是题目的一部分,且我们知道库函数的大体实现逻辑和时间复杂度,那就可以用。代码如下所示:

1
2
3
4
5
6
7
class Solution {
public:
void reverseString(vector<char>& s) {
for (int i = 0, j = s.size() - 1; i < s.size() / 2; i ++ , j -- )
swap(s[i], s[j]);
}
};

swap函数有两种方法,一种是常见的交换数值,另一种是位运算,可参见代码随想录。

541. 反转字符串II

模拟题,模拟复杂的规则下,如何反转字符串。题意:每2k段的前k个字符进行反转,尾部如果剩下的字符超过长度超过k,则反转k个字符,剩下的不动。尾部如果剩下的字符长度小于k,则全部反转。本题的代码可以很简洁。

本题每次取2k段,因此按照2k来遍历:for (int i = 0; i < s.size(); i += 2k)。然后在for循环中操作前k个字符即可。边界条件想不明白可以带一个具体的例子来试。代码和注释如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
string reverseStr(string s, int k) {
// 每隔2k个字符跳转一次,即每次取出2k个字符
for (int i = 0; i < s.size(); i += 2 * k)
{
// 对2k个字符的前k个字符进行反转
// 由于每次取2k,取若干次后。字符串的尾部剩下的字符长度l可能l < k 或 k <= l < 2k
// 对前一种情况,需要将尾部全部反转,对后一种情况,需要反转尾部剩下字符的前k个字符
// 先处理后一种情况,注意加上条件i + k <= s.size(),这可以避免对索引超出范围的元素进行反转
// 至于i + k是否能取到s.size(),可以举例子:k = 3, s = {a, b, c},由此可见可以取等于
// 也可以从理论上分析,由于reverse的区间是左闭右开的,因此s.begin() + i + k实际上取不到,因此可以让i + k = s.size()
// 处理完后continue即可,除去反转2k个字符中的前k个字符的一般情况,尾部剩下的字符的长度的第一种情况和第二种情况只可能有一种发生
if (i + k <= s.size())
{
reverse(s.begin() + i, s.begin() + i + k); // 左闭右开
continue;
}
// 再处理前一种情况,当剩余的字符长度l < k时,反转剩余的全部字符
reverse(s.begin() + i, s.end());
}
return s;
}
};

也可以不用continue,直接采用if-else写法,参见代码随想录的写法(代码随线录的注释也更加简洁明了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
string reverseStr(string s, int k) {
for (int i = 0; i < s.size(); i += (2 * k)) {
// 1. 每隔 2k 个字符的前 k 个字符进行反转
// 2. 剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符
if (i + k <= s.size()) {
reverse(s.begin() + i, s.begin() + i + k );
} else {
// 3. 剩余字符少于 k 个,则将剩余字符全部反转
reverse(s.begin() + i, s.end());
}
}
return s;
}
};

卡码网:54.替换数字

本题的最佳解法不需要额外的辅助空间。首先扩充原字符串到每个数字字符替换成 “number” 之后的大小。然后用双指针算法,指针i指向旧字符串的末尾,指针j指向新字符串的末尾。用指针i遍历旧字符串,若遇到字母,则原样填入指针j指向的位置;若遇到数字,则从后往前将number填入到指针j指向的位置。直到i和j都指向新旧字符串的开头为止。这里的新旧字符串其实是扩容之后和扩容之前的同一字符串,只是为了方便区分称它们为新旧字符串。根据这个思路,我写出了如下的代码:

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
# include <iostream>

using namespace std;

int main()
{
string s;
cin >> s;

// count为字符串中数字的数量
int count = 0;
for (int i = 0; i < s.size(); i ++ )
if (s[i] >= 48 && s[i] <= 57)
count ++ ;

int oldSize = s.size();
s.resize(oldSize + count * 5); // 字符串扩容

// 双指针算法,i指向旧字符串,j指向新字符串
for (int i = oldSize - 1, j = s.size() - 1; i >= 0; )
{
if (s[i] < 48 || s[i] > 57)
{
s[j] = s[i];
i -- ;
j -- ;
}
else
{
s[j] = 'r';
s[j - 1] = 'e';
s[j - 2] = 'b';
s[j - 3] = 'm';
s[j - 4] = 'u';
s[j - 5] = 'n';
i -- ;
j -= 6;
}
}

// 可以直接写作cout << s << endl;
for (int i = 0; i < s.size(); i ++ )
cout << s[i];
return 0;
}

代码随想录的代码本质上和我写的是一样的,但他写的更见简洁一些,我写的更易于理解一些。

我的写法中,必须让i >= 0,不能写成i > 0,否则答案错误。例子,输入1,输出本来应该为number,若for循环的条件为i > 0,则不会进入for循环,直接输出1,这显然是不对的。但对于代码随想录的写法:for (int i = sNewSize - 1, j = sOldSize - 1; j < i; i--, j--),则j < i是正确的,若首字符为字母,则j = i时两指针均以指向首字符,首字符保留即可,不需要处理;若首字符为数字,则逻辑也可以正确执行。若j <= i,则反而会出现越界的问题,因为当j和i都指向首字符后,for循环的条件依然满足,此时完成当前循环后,i和j继续-1,再次判断时,i依然等于j,再次进入循环,此时s[i]和s[j]就不存在了(s[-1]不存在)。

151.翻转字符串里的单词

是字符串中操作比较复杂的题目,给的字符串中在开头、中间、结尾都可能有空格。反转字符串里的单词后,要将多余的空格都删掉。

整体思路:先让单词的顺序和目标相同,即将整个字符串都反转。再对每个单词做反转,就得到了目标字符串。将原字符串整体反转,再将每一个单词反转

难点:如何删去多余的空格。要求空间复杂度O(1),即不能申请新的字符串来放置删去多余空格后的字符串。且不能使用库函数。使用快慢双指针算法,删除多余空格的时间复杂度为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
class Solution
{
public:
void removeExtraSpace(string &s)
{
int slow = 0;
for (int fast = 0; fast < s.size(); fast ++ )
{
if (s[fast] != ' ') // 去除字符串开头的空格
{
// 每复制完一个单词后,加一个空格
// 这句话不可以放在while循环后,否则会在最后一个单词后面增加一个多余的空格
if (slow != 0) s[slow ++ ] = ' ';

// 将旧字符串的非空部分复制到新字符串中
while (fast < s.size() && s[fast] != ' ') s[slow ++ ] = s[fast ++ ];
}
}
s.resize(slow);
}

string reverseWords(string s)
{
removeExtraSpace(s); // 删去所有多余的空格

reverse(s.begin(), s.end()); // 反转整个字符串,注意reverse函数是左开右闭的

// 反转每个单词
int start = 0, i = 0;
while (i < s.size())
{
while (i < s.size() && s[i] != ' ') i ++ ; // 找到空格
reverse(s.begin() + start, s.begin() + i); // 反转start到空格之间的单词
start = i + 1; // 更新start
i = start; // 更新i
}
return s;
}
};

代码随想录中的反转每个单词的写法和我的略有不同,他用的是for循环,但本质是一样的。

卡码网:55.右旋转字符串

我在初次尝试中已经给出了空间复杂度为O(1)的最优解法,下面两幅图(对应两种等效的方法)可以帮助理解:

  1. 先反转整个字符串,再反转两个子串

    img

  2. 先反转子串,再反转整个字符串

    img

心得与备忘录

344.反转字符串

两种for循环的写法:for (int i = 0, j = s.size() - 1; i < s.size() / 2; i ++ , j -- )for (int l = 0, r = s.size() - 1; l < r; l ++ , r -- )都可以。

541. 反转字符串II

  1. for循环每次以2k为长度去跳转
  2. 本题反转字符的三种情况

    • 每隔 2k 个字符的前 k 个字符进行反转
    • 剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符
    • 剩余字符少于 k 个,则将剩余字符全部反转

    三种情况每次只可能出现一种,即出现了一种情况,另外两种情况就不会出现了。据此,我写出了结构分明的三段式代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    class Solution {
    public:
    string reverseStr(string s, int k) {
    for (int i = 0; i < s.size(); i += 2 * k)
    {
    // 情况1
    if (i + 2 * k <= s.size())
    {
    reverse(s.begin() + i, s.begin() + i + k);
    continue; // 可以省略
    }
    // 情况2
    else if (i + k <= s.size())
    {
    reverse(s.begin() + i, s.begin() + i + k);
    continue; // 可以省略
    }
    // 情况3
    else
    reverse(s.begin() + i, s.end());
    }
    return s;
    }
    };

    情况1和情况2可以合并(即剩余字符的长度l满足l >= k时,都是反转剩下字符的前k个;只有当l满足l < k时,才要反转剩下的所有字符),因此产生了实现部分中的第二版代码。每次思考时应该先想到三种情况,再写出结构分明的三段式代码,然后对其进行简化。能够写出三段式代码即可,虽然不简洁但思路清晰简单、不容易出错

  3. 如果要求一段段地操作字符串或数组,那么for循环中的i变量是可以一段段增加的,而没必要每次+1

卡码网:54.替换数字

  1. 本题注意使用双指针做法。代码推荐参考我在实现中的写法,虽然和代码随想录的代码略有差别,但本质是完全一样的。
  2. 本题注意考虑边界条件,在我的写法中,是i >= 0而非i > 0;在代码随想录的写法中,是j < i而非j <= i。如果边界条件写得不对会导致发生指针异常或者部分样例无法通过。考虑边界条件时,可以举特例,也可以让代码先运行,若发生错误则修改相应的边界条件。
  3. 很多数组填充类的问题,其做法都是先预先给数组扩容带填充后的大小,然后在从后向前进行操作。对于线性数据结构,填充或者删除,后序处理会高效的多。

    这么做有两个好处:

    1. 不用申请新数组。算法的空间复杂度从O(N)降到了O(1)。
    2. 从后向前填充元素,避免了从前向后填充元素时,每次添加元素都要将添加元素之后的所有元素向后移动的问题。算法的时间复杂度从O(n^2)降到了O(n)。

151.翻转字符串里的单词

  1. 本题的总体思路:移除多余的空格->反转整个字符串->反转字符串中的每个单词
  2. 利用快慢双指针移除多余的空格有两种写法,一种较为复杂,需要分别移除字符串前面的空格和字符串中间和最后的连续的不止一个的空格,最后再移除字符串最后可能存在的一个空格。另一种较为简单,思路和27.移除元素是相同的快指针用于遍历旧字符串,慢指针用于依次指向新字符串中的各个元素。时间复杂度O(n)
  3. 推荐使用较为简单的双指针写法。除去从旧字符串中复制每个单词到新字符串中的代码,还需要加上用于在新字符串中添加每个单词尾部的空格的代码。注意这两行代码的顺序不能写反,必须是先有添加空格的代码,再有复制单词的代码,否则会导致在新字符串的末尾多添加一个空格
  4. 上面提到的新旧字符串只是有时间上的先后,没有空间上的拷贝。新字符串就是在旧字符串的基础上利用双指针算法通过删除和改动部分元素得到的。因此空间复杂度为O(1)。

卡码网:55.右旋转字符串

  1. 本题加上限制条件:不能申请额外空间,只能在本串上操作(对cpp)。
  2. 可以先反转总串,再反转子串;也可以先反转子串,再反转总串。
  3. 右旋转字符串和左旋转字符串方法完全相同,就是反转的区间不同。

链接

454.四数相加II
383. 赎金信
15. 三数之和
18. 四数之和
哈希表总结篇

知识

454.四数相加II

cpp中的map中的value是支持++操作的,且value可以通过key直接索引到,就像普通的数组那样。

383. 赎金信

  1. 不仅对vector可以用范围遍历,对string类型的变量和普通的数组也可以用范围遍历的写法来简化代码。似乎范围遍历的速度要稍快于普通的for循环遍历。

  2. cpp中,可以用erase函数来删除string类型变量的第j个字符,有两种写法:
    string.erase(j, 1);
    string.erase(s.begin() + j);

  3. cpp中,如果想使用变量类型来给变量命名,需要使用std,有如下例子:

    1
    2
    3
    4
    5
    6
    7
    8
    #include <set>

    int main() {
    std::set<int> set; // 使用 "set" 作为变量名
    set.insert(1);
    set.insert(2);
    return 0;
    }

    在这个例子中,set是作为std::set<int>类型的变量名使用的。由于std::set是在std命名空间中定义的,而变量set是在局部作用域中定义的,所以编译器能够区分这两者。

18. 四数之和

  1. 将四数之和由int类型转换为long类型:(long) nums[i] + nums[j] + nums[l] + nums[r] > target

初次尝试

454.四数相加II

这道题肯定是要用map做哈希的,且map的key用来存储元素的值,map的value用来存储元素的索引。此题和两数之和为target有较多的相同点,但也有些不同。若四个数相加为0,则其中的数两两互为相反数。但这种想法是不对的,可以存在2, 4, -3, -3的情况。对这题的算法我暂时想不出来什么好主意。

383. 赎金信

看着就是242.有效的字母异位词的变式,若前面那个字符串可以由后面那个字符串中的字母构成,则返回true,否则返回false。本质就是看后面的字符串是否包含前面的字符串。因为两个字符串都只是由小写字母构成,因此用数组做哈希足矣。根据这个思路,我写出了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
// 本题的本质是判断后面的字符串是否包含前面的字符串,即后面的字符串中出现的所有字符是否在前面的字符串中出现过
int N[26] = {0};

for (int i = 0; i < ransomNote.size(); i ++ )
N[ransomNote[i] - 'a'] ++ ;

for (int i = 0; i < magazine.size(); i ++ )
N[magazine[i] - 'a'] -- ;

// 数组N中有元素大于0,说明ransomNote中出现了magazine中未出现的字母
// 说明前者不能完全由后者组成,返回false
for (int i = 0; i < 26; i ++ )
if (N[i] > 0)
return false;
return true;
}
};

采用范围遍历的方法,可以把上述代码写得更简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
// ransomNote < magazine return true
// else return false
int N[26] = {0};

for (char r: ransomNote)
N[r - 'a'] ++ ;
for (char m: magazine)
N[m - 'a'] -- ;

for (int i: N)
if (i > 0)
return false;
return true;
}
};

15. 三数之和

这道题的题目我都不太理解,什么叫答案中不可以包含重复的三元组。直到我看到了示例1,明白了这个意思是可能存在情况:两个三元组,它们的索引组成的三元组可能不同,但这两个三元组本身的数值是完全相同的(忽略顺序),此时这两个三元组只能算作一个。这道题应该可以用哈希法,但需要去重。本题我认为有三个难点:

  • 枚举完一个数,怎么去寻找另外两个数
  • 用什么数据结构维护另外两个数
  • 如何去重

18. 四数之和

本题应该依然是双指针算法。但需要注意去重的操作。我的思路是先对数组进行排序,然后让a = i, b = i + 1, c = i + 2, d = nums.size() - 1。然后一边向后移动a, b和c,一边对a,b和c去重,一边向前移动d,一边对d去重。根据以上思路,我写下了以下的代码:

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
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> res;
sort(nums.begin(), nums.end());

for (int i = 0; i < nums.size(); i ++ )
{
if (nums[i] > target) return res;
// 对i去重
if (i > 0 && nums[i] == nums[i - 1]) continue;

for (int j = i + 1; j < nums.size(); j ++ )
{
// 对j去重
if (j > i + 1 && nums[j] == nums[j - 1]) continue;
int left = j + 1, right = nums.size() - 1;
while (left < right)
{
if (nums[i] + nums[j] + nums[left] + nums[right] > target) right -- ;
else if (nums[i] + nums[j] + nums[left] + nums[right] < target) left ++ ;
else
{
res.push_back({nums[i], nums[j], nums[left], nums[right]});
// 对left和right进行去重
while (left < right && nums[left] == nums[left + 1]) left ++ ;
while (left < right && nums[right] == nums[right - 1]) right -- ;
left ++ ;
right -- ;
}
}
}
}
return res;
}
};

以上代码测试样例通过了229 / 294,可见思路是对的,但细节仍不完美。我将在实现部分进一步优化细节。

实现

454.四数相加II

四数相加和四数之和题目看起来相似,但前者是哈希表中的经典题目,后者用哈希表的方法不太合适。其实只需要知道有多少对四数之和为0,不需要知道每一对的具体数值。

本题不需要去重,因此相对简单,四数之和则需要考虑去重。举例:四个数组,每个数组中都有n个0,则返回的结果是n。

思路:遍历数组A和B,将从这两个数组取出的元素a + b放入map中;再遍历数组C和D,求得c + d,再判断map中有无我们想要的元素-(c + d),有则count += -(c+d)出现过的次数(即map中key为-(c+d)的元素的value)。

本题的数据范围很大,因此用数组来做哈希不可取,只能考虑set/map。因为不仅需要将a + b放入哈希结构中,还需要统计a + b出现过多少次,因此用map。用map的key存a + b的值,用map的value存a + b出现的次数。

时间复杂度:O(n^2) + O(n^2),还是O(n^2)。如果先遍历一个数组,再遍历三个数组,则时间复杂度是O(n^3)。

我知道上述思路后,尝试写代码,出现一个问题:不知道如何统计数组A和数组B中各取一个元素求和后的值出现的次数。我把简单的问题想复杂了,map中的value是支持++操作的,且value可以通过key索引到,因此直接:map[num1 + num2] ++ ;即可,这个代码的意思是:若num1 + num2的值出现过,则其value += 1;若没出现过,则相当于:map.insert({num1 + num2, 1})。写出了以下的代码:

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
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
// 遍历nums1和nums2数组,将两个数组各取一个值的和作为key,和出现的次数作为value存入map中
unordered_map<int, int> sum;

for (int num1: nums1)
for (int num2: nums2)
sum[num1 + num2] ++ ; // 和为num1 + num2的值的出现次数 + 1

// 遍历nums3和nums4数组,设两个数组各取一个值的和是c + d
// 若map中出现了-(c + d),则count += value
int count = 0;
for (int num3: nums3)
{
for (int num4: nums4)
{
int s = num3 + num4;
auto it = sum.find(-s);
if (it != sum.end())
count += it->second; // it->second也可以写作sum[-s]
}
}
return count;
}
};

更简洁的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> map;

for (int num1: nums1)
for (int num2: nums2)
map[num1 + num2] ++ ;

int count = 0;

for (int num3: nums3)
for (int num4: nums4)
{
int target = -(num3 + num4);
if (map.find(target) != map.end())
count += map[target];
}

return count;
}
};

383. 赎金信

注意,本题的题干中虽然强调了Each letter in magazine can only be used once in ransomNote,但这个条件在写代码时实际上并不需要考虑。这应该只是生成测试样例时需要遵守的规则。

本题用暴力做法也可以过,但暴力做法的代码写起来似乎还更麻烦一点。暴力做法就是两重for循环,若ransomNote中出现了magazine中出现过的字符,则从ransomNote中移除该字符,最后判断ransomNote的长度是否为0即可。暴力做法的代码可以参见代码随想录。

至于时间复杂度为O(n)的哈希解法,我在初次尝试中写的就已经很完美了。若想进一步优化,可以加上判断:若ransomNote的长度大于magazine的长度,则可以直接return false。若在遍历字符串时就对数组中元素的正负进行判断,那需要注意:只能在ransomNote中对数组中元素的正负进行判断,为负则说明赎金信中有magazine中没有的字符。若在magazine中对数组中元素的正负进行判断,可能存在问题:数组中的元素为正不一定代表赎金信中有magazine中没有的字符,可能仅仅是因为尚未遍历完成,数组中的元素还没被减到负数。因此,下面的代码是错误的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int N[26];

if (ransomNote.size() > magazine.size()) return false;

for (char r: ransomNote)
N[r - 'a'] ++ ;
for (char m: magazine)
{
N[m - 'a'] -- ;
if (N[m - 'a'] > 0) return false;
}

return true;
}
};

可以通过测试样例轻而易举地看出上述解法的漏洞,比如
ransomNote =”aa”
magazine =”aab”
Output false
Expected true
而代码随想录上的哈希解法的代码是正确的。

若想避免上述问题,最直接的办法就是等到N数组中的元素全部计算完成后,另开一个循环来判断其中是否有为正的元素。

15. 三数之和

本题可以用哈希法做,但比较复杂。本题需要返回的三元组,其中的元素是数组中元素的值,而非下标。注意:三元组是去重的。本题相较于两数之和的难点就在于去重

哈希法的大致思路:用两重for循环,第一重确定a,第二重确定b,然后看-(a + b)是否在map中出现过。但这里的难点在于:需要同时对a, b和c(-a - b)去重。去重的细节太多了,基本上都会遇到小问题,难以一次想周全。因此推荐使用更易于理解的双指针法

双指针法的思路:使用双指针法之前需要对数组进行排序。for循环遍历数组,得到a;left指针从数组的第2个位置开始向后移动,得到b;right指针从数组的最后一个位置开始向前移动,得到c。若num[i] + num[left] + num[right] > 0,说明三数之和大了,i是固定的(for循环从头开始遍历),因此应当让right --。若num[i] + num[left] + num[right] < 0,说明三数之和小了,应该让其变大,则应当让left ++。若三数之和为0,则将三者放入二维数组res中。注意细节:去重。num[i], num[left], num[right]三个数都需要去重,因为res中不能有重复的三元组。

伪代码:(注:a = num[i], b = num[left], c = num[right]

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
vector<vector<int>> res; // 存储结果
sort(nums.begin(), nums.end()); // 排序

for (int i = 0; i < nums.size(); i ++ )
{
// 排序后,若最小值仍大于0,说明不存在三数之和等于0的情况,返回现有的res即可
if (nums[i] > 0) return res;

// nums[i]即a,需要对a去重
// 三元组之间不可重复,但三元组内部可以有重复的数字,比如000
// 去重是nums[i] == nums[i + 1] continue还是nums[i] == nums[i - 1] continue
// 应该是后者。若是前者,由于left指针指向nums[i + 1],因此若b和a相同,则会跳过这个结果集,这显然是错误的
// 因为三元组内部是可以有重复的数字的
if (i > 0 && nums[i] == nums[i - 1]) continue; // 当前三元组的a和上一个三元组的a重复,则进入下一个循环

int left = i + 1, right = nums.size() - 1;
// 求三个数,因此是left > right。若left = right,则三个数变为了两个数
while (right > left)
{
if (nums[i] + nums[left] + nums[right] > 0) right -- ;
else if (nums[i] + nums[left] + nums[right] < 0) left ++ ;
else
{
res.push_back({nums[i], nums[left], nums[right]}); // 三者之和等于0.则放入结果数组中,收获结果
// 去重
while (right > left && right[i] == right[i - 1]) right -- ; // 对c去重
while (right > left && left[i] == left[i + 1]) left ++ ; // 对b去重
// 收获一个结果后,left和right都向着数组的中间位置移动
left ++ ;
right -- ;
}
}
}
return res;

细节:

  • 如何对a去重:if (i > 0 && nums[i] == nums[i - 1]) continue;

  • 如何对b和c去重:

    1
    2
    while (right > left && right[i] == right[i - 1]) right -- ; // 对c去重
    while (right > left && left[i] == left[i + 1]) left ++ ; // 对b去重
  • 对b和c去重的代码放在哪里
    必须先收获结果,再去重。否则若出现数组中全是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
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end()); // 用双指针算法前需要先排序

vector<vector<int>> res; // 二维数组,存放结果

// 三元组{a, b, c},i指向a, left指向b, right指向c
// a = nums[i], b = nums[left], c = nums[right]
for (int i = 0; i < nums.size(); i ++ )
{
// 若最小的a都大于0,则三数之和不可能等于0,不需要继续循环,返回现有的res即可
if (nums[i] > 0) return res;

// 对a去重
if (i > 0 && nums[i] == nums[i - 1]) continue;

int left = i + 1, right = nums.size() - 1;

while (left < right)
{
if (nums[i] + nums[left] + nums[right] > 0) right -- ;
else if (nums[i] + nums[left] + nums[right] < 0) left ++ ;
else
{
res.push_back({nums[i], nums[left], nums[right]}); // 收获结果

// 对b和c去重
while (left < right && nums[left] == nums[left + 1]) left ++ ;
while (left < right && nums[right] == nums[right - 1]) right -- ;

// 移动left和right指针
left ++ ;
right -- ;
}
}
}
return res;
}
};

18. 四数之和

和三数之和思路相同,但多一重for循环。共有i, j, left, right四个指针,前三者初始时分别指向数组的前三个元素,right指向数组最后一个元素。left和right向中心靠拢,使得nums[i] + nums[j] + nums[left] + nums[right] = target

细节:剪枝和去重。

  • 一级剪枝:不能延续三数之和的剪枝操作:if(nums[i] > target) return res;。这没有考虑到数组中可能有负数的情况,若数组中有负数,几个元素相加是越加越小的,因此即使最小的数大于target,通过加上一些负数,四数之和依然可能为target。正确的剪枝操作应该为:if (nums[i] > target && nums[i] > 0 && target > 0) break;。其实这里写break(即最后返回)和写return res都是可以的,并不会影响运行结果。
  • 二级剪枝:if (nums[i] + nums[j] > target && nums[i] + nums[j] > 0 && target > 0) break;二级剪枝完成后只能写break,写return res会有几个测试样例无法通过。原因:一级剪枝条件时直接return res,相当于结束所有循环,返回结果,不会漏掉部分四元组;二级剪枝时直接return res,同样相当于结束所有循环,返回结果,此时就会漏掉部分四元组。正确的做法应该是结束第二重循环,继续进行第一重循环

其实还有一个细节需要注意,在求四数之和nums[i] + nums[j] + nums[left] + nums[right]时,若四个数都是10亿,加起来就会超过int的限制(大约21亿),因此需要把四数之和转化为long类型:(long) nums[i] + nums[j] + nums[l] + nums[r] > target。如果不将int转换为long,会报错:整数溢出,同时有几个测试样例无法通过。代码如下所示:

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
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> res;
sort(nums.begin(), nums.end());

// a = i, b = j(i + 1), c = l(i + 2), d = r(nums.size() - 1)
for (int i = 0; i < nums.size(); i ++ )
{
if (nums[i] > target && target > 0 && nums[i] > 0) return res; // 一级剪枝
if (i > 0 && nums[i] == nums[i - 1]) continue; // 一级去重

for (int j = i + 1; j < nums.size(); j ++ )
{
if (nums[i] + nums[j] > target && target > 0 && nums[i] + nums[j] > 0) return res; // 二级剪枝
if (j > i + 1 && nums[j] == nums[j - 1]) continue; // 二级去重

int l = j + 1, r = nums.size() - 1;
while (l < r)
{
if ((long) nums[i] + nums[j] + nums[l] + nums[r] > target) r -- ;
else if ((long) nums[i] + nums[j] + nums[l] + nums[r] < target) l ++ ;
else
{
res.push_back({nums[i], nums[j], nums[l], nums[r]});
// 对l和r去重
while (l < r && nums[l] == nums[l + 1]) l ++ ;
while (l < r && nums[r] == nums[r - 1]) r -- ;
l ++ ;
r -- ;
}
}
}
}
return res;
}
};

心得与备忘录

454.四数相加II

  1. 本题的大体思路?
    遍历前两个数组A和B,将a + b的值存入map
    再遍历后两个数组C和D,在map中查找-(c + d)的值

  2. 为什么选择map做哈希?
    因为不仅需要存储a + b的值,还需要存储这个值出现的次数(map[a + b] ++),用于在4中统计元组的个数

  3. map中的key放什么?value放什么?
    map中的key放a + b的值,map中的value放这个值出现的次数

  4. 如何统计元组的个数?
    count += map[-(c + d)]

  5. 如何统计a和b的和出现的次数?
    map[a + b] ++

383. 赎金信

代码随想录上的哈希解法不如我在初次尝试部分写的哈希解法简洁,而且代码随想录的哈希解法在颠倒遍历两个字符串的顺序时容易出错。本题的最佳解法是我在初次尝试部分写的第二个版本的代码

15. 三数之和

  1. 采用双指针法,不要用哈希法,哈希法写起来复杂,去重麻烦、难以做剪枝操作,故效率显著低于双指针法
  2. 双指针法思路简单,但要注意去重的细节
  3. 排序的目的是方便剪枝,且一个三元组只会有唯一的顺序
  4. 双指针法只适用于返回的结果是数而不是索引的题目,因为双指针法使用前必须对数组进行排序,排序后索引会被打乱,因此返回的结果不能是索引。若两数之和要求返回的结果是数,那么也可以用双指针算法。这不禁让我思考,若本题要求返回的结果是索引,那么也只能用哈希法。但如果要求返回的结果是索引,那么就不需要有复杂的去重操作,因此实际上是简化了本题。
  5. 对于nums[i](即a)去重的代码,可以用if判断写:if (i > 0 && nums[i] == nums[i - 1]) continue;,也可以用while循环写:while (i < nums.size() && i > 0 && nums[i] == nums[i - 1]) i ++ ;。一般在写while循环时,都需要加上对数组索引不可越界的限制i < nums.size()。如果出现报错:Runtime Error: AddressSanitizer,大概率是因为数组索引越界了,此时需要检查是否加上了限制条件i < nums.size()i > 0

18. 四数之和

  1. 本题思路和三数之和相同,但需要注意剪枝的细节
  2. 还需要注意在求四数之和时将int类型转换为long类型,避免整数溢出。
  3. 若采用双指针算法,三数之和的时间复杂度是O(n^2),四数之和的时间复杂度是O(n^3)。用暴力做法的时间复杂度则分别为O(n^3)O(n^4)
  4. 本题相比于四数相加,由于要考虑去重问题,所以更加复杂,因此无法(不推荐)使用哈希法,推荐使用双指针算法。
  5. 剪枝方面可以做进一步的优化,但属实没有必要。
  6. 本题写剪枝统一用break,不要用return res,以免方式意外的错误
  7. 本题如果有几个测试样例总是过不了,可以直接删去剪枝的代码,一般就可以通过了。剪枝是优化,即使不加,依然可以轻松通过。剪枝部分是易错点。

哈希表总结

  1. 哈希表的使用场景:快速判断一个元素是否在集合中出现过。
  2. 哈希的三重境界:普通数组->set->map。
  3. 目前哈希中用到的set和map实际上是unordered_set和unordered_map,相对于set和map中的另外两种数据结构(set, multiset, map, multimap),unordered_set和unordered_map的查询效率和增删效率都是最高的。选择set类型的三种数据结构时,若我们不需要数据有序,且需要去重,且希望效率高,则用unordered_set。选择map类型的三种数据结构时,若我们不需要key有序,且希望效率高,则用unordered_map。
  4. 遇到哈希问题时,首先想想能不能用数组做哈希(比如题目中提到字符串中全是小写英文字母,就果断用数组做哈希)。用数组做哈希最直接,运行速度也最快,用set做哈希速度更慢,但遇到大规模的索引,数组放不下时,只能用set。
  5. 什么时候用map做哈希?当对一个元素需要同时存储两个值时,就必须用map做哈希。这两个值一个作为key,一个作为value存入map中。key中一般存储的是元素的值(便于查询),value中可以存放元素的索引(如1. 两数之和),也可以存放元素出现的次数(如454.四数相加II)。
  6. map可以当作普通数组一样使用,忘了STL的用法可以复习知识部分。
  7. 哈希表部分的八道算法题,前六道都使用的是正统的哈希法,最后两道(三树之和&四数之和)并非不可以使用哈希法,但使用哈希法需要进行复杂的去重操作,代码容易写错,且运行效率低下,因此推荐使用双指针算法。
  8. 三数之和&四数之和的易错点在于剪枝和去重。每重for循环都需要剪枝和去重,while循环进行去重即可,但其实剪枝是一种优化,并不是必须的。但去重是必须的!