昨天结束了《代码随想录》一刷(只看不上机操作,目的是对算法建立初步的认识),历时一个多月。在第一遍阅读之后,有许多不理解的地方,在书中用问号标识。第二遍阅读本书目的就是想通过上机训练将之前的疑惑解开。另外,本书题解和内容仍有一小部分错误,希望在此更正。
本章主要介绍面试流程、如何写简历及代码规范。本书中的C++代码严格按照Google C++编程规范:
1.操作符左右一定有空格。
2.分隔符(,和;)的前一位没有空格,后一位有空格。
3.花括号和函数位于同一行,并且前面有一个空格。
4.控制语句(while、if、for)后都有一个空格。
本章主要介绍时间复杂度分析、程序运行时间、编程语言内存管理和空间复杂度分析。
本节介绍数组的基础知识。在写编程题的过程中,我们不但要会使用数组,更要了解数组的一些特性。
1. 数组下标都是从0开始。
2. 数组在内存空间的地址都是连续的。正因如此,所以在删除或添加元素时要移动其他元素的地址。
3. 如果使用C++进行编程,要注意vector和array的区别。(vector的底层实现其实就是array)整理如下:
vector是顺序容器,其利用连续的内存空间来存储元素,但是其内存空间大小是能够改变的。其能够很方便的管理内存空间并能动态增长内存空间,每次新申请的空间是原有空间大小(即申请后是申请前的2倍)。但是这个能力是有代价的,它耗费了更多的内存空间。与其他顺序容器(deques,lists,forward_lists)相比,vector能更快速地访问元素并且从end位置增加和删除元素相对更高效,但是在其他位置进行插入和删除操作比其他顺序容器在效率上差得多。
array是顺序容器,其也是利用连续的内存空间来存储元素,但它的内存空间是固定大小的,申请之后就无法改变。array不能存储其定义之外的数据,包括超出其大小尺寸的、非定义类型的数据。0尺寸(zero-sized)的Array是合法的,但是它不能被解引(dereference),即front/back/data操作。Swap操作是一个线性操作(只能交换两个属性相同的array:大小和类型),它分别交换两个数组中的所有元素,是一个比较低效的操作。值得一提的是,在交换之后,迭代器仍可以指向原来的容器(两个array,a和b,两个迭代器it1和it2,it1指向a,it2指向b,a和b进行swap操作;那么it1指向的还是a,但是内容是b的;it2指向的还是b,但是内容是a的)。array容器的独特特性是他们能被看做tuple对象,重载了get函数来像访问tuple一样访问array元素,并重载了独有的tuple_size和tuple_elelment。
相同点:都能用下标来访问元素。都是顺序容器,采用的是顺序的存储空间。
不同点:创建方式上不同。vector无需指定大小,只需指定类型,e.g. vector
。array需要同时指定类型和大小,e.g. array
;内存使用上不同。vector需要占据比array更多的内存,因为其内存空间大小是动态可变的。array内存是高效的,用多少就申请多少;效率上不同。vector效率偏低,因为当向vector中添加新元素的时候,内存空间不够,需要重新申请更大的空间,由于vector是连续内存空间的,因此其申请更多空间的时候,可能整个位置发生改变,需要将原来空间里的数据拷贝过去;下标类型不同。在用下标访问元素时,vector 使用 vector::size_type 作为下标的类型,而数组下标的正确类型则是 size_t;swap操作不同
vector是将引用进行交换,效率高,其迭代器指向原来的容器(原来的容器中的元素指向的却是另一个容器的值),但是end的引用并没有发生交换,因此在输出的时候注意别用end作为迭代终止条件。 array是进行值的交换,效率低,且迭代器仍指向原来的容器。
4. 二维数组在内存中是连续的。
力扣题号:704.二分查找
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
示例 1:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
示例 2:
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1
提示:
你可以假设 nums 中的所有元素是不重复的。
n 将在 [1, 10000]之间。
nums 的每个元素都将在 [-9999, 9999]之间。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/binary-search
思路
二分法使用前提条件:元素有序,无重复(否则返回的元素下标可能不是唯一的)。
二分法虽然逻辑简单,但是边界条件容易在紧张情况下写错。原因主要在于不清楚区间的定义。在本书中,区间的定义就是“不变量”。要在二分查找的过程中保持“不变量”——在while循环中,每一次边界的处理都要根据区间的定义来操作,这就是“循环不变量"规则。
二分法中区间的定义一般有两种,一种是左闭右闭即[left,right],另外一种是左闭右开即[left,right)。下面基于这两种区间的定义分别讲解两种不同的二分法写法。
定义target在[left,right]区间有两个特定:
1.left和right相等的情况在[left,right]区间是有意义的(右边界right所指元素是实际存在的,例如leftright最后一个元素),所以要在while循环条件中用left<=right。
2.如果num[middle]大于target,则更新搜索范围右下标right为middle-1.因为当前这个nums[middle]一定不是target,所以接下来要查找的是middle左边的区间,结束下标位置right是middle-1。
题解
class Solution{
public:
int search(vector<int>& nums,int target) {
int left = 0;
int right = nums.size()-1;//定义target在左闭右闭区间
while(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 {
return middle;//找到元素,直接返回下标
}
}
return -1;//未找到目标值
}
};
如果定义target在一个左闭右开的区间,也就是[left,right),那么二分法的处理边界方式截然不同,体现在如下两点:
1.while(left
题解
class Solution{
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size();//定义target在左闭右开
//因为left==right,[left,right)是无效的空间,所以使用<
while(left < right) {
int middle = left + ((right - left)>>1);//右移一位等效于/2
if (nums[middle] > target) {//在左区间
right = middle;
}
else if (nums[middle] < target) {//在右区间
left = middle + 1;
}
else {
return middle;
}
}
return -1;
}
};
力扣题号: 27.移除元素
给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并 原地 修改输入数组。
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
说明:
为什么返回数值是整数,但输出的答案是数组呢?
请注意,输入数组是以「引用」方式传递的,这意味着在函数里修改输入数组对于调用者是可见的。
你可以想象内部操作如下:
// nums 是以“引用”方式传递的。也就是说,不对实参作任何拷贝
int len = removeElement(nums, val);
// 在函数里修改输入数组对于调用者是可见的。
// 根据你的函数返回的长度, 它会打印出数组中 该长度范围内 的所有元素。
for (int i = 0; i < len; i++) {
print(nums[i]);
}
示例 1:
输入:nums = [3,2,2,3], val = 3
输出:2, nums = [2,2]
解释:函数应该返回新的长度 2, 并且 nums 中的前两个元素均为 2。你不需要考虑数组中超出新长度后面的元素。例如,函数返回的新长度为 2 ,而 nums = [2,2,3,3] 或 nums = [2,2,0,0],也会被视作正确答案。
示例 2:
输入:nums = [0,1,2,2,3,0,4,2], val = 2
输出:5, nums = [0,1,4,0,3]
解释:函数应该返回新的长度 5, 并且 nums 中的前五个元素为 0, 1, 3, 0, 4。注意这五个元素可为任意顺序。你不需要考虑数组中超出新长度后面的元素。
提示:
0 <= nums.length <= 100
0 <= nums[i] <= 50
0 <= val <= 100
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/remove-element
思路
在数组中元素在内存地址上是连续的,不能单独删除数组中的某个元素,只能覆盖。
使用两个for循环,第一个for循环遍历数组,第二个for循环更新数组元素(覆盖)
题解
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;
}
};
时间复杂度O(n2)
空间复杂度O(1)
双指针法(快慢指针法):通过一个快指针和 一个慢指针在一个for循环里完成两个for循环的工作。
题解
class Solution{
public:
int removeElement(vector<int>& nums,int val) {
int slowIndex = 0;
for(int fastIndex = 0; fastIndex < nums.size(); fastIndex++) {
if(nums[fastIndex] != val){
nums[slowIndex++] = nums[fastIndex];
//当fastIndex指针指向的元素不等于val时,两个指针都向后移动一位;
//当fastIndex指针指向的元素等于val时(slowIndex也指向第一个val元素)fastIndex向后移动一位,
//直到fastIndex指向的元素不等于val,此时将fastIndex指向的元素覆盖slowIndex指向的元素
}
}
return slowIndex;//循环结束后slowIndex指向的是最后一个不为val的元素即新size
}
};
时间复杂度O(n)
空间复杂度O(1)
双指针法(快慢指针)在数组和链表的操作中是很常见的,很多考查数组和链表操作的面试题都可以使用双指针法解决。
力扣题号: 209.长度最小的子数组。
给定一个含有 n 个正整数的数组和一个正整数 target 。
找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, …, numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
示例 1:
输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。
示例 2:
输入:target = 4, nums = [1,4,4]
输出:1
示例 3:
输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0
提示:
1 <= target <= 109
1 <= nums.length <= 105
1 <= nums[i] <= 105
进阶:
如果你已经实现 O(n) 时间复杂度的解法, 请尝试设计一个 O(n log(n)) 时间复杂度的解法。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/minimum-size-subarray-sum
这道题的暴力解法是使用两个for循环,不断寻找符合条件的子数组,时间复杂度为O(n2)
题解
class Solution{
public:
int minSubArrayLen(int target, vector<int>& nums) {
int subLength = 0;//子数组长度
int result = INT32_MAX;//最终的结果
int sum = 0; //子数组元素和
for(int i = 0; i < nums.size(); i++){
sum = 0;
for(int j = i; j < nums.size(); j++) {
sum += nums[j];
if(sum >= target){
subLength = j - i + 1;//获取子数组长度
result = result < subLength ? result : subLength;//更新最小的子数组长度
break; //起始位置为i,终止位置为j的子数组已经满足条件,不必再j++,应退出当前循环
}
}
}
return result == INT32_MAX ? 0 : result; //如果result仍未INT32_MAX,则返回0
}
};
时间复杂度O(n2)
空间复杂度O(1)
所谓滑动窗口就是不断调整子数组的起始位置和终止位置。它也可以理解成一种双指针法的一种,只不过这种方法更像是一种窗口的移动。在本题中使用滑动窗口要确定以下三点:
1.窗口内的元素是什么?保持窗口内元素总和大于或等于target的最小连续子数组。
2.如何移动窗口的起始位置?如果当前窗口的值大于target,则窗口向前移动(也就是窗口该缩小了)。
3.如何移动窗口的终止位置?就是for循环遍历数组的指针。
解题的关键就是如何移动窗口的起始位置。
题解
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int result = INT32_MAX;
int sum = 0;
int i = 0;//滑动窗口起始位置
int subLength;//滑动窗口长度
for(int j = 0; j < nums.size(); j++) {
sum += nums[j];
while(sum >= target){
subLength = j - i + 1;
result = result < subLength ? result : subLength;//取最小的长度
sum -= nums[i++];//不断变更i(子数组的起始位置)
}
}
return result == INT_MAX ? 0 : result;
}
};
时间复杂度O(n)
空间复杂度O(1)
可以发现滑动窗口的精妙之处在于根据当前子数组和的大小,不断调节子数组的起始位置(大于等于就向前移动一位,否则就不变)
力扣题号: 59.螺旋矩阵Ⅱ
给你一个正整数 n ,生成一个包含 1 到 n2 所有元素,且元素按顺时针顺序螺旋排列的 n x n 正方形矩阵 matrix 。
示例 1:
输入:n = 3
输出:[[1,2,3],[8,9,4],[7,6,5]]
示例 2:
输入:n = 1
输出:[[1]]
提示:
1 <= n <= 20
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/spiral-matrix-ii
思路
这道题在面试中出现的频率较高,虽然本题不涉及算法,就是模拟螺旋顺序打印的过程,但却十分考查面试者对代码的掌控能力。
我们模拟顺时针画矩阵的过程:
1.从左到右填充上行。
2.从上到下填充右列。
3.从右到左填充下行。
4.从下到上填充左列。
我们发现,如果从外向内一圈一圈画下去,会有很多边界条件,如果不按一定的规则去遍历则很难写出完全正确的代码。之前我们在二分法时提到了如果要正确书写代码,一定要坚持循环不变量原则,本题同理 。
矩阵的四条边要坚持一致的左闭右闭或左闭右开的原则。这里我们用左闭右开。
class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> res(n, vector<int>(n, 0));//初始化二维矩阵全部元素为0;
int startx = 0,starty = 0;//定义每循环一个圈的起始位置
int loop = n / 2;//循环几个圈
int mid = n / 2;//矩阵中间位置,如果n=3则中间位置为(1,1)
int count = 1;//用来给矩阵的元素赋值
int offset = 1;//每一圈循环都需要控制每个边遍历的长度
int i, j;//遍历时的元素下标
while (loop --) {
i = startx;
j = starty;
//下面 用四个for循环来模拟一圈
//模拟从左到右填充上行
for (j = starty; j < starty + n - offset; j++) {
res[startx][j] = count++;
}
//模拟从上到下填充右列
for (i = startx; i < startx + 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 += 2;
}
//如果n为奇数,则需要另外对最中间元素赋值
if(n % 2 == 1) res[mid][mid] = count;
return res;
}
};
可以看出在循环中判断逻辑很多,如果不坚持循环不变量原则即左闭右开,很容易出错。
从二分法到双指针法,从滑动窗口到螺旋矩阵,这些都是非常重要的算法思想。本章并没有给出太多纯数组的题目,因为数组是基本的数据结构,在讲解其他算法时会变相的运用到数组,在讲解后续章节的算法题目时也会涉及到数组。
链表是一种通过指针串联在一起的线性结构,每一个节点有两部分组成,一个是数据域,另一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向NULL(空指针)。
链表的入口成为链表的头节点也就是head。
链表主要有三种类型:
1.单链表
2.双链表
单链表中的节点只能指向节点的下一个节点,而双链表中的每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点。双链表既可以向前查询,也可以向后查询。
3.循环链表
顾名思义,循环链表就是首尾相连的链表。循环链表可以用来解决约瑟夫环问题。 0,1,···,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字(删除后从下一个数字开始计数)。求出这个圆圈里剩下的最后一个数字。
例如,0、1、2、3、4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。
数组在内存中是连续分布的,但链表在内存中不是连续分布的,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。链表是通过指针域的指针来链接内存中的各个节点的。
很多人在面试的时候都写不好链表的定义,这是因为在平时OJ上做题时,链表的节点都被定义好了可以直接使用。而在面试的时候可能需要手写链表,这就容易出错。
链表节点的C++定义方式:
struct ListNode {
int val;//节点上存储的元素
ListNode *next;//指向下一节点的指针
ListNode(int x) : val(x), next(nullptr) {}//节点的构造函数
}
结构体中有一个构造函数。虽然不写这个构造函数也会生成一个default构造函数,但default构造函数不会初始化任何成员变量。举两个例子:
通过自定义构造函数初始化节点:
ListNode* head = new ListNode(5);
通过default构造函数初始化节点:
ListNode* head = new ListNode();
head->val =5;//如果不定义自己的构造函数而是使用default构造函数,那么在初始化时就不能直接给变量赋值(default构造函数无参数)
1. 删除操作
假设有链表a->b->c->d->e,要想删除d节点,只需将c节点的next指针指向e节点即可。此时节点d仍在内存中,在C++中,最好手动释放掉这个d节点和这块内存。其他语言(Java、Python)有自己的内存回收机制,不用手动释放。
2. 添加节点
假设有链表a->b->c->e,要想在c和e节点之间添加d节点,则应让c节点的next指针指向d节点,再让d节点的next指针指向e节点。
可以看出节点的删除和添加操作(单单这个操作)都是时间复杂度为O(1)的操作,不会影响其他节点。但需要注意的是,删除或者添加某个节点的前提是找到操作节点的前一个节点,而查找前一个节点的时间复杂度为O(n)。
插入/删除(时间复杂度) | 查询(时间复杂度) | 使用场景 | |
---|---|---|---|
数组 | O(n) | O(1) | 数据量固定,频繁查询,较少增删 |
链表 | O(1) | O(n) | 数据量不固定,频繁增删,较少查询 |
在定义数组时,长度是固定的,要想改动数组的长度,则需要重新定义一个数组。而链表的长度是不固定的,并且可以动态增删。 |
力扣题号: 203.移除链表元素
给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回 新的头节点 。
示例 1:
输入:head = [1,2,6,3,4,5,6], val = 6
输出:[1,2,3,4,5]
示例 2:
输入:head = [], val = 1
输出:[]
示例 3:
输入:head = [7,7,7,7], val = 7
输出:[]
提示:
列表中的节点数目在范围 [0, 104] 内
1 <= Node.val <= 50
0 <= val <= 50
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/remove-linked-list-elements
思路
使用C++、C要记得手动清理内存。在oj上不手动清理内存也是可以的,只不过内存使用的空间大一些而已。
删除节点的关键是找到被删除节点的前一个结点。但如果删除的是头节点呢?则有两种方式操作:
1. 直接使用原来的链表执行删除操作(需要对头节点进行判断,之后进行特殊处理):其实只需将头节点向后移动一位就可以了,这样就从链表中删除了一个头节点。实现代码如下:
题解
class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
//删除头节点
while (head != NULL && head->val == val) {//特别注意,这里是while循环而不是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;//或cur->next = tmp->next;
delete tmp;
}
else {
cur = cur->next;
}
}
return head;
}
};
2. 设置一个虚拟头节点再执行删除操作(所有节点的删除操作都被统一)
这里给链表添加一个虚拟头节点并将其设为新的头节点,此时删除旧的头节点就和删除链表的其他节点的方式统一了。最后返回头节点的时候,别忘了dummyNode->next才是真正的头节点。 ** 还需要特别注意的是,最后返回前head = dummyHead->next;这条语句不能没有,因为可能会出现所有节点都被删除,此时之前的head节点已经被我们手动释放掉了。**
题解
/**
* 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* 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;//注意这条语句不能没有,因为可能会出现所有节点都被删除这一情况,此时head已被我们手动清除
delete dummyHead;//手动清理内存
return head;
}
};
力扣题号: 707.设计链表。
设计链表的实现。您可以选择使用单链表或双链表。单链表中的节点应该具有两个属性:val 和 next。val 是当前节点的值,next 是指向下一个节点的指针/引用。如果要使用双向链表,则还需要一个属性 prev 以指示链表中的上一个节点。假设链表中的所有节点都是 0-index 的。
在链表类中实现这些功能:
get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。
addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。
addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。
deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。
printLinkedList() : 打印当前链表
示例:
MyLinkedList linkedList = new MyLinkedList();
linkedList.addAtHead(1);
linkedList.addAtTail(3);
linkedList.addAtIndex(1,2); //链表变为1-> 2-> 3
linkedList.get(1); //返回2
linkedList.deleteAtIndex(1); //现在链表是1-> 3
linkedList.get(1); //返回3
提示:
所有val值都在 [1, 1000] 之内。
操作次数将在 [1, 1000] 之内。
请不要使用内置的 LinkedList 库。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/design-linked-list
思路
这是练习链表操作非常好的一道题目,这六个接口覆盖了链表常见操作。
下面采用设置虚拟头节点的方法实现这六个接口。
题解
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
int get(int index) {//真正的头节点index为0
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指向最后一个节点
cur->next = newNode;
_size++;
}
//在第index个节点之前插入一个新节点
//如果index为0 ,那么新插入的节点就是头节点
//如果index为链表长度,则是要在链表尾部插入节点
void addAtIndex(int index, int val) {
if (index > _size || index < 0) {
return ;
}
LinkedNode* newNode = new LinkedNode(val);
LinkedNode* cur = _dummyHead;
while (index--) {
cur = cur->next;
}
//此时cur指向的是第index节点之前的那个节点
newNode->next = cur->next;
cur->next = newNode;
_size++;
}
//删除第index个节点,如果index节点大于或等于链表长度,则直接返回
void deleteAtIndex(int index) {
if (index >= _size || index < 0) {
return ;
}
LinkedNode* cur = _dummyHead;
while (index--){
cur = cur->next;
}
//此时cur指向第index个节点前一个节点
LinkedNode* tmp = cur->next;
cur->next = cur->next->next;//或=tmp->next
delete tmp;
_size--;
}
//打印链表
void printLinkedNode() {
LinkedNode* cur = _dummyHead;
while (cur->next != nullptr) {
cout << cur->next->val << " ";
cur = cur->next;
}
cout << endl;
}
private:
int _size;
LinkedNode* _dummyHead;
};
/**
* Your MyLinkedList object will be instantiated and called as such:
* MyLinkedList* obj = new MyLinkedList();
* int param_1 = obj->get(index);
* obj->addAtHead(val);
* obj->addAtTail(val);
* obj->addAtIndex(index,val);
* obj->deleteAtIndex(index);
*/
力扣题号:206.反转链表
给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
示例 1:
输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]
示例 2:
输入:head = [1,2]
输出:[2,1]
示例 3:
输入:head = []
输出:[]
提示:
链表中节点的数目范围是 [0, 5000]
-5000 <= Node.val <= 5000
进阶:链表可以选用迭代或递归方式完成反转。你能否用两种方法解决这道题?
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/reverse-linked-list
思路
不难想到定义一个新的链表实现原链表元素的反转,但是对内存空间来说造成了浪费。其实最优的解法是只改变链表next指针的指向,直接将链表反转。
首先定义一个cur指针,指向头节点,再定义一个pre节点,初始化为NULL,最后定义一个tmp指针指向cur->next(保存这个节点,否则经反转后cur->next就指向了pre)。循环执行上述逻辑,继续移动cur和pre指针。最后cur指针指向了NULL,循环结束,链表也反转结束。此时只需返回pre指针即可。
题解
/**
* 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* cur = head;
ListNode* pre = nullptr;
ListNode* tmp;
while (cur) {
tmp = cur->next;
cur->next = pre;
pre = cur;
cur = tmp;
}
return pre;
}
};
递归法相对抽象一些,但其实和双指针法的逻辑是一样的,同样是cur为空时循环结束,不断将cur指向pre的过程。
关键是初始化的地方,再双指针法中cur=head、pre=NULL。在递归法中初始化逻辑是一样的,只不过写法变了。
题解
/**
* 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* reverse(ListNode* pre, ListNode* cur) {
if (cur == nullptr) 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 = nullptr;
return reverse(nullptr,head);
}
};
力扣题号: 19.删除链表倒数第n个节点。
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
示例 1:
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]
示例 2:
输入:head = [1], n = 1
输出:[]
示例 3:
输入:head = [1,2], n = 1
输出:[1]
提示:
链表中结点的数目为 sz
1 <= sz <= 30
0 <= Node.val <= 100
1 <= n <= sz
进阶:你能尝试使用一趟扫描实现吗?
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list
思路
本题又是双指针法的经典应用:求倒数第n个节点。初始化fast和slow指针指向虚拟头节点,我们先让fast指针先移动n个节点,然后让fast和slow指针同时移动(移动了l-n个节点,即倒数第n个节点),直到fast指向链表末尾(nullptr),删除slow指针所指的节点即可。
另外此题还有一些细节需要注意:
1.推荐使用虚拟头节点,因为我们要统一删除普通节和头节点的操作。
2.slow和fast指针初始化为虚拟头节点。
3.先让fast移动n+1步,因为只有这样,slow指针才能移动l-n-1步,指向被删除节点的前一个节点。
题解
/**
* 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* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);//创建虚拟头节点
dummyHead->next = head;
ListNode* slow = dummyHead;
ListNode* fast = dummyHead;
while (n-- && fast != nullptr) {//先让fast指针移动n步
fast = fast->next;
}
fast = fast->next;//再移动一步,方便slow指针指向被删除节点的前一个节点
while (fast != nullptr) {
fast = fast->next;
slow = slow->next;
}
ListNode* temp = slow->next;//记录被删除节点
slow->next = temp->next;//删除操作
delete temp;//释放内存
return dummyHead->next;
}
};
也可以采用让fast指针先移动n个节点,再slow和fast指针同时移动,直到fast->next==nullptr,此时slow指针也指向被删除节点的前一个结点。这种方法在OJ系统上竟然比上面的方法快一倍。为什么呢?
/**
* 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* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummyHead = new ListNode(0);//创建虚拟头节点
dummyHead->next = head;
ListNode* slow = dummyHead;
ListNode* fast = dummyHead;
while (n-- && fast != nullptr) {//先让fast指针移动n步
fast = fast->next;
}
//fast = fast->next;//再移动一步,方便slow指针指向被删除节点的前一个节点
while (fast->next != nullptr) {
fast = fast->next;
slow = slow->next;
}
ListNode* temp = slow->next;//记录被删除节点
slow->next = temp->next;//删除操作
delete temp;//释放内存
return dummyHead->next;
}
};
力扣题号: 142.环形链表Ⅱ
给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
不允许修改 链表。
示例 1:
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入:head = [1,2], pos = 0
输出:返回索引为 0 的链表节点
解释:链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入:head = [1], pos = -1
输出:返回 null
解释:链表中没有环。
提示:
链表中节点的数目范围在范围 [0, 104] 内
-105 <= Node.val <= 105
pos 的值为 -1 或者链表中的一个有效索引
进阶:你是否可以使用 O(1) 空间解决此题?
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/linked-list-cycle-ii
思路
这道题不仅考查对链表的操作,还需要一些数学运算,主要考查两个知识点:
1.判断链表是否有环。
2.如果有环,如何找到环的入口。
408考研真题曾经涉及到链表是否有环。可以使用快慢指针法,分别定义fast和slow指针,从头节点出发,快指针每次移动两个结点,慢指针每次移动一个节点。如果快慢指针在途中相遇,则说明这个链表有环。这很容易理解,我们知道快指针一定先比慢指针先进入环,而且快慢指针一定是在环中相遇。(快指针会不断在环里转圈等待慢指针进入环,然后相遇)换一种理解方式,因为快指针每次移动两个节点,慢指针每次移动一个节点,相对于慢指针而言,快指针是一个节点一个节点地靠近慢指针的,所以快慢指针一定可以相遇。
寻找环的入口需要一定的数学推导。假设从头节点到环的入口节点的节点数为x,环的入口结点到快慢节点相遇节点的节点数为y,从相遇节点再到环的入口节点的节点数为z,则环一圈的节点数为y+z。
当快慢指针相遇时,慢指针移动的节点数为x+y,快指针移动的节点数为x+y+n(y+z),n为快指针在环内转了n圈才与慢指针相遇。由此我们可以列出等式:(x+y)×2=x+y+n(y+z),化简得:x+y=n(y+z)。因为我们要寻找的是环的入口,所以要计算的是x。进一步变形得:x=n(y+z)-y,提取一个y+z出来:x=(n-1)(y+z)+z。这里的n一定是大于或等于1的,因为快指针要至少在环内移动一圈才能遇到慢指针。
先令n=1,这说明快指针在环内移动一整圈之后又移动了y个节点后与慢指针相遇。等式就变为x=z。这意味着,如果定义一个指针index2在头节点,定义另一个指针index1在相遇节点,这两个指针每次只移动一个节点,这两个指针相遇的节点就是环的入口节点。
如果n>1呢?其实判断方法是一样的。只不过index1指针会在环内多移动n-1圈,最后在环的入口与index2相遇。仔细揣摩上面的公式:x=(n-1)(y+z)+z。
题解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
ListNode* fast = head;
ListNode* slow = head;
while (fast != nullptr && fast->next != nullptr) {//假如无环,就会在链表结尾终止循环
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {//快慢指针相遇
ListNode* index1 = slow;//定义index1指向相遇节点
ListNode* index2 = head;//定义index2指向头节点
while (index1 != index2) {//两指针相遇结束时循环,相遇的节点就是环的入口
index1 = index1->next;
index2 = index2->next;
}
return index2;//返回环的入口
}
}
//程序运行到此说明没有环,返回nulltpr
return nullptr;
}
};
推理过程
上述推导可能会产生一个疑问:为什么两个指针第一次在环内相遇,slow指针移动的节点数是x+y,而不是x+n(y+z)+y?《代码随想录》这本书对此的讲解我感觉有点词不达意。我的理解是:已知快指针比慢指针每次多走一个节点,可以类比成两名同学在操场跑步,甲同学比乙同学速度快一倍,无论甲在哪出发,(乙同学在环入口出发)甲追上乙的时候,乙都跑不到一圈! Carl哥的意思我觉得是甲乙都在环入口出发,此时乙可以跑够一圈,否则就不能。
还有1个问题,为什么fast指针不会直接跳过去呢?因为fast指针相对于slow指针来说是一次移动一个节点,所以不可能跳过去。
链表和数组都是算法中基础的数据结构,需要非常熟练地掌握。4.1节讲解了数组和链表最基本的差异,以及应用的前景。4.2节讲解了虚拟头节点 ,这个技巧在链表中很实用,如果涉及到链表的增删操作,那么使用虚拟头节点会非常方便。4.3节详细介绍了链表常用的六个接口,一定要熟练掌握。4.4节反转链表和4.5节的删除链表倒数第n个节点,都是链表中高频的面试题目。4.6节环形链表考查了两个方面,一是判断链表有无环,而是求环的入口,这是双指针法在链表上的经典应用,有一定难度。
哈希表(Hash Table)也称散列表。它是可以根据关键码的值直接访问数据的数据结构。其实我们经常使用的数组就是一张哈希表,哈希表中的关键码就是数组的索引下标,通过下标访问数组的元素。
那么哈希表可以解决什么问题呢?哈希表一般用来快速判断一个元素是否出现在集合中。 例如:查询一个名字是否出现在学校的学生名单里。如果使用枚举法,时间复杂度为O(n),如果使用哈希表,则时间复杂度为O(1)。我们只需把所有学生的名字都保持在哈希表里,通过索引就可以查询出这名学生在不在这所学校里了。将学生的名字映射到哈希表上这就涉及到了Hash Function(哈希函数)。
在上例里面,通过hashCode把名字转换为数值。hashCode通过特定编码方式,可以将其他数值格式转化为不同的数值,这样就把学生的名字映射为哈希表上的索引数字了。但如果hashCode得到的数值大于哈希表的大小(tablesize),我们会对数值再进行一个取模操作,保证学生的名字一定可以映射到哈希表上。但如果我们学生的数量大于哈希表的大小,那该怎么办?就算哈希函数计算得再均匀,也避免不了有几名学生的名字会映射到哈希表上同一个索引位置。下面就要介绍哈希碰撞。
两名同学都映射到同一索引位置,这一现象叫做哈希碰撞。哈希碰撞一般有两种解决方法:拉链法和线性探测法。关于哈希碰撞还有非常多的细节,可以自行研究。
1.拉链法:小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。这样我们就可以通过索引找到小李和小王了。拉链法最重要的就是选择适当的哈希表大小,这样既不会因为数组空值而浪费内存,也会因为链表太长而在查找上浪费太多时间。
2.线性探测法:如果使用线性探测法,就要保证tableSize大于dataSize,我们需要依靠哈希表中的空位来解决碰撞问题。例如:小李存放在索引1的位置,小王经哈希函数计算索引也为1,那么就向下找一个空位放置小王的信息。
在使用哈希函数解决问题时,一般会选择如下三种数据结构:
1.数组
2.set(集合)
3.map(映射)
在C++中,set的底层实现及优劣如下表所示。
集合 | 底层实现 | 是否有序 | 数值是否可重复 | 是否可更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::set | 红黑树 | key有序 | 否 | 否 | O(logn) | O(logn) |
std::multiset | 红黑树 | key有序 | 是 | 否 | O(logn) | O(logn) |
sd::unordered_set | 哈希表 | key无序 | 否 | 否 | O(1) | O(1) |
红黑树是一种平衡二叉搜索树,所以key是有序的,但key不可以修改,改动key会导致整棵树的错乱,所以只能删除和增加。
map(映射)底层实现及优劣如下表所示。
映射 | 底层实现 | 是否有序 | 数值是否可重复 | 是否可更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::map | 红黑树 | key有序 | key不可重复 | key不可修改 | O(logn) | O(logn) |
std::multimap | 红黑树 | key有序 | key可重复 | key不可修改 | O(logn) | O(logn) |
std::unordered_map | 哈希表 | key无序 | key不可重复 | key不可修改 | O(1) | O(1) |
std::map和std::multimap的key也是有序的(这个问题也经常作为面试题,考查面试者对语言容器底层的理解)
当我们要使用集合来解决哈希问题时,优先使用unordered_set,因为它的查询和增删效率是最优的;如果要求集合是有序的,那么就使用set;如果要求集合不仅有序,还要求有重复数据,那么就使用multiset。
map是一个
当我们需要快速判断一个元素是否出现在集合中时,就要考虑使用哈希法,但哈希法牺牲了空间换取了时间,因为我们要使用额外的数值、set或map来存放数据。如果面试时遇到需要判断一个元素是否出现在集合的场景,则应该第一时间想到哈希法。
力扣题号: 242.有效的字母异位词
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
注意:若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词。
示例 1:
输入: s = “anagram”, t = “nagaram”
输出: true
示例 2:
输入: s = “rat”, t = “car”
输出: false
提示:
1 <= s.length, t.length <= 5 * 104
s 和 t 仅包含小写字母
进阶: 如果输入字符串包含 unicode 字符怎么办?你能否调整你的解法来应对这种情况?
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/valid-anagram
思路
暴力解法:使用暴力法的思路是将两字符串排序,如果排序后相等则互为字母异位词。排序时间复杂度O(nlogn),判断两个字符串相等O(n),总时间复杂度O(nlogn)。排序所需的空间复杂度为O(logn)(快排每一次都平分数组)。
哈希解法:这道题提到,所有字符均为小写字符,所以我们可以用一个数组来记录字符串s中字符出现的次数。首先我们需要定义长度为26的数组就够了,其次因为字符a到字符z的ASCII值是26个连续的数值,所以字符a映射到索引0,字符z映射到索引25,最后在遍历字符串的时候只需对s[i]-'a’所在的元素做+1操作即可,不用记住字符a的ASCII值。如何判断字符串t的字符出现次数呢?在遍历字符串t时,把出现的字符映射到哈希索引上的数值-1,遍历结束后如果数值所有元素都为0,则两字符串互为字母异位词。
题解
class Solution {
public:
bool isAnagram(string s, string t) {
int record[26] = {0};//哈希表初始化为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;
}
};
力扣题号: 349.两个数组的交集
给定两个数组 nums1 和 nums2 ,返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
示例 1:
输入:nums1 = [1,2,2,1], nums2 = [2,2]
输出:[2]
示例 2:
输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
输出:[9,4]
解释:[4,9] 也是可通过的
提示:
1 <= nums1.length, nums2.length <= 1000
0 <= nums1[i], nums2[i] <= 1000
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/intersection-of-two-arrays
思路
暴力解法: 遍历第一个数组的每一个元素,判断是否出现在第二个数组中,如果是,就加入输出数组。时间复杂度O(mn)。
哈希解法: 我们知道用数组来做哈希表是个很好的选择,但是这道题中规定了数组中的数值范围为[0,1000],假设我们采用大小为1000的数组,但实际上如果哈希值较少,特别分数,跨度非常大,那么使用数组就会造成空间的极大浪费。再次看题目描述,题目要求输出结果中每一个元素是唯一的,这暗示着哈希表中数值是不重复的,另外题目还不考虑输出结果的顺序,这暗示着哈希表中的key(set里面放的元素只能是key)是无序的。根据之前我们总结的表格,可以明确这道题要使用一种哈希数据结构——unordered_set。
set和multiset的底层实现都是红黑树,unordered_set的底层实现是哈希表,使用unordered_set的读写效率是最高的,而且不需要对数据进行排序,还不会让数据重复。所以我们采用unordered_set类型的容器来存放输出结果和用来查找数组2是否出现在数组1。
题解
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());//用来查找数组2的元素是否出现在数组1
for (int num : nums2) {
if (nums_set.find(num) != nums_set.end()) {//nums2的元素num可以在nums_set(nums1)中找到,就加入输出结果
result_set.insert(num);
}
}
return vector<int>(result_set.begin(), result_set.end());
}
};
力扣题号:1.两数之和
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
示例 1:
输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
示例 2:
输入:nums = [3,2,4], target = 6
输出:[1,2]
示例 3:
输入:nums = [3,3], target = 6
输出:[0,1]
提示:
2 <= nums.length <= 104
-109 <= nums[i] <= 109
-109 <= target <= 109
只会存在一个有效答案
进阶:你可以想出一个时间复杂度小于 O(n2) 的算法吗?
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/two-sum
思路
暴力解法: 很显然,用两层循环,第一层循环遍历数组每个元素,第二层循环从第一层循环当前遍历元素的下个元素开始遍历到数组最后一个元素,如果两数相加等于target,则return{i,j},在最后加上return{}表示未找到这两个元素。
**哈希解法: ** 这道题应该用什么哈希数据结构呢?数组可以吗?数组下标代表nums[i],其中存放i。题目给出nums[i]的范围是[-109,109],出现负值,而数组的下标应该大于等于0。另外数组的长度是受限制的,如果元素很少而哈希值太大,则会造成内存浪费。set可以吗?set是一个集合,里面放的元素只能是一个key,而本题不仅要判断y是否存在,还要记录y的下标,因为要返回x,y的下标,所以set也不可以用。
此时,我们就要选择另一中哈希数据结构——map,它是一种
首先定义map,遍历数组中的每一个元素nums[i],在map中查找target-nums[i]元素,如果找到就返回targetn-nums[i]这个元素的下标和i;如果没找到,就将
题解
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map <int, int> map;
for (int i = 0; i < nums.size(); i++){
auto iter = map.find(target - nums[i]);//在map中的key里查找与nums[i]相加等于target的元素,返回迭代器
if (iter != map.end()){//找到
return {iter->second, i};//返回找到的value(下标)和i
}
map.insert(pair<int, int>(nums[i], i));//未在map中找到,则将此键值对插入map以供下次查找
}
return {};
}
};
此题切记不能将nums的所有元素都存入map,再遍历nums数组在map中查找target-nums[i]。假设target=6,数组第一个元素为3,在map中查找target-nums[0]返回iter->second为0,输出结果为{0,0}。
力扣题号: 454.四数相加
给你四个整数数组 nums1、nums2、nums3 和 nums4 ,数组长度都是 n ,请你计算有多少个元组 (i, j, k, l) 能满足:
0 <= i, j, k, l < n
nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0
示例 1:
输入:nums1 = [1,2], nums2 = [-2,-1], nums3 = [-1,2], nums4 = [0,2]
输出:2
解释:
两个元组如下:
(0, 0, 0, 1) -> nums1[0] + nums2[0] + nums3[0] + nums4[1] = 1 + (-2) + (-1) + 2 = 0
(1, 1, 0, 0) -> nums1[1] + nums2[1] + nums3[0] + nums4[0] = 2 + (-1) + (-1) + 0 = 0
示例 2:
输入:nums1 = [0], nums2 = [0], nums3 = [0], nums4 = [0]
输出:1
提示:
n == nums1.length
n == nums2.length
n == nums3.length
n == nums4.length
1 <= n <= 200
-228 <= nums1[i], nums2[i], nums3[i], nums4[i] <= 228
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/4sum-ii
思路
这道题是四个独立的数组,只要找到nums1[i]+nums2[j]+nums3[k]+nums4[l]=0即可,不用考虑有重复四个元素相加等于0的情况。如果想升级难度,则给出一个数组,找出四个元素相加等于0,答案中不可以包含重复的四元组。(四数之和)
题解
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> umap;//key:a+b,value:a+b出现的次数
for (int a : nums1) {//遍历nums1和nums2数组,将两个数组的元素之和及出现的次数存入umap中,以供后面查找
for (int b : nums2) {
umap[a + b]++;
}
}
int count = 0;//统计a+b+c+d出现的次数
for (int c : nums3){
for (int d : nums4){
if (umap.find(0-c-d) != umap.end()) {//找到,
count += umap[0-c-d];//count要加上0-c-d出现的次数,即a+b+c+d=0出现的次数
}
}
}
return count;
}
};
力扣题号: 15.三数之和
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例 1:
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
示例 2:
输入:nums = []
输出:[]
示例 3:
输入:nums = [0]
输出:[]
提示:
0 <= nums.length <= 3000
-105 <= nums[i] <= 105
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/3sum
思路
通过两层for循环就可以确定a和b的数值了,可以使用哈希法来确定0-a-b是否在数值内出现过。这个思路虽然正确,但是有一个非常棘手的问题,就是题目要求输出不能包含重复的三元组。
把符合条件的三元组放入vector,然后去重,这样是非常费时的,很容易超时。我们在循环过程中去重有很多细节需要注意。时间复杂度虽然可以做到O(n2),但还是比较费时,因为不方便做剪枝操作。
题解
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) {
continue;
}
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去重,此三元组已经加入结果,所以此c不能再使用
} else {
set.insert(nums[j]);//为什么要在set中加入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,当三数之和>0时,说明right应该向左移动,才能变小;当三数之和<0时,说明left应该向右移动,才能变大。直到left和right相遇为止。
题解
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;//三元组元素a去重
//不能用nums[i]==nums[i + 1]去重,因为left=i+1,这样会漏掉某些三元组比如[-1,-1,2]
int left = i + 1;
int right = nums.size() - 1;
while (right > left) {
//去重逻辑不能放在这里,否则会直接导致right<=right,从而漏掉[0,0,0]三元组
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++;
//找到答案时,双指针同时收缩,避免重复
left++;
right--;
}
}
}
return result;
}
};
//时间复杂度O(n2)
//空间复杂度O(1)
思考题
既然三数之和可以用双指针可以用双指针法,那么5.4节的两数之和可不可以用双指针法呢?如果不能怎么修改题意才能使用呢?
答:不能。因为双指针法需要对数组进行排序,而5.4节的两数之和要求返回数组下标,排序之和数组下标会发生变化!可以将题意改成数组有序或者不要求返回数组下标而是要求返回符合条件的二元组们。
力扣题号: 18.四数之和
给你一个由 n 个整数组成的数组 nums ,和一个目标值 target 。请你找出并返回满足下述全部条件且不重复的四元组 [nums[a], nums[b], nums[c], nums[d]] (若两个四元组元素一一对应,则认为两个四元组重复):
0 <= a, b, c, d < n
a、b、c 和 d 互不相同
nums[a] + nums[b] + nums[c] + nums[d] == target
你可以按 任意顺序 返回答案 。
示例 1:
输入:nums = [1,0,-1,0,-2,2], target = 0
输出:[[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]
示例 2:
输入:nums = [2,2,2,2,2], target = 8
输出:[[2,2,2,2]]
提示:
1 <= nums.length <= 200
-109 <= nums[i] <= 109
-109 <= target <= 109
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/4sum
思路
四数之和和上一节的三数之和是一个思路,都是使用双指针法,基本解法就是在三数之和解法的基础上再套一个for循环。
但有一些细节需要注意,在三数之和中,我们首先在for循环里判断nums[i]>0就返回,因为0是题目中已经确定的数,而在四数之和中则不需要判断nums[k]>target就返回,因为这道题中规定了target是任意值。
四数之和的双指针解法是两层for循环遍历得到的nums[k]+nums[i]为确定值,时间复杂度为O(n3)。同理五数之和,六数之和等都采用这种解法。
还记得在5.5节提到的加深难度那道题吗?就是这道,因为本题要求在同一个集合内找出四个数相加等于target,同时四元组不能重复。而四数相加却是四个独立的数组,只要在各自数组中找到一个元素使四个元素相加等于0 即可。不用考虑有重复四个元素相加等于0 的情况。
题解
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++) {
// 这种剪枝是错误的,这道题目target 是任意值
// if (nums[k] > target) {
// return result;
// }
// 去重
if (k > 0 && nums[k] == nums[k - 1]) {
continue;
}
for (int i = k + 1; i < nums.size(); i++) {
// 正确去重方法
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) {//四数之和可能会超过int型的最大值
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;
}
};
本章从哈希表理论到数组、set和map的经典应用,把哈希表的整个全貌完整呈现出来,也强调了虽然map是万能的,但要知道什么时候用数组什么时候用set。
双指针法分别有如下三种应用:
1.双指针法将时间复杂度为O(n2)的解法优化为时间复杂度为O(n)的解法
3.3节移除元素
5.6节三数之和
5.7节四数之和
2.使用双指针记录前后指针实现链表反转
4.4节反转链表
使用双指针确定有环
4.6节环形链表
字符串是若干字符组成的有限序列,也可以理解成一个字符数组,但很多语言对字符串做了特殊规定,本书以C/C++为例,分析以下字符串。
在C语言中,把一个字符串存入一个数组时,也把结束符’\0’存入了数组,并以此作为该字符串是否结束的标志。例如:
char a[5] = "asd";
for (int i = 0; a[i] != '\0'; i++) {}
在C++中,提供了一个string类,string类会提供size接口,用来判断string类的字符串是否结束,不用’\0’判断字符串是否结束。例如:
string a = "asd";
for (int i = 0; i < a.size(); i++) {}
在基本操作上没有区别,但string提供了更多的字符串处理的接口。例如,string重载了“+”,而vector却没有。
所以在处理字符串时,我们还是会定义一个string类型的变量。
力扣题号: 344.反转字符串
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
示例 1:
输入:s = [“h”,“e”,“l”,“l”,“o”]
输出:[“o”,“l”,“l”,“e”,“h”]
示例 2:
输入:s = [“H”,“a”,“n”,“n”,“a”,“h”]
输出:[“h”,“a”,“n”,“n”,“a”,“H”]
提示:
1 <= s.length <= 105
s[i] 都是 ASCII 码表中的可打印字符
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/reverse-string
拓展
对于这道题目,有些人会使用C++中的一个库函数reverse,调用库函数直接输出结果。如果按这种方式解题,那么很难理解反转字符串的实现原理。
库函数是什么时候都不能用吗?不是,而是要分场景。如果是现场面试,那么什么时候可以用库函数,什么不能库函数呢?
如果题目的关键部分直接用库函数就可以实现,那么建议不要使用库函数。
毕竟面试官不是考查你对库函数的熟悉程度,使用Python和Java更要注意这一点,因为Java和Python提供的库函数十分丰富。
如果库函数仅仅是解题过程中的一部分,并且你熟悉这个库函数的内部实现原理,那么可以考虑使用库函数。
思路
我们在4.4节反转链表时使用的是双指针法,反转字符串依然可以使用双指针法,只不过字符串反转更简单。
因为字符串也是一种数组,所以元素在内存中是连续分布的,这就决定了反转链表和反转字符串的方式也是有所差异的。
对于字符串,我们定义两个指针(也可以说是索引下标),一个从字符串前面,另一个从字符串后面,两个指针同时向中间移动,并交换元素。
题解
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]);
}
}
};
循环里只要做交换s[i]和s[j]的操作就可以了,这里使用了swap库函数。
swap库函数可以有两种实现方式,一种方式是常见的交换数组:
int tmp = s[i];
s[i] = s[j];
s[j] = tmp;
另一种就是位运算:
s[i] ^= s[j];
s[j] ^= s[i];
s[i] ^= s[j];
执行速度:库函数swap>直接交换数值>位运算。
力扣题号: 541.反转字符串Ⅱ。
给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。
如果剩余字符少于 k 个,则将剩余字符全部反转。
如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。
示例 1:
输入:s = “abcdefg”, k = 2
输出:“bacdfeg”
示例 2:
输入:s = “abcd”, k = 2
输出:“bacd”
提示:
1 <= s.length <= 104
s 仅由小写英文组成
1 <= k <= 104
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/reverse-string-ii
思路
这道题目其实也是模拟类型的题目,实现题目中规定的反转规则就可以了。一些读者在处理题目中“每隔2k个字符的前k个字符”的逻辑,写了一堆逻辑代码,或者编写了一个计数器,先统计2k个字符,再统计前k个字符。
题目中要找的是每个2k区间的起点,在遍历字符串的过程中,只要让i+=(2k),i每次移动2k,然后判断是否有需要反转的区间就可以了。
所以我们总结一下:如果要按照一定规律一段一段地处理字符串时,要在for循环的表达式上多做文章。
那么具体实现反转的逻辑要不要使用库函数呢?其实都可,使用reverse实现反转也可以,毕竟不是解题的关键部分。
题解
class Solution {
public:
string reverseStr(string s, int k) {
for (int i = 0; i < s.size(); i += 2*k) {
//每个2k个字符反转前k个字符
//剩余字符小于2k但大于或等于k个,则反转前k个字符
if ((i + k) <s.size()) {
reverse(s.begin() + i, s.begin() + i + k);
continue;
}
//如果剩余字符小于k个,则将剩余字符全部反转
reverse(s.begin() + i,s.begin() + s.size());
}
return s;
}
};
力扣题号: 151.反转字符串里的单词
给你一个字符串 s ,颠倒字符串中 单词 的顺序。
单词 是由非空格字符组成的字符串。s 中使用至少一个空格将字符串中的 单词 分隔开。
返回 单词 顺序颠倒且 单词 之间用单个空格连接的结果字符串。
注意:输入字符串 s中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中,单词间应当仅用单个空格分隔,且不包含任何额外的空格。
示例 1:
输入:s = “the sky is blue”
输出:“blue is sky the”
示例 2:
输入:s = " hello world "
输出:“world hello”
解释:颠倒后的字符串中不能存在前导空格和尾随空格。
示例 3:
输入:s = “a good example”
输出:“example good a”
解释:如果两个单词间有多余的空格,颠倒后的字符串需要将单词间的空格减少到仅有一个。
提示:
1 <= s.length <= 104
s 包含英文大小写字母、数字和空格 ’ ’
s 中 至少存在一个 单词
进阶:如果字符串在你使用的编程语言中是一种可变数据类型,请尝试使用 O(1) 额外空间复杂度的 原地 解法。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/reverse-words-in-a-string
思路
这道题目综合考查了字符串的多种操作。一些读者可能会使用split库函数来分割单词,然后定义新的string字符串,然后把单词倒序相加。那么这道题就失去了它的意义,所以题目还要求不可以使用额外的辅助空间。
不能使用额外的空间就只能在原字符串上做文章了。我们可以将整个字符串都反转过来,此时单词的顺序就反转了,但单词本身字母的顺序也被反转了,需要再把单词反转一下,单词就正过来了。
步骤如下:
1.删除多余空格。
2.将整个字符串 反转。
3.将每个单词反转。
void removeExtraSpaces(string& s) {
for (int i = s.size() - 1; i > 0; i--) {
//删除单词间冗余的空格和单词后的冗余空格
if (s[i] == s[i-1] && s[i] == ' ') {
s.erase(s.begin() + i);
}
}
//删除字符串最后的空格
if(s.size() > 0 && s[s.size() - 1] == ' ') {
s.earse(s.begin() + s.size() - 1);
}
//删除字符串最前的空格
if(s.size() > 0 && s[0] == ' ') {
s.earse(s.begin());
}
}
下面讲解代码的实现细节,以删除多余空格为例,很多读者打算采用earse函数删除,觉得时间复杂度是O(n)。但真正的时间复杂度是多少呢?earse函数本来就是时间复杂度为O(n)的操作,而数组中的元素是不能删除的,只能覆盖。以上代码的erase操作还套了一个for循环,所以这段代码的时间复杂度为O(n2).
使用双指针法删除空格,最后重新设置(resize)字符串的大小,就可以实现O(n)的时间复杂度。
使用双指针法删除冗余空格的代码如下:
void removeExtraSpaces(string& s) {
int slowIndex = 0, fastIndex = 0;//定义快慢指针
//去掉字符串前面的空格
while (s.size() > 0 && fastIndex < s.size() && s[fastIndex] == ' ') {
fastIndex++;
}
//此时fastIndex指向第一个不为' '的字符
//去掉字符串中间部分的冗余空格
for (;fastIndex < s.size(); fastIndex++) {
if (fastIndex - 1 > 0
&& s[fastIndex - 1] == s[fastIndex]
&& s[fastIndex] == ' ') {//fastIndex指针会在指向空格时不断向后移动直至指向第一个字符
continue;
}
else {//fastIndex指针会在指向第一个不为' '的字符时执行else
s[slowIndex++] = s[fastIndex];//用快指针所指字符覆盖慢指针所指字符
//先覆盖掉字符串前面的空格,然后覆盖字符串中间部分的冗余空格
}
}
//去掉末尾的空格
//slowIndex-1指向的是删除末尾空格前字符串最后一个字符,
if (slowIndex - 1 > 0 && s[slowIndex - 1] == ' ') {//假如是空格则重设大小为slowIndex-1
s.resize(slowIndex - 1);
}
else {
s.resize(slowIndex);//slowIndex-1指向的是非空格字符,它前面是最后一个字符,下标从0开始,所以要设大小为slowIndex
}
}
实现反转字符串功能(支持反转字符串子区间)代码:
void reverse(string& s; int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
swap(s[i], s[j]);
}
}
题解
class Solution {
public:
//去除冗余空格
void removeExtraSpaces(string& s) {
int slowIndex = 0, fastIndex = 0;//定义快慢指针
//去掉字符串前面的空格
while (s.size() > 0 && fastIndex < s.size() && s[fastIndex] == ' ') {
fastIndex++;
}
//此时fastIndex指向第一个不为' '的字符
//去掉字符串中间部分的冗余空格
for (;fastIndex < s.size(); fastIndex++) {
if (fastIndex - 1 > 0
&& s[fastIndex - 1] == s[fastIndex]
&& s[fastIndex] == ' ') {//fastIndex指针会在指向空格时不断向后移动直至指向第一个字符
continue;
}
else {//fastIndex指针会在指向第一个不为' '的字符时执行else
s[slowIndex++] = s[fastIndex];//用快指针所指字符覆盖慢指针所指字符
//先覆盖掉字符串前面的空格,然后覆盖字符串中间部分的冗余空格
}
}
//去掉末尾的空格
//slowIndex-1指向的是删除末尾空格前字符串最后一个字符,
if (slowIndex - 1 > 0 && s[slowIndex - 1] == ' ') {//假如是空格则重设大小为slowIndex-1
s.resize(slowIndex - 1);
}
else {
s.resize(slowIndex);//slowIndex-1指向的是非空格字符,它前面是最后一个字符,下标从0开始,所以要设大小为slowIndex
}
}
//反转字符串s中左闭右闭的区间[start,end]
void reverse(string& s, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
swap(s[i], s[j]);
}
}
string reverseWords(string s) {
removeExtraSpaces(s);//去除冗余空格
reverse(s, 0, s.size() - 1);//全部反转
int start = 0;//要反转的单词在字符串的起始位置
int end = 0; //要反转的单词在字符串的终止位置
bool entry = false;//标记枚举字符串过程中是否已经进入单词区间
for (int i = 0; i < s.size(); i++) {
if ((!entry) || (s[i] != ' ' && s[i-1] ==' ')) {//i所指字符不是空格,但前一个字符是空格,说明进入单词区间
start = i;
entry = true ;//进入单词区间
}
if (entry && s[i] == ' ' && s[i - 1] != ' ') {//i所指字符是空格,但前一个字符不是空格,说明i所指的是单词最后一个字符
end = i - 1;// 确定单词终止位置
entry = false;//结束单词区间
reverse(s, start, end); //反转这个单词
}
//因为我们已经去掉了字符串最后的空格,所以我们要单独考虑遍历到字符串最后一个单词的情况
if (entry && (i == (s.size() - 1)) && s[i] != ' ') {
end = i;//确定单词终止位置
entry = false;//结束单词区间
reverse(s, start, end);
}
}
return s;
}
};
KMP算法比较晦涩难懂,在看本章时可以搭配Carl的b站视频帮助理解
KMP算法由Knuth、Morris和Pratt三位学者发明,取三个名字的首字母。
KMP算法主要应用在字符串匹配的场景中,其思想是当出现字符串不匹配的情况时,可以知道一部分已经匹配的文字内容,利用这些信息避免从头进行匹配。
如何记录已经匹配的文字内容是KMP的重点,也是next数组的任务。KMP算法的代码不容易理解,如果没有真正理解而去死记硬背的话是很容易忘记的。如果面试官问:next数组中的数字表示的是什么,为什么这么表示?估计很多人答不上来。
next数字是一个前缀表(prefix table),或者说是前缀表的变形。而前缀表有什么作用呢?它是用来回退的,它记录了模式串与主串(文本串)不匹配时,模式串应该从哪里开始重新匹配的信息。
看一个例子,文本串aabaabaafa中查找是否出现过一个模式串aabaaf。可以看出,文本串第六个字符b和模式串第六个字符f不匹配了。如果用暴力匹配,此时就要从头匹配了。如果使用前缀表,则不会从头匹配,而是从上次已经匹配的内容开始匹配,找到模式串中第三个字符b继续匹配。
前缀表时如何记录的呢?前缀表的任务是当前任务匹配失败后,找到之前已经匹配的位置再重新匹配,这意味着在某个字符匹配失败时,前缀表会告诉你下一步匹配时模式串应该跳到哪个位置。
这里定义前缀表为:记录下标i(包括i)之前的字符串有多长的相同前后缀。
还看之前的例子。下标5之前的字符串(也就是字符串aabaa的最长相等的前缀和后缀字符串)是子字符串aa,因为找到了最长相等的前缀和后缀,所以匹配失败的位置是后缀子字符串的后一字符,我们找到与其相同的前缀,从后面重新匹配即可。
正是因为前缀表记录的是最长相同前后缀的长度的信息,才具有告诉我们当前位置匹配失败时,跳到之前已经匹配过的地方的能力。
正确理解什么是前缀什么是后缀很重要。字符串的前缀是指:不包含最后一个字符的所有以第一个字符开头的连续子字符串。后缀是指:不包含第一个字符的所有以最后一个字符结尾的连续子字符串。
以模式串aabaaf为例,前缀表应为:
下标: 012345
模式串: aabaaf
前缀表: 010120
当下标5字符不匹配时,则应该寻找前一位下标在前缀表中对应的元素,即跳到下标为2
的位置继续匹配。
在KMP算法的实现中,一般会用到next数组表示前缀表,但不同的KMP算法实现,next数组表示方法会不同。next数组可以是前缀表,但也有一些实现方法是把前缀表统一减一或整体右移一位,初始值置为-1,这样操作之后的前缀表将作为next数组。
n为文本串长度,m为模式串长度,在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程的时间复杂度为O(n)(因为文本串不后退),之前还有单独生成next数组,时间赋值度为O(m),总时间复杂度为O(m+n)。可以看出相比于暴力解法的O(m*n),KMP算法在字符串匹配过程中极大的提高了搜索效率。
力扣题号: 28.实现strStr()
实现 strStr() 函数。
给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串出现的第一个位置(下标从 0 开始)。如果不存在,则返回 -1 。
说明:
当 needle 是空字符串时,我们应当返回什么值呢?这是一个在面试中很好的问题。
对于本题而言,当 needle 是空字符串时我们应当返回 0 。这与 C 语言的 strstr() 以及 Java 的 indexOf() 定义相符。
示例 1:
输入:haystack = “hello”, needle = “ll”
输出:2
示例 2:
输入:haystack = “aaaaa”, needle = “bba”
输出:-1
示例 3:
输入:haystack = “”, needle = “”
输出:0
提示:
0 <= haystack.length, needle.length <= 5 * 104
haystack 和 needle 仅由小写英文字符组成
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/implement-strstr
我们生成next数组的方式是将前缀表统一减一之后实现的,后面也会讲解直接使用前缀表作为next数组的实现方法。
定义一个函数getNext来构建next数组,函数参数指向next数组的指针和一个字符串。构建next数组其实就是计算模式串s的前缀表的过程,主要有三步:
1.初始化next数组。
定义两个指针i和j,j指向前缀的起始位置(j也代表i包括i之前这个子串最长相等前后缀的长度),i指向后缀的起始位置。然后对next数组进行初始化赋值,将next[0]初始化为-1。(前缀表统一减一版本)next[i]表示i(包括i)之前最长相等前后缀的长度(其实就是j),所以初始化next[0]=j。
2.处理前后缀不相同的情况。
因为j初始化为-1,所以i就从1开始,比较s[i]与s[j+1]是否相同。如果不相同,也就是遇到前后缀末尾不相同的情况,那么就要向前回退。怎么回退呢?next[j]记录了j(包括j)之前的子字符串的相同前后缀的长度,如果s[i]与s[j+1]不相同,则要查找下标j+1的前一个元素在next数组的值(即next[j])
3.处理前后缀相同的情况。
如果s[i]和s[j+1]相同,就同时向后移动i和j,说明找到相同的前后缀,同时还要将j(前缀的长度)赋给next[i],因为next[i]要记录相同前后缀的长度。
整体构建next数组(前缀表统一减一)的代码如下:
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]
}
}
整体构建next数组(用前缀表直接构建next数组)的代码如下:
void getNext(int* next, const string& s) {
int j = 0;
next[0] = j;
for (int i = 1; i < s.size(); i++) {//i从1开始
while (j >= 1 && s[i] != s[j ]) {//前后缀不相同
j = next[j-1];
}
if (s[i] == s[j]) {//找到相同的前后缀
j++;
}
next[i] = j;//将j(前缀长度)赋给next[i]
}
}
定义两个下标i和j,i指向文本串的起始位置,j指向模式串起始位置。
这里j的初始值依然为-1(前缀表整体减一),因为next数组中记录的起始位置为-1。i从0开始,遍历文本串。接下来比较s[i]和t[j+1]是否相同。如果不相同,则j就要从next数组中寻找下一个匹配的位置。如果相同,则i和j同时后移一位。最后如何判断在文本串 s中出现了模式串t呢?如果j指向了模式串t的末尾,那么说明模式串t完全匹配文本串s中某个字串了。
因为本题要在文本串中找出模式串匹配成功出现的第一个字符的下标,所以返回当前文本串匹配模式串的最后一个位置i,再减去模式串的长度,就是文本串中出现模式串的第一个字符的位置。
使用next数组(整体减一)用模式串匹配文本串的整体代码:
int j = -1;//next数组中记录的起始位置为-1
for (int i = 0; i < haystack; i++) {//i从0开始(文本串)
while (j > 0 && haystack[i] != needle[j+1]) {//不匹配
j = next[j];//j寻找之前匹配的位置
}
if (haystack[i] == needle[j+1]) {//匹配
j++;
}
if (j == needle.size() - 1) {//文本串s中出现了模式串
return (i - needle.size() + 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++) {//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()];//定义next数组
getNext(next,needle);//求next数组
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++;
}
if (j == needle.size() - 1) {//文本串s中出现了模式串
return (i - needle.size() + 1);
}
}
return -1;
}
};
如果直接使用前缀表,则j初始化为0,next[0]=0,回退方式改为j=next[j-1],匹配时遇到不匹配的地方就查找前一位下标next数组对应的数值进行回退。
题解
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;
}
};
力扣题号: 459.重复的子字符串
给定一个非空的字符串 s ,检查是否可以通过由它的一个子串重复多次构成。
示例 1:
输入: s = “abab”
输出: true
解释: 可由子串 “ab” 重复两次构成。
示例 2:
输入: s = “aba”
输出: false
示例 3:
输入: s = “abcabcabcabc”
输出: true
解释: 可由子串 “abc” 重复四次构成。 (或子串 “abcabc” 重复两次构成。)
提示:
1 <= s.length <= 104
s 由小写英文字母组成
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/repeated-substring-pattern
思路
为什么将这道题也归为KMP的题目呢?是因为next数组的含义:next数组记录的就是最长相等前后缀的长度,如果next[len-1]!=-1,则说明字符串有相同的前后缀。最长相等前后缀的长度为next[len-1]+1。字符串有相同的前后缀还不行,因为本题是要判断字符串是否可由它的子字符串重复多次构成。如果(字符串长度-字符串最长相等前后缀长度)(第一个重复子字符串的长度,也就是一个重复周期的长度)可以被字符串长度整除,即len%(len-(next[len-1]+1)) == 0,那么说明这个字符串是这个周期的循环。
下面举个例子来说明:以字符串asdfasdfasdf为例,此时next[len-1]=7,next[len-1]+1=8,8就是这个字符串最长相等前后缀的长度。(前缀asdfasdf,后缀asdfasdf)。(len-(next[len-1]+1))=12-8=4,4就是第一个重复周期的长度。而4又可被字符串长度12整除,说明有重复的子字符串(asdf)。
题解
使用前缀表统一减一的实现方式:
class Solution {
public:
void getNext(int*next, const string& s) {
int j = -1;
next[0] = -1;
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) {
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;
}
return false;
}
};
直接使用前缀表构造next数组:
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 >=1 && s[i] != s[j]) {
j = next[j-1];
}
if (s[i] == s[j]) {
j++;
}
next[i] = j;
}
}
bool repeatedSubstringPattern(string s) {
if (s.size() == 0) {
return false;
}
int next[s.size()];
getNext(next, s);
int len = s.size();
if (next[len-1] != 0 && len % (len-(next[len-1])) == 0) {
return true;
}
return false;
}
};
字符串类型的题目和数组是类似的,复杂的字符串题目非常考验面试者对代码的掌控能力。
双指针法经常用在数组、链表和字符串类题目中,本章讲解了七道使用双指针法的题目:
3.3节移除元素
5.6节三数之和
5.7节四数之和
4.4节反转链表
4.6节环形链表
6.2节反转字符串
使用KMP算法可以解决两类经典问题:
1.匹配问题,6.6节使用KMP匹配字符串
2.重复子串问题,6.7节找到重复的子字符串
队列是先进先出,栈是先进后出。
下面列出四个关于栈(stack)的四个问题。务必于重复理解。
1.我们使用的stack属于哪个版本的STL?
栈和队列是STL(C++标准库)中的两种数据结构。STL有多个版本——
HP STL:其他版本的C++STL一般是以HP STL为蓝本实现的,HPSTL是C++STL的第一个实现版本,且开放源代码;P.J.Plauger STL:由P.J.Plauger参照HP STL实现,被visu C++编译器所采用,不是开源的;SGI STL:由Silicon Graphics Computer Systems公司参照HP STL实现,被Linux的C++编译器GCC采用,SGI STL是开源软件,源码可读性很高。
2.stack提供迭代器来遍历stack空间吗?
栈提供了push和pop等接口,所有元素都必须符合先进后出的规则**,所以栈不提供走访功能,也不提供迭代器(iterator),不像set或map提供迭代器iterator来遍历所有元素**。
3.C++中使用的stack是容器吗?
栈使用底层容器来完成其所有工作,对外提供统一的接口,底层容器是可插拔的,也就是说我们可以控制使用什么容器来实现栈的功能。所以STL中的栈往往不被归类为容器,而被归类为container adapter(容器适配器)。
4.我们使用的STL中的stack是如何实现的?
栈的底层实现可以是vector、deque、list,主要是数组和链表的底层实现。我们常用的SGI STL,如果没有指定底层实现,则默认以deque为栈的底层结构。deque是双向队列,只要封住双向队列的一端,只从双向队列的另一端操作数据就可以实现栈的逻辑了。
SGI STL中的队列的底层实现在默认情况下也使用deque。我们也可以指定vector为栈的底层实现,初始化语句如下:
std::stack<int, std::vector<int> > third;//定义以vector为底层容器的栈
队列中先进先出的数据结构同样不允许遍历行为,不提供迭代器,也可以指定list为底层实现,初始化queue的语句如下:
std::queue<int, std::list<int>> third;//定义以list为底层容器的队列
所以STL中的队列也不归类为容器,也是被归类为容器迭代器。
力扣题号: 232.用栈组成队列。
请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push、pop、peek、empty):
实现 MyQueue 类:
void push(int x) 将元素 x 推到队列的末尾
int pop() 从队列的开头移除并返回元素
int peek() 返回队列开头的元素
boolean empty() 如果队列为空,返回 true ;否则,返回 false
说明:
你 只能 使用标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。
你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。
示例 1:
输入:
[“MyQueue”, “push”, “push”, “peek”, “pop”, “empty”]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 1, 1, false]
解释:
MyQueue myQueue = new MyQueue();
myQueue.push(1); // queue is: [1]
myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue)
myQueue.peek(); // return 1
myQueue.pop(); // return 1, queue is [2]
myQueue.empty(); // return false
提示:
1 <= x <= 9
最多调用 100 次 push、pop、peek 和 empty
假设所有操作都是有效的 (例如,一个空的队列不会调用 pop 或者 peek 操作)
进阶:
你能否实现每个操作均摊时间复杂度为 O(1) 的队列?换句话说,执行 n 个操作的总时间复杂度为 O(n) ,即使其中一个操作可能花费较长时间。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/implement-queue-using-stacks
思路
本题是一道模拟题,并不涉及具体算法,考查的是对栈和队列的熟悉程度。
要想用栈来模拟队列的行为,一个栈是不够的,得需要两个栈——输入栈和输出栈。当需要push元素时,将元素直接push到输入栈即可;当需要pop元素时,应该先检查输出栈是否为空,不为空时直接pop即可,否则需要把输入栈的元素依次导入到输出栈,然后再执行pop操作;判断队列是否为空需要同时判断输入栈和输出栈是否为空;peek()操作和pop操作功能类似,在代码上也是类似的,可以考虑代码复用。
题解
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());//输出栈push输入栈的栈顶元素,然后再将输入栈的栈顶元素pop
stIn.pop();
}
}
int result = stOut.top();
stOut.pop();
return result;
}
int peek() {
int result = this->pop();//this指向调用peek的对象,可以直接使用已有的pop函数实现代码复用
//pop()函数将输出栈栈顶弹出了,还得再添加回去
stOut.push(result);
return result;
}
bool empty() {
return stIn.empty() && stOut.empty();
}
};
/**
* Your MyQueue object will be instantiated and called as such:
* MyQueue* obj = new MyQueue();
* obj->push(x);
* int param_2 = obj->pop();
* int param_3 = obj->peek();
* bool param_4 = obj->empty();
*/
在上述代码中可以看出,peek()的实现直接复用了pop()。
在工业级别的代码开发中,最忌讳的就是实现一个功能类似的函数(这个功能还其他模块中出现过),在模块直接复制并使用该函数的代码。
一定要懂得代码复用,将功能相近的函数要抽象处理,而不是频繁的复制粘贴,很容易出问题。
力扣题号: 225.用队列实现栈
请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push、top、pop 和 empty)。
实现 MyStack 类:
void push(int x) 将元素 x 压入栈顶。
int pop() 移除并返回栈顶元素。
int top() 返回栈顶元素。
boolean empty() 如果栈是空的,返回 true ;否则,返回 false 。
注意:
你只能使用队列的基本操作 —— 也就是 push to back、peek/pop from front、size 和 is empty 这些操作。
你所使用的语言也许不支持队列。 你可以使用 list (列表)或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。
示例:
输入:
[“MyStack”, “push”, “push”, “top”, “pop”, “empty”]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 2, 2, false]
解释:
MyStack myStack = new MyStack();
myStack.push(1);
myStack.push(2);
myStack.top(); // 返回 2
myStack.pop(); // 返回 2
myStack.empty(); // 返回 False
提示:
1 <= x <= 9
最多调用100 次 push、pop、top 和 empty
每次调用 pop 和 top 都保证栈不为空
进阶:你能否仅用一个队列来实现栈。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/implement-stack-using-queues
思路
队列的规则是先进先出,而栈的规则是后进先出。用队列来实现栈的功能,用两个队列和一个队列都可以。
如果用两个队列,那么和用两个栈实现队列不一样的是,不需要有输入队列和输出队列,另外一个队列完全是用来备份的。
在模拟入栈时,直接用队列1进行入队。在模拟出栈时,为了达到输出队列最后元素的目的,我们需要将队列1除最后一个元素外,全部出队,然后进队到队列2(备份)。然后弹出最后的元素,最后再把导入到队列2的元素在导回队列1。
题解
class MyStack {
public:
queue<int> que1;
queue<int> que2;//用来备份
MyStack() {
}
void push(int x) {
que1.push(x);
}
int pop() {
int size = que1.size() - 1;//需要导入到队列2的元素数量
while (size--) {
que2.push(que1.front());
que1.pop();
}
int result = que1.front();//队列1剩下的元素就是要返回的值
que1.pop();//弹出队列1最后的元素
que1 = que2;//再将que2赋给que1(导回去)
while (!que2.empty()) { //清空队列2
que2.pop();
}
return result;
}
int top() {
return que1.back();
}
bool empty() {
return que1.empty();
}
};
/**
* Your MyStack object will be instantiated and called as such:
* MyStack* obj = new MyStack();
* obj->push(x);
* int param_2 = obj->pop();
* int param_3 = obj->top();
* bool param_4 = obj->empty();
*/
使用一个队列来实现栈与两个队列实现不同的是pop(),我们不用将队列1的元素导入到队列2来实现保存副本的目的。我们只需在模拟出栈时,将队列头部的元素出队然后重新加入到队列尾(除了最后一个元素),此时再弹出元素的顺序就是出栈的顺序了。
题解
class MyStack {
public:
queue<int> que;
MyStack() {
}
void push(int x) {
que.push(x);
}
int pop() {
int size = que.size() - 1;
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();
}
};
/**
* Your MyStack object will be instantiated and called as such:
* MyStack* obj = new MyStack();
* obj->push(x);
* int param_2 = obj->pop();
* int param_3 = obj->top();
* bool param_4 = obj->empty();
*/
记得在王道408数据结构中,把括号匹配归类到了栈的应用之中。不过下面这道题的给出的字符串只包括括号。
在编译原理中,编译器在词法分析的过程中处理括号、花括号等符号的逻辑也使用了栈这种数据结构。
在Linux系统中,cd命令:
cd a/b/c/../../
这个命令最后进入a目录(???),系统是如何知道进入了a目录呢?这就是栈的应用。
力扣题号:20.有效的括号
给定一个只包括 ‘(’,‘)’,‘{’,‘}’,‘[’,‘]’ 的字符串 s ,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
示例 1:
输入:s = “()”
输出:true
示例 2:
输入:s = “()[]{}”
输出:true
示例 3:
输入:s = “(]”
输出:false
示例 4:
输入:s = “([)]”
输出:false
示例 5:
输入:s = “{[]}”
输出:true
提示:
1 <= s.length <= 104
s 仅由括号 ‘()[]{}’ 组成
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/valid-parentheses
思路
由于栈这种数据结构的特殊性,非常适合处理对称匹配类的问题。建议写代码之前先分析字符串中的括号有几种不匹配的情况。
1.字符串左方向的括号多余了,导致不匹配。例如:( [ { } ] ( )
2.字符串右方向的括号多余了,导致不匹配。例如: ( [ { } ] ) )
3.括号并没有多余,只是类型不匹配。例如: ( [ { } } )
只要代码覆盖了所有不匹配的情况,就不会出问题。
1.已经遍历完所有字符串,但栈不为空,说明有相应的左括号但没有右括号和它进行匹配。返回错误。
2.在遍历字符串的过程中,栈就为空,说明没有供右括号匹配的左括号了。返回错误。
3.在遍历字符串的过程中,发现栈中没有要匹配的字符,说明类型不匹配。返回错误。
当遍历完字符串,栈是空的,说明全部匹配成功。
还有一些技巧,在匹配左括号时候,右括号先入栈,这时只需比较当前元素是否和栈顶相等即可,比左括号先入栈的代码要简单得到。
题解
class Solution {
public:
bool isValid(string s) {
stack<int> 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();//这种情况是st.top()==s[i]匹配成功,栈弹出元素
//第一种情况:此时我们已经遍历完字符串但是 栈不为空,说明有相应的左括号没有右括号进行匹配
}
return st.empty();
}
};
力扣题号: 150.逆波兰表达式求值
逆波兰数即后缀表达式
根据 逆波兰表示法,求表达式的值。
有效的算符包括 +、-、*、/ 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。
注意 两个整数之间的除法只保留整数部分。
可以保证给定的逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。
示例 1:
输入:tokens = [“2”,“1”,“+”,“3”,“*”]
输出:9
解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9
示例 2:
输入:tokens = [“4”,“13”,“5”,“/”,“+”]
输出:6
解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6
示例 3:
输入:tokens = [“10”,“6”,“9”,“3”,“+”,“-11”,““,”/“,””,“17”,“+”,“5”,“+”]
输出:22
解释:该算式转化为常见的中缀算术表达式为:
((10 * (6 / ((9 + 3) * -11))) + 17) + 5
= ((10 * (6 / (12 * -11))) + 17) + 5
= ((10 * (6 / -132)) + 17) + 5
= ((10 * 0) + 17) + 5
= (0 + 17) + 5
= 17 + 5
= 22
提示:
1 <= tokens.length <= 104
tokens[i] 是一个算符(“+”、“-”、“*” 或 “/”),或是在范围 [-200, 200] 内的一个整数
逆波兰表达式:
逆波兰表达式是一种后缀表达式,所谓后缀就是指算符写在后面。
平常使用的算式则是一种中缀表达式,如 ( 1 + 2 ) * ( 3 + 4 ) 。
该算式的逆波兰表达式写法为 ( ( 1 2 + ) ( 3 4 + ) * ) 。
逆波兰表达式主要有以下两个优点:
去掉括号后表达式无歧义,上式即便写成 1 2 + 3 4 + * 也可以依据次序计算出正确结果。
适合用栈操作运算:遇到数字则入栈;遇到算符则取出栈顶两个数字进行计算,并将结果压入栈中
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/evaluate-reverse-polish-notation
我们习惯看到的表达式都是中缀表达式,但中缀表达式对计算机来说并不是很友好。计算机从左到右去扫描,还要比较一下优先级,还要回退,这个流程十分复杂。如果将中缀表达式转化为后缀表达式(逆波兰表达式)之和,计算机可以根据栈的顺序对数据进行处理,不需要考虑运算符的优先级,也不用回退了。
思路
从二叉树的角度上来看,逆波兰表达式相当于二叉树的后序遍历。可以把运算符作为中间节点,按照后序遍历的规则画出一个二叉树。但我们没必要从二叉树的角度去解决这个问题,只要知道逆波兰表达式是用后序遍历的方式把二叉数序列化的就可以了。
根据题目描述给的提示我们知道,每一个子表达式要得出一个结果,然后再将这个结果进行运算。基本的思想是:遇到整数就加入栈中,遇到运算符就弹出两个整数进行运算再压入栈中。最后栈中的元素就是我们要求的结果。
题解
class Solution {
public:
int evalRPN(vector<string>& tokens) {
stack<int> st;
for (int i = 0; i < tokens.size(); i++) {
if (tokens[i] == "+" || tokens[i] == "-" || tokens[i] == "*" || tokens[i] == "/") {//遇到运算符就从栈中弹出两个元素进行运算
int num1 = st.top();
st.pop();
int 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(stoi(tokens[i]));
}
}
int result = st.top();
st.pop();
return result;
}
};
力扣题号: 239.滑动窗口最大值
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
示例 1:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
示例 2:
输入:nums = [1], k = 1
输出:[1]
提示:
1 <= nums.length <= 105
-104 <= nums[i] <= 104
1 <= k <= nums.length
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/sliding-window-maximum
思路
本题的难点是求区间的最大值。很多人一开始就想到的是暴力解法,在遍历窗口的过程中找到最大的数值,但这样的解法时间复杂度是O(n*k)。
还有人想到大顶堆(优先级队列)来存放这个窗口的k个数字,这样就可以知道最大值是多少了(大顶堆最上面的元素是最大值)。但这个窗口是移动的,而大顶堆每次只能弹出最大值元素,这就会造成大顶堆维护的并不是滑动窗口的元素。所以不能用大顶堆。
那应该用什么数据结果来存储滑动窗口呢?符合滑动窗口一进一出逻辑的数据结构有栈和队列。栈可以吗?栈每次只能弹出刚压入的元素,不符合。此时就需要一个队列,将窗口的元素放入这个队列,随着窗口的移动,队列也一进一出,每次移动后,告诉我们队列中的最大值是什么。
这个队列的代码如下:
class MyQueue {
public:
void pop(int value) {
}
void push(int value) {
}
int front() {
return que.front();
}
};
每次窗口移动时,调用que.pop(),que.push(),然后que.front()返回队列中的最大值。
这样的队列很符合我们的需求,可惜没有现成的数据结构,需要我们自己实现。
队列中的元素需要排序,而且将最大值放在出队口,否则就不知道哪个元素是最大值(que.front())。如果把窗口的元素全部放入队列,那么窗口移动时,队列需要弹出对应的元素。排序后的队列如何弹出要移除的元素呢?
其实队列没必要维护窗口的所有元素,只需要维护有可能成为最大值的元素即可。同时保证队列的元素数值由大到小排序。
这个维护元素单调递减的队列就叫做单调队列,需要我们自己实现它。注意:不要以为实现单调对了就是对窗口中的元素进行排序,如果仅对元素进行排序,那就和大小顶堆(优先级队列)没有区别了。
对于窗口中的元素{2,3,5,1,4,8,9,0},k=5,只维护{5,4}就够了。保持队列单调递减,此时队列出口的元素就是窗口的最大元素。
单调队列中的{5,4}要如何配合窗口移动呢?
设计单调队列时,pop和push操作要保持如下规则:
pop():如果窗口移除的元素value等于单调队列的出口元素,此时必须将队列出口的元素弹出,因为它将不属于这个滑动窗口。否则不执行任何操作。
push(value):如果push的元素value大于入口元素,那么就将队列入口的元素弹出,直至push元素的值小于或等于队列入口元素的数值,保持。当push(8)时,需要将4弹出,再将5弹出,此时8<=队列入口元素的值(8)。
基于以上规则,每次窗口移动时,只要调用que.front()就可以返回当前窗口的最大值。
我们用什么数据结构来实现这个单调队列呢?使用deque最合适,7.1节提到了SGI STL中的队列在没有指定容器的情况下,deque就是默认的底层容器。
基于单调队列pop和push的规则,代码如下:
class Myqueue {
public:
deque<int> que;
//每次弹出元素时比较当前要弹出的数值是否等于队列出口的元素,如果相等则弹出
//弹出元素时需要判断队列当前是否为空
void pop(int value) {
if(!que.empty() && value == que.front()) {
que.pop_front();
}
}
//如果push的元素value大于入口元素,那么就将队列入口的元素弹出,直至push元素的值小于或等于队列入口元素的数值
//这样就保持了队列中的数值是从大到小单调递减了
void push(int value) {
while (!que.empty() && value > que.back()) {
que.pop_back();
}
que.push_back(value);
}
//查询当前队列的最大值,直接返回队列的出口元素(前端)
int front() {
return que.front();
}
};
这样就用deque实现了一个单调队列,接下来用它来求解滑动窗口最大值的问题就简单了。
题解
class Solution {
private:
class Myqueue {
public:
deque<int> que;
//每次弹出元素时比较当前要弹出的数值是否等于队列出口的元素,如果相等则弹出
//弹出元素时需要判断队列当前是否为空
void pop(int value) {
if(!que.empty() && value == que.front()) {
que.pop_front();
}
}
//如果push的元素value大于入口元素,那么就将队列入口的元素弹出,直至push元素的值小于或等于队列入口元素的数值
//这样就保持了队列中的数值是从大到小单调递减了
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;//result记录的是滑动窗口每次移动后的最大元素
for (int i = 0; i < k; i++) {//先将第一个滑动窗口的所有元素(k个)放入队列
que.push(nums[i]);//调用单调队列的push()
}
//此时que中存放的是经过排序的滑动窗口的元素(有可能成为最大值的元素)
result.push_back(que.front());//result,记录前k个元素的最大值
for (int i = k; i < nums.size(); i++) {
que.pop(nums[i-k]);//单调队列按它的规则移除滑动窗口最前面的元素(即将不属于这个滑动窗口的元素)i-k(有可能不弹出)
que.push(nums[i]);//单调队列按它的规则加入滑动窗口新包含的元素(有可能要弹出单调队列中的元素)
result.push_back(que.front());//记录对应的最大值
}
return result;
}
};
使用单调队列的时间复杂度和空间复杂度都为O(n)。有的读者会想,在调用push的过程中还有一个while循环,感觉时间复杂度不是纯粹的O(n)。其实观察单调队列的实现,nums中的每个元素最多也就被push_back和pop_back各一次,没有多余操作,所以时间复杂度还是O(n)。
扩展
我们设计的单调队列的接口pop和push接口仅适用于本题。单调队列不只是这一种实现方式,要根据不同的题目进行不同的实现。总之,根据单调递减或单调递增原则的队列就叫做单调队列。
我在此出一个题目:滑动窗口的最小元素,基于本题的单调队列该怎么实现?
力扣题号: 347.前k个高频元素
给你一个整数数组 nums 和一个整数 k ,请你返回其中出现频率前 k 高的元素。你可以按 任意顺序 返回答案。
示例 1:
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
示例 2:
输入: nums = [1], k = 1
输出: [1]
提示:
1 <= nums.length <= 105
k 的取值范围是 [1, 数组中不相同的元素的个数]
题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的
进阶:你所设计算法的时间复杂度 必须 优于 O(n log n) ,其中 n 是数组大小。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/top-k-frequent-elements
思路
上小节我们学习了单调队列的用法,这下节我们学习优先级队列。
这道题主要涉及如下三部分内容:
1.统计元素出现的次数
可以使用map进行统计。
2.对次序进行排序
这里可以使用一种容器设配器——即优先级队列,它就是一个披着队列外衣的堆,因为优先级队列对外提供的接口只有从队头取元素、从队尾添加元素,再无其他取元素的方式,所以看起来像一个队列。优先级队列中的元素自动依照元素的权值进行排列,它是如何排序的呢?默认情况下啊,priority_queue利用max_heap(大顶堆)完成对元素的排序。
3.找出前k个高频元素
背景知识
这里介绍一下关于堆的背景知识。堆是一颗完全二叉树,树中的每个节点都不小于(或不大于)其左右孩子的值。如果父节点大于或等于左右孩子的值,那么就是大顶堆,否则就是小顶堆。所以大顶堆的堆头为最大元素,小顶堆的堆头为最小元素。在C++中,优先级队列其实 就是堆,底层实现都是一样的,元素从小到大排列就是小顶堆。
本题我们就使用优先级队列对部分元素出现的次数进行排序。为什么不使用快排呢?使用快排就要将map转换为vector的结构,然后对整个数组进行排序,而在本题的背景下,我们只需维护k个有序序列,所以用优先级队列是最优的。
那我们是用大顶堆还是小顶堆呢?乍一看要用大顶堆,可仔细想想,如果我们定义了大小为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) {
//定义map来统计元素出现的次数,并不需要key有序,所以用unordered_map效率更高(哈希表)
unordered_map<int, int> map;//map
for (int i = 0; i < nums.size(); i++) {
map[nums[i]]++;
}
//对元素出现的次数进行排序
//定义一个小顶堆,大小为k
//priority_queue,分别为数据类型,容器类型,比较方式。其中容器类型必须用数组实现的容器,如vector,deque等,但不能用list
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++) {//可以用auto
pri_que.push(*it);//将迭代器(指针)指向的内容加入优先级队列、解引用
if (pri_que.size() > k) {//保证优先级队列(堆)的大小一直为k
pri_que.pop();
}
}
//此时,小顶堆里只剩k个最大元素,找出前k个高频元素需要将里面的元素倒序输入进result数组中, 弹出的是最小的元素输入进数组尾
vector<int> result(k);
for (int i = k - 1; i >= 0; i--) {
result[i] = pri_que.top().first;
pri_que.pop();
}
return result;
}
};
时间复杂度O(nlogk),空间复杂度O(n)。
力扣题号: 42.接雨水
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
示例 2:
输入:height = [4,2,0,3,2,5]
输出:9
提示:
n == height.length
1 <= n <= 2 * 104
0 <= height[i] <= 105
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/trapping-rain-water
本题有三种常见解法:双指针法、动态规划法和单调栈解法。双指针法前面讲解过,动态规范法还没讲解,这章的主题是栈与队列,所以着重讲解单调栈解法。
首先应该明确应该怎么计算雨水的面积,是按行计算还是按列计算。在用双指针法解答本题时,很容易一会按行计算一行又按列计算,越写越乱。其实按行和按列都可以,只不过要始终坚持一个方向。下面给出按列计算雨水面积的思路。
思路
如果按照列来计算,那么列的宽度一定是1,再求每一列的高度即可。每一列雨水的高度取决于该列左侧最高的柱子和右侧最高的柱子之间最矮的柱子的高度。 如题目描述的图,列3左侧最高的柱子是列2,高度为2(lHeight),列3右侧最高的柱子是列6,高度为3(rHeight),列3高度为1(height)。列3的雨水高度为列2和列6的高度的最小值减去列3的高度,即min(3,2)-1=1,抽象为公式为——min(lHeight,rHeight)-height。列3雨水的高度和列的宽度相乘就是列5的雨水面积。
同样的方法,只要从头遍历一遍所有列,注意第一个柱子和最后一个柱子不接雨水,然后求出每一列的雨水面积再求和就是总雨水的面积。
题解
class Solution {
public:
int trap(vector<int>& height) {
int sum = 0;//总雨水面积
for (int i = 1; i < height.size() - 1; i++) {//第一个柱子和最后一个柱子不接雨水
int rHeight = height[i];//记录右边最高柱子的高度
int lHeight = height[i];//记录左边最高柱子的高度
for (int j = i + 1; j < height.size(); j++) {
if (rHeight < height[j]) rHeight = height[j];
}
for (int k = i - 1; k >= 0; k--) {
if (lHeight < height[k]) lHeight = height[k];
}
//此时lHeight和rHeight分别记录的就是i这列左侧最高柱子的高度和右侧最高柱子的高度
//按照我们抽象出来的公式计算
int h = min(lHeight,rHeight) - height[i];
if (h > 0) sum += h;
}
return sum;
}
};
只不过这种解法时间复杂度为O(n2),空间复杂度为O(1),在力扣上会超时。
在上小节介绍的双指针法中,我们每遍历一列都向其两边遍历一遍,寻找最高的柱子,这其中是有重复的。如果我们把每列左边的最高柱子记录在一个数组(maxLeft)中,把每列右边的最高柱子记录在另一个数组(maxRight)中,这样我们通过这两个数组就能知道每列左边右边最高的柱子,避免了重复计算,这就用到了动态规划。
当前位置的左边最高高度是前一个位置的左边最高高度和本列高度比较后的最大值。
从左到右遍历:maxLeft[i]=max(height[i],maxLeft[i-1])
从右到左遍历:maxRight[i]=max(height[i],maxRight[i+1])
题解
class Solution {
public:
int trap(vector<int>& height) {
if (height.size() <= 2) return 0;
int size = height.size();
vector<int> maxLeft(size,0);
vector<int> maxRight(size,0);
maxLeft[0] = height[0];
//从左到右遍历,记录每列左边柱子的最大高度
for (int i = 1; i < size; i++) {
maxLeft[i] = max(height[i], maxLeft[i - 1]);
}
//从右到左遍历,记录每列右边柱子的最大高度
maxRight[size-1] = height[size - 1];
for (int i = size -2; i > = 0) {
maxRight[i] = max(height[i], maxRight[i + 1]);
}
//利用之前的结果,求和
int sum = 0;
for (int i = 0; i < size; i++) {
int h = min(maxLeft[i], maxRight[i]) - height[i];
if (h) sum += h;
}
return sum;
}
};
单调栈就是保持栈内的元素有序,和单调队列一样,没有现成的数据结构,需要我们自己实现。
需要注意几点:
1. 之前我们都是按列来计算雨水面积,但单调栈只能按行方向计算雨水。
栈内的元素顺序是升序还是降序?因为一旦发现新添加的柱子高度大于栈头元素,就说明此时出现凹槽了,栈头元素就是凹槽底部的柱子,栈头的第二个元素就是凹槽底部左侧的柱子,而新添加的柱子就是凹槽右边的柱子。例如:栈内元素为1,0,新添加的柱子为2,就会形成列1这个凹槽接雨水。
2. 遇到相同的柱子怎么办?遇到相同的柱子就更新栈内元素,即将栈内元素(旧下标)弹出,将新元素(新下标)。例如:[5,5,1,3]这种情况,在添加第二个5的时候,就应该将第一个5弹出,将第二个5压入栈内。因为求雨水行方向宽度的时候,如果遇到相同高度的柱子,一定是用最右边的柱子计算宽度。
3. 栈内要保存什么数值?使用单调栈,其实就是通过长*高来计算雨水面积。长是通过柱子的高度计算的,宽是通过柱子之间的下标来计算的,那么栈中有没有必要存放一个键值对类型的元素,用于保存柱子的高度和下标呢?其实不用,栈中存放int类型的元素即可,即下标。通过height[stack.pop()],就知道弹出下标对应的高度了。
思路
弄清楚以上几点我们来看处理逻辑。首先将下标0的柱子加入栈,然后从下标1开始遍历所有柱子。
如果当前遍历的柱子高度小于栈顶元素的高度,就把这个元素加入栈中。因为栈中本来就要保持从栈顶到栈底从小到大的顺序;如果当前遍历的柱子高度等于栈顶元素的高度,就把旧的栈顶弹出,把新的元素加入栈中,因为需要最右边的柱子来计算行方向宽度;
如果当前遍历的柱子高度大于栈顶元素的高度,说明出现凹槽了,该计算雨水了。首先将栈顶元素弹出,这是凹槽底部柱子的下标,记为mid,height[mid]就是凹槽底部柱子的高度。再将下一个栈顶元素下标记为st.top(),这就是凹槽左边的柱子,对应的高度为height[st.top()],当前遍历的元素就是凹槽右边的柱子,下标为i,对应高度为height[i]。
雨水的高度就是h = min(height[st.top()],height[i]) - height[mid]
雨水的宽度就是w = i - st.top() - 1
雨水的总面积就是h*w
题解
class Solution {
public:
int trap(vector<int>& height) {
int size = height.size();
if (size <= 2) return 0;
stack<int> st;//单调栈,存放柱子下标,可根据下标和height得出柱子高度
st.push(0);//先将下标0入栈
int sum = 0;
for (int i = 1; i < size; i++) {
if (height[i] < height[st.top()]) st.push(i);
else if (height[i] == height[st.top()]) {
st.pop();
st.push(i);
}
else {
while (!st.empty() && height[i] > height[st.top()]) {//注意这里是while循环,而不是if,因为可能有多行雨水
int mid = st.top();
st.pop();
if (!st.empty()) {
int h = min(height[st.top()], height[i]) - height[mid];
int w = i - st.top() - 1;
sum += h * w;
}
}
//在计算完雨水后再将新柱子加入栈中
st.push(i);
}
}
return sum;
}
};
用时比动态规划法慢。
上述代码冗余了一些,但思路非常清晰,精简之后的代码如下:
题解
class Solution {
public:
int trap(vector<int>& height) {
stack<int> st;//单调栈,存放柱子下标,可根据下标和height得出柱子高度
st.push(0);//先将下标0入栈
int sum = 0;
for (int i = 1; i < height.size(); i++) {
while (!st.empty() && height[i] > height[st.top()]) {//注意这里是while循环,而不是if,因为可能有多行雨水
int mid = st.top();
st.pop();
if (!st.empty()) {
int h = min(height[st.top()], height[i]) - height[mid];
int w = i - st.top() - 1;
sum += h * w;
}
}
//在计算完雨水后再将新柱子加入栈中
st.push(i);//情况一和二都包含到此,只不过情况二遇到相等高度的柱子不再弹出而是像情况一那样直接加入,单调栈内存放的是单调不增的元素
}
return sum;
}
};
精简之后的代码判断条件和进出站少了,所以用时减少,比动态规划快的多,只不过不利于理解。
本章讲解了栈和队列的基础理论,可以出一道面试题:栈内的元素在内存中是连续分布的吗?
这个问题有两个陷阱:1.栈是容器适配器,底层容器使用不同的容器,决定了栈内数据在内存中是不是连续的。2.在默认情况下,默认底层容器是deque,deque在内存中的数据分布是不连续的。vector是连续的。
了解栈与队列的基础知识后,可以用7.2节和7.3节的题目练习一下栈和队列的基础操作。通过7.4节和7.5节的讲解,可以看出栈在计算机领域的应用是非常广泛的,特别是解决匹配问题。7.6节主要是解决获取滑动窗口最大值问题,核心思想是单调队列没必要维护窗口的所有元素,只需维护可能成为最大值的元素即可,同时保证队列中的元素数值由大到小排序的。7.7节通过求前k个高频元素,引出另一种队列即优先级队列,本质上是堆。7.8节是面试中经常出现的题目,最快的解法是单调栈。