存STL與多線程考點精講)
2017年秋天我在圖書館刷到愛奇藝的校招筆試通知點進去做完那套C開發(fā)工程師筆試卷整整花了一晚上復盤。說起來這套卷子不算偏但也正因為不偏它特別能暴露基礎功底的薄弱點。指針、內(nèi)存、STL、算法、多線程基本把C方向校招筆試的高頻考點都覆蓋了一遍題目難度呈現(xiàn)明顯的梯度前面還能靠記憶應付越往后越考驗真功夫。我當時做完最大感受是這套卷子篩的不是誰刷題多而是誰對C這門語言有真正體系化的理解。今天我把這類卷子的出題邏輯、高頻考點和答題策略拆開講一遍給準備秋招的同學做個參考。1. 這套卷子的出題邏輯它到底想篩什么樣的人1.1 筆試形式與C崗位的考察范圍先說形式。這類大廠C方向的秋招筆試卷通常會分為三個部分選擇題、簡答題和編程題。選擇題大概20到30道覆蓋語言基礎、操作系統(tǒng)、網(wǎng)絡、數(shù)據(jù)結(jié)構(gòu)簡答題一般2到3道考設計思路或問題分析編程題有2到3道要求在線編碼跑通測試用例。整套卷子時間給得不多有些公司甚至是90分鐘到120分鐘完成所有題目這就要求答題節(jié)奏非常緊湊。愛奇藝2017年這套卷子的整體風格是基礎題量大、編程題有區(qū)分度。選擇題里的C語法和內(nèi)存題數(shù)量不少而且選項之間非常接近如果平時只是眼熟而沒有真正理解很容易掉坑。編程題里既有可以直接套模板的經(jīng)典題也有需要現(xiàn)場推理的變種題靠死記硬背過不了最后那幾道。從崗位屬性來說C開發(fā)工程師在互聯(lián)網(wǎng)公司主要做的是底層基礎設施、音視頻處理、網(wǎng)絡服務和高性能組件。愛奇藝這類視頻平臺對C的需求尤其集中在播放器內(nèi)核、CDN調(diào)度、流媒體傳輸、轉(zhuǎn)碼服務這一條鏈路上。筆試考的東西其實就是在為這些業(yè)務方向篩選候選人。1.2 從愛奇藝的業(yè)務屬性反推考點偏好視頻平臺的核心技術(shù)棧里C主要出現(xiàn)在三個方面一是流媒體服務端需要處理高并發(fā)連接和海量數(shù)據(jù)轉(zhuǎn)發(fā)二是音視頻處理涉及編解碼、轉(zhuǎn)碼、畫質(zhì)增強對內(nèi)存管理和性能極度敏感三是播放器內(nèi)核和客戶端組件要求代碼在資源受限的終端設備上穩(wěn)定運行。這三個方向?qū)腃技能要求其實很明確內(nèi)存管理和對象生命周期要扎實這塊直接決定線上服務穩(wěn)定性多線程和并發(fā)控制要熟練因為流媒體服務本質(zhì)就是并發(fā)系統(tǒng)STL和算法要熟海量數(shù)據(jù)的排序、查找、去重是日常工作網(wǎng)絡編程和系統(tǒng)調(diào)用要懂這涉及傳輸協(xié)議和IO模型。所以這套卷子里出現(xiàn)的知識點并不是出題人隨手抓的而是業(yè)務倒推考點的結(jié)果。你在復習的時候如果能站在這個角度理解題目就不容易覺得某些偏怪問題沒有意義。比如虛函數(shù)考察的是你對對象內(nèi)存布局的理解放到實際場景里就是基類指針能不能安全地操作派生類對象這在組件化架構(gòu)中天天遇到。1.3 這套卷子的時間節(jié)奏與丟分重災區(qū)我當時給自己模擬了真實考試的時間分配發(fā)現(xiàn)最耗時間的不是編程題而是前面的選擇題和簡答題。選擇題里很多是下列哪個說法是錯誤的這類否定式提問每個選項都很像是對的需要逐個排查。一道這樣的題就可能花掉3到5分鐘20道基礎題做完40分鐘就沒了。結(jié)合周圍同學反饋這套卷子丟分比較多的位置通常有三處內(nèi)存和指針相關(guān)的題目尤其涉及二維指針、指針數(shù)組、數(shù)組指針的辨析拷貝構(gòu)造與移動語義的調(diào)用時機判斷題目改一個參數(shù)傳遞方式答案就完全不同編程題里的邊界條件處理測試用例隱藏了空指針、越界和溢出導致看似正確的代碼只有部分用例通過。搞清楚這些丟分點后你會發(fā)現(xiàn)復習重心其實很明確。基礎題靠體系化復習編程題靠高頻題型訓練兩者缺一不可。下面我把每類考點的答題思路拆開細講。2. 語言基礎題拆解指針、內(nèi)存和對象生命周期2.1 指針與引用的組合陷阱C筆試題里指針和引用是一個怎么考都不過時的主題。這套試卷里涉及指針的部分主要不是讓你寫指針運算而是給你一段代碼讓判斷輸出結(jié)果或指出錯誤。比如下面這類問題int a[5] {1, 2, 3, 4, 5}; int *p a; cout *(p) endl; cout *p endl; cout *p 1 endl;很多人會在這種題上翻車原因是把p和p的副作用時機搞混了。*(p)先把p指向的值取出來然后p后移一位所以輸出1*p先把p前移一位再取值此時p指向a[2]輸出3*p 1由于運算符優(yōu)先級等價于(*p) 1輸出4。這類題考的不是你記沒記住優(yōu)先級表而是你對表達式求值過程中指針狀態(tài)如何變化有沒有清晰認知。指針數(shù)組和數(shù)組指針也是一個經(jīng)典區(qū)分點。int *p[3]表示一個數(shù)組數(shù)組元素是三個int*指針int (*p)[3]表示一個指針指向含有三個int元素的數(shù)組。在二維數(shù)組傳參時才分得清這兩種寫法的差異。不少同學在簡化代碼時把int (*p)[3]錯寫成int *p[3]編譯雖然能過但語義完全錯了。2.2 構(gòu)造、析構(gòu)、拷貝與移動的調(diào)用時機C筆試里考察對象生命周期的方式很多最常見的是輸出題讓你數(shù)一個類被構(gòu)造了幾次、析構(gòu)了幾次、拷貝了幾次。比如傳值、傳引用、返回臨時對象的情況class Test { public: Test() { cout ctor endl; } Test(const Test t) { cout copy endl; } Test(Test t) { cout move endl; } ~Test() { cout dtor endl; } }; Test func() { Test t; return t; } int main() { Test a func(); }在C11之前這段代碼可能會觸發(fā)兩次構(gòu)造和多次拷貝在C11之后由于返回值優(yōu)化和移動語義的存在實際輸出可能簡化。這里的關(guān)鍵點是編譯器優(yōu)化RVO/NRVO)并不是強制行為而移動構(gòu)造的優(yōu)先級高于拷貝構(gòu)造當你有右值引用時return t會優(yōu)先走移動而不是拷貝。當時這套卷子在移動語義上挖了一個很細的坑類里如果手動實現(xiàn)了析構(gòu)函數(shù)或者拷貝構(gòu)造函數(shù)編譯器就不會自動生成移動構(gòu)造函數(shù)。這意味著即使你的類滿足移動條件也只能退回到拷貝性能差異在上千萬元素容器里非常明顯。這類題表面考語法實際考的是你知不知道C11規(guī)則中的隱式函數(shù)生成條件。2.3 虛函數(shù)與多態(tài)的實現(xiàn)機制虛函數(shù)是C筆試的必考內(nèi)容考察方向一般有兩種一種是讓你描述虛表vtable的實現(xiàn)機制另一種是給一段多態(tài)代碼讓判斷輸出。描述虛表時關(guān)鍵是講清楚三點每個包含虛函數(shù)的類都有一張?zhí)摵瘮?shù)表表中存放虛函數(shù)地址每個對象內(nèi)部有一個虛表指針vptr指向所屬類的虛表構(gòu)造對象時vptr會被設置為指向當前正在構(gòu)造的類的虛表所以在構(gòu)造函數(shù)里調(diào)用虛函數(shù)不會觸發(fā)多態(tài)。關(guān)于最后一點經(jīng)常有人踩坑。因為父類構(gòu)造過程中子類部分還沒有初始化如果這時候調(diào)用虛函數(shù)走的是子類實現(xiàn)子類成員可能還沒構(gòu)造程序就會出問題。C選擇了在構(gòu)造期間把vptr指向當前類從機制上避免這個風險。筆試題里讓你判斷new Derived()之后構(gòu)造函數(shù)中虛函數(shù)調(diào)用的輸出答案就是父類版本。這套卷子還考過一個很容易錯的知識點析構(gòu)函數(shù)為什么要聲明為虛函數(shù)。如果基類析構(gòu)函數(shù)不是虛的通過基類指針刪除派生類對象時只會調(diào)用基類析構(gòu)函數(shù)派生類資源無法釋放造成內(nèi)存泄漏。代碼層面對應的場景是所有組件類、接口類、抽象基類這類體系設計中的基類析構(gòu)函數(shù)幾乎都應該是虛的。3. 算法與編程題實戰(zhàn)字符串、排序和數(shù)學計算的拿分策略3.1 字符串類題目從字符數(shù)組的轉(zhuǎn)換說起C筆試題里字符串處理是編程題??投医?jīng)常結(jié)合字符數(shù)組和std::string互相轉(zhuǎn)換來考。很多同學對string用得很熟一旦碰到C風格字符串就懵。比如要求按指定分隔符把字符串拆成數(shù)組有人會用strtok但這個函數(shù)會修改原字符串并且不是線程安全的在多線程題目場景下容易被扣分。更好的做法是使用std::string的find和substr組合實現(xiàn)分詞std::vectorstd::string split(const std::string s, char delim) { std::vectorstd::string result; std::string::size_type start 0; auto pos s.find(delim); while (pos ! std::string::npos) { result.push_back(s.substr(start, pos - start)); start pos 1; pos s.find(delim, start); } result.push_back(s.substr(start)); return result; }寫這類代碼時注意find的第二個參數(shù)表示從哪個位置開始搜索避免漏掉最后一個分隔符后面的內(nèi)容。字符串轉(zhuǎn)數(shù)字也是高頻題std::stoi和std::to_string雖然方便但筆試題更愛考察手寫轉(zhuǎn)換邏輯因為你得自己處理符號位、溢出和非法字符。3.2 排序算法冒泡和選擇之外的考察角度排序算法在筆試里的考法分為兩種一種是直接讓你實現(xiàn)某個排序算法另一種是考察算法特性和復雜度。選做題里經(jīng)常出現(xiàn)冒泡排序和選擇排序因為代碼短、易于驗證。但這里有個容易被忽略的知識點冒泡排序是穩(wěn)定排序選擇排序是不穩(wěn)定排序。為什么冒泡排序只在相鄰元素之間做交換相等元素的相對順序不會改變選擇排序在每一輪把一個元素放到最終位置如果當前輪發(fā)現(xiàn)一個更小值會和前面的元素交換這個交換可能跨過相等的元素導致相對順序改變??此浦皇前斯晌膶嶋H在排序?qū)ο蟮膶傩杂行蛐砸笊虾荜P(guān)鍵比如按分數(shù)降序、分數(shù)相同按學號升序的場景??焖倥判蛟诠P試中出現(xiàn)頻率也很高但多數(shù)不是讓你寫基本版而是考察如何優(yōu)化。比如三數(shù)取中、小區(qū)間插入排序、尾遞歸優(yōu)化。我見過一道題是要求手寫快速排序的partition函數(shù)并保證把所有等于pivot的元素集中在中間這就是三路快排的思路。寫三路快排的要點是維護lt、gt兩個邊界把小于pivot、等于pivot、大于pivot分成三個區(qū)域避免重復元素的性能退化。3.3 數(shù)學類題目快速冪與最小公倍數(shù)的邊界處理數(shù)學類題目看著不起眼但往往是編程題里的送分題前提是你能快速寫出無Bug的版本??焖賰缡歉哳l考點中的高頻描述很簡單計算a的b次方模mod。如果直接循環(huán)乘b次b到1e9量級就超時了所以要用二分思想long long quickPow(long long a, long long b, long long mod) { long long result 1; a % mod; while (b 0) { if (b 1) result result * a % mod; a a * a % mod; b 1; } return result; }寫這個代碼有三個容易出錯的地方第一步要a % mod否則a可能溢出乘法時要用long long接收中間結(jié)果因為兩個1e9量級的數(shù)相乘會超過int范圍循環(huán)條件是while (b 0)而不是while (b)雖然效果一樣但顯式比較更清晰不容易讓閱卷人誤解。最小公倍數(shù)的題目也不少見核心公式是lcm(a, b) a / gcd(a, b) * b。注意這里要先除后乘否則a * b可能溢出。如果是多個整數(shù)求最小公倍數(shù)就兩兩迭代計算。筆試里常給幾個較大的數(shù)比如6、8、12、15很多人能算對但如果換成包含質(zhì)數(shù)的長列表并且在代碼里要求處理就需要確保gcd函數(shù)在遞歸和迭代兩種寫法下都能正確工作尤其注意gcd中a或b為零的邊界情況。3.4 進階結(jié)構(gòu)單調(diào)棧與鏈表操作的常見考法這套卷子或者同類互聯(lián)網(wǎng)公司的筆試卷編程題里偶爾會拔高到單調(diào)棧這種進階數(shù)據(jù)結(jié)構(gòu)。單調(diào)棧的典型應用是尋找每個元素下一個更大或更小的元素位置比如每日溫度類問題。核心理解是元素入棧時保持棧內(nèi)單調(diào)性出棧時就可以確定某些答案。寫單調(diào)棧的代碼初學者最常犯的錯誤是混淆棧中存值和存下標的區(qū)別。大多數(shù)情況下應該存儲下標因為最終要求的往往是位置距離或者需要根據(jù)下標去原數(shù)組取值。如果存值當數(shù)組里有重復元素時索引信息會丟失。鏈表操作中反轉(zhuǎn)鏈表、合并有序鏈表、判斷鏈表是否有環(huán)是三個標配題。反轉(zhuǎn)鏈表迭代寫法要維護三個指針順序上容易錯ListNode* reverseList(ListNode* head) { ListNode* prev nullptr; ListNode* curr head; while (curr ! nullptr) { ListNode* next curr-next; curr-next prev; prev curr; curr next; } return prev; }這道題的關(guān)鍵是在改變curr-next之前先把next保存下來否則鏈表斷掉。很多面試官會追問如果鏈表有環(huán)會怎樣、如果要求遞歸實現(xiàn)呢所以現(xiàn)場編碼時要把邊界情況一并考慮清楚。4. 多線程、設計模式與C新特性容易拖后腿的半基礎題4.1 多線程同步從互斥鎖到ABA問題多線程是互聯(lián)網(wǎng)公司C筆試里繞不開的題因為它直接對應真實服務端的高并發(fā)場景?;A考察方向包括互斥鎖、條件變量、讀寫鎖、原子操作以及死鎖的形成條件。一個經(jīng)典考題是多個線程對同一個變量進行自增操作如何保證結(jié)果正確。最直接的回答是加互斥鎖但要注意自增操作count不是原子的它包含讀、加、寫三步。用C11的std::atomicint可以解決但如果你在代碼里用了兩次原子操作做比較并交換就會引入ABA問題。ABA問題的場景是線程A讀取到值X線程B把X改成Y又改回X線程A的CAS操作無法察覺中間的變動。這在無鎖數(shù)據(jù)結(jié)構(gòu)中特別危險比如無鎖棧中可能因為ABA問題導致重復釋放同一塊內(nèi)存。解決辦法一般是引入版本號或者使用帶有標簽的指針每次修改帶上遞增的標簽CAS時比較值的同時比較標簽。筆試中能說出這層說明你真的理解并發(fā)場景下的內(nèi)存安全問題。另外還有個細節(jié)容易被忽略std::mutex類型的變量不能拷貝所以包含互斥鎖的類不能直接放進std::vector或使用默認拷貝構(gòu)造。如果類里需要互斥鎖又必須實現(xiàn)拷貝只能自定義拷貝邏輯這一般在筆試簡答題里出現(xiàn)。4.2 設計模式的經(jīng)典問法與答題思路C方向筆試涉及設計模式時最??嫉氖菃卫J狡浯问怯^察者模式、工廠模式。單例模式問你如何實現(xiàn)線程安全的單例這道題有一個標準的進化路線懶漢式加鎖在獲取實例的成員函數(shù)里加鎖但性能差雙重檢查鎖先判斷指針是否為空為空才加鎖加鎖后再判斷一次注意防止指令重排序要加內(nèi)存屏障C11之后的靜態(tài)局部變量初始化編譯器保證線程安全。class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; private: Singleton() {} };這個寫法的核心在static局部變量C11標準規(guī)定它的初始化是線程安全的所以最簡潔高效的實現(xiàn)方式就是靠它。把構(gòu)造函數(shù)設為私有并把拷貝構(gòu)造刪除防止外部創(chuàng)建實例。觀察者模式的考察方式一般是用一句話說明模式中Subject和Observer的關(guān)系或者讓你寫一個簡化版本。答題時不需要硬背UML圖只要說清主題對象維護一個觀察者列表狀態(tài)變化時遍歷列表通知觀察者更新即可寫代碼時用std::vectorstd::functionvoid()來存放回調(diào)比定義抽象觀察者基類更符合現(xiàn)代C的做法。4.3 C11之后的新特性constexpr、智能指針與回調(diào)C11是C發(fā)展史的分水嶺筆試中涉及新特性的題目逐年增加。一個常見的送分題是constexpr是哪個C版本引入的。答案是C11引入C14放寬了函數(shù)內(nèi)可以包含的邏輯C17又允許在if和switch中聲明變量C20進一步支持constexpr虛函數(shù)和constexpr動態(tài)分配。這個問題不難但很多人會答錯成C14或C17。智能指針也是重點。unique_ptr是獨占所有權(quán)語義不可拷貝只能移動shared_ptr是共享所有權(quán)語義通過引用計數(shù)管理生命周期weak_ptr用于打破循環(huán)引用它不會增加引用計數(shù)。筆試里經(jīng)常給一段代碼問你shared_ptr管理的對象什么時候析構(gòu)。關(guān)鍵是要能發(fā)現(xiàn)循環(huán)引用兩個對象互相持有對方的shared_ptr引用計數(shù)永遠不為零析構(gòu)函數(shù)永遠不會被調(diào)用。解決辦法就是把其中一個方向的指針改為weak_ptr?;卣{(diào)函數(shù)的題目這幾年越來越多主要問法有回調(diào)函數(shù)是什么、在C里怎么實現(xiàn)。從最傳統(tǒng)C函數(shù)指針到std::functionstd::bind再到C11的lambda表達式三層遞進。答題時寫出lambda版本是最討巧的因為代碼簡潔且捕獲列表能控制捕獲方式std::functionint(int, int) add [](int a, int b) { return a b; };如果深究還可以講一下回調(diào)在異步IO、定時器、事件循環(huán)中的底層作用。理解到這個層面筆試的簡答題分數(shù)基本穩(wěn)了。4.4 現(xiàn)場答題的代碼風格與踩坑預防編程題除了算法正確性代碼風格也會影響整體評價。我當時總結(jié)出幾條實用原則變量命名要有含義i、j、k可以用在循環(huán)里但最好不要出現(xiàn)tmp1、tmp2這種寫完代碼要自查邊界條件空數(shù)組、單元素數(shù)組、全相同元素數(shù)組、指針為空的情況涉及數(shù)組下標的地方留意是不是會越界尤其是while循環(huán)里同時訪問i和i1的情況能用常量引用做參數(shù)的就用常量引用避免無意義拷貝這既是性能優(yōu)化也是代碼習慣的體現(xiàn)。還有一個小技巧筆試環(huán)境沒有本地IDE的自動糾錯代碼寫完之后自己是沒法編譯運行的所以要靠人工編譯自查。我會在腦中模擬一次執(zhí)行過程用一個小例子從頭走一遍比如排序算法用一個5元素數(shù)組模擬基本能發(fā)現(xiàn)絕大多數(shù)低級錯誤。5. 復盤與延伸這套卷子留給今天的備考建議5.1 筆試后的復盤方法不管是這套愛奇藝的卷子還是其他公司的筆試卷考完以后最重要的事是復盤不是看分數(shù)。復盤第一步是把每道題按會做但錯了蒙對的完全不會三個標簽分類。第二步是回來查每一個錯題背后的知識點找到知識盲區(qū)后補一輪系統(tǒng)學習而不是只看這道題的解析。我當時會把所有錯題對應的知識點整理成一份清單比如指針數(shù)組和數(shù)組指針辨析移動構(gòu)造的生成條件單調(diào)棧應用場景。每收集一個新知識點就往清單里加到秋招結(jié)束時這份清單已經(jīng)覆蓋了上百個考點。筆試前重看一遍這份清單比刷一大堆新題更有針對性。5.2 從筆試到技術(shù)面試的知識銜接筆試通過后緊接著是技術(shù)面試很多在筆試里考的知識點會在面試中以追問形式出現(xiàn)。比如筆試考了單例模式面試就會追問靜態(tài)局部變量初始化為什么線程安全或者如果我需要提前釋放單例對象怎么辦。因此筆試復盤的內(nèi)容其實也是技術(shù)面試的復習素材你需要從知道怎么回事升級到能講清楚原理。編程題也一樣筆試里寫了快速冪面試時可能會讓你口頭分析復雜度并說明為什么取模要分布在整個計算過程中。準備面試的時候把筆試中遇到的每個知識點都往深處想一想基本就能覆蓋大部分問題。5.3 幾條具體的刷題和復習建議結(jié)合我自己的經(jīng)驗給準備秋招C方向的同學幾條實操建議語言基礎要系統(tǒng)過一遍推薦找一本講C原理的書完整讀下來做筆記、寫示例代碼不要只看博客碎片算法題按專題刷數(shù)組、字符串、鏈表、樹、動態(tài)規(guī)劃、數(shù)學計算各找20到30道代碼手寫到熟練為止多線程、設計模式這類半基礎題一定要動手寫demo光看概念記不住比如自己實現(xiàn)一個線程安全的單例再用兩個線程并發(fā)調(diào)用測試每次筆試完都要把錯題整理到個人題庫里標記好錯誤原因和正確思路。如果時間有限優(yōu)先保證基礎和常見算法題的正確率這兩項撐起了筆試的大部分分數(shù)。我之前見過不少同學在偏題怪題上花了很多時間結(jié)果基礎選擇題錯一片編程題也沒寫完非??上А0盐兆∽约耗芊€(wěn)穩(wěn)拿分的部分再逐步提升難度這才是校招筆試最務實的策略。