Google經典面試題解析

AI科技大本營發表於2019-02-02

640?wx_fmt=jpeg

作者 | Alex Golec
譯者 | 彎月

責編 | 屠敏
出品 | CSDN(ID:CSDNnews)


在深入問題之前,有一個令人振奮的訊息:我離開了Google!我激動地宣佈,我已經加入了Reddit,並在紐約市擔任專案經理!

宣告:雖然面試候選人是我的職責,但這篇文章僅代表我個人的觀察、軼事和觀點。請不要把本文當成Google、Alphabet、Reddit,或其他個人或組織的官方宣告。

問題


想象一下,你在操作一個流行的搜尋引擎,而且你在日誌中看到了兩則查詢,比如說“奧巴馬的支援率”和“奧巴馬的流行度”。(如果我沒記錯的話,這些是資料庫面試中實際使用的例子,雖然這個問題的日期有點過時了……)這兩個查詢是不同的字串,但是我認為(相信你也會同意)從根本上說它們查詢的都是同一個東西,在計數查詢、顯示結果等方面可以將這兩個查詢視作等價。那麼,我們如何才能知道兩個查詢是同義詞呢?

讓我們用正式語言描述一下。假設你有兩個輸入。第一個是一個列表,其中每個元素都是成對的兩個字串,而且它們是同義詞。第二個也是一個列表,每個元素都是一組成對的字串,代表兩個查詢。

為了具體起見,我通過以下示例輸入來說明:

SYNONYMS = [
  ('rate''ratings'),
  ('approval''popularity'),
]

QUERIES = [
  ('obama approval rate''obama popularity ratings'),
  ('obama approval rates''obama popularity ratings'),
  ('obama approval rate''popularity ratings obama')
]

你的任務是輸出一組布林值,其中的每一個值說明相應的一對查詢是否是同義詞。

問題,問題


從表面上看,這是一個很簡單的問題。然而,你越細想,就會覺得它越複雜。首先很明顯,這個問題的定義並不完善。每個單詞可以有多個同義詞嗎?單詞的順序有關係嗎?同義詞關係是否具有傳遞性,也就是說如果A與B是同義詞,而B與C是同義詞,那麼是否意味著A與C也是同義詞?多個單片語成的詞語也是同義詞嗎?例如,“USA”與“United States of America”或“United States”是不是同義詞?

優秀的候選人會立刻將這種模糊性當作機會,讓自己脫穎而出。優秀的候選人會做的第一件事就是找出這類的歧義,並設法解決它們。如何做到這一點因人而異:有些人會到白板前設法手動解決具體的問題;有些人則看一眼立即就能看出其中的貓膩。不管怎樣,儘早發現這些問題至關重要。

我非常重視這個問題的“問題理解”階段。我喜歡將軟體工程稱為分形學科,這意味著它與分形學有相似之處,即放大問題就可以顯現出額外的複雜性。就在你以為你理解了某個問題的時候,走近一看才發現你忽略了一個微妙之處,或者可以改進的實施細節,或者找到一種新的看待這個問題的方法從而洞悉更多細節。

工程師的能力在很大程度上取決於他們對問題的理解程度。將一個模糊的問題陳述轉換成一組詳細的需求是這個過程的第一步,有目的地定義問題是我評估候選人處理新情況能力的依據。

順便說一句,還有一些不重要的問題,例如“是否需要考慮大小寫”,這些問題即使候選人不知道也不會影響核心演算法問題。對於這些問題,一般情況下我會給出最簡單的答案(就這個例子而言,我會說“假設所有內容都已預處理為小寫了”)。

第1部分:(並非)簡單的例子


每當候選人遇到這些問題時,他們總是會問我答案,而我總是會從最簡單的情況開始:單詞可以有多個同義詞,順序很重要,同義詞不可傳遞,而且同義詞只能從一個單詞對映到另一個。所以放到搜尋引擎中則功能非常有限,但它的細微之處會給面試帶來很多問題。

這個問題的解決方案可以高度概括為:將查詢分解成單詞(用空格分割就可以),並比較相應成對的單詞,看看它們是否完全相同或者是同義詞。如下圖所示:

640?wx_fmt=png

實現大致如下:

def synonym_queries(synonym_words, queries):
    '''
    synonym_words: iterable of pairs of strings representing synonymous words
    queries: iterable of pairs of strings representing queries to be tested for 
             synonymous-ness
    '''

    output = []
    for q1, q2 in queries:
        q1, q2 = q1.split(), q2.split()
        if len(q1) != len(q2):
            output.append(False)
            continue
        result = True
        for i in range(len(q1)):
            w1, w2 = q1[i], q2[i]
            if w1 == w2:
                continue
            elif words_are_synonyms(w1, w2):
                continue
            result = False
            break
        output.append(result)
    return output

請注意:這裡我故意沒有定義words_are_synonyms

很簡單,對不對?從演算法上講,這非常簡單。沒有動態規劃,沒有遞迴,沒有棘手的資料結構等等。只是非常簡單的標準庫操作,以及線性時間演算法,對吧?

你可能會這麼想,但是這裡面比你第一眼看到的更微妙。到目前為止,這個簡單的演算法中最棘手的部分是同義詞比較。雖然易於理解和描述,但同義詞比較這部分有可能會出很多錯。下面我來介紹一下我看到的一些常見的問題。

首先宣告,在我看來候選人不會因為這些錯誤而遭遇面試失敗;如果候選人做出來的實現有錯,那麼我會指出來,他們會調整他們的解決方案,然後面試繼續。但是,面試是一個分秒必爭的過程。犯錯,發現錯誤,改正錯誤,這些行為都是意料之中的,但是因此而浪費掉的時間本可以用來幹別的,比如找到更優的解決方案等。不犯錯的候選人很少,但是犯錯少的候選人就可以做得更好,因為他們花費在清理錯誤上的時間更少。

這就是我喜歡這道題目的原因:上一篇文章中的題目需要在靈光閃現之際找到一個演算法,然後再找到一個簡單的實現。這道題目與之不同,它需要在正確的方向上一步步前行。每一步都代表著一個很小的障礙,候選人可以優雅地跳過去,或者被絆倒再站起來。優秀的候選人會利用他們的經驗和直覺來避免這些小陷阱,並找到更加詳實和正確的解決方案,而實力比較弱的人會浪費時間和精力去處理錯誤,而且通常最後只會留下錯誤累累的程式碼。

每次面試我都會看到有人優雅地跳過去了,而有人卻摔得鼻青臉腫,但在此我只想舉幾個例子說明常見的幾個小錯誤。

意外的執行時殺手

首先,有些候選人會通過簡單地遍歷同義詞列表來實現同義詞的檢測:

...
elif (w1, w2) in synonym_words:
  continue
...

從表面上看,這似乎很合理。但仔細觀察,你就會發現這是一個非常非常糟糕的主意。我想跟那些不瞭解Python的人解釋一下:關鍵字in是contains方法的語法糖,適用於所有標準的Python容器。這裡的問題在於synonym_words是一個列表,它通過線性搜尋實現了關鍵字in。Python使用者特別容易受到這種錯誤的影響,因為這種語言會隱藏型別,但C ++和Java使用者偶爾也會犯同樣的錯誤。

在我的整個職業生涯中,編寫這類線性搜尋程式碼的次數屈指可數,而且每次涉及的列表都不會超過二十多個元素,即便如此,我還是會寫一大篇註釋告訴讀者為什麼我選擇了這種看似不太理想的方法。我懷疑有些候選人在這裡使用線性搜尋的原因是因為他們對Python標準庫的瞭解不夠,他們不知道如何在列表上實現關鍵字in。這是很容易犯的一個錯誤,雖然這並不致命,但是你對選擇的語言不夠熟練似乎也不太好看。

至於實際的建議嘛,其實很容易避免這種錯誤。首先,在你使用python這種無型別的語言時,永遠不要忘記物件的型別!其次,請記住,如果你對列表使用關鍵字in,那麼就會形成線性搜尋。除非你可以保證這個列表始終非常小,否則它就會成為效能殺手。

通常,提醒候選人這個輸入結構是一個列表就可以讓他們反應過來。在我給出提示後就有好戲看了。優秀的候選人會立即想到以某種方式預處理同義詞,這是一個不錯的開端。然而,這種方法也並非沒有陷阱......

使用正確的資料結構

從上面的程式碼可以看出,為了線上性時間內實現這個演算法,我們需要一個常數時間的同義詞查詢。而且在每次常數時間的查詢後面都應該有一個hashmap或hashset。

我感興趣的不是候選人會從這兩個中選擇哪一個,而是他們會在裡面存什麼。(順便說一句,永遠不要使用返回True或False的dict / hashmap。這叫做集合。)大多數的候選人都會選擇某種dict / hashmap。我最常見到的錯誤是一種潛意識的假設,即每個單詞最多隻有一個同義詞:

...
synonyms = {}
for w1, w2 in synonym_words:
  synonyms[w1] = w2
...
elif synonyms[w1] == w2:
  continue 

我並不會因為這個錯誤而懲罰候選人。這個示例的輸入是有意設計成讓人想不起單詞可以有多個同義詞,而有些候選人根本想不到這種邊界情況。在我指出這個錯誤後,大多數人都會快速改正。優秀的候選人會在早期注意到這個問題,從而避免這種麻煩,但通常這不會造成大量的時間流逝。

一個稍微嚴重的問題是,沒有意識到同義詞關係是雙向的。你可能注意到上述程式碼會這麼做。然而,改正這個問題可能會出錯。請考慮如下實現這個屬性的方法:

...
synonyms = defaultdict(set)
for w1, w2 in synonym_words:
  synonyms[w1].append(w2)
  synonyms[w2].append(w1)
...
elif w2 in synonyms.get(w1, tuple()):
  continue

如果你可以不消耗額外的記憶體只需執行兩次檢查,那麼為什麼要用兩個插入來消耗雙倍記憶體呢?

...
synonyms = defaultdict(set)
for w1, w2 in synonym_words:
  synonyms[w1].append(w2)
...
elif (w2 in synonyms.get(w1, tuple()) or
    w1 in synonyms.get(w2, tuple())):
  continue

提示:始終要問問你自己是否可以減少工作量!事後看來,對查詢進行排列明顯是一種節省時間的方法,但是使用非最優的實現則表明候選人沒有考慮尋找優化的方法。再次重申,我可以給出提示,但是無需我提示不是更好嗎?

排序?


有些很聰明的候選人認為可以對同義詞列表進行排序,然後使用折半查詢法來檢查兩個單詞是否是同義詞。實際上這種方法的主要優點在於,除了輸入的同義詞列表外,不佔用任何額外的空間(假定可以修改輸入列表)。

不幸的是,時間複雜度並不是很大:對同義詞列表進行排序需要花費的時間為Nlog(N),而查詢每對同義詞的時間為log(N),而上述預處理解決方案是線性的,接下來才是查詢的常數時間。另外,我並不想讓候選人在白板上實現排序和折半查詢法,因為(1)排序演算法眾所周知,所以我知道候選人可能只是做機械的重複;而且(2)想要寫正確這些演算法其實還是很有難度,通常即使最優秀的候選人偶爾也會犯錯,難道你能說他們的程式設計能力有問題嗎?

每當候選人提供這種解決方案時,我都會詢問執行時的複雜性,並問他們有沒有更好的方法。順便提一句:如果面試官問你有沒有更好的方法,那麼絕大多數情況下的答案都是“是”。如果我問過你這個問題,那麼答案肯定是“是”。

最後的解決方案


希望到這裡候選人已經得出了正確且最優的結果。以下是這道題目線性時間線性空間的實現:

def synonym_queries(synonym_words, queries):
    '''
    synonym_words: iterable of pairs of strings representing synonymous words
    queries: iterable of pairs of strings representing queries to be tested for 
             synonymous-ness
    '''

    synonyms = defaultdict(set)
    for w1, w2 in synonym_words:
        synonyms[w1].add(w2)

    output = []
    for q1, q2 in queries:
        q1, q2 = q1.split(), q2.split()
        if len(q1) != len(q2):
            output.append(False)
            continue
        result = True
        for i in range(len(q1)):
            w1, w2 = q1[i], q2[i]
            if w1 == w2:
                continue
            elif ((w1 in synonyms and w2 in synonyms[w1]) 
                    or (w2 in synonyms and w1 in synonyms[w2])):
                continue
            result = False
            break
        output.append(result)
    return output

以下是一些簡要說明:

  • 注意dict.get()的使用。你可以採用“先檢查key是否在dict中再獲取”的實現,但那樣你就失去了展示你對標準庫的瞭解的機會。

  • 我個人並不太喜歡用了很多continue的程式碼,而且有些程式設計風格指南禁止或不建議這麼使用。其實,我最初的程式碼中有一個bug——查詢長度檢查後面省略了continue。這個bug不是很糟糕,但是要知道它很容易出錯。


第2部分:加大難度!


在面試優秀的候選人時,我常常發現最後還剩下10-15分鐘的時間。幸運的是,我可以提出很多後續的問題,但是我們不太可能在那段時間內編寫很多程式碼。儘管在一天結束後,我覺得自己沒必要那麼做,但我想了解候選人兩方面的能力:他們能夠設計演算法嗎?還有他們能夠寫程式碼嗎?我上一篇文章中的問題首先回答了演算法設計的問題,然後還可以檢查程式碼,而本文中的這道題目得到答案的順序正好相反。

等到候選人完成了這道題目的第一部分後,他們就解決了(這個非常有難度的)程式設計問題。這時,我可以很自信地說他們具備設計基本演算法的能力,還可以將他們的想法轉化為程式碼,而且他們還很熟悉自己喜歡的語言和標準庫。接下來這個問題就變得更加有趣了,因為程式設計要求已經可以了,我們可以深入研究演算法部分了。

為此,讓我們回到第一部分的基本假設:單詞的順序很重要,同義詞關係沒有傳遞性,同義詞不能包含多個單詞。隨著面試的進行,我會改變這些約束,而且在這個程式設計後的階段裡,我和候選人可以只討論純粹的演算法。我會通過一些程式碼示例來說明我的觀點,但在實際面試中,我會使用純粹的演算法術語進行討論。

在深入說明之前,我想說從我的期望值來看後面的面試基本上都屬於“加分項”。我個人對這個問題的處理方法是,挑選第一部分考察完全“合格”的候選人,然後通過下一個環節的考察從“合格”的候選人挑選“強力”的候選人。“合格”的候選人已經很厲害了,代表了“我相信這個人可以很好地勝任工作”,而“強力”的候選人則表示“我相信這個人非常優秀,聘用他們可以為公司帶來巨大的利益。”

傳遞性:樸素的方法


我想討論的第一個問題是有關傳遞性,也就是說如果單詞A與B是同義詞,而單詞B與C是同義詞,那麼單詞A與C也是同義詞。反應靈敏的候選人很快會意識到他們可以調整之前的解決方案來解決這個問題,因為他們仍然覺得應該檢查簡單的一對單詞是否是同義詞,而有的人則認為之前的演算法的核心邏輯已經沒用了。

那麼究竟我們該怎麼做呢?一種常見的方法是根據傳遞關係為每個單詞維護一組完整的同義詞。每當我們向同義詞集合中插入一個單詞時,同時也把它插入到該集合當前所有單詞相應的集合中:

synonyms = defaultdict(set)
for w1, w2 in synonym_words:
    for w in synonyms[w1]:
        synonyms[w].add(w2)
    synonyms[w1].add(w2)
    for w in synonyms[w2]:
        synonyms[w].add(w1)
    synonyms[w2].add(w1)

請注意,通過以上程式碼我們已經深入到我允許候選人選用的這個解決方案中了。

這個解決方案很有效,但它遠非最佳解決方案。想知道為什麼嗎?讓我們來考慮一下這個解決方案的空間複雜性。每當新增一個同義詞,我們不僅要新增到起始單詞的集合,還要新增到該單詞的所有同義詞的集合。如果該單詞有一個同義詞,那麼就需要新增一條資料。如果該單詞有50個同義詞,那麼我就需要新增50條資料。如下圖所示:

640?wx_fmt=png

請注意,我們已經從3個鍵和6個資料項擴充套件到了4個鍵和12個資料項。如果一個單詞有50個同義詞,那麼就需要50個鍵和將近2500個資料項。表示一個單詞所需的空間與其同義詞集的大小呈二次方式增長,這是巨大的浪費。

還有其他解決方案,但考慮到篇幅有限,我就不在此贅述了。其中最有趣的一種方法是使用同義詞的資料結構來構造有向圖,然後使用廣度優先搜尋來查詢兩個單詞之間是否存在路徑。這是一個很好的解決方案,但查詢就變成了單詞同義詞集大小的線性。由於每個查詢我們都需要執行多次查詢,所以這個方法並不是最優解決方案。


傳遞性:使用不相交集


事實證明,我們可以通過使用名為“不相交集”的資料結構在常數時間內查詢同義詞關係。這種結構稱為集合,但它提供的功能與大多數人想象中的單詞“集合”有所不同。

常見的集合結構(hashset,treeset)是一個容器,允許你快速查詢集合中是否包含某個物件。不相交集(disjoint set)解決的是另一個不同的問題:它並不關注某個集合本身是否包含某個特定項,而是允許你檢查兩項是否屬於同一個集合。更重要的是,它完成這項操作所花費的時間非常短,只需O(a(n)),其中a(n)是Ackerman函式的相反數。除非你曾經上過高階演算法的課程,否則即便你不知道這個功能也無需自責,對於所有合理的輸入來說,這實際上可以在常數時間內完成。

該演算法的操作大體如下。樹代表集合,其中每一項都有父項。由於每個樹都有一個根(意味著有一專案的父項是它本身),那麼我們可以通過檢視父項來確定兩個專案是否屬於同一個集合,直到找到每個專案的根元素。如果兩個元素擁有同一個根元素,則它們必然屬於同一個集合。連線這些集合也很容易:我們只需找到根元素並將其中一個作為另一個元素的根。

到這裡為止,一切都很順利,但在效能方面依然沒有突破性的進展。這種結構的天才之處在於一種名為“壓縮”的過程。假設你有以下樹:

640?wx_fmt=png

假設你想知道“speedy”和“hasty”是否是同義詞。從每個節點開始遍歷父關係,直到你發現它們擁有同一個根節點“fast”,因此它們肯定是同義詞。現在假設你想知道“speedy”和“swift”是否是同義詞。你會在一次從每個節點開始遍歷,直到你找到“fast”,但是這一次你注意到你重複了“speedy”的遍歷。你可以避免這種重複工作嗎?

事實證明,你可以。在某種程度上,這棵樹中的每個元素都註定會找到“fast”節點。與其多次遍歷這棵樹,為什麼不簡單地將每個元素的父元素改為“fast”呢,如此一來不是就可以減輕工作量了嗎?這個過程被稱作“壓縮”,而在不相交的集合中,“壓縮”的構建就是查詢根的操作。例如,在我們確定“speedy”和“hasty”是同義詞之後,上面的樹就變成了下面這樣:

640?wx_fmt=png

“speedy”和“fast”之間的每個單詞的父節點都被更新了,“hasty”的父節點也被更新成了“fast”。

如此一來,所有後續訪問都可以在常數時間內完成了,因為這棵樹中的每個節點都指向了“fast”。分析這種結構的時間複雜度非常重要:它並非真正的常數,因為它取決於樹的深度,但是它並不比常數差,因為它很快就能攤銷成常量時間。對於我們的分析,我們偷懶稱其為常量時間。

有了這個概念後,我們來看看不相交集合的實現,它提供了我們解決這個問題所需的功能:

class DisjointSet(object):
    def __init__(self):
        self.parents = {}

    def get_root(self, w):
        words_traversed = []
        while self.parents[w] != w:
            words_traversed.append(w)
            w = self.parents[w]
        for word in words_traversed:
            self.parents[word] = w
        return w

    def add_synonyms(self, w1, w2):
        if w1 not in self.parents:
            self.parents[w1] = w1
        if w2 not in self.parents:
            self.parents[w2] = w2

        w1_root = self.get_root(w1)
        w2_root = self.get_root(w2)
        if w1_root < w2_root:
            w1_root, w2_root = w2_root, w1_root
        self.parents[w2_root] = w1_root

    def are_synonymous(self, w1, w2):
        return self.get_root(w1) == self.get_root(w2)


有了這種結構,我們就可以預處理同義詞,並線上性時間內解決這個問題了。

評估和說明


到這裡,我們就到達了在40-45分鐘的面試時間內能做的所有事情的極限了。我挑出了第一部分考查“合格”的候選人,並通過描述(不包括實現)不相交集合的解決方案挑出了“強力”的候選人,最後可以讓他們向我提問了。我從來沒遇到過一個候選人可以走到這一步,還有時間向我提問。

接下來還有一些後續工作要做:這道題目的一個版本是單詞的順序無關緊要,還有同義詞可以是多個單詞。每個問題的解決方案都富有挑戰且令人期待,但是受篇幅所限,我會在後續的文章中討論。

這個問題很實用,因為它允許候選人犯錯誤。日常的軟體工程工作包括永無止境的分析、執行和細化。這個問題為候選人提供了機會,可以讓他們展示每個階段他們的做法。如果你想通過這個問題成為“強力”的候選人,那麼需要如下的技術力:

  • 分析問題的描述,找出模糊與不明確的地方,澄清必要的地方建立明確的問題描述。繼續這種做法不斷尋找解決方案,並遇到新問題。為了最大化效率,在該階段儘可能早地展開這種做法,因為隨著工作的進展,改正錯誤的代價會越來越大。

  • 通過易於接近和解決問題的方式重新構建問題。在我們的這道題中,最重要的一點是觀察你可以在查詢中排列相應的單詞。

  • 實現你的解決方案。這涉及選擇最佳資料結構和演算法,以及設計出可讀且將來易於修改的邏輯。

  • 回過頭來設法找到bug和錯誤。程式碼中可能會有一些實際的bug,比如上述我忘記插入“continue”語句,或者由於使用不正確的資料結構等導致的效能問題。

  • 當問題定義發生變化時,請重複上述過程,並在適當的時候調整你的解決方案,如果不適用則需要棄用。無論是在面試中,還是在現實世界中,把握時機是一項關鍵的技能。

  • 多多學習資料結構和演算法知識。不相交集合的資料結構並不是一種普通的結構,但也不是非常罕見或完美無缺。確保自己瞭解工作中的工具的唯一方法就是儘可能地學習。

這些技術都不是能從課本上學來的(除了資料結構和演算法)。獲得這些技術的唯一途徑是持續和廣泛的實踐,這與公司的希望一致:候選人掌握了他們的技術力,並且有大量的實踐經驗可以有效地使用這些技術。尋找這樣的人才是面試的真正目的,而這道題目我使用了很長一段時間。


期待


通過本文的閱讀,可能你已經看出來這道題目也被洩露了。從那以後,我還用過幾個問題,根據我的心情和早期的候選人提出的問題挑一個(一直問一個問題很無聊)。其中有些問題仍在使用,所以我會保守祕密,但有些已經沒人用了!所以,你可以期待在今後的文章中看到這些題目。

原文:

https://medium.com/@alexgolec/google-interview-problems-synonymous-queries-36425145387c

作者:Alex Golec,工程經理@ Reddit NYC,前 Google 員工。

本文為 CSDN 翻譯,如需轉載,請註明來源出處。


推薦

640?wx_fmt=png


推薦閱讀

640?wx_fmt=png

點選“閱讀原文”,開啟CSDN APP 閱讀更貼心!

相關文章