剑指offer刷题笔记
前言
主要刷题平台为 牛客网,部分题目使用 LeetCode 和 ACwing 作为辅助。每题均包含主要思路、详细注释、时间复杂度和空间复杂度分析,每题均是尽可能最佳的解决办法。
重复数字I
链接:
题目:
给定一个长度为 n 的整数数组
nums
,数组中所有的数字都在 0∼n−1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。
请找出数组中任意一个重复的数字。
注意:如果某些数字不在 0∼n−1 的范围内,或数组中不包含重复数字,则返回 -1;
思路:
先将每个数字都放到对应的位置上,然后如果在放的过程中,发现该位置已经有数字了,那么就说明这个数字是多余的了
class Solution {
public:
int duplicateInArray(vector<int>& nums) {
//每个数放到对应的位置上,如 时间复杂度 O(1)
int n=nums.size();
for(int i=0;i<n;i++)
{
//如果超出数据范围
if(nums[i]<0||nums[i]>=n)
{
return -1;
}
}
for(int i=0;i<n;i++)
{
//将每个数字都放到对应的位置上
while(i!=nums[i]&&nums[nums[i]]!=nums[i])
{
//如果该位置已经无法交换,就是 该位置上的数字已经是 对应的数字了
swap(nums[i],nums[nums[i]]);
}
//当前位置已经有对应数字了
if(nums[i]!=i)
{
return nums[i];
}
}
return -1;
}
};
时间复杂度 O ( n ) O(n) O(n),额外空间复杂度 O ( 1 ) O(1) O(1)
重复数字II
题目:
给定一个长度为 n+1的数组
nums
,数组中所有的数均在 1∼n 的范围内,其中 n≥1。请找出数组中任意一个重复的数,但不能修改输入的数组。
思路:
有n+1个数字,但是数字范围在1~n,所以一定会有一个重复的数字,我们要 做的工作就是找到这个重复数字,
我们采用分治的思想,将每个数的取值的区间[1, n]划分成[1, n/2]和[n/2+1, n]两个子区间,然后分别统计两个区间中数的个数。
注意这里的区间是指 数的取值范围,而不是 数组下标。
划分之后,左右两个区间里一定至少存在一个区间,区间中数的个数大于区间长度。
class Solution {
public:
int duplicateInArray(vector<int>& nums) {
//[l,mid] 和 [mid+1,r]
int l=1,r=nums.size()-1;
//整数二分
while(l<r)
{
int mid=l+r>>1;
int s=0;
//统计左边数字数量
for(auto x:nums)
{
//s += x >= l && x <= mid;
if (x >= l && x <= mid)
s ++ ;
}
if(s>mid-l+1)
{
//重复数字在左半边
r=mid;
}
else
{
l=mid+1;
}
}
return r;
}
};
时间复杂度:每次会将区间长度缩小一半,一共会缩小
O
(
l
o
g
n
)
O(logn)
O(logn) 次。每次统计两个子区间中的数时需要遍历整个数组,时间复杂度是
O
(
n
)
O(n)
O(n)。所以总时间复杂度是
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)
空间复杂度:代码中没有用到额外的数组,所以额外的空间复杂度是
O
(
1
)
O(1)
O(1)
二维数组中的查找
题目:
在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。
请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
数据范围:二维数组中元素个数范围 [0,1000]
思路:
暴力搜索的时间复杂度是 O ( n 2 ) O(n^2) O(n2), 由于数据不是很高,所以可以通过:
class Solution {
public:
bool searchArray(vector<vector<int>> array, int target) {
for(int i=0;i<array.size();i++)
{
for(int j=0;j<array[i].size();j++)
{
if(array[i][j]==target)
{
return true;
}
}
}
return false;
}
};
这里是有时间复杂度可以达到
O
(
n
)
O(n)
O(n),因为题目中的二维数组 是有单调性的,所以我们可以从 右上角 的那个元素入手,如果 target
比右上角的元素大,则说明 target
不在该行,如果target
比右上角元素小,则说明target
不在该列,就可以实现要么排除掉 一行,要么排除掉一列
class Solution {
public:
bool searchArray(vector<vector<int>> array, int target) {
if(array.empty()||array[0].empty())
{
return false;
}
int i=0,j=array[0].size()-1;
while(j>=0&&i<array.size())
{
if(array[i][j]>target)
{
j--;
}
else if(array[i][j]<target)
{
i++;
}
else
{
return true;
}
}
return false;
}
};
替换空格
题目:
请实现一个函数,把字符串
s
中的每个空格替换成"%20"。
思路:
比较简单,使用额外空间:
class Solution {
public:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param s string字符串
* @return string字符串
*/
string replaceSpace(string s) {
// write code here
string result;
for(auto x:s)
{
if(x==' ')
{
result+="%20";
}
else
{
result+=x;
}
}
return result;
}
};
用时间换空间,在原数组上操作,不使用额外的内存空间
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();
//从后向前替换为 “%20”
for (int i=sNewSize-1,j=sOldSize-1;j<i;i--,j--)
{
//i 是新字符串(扩容后,后面全部都是空格)的最后一个位置, j 是旧字符串(扩容前)的最后一个位置,现在就是把就字符串依次后移,如果遇到空格,就替换为 %20
if (s[j]!=' ')
{
//将字符串中的字符后移
s[i]=s[j];
}
else
{
s[i]='0';
s[i-1]='2';
s[i-2]='%';
i-=2;
}
}
return s;
}
};
打印链表
链接:
题目:
输入一个链表的头节点,从尾到头反过来返回每个节点的值(用数组返回)。
思路:
如果要使用 容器的性质,可以很轻易的实现
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
class Solution {
public:
vector<int> reversePrint(ListNode* head) {
vector<int> result;
ListNode* cur=head;
while(cur!=nullptr)
{
result.push_back(cur->val);
cur=cur->next;
}
//反转容器中的数据
return vector<int>(result.rbegin(),result.rend());
}
};
*重建二叉树
链接:重建二叉树
题目:
给定节点数为 n 的二叉树的前序遍历和中序遍历结果,请重建出该二叉树并返回它的头结点。
例如输入前序遍历序列{1,2,4,7,3,5,6,8}和中序遍历序列{4,7,2,1,5,3,8,6},则重建出如下图所示。
/**
* Definition for binary tree
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
TreeNode* traversal(vector<int>& vin,int vinBegin,int vinEnd,vector<int>& pre,int preBegin,int preEnd)
{
if(preBegin==preEnd)
{
return nullptr;
}
TreeNode* root=new TreeNode(pre[preBegin]);
//在 中序数组中寻找 根节点
int delimeterIndex=0;
for(delimeterIndex=vinBegin;delimeterIndex<vinEnd;delimeterIndex++)
{
if(vin[delimeterIndex]==pre[preBegin])
{
break;
}
}
//切割中序数组,因为要使用递归,所以一定要使用函数内的变量
int leftVinBegin=vinBegin; //左闭右开
int leftVinEnd=delimeterIndex;
int rightVinBegin=delimeterIndex+1;
int rightVinEnd=vinEnd;
//切割前序数组
int leftPreBegin=preBegin+1; //左闭右开
int leftPreEnd=preBegin+1+(delimeterIndex-vinBegin); //终止位置为 起始位置+中序左区间大小
int rightPreBegin=preBegin+1+(delimeterIndex-vinBegin);
int rightPreEnd=preEnd;
root->left=traversal(vin, leftVinBegin, leftVinEnd,pre,leftPreBegin,leftPreEnd);
root->right=traversal(vin, rightVinBegin, rightVinEnd,pre,rightPreBegin,rightPreEnd);
return root;
}
TreeNode* reConstructBinaryTree(vector<int> pre,vector<int> vin) {
if(pre.size()==0||vin.size()==0)
{
return nullptr;
}
return traversal(vin,0,vin.size(),pre,0,pre.size());
}
};
二叉树的下一个结点
题目:
给定一个二叉树其中的一个结点,请找出中序遍历顺序的下一个结点并且返回。注意,树中的结点不仅包含左右子结点,同时包含指向父结点的next指针。下图为一棵有9个节点的二叉树。树中从父节点指向子节点的指针用实线表示,从子节点指向父节点的用虚线表示
最重要的就是要分开讨论不同的情况,实现所有要求,一定要记清 中序遍历的顺序:左-中-右
如果该节点有右子树,那么下一个结点就是右子树中的 最左边的 左孩子
如果该节点没有右子树,那么下一个节点就要看父节点了,如果当前节点是父节点的左孩子,那么下一个结点就是 当前节点的 父节点(不一定是直接父节点)
/*
struct TreeLinkNode {
int val;
struct TreeLinkNode *left;
struct TreeLinkNode *right;
struct TreeLinkNode *next;
TreeLinkNode(int x) :val(x), left(NULL), right(NULL), next(NULL) {
}
};
*/
class Solution {
public:
TreeLinkNode* GetNext(TreeLinkNode* pNode) {
//根据中序遍历,如果有右子树,那么右子树的左孩子(最左边的左孩子)J就是下一额节点
if(pNode->right)
{
pNode=pNode->right;
//右子树的最左边的孩子
while(pNode->left)
{
pNode=pNode->left;
}
return pNode;
}
//直到找到当前节点 是 父节点 的左孩子
// while(pNode->next&&pNode==pNode->next->right)
// {
// //第一个当前节点是父节点左孩子的节点
// pNode=pNode->next;
// }
// return pNode->next;
//没有右子树,只有左子树
while(pNode->next)
{
//是父节点的左子树,那么下一个结点就是 父节点
if(pNode==pNode->next->left)
{
return pNode->next;
}
//不是父节点的左子树就继续找父节点
pNode=pNode->next;
}
//没有父节点
return nullptr;
}
};
两个栈实现队列
题目:
用两个栈来实现一个队列,使用n个元素来完成 n 次在队列尾部插入整数(
push
)和n次在队列头部删除整数(pop
)的功能。 队列中的元素为int
类型。保证操作合法,即保证pop操作时队列内已有元素。数据范围: n≤1000
要求:存储n个元素的空间复杂度为 O(n) ,插入与删除的时间复杂度都是 O(1)
思路:
一定要先想清楚再下手,我们要用栈(先进后出)实现队列(先进先出),一个 栈 肯定是完不成的,必须要使用两个栈来实现,我们可选择将所有的 入队 都放在stack1
中,然后如果要 出队,就可以将 stack1
中的内容 再放到 stack2
中,这样放入stack2
中的 从顶到底的顺序,就是 队列 出队的顺序了,只要 stack2
不是空的,就可以确保 stack2
的顺序是出队顺序,如果stack2
空了,就继续将 stack1
中的元素放入 stack2
class Solution
{
public:
void push(int node) {
//新数据都放入 stack1 中
stack1.push(node);
}
int pop() {
//由于数据都是放在 stack1 中的,我们要进行出队,就是stack1 中的栈底元素,我们将 stack1 中的元素全部发放到
//stack2 中之后,stack2的遍历顺序就是 队列的出列顺序了
if(stack2.empty())
{
while(!stack1.empty())
{
stack2.push(stack1.top());
stack1.pop();
}
}
//元素出栈之后,要在栈中弹出
int ret=stack2.top();
stack2.pop();
return ret;
}
private:
stack<int> stack1;
stack<int> stack2;
};
斐波那契数列
题目:
大家都知道斐波那契数列,现在要求输入一个正整数 n ,请你输出斐波那契数列的第 n 项。
斐波那契数列是一个满足 f i b ( x ) = { 1 x = 1 , 2 f i b ( x − 1 ) + f i b ( x − 2 ) x > 2 fib(x)=\left\{ \begin{array}{rcl} 1 & {x=1,2}\\ fib(x-1)+fib(x-2) &{x>2}\\ \end{array} \right. fib(x)={1fib(x−1)+fib(x−2)x=1,2x>2的数列
数据范围: 1 ≤ n ≤ 40 1\leq n\leq 40 1≤n≤40
要求:空间复杂度 O ( 1 ) O(1) O(1),时间复杂度 O ( n ) O(n) O(n) ,本题也有时间复杂度 O ( l o g n ) O(logn) O(logn) 的解法
思路:
这个做法是我目前看到的最优的做法了,时间复杂度为
O
(
n
)
O(n)
O(n), 空间复杂度为
O
(
1
)
O(1)
O(1), 因为每一个 斐波那契数字都 只与前面的两个数字有关,所以可以只记录前面的两个,实现降低 空间复杂度(空间复杂度为
O
(
n
)
O(n)
O(n)的做法就是使用 动态规划的做法了)
递归的写法就不再演示了,递归法:时间复杂度:
O
(
2
n
)
O(2^n)
O(2n) 空间复杂度:递归栈的空间
class Solution {
public:
int Fibonacci(int n) {
/*
if(n==1||n==2)
{
return 1;
}
*/
//因为求解只涉及到三个变量,所以使用三个变量来实现
int a=0,b=1;
//对于每一个斐波那契数,只和他前面的两个有关,所以可以进一步压缩空间
while(n--)
{
//a b 交替更新
int c=a+b;
a=b;
b=c;
}
return a;
}
};
空间复杂度 O ( 1 ) O(1) O(1),时间复杂度 O ( n ) O(n) O(n)
动态规划做法:可以根据数据范围开辟对应大小的 dp数组,虽然时间复杂度可以达到要求,但是 空间复杂度明显没有上一个更小
class Solution {
public:
int Fibonacci(int n) {
//根据数据范围确定 dp数组的大小
int dp[40]={0};
dp[0]=0;
dp[1]=1;
dp[2]=1;
//从第3项开始,要包含最后一项
for(int i=3;i<=n;i++)
{
dp[i]=dp[i-1]+dp[i-2];
}
return dp[n];
}
};
旋转数组
题目:
有一个长度为 n 的非降序数组,比如[1,2,3,4,5],将它进行旋转,即把一个数组最开始的若干个元素搬到数组的末尾,变成一个旋转数组,比如变成了[3,4,5,1,2],或者[4,5,1,2,3]这样的。请问,给定这样一个旋转数组,求数组中的最小值。
数据范围: 1 ≤ n ≤ 10000 1 \le n \le 10000 1≤n≤10000,数组中任意元素的值: 0 ≤ v a l ≤ 100000 0 \le val \le 100000 0≤val≤100000
要求:空间复杂度: O ( 1 ) O(1) O(1) ,时间复杂度: O ( l o g n ) O(logn) O(logn)
思路:
直接使用二分查找
class Solution {
public:
int minNumberInRotateArray(vector<int> rotateArray) {
//二分查找
if(rotateArray.size()==0)
{
return 0;
}
int left=0,right=rotateArray.size()-1;
while(left<right)
{
if(rotateArray[left]<rotateArray[right])
{
return rotateArray[left];
}
int mid=left+right>>1;
//int mid=left+((right-left))>>1;
if(rotateArray[right]<rotateArray[mid])
{
left=mid+1;
}
else if(rotateArray[right]>rotateArray[mid])
{
right=mid;
}
else
{
//不能确定答案在左边还是右边,那么就让last = last - 1;慢慢缩少区间,同时也不会错过答案
--right;
}
}
return rotateArray[left];
}
};
空间复杂度: O ( 1 ) O(1) O(1) ,时间复杂度: O ( l o g n ) O(logn) O(logn)
矩阵中的路径
题目:
请设计一个函数,用来判断在一个n乘m的矩阵中是否存在一条包含某长度为 len 的字符串所有字符的路径。路径可以从矩阵中的任意一个格子开始,每一步可以在矩阵中向左,向右,向上,向下移动一个格子。如果一条路径经过了矩阵中的某一个格子,则该路径不能再进入该格子。 例如 [ a b c e s f c s a d e e ] \begin{bmatrix} a & b & c &e \\ s & f & c & s \\ a & d & e& e\\ \end{bmatrix}\quad ⎣⎡asabfdcceese⎦⎤矩阵中包含一条字符串"bcced"的路径,但是矩阵中不包含"abcb"路径,因为字符串的第一个字符b占据了矩阵中的第一行第二个格子之后,路径不能再次进入该格子。
数据范围: 0 ≤ n , m ≤ 20 , 1 ≤ l e n ≤ 25 0 \le n,m \le 20\ ,1\le len \le 25 0≤n,m≤20 ,1≤len≤25
进阶:时间复杂度 O ( n 2 ) O(n^2) O(n2),空间复杂度 O ( n 2 ) O(n^2 ) O(n2)
思路:
深度优先遍历的经典题目
枚举单词的起点,然后依次枚举单词的每个字母。
过程中需要将已经使用过的字母改成一个特殊字母,以避免重复使用字符。
class Solution {
public:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param matrix char字符型vector<vector<>>
* @param word string字符串
* @return bool布尔型
*/
bool dfs(vector<vector<char>>& matrix,string &word,char u,int x,int y)
{
//该路径上的字符不一致
if(matrix[x][y]!=word[u])
{
return false;
}
//这两个判断的先后顺序不能颠倒
//找到该字符串,当前字符匹配且当前为最后一个字符时,直接返回true
if(u==word.size()-1)
{
return true;
}
//上右下左 横为y,纵为x
int idx[4]={-1,0,1,0};
int idy[4]={0,1,0,-1};
//记录当前值
char t=matrix[x][y];
//修改当前坐标的值,防止重复使用
matrix[x][y]='*';
for(int i=0;i<4;i++)
{
//尝试四个方向
int a=x+idx[i];
int b=y+idy[i];
if(a>=0&&a<matrix.size()&&b>=0&&b<matrix[a].size())
{
if(dfs(matrix, word, u+1, a, b))
{
return true;
}
}
}
//恢复现场
matrix[x][y]=t;
return false;
}
bool hasPath(vector<vector<char> >& matrix, string word) {
// write code here
for(int i=0;i<matrix.size();i++)
{
for(int j=0;j<matrix[i].size();j++)
{
if(dfs(matrix,word,0,i,j))
{
return true;
}
}
}
return false;
}
};
时间复杂度分析:单词起点一共有 n 2 n^2 n2 个,单词的每个字母一共有上下左右四个方向可以选择,但由于不能走回头路,所以除了单词首字母外,仅有三种选择。所以总时间复杂度是 O ( n 2 3 k ) O(n^23^k) O(n23k)。
*机器人的运动范围
题目:
地上有一个 rows 行和 cols 列的方格。坐标从 [0,0] 到 [rows-1,cols-1] 。一个机器人从坐标 [0,0] 的格子开始移动,每一次只能向左,右,上,下四个方向移动一格,但是不能进入行坐标和列坐标的数位之和大于 threshold 的格子。 例如,当 threshold 为 18 时,机器人能够进入方格 [35,37] ,因为 3+5+3+7 = 18。但是,它不能进入方格 [35,38] ,因为 3+5+3+8 = 19 。请问该机器人能够达到多少个格子?
思路:
宽度优先遍历的经典题目
要注意判断障碍物,有些格子是不能走的
class Solution {
public:
int get_signal_sum(int x)
{
int s=0;
while(x)
{
s+=x%10;
x/=10;
}
return s;
}
int get_sum(pair<int, int> p)
{
return get_signal_sum(p.first)+get_signal_sum(p.second);
}
int movingCount(int threshold, int rows, int cols) {
int res=0;
if(rows==0||cols==0)
{
return 0;
}
//默认所有格子都是没有走过的
vector<vector<bool>> st(rows,vector<bool>(cols));
queue<pair<int, int>> q; //宽度优先搜索使用的队列
//也可以使用 make_pair
q.push({0,0});
//走的方向: 纵为x 横为y 上右下左
int dx[4]={-1,0,1,0};
int dy[4]={0,1,0,-1};
//循环放入
while(q.size())
{
auto t=q.front();
q.pop();
//如果不符合条件, 就不走这条路
if(get_sum(t)>threshold||st[t.first][t.second]==true)
{
continue;
}
res++;
st[t.first][t.second]=true; //表示走过了
//走的方向
for(int i=0;i<4;i++)
{
int x=t.first+dx[i];
int y=t.second+dy[i];
//判断是否合法的坐标
if(x>=0&&x<rows&&y>=0&&y<cols)
{
q.push({x,y});
}
}
}
return res;
}
};
每个节点最多只会入队一次,所以时间复杂度不会超过方格中的节点个数。
最坏情况下会遍历方格中的所有点,所以时间复杂度就是 O(nm)