1. 程式人生 > >嗨!請查收這道有趣的面試題

嗨!請查收這道有趣的面試題

1. 前言

今天和大家一起看一道以前遇到的面試題,之所以印象深刻是因為大白在兩家公司面試都被問到了,分別是蔚來汽車和曠視科技。

這道題目描述比較簡單,大致是這樣的:

使用你喜歡或者擅長的語言實現一個矩陣乘法,為了簡單起見,當做兩個n維的方陣相乘。

描述簡單的題目往往做起來並不簡單,冷靜想想,這道題其實有三層考察點:

  • 面試者對矩陣及其基本運算的掌握
  • 面試者實現基礎版本的矩陣乘法運算
  • 面試者分析和優化實現高效版本

這道題應該算是白銀題,而不是青銅題,一起來搞一下吧,鐵定會有收穫!

2.矩陣及其基本運算

說到矩陣想必各位都不陌生,大白在大一的《線性代數》和研一的《矩陣分析》兩門課都曾比較深入的學過,但是目前基本上都還給老師了。

遺忘不要緊,只要肯撿起,(偽)學霸的光環還是可以暫時回來的...

2.1 矩陣的歷史

阿瑟·凱萊被公認為矩陣論的奠基人,他開始將矩陣作為獨立的數學物件研究時,許多與矩陣有關的性質已經在行列式的研究中被發現了,這也使得凱萊認為矩陣的引進是十分自然的。
他從1858年開始,發表了《矩陣論的研究報告》等一系列關於矩陣的專門論文,研究了矩陣的運算律、矩陣的逆以及轉置和特徵多項式方程。 
此後更多的數學家開始對矩陣進行研究,埃爾米特證明了如果矩陣等於其複共軛轉置,則特徵根為實數。這種矩陣後來被稱為埃爾米特矩陣。
弗羅貝尼烏斯對矩陣的特徵方程、特徵根、矩陣的秩、正交矩陣、矩陣方程等方面做了大量工作。1878年,在引進了不變因子、初等因子等概念的同時,弗羅貝尼烏斯給出了正交矩陣、相似矩陣和合同矩陣的概念。

2.2 矩陣的用途

矩陣是高等代數學中的常見工具,矩陣的一個重要用途是解線性方程組,線性方程組中未知量的係數可以排成一個矩陣,加上常數項,則稱為增廣矩陣,另一個重要用途是表示線性變換,矩陣的特徵值和特徵向量可以揭示線性變換的深層特性。
在物理學中,矩陣於力學、電路學、光學和量子物理中都有應用,電腦科學中,三維動畫製作也需要用到矩陣。矩陣的運算是數值分析領域的重要問題。將矩陣分解為簡單矩陣的組合可以在理論和實際應用上簡化矩陣的運算。在天體物理、量子力學等領域,也會出現無窮維的矩陣,是矩陣的一種推廣。

2.3 矩陣的基本運算

矩陣的運算主要包括:加減、數乘、轉置、相乘等。其中加減、數乘比較簡單,轉置運算本文用不到,重點說一下矩陣乘法運算。

兩個矩陣相乘需要滿足一定的條件:

  • 矩陣A的列數等於矩陣B的行數
    eg:A是m*n的矩陣,B是n*p的矩陣 則二者相乘後的矩陣C=A*B為m*p的矩陣
  • 矩陣乘法不滿足交換律
    也就是說C=A*B,不一定存在D=B*A,因為B的列數並不一定等於A的行數,並且即使B*A可以相乘,那麼C和D也不一定相等

矩陣的運算過程:

以C=A*B為例來說明,矩陣相乘的運算過程本質上就是求得新矩陣的每個位置上的值,然而這個值取決於A特定行的全部元素和B特定列的全部元素的交叉乘積之和,聽起來有點繞,不要怕,寫個表示式就清楚了:

舉個栗子:

矩陣的運算圖示:

再來一個清晰版的(摘自維基百科):

2.4 階段一小結

通過前面的一些描述,我們知道矩陣這個東西曆史不算短,並且在解線性方程、電子學、力學、光學等諸多領域都有非常廣泛的用途,是個基礎的數學工具。
矩陣也包含了一些運算,本文只針對矩陣乘法做了一些描述,這些知識儲備也足夠我們完成今天的面試題啦!

3.基礎版本矩陣乘法的實現

有了前面的數學知識儲備,我們就可以放心大膽的Coding了,首先擺在眼前的問題是如何將數學模型轉換為程式程式碼?

3.1 矩陣的多維陣列表示

一維陣列我們用的非常多,多維陣列本質上就是一維陣列的集合,用STL裡面的vector表示就是:

vector<vector<int>>

不過這裡我們暫時不使用vector表示多維陣列了,而是使用C語言中多維陣列來表示,這樣確定了大小操作比較方便,那我們來寫一個4*4的陣列來表示4維的方陣:

3.2 多維陣列的儲存

理解多維陣列的儲存是解決和優化本題的關鍵,由於陣列是連續記憶體儲存的,多維陣列看做是裝載陣列的陣列,換句話說多維陣列的元素就是陣列,以a[4][4]為例就是儲存了4個一維陣列,且一維陣列的空間大小是4。

多維陣列在記憶體儲存時是連續儲存的,因此面臨一個問題就是先儲存行還是先儲存列?

  • 行優先儲存
  • 列優先儲存

畫個圖表示一下:

在C語言中使用的是行優先儲存,也就是a[0][1]和a[0][2]是相鄰的,但是a[0][1]和a[1][1]是不相鄰的,中間相隔了整個第一行的儲存長度。

3.3 基礎版本的程式碼實現

Talk Is Cheap,Show You The Code.
在開始寫程式碼之前,大白先自己人腦算了一遍,作為標準對照答案,簡單起見只寫了3維方陣,人腦版計算過程如圖(手機拍的 湊合一下吧):

由於微信顯示程式碼效果不好,因此改為貼圖片了,大白是VIM&Notepad++黨,所以其他的IDE統統不常用,看下程式碼吧:

寫好之後開始編譯,不開任何優化後執行bin檔案:

g++ martrix.cpp -o exe
./exe

輸出結果:

基礎版本輸出矩陣乘法結果:
4 20 35
24 83 144
19 76 128

這個結果對比人腦版二者是一樣的,所以基礎版本程式碼是無誤的,到這裡這道題在面試官那邊算勉強通過了。
但是這還沒完,精益求精是我猿的本色,接來下開始靈魂發問:

  • 擴大資料規模,現在給定1000維的方陣能很快搞定嗎?
  • 這個基礎版本效能ok嗎?
  • 是否還有優化空間呢?

3.4 基礎版本的效能分析

前面我們分析了多維陣列的行優先儲存,從基礎版本的程式碼可以知道對於矩陣A是按照行遍歷的,但是對於矩陣B卻是按照列來遍歷的,這樣的話貌似效能不是最好的。

因為CPU也是有快取的呀!這種情況下矩陣B的CacheMiss率非常高,但是有人可能會問從CPU快取和從記憶體讀取效能差別有多大呀?

這個問題,問的非常好,那我們就一起來看看吧!

1.CPU、快取、主存架構

我們以現在主流的CPU3級快取的處理器為例,看一下這個結構圖:

 

CPU快取通常分為L1,L2,L3三個級別:

  • L1是最接近CPU但是它容量最小,速度最快,L1快取分為資料快取L1d和指令快取L1i
  • L2快取比L1大一些並且速度要慢一些
  • L3快取是三級快取中容量最大的,也是最慢的一級快取

從各級快取讀取資料的時鐘週期消耗如圖:

2.優化方向

從上面的分析可以知道,各級快取和記憶體的存取時間差距還是非常大的,記憶體的耗時是L1的40-60倍,因此有效降低CacheMiss可以降低時鐘週期,也就是降低程式的時間消耗。

但是對於矩陣乘法運算而言,是必然會存在列的上下移動,這樣的話CacheMiss貌似是不可避免了,一時間大白慌了神。

4.高效版本的實現

路子總是人想的,我們從數學的等價運算上來考慮試一試:

  • 之前的做法是將C[i][j]的一個元素正確算完才算結束,然後進行下一個,這樣就造成了跳著的CacheMiss
  • 換一種思路,每次都儘量沿著兩個矩陣的行走,但是不算完C[i][j],只算一點,迴圈結束最終就算完了

上面說的算是心法吧,大白麵試時確實沒有想出來這種做法,事後研究了一下,覺得辦法很不錯,我來寫一寫來詳細說明一下這個過程吧:

優化版本程式碼實現

大白試著實現了上述優化過程的程式碼,寫完之後感覺和基礎版本很像,不仔細看都區分不出來,本質上卻有很大不同,程式碼如下:

寫好之後開始編譯,不開任何優化後執行bin檔案:

g++ martrix_v2.cpp -o exe
./exe

輸出結果:

優化版本輸出矩陣乘法結果:
4 20 35
24 83 144
19 76 128

優化思維

說到底優化版本就是將常見的交叉相乘累加一次算完轉換為兩個行運算拆分成子結果最終累加,本質上是一樣的結果,但是在計算機看來區別卻很大,所以有時候要將人的思維等價轉換為符合計算機的思維。

5.總結

本文從一道面試題作為出發點,展開了關於矩陣乘法的實現,文章中使用了兩個版本來實現,鑑於驗證正確性考慮並沒有擴充套件到更大的資料規模。

不過我想應該是有線上的矩陣運算器,這樣可以做個標準答案,然後使用兩個版本擴大到1000唯甚至更多來看兩個版本的耗時更有說服力,這個後面弄一下吧,時間關係本次就只能寫這麼多啦!

重要的是一種思維方式:人的思維方式不一定適用於計算機,因此有時候為了提高程式的效能我們要做等價轉換,讓計算機覺得很nice,這樣寫出了的程式碼才是精益求精的。

暫時就寫這麼多吧,昨晚修bug太晚,早上匆忙爬起繼續碼字,太困了...

最後希望各位讀者覺得本文還不錯,那就轉發分享一下吧,這樣大白會更加有動力,提高產量的同時保證質量!

6.巨人的肩膀

https://lwn.net/Articles/252125/

https://blog.slinuxer.com/2014/10/cache-optimization

C++ 多維陣列 | 菜鳥教程

一種高效的矩陣乘法實現 - lisperl - 部落格園

7.關於我

歡迎關注