这篇主要总结力扣上一些经典的算法题目,参考网站代码随想录 (programmercarl.com) (该博客仅作为自学使用)
文章的分类不是严格按照基本数据结构来分类的,而是按照力扣算法题的共性总结,可能是使用了某种共同的数据结构、解题思想或某种共同的算法;
2023/2/20 8:28 刷题这部分暂时也搁置一段时间,主要是这个东西只需要在短时间内大量刷题就可以,而对于考研来说现阶段刷题的收益太低了,现阶段几乎用不到这些算法相关的知识点;
2023/10/24 20:53 时隔大半年,因为种种原因我又回来刷题了…看了看自己做的这些刷题整理说实话挺佩服这么有毅力能够坚持这么麻烦的事情干这么久…之后的话可能就不会继续这么详细的做笔记(效率太低了,刷题不是这么个刷法),可能遇到的一些比较经典好玩的题型我会补充总结到这里面。因为最开始刷题和之后复习数据结构使用的都是c/c++语言,所以后续刷题也保持用c/c++语言;
一、数组 0.数组概述
一维数组
二维数组
1.二分查找 题目要求:
解析:
本题可以直接暴力遍历数组,以查找数组中不重复的某个元素(由假设得来);
但是直接使用暴力遍历会导致时间复杂度极高,题干中出现关键词“有序”“不重复元素”,可以考虑使用二分法;
使用二分法要注意原始区间是双闭还是半闭半开,这将影响判断条件的书写;
解题思路1:
实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution {public : int search (vector<int >& nums, int target) { int left = 0 ; int right = nums.size () - 1 ; while (left <= right) { int middle = left + ((right - left) / 2 ); if (nums[middle] > target) { right = middle - 1 ; } else if (nums[middle] < target) { left = middle + 1 ; } else { return middle; } } return -1 ; } };
解题思路2:
实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution {public : int search (vector<int >& nums, int target) { int left = 0 ; int right = nums.size (); while (left < right) { int middle = left + ((right - left) >> 1 ); if (nums[middle] > target) { right = middle; } else if (nums[middle] < target) { left = middle + 1 ; } else { return middle; } } return -1 ; } };
2.移除元素 题目要求:
解析:
这道题直接看示例很容易把人搞晕不知道该干什么,实际上就是一个简单的删除数组中所有值等于val的元素并返回最终数组的大小;
因为数组内存空间连续,只能使用后面的元素将前面的元素覆盖,所以我们有两种解题思路;
一种是使用两层for循环,第一个循环遍历数组元素,第二个循环用于更新数组,时间复杂度为O(n^2);
第二种是使用双指针,通过一个快指针一个慢指针,在一层for循环中完成两层for循环的工作;
思路1实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution {public : int removeElement (vector<int >& nums, int val) { int size = nums.size (); for (int i = 0 ; i < size; i++) { if (nums[i] == val) { for (int j = i + 1 ; j < size; j++) { nums[j - 1 ] = nums[j]; } i--; size--; } } return size; } };
解题思路2:
首先我们需要定义快慢指针的含义(其实这里的指针并不是严格意义上的指针,只能说是一个数组元素位置标记):
快指针:遍历原数组,寻找新数组的元素 ,新数组就是不含有目标元素的数组
慢指针:更新新数组元素
实现代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Solution {public : int removeElement (vector<int >& nums, int val) { int slowIndex = 0 ; for (int fastIndex = 0 ; fastIndex < nums.size (); fastIndex++) { if (val != nums[fastIndex]) { nums[slowIndex++] = nums[fastIndex]; } } return slowIndex; } };
3.有序数组的平方 题目要求:
解析:
这道题有一个很容易忽略的点,假如原数组只有正数则直接遍历并返回平方即可,但是原数组是可以有负数的;
本题同样有两种解法,一种是使用单层for循环返回原数组的平方,接着再对返回的数进行排序(自己写函数或者直接调用sort()函数都可以);
第二种是使用双指针(注意这里的双指针是头尾指针,与前面使用的快慢指针有一定区别);
思路1代码如下:
1 2 3 4 5 6 7 8 9 10 11 class Solution {public : vector<int > sortedSquares (vector<int >& A) { for (int i = 0 ; i < A.size (); i++) { A[i] *= A[i]; } sort (A.begin (), A.end ()); return A; } };
思路2:
尽管数组中有负数,但是原数组因为是有序的,所以平方后得到的较大值在原数组中要么就是靠左要么靠右;
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution {public : vector<int > sortedSquares (vector<int >& A) { int k = A.size () - 1 ; vector<int > result (A.size(), 0 ) ; for (int i = 0 , j = A.size () - 1 ; i <= j;) { if (A[i] * A[i] < A[j] * A[j]) { result[k--] = A[j] * A[j]; j--; } else { result[k--] = A[i] * A[i]; i++; } } return result; } };
4.长度最小的子数组
解析:
这道题同样也是题干就很容易让人混淆,究竟是什么意思?
翻译过来就是给定一个target,我们需要在原数组中寻找元素使其和大于等于target,我们使用的这些元素将会构成一个新的数组,要求该数组的长度最小;
这道题同样有两种解法,一种是使用暴力双循环,外循环用于确定子序列起点i,内循环用于确定子序列的终点j;
第二种解法是使用滑动窗口,本质上也是双指针;
思路1代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Solution {public : int minSubArrayLen (int s, vector<int >& nums) { int result = INT32_MAX; int sum = 0 ; int subLength = 0 ; for (int i = 0 ; i < nums.size (); i++) { sum = 0 ; for (int j = i; j < nums.size (); j++) { sum += nums[j]; if (sum >= s) { subLength = j - i + 1 ; result = result < subLength ? result : subLength; break ; } } } return result == INT32_MAX ? 0 : result; } };
思路2:
滑动窗口:不断调整子序列的起始位置和终止位置(其实和上面设置起始位置i和终止位置j相同,但是只用了一个for循环)
使用滑动窗口需要解决下面的几个问题
窗口内是什么?——窗口内是满足其和 ≥s 的长度最小的连续子数组
如何移动窗口的起始位置?——如果当前窗口的值大于s了,窗口就要向后移动了(也就是该缩小了)
如何移动窗口的结束位置?——窗口的结束位置就是遍历数组的指针,也就是for循环里的索引值i
因此使用滑动窗口的关键在于 如何移动窗口的起始位置
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Solution {public : int minSubArrayLen (int s, vector<int >& nums) { int result = INT32_MAX; int sum = 0 ; int i = 0 ; int subLength = 0 ; for (int j = 0 ; j < nums.size (); j++) { sum += nums[j]; while (sum >= s) { subLength = (j - i + 1 ); result = result < subLength ? result : subLength; sum -= nums[i++]; } } return result == INT32_MAX ? 0 : result; } };
5.螺旋矩阵
解析
首先这道题其实就是考察我们对二维数组/矩阵的掌握程度,
二维数组/矩阵是以数组作为数组元素的数组;
二维数组第一个参数是行,第二个参数是列,先按照行排再按照列排,即数Aij位于矩阵的第i行第j列;
第二个考察点是在画矩阵的时候需要按照顺序,不要想到哪里画哪里
由外向内构建二维矩阵
填充上行从左到右
填充右列从上到下
填充下行从右到左
填充左列从下到上
第三点是每一条边都要遵守某个相同的规则
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 class Solution {public : vector<vector<int >> generateMatrix (int n) { vector<vector<int >> res (n, vector <int >(n, 0 )); int startx = 0 , starty = 0 ; int loop = n / 2 ; int mid = n / 2 ; int count = 1 ; int offset = 1 ; int i,j; while (loop --) { i = startx; j = starty; for (j = starty; j < n - offset; j++) { res[startx][j] = count++; } for (i = startx; i < n - offset; i++) { res[i][j] = count++; } for (; j > starty; j--) { res[i][j] = count++; } for (; i > startx; i--) { res[i][j] = count++; } startx++; starty++; offset += 1 ; } if (n % 2 ) { res[mid][mid] = count; } return res; } };
二、链表 0.链表概述 链表这一章仔细看看,能够提升对C++指针概念的理解
链表是通过指针串联在一起的节点的线性集合;
每个节点由两部分组成,数据域用于存放数据,指针域用于存放指向下一个节点的指针(最后一个节点的指针域为NULL)
单链表
注意区分头指针和头节点的概念
头指针指向链表的第一个结点,若存在头结点则指向头结点,若不存在头节点则指向第一个结点;
头节点的数据域一般不存数据(也可以存储表长等数据),头节点不一定是链表必须要的元素(引入头节点为了方便单链表的特殊操作,保证在表头插入或者删除第一个结点与其他结点操作相同.保持了单链表操作的统一性);
常用头指针冠以链表的名字,头指针是链表的必要元素;
双链表(每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点;双链表既可以向前查询也可以向后查询)
循环链表
1.移除链表元素
这道题本质上非常简单,就是考察单链表的删除操作;
需要注意的是,因为力扣直接给出了单链表的定义,但是这个单链表是不包含头节点的,需要我们在处理的时候增加一个头节点;
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Solution {public : ListNode* removeElements (ListNode* head, int val) { ListNode* dummyHead = new ListNode (0 ); dummyHead->next = 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; } };
2.设计链表
可能刚开始看到这个题会很疑惑,不是说leecode不是已经帮我们定义了链表吗?怎么这里又让我们自己去设计?
其实这是leecode刷题这种模式的特点,它不像牛客所有的题都让你从0开始写,很多题目leecode是直接帮你调用了STL或者默认帮你实现了数据结构,只需要额外定义一个solution类来解决题目中的问题即可;
但是这道题的话就有点0基础使用链表定义来设计并实现具有特定功能的链表了;
这个题也非常简单非常基础,有利于理解链表的知识框架,我们直接给出代码和注释即可;
代码如下(虚拟头节点版本):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 class MyLinkedList {public : struct LinkedNode { int val; LinkedNode* next; LinkedNode (int val):val (val), next (nullptr ){} }; MyLinkedList () { _dummyHead = new LinkedNode (0 ); _size = 0 ; } int get (int index) { if (index > (_size - 1 ) || index < 0 ) { return -1 ; } LinkedNode* cur = _dummyHead->next; while (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++; } void addAtIndex (int index, int val) { if (index > _size) { return ; } LinkedNode* newNode = new LinkedNode (val); LinkedNode* cur = _dummyHead; while (index--) { cur = cur->next; } newNode->next = cur->next; cur->next = newNode; _size++; } 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; _size--; } void printLinkedList () { LinkedNode* cur = _dummyHead; while (cur->next != nullptr ) { cout << cur->next->val << " " ; cur = cur->next; } cout << endl; }private : int _size; LinkedNode* _dummyHead; };
3.反转链表
解析
反转链表这种题,实际上就是改变了next指针的指向,同时将第一个节点和最后一个节点互换;
解题使用双指针,第一个cur指针用于从前到后遍历链表的每个节点,第二个pre指针用于创建新的链表(本质上并没有使用额外空间,仅仅只是改变了next指针的指向而已)
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution {public : ListNode* reverseList (ListNode* head) { ListNode* temp; ListNode* cur = head; ListNode* pre = NULL ; while (cur) { temp = cur->next; cur->next = pre; pre = cur; cur = temp; } return pre; } };
4.交换链表节点
leecode的题总是能够让你读的半懵半懵的,实际上就是交换相邻的两个节点(交换过后的节点不再交换)——所以如果节点个数是奇数怎么办???假如是单数的话默认最后一个节点没得交换;
注意这个是单向链表,只能操作其next指针,所以需要注意交换的步骤
具体交换过程主要分为三个步骤(这里需要引入虚拟头节点,我们假设涉及交换的节点分别为cur,1,2,3,其中要交换的节点是1,2)
cur->next=2
2->next=1
1->next=3
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution {public : ListNode* swapPairs (ListNode* head) { ListNode* dummyHead = new ListNode (0 ); dummyHead->next = head; ListNode* cur = dummyHead; while (cur->next != nullptr && cur->next->next != nullptr ) { ListNode* tmp = cur->next; ListNode* tmp1 = cur->next->next->next; cur->next = cur->next->next; cur->next->next = tmp; cur->next->next->next = tmp1; cur = cur->next->next; } return dummyHead->next; } };
5.删除链表倒数第n个节点
解析:
这个题感觉很简单啊,直接找到并删除倒数第n个节点即可;
所以这道题是中等难度的原因就在于,找到倒数第n个节点在链表中是一件不容易的事;
这道题使用双指针可以快速解决——如果要删除倒数第n个节点,让fast移动n步,然后让fast和slow同时移动,直到fast指向链表末尾NULL,删掉slow所指向的节点就可以了
细节:
定义虚拟头节点且fast和slow初值均指向虚拟头节点;
slow指针在fast指针指向NULL的时候,slow指针是指向要删除的节点的前一个节点而非要删除的节点(否则没办法进行删除操作);
删除的节点是slow指针指向的节点的下一个节点;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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; while (fast != NULL ) { fast = fast->next; slow = slow->next; } slow->next = slow->next->next; return dummyHead->next; } };
6.环形链表1
这道题不是直接根据输入的skip就写出来了吗…还是说是我读题理解有问题?
这道题的举例是有问题的,我们定义的Solution类中的方法仅仅只有两个参数,即传入链表A和链表B,而相交节点的值明显是一个误导信息,因为链表中节点相同实质上是指向节点的指针的地址相同而不是节点存储的值相同,另外,skip这个条件属于是作弊条件,我要是都知道相交节点在距离头节点多远的位置,为什么我不直接跳过去呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 class Solution {public : ListNode *getIntersectionNode (ListNode *headA, ListNode *headB) { ListNode* curA = headA; ListNode* curB = headB; int lenA = 0 , lenB = 0 ; while (curA != NULL ) { lenA++; curA = curA->next; } while (curB != NULL ) { lenB++; curB = curB->next; } curA = headA; curB = headB; if (lenB > lenA) { swap (lenA, lenB); swap (curA, curB); } int gap = lenA - lenB; while (gap--) { curA = curA->next; } while (curA != NULL ) { if (curA == curB) { return curA; } curA = curA->next; curB = curB->next; } return NULL ; } };
7.环形链表2
这道题乍一看挺吓人的,找到环形的入口,这让人如何是好?实际上这道题主要有两个考点:
判断链表是否有环;
若存在环,如何找到这个环的入口;
判断链表是否有环可以使用双指针,fast每次移动2步,slow每次移动1步,如果有环则fast和slow一定会相交并且是在环内相交
如果链表有环,
参考题解:刘翔以老八2倍的速度和老八从头开始跑,直到和老八相遇,很疑惑:哦原来有环啊?
老八也很疑惑,那么什么是哪个点出现的环啊?
刘翔说:忘记了,我只知道我跑的路程是你的两倍,而且我已经比你这个乌龟多跑一圈了,不然怎么可能抓到你。
老八说:杀千刀的,那假设那个点前距离是a好了,我现在跑了a+b了,你比我多一圈那就是2(a+b) 那 2(a+b)==a+c(c是圈的长度)+b 得出a+b=c。
刘翔:哦那简单啊,那你现在距离那个点已经b了,再跑a个点不就到那里了嘛。
老八生气的说: 我他niang的怎么知道a是多少?求的就是a啊大哥!
刘翔邪魅一笑:那简单啊,我现在叫我朋友博尔特从起点和你一个速度跑,你们都差a 就到了,相遇了就是答案了哇。
老八: 呵呵。算你狠。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution { public ListNode detectCycle (ListNode head) { ListNode root=head; ListNode left=head; ListNode right=head; while (right!=null && right.next!=null){ right=right.next.next; left=left.next; if (left==right){ while (root!=right){ root=root.next; right=right.next; } return root; } } return null; } }
三、哈希表 0.哈希表概述 hash table即哈希表、散列表,一般用来快速判断一个元素是否出现在集合里;
哈希表是根据关键码的值直接进行访问的数据结构;
数组就是一张哈希表,哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素;
哈希表上的映射函数也称为哈希函数,下面展示了一个将学生姓名映射到哈希表上的hash function;
使用哈希函数涉及模运算,这可能导致小王和小张在哈希表的同一个索引下标的位置进而发生冲突,这就叫做哈希碰撞;
一般哈希碰撞有两种解决方法,拉链法和线性探测法;
拉链法是指将发生冲突的元素都存储在链表中;
拉链法就是要选择适当的哈希表的大小,这样既不会因为哈希表空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间;
使用线性探测法,一定要保证哈希表大小tableSize大于数据规模dataSize,我们需要依靠哈希表中的空位来解决碰撞问题;
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放冲突的数据了
上面我们介绍了哈希表的基本概念(这个在数据结构的笔记里没有专门的介绍,感兴趣可以再查资料学习),当我们需要使用哈希算法
解决问题通常涉及如下三种数据结构:
STL为集合set提供了以下三种数据结构
红黑树是一种平衡二叉搜索树,所有key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加
STL为映射map提供了以下三种数据结构
虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,但是std::set、std::multiset 依然使用哈希函数来做映射,只不过底层的符号表使用了红黑树来存储数据,所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 map也是一样的道理。
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法;
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找;
如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法;
红黑树是一种自平衡二叉查找树,是一种接近平衡的二叉搜索树,它能够保证任意一个节点左右子树的高度差不会超过较低子树的高度;
1.有效字母异位词
解析:
首先要知道什么是字母异位词,实际上就是两个单词包含相同的字母但是字母的次序不同如”anagram” 和 “nagaram”;
暴力解法本质上就是两层for循环并记录出现的字符是否重复,时间复杂度为O(n^2^);
第二种解法使用数组,数组是一个哈希表,因为这题的本质就是判断元素是否出现在集合中(可以使用两个集合互相比较,也可以使用一个集合加减判断),所以我们考虑哈希表;
因为题目说只需要考虑小写字母,故数组的大小设置为26,字母a映射在数组下标0,字母z映射在数组下标25,数组record用于记录字符串s和t中字符出现的次数,s中出现的字符对应在数组中+1,t中出现的字符对应在数组中-1,最后检查record数组中如果存在元素不为0则reurn false,该方法时间复杂度为O(n),空间复杂度为O(1);
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution {public : bool isAnagram (string s, string t) { int record[26 ] = {0 }; for (int i = 0 ; i < s.size (); i++) { record[s[i] - 'a' ]++; } for (int i = 0 ; i < t.size (); i++) { record[t[i] - 'a' ]--; } for (int i = 0 ; i < 26 ; i++) { if (record[i] != 0 ) { return false ; } } return true ; } };
2.两个数组的交集
解析:
这道题本质上也就是需要判断某个元素是否出现在集合中,因此我们选择考虑哈希表;
当然这道题可以使用暴力for循环,其时间复杂度为O(n^2^);
前面使用了数组作为哈希表,但需要注意这样的前提是题目已经限制了数组的大小,而且如果哈希值较少、分散且跨度很大使用数组会造成浪费;
我们这里考虑使用set结构体作为哈希表,关于set,C++提供了三种可用的数据结构:
std::set
std::multiset
std::unordered_set
std::set和std::multiset底层实现都是红黑树,std::unordered_set的底层实现是哈希表,更多关于set容器的介绍可以参考STL初级 - Tintoki_blog (gintoki-jpg.github.io)
set 容器具有以下几个特性:
不再以键值对的方式存储数据,因为 set 容器专门用于存储键和值相等的键值对,因此该容器中真正存储的是各个键值对的值(value);
set 容器在存储数据时,会根据各元素值的大小对存储的元素进行排序(默认做升序排序);
存储到 set 容器中的元素,虽然其类型没有明确用 const 修饰,但正常情况下它们的值是无法被修改的;
set 容器存储的元素必须互不相等;
我们很容易看出set容器恰好符合题干的要求,这里综合考虑最终选择unordered_set
set容器代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Solution {public : vector<int > intersection (vector<int >& nums1, vector<int >& nums2) { unordered_set<int > result_set; unordered_set<int > nums_set (nums1.begin(), nums1.end()) ; for (int num : nums2) { if (nums_set.find (num) != nums_set.end ()) { result_set.insert (num); } } return vector <int >(result_set.begin (), result_set.end ()); } };
数组代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution {public : vector<int > intersection (vector<int >& nums1, vector<int >& nums2) { unordered_set<int > result_set; int hash[1005 ] = {0 }; for (int num : nums1) { hash[num] = 1 ; } for (int num : nums2) { if (hash[num] == 1 ) { result_set.insert (num); } } return vector <int >(result_set.begin (), result_set.end ()); } };
3.快乐数 题目链接202. 快乐数 - 力扣(LeetCode)
快乐数被定义为
解析:
“需要快速判断一个元素是否出现在集合中,考虑哈希法”,本题采用哈希法快速判断sum平方和是否重复出现,如果sum重复出现代表一定是无限循环,则return false,否则需要一直计算sum直到sum为1;
因为需要判断sum是否重复出现,也就是集合中重复元素的判断,因此这里使用unordered_set而非数组;
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class Solution {public : int getSum (int n) { int sum = 0 ; while (n) { sum += (n % 10 ) * (n % 10 ); n /= 10 ; } return sum; } bool isHappy (int n) { unordered_set<int > set; while (1 ) { int sum = getSum (n); if (sum == 1 ) { return true ; } if (set.find (sum) != set.end ()) { return false ; } else { set.insert (sum); } n = sum; } } };
4.两数之和
解析:
这道题很眼熟…因该是之前在哪里做过?或者其实数组那一节的第四题有点类似;
什么时候使用哈希法 —— 需要查询一个元素是否出现过,或者一个元素是否在集合里的时候
本题题意为使用一个集合存放我们遍历过的元素,然后在遍历数组的时候去访问这个集合(如果觉得绕的话可以看了代码解析再回头理解这句话)
本题采用map的key value结构分别存放元素以及该元素对应的下标,之所以不选择数组和set是因为
数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费;
set是一个集合,里面放的元素只能是一个key,而本题不仅要判断y是否存在而且还要记录y的下标位置,所以set 也不能用;
C++提供了三种map的数据类型
基本原理图如下
代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Solution {public : vector<int > twoSum (vector<int >& nums, int target) { std::unordered_map <int ,int > map; for (int i = 0 ; i < nums.size (); i++) { auto iter = map.find (target - nums[i]); if (iter != map.end ()) { return {iter->second, i}; } map.insert (pair <int , int >(nums[i], i)); } return {}; } };
5.四数相加
这道题目是四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑有重复的四个元素相加等于0的情况;(这道题和只给出一个数组并找出四个元素相加等于0相比的难度较小)
解析:(看不懂很正常,结合代码理解)
首先定义 一个unordered_map,key放a和b两数之和,value 放a和b两数之和出现的次数;
遍历大A和大B数组,统计两个数组元素之和,和出现的次数,放到map中;
定义int变量count,用来统计 a+b+c+d = 0 出现的次数;
在遍历大C和大D数组,找到如果 0-(c+d) 在map中出现过的话,就用count把map中key对应的value也就是出现次数统计出来;
最后返回统计值 count 就可以了;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Solution {public : int fourSumCount (vector<int >& A, vector<int >& B, vector<int >& C, vector<int >& D) { unordered_map<int , int > umap; for (int a : A) { for (int b : B) { umap[a + b]++; } } int count = 0 ; for (int c : C) { for (int d : D) { if (umap.find (0 - (c + d)) != umap.end ()) { count += umap[0 - (c + d)]; } } } return count; } };
6.三数之和
解析
之所以把这道题放在这里不是因为它可以使用哈希法解决,而是它恰好不能使用哈希法(因为三元组不能重复所以细节处理很耗费时间);
这道题使用双指针解法如下,首先我们需要将数组排序(双指针法一定需要排序),借助一层for循环,定义了left和right两个指针,left初始化为i+1,right初始化为数组末尾,将三个数转换为nums[i]、nums[left]以及nums[right];
移动left指针和right指针的规则如下:
如果nums[i] + nums[left] + nums[right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以right下标就应该向左移动,这样才能让三数之和小一些;
如果 nums[i] + nums[left] + nums[right] < 0 说明 此时三数之和小了,left 就向右移动,才能让三数之和大一些;
如果找到结果,则将三元组存储后同时收缩left和right指针,转移到下一层for循环;
如果没有找到结果,直到left与right相遇,转移到下一层for循环;
基本思想并不难,需要注意的是题干要求不能有重复的三元组,那么还需要考虑去重,也就是nums[i]、nums[left]以及nums[right] —— 注意是三元组不能相同,但是三元组中的元素可以重复;
很多人可能一来的想法是直接得到所有符合的三元组之后,依次比较每一个三元组的元素进行三元组去重,这种方法就属于是最低效的,我们应该考虑在构造三元组的时候就进行去重;
首先是对nums[i]的去重,因为nums[i]是依次移动的,从数组下标0位置移动到末尾(注意我们的数组是经过排序的),因此只需要比较nums[i]和它前面一个数nums[i-1]是否相同,如果相同则直接跳过(注意千万不能和nums[i]后面的数nums[i+1]比较,比如三元组[-1,-1,2]这种情况就会被遗漏)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class Solution {public : vector<vector<int >> threeSum (vector<int >& nums) { vector<vector<int >> result; sort (nums.begin (), nums.end ()); for (int i = 0 ; i < nums.size (); i++) { if (nums[i] > 0 ) { return result; } if (i > 0 && nums[i] == nums[i - 1 ]) { continue ; } int left = i + 1 ; int right = nums.size () - 1 ; while (right > 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]}); while (right > left && nums[right] == nums[right - 1 ]) right--; while (right > left && nums[left] == nums[left + 1 ]) left++; right--; left++; } } } return result; } };
7.四数之和
解析
这道题同样不能使用哈希表,因为和三数求和一样需要去重,这道题的解法也是使用双指针,只是多套用了一层for循环(为什么不能使用三指针呢?因为三指针不便于管理,还不如for循环);
需要注意以下细节:
判断第一个数大于target值直接return的逻辑需要严格限制,因为这里的target并不是指定的0,将nums[k] > target
变为nums[i] > target && (nums[i] >=0 || target >= 0)
;
三数求和的双指针解法是一层for循环num[i]为确定值,然后循环内有left和right下标作为双指针,找到nums[i] + nums[left] + nums[right] == 0;四数之和的双指针解法是两层for循环nums[k] + nums[i]为确定值,依然是循环内有left和right下标作为双指针,找出nums[k] + nums[i] + nums[left] + nums[right] == target的情况,三数之和的时间复杂度是O(n^2^),四数之和的时间复杂度是O(n^3^);(同理我们可以类推五数求和、六数求和…实际上三数求和就是将暴力O(n^3^)降为O(n^2^)同理…)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 class Solution {public : vector<vector<int >> fourSum (vector<int >& nums, int target) { vector<vector<int >> result; sort (nums.begin (), nums.end ()); for (int k = 0 ; k < nums.size (); k++) { if (nums[k] > target && nums[k] >= 0 ) { break ; } if (k > 0 && nums[k] == nums[k - 1 ]) { continue ; } for (int i = k + 1 ; i < nums.size (); i++) { if (nums[k] + nums[i] > target && nums[k] + nums[i] >= 0 ) { break ; } if (i > k + 1 && nums[i] == nums[i - 1 ]) { continue ; } int left = i + 1 ; int right = nums.size () - 1 ; while (right > left) { if ((long ) nums[k] + nums[i] + nums[left] + nums[right] > target) { right--; } else if ((long ) nums[k] + nums[i] + nums[left] + nums[right] < target) { left++; } else { result.push_back (vector<int >{nums[k], nums[i], nums[left], nums[right]}); while (right > left && nums[right] == nums[right - 1 ]) right--; while (right > left && nums[left] == nums[left + 1 ]) left++; right--; left++; } } } } return result; } };
8.赎金信
解析
杂志字符串中的每个字符只能在赎金信字符串中使用一次,且我们假设两个字符串只有小写字符
这道题使用暴力解法(双层for循环)也是非常容易想到的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution {public : bool canConstruct (string ransomNote, string magazine) { for (int i = 0 ; i < magazine.length (); i++) { for (int j = 0 ; j < ransomNote.length (); j++) { if (magazine[i] == ransomNote[j]) { ransomNote.erase (ransomNote.begin () + j); break ; } } } if (ransomNote.length () == 0 ) { return true ; } return false ; } };
当然也可以使用哈希表,采取空间换取时间的策略,用长度为26的数组记录magazine中字母出现的次数,接着使用ransomNote验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 class Solution {public : bool canConstruct (string ransomNote, string magazine) { int record[26 ] = {0 }; if (ransomNote.size () > magazine.size ()) { return false ; } for (int i = 0 ; i < magazine.length (); i++) { record[magazine[i]-'a' ] ++; } for (int j = 0 ; j < ransomNote.length (); j++) { record[ransomNote[j]-'a' ]--; if (record[ransomNote[j]-'a' ] < 0 ) { return false ; } } return true ; } };
9.总结
首先我要说一下,哈希表在整个数据结构课程中其实是没有单独作为一个基本的数据结构介绍的,因为它实际上是作为一种“查找思想”,使得我们不需要挨个比较值是否相等来查找某个数值;
在我们解题的过程中只需要记住可以使用数组、set以及map这三种基本数据结构都可以实现哈希法即可,不要手动去写一个哈希表的数据结构(这当然是可行的,这就需要我们选择散列函数、处理冲突…本末倒置了属于是);
我们知道有如下常见的三种哈希结构:
数组是最简单的哈希表,但是数组的大小是受限的,当题干明显给出暗示如小写字母(仅有26个)则直接选择数组,注意可以选择数组和map的时候一定要选择数组,因为map的消耗在实际数据量很大的时候是远远超过数组的;
当题干没有限制数组大小的时候就不能使用数组作为哈希表,此时就可以选择set(set还可以避免数据重复);
选择数组和set作为哈希表有如下限制:
数组的大小是受限制的,而且如果元素很少,但哈希值太大会造成内存空间的浪费;
set只能存储单个值,当面临需要存储某个元素的两个值的时候set是不能满足的;
四、字符串 在字符串相关的题目中,因为C/C++对字符串的特性,所以有很多针对字符串的库函数,注意选择的原则就是“库函数仅仅是解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数”;(如果不太明白这个库函数就最好不要使用,否则分析时间复杂度的时候真的会一脸懵)
1.反转字符串1
解析
这个题实际上大一的时候做过一次,用的是最简单的for循环以及一个额外的result数组,however,此处要求必须原地修改数组,因此我们选择和反转链表
类似的双指针方法;
1 2 3 4 5 6 7 8 class Solution {public : void reverseString (vector<char >& s) { for (int i = 0 , j = s.size () - 1 ; i < s.size ()/2 ; i++, j--) { swap (s[i],s[j]); } } };
2.反转字符串2
解析
与简单的字符串反转不同,这里是花式反转;
这道题乍一看需要分三种情况,实际只需要分两种情况:
剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符
剩余字符少于 k 个,则将剩余字符全部反转
还有一点需要注意的是,不要一来就搞个计数器计数2k以及前k个字符,这道题关键点在于“以固定规律分段处理字符串”
,这也就意味着我们可以在for循环上考虑优化,每次for循环都在2k的起点即可;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution {public : string reverseStr (string s, int k) { for (int i = 0 ; i < s.size (); i += (2 * k)) { if (i + k <= s.size ()) { reverse (s.begin () + i, s.begin () + i + k ); } else { reverse (s.begin () + i, s.end ()); } } return s; } };
3.替换空格
解析
这道题最容易想到的思路就是for循环,比较每个字符,如果是空格则替换,乍一看时间复杂度为O(n)可以接受;
However,注意题干是将空格字符替换为”%20”字符串,这意味着每次替换空格都需要将其后面的字符向后移动两个字符,如果只是使用简单的替换方法会导致非常大的时间开销;
假如需要将这道题的时间缩短到极致,就需要利用双指针;
这里我们先给出一个解题原则 —— 大多数数组填充的问题,都可以预先给数组扩容为填充后的大小,然后再从后向前填充以减少原有字符的移动次数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class Solution {public : string replaceSpace (string s) { int count = 0 ; int sOldSize = s.size (); for (int i = 0 ; i < s.size (); i++) { if (s[i] == ' ' ) { count++; } } s.resize (s.size () + count * 2 ); int sNewSize = s.size (); 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.反转字符串中的单词
解析
这题当然可以直接以空格或字符串尾部分隔每个单词(注意这里默认符号属于前一个单词,但是如果是”hello,world”这种字符串就会被认为是同一个单词…),然后使用新的string字符串或char数组将分隔的单词倒序相加;
假如我们增加限制,要求空间复杂度为O(1),也就是只能在原有字符串进行反转;
解题主要步骤如下:
移除多余空格;
将整个字符串反转;
分别将每个单词反转;
在移除空格的过程中,如果我们使用简单的earse会导致时间复杂度实际是O(n^2^)(因为数组元素是不能被删除的,earse的底层原理实际仍然是覆盖元素),所以采用双指针的方法来移除空格最后resize整个字符串的大小就可以达到O(n)的复杂度;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void removeExtraSpaces (string& s) { int slowIndex = 0 , fastIndex = 0 ; while (s.size () > 0 && fastIndex < s.size () && s[fastIndex] == ' ' ) { fastIndex++; } for (; fastIndex < s.size (); fastIndex++) { if (fastIndex - 1 > 0 && s[fastIndex - 1 ] == s[fastIndex] && s[fastIndex] == ' ' ) { continue ; } else { s[slowIndex++] = s[fastIndex]; } } if (slowIndex - 1 > 0 && s[slowIndex - 1 ] == ' ' ) { s.resize (slowIndex - 1 ); } else { s.resize (slowIndex); } }
1 2 3 4 5 6 7 void reverse (string& s, int start, int end) { for (int i = start, j = end; i < j; i++, j--) { swap (s[i], s[j]); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 string reverseWords (string s) { removeExtraSpaces (s); reverse (s, 0 , s.size () - 1 ); int start = 0 ; for (int i = 0 ; i <= s.size (); ++i) { if (i == s.size () || s[i] == ' ' ) { reverse (s, start, i - 1 ); start = i + 1 ; } } return s; }
5.左旋转字符串
解析
这道题究竟是否涉及数组的扩容?肯定最简单的方式就是截取前n个字符然后移动后面的所有字符,再在末尾加上n个字符,但是这种方法需要的时间成本太高了;
这里我们加限制,不能使用额外的空间,只能在本串上操作;
前面反转字符串中的单词说过,使用整体反转+局部反转可以实现反转单词顺序的目的(这里我们将字符串看作整体,将需要旋转的n个字符看作局部),步骤为:
反转区间为前n的子串
反转区间为n到末尾的子串
反转整个字符串
举例来说,abcdefg,先反转ab得到bacdefg,再反转cdefg得到bagfedc,最后反转整个字符串得到cdefgab
1 2 3 4 5 6 7 8 9 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.实现strStr()
这道题是KMP的经典题目,所以需要我们掌握KMP算法;
KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配;
strstr(str1,str2) 函数用于判断字符串str2是否是str1的子串
如果是,则该函数返回 str1字符串从 str2第一次出现的位置开始到 str1结尾的字符串;
否则,返回NULL;
具体实现参考代码随想录 (programmercarl.com)
7.重复子串
解析
暴力解法就是一个for循环获取子串的终止位置(之所以只使用一个for循环而不是两个for循环是因为只需要判断以第一个字母开始的子串
即可(第一个不匹配那么就不可能重复组成主串),同时根本不需要for循环结束,因为子串结束的位置大于主串中间位置则一定不能重复组成字符串),然后判断子串是否能重复构成字符串,这将嵌套一个for循环,一共是O(n^2^)的时间复杂度;
我们这里主要讲解移动匹配解法;
一个主串如果是由重复子串组成,则将两个主串拼接在一起中间一定还会出现一个主串,比如”abcabcabc”拼接在一起得到”abcabcabcabcabcabc”我们能在中间找到新出现的主串;
因此判断字符串s是否有重复子串组成,只要两个s拼接在一起,里面还出现一个s的话,就说明是由重复子串组成。当然,我们在判断 s + s 拼接的字符串里是否出现一个s的的时候,要刨除 s + s 的首字符和尾字符,这样避免在s+s中搜索出原来的s,我们要搜索的是中间拼接出来的s;
1 2 3 4 5 6 7 8 9 class Solution {public : bool repeatedSubstringPattern (string s) { string t = s + s; t.erase (t.begin ()); t.erase (t.end () - 1 ); if (t.find (s) != std::string::npos) return true ; return false ; } };
这里我们衍生一下,前面说到的KMP也可以用于这道题,KMP主要用于在一个串中查找是否出现过另一个串,这里我们主要使用的是KMP算法概念中的前缀表,并不涉及KMP算法查找;
在由重复子串构成的字符串中,最长相等前后缀不包含的子串就是组成字符串的最小重复子串,比如下面的例子,ab就是最小重复单位
假设某字符串s(其长度也是s)是由n个最小重复子串x(其长度也是x)构成,也就是s=n * x;字符串s的最长相同前后缀的长度为(n-1) * x;
我们可以得出如下结论:前缀表的长度(实际就是字符串s的长度)减去最长相同前后缀的长度等于最小重复单位的长度,如果这个长度可以被整除代表该字符串由最小重复单位重复n次组成;
这里举个例子,字符串”asdfasdfasdf”长度为12,其最长相同前后缀为”asdfasdf”长度为8,因此最小重复单位”asdf”的长度为4,4可以被12整除说明字符串由最小重复单位组成;
这个解题方法很新颖,将字符串匹配的问题转换为数学问题,主要利用了KMP算法的前后缀概念;
8.总结 字符串类型的题目往往想法简单但是实现并不容易,其中双指针是常用策略,而KMP算法是字符串查找的最重要的算法;
前面使用过很多次的双指针算法,双指针并不隶属于某种特定的数据结构(当然也并不是严格意义上的指针,可以是数组下标),双指针主要有以下的作用:
通过两个指针在一个for循环下完成两个for循环的工作;
双指针主要有快慢指针、前后指针;
五、栈和队列 0.概述 队列是先进先出,栈是先进后出;
栈和队列是STL中的数据结构,而STL有多个版本,我们这里介绍和使用的STL都是SGI STL中的数据结构;
栈
栈提供push和pop等接口,所有元素必须符合先进后出规则,所以栈不提供走访功能,也不提供迭代器(iterator),不像是set或者map提供迭代器iterator来遍历所有元素;
栈是以底层容器完成其所有的工作,对外提供统一的接口,底层容器是可插拔的(也就是说我们可以控制使用哪种容器来实现栈的功能),这也意味着STL中栈常常并不被归类为容器,而是被归类为容器适配器;
STL中栈的底层实现可以是vector、list或deque,主要是链表和数组;
队列
队列中先进先出的数据结构,同样不允许有遍历行为,不提供迭代器,与栈相同,SGI STL中队列一样是以deque为缺省情况下的底部结构;
STL中的结构同样不被归类为容器,而被认为是容器适配器;
1.栈实现队列
解析
实际这道题的意思就是利用STL提供的栈来实现一个队列的数据结构和相关操作(模拟队列的行为),只用一个栈是无法实现的,需要一个输入栈,一个数输出栈:
模拟队列的push只需要将数据放入输入栈即可;
模拟队列的pop需要进行判断:
输出栈为空则将输入栈中的元素全部导入,再将栈中的数据全部弹出;
输出栈不为空则直接将栈中的元素弹出;
如果输入栈和输出栈都是空则说明模拟的队列空;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 class MyQueue {public : stack<int > stIn; stack<int > stOut; MyQueue () { } void push (int x) { stIn.push (x); } int pop () { if (stOut.empty ()) { while (!stIn.empty ()) { stOut.push (stIn.top ()); stIn.pop (); } } int result = stOut.top (); stOut.pop (); return result; } int peek () { int res = this ->pop (); stOut.push (res); return res; } bool empty () { return stIn.empty () && stOut.empty (); } };
2.队列实现栈
解析
依旧需要使用两个队列来模拟栈,但注意这两个队列的关系并不是输入和输出,另一个队列用于备份;
两个队列que1和que2实现队列的功能,que2其实就是一个备份的作用:
模拟实现pop操作,把除que1最后的元素都备份到que2,然后弹出最后的元素,再把其他元素从que2导回que1(如此往复,尽管很复杂);
模拟实现push操作,直接将元素存入que1即可;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 class MyStack {public : queue<int > que1; queue<int > que2; MyStack () { } void push (int x) { que1.push (x); } int pop () { int size = que1.size (); size--; while (size--) { que2.push (que1.front ()); que1.pop (); } int result = que1.front (); que1.pop (); que1 = que2; while (!que2.empty ()) { que2.pop (); } return result; } int top () { return que1.back (); } bool empty () { return que1.empty (); } };
当然这个题使用一个队列也可以实现
一个队列在模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时在去弹出元素就是栈的顺序;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class MyStack {public : queue<int > que; MyStack () { } void push (int x) { que.push (x); } int pop () { int size = que.size (); size--; while (size--) { que.push (que.front ()); que.pop (); } int result = que.front (); que.pop (); return result; } int top () { return que.back (); } bool empty () { return que.empty (); } };
3.有效括号
解析
括号匹配是使用栈解决的经典问题
,实际上编译器在词法分析的过程中处理括号、花括号这些符号逻辑的时候使用的也是栈数据结构;
由于栈的特殊性质,非常适合做对称匹配的题目,括号不匹配
有如下三种情况:
字符串左边括号多余 —— 对应指针遍历完字符串但栈不为空;
括号不多于但括号类型不匹配 —— 对应指针遍历字符串的过程中指针和栈的字符不匹配;
字符串右边括号多余 —— 对应指针遍历字符串的过程中栈已经空;
匹配成功的情况是指针遍历完字符串且栈为空;
使用栈解决括号匹配问题(小技巧:“左括号入栈改为右括号入栈,则只需要比较当前栈顶符号是否和输入符号一致即可”)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution {public : bool isValid (string s) { if (s.size () % 2 != 0 ) return false ; 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 (']' ); else if (st.empty () || st.top () != s[i]) return false ; else st.pop (); } return st.empty (); } };
4.删除字符串相邻重复项
解析
实际解读这道题很容易想到这也需要使用栈来解决,上一题使用栈解决左右括号匹配,本题使用栈匹配相邻元素
使用栈解决相邻字符匹配(最终只需要将栈中的字符弹出并做一个反转即可)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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; } };
5.逆波兰表达式求值
解析
逆波兰表达式相当于二叉树中的后序遍历,当然解题完全没必要从二叉树的角度进行;
逆波兰表达式的每一个子表达式要得出一个结果并用该结果做运算,本质上就是相邻字符串消除过程:指针遍历字符串的时候,遇到数字则入栈,遇到算符则取出栈顶两个数字并将计算结果压入栈中;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Solution {public : int evalRPN (vector<string>& tokens) { 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; } };
6.滑动窗口最大值
解析
本题是使用单调队列的经典题目(单调队列是指队列中元素之间的关系具有单调性,单调队列的典型题目是滑动窗口,我们下面会详细介绍什么是单调队列,注意和优先级队列区别)
本题的核心是求解每个区间中的最大值,最简单的暴力法就是遍历的过程中每次都从窗口中找到最大的数值,时间复杂度为O(n*k);
我们需要这样一个队列:该队列中的元素就是窗口中的元素,随着窗口的移动,队列元素也随之进出,每次移动后队列会返回其中的最大值;
单调队列的定义
:
不维护窗口中的所有元素,只维护可能成为窗口中最大值的元素即可;
保证单调队列中的元素数值是从大到小的;
单调队列配合窗口移动:
pop(value):如果窗口滑动导致将要移除的元素value等于单调队列的出口元素,那么队列pop队首元素,否则不用任何操作;
push(value):如果push的元素value大于入口元素的数值,那么就将队首元素pop,直到push元素的数值小于等于队列入口元素的数值,再进行push进队尾;
单调队列配合窗口进行滑动:
第一个窗口(1,3,-1):
空队列,push 1;
因为3>1,故pop 1再push 3;
因为-1<3,故直接push -1;
第二个窗口(3,-1,-3):
因为窗口滑动将要移除1,于是不需要做任何操作;
因为-3<3,故直接push -3;
第三个窗口(-3,5,3);
因为窗口滑动将要移除3,故pop3并滑动窗口,此时队首元素为-1;
因为5>-1,故pop -1;
因为5>-3,故pop -1并push 5;
…
使用deque作为底层容器实现单调队列
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class MyQueue { public : deque<int > que; void pop (int value) { if (!que.empty () && value == que.front ()) { que.pop_front (); } } void push (int value) { while (!que.empty () && value > que.back ()) { que.pop_back (); } que.push_back (value); } int front () { return que.front (); } };
使用单调队列实现滑动窗口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 class Solution {private : class MyQueue { public : deque<int > que; void pop (int value) { if (!que.empty () && value == que.front ()) { que.pop_front (); } } void push (int value) { while (!que.empty () && value > que.back ()) { que.pop_back (); } que.push_back (value); } 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++) { que.push (nums[i]); } result.push_back (que.front ()); for (int i = k; i < nums.size (); i++) { que.pop (nums[i - k]); que.push (nums[i]); result.push_back (que.front ()); } return result; } };
注意:
单调队列并不是一成不变的,针对不同的应用其pop和push接口也不同,唯一不变的就是要保证队列中单调递减或递增的原则
7.前k个高频元素
解析
实际上本题由三个模块组成:
统计元素出现的频率 —— 使用map统计;
对频率进行排序 —— 使用优先级队列(本质上是一个堆,算法动画图解中直接认为堆就是在实现优先级队列时使用);
找出前k个高频元素;
之所以使用堆排序而不是快速排序,是因为快排需要将map转换为vector结构;
本题使用小顶堆,要统计前k个最大元素,小顶堆只需要每次将最小的元素弹出,最后小顶堆中积累的就是前k个最大的元素;
寻找前k个最大频率元素的流程如下
C++代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class Solution {public : 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; for (int i = 0 ; i < nums.size (); i++) { map[nums[i]]++; } priority_queue<pair<int , int >, vector<pair<int , int >>, mycomparison> pri_que; for (unordered_map<int , int >::iterator it = map.begin (); it != map.end (); it++) { pri_que.push (*it); if (pri_que.size () > k) { pri_que.pop (); } } vector<int > result (k) ; for (int i = k - 1 ; i >= 0 ; i--) { result[i] = pri_que.top ().first; pri_que.pop (); } return result; } };
六、二叉树 0.二叉树概述
满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。
满二叉树的深度为k,有2^k^-1个节点;
完全二叉树:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。
若完全二叉树最底层为第h层,则该层包含1~ 2^(h-1)^个节点;
二叉搜索树:又被称为二叉排序树,二叉搜索树是一个有数值的树,二叉搜索树是一个有序树
平衡二叉搜索树:又被称为AVL(Adelson-Velsky and Landis)树,它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉搜索树。
C++中的map、set等容器的底层实现都是平衡二叉搜索树
,实际上红黑树就是一种平衡二叉搜索树,红黑树和平衡二叉搜索树不是独立的,具体信息可以参考(4条消息) 红黑树和AVL树_TABE_的博客-CSDN博客_avl和红黑树区别
二叉树的遍历方式
二叉树主要有两种遍历方式:
深度优先:先往深层次遍历,遇到叶子节点再往回遍历
广度优先:一层一层的遍历
从深度优先和广度优先进一步拓展才有以下遍历方式:
深度优先遍历(这里的前中后实际上就是指中间节点的遍历顺序)
前序遍历(递归法,迭代法):中左右
中序遍历(递归法,迭代法):左中右
后序遍历(递归法,迭代法):左右中
广度优先遍历
1.二叉树的递归遍历 递归算法的三要素:
确定递归函数的参数和返回值:确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数,并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型;
确定终止条件:写完了递归算法,运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出;
确定单层递归的逻辑:确定每一层递归需要处理的信息,在这里也就会重复调用自己来实现递归的过程;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 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); } void traversal (TreeNode* cur, vector<int >& vec) { if (cur == NULL ) return ; traversal (cur->left, vec); vec.push_back (cur->val); traversal (cur->right, vec); } void traversal (TreeNode* cur, vector<int >& vec) { if (cur == NULL ) return ; traversal (cur->left, vec); traversal (cur->right, vec); vec.push_back (cur->val); } vector<int > preorderTraversal (TreeNode* root) { vector<int > result; traversal (root, result); return result; } };
2.二叉树的迭代遍历 递归的实现就是每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,之后递归返回的时候从栈顶弹出上一次递归的各项参数,这也是递归可以返回上一层位置的原因;
下面我们要将递归法修改为迭代法(即非递归的方式)来实现二叉树的遍历;
前序遍历
这里需要借助栈实现递归向迭代的转换(之后的层次遍历会使用队列来实现,这都是因为栈和队列不同的特点所决定的),前序遍历每次先处理的都是中间节点,因此入栈的顺序是根节点、右孩子、左孩子,这样出栈的时候就是中左右的顺序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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数组中),这就造成处理顺序和访问顺序是不一致的;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 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 (); st.pop (); result.push_back (cur->val); cur = cur->right; } } return result; } };
后序遍历
后序遍历的实现相对简单,只需要将前序遍历的中左右变为中右左,接着反转result数组就能得到左右中的遍历结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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; } };
统一迭代
实际上前中后遍历的迭代代码是可以统一为一样的风格的(了解即可),之所以之前不一样是因为没有解决遍历节点和处理节点不一致的情况,解决方法是将访问的节点放入栈中,将要处理的节点也放入栈中但需要做上标记;
标记的方法就是将要处理的节点放入栈后,紧跟着放入一个空指针作为标记;这样只有空节点弹出的时候,才将下一个节点放进结果集(也就是只有先出现空节点的时候,才对下一个节点进行处理)
迭代法中序遍历
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 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; } };
3.二叉树的层序遍历
解析
层序遍历指的是从左到右,从上到下遍历二叉树,需要借助一个辅助队列来实现,因为队列先进先出的特点符合从上到下、从左到右逐层遍历的逻辑;
实际上这种层序遍历的方式就是图论中的广度优先遍历;
下面这份代码作为二叉树层序遍历的基本模板,有必要好好理解(实际上使用递归也可以实现,并且不需要借助队列数据结构,但是递归的代码的确过于难以理解,故不推荐)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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; 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; } };
4.翻转二叉树
解析
这道题解题思想很简单,只需要将每个节点的左右孩子翻转即可,即可达到整体翻转的效果;
但是关键在于遍历顺序(这是所有二叉树问题的重中之重),不能使用中序遍历(这将导致某些节点的左右孩子被翻转两次);
1 2 3 4 5 6 7 8 9 10 11 12 13 class Solution {public : TreeNode* invertTree (TreeNode* root) { if (root == NULL ) return root; swap (root->left, root->right); invertTree (root->left); invertTree (root->right); return root; } };
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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; } };
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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; } };
5.对称二叉树
解析
判断二叉树是否对称,要比较的不是左右节点,而是根节点的左子树和右子树是否是相互翻转的,即比较的是两个树,因此在递归遍历的过程中要同时遍历两棵子树;
接着比较两个子树的内侧和外侧元素是否相等;
递归解法
本题的遍历顺序只能是后序遍历,即“左右中”,因为要通过递归函数的返回值来判断两个子树的内侧节点和外侧节点是否相等 —— 实际上一棵树的遍历顺序是“左右中”,另一棵树的遍历顺序是“右左中”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Solution {public : bool compare (TreeNode* left, TreeNode* right) { if (left == NULL && right != NULL ) return 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); } };
迭代解法
本题的迭代解法既可以使用队列也可以使用栈,大致的意思参考下面的动画
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class Solution {public : 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 ; } 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 ; } };
6.二叉树的最大深度
递归解法
这道题使用前序遍历可以求得深度,使用后序遍历求的是高度,深度和高度的区别是什么?
二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从0开始还是从1开始)
二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数或者节点数(取决于高度从0开始还是从1开始)
因此根节点的高度就是二叉树的最大深度,因此本题选用后序遍历求根节点的高度进而得到二叉树的最大深度(实际上使用前序遍历才是真正的求深度的逻辑,但此处因为根节点的高度就是二叉树的最大深度,所以可以直接后序遍历求根节点的高度);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class solution {public : int getdepth (treenode* node) { if (node == NULL ) return 0 ; int leftdepth = getdepth (node->left); int rightdepth = getdepth (node->right); int depth = 1 + max (leftdepth, rightdepth); return depth; } int maxdepth (treenode* root) { return getdepth (root); } };
迭代解法
迭代解法使用层序遍历是最适合的,因为二叉树的层数就是其最大深度,实际上这道题直接使用层序遍历模板即可求解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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; } };
7.n叉树的最大深度
在介绍了二叉树的最大深度之后,可以直接利用类似的思路
后续递归遍历
1 2 3 4 5 6 7 8 9 10 11 12 class solution {public : int maxdepth (node* root) { if (root == 0 ) return 0 ; int depth = 0 ; for (int i = 0 ; i < root->children.size (); i++) { depth = max (depth, maxdepth (root->children[i])); } return depth + 1 ; } };
层序迭代遍历
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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 (int j = 0 ; j < node->children.size (); j++) { if (node->children[j]) que.push (node->children[j]); } } } return depth; } };
8.二叉树的最小深度
后序递归遍历
需要注意的是最小深度很容易理解出错,最小深度指的是根节点到最近叶子节点的最短路径上的节点数量,因此不能将根节点本身算进去(叶子节点指的是左右孩子都为空的节点);
为了解决上述问题,需要利用以下逻辑:
左子树为空,右子树不为空,说明最小深度是 1 + 右子树的深度;
右子树为空,左子树不为空,最小深度是 1 + 左子树的深度;
如果左右子树都不为空,返回左右子树深度最小值 + 1 ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class Solution {public : int getDepth (TreeNode* node) { if (node == NULL ) return 0 ; int leftDepth = getDepth (node->left); int rightDepth = getDepth (node->right); if (node->left == NULL && node->right != NULL ) { return 1 + rightDepth; } if (node->left != NULL && node->right == NULL ) { return 1 + leftDepth; } int result = 1 + min (leftDepth, rightDepth); return result; } int minDepth (TreeNode* root) { return getDepth (root); } };
层序递归遍历
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class Solution {public : int minDepth (TreeNode* root) { 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); if (!node->left && !node->right) { return depth; } } } return depth; } };
9.完全二叉树的节点个数
解析
完全二叉树:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若完全二叉树最底层为第h层,则该层包含1~ 2^(h-1)^个节点;
额…我们看输入和输出的时候很容易产生这样一个疑问,我不是只需要获取root数组的元素个数就知道节点个数了吗,关键真的好像是这样???实际考察的是我们对完全二叉树性质的掌握(类中的某个函数是不能获取构造函数的参数的),当然不熟悉直接使用普通二叉树的遍历方法也可以做出来,我们下面会介绍这两种解法;
普通二叉树的后序递归解法:
只需要修改模板加入treeNum变量记录节点数量即可,时间复杂度为O(n),空间复杂度为O(logn)-递归栈会占用空间
1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Solution {private : int getNodesNum (TreeNode* cur) { if (cur == NULL ) return 0 ; int leftNum = getNodesNum (cur->left); int rightNum = getNodesNum (cur->right); int treeNum = leftNum + rightNum + 1 ; return treeNum; }public : int countNodes (TreeNode* root) { return getNodesNum (root); } };
普通二叉树的层序迭代解法:
同理,层序遍历模板不变,增加统计节点数量即可,时间复杂度和空间复杂度均为O(n);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution {public : int countNodes (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 (); result++; if (node->left) que.push (node->left); if (node->right) que.push (node->right); } } return result; } };
完全二叉树性质:完全二叉树分为两种情况,当它为满二叉树的时候直接使用2^深度-1得到节点数;当它最后一层的叶子节点不满时,可以分别递归左孩子和右孩子,递归到一定深度就可以发现左孩子或右孩子为满二叉树,同理使用计算公式计算;
关键在于如何判断左子树或者右子树是否是一个满二叉树呢?
在完全二叉树中,如果递归向左遍历的深度等于递归向右遍历的深度,那说明该完全二叉树是一棵满二叉树;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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 ; while (left) { left = left->left; leftDepth++; } while (right) { right = right->right; rightDepth++; } if (leftDepth == rightDepth) { return (2 << leftDepth) - 1 ; } int leftTreeNum = countNodes (root->left); int rightTreeNum = countNodes (root->right); int result = leftTreeNum + rightTreeNum + 1 ; return result; } };
10.平衡二叉树
解析
需要先强调一下之前的概念:
二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数 —— 求深度需要从上至下,使用前序遍历(有一种等价情况是,根节点的高度就是该二叉树的最大深度);
二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数 —— 求高度需要从下至上,使用后序遍历;
该定义来自Wiki,但实际上不同的地方有不同的规定,leecode上规定以节点为度,同时不同的地方对根节点的深度究竟是1还是0的定义也各不相同;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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); }
另外一个想法是能否像使用层序遍历求深度一样来求高度呢?这是不行的,也是求解高度和深度的区别之一;
在一定条件下迭代是可以转换成递归的,但是本题使用迭代的效率非常低而且代码体系也更加复杂,所以这里我们就不再赘述迭代法;
11.二叉树的所有路径
本题需要明确的两个点是需要使用前序遍历(方便父节点指向子节点)和回溯(方便回退并选择另一条路径),还需要注意的是回溯法就是递归,没有人会去用迭代实现回溯(回溯是复杂递归,拆分为迭代的复杂程度极其大)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 class Solution {private : void traversal (TreeNode* cur, vector<int >& path, vector<string>& result) { path.push_back (cur->val); 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; } };
七、回溯算法 0.回溯法概述 回溯是递归的副产品,只要有递归就会有回溯,所以我们所说的回溯函数就是递归函数;
回溯法的本质是穷举所有的可能选出我们想要的答案,所以回溯法尽管很难理解但实际上效率并不是很高;
但是有些问题还真的就只能使用暴力穷举能做,我们能够对回溯法做的优化最多就是加一些剪枝的处理,回溯法一般用于解决如下类型的问题:
组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等
结论:回溯法解决的问题都可以抽象为树形结构(N叉树),所有回溯法的问题都是如此没有例外(现在知道为什么先介绍树再介绍回溯了吧)
因为回溯法解决的问题抽象出来都是在集合中递归的查找子集,因此集合的大小构成了树的宽度,递归的深度构成了树的深度;
回溯法的三个步骤:
确定回溯函数的参数和返回值
回溯法的返回值一般是void;
回溯法的参数不像普通递归那么容易确定,一般是先写逻辑,需要什么参数写什么参数;
确定回溯函数的终止条件
回溯解决的问题是树形结构,一般而言树的终止条件就是搜索到叶子节点中,对回溯来说就是找到了满足条件的一条答案,将该答案保存并结束本层递归即可;
单层回溯搜索
现在可以解释为什么集合大小就是树的宽度了,因为集合大小等于节点的孩子的数量,回溯函数遍历过程的伪代码如下
1 2 3 4 5 for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking (路径,选择列表); 回溯,撤销处理结果 }
for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索到的叶子节点就是找到的其中一个结果;
整个回溯算法的模板算法如下
1 2 3 4 5 6 7 8 9 10 11 void backtracking (参数) { if (终止条件) { 存放结果; return ; } for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking (路径,选择列表); 回溯,撤销处理结果 } }
八、贪心算法 九、动态规划 0.动态规划概述 动态规划Dyanmic Programming,简称DP,针对具有很多重叠子问题的问题来说很适合使用动态规划;
动态规划中的每一个状态一定是由上一个状态推导出来的,区别于直接从局部选最优的贪心算法;
需要注意的是分治法和动态规划都要求原问题具有最优子结构(可以认为动态规划也是一种分治思想),都是将原问题分治接着合并子问题的解形成原问题的解,分治法通常使用递归求解,动态规划可以使用自底向上的迭代或自顶向下的递归,不同点在于分治法将子问题看作独立的而动态规划将子问题看作相互依赖的、有重叠部分的;(分治法是万精油,动态规划可以求解全局最优并避免重复的计算,但是在使用上有一定限制(无后效性))