面试常见的算法题(JavaScript版)

973 阅读16分钟

以下题目为本人在面试中遇到的题,以及在LeetCode刷题过程中,出现率较高的题目,如有错误或者更好的解题思路,欢迎各位大神帮忙提点。

成对的括号

判断括号成对出现

sdfj(nrg(lj()k)nk)sldjwef          合法
q(wdwf()hknkql(whdq)w)       合法
hk)nqeif)liq(h(flq)wj(              不合法

思路:括号存在嵌套的关系,也存在并列关系。可以遍历字符串的每一个字符,使用栈来处理:
(1)遇到左括号,把左括号压入栈中。
(2)遇到右括号,判断栈是否为空,为空说明没有左括号与之对应,则不合法。如果栈不为空,则移除栈顶的左括号---这对括号抵消了。
(3)当遍历结束后,如果栈是空的则合法,否则不合法。

const JudgeBracket = str => {
  if(str === '') return true;
  if(!str) return false;

  const arr = [];
  for(let i=0; i<str.length; i++) {
    if(str[i] === '(') {
      arr.push('(');
    }
    if(str[i] === ')') {
      if(arr.length <= 0) return false;
      arr.pop();
    }
  }
  if(arr.length === 0) return true;
  return false;
};

const str1 = 'sdfj(nrg(lj()k)nk)sldjwef';
const str2 = 'q(wdwf()hknkql(whdq)w)';
const str3 = 'hk)nqeif)liq(h(flq)wj( ';

console.log(JudgeBracket(str1)); // true
console.log(JudgeBracket(str2)); // true
console.log(JudgeBracket(str3)); // false

冒泡排序

(1)比较相邻的两个元素,如果前一个比后一个大,则交换位置。
(2)第一轮的时候,最后一个元素应该是最大的一个。
(3)按照步骤(1)的方法进行相邻两个元素比较,由于最后一个元素已经是最大的了,所以最后一个元素不用比较。

// 冒泡排序
let arr = [1, 6, 3, 7, 5, 9, 2, 8];
function sort(arr) {
  // 升序
  console.time("冒泡排序耗时")

  let num = null
  for(let i=0; i<arr.length-1; i++) {
    // 外层循环的作用是:每次循环找出一个最大数放在这个数组的最后面
    for(let j=0; j<arr.length-i-1; j++) {
      // 内层循环的作用是:比较相邻两个数的大小从而进行交换位置
      // 借助一个中间容器来交换位置
      if(arr[j] > arr[j+1]){
        num = arr[j]
        arr[j] = arr[j+1]
        arr[j+1] = num
      }
    }
  }
  console.log(arr);
  console.timeEnd("冒泡排序耗时")
}
sort(arr)

快速排序

解析:快排是对冒泡排序的一种改进,第一趟排序时将数据分成两个部分,一部分比另一部分的所有数据都要小。然后递归调用,在两边都实现快速排序。
(1)从中间取一个数(称之为中位数),然后声明两个空数组
(2)遍历原始数组,小于中位数的放在左边,大于中位数的放右边
(3)递归调用第一步和第二步
快排比冒泡时间复杂度更小,平常用的更多

let arr = [1, 6, 3, 7, 2, 2, 2, 5, 9, 2, 8];

function sort(arr) {
  // 递归出口
  if(arr.length <= 1) return arr;

  let middleIndex = Math.floor(arr.length / 2); // 中位数的下标
  let middle = arr.splice(middleIndex, 1)[0]; // 取出中位数
  let left = [];
  let right = [];
  for(let i=0; i<arr.length; i++) {
    if(arr[i] < middle) {
      left.push(arr[i]);
    }else{
      right.push(arr[i]);
    }
  }
  return sort(left).concat([middle], sort(right));
}

console.log(sort(arr)); // [1, 2, 2, 2, 2, 3, 5, 6, 7, 8, 9]

零钱兑换

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例:
输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1
输入: coins = [2], amount = 3
输出: -1
  • 用dp[i]来表示找i块钱所需要的最少硬币数
  • 外层遍历coins数组,内层遍历所有的amount
  • 这种自底向上,每次都用更优解替代之前解法的思想就是动态规划
  • 算法的基本思想就是遍历所有的可能,然后给出最优解
var coinChange = function(coins, amount) {
    if(amount===0) return 0
    let dp = new Array(amount+1).fill(Infinity)
    dp[0] = 0 // 0块钱只需要找0枚硬币
    for(let coin of coins){
        for(let i=1; i<=amount; i++){
            if(i-coin >=0){ // 进入条件:当前需要找的钱数大于等于当前循环中的硬币额度时
                // i:当前要找零的钱数
                // dp[i]:之前钱数为i时的找零方案(这里为使用的硬币数)
                // dp[i-coin]+1:当前钱数减去用掉当前循环的一个硬币数,所以最后需要+1
                dp[i] = Math.min(dp[i], dp[i-coin]+1)
            }
        }
    }
    return dp[amount]===Infinity ? -1 : dp[amount]
};

如果想要知道具体的找零方案是什么,只需要加一个cache缓存就可以了

var coinChange = function(coins, amount) {
  if(amount===0) return 0
  let dp = new Array(amount+1).fill(Infinity)
  let cache = { // 这里缓存找零方案
    "0": []
  }
  dp[0] = 0
  for(let coin of coins){
      for(let j=1; j<=amount; j++){
          if(j-coin >=0){
              if(dp[j]>dp[j-coin]+1){
                  dp[j] = dp[j-coin]+1
                  cache[j] = [...cache[j-coin], coin]
              }
          }
      }
  }
  // return dp[amount]===Infinity ? -1 : dp[amount]
  return cache[amount] ? cache[amount] : null
};

console.log(coinChange([1,3,4],7))  // [3,4]

动态规划就是要动态的调整最优解,与之对应的就是贪心算法
比如我们要找钱,目前零钱数为[1,3,4]三种硬币,如果我们要找6块钱
动态规划的方式:

  • 先给1块钱看看
  • 再给1块钱看看
  • 再给1块钱,发现可以用1个3取代
  • 最后结果:[3,3]

贪心算法的方式:(只考虑局部最优解)

  • 上来先给4块
  • 再给1块
  • 再给1块
  • 最后结果:[4,1,1]

接下来使用递归动态规划的方式来解决找零这个问题

class Change{
  constructor(changeType){
    this.changeType = changeType
    this.cache = {}
  }
  makeChange(amount){
    if(!amount) return []
    let min = []
    // 开始找钱
    if(this.cache[amount]) return this.cache[amount]

    for(let i=0; i<this.changeType.length; i++){
      // 先找一块钱试试,看看剩多少钱
      const leftAmount = amount - this.changeType[i]
      let newMin
      if(leftAmount>=0){ // 说明没找完,需要再找一次
        newMin = this.makeChange(leftAmount) // 这句是动态规划的体现
      }
      if(leftAmount>=0 && (newMin.length<min.length-1 || !min.length)){
        // 说明新的找零的结果长度,小于旧的找零结果长度
        // 获得一个硬币更少的找零方案
        min = [this.changeType[i]].concat(newMin)
      }
    }
    return this.cache[amount] = min
  }
}

const change = new Change([1,3,4])
console.log(change.makeChange(2)) // [ 1, 1 ]
console.log(change.makeChange(6)) // [ 3, 3 ]
console.log(change.makeChange(7)) // [ 3, 4 ]

贪心算法
贪心算法是一种求近似解的思想。当能满足大部分最优解时,就认为符合逻辑要求。
比如刚刚的找零问题,零钱数为[1,3,4],找6块钱,动态规划给出的结果是[3,3],而贪心算法给出的结果是[4,1,1]。贪心算法会从硬币的最大值开始填充。

class Change{
  constructor(changeType){
    this.changeType = changeType.sort((a,b)=>b-a) // 硬币降序排列
  }
  makeChange(amount){
    let arr = []
    for(let i=0; i<this.changeType.length; i++){
      while(amount - this.changeType[i] >= 0){
        arr.push(this.changeType[i])
        amount = amount - this.changeType[i]
      }
    }
    return arr
  }
}

const change = new Change([1,3,4])
console.log(change.makeChange(2)) // [ 1, 1 ]
console.log(change.makeChange(6)) // [ 4, 1, 1 ]
console.log(change.makeChange(7)) // [ 4, 3 ]

贪心算法相对简单,就是先怼最大的,大部分情况都没问题,但是有些情况不是最优解。

三数之和

给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。
注意:答案中不可以包含重复的三元组。

  • 首先对数组进行排序,排序后固定一个数nums[i](从数组第一项开始)
  • 使用左右两个指针,左指针为nums[L](从 i+1 开始),右指针为nums[R](从 nums.length-1 开始)
  • 计算三个数的和是否为0,是0就添加进结果集,进行下一次循环
  • 如果nums[i]===nums[i-1],则说明该数字重复,会导致结果重复,所以应该跳过
  • 当sum等于0时,如果 nums[L]===nums[L+1],则会导致结果重复,L++
  • 当sum等于0时,如果 nums[R]===nums[R-1],则会导致结果重复,R--
  • 这里有两层循环,所以时间复杂度为 O(n^2)
/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var threeSum = function(nums) {
    let res = [];
    const len = nums.length;
    if(nums == null || len < 3) return res;
    nums.sort((a, b) => a - b); // 排序
    for (let i = 0; i < len ; i++) {
        if(nums[i] > 0) break; // 如果当前数字大于0,则三数之和一定大于0,所以结束循环
        if(i > 0 && nums[i] == nums[i-1]) continue; // 去重
        let L = i+1;
        let R = len-1;
        while(L < R){ // L和R分别为左指针和右指针
            const sum = nums[i] + nums[L] + nums[R];
            if(sum == 0){
                res.push([nums[i],nums[L],nums[R]]);
                while (L<R && nums[L] == nums[L+1]) L++; // 去重
                while (L<R && nums[R] == nums[R-1]) R--; // 去重
                L++;
                R--;
            }else if (sum < 0){
                L++;
            }else if (sum > 0){
                R--;
            }
        }
    }        
    return res;
};

斐波那契数

通常用 F(n) 表示,形成的序列称为斐波那契数列。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0,   F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.

给定 N,计算 F(N)。

/**
 * @param {number} N
 * @return {number}
 */
var fib = function(N) {
    if(N === 0 || N === 1){
        return N
    }
    let cache = [] // 自底向上进行规划,这里记录缓存
    for(let i=0; i<=N; i++){
        if( i == 0 || i == 1 ){
            cache[i] = i
        }else{
            cache[i] = cache[i-1] + cache[i-2]
        }
    }
    return cache[N]
};

当然斐波那契数列简单实现的方式就是递归,但使用上述动态规划的思想,比递归性能会高很多。


有效的括号

给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。

有效字符串需满足:
    1、左括号必须用相同类型的右括号闭合。
    2、左括号必须以正确的顺序闭合。
/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function(s) {
    let left = ['(', '[', '{']
    let right = [')', ']', '}']
    let stack = []
    let arr = s.split('')
    for(let i=0; i<arr.length; i++){
        let temp = arr[i]
        if(left.indexOf(temp) !== -1){
            stack.push(temp)
        }else{
            if(left.indexOf(stack.pop()) === right.indexOf(temp)){
                continue
            }else{
                return false
            }
        }
    }
    if(stack.length === 0){
        return true
    }else{
        return false
    }
};

Pow(x, n)

即计算 x 的 n 次幂

输入: 2.00000, 10
输出: 1024.00000
输入: 2.00000, -2
输出: 0.25000
解释: 2^-2 = 1/2 -> 1/4 = 0.25
/**
 * @param {number} x
 * @param {number} n
 * @return {number}
 */
var myPow = function(x, n) {
    if(n === 0) return 1
    if(n<0) return 1 / myPow(x, -n)
    // 二分+递归的思想,n每次向右移动一位,如果该位上有值(二进制),则结果 * x
    // 每次让 x = x*x
    if(n%2 === 1){
        return myPow(x*x, Math.floor(n/2)) * x
    }else{
        return myPow(x*x, Math.floor(n/2))
    }
};

N皇后

n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。


上图为8皇后问题的一种解法。
给定一个整数n,返回所有不同的n皇后问题的解决方案。
每一种解法包含一个明确地n皇后问题的棋子放置方案,该方案中 ‘Q’和‘.’分别代表了皇后和空位。

输入: 4
输出: [
 [".Q..",  // 解法 1
  "...Q",
  "Q...",
  "..Q."],

 ["..Q.",  // 解法 2
  "Q...",
  "...Q",
  ".Q.."]
]
解释: 4 皇后问题存在两个不同的解法。

思路如下:
1、观察皇后攻击的索引特点,除了行、列不能相同之外,发现右侧的斜线 行-列 得到的数值相同,左侧的斜线 行+列 数值相同

  • 行不能一样(这里按行查找)
  • 列不能一样
  • 行-列不能一样
  • 行+列不能一样

2、设置一个temp=[],来记录之前的棋子摆放位置。temp的索引是行数据,值是列数据,例如:[2,4,1]代表第一行棋子摆放在2这个位置,第二行棋子摆放在4这个位置,第三行棋子摆放在1这个位置。

/**
 * @param {number} n
 * @return {string[][]}
 */
var solveNQueens = function(n) {
    let ret = []
    // 查找第1行
    find(0)
    return ret
    function find(row, tmp=[]){
        if(row===n){
            // 找完了 n-1就已经是最后一行了 tmp就是所有的摆放位置
            ret.push(tmp.map(c=>{
                let arr = new Array(n).fill('.')
                arr[c] = 'Q'
                return arr.join('')
            }))
        }

        for(let col=0; col<n; col++){
            // 是不是不能放
            let cantSet = tmp.some((ci,ri)=>{
                // ci和ri是之前摆放棋子的行列索引
                // col和row是当前所在位置的索引
                return ci===col || 
                       (ri-ci)===(row-col) ||
                       (ri+ci)===(row+col)
            })
            if(cantSet) continue
            // 如果能放,直接下一行
            find(row+1, [...tmp, col])
        }
    }
};

青蛙跳台阶问题

一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。

输入:n = 7
输出:21
/**
 * @param {number} n
 * @return {number}
 */
var numWays = function(n) {
    let cache = {}
    if(n<2) return 1
    for(let i=0; i<=n; i++){
        if(i<2){
            cache[i] = 1
        }else{
            cache[i] = (cache[i-1] + cache[i-2])
        }
    }
    return cache[n]
};

  一共n阶台阶,倒数第一步时,无论前面怎么走,都只有两种走法:走一步或走两步。两种走法的总数相加就是n阶台阶情况下的所有方式了,即:
f(n) = f(n-1) + f(n-2)
此时可以很明显的发现就是斐波那契数列。

将数组分成相等的三个部分

  给你一个整数数组 A,只有可以将其划分为三个和相等的非空部分时才返回 true,否则返回 false。
  形式上,如果可以找出索引 i+1 < j 且满足 (A[0] + A[1] + ... + A[i] == A[i+1] + A[i+2] + ... + A[j-1] == A[j] + A[j-1] + ... + A[A.length - 1]) 就可以将数组三等分。

输出:[0,2,1,-6,6,-7,9,1,2,0,1]
输出:true
解释:0 + 2 + 1 = -6 + 6 - 7 + 9 + 1 = 2 + 0 + 1

思路:

  • 想要将数组等分成3段的话,那么数组的累加和就一定能被3整除
  • 计算出平均值后,遍历数组。每当值达到平均值的时候,对count进行加1操作
  • 当count等于2的时候,说明已经分好了2等分了,这时候判断当前循环的i是否与A.length-1相等,如果相等的话,说明只能分成2等分。否则不管后面有多少个数字,它们的累加和一定等于平均值。
/**
 * @param {number[]} A
 * @return {boolean}
 */
var canThreePartsEqualSum = function(A) {
    let sum = A.mySum();             // 数组的累加和
    if(sum % 3 !== 0) return false;  // 不能被3整除,则return false
    let avgrage = parseInt(sum/3);   // 每个部分应该累加的和
    let temp = 0;  // 记录每部分的累加
    let count = 0; // 记录分成部分的个数
    for(let i=0;i<A.length;i++) {
        temp += A[i];
        if(temp === avgrage) { // 累加和达到平均值,说明可分为一个部分
            temp = 0; // 重置为0,准备下一部分的累加
            count++; 
            if(count === 2) { 
                // count为2时,说明已经分好了两个部分
                if(i === A.length-1) {
                    return false;
                }
                return true;
            }
        }  
    }
    return false;
};

Array.prototype.mySum = function() {
    return this.reduce((a,b)=>a+b, 0);
}


礼物最大价值

  在一个 m*n 的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于 0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格、直到到达棋盘的右下角。给定一个棋盘及其上面的礼物的价值,请计算你最多能拿到多少价值的礼物?

输入: 
[
  [1,3,1],
  [1,5,1],
  [4,2,1]
]
输出: 12
解释: 路径 1→3→5→2→1 可以拿到最多价值的礼物

思路:
  棋盘问题寻求路径的最大值,如果使用动态规划的思想,可以新建立一个缓存棋盘cache,值为到达每个位置时的最大路径(这里是礼物价值),这个缓存棋盘的行列比原棋盘都多1,可以确保边界问题。
  由于这里只能向下或向右前进,每个到达位置的最大路径为:Math.max(上边位置的值,左边位置的值) 加上棋盘当前位置的值

/**
 * @param {number[][]} grid
 * @return {number}
 */
var maxValue = function(grid) {
    const row = grid.length
    const col = grid[0].length
    let cache = []
    // cache缓存的行列都比grid多一条,就不需要考虑边界问题了
    for(let i=0; i<row+1; i++){
        let arr = new Array(col+1).fill(0)
        cache[i] = arr
    }
    for(let i=0; i<row; i++){
        for(let j=0; j<col; j++){
            // cache当前位置的值
            // 是由上方的值和左方的值比较大小后,加上grid当前的值
            cache[i+1][j+1] = Math.max(cache[i][j+1], cache[i+1][j])
                              + grid[i][j]
        }
    }
    return cache[row][col]
};

字符串的最大公因子

  对于字符串 S 和 T,只有在 S = T + ... + T(T 与自身连接 1 次或多次)时,我们才认定 “T 能除尽 S”。
  返回最长字符串 X,要求满足 X 能除尽 str1 且 X 能除尽 str2。

输入:str1 = "ABCABC", str2 = "ABC"
输出:"ABC"
输入:str1 = "ABABAB", str2 = "ABAB"
输出:"AB"
输入:str1 = "LEET", str2 = "CODE"
输出:""

  看到标题中有最大公因子这个词,可以考虑一下辗转相除

const gcd = (a, b) => (0 === b ? a : gcd(b, a % b))
  • 如果它们有公因子 abc,那么str1就是m个abc的重复,str2就是n个abc的重复,连起来就是m+n个abc。m+n个abc与n+m个abc是一样的。
  • 所以如果 str1 + str2 === str2 + str1,就意味着有解
  • 当确定有解的情况下,最优解是长度为 gcd(str1.length, str2.length) 的字符串
var gcdOfStrings = function(str1, str2) {
  if (str1 + str2 !== str2 + str1) return ''
  const gcd = (a, b) => (0 === b ? a : gcd(b, a % b))
  return str1.substring(0, gcd(str1.length, str2.length))
};

根据字符出现频率排序

给定一个字符串,请将字符串里的字符按照出现的频率降序排列。

输入: "tree"
输出: "eert"
解释:
'e'出现两次,'r''t'都只出现一次。
因此'e'必须出现在'r''t'之前。此外,"eetr"也是一个有效的答案。
输入: "cccaaa"
输出: "cccaaa"
解释:
'c''a'都出现三次。此外,"aaaccc"也是有效的答案。
注意"cacaca"是不正确的,因为相同的字母必须放在一起。
输入: "Aabb"
输出: "bbAa"
解释:
此外,"bbaA"也是一个有效的答案,但"Aabb"是不正确的。
注意'A''a'被认为是两种不同的字符。

思路:

  • 先遍历一次字符串,计算字符串中各个字符出现的次数
  • 对数据进行降序排序
  • 最后进行字符串的拼接
/**
 * @param {string} s
 * @return {string}
 */
var frequencySort = function(s) {
    let map = new Map()
    let temp = []
    let str = ''
    // 循环字符串,并塞进map(去重+统计次数)
    for(let i=0; i<s.length; i++){
        let char = s[i]
        if(map.has(char)){
            map.set(char, map.get(char)+1)
        }else{
            map.set(char, 1)
        }
    }
    map.forEach((value, key)=>{
        temp.push({char:key, numer:value})
    })
    // 降序排序
    temp.sort((a,b)=>{
        return b.numer - a.numer
    })
    // 字符串拼接
    temp.forEach(item=>{
        str = str + item.char.repeat(item.numer)
    })
    return str
};

M个人相互传球,由甲开始,经过N次传球后,球仍回到甲手中,则不同的传球方式共有多少种?

(m-2)*(n-1)
思路:球在传递过程中是不能传递给自己的
可以想象它是一颗树
假设:甲乙丙三个人传球,传5次,最后球在甲手中的传球方式。

// 甲乙丙三个人传球,由甲先传,传5次,列出最后球落在甲手上的所有传球方式,这里用 0 1 2 分别代表甲乙丙  
function getBool(persons, total) {
  const res = [];
  find(0);
  return res;

  // bool:当前球在谁手上
  // temp:之前传球的记录
  function find(bool, temp = []) {
    if (temp.length === total) { // 传球达到了5次
      if (bool === 0) { // 当前球在甲手上
        res.push([bool, ...temp]); // 最后补上一开始球在谁手上
      }
      return;
    }
    for (let i = 0; i < persons.length; i++) {
      if (i === bool) continue; // 不能传球给自己
      find(i, [...temp, i]);
    }
  }
}
console.log(getBool([0, 1, 2], 5));