LCR 121.寻找目标值 - 二维数组
核心考点:数组相关,特性观察,时间复杂度的把握
正常的查找过程,本质就是排除的过程,如果使用两个for循环进行查找,本质是一次排除一个,效率比较低,而我们根据题目的要求,我们可以采取从右上角(左下角)的值进行比较,拿右上角为例,右上角的值有一个特征,它是一行中最大的值,一列中最小的值,此时我们拿出要查找的值和右上角的值进行比较,此时就能做到排除一行或者一列的值,提高了我们查找的效率。
此时我们直接来上手代码啦。
class Solution {
public:
bool findTargetIn2DPlants(vector<vector<int>>& plants, int target) {
if(plants.size() == 0 || plants[0].size() == 0)
return false;
int i = 0;
int j = plants[0].size() - 1;
// 使用右上角的值进行比较
while(i < plants.size() && j >= 0)
{
// plants[i][j]是当前行最大的,当前列最小的值
if(plants[i][j] < target)
{
i++; // 排除当前行
}
else if(plants[i][j] > target)
{
j--; // 排除当前列
}
else
{
// 找到
return true;
}
}
return false;
}
};
JZ11 旋转数组的最小数字
核心考点:数组理解,二分查找,临界条件
题目解析:数组问题,本质其实是一个求最小值问题
⭐方法一:理论上,遍历一次即可,但是我们可以根据题面使用稍微高效且更简单一点的做法,按照要求,要么是一个非递减排序的数组(最小值在最开始),要么是一个旋转(最小值在中间某个地方),而且,旋转之后有个特征,就是在遍历的时候,原始数组是非递减的,旋转之后,就有可能出现递减,引起递减的数字,就是最小值。
class Solution {
public:
int minNumberInRotateArray(vector<int>& nums) {
for(int i = 1; i < nums.size(); i++)
{
// 查找引起递减的数字
if(nums[i - 1] > nums[i])
return nums[i];
}
// 此时第一个数字最小
return nums[0];
}
};
⭐方法二:采用二分查找的方式,进行定位,定义首尾下标,因为是非递减数组旋转,所以旋转最后可以看做成两部分,前半部分整体非递减,后半部分整体非递减,前半部分整体大于后半部分。
所以,我们假设如下定义,left指向最左侧,right指向最右侧,mid为二分之后的中间位置。
- 如果a[mid] > a[right],此时中间处位置的值大于右端点的值,此时说明该数组一定是被旋转过的,那么就有左端点的值一定大于右端点的值,那么此时就能得出在[left, mid]一定不存在最小值,最小值在mid的右侧,并且mid的值不可能是最小,直接让left = mid + 1;
- 如果a[mid] < a[right],此时中间处位置的值小于右端点的值,说明此时可以说明目标最小值可能就是mid,或者在mid的左侧,此时需要让right=mid
- 如果a[mid] < a[right],此时中间位置的值等于右端点的值,说明此时右端点的这个值一定不是最小值,直接让right--即可。
class Solution {
public:
int minNumberInRotateArray(vector<int>& nums) {
int left = 0;
int right = nums.size() - 1;
while(left < right)
{
int mid = left + ((right - left) >> 1); // 防止溢出
if(nums[mid] > nums[right]) // 说明左边的所有值都大于nums[right]
left = mid + 1;
else if(nums[mid] < nums[right])
right = mid;
else // 值相等情况
right--;
}
return nums[left];
}
};
这个过程,会让[left,right]区间缩小,这个过程中,left永远在原数组前半部分,right永远在原数组的后半部分,而范围会一直缩小,当left和right相邻时,left指向的位置,就是最小元素的位置,但是,因为题目说的是非递减,也就意味着数据允许重复,因为有重复值,就可能会有a[left] == a[mid] ==a[right]的情况,此时我们就不能用单纯的二分算法模板来写这个题目,我们此时需要单独处理一下有重复值的情况,我们上面是采用的右端点进行判断,同时我们也可以使用左端点进行判断。
class Solution {
public:
int minNumberInRotateArray(vector<int>& nums) {
int left = 0;
int right = nums.size() - 1;
int mid = 0;
while(nums[left] >= nums[right])
{
if(right - left == 1)
{
mid = right;
break;
}
mid = left + (right - left) / 2;
// 处理重复值,使用线性探测
if(nums[left] == nums[mid] && nums[mid] == nums[right])
{
for(int i = left + 1; i <= right; i++)
{
// 查找引起递减的数字
if(nums[i - 1] > nums[i])
return nums[i];
}
// 此时第一个数字最小
return nums[left];
}
if(nums[mid] >= nums[left])
left = mid;
else
right = mid;
}
return nums[mid];
}
};
此时我们无法直接解决重复值的情况,只能使用方法一的线性探测去解决重复值的问题。
JZ81 调整数组顺序使奇数位于偶数前面
核心考点:数组操作,排序思想的扩展使用
其实这个题目的原型其实是没有后面的保证奇偶顺序不变的要求,但是现在假如新增了这个要求,此时我们可以利用插入排序的思想来解决这个问题,我们从开始位置开始遍历,当我们遇到一个奇数,把偶数序列向后移动一个单位,腾出这个位置放入奇数,就能保持奇偶相对顺序不变的要求。
class Solution {
public:
vector<int> reOrderArrayTwo(vector<int>& array){
int k = 0;
for(int i = 0; i < array.size(); i++)
{
// 控制[0,k]位置是奇数,[k,n]位置是偶数
// 并保持相对顺序不变
if((array[i] & 1) == 1) // 奇数
{
// 保存当前奇数
int temp = array[i];
int j = i;
// 将该奇数之前的偶数序列整体向后移动一位
while(k < j)
{
array[j] = array[j - 1];
j--;
}
// 此时k位置就可以存放奇数
array[k++] = temp;
}
}
return array;
}
};
JZ39 数组中出现次数超过一半的数字
核心考点:数组使用,简单算法设计
这道题整体思路比较明确,我就直接上思路和代码啦!
⭐思路一:定义map,使用<数字,次数>的映射关系,最后统计每个字符出现的次数
class Solution {
public:
int MoreThanHalfNum_Solution(vector<int>& numbers) {
unordered_map<int, int> hash;
for(auto& e : numbers)
{
hash[e]++; // 统计每个元素出现的次数
if(hash[e] > numbers.size() / 2)
{
return e;
}
}
return -1;
}
};
⭐思路二:排序,出现次数最多的数字,一定在中间位置。然后检测中间出现的数字出现的次数是否符合要求
class Solution {
public:
int MoreThanHalfNum_Solution(vector<int>& numbers) {
sort(numbers.begin(), numbers.end());
// 此时中间位置的值就是出现次数超过一半的次数
return numbers[numbers.size() / 2];
}
};
⭐思路三:目标条件:目标数据超过数组长度的一半,那么对数组,我们同时去掉两个不同的数字,到最后剩下的一个数就是该数字。如果剩下两个,那么这两个也是一样的,就是结果),在其基础上把最后剩下的一个数字或者两个回到原来数组中,将数组遍历一遍统计一下数字出现次数进行最终判断。
class Solution {
public:
int MoreThanHalfNum_Solution(vector<int>& numbers) {
int time = 1;
int ret = numbers[0];
for(int i = 1; i < numbers.size(); i++)
{
if(time == 0)
{
ret = numbers[i];
time = 1;
}
if(ret == numbers[i])
{
time++;
}
else
{
time--;
}
}
return ret;
}
};
JZ5 替换空格
核心考点:字符串相关,特性观察,临界条件处理。
这个题目是字符串操作问题,解决思路:虽然是替换问题,但是生成的字符串整体变长了,因替换内容比被替换内容长,所以,一定涉及到字符串中字符的移动问题,移动方向一定是向后移动,所以现在的问题无非是移动多少的问题,因为是" "->"%20",是1换3,所以可以先统计原字符串中空格的个数(设为n),然后可以计算出新字符串的长度,所以:new_length = old_length + 2*n,最后,定义新老索引(或者指针),各自指向新老空间的结尾,然后进行old->new的移动,如果是空格,就连续放入“%20”,其他平移即可。
当然,C++和Java都有很多容器,也可以从前往后通过开辟空间来进行解决。也就是使用空间来换取时间。但是,我们最好不要在当前场景下这么做。
class Solution {
public:
string replaceSpace(string s) {
int count = 0;
for(auto& str : s)
if(isspace(str))
count++;
int oldsize = s.size() - 1;
int newsize = oldsize + count * 2;
s.resize(newsize + 1);
while(oldsize >= 0)
{
// 当前位置是空格
if(isspace(s[oldsize]))
{
s[newsize--] = '0';
s[newsize--] = '2';
s[newsize--] = '%';
oldsize--;
}
else // 当前位置不是空格,直接平移
{
s[newsize--] = s[oldsize--];
}
}
return s;
}
};
JZ6 从尾到头打印链表
核心考点:链表相关,多结构混合使用,递归
这个题目整体解决的思路很多,可以使用栈,可以将数据保存在数组,然后再去逆序数组即可,也可以使用递归,但是前两种都是通过第三方数据结构,消耗较大,所以我们这里使用递归的方法来解决这个问题,使用递归的方法来解决只要思路是遇到一个节点,判断一下是否到了尾节点,如果没有遇到到尾节点,继续向下递归,否则加入到输出数组里,此时便完成了逆序输出打印。
/**
* struct ListNode {
* int val;
* struct ListNode *next;
* ListNode(int x) :
* val(x), next(NULL) {
* }
* };
*/
class Solution {
public:
vector<int> printListFromTailToHead(ListNode* head) {
vector<int> ret;
dfs(ret, head);
return ret;
}
void dfs(vector<int>& ret, ListNode* head)
{
if(head == nullptr)
return;
dfs(ret, head->next);
ret.push_back(head->val);
}
};
JZ7 重建二叉树
核心考点:二叉树重建,遍历理解,递归
解题思路:根据root节点,将中序序列划分成vleft,vright两部分中序子序列,随后根据中序子序列长度,将前序序列划分成pleft,pright对应的前序子序列,最后根据划分的序列root->left递归生成和root->right递归生成即可。
细节:在中序序列中,找根节点,可以将数组划分为两部分,前序的第一个节点,是root,能将中序划分为两部分,一棵树,无论前,中,后怎么遍历,元素的个数是不变的,在实际遍历的时候,前,中,后序遍历,各种遍历方式左右子树的节点都是在一起的,所以这里重点是要想清楚下标问题,根据中序,我们能确认左子树的节点个数是:i - vinleft(没有从0开始哦),所以,需要从pleft + 1,连续 i - vinstart 个元素,就是左子树的前序序列,并且我们还能知道从pleft + i - vleft + 1,到pright就是右子树的前序序列。
/**
* struct TreeNode {
* int val;
* struct TreeNode *left;
* struct TreeNode *right;
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* };
*/
class Solution {
public:
TreeNode* reConstructBinaryTree(vector<int>& preOrder, vector<int>& vinOrder) {
int n = preOrder.size();
return dfs(preOrder,vinOrder, 0, n - 1, 0, n - 1);
}
TreeNode* dfs(vector<int>& preOrder, vector<int>& vinOrder, int pleft, int pright, int vleft, int vright)
{
// 递归出口
if(pleft > pright || vleft > vright)
return nullptr;
int rootval = preOrder[pleft];
TreeNode* root = new TreeNode(rootval);
// 划分区间,找出根在中序中的位置
int i = vleft;
for(; i <= vright; i++)
if(vinOrder[i] == rootval)
break;
// 注意:前序序列和中序序列的个数是相同的
// 左子树中序序列 [vleft, i - 1];
// 左子树前序序列 [pleft + 1, pleft + i - 1 - vleft + 1];
// 右子树中序序列 [i + 1, vright];
// 右子树前序序列 [pleft + i - 1 - vleft + 1 + 1, pright];
root->left = dfs(preOrder, vinOrder, pleft + 1, pleft + i - 1 - vleft + 1, vleft, i - 1);
root->right = dfs(preOrder, vinOrder, pleft + i - 1 - vleft + 1 + 1, pright, i + 1, vright);
return root;
}
};
JZ10 斐波那契数列
核心考点:空间复杂度,fib理解,剪纸重复计算
这个题目的解决思路有很多种,有递归方式,也有动归(迭代)方式,我们以此来实现一下这几种方式。
迭代方法:
-
初始化: first 和 second 分别代表数列中的前两项,初始值为 1。
-
循环: 循环次数为需要计算的项数减 2。
-
在循环中,计算 third 为 first 和 second 的和。
-
更新 first 为 second,second 为 third,以便下一轮循环计算。
-
-
返回结果: 循环结束后,second 的值即为所求的斐波那契数列中的指定项。
class Solution {
public:
int Fibonacci(int n) {
int first = 1;
int second = 1;
int third = 1; //此时赋值为n=1或n=2返回结果
while (n > 2) {
third = first + second;
// 更新first和second
first = second;
second = third;
n--;
}
return third;
}
};
递归方法:
-
将问题分解成更小的子问题: 斐波那契数列的第 n 项的值可以通过计算前两项的值(即第 n-1 项和第 n-2 项)并将其相加得到。
-
递归调用自身解决子问题: 为了计算第 n-1 项和第 n-2 项的值,我们可以再次调用 fibonacci 函数,传入相应的参数 n-1 和 n-2,直到遇到递归出口条件。
-
定义递归出口: 当 n <= 1 时,我们已经知道斐波那契数列的第 1 项和第 2 项的值都为 1,因此直接返回 n。
class Solution {
public:
int Fibonacci(int n) {
if(n <= 2)
return 1;
return Fibonacci(n-1) + Fibonacci(n-2);
}
};
-
重复计算: 如上例所示,当我计算fibonacci(5)的时候fibonacci(3) 被计算了 2 次,fibonacci(2) 被计算了 3 次,重复计算会导致效率低下。
-
栈溢出: 递归深度过大时,可能会出现栈溢出问题,特别是当计算较大的斐波那契数列项时。
虽然此时代码可以通过,但是不太优雅,此时我们就可以通过map进行剪枝。
#include <unordered_map>
class Solution {
private:
unordered_map<int, int> map;
public:
int Fibonacci(int n) {
if (n <= 2)
return 1;
int num1 = 0; // 代表n-1位置的数
if (map.count(n - 1)) {
num1 = map[n - 1];
} else {
// 没找到
num1 = Fibonacci(n - 1);
map[n - 1] = num1;
}
int num2 = 0; // 代表n-2位置的数
if (map.count(n - 2)) {
num2 = map[n - 2];
} else {
// 没找到
num2 = Fibonacci(n - 2);
map[n - 2] = num2;
}
return num1 + num2;
}
};
JZ69 跳台阶
核心考点:场景转化模型,模型提取解法,简单dp,变种fib
这个题目是一个明显的动态规划的问题,所以我们按照动态规划的步骤完成题目
- 定义状态:dp[n]表示青蛙跳上第n级台阶的总跳法数
- 状态转移方程:由于青蛙只能跳上1级台阶,也可以跳上2级,所以青蛙跳到第n级台阶只能是从第n-1级台阶跳1级,或者从第n-2级台阶跳2级到达,那么青蛙跳上第n级台阶是跳上第n-1级台阶和跳上n-2级台阶的总跳法数,那么dp[n] = dp[n-1] + dp[n-2]
- 初始值:dp[0] = 1(0台阶,就是起点,到达0台阶的方法只有一种,就是不跳);dp[1] = 1;dp[2] = 2
class Solution {
public:
int jumpFloor(int number) {
vector<int> dp(number + 1); // 多个一个位置的值
// 初始值
dp[0] = dp[1] = 1;
if(number == 0 || number == 1)
return 1;
for(int i = 2; i <= number; i++)
{
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[number];
}
};
我们会发现这个题目的状态转移方程和我们上一题斐波那契数列给我们提供的是一样的,所以我们这个题目也可以使用迭代,或者动归剪纸的思路来解决,这里就交给各位小伙伴啦。
JZ70 矩形覆盖
核心考点:场景转换成模型,特殊情况分析,简单dp
解决思路:用n个2*1的小矩形无重叠地覆盖一个2*n的大矩形,每次放置的时候,无非两种放法,横着放或竖着放,其中,横着放一个之后,下一个的放法也就确定了,故虽然放置了两个矩形,但属于同一种放法,其中,竖着放一个之后,本轮放置也就完成了,也属于一种方法,所以,当2*n的大矩形被放满的时候,它无非就是从上面两种放置方法放置来的。我们继续使用dp来进行处理,当然后续会发现,斐波那契数列的方式也可以处理,因为之前已经讲过,就留给大家完成
- 状态定义:f(n):用n个2*1的小矩形无重叠地覆盖一个2*n的大矩形所用的总方法数
- 状态递推:f(n)= f(n-1)【最后一个竖着放】+ f(n-2)【最后两个横着放】
- 初始化:f(1)= 1,f(2)=2,f(0)=0,注意f(0)我们这个可以不考虑,因为题目上描述了该值,我们根据题目中给出的进行赋值
class Solution {
public:
int rectCover(int number) {
vector<int> dp(number + 1); // 需要多给一个位置
// 初始化
dp[0] = 0;
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i <= number; i++)
{
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[number];
}
};
这不就是斐波那契数列问题吗?我们反思一下,很多问题会包裹很多现实问题,解决问题的第一步往往是从实际问题中提炼出我们的解决问题的数学模型,然后在解决,这里也可以使用多种方法解决,不过我们这里重点用dp,倒不是说他是最优的,而是我们平时在写代码的时候,这种思想
用得少,我们就多用用,正所谓熟能生巧。