2022.4.27 day 14
《代码随想录》双指针法总结
已做10余题,不再赘述。下面给出相关题目总结:
- 26.删除排序数组中的重复项
给你一个 升序排列 的数组 nums
,请你原地删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。
分析:
由于是有序递增数列,所以重复的元素其下标必然连续。当数组长度大于等于2,设置两个指针指向前两个位置,若元素相同则后移fast指针,不相同则将fast值覆盖到slow
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
if (nums.size() < 2) {
return nums.size();
}
int i = 0, j = 1;
while(j < nums.size()) {
if (nums[i] == nums[j]) {
j++;
} else {
i++;
nums[i] = nums[j];
j++;
//i + 1不放这里而放返回中的原因是,本来j就是快指针,i在前面,前面i++已经更新位置,不能再多走
}
}
return i + 1;
}
};
- 283.移动零
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。必须在不复制数组的情况下原地对数组进行操作。
分析:
遍历两次,第一次将所有非零的数填充到前半部分,第二次往后面补充0
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int index = 0;
for (int num : nums) {
if (num != 0) {
nums[index++] = num;
}
}
for (int i = index; i < nums.size(); i++) {
nums[i] = 0;
}
}
};
双指针法:
使用双指针,左指针指向当前已经处理好的序列的尾部,右指针指向待处理序列的头部。 右指针不断向右移动,每次右指针指向非零数,则将左右指针对应的数交换,同时左指针右移。
注意到以下性质: 左指针左边均为非零数; 右指针左边直到左指针处均为零。 因此每次交换,都是将左指针的零与右指针的非零数交换,且非零数的相对顺序并未改变。
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int left = 0, right = 0;
while (right < nums.size()) {
if (nums[right]) {
swap(nums[left], nums[right]);
left++;
}
right++;
}
}
};
- 844.比较含退格的字符串
给定 s
和 t
两个字符串,当它们分别被输入到空白的文本编辑器后,如果两者相等,返回 true
#
代表退格字符。
分析:
同时从后向前遍历S和T(i初始为S末尾,j初始为T末尾),记录#的数量,模拟消除的操作,如果#用完了,就开始比较S[i]和S[j]。
class Solution {
public:
bool backspaceCompare(string S, string T) {
int sSkipNum = 0; // 记录S的#数量
int tSkipNum = 0; // 记录T的#数量
int i = S.size() - 1;
int j = T.size() - 1;
while (1) {
while (i >= 0) { // 从后向前,消除S的#
if (S[i] == '#') sSkipNum++;
else {
if (sSkipNum > 0) sSkipNum--;
else break;
}
i--;
}
while (j >= 0) { // 从后向前,消除T的#
if (T[j] == '#') tSkipNum++;
else {
if (tSkipNum > 0) tSkipNum--;
else break;
}
j--;
}
// 后半部分#消除完了,接下来比较S[i] != T[j]
if (i < 0 || j < 0) break; // S 或者T 遍历到头了
if (S[i] != T[j]) return false;
i--;j--;
}
// 说明S和T同时遍历完毕
if (i == -1 && j == -1) return true;
return false;
}
};
- 时间复杂度:O(n + m)
- 空间复杂度:O(1)
每日小结(双指针用途)
- 原地移除数组,用双指针进行覆盖,通过两个指针在一个for循环下完成两个for循环的工作;删除重复元素;移动0
- 翻转字符串,两个双指针从从两端向中间交换元素;扩容数组并填充,采用从后向前的方法;删除冗余空格,使用双指针法;比较两个含退格的字符串
- 翻转链表;求链表中环的入口
- 求下标的两数之和没法用双指针法。但是求元素数值的三数、四数之和时,均可使用双指针法减少时间复杂度的数量级,一般是将O(n^2)的时间复杂度,降为O(n)
2022.4.28 day 15
东哥带你刷二叉树(纲领篇)
先给出醍醐灌顶的一句话,在学的过程当中体会:
快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后序遍历
一、开头总结
二叉树解题的思维模式分两类:
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse
函数配合外部变量来实现,这叫「遍历」的思维模式。(对应回溯算法核心框架)
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。(对应动态规划核心框架)
无论使用哪种思维模式,你都需要思考:
如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作。
二、二叉树重要性
二叉树无非就是二叉链表,而数组、链表都可以用迭代和递归的方式遍历,又由于二叉链表不能简单地改为迭代方式访问,因此常用递归方式。
三、深入理解前中后序,注意是位置!
前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点,二叉树的所有问题,就是让你在 前中后序位置 注入巧妙的代码逻辑,去达到自己的目的,你只需要单独思考每一个节点应该做什么,其他的不用你管,抛给二叉树遍历框架,递归会在所有节点上做相同的操作。
因此,倒序打印(不是原地翻转)链表可以这么写:
/* 递归遍历单链表,倒序打印链表元素 */
void traverse(ListNode* head) {
if (head == null) {
return;
}
traverse(head.next);
// 后序位置
print(head.val);
}
四、后序位置的特殊之处
中序位置主要用在二叉搜索树场景中,你完全可以把 BST 的中序遍历认为是遍历有序数组。
前序位置本身其实没有什么特别的性质,之所以你发现好像很多题都是在前序位置写代码,实际上是因为我们习惯把那些对前中后序位置不敏感的代码写在前序位置罢了。
前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。
那么换句话说,一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。
104.二叉树的最大深度
遍历:
class Solution {
public:
int res = 0;//最大深度
int depth = 0;//遍历到节点的深度
void traverse(TreeNode* p) {
if (p == nullptr) {
//到达叶子节点后更新最大深度
res = max(res, depth);
return;
}
depth++;
traverse(p->left);
traverse(p->right);
depth--;/*这个解法应该很好理解,但为什么需要在前序位置增加 depth,在后序位置减小 depth?
因为前面说了,前序位置是进入一个节点的时候,后序位置是离开一个节点的时候,depth
记录当前递归到的节点深度,你把 traverse 理解成在二叉树上游走的一个指针,所以当然要这样维
护。*/
}
int maxDepth(TreeNode* root) {
traverse(root);
return res;
}
};
分解:
// 定义:输入根节点,返回这棵二叉树的最大深度
int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
// 利用定义,计算左右子树的最大深度
int leftMax = maxDepth(root.left);
int rightMax = maxDepth(root.right);
// 整棵树的最大深度等于左右子树的最大深度取最大值,
// 然后再加上根节点自己
int res = Math.max(leftMax, rightMax) + 1;
return res;
}
这个解法短小精干,但为什么不常见呢?
一个原因是这个算法的复杂度不好把控,比较依赖语言特性。
Java 的话无论 ArrayList 还是 LinkedList,addAll
方法的复杂度都是 O(N),所以总体的最坏时间复杂度会达到 O(N^2),除非你自己实现一个复杂度为 O(1) 的 addAll
方法,底层用链表的话并不是不可能。
当然,最主要的原因还是因为教科书上从来没有这么教过……(给出Java解法供引申思考)
543.二叉树的直径
给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。
class Solution {
public:
int maxDiameter = 0;
int maxDepth(TreeNode* p) {
if (p == nullptr) {
return 0;
}
int leftMax = maxDepth(p->left);
int rightMax = maxDepth(p->right);
int temp = leftMax + rightMax;
maxDiameter = max(temp, maxDiameter);
return max(leftMax, rightMax) + 1;
}
int diameterOfBinaryTree(TreeNode* root) {
maxDepth(root);
return maxDiameter;
}
};
144.二叉树的前序遍历
最常规的遍历法:
class Solution {
public:
vector<int> result;
void traverse(TreeNode* p) {
if (p == nullptr) {
return;
}
result.push_back(p->val);
traverse(p->left);
traverse(p->right);
}
vector<int> preorderTraversal(TreeNode* root) {
traverse(root);
return result;
}
};
分解问题:一棵二叉树的前序遍历结果 = 根节点 + 左子树的前序遍历结果 + 右子树的前序遍历结果
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
// 定义:输入一棵二叉树的根节点,返回这棵树的前序遍历结果
List<Integer> res = new LinkedList<>();
if (root == null) {
return res;
}
// 前序遍历的结果,root.val 在第一个
res.add(root.val);
// 利用函数定义,后面接着左子树的前序遍历结果
res.addAll(preorderTraversal(root.left));
// 利用函数定义,最后接着右子树的前序遍历结果
res.addAll(preorderTraversal(root.right));
return res;
}
}
102.二叉树的层序遍历
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
//空则直接返回
vector<vector<int>> res;
if (root == nullptr) return res;
//不空,将根节点入栈
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
//新建数组放每一层的值
vector<int> v;
int size = q.size();
for (int i = 0; i < size; i++) {
TreeNode* cur = q.front();
q.pop();
v.push_back(cur->val);
if (cur->left != nullptr) {
q.push(cur->left);
}
if (cur->right != nullptr) {
q.push(cur->right);
}
}
//每层的节点值存入二维数组
res.push_back(v);
}
return res;
}
};
这里面 while 循环和 for 循环分管从上到下和从左到右的遍历,BFS 算法框架就是从二叉树的层序遍历扩展出来的,常用于求 无权图的 最短路径 问题。
当然这个框架还可以灵活修改,题目不需要记录层数(步数)时可以去掉上述框架中的 for 循环;比如 Dijkstra 算法 中计算加权图的最短路径问题,详细探讨了 BFS 算法的扩展。
值得一提的是,有些很明显需要用层序遍历技巧的二叉树的题目,也可以用递归遍历的方式去解决,而且技巧性会更强,非常考察你对前中后序的把控。
希望大家能探索尽可能多的解法,只要参透二叉树这种基本数据结构的原理,那么就很容易在学习其他高级算法的道路上找到抓手,打通回路,形成闭环(手动狗头)。
学到此处,我直呼东哥NB!
每日小结
二叉树问题万变不离其宗,遇到题目当思考:
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse
函数配合外部变量来实现,这叫「遍历」的思维模式。(对应回溯算法核心框架)
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。(对应动态规划核心框架)
无论使用哪种思维模式,你都需要思考:
如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作。
2022.4.30 day 16
东哥带你刷二叉树(思路篇)
这部分是将(纲领篇)总结的思路框架进行实践。
226.翻转二叉树
遍历法直接秒杀:
class Solution {
public:
void traverse(TreeNode* p) {
if (p == nullptr) {
return;
}
traverse(p->left);
traverse(p->right);
swap(p->left, p->right);
}
TreeNode* invertTree(TreeNode* root) {
traverse(root);
return root;
}
};
分解法:
class Solution {
public:
//定义,将root为根的二叉树翻转,返回翻转后的二叉树的根节点
TreeNode* invertTree(TreeNode* root) {
if (root == NULL) return root;
//先翻转左右子树
TreeNode* left = invertTree(root->left);
TreeNode* right = invertTree(root->right);
//交换左右子节点
root->left = right;
root->right = left;
return root;
}
};
116.填充每一个节点的下一个右侧节点指针
题目的意思就是把二叉树的每一层节点都用 next
指针连接起来。为了链接不是同一父节点的孩子,需要将二叉树抽象为三叉树,三叉树上的每个节点就是原先二叉树的两个相邻节点。
做完此题,感觉抽象为三叉树的思路还是非常巧妙的。
class Solution {
public:
void traverse(Node* node1, Node* node2) {
if (node2 == NULL || node1 == NULL) return;
// 将传入的两个节点连接起来
node1->next = node2;
traverse(node1->left, node1->right);
// 连接跨越父节点的两个子节点
traverse(node1->right, node2->left);
traverse(node2->left, node2->right);
}
Node* connect(Node* root) {
if (root == NULL) return root;
// 遍历三叉树,连接相邻节点
traverse(root->left, root->right);
return root;
}
};
本题无法使用分解问题解决。
114.二叉树展开为链表
乍一看感觉是可以遍历解决的:对整棵树进行前序遍历,一边遍历一边构造出一条「链表」就行了。但是注意 flatten
函数的签名,返回类型为 void
,也就是说题目希望我们在原地把二叉树拉平成链表。这样一来,没办法通过简单的二叉树遍历来解决这道题了。
因此,采取分解的思路。对于一个节点 x
,可以执行以下流程:
1、先利用 flatten(x.left)
和 flatten(x.right)
将 x
的左右子树拉平。
2、将 x
的左子树作为右子树,然后将先前的整个右子树作为新的右子树。
class Solution {
public:
//注意返回值是void,原地工作
void flatten(TreeNode* root) {
//空或只有根节点,直接返回
if (root == nullptr) return;
if (root->left == nullptr && root->right == nullptr) return;
flatten(root->left);
flatten(root->right);
//保存左右子树,类似翻转二叉树的思路
TreeNode* left = root->left;
TreeNode* right = root->right;
//左树移接到右树
root->left = nullptr;
root->right = left;
//找到拉平的右末尾位置,补充之前的右子树
TreeNode* p = root;
while(p->right != nullptr) p = p ->right;
p->right = right;
}
};
每日小结
- 体会遍历、分解两种方法在具体题目当中的应用。有些题目二者均可解决,有些则只能选择其一解决。
- 如何分辨?还需多练习、多总结。
本周小结
- 《代码随想录》跟刷到一段落。前期跟Carl哥刷题入门,熟悉了数组、字符串等的相关题目,并进行了双指针思想在10道题目中如何运用的总结。
- 《labuladong的算法小抄》开始跟刷。久闻东哥的二叉树讲的好,而二叉树是以后回溯和动规算法的基础,因此调整跟刷策略。
- 发现遗忘的很快,因此今后当刷二轮、三轮,并定期复习总结归纳。目前先进行第一轮稳步推进,打好基础,不能着急。