2016.3.15,参加了CVTE的技术面,很不幸,我和我的两位小伙伴均跪在了一面。先将当日的面试内容汇总如下,供后来者参考。我们三人各自也都总结了失败的原因,大致如下:
一是算法与数据结构、操作系统、CC++基础知识不牢固,理论知识点不深入;
二是说话语气要沉稳谦逊,不要表现的不屑与轻浮(我就有点);
三是手写代码时略显紧张,大脑反应不过来,表现不佳。
下面就将回忆起的问题与大家分享。
问题一:
初次见面,先手写一段二分查找的算法吧,假定数组是由小到大排序!
答:
二分查找,别名折半查找。其思想很简单,标准写法如下:
//array递增有序
int binarySearch(int array[],int s,int e,int value){
int mid=0;
while(s<=e){
mid=(s+e)/2;
if(value==array[mid])
return mid;
if(array[mid]>value) //在左半区
e=mid-1;
else
s=mid+1; //在右半区
}
return -1;
}
但是在面试时手写代码,若没有充分准备,很容易会写出如下递归形式的二分查找:
//有序数组增排列
int binarySearchRecusion(int array[],int sIndex,int eIndex,int target){
if(eIndex<sIndex) return -1;
int mid=(sIndex+eIndex)/2;
if(array[mid]==target) return mid;
if(array[mid]>target)
m_binarySearch(array,0,mid-1,target); //在左半区
else
m_binarySearch(array,mid+1,eIndex,target);//在右半区
}
}
所以,如果面试官没有明确说明书写哪个版本,可以主动询问面试官其想要的版本,既表现出你的积极主动,也体现出自己对二分查找较全面的理解!
问题二:
将上面的二分法查找延伸一下,求下标最小的目标数?
答:
在代码中稍作修改即可,找到了匹配的数之后,向左线性探测即可。以非递归版本为例:
else //找到
while(array[--mid]==value);
return mid+1;
下面是关于Linux环境编程的相关知识点。
问题三:
简述我在Linux环境编程的项目中较大的收获是什么。我的回答是多线程程序中对未加锁的map进行插入操作时,会造成程序崩溃。然后考官问为什么?
答:
这和map的内在实现有关。map插入时键值对时,需要申请节点并调整红黑树的结构,其间若有其他线程同时进行插入,势必会造成对内存的非法访问,造成程序崩溃。
问题四:
Linux环境中,如何产生子进程,由如何判断哪个是子进程和父进程?
答:
使用fork()来产生子进程。通过fork()的返回值来判断当前进程是父进程还是子进程,父进程返回子进程的进程ID,子进程返回0,如果fork失败,返回-1,错误号保存在errno中。
问题五:
子进程可以访问父进程的变量吗?
答:
子进程可以访问父进程变量。子进程“继承”父进程的数据空间,堆和栈,其地址总是一样的,因为在fork时整个虚拟地址空间被复制,但是虚拟地址空间所对应的物理内存却没有复制。比如,这个时候父子进程中变量 x对应的虚拟地址和物理地址都相同,但等到虚拟地址空间被写时,对应的物理内存空间被复制,这个时候父子进程中变量x对应的虚拟地址还是相同的,但是物理地址不同,这就是”写时复制”。还有父进程和子进程是始终共享正文段(代码段)。
问题六:
除了使用fork产生子进程,还有其它的方法吗?
答:
我当时说没有了,竟然把vfork()给忘记了。vfork()函数的调用序列和返回值与fork相同,同样可以创建一个新进程,但两者的语义不同。
vfork()与fork的区别有二:
(1)vfork出的子进程不拷贝父进程的地址空间,即使父进程的数据被修改。新进程的目的是exec一新程序。
(2)在vfork调用中,子进程先运行,父进程挂起,直到子进程调用exec或exit,在这以后,父子进程的执行顺序不再有限制。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
问题七:
进程间通信的几种方式?哪种效率最高?
答:
(1)管道(PIPE)和命名管道(FIFO)——比如shell的重定向。
(2)信号(Signal)——比如杀死某些进程kill -9,比如使用命令nohup使进程忽略SIGHUP信号,让进程在终端退出后,运行在系统后台。信号是一种软件中断。
(3)消息队列(Message Queue)——相比共享内存会慢一些,缓冲区有限制,但不用加锁,适合命令等小块数据。
(4)共享内存(Shared Memory)——最快的IPC方式,同一块物理内存映射到进程A、B各自的进程地址空间,可以看到对方的数据更新,需要注意同步机制,比如互斥锁、信号量。适合传输大量数据。
(5)信号量(Semaphore)。 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。不是用于交换大批数据,而用于多线程之间的同步.常作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步和互斥。如对信号量执行PV操作,实现生产者与消费者之间的同步。
(6)套接字(Socket)——Socket网络编程,网络中不同主机间的进程间通信,属高级进程间通信。
关于进程间的通信方式可以参考:线程间的通信、同步方式与进程间通信方式。
问题七:
Linux多线程同步方式 ?
答:
(1)互斥量(mutex);
(2)读写锁(reader-writer lock);
(3)自旋锁(spin lock);
(4)条件变量(condition);
(5)信号量(semaphore)。如同进程一样,线程也可以通过信号量来实现同步;
(6)屏障(barrier)。
具体请参考《Unix环境高级编程》。
这里要注意两点:
(1)互斥量可通过pthread_mutex_setpshared
接口设置,用于进程间同步。条件变量在初始化时,也可以通过接口pthread_condattr_setpshared
指定该条件变量可用于进程间同步。
(2)不要把Windows提供的多线程同步方式临界区结构对象(CRITICAL_SECTION)记在Linux名下。临界区结构对象分别用EnterCriticalSection()和LeaveCriticalSection()函数去标识和释放一个临界区。
问题八:
软中断和硬中断的区别?
答:
从本质上来讲,中断是一种电信号,当设备有某种事件发生时,它就会产生中断,通过总线把电信号发送给中断控制器。
如果中断的线是激活的,中断控制器就把电信号发送给处理器的某个特定引脚。处理器于是立即停止自己正在做的事,跳到中断处理程序的入口点,进行中断处理。
①硬中断是由外部设备对CPU的中断,具有随机性和突发性;软中断由程序控制,执行中断指令产生的,无面外部施加中断请求信号,因此不是随机的而是安排好的。如信号就是软件中断,键盘输入、鼠标点击就是硬件中断。
②硬中断的中断响应周期,CPU需要发中断回合信号(NMI(Non Maskable Interrupt,不可屏蔽中断)不需要),软中断的中断响应周期内,CPU不需发中断回合信号。
③硬中断的中断号是由中断控制器提供的(NMI硬中断的中断号系统指定为02H);软中断的中断号由指令直接给出,无需使用中断控制器。
④硬中断是可屏蔽的(NMI硬中断不可屏蔽),软中断不可屏蔽。
问题九:
UDP和TCP可以绑定同一个端口?
答:
TCP、UDP可以绑定同一端口来进行通信。端口是一种抽象的软件结构(包括一些数据结构和I/O缓冲区),TCP/IP传输层的两个协议TCP和UDP是完全独立的两个软件模块,因此各自的端口号也相互独立,可以拥有相同的端口号,并不冲突。
问题十:
如何解决哲学家进餐问题?
答:
哲学家进餐问题是由荷兰学者Dijkstra提出的经典的线程和进程间步问题之一。产生死锁的四个必要条件是:
(1)互斥条件;
(2)请求和保持条件;
(3)不可剥夺条件;
(4)环路等待条件。
筷子是绝对互斥的,我们可以破坏其它三种条件来解决哲学家进餐问题。解决办法如下:
(1)破坏请求保持条件
利用原子思想完成。即只有拿起两支筷子的哲学家才可以进餐,否则,一支筷子也不拿。
(2)破坏不可剥夺条件
当哲学家相互等待时,选择一个哲学家作为牺牲者,放弃手中的筷子,这样就保证有一位哲学家可以就餐了。
(3)破坏环路等待条件
解法一:奇数号哲学家先拿他左边的筷子,偶数号哲学家先拿他右边的筷子。这样破坏了同方向环路;
解法二:至多允许N-1位哲学家进餐,将最后一个哲学家停止申请资源,断开环路。最终能保证有一位哲学家能进餐,用完释放两只筷子,从而使更多的哲学家能够进餐。
面试官话风陡转,问我算法和数据结构是否学过,正式开始跟我聊算法与数据结构的问题。
问题十一:
两个栈如何模拟出队列。
答:
现在想想,其实这个问题很简单,可是面试时因略紧张,瞬间懵逼。其思想如下:
两个栈:实际存储栈A,中转栈B,
入队列:若B中有数据,全部POP进入A,再将入队列的数据PUSH到存储栈A;
出队列:将存储栈A的所有数据POP到中转栈B后再出栈。
具体实现可参考:两个栈实现一个队列。
问题十二:
延伸一下,类似问题,如何使用两个队列模拟出栈?
答:
队列A、B
入栈:入队列A
出栈:把队列A的前n-1个元素倒到队列B,把第n个元素去掉。此时数据在B中,下次操作,则对B操作。
具体实现可参考:两个队列实现一个栈 。
问题十三:
堆排序和快速排序的区别?
答:
堆排序和快速排序都是比较类非线性时间排序中较优的排序方法,均是不稳定排序,且平均时间复杂度均为O(nlogn)。区别有二:
(1)堆属于选择类排序,快速排序属于交换类排序;
(2)堆排序一般优于快速排序的重要一点是,数据的初始分布情况对堆排序的效率没有大的影响,而快速排序如果数据已经有序,那么时间复杂度将变成
具体实现参考:十种常见排序算法。
问题十四:
手写代码,反转单链表。
答:
这个不需要什么算法思想,只要对链表节点逐个操作即可。参考一下代码:
LinkedListNode* reverseLinkedList(LinkedListNode* head){
ListNode* pNode = head;
ListNode* pPrev = NULL;
while (pNode != NULL){
ListNode* pNext = pNode->m_pNext; //保存下一个节点的值
pNode->m_pNext = pPrev; //把当前pNode的下一个节点指向pPrev
pPrev = pNode; //此时pPrev向后移动指向此时的pNode
pNode = pNext; //而pNode也向后移动,指向刚才保存的pNext;
}
return pPrev;
问题十五:
如何判断单链表是否存在环?
答:
(1)方法一,算法的思想是设定两个指针p, q,其中p每次向前移动一步,q每次向前移动两步。那么如果单链表存在环,则p和q相遇;否则q将首先遇到null。
(2)方法二,如果已知链表的长度,使用节点指针p指向头节点,每次移动一步,记录移动的步数,如果移动的步数大于链表长度,则存在环。
问题十六:
二叉树的先根、中根和后根遍历次序。叫我自己随便画个二叉树,写出它的遍历结果。
答:
这个比较简单,这里我就不赘述了。
问题十七:
手写一个小程序,打印杨辉三角?
答:
#include <stdio.h>
int main(){
int a[10][10];
int i,j;
for(i=0;i<10;i++){
a[i][0]=1;
a[i][i]=1;
}
for(i=2;i<10;i++){ //直接从第三行开始,因为第二行全是1
for(j=1;j<i;j++)
a[i][j]=a[i-1][j]+a[i-1][j-1];
}
for(i=0;i<10;i++){
for(j=0;j<(10-i-1);++j)
printf(" "); //每行前需要空的数的位置,每个数占4个空格
for(j=0;j<=i;j++)
printf("%4d ",a[i][j]);
printf("\n");
}
return 0;
}
截图:
我觉得CVTE的员工出这种题真的好无聊!
既然是应聘CC++岗位,自然少不了C++的一些问题。话风又转,开始正式切入C++语言知识点。
问题十八:
C++面向对象的三大特性?
答:
随口而出,封装、继承和多态。
问题十九:
C++多态实现的机制?
答:
又随口而出,虚函数。但是,仔细深究,C++的多态应该分为编译时多态和运行时多态。前者主要是模板和函数重载,后者主要指虚函数。我感觉多说无益,反而显得啰嗦,一般C++的多态指的就是虚函数。
问题二十:
既然虚函数用来实现多态,然运行时如何确定当前对象调用的是哪一个虚函数呢?
答:
对象数据实体中有虚函数表指针,通过虚函数表指针找到虚函数表,再确定虚函数的入口地址。
问题二十一:
那么虚函数表存放的位置在哪里?一个类又有多少个虚函数表呢?
答:
一个类若继承了多个含有虚函数的基类,那么该类就有对应数量的虚函数表。虚函数表是类所拥有的,程序运行过程中不能够修改,它存放在常量区。
具体参见:C++ 对象的内存布局(下)。注意,陈皓老师的这篇博客对虚拟继承下子类的内存布局没有讲清楚,当然对于虚函数表的讲解还是比较仔细的。
另外,鄙人也总结了一些,可参见动态联编实现原理分析。大家可对比阅读。
问题二十二:
你听过虚基类吧,虚基类的作用是什么,虚基类的实现机制就是什么呢?
答:
虚基类的作用是在C++多重继承的情况下,如果出现菱形继承的话,为了消除 在子类中出现父类数据实体的多份拷贝。
虚基类的实现机制这个有点复杂。不同编译器内部实现的机制也不相同。其主要有两种实现方案:
(1)是引入virtual base class table,不管多少个虚基类,总是只有一个指针指向它,这个virtual base class table(VBTBL)包括真正的 virtual base class 指针。
(2)C++之父Bjarne Stroustrup的办法是在virtual function table中放置virtual base class的offset,而非地址,这个offset在virtual function table 的负位置(正值是索引virtual function,而负值则将方向盘引到virtual base class offsets)。
在VC++中,采用的是类似第一种方案。对每个继承自虚基类的类实例,将增加一个隐藏的“虚基类表指针”(vbptr)成员变量,从而达到间接计算虚基类位置的目的。该变量指向一个全类共享的偏移量表,表中项目记录了对于该类而言,“虚基类表指针”与虚基类之间的偏移量”,而不是真正的 virtual base class指针,这就是说类似于上面的第一种方案,而非严格按照该方案。具体参见C++虚继承对象的内存布局。
对于g++,实现上和VC++不同,它并没有生成独立的虚基类表和虚基类表指针来指明虚基类的偏移地址,具体实现细节我还不太清楚,可能《深度探索c++对象模型》会有说明。这是只测试了当子类存在虚函数表指针和虚函数表时,编译器并不会为子类对象实体生成额外的一个虚基类表指针。
但是当子类没有虚函数表指针时,编译器会为子类对象生成一个指针变量,这个指针变量很可能就是指向虚基类表。种种迹象表明g++的实现方案和上面提到的第二种方案很相似,具体我没有深入研究其对象布局,以后再探讨我猜测的真伪。
问题二十三:
又是手写代码。写一个C++的单例模式吧!
答:
class CSingleton{
private:
CSingleton(){} //构造函数是私有的
static CSingleton *m_pInstance;
public:
static CSingleton * GetInstance(){
if(m_pInstance == NULL) //判断是否第一次调用
m_pInstance = new CSingleton();
return m_pInstance;
}
};
关于C++单例模式的优化,可以参考 C++中的单例模式。
问题二十四:
C++有没有自动垃圾回收机制?
答:
我不知道面试官为什么这么问,搞得我以为是什么陷阱,人人都知道C++是没有的。C++的设计者Bjarne Stroustrup曾说:“我有意这样设计C++,使它不依赖于自动垃圾回收(通常就直接说垃圾回收)。这是基于自己对垃圾回收系统的经验,我很害怕那种严重的空间和时间开销,也害怕由于实现和移植垃圾回收系统而带来的复杂性。还有,垃圾回收将使C++不适合做许多底层的工作,而这却正是它的一个设计目标。但我喜欢垃圾回收的思想,它是一种机制,能够简化设计、排除掉许多产生错误的根源。”
问题二十五:
指针和引用的区别?以及使用引用的注意事项?
答:
《C++高级进阶教程》中指出,引用的底层实现由指针按照指针常量的方式实现,见:C++引用的本质。
非要说区别,那么只能是使用上存在的区别。主要有:
(1)引用在定义时必须初始化;
(2)指针常量本身(以p为例)允许寻址,而引用变量本身(以r为例)不允许寻址,&r返回的是被引用对象的地址,而不是变量r的地址;
问题二十六:
最后一个非技术问题。问我想搞哪方面的开发?
答:
我还真不知道,我就反问了他,说贵公司有哪些CC++岗位的开发呢?他说有窗体应用程序的后台,Linux环境服务程序的后台,还有两个是什么忘记了。因为鄙人在实验室主要开发都是Linux环境,所以选择了后者。
结束
千年不变的最后一句:好了,差不多了,你还有什么问题需要问的吗?面试官如是说。
我问了面试结果大概什么时候能出来,然后就走了。我觉得最好不要问面试结果,二是问一些与技术和工作相关的问题,也可以问一下面试过程中自己未解的问题,既表现出了自己的好学,也增加了与面试官的交流和互动,给面试官留下好印象。
一面大概40分钟,本以为可以进入二面,结果想多了,还是自己too young too naive,game over。这里花了两天的时间总结如上,同大家互助共勉!
参考文献
[1]Linux环境编程的相关问题
[2]父子进程变量的虚拟进程是一样
[3]进程间同步慎用条件变量
[4]C++虚继承对象的内存布局.
[5]C++ 对象的内存布局(下)