從一道前端面試題談起

發表於2019-06-01

今天在知乎上看到一個回答《為什麼前端工程師那麼難招?》,作者提到說有很多前端工程師甚至連單連結串列翻轉都寫不出來。說實話,來面試的孩子們本來就緊張,你要冷不丁問一句單連結串列翻轉怎麼寫,估計很多人都會蒙掉。

clipboard.png

於是我在leetcode 上找了一下這道題,看看我能不能寫得出來。

題目的要求很簡單:

反轉一個單連結串列。

示例:
輸入: 1->2->3->4->5->NULL
輸出: 5->4->3->2->1->NULL

最後的解決就是這樣的一行程式碼:

const reverseList = (head, q = null) => head !== null ? reverseList(head.next, { val: head.val, next: q }) : q;

答案並不重要,有意思的是整個的解題思路。

前端工程師需要了解演算法嗎?

在解題之前,我們先來聊聊演算法。嚴格來說,單連結串列翻轉這種問題只是對於連結串列這種資料結構的一種操控而已,根本談不上是什麼演算法。當然,寬泛地來說,只要涉及到迴圈和遞迴的都把它歸入到演算法也可以。在這裡,我們採用一種寬容的定義。

演算法需要背嗎?我覺得演算法是不需要背的,你也不可能背的下來,光leetcode就有上千道題目,並且還在增加,怎麼可能背的下來?所以對於現階段的程式設計師來說,演算法分為兩類,一類是你自己能推算出來的,這種不用背,一類是你推算不出來的,比如KMP演算法,這種也不用背,需要的時候直接Google就可以了。特別是對於前端以及80%的後端程式設計師來說,你需要什麼演算法,就直接使用現在的庫就行了,陣列排序直接array.sort就可以,誰沒事還非要去寫一個快速排序?

那為什麼面試前端的時候還必須要考演算法?這個道理基本上類似於通過考腦筋急轉彎來測試智商一樣,實際工作中是完全用不上的,就像高考的時候考一大堆物理、化學、生物,恨不得你上知天文,下知地理,上下五千年,精通多國語言,但其實你參加工作以後發現根本用不上一樣,這其實就是一個智商篩子,過濾一下而已。

所以,別管工作中用不用得到,如果你想通過這道篩子的話,演算法的東西多少還是應該學習一些的。

單連結串列的資料結構

說實話,我剛做這道題的時候,我也有點蒙。雖然上學的時候學過資料結構,連結串列、堆疊、二叉樹這些東西,但這麼多年實際工作中用的很少,幾乎都快忘光了,不過沒關係,我們就把它當成是腦筋急轉彎來做一下好了。

我們先來看一下它的資料結構是什麼樣的:

var reverseList = function(head) {
    console.log(head);
};
ListNode {  
  val: 1, next: ListNode {
    val: 2, next: ListNode {
      val: 3, next: [ListNode] } } }

一個物件裡包含了兩個屬性,一個屬性是val,一個屬性是next,這樣一層一層迴圈巢狀下去。

通常來講,在前端開發當中,我們最常用的是陣列。如果是用陣列的話,就太簡單了,js陣列自帶reverse方法,直接array.reverse反轉就行了。但是題目非要弄成連結串列的形式,說實在的,我真沒有見過前端什麼地方還需要用連結串列這種結構的(除了面試的時候),所以說這種題目對於實際工作是沒什麼用處的,但是腦筋急轉彎的智商題既然這樣出了,我們就來看看怎麼解決它吧。

迴圈迭代

首先想到的,這肯定是一個while迴圈,迴圈到最後,發現nextnull就結束,這個很容易想。但關鍵是怎麼倒序呢?這個地方需要稍微動一下腦子。我們觀察一下,倒序之後的結果,1變成了最後一個,也就是說1nextnull,而2next1。所以我們一上來先構建一個nextnull1結點,然後讀到2的時候,把2next指向1,這樣不就倒過了嗎?所以一開始的程式寫出來是這樣的:

var reverseList = function(head) {
  let p = head;
  let q = { val: p.val, next: null };
  while (p.next !== null) {
    p = p.next;
    q = { val: p.val, next: q };
  }
  return q;
};

先初始化了一個q,它的nextnull,所以它就是我們的尾結點,然後再一個一個指向它,這樣整個連結串列就倒序翻轉過來了。

第一個測試用例沒有問題,於是就提交了,但是提交完了發現不對,如果head本身是null的話,會報錯,所以修改了一下:

var reverseList = function(head) {
  let p = head;
  if (p === null) {
    return null;
  }
  let q = { val: p.val, next: null };
  while (p.next !== null) {
    p = p.next;
    q = { val: p.val, next: q };
  }
  return q;
};

這回就過了。

遞迴

解決是解決了,但是這麼長的程式碼,明顯不夠優雅,我們嘗試用遞迴的方法對它進一步優化。

如果有全域性變數的話,遞迴本身並不複雜。但因為leetcode裡不允許用全域性變數,所以我們只好構造一個雙引數的函式,把倒序之後的結果也作為一個引數傳進去,這樣剛一開始的時候q是一個null,隨著遞迴的層層深入,q逐漸包裹起來,直到最後一層:

const reverseList = function(head) {
    let q = null;
    return r(head, q);
}
const r = function(p, q) {
    if (p === null) {
        return q;
    } else {
        return r(p.next, { val: p.val, next: q });
    }
}

這裡我們終於理清了出題者的思路,用遞迴的方式我們可以把這個if判斷作為整個遞迴結束的必要條件。如果p不是null,那麼我們就再做一次,把p的下一個結點放進來,比如說1的下一個是2,那麼我們這時候就從2開始執行,直到最後走到55的下一個結點是null,然後我們退回上一層,這樣一層層鑽下去,最後再一層層返回來,就完成了整個翻轉的過程。

優化程式碼

遞迴成功之後,後面的事情就相對簡單了。

怎麼能把程式碼弄簡短一些呢?我們注意到這裡這個if語句裡面都是直接return,那我們乾脆直接做個三元操作符就好了:

const reverseList = function(head) {
    let q = null;
    return r(head, q);
}
const r = function(p, q) {
    return p === null ? q : r(p.next, { val: p.val, next: q });
}

更進一步,我們用箭頭函式來表示:

const reverseList = (head) => {
    let q = null;
    return r(head, q);
}
const r = (p, q) => {
    return p === null ? q : r(p.next, { val: p.val, next: q });
}

箭頭函式還有一個特色是如果你只有一條return語句的話,連外面的花括號和return關鍵字都可以省掉,於是就變成了這樣:

const reverseList = (head) => {
    let q = null;
    return r(head, q);
}
const r = (p, q) => (p === null ? q : r(p.next, { val: p.val, next: q }));

這樣是不是看著就短多了呢?但是還可以更進一步簡化,我們把上面的函式再精簡,這時候你仔細觀察的話,會發現第一個函式和第二個函式很類似,都是在呼叫第二個函式,那麼我們能不能精簡一下把它們合併呢?我們先把第一個函式變換為和第二函式的引數數目一致的形式:

const reverseList = (head, q) => r(head, q);
const r = (p, q) => (p === null ? q : r(p.next, { val: p.val, next: q }));

但這時候出現了一個問題,如果q沒有初始值的話,它是undefined,不是null,所以我們還需要給q一個初始值:

const reverseList = (head, q = null) => r(head, q);
const r = (p, q) => (p === null ? q : r(p.next, { val: p.val, next: q }));

這時候我們的兩個函式長的基本一致了,我們來把它們合併一下:

const reverseList = (head, q = null) => (head === null ? q : reverseList(head.next, { val: head.val, next: q }));

看,這樣你就得到了一個一行程式碼的遞迴函式可以解決單連結串列翻轉的問題。

實話說,即使是像我這樣有多年經驗的程式設計師,要解決這樣的一個問題,都需要這麼長的時間這麼多步驟才能優化完美,更何況說一個大學剛畢業的孩子,很難當場就一次性回答正確,能把思路說出來就很不容易了,但你可以從這個過程中看到程式程式碼是如何逐漸演進的。背誦演算法沒有意義,我覺得我們更多需要的是這一個思考的過程,畢竟程式設計是一個腦筋急轉彎的過程,不是唐詩三百首。

相關文章