[个人记录]春招C/C++后台/运维面试被问到的那些知识点(第一周)

公司:

  1. 华为中央硬件研究院,嵌入式软件开发(杭州)。因为疫情,是通过zoom面试的。我的面试一般是按照项目+语言/DS基础+算法题来的。华为面试结果出的相当快。不管一面还是二面,总体而言都非常简单。
  2. 百度内容技术架构部,C++工程师(北京)。百度是通过自己的会议平台进行面试,但是当天因为网络问题无法使用,所以变成了电话面试。我的一面时间非常长(一个半小时),按照项目,语言,OS,计网的次序来。
  3. 腾讯天美J2工作室,后台(广州)。腾讯使用牛客网。我的一面时间是一个小时,同样是按照项目、算法题、语言、OS、计网的次序。我的算法题是在谈到项目的时候提到了TopK被突然提问,所以可以结合项目做一下准备。
  4. 字节跳动抖音/抖音火山版/直播,后端开发(北京)。使用牛客网。我的一二面都在一个小时到一个半小时之间。字节跳动的面试难度相较前面三个来说要高一个档次,务必好好准备。结果同样出的非常快,一般当晚就能出。另外,字节跳动的二面可以看到一面的具体表现,所以一面问道的二面有大概率不再问,因而每一面都应该好好表现。同时,面我的两个面试官都是影帝级别,特别喜欢问“你确定吗?”,所以一定要有信心坚持一开始的意见。

算法题部分

  1. 华为一面,是lc20原题,easy难度,判断一串括号是否为有效。其中,征询了面试官对于用何种数据结构实现栈的意见,面试官告知我可以以字符串输入,以一个char的数组实现。面试期间写下来的代码如下:
int judge(string s){
     
    char stack[100];
    int top = 0;
    int nums, i;
    if(s == NULL) return 1;
    nums = strlen(s);
    for(i = 0; i < nums; ++i){
     
        if(s[i] == '(' || s[i] == '[' || s[i] == '{'){
     
            stack[top++] = s[i];
        }
        else{
     
            if(s[i] ==')'){
     
                if(top == 0){
     
                    return 0; //false
                }
                else{
     
                    if(s[top] != '(') return 0; //false
                    else s[top--] = 0;
                    }
            }
            if(s[i] ==']'){
     
                if(top == 0)
                    return 0; //false                
                else{
     
                    if(s[top] != '[')
                        return 0; //false
                    else
                        s[top--] = 0;                    
                }
            }
            if(s[i] =='}'){
     
                if(top == 0)
                    return 0; //false                
                else{
     
                    if(s[top] != '{')
                        return 0; //false
                    else
                        s[top--] = 0;                    
                }
            }
        }
    }
    if(top == 0) return 1;// true
    else return 0; //false
}
  1. 华为二面
    华为二面的题同样很简单,非常基础,一共有三道:
    1. 给定数组和值,原地删除数组中值等于给定值的元素,然后返回新的长度。非常基础的一个双指针解法:
    int test1(int *nums, int numsSize, int val){
           
    	if(!nums) return NULL;
    	int i = 0, insertLoction = 0;
    	for(;i < numsSize; ++i){
           
    		if(nums[i] != val){
           
    			nums[insertLocation++] = nums[i];
    		}
    	}
    	return insertLocation;
    }
    
    1. 输入一行字符,统计其中的数字、字母、空格和其他字符的个数:
    void test2(){
           
    	int digitNum = 0, alphaNum = 0;
    	int spaceNum = 0, others = 0;
    	char cur;
    	while((cur = getcahr()) != '\n'){
           
    		if(cur >= '0' && cur <= '9') digitNum++;
    		else if(cur == ' ') spaceNum++;
    		else if((cur >= 'A' && cur <= 'Z') ||
    		        (cur >= 'a' && cur <= 'z'))
    		        alphaNum++;
    		else others++;
    	}
    	print("%d, %d, %d, %d", digitNum, alphaNum, spaceNum, others);
    }
    
    另外,在作答完毕之后,面试官询问了如何降低圈复杂度。可以通过开辟一个256长度的数组来实现,不过对于这个提来说,这样实在没有必要。
    1. 给定一个正整数,输出它的补数。注意没有前导零。比如5的结果是2。
    int test3(int n){
           
    	int allOnes = 0, ordinaryN = n;
    	do{
           
    		n >>= 1;
    		allOnes <<= 1;
    		allOnes += 1;
    	} while(n != 0);
    	return allOnes - ordinaryN;
    }
    
  2. 百度一面,是lc146原题LRUcache,参考代码见下,来自afernandez90的解答,在他基础上稍作修改,加入了模板与异常处理:
#include
#include
#include
#include
#include
#include
#include
#include

using namespace std;
template<class TKey, class TVal>
class LRUCache {
     
public:
	LRUCache(int cap) : capacity(cap) {
     }

	TVal get(TKey key) {
     
		auto it = cache.find(key);
		if (it == cache.end()) {
     
			throw "Not Found!";
		}
		touch(it);
		return it->second.first;
	}

	void set(TKey key, TVal value) {
     
		auto it = cache.find(key);
		if (it != cache.end()) {
     
			touch(it);
		}
		else {
     
			if (cache.size() == capacity) {
     
				cache.erase(recentlyUsedQueue.back());
				recentlyUsedQueue.pop_back();
			}
			recentlyUsedQueue.push_front(key);
		}
		cache[key] = {
      value, recentlyUsedQueue.begin() };
	}

private:
	using cache_map = unordered_map<TKey, pair<TVal, typename list<TVal>::iterator>>;
	void touch(typename cache_map::iterator it) {
     
		TKey key = it->first;
		recentlyUsedQueue.erase(it->second.second);
		recentlyUsedQueue.push_front(key);
		it->second.second = recentlyUsedQueue.begin();
	}

	cache_map cache;
	list<TVal> recentlyUsedQueue;
	int capacity;
};

int main(void) {
     
	LRUCache<string, string> myCache(3);
	myCache.set("www.baidu.com", "10.0.0.1");
	myCache.set("www.fanyi.baidu.com", "10.0.0.2");
	myCache.set("www.aqiyi.com", "10.0.0.3");
	cout << myCache.get("www.baidu.com") << endl;
	myCache.set("www.tieba.baidu.com", "10.0.0.4");
	try {
     
		myCache.get("www.fanyi.baidu.com");
	}
	catch (const char* msg) {
     
		cout << msg << endl;
	}
}
  1. 腾讯一面,实现堆排序。
#include
#include
#include

using namespace std;

void makeMaxHeap(vector<int>& arr, size_t heapSize) {
     
	int cur = heapSize / 2 - 1;
	while (cur >= 0) {
     
		int parent = cur;
		int child = (parent * 2 + 2 >= heapSize) ? parent * 2 + 1 :
			(arr[parent * 2 + 2] > arr[parent * 2 + 1]) ? parent * 2 + 2 : parent * 2 + 1;
		while (child < heapSize && arr[parent] < arr[child]) {
     
				swap(arr[child], arr[parent]);
				parent = child;
				child = (parent * 2 + 2 >= heapSize) ? parent * 2 + 1 :
					    (arr[parent * 2 + 2] > arr[parent * 2 + 1]) ? parent * 2 + 2 : parent * 2 + 1;
		}
		cur--;
	}
}
void heapSort(vector<int>& arr) {
     
	auto heapSize = arr.size();
	while (heapSize) {
     
		makeMaxHeap(arr, heapSize--);
		swap(arr[0], arr[heapSize]);
	}
}

int main(void) {
     
	vector<int> arr = {
      3,1,2,5,4,8,7,6,9,0 };
	heapSort(arr);
	for (auto ele : arr) {
     
		cout << ele << " ";
	}
	cout << endl;
	system("pause");
}

5.字节跳动一面,寻找二叉树最近公共父节点(5分钟)

struct binaryTreeNode {
     
	int val;
	binaryTreeNode* left;
	binaryTreeNode* right;
	binaryTreeNode(int v) :val(v), left(nullptr), right(nullptr) {
     };
};

binaryTreeNode* findNearestCommonParent(binaryTreeNode* root, binaryTreeNode* n1, binaryTreeNode* n2) {
     
	if (!root || root == n1 || root == n2) {
     
		return root;
	}
	binaryTreeNode* left = findNearestCommonParent(root->left, n1, n2);
	binaryTreeNode* right = findNearestCommonParent(root->right, n1, n2);
	if (right && left) {
     
		return root;
	}
	else if (!right) {
     
		return left;
	}
	else {
     
		return right;
	}
}
  1. 字节跳动二面:删除一个升序链表里面重复的节点(20min)
#include 
#include
using namespace std;

struct ListNode{
     
    int val;
    ListNode* next;
    ListNode(int n):val(n), next(nullptr){
     };
};

ListNode* delDuplicate(ListNode* head){
     
    if(!head){
     
        return nullptr;
    }
    ListNode* dummy = new ListNode(0);
    dummy->next = head;
    ListNode* prev = dummy;  
    ListNode *cur = head;
    ListNode *next = head->next;
    while(next != nullptr){
     
        if(cur->val != next->val){
     
            prev = cur;
            cur = next;
            next = cur->next;
        }
        else{
     
            while(next && cur->val == next->val){
     
                ListNode* toDel = cur;
                cur = next;
                next = next->next;
                toDel->next = nullptr;
                delete toDel;
                prev->next = cur;
            }
            prev->next = next;
            ListNode* toDel = cur;
            cur = next;
            if(next) next = cur->next;
            toDel->next = nullptr;
            delete toDel;
        }
    }
    return dummy->next;
}

int main() {
     
    vector<int> arr = {
     1, 2,2,3,3,3, 4};
    ListNode* dummy = new ListNode(0);
    ListNode* cur = dummy;
    for(auto ele : arr){
     
        ListNode* tmp = new ListNode(ele);
        cur->next = tmp;
        cur = cur->next;
    }
    dummy->next = delDuplicate(dummy->next);
    cur = dummy->next;
    while(cur){
     
        cout << cur->val << endl;
        cur = cur->next;
    }
}

智力题部分

可以参考:【盘点】面试中常常看见的智力题

  1. 64匹马,8个赛道赛跑,最多跑几次可以选出跑得最快的前四匹马?(华为一面)

11次

  1. 首先分成八组,比赛八次选出八组中的第一
  2. 然后让这八匹马比赛,将八组进行排序,直接淘汰后四组,第一组第一直接出线。
  3. 然后第一组的二三四,第二组的一二三,第三组的一二和第四组第一一共九匹马比赛两次,就可以找出最快的八匹。
  1. 桌子上具有两堆苹果,一堆6个一个8个。甲和乙轮流取苹果,拿走最后一个的判负,甲先取。取的规则是:只能从一堆里面取,必须要取,取的数量不限。问甲是必胜、必负,还是不确定?

甲必胜
甲先从8个的那堆里面取出2个,形成(6,6)的局面,然后:

  1. 如果乙拿完了某一堆,那么甲只需将另一堆取至剩下一个
  2. 如果乙将一堆拿剩下一个,甲只需将另一堆取完
  3. 其他情况,乙拿了几个,甲就在另一堆里面拿走几个

语言部分

  1. 函数参数中使用引用有什么作用?(腾讯一面)
  1. 可以修改实参的值。在函数调用的过程中,实参的值会被拷贝给形参,并在函数执行完之后销毁。因而如果我们希望修改实参的值,就需要传递它的引用。
  2. 有些类型不支持拷贝初始化,因而只能通过引用传值,比如istream。
  3. 避免拷贝初始化带来的开销,如果我们不希望被修改的话,可以使用常量引用。
  4. 为类定义一个拷贝构造函数时,必须要使用引用,不然会无限递归。
  1. 指针和引用的区别。(百度一面)
  1. 最本质的不同:指针是一个对象(一块能存储数据并具有某种类型的内存空间),而引用仅仅是一个别名,这决定了引用与指针其他的不同
  2. 引用必须被初始化,但是指针不必。
  3. 存在空指针,但是没有空引用。
  4. 引用的创建和销毁不会调用构造与析构函数。
  5. 引用一旦与某个对象绑定便无法被绑定到其他变量上,但是指针是可以的。
  1. 请讲一讲C++里面的static关键字。(字节跳动二面,百度一面)
  1. 修饰全局变量或者函数时:使其不能被其他文件访问到,避免出现函数同名引发重定义的现象。同时,静态全局变量在没有被显式初始化的情况下,也可以被初始化。
  2. 修饰函数内的局部变量的时候:这个变量会在全局数据区,同时在第一次调用函数的时候初始化,此后不会再被初始化,如果没有被显式初始化会自动初始化。函数调用结束后,不会被销毁。
  3. 修饰类成员的时候:此成员为类的所有对象所共有,在静态数据区,生命周期大于类的实例。此成员不能再类内初始化(const除外),因为它不属于某个实例。静态成员的类型可以是这个类本身。
  4. 修饰类的方法:此方法为类的所有对象所共有,可以通过类名::函数名的方式直接访问,也可以使用.和->来访问。静态可以访问静态,非静态可以访问静态,但是静态不能访问非静态。另外,静态成员函数不能被申明为const,因为const本质上是修饰*this的,而静态成员函数没有*this。
  1. 分析全局变量与函数体内部的静态局部变量的作用域与生命周期,如何在函数体外调用函数的静态变量?(字节跳动二面)

全局变量:整个工程,整个程序运行期间
静态全局变量:当前文件,整个程序运行期间
局部变量:当前代码块,代码块运行期间
静态局部变量:当前代码块,整个程序运行期间
在函数体外调用函数的静态变量是做不到的

  1. 请讲一讲C++里面的virtual关键字。(字节跳动一面、腾讯一面)
  1. 修饰函数且给出定义:虚函数,如果不定义virtual的话,指向派生类的基类指针会调用基类方法,但是如果加上virtual就会发生动态绑定,可以调用派生类的方法。virtual会自动继承,不需要在派生类的方法里面特意声明virtual关键字。
  2. 修饰函数且没有定义:纯虚函数,此时基类变成了抽象类,它的派生类必须对于纯虚函数给出定义。
  3. 修饰继承关系:虚继承,为了消除菱形继承中出现的重名问题。被虚继承的基类便成为了虚基类,它在菱形继承的派生类中只会存在一份。
  1. 请讲一讲虚函数表与虚函数指针。含有虚函数的基类的析构函数是否需要为虚函数?(百度一面)
  1. 编译时,如果基类有虚函数,编译器为该类创建一个一位数组的虚函数表
  2. 虚函数表属于类,不属于某个对象,放在只读数据块,每个势力都有一个虚函数指针,从属于类,在堆或者栈上,在类的其他成员前面
  3. 如果基类有虚函数,派生类不管重写与否,都会有虚函数指针。
  4. 如果没有重写,则派生类与基类的虚函数表一致,否则不一致。
  5. 多重继承,含有虚函数的基类有几个,派生类就会有几个虚函数表和虚函数指针。
  6. 应当被定义为虚函数,否则指向派生类的基类指针只会调用基类的析构函数,从而产生内存泄漏。
  1. 如何判断一个基类的指针是否指向了派生类的对象?(腾讯一面)

使用dynamic_cast,如果返回不为空便是指向了派生类的对象。

  1. 宏定义有何作用?(字节跳动一面)
  1. 不带参数的宏定义:提高通用性与易读性,减少输入错误与修改难度。
  2. 带参数的宏定义:定义简单的函数,他们会在编译期间展开,因而不会提高开销。
  3. 条件编译:避免#include导致的重名问题。
  1. 请讲一讲malloc、realloc和free,以及他们与delete,new的区别。(字节跳动一面)
  1. malloc:申请指定字节数的内存。申请到的内存中的初始值不确定。
  2. calloc:为指定长度的对象,分配能容纳其指定个数的内存。申请到的内存的每一位(bit)都初始化为 0。
  3. realloc:更改以前分配的内存长度(增加或减少)。当增加长度时,可能需将以前分配区的内容移到另一个足够大的区域,而新增区域内的初始值则不确定。
  4. new / new[]:先底层调用 malloc 分了配内存,然后调用构造函数(创建对象)。
  5. delete/delete[]:先调用析构函数(清理资源),然后底层调用 free 释放空间。
  6. malloc等是系统的库函数,而new和delete则是运算符
  1. Pascal是门图灵完备的语言,什么是图灵完备?(字节跳动二面)

机器执行任何其他可编程计算机能够执行计算的能力,也就是说,具有和图灵机等同的能力。

  1. C++的容器具体有哪些?STL中map是用什么实现的?(百度一面)
  1. 顺序容器:向量(vector), 双端队列(deque),双向链表(list), 单向链表(forward_list)等
  2. 容器适配器: 队列(queue)与栈(stack)
  3. 关联容器:集合(set)和字典(map),以及他们的无序版本:unordered_map&unordered_set以及允许重复key值版本:multiset&multimap。

map是通过红黑树来实现的,而unordered版本则是使用的哈希。

  1. 请讲一讲智能指针。(百度一面)
  1. C++11还在支持三种智能指针:shared_ptr, weak_ptr, unique_ptr,他们是为了解决C++复杂的内存管理引入的。C++在我们new了之后,容易因为各种各样的原因没有成功delete导致内存泄漏,而智能指针是一个对象,超出作用域之后会自动调用析构函数。
  2. unique_ptr:具有对于一块内存的独占权,于不能被多个实例共享的内存管理。不支持复制与复制操作,但是可以通过move实现语义转移。
  3. shared_ptr:内置一个计数器跟踪引用同一个真实指针对象的智能指针实例的数目,只有最后一个智能指针才会析构所管理的真实指针。但是会出现类似于死锁的“循环引用”现象。
  4. weak_ptr:“旁观者”,为了处理循环引用引入。并不具有对于某个真实指针的所有权,一旦所管理的指针被析构,自身也会自动析构。
  5. 可以参考知乎文章:C++智能指针
  1. 标准输入,标准输出和标准错误有什么区别?(字节跳动一面)

最重要的区别就是cin和cout是缓冲的,而cerr不缓冲,一旦出现马上输出。

  1. 讲一讲深拷贝与浅拷贝(百度一面)

深拷贝:开辟了一个新的内存区域去存储原来对象的复制
浅拷贝:仅仅是新建了一个指针,复制了对象的地址

数据结构与算法部分

  1. 讲一讲DS里面的堆和栈(字节跳动一面)

堆:一个可以被看做数组的完全二叉树对象,他的父节点总是大/小于子节点(当然,这个比较函数可以自定义)
栈:一个先进后出的数据结构

  1. 排序算法,他们的时空复杂度以及稳定性(字节跳动二面)
  1. 冒泡、插入:平均n2,最差n2,空间1,稳定
  2. 选择:平均n2,最差n2,空间1,不稳定
  3. 希尔:平均nlogn,最差n2,空间1,不稳定
  4. 归并:平均nlogn,最差nlogn,空间n,稳定
  5. 快排:平均nlogn,最差n2,空间logn,不稳定
  6. 堆: 平均nlogn,最差nlogn,空间1,不稳定
  7. 桶排序&基数排序

注:排序算法的稳定性是指,排序的过程中不会改变元素彼此的位置的相对次序

  1. Dijkstra算法(字节跳动二面)

应用:指定两点之间的最短路径,或者是指定一点到其它所有点之间的最短路径,不能有负权边。本质是Greedy算法。
算法:寻找由A到T的最短距离

  1. 首先,创建两个集合,第一个集合(记为S),包含已经得知的最短路径的点及其距离,而第二个集合(记为U),包含尚未得知最短路径的点以及A到他们的距离(如果不相连则记为INF)
  2. 然后,在U中找出距离A距离最近的点(记为B),并加入S,然后更新U,更新方式是他们与B的距离加上AB距离之和与原距离的最小值,可以使用优先队列来优化算法
  3. 重复,直到T进入S。

操作系统&Linux部分

  1. 讲一讲C语言程序进程在内存中的分布(华为一面,百度一面,腾讯一面,字节跳动一面)

由高地址向低地址:系统内核使用的虚拟内存、栈、堆、静态数据区、代码区

  1. 栈:局部变量,函数形参等
  2. 堆:由malloc或者new申请的变量,必须手动释放(注:堆和栈没有明确的分界线)
  3. 静态数据区:全局变量与静态变量
  4. 代码区:只读区域,常量与代码等
  1. 讲一讲进程与线程的通信方式(百度一面,腾讯一面,字节跳动一面)

进程之间的通信:

  1. 管道
  2. 套接字
  3. 信号量
  4. 信号机制
  5. 消息队列
  6. 共享内存

线程之间的通信:

  1. 信号量机制
  2. 信号机制
  3. 屏障
  1. 进程和线程都有什么资源是私有的,什么是共享的?(腾讯一面)

进程私有:地址空间、堆、全局变量、栈、寄存器
进程共享:代码段,公共数据,进程目录,进程 ID
线程私有:线程栈,寄存器,程序寄存器
线程共享:堆,地址空间,全局变量,静态变量

  1. 讲一讲多线程模型(字节跳动一面)

线程具有两种实现方式:一种是用户线程,在用户态实现,不需要内核支持,一种是内核线程,由内核直接管理。这二者最终必然要产生一种联系关系

  1. 多对一:多个用户线程对应一个内核线程。这样的好处是,线程之间的管理可以全部在用户态完成,比较的高效,但是坏处是一个线程block会导致其他的线程也block掉 ,同时在多核系统上无法实现真正的parallelism。
  2. 一对一:一个用户线程对应一个内核线程,规避掉了多对一的两个缺点,但是缺点是会创建大量的内核线程,影响OS的性能
  3. 多对多:一定数量的用户态线程对应小于等于这个数量的内核态线程。兼具上面两者的优点,d剧场在实践环境下比较难以实现。
  4. 双层:多对多+一对一的混合,更加灵活

随着大多数系统上出现越来越多的处理核心,限制内核线程的数量已变得不那么重要。 因此,大多数操作系统现在使用一对一模型

  1. 讲一讲虚拟内存的作用(字节跳动一面)
  1. 更加高效的使用内存,程序不需要全部处在物理内存之中,虚拟内存机制可以实现缓存的功能
  2. 更加方便的操作内存:
    1. 编译时虚拟地址确定,可以更方便的链接与装载
    2. malloc在虚拟地址上可以很轻易地得到连续内存空间,然后映射到不连续的物理内存上去
    3. 可以更好地实现内存共享,一般情况下,程序内存空间独立,但是虚拟内存机制使得不同进程的虚拟内存可以被映射到同一个物理地址,实现内存的共享
  3. 使得内存更加安全:控制用户进程对于内存的访问
    1. 不允许修改只读代码段
    2. 不允许读或者修改内核的内存以及数据结构
    3. 不允许读其他进程的私有内存
    4. 不允许修改和其他进程共享的虚拟页表
  1. 讲一讲fork和clone(字节跳动一面)

有三个函数常常在这个里面被讲到,fork,vfork,clone,他们实际上分别又调用了sys_fork,sys_vfork,sys_clone这几个系统调用,然后最后都调用了do_fork但是参数不同:

  1. fork:子进程是父进程的完整副本,复制了比如说task_struct(PCB),内存空间,页表等等。这个复制是copy on write的,也就是说,如果我们不去修改值的时候,父子进程的虚拟地址映射到了同一个物理地址,但是如果其中一个进程试图去修改,那么内核就会将这一页复制,同时将地址映射过去。cow节省了开销,提高了性能。fork有个特点是返回两次,父进程返回一次,进程返回一次。
  2. vfork:子进程直接与父进程共享数据段,包括虚拟地址空间和物理地址,因而子进程可以修改父进程中的变量。同时父进程在子进程创建之后会被block。vfork的正确操作是fork之后马上执行_exit或者exec族函数,在这之后父进程也可以被继续执行。vfork主要是因为很多fork操作之后马上又进行exec,使得fork做的一些工作丧失了作用,因而采用了更为火爆的复制方式。需要注意的是vfork之后不应该使用return返回,最好是使用_exit
  3. clone参数众多,功能强大,主要用来创建线程,也可以用来创建进程(毕竟Linux的线程就是轻量化进程)。其中的参数主要有:
    1. 一个函数指针fn,用来表示子进程需要执行的操作
    2. 一块动态申请的内存,用来表示堆栈
    3. 一串参数,传入子进程
    4. 一个类型为int的flag值,用来表示与父进程共享的内容
  1. 如何统计一个文件夹下所有cpp文件的行数(腾讯一面)
wc -l *.cpp

计算机网络部分

  1. TCP和IP协议处在OSI协议的第几层? (百度一面)

OSI 协议共有七层:

  1. 物理层,设备是中继器与集线器,传输单位是比特
  2. 数据链路层,设备是网桥和交换机,重要协议是PPP和MAC,传输单位是帧
  3. 网络层,设备是路由器,协议有IP、ICMP、ARP、RARP、OSPF、RIP等,传输单位是包
  4. 传输层,重要协议是TCP&UDP,最小传输单位是段
  5. 会话层,建立、管理与终止会话
  6. 表示层,对于数据进行翻译、加密和压缩
  7. 应用层,主要的协议有FTP、DNS、Telnet、SMTP、HTTP、WWW等

TCP在第四层,而IP在第三层

  1. 讲一讲三次握手和四次握手,为什么三次握手要握三次,而四次挥手之后client需要等待两个MSL?(百度一面)

三次握手:

  1. C->S,SYN = 1, seq = x
  2. S->C,SYN = 1, ACK = 1, seq = y, ack = x + 1
  3. C->S,ACK = 1, seq = x + 1,ack = y + 1

为什么一定要三次握手?

  1. 双方都需要确认对方收到了自己发送的序列号
  2. 在不可靠的信道上进行通信的要求。

四次挥手:

  1. C->S, FIN = 1, seq = u
  2. S->C, ACK = 1, seq = v, ack = u + 1
  3. S->C: FIN = 1, ACK = 1, seq = w, ack = u + 1
  4. C->S: ACK = 1, seq = u + 1, ack = w + 1

前两次挥手是停止客户到服务器的数据连接,之后服务器传输完毕数据之后,后两次挥手停止服务器到客户端的数据传输。

客户端需要在四次挥手之后额外等待2MSL的原因:
为了保证客户端发送的最后一个 ACK 报文能够到达服务端。若未成功到达,则服务端超时重传 FIN+ACK 报文段,客户端再重传 ACK,并重新计时

  1. 讲一讲TCP和UDP的区别以及应用。(腾讯一面)

区别:

  1. TCP面向连接(三次握手)而UDP无连接
  2. TCP是点对点传输,而UDP可以支持一对一一对多多对一多对多
  3. TCP提供可靠交互,而UDP仅仅是最大努力交付
  4. TCP面向字节流,可能出现黏包,UDP面向报文
  5. TCP具有拥塞控制,UDP没有,意味着网络拥塞不会降低UDP发送方的发送速率
  6. UDP首部开销更小(8字节),TCP要20字节
  7. TCP有序,而UDP无序

应用:

  1. TCP适合准确性更为重要的场合,比如文件传输,远程登录或者邮件发送
  2. UDP适合效率更为重要的场合,比如音视频通话、游戏、视频广播等
  1. 使用哪个命令检查主机之间的连通性?(腾讯一面)

使用ping命令,这个命令通过ICMP协议查看当前网络的情况。

  1. 讲一讲IO多路复用里面的三种方法 (百度一面,字节跳动一面)
  1. select,拥有一个fd_set的结构,本质是一个数组,在一般的32位机上面大小是1024。这个fd_set里面保存着需要处理的事件的句柄。在我们调用select的时候,fd_set从用户态复制到内核态,然后内核调用对应的poll操作轮询这些数组,一旦发现有需要处理的事件,就会设置fd_set里面的mask的值,在轮询结束后,查验mask值,如果表示有数据需要处理,那么就将结果复制回用户态。如果没有,那么暂挂,稍后再次轮询,直到超时。
  2. poll:同上面大致类似,但是采用了链表的结构pollfd,因而没有了最大数量的限制。
  3. epoll:和上面两个有本质的不同,也因此只在Linux系统里面支持。它会注册一个文件系统,采用红黑树实现,同时设置一个双向链表,表示就绪队列。然后当文件就绪的时候触发注册的回调函数,加入到链表当中。因此,epoll无需轮询,只需要查看链表是否为空即可。同时,使用mmap,利用共享内存来实现,所以省去了来回复制fd_set的麻烦。
  4. 一般来说,epoll拥有更高的性能,但是在大部分连接都很活跃的时候,epoll大量注册函数被调用也会带来不小的开销,因此这个时候,简单粗暴的select效果可能更好。
  1. 讲一讲惊群效应(百度一面)

惊群效应:高并发的时候,某一个资源可用,会导致众多处于就绪态的进程同时被唤醒,然后竞争资源。

惊群效应的影响:

  1. 惊醒所有进程/线程,导致n-1个进程/线程做了无效的调度,上下文切换,cpu瞬时增高
  2. 多个进程/线程争抢资源,所以涉及到同步问题,需对资源进行加锁保护,加解锁加大系统CPU开销

惊群效应的类型:

  1. accept惊群:如果我们先申请了一个listenfd,然后fork多个子进程,接下来我们的子进程都会绑定在一个监听描述符上。一旦当这个listenfd监听到链接的时候,所有的进程就都会被唤醒,触发惊群效应;
  2. epoll惊群:epoll的操作我们可以分为两种,
    1. 一种是在fork之前进行epoll_create,这样所有的进程都会共享一个epfd,类似于上面的accept惊群。但是这样有个不好的地方,就是如果我们之后使用epoll_ctl将connfd加入到epfd之中进行统一的监控。因为我们的epoll_create在fork之前创建,所有的进程共享一个,因而其他进程的修改也会影响的这个connfd的状态,比如另一个进程添加了一个相同的connfd,这样就会导致连接错乱。
    2. 如果fork之后epoll_create,么样进程会拥有属于自己的,不同的epfd,他们在监听同一个端口的时候,会轮询同一个listenfd,这样一旦pollin信号出现,惊群就被触发了
  3. 线程池惊群:类似于进程,比如我们发出一个pthread_cond_boardcast广播之后,线程池里面的所有线程都会被惊醒,但是只有一个可以得到时间的控制权。

惊群效应的解决:

  1. Linux2.6之后的更新,解决了accept惊群和第一种epoll惊群,系统只会唤醒队列中的第一个进程来处理连接请求,解决了此类惊群
  2. NGINX的解决方案:设计一个mutex锁,使得同一时间只能有一个进程处在epoll_wait和accept状态
  3. Linux在3.9之后引入了SO_REUSEPORT,允许将多个进程绑定到同一个端口上。这样一个端口就有了多个listenfd,在连接请求发送到端口上时,内核会自动挑选一个listenfd来处理,解决了惊群问题。

数据库部分

  1. 数据库如何维持一致性?(字节跳动一面)

众所周知,事务(transactions,txn)具有ACID特性(原子性、一致性、隔离性、持久性),如果隔离性没有做好的话,并发执行就会产生冲突。

  1. 并发执行中的冲突:
    1. 更新丢失(写后写)
    2. 不可重复读(写后读)
    3. 脏读(rollback导致另一个事物的更新消失)
    4. 幻读(T1W(A),T2得到了一个基于A和B的结果,然后T1W(B),那样导致T2得到的结果实际上是错误的)
  2. 基于锁机制的并发控制:如果一个txn试图访问一个数据,需要提前上好对应权限的锁,来避免冲突。
    1. 二阶段封锁协议(2PL):任何txn在读或者写之前,都必须首先获得对该数据的锁。两个阶段分别是扩展阶段(获得锁)和收缩阶段(释放锁),在释放锁之后,txn不能再获得锁。此外,还有“一次封锁法”:任何txn都必须一次性将所有锁全部申请到才可以执行,用来避免死锁。以及“严格二阶段协议”:一个txn必须在committed时才释放锁,用来避免更新丢失等情况。
    2. 基于锁的并发控制可能产生死锁。可以采用两种方式解决:
      1.死锁检测。构建一个图结构,表示各个txn之前的等待情况,如果出现环,随机选择一个失误回滚,可以依据时间戳、txn已经完成的进度等选择回滚。同时,回滚的程度上,可以全部回滚,也可以仅仅回滚到 可以释放锁的位置。
      2. 死锁避免,有如下方式:
      1. txn试图访问被另一个txn掌握的锁的时候,中止一个
      2. 一次封锁法(见上)
      3. 提前将数据排序,txn必须依照数据的顺序去获得锁
      4. wait-die:更“年轻”(时间戳更大)的txn不会等待更老的txn控制的锁,他会直接回滚
      5. wound-wait:更老的txn不会等待更年轻的txn的锁,他会直接抢占
    3. 在多粒度结构之下,数据库的结构可以描述为一个树结构,各个level由上到下是database,table,tuple等,这样就产生了“意向锁”的概念,因而在这里我们把所有锁的种类全部说明一下
      1. X:互斥锁,在对数据库修改时使用,与其他所有的锁都不兼容
      2. SIX:可以理解为S+IX,在需要遍历一个数据结构,同时修改其中某些项的时候使用,仅与IS锁相容
      3. S:共享锁,读某个结构的时候使用,与IS和S相容
      4. IX:意向互斥锁,需要修改某个节点的子节点时使用,与IS,IX相容
      5. IS:意向共享锁,需要读某个节点的子节点时使用,与除了X之外的其他所有锁相容
  3. 基于时间戳的并发控制:每个txn具有一个自己的时间戳TS(T),每个数据项又具有时间戳,一个是w,表示最近读了他的txn的TS,一个是r,表示最近读了它的txn的ts。
    1. 控制的具体方法:如果一个试图读取Q的txn的ts比Q的w要小,那么这个txn就试图去读取一个”来自未来“的数据,无效,回滚;如果一个试图修改Q的txn的ts比Q的r要小,那么这次修改就会导致一个读txn失效,所以需要回滚,而如果它比Q的w要小,那么这此修改的值实际上已经过时了,回滚。
    2. 基于时间戳的控制有可能产生级联回滚(一个txn的回滚导致其他txn的值失效,因而必须一起回滚),避免的方法有:
      1. 将写操作放在txn的最后
      2. 有限地采用 锁来控制
      3. 通过提交依赖(必须要等待所有操作Q的txn准备提交时才能提交)来保证可恢复性
    3. Thomas写控制:大体与上面所讲相同,但是如果小于w,并不是回滚,还是放弃本次写操作并继续执行,同时也不修改Q的w。
  4. 基于有效性的并发控制:又称乐观并发控制(OCC),上面的基于锁的并发控制就是悲观并发控制。OCC是基于如下应用场景使用的:大部分txn是只读操作,同时数据冲突发生的概率很低。
    1. OCC的三阶段:不同txn的三个阶段可以互相穿插,但是同一个txn必须依次顺序执行,同时同一瞬间只能有一个txn处在有效性检查或者写阶段。
      1. 读阶段(read and execute stage),txn完成读取操作,同时将所需要的修改存储在本地的临时区域
      2. 有效性检查,执行有效性检查函数,OCC中,txn的时间戳被定义为他执行有效性检查阶段的时间。
      3. 写阶段:如果有效,那么提交修改,如果无效,那么回滚。只读txn没有此阶段。
    2. OCC中的有效性检查:对于需要进行检查的txn T1,他前面所有ts小于他的txn Tk,应当满足:
      1. T1的开始时间晚于Tk的结束时间:两个txn没有交集,毫无疑问是不会相互影响的;
      2. 如果不满足上述条件,那么应当:
        1. Tk写的数据没有被T1读
        2. Tk在T1执行有效性检查阶段之前完成了自己的写阶段
  5. 基于多版本的并发控制(MVCC):每一次成功的写会产生一个新版本,而每一次成功的读则会挑选一个合适的版本。每个数据项会包含多个版本,每个版本包含如下信息:内容+r戳(最近读这个数据项的txn的时间戳)+w戳(创建这个版本的txn的时间戳)
    1. 基于时间戳的MVCC:如果txn试图对于Q进行r或者w操作,而Qk是Q中w戳接近且小于txn的ts的版本。如果txn要r,那么那么直接返回当前的版本,如果要w,那么需要查看Qk的r戳,如果r更新,那么txn回滚,否则,如果和Qk的w相等,覆写,其他情况创建一个新版本。基于ts的mvcc,r总是成功的,但是w不一定。
    2. 基于2PL的MVCC,txn可以分为两类,一类是更新操作, 一类是只读操作,每个数据项都从一个名为ts-counter的变量处获得版本名,每一次更新操作,都可以令ts-counter++,如果一个更新txn要r,那么他会申请s锁,如果要写,会申请x锁,然后创建一个版本为ts-counter+1的版本,最后将版本号+1。对于只读txn,他只需要依据自己的版本号去访问对应的版本即可。
  1. 讲一讲mySQL里面的四种隔离级别。(字节跳动一面)

1.读未提交(Read uncommitted),不能同时写,避免更新丢失
2. 读提交(Read committed),只读的情况下,其他可以同时写或者读,但是如果要写,则其他事务不能访问
3. 可重复读(repeatable read):读的时候也不能写,但是可以读(mySQL默认,但是实践中更多使用)。
4. 序列化(Serializable):事务只能一个一个操作。

安全方向

因为笔者是信息安全专业,所以被问了一些安全相关的问题,如果各位是计科或者软工出身应该不会被问到

  1. 泛洪和DDoS是怎么回事?如何应对?(字节跳动一面)

泛洪攻击:泛洪攻击事实上是包括DoS和DDoS的,我们先说明DoS攻击

  1. SYN泛洪:服务器在第二次握手分配资源,而客户端在第三次,这样攻击者可以伪造大量的虚假SYN包,服务器分配大量资源却无法释放,内存消耗完之后无法提供服务。可以通过使用SCTP协议(四次握手简历链接,使用了状态Cookie),增加TCP backlog的大小,减小超时时间,使用SYN cookies,使用防火墙等。
  2. ICMP泛洪/Ping泛洪,ad向vic发送大量的ICMP ECHO包,从而使得vic疲于应对,网络质量降低。
  3. ICMP反射泛洪,ad伪装为vic的IP,并在网络中广播ICMP请求,vic因此收到大量回应而导致网络瘫痪
  4. UDP泛洪:和ICMP泛洪类似,不过这一次我们发送的是UDP包。
  5. DHCP泛洪:发送大量的DHCP请求,消耗完IP资源来实现DoS攻击

DDoS攻击:分布式拒绝服务攻击,同DoS攻击的最大区别就是采用了僵尸网络来进行攻击。攻击者通过后门进入其他计算机,并在其中安置恶意程序,生成一个被ad控制的僵尸网络。然后同时对于vic发起DoS。可以通过特征匹配、随机丢包等方式。

  1. 讲一讲对称加密与非对称加密。(字节跳动一面)

对称加密:

  1. 加密和解密使用相同的密钥,同时解密使用的算法为加密算法的逆算法
  2. 算法公开、计算量小、加密速度快、加密效率高
  3. 分为块加密和流加密两种,前者的代表算法有DES、AES,后者这是RC4等算法,前者更安全但是速度略慢,后者速度快,且支持流
  4. 块加密通过替换(substitution)和混淆(Transposition)两种重要操作来保证安全,同时有ECB、CBC、CFB、OFB等分组方式

非对称加密:

  1. 加密和解密使用不同的密钥,一个为公钥(public key),一个为私钥(secret key),免去了密钥的传输,提高了安全性。
  2. 除去加密之外,还可以进行数据签名。前者是发送方使用接收方的公钥加密,然后接收方使用自己的私钥解密;后者则是发送方使用自己的私钥加密,然后接收方使用公钥验证发送方的身份。
  3. 常见的算法有RSA、ECC。
  4. 加密速度慢,能加密的文件小。
  1. 讲一讲AES的实现过程,和SBox的作用(字节跳动二面)

AES定义的特殊运算:
AES中的“加法”与“乘法”实际上是定义在伽罗瓦域 G F ( 2 8 ) GF(2^8) GF(28)上的特殊运算。

  1. 加法就是正常的异或运算;
  2. 乘法运算则有很大区别,可以使用代码来理解他:
 unsigned int product = 0;
 for (int i = 0; i < 8; ++i){
      
 	if (multiplier2 & 1)
 		product ^= multiplier1;
 	multiplier2 >>= 1;
 	multiplier1 <<= 1;
 	if (multiplier1 & 0x100)
 		 multiplier1 ^= 0x101;
 }
 return product;

大致就是对每一位进行乘法,如果超出0x100就同某个值异或
AES中的状态矩阵
由16*8位数据组成,大致如下:
a0, a4, a8, a12
a1, a5, a9, a13
a2, a6, a10, a14
a3, a7, a11, a15
AES中的字节代换
AES实际上具有一个16*16的S-Box,对于状态矩阵中的每八位数,取其前四位生成行号,取其后四位生成列号,取得SBox中的值替换当前值。S-Box的生成规则之后再讲。
AES中的行移位
第一行不移位,第二行左移一位,以此类推,结果是:
a0, a4, a8, a12
a5, a9, a13, a1
a10, a14, a2, a6
a15, a3, a7, a11
AES中的列混淆:
状态矩阵乘以一个固定的矩阵。乘法规则见上,矩阵见下:
2 3 1 1
1 2 3 1
1 1 2 3
3 1 1 2
AES里面的密钥拓展
AES里面我们的初始密钥长度有128、192、256三种长度可选,对应的加密轮数为10轮、12轮、14轮。扩展方式略有不同:

  1. 首先讲一下RC(Round Constant),这个常数列的生成算法是:从0x01开始,后一个是前一个的二倍,特别的,如果乘以2之后会超过8 位的话,结果应当异或上0x11B。在AES上,我们可能用到的RC序列为: 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36。
  2. 然后是128位密钥,首先将密钥4字节分成一组,一共4组,然后记为 W 0 , W 1 , W 2 , W 3 W_0, W_1, W_2, W_3 W0,W1,W2,W3,之后,继续生成 W 4 到 W 43 W_4到W_{43} W4W43,如果它的下标不是4的倍数的情况下,公式为 W i = W i − 1 ⨁ W i − 4 W_i = W_{i - 1} \bigoplus W_{i - 4} Wi=Wi1Wi4,如果是四的倍数的情况下,那么首先将 W i − 4 W_{i - 4} Wi4循环左移8 位,然后依据S-Box进行字节替换,然后执行上述公式。
  3. 对于192位密钥,仅仅是将4个一组换为6个一组即可。混淆操作没六次执行一次
  4. 256位则是8个一组,但是请注意,每四个需要额外执行一次S-Box的替换操作。

AES的流程:了解了上面的前置信息,我们终于可以一睹AES的芳容了:

  1. 首先密钥拓展,得到N轮密钥,每个密钥有128位,正好对应4*4的状态矩阵
  2. 然后先执行一次轮密钥加(即逐位异或),接下的N-1轮里面依次执行:字节代换,行移位,列混合,轮密钥加,最后一轮执行字节代换、行移位、轮密钥加得到密文
  3. 解密时,先轮密钥加,然后的N-1轮里面,依次执行:逆行移位,逆字节代换,轮密钥加,逆列混淆,最后一轮执行逆行移位,逆字节代换,轮密钥加,得到明文。
  1. 讲一讲异或运算的安全性。(字节跳动二面)

在流加密中使用的异或运算,实际上是不安全,比如:我们假定c = p ^ k,那么,c1 ^ c2 = p1 ^ p2,这样,两段密文的异或结果就是对应明文的异或结果,明文的某些特征暴露出来了。
实际上,对于无线局域网的两种攻击方式:ChopChop和KRACK就是利用了上述原理。

  1. 讲一讲RSA中,密钥对的生成过程,以及欧拉函数的具体定义。(字节跳动二面)

先找到两个大素数 p p p q q q ,然后计算
n = p × q λ ( n ) = l c m ( ( p − 1 ) , ( q − 1 ) ) n = p \times q\\ \lambda(n) = lcm((p-1), (q-1))\\ n=p×qλ(n)=lcm((p1),(q1))
找到一个随机数 e e e ,它满足 1 < e < λ ( n ) 11<e<λ(n)以及 g c d ( e , λ ( n ) ) = 1 gcd(e, \lambda(n)) = 1 gcd(e,λ(n))=1 (最常用的是 65537 ( 2 16 + 1 ) 65537(2^{16}+1) 65537(216+1)) , 然后计算:
d = e − 1 m o d   ( λ ( n ) ) d = e^{-1}mod\ (\lambda(n)) d=e1mod (λ(n))
于是 ( e , n ) (e, n) (e,n) 就是公钥对, ( d , n ) (d,n) (d,n)就是私钥对。
P P P为明文,记 C C C为密文:
C = P e m o d   n P = C d m o d   n C = P^e mod\ n\\ P = C^d mod\ n C=Pemod nP=Cdmod n
欧拉函数 Φ ( n ) \Phi(n) Φ(n)的含义为小于等于正整数n的正整数中,与正整数n互质的数的个数。规定 Φ ( 1 ) = 1 \Phi(1) = 1 Φ(1)=1,所有质数n的欧拉函数值为n - 1,合数则为其所有质因数的欧拉函数值之积。

你可能感兴趣的:(杂录,面试,后端,c++)