字节跳动算法整理
Posted yan1345
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字节跳动算法整理相关的知识,希望对你有一定的参考价值。
无重复字符的最长子串
输入: "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
class Solution {
public:
int lengthOfLongestSubstring(string s) {
int m[256] = {0}; //存储每个字符在字符串的位置
res = 0, left = 0;//结果和左边界
for(int i=0;i<s.size();i++){
if(m[s[i]] == 0 || m[s[i]]<left){
res = max(res, i-left+1);//更新res
}
else{//如果s[i]重复出现且s[i]第一次出现的位置在s[left,i]之间
left = m[s[i]]; //更新left坐标
}
m[s[i]] = i+1;
}
return res;
}
};
最长公共前缀 *
class Solution {
public:
string longestCommonPrefix(vector<string>& strs) {
if(strs.size() == 0)
return "";
string s = strs[0];//取出第一个字符串,首字母
for(int i = 1; i< strs.size(); i++){ //从第2个字符串,遍历
while(strs[i].find(s) != 0){ //index ==0 字符串开头
s = s.substr(0,s.size()-1);
if(s.empty())
return "";
}
}
return s;
}
};
简化路径
本题思路较为简单,采取入栈的方法。先只考虑"/"之间的字符串:
- 当遇到不是"."和".."时的字符串,则将当前元素入栈;
- 当遇到".."且栈不为空时,则出栈顶部元素;
- 其他情况不做处理。
问题的关键在于如何识别一个和多个"/",利用string流的办法,先把"/"替换成空格,之后将string绑定到一个istringstream中,通过类似 cin 的方法提取内容 while(cout >> string) 。
class Solution {
public:
string simplifyPath(string path) {
if(path == "/")
return "/";
//将字符串中的‘/‘替换为空格
for(int i = 0; i < path.size(); i++){
if(path[i] == ‘/‘)
path[i] = ‘ ‘;
}
vector<string> stack; //声明栈,存放两个‘/‘之间的字符串
istringstream str(path); //声明并绑定一个输入流
string buf; //输入流读入的临时缓存
while(str >> buf){
if(buf == ".." && !stack.empty() ) //遇到".."且栈不为空则出栈
stack.pop_back();
else if(buf != "." && buf != "..") //不是"."和".."的字符串则入栈
stack.push_back(buf);
}
if(stack.empty()) //简化结果为空则返回根目录
return "/";
string res; //存放结果
for(int i = 0; i < stack.size(); i++){
res = res + "/" + stack[i];
}
return res;
}
};
复原IP
class Solution {
public:
vector<string> res;
vector<string> restoreIpAddresses(string s) {
string temp = "";
dfs(s,temp,0);
return res;
}
//参数 s 字符串递归减小,temp 返回值路径,最后一个参数控制word数目
void dfs(string s,string &temp, int word_number)
{
if(word_number==4)
{
//cout << temp << " ";
if(s.empty()) res.push_back(temp);
}else{
if(word_number > 0) temp += ‘.‘;
for(int i = 1; i <=3 && i <= s.length();i++)
{
if(valid(s.substr(0,i))){
//注意截取操作函数 s.substr(o,i)从pos0截取i个字符
temp += s.substr(0,i);
//s变短。试探后
dfs(s.substr(i,s.length()-i), temp , word_number+1);
//回溯i个字符 注意temp不断变长 所以回溯不是 0~i 而是回溯 length - i
temp = temp.substr(0,temp.length()-i);
}
}
temp.pop_back();
}
}
bool valid(const string &s){
if(s.empty()||(s[0]==‘0‘ && s.size()>1)) return false;
int val = stoi(s);
if(val >= 0 && val <= 255) return true;
return false;
}
};
三数之和
? 先排序
? 以数组中每个元素为三个数中的第一个,在其后找到满足条件的剩下2个
– 因为已经排序,为了避免重复,所以相同元素只处理一次(也就是说,如果nums[i] == nums[i - 1],说明以nums[i]为第一个元素的情况已经处理过,那么跳过,继续处理下一个数)
? 为了找出剩余2个满足要求target = 0 - nums[i]的数,使用两个指针l和r,令sum = nums[l] + nums[r]
– 如果sum > target,则将r向左移动至第一个不等的元素处
– 如果sum < target,则将l向右移动至第一个不等的元素处
– 否则,满足要求,添加到结果中,并将l和r都移动至第一个不等的元素处
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
if(nums.size() < 3)
return vector<vector<int>>();
sort(nums.begin(),nums.end());
vector<vector<int>> res;
for(int i = 0;i < nums.size() - 2;i++){
if(i == 0 || nums[i] != nums[i - 1]){
int target = 0 - nums[i];
int l = i + 1,r = nums.size() - 1;
while(l < r){
int sum = nums[l] + nums[r];
if(sum < target){
while(l < r && nums[l + 1] == nums[l]) l++;
l++;
}
else if(sum > target){
while(l < r && nums[r - 1] == nums[r]) r--;
r--;
}
else{
vector<int> triplet = {nums[i],nums[l],nums[r]};
res.push_back(triplet);
while(l < r && nums[l + 1] == nums[l]) l++;
while(l < r && nums[r - 1] == nums[r]) r--;
l++;r--;
}
}
}
}
return res;
}
};
岛屿的最大面积
给定一个包含了一些
0
和1
的非空二维数组grid
。一个 岛屿 是由一些相邻的
1
(代表土地) 构成的组合,这里的「相邻」要求两个1
必须在水平或者竖直方向上相邻。你可以假设grid
的四个边缘都被0
(代表水)包围着。找到给定的二维数组中最大的岛屿面积。(如果没有岛屿,则返回面积为
0
。)
当遇到1时,使用DFS求该岛屿的面积,然后与最大值比较,如果更大则更新最大值。为了防止重复求相同岛屿的面积,在DFS过程中,将1设置为0
class Solution {
public:
int maxAreaOfIsland(vector<vector<int>>& grid) {
if(grid.size() == 0)
return 0;
int res = 0;
int rows = grid.size(),columns = grid[0].size();
for(int i = 0;i < rows;i++){
for(int j = 0;j < columns;j++){
if(grid[i][j] == 1){
int area = DFS(grid,i,j,rows,columns);
res = area > res ? area : res;
}
}
}
return res;
}
int DFS(vector<vector<int>> &grid,int row,int column,int rows,int columns){
//越界或者不连通
if(row < 0 || row >= rows || column < 0
|| column >= columns || grid[row][column] == 0)
return 0;
int count = 1;
grid[row][column] = 0;//防止重复计算
/*遍历该顶点的四个方向*/
count += DFS(grid,row - 1,column,rows,columns);
count += DFS(grid,row + 1,column,rows,columns);
count += DFS(grid,row,column - 1,rows,columns);
count += DFS(grid,row,column + 1,rows,columns);
return count;
}
};
搜索旋转排序数组
给你一个整数数组
nums
,和一个整数target
。该整数数组原本是按升序排列,但输入时在预先未知的某个点上进行了旋转。(例如,数组
[0,1,2,4,5,6,7]
可能变为[4,5,6,7,0,1,2]
)。请你在数组中搜索
target
,如果数组中存在这个目标值,则返回它的索引,否则返回-1
。
class Solution {
public:
int search(vector<int>& nums, int target) {
int l = 0,r = nums.size() - 1;
while(l <= r){
int mid = (l + r) / 2;
if(nums[mid] == target) return mid;
if(nums[l] < nums[r]){
if(nums[mid] < target)
l = mid + 1;
else
r = mid - 1;
}
else{
if(nums[mid] >= nums[l]){//mid在左边
if(target > nums[r] && target < nums[mid])
r = mid - 1;
else
l = mid + 1;
}
else{//mid在右边
if(target > nums[mid] && target < nums[l])
l = mid + 1;
else
r = mid - 1;
}
}
}
return -1;
}
};
朋友圈
班上有 N 名学生。其中有些人是朋友,有些则不是。他们的友谊具有是传递性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么我们可以认为 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。
给定一个 N * N 的矩阵 M,表示班级中学生之间的朋友关系。如果M[i][j] = 1,表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。
提示:
1 <= N <= 200
M[i][i] == 1
M[i][j] == M[j][i]
接雨水
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
示例 1:
输入:height = [0,1,0,2,1,0,1,3,2,1,2,1] 输出:6 解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
示例 2:
输入:height = [4,2,0,3,2,5] 输出:9
提示:
n == height.length
0 <= n <= 3 * 104
0 <= height[i] <= 105
使用两个变量max_left和max_right,分别表示左边柱子的最高者和右边柱子的最高者,初始化为最左边柱子的高度和最右边柱子的最高者
另外使用两个指针left_bar和right_bar,分别表示左边的一个柱子和右边的一个柱子,初始化为最左边的柱子和最右边的柱子。现在不考虑中间的柱子怎么样,只分析这两个指针所表示的柱子
假设left_bar和right_bar中高度较低者为left_bar:
?如果left_bar比max_left要低,那么left_bar上面能储水max_left-left_bar
?否则,left_bar比左边的柱子都要高,那么需要更新max_left的值
经过上面的处理后,递增left_bar,继续处理下一个柱子
同理,如果高度较低者为right_bar:
?如果right_bar比max_right要低,那么right_bar上面能储水max_right-right_bar
?否则,right_bar比右边的柱子都要高,那么需要更新max_right的值
经过上面的处理后,递减right_bar,继续处理下一个柱子
这里可能有个疑问:为什么增加储水量时不考虑另外一个边界?原因在于每次更新max_left或max_right时,都是left_bar和right_bar中高度较低者,因此,另外一边必然存在一个边界大于max_left或max_rightclass Solution { public: int trap(vector<int>& height) { int water = 0,sz = height.size(); int left_bar = 0,right_bar = sz - 1; int max_left = 0,max_right = 0; while(left_bar < right_bar){ if(height[left_bar] < height[right_bar]){ if(height[left_bar] < max_left) water += max_left - height[left_bar]; else max_left = height[left_bar]; left_bar++; } else{ if(height[right_bar] < max_right) water += max_right - height[right_bar]; else max_right = height[right_bar]; right_bar--; } } return water; } }; //时间复杂度:O(n) 空间复杂度:O(1)
反转链表
反转一个单链表。
示例:
输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL
进阶:
你可以迭代或递归地反转链表。你能否用两种方法解决这道题?1)迭代
每次循环将curr.next修改为前一节点
因为需要将当前节点的next节点修改为前一节点,所以需要用一个指针prev记录前一节点。同时,由于修改了当前节点的next节点后,无法访问原来的next节点,因此需要一个指针next,在修改前记录原来的next节点
? 时间复杂度:O(n)
? 空间复杂度:O(1)
class Solution { public: ListNode* reverseList(ListNode* head) { ListNode *curr = head, *next = head ? head->next : NULL, *prev = NULL; while(curr){ curr->next = prev; prev = curr; curr = next; if(next) next = next->next; } return prev; } };
递归
class Solution { public: ListNode* reverseList(ListNode* head) { if(head == NULL || head->next == NULL) return head; ListNode* p = reverseList(head->next); head->next->next = head; head->next = NULL; return p; } };
两数相加
给出两个 非空 的链表用来表示两个非负的整数。其中,它们各自的位数是按照 逆序 的方式存储的,并且它们的每个节点只能存储 一位 数字。
如果,我们将这两个数相加起来,则会返回一个新的链表来表示它们的和。
您可以假设除了数字 0 之外,这两个数都不会以 0 开头。
示例:
输入:(2 -> 4 -> 3) + (5 -> 6 -> 4) 输出:7 -> 0 -> 8 原因:342 + 465 = 807
//?可以在新链表头结点之前创建一个辅助节点来便利处理
//?要注意链表处理完后可能还有进位,此时还需创建一个节点
class Solution {
public:
ListNode *addTwoNumbers(ListNode *l1, ListNode *l2) {
ListNode preHead(0), *p = &preHead;
int extra = 0;
while (l1 || l2 || extra) {
int sum = (l1 ? l1->val : 0) + (l2 ? l2->val : 0) + extra;
extra = sum / 10;
p->next = new ListNode(sum % 10);
p = p->next;
l1 = l1 ? l1->next : l1;
l2 = l2 ? l2->next : l2;
}
return preHead.next;
}
};
合并K个升序链表
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例 1:
输入:lists = [[1,4,5],[1,3,4],[2,6]] 输出:[1,1,2,3,4,4,5,6] 解释:链表数组如下: [ 1->4->5, 1->3->4, 2->6 ] 将它们合并到一个有序链表中得到。 1->1->2->3->4->4->5->6
class Solution {
public:
ListNode* merge(ListNode* l1, ListNode* l2) {
if (l1 == NULL) return l2;
if (l2 == NULL) return l1;
ListNode* pre = new ListNode(0);
ListNode* cur = pre;
while (l1 && l2) {
if (l1->val <= l2->val) {
cur->next = l1;
l1 = l1->next;
} else {
cur->next = l2;
l2 = l2->next;
}
cur = cur->next;
}
cur->next = l1 != NULL ? l1 : l2;
return pre->next;
}
ListNode* mergeKLists(vector<ListNode*>& lists) {
if (lists.size()== 0) return NULL;
// 分组间隔每次扩大两倍
int len = lists.size();
int interval = 1;
while (interval < len) {
// 根据当前间隔,两两合并,合并后的结果保存在两个链表的第一个
for (int i = 0; i < len - interval; i += 2 * interval) {
lists[i] = merge(lists[i], lists[i + interval]);
}
interval *= 2;
}
return lists[0];
}
};
买卖股票的最佳时机
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
如果你最多只允许完成一笔交易(即买入和卖出一支股票一次),设计一个算法来计算你所能获取的最大利润。
注意:你不能在买入股票前卖出股票。
示例 1:
输入: [7,1,5,3,6,4] 输出: 5 解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。 注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
示例 2:
输入: [7,6,4,3,1] 输出: 0 解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int buy = INT_MAX,sell,maxprofit = 0;
for(int i = 0;i < prices.size();i++){
if(prices[i] < buy)
buy = prices[i];
else if(prices[i] > buy){
sell = prices[i];
if(sell - buy > maxprofit) maxprofit = sell - buy;
}
}
return maxprofit;
}
};
买卖股票的最佳时机 II
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
class Solution {
public:
int maxProfit(vector<int>& prices) {
if(prices.size() <= 1) return 0;
vector<int> dp(3,0);
vector<int> local(3,0);
for(int i = 1;i < prices.size();i++)
for(int k = 2;k > 0;k--){
local[k] = prices[i] - prices[i - 1] + max(dp[k - 1],local[k]);
dp[k] = max(dp[k],local[k]);
}
return dp[2];
}
};
最大子序和
给定一个整数数组
nums
,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。示例:
输入: [-2,1,-3,4,-1,2,1,-5,4] 输出: 6 解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
进阶:
如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。
class Solution { public: int maxSubArray(vector<int>& nums) { int n = nums.size(); if(n == 0) return 0; int tempsum = nums[0], maxsum = nums[0]; for(int i=1;i<n;i++) { if(tempsum < 0) { //tempsum < 0时,代表对之后的结果无增益 tempsum = nums[i]; //更新tempsum }else{ tempsum += nums[i]; } maxsum = tempsum > maxsum ? tempsum : maxsum; } return maxsum; } };
最小栈
设计一个支持
push
,pop
,top
操作,并能在常数时间内检索到最小元素的栈。
push(x)
—— 将元素 x 推入栈中。
pop()
—— 删除栈顶的元素。
top()
—— 获取栈顶元素。
getMin()
—— 检索栈中的最小元素。class Solution { public: void push(int value) { dataStack.push(value); if(minStack.empty() || !minStack.empty() && value < minStack.top()){ minStack.push(value); } else minStack.push(minStack.top()); } void pop() { if(dataStack.empty()) throw new exception(); dataStack.pop(); minStack.pop(); } int top() { if(dataStack.empty()) throw new exception(); return dataStack.top(); } int min() { if(dataStack.empty()) throw new exception(); return minStack.top(); } private: stack<int> dataStack; stack<int> minStack; };
LRU 缓存机制
运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。
实现
LRUCache
类:
LRUCache(int capacity)
以正整数作为容量capacity
初始化 LRU 缓存int get(int key)
如果关键字key
存在于缓存中,则返回关键字的值,否则返回-1
。void put(int key, int value)
如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。进阶:你是否可以在
O(1)
时间复杂度内完成这两种操作?
class LRUCache {
public:
LRUCache(int capacity) : hashtable() , ls() , cap(capacity) , curr(0) {}
int get(int key) {
if(hashtable.find(key) == hashtable.end()) return -1;
auto itr = hashtable[key];
if(itr == ls.begin())
return itr->second;
else{
ls.push_front(pair<int,int>(itr->first,itr->second));
auto new_itr = ls.begin();
hashtable[key] = new_itr;
ls.erase(itr);
return ls.front().second;
}
return 1;
}
void put(int key, int value) {
if(hashtable.find(key) != hashtable.end()){
ls.erase(hashtable[key]);
ls.push_front(pair<int,int>(key,value));
auto new_itr = ls.begin();
hashtable[key] = new_itr;
return;
}
if(curr == cap){
hashtable.erase(ls.back().first);
ls.pop_back();
curr--;
}
ls.push_front(pair<int,int>(key,value));
auto new_itr = ls.begin();
hashtable[key] = new_itr;
curr++;
}
private:
unordered_map<int,list<pair<int,int>>::iterator> hashtable;
list<pair<int,int>> ls;
int cap;
int curr;
};
x 的平方根
实现
int sqrt(int x)
函数。计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
#include<iostream> using namespace std; int main() { double eps = 1e-6; double k = 0.8; // 输入正数 double l = 0.0,r,mid; if (k>=1) r = k; // 若输入正数大于1,则右端点设为 k if (k<1) r = 1; // 若输入整数小于1,则右端点设为 1 while (fabs(l-k/l)>eps) { mid = l + (r - l) /2 ; if (mid<k/mid) { l = mid; } else { r = mid; } } cout << l << endl; return 0; }
UTF-8 编码验证
UTF-8 中的一个字符可能的长度为 1 到 4 字节,遵循以下的规则:
- 对于 1 字节的字符,字节的第一位设为0,后面7位为这个符号的unicode码。
- 对于 n 字节的字符 (n > 1),第一个字节的前 n 位都设为1,第 n+1 位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的unicode码。
这是 UTF-8 编码的工作方式:
Char. number range | UTF-8 octet sequence (hexadecimal) | (binary) --------------------+--------------------------------------------- 0000 0000-0000 007F | 0xxxxxxx 0000 0080-0000 07FF | 110xxxxx 10xxxxxx 0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx 0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
给定一个表示数据的整数数组,返回它是否为有效的 utf-8 编码。
注意:
输入是整数数组。只有每个整数的最低 8 个有效位用来存储数据。这意味着每个整数只表示 1 字节的数据。class Solution { public: bool validUtf8(vector<int>& data) { int size = data.size(); for(int i = 0; i < size; i++) { int bitCount = 0; for(int j = 7; j > 0; j--) { if(((data[i] >> j) % 2) == 0) break; bitCount ++; } if(bitCount == 1 || bitCount > 4) return false; if(bitCount + i > size) return false; for(int j = 0; j < bitCount - 1; j++) if(((data[i + j + 1] >> 7) % 2) == 0 || ((data[i + j + 1] >> 6) % 2) != 0) return false; if(bitCount != 0) i += bitCount - 1; } return true; } };
第二高的薪水
编写一个 SQL 查询,获取
Employee
表中第二高的薪水(Salary) 。+----+--------+ | Id | Salary | +----+--------+ | 1 | 100 | | 2 | 200 | | 3 | 300 | +----+--------+
例如上述
Employee
表,SQL查询应该返回200
作为第二高的薪水。如果不存在第二高的薪水,那么查询应返回null
。+---------------------+ | SecondHighestSalary | +---------------------+ | 200 | +---------------------+
SELECT (SELECT DISTINCT salary FROM employee ORDER BY salary DESC LIMIT 1 OFFSET 1) AS secondHighestSalary
strcmp
//字符串比较
int strcmp(const char *str1,const char *str2)
{
assert(str1 && str2);
//在判断是否相等时不一定要转换为unsigned char
while((*str1 == *str2) && *str1){
str1++;
str2++;
}
if(*str1 == *str2)//说明上面循环退出时*str等于0
return 0;
//128种扩展ascii码使用最高位来标识,
//所以在判断返回大于0还是小于0是,要转换为unsigned char,否则结果相反
return *(unsigned char*)str1 > *(unsigned char*)str2 ? 1 : -1;
}
strcat
//字符串拼接
char* strcat(char *strDest,const char *strSrc)
{
assert(strDest && strSrc);
char *p = strDest;
while(*p)
p++;
while(*p++ = *strSrc++);
return strDest;
}
strlen
//字符串长度计算
int strlen(const char *str)
{
assert(str);
int len = 0;
while(*str++)
len++;
return len;
}
//字符串长度计算
int strlen(const char *str)
{
assert(str);
return (*str == 0) ? 0 : 1 + strlen(str + 1);
}
memcpy
void* memcpy(void *dst,const void *src,size_t size)
{
if(dst == NULL || src == NULL){
return NULL;
}
char *pdst = (char*)dst;
char *psrc = (char*)src;
//有重叠,从高地址开始复制
if(pdst > psrc && pdst < psrc + size){
pdst = pdst + size - 1;
psrc = psrc + size - 1;
while(size--){
*pdst-- == *psrc--;
}
}
//没有重叠,从低地址开始复制
else{
while(size--){
*pdst++ = *psrc++;
}
}
return dst;
}
strcpy
//字符串拷贝
char* strcpy(char *strDest,const char *strSrc)
{
assert(strDest && strSrc);
char *p = strDest;
while(*p++ = *strSrc++);
return strDest;
}
以上是关于字节跳动算法整理的主要内容,如果未能解决你的问题,请参考以下文章
最新整理:字节跳动历年Java中高级面试题全收录!后台开发JAVA岗
字节跳动算法工程师总结:腾讯+字节+阿里面经真题汇总,含面试题+答案
字节跳动二面面试官问:JVM垃圾回收器和回收算法,我全靠这个逆天改命!
仅一年工作经验成功跳槽字节跳动,腾讯并拿到字节的offer,全靠这份面经!