YifanChen's Blog

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

0%

链接

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

知识

理论基础

二叉树的种类

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

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

满二叉树

满二叉树:如果一棵二叉树只有度为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循环进行去重即可,但其实剪枝是一种优化,并不是必须的。但去重是必须的!

快速了解项目

Snipaste_2024-01-30_11-45-52.png

从0带读Java小说项目。项目:小说精品屋

首先看代码的简介(README),然后看代码的更新频率(几年没更新的就不用看了)。

接着看项目的介绍,看项目的技术栈和我们自己的技术栈是否匹配。

接着看包结构(项目结构)。

看技术选型。高级的技术:ShardingSphere-JDBC(数据库分库分表支持)、分布式缓存支持、搜索引擎服务、开源消息中间件、文件存储、对象存储。

接着看核心:项目如何安装,如何启动

了解项目依赖

通过github1s(在线查看项目的工具)看项目。

==看项目从整体到局部,先看项目的架构及关键配置文件==

比如assets放静态文件,sql放SQL语句。根目录下的pom.xml定义了父工程的配置。在父工程的配置中又定义了子模块,可以达到多包同时编译的效果。

dockerfile:可以用其来生成一个docker镜像

Java的项目主要分为两部分:resources放一些资源文件和配置,另一部分是java的核心代码。

看resources/application.yml:跑起这个项目需要启动哪些服务。

resources/mybatis:放一些SQL语句

resources/static:放前端的文件,比如javascript, css等等。

resources/templates:用的是thymeleaf,拓展标签可以动态地把一些后台数据渲染到页面。

resources/application-dev.yml:是项目的开发环境的配置。

resources/application-prod.yml:是项目生成环境的配置。

resources/logback.yml:日志

了解项目结构

现在java项目的目录结构比较清晰和规范。都是mvc结构:model view controller。

controller:控制层,接收用户的请求,给予一些响应,业务逻辑一般不写在其中

core:项目核心的类

mapper:mybatis的映射文件,在这个文件中定义操作数据库的方法

page:控制页面的返回。用户请求一个地址,请求发送到controller,会响应并返回某个页面给用户,和前端的模板有关联。

service:编写业务的逻辑

vo:返回给页面的数据

springboot的启动类,会自动帮助我启动一个tomcat服务器

追踪请求(了解分层)

参考

带你读懂一个开源项目,学习通用套路!程序员阅读项目源码技巧、Java 编程项目分享

编程导航

小说精品屋

链接

哈希表理论基础
242.有效的字母异位词
349. 两个数组的交集
202. 快乐数
1. 两数之和

知识

哈希表理论基础

哈希表->哈希函数->哈希碰撞->拉链法/线性探测法->常见的三种哈希结构->set & map及如何选取->总结

哈希表

哈希表是根据关键码的值而直接进行访问的数据结构。举例:其实数组就是一张哈希表。哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素。

哈希表能解决什么问题呢,一般哈希表都是用来快速判断一个元素是否出现集合里。

哈希函数

哈希函数:哈希函数,把学生的姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了。通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。如果hashCode得到的数值大于哈希表的大小了,也就是大于tableSize了,此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,这样我们就保证了学生姓名一定可以映射到哈希表上了。

如果学生的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。接下来哈希碰撞登场。

哈希碰撞

小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。一般哈希碰撞有两种解决方法, 拉链法和线性探测法。

拉链法/线性探测法

其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放冲突的数据了。

常见的三种哈希结构

当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。

  • 数组
  • set (集合)
  • map(映射)

set & map及如何选取

Snipaste_2024-01-29_09-29-58.png

std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。

img

std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。

两个unordered都是哈希表实现的,其他四个都是红黑树实现的。三类set和三类map性质上是类似的。

当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。

那么再来看一下map ,在map 是一个key-value 的数据结构,map中,对key是有限制,因为key的存储方式使用红黑树实现的,对value没有限制。

虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,std::set、std::multiset 使用红黑树来索引和存储,不过给我们的使用方式,还是哈希法的使用方式,即key和value。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 map也是一样的道理。

总结

总结一下,==当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法==。哈希法的查询速度很快:O(1)。

但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。

如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!

题目

242.有效的字母异位词

  1. 将一个数组中的元素全部置为0:int hash[26] = {0};。实际上,直接写int hash[26]也可以,不给数组中的值赋值,数组中的值默认为0。
  2. 求字符串string s的长度,可以用s.size(),也可以用s.length()
  3. s[i] - 'a':编译器会自动用ascii码进行计算,不需要手动将变量类型转换为整数。
  4. 一个有返回值的函数,如果执行了return语句,函数直接结束,不需要再break。
  5. 定义一个常量大小的数组,空间复杂度是O(1)。

349. 两个数组的交集

  1. set, multiset, unordered_set。前两者底层实现是红黑树,最后一个的底层实现是哈希值直接映射。unordered_set就是一个可以无限存装的数组。本题用unordered_set,因为其做映射和取值时效率最高,前两者的底层实现是树,因此取值是还要有查找的过程。unordered_set中的元素不可重复,相当于自动帮我们做去重;而multiset中的元素可以重复。
  2. 可以直接将set类型的数据转换为vector类型:return vector<int>(set.begin(), set.end())
  3. cpp中的vector中既有insert方法,又有push_back方法,前者需要指定插入元素的具体位置,后者直接将元素插入到vector的末尾。cpp的set(包括set, multiset, unordered_set)中只有insert方法,传入的参数为要插入的值,不需要指定插入元素的具体位置。
  4. 将vector转换为unordered_set: unordered_set<int> nums1_set(nums1.begin(), nums1.end())
  5. 在unordered_set中查找元素:nums1_set.find(nums2[i]),返回的结果是一个迭代器(指针)。如果找到该值,find返回一个指向该元素的迭代器;如果未找到,则返回一个指向unordered_set末尾的迭代器,即end()迭代器。

1. 两数之和

  1. 返回一个vector可以直接将vector的内容写入大括号中,然后返回,比如return {a[i], b[i]},返回一个空数组可以直接写成return {},而不用定义一个vector再利用push_back方法向其中插入数,然后再返回这个vector。
  2. 定义的vector<int> a,若不给其赋值,则该vector长度为0。可见vector是动态地被分配内存,如果不给其赋值,则其长度为0,不占用内存,这与普通数组需要在定义时声明长度有所不同。
  3. 定义unordered_map的方式:unordered_map<int, int> map;unordered_map中有insert函数和find函数,用法同unordered_set;遍历这些STL容器都要用迭代器,相当于是一种加强版的指针;访问unordered_map的key和value:map->firstmap->second

初次尝试

242.有效的字母异位词

想到一个办法,用两个数组分别统计两个字符串中出现的字母和字母出现的频次,然后判断两个数组是否完全相同。代码如下所示,可以成功运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
bool isAnagram(string s, string t) {
int hash1[26] = {0};
int hash2[26] = {0};

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

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

for (int i = 0; i < 26; i ++ )
if (hash1[i] != hash2[i])
return false;
return true;
}
};

稍微麻烦了点,实际上用一个数组就够了。

349. 两个数组的交集

暂时还不会用set做哈希,因此先尝试用数组做哈希。我写下了如下的代码:

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> intersection(vector<int>& nums1, vector<int>& nums2) {
int hash1[1000] = {0};
int hash2[1000] = {0};

// 统计两个数组nums1和nums2中每个数值出现的频次
for (int i = 0; i < nums1.size(); i ++ )
hash1[nums1[i]] ++ ;
for (int i = 0; i < nums2.size(); i ++ )
hash2[nums2[i]] ++ ;

vector<int> res;

// 若一个数同时在nums1和nums2数组中出现的频次>=1,则该数是两数组的重叠,放入结果数组res中
for (int i = 0; i < 1000; i ++ )
{
if (hash1[i] && hash2[i])
res.push_back(i);
}
return res;
}
};

本题其实是使用set的好题,但是后来力扣改了题目描述和测试用例,添加了 0 <= nums1[i], nums2[i] <= 1000 条件,所以使用数组也可以了,不过建议大家忽略这个条件。 尝试去使用set。

202. 快乐数

尽管说这道题和上一道题原理上差不多,只是套了快乐数的壳子,但我看不出这题怎么用set来进行哈希。直接看讲解吧。

1. 两数之和

看到的第一想法是类似滑动窗口,但滑动窗口(209.长度最小的子数组)返回的是长度最小的子数组的长度,这道题却要返回两个整数的下标,因此还是有很大不同的。如果要快速在集合中查找一个元素是否出现过,那么应该采用哈希表的方法。我产生了一个想法,将nums数组中的所有数一对一对不重不漏地取出,将每一对数的和作为索引(key),将它们的下标作为(value)存入map中,然后通过查询map的索引来找到目标对,进而返回目标对的下标。实现起来有两个难点:

  • 如何不重不漏地枚举所有的数对?
  • 如何将两个下标存入一个value里?

实现

242.有效的字母异位词

判断两个字符串是否由相同的字母组成,但字母的位置可以不同。两个完全一样的字符串也是有效异位词。由于字符串中都是小写字母,因此a可以对应数组中索引为0的位置,z可以对应数组中索引为25的位置。用数组hash[26]统计第一个数组中每个字母出现的频率,然后第二个字符串中每个字母出现的频率再在hash数组中做对应的减法,若最后数组中所有元素均为0,则说明两个字符串互为有效的字母异位词。

什么时候用数组/set/map:当哈希值较小,且范围也较小且可控;若哈希值很大,则用set;若是key-value对,则用map

根据上述思路,我独立写出了代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool isAnagram(string s, string t) {
int hash[26] = {0}; // 数组中的元素全部初始化为0

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

for (int j = 0; j < t.size(); j ++ )
hash[t[j] - 'a'] -- ;

for (int i = 0; i < 26; i ++ )
if (hash[i])
return false;
return true;
}
};

349. 两个数组的交集

返回两个数组的交集。注意要去重。虽然可以用数组哈希,但还是建议用set。若保持之前的题目描述,让两个数组中的数值可能非常大,比如上亿,此时就必须要用set了,因为数组下标放不下那么大的数,同时会浪费很多存储空间。

哈希表善于解决判断一个元素是否在一个集合中出现过的题目。集合中的数值很大时,或者集合中的元素数量很少但数值很分散时,用数组不合适,要用set。先将数组nums1中的所有数值放到哈希表中,再遍历num2,查看其中的元素的数值是否在哈希表中出现过,出现过则放入res集合中。

因为要去重,所以定义unordered_set来存储result。哈希表也用unordered_set。直接将nums1转化为unordered_set的存储结构。接着遍历nums2,看哈希表中是否出现了nums2中的元素,出现了则将其放入result中。

set哈希法

代码如下所示:

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> intersection(vector<int>& nums1, vector<int>& nums2) {
// 存储答案的unordered_set,因为是unordered_set所以自动去重
unordered_set<int> res;

// 将nums1从vector转换为unordered_set
unordered_set<int> nums1_set(nums1.begin(), nums1.end());

// 在nums1_set中查找nums2中的数据,如果出现过,则将其插入res中
// 也可以用范围遍历for (int num: nums2)
for (int i = 0; i < nums2.size(); i ++ )
{
if (nums1_set.find(nums2[i]) != nums1_set.end())
res.insert(nums2[i]);
}

// 将res从unordered_set类型转换回vector类型
return vector<int>(res.begin(), res.end());
}
};

时间复杂度:
构建nums_set:O(n)
遍历nums2并检查元素是否在nums_set中:O(m)
构建结果向量:O(k),其中k是结果集中元素的数量
综上所述,总的时间复杂度是O(n + m + k)。但是由于k(结果集的大小)是由n和m决定的,并且在大多数情况下k会小于n和m,所以可以近似地认为时间复杂度主要由n和m决定,即O(n + m)。

如果用数组做哈希的话,除了我在初次尝试中写的那种方式,其实还有另一种方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> res; // 存储结果,去重

int hash[1000] = {0};

// nums1中出现过的数,则将其在哈希数组中的值标记为1
for (int num: nums1)
hash[num] = 1;

// 若nums2中的数在nums1中出现过,则将其插入res中
for (int num: nums2)
if (hash[num] == 1)
res.insert(num);

// unordered_set->vector
return vector<int>(res.begin(), res.end());
}
};

202. 快乐数

题目中说:Repeat the process until the number equals 1 (where it will stay), or it loops endlessly in a cycle which does not include 1.本题本来是一个数学问题,可以得到严格的数学证明,但我们不需要懂数学,可以用编程的思维去解决它。

因此,一个数进行如题的操作后,要么会陷入死循环,要么会在某个时刻等于1并保持。因此,可以写一个循环来持续对输入的数进行如题的操作,如果某次操作的结果在之前出现过,那么该数就不是快乐数;如果操作的结果为1,那么该数就是快乐数。要快速判断一个元素是否在集合中出现过,就应该用一个set将集合中的所有元素维护起来。代码如下所示:

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:
// 用于求一个数每一位的平方之和的函数
int getSumofDigits(int n)
{
int s = 0;

while (n)
{
s += (n % 10) * (n % 10); // 求每一位的平方
n /= 10;
}
return s;
}

bool isHappy(int n) {
unordered_set<int> loop;

// 持续循环
while (1)
{
int s = getSumofDigits(n);

if (s == 1) return true; // 结束循环,是快乐数
else
{
// 若发现出现死循环,则立即返回不是快乐数
if (loop.find(s) != loop.end()) return false;
// 尚未出现死循环,则继续
else
{
loop.insert(s);
n = s;
}
}
}
}
};

可以将上述代码写得更见简练,更好理解:

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 getSum(int n)
{
int s = 0;

while (n)
{
s += (n % 10) * (n % 10);
n /= 10;
}
return s;
}

bool isHappy(int n) {
unordered_set<int> set;

while (1)
{
int s = getSum(n);

if (s == 1) return true; // 退出条件1,是快乐数
if (set.find(s) != set.end()) return false; // 退出条件2,不是快乐数

// 不满足两个退出条件,则继续循环
n = s;
set.insert(s);
}
}
};

这里主要处理的是数字 n 的每一位,一个数字n它的位数可以认为是logn(一个d位的数大约是10的d次方,n = 10^d => d = logn)。每次进行快乐数的判断会执行一次该计算操作,但是因为快乐数的范围有限,总体来看不会超过 logn 的常数倍,因此时间复杂度是O(log n)。

所以随着n的增加,存储在unordered_set中的不同可能结果的数量实际上是有限的。事实上,随着n的增长,这个数量的增长速度接近于对数增长。换句话说,即使n非常大,经过getSum处理后的结果仍然是一个相对较小的数字集合。因此空间复杂度为O(logn)。至于为什么是logn,我认为原因是其增长速度最慢,这比sqrt(n)等其他形式更符合n较大时set中存储的元素的数量接近一个常数的事实。

1. 两数之和

本题需要用map解决。判断一个元素是否在一个集合中出现过:用哈希法。假设target = 9,当遍历到元素3时,我们需要去寻找元素6是否被遍历过。把遍历过的元素加到一个集合(哈希表结构)中,每次遍历新元素a时,判断(target - a)是否在集合中出现过。若出现过,我们需要知道其下标,因此集合中既要存储元素的值,又要存储元素的下标。此时想到用map,存储元素的值用map的key,存储元素的下标用map的value(因为要查找元素是否出现过,因此以元素的值作为key,map能以最快的速度找到这个key是否在这个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
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> store;
vector<int> res;

// 将所有元素的值作为key,索引作为value存入map中
for (int i = 0; i < nums.size(); i ++ )
store.insert({nums[i], i});

// 每遍历到一个元素nums[i],查找target - nums[i]是否在map中
// 是则返回结果
for (int i = 0; i < nums.size(); i ++ )
{
int t = target - nums[i];
auto it = store.find(t);

// 注意除去第一个条件外,还需要保证查找到的元素并非当前元素本身
// 否则会出现target = 两倍当前元素而导致的误判
if (it != store.end() && it->second != i)
{
res.push_back(i);
res.push_back(it->second);
return res;
}
}
return res;
}
};

继续听讲解,map用于存放遍历过的元素的值和索引。本题使用unordered_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
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> store;
vector<int> res;

// 每遍历到一个元素nums[i],查找target - nums[i]是否在map中
// 是则返回结果
for (int i = 0; i < nums.size(); i ++ )
{
int t = target - nums[i];
auto it = store.find(t);

// 若找到了target - nums[i],则将其索引和当前遍历的元素的索引返回
if (it != store.end())
{
res.push_back(i);
res.push_back(it->second);
return res;
}
// 将已经遍历过的元素的值作为key,索引作为value存入map中
store.insert({nums[i], i});
}
return res;
}
};

我的第一版代码在store中存储了数组中所有元素的值和索引,因此需要保证查找到的元素并非当前元素本身。第二版代码在store中存储的是已经遍历过的元素,故天然满足查找到的元素并非当前元素本身的条件。两版代码都是对的,但后者更为简洁。最简洁版本的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> map; // 用于存储已遍历过的元素的值和索引

for (int i = 0; i < nums.size(); i ++ )
{
// 用于查找map中是否有目标元素
auto it = map.find(target - nums[i]);
// 有,则返回两个索引构成的vector
if (it != map.end()) return {i, it->second};
// 无,则将当前元素的值和索引插入map中,然后开始循环的下一轮
map.insert({nums[i], i});
}
// 完成循环后还没找到两个索引,则返回空vector
return {};
}
};

心得与备忘录

242.有效的字母异位词

  1. 本题的本质是判断两个字符串是否由相同的字母组成。
  2. 本题用数组实现哈希。
  3. 遇到哈希问题时,首先想想能不能用数组做哈希。用数组做哈希最直接,运行速度也最快,用set做哈希速度更慢,但遇到大规模的索引,数组放不下时,只能用set。

349. 两个数组的交集

  1. 本题本来不改测试数据,数组中的数值可能很大时,只能用set做哈希。现在对数组中的数值做了限制,最大不超过1000,则可以用数组做哈希。
  2. 用数组做哈希比用set做哈希效率更高,因为用set的话每次往里Insert一个值,都需要对这个值做一次哈希运算,同时还要开辟一个新的空间。用数组的下标做哈希映射永远是最快的。
  3. 本题适合用来衔接用数组做哈希和用set做哈希。
  4. 本题用set做哈希时,要记住set的各种用法:vector和unordered_set互相转化,在unordered_set中查找元素。这些用法归纳在知识中。
  5. 本题有三种解法:一种是用set哈希,另外两种是用数组做哈希。用数组做哈希建议采用我在初次尝试中的做法,只需要用到数组,不需要用到unordered_set去重。
  6. 采用范围遍历的写法可以简化代码。

202. 快乐数

这道题的逻辑其实非常简单。若各个位上的平方和为1,则退出循环,返回是快乐数;若平方和之前出现过,则说明进入了死循环,也退出循环,返回不是快乐数;其他情况下,继续循环。由于本题的1 <= n <= 2^31 - 1,各个位的平方和的数据范围非常大,因此必须用set做哈希,不能再用数组做哈希。注意本题时间复杂度和空间复杂度的分析。时间复杂度和空间复杂度不存在sqrt(n)等表达式,要么是1, 要么是logn,要么是n,要么nlogn或者更大。

1. 两数之和

四个重要问题:

  1. 为什么用哈希法:快速查找一个元素(目标元素)是否在集合(unordered_map存放已遍历过的元素)中出现过。
  2. 为什么要用map(unordered_map):因为既需要存储元素的值,也需要存储元素的索引。这道题目中并不需要key有序,选择unordered_map 效率更高。
  3. map的作用:存储已遍历过的元素的值和索引。
  4. map中的key存了元素的值(便于查询),value存了元素的索引(作为结果返回)。

链接

24. 两两交换链表中的节点
19.删除链表的倒数第N个节点
面试题 02.07. 链表相交
142.环形链表II
链表总结篇

知识

面试题 02.07. 链表相交

  1. 两个链表的交点不是数值相等,而是指针相等。
  2. 本题在构造测试样例时,会输入两个单链表和两个单链表的交叉点,以及交叉点到两个链表头节点的距离。因此,只有指定的交叉点才是真正的交叉点,仅仅是值相等的节点并不一定是真正的交叉点。指定的交叉点被构造出来时在内存中的地址相同,而仅仅是值相等的两个节点在内存中的地址不一定相同。

初次尝试

24. 两两交换链表中的节点

应该和交换数组中的两个元素相同。需要创建一个额外的节点tmp,然后若要交换a节点和b节点,则进行:tmp = b, a = b, b = tmp即可。

19.删除链表的倒数第N个节点

我能想到的办法:先遍历一遍列表,返回列表有几个节点。然后再遍历一遍列表,当cur指向倒数第N个节点的前一个节点时,停止遍历链表,删除cur->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
28
29
30
31
32
33
34
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
// 创建虚拟头节点
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

// 统计链表中的节点数量
int size = 0;
ListNode* cur = dummyHead;
while (cur->next != NULL)
{
cur = cur->next;
size ++ ;
}

// 将cur指向倒数第n个节点的前一个节点
ListNode* cur1 = dummyHead;
int size1 = 0;
while (size1 < size - n)
{
cur1 = cur1->next;
size1 ++ ;
}

// 删除倒数第n个节点,并释放其占用的内存
ListNode* tmp = cur1->next;
cur1->next= cur1->next->next;
delete tmp;

// 返回结果
return dummyHead->next;
}
};

更好的办法是采用双指针算法,见实现部分。

面试题 02.07. 链表相交

我的第一想法是采用暴力解法。一个指针指向链表A的头节点,一个指针指向链表B的头节点,移动两个指针,当两个指针指向同一个节点时,说明该节点是两个链表相交的节点。据此,我写出了暴力解法的代码:

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:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* dummyHead1 = new ListNode(0);
ListNode* dummyHead2 = new ListNode(0);
dummyHead1->next = headA;
dummyHead2->next = headB;

ListNode* cur1 = dummyHead1;
ListNode* cur2 = dummyHead2;

for (cur1 = dummyHead1; cur1 != NULL; cur1 = cur1->next)
{
for (cur2 = dummyHead2; cur2 != NULL; cur2 = cur2->next)
{
if (cur1 == cur2)
return cur1;
}
}
return NULL;
}
};

暴力解法的时间复杂度是O(n^2),应该有时间复杂度为O(n)的解法。时间复杂度更低的代码参见代码随想录。

142.环形链表II

从没有见过这类题目,拿到题目毫无思路,直接看视频讲解和文字题解。

实现

24. 两两交换链表中的节点

注意:是交换链表中的节点,而不仅仅交换节点的数值。偶数个节点则正好两两交换,奇数个节点则最后一个点不参与交换。一定需要dummyHead,因为要交换节点1、2,就一定要用到它们之前的那个节点,即dummyHead(dummyHead->2->1->3->…)。同理,要交换节点3、4,就一定要用到它们之前的那个节点,即节点2。因此当前指针一定要指向要反转的两个节点中的前一个节点,且当前指针每次移动两位

若链表中的节点个数为奇数,则cur->next->next == NULL时循环结束,若链表中的节点个数为偶数,则cur -> next == NULL时循环结束。如下图所示。故遍历结束的条件为 while (cur->next != NULL && cur->next->next != NULL)。两个条件不可以反过来写,否则当出现空链表时,cur->next->next没有被定义,会出现空指针异常。
绘图1.png

接下来是两两交换节点的逻辑。改变后的链表为dummyHead->2->1->3,由于dummyHead->1改变为dummyHead->2后,原本的节点1已经不能被访问到了,因此需要先用tmp存下节点1。同理,由于要将2->3改为2->1,因此需要先用tmp1存下节点3。交换完节点的链表为:dummyHead->2->1->4->3…..。对于两两交换节点的逻辑,可以参考代码随想录教程中的三幅图片。

24.两两交换链表中的节点3

交换3和4节点的步骤时:cur目前为1,我们让1指向4,4再指向3,3再指向5(如果有5的话)。

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:
ListNode* swapPairs(ListNode* head) {
// 交换两个节点需要用到这两个节点前的那个节点
// 因此定义虚拟头节点,用于交换节点1和节点2
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* cur = dummyHead;
// 搞清楚遍历的终止条件,参见笔记的图示
// 以下两个终止条件分别针对节点数目为偶数和奇数的情况
while (cur->next != NULL && cur->next->next != NULL)
{
// dummyHead->2时,dummyHead->1不再存在,无法访问到1,因此需要事先存储节点1
ListNode* tmp = cur->next;
// 同理,2->1时,2->3不再存在,无法访问到3,因此需要事先存储节点3
ListNode* tmp1 = cur->next->next->next;

// dummyHead->2->1->3
cur->next = cur->next->next; // dummyHead->1变为dummyHead->2
cur->next->next = tmp; // dummyHead->2->3变成dummyHead->2->1
tmp->next = tmp1; //dummyHead->2->1再在末尾接上3

cur = cur->next->next; // cur指针后移两位
}
return dummyHead->next;
}
};

19.删除链表的倒数第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
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

// 快慢指针
ListNode* fast = dummyHead;
ListNode* slow = dummyHead;

// fast先向后移动n位
while (n -- ) fast = fast->next;

// fast移动到链表的最后一个节点(非空节点),此时slow移动到链表的倒数第n个节点前面的那个节点
while (fast->next != NULL)
{
slow = slow->next;
fast = fast->next;
}

// 删除倒数第n个节点
ListNode* tmp = slow->next;
slow->next = slow->next->next;
delete tmp;

return dummyHead->next;
}
};

也可以让fast先向后移动(n + 1)位,然后让fast和slow同时移动,直到fast移动到NULL为止,此时slow指向的也是倒数第n个节点的前一个节点。对这种办法,可以在移动fast指针前先让n ++ , 也可以在第一个while循环后让fast指针多向后移动一位。最稳妥的写法如下所示:

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:
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);
dummyHead->next = head;

ListNode* fast = dummyHead;
ListNode* slow = dummyHead;

// 首先将快指针移动n + 1步
n ++ ;
while (n -- && fast != NULL) fast = fast->next;

// 快慢指针同时移动,直到快指针指向NULL。此时慢指针指向要删除的节点前面那个节点
while (fast != NULL)
{
fast = fast->next;
slow = slow->next;
}

// 释放内存并删除倒数第n个节点
ListNode* tmp = slow->next;
slow->next = slow->next->next;
delete tmp;

return dummyHead->next;
}
};

由于题目有如下限制:

1
2
3
4
The number of nodes in the list is sz.
1 <= sz <= 30
0 <= Node.val <= 100
1 <= n <= sz

因此即使不加上fast != NULL,也可以通过,但如果题目没有n <= sz的限制,那么必须加上fast != NULL,且不能使用以下写法:

1
2
while (n -- && fast != NULL) fast = fast->next;
fast = fast->next;

因为若采用以上写法,当n > sz时,当while循环结束后,fast已经指向了NULL,此时再做fast = fast->next操作,会导致空指针异常。

面试题 02.07. 链表相交

代码随想录的思路:求出两个链表的长度,并求出两个链表长度的差值,然后让curA移动到,和curB末尾对齐的位置。此时我们就可以比较curA和curB是否相同,如果不相同,同时向后移动curA和curB,如果遇到curA == curB,则找到交点。否则循环退出返回空指针。

看了代码随想录的思路后,我独立写出了代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
int sizea = 0, sizeb = 0;
ListNode* cura = headA;
ListNode* curb = headB;

// 计算a链表的长度
while (cura != NULL)
{
cura = cura->next;
sizea ++ ;
}

// 计算b链表的长度
while (curb != NULL)
{
curb = curb->next;
sizeb ++ ;
}

cura = headA, curb = headB;

int delta = abs(sizea - sizeb); // 两链表长度的差值

if (sizea >= sizeb)
{
while (delta -- ) cura = cura->next;

while (sizeb -- )
{
if (cura == curb) return cura;
else
{
cura = cura->next;
curb = curb->next;
}
}
}
else
{
while (delta -- ) curb = curb->next;

while (sizea -- )
{
if (cura == curb) return cura;
else
{
cura = cura->next;
curb = curb->next;
}
}
}
return NULL;
}
};

这里特别需要注意的是,在计算完a链表和b链表的长度后,需要让 cura = headA, curb = headB

代码随想录的写法更见简洁:

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
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* cura = headA, * curb = headB;
int sizea = 0, sizeb = 0;

// 计算a链表和b链表的长度
while (cura != NULL)
{
cura = cura->next;
sizea ++ ;
}
while (curb != NULL)
{
curb = curb->next;
sizeb ++ ;
}

// 始终保证链表a的长度大于等于链表b的长度
if (sizea < sizeb)
{
swap(sizea, sizeb);
swap(headA, headB);
}

// 交换cura和curb后,再恢复cura和curb的指向
// 也可以在上面直接swap(cura, curb),那这句话就可以写到if判断的前面去
cura = headA, curb = headB;

// 计算两链表的长度之差
int delta = sizea - sizeb;

// 移动指向链表a的指针,让链表a和b的尾部对齐
while (delta -- ) cura = cura->next;

while (cura != NULL) // 写作while (sizeb -- )也可
{
if (cura == curb) return cura;
cura = cura->next;
curb = curb->next;
}
return NULL;
}
};

142.环形链表II

有两问:

  1. 判断链表中是否有环

    用快慢指针来判断是否有环。若链表是一条直线,则快慢指针永远不会相遇。只有当链表中有环存在时,快指针先进入了环且在环中浪费了时间,快慢指针才会相遇。快指针从头节点开始,每次移动两位,慢指针也从头节点开始,每次移动一位,二者若相遇则一定在环里相遇,相遇则说明有环。快指针是一个节点一个节点的靠近慢指针,因此二者一定会在环中相遇。

  2. 找到环的入口

    img

    列方程即可解出x:x = n (y + z) - y, (n >= 1),由于看不出x和负数-y之间的关系,我们让出一圈,看x和z的关系:x = (n - 1) (y + z) + z, (n >= 1)。这就意味着:从头结点出发一个指针,从相遇节点 也出发一个指针,这两个指针每次只走一个节点, 那么当这两个指针相遇的时候就是环形入口的节点

    为什么第一次在环中相遇,slow的步数是x+y而不是 x + 若干环的长度 + y 呢?”这个问题,可以这样解释,设快指针每秒移动2格,慢指针每秒移动1格,圆的周长是k。则慢指针走一圈需要的时间是k,设两指针之间的距离为m(m < k),则快指针追上慢指针的时间是m(快指针相对于满指针每秒移动1格),此时慢指针走过的距离是m,由于m < 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
25
26
27
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode* fast = head, * slow = head;

// 下面两个循环条件保证了fast指针可以指向NULL,但不能指向NULL的next,这样就不会导致空指针异常
while (fast != NULL && fast->next != NULL)
{
fast = fast->next->next; // 快指针每次移动两位
slow = slow->next; // 慢指针每次移动一位

// 快慢指针相遇,说明链表中有环
if (fast == slow)
{
// 一指针从相遇处开始移动,一指针从head处开始移动,二者相遇的位置就是环的入口,数学推导见笔记
ListNode* index1 = fast, * index2 = head;
while (index1 != index2)
{
index1 = index1->next;
index2 = index2->next;
}
return index1;
}
}
return NULL; // 若没有返回环的入口节点,则说明没有环,返回空指针
}
};

我自研的另一种写法:

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:
ListNode *detectCycle(ListNode *head) {
ListNode* fast = head, * slow = head;

bool flag = false;
// 判断链表中是否有环
while (fast != NULL && fast->next != NULL)
{
fast = fast->next->next;
slow = slow->next;
if (fast == slow)
{
flag = true;
break;
}
}

// 有环,则返回环的起点,无环,则返回空指针
if (flag && fast == slow)
{
ListNode* index1 = head, * index2 = slow;

while(index1 != index2)
{
index1 = index1->next;
index2 = index2->next;
}
return index1;
}
else
return NULL;
}
};

注意,一定要通过flag判断,只有当fast和slow相等且二者都在第一个while循环中转过时,才能确保链表中有环,若fast和slow相等,则可能是链表中只有一个节点的情况,此时fast和slow都没有在第一个循环中转过,因此二者相等且都等于head。

心得与备忘录

24. 两两交换链表中的节点

  1. 注意cur应该指向哪里。
  2. 注意遍历的终止条件(奇数/偶数个节点)
  3. 若原先的两节点之间的连接被断开,则需要在断开前保存两节点中后面那个节点,否则后面的那个节点无法被访问到

面试题 02.07. 链表相交

  1. 本题的关键思路在于:对齐两个链表的尾部。本题的算法实际上也是(快慢)双指针算法。
  2. 比较链表中的两个节点是否相同,直接用 cura == curb即可,不能用 cura->val == curb->val && cura->next == curb->next,因为比较两个节点除去比较val和next这两个参数外,还需要比较其本身的内存地址。
  3. 本题的时间复杂度分析:
    计算两个链表的长度:O(n) + O(m)
    调整指针以对齐两个链表:O(n - m)或O(m - n)
    同时遍历两个链表寻找交点:O(min(n, m))
    第一步和第三步的时间复杂度加在一起是 O(n) + O(m) + O(min(n, m))。但是,因为 O(min(n, m))O(n) + O(m)中已经被包含(总是小于或等于 nm),所以总的时间复杂度简化为 O(n) + O(m)。第二步(调整指针以对齐两个链表)的时间复杂度实际上也包含在 O(n) + O(m)中,因为无论是 n - m还是 m - n,它的值总是小于或等于 nm。因此,整个函数的总时间复杂度为 O(n + m),这里 nm分别是两个链表的长度。这个时间复杂度已经涵盖了所有的主要操作,包括计算长度、对齐链表和寻找交点。时间复杂度的计算应当关注主要操作,省略次要操作
  4. 在leetcode中调用swap,abs等函数时,不需要自行引用头文件,基本的函数和数据结构(STL)已经默认被引用了,因此直接写出来即可。

142.环形链表II

  1. 记住使用快慢双指针算法,有环的情况下快慢指针必然会相遇。
  2. 画图理解如何求环的起点的index。
  3. 记得复习时着重看这道题

总结:链表

  1. 插入虚拟头节点dummyHead,可以避免空链表并避免对头节点操作的特判
  2. 创建一个当前节点cur,对整个链表进行遍历(cur = cur->next),而不用链表中原本存在的节点对链表进行遍历
  3. NULL节点表示不存在的节点;虚拟节点实际上是存在的,其值为0,是人为创建的节点
  4. 递归时,需要先检查递归的终止条件,然后执行递归步骤
  5. 想要删除哪个节点,就用cur指针指向其前面的那个节点
  6. 链表中最常用的算法是双指针算法,在206.反转链表,19.删除链表的倒数第N个节点,面试题 02.07. 链表相交,142.环形链表II中都用到了,其他题目基本不需要算法,利用链表的一些基本性质进行增删改查即可。
  7. 记得复习142.环形链表II和24.两两交换链表中的节点,前者是链表中最独特也最难的一道题,难在数学推导和想清楚细节;后者在退出循环的条件和用tmp保存节点方面需要特别注意。

img