一、编程语言
关于C++的知识问题另有整理,本文章不做赘述。
面试题1.赋值运算符函数
考点
1.返回值类型为该类型的引用,并在函数结束前返回实例自身的引用(*this)。只有返回一个引用才允许连续赋值,否则如果返回值是void则不能连续赋值。
2.传入的参数类型声明为常量引用。如果传入的参数不是引用而是实例,从形参到实参会调用一次复制构造函数,造成消耗。同时我们在赋值运算符函数内不会改变传入的实例状态,所以传入的引用参数应该加上const关键字。
3.释放实例自身已有的内存。如果分配新内存之前忘记释放自身已有的空间就会造成内存泄漏。
4.判断传入的参数和当前实例(*this)是不是同一个实例。如果是同一个,事先没有判断,一旦释放了自身的内存,传入的参数也同时被释放了,就再也找不到需要赋值的内容了。
(1)初级写法:
class CMyString
{
public:
CMyString(char* pData = nullptr){}
CMyString(const CMyString& str){}
CMyString& operator=(const CMyString& str)
{
if (this == &str) return *this;
//析构对象
delete[]m_pData;
m_pData = nullptr;
//重新构建
m_pData = new char(strlen(str.m_pData)+1);
m_pData = str.m_pData;//私有数据成员逐一赋值
return *this;
}
~CMyString(void){}
private:
char* m_pData;
};
(2)考虑异常安全的写法:
前面的写法我们在分配内存前已经释放了实例的内存,然而如果此时内存不足抛出异常,m_pData将是一个空指针,这样程序容易崩溃。两种解决办法:
1.先new分配新内容,再delete释放已有内容
2.先创建一个临时实例,再交换临时实例和原来的实例。
class CMyString
{
public:
CMyString(char* pData = nullptr) {}
CMyString(const CMyString& str) {}
CMyString& operator=(const CMyString& str)
{
if (this != &str)
{
CMyString strtemp(str);//用str拷贝构造tmp
//交换二者的资源
char* ptmp = strtemp.m_pData;
strtemp.m_pData = this->m_pData;
m_pData = ptmp;
}
return *this;
}
~CMyString(void) {}
private:
char* m_pData;
};
测试用例:
1.把一个对象赋值给另一个对象
2.把一个1对象赋值给自己
3.连续赋值A=B=C
面试题2.实现Singleton(单例)模式
一是单例模式的类只提供私有的构造函数
二是类定义中含有一个该类的静态私有对象
三是该类提供了一个静态的公有的函数用于创建或获取它本身的静态私有对象。
class A
{
protected:
A(){ cout << "A" << endl; }
A(const A&) = delete;//删除拷贝构造
public:
A& operator=(const A&) = delete;//删除赋值重载
public:
static A& fn()
{
static A a;
return a;
}
};
void main()
{
A& b = A::fn();
A& bb = A::fn();//没有构造成功
}
二、数据结构
1.数组
面试题3.数组中重复的数字
最粗暴的方式就是双层循环,这显然不是面试官想要的;
一个简单的解决方式就是先给他排个序再遍历,排序的时间复杂度是O(nlogn);
也可用哈希表,但是用O(1)的时间来判断但是需要以一个大小为O(n)的哈希表为代价;
3.1不要求数组结构是否改变
(1)哈希表,时间复杂度O(1),空间复杂度O(n)
int Search(const int* nums, int len)
{
if (nums == nullptr || len <= 0) return -1;
unordered_map<char, int> dic;
for (int i = 0; i < len; i++)
{
//键为nums[i]的键值(个数)加一
dic[nums[i]]+=1;
}
for (auto i : dic)//i遍历哈希表,i第一个元素frist表示键,第二个元素second表示键值
{
if (i.second > 1)
return i.first;
}
return -1;
}
void main()
{
int nums[] = { 2,3,5,4,3,2,6,7 };
int i = Search(nums, 8);
cout << i << endl;
}
(2)交换寻找,i下标的数字如果不是i,就和nums[i]下的数字交换,直到i下标数字等于i,交换好了后继续向后遍历发现i又≠nums[i]了,但是num[i]下标下的数字已经是i了,此时就说明这个数字重复了
由于数组里的数字范围是0~n-1,如果没有重复数字,那排列后i下标的数字就是i;
我们依次扫描数组中的每个数字(num),下标记为i,如果num等于i,继续扫描,如果num不等于i,把第i个数字(num)和第num个数字交换。一直这样比较下去我们就能发现重复数字了。
int Search(int* nums,int n)
{
if (nums == nullptr || n <= 0) return -1;
int i = 0;
while (i < n)
{
while (nums[i] != i)
{
if (nums[i] != nums[nums[i]])
{
std::swap(nums[i], nums[nums[i]]);
}
else
return nums[i];
}
i++;
}
return -1;
}
void main()
{
//int nums[] = { 1,2,3,0,3,2,6 };
//int nums[] = { 0,1,2,3,4,5,6 };
//int nums[1];
int nums[10] = { 1,7,3,2,1,5 };
int i = Search(nums, 6);
cout << i << endl;
}
3.2不修改数组找出重复数字(类似二分法) (有递归和非递归两种方式实现)
//O(n)
//该数组中[left,right]数字个数,比如1,2,3,4,4;[1,2]个数为2,所以重复的数字在[3,4]
int CountRange(const int* nums, int len, int left, int right)
{
if (nums == nullptr) return 0;//一定要记得边界问题的讨论
int count = 0;
for (int i = 0; i < len; i++)
{
if (nums[i] >= left && nums[i] <= right)
{
count++;
}
}
return count;
}
//非递归
int Search(const int* nums, int len)
{
if (nums == nullptr || len <= 0) return -1;
int left = 1, right = len - 1;//数字大小的范围是1—len-1
int middle = 0;
int count = 0;
while (left <= right)
{
middle = ((right - left) >> 1) + left;//1-4,5-7
count = CountRange(nums, len, left, middle);
if (left == right)//已经找到最后了
{
if (count > 1)
return left;
else
break;
}
//假如总共8个数字,大小为1-4的数字个数超过了4个,说明重复的数字大小为1-4,否则就是5-7.
if (count > (middle - left + 1))
right = middle;
else
left = middle + 1;//注意,left变成mid+1,而不是mid
}
return -1;
}
//递归
int RecuSearch(const int* nums, int len,int left, int right)
{
if (nums == nullptr) return -1;
int middle = ((right-left)>>1)+left;
int count = CountRange(nums,len,left,middle);
if (left == right)
{
if (count > 1)
return left;
else
return -1;
}
if (count > (middle - left + 1))
return RecuSearch(nums, len,left, middle);//左半边找
else
return RecuSearch(nums, len,middle + 1, right);//右半边找
}
//总体时间复杂度O(nlogn),空间复杂度为O(1),所以这是一种时间换空间;主要看面试官要求,如果它要求空间换时间就用哈希表
void main()
{
int nums[] = { 2,3,5,4,3,2,6,7 };
//int i = Search(nums, 8);
int i = RecuSearch(nums, 8, 1, 7);
cout << i << endl;
}
面试题4.二维数组中的查找
补充一下:二维数组的行数:matrix.size();列数:matrix[0].size()
class Solution {
public:
bool findNumberIn2DArray(vector<vector<int>>& matrix, int target) {
if(matrix.empty()) return false;//一定要记得判空
int i=0,j=matrix[0].size()-1;//从二维数组右上角那个数字开始,左下角也行
while(i<matrix.size()&&j>=0)
{
if(target>matrix[i][j]) i++;
else if(target<matrix[i][j]) j--;
else return true;
}
return false;
}
};
2.字符串
面试题5.替换空格
class Solution {
public:
string replaceSpace(string s) {
if(s.length()==0) return s;
//时间复杂度O(n),空间复杂度也是O(n)
string newstr;
for(int i=0;i<s.length();i++)
{
if(s[i]!=' ')
{
newstr+=s[i];
}
else
newstr+="%20";//字符串要用""
}
return newstr;
}
};
3.链表
面试题6.从尾到头打印链表
首先看面试官的要求,是否可以改变链表结构:
1.如果可以改变,那就先①反转链表再遍历打印;
2.如果链表不可改变,可②使用栈“后进先出”的特点,遍历链表,节点的值依次入栈最后依次出栈打印。也可以③用递归编写,因为递归本质就是一个栈结构。
(1)第一种解法:先反转链表,再依次遍历打印
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr) return nullptr;
ListNode* newhead = head;
ListNode* p = head->next;
newhead->next = nullptr;
while (p != nullptr)
{
ListNode* q = p->next;
p->next = newhead;
newhead = p;
p = q;
}
return newhead;
}
void reversePrint(ListNode* head) {
head = reverseList(head);
while (head != nullptr)
{
cout << head->val << "->";
head = head->next;
}
cout << endl;
}
};
(2)第二种解法:使用栈“后进先出”的特点,遍历链表,节点的值依次入栈最后依次出栈打印。
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(NULL) {}
};
class Solution {
public:
void reversePrint(ListNode* head) {
stack<int> ss;
struct ListNode* p = head;
while (p != NULL)
{
ss.push(p->val);//入栈
p = p->next;
}
while (!ss.empty())
{
cout << ss.top() << "->";
ss.pop();
}
cout<<endl;
}
};
(3)第三种解法:用递归编写,每访问一个节点的时候先递归输出它后面的节点再输出本身。
class Solution {
public:
void reversePrint(ListNode* head) {
if (head != nullptr)
{
if (head->next != nullptr)
{
reversePrint(head->next);
}
cout << head->val <<" ";
}
cout << endl;
}
};
4.树
面试题7.重建二叉树
用C语言实现
int FindPos(int* inorder, int inorderSize,int target)
{
int pos=-1;
for(int i=0;i<inorderSize;i++)
{
if(inorder[i]==target)
{
pos=i;
break;
}
}
return pos;
}
struct TreeNode* CreateTree(int* preorder, int* inorder, int n)
{
struct TreeNode* root=NULL;
if(n>0)
{
root=(struct TreeNode*)malloc(sizeof(struct TreeNode));
root->val=preorder[0];
int pos=FindPos(inorder,n,preorder[0]);
if(pos==-1) exit(1);
root->left=CreateTree(preorder+1,inorder,pos);//左子树节点个数为pos个
root->right=CreateTree(preorder+pos+1,inorder+pos+1,n-pos-1);//总个数=一个根节点+左子树节点数+右子树节点数
}
return root;
}
struct TreeNode* buildTree(int* preorder, int preorderSize, int* inorder, int inorderSize){
struct TreeNode* root=CreateTree(preorder,inorder,inorderSize);
return root;
}
用C++语言实现
class Solution {
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
int n=preorder.size();
for(int i=0;i<n;i++)
dic[inorder[i]]=i;//用哈希表存储中序序列中的数字和其下标,方便寻找根节点
TreeNode*root=Create(preorder,inorder,0,n-1,0,n-1);
return root;
}
private:
unordered_map<int,int> dic;
TreeNode* Create(vector<int>& preorder, vector<int>& inorder,int preorder_left,int preorder_right,int inorder_left,int inorder_right)//参数为先中序遍历的序列,先、中序序列的左右边界
{
if(preorder_left>preorder_right) return nullptr;
//中序序列中根节点的位置
int inorder_root=dic[preorder[preorder_left]];//先序序列第一个是根节点
//左子树节点的个数(中序遍历是:左子树-根-右子树)
int count_lefttree=inorder_root-inorder_left;
//创建根节点
TreeNode *root=new TreeNode(preorder[preorder_left]);
//递归构建左子树
root->left=Create(preorder,inorder,preorder_left+1,preorder_left+count_lefttree,inorder_left,inorder_root-1);
//递归构建右子树
root->right=Create(preorder,inorder,preorder_left+count_lefttree+1,preorder_right,inorder_root+1,inorder_right);
return root;//回溯返回根节点
}
};
面试题8.二叉树的下一个节点
1.如果这个节点有右子树,那么其右子树的最左下节点就是它中序遍历的下一个节点(a->f)
2.如果这个节点没有右子树,并且它是其父节点的左子树,那它的父节点就是它中序遍历的下一个节点(h->e)
3.如果这个节点没有右子树,并且它还是其父节点的右子树。这种情况我们可以沿着指向父节点的指针一直向上遍历直到找到自己是自己父节点的左子节点,那么这个父节点就是我们要找的节点的中序遍历的下一个(i->a),如果不存在(自己是自己父节点的左子节点)这样的结点,那它的中序遍历的下一个就是nullptr(g)。
分析清楚问题后,用代码去实现它将不是一件难事:
class Solution {
public:
TreeLinkNode* GetNext(TreeLinkNode* pNode) {
if(pNode==nullptr||(pNode->next==nullptr&&pNode->right==nullptr)) return nullptr;
TreeLinkNode* p=pNode;
if(pNode->right!=nullptr)//有右子树,找右子树的最左
{
//pNode的右子树的最左孩子,一定是pNode的右孩子的最左孩子
p=p->right;
while(p->left!=nullptr)//如果pNode的右孩子没有左孩子,那pNode的右孩子就是它中序遍历的下一个
{
p=p->left;
}
return p;
}
//没有右子树但是它是父节点的左孩子,父节点就是它的中序遍历的下一个
else if(pNode->right==nullptr&&pNode->next->left==pNode)
{
return pNode->next;
}
//没有右子树而且是父节点的右子树
else {
p=pNode;
while(p!=(p->next->left)&&p->next!=nullptr)
{
p=p->next;
}
if(p!=nullptr)
return p->next;
}
return nullptr;
}
};
5.栈和队列
面试题9.用两个栈实现队列
因为队列的特点是“先进先出”,栈的特点是“后进先出”
用一个栈实现“入队”操作,另一个栈实现“出队”操作
class CQueue {
private:
stack<int> Instack;
stack<int> Outstack;
public:
CQueue() {}
void appendTail(int value) {
Instack.push(value);//入只入在Instack里
}
int deleteHead() {//“Outstack用来出”
if(Instack.empty()&&Outstack.empty()) return -1;
if(Outstack.empty())//outstack空时需要把instack的数据都出栈入到outstack里
{
while(!Instack.empty())
{
Outstack.push(Instack.top());
Instack.pop();
}
}
//outstack不为空时,可以直接出栈
int res=Outstack.top();
Outstack.pop();
return res;
}
};
三、算法和数据操作
1.递归和循环
面试题10.斐波那契数列
题目一:求斐波那契数列的第n项
(1)写法最简单的递归写法,效率低面试官不喜欢
int fib(int n) {
if(n<2)
return n;
return fib(n-1)+fib(n-2);
}
(2)面试官期待的时间复杂度为O(n)的实用写法
int fib(int n) {
if(n==0) return 0;
if(n==1) return 1;
int f1=0,f2=1;
int fn=0;
for(int i=2;i<=n;i++)
{
fn=f1+f2;
f1=f2;
f2=fn;
}
return fn;
}
题目二:青蛙跳台阶问题
青蛙跳台阶的原理和斐波那契数列是一样的。
2.查找和排序
面试题11.旋转数组的最小数字
class Solution {
public:
int minArray(vector<int>& numbers) {
int left=0,right=numbers.size()-1;
int mid=left;
if(right==0) return numbers[0];
while(numbers[left]>=numbers[right])
{
if(right-left==1)
{
mid=right;
break;
}
mid=(right+left)/2;
if(numbers[left]==numbers[right]&&numbers[left]==numbers[mid]){//特殊情况11011这种只能顺序查找
int min=numbers[left];
for(int i=left+1;i<right;i++)
{
if(numbers[i]<min) min=numbers[i];
}
return min;
}
if(numbers[mid]>=numbers[left])
left=mid;
else if(numbers[mid]<=numbers[right])
right=mid;
}
return numbers[mid];
}
};
3.回溯法
面试题12.矩阵中的路径
做题的思路是:从(0,0)开始遍历,然后与word当前的元素比较,如果不同就与它的上左下右四个方位再作比较,符合的元素需要标记然后再比较下一个,因为路径不能折回,具体如下:
bool Judge(vector<vector<char> >& matrix, string word,int rows,int cols,int r,int c,int t,bool* flag)
{
if(t==word.length()) return true;//递归的结束标志
bool judger=false;
if(r>=0&&r<rows&&c>=0&&c<cols&&matrix[r][c]==word[t]&&!flag[r*cols+c]){//如果flag已经被标记就不能再作比较
t++;
flag[r*cols+c]=true;
//比较上左下右是否有下一个元素
judger=Judge(matrix,word,rows,cols,r-1,c,t,flag)
||Judge(matrix,word,rows,cols,r+1,c,t,flag)
||Judge(matrix,word,rows,cols,r,c-1,t,flag)
||Judge(matrix,word,rows,cols,r,c+1,t,flag);
}
if(!judger)//如果上左下右都没有下一个元素,回溯到上一个元素看有没有,要把flag先还原成false否则循环进不去
{
t--;
flag[r*cols+c]=false;
}
return judger;
}
bool hasPath(vector<vector<char> >& matrix, string word) {
int rows=matrix.size(),cols=matrix[0].size();
if(rows<1||cols<1) return false;
bool *flag=new bool[rows*cols];
memset(flag,0,rows*cols);//先全部置为false
int i,j;
for(i=0;i<rows;i++)
{
for(j=0;j<cols;j++)
{
if(Judge(matrix,word,rows,cols,i,j,0,flag))
return true;
}
}
delete[] flag;
return false;
}
面试题13.机器人的运动范围
这个题的做法和上一道题有异曲同工之妙。做法计本类似
int NumSum(int n)//获取一个数字的数位和
{
int sum=0;
while(n)
{
sum+=n%10;
n/=10;
}
return sum;
}
bool IsJoin(int threshold, int rows, int cols,int r,int c,bool* flag)//判断机器人能否进入该格子
{
if(r>=0&&r<rows&&c>=0&&c<cols&&!flag[r*cols+c]&&threshold>=NumSum(r)+NumSum(c))
return true;
return false;
}
int movingCountSize(int threshold, int rows, int cols, int r, int c,bool*flag)
{
int count=0;
if(IsJoin(threshold,rows,cols,r,c,flag))
{
flag[r*cols+c]=true;//进入了就要标记为true
//递归判断上下左右四个格子;
count=1+movingCountSize(threshold, rows, cols, r-1, c,flag)
+movingCountSize(threshold, rows, cols, r+1, c,flag)
+movingCountSize(threshold, rows, cols, r, c-1,flag)
+movingCountSize(threshold, rows, cols, r, c+1,flag);
}
return count;
}
int movingCount(int threshold, int rows, int cols) {
bool* flag=new bool[rows*cols];//需要标记已进入的格子避免计数重复
memset(flag,0,rows*cols);
int count=movingCountSize(threshold, rows, cols, 0, 0,flag);
delete[] flag;
return count;
}
特殊用例
4.动态规划与贪婪算法
面试题14.剪绳子
动态规划
剪第一刀的时候有n-1种可能的选择1,也就是剪出来的第一段绳子长度可能为1,2,3,4……n-1
int cuttingRope(int n) {
if(n<4) return n-1;
int max=0;
vector<int> res(n+1);//i下标存储长度为i的绳子最优解
res[0]=0;
res[1]=1;
res[2]=2;
res[3]=3;
for(int i=4;i<=n;i++)
{
max=0;
int result=0;
for(int j=1;j<=i/2;j++)
{
result=res[j]*res[i-j];
if(result>max) max=result;
res[i]=max;
}
}
return max;
}
贪心算法
当n>=5 时,我们尽可能多地剪长度为3的绳子;当剩下的绳子长度为4时,把绳子剪成两段长度为 2的绳子。这种思路对应的参考代码如下:
int cuttingRope(int n) {
//对于任意一个数字n≥3都可以表示为2∗i+3∗j,其中i,j≥0
if(n<4) return n-1;
//首先尽可能多剪长度为3的绳子段
int timesof3=n/3;
//当绳子最后剩下的长度为4时,不能再去剪长度为3的,因为1x3<2x2
if(n-timesof3*3==1) timesof3-=1;
int timesof2=(n-timesof3*3)/2;
return (int)(pow(2,timesof2))*(int)(pow(3,timesof3));
}
5.位运算
面试题15.二进制中1的个数
除法效率比移位低得多。
(1)可能会引起死循环的解法
首先右移不能换成除法。其次如果是负数,就会出现死循环。负数的第一位是1,移位后不仅仅是把最高位的1移到第二位这么简单·,因为移位后也要保证它是一个负数。
int countDigitOne(int n) {
int count=0;
while(n)
{
if(n&1) count++;
n=n>>1;
}
return count;
}
(2) 常规解法
首先把数字n与1做与运算,这样可以判断最低位是不是1,然后把1左移一位得到2再与n相与判断次低位是否为1,依次左移就可以得到1的个数。
int NumberOf1(int n) {
int count=0;
unsigned int m=1;
while(m)
{
if(n&m) count++;
m=m<<1;
}
return count;
}
(3)可以让面试官惊喜的解法
一个数字减去1之后,二进制中从左到右第一个1就会变成0,如果右边还有0都变成1,左边的位都保持不变。
所以,把一个整数减去 1,再和原整数做与运算,会把该整数最右边的1变成0。(1100-1=1011,1011&1100=1000;1000-1=0111,0111&1000=0)那么一个整数的二进制表示中有多少个1,就可以进行多少次这样的操作。
int NumberOf1(int n) {
int count=0;
while(n)
{
n=(n-1)&n;
count++;
}
return count;
}
四、小结
技术面要求:扎实的基础知识、高质量的代码、清晰的思路、优化效率的能力、优秀的综合能力。
面试官通常采用概念题、代码分析题、编程题来考察对编程语言的掌握程度。
数据结构一直是一个重点。数组和字符串是两种最基本的数据结构。链表是面试题频率最高的一种数据结构。如果面试官想加大难度,会选用树。栈和递归调用密切相关,队列在图(包括树)的宽度优先遍历要用到,因此也要掌握。
算法是另一个重点。查找(特别是二分查找)和排序(特别是快速排序和归并排序)是面试中常考的算法。回溯法适合解决迷宫类问题。动态规划适合解决最优解问题。假如我们用动态规划分析时发现每一步都存在最优解,可以尝试使用贪婪算法。另外基于循环和递归的不同实现,时间复杂度大有不同,很多时候我们会用自上而下的递归思路分析问题,却会基于自下而上的循环实现代码。
位运算是针对二进制数的运算规律。只要掌握二进制的与、或、异或运算及左移、右移操作就能解决位运算相关面试题。