面試回顧與解析:在 O (logN) 時間複雜度下求二叉樹中序後繼

jeremy1127發表於2020-02-21

話說,幾個月前,那時我剛剛開始找工作,對獵頭推的一家跨境電商w**h很感興趣,大有志在必得的興致。

獵頭和我打過招呼,這家公司演算法必考,無奈只有一週時間複習的我,按照自己的猜測,在leetcode上重點的刷了下字串、動態規劃之類的題。

可惜壓題失敗,真正面試時,面試官考的是二叉樹中序後繼。唉,當場先是有些失落,接著就是一點小緊張。

回想起來,幸虧面試是透過牛客網做的遠端面試,要不這些情緒變化一定落在的面試官眼裡。

心神不寧的我,又偏偏遇上了一個較真的面試官,非讓我和他說清楚思路再開始寫……

當場我是沒有完全寫出來,但心中確是很不服氣,畢竟已經寫出大半。於是,面試完又努力了一下,把程式碼完整的寫了出來,發給HR。心想著如果轉給面試官看一看,也許還有一線生機,但最終還是跪了……

回顧完了面試過程,下面進入正題。

給一個二叉樹,以及一個節點(此節點在二叉樹中一定存在),求該節點的中序遍歷後繼,如果沒有返回null

樹節點的定義如下:

type TreeNode struct {
    Val int
    Parent *TreeNode
    Left *TreeNode
    Right *TreeNode
}

首先,樹的中序遍歷是遵循(左)-(根)-(右)的順序依次輸出樹的節點。

於是,要想知道當前節點的中序後繼是什麼,首先要知道當前節點在它的父節點的左側還是右側。

如果它是在其父節點的左側,那就簡單了,直接返回它的父節點就好。

如果它是在其父節點的右側,情況又分為以下兩種:

  1. 如果它有右側的子節點,那麼下一個節點就是以它右側子節點為根的子樹的最左側節點。
  2. 如果它沒有右側子節點,需要向上回溯它的父節點,然後用這個父節點為新的當前節點,在排除已經訪問過的節點的前提下,重複上述的判斷過程。

根據這個思路,我寫了下面的解法。

func FindNext(root, toFind *TreeNode) *TreeNode  {
    tmpCurr := toFind
    paths := []*TreeNode {tmpCurr}

    for tmpCurr.Parent != nil{
        paths = append(paths, tmpCurr.Parent)
        tmpCurr = tmpCurr.Parent
    }

    tmpCurr = paths[0]
    leftRights := []bool{}

    for i:=1; i<len(paths); i++{
        parent := paths[i]
        if parent.Left == tmpCurr{
            leftRights = append(leftRights, true)
        }else {
            leftRights = append(leftRights, false)
        }
        tmpCurr = parent
    }

    visitedMap := make(map[*TreeNode]struct{})
    tmpCurr = toFind

    for i:=0; i<len(leftRights); {
        onLeft := leftRights[i]
        if onLeft {
            return tmpCurr.Parent
        } else {
            _, visited := visitedMap[tmpCurr.Right]
            visitedMap[tmpCurr] = struct{ }{}
            if tmpCurr.Right != nil  && !visited {
                newRoot := tmpCurr.Right
                for newRoot.Left != nil{
                    newRoot = newRoot.Left
                }
                return newRoot
            } else {
                tmpCurr = tmpCurr.Parent
                i++
            }
        }
    }

    return  nil
}

當時的測試用例:

func main() {
    tn11 := &TreeNode{Val:11}
    tn12 := &TreeNode{Val:12}

    tn8:= &TreeNode{Val:8, Left: tn11, Right: tn12}
    tn11.Parent = tn8
    tn12.Parent = tn8

    tn5 := &TreeNode{Val:5, Right:tn8}
    tn8.Parent = tn5

    tn4 := &TreeNode{Val:4}

    tn2 := &TreeNode{Val:2, Left:tn4, Right:tn5}
    tn4.Parent = tn2
    tn5.Parent = tn2

    tn9 := &TreeNode{Val:9}

    tn6 := &TreeNode{Val:6, Right:tn9}
    tn9.Parent = tn6

    tn10 := &TreeNode{Val:10}

    tn7 := &TreeNode{Val:7, Left:tn10}
    tn10.Parent = tn7

    tn3 := &TreeNode{Val:3, Left:tn6, Right:tn7}
    tn6.Parent = tn3
    tn7.Parent = tn3

    tn1 := &TreeNode{Val:1, Left:tn2, Right:tn3}
    tn2.Parent = tn1
    tn3.Parent = tn1

/**樹的樣子如圖
                      1
                  /       \
                 2         3
                / \      /   \
               4   5     6    7
                    \    \   /
                      8   9  10
                     / \
                    11 12
                                                  **/

    res := FindNext(tn1, tn8)
    fmt.Println(res)

    res = FindNext(tn1, tn12)
    fmt.Println(res)

    res = FindNext(tn1, tn9)
    fmt.Println(res)
}

存在的問題

寫文章時,我才發現,這個解法有一個bug,是判斷根的節點的下一個節點時,永遠都返回nil,這是不對的。

這個坑也是自己挖的,上面的程式碼在一開始時就計處當前節點回溯到根節點的路徑,一方面這不是必須提前全部算好;另一方面,也忽略了當前節點是根節點的邊界狀態。

改進的解法

其實,改進也很簡單,一方面,使用雙指標,一個代表當前節點,另一個代表它的父節點,即可以不必提前算出當下節點到根節點的路徑; 另一方面,需要對根節點是當前的節點的情況做些特殊處理,讓程式“誤以為”當前節點在根節點的右側,這樣後面的邏輯就能對得上了。

改進後的程式碼如下:

func FindNext(root, toFind *TreeNode) *TreeNode  {
    tmpCurr := toFind
    tmpParent := toFind.Parent

    if toFind == root {
        tmpParent = root // cheat the parent nil check, and it will go to the right child branch
    }

    visitedMap := make(map[*TreeNode]struct{})

    for tmpParent != nil {
        if tmpParent.Left == tmpCurr {
            return tmpParent
        } else { 
            _, visited := visitedMap[tmpCurr.Right]
            visitedMap[tmpCurr] = struct{ }{}
            if tmpCurr.Right != nil  && !visited{
                tmpCurr = tmpCurr.Right
                for tmpCurr.Left != nil {
                    tmpCurr = tmpCurr.Left
                }
                return tmpCurr
            } else {
                tmpCurr = tmpParent
                tmpParent = tmpCurr.Parent
            }
        }
    }

    return  nil
}

關於面試是否要考純演算法題,一直眾說紛紜。

反對者說,工作中大都是寫業務程式碼,哪有需要用到演算法的地方,能有機會寫個遞迴就頂天了。

支持者說,演算法題可以考查候選人的邏輯能力和編碼的嚴謹性,邏輯能力強、編碼嚴謹的工程師做業務開發也一定不會差呀!

其實,說到底考演算法只是企業篩選候選人的一種方式。如果企業品牌在外,根本不差候選人,自然可以大浪淘沙,不求合適,但求最好,演算法不行,一律pass。否則的話,還是需要多方面考慮,合適最重要。

從面試者的角度來看,遇到不熟悉演算法題也不要慌,一般面試時考察的演算法不會太冷門,只要基礎紮實,冷靜分析,即使做不能完全做出來,也能寫出個大概來。至於能不能透過面試,那就不好說啦。

如果真不想在演算法題上吃虧,請出門左拐,leetcode刷題去吧。除了刷題,好像也沒啥好辦法,誰讓你想進大廠呢?

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章