力扣刷题复习第一篇
数组
1、数组理论基础
2、二分查找
-
方法一
// 版本一 class Solution { public: int search(vector<int>& nums, int target) { int left = 0; int right = nums.size() - 1; // 定义target在左闭右闭的区间里,[left, right] while (left <= right) { // 当left==right,区间[left, right]依然有效,所以用 <= int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2 if (nums[middle] > target) { right = middle - 1; // target 在左区间,所以[left, middle - 1] } else if (nums[middle] < target) { left = middle + 1; // target 在右区间,所以[middle + 1, right] } else { // nums[middle] == target return middle; // 数组中找到目标值,直接返回下标 } } // 未找到目标值 return -1; } };
方法二
// 版本二 class Solution { public: int search(vector<int>& nums, int target) { int left = 0; int right = nums.size(); // 定义target在左闭右开的区间里,即:[left, right) while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间,所以使用 < int middle = left + ((right - left) >> 1); if (nums[middle] > target) { right = middle; // target 在左区间,在[left, middle)中 } else if (nums[middle] < target) { left = middle + 1; // target 在右区间,在[middle + 1, right)中 } else { // nums[middle] == target return middle; // 数组中找到目标值,直接返回下标 } } // 未找到目标值 return -1; } };
-
总结
二分法是非常重要的基础算法,为什么很多同学对于二分法都是一看就会,一写就废?
其实主要就是对区间的定义没有理解清楚,在循环中没有始终坚持根据查找区间的定义来做边界处理。
区间的定义就是不变量,那么在循环中坚持根据查找区间的定义来做边界处理,就是循环不变量规则。
本篇根据两种常见的区间定义,给出了两种二分法的写法,每一个边界为什么这么处理,都根据区间的定义做了详细介绍。
相信看完本篇应该对二分法有更深刻的理解了。
3、移除元素
-
https://leetcode.cn/problems/remove-element/
子数组问题很容易想到双指针的做法,我们检查是否有可以使用双指针的性质。
-
使用双指针计算子数组数量的题目需要满足的性质,是以下两者之一:
- 当一个数组满足条件的情况下,增加一个元素必然仍满足条件。
- 当一个数组满足条件的情况下,去掉一个元素必然仍满足条件。
4、有序数组的平方
5、长度最小的子数组
-
接下来就开始介绍数组操作中另一个重要的方法:滑动窗口。
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
在暴力解法中,是一个for循环滑动窗口的起始位置,一个for循环为滑动窗口的终止位置,用两个for循环 完成了一个不断搜索区间的过程。
那么滑动窗口如何用一个for循环来完成这个操作呢。
首先要思考 如果用一个for循环,那么应该表示 滑动窗口的起始位置,还是终止位置。
如果只用一个for循环来表示 滑动窗口的起始位置,那么如何遍历剩下的终止位置?
此时难免再次陷入 暴力解法的怪圈。
所以 只用一个for循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。
那么问题来了, 滑动窗口的起始位置如何移动呢?
6、螺旋矩阵Ⅱ
链表
1、链表理论基础
链表在内存中不是连续分布的
链表的定义
// 单链表
struct ListNode {
int val; // 节点上存储的元素
ListNode *next; // 指向下一个节点的指针
ListNode(int x) : val(x), next(NULL) {} // 节点的构造函数
};
通过自己定义构造函数初始化节点:
ListNode* head = new ListNode(5);
使用默认构造函数初始化节点:
ListNode* head = new ListNode();
head->val = 5;
所以如果不定义构造函数使用默认构造函数的话,在初始化的时候就不能直接给变量赋值!
2、移除链表元素
-
要记得清零节点内存
删除头节点有两种方式
-
直接使用原来的链表来进行删除操作
将head后移一个,删除方式和其他节点不一样
class Solution { public: ListNode* removeElements(ListNode* head, int val) { // 删除头结点 while (head != NULL && head->val == val) { // 注意这里不是if ListNode* tmp = head; head = head->next; delete tmp; } // 删除非头结点 ListNode* cur = head; while (cur != NULL && cur->next!= NULL) { if (cur->next->val == val) { ListNode* tmp = cur->next; cur->next = cur->next->next; delete tmp; } else { cur = cur->next; } } return head; } };
-
设置一个虚拟头结点在进行删除操作
和其他节点一样
class Solution { public: ListNode* removeElements(ListNode* head, int val) { ListNode* dummyHead = new ListNode(0); // 设置一个虚拟头结点 dummyHead->next = head; // 将虚拟头结点指向head,这样方面后面做删除操作 ListNode* cur = dummyHead; while (cur->next != NULL) { if(cur->next->val == val) { ListNode* tmp = cur->next; cur->next = cur->next->next; delete tmp; } else { cur = cur->next; } } head = dummyHead->next; delete dummyHead; return head; } };
-
3、设计链表
-
链表操作的两种方式:
- 直接使用原来的链表来进行操作。
- 设置一个虚拟头结点在进行操作。
下面采用的设置一个虚拟头结点(这样更方便一些,大家看代码就会感受出来)。
class MyLinkedList { public: // 定义链表节点结构体 struct LinkedNode { int val; LinkedNode* next; LinkedNode(int val):val(val), next(nullptr){} }; // 初始化链表 MyLinkedList() { _dummyHead = new LinkedNode(0); // 这里定义的头结点 是一个虚拟头结点,而不是真正的链表头结点 _size = 0; } // 获取到第index个节点数值,如果index是非法数值直接返回-1, 注意index是从0开始的,第0个节点就是头结点 int get(int index) { if (index > (_size - 1) || index < 0) { return -1; } LinkedNode* cur = _dummyHead->next; while(index--){ // 如果--index 就会陷入死循环 cur = cur->next; } return cur->val; } // 在链表最前面插入一个节点,插入完成后,新插入的节点为链表的新的头结点 void addAtHead(int val) { LinkedNode* newNode = new LinkedNode(val); newNode->next = _dummyHead->next; _dummyHead->next = newNode; _size++; } // 在链表最后面添加一个节点 void addAtTail(int val) { LinkedNode* newNode = new LinkedNode(val); LinkedNode* cur = _dummyHead; while(cur->next != nullptr){ cur = cur->next; } cur->next = newNode; _size++; } // 在第index个节点之前插入一个新节点,例如index为0,那么新插入的节点为链表的新头节点。 // 如果index 等于链表的长度,则说明是新插入的节点为链表的尾结点 // 如果index大于链表的长度,则返回空 // 如果index小于0,则在头部插入节点 void addAtIndex(int index, int val) { if(index > _size) return; if(index < 0) index = 0; LinkedNode* newNode = new LinkedNode(val); LinkedNode* cur = _dummyHead; while(index--) { cur = cur->next; } newNode->next = cur->next; cur->next = newNode; _size++; } // 删除第index个节点,如果index 大于等于链表的长度,直接return,注意index是从0开始的 void deleteAtIndex(int index) { if (index >= _size || index < 0) { return; } LinkedNode* cur = _dummyHead; while(index--) { cur = cur ->next; } LinkedNode* tmp = cur->next; cur->next = cur->next->next; delete tmp; //delete命令指示释放了tmp指针原本所指的那部分内存, //被delete后的指针tmp的值(地址)并非就是NULL,而是随机值。也就是被delete后, //如果不再加上一句tmp=nullptr,tmp会成为乱指的野指针 //如果之后的程序不小心使用了tmp,会指向难以预想的内存空间 tmp=nullptr; _size--; } // 打印链表 void printLinkedList() { LinkedNode* cur = _dummyHead; while (cur->next != nullptr) { cout << cur->next->val << " "; cur = cur->next; } cout << endl; } private: int _size; LinkedNode* _dummyHead; };
注
构造函数后面的冒号起分割作用,是类给成员变量赋值的方法,初始化列表,更适用于成员变量的常量const型。
struct _XXX{
_XXX() : y(0xc0) {}
};
4、 反转链表
-
解题思路
用前后指针就可以优雅的解决了,遍历一次,更改头节点即可/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* reverseList(ListNode* head) { ListNode *p = nullptr, *q = head;//前后指针,前后指针,反转next即可 while (q != nullptr) { ListNode *t = q->next; q->next = p; p = q; q = t; } head = p; return head; } }; //递归实现 class Solution { public: ListNode* reverse(ListNode* pre,ListNode* cur){ if(cur == NULL) return pre; ListNode* temp = cur->next; cur->next = pre; // 可以和双指针法的代码进行对比,如下递归的写法,其实就是做了这两步 // pre = cur; // cur = temp; return reverse(cur,temp); } ListNode* reverseList(ListNode* head) { // 和双指针法初始化是一样的逻辑 // ListNode* cur = head; // ListNode* pre = NULL; return reverse(NULL, head); } };
5、两两交换链表中的节点
-
创建应该虚拟头节点会事半功倍,以为不用单独处理head了
在写停止遍历条件时可以先掠过,写完下面代码后根据用到的数据来限制跟清晰
6、删除链表的倒数第N个节点(遍历一遍)
-
19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)
用双指针,让fast移动n步,然后让fast和slow同时移动,直到fast指向链表末尾。删掉slow所指向的节点就可以了
class Solution { public: ListNode* removeNthFromEnd(ListNode* head, int n) { ListNode* dummyHead = new ListNode(0); dummyHead->next = head; ListNode* slow = dummyHead; ListNode* fast = dummyHead; while(n-- && fast != NULL) { fast = fast->next; } fast = fast->next; // fast再提前走一步,因为需要让slow指向删除节点的上一个节点 while (fast != NULL) { fast = fast->next; slow = slow->next; } ListNode *tmp = slow->next; slow->next = tmp->next; delete tmp; return dummyHead->next; } };
7、链表相交
-
面试题 02.07. 链表相交 - 力扣(LeetCode)
两种思路,有一个是自己想的,就是遍历任意一个链表,把每个结点的指针存到数组中,然后再遍历另一个链表,当遍历的指针存在于数组中时,就是交点。另外一个思路时看把两个链表尾部对其,让后从较短链表的头节点位置同时对两个链表开始遍历,相等时就找到了交点。
//第二种想法 class Solution { public: ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) { ListNode* curA = headA; ListNode* curB = headB; int lenA = 0, lenB = 0; while (curA != NULL) { // 求链表A的长度 lenA++; curA = curA->next; } while (curB != NULL) { // 求链表B的长度 lenB++; curB = curB->next; } curA = headA; curB = headB; // 让curA为最长链表的头,lenA为其长度 if (lenB > lenA) { swap (lenA, lenB); swap (curA, curB); } // 求长度差 int gap = lenA - lenB; // 让curA和curB在同一起点上(末尾位置对齐) while (gap--) { curA = curA->next; } // 遍历curA 和 curB,遇到相同则直接返回 while (curA != NULL) { if (curA == curB) { return curA; } curA = curA->next; curB = curB->next; } return NULL; } };
8、环形链表2
-
-
判断链表是否有环
可以使用快慢指针法,分别定义 fast 和 slow 指针,从头结点出发,fast指针每次移动两个节点,slow指针每次移动一个节点,如果 fast 和 slow指针在途中相遇 ,说明这个链表有环。
为什么fast 走两个节点,slow走一个节点,有环的话,一定会在环内相遇呢,而不是永远的错开呢
首先第一点:fast指针一定先进入环中,如果fast指针和slow指针相遇的话,一定是在环中相遇,这是毋庸置疑的。
-
此时已经可以判断链表是否有环了,那么接下来要找这个环的入口了。
那么相遇时: slow指针走过的节点数为:
x + y
, fast指针走过的节点数:x + y + n (y + z)
,n为fast指针在环内走了n圈才遇到slow指针, (y+z)为 一圈内节点的个数A。因为fast指针是一步走两个节点,slow指针一步走一个节点, 所以 fast指针走过的节点数 = slow指针走过的节点数 * 2:
(x + y) * 2 = x + y + n (y + z)
两边消掉一个(x+y):
x + y = n (y + z)
因为要找环形的入口,那么要求的是x,因为x表示 头结点到 环形入口节点的的距离。
所以要求x ,将x单独放在左面:
x = n (y + z) - y
,再从n(y+z)中提出一个 (y+z)来,整理公式之后为如下公式:
x = (n - 1) (y + z) + z
注意这里n一定是大于等于1的,因为 fast指针至少要多走一圈才能相遇slow指针。这个公式说明什么呢?
先拿n为1的情况来举例,意味着fast指针在环形里转了一圈之后,就遇到了 slow指针了。
当 n为1的时候,公式就化解为
x = z
,这就意味着,从头结点出发一个指针,从相遇节点 也出发一个指针,这两个指针每次只走一个节点, 那么当这两个指针相遇的时候就是 环形入口的节点。
-
附加
-
-
官方题解做法很好,将链表结点指针存数组中进行操作
class Solution { public: void reorderList(ListNode *head) { if (head == nullptr) { return; } vector<ListNode *> vec; ListNode *node = head; while (node != nullptr) { vec.emplace_back(node);//常用的push_back也可以 node = node->next; } int i = 0, j = vec.size() - 1; while (i < j) { vec[i]->next = vec[j]; i++; if (i == j) { break; } vec[j]->next = vec[i]; j--; } vec[i]->next = nullptr; } };
-
-
调试的时候很刻骨铭心
/** * Definition for singly-linked list. * struct ListNode { * int val; * ListNode *next; * ListNode() : val(0), next(nullptr) {} * ListNode(int x) : val(x), next(nullptr) {} * ListNode(int x, ListNode *next) : val(x), next(next) {} * }; */ class Solution { public: ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) { //创建虚拟头节点 ListNode *h = new ListNode(0, list1), *q1 = h, *q2 = list2; while (q2 != nullptr) { //if中的两个语句顺序也至关重要,处理list1尾节点 if (q1->next == nullptr || q1->next->val > q2->val) { ListNode *t = q2->next; q2->next = q1->next; q1->next = q2; q2 = t; } else { q1 = q1->next; } }; return h->next; } };
哈希表
1、哈希表理论基础
哈希表也称散列表。
哈希表是根据关键码的值而直接进行访问的数据结构。
直白来讲其实数组就是一张哈希表。
哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,一般哈希表都是用来快速判断一个元素是否出现集合里。枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
将数据信息映射到哈希表上就涉及到了hash function ,也就是哈希函数
哈希函数
哈希函数,把数据信息直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道数据内容。
当数值大于哈希表大小时,为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,就要我们就保证了学生姓名一定可以映射到哈希表上了。
哈希碰撞
当数据存放下标一样时就产生了哈希碰撞,有以下两种解决方法
-
拉链法
将发生冲突的元素的元素储存到链表中,放到索引处其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
-
线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题
常见的三种哈希结构
-
数组
-
set(集合)
集合 底层实现 是否有序 数值是否可以重复 能否更改数值 查询效率 增删效率 std::set 红黑树 有序 否 否 O(log n) O(log n) std::multiset 红黑树 有序 是 否 O(logn) O(logn) std::unordered_set 哈希表 无序 否 否 O(1) O(1) std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。
当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。
虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,std::set、std::multiset 使用红黑树来索引和存储,不过给我们的使用方式,还是哈希法的使用方式,即key和value。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 map也是一样的道理。
-
map(映射)
映射 底层实现 是否有序 数值是否可以重复 能否更改数值 查询效率 增删效率 std::map 红黑树 key有序 key不可重复 key不可修改 O(logn) O(logn) std::multimap 红黑树 key有序 key可重复 key不可修改 O(log n) O(log n) std::unordered_map 哈希表 key无序 key不可重复 key不可修改 O(1) O(1) std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。那么再来看一下map ,在map 是一个key value 的数据结构,map中,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的
2、有效的字母异位词
-
简单方法有很多种,可以排序判相等,也可以用哈希表
3、两个数组的交集
-
如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费!这里用set实现一下,当然自己也写出了数组方式是。
4、快乐数
-
题目中说了会 无限循环,那么也就是说求和的过程中,sum会重复出现,这对解题很重要!
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法了。所以这道题目使用哈希法,来判断这个sum是否重复出现,如果重复了就是return false, 否则一直找到sum为1为止。出快慢指针也可以解决该问题,官方题解指出有三种方向:
- 等于1
- 循环
- 无限趋近于无穷大(不存在)
证明了重复的合理性
5、两数之和
-
需要一个集合来存放我们遍历过的元素,然后在遍历数组的时候去询问这个集合,某元素是否遍历过,也就是 是否出现在这个集合。
那么我们就应该想到使用哈希法了。
因为本地,我们不仅要知道元素有没有遍历过,还要知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。这道题 我们需要 给出一个元素,判断这个元素是否出现过,如果出现过,返回这个元素的下标。
那么判断元素是否出现,这个元素就要作为key,所以数组中的元素作为key,有key对应的就是value,value用来存下标。
当然也可以双for暴力
class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { //std带上肯定对,不带也没错 std::unordered_map<int, int> map; int n = nums.size(); for (int i = 0; i < n; i++) { //用auto来创建迭代器 auto it = map.find(target-nums[i]); if (it == map.end()) {、 //插入时要用pair map.insert(pair<int, int>(nums[i],i)); } else { //可以用大括号返回 return {it->second,i}; } } return {}; } };
6、四数相加
-
直接暴力会超时,要用哈希表,可以用unordered_map实现,具体写出了两个代码,思路都一样
class Solution { public: int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) { std::unordered_map<int, int> map; int count = 0; for (int a : nums1) { for (int b : nums2) { auto it = map.find(a + b); if (it == map.end()) { map.insert(pair<int, int>(a+b, 1)); } else { //key不可重复这种计数是错误的 // int t = it->second; // t++; // map.insert(pair<int, int>(a+b, 2)); it->second++; } } } for (int c : nums3) { for (int d : nums4) { auto it = map.find(-c - d); if (it != map.end()) { count += it->second; } } } return count; } };
直接用索引进行操作
class Solution { public: int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) { std::unordered_map<int, int> map; int count = 0; for (int a : nums1) { for (int b : nums2) { map[a + b]++; } } for (int c : nums3) { for (int d : nums4) { if (map.find(0 - (c + d)) != map.end()) { count += map[0 - (c + d)]; } } } return count; } };
7、赎金信
-
分别用来三种方法,暴力,数组哈希表,unordered_map哈希表。进一步熟悉了map代码如下
class Solution { public: bool canConstruct(string ransomNote, string magazine) { //哈希表unordered_map实现 unordered_map<char, int> smap; for (char m : magazine) { smap[m]++; } for (char r : ransomNote) { if (smap[r]-- != 0) { continue; } else { return false; } } return true; } };
8、三数之和
-
哈希表(超时):两层for循环就可以确定 a 和b 的数值了,可以使用哈希法来确定 0-(a+b) 是否在 数组里出现过,其实这个思路是正确的,但是我们有一个非常棘手的问题,就是题目中说的不可以包含重复的三元组。
把符合条件的三元组放进vector中,然后再去重,这样是非常费时的,很容易超时,也是这道题目通过率如此之低的根源所在。
去重的过程不好处理,有很多小细节,如果在面试中很难想到位。
时间复杂度可以做到O(n^2),但还是比较费时的,因为不好做剪枝操作。
class Solution { public: vector<vector<int>> threeSum(vector<int>& nums) { vector<vector<int>> result; sort(nums.begin(), nums.end()); // 找出a + b + c = 0 // a = nums[i], b = nums[j], c = -(a + b) for (int i = 0; i < nums.size(); i++) { // 排序之后如果第一个元素已经大于零,那么不可能凑成三元组 if (nums[i] > 0) { break; } if (i > 0 && nums[i] == nums[i - 1]) { //三元组元素a去重 continue; } unordered_set<int> set; for (int j = i + 1; j < nums.size(); j++) { if (j > i + 2 && nums[j] == nums[j-1] && nums[j-1] == nums[j-2]) { // 三元组元素b去重 continue; } int c = 0 - (nums[i] + nums[j]); if (set.find(c) != set.end()) { result.push_back({nums[i], nums[j], c}); set.erase(c);// 三元组元素c去重 } else { set.insert(nums[j]); } } } return result; } };
双指针:首先将数组排序,然后有一层for循环,i从下标0的地方开始,同时定一个下标left 定义在i+1的位置上,定义下标right 在数组结尾的位置上。
依然还是在数组中找到 abc 使得a + b +c =0,我们这里相当于 a = nums[i],b = nums[left],c = nums[right]。
接下来如何移动left 和right呢, 如果nums[i] + nums[left] + nums[right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以right下标就应该向左移动,这样才能让三数之和小一些。
如果 nums[i] + nums[left] + nums[right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些,直到left与right相遇为止。
class Solution { public: vector<vector<int>> threeSum(vector<int>& nums) { vector<vector<int>> result; sort(nums.begin(), nums.end()); // 找出a + b + c = 0 // a = nums[i], b = nums[left], c = nums[right] for (int i = 0; i < nums.size(); i++) { // 排序之后如果第一个元素已经大于零,那么无论如何组合都不可能凑成三元组,直接返回结果就可以了 if (nums[i] > 0) { return result; } // 错误去重a方法,将会漏掉-1,-1,2 这种情况 /* if (nums[i] == nums[i + 1]) { continue; } */ // 正确去重a方法 if (i > 0 && nums[i] == nums[i - 1]) { continue; } int left = i + 1; int right = nums.size() - 1; while (right > left) { // 去重复逻辑如果放在这里,0,0,0 的情况,可能直接导致 right<=left 了,从而漏掉了 0,0,0 这种三元组 /* while (right > left && nums[right] == nums[right - 1]) right--; while (right > left && nums[left] == nums[left + 1]) left++; */ if (nums[i] + nums[left] + nums[right] > 0) right--; else if (nums[i] + nums[left] + nums[right] < 0) left++; else { result.push_back(vector<int>{nums[i], nums[left], nums[right]}); // 去重逻辑应该放在找到一个三元组之后,对b 和 c去重 while (right > left && nums[right] == nums[right - 1]) right--; while (right > left && nums[left] == nums[left + 1]) left++; // 找到答案时,双指针同时收缩 right--; left++; } } } return result; } };
9、四数之和
-
用双指针更加清晰明了
class Solution { public: vector<vector<int>> fourSum(vector<int>& nums, int target) { vector<vector<int>> result; sort(nums.begin(), nums.end()); int n = nums.size(); for (int i = 0; i < n; i++) { //剪枝处理 if (nums[i] > target && nums[i] >= 0) { break; } //去重 if (i > 0 && nums[i] == nums[i-1]) { continue; } for (int j = i + 1; j < n; j++) { //二级剪枝 if (nums[i] + nums[j] > target && nums[i] + nums[j] >= 0) { break; } //去重 if (j > i + 1 && nums[j] == nums[j - 1]) { continue; } int left = j + 1; int right = n - 1; while (left < right) { //注意数据范围,测试样例可能要超int if ((long)nums[i] + nums[j] + nums[left] +nums[right] < target) { left++; } else if ((long)nums[i] + nums[j] + nums[left] + nums[right] > target) { right--; } else { result.push_back({nums[i], nums[j], 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 result; } };
附加
-
可以把前后一样的卡片加入哈希表,再遍历其他卡片,在哈希表中查找,若找不到,说明这个数可以去其他不同,符合条件。
class Solution { public: int flipgame(vector<int>& fronts, vector<int>& backs) { unordered_set<int> card; int result = 5000, n = fronts.size(); for (int i = 0; i < n; i++) { if (fronts[i] == backs[i]) { card.insert(fronts[i]); } } for (int f : fronts) { if (f < result && card.find(f) == card.end()) { result = f; } } for (int b : backs) { //也可以改成card.count(b)==0 if (b < result && card.find(b) == card.end()) { result = b; } } return result % 5000;//灵活运用模除,处理result没更新的情况 } };
字符串
1、反转字符串
-
双指针解题9行解决。在练题要合理运用库函数,非核心考的运用库函数
2、反转字符串2
-
在遍历字符串的过程中,只要让 i += (2 * k),i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。
因为要找的也就是每2 * k 区间的起点,这样写,程序会高效很多。
下面代码要熟悉reverse的运用
class Solution { public: void reverse(string& s, int start, int end) { for (int i = start, j = end; i < j; i++, j--) { swap(s[i], s[j]); } } 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, i, i + k - 1); continue; } // 3. 剩余字符少于 k 个,则将剩余字符全部反转。 reverse(s, i, s.size() - 1); } return s; } };
3、替换空格
-
剑指 Offer 05. 替换空格 - 力扣(LeetCode)
可以开辟一个新字符串,用双指针可以避免开辟新字符串
class Solution { public: string replaceSpace(string s) { int count = 0; // 统计空格的个数 int sOldSize = s.size(); for (int i = 0; i < s.size(); i++) { if (s[i] == ' ') { count++; } } // 扩充字符串s的大小,也就是每个空格替换成"%20"之后的大小 s.resize(s.size() + count * 2); int sNewSize = s.size(); // 从后先前将空格替换为"%20" for (int i = sNewSize - 1, j = sOldSize - 1; j < i; i--, j--) { if (s[j] != ' ') { s[i] = s[j]; } else { s[i] = '0'; s[i - 1] = '2'; s[i - 2] = '%'; i -= 2; } } return s; } };
4、反转字符串中的单词
-
第一种是在开辟额外内存空间,看大佬用sstream比较简单。第二种看随想录不开辟额外空间用双指针,先后去空格,反转字符串,反转单词实现。
//sstream方法 class Solution { public: string reverseWords(string s) { stringstream ss; string ans="",temp; ss<<s; while(ss>>temp){ ans=" "+temp+ans; } if(ans!="") ans.erase(ans.begin()); return ans; } }; //双指针方法 class Solution { public: void reverse(string& s, int start, int end){ //翻转,区间写法:左闭右闭 [] for (int i = start, j = end; i < j; i++, j--) { swap(s[i], s[j]); } } void removeExtraSpaces(string& s) {//去除所有空格并在相邻单词之间添加空格, 快慢指针。 int slow = 0; //整体思想参考https://programmercarl.com/0027.移除元素.html for (int i = 0; i < s.size(); ++i) { // if (s[i] != ' ') { //遇到非空格就处理,即删除所有空格。 if (slow != 0) s[slow++] = ' '; //手动控制空格,给单词之间添加空格。slow != 0说明不是第一个单词,需要在单词前添加空格。 while (i < s.size() && s[i] != ' ') { //补上该单词,遇到空格说明单词结束。 s[slow++] = s[i++]; } } } s.resize(slow); //slow的大小即为去除多余空格后的大小。 } string reverseWords(string s) { removeExtraSpaces(s); //去除多余空格,保证单词之间之只有一个空格,且字符串首尾没空格。 reverse(s, 0, s.size() - 1); int start = 0; //removeExtraSpaces后保证第一个单词的开始下标一定是0。 for (int i = 0; i <= s.size(); ++i) { if (i == s.size() || s[i] == ' ') { //到达空格或者串尾,说明一个单词结束。进行翻转。 reverse(s, start, i - 1); //翻转,注意是左闭右闭 []的翻转。 start = i + 1; //更新下一个单词的开始下标start } } return s; } };
5、剑指 Offer 58 - II. 左旋转字符串
-
剑指 Offer 58 - II. 左旋转字符串 - 力扣(LeetCode)
代码随想录思路很棒,反转三次就行了,其实上个题中涉及到了,加深印象。
class Solution { public: void sswap(string &s, int left, int right) { while (left < right) { swap (s[left++], s[right--]); } } string reverseLeftWords(string s, int n) { int m = s.size(); //全反转 sswap(s, 0, m - 1); //前反转 sswap(s, 0, m - n - 1); //后反转 sswap(s, m - n, m - 1); return s; } };
用库函数好像更酷
class Solution { public: string reverseLeftWords(string s, int n) { reverse(s.begin(), s.begin() + n); reverse(s.begin() + n, s.end()); reverse(s.begin(), s.end()); return s; } };
6、找出字符串中第一个匹配项的下标(KMP)
-
28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)
实现库函数,用到了KMP
-
KMP学习
-
KMP有什么用
KMP主要应用在字符串匹配上。
KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。
-
什么是前缀表
next数组就是一个前缀表(prefix table)。
前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。
前缀表是如何记录的呢?
首先要知道前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。
那么什么是前缀表:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
-
为什么要用前缀表
前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力
-
如何计算前缀表
注意字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
求最长相等(公共)前后缀
例如
aab 长度为0,前缀有aa、a。后缀有ab、b
aaba 长度为1,前缀有aab、aa、a。后缀有aba、ba、a
可以看出似乎前后对应有几位相同的有关系,但前后缀也需要脚踏实地理解怎么求出来的。
那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:
可以看出模式串与前缀表对应位置的数字表示的就是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。
为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀。
所以要看前一位的 前缀表的数值。
前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比配。 可以再反复看一下上面的动画。
最后就在文本串中找到了和模式串匹配的子串了。
-
前缀表与next数组(next下一个位置)
很多KMP算法的时间都是使用next数组来做回退操作,那么next数组与前缀表有什么关系呢?
next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。
-
使用next数组来匹配
以下我们以前缀表统一减一之后的next数组来做演示。
有了next数组,就可以根据next数组来 匹配文本串s,和模式串t了。
注意next数组是新前缀表(旧前缀表统一减一了)。其实最后还要转换回来
-
时间复杂度
其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。
-
-
KMP实现,求next数组
-
具体实现next有三种(原理是一样的方法可能会有出入):
- 初始值(遇见冲突,根据next[i-1]前退);
- 右移一位,初始位置为-1(遇见冲突,根据next[i]前退);
- 统一减一(遇见冲突,根据next[i-1]+1前退);
-
构造next数组
-
初始化
i后缀末尾,j前缀末尾;可以是在理解i是控制字符长度的变量,而
-
前后缀不同
j根据规则练连续回退(j>0)
-
前后缀相同
将j++存入next数组和值
-
-
-
具体实现:
前缀表统一减一 C++代码实现
class Solution {
public:
void getNext(int* next, const string& s) {
int j = -1;
next[0] = j;
for(int i = 1; i < s.size(); i++) { // 注意i从1开始
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回退
}
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
int strStr(string haystack, string needle) {
if (needle.size() == 0) {
return 0;
}
int next[needle.size()];
getNext(next, needle);
int j = -1; // // 因为next数组里记录的起始位置为-1
for (int i = 0; i < haystack.size(); i++) { // 注意i就从0开始
while(j >= 0 && haystack[i] != needle[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
if (haystack[i] == needle[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (needle.size() - 1) ) { // 文本串s里出现了模式串t
return (i - needle.size() + 1);
}
}
return -1;
}
};
前缀表(不减一)C++实现
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) {
if (needle.size() == 0) {
return 0;
}
int next[needle.size()];
getNext(next, needle);
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);
}
}
return -1;
}
};
7、重复的子字符串
-
暴力。以第一个字母为开始的子串就可以,所以一个for循环获取子串的终止位置就行了
class Solution { public: bool repeatedSubstringPattern(string s) { //暴力 int n = s.size(); if (n == 1) return false; for (int i = 0; i < n / 2; i++) { string str = s.substr(0,i + 1); string ss = str; while (ss.size() <= n) { if (ss == s) { return true; } else { ss += str; } } } return false; } };
移动匹配。判断字符串s是否由重复子串组成,只要两个s拼接在一起,里面还出现一个s的话,就说明是由重复子串组成。在判断 s + s 拼接的字符串里是否出现一个s的的时候,要刨除 s + s 的首字符和尾字符,这样避免在s+s中搜索出原来的s,我们要搜索的是中间拼接出来的s。
class Solution { public: bool repeatedSubstringPattern(string s) { //移动匹配 string str = s + s; int n = str.size(); str.erase(n-1, 1),str.erase(0, 1); if (str.find(s) == -1) { return false; } else { return true; } } };
KMP(-1自己实现)
class Solution { public: void getNext(int* next, const string& s) { int j = -1; next[0] = j; for (int i = 1; i < s.size(); i++) { while (j >= 0 && s[i] != s[j + 1]) { j = next[j]; } if (s[i] == s[j + 1]) { j++; } next[i] = j; } } bool repeatedSubstringPattern(string s) { //KMP实现 if (s.size() == 0) { return false; } int next[s.size()]; getNext(next, s); int len = s.size(); if (next[len - 1] != -1 && len % (len - next[len - 1] - 1) == 0) { return true; } else { return false; } } };
双指针
while较好
1、移除元素
-
相关推荐
-
定义的时候vector
vec(n) 可以预设vec的长度
2、反转字符串
-
可以实现交换数组
s[i] ^= s[j]; s[j] ^= s[i]; s[i] ^= s[j];
3、替换空格
-
剑指 Offer 05. 替换空格 - 力扣(LeetCode)
重新定义长度
s.resize(n);
4、反转字符串里的单词
-
//第一次实现比较乱 lass Solution { public: string reverseWords(string s) { //双指针处理空格 int slowIndex = 0; for (int fastIndex = 0; fastIndex < s.size(); fastIndex++) { //去除前面字符串空格 if (slowIndex == 0 && s[fastIndex] == ' ') continue; //去除中间多余字符串空格 //不用判断s[slowIndex]是不是为' '。 if (s[fastIndex] == ' ' && s[slowIndex - 1] == ' ') continue; s[slowIndex++] = s[fastIndex]; } while (s[slowIndex - 1] == ' ') { slowIndex--; } s.resize(slowIndex); int left = 0, right = s.size() - 1; while (left < right) { swap(s[left++], s[right--]); } int d = 0; for (int i = 0; i < s.size(); i++) { if (s[i] == ' ') { left = d, right = i - 1; while (left < right) { swap(s[left++], s[right--]); } d = i + 1; } if (i == s.size() - 1){ left = d, right = i; while (left < right) { swap(s[left++], s[right--]); } } } return s; } };
5、反转链表
6、删除链表的倒数第N个节点(遍历一遍)
-
19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)
//完美实现 class Solution { public: ListNode* removeNthFromEnd(ListNode* head, int n) { ListNode *h = new ListNode(0, head); ListNode *p = h, *q = h; while (n--) { q = q->next; } while (q->next != nullptr) { q = q->next; p = p->next; } ListNode *t = p->next; p->next = p->next->next; delete t; return h->next; } };
7、链表相交
8、环形链表2
9、三数之和
10、四数之和
-
class Solution { public: vector<vector<int>> fourSum(vector<int>& nums, int target) { vector<vector<int>> vec; sort(nums.begin(), nums.end()); for (int i = 0; i < nums.size(); i++) { //注意nums[i]>=0; if (nums[i] > target && nums[i] >= 0) break; if (i > 0 && nums[i] == nums[i - 1]) continue; for (int j = i + 1; j < nums.size(); j++) { //注意j也要判断 if (nums[i] + nums[j] > target && nums[i] + nums[j] >= 0) break; if (j > i + 1 && nums[j] == nums[j - 1]) continue; int left = j + 1, right = nums.size() - 1; while (left < right) { //注意扩大范围 if ((long)nums[i] + nums[j] + nums[left] + nums[right] < target) { left++; } else if ((long)nums[i] + nums[j] + nums[left] + nums[right] > target) { right--; } else { vec.push_back(vector<int>{nums[i], nums[j], 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 vec; } };
栈与队列
1、栈与队列理论基础
关于栈的几个问题
- C++中stack 是容器么?
- 我们使用的stack是属于哪个版本的STL?
- 我们使用的STL中stack是如何实现的?
- stack 提供迭代器来遍历stack空间么?
基础知识
-
STL 中栈是用什么容器实现的
栈是以底层容器完成其所有的工作,对外提供统一的接口,底层容器是可插拔的(也就是说我们可以控制使用哪种容器来实现栈的功能)。栈的底层实现可以是vector,deque,list 都是可以的, 主要就是数组和链表的底层实现。
SGI STL中 队列底层实现缺省情况下一样使用deque实现的。
我们也可以指定vector为栈的底层实现,初始化语句如下:
std::stack<int, std::vector<int> > third; // 使用vector为底层容器的栈
-
队列的实现
队列中先进先出的数据结构,同样不允许有遍历行为,不提供迭代器, SGI STL中队列一样是以deque为缺省情况下的底部结构。
也可以指定list 为起底层实现,初始化queue的语句如下:
std::queue<int, std::list<int>> third; // 定义以list为底层容器的队列
-
其他
C++中stack,其中有两个方法:
pop(), 返回void,
top(),返回栈顶的引用。
所以想要提取栈顶元素,直接用s.top()
2、用栈实现队列
-
在push数据的时候,只要数据放进输入栈就好,但在pop的时候,操作就复杂一些,输出栈如果为空,就把进栈数据全部导入进来(注意是全部导入),再从出栈弹出数据,如果输出栈不为空,则直接从出栈弹出数据就可以了。
class MyQueue { public: stack<int> stIn; stack<int> stOut; /** Initialize your data structure here. */ MyQueue() { } /** Push element x to the back of queue. */ void push(int x) { stIn.push(x); } /** Removes the element from in front of queue and returns that element. */ int pop() { // 只有当stOut为空的时候,再从stIn里导入数据(导入stIn全部数据) if (stOut.empty()) { // 从stIn导入数据直到stIn为空 while(!stIn.empty()) { stOut.push(stIn.top()); stIn.pop(); } } int result = stOut.top(); stOut.pop(); return result; } /** Get the front element. */ int peek() { int res = this->pop(); // 直接使用已有的pop函数 stOut.push(res); // 因为pop函数弹出了元素res,所以再添加回去 return res; } /** Returns whether the queue is empty. */ bool empty() { return stIn.empty() && stOut.empty(); } };
以看出peek()的实现,直接复用了pop(), 要不然,对stOut判空的逻辑又要重写一遍。一定要懂得复用,功能相近的函数要抽象出来,不要大量的复制粘贴,很容易出问题
peek有个this要好好理解
3、用队列实现栈
-
要用两个队列来模拟栈,只不过没有输入和输出的关系,而是另一个队列完全用来备份的!
如下面动画所示,用两个队列que1和que2实现队列的功能,que2其实完全就是一个备份的作用,把que1最后面的元素以外的元素都备份到que2,然后弹出最后面的元素,再把其他元素从que2导回que1
class MyStack { public: queue<int> que1; queue<int> que2; // 辅助队列,用来备份 /** Initialize your data structure here. */ MyStack() { } /** Push element x onto stack. */ void push(int x) { que1.push(x); } /** Removes the element on top of the stack and returns that element. */ int pop() { int size = que1.size(); size--; while (size--) { // 将que1 导入que2,但要留下最后一个元素 que2.push(que1.front()); que1.pop(); } int result = que1.front(); // 留下的最后一个元素就是要返回的值 que1.pop(); que1 = que2; // 再将que2赋值给que1 while (!que2.empty()) { // 清空que2 que2.pop(); } return result; } /** Get the top element. */ int top() { return que1.back(); } /** Returns whether the stack is empty. */ bool empty() { return que1.empty(); } };
其实这道题目就是用一个队列就够了。
一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时再去弹出元素就是栈的顺序了。
class MyStack { public: queue<int> que; /** Initialize your data structure here. */ MyStack() { } /** Push element x onto stack. */ void push(int x) { que.push(x); } /** Removes the element on top of the stack and returns that element. */ int pop() { int size = que.size(); size--; while (size--) { // 将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部 que.push(que.front()); que.pop(); } int result = que.front(); // 此时弹出的元素顺序就是栈的顺序了 que.pop(); return result; } /** Get the top element. */ int top() { return que.back(); } /** Returns whether the stack is empty. */ bool empty() { return que.empty(); } };
4、有效的括号
-
由于栈结构的特殊性,非常适合做对称匹配类的题目。
首先要弄清楚,字符串里的括号不匹配有几种情况。
自己实现
class Solution { public: bool isValid(string s) { if (s.size() % 2 != 0) return false; // 如果s的长度为奇数,一定不符合要求 stack<char> st; for (char i : s) { if (i == '(' || i == '{' || i == '[') { st.push(i); } if (i == ')' || i == '}' || i == ']') { if (st.empty()) return false; if (i == ')' && st.top() != '(') return false; if (i == '}' && st.top() != '{') return false; if (i == ']' && st.top() != '[') return false; st.pop(); } } if (!st.empty()) return false; return true; } };
但还有一些技巧,在匹配左括号的时候,右括号先入栈,就只需要比较当前元素和栈顶相不相等就可以了,比左括号先入栈代码实现要简单的多了!
class Solution { public: bool isValid(string s) { if (s.size() % 2 != 0) return false; // 如果s的长度为奇数,一定不符合要求 stack<char> st; 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(']'); // 第三种情况:遍历字符串匹配的过程中,栈已经为空了,没有匹配的字符了,说明右括号没有找到对应的左括号 return false // 第二种情况:遍历字符串匹配的过程中,发现栈里没有我们要匹配的字符。所以return false else if (st.empty() || st.top() != s[i]) return false; else st.pop(); // st.top() 与 s[i]相等,栈弹出元素 } // 第一种情况:此时我们已经遍历完了字符串,但是栈不为空,说明有相应的左括号没有右括号来匹配,所以return false,否则就return true return st.empty(); } };
5、删除字符串中的所有相邻重复项
-
1047. 删除字符串中的所有相邻重复项 - 力扣(LeetCode)
用栈实现
class Solution { public: string removeDuplicates(string s) { stack<char> st; for (char i : s) { if (!st.empty()) { if (st.top() == i) { st.pop(); continue; } } st.push(i); } string sr = ""; while (!st.empty()) { sr = st.top() + sr; st.pop(); } return sr; } };
也可以用string直接实现
class Solution { public: string removeDuplicates(string S) { string result; for(char s : S) { if(result.empty() || result.back() != s) { result.push_back(s); } else { result.pop_back(); } } return result; } };
6、逆波兰表达式求值
-
自己实现,其中自己实现了转换数据,注意负数的特殊情况,num1,num2的前后顺序
class Solution { public: long long zh(string s) { long long sum = 0; int m = 0; int n = s.size(); if (s[0] != '-') { for (int i = n -1; i >= 0; i--) { sum += (s[i] - '0') * pow(10,m++); } } else { for (int i = n -1; i > 0; i--) { sum += (s[i] - '0') * pow(10,m++); } sum *= -1; } return sum; } int evalRPN(vector<string>& tokens) { stack<long long> st; for (string i : tokens) { if (i == "+" || i == "-" || i == "*" || i == "/") { long long num1 = st.top(); st.pop(); long long num2 = st.top(); st.pop(); if (i == "+") st.push(num2 + num1); if (i == "-") st.push(num2 - num1); if (i == "*") st.push(num2 * num1); if (i == "/") st.push(num2 / num1); } else { st.push(zh(i)); } } int result = st.top(); st.pop(); return result; } };
随想录实现的,其中用到了stoll函数将字符串转换成对应的long long int 数据
class Solution { public: int evalRPN(vector<string>& tokens) { // 力扣修改了后台测试数据,需要用longlong stack<long long> st; for (int i = 0; i < tokens.size(); i++) { if (tokens[i] == "+" || tokens[i] == "-" || tokens[i] == "*" || tokens[i] == "/") { long long num1 = st.top(); st.pop(); long long num2 = st.top(); st.pop(); if (tokens[i] == "+") st.push(num2 + num1); if (tokens[i] == "-") st.push(num2 - num1); if (tokens[i] == "*") st.push(num2 * num1); if (tokens[i] == "/") st.push(num2 / num1); } else { st.push(stoll(tokens[i])); } } int result = st.top(); st.pop(); // 把栈里最后一个元素弹出(其实不弹出也没事) return result; } };
7、滑动窗口最大值
-
自己写一个单调队列,不能使用优先队列
class Solution { private: class MyQueue { //单调队列(从大到小) public: deque<int> que; // 使用deque来实现单调队列 // 每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。 // 同时pop之前判断队列当前是否为空。 void pop(int value) { if (!que.empty() && value == que.front()) { que.pop_front(); } } // 如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。 // 这样就保持了队列里的数值是单调从大到小的了。 void push(int value) { while (!que.empty() && value > que.back()) { que.pop_back(); } que.push_back(value); } // 查询当前队列里的最大值 直接返回队列前端也就是front就可以了。 int front() { return que.front(); } }; public: vector<int> maxSlidingWindow(vector<int>& nums, int k) { MyQueue que; vector<int> result; for (int i = 0; i < k; i++) { // 先将前k的元素放进队列 que.push(nums[i]); } result.push_back(que.front()); // result 记录前k的元素的最大值 for (int i = k; i < nums.size(); i++) { que.pop(nums[i - k]); // 滑动窗口移除最前面元素 que.push(nums[i]); // 滑动窗口前加入最后面的元素 result.push_back(que.front()); // 记录对应的最大值 } return result; } };
8、前 K 个高频元素
-
我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。
class Solution { public: // 小顶堆 class mycomparison { public: bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) { return lhs.second > rhs.second; } }; vector<int> topKFrequent(vector<int>& nums, int k) { // 要统计元素出现频率 unordered_map<int, int> map; // map<nums[i],对应出现的次数> for (int i = 0; i < nums.size(); i++) { map[nums[i]]++; } // 对频率排序 // 定义一个小顶堆,大小为k priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pri_que; // 用固定大小为k的小顶堆,扫面所有频率的数值 for (unordered_map<int, int>::iterator it = map.begin(); it != map.end(); it++) { pri_que.push(*it); if (pri_que.size() > k) { // 如果堆的大小大于了K,则队列弹出,保证堆的大小一直为k pri_que.pop(); } } // 找出前K个高频元素,因为小顶堆先弹出的是最小的,所以倒序来输出到数组 vector<int> result(k); for (int i = k - 1; i >= 0; i--) { result[i] = pri_que.top().first; pri_que.pop(); } return result; } };
总结
栈里面的元素在内存中是连续分布的么?
这个问题有两个陷阱:
- 陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中是不是连续分布。
- 陷阱2:缺省情况下,默认底层容器是deque,那么deque的在内存中的数据分布是什么样的呢? 答案是:不连续的,下文也会提到deque。
递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。
二叉树
1、二叉树理论基础
-
二叉树种类
-
满二叉树
如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。
-
完全二叉树
在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层(h从1开始),则该层包含 1~ 2^(h-1) 个节点。
-
二叉搜索数
二叉搜索树是一个有序树。
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树
-
平衡二叉搜索树
又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn,注意我这里没有说unordered_map、unordered_set,unordered_map、unordered_set底层实现是哈希表。
-
-
二叉树的存储方式
二叉树可以链式存储,也可以顺序存储。
顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在各个地址的节点串联一起。
如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。
但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树。
-
二叉树的遍历方式
二叉树主要有两种遍历方式:
- 深度优先遍历:先往深走,遇到叶子节点再往回走。
- 广度优先遍历:一层一层的去遍历。
这两种遍历是图论中最基本的两种遍历方式,后面在介绍图论的时候 还会介绍到。
那么从深度优先遍历和广度优先遍历进一步拓展,才有如下遍历方式:
- 深度优先遍历
- 前序遍历(递归法,迭代法)
- 中序遍历(递归法,迭代法)
- 后序遍历(递归法,迭代法)
- 广度优先遍历
- 层次遍历(迭代法)
做二叉树相关题目,经常会使用递归的方式来实现深度优先遍历,也就是实现前中后序遍历,使用递归是比较方便的。
之前我们讲栈与队列的时候,就说过栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用非递归的方式来实现的。
而广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。
这里其实我们又了解了栈与队列的一个应用场景了。
-
二叉树的定义
二叉树有两种存储方式顺序存储,和链式存储,顺序存储就是用数组来存,这个定义没啥可说的,我们来看看链式存储的二叉树节点的定义方式。
C++代码如下:
struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode(int x) : val(x), left(NULL), right(NULL) {} };
2、二叉树的递归遍历
- 递归算法三要素
- 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
以下以前序遍历为例:
-
确定递归函数的参数和返回值:因为要打印出前序遍历节点的数值,所以参数里需要传入vector来放节点的数值,除了这一点就不需要再处理什么数据了也不需要有返回值,所以递归函数返回类型就是void,代码如下:
void traversal(TreeNode* cur, vector<int>& vec)
-
确定终止条件:在递归的过程中,如何算是递归结束了呢,当然是当前遍历的节点是空了,那么本层递归就要结束了,所以如果当前遍历的这个节点是空,就直接return,代码如下:
if (cur == NULL) return;
-
确定单层递归的逻辑:前序遍历是中左右的循序,所以在单层递归的逻辑,是要先取中节点的数值,代码如下:
vec.push_back(cur->val); // 中 traversal(cur->left, vec); // 左 traversal(cur->right, vec); // 右
单层递归的逻辑就是按照中左右的顺序来处理的,这样二叉树的前序遍历,基本就写完了,再看一下完整代码:
前序遍历:
class Solution {
public:
void traversal(TreeNode* cur, vector<int>& vec) {
if (cur == NULL) return;
vec.push_back(cur->val); // 中
traversal(cur->left, vec); // 左
traversal(cur->right, vec); // 右
}
vector<int> preorderTraversal(TreeNode* root) {
vector<int> result;
traversal(root, result);
return result;
}
};
3、二叉树的迭代遍历
用栈也可以是实现二叉树的前后中序遍历了
我们有两个操作:
- 处理:将元素放进result数组中
- 访问:遍历节点
-
前序遍历(迭代法)
我们先看一下前序遍历。
前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子。
为什么要先加入 右孩子,再加入左孩子呢? 因为这样出栈的时候才是中左右的顺序。
class Solution { public: vector<int> preorderTraversal(TreeNode* root) { stack<TreeNode*> st; vector<int> result; if (root == NULL) return result; st.push(root); while (!st.empty()) { TreeNode* node = st.top(); // 中 st.pop(); result.push_back(node->val); if (node->right) st.push(node->right); // 右(空节点不入栈) if (node->left) st.push(node->left); // 左(空节点不入栈) } return result; } };
-
后序遍历
再来看后序遍历,先序遍历是中左右,后续遍历是左右中,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后在反转result数组,输出的结果顺序就是左右中了
class Solution { public: vector<int> postorderTraversal(TreeNode* root) { stack<TreeNode*> st; vector<int> result; if (root == NULL) return result; st.push(root); while (!st.empty()) { TreeNode* node = st.top(); st.pop(); result.push_back(node->val); if (node->left) st.push(node->left); // 相对于前序遍历,这更改一下入栈顺序 (空节点不入栈) if (node->right) st.push(node->right); // 空节点不入栈 } reverse(result.begin(), result.end()); // 将结果反转之后就是左右中的顺序了 return result; } };
-
中序遍历
中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的。
那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。
class Solution { public: vector<int> inorderTraversal(TreeNode* root) { vector<int> result; stack<TreeNode*> st; TreeNode* cur = root; while (cur != NULL || !st.empty()) { if (cur != NULL) { // 指针来访问节点,访问到最底层 st.push(cur); // 将访问的节点放进栈 cur = cur->left; // 左 } else { cur = st.top(); // 从栈里弹出的数据,就是要处理的数据(放进result数组里的数据) st.pop(); result.push_back(cur->val); // 中 cur = cur->right; // 右 } } return result; } };
4、二叉树的统一迭代法
把要处理的节点也放入栈中但是要做标记。
如何标记呢,就是要处理的节点放入栈之后,紧接着放入一个空指针作为标记。 这种方法也可以叫做标记法。
-
迭代法中序遍历
class Solution { public: vector<int> inorderTraversal(TreeNode* root) { vector<int> result; stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中 if (node->right) st.push(node->right); // 添加右节点(空节点不入栈) st.push(node); // 添加中节点 st.push(NULL); // 中节点访问过,但是还没有处理,加入空节点做为标记。 if (node->left) st.push(node->left); // 添加左节点(空节点不入栈) } else { // 只有遇到空节点的时候,才将下一个节点放进结果集 st.pop(); // 将空节点弹出 node = st.top(); // 重新取出栈中元素 st.pop(); result.push_back(node->val); // 加入到结果集 } } return result; } };
-
迭代法前序遍历
注意此时我们和中序遍历相比仅仅改变了两行代码的顺序
class Solution { public: vector<int> preorderTraversal(TreeNode* root) { vector<int> result; stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); if (node->right) st.push(node->right); // 右 if (node->left) st.push(node->left); // 左 st.push(node); // 中 st.push(NULL); } else { st.pop(); node = st.top(); st.pop(); result.push_back(node->val); } } return result; } };
-
迭代法后序遍历
class Solution { public: vector<int> postorderTraversal(TreeNode* root) { vector<int> result; stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); st.push(node); // 中 st.push(NULL); if (node->right) st.push(node->right); // 右 if (node->left) st.push(node->left); // 左 } else { st.pop(); node = st.top(); st.pop(); result.push_back(node->val); } } return result; } };
5、二叉树的层序遍历
层序遍历一个二叉树。就是从左到右一层一层的去遍历二叉树。这种遍历的方式和我们之前讲过的都不太一样。
需要借用一个辅助数据结构即队列来实现,队列先进先出,符合一层一层遍历的逻辑,而用栈先进后出适合模拟深度优先遍历也就是递归的逻辑。
而这种层序遍历方式就是图论中的广度优先遍历,只不过我们应用在二叉树上。
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
queue<TreeNode*> que;
if (root != NULL) que.push(root);
vector<vector<int>> result;
while (!que.empty()) {
int size = que.size();
vector<int> vec;
// 这里一定要使用固定大小size,不要使用que.size(),因为que.size是不断变化的
for (int i = 0; i < size; i++) {
TreeNode* node = que.front();
que.pop();
vec.push_back(node->val);
if (node->left) que.push(node->left);
if (node->right) que.push(node->right);
}
result.push_back(vec);
}
return result;
}
};
递归法
class Solution {
public:
void order(TreeNode* cur, vector<vector<int>>& result, int depth)
{
if (cur == nullptr) return;
if (result.size() == depth) result.push_back(vector<int>());
result[depth].push_back(cur->val);
order(cur->left, result, depth + 1);
order(cur->right, result, depth + 1);
}
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> result;
int depth = 0;
order(root, result, depth);
return result;
}
};
我要一打十(递归迭代同时来)
- 102.二叉树的层序遍历
- 107.二叉树的层次遍历II
- 199.二叉树的右视图
- 637.二叉树的层平均值
- 429.N叉树的层序遍历
- 515.在每个树行中找最大值
- 116.填充每个节点的下一个右侧节点指针
- 117.填充每个节点的下一个右侧节点指针II
- 104.二叉树的最大深度
- 111.二叉树的最小深度
6、翻转二叉树
-
题目很简单,重要的是明白各种方法
-
递归法
class Solution { public: TreeNode* invertTree(TreeNode* root) { if (root == NULL) return root; swap(root->left, root->right); // 中 invertTree(root->left); // 左 invertTree(root->right); // 右 return root; } };
-
迭代法
-
深度优先遍历
前序遍历(非统一写法)
class Solution { public: TreeNode* invertTree(TreeNode* root) { if (root == NULL) return root; stack<TreeNode*> st; st.push(root); while(!st.empty()) { TreeNode* node = st.top(); // 中 st.pop(); swap(node->left, node->right); if(node->right) st.push(node->right); // 右 if(node->left) st.push(node->left); // 左 } return root; } };
前序遍历(统一写法)
class Solution { public: TreeNode* invertTree(TreeNode* root) { stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); if (node->right) st.push(node->right); // 右 if (node->left) st.push(node->left); // 左 st.push(node); // 中 st.push(NULL); } else { st.pop(); node = st.top(); st.pop(); swap(node->left, node->right); // 节点处理逻辑 } } return root; } };
-
广度优先遍历
class Solution { public: TreeNode* invertTree(TreeNode* root) { queue<TreeNode*> que; if (root != NULL) que.push(root); while (!que.empty()) { int size = que.size(); for (int i = 0; i < size; i++) { TreeNode* node = que.front(); que.pop(); swap(node->left, node->right); // 节点处理 if (node->left) que.push(node->left); if (node->right) que.push(node->right); } } return root; } };
-
-
7、二叉树周末总结
-
周一
-
其实红黑树就是一种二叉平衡搜索树,这两个树不是独立的,所以C++中map、multimap、set、multiset的底层实现机制是二叉平衡搜索树,再具体一点是红黑树。
-
TreeNode(int x) : val(x), left(NULL), right(NULL) {}
有同学不清楚干什么的。这是构造函数
-
还有一种牛逼的遍历方式:morris遍历。
morris遍历是二叉树遍历算法的超强进阶算法,morris遍历可以将非递归遍历中的空间复杂度降为O(1),
-
-
周二
-
周三
从时间复杂度上其实迭代法和递归法差不多(在不考虑函数调用开销和函数调用产生的堆栈开销),但是空间复杂度上,递归开销会大一些,因为递归需要系统堆栈存参数返回值等等。
递归更容易让程序员理解,但收敛不好,容易栈溢出。
这么说吧,递归是方便了程序员,难为了机器(各种保存参数,各种进栈出栈)。
在实际项目开发的过程中我们是要尽量避免递归!因为项目代码参数、调用关系都比较复杂,不容易控制递归深度,甚至会栈溢出。
-
周四
前中后序迭代法是不是一定要统一来写,这样才算是规范。
其实没必要,还是自己感觉哪一种更好记就用哪种。
但是一定要掌握前中后序一种迭代的写法,并不因为某种场景的题目一定要用迭代,而是现场面试的时候,面试官看你顺畅的写出了递归,一般会进一步考察能不能写出相应的迭代。
-
周五
层序遍历遍历相对容易一些,只要掌握基本写法(也就是框架模板),剩下的就是在二叉树每一行遍历的时候做做逻辑修改
-
周六
class Solution { public: TreeNode* invertTree(TreeNode* root) { stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); if (node->right) st.push(node->right); // 右 st.push(node); // 中 st.push(NULL); if (node->left) st.push(node->left); // 左 } else { st.pop(); node = st.top(); st.pop(); swap(node->left, node->right); // 节点处理逻辑 } } return root; } };
为什么这个中序就是可以的呢,因为这是用栈来遍历,而不是靠指针来遍历,避免了递归法中翻转了两次的情况,大家可以画图理解一下,这里有点意思的。
8、对称二叉树
-
判断根节点的左右子树是否可以相互反转
递归实现
class Solution { public: bool compare(TreeNode* left, TreeNode* right) { // 首先排除空节点的情况 if (left == NULL && right != NULL) return false; else if (left != NULL && right == NULL) return false; else if (left == NULL && right == NULL) return true; // 排除了空节点,再排除数值不相同的情况 else if (left->val != right->val) return false; // 此时就是:左右节点都不为空,且数值相同的情况 // 此时才做递归,做下一层的判断 bool outside = compare(left->left, right->right); // 左子树:左、 右子树:右 bool inside = compare(left->right, right->left); // 左子树:右、 右子树:左 bool isSame = outside && inside; // 左子树:中、 右子树:中 (逻辑处理) return isSame; } bool isSymmetric(TreeNode* root) { if (root == NULL) return true; return compare(root->left, root->right); } };
队列迭代实现
public: bool isSymmetric(TreeNode* root) { if (root == NULL) return true; queue<TreeNode*> que; que.push(root->left); // 将左子树头结点加入队列 que.push(root->right); // 将右子树头结点加入队列 while (!que.empty()) { // 接下来就要判断这两个树是否相互翻转 TreeNode* leftNode = que.front(); que.pop(); TreeNode* rightNode = que.front(); que.pop(); if (!leftNode && !rightNode) { // 左节点为空、右节点为空,此时说明是对称的 continue; } // 左右一个节点不为空,或者都不为空但数值不相同,返回false if ((!leftNode || !rightNode || (leftNode->val != rightNode->val))) { return false; } que.push(leftNode->left); // 加入左节点左孩子 que.push(rightNode->right); // 加入右节点右孩子 que.push(leftNode->right); // 加入左节点右孩子 que.push(rightNode->left); // 加入右节点左孩子 } return true; } };
栈迭代遍历
class Solution { public: bool isSymmetric(TreeNode* root) { if (root == NULL) return true; stack<TreeNode*> st; // 这里改成了栈 st.push(root->left); st.push(root->right); while (!st.empty()) { TreeNode* leftNode = st.top(); st.pop(); TreeNode* rightNode = st.top(); st.pop(); if (!leftNode && !rightNode) { continue; } if ((!leftNode || !rightNode || (leftNode->val != rightNode->val))) { return false; } st.push(leftNode->left); st.push(rightNode->right); st.push(leftNode->right); st.push(rightNode->left); } return true; } };
-
在基于100.相同的数这个题的情况下,要加一个isSub判断是否含有子树,用或链接,中间也用到了isSame;
/** * Definition for a binary tree node. * 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: //判断两个树是否相等 bool isSame(TreeNode* node, TreeNode* sub) { if (node == nullptr && sub != nullptr) return false; else if (node != nullptr && sub == nullptr) return false; else if (node == nullptr && sub == nullptr) return true; else if (node->val != sub->val) return false; else return isSame(node->left, sub->left) && isSame(node->right,sub->right); } //判断树中是否包含另一个树 bool isSub(TreeNode* node, TreeNode* sub) { if (node == nullptr) return false; return isSame(node, sub) || isSub(node->left, sub) || isSub(node->right, sub); } bool isSubtree(TreeNode* root, TreeNode* subRoot) { return isSub(root, subRoot); } };
9、二叉树的最大深度
-
递归中序遍历
class Solution { public: int dfs(TreeNode* cur, int deepth) { if (cur == nullptr) return --deepth; return max(dfs(cur->left, deepth + 1), dfs(cur->right, deepth + 1)); } int maxDepth(TreeNode* root) { return dfs(root, 1); } };
递归前序遍历
class Solution { public: int reslut; void dfs(TreeNode* cur, int deepth) { if (deepth > reslut) reslut = deepth; if (cur->left != nullptr) { dfs(cur->left, deepth + 1); } if (cur->right != nullptr) { dfs(cur->right, deepth + 1); } } int maxDepth(TreeNode* root) { reslut = 0; if (root != nullptr) dfs(root, 1);; return reslut; } };
迭代队列实现层序遍历
class solution { public: int maxDepth(TreeNode* root) { if (root == NULL) return 0; int depth = 0; queue<TreeNode*> que; que.push(root); while(!que.empty()) { int size = que.size(); depth++; // 记录深度 for (int i = 0; i < size; i++) { TreeNode* node = que.front(); que.pop(); if (node->left) que.push(node->left); if (node->right) que.push(node->right); } } return depth; } };
-
迭代层序遍历
class Solution { public: int maxDepth(Node* root) { queue<Node*> que; if (root != NULL) que.push(root); int depth = 0; while (!que.empty()) { int size = que.size();//上次遗忘了 depth++; for (int i = 0; i < size; i++) { Node *node = que.front(); que.pop(); for (Node *cur : node->children) { if (cur != NULL) que.push(cur); } } } return depth; } };
10、二叉树的最小深度
-
最小深度是从根节点到最近叶子节点的最短路径上的节点数量
11、完全二叉树的节点数
-
222. 完全二叉树的节点个数 - 力扣(LeetCode)
因为是完全二叉树,可以不用每个节点都遍历,用二叉树特有的方式统计节点数
完全二叉树只有两种情况,情况一:就是满二叉树,情况二:最后一层叶子节点没有满。
对于情况一,可以直接用 2^树深度 - 1 来计算,注意这里根节点深度为1。
对于情况二,分别递归左孩子,和右孩子,递归到某一深度一定会有左孩子或者右孩子为满二叉树,然后依然可以按照情况1来计算。
class Solution { public: int countNodes(TreeNode* root) { if (root == nullptr) return 0; TreeNode* left = root->left; TreeNode* right = root->right; int leftDepth = 0, rightDepth = 0; // 这里初始为0是有目的的,为了下面求指数方便 while (left) { // 求左子树深度 left = left->left; leftDepth++; } while (right) { // 求右子树深度 right = right->right; rightDepth++; } if (leftDepth == rightDepth) { return (2 << leftDepth) - 1; // 注意(2<<1) 相当于2^2,所以leftDepth初始为0 } return countNodes(root->left) + countNodes(root->right) + 1; } };
12、平衡二叉树
-
树高度和深度其实不是一样的
- 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数。
- 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数。
因为求深度可以从上到下去查 所以需要前序遍历(中左右),而高度只能从下到上去查,所以只能后序遍历(左右中)
有的同学一定疑惑,为什么104.二叉树的最大深度 中求的是二叉树的最大深度,也用的是后序遍历。
那是因为代码的逻辑其实是求的根节点的高度,而根节点的高度就是这棵树的最大深度,所以才可以使用后序遍历。
其实自己用递归实现成功了
class Solution {
public:
int depth(TreeNode* node, int h) {
if (node == nullptr) return --h;
int lefth = depth(node->left, h + 1);
int righth = depth(node->right, h + 1);
return max(lefth, righth);
}
bool is(TreeNode* node) {
if (node == nullptr) return true;
return abs(depth(node->left, 1) - depth(node->right, 1)) <=1 && is(node->left) && is(node->right);
}
bool isBalanced(TreeNode* root) {
return is(root);
}
};
和随想录有一定差别,可以狠狠借鉴
class Solution {
public:
// 返回以该节点为根节点的二叉树的高度,如果不是平衡二叉树了则返回-1
int getHeight(TreeNode* node) {
if (node == NULL) {
return 0;
}
int leftHeight = getHeight(node->left);
if (leftHeight == -1) return -1;
int rightHeight = getHeight(node->right);
if (rightHeight == -1) return -1;
return abs(leftHeight - rightHeight) > 1 ? -1 : 1 + max(leftHeight, rightHeight);
}
bool isBalanced(TreeNode* root) {
return getHeight(root) == -1 ? false : true;
}
};
迭代实现比较繁琐(可以有一个思路,也不一定说非要写出来。)
我们可以使用层序遍历来求深度,但是就不能直接用层序遍历来求高度了,这就体现出求高度和求深度的不同。
本题的迭代方式可以先定义一个函数,专门用来求高度。
这个函数通过栈模拟的后序遍历找每一个节点的高度(其实是通过求传入节点为根节点的最大深度来求的高度)
class Solution {
private:
int getDepth(TreeNode* cur) {
stack<TreeNode*> st;
if (cur != NULL) st.push(cur);
int depth = 0; // 记录深度
int result = 0;
while (!st.empty()) {
TreeNode* node = st.top();
if (node != NULL) {
st.pop();
st.push(node); // 中
st.push(NULL);
depth++;
if (node->right) st.push(node->right); // 右
if (node->left) st.push(node->left); // 左
} else {
st.pop();
node = st.top();
st.pop();
depth--;
}
result = result > depth ? result : depth;
}
return result;
}
public:
bool isBalanced(TreeNode* root) {
stack<TreeNode*> st;
if (root == NULL) return true;
st.push(root);
while (!st.empty()) {
TreeNode* node = st.top(); // 中
st.pop();
if (abs(getDepth(node->left) - getDepth(node->right)) > 1) {
return false;
}
if (node->right) st.push(node->right); // 右(空节点不入栈)
if (node->left) st.push(node->left); // 左(空节点不入栈)
}
return true;
}
};
当然此题用迭代法,其实效率很低,因为没有很好的模拟回溯的过程,所以迭代法有很多重复的计算。
虽然理论上所有的递归都可以用迭代来实现,但是有的场景难度可能比较大。
例如:都知道回溯法其实就是递归,但是很少人用迭代的方式去实现回溯算法!
因为对于回溯算法已经是非常复杂的递归了,如果再用迭代的话,就是自己给自己找麻烦,效率也并不一定高。
13、二叉树的所有路径
-
以为只用了递归,其实还用了回溯
递归体现回溯
// 版本一 class Solution { private: void traversal(TreeNode* cur, vector<int>& path, vector<string>& result) { path.push_back(cur->val); // 中,中为什么写在这里,因为最后一个节点也要加入到path中 // 这才到了叶子节点 if (cur->left == NULL && cur->right == NULL) { string sPath; for (int i = 0; i < path.size() - 1; i++) { sPath += to_string(path[i]); sPath += "->"; } sPath += to_string(path[path.size() - 1]); result.push_back(sPath); return; } if (cur->left) { // 左 traversal(cur->left, path, result); path.pop_back(); // 回溯 } if (cur->right) { // 右 traversal(cur->right, path, result); path.pop_back(); // 回溯 } } public: vector<string> binaryTreePaths(TreeNode* root) { vector<string> result; vector<int> path; if (root == NULL) return result; traversal(root, path, result); return result; } };
简化版
class Solution { public: void bt(TreeNode* node, string path, vector<string>& reslut) { path += to_string(node->val); //中 if (node->left == nullptr && node->right == nullptr) reslut.push_back(path); if (node->left) bt(node->left, path + "->", reslut); if (node->right) bt(node->right, path + "->", reslut); } vector<string> binaryTreePaths(TreeNode* root) { vector<string> reslut; string path; bt(root, path, reslut); return reslut; } };
迭代法
class Solution { public: vector<string> binaryTreePaths(TreeNode* root) { stack<TreeNode*> treeSt;// 保存树的遍历节点 stack<string> pathSt; // 保存遍历路径的节点 vector<string> result; // 保存最终路径集合 if (root == NULL) return result; treeSt.push(root); pathSt.push(to_string(root->val)); while (!treeSt.empty()) { TreeNode* node = treeSt.top(); treeSt.pop(); // 取出节点 中 string path = pathSt.top();pathSt.pop(); // 取出该节点对应的路径 if (node->left == NULL && node->right == NULL) { // 遇到叶子节点 result.push_back(path); } if (node->right) { // 右 treeSt.push(node->right); pathSt.push(path + "->" + to_string(node->right->val)); } if (node->left) { // 左 treeSt.push(node->left); pathSt.push(path + "->" + to_string(node->left->val)); } } return result; } };
14、二叉树周末总结
-
周一
本周刚开始我们讲解了判断二叉树是否对称的写法, 二叉树:我对称么? 。
这道题目的本质是要比较两个树(这两个树是根节点的左右子树),遍历两棵树而且要比较内侧和外侧节点,所以准确的来说是一个树的遍历顺序是左右中,一个树的遍历顺序是右左中。
而本题的迭代法中我们使用了队列,需要注意的是这不是层序遍历,而且仅仅通过一个容器来成对的存放我们要比较的元素,认识到这一点之后就发现:用队列,用栈,甚至用数组,都是可以的。
-
在二叉树:看看这些树的最大深度 中,我们讲解了如何求二叉树的最大深度。
本题可以使用前序,也可以使用后序遍历(左右中),使用前序求的就是深度,使用后序呢求的是高度。
而根节点的高度就是二叉树的最大深度,所以本题中我们通过后序求的根节点高度来求的二叉树最大深度,所以二叉树:看看这些树的最大深度 中使用的是后序遍历。
-
周三
在二叉树:看看这些树的最小深度 (opens new window)中,我们讲解如何求二叉树的最小深度, 这道题目要是稍不留心很容易犯错。
注意这里最小深度是从根节点到最近叶子节点的最短路径上的节点数量。注意是叶子节点。
什么是叶子节点,左右孩子都为空的节点才是叶子节点!
求二叉树的最小深度和求二叉树的最大深度的差别主要在于处理左右孩子不为空的逻辑。
注意到这一点之后 递归法和迭代法 都可以参照二叉树:看看这些树的最大深度 (opens new window)写出来。
-
周四
我们在二叉树:我有多少个节点? (opens new window)中,讲解了如何求二叉树的节点数量。
只要把之前两篇二叉树:看看这些树的最大深度 (opens new window), 二叉树:看看这些树的最小深度 (opens new window)都认真看了的话,这道题目可以分分钟刷掉了。
估计此时大家对这一类求二叉树节点数量以及求深度应该非常熟练了。
-
周五
在二叉树:我平衡么? (opens new window)中讲解了如何判断二叉树是否是平衡二叉树
今天讲解一道判断平衡二叉树的题目,其实 方法上我们之前讲解深度的时候都讲过了,但是这次我们通过这道题目彻底搞清楚二叉树高度与深度的问题,以及对应的遍历方式。
二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数。 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数。
但leetcode中强调的深度和高度很明显是按照节点来计算的。
关于根节点的深度究竟是1 还是 0,不同的地方有不一样的标准,leetcode的题目中都是以节点为一度,即根节点深度是1。但维基百科上定义用边为一度,即根节点的深度是0,我们暂时以leetcode为准(毕竟要在这上面刷题)。
当然此题用迭代法,其实效率很低,因为没有很好的模拟回溯的过程,所以迭代法有很多重复的计算。
虽然理论上所有的递归都可以用迭代来实现,但是有的场景难度可能比较大。
例如:都知道回溯法其实就是递归,但是很少人用迭代的方式去实现回溯算法!
讲了这么多二叉树题目的迭代法,有的同学会疑惑,迭代法中究竟什么时候用队列,什么时候用栈?
如果是模拟前中后序遍历就用栈,如果是适合层序遍历就用队列,当然还是其他情况,那么就是 先用队列试试行不行,不行就用栈。
-
周六
在二叉树:找我的所有路径? (opens new window)中正式涉及到了回溯,很多同学过了这道题目,可能都不知道自己使用了回溯,其实回溯和递归都是相伴相生的。最后我依然给出了迭代法的版本。
我在题解中第一个版本的代码会把回溯的过程充分体现出来,如果大家直接看简洁的代码版本,很可能就会忽略的回溯的存在。
我在文中也强调了这一点。
有的同学还不理解 ,文中精简之后的递归代码,回溯究竟隐藏在哪里了。
文中我明确的说了:回溯就隐藏在traversal(cur->left, path + "->", result);中的 path + "->"。 每次函数调用完,path依然是没有加上"->" 的,这就是回溯了。
15、左叶子之和
-
先要注意是判断左叶子,不是二叉树左侧节点,所以不要上来想着层序遍历。
因为题目中其实没有说清楚左叶子究竟是什么节点,那么我来给出左叶子的明确定义:节点A的左孩子不为空,且左孩子的左右孩子都为空(说明是叶子节点),那么A节点的左孩子为左叶子节点
判断当前节点是不是左叶子是无法判断的,必须要通过节点的父节点来判断其左孩子是不是左叶子。
递归法(详细)
class Solution { public: int sumOfLeftLeaves(TreeNode* root) { if (root == NULL) return 0; if (root->left == NULL && root->right== NULL) return 0; int leftValue = sumOfLeftLeaves(root->left); // 左 if (root->left && !root->left->left && !root->left->right) { // 左子树就是一个左叶子的情况 leftValue = root->left->val; } int rightValue = sumOfLeftLeaves(root->right); // 右 int sum = leftValue + rightValue; // 中 return sum; } };
迭代法
class Solution { public: int sumOfLeftLeaves(TreeNode* root) { if (root == NULL) return 0; if (root->left == NULL && root->right== NULL) return 0; int leftValue = sumOfLeftLeaves(root->left); // 左 if (root->left && !root->left->left && !root->left->right) { // 左子树就是一个左叶子的情况 leftValue = root->left->val; } int rightValue = sumOfLeftLeaves(root->right); // 右 int sum = leftValue + rightValue; // 中 return sum; } };
16、找树左下角的值
-
找深度最大的叶子节点。
那么如何找最左边的呢?可以使用前序遍历(当然中序,后序都可以,因为本题没有 中间节点的处理逻辑,只要左优先就行),保证优先左边搜索,然后记录深度最大的叶子节点,此时就是树的最后一行最左边的值。
递归
class Solution { public: int maxDepth = INT_MIN; int result; void traversal(TreeNode* root, int depth) { if (root->left == NULL && root->right == NULL) { if (depth > maxDepth) { maxDepth = depth; result = root->val; } return; } if (root->left) { depth++; traversal(root->left, depth); depth--; // 回溯 } if (root->right) { depth++; traversal(root->right, depth); depth--; // 回溯 } return; } int findBottomLeftValue(TreeNode* root) { traversal(root, 0); return result; } };
迭代(层序遍历,主要size的应用)
class Solution { public: int findBottomLeftValue(TreeNode* root) { queue<TreeNode*> que; if (root != NULL) que.push(root); int result = 0; while (!que.empty()) { int size = que.size(); for (int i = 0; i < size; i++) { TreeNode* node = que.front(); que.pop(); if (i == 0) result = node->val; // 记录最后一行第一个元素 if (node->left) que.push(node->left); if (node->right) que.push(node->right); } } return result; } };
17、路径总和
-
递归函数什么时候需要返回值?什么时候不需要返回值?这里总结如下三点:
- 如果需要搜索整棵二叉树且不用处理递归返回值,递归函数就不要返回值。(这种情况就是本文下半部分介绍的113.路径总和ii)
- 如果需要搜索整棵二叉树且需要处理递归返回值,递归函数就需要返回值。 (这种情况我们在236. 二叉树的最近公共祖先 (opens new window)中介绍)
- 如果要搜索其中一条符合条件的路径,那么递归一定需要返回值,因为遇到符合条件的路径了就要及时返回。(本题的情况)
递归
class Solution { private: bool traversal(TreeNode* cur, int count) { if (!cur->left && !cur->right && count == 0) return true; // 遇到叶子节点,并且计数为0 if (!cur->left && !cur->right) return false; // 遇到叶子节点直接返回 if (cur->left) { // 左 count -= cur->left->val; // 递归,处理节点; if (traversal(cur->left, count)) return true; count += cur->left->val; // 回溯,撤销处理结果 } if (cur->right) { // 右 count -= cur->right->val; // 递归,处理节点; if (traversal(cur->right, count)) return true; count += cur->right->val; // 回溯,撤销处理结果 } return false; } public: bool hasPathSum(TreeNode* root, int sum) { if (root == NULL) return false; return traversal(root, sum - root->val); } };
迭代
class solution { public: bool haspathsum(TreeNode* root, int sum) { if (root == null) return false; // 此时栈里要放的是pair<节点指针,路径数值> stack<pair<TreeNode*, int>> st; st.push(pair<TreeNode*, int>(root, root->val)); while (!st.empty()) { pair<TreeNode*, int> node = st.top(); st.pop(); // 如果该节点是叶子节点了,同时该节点的路径数值等于sum,那么就返回true if (!node.first->left && !node.first->right && sum == node.second) return true; // 右节点,压进去一个节点的时候,将该节点的路径数值也记录下来 if (node.first->right) { st.push(pair<TreeNode*, int>(node.first->right, node.second + node.first->right->val)); } // 左节点,压进去一个节点的时候,将该节点的路径数值也记录下来 if (node.first->left) { st.push(pair<TreeNode*, int>(node.first->left, node.second + node.first->left->val)); } } return false; } };
-
递归实现
class solution { private: vector<vector<int>> result; vector<int> path; // 递归函数不需要返回值,因为我们要遍历整个树 void traversal(TreeNode* cur, int count) { if (!cur->left && !cur->right && count == 0) { // 遇到了叶子节点且找到了和为sum的路径 result.push_back(path); return; } if (!cur->left && !cur->right) return ; // 遇到叶子节点而没有找到合适的边,直接返回 if (cur->left) { // 左 (空节点不遍历) path.push_back(cur->left->val); count -= cur->left->val; traversal(cur->left, count); // 递归 count += cur->left->val; // 回溯 path.pop_back(); // 回溯 } if (cur->right) { // 右 (空节点不遍历) path.push_back(cur->right->val); count -= cur->right->val; traversal(cur->right, count); // 递归 count += cur->right->val; // 回溯 path.pop_back(); // 回溯 } return ; } public: vector<vector<int>> pathSum(TreeNode* root, int sum) { result.clear(); path.clear(); if (root == NULL) return result; path.push_back(root->val); // 把根节点放进路径 traversal(root, sum - root->val); return result; } };
18、从中序与后序遍历序列构造二叉树
-
106. 从中序与后序遍历序列构造二叉树 - 力扣(LeetCode)
如何根据两个顺序构造一个唯一的二叉树,相信理论知识大家应该都清楚,就是以 后序数组的最后一个元素为切割点,先切中序数组,根据中序数组,反过来再切后序数组。一层一层切下去,每次后序数组最后一个元素就是节点元素。
说到一层一层切割,就应该想到了递归。
来看一下一共分几步:
- 第一步:如果数组大小为零的话,说明是空节点了。
- 第二步:如果不为空,那么取后序数组最后一个元素作为节点元素。
- 第三步:找到后序数组最后一个元素在中序数组的位置,作为切割点
- 第四步:切割中序数组,切成中序左数组和中序右数组 (顺序别搞反了,一定是先切中序数组)
- 第五步:切割后序数组,切成后序左数组和后序右数组
- 第六步:递归处理左区间和右区间
框架如下
TreeNode* traversal (vector<int>& inorder, vector<int>& postorder) { // 第一步 if (postorder.size() == 0) return NULL; // 第二步:后序遍历数组最后一个元素,就是当前的中间节点 int rootValue = postorder[postorder.size() - 1]; TreeNode* root = new TreeNode(rootValue); // 叶子节点 if (postorder.size() == 1) return root; // 第三步:找切割点 int delimiterIndex; for (delimiterIndex = 0; delimiterIndex < inorder.size(); delimiterIndex++) { if (inorder[delimiterIndex] == rootValue) break; } // 第四步:切割中序数组,得到 中序左数组和中序右数组 // 第五步:切割后序数组,得到 后序左数组和后序右数组 // 第六步 root->left = traversal(中序左数组, 后序左数组); root->right = traversal(中序右数组, 后序右数组); return root; }
此时应该注意确定切割的标准,是左闭右开,还有左开右闭,还是左闭右闭,这个就是不变量,要在递归中保持这个不变量
初级代码如下
class Solution { private: TreeNode* traversal (vector<int>& inorder, vector<int>& postorder) { if (postorder.size() == 0) return NULL; // 后序遍历数组最后一个元素,就是当前的中间节点 int rootValue = postorder[postorder.size() - 1]; TreeNode* root = new TreeNode(rootValue); // 叶子节点 if (postorder.size() == 1) return root; // 找到中序遍历的切割点 int delimiterIndex; for (delimiterIndex = 0; delimiterIndex < inorder.size(); delimiterIndex++) { if (inorder[delimiterIndex] == rootValue) break; } // 切割中序数组 // 左闭右开区间:[0, delimiterIndex) vector<int> leftInorder(inorder.begin(), inorder.begin() + delimiterIndex); // [delimiterIndex + 1, end) vector<int> rightInorder(inorder.begin() + delimiterIndex + 1, inorder.end() ); // postorder 舍弃末尾元素 postorder.resize(postorder.size() - 1); // 切割后序数组 // 依然左闭右开,注意这里使用了左中序数组大小作为切割点 // [0, leftInorder.size) vector<int> leftPostorder(postorder.begin(), postorder.begin() + leftInorder.size()); // [leftInorder.size(), end) vector<int> rightPostorder(postorder.begin() + leftInorder.size(), postorder.end()); root->left = traversal(leftInorder, leftPostorder); root->right = traversal(rightInorder, rightPostorder); return root; } public: TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) { if (inorder.size() == 0 || postorder.size() == 0) return NULL; return traversal(inorder, postorder); } };
下面给出用下标索引写出的代码版本:(思路是一样的,只不过不用重复定义vector了,每次用下标索引来分割)
class Solution { private: // 中序区间:[inorderBegin, inorderEnd),后序区间[postorderBegin, postorderEnd) TreeNode* traversal (vector<int>& inorder, int inorderBegin, int inorderEnd, vector<int>& postorder, int postorderBegin, int postorderEnd) { if (postorderBegin == postorderEnd) return NULL; int rootValue = postorder[postorderEnd - 1]; TreeNode* root = new TreeNode(rootValue); if (postorderEnd - postorderBegin == 1) return root; int delimiterIndex; for (delimiterIndex = inorderBegin; delimiterIndex < inorderEnd; delimiterIndex++) { if (inorder[delimiterIndex] == rootValue) break; } // 切割中序数组 // 左中序区间,左闭右开[leftInorderBegin, leftInorderEnd) int leftInorderBegin = inorderBegin; int leftInorderEnd = delimiterIndex; // 右中序区间,左闭右开[rightInorderBegin, rightInorderEnd) int rightInorderBegin = delimiterIndex + 1; int rightInorderEnd = inorderEnd; // 切割后序数组 // 左后序区间,左闭右开[leftPostorderBegin, leftPostorderEnd) int leftPostorderBegin = postorderBegin; int leftPostorderEnd = postorderBegin + delimiterIndex - inorderBegin; // 终止位置是 需要加上 中序区间的大小size // 右后序区间,左闭右开[rightPostorderBegin, rightPostorderEnd) int rightPostorderBegin = postorderBegin + (delimiterIndex - inorderBegin); int rightPostorderEnd = postorderEnd - 1; // 排除最后一个元素,已经作为节点了 root->left = traversal(inorder, leftInorderBegin, leftInorderEnd, postorder, leftPostorderBegin, leftPostorderEnd); root->right = traversal(inorder, rightInorderBegin, rightInorderEnd, postorder, rightPostorderBegin, rightPostorderEnd); return root; } public: TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) { if (inorder.size() == 0 || postorder.size() == 0) return NULL; // 左闭右开的原则 return traversal(inorder, 0, inorder.size(), postorder, 0, postorder.size()); } };
19、最大二叉树
-
构造树一般采用的是前序遍历,因为先构造中间节点,然后递归构造左子树和右子树。
实现同上
class Solution { private: // 在左闭右开区间[left, right),构造二叉树 TreeNode* traversal(vector<int>& nums, int left, int right) { if (left >= right) return nullptr; // 分割点下标:maxValueIndex int maxValueIndex = left; for (int i = left + 1; i < right; ++i) { if (nums[i] > nums[maxValueIndex]) maxValueIndex = i; } TreeNode* root = new TreeNode(nums[maxValueIndex]); // 左闭右开:[left, maxValueIndex) root->left = traversal(nums, left, maxValueIndex); // 左闭右开:[maxValueIndex + 1, right) root->right = traversal(nums, maxValueIndex + 1, right); return root; } public: TreeNode* constructMaximumBinaryTree(vector<int>& nums) { return traversal(nums, 0, nums.size()); } };
这道题目其实和 二叉树:构造二叉树登场!是一个思路,比二叉树:构造二叉树登场! 还简单一些。
注意类似用数组构造二叉树的题目,每次分隔尽量不要定义新的数组,而是通过下标索引直接在原数组上操作,这样可以节约时间和空间上的开销。
一些同学也会疑惑,什么时候递归函数前面加if,什么时候不加if,这个问题我在最后也给出了解释。
其实就是不同代码风格的实现,一般情况来说:如果让空节点(空指针)进入递归,就不加if,如果不让空节点进入递归,就加if限制一下, 终止条件也会相应的调整。
20、二叉树周末总结
-
周一
讲解了递归如何隐藏着回溯,一些代码会把回溯的过程都隐藏了起来了,甚至刷过这道题的同学可能都不知道自己用了回溯。
文章中第一版代码把每一个细节都展示了输出来了,大家可以清晰的看到回溯的过程。
然后给出了第二版优化后的代码,分析了其回溯隐藏在了哪里,如果要把这个回溯扣出来的话,在第二版的基础上应该怎么改。
主要需要理解:回溯隐藏在traversal(cur->left, path + "->", result);中的 path + "->"。 每次函数调用完,path依然是没有加上"->" 的,这就是回溯了。
-
周二
在文章二叉树:做了这么多题目了,我的左叶子之和是多少? 中提供了另一个判断节点属性的思路,平时我们习惯了使用通过节点的左右孩子判断本节点的属性,但发现使用这个思路无法判断左叶子。
此时需要相连的三层之间构成的约束条件,也就是要通过节点的父节点以及孩子节点来判断本节点的属性。
这道题目可以扩展大家对二叉树的解题思路。
-
周三
在二叉树:我的左下角的值是多少? 中的题目如果使用递归的写法还是有点难度的,层次遍历反而很简单。
题目其实就是要在树的最后一行找到最左边的值。
如何判断是最后一行呢,其实就是深度最大的叶子节点一定是最后一行。
在这篇文章中,我们使用递归算法实实在在的求了一次深度,然后使用靠左的遍历,保证求得靠左的最大深度,而且又一次使用了回溯。
如果对二叉树的高度与深度又有点模糊了,在看这里二叉树:我平衡么? ,回忆一下吧。
二叉树:我的左下角的值是多少? 中把我们之前讲过的内容都过了一遍,此外,还用前序遍历的技巧求得了靠左的最大深度。
求二叉树的各种最值,就想应该采用什么样的遍历顺序,确定了遍历循序,其实就和数组求最值一样容易了。
-
周四
通过两道题目,彻底说清楚递归函数的返回值问题。
一般情况下:如果需要搜索整棵二叉树,那么递归函数就不要返回值,如果要搜索其中一条符合条件的路径,递归函数就需要返回值,因为遇到符合条件的路径了就要及时返回。
特别是有些时候 递归函数的返回值是bool类型,一些同学会疑惑为啥要加这个,其实就是为了找到一条边立刻返回。
其实还有一种就是后序遍历需要根据左右递归的返回值推出中间节点的状态,这种需要有返回值,例如222.完全二叉树 ,110.平衡二叉树 ,这几道我们之前也讲过。,110.平衡二叉树 ,这几道我们之前也讲过。
-
周五
构造二叉树有三个注意的点:
- 分割时候,坚持区间不变量原则,左闭右开,或者左闭又闭。
- 分割的时候,注意后序 或者 前序已经有一个节点作为中间节点了,不能继续使用了。
- 如何使用切割后的后序数组来切合中序数组?利用中序数组大小一定是和后序数组的大小相同这一特点来进行切割。
这道题目代码实现并不简单,大家啃下来之后,二叉树的构造应该不是问题了。
最后我还给出了为什么前序和后序不能唯一构成一棵二叉树,因为没有中序遍历就无法确定左右部分,也就无法分割。
-
周六
用数组构造二叉树的题目,每次分隔尽量不要定义新的数组,而是通过下标索引直接在原数组上操作,这样可以节约时间和空间上的开销。
文章中我还给出了递归函数什么时候加if,什么时候不加if,其实就是控制空节点(空指针)是否进入递归,是不同的代码实现方式,都是可以的。
一般情况来说:如果让空节点(空指针)进入递归,就不加if,如果不让空节点进入递归,就加if限制一下, 终止条件也会相应的调整。
21、合并二叉树
-
如何同时遍历两个二叉树呢?
其实和遍历一个树逻辑是一样的,只不过传入两个树的节点,同时操作。
递归前序遍历,前序中序后序都可以
class Solution { public: TreeNode* mergeTrees(TreeNode* t1, TreeNode* t2) { if (t1 == NULL) return t2; // 如果t1为空,合并之后就应该是t2 if (t2 == NULL) return t1; // 如果t2为空,合并之后就应该是t1 // 修改了t1的数值和结构 t1->val += t2->val; // 中 t1->left = mergeTrees(t1->left, t2->left); // 左 t1->right = mergeTrees(t1->right, t2->right); // 右 return t1; } };
迭代法,队列实现
class Solution { public: TreeNode* mergeTrees(TreeNode* t1, TreeNode* t2) { if (t1 == NULL) return t2; if (t2 == NULL) return t1; queue<TreeNode*> que; que.push(t1); que.push(t2); while(!que.empty()) { TreeNode* node1 = que.front(); que.pop(); TreeNode* node2 = que.front(); que.pop(); // 此时两个节点一定不为空,val相加 node1->val += node2->val; // 如果两棵树左节点都不为空,加入队列 if (node1->left != NULL && node2->left != NULL) { que.push(node1->left); que.push(node2->left); } // 如果两棵树右节点都不为空,加入队列 if (node1->right != NULL && node2->right != NULL) { que.push(node1->right); que.push(node2->right); } // 当t1的左节点 为空 t2左节点不为空,就赋值过去 if (node1->left == NULL && node2->left != NULL) { node1->left = node2->left; } // 当t1的右节点 为空 t2右节点不为空,就赋值过去 if (node1->right == NULL && node2->right != NULL) { node1->right = node2->right; } } return t1; } };
拓展
当然也可以秀一波指针的操作,这是我写的野路子,大家就随便看看就行了,以防带跑偏了。
如下代码中,想要更改二叉树的值,应该传入指向指针的指针。
代码如下:(前序遍历)无参数返回
class Solution { public: void process(TreeNode** t1, TreeNode** t2) { if ((*t1) == NULL && (*t2) == NULL) return; if ((*t1) != NULL && (*t2) != NULL) { (*t1)->val += (*t2)->val; } if ((*t1) == NULL && (*t2) != NULL) { *t1 = *t2; return; } if ((*t1) != NULL && (*t2) == NULL) { return; } process(&((*t1)->left), &((*t2)->left)); process(&((*t1)->right), &((*t2)->right)); } TreeNode* mergeTrees(TreeNode* t1, TreeNode* t2) { process(&t1, &t2); return t1; } };
22、二叉搜索树中的搜索
-
二叉搜索树是一个有序树:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉搜索树
这就决定了,二叉搜索树,递归遍历和迭代遍历和普通二叉树都不一样。
递归
class Solution { public: TreeNode* searchBST(TreeNode* root, int val) { if (root == NULL || root->val == val) return root; if (root->val > val) return searchBST(root->left, val); if (root->val < val) return searchBST(root->right, val); return NULL; } };
迭代
对于二叉搜索树可就不一样了,因为二叉搜索树的特殊性,也就是节点的有序性,可以不使用辅助栈或者队列就可以写出迭代法。
对于一般二叉树,递归过程中还有回溯的过程,例如走一个左方向的分支走到头了,那么要调头,在走右分支。
而对于二叉搜索树,不需要回溯的过程,因为节点的有序性就帮我们确定了搜索的方向。
例如要搜索元素为3的节点,我们不需要搜索其他节点,也不需要做回溯,查找的路径已经规划好了。
class Solution { public: TreeNode* searchBST(TreeNode* root, int val) { while (root != NULL) { if (root->val > val) root = root->left; else if (root->val < val) root = root->right; else return root; } return NULL; } };
23、验证二叉搜索树
-
要知道中序遍历下,输出的二叉搜索树节点的数值是有序序列。
有了这个特性,验证二叉搜索树,就相当于变成了判断一个序列是不是递增的了。
递归
class Solution { private: vector<int> vec; void traversal(TreeNode* root) { if (root == NULL) return; traversal(root->left); vec.push_back(root->val); // 将二叉搜索树转换为有序数组 traversal(root->right); } public: bool isValidBST(TreeNode* root) { vec.clear(); // 不加这句在leetcode上也可以过,但最好加上 traversal(root); for (int i = 1; i < vec.size(); i++) { // 注意要小于等于,搜索树里不能有相同元素 if (vec[i] <= vec[i - 1]) return false; } return true; } };
以上代码中,我们把二叉树转变为数组来判断,是最直观的,但其实不用转变成数组,可以在递归遍历的过程中直接判断是否有序。
这道题目比较容易陷入两个陷阱:
- 陷阱1
不能单纯的比较左节点小于中间节点,右节点大于中间节点就完事了。
我们要比较的是 左子树所有节点小于中间节点,右子树所有节点大于中间节点。所以以上代码的判断逻辑是错误的。
例如: [10,5,15,null,null,6,20] 这个case:
节点10大于左节点5,小于右节点15,但右子树里出现了一个6 这就不符合了!
- 陷阱2
样例中最小节点 可能是int的最小值,如果这样使用最小的int来比较也是不行的。
此时可以初始化比较元素为longlong的最小值。
问题可以进一步演进:如果样例中根节点的val 可能是longlong的最小值 又要怎么办呢?文中会解答。
了解这些陷阱之后我们来看一下代码应该怎么写:
递归三部曲:
- 确定递归函数,返回值以及参数
要定义一个longlong的全局变量,用来比较遍历的节点是否有序,因为后台测试数据中有int最小值,所以定义为longlong的类型,初始化为longlong最小值。
注意递归函数要有bool类型的返回值, 我们在二叉树:递归函数究竟什么时候需要返回值,什么时候不要返回值? (opens new window)中讲了,只有寻找某一条边(或者一个节点)的时候,递归函数会有bool类型的返回值。
其实本题是同样的道理,我们在寻找一个不符合条件的节点,如果没有找到这个节点就遍历了整个树,如果找到不符合的节点了,立刻返回。
递归
class Solution { public: long long maxVal = LONG_MIN; // 因为后台测试数据中有int最小值 bool isValidBST(TreeNode* root) { if (root == NULL) return true; bool left = isValidBST(root->left); // 中序遍历,验证遍历的元素是不是从小到大 if (maxVal < root->val) maxVal = root->val; else return false; bool right = isValidBST(root->right); return left && right; } };
或者(不用设置LONG_MIN)
class Solution { public: TreeNode* pre = NULL; // 用来记录前一个节点 bool isValidBST(TreeNode* root) { if (root == NULL) return true; bool left = isValidBST(root->left); if (pre != NULL && pre->val >= root->val) return false; pre = root; // 记录前一个节点 bool right = isValidBST(root->right); return left && right; } };
迭代法
class Solution { public: bool isValidBST(TreeNode* root) { stack<TreeNode*> st; TreeNode* cur = root; TreeNode* pre = NULL; // 记录前一个节点 while (cur != NULL || !st.empty()) { if (cur != NULL) { st.push(cur); cur = cur->left; // 左 } else { cur = st.top(); // 中 st.pop(); if (pre != NULL && cur->val <= pre->val) return false; pre = cur; //保存前一个访问的结点 cur = cur->right; // 右 } } return true; } };
24、二叉搜索树的最小绝对差
-
530. 二叉搜索树的最小绝对差 - 力扣(LeetCode)
题目中要求在二叉搜索树上任意两节点的差的绝对值的最小值。
注意是二叉搜索树,二叉搜索树可是有序的。
遇到在二叉搜索树上求什么最值啊,差值之类的,就把它想成在一个有序数组上求最值,求差值,这样就简单多了。有序数组很关键
递归(可以数组,一个用一个指针)
class Solution { private: int result = INT_MAX; TreeNode* pre = NULL; void traversal(TreeNode* cur) { if (cur == NULL) return; traversal(cur->left); // 左 if (pre != NULL){ // 中 result = min(result, cur->val - pre->val); } pre = cur; // 记录前一个 traversal(cur->right); // 右 } public: int getMinimumDifference(TreeNode* root) { traversal(root); return result; } };
迭代
class Solution { public: int getMinimumDifference(TreeNode* root) { stack<TreeNode*> st; TreeNode* cur = root; TreeNode* pre = NULL; int result = INT_MAX; while (cur != NULL || !st.empty()) { if (cur != NULL) { // 指针来访问节点,访问到最底层 st.push(cur); // 将访问的节点放进栈 cur = cur->left; // 左 } else { cur = st.top(); st.pop(); if (pre != NULL) { // 中 result = min(result, cur->val - pre->val); } pre = cur; cur = cur->right; // 右 } } return result; } };
25、二叉搜索树中的众数
-
-
如果是普通二叉树
如果不是二叉搜索树,最直观的方法一定是把这个树都遍历了,用map统计频率,把频率排个序,最后取前面高频的元素的集合。
有的同学可能可以想直接对map中的value排序,还真做不到,C++中如果使用std::map或者std::multimap可以对key排序,但不能对value排序。
所以要把map转化数组即vector,再进行排序,当然vector里面放的也是
pair<int, int>
类型的数据,第一个int为元素,第二个int为出现频率。具体代码如下(要熟悉sort中cmp,和map和vec的转换)
class Solution { private: void searchBST(TreeNode* cur, unordered_map<int, int>& map) { // 前序遍历 if (cur == NULL) return ; map[cur->val]++; // 统计元素频率 searchBST(cur->left, map); searchBST(cur->right, map); return ; } bool static cmp (const pair<int, int>& a, const pair<int, int>& b) { return a.second > b.second; } public: vector<int> findMode(TreeNode* root) { unordered_map<int, int> map; // key:元素,value:出现频率 vector<int> result; if (root == NULL) return result; searchBST(root, map); vector<pair<int, int>> vec(map.begin(), map.end()); sort(vec.begin(), vec.end(), cmp); // 给频率排个序 result.push_back(vec[0].first); for (int i = 1; i < vec.size(); i++) { // 取最高的放到result数组中 if (vec[i].second == vec[0].second) result.push_back(vec[i].first); else break; } return result; } };
-
二叉搜索树
既然是搜索树,它中序遍历就是有序的。
用之前的指针很关键的小技巧,思路很朴实
递归(清空是clear)
class Solution { private: int maxCount = 0; // 最大频率 int count = 0; // 统计频率 TreeNode* pre = NULL; vector<int> result; void searchBST(TreeNode* cur) { if (cur == NULL) return ; searchBST(cur->left); // 左 // 中 if (pre == NULL) { // 第一个节点 count = 1; } else if (pre->val == cur->val) { // 与前一个节点数值相同 count++; } else { // 与前一个节点数值不同 count = 1; } pre = cur; // 更新上一个节点 if (count == maxCount) { // 如果和最大值相同,放进result中 result.push_back(cur->val); } if (count > maxCount) { // 如果计数大于最大值频率 maxCount = count; // 更新最大频率 result.clear(); // 很关键的一步,不要忘记清空result,之前result里的元素都失效了 result.push_back(cur->val); } searchBST(cur->right); // 右 return ; } public: vector<int> findMode(TreeNode* root) { count = 0; maxCount = 0; TreeNode* pre = NULL; // 记录前一个节点 result.clear(); searchBST(root); return result; } };
迭代
class Solution { public: vector<int> findMode(TreeNode* root) { stack<TreeNode*> st; TreeNode* cur = root; TreeNode* pre = NULL; int maxCount = 0; // 最大频率 int count = 0; // 统计频率 vector<int> result; while (cur != NULL || !st.empty()) { if (cur != NULL) { // 指针来访问节点,访问到最底层 st.push(cur); // 将访问的节点放进栈 cur = cur->left; // 左 } else { cur = st.top(); st.pop(); // 中 if (pre == NULL) { // 第一个节点 count = 1; } else if (pre->val == cur->val) { // 与前一个节点数值相同 count++; } else { // 与前一个节点数值不同 count = 1; } if (count == maxCount) { // 如果和最大值相同,放进result中 result.push_back(cur->val); } if (count > maxCount) { // 如果计数大于最大值频率 maxCount = count; // 更新最大频率 result.clear(); // 很关键的一步,不要忘记清空result,之前result里的元素都失效了 result.push_back(cur->val); } pre = cur; cur = cur->right; // 右 } } return result; } };
-
26、二叉树的最近公共祖先
-
236. 二叉树的最近公共祖先 - 力扣(LeetCode)
用后序遍历,实现自下而上的遍历
首先最容易想到的一个情况:如果找到一个节点,发现左子树出现结点p,右子树出现节点q,或者 左子树出现结点q,右子树出现节点p,那么该节点就是节点p和q的最近公共祖先。如果递归遍历遇到q,就将q返回,遇到p 就将p返回,那么如果 左右子树的返回值都不为空,说明此时的中节点,一定是q 和p 的最近祖先。
那么有录友可能疑惑,会不会左子树 遇到q 返回,右子树也遇到q返回,这样并没有找到 q 和p的最近祖先。
二叉树节点数值是不重复的,而且一定存在 q 和 p。
但是很多人容易忽略一个情况,就是节点本身p(q),它拥有一个子孙节点q(p)。
两种情况代码实现过程都是一样的,也可以说,实现情况一的逻辑,顺便包含了情况二。
因为遇到 q 或者 p 就返回,这样也包含了 q 或者 p 本身就是 公共祖先的情况。
搜索一条边的写法:
if (递归函数(root->left)) return ; if (递归函数(root->right)) return ;
搜索整个树写法:
left = 递归函数(root->left); // 左 right = 递归函数(root->right); // 右 left与right的逻辑处理; // 中
实现
class Solution { public: TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { if (root == q || root == p || root == NULL) return root; TreeNode* left = lowestCommonAncestor(root->left, p, q); TreeNode* right = lowestCommonAncestor(root->right, p, q); if (left != NULL && right != NULL) return root; if (left == NULL && right != NULL) return right; else if (left != NULL && right == NULL) return left; else { // (left == NULL && right == NULL) return NULL; } } };
27、二叉树周末总结
-
周一
合并两个二叉树,平时我们都习惯了操作一个二叉树,一起操作两个树可能还有点陌生。
其实套路是一样,只不过一起操作两个树的指针
-
周二
大多是二叉搜索树的题目,其实都离不开中序遍历,因为这样就是有序的。
至于迭代法,相信大家看到文章中如此简单的迭代法的时候,都会感动的痛哭流涕。
-
周三
首先在此强调一下二叉搜索树的特性:
- 节点的左子树只包含小于当前节点的数。
- 节点的右子树只包含大于当前节点的数。
- 所有左子树和右子树自身必须也是二叉搜索树。
那么我们在验证二叉搜索树的时候,有两个陷阱:
- 陷阱一
不能单纯的比较左节点小于中间节点,右节点大于中间节点就完事了,而是左子树都小于中间节点,右子树都大于中间节点。
- 陷阱二
在一个有序序列求最值的时候,不要定义一个全局遍历,然后遍历序列更新全局变量求最值。因为最值可能就是int 或者 longlong的最小值。
推荐要通过前一个数值(pre)和后一个数值比较(cur),得出最值。
在二叉树中通过两个前后指针作比较,会经常用到。
-
周四
要知道二叉搜索树和中序遍历是好朋友!
在二叉树:搜索树的最小绝对差 中强调了要利用搜索树的特性,把这道题目想象成在一个有序数组上求两个数最小差值,这就是一道送分题了。
需要明确:在有序数组求任意两数最小值差等价于相邻两数的最小值差。
同样本题也需要用pre节点记录cur节点的前一个节点。(这种写法一定要掌握)
-
周五
在求众数集合的时候有一个技巧,因为题目中众数是可以有多个的,所以一般的方法需要遍历两遍才能求出众数的集合。
但可以遍历一遍就可以求众数集合,使用了适时清空结果集的方法,这个方法还是很巧妙的。相信仔细读了文章的同学会惊呼其巧妙!
所以大家不要看题目简单了,就不动手做了,我选的题目,一般不会简单到不用动手的程度,哈哈
-
周六
我们开始讲解如何在二叉树中求公共祖先的问题,本来是打算和二叉搜索树一起讲的,但发现篇幅过长,所以先讲二叉树的公共祖先问题。
如果找到一个节点,发现左子树出现结点p,右子树出现节点q,或者 左子树出现结点q,右子树出现节点p,那么该节点就是节点p和q的最近公共祖先。
这道题目的看代码比较简单,而且好像也挺好理解的,但是如果把每一个细节理解到位,还是不容易的。
主要思考如下几点:
- 如何从底向上遍历?
- 遍历整棵树,还是遍历局部树?
- 如何把结果传到根节点的?
这些问题都需要弄清楚,上来直接看代码的话,是可能想不到这些细节的。
公共祖先问题,还是有难度的,初学者还是需要慢慢消化!
-
总结
现在已经讲过了几种二叉树了,二叉树,二叉平衡树,完全二叉树,二叉搜索树,后面还会有平衡二叉搜索树。 那么一些同学难免会有混乱了,我针对如下三个问题,帮大家在捋顺一遍:
- 平衡二叉搜索树是不是二叉搜索树和平衡二叉树的结合?
是的,是二叉搜索树和平衡二叉树的结合。
- 平衡二叉树与完全二叉树的区别在于底层节点的位置?
是的,完全二叉树底层必须是从左到右连续的,且次底层是满的。
- 堆是完全二叉树和排序的结合,而不是平衡二叉搜索树?
堆是一棵完全二叉树,同时保证父子节点的顺序关系(有序)。 但完全二叉树一定是平衡二叉树,堆的排序是父节点大于子节点,而搜索树是父节点大于左孩子,小于右孩子,所以堆不是平衡二叉搜索树。
28、二叉搜索树的最近公共祖先
-
235. 二叉搜索树的最近公共祖先 - 力扣(LeetCode)
在有序树里,如果判断一个节点的左子树里有p,右子树里有q呢?
因为是有序树,所有 如果 中间节点是 q 和 p 的公共祖先,那么 中节点的数组 一定是在 [p, q]区间的。即 中节点 > p && 中节点 < q 或者 中节点 > q && 中节点 < p。
那么只要从上到下去遍历,遇到 cur节点是数值在[p, q]区间中则一定可以说明该节点cur就是q 和 p的最近公共祖先。
- 那么如果 cur->val 大于 p->val,同时 cur->val 大于q->val,那么就应该向左遍历(说明目标区间在左子树上)。
- 如果 cur->val 小于 p->val,同时 cur->val 小于 q->val,那么就应该向右遍历(目标区间在右子树)
递归
class Solution { public: TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { if (root->val > p->val && root->val > q->val) { return lowestCommonAncestor(root->left, p, q); } else if (root->val < p->val && root->val < q->val) { return lowestCommonAncestor(root->right, p, q); } else return root; } };
迭代
class Solution { public: TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { while(root) { if (root->val > p->val && root->val > q->val) { root = root->left; } else if (root->val < p->val && root->val < q->val) { root = root->right; } else return root; } return NULL; } };
29、二叉搜索树中的插入操作
-
701. 二叉搜索树中的插入操作 - 力扣(LeetCode)
只要遍历二叉搜索树,找到空节点 插入元素就可以了,其实根本不用重构,那么这道题其实就简单了。
递归(返回节点)
class Solution { public: TreeNode* insertIntoBST(TreeNode* root, int val) { if (root == NULL) { TreeNode* node = new TreeNode(val); return node; } if (root->val > val) root->left = insertIntoBST(root->left, val); if (root->val < val) root->right = insertIntoBST(root->right, val); return root; } };
递归(不返回节点)
class Solution { private: TreeNode* parent; void traversal(TreeNode* cur, int val) { if (cur == NULL) { TreeNode* node = new TreeNode(val); if (val > parent->val) parent->right = node; else parent->left = node; return; } parent = cur; if (cur->val > val) traversal(cur->left, val); if (cur->val < val) traversal(cur->right, val); return; } public: TreeNode* insertIntoBST(TreeNode* root, int val) { parent = new TreeNode(0); if (root == NULL) { root = new TreeNode(val); } traversal(root, val); return root; } };
网上千篇一律的代码,可能会误导大家认为通过递归函数返回节点 这样的写法是天经地义,其实这里是有优化的!
迭代
class Solution { public: TreeNode* insertIntoBST(TreeNode* root, int val) { if (root == NULL) { TreeNode* node = new TreeNode(val); return node; } TreeNode* cur = root; TreeNode* parent = root; // 这个很重要,需要记录上一个节点,否则无法赋值新节点 while (cur != NULL) { parent = cur; if (cur->val > val) cur = cur->left; else cur = cur->right; } TreeNode* node = new TreeNode(val); if (val < parent->val) parent->left = node;// 此时是用parent节点的进行赋值 else parent->right = node; return root; } };
30、删除二叉搜索树中的节点
-
450. 删除二叉搜索树中的节点 - 力扣(LeetCode)
这里就把二叉搜索树中删除节点遇到的情况都搞清楚。
有以下五种情况:
- 第一种情况:没找到删除的节点,遍历到空节点直接返回了
- 找到删除的节点
- 第二种情况:左右孩子都为空(叶子节点),直接删除节点, 返回NULL为根节点
- 第三种情况:删除节点的左孩子为空,右孩子不为空,删除节点,右孩子补位,返回右孩子为根节点
- 第四种情况:删除节点的右孩子为空,左孩子不为空,删除节点,左孩子补位,返回左孩子为根节点
- 第五种情况:左右孩子节点都不为空,则将删除节点的左子树头结点(左孩子)放到删除节点的右子树的最左面节点的左孩子上,返回删除节点右孩子为新的根节点。
递归(二叉树专属)
class Solution { public: TreeNode* deleteNode(TreeNode* root, int key) { if (root == nullptr) return root; // 第一种情况:没找到删除的节点,遍历到空节点直接返回了 if (root->val == key) { // 第二种情况:左右孩子都为空(叶子节点),直接删除节点, 返回NULL为根节点 if (root->left == nullptr && root->right == nullptr) { ///! 内存释放 delete root; return nullptr; } // 第三种情况:其左孩子为空,右孩子不为空,删除节点,右孩子补位 ,返回右孩子为根节点 else if (root->left == nullptr) { auto retNode = root->right; ///! 内存释放 delete root; return retNode; } // 第四种情况:其右孩子为空,左孩子不为空,删除节点,左孩子补位,返回左孩子为根节点 else if (root->right == nullptr) { auto retNode = root->left; ///! 内存释放 delete root; return retNode; } // 第五种情况:左右孩子节点都不为空,则将删除节点的左子树放到删除节点的右子树的最左面节点的左孩子的位置 // 并返回删除节点右孩子为新的根节点。 else { TreeNode* cur = root->right; // 找右子树最左面的节点 while(cur->left != nullptr) { cur = cur->left; } cur->left = root->left; // 把要删除的节点(root)左子树放在cur的左孩子的位置 TreeNode* tmp = root; // 把root节点保存一下,下面来删除 root = root->right; // 返回旧root的右孩子作为新root delete tmp; // 释放节点内存(这里不写也可以,但C++最好手动释放一下吧) return root; } } if (root->val > key) root->left = deleteNode(root->left, key); if (root->val < key) root->right = deleteNode(root->right, key); return root; } };
普通二叉树的删除方式(通用)
这里我在介绍一种通用的删除,普通二叉树的删除方式(没有使用搜索树的特性,遍历整棵树),用交换值的操作来删除目标节点。
代码中目标节点(要删除的节点)被操作了两次:
- 第一次是和目标节点的右子树最左面节点交换。
- 第二次直接被NULL覆盖了。
class Solution { public: TreeNode* deleteNode(TreeNode* root, int key) { if (root == nullptr) return root; if (root->val == key) { if (root->right == nullptr) { // 这里第二次操作目标值:最终删除的作用 return root->left; } TreeNode *cur = root->right; while (cur->left) { cur = cur->left; } swap(root->val, cur->val); // 这里第一次操作目标值:交换目标值其右子树最左面节点。 } root->left = deleteNode(root->left, key); root->right = deleteNode(root->right, key); return root; } };
迭代法
class Solution { private: // 将目标节点(删除节点)的左子树放到 目标节点的右子树的最左面节点的左孩子位置上 // 并返回目标节点右孩子为新的根节点 // 是动画里模拟的过程 TreeNode* deleteOneNode(TreeNode* target) { if (target == nullptr) return target; if (target->right == nullptr) return target->left; TreeNode* cur = target->right; while (cur->left) { cur = cur->left; } cur->left = target->left; return target->right; } public: TreeNode* deleteNode(TreeNode* root, int key) { if (root == nullptr) return root; TreeNode* cur = root; TreeNode* pre = nullptr; // 记录cur的父节点,用来删除cur while (cur) { if (cur->val == key) break; pre = cur; if (cur->val > key) cur = cur->left; else cur = cur->right; } if (pre == nullptr) { // 如果搜索树只有头结点 return deleteOneNode(cur); } // pre 要知道是删左孩子还是右孩子 if (pre->left && pre->left->val == key) { pre->left = deleteOneNode(cur); } if (pre->right && pre->right->val == key) { pre->right = deleteOneNode(cur); } return root; } };
31、修剪二叉搜索树
-
如果不对递归有深刻的理解,本题有点难 单纯移除一个节点那还不够,要修剪!
因为是要遍历整棵树,做修改,其实不需要返回值也可以,我们也可以完成修剪(其实就是从二叉树中移除节点)的操作。
但是有返回值,更方便,可以通过递归函数的返回值来移除节点。
递归
class Solution { public: TreeNode* trimBST(TreeNode* root, int low, int high) { if (root == nullptr ) return nullptr; if (root->val < low) { TreeNode* right = trimBST(root->right, low, high); // 寻找符合区间[low, high]的节点 return right; } if (root->val > high) { TreeNode* left = trimBST(root->left, low, high); // 寻找符合区间[low, high]的节点 return left; } root->left = trimBST(root->left, low, high); // root->left接入符合条件的左孩子 root->right = trimBST(root->right, low, high); // root->right接入符合条件的右孩子 return root; } };
迭代
class Solution { public: TreeNode* trimBST(TreeNode* root, int L, int R) { if (!root) return nullptr; // 处理头结点,让root移动到[L, R] 范围内,注意是左闭右闭 while (root != nullptr && (root->val < L || root->val > R)) { if (root->val < L) root = root->right; // 小于L往右走 else root = root->left; // 大于R往左走 } TreeNode *cur = root; // 此时root已经在[L, R] 范围内,处理左孩子元素小于L的情况 while (cur != nullptr) { while (cur->left && cur->left->val < L) { cur->left = cur->left->right; } cur = cur->left; } cur = root; // 此时root已经在[L, R] 范围内,处理右孩子大于R的情况 while (cur != nullptr) { while (cur->right && cur->right->val > R) { cur->right = cur->right->left; } cur = cur->right; } return root; } };
32、将有序数组转换成二叉搜索树
-
108. 将有序数组转换为二叉搜索树 - 力扣(LeetCode)
题目中说要转换为一棵高度平衡二叉搜索树。为什么强调要平衡呢?
因为只要给我们一个有序数组,如果不强调平衡,都可以以线性结构来构造二叉搜索树。其实数组构造二叉树,构成平衡树是自然而然的事情,因为大家默认都是从数组中间位置取值作为节点元素,一般不会随机取。所以想构成不平衡的二叉树是自找麻烦。
本质就是寻找分割点,分割点作为当前节点,然后递归左区间和右区间。
分割点就是数组中间位置的节点。
那么为问题来了,如果数组长度为偶数,中间节点有两个,取哪一个?
取哪一个都可以,只不过构成了不同的平衡二叉搜索树。
递归(主要去中间值防止超范围技巧,和循环不变量)
class Solution { private: TreeNode* traversal(vector<int>& nums, int left, int right) { if (left > right) return nullptr; int mid = left + ((right - left) / 2); TreeNode* root = new TreeNode(nums[mid]); root->left = traversal(nums, left, mid - 1); root->right = traversal(nums, mid + 1, right); return root; } public: TreeNode* sortedArrayToBST(vector<int>& nums) { TreeNode* root = traversal(nums, 0, nums.size() - 1); return root; } };
在调用traversal的时候传入的left和right为什么是0和nums.size() - 1,因为定义的区间为左闭右闭。也可以是其他值,比如左闭右开
迭代
class Solution { public: TreeNode* sortedArrayToBST(vector<int>& nums) { if (nums.size() == 0) return nullptr; TreeNode* root = new TreeNode(0); // 初始根节点 queue<TreeNode*> nodeQue; // 放遍历的节点 queue<int> leftQue; // 保存左区间下标 queue<int> rightQue; // 保存右区间下标 nodeQue.push(root); // 根节点入队列 leftQue.push(0); // 0为左区间下标初始位置 rightQue.push(nums.size() - 1); // nums.size() - 1为右区间下标初始位置 while (!nodeQue.empty()) { TreeNode* curNode = nodeQue.front(); nodeQue.pop(); int left = leftQue.front(); leftQue.pop(); int right = rightQue.front(); rightQue.pop(); int mid = left + ((right - left) / 2); curNode->val = nums[mid]; // 将mid对应的元素给中间节点 if (left <= mid - 1) { // 处理左区间 curNode->left = new TreeNode(0); nodeQue.push(curNode->left); leftQue.push(left); rightQue.push(mid - 1); } if (right >= mid + 1) { // 处理右区间 curNode->right = new TreeNode(0); nodeQue.push(curNode->right); leftQue.push(mid + 1); rightQue.push(right); } } return root; } };
33、把二叉搜索转换为累加树
-
538. 把二叉搜索树转换为累加树 - 力扣(LeetCode)
那么知道如何遍历这个二叉树,也就迎刃而解了,从树中可以看出累加的顺序是右中左,所以我们需要反中序遍历这个二叉树,然后顺序累加就可以了。
递归
class Solution { private: int pre = 0; // 记录前一个节点的数值 void traversal(TreeNode* cur) { // 右中左遍历 if (cur == NULL) return; traversal(cur->right); cur->val += pre; pre = cur->val; traversal(cur->left); } public: TreeNode* convertBST(TreeNode* root) { pre = 0; traversal(root); return root; } };
迭代
class Solution { private: int pre; // 记录前一个节点的数值 void traversal(TreeNode* root) { stack<TreeNode*> st; TreeNode* cur = root; while (cur != NULL || !st.empty()) { if (cur != NULL) { st.push(cur); cur = cur->right; // 右 } else { cur = st.top(); // 中 st.pop(); cur->val += pre; pre = cur->val; cur = cur->left; // 左 } } } public: TreeNode* convertBST(TreeNode* root) { pre = 0; traversal(root); return root; } };
34、二叉树总结篇
在二叉树题目选择什么遍历顺序是不少同学头疼的事情,我们做了这么多二叉树的题目了。
- 涉及到二叉树的构造,无论普通二叉树还是二叉搜索树一定前序,都是先构造中节点。
- 求普通二叉树的属性,一般是后序,一般要通过递归函数的返回值做计算。
- 求二叉搜索树的属性,一定是中序了,要不白瞎了有序性了。
注意在普通二叉树的属性中,我用的是一般为后序,例如单纯求深度就用前序,二叉树:找所有路径 也用了前序,这是为了方便让父节点指向子节点。
所以求普通二叉树的属性还是要具体问题具体分析