我敢打賭,Google這道口試題你解不出來!

尋夢新聞LINE@每日推播熱門推薦文章,趣聞不漏接❤️

加入LINE好友

摘要:為每組相鄰的X和Y值調用getNodeAtLocation,並找到northId、eastId、southId和westId。const linkedContiguousIds = (。

全文共6793字,預計學習時長30分鐘或更長

在YouTube上,TechLead關於軟件工程的視頻非常受歡迎。其中一期視頻中,TechLead提到了他在Google100多次面試中提出的一個問題。

他提問題主要目的是想了解:應聘者是否會在編程之前提出合適的問題?提出的解決方法是否符合項目指南?他甚至說,能否回答出正確答案並不重要,只是想要探明你如何思考,亦或你是否正確理解問題所在。

也許你會很好奇,如何解決這個問題?TechLead談到了幾個解決方案,一個是遞歸式(受堆棧大小的限制),一個是迭代式(受內存大小的限制)。本文將會對這兩個問題進行更多的研究!

我敢打賭,谷歌這道面試題你解不出來!

Techlead的問題

Techlead提出的問題是:在這張網格圖中,計算最大的同色相連方塊數量。

我敢打賭,谷歌這道面試題你解不出來!

我敢打賭,谷歌這道面試題你解不出來!

當聽到這個問題時,我邊看圖邊想,「天吶,我得用二維模型來解答了。」這聽起來簡直就是個近乎不可能在面試中能夠回答的問題。

隨著講解的深入,我意識到問題並未如此簡單。前面的解法事實上只是在運行已抓取的數據而不是解析圖像。我意識到,圖像這個詞,其實是用詞不當的。

我敢打賭,谷歌這道面試題你解不出來!

數據建模

在開始編程之前,需要定義好數據模型。這件事再怎麼強調也不為過。在編寫如此高級的代碼之前,首先要弄清楚自己正在處理什麼,同時收集好業務需求。

在本文所舉的例子里,TechLead給大家定義了許多要求:

· 彩色方塊的概念,或者稱之為「節點」。

· 此數據集中有10K節點。

· 節點被組織成行和列二維)。

· 行數和列數可能不均勻。

· 節點有顏色和表示鄰接的方法。

從數據中還能得到更多的信息:

· 任意兩個節點不會重疊。

· 節點永遠不會和自身相鄰。

· 一個節點永遠不會重復相鄰。

· 位於邊和角上的節點將分別丟失一個或兩個鄰接。

未知的是:

· 行列比值

· 可能的顏色數量

· 只有一種顏色的概率

· 色彩的粗略分布

作為開發者,級別越高,越清楚應該問哪些問題。當然這也不全是過往經驗所能賦予的。盡管有所幫助,但是如若不能找出這些未知量,解決問題時還是會遇到困難。

我們並不期望眾人能夠找出這些未知量。在腦海中計算這個算法之前,我對其也一無所知。要找到這些未知量需要一定的時間,需要通過反復與業務人員進行大量的討論來找到這當中的症結。

圖片里色塊的分布看起來似乎是隨機的。他除了說只用了3種顏色,並沒有說別的,所以我們也可以這麼做。同時,做個假設:有一種可能情況是所有的顏色都有相同數量。

由於它可能會破壞算法,因此,假設正在處理一個100*100的網格圖。這樣就不需要處理奇數行和10K列的情況。

一般來說, 我會在數據探索的最初幾個小時內問這些問題,而這才是TechLead 真正關心之處。你會先編寫一個隨機解還是先找出問題呢?

在數據建模的時候你應該會犯錯誤,我第一次寫文章的時候就犯了錯誤。但如果你能夠提前考慮到問題並有所防范,這些問題可能處理起來會更加容易。反正當時我最終不得不重寫一部分代碼。

創建數據模型

為了建立模型,我們需要知道數據是如何輸入以及希望如何處理。由於沒有適當的系統來處理數據,因此需要自己進行可視化處理。

數據的基本構成是這樣的:

· 顏色

· ID號碼

· X

· Y

為什麼需要ID? 因為處理時有可能不止一次遇到相同的方塊。想要防止無限循環,則需要標記在這些情況下該方塊所處的位置。

同時,像這樣的數據,通常會配有某種ID、散列或者其他值。它是一個獨一無二的標識符,因而我們有辦法能識別那個特定的節點。如果想知道最大的相連方塊,我們需要知道該方塊上有哪些節點。

由於TechLead用網格表示數據,我們假設會得到X, Y的值。僅使用這些屬性,可以生成一些HTML, 以確保所生成的內容與他給定的內容相似。

這是用絕對定位完成的,如他的例子那樣:

我敢打賭,谷歌這道面試題你解不出來!

答案:3

在更大的網格圖中,這個方法也可以使用:

我敢打賭,谷歌這道面試題你解不出來!

答案:18

這是他用於生成節點的代碼:

const generateNodes = ({ 
numberOfColumns,
numberOfRows,
}) => (
Array(
numberOfColumns
* numberOfRows
)
.fill()
.map((
item,
index,
) => ({
colorId: (
Math
.floor(
Math.random() * 3
)
),
id: index,
x: index % numberOfColumns,
y: Math.floor(index / numberOfColumns),
}))
)

取行和列,從項的數量中創建一個一維數組,然後根據這些數據生成節點。

在這里用的不是顏色,而是colorID。這樣做的原因有兩個,一是隨機化更簡便;二是顏色值通常需要自己查找。

盡管他從來沒有明確說明他只使用了3個顏色值,這里也將數據集限制為3種顏色。只要知道它可以有數百種顏色,最終的算法就不需要改變。

舉個更簡單的例子,這里有一個2×2節點列表:

[ 
{ colorId: 2, id: 0, x: 0, y: 0 },
{ colorId: 1, id: 1, x: 1, y: 0 },
{ colorId: 0, id: 2, x: 0, y: 1 },
{ colorId: 1, id: 3, x: 1, y: 1 },
]

我敢打賭,谷歌這道面試題你解不出來!

數據處理

無論使用什麼方法,我們都希望得到這里每個節點的相鄰值。X和Y的值並不能滿足要求。

因此,給定一個X和Y,相對應需要找出X和Y的相鄰值。當然,這很簡單,只需要在X和Y上找到+ 1和- 1的節點就可以了。

為此,我寫了一個輔助函數:

Const getNodeAtLocation = ({ 
nodes,
x: requiredX,
y: requiredY,
}) => (
(
nodes
.find(({
x,
y,
}) => (
x === requiredX
&& y === requiredY
))
|| {}
)
.id
)

相反,假設節點會隨機進入系統。

生成節點的方式,其實確有一種可以推算出相鄰節點ID的數學方法。但我不這麼做,相反我假設節點會隨機進入系統。

在第二輪遍歷,所有節點時加入相鄰節點:

Const getNodeAtLocation
const addAdjacencies = (
nodes,
) => (
nodes
.map(({
colorId,
id,
x,
y,
}) => ({
color: colors[colorId],
eastId: (
getNodeAtLocation({
nodes,
x: x + 1,
y,
})
),
id,
northId: (
getNodeAtLocation({
nodes,
x,
y: y - 1,
})
),
southId: (
getNodeAtLocation({
nodes,
x,
y: y + 1,
})
),
westId: (
getNodeAtLocation({
nodes,
x: x - 1,
y,
})
),
}))
.map(({
color,
id,
eastId,
northId,
southId,
westId,
}) => ({
adjacentIds: (
[
eastId,
northId,
southId,
westId,
]
.filter((
adjacentId,
) => (
adjacentId !== undefined
))
),
color,
id,
}))
)

避免在這個預處理器代碼中進行任何不必要的優化。它不會影響最終的性能統計,只會幫助簡化算法。

接著,繼續把colorID變成一種顏色。盡管對於這里的算法來說這完全沒有必要,但是我想讓它更好地可視化。

為每組相鄰的X和Y值調用getNodeAtLocation,並找到northId、eastId、southId和westId。這里不再傳遞X和Y值,因為它們不再是必需的。

在獲得基本ID之後,將它們轉換為一個鄰接數組,該數組只包含那些具有數值的鄰接數組。這樣,只要有角和邊,就不用擔心所檢查id是否為空。它還允許循環一個數組,而不必在算法中手工記錄每個基本ID。

下面是另一個2×2示例,它使用一組新的節點遍歷addAdjacencies:

[ 
{ adjacentIds: [ 1, 2 ], color: 'red', id: 0 },
{ adjacentIds: [ 3, 0 ], color: 'grey', id: 1 },
{ adjacentIds: [ 3, 0 ], color: 'blue', id: 2 },
{ adjacentIds: [ 1, 2 ], color: 'blue', id: 3 },
]

處理優化

因為想大力簡化本文的算法,所以我在另一個優化過程中添加了該算法。該操作會刪除與當前節點顏色不匹配的相鄰id。

重寫了addAdjacencies函數後,現在能得到:

const addAdjacencies = ( 
nodes,
) => (
nodes
.map(({
colorId,
id,
x,
y,
}) => ({
adjacentIds: (
nodes
.filter(({
x: adjacentX,
y: adjacentY,
}) => (
adjacentX === x + 1
&& adjacentY === y
|| (
adjacentX === x - 1
&& adjacentY === y
)
|| (
adjacentX === x
&& adjacentY === y + 1
)
|| (
adjacentX === x
&& adjacentY === y - 1
)
))
.filter(({
colorId: adjacentColorId,
}) => (
adjacentColorId
=== colorId
))
.map(({
id,
}) => (
id
))
),
color: colors[colorId],
id,
}))
.filter(({
adjacentIds,
}) => (
adjacentIds
.length > 0
))
)

在增加更多功能的,同時縮減了addAdjacencies。

通過刪除顏色不匹配的節點,算法可以100%確定adjacentids實體中的任何id都是相鄰節點。

最後,刪除所有沒有相同顏色鄰接的節點。這進一步簡化了算法,將節點總數縮減剩所需的節點。

我敢打賭,谷歌這道面試題你解不出來!

錯誤的方法—遞歸法

TechLead說不能遞歸地做這個算法,因為會碰到堆棧溢出。

雖然在一定程度上是正確的,但有幾種方法可以緩解這個問題。要麼用迭代法要麼使用尾部遞歸。下面將看到迭代的例子,但是JavaScript不再將尾部遞歸作為一種本地語言特性。

雖然仍可以在JavaScript中模擬尾部遞歸,但這里將保持這種簡單性,並創建一個典型的遞歸函數。

編寫代碼之前要弄清楚算法。對於遞歸,使用深度優先搜尋也行得通。「即便不知道計算機科學術語也沒關係。」 當我向一位同事展示想出來的不同解決方案時,他如是說。

算法

先從一個節點開始,然後一直延續下去直到達到終點。然後再回來並採取下一個分支路徑,直到掃描到整個連續塊為止。

這是其中一部分。此外也必須追蹤所處位置和最大連續塊的長度。

這里做的是把函數分成兩段。其中一個將保存最大列表,以及給每個節點進行至少一次循環時掃描過的ID。另一個則從一個未掃描的根節點開始,並執行深度優先遍歷。

函數如下所示:

const getContiguousIds = ({ 
contiguousIds = [],
node,
nodes,
}) => (
node
.adjacentIds
.reduce(
(
contiguousIds,
adjacentId,
) => (
contiguousIds
.includes(adjacentId)
? contiguousIds
: (
getContiguousIds({
contiguousIds,
node: (
nodes
.find(({
id,
}) => (
id
=== adjacentId
))
),
nodes,
})
)
),
(
contiguousIds
.concat(
node
.id
)
),
)
)

const getLargestContiguousNodes = ( 
nodes,
) => (
nodes
.reduce(
(
prevState,
node,
) => {
if (
prevState
.scannedIds
.includes(node.id)
) {
return prevState
}
const contiguousIds = (
getContiguousIds({
node,
nodes,
})
)
const {
largestContiguousIds,
scannedIds,
} = prevState
return {
largestContiguousIds: (
contiguousIds.length
> largestContiguousIds.length
? contiguousIds
: largestContiguousIds
),
scannedIds: (
scannedIds
.concat(contiguousIds)
),
}
},
{
largestContiguousIds: [],
scannedIds: [],
},
)
.largestContiguousIds
)

是不是很誇張?本來不想把編碼放上去,因為看起來實在是太亂了。

下面將一步一步將其簡化。

遞歸函數

getContiguousIds 是遞歸函數,每個節點都會用到一次。該函數每次返回結果時,都會得到連續節點更新後的列表。

該函數只有一個條件,那就是:節點是否在列表里了?如果不是,那就再用一遍getContiguousIds。當它返回時,就會得到連續節點更新後的列表。而這列表也將回到縮減器,用作下一個adjacentId的狀態。

你也許會困惑,為什麼要給contiguousIds加值。當我們concat當前節點到contiguousIds時都要給其加值。而每次遞歸下去,都要確保給adjacentIds進行循環時,將當前節點加到contiguousIds的列表中。

一直增加當前節點是為了確保遞歸不會無限進行。

循環

函數的下半段也會經過每個節點至少一次。

遞歸函數被縮減器包圍著,縮減器會檢查編碼是否有被掃描過。如果有的話,就會繼續循環直到遇到一個未被掃描的節點或者退出循環為止。

如果節點還未被掃描,就用getContiguousIds然後等到掃描完畢。這是同步進行的,但也會花上一點時間。

當它得到一個contiguousIds的列表後,將其對著largestContiguousIds列表進行比較。然後將較大的那個值保存下來。

同時,把contiguousIDs加到scannedIds的列表中,以標記進展。

把這些列出來後,看起來就簡單多了。

執行

就算使用了10K項,在三個隨機顏色下它依然沒有堆棧溢出。如果換成是同色,那就會形成堆棧溢出。那是因為該遞歸函數經過的是10K的遞歸。

順序迭代

由於內存大於函數調用棧,下一步應該在一個循環中完成整個操作。

這里將會把所有的節點列表記錄下,並在跳出循環前持續將其添加和鏈接在一起。

這個方式要求在完成循環之前,將所有可能的節點列表保存在內存中。而遞歸版本中,只有最大的列表被保存在內存中。

const getLargestContiguousNodes = ( 
nodes,
) => (
nodes
.reduce(
(
contiguousIdsList,
{
adjacentIds,
id,
},
) => {
const linkedContiguousIds = (
contiguousIdsList
.reduce(
(
linkedContiguousIds,
contiguousIds,
) => (
contiguousIds
.has(id)
? (
linkedContiguousIds
.add(contiguousIds)
)
: linkedContiguousIds
),
new Set(),
)
)
return (
linkedContiguousIds
.size > 0
? (
contiguousIdsList
.filter((
contiguousIds,
) => (
!(
linkedContiguousIds
.has(contiguousIds)
)
))
.concat(
Array
.from(linkedContiguousIds)
.reduce(
(
linkedContiguousIds,
contiguousIds,
) => (
new Set([
...linkedContiguousIds,
...contiguousIds,
])
),
new Set(adjacentIds),
)
)
)
: (
contiguousIdsList
.concat(
new Set([
...adjacentIds,
id,
])
)
)
)
},
[new Set()],
)
.reduce((
largestContiguousIds = [],
contiguousIds,
) => (
contiguousIds.size
> largestContiguousIds.size
? contiguousIds
: largestContiguousIds
))
)

要不嘗試另一個更瘋狂的方式?可以嘗試從上往下拆開。將每個節點循環一次。但是現在必須檢查id是否在節點列表的列表contiguousIdsList中。

如果它不在contiguousIds的任何列表中,就將它和它的adjacentIDs添加進去。這樣,在循環時,其他東西也就會和它連接上。

如果節點在列表中,那它也有可能存在於其它列表中。必須把它們全部連接起來,並從contiguousIdsList移除未連接的節點。

就這樣。

得到所有節點列表後,檢查看哪個最大,就完成了。

執行

與遞歸版本不同的是,當所有10K項都是同個顏色時,這個方式是會結束的。

除了這一點之外,這個方式比較慢,比原先預計的還慢。而這里忘了考慮到一點,就是在性能評估中考慮對列表中的列表進行循環這一因素,因為這顯然對性能有一定的影響。

隨機迭代

這里的想法是採用遞歸方法背後的概念,將其以迭代的方式進行應用。

我在花了一晚上大部分時間試圖想起來如何動態地改變循環中的索引後,才想到了while(true) 。

有了這個「武器」後,便展開「攻擊」。由於花了很多時間嘗試加快observable版本,最後決定了「抄小路」,以傳統的方式改變數據。

該算法的目的是命中每個節點各一次,並只保存最大的連續塊:

const getLargestContiguousNodes = ( 
nodes,
) => {
let contiguousIds = []
let largestContiguousIds = []
let queuedIds = []
let remainingNodesIndex = 0
let remainingNodes = (
nodes
.slice()
)
while (remainingNodesIndex < remainingNodes.length) {
const [node] = (
remainingNodes
.splice(
remainingNodesIndex,
1,
)
)
const {
adjacentIds,
id,
} = node
contiguousIds
.push(id)
if (
adjacentIds
.length > 0
) {
queuedIds
.push(...adjacentIds)
}
if (
queuedIds
.length > 0
) {
do {
const queuedId = (
queuedIds
.shift()
)
remainingNodesIndex = (
remainingNodes
.findIndex(({
id,
}) => (
id === queuedId
))
)
}
while (
queuedIds.length > 0
&& remainingNodesIndex === -1
)
}
if (
queuedIds.length === 0
&& remainingNodesIndex === -1
) {
if (
contiguousIds.length
> largestContiguousIds.length
) {
largestContiguousIds = contiguousIds
}
contiguousIds = []
remainingNodesIndex = 0
if (
remainingNodes
.length === 0
) {
break
}
}
}
return largestContiguousIds
}
module.exports = getLargestContiguousNodesIterative2

這里不在列表添加先前掃描過的ID,而是從remainingNodes數組剪接數值。

其實不建議這麼做,只是在做這些實例時我已經快失去耐心,所以想嘗試不一樣的東西。

詳解

在這里使用if塊將它分成三個部分。

先從中間部分開始。查看有沒有queuedIds。如果有的話,就進行一個循環使其透過已隊列項,看它們是否在remainingNodes里。

而第三部分取決於第二部分的結果。如果沒有任何queuedIds,而remainingNodesIndes為-1,那該節點列表就可以放一邊了,並開始一個新的根節點。新根節的索引始終為0,因為remainingNodes正在進行剪接中。

回到第一個循環,其實可以使用while(true) ,但是為以防萬一需要想一個辦法。這在排除錯誤時很有幫助,因為有時候無限循環很難被判斷出來。

之後,將節點剪接出來。將其加入contiguousIds的列表,然後將adjacentIds加入隊列中。

執行

結果是這個方法幾乎和遞歸版本一樣快。當所有節點是同一個顏色時,它是所有算法中最快的。

我敢打賭,谷歌這道面試題你解不出來!

針對數據的優化

按顏色分組

既然已知藍色會和藍色同組,那在序列迭代版本中就可以將類似顏色的節點分組在一起。

將其分成三個更小的數組,會減少對內存的占用和對列表進行循環的次數。但是,這依然解決不了所有顏色一樣的情況下的問題。所以這並無法修正上述的遞歸版本。

此外,這也代表可以通過多線程操作,減少三分之二所需的執行時間。

若按序列執行,只需要先運行三個數組中最大的那個。如果另外兩個合起來依然小過最大的那個,就不需要檢查它們。

最大可能大小

其實不需要在特定間隔檢查最大的列表,建議每次迭代都檢查一次。

如果最大組大於或等於可用節點的一半(5K或以上),那很明顯手中的列表就是最大的。

利用隨機迭代版本,可以找到目前為止最大的列表和剩餘的節點量。如果後者小於前者,那就代表已獲得最大列表。

使用遞歸

雖然遞歸有其局限性,但依然可被使用。所需做的就只是檢查剩餘節點的數量。若它們低於堆疊上限,就可轉換去更快的遞歸版本。這方法雖然有風險,但隨著循環的深入,執行時間也肯定會縮短。

使用 ‘for’ 循環

既然已知最大項數,從reduce函數切換到傳統的for循環會帶來一個微好處。

不知為什麼,Array.protoype方式與for循環比起來,速度慢得很。

使用尾遞歸

同樣的,這篇文章沒有討論到observable版本,因為尾遞歸需要另一篇文章進行解釋。

尾遞歸要討論的地方很多,雖然這個方式可以讓遞歸版本運行,但它始終不如想像中那樣比while循環速度快。

RxJS:可維護性和性能

要重寫這些函數有幾種方法,可使它們更容易理解和維護。首先想到的主要解決方案就是使用Redux-Observable式的RxJS,但不使用Redux。

這其實是寫這篇文章時面對的難題。本來的想法是以常規方式編碼,然後使用RxJS來傳輸數據,看看性能可以提高到什麼水平。

這里在RxJS製作了3個版本,並且加快了執行時間。與之前關於變換器的文章不同的是,即使增加了行和列,這三個版本的速度都變得更慢了。

那個星期的每一晚幾乎都在構思解決這個問題的方法,甚至仔細查閱每一條代碼。每一次,以為有了更好的想法,可總是受到JavaScript速度的限制。

當然其實可以進行很多優化方式,但代價是代碼的可讀性。這不是我想要的。

最後終於得到了一個Observable的解決方案,而且目前是最快的,所需時間是其它方案的一半。這是整體上最大的改進。

唯有在每個節點是同個顏色時,才能用observable克服占內存的序列迭代。沒有別的時候了。嚴格來說,這也勝過遞歸版本,因為在那個情況下會堆棧溢出。

我敢打賭,谷歌這道面試題你解不出來!

最終數據

總的來說,最大的連續快平均在30-80個節點間。

以下是本篇文章所獲得的數據:

隨機顏色

我敢打賭,谷歌這道面試題你解不出來!

同色

我敢打賭,谷歌這道面試題你解不出來!

無論運行了多少次測試,每個方式的相對位置都是不變的。

可以發現,Redux-Observable方式在所有節點同色時就無法運行了。雖然我已嘗試許多辦法來加快它的速度,可是最後無濟於事。

我敢打賭,谷歌這道面試題你解不出來!

遊戲開發

在我的職業生涯中,曾兩次遇過這種代碼。那個時候我正在使用Lua開發我的獨立遊戲Pulsen,但那時的代碼短很多。

有一次,我正在繪制一張世界地圖。它已經有預定義的節點列表,而我實時處理這個列表。這樣的話,通過點擊[LEFT], [RIGHT], [UP] 和 [DOWN] 鍵,就可以在世界地圖上移動,即使角度略有偏差。

我還編寫了一個節點生成器,負責處理含有X和Y值的未知項列表。聽起來是不是很熟悉?那時必須把螢幕上的網格居中,在HTML比在遊戲引擎中更容易做到這一點。盡管如此,要集中一堆絕對定位的div也並不容易。

在這兩個情況下,實時的執行時間並不重要,因為在加載遊戲時已經做了許多預處理。

這里想強調的是,你可能會在職業生涯碰上TechLead的這個問題。或許,但是在典型的JavaScript應用程序中,速度並不是主要因素。

根據TechLeads的其它視頻,可看出他在Google時使用的是Java。我猜測他面試的職位應該很看重執行速度。他們很可能有一堆worker任務負責處理大量的數據,所以才會需要這樣的一個解決方案。

但是呢,也有可能這只是一份關於HTML和CSS的工作,他也許只是在和應聘者開玩笑,誰知道呢!

正如最終數據顯示的那樣,看起來最糟糕的編碼其實是運行最快的,而且還達到了所有需求。要維護它,就祝你好運了!

根據之前的經驗,開發非RxJS版本花了更長的時間。這可能是因為更快的版本需要經過全面的思考。而Redux-Observable可以從小細節想起。

這是一個非常有趣但又讓人抓破頭的問題。起初,看起來真的很複雜,但是把它拆成幾個部分來看後,慢慢就有頭緒了~

我敢打賭,谷歌這道面試題你解不出來!

留言 點讚 關注

我們一起分享AI學習與發展的乾貨

歡迎關注全平台AI垂類自媒體 「讀芯術」

About 尋夢園
尋夢園是台灣最大的聊天室及交友社群網站。 致力於發展能夠讓會員們彼此互動、盡情分享自我的平台。 擁有數百間不同的聊天室 ,讓您隨時隨地都能找到志同道合的好友!