JavaScript中的演算法(附10道面試常見演算法題解決方法和思路)

lvwxx發表於2019-04-02

Introduction

面試過程通常從最初的電話面試開始,然後是現場面試,檢查程式設計技能和文化契合度。幾乎毫無例外,最終的決定因素是還是編碼能力。通常上,不僅僅要求能得到正確的答案,更重要的是要有清晰的思維過程。寫程式碼中就像在生活中一樣,正確的答案並不總是清晰的,但是好的推理通常就足夠了。有效推理的能力預示著學習、適應和進化的潛力。好的工程師一直是在成長的,好的公司總是在創新的。

演算法挑戰是有用的,因為解決它們的方法不止一種。這為決策的制定和決策的計算提供了可能性。在解決演算法問題時,我們應該挑戰自己從多個角度來看待問題的定義,然後權衡各種方法的優缺點。通過足夠的嘗試後,我們甚至可能看到一個普遍的真理:不存在“完美”的解決方案。

要真正掌握演算法,就必須瞭解它們與資料結構的關係。資料結構和演算法就像陰陽、水杯和水一樣密不可分。沒有杯子,水就不能被容納。沒有資料結構,我們就沒有物件來應用邏輯。沒有水,杯子是空的,沒有營養。沒有演算法,物件就不能被轉換或“消費”。

要了解和分析JavaScript中的資料結構,請看JavaScript中的資料結構

Primer

JavaScript中,演算法只是一個函式,它將某個確定的資料結構輸入轉換為某個確定的資料結構輸出。函式內部的邏輯決定了怎麼轉換。首先,輸入和輸出應該清楚地提前定義。這需要我們充分理解手上的問題,因為對問題的全面分析可以很自然地提出解決方案,而不需要編寫任何程式碼。

一旦完全理解了問題,就可以開始對解決方案進行思考,需要那些變數? 有幾種迴圈? 有那些JavaScript內建方法可以提供幫助?需要考慮那些邊緣情況?複雜或者重複的邏輯會導致程式碼十分的難以閱讀和理解,可以考慮能否提出抽象成多個函式?一個演算法通常上需要可擴充套件的。隨著輸入size的增加,函式將如何執行? 是否應該有某種快取機制嗎? 通常上,需要犧牲記憶體優化(空間)來換取效能提升(時間)。

為了使問題更加具體,畫圖表!

當解決方案的具體結構開始出現時,虛擬碼就可以開始了。為了給面試官留下深刻印象,請提前尋找重構和重用程式碼的機會。有時,行為相似的函式可以組合成一個更通用的函式,該函式接受一個額外的引數。其他時候,函式柯里的效果更好。保證函式功能的純粹方便測試和維護也是非常重要的。換句話說,在做出解決問題的決策時需要考慮到架構和設計模式。

Big O(複雜度)

為了計算出演算法執行時的複雜性,我們需要將演算法的輸入大小外推到無窮大,從而近似得出演算法的複雜度。最優演算法有一個恆定的時間複雜度和空間複雜度。這意味著它不關心輸入的數量增長多少,其次是對數時間複雜度或空間複雜度,然後是線性、二次和指數。最糟糕的是階乘時間複雜度或空間複雜度。演算法複雜度可用以下符號表示:

  1. Constabt: O(1)
  2. Logarithmic: O(log n)
  3. Linear: O(n)
  4. Linearithmic: O(n log n)
  5. Quadratic: O(n^2)
  6. Expontential: O(2^n)
  7. Factorial: O(n!)

''

在設計演算法的結構和邏輯時,時間複雜度和空間複雜度的優化和權衡是一個重要的步驟。

Arrays

一個最優的演算法通常上會利用語言裡固有的標準物件實現。可以說,在電腦科學中最重要的是陣列。在JavaScript中,沒有其他物件比陣列擁有更多的實用方法。值得記住的陣列方法有:sort、reverse、slice和splice。陣列元素從第0個索引開始插入,所以最後一個元素的索引是 array.length-1。陣列在push元素有很好的效能,但是在陣列中間插入,刪除和查詢元素上效能卻不是很優,JavaScript中的陣列的大小是可以動態增長的。

陣列的各種操作複雜度

  • Push: O(1)
  • Insert: O(n)
  • Delet: O(n)
  • Searching: O(n)
  • Optimized Searching: O(log n)

MapSet是和陣列相似的資料結構。set中的元素都是不重複的,在map中,每個Item由鍵和值組成。當然,物件也可以用來儲存鍵值對,但是鍵必須是字串。

Iterations

與陣列密切相關的是使用迴圈遍歷它們。在JavaScript中,有5種最常用的遍歷方法,使用最多的是for迴圈,for迴圈可以用任何順序遍歷陣列的索引。如果無法確定迭代的次數,我們可以使用whiledo while迴圈,直到滿足某個條件。對於任何Object, 我們可以使用 for infor of迴圈遍歷它的keys 和values。為了同時獲取key和value我們可以使用 entries()。我們也可以在任何時候使用break語句終止迴圈,或者使用continue語句跳出本次迴圈進入下一次迴圈。

原生陣列提供瞭如下迭代方法:indexOf,lastIndexOf,includes,fill,join。 另外我們可以提供一個回撥函式在如下方法中:findIndex,find,filter,forEach,map,some,every,reduce

Recursions

在一篇開創性的論文中,Church-Turing論文證明了任何迭代函式都可以用遞迴函式來複制,反之亦然。有時候,遞迴方法更簡潔、更清晰、更優雅。以這個迭代階乘函式為例:

const factorial = number => {
  let product = 1
  for (let i = 2; i <= number; i++) {
    product *= i
  }
  return product
}
複製程式碼

如果使用遞迴,僅僅需要一行程式碼

const _factorial = number => {
  return number < 2 ? 1 : number * _factorial(number - 1)
}
複製程式碼

所有的遞迴函式都有相同的模式。它們由建立一個呼叫自身的遞迴部分和一個不呼叫自身的基本部分組成。任何時候一個函式呼叫它自身都會建立一個新的執行上下文並推入執行棧頂直。這種情況會一直持續到直到滿足了基本情況為止。然後執行棧會一個接一個的將棧頂元素推出。因此,對遞迴的濫用可能導致堆疊溢位的錯誤。

最後,我們一起來思考一些常見演算法題!

1. 字串反轉

一個函式接受一個字串作為引數,返回反轉後的字串

describe("String Reversal", () => {
 it("Should reverse string", () => {
  assert.equal(reverse("Hello World!"), "!dlroW olleH");
 })
})
複製程式碼
思考

這道題的關鍵點是我們可以使用陣列自帶的reverse方法。首先我們使用 split方法將字串轉為陣列,然後使用reverse反轉字串,最後使用join方法轉為字串。另外也可以使用陣列的reduce方法

給定一個字串,每個字元需要訪問一次。雖然這種情況發生了很多次,但是時間複雜度會正常化為線性。由於沒有單獨的內部資料結構,空間複雜度是恆定的。

const reverse = string => string.split('').reverse().join('')

const _reverse = string => string.split('').reduce((res,char) => char + res)
複製程式碼

2. 迴文

迴文是一個單詞或短語,它的讀法是前後一致的。寫一個函式來檢查。

describe("Palindrome", () => {
 it("Should return true", () => {
  assert.equal(isPalindrome("Cigar? Toss it in a can. It is so tragic"), true);
 })
 it("Should return false", () => {
  assert.equal(isPalindrome("sit ad est love"), false);
 })
})
複製程式碼
思考

函式只需要簡單地判斷輸入的單詞或短語反轉之後是否和原輸入相同,完全可以參考第一題的解決方案。我們可以使用陣列的 every 方法檢查第i個字元和第array.length-i個字元是否匹配。但是這個方法會使每個字元檢查2次,這是沒必要的。那麼,我們可以使用reduce方法。和第1題一樣,時間複雜度和空間複雜度是相同的。

const isPalindrome = string => {
  const validCharacters = "abcdefghijklmnopqrstuvwxyz".split("")
  const stringCharacters = string // 過濾掉特殊符號
      .toLowerCase()
      .split("")
      .reduce(
        (characters, character) =>
          validCharacters.indexOf(character) > -1
            ? characters.concat(character)
            : characters,
        []
      );
  return stringCharacters.join("") === stringCharacters.reverse().join("")
複製程式碼

3. 整數反轉

給定一個整數,反轉數字的順序。

describe("Integer Reversal", () => {
 it("Should reverse integer", () => {
  assert.equal(reverse(1234), 4321);
  assert.equal(reverse(-1200), -21);
 })
})
複製程式碼
思考

把number型別使用toString方法換成字串,然後就可以按照字串反轉的步驟來做。反轉完成之後,使用parseInt方法轉回number型別,然後使用Math.sign加入符號,只需一行程式碼便可完成。

由於我們重用了字串反轉的邏輯,因此該演算法在空間和時間上也具有相同的複雜度。

const revserInteger = integer => parseInt(number
      .toString()
      .split('')
      .reverse()
      .join('')) * Math.sign(integer)
複製程式碼

4. 出現次數最多的字元

給定一個字串,返回出現次數最多的字元

describe("Max Character", () => {
 it("Should return max character", () => {
  assert.equal(max("Hello World!"), "l");
 })
})
複製程式碼
思考

可以建立一個物件,然後遍歷字串,字串的每個字元作為物件的key,value是對應該字元出現的次數。然後我們可以遍歷這個物件,找出value最大的key。

雖然我們使用兩個單獨的迴圈來迭代兩個不同的輸入(字串和字元對映),但是時間複雜度仍然是線性的。它可能來自字串,但最終,字元對映的大小將達到一個極限,因為在任何語言中只有有限數量的字元。空間複雜度是恆定的。

const maxCharacter = (str) => {
    const obj = {}
    let max = 0
    let character = ''
    for (let index in str) {
      obj[str[index]] = obj[str[index]] + 1 || 1
    }
    for (let i in obj) {
      if (obj[i] > max) {
        max = obj[i]
        character = i
      }
    }
    return character
  }
複製程式碼

5.找出string中母音字母出現的個數

給定一個單詞或者短語,統計出母音字母出現的次數

describe("Vowels", () => {
 it("Should count vowels", () => {
  assert.equal(vowels("hello world"), 3);
 })
})
複製程式碼
思考

最簡單的解決辦法是利用正規表示式提取所有的母音,然後統計。如果不允許使用正規表示式,我們可以簡單的迭代每個字元並檢查是否屬於母音字母,首先應該把輸入的引數轉為小寫。

這兩種方法都具有線性的時間複雜度和恆定的空間複雜度,因為每個字元都需要檢查,臨時基元可以忽略不計。

  const vowels = str => {
    const choices = ['a', 'e', 'i', 'o', 'u']
    let count = 0
    for (let character in str) {
      if (choices.includes(str[character])) {
        count ++
      }
    }
    return count
  }

  const vowelsRegs = str => {
    const match = str.match(/[aeiou]/gi)
    return match ? match.length : 0
  }
複製程式碼

6.陣列分隔

給定陣列和大小,將陣列項拆分為具有給定大小的陣列列表。

describe("Array Chunking", () => {
 it("Should implement array chunking", () => {
  assert.deepEqual(chunk([1, 2, 3, 4], 2), [[1, 2], [3, 4]]);
  assert.deepEqual(chunk([1, 2, 3, 4], 3), [[1, 2, 3], [4]]);
  assert.deepEqual(chunk([1, 2, 3, 4], 5), [[1, 2, 3, 4]]);
 })
})
複製程式碼

一個好的解決方案是使用內建的slice方法。這樣就能生成更乾淨的程式碼。可通過while迴圈或for迴圈來實現,它們按給定大小的步驟遞增。

這些演算法都具有線性時間複雜度,因為每個陣列項都需要訪問一次。它們還具有線性空間複雜度,因為保留了一個內部的“塊”陣列,它與輸入陣列成比例地增長。

const chunk = (array, size) => {
  const chunks = []
  let index = 0
   while(index < array.length) {
     chunks.push(array.slice(index, index + size))
     index += size
   }
   return chunks
}
複製程式碼

7.words反轉

給定一個短語,按照順序反轉每一個單詞

describe("Reverse Words", () => {
 it("Should reverse words", () => {
  assert.equal(reverseWords("I love JavaScript!"), "I evol !tpircSavaJ");
 })
})
複製程式碼
思考

可以使用split方法建立單個單詞陣列。然後對於每一個單詞,可以複用之前反轉string的邏輯。

因為每一個字元都需要被訪問,而且所需的臨時變數與輸入的短語成比例增長,所以時間複雜度和空間複雜度是線性的。

const reverseWords = string => string
                                  .split(' ')
                                  .map(word => word
                                                .split('')
                                                .reverse()
                                                .join('')
                                      ).join(' ')

複製程式碼

8.首字母大寫

給定一個短語,每個首字母變為大寫。

describe("Capitalization", () => {
 it("Should capitalize phrase", () => {
  assert.equal(capitalize("hello world"), "Hello World");
 })
})
複製程式碼
思考

一種簡潔的方法是將輸入字串拆分為單詞陣列。然後,我們可以迴圈遍歷這個陣列並將第一個字元大寫,然後再將這些單詞重新連線在一起。出於不變的相同原因,我們需要在記憶體中儲存一個包含適當大寫字母的臨時陣列。

因為每一個字元都需要被訪問,而且所需的臨時變數與輸入的短語成比例增長,所以時間複雜度和空間複雜度是線性的。

const capitalize = str => {
  return str.split(' ').map(word => word[0].toUpperCase() + word.slice(1)).join(' ')
}
複製程式碼

9.凱撒密碼

給定一個短語,通過在字母表中上下移動一個給定的整數來替換每個字元。如果有必要,這種轉換應該回到字母表的開頭或結尾。

describe("Caesar Cipher", () => {
 it("Should shift to the right", () => {
  assert.equal(caesarCipher("I love JavaScript!", 100), "E hkra FwrwOynelp!")
 })
it("Should shift to the left", () => {
  assert.equal(caesarCipher("I love JavaScript!", -100), "M pszi NezeWgvmtx!");
 })
})
複製程式碼
思考

首先我們需要一個包含所有字母的陣列,這意味著我們需要把給定的字串轉為小寫,然後遍歷整個字串,給每個字元增加或減少給定的整數位置,最後判斷大小寫即可。

由於需要訪問輸入字串中的每個字元,並且需要從中建立一個新的字串,因此該演算法具有線性的時間和空間複雜度。

const caesarCipher = (str, number) => {
  const alphabet = "abcdefghijklmnopqrstuvwxyz".split("")
    const string = str.toLowerCase()
    const remainder = number % 26
    let outPut = ''
    for (let i = 0; i < string.length; i++) {
      const letter = string[i]
      if (!alphabet.includes(letter)) {
        outPut += letter
      } else {
        let index = alphabet.indexOf(letter) + remainder
        if (index > 25) {
          index -= 26
        }
        if (index < 0) {
          index += 26
        }
        outPut += str[i] === str[i].toUpperCase() ? alphabet[index].toUpperCase() : alphabet[index]
      }
    }
  return outPut
}
複製程式碼

10.找出從0開始到給定整數的所有質數

describe("Sieve of Eratosthenes", () => {
 it("Should return all prime numbers", () => {
  assert.deepEqual(primes(10), [2, 3, 5, 7])
 })
})
複製程式碼
思考

最簡單的方法是我們迴圈從0開始到給定整數的每個整數,並建立一個方法檢查它是否是質數。

const isPrime = n => {
  if (n > 1 && n <= 3) {
      return true
    } else {
      for(let i = 2;i <= Math.sqrt(n);i++){
        if (n % i == 0) {
          return false
        }
      }
      return true
  }
}

const prime = number => {
  const primes = []
  for (let i = 2; i < number; i++) {
    if (isPrime(i)) {
      primes.push(i)
    }
  }
  return primes
}
複製程式碼

自己動手實現一個高效的斐波那契佇列(歡迎評論區留下程式碼)

describe("Fibonacci", () => {
 it("Should implement fibonacci", () => {
  assert.equal(fibonacci(1), 1);
  assert.equal(fibonacci(2), 1);
  assert.equal(fibonacci(3), 2);
  assert.equal(fibonacci(6), 8);
  assert.equal(fibonacci(10), 55);
 })
})
複製程式碼

相關文章