Android程式設計師面試會遇到的演算法系列:
今年可謂是跌宕起伏的一年,幸好結局還算是圓滿。開年的時候由於和公司CTO有過節,被"打入冷宮",到下半年開始找工作,過程還是蠻艱辛。先分享一下offer的情況
國內的有
1.阿里口碑(offer)
2.Wish(offer)
3.Booking(Offer)
4.今日頭條(Offer)
5.Airbnb(北京)被拒
最讓我開心的是拿到了矽谷的offer!
FaceBook Menlo Park總部的offer
Amazon 西雅圖總部 offer
在面試的過程中我深深的感受到,對於一個優秀的安卓開發來說,首先擺在第一位的還是他/她作為一個軟體工程師的基本素養。無論你是做前端還是後端,最後定義你的優秀程度的還是作為軟體工程師的基本素養,學習能力和程式設計能力,還有設計能力。我自己在現在的公司也做過面試官,發現新加坡的大部分碼農(東南亞的碼農),對基礎的程式設計能力實在是有所欠缺,熟練的使用API卻不能理解為什麼。
很多同學會在長久以往的業務邏輯開發中慢慢迷失,逐漸的把寫程式碼變成了一種習慣,而沒有再去思考自己程式碼的優化,結構的調整。這個現象不止是安卓開發的小夥伴有,任何大公司的朋友都會遇到。所以我這一系列的文章打算深入的講解一下對於安卓程式設計師面試中可能遇到的演算法。也希望能培養大家多思考,業餘時間多動手寫好程式碼,優質程式碼的習慣。
那麼第一篇我打算著重講一下二叉樹的問題。
1.二叉樹的遞迴(深度優先)處理
相信大家以前在學習演算法與資料結構的時候都遇到過。比如說,列印二叉樹前序,中序,後序的字串這種問題。一般來說我們會選擇使用遞迴的形式來列印,比如說
/**
** 二叉樹節點
**/
public class TreeNode{
TreeNode left;
TreeNode Right;
int value;
}
//中序
public void printInoderTree(TreeNode root){
//base case
if(root == null){
return;
}
//遞迴呼叫printTree
printInoderTree(root.left);
System.out.println(root.val);
printInoderTree(root.right);
}
//中序
public void printPreoderTree(TreeNode root){
//base case
if(root == null){
return;
}
//遞迴呼叫printTree
System.out.println(root.val);
printPreoderTree(root.left);
printPreoderTree(root.right);
}
複製程式碼
一開始上學的時候,我這幾段程式碼都是背下來的,完全沒有理解其中的奧妙。對於二叉樹的遞迴操作,其實正確的理解方式
- 把每次遞迴想象成對其子集(左右子樹)的一個操作,假設該遞迴已經可以處理好左右子樹,那麼根據已經處理好的左右子樹在調整根節點。
這樣的思想其實和分而治之 分治法 相似,就是把一個大問題先分成小問題,再去解決。我們還是以二叉樹的中序列印為例子。
因為中序列印我們需要以左中右的順序列印二叉樹,以下圖為例子我們分解一下問題。
上面這個gif詳細的解釋了怎麼叫分而治之,首先,我們假設A節點的左右子樹分開而且已經列印完畢,那麼只剩下A節點需要單獨處理,那麼久列印它。對於B子樹來說,我們以同樣的思維處理。所以動圖裡面是B子樹先鋪平,然後輪到A節點,最後到C子樹。
最後我們需要考慮一下這個遞迴的結束條件。我們假設A節點左右子樹都為空,null,那麼在呼叫該方法的時候我們需要在Node為空的時候直接返回不做任何操作。該條件我們一般稱為遞迴的Base Case。每個遞迴都是這樣,先想好我們怎麼把問題分治
, 再考慮base case
是哪些,怎麼處理,我們的遞迴就結束了。
問題來了,我們明明要講深度優先,為什麼講起遞迴了。兩者的聯絡是什麼?
其實遞迴對於很多資料結構來說,就是深度優先,比如二叉樹,圖。因為在遞迴的過程中,我們就是在一層一層的往下走,比如對於二叉樹的中序列印來說,我們遞迴樹的左節點,除非左節點為空,我們會一直往下走,這本身就是深度優先了。所以一般來說
,對於深度優先,我們都會用遞迴來解決,因為寫起來最方便。當然我們深度優先如果不想用遞迴,還可以使用棧(Stack)
來解決,我們在以後的文章來講(不過大家需要知道的是,遞迴本身就是使用方法棧的一種操作,聯想一下我們常常聽到的StackOverFlow,你應該能明白其中的奧妙了吧)。
好!相信我已經勾起了大家對大學演算法課的記憶了!那麼我們來鞏固一下。使用分治思想+遞迴,我們就已經可以解決大部分二叉樹的問題了。 我們來看一道題目->
1.1 翻轉二叉樹
這道題是一個經典的題目,Mac上著名軟體HomeBrew的作者曾經在面試Google的時候被問到了,還沒做出來,因此最後被拒。。。。於是他在個人推特上抱怨到:
Google: 90% of our engineers use the software you wrote (Homebrew), but you can’t invert a binary tree on a whiteboard so fuck off.
最後大家的關注點就慢慢從作者被拒本身轉移到了題目上了。。。那我們看看這道題到底有多難。
翻轉前
翻轉後
看起來好像很麻煩的樣子,每個子樹本身都被翻轉一遍。但是我們使用分治的思維,假如說我們有個函式,專門翻轉二叉樹的。假如我們把B子樹翻轉好,再把C子樹翻轉好,那麼我們要做的豈不就是簡單的把A節點的左賦給C(原來是B),再把A節點的右賦給B(原來是C)。這個問題是不是就解決了?
對於B和C我們可以用同樣的分治思維去遞迴解決。用一段程式碼來描述一下
public TreeNode reverseBinaryTree(TreeNode root){
//先處理base case,當root ==null 時,什麼都不需要做,返回空指標
if(root == null){
return null;
}
else{
//把左子樹翻轉
TreeNode left = reverseBinaryTree(root.left);
//把右子樹翻轉
TreeNode right = reverseBinaryTree(root.right);
//把左右子樹分別賦值給root節點,但是是翻轉過來的順序
root.left = right;
root.right = left;
//返回根節點
return root;
}
}
複製程式碼
根據這個例子,再加上中序列印的題目,我們應該已經可以很輕鬆的理解到了,對於二叉樹的題目或者演算法,分而治之
和 遞迴
的核心思想了,就是把左右子樹分開處理,最後在把結果合併(把處理好的左右子樹對應根節點進行處理)。
那麼接下來我們來一個複雜一點點的題目
1.2 把二叉樹鋪平
這個題目我們需要把一個二叉樹變成一個類似於連結串列的結構,所有的子節點都移到右節點去,看圖為例。
轉變之後
從圖中我們可以看出來,把二叉樹鋪平的這個過程,是先把左子樹鋪平,連結到根節點的右節點上面,再把右子樹鋪平,連結到已經鋪平的左子樹的最後一個節點上。最後返回根節點。那麼我們從一個巨集觀的角度來說,需要做的就是先把左右子樹鋪平。
假設我們有一個方法叫flatten()
,它會把一個二叉樹鋪平最後返回根節點
public TreeNode flatten(TreeNode root){
}
複製程式碼
那麼從巨集觀的角度,我們對鋪平這個操作,已經做完了!!!接下來就是第二步,還是以一個動畫來闡述這個過程。
最終程式碼如下,附上註釋
public TreeNode flatten(TreeNode root){
//base case
if(root == null){
return null;
}
else{
//用遞迴的思想,把左右先鋪平
TreeNode left = flatten(root.left);
TreeNode right = flatten(root.right);
//把左指標和右指標先指向空。
root.left = null;
root.right = null;
//假如左子樹生成的連結串列為空,那麼忽略它,把右子樹生成的連結串列指向根節點的右指標
if(left == null){
root.right = right;
return root;
}
//如果左子樹生成連結串列不為空,那麼用while迴圈獲取最後一個節點,並且它的右指標要指向右子樹生成的連結串列的頭節點
root.right = left;
TreeNode lastLeft = left;
while(lastLeft != null && lastLeft.right != null){
lastLeft = lastLeft.right;
}
lastLeft.right = right;
return root;
}
}
複製程式碼
至此,我們已經做完了這道題了,希望大家最後能好好理解我們所謂的分而治之的思想和二叉樹中對左右子樹遞迴的處理。大部分的二叉樹演算法題也就是圍繞著這個思想為中心,只要從巨集觀上能把對左右子樹處理的邏輯想清楚,那麼就不難解決了。
1.3 安卓開發中遇到的樹形結構?
那麼對於安卓開發中,我們會不會遇到類似的問題呢?或者說安卓開發中會遇到樹形結構的演算法麼?
答案是肯定有!
我們都知道在安卓系統裡面,每個ViewGroup
裡面又會包含多個或者零個View
,每一個View
或者 ViewGroup
都有一個整型的Id,那麼每次我們在使用ViewGroup
的findViewById(int id)
的時候,我們是以什麼方式來查詢並返回在當前ViewGroup下面,我們要查詢的View呢?
這個也是我非常喜歡對來我司應聘的求職者的問題,不過很遺憾,目前為止能完完整整寫出來的就一個。。。。(再次可見東南亞開發者的水平,不忍吐槽)
那麼題目來了
請完成以下方法
//返回一個在vg下面的一個View,id為方法的第二個引數
public static View find(ViewGroup vg, int id){
}
複製程式碼
可以使用的方法有:
View -> getId()
返回一個int 的 idViewGroup -> getChildCount()
返回一個int的孩子數量ViewGroup -> getChildAt(int index)
返回一個孩子,返回值為View。
這個題目就可以說非常經典了,以往的樹形結構的題目,我們都是做一個二叉樹的處理,除了左就是右,但是這裡我們每個ViewGroup都可能有多個孩子,每個孩子既可能是ViewGroup,也可能只是View(ViewGroup是View的子類,這裡是一個知識點!)
我這裡就不做過多的解釋了,直接貼程式碼,而且安卓系統本身也是用這種方式進行View的查詢的。
//返回一個在vg下面的一個View,id為方法的第二個引數
public static View find(ViewGroup vg, int id){
if(vg == null) return null;
int size = vg.getChildCount();
//迴圈遍歷所有孩子
for(int i = 0 ; i< size ;i++){
View v = vg.getChildAt(i);
//如果當前孩子的id相同,那麼返回
if(v.getId == id) return v;
//如果當前孩子id不同,但是是一個ViewGroup,那麼我們遞迴往下找
if(v instance of ViewGroup){
//遞迴
View temp = find((ViewGroup)v,id);
//如果找到了,就返回temp,如果沒有找到,繼續當前的for迴圈
if(temp != null){
return temp;
}
}
}
//到最後還沒用找到,代表該ViewGroup vg 並不包含一個有該id的孩子,返回空
return null;
}
複製程式碼
2.二叉樹的層序處理(廣度優先)
說到廣度優先,大部分同學可能會想到圖,不過畢竟樹結構本身就是一種特殊的圖。所以一般說樹,尤其是二叉樹的廣度優先我們指的一般是層序遍歷。
比如說樹
層序列印的結果就是A->B->C->D->D->E->F->G
對於層序遍歷的相關演算法,真理只有一個!
就是用佇列(Queue)
!
道理很簡單,每次遍歷當前節點的時候,把該節點從佇列拿出來,並且把它的子節點全部加入到佇列中。over~
上一個簡單的列印程式碼
public void printTree(TreeNode root){
if(root == null){
return;
}
Queue queue = new LinkedList();
queue.add(root);
while(!queue.isEmpty()){
TreeNode current = queue.poll();
System.out.println(current.toString());
if(current.left != null){
queue.add(current.left);
}
if(current.right != null){
queue.add(current.right);
}
}
}
複製程式碼
這段程式碼很簡單,利用佇列先進先出的性質,我們可以一層層的列印二叉樹的節點們。
所以對於二叉樹的層序遍歷來說,一般都會使用佇列,這都是套路。因此,二叉樹的層序遍歷相對來說比較簡單,大家下次見到二叉樹的層序遍歷相關的面試題,先大膽的和麵試官說出你打算使用佇列,肯定沒錯!
最後對於層序遍歷來說我們再來一個比較具有代表性的題目!
2.1 連結二叉樹的Next節點
這個題目要求大家在擁有一個二叉樹節點的左右節點指標之餘,還要幫它找到它的next指標指向的節點。
大概是這樣:
在上面這個圖中,紅色的箭頭代表next指標的指向
邏輯很簡單,每一個的節點的next指向同一層中的下一個節點,不過如果該節點是當前層的最後一個節點的話,不設定next,或者說next為空。
其實這個題目就是典型的層序遍歷,使用佇列就可以輕鬆解決,每次poll出來一個節點,判斷是不是當前層的最後一個,如果不是,把其next設定成queue中的下一個節點就ok了。至於怎麼判斷當前節點是哪一層呢?我們有個小技巧,使用當前queue的size做for迴圈,且看程式碼
public void nextSibiling(TreeNode node){
if(node == null){
return;
}
Queue queue = new LinkedList();
queue.add(node);
//這個level沒有實際用處,但是可以告訴大家怎麼判斷當前node是第幾層。
int level = 0;
while(!queue.isEmpty()){
int size = queue.size();
//用這個for迴圈,可以保證for迴圈裡面對queue不管加多少個子節點,我只處理當前層裡面的節點
for(int i = 0;i<size;i++){
//把當前第一個節點拿出來
TreeNode current = queue.poll();
//把子節點加到queue裡面
if(current.left != null){
queue.add(current.left);
}
if(current.right != null){
queue.add(current.right);
}
if(i != size -1){
//peek只是獲取當前佇列中第一個節點,但是並不把它從佇列中拿出來
current.next = queue.peek();
}
}
}
level++;
}
}
複製程式碼
二叉樹的知識點我就大概講這些,下次的文章我會接著詳細的講深度優先和廣度優先的演算法。深度優先是一個非常非常寬泛而且難以完全掌握的知識點,我會用詳細的篇幅來覆蓋所有的深度優先的基本題型,包括對樹,圖的深度優先搜尋,集合的回朔等等。
Part2 的連結: Android程式設計師面試會遇到的演算法(part 2 廣度優先搜尋)